React Hooks(二): useCallback 之痛

很早总结的 hooks 的问题文章,内部讨论一直没想到啥最优解,发出来看看有没有人有更好的解法css

最近 rxjs 做者 ben lesh 发了条推 twitter.com/benlesh/sta… 如此推所示,useCallback 问题很是严重,社区也讨论了不少作法,但仍然有不少问题。html

useCallback 问题原因

先回顾下 hook 以前组件的写法前端

class 组件vue

export class ClassProfilePage extends React.Component<any,any> {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  }; 


  handleClick = () => {
    setTimeout(this.showMessage, 3000);
  };


  render() {
 return <button onClick={this.handleClick}>Follow</button>;
  }
}
复制代码

functional 组件react

export function FunctionProfilePage(props) {
 const showMessage = () => {
    alert('Followed ' + props.user);
  };


 const handleClick = () => {
    setTimeout(showMessage, 3000);
  };


 return (
    <button onClick={handleClick}>Follow</button>
  );
}
复制代码

点击按钮,同时将 user 由 A 切换到 B 时,class 组件显示的是 B 而 function 组件显示的是 A,这两个行为难以说谁更加合理git

import React, { useState} from "react";
import ReactDOM from "react-dom";

import { FunctionProfilePage, ClassProfilePage  } from './profile'


import "./styles.css";


function App() {
  const [state,setState] = useState(1);
 return (
    <div class>
      <button onClick={() => {
        setState(x => x+x);
      }}>double</button>
      <div>state:{state}</div>
      <FunctionProfilePage user={state} /> // 点击始终显示的是快照值
      <ClassProfilePage user={state} /> // 点击始终显示的是最新值
    </div>
  );
}


const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
复制代码

codesandbox.io/s/dreamy-wa…github

当你的应用里同时存在 Functional 组件和 class 组件时,你就面临着 UI 的不一致性,虽然 react 官方说 function 组件是为了保障 UI 的一致性,但这是创建在全部组件都是 functional 组件,事实上这假设几乎不成立,若是你都采用 class 组件也可能保证 UI 的一致性(都显示最新值),一旦你页面里混用了 class 组件和 functional 组件(使用 useref 暂存状态也视为 class 组件),就存在的 UI 不一致性的可能编程

快照 or 最新值

因此 function 和 class 最大区别只在于默认状况不一样,二者能够相互转换, 快照合理仍是最新值合理,这彻底取决于你的业务场景,不能一律而论redux

事实上在 class 里也能够拿到快照值,在 function 里也能够拿到最新值api

class 里经过触发异步以前保存快照便可

export class ClassProfilePage extends React.Component<any,any> {
  showMessage = (message) => {
    alert('Followed ' +message);
  };


  handleClick = () => {
 const message = this.props.user // 在触发异步函数以前保存快照
    setTimeout(() =>showMessage(message)), 3000);
  };


  render() {
 return <button onClick={this.handleClick}>Follow</button>;
  }
}
复制代码

function 里经过 ref 容器存取最新值

export function FunctionProfilePage(props) {
 const ref = useRef("");
  useEffect(() => {
    ref.current = props.user;
  });
 const showMessage = () => {
 console.log('ref:',ref)
    alert("Followed " + props.user +',' + ref.current);
  };


 const handleClick = () => {
    setTimeout(showMessage, 3000);
  };


 return <button onClick={handleClick}>function Follow</button>;
}
复制代码

其实就是个经典的函数闭包问题

  • 在异步函数执行前能够对闭包访问的自由变量进行快照捕获:实现快照功能
  • 在异步函数执行中能够经过 ref 读取最新的值
for(var i=0;i<10;i++){
   setTimeout(() => console.log('val:',i)) // 拿到的是最新值
}
for(var i=0;i<10;i++){
  setTimeout(((val) => console.log('val:',val)).bind(null,i)); // 拿到的是快照
}
const ref = {current: null}
for(var i=0;i<10;i++){ 
  ref.current = i;
  setTimeout(((val) => console.log('val:',ref.current)).bind(null,ref)); // 拿到的是最新值
}
for (var i = 0; i < 10; i++) { // 拿到的是快照
 let t = i;
  setTimeout(() => {
 console.log("t:", t);
  });
}


复制代码

重渲染机制

虽然 functional 和 class 组件在快照处理方式不一致,可是二者的重渲染机制,并无大的区别

class 重渲染触发条件, 此处暂时不考虑采用 shouldComponentUpdate 和 pureComponent 优化

  • this.setState : 无条件重渲染,不进行新旧比较
  • this.forceUpdate: 无条件重渲染,不进行新旧比较
  • 父组件 render 带动子组件 render: 无条件,和 props 是否更新无关
  • 祖先组件 context 变更: 不作 props 变更假设

咱们发现 react 默认的重渲染机制压根没有对 props 作任何假设,性能优化彻底交给框架去作,react-redux 基于 shouldComponent, mobx-react 基于 this.forceUpdatehooks 来作一些性能优化

带来的问题

