近期,我们遇到了 Vue 直出内存泄露问题,并进行了追查。其项目背景是,我们在一次规模较大的运营活动中正好碰到了内存泄漏的问题,技术背景和业务背景分别如下:
本文将回顾整个追查的实践过程。
项目上线之后,前端收到了报错,伴随着就是 CPU 占有增加,内存增加。
报错伴随着内存泄漏,那么立即根据报错信息,修复问题。
报错的原因是me.masterInfo.fans_num不存在,后端的一定不可用性导致该字段出现问题。
c computed: {
fansNum: function () {
var me = this;
var fansNum = [];
if (me.masterInfo.fans_num && me.masterInfo.fans_num.toString()) {
fansNum = Array.from(('0000000' + me.masterInfo.fans_num).slice(-8));
}
return fansNum;
}
},
修复解决完毕,内存泄漏修复完成。
这里大概猜测一下,熟悉Vue的基本知道,computed里面的function是需要return的,如果没有会导致报错,报错是正常的,内存泄漏缺是不正常的,两者是不是似乎有关系呢?
这里抽离了基本代码,印证是否是computed引起。
define(function (require, exports, module) {
module.exports = function (data, tmpl) {
return {
template: tmpl.main(),
computed: {
'Test': function (argument) {
throw 'error happened';
return 1;
}
}
};
};
});
直出端正常的报错。
我们这里尝试部署到开发机上。
AB 压测:
ab -c 2000 -n 50000
-H 'Accept-Encoding: gzip, deflate, sdch'
-H 'Accept-Language: zh-CN,zh;q=0.8,en;q=0.6,zh-TW;q=0.4'
-H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X)'
-H 'Accept: text/html,p,*/*;q=0.8' -H 'Cache-Control: max-age=0'
-H 'Cookie: 'p_skey=*******'
-H 'Proxy-Connection: keep-alive'
-X ip地址xx.xx.xx.xx
-k
'http://活动地址'
2000 的并发,50000 的请求量,这里其实不大,但是对于排查错误已经足够了。
这里我们这边的node服务框架提供了堆转储的快照功能,于是我们去下载快照如下:
上面这个是压测无报错的正常页面情况,其实这里搜索 Vue $ 2 的对象是没有的。
下面这个是压测有报错的异常页面情况,这里搜索 Vue $ 2 发现创建了 4368 个对象。
现在基本能确定是中 Vue 引发的情况,虽然内部的node框架很好的帮助了我们的排查但是脱离本身的node框架是必要的。
app.js
'use strict';
const path = require('path');
const Vue = require('vue/dist/vue.runtime.common.js');
const VueRender = require('vue-server-renderer').createRenderer();
var timer = setInterval(function(argument) {
const App = {
template: '{{test}}
',
computed: {
"test": function() {
throw 'erro happened';
return 1;
}
}
}
const vm = new Vue(App);
VueRender.renderToString(vm, function(error, html) {
if (error) {
console.log('error happened', error)
} else {
console.log(html);
}
})
}, 10);
无限循环下:
25221 user_00 20 0 973m 123m 14m S 3.0 3.0 0:10.72 node
25221 user_00 20 0 1003m 141m 14m S 3.3 3.7 0:13.07 node
25221 user_00 20 0 1200m 332m 14m S 13.0 8.7 0:34.88 node
通过上面,我们发现内存确实不断增长。
var timers = setInterval( function (argument) {
heapdump.writeSnapshot('./' + Date.now() + '.heapsnapshot');
}, 30000);
紧接着安装heapdump(查看 V8 内存消耗,并生产快照的实用工具)
红框的是有error发生的,无报错的heapdump的快照其实很小:
已经解决了 https://github.com/Vuejs/Vue/issues/5975 // 提供 issure 给 Vue。
PS:虽然 Vue 已经更改 bug,但我仍然事后诸葛亮去追寻这个问题 ;
(1)Vue $ 2 对象是哪里来的,自然而然是 Vue export的一个对象。既然computed里面的报错导致了内存泄漏,那么我们就去看我们创建的vue对象的computed在哪里被init的。
// src/core/instance/index.js
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
(2)init作为Vue调用的第一个方法,将我们所有的参数options传递进去,那么这个入口没问题。
computed作为Vue的非静态属性,那么一定会在Vue的声明周期中去执行,那么初步猜测,initState不论从命名还是时机都符合。
// src/core/instance/init.js
vm._self = vm
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch) initWatch(vm, opts.watch)
}
于是我们去看initComputed的代码:
function initComputed (vm, computed) {
var watchers = vm._computedWatchers = Object.create(null);
for (var key in computed) {
var userDef = computed[key];
var getter = typeof userDef === 'function' ? userDef : userDef.get;
// create internal watcher for the computed property.
// 这句话是重点啊 传入了我们报错的getter,并且开启了dirty为true。
watchers[key] = new Watcher(vm, getter, noop, computedWatcherOptions);
// component-defined computed properties are already defined on the
// component prototype. We only need to define computed properties defined
// at instantiation here.
if (!(key in vm)) {
defineComputed(vm, key, userDef);
}
}
}
这里注意,在initComputed方法的时候我们会有一个vm._computedWatchers,并对里面每个key(我们这里是test里面新建一个watcher,这里先不讲这个watcher是为了什么)
因为我们创建的test是不在Vue里面的值,故而,defineComputed;
function defineComputed (target, key, userDef) {
if (typeof userDef === 'function') {
sharedPropertyDefinition.get = createComputedGetter(key);
sharedPropertyDefinition.set = noop;
} else {
sharedPropertyDefinition.get = userDef.get
? userDef.cache !== false
? createComputedGetter(key)
: userDef.get
: noop;
sharedPropertyDefinition.set = userDef.set
? userDef.set
: noop;
}
Object.defineProperty(target, key, sharedPropertyDefinition);
}
Vue数据监听的本质是 defineProperty,万变不离其宗,最后执行了defineProperty,并且createComputedGetter则作为get的赋值方法。
现在,我们的computed已经被注入代码之中了,那么下一次解析我们测试demo里面的{{test}}的时候,那么get方法将被触发,createComputedGetter()调用开始:
function createComputedGetter (key) {
return function computedGetter () {
var watcher = this._computedWatchers && this._computedWatchers[key];
if (watcher) {
if (watcher.dirty) {
watcher.evaluate();
}
if (Dep.target) {
watcher.depend();
}
return watcher.value
}
}
}
现在用到我们在initComputed里面新建的watcher了,对于新建的computedWatcher,lazy为true,那么dirty也是true.(这里是这么考虑的,新建的computed里面,假设里面用到了vue的一些其他被监听的data,那么势必要进行一次重新的get计算)。
Watcher.prototype.evaluate = function evaluate () {
this.value = this.get();
this.dirty = false;
};
ps: 其实watcher在vue里面就是负责对表达式求值,触发defineProperty里面的get函数。
现在是最关键代码了:
Watcher.prototype.get = function get () {
pushTarget(this);
var value;
var vm = this.vm;
if (this.user) {
try {
value = this.getter.call(vm, vm);
} catch (e) {
handleError(e, vm, ("getter for watcher \"" + (this.expression) + "\""));
}
} else {
value = this.getter.call(vm, vm);
}
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value);
}
popTarget();
this.cleanupDeps();
return value
};
一步一步分析如下:
这里引入一个 Dep 的概念,在 vue 中如何在每次数据发生改变的时候通知有哪些属性依赖了这个变化的对象呢,Deps 就是作为依赖收集器负责将所有依赖被改变对象的对象收集起来(在 defineProperty 的 get 里面),并且触发他们(在 definePropery 的 set 里面)。
所以在每次 defineProperty 里面会有 new 一个 Dep 对象,
这样就可以理解,凡是 Vue 中有一个 watcher 执行到了,那么势必会执行 Dep.target 的添加,和被监听属性 get 里面的 Dep 的新建,而被监听属性 set 触发,那么这个 Dep 就会按照 target 全部 notify。
Dep.target = null;
var targetStack = [];
function pushTarget (_target) {
if (Dep.target) { targetStack.push(Dep.target); }
Dep.target = _target;
}
function popTarget () {
Dep.target = targetStack.pop();
}
但是我们watch方法,以及computed方法而言,每次收集和执行时相当于独立于其他data的监听的,所以我们这边用了targetStack的堆栈临时存放Dep.target进行依赖收集,这点其实算是用了一个比较讨巧的方法吧。执行完,得到value,然后在pop回去,Dep的target原来是什么就是什么好了。
对于用户自定义而言的callback,Vue这里做了处理,防止报错。
然而,计算的方法是在代码里是属于 user!= true 里面的。
于是:
value = this.getter.call(vm, vm);
——这句话华丽丽的挂掉了。(this.getter 里面是我们写的报错的代码.....)
targetStack作为一个全局定义的堆栈,我们每次请求向里面pop了一个watcher对象(这个读者可以打出看看,其实还不小的对象的),然后呢,错误,异常。其实targetStack一直没有被销毁。
长期以往,当每次请求都过来,都向targetStack里面pop一个watcher,好吧,内存泄漏了。
好吧,剩下我们看看他们的解决方案吧:
简单粗暴,不再以this.user作为判断了,这个只给$watch用。
当然value = this.getter.call(vm, vm)被try catch了,防止报错。
最后,finally就是决定了最后报错情况下依旧会进行处理,这里主要是 popTarget的执行,使得之前发生的内存泄漏得以消亡。
以上,这个问题总结完毕,不过不得不承认Vue的开发效率还是尤为高效的。
最后分享一下分析 Vue源码文章《Vue 2.1.7源码学习》,个人认为适合在一开始阅读:
http://hcysun.me/2017/03/03/Vue%E6%BA%90%E7%A0%81%E5%AD%A6%E4%B9%A0/
最后再附上一个仿这次内存泄漏的例子:
eg.js
'use strict';const path = require('path');const Zue = require('./test.js');const heapdump = require('heapdump');var timer = setInterval(function(argument) { const App = {} const vm = new Zue(App);
}, 10);var timers = setInterval( function (argument) {
heapdump.writeSnapshot('./' + Date.now() + '.heapsnapshot');
}, 10000);
test.js
function Zue (options) {
pushTarget(this);
}var targetStack = [];function pushTarget (_target) {
targetStack.push(_target);
}module.exports = Zue;
然后node eg.js
然后 ok,本文完。
今日荐文
点击下方图片即可阅读
百度 Web 生态构建:发布基于 Vue 的 PWA 解决方案 LAVAS;将全面支持 Web AR
推荐一个不可错过的会议:ArchSummit 全球架构师峰会,邀请了上百位国内外顶尖技术专家前来分享各业务的核心架构设计,从 Web 协议底层优化到超级 App 的系列魔改,这里只谈最优秀的架构实践。
7 月 7 日,ArchSummit 全球架构师峰会即将开幕!如需报名,可直接识别下方二维码联系大会售票天使豆包,欢迎骚扰~!
「前端之巅」是 InfoQ 旗下关注前端技术的垂直社群,加入前端之巅学习群请关注「前端之巅」公众号后回复“加群”。投稿请发邮件到 [email protected],注明“前端之巅投稿”。