当React 18出现,create-react-app 创建项目遇到的小问题

一不留神,发现react版本已经升级至18,create-react-app里的react版本已经更新为18,但有一些模版还没改过来。

第一,如果安装模块说依赖于react@17.0的话,可以–force强制安装

npm i package-name --force

第二,v18修改了react-dom的使用方法,但因为用ts的模版创建的项目,react和react-dom的types还是老的,报告没有react-dom/client模块,因此在package.json中,删掉dependencies里的@types/react和@types/react-dom,重新安装

npm i @types/react @types/react-dom

这样再使用react-dom/client就可以了

import { createRoot } from 'react-dom/client'

const root = createRoot(document.getElementById('root') as Element)
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
)
无聊的谐音梗,“码”被提升

奇怪的变量提升现象

学生提了一个问题

var a = 100
{
  a = 10
  function a() {}
  a = 20
  console.log(a) // 20 
}
console.log(a) // 10

最后打印20和10。这确实是一个奇怪的现象,因为按照以前对函数提升的理解,函数的声明部分会提升到生命周期的最前,赋值会提升到代码块的最前,这个例子显然不符合这个规则,那是怎么回事呢?

stackoverflow上有相同问题,看过答案后,现在我对这段代码的理解是,function的声明在代码块中的提升会产生两个提升,一个是到生命周期的最前端,一个是到代码块的最前端成为一个局部变量(生命周期是块,类似于let),所以形成内外两个生命周期的变量。而且function提升的位置永远比同var更靠前。

这样上面的代码就变成(a1表示外部生命周期的a,a2表示块作用域中的a)

var a1 <-- function 提升到外部的声明
a1 = 100
{
  function a2() {}
  a = 10 <-- 解释器解释到这时候a指向a1,所以a1=10
  a1 = a2 <-- function留在原地的部分要用a1赋值a2
  a = 20 <-- 解释器解释到这的时候a2已经提升了,所以它是a2=20
  console.log(a) // 20 <-- 这里生命周期较近的是a2,所以打印20
}
console.log(a) // 10 <-- 这里是a1,打印10

另外当function在块作用域中提升后,效果类似于let,所以还有这样的效果

{
  var x = 1
  function x() {} // 报错,重复声明
}

还有这样一个效果

{ //block 1
  function foo() { // declared in block 1
    return 1;
  }
  console.log("block 1: foo() === 1", foo() === 1);
  
  { // block 2
    function foo() { // declared in block 2
      return 2;
    }
    console.log("block 2: foo() === 2", foo() === 2);
  }
  
  console.log("block 1: foo() === 1", foo() === 1);
}
console.log("block 1: foo() === 1", foo() === 1);

/* 结果
block 1: foo() === 1 true
block 2: foo() === 2 true
block 1: foo() === 1 true
block 1: foo() === 1 true
*/

运行到block 2(7到12行位置的function foo),发现全局有一个变量已经叫foo,并且它处于block1处的局部变量foo1控制之下,所以会在此代码块里生成一个局部变量foo2(类似let声明的)而不覆盖全局的foo并且区分开和foo1的生命周期,所以造成这种现象。

如果注释掉2到5行,则全局变量foo也会被赋值为 block2处的foo。

总之,推荐用"use strict"可以减少这种奇怪的现象

<script>
"use strict"
var a = 100
{
  a = 10
  function a() {}
  a = 20
  console.log(a) // 20
}
console.log(a) // 100
</script>

useSelector优化

在react函数组件中如果要访问state上的值应该用useSelector,这个函数本身使用很简单,但要注意优化,避免产生性能问题。

基本使用方法

const selectTodos = state => state.todos

const todos = useSelector(selectTodos)

文档上给出的注意要点:

注意:useSelector的结果会用 === 对比,所以如果返回值不同就会导致组件重新渲染,如果不小心在不必要返回新对象的时候返回了新的对象,你的组件会在每次action被触发的时候都重新刷新。

错误做法

// 错误做法
const selectTodoDescriptions = state => {
  // 每次返回一个新的对象
  return state.todos.map(todo => todo.text)
}

因为array.map方法返回的是新的对象,因而虽然每次的内容相同,但是返回的结果用===比较时是不同的,因此每次都会重新渲染。

正确做法

// 正确做法
const todos = useSelector(state => state.todos)

仍然存在问题

