Vue2源码学习 - 3.解析模板参数,实现模板转化成ast语法树,代码生成实现原理

目录

1,解析模板参数

2,实现模板转化成ast语法树

3,代码生成实现原理

4,开始准备执行render函数


1,解析模板参数

我们先增加一个模板,想在页面取值可以用{{ }},但是要对这个模板进行编译,所以新增一个el属性,我们要将数据 解析到el元素上。

    <div id="app">
        <div>{{name}}</div>
        <span> {{age}} </span>
    </div>
    <script src="vue.js"></script>
    <script>
        const vm = new Vue({
            data:{   //代理数据
                    name:'zf',
                    age:20,
                    address:{
                        num:30,
                        content:'回龙观'
                    },
                    hobby:['eat','drink',{a:1}]
            },
            template:'<div>hello</div>'
            // el:'#app',  //我们要将数据 解析到el元素上,也可以不写el,手动挂载vm.$mount('#app');   
            
        });
        vm.$mount('#app'); 
     <script>

指向一个元素后,现在要做的就是将模板里的name和age进行数据的替换

1,模板引擎,就是每次把模板拿到,用数据来替换。这样操作性能差,需要正则匹配替换,  Vue1.0 没有引入虚拟DOM的改变。 

2,采用虚拟DOM,数据变化后比较虚拟DOM的差异,最后更新需要更新的地方。

3,核心就是我们需要将模板变成我们的js语法,通过js语法生成虚拟DOM

我们需要先变成语法树再重新组装代码成为新的语法,我们的目标就是将template语法转换成render函数。

状态初始完,要看用户有没有给一个el属性,如果有,就要去挂载应用,调用vm.$mount。

init.js

        // 初始化状态 props,data,computed....
        initState(vm); 

        if(options.el){
            vm.$mount(options.el);  //实现数据的挂载
        }

接着就给Vue.prototype写方法。要先获取el元素,先看下用户有没有写render,没有的话是否在vm.$options里写了template,没有的话就用外面的模板。最后再判断一下,有模板再去进行模板编译。新建compiler文件夹,把compileToFunction方法写在index.js里。

Vue.prototype.$mount = function(el){
        const vm = this ;
        el = document.querySelector(el);
        let ops = vm.$options;
        if(!ops.render){   //先进行查找有没有render函数
            let template;   //没有render看一下是否写了template,没写template采用外部的template
            if(!ops.template && el ){   //没有写模板,但是写了el
                template = el.outerHTML
            }else{
                if(el){
                    template = ops.template   //如果有el 则采用模板的内容
                }
            }
            //写了template 就用写了的template
            // console.log(template)
            if(template){
                //这里需要对模板编译成render函数
                const render = compileToFunction(template);
                ops.render = render;   //jsx最终会被编译成h('xxx')
            }
        }
        ops.render;  //最终就可以获取render方法
    }

扩展:安装插件npm install @rollup/plugin-node-resolve,可以在引入方法的时候不用在文件后面补充index。然后在rollup.config.js 更改配置。

import resolve from '@rollup/plugin-node-resolve'

plugins:[
        babel({
            exclude: 'node_modules/**'  
        }),
        resolve()
    ]

2,实现模板转化成ast语法树

接下来就要对模板进行编译,如何处理:

1,就是将template转化成ast语法树

2,生成render方法(render方法执行后的返回结果就是虚拟DOM)

这里我们需要拿到一个ast,写一个方法parseHTML,来解析HTML,把template传进来,它就会返回一个语法树。每解析一个标签,就把它从字符串里删掉,直到字符串都被截取完。

先在compiler  index.js里匹配正则

