手写Vue2源码(三)—— 模板编译

698 阅读6分钟

前言

通过手写Vue2源码,更深入了解Vue; 在项目开发过程,一步一步实现Vue核心功能,我会将不同功能放到不同分支,方便查阅; 另外我会编写一些开发文档,阐述编码细节及实现思路; 源码地址:手写Vue2源码

流程分析

  1. vm._init() 中如果存在 vm.$options.el,则需要进行渲染:
Vue.prototype._init = function (options) {
    const vm = this;
    vm.$options = options; // 后面会对options进行扩展
    callHook(vm, "beforeCreate");
    initState(vm);
    callHook(vm, "created");

    if (vm.$options.el) {
      vm.$mount(vm.$options.el);
    }
  };

在Vue中,我们既可以直接写render函数,还可以写template;当写template时,需要进行模板编译,将模板编译成render函数;

所以思考一下,我们的$mount如何实现:

  1. $mount写在Vue原型上,可以通过 vm.$mount() 直接调用
  2. 分情况处理render和template:
    1. 当存在render函数时,直接将render函数生成VNode,转换成真实DOM挂载到页面
    2. 不存在render,且存在template时,将template编译成render函数
    3. 不存在render,也不存在template,直接将template赋值成el元素,再编译成render函数
// src/init.js
Vue.prototype.$mount = function (el) {
    // $mount 由vue实例调用,所以this指向vue实例
    const vm = this;
    const options = vm.$options;
    el = document.querySelector(el);

    /**
     * 1. 把模板转化成render函数
     * 2. 执行render函数,生成VNode
     * 3. 更新时进行diff
     * 4. 产生真实DOM
     */
    // 可以直接在options中写render函数,它的优先级比template高
    if (!options.render) {
      let template = options.template;

      // 如果不存在render和template但是存在el属性,则直接将template赋值为el元素
      if (!template && el) {
        template = el.outerHTML;
      }

      // 最终需要把tempalte模板转化成render函数
      if (template) {
        // 将template转化成render函数
        const render = compileToFunctions(template);
        options.render = render;
      }
    }

    // 调用render方法,渲染成真实DOM
    // 组件挂载方法
    return mountComponent(vm, el);
};

这里面核心的方法就是 mountComponent(vm, el)(将render函数转化成真实DOM,挂载到页面,后续章节再实现) 和 compileToFunctions(template)(即将template编译成render函数)。

compileToFunctions

compileToFunctions一共分成四个步骤:

  1. parse:把template转成AST语法树
  2. optimize:优化静态节点
  3. generate:通过ast,重新生成代码
  4. 通过new Function生成函数
// src/compiler/index.js
import { parse } from "./parse";
import { generate } from "./codegen";
export function compileToFunctions(template) {
  // 1. 把template转成AST语法树;AST用来描述代码本身形成树结构,不仅可以描述html,也能描述css以及js语法
  let ast = parse(template);
  console.log("AST", ast);
  // 2. 优化静态节点
  // 这个有兴趣的可以去看源码  不影响核心功能就不实现了
  //   if (options.optimize !== false) {
  //     optimize(ast, options);
  //   }

  // 3. 通过ast,重新生成代码
  // 我们最后生成的代码需要和render函数一样
  // 类似_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))
  // _c代表创建元素 _v代表创建文本 _s代表文Json.stringify--把对象解析成文本
  let code = generate(ast);
  console.log("code", code);
  
  // 通过new Function生成函数
  // with(this){return code}语法,使得再code中直接通过属性名访问到vm中的属性(this指向vm实例)
  let renderFn = new Function(`with(this){return ${code}}`);
  return renderFn;
}

parse

实现思路:

  1. 将template匹配不同的正则(开始标签正则、结束标签正则、标签关闭正则、标签属性正则等),匹配成功则交由不同别的方法处理(返回tagName、attributes、text等)
  2. 在处理方法handleStartTag中,返回一个描述元素的对象(包含tag、type、children、parent、attrs等属性)将他们push到一个栈;
  3. 在处理方法handleEndTag中,将元素pop出栈,并设置它及上一个元素的parent、children关系;
  4. 在处理方法handleChars中,设置文本为currentParent的children元素;
  5. 解析完一部分,template就截取掉一部分,然后循环继续匹配,直到template为空;
  6. 通过进出栈操作,以及parent、children关系,建立一个树状结构(通过parent、children描述)

具体实现如下:

