JuniorTour / blog

not only front-end
8 stars 5 forks source link

《8分钟学会 Vue.js 原理》:二、AST 编译 render() 实现原理 #8

Open JuniorTour opened 2 years ago

JuniorTour commented 2 years ago

在上一节《一、template 字符串编译为抽象语法树 AST》中,我们实现了template渲染为AST的逻辑,距离最终目标「渲染为真实DOM」更近了一步!

这一节,我们来继续实现 AST 编译为渲染函数 render() 。

本节目标

完整代码:在线示例:抽象语法树 AST 编译为渲染函数render() - JS Bin

image

也就是将上一节我们编译出来的 AST 对象

{
    "type": 1,
    "tag": "div",
    "children": [
        {
            "type": 2,
            "expression": "_s(msg)",
            "tokens": [
                {
                    "@binding": "msg"
                }
            ],
            "text": "{{msg}}"
        }
    ]
}

编译为渲染函数render()

function render() {
  with(this) {
   return _c('div',[_v(_s(msg))])
  }
}

暂时不必理解渲染函数的含义,后续我们会深入了解。

什么是渲染函数render()

渲染函数是 AST 到虚拟 DOM 节点的中间媒介,本质上就是 JS 的函数,执行后会基于『运行时』返回虚拟节点的对象。

在 Vue.js 2 中,通过执行「渲染函数」获得了虚拟 DOM 节点,用于虚拟节点 Diff 并最终生成真实 DOM。

Vue.js 源码链接:lifecycle.js#L189-L191

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

上述3行源码中,调用的vm._render()即是「渲染函数」,其返回值即为「虚拟 DOM 节点」。

将虚拟 DOM 节点作为参数传给vm._update()后,就开始了著名的『虚拟 DOM Diff』。

核心原理

1. 把字符串函数体转化为函数

写 JS 时,我们可以通过声明表达式的形式创造函数。

但是在 JS 的执行过程中「创造函数」我们需要new Function() API,即JS中函数的构造函数。

通过调用函数的构造函数,我们可以将「字符串」类型的函数体,转化为一个可执行的JS函数:

const func = new Function('console.log(`新函数`)')

/* 
func ===
ƒ anonymous() {
  console.log(`新函数`)
}
*/

func() // 打印 `新函数`

通过new Function()API,我们就拥有了在 JS 执行过程中生成函数体,并最终声明函数的能力。

2. 基于AST生成字符串格式的函数体

有了声明函数的能力,我们就可以把 AST 编译为「字符串格式的函数体」,再将之转化为可执行的函数。

例如,我们有一个<div />对应的 AST:

{
    "type": 1,
    "tag": "div",
    "children": [],
}

想要把 AST 编译为渲染函数的函数体:_c('div')

我们只需要对 AST 进行遍历,根据tag属性就可以拼接出想要的函数体:

function generate(ast) {
  if (ast.tag) {
    return `_c('${ast.tag}')`
  }
}

如果 AST 的children属性不为空,我们继续对其进行深度优先递归搜索,就可继续增加渲染函数的函数体,最终生成各种复杂的渲染函数,渲染出复杂的 DOM,例如:

const render = function () {
  with (this) {
    return _c(
      'div', {attrs: {"id": "app"}},
      [
        _c('h1', [_v("Hello vue-template-babel-compiler")]),
        _v(" "),
        (optional?.chaining)
          ? _c('h2', [_v("\\n      Optional Chaining enabled: " + _s(optional?.chaining) + "\\n    ")])
          : _e()
      ]
    )
  }
}

如果有兴趣,可以找到自己项目中的node_modules/vue-template-compiler/build.js第4815行:var code = generate(ast, options); 加上console.log(code)npm run serve运行后,就可以在控制台中看到自己写的.vue文件编译出的渲染函数。

具体步骤

这次的代码逻辑更加简单,总共只需要写 41 行代码。

1. 增加CodeGenerator类及其调用

我们用CodeGenerator封装编译AST为渲染函数的逻辑,其带有一个generate(ast)方法,

传入 AST 作为参数,调用后会返回带有 render() 函数作为属性值的对象:

