react学习笔记-1 - Shiina's Blog

react学习笔记-1

2024 年 5 月 26 日 - 18:43:15 发布
9.5K 字 32分钟
本作品采用 CC BY-NC-SA 4.0 进行许可。
本文距离首次发布已经过去了 38 天,请注意文章的时效性!

react学习笔记-1

前言

本学习笔记参考 https://react.iamkasong.com/

本片是 React 理念篇 阅读后的笔记与理解

React 理念

在 React 出现之前 web 开发的难点是什么?

一个是架构,另一个是大量计算与 DOM 操作下的快速响应实现

前面还好,只要你有足够的设计模式基础与丰富的代码经验,这里的难度就不是很大,就是需要花时间设计模块与接口

后面则是pc的性能瓶颈,这个不是靠设计模式就能解决的

众所周知浏览器的js执行环境是单线程,这没法依赖多线程并行的优势

而且一旦 js 执行的任务时间长了,就会阻塞渲染线程,导致交互卡顿,浏览体验下降了甚多,十分甚至九分的难受啊

还有一个是网络请求的响应,某些操作需要发送网络请求并等待数据返回才能进一步操作导致不能快速响应,但是个人认为这个是受网络的客观因素影响,而不是受性能影响,所以在下文不会针对这个问题详细讨论

我们接下来先看性能问题以及 react 是如何解决的。

性能问题与分时处理任务

接下来展示一个渲染Demo,我们向视图中渲染 3000 个li:

function App() {
  const len = 3000;
  return (
    <ul>
      {Array(len)
        .fill(0)
        .map((_, i) => (
          <li>{i}</li>
        ))}
    </ul>
  );
}

const rootEl = document.querySelector("#root");
ReactDOM.render(<App />, rootEl);

一般家用PC的屏幕刷新率为 60hz,相当于每 16.6ms 要渲染一帧,于是在每一帧浏览器需要完成如下的事情:

JS脚本执行 -----  样式布局 ----- 样式绘制

但是假如js此时来了个计算密集型任务时,花费的时间就超过了每一帧的限制,因此就会出现操作卡顿 + 掉帧的问题。

react 是如何解决这个问题的?答案是他使用了类似操作系统的调度原理,每次给 js 分配一个时间片,这段时间 js 可以执行他的任务,当时间片用尽,则暂停任务并让出线程来更新 DOM。每一帧都是这样。

类似于操作系统,每个进程都可以分配到一定的时间片,时间片用尽则进入就绪状态并把 CPU 让给其他进程,操作系统根据实际情况把该任务放进不同优先级的队列

*这里根据原文看好像这个 unstable_createRoot 是源码才开放的 api,正式版本没有这玩意,我在 React 18.2.0 的版本和 vite 脚手架的环境下测试的时候发现调用的都是 renderSync 的方法

也就是说在平时情况下是不启用的?

中断处理

回顾操作系统,其中一个很重要的就是中断,用于处理外界输入的响应

中断的时候操作系统做了以下的事情

  • 记录当前执行到第几条命令 PC
  • 记录当前程序状态字 PSW
  • 记录通用寄存器的值

React 同理,为了做到时间切片功能,React 必须也要有中断和上下文保存的能力,这个能力的实现会在后续内容讲到

旧的架构 (React 15 及以前的版本)

旧的 React 架构可以分为两层:

  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

Reconciler(协调器)

作用如下:

  • 调用函数组件、或 class 组件的render方法,将返回的 JSX 转化为虚拟 DOM
  • 将虚拟 DOM 和上次更新时的虚拟 DOM 对比
  • 通过对比找出本次更新中变化的虚拟 DOM
  • 通知Renderer将变化的虚拟 DOM 渲染到页面上

Renderer(渲染器)

浏览器用的就是 ReactDOM 渲染器,同理还要 RN 渲染器

旧架构的缺点

旧架构在更新组件时都是进行递归更新,一直找到最深层的组件,这个过程无法被中断

而且旧架构是这样更新 DOM 的:

  • 发现组件依赖项变化,通知 Renderer 更新 DOM
  • DOM 调用 api 更新

想象以下组件:

function App() {
  const [val, setVal] = useState(1)

  return (
    <ul>
      <li>{1 * val}</li>
      <li>{2 * val}</li>
      <li>{3 * val}</li>
    </ul>
  )
}

更新对应的过程如下:

  • 设置 val 为 2
  • 发现 li 的内部值从 1 变为 2,通知 Renderer 更新 DOM
  • DOM 调用 api 更新
  • 发现 li 的内部值从 2 变为 4,通知 Renderer 更新 DOM
  • DOM 调用 api 更新
  • 发现 li 的内部值从 3 变为 6,通知 Renderer 更新 DOM
  • DOM 调用 api 更新