咱们发现即便不用 hooks 自己 functional 组件和 class 组件表现就存在较大差别,因为 hook 目前只能在 function 组件里使用,这致使了一些原本是 functional 组件编程思惟的问题反映到了 hooks 上。

hooks 的使用引入了两条强假设,致使了编程思惟的巨大变更

  • 只能在 functional 组件里使用: 致使咱们须要处理最新值的问题
  • 反作用(包括 rerender 和 effect)基于新旧值的 reference equality : 强制咱们使用 immutable 进行编程

上述两条带来了很大的心智负担

Stale closure 与 infinite loop

这两个问题是硬币的两面,一般为了解决一个问题,可能致使另一个问题

一个最简单的 case 就是一个组件依赖了父组件的 callback,同时内部 useffect 依赖了这个 callback

以下是一个典型的搜索场景

function Child(props){
 console.log('rerender:')
 const [result,setResult] = useState('')
 const { fetchData } = props;
  useEffect(() => {
    fetchData().then(result => {
      setResult(result);
    })
  },[fetchData])
 return (
    <div>query:{props.query}</div>
    <div>result:{result}</div>
  )
}
export function Parent(){
 const [query,setQuery] = useState('react');
 const fetchData = () => {
 const url = 'https://hn.algolia.com/api/v1/search?query=' + query
 return fetch(url).then(x => x.text())
 } 
 return (
    <div>
    <input onChange={e => setQuery(e.target.value)} value={query} />
    <Child fetchData={fetchData} query={query}/>
    </div>
  )
}
复制代码

上述代码存在的一个问题就是,每次 Parent 重渲染都会生成一个新的 fetchData,由于 fetchData 是 Child 的 useEffect 的 dep,每次 fetchData 变更都会致使子组件从新触发 effect,一方面这会致使性能问题,假如 effect 不是幂等的这也会致使业务问题(若是在 effect 里上报埋点怎么办)

解决思路 1

再也不 useEffect 里监听 fetchData: 致使 stale closure 问题 和页面 UI 不一致

useEffect(() => {
    fetchData().then(result => {
      setResult(result);
    })
  },[]) // 去掉fetchData依赖
复制代码

此时一方面父组件 query 更新,可是子组件的搜索并未更新可是子组件的 query 显示却更新了,这致使了子组件的 UI 不一致

解决思路 2

在思路 1 的基础上增强刷 token

// child
useEffect(() => {
 fetchData().then(result => {
      setResult(result);
    })
},[refreshToken]);


// parent
<Child fetchData={fetchData} query={query} refreshToken={query} />
复制代码

问题:

  • 若是子组件的 effect 较多,须要创建 refreshToken 和 effect 的映射关系
  • 触发 eslint-hook 的 warning,进一步的可能触发 eslint-hook 的 auto fix 功能,致使 bug
  • fetchData 仍然可能获取的是旧的闭包?

为了更好的语义化和避免 eslint 的报错,能够自定义封装 useDep 来解决

useDepChange(() => 
  fetchData().then(result => {
      setResult(result);
    })
  },[fetchData])
},[queryToken]); // 只在dep变更的时候触发,约等于componentWillReceiveProps了

复制代码
  • 其实是放弃了 eslint-hook 的 exhaustive 检查,可能会致使忘记添加某些依赖,须要写代码时很是仔细了

解决思路 3

useCallback 包裹 fetchData, 这其实是把 effect 强刷的控制逻辑从 callee 转移到了 caller

// parent 
const fetchData = useCallback(() => {
 const url = 'https://hn.algolia.com/api/v1/search?query=' + query
 return fetch(url).then(x => x.text())
  },[query]);

// child
  useEffect(() => {
    fetchData().then(result => {
      setResult(result);
    })
  },[fetchData])

复制代码

问题:

  • 若是 child 的 useEffect 里依赖了较多的 callback,须要全部的 callback 都须要进行 useCallback 包装,一旦有一个没用 useCallback 包装,就前功尽弃
  • props 的不可控制,Parent 的 fetchData 极可能是从其余组件里获取的,本身并无控制 fetchData 不可变的权限,这致使千里以外的一个祖先组件改变了 fetchData,致使 Child 最近疯狂刷新 effect, 这就须要将 callback 作层层 useCallback 处理才能避免该问题
  • 官方说 useCallback 不能作语义保障,并且存在 cache busting 的风险
  • 组件 API 的设计:咱们发现此时设计组件时须要关心传进来的组件是不是可变的了,可是在接口上并不会反馈这种依赖
<Button onClick={clickHandler} />  // onClick改变会触发Button的effect吗? 

复制代码

解决思路 4

使用 useEventCallback 做为逃生舱,这也是官方文档给出的一种用法 useEventCallback

// child
useEventCallback(() => {
  fetchData().then(result => {
     setResult(result);
  });
},[fetchData]);
function useEventCallback(fn, dependencies) {
  const ref = useRef(() => {
    throw new Error('Cannot call an event handler while rendering.');
  });

  useEffect(() => {
    ref.current = fn;
  }, [fn, ...dependencies]);

  return useCallback(() => {
    const fn = ref.current;
    return fn();
  }, [ref]);
}
复制代码

这仍然存在问题,

解决思路 5:

拥抱 mutable,实际上这种作法就是放弃 react 的快照功能(变相放弃了 concurrent mode ),达到相似 vue3 的编码风格

实际上咱们发现 hook + mobx === vue3, vue3 后期的 api 实际上能用 mobx + hook 进行模拟

问题就是: 可能放弃了 concurrent mode (concurrent mode 更加关注的是 UX,对于通常业务开发效率和可维护性可能更加剧要)

调用者约定:

  • 父组件传递给子组件的 callback: 永远获取到的是父组件的最新 state (经过 useObservable|useRef)

被调用者约定

  • 不要把 callback 做为 useEffect 的依赖:由于咱们已经限定了 callback 永远是最新的,实际上避免了陈旧闭包问题,因此不须要把 callback 做为 depdency

  • 代码里禁止直接使用 useEffect:只能使用自定义封装的 hook,(由于 useEffect 会触发 eslint-hook 的 warning,每次都禁止很差,且 useEffect 没有那么语义化)如可使用以下 hook

  • useMount: 只在 mount 触发(更新不触发)

  • useUpdateEffect: 只在更新时触发(mount 不触发)

  • useDepChange: dep 改变时触发,功能和 useEffect 相似,不会触发 wanring

// parent.js
export observer(function VueParent(){
 const [state] = useState(observable({
    query: 'reqct'
  }))
 const fetchData = () => {
 const url = 'https://hn.algolia.com/api/v1/search?query=' + state.query
 return fetch(url).then(x => x.text())
  }
 return (
    <div>
    <input onChange={e => state.query = e.target.value} value={state.query} />
    <Child fetchData={fetchData} query={state.query}  />
    </div>
  )
})
// child.js
export function observer(VueChild(props){
 const [result,setResult] = useState('')
  useMount(() => {
      props.fetchData().then(result => {
        setResult(result);
      })
  })
  useUpdateEffect(() => {
    props.fetchData().then(result => {
      setResult(result);
    })
  },[props.query])
  /* 或者使用useDepChange
   useUpdateEffect(() => {
    props.fetchData().then(result => {
      setResult(result);
    })
    },[props.query])
   */
 return (
    <div>
    <div>query: {props.query}</div>
    <div>result:{result}</div>
    </div>
  )
})
复制代码

解决思路 6

useReducer 这也是官方推荐的较为正统的作法

咱们仔细看看咱们的代码,parent 里的 fetchData 为何每次都改变,由于咱们父组件每次 render 都会生成新的函数,为什每次都会生成新的函数,咱们依赖了 query 致使无法提取到组件外,除了使用 useCallback 咱们还能够将 fetchData 的逻辑移动至 useReducer 里。由于 useReducer 返回的 dispatch 永远是不变的,咱们只须要将 dispatch 传递给子组件便可,然而 react 的 useReducer 并无内置对异步的处理,因此须要咱们自行封装处理, 幸亏有一些社区封装能够直接拿来使用,好比 zustand, 这也是我目前以为较好的方案,尤为是 callback 依赖了多个状态的时候。codesandbox.io/s/github/ha…

function Child(props) {
  const [result, setResult] = useState("");
  const { fetchData } = props;
  useEffect(() => {
    console.log("trigger effect");
    fetchData().then(result => {
      setResult(result);
    });
  }, [props.query, fetchData]);
  return (
    <>
      <div>query:{props.query}</div>
      <div>result:{result}</div>
    </>
  );
}
const [useStore] = create((set, get) => ({
  query: "react",
  setQuery(query) {
    set(state => ({
      ...state,
      query
    }));
  },
  fetchData: async () => {
    const url = "https://hn.algolia.com/api/v1/search?query=" + get().query;
    const x = await (await fetch(url)).text();
    return x;
  }
}));
export function Parent() {
  const store = useStore();
  const forceUpdate = useForceUpdate();
  console.log("parent rerender");
  useEffect(() => {
    setInterval(() => {
      forceUpdate({});
    }, 1000);
  }, [forceUpdate]);
  return (
    <div>
      <input
        onChange={e => store.setQuery(e.target.value)}
        value={store.query}
      />
      <Child fetchData={store.fetchData} query={store.query} />
    </div>
  );
}
复制代码

解决思路 7:

这也是我以为可能的最佳解法了,核心问题仍是在于 js 语言对于并发 | immutable | 函数式编程的羸弱支持如(thread local object | mutable, immutable 标记 | algebraic effects 支持),致使 react 官方强行在框架层面对语言设施进行各类 hack,引发了各类违反直觉的东西,换一门语言作 react 多是更好的方案(如 reasonml)。


插播一条广告。字节跳动诚邀优秀的前端工程师和Node.js工程师加入,一块儿作有趣的事情,欢迎有意者私信联系,或发送简历至 yangjian.fe_@bytedance.com。校招戳 这里 (一样欢迎实习生同窗。

相关文章
相关标签/搜索