Vue3.0 源码系列(二)编译器核心 - Compiler core 3: compile.ts
文章目录
该系列文章,均以测试用例通过为基准一步步实现一个 vue3 源码副本(学习)。
文字比较长,如果不想看文字可直接转到这里看脑图
由于 compile 和 transform 关联性比较强这里将放在一起去完成。
准备工作
要完成这一部分,首先要了解它的作用是什么?
在 parse.ts 文中我们完成了解析器的部分,作用是将模板解析成 AST 对象。
在这里 compile.ts 作用就是将这些 AST 如何翻译成 render 函数。
为了更直观的体验 compile 的作用,在 vue 源码里面有一个打包之后的目录:
/vue-next/packages/vue/dist/vue.global.js
然后我们使用第一个测试用例的模板,去编译下看看结果:
|
|
进行编译(完整示例):
|
|
运行之后 result 结果:
|
|
诸多的疑问等着去解答!!!
但至少有一点很清晰的知道,compile 就是将 AST 编译成 render 函数用的。
知道了最终目的,接下来就是漫长的探索之路了 🏃 🏃 🏃
构造数据,观察最终生成的 VNode 结构(上面代码执行之后结果返回给 result,其实就 是 render 函数):
|
|
传递一些参数调用之后结果:
|
|
compile.spec.ts
由于 compile.spec.ts 原来只有一个用例,相对是比较复杂的,不利于学习。
这里将根据 parse.spec.ts 循序渐进的去实现 compile + transform 的功能。
下面所有的测试用例均以 vue.global.js 打包之后的文件,运行结果为前提:
|
|
通过修改 test 值来得到真实的 render 函数。
完成了 01-simple text 用例之后发现按照 parse.spec.ts 可能不太理想,毕竟 parse 部 分的用例有点多,如果按照那个来这部分也将会很漫长,思考良久应该还是按照 compile.spec.ts 中的用例进行拆分之后右简入难式去通过该用例。
完整用例:
|
|
01-simple text
compiled:
|
|
也就是说 "simple text" 最后转变成的 render 函数如上所示。
我们的第一步就是如何来实现 compile 和 transform 能得到这样的结果,这将是该模块完 成第一步 🆙 🆙 🆙 🆙 🆙 🆙 🆙 🆙 🆙 🆙
parse 之后的 ast:
|
|
在完成 transformText 之后,发现 result.code 是空的,还以为是这里面实现问题的,其 实是 generate 函数还没实现的原因。
所有需要支持的函数都完成之后:
{ast: {…}, code: "function render(_ctx, _cache) {↵ with (_ctx) {↵ return "simple text"}}", map: ""} ast: {type: 0, children: Array(1), loc: {…}, helpers: Array(0), components: Array(0), …} code: "function render(_ctx, _cache) {↵ with (_ctx) {↵ return "simple text"}}" map: ""
会发现最终的 code 即我们想要的 render 函数,和用 vue.global.js 生成的一致。
如果需要将转成函数,这个需要用到 compileToFunction 这个不在我们这个讨论范围,其
实里面也很简单,直接调用 new Function(code)
就行了,来看下:
|
|
输出:
ƒ anonymous( ) { function render(_ctx, _cache) { with (_ctx) { return "simple text"}} } "compiled"
然后会发现结果好像不太对,首先 render 会被一个匿名函数包起来,这个是没问题的,但 是貌似匿名函数没有结束的 } 这个我想问题肯定处在了 generate 里面。
其实是因为 createCodgenContext 里面的 函数没有实现,另外这样是不对的,因为 new
Function(code)
会将 code 用一个匿名函数来包裹起来,因此想要得到 render 函数,必
须是以 return 形式返回,因此这里还有个遗漏的地方: genFunctionPreamble 需要去实
现,这里面最后会 push 一个 return 到 code 开头。
更新后输出:
|
|
在实现 genFunctionPreamble 之后,至此完成了一个得到 render 函数的完整过程。
下面将使用流程图方式进行回顾,分析整个过程。
02-pure interpolation 第一个孩子节点
{{ world.burn() }}
测试:
|
|
vue.global 结果:
|
|
01-simple text 阶段代码返回的结果:
|
|
通过用例 01 大概的完成了一个比较完整的编译过程,要通过该用例应该只需要在这过程中增 加对插值的处理即可。
处理步骤(通过用例 01 总结出的步骤):
baseCompile -> baseParse -> getBaseTransformPreset 得到 transform 函数 -> transform -> generate
baseParse -> ast
getBaseTransformPreset -> 这里并没有什么 transformInterpolation,插值并没有 对应的 transform 函数,而是直接在 generate 中结合
ast.helpers
处理。transform -> createTransformContext -> traverseNode -> createRootCodegen -> …
这一步需要处理的应该只有 traverseNode 需要修改,在 switch 里增加 INTERPOLATION 分支,因为 createRootCodegen 里面 root 如果只有一个孩子的情况 下会和用例 01 一样直接赋值
context.codegenNode = root.children[0]
generate -> createCodegenContext -> genFunctionPreamble 默认是 function 模 式 -> push
function render(_ctx, _cache) {
-> pushwith (_ctx)
-> … -> genNode(ast.codegenNode, context)这里需要修改的点应该只有 genNode 里面,也是增加 INTERPOLATION switch 分支, 处理插值部分的代码。
有了上面的初步分析,这里可以比较明确的知道需要修改的点:
DONE traverseNode 中增加 INTERPOLATION 分支
DONE genNode 中增加 INTERPOLATION 分支
DONE genNode 中增加 SIMPLE_EXPRESSION 分支处理插值内的表达式
修正:事实上并没有 transformInterpolation 🤦🤦🤦🤦
修改完之后报错:
transform.js:184 Uncaught TypeError: Cannot read property 'length' of undefined at traverseChildren (transform.js:184) at traverseNode (transform.js:119) at traverseChildren (transform.js:192) at traverseNode (transform.js:119) at transform (transform.js:133) at baseCompile (compile.js:37) at compile.html:12
根据报错定位到,在解析 root.children[0] 的时候经过 traverseChildren 里面时候的 parent.children 值为 undefined。
原因是 traverseNode 里面的 NodeTypes.INTERPOLATION 分支没有加 break 导致的,加上 之后:
|
|
和正确结果相比少了点东西 return _toDisplayString(world.burn())
with 内的解构来源:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
function generate() { // ... if (hasHelpers) { // 比如:插值处理时用到 TO_DISPLAY_STRING helper // 为了避免命名冲突,这里都需要将他们重命名 // traverseNode 里面 context.help(helper) push( `const { ${ast.helpers .map((s) => `${helperNameMap[s]} : _${helperNameMap[s]}`) .join(", ")} } = _Vue` ); push("\n"); newline(); } // ... }
缺少的
return _toDisplayString(world.burn())
generate 中最后 push `return `
执行 genNode(ast.codgenNode, context) 处理缺少的部分
1 2 3 4 5 6 7 8 9
{type: 5, content: {…}, loc: {…}} content: content: "world.burn()" isConstant: false isStatic: false loc: {start: {…}, end: {…}, source: "world.burn()"} type: 4 // SIMPLE_EXPRESSION,第二步 loc: {start: {…}, end: {…}, source: "{{ world.burn() }}"} type: 5 // INTERPOLATION,第一步
node 类型首先是 INTERPOLATION ,进入 genInterpolation(node, context)
1 2 3 4 5 6 7 8 9 10 11 12
function genInterpolation(node, context) { const { push, helper, pure } = context; if (pure) push(PURE_ANNOTATION); // 这里从 helpers 里面取出 toDisplayString push(`${helper(TO_DISPLAY_STRING)}(`); // 这里生成 `world.burn()` SIMPLE_EXPRESSION 类型 genNode(node.content, context); push(`)`); }
取 node.content 调用 genNode(node.content, context) 生成 `world.burn()` 表达式。
进入
switch node.type === NodeTypes.SIMPLE_EXPRESSION
分支,调用 genExpression(node, context)
🌻 Perfect: 最后结果:
|
|
完整流程图:
03-inerpolation in pure div
test:
|
|
vue.global:
|
|
先阶段的结果:
|
|
流程图:
流程分析:
baseParse(template, options) 解析出 ast
transform(ast, …) 递归遍历处理 root.children 生成各节点的 codegenNode
traverseNode(root, context) 核心函数,结合 traverseChildren 通过遍历+递归 处理所有节点,收集对应的 transform* 函数,在结束递归之后回溯过程中执行这些 transform* 来收集节点对应的 codegenNode
遍历所有的
nodeTrasforms[]
来收集当前节点满足条件的 transform* 函数 到exitFns[]
中,比如: 这里的 ELEMENT 类型(<div></div>
)会收集到 transformElement 和 transformText 。NodeTypes.ROOT
进入 traverseChildren(node, context) 继续处理root.children
,这里同时会记录每个节点的 parent 值,ROOT 类型收集 transformText 。NodeTypes.ELEMENT
也会进入到traverseChildren(node, context)
处理node.children
,赋值 parent, 收集transformText
和transformElement
。NodeTypes.INTERPOLATION
对于插值节点,不会进入traverseChildren
而是在 switch 分支中调用context.helper()
去更新context.helpers
用来从Vue
中 解构出需要的函数。
TODO hoistStatic(root, context) 静态提升用的,针对静态节点提升到函数外面(这里暂 时未深入,没用到)
createRootCodegen(root, context) 生成 root.codegenNode ,有可能是来自第一 个且唯一一个孩子节点,分为两个分支具体细节点击函数链接。
经过 transform 处理之后的
ast
对象1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
{ // 去掉不重要的部分 "type":0, // ROOT 类型 "children":[ { "type":1, // ELEMENT 类型 "tag":"div", "tagType":0, // Start "children":[ { "type":5, // INTERPOLATION "content":{ "type":4, // SIMPLE_EXPRESSION "isStatic":false, "isConstant":false, "content":"world.burn()", }, } ], "codegenNode":{ // 这里实际上是 root.children[0] 经过 transformElement 之后的结果 // 变成了VNODE_CALL 在 codegen-generate 处理部分会用到 "type":13, // VNODE_CALL "tag":""div"", "children":{ "type":5, // INTERPOLATION "content":{ "type":4, // SIMPLE_EXPRESSION "isStatic":false, "isConstant":false, "content":"world.burn()", }, }, "patchFlag":"1 /* TEXT */", // 这个目前不知道干啥的 "isBlock":true, // 决定使用 openBlock/createBlock, 还是 createVNode "isForBlock":false, } } ], "codegenNode":{ // root 根节点的 // 在 createRootCodegen 中有个处理是,如果root.children 有且只有一个 // ELEMENT 类型的节点的时候,root.codegenNode 会被这个节点的 codegenNode // 覆盖,即root 使用它唯一的孩子节点的 codegenNode "type":13, // VNODE_CALL "tag":""div"", "children":{ "type":5, "content":{ "type":4, "isStatic":false, "isConstant":false, "content":"world.burn()", }, }, "patchFlag":"1 /* TEXT */", "isBlock":true, "isForBlock":false, }, }
generate(ast, …) 生成代码片段 ->
new Function(context.code)
genFunctionPreamble(ast, context) 主要使用来检测环境从而导入 Vue 实例 (如:~const _Vue = Vue~),最后 render 函数的
`return `
也是这里生成的。genNode(ast.codegenNode, context) 对每个 ast 节点结构进行处理,生成对应的 Render 函数相关部件。
genVNodeCall(node, context) 生成节点的参数,调用函数之类的,如:
openBlock()
,createBlock(...)
及参数列表createBlock(tag, props, children, patchFlag, ...)
,指令等部件。patchFlag 是在 transformElement 里面处理值的。
经过 generate 之后生成的
render
函数:1 2 3 4 5 6 7 8 9 10 11 12
(function anonymous( ) { const _Vue = Vue return function render(_ctx, _cache) { with (_ctx) { const { toDisplayString: _toDisplayString, createVNode: _createVNode, openBlock: _openBlock, createBlock: _createBlock } = _Vue return (_openBlock(), _createBlock("div", null, _toDisplayString(world.burn()), 1 /* TEXT */)) } } })
render({ world: { burn() { return `burn the world !` }}})
执行之后得到的 VNode 节点:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
{ "_isVNode":true, "type":"div", "props":null, "key":null, "ref":null, "scopeId":null, "children":"burn the world !", "component":null, "suspense":null, "dirs":null, "transition":null, "el":null, "anchor":null, "target":null, "targetAnchor":null, "shapeFlag":9, "patchFlag":1, "dynamicProps":null, "dynamicChildren":[ ], "appContext":null }
04-interpolation in div with props
code: `<div id="foo" :class="bar.baz">{{ world.burn() }}</div>`
这个用例和 用例3 只有一个属性的差别,所以这里只要参考 test 03 来实现 div 属性的 解析和编译即可,所有流程和流程图可参考 03 来实现。
还是老方法,根据跟踪 vue.global debugger 过程来分析整个过程。 期待结果:
|
|
createStructuralDirectiveTransform(name, fn) 如果存在属性,都会经过这个函数是因
为 if,else-if,else,for
的 transform 都是通过这个创建的,所以在经过
traverseNode 中的 exitFns 收集过程中会执行到这里。
然后这个用例中并没有 v-if, v-for 类似的分支指令,所以这些 transform* 不会被收集 到。
root.children[0]: div
收集 transformElement ,ELEMENT 类型需要收集来解析出 codegenNode。
流程图分析:这里和 03 对比多了两部分处理
transform 阶段的 props 解析
这一阶段的处理发生在 transformElement 中对 props 属性的检测,一旦检测到有属性 列表,需要经过 buildProps 解析出新的属性对象:
buildProps 之前的 props
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
props: Array(2) 0: // 属性 id loc: {..., source: "id="foo""} name: "id" type: 6 // ATTRIBUTE value: content: "foo" loc: {..., source: ""foo""} type: 2 // TEXT 1: // 属性 :class arg: content: "class" isConstant: true isStatic: true loc: {..., source: "class"} type: 4 // SIMPLE_EXPRESSION exp: content: "bar.baz" isConstant: false isStatic: false loc: {..., source: "bar.baz"} type: 4 // SIMPLE_EXPRESSION loc: {..., source: ":class="bar.baz""} modifiers: [] name: "bind" type: 7 // DIRECTIVE
buildProps 解析之后:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Return value: Object directives: [] dynamicPropNames: [] patchFlag: 2 // CLASS props: properties: Array(2) 0: key: {type: 4, isConstant: false, content: "id", isStatic: true} type: 16 // JS_ARRAY_EXPRESSION value: {type: 4, isConstant: false, content: "foo", isStatic: true} 1: key: {type: 4, content: "class", isStatic: true, isConstant: true}} type: 16 value: {type: 4, content: "bar.baz", isStatic: false, isConstant: false, type: 15 // JS_PROPERTY
这里面的处理分为两种类型: 1. ATTRIBUTE<6> 类型, 2. DIRECTIVE<7> 指令类型是 分开处理的,普通属性调用
createObjectProperty(key, value)
构建新的对象,指 令属性通过指令名称从context.directiveTransforms
对象中取出对应的函数进行处 理,比如v-bind
对应函数 transformBind(prop, node, context) 处理。比如:
id="foo"
处理之后的1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
Return value: Object key: // 属性名 content: "id" isConstant: false isStatic: true loc: {source: "id"} type: 4 // SIMPLE_EXPRESSION type: 16 // JS_PROPERTY value: // 属性值 content: "foo" isConstant: false isStatic: true loc: {source: ""foo""} type: 4 // SIMPLE_EXPRESSION // 包含 key, type, value 三个属性值
比如:
:class="bar.baz"
处理之后的1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
Return value: Object props: Array(1) 0: key: content: "class" isConstant: true isStatic: true type: 4 // SIMPLE_EXPRESSION type: 16 // JS_ARRAY_EXPRESSION value: content: "bar.baz" isConstant: false isStatic: false type: 4 // 包含 key, type, value,和普通属性类型不一样 // 这里是 JS_ARRAY_EXPRESSION
generate 阶段的 props 解析
render 函数生成阶段, genVNodeCall 解析
codegenNode
, 其中有一个 genNodeList(nodes, …) 这里的 nodes 是[tag, props, children, patchFlag, ...]
该用例中相比用例03 这里的 props 不是 null ,所以在genNodeList
中i = 1
的时候会进入到 genNode(props, context) 去解析属性列表。进入之前 props 值:
1 2 3
loc: {source: "<div id="foo" :class="bar.baz">{{ world.burn() }}</div>"} properties: (2) [{…}, {…}] // 这里是 id, class 两个属性 type: 15 // JS_OBJECT_EXPRESSION
类型为 JS_OBJECT_EXPRESSION<15> 在
genNode
里面会进入 genObjectExpression(node, context) 分支将属性解析成对象,如:{id: "foo", class: bar.baz}
。genObjectExpression
里面对属性的处理主要分两步,先调用 genExpressionAsPropertyKey(node, context) 去处理属性名 key node,完成之后,再调 用 genNode(value, context) 去处理值 value node(最后进入 genExpression(node, context), 因为类型为 SIMPLE_EXPRESSION<4>)。最后得到
{id: "foo", class: bar.baz}
作为createBlock('div', ...)
的第二个参数。
修改完之后运行结果:
|
|
结果与正确结果又两点缺陷:
属性漏掉了
class
patchFlag 那里不对,正确应该是
3 /* TEXT, CLASS */
没有报错能走通说明至少逻辑是通的出现上面两个问题原因,溯源起来也很清晰,因为我们
知道 props 在 transform 阶段是 transformElement 里面,generate 阶段是在
genObjectExpression() 中, 而 patchFlag 也是在 transformElement
处理的。
通过在 genObjectExpression() for 循环中增加打印,显示 properties 中只有一个 id 属性,那么属性解析最后是在 buildProps 里面的, bingo!!! 没有实现 transformBind 。
那么修改点有二:
在 compile.ts 的 getBaseTransformPreset 增加指令 transform 函数 transformBind
实现
transformBind
修改之后:
|
|
扩展 1:增加 camel
修饰符
code: `<div id="foo" :class="bar.baz" :test-prop.camel="bar.bax">{{ world.burn() }}</div>`
结果:
|
|
因为 transformBind 中有检测修饰符中是否包含 camel
,如果有则会进行驼峰转换,否
则不会转而是将 test-prop 用引号包起来: "test-prop"
。
扩展 2:动态属性且有 camel
修饰符
code: `<div id="foo" :class="bar.baz" :[prop_name].camel="bar.bax">{{ world.burn() }}</div>`
这个时候需要实现 transform.js 中 createTransformContext 中 context.helperString
|
|
结果:
|
|
会发现这里多解构了个 _camelize
函数出来,通过函数调用方式去处理动态属性名。
05-interpolation, v-if, props
|
|
增加了 <div v-if="ok"></div>
ast: 在经过 parse.ts 之后应该具备看到模板能够分析出 ast 结构能力。
|
|
vue.global 结果:
|
|
这里有几个不同点:
_createBlock 第三个参数 children 变成了数组,且使用了 _createTextVNode() 创建 虚拟节点
就是多了个新增的那个
div v-if
节点patchFlag 的变化
先看下修改之前的结果:
|
|
差异点:
没有 render 函数外的解构
没有 render 函数外的
const _hoisted_1 = { key: 0 };
没有
_createCommentVNode
children 里面的差值节点丢失了
div v-if 节点处理错误
先解决差值问题(第 4 点),这里插值节点为什么会丢失?
补漏:
实现 transformIf
createStructuralDirectiveTransform 创建指令(如:v-if, v-else 等)相关的 transform 函数,注意这里的正则:
/^(if|else|else-if)$/
由于指令是存在
node.props
属性里面的,这里会直接遍历所有的属性,找出满足条件type:DIRECTIVE
且prop.name
匹配上面的正则的指令。因为这里要将所有的指令转成分支类型的结构。
1 2 3 4 5 6 7 8 9 10 11 12 13
{ type: 9, // IF branches: [{ children: [{ /* 这里保存了转换之前的 v-if 节点 */}] condition: { content: 'ok', // ... type: 4, // SIMPLE_EXPRESSION } // branch type: 10, // IF_BRANCH }] }
最后处理之后得到的 ifNode 包含所有分支
ifNode.branches
, branch 即当前要处 理的分支交给返回的那个 transform 函数待递归完成之后取处理得到该分支节点的codegenNode
然后经过递归之后,回溯过程中会执行返回的那个函数(transform if) 进入 createCodegenNodeForBranch -> createChildrenCodegenNode ->
createCallExpression
创建分支节点 codegen。实现 traverseNode 中的
IF(9)
和IF_BRANCH(10)
分支实现 generate 阶段的 if 节点处理
修改 genNode 增加
IF, IF_BRANCH, JS_CALL_EXPRESSION, JS_CONDITIONAL_EXPRESSION
分支。增加 genCallExpression 函数生成参数。
增加 genConditionalExpression 函数生成 if 分支(如:
ok ? ... : ...
)
在完成上述三个步骤之后输出结果:
|
|
扩展一:hoisted 支持
genFunctionPreamble(ast, context) 中增加 genHoists(ast.hoists, context)
transform 阶段对
hoisted
处理(transforms/hoistStatic.ts 的walk()
函数,遍 历所有孩子节点,找出节点 props 中所有属性名为 key 或 ref 的属性)transform.js 的 context.hoist() 实现,修改 props 属性
这里 vue.global.js 和 实际 vue transform.ts 中的代码有细微差别,但不影响整体 流程,不知道为何?
实现之后会发现在返回 render 函数之前多了 const _hoisted_1 = { key: 0 }
和一些
函数的解构。
|
|
扩展二:v-else 支持
|
|
v-if 和 v-else 的 ast:
|
|
从之前的实现结果可知, 单个 v-if
的处理是在后面追加一个注释节点,因为在 Render
函数中这些节点是以三目运算符(?:
)链接起来组成表达式的,如:
|
|
按照理解,如果增加了 v-else
分支,那么应该需要将 :
后的注释节点替换成真正的
节点?
猜想 :
从代码语法角度思考,
if/else/else-if
肯定必须是连续的,那么这里的 else 如果想要正 确解析那前提必须要有 if 才行。这一步是如何实现呢?根据上面 ast 解析结果显示,在 parser 阶段,无论是 if 还是 else 都是同等对待的,即它 们都是个指令(9,DIRECTIVE),然后根据之前 v-if 的 transform 可知,v-else 也是在 这个阶段并且是在同一个 transformIf 中处理的,即下面 else TODO 部分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
export const transformIf = createStructuralDirectiveTransform( /^(if|else|else-if)$/, (node, dir, context) => { return processIf(node, dir, context, (ifNode, branch, isRoot) => { // 能到这里说明 v-if 下所有的 child 都已经处理完毕,可以返回处理 // codegenNode 的函数了 return () => { console.log({ dir, isRoot }); if (isRoot) { ifNode.codegenNode = createCodegenNodeForBranch(branch, 0, context); } else { // TODO } }; }); } )
所以实现步骤如下
实现 transformIf 返回的 transform 函数的 else 部分,这部分承担 了
v-else/v-else-if
指令节点的 codegenNode 生成工作。实现 processIf() 的分支部分
createCodegenNodeForBranch(branch, index, context) 返回新的 alternate 替换掉 占位的注释分支。
因为都是在 v-if 的 branches 里面,在 codegen 阶段和 v-if 的处理是一样的,不需 要修改什么。
修改完之后:
|
|
问题列表
compile.js:37 Uncaught TypeError: object is not iterable (cannot read property Symbol(Symbol.iterator))
原因是:在数组里面使用展开符的时候 [], {} 混用了
1 2 3 4 5 6 7 8 9 10 11 12 13
transform(ast, { // 合并选项 ...options, // 调用 baseCompile 时候的第二个参数 prefixIdentifiers, // 还不知道是干啥的??? // 节点转换器合并,外部转换器优先,即使用者可自定义自己的转换器 // nodeTransforms: [...nodeTransforms, ...(options.nodeTransforms || {})], // FIX: 这里用法有问题修改前 nodeTransforms: [...nodeTransforms, ...(options.nodeTransforms || [])], // FIX: 修改后 // 指令转换器,同上。 directiveTransforms: { ...directiveTransforms, ...(options.directiveTransforms || {}), }, });