此时万一中间发生了中断,那么页面就会渲染没有更新完成的 DOM,即 2 4 3 或者 2 2 3。

因此在旧架构下这个过程是同步的,也就是直到所有更新完成后才会去执行其他任务。

新架构(React 16 及以后的版本)

新架构可以分为三层:

  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

Scheduler

既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们,假如没有剩余时间了,则需要对任务进行中断。

Scheduler 就是为了解决问题而实现的一套库,他除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置。

如何调度的实现有待补充:todo!

Reconciler

相比旧架构,新架构多了个方法来实现中断:

/** @noinline */
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

我们可以看见,更新工作从递归变成了可以中断的循环过程。每次循环都会调用 shouldYield 判断当前是否有剩余时间。

新架构下是如何解决旧架构无法被中断更新 DOM 的过程的?

以下内容是在 React 16 有效

在 React16 中,Reconciler与Renderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟 DOM 打上代表增/删/更新的标记,类似这样:

export const Placement = /*             */ 0b0000000000010;
export const Update = /*                */ 0b0000000000100;
export const PlacementAndUpdate = /*    */ 0b0000000000110;
export const Deletion = /*              */ 0b0000000001000;

在 React 18 这些似乎找不到了,取而代之的是其他 flag

todo!: 后续找到了就把 flag 补充回来

Renderer

顾名思义,就是执行实际渲染的代码部分

以下是新版 React 的更新流程:

图来源于:https://react.iamkasong.com/preparation/newConstructure.html#react16-%E6%9E%B6%E6%9E%84

其中红框中的步骤随时可能由于以下原因被中断:

  • 有其他更高优任务需要先更新
  • 当前帧没有剩余时间

红框部分为可中断部分,DOM 无法被中断的原因是他更新的内容会实时反应在页面上

Fiber 心智模型

从代数效应讲起

这个建议去百度,这里只用一句话解释:将函数副作用分离,使得函数的关注点保持存粹

React 下的代数效应实践

最明显的例子就是 hooks,比如 useState:我们只关注值变化,和渲染结果,而实现这些过程的代码是由 React 帮我们处理,我们只关系渲染结果是否是正确的。

代数效应与Fiber

React Fiber 可以理解为:

React内部实现的一套状态更新机制。支持任务不同优先级,可中断与恢复,并且恢复后可以复用之前的中间状态。

其中每个任务更新单元为React Element对应的Fiber节点。

Fiber 实现原理

提问:Fiber 解决的问题是什么

不知道就回到前面看一眼 _(:з」∠)_

Fiber的含义

Fiber包含三层含义:

  1. 作为架构来说,之前React15的Reconciler采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack Reconciler。React16的Reconciler基于Fiber节点实现,被称为Fiber Reconciler。
  2. 作为静态的数据结构来说,每个Fiber节点对应一个React element,保存了该组件的类型(函数组件/类组件/原生组件…)、对应的DOM节点等信息。
  3. 作为动态的工作单元来说,每个Fiber节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新…)。

Fiber 结构

首先是节点的相互连接:

// 指向父级Fiber节点
this.return = null;
// 指向子Fiber节点
this.child = null;
// 指向右边第一个兄弟Fiber节点
this.sibling = null;

他采用的是链表而不是数组作为数据结构

相比 vue 采用数组,这种方式不会像 vue 的 diff 算法那样需要寻找最长子序列

其次来看静态数据结构:

// Fiber对应组件的类型 Function/Class/Host...
this.tag = tag;
// key属性
this.key = key;
// 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
this.elementType = null;
// 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
this.type = null;
// Fiber对应的真实DOM节点
this.stateNode = null;

然后看动态工作单元:

以下是 React 18 的源码

this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;

// 副作用,比如 DOM 操作标志位
this.flags = NoFlags;
this.subtreeFlags = NoFlags;
this.deletions = null;

最后是调度优先级相关的字段:

// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;

Fiber 工作原理

从双缓存说起

当我们用canvas绘制动画,每一帧绘制前都会调用ctx.clearRect清除上一帧的画面。

如果当前帧画面计算量比较大,导致清除上一帧画面到绘制当前帧画面之间有较长间隙,就会出现白屏。

为了解决这个问题,我们可以在内存中绘制当前帧动画,绘制完毕后直接用当前帧替换上一帧画面,由于省去了两帧替换间的计算时间,不会出现从白屏到出现画面的闪烁情况。

这种在内存中构建并直接替换的技术叫做双缓存

Fiber 下的实现

