【JS】React 整体感知

当我们由浅入深地认知一样新事物的时候,往往需要遵循 Why > What > How 这样一个认知过程。它们是相辅相成、缺一不可的。而了解了具体的 What 和 How 之后,往往能够更加具象地回答理论层面的 Why,因此,在进入 Why 的探索之前,我们先整体感知一下 What 和 How 两个过程。

What

打开 React 官网,第一眼便能看到官方给出的回答。

不知道你有没有想过,构建用户界面的方式有千百种,为什么 React 会突出?同样,我们可以从 React 哲学里得到回应。

可见,关键是实现了 快速响应 ,那么制约 快速响应 的因素有哪些呢?React 是如何解决的呢?

How

让我们带着上面的两个问题,在遵循真实的React代码架构的前提下,实现一个包含时间切片、fiberHooks的简易 React,并舍弃部分优化代码和非必要的功能,将其命名为 HuaMu

CreateElement 函数

在开始之前,我们先简单的了解一下JSX,如果你感兴趣,可以关注下一篇《JSX背后的故事》。

JSX会被工具链Babel编译为React.createElement(),接着React.createElement()返回一个叫作React.ElementJS对象。

这么说有些抽象,通过下面demo看下转换前后的代码:

// JSX 转换前

const el = <h1 title="el_title">HuaMu<h1>;

// 转换后的 JS 对象

const el = {

type:"h1",

props:{

title:"el_title",

children:"HuaMu",

}

}

可见,元素是具有 typeprops 属性的对象,而 CreateElement 函数的主要任务就是创建该对象。

/**

* @param {string} type HTML标签类型

* @param {object} props 具有JSX属性中的所有键和值

* @param {string | array} children 元素树

*/

function CreateElement(type, props, ...children) {

return {

type,

props:{

...props,

children,

}

}

}

CreateElement("h1", {title:"el_title"}, 'hello', 'HuaMu')

// 返回的 JS 对象

{

"type": "h1",

"props": {

"title": "el_title" // key-value

"children": ["hello", "HuaMu"] // 数组类型

}

}

function CreateElement(type, props, ...children) {

return {

type,

props:{

...props,

children: children.map(child => typeof child === "object" ? child : CreateTextElement(child))

}

}

}

function CreateTextElement(text) {

return {

type: "TEXT_EL",

props: {

nodeValue: text,

children: []

}

}

}

Render 函数

CreateElement 函数将标签转化为对象输出,接着 React 进行一系列处理,Render 函数将处理好的节点根据标记进行添加、更新或删除内容,最后附加到容器中。下面简单的实现 Render 函数是如何实现添加内容的:

  • 首先创建对应的DOM节点,然后将新节点附加到容器中,并递归每个孩子节点做同样的操作。
  • 将元素的 props 属性分配给节点。

    function Render(el,container) {

    // 创建节点

    const dom = el.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(el.type);

    el.props.children.forEach(child => Render(child, dom))

    // 为节点分配 props 属性

    const isProperty = key => key !== 'children';

    const setProperty = name => dom[name] = el.props[name];

    Object.keys(el.props).filter(isProperty).forEach(setProperty)

    container.appendChild(dom);

    }

到目前为止,我们已经实现了一个简易的用于构建用户界面的 JavaScript 库。现在,让 Babel 使用自定义的 HuaMu 代替 React,将 /** @jsx HuaMu.CreateElement */ 添加到代码中,打开 codesandbox看看效果吧。

并发模式

在继续向下探索之前,我们先思考一下上面的代码中,有哪些代码制约 快速响应 了呢?

是的,在Render函数中递归每个孩子节点,即这句代码el.props.children.forEach(child => Render(child, dom))存在问题。一旦开始渲染,便不会停止,直到渲染了整棵元素树,我们知道,GUI渲染线程与JS线程是互斥的,JS脚本执行和浏览器布局、绘制不能同时执行。如果元素树很大,JS脚本执行时间过长,可能会阻塞主线程,导致页面掉帧,造成卡顿,且妨碍浏览器执行高优作业。

那如何解决呢?

