bosens-China/blog

为 React 添加双向绑定 hooks

bosens-China opened this issue · 0 comments

最近换了一家新公司,用的技术栈react为主,所以上周紧急的看了一下react相关的文档,也对照文档写了几个 demo,不过在开发阶段我的体验还是蛮差的。

  • 生态很繁荣,但是不知道那种方案是最佳
  • 开发效率很繁琐(这一点待商榷)

刚刚简单写了一个 todolist 的功能,不过在对 list 进行保存、修改、删除的时候,感觉很酸爽

// ...
const list = myContent.list[props.index]!;
list.title = inp;
list.show = false;
myContent.setlist([...myContent.list]);

大量这样的代码,所以就想通过 hook + 数据劫持 来实现下面这样的功能

import { useModel } from './utils';
const app = () => {
  // ... 省略其他代码
  const [arr] = useModel([]);
  arr.push(1);
};

在 push 之类的操作时,自动帮我们完成setArr([...arr])这样的操作

实现思路

之前想借鉴useEffect, useCallback之类自带的 hook 来实现,不过很遗憾这个必须要显示调用setxxx才会触发,所以现在摆在面前的如何通过变化之后通过回调来触发 set 的操作。

因为之前使用的是 vue 所以脑海中最先蹦出的就是通过Object.defineProperty来劫持数据

这里补充一点,proxy 的效果更好,不过这里会有一些兼容性问题,后面我会将这些功能封装成一个库,优先使用proxy之后降级到Object.defineProperty

Object.defineProperty

刷过面试题的应该对这个 api 应该不陌生,它是 vue2.x 实现数据劫持的关键,通过拦截对象的 get 和 set 属性,之后分发事件来通知视图进行更新。

不过这个 api 是有一些缺点的,尤其是对数组而言

  • 不支持拦截length属性,这点很关键,会导致我们直接修改arr.length = 0无效,原因是内部引擎的规定不允许监听

  • 不支持方法监听,例如使用 push 等

明确上面两点之后,我们就来动手设计这个useModel应该怎么写,思路借鉴 vue2.x 的写法,

  • 约定不能直接修改length,例如:arr[100] = {}
  • 使用变异方法push、pop、shift、unshift、splice、sort、reverse来完成对数组的删除和其他修改;
  • 允许修改存在的数组下标,可以直接修改 arr[0]这样的数据;

这里稍微提一下 vue 官方不允许修改已存在数组下标是因为存在性能考虑

实现思路

实现思路很简单,主要就是递归遍历对象的所有属性,之后将属性改用getset的形式进行定义,在对象属性值更改的时候来调用useState返回的 set 方法进行数据的更新。

而数组的变异方法监听,则是通过改写数组的原型链实现,例如

const arr = [];
const myProto = Object.create(Array.prototype);
const arrFn = arr.push;
myProto.push = (...rest) => {
  consoole.log(1);
  arrFn.push.apply(arr, rest);
};
Object.setPrototypeOf(arr, myProto);
// 1
arr.push(1);

将这个数组的 __propo__指向我们自定义的原型对象上,这个原型对象上有 push、pop 等变异方法,通过调用变异方法完成对原数组的的操作和对 set 的更新

实现思路

具体实现

代码量并不是很大,所以直接放代码了,一些关键的地方已经进行了注释

import { useState } from 'react';
const isObject = (obj) => {
  return (typeof obj === 'object' && obj) || typeof obj === 'function';
};
// 直接改写成一个通用遍历,这里进行类型判断,后续的useModel则不需要进行判断了
const each = (obj, change) => {
  if (!isObject(obj)) {
    return;
  }
  const isArr = Array.isArray(obj);
  const keys = isArr ? obj : Object.keys(obj);
  for (let i = 0, len = keys.length; i < len; i++) {
    const key = isArr ? i : keys[i];
    const value = obj[key];
    change(key, value);
  }
};
export const useModel = (obj) => {
  const [model, setModel] = useState(obj);
  // 更新的时候直接更新顶层对象即可,因为这是hook写法不存在class的局部替换
  const setRootValue = () => {
    if (Array.isArray(model)) {
      setModel([...model]);
      return;
    }
    setModel(Object.assign({...model});
  };
  // 定义对象的key
  const defineProperty = (key, value, o) => {
    Object.defineProperty(o, key, {
      enumerable: true,
      get() {
        return value;
      },
      set(v) {
        if (v === value || (Number.isNaN(v) && Number.isNaN(value))) {
          return;
        }
        value = v;
        setRootValue();
      },
    });
  };
  const definePropertyArray = (all) => {
    const myProto = Object.create(Array.prototype);
    each(['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'], (_, value) => {
      const fn = all[value];
      myProto[value] = (...rest) => {
        fn.apply(all, rest);
        setRootValue();
      };
    });
    Object.setPrototypeOf(all, myProto);
  };
  const observer = (all) => {
    each(all, (key, value) => {
      defineProperty(key, value, all);
      observer(value);
    });
    if (Array.isArray(all)) {
      definePropertyArray(all);
    }
  };
  observer(model);
  return [model, setRootValue];
};

注意,上面并没有在Object.definePropertyset 的时候继续执行深度监听,是因为 hook 在改变的时候就会重新执行这个方法,所以并不需要深度监听

最后

如果写的有什么不对的地方欢迎指出,如果对你有帮助可以点一下start

为了方便验证效果,放一个示例代码(可能需要翻墙)