Vue 源码初探(二)模板编译

485 阅读3分钟

思维导图

Vue源码初探.png

前言

上一篇文章里面主要写了Vue的数据响应式原理是怎么实现的,涉及对象的数据劫持数组的变异方法的重写、以及data下面数据的便捷取值Vue 源码初探(一)响应式原理

这一篇我们主要讲Vue的模板编译原理,首先我们来回顾一下,

var vm = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!',
    arr: [1, 2, 3]
  },
  template: '<div id="app">{{message}}</div>'
})
console.log(vm.$options.render)

我们来看一下打印出来的结果,这块引用的是官方的Vue代码。

image.png

这么来说我们写在options里面的templateVue转换成了这样的一个render函数了,并且还可以通过这个函数,把页面渲染出来。现在我们回头来想一想,我们在template里面的写的v-if{{}}v-for、等等一系列的指令,都是原生html所不具备的,正式为了让原生html的功能更加强大,Vue内部需要对template里面的代码进行对应的处理,所以才会有了模板编译的这个过程。

image.png

贴上这张图呢,是要表达本篇文章的主要目的,搞清楚Vue内部是怎样把template字符串变成一个render函数的。

正文

1、模板编译函数入口

import { compileToFunction } from "./compiler"
import { initState } from "./state"

export function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    //这里的 this 其实就是外面new 出来的实例
    const vm = this
    //把用户的选项放到vm 上,这样在其他的方法中都可以获取到了
    vm.$options = options  //为了后续方法,都可以获取到$options选项
    //options中包含了很多的选项  el data props 
    initState(vm)

    //模板编译入口
    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
    //用户没有传递render函数
    if(!options.render){
      //用户没有传递template字符串
      let template = options.template
      if(!template && el) {
        template = el.outerHTML
      }
      //获取用户传递的el元素的html当做template 进行编译成render函数
      let render = compileToFunction(template)
      //把render函数放在 options上面放一份。
      options.render = render
    }
  }
}

因为生成render函数,之前我们先要得到template解析生成的ast代码。

2、模板编译核心入口

import { generate } from "./generate";
import { parserHTML } from "./parser";


export function compileToFunction(template) {

  //将模板变成ast
  let ast = parserHTML(template)

  //代码优化,静态节点标记

  //代码生成
  let code = generate(ast)

  let render = new Function(`with(this){return ${code}}`);

  console.log(render.toString())
}

3、template转换成ast

const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; // 匹配标签名的  aa-xxx
const qnameCapture = `((?:${ncname}\\:)?${ncname})`; //  aa:aa-xxx  
const startTagOpen = new RegExp(`^<${qnameCapture}`); //  此正则可以匹配到标签名 匹配到结果的第一个(索引第一个) [1]
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾的 </div>  [1]
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性的

// [1]属性的key   [3] || [4] ||[5] 属性的值  a=1  a='1'  a=""
const startTagClose = /^\s*(\/?)>/; // 匹配标签结束的  />    > 
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // {{   xxx  }}  

// vue3的编译原理比vue2里好很多,没有这么多正则了

export function parserHTML(html) {
  // 可以不停的截取模板,直到把模板全部解析完毕 
  let stack = [];
  let root = null;
  // 我要构建父子关系  
  function createASTElement(tag, attrs, parent = null) {
    return {
      tag,
      type: 1, // 元素
      children: [],
      parent,
      attrs
    }
  }
  function start(tag, attrs) { // [div,p]
    // 遇到开始标签 就取栈中的最后一个作为父节点
    let parent = stack[stack.length - 1];
    let element = createASTElement(tag, attrs, parent);
    if (root == null) { // 说明当前节点就是根节点
      root = element
    }
    if (parent) {
      element.parent = parent; // 跟新p的parent属性 指向parent
      parent.children.push(element);
    }
    stack.push(element)
  }
  function end(tagName) {
    //匹配到结束标签,就从栈中移除
    let endTag = stack.pop();
    if (endTag.tag != tagName) {
      console.log('标签出错')
    }
  }
  function text(chars) {
    let parent = stack[stack.length - 1];
    chars = chars.replace(/\s/g, "");
    if (chars) {
      parent.children.push({
        type: 2,
        text: chars
      })
    }
  }
  function advance(len) {
    html = html.substring(len);
  }
  function parseStartTag() {
    const start = html.match(startTagOpen);  // 4.30 继续
    if (start) {
      const match = {
        tagName: start[1],
        attrs: []
      }
      advance(start[0].length);
      let end;
      let attr;
      while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) { // 1要有属性 2,不能为开始的结束标签 <div>
        match.attrs.push({ name: attr[1], value: attr[3] || attr[4] || attr[5] });
        advance(attr[0].length);
      } // <div id="app" a=1 b=2 >
      if (end) {
        advance(end[0].length);
      }
      return match;
    }
    return false;
  }
  while (html) {
    // 解析标签和文本   
    let index = html.indexOf('<');
    if (index == 0) {
      // 解析开始标签 并且把属性也解析出来  </div>
      const startTagMatch = parseStartTag()
      if (startTagMatch) { // 开始标签
        start(startTagMatch.tagName, startTagMatch.attrs);
        continue;
      }
      let endTagMatch;
      if (endTagMatch = html.match(endTag)) { // 结束标签
        end(endTagMatch[1]);
        advance(endTagMatch[0].length);
        continue;
      }
    }
    // 文本
    if (index > 0) { // 文本
      let chars = html.substring(0, index) //<div></div>
      text(chars);
      advance(chars.length)
    }
  }
  return root;
}