// src/compiler/parse.js
// 以下为vue源码的正则表达式
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; //匹配标签名;形如 abc-123
const qnameCapture = `((?:${ncname}\\:)?${ncname})`; //匹配特殊标签;形如 abc:234,前面的abc:可有可无;获取标签名;
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 匹配标签开头;形如  <  ;捕获里面的标签名
const startTagClose = /^\s*(\/?)>/; // 匹配标签结尾,形如 >、/>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配结束标签 如 </abc-123> 捕获里面的标签名
const attribute =
  /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性  形如 id="app"

export function parse(template) {
  /**
   * handleStartTag、handleEndTag、handleChars将初始解析的结果,组装成一个树结构。
   * 使用栈结构构建AST树
   */
  let root; // 根节点
  let currentParent; // 下一个子元素的父元素
  let stack = []; // 栈结构;栈中push/pop元素节点,对于文本节点,直接push到currentParent.children即可,不用push到栈中
  // 表示元素和文本的type
  const ELEMENT_TYPE = 1;
  const TEXT_TYPE = 3;

  // 创建AST节点
  function createASTElement(tagName, attrs) {
    return {
      tag: tagName,
      type: ELEMENT_TYPE,
      children: [],
      attrs,
      parent: null,
    };
  }
  // 对开始标签进行处理
  function handleStartTag({ tagName, attrs }) {
    let element = createASTElement(tagName, attrs);
    // 如果没有根元素,则当前元素即为根元素
    if (!root) {
      root = element;
    }
    currentParent = element;
    // 将元素放入栈中
    stack.push(element);
  }
  // 对结束标签进行处理
  function handleEndTag(tagName) {
    // 处理到结束标签时,将该元素从栈中移出
    let element = stack.pop();
    if (element.tag !== tagName) {
        throw new Error('标签名有误')
    }
    // currentParent此时为element的上一个元素
    currentParent = stack[stack.length - 1];
    // 建立parent和children关系
    if (currentParent) {
      element.parent = currentParent;
      currentParent.children.push(element);
    }
  }
  // 对文本进行处理
  function handleChars(text) {
    // 去掉空格
    text = text.replace(/\s/g, "");
    if (text) {
      currentParent.children.push({
        type: TEXT_TYPE,
        text,
      });
    }
  }

  /**
   * 递归解析template,进行初步处理
   * 解析开始标签,将结果{tagName, attrs} 交给 handleStartTag 处理
   * 解析结束标签,将结果 tagName 交给 handleEndTag 处理
   * 解析文本门将结果 text 交给 handleChars 处理
   */
  while (template) {
    // 查找 < 的位置,根据它的位置判断第一个元素是什么标签
    let textEnd = template.indexOf("<");

    // 当第一个元素为 '<' 时,即碰到开始标签/结束标签时
    if (textEnd === 0) {
      // 匹配开始标签<div> 或 <image/>
      const startTagMatch = parseStartTag();
      if (startTagMatch) {
        handleStartTag(startTagMatch);
        continue; // continue 表示跳出本次循环,进入下一次循环
      }

      // 匹配结束标签</div>
      const endTagMatch = template.match(endTag);
      if (endTagMatch) {
        // endTagMatch如果匹配成功,其格式为数组:['</div>', 'div']
        advance(endTagMatch[0].length);
        handleEndTag(endTagMatch[1]);
        continue;
      }
    }

    // 当第一个元素不是'<',即第一个元素是文本时
    let text;
    if (textEnd >= 0) {
      // 获取文本
      text = template.substring(0, textEnd);
    }
    if (text) {
      advance(text.length);
      handleChars(text);
    }
  }

  // 解析开始标签
  function parseStartTag() {
    // 1. 匹配开始标签
    const start = template.match(startTagOpen);
    // start格式为数组,形如 ['<div', 'div'];第二项为标签名
    if (start) {
      const match = {
        tagName: start[1],
        attrs: [],
      };

      //匹配到了开始标签,就把 <tagname 截取掉,往后继续匹配属性
      advance(start[0].length);

      // 2. 开始递归匹配标签属性
      // end代表结束符号 > ;如果匹配成功,格式为:['>', '']
      // attr 表示匹配的属性
      let end, attr;
      // 不是标签结尾,并且能匹配到属性时
      while (
        !(end = template.match(startTagClose)) &&
        (attr = template.match(attribute))
      ) {
        // attr如果匹配成功,也是一个数组,格式为:["class=\"myClass\"", "class", "=", "myClass", undefined, undefined]
        // attr[1]为属性名,attr[3]/attr[4]/attr[5]为属性值,取决于属性定义是双引号/单引号/无引号

        // 匹配成功一个属性,就在template上截取掉该属性,继续往后匹配
        advance(attr[0].length);
        attr = {
          name: attr[1],
          value: attr[3] || attr[4] || attr[5], //这里是因为正则捕获支持双引号() 单引号 和无引号的属性值
        };
        match.attrs.push(attr);
      }

      // 3. 匹配到开始标签结尾
      if (end) {
        //   代表一个标签匹配到结束的>了 代表开始标签解析完毕
        advance(1);
        return match;
      }
    }
  }

  // 截取template字符串 每次匹配到了就【往前继续匹配】
  function advance(n) {
    template = template.substring(n);
  }

  // 返回生成的ast;root包含整个树状结构信息
  return root;
}