通过时间切片的方式,即将任务分解为多个工作单元,每完成一个工作单元,判断是否有高优作业,若有,则让浏览器中断渲染。下面通过requestIdleCallback模拟实现:

简单说明一下:

  • window.requestIdleCallback(cb[, options]) :浏览器将在主线程空闲时运行回调。函数会接收到一个IdleDeadline的参数,这个参数可以获取当前空闲时间(timeRemaining)以及回调是否在超时前已经执行的状态(didTimeout)。

  • React 已不再使用requestIdleCallback,目前使用 scheduler package。但在概念上是相同的。

依据上面的分析,代码结构如下:

// 当浏览器准备就绪时,它将调用 WorkLoop

requestIdleCallback(WorkLoop)

let nextUnitOfWork = null;

function PerformUnitOfWork(nextUnitOfWork) {

// TODO

}

function WorkLoop(deadline) {

// 当前线程的闲置时间是否可以在结束前执行更多的任务

let shouldYield = false;

while(nextUnitOfWork && !shouldYield) {

nextUnitOfWork = PerformUnitOfWork(nextUnitOfWork) // 赋值下一个工作单元

shouldYield = deadline.timeRemaining() < 1; // 如果 idle period 已经结束,则它的值是 0

}

requestIdleCallback(WorkLoop)

}

我们在 PerformUnitOfWork 函数里实现当前工作的执行并返回下一个执行的工作单元,可下一个工作单元如何快速查找呢?让我们初步了解 Fibers 吧。

Fibers

为了组织工作单元,即方便查找下一个工作单元,需引入fiber tree的数据结构。即每个元素都有一个fiber,链接到其第一个子节点,下一个兄弟姐妹节点和父节点,且每个fiber都将成为一个工作单元。

// 假设我们要渲染的元素树如下

const el = (

<div>

<h1>

<p />

<a />

</h1>

<h2 />

</div>

)

其对应的 fiber tree 如下:

【JS】React 整体感知

若将上图转化到我们的代码里,我们第一件事得找到root fiber,即在Render中,设置nextUnitOfWork初始值为root fiber,并将创建节点部分独立出来。

function Render(el,container) {

// 设置 nextUnitOfWork 初始值为 root fiber

nextUnitOfWork = {

dom: container,

props:{

children:[el],

}

}

}

// 将创建节点部分独立出来

function CreateDom(fiber) {

const dom = fiber.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(fiber.type);

// 为节点分配props属性

const isProperty = key => key !== 'children';

const setProperty = name => dom[name] = fiber.props[name];

Object.keys(fiber.props).filter(isProperty).forEach(setProperty)

return dom

}

剩余的 fiber 将在 performUnitOfWork 函数上执行以下三件事:

  • 为元素创建节点并添加到 dom

  • 为元素的子代创建 fiber

  • 选择下一个执行工作单元

    function PerformUnitOfWork(fiber) {

    // 为元素创建节点并添加到 dom

    if(!fiber.dom) {

    fiber.dom = CreateDom(fiber)

    }

    // 若元素存在父节点,则挂载

    if(fiber.parent) {

    fiber.parent.dom.appendChild(fiber.dom)

    }

    // 为元素的子代创建 fiber

    const els = fiber.props.children;

    let index = 0;

    // 作为一个容器,存储兄弟节点

    let prevSibling = null;

    while(index < els.length) {

    const el = els[index];

    const newFiber = {

    type: el.type,

    props: el.props,

    parent: fiber,

    dom: null

    }

    // 子代在fiber树中的位置是child还是sibling,取决于它是否第一个

    if(index === 0){

    fiber.child = newFiber;

    } else {

    prevSibling.sibling = newFiber;

    }

    prevSibling = newFiber;

    index++;

    }

    // 选择下一个执行工作单元,优先级是 child -> sibling -> parent

    if(fiber.child){

    return fiber.child;

    }

    let nextFiber = fiber;

    while(nextFiber) {

    if(nextFiber.sibling) {

    return nextFiber.sibling;

    }

    nextFiber = nextFiber.parent;

    }

    }

RenderCommit 阶段

