Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

《8分钟学会 Vue.js 原理》:一、template 字符串编译为抽象语法树 AST #7

Open
JuniorTour opened this issue Feb 11, 2022 · 0 comments
Labels

Comments

@JuniorTour
Copy link
Owner

JuniorTour commented Feb 11, 2022

《8分钟学会 Vue.js 原理》:一、template 字符串编译为抽象语法树 AST

Vue.js 并没有什么神秘的魔法,模板渲染、虚拟 DOM diff,也都是一行行代码基于 API 实现的。

本文将用几分钟时间,分章节讲清楚 Vue.js 2.0 的 <template>渲染为 HTML DOM 的原理。
有任何疑问,欢迎通过评论联系我~~

本节目标

将 Vue.js 的字符串模板template编译为抽象语法树 AST;

完整示例:DEMO -《8分钟学会 Vue.js 原理》:一、template 字符串编译为抽象语法树 AST - JSBin

3670fe62c282c35ae6c293bb85c8f6c

极其简单的核心逻辑

实现「字符串模板<template>编译为 render() 函数」的核心逻辑极其简单,只有 2 部分:

  1. String.prototype.match()

首先用.match()方法,提取字符串中的关键词,例如标签名div,Mustache标签对应的变量msg等。

用 1 行代码就能说明白:

'<div>{{msg}}</div>'.match(/\{\{((?:.|\r?\n)+?)\}\}/)
// ["{{msg}}", "msg"]

这个示例用/\{\{((?:.|\r?\n)+?)\}\}/ 正则表达式,提取了字符串'<div>{{msg}}</div>'中的Mustache标签

暂时不用理解该正则的含义,会用即可。

如果想要理解正则,可以试一试正则在线调试工具:Vue.js 开始标签正则,能够可视化的检查上述正则表达式

获得了"msg"标签对应变量的名称,我们就能在后续拼接出渲染DOM所需要的_vm.msg

即从我们声明的实例new Vue({data() {return {msg: 'hi'}}})中提取出msg: 'hi',渲染为 DOM 节点。

  1. 遍历字符串并删除已遍历结果

其次,因为<template>本质上是一段有大量HTML标签的字符串,通常内容较长,为了不遗漏地获取到其中的所有标签、属性,我们需要遍历。

实现方式也很简单,用while(html)循环,不断的html.match()提取模板中的信息。(html变量即template字符串)

每提取一段,再用html = html.substring(n)删除掉n个已经遍历过的字符。

直到html字符串为空,表示我们已经遍历、提取了全部的template

html = `<div`   // 仅遍历一遍,提取开始标签

const advance = (n) => {
    html = html.substring(n)
}

while (html) {
    match = html.match(/^<([a-zA-Z_]*)/)
    // ["<div", "div"]
    if (match) {
        advance(match[0].length)
        // html = '' 跳出循环         
    }
}

理解了这2部分逻辑,就理解了字符串模板template编译为 render() 函数的原理,就是这么简单!

具体步骤

0. 基于class语法封装

我们用 JS 的class语法对代码进行简单的封装、模块化,具体来说就是声明 3 个类:

// 将 Vue 实例的字符串模板 template 编译为 AST
class HTMLParser {}

// 基于 AST 生成渲染函数;
class VueCompiler {
    HTMLParser = new HTMLParser()
}

// 基于渲染函数生成虚拟节点和真实 DOM
class Vue {
    compiler = new VueCompiler()
}

问:为什么要生成 AST?直接把template字符串模板编译为真实 DOM 有什么问题?

答:没有问题,技术上也可以实现,
Vue.js 以及众多编译器都采用 AST 做为编译的中间状态,个人理解是为了编译过程中做「转化」(Transform),
例如v-if属性转化为 JS 的if-else判断,有了 AST 做为中间状态,有助于更便捷的实现v-ifif-else

1. 开始遍历template字符串模板

基于我们上述提到的while()html = html.substring(n)

