Static hoisting 静态提升
2022年10月1日 · 265 字 · 2 分钟 · 踩坑 Vue Vue3 静态提升 优化 UB
文章地址:https://jaufey.notion.site/Static-hoisting-85e751cf04694a47a1b8d9f9f04cfb35
在 JS 中,有一个术语叫做 Hoisting,指在编译期将变量、函数、类的声明提升至作用域顶端。
本篇文章所讲的 Static hoisting 与 Hoisting 有一点相似,但相关性不大。Static hoisting 是 Vue3 在模板编译阶段的一种优化手段,恰好有一个提升的操作,所以名字中包含 hoisting,同时,Static 则表示 hoisting 的对象是静态的。
由问题出发
我是如何感知到 “静态提升” 的?
答案是:某项目从 Vue2 升级到 Vue3 后,出现了 “副作用被 cache” 的问题。
问题描述
- 假设有个
v-if="toggle"
控制的 A 组件。- 在 A 组件初始化的时候,徒手在组件元素中绘制了一个圆圈,类似
$el.appendChild(circle)
,这算是框架内的 UB,只管拉不管擦的脏写。 - 当 toggle 由 true 切为 false 再切回 true 的时候,即便代码中没有显式指定任何缓存方式,A 组件却仍保留了上一次挂载后所绘制的圆圈。导致每展示 A 组件一次,脏写内容便累加一次。
- 在 A 组件初始化的时候,徒手在组件元素中绘制了一个圆圈,类似
- 此问题只在构建后出现,而不会在开发间出现。脚手架配置为 Vue-cli 默认配置。
- 副作用只是累计了副作用中的 DOM 元素,而事件监听等功能则没有被缓存。
在线示例:https://demo-0814-test.vercel.app/
代码:https://github.com/N-index/demo-0814
问题解决
经过排查与请教,发现是 “静态提升” 这个构建期的优化手段导致了这个问题。
解决方案为以下任意一种:
-
在构建阶段,关闭静态提升的功能: https://github.com/vuejs/core/issues/5256#issuecomment-1173723516
-
在副作用的父容器上添加 ref 属性,使得静态提升越过这个静态节点。
具体参考:ref 和 key 的编译示例
问题总结
一句话描述 “静态提升” :在模板编译阶段生成渲染函数的过程中,对于静态内容,Vue 将其对应的 vnode 结果 hoist 到了渲染函数外部,而不是每次都渲染都重新生成 vnode。(示例:编译期间的静态提升)
Vue3 基于这样一个假设,才有了这个优化策略:
静态内容不会被改动,每次都会渲染出一样的 DOM,所以直接缓存起来就可以了。
由此,可推断出上面所说的第 2 种解决方案之所以可行,是因为:
编译模板时遇到 ref :哦这个 DOM 大概率不是静态的,所以就不提升了。
另外,问题描述中的第 3 点可能是因为这个原因:
大量静态内容时会使用 cloneNode,而且 cloneNode 指挥克隆 DOM 结构而不会克隆事件。只是猜测,因为我无从得知本文场景中第二次渲染静态内容的时候是否使用了 cloneNode,尚待验证。
静态提升的两个步骤
-
在编译期间,将静态节点的 patchFlag 标记为 -1,代表此节点为可以 hoist:
- 将 compile 模板后的 AST 传入 transform 函数
function transform(root, options) { const context = createTransformContext(root, options); traverseNode(root, context); if (options.hoistStatic) { // 判断是否为静态节点,是否可以提升 hoistStatic(root, context); } }
- 在 hoistStatic 函数中执行 walk 函数遍历节点,将静态节点的 patchFlag 标记为 -1,表示可以 hoist.
if (constantType >= 2 /* CAN_HOIST */) { child.codegenNode.patchFlag = -1 /* HOISTED */ + (` /* HOISTED */` ); child.codegenNode = context.hoist(child.codegenNode); hoistedCount++; continue; }
- 将 compile 模板后的 AST 传入 transform 函数
-
在运行时 diff 期间 中对新旧节点进行 patch 时更新节点:
case Static: // 初次挂载静态节点 if (n1 == null) { mountStaticNode(n2, container, anchor, isSVG); } // 更新静态节点 else { patchStaticNode(n1, n2, container, isSVG); } // -------- // mountStaticNode 函数 const mountStaticNode = (n2, container, anchor, isSVG) => { [n2.el, n2.anchor] = hostInsertStaticContent(n2.children, container, anchor, isSVG, n2.el, n2.anchor); }; // patchStaticNode 函数 const patchStaticNode = (n1, n2, container, isSVG) => { // 在此可以看到,如果是静态节点,vue 直接是把旧节点的 el 直接赋值给了新节点的 el n2.el = n1.el; n2.anchor = n1.anchor; }
延伸思考
问题在开发阶段不会暴露,而只在编译后才暴露。说明了基于假设而默认启用的优化策略在遇到未定义行为的时候,有不小的概率会产生未预期的结果。
我们常调侃 “在我机器上是正常的呀”,但在这个问题上 Vue3 又给我开了个玩笑:“这东西在开发期间是正常的呀!”。要说这个 Feature 有什么问题,“没写进 breaking change” 或许算一个。而且,作为一个普通开发者要去关心框架层面的东西,本身就很奇怪。