在网上能看到很多人在谈论 Vue 的组合式 API(Composition API)与 React Hooks 时,将两者一起拿来比较,并认为随着这两者的推出,React 和 Vue 变得越来越像,甚至还引发过一些不大不小的撕逼。
诚然,Vue 的组合式 API 在设计之初受到过 React Hooks 的启发,且这两者都可以把逻辑抽象成函数的组合,来实现逻辑的内聚与复用,在视觉上有相似之处。但或许这就是它们所剩无几的相同点了。
本质上组合式 API 与 React Hooks 的心智模型还是大不相同的,如果将它们混为一谈,不但容易引发对两者的误解,更可能(基于这份误解)造成写出来的代码中含有不易察觉的 bug。
组合式 API 的动机与实现
组合式 API 的效果用下面这张图片就可以清楚地表示出来:
在传统的选项式 API(Options API)中,组件为我们提供了一组 Object 形式的配置对象。我们可以将响应式变量定义在 data
字段,监听逻辑定义在 watch
字段,各个生命周期想做的事情定义在诸如 mounted
等生命周期字段中。
但真实世界中的一个组件往往由复杂的逻辑交织而成,在组合式 API 中,我们只能把同一逻辑关注点的代码分散到各个字段中。例如我们需要一个监听器,但开始监听和取消监听就得分别放在 mounted
和 beforeDestroy
中。随着组件体积的膨胀,这些代码逻辑所在的位置愈发割裂,使得开发者在阅读理解代码时也愈发困难。
为了解决这个问题,组合式 API 就应运而生了。在组合式 API 中,我们不再要求业务代码把自己的逻辑分门别类地放置在配置对象中,而是利用依赖倒置的思想,将配置对象的各个字段变为钩子函数(例如 mounted: someFunc
→ onMounted(someFunc)
),提供给业务代码,使业务代码能够将自己钩入 组件的生命周期等逻辑中。这样,相同逻辑关注点的代码就可以被放在一起,从而提高了代码的内聚性。
一段典型的组合式 API 代码如下所示(来自官网的例子):
setup(props) {
const repositories = ref([])
const getUserRepositories = async () => {
repositories.value = ... // fetch repositories
}
onMounted(getUserRepositories)
// 在 user prop 的响应式引用上设置一个侦听器
watch(user, getUserRepositories)
return {
repositories,
getUserRepositories
}
}
与它等价的选项式 API 则是:
export default {
data() {
return {
repositories: [],
}
},
watch: {
user: 'getUserRepositories'
},
methods: {
getUserRepositories() {
this.repositories = ... // fetch repositories
},
},
mounted() {
this.getUserRepositories()
}
}
虽然这个简单的例子里两种写法看上去差不多,但如果这个组件的功能再复杂些,选项式 API 写出来的代码很快就会变成一团乱麻。而组合式 API 中,我们的这段代码逻辑可以天然地放到一个函数当中,隐藏掉内部实现的细节,使组件整体的代码保持清爽。
不仅如此,以函数来表示逻辑,还可以实现逻辑的细粒度复用。这也是组合式 API 带来的另一个好处。在选项式 API 中,虽然可以通过 mixin 的方式实现一定的复用,但存在着变量名冲突、难以处理逻辑之间的依赖关系等种种问题。而通过函数的方式进行逻辑复用,使这些问题迎刃而解,并且没有任何 magic,可以说是十分自然优雅。
Android Lifecycle API
与其说 Vue 的组合式 API 像 React Hooks,我觉得不如说它更像 Android 的 Lifecycle API 。我挺惊讶居然没有人把这两者作对比。说好的大前端呢
虽说 Android 原生并没有 Vue 核心的响应式数据概念(当然,已经有官方支持的 LiveData 与 DataBinding 这些了更不用说 Jetpack Compose,但这里不展开,不然就没完没了了),但 Android Lifecycle API 与 Vue 组合式 API 所要解决的问题 —— 即逻辑关注点分散、混乱的问题 —— 与解决手段,却是有着异曲同工之妙。
在传统的 Android 应用开发中,不少逻辑都依赖于生命周期实现。Android 中拥有生命周期的单元是 Activity 和 Fragment ,其他的业务逻辑若是需要感知生命周期,就需要把它们的代码放到 Activity 和 Fragment 的各个生命周期中,这就导致了代码膨胀与逻辑关注点分离的问题。
例如我们需要一个感知当前地理位置的功能,一般以如下形式实现:
class MyActivity extends AppCompatActivity {
private MyLocationListener myLocationListener;
@Override
public void onCreate(...) {
// 可以类比为 Vue 的 created
myLocationListener = new MyLocationListener(this, (location) -> { ... });
}
@Override
public void onStart() {
// 可以类比为 Vue 的 mounted
super.onStart();
myLocationListener.start();
}
@Override
public void onStop() {
// 可以类比为 Vue 的 beforeDestroy
super.onStop();
myLocationListener.stop();
}
}
很明显,这样的做法与 Vue 的选项式 API 存在同样的逻辑割裂、组件膨胀等问题。Android 官方也逐渐意识到了这个问题。因此,在它们提供的新的架构组件中,推出了 Lifecycle API。它提供的解决方案我们也不难想到。我们把 Lifecycle 作为一个单独的概念抽象出来,使之不局限于 Activity 和 Fragment 中。而需要感知生命周期的业务逻辑,可以以回调函数的形式把这些逻辑勾入 相应的生命周期中。
这样一来,和这个业务单元的所有相关代码都可以被放在一起,提高了内聚性,且保持了 Activity/Fragment 文件本身逻辑的清晰。另一方面,我们在使用这业务逻辑时也无需关心它究竟在 Activity 还是 Fragment 中,只要存在生命周期就好。这也提升了逻辑的可复用性。
使用 Lifecycle API 实现的例子如下所示:
public class MyObserver implements LifecycleObserver {
// 可以类比为 Vue 的 onMounted()
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
public void connectListener() {
...
}
// 可以类比为 Vue 的 onBeforeUnmount()
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
public void disconnectListener() {
...
}
}
// 类比为在 setup 函数中调用你自己的逻辑
myLifecycleOwner.getLifecycle().addObserver(new MyObserver());
对比 Android 与 Vue,可以看到它们所面临的问题,以及选择的解决方式,都是极为相似的。
那么 React Hooks 呢?
在 React Hooks 出现之前,我们编写有状态组件,只能使用类组件(Class Component)的形式,并在类组件的生命周期方法中编写相关的逻辑。明显,这和 Vue 与 Android 中我们面临的问题是一样的。我们可以轻易地将上文展现的解决方式迁移到 React 中,为它设计一套新的 API:
class MyComponent extends React.Component {
// 相当于 Vue 的 setup()
contructor(props) {
super(props)
// 把业务逻辑注册到生命周期中,以实现逻辑的内聚和可复用
didMount(() => { ... })
didUpdate(() => { ... })
}
render() {
return ...
}
}
很容易理解,不是么?可是 React 团队却不满意于此,而是交出了一份不一样的答卷。他们完全抛弃了类组件的写法,连带着抛弃了生命周期的概念。
我们学习一项新技术时,会想怎么把旧的思维方式迁移过去。这不就有人会问:class 的生命周期方法是怎么对应到新的 hooks 上面的呢?于是搜索了一下发现 useEffect
可以通过各种写法的组合来模拟原来的 componentDidMount
、componentDidUpdate
与 componentWillUnmount
,然后一边抱怨怎么起了个这么一个和原来的生命周期完全不像的名字,一边死记硬背各种组合的用法。
那么为什么 React 团队放着前面简单的方式不做,而是采用这种看似如此绕的方式呢?其实 React Hooks 为我们带来的是一种新的思维方式,只是我们还拿旧的思路去理解它,所以觉得它比较绕。
为了说明这个问题,我们还得从类组件说起。众所周知,React 采用了函数式编程的理念,通过不可变性来感知状态变更,从而触发 UI 更新。无状态的函数组件是纯函数,自然可以无限次数地调用。可对于有状态的类组件,每次渲染仅仅是调用它的 render
函数,这个组件类的实例在多次渲染之间仍然是同一个,因此在它身上定义的实例变量事实上成为了可变数据。这就使得类组件的工作方式在 React 理念中显得格格不入,甚至带来 bug 的风险。
可变数据的典型便是 this.state
。虽然理论上我们必须通过 setState
函数来修改 this.state
,但这只是为了让 React 能够感知到变化并触发重新渲染。你也完全可以直接对 this.state
赋值,然后手动触发渲染,或是让别处触发的重新渲染使你之前的修改体现在 UI 上。
这样的行为符合我们长久以来(习惯于面向对象与可变数据)的认知,但有时会给我们带来麻烦,尤其是在有异步行为的场景中。React 核心开发者 Dan Abramov 在他的文章中举了一个十分有代表性的例子。例如在社交网站中,我们可以点击关注一个人的社交帐号,向服务器请求回包后弹窗告知用户关注成功。
class ProfilePage extends React.Component {
showMessage = () => {
alert('Followed ' + this.props.user);
};
handleClick = () => {
setTimeout(this.showMessage, 3000);
};
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
这段代码存在着 bug,而 bug 的根源就在 this.props
的可变性上。假如我们在 A 的主页点击了关注,但在 3 秒中内切换到 B 的主页,由于 this.props.user
变成了 B,弹窗的内容就成了“Followed B”。但如果我们使用函数组件,就可以天然规避这个问题(注意这个例子并不涉及 Hooks):
function ProfilePage(props) {
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
没有 bug 是因为,作为函数式组件,ProfilePage
本质只是一个普通的函数。当 user
改变时,React 传入新的 props
重新执行了这个函数。但在上次函数执行过程中声明的 showMessage
函数,它绑定的 props
的值仍然是上次执行时的值,而不是像类组件那样,改变了 this.props
的内容。以这个函数的视角来看,props
只是一个普通的对象值罢了,而不是某个实例变量的引用,因此在当次函数执行过程中,所有地方读到的 props
永远都是同一个值,即当次渲染传入的值。
理解了函数组件如何规避可变状态带来的问题,就可以理解为何要用 Hooks 取代类组件。通过 Hooks,我们可以让 state
也和 props
一样,获得在组件单次渲染中的不可变性。这也更加贯彻了 React 的函数式、不可变的理念。我们可以把上面的例子用 useState
来改写,一样是符合我们预期,没有 bug 的。
function ProfilePage() {
const [user, setUser] = useState('A')
const showMessage = () => {
alert('Followed ' + user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
没有 bug 的原因是,user
变量在单次渲染时实际上就是一个普通的字符串(可以想象把所有的 user
都替换成 'A'
)。而倘若通过 setUser
改变了 user
值,也只是触发下一次对函数的调用,对当次渲染毫无影响。
我们可以以 Vue 的视角审视下这个例子。如果你认为 Vue 的组合式 API 与 React Hooks 相当的话,我们可以用组合式 API 来实现这个例子:
<template>
<select v-model="user">
<option>A</option>
<option>B</option>
</select>
<button @click="handleClick">Follow</button>
</template>
<script setup>
import { ref } from 'vue'
const user = ref('A')
const showMessage = () => alert(`Followed ${user.value}`)
const handleClick = () => setTimeout(showMessage, 3000)
</script>
不难看出这段代码和 React 的类组件实现一样,是存在 bug 的版本(当然,用选项式 API 也一样)。但是对于 Vue 来说这样的结果是符合预期的。因为不像 React 遵循的不可变性理念,Vue 采用的是响应式数据的理念,数据是可变的,这必然要求我们持有响应式数据的引用。
事实上想要使用 React Hooks 完美还原这段 Vue 代码的能力,我们也要采取类似的做法,通过 useRef
Hook 为 user
变量创建一个可以横跨多次渲染的引用。而想要在 Vue 中规避这个 bug,并没有完全对应 React 函数组件的做法,只能另想办法规避了。
useEffect 与 watch
React 的 useEffect
Hook 与 Vue 组合式 API 的 watch
方法是一组经常被拿出来比较的概念。它们做的事情类似,都是在各自的依赖发生改变时,执行对应的逻辑。但光看它们的名字却是大不相同。倘若我们了解了 React Hooks 与 Vue 组合式 API 各自的理念和动机,就不难理解它们为什么不同。
Vue 的 watch
API 很容易理解,它对应于原来选项式 API 中的 watch
选项。顾名思义,就是监听数据的变更。由于 Vue 基于响应式数据的理念,能够跟踪所有响应式变量的变更,因此能做到这些并不费吹灰之力。
而对于 React 的 useEffect
Hook,上文已经提到,我们不要再用生命周期的思想去类比它,而是从函数式的角度去理解。
在理想情况下,所有渲染函数都是纯函数,这样我们可以放心地无限次调用它们。但是现实显然没有这么美好,除了渲染之外往往还要做些别的事情。React 统一把这些事情认为是副作用(Side Effect),这也就是函数名 useEffect
的来历。
说句题外话,从源码的角度,React 核心只是维护内部的组件树。至于把树的最新状态同步到 DOM 上这一工作(即 react-dom 的职责),也被认为是一种副作用。
既然是渲染的副作用,那它自然是在每次渲染之后都会调用一次。而它的依赖数组,也只是用来规避重复调用副作用时可能带来的性能、死循环等问题。在 React 看来,依赖数组只是一个普通的数组,并不非得是真正的依赖。这也是为什么 React 不会为我们自动收集依赖。如果你愿意,你完全可以通过自己编排依赖数组来达到目的。只是大多数时候人脑应付不过来过于复杂的逻辑,因此官方推荐我们诚实地把依赖数组的内容与副作用函数的真正依赖保持一致。
所以 useEffect
和 watch
这两者在设计意图上就完全不同,只是最终殊途同归而已。网上很多文章在对比组合式 API 与 React Hooks 时只会轻描淡写地提到一句“前者只执行一次,后者会执行多次”,却没有意识到这一区别的背后隐藏着它们设计理念上的巨大差别。
结语
在对比了 Vue 组合式 API 与 React Hooks 之后,我们发现它们并不是像看上去那样变得逐渐相似,恰恰相反,它们进一步把自己的特点推向了极致。Vue 通过组合式 API 进一步暴露了它的响应式数据能力,使之不再局限于 Vue 实例以内,更便于逻辑组合与复用。而 React 通过 Hooks 弥补了 class 组件中可变数据的隐含问题,进一步贯彻了函数式编程、不可变数据的设计理念。React 和 Vue 实际上变得越来越不同了。
而对于我们使用者来说,在使用一项技术前需要让自己充分理解这一技术的设计理念,顺着它的思路来写代码。当然,这需要花点时间去学习和适应新的概念,尤其是对于 React Hooks。顺着正确的心智模型,才能事半功倍,而不是想当然地把旧的经验套用在新技术上,等出了问题再高呼真坑。