Skip to content

JS 内存泄漏

题目

如何检测 JS 内存泄漏?内存泄漏的场景有哪些?

垃圾回收

正常情况下,一个函数执行完,其中的变量都会是会 JS 垃圾回收。

js
function fn() {
    const a = 'aaa'
    console.log(a)

    const obj = {
        x: 100
    }
    console.log(obj)
}
fn()

但某些情况下,变量是销毁不了的,因为可能会被再次使用。

js
function fn() {
    const obj = {
        x: 100
    }
    window.obj = obj // 引用到了全局变量,obj 销毁不了
}
fn()
js
function genDataFns() {
    const data = {} // 闭包,data 销毁不了
    return {
        get(key) {
            return data[key]
        },
        set(key, val) {
            data[key] = val
        }
    }
}
const { get, set } = genDataFns()

变量销毁不了,一定就是内存泄漏吗?—— 不一定

垃圾回收算法 - 引用计数

早起的垃圾回收算法,以“数据是否被引用”来判断要不要回收。

js
// 对象被 a 引用
let a = {
    b: {
        x: 10
    }
}

let a1 = a // 又被 a1 引用
let a = 0 // 不再被 a 引用,但仍然被 a1 引用
let a1 = null // 不再被 a1 引用

// 对象最终没有任何引用,会被回收

但这个算法有一个缺陷 —— 循环引用。例如

js
function fn() {
    const obj1 = {}
    const obj2 = {}
    obj1.a = obj2
    obj2.a = obj1 // 循环引用,无法回收 obj1 和 obj2
}
fn()

此前有一个很著名的例子。IE6、7 使用引用计数算法进行垃圾回收,常常因为循环引用导致 DOM 对象无法进行垃圾回收。
下面的例子,即便界面上删除了 div1 ,但在 JS 内存中它仍然存在,包括它的所有属性。但现代浏览器已经解决了这个问题。

js
var div1
window.onload = function () {
    div1 = document.getElementById('div1')
    div1.aaa = div1
    div1.someBigData = { ... } // 一个体积很大的数据。
}

以上这个例子就是内存泄漏。即,不希望它存在的,它却仍然存在,这是不符合预期的。关键在于“泄漏”。

垃圾回收算法 - 标记清除

基于上面的问题,现代浏览器使用“标记-清除”算法。根据“是否是否可获得”来判断是否回收。

定期从根(即全局变量)开始向下查找,能找到的即保留,找不到的即回收。循环引用不再是问题。

检测内存变化

可使用 Chrome devTools Performance 来检测内存变化

  • 刷新页面,点击“GC”按钮
  • 点击“Record”按钮开始记录,然后操作页面
  • 操作结束,点击“GC”按钮,点击“结束”按钮,看分析结果

代码参考 memory-change.html

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>memory change</title>
</head>
<body>
    <p>
        memory change
        <button id="btn1">start</button>
    </p>

    <script>
        const arr = []
        for (let i = 0; i < 10 * 10000; i++) {
            arr.push(i)
        }

        function bind() {
            // 模拟一个比较大的数据
            const obj = {
                str: JSON.stringify(arr) // 简单的拷贝
            }

            window.addEventListener('resize', () => {
                console.log(obj)
            })
        }

        let n = 0
        function start() {
            setTimeout(() => {
                bind()
                n++

                // 执行 50 次
                if (n < 50) {
                    start()
                } else {
                    alert('done')
                }
            }, 200)
        }

        document.getElementById('btn1').addEventListener('click', () => {
            start()
        })
    </script>
</body>
</html>

内存泄漏的场景

拿 Vue 来举例说明。

组件中有全局变量、函数的引用。组件销毁时要记得清空。

js
export default {
    data() {
        return {
            nums: [10, 20, 30]
        }
    },
    mounted() {
        window.printNums = () => {
            console.log(this.nums)
        }
    },
    // beforeUnmount() {
    //     window.printNums = null
    // },
}

组件有全局定时器。组件销毁时要记得清除。

js
export default {
    data() {
        return {
            // intervalId: 0,
            nums: [10, 20, 30]
        }
    },
    // methods: {
    //     printNums() {
    //         console.log(this.nums)
    //     }
    // },
    mounted() {
        setInterval(() => {
            console.log(this.nums)
        }, 200)
        
        // this.intervalId = setInterval(this.printNums, 200)
    },
    beforeUnmount() {
        // clearInterval(this.intervalId)
    },
}

组件中有全局事件的引用。组件销毁时记得解绑。

js
export default {
    data() {
        return {
            nums: [10, 20, 30]
        }
    },
    // methods: {
    //     printNums() {
    //         console.log(this.nums)
    //     }
    // },
    mounted() {
        window.addEventListener('resize', () => {
            console.log(this.nums)
        })
        // window.addEventListener('reisze', this.printNums)
    },
    beforeUnmount() {
        // window.removeEventListener('reisze', this.printNums)
    },
}

组件中使用了自定义事件,销毁时要记得解绑。

js
export default {
    data() {
        return {
            nums: [10, 20, 30]
        }
    },
    // methods: {
    //     printNums() {
    //         console.log(this.nums)
    //     }
    // },
    mounted() {
        event.on('event-key', () => {
            console.log(this.nums)
        })

        // event.on('event-key', this.printNums)
    },
    beforeUnmount() {
        // event.off('event-key', this.printNums)
    },
}

闭包是内存泄漏吗

上述代码 genDataFns() 就是一个很典型的闭包,闭包的变量是无法被垃圾回收的。

但闭包不是内存泄漏,因为它是符合开发者预期的,即本身就这么设计的。而内存泄漏是非预期的。

【注意】这一说法没有定论,有些面试官可能会说“不可被垃圾回收就是内存泄漏”,不可较真。

答案

  • 可使用 Chrome devTools Performance 检测内存变化
  • 内存泄漏的场景
    • 全局变量,函数
    • 全局事件
    • 全局定时器
    • 自定义事件
    • 闭包(无定论)

划重点

前端之前不太关注内存泄漏,因为不会像服务单一样 7*24 运行。
而随着现在富客户端系统不断普及,内存泄漏也在慢慢的被重视。

扩展

WeakMap WeakSet 弱引用,不会影响垃圾回收。

js
// 函数执行完,obj 会被销毁,因为外面的 WeakMap 是“弱引用”,不算在内
const wMap = new WeakMap()
function fn() {
    const obj = {
        name: 'zhangsan'
    }
    // 注意,WeakMap 专门做弱引用的,因此 WeakMap 只接受对象作为键名(`null`除外),不接受其他类型的值作为键名。其他的无意义
    wMap.set(obj, 100) 
}
fn()
// 代码执行完毕之后,obj 会被销毁,wMap 中也不再存在。但我们无法第一时间看到效果。因为:
// 内存的垃圾回收机制,不是实时的,而且是 JS 代码控制不了的,因此这里不一定能直接看到效果。
js
// 函数执行完,obj 会被销毁,因为外面的 WeakSet 是“弱引用”,不算在内
const wSet = new WeakSet()
function fn() {
    const obj = {
        name: 'zhangsan'
    }
    wSet.add(obj) // 注意,WeakSet 就是为了做弱引用的,因此不能 add 值类型!!!无意义
}
fn()

wangEditor 多次销毁创建,测试内存泄漏。日常开发时可以参考这种方式
参考 examples/batch-destroy.html