概要

本文主要讲解了下我平时在工作开发中遇到的关于 Hooks 的一些缺点和问题,并尝试配合 Mobx 解决这些问题的经历。我觉得两者的配合可以极大的降低开发过程中有可能出现的问题以及极大的提高开发体验,而且学习成本也是非常的低。如果你对 Hooks 以及 Mobx 有兴趣,想知道更进一步的了解,那么这篇文章适合你。这篇文章会介绍如下内容,方便你决定是否要仔细阅读,节省时间:

  • 本文不会介绍太过于基础的内容,你需要对 Mobx 以及 Hooks 有基础的了解

  • 本文介绍了平时开发中的一些最佳实践,方便小伙伴们对两者有更加深入的认识

  • 如果你使用过一部分 Mobx,但是不太了解如何和 Hooks 更好的合作,可以尝试来看看

另外 Hooks 本身真的就是一个理解上非常简单的东西,所以本文也不长,我也不喜欢去写什么万字长文,又不是写教程,而且读者看着标题就失去兴趣了。

Hooks 究竟有什么问题?

首先,在这里我不再说 Hooks 的优点,因为他的优点用过的人都清楚是怎么回事,这里主要讲解一下他存在的缺点,以及如何用 Mobx 来进行改进。

  • 依赖传染性 —— 这导致了开发复杂性的提高、可维护性的降低

  • 缓存雪崩 —— 这导致运行性能的降低

  • 异步任务下无法批量更新 —— 这也会导致运行性能的降低

换句话说,造成这种原因主要是因为 Hooks 每次都会创建一个全新的闭包,而闭包内所有的变量其实都是全新的。而每次都会创建闭包数据,而从性能角度来讲,此时缓存就是必要的了。而缓存又会牵扯出一堆问题。

说到底,也就是说没有一个公共的空间来共享数据,这个在 Class 组件中,就是 this,在 Vue3 中,那就是 setup 作用域。而 Hooks 中,除非你愿意写 useRef + ref.current 否则是没有办法找到共享作用域。

而 mobx 和 Hooks 的结合,可以很方便在 Hooks 下提供一个统一的作用域来解决上面遇到的问题,所谓双剑合并,剑走天下。

Hook1 useObserver

在传统的使用 mobx 的过程中,大家应该都知道 observer 这个 api,对需要能够响应式的组件用这个包裹一下。同样,这个 api 直接在 hooks 中依旧可以正常使用。 但是 hooks 并不推荐 hoc 的方式。自然,mobx 也提供了 hookify 的使用方式,那就是 useObserver

1
2
3
4
5
6
const store = observable({})
function App() {
return useObserver(() => {
return <div>{store.count}</div>
})
}

看到这里,相信使用过 mobx 的应该可以发现,useObserver 的使用几乎和 Class 组件的 render 函数的使用方式一致。事实上也确实如此,而且他的使用规则也很简单,直接把需要返回的 Node 用该 hooks 包裹后再返回就可以了。

经过这样处理的组件,就可以成功监听数据的变化,当数据变化的时候,会触发组件的重渲染。至此,第一个 api 就了解完毕了

Hook2 useLocalStore

简单来讲,就是在 Hooks 的环境下封装的一个更加方便的 observable。就是给他一个函数,该函数返回一个需要响应式的对象。可以简单的这样理解

1
2
3
const store = useLocalStore(() => ({key: 'value'}))
// equal
const [store] = useState(() => observable({key: 'value'}))

然后就没有了,极其简单的一个 api 使用。而后面要讲的一些最佳实践更多的也是围绕这个展开,后文简化使用 local store 代指。

这两个 API 能带来什么?

简单来讲,就是在保留 Hooks 的特性的情况下,解决上面 hooks 所带来的问题。

第一点,由于 local store 的存在,作为一个不变的对象存储数据,我们就可以保证不同时刻对同一个函数的引用保持不变,不同时刻都能引用到同一个对象或者数据。不再需要手动添加相关的 deps。由此可以避免 useCallback 和 useRef 的过度使用,也避免很多 hooks 所面临的的闭包的坑(老手请自动忽略)。依赖传递性和缓存雪崩的问题都可以得到解决

直接上代码,主要关注注释部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 要实现一个方法,只有当鼠标移动超过多少像素之后,才会触发组件的更新
// props.size 控制移动多少像素才触发回调
function MouseEventListener(props) {
const [pos, setPos] = useState({x: 0, y: 0})
const posRef = useRef()
const propsRef = useRef()
// 这里需要用 Ref 存储最新的值,保证回调里面用到的一定是最新的值
posRef.current = pos
propsRef.current = propsRef

useEffect(() => {
const handler = (e) => {
const newPos = {x: e.xxx, y: e.xxx}
const oldPos = posRef.current
const size = propsRef.current.size
if (
Math.abs(newPos.x - oldPos.x) >= size
|| Math.abs(newPos.y - oldPos.y) >= size
) {
setPos(newPos)
}
}
// 当组件挂载的时候,注册这个事件
document.addEventListener('mousemove', handler)
return () => document.removeEventListener('mousemove', handler)
// 当然这里也可以监听 [pos.x, pos.y],但是性能不好
}, [])

return (
props.children(pos.x, pos.y)
)
}

// 用 mobx 改写之后,这种使用方式远比原生 hooks 更加符合直觉。
// 不会有任何 ref,任何 current 的使用,任何依赖的变化
function MouseEventListenerMobx(props) {
const state = useLocalStore(target => ({
x: 0,
y: 0,
handler(e) {
const nx = e.xxx
const ny = e.xxx
if (
Math.abs(nx - state.x) >= target.size ||
Math.abs(ny - state.y) >= target.size
) {
state.x = nx
state.y = ny
}
}
}), props)

useEffect(() => {
document.addEventListener('mousemove', state.handler)
return () => document.removeEventListener('mousemove', state.handler)
}, [])

return useObserver(() => props.children(state.x, state.y))
}

第二,就是针对异步数据的批量更新问题,mobx 的 action 可以很好的解决这个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 组件挂载之后,拉取数据并重新渲染。不考虑报错的情况
function AppWithHooks() {
const [data, setData] = useState({})
const [loading, setLoading] = useState(true)
useEffect(async () => {
const data = await fetchData()
// 由于在异步回调中,无法触发批量更新,所以会导致 setData 更新一次,setLoading 更新一次
setData(data)
setLoading(false)
}, [])
return (/* ui */)
}

function AppWithMobx() {
const store = useLocalStore(() => ({
data: {},
loading: true,
}))
useEffect(async () => {
const data = await fetchData()
runInAction(() => {
// 这里借助 mobx 的 action,可以很好的做到批量更新,此时组件只会更新一次
store.data = data
store.loading = false
})
}, [])
return useObserver(() => (/* ui */))
}

不过也有人会说,这种情况下用 useReducer 不就好了么?确实,针对这个例子是可以的,但是往往业务中会出现很多复杂情况,比如你在异步回调中要更新本地 store 以及全局 store,那么就算是 useReducer 也要分别调用两次 dispatch ,同样会触发两次渲染。而 mobx 的 action 就不会出现这样的问题。// 如果你强行 ReactDOM.unstable_batchedUpdates 我就不说啥了,勇士受我一拜

Quick Tips

知道了上面的两个 api,就可以开始愉快的使用起来了,只不过这里给大家一下小 tips,帮助大家更好的理解、更好的使用这两个 api。(不想用而且也不敢用「最佳实践」这个词,感觉太绝对,这里面有一些我自己也没有打磨好,只能算是 tips 来帮助大家拓展思路了)

no this

对于 store 内的函数要获取 store 的数据,通常我们会使用 this 获取。比如

1
2
3
4
5
6
7
8
9
const store = useLocalStore(() => ({
count: 0,
add() {
this.count++
}
}))

const { add } = store
add() // boom

这种方式一般情况下使用完全没有问题,但是 this 依赖 caller,而且无法很好的使用解构语法,所以这里并不推荐使用 this,而是采用一种 no this 的准则。直接引用自身的变量名

1
2
3
4
5
6
7
8
9
const store = useLocalStore(() => ({
count: 0,
add() {
store.count++
}
}))

const { add } = store
add() // correct,不会导致 this 错误
  • 避免 this 指向的混乱

  • 避免在使用的时候直接解构从而导致 this 丢失

  • 避免使用箭头函数直接定义 store 的 action,一是没有必要,二是可以将职责划分的更加清晰,那些是 state 那些是 action

source

在某些情况下,我们的 local store 可能需要获取 props 上的一些数据,而通过 source 可以很方便的把 props 也转换成 observable 的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
function App(props) {
const store = useLocalStore(source => ({
doSomething() {
// source 这里是响应式的,当外界 props 发生变化的时候,target 也会发生变化
if (source.count) {}
// 如果这里直接用 props,由于闭包的特性,这里的 props 并不会发生任何变化
// 而 props 每次都是不同的对象,而 source 每次都是同一个对象引用
// if (props.count) {}
}
// 通过第二个参数,就可以完成这样的功能
}), props)
// return Node
}

当然,这里不仅仅可以用于转换 props,可以将很多非 observable 的数据转化成 observable 的,最常见的比如 Context、State 之类,比如

1
2
3
4
5
6
7
const context = useContext(SomeContext)
const [count, setCount] = useState(0)
const store = useLocalStore(source => ({
getCount() {
return source.count * source.multi
}
}), {...props, ...context, count})

自定义 observable

有的时候,默认的 observable 的策略可能会有一些性能问题,比如为了不希望针对一些大对象全部响应式。可以通过返回自定义的 observable 来实现。

1
2
3
4
5
6
7
const store = useLocalStore(() => observable({
hugeObject: {},
hugeArray: [],
}, {
hugeObject: observable.ref,
hugeArray: observable.shallow,
}))

甚至你觉得自定义程度不够的话,可以直接返回一个自定义的 store

1
const store = useLocalStore(() => new ComponentStore())

类型推导

默认的使用方式下,最方便高效的类型定义就是通过实例推导,而不是通过泛型。这种方式既能兼顾开发效率也能兼顾代码可读性和可维护性。当然了,你想用泛型也是可以的啦

1
2
3
4
5
6
7
8
9
// 使用这种方式,直接通过对象字面量推导出类型
const store = useLocalStore(() => ({
todos: [] as Todo[],
}))

// 当然你可以通过泛型定义,只要你不觉得烦就行
const store = useLocalStore<{
todos: Todo[]
}>(() => ({todos: []}))

但是这个仅仅建议用作 local store 的时候,也就是相关的数据是在本组件内使用。如果自定义 Hooks 话,建议还是使用预定义类型然后泛型的方式,可以提供更好的灵活性。

memo?

当使用 useObserver api 之后,就意味着失去了 observer 装饰器默认支持的浅比较 props 跳过渲染的能力了,而此时需要我们自己手动配合 memo 来做这部分的优化

另外,memo 的性能远比 observer 的性能要高,因为 memo 并不是一个简单的 hoc

1
2
3
4
5
6
export default memo(function App(){
const xxx = useLocalStore(() => ({}))
return useObserver(() => {
return (<div/>)
})
})

不再建议使用 useCallback/useRef/useMemo 等内置 Hooks

上面的这几个 Hooks 都可以通过 useLocalStore 代替,内置 Hooks 对 Mobx 来说是毫无必要。而且这几个内置 api 的使用也会导致缓存的问题,建议做如下迁移

  • useCallback 有两种做法

    • 如果函数不需要传递给子组件,那么完全没有缓存的必要,直接删除掉 useCallback 即可,或者放到 local store 中也可以

    • 如果函数需要传递给子组件,直接放到 local store 中即可。

  • useMemo 直接放到 local store,通过 getter 来使用

useEffect or reaction?

经常使用 useEffect 知道他有一个功能就是监听依赖变化的能力,换句话说就是可以当做 watcher 使用,而 mobx 也有自己的监听变化的能力,那就是 reaction,那么究竟使用哪种方式更好呢?

这边推荐的是,两个都用,哈哈哈,没想到吧。

1
2
3
useEffect(() =>
reaction(() => store.count, () => console.log('changed'))
, [])

说正经的,针对非响应式的数据使用 useEffect,而响应式数据优先使用 reaction。当然如果你全程抛弃原生 hooks,那么只用 reaction 也可以的。

组合?拆分?

逻辑拆分和组合,是 Hooks 很大的一个优势,在 mobx 加持的时候,这个有点依旧可以保持。甚至在还更加简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function useCustomHooks() {
// 推荐使用全局 Store 的规则来约束自定义 Hooks
const store = useLocalStore(() => ({
count: 0,
setCount(count) {
store.count = count
}
}))
return store
}

function App() {
// 此时这个 store 你可以从两个角度来思考
// 第一,他是一个 local store,也就是每一个都会初始化一个新的
// 第二,他可以作为全局 store 的 local 化,也就是你可以将它按照全局 store 的方式来使用
const store = useCustomHook()
return (
// ui
)
}

App Store

Mobx 本身就提供了作为全局 Store 的能力,这里只说一下和 Hooks 配合的使用姿势

当升级到 mobx-react@6 之后,正式开始支持 hooks,也就是你可以简单的通过这种方式来使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function App() {
return (
<Provider sa={saStore} sb={sbStore}>
<Todo/>
</Provider>
)
}

export function Todo() {
const {sa, sb} = useContext(MobxProviderContext)
return (
<div>{sa.foo} {sb.bar}</div>
)
}

Context 永远是数据共享的方案,而不是数据托管的方案,也就是 Store

这句话怎么理解数据共享和组件通讯呢?举个例子

  • 有一些基础的配置信息需要向下传递,比如说 Theme。而子组件通常只需要读取,然后做对应的渲染。换句话说数据的控制权在上层组件,是上层组件共享数据给下层组件,数据流通常是单向的,或者说主要是单向的。这可以说是数据共享

  • 而有一些情况是组件之间需要通讯,比如 A 组件需要修改 B 组件的东西,这种情况下常见的做法就是将公共的数据向上一层存放,也就是托管给上层,但是使用控制权却在下层组件。其实这就是全局 Store,也就是 Redux 这类库做的事情。可以看出来数据流通常是双向的,这就可以算作数据托管

曾经关注过 Hooks 的发展,发现很多人在 Hooks 诞生的时候开始尝试用 Context + useReducer 来替换掉 Redux,我觉得这是对 Context 的某种曲解。

原因就是 Context 的更新问题,如果作为全局 Store,那么一定要在根组件上挂载,而 Context 检查是否发生变化是通过直接比较引用,那么就会造成任意一个组件发生了变化,都会导致从 Provider 开始的整个组件树发生重新渲染的情况。

1
2
3
4
5
6
7
8
9
10
function App() {
const [state, dispatch] = useReducer(reducer, init)
return (
// 每次当子组件调用 dispatch 之后,会导致 state 发生变化,从而导致 Provider 的 value 变化
// 进而让所有的子组件触发刷新
<GlobalContext.Provider value={{...state, dispatch}}>
{/* child node */}
</GlobalContext.Provider>
)
}

而如果你想避免这些问题,那就要再度封装一层,这和直接使用 Redux 也就没啥区别了。

主要是 Context 的更新是一个性能消耗比较大的操作,当 Provider 检测到变化的时候,会遍历整颗 Fiber 树,比较检查每一个 Consumer 是否要更新。

专业的事情交给专业的来做,使用 Redux Mobx 可以很好的避免这个问题的出现。

如何写好一个 Store

知道 Redux 的应该清楚他是如何定义一个 Store 吧,官方其实已经给出了比较好的最佳实践,但在生产环境中,使用起来依旧很多问题和麻烦的地方。于是就诞生了很多基于 Redux 二次封装的库,基本都自称简化了相关的 API 的使用和概念,但是这些库其实大大增加了复杂性,引入了什么 namespace/modal 啥的,我也记不清了,反正看到这些就自动劝退了,不喜欢在已经很麻烦的东西上为了简化而做的更加麻烦。

而 Mobx 这边,官方也有了一个很好的最佳实践。我觉得是很有道理,而且是非常易懂易理解的。

但还是那个问题,官方在有些地方还是没有进行太多的约束,而在开发中也遇到了类似的问题,所以这里在基于官方的框架下有几点意见和建议:

  • 保证所有修改 store 的操作都只能在 store 内部操作,也就是说你要通过调用 store 上的 action 方法更新 store,坚决不能在外部直接修改 store 的 property 的值。

  • 保证 store 的可序列化,方便 SSR 的使用以及一些 debug 的功能

    • 类构造函数的第一个参数永远是初始化的数据,并且类型保证和 toJSON 的返回值的类型一致

    • 如果 store 不定义 toJSON 方法,那么要保证 store 中的数据不存在不可序列化的类型,比如函数、DOM、Promise 等等类型。因为不定义默认就走 JSON.stringify 的内置逻辑了

  • store 之间的沟通通过构造函数传递实现,比如 ThemeStore 依赖 GlobalStore,那么只需要在 ThemeStore 的构造参数中传入 GlobalStore 的实例即可。不过说到这里,有的人应该会想到,这不就是手动版本的 DI 么。没错,DI 是一个很好的设计模式,但是在前端用的比较轻,就没必要引入库来管理了,手动管理下就好了。也通过这种模式,可以很方便的实现 Redux 那种 namespace 的概念以及子 store

  • 如果你使用 ts 开发,那么建议将实现和定义分开,也就是说分别定义一个 interface 和 class,class 继承 Interface,这样对外也就是组件内只需要暴露 interface 即可。这样可以很方便的隐藏一些你不想对外部暴露的方法,但内部却依旧要使用的方法。还是上面的例子,比如 GlobalStore 有一个属性是 ThemeStore 需要获取的,而不希望组件获取,那么就可以将方法定义到 class 上而非 interface 上,这样既能有良好的类型检查,又可以保证一定的隔离性。