这样虽然能解决一点问题,但是对于需要过滤的selector却是无效,因为不论使用什么方法,filter过后的值总是会返回一个新的对象

const filteredTodos = (state, completed) => 
  state.todos.filter(todo => todo.completed === completed)

并且这种情况下使用useMemo也是比较麻烦的。

所以我们要解决的问题是,当选择的条件不变,并且相关数据也没有变化的时候,返回相同的数据。

所幸已经有人解决了这个问题,就是使用reselect库,@reduxjs/toolkit 也已经集成了这个库。

使用createSelector

假设有这样结构的state

{
  users: {ids: ['id1', 'id2'], entities: {id1: user1, id2: user2}}
}

如果使用普通的selector取得用户列表,在render方法中使用useSelector:

const users = useSelector((state) => {
  // run every times when rerender
  console.log("users selector called");
  return state.users.ids.map((id) => state.users.entities[id]);
});

这样虽然能达到效果,但是如果父组件刷新,不但每次都会执行selector,并且还会每次返回一个新的数组,导致列表重新渲染(虽然子组件如果用memo处理仍然是同一个user对象,props不变的情况下不会重新渲染)。

如果用了createSelector,可以解决这个问题。createSelector的调用方式如下:

createSelector(
  depends1, 
  depends2,
  ...,
  selector(depends1result, depends2result, ...) {
    return 结果
  }
)

depends的结构为:

(state, ...args) => dependsResult

args 就是传给selector的额外参数

如果没有任何depends,则selector的形式就是上面的depends函数形式。

之所以需要上面的depends,是因为如果每个依赖项的返回值跟上一次都一样的话,selector就直接返回缓存后的结果,避免重新运算。所以用createSelector版本写出来就是这样:

import { createSelector } from "@reduxjs/toolkit";

export const usersSelector = createSelector(
  (state) => state.users.ids,
  (state) => state.users.entities,
  (_, filter) => filter,
  (ids, users, filter) => {
    // when any of ids, users, filter arguments changes, this selector will run
    console.log("users selector called");
    switch (filter) {
      case "male":
      case "female":
        return ids
          .map((id) => users[id])
          .filter((user) => user.gender === filter);
      default:
        return ids.map((id) => users[id]);
    }
  }
);

这里还加入了filter参数,只要ids/entities/filter参数都没变化,则selector返回的结果不会重新计算,达到了优化效果。

更复杂的完整例子见链接:
https://codesandbox.io/s/useselector-demo-1q18xg?file=/src/App.js

React.forwardRef使用

关于forwardRef的使用做一下总结。

转发ref的目的是为了不会出现this.ref.ref.ref之类的情况,可以对外界暴露内部的ref,因此称为转发(forward)。

应用场景:

  • 获取深层次子孙组件的 DOM 元素
  • 在函数式组件上使用ref赋值(函数组件本身上ref的是无效的,因为它自己没有实例,但forwardRef返回的组件可以)
  • 传递 refs 到高阶组件

需要了解的:

  • ref并非react元素的属性,它经过了特殊处理,因此不能从props取得,类似的属性还有key
  • React.forwardRef只能用于函数组件
  • React.forwardRef返回高阶组件(HOC)
  • React.forwardRef的参数是一个函数组件或者说render函数
  • render函数组件第二个参数是ref,平时这个参数在函数组件中没用,只有作为forwardRef参数时才有效。

注意:forwardRef并不能解决直接选取到第三方组件库中的dom的作用,除非对方提供了forwardRef的支持。

最基本的使用

import React, { useRef, useImperativeHandle } from "react";

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    myfocus: () => {
      inputRef.current.focus();
    },
    myblur: () => {
      inputRef.current.blur();
    }
  }));

  return <input ref={inputRef} />;
}

const FancyInputWrapped = React.forwardRef(FancyInput);

const App = () => {
  const inputRef = useRef(null);

  const handleFocus = () => {
    inputRef.current.myfocus();
  };

  const handleBlur = () => {
    inputRef.current.myblur();
  };

  return (
    <div>
      <FancyInputWrapped ref={inputRef} />
      <button style={{ marginLeft: 8 }} onClick={handleFocus}>
        FOCUS
      </button>
      <button style={{ marginLeft: 8 }} onClick={handleBlur}>
        BLUR
      </button>
    </div>
  );
};

export default () => <App />;

查看:https://codesandbox.io/s/basic-forward-ref-2hjwky

高阶组件转发

