进阶技巧

Misaka10032进阶技巧扩展API性能优化大约 13 分钟

懒加载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 全部都要重新写一遍,并没有真正达到组件复用的行为

3.Render-props

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。这种依赖方式使组件更换更加容易,解除了功能相同细节不同的组件之间的束缚,使得我们只需要专注于组件细节

注意事项

  1. 不要在 render 方法中使用 HOC。因为组件 rerender 重新挂载组件会导致该组件及其所有子组件已有状态丢失
  2. 务必复制静态方法。HOC 包装组件之后,原组件上的静态方法或原型方法将丢失,因此需要注意方法拷贝
  3. 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>

语法执行分析:

  1. 调用createRef创建一个React ref并将其赋值给 ref 变量
  2. 指定 ref 为 JSX 属性,将其向下传递给<FancyButton ref={ref}>
  3. React 传递 ref 给 forwardRef 内的函数(props, ref) => ...,作为第二参数
  4. 我们向下转发该 ref 参数到<button ref={ref}>,指定为 JSX 属性
  5. 当 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>;
  }
}