进阶技巧
lazy
懒加载React.lazy
函数可以动态引入组件
React.suspense
支持组件等待加载完成前的加载过程中操作如:loading 加载中... 显示
语法
const OtherComponent = React.lazy(() => import('./OtherComponent'));
import React, { Suspense } from "react";
const OtherComponent = React.lazy(() => import("./OtherComponent"));
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
);
}
fallback 属性接受任何在组件加载过程中你想展示的 React 元素。你可以将 Suspense 组件置于懒加载组件之上的任何位置。你甚至可以用一个 Suspense 组件包裹多个懒加载组件。
import React, { Suspense } from "react";
const OtherComponent = React.lazy(() => import("./OtherComponent"));
const AnotherComponent = React.lazy(() => import("./AnotherComponent"));
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<section>
<OtherComponent />
<AnotherComponent />
</section>
</Suspense>
</div>
);
}
过渡效果
import React, { Suspense, startTransition } from "react";
import Tabs from "./Tabs";
import Glimmer from "./Glimmer";
const Comments = React.lazy(() => import("./Comments"));
const Photos = React.lazy(() => import("./Photos"));
function MyComponent() {
const [tab, setTab] = React.useState("photos");
function handleTabSelect(tab) {
startTransition(() => {
setTab(tab);
});
}
return (
<div>
<Tabs onTabSelect={handleTabSelect} />
<Suspense fallback={<Glimmer />}>
{tab === "photos" ? <Photos /> : <Comments />}
</Suspense>
</div>
);
}
当 tab 切换时,标签切换为"comments"不会标记为紧急更新,而是标记为需要一些准备时间的 transition,实现一个过渡效果。React 会保留旧的 UI 并进行交互,当它准备好时,会切换为 Comments 组件
Context
Context 实现了祖孙组件之间的通信,类似于 Vue 中的 provide 与 inject
Hook 函数式组件的 context 写法详见useContext
使用时机与考虑
Context 设计目的是为了共享自上而下的组件树中的“全局”数据,例如登录认证用户信息、主题或语言等
Context 主要应用于很多不同层级的组件需要访问共享、共同使用、同步刷新的数据
使用时需要考虑组件的复用性,因为 Context 的引入会使复用性变差
API
React.createContext
创建一个 Context 对象
const MyContext = React.createContext(defaultValue);
只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效
在组件中引入 MyContext 对象,并在 render 函数中使用<MyContext.Provider></MyContext.Provider>
来开启订阅
注意:当组件没有捕获到来自于祖先组件的 Provider 时使用 MyContext,其默认值才会是 defaultValue
Context.Provider
<MyContext.Provider value={/* 某个值 */}>
每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化
Provider 接收一个 value 属性,传递给消费组件
当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染,无论组件嵌套结构有多么复杂
Class.contextType
该 API 仅适用于类式组件,为 class 声明一个指向 Context 实例对象的 contextType 属性,会开启this.context
对象,允许使用 Context 中的 value,这些值可以在全生命周期中随时调用
class App extends Component {
static contextType = MyContext;
render() {
const context = this.context;
return (
// ...
)
}
}
Context.Consumer
<MyContext.Consumer>
{value => /* 基于 context 值进行渲染*/}
</MyContext.Consumer>
在 Consumer 内部,后代组件可以订阅到 Context 变更
注意:消费组件必须是<MyContext.Provider></MyContext.Provider>
中嵌套组件的后代组件,否则 value 参数为一开始createContext(defaultValue)
中的 defaultValue
Context.displayName
context 对象接受一个名为 displayName 的 property,类型为 string
React DevTools 检测工具中可以以此更名
写法
import { Component, createContext } from "react";
const themes = {
light: {
foreground: "#000000",
background: "#eeeeee",
},
dark: {
foreground: "#ffffff",
background: "#222222",
},
};
const ThemeContext = createContext({
theme: themes.light,
changeTheme: () => {},
});
class ClassContextApp extends Component {
state = {
themeSwitch: "dark",
};
changeTheme = () => {
const theme = this.state.themeSwitch;
this.setState({ themeSwitch: theme === "light" ? "dark" : "light" });
};
render() {
return (
<ThemeContext.Provider
value={{
theme: themes[this.state.themeSwitch],
changeTheme: this.changeTheme,
}}
>
<Toolbar />
</ThemeContext.Provider>
);
}
}
function Toolbar(props) {
return (
<>
<ThemedButton />
</>
);
}
class ThemedButton extends Component {
// 声明静态属性后,class中即可使用this.context获取上下文对象
static contextType = ThemeContext;
render() {
const { theme, changeTheme } = this.context;
return (
<button
style={{ background: theme.background, color: theme.foreground }}
onClick={changeTheme}
>
I am styled by theme context!@
</button>
);
}
}
export default ClassContextApp;
Fragments
Fragments 可以减少 render 渲染的节点嵌套,最外部元素在渲染时不会向 DOM 中添加父节点
// 渲染DOM时,只有ChildA ChildB ChildC的DOM元素
render() {
return (
<React.Fragment>
<ChildA />
<ChildB />
<ChildC />
</React.Fragment>
);
}
短语法
最简洁的写法,类似空标签
class Columns extends React.Component {
render() {
return (
<>
<td>Hello</td>
<td>World</td>
</>
);
}
}
带 key 的情况
使用显式 <React.Fragment> 语法声明的片段可能具有 key。一个使用场景是将一个集合映射到一个 Fragments 数组 - 举个例子,创建一个描述列表:
function Glossary(props) {
return (
<dl>
{props.items.map((item) => (
// 没有`key`,React 会发出一个关键警告
<React.Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.description}</dd>
</React.Fragment>
))}
</dl>
);
}
Portals
Portal:门,门户。此处可理解为传送门
该 API 可以将子节点渲染到父组件之外的 DOM 节点上,甚至可以添加到<body></body>
上
语法
ReactDOM.createPortal(child, container)
- 第一个参数 child:任意可渲染的子元素
- 第二个参数 container:DOM 元素
render() {
// React 并*没有*创建一个新的 div。它只是把子元素渲染到 `domNode` 中。
// `domNode` 是一个可以在任何位置的有效 DOM 节点。
return ReactDOM.createPortal(
this.props.children,
domNode
);
}
事件冒泡
虽然 portal 可以传送子元素到 DOM 树的任意地方,但是 React 树的挂载顺序是固定的,与 DOM 树中的位置无关,所以 React 的特性仍然不变
这其中就包含事件冒泡,一个从 portal 内部触发的事件会一直冒泡至包含 React 树的祖先,即便这些元素并不是 DOM 树 中的祖先
Render-props
Render-prop
属性接收一个函数,返回一个 React 元素并调用它,而非执行渲染
语法
<DataProvider render={(data) => <h1>Hello {data.target}</h1>} />
应用
以官方文档的鼠标跟踪 demo 为例,此处使用函数式组件简写
1.建立鼠标跟踪组件
function MouseTracker() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
function handleMouseMove(event) {
setX(event.clientX);
setY(event.clientY);
}
return (
<div style={{ height: "100vh" }} onMouseMove={handleMouseMove}>
<h1>移动鼠标</h1>
<p>
当前的鼠标位置是:{x}, {y}
</p>
</div>
);
}
以上组件实现的效果是,当光标在屏幕上移动时,组件在<p>
中显示其 x,y 坐标
2.普通的组件复用
function Mouse() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
function handleMouseMove(event) {
setX(event.clientX);
setY(event.clientY);
}
return (
<div style={{ height: "100vh" }} onMouseMove={handleMouseMove}>
{/* 此处可以是记录当前鼠标位置的文本信息,也可以是基于当前鼠标位置实现其他功能的封装组件
出于鼠标组件复用性考虑,此处位置应该空出给其他组件使用,就像Vue中的slot一样 */}
<p>
当前的鼠标位置是:{x}, {y}
</p>
</div>
);
}
function MouseTracker() {
return (
<>
<h1>移动鼠标</h1>
<Mouse />
</>
);
}
以上组件为封装后可跟随鼠标移动更新鼠标位置信息文本的组件,该组件的局限性在于,如果我们需要实现一个同样跟随鼠标移动做其他功能的组件时,除了注释之外的位置,其他的组件代码包括 x、y、handleMouseMove 全部都要重新写一遍,并没有真正达到组件复用的行为
Render-props
3.Render-props
实际上就是提供了组件插槽,在组件外部就能向组件内部指定的位置写入任何 jsx 内容,实现控制反转
function Cat(props) {
const { x, y } = props.mouse;
return (
<img src="/cat.jpg" style={{ position: "absolute", left: x, top: y }} />
);
}
function Mouse() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
function handleMouseMove(event) {
setX(event.clientX);
setY(event.clientY);
}
return (
<div style={{ height: "100vh" }} onMouseMove={handleMouseMove}>
{/* 开启props,具体渲染交给外层组件控制,Mouse只负责提供props */}
{props.render({ x, y })}
</div>
);
}
function MouseTracker() {
return (
<>
<h1>移动鼠标</h1>
{/* 实际效果是将Mouse内的{x,y}赋值mouse给Cat作为props */}
<Mouse render={(mouse) => <Cat mouse={mouse} />} />
</>
);
}
这样,通过提供像 slot 插槽一样的 render 方法,让<Mouse>
动态渲染其内部组件,避免了多次重复克隆<Mouse>
组件
具体来说,render prop
是一个告知组件其内部需要渲染什么内容的函数
注意:render prop
是因为这种特殊模式才被称为render prop
,render 的名字是不固定的,也可以是 formatter、create 这些名字
简写方式
在组件标签内部{}
书写的内容都视作 props.children 内容,从而可以简写为:
<Mouse>
{mouse => (
<p>鼠标的位置是:{mouse.x}, {mouse.y}</p>
)}
</Mouse>
// Mouse函数
function Mouse(props) {
// ...
return (
// ...
// 默认props.children获取函数
{ props.children(...) }
)
}
注意事项
在 render 函数或函数组件返回体中创建render prop
需要注意函数 rerender 的问题
如果在 render 函数或函数组件返回体中声明render prop
函数,那么当组件每次刷新渲染的时候,总会声明一个新的render prop
函数
为了绕过这一问题,优化渲染性能,可以声明一个函数后在绑定给 render
class MouseTracker extends React.Component {
// 定义为实例方法,`this.renderTheCat`始终
// 当我们在渲染中使用它时,它指的是相同的函数
renderTheCat(mouse) {
return <Cat mouse={mouse} />;
}
render() {
return (
<div>
<h1>Move the mouse around!</h1>
<Mouse render={this.renderTheCat} />
</div>
);
}
}
高阶组件
高阶组件(HOC)是 React 中用于复用组件逻辑的一种高级技巧,是一种基于 React 组合特性的特殊设计模式
语法
高阶组件参数为组件,返回值为新组件。HOC 本身是一个纯函数,没有副作用
const EnhancedComponent = higherOrderComponent(WrappedComponent);
应用场景
现假设项目中需要编写两个组件,分别是订阅评论与发布评论的组件、订阅博客帖子与发布博客帖子的组件,两者结构类似,假定它们的数据源都出自DataSource
,那么它们均需要实现:
- 挂载时,向 DataSource 添加一个更改侦听器
- 在侦听器内部,当数据源发生变化时,调用 setState
- 在卸载时,删除侦听器
为了避免这种重复逻辑带来的冗余代码,现在应用 HOC 来实现它:
function withSubscription(WrappedComponent, selectData) {
// 返回另一个组件
return function(props) {
// 根据props筛选data
const [data, setData] = useState(selectData(DataSource, props))
// 实时更新列表数据
function handleChange() {
setData(val => selectData(DataSource, props))
}
// 挂载时添加侦听
useEffect(() => {
DataSource.addChangeListener(handleChange)
return () => {
DataSource.removeChangeListener(handleChange)
}
})
return (
<WrappedComponent {data} {...props} />
)
}a
}
// 评论组件与博客组件复用
const CommentListWithSubscription = withSubscription(
CommentList,
(DataSource) => DataSource.getComments()
);
const BlogPostWithSubscription = withSubscription(
BlogPost,
(DataSource, props) => DataSource.getBlogPost(props.id)
);
被包装组件<WrappedComponent>
接收来自容器组件所有的 prop,同时也接收一个新的用于 render 的data prop
。HOC 不关心数据的使用方式或原因,同时被包装组件也不需要关心数据何处而来
与组件一样,withSubscription 函数和被包装组件之间的契约完全基于传递的 props。这种依赖方式使组件更换更加容易,解除了功能相同细节不同的组件之间的束缚,使得我们只需要专注于组件细节
注意事项
- 不要在 render 方法中使用 HOC。因为组件 rerender 重新挂载组件会导致该组件及其所有子组件已有状态丢失
- 务必复制静态方法。HOC 包装组件之后,原组件上的静态方法或原型方法将丢失,因此需要注意方法拷贝
Refs
不会被传递。HOC 的约定即传递 props 给被包装组件,对 refs 并不适用
Refs 转发
function FancyButton(props) {
return <button className="FancyButton">{props.children}</button>;
}
以该组件为例,FancyButton 内部的 button 元素被组件隐藏细节,因此外部组件的 ref 一般无法直接绑定 button 进行操作
为了操作其内部的 button,需要使用 Ref 转发来实现
语法
import { forwardRef, createRef } from 'react'
const FancyButton = forwardRef((props, ref) => {
<button {ref} className="FancyBUtton">
{props.children}
</button>
})
// 这里可以直接获取DOM button 的 ref
const ref = createRef();
<FancyButton ref={ref}>Click me</FancyButton>
语法执行分析:
- 调用
createRef
创建一个React ref
并将其赋值给 ref 变量 - 指定 ref 为 JSX 属性,将其向下传递给
<FancyButton ref={ref}>
- React 传递 ref 给 forwardRef 内的函数
(props, ref) => ...
,作为第二参数 - 我们向下转发该 ref 参数到
<button ref={ref}>
,指定为 JSX 属性 - 当 ref 挂载完成后,
ref.current
指向<button>
节点
注意
第二个参数 ref 只能在使用React.forwardRef
定义组件时存在,常规函数和 class 不接收 ref 参数,且 props 中野不存在 ref
ref 除了转发 DOM 组件,也能转发 class 或函数组件
高阶函数转发 refs
这个技巧对于高阶组件非常有用,以下是一个 demo 示例
function logProps(WrappedComponent) {
class LogProps extends Component {
componentDidUpdate(prevProps) {
console.log("old props:", prevProps);
console.log("new props:", this.props);
}
render() {
return <WrappedComponent {...this.props} />;
}
}
return LogProps;
}
class FancyButton extends React.Component {
focus() {
// ...
}
// ...
}
// 我们导出 LogProps,而不是 FancyButton。
// 虽然它也会渲染一个 FancyButton。
export default logProps(FancyButton);
为了让 refs 能准确绑定到高阶组件返回的组件上,需要使用React.forwardRef
function logProps(component) {
class LogProps extends Component {
componentDidUpdate(prevProps) {
console.log("old props:", prevProps);
console.log("new props:", this.props);
}
render() {
const { forwardedRef, ...rest } = this.props;
}
}
}
// ...
const ref = createRef();
return React.forwardRef((props, ref) => {
return <LogProps {...props} forwardedRef={ref} />;
});
显示自定义名称
如果你命名了渲染函数,DevTools 也将包含其名称(例如 “ForwardRef(myFunction)”)
const WrappedComponent = React.forwardRef(function myFunction(props, ref) {
return <LogProps {...props} forwardedRef={ref} />;
});
也可以直接通过设置静态 displayName 属性来命名
function logProps(Component) {
class LogProps extends React.Component {
// ...
}
function forwardRef(props, ref) {
return <LogProps {...props} forwardedRef={ref} />;
}
// 在 DevTools 中为该组件提供一个更有用的显示名。
// 例如 “ForwardRef(logProps(MyComponent))”
const name = Component.displayName || Component.name;
forwardRef.displayName = `logProps(${name})`;
return React.forwardRef(forwardRef);
}
错误边界
错误边界是一种 React 组件,这种组件可以捕获并打印发生在其子组件树任何位置的 JS 错误,并能渲染备用 UI 而不是渲染崩溃了的子组件树。错误边界在渲染期间、生命周期方法和整个组件树的构造函数中捕获错误。
语法及注意事项
注意
错误边界无法捕获以下错误
- 事件处理
- 异步代码(setTimeout 或 requestAnimationFrame 回调)
- 服务端渲染
- 它自身抛出的错误(并非它的子组件)
如果一个 class 组件中定义了 static getDerivedStateFromError()
或 componentDidCatch()
这两个生命周期方法中的任意一个(或两个)时,那么它就变成一个错误边界。当抛出错误后,请使用 static getDerivedStateFromError()
渲染备用 UI ,使用 componentDidCatch()
打印错误信息。
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;
}
}
// 包裹所需组件使用
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>;
注意
错误边界仅可以捕获其子组件的错误,它无法捕获其自身的错误。如果一个错误边界无法渲染错误信息,则错误会向最近的上层错误边界冒泡
应用场景
错误边界的粒度由实际开发情景决定,既可以将其包装在最顶层的路由组件并为用户展示一个错误信息,像服务端框架处理崩溃一样,也可以将单独的组件包装在错误边界以保护应用其他部分不崩溃
组件树卸载
从 React16 开始,任何未被错误边界捕获的错误将会导致整个 React 组件树被卸载,需要注意
事件处理
错误边界无法捕获事件处理器内部的错误,原因:事件处理器错误于 JS 执行栈触发,而错误边界捕获的是组件渲染的错误
在事件处理器内部捕获错误,仍需要使用普通的 try/catch 语句
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.state = { error: null };
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
try {
// 执行操作,如有错误则会抛出
} catch (error) {
this.setState({ error });
}
}
render() {
if (this.state.error) {
return <h1>Caught an error.</h1>;
}
return <button onClick={this.handleClick}>Click Me</button>;
}
}