在上面的代码中,我们加入了时间切片,但它还存在一些问题,下面我们来看看:

  • performUnitOfWork函数里,每次为元素创建节点之后,都向dom添加一个新节点,即

    if(fiber.parent) {

    fiber.parent.dom.appendChild(fiber.dom)

    }

  • 我们都知道,主流浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了。也就是在渲染完整棵树之前,浏览器可能会中断,导致用户看不到完整的UI。

那该如何解决呢?

  • 首先将创建一个节点就向dom进行添加处理的方式更改为跟踪 fiber root,也被称为progress root 或者 wipRoot

  • 一旦完成所有的工作,即没有下一个工作单元时,才将fiber提交给dom

    // 跟踪根节点

    let wipRoot = null;

    function Render(el,container) {

    wipRoot = {

    dom: container,

    props:{

    children:[el],

    }

    }

    nextUnitOfWork = wipRoot;

    }

    // 一旦完成所有的工作,将整个fiber提交给dom

    function WorkLoop(deadline) {

    ...

    if(!nextUnitOfWork && wipRoot) {

    CommitRoot()

    }

    requestIdleCallback(WorkLoop)

    }

    // 将完整的fiber提交给dom

    function CommitRoot() {

    CommitWork(wipRoot.child)

    wipRoot = null

    }

    // 递归将每个节点添加进去

    function CommitWork(fiber) {

    if(!fiber) return;

    const parentDom = fiber.parent.dom;

    parentDom.appendChild(fiber.dom);

    CommitWork(fiber.child);

    CommitWork(fiber.sibling);

    }

Reconciliation

到目前为止,我们优化了上面自定义的HuaMu库,但上面只实现了添加内容,现在,我们把更新和删除内容也加上。而要实现更新、删除功能,需要将render函数中收到的元素与提交给dom的最后的fiber tree进行比较。因此,需要保存最后一次提交给fiber tree 的引用currentRoot。同时,为每个fiber添加alternate属性,记录上一阶段提交的old fiber

let currentRoot = null;

function Render(el,container) {

wipRoot = {

...

alternate: currentRoot

}

...

}

function CommitRoot() {

...

currentRoot = wipRoot;

wipRoot = null

}

  • 为元素的子代创建fiber的同时,将old fibernew fiber进行reconcile

  • 通过以下三个维度进行比较

    1. 如果old fibernew fiber具有相同的type,保留dom节点并更新其props,并设置标签effectTagUPDATE

    2. type不同,且为new fiber,意味着要创建新的dom节点,设置标签effectTagPLACEMENT;若为old fiber,则需要删除节点,设置标签effectTagDELETION

    let deletions = null;

    function PerformUnitOfWork(fiber) {

    ...

    const els = fiber.props.children;

    // 提取 为元素的子代创建fiber 的代码

    ReconcileChildren(fiber, els);

    }

    function ReconcileChildren(wipFiber, els) {

    let index = 0;

    let oldFiber = wipFiber.alternate && wipFiber.alternate.child;

    let prevSibling = null;

    // 为元素的子代创建fiber 的同时 遍历旧的fiber的子级

    // undefined != null; // false

    // undefined !== null; // true

    while(index < els.length || oldFiber != null) {

    const el = els[index];

    const sameType = oldFiber && el && el.type === oldFiber.type;

    let newFiber = null;

    // 更新节点

    if(sameType) {

    newFiber = {

    type: el.type,

    props: el.props,

    parent: wipFiber,

    dom: oldFiber.dom, // 使用 oldFiber

    alternate: oldFiber,

    effectTag: "UPDATE",

    }

    }

    // 新增节点

    if(!sameType && el){

    newFiber = {

    type: el.type,

    props: el.props,

    parent: wipFiber,

    dom: null, // dom 设置为null

    alternate: null,

    effectTag: "PLACEMENT",

    }

    }

    // 删除节点

    if(!sameType && oldFiber) {

    // 删除节点没有新的fiber,因此将标签设置在旧的fiber上,并加入删除队列 [commit阶段提交时,执行deletions队列,render阶段执行完清空deletions队列]

    oldFiber.effectTag = "DELETION";

    deletions.push(oldFiber)

    }

    if(oldFiber) {

    oldFiber = oldFiber.sibling;

    }

    if(index === 0) {

    wipFiber.child = newFiber;

    } else if(el) {

    prevSibling.sibling = newFiber;

    }

    prevSibling = newFiber;

    index++;

    }

    }

  • CommitWork函数里,根据effectTags进行节点处理

    1. PLACEMENT - 跟之前一样,将dom节点添加进父节点
    2. DELETION - 删除节点
    3. UPDATE - 更新dom节点的props

    function CommitWork(fiber) {

    if (!fiber) return;

    const parentDom = fiber.parent.dom;

    if (fiber.effectTags === 'PLACEMENT' && fiber.dom !== null){

    parentDom.appendChild(fiber.dom);

    } else if (fiber.effectTags === 'DELETION') {

    parentDom.removeChild(fiber.dom)

    } else if(fiber.effectTags === 'UPDATE' && fiber.dom !== null) {

    UpdateDom(

    fiber.dom,

    fiber.alternate.props,

    fiber.props

    )

    }

    CommitWork(fiber.child);

    CommitWork(fiber.sibling);

    }

