此前为了学习Vue的源码,我决定本身动手写一遍简化版的Vue。如今我将我所了解到的分享出来。若是你正在使用Vue但还不了解它的原理,或者正打算阅读Vue的源码,但愿这些分享能对你了解Vue的运行原理有所帮助。html
今天咱们的目标是,对于如下的html模板:前端
<div class="outer">
<div class="inner" v-on-click="onClick($event, 1)">abc</div>
<div class="inner" v-class="{{innerClass}}" v-on-click="onClick">1{{name}}2</div>
</div>
复制代码
咱们但愿生成以下的js代码:vue
with(this) {
return _c(
'div',
{
staticClass: "outer"
},
[
_c(
'div',
{
staticClass: "inner",
on: {
"click": function($event) {
onClick($event, 1)
}
}
},
[_v("abc")]
),
_c(
'div',
{
staticClass: "inner",
class: {
active: isActive
},
on: {
"click": onClick
}
},
[_v("1" + _s(name) + "2")]
)
]
)
}
复制代码
(注:对于生成的代码,为了方便展现,这里手动的添加了换行与空格;对于模板,接下来将实现的代码还不能正确处理换行和空格,这里也是为了展现而添加了换行和空格。)git
咱们的工做将分为两步进行:github
AST Tree
(抽象语法树)。首先,咱们建立类ASTElement
,用来存放咱们的抽象语法树:ASTElement
实例拥有一个数组children
,用来存放这个节点的子节点,一棵树的入口是它的根节点;节点类型咱们简单地划分为两类,文本节点和普通节点(分别将经过document.createTextNode
和document.createElement
建立);文本节点拥有text
属性,而普通节点将包含标签tag
信息和attrs
列表,attrs
用来存放class
、style
、v-if
、@click
、:class
这类的各类信息:正则表达式
const ASTElementType = {
NORMAL: Symbol('ASTElementType:NORMAL'),
PLAINTEXT: Symbol('ASTElementType:PLAINTEXT')
};
class ASTElement {
constructor(tag, type, text) {
this.tag = tag;
this.type = type;
this.text = text;
this.attrs = [];
this.children = [];
}
addAttr(attr) {
this.attrs.push(attr);
}
addChild(child) {
this.children.push(child);
}
}
复制代码
解析模板字符串的过程,将从模板字符串头部开始,循环使用正则匹配,直至解析完整个字符串。让咱们用一张图来表示这个过程:数组
在左边的图中,咱们看到,示例模板被咱们分为多个部分,分别归为3类:开始标签、结束标签和文本。开始标签能够包含属性对。bash
而在右边的解析过程示意图中,咱们看到咱们的解析是一个循环:每次循环,首先判断下一个<
字符是否是就是接下来的第一个字符,若是是,则尝试匹配标签,匹配标签又分为两种状况,前后尝试匹配开始标签与结束标签;若是不是,则将当前位置直到下一个<
字符之间字符串都做为文本处理(为了简化代码这里忽略了文本中包含<
的状况)。如此循环直至模板所有被解析:函数
const parseHtml = function (html) {
const stack = [];
let root;
let currentElement;
...
const advance = function (length) {
index += length;
html = html.substring(length);
};
while (html) {
last = html;
const textEnd = html.indexOf('<');
if (textEnd === 0) {
const endTagMatch = html.match(endTag);
if (endTagMatch) {
...
continue;
}
const startTagMatch = parseStartTag();
if (startTagMatch) {
...
continue;
}
}
const text = html.substring(0, textEnd);
advance(textEnd);
if (text) chars(text);
}
return root;
};
复制代码
咱们申明了几个变量,它们分别表示:学习
ASTElement
的栈结构,例如对于<div class="a"><div class="b"></div><div class="c"></div></div>
,则会依次push(.a) -> push(.b) -> pop -> push(.c) -> pop -> pop
。经过这个栈结构的数据咱们能够检查模板中的标签是否正确地匹配了,不过在这里咱们会略去这种检查,认为全部的标签都正确匹配了。ASTElement
树的根节点,在赶上第一个开始标签并为其建立ASTElement
实例时会设置这个值。一个模板应当只有根节点,这也是能够经过stack
变量的状态来检查的。ASTElement
实例,同时也应当是stack
栈顶的元素。在循环体中,咱们使用了正则endTag
来来尝试匹配闭合标签,它的定义以下:
const endTag = /^<\/([\w\-]+)>/;
复制代码
用图来表示:
\w
匹配包括下划线的任何单词字符,相似但不等价于“[A-Za-z0-9_]”。这个正则能够匹配</item>
、</Item>
、</item-one>
等字符串。固然有不少符合规范的闭合标签的形式被排除在外了,不过出于理解Vue
原理的目的这个正则对咱们来讲就够了。
若是咱们比配到了闭合标签,那咱们须要跳过被匹配到的字符串(经过advance
)并继续循环,同时维护stack
和currentElement
变量:
const end = function () {
stack.pop();
currentElement = stack[stack.length - 1];
};
const parseEndTag = function (tagName) {
end();
};
...
const endTagMatch = html.match(endTag);
if (endTagMatch) {
const curIndex = index;
advance(endTagMatch[0].length);
parseEndTag(endTagMatch[1], curIndex, index);
continue;
}
复制代码
这时咱们能够进行一些容错性判断,好比标签对是否正确的匹配了等等,这些步骤咱们就先通通跳过了。
若是下一个字符不是<
,那直到此以前的字符串咱们将为其生成一个文本节点,并将其加入当前节点做为子节点:
const chars = function (text) {
currentElement.addChild(new ASTElement(null, ASTElementType.PLAINTEXT, text));
};
复制代码
对于开始标签,由于咱们会将0、1或多个属性对写在开始标签中,所以咱们须要分为3部分处理:开始标签的头部、尾部,以及可缺省的属性部分。因而,咱们须要建立一下3个正则表达式:
const startTagOpen = /^<([\w\-]+)/;
const startTagClose = /^\s*>/;
const attribute = /^\s*([\w\-]+)(?:(=)(?:"([^"]*)"+))?/; 复制代码
经过图(由regexper.com生成)来表示:
startTagOpen
和startTagClose
都比较简单,这里不赘述了(须要注意的一点是,我这里并无考虑存在自闭合标签的状况,例如<input />
)。对于属性对,咱们能够看到=
以及以后的部分是可缺省的,例如disabled="disabled"
和disabled
都是能够的。
所以整个匹配过程也分为3步:
ASTElement
的属性对中最后,将新建立的ASTElement
压入栈顶并标记为当前元素:
const start = function (match) {
if (!root) root = match;
if (currentElement) currentElement.addChild(match);
stack.push(match);
currentElement = match;
};
const parseStartTag = function () {
const start = html.match(startTagOpen);
if (start) {
const astElement = new ASTElement(start[1], ASTElementType.NORMAL);
advance(start[0].length);
let end;
let attr;
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length);
astElement.addAttr([attr[1], attr[3]]);
}
if (end) {
advance(end[0].length);
return astElement;
}
}
};
const handleStartTag = function (astElement) {
start(astElement);
};
const startTagMatch = parseStartTag();
if (startTagMatch) {
handleStartTag(startTagMatch);
continue;
}
复制代码
通过以上的步骤,咱们即可以解析模板字符串并获得一颗由ASTElement
组成的树。接下来,咱们就须要遍历这棵树,生成用于渲染这棵树的代码字符串。最终在获得代码字符串以后,咱们将其传入Function
构造函数来生成渲染函数。
首先要作的事,即是用with(this)
来包裹整段代码:
const generateRender = function (ast) {
const code = genElement(getRenderTree(ast));
return 'with(this){return ' + code + '}';
};
复制代码
这样当咱们正确的指定this
以后,在模板中咱们就能够书写{{ calc(a + b.c) }}
而非啰嗦的{{ this.calc(this.a + this.b.c) }}
了。
getRenderTree
将递归地遍历整棵树:
const getRenderTree = function ({ type, tag, text, attrs, children}) {
return {
type,
tag,
text: parseText(text),
attrs: parseAttrs(attrs),
children: children.map(x => getRenderTree(x))
};
};
复制代码
在此过程当中,咱们将对原先的ASTElement
树进行进一步的处理,由于原先的书保留的都是原始的数据,而这里咱们须要根据咱们的渲染过程对数据进行进一步的加工处理。
这里的加工处理分为两个部分:
接下来咱们就经过代码来看看咱们要进行哪些预处理。
首先对于文本节点,咱们须要从中找到包含方法/变量的部分,即被{{}}
所包含的部分。这里咱们来举几个例子,例如abc
须要被转换为代码'abc'
,{{ getStr(item) }}
须要被转换为代码getStr(item)
,abc{{ getStr(item) }}def
须要被转换为代码'abc' + getStr(item) + 'def'
。
也就是说,咱们须要不断的匹配文本中包含{{}}
的部分,保留其中的内容,同时将其他部分转换为字符串,并最终拼接在一块儿:
const tagRE = /\{\{(.+?)\}\}/g;
const parseText = function (text) {
if (!text) return;
if (!tagRE.test(text)) {
return JSON.stringify(text);
}
tagRE.lastIndex = 0;
const tokens = [];
let lastIndex = 0;
let match;
let index;
let tokenValue;
while ((match = tagRE.exec(text))) {
index = match.index;
if (index > lastIndex) {
tokenValue = text.slice(lastIndex, index);
tokens.push(JSON.stringify(tokenValue));
}
tokens.push(match[1].trim());
lastIndex = index + match[0].length;
}
if (lastIndex < text.length) {
tokenValue = text.slice(lastIndex)
tokens.push(JSON.stringify(tokenValue));
}
return tokens.join('+');
};
复制代码
对于属性部分(或者说,指令),首先来讲一下咱们将支持的(至关有限的)属性:
class="abc def"
,将被处理为'class': 'abc def'
这样的键值对。v-class="{{innerClass}}"
,将被处理为'v-class': innerClass
这样的键值对。这里咱们偷个懒,暂时不像Vue那样对动态的class
实现对象或数组形式的绑定。v-on-click="onClick"
,将被处理为'v-on-click': onClick
这样的键值对;而v-on-click="onClick($event, 1)"
,将被处理为'v-on-click': function($event){ onClick($event, 1) }
这样的键值对。因为以前实现属性匹配所使用的正则比较简单,暂时咱们并不能使用:class
或者@click
这样的形式来进行绑定。
对于v-class
的支持,和处理文本部分是类似的。
对于事件,须要判断是否须要用function($event){}
来包裹。若是字符串中仅包含字母等,例如onClick
这样的,咱们就认为它是方法名,不须要包裹;若是不只仅包含字母,例如onClick()
,flag = true
这样的,咱们则包裹一下:
const parseAttrs = function (attrs) {
const attrsStr = attrs.map((pair) => {
const [k, v] = pair;
if (k.indexOf('v-') === 0) {
if (k.indexOf('v-on') === 0) {
return `'${k}': ${parseHandler(v)}`;
} else {
return `'${k}': ${parseText(v)}`;
}
} else {
return `'${k}': ${parseText(v)}`;
}
}).join(',')
return `{${attrsStr}}`;
};
const parseHandler = function (handler) {
console.log(handler, /^\w+$/.test(handler));
if (/^\w+$/.test(handler)) return handler;
return `function($event){${handler}}`;
};
复制代码
在Vue中对于不一样的属性/绑定所须要进行的处理是至关复杂的,这里咱们为了简化代码用比较简单的方式实现了至关有限的几个属性的处理。感兴趣的童鞋能够阅读Vue源码或者本身动手试试实现自定义指令。
最后,咱们遍历被处理过的树,拼接出咱们的代码。这里咱们调用了_c
和_v
两个方法来渲染普通节点和文本节点,关于这两个方法的实现,咱们将在下一次实践中介绍:
const genElement = function (el) {
if (el.type === ASTElementType.NORMAL) {
if (el.children.length) {
const childrenStr = el.children.map(c => genElement(c)).join(',');
return `_c('${el.tag}', ${el.attrs}, [${childrenStr}])`;
}
return `_c('${el.tag}', ${el.attrs})`;
} else if (el.type === ASTElementType.PLAINTEXT) {
return `_v(${el.text})`;
}
};
复制代码
<?xml
、<!DOCTYPE
或是<xsl:stylesheet
这样的标签<
那该怎么处理table
下应当先包含tbody
,而不该当直接包含tr
;p
内部不能包含div
等等)@click
和:src
这种形式disabled
与disabled="disabled"
这样的缩写格式若是你想本身动手实践一下,这些都将是颇有趣的功能点。
这一次,咱们实践了怎样去解析模板字符串并由今生成一颗抽象语法树,同时由今生成了渲染代码。
在最后一次实践中,咱们将把咱们已经完成的内容结合起来,最终完成前端的渲染工做。
参考: