手写简化版Vue(三) 编译器原理

216 阅读10分钟

本系列文章会以实现Vue的各个核心功能为标杆(初始化、相应式、编译、虚拟dom、更新、组件原理等等), 不会去纠结于非重点或者非本次学习目标的细节, 从头开始实现简化版Vue, 但是, 即使是简化, 也需要投入一定的时间和精力去学习, 而不可能毫不费力地学习到相对复杂的知识; 所有简化代码都会附上原版源码的路径, 简化版仅仅实现了基本功能, 如需了解更多细节, 可以去根据源码路径去阅读对应的原版源码;

概述

手写简化版Vue(二) 响应式原理前面我们已经实现了响应式的原理, 在最后, 我们在渲染页面的时候, 用了innerHTML的原升方式来处理, 当然这个肯定是一个临时解决方案, 现实中不可能那么low, 那么现实中又是如何呢? 现实中, 这里应该是执行render方法, 并将虚拟dom转为真实节点, 并挂载到页面上

在编译阶段的过程是:

  1. 先获取到字符串代码, 形如
    123
    这种, 也就是我们在template中写的代码;
  2. 字符串代码 转 ast: 即抽象语法树, 类似于:
{
  "type":1,
  "tag":"div",
  "attrsList":[],
  "attrsMap":{},
  "rawAttrsMap":{},
  	"children":[{"type":3,"text":"123","start":5,"end":8}]
}
  1. ast 转 render: 其实就是一段代码了, 但是是字符串的形式, 最终将被new Function方法转为真实代码并执行
with(this){return _c('div',{},[_v("123")])}
  1. render 转 虚拟dom: 所谓虚拟dom, 也是一组描述节点信息的对象;
  2. 虚拟dom 转 真实dom并渲染

由于这部分内容颇多, 所以将分2节进行讲解, 既然已经知道了大体步骤, 我们就先从ast转换入手吧

Ast

所谓的ast, 就是抽象语法树, 编译原理中的一个基本概念, 那么如何将一段字符串代码转为一个对象呢? 两者之间的差距不可谓不大, 其实, 我们无非就是要将字符串中的关键信息提取出来, 例如最简单的

123
, div是元素节点, 123是文本节点, 这些信息, 必须在ast中体现出来

正则提取

那如何将一段字符串代码中的关键信息提取出来呢? 我们首先想到的, 应该也是正则! 下面, 我们就先研究编译源码中最基础的部分--正则匹配

标签名匹配

标签名匹配较为简单

// 源码地址: /src/compiler/parser/html-parser.ts
const cname = '[a-zA-Z_][\-\.a-zA-Z_0-9]*'
const capturename = `((?:${cname}\:)?${cname})`
const startTagOpen = new RegExp(`^<${capturename}`)

// 来做个简单的测试
let str = '<div>123</div>'
console.log(str.match(startTagOpen))
// ['<div', 'div', index: 0, input: '<div>123</div>', groups: undefined]

属性匹配

注意, 此时我们获取到了div这个标签名了但是为何此处只匹配到<div这部分, 为啥不直接匹配掉

得了? 想想看, <div 后面紧跟着的是什么? 没错, 就是属性! 其实严格来讲就是vue当中的属性/指令/事件等等, 为了方便叙述, 以下只称其为属性; 所以接下来要看下怎么取出属性的信息

// 源码地址: /src/compiler/parser/html-parser.ts
// 在vue源码中, 属性分为动态和静态两部分
// 动态属性正则
const dynamicAttrs = /^\s*((?:v-[\w-]+:|@|:|#)[[^=]+?][^\s"'<>/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 静态属性正则
const staticAttrs = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

正则表达是很长对吧, 不过没关系,我们可以逐步分析, 先从两者相同的部分开始:

公共部分解析

我们的属性通常是name="jack"这种形式 以下为静态/动态属性正则匹配相同部分, 也就是="jack"这部分的匹配

(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?

代码解析:

  1. 这段正则整体是从一个非捕获分组开始, 大体结构为 (?:匹配内容)? ,匹配内容作为一个分组, 但不进行捕获, 该分组出现0或1次(末尾量词'?'), 其实也好理解, 我们写vue的时候, 可以写 也可以直接, 说白了属性值和等号, 可能有, 也可能没有!
  2. 继续往里看, \s*(=)\s*就不解释了, 再往后, 发现里面又有一个非捕获性分组, 结构为(?:情况1|情况2|情况3) 是属性值的匹配
(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+))

可以看出, 这里其实实现一个‘或’的逻辑, 列举了可能的三种情况: "([^"])"+ 或 '([^'])'+ 或 ([^\s"'=<>`]+) , 这三种情况, 注意,这三种情况就是捕获性匹配了, 即 不含‘?:’, 也就是要对其进行捕获的, 这三种情况的具体含义就不再解析了, 相信都能看懂这种基础正则了

