关于react.js:React核心技术浅析

52次阅读

共计 8623 个字符,预计需要花费 22 分钟才能阅读完成。

1. JSX 与虚构 DOM

咱们从 React 官网文档结尾最根本的一段 Hello World 代码动手:

ReactDOM.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')
);

这段代码的意思是通过 ReactDOM.render() 办法将 h1 包裹的 JSX 元素渲染到 id 为“root”的 HTML 元素上. 除了在 JS 中早已熟知的 document.getElementById() 办法外, 这段代码中还蕴含两个知识点:

  • h1 标签包裹的 JSX 元素
  • ReactDOM.render() 办法

而这两个知识点则对应着 React 中要解决的外围问题:

  • 为何以及如何应用 (JSX 示意的) 虚构 DOM?
  • 如何对虚构 DOM 进行解决, 使其高效地渲染进去?

1.1 虚构 DOM 是什么? 为何要应用虚构 DOM?

虚构 DOM 其实就是用 JavaScript 对象示意的一个 DOM 节点, 外部蕴含了节点的 tag , propschildren .

为何应用虚构 DOM? 因为间接操作实在 DOM 繁琐且低效, 通过虚构 DOM, 将一部分低廉的浏览器重绘工作转移到绝对便宜的存储和计算资源上.

1.2 如何将 JSX 转换成虚构 DOM?

React 实战视频解说:进入学习

通过 babel 能够将 JSX 编译为特定的 JavaScript 对象, 示例代码如下:

// JSX
const e = (
    <div id="root">
        <h1 className="title">Title</h1>
  </div>
);
// babel 编译后果(React17 之前), 留神子元素的嵌套构造
var e = React.createElement(
    "div",
  {id: "root"},
    React.createElement(
        "h1",
        {className: "title"},
        "Title"
    )
);
// React17 之后编译后果有所区别, 创立节点的办法由 react 导出, 但基本原理大同小异

1.3 如何将虚构 DOM 渲染进去?

从上一节 babel 的编译后果能够看出, 虚构 DOM 中蕴含了创立 DOM 所需的各种信息, 对于首次渲染, 间接按照这些信息创立 DOM 节点即可.

但虚构 DOM 的真正价值在于“更新”: 当一个 list 中的某些项产生了变动, 或删除或减少了若干项, 如何通过比照前后的虚构 DOM 树, 最小化地更新实在 DOM? 这就是 React 的外围指标.

2. React Diffing

“Diffing” 即“找不同”, 就是解决上文引出的 React 的外围指标——如何通过比照新旧虚构 DOM 树, 以在最小的操作次数下将旧 DOM 树转换为新 DOM 树.

在算法畛域中, 两棵树的转换目前最优的算法复杂度为 O(n**3) , n 为节点个数. 这意味着当树上有 1000 个元素时, 须要 10 亿次比拟, 显然远远不够高效.

React 在基于以下两个假如的根底上, 提出了一套复杂度为 O(n) 的启发式算法

  1. 不同类型 (即标签名、组件名) 的元素会产生不同的树;
  2. 通过设置 key 属性来标识一组同级子元素在渲染前后是否放弃不变.

在实践中, 以上两个假如在绝大多数场景下都成立.

2.1 Diffling 算法形容

不同类型的元素 / 组件

当元素的标签或组件名发生变化, 间接卸载并替换以此元素作为根节点的整个子树.

同一类型的元素

当元素的标签雷同时, React 保留此 DOM 节点, 仅比照和更新有扭转的属性, 如 className、title 等, 而后递归比照其子节点.

对于 style 属性, React 会持续深刻比照, 仅更新有扭转的属性, 如 color、fontSize 等.

同一类型的组件

当组件的 props 更新时, 组件实例放弃不变, React 调用组件的 componentWillReceiveProps() componentWillUpdate()componentDidUpdate() 生命周期办法, 并执行 render() 办法.

Diffing 算法会递归比对新旧 render() 执行的后果.

对子节点的递归

当一组同级子节点 (列表) 的开端增加了新的子节点时, 上述 Diffing 算法的开销较小; 但当新元素被插入到列表结尾时, Diffing 算法只能按程序顺次比对并重建从新元素开始的后续所有子节点, 造成极大的开销节约.

解决方案是为一组列表项增加 key 属性, 这样 React 就能够不便地比对出插入或删除项了.

对于 key 属性, 应稳固、可预测且在列表内惟一(无需全局惟一), 如果数据有 ID 的话间接应用此 ID 作为 key, 或者利用数据中的一部分字段哈希出一个 key 值.

