Vue已经3.0了,Vue2的原理你会了吗(二)-- 模板编译

1. 前言

    万字长文,建议收藏哦!

    书接上回,Vue2中在进行数据初始化与监听之后(当然还有状态初始化、事件初始化等,但不影响流程,随后再说,咱们先来个大的),接下来就是进行模板编译了。这块考验的是对字符串拼接的理解,有点麻烦,相关源码已开源:手写Vue2。在开始之前,先上一张流程图。

1641971537(1).jpg

2. 编译内部流程

2.1 获取编译内容

    这一步主要考虑各种兼容情况,有以下几种场景:

  • 无el属性
  • 无render
  • 无template

    预设以上场景,下面开始codeing:

    编译内容有三个来源:render、template和el中的内容;相信我们对以下代码不会陌生:

image.png

    这里手动执行了$mount方法,进行编译,那如果有el属性,则自动执行。而对于template和render就好办了,有render就是用render,无render则将template转换成render,如果没template,则将el中的内容转换成template,再转换成render。简写代码如下:

image.png

具体源码:

image.png

2.2 将编译内容转换为抽象语法树(AST)

举个栗子,html内容如下:

image.png

    解析html:使用$mount中的compileToFunction方法进行解析。

    这里先就获取开始标签及标签属性做一个示范:

// 正则匹配:以下正则注意起始位置
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/   //匹配属性的,比如:aaa='aaa' aaa="aaa" aaa=aaa
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`   //标签名
const qnameCapture = `((?:${ncname}\\:)?${ncname})` //命名空间标签名<aa:aa>
const startTagOpen = new RegExp(`^<${qnameCapture}`)    //包括尖括号在内的字符串:<div(首位)
const startTagClose = /^\s*(\/?)>/  //匹配最后的闭合尖括号:/> 、>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) //匹配整个闭合标签:</div>
const doctype = /^<!DOCTYPE [^>]+>/i
// #7298: escape - to avoid being passed as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //匹配花括号里面的 内容

function star(tagName,attrs){
    console.log(tagName);
    console.log(attrs);
}
// 解析html:原理是利用正则,匹配html字符串,拿出各种标签,组成需要的数据
function parseHTML(html){;
    while(html){    //匹配一段,删除一段
        let textEnd=html.indexOf("<")   
        if(textEnd===0){    //说明是开始标签
            const startTagMatch=parseStartTag() //开始标签的匹配结果
            if(startTagMatch){
                star(startTagMatch.tagName,startTagMatch.attrs)
            }
            break;
        }
    }
    function parseStartTag(){
        const starMatch=html.match(startTagOpen) //如果匹配上了,返回数组第一位是标签名+尖括号,第二位是标签名
        if(starMatch){
            // 结果是为了获取这个match:比如{tagName:"div",attrs:[{name:"id",value:"app"},{name:"class",value:"xxx"}]}
            const match={
                tagName:starMatch[1],
                attrs:[]
            }
            advance(starMatch[0].length)
            let end,attr;
            //在开始标签结束以前都在循环,为了获取属性:改正则返回6位数组,第一位为完整属性,第二位为属性名,第三位位“=”,第四位/第五/第六位为属性值
            while(!(end=html.match(startTagClose)) && (attr=html.match(attribute))){    
                match.attrs.push({
                    name:attr[1],
                    value:attr[3]||attr[4]||attr[5]
                })
                advance(attr[0].length)
            }
            if(end){    //end匹配的是结束位置的尖角符号'>'
                advance(end[0].length)
                return match
            }
        }
    }
    // 截取字符串:匹配完之后,将前面的字符串删掉,赋值新的字符串
    function advance(index){    //参数为下标
        html=html.substring(index)
    }

}
// html模板-->变为render函数
export function compileToFunction(template){ 
    // 1、将html转为"AST"语法树,注意AST语法树与虚拟dom的区别:虚拟dom是来描述dom的,AST是来描述语言的。
    parseHTML(template)
}
复制代码

    这部分代码很长,主要功能是获取开始标签及开始标签身上的属性。

    总体的流程就是:使用一堆正则-->进行字符串匹配-->处理匹配到的字符串(进行属性获取等操作)-->从原字符串中将匹配到的字符串删除获取新的字符串(advance()方法简短有效)

    举个栗子:

image.png

    接下来为完整逻辑:循环整个html字符串-->匹配到开始节点、内容节点、结束节点--->处理节点并生成对应的树结构(依靠栈,先进来的就是父节点,再进一个就是上一位的子节点)-->最终得到整个AST对象

// 正则匹配:以下正则注意起始位置
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/   //匹配属性的,比如:aaa='aaa' aaa="aaa" aaa=aaa
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`   //标签名
const qnameCapture = `((?:${ncname}\\:)?${ncname})` //命名空间标签名<aa:aa>
const startTagOpen = new RegExp(`^<${qnameCapture}`)    //包括尖括号在内的字符串:<div(首位)
const startTagClose = /^\s*(\/?)>/  //匹配最后的闭合尖括号:/> 、>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) //匹配整个闭合标签:</div>
const doctype = /^<!DOCTYPE [^>]+>/i
// #7298: escape - to avoid being passed as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //匹配花括号里面的 内容