是的,基本上这样就可以写好一个 Store 了,没有什么花里胡哨的概念,也没有什么乱七八糟的工具,约定俗成就足以。我向来推崇没有规则就是最大的规则,没有约束就是最大的约束。很多东西能约定俗成就约定俗成,落到纸面上就足够了。完全没必要做一堆 lint/tools/library 去约束,既增加了前期开发成本,又增加了后期维护成本,就问问你司内部有多少 dead 的工具和库?

俗话说的话,「秦人不暇自哀而后人哀之,后人哀之而不鉴之,亦使后人而复哀后人也」,这就是现状(一巴掌打醒)

不过以上的前提是要求你们的开发团队有足够的开发能力,否则新手很多或者同步约定成本高的话,搞个库去约束到也不是不行(滑稽脸)

缺点?

说了这么多,也不是说是万能的,有这个几个缺点

  • 针对一些就带状态的小组件,性能上还不如原生 hooks。可以根据业务情况酌情针对组件使用原生 hooks 还是 mobx hooks。而且针对小组件,代码量可能相应还是增多。因为每次都要包裹 useObserver 方法。

  • mobx 就目前来看,无法很好在未来使用异步渲染的功能,虽然我觉得这个功能意义不大。某种程度上说就是一个障眼法,不过这个思路是值得一试的。

  • 需要有一定 mobx 的使用基础,如果新手直接上来写,虽然能避免很多 hooks 的坑,但是可能会踩到不少 mobx 坑

总结

Mobx 在我司的项目中已经使用了很久了,但 Hooks 也是刚刚使用没多久,希望这个能给大家帮助。也欢迎大家把遇到的问题一起说出来,大家一起找解决办法。

我始终觉得基于 Mutable 的开发方式永远是易于理解、上手难度最低的方式,而 Immutable 的开发方式是易维护、比较稳定的方式。这两者没必要非此即彼,而 Mobx + React 可以认为很好的将两者整合在一起,在需要性能的地方可以采用 Immutable 的方式,而在不需要性能的地方,可以用 Mutable 的方式快速开发。

当然了,你就算不用 Mobx 也完全没有问题,毕竟原生的 Hooks 的坑踩多了之后,习惯了也没啥问题,一些小项目,我也会只用原生 Hooks 的(防杠声明)。

注释和共享

自从 Hooks 诞生以来,官方就有考虑到了性能的问题。添加了各种方法优化性能,比如 memo、hooks deps、lazy initilize 等。而且在官方 FAQ 中也有讲到,Function 组件每次创建闭包函数的速度是非常快的,而且随着未来引擎的优化,这个时间进一步缩短,所以我们这里根本不需要担心函数闭包的问题。

当然这一点也通过我的实验证实了,确实不慢,不仅仅是函数闭包不慢,就算是大量的 Hooks 调用,也是非常快的。简单来说,1 毫秒内大约可以运行上千次的 hooks,也就是 useState useEffect 的调用。而函数的创建,就更多了,快的话十万次。

很多人都觉得既然官方都这么说了,那我们这么用也就好了,不需要过分担心性能的问题。我一开始也是这样想的。但是直到最近有一次我尝试对公司项目里面一个比较复杂的组件用 Hooks 重写,我惊奇的发现重渲染时间竟然从 2ms 增长到了 4ms。业务逻辑没有任何变化,唯一的变的是从 Class 变成了 Hooks。这让我有点难以相信,我一直觉得就算是慢也不至于慢了一倍多吧,怎么着两者差不多吧。于是我开始无差别对比两个写法的性能区别。

懒人阅读指南

我相信肯定很多懒人不想看下面的分析,想直接看结果。没问题,满足你们,直接通过目录找到最后看「总结」就好了,如果你觉得有问题或者觉得我说的不对,可以重新仔细阅读一下文章,帮我指出哪里有问题。

为什么有这篇文章

其实我原本不是很想写一篇文章的,因为我觉得这个只是很简单的一个对比。于是我只是在掘金的沸点上随口吐槽了两句,结果……我决定写一篇文章。主要是觉得这群人好 two,就算是质疑也应该先质疑我的测量方式,而不是说我的使用方式。都用了这么多年了,还能用错)滑稽脸

不过既然要写,就写的完备一些,尽量把一些可能的情况都覆盖了,顺便问问大家是否有问题。如果大家对下面的测试方法或者内容有任何问题的话,请大家正常交流哦,千万不要有一些过激或者偏激的言论。因为性能测试这东西,一人一个方法,一人一个想法。

既然说道这里,其实有一点我要说,沸点里面说到的 50% 这个测量数据确实有些问题。主要有这么几个原因,第一,我当初只是想抱着试试的心态,于是就直接在开发模式下运行的。第二,平时写代码写习惯了,就直接用了 Date.now() 而没有使用精度更高 performance.now() 从而导致了误差略微有点大。虽然误差略大,但是大方向还是没错的

后文的测试中,我也将这些问题修复了,尽量给大家一个正确的数据。

开始之前,我们要知道……

假设现在有 HookCompClassComp 两个组件分别表示函数式组件和类组件,后文用 Hook(HC) 和 Class(CC) 代替。

功能定义

为了更加贴近实际,这里假设两个组件都要完成相同的一个功能。那就是用户登录这个流程:

  • 有用户名输入框和密码输入框

  • 有一个登录按钮,点击之后校验用户名是否为 admin 且密码为 admin

  • 如果校验成功,下方提示登录成功,否则提示用户名或者密码错误

  • 每次输入内容,都将清空内容

  • 另外为了消除误差,额外添加一个按钮,用于触发 100 次的 render,并 log 出平均的渲染时间。

具体的业务逻辑的实现,请看后面的 DEMO 地址。

另外因为 Class 组件有 setState 可以自动实现 batch 更新,但是 Hook 不行,所以这里实现的时候把所有的更新操作都放在 React 事件中同步更新,众所周知,React 的事件是自带 batch 更新的,从而保证只有一次渲染。保证两者功能效果一致。

对比常量

  • 2018 款 15 寸 MacBook Pro 入门款,i7-8750H 6 核 12 线程 + 16g + 256g

  • Chrome Stable 79.0.3945.117

  • react 16.12.0 PS: 其实我从 16.8.0 就开始测试了,懒癌发作一直没有继续搞

  • react-dom 16.12.0

    React 全家桶版本全部使用生产模式,降低开发模式的影响。

衡量标准:从函数调用到渲染到 DOM 上的时间

这个时间其实当组件量非常大的时候其实是不准的,因为大家调用的时间是不同的,但是渲染到 DOM 上的时间基本是一致的,就会导致在组件树越浅越前的组件测量出来的时间就会越长。但是这里的情况是页面只有一个对比组件,所以可以暂时用这个作为衡量标准。

针对 HC 来说

  • 在组件运行的一开始就记录为开始时间

  • 使用 useLayoutEffect 的回调作为结束时间。该 Hook 会在组件挂载或者更新 DOM 之后同步调用。而 useEffect 会在下一个 tick 调用,如果使用该 hook 就会导致最终测量出来的结果普遍慢一些。

1
2
3
4
5
function Hooks() {
const now = performance.now()
useLayoutEffect(() => console.log(performance.now() - now))
return (/* ui */)
}

针对 CC 来说

  • 当运行 render 方法的时候,记录时间

  • 当运行 componentDidUpdate 或者 componentDidMount 的时候,打印耗时。这两个钩子都是在组件挂载或者更新 DOM 之后同步调用,与 useLayoutEffect 调用时机一致。

1
2
3
4
5
6
7
8
9
class Class extends Component {
componentDidMount = () => this.log()
componentDidUpdate = () => this.log()
log = () => console.log(performance.now() - this.now)
render() {
this.now = performance.now()
return (/* ui */)
}
}

测试流程和结果计算

  • 页面刷新,此时要针对测试内容先进行 5 轮预热测试。目的是为了让 Chrome 对热区代码进行优化,达到最高的性能。

  • 每一轮包含若干次的渲染,比如 100 次或者 50 次,对于每一轮测试,都会抛弃 5% 最高和最低一共 10% 的数据,只保留中间的值,并对这些值计算平均值得到该轮测试结果

  • 然后进行 5 轮正常测试,记录每次的结果,统计平均值。

  • 将此时的值计算作为最终的数据值

DEMO 地址

PS: CodeSandBox 似乎不能以生产模式运行,不过你可以将它一键部署到 ZEIT 或者 netlify 上面,查看生产环境的效果。

开胃菜-重渲染测试结果

最为开胃菜,用一个最常见的场景来测试实在是最合适不过了,那就是组件的重渲染。话说不多,直接上测试结果

Avg. Time(ms) Hook Slow Hook(Self) Class Class(Self) Hook Self
第五次平均时间 0.171808623414 0.04126375367107627 0.1941208809532307 0.024725271102327567 0.22747252228577713 0.6688898374583169
第四次平均时间 0.1696739222 0.04082417709159327 0.18879122377096952 0.02120880942259516 0.22082417118516598 0.9248688730306829
第三次平均时间 0.160409555184 0.04109888910674132 0.1931868181410399 0.022967028748858104 0.22417582970644748 0.7894734907224213
第二次平均时间 0.130965058748 0.045824176785382593 0.2072527365001676 0.02346153545019391 0.23439560331158585 0.953161884168321
第一次平均时间 0.216216175927 0.04549450906259673 0.20939560484263922 0.02357143663115554 0.2546703217776267 0.9300694214990858

简单解释下数据,Hook 和 Class 是通过上面规定的方式统计出来的数据,而 Hook(Self) Class(Self) 是计算了 HC 和 CC 函数调用的时间,最后的 Self 和 Hook Slow 则是 Hook 相比 Class 慢的百分比。这里只需要关注不带 Self 的数据即可。

让我们来细细「品味」一下,Hook 比 Class 慢了 16%。

等等??? 16%,emmm……乍一听这是一个多么惊人的数字,5 % 的性能降低都很难接受了,何况是 16%。如果你的页面中有上百个这样组件,想想都知道……咦~~~那酸爽

Wait!!! 或许有人会说了,抛开数值大小谈相对值,这根本就是耍流氓么。每个组件组件都是毫秒级别的渲染,这么小的级别作比较误差也会很大。而且你的测试的测量方式真的很对么?为啥看到很多文章说 Hooks 性能甚至比 Class 组件还高啊。而且你这个测量真的准确么?

这里先回答一下测量的问题,上面也说了,useLayoutEffect 和 CDU/CDM 基本是一致的,而且为了佐证,这里直接上 Performance 面板的数据,虽然只能在开发模式下才能看到这部分数据,但依旧具有参考意义

当然因为我这里只是截取了一个样例,没法给大家一个平均的值,但是如果大家多次尝试可以发现就算是 React 自己打的标记点,Class 也会比 Hook 快那么一点点。

而针对更多的疑问,这里我们就基于这个比较结果,引申出更多的比较内容,来逐步完善:

  • 挂载性能如何?也就是第一次渲染组件

  • 大量列表渲染性能如何?没准渲染的组件多了,性能就不会呈现线性叠加呢?

  • 当 Class 被很多 HOC 包裹的时候呢?

其他对比

挂载性能

通过快速卸载挂载 40 次计算出平均时间,另外将两者横向布局,降低每次挂载卸载的时候 Chrome Layout&Paint 上的差异。话不多说,直接上结果

Avg. Time(ms) Hook Slow(%) Hook(Self) Class(Self) Hook Class
第三次平均时间 0.100681682204 0.04797298587053209 0.024729729252489837 0.5672973001728187 0.5154054158845464
第二次平均时间 0.137816482105 0.041216209128096294 0.02486483395301007 0.6013513618224376 0.5285134916571347
第四次平均时间 0.009446076914 0.04378377736822979 0.025405410073093465 0.5343243404216057 0.5293243023491389
第五次平均时间 0.05774346214 0.041081066671255474 0.025540552529934292 0.5371621495263802 0.5078378347428264
第一次平均时间 0.036722530281 0.04027024968653112 0.025810805980015446 0.5608108209295047 0.5409459180727199

通过交替运行连续跑 5 轮 40 次测试,可以得到上面这个表格。可以发现,不管那一次运行,都是 Class 时间会少于 Hook 的时间。通过计算可得知,Hook 平均比 Class 慢了 (0.53346 - 0.49811) / 0.49811 = 7%,绝对差值为 0.03535ms。

这个的性能差距可以说是很少了,如果挂载上百个组件的时候,两者差距基本是毫秒内的差距。而且可以看出来,绝对值的差距可以说是依旧没有太多的变化,甚至略微微微微减少,可以简单的认为其实大部分的时间依旧都花费在一些常数时间上,比如 DOM。

大列表性能

通过渲染 100 个列表的数据计算出平均时间。

Avg. Time(ms) Hook(500) Hook Hook Slow(%,500) Hook Slow(%) Class(500) Class
第二次平均时间 9.59723405143682 2.6090425597701934 0.10286063613 0.104480973312 8.702127664211266 2.3622340473168073
第三次平均时间 9.64329787530005 2.5888297637488615 0.10438723417 0.104028668798 8.731808533218313 2.344893603684737
第一次平均时间 9.55063829376818 2.5251063647026077 0.085798601307 0.081415992606 8.795957447604296 2.335000020313136
第五次平均时间 9.597553207756992 2.571702087694343 0.10075770158 0.15273472846 8.719042523149797 2.230957413012994
第四次平均时间 9.604468084673615 2.567340426662184 0.095974553092 0.0995534837 8.76340427574642 2.334893631570517

我们先不计算下慢了多少,先看看这个数值,100 次渲染一共 2ms 多,平均来说一次 0.02ms,而而我们上面测试的时候发现,单独渲染一个组件,平均需要 0.2ms,这中间的差距是有点巨大的。

而如何合理解释这个问题呢?只能说明在组件数小的时候,React 本身所用的时间与组件的时间相比来说比例就会比较大,而当组件多了起来之后,这部分就变少了。

换句话说,React Core 在这中间占用了多少时间,我们不得而知,但是我们知道肯定是不少的。

HOC

Hook 的诞生其实就是为了降低逻辑的复用,简单来讲就是简化 HOC 这种方式,所以和 Hook 对线的其实是 HOC。最简单的例子,Mobx 的注入,就需要 inject 高阶组件包裹才可以,但是对于 Hook 来讲,这一点完全不需要。

这里测试一下 Class 组件被包裹了 10 层高阶组件的情况下的性能,每一层包裹的组件做的事情非常简单,那就是透传 props。

啥?你说根本不可能套 10 层?其实也是很容易的,你要注意这里我们所说的 10 层其实是指有 10 层组件包裹了最终使用的组件。比如说大家都知道 mobx inject 方法或者 redux 的 connect 方法,看似只被包裹了一层,其实是两层,因为还有一层 Context.Consumer。同理你再算上 History 的 HOC,也是一样要来两层的。再加上一些其他的东西,再加一点夸张不就够了,手动滑稽)

Avg. Time(ms) Class With 10 HOC
第五轮 0.25384614182697546
第四轮 0.27269232207602195
第二轮 0.2821977993289193
第三轮 0.278846147951189
第一轮 0.2710439444898249

这结果也就是很清楚了吧,在嵌套较多 HOC 的时候,Class 的性能其实并不好,从 0.19855ms 增加到 0.27173ms,时间接近有 26% 的增加。而这个性能不好并不是因为 Class,而是因为渲染的组件过多导致的。从另一个角度,hook 就没有这种烦恼,即使是大量 Hook 调用性能依旧在可接受范围内。

量化娱乐一下?

有了上面的数据,来做一个有意思的事情,将数据进行量化。

假设有如下常数,r 表示 React 内核以及其他和组件数无关的常数,h 表示 hook 组件的常数,而 c 表示 Class 组件的常数,T 表示最终所消耗的时间。可以得知这四个参数肯定不为负数。

通过简单的假设,可以得到如下等式:

1
2
3
T(n,m) = hn + cm + r
// n 表示 hook 组件的数量
// m 表示 class 组件的数量

想要计算得到 r h c 参数也很–简单–,简单个鬼,因为数据不是准确的,不能直接通过求解三元一次方程组的方式,而是要通过多元一次拟合的方式求得,而我又不想装 matlab,于是千辛万苦找到一个支持在线计算多元一次方程的网站算了下,结果如下:

1
2
3
4
5
h = 0.0184907294
c = 0.01674766395
r = 0.4146159332
RSS = 0.249625719
R^2 = 0.9971412136

这个拟合的结果有那么一点点差强人意,因为如果你把单个 Class 或者 Hook 的结果代入的话,会发现偏差了有一倍多。所以我上面也说道只是娱乐娱乐,时间不够也没法细究原因了。不过从拟合的结果上来看,也能发现一个现象,那就是 h 比 c 要大。

另外观察最后的拟合度,看起来 0.99 很大了,但实际上并没有什么意义。而且这里数据选取的也不是很好,做拟合最好还是等距取样,这样做出来的数据会更加准确。这里只是突然奇想想要玩玩看,所以就随便做了下。

总结

不管你是空降过来的还是一点点阅读到这里的,我这边先直接说基于上面的结论:

  • 当使用 Hook 的时候,整体性能相比 Class 组件会有 10 - 20% 的性能降低。

  • 当仅仅使用函数式组件,而不使用 Hook 的时候,性能不会有降低。也就是说可以放心使用纯函数式组件

  • Hook 的性能降低不仅仅体现在渲染过程,就算是在第一次挂载过程中,也相比 Class 有一定程度的降低

  • Hook 的性能降低有三部分

    • 第一部分是 Hook 的调用,比如说 useState 这些。但是这里有一点需要注意的是,这里的调用指的是有无,而不是数量。简单来说就是从 0 到 1,性能降低的程度远远高于 从 1 到 n。

    • 第二部分是因为引入 Hook 而不得不在每次渲染的时候创建大量的函数闭包,临时对象等等

    • 第三部分是 React 对 Hook 处理所带来的额外消耗,比如对 Hook 链表的管理、对依赖的处理等等。随着 Hook 的增加,这些边际内容所占用的时间也会变得越来越大。

  • 但 Hook 有一点很强,在逻辑的复用上,是远高于 HOC 方式,算是扳回一局。

所以 Hook 确实慢,慢的有理有据。但究竟用不用 Hooks 就全看,我不做定夺。凡事都有两面,Hooks 解决了 Class 一些短板,但是也引入了一些不足。如果一定要我推荐的话,我推荐 Hooks+Mobx。

Refs

One More

以上内容是我花了快一个月一点点整理出来的,甚至还跨了个与众不同的「年」。性能测试本身就是一个很有争议的东西,不同的写法不同的测试方式都会带来不同的结果。我也是在这期间一点点修改我的测试内容,从最开始只有单组件测试,到后来添加了组件列表的测试,以及挂载的测试。另外对数据收集也修改了很多,比如多次取平均值,代码预热等等。每一次修改都意味着所有测试数据要重新测试,但我只是想做到一个公平的对比。

就在现在,我依旧会觉得测试里面有很多内容依旧值得去改进,但是我觉得拖的时间太长了,而且我认为把时间花在从源码角度分析为什么 Hook 比 Class 慢上远比用数据证明要有意义的多。

注释和共享

当你看到这个标题的时候,一定很好奇,React 不是很快么?为啥会变慢呢?在写这篇文章之前,我也是这么认为的,但是当我去看了一下 React 有关 Array 的 Diff 之后,我才认识到其实 React 如果你用的不正确,那么是会变慢的。

React Diff 算法

React Diff 算法相信大家不是很陌生吧,这里就不具体展开讲了。不过有一点要补充下,Diff 算法针对的是整个 React 组件树,而不仅仅是 DOM 树,虽然这样性能会比较低一些,但是实现起来却很方便。

而在 Diff 算法中,针对数组的 diff 其实是比较有意思的一个地方。在开始讲解方面,我希望你能对 React 有一定的了解和使用。

试一试有什么区别?

首先我们创建 3 个组件,分别渲染 10000 个 DOM 元素,从 [1...10000] ,渲染成如下。

1
2
const e10000 = new Array(10000).fill(0).map((_, i) => i + 1)
element10000.map(i => <div key={`${i}`}>{i}</div>)

每个组件有两个状态,会切换数据的顺序

  • 组件 A 在 [1...10000][2,1,3...10000] 之间切换。

  • 组件 B 在 [1...10000][10000,1...9999] 之间切换

  • 组件 C 在 [1...10000][10000...1] 之间切换,也就是正序和倒序之间切换。

我们简单命名下,默认的初始状态为 S1 而切换之后的状态为 S2 。大家可以思考一下,同一个组件状态切换的时候,所耗费的时间是不是都是一样的?可以直接使用这个 DEMO

可以直接点击上方的 toggle 来切换两者之间的状态,并在控制台中查看渲染的时间。因为每次时间都不是绝对准确的,所以取了多次平均值,直接揭晓答案:

组件 S2 ⇒ S1 S1 ⇒ S2
A 102ms 103ms
B 129ms 546ms
C 556ms 585ms

有么有觉得很奇怪,为什么同样是 S1 ⇒ S2 ,同样是只改变了一个元素的位置,为什么 A 和 B 的时间差距有这么多的差距。这个具体原理就要从 Diff 算法开始讲起了。

Array Diff 的原理

在讲 React 的实现之前,我们先来抛开 React 的实现独立思考一下。但是如果直接从 React 的组件角度下手会比较麻烦,首先简化一下问题。

存在两个数组 A 和 B,数组中每一个值必须要保证在对应数组内是唯一的,类型可以是字符串或者数字。那么这个问题就转变成了如何从数组 A 通过最少的变换步骤到数组 B。

其实每个元素的值对应的就是 React 当中的 key。如果一个元素没有 key 的话,index 就是那个元素默认的 key。为什么要强调最少?因为我们希望的是能够用最少的步数完成,但是实际上这会造成计算量的加大,而 React 的实现并没有计算出最优解,而是一个较快解。

顺便定义一下操作的类型有:删除元素插入元素移动元素

这里又要引申一个特殊点,React 充分利用了 DOM 的特性,在 DOM 操作中,你是可以不使用 index 来索引数据的。简单来讲,如果用数组表示,删除需要指定删除元素的索引,插入需要指定插入的位置,而移动元素需要指定从哪个索引移动到另一个索引。而利用 DOM,我们就可以简化这些操作,可以直接删除某个元素的实例,在某个元素前插入或者移动到这里(利用 insertBefore API,如果是要在添加或者移动到最后,可以利用 append )。这样最大的好处是我们不需要记录下移动到的位置,只需要记录下那些元素移动了即可,而且这部分操作正好可以由 Fiber 来承担。

举个例子说,从 A=[1,2,3] 变化到 B=[2,3,4,1],那么只需要记录如下操作即可:

有人好奇,不需要记录移动插入到那个元素前面么?其实不需要的,这是因为你有了操作列表和 B 数组之后,就可以知道目标元素在哪里了。而且采用这种方式就根本不需要关心每次操作之后索引的变化。

回到上面的简化后的问题,首先通过对比 A、B 数组,可以得到哪些元素是删除的,哪些元素是添加的,而不管采用什么样子的策略,添加删除元素的操作次数是无法减少的。因为你不能凭空产生或者消失一个元素。那么我们问题就可以再简化一下,把所有的添加删除的元素剔除后分别得到数组 A’ 和 B’,也就是 A’ 中不包含被删除的元素,B’ 中不包含被添加的元素,此时 A’ 和 B’ 的长度一定是一样长的。也就是求解出最少移动次数使得数组 A’ 能够转化成数组 B’。

如果只是简单的求解一下最少移动步数的话,答案很简单,就是最长上升子序列(LIS,Longest Increasing Subsequence)。关于如何证明为什么是最长不下降子序列这个算法,可以通过简单的反证法得到。关于这个算法的内容我就不具体讲解了,有兴趣的可以自行 Google。在这里我们只需要知道这个算法的时间复杂度是 O(n^2)

但是现在我们还无法直接应用这个算法,因为每个元素的类型可能是字符串或者数字,无法比较大小。定义数组 T 为 B’ 内元素在 A’ 的位置。举个例子,如果 A' = ['a', 'b', 'c'] B' = ['b', 'c', 'a'],那么 T = [2, 3, 1]。本文约定位置是从 1 开始,索引从 0 开始。

此时便可以对 T 求解 LIS,可以得到 [2, 3],我们将剩下不在 LIS 中的元素标记为移动元素,在这里就是 1,最后补上被剔除的删除和插入的元素的操作动作。这样 Diff 算法就可以结束了。

上面讲解的是一个个人认为完整的 Array Diff 算法,但是还是可以在保证正确性上继续优化。但是不管优化,这个复杂度对于 React 来讲还是偏高的,而如何平衡效率和最优解成为了最头疼的问题,好在 React 采用了一个混合算法,在牺牲掉一定正确性的前提下,将复杂度降低为 O(n)。下面我们来讲解下。

React 简化之后的 Array Diff

大家有过 React 开发经验的人很清楚,大部分情况下,我们通常是这样使用的:

  • 情形1:一个标签的的直接子子标签数量类型顺序不变,通常用于静态内容或者对子组件的更新

    1
    2
    3
    4
    5
    6
    7
    // 比如每次渲染都是这样的,里面的直接子元素的类型和数量是不变的,在这种情况下,其实是可以省略 key
    <div>
    <div key="header">header</div>
    <div key="content">content</div>
    <div key="footer">footer</div>
    <SubCmp time={Date.now()}/>
    </div>
  • 情形2:一个标签有多个子标签,但是一般只改变其中的少数几个子标签。最常见的场景就是规则编辑器,每次只在最后添加新规则,或者删除其中某个规则。当然了,滚动加载也算是这种。

  • 情形3:交换某几个子标签之间的顺序

  • 情形4:翻页操作,几乎重置了整个子元素

上面只是简单举了几个常见的例子,大家可以发现,大部分情况下子标签变动的其实并不多,React 利用了这个,所以将 LIS 简化成以第一个元素开始,找到最近上升子序列。简单来来讲就是从头开始遍历,只要这个元素不小于前的元素,那么就加入队列。

1
2
3
4
5
Q = [4, 1, 5, 2, 3]
// 标准算法
LIS = [1, 2, 3]
// 简化后的算法,从第一个开始,找到最近的不下降子序列即可。
LIS_React = [4, 5]

我们乍一看,这个算法不对呀,随便就能举出一个例子让这个算法错成狗,但是我们要结合实际情况来看。如果我们套回前面说的几种情况,可以看到对于情况 1,2,3 来讲,几乎和简化前效果是一样。而这样做之后,时间复杂度降低为 O(n) ,空间复杂度降低为 O(1)。我们给简化后的算法叫做 LIS' 方便后面区分。

我们将 LIS 算法简化后,配合上之前一样的流程就可以得出 React 的 Array Diff 算法的核心流程了。(为什么叫核心流程,因为还有很多优化的地方没有讲)

变慢的原因?

当我们在了解了 React 的实现之后,我们再回过来头来看看前面给出的三个例子为啥会有这么大的时间差距?

  • 组件 A 从 [1...10000] 变化到 [2,1,3...10000] 。此时我们先求解一下 LIS' 可以得到 [2,3,4...10000],那么我们只需要移动 1 这个元素就可以了,将移动到元素 3 前面。同理反过来也是如此,也就是说 S1 ⇒ S2 和 S2 ⇒ S1 的所需要移动的次数是一致的,理论上时间上也就是相同的。

  • 组件 B 从 [1...10000] 变化到 [10000,1,2...9999] 。同理,先计算 LIS' 可以得到 [10000],没错,你没看错,就是只有一次元素,那么我需要将剩下的所有元素全都移动到 10000 的后面去,换句话要进行 9999 次移动。这也就是为啥 S1 => S2 的时间会这么慢。但是反过来却不需要这个样子,将状态反过来,并重新计算索引,那么也就是从 [1...10000][2,3....10000,1],在计算一次 LIS' 得到 [2,3...10000] ,此时只需要移动一次即可,S2 ⇒ S1 的时间也就自然恢复的和组件 A 一致。

  • 组件 C 是完全倒序操作,所以只分析其中一个过程即可。首先计算 LIS' 可以得到,[10000] ,也就是说要移动 9999 次,反过来也是要 9999 次,所以时间状态是一致的。

经过这样的分析大家是不是就明白为啥会变慢了吧?

优化细节

降低 Map 的生成操作次数

上面有一点没有讲到,不知道大家有没有思考到,我怎么知道某个元素是该添加函数删除呢?大家第一反应就是构建一个 Set,将数组元素全放进去,然后进行判断就可以了。但是在 React 中,其实用的是 Map,因为要存储对应的 Fiber,具体细节大家可以不用关注,只需要知道这里用 Map 实现了这个功能。

不管怎么样,根据算法,一开始肯定要构建一遍 Map,但是我们来看下上面的 情形1。发现内容是根本不会发生变化的,而且对于 情形2 来讲,有很大的概率前面的大部分是相同的。

于是 React 一开始不构建 Map,而是假设前面的内容都是一致的,对这些元素直接执行普通的更新 Fiber 操作,直到碰到第一个 key 不相同的元素才开始构建 Map 走正常的 Diff 流程。按照这个方式,情形1根本不会创建 Map,而且对于情形2、3来讲也会减少很多 Map 元素的操作(set、get、has)。

降低循环次数

按照上面的算法,我们需要至少 3 遍循环:第一遍构建 Map,第二遍剔除添加删除的元素生成 A’ 和 B’,第三遍计算 LIS 并得到哪些元素需要移动或者删除。而我们发现第二遍和第三遍是可以合并在一起的。也即是说我们在有了 Map 的情况下,不需要剔除元素,当遍历发现这个元素是新增的时候,直接记录下来。

总结

关于 Diff 算法其实还有很多的细节,我这边没有过多讲解,因为比较简单,比较符合直觉。大家有兴趣的可以自己去看下。另外有人应该会注意到,上面的例子中,为什么切换同样的次数,有的时间长,有的时间短了。日后有时间再分析下补充了。

注释和共享

目录

  1. 可控组件?不可控组件?
    1. 什么是可控和不可控?
    2. 为啥要区分呢?
    3. value? defaultValue? onChange?
    4. propName in this.props?
    5. Independence
    6. 如何使用?
    7. 总结

前言:本人入职之后算是第一次真正去写 React,发现了 React 的组件系统和其他框架的组件系统有些许的不同,这也触发了我对其中组件的可控性的一些思考和总结。

可控组件?不可控组件?

自从前端有了组件系统之后,有一个很常见但是却又被大家忽视的概念,就是可控组件(Controlled Component)和不可控组件(Uncontrolled Component)。

什么是可控和不可控?

官方详细讲解了什么事可控和不可控组件,虽然只是针对 input 组件的 value 属性来讲的。但是对于很多第三方组件库来讲,一个组件不止有一个数据属于可控。比如 Ant Design 的 Select 组件,valueopen 都属于可控的数据,如果你让 value 可控 open 不可控,那这到底是可控组件还是不可控组件呢?

所以从广义来讲使用可控/不可控组件其实不是很恰当,这里使用可控数据不可控数据更加合理一点。一个组件可能可能同时有可控的数据和不可控的数据。

可控数据是指组件的数据被使用者所控制。不可控数据是指组件的数据不由使用者来控制而是由组件内部控制。

之所以会有可控和不可控,主要是跟人奇怪的心理有关。如果把框架比作一个公司,组件比作人,组件之间的关系比作上下级。那么上级对下级的期望就是你既能自己做好分内的事情,也可以随时听从我的命令。这本身就是一件矛盾的事情,一边撒手不管,一边又想全权掌控。遇到这样的上级,下级肯定会疯了吧。

为啥要区分呢?

在 Vue 中,其实都忽视了这两者的区别,我们来看下面这个例子。

1
<input/>

上面是一个最简单 Input 组件,我们来思考一下如下几种使用场景:

  • 如果我只关心最后的结果,也就是输入的值,中间的过程不关心,最简单的方式是用 v-model 或者自己在 change 事件里面获取值并保存下来。

    1
    2
    3
    <input v-model="value"/>
    <!-- OR -->
    <input @change="change"/>

    这种场景是非常普遍,Vue 可以很好的完成,结果也符合人们的预期。

  • 如果我也只是关心结果,但是想要一个初始值。
    也很简单,通过 value 传入一个静态字符串不就好了,或者传入一个变量,因为 Vue 的 props 是单向的。

    1
    2
    3
    <input v-model="value"/> <!-- value 有初始值 -->
    <input value="init string" @change="change"/>
    <input :value="initValue" @change="change"/>

    其中第三个方案并不是非常正确的方式,如果 initValue 在用户输入期间发生了更新,那么他将覆盖用户的数据,且不会触发 change 事件。

  • 我不仅仅关心结果,还关心过程,我需要对过程进行控制。比如说把输入的字符串全部大小写,或者锁定某些字符串。
    熟练的工程师肯定可以写出下面的代码。

    1
    2
    <input v-model="value"/> <!-- watch "value",做修改 -->
    <input :value="value" @change="change"/> <!-- 在 change 中修改数据 -->

    但是这会有问题:

    1. 数据的修改都是在渲染 dom 之后,也就是说你不管怎么处理,都会出现输入的抖动。
    2. 如果通过第二种方法,恰巧你做的工作是限制字符串长度,那么你这样写 change(e) {this.value = e.target.slice(0, 10)} 函数会发现没有效果。这是因为当超过 10 字符之后,value 的值长度一直是 10,vue 没有检测到 value 的变化,从而不会更新 input.value。

出现这个问题最关键的是因为没有很好的区分可控组件和不可控组件,我们来回顾一下上面的某一段代码:

1
<input :value="value" @change="change"/>

你能从这块代码能看出来使用这个组件的用户的意图是什么呢?他是想可控的使用组件还是说只是想设置一个初始值?你无法得知。我们人类都无法得知,那么代码层面就不可能得知的了。所以 vue 对这一块的处理其实是睁一只眼闭一只眼。用户用起来方便,

用一个例子来简单描述一下:上级让你去做一项任务,你询问了上级关于这些任务的信息(props),然后你就开始(初始化组件)工作了,并且你隔一段时间就会向上级汇报你的工作进度(onChange),上级根据你反馈的进度,合理安排其他的事情。看起来一切都很完美。但是有的上级会有比较强的控制欲,当你提交了你的工作进度之后,他还会瞎改你的工作,然后告诉你,按照我的继续做。然后下级就懵逼,当初没说好我要接受你的修改的呀(value props),我这里也有一份工作进度呀(component state),我应该用我自己的还是你的?

对于人来说,如何处理上级的要求(props)和自身工作(state)是一个人情商的表现,这个逻辑很符合普通人的想法,但是对于计算机来说,它没有情商也无法判断究竟应该听谁的。为了克服这个问题,你需要多很多的判断和处理才可以,而且对于一些不变的值,你需要先清空再 nextTick 之后赋值才可以出发组件内部的更新。

最近入职之后,公司用到了 React,我才真正的对这个有所理解。

value? defaultValue? onChange?

