最近在学习vue模版编译原理,整理一下,以后复习用。
模版编译的过程大致是:
template=》ast=》 render 函数=》创建虚拟 dom=》diff 算法更新虚拟 dom=》产生、更新真实节点
参照这张图,一起看看具体怎么实现的吧。
模版确定
- 看用户是否调用了
render函数传入了模板 - 没有传入,可能传入的是
template - 没
template用$options.el调用compileTofunction生成render函数,再把render函数挂载到vm的$options上
if (vm.$options.el) {
//将数据挂载到这个模版上
vm.$mount(vm.$options.el);
}
Vue.prototype.$mount = function (el) {
const vm = this;
const options = vm.$options
el = document.querySelector(el);
vm.$el = el;
if(!options.render){ // 没有render用template,目前没render
let template = options.template;
if(!template && el){ // 用户也没有传递template 就取el的内容作为模板
template = el.outerHTML;
}
let render = compileToFunction(template);
options.render = render;
}
// options.render 就是渲染函数
// 调用render方法 渲染成真实dom 替换掉页面的内容
mountComponent(vm,el); // 组件的挂载流程
}
1.html语法解析
通过
正则解析 el.outerHTML(开始标签、结束标签、属性、文本)。循环 html 字符串,不停的正则匹配解析,每解析一部分就删除已解析的部分,直到 html 字符串为空,停止解析。
- 先解析
<div id="app">开始标签 - 再解析
{{name}}文本 - 再解析
</div>结尾标签
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 标签开头的正则 捕获的内容是标签名
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 end(tagName){
console.log(tagName)
}
function chars(text){
console.log(text);
}
function parseHTML(html){
while(html){
let textEnd = html.indexOf('<');
if(textEnd == 0){
const startTagMatch = parseStartTag();
if(startTagMatch){
start(startTagMatch.tagName,startTagMatch.attrs);
continue;
}
const endTagMatch = html.match(endTag);
if(endTagMatch){
advance(endTagMatch[0].length);
end(endTagMatch[1]);
continue;
}
}
let text;
if(textEnd >= 0){
text = html.substring(0,textEnd);
}
if(text){
advance(text.length);
chars(text);
}
}
function advance(n){
html = html.substring(n);
}
function parseStartTag(){
const start = html.match(startTagOpen);
if(start){
const match = {
tagName:start[1],
attrs:[]
}
advance(start[0].length);
let attr,end;
while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))){
advance(attr[0].length);
match.attrs.push({name:attr[1],value:attr[3]});
}
if(end){
advance(end[0].length);
return match
}
}
}
}
export function compileToFunctions(template){
parseHTML(template);
return function(){}
}
2.生成ast语法树
上面我们已经,匹配出 开始标签、文本、结尾标签。那么问题来了,怎么把匹配出来的开始标签、文本、结尾标签,组装成一个AST树结构呢?
主要思想: ast树结构即用对象描述js语法,vue采用了栈的思想来生成。遇到
<div>起始标签往栈中放入,依次将他的子节点放入栈中.第一个放入栈的节点是根节点。每个节点在栈的中下一层是自己的父节点,当标签闭合的时候,弹出标签对。遇到开始标签就创建一个元素。当前没有根,那么这个元素就是根元素,同时记录一下他的父节点是谁,并且记录一下的子节点。
let root;
let currentParent;
let stack = [];
const ELEMENT_TYPE = 1;
const TEXT_TYPE = 3;
function createASTElement(tagName,attrs){
return {
tag:tagName,
type:ELEMENT_TYPE,
children:[],
attrs,
parent:null
}
}
//vue将匹配出标签、文本等,传入对应function start end chars 循环生成 ast。
// 开始标签:`start`:根据开始标签,构建元素,并将栈中上一个元素作为父节点,没有根,那这个元素就是根元素,并把自己放到栈中。
function start(tagName, attrs) {
let element = createASTElement(tagName,attrs);
if(!root){
root = element;
}
currentParent = element;
stack.push(element);
}
// 结尾:`end`:元素结尾把栈弹出去
function end(tagName) {
let element = stack.pop();
currentParent = stack[stack.length-1];
if(currentParent){
element.parent = currentParent;
currentParent.children.push(element);
}
}
// 文本:`chars`:文本是栈中最后一个元素的 children
function chars(text) {
text = text.replace(/\s/g,'');
if(text){
currentParent.children.push({
type:TEXT_TYPE,
text
})
}
}
3.生成代码
遍历 ast 树,拼接成字符串
"\_c('div',{id:'app',a:1},\_c('span',{},'world'),\_v())"。
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // {{aaaaa}}
function genProps(attrs) { // [{name:'xxx',value:'xxx'},{name:'xxx',value:'xxx'}]
let str = '';
for (let i = 0; i < attrs.length; i++) {
let attr = attrs[i];
if (attr.name === 'style') { // color:red;background:blue
let styleObj = {};
attr.value.replace(/([^;:]+)\:([^;:]+)/g, function() {
styleObj[arguments[1]] = arguments[2]
})
attr.value = styleObj
}
str += `${attr.name}:${JSON.stringify(attr.value)},`;
}
return `{${str.slice(0,-1)}}`
}
//child 节点 通过 gen 生成
function gen(el) {
if (el.type == 1) { // element = 1 text = 3
//递归生成子节点的字符串
return generate(el);
} else {
//文本节点,则通过正则匹配出 name{{vmdata}} 拼接出字符串和变量的方式
let text = el.text;
if (!defaultTagRE.test(text)) {
return `_v('${text}')`;
} else {
// 'hello' + arr + 'world' hello {{arr}} {{aa}} world
let tokens = [];
let match;
let lastIndex = defaultTagRE.lastIndex = 0;
while (match = defaultTagRE.exec(text)) { // 看有没有匹配到
let index = match.index; // 开始索引
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}
tokens.push(`_s(${match[1].trim()})`); // JSON.stringify()
lastIndex = index + match[0].length;
}
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return `_v(${tokens.join('+')})`
}
}
}
function genChildren(el) {
let children = el.children; // 获取儿子
if (children) {
return children.map(c => gen(c)).join(',')
}
return false;
}
export function generate(el) { // _c('div',{id:'app',a:1},_c('span',{},'world'),_v())
// 遍历ast树 将ast树拼接成字符串
let children = genChildren(el);
let code = `_c('${el.tag}',${
el.attrs.length? genProps(el.attrs): 'undefined'
}${
children? `,${children}`:''
})`;
return code;
}
4.生成render函数
通过
newFunction("拼接成的字符串")+with生成可执行的代码。
export function renderMixin(Vue) {
Vue.prototype._c = function () {
// createElement
return createElement(this, ...arguments);
};
Vue.prototype._v = function (text) {
// createTextElement
return createTextElement(this, text);
};
Vue.prototype._s = function (val) {
// stringify
if (typeof val == "object") return JSON.stringify(val);
return val;
};
Vue.prototype._render = function () {
const vm = this;
let render = vm.$options.render; // 就是我们解析出来的render方法,同时也有可能是用户写的
let vnode = render.call(vm);
return vnode;
};
}
export function compileToFunctions(template) {
parseHTML(template);
let code = generate(root);
let render = `with(this){return ${code}}`;
let renderFn = new Function(render);
return renderFn
}
5.生成虚拟 dom
调用
render生成虚拟 dom
function createElement(vm, tag, data = {}, ...children) {
return vnode(vm, tag, data, data.key, children, undefined);
}
function createTextElement(vm, text) {
return vnode(vm, undefined, undefined, undefined, undefined, text);
}
function vnode(vm, tag, data, key, children, text) {
return {
vm,
tag,
data,
key,
children,
text,
// .....
}
}
export function renderMixin(Vue){
Vue.prototype._c = function(){ // createElement
return createElement(this,...arguments)
}
Vue.prototype._v = function (text) { // createTextElement
return createTextElement(this,text)
}
Vue.prototype._s = function(val){ // stringify
if(typeof val == 'object') return JSON.stringify(val)
return val;
}
Vue.prototype._render = function(){
const vm = this;
let render = vm.$options.render; // 就是我们解析出来的render方法,同时也有可能是用户写的
let vnode = render.call(vm);
return vnode;
}
}
6.生成真实dom,并替换老dom
数据变化后调用
updataComponent此函数, 进行vm._update(vm._render())操作。vm._update(vm._render())这个是生成真实dom,替换原本dom的关键代码。
前面我们已经通过调用_render函数生成一个的vnode,然后我们再调用_update方法,来进行patch(vm.$el, vnode)。patch先根据vnode生成最新的真实dom,再将最新的真实dom替换掉原本的dom元素。
export function patch(oldVnode, vnode) {
if (oldVnode.nodeType == 1) {
// 用vnode 来生成真实dom 替换原本的dom元素
const parentElm = oldVnode.parentNode; // 找到他的父亲
let elm = createElm(vnode); //根据虚拟节点 创建元素
// 在第一次渲染后 是删除掉节点,下次在使用无法获取
parentElm.insertBefore(elm, oldVnode.nextSibling);
parentElm.removeChild(oldVnode)
return elm
}
}
function createElm(vnode) {
let { tag, data, children, text, vm } = vnode
if (typeof tag === 'string') { // 元素
vnode.el = document.createElement(tag); // 虚拟节点会有一个el属性 对应真实节点
children.forEach(child => {
vnode.el.appendChild(createElm(child))
});
} else {
vnode.el = document.createTextNode(text);
}
return vnode.el
}
export function lifecycleMixin(Vue) {
Vue.prototype._update = function(vnode) {
// 既有初始化 又有更新
const vm = this;
patch(vm.$el, vnode);
}
}
export function mountComponent(vm, el) {
// 更新函数 数据变化后 会再次调用此函数
let updateComponent = () => {
// 调用render函数,生成虚拟dom
vm._update(vm._render()); // 后续更新可以调用updateComponent方法
// 用虚拟dom 生成真实dom
}
updateComponent();
}
小结
细心的朋友肯定发现了,这篇文章没有讲diff算法。这个下次再写,嘿嘿。