【Vue源码初探】二.模板编译

二.模板编译


在上一节中,我们已经实现了初始化数据,并且对数据进行劫持,现在需要将数据渲染到页面上,即实现模板编译。

首先,我们在测试的index.html中增加一些模板。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <--!-->增加一个模板</-->
  <div id="app" style="color:aqua; background: yellow">
    <div style="color: red">{
   
   { firstname }}</div>
  </div>
  <script src="vue.js"></script>
  <script>
    const vm = new Vue({
      
      
      data: {
      
       
        firstname: '珠',
        lastname: '峰'
      },
      el: '#app', //将数据解析到el元素上
    })
    console.log(vm)
  </script>
</body>
</html>

然后在init.js文件中实现挂载el:$mount

import {
    
     initState } from './state'
import {
    
     compileToFunction } from './compiler/index'

export function initMixin(Vue) {
    
     //就是给Vue增加init方法的
  Vue.prototype._init = function(options){
    
     //用于初始化话操作
    // vm.$options 就是获取用户的配置

    const vm = this;
    vm.$options = options; //将用户的选项挂载到实例上

    //初始化状态:就是挂载属性,方法,计算属性...
    initState(vm);

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

  Vue.prototype.$mount = function(el) {
    
    
    const vm = this;
    el = document.querySelector(el);
    let ops = vm.$options;

    //优先级:render > template > el
    //如果没有render
    if(!ops.render) {
    
     //先进行查找有没有render函数
      let template; //没有render查看是否写了template,没有写template就用外部的template
      if(!ops.template && el) {
    
     //没有写模板,但是写了 el
        template = el.outerHTML;
      }else {
    
     //如果没有模板
        if(el) {
    
     //如果有el,采用模板的内容
          template = ops.template;
        }
      }
      //写了template,就用写了的template
      if(template && el){
    
    
        //这里需要对模板进行编译
        const render = compileToFunction(template); //根据template生成一个render函数
        ops.render = render; //挂载render属性
      }
    }

    //现在我们就获得了render方法 ops.render
  }
}

我们需要将template编译成render函数


如何转成render函数?

  • 先解析模板(使用正则匹配标签和内容)
  • 生成ast语法树
  • ast语法树生成render函数的类似字符串
  • 将字符串转化成代码

一.解析标签和内容

上面说到,我们需要对模板进行处理来生成一个render函数 -> (compileToFunction函数)

在这里进行实现。

export function compileToFunctions(template){
    
    
  // 1.解析模板,并生成ast语法树
    let ast = parseHTML(template); //解析后返回的是一个ast语法树
  
  // 2.ast语法树生成render函数的类似字符串
  // 3.将字符串转化成代码
    return ast;
}

二.生成ast语法树

parseHTML函数就是用来解析模板(模板解析使用正则匹配标签和内容)。

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"'=<>`]+)))?/; // 匹配属性的,第一个分组就是属性的key, value就是 分组3、分组4、分组5
const startTagClose = /^\s*(\/?)>/; // 匹配开始标签结束的 >
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //匹配的是表达式的变量 {
    
    { name }} 这里会匹配出name

将模板解析成ast语法树(parseHTML函数)

思路:匹配一部分,然后删掉这部分,直到删除完毕,使用while函数

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)
    if(!root){
    
     //看一下是否为空树
      root = node; //如果为空则当前是数的根节点
    }
    if(currentParent){
    
    
      node.parent = currentParent
      currentParent.children.push(node)
    }
    stack.push(node);
    currentParent = node; //currentParent是最近的父节点
  }
  function end(tag){
    
    
    let node = stack.pop();
    currentParent = stack[stack.length - 1]
  }
  function chars(text) {
    
    
    text = text.replace(/\s/g,'') //替换掉空文本
    text && currentParent.children.push({
    
    
      type: TEXT_TYPE,
      text,
      parent: currentParent
    })
  }

	//实现向前走:匹配一部分,然后删掉这部分
  function advance(n){
    
    
    html = html.substring(n);
  }

  //该函数用来匹配开始标签
  function parseStartTag(){
    
    
    const start = html.match(startTagOpen);
    // console.log(start); start是一个数组,打印查看
    if(start) {
    
     //如果是开始标签
      const match = {
    
    
        tagName: start[1], //标签名(其实对于start数组我们可以将值打印出来看)
        attrs: [] //标签属性
      }
      advance(start[0].length); //前进:将已经匹配了的删除
      // console.log(match, html);

      //走到这一步:已经将开始标签匹配完了:就是将 <div 匹配到,接下来该匹配开始标签里面的属性了

      // 如果不是开始标签的结束,说明是属性:就一直匹配下去(一直在匹配属性)
      let attr; //一个变量:存储属性
      let end; //一个变量:存储开始标签的结束> eg: <div id='app'> 这里匹配的是右边的 >
      while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))){
    
    
        advance(attr[0].length) //删掉属性
        match.attrs.push({
    
    name: attr[1], value: attr[3] || attr[4] || attr[5]})
      }
      //删掉开始标签的右>
      if(end){
    
    
        advance(end[0].length)
      }
      return match;
    }
    
    return false; //不是开始标签
  }

  
//-------------------上面都是一些功能函数,下面才是真正的执行流程------------------
  
  
  //使用while循环:一直匹配直到html解析完
  while(html){
    
    
    // 如果textEnd 为0, 说明是一个开始标签或者 是结束标签(如果是结束标签的话,那么就说明结束标签前面的字符全部被匹配完毕,并且也被删除了,因为我们的匹配会将匹配完的地方逐渐删除掉)
    // 如果textEnd 大于0,说明是这样的: hello</div> , 此时还在匹配文本串
    let textEnd = html.indexOf('<'); 

    if(textEnd == 0){
    
     //如果是开始标签
      const startTagMatch = parseStartTag(); //开始标签的匹配结果
      if(startTagMatch){
    
    
        start(startTagMatch.tagName, startTagMatch.attrs)//start,end,chars函数用来处理对应标签的内容
        // 在这里,开始标签已经截取结束了。那么就continue,跳出循环然后继续向后匹配
        continue;
      }

      // 如果不是开始标签,那就是匹配开始标签的结束标签(因为上面直接continue了,所以开始和结束只会匹配一个)
      let endTagMatch = html.match(endTag)
      if(endTagMatch){
    
    
        advance(endTagMatch[0].length)
        end(endTagMatch[1]) 
        continue;
      }
    }

    if(textEnd > 0){
    
     //如果是文本内容
      let text = html.substring(0,textEnd)
      if(text){
    
    
        chars(text)
        advance(text.length)//移出解析到的文本
      }
    }
  }
  // console.log("解析到的ast树:", root)
  return root;
}

此时,我们就得到了一棵ast抽象语法树。

{
    
    
    tag:'div',
    type:1,
    children:[{
    
    tag:'span',type:1,attrs:[],parent:'div对象'}],
    attrs:[{
    
    name:'zf',age:10}],
    parent:null
}

//...

三.生成代码字符串

template转化成render函数的结果应该为:

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

//转化为:
render(){
    
    
   return _c('div',{
    
    style:{
    
    color:'red'}},_v('hello'+_s(name)),_c('span',undefined,''))
}
// _c:创建元素  _v:创建文本  _s: JSON.stringify

上面就是这部分要实现的效果。

ast语法树生成render函数的类似字符串

export function compileToFunction(template) {
    
    
  // 第一步:将template 转换为 ast语法树
  let ast = parseHTML(template)
  console.log('生成的ast树:',ast) //验证ast树

  // 第二步:生成render方法(render方法执行后返回的结果就是 虚拟DOM)
  /**我们最终要转换成的render函数
    render(){
      return _c('div',{style:{color:'red'}},_v('hello'+_s(name)),_c('span',undefined,''))
    }
    _c:创建元素  _v:创建文本  _s: JSON.stringify
   */
  let code = codegen(ast); //生成了render函数的字符串
}

实现ast转化成render字符串 -> codegen(ast)

// codegen函数用来将ast生成render函数(render函数其实就是一个字符串,用with调用来使这个字符串变成函数,通过调用可以生成虚拟DOM)
function codegen(ast){
    
    
  let children = genChildren(ast.children);
  let code = (`_c('${
      
       ast.tag }', ${
      
       ast.attrs.length > 0 ? genProps(ast.attrs) : 'null' }${
      
       ast.children.length ? `,${ 
         children }` : '' })`)
 // 上面分别是自己的标签,属性,孩子(属性和孩子之间的,放在了孩子那里再加)
  return code
}