小结:利用正则表达式,匹配开始标签和结束标签,并且用栈来记录匹配到的标签的ast结构,当遇到开始标签的时候将ast节点推入栈中,取栈顶上面的最后一个元素作为要加入元素节点的父节点,当匹配到结束标签的时候,就把当前标签节点从栈中移除。并通过advance不断截取匹配字符串,直到字符串全部解析完毕。

整体来说这块的代码所做的事情就是将template转化成一个js对象

<div id="app">
    {{message}}
</div>

转化结果如下。

{
    "tag": "div",
    "type": 1,   //元素节点
    "children": [
        {
            "type": 2,  //文本节点
            "text": "{{message}}"
        }
    ],
    "parent": null,
    "attrs": [
        {
            "name": "id",
            "value": "app"
        }
    ]
}

现在可以先到# Vue Template Explorer这个网站上面大概了解一下最终要生成的render函数长什么样子。

4、通过ast生成render函数

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // {{   xxx  }}  

function genProps(attrs) {
  // {key:value,key:value,}
  let str = '';
  for (let i = 0; i < attrs.length; i++) {
    let attr = attrs[i];
    if (attr.name === 'style') { // {name:id,value:'app'}
      let styles = {}
      attr.value.replace(/([^;:]+):([^;:]+)/g, function () {
        styles[arguments[1]] = arguments[2];
      })
      attr.value = styles
    }
    str += `${attr.name}:${JSON.stringify(attr.value)},`
  }
  return `{${str.slice(0, -1)}}`
}

function gen(el) {
  if (el.type == 1) {
    return generate(el); // 如果是元素就递归的生成
  } else {
    let text = el.text; // {{}}
    if (!defaultTagRE.test(text)) return `_v('${text}')`; // 说明就是普通文本

    // 说明有表达式 我需要 做一个表达式和普通值的拼接 ['aaaa',_s(name),'bbb'].join('+)
    // _v('aaaa'+_s(name) + 'bbb')
    let lastIndex = defaultTagRE.lastIndex = 0;
    let tokens = []; // <div> aaa{{bbb}} aaa </div>
    let match;

    // ,每次匹配的时候 lastIndex 会自动向后移动
    while (match = defaultTagRE.exec(text)) { // 如果正则 + g 配合exec 就会有一个问题 lastIndex的问题
      let index = match.index;
      if (index > lastIndex) {
        tokens.push(JSON.stringify(text.slice(lastIndex, index)));
      }
      tokens.push(`_s(${match[1].trim()})`);
      lastIndex = index + match[0].length;
    }
    if (lastIndex < text.length) {
      tokens.push(JSON.stringify(text.slice(lastIndex)));
    }
    return `_v(${tokens.join('+')})`; // webpack 源码 css-loader  图片处理
  }
}

function genChildren(el) {
  let children = el.children;
  if (children) {
    return children.map(item => gen(item)).join(',')
  }
  return false;
}

// _c(div,{},c1,c2,c3,c4)
export function generate(ast) {
  let children = genChildren(ast)
  let code = `_c('${ast.tag}',${ast.attrs.length ? genProps(ast.attrs) : 'undefined'
    }${children ? `,${children}` : ''
    })`
  return code;
}

总结

其实这一节,只要记住几个重要的步骤就可以了,至于内部的细节,可以有时间了在看相应内部的细节。

  1. template 转换成ats。
  2. ast 编译成render函数。

系列文章链接(持续更新中...)

Vue 源码初探(一)响应式原理

Vue 源码初探(二)模板编译

Vue 源码初探(三)单组件挂载(渲染)

Vue 源码初探(四)依赖(对象)收集的过程

Vue 源码初探(五)对象异步更新nextTick()