重点分析一下UpdateDom函数:

  • 普通属性

    1. 删除旧的属性
    2. 设置新的或更改的属性

  • 特殊处理以 on为前缀的事件属性

    1. 删除旧的或更改的事件属性
    2. 添加新的事件属性

      const isEvent = key => key.startsWith("on");

      const isProperty = key => key !== 'children' && !isEvent(key);

      const isNew = (prev, next) => key => prev[key] !== next[key];

      const isGone = (prev, next) => key => !(key in next);

      /**

      * 更新dom节点的props

      * @param {object} dom

      * @param {object} prevProps 之前的属性

      * @param {object} nextProps 当前的属性

      */

      function UpdateDom(dom, prevProps, nextProps) {

      // 删除旧的属性

      Object.keys(prevProps)

      .filter(isProperty)

      .filter(isGone(prevProps, nextProps))

      .forEach(name => {

      dom[name] = ""

      })

      // 设置新的或更改的属性

      Object.keys(nextProps)

      .filter(isProperty)

      .filter(isNew(prevProps, nextProps))

      .forEach(name => {

      dom[name] = nextProps[name]

      })

      // 删除旧的或更改的事件属性

      Object.keys(prevProps)

      .filter(isEvent)

      .filter(key => (!(key in nextProps) || isNew(prevProps, nextProps)(key)))

      .forEach(name => {

      const eventType = name.toLowerCase().substring(2)

      dom.removeEventListener(

      eventType,

      prevProps[name]

      )

      })

      // 添加新的事件属性

      Object.keys(nextProps)

      .filter(isEvent)

      .filter(isNew(prevProps, nextProps))

      .forEach(name => {

      const eventType = name.toLowerCase().substring(2)

      dom.addEventListener(

      eventType,

      nextProps[name]

      )

      })

      }

现在,我们已经实现了一个包含时间切片、fiber的简易 React。打开 codesandbox看看效果吧。

Function Components

组件化对于前端的同学应该不陌生,而实现组件化的基础就是函数组件,相对与上面的标签类型,函数组件有哪些不一样呢?让我们来啾啾

function App(props) {

return <h1>Hi {props.name}</h1>

}

const element = <App name="foo" />

若由上面实现的Huamu库进行转换,应该等价于:

function App(props) {

return Huamu.CreateElement("h1",null,"Hi ",props.name)

}

const element = Huamu.CreateElement(App, {name:"foo"})

由此,可见Function Componentsfiber是没有dom节点的,而且其children是来自于函数的运行而不是props。基于这两个不同点,我们将其划分为UpdateFunctionComponentUpdateHostComponent 进行处理

function PerformUnitOfWork(fiber) {

const isFunctionComponent = fiber.type instanceof Function;

if(isFunctionComponent) {

UpdateFunctionComponent(fiber)

} else {

UpdateHostComponent(fiber)

}

// 选择下一个执行工作单元,优先级是 child -> sibling -> parent

...

}

function UpdateFunctionComponent(fiber) {

// TODO

}

