React.memo使用

React.memo 用于包裹函数组件,返回一个高阶组件。

React.memo和React.PureComponent是一对概念,React.PureComponent是class组件的优化版本。

如要了解它们和普通没有缓存的组件的区别是什么,首先应该了解React的渲染原理,通常只要父级的组件重新render,不论子组件是否有改变都强制重新渲染,这样会产生一些不必要的渲染。

所以作为普通的class组件(即继承React.Component),shouldComponentUpdate方法总是返回true,而如果继承React.PureComponent的class组件,shouldComponentUpdate会判断组件的props或state是否发生变化(浅对比 shallow compare),如果未发生变化则返回false,从而略过渲染。

React.memo返回的高阶组件渲染行为和React.PureComponent是同样的

引用自官方文档

React.memo 仅检查 props 变更。如果函数组件被 React.memo 包裹,且其实现中拥有 useStateuseReducer 或 useContext 的 Hook,当 state 或 context 发生变化时,它仍会重新渲染。

示例:https://codesandbox.io/s/react-memo-demo-1z0zgn

以上示例App组件中有两个state:title和name,name作为子组件的属性。示例

  • 当title发生改变时,父组件render会造成子组件render,title并不会影响子组件的props,所以看到两种不同的表现,组件1和组件3父组件render时它们也会同时render,但组件2和组件4不受影响。
  • 当name发生改变时,只要和props和上次不同,子组件都会render。
  • 子组件内部的state发生变化时,它总会重新render。

究竟什么时候应该使用React.memo,要注意并非所有情况下都用React.memo返回高阶组件就是最有效率的做法,如果一个组件的属性多数情况下会每次都改变,那么React.memo就发挥不了什么作用,反而增加了缓存和对比 所花费的系统资源,这也是React作者没有将所有组件都这样处理的原因,并且渲染并不一定带来dom元素的真实改变,所以要根据实际情况因地制宜的判断。

useEffect-hook

useEffect和useLayoutEffect的区别

useEffect

基本上绝大多数的情况下使用useEffect就够了。需要注意的是useEffect的副作用调用不会阻塞dom元素的渲染,这和class组件componentDidMount或componentDidUpdate不同,componentDidMount或componentDidUpdate是dom更新后同步运行的,通常来说,如果不影响你的使用,useEffect这种运行方式有助于提高性能。

但是,如果你的副作用会导致dom元素的显示修改,这样会有两次dom修改,这时不应该使用useEffect,而应该使用useLayoutEffect。否则你会看到界面的修改产生闪动,通常会使用useLayoutEffect取代useEffect就是出于这种情况。

useLayoutEffect

useLayoutEffect和useEffect的使用方式完全一样。

useLayoutEffect的副作用调用在dom变更后马上发生,一般来说,如果你想在dom变更后测量一些dom元素的位置或者滚动条位置或者dom元素的样式后再设置新的dom显示,会使用useLayoutEffect。

这个运行的过程和componentDidMount或componentDidUpdate完全一样,代码会在dom更新后马上运行,这样允许你重新渲染一次dom。

总结

  • 如果dom更新后想通过更新后的dom属性再次渲染,使用useLayoutEffect。
  • 如果不需要dom更新或者dom更新无需被观测,则使用useEffect。

例子

useEffect的效果会产生一次闪烁,useLayoutEffect则没有刷新闪烁。

import React, { useState, useEffect, useLayoutEffect } from 'react';
import './index.less'

const random = () => {
  return 10 + Math.random() * 200
}

const App = () => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  useEffect(() => {
    if (count1 === 0) {
      setCount1(random());
    }
  }, [count1]);

  useLayoutEffect(() => {
    if (count2 === 0) {
      setCount2(random());
    }
  }, [count2]);

  return (
    <div className="useLayoutEffect">
      <div onClick={() => setCount1(0)}>useEffect:{count1}</div>
      <div onClick={() => setCount2(0)}>useLayoutEffect:{count2}</div>
    </div>
  );
};

export default () => <App />;

查看效果

react渲染行为详解

React渲染行为原理详解及总结

看到一篇redux作者写的介绍React 渲染行为原理的文章(找到中文版),里面也厘清了一些概念上的问题,很值得一读,以下做一些阅读方面的重点归纳。

如果不想看中间的内容可以直接跳到最后看总结

渲染过程概述

