在Vue中,从模板到页面更新的流程大概是这样的:模板编译器将用户提供的一个模板字符串(或dom节点id)解析生成抽象语法树,再经由优化器优化,标记所有的静态节点后,交由代码生成器生成渲染代码,再通过渲染函数构建器将渲染代码构建成一个渲染函数,调用这个渲染函数,我们就可以得到目标模板的虚拟dom,经过patching算法的对比后,得到最少更改的虚拟dom,再根据这个虚拟dom实现页面的更新。
万丈高楼平地起,模板编译器便是上述整个渲染过程的第一块砖头,可见其重要性(当然,你也可以直接使用现成的渲染函数,这样就可以跳过模板编译过程了,但我们绝大多数情况都是直接使用模板进行开发的)。
那么,我们现在来看一下,模板编译器是怎么实现的吧。
注:本文只是介绍模板编译器的原理与基本实现,有一些跟模板编译器关联性不是很大的代码可能会被省略,如果想要详细的了解这些未说明部分的原理和逻辑,可以看本人github最新分支(PS:不要看master分支,那个是第一版的代码,只实现了数据响应化等基本逻辑。),截止至本文发布,github已经更新值dev-0.0.1分支。附上github传送门:kinerVue/dev-0.0.1
既然是解析模板字符串,也就是解析一段html的字符串,那么本质上其实就是对字符串的解析处理。在Vue中,对模板字符串的处理原理其实很简单:
就是我每匹配到一个满足要求的字符串
所谓的满足要求即:
如果满足以上任何一种情况,他便会触发对应的钩子函数,通知调用paserHTML的地方去收集这些信息,然后根据这些信息生成抽象语法树。
大概的示例代码是这样的:
let html = '这是一段文本';
function parseHTML(html, options) {
while (html) {
// 首先通过正则匹配判断标签是否是注释元素(注释包括普通注释和条件注释)
// 如果是的话,触发注释钩子函数通知收集信息,并将当前匹配的文本从html中截取掉
// options.comment(text /*注释的文本*/ , curIndex /*注释所在的开始位置*/ , endIndex /*注释所在的结束位置*/ );
// html = html.substring(endIndex);
// 其次通过正则判断是否是结束标签
// options.end(tag /*标签名*/ , curIndex /*注释所在的开始位置*/ , endIndex /*注释所在的结束位置*/ );
// html = html.substring(endIndex);
// 其次通过正则判断是否是开始标签
// options.start(tag /*标签名*/ ,[]/*属性列表*/, true/*是否是自闭标签*/, curIndex /*注释所在的开始位置*/ , endIndex /*注释所在的结束位置*/ );
// html = html.substring(endIndex);
// 最后判断是否是文本
// options.char(text /*文本*/ , curIndex /*文本所在的开始位置*/ , endIndex /*文本所在的结束位置*/ );
// html = html.substring(endIndex);
}
}
parseHTML(html, {
start(tag, attrs, isUnary, startIndex, endIndex) {
// 收集整理信息
},
end(tag, startIndex, endIndex) {
// 收集整理信息
},
chars(text, startIndex, endIndex) {
// 收集整理信息
},
comment(text, startIndex, endIndex) {
// 收集整理信息
},
});
从上面的代码可以看到,我们将模板字符串是否存在作为while的终止条件,我们每匹配到 一种情况,就会将相应的文本从html中删除掉,这样不断循环下去,就会不断的触发响应的钩子函数收集数据,直至html变成空字符串退出循环,此时,我们已经将整个文本都解析完了。
那么,我们再来看看我们再钩子函数里都要做什么操作呢?
当执行完整个逻辑,我们就会得到一个抽象语法树的根节点,通过这个根节点可以找到他下面的所有子节点及其相应的属性、文本等。
以下为parseHTML涉及到的主要逻辑代码,如果想要看全部代码,可以观看本人github:kinerVue/dev-0.0.1
// compiler/parse.js 定义了用于真正解析html模板和解析文本(包括静态文本和带参数的动态文本)的方法
import {
cached,
canBeLeftOpenTag,
decodingMap,
isNonPhrasingTag,
isPlainTextElement,
isUnaryTag, makeMap,
noop
} from "../shared/utils.js";
import {
attribute,
comment,
conditionalComment, defaultTagRE,
doctype,
dynamicArgAttribute, encodedAttr, encodedAttrWithNewLines,
endTag, regexEscapeRE,
startTagClose,
startTagOpen
} from "../shared/RE.js";
import SimpleStack from "../shared/SimpleStack.js";
import {parseFilter} from "./filter-paser.js";
// #5992 忽略pre和textarea标签的第一个换行符
const isIgnoreNewlineTag = makeMap('pre,textarea', true);
const shouldIgnoreFirstNewline = (tag, html) => tag && isIgnoreNewlineTag(tag) && html[0] === '\n';
/**
* 解析html模板,并通过不断触发钩子函数通知调用者手机相关信息创建抽象语法树
* @param html
* @param options
*/
export const parseHTML = (html, options) => {
let {
start: startHook = noop,
end: endHook = noop,
chars: charsHook = noop,
comment: commentHook = noop,
shouldKeepComment = true,// 是否需要保留注释
shouldDecodeNewlinesForHref = false,// 是否应该对a标签的href进行一次编码
shouldDecodeNewlines = false// 是否应该对属性值进行一次编码
} = options;
let lastTag, last;
let endChars;// 截止字符串
let index = 0;// 当前指针所在的位置
const stack = new SimpleStack();// 用于存储标签信息的栈,通过将标签信息存储再栈中方便标签的匹配处理和父级标签的寻找
while (html) {
last = html;
if (!lastTag || !isPlainTextElement(lastTag)) {
// 父元素为正常元素
let textEnd = html.indexOf('<');
if (textEnd === 0) {
// 首先判断标签是否是注释元素
if (comment.test(html)) {
// 找出第一个注释结束标签的索引
endChars = '-->';
const commentEnd = html.indexOf(endChars);
if (commentEnd >= 0) {
// 看一下配置是否需要保留注释,如果不需要保留注释,则不触发钩子函数,否则触发
if (shouldKeepComment) {
// 触发钩子函数
// 参数有三个:
// 1、注释文本
// 2、指针开始位置,即上一个节点的结束位置
// 3、指针结束位置,即注释节点的结束位置
// 截取注释文本
commentHook(html.substring(4, commentEnd), index, index + commentEnd + endChars.length);
// 指针向前,指向注释标签的后面一个节点
advance(commentEnd + endChars.length);
// 本次处理完毕,继续下一次的字符串切割处理
continue;
}
}
}
// 如果不是普通注释,再看看是不是条件注释
if (conditionalComment.test(html)) {
endChars = ']>';
// 找到条件注释的截止位置
const commentEnd = html.indexOf(endChars);
if (commentEnd >= 0) {
// 条件注释无需触发commentHook钩子函数,直接跳过即可
advance(commentEnd + endChars.length);
// 本次处理完毕,继续下一次的字符串切割处理
continue;
}
}
// 如果是文档类型标签,如:
const docTypeMatch = html.match(doctype);
if (docTypeMatch) {
// 如果是文档类型标签,也直接跳过
advance(docTypeMatch[0].length);
// 本次处理完毕,继续下一次的字符串切割处理
continue;
}
// 如果是结束标签,如
这是内联元素
这是内联元素
// compiler/compile-tpl-to-ast.js 本文件用于将一个html模板生成一个抽象语法树
import {parseHTML, parseText} from "./parse.js";
import SimpleStack from "../shared/SimpleStack.js";
import {cached, isForbiddenTag, isIE, isPreTag, isTextTag, warn} from "../shared/utils.js";
import {createASTComment, createASTElement, createASTExpression, createASTText} from "./Ast.js";
import {invalidAttributeRE, lineBreakRE, whitespaceRE} from "../shared/RE.js";
import {
addIfCondition,
preTransformNode, processElement,
processFor,
processIf, processIfConditions,
processOnce,
processPre,
processRawAttrs
} from "./helper.js";
import {AST_ITEM_TYPE} from "../shared/constants.js";
// 第三方html编码解码库
import he from "../shared/he.js";
// 将解码方法加入到缓存中
const decodeHTMLCached = cached(he.decode);
/**
* 根据模板与选项转换成抽象语法树
* @param tpl
* @param options
* @returns {{type: string, tag: *, attrList: *, parent: Window, children: Array}|*}
*/
export const parse = (tpl, options) => {
tpl = tpl.trim();
console.log('待转换模板:', tpl);
return compilerHtml(tpl, options);
};
let currentParent = null;
let nodeStack = new SimpleStack();
let inVPre = false;// 是否标记了v-pre,若标记了,则编译时可以跳过内部文本的编译工作,加快编译效率
let inPre = false;// 当前标签是否为pre标签
let root;// 根节点
/**
* 编译html模板,编译完成返回抽象语法树的根节点
* @param tpl
* @param options
* @returns {*}
*/
export const compilerHtml = (tpl, options) => {
parseHTML(tpl, {
...options,
start(tag, attrs, isUnary, startIndex, endIndex) {
// 当解析到标签开始位置时会执行这个钩子函数,将标签名和对应的属性传过来
let elem = createASTElement(tag, attrs, currentParent);
// 检测非法属性并提示
attrs.forEach(attr => {
if (invalidAttributeRE.test(attr.name)) {
warn(`属性名:${attr.name}中不能包含空格、双引号、单引号、<、>、\/、= 这些字符`);
}
});
// 如果当前标签是一个或、的话
// 提示用户这是一个在模板中被禁止使用的标签,因为模板仅仅只是用来描述状态与页面的呈现的,不应该包含样式和脚本标签
if (isForbiddenTag(elem)) {
elem.forbidden = true;
warn(`模板文件只是用来建立状态与UI之间的关系,不应该包含样式与脚本标签,当前使用的标签:${elem.tag}是被禁止的,我们不会对他进行便编译`);
}
// 处理checkbox、radio等需要预处理的标签
preTransformNode(elem);
// 如果inVPre为false,可能还没有解析当前标签是否标记了v-pre
if (!inVPre) {
// 解析一下
processPre(elem);
// 如果解析过后发现elem上标记有pre=true,说明标签确实标记了v-pre
if (elem.pre) {
// 修正inVPre
inVPre = true;
}
}
// 当然,除了vue的指令v-pre之外,我们html也自带一个pre标签,
// 如果标签名是pre,那也要将inPre标记为true
isPreTag(elem.tag) && (inPre = true);
if (inVPre) {
// 如果一个标签被标记了v-pre,那我们只需要把attrList中剩余的属性复制到elem的attrs中去即可
// 因为attrList中的其他属性都在刚刚进行预处理的时候已经处理并从attrList中删除了
processRawAttrs(elem);
} else if (!elem.processed) {
// 如果还有没有处理的结构指令,如v-for、v-if等,就处理一下
processFor(elem);
processIf(elem);
processOnce(elem);
}
// 如果不存在根节点,则当前节点就是根节点
!root && (root = elem);
// 判断当前节点是不是一个自闭标签,如果是一个自闭标签,那么直接结束当前标签解析
// 如果是不是自闭标签,我们需要记录下当前节点当做是下个节点的父级元素,并加这个元素压入栈中
if (isUnary) {
closeElement(elem);
} else {
currentParent = elem;
nodeStack.push(elem);
}
},
end(tag, startIndex, endIndex) {
// TODO 触发了两次,未解决
// console.log(`解析到终止标签:${tag}`, startIndex, endIndex);
// 当前标签已经解析结束了,将标签从栈中弹出
let elem = nodeStack.pop();
// 此时栈顶元素便是我们下一个元素的父级
currentParent = nodeStack.top();
// 关闭标签
closeElement(elem);
},
chars(text, startIndex, endIndex) {
// console.log(`解析到文本:${text}`, startIndex, endIndex);
// 如果不存在父级节点,那么我们可以得知,
// 这个解析出来的文本,要么就是在根节点之外,要么,压根就没有根节点,所给的tpl直接就是一段文本
if (!currentParent) {
// 如果解析出来的文本跟传入的模板完全相同,那么,说明直传进来一个文本内容,警告提示
if (text === tpl) {
warn(`组件模板需要一个根元素,而不仅仅是文本。`);
} else if ((text = text.trim())) { // 文本定义在了根节点的外面,警告提示
warn(`定义在根节点之外的文本:${text}将会被忽略掉`);
}
// 没有父节点的文本,压根就没有存在的意义,直接人道毁灭,不管他吧
return;
}
// 在IE浏览器中的textarea的placeholder有一个bug,浏览器会将placeholder的内容会被作为textarea的文本节点放入到textarea中
// 如果是这种情况框,直接忽略他吧,IE太难伺候了
if (isIE &&
currentParent.tag === 'textarea' &&
currentParent.attrsMap.placeholder === text
) {
return;
}
const children = currentParent.children;
// 如果当前文本在pre标签里或者是文本去掉前后空白后依然不为空
if (inPre || text.trim()) {
// 如果父级标签是纯文本标签,那么解析出来的文本就是我们要的内容
// 如果不是的话,需要进行一定的解码,这里使用的是一个第三方的html编解码库:he
// he链接为:https://www.npmjs.com/package/he
text = isTextTag(currentParent) ? text : decodeHTMLCached(text);
} else if (!children.length) {
// 如果当前文本父级元素下面没有子节点的话并且当前文本删除前后空格之后为空字符串的话,我们就清空文本
// 请注意,判断当前文本删除前后空格之后是否为空字符串是在上线的if语句中判断的,我刚开始看的时候不理解,
// 为啥父元素没有子节点就要清空文本呢,那要是他是这是一段文字呢?原来是因为我漏了上面的if判断里面
// 还有一个text.trim(),如果一个文本去除前后空白之后不为空的话,那他就应该进入到if的分支,而不会进入到这里。正是
// 因为他去除空白之后是空的,所以才会进入到这个判断逻辑,那么,结果就很明显了,现在正在判断的情况是:
// ,那么我们直接把text清空就可以了。
text = '';
} else if (options.whitespaceOption) {// 根据不同的去空白选项将空白去掉
// ``` html
//
//
//
// foo
// bar
//
//
//
//
// foo
// bar
//
//
// foo bar
// ```
if (options.whitespaceOption === 'condense') {
text = lineBreakRE.test(text) ? '' : ' '
} else {
text = ' ';
}
} else {// 其他情况:看看是不是需要保留空格
text = options.preserveWhitespace ? ' ' : '';
}
if (text) {
// 如果不是在pre标签中且删除空白的选项是condense,则删除文本中的换行符
if (!inPre && options.whitespaceOption === "condense") {
text = text.replace(whitespaceRE, '');
}
let res, elem;
// 如果当前节点没有v-pre属性且是一个空白符并且可以解析出动态变量
if (!inPre && text !== ' ' && (res = parseText(text, options.delimiters))) {
elem = createASTExpression(text, res.exp, res.tokens);
} else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
elem = createASTText(text);
}
// 将创建的文本节点或表达式节点加入到父级节点的children中
if (elem) {
children.push(elem);
}
}
},
comment(text, startIndex, endIndex) {
// 只有在根节点下创建注释才有效,只要不在根节点内部的注释都会被忽略
if (currentParent) {
let elem = createASTComment(text);
currentParent.children.push(elem);
}
}
});
return root;
};
let warned = false;
/**
* 辅助警告提示类,由于在生成模板过程是在循环体里面,为避免重复警告提示,定义这个只要提示一次就不再提示的警告方法
* @param message
*/
function warnOnce(message) {
if (!warned) {
warn(message);
warned = true;
}
}
/**
* 关闭标签并做一些收尾工作
* @param elem
*/
export const closeElement = elem => {
if(!elem) return;
// 若当前元素不是pre元素,则删除元素尾部的空白文本节点
trimEndingWhitespace(elem);
// 如果当前标签没有v-pre并且没有编译过,则编译一下
if (!inVPre && !elem.processed) {
processElement(elem);
}
// 当我们的元素存储栈为空并且当前元素不是根节点时
// 即模板中的元素都是自闭标签,如:
// 正确的做法(由于加上了判断,因此,同时只可能有一个元素被输出):
// 错误的做法(因为vue模板始终需要一个根元素包裹,这里已经有三个元素了):
// 此时根节点root=input,但当前元素是br,由于元素都是自闭标签,因此不存在父子关系,大家都是平级,
// 因此,也就不会想用于维护层级关系的nodeStack中添加元素
if (!nodeStack.size() && root !== elem) {
if (root.if && (elem.elseIf || elem.else)) {
addIfCondition(root, {
exp: elem.elseIf,
block: elem
});
} else {
warnOnce(`模板必须保证只有一个根元素,如果你想用v-if动态渲染元素,请将其他元素也用v-else-if串联成条件链`);
}
}
// 如果不是根节点且不是script或style之类被禁止的标签的话
if (currentParent && !elem.forbidden) {
// 如果当前标签绑定有v-else-if或v-else,则需要解析一下
if (elem.elseIf || elem.else) {
processIfConditions(elem, currentParent);
} else {
// 如果当前标签是一个作用域插槽
if (elem.slotScope) {
// 获取插槽名称
const name = elem.slotTarget || '"default"';
// 将它保留在子列表中,以便v-else(-if)条件可以
// 找到它作为prev节点。
(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = elem;
}
// 把当前元素加入到父级元素的子节点列表中,从而创建AST的父子层级关系
currentParent.children.push(elem);
// 同时也将当前节点的父级节点标记为当前的父级节点
elem.parent = currentParent
}
}
// 最后,因为作用域插槽并不是一个真实的标签,我们需要把他从子节点中移除掉
elem.children = elem.children.filter(item => !item.slotScope);
// 因为我们上线又操作过元素了,可能会在后面产生一些空白文本节点,我们再清理一下
trimEndingWhitespace(elem);
// 然后,因为我们的inVPre和inPre是公共变量,一个标签解析结束之后,需要重置一下,否则会影响下一个标签的解析
if (elem.pre) {
inVPre = false;
}
if (isPreTag(elem.tag)) {
inPre = false;
}
// 注:vue还有这样一个不走,不过我看了一下,这个步骤好像只对weex环境才有注入方法postTransforms,因此此处就不实现了
// // apply post-transforms
// for (let i = 0; i < postTransforms.length; i++) {
// postTransforms[i](element, options)
// }
};
/**
* 若当前元素不是pre元素,则删除元素尾部的空白文本节点
* @param elem
*/
function trimEndingWhitespace(elem) {
if (inPre) {
let lastNode;
while (
(lastNode = elem.children[elem.children.length - 1]) && // 节点存在
lastNode.type === AST_ITEM_TYPE.TEXT && // 是文本节点
lastNode.text === ' ' // 文本节点的内容是空白符
) {
// 弹出该元素
elem.children.pop();
}
}
}
上面已经解释了html的解析原理,但并没有对文本,特别是带有插值表达{{name}}这样的文本进行解析,现在,咱们再来看一下如何解析特殊文本的。
/**
* 构建动态文本转化正则表达式
* @type {function(*): *}
*/
const buildRegex = cached(delimiters => {
// 将分割符变为转义字符
// "{{".replace(/[-.*+?^${}()|[\]\/\\]/g,'\\$&');
// \{\{
const open = delimiters[0].replace(regexEscapeRE, '\\$&');
// "}}".replace(/[-.*+?^${}()|[\]\/\\]/g,'\\$&');
// \}\}
const close = delimiters[1].replace(regexEscapeRE, '\\$&');
return new RegExp(open + '((?:.|\\n)+?)' + close, 'g')
});
/**
* 文本解析器,用于解析文本中的变量
* @param {String} text
* @param {Array} delimiters 分隔符,默认是:["{{","}}"]
* @returns {Object}
*/
export const parseText = (text, delimiters) => {
const expRE = delimiters ? buildRegex(delimiters) : defaultTagRE;
if (!expRE.test(text)) return '';
// 将正则的游标移动到开始的位置
let lastIndex = expRE.lastIndex = 0;
let match, index, res = [],tokenValue = '',rawTokens = [],exp;
while ((match = expRE.exec(text))) {
index = match.index;
// 将{{之前的文本加入到结果数组
if (index > lastIndex) {
rawTokens.push((tokenValue = text.slice(lastIndex, index)))
res.push(JSON.stringify(tokenValue));
}
exp = match[1].trim();
// 解析过滤器
exp = parseFilter(exp);
// 将解析出来的变量转化为调用方法的方式并加入结果数组如:_s(name)
res.push(`_s(${exp})`);
rawTokens.push({ '@binding': exp });
// 设置lastIndex保证下一次循环不会重复匹配已经解析过的文本
lastIndex = index + match[0].length;
}
// 将}}之后的文本加入到结果数组
if (lastIndex < text.length) {
rawTokens.push((tokenValue = text.slice(lastIndex)));
res.push(JSON.stringify(tokenValue));
}
return {
exp: res.join('+'),
tokens: rawTokens
};
};
从上面的代码中,我们可以看到,其实对于文本的解析逻辑也并不难,就是通过正则匹配目标表达式,然后,然后将匹配到的所有表达式生成的代码片段 `_s(${exp})` 和普通文本的加入到一个数组中 ,最后通过join('+')将这些字符串串联起来。其中,_s是一个运行时才会注入的工具方法,其实就把一个值转换成字符串的方法。
从上面解析html代码的时候,我们可以看到,我们使用了一个叫做nodeStack的栈用来存储标签,我们之前也说了,通过这个栈,可以帮助我们维护标签之前的父子关系,那么,他到底是怎么通过nodeStack维护父子关系的呢?nodeStack有到底是什么呢?
我们先来解释一下nodeStack吧,这个其实本质上就是一个普通的栈结构的对象,所谓栈,就是严格遵循后进先出的顺序操作的数组。下面来看一下这个栈的具体实现:
// shared/SimpleStack.js 实现了一个简单的栈
class SimpleStack {
constructor() {
this.stack = [];
}
push(item) {
this.stack.push(item);
}
pop() {
return this.stack.pop();
}
size(){
return this.stack.length;
}
empty(){
return this.size()===0;
}
top(){
return this.stack[this.size()-1];
}
findIndex(handle){
let pos = this.size() - 1;
for(;pos>=0;pos--){
let cur = this.stack[pos];
if(cur&&handle(cur)){
return pos;
}
}
return -1;
}
get(pos){
return this.stack[pos];
}
popItemByStartIndex(index){
return this.stack.splice(index).reverse();
}
clear(){
this.stack = [];
}
print(){
console.log(`%c当前栈的数据结构是:`,'color: green', JSON.stringify(this.stack));
}
}
export default SimpleStack;
需要注意的是,在vue源码中其实并没有这样一个对象,Vue源码中是直接使用数组来模拟栈结构的操作的,不过原理都是一样的,我这边对这个栈进行了封装,方便理解与使用。从上面的代码可以看出,其实底层原理使用的也是一个数组,我们所有的操作其实也都是基于数组的操作,我们将它封装起来,只是为了不让外界随意的访问而已。
了解了栈是怎样的,那么我们再来看看如何通过这个栈来帮助我们解析节点之间的父子关系。
我们举个例子来说明一下:
加入说我们的html结构是这样的:
这是一段文字
- 选项1
- 选项2
当我们解析到
的时候,我们再把p也加入到栈中,此时栈中有div和p两个元素,栈顶的元素是p。再往下解析,解析到一段文字,触发chars收集并生成抽象语法树文本节点,那么,此时,这个文本节点的父级是什么呢?显而易见,就是我们栈顶的元素p。所以我们将这个文本节点加入到p的children中即可。继续解析,发现
结束标签,我们将栈顶元素p弹出来,也就是说,现在我们的栈里又只有一个div了,同理,下面的ul和li也是这样操作。当最后解析到==[入栈]=> [div,p] 栈顶元素为p
因此,使用栈来辅助,我们要找到当前节点的父标签其实很容易,栈顶元素就是了。
经过模板编译器的编译,我们就能得到一个抽象语法树的根节点
好了,模板编译器的相关原理就讲到这里吧,之后还会陆续更新优化器、代码生成器等相关原理的文章,欢迎共同学习讨论。
最后,再附上项目github:kinerVue/dev-0.0.1 ,欢迎star!