generate

思考一下如何将ast树转化成render函数:

  • render函数的格式:_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))));其中_c是创建元素,_v是创建文本,_s是创建字符串,所以我们需要实现这三个方法,否则在执行render函数的时候会报错;它们都是可以直接通过实例调用的,则直接在Vue原型上挂载这三个方法即可;(在后续章节实现)
  • 考虑到元素节点的子元素可能依然是一个元素节点,所以需要递归调用generate(),需要把generate()设置成一个入口函数,children的生成用外部方法getChildren(children)
  • 一个元素的子节点可能不止一个,需要对children进行遍历,使用gen(child)生成每一个子元素。
  • 在生成children时需要分类型;当child为文本时,创建文本节点;当child是元素时,递归调用generate()

具体实现:

// src/compiler/

import { ELEMENT_TYPE } from './parse'

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // 匹配花括号 {{  }};捕获花括号里面的内容

function gen(node) {
  // 1. 如果是元素,则递归调用generate
  if (node.type === ELEMENT_TYPE) {
    return generate(node) // 【关键】递归创建元素
  } else {
    // 2. 如果是文本
    let text = node.text

    // 2.1. 如果text中不含花括号变量表达式
    if (!defaultTagRE.test(text)) {
      // _v表示创建文本
      return `_v(${JSON.stringify(text)})`
    }

    // 正则是全局模式,每次需要重置正则的lastIndex属性
    let lastIndex = (defaultTagRE.lastIndex = 0)
    let tokens = [] // 存放解析的文本
    let match, index

    // 2.2. 如果text中存在花括号变量
    while ((match = defaultTagRE.exec(text))) {
      // match如果匹配成功,其结构为:['{{myValue}}', 'myValue', index: indexof({) ]
      // match.index值为indexof({),表示匹配到的位置
      index = match.index

      // 2.2.1 初始 lastIndex 为0,index > lastIndex 表示在 {{ 前有普通文本
      if (index > lastIndex) {
        // 在tokens里面放入 {{ 之前的普通文本
        tokens.push(JSON.stringify(text.slice(lastIndex, index)))
      }
      // 2.2.2 tokens中放入捕获到的变量内容
      tokens.push(`_s(${match[1].trim()})`)
      // 匹配指针后移,移到 }} 后面
      lastIndex = index + match[0].length
    }

    // 2.2.3. 匹配完了花括号,text里面还有剩余的普通文本,那么继续push
    if (lastIndex < text.length) {
      tokens.push(JSON.stringify(text.slice(lastIndex)))
    }

    // _v表示创建文本
    return `_v(${tokens.join('+')})`
  }
}

// 处理attrs/props属性,将[{name: 'class', value: 'home'}, {name: 'style', value: "font-size:12px;color:red"}]
// 转化成 "class:"home",style:{"font-size":"12px","color":"red"}"
function genProps(attrs) {
  let str = ''
  for (let i = 0; i < attrs.length; i++) {
    let attr = attrs[i]
    // 对attrs属性里面的style做特殊处理
    if (attr.name === 'style') {
      let obj = {}
      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)}}`
}

// 生成子节点:遍历children,调用gen(item),使用逗号拼接每一项的结果
function getChildren(ast) {
  const children = ast.children
  if (children) {
    return `${children.map((c) => gen(c)).join(',')}`
  }
}

// 将AST转化成字符串形式的render函数
export function generate(ast) {
  let children = getChildren(ast)

  let code = `_c('${ast.tag}',${ast.attrs.length ? `${genProps(ast.attrs)}` : 'undefined'}${children ? `,${children}` : ''})`

  return code
}

将generate生成的code转化成函数

思考一下如何转化成函数:

  • 使用new Function()
  • 在code中有很多vue实例中的属性及方法,还有渲染方法_c_v_s(定义在vue实例原型上),我们需要绑定它们的this到vue实例;并且还要省略 this.xxx 前的 this ,直接访问vm属性。

具体实现:

// src/compiler/index.js
let renderFn = new Function(`with(this){return ${code}}`);

系列文章