當 React Hooks 遇見 Vue3 Composition API
1. 前言
前幾天在知乎看到了一個問題,React 的 Hooks 是否可以改為用類似 Vue3 Composition API 的方式實現(xiàn)?
關于 React Hooks 和 Vue3 Composition API 的熱烈討論一直都存在,雖然兩者本質(zhì)上都是實現(xiàn)狀態(tài)邏輯復用,但在實現(xiàn)上卻代表了兩個社區(qū)的不同發(fā)展方向。
我想說,小孩子才分好壞,成年人表示我全都要。

2. 你不知道的 Object.defineProperty
那今天我們來討論一下怎么用 React Hooks 來實現(xiàn) Vue3 Composition 的效果。
先來看一下我們最終要實現(xiàn)的效果。

看到這個 API 的用法你會聯(lián)想到什么?沒錯,很明顯這里借用了 Proxy 或者 Object.defineProperty。
在《你不知道的 Proxy:ES6 Proxy 能做哪些有意思的事情?》一文中,我們已經(jīng)對比過兩者的用法了。

其實這里還有一個不為人知的區(qū)別,那就是可以通過 Object.defineProperty 給對象添加一個新屬性。
const person = {}
Object.defineProperty(person, "name", {
enumerable: true,
get() {
return "sh22n"
}
})
打印出來的效果是這樣的:

這就很有意思了,意味著我們可以把某個對象 A 上所有屬性都掛載到對象 B 上,這樣我們不必對 A 進行任何監(jiān)聽,即不會污染 A。
const state = { count: 0 }
Object.defineProperty({}, "count", {
get() {
return state.count
}
})
3. React Hooks + Object.defineProperty = ?
如果將上面的代碼結(jié)合 React Hooks,那會出現(xiàn)什么效果呢?沒錯,我們的 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>
)
}
當然,這段代碼還存在很多問題,依賴了對象的結(jié)構(gòu)、不支持更深層的 getter/setter 等等,我們接下來就一起來優(yōu)化一下。
4. 實現(xiàn) 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 這里應該根據(jù)訪問路徑來設置。
首先,我們來對深層對象做一次遞歸。
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 })
}
}
}
}, {})
}
如果你仔細觀察了這段代碼,會發(fā)現(xiàn)有個非常致命的問題。那就是在做遞歸的時候,set(newVal) 里面的代碼并不對,state 是個深層對象,不能這么簡單地對其外層進行賦值。
這意味著,我們需要將訪問這個對象深層屬性的一整條路徑保存下來,以便于 set 到正確的值,可以用一個數(shù)組來收集路徑上的 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 })
}
}
}
}, {})
}
但是,如果傳入的是個數(shù)組,這里就會有問題了。因為我們只是對 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. 完整版
這樣,我們就實現(xiàn)了一個完整版的 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, []));
};
