本文主要记录日常开发中常见的优化技巧。主要是针对2.x
版本的。
函数式组件是使用 functional
字段来进行声明的。它是一个没有data
响应式数据和this
上下文,也没有生命周期钩子函数这些东西,只接受一个props
。普通对象类型的组件在patch
的时候,如果遇见一个节点是组件,就会递归执行子组件的的初始化话过程。而函数式组件render
生成的是普通vnode
,不会有递归子组件的过程,因此渲染开销会低很多。实际上可以理解成把DOM
抽离了出来,是一种在DOM
层面的复用。
我们可以从源码中看见:
function createComponent(Ctor, data, context, children, tag) {
// ...
// 根据 functional 字段来判断是否为函数式组件
if (isTrue(Ctor.options.functional)) {
return createFunctionalComponent(Ctor, propsData, data, context, children);
}
// ...
// 正常的组件是在此进行初始化方法(包括响应数据和钩子函数的执行)
installComponentHooks(data);
// ...
return vnode;
}
从上面我们可以看见,在创建组件的时候,会根据functional
字段来判断是否为函数式组件,是就会走函数式组件的创建过程,不是就会走正常组件的创建过程(初始化生命周期函数,响应式数据等等)。
函数式组件一般是使用在一些没有交互,不需要存储内部状态,纯展示 UI 的组件上面。比如新闻公告详情这些页面,就是单纯地把数据显示出来。
使用方式如下:
Vue.component("my-component", {
functional: true,
// Props 是可选的
props: {
// ...
},
// 为了弥补缺少的实例
// 提供第二个参数作为上下文
render: function (createElement, context) {
// ...
},
});
在2.5.0
以上的版本,还可以这样子写
在我们平常的开发中,会经常遇见一些列表的数据。这些列表数据是一个Array
数组,数据的每一项又是一个普通对象,但是这些列表数据只是单纯的展示,每一项数据是不需要发生变化的。那么,我们可以使用Object.freeze([])
来冻结列表数据,减少数据响应的层级(递归),提高性能。
我们可以从源码中看见:
export class Observer {
constructor(value: any) {
def(value, "__ob__", this);
if (Array.isArray(value)) {
// 将数组中的所有元素都转化为可被侦测的响应式
this.observeArray(value);
} else {
// 普通对象
this.walk(data);
}
}
walk(data) {
for (const key in data) {
if (Object.hasOwnProperty.call(data, key)) {
// 将普通对象转化为响应式数据
definedRetive(data, key, data[key]);
}
}
}
observeArray(items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
// 监听数组的每一项
observe(items[i]);
}
}
}
export function observe(value, asRootData) {
// 如果监听的数据是一个非对象类型或者是一个vnode,则不进行监听
if (!isObject(value) || value instanceof VNode) {
return;
}
let ob;
if (hasOwn(value, "__ob__") && value.__ob__ instanceof Observer) {
// 已经监听过的数据上面会有__ob__属性
ob = value.__ob__;
} else {
ob = new Observer(value);
}
return ob;
}
从上面我们可以看出,数组里面的数据会被递归进行数据监听,如果数组中的每一项拥有更深层次的对象,这些更深层次的对象也会被递归变成响应式数据。
Object.freeze
是可以将一个对象变为不可配置的,也就是只能读,也就是将configurable
设置为false
,不能进行增删改这些操作。vue 进行数据响应的时候,如果发现是一个不可配置的对象后,就会return
返回,不会执行下面的逻辑,也就是不会把数据变成响应式数据的逻辑。
我们可以从源码中看见:
export function defineReactive(
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
// 获取对象的描述信息
const property = Object.getOwnPropertyDescriptor(obj, key);
// configurable判断是否为可配置的
if (property && property.configurable === false) {
return;
}
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
// ...
},
set: function reactiveSetter(newVal) {
// ...
},
});
}
冻结列表数据一般是使用在那些数据量大,但是又不需要对每一项数据进行修改的场景,通常这些列表数据只是用来展示。比如新闻公告列表。
代码示例:
{{ item.label }}
当我们的页面上有如下代码时:
首页
{{ message }}
{{ count }}
从上面可以看见,该页面由于有一个定时器,所以每秒会触发一次更新。由于 vue 的更新是组件粒度的(只更新发生数据变化的组件,不会递归更新子组件),整个页面都会被重新更新,当我们的页面上还有其他比较复杂的逻辑时,这个更新过程是很耗时的(先转化为 vnode->在进行 patch 对比新旧 vnode->更新)。所以我们要把上面的代码封装成一个组件,减少重新更新的范围。代码如下
count-component 组件
{{ count }}
首页
{{ message }}
我们先看一下下面的代码:
{{ result }}
从上面可以看见,result 这个计算属性在计算结果的时候会频繁访问this.base
这个数据。
我们再看看 vue 中关于数据响应的源码:
function defineReactive(obj: Object, key: string, val: any) {
const dep = new Dep();
let childOb = observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
// ...
// getter的时候进行依赖收集
if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
if (Array.isArray(val)) {
dependArray(val);
}
}
}
return val;
},
set: function reactiveSetter(newVal) {
if (newVal === val) {
return;
}
val = newVal;
childOb = observe(newVal);
dep.notify();
},
});
}
综合来看,在读取this.base
这个属性的时候会触发它的getter
,进而会执行依赖收集相关逻辑代码。result
这个计算属性中,每一次 for 循环都会读取 6 次this.base
属性,一共循环了 1000 次,所以getter
依赖收集相关逻辑代码会被执行 6000 次。这 6000 次做的都是无用功的,从而导致性能下降了。
实际上来说,this.base
只需要被读取一次,然后执行一次依赖收集就可以了。所以我们可以使用局部变量来缓存this.base
属性的值,后续我们就是用这个局部变量代替this.base
,就不会在走依赖收集的相关逻辑了。优化后的代码如下:
{{ result }}
在实际的开发中,我看见有很多人每次取变量的时候都是喜欢直接写this.xxx
,当访问次数多了(特别是在 for 循环里面),性能的缺陷就会凸显出来了。所以当你在一个函数中频繁的读取某个变量值的时候,请记得使用局部变量来缓存变量值。
局部变量这个性能优化其实不单单可以使用在 vue 上面,还可以使用在其他地方。比如我们需要循环一个数组的时候,可以缓存数组的长度,而不是在每次循环的时候读取数组的length
属性(实际上很多人喜欢在循环的直接读取数组的length
属性)。操作 DOM 的时候也要把 DOM 使用局部变量缓存下来,因为 DOM 的读取是相当消耗性能的。
bar1--{{ count }}
bar2--{{ count }}
从上面我们可以看见,style
计算属性返回的东西跟getStyle
函数返回的东西实际上是一样的。但是当我们的定时器启动的时候,就会每一秒触发一次视图的更新。我们可以从控制台中可以看见,每一秒都会打印出一次getStyle
,而style
只打印了一次。这个得益于 vue 的computed
计算属性具有缓存的特性,只有当width
的值发生变化的时候,style
这个计算属性才会重新计算,count
这个属性并不是style
计算属性依赖的变量,所以count
的变化不会影响到count
计算属性。所以我们要善于利用computed
这个计算属性,而不是通过一个methods
函数返回一个值,methods
函数会随着每次视图更新而触发,重新执行一次。如果methods
函数中包含了大量的逻辑运算,就会造成大量的性能损耗。
vue 的计算属性源码如下:
const computedWatcherOptions = { lazy: true };
function initComputed(vm: Component, computed: Object) {
// 往组件实例上面添加一个_computedWatchers属性,保存所有computed watcher
const watchers = (vm._computedWatchers = Object.create(null));
// 遍历computed上面的所有属性
for (const key in computed) {
const userDef = computed[key];
// computed可以是一个函数或者是对象
const getter = typeof userDef === "function" ? userDef : userDef.get;
// 数据响应的watcher
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
);
if (!(key in vm)) {
defineComputed(vm, key, userDef);
}
}
}
function defineComputed(target: any, key: string, userDef: Object | Function) {
if (typeof userDef === "function") {
sharedPropertyDefinition.get = createComputedGetter(key);
sharedPropertyDefinition.set = noop;
} else {
sharedPropertyDefinition.get = userDef.get
? userDef.cache !== false
? createComputedGetter(key)
: createGetterInvoker(userDef.get)
: noop;
sharedPropertyDefinition.set = userDef.set || noop;
}
// 重写get,set
Object.defineProperty(target, key, sharedPropertyDefinition);
}
function createComputedGetter(key) {
// 返回的是一个`getter`
return function computedGetter() {
const watcher = this._computedWatchers && this._computedWatchers[key];
// watcher存在说明computed属性存在
if (watcher) {
// 如果computed依赖的响应式数据发生了变化,就会触发watcher.update,把dirty置为true,重新计算computed属性
// 如果没有发生变化,那么返回的还是上一次的值
if (watcher.dirty) {
// evaluate函数内部会重新获取watcher.value的值,并把watcher.dirty设置为false,下一次就不会被重新计算了
watcher.evaluate();
}
return watcher.value;
}
};
}
function createGetterInvoker(fn) {
return function computedGetter() {
return fn.call(this, this);
};
}
class Watcher {
constructor(vm, expOrFn, cb, options, isRenderWatcher) {
this.vm = vm;
if (isRenderWatcher) {
vm._watcher = this;
}
vm._watchers.push(this);
if (options) {
// 初始化为true
this.lazy = !!options.lazy;
}
this.getter = expOrFn;
// 初始化为true
this.dirty = this.lazy;
// 默认是undefined
this.value = this.lazy ? undefined : this.get();
}
update() {
if (this.lazy) {
// computed依赖的数据发生变化的时候,会把dirty置为true
this.dirty = true;
}
}
evaluate() {
// 重新获取值
this.value = this.get();
this.dirty = false;
}
}
v-for
指令是用来循环列表的。v-if
是用来隐藏组件的,使用v-if
隐藏的组件是不会执行内部的渲染逻辑的。我们看一下如下代码:
{{item}}
当v-if
和v-for
同时出现的时候,v-for
的优先级会比v-if
的高。也就是说class='item'
的 div 首先会被渲染成 10 个 div,然后再判断下标索引号是否为偶数,不是就隐藏掉。其中有 5 次(5 个奇数)渲染是做无用功的。5 次的无用功无疑就会造成性能上面的浪费。所以我们可以借助computed
先过滤掉那些不需要显示的数据,然后在使用v-for
循环列表。代码如下:
{{item}}
有时候我们需要根据某个字段来控制列表是否显示,代码如下:
{{item}}
从上面可以看见,show 为 false,也就意味着做了 10 次没有意义的渲染。我们可以将v-for
和v-if
指令分离,让v-if
先执行,这样就不会做 10 次无意义的渲染了。代码如下:
{{item}}
我们来看看 vue 的源码:
function genElement(el: ASTElement, state: CodegenState): string {
if (el.parent) {
el.pre = el.pre || el.parent.pre;
}
if (el.staticRoot && !el.staticProcessed) {
// 静态节点
return genStatic(el, state);
} else if (el.once && !el.onceProcessed) {
// v-once指令
return genOnce(el, state);
} else if (el.for && !el.forProcessed) {
// v-for指令
return genFor(el, state);
} else if (el.if && !el.ifProcessed) {
// v-if指令
return genIf(el, state);
} else if (el.tag === "template" && !el.slotTarget && !state.pre) {
return genChildren(el, state) || "void 0";
} else if (el.tag === "slot") {
return genSlot(el, state);
} else {
// ...
}
}
从上面的 if-else 判断条件中,我们可以看见v-for
的执行要优先于v-if
不需要渲染在视图的数据不要写在 data 中
渲染在视图的数据是指在
html 模板中使用到的数据,这些数据都是响应式数据来的。而定义在 data 字段中的数据都会变成响应式数据,但是有些数据我们是不需要显示在视图中的,就不要把数据声明在 data 中了,比如在移动端中进行滚动加载,需要使用到分页参数,这些分页参数就不应该写在 data 中的(实际上就我看见的,很多人喜欢吧分页参数pageSize
,pageIndex
写在 data 中的)。
优化前代码如下:
export default {
data() {
return {
pageSize: 10,
pageIndex: 1,
};
},
methods: {
getList() {
axios
.get("xxx", {
params: { pageSize: this.pageSize, pageIndex: this.pageIndex },
})
.then(() => {});
},
scrollBottom() {
this.pageIndex += 1;
this.getList();
},
},
};
上面我们可以看见,pageSize
和pageIndex
被定义在了data
中,这就意味着这 2 个数据将会变成响应式数据,但是实际上pageSize
和pageIndex
不需要像是在视图中。当我们对pageSize
和pageIndex
就进行读操作时候,就会走getter
依赖收集的逻辑,进行写操作的时候setter
通知更新的逻辑,由于不需要反馈到视图中,所以getter
和setter
中的逻辑就是在做无用功,损耗了性能。
优化后代码如下:
export default {
created() {
this.pageSize = 10;
this.pageIndex = 1;
},
methods: {
getList() {
axios
.get("xxx", {
params: { pageSize: this.pageSize, pageIndex: this.pageIndex },
})
.then(() => {});
},
scrollBottom() {
this.pageIndex += 1;
this.getList();
},
},
};
从上面可以看见,我们把pageSize
和pageIndex
挂在到了实例this
上面,这样既可以在组件的每个函数中访问到,又可以避免把它们变成响应式数据。
我们看看 vue 的源码:
function initState(vm: Component) {
vm._watchers = [];
const opts = vm.$options;
// 判断有没有data字段
if (opts.data) {
// 初始化data字段中数据
initData(vm);
} else {
observe((vm._data = {}), true /* asRootData */);
}
}
function initData(vm: Component) {
let data = vm.$options.data;
data = vm._data = typeof data === "function" ? getData(data, vm) : data || {};
const keys = Object.keys(data);
let i = keys.length;
while (i--) {
const key = keys[i];
// 把data中的数据代理到this上
proxy(vm, `_data`, key);
}
// 将data中的数据转化为响应式数据
observe(data, true /* asRootData */);
}
从上面可以看见,在initData
的时候会把data
中的数据代理到this
上面,同时转化为响应式数据。所以一些需要共享的数据,但是不需要响应的数据,不要写在data
字段中,否则会在数据进行读写的时候进行无用功操作,我们可以在适当的时机直接在组件实例this
上面挂载一些变量,从而提高性能。
v-for 中的 key
在 v-for 中,我们设置 key 是为了给 vnode 一个唯一的标识,标识新旧 vnode 是否为同一个 vnode,快速找到新旧 vnode 的变化。当我们不设置的时候,默认就是undefined
,也就是 v-for 中的每一项都是相同的 key 值。
我们先看一下 vue 源码中sameVnode
函数的逻辑:
function sameVnode(a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error)))
);
}
上面可以看出,
-
判断新旧 vnode 是否为同一个 vnode 的时候,首先会判断新旧 vnode 中的key
值是否相同,不同就是不同的 vnode,相同就继续走后面更加复杂逻辑判断。
-
当我们不设置 v-for 中的 key 值的时候,也就是 key 值为undefined
,即a.key === b.key
->undefined === undefined
->true
,不管新旧 vnode 是否为相同还是不相同的 vnode,都会走后面更加复杂的逻辑判断。
-
当我们设置了key
值,并且每个key
都是不一样的,当新旧 vnode 不是相同的 vnode 的时候,a.key === b.key
->false
,在对比 key 值的时候就可以直接返回false
了,不用走后面更加复杂的逻辑判断,只有当新旧 vnode 相同的时候,也就是a.key === b.key
->true
,才会走后面的更加复杂的逻辑判断。
-
设置key
相对于不设置key
,主要是减少了a.key === b.key
后面的复杂逻辑判断流程,从而减少了 js 执行的时间。
从上面中,我们可以总结出,key
的作用是为了更加快速地对比出两个是不同的vnode,而相同的vnode,因为我们可以看见在对比完key
值之后,后面还有一些判断条件
当我们设置 key 值的时候,要保证key
值的唯一性,最好不要用数组的下标索引作为key
值,可能会产生一些 bug,我们看看下面的使用数组下标索引作为key
值的代码
list-card.vue
{{ label }}
app.vue
上面我们可以看见list-card
组件内部自己维护了一个isClick
变量,当点击组件的时候,组件字体会变成红色。当我们点击添加按钮的时候,会在数组的顶部添加元素。现在我们进行如下操作:
-
点击第三个元素,也就是 label=3
的组件,此时, label=3
的组件字体变红色
-
点击添加按钮,往数组顶部追加元素,此时list
数组变成[7,1, 2, 3, 4, 5, 6]
-
这时我们理想中的效果应该是第四个元素,也就是label=3
的组件应该是红色字体的。但是实际的效果是,第三个元素,也就是label=2
的组件变成了红色字体
keep-alive
在实际的开发过程中,我们每次渲染路由的时候,都会重新渲染一次组件,都会经过render
,patch
等过程,如果组件逻辑比较复杂,或者嵌套比较深,整个渲染过程都会很耗时。
我们可以使用keep-alive
组件将router-view
包裹起来,这样就可以将已经渲染过的路由组件缓存起来,下次需要渲染组件的时候,就不会走created
,mounted
等生命周期函数,而是会触发activated
(激活)和deactivated
(失活)生命周期函数。keep-alive
不仅可以用来缓存路由组件,还可以用来缓存动态组件。这是一种使用空间换取时间的优化方式。
keep-alive
主要参数有如下三个:
- include:字符串或正则表达式,也可以使用数组来指定换缓存那些组件
- exclude:字符串或正则表达式,也可以使用数组来指定换不缓存那些组件
- max:最大缓存的组件实例
注意:当你使用了include
和exclude
参数时,组件必须要有name
选项。没有name
选项就会去匹配它的局部注册名称 (父组件 components 选项的键值)。匿名组件不能被匹配。
代码示例:
常用的场景是在移动端中,从一个列表中,跳转到一个详情页,再从详情页返回到列表的时候,需要恢复到列表上一次进入到详情页时的状态。
渐进式渲染
渐进式渲染就是分批延时渲染组件,就是先渲染组件 A,等过了几秒之后就渲染组件 B。当一个页面存在非常多的组件的时候,浏览器在同一时间内需要渲染的 DOM 比较多,会给人一种页面卡顿的感觉。
我们来看看怎么实现分批延时渲染
我们可以看见组件内部维护了一个displayPriority
变量,然后通过requestAnimationFrame
在每一帧渲染的时候自增,最多自增到count
。然后就可以通过v-if="defer(xxx)"
来控制渲染的先后顺序,xxx
数值越小,渲染优先级越高。这里只能用v-if
来控制渲染的优先级,不能使用v-show
,因为v-show
会直接渲染组件的,只是在样式上面做了隐藏。
当你的页面上存在非常多的组件,或者有非常耗时的组件时,使用这种渐进式的渲染方式可以避免由于 js 执行时间过长或者一次性渲染出大量的 DOM 元素时导致渲染卡住的现象,提高了用户的体验度。
时间片切割
当我们一次性提交很多数据的时候,内部的 js 执行时间过长,阻塞了 UI 线程,导致页面卡死。
通常来说我们在提交大量数据的时候回加一个loading
效果,但是但数据量很大的时候,由于 js 执行时间过长,UI 线程无法执行,导致我们的loading
效果根本就是静态效果,不是动态的。这是我们可以拆成多个时间片去提交数据,使得单次 js 运行的时间变短,这样就不会阻塞了 UI 线程,我们的loading
效果也有机会动起来了。
我们来看看这么实现时间片切割技术
function splitArray(items, splitCount) {
const arr = [];
const count = Math.ceil(items.length / splitCount);
for (let i = 0; i < count; i++) {
arr.push(items.slice(i * splitCount, (i + 1) * splitCount));
}
return arr;
}
fetchData({ commit }, { list, splitCount }) {
// 将数据切分成多个数据块
const chunkList = splitArray(list,splitCount);
const step = (index) => {
if (index >= chunkList.length) {
return;
}
// requestAnimationFrame在浏览器每一次重绘之后会执行传入的回调函数,这样就使得,UI线程有机会执行。当然也可以使用setTimeout来代替requestAnimationFrame
requestAnimationFrame(() => {
// 结合requestAnimationFrame每一帧提交一部分数据
const chunk = chunkList[index];
commit('addItems',chunk)
index += 1;
step(index);
});
};
step(0);
}
组件懒加载
组件懒加载是将异步组件和 webpack 的 code-splitting 功能一起配合使用的。这样就可以将组件代码切割成多个文件,需要真正使用组件的时候才去请求加载代码。
代码示例:
const comp = (/* webpackChunkName: "comp" */)=>import('./my-component')
Vue.Component('comp',comp)
上面我们可以看见有一个webpackChunkName: "comp"
的参数,这个参数是用来指定代码块的文件名称,也就是打包出来的文件名叫comp
。这里我建议大家把相同模块的组件都打包进同一个文件中(只要webpackChunkName
的值相同即可打包进同一个文件中),实际上我看见很多人喜欢一个组件就是一个文件,打包出来的文件实际上只有几kb,发送网络请求去请求文件携带的请求头,请求体等数据可能都比这个文件体积大,会造成不必要的浪费。
总结
通过这篇文章,我希望大家可以把这些优化技巧运用到实际项目开发中,这可以给你带来不少的收益。当然除了上述的优化技巧,还有图片懒加载,虚拟滚动等性能优化手段。
性能优化并不能盲目的去做,你要结合当前实际,考虑这个优化能带来什么,有什么收益,需要花费多少成本。同时还要考虑可能会带来的风险,比如上面keep-alive
,就是采用空间来换取时间,一旦缓存的组件数量多了,内存容易造成溢出,所以要控制缓存组件数量。
性能的提升是靠一点一滴去积累的,并不是说你做了某个优化之后,就可以突然把渲染时间从5秒降低到3秒。其实在实际开发中,有很多地方我们都可以去进行优化的,只是可能你认为这样无所谓,影响不大,可以忽略不计。但是等项目到了一定规模之后,这些看似微不足道的地方就会被积累起来,量变形成质变,导致系统的性能下降。所以很多你看似无所谓,影响不大的地方,往往就是性能瓶颈的突破口。
最后,我希望大家在日常开发中,不要为了写而写,你要去充分考虑你写的这一行代码会产生什么样的性能影响。