防止应用数组索引值作为 key, 因为当插入或删除元素后, 之后的元素和索引值的对应关系都会产生错乱, 导致谬误的比对后果.

防止应用不稳固的 key(如随机数), 因为每次渲染都会产生扭转, 从而导致列表项被不必要地重建.

2.2 递归的 Diffing

在 1.2 节中的虚构 DOM 对象中能够得悉: 虚构 DOM 树的每个节点通过 children 属性形成了一个嵌套的树结构, 这意味着要以递归的模式遍历和比拟新旧虚构 DOM 树.

2.1 节的策略解决了 Diffing 算法的工夫复杂度的问题, 但咱们还面临着另外一个重大的性能问题——浏览器的渲染线程和 JS 的执行线程是互斥的, 这意味着 DOM 节点过多时, 虚构 DOM 树的构建和解决会长工夫占用主线程, 使得一些须要高优先级解决的操作如用户输出、平滑动画等被阻塞, 重大影响应用体验.

工夫切片(Time Slice)

为了解决浏览器主线程的阻塞问题, 引出 工夫切片 的策略——将整个工作流程分解成小的工作单元, 并在浏览器闲暇时交由浏览器执行这些工作单元, 每个执行单元执行结束后, 浏览器都能够抉择中断渲染并解决其余须要更高优先级解决的工作.

浏览器中提供了 requestIdleCallback 办法实现此性能, 将待调用的函数退出执行队列, 浏览器将在不影响要害事件处理的状况下一一调用.

思考到浏览器的兼容性以及 requestIdleCallback 办法的不稳定性, React 本人实现了专用于 React 的相似 requestIdleCallback 且性能更齐备的 Scheduler 来实现闲暇时触发回调, 并提供了多种优先级供工作设置.

递归与工夫切片

工夫切片策略要求咱们将虚构 DOM 的更新操作合成为小的工作单元, 同时具备以下个性:

  • 可暂停、可复原的更新;
  • 可跳过的重复性、覆盖性更新;
  • 具备优先级的更新.

对于递归模式的程序来说, 这些是难以实现的. 于是就须要一个处于递归模式的虚构 DOM 树下层的数据结构, 来辅助实现这些个性.

这就是 React16 引入的重构后的算法外围——Fiber.

3. Fiber

从概念上来说, Fiber 就是重构后的虚构 DOM 节点, 一个 Fiber 就是一个 JS 对象.

Fiber 节点之间形成 单向链表 构造, 以实现前文提到的几个个性: 更新可暂停 / 复原、可跳过、可设优先级.

3.1 Fiber 节点

一个 Fiber 节点就是一个 JS 对象, 其中的要害属性可分类列举如下:

  • 构造信息(形成链表的指针属性)

    • return: 父节点
    • child: 第一个子节点
    • sibling: 右侧第一个兄弟节点
    • alternate: 本节点在相邻更新时的状态, 用于比拟节点前后的变动, 3.3 节详述
  • 组件信息

    • tag: 组件创立类型, 如 FunctionComponent、ClassComponent、HostComponent 等
    • key: 即 key 属性
    • type: 组件类型, Function/Class 组件的 type 就是对应的 Function/Class 自身, Host 组件的 type 就是对应元素的 TagName
    • stateNode: 对应的实在 DOM 节点
  • 本次更新的 props 和 state 相干信息

    • pendingProps、memoizedProps
    • memoizedState
    • dependencies
    • updateQueue
  • 更新标记

    • effectTag: 节点更新类型, 如替换、更新、删除等
    • nextEffect、firstEffect、lastEffect
  • 优先级相干: lanes、childrenLanes

3.2 Fiber 树

前文说到, Fiber 节点通过 return , childslibling 属性形成了单向链表构造, 为了与 DOM 树对应, 习惯上仍称其为“树”.

如一棵 DOM 树:

<div>
    <h1>Title</h1>
    <section>
        <h2>Section</h2>
        <p>Content</p>
    </section>
    <footer>Footer</footer>
</div>

section 节点的 Fiber 可示意为:

const sectionFiber = {
    key: "SECTION_KEY",
    child: h2Fiber,
    sibling: footerFiber,
    return: divFiber,
    alternate: oldSectionFiber,  
    ...otherFiberProps,
}

整体的 Fiber 构造:

3.3 Fiber 架构

基于 Fiber 形成的虚构 DOM 树就是 Fiber 架构.

在 3.1 节中咱们介绍过, 在 Fiber 节点中有一个重要属性 alternate , 单词意为“备用”.

