当 React Hooks 遇见 Vue3 Composition API

2,785 阅读4分钟

1. 前言

前几天在知乎看到了一个问题,React 的 Hooks 是否可以改为用类似 vue3 composition api 的方式实现?

关于 React Hooks 和 Vue3 Composition API 的热烈讨论一直都存在,虽然两者本质上都是实现状态逻辑复用,但在实现上却代表了两个社区的不同发展方向。

我想说,小孩子才分好坏,成年人表示我全都要。

![image_1e49c6pov12uu1tnirjl1vl3o6ql.png-64kB][1]

2. 你不知道的 Object.defineProperty

那今天我们来讨论一下怎么用 React Hooks 来实现 Vue3 Composition 的效果。

先来看一下我们最终要实现的效果。

![composition.gif-2420.4kB][2]

看到这个 API 的用法你会联想到什么?没错,很明显这里借用了 Proxy 或者 Object.defineProperty

在《你不知道的 Proxy:ES6 Proxy 能做哪些有意思的事情?》一文中,我们已经对比过两者的用法了。

Proxy vs Object.defineProperty

其实这里还有一个不为人知的区别,那就是可以通过 Object.defineProperty 给对象添加一个新属性。

const person = {}
Object.defineProperty(person, "name", {
    enumerable: true,
    get() {
        return "sh22n"
    }
})

打印出来的效果是这样的:

![image_1e4bp7a55168380160oc84kcg12.png-23.3kB][3]

这就很有意思了,意味着我们可以把某个对象 A 上所有属性都挂载到对象 B 上,这样我们不必对 A 进行任何监听,即不会污染 A。

const state = { count: 0 }
Object.defineProperty({}, "count", {
    get() {
        return state.count
    }
})

3. React Hooks + Object.defineProperty = ?

如果将上面的代码结合 React Hooks,那会出现什么效果呢?没错,我们的 Hooks 变得更加 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][4]

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(v, newPath));
            } else if (isArray(v)) {
                v = Object.defineProperties([], descriptors(v, 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, []));
};