我们可以实现一套一边解析模板字符串、一边删除已解析部分,直到全部解析完成的逻辑。

很简单,只有几行代码,

我们为class HTMLParser增加一个parseHTML(html, options)方法:

parseHTML(html, options) {
  const advance = (n) => {
    html = html.substring(n)
  }

  while(html) {
    const startTag = parseStartTag()  // TODO 下一步实现 parseStartTag
    if (startTag) {
      advance(startTag[0].length)
      continue
    }
  }
}

html参数即初始化 Vue 实例中的template: '<div>{{msg}}</div>',属性,

在遍历 html 过程中,我们每解析出一个关键词,就调用advance() { html.substring(n) },删去这部分对应的字符串,

示例中我们调用parseStartTag()(下一步实现)解析出开始标签<div>对应的字符串后,就从 html 删除了<div>这5个字符。

2. 解析开始标签<div>

接下来让我们实现parseStartTag(),解析出字符串中的开始标签<div>

也非常简单,用html.match(regularExp)即可,我们可以从Vue.js 源码中的 html-parser.js找到对应的正则表达式startTagOpen

源码的正则中非常复杂,因为要兼容生产环境的各种标签,我们暂时不考虑,精简后就是:/^<([a-zA-Z_]*)/

在线调试开始标签正则

用这个正则调用html.match(),即可得到['<div', 'div']这个数组。

提取出需要的信息后,就把已经遍历的字符调用advance(start[0].length)删除。

const parseStartTag = () => {
  let start = html.match(this.startTagOpen);
  // ['<div', 'div']
  if (start) {
    const match = {
      tagName: start[1],
      attrs: [],
    }
    advance(start[0].length)
  }
}

注意,startTagOpen只匹配了开始标签的部分'<div',还需要一次正则匹配,找到开始标签的结束符号>

找到结束符号后,也要删除对应已遍历的部分。

const end = html.match(this.startTagClose)
debugger
if (end) {
  advance(end[0].length)
}
return match

两部分组合后,就能完整遍历、解析出模板字符串中的<div>开始标签了。

3. 解析文本内容{{msg}}

下一步我们把模板字符串中的文本内容{{msg}}提取出来,仍然是字符串遍历 && 正则匹配

继续补充 while 循环:

while (html) {
  debugger
  // 顺序对逻辑有影响,startTag 要先于 text,endTag 要先于 startTag
  const startTag = parseStartTag()
  if (startTag) {
    handleStartTag(startTag)
    continue
  }

  let text
  let textEnd = html.indexOf('<')
  if (textEnd >= 0) {
    text = html.substring(0, textEnd)
  }

  if (text) {
    advance(text.length)
  }
}

因为删除了已解析部分、并且各部分有解析顺序,所以我们只要检测下一个<标签的位置即可获得文本内容在 html 中的结束下标:html.indexOf('<')

之后就能获得完整的文本内容{{msg}}text = html.substring(0, textEnd)

最后,别忘了,删除已经遍历的文本内容:advance(text.length)

4. 解析闭合标签</div>

到这一步,html 字符串已经只剩下'</div>'了,我们继续用遍历&&正则解析:

while (html) {
  debugger
  // 顺序对逻辑有影响,startTag 要先于 text,endTag 要先于 startTag
  let endTagMatch = html.match(this.endTag)
  if (endTagMatch) {
    advance(endTagMatch[0].length)
    continue
  }
}

我们暂时不需要从闭合标签中提取信息,所以只需要遍历、匹配后,删除它即可。

解析完成总结

到目前为止,我们已经基本实现了class HTMLParser {},多次用正则解析提取出了 template 字符串中的 3 部分信息:

  • 开始标签:'<div', '>'
  • 文本内容:'{{msg}}'
  • 闭合标签:'</div>'

这部分的完整代码,可以访问DEMO -《8分钟学会 Vue.js 原理》:一、template 字符串编译为抽象语法树 AST - JSBin查看。