实际上, 在 React 中最多会同时存在两棵 Fiber 树:

  • 以后显示在屏幕上、曾经构建实现的 Fiber 树称为“Current Fiber Tree”, 咱们将其中的 Fiber 节点简写为 currFiber;
  • 以后正在构建的 Fiber 树称为“WorkInProgress Fiber Tree”, 咱们将其 Fiber 节点节点简写为 wipFiber.

而这两棵树中节点的 alternate 属性相互指向对方树中的对应节点, 即: currFiber.alternate === wipFiber; wipFiber.alternate === currFber; 他们用于比照更新前后的节点以决定如何更新此节点.

在 React 中, 整个利用的根节点为 fiberRoot , 当 wipFiber 树构建实现后, fiberRoot.current 将从 currFiber 树的根节点切换为 wipFiber 的根节点, 以实现更新操作.

3.1 基于 Fiber 的调度——工夫切片

在 2.2 节咱们探讨了采纳拆分工作单元并以工夫切片的形式执行, 以防止阻塞主线程. 在 Fiber 架构下, 每个 Fiber 节点就是一个工作单元.

在以下示例代码中, 咱们应用浏览器提供的 requestIdleCallback 办法演示这个过程, 它会在浏览器闲暇时执行一个 workLoop、解决一个 Fiber 节点, 而后能够依据理论状况继续执行或暂停期待执行下一个 workLoop.

function workLoop(deadline) {
  let shouldYield = false;
  while (nextUnitOfWork && !shouldYield) {
        // 解决一个 Fiber 节点, 返回下一个 Fiber 节点, 详见 3.3 节
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    // 暂停解决的演示: 当工夫有余时勾销循环处理过程
    shouldYield = deadline.timeRemaining() < 1;}
  // 当执行结束(不存在下一个执行单元), 提交整个 DOM 树
  if (!nextUnitOfWork && wipRoot) {commitRoot();
  }
  requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);

3.2 对 Fiber 节点的解决程序——DFS

由前文咱们可知, Fiber 节点通过 return child sibling 三个属性相互连接, 整体形成一个单向链表构造, 其调度形式就是 深度优先遍历 :

  1. 以 wipFiber 树的 Root 节点作为第一个执行单元;
  2. 若以后执行单元存在 child 节点, 则将 child 节点作为下一个执行单元;
  3. 反复 2, 直至以后执行单元无 child;
  4. 若以后执行单元存在 sibling 节点, 则将 sibling 节点作为下一个执行单元, 并回到 2;
  5. 若以后执行单元无 child 且无 sibling, 返回到父节点, 并回到 4;
  6. 反复 5; 直至回到 Root 节点, 执行结束, 将 fiberRoot.current 只为 wipFiber 树的根节点.

以上步骤阐明, Fiber 节点通过 childsiblingreturn 的程序进行深度优先遍历“解决”, 而后更新 Fiber 树. 那么如何“解决”Fiber 节点呢?

3.3 对 Fiber 节点的处理过程

