别再问我vue模版怎么编译了

2,087 阅读4分钟

最近在学习vue模版编译原理,整理一下,以后复习用。

模版编译的过程大致是:

template=》ast=》 render 函数=》创建虚拟 dom=》diff 算法更新虚拟 dom=》产生、更新真实节点 参照这张图,一起看看具体怎么实现的吧。

模版确定

  1. 看用户是否调用了 render 函数传入了模板
  2. 没有传入,可能传入的是 template
  3. 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 字符串为空,停止解析。

  1. 先解析<div id="app">开始标签
  2. 再解析{{name}}文本
  3. 再解析</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算法。这个下次再写,嘿嘿。