前言
众所周知,React和Vue是现时代最流行的两个前端框架,建立在它们之上的前端项目不计其数。时代的车轮滚滚向前,进入React Hooks及Vue3时代后,它俩的孰优孰劣,更是让各路英豪吵得不可开交。
在我个人的前端开发生涯中使用React与Vue的频率可以算是55开,且React Hooks和Vue3也都早早的推到了项目中使用,在日常开发中也总会对比它俩的孰优孰劣并根据其框架的特性进行性能的优化,在一些复杂或者特殊的场景中 有些优化会显著的看到成效,所以不得不说其重要性。
本篇则重点讲述一下它俩底层状态管理模式的区别,根据其特性引出它俩在性能优化方面的注意焦点及优化方法。
状态管理
相信不少朋友都熟悉
UI = f(state)这个公式,函数f将数据(data)映射到用户界面(UI),通俗来讲即 —— UI是对状态的映射,几乎所有主流前端框架的实现原理都在践行它。
而React与Vue在践行这个公式时则是不同的两种模式
可以说是 你有尚方宝剑 我有屠龙宝刀
React的状态管理模式
React并不关心状态如何变化。
每当调用更新状态的方法(比如this.setState或者useState、dispatch),就会对整个应用进行diff。
所以在React中,传递给更新状态的方法的,是状态的快照,换言之,是个不可变的数据。
则在React中,需要通过调用更新状态的方法 来实现UI对状态的映射
Vue的状态管理模式
Vue关心状态如何变化。
Vue通过Proxy或Object.defineProperty方法对对象/属性进行代理/劫持,每当更新状态时 都会对与状态关联的组件进行diff
所以在Vue中,是直接改变状态的值。换言之,状态是个可变的数据
则在Vue中,需要通过调用包装数据的方法(ref/reactive)或者通对data函数内声明的对象属性进行数据代理/劫持 来实现UI对状态的映射
性能优化
以状态管理模式的讨论为切入点,我们再来进一步剖析React与Vue在性能优化上的注意点及优化措施。
react
在react中,每当调用更新状态的方法后,React就会重新开始构建整颗UI Tree。但往往实际操作只会影响个别节点,如果全部重新构建一遍显然是性能成本较高的。
默认优化策略
在React默认优化策略下,构建阶段react会对节点的props、state(pending update)、context进行判断,如果没有发现变换就会跳过构建。
但我们看下面的例子:
const Son = () => {
console.log("son组件--我render啦");
return <div>我是 son 组件</div>;
};
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Son />
</div>
);
}
可以看到Son组件的props、state、context都没有变,但是每次点击后Son组件都会重新渲染,实际是因为props传递的是一个对象(默认传递空对象),由于每次渲染传递的对象都是重新生成的,即{}!=={}。
手动跳过构建
那么我们怎么使与count状态的无关的Son组件避免重新渲染呢?首先我们可能想到React.memo、PureComponent、ShouldComponentUpdate等React API 来手动跳过构建。
比如通过React.memo来包裹函数式组件:
const Son = memo(() => {
console.log("son组件--我render啦");
return <div>我是 son 组件</div>;
});
这时Son组件就不会重新渲染了,其原理是memo对props进行了浅对比(默认情况下只会对复杂对象做浅层对比,如果想控制对比过程 第二个参数传入自定义比较函数来实现),替换了之前的props全等比较,那么在上面的例子中 就满足了优化策略,以此就跳过了构建过程。
class组件中可以使用PureComponent、ShouldComponentUpDate来实现类似的效果。
当然还有最常见到一种情况是,Son组件在传入对象/函数时,因每次组件重新渲染都会重新生成一个全新的对象/函数,导致Son组件即使使用了例如React.memo等性能优化的api也无法使组件避免重新渲染 如下case:
const Son = memo(({ list }: SonProps) => {
console.log("son组件--我render啦");
return <div>我是 son 组件</div>;
});
export default function App() {
const list: any[] = [];
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Son list={list} />
</div>
);
}
如要解决这类情况 就要引出 useMemo、useCallback这两个api。
useMemo、useCallback
useMemo接受一个"创建"函数和一个依赖项数组作为参数,返回一个值,它仅在某个依赖项改变时才会重新执行"创建"函数返回一个新值,那么使用useMemo就可以把值缓存起来,这样就可以避免子组件memo失效。
const Son = memo(({ list }: SonProps) => {
console.log("son组件--我render啦");
return <div>我是 son 组件</div>;
});
export default function App() {
//使用useMemo缓存这个值 来避免子组件memo失效
const list = useMemo(() => [], []);
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Son list={list} />
</div>
);
}
需要使用到useMemo还有一个场景就是可以 避免在每次渲染时都进行高开销的计算,如下case所示,result值每次都需要高消耗的计算就可以用useMemo缓存起来,仅在num依赖项变化时再进行重新计算。
//使用useMemo缓存这个值 仅在依赖项num改变时才会重新计算result值
const result = useMemo(() => {
let data = 0;
for (let i = 0; 1 < 100000; i++) {
data += (num * Math.pow(2, 15)) / 9;
}
return data;
}, [num]);
useCallback接受一个内联回调函数及依赖项数组作为参数,返回一个回调函数,它仅在某个依赖项改变时才会更新。useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。
const Son = memo((props: SonProps) => {
console.log("son组件--我render啦");
return <div>我是 son 组件</div>;
});
export default function App() {
const [count, setCount] = useState(0);
//使用useCallback缓存这个函数 避免子组件memo失效
const handleCancel = useCallback(() => {}, []);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Son onClick={handleCancel} />
</div>
);
}
那么我们是否可以把每个组件都使用memo/PureComponent包裹起来且再使用useMemo与useCallback把组件内部都整顿一遍呢?
答案是否定的,因为memo/PureComponent并不是免费的,每次要对props进行遍历对比,显然要比直接全等的成本大很多。而且useMemo和useCallback本身也需要开辟空间去储存依赖且每次都要去进行比较,在组件中大量使用useMemo和useCallback也会导致代码臃肿可读性变差且增加开发者对其依赖项进行维护的心智负担。
那有没有更好的方法呢?
答案肯定是有的,通过合理的规划组件之间的状态管理,也可以避免组件进行不必要的重新渲染。
状态下放
还是以刚才的例子为例,虽然Son组件与count状态没有关系,但因同属于一个父组件,在父组件渲染的同时也连带着Son组件一起重新渲染了。
const Son = () => {
console.log("son组件--我render啦");
return <div>我是 son 组件</div>;
};
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Son />
</div>
);
}
而我们看到只有button与count状态相关,所以可以把相关的逻辑单独抽离到一个子组件中:
const Box = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
const Son = () => {
console.log("son组件--我render啦");
return <div>我是 son 组件</div>;
};
export default function App() {
return (
<div>
<Box />
<Son />
</div>
);
}
可以看到把状态管理下放到Box组件中,A组件就满足了默认优化策略且不会重新渲染,那么传递给Son组件的props就不会改变且Son组件也不会重新渲染,而且如果Son还有子组件且子组件内部状态没有变化也会被一并跳过渲染。
内容提升
状态下放只适用于逻辑状态与父容器无关的情况,如果Son的父容器也用到了count状态(如下case),那么就可以用内容提升来解决:
const Son = () => {
console.log("son组件--我render啦");
return <div>我是 son 组件</div>;
};
export default function App() {
const [count, setCount] = useState(0);
return (
<div className={"wrap" + count}>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Son />
</div>
);
}
可以看到Son组件不依赖任何外部状态,就可以把与状态相关的节点都提取到Wrap组件中,然后再把Son组件传入到Wrap组件中
const Son = () => {
console.log("son组件--我render啦");
return <div>我是 son 组件</div>;
};
const Wrap = ({ children }: { children: ReactElement }) => {
const [count, setCount] = useState(0);
return (
<div className={"wrap" + count}>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</div>
);
};
export default function App() {
return (
<Wrap>
<Son />
</Wrap>
);
}
原因是Son组件作为props传入到Wrap组件中且App并没有重新渲染,即Son组件也不会重新渲染。
Vue
在Vue中,我们知道其实现UI对状态的映射是通过Proxy或Object.defineProperty方法先将对象或其属性进行代理,每当更新状态时与其状态所关联的组件就会重新渲染,就渲染控制来说 Vue原生处理 比 React更加精确,但Vue也有其自己的性能陷阱,就是数据代理这一层,下面通过一些case我们先来了解一下 将对象进行代理后对性能带来的消耗。
下面这个case进行了1000万次的循环,分别为无代理,有代理,代理无Reflect函数,代理无捕获函数,以及Object.defineProperty函数代理:
const target = { a: 0 };
const FOR_NUM = 10000000;
/**
* 普通对象
*/
console.time("time_normal");
let result_normal = 0;
for (let index = 0; index < FOR_NUM; index++) {
result_normal += target.a;
target.a = index;
}
console.timeEnd("time_normal");
/**
* Proxy代理对象
*/
target.a = 0;
let handle = {
get (target, prop, receiver) {
return Reflect.get(target, prop);
},
set (target, prop, value) {
return Reflect.set(target, prop, value);
},
};
let targetProxy = new Proxy(target, handle);
result_normal = 0;
console.time("time_proxy");
for (let index = 0; index < FOR_NUM; index++) {
result_normal += targetProxy.a;
targetProxy.a = index;
}
console.timeEnd("time_proxy");
/**
* Proxy代理对象-无reflect
*/
target.a = 0;
let handleNoReflect = {
get (target, prop, receiver) {
return target[prop];
},
set (target, prop, value) {
return target[prop] = value;
},
};
let targetNoReflect = new Proxy(target, handleNoReflect);
result_normal = 0;
console.time("time_noReflect");
for (let index = 0; index < FOR_NUM; index++) {
result_normal += targetNoReflect.a;
targetNoReflect.a = index;
}
console.timeEnd("time_noReflect");
/**
* Object.defineProperty劫持属性
*/
let b = 0;
Object.defineProperty(target, "a", {
get () {
return b;
},
set (newVal) {
b = newVal;
},
});
result_normal = 0;
console.time("time_defineProperty");
for (let index = 0; index < FOR_NUM; index++) {
result_normal += target.a;
target.a = index;
}
console.timeEnd("time_defineProperty");
打印结果如下所示:
我们可以看到对象或其属性进行代理后,对其进行读写操作,性能消耗有显著的差距。
值得一提的是我们知道在Vue2中,对于一个深层属性嵌套的对象,要劫持它内部深层次的变化,就需要递归遍历这个对象,执行Object.definePropery把每一层对象数据都变成响应式的,这无疑会有很大的性能消耗。在Vue3中,使用Proxy并不能监听到对象内部深层次的属性变化,因它的处理方式是在getter中去递归响应式,这样的好处是真正访问到的内部属性才会变成响应式,简单的可以说是按需实现响应式,相比与vue2的方式 在初始化时的性能负担要小。
在Vue的响应性系统默认是深度的(如ref()、reactive()方法或者在optionsApi中通过data函数所定义的数据),每个属性的访问都会触发代理的依赖追踪,在处理大型数组或层级很深的对象时也会造成不少的性能负担。
所以我们要做的是,减少没必要的数据代理/劫持,来实现对性能减负。
那么,首先我们引出vue的官方API帮我们优化大部分场景下的问题
浅响应与浅只读API(shallowRef、shallowReactive与shallowReadonly)
根据Vue官方文档的例子来说,虽然默认的深度响应系统让状态管理变得主观,但在数据量巨大或层级很深时,深度响应性也会导致不小的性能负担,Vue 确实也为此提供了一种解决方案,通过使用 shallowRef() 和 shallowReactive() 来绕开深度响应。浅层式 API 创建的状态只在其顶层是响应式的,对所有深层的对象不会做任何处理。这使得对深层级属性的访问变得更快,但代价是,我们现在必须将所有深层级对象视为不可变的,并且只能通过替换整个根状态来触发更新:
const shallowArray = shallowRef([
/* 巨大的列表,里面包含深层的对象 */
])
// 这不会触发更新...
shallowArray.value.push(newObject)
// 这才会触发更新
shallowArray.value = [...shallowArray.value, newObject]
// 这不会触发更新...
shallowArray.value[0].foo = 1
// 这才会触发更新
shallowArray.value = [
{
...shallowArray.value[0],
foo: 1
},
...shallowArray.value.slice(1)
]
除了对象整体进行替换来触发更新,也可以搭配triggerRef来使用,使我们对浅层ref的操作可以更加的灵活。
const shallow = shallowRef({
greet: 'Hello, world'
})
// 触发该副作用第一次应该会打印 "Hello, world"
watchEffect(() => {
console.log(shallow.value.greet)
})
// 这次变更不应触发副作用,因为这个 ref 是浅层的
shallow.value.greet = 'Hello, universe'
// 打印 "Hello, universe"
triggerRef(shallow)
同理,我们看一下shallowReactive的示例:
const state = shallowReactive({
foo: 1,
nested: {
bar: 2
}
})
// 更改状态自身的属性是响应式的
state.foo++
// ...但下层嵌套对象不会被转为响应式
isReactive(state.nested) // false
// 不是响应式的
state.nested.bar++
同理,shallowReadonly不会进行深层级的转换,只有根层级的属性变为了只读:
const state = shallowReadonly({
foo: 1,
nested: {
bar: 2
}
})
// 更改状态自身的属性会失败
state.foo++
// ...但可以更改下层嵌套对象
isReadonly(state.nested) // false
// 这是可以通过的
state.nested.bar++
相对于shallowRef不同的是,官方文档中建议谨慎使用shallowReactive与shallowReadonly,因为它们创建的树浅层和深层具有不一致的响应行为,这可能很难理解和调试。
而shallowRef包装的对象每一层的表现都是一致的,也是日常开发中会经常用到的,如果发生意外也可以使用triggerRef来主动触发更新。下面通过一个例子来说明一下具体的使用场景。
比如在使用高德地图API时,我们不需要让Vue监听Map对象内部的变化,如果使用深度监听的方法(如useRef)而不是非深度监听方法(如shallowRef),不但会出现明显的性能问题,还可能会引起意想不到的问题,通过下面这个case就可以明显看出性能方面的差别:
onMounted(() => {
initMap() //初始化地图
})
const map = ref<AMap.Map>() //深度监听map对象
const mapOptions: AMap.MapOptions = {
viewMode: "3D", //是否为3D地图模式
zoom: 12, //初始化地图级别
center: [116.556315, 35.558775], //地图中心点
}
const initMap = () => {
AMapLoader.load({
key: MAP_KYE, //地图key
version: "2.0", //地图版本
}).then((AMap) => {
map.value = new AMap.Map("container", mapOptions);
addMarker(AMap); //添加点标记
}).catch(e => {
console.log(e);
})
}
// 实例化点标记
const addMarker = (AMap: any) => {
const randomNum = (): string => {
return String(Math.floor(Math.random() * 10))
}
for (let i = 0; i < 3000; i++) {
const position = [
Number(`116.${randomNum() + randomNum()}6315`),
Number(`35.${randomNum() + randomNum()}8775`)
]
new AMap.Marker({
icon: "//a.amap.com/jsapi_demos/static/demo-center/icons/poi-marker-default.png",
position,
offset: new AMap.Pixel(-13, -30),
map: map.value,
});
}
}
我们简单新建一个地图实例,然后添加3000个Marker对象,当然在真实的业务场景中如此大数量的Marker需要使用海量点或者点聚合来展示,在此仅仅用来模拟真实地图业务场景中的复杂场景,对比一下使用ref 与 使用shallowRef 在性能上的差别。
如上是使用ref的效果。
const map = shallowRef<AMap.Map>() //浅监听map对象
如上是使用shallowRef的效果,虽然因为marker对象过多也有些许的卡顿,但是对比使用ref明显要流畅很多。
其实不只是上面的实例,实际业务场景下shallowRef的适用场景有很多,在封装业务组件时建议多考虑是否要用shallowRef来代替ref,如果一个需要监听的对象 大部分情况下只需要在替换它的时候更新视图 就可以考虑使用shallowRef。
toRaw、markRaw
toRaw
根据一个Vue创建的代理返回其原始对象。toRaw() 可以返回由 reactive()、readonly()、shallowReactive() 或者 shallowReadonly() 创建的代理对应的原始对象。
其作用就是可以用于临时读取而不引起代理访问/跟踪开销,或者是写入而不触发更改的特殊方法。
const state = reactive({
a: {
b: [1, 2, 3]
}
})
onMounted(() => {
//使用toRaw把state转换为普通对象(获取reactive 创建的代理对应的原始对象)
const stateRaw = toRaw(state)
for (let i = 0; i < 1000; i++) {
// ...
// 对stateRaw进行一些临时读取操作而不引起代理访问/跟踪开销
// 对stateRaw进行一些写入操作而不触发页面重新渲染
}
})
markRaw
将一个对象标记为不可被转为代理。返回该对象本身。
应用场景:标记不应被设置为响应式的数据,比如复杂的第三方类库等,或者跳过一些不可变数据源的大列表的响应式转换以提高性能。
如
const foo = markRaw({})
console.log(isReactive(reactive(foo))) // false
// 也适用于嵌套在其他响应性对象
const bar = reactive({ foo })
console.log(isReactive(bar.foo)) // false
或者在optionsAPI中,我们拿常见的echarts图表为例:
<script lang="ts">
import * as eCharts from 'echarts'
import { EChartsType } from 'echarts';
import { option } from '../config';
interface Data {
myChart: EChartsType | null
}
export default {
data(): Data {
return {
myChart: null
};
},
mounted() {
this.initChart() //初始化图表
window.addEventListener('resize', () => { //监听浏览器size变化并动态设置图表size
if (this.myChart) this.myChart.resize()
})
},
methods: {
initChart() {
const chartDom = document.getElementById("chart")
if (!chartDom) return
this.myChart = eCharts.init(chartDom)
this.myChart.setOption(option)
}
},
}
</script>
<template>
<main id="chart"></main>
</template>
<style>
#chart {
height: 500px;
}
</style>
通过如上case我们可以看到如果对echarts对象进行默认的proxy响应式代理,不但会造成额外的性能消耗,而且会引起意想不到的错误。这时就需要使用markRaw对echarts对象进行标记以跳过响应式代理来解决。
initChart() {
const chartDom = document.getElementById("chart")
if (!chartDom) return
// 使用markRaw标记该对象不可被转为代理
this.myChart = markRaw(eCharts.init(chartDom))
this.myChart.setOption(option)
}
v-memo
v-memo是Vue3.2新增的指令,它用于缓存一个模板的子树,在元素和组件上都可以使用。为了实现缓存,该指令需要传入一个固定长度的依赖值数组进行比较。如果数组里的每个值都与最后一次的渲染相同,那么整个子树的更新将被跳过。
简单来说只有依赖值数组的任意一个值发生变化,整个子树才会重新渲染。
<div v-memo="[valueA, valueB]">
...
</div>
当组件重新渲染,如果 valueA 和 valueB 都保持不变,这个 <div> 及其子项的所有更新都将被跳过。实际上,甚至虚拟 DOM 的 vnode 创建也将被跳过,因为缓存的子树副本可以被重新使用。
正确指定缓存数组很重要,否则应该生效的更新可能被跳过。v-memo 传入空依赖数组 (v-memo="[]") 将与 v-once 效果相同。
我们来看一下 与v-for一起使用的官方示例:
v-memo 仅用于性能至上场景中的微小优化,应该很少需要。最常见的情况可能是有助于渲染海量 v-for 列表 (长度超过 1000 的情况):
<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
<p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p>
<p>...more child nodes</p>
</div>
当组件的 selected 状态改变,默认会重新创建大量的 vnode,尽管绝大部分都跟之前是一模一样的。v-memo 用在这里本质上是在说“只有当该项的被选中状态改变时才需要更新”。这使得每个选中状态没有变的项能完全重用之前的 vnode 并跳过差异比较。注意这里 memo 依赖数组中并不需要包含 item.id,因为 Vue 也会根据 item 的 :key 进行判断。
v-memo 也能被用于在一些默认优化失败的边际情况下,手动避免子组件出现不需要的更新。但是一样的,开发者需要负责指定正确的依赖数组以免跳过必要的更新。
Vue2的性能优化
通过以上章节中的讨论,我们知道Vue2是通过初始化时递归遍历执行Object.definePropery把每一层对象数据进行拦截来实现数据响应式的,如果把不需要响应式的数据也定义在data中 就会造成额外的性能消耗,总结了如下几个方案来解决这个问题:
数据放在Vue实例外
const test = { value:1 }
export default {
// options
}
这种方式虽然不会把数据进行响应式拦截,但是在Vue template中也访问不到数据。
那可能大家会想到,如下再把对象引入到data中来解决Vue template中不能访问的问题:
const test = { value: 1 }
export default {
data() {
return {
test
}
}
}
但是这样做 test对象 又会被响应式拦截处理,那么有办法可以解决吗?
Object.freeze()
当然有,就是使用Object.freeze()冻结对象,禁止对该对象的属性进行修改。这个方法返回传递的对象,而不是创建一个被冻结的副本。
const test = Object.freeze({ value: 1 })
export default {
data() {
return {
test
}
},
}
但Object.freeze是浅冻结只冻结一层,如果存在嵌套对象则深层对象仍然可以改变,深冻结函数递归实现:
function deepFreeze (obj) {
let names = Object.getOwnPropertyNames(obj)
names.forEach(name => {
var property = obj[name]
if (typeof(property) === 'object' && property !== null) {
deepFreeze(property)
}
return Object.freeze(property)
})
}
如果使用递归来冻结每一层的对象,又会造成额外的性能消耗,所以这种方案我们不推荐。
created,mounted钩子函数中定义
created() {
// 注意data中不要声明该变量名
this.testData = 'testData'
}
这种方式既可以避免数据被响应式拦截也可以在Vue template里访问到还不会如冻结方案那样需要递归处理,但是容易和data中定义的数据混淆和冲突,不易分辨,那么还有没有更好的方案来实现呢?
自定义Options
<template>
<div>{{ $options.myOptions.test }}</div>
</template>
<script>
export default {
data() {
},
// 自定义options和data同级
// 取值时: this.$options.myOptions.test
myOptions: {
test: '111'
}
}
</script>
如上case,自定义Options可以实现我们的需求的同时也避免了如上其它方式的缺点,所以也比较推荐使用这种方式。
总结
在React中,每当调用更新状态的方法后,组件树就会自上而下的对整个应用进行UI Tree构建然后进行diff,
但可能在UI Tree构建阶段由于props问题不满足默认优化策略而造成不必要的组件重新渲染。
所以对于React性能优化的关键就是要 减少没必要的render。
在Vue中,需要先将数据进行代理/劫持,然后更新状态时对与状态关联的组件进行diff。
但将数据进行代理/劫持过程本身以及代理/劫持后的数据读写和响应式处理都会有性能消耗。
所以对于Vue性能优化的关键就是要 减少没必要的数据代理/劫持。