主题
模板编译
数据观测完后, 数据变成响应式了,接下来就需将数据渲染到页面, 因此现在的步骤到了模板编译,大体步骤为template -> ast -> code -> vdom -> dom
$mount
挂载优先级
options.render函数
> options.template
> options.el.outerHTML
js
import { initState } from "./state";
import { compileTemplate } from "../../compile/index";
export function initMixin(Vue) {
// 初始化
Vue.prototype._init = function (option) {
const vm = this;
// TODO: 选项合并
vm.$options = option || {};
// 数据观测(初始化状态)
initState(vm);
// 挂载
if (vm.$options.el) {
vm.$mount(vm.$options.el);
}
};
// 挂载
Vue.prototype.$mount = function (el) {
const vm = this;
const opts = vm.$options;
// 取到el元素
el = typeof el === "string" ? document.querySelector(el) : el;
// 挂载优先级 render函数 > temlate > el
if (!opts.render) {
// 取到Html模板
if (!opts.template && el) {
opts.template = el.outerHTML;
}
// 开始编译模板得到render函数opts.render
opts.render = compileTemplate(opts.template);
}
};
}
dom.outerHTML
取到的是包含描述元素(本身)及其后代的序列化 HTML 片段
, 有一定兼容性, 也可以创建一个div
, 然后将el
丢进去作为children
, 用innerHTML
来取到
compileTemplate
注意: 在$mount
中需要的是一个render
函数, 因此compileTemplate
内返回的应该是一个函数
js
import {parseHtml} from './parseHtml'
// 模板编译
export function compileTemplate(template) {
// 解析Html字符串后, 得到ast语法树
const root = parseHtml(template); // root就是生成的语法树
console.log(root);
// 编译ast语法树后得到render函数, 内部其实就是字符串拼接(模板引擎的原理)
// 将js代码转换为render函数返回
return function render() {};
}
parseHtml
js
/* 下列正则用于解析模板 */
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 标签开头的正则 捕获的内容是标签名 <div
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾的 </div >
const attribute =
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性的
const startTagClose = /^\s*(\/?)>/; // 匹配标签结束的 >
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配 {{}} 部分
// 处理开始标签
function start(tagName, attrs) {
console.log('开始标签是:', tagName, "属性是", attrs)
}
// 处理文本
function char(text) {
console.log('文本内容是:', text)
}
// <div><span></span></div>
function end(tagName) {
console.log('结束标签是:', tagName)
}
export function parseHtml(html) {
while (html) {
const startTagIndex = html.indexOf("<");
// 遇到 < 开头: 以 < 开头 可能是开始标签也可能是结束标签, 也可能是HTML注释(注释此处没有考虑, 仅考虑核心部分)
if (startTagIndex === 0) {
// 先处理开始标签
const startTagMatch = parseStartTag();
if (startTagMatch) {
start(startTagMatch.tag, startTagMatch.attrs);
continue; // 开始标签 + 属性 + > 匹配完后
}
// 处理结束标签 </div>
let endTagMatch = html.match(endTag);
if (endTagMatch) {
end(endTagMatch[1]);
advance(endTagMatch[0].length);
continue;
}
}
// 遇到文本 dsdsd < 或者 \n*<
if (startTagIndex >= 0) {
let text = html.substring(0, startTagIndex);
if (text) {
char(text);
advance(text.length);
}
}
function parseStartTag() {
const startTagMatch = html.match(startTagOpen);
// 遇到开始标签
if (startTagMatch) {
advance(startTagMatch[0].length); // 先前进一个开始标签长度 <div
let startTagAst = {
tag: startTagMatch[1],
attrs: [],
};
// 接着解析属性, 由于属性只能一个一个解析, 可能存在多个属性, 因此用while循环来匹配
let endMatch, attrsMatch;
while (
!(endMatch = html.match(startTagClose)) && // 不以 \s*> 开头
(attrsMatch = html.match(attribute)) // 有属性 例如: id="app"
) {
advance(attrsMatch[0].length); // html前进一个属性的位置
startTagAst.attrs.push({
name: attrsMatch[1],
value: attrsMatch[3] || attrsMatch[4] || attrsMatch[5] || "", // 匹配属性的正则attribute有三种情况 id="app" id='app' id=app
});
}
// 以 \s*> 开头,直接前进
if (endMatch) {
advance(endMatch[0].length);
}
// 以 \s* /> 结尾, 表示为自闭合标签
return startTagAst; // 开始标签 + 属性 + > 匹配完后
}
}
}
// 让html前进n字符(通过丢弃前面不要的部分)
function advance(n) {
html = html.substring(n);
}
return root;
}
提示
正则中 ?:
表示用字符创的 match 方法匹配时, 能匹配上, 但是不捕获这部分内容,例如:
js
let str = "hello-word"
let reg = /(\w+)(\-)(\w+)/; // 有3个分组, 且都会匹配和捕获
str.match(reg) // ['hello-word', 'hello', '-', 'word', index: 0, input: 'hello-word', groups: undefined]
// 若是:
let reg = /(\w+)(?:\-)(\w+)/; // 中间的 - 仅会匹配, 不会捕获, 也就不会出现在match结果中
str.match(reg) // ['hello-word', 'hello', 'word', index: 0, input: 'hello-word', groups: undefined]
构建AST层级树
上一步已经解析了html, 且在解析过程中会不断调用start
,end
,char
三个函数,这三个函数内部就能得到解析后的结果, Html是tree结构, 解析过程中也会是一层层的触发这三个函数,要构建tree, 可以借助栈结构
js
// ...regexp......
const ELEMENT_TYPE = 1; // 元素类型
const TEXT_TYPE = 3; // 文本类型
const singleTag = ["img", "hr", "br", "input"]; // Html中的单标签
// 创建AST元素节点
function createAstElement(tagName, attrs) {
return {
tag: tagName,
type: ELEMENT_TYPE,
attrs,
parent: null,
children: [],
};
}
// 创建AST文本节点
function createAstText(text) {
return {
text,
type: TEXT_TYPE,
};
}
// 解析HTML模板
export function parseHtml(template){
let root = null; // ast根节点
let currentParent; // 当前父节点
const stack = []; // 栈
// 处理开始标签
function start(tagName, attrs) {
const element = createAstElement(tagName, attrs);
if (root === null) {
root = element;
}
// 若当前元素是单标签, 不用入栈, 直接作为当前父级的children即可
if (singleTag.includes(tagName)) {
if (currentParent) {
currentParent.children.push(element);
}
} else {
currentParent = element;
stack.push(element);
}
}
// 处理文本
function char(text) {
text = text ? text.trim() : "";
if (text) {
const textNode = createAstText(text);
currentParent.children.push(textNode);
}
}
// 处理结束标签 <div><span></span></div>
function end(tagName) {
const closeElement = stack.pop();
if (tagName === closeElement.tag) {
currentParent = stack[stack.length - 1];
if (currentParent) {
closeElement.parent = currentParent;
currentParent.children.push(closeElement);
}
} else {
// TODO: 可以判断当前标签是否正常关闭
console.error("标签没有正常关闭");
}
}
// while......
return root; // 最终得到ast tree
}
generate code
上一步有了ast语法树后, 需要利用ast语法树生成渲染函数
js
import { parseHtml } from "./parseHtml";
import { generate } from "./generate";
export function compileTemplate(template) {
// 解析Html字符串后, 得到ast语法树
const root = parseHtml(template); // root就是生成的语法树
/**
* 编译ast语法树后得到js代码字符串
*
* vue中render函数中有几个函数(这里不全. 仅用了几个主要的), 用于生成虚拟DOM
* _c(): 用于创建元素
* _v(): 用于创建文本
* _s(): 用于序列化得到字符串
*
* 若html如下:
* <div id="app" style="color: red;background: #ccc;">
* <a href="http://baidu.com"> 百度</a>
* <img src="" alt="" />
* <span>hello{{name}} ---{{age}} 岁了</span>
* <p>{{age}}</p>
* </div>
* 得到的code为:
* _c("div",{"id":"app","style":{"color":" red","background":" #ccc"}},_c("a",{"href":"http://baidu.com"},_v('百度')),_c("img",{"src":"","alt":""},""),_c("span",undefined,_v('hello'+_s(name)+' ---'+_s(age)+' 岁了')),_c("p",undefined,_v(_s(age))))
*/
const code = generate(root);
/**
* 将js代码转换为render函数返回
* 通过new Function 将原始的code字符串转化为函数, 中间需要注意作用域问题, _c _v _s 调用时候, this指向应该为vm,
* 包括code中 "{{}}" 之间的变量(例如: name)取值的时候, this指向应该为vm, 因此这里用到with, renderFn最终是vm在调用,
* 所以with(this)里面的this会指向vm, 下面的逻辑就顺理成章了
*/
const render = `with(this){return ${code}}`; // 改变code中的this
const renderFn = new Function(render);
return renderFn;
}
生成代码的过程
js
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配 {{}} 部分
function geneChildrens(el) {
const children = el.children;
if (children && children.length) {
return children.map((child) => gen(child)).join(",");
} else {
return "";
}
}
function genText(text) {
let strAry = [];
let index = (defaultTagRE.lastIndex = 0);
let match;
while ((match = defaultTagRE.exec(text))) {
const txt = text.substring(index, match.index);
const val = match[1];
if (txt) {
strAry.push(`'${txt}'`);
}
strAry.push(`_s(${val})`);
text = text.substring(defaultTagRE.lastIndex);
index = defaultTagRE.lastIndex = 0;
}
if (text) {
strAry.push(`'${text}'`);
}
return strAry.join("+");
}
function gen(node) {
if (node.type === 1) {
return generate(node);
} else {
return `_v(${genText(node.text)})`;
}
}
// 生成属性
function genProps(el) {
const attrs = el.attrs;
if (attrs && attrs.length) {
//attrs ==> [{name: 'style', value: 'color: red;background: #ccc;'},{name: 'id', value:'app'}]
// 需要变成 ==> {style: {color: 'red', background: '#ccc'}, id: 'app'}
function formatAttrs(attrs) {
return attrs.reduce((pre, cur) => {
const { name, value } = cur;
if (name === "style") {
const valueAry = value.split(";").map((item) => {
const [name, value] = item.split(":");
return { name, value };
});
pre[name] = formatAttrs(valueAry);
} else {
name && (pre[name] = value);
}
return pre;
}, {});
}
return JSON.stringify(formatAttrs(attrs));
}
return "undefined";
}
// 生成Code
export function generate(el) {
return `_c("${el.tag}",${genProps(el)},${geneChildrens(el) || '""'})`;
}
这里有几个注意点:
- 在生成属性的时候, 仅做了常规属性以及style的转换, 且style里面类似
font-size
中横线连接的属性,没有做转小驼峰命名处理 - 在用正则匹配
hello ---- 岁
这种差值表达式语法时候,用的是exec
方法:
js
let reg = 'hello {{name}}----{{age}}'
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配 {{}} 部分
defaultTagRE.exec(reg); // ['{{name}}', 'name', index: 6, input: 'hello {{name}}----{{age}}', groups: undefined]
一次只能匹配一个若需要将一段文本内的
匹配完, 需要通过循环匹配, 正则循环匹配会存在一个
lastIndex
的问题, 因此需要在每次匹配的时候处理偏移量, 这里采用的方法为, 匹配一段, 丢弃一段, 再将lastIndex
重置为0
code
生成renderFn
如上所示, code为字符串, 要转为render函数, 需要通过new Function()
来实现, 这里需要考虑作用域的问题,code在后期执行的时候, this
都会是vm
,所以用with(this){return code}
将code
中的this
指向vm
js
const code = generate(root);
/**
* 将js代码转换为render函数返回
* 通过new Function 将原始的code字符串转化为函数, 中间需要注意作用域问题, _c _v _s 调用时候, this指向应该为vm,
* 包括code中 "{{}}" 之间的变量(例如: name)取值的时候, this指向应该为vm, 因此这里用到with, renderFn最终是vm在调用,
* 所以with(this)里面的this会指向vm, 下面的逻辑就顺理成章了
*/
const render = `with(this){return ${code}}`; // 改变code中的this
const renderFn = new Function(render);
最终得到的new Function(...)
就是编译模板后得到的render函数
小结
- 数据初始化完成后,需要初渲染,渲染就需要用到
render
函数 - vue中渲染优先级最高的就是
render
函数, 其次是template
,最后是el
- 没有
render
函数的时候回通过template
编译得到render
函数, 在没有template
时候, 会用el
元素及其子元素作为模板编译得到render
函数 - 模板编译的过程为解析
Html
得到ast
语法树, 再由ast
语法树转为code
字符串,code
中包含_c
_s
_v
等等一些原型上的函数(这一块后面会讲到), 作用都是为了创建虚拟DOM - 为了解决
render
函数中的this
问题, 使用with(this){return code}
保证code中的this指向vm, 最后通过new Function
将字符串类型的代码转为真正的render
函数