const ncname = `[a-zA-Z][\\-\\.0-9_a-zA-Z]*`;
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 他匹配到的分组是一个 标签名  <xxx 匹配到的是开始 标签的名字
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`);  // 匹配的是</xxxx>  最终匹配到的分组就是结束标签的名字
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;  // 匹配属性   color = "xxx" a='bb'  c=d
// 第一个分组就是属性的key value 就是 分组3/分组4/分组五
const startTagClose = /^\s*(\/?)>/;  // <div> <br/>
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // {{ asdsadsa }}  中间可以换行回车,匹配到的内容就是我们表达式的变量 

由于html最开始肯定是一个<,因为Vue要求模板不能是字符串(Vue3可以在template里直接写字符串,hello</div>)。先判断如果是 < 且索引是0的话,则可能是开始标签或者结束标签。假设是开始标签,写个方法parseStartTag解析这个开始标签。用html去匹配正则看是否是开始标签,如果匹配到了这个标签,则是开始标签,如果不是,return false。这里就把结果组成一个对象match,里面把当前的标签名放进去,还需要标签属性,这个要等下一次匹配。另外还要写一个截取的方法advance,把刚刚匹配成功的部分删除掉。

接下来就是匹配属性,这里要考虑的是,在匹配属性的过程中,只要不是开始标签的结束,就一直匹配。所以可以加一个while循环,每次匹配同时也希望把这个属性保留起来,之后再用advance截取删除。

到最后会剩一个开始标签的结束>,定义变量end来接收这个>,也要把它截取删除。这里输出match,可以看到到这就是一个开始标签的解析。最后就会返回一个解析到的结果,就可以拿着这个结果startTagMatch接着处理文本和内容。

function parseHTML(html) {   //html最开始肯定是一个<
    //截取匹配成功的部分
    function advance(n) {
        html = html.substring(n);
    }
    function parseStartTag() {
        const start = html.match(startTagOpen);
        // console.log(start)   ---0: "<div"   1: "div"
        if (start) {
            const match = {
                tagName: start[1],   //标签名(分组就是标签名)
                attrs: []   //还需要有标签的属性
            }
            advance(start[0].length);

            let attr, end   //end就是>
            while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
                advance(attr[0].length); 
                // console.log(attr)  0:' id="app"', 1:'id', 2:'=', 3:'app',   
                match.attrs.push({name:attr[1],value:attr[3] || attr[4] || attr[5] ||true})
            }
            if (end) {
                advance(end[0].length)
            }
        }
        return false;  //不是开始标签
    };
    while (html) {
        // 如果textEnd 为0,那说明是一个开始标签或者结束标签   hello</div>
        // 如果textEnd >0,说明就是文本的结束位置
        let textEnd = html.indexOf('<'); 

        if (textEnd == 0) {    
            const startTagMatch =  parseStartTag();   //开始标签的匹配结果
            if(startTagMatch){   //解析到的开始标签
                continue
            }
        }
    }
}

 

如果开始标签startTagMatch有值,就不用走循环,直接跳过本轮操作。见上图,有可能开始标签截取之后还会有开始标签,但总有一天开始标签会被全部截掉到达文本位置。当textEnd > 0时,就代表标签前面有文本,就从0截取到textEnd,这部分就是文本内容,删除。下图打印输出的部分,截取的文本内容是空格和换行。

等到最后就是结束标签。按照一开始关于textEnd = 0即最前面是<的判断,已经排除是开始标签,接下来就是结束标签,则直接用html匹配正则后返回结束标签名,截取删除,将判断写在开始标签下面。

整个思路就是遇到开始标签处理开始标签,遇到文本处理文本,遇到结束标签处理结束标签。最后打印html则为空。解析开始标签后跳过本地循环,原因是开始标签后可能就跟着结束标签,所以重新走while循环就好。假设<div></div>,解析完开始标签,重新再解析结束标签就好,不需要走textEnd > 0 的代码。

while (html) {
        let textEnd = html.indexOf('<'); 

        if (textEnd == 0) {    
            const startTagMatch =  parseStartTag();   //开始标签的匹配结果
            if(startTagMatch){   //解析到的开始标签
                continue
            }
            let endTagMatch = html.match(endTag);
            if(endTagMatch){   //结束标签
                advance(endTagMatch[0].length);
                continue;
            }
        }
        if(textEnd > 0){   //有文本
            let text = html.substring(0,textEnd);    //从0截取到textEnd文本内容 
            if(text){
                advance(text.length);//解析到的文本
                console.log(html)
            }
        }
    }
    console.log(html)

目前到这里只把字符串删掉,没有替换文本,我们期望的是把文本稍作处理。比如匹配好开始标签,文本和结束标签,那就在外面写三个方法start,chars和end暴露出去,每次调用的时候,会把对应的内容传给方法,调用对应的方法即可。

由于最终需要转化成一棵抽象语法树,这里可以搞一个栈型结构。把div标签放进来,[div],之后当匹配到下一个div的时候,就可以知道第二个div是栈中最后一个的孩子,继续把第二个div放进去 [div div],当匹配到结束标签的时候,再把儿子抽取出来 [div],假设span中还有a标签,则是 [div span a],解析a的时候就知道a是span的孩子,就是匹配括号的算法题。简而言之,栈中的最后一个元素是当前匹配到开始标签的父亲。

<div id="app">
    <div style="color: red">{{name}}hello</div>
    <span> {{age}} </span>
</div>

这里主要有两种类型,一种是文本,一种是元素,先定义两个变量ELEMENT_TYPE和TEXT_TYPE,再定义栈来存放元素,而且还需要一个指针currentParent来指向栈中的最后一个。接着开始进行,遇到开始标签的时候,就创建一个ast元素。这里写一个方法createASTElement,当遇到开始标签时,就调这个方法,即start方法里调用这个方法,这样就产生了节点。但同时我们需要知道这个是不是根节点,所以还要定义一个属性root。判断如果没有根节点,那这个节点就是根节点。如果currentParent有值,就让当前节点的parent为currentParent,并且给children赋值。同时放进栈中,并且还要将当前这个人指向栈中的最后一位

    const ELEMENT_TYPE = 1;
    const TEXT_TYPE = 3;
    const stack = [];  //定义栈用于存放元素
    let currentParent;  //指向的是栈中的最后一个
    let root;  //根节点

    //最终需要转化成一棵抽象语法树
    function createASTElement(tag,attrs){
        return{
            tag,
            type:ELEMENT_TYPE,
            children:[],   //开始I标签时还不知道孩子是谁
            attrs,
            parent:null
        }
    }
    function start(tag,attrs){
        let node = createASTElement(tag,attrs);   //创造一个ast节点
        if(!root){   //看一下是否是空树
            root = node;  //如果为空则当前是树的根节点
        }
        if(currentParent){
            node.parent = currentParent;
        }
        stack.push(node);   
        currentParent = node;  //currentParent为栈中的最后一个
    }

处理完开始标签后,接着就是文本,那文本就是当前的孩子,就可以直接拿到currentParent.children,将文本放进去。

function chars(text){  //文本直接放到当前指向的节点中
        text = text.replace(/\s/g,'');  //如果空格超过2就删除2个以上
        text && currentParent.children.push({
            type:TEXT_TYPE,
            text,
            parent:currentParent
        });
    }

如果遇到结束标签,那就直接去除掉最后一个,并且更新currentParent。

 function end(tag){
        let node = stack.pop();   //弹出最后一个,检验标签是否合法
        currentParent = stack[stack.length-1];
    }

 

 代码汇总。在complier文件夹里新建parse.js文件,将所有功能包括正则都放到该文件中。

export function parseHTML(html) {   //html最开始肯定是一个<

    const ELEMENT_TYPE = 1;
    const TEXT_TYPE = 3;
    const stack = [];  //定义栈用于存放元素
    let currentParent;  //指向的是栈中的最后一个
    let root;  //根节点

    //最终需要转化成一棵抽象语法树
    function createASTElement(tag,attrs){
        return{
            tag,
            type:ELEMENT_TYPE,
            children:[],   //开始标签时还不知道孩子是谁
            attrs,
            parent:null
        }
    }
    // 利用栈型结构来构造一棵树
    function start(tag,attrs){
        let node = createASTElement(tag,attrs);   //创造一个ast节点
        if(!root){   //看一下是否是空树
            root = node;  //如果为空则当前是树的根节点
        }
        if(currentParent){
            node.parent = currentParent;   //只赋予了parent属性
            currentParent.children.push(node);  //还需要让父亲记住自己
        }
        stack.push(node);   
        currentParent = node;  //currentParent为栈中的最后一个
    }
    function chars(text){  //文本直接放到当前指向的节点中
        text = text.replace(/\s/g,'');   //如果空格超过2就删除2个以上
        text && currentParent.children.push({
            type:TEXT_TYPE,
            text,
            parent:currentParent
        });
    }
    function end(tag){
        let node = stack.pop();   //弹出最后一个,检验标签是否合法
        currentParent = stack[stack.length-1];
    }
    //截取匹配成功的部分
    function advance(n) {
        html = html.substring(n);
    }
    function parseStartTag() {
        const start = html.match(startTagOpen);
        // console.log(start)   ---0: "<div"   1: "div"
        if (start) {
            const match = {
                tagName: start[1],   //标签名(分组就是标签名)
                attrs: []   //还需要有标签的属性
            }
            advance(start[0].length);
            // console.log(match,html)
            //如果不是开始标签的结束,就一直匹配,且每次匹配同时也希望把这个属性保留起来
            let attr, end   //end就是>
            while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
                advance(attr[0].length); 
                // console.log(attr)  0:' id="app"', 1:'id', 2:'=', 3:'app',   
                match.attrs.push({name:attr[1],value:attr[3] || attr[4] || attr[5] ||true})
            }
            if (end) {
                advance(end[0].length)
            }
            return match;
        }
        // console.log(html)
        return false;  //不是开始标签
    };
    while (html) {
        // 如果textEnd 为0,那说明是一个开始标签或者结束标签   hello</div>
        // 如果textEnd >0,说明就是文本的结束位置
        let textEnd = html.indexOf('<');  //如果indexOf中的索引是0  则说明是个标签,vue3可以不是标签开始,在template里直接写字符串

        if (textEnd == 0) {    
            const startTagMatch =  parseStartTag();   //开始标签的匹配结果
            if(startTagMatch){   //解析到的开始标签
                start(startTagMatch.tagName,startTagMatch.attrs)
                continue
            }
            let endTagMatch = html.match(endTag);
            if(endTagMatch){   //结束标签
                advance(endTagMatch[0].length);
                end(endTagMatch[1]);
                // console.log(endTagMatch)    0: "</div>"  1:div
                continue;
            }
        }
        if(textEnd > 0){   //有文本
            let text = html.substring(0,textEnd);    //从0截取到textEnd文本内容 
            if(text){
                chars(text);
                advance(text.length);//解析到的文本
            }
        }
    }
    return root;
}

3,代码生成实现原理

在complier中心间index,引入parseHTML函数。前面已经将template转为ast语法树了,接下来要做的就是生成render方法。

export function compileToFunction (template){
    //1,就是将template转化成ast语法树
    let ast = parseHTML(template);
    console.log(ast);
    console.log(codegen(ast))

    //2,生成render方法(render方法执行后的返回结果就是虚拟DOM
    // render(){
    //     return _c('div',{id:'app'},_c('div',{style:{color:'red'}},_v(_s(name) + 'hello'),_c('span',undefined,_v(_s(age)))))
    // }

    codegen(ast);
   
}

function codegen(ast){
    let children = genChildern(ast.children);
    let code = (`_c('${ast.tag}',${ast.attrs.length > 0? genProps(ast.attrs) :'null' 
        }${ast.children.length ? `,${children}` : ''
        })`) //最后的结果,里面放一个div

    return code;
}

先匹配属性,写一个genProps方法,这个方法传的参数就是ast语法树里的属性attrs数组,遍历之后拿到每个索引对象的name和value。另外还需要判断attr.name是不是style,因为如果属性是style,里面会有多个属性值。所以这里判断后要进行切割。拼接的时候要在最后加个逗号 , 等到完成最后一个拼接之后再删掉最后一个逗号。

function genProps(attrs){
    let str = ''   //{name:'a',value:'b'}
    for (let i = 0;i<attrs.length;i++){
        let attr = attrs[i];
        if(attr.name === 'style'){
            // {color: 'red', background-color: ' yellow'}  =>   color:red;background:yellow   
            // console.log(typeof(attr.value))
            let obj = {}; 
            attr.value.split(';').forEach(item => {  //qs库
                let [key,value] = item.split(':');
                obj[key] = value;
            });
            attr.value = obj;
        }
        str += `${attr.name}:${JSON.stringify(attr.value)},`  //a:b,c:d
    }
    //由于上面那样拼接最后会多出一个,要删掉
    return `{${str.slice(0,-1)}}`
}

接着判断孩子,如果有孩子,就把孩子映射成一个一个的元素,同时用逗号, 拼接,这里写一个方法gen。先判断孩子是元素还是文本,这里要用到前面定义好的type。

const ELEMENT_TYPE = 1;  //元素类型

const TEXT_TYPE = 3;  //文本类型

如果是文本,还要用到正则,去判断是纯文本还是变量。由于变量还有特殊情况,例如变量之后继续跟着文本和变量,{{name}} hello {{age}},用exec匹配的话,这里只能匹配到第一个name,就终止了,所有这里要把正则的lastIndex重置,且写个循环。这样就可以拿到两个变量各自的索引,第一个变量的索引加上第一个变量的长度,就能拿到中间的文本。

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // {{ asdsadsa }}  中间可以换行回车,匹配到的内容就是我们表达式的变量 
function gen(node){
    // 拿到节点,要先判断是文本还是元素,如果是文本要创建文本,如果是元素就直接生成
    if(node.type === 1){
        // 元素
        return codegen(node);
    }else{
        // 文本
        let text = node.text;
        if(!defaultTagRE.test(text)){
            //纯文本
            return `_v(${JSON.stringify(text)})`
        }else{
            // {{a}}  变量
            let tokens = [];
            let match;
            let lastIndex = 0;
            defaultTagRE.lastIndex = 0; //这里用exec匹配之后需要重置,不然只会匹配一个之后就结束
            while(match = defaultTagRE.exec(text)){   
                let index = match.index;  //匹配的位置
                if(index > lastIndex){
                    tokens.push(JSON.stringify(text.slice(lastIndex,index)))
                }
                // console.log(index)-------0  13
                // console.log(match)-------0:"{{name}}"  1: "name"  groups: undefined   index:0
                tokens.push(`_s(${match[1].trim()})`)  //去掉空格
                lastIndex = index + match[0].length  // {{name}} hello {{age}}   match[0]------{{name}}
            }
            // 假设变量后面还有文本
            if(lastIndex < text.length){
                tokens.push(JSON.stringify(text.slice(lastIndex)))
            }
            return `_v(${tokens.join('+')})`
            console.log(tokens)
        }
    }
}
function genChildern(children){
    return children.map(child => gen(child)).join(',')
    
}

 这样属性和孩子就都匹配完成

 4,开始准备执行render函数

由于name和age等变量是要去vm上去取值,所以用with方便取值,模板引擎的实现原理 就是 with + new Function,这样就生成render函数。

export function compileToFunction (template){
    //1,就是将template转化成ast语法树
    let ast = parseHTML(template);
    console.log(ast);
    codegen(ast);
    console.log(codegen(ast))

    //2,生成render方法(render方法执行后的返回结果就是虚拟DOM
    // render(){
    //     return _c('div',{id:'app'},_c('div',{style:{color:'red'}},_v(_s(name) + 'hello'),_c('span',undefined,_v(_s(age)))))
    // }

    // 模板引擎的实现原理 就是 with + new Function

    let code = codegen(ast);
    code = `with(this){return ${code}}`;

    let render = new Function(code);  //根据代码生成render函数
    console.log(render.toString());

    // function render(
    //     ) {
    //     with(this){return _c('div',{id:"app",style:{"color":"red","background-color":" yellow"}},_c('div',{style:{"color":"red"}},_v(_s(name)+"hello"+_s(age))),_c('span',null,_v("world")))}
    // }

    // render.call(vm);
    return render;
   
}

接下来要调用render才能实现页面渲染,在 init.js 调用新方法mountComponent,传入vm和el,对当前vm的render进行挂载,产生虚拟dom,有了虚拟dom再去渲染el。在src下的index.js 调用initLifeCycle(Vue);新建一个lifeCycle.js写方法mountComponent和initLifeCycle。

5,实现虚拟Dom转化成真实Dom

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值