大家好,我卡颂。
最近看到个写得很不错的知乎回答Hooks是否过誉了?前端应该跟着React走还是跟着JS、TS走?- beeplin的回答。
在这个回答的基础上,我想引申出一个问题 —— 对于前端状态相关问题,如何思考比较全面?
今天,我们试着从多个抽象层级的角度回答这个问题。
欢迎加入人类高质量前端框架群,带飞
问题的起源
有相当比例的前端从业者入行是从学习前端框架的使用开始的。换言之,在他们的知识体系中,最底层是前端框架如何使用,其他业务知识都是构建于此之上。
要以此为基础回答前端状态相关问题,并不容易。就比如你问组长:
- 为什么项目中用
Redux
而不用Mobx
? - 为什么要用
Hooks
而不用ClassComponent
?
很多时候得到的是一个既定的事实(就是这样,没有为什么),而不是分析后的结果。
要分析这类问题,我们需要知道一些更低抽象层级的知识。
几乎所有主流前端框架的实现原理,都在践行UI = f(state)
这个公式,通俗的说 —— UI是对状态的映射。
这应该是前端状态会出现的最低抽象层级了,所以我们从这个层级出发。
前端框架的实现原理
限于篇幅有限,这里我们以最常见的React
与Vue
举例。
在实现UI是对状态的映射过程中,两者的方向不同。
React
并不关心状态如何变化。每当调用更新状态的方法(比如this.setState
,或者useState dispatch
...),就会对整个应用进行diff
。
所以在React
中,传递给更新状态的方法的,是状态的快照,换言之,是个不可变的数据。
Vue
关心状态如何变化。每当更新状态时,都会对与状态关联的组件进行diff
。
所以在Vue
中,是直接改变状态的值。换言之,状态是个可变的数据。
这种底层实现的区别在单独使用框架时不会有很大区别,但是会影响上层库的实现(比如状态管理库)。
现在我们知道,通过前端框架,我们可以将状态映射到UI
。那么如何管理好对应的映射关系呢?
换言之,如何将状态与和他相关的UI约束在一起?
我们再往更高一级抽象看。
如何封装组件
前端开发普遍采用组件作为状态与UI的松散耦合单元。
到这里我们可以发现,如果仅仅会使用前端框架,那么只能将组件看作是前端框架中既定的设计。
但如果从更低一层抽象(前端框架的实现原理)出发,就能发现 —— 组件是为了解决框架实现原理中UI到状态的映射的途径。
那么组件该如何实现,他的载体是什么呢?从软件工程的角度出发,有两个方向可以探索:
- 面向对象编程
- 函数式编程
面向对象编程的特点包括:
- 继承
- 封装
- 多态
其中封装这一特点使得面向对象编程很自然成为组件的首选实现方式,毕竟组件的本质就是将状态与UI封装在一起的松散耦合单元。
React
的ClassComponent
,Vue
的Options API
都是类似实现。
但毕竟组件的本质是状态与UI的松散耦合单元,在考虑复用性时,不仅要考虑逻辑的复用(逻辑是指操作状态的业务代码),还要考虑UI的复用。所以面向对象编程的另两个特性并不适用于组件。
框架们根据自身特点,在类面向对象编程的组件实现上,拓展了复用性:
React
通过HOC
、renderProps
Vue2
通过mixin
经过长期实践,框架们逐渐发现 —— 类面向对象编程的组件实现中封装带来的好处不足以抵消复用性上的劣势。
于是React
引入了Hooks
,以函数作为组件封装的载体,借用函数式编程的理念提高复用性。类似的还有Vue3
中的Composition API
。
不管是ClassComponent
还是FunctionComponent
、Options API
还是Composition API
,他们的本质都是状态与UI的松散耦合单元。
当组件数量增多,逻辑变复杂时,一种常见的解耦方式是 —— 将可复用的逻辑从组件中抽离出来,放到单独的Model
层。UI
直接调用Model
层的方法。
对Model
层的管理,也就是所谓的状态管理。
对状态的管理,是比组件中状态与UI的耦合更高一级的抽象。
状态管理问题
状态管理要考虑的最基本的问题是 —— 如何与框架实现原理尽可能契合?
比如,我们要设计一个User Model
,如果用class
的形式书写:
class User {
name: String;
constructor(name: string) {
this.name = name;
}
changeName(name: string) {
return this.name = name;
}
}
只需要将这个Model
的实例包装为响应式对象,就能很方便的接入Vue3
:
import { reactive } from 'vue'
setup() {
const user = reactive(new User('KaSong') as User;
return () => (
)
}
之所以这么方便,诚如本文开篇提到的 —— Vue
的实现原理中,状态是可变的数据,这与User Model
的用法是契合的。
同样的User Model
要接入React
则比较困难,因为React
原生支持的是不可变数据类型的状态。
要接入React
,我们可以将同样的User Model
设计为不可变数据,采用reducer
的形式书写:
const userModel = {
name: 'KaSong'
};
const userReducer = (state, action) => {
switch (action.type) {
case "changeName":
const name = action.payload;
return {...state, name}
}
};
function App() {
const [user, dispatch] = useReducer(userReducer, userModel);
const changeName = (name) => {
dispatch({type: "changeName", payload: name});
};
return (
);
}
如果一定要接入可变类型状态,可以为React
提供类似Vue
的响应式更新能力后再接入。比如借用Mobx
提供的响应式能力:
import { makeAutoObservable } from "mobx"
function createUser(name) {
return makeAutoObservable(new User(name));
}
到目前为止,不管是可变类型状态还是不可变类型状态的Model
,都带来了从组件中抽离逻辑的能力,对于上例来说:
- 可变类型状态将状态与逻辑抽离到
User
中 - 不可变类型状态将状态与逻辑抽离到
userModel
与userReducer
- 最终暴露给
UI
的都仅仅是changeName
方法
当业务进一步复杂,Model
本身需要更完善的架构,此时又是更高一级的抽象。
到这一层时已经脱离前端框架的范畴,上升到纯状态的管理,比如为mobx
带来结构化数据的mobx-state-tree
。
此时框架实现原理对Model
的影响已经在更高的抽象中被抹去了,比如Redux-toolkit
是React
技术栈的解决方案,Vuex
是Vue
技术栈的解决方案,但他们在使用方式上是类似的。
这是因为Redux
与Vuex
的理念都借鉴自Flux
,即使React
与Vue
在实现原理上有区别,但这些区别都被状态管理方案抹平了。
更高的抽象
在此之上,对于状态还有没有更高的抽象呢?答案是肯定的。
对于常规的状态管理方案,根据用途不同,可以划分出更多细分领域,比如:
- 对于表单状态,收敛到表单状态管理库中
- 对于服务端缓存,收敛到服务端状态管理库中(
React Query
、SWR
) - 用完整的框架收敛前后端
Model
,比如Remix
、Next.js
总结
回到我们开篇提到的问题:
- 为什么项目中用
Redux
而不用Mobx
? - 为什么要用
Hooks
而不用ClassComponent
?
现在我们已经能清晰的知道这两个问题的相同点与不同点:
- 相同点:都与状态相关
- 不同点:属于不同抽象层级的状态相关问题
要回答这些问题需要哪些知识呢?只需要知道问题涉及的状态的抽象层级,以及比该层级更低的抽象层级对应的知识即可。
比如回答:为什么项目中用Redux
而不用Mobx
?
考虑当前抽象层级
Redux
与Mobx
都属于Model
的实现,前者带来一套类Flux的状态管理理念,后者为React
带来响应式更新能力,在设计Model
时我的项目更适合哪种类型?
或者两种类型我都不在乎,那么要不要使用更高抽象的解决方案(比如MST
、Redux Toolkit
)抹平这些差异?
考虑低一级抽象层级
项目用的ClassComponent
还是FunctionComponent
?Redux
、Mobx
与他们结合使用时哪个组合更能协调好UI
与逻辑的松散耦合?
考虑再低一级抽象层级
React
的实现原理决定了他原生与不可变类型状态更亲和。Redux
更契合不可变数据,Mobx
更契合可变数据。我的项目需要考虑这些差异么?
当了解不同抽象层级需要考虑的问题后,任何宽泛的、状态相关问题都能转化成具体的、多抽象层级问题。
从不同抽象层级出发思考,就能更全面的回答问题。