注:实际上,我并没有从这个例子中看到特别有用的部分,其实我们没有fowardRef一样可以实现这个过程,如果你不想用forwardRef解决完全是可以的。

import React from "react";

const FocusInput = React.forwardRef((props, ref) => (
  <input type="text" ref={ref} />
));

function enhance(WrappedComponent) {
  class Enhance extends React.Component {
    render() {
      const { forwardedRef, ...restProps } = this.props;
      // 将定义的 prop 属性 forwardRef 定义为 ref
      return <WrappedComponent ref={forwardedRef} {...restProps} />;
    }
  }

  // 注意 React.forwardRef 回调的第二个参数 ref
  // 我们可以将其作为常规 prop 属性传递给 Enhance,例如 forwardedRef
  // 然后它就可以被挂载到被 Enhance 包裹的子组件上
  return React.forwardRef((props, ref) => (
    <Enhance {...props} forwardedRef={ref} />
  ));
}

// EnhancedChildComponet 会渲染一个高阶组件 enhance(FocusInput)
const EnhancedChildComponet = enhance(FocusInput);

// 我们导入的 EnahcnedComponent 组件是高阶组件(HOC)Enhance
// 通过 React.forward 将 ref 将指向了 Enhance 内部的 FocusInput 组件
// 这意味着我们可以直接调用 ref.current.focus() 方法
class App extends React.Component {
  private ref = React.createRef<HTMLInputElement>();

  constructor(props) {
    super(props);
  }
  handleFocus = () => {
    const { current } = this.ref;
    current.focus();
  };
  handleBlur = () => {
    const { current } = this.ref;
    current.blur();
  };
  render() {
    return (
      <>
        <EnhancedChildComponet ref={this.ref} />
        <button style={{ marginLeft: 8 }} onClick={this.handleFocus}>
          FOCUS
        </button>
        <button style={{ marginLeft: 8 }} onClick={this.handleBlur}>
          BLUR
        </button>
      </>
    );
  }
}

export default () => <App />;

查看:https://codesandbox.io/s/hoc-foward-ref-8z727h?file=/App.tsx

useImperativeHandle

此Hook必须配合forwardRef使用,其作用看起来是封装自己ref对象上的方法,其实也不是特别必要使用的技术。

import React, { useRef, useImperativeHandle } from 'react';

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    myfocus: () => {
      inputRef.current.focus();
    },
    myblur: () => {
      inputRef.current.blur();
    }
  }));

  return <input ref={inputRef} />;
}

const FancyInputWrapped = React.forwardRef(FancyInput);

const App = () => {

  const inputRef = useRef(null)

  const handleFocus = () => {
    inputRef.current.myfocus();
  }

  const handleBlur = () => {
    inputRef.current.myblur();
  }

  return (
    <div>
      <FancyInputWrapped ref={inputRef} />
      <button style={{ marginLeft: 8 }} onClick={handleFocus}>FOCUS</button>
      <button style={{ marginLeft: 8 }} onClick={handleBlur}>BLUR</button>
    </div>
  )
};

export default () => <App />

查看:https://codesandbox.io/s/ji-ben-yong-fa-forked-2hjwky?file=/App.tsx

参考

React.StrictMode下会渲染两次

不废话直接代码

App.js

import { useState } from "react";
import "./styles.css";

export default function App() {
  const [count, setCount] = useState(0);
  const promise = Promise.resolve("hello");
  console.log(promise);
  promise.then((c) => console.log(c));
  return (
    <div className="App">
      <h1 onClick={() => setCount(count + 1)}>count: {count}</h1>
    </div>
  );
}

index.js

import { StrictMode } from "react";
import ReactDOM from "react-dom";

import App from "./App";

const rootElement = document.getElementById("root");
ReactDOM.render(
  <StrictMode>
    <App />
  </StrictMode>,
  rootElement
);

看到每次渲染会打印两次hello,如果去掉index.js里的StrictMode则只会打印一次hello。

官方作者的说法是在StrictMode下的development环境里,会render两次,但是打印promise却只有一次!

更奇怪的是,如果仍然在StrictMode下,将第8行注释掉,第9行注释取消,则只会打印hello一次。这个我暂时无法理解😣

代码:https://codesandbox.io/s/strict-mode-render-twice-gxdick?file=/src/App.js

useMemo和useCallback

本文探究useMemo和useCallback两个Hook究竟应该如何使用。

