一、场景
先理解什么是 hook,react 对它的定义是:
它可以让你在不编写 class 的情况下,让你在函数组件里“钩入” React state 及生命周期等特性的函数
Vue 提出的新的书写 Vue 组件的 API:Composition API RFC,即组合式 API,作用也是类似。组合式 API 受到 React Hooks 的启发,但有一些有趣的差异,规避了一些 react 的问题。
二、Hook 的时代意义
框架是服务于业务的,业务开发中的一个核心问题就是——逻辑的组合与复用。同样的功能、同样的组件,在不同场景下,我们有时不得不去写 2+ 次。为了避免耦合,各大框架纷纷想出了一些办法,比如mixin
、render props
、高阶组件等逻辑上复用模式,但是或多或少都有一些额外的问题。
-
Mixin 与组件之间存在隐式依赖,可能会产生冲突。且 mixin 倾向于增加更多状态,降低了应用的可预测性:
- 模版中的数据来源不清晰。当一个组件中使用了多个 mixin 的时候,光看模版会很难分清一个属性到底是来自哪一个 mixin。
- 命名空间冲突。由不同开发者开发的 mixin 无法保证不会正好用到一样的属性或是方法名。HOC 在注入的 props 中也存在类似问题。
- 高阶组件(HOC) 需要额外的组件实例嵌套来封装逻辑,导致无谓的性能开销。同时增加了复杂度和理解成本,对于外层是黑盒。
- Render Props 使用繁琐、不好维护、代码体积过大,同样容易嵌套过深。
...
hook 的出现是划时代的,通过 function 抽离的方式,实现了复杂逻辑的内部封装:
- 逻辑代码的复用
- 减小了代码体积
- 没有
this
的烦恼
三、React Hooks
React Hooks 允许你“勾入”诸如组件状态和副作用处理等 React 功能中。Hooks 只能在函数组件中使用,并在不需要创建类的情况下将状态、副作用处理和更多东西带入组件中。
import React, { useState, useEffect } from "react";
const NoteForm = ({ onNoteSent }) => {
const [currentNote, setCurrentNote] = useState("");
useEffect(() => {
console.log(`Current note: ${currentNote}`);
});
return (
<form
onSubmit={e => {
onNoteSent(currentNote);
setCurrentNote("");
e.preventDefault();
}}
>
<label>
<span>Note: </span>
<input
value={currentNote}
onChange={e => {
const val = e.target.value && e.target.value.toUpperCase()[0];
const validNotes = ["A", "B", "C", "D", "E", "F", "G"];
setCurrentNote(validNotes.includes(val) ? val : "");
}}
/>
</label>
<button type="submit">Send</button>
</form>
);
};
useState 和 useEffect 是 React Hooks 中的一些例子,使得函数组件中也能增加状态和运行副作用
还有更多其他 hooks, 甚至能自定义 hook,hooks 打开了代码复用性和扩展性的新大门。
四、Vue Composition API
Vue Composition API 围绕一个新的组件选项 setup 而创建。setup()
为 Vue 组件提供了状态、计算值、watcher 和生命周期钩子。
<template>
<form @submit="handleSubmit">
<label>
<span>Note:</span>
<input v-model="currentNote" @input="handleNoteInput">
</label>
<button type="submit">Send</button>
</form>
</template>
<script>
import { ref } from "vue";
export default {
props: ["divRef"],
setup(props, context) {
const currentNote = ref("");
const handleNoteInput = e => {
const val = e.target.value && e.target.value.toUpperCase()[0];
const validNotes = ["A", "B", "C", "D", "E", "F", "G"];
currentNote.value = validNotes.includes(val) ? val : "";
};
const handleSubmit = e => {
context.emit("note-sent", currentNote.value);
currentNote.value = "";
e.preventDefault();
};
return {
currentNote,
handleNoteInput,
handleSubmit,
};
}
};
</script>
从上面的例子中可以看到:
- 暴露给模版的属性来源清晰 (从函数返回);
- 返回值可以被任意重命名,所以不存在命名空间冲突;
- 没有创建额外的组件实例所带来的性能损耗。
五、React Hooks vs Vue Composition API
5.1 原理
React hook 底层是基于链表实现,调用的条件是每次组件被 render 的时候都会顺序执行所有的 hooks,所以下面的代码会报错:
function App(){
const [name, setName] = useState('demo');
if(condition){
const [val, setVal] = useState('');
}
}
因为底层是链表,每一个 hook 的 next 是指向下一个 hook 的,if 会导致顺序不正确,所以 react 是不允许这样使用 hook 的。
vue hook 只会被注册调用一次,它对数据的响应是基于proxy
的,对数据直接代理观察。这种场景下,只要任何一个更改 data 的地方,相关的 function 或者 template 都会被重新计算,因此避开了 react 可能遇到的性能上的问题。
react 数据变动的时候,会导致重新 render,重新 render 又会把 hooks 重新注册一次,所以 react 的上手难度更高一些。
当然 react 对这些都有自己的解决方案,比如 useCallback, useMemo 等。
5.2 代码的执行
Vue 中,“钩子”就是一个生命周期方法
Vue Composition API 的setup()
早于beforeCreate
钩子被调用。
React hooks 在组件每次渲染时都会运行,而 Vue setup() 只在组件创建时运行一次。
React 靠 Hook 的调用顺序来获悉 state 和 useState 的对应关系。 只要调用顺序在多次渲染之间保持一致,React 就能正确地将内部 state 和对应的 Hook 进行关联。因此时候 Hook 时必须遵守一些规则:只在最顶层使用 Hook,不要在循环内部、条件语句中或嵌套函数中调用 Hooks。
// React 文档中的示例代码
import React, { useState, useEffect } from 'react';
function Form() {
// 1. 使用 name 状态变量
const [name, setName] = useState('Mary');
// 2. 使用一个持久化表单的副作用
// 🔴 在条件语句中使用 Hook 违反第一条规则
if (name !== '') {
useEffect(function persistForm() {
localStorage.setItem('formData', name);
});
}
// 3. 使用 surname 状态变量
const [surname, setSurname] = useState('Poppins');
// 4. 使用一个更新 title 的副作用
useEffect(function updateTitle() {
document.title = `${name} ${surname}`;
});
}
在第一次渲染中name !== ''
这个条件值为 true,所以我们会执行这个 Hook。但是下一次渲染时我们可能清空了表单,表达式值变为 false。此时的渲染会跳过该 Hook,Hook 的调用顺序发生了改变:
// ------------
// 首次渲染
// ------------
useState('Mary') // 1. 使用 'Mary' 初始化变量名为 name 的 state
useEffect(persistForm) // 2. 添加 effect 以保存 form 操作
useState('Poppins') // 3. 使用 'Poppins' 初始化变量名为 surname 的 state
useEffect(updateTitle) // 4. 添加 effect 以更新标题
// -------------
// 二次渲染
// -------------
useState('Mary') // 1. 读取变量名为 name 的 state(参数被忽略)
// useEffect(persistForm) // 🔴 此 Hook 被忽略!
useState('Poppins') // 🔴 2 (之前为 3)。读取变量名为 surname 的 state 失败
useEffect(updateTitle) // 🔴 3 (之前为 4)。替换更新标题的 effect 失败
React 不知道第二个 useState 的 Hook 应该返回什么。React 会以为在该组件中第二个 Hook 的调用像上次的渲染一样,对应得是 persistForm 的 effect,但并非如此。从这里开始,后面的 Hook 调用都被提前执行,导致 bug 的产生。
要实现在 name 为空时也运行对应的副作用, 可以简单的将条件判断语句移入 useEffect 回调内部:
useEffect(function persistForm() {
// 👍 将条件判断放置在 effect 中
if (name !== '') {
localStorage.setItem('formData', name);
}
});
对于以上的实现,Vue 的写法大概是这样:
import { ref, watchEffect } from 'vue'
export default {
setup() {
// 1. 使用 name 状态变量
const name = ref("Mary");
// 2. 使用一个 watcher 以持久化表单
if(name.value !== '') {
watchEffect(function persistForm() => {
localStorage.setItem('formData', name.value);
});
}
// 3. 使用 surname 状态变量
const surname = ref("Poppins");
// 4. 使用一个 watcher 以更新 title
watchEffect(function updateTitle() {
document.title = `${name.value} ${surname.value}`;
});
}
}
注:watchEffect 可以在响应式地跟踪其依赖项时立即运行一个函数,并在更改依赖项时重新运行它。watch 也可以实现相同的行为。
Vue 的 setup() 只会运行一次,是可以将 Composition API 中不同的函数 (reactive、ref、computed、watch、生命周期钩子等) 作为循环或条件语句的一部分。
但是 if 语句同样只运行一次,所以它在 name 改变时也同样无法作出反应,除非我们将其包含在 watchEffect 回调的内部:
watchEffect(function persistForm() => {
if(name.value !== '') {
localStorage.setItem('formData', name.value);
}
});
5.3 声明状态(Declaring state)
(1) react
useState 是 React Hooks 声明状态的主要途径。
- 可以向调用中传入一个初始值作为参数;
- 如果初始值的计算代价比较昂贵,也可以将其表达为一个函数,这样就只会在初次渲染时才会被执行。
useState() 返回一个数组,第一项是 state,第二项是一个 setter 函数。
const [name, setName] = useState("Mary");
const [age, setAge] = useState(25);
console.log(`${name} is ${age} years old.`);
useReducer 是个有用的替代选择,其常见形式是接受一个 Redux 样式的 reducer 函数和一个初始状态:
const initialState = {count: 0};
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
default:
throw new Error();
}
}
const [state, dispatch] = useReducer(reducer, initialState);
dispatch({type: 'increment'}); // state 就会变为 {count: 1}
useReducer 还有一种 延迟初始化 的形式,传入一个 init 函数作为第三个参数。
(2) vue
Vue 则由于其天然的反应式特性,有着不同的做法。使用两个主要的函数来声明状态:ref
和reactive
。
ref()
返回一个反应式对象,其内部值可通过其value
属性被访问到。可以将其用于基本类型,也可以用于对象,在后者的情况下是深层反应式的。
const name = ref("Mary");
const age = ref(25);
watchEffect(() => {
console.log(`${name.value} is ${age.value} years old.`);
});
reactive()
只将一个对象作为其输入并返回一个对其的反应式代理。注意其反应性也会影响到所有嵌套的属性。
const state = reactive({
name: "Mary",
age: 25,
});
watchEffect(() => {
console.log(`${state.name} is ${state.age} years old.`);
});
注意⚠️:
- 使用
ref
时需要用value
属性访问其包含的值(除非在 template 中,Vue 允许你省略它) - 用 reactive 时,要注意如果使用了对象解构(destructure),会失去其反应性。所以需要定义一个指向对象的引用,并通过其访问状态属性。
总结使用这两个函数的处理方式:
- 像在正常的 JavaScript 中声明基本类型变量和对象变量那样去使用
ref
和reactive
即可。- 用到
reactive
的时候,要记住从 composition 函数中返回反应式对象时得使用toRefs()
。这样做减少了过多使用 ref 时的开销。
// toRefs() 将反应式对象转换为普通对象,该对象上的所有属性都自动转换为 ref。
// 这对于从自定义组合式函数中返回对象时特别有用(这也允许了调用侧正常使用结构的情况下还能保持反应性)。
function useFeatureX() {
const state = reactive({
foo: 1,
bar: 2
})
return toRefs(state)
}
const {foo, bar} = useFeatureX();
5.4 如何跟踪依赖(How to track dependencies)
(1) react
React 中的useEffect hook
允许在每次渲染之后运行某些副作用 (如请求数据或使用 storage 等 Web APIs),并视需要在下次执行回调之前或当组件卸载时运行一些清理工作。
默认情况下,所有用useEffect
注册的函数都会在每次渲染之后运行,但可以定义真实依赖的状态和属性,以使 React 在相关依赖没有改变的情况下(如由 state 中的其他部分引起的渲染)跳过某些useEffect hook
执行。
// 传递一个依赖项的数组作为 useEffect hook 的第二个参数,只有当 name 改变时才会更新 localStorage
function Form() {
const [name, setName] = useState('Mary');
const [surname, setSurname] = useState('Poppins');
useEffect(function persistForm() {
localStorage.setItem('formData', name);
}, [name]); // 传递一个依赖项的数组作为 useEffect hook 的第二个参数
// ...
}
这样一来,只有当 name 改变时才会更新 localStorage。使用 React Hooks 时一个常见的 bug 来源就是忘记在依赖项数组中详尽地声明所有依赖项;这可能让 useEffect 回调以“依赖和引用了上一次渲染的陈旧数据而非最新数据”从而无法被更新而告终。
解决方案:
-
eslint-plugin-react-hooks
包含了一条 lint 提示关于丢失依赖项的规则。 -
useCallback
和useMemo
也使用依赖项数组参数,以分别决定其是否应该返回缓存过的(memoized
)与上一次执行相同的版本的回调或值。
(2) vue
在 Vue Composition API 的情况下,可以使用 watcher 执行副作用以响应状态或属性的改变。依赖会被自动跟踪,注册过的函数也会在依赖改变时被反应性的调用。
export default {
setup() {
const name = ref("Mary");
const lastName = ref("Poppins");
watchEffect(function persistForm() => {
localStorage.setItem('formData', name.value);
});
}
}
在 watcher 首次运行后,name 会作为一个依赖项被跟踪,而稍后当其值改变时,watcher 会再次运行。
5.5 访问组件生命周期(Access to the lifecycle of the component)
(1) react
Hooks 在处理 React 组件的生命周期、副作用和状态管理时表现出了心理模式上的完全转变。 React 文档中也指出:
如果你熟悉 React 类生命周期方法,那么可以将 useEffect Hook 视为
componentDidMount
、componentDidUpdate
及componentWillUnmount
的合集
useEffect(() => {
console.log("这段只在初次渲染后运行");
return () => { console.log("这里会在组件将要卸载时运行"); };
}, []);
但要再次强调的是,使用 React Hooks 时停止从生命周期方法的角度思考,而是考虑副作用依赖什么状态,才是更符合习惯的。
(2) vue
Vue Component API 通过onMounted
、onUpdated
和onBeforeUnmount
等可以访问生命周期钩子(Vue 世界中对生命周期方法的等价称呼):
setup() {
onMounted(() => {
console.log(`这段只在初次渲染后运行`);
});
onBeforeUnmount(() => {
console.log(`这里会在组件将要卸载时运行`);
});
}
故而在 Vue 的情况下心理模式转变更多在停止通过组件选项 (data、computed、watch、methods、生命周期钩子等) 来管理代码,而是转向用不同函数处理对应的特性。
5.6 自定义代码(Custom code)
(1) react
React 团队意图聚焦于 Hooks 上的原因之一,是之于先前社区采纳的诸如
Higher-Order Components
或Render Props
等,Custom Hooks 正是提供给开发者编写可复用代码的一种更优秀的方式。
Custom Hooks 就是普通的 JavaScript 函数,在其内部利用了 React Hooks。它遵守的一个约定是其命名应以 use 开头,以明示这是被用作一个 hook 的。
export function useDebugState(label, initialValue) {
const [value, setValue] = useState(initialValue);
useEffect(() => {
console.log(`${label}: `, value);
}, [label, value]);
return [value, setValue];
}
// 调用
const [name, setName] = useDebugState("Name", "Mary");
这个 Custom Hook 的小例子可被作为一个 useState 的替代品使用,用于当 value 改变时向控制台打印日志。
(2) vue
在 Vue 中,组合式函数(Composition Functions)与 Hooks 在逻辑提取和重用的目标上是一致的。我们能在 Vue 中实现一个类似的useDebugState
组合式函数。
export function useDebugState(label, initialValue) {
const state = ref(initialValue);
watchEffect(() => {
console.log(`${label}: `, state.value);
});
return state;
}
// 在其他某处:
setup() {
const name = useDebugState("Name", "Mary");
}
注意:根据约定,组合式函数也像 React Hooks 一样使用 use 作为前缀以明示作用,并且表明该函数用于
setup()
中
5.7 Refs
React 的useRef
和 Vue 的ref
都允许你引用一个子组件 (如果是 React 则是一个类组件或是被 React.forwardRef 包装的组件) 或 要附加到的 DOM 元素。
(1) react
const MyComponent = () => {
const divRef = useRef(null);
useEffect(() => {
console.log("div: ", divRef.current)
}, [divRef]);
return (
<div ref={divRef}>
<p>My div</p>
</div>
)
}
React 中的useRef Hook
不止能获得 DOM 元素的引用,亦可用在你想保持在渲染函数中但并不是 state 一部分的任何类型的可变值上(也就是它们的改变触发不了重新渲染)。useRef Hook
可将这些可变值视为类组件中的 "实例变量" 。例子:
const timerRef = useRef(null);
useEffect(() => {
timerRef.current = setInterval(() => {
setSecondsPassed(prevSecond => prevSecond + 1);
}, 1000);
return () => {
clearInterval(timerRef.current);
};
}, []);
return (
<button onClick={() => { clearInterval(timerRef.current) }} >
停止 timer
</button>
)
(2) vue
// 1. with template
<template>
<div ref={divRef}>
<p>My div</p>
</div>
</template>
<script>
import { ref, h, onMounted } from 'vue'
export default {
setup() {
const divRef = ref(null);
onMounted(() => {
// DOM 元素将在初始渲染后分配给 ref
console.log("div: ", divRef.value);
});
// 1. with template
return {
divRef
}
// 2. with 渲染函数
return () => h('div', { ref: divRef }, [ h('p', 'My div') ])
// 3. with JSX
return () => (
<div ref={divRef}>
<p>My div</p>
</div>
)
}
}
</script>
5.8 附加的函数(Additional functions)
(1) react
React Hooks 在每次渲染时都会运行,所以没有一个等价于 Vue 中computed
函数的方法。你可以自由地声明一个变量,其值基于状态或属性,并将指向每次渲染后的最新值:
const [name, setName] = useState("Mary");
const [age, setAge] = useState(25);
const description = `${name} is ${age} years old`;
计算一个值开销比较昂贵。你不会想在组件每次渲染时都计算它。React 包含了针对这点的useMemo hook
:
function fibNaive(n) {
if (n <= 1) return n;
return fibNaive(n - 1) + fibNaive(n - 2);
}
const Fibonacci = () => {
const [nth, setNth] = useState(1);
const nthFibonacci = useMemo(() => fibNaive(nth), [nth]);
return (
<section>
<label>
Number:
<input type="number" value={nth} onChange={e => setNth(e.target.value)} />
</label>
<p>nth Fibonacci number: {nthFibonacci}</p>
</section>
);
};
React 建议你使用useMemo
作为一个性能优化手段, 而非一个 任何一个依赖项改变之前的缓存值。
(2) vue
Vue 中,setup()
只运行一次。因此需要定义计算属性,观察某些状态更改并作出相应的更新:
const name = ref("Mary");
const age = ref(25);
const description = computed(() => `${name.value} is ${age.value} years old`);
Vue 的computed
执行自动的依赖追踪,所以它不需要一个依赖项数组。
react 的
useCallback
类似于useMemo
,但它是用来缓存一个回调函数的。事实上useCallback(fn, deps)
等价于useMemo(() => fn, deps)
。其理想用例是当我们需要在多次渲染间保持引用相等性时,比如将回调传递给一个用 React.memo 定义的已优化子组件,而我们想避免其不必要的重复渲染时。
鉴于 Vue Composition API 的天然特性,并没有等同于useCallback
的函数。setup()
中的任何回调函数都只会定义一次。
5.9 Context 和 provide/inject
(1) react
React 中的useContext hook
,可以作为一种读取特定上下文当前值的新方式。返回的值通常由最靠近的一层<MyContext.Provider>
祖先树的 value 属性确定。
其等价于一个类中的static contextType = MyContext
,或是<MyContext.Consumer>
组件。
// context 对象
const ThemeContext = React.createContext('light');
// provider
<ThemeContext.Provider value="dark">
// consumer
const theme = useContext(ThemeContext);
(2) vue
Vue 中类似的 API 叫provide/inject
。在 Vue 2.x 中作为组件选项存在,而在 Composition API 中增加了一对用在setup()
中的 provide 和 inject 函数:
// key to provide
const ThemeSymbol = Symbol();
// provider
provide(ThemeSymbol, ref("dark"));
// consumer
const value = inject(ThemeSymbol);
注⚠️:如果你想保持反应性,必须明确提供一个ref/reactive
作为值。
5.10 在渲染上下文中暴露值(Exposing values to render context)
(1) react
因为所有 hooks 代码都在组件中定义,且你将在同一个函数中返回要渲染的 React 元素。
所以你对作用域中的任何值拥有完全访问能力,就像在任何 JavaScript 代码中的一样:
const Fibonacci = () => {
const [nth, setNth] = useState(1);
const nthFibonacci = useMemo(() => fibNaive(nth), [nth]);
return (
<section>
<label>
Number:
<input type="number" value={nth} onChange={e => setNth(e.target.value)} />
</label>
<p>nth Fibonacci number: {nthFibonacci}</p>
</section>
);
};
(2) vue
而在 Vue 要在template
或render
选项中定义模板;如果使用单文件组件,就要从setup()
中返回一个包含你想输出到模板中的所有值的对象。由于要暴露的值很可能过多,你的返回语句也容易变得冗长。
<template>
<p>
<label>
Number:
<input type="number" v-model="nth" />
</label>
<p>nth Fibonacci number: {{nthFibonacci}}</p>
</p>
</template>
<script>
export default {
setup() {
const nth = ref(1);
const nthFibonacci = computed(() => fibNaive(nth.value));
return { nth, nthFibonacci };
}
};
</script>
要达到 React 同样简洁表现的一种方式是从setup()
自身中返回一个渲染函数。
export default {
setup() {
const nth = ref(1);
const nthFibonacci = computed(() => fibNaive(nth.value));
return () => (
<p>
<label>
Number:
<input type="number" vModel={nth} />
</label>
<p>nth Fibonacci number: {nthFibonacci}</p>
</p>
);
}
};
不过,模板在 Vue 中是更常用的一种做法,所以暴露一个包含值的对象,是你使用 Vue Composition API 时必然会多多遭遇的情况。
总结
React 和 Vue 都有属于属于自己的“惊喜”,无优劣之分,自 React Hooks 在 2018 年被引入,社区利用其产出了很多优秀的作品,自定义 Hooks 的可扩展性也催生了许多开源贡献。
Vue 受 React Hooks 启发将其调整为适用于自己框架的方式,这也成为这些不同的技术如何拥抱变化且分享灵感和解决方案的成功案例。