你应该在 React 中传递 Promise
前言
展示加载态 & 错误态, 可以使用 React 自身能力
// ❌️
export default () => {
return error ? <div>error</div>
: loading ? <div>loading</div>
: data
}
// ✅️ Suspense & ErrorBoundery 不一定在当前组件使用
// 会一直冒泡到最外层, 如触发页面级 loading, 而无需额外通讯
export default () => {
return <Suspense fallback={<div>loading</div>}>
<ErrorBoundery fallback={<div>error</div>}>
...
</ErrorBoundery>
</Suspense>
}
如何才能拿到 Promise 的加载态 & 错误态
// ❌️
export default () => {
const [error, setError] = useState();
const [loading, setLoading] = useState();
const [data, setData] = useState();
useEffect(() => {
setLoading(true)
fetcher('xxx').then((data) => {
setLoading(false);
setData(data)
}).catch((error) => {
setError(error);
})
}, []);
return error ? <div>error</div>
: loading ? <div>loading</div>
: data
}
// ❌️ react-use
export default () => {
const { error, loading, data } = useAsync(() => fetcher(xxx), []);
return error ? <div>error</div>
: loading ? <div>loading</div>
: data
}
// ✅️
const promise = fetcher('xxx');
export default () => {
return <Suspense fallback={<div>loading</div>}>
<ErrorBoundery fallback={<div>error</div>}>
<Await resolver={promise}></Await>
</ErrorBoundery>
</Suspense>
}
谁在这么用: ReactRouter
他消费 Promise 的方式并不是最优的,但这不是重点。
而是我们有了另外一个选择,在 Props 中传递 Promise 而不是其返回的 data.
优势
首屏网络请求不再是副作用
// ❌️ 重新 render 会再次触发请求
export default () => {
const resultP = useRef(fetcher('xxx'));
}
// ❌️ 重新 render 会再次触发请求
export default () => {
const [resultP, setResultP] = useState(fetcher('xxx'));
}
// ❌️ 造成没必要的重新 render
export default () => {
const [resultP, setResultP] = useState();
useEffect(() => {
setResultP(fetcher('xxx'))
}, []);
}
// ❌️ 重发请求无法触发 render
export default () => {
const resultP = useRef(fetcher('xxx'));
useEffect(() => {
resultP.current = fetcher('xxx')
}, []);
}
// ❌️ react-use 多此一举的 useRefState
// ✅️
export default () => {
const [resultP, setResultP] = useState(() => fetcher('xxx'));
}
// ✅️
export default () => {
const resultP = useMemo(() => fetcher('xxx'), []);
}
如何重发请求, 状态里保存新的 Promise 就是了
// ✅️ 请求函数想调用就调用, 何必使用 react-use 要区分 useAsync / useAsyncFn / useAsyncRetry
export default () => {
const [resultP, setResultP] = useState(() => fetcher('xxx'));
return <>
<FilterForm onSubmit={(filterCondition) => {
setResultP(fetcher(filterCondition))
}} />
<Suspense fallback={<div>loading</div>}>
<ErrorBoundery fallback={<div>error</div>}>
<Await resolver={promise}>
<Table />
</Await>
</ErrorBoundery>
</Suspense>
</>
}
只是为了代码好看吗? 还巧妙的解决了异步竞态问题
bytedance.feishu.cn/docx/NFoddu…
// ❌️ 多次修改筛选条件造成网络请求竞态
export default () => {
const [filterCondition, setFilterCondition] = useState();
const [error, setError] = useState();
const [loading, setLoading] = useState();
const [data, setData] = useState();
useEffect(() => {
setLoading(true)
fetcher(filterCondition).then((data) => {
setLoading(false);
setData(data)
}).catch((error) => {
setError(error);
})
}, [filterCondition]);
return <>
<FilterForm onSubmit={(filterCondition) => {
setFilterCondition(filterCondition)
}} />
{error ? <div>error</div>
: loading ? <div>loading</div>
: data}
</>
}
局部加载还可以提升用户体验
www.infoxicator.com/react-route…
将首屏请求提前到路由加载, 而不会阻塞页面进入
// ❌️ react-router 中的 loader 会阻塞页面进入
createBrowserRouter([
{
element: <Teams />,
path: "teams",
loader: async () => {
return await fetcher('xxx');
}
},
]);
// ✅️
createBrowserRouter([
{
element: <Teams />,
path: "teams",
loader: () => {
return defer({
resultP: fetcher('xxx')
});
}
},
]);
为什么 Promise 代表未来, React 社区推出新 api
bytedance.feishu.cn/docx/NFoddu…
// ⏸️ react unstable-api
import { use } from 'react';
const ChildComponent = () => {
const [resultP, setResultP] = useState();
const data = use(resultP)
return <>
<FilterForm onSubmit={(filterCondition) => {
setResultP(fetcher(filterCondition))
}} />
<Table data={data} />
<>;
}
export default () => {
return <>
<Suspense fallback={<div>loading</div>}>
<ErrorBoundery fallback={<div>error</div>}>
<ChildComponent />
</ErrorBoundery>
</Suspense>
</>
}
// ✅️ 基于 react-router <Await /> + useAsyncValue 的实现
const ChildComponent = () => {
const data = useAsyncValue()
return <>
<Table data={data} />
<>;
}
export default () => {
const [resultP, setResultP] = useState();
return <>
<FilterForm onSubmit={(filterCondition) => {
setResultP(fetcher(filterCondition))
}} />
<Suspense fallback={<div>loading</div>}>
<Await resolver={resultP} errorElement={<div>error</div>}>
<ChildComponent />
</Await>
</Suspense>
</>
}
使用
父子组件如何传递 Promise, 和传普通状态一样
// ✅️ 通过 Props 直接传入 promise
// ✅️ 通过 React Context 传递 promise
// ✅️ 通过状态库保存 promise
与 reduck 结合使用, 比想象的更加简洁
const fetcherModel = model('fetcher');
// ✅️
const FilterForm = () => {
const [state, actions] = useModel(fetcherModel);
return <Form>
<Button onClick={() => actions.setState(fetcher('xxx'))}}></Button>
</Form>
}
// ✅️
const ShowData = () => {
const [state] = useModel(fetcherModel);
return <Suspense fallback={<div>loading</div>}>
<ErrorBoundery fallback={<div>error</div>}>
<Await resolver={state}></Await>
</ErrorBoundery>
</Suspense>
}
// ✅️ 状态库将不再有副作用
const fetcherModel = model('fetcher').define({
state: {
resultP: null
},
computed: {
some(state) {
return state.resultP?.then((data) => {
return ... //数据处理
})
}
},
actions: {
doSomeThing(state) {
state.resultP = fetcher('xxx')
},
// ❌️ 省去多余状态
load: handleEffect({ result: 'items', pending: false, error: false }),
},
// ❌️ 不再有副作用
effects: {}
})
进阶
我的 React / ReactRouter 版本不够怎么办,自己实现
// 基于 loadable 实现 <Await /> & useAsyncValue();
// ⏸️ 仅供参考, 推荐下文中基于 use() api 的实现
import loadable from "@loadable/component";
import React, { ReactNode, createContext, useContext } from "react";
const AsyncDataContext = createContext<unknown>(undefined);
export const Await = loadable(
async (props: {
resolver: Promise<unknown>;
children?: ReactNode | ((data: any) => ReactNode);
}) => {
const { resolver } = props;
const data = await resolver;
return (props) => {
const { children } = props;
if (typeof children === "function") {
return children(data);
}
return (
<AsyncDataContext.Provider value={data}>
{children}
</AsyncDataContext.Provider>
);
};
},
{
cacheKey: ({ resolver }) => resolver,
}
) as unknown as <T>(props: {
resolver: Promise<T>;
children?: ReactNode | ((data: T) => ReactNode);
}) => ReactNode;
export const useAsyncValue = () => {
return useContext(AsyncDataContext);
};
实现 use() api
// ✅️
function use(promise) {
if (promise.status === 'fulfilled') {
return promise.value;
} else if (promise.status === 'rejected') {
throw promise.reason;
} else if (promise.status === 'pending') {
throw promise;
} else {
promise.status = 'pending';
promise.then(
result => {
promise.status = 'fulfilled';
promise.value = result;
},
reason => {
promise.status = 'rejected';
promise.reason = reason;
},
);
throw promise;
}
}
基于 use() api 实现 <Await />
// ✅️ 这个可以取代 loadable 的实现版本
export const Await = <T>(props: {
resolver: Promise<T>;
children?: ReactNode | undefined | ((data?: T) => ReactNode);
}) => {
const { resolver, children } = props;
const data = use(resolver);
if (typeof children === 'function') {
return children(data);
}
return children;
};
例外, 我就是要获取 loading 状态怎么办
// ❌️ react-use 何必要区分 useAsync / useAsyncFn / useAsyncRetry
export default () => {
const { error, loading, data } = useAsync(() => fetcher(xxx), []);
return <Loading loading={loading}>
<Table />
</Loading>
}
// ✅️
const promise = fetcher('xxx');
export default () => {
const { error, loading, data } = usePromise(promise);
return <Loading loading={loading}>
<Table />
</Loading>
}
// ✅️ 实现 usePromise()
type PromiseCanUse<T> = Promise<T> & {
status: 'pending' | 'fulfilled' | 'rejected';
reason: unknown;
value: T;
};
function usePromise<T>(promise?: PromiseCanUse<T>) {
const [_, forceUpdate] = useState({});
const ref = useRef<PromiseCanUse<T>>();
if (!promise) return { loading: false, data: promise };
ref.current = promise;
if (!promise.status) {
promise.status = 'pending';
promise
.then(
result => {
promise.status = 'fulfilled';
promise.value = result;
},
reason => {
promise.status = 'rejected';
promise.reason = reason;
},
)
.finally(() => {
setTimeout(() => {
if (ref.current === promise) { // 最新的 promise 才会触发 forceUpdate
forceUpdate({});
}
}, 0);
});
}
return {
loading: promise.status === 'pending',
data: promise.value,
error: promise.reason,
};
}
总结
传统网络请求创建 Promise 后, 会立即使用 useEffect 消费掉 Promise 转换成 loading & data || error.
而现在, 你应该在组件内或跨组件传递 Promise.
⭐️⭐️⭐️ 在你的项目中使用
// ✅️ 一个文件导出 usePromise() / use() / <Await />
import { ReactNode, createContext, useContext, useRef, useState } from 'react';
type PromiseCanUse<T> = Promise<T> & {
status?: 'pending' | 'fulfilled' | 'rejected';
reason?: unknown;
value?: T;
};
/**
* 在当前组件使用 loading/error
*/
export function usePromise<T>(promise?: PromiseCanUse<T>) {
const [_, forceUpdate] = useState({});
const ref = useRef<PromiseCanUse<T>>();
if (!promise) return { loading: false, data: promise };
ref.current = promise;
if (!promise.status) {
promise.status = 'pending';
promise
.then(
result => {
promise.status = 'fulfilled';
promise.value = result;
},
reason => {
promise.status = 'rejected';
promise.reason = reason;
},
)
.finally(() => {
setTimeout(() => {
if (ref.current === promise) {
forceUpdate({});
}
}, 0);
});
}
return {
loading: promise.status === 'pending',
data: promise.value,
error: promise.reason,
};
}
/**
* 在父级/祖父级组件中使用 Suspense/ErrorBoundery 接收 loading/error
*/
export function use<T>(promise?: PromiseCanUse<T>) {
if (!promise) return promise;
if (promise.status === 'fulfilled') {
return promise.value;
} else if (promise.status === 'rejected') {
throw promise.reason;
} else if (promise.status === 'pending') {
throw promise;
} else {
promise.status = 'pending';
promise.then(
result => {
promise.status = 'fulfilled';
promise.value = result;
},
reason => {
promise.status = 'rejected';
promise.reason = reason;
},
);
throw promise;
}
}
const AsyncDataContext = createContext<unknown>(undefined);
/**
* 在当前组件或父级/祖父级组件中使用 Suspense/ErrorBoundery 接收 loading/error
*/
export const Await = <T,>(props: {
resolver?: Promise<T>;
children?: ReactNode | undefined | ((data?: T) => ReactNode);
}) => {
const { resolver, children } = props;
const data = use(resolver);
if (typeof children === 'function') {
return children(data);
}
return (
<AsyncDataContext.Provider value={data}>
{children}
</AsyncDataContext.Provider>
);
};
/**
* 在当前组件接收来自父级 <Await /> 组件的 data
* @deprecated 不推荐使用, 会丢失 ts 类型
*/
export const useAsyncValue = () => {
return useContext(AsyncDataContext);
};
实例演练
- 实现按钮的权限控制
使用场景
const MyComponent = () => {
const xPermission = usePermission('xButton');
return <div>
...someThing 展示
{xPermission ? <Button onClick={...}>x</Button>}
</div>
}
实现
设 fetchPermission 为获取权限的网络请求
import { createContext, useContext, useMemo, use } from 'react';
const PermissionContext = createContext(undefined);
export const Provider = () => {
const promise = useMemo(() => fetchPermission(), []);
return <PermissionContext.Provider value={promise}></PermissionContext.Provider>;
};
export const usePermission = (functionCode?: string) => {
const promise = useContext(PermissionContext);
const permissions = use(promise); // loading 会由上层最近的 Suspense 承接.
if (!functionCode) return true;
return permissions?.find(functionCode)
}
如果你想用 set 来优化数组性能
import { createContext, useContext, useMemo } from 'react';
const PermissionContext = createContext(undefined);
export const Provider = () => {
const promise = useMemo(
() => fetchPermission().then(permissions => new Set(permissions)),
[],
);
return <PermissionContext.Provider value={promise}></PermissionContext.Provider>;
};
export const usePermission = (functionCode?: string) => {
const promise = useContext(PermissionContext);
const permissionSet = use(promise);
if (!functionCode) return true;
return permissionSet?.has(functionCode)
}
一个 hook 实现, 避免将逻辑散落在状态管理.
杂谈
你真的会使用 await 吗, 你的行为可能正在劣化性能
// ❌️
export default async () => {
await fetcher('some1')
await fetcher('some2')
}
// ✅️
export default async () => {
return Promise.all([fetcher('some1'), fetcher('some2')])
}
注意事项
use(promise) 能够在 loading 结束后正常渲染的前提是loading前后的 promise 需要是同一个值,而 use(Promise.all(xxx)) 会在每次 render 的时候创建一个新的 promise 就会一直触发 loading 态。
解决方案是把会生成新 Promise 的运算都放在一个 useMemo 中。
扩展阅读
转载自:https://juejin.cn/post/7268187099525349431