我自认不比写 React 的人更懂 React ,因此相关的哲学理念层面上的思考、相比传统方式的优劣的讨论我就不献丑了,请大家自行阅读这篇 官方文档 ,这篇文章只介绍 react-cache 的使用方式和原理。
Suspense
相信做过 React 代码分割的同学基本上对 Suspense 都比较了解,但是 Suspense 其实并不是局限于加载异步组件,而是有着一种更通用的范围。为了更好的理解 react-cache 的原理,我们事先需要了解 Suspense 的运作流程。
错误边界(Error Boundaries)
Suspense 的底层实现依赖于 错误边界(Error Boundaries) 组件,从描述中我们知道, 错误边界 是一种组件,生成一个 错误边界 组件也很容易,任何实现了 static getDerivedStateFromError() 静态方法的 class 组件 就是一个 错误边界 组件。
错误边界 组件的主要作用在于, **错误边界 组件能够捕获子组件(不包括自身) throw 出的 Error** ,如以下示例
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能够显示降级后的 UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 你同样可以将错误日志上报给服务器
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// 你可以自定义降级后的 UI 并渲染
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
错误边界 使我们在子组件树崩溃时,可以渲染 备用UI 而非 错误UI,那么这又和 Suspense 有什么关系呢?
刚才我们说了 错误边界 组件能够捕获子组件(不包括自身) throw 出的 Error ****,这句话其实并不是完全正确,应该是 错误边界 组件能够捕获子组件(不包括自身) throw 出的 任何东西 。可以将 Suspense 当做一种特殊 错误边界 组件,当 Suspense 捕获到子组件抛出的时 Promise 时会暂时挂起 Promise 渲染 fallback UI ,当其 Resolved 之后重新渲染。
react-cache
react-cache 暂时处于实验性阶段,是对 React 如何获取数据的一种新的思考方式
先来快速看下使用方式
// app.jsx
import { getTodos, getTodoDetail } from './api';
import { unstable_createResource as createResource } from 'react-cache';
import { Suspense, useState } from 'react';
const remoteTodos = createResource(() => getTodos());
const remoteTodoDetail = createResource((id) => getTodoDetail(id));
const Todo = (props) => {
const [showDetail, setShowDetail] = useState(false);
if (!showDetail) {
return (
<li onClick={() => {
setShowDetail(true);
}}>
<strong>{props.todo.title}</strong>
</li>
);
}
const todoDetail = remoteTodoDetail.read(props.todo.id);
return (
<li>
<strong>{props.todo.title}</strong>
<div>{todoDetail.detail}</div>
</li>
);
};
function App() {
const todos = remoteTodos.read();
return (
<div className="App">
<ul>
{todos.map(todo => (
<Suspense key={todo.id} fallback={<div>loading detail...</div>}>
<Todo todo={todo} />
</Suspense>
))}
</ul>
</div>
);
}
export default App;
// index.jsx
ReactDOM.render(
<React.StrictMode>
<Suspense fallback={<div>fetching data...</div>}>
<App />
</Suspense>
</React.StrictMode>,
document.getElementById('root')
);
效果演示
API
unstable_createResource
react-cache 有两个 API ,但是核心函数就一个 unstable_createResource ,我们使用他来创建适用于 Suspense 的数据拉取函数 remoteTodos 和 remoteTodoDetail 。
unstable_createResource 接受两个参数,第一个必选,第二个可选。第一个参数是是一个函数,其返回值要求必须是 Promise ,第二个参数是可选的,接受一个 哈希 函数,主要作用是为了区别在复杂输入情况下对应数据缓存的情况,这个部分等下会再讲,这里先带过。
unstable_setGlobalCacheLimit
用于设置全局的 react-cache 的缓存数量限制
原理
由于 react-cache 的代码很少,我们直接看下源码实现
export function unstable_createResource<I, K: string | number, V>(
fetch: I => Thenable<V>,
maybeHashInput?: I => K,
): Resource<I, V> {
const hashInput: I => K =
maybeHashInput !== undefined ? maybeHashInput : id => id;
// 简单输入的情况,默认哈希函数就够用了
const resource = {
read(input: I): V {
const key = hashInput(input);
// 生成特定输入对应的 key
const result: Result<V> = accessResult(resource, fetch, input, key);
switch (result.status) {
case Pending: {
const suspender = result.value;
throw suspender;
}
case Resolved: {
const value = result.value;
return value;
}
case Rejected: {
const error = result.value;
throw error;
}
default:
// Should be unreachable
return (undefined: any);
}
},
preload(input: I): void {
// react-cache currently doesn't rely on context, but it may in the
// future, so we read anyway to prevent access outside of render.
readContext(CacheContext);
const key = hashInput(input);
accessResult(resource, fetch, input, key);
},
};
return resource;
}
当你执行 unstable_createResource 后会返回一个带有 .read 和 .preload 方法的对象, .preload 很简单,我们着重讲下 .read
- 当在 React 组件里调用
.read()后 - 会通过
maybeHashInput()生成的 key 去查缓存 - 如果有 同步返回
- 没有则执行创建对象时传入的数据拉取函数,生成一个
Promise同时throw - 最近的祖先
Suspense组件捕获到这个Promise,挂起,渲染 fallback UI - 当
Promiseresolved后,react-cache内部监听了Promise,会将resolved的值设置到缓存中 - 同时
Suspense组件也发现Promiseresolved,重新渲染子组件 - 子组件再次执行
.read()方法,通过 key 去查缓存,发现已经缓存过,同步返回,然后进行渲染,自此整个流程结束
其他
react-cache 内部的缓存机制使用 LRU 策略,这里就不多讲了,整个使用下来最大感受其实是,我们在以一种同步的思维来写,即我们认为数据是已经存在的,我们只是做的读取操作,而非 拉取 。
const Todo = (props) => {
const [showDetail, setShowDetail] = useState(false);
if (!showDetail) {
return (
<li onClick={() => {
setShowDetail(true);
}}>
<strong>{props.todo.title}</strong>
</li>
);
}
const todoDetail = remoteTodoDetail.read(props.todo.id);
return (
<li>
<strong>{props.todo.title}</strong>
<div>{todoDetail.detail}</div>
</li>
);
};
不需要考虑如何使用 useEffect ,而是以一种自然而然方式书写组件:
- 读取数据
- 渲染 UI
而非
- 渲染组件
- 考虑 loading 态
- 触发生命周期
- 进行数据拉取
- 考虑拉取时组件状态
- 设置到 state 上
- 重新渲染 UI
让我们看看上述代码,如果使用传统的书写方式会是什么样
const Todo = (props) => {
const [showDetail, setShowDetail] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [todoDetail, setTodoDetail] = useState(null);
useEffect(() => {
if (showDetail) {
setIsLoading(true);
getTodoDetail(props.todo.id)
.then(todoDetail => setTodoDetail(todoDetail))
.finally(() => {
setIsLoading(false);
})
}
}, [showDetail, props.todo.id]);
if (isLoading) {
return <div>loading detail...</div>;
}
if (!showDetail) {
return (
<li onClick={() => {
setShowDetail(true);
}}>
<strong>{props.todo.title}</strong>
</li>
);
}
if (todoDetail === null) return null;
return (
<li>
<strong>{props.todo.title}</strong>
<div>{todoDetail.detail}</div>
</li>
);
};
对于想实际上手把玩下的,示例代码已推送到 github仓库
WARN
如果你尝试下载试用 react-cache ,那么大概率你会遇到 TypeError: Cannot read property 'readContext' of undefined 问题,从 issue 上看似乎是因为代码使用了未发布的私有的 context 相关的 API ,不过由于 context 相关的代码仅仅是处于 TODO 阶段,并没有实际的作用, 因此有两种解决方法
详见这两个 issue
- Cannot ready property 'readContext' of undefined #14575
- react-cache alphas don't work with 16.8+ #14780
解决办法
- 自己将
react-cache从node_modules里面复制出来,然后手动将readContext相关的代码注释掉 - 使用从 github 仓库源码直接构建的
react-cache,可以将以下代码写入到package.json,然后执行即可- 构建过程会用到 java,记得安装
"postinstall": "git clone https://github.com/facebook/react.git --depth=1 && cd react && yarn install --frozen-lockfile && npm run build react-cache && cd .. && npm i $(npm pack ./react/build/node_modules/react-cache) && rm -rf react react-cache-*.tgz"