但为了获得 AST,我们还需要基于这些信息,做一些简单的拼接。

5. 初始化抽象语法树 AST 的根节点

我们继续参考Vue.js 源码中拼接 AST 的实现

完善class VueCompiler,添加HTMLParser = new HTMLParser()实例,以及parse(template)方法。

class VueCompiler {
  HTMLParser = new HTMLParser()

  constructor() {}

  parse(template) {}
}

AST 是什么?

先不用去理解晦涩的概念,在 Vue.js 的实现中,AST 就是普通的 JS object,记录了标签名、父元素、子元素等属性:

createASTElement (tag, parent) {
  return { type: 1, tag, parent, children: [] }
}

我们把createASTElement方法也添加到class VueCompiler中。

并增加parse方法中this.HTMLParser.parseHTML()的调用

parse(template) {
  const _this = this
  let root
  let currentParent

  this.HTMLParser.parseHTML(template, {
    start(tag) {},
    chars (text) {},
  })

  debugger
  return root
}

start(tag) {}就是我们提取开始标签对应 AST 节点的回调,

其接受一个参数tag,调用_this.createASTElement(tag, currentParent)来生成 AST 节点。

 start(tag) {
    let element = _this.createASTElement(tag, currentParent)

    if (!root) {
      root = element
    }
    currentParent = element
  },

调用start(tag)的位置在class HTMLParser中的parseHTML(html, options)方法:

  const handleStartTag = (match) => {
    if (options.start) {
      options.start(match.tagName)
    }
  }

  while(html) {
    const startTag = parseStartTag()
    if (startTag) {
      handleStartTag(startTag)
      continue
    }
  }

当我们通过parseStartTag()获取了{tagName: 'div'},就传给options.start(match.tagName),从而生成 AST 的根节点:

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

我们把根节点保存到root变量中,用于最终返回整个AST的引用。

6. 为 AST 增加子节点

除了根节点,我们还需要继续为 AST 这棵树添加子节点:文本内容节点

仍然是用回调的形式(options.char(text)),提取出文本内容节点所需的信息,

完善VueCompiler.parse()中的chars(text)方法

chars(text) {
  debugger
  const res = parseText(text)
  const child = {
    type: 2,
    expression: res.expression,
    tokens: res.tokens,
    text
  }
  if (currentParent) {
    currentParent.children.push(child)
  }
},

parseHTML(html, options)的循环中添加options.chars(text)调用:

while (html) {
  // ...省略其他标签的解析
  let text
  let textEnd = html.indexOf('<')
  // ...

  if (options.chars && text) {
    options.chars(text)
  }
}

解析文本内容的Mustache标签语法

options.chars(text)接收的text值为字符串'{{msg}}',我们还需要从中剔除{{}},拿到msg字符串。

仍然是用熟悉的正则匹配:

  const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/
  function parseText(text) {
    let tokens = []
    let rawTokens = []
    const match = defaultTagRE.exec(text)

    const exp = match[1]
    tokens.push(("_s(" + exp + ")"))
    rawTokens.push({ '@binding': exp })

    return {
      expression: tokens.join('+'),
      tokens: rawTokens
    }
  }

结果将是:

{
  expression: "_s(msg)",
  tokens: {
    @binding: "msg"
  }
}

暂时不必了解expression, tokens及其内容的具体含义,后续到运行时阶段我们会再详细介绍。

7. 遍历template字符串完成,返回 AST

完整示例:DEMO -《8分钟学会 Vue.js 原理》:一、template 字符串编译为抽象语法树 AST - JSBin

经过以上步骤,我们将 template 字符串解析后得到这样一个对象:

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

这就是 Vue.js 的 AST,实现就是这么简单,示例中的代码都直接来自 Vue.js 的源码(compiler 部分

后续我们将基于 AST 生成render()函数,并最终渲染出真实 DOM。


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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant