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。校招戳 這裏 (一樣歡迎實習生同窗。

相關文章
相關標籤/搜索