很早總結的 hooks 的問題文章,內部討論一直沒想到啥最優解,發出來看看有沒有人有更好的解法css
最近 rxjs 做者 ben lesh 發了條推 twitter.com/benlesh/sta… 如此推所示,useCallback 問題很是嚴重,社區也討論了不少作法,但仍然有不少問題。html
先回顧下 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 不一致性的可能編程
因此 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>;
}
複製代碼
其實就是個經典的函數閉包問題
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 優化
咱們發現 react 默認的重渲染機制壓根沒有對 props 作任何假設,性能優化徹底交給框架去作,react-redux 基於 shouldComponent, mobx-react 基於 this.forceUpdatehooks 來作一些性能優化
咱們發現即便不用 hooks 自己 functional 組件和 class 組件表現就存在較大差別,因爲 hook 目前只能在 function 組件裏使用,這致使了一些原本是 functional 組件編程思惟的問題反映到了 hooks 上。
hooks 的使用引入了兩條強假設,致使了編程思惟的巨大變更
上述兩條帶來了很大的心智負擔
這兩個問題是硬幣的兩面,一般爲了解決一個問題,可能致使另一個問題
一個最簡單的 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 裏上報埋點怎麼辦)
再也不 useEffect 裏監聽 fetchData: 致使 stale closure 問題 和頁面 UI 不一致
useEffect(() => {
fetchData().then(result => {
setResult(result);
})
},[]) // 去掉fetchData依賴
複製代碼
此時一方面父組件 query 更新,可是子組件的搜索並未更新可是子組件的 query 顯示卻更新了,這致使了子組件的 UI 不一致
在思路 1 的基礎上增強刷 token
// child
useEffect(() => {
fetchData().then(result => {
setResult(result);
})
},[refreshToken]);
// parent
<Child fetchData={fetchData} query={query} refreshToken={query} />
複製代碼
問題:
爲了更好的語義化和避免 eslint 的報錯,能夠自定義封裝 useDep 來解決
useDepChange(() =>
fetchData().then(result => {
setResult(result);
})
},[fetchData])
},[queryToken]); // 只在dep變更的時候觸發,約等於componentWillReceiveProps了
複製代碼
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])
複製代碼
問題:
<Button onClick={clickHandler} /> // onClick改變會觸發Button的effect嗎?
複製代碼
使用 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]);
}
複製代碼
這仍然存在問題,
擁抱 mutable,實際上這種作法就是放棄 react 的快照功能(變相放棄了 concurrent mode ),達到相似 vue3 的編碼風格
實際上咱們發現 hook + mobx === vue3, vue3 後期的 api 實際上能用 mobx + hook 進行模擬
問題就是: 可能放棄了 concurrent mode (concurrent mode 更加關注的是 UX,對於通常業務開發效率和可維護性可能更加劇要)
調用者約定:
被調用者約定
不要把 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>
)
})
複製代碼
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>
);
}
複製代碼
這也是我以爲可能的最佳解法了,核心問題仍是在於 js 語言對於併發 | immutable | 函數式編程的羸弱支持如(thread local object | mutable, immutable 標記 | algebraic effects 支持),致使 react 官方強行在框架層面對語言設施進行各類 hack,引發了各類違反直覺的東西,換一門語言作 react 多是更好的方案(如 reasonml)。
插播一條廣告。字節跳動誠邀優秀的前端工程師和Node.js
工程師加入,一塊兒作有趣的事情,歡迎有意者私信聯繫,或發送簡歷至 yangjian.fe_@bytedance.com。校招戳 這裏 (一樣歡迎實習生同窗。