function UpdateHostComponent(fiber) {

if (!fiber.dom) = fiber.dom = CreateDom(fiber);

const els = fiber.props.children;

ReconcileChildren(fiber, els);

}

  • children来自于函数的运行而不是props,即运行函数获取children

    function UpdateFunctionComponent(fiber) {

    const children = [fiber.type(fiber.props)];

    ReconcileChildren(fiber,children);

    }

  • 没有dom节点的fiber

    1. 在添加节点时,得沿着fiber树向上移动,直到找到带有dom节点的父级fiber

    2. 在删除节点时,得继续向下移动,直到找到带有dom节点的子级fiber

      function CommitWork(fiber) {

      if (!fiber) return;

      // 优化:const domParent = fiber.parent.dom;

      let domParentFiber = fiber.parent;

      while(!domParentFiber.dom) {

      domParentFiber = domParentFiber.parent;

      }

      const domParent = domParentFiber.dom;

      if (fiber.effectTags === 'PLACEMENT' && fiber.dom!=null){

      domParent.appendChild(fiber.dom);

      } else if (fiber.effectTags === 'DELETION') {

      // 优化: domParent.removeChild(fiber.dom)

      CommitDeletion(fiber, domParent)

      } else if(fiber.effectTags === 'UPDATE' && fiber.dom!=null) {

      UpdateDom(

      fiber.dom,

      fiber.alternate.props,

      fiber.props

      )

      }

      CommitWork(fiber.child);

      CommitWork(fiber.sibling);

      }

      function CommitDeletion(fiber,domParent){

      if(fiber.dom){

      domParent.removeChild(fiber.dom)

      } else {

      CommitDeletion(fiber.child, domParent)

      }

      }

最后,我们为Function Components添加状态。

Hooks

fiber添加一个hooks数组,以支持useState在同一组件中多次调用,且跟踪当前的hooks索引。

let wipFiber = null

let hookIndex = null

function UpdateFunctionComponent(fiber) {

wipFiber = fiber;

hookIndex = 0

wipFiber.hooks = []

const children = [fiber.type(fiber.props)]

ReconcileChildren(fiber, children)

}

  • Function Components组件调用UseState时,通过alternate属性检测fiber是否有old hook
  • 若有old hook,将状态从old hook复制到new hook,否则,初始化状态。
  • new hook添加fiberhook index递增,返回状态。

    function UseState(initial) {

    const oldHook =

    wipFiber.alternate &&

    wipFiber.alternate.hooks &&

    wipFiber.alternate.hooks[hookIndex]

    const hook = {

    state: oldHook ? oldHook.state : initial,

    }

    wipFiber.hooks.push(hook)

    hookIndex++

    return [hook.state]

    }

  • UseState还需返回一个可更新状态的函数,因此,需要定义一个接收actionsetState函数。

  • action添加到队列中,再将队列添加到fiber
  • 在下一次渲染时,获取old hookaction队列,并代入new state逐一执行,以保证返回的状态是已更新的。
  • setState函数中,执行跟Render函数类似的操作,将currentRoot设置为下一个工作单元,以便开始新的渲染。

    function UseState(initial) {

    ...

    const hook = {

    state: oldHook ? oldHook.state : initial,

    queue: [],

    }

    const actions = oldHook ? oldHook.queue : []

    actions.forEach(action => {

    hook.state = action(hook.state)

    })

    const setState = action => {

    hook.queue.push(action)

    wipRoot = {

    dom: currentRoot.dom,

    props: currentRoot.props,

    alternate: currentRoot,

    }

    nextUnitOfWork = wipRoot

    deletions = []

    }

    wipFiber.hooks.push(hook)

    hookIndex++

    return [hook.state, setState]

    }

现在,我们已经实现一个包含时间切片、fiberHooks 的简易 React。打开codesandbox看看效果吧。

结语

到目前为止,我们从 What > How 梳理了大概的 React 知识链路,后面的章节我们对文中所提及的知识点进行 Why 的探索,相信会反哺到 What 的理解和 How 的实践。

以上是 【JS】React 整体感知 的全部内容, 来源链接: utcz.com/a/94124.html

回到顶部