很早总结的 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。校招戳 这里 (一样欢迎实习生同窗。