很早总结的hooks的问题文章,内部讨论一直没想到啥最优解,发出来看看有没有人有更好的解法
最近rxjs作者ben lesh发了条推 https://twitter.com/benlesh/status/1195504467707355136?s=21 如此推所示,useCallback问题非常严重,社区也讨论了很多做法,但仍然有很多问题。
先回顾下hook之前组件的写法
class组件
export class ClassProfilePage extends React.Component {
showMessage = () => {
alert('Followed ' + this.props.user);
};
handleClick = () => {
setTimeout(this.showMessage, 3000);
};
render() {
return ;
}
}
functional组件
export function FunctionProfilePage(props) {
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
);
}
点击按钮,同时将user由A切换到B时,class组件显示的是B而function组件显示的是A,这两个行为难以说谁更加合理
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 (
state:{state}
// 点击始终显示的是快照值
// 点击始终显示的是最新值
);
}
const rootElement = document.getElementById("root");
ReactDOM.render( , rootElement);
https://codesandbox.io/s/dreamy-water-wkoeg
当你的应用里同时存在Functional组件和class组件时,你就面临着UI的不一致性,虽然react官方说function组件是为了保障UI的一致性,但这是建立在所有组件都是functional组件,事实上这假设几乎不成立,如果你都采用class组件也可能保证UI的一致性(都显示最新值),一旦你页面里混用了class组件和functional 组件(使用useref暂存状态也视为class组件),就存在的UI不一致性的可能
所以function和class最大区别只在于默认情况不同,两者可以相互转换,快照合理还是最新值合理,这完全取决于你的业务场景,不能一概而论
事实上在class里也可以拿到快照值,在function里也可以拿到最新值
class里通过触发异步之前保存快照即可
export class ClassProfilePage extends React.Component {
showMessage = (message) => {
alert('Followed ' +message);
};
handleClick = () => {
const message = this.props.user // 在触发异步函数之前保存快照
setTimeout(() =>showMessage(message)), 3000);
};
render() {
return ;
}
}
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 ;
}
其实就是个经典的函数闭包问题
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 (
query:{props.query}
result:{result}
)
}
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 (
setQuery(e.target.value)} value={query} />
)
}
上述代码存在的一个问题就是,每次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
问题:
为了更好的语义化和避免eslint的报错,可以自定义封装useDep来解决
useDepChange(() =>
fetchData().then(result => {
setResult(result);
})
},[fetchData])
},[queryToken]); // 只在dep变动的时候触发,约等于componentWillReceiveProps了
解决思路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])
问题:
// 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,对于一般业务开发效率和可维护性可能更加重要)
调用者约定:
被调用者约定
// 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 (
state.query = e.target.value} value={state.query} />
)
})
// 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 (
query: {props.query}
result:{result}
)
})