当 React Hooks 遇见 Vue3 Composition API
1. 前言
前几天在知乎看到了一个问题,React 的 Hooks 是否可以改为用类似 Vue3 Composition API
的方式实现?
关于 React Hooks
和 Vue3 Composition API
的热烈讨论一直都存在,虽然两者本质上都是实现状态逻辑复用,但在实现上却代表了两个社区的不同发展方向。
我想说,小孩子才分好坏,成年人表示我全都要。
2. 你不知道的 Object.defineProperty
那今天我们来讨论一下怎么用 React Hooks
来实现 Vue3 Composition
的效果。
先来看一下我们最终要实现的效果。
看到这个 API 的用法你会联想到什么?没错,很明显这里借用了 Proxy 或者 Object.defineProperty
。
在《你不知道的 Proxy:ES6 Proxy 能做哪些有意思的事情?》一文中,我们已经对比过两者的用法了。
其实这里还有一个不为人知的区别,那就是可以通过 Object.defineProperty
给对象添加一个新属性。
const person = {}
Object.defineProperty(person, "name", {
enumerable: true,
get() {
return "sh22n"
}
})
打印出来的效果是这样的:
这就很有意思了,意味着我们可以把某个对象 A 上所有属性都挂载到对象 B 上,这样我们不必对 A 进行任何监听,即不会污染 A。
const state = { count: 0 }
Object.defineProperty({}, "count", {
get() {
return state.count
}
})
3. React Hooks + Object.defineProperty = ?
如果将上面的代码结合 React Hooks
,那会出现什么效果呢?没错,我们的 React 变得更加 reactive
了。
const [state, setState] = useState({ count: 0 })
const proxyState = Object.defineProperty({}, "count", {
get() {
return state.count
},
set(newVal) {
setState({ ...state, count: newVal })
}
})
return (
<h1 onClick={() => proxyState.count++}>
{ proxyState.count }
</h1>
)
将这段代码进一步封装,可以得到一个 Custom Hook
,也就是我们今天要说的 Composition API
。
const ref = (value) => {
const [state, setState] = useState(value)
return Object.defineProperty({}, "count", {
get() {
return state.count
},
set(newVal) {
setState({ ...state, count: newVal })
}
})
}
function Counter() {
const count = ref({ value: 0 })
return (
<h1 onClick={() => count.value++}>
{ count.value }
</h1>
)
}
当然,这段代码还存在很多问题,依赖了对象的结构、不支持更深层的 getter/setter
等等,我们接下来就一起来优化一下。
4. 实现 Composition
4.1 递归劫持属性
对于属性的对象来说,我们可以遍历,配合 Object.defineProperties
来劫持它的所有属性。
const descriptors = Object.keys(state).reduce((handles, key) => {
return {
...handles,
[key]: {
get() {
return state[key]
},
set(newVal) {
setState({ ...state, [key]: newVal })
}
}
}
}, {})
Object.defineProperty({}, descriptors)
而对于更深层的对象来说,不仅要做递归,还要考虑 setState 这里应该根据访问路径来设置。
首先,我们来对深层对象做一次递归。
const descriptors = (obj) => {
return Object.keys(obj).reduce((handles, key) => {
let value = obj[key];
// 如果 value 是个对象,那就递归其属性进行 `setter/getter`
if (Object.prototype.toString.call(obj) === "[object Object]") {
value = Object.defineProperty({}, descriptors(value));
}
return {
...handles,
[key]: {
get() {
return value
},
set(newVal) {
setState({ ...state, [key]: newVal })
}
}
}
}, {})
}
如果你仔细观察了这段代码,会发现有个非常致命的问题。那就是在做递归的时候,set(newVal)
里面的代码并不对,state
是个深层对象,不能这么简单地对其外层进行赋值。
这意味着,我们需要将访问这个对象深层属性的一整条路径保存下来,以便于 set
到正确的值,可以用一个数组来收集路径上的 key 值。
这里用使用 lodash 的 set 和 get 来做一下演示。
const descriptors = (obj, path) => {
return Object.keys(obj).reduce((handles, key) => {
// 收集当前路径的 key
let newPath = [...path, key],
value = _.get(state, newPath);
// 如果 value 是个对象,那就递归其属性进行 `setter/getter`
if (Object.prototype.toString.call(obj) === "[object Object]") {
value = Object.defineProperty({}, descriptors(value, newPath));
}
return {
...handles,
[key]: {
get() {
return value
},
set(newVal) {
_.set(state, newPath, newVal)
setState({ ...state })
}
}
}
}, {})
}
但是,如果传入的是个数组,这里就会有问题了。因为我们只是对 Object 进行了拦截,没有对 Array 进行处理。
const isArray = arr => Object.prototype.toString.call(arr) === '[object Array]'
const isObject = obj => Object.prototype.toString.call(arr) === '[object Object]'
const descriptors = (obj, path) => {
return Object.keys(obj).reduce((handles, key) => {
// 收集当前路径的 key
let newPath = [...path, key],
value = _.get(state, newPath);
// 如果 value 是个对象,那就递归其属性进行 `setter/getter`
if (isObject(value)) {
value = Object.defineProperties({}, descriptors(value, newPath));
}
if (isArray(value)) {
value = Object.defineProperties([], descriptors(value, newPath));
}
return {
...handles,
[key]: {
get() {
return value
},
set(newVal) {
_.set(state, newPath, newVal)
setState({ ...state })
}
}
}
}, {})
}
5. 完整版
这样,我们就实现了一个完整版的 ref
,我将代码和示例都放到了 codesandbox
上面:Compostion API
const isArray = arr => Object.prototype.toString.call(arr) === '[object Array]'
const isObject = obj => Object.prototype.toString.call(arr) === '[object Object]'
const ref = (value) => {
if (typeof value !== "object") {
value = {
value
};
}
const [state, setState] = useState(value);
const descriptors = (obj, path) => {
return Object.keys(obj).reduce((result, key) => {
let newPath = [...path, key];
let v = _.get(state, newPath);
if (isObject(v)) {
v = Object.defineProperties({}, descriptors(state, newPath));
} else if (isArray(v)) {
v = Object.defineProperties([], descriptors(state, newPath));
}
return {
...result,
[key]: {
enumerable: true,
get() {
return v;
},
set(newVal) {
setState(
_.set(state, newPath, newVal)
setState({ ...state })
);
}
}
};
}, {});
};
return Object.defineProperties(isArray(value) ? [] : {}, descriptors(state, []));
};