// 解析html:原理是利用正则,匹配html字符串,拿出各种标签,组成需要的数据
// 这里先不管指令、<!DOCTYPE、注释等解析
export function parseHTML(html) {
    function createASTElement(tagName, attrs) {
        return {
            tag: tagName,
            type: 1,    //'1'表示标签元素类型
            children: [],
            attrs,
            parent: null
        }
    }
    let root;   //根节点
    let currentParent;  //当前父节点
    let stack=[]    //存储节点数组
    //处理开始节点
    function star(tagName, attrs) {
        let element=createASTElement(tagName,attrs)
        if(!root){
            root=element
        }
        // 当前获取的开始节点即作为父节点出现
        currentParent=element
        stack.push(element) //将开始节点放进去,在节点结尾处,将这个节点删掉
    }
    //处理文本节点
    function chars(text) {
        text=text.replace(/s/g,'')  //空格去掉
        if(text){   //如果去掉空格还有字符串,则收入父节点的children中
            currentParent.children.push({
                type:3, //3代表文本节点
                text
            })

        }

    }
    //处理结尾标签并组建父子关系
    function end(tagName) {
        let element=stack.pop() //删除结尾标签,重新赋值currentParent
        currentParent=stack[stack.length-1]
        if(currentParent){
            element.parent=currentParent    //数组的上一位是下一位的父节点
            currentParent.children.push(element)
        }
    }
    
    while (html) {    //匹配一段,删除一段
        let textEnd = html.indexOf("<")
        if (textEnd === 0) {    //说明是开始标签/结束标签
            const startTagMatch = parseStartTag() //开始标签的匹配结果
            if (startTagMatch) {
                star(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 parseStartTag() {
        const starMatch = html.match(startTagOpen) //如果匹配上了,返回数组第一位是标签名+尖括号,第二位是标签名
        if (starMatch) {
            // 结果是为了获取这个match:比如{tagName:"div",attrs:[{name:"id",value:"app"},{name:"class",value:"xxx"}]}
            const match = {
                tagName: starMatch[1],
                attrs: []
            }
            advance(starMatch[0].length)
            let end, attr;
            //在开始标签结束以前都在循环,为了获取属性:改正则返回6位数组,第一位为完整属性,第二位为属性名,第三位位“=”,第四位/第五/第六位为属性值
            while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
                match.attrs.push({
                    name: attr[1],
                    value: attr[3] || attr[4] || attr[5]
                })
                advance(attr[0].length)
            }
            if (end) {    //end匹配的是结束位置的尖角符号'>'
                advance(end[0].length)
                return match
            }
        }
    }
    // 截取字符串:匹配完之后,将前面的字符串删掉,赋值新的字符串
    function advance(index) {    //参数为下标
        html = html.substring(index)
    }
    return root //返回ast树

}
复制代码

    获得最终AST数据结构如下:

image.png

2.3 将AST解析成render函数

    render函数中,对AST中各种type类型的标签,提供对应的方法。比如type=1的节点类型,使用_c()方法进行标签创建;type=3的文本类型,使用_v()方法进行文本创建;使用_s()方法进行变量转换……所以,这一节就是对AST进行递归遍历,进行字符串拼接。下面是具体的方法(简写)。

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //匹配花括号里面的 内容
function gen(node) {
    if (node.type === 1) {  //标签节点
        return generate(node)
    } else if (node.type === 3) {  //暂定为文本节点
        let text = node.text;
        if (!defaultTagRE.test(text)) {   //没有花括号,则直接导出
            return `_v(${JSON.stringify(text)})`
        }
        // 切割并拼接字符串,处理花括号里的内容
        let tokens = [];
        let lastIndex = defaultTagRE.lastIndex = 0;
        let match, index;    //每次匹配到的结果与下标
        // 正则匹配到的结果:第一位为全字符,第二位为括号内容
        while (match = defaultTagRE.exec(text)) {
            index = match.index;
            if (index > lastIndex) {    //将非括号内的也加入进去
                tokens.push(JSON.stringify(text.slice(lastIndex, index)))
            }
            // 预设此处_s为变量处理方法
            tokens.push(`_s(${match[1].trim()})`)
            lastIndex = index + match[0].length //重设lastIndex
        }
        // 最后的最后,将括号后边的也加入进来
        if (lastIndex < text.length) {
            tokens.push(JSON.stringify(text.slice(lastIndex)))
        }
        return `_v(${tokens.join("+")})`

    }
}
//针对标签属性
function genProps(attrs) {
    let str = '';
    for (let attr of attrs) {
        if (attr.name === 'style') {
            let obj={}
            // attr.value:"color:red;diplay:block"
            attr.value.split(";").forEach(item=>{
                let [key,value]=item.split(":")
                obj[key]=value
            })
            attr.value=obj
        }
        str += `${attr.name}:${JSON.stringify(attr.value)},`  //加JSON.stringify是为了加入引号
    }
    return `{${str.slice(0, -1)}}`   //删除最后一个逗号
}
function genChildren(ast) {
    const children = ast.children
    if (children) {
        return children.map(child => gen(child)).join(",")
    }
}
/**
 * @desc: 这是要生成的render函数,预设_c方法:就是创造标签节点,_v方法:创造文本节点 _s转换变量等
 * render(){
 *      return _c('div',{id:'app',style:{padding:'10px'}},_v('姓名:'+_s(information.name))……)
 * }
**/
export function generate(ast) {
    let children = genChildren(ast)
    let code = `_c(${JSON.stringify(ast.tag)},${ast.attrs.length ? genProps(ast.attrs) : 'undefined'}${children ? (',' + children) : ''})`
    console.log(code);
    return code
}
复制代码

    上述代码中,有个gen()方法,gen()方法中,主要麻烦的一点就是对花括号的处理。使用 defaultTagRE.exec(text)进行花括号内容的循环匹配,目的是将花括号替换成_s方法,并将前后字符串进行拼接。比如:这是一段字符串:我的名字是{{name}},今年{{age}}岁,最终解析成:这是一段字符串:我的名字是_s(name),今年_s(age)岁

    需要注意的是,lastIndex问题

image.png

    最终解析出来的字符串为:

image.png

    将生成的字符串生成一个render函数

image.png

    这里使用了with方法,with方法可以改变作用域,这里,根据上文中$mount方法,可以预知,this最理想的指向就是Vue实例,这样就可以拿到data等其中的变量,所以,自然而然,_c、_v、_s等方法都应该挂载在Vue原型上。上述步骤结束以后,我们就得到一个新鲜的render函数:

(function anonymous(
) {
with(this){return _c("div",{id:"app",class:"xxx"},_v("\n        人员信息\n        "),_c("ul",{style:{"color":"red","padding":"10px"}},_v("\n            "),_c("li",undefined,_v("姓名:"+_s(information.name))),_v("\n            "),_c("li",undefined,_v("年龄:"+_s(information.age))),_v("\n            "),_c("li",undefined,_v("职位:"+_s(information.job))),_v("\n        ")),_v("\n    "))}
})
复制代码

2.4 将render函数解析成虚拟dom

    生成render之后,就是将render转换成虚拟dom-->转成真实节点,然后挂载到el中去。

image.png

    我们导入mountComponent方法,并在原型上挂载_update方法(Vue中最重要的两个函数就是_update和_render)。mountComponent如下:

import {patch } from "./vdom/patch"
export function  lifecycleMixin(Vue) {
    Vue.prototype._update=function (vnode) {
        let vm=this
        patch(vm.$el,vnode)
    }
}
export function  mountComponent(vm,el) {
    // 生成真实的dom,放进el中
    // 先调用render方法创建虚拟节点,再将虚拟节点渲染到页面上
    vm._update(vm._render())
}
复制代码

    patch方法是打补丁,将节点渲染到页面中。而在此之前,我们要生成虚拟dom。让我们看下_render方法和_s、_c、_v等方法吧。

export function renderMixin(Vue) {
        // 创建虚拟dom节点
        Vue.prototype._c = function () {
                return createElement(...arguments)
        }
        // 创建虚拟dom文本节点
        Vue.prototype._v = function (text) {
                return createTextElement(text)
        }
        // 处理花括号中的变量
        Vue.prototype._s = function (val) {
                return val === null ? '' : (typeof val === 'object') ? JSON.stringify(val) : val
        }
        // 调用的就是Vue中的render方法
        Vue.prototype._render = function () {
                const vm = this;
                const render = vm.$options.render
                const vnode = render.call(vm)
                console.log(vnode);
                // debugger
                return vnode
        }
}
// _c('div',{id:'app',style:{padding:'10px'}},child1,child2……)   
function createElement(tag, data = {}, ...children) {       //data是属性
        return vnode(tag, data, data.key, children)
}
function createTextElement(text) {
        return vnode(undefined, undefined, undefined, undefined, text)
}
// 生成虚拟node
function vnode(tag, data, key, children, text) {
        return {
                tag,
                data,
                key,
                children,
                text
        }
}
复制代码

    可以看出_render方法其实就是咱们之前生成的render。这里call(vm),劫持了render中的this,再通过_c、_v、_s等方法,生成虚拟dom。

image.png

2.5 将虚拟dom解析成真实节点

    终于来到了最后一步,生成真实节点。vue中利用patch方法,生成真实节点,并将原节点进行一波替换。这里简写下:真实的patch并不是这样的哈,但原理类似。

export function patch(oldVnode,vnode) {
    // 将虚拟节点转换为真实节点
    let el=createElement(vnode) //真实节点
    // 获取el的父节点
    let parentEle=oldVnode.parentNode
    // 当前元素插入app的后边
    parentEle.insertBefore(el,oldVnode.nextSibling);
    // 删除老的节点
    parentEle.removeChild(oldVnode)
}
function createElement(vnode) {
    let {tag,children,key,data={},text}=vnode
    if(typeof tag ==='string'){ //说明是标签,则创建标签
        vnode.el=document.createElement(tag)
        // 设置属性源码中有单独的方法,这里只简单考虑下style和类名id等
        for(let key in data){
            if(key==='style'){
                let str=''
                for(let styleItem in data[key]){
                    str+=`${styleItem}:${data[key][styleItem]};`
                }
                vnode.el.setAttribute(key,str)
            }else{
                vnode.el.setAttribute(key,data[key])
            }
        }
        // 递归渲染子节点
        children.forEach(item=>{
            vnode.el.appendChild(createElement(item))
        })
    }else{  //文本节点
        vnode.el=document.createTextNode(text)
    }
    return vnode.el
}

复制代码

    步骤很简单,就是创建节点-->找到父节点-->将新节点插入原节点后边-->再删除原节点。而且,咱们也真正实现了效果。

image.png

3. 总结

    咱们的编译流程就是:初始化数据-->模板编译-->生成code字符串-->转换为render函数-->生成虚拟dom-->生成真实节点并进行节点替换。

    你学废了吗?

image.png

参考资料:

Guess you like

Origin juejin.im/post/7053976882722635813