// genProps函数用来解析属性
function genProps(attrs){
    
    
  let str = '' // {name. value} //str返回所有属性拼接后的字符串
  for(let i=0; i<attrs.length; i++){
    
    
    let attr = attrs[i];
    if(attr.name === 'style'){
    
     //style属性特殊处理,因为要对style属性加一个{},style标签返回的是一个对象
      let obj = {
    
    };
      
      // color:red;background:red => {color:'red'}
      // 每一项用;分割,然后每一项item用:分割出属性和属性名
      //用;分割出来的是一个属性名加属性值
      attr.value.split(';').forEach(item => {
    
    
        let [key, value] = item.split(':')
        obj[key] = value;
      });
      attr.value = obj;
    }
    str += `${
      
      attr.name}:${
      
      JSON.stringify(attr.value)},` //这里是加上每个属性
  }
  return `{
     
     ${
      
      str.slice(0,-1)}}`; //因为每个属性直接都是用 , 隔开,所以最后一个属性多了一个逗号 所以进行截取
  //最终返回的结果样子:{id:"app",style:{"color":"aqua"," background":" yellow"}}
}

//genChildren函数处理孩子
function genChildren(children){
    
    
  return children.map(child => gen(child))
}

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //匹配的是表达式的变量 {
    
    { name }} 这里会匹配出name
function gen(node){
    
    
  if(node.type === 1){
    
     //是节点
    return codegen(node); //继续递归遍历
  } else {
    
     //是文本
    let text = node.text;
    if(!defaultTagRE.test(text)){
    
     //如果没有匹配到双括号的变量,说明是一个纯文本,直接使用JSON.stringify
      return `_v(${
      
      JSON.stringify(text)})`
    } else {
    
     //说明出现了变量:比如是{
    
    {name}}
      //eg: _v(_s(name) + 'hello' + _s(name))
      let tokens = []; //最后匹配到的多个变量
      let match; //每次匹配到的变量
      defaultTagRE.lastIndex = 0; //将lastIndex属性置为0:因为exec匹配只会每次重头开始匹配,我们需要让它继续上一次的位置进行匹配
      let lastIndex = 0;
      while(match = defaultTagRE.exec(text)){
    
     //循环匹配,因为有多个变量
        let index = match.index;
        if(index > lastIndex){
    
     // 这里是用来匹配 {
    
    {name}} hello {
    
    {age}} 中的hello
          tokens.push(JSON.stringify(text.slice(lastIndex,index))) //自己的普通字符串直接stringify就行
        }
        tokens.push(`_s(${
      
      match[1].trim()})`) //对于变量,要用_s包裹
        lastIndex = index + match[0].length //lastIndex每次都要向前进一步:超过当前变量的长度
      }
      if(lastIndex < text.length){
    
     //这里是用来匹配 {
    
    {name}} hello {
    
    {age}} hello 中的第二个hello
        tokens.push(JSON.stringify(text.slice(lastIndex))) //最后出现的普通字符串也是只用stringify就行
      }
      return `_v(${
      
      tokens.join('+')})`
    }
  }
}

四.生成render函数

export function compileToFunction(template) {
    
    
  let code = codegen(ast); //现在已经生成了render函数的字符串,接下来我们需要让字符串可以运行
  
  code = `with(this){return ${
      
      code}}` //包了一层with,那么code中的代码都会去this上取值,那么一会儿我们准备执行的时候,可以绑定个this,这样我们就可以随意的指定要执行的对象了
    //因为我们的render函数上的变量比如name,age都在vm上,所以一会儿我们执行render函数的时候可以设置.call(vm),这样就拿到了vm身上的变量值

  let render = new Function(code);//生成render函数
  /**
   * with是干嘛的?
   * let obj = {a:1}
   * with(obj){
   *  console.log(a)
   * }
   * 对于上面这段代码:在{}里面的代码都会去obj上取值,也就是说:with可以限定取值的对象是谁
   */


  return render;
}

猜你喜欢

转载自blog.csdn.net/weixin_52834435/article/details/127261078