React

前沿

因为公司的这次分享主要与大家分享React源码相关的知识,我之前稍微理解过创建一个简单的React是一个什么样的过程,可是随着时间的过去,我的记忆逐渐淡忘了,然后我又去反复看了好几遍,想简单给大家介绍一下。

目录

  • createElement函数
  • render函数
  • 并发渲染与调度
  • Fibers
  • Render 与 Commit 两大阶段(Phases)
  • 调和算法 Reconciliation

回顾

//定义一个React元素
const element = (
 <h1 title="foo">Hello</h1>
);
//从DOM中获取一个DOM node
const container = document.getElementById("root");
//将React元素渲染到DOM node上
ReactDOM.render(element, container)

我们将使用以上这个仅包含三行代码的React App,接下来,让我们把React代码替换成原生的JavaScript代码。
JSX转换成JS代码的过程是由Babel之类的构建工具来完成的。转换过程通常很简单:使用creatElement函数的调用来替换Tag内的代码,并将Tag名、props和children作为参数传入:

const element = React.createElement(
  'h1',
  {title: 'foo'},
  'Hello, world!'
);

React.createElement() 会预先执行一些检查,以帮助你编写无错代码,但实际上它创建了一个这样的对象:

// 注意:这是简化过的结构
const element = {
  type: 'h1',
  props: {
    title: 'foo',
    children: 'Hello, world!'
  }
};

type是一个字符串,用于指定我们要创建的DOM node的类型,它就是HTML元素;props是另一个对象,它具有JSX attributes中的所有键值对,它还有一个特殊的属性:children

我们需要替换的另一部分 React 代码是对ReacDOM.render 的调用。
render 函数是 React 改变 DOM 的地方,所以在这里我们手动实现一下 DOM 的更新:

const node = document.createElement(element.type)
node["title"] = element.props.title

1、首先我们使用element type属性创建一个DOM节点,在这个例子是h1
2、然后我们将所有的element props分配到这个DOM节点中,在这里只有一个title。
3、接下来我们为children创建DOM节点。我们只有一个字符串作为children,所以我们将创建一个文本节点。

const text = document.creatTextNode("");
text["nodeValue"] = element.props.children;

4、最后,我们将textNode添加至h1,将h1添加至container。

node.appendChild(text);
container.appendChild(node);

完整代码如下:

const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}

const container = document.getElementById("root")

const node = document.createElement(element.type)
node["title"] = element.props.title

const text = document.createTextNode("")
text["nodeValue"] = element.props.children

node.appendChild(text)
container.appendChild(node)

一、createElement函数

JSX转化成JS
我们从另外一个稍微复杂的例子来开始:

const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)
const container = document.getElementById("root")
ReactDOM.render(element, container)

在之前的步骤中我们可以看到,一个element是一个含有type和props的对象。我们这个函数唯一要做的就是创建这个对象

const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b")
);

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children,
    },
  }
}

children数组也可以包含字符串和数字这样的原始类型。所以我们为所有不是对象的内容创建一个独立的元素,并为其创建一个特殊的类型:TEXT_ELEMENT

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_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}

二、render

创建DOM node

接下来我们要编写简化版的ReactDOM.render 函数。

function render(element, container) {
 //使用element type创建DOM节点
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)

  //递归地为每个 child 做同样的事,同时将element props分配给DOM node
  const isProperty = key => key !== "children"
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    })

   element.props.children.forEach(child =>
    render(child, dom)
  )
 //将新节点附加到container
  container.appendChild(dom)
}

三、并发渲染与调度

在我们开始实现功能之前我们需要一次重构。

element.props.children.forEach(child =>
    render(child, dom)
)

这里的递归调用便是症结所在。
一旦我们开始渲染,在整棵element tree渲染完成之前程序是不会停止的,如果这棵element tree过于庞大,它有可能会阻塞主进程太长时间,如果浏览器需要做类似于用户输入、拖拽保持动画流畅这样的高优先级任务,则必须等到渲染完成为止。

因此,我们将渲染工作分成几个小部分,在完成每个单元后,如果需要执行其他操作,我们将让浏览器中断渲染。

let nextUnitOfWork = null

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(nextUnitOfWork) {
  // TODO
}

我们使用 requestIdleCallback 构建循环。你可以把 requestIdleCallback 当作是一个 setTimeout ,但在这里浏览器将在主线程空闲时进行回调,而不是指定回调何时运行。

requestIdleCallback 还为我们提供了 deadline 参数。我们可以用它来检查在浏览器需要再次控制之前我们有多少时间。

调度