在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树。

current Fiber树中的Fiber节点被称为current fiber,workInProgress Fiber树中的Fiber节点被称为workInProgress fiber,他们通过alternate属性连接。

currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;

React应用的根节点通过使current指针在不同Fiber树的rootFiber间切换来完成current Fiber树指向的切换。

对比 vue,vue3 会直接针对一个组件构造出新的虚拟 dom 对象,并走 patch 流程实现 dom 的更新。

构建/替换流程

https://react.iamkasong.com/process/doubleBuffer.html#mount-%E6%97%B6

就是基本 diff 递归更新节点算法

这个真没必要额外再写内容了(恼

比起这个可能更需要关注这个过程是如何做到中断的

理论简易,但是实践困难,可以在看完后续内容后再一点点实现

总结

现在我们知道了 React 新架构下的体系,分为:Scheduler - Reconciler - Renderer

然后cp一下参考文章内的术语:

  • Reconciler工作的阶段被称为 render 阶段。因为在该阶段会调用组件的 render 方法。
  • Renderer工作的阶段被称为commit阶段。这一段就是对 DOM 进行真实操作的过程。
  • render 与 commit阶段统称为work,即 React 在工作中。相对应的,如果任务正在Scheduler内调度,就不属于work。

*一点额外知识

React 源码架构

我们只需要关系四个包:

  • packages/react React 核心 API
  • packages/react-dom 浏览器渲染包,包括客户端和服务端渲染
  • packages/react-reconciler 重点关注,React 的协调器
  • packages/Scheduler 调度器具体实现

一些文件夹:

  • packages/shared 全局共享的模块,方法和全局变量

JSX 到底是什么

这是一种在 js 里面可以描述 dom 的嵌入式语言。

他通过 babel 插件后会将一个 html 标签转为 React.createElement 方法:

function App() {
  return <div></div>
}

to:

function App() {
  return React.createElement('div', {/* props */}, [/* children */])
}

是不是很眼熟?就跟 vue 的 h 函数是一样的

既然是 babel 编译,那么它也可以转为其他的过程声明方式,只要编写插件即可。

createElement 函数干了啥

以下摘自 React 18 源码:


/**
 * Create and return a new ReactElement of the given type.
 * See https://reactjs.org/docs/react-api.html#createelement
 */
export function createElement(type, config, children) {
  let propName;

  // Reserved names are extracted
  const props = {};

  let key = null;
  let ref = null;

  if (config != null) {
    // 将 config 处理后赋值给 props
    // ...省略
  }

  // Children can be more than one argument, and those are transferred onto
  // the newly allocated props object.
  const childrenLength = arguments.length - 2;
  // 处理 children,会被赋值给props.children

  // Resolve default props
  // 省略

  return ReactElement(type,key,ref,undefined,undefined,getOwner(),props);
}

function ReactElement( type, key, _ref, self, source, owner, props, debugStack, debugTask,) {
  let ref;
  // 允许 ref 作为 props 一项的标记,对应 react 近期新功能增加
  if (enableRefAsProp) {
    // 省略
  }

  let element = {
      // This tag allows us to uniquely identify this as a React Element
      // 类型标记,标明这是 react 元素
      $$typeof: REACT_ELEMENT_TYPE,

      // Built-in properties that belong on the element
      type,
      key,
      ref,

      props,
    };

  return element;
}

// 验证是否是合法 React 元素
export function isValidElement(object) {
  return (
    typeof object === "object" &&
    object !== null &&
    object.$$typeof === REACT_ELEMENT_TYPE
  );
}

由此可知我们的一个函数,他在返回 html 标签时,就代表着函数本身是一个组件,而调用它能够返回一个 React Element 节点,类似 vue 的 vnode。

JSX 与 Fiber 节点的关系

从上面的内容我们可以发现,JSX是一种描述当前组件内容的数据结构,他不包含组件 schedule、reconcile、render 所需的相关信息。

比如如下信息就不包括在JSX中:

  • 组件在更新中的优先级
  • 组件的state
  • 组件被打上的用于Renderer的标记

这些内容都包含在Fiber节点中。

所以,在组件 mount 时,Reconciler 根据 JSX 描述的组件内容生成组件对应的 Fiber 节点。

在 update 时,Reconciler 基于新的 jsx 结构生成新的 fiber 节点,并与旧节点进行对比和打上更新标记。

而 vue 则是直接根据 h 函数生成的 vnode 树走 patch 流程做更新了,不是像 react 会多生成一次 fiber 节点。

参考资料

React 技术解密

个人信息
avatar
Shiinafan
文章
41
标签
52
归档
4
导航