首先介绍Render的大体过程:

在渲染过程中:

  • React是从组件树的根节点循环向下寻找所有被标记为需要更新的组件
  • 对每个被标记为需更新的组件执行它的 classComponentInstance.render()方法(对于class组件)或FunctionComponent()方法(对于函数组件)。
  • 保存render输出

JSX元素 => 被解释为React.createElement(…) => createElement函数返回的是一个JavaScript object

// This JSX syntax:
return <SomeComponent a={42} b="testing">Text here</SomeComponent>

// is converted to this call:
return React.createElement(SomeComponent, {a: 42, b: "testing"}, "Text Here")

// and that becomes this element object:
{type: SomeComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}

然后React会把整个render出来的组件树对象和上一次的组件树对象做对比 => 收集不同之处用于做相应dom元素的更新。这个对比和计算的过程被称为 reconciliation 协调

文章里引用了一段React的作者之一的Dan Abramov的话,很有参考意义

I wish we could retire the term “virtual DOM”. It made sense in 2013 because otherwise people assumed React creates DOM nodes on every render. But people rarely assume this today. “Virtual DOM” sounds like a workaround for some DOM issue. But that’s not what React is.

React is “value UI”. Its core principle is that UI is a value, just like a string or an array. You can keep it in a variable, pass it around, use JavaScript control flow with it, and so on. That expressiveness is the point — not some diffing to avoid applying changes to the DOM.

It doesn’t even always represent the DOM, for example <Message recipientId={10} /> is not DOM. Conceptually it represents lazy function calls: Message.bind(null, { recipientId: 10 }).

大意就是说他希望现在不再用Virtual DOM这个概念解释React的渲染过程,2013年以前React或许是这样处理的,但是现在Virtual DOM会让人感觉React的reconciliation会创建dom元素,其实这是不正确的。

代替的是React用一种Value UI的概念(也就是上面所说JSX最后转换成的Object),这个阶段不会做任何产生DOM的事。

他举例<Message recipientId={10} />甚至渲染出来都不是一个DOM,它可以理解成产生了一个新的(滞后执行的)方法Message.bind(null, { recipientId: 10 })

渲染和提交阶段

区分渲染(render)和提交(commit)两个概念

  • 渲染就是reconciliation的过程
  • 提交才是更新到dom上的过程

提交后的过程

  • 更新ref对象
  • 执行componentDidMount或componentDidUpdate
  • 执行useLayoutEffet
  • 设置timeout,当到期的时候执行useEffect钩子

因此render和更新dom并不相同,render结束并不代表dom元素生效或者更改了,有两种情况render后不会更新dom

  • render结果和上一次完全一样
  • 并发模式下,当前更新可能会被抛弃

当初次渲染后,导致组件重新进入渲染队列的情况有:

  • 类组件
    • this.setState()
    • this.forceUpdate()
  • 函数组件
    • 调用useState的setter
    • 调用useReducer的dispatch
  • 其他
    • 调用ReactDOM.render — 效果等同与 forceUpdate

标准渲染行为

重点:React的默认render行为是这样的,只要父级元素执行了render,它会递归它下面所有的子元素都执行render过程。默认的render行为中,子元素的属性就算没有发生任何改变,只要父元素渲染了,子元素就会执行render。

React中渲染的规范

render方法必须是“纯”的,不能包含副作用。

render中不能做的事

  • 修改一个已存在的变量或object的值
  • 使用产生random 值的函数,比如Math.random()或Date.now()
  • 发起网络请求
  • 不能发起setState之类请求

render中可以做的事

  • 修改render函数中本地的变量
  • 抛出异常
  • “Lazy initialize” data that hasn’t been created yet, such as a cached value “懒初始化”一条还未创建的数据,比如缓存(不明白)

Fiber

React保存组件数据的核心结构叫做Fiber,Fiber包含的数据:

  • component type
  • props / state
  • 父级元素/兄弟/子元素的指针
  • 其他用于渲染的元数据

component是Fiber数据的表现层(facade)

hooks的工作原理,react将组件所有的hooks列表储存到一个fiber对象上,当React渲染一个函数组件时,它从fiber的hooks列表上得到hook的数据,每次当你调用一个hook的时候,它会返回储存在该hook描述对象中的相关数据。

当父组件渲染它内部的一个子组件的时候,它会创建一个fiber对象来跟踪这个组件,如果是类组件,它的内容就是new ClassComponent(props),组件的实例保存到fiber对象上,如果是函数组件,则就是运行FunctionComponent(props)的结果保存在fiber对象上。

组件类型和协调的关系

React如何知道要重新渲染?

首先对比type(基于===)

绝对不能在渲染阶段创建新的component类型,因为它每次都是一个新的引用,会造成react不停的销毁并重建这个component在整个树结构中。

🙅‍♂️错误的做法

function ParentComponent() {
  // This creates a new `ChildComponent` reference every time!
  function ChildComponent() {}

  return <ChildComponent />
}

🙆正确的做法

  // This only creates one component type reference
function ChildComponent() {}

function ParentComponent() {

  return <ChildComponent />
}

Key属性和协调的关系

重新渲染的条件,第二可以通过key属性(key是组件的伪属性,从component上读取不到一个叫key的属性),同一个位置的实例如果key不一样,代表它要重新创建(渲染)。

因此在列表组件的时候用index作为key有缺陷,比如有10个元素,渲染key从0…9,如果删掉了第6,7(index=5,index=6)个元素,然后又追加三个元素在后面,这时列表是11个元素,key从0…10。这时只比之前多了一个元素,前10个元素的dom元素还可以重用,这看起来不错。

但是想一下当前<TodoListItem key={6}>其实渲染的内容是原来的第8个元素,这样如果不重新渲染的话就出错了。所以React必须更新这个子元素的DOM,这样的运行效率是比较低的(删除的元素序号越靠前效率越低)。

因此如果用key={todo.id}才是正确的处理方式。

渲染的合并和时间问题

关于在同步过程中setState多次会合并不再赘述,不过有以下现象

const StateComponent = () => {
  const [counter, setCounter] = useState(-1)

  const onClick = async () => {
    setCounter(0)
    setCounter(1)

    await new Promise((resolve) => setTimeout(resolve, 0))

    setCounter(2)
    setCounter(3)
  }

  console.log('***', counter)

  return <button onClick={onClick}>render { counter }</button>
}

上面的代码会造成3次render(点下按钮,console.log打印了3次),以下是解释

This will execute three render passes. The first pass will batch together setCounter(0) and setCounter(1), because both of them are occurring during the original event handler call stack, and so they’re both occurring inside the unstable_batchedUpdates() call.

However, the call to setCounter(2) is happening after an await. This means the original synchronous call stack is done, and the second half of the function is running much later in a totally separate event loop call stack. Because of that, React will execute an entire render pass synchronously as the last step inside the setCounter(2) call, finish the pass, and return from setCounter(2).

The same thing will then happen for setCounter(3), because it’s also running outside the original event handler, and thus outside the batching.

componentDidMount, componentDidUpdate, 和useLayoutEffect 这些生命周期函数中允许你在render后再触发渲染逻辑,运用于以下场景:

  • 首次渲染后还需要更多数据显示
  • 在提交阶段(commit-phase)的生命周期,用ref测量dom元素的大小
  • 再根据这些值设置其他state
  • 用更新的数据立即重新渲染

React 总是在提交阶段的生命周期中使用同步运算,所以如果如果你想执行一次“部分->全部”的状态切换,其实屏幕上能看到的都是“全部”。

这段没体会,暂时不翻译了,但是最后说今后的react concurrent mode中,将总是合并update了。

Finally, as far as I know, state updates in useEffect callbacks are queued up, and flushed at the end of the “Passive Effects” phase once all the useEffect callbacks have completed.

It’s worth noting that the unstable_batchedUpdates API is exported publicly, but:

Per the name, it is labeled as “unstable” and is not an officially supported part of the React API

On the other hand, the React team has said that “it’s the most stable of the ‘unstable’ APIs, and half the code at Facebook relies on that function”

Unlike the rest of the core React APIs, which are exported by the react package, unstable_batchedUpdates is a reconciler-specific API and is not part of the react package. Instead, it’s actually exported by react-dom and react-native. That means that other reconcilers, like react-three-fiber or ink, will likely not export an unstable_batchedUpdates function.

For React-Redux v7, we started using unstable_batchedUpdates internally, which required some tricky build setup to work with both ReactDOM and React Native (effectively conditional imports depending on which package is available.)

In React’s upcoming Concurrent Mode, React will always batch updates, all the time, everywhere.

注意:React 在开发环境的<StrictMode>下总是会重复渲染,这样render和commit的次数是不对等的,所以你不能依靠console.log的打印次数判断渲染次数。取而代之应该用React DevTools Profiler 来捕获跟踪并计算整体提交的渲染数量。

优化渲染的三个途径

  • React.Component.shouldComponentUpdate 默认总返回true
  • React.PureComponent: 由于 props 和 state 的比较是最常见的实现方式shouldComponentUpdate,因此PureComponent基类默认实现了该行为,并且可以用来代替Component+ shouldComponentUpdate。
  • React.memo()

注意react对props和state的比较都使用浅比较。

有一些鲜为人知的技巧:当react组件返回的子元素和上次渲染返回的值完全一致的时候,react会略过渲染这个元素(和它的子树)。

  • 将props.children放在output里
  • 用useMemo返回的元素,只有在依赖关系发生改变时才会重新渲染
// The `props.children` content won't re-render if we update state
function SomeProvider({children}) {
  const [counter, setCounter] = useState(0);

  return (
    <div>
      <button onClick={() => setCounter(counter + 1)}>Count: {counter}</button>
      <OtherChildComponent />
      {children}
    </div>
  )
}

function OptimizedParent() {
  const [counter1, setCounter1] = useState(0);
  const [counter2, setCounter2] = useState(0);

  const memoizedElement = useMemo(() => {
    // This element stays the same reference if counter 2 is updated,
    // so it won't re-render unless counter 1 changes
    return <ExpensiveChildComponent />
  }, [counter1]) ;

  return (
    <div>
      <button onClick={() => setCounter1(counter1 + 1)}>Counter 1: {counter1}</button>
      <button onClick={() => setCounter1(counter2 + 1)}>Counter 2: {counter2}</button>
      {memoizedElement}
    </div>
  )
}


什么时候需要用useMemo和useCallback?不是每个function都要变成useCallback,只有当用和不用会影响子元素的渲染逻辑的时候才需要考虑。

另外,有人提问为什么不让每个组件默认都是被React.memo化的

Dan Abramov早已解答过这个问题,他指出缓存也会加大比较的开销,并且很多情况下缓存化的组件仍然会render,因为它的props发生变化了。

以下来自Dan的Twitter

Why doesn’t React put memo() around every component by default? Isn’t it faster? Should we make a benchmark to check?

Ask yourself:

Why don’t you put Lodash memoize() around every function? Wouldn’t that make all functions faster? Do we need a benchmark for this? Why not?

Immutability 和渲染的关系

react当中应该一直坚持用Immutability修改状态,这个毋需多言了。

上下文和渲染的关系

function GreatGrandchildComponent() {
  return <div>Hi</div>
}

function GrandchildComponent() {
    const value = useContext(MyContext);
    return (
      <div>
        {value.a}
        <GreatGrandchildComponent />
      </div>
}

function ChildComponent() {
    return <GrandchildComponent />
}

const MemoizedChildComponent = React.memo(ChildComponent);

function ParentComponent() {
    const [a, setA] = useState(0);
    const [b, setB] = useState("text");

    const contextValue = {a, b};

    return (
      <MyContext.Provider value={contextValue}>
        <MemoizedChildComponent />
      </MyContext.Provider>
    )
}

上面例子中,如果setA(42)

  • ParentComponent 会render
  • MemoizedChildComponent 不回 render
  • GrandchildComponent 因为消费了context,所以会render
  • GreatGrandchildComponent 也会render,因为GrandchildComponent render了!

建议:Proivder下的首个子元素应该用memo缓存起来,否则context值的改变会导致下面每个组件都rerender,而有效的memo可以阻断这种级联效应。

React-Redux和渲染的关系

直接上结论吧:

  • 使用connect的高阶组件可以视之为 PureComponent/React.memo(),除非它算出来的props改变了,才会重新渲染。
  • 如果用useSelector,则没有这种优势,只要父级render了,子元素就会render,如果想提高性能,应该在适当的地方使用React.memo()阻断这种级联的重复渲染。

总结

  • React在默认情况(没有用PureComponent和Memo)下总是递归渲染父组件下面的所有子组件
  • 渲染本身是没问题的,它是React知道哪些DOM需要改变的方式
  • 但是渲染需要花费时间(cpu资源),即便UI输出没有变化却有可能增加了很多“无效的渲染”
  • 大多数时候直接传一个新的引用类型(回调函数或对象)给属性是可以的
  • 当props没有变化的时候React.memo()可以略过不必要的渲染
  • 但如果你总是传新引用属性,React.memo()实际是无效的,反而还要耗费内存去缓存结果
  • 上下文可以让任意层级的组件获得它需要的上下文数据
  • 上下文比较前后值的引用(引用类型)确认上下文的值是否改变
  • 一个新的上下文值会导致所有消费该上下文值的组件重新渲染
  • 但是大部分情况下子组件渲染都是因为父组件执行了渲染
  • 所以通常来说,对context provider的子元素用React.memo包裹一下会减少很多不必要的渲染
  • 当一个组件因其消费的上下文值而重新渲染,它下级的子组件也会因此而重新渲染
  • React-Redux使用订阅方式检查store上的state是否改变,而不是通过改变上下文的值(provider的store值并没有变)
  • 订阅是紧接着store 上state更新发生的(同步)
  • React-Redux 框架做了很多工作确保只有相关属性变化的组件才会重新渲染
  • connect的行为很像React.mem(),因此多使用connect能够减少同时render的组件数量
  • useSelector是使用hook形式,因此它不能阻止因parent组件渲染而造成的子组件渲染,如果你的代码里到处用到useSelector,可能需要在一些关键地方使用React.memo()来减少大批量下层的组件递归刷新

还有一些使用context和redux的建议

  • 以下情况使用context解决:
    • 传递一些简单类型的数据,而且不经常改变
    • 当你在很多地方都需要用到一些状态或者方法,又不想一层层传递props的时候
    • 你不想必须使用更多额外的状态管理库,只想用当前的React方案解决问题的时候
  • 以下情况使用Redux解决:
    • 你有大量的应用状态,而且在不同的组件中都需用到这些数据的时候
    • app的状态常常发生改变
    • app状态改变的逻辑非常复杂
    • app的规模较大,需要多人协作完成的大项目

相关文章

arguments对象和参数的互相引用

学生问了一个问题

function funcA(arr) {
  arr[0] = arr[2]
}
function funcB(a, b, c=3) {
  c = 10
  funcA(arguments)
  return a + b + c
}
console.log(funcB(1,1,1)) // 12

而如果funcB的c没有默认值

function funcA(arr) {
  arr[0] = arr[2]
}
function funcB(a, b, c) {
  c = 10
  funcA(arguments)
  return a + b + c
}
console.log(funcB(1,1,1)) // 21

以上两段代码的结果不同,这种现象是为什么?

这涉及到两个知识点:

1)这是因为严格模式和非严格模式的时候arguments对象的行为有所不同:

非严格模式:arguments对象和形参是互相引用

function func(a) {
  arguments[0] = 99;   // 更新了arguments[0] 同样更新了a
  console.log(a);
}
func(10); // 99

function func(a) {
  a = 99;              // 更新了a 同样更新了arguments[0]
  console.log(arguments[0]);
}
func(10); // 99

严格模式:arguments对象和形参互相无关

function func(a) {
  "use strict"
  arguments[0] = 99;   // 不会更新a
  console.log(a);
}
func(10); // 10

function func(a) {
  "use strict"
  a = 99;              // 不会更新arguments[0]
  console.log(arguments[0]);
}
func(10); // 10

非严格模式下的这个行为是很讨厌的,这也是为什么我们不喜欢潜规则,如果你不小心,它就会把东西弄得很复杂。

2)当函数中使用了参数的解构、默认值或…rest参数,则函数内部自动按严格模式解释

参考:MDN-Arguments对象

尝试运行下面这个例子,观察结果

<script>
  function func(a) {
    arguments[0] = 99 // 更新了arguments[0] 同样更新了a
    console.log(a)
  }
  func(10) // 10
</script>

<script>
  function func(a) {
    arguments[0] = 99 // 更新了arguments[0] 同样更新了a
    console.log(a)
  }
  func(10) // 99
</script>

<script>
  function func(a=1) {
    arguments[0] = 99 
    console.log(a)
  }
  func(10) // 10
</script>

<script>
  function func(a, ...b) {
    arguments[0] = 99 
    console.log(a)
  }
  func(10) // 10
</script>