如果对 React 可控组件和不可控组件有了解了可以跳过这块内容了。

让我们来看一下 React 如何处理这个的?我们还是拿上面的那三种情况来说:

  • 如果我只关心最后的结果,也就是输入的值,中间的过程不关心

    1
    <input onChange={onChange}/>
  • 如果我也只是关心结果,但是想要一个初始值

    1
    2
    <input defaultValue="init value" onChange={onChange}/>
    <input defaultValue={initValue} onChange={onChange}/>
  • 我不仅仅关心结果,还关心过程,我需要对过程进行控制

    1
    <input value={value} onChange={onChange}/>

当看完了这段你会很清楚的知道什么样的结构是可控,什么结构是不可控:

  • 如果有 value 那么就属于可控数据,永远使用 value 的值
  • 否则属于不可控数据,由组件使用内部 value 的值,并且通过 defaultValue 设置默认值

不论什么情况修改都会触发 onChange 事件。

React 对可控和不可控的区分其实对于计算机来说是非常合理的,而且也会让整个流程变的非常清晰。当然,不仅仅只有这一种设置的方式,你可以按照一定的规则也同样可以区分,但是保证可控和不可控之间清晰的界限是一个好的设计所必须要满足的

propName in this.props?

了解上面的概念之后,我们进入到实战环节,我们怎么从代码的层面来判断当前组件是可控还是不可控呢?

根据上面的判断逻辑来讲:

1
2
3
const isControlled1 = 'value' in this.props // approval 1
const isControlled2 = !!this.props.value // approval 2
const isControlled3 = 'value' in this.props && this.props.value !== null && this.props.value !== undefined // approval 3

我们来观察上面几个判断的方式,分别对应一下下面几个模板(针对第三方组件):

1
2
3
4
<Input value={inputValue} /> // element 1,期望可控
<Input value="" /> // element 2,期望可控
<Input /> // element 3,期望不可控
<Input value={null} /> // element 4,期望???

可以得到如下表格

是否可控 approval 1 approval 2 approval 3
element1 true true true
element2 true false true
element3 false false false
element4 true false false

大家第一眼就应该能看出来方法二其实是不正确的,他无法很好的区分这两种状态,所以直接 pass 掉。

眼尖的同学也会发现为什么 element 4 的期望没有填写呢?这是因为有一条官方的规则没有讲,这条规则是这样的:当设置了 value 属性之后,组件就变成了可控组件,会阻止用户修改 input 的内容。但是如果你想在设置了 value prop 的同时还想让用户可以编辑的话,只可以通过设置 valueundefinednull

在官方的这种规则下面,element 4 期望是不可控组件,也就是说 approval 3 是完全符合官方的定义的。但是这样会导致可控和不可控之间的界限有些模糊。

1
2
<Input value={inputValue} />
// 如果 inputValue 是 string,组件是什么状态?如果是 null 又是什么状态?

所以这里其实我推荐使用 approval 1 的方式,这也是 antd 所采用的。虽然不符合官方的定义,但是我觉得符合人们使用组件的一种直觉。第六感,=逃=

Independence

有了判断的方法,那么我们可以画出一个简单的流程图(Input 组件为例):

图片有点复杂,简单来讲就是每一次需要获取可控数据或者更新可控数据的时候,都需要检测一下当前组件的状态,并根据状态选择是从 props 中获取数据还是从 state 中获取数据已经更新的时候调用的是那个函数等等。图中有一些箭头的方向不是很正确,而且部分细节未画出,大家见谅。

如果只是添加这一个可控的属性 value ,这样写未尝不可,但是如果我们要同时考虑很多属性呢?比如说 Antd Select 组件就同时有 valueopen 两个可控属性,那么整个代码量是以线性方式增长的。这很明显是无法接受的。

于是这里我引入了 Independence 装饰器来做这件事情。架构如下:

我们可以这么理解,一个支持可控和不可控的组件本质上可以拆分成内部一个展示型的无状态受控的组件和外面的包装组件,通过包装(也就是高阶组件的方式)让内部受控组件支持不可控。

这样写其实有如下几个好处:

  1. 组件逻辑复杂度降低,只需要将组件的受控情况
  2. 可以将任意受控组件包装成不受控组件,尤其是对第三方组件的封装上
  3. 组件复杂度降低,代码冗余变少
  4. 非常方便的添加和删除受控属性,只需要修改装饰器即可

如何使用?

目前我简单实现了 Independence 装饰器,代码在网易猛犸开源的组件库 bdms-ui(建设中,组件不全、文档不全、时间不够,敬请期待)中,代码在此

他遵循这样的规范:假如属性名称为 value,那么默认值为 defaultValue,change 事件为 onValueChange。支持通过 onChangeName 修改 change 事件名称,通过 defaultName 修改默认值名称。

另外最简单的使用方式就是通过装饰器了,拿 Select 组件举例。

1
2
3
4
5
6
7
8
9
@Independence({
value: {
onChangeName: 'onChange'
},
open: {} // 使用默认值
})
export default class Select extends Component {
// blahblah,你就可以当受控组件来编写了
}

从此编写可控和不可控的数据从未如此简单。另外 Independence 还实现了 forward ref 的功能。

不过现在功能还比较薄弱,需要经过时间的检验,等完备之后可以封装成一个库。

总结

本文简单讲解了一下什么是可控和不可控,以及提出了一个 React 的解决方案。

这些只是基于我的经验的总结,欢迎大家积极交流。

注释和共享

阅读推荐:本人需要您有一定的 React 基础,并且想简单了解一下 Hook 的工作方式和注意点。但是并不详细介绍 React Hook,如果想有进一步的了解,可以查看官方文档。因为项目比较简单,所以我会比较详细的写出大部分代码。建议阅读文章之前请先阅读目录找到您关注的章节。

目录

  1. React Hook + Parcel
    1. 环境搭建
    2. useState 第一个接触的 Hook
    3. useEffect 监听开始和结束事件
    4. 其他 Hook
    5. 我们来用 Emotion 加点样式
    6. 收尾
    7. 总结复盘 —— 性能问题?
    8. 总结

React Hook + Parcel

几天前,我女票和我说他们新人培训需要一个《真心话大冒险》的界面,想让我帮她写一个。我说好呀,正好想到最近的 React Hook 还没有玩过,赶紧来试试,于是花了一个晚上的时间,其实是俩小时,一个小时搭建项目,一个小时写。

Demo: http://souche-truth-or-dare.surge.sh (因为女票是大搜车的)

环境搭建

首先我们创建一个文件夹,做好初始化操作。

1
2
3
mkdir truth-or-dare
cd truth-or-dare
npm init -y

安装好依赖,react@next react-dom@next parcel-bundler emotion@9 react-emotion@9 babel-plugin-emotion@9

React Hook 截止发稿前(2018-12-26)还处于测试阶段,需要使用 next 版本。

emotion 是一个比较完备的 css-in-js 的解决方案,对于我们这个项目来讲是非常方便合适的。另外因为 emotion@10 的最新版本对 parcel 还有一定的兼容性问题,见 issue。所以这里暂时使用 emotion@9 的旧版本。

1
2
npm i react@next react-dom@next emotion@9 react-emotion@9
npm i parcel-bundler babel-plugin-emotion@9 -D

创建 .babelrc 文件或者在 package.json 中写入 Babel 配置:

1
2
3
4
5
{
"plugin": [
["emotion", {"sourceMap": true}]
]
}

创建 src 文件夹,并创建 index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>真心话大冒险</title>
</head>
<body>
<div id="app"></div>
<script src="./index.jsx"></script>
</body>
</html>

index.jsx 文件

1
2
3
4
import * as React from 'react'
import { render } from 'react-dom'

render(<div>First Render</div>, document.getElementById('app'))

最后添加如下 scriptspackage.json

1
2
3
4
{
"start": "parcel serve src/index.html",
"build": "rm -rf ./dist && parcel build src/index.html"
}

最后我们就可以 npm start 就可以成功启动开发服务器了。在浏览器中打开 localhost:1234 即可。

parcel 已经内建了 Hot Reload,所以不需要进行额外的配置,开箱即用。是不是觉得非常简单,有了它,手动搭建项目不再困难。当然了,TS 也是开箱即用的,不过这次我这个项目真的很小,就不用 TS 了。

useState 第一个接触的 Hook

我们创建一个 App.jsx 开始我们真正的编码。先简单来看一下

1
2
3
4
5
6
7
8
9
10
11
export default function App() {
const [selected, setSelected] = useState('*')
const [started, setStarted] = useState(false)

return (
<div>
<div>{selected}</div>
<button>{started ? '结束' : '开始'}</button>
</div>
)
}

我们就完成了对 Hook 最简单的使用,当然了现在还没有任何交互效果,也许你并不明白这段代码有任何用处。

简单讲解一下 useState,这个函数接受一个参数,为初始值,可以是任意类型。它会返回一个 [any, (v: any) => void] 的元组。其中第一个 State 的值,另一个是一个 Setter,用于对 State 设置值。

