解决vue3中内存泄漏的问题
作者:PK
vue3的内存泄漏
在项目中会发现一个奇怪的现象,当使用element-plus中的图标组件时会出现内存泄漏。详情查看
解决方案1:关闭静态提升。详情查看
解决方案2:参考本文
至于为什么静态提升会导致内存泄漏,本文将通过几个案例的源码分析详细讲解。
案例1
<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的游离节点,最下面的注释节点不需要管。
先来看一下这个模板编译后的结果:
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
。
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
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。
type UnmountFn = ( vnode: VNode, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, doRemove?: boolean, optimized?: boolean ) => void const unmount: UnmountFn
进入unmount函数,会走到type===Fragment
的分支。
else if ( (type === Fragment && patchFlag & (PatchFlags.KEYED_FRAGMENT | PatchFlags.UNKEYED_FRAGMENT)) || (!optimized && shapeFlag & ShapeFlags.ARRAY_CHILDREN) ) { unmountChildren(children as VNode[], parentComponent, parentSuspense) }
调用unmountChildren
方法。
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
方法中的这个分支
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
方法中的最后有
if (doRemove) { remove(vnode) }
此时doRemove
为false,不会调用remove
方法。
处理完三个div的节点后,函数回到上一层。接着处理type是Symbol(v-fgt)
的虚拟节点。而此时doRemove
为true,调用remove
方法。
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
方法。
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的代码稍稍做下改动。
<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的游离节点,最下面的注释节点不需要管。
同样查看编译后的结果
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。
type UnmountFn = ( vnode: VNode, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, doRemove?: boolean, optimized?: boolean ) => void const unmount: UnmountFn
进入unmount函数,会走到这个分支。
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的重大区别。
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
会走这个分支
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
方法中的最后有
if (doRemove) { remove(vnode) }
此时doRemove
为false,不会调用remove
方法。所以到这里,依旧没有到案例一的循环删除真实节点的环节。接着往下看。
处理完Framgent
,再回到<div v-if>
对应的虚拟节点的那一层。
if (doRemove) { remove(vnode) }
此时doRemove为true,进入remove函数。
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
时没有进入到
if (doRemove) { remove(vnode) }
也就没有到
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的图标库会造成内存泄漏?
看看源码:
// 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
先来看看关闭静态提升后,案例一和案例二编译后的结果: 案例一:
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) ])) } }
案例二:
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为例。
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(), ], }; });
// 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会进行依赖预构建,所以开发阶段需要添加配置排除。详情查看
编译后
// 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]); }
打开控制台进行测试,完美解决内存泄漏!
以上就是解决vue3中内存泄漏的问题的详细内容,更多关于vue3内存泄漏的资料请关注脚本之家其它相关文章!