对 Fiber 节点的解决就是执行一个 performUnitOfWork 办法, 它接管一个将要解决的 Fiber 节点, 而后实现以下工作:

  1. 欠缺构建 Fiber 节点: 创立 DOM 并获取 children

    • 对于 HostComponent 和 ClassComponent, 依据 Fiber 中的相干属性, 创立 DOM 节点并赋给 Fiber.stateNode 属性;
    • 对于 FunctionComponent, 间接通过函数调用获取其 children: Fiber.type(Fiber.props)
    // 执行工作单元, 并返回下一个工作单元
    function performUnitOfWork(fiber) {
     // 构建以后节点的 fiber
     const isFunctionComponent = fiber.type instanceof Function;
     if (isFunctionComponent) {updateFunctionComponent(fiber);
     } else {updateHostComponent(fiber);
     }
    
     // 解决子节点, 构建 Fiber 树
     const elements = fiber.props.children;
     reconcileChildren(fiber, elements);
    
     // TODO: 返回下一个执行单元
     // fiber.child || fiber.sibling || fiber.return
    }
    
    // Class/Host 组件: 创立 DOM
    function updateHostComponent(fiber) {if (!fiber.dom) {fiber.dom = createDom(fiber);
     }
     reconcileChildren(fiber, fiber.props.children);
    }
    
    // 更新 Function 组件, Function 组件须要从返回值获取子组件
    // 留神: Function 组件无 DOM
    function updateFunctionComponent(fiber) {
     // 初始化 hooks
     wipFiber = fiber;
     hookIndex = 0;
     fiber.hooks = [];
     const children = [fiber.type(fiber.props)]; // Function 组件返回 children
     reconcileChildren(fiber, children);
    }
    // TODO: reconcileChildren 解决子节点, 见第 3 步
    
  2. 通过 Fiber.alternate 获取 oldFiber , 即上一次更新后的 Fiber 值, 而后在下一步中构建和 Diff 以后 Fiber 的 children .

    function reconcileChildren(wipFiber, elements) {
     let oldFiber = wipFiber.alternate
               && wipFiber.alternate.child;
     // ...
    }
    
  3. 构建 children Fibers, 对于每个子 Fiber, 同步地实现以下工作:

    • 构建 Fiber 链表: 为每个子元素创立 Fiber, 并将父 Fiber 的 child 属性指向第一个子 Fiber, 而后按程序将子 Fiber 的 sibling 属性指向下一个子 Fiber;
    • 比照 (Diffing) 新旧 Fiber 节点的 type props key 等属性, 确定节点是能够间接复用、替换、更新还是删除, 须要更新的 Fiber 节点在其 effectTag 属性中打上 Update Placement PlacementAndUpdate Deletion 等标记, 以在提交更新阶段进行解决.
    function reconcileChildren(wipFiber, elements) {
     let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
     let index = 0;
     let prevSibling = null;
    
     while (index < elements.length || oldFiber !== null) {const element = elements[index];
       let newFiber = null;
    
       // Compare oldFiber to element
       const sameType = oldFiber && element && element.type === oldFiber.type;
    
       if (sameType) {
         // update the node
         newFiber = {
           type: oldFiber.type,
           props: element.props,
           dom: oldFiber.dom,
           parent: wipFiber,
           alternate: oldFiber,
           effectTag: "UPDATE",
         };
       }
    
       if (element && !sameType) {
         // add this node
         newFiber = {
           type: element.type,
           props: element.props,
           dom: null,
           parent: wipFiber,
           alternate: null,
           effectTag: "PLACEMENT",
         };
       }
    
       if (oldFiber && !sameType) {
         // delete the oldFiber's node
         oldFiber.effectTag = "DELETION";
         deletions.push(oldFiber);
       }
    
       if (oldFiber) {oldFiber = oldFiber.sibling;}
    
       if (index === 0) {wipFiber.child = newFiber;} else {prevSibling = newFiber;}
    
       prevSibling = newFiber;
       index++;
     }
    
  4. 按 DFS 程序返回下一个工作单元, 示例代码如下:

    if (fiber.child) {return fiber.child;}
     let nextFiber = fiber;
     while (nextFiber) {if (nextFiber.sibling) {return nextFiber.sibling;}
       nextFiber = nextFiber.parent;
     }
    

当 DFS 过程回到根节点时, 表明本次更新的 wipFiber 树 构建实现, 进入下一步的提交更新阶段.

3.4 提交更新阶段

在进入本阶段时, 新的 Fiber 树已构建实现, 须要进行替换、更新或删除的 Fiber 节点也在其 effectTag 中进行了标记, 所以本阶段第一个工作就是依据 effectTag 操作实在 DOM.

为了防止从头再遍历 Fiber 树寻找具备 effectTag 属性的 Fiber, 在上一步 Fiber 树的构建过程中保留了一条须要更新的 Fiber 节点的单向链表 effectList , 并将此链表的头节点存储在 Fiber 树根节点的 firstEffect 属性中, 同时这些 Fiber 节点的 updateQueue 属性中也保留了须要更新的 props .

除了更新实在 DOM 外, 在提交更新阶段还须要在特定阶段调用和解决生命周期办法、执行 Hooks 操作, 本文不再详述.

在此参考了 pomb.us/build-your-… 中提供的 useState Hook 的实现代码, 有助于了解在执行 setState 办法后都产生了什么:

function useState(initial) {
  // 判断上一次渲染是否存在此 Hook, 如果存在就应用上一个 state, 否则创立新的 hook 并更新索引
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex];
  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [], // 每次执行 setState 时, 将 action 退出此队列, 并在下一次渲染时执行};

  // 下一次渲染时, 获取执行队列并逐渐执行, 使得 state 放弃最新
  const actions = oldHook ? oldHook.queue : [];
  actions.forEach((action) => {hook.state = action(hook.state);
  });

  // setState 办法: 将 action 增加到执行队列并触发渲染, 在下一次渲染时执行此 action
  const setState = (action) => {hook.queue.push(action);
    // 执行 setState 后应从新触发渲染
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    };
    nextUnitOfWork = wipRoot;
    deletions = [];};

  wipFiber.hooks.push(hook);
  hookIndex++;
  return [hook.state, setState];
}

正文完
 0