class CodeGenerator {
    generate(ast) {
      debugger
      var code = this.genElement(ast)

      return {
        render: ("with(this){return " + code + "}"),
      }
    }
}

拼接render时的with(this) {}有什么用?

with(this)关键字就是 Vue.js 单文件组件(.vue 文件,SFC)中不用写this关键字,就能渲染出this.msg的秘密。

with 关键字文档 - MDN

通过在渲染函数中使用with(this)关键字,可以把this作为其中作用域的全局变量(类似于window, global),{}花括号内的变量都会直接取this对应的属性。

例如:

with (Math) {
  val = random()
}
console.log(val) // 调用Math.random()的返回值

2. 编译 AST 中的父元素

我们再为类添加一个genElement方法,

这个方法接受一个 AST 节点,做2件事:

genElement(el) {
  var children = this.genChildren(el)
  const code = `_c('${el.tag}'${children ? `,${children}` : ''})`
  return code
}

genElement用于将AST:

{
    "type": 1,
    "tag": "div",
    "children": [],
}

编译为字符串函数体:_c('div')

3. 编译 AST 中的子元素

接下来我们编译子元素ast.children

children是一个数组,可能有多个子元素,所以我们需要对其进行.map()遍历,分别处理每一个子元素。

genChildren (el, state) {
  var children = el.children
  if (children.length) {
    return `[${children.map(c => this.genNode(c, state)).join(',')}]`
  }
}

我们再为类添加一个genElement方法,用于调用genChildren

  genElement(el) {
    debugger
    var children = this.genChildren(el)
    const code = `_c('${el.tag}'${children ? `,${children}` : ''})`
    return code
  }

4. 分别处理每一个子元素

我们用genNode(node)方法处理子元素,

生产环境中,子元素有多种,可能是文本、注释、HTML元素,所以需要用if (node.type === 2)判断类型,在分情况处理。

genNode(node) {
  if (node.type === 2) {
    return this.genText(node)
  }
  // TODO else if (node.type === otherType) {}
}

我们此次需要处理的只有「文本」(node.type === 2)这一种,所以我们再增加一个genText(text)来处理。

genText(text) {
  return `_v(${text.expression})`
}

在编译 AST 阶段,我们已经把{{msg}}编译为了一个 JS 对象:

  {
    "type": 2,
    "expression": "_s(msg)",
    "tokens": [
        {
           "@binding": "msg"
        }
    ],
    "text": "{{msg}}"
  }

现在我们只要取expression属性,就是其对应的渲染函数。

简而言之_s()是 Vue.js 内置的一个方法,可以把传入的字符串生成一个对应的虚拟 DOM 节点。

后续我们将详细介绍_s(msg)的含义及其实现。

5. 拼接为字符串函数体、生成渲染函数

经过以上各步骤,我们已将 AST 对象解析成了渲染函数的函数体字符串:with(this){return _c('div',[_v(_s(msg))])}

为了将仍然是字符串函数体的render属性,转化为可执行的函数,我们再增加一段new Function(code)逻辑,

并把createFunction (code)声明到VueCompiler类,以便于最终调用:

createFunction (code) {
  try {
    return new Function(code)
  } catch (err) {
    throw err
  }
}

最后我们来统一调用。

VueCompiler类的compile(template)中添加CodeGenerator实例及this.CodeGenerator.generate(ast)调用:

class VueCompiler {
  HTMLParser = new HTMLParser()
  CodeGenerator = new CodeGenerator()

  compile(template) {
    const ast = this.parse(template)
    console.log(`一、《template 字符串编译为抽象语法树 AST》`)
    console.log(`ast = ${JSON.stringify(ast, null, 2)}`)

    const code = this.CodeGenerator.generate(ast)
    const render = this.createFunction(code.render)
    console.log(`二、《抽象语法树 AST 编译为渲染函数 render()》`)
    console.log(`render() = ${render}`)
    return render
  }
}

基于我们前一节已经写好的this.compiler.compile(this.options.template),最终我们就能看到控制台打印出来的渲染函数render() =

image.png

完整代码:在线示例:抽象语法树 AST 编译为渲染函数render() - JS Bin


《8分钟学会 Vue.js 原理》系列,共计5部分:

正在热火朝天更新中,欢迎交流~ 欢迎催更~