vue3的内存泄漏
在项目中会发现一个奇怪的现象,当使用element-plus中的图标组件时会出现内存泄漏。详情查看
解决方案1:关闭静态提升。详情查看
解决方案2:文末附上
至于为什么静态提升会导致内存泄漏,本文将通过几个案例的源码分析详细讲解。
案例1
html
<div id="app"></div>
<script type="module">
import {
createApp,
ref,
} from '../packages/vue/dist/vue.esm-browser.js'
const app = createApp({
setup() {
const show = ref(false)
return {
show,
}
},
template: `
<div>
<button @click="show=!show">show</button>
<template v-if="show">
<template v-for="i in 3">
<div>
<span>12</span>
<span>34</span>
</div>
</template>
</template>
</div>
`
})
app.mount('#app')
</script>
点击按钮前:游离节点只有一个,这是热更新导致的,不需要管。
点击两次按钮后:
对比可以发现多出了两个span和和一个div和两个text的游离节点,最下面的注释节点不需要管。
先来看一下这个模板编译后的结果:
ts
const _Vue = Vue
const { createElementVNode: _createElementVNode, createCommentVNode: _createCommentVNode } = _Vue
const _hoisted_1 = ["onClick"]
const _hoisted_2 = /*#__PURE__*/_createElementVNode("span", null, "12", -1 /* HOISTED */)
const _hoisted_3 = /*#__PURE__*/_createElementVNode("span", null, "34", -1 /* HOISTED */)
const _hoisted_4 = [
_hoisted_2,
_hoisted_3
]
return function render(_ctx, _cache, $props, $setup, $data, $options) {
with (_ctx) {
const { createElementVNode: _createElementVNode, renderList: _renderList, Fragment: _Fragment, openBlock: _openBlock, createElementBlock: _createElementBlock, createCommentVNode: _createCommentVNode } = _Vue
return (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("button", {
onClick: $event => (show=!show)
}, "show", 8 /* PROPS */, _hoisted_1),
show
? (_openBlock(true), _createElementBlock(_Fragment, { key: 0 }, _renderList(3, (i) => {
return (_openBlock(), _createElementBlock("div", null, _hoisted_4))
}), 256 /* UNKEYED_FRAGMENT */))
: _createCommentVNode("v-if", true)
]))
}
}
这里关注_hoisted_4
静态节点。
挂载阶段
挂载第一个div的子节点时:
此时children
中两个节点分别指向_hoisted_2
和 _hoisted_3
循环遍历children时,会走cloneIfMounted
。
ts
export function cloneIfMounted(child: VNode): VNode {
return (child.el === null && child.patchFlag !== PatchFlags.HOISTED) ||
child.memo
? child
: cloneVNode(child)
}
而_hoisted_2
和 _hoisted_3
一开始属性el为null但patchFlag使HOISTED,所以会走cloneVnode
ts
export function cloneVNode<T, U>(
vnode: VNode<T, U>,
extraProps?: (Data & VNodeProps) | null,
mergeRef = false
): VNode<T, U> {
// This is intentionally NOT using spread or extend to avoid the runtime
// key enumeration cost.
const { props, ref, patchFlag, children } = vnode
const mergedProps = extraProps ? mergeProps(props || {}, extraProps) : props
const cloned: VNode<T, U> = {
__v_isVNode: true,
__v_skip: true,
type: vnode.type,
props: mergedProps,
key: mergedProps && normalizeKey(mergedProps),
ref:
extraProps && extraProps.ref
? // #2078 in the case of <component :is="vnode" ref="extra"/>
// if the vnode itself already has a ref, cloneVNode will need to merge
// the refs so the single vnode can be set on multiple refs
mergeRef && ref
? isArray(ref)
? ref.concat(normalizeRef(extraProps)!)
: [ref, normalizeRef(extraProps)!]
: normalizeRef(extraProps)
: ref,
scopeId: vnode.scopeId,
slotScopeIds: vnode.slotScopeIds,
children:
__DEV__ && patchFlag === PatchFlags.HOISTED && isArray(children)
? (children as VNode[]).map(deepCloneVNode)
: children,
target: vnode.target,
targetAnchor: vnode.targetAnchor,
staticCount: vnode.staticCount,
shapeFlag: vnode.shapeFlag,
// if the vnode is cloned with extra props, we can no longer assume its
// existing patch flag to be reliable and need to add the FULL_PROPS flag.
// note: preserve flag for fragments since they use the flag for children
// fast paths only.
patchFlag:
extraProps && vnode.type !== Fragment
? patchFlag === -1 // hoisted node
? PatchFlags.FULL_PROPS
: patchFlag | PatchFlags.FULL_PROPS
: patchFlag,
dynamicProps: vnode.dynamicProps,
dynamicChildren: vnode.dynamicChildren,
appContext: vnode.appContext,
dirs: vnode.dirs,
transition: vnode.transition,
// These should technically only be non-null on mounted VNodes. However,
// they *should* be copied for kept-alive vnodes. So we just always copy
// them since them being non-null during a mount doesn't affect the logic as
// they will simply be overwritten.
component: vnode.component,
suspense: vnode.suspense,
ssContent: vnode.ssContent && cloneVNode(vnode.ssContent),
ssFallback: vnode.ssFallback && cloneVNode(vnode.ssFallback),
el: vnode.el,
anchor: vnode.anchor,
ctx: vnode.ctx,
ce: vnode.ce
}
if (__COMPAT__) {
defineLegacyVNodeProperties(cloned as VNode)
}
return cloned
}
克隆时会将被克隆节点的el赋值给新的节点。
回到循环体,可以看到将克隆后的节点重新赋值给了children即_hoisted_4
[i],此时_hoisted_4
中的内容不再指向_hoisted_2
和_hoisted_3
,而是克隆后的节点。_hoisted_2
和_hoisted_3
就此完全脱离了关系。这是一个疑点,每次都需要克隆,不懂这样静态的提升的意义在哪里。
后续div子节点的挂载都会走这个循环,每次循环都会克隆节点并重新赋值给children即_hoisted_4
[i]。
到此,挂载完成。
可想而知,挂载完成后,children即_hoisted_4
中的内容是最后一个div的两个虚拟子节点。
卸载阶段
这里卸载的虚拟节点的type是Symbol(v-fgt)
这是vue处理<template v-if>
标签时创建的虚拟节点,这里需要关注的是unmount
方法的第四个参数doRemove
,传入了true。
ts
type UnmountFn = (
vnode: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
doRemove?: boolean,
optimized?: boolean
) => void
const unmount: UnmountFn
进入unmount函数,会走到type===Fragment
的分支。
ts
else if (
(type === Fragment &&
patchFlag &
(PatchFlags.KEYED_FRAGMENT | PatchFlags.UNKEYED_FRAGMENT)) ||
(!optimized && shapeFlag & ShapeFlags.ARRAY_CHILDREN)
) {
unmountChildren(children as VNode[], parentComponent, parentSuspense)
}
调用unmountChildren
方法。
ts
type UnmountChildrenFn = (
children: VNode[],
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
doRemove?: boolean,
optimized?: boolean,
start?: number
) => void
const unmountChildren: UnmountChildrenFn = (
children,
parentComponent,
parentSuspense,
doRemove = false,
optimized = false,
start = 0
) => {
for (let i = start; i < children.length; i++) {
unmount(children[i], parentComponent, parentSuspense, doRemove, optimized)
}
}
调用时没有传入第四个参数,默认是false。然后会递归调用unmount
方法。
注意,此时传入的doRemove
是false。
循环调用unmount
传入div的虚拟节点
此时走到unmount
方法中的这个分支
ts
else if (
dynamicChildren &&
// #1153: fast path should not be taken for non-stable (v-for) fragments
(type !== Fragment ||
(patchFlag > 0 && patchFlag & PatchFlags.STABLE_FRAGMENT))
) {
// fast path for block nodes: only need to unmount dynamic children.
unmountChildren(
dynamicChildren,
parentComponent,
parentSuspense,
false,
true
)
}
dynamicChildren
是空数组,所以unmountChildren
不会发生什么。
继续往下走,unmount
方法中的最后有
ts
if (doRemove) {
remove(vnode)
}
此时doRemove
为false,不会调用remove
方法。
处理完三个div的节点后,函数回到上一层。接着处理type是Symbol(v-fgt)
的虚拟节点。而此时doRemove
为true,调用remove
方法。
ts
const remove: RemoveFn = vnode => {
const { type, el, anchor, transition } = vnode
if (type === Fragment) {
if (
__DEV__ &&
vnode.patchFlag > 0 &&
vnode.patchFlag & PatchFlags.DEV_ROOT_FRAGMENT &&
transition &&
!transition.persisted
) {
;(vnode.children as VNode[]).forEach(child => {
if (child.type === Comment) {
hostRemove(child.el!)
} else {
remove(child)
}
})
} else {
removeFragment(el!, anchor!)
}
return
}
if (type === Static) {
removeStaticNode(vnode)
return
}
const performRemove = () => {
hostRemove(el!)
if (transition && !transition.persisted && transition.afterLeave) {
transition.afterLeave()
}
}
if (
vnode.shapeFlag & ShapeFlags.ELEMENT &&
transition &&
!transition.persisted
) {
const { leave, delayLeave } = transition
const performLeave = () => leave(el!, performRemove)
if (delayLeave) {
delayLeave(vnode.el!, performRemove, performLeave)
} else {
performLeave()
}
} else {
performRemove()
}
}
会走到removeFragment
方法。
ts
const removeFragment = (cur: RendererNode, end: RendererNode) => {
// For fragments, directly remove all contained DOM nodes.
// (fragment child nodes cannot have transition)
let next
while (cur !== end) {
next = hostNextSibling(cur)!
hostRemove(cur)
cur = next
}
hostRemove(end)
}
从这里可以看到,会依次删除掉3个div的真实dom。
到此,整个<template v-if>
卸载完成。
那到底内存泄漏在哪里?
还记得_hoissted_4
保存的是最后一个虚拟div节点的两个虚拟span节点,而节点中的el属性依然维持着真实节点的引用,不会被GC,
所以这就造成了内存泄漏。这里就解释了那两个游离的span节点。
好奇的你一定会问:还有一个游离的div和两个游离的text节点哪里来的呢?
不要忘记了,el中也会保持对父亲和儿子的引用。详情见下图
每一个span都有一个text儿子,共用一个div父节点,完美解释了前面提到的所有游离节点。
案例2
将案例1的代码稍稍做下改动。
ts
<div id="app"></div>
<script type="module">
import {
createApp,
ref,
} from '../packages/vue/dist/vue.esm-browser.js'
const app = createApp({
setup() {
const show = ref(false)
return {
show,
}
},
template: `
<div>
<button @click="show=!show">show</button>
<div v-if="show">
<template v-for="i in 3">
<div>
<span>12</span>
<span>34</span>
</div>
</template>
</div>
</div>
`
})
app.mount('#app')
</script>
点击按钮前:游离节点只有一个,这是热更新导致的,不需要管。
点击两次按钮后:
你会震惊地发现,我就改变了一个标签,泄漏的节点竟然多了这么多。 对比可以发现多出了六个span和和四个div和八个text的游离节点,最下面的注释节点不需要管。
同样查看编译后的结果
ts
const _Vue = Vue
const { createElementVNode: _createElementVNode, createCommentVNode: _createCommentVNode } = _Vue
const _hoisted_1 = ["onClick"]
const _hoisted_2 = { key: 0 }
const _hoisted_3 = /*#__PURE__*/_createElementVNode("span", null, "12", -1 /* HOISTED */)
const _hoisted_4 = /*#__PURE__*/_createElementVNode("span", null, "34", -1 /* HOISTED */)
const _hoisted_5 = [
_hoisted_3,
_hoisted_4
]
return function render(_ctx, _cache, $props, $setup, $data, $options) {
with (_ctx) {
const { createElementVNode: _createElementVNode, renderList: _renderList, Fragment: _Fragment, openBlock: _openBlock, createElementBlock: _createElementBlock, createCommentVNode: _createCommentVNode } = _Vue
return (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("button", {
onClick: $event => (show=!show)
}, "show", 8 /* PROPS */, _hoisted_1),
show
? (_openBlock(), _createElementBlock("div", _hoisted_2, [
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(3, (i) => {
return (_openBlock(), _createElementBlock("div", null, _hoisted_5))
}), 256 /* UNKEYED_FRAGMENT */))
]))
: _createCommentVNode("v-if", true)
]))
}
}
这里关注的是_hoisted_5
节点
挂载阶段
挂载和案例1的过程大差不差,只需要知道挂载完成后,children即_hoisted_5
中的内容是最后一个div的两个虚拟子节点。
卸载阶段
这里卸载的虚拟节点的type是div
,这是<div v-if>
的虚拟节点,这里需要关注的是unmount
方法的第四个参数doRemove
,传入了true。
ts
type UnmountFn = (
vnode: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
doRemove?: boolean,
optimized?: boolean
) => void
const unmount: UnmountFn
进入unmount函数,会走到这个分支。
go
else if (
dynamicChildren &&
// #1153: fast path should not be taken for non-stable (v-for) fragments
(type !== Fragment ||
(patchFlag > 0 && patchFlag & PatchFlags.STABLE_FRAGMENT))
) {
// fast path for block nodes: only need to unmount dynamic children.
unmountChildren(
dynamicChildren,
parentComponent,
parentSuspense,
false,
true
)
}
dynamicChildren
是长度为1的数组,保存着:
注意,这里传入的 doRemove
参数是false,这是和案例一同样是卸载Fragment的重大区别。
ts
type UnmountChildrenFn = (
children: VNode[],
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
doRemove?: boolean,
optimized?: boolean,
start?: number
) => void
const unmountChildren: UnmountChildrenFn = (
children,
parentComponent,
parentSuspense,
doRemove = false,
optimized = false,
start = 0
) => {
for (let i = start; i < children.length; i++) {
unmount(children[i], parentComponent, parentSuspense, doRemove, optimized)
}
}
接着调用unmount
方法,传入的doRemove
是false。
新的unmount
会走这个分支
ts
else if (
(type === Fragment &&
patchFlag &
(PatchFlags.KEYED_FRAGMENT | PatchFlags.UNKEYED_FRAGMENT)) ||
(!optimized && shapeFlag & ShapeFlags.ARRAY_CHILDREN)
) {
unmountChildren(children as VNode[], parentComponent, parentSuspense)
}
调用时没有传入第四个参数,默认是false。然后会递归调用unmount
方法。
处理3个div时与案例一一样,接着回到处理Framgent。
继续往下走,unmount
方法中的最后有
ts
if (doRemove) {
remove(vnode)
}
此时doRemove
为false,不会调用remove
方法。所以到这里,依旧没有到案例一的循环删除真实节点的环节。接着往下看。
处理完Framgent
,再回到<div v-if>
对应的虚拟节点的那一层。
ts
if (doRemove) {
remove(vnode)
}
此时doRemove为true,进入remove函数。
ts
const remove: RemoveFn = vnode => {
const { type, el, anchor, transition } = vnode
...
...
...
const performRemove = () => {
hostRemove(el!)
if (transition && !transition.persisted && transition.afterLeave) {
transition.afterLeave()
}
}
if (
vnode.shapeFlag & ShapeFlags.ELEMENT &&
transition &&
!transition.persisted
) {
const { leave, delayLeave } = transition
const performLeave = () => leave(el!, performRemove)
if (delayLeave) {
delayLeave(vnode.el!, performRemove, performLeave)
} else {
performLeave()
}
} else {
//走到这
performRemove()
}
}
调用performRemove
,此时<div v-if>
这个节点从文档中移除,那么它所包含的所有子节点也移除了。
此时卸载阶段完成。
现在来思考一下,游离的节点是从哪里来的?
同样的,_hoisted_5
中的两个虚拟节点中还各自保存着相应的el 即span。那么根据其引用父亲和儿子将全部不能被GC,所以变成了游离的节点。
这时的你会有所疑惑,为什么同样是维持着父子关系,案例一种游离的只有2个span,2个text和一个div,而这却多了这么多。
进入解答环节:
对比可以发现,关键点在于方案二中处理Fragment
时没有进入到
ts
if (doRemove) {
remove(vnode)
}
也就没有到
ts
const removeFragment = (cur: RendererNode, end: RendererNode) => {
// For fragments, directly remove all contained DOM nodes.
// (fragment child nodes cannot have transition)
let next
while (cur !== end) {
next = hostNextSibling(cur)!
hostRemove(cur)
cur = next
}
hostRemove(end)
}
取而代之的是直接将最顶层的div从文档删除。那么_hoisted_5
中的两个虚拟节点中保存的el,其parentNode是div,而div中又保持着兄弟节点(因为没有显示地删除,所以会继续存在),即剩余的两个div以及它的父节点即<div v-if>
,而各自又保存着各自的儿子。
所以游离的个数:
Span: 3 * 2 =6
Div: 3 + 1 = 4
Text: 3*2 = 6
等等,text节点不是有8个吗,还有两个在哪里?
这就要提到vue处理Fragment
的时候做的处理了。
可以看到,处理Fragment
时会创建两个空节点,作为子节点插入的锚点。所以上下会多了两个文本节点。如下图。
所以最终的游离个数:
Span: 3 * 2 =6
Div: 3 + 1 = 4
Text: 3*2+2 = 8
到此,完美解释了所有的游离节点。
通过案例引出解决方案
可以看到一个标签的改变,直接改变了游离节点的个数,设想一下,这是一个表格,而且里面包含静态提升的节点,那么整个表格将会变成游离节点,发生内存泄漏,这是我在项目中的亲身经历,才会引发我写出这篇文章。
好了,为什么使用element的图标库会造成内存泄漏?
看看源码:
ts
// src/components/add-location.vue
var _hoisted_1 = {
viewBox: "0 0 1024 1024",
xmlns: "http://www.w3.org/2000/svg"
}, _hoisted_2 = /* @__PURE__ */ _createElementVNode("path", {
fill: "currentColor",
d: "M288 896h448q32 0 32 32t-32 32H288q-32 0-32-32t32-32z"
}, null, -1), _hoisted_3 = /* @__PURE__ */ _createElementVNode("path", {
fill: "currentColor",
d: "M800 416a288 288 0 1 0-576 0c0 118.144 94.528 272.128 288 456.576C705.472 688.128 800 534.144 800 416zM512 960C277.312 746.688 160 565.312 160 416a352 352 0 0 1 704 0c0 149.312-117.312 330.688-352 544z"
}, null, -1), _hoisted_4 = /* @__PURE__ */ _createElementVNode("path", {
fill: "currentColor",
d: "M544 384h96a32 32 0 1 1 0 64h-96v96a32 32 0 0 1-64 0v-96h-96a32 32 0 0 1 0-64h96v-96a32 32 0 0 1 64 0v96z"
}, null, -1), _hoisted_5 = [
_hoisted_2,
_hoisted_3,
_hoisted_4
];
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("svg", _hoisted_1, _hoisted_5);
}
这是什么?天,静态提升!所以只要使用了就会造成内存泄漏!
解决方案一在开头就有,就是关闭静态提升。没错。关闭后就不会出现静态提升的数组,就不会有数组中的虚拟节点一直引用着el。
一开始以为是element的锅,经过深层次分析,其实不是。这里只要换成任意静态节点并开启静态提升都会有这个问题。
解决方案2
先来看看关闭静态提升后,案例一和案例二编译后的结果: 案例一:
ts
const _Vue = Vue
return function render(_ctx, _cache, $props, $setup, $data, $options) {
with (_ctx) {
const { createElementVNode: _createElementVNode, renderList: _renderList, Fragment: _Fragment, openBlock: _openBlock, createElementBlock: _createElementBlock, createCommentVNode: _createCommentVNode } = _Vue
return (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("button", {
onClick: $event => (show=!show)
}, "show", 8 /* PROPS */, ["onClick"]),
show
? (_openBlock(true), _createElementBlock(_Fragment, { key: 0 }, _renderList(3, (i) => {
return (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("span", null, "12"),
_createElementVNode("span", null, "34")
]))
}), 256 /* UNKEYED_FRAGMENT */))
: _createCommentVNode("v-if", true)
]))
}
}
案例二:
ts
const _Vue = Vue
return function render(_ctx, _cache, $props, $setup, $data, $options) {
with (_ctx) {
const { createElementVNode: _createElementVNode, renderList: _renderList, Fragment: _Fragment, openBlock: _openBlock, createElementBlock: _createElementBlock, createCommentVNode: _createCommentVNode } = _Vue
return (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("button", {
onClick: $event => (show=!show)
}, "show", 8 /* PROPS */, ["onClick"]),
show
? (_openBlock(), _createElementBlock("div", { key: 0 }, [
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(3, (i) => {
return (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("span", null, "12"),
_createElementVNode("span", null, "34")
]))
}), 256 /* UNKEYED_FRAGMENT */))
]))
: _createCommentVNode("v-if", true)
]))
}
}
可以看到关键在于每次都会创建一个新的数组,这样卸载之后,这个数组能被GC,自然就不会存在对el的引用,不会产生游离的节点,自然就不会发生内存泄漏。
所以,编写一个插件,对element的图标组件库进行改造。这里以vite为例。
ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import fixHoistStatic from "./plugins/fix-hoist-static";
export default defineConfig(({ command, mode }) => {
return {
optimizeDeps: {
exclude: ["@element-plus/icons-vue"],
},
plugins: [
vue(),
fixHoistStatic(),
],
};
});
ts
// plugins/fix-hoist-static
const reg = /(_createElementBlock\d*("svg", _hoisted_\d+, )(_hoisted_\d+)/g;
export default () => ({
name: "fix_hoistStatic",
transform(code, id) {
if (id.includes("@element-plus/icons-vue/dist/index.js")) {
code = code.replace(reg, "$1[...$2]");
return code;
}
},
});
这里利用正则将数组替换成解构的数组。
因为vite会进行依赖预构建,所以开发阶段需要添加配置排除。详情查看
编译后
ts
// src/components/add-location.vue
var _hoisted_1 = {
viewBox: "0 0 1024 1024",
xmlns: "http://www.w3.org/2000/svg"
}, _hoisted_2 = /* @__PURE__ */ _createElementVNode("path", {
fill: "currentColor",
d: "M288 896h448q32 0 32 32t-32 32H288q-32 0-32-32t32-32z"
}, null, -1), _hoisted_3 = /* @__PURE__ */ _createElementVNode("path", {
fill: "currentColor",
d: "M800 416a288 288 0 1 0-576 0c0 118.144 94.528 272.128 288 456.576C705.472 688.128 800 534.144 800 416zM512 960C277.312 746.688 160 565.312 160 416a352 352 0 0 1 704 0c0 149.312-117.312 330.688-352 544z"
}, null, -1), _hoisted_4 = /* @__PURE__ */ _createElementVNode("path", {
fill: "currentColor",
d: "M544 384h96a32 32 0 1 1 0 64h-96v96a32 32 0 0 1-64 0v-96h-96a32 32 0 0 1 0-64h96v-96a32 32 0 0 1 64 0v96z"
}, null, -1), _hoisted_5 = [
_hoisted_2,
_hoisted_3,
_hoisted_4
];
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("svg", _hoisted_1, [..._hoisted_5]);
}
打开控制台进行测试,完美解决内存泄漏!