这个 Setter 我们如何使用呢?只需要在需要的地方调用他就可以了。

1
<button onClick={() => setStarted(!started)}>{started ? '结束' : '开始'}</button>

保存,去页面点击一下这个按钮看看,是不是发现他会在 结束开始 之间切换?Setter 就是这么用,非常简单,如果用传统的 Class Component 来理解的话,就是调用了 this.setState({started: !this.state.started}) 。不过和 setState 不同的是,Hook 里面的所有数据比较都是 ===(严格等于)。

useState 还有很多用法,比如说 Setter 支持接收一个函数,用于传入之前的值以及返回更新之后的值。

useEffect 监听开始和结束事件

接下来,我们想要点击开始之后,屏幕上一直滚动,直到我点击结束。

如果这个需求使用 Class Component 来实现的话,是这样的:

  1. 监听按钮点击事件
  2. 判断是开始还是结束
    • 如果是开始,那么就创建一个定时器,定时从数据当中随机获取一条真心话或大冒险并更新 selected
    • 如果是结束,那么就删除之前设置的定时器

非常直接,简单粗暴。

用了 Hook 之后,当然也可以这样做了,不过你还需要额外引入一个 State 来存储 timer,因为函数组件无法持有变量。但是如果我们换一种思路:

  1. 监听 started 变化
    • 如果是开始,那么创建一个定时器,做更新操作
    • 如果是结束,那么删除定时器

好像突然变简单了,让我们想象这个用 Class Component 怎么实现呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default class App extends React.Component {
componentDidUpdate(_, preState) {
if (this.state.started !== preState.started) {
if (this.state.started) {
this.timer = setInterval(/* blahblah*/)
} else {
clearInterval(this.timer)
}
}
}

render() {
// blahblah
}
}

好麻烦,而且逻辑比较绕,而且如果 componentDidUpdate 与 render 之间有非常多的代码的时候,就更难对代码进行分析和阅读了,如果你后面维护这样的代码,你会哭的。可是用 useEffect Hook 就不一样了。画风如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export default function App() {
// 之前的代码

// 当 started 变化的时候,调用传进去的回调
useEffect(() => {
if (started) {
const timer = setInterval(() => {
setSelected(chooseOne())
}, 60)

return () => clearInterval(timer)
}
}, [started])

return (
// 返回的 View
)
}

当用了 React Hook 之后,所有的逻辑都在一起了,代码清晰且便于阅读。

useEffect 从字面意义上来讲,就是可能会产生影响的一部分代码,有些地方也说做成副作用,其实都是没有问题的。但是副作用会个人一种感觉就是这段代码是主动执行的而不是被动执行的,不太好理解。我觉得更好的解释就是受到环境(State)变化影响而执行的代码。

为什么这么理解呢?你可以看到 useEffect 还有第二个参数,是一个数组,React 会检查这个数组这次渲染调用和上次渲染调用(因为一个组件内可能会有多次 useEffect 调用,所以这里加入了渲染限定词)里面的每一项和之前的是否变化,如果有一项发生了变化,那么就调用回调。

当理解了这个流程之后,或许你就能理解为什么我这么说。

当然了,第二个参数是可以省略的,省略之后就相当于默认监听了全部的 State。(现在你可以这么理解,但是当你进一步深入之后,你会发现不仅仅有 State,还有 Context 以及一些其他可能触发状态变化的 Hook,本文不再深入探究)

到现在,我们再来回顾一下关于定时器的流程,先看一下代码:

1
2
3
4
5
6
7
if (started) {
const timer = setInterval(() => {
setSelected(chooseOne())
}, 60)

return () => clearInterval(timer)
}

理想的流程是这样的:

  • 如果开始,那么注册定时器。——Done!
  • 如果是结束,那么取消定时器。——Where?

咦,else 的分支去哪里了?为啥在第一个分支返回了取消定时器的函数?

这就牵扯到 useEffect 的第二个特性了,他不仅仅支持做正向处理,也支持做反向清除工作。你可以返回一个函数作为清理函数,当 effect 被调用的时候,他会先调用上次 effect 返回的清除函数(可以理解成析构),然后再调用这次的 effect 函数。

于是我们轻松利用这个特性,可以在只有一条分支的情况下实现原先需要两条分支的功能。

其他 Hook

在 Hook 中,上面两个是使用非常频繁的,当然还有其他的比如说 useContext/useReducer/useCallback/useMemo/useRef/useImperativeMethods/useLayoutEffect

你可以创建自己的 Hook,在这里 React 遵循了一个约定,就是所有的 Hook 都要以 use 开头。为了 ESLint 可以更好对代码进行 lint。

这些都属于高级使用,感兴趣的可以去研究一下,本片文章只是入门,不再过多讲解。

我们来用 Emotion 加点样式

css-in-js 大法好,来一顿 Duang, Duang, Duang 的特技就好了,代码略过。

收尾

重新修改 src/index.jsx 文件,将 <div/> 修改为 <App/> 即可。

最后的 src/App.jsx 文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import React, { useState, useEffect } from 'react'
import styled from 'react-emotion'

const lists = [
'说出自己的5个缺点',
'绕场两周',
'拍一张自拍放实习生群里',
'成功3个你说我猜',
'记住10个在场小伙伴的名字',
'大声说出自己的名字“我是xxx”3遍',
'拍两张自拍放实习生群里',
'选择另一位小伙伴继续游戏',
'直接通过',
'介绍左右两个小伙伴',
]

function chooseOne(selected) {
let n = ''
do {
n = lists[Math.floor(Math.random() * lists.length)]
} while( n === selected)
return n
}

const Root = styled.div`
background: #FF4C19;
height: 100vh;
width: 100vw;
text-align: center;
`

const Title = styled.div`
height: 50%;
font-size: 18vh;
text-align: center;
color: white;
padding: 0 10vw;
font-family:"Microsoft YaHei",Arial,Helvetica,sans-serif,"宋体";
`

const Button = styled.button`
outline: none;
border: 2px solid white;
border-radius: 100px;
min-width: 120px;
width: 30%;
text-align: center;
font-size: 12vh;
line-height: 20vh;
margin-top: 15vh;
color: #FF4C19;
cursor: pointer;
`

export default function App() {
const [selected, setSelected] = useState('-')
const [started, setStarted] = useState(false)

function onClick() {
setStarted(!started)
}

useEffect(() => {
if (started) {
const timer = setInterval(() => {
setSelected(chooseOne(selected))
}, 60)

return () => clearInterval(timer)
}
}, [started])

return (
<Root>
<Title>{selected}</Title>
<Button onClick={onClick}>{started ? '结束' : '开始'}</Button>
</Root>
)
}

总结复盘 —— 性能问题?

最近刚刚转正答辩,突然发现复盘这个词还挺好用的,哈哈哈。

虽然这么短时间的使用,还是有一些自己的思考,说出来供大家参考一下。

如果你仔细思考一下会发现,当使用 useEffect 的时候,其实每次都是创建了一个新的函数,但并不是说每次都会调用这个函数。如果你代码里面 useEffect 使用的很多,而且代码还比较长,每次渲染都会带来比较大的性能问题。

所以解决这个问题有两个思路:

  1. 不要在 Hook 中做太多的逻辑,比如说可以让 Hook 编写一些简单的展示组件,比如 Tag/Button/Loading 等,逻辑不复杂,代码量小,通过 Hook 写在一起可以降低整个组件的复杂度。

  2. 将 Effect 拆分出去,并通过参数传入。类似于这个样子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function someEffect(var1, var2) {
    // doSomething
    }

    export function App() {
    // useState...
    useEffect(() => someEffect(var1, var2), [someVar])
    // return ....
    }

    虽然这也是创建了一个函数,但是这个函数创建的速度和创建一个几十行几百行的逻辑的函数相比,确实快了不少。其次不建议使用 .bind 方法,他的执行效率并没有这种函数字面量快。

    这种方式不建议手动来做,可以交给 babel 插件做这部分的优化工作。

其实作为一个开发者来说,不应该太多的关注这部分,但是性能就是程序员的 XX 点,我还是会下意识从性能的角度来思考。这里只是提出了一点小小的优化方向,希望以后 React 官方也可以进一步做这部分的优化工作。

已经有的优化方案,可以查看官方 FAQ

总结

经过这个简短的使用,感觉用了 Hook 你可以将更多的精力放在逻辑的编写上,而不是数据流的流动上。对于一些轻组件来说简直是再合适不过了,希望早点能够正式发布正式使用上吧。

另外 parcel 提供了强大的内置功能,让我们有着堪比 webpack 的灵活度却有着比 webpack 高效的开发速度。

好的,一篇 1 小时写代码,1 天写文章的水文写完了。以后如果有机会再深入尝试。

注释和共享

  • 第 1 页 共 1 页

XGHeaven

一个弱弱的码农


杭州电子科技大学学生一枚


Weifang Shandong, China