2023-08-29
VueJS
00
请注意,本文编写于 468 天前,最后修改于 465 天前,其中某些信息可能已经过时。

目录

设计思路
baseParse生成AST
生成AST的思路
生成AST的代码实现
AST转JavaScriptAST
AST转换 实现思路
AST 转换代码实现
生成render函数
支持数据响应式
通过源码分析数据结构改动
代码实现
createApp实现
总结

本篇文章将讲解Vue3编译器,在阅读本篇文章前建议先看一下阅读一下我之前的一篇文章《AST抽象语法树》 尤其是 全网最小编译器,强烈建议跟着写一遍,读一篇可能当时理解了,然后吃过饭就忘,手写一遍代码胜过千言万语, 你的印象会深刻很多, 你会对AST有什深刻的认识,对于学习babel插件及本篇文章都很有帮助。本篇文章假设你已经对AST有一些了解了。

回归正题,先介绍本文的内容也就是Vue3编译器的思路

  1. Vue3编译器的实现思路 源代码(模版) --》 错误分析 --》 parse(生成AST) --》transform(生成JavaScriptAST) --》 generate生成目标代码
  2. 基于有限状态机的思路分析DOM模版状态转换的过程,将模版转换为AST
  3. AST转换为JavaScriptAST(增加了codegenNode属性)
  4. 根据JavaScriptAST生成render函数
  5. createApp实现 打通compileruntime

设计思路

  1. 先来看一个测试用例
html
<body> <div id="app"></div> <script> const { compile, render, h } = Vue const template = `<div>hello world</div>` const component = { render: compile(template) } const vnode = h(component) render(vnode, document.getElementById('app')) </script> </body>

h函数render函数前一篇文章已经讲过,至于依赖收集,这个先不考虑,我们先实现一个逻辑大幅简化的编译器骨架

  1. compile函数 compile函数正式本篇文章所要讲的内容,它的作用是将template模版转换为render函数

compile的思路如下

ts
function compile(template: string, options) { const ast = baseParse(template) transform( ast, Object.assign(options, { nodeTransforms: [transformElement, transformText, transformIf] }) ) const {code} = generate(ast) return new Function(code)() }
  • baseParse函数将 template转换为ast
  • transform函数将 ast转换为JavaScript Ast就是生成codegenNode属性
  • generate函数将JavaScript Ast转换为render函数

我做了一张图

image.png

baseParse生成AST

生成AST的思路

  1. 构建parse函数 生成context实例
  2. 构建parseChildren, 处理所有子节点
    • 构建有限状态机 解析模版
    • 扫描token生成AST结构
  3. 生成JS AST,构建测试

注:关于有限状态机, 参考阮老师的文章《JS与有限状态机》
这里模版的解析过程就类比有限状态机的转换

生成AST的代码实现

  • packages/compile-dom/src/index.ts
ts
import { baseCompile } from '@vue/compile-core' export function compile(template: string, options) { return baseCompile(template, options) }
  • packages/compile-core/src/compile.ts
ts
import { baseParse } from './parse' export function baseCompile(template: string, options) { const ast = baseParse(template) console.log(ast); /* transform( ast, extend(options, { nodeTransforms: [transformElement, transformText, transformIf] }) ) return generate(ast) */ }
  • packages/compile-core/src/parse.ts