1、任务优先级
UI产生交互的根本原因是各种事件,这也就意味着事件与更新有着直接联系,不同事件产生的更新,它们的优先级是有差异的,所以更新优先级的根源在于事件的优先级。一个更新的产生可直接导致React生成一个更新任务,最终这个任务被Scheduler调度。
所以在React中,人为地将事件划分了等级,最终目的是决定调度任务的轻重缓急,因此,React有一套从事件到调度的优先级机制。
我们将围绕事件优先级、更新优先级、任务优先级、调度优先级,重点梳理它们之间的转化关系。

  • 事件优先级:按照用户时间的交互紧急程度,划分的优先级
  • 更新优先级:事件导致React产生的更新对象(update)的优先级(update.lane)
  • 任务优先级:产生更新对象之后,React去执行一个更新任务,这个任务所持有的优先级
  • 调度优先级: Scheduler依据React去更新任务生成一个调度任务,这个调度任务所持有的优先级
    前三者属于React的优先级机制,第四个属于Scheduler的优先级机制,Scheduler内部有自己的优先级机制,虽然与React有所区别,但等级的划分基本一致。

2、时间片

四、Fibers

为了组织各个工作单元,我们需要一个数据结构:fiber tree。
我们将为每一个 element 分配一个 fiber,而每个 fiber 将成为一个工作单元。

Fiber 的执行

举个例子,假设我们像渲染这样一个 element tree:

Didact.render(
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>,
  container
)

在 render 函数中我们将会创建 root fiber,将其设置为 nextUnitOfWork。剩下的工作将在 performUnitOfWork 中进行,在那里我们将为每个 fiber 做三件事:

  1. 将 element 添加至 DOM
  2. 为 element 的 children 创建 fiber
  3. 选出下一个工作单元
//1、首先我们删除 render 函数中的原有代码。将创建 DOM node 的部分代码抽离处理,稍后进行填充
function createDom(fiber) {
  const dom =
    fiber.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type)

  const isProperty = key => key !== "children"
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = fiber.props[name]
    })

  return dom
}

function render(element, container) {
  // TODO set next unit of work
}

let nextUnitOfWork = null
//2、在 render 函数中,我们将 nextUnitOfWork 设置为 Fiber Tree 的根节点:
function render(element, container) {
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],
    },
  }
}

let nextUnitOfWork = null
//3、接下来,当浏览器准备好的时候,它将会调用我们的 workLoop 函数,从根节点开始执行 performUnitOfWork 
function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(fiber) {
  // TODO add dom node
  // TODO create new fibers
  // TODO return next unit of work
}
//4、首先,我们创建一个 node 节点然后将其添加至 DOM,将这个 DOM node 保存在 fiber.dom 属性中以持续跟踪
function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }

  // TODO create new fibers
  // TODO return next unit of work
}
//5、接着,为每一个chid创建一个新的 fiber
function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }

  const elements = fiber.props.children
  let index = 0
  let prevSibling = null
    
  while (index < elements.length) {
    const element = elements[index]

    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }
  }
  // TODO return next unit of work
}
//6、将其添加到 Fiber Tree 中,它是 child 还是 sibling ,取决于它是否是第一个 child
function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }

  const elements = fiber.props.children
  let index = 0
  let prevSibling = null
    
  while (index < elements.length) {
    const element = elements[index]

    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }
  }

   if (index === 0) {
      fiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }
  // TODO return next unit of work
}
//7、最后,我们选出下一个工作单元。首先寻找 child ,其次 sibling ,然后是 uncle ( parent 的 sibling)
function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }

  const elements = fiber.props.children
  let index = 0
  let prevSibling = null

  while (index < elements.length) {
    const element = elements[index]

    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }

    if (index === 0) {
      fiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }

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

五、Render 与 Commit 两大阶段(Phases)

为什么要分阶段?

每当我们在处理一个React element时,我们都会添加一个新的节点到DOM中,而浏览器在渲染完成整个数之前可能会中断我们的工作。在这种情况下,用户将看不到完整的UI。

如何分阶段

首先我们需要删除那部分对 DOM 进行修改的代码:

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

 // if (fiber.parent) {
 //   fiber.parent.dom.appendChild(fiber.dom)
 // }
//...
}

相反地,我们会跟踪 Fiber Tree 的根节点。我们称它为「进行中的 root」—— wipRoot。

function commitRoot() {
  commitWork(wipRoot.child)
  wipRoot = null
}

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  domParent.appendChild(fiber.dom)
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
  }
  nextUnitOfWork = wipRoot
}

let nextUnitOfWork = null
let wipRoot = null

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }

  if (!nextUnitOfWork && wipRoot) {
    commitRoot()
  }

  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

我们将这个步骤在 commitRoot 函数中完成。在这里我们将所有节点递归附加到 DOM 中。

六、协调