公共部分正则整体的匹配如下图:

动态属性部分

相同的部分解析完了, 再来看看动态/静态属性正则的剩余部分, 值得注意的是, 静态和动态的区别, 就在于其等号左侧的部分! 因此, 我们说的非公共部分, 其实就是指等号左侧;

/^\s*((?:v-[\w-]+:|@|:|#)[[^=]+?][^\s"'<>/=]*)

动态属性中, 等号左侧部分存在方括号包裹的变量, 而不是常见的写死的常量, 我们通常在写vue的代码时会这样用

<template>
  <div>
    <input type="text" @[eventName]="handleInput($event.target.value)" 
      :[key]="data">
  </div>
</template>
<script>
export default {
  data () {
    return {
      eventName: 'input',
      key: 'value',
      data: '123123'
    }
  },
  methods: {
    handleInput (value) { // value就是输入框输入的值
      this.data = value
    }
  }
}
</script>

同样, 先从最外层分析, 一个\s*开头, 这里不解析了; 接着是一个普通的分组, 结构: (匹配内容), 而这部分可以继续拆分为2部分:

  1. 第一部分一个是刚才我们介绍过的非捕获性分组或结构: (?:v-[\w-]+:|@|:|#), 也就是这部分有可能是v-xx, 可能是:, 可能是@, 可能是#, 熟悉vue的都很容易理解吧;
  2. 这个结构后面跟着的[[^=]+?] ,即一个方括号, 里面的内容不得为=, 匹配到的数量至少有1个, 注意+?, 这里是一个非贪婪匹配, 也就是匹配到符合条件的字符串就停止, 而不会一直匹配下去.

关于非贪婪匹配, 我们可以看以下案例:

// 此时我们要匹配第一个方括号
let str = '[111][222][333]444555'
let reg = /[[^=]+]/
console.log(str.match(reg), '匹配')

可以看到, 我们本想匹配[111], 结果所有方括号都被匹配到了, 这就是正则'贪婪'的一面; 而如果我们将正则改为/[[^=]+?]/, 即量词+后面加上'?', 匹配的结果就成了:

这部分的匹配如下:

静态属性部分解析

最后, 再来看看静态属性的剩余部分, 即等号左侧部分, 由于静态的属性, 等号左侧都是写死的内容

/^\s*([^\s"'<>/=]+)

这部分可以说是很简单了, 综合一句话就是, 规定了不能包含哪些符号

编译

前面我们介绍了正则, 也就是有了提取标签名和属性的'工具'了, 现在就可以利用这些工具, 来对字符串进行提取了;

下面是源码中, html转ast的逻辑, 主要涉及两个方法: parseHTML 和 parse, 前者负责对字符串进行截取, 后者负责将截取来的字符串, 转为对应的ast, 先来看parseHTML, 我们刚才介绍的正则, 也正是在这部分:

// 源码地址: /src/compiler/parser/html-parser.ts
const cname = '[a-zA-Z_][\-\.a-zA-Z_0-9]*'
const capturename = `((?:${cname}\:)?${cname})`

const startTagOpen = new RegExp(`^<${capturename}`)
// 动态属性正则
const dynamicAttrs = /^\s*((?:v-[\w-]+:|@|:|#)[[^=]+?][^\s"'<>/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 静态方法正则
const staticAttrs = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 匹配起始标签的末端, 形如:  /> 或者 >等
const startTagClose = /^\s*(/?)>/
// 匹配结束标签,形如:  </div>等
const endTag = new RegExp(`^<\/${capturename}[^>]*>`)

// html就是我们写的template中的代码;
export default function parseHTML(html, options) {
  // 当前执行到的位置
  let index = 0
  // isUnaryTag, 判断是否是单标签
  const isUnaryTag = options.isUnaryTag
  // 注意这个while大循环, 这个方法其实就是通过这个大循环, 不断对html字符串进行截取
  // 直至全部被截取殆尽, 截取的策略是从左到右, 一部分一部分截, 并将截取的内容交给不同
  // 的ast转换方法(options.start这种)
  while (html) {
    let textEnd = html.indexOf('<')
    // 如果是<开头, 则可能是一个标签
    if (textEnd === 0) {
      // 是否为起始标签
      let match = parseStartTag()
      if (match) {
        // 将起始标签转为ast
        handleStartTag(match)
        continue
      }
      // 结束标签
      const endTagMatch = html.match(endTag)
      if (endTagMatch) {
        let curIndex = index
        advance(endTagMatch[0].length)
        // 将结束标签转为ast
        parseEndTag(endTagMatch[1], curIndex, index)
        continue
      }
    }
    /**
     * 代码执行到这里, 说明此时的html有4种情况:
     * 1. 类似于: 123</div>
     * 2. 类似于: 123<div>xxx</div>
     * 3. 类似于: <123</div>
     * 4. 类似于: 12<123</div>
     * 说白了, 就是从左起首个<, 可能是节点的一部分, 也可能是文本的一部分的问题;
     * 而这一步, 我们的目的是为了确保textEnd是从左开始, 首个节点的'<'的下标值
     * 这样, 我们就可以通过html.substring(0, textEnd)来拿到真正的文本部分
     */
    let text
    if (textEnd >= 0) {
      /**
       * 注意下面这个rest, 根据上面我们说的4种情况, 我们可以推断出它的值
       * 1. html类似于: 123</div>, 则rest 为 </div>
       * 2. html类似于: 123<div>xxx</div>, 则rest 为 <div>xxx</div>
       * 3. html类似于: <123</div>, 则rest 为 <123</div>
       * 4. html类似于: 12<34<56</div>, 则rest 为 <34<56</div>
       * 说白了, rest此时可能是一个终止节点开头, 也可能是一段文本开头
       */
      let rest = html.slice(textEnd)
      let next
      /**
       * 从while的条件可以看出: 第1,2种情况不会进入循环
       * 只需考虑3,4种情况
       */
      while (!rest.match(startTagOpen) && !rest.match(endTag)) {
        /**
        注意, 这一步很关键, 它将获取此时的html从左起第二个<的位置!
        刚才第一个<不是节点的一部分, 那下一个<呢?就这样不断推进
        */
        next = rest.indexOf('<', 1)
        if (next < 0) break // 如果后续无<了, 直接结束
        // textEnd更新为下一个<的位置
        textEnd += next
        // 此时再更新rest
        // 此时第3种情况都变成了</div>, 说明此时的textEnd已经可以拿到完整的文本部分了, 跳出循环;
        // 此时第4种情况都变成了<56</div>, 成为了之前第三种情况开始的样子, 所以继续while循环, 下一次, 它也能拿到文本并跳出;
        // 以此类推, 无论文本中有多少<都会被最终循环殆尽;
        rest = html.slice(textEnd)
        // 如果还有
      }
      // 这样, 我们就得到了首个节点的<的下标值, 从而获取文本的部分
      text = html.substring(0, textEnd)
    }
    // 文本已经处理完, 从, html中截掉
    if (text) {
      advance(text.length)
    }
    if (options.chars && text) {
      // 转换ast逻辑
      options.chars(text, index - text.length, index)
    }
    // 解析结束的标签
    function parseEndTag (tagName, start, end) {
      // 转换ast逻辑
      options.end(tagName, start, end)
    }
    /**
     * handleStartTag主要做了两件事:
     * 1. 完善属性部分的数据
     * 2. 通过options.start来生成ast
     */
    function handleStartTag (match) {
      const tagName = match.tagName
      const l = match.attrs.length
      const attrs = new Array(l)
      const unary = isUnaryTag(tagName)
      // 处理属性
      for (let i = 0; i < l; i ++) {
        const args = match.attrs[i]
        attrs[i] = {
          name: args[1],
          value: args[3] || args[4] || args[5],
          start: args.start + args[0].match(/^\s*/).length,
          end: args.end
        }
      }
      if (options.start) {
        // 转换ast逻辑
        options.start(tagName, attrs, unary, match.start, match.end)
      }
    }
    // 只做两件事: 更新index, 截掉html已匹配完的部分
    function advance (n) {
      index += n
      html = html.substring(n)
    }
    // 其实做了两件事: 1. 生成match对象; 2. 搜集属性
    // 就是将一个带了属性的完整标签转换为一个对象
    function parseStartTag () {
      // <div 部分
      const start = html.match(startTagOpen)
      /**
       * start如有值: ['<div', 'div', index: 0, ....], 也就是, 第一项为匹配到的标签
       * 部分, 第二项是本标签的标签名
       */
      let match
      // 如能匹配到
      if (start) {
        match = {
          tagName: start[1], // 标签名
          attrs: [], // 属性, 初始化为空,后续会填充内容
          start: index // 当前匹配的起始位置
        }
        /**
         * 这一步很关键, 将start第一项, 也就是本次匹配到的内容, 从html中截掉
         * 同时增加index的数值
         */
        advance(start[0].length)
        let end, attr
        // 填充属性部分
        while (
          !(end = html.match(startTagClose))
          &&
          ((attr = html.match(dynamicAttrs)) || (attr = html.match(staticAttrs)))) {
          /**
           * 如果html是<div name="jack">123</div>
           * 那么这里的attr的形式大体将是: [' name="jack"', 'name', '=', 'jack', ...]
          */
          attr.start = index // 属性匹配起始部分
          advance(attr[0].length) // 更新index, 并将已经匹配到的部分从html中截掉
          attr.end = index // 属性匹配结束部分
          match.attrs.push(attr) // 将匹配到的属性填充到attrs中
        }
        // 如果此时开头是‘>’或者‘/>’, 则说明属性已经被处理完毕
        if (end) {
          advance(end[0].length)
          /**
           * 设置本节点的结束位置, 注意, 这里的结束位置仅仅是起始标签结束的位置
           * 例如: <div>123</div>中<div>结束的位置, 也就是5(这里的位置是从1开始算起的,不是0)
           */
          match.end = index
        }
        /**
         * 此时的match, 大体像这样:
         * {
         *    tagName: 'div',
         *    start: 0,
         *    end: 5,
         *    attrs: [[' name="jack"', 'name', '=', 'jack', ...]]
         * }
         */
        return match
      }
    }
  }
}

代码小节:

总体上看, 以上将html放入while循环, 将起始节点, 文本节点, 结束节点进行while循环处理, 直到html完全被截取完为止, 接下来, parse方法, 实际上, parseHMTL在parse方法中调用, parse主要负责的逻辑, 其实就是将parseHTML截取出来的一小部分字符串转为ast, 并确定各个ast对象之间的关系

// 源码地址: /src/compiler/parser/index.ts
import parseHTML from './parseHTML.js'
import { parseText } from './text-parser.js'
let currentParent
let root
let stack = []
let dirRE = /^v-|^@|^:|^#/
let bindRE = /^v-bind:|^\.|^:/
let dynamicArgRE = /^[.*]$/
export default function parse (template, options) {
  // 此处执行了我们刚才定义的parseHTML方法
  parseHTML(template, {
    // 是否为单标签
    isUnaryTag: options.isUnaryTag,
    // 元素节点转ast
    start (tagName, attrs, unary, start, end) {
      // 1. 创建ast节点
      const element = createASTElement(tagName, attrs, currentParent)
      // 2. 注意, root有且只有一个, 这也是为啥我们的template里总是有个根节点包裹所有内容
      if (!root) root = element
      /**
        unary, 是否为单标签(比如: input, img等)
        这些节点不存在子节点, 所以不得设置为父节点(currentParent)
      */
      if (!unary) {
        // 设置element为当前的父节点, 这里要记住了
        currentParent = element
        /**
         * 3. 存储stack, 注意了, 此处存入的stack的意义其实是为了记录当前的一个层级
         * 简单来说就是,记录当前的currentParent, 听不懂没关系, 继续往下看
         */
        stack.push(element)
      } else {
        closeElement(element)
      }
    },
    // 文本节点
    chars (text, start, end) {
      const children = currentParent.children
      let child, res
      // 如果是带花括号的, 说明存在一个变量
      if (res = parseText(text)) {
        child = {
          type: 2,
          expression: res.expression,
          tokens: res.tokens
        }
      // 如果没有, 则是一段普通文本
      } else {
        child = {
          type: 3,
          text
        }
      }
      child.start = start
      child.end = end
      children.push(child)
    },
    // 终止节点
    end (tagName, start, end) {
      // 1. 取出栈内末尾元素
      let element = stack[stack.length - 1]
      // 2. 删除栈内末尾元素
      stack.length -= 1
      // 3. 将element的前一个节点作为父节点
      currentParent = stack[stack.length - 1]
      closeElement(element)
    }
  })
  // 返回的root就是最终的ast结构
  return root
}
// 解析节点的属性指令等等
// 并确定上下父子节点关系
function closeElement (el) {
  processAttrs(el)
  if (currentParent) {
    currentParent.children.push(el)
    el.parent = currentParent
  }
}

// 处理静态/动态属性, 这个方法后续还会处理指令等等
function processAttrs (el) {
  const lists = el.attrsList
  let isDynamic, i, l
  for ( i = 0, l = lists.length; i < l; i ++) {
    let name = lists[i].name
    let value = lists[i].value
    // 动态属性
    if (dirRE.test(name)) {
      // v-bind属性部分
      if (bindRE.test(name)) {
        name = name.replace(bindRE, '')
        isDynamic = dynamicArgRE.test(name)
        // 是动态属性
        if (isDynamic) {
          name = name.slice(1, -1)
        }
        addAttrs(el, name, value, lists[i], isDynamic)
      }
    // 非动态属性
    } else {
      addAttrs(el, name, JSON.stringify(value), lists[i])
    }
  }
}
// 为el.attrs赋值
function addAttrs (el, name, value, range, dynamic) {
  // 注意, 这段赋值代码也写得比较巧妙
  const attrs = dynamic ?
    el.dynamicAttrs || (el.dynamicAttrs = []) : el.attrs || (el.attrs = [])
    attrs.push(rangeSetItem({name, value, dynamic}, range))
}

function rangeSetItem (item, range) {
  if (range) {
    if (range.start !== null) {
      item.start = range.start
    }
    if (range.end !== null) {
      item.end = range.end
    }
  }
  return item
}
// 创建AST节点
function createASTElement (tagName, attrs, parent) {
  return {
    type: 1,
    tag: tagName,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs),
    rawAttrsMap: {},
    parent,
    children: []
  }
}

function makeAttrsMap (attrsList) {
  let map = {}
  for (let i = 0, l = attrsList.length; i < l; i++) {
    map[attrsList[i].name] = attrsList[i].value
  }
  return map
}

代码小节

  1. 起始节点部分:
    1. 非单标签节点, 如果是, 设为root, 并将其设为currentParent, 也就是当前的全局父节点, 存入stack;
    2. 单标签节点, 不存入stack, 直接执行closeElement, 提取其属性, 将其存入currentParent.children
  1. 文本节点部分: 不存入stack, 直接存入给currentParent.children
  2. 结束节点: 取出末尾元素element, 将其从stack中(最后一位)删除, 将此时的stack的最后一个元素重新设置为currentParent

我们已经介绍完了2个方法, parseHTML和parse, 前者在后者中调用, 但是我们还有一个options.isUnaryTag方法没有介绍到, 在源码中这个方法的传值过程比较复杂, 在后续会讲解到, 此处仅展示下其逻辑

// 源码地址: /src/platforms/web/compiler/util.ts
import { makeMap } from 'shared/util'
export const isUnaryTag = makeMap(
  'area,base,br,col,embed,frame,hr,img,input,isindex,keygen,' +
    'link,meta,param,source,track,wbr'
)

// 源码地址: /src/shared/util.ts
/**
 *
 * @param {String} str 传入的字符串参数, 如有多个, 以逗号隔开
 * @param {*} expectsLowerCase 是否将key转为小写
 * @returns
 */
export function makeMap(str, expectsLowerCase) {
  const map = Object.create(null)
  const list = str.split(',')
  for (let i = 0; i < list.length; i++) {
    map[list[i]] = true
  }
  return expectsLowerCase ? val => map[val.toLowerCase()] : val => map[val]
}

我们来测试下结果吧

const ast = parse(`<div name="father">123<span>son2</span></div>`, {isUnaryTag: () => {}})
console.log('~ ast输出:', ast)

Render

拿到了ast之后, 接下来, 我们需要将其转换为render函数, 前面已经介绍过了, render函数起始就是一段字符串, 只不过在运行时将其转为真正的一段方法, 然后执行这些方法, 生成真实的dom节点

// 源码地址: /src/compiler/codegen/index.ts
// 生成render的generate方法
export function generate (ast) {
  const code = ast ?
                ast.tag === 'script'
                ? 'null'
                : genElement(ast)
              : '_c("div")'
  return {
    render: `with(this){return ${code}}`
  }
}

// 生成元素
export function genElement (ast) {
  // 生成属性
  let data = genData(ast)
  let code
  let tag
  if (ast.tag) tag = `'${ast.tag}'`
  const children = genChildren(ast)
  code = `_c(${tag}${data ? `,${data}` : ''}${children ? `,${children}`: ''})`
  return code
}

// 提取属性/方法/指令, 此处目前只展示提取属性的操作, 这个方法很重要
function genData (el) {
  let data = '{'

  if (el.attrs) {
    data += `attrs:${genProps(el.attrs)},`
  }

  data = data.replace(/,$/, '') + '}'
  if (el.dynamicAttrs) {
    data = `_b(${data},"${el.tag}",${genProps(el.dynamicAttrs)})`
  }
  return data
}

// 生成属性操作
function genProps (props) {
  let staticProps = ``
  let dynamicProps = ``
  for (let i = 0; i < props.length; i++) {
    const prop = props[i]
    // 动态属性
    if (prop.dynamic) {
      dynamicProps += `${prop.name},${prop.value},`
    // 静态属性
    } else {
      staticProps += `"${prop.name}":${prop.value},`
    }
  }
  // 静态属性以对象形式存在; 动态属性以数组形式存在
  staticProps = `{${staticProps.slice(0, -1)}}`
  if (dynamicProps) {
    return `_d(${staticProps}, [${dynamicProps.slice(0, -1)}])`
  } else {
    return staticProps
  }
}

// 处理节点
function genNode (node) {
  // dom节点
  if (node.type === 1) {
    // 递归
    return genElement(node)
  // 文本节点
  } else if (node.type === 3) {
    return `_v(${JSON.stringify(node.text)})`
  } else {
    return `_v(${node.expression})`
  }
}

// 生成子节点
function genChildren (el) {
  const children = el.children
  let gen = genNode
  return `[${children.map(item => gen(item)).join(',')}]`
}

代码小结:

  1. 总体思路是, 将ast中, 表示节点/属性/子节点的部分转换为对应的字符串方法;
  2. 在genElement中, 使用递归的方式, 将children也进行相应的转换.
  3. 通过with(this){}的调用, 将方法中的变量, 转为this的属性.

再来测试下吧, 接上面的ast例子

const ast = parse(`<div name="father">123<span>son2</span></div>`, {isUnaryTag: () => {}})
const code = generate(ast)
console.log('code:', code.render)

多平台处理

函数式封装

我们已经知道了编译器做了2件事, 字符串 转 ast 和 ast 转 render, 但是Vue当中又是如何将这些逻辑整合为一个完整通用的编译器, 或者说编译方法, 供其他模块引入的呢? 我们先看下Vue源码中, compiler模块的入口文件index.js

// 源码地址: /src/compiler/index.ts
/** 
	这个文件的代码不多, 就 createCompilerCreator接受了一个baseCompiler方法作为入参
  而baseCompiler中又执行了我们前面介绍了ast和render两大过程
*/
import parse from './parse/index.js'
import { generate } from './codegen/index.js'
import { createCompilerCreator } from './create-compiler'

// createCompilerCreator, 接受一个基础编译方法, 而这个方法内, 就包含了我们前面所说的
// 两个步骤
export const createCompiler = createCompilerCreator(
  // 基础编译方法
  function baseCompiler (template, options) {
    const ast = parse(template.trim(), options)
    const code = generate(ast, options)
    return {
      ast,
      render: code.render
    }
  }
)

我们再来看下createCompilerCreator的逻辑

// 源码地址: /src/compiler/create-compiler.ts
import { createCompilerToFunctionFn } from './to-function'
export function createCompilerCreator (baseCompile) {
  return function createCompiler (options){
    function compile (template) {
      const compiled = baseCompile(template, options)
      return compiled
    }
    return {
      compile,
      compileToFunctions: createCompilerToFunctionFn(compile)
    }
  }
}

可以看出, createCompilerCreator并没有将baseCompile简单返回, 而是做了一系列处理:

  1. 返回了一个createCompiler方法(即编译器生成器), 接受options参数;
  2. createCompiler中又定义了一个方法, compile, 接受template参数;
  3. compile 中, 才执行了我们的baseCompile方法, 最终接受了上面的template和options参数

代码小节: 这里其实就是利用了函数式编程中的偏函数思想; 其实说白了就是将一个接受多个参数的函数转为多个接受一个参数的函数, 这里的逻辑本应该是compile(baseCompile, options, template); 那为何要改为多个函数呢? 这么做的好处就在于, 代码更加的灵活了; 比如, 我们在parseHTML方法中, 有一个options.isUnaryTag, 这类方法在不同的平台之下, 可能有不同的逻辑, 如果全部都写死, 那代码的可复用性将大打折扣,而如果采用函数式编程方式, 我们就可以在不同平台的引入部分就直接执行createCompiler, 再传入该平台特有的options, 这样, 就在引入阶段, 生成了一个专属于本平台的编译器; 而其他平台只需要更换options入参, 其他的都能复用;

// 源码地址: /src/compiler/to-function.ts
// 将我们前面baseCompile返回的render字符串, 转为一个方法
export function createCompilerToFunctionFn (compile) {
  return function compilerToFunction (template) {
    const compiled = compile(template)
    let res = {}
    // 注意, 这里将render字符串转为了一个可执行的函数!
    res.render = toFunction(compiled.render)
    return res
  }
}

function toFunction (code) {
  try {
    return new Function(code)
  } catch (e) {}
}

代码小节: createCompilerToFunctionFn同样利用了函数式编程的方式, 在引入编译器阶段, 只是确定了哪个平台的编译器(comiple), 等到真正执行代码的时候, 才会执行, 将render字符串转为可执行的函数

多平台使用

前面我们完成了一个编译器, 现在, 我们要将其引入

// 源码地址: /src/platforms/web/compiler/index.ts
import {baseOptions} from './options'
import { createCompiler } from '../../../compiler/index'
// 这里, 为createCompiler传入了专属于web平台的baseOptions
export const { compileToFunctions } = createCompiler(baseOptions)

// 源码地址: /src/platforms/web/compiler/options.ts
import { isUnaryTag } from './util'
export const baseOptions = {
  isUnaryTag
}

这里我们执行了createCompiler, 并传入了属于本平台的baseOptions, 这也是前面函数式编程的意义所在, 通过这种方式实现了代码的灵活性, 这样就得到了专属于本平台的编译方法; 在前面介绍parseHTML的时候, 我们用到了 isUnaryTag 方法, 当时只是展示了其逻辑, 没有说明其引入的路径, 它其实就是通过createCompiler的参数传入的!isUnaryTag的具体逻辑前面已经展示过了, 此处不再赘述;

测试

我们已经得到了一个编译方法compilerToFunction, 我们来试试它行不行

// ...
export const { compileToFunctions, compile } = createCompiler(baseOptions)
const res = compile('<div>123</div>')
console.log('render结果:', res.render)
/** 
执行结果:
	render结果: with(this){return _c('div',{attrs:{"name":"laowang"}},[_v("123")])}
*/
// 注意, 这里之所以只用compile而不用compileToFunctions, 是因为现在如果用compileToFunctions,
// 那执行的结果必然会出现一堆‘undefined’, 因为此时_c, _v这些方法, 都不存在!

往期回顾

手写简化版Vue(一) 初始化

手写简化版Vue(二) 响应式原理