ts
import { ElementTypes, NodeTypes } from './ast' // ElementTypes、NodeTypes 是两个常数枚举,copy自源码 export function baseParse(content: string) { const context: ParserContext = { source: content } const children = parseChildren(context, []) return createRoot(children) } export function createRoot(children) { return { type: NodeTypes.ROOT, children, loc: {} } } export interface ParserContext { source: string } const enum TagType { Start, End } function parseChildren(context: ParserContext, ancestors) { const nodes: any[] = [] while (!isEnd(context, ancestors)) { const s = context.source let node if (s.startsWith('{{')) { // TODO } else if (s[0] === '<') { if (/[a-z]/i.test(s[1])) { node = parseElement(context, ancestors) } } if (!node) { node = parseText(context) } nodes.push(node) } return nodes } function parseElement(context: ParserContext, ancestors) { const element = parseTag(context, TagType.Start) ancestors.push(element) const children = parseChildren(context, ancestors) ancestors.pop() element.children = children if (startsWithEndTagOpen(context.source, element.tag)) { parseTag(context, TagType.End) } return element } function parseTag(context: ParserContext, type: TagType) { const match: any = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source) const tag = match[1] advanceBy(context, match[0].length) let isSelfCloseing = context.source.startsWith('/>') advanceBy(context, isSelfCloseing ? 2 : 1) return { type: NodeTypes.ELEMENT, tag, tagType: ElementTypes.ELEMENT, children: [] as any[], props: [] } } function parseText(context: ParserContext) { const endToken = '<' let endIndex = context.source.length const index = context.source.indexOf(endToken, 1) if (index !== -1 && endIndex > index) { endIndex = index } const content = parseTextData(context, endIndex) return { type: NodeTypes.TEXT, content } } function parseTextData(context: ParserContext, length: number) { const rawText = context.source.slice(0, length) advanceBy(context, length) return rawText } function isEnd(context: ParserContext, ancestors) { const s = context.source if (s.startsWith('</')) { for (let i = ancestors.length - 1; i >= 0; --i) { if (startsWithEndTagOpen(s, ancestors[i].tag)) { return true } } } return !s } /** * 判断当前是否为《标签结束的开始》。比如 </div> 就是 div 标签结束的开始 * @param source 模板。例如:</div> * @param tag 标签。例如:div * @returns */ function startsWithEndTagOpen(source: string, tag: string): boolean { // return source.startsWith('</') return ( source.startsWith('</') && source.slice(2, 2 + tag.length).toLowerCase() === tag.toLowerCase() && /[\t\r\n\f />]/.test(source[2 + tag.length] || '>') ) } function advanceBy(context: ParserContext, numberOfCharacters: number) { const { source } = context context.source = source.slice(numberOfCharacters) }

验证AST有些麻烦
需要打印ASTconsole.log(JSON.stringify(ast))
然后copy到官方源码里(或者扔到22.compile-generate里),使用本文开头的测试用例,看看能否正常渲染 image.png

AST转JavaScriptAST

AST转换 实现思路

遵循的策略

  1. 后序深度优先遍历
  2. 转化函数分离
  3. 上下文对象

先看个后序深度优先遍历的例子, 略有点绕,照着官方代码思路写的,为了方面后序代码的理解, 有兴趣可以使用 npx ts-node 执行看看