比较useMemo和useCallback

首先,useMemo和useCallback在函数的参数方面类似

useMemo(callback, deps)
useCallback(callback, deps)

两者的形式的确相似,第一个参数都是回调函数,第二个参数是依赖关系(useEffect的依赖关系),先解释依赖关系

  • 当deps是null/undefined时候无论是挂载还是更新阶段都会返回新的值,所以在useMemo或useCallback中deps是null值是没有意义的!(但是对于useEffect是有意义的);
  • 当deps是空数组[],则只在挂载阶段返回一次值;
  • 当deps是变量列表时,如[a, b]则a或b中任何一个值发生改变则要返回新值。

这是useMemo和useCallback的相似之处,但实际上两个函数在参数的签名上不完全一样,第一个回调函数参数,useCallback是允许有参数的,但useMemo是没有参数的!

const memo = useMemo((a) => a+b, [b]) // 参数a没有机会传
const memo = useMemo(() => a+b, [a, b]) // 这样才可能是正常的

const handleClick = useCallback((e) => { // 这是很常见的
  e.preventDefault()
  ...
}

useMemo和useCallback 的返回值不同

  • useMemo返回的是回调函数运行后的返回值
  • useCallback返回的是回调函数

可以把下面的代码视为同等

const handleClick = useCallback((page) => dispatch(fetchFeeds(page)), [dispatch])
const handleClick = useMemo(() => (page) => dispatch(fetchFeeds(page)), [dispatch])

这里用useMemo返回了一个function,和上一行useCallback做的事情一样,只不过通常我们并不会这样写。

useMemo要点

useMemo的主要作用在于如果组件产生一次新的渲染,只要缓存值的依赖关系没有变,就仍然返回上次缓存的值,节省了运算时间,并且保持计算值不变(有可能避免额外的子组件渲染)。

官网说明也需要注意

你可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证。将来,React 可能会选择“遗忘”以前的一些 memoized 值,并在下次渲染时重新计算它们,比如为离屏组件释放内存。先编写在没有 useMemo 的情况下也可以执行的代码 —— 之后再在你的代码中添加 useMemo,以达到优化性能的目的。

其实就是说缓存值并不一定是缓存的,缓存有可能丢失!

另外要注意无论是useMemo还是useCallback它们并不会把历史缓存值返回,返回的都是上一次缓存住的值!

useCallback的使用

有人在凡是用到回调函数的地方不论什么情况都用useCallback处理一下,认为这样能提高性能,是不对的,不清楚useCallback的工作原理反而可能会造成效率下降运行变慢。我们必须厘清在什么情况下才应该使用useCallback。

首先,之所以要使用useCallback是为了避免不必要的属性变化而造成子组件重新渲染,从这个角度来说,直接绑定在dom上的回调事件没有必要用useCallback处理

function MyComponent() {
  const handleClick = useCallback(() => {...}, [...]) // 这样没什么用
  return (<button onClick={handleClick}></button>)
}

要正确使用useCallback,需要理解function是一种引用类型,在闭包内创建的function每次都会是一个新的对象,所以如果这样将这个函数作为参数传给子组件的时候,虽然函数做的事情没有变,但是作为函数的对象已经发生了改变,则会造成子组件重新渲染,所以为了避免这种情况,应该使用useCallback。

假设,我们渲染一个很长的数组列表,所有列表元素上都有一个父元素传来的onClick回调,这时如果父组件刷新,某个不应该影响子组件渲染的属性发生改变,如果用了useCallback将减少很多不必要的子组件渲染。

示例:https://codesandbox.io/s/usecallback-demo-gozqsm?file=/src/App.js

此示例中有一个大列表,如果传给它的onItemClick回调用useCallback处理和不用它处理的效果不同(需要注意的是,MyList是一个memo后的组件,否则父组件只要重新render,即使子组件的属性完全没变也仍然会渲染)。现在当父组件重新渲染时handleClickCached函数每次都是一样的值,因此list2不会重新渲染,而list1重新渲染其下的每个子组件也会随之重新渲染造成资源浪费

因此要注意区分开什么情况下才应该使用useCallback,useCallback的返回值并非没有开销,如果是没有大量的子组件渲染,则不必一定useCallback处理

最后,凡是开发过程中其实都可以先不用useMemo或useCallback处理,只在事后优化的时候再酌情添加,而且这些值的使用往往也要和React.memo配合

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>