更新和删除节点的过程,我们称为协调
目前我们只做了添加节点至DOM这个动作,那么更新和删除节点是怎么实现的呢?
我们需要将在render函数上接收到的elements与我们提交给DOM的最后一棵Fiber Tree进行比较。

保存当前渲染的Fiber Tree

因此,在完成commit之后,我们需要对最后一次commit到DOM的一颗Fiber Tree的引用进行保存。我们称它为currentRoot。同时我们也对那个Fiber添加了一个alternate属性。这个属性是对旧Fiber的链接,这个旧Fiber是我们在上个commit阶段向DOM commit的Fiber。

function commitRoot() {
  commitWork(wipRoot.child)
  // currentRoot: 最后一次 commit 到 DOM 的一棵 Fiber Tree
  currentRoot = wipRoot
  wipRoot = null
}

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  domParent.appendChild(fiber.dom)
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  }
  nextUnitOfWork = wipRoot
}

let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null

Reconcile

Reconcile的过程会在执行工作单元时完成。
现在我们把performUnitOfWork中用来创建新Fiber的部分代码抽离成一个新的reconcileChildren函数。

function reconcileChildren(wipFiber, elements) {
  let index = 0
  let prevSibling = null

  while (index < elements.length) {
    const element = elements[index]

    const newFiber = {
      type: element.type,
      props: element.props,
      parent: wipFiber,
      dom: null,
    }

    if (index === 0) {
      wipFiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }
}

在这里我们将用旧的Fibers与新的elements进行调和:

function reconcileChildren(wipFiber, elements) {
  let index = 0
  let oldFiber =
    wipFiber.alternate && wipFiber.alternate.child
  let prevSibling = null

  while (
    index < elements.length ||
    oldFiber != null
  ) {
    const element = elements[index]
    let newFiber = null

    // TODO compare oldFiber to element
        ....
   }
}

我们使用type属性对它们进行比较:

  1. 如果老的Fiber和新的element拥有相同的type,我们可以保留DOM节点并仅使用新的props进行更新。这里我们会创建一个新的Fiber来使DOM节点与旧的Fiber保持一致,而props与新的element保持一致。

    • 我们还向Fiber中添加了一个新的属性effectTag,这里的值为UPDATE。为稍后我们将在commit阶段使用这个属性。
  2. 如果两者的type不一样并且有一个新的element,这意味着我们需要创建一个新的DOM节点。

    • 在这种情况下,我们会用PLACEMENT effect tag来标记新的Fiber。
  3. 如果两者的type不一样,并且有一个旧的Fiber,我们需要删除旧节点。

    • 在这种情况下,我们没有新的Fiber,所以我们把DELETION effect tag添加到旧Fiber中。
// 对于新旧 element 的处理
const sameType =
      oldFiber &&
      element &&
      element.type == oldFiber.type

    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      };
    }
    if (element && !sameType) {
       newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      }
    }
    if (oldFiber && !sameType) {
     oldFiber.effectTag = "DELETION"
      deletions.push(oldFiber) // 这里使用了一个数组来追踪我们想要删除的 node
    }

变更commitWork以处理不同类型的变化

接下来,当我们要 commit 这些变更到 DOM 时,我们就会用到 deletions 这个数组中的 fibers。

function commitRoot() {
  deletions.forEach(commitWork)
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

为了处理前面定义的各种 effectTags,我们也需要对 commitWork 函数进行变更:

function commitWork(fiber) {
if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  if (
    fiber.effectTag === "PLACEMENT" &&
    fiber.dom != null
  ) {
    domParent.appendChild(fiber.dom)
  } else if (
    fiber.effectTag === "UPDATE" &&
    fiber.dom != null
  ) {
    updateDom(
      fiber.dom,
      fiber.alternate.props,
      fiber.props
    )
  } else if (fiber.effectTag === "DELETION") {
    domParent.removeChild(fiber.dom)
  }

  commitWork(fiber.child)
  commitWork(fiber.sibling)
}
  • PLACEMENT: 这个DOM节点添加到父Fiber的节点上
  • DELETION:删除这个child
  • UPDATE:使用最新的props来更新现有的DOM节点

    • 这部分动作将由updateDOM函数来完成: 我们将旧Fiber的props与新Fiber的props进行比较,删除旧的props,并设置新的或者变更之后的props
    • 针对event listener这种特殊的prop,我们将以不同的方式处理:如果event listener发生了变更我们会把它从node中移除,然后设置一个新的event listener
function updateDom(dom, prevProps, nextProps) {
  //Remove old or changed event listeners
  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]
      )
    })

  // Remove old properties
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = ""
    })

  // Set new or changed properties
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name]
    })

  // Add event listeners
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.addEventListener(
        eventType,
        nextProps[name]
      )
    })
}

此处评论已关闭