ts
class MyNode { constructor(public value, public children: MyNode[] | null = null) {} } const nodes = new MyNode(1, [ new MyNode(2, [new MyNode(3), new MyNode(4)]), new MyNode(5) ]) const context = { nodeTransforms: [ arg => { // console.log(arg?.value) // 这里是先序遍历 return _arg => console.log(_arg?.value) }, /* arg => { // console.log(arg?.value) // 这里是先序遍历 return _arg => console.log(':: ' + _arg?.value) } */ ] } // 按照先序访问 1,2,3,4,5 // 按照后续访问 3,4,2,5,1 traverseNode(nodes, context) interface TransformContext { nodeTransforms: any[] } function traverseNode(node: MyNode, context: TransformContext) { const { nodeTransforms } = context const existFns: any = [] for (let i = 0; i < nodeTransforms.length; i++) { const onExit = nodeTransforms[i](node, context) onExit && existFns.push(onExit) } if (node.children?.length) { traverseChildren(node, context) } let i = existFns.length while (i--) { existFns[i]?.(node) } } function traverseChildren(parent: MyNode, context: TransformContext) { parent.children!.forEach((node, index) => { traverseNode(node, context) }) }
  • context 就是上下文对象, 在遍历的过程中访问或修改上下文对象上的属性
  • nodeTransforms 就是用来存储转换函数的,遍历ast的时候会调用这里的方法
  • 转换函数的分离是通过依赖注入实现的

AST 转换代码实现

  • packages/compile-core/src/compile.ts

image.png

  • packages/compile-core/src/transfrom.ts
ts
import { NodeTypes } from './ast' import { isSingleElementRoot } from './hoistStatic' export function createTransformContext(root, { nodeTransforms = [] }) { // 记录AST --> JS AST 转换过程的状态 // 会不断访问和修改该对象下的属性 const context: TransformContext = { nodeTransforms, root, helpers: new Map(), // 第三阶段generate 从这里根据标记取函数 currentNode: root, parent: null, childIndex: 0, helper(name) { // 第二阶段生成 JS AST 往里面放 函数标记 const count = context.helpers.get(name) || 0 context.helpers.set(name, count + 1) return name } } return context } export function transform(root, options) { const context = createTransformContext(root, options) traverseNode(root, context) createRootCodegen(root) root.helpers = [...context.helpers.keys()] // 这些属性本阶段用不到还是注释吧,方面上手 /* root.components = [] root.directives = [] root.imports = [] root.hoists = [] root.temps = [] root.cached = [] */ } export function traverseNode(node, context: TransformContext) { context.currentNode = node const { nodeTransforms } = context const existFns: any = [] for (let i = 0; i < nodeTransforms.length; i++) { const onExit = nodeTransforms[i](node, context) if (onExit) { existFns.push(onExit) } } switch (node.type) { case NodeTypes.ELEMENT: case NodeTypes.ROOT: traverseChildren(node, context) break default: return } context.currentNode = node let i = existFns.length while (i--) { existFns[i]() } } export function traverseChildren(parent, context: TransformContext) { parent.children.forEach((node, index) => { context.parent = parent context.childIndex = index traverseNode(node, context) }) } function createRootCodegen(root) { const { children } = root // Vue2 仅支持单个根节点 if (children.length === 1) { const child = children[0] if (isSingleElementRoot(root, child) && child.codegenNode) { root.codegenNode = child.codegenNode } } } export interface TransformContext { root parent: ParentNode | null childIndex: number currentNode helpers: Map<symbol, number> helper<T extends symbol>(name: T): T nodeTransforms: any[] }

transformElements方法有大致印象即可, 需要结合下一阶段generate() 才好理解

  • compile-core/src/transforms/transformElements.ts
ts
import { createVNodeCall, NodeTypes } from '../ast' export const transformElement = (node, context) => { return function postTransformElement() { node = context.currentNode if (node.type !== NodeTypes.ELEMENT) { return } const { tag } = node let vnodeTag = `"${tag}"` let vnodeProps = [] let vnodeChildren = node.children node.codegenNode = createVNodeCall( context, vnodeTag, vnodeProps, vnodeChildren ) } }
  • compile-core/src/transforms/ast.ts 新增 createVNodeCall方法
ts
export function createVNodeCall(context, tag, props?, children?) { if (context) { // 往Map对象context.helper中函数标志,在generate阶段使用 context.helper(CREATE_ELEMENT_VNODE) } return { type: NodeTypes.VNODE_CALL, tag, props, children } }

剩下的都是一些工具方法

ts
/* packages/compile-core/src/hoistStatic.ts */ import { NodeTypes } from './ast' export function isSingleElementRoot(root, child) { const { children } = root return children.length === 1 && child.type === NodeTypes.ELEMENT } /* packages/compile-core/src/runtimeHelpers.ts */ export const CREATE_ELEMENT_VNODE = Symbol('createElementVNode') export const CREATE_VNODE = Symbol('createVNode') export const helperNameMap = { [CREATE_ELEMENT_VNODE]: 'createElementVNode', [CREATE_VNODE]: 'createVNode' } /* packages/compile-core/src/utils.ts */ import { NodeTypes } from './ast' export function isText(node) { return [NodeTypes.TEXT].includes(node.type) }

如何验证JavaScript AST是否正确?
与模版AST验证的方法类似, console.log(JSON.stringify(ast)) cope代码到Vue源码(或者放在22.compile-generate这里), 然后使用文本开头的测试用例看看是否正常渲染。

但是这里有个注意事项 Symbol序列化后的值变成了null 要修复symbol值

ts
- helpers: [null], + helpers: [Symbol('createElementVNode')],

生成render函数

JavaScript AST是无法生成函数的,它生成后的是字符串,通过 new Function(code)()转换成函数。

对照JavaScript AST 和 生成后代码 image.png

generate方法写起来只剩下体力活

代码改动

  • packages/compile-core/src/compile.ts

  • packages/compile-core/src/codegen.ts

ts
import { NodeTypes } from './ast' import { CREATE_ELEMENT_VNODE, CREATE_VNODE, helperNameMap } from './runtimeHelpers' /** * 先按照这个拼接 const _Vue = Vue return function render(_ctx, _cache) { with (_ctx) { const { createElementVNode: _createElementVNode } = _Vue return _createElementVNode( "div", [], ["hello world"] ) } } */ const aliasHelper = (s: symbol) => `${helperNameMap[s]}: _${helperNameMap[s]}` export function generate(ast) { const context = createCodegenContext(ast) const { push, newline, indent, deindent, runtimeGlobalName } = context genFunctionPreamble(context) push(`function render(_ctx, _cache){`) indent() push(`with(_ctx){`) indent() const hasHelpers = ast.helpers.length > 0 if (hasHelpers) { const varStrs = ast.helpers.map(aliasHelper).join(',') push(`const { ${varStrs} } = _${runtimeGlobalName}`) } newline() push(`return `) // 还剩 _createElementVNode("div", [], ["hello world"]) if (ast.codegenNode) { genNode(ast.codegenNode, context) } else { push('null') } deindent() push(`}`) deindent() push(`}`) console.log(context.code) return { ast, code: context.code } } function createCodegenContext(ast) { const context = { code: ``, runtimeGlobalName: 'Vue', source: ast.loc.source, indentLevel: 0, // 锁进级别 helper(key) { return `_${helperNameMap[key]}` }, push(code) { context.code += code }, newline() { newline(context.indentLevel) }, // 增加锁进+换行 indent() { newline(++context.indentLevel) }, // 减少锁进和换行 deindent() { newline(--context.indentLevel) } } function newline(n: number) { context.code += `\n` + ` `.repeat(n) } return context } function genFunctionPreamble(context) { const { push, newline, runtimeGlobalName } = context push(`const _${runtimeGlobalName} = ${runtimeGlobalName}`) newline() push('return ') } function genNode(node, context) { switch (node.type) { case NodeTypes.VNODE_CALL: // 13 genVNodeCall(node, context) break case NodeTypes.ELEMENT: // 1 genNode(node.codegenNode, context) break case NodeTypes.TEXT: // 2 genText(node, context) } } function genVNodeCall(node, context) { const { push, helper } = context const { tag, props, children, isComponent } = node const callHelper = isComponent ? CREATE_VNODE : CREATE_ELEMENT_VNODE push(`${helper(callHelper)}(`) const args = [tag, props, children].map(arg => arg || null) genNodeList(args, context) push(`)`) } function genText(node, context) { context.push(JSON.stringify(node.content)) } function genNodeList(nodes, context) { const { push } = context for (let i = 0; i < nodes.length; i++) { const node = nodes[i] if (typeof node === 'string') { push(node) } else if (Array.isArray(node)) { context.push(`[`) genNodeList(node, context) context.push(`]`) } else { genNode(node, context) } if (i < nodes.length - 1) { push(', ') } } }

运行本文开头的测试用例

image.png 有个小问题,因为使用了with语句要避免with(undefind)with(null)报错, 如果你熟悉我的前一篇文章vue3运行时的话,很快就找到generate()生成的匿名函数是runtime-core/src/renderer.ts调用的

image.png 这避免这两处datanullundefined
instance.data 是在 createComponentInstance方法中初始化的
也就是runtime-core/src/component.ts有一处代码要调整

image.png

这里的render调用generate函数生成的匿名函数。

到这一步为止,才跑通本文开头的测试用例

image.png

支持数据响应式

通过源码分析数据结构改动

先来看一下测试用例

html
<body> <div id="app"></div> <script> const { compile, h, render } = Vue const component = { data() { return { msg: 'world' } }, render: compile(`<div> hello {{ msg }} </div>`), created() { setTimeout(() => { this.msg = '世界' }, 2000) } } const vnode = h(component); render(vnode, document.getElementById('app')) </script> </body>

通过对源码的调试,发现三个阶段 ASTJavaScriptAst生成的代码都有改动。

AST改动

注:默认右边为改动后的数据

image.png

JavaScript AST改动

image.png

生成后的代码改动

image.png

根据上述数据结构变更我们看一下代码改动

代码实现

  1. parse改动
  • runtime-core/src/parse.ts image.png parseInterpolation()代码如下
ts
// 解析插值表达式 {{ xxx }} function parseInterpolation(context: ParserContext) { const [open, close] = ['{{', '}}'] advanceBy(context, open.length) const closeIndex = context.source.indexOf(close, open.length) const preTrimContent = parseTextData(context, closeIndex) const content = preTrimContent.trim() advanceBy(context, close.length) return { type: NodeTypes.INTERPOLATION, content: { type: NodeTypes.SIMPLE_EXPRESSION, isStatic: false, content } } }

此外parseText()方法也要调整 image.png

  1. transform改动
  • runtime-core/src/compile.ts image.png

  • runtime-core/src/transforms/transformText.ts

ts
import { createCompoundExpression, NodeTypes } from '../ast' import { isText } from '../utils' /** * 将相邻的文本节点和表达式合并为一个表达式。 * * 例如: * <div>hello {{ msg }}</div> * 上述模板包含两个节点: * 1. hello:TEXT 文本节点 * 2. {{ msg }}:INTERPOLATION 表达式节点 * 这两个节点在生成 render 函数时,需要被合并: 'hello' + _toDisplayString(_ctx.msg) * 那么在合并时就要多出来这个 + 加号。 * 例如: * children:[ * { TEXT 文本节点 }, * " + ", * { INTERPOLATION 表达式节点 } * ] */ export const transformText = (node, context) => { if ( [ NodeTypes.ROOT, NodeTypes.ELEMENT, ].includes(node.type) ) { return () => { const children = node.children let currentContainer for (let i = 0; i < children.length; i++) { const child = children[i] if (!isText(child)) { continue } for (let j = i + 1; j < children.length; j++) { let next = children[j] if (!isText(next)) { currentContainer = undefined break } if (!currentContainer) { currentContainer = children[i] = createCompoundExpression( [child], child.loc ) } // 在 当前节点 child 和 下一个节点 next 中间,插入 "+" 号 currentContainer.children.push(` + `, next) // 把下一个删除 children.splice(j, 1) j-- } } } } }
  • runtime-core/src/transform.ts image.png image.png

  • 其他一些常量或工具方法改动

image.png

image.png

  1. generate()改动
  • runtime-core/src/codegen.ts image.png 新增方法内容如下
ts
// 表达式处理 4 function genExpression(node, context) { const { content, isStatic } = node context.push(isStatic ? JSON.stringify(content) : content, node) } // {{}} 处理 5 function genInterpolation(node, context) { const { push, helper } = context push(`${helper(TO_DISPLAY_STRING)}(`) genNode(node.content, context) push(`)`) } // 复合表达式处理 8 function genCompoundExpression(node, context) { for (let i = 0; i < node.children!.length; i++) { const child = node.children![i] if (typeof child === 'string') { context.push(child) } else { genNode(child, context) } } }

运行效果

23.gif

createApp实现

先来看一下测试用例

html
<body> <div id="app"></div> <script> const { createApp, h, compile, reactive } = Vue createApp({ template: '<div>{{obj.name}}<div>', setup() { const obj = reactive({ name: '张三' }) setTimeout(() => { obj.name = '李四' }, 2000); return { obj } } }).mount('#app') </script> </body>

createApp(component).mount(el) 等价于 render(h(component), el)

总的来说createApp就是组装轮子,实现起来很简单,但是以前我们没有对templatesetup返回的对象类型进行处理,这次我们一块补上

代码实现

  • runtime-dom/src/index.ts image.png

  • runtime-core/src/renderer.ts image.png image.png

  • runtime-core/src/apiCreateApp.ts

ts
import { createVNode } from './vnode' export function createAppAPI<HostElement>(render) { return function createApp(rootComponent, rootProps) { const app = { _component: rootComponent, _container: null, mount(rootContainer: HostElement) { const vnode = createVNode(rootComponent, rootProps) render(vnode, rootContainer) } } return app } }
  • runtime-core/src/components.ts

image.png image.png image.png

运行结果

24.gif

总结

  1. 本篇文章看起来不是特别多,但吃透却要花不少时间。写从学习到写作这篇文章花费了我大几十个小时,当然收获也满满的,不过我想说的是编译器还有很有很多内容,如指令、属性、插槽、模版引用等,本文也只是开了个头,实现了一个微型vue3编译器。
  2. 前两篇文章也一样只是实现了一个微型vue3响应式和运行时,还有很多api及边缘情况没有处理,不过面试能掌握到这种程度应该够了。

再次提醒: 本文所有案例 Vue3 study

本文作者:郭敬文

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!