最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 我打破了 React Hook 必须按顺序、不能在条件语句中调用的枷锁

    正文概述 掘金(ssh_晨曦时梦见兮)   2021-03-15   688

    React 官网介绍了 Hook 的这样一个限制:

    这个限制在开发中也确实会时常影响到我们的开发体验,比如函数组件中出现 if 语句提前 return 了,后面又出现 Hook 调用的话,React 官方推的 eslint 规则也会给出警告。

    function App(){
      if (xxx) {
        return null;
      }
    
      // ❌ React Hook "useState" is called conditionally. 
      // React Hooks must be called in the exact same order in every component render.
      useState();
      
      return 'Hello'
    }
    

    其实是个挺常见的用法,很多时候满足某个条件了我们就不希望组件继续渲染下去。但由于这个限制的存在,我们只能把所有 Hook 调用提升到函数的顶部,增加额外开销。

    由于 React 的源码太复杂,接下来本文会以原理类似但精简很多的 Preact 的源码为切入点来调试、讲解。

    限制的原因

    这个限制并不是 React 团队凭空造出来的,的确是由于 React Hook 的实现设计而不得已为之。

    以 Preact 的 Hook 的实现为例,它用数组和下标来实现 Hook 的查找(React 使用链表,但是原理类似)。

    // 当前正在运行的组件
    let currentComponent
    
    // 当前 hook 的全局索引
    let currentIndex
    
    // 第一次调用 currentIndex 为 0
    useState('first') 
    
    // 第二次调用 currentIndex 为 1
    useState('second')
    

    可以看出,每次 Hook 的调用都对应一个全局的 index 索引,通过这个索引去当前运行组件 currentComponent 上的 _hooks 数组中查找保存的值,也就是 Hook 返回的 [state, useState]

    那么假如条件调用的话,比如第一个 useState 只有 0.5 的概率被调用:

    // 当前正在运行的组件
    let currentComponent
    
    // 当前 hook 的全局索引
    let currentIndex
    
    // 第一次调用 currentIndex 为 0
    if (Math.random() > 0.5) {
      useState('first')
    }
    
    // 第二次调用 currentIndex 为 1
    useState('second')
    

    在 Preact 第一次渲染组件的时候,假设 Math.random() 返回的随机值是 0.6,那么第一个 Hook 会被执行,此时组件上保存的 _hooks 状态是:

    _hooks: [
      { value: 'first', update: function },
      { value: 'second', update: function },
    ]
    

    用图来表示这个查找过程是这样的:

    我打破了 React Hook 必须按顺序、不能在条件语句中调用的枷锁

    假设第二次渲染的时候,Math.random() 返回的随机值是 0.3,此时只有第二个 useState 被执行了,那么它对应的全局 currentIndex 会是 0,这时候去 _hooks[0] 中拿到的却是 first 所对应的状态,这就会造成渲染混乱。

    我打破了 React Hook 必须按顺序、不能在条件语句中调用的枷锁

    没错,本应该值为 second 的 value,莫名其妙的被指向了 first,渲染完全错误!

    以这个例子来看:

    export default function App() {
      if (Math.random() > 0.5) {
        useState(10000)
      }
      const [value, setValue] = useState(0)
    
      return (
        <div>
          <button onClick={() => setValue(value + 1)}>+</button>
          {value}
        </div>
      )
    }
    

    结果是这样:

    我打破了 React Hook 必须按顺序、不能在条件语句中调用的枷锁

    破解限制

    有没有办法破解限制呢?

    如果要破解全局索引递增导致的 bug,那么我们可以考虑换种方式存储 Hook 状态。

    如果不用下标存储,是否可以考虑用一个全局唯一的 key 来保存 Hook,这样不是就可以绕过下标导致的混乱了吗?

    比如 useState 这个 API 改造成这样:

    export default function App() {
      if (Math.random() > 0.5) {
        useState(10000, 'key1');
      }
      const [value, setValue] = useState(0, "key2");
    
      return (
        <div>
          <button onClick={() => setValue(value + 1)}>+</button>
          {value}
        </div>
      );
    }
    

    这样,通过 _hooks['key'] 来查找,就无所谓前序的 Hook 出现的任何意外情况了。

    也就是说,原本的存储方式是:

    _hooks: [
      { value: 'first', update: function },
      { value: 'second', update: function },
    ]
    

    改造后:

    _hooks: [
      key1: { value: 'first', update: function },
      key2: { value: 'second', update: function },
    ]
    

    注意,数组本身就支持对象的 key 值特性,不需要改造 _hooks 的结构。

    改造源码

    来试着改造一下 Preact 源码,它的 Hook 包的位置在 hooks/src/index.js 下,找到 useState 方法:

    export function useState(initialState) {
      currentHook = 1;
      return useReducer(invokeOrReturn, initialState, undefined);
    }
    

    它的底层调用了 useReducer,所以新增加一个 key 参数透传下去:

    + export function useState(initialState, key) {
      currentHook = 1;
    + return useReducer(invokeOrReturn, initialState, undefined, key);
    }
    

    useReducer 原本是通过全局索引去获取 Hook state:

    // 全局索引
    let currentIndex
    
    export function useReducer(reducer, initialState, init) {
      const hookState = getHookState(currentIndex++, 2);
      hookState._reducer = reducer;
    
      return hookState._value;
    }
    

    改造成兼容版本,有 key 的时候优先传入 key 值:

    // 全局索引
    let currentIndex
    
    + export function useReducer(reducer, initialState, init, key) {
    +  const hookState = getHookState(key || currentIndex++, 2);
       hookState._reducer = reducer;
    
       return hookState._value;
    }
    

    最后改造一下 getHookState 方法:

    function getHookState(index, type) {
      const hooks =
        currentComponent.__hooks ||
        (currentComponent.__hooks = {
          _list: [],
          _pendingEffects: [],
        });
    
    // 传入 key 值是 string 或 symbol 都可以
    +  if (typeof index !== 'number') {
    +    if (!hooks._list[index]) {
    +      hooks._list[index] = {};
    +    }
    +  } else {
        if (index >= hooks._list.length) {
          hooks._list.push({});
        }
      }
      // 这里天然支持 key 值取用的方式
      return hooks._list[index];
    }
    

    这里设计成传入 key 值的时候,初始化就不往数组里 push 新状态,而是直接通过下标写入即可,原本的取状态的写法 hooks._list[index] 本身就支持通过 key 从数组上取值,不用改动。

    至此,改造就完成了。

    来试试新用法:

    export default function App() {
      if (Math.random() > 0.5) {
        useState(10000, 'key1');
      }
      const [value, setValue] = useState(0, 'key2');
    
      return (
        <div>
          <button onClick={() => setValue(value + 1)}>+</button>
          {value}
        </div>
      );
    }
    

    我打破了 React Hook 必须按顺序、不能在条件语句中调用的枷锁

    自动编译

    事实上 React 团队也考虑过给每次调用加一个 key 值的设计,在 Dan Abramov 的 为什么顺序调用对 React Hooks 很重要? 中已经详细解释过这个提案。

    多重的缺陷导致这个提案被否决了,尤其是在遇到自定义 Hook 的时候,比如你提取了一个 useFormInput

    const valueKey = Symbol();
     
    function useFormInput() {
      const [value, setValue] = useState(valueKey);
      return {
        value,
        onChange(e) {
          setValue(e.target.value);
        },
      };
    }
    

    然后在组件中多次调用它:

    function Form() {
      // 使用 Symbol
      const name = useFormInput(); 
      // 又一次使用了同一个 Symbol
      const surname = useFormInput(); 
      // ...
      return (
        <>
          <input {...name} />
          <input {...surname} />
          {/* ... */}
        </>    
      )
    }
    

    此时这个通过 key 寻找 Hook state 的方式就会发生冲突。

    但我的想法是,能不能借助 babel 插件的编译能力,实现编译期自动为每一次 Hook 调用都注入一个 key, 伪代码如下:

    traverse(node) {
      if (isReactHookInvoking(node)) {
        addFunctionParameter(node, getUniqKey(node))
      }
    }
    

    生成这样的代码:

    function Form() {
    +  const name = useFormInput('key_1'); 
    +  const surname = useFormInput('key_2'); 
      // ...
      return (
        <>
          <input {...name} />
          <input {...surname} />
          {/* ... */}
        </>    
      )
    }
    
    + function useFormInput(key) {
    +  const [value, setValue] = useState(key);
      return {
        value,
        onChange(e) {
          setValue(e.target.value);
        },
      };
    }
    

    key 的生成策略可以是随机值,也可以是注入一个 Symbol,这个无所谓,保证运行时期不会改变即可。也许有一些我没有考虑周到的地方,对此有任何想法的同学都欢迎加我微信 sshsunlight 讨论,当然单纯的交个朋友也没问题,大佬或者萌新都欢迎。

    总结

    本文只是一篇探索性质的文章:

    • 介绍 Hook 实现的大概原理以及限制
    • 探索出修改源码机制绕过限制的方法

    其实本意是帮助大家更好的理解 Hook

    我并不希望 React 取消掉这些限制,我觉得这也是设计的取舍。

    如果任何子函数,任何条件表达式中都可以调用 Hook,代码也会变得更加难以理解和维护

    如果你真的希望更加灵活的使用类似的 Hook 能力,Vue3 底层响应式收集依赖的原理就可以完美的绕过这些限制,但更加灵活的同时也一定会无法避免的增加更多维护风险。

    感谢大家

    我是 ssh,目前就职于字节跳动的 Web Infra 团队,目前团队在北上广深杭都还缺人(尤其是北京)。

    为此我组建了一个氛围特别好的招聘社群,大家在里面尽情的讨论面试相关的想法和问题,也欢迎你加入,随时投递简历给我。


    起源地下载网 » 我打破了 React Hook 必须按顺序、不能在条件语句中调用的枷锁

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元