react源码学习

学习build-your-own-react,实现一个包括时间切片、fibers架构和Hooks的简易React。

render

这儿有一段简单的JSX语句:

const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)

createElement的实现

const element = <h1 title="foo">Hello</h1>是一个JSX语法,在react中会转换为

const element = React.createElement(
  "h1",
  { title: "foo" },
  "Hello"
)

这里的createElement方法传入type,props和children,然后返回特定的字典:

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

该字典包含了一个dom节点的所有信息。

上例中生成的字典是:

{
    type: "h1",
    props: {
        "children": ["Hello"],
        "title": "foo"
    }
}

render的实现

render实际上就是解析上述字典,然后调用Document对象方法生成目标节点。 相关代码实现如下:

function render(element, container) {
  const dom = document.createElement(element.type)
​  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.appendChild(dom)
}

这里会先创建指定类型的节点,然后将除了children以外的props赋给节点。最后再递归render所有children节点,从而完成该节点的渲染。

render的并发实现

如果children过多,render时间过长,会造成阻塞。因此需要将渲染工作分成小块(unit of work),在切片时间里做这些工作。

首先定义workLoop函数,该方法会在空闲时一直执行渲染工作:

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
}

这里的performUnitOfWork方法会执行一小块工作,同时返回下一块。在切片时间内,会一直做下去。

requestIdleCallBack方法用来在Idle(空闲)状态下调用回调函数,这里调用的是workLoop

每当机器空闲时,会进行切片工作,不会造成阻塞,从而给用户流畅的用户体验。

fibers架构

由上文可知,performUnitOfWork方法会执行当前的unit of work,同时更新nextUnitOfWork,这是如何实现的?

fibers

fibers是一个数据结构,通过childparentsibling三种指针,将DOM元素组织起来,形成fiber tree,从而能够更快的获取nextUnitOfWork

假设有以下的例子:

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

对应的fiber tree结构如下:

fiber_tree

performUnitWork的具体步骤

performUnitOfWork基于fibers架构,具体步骤如下:

  1. 添加元素到DOM中

  2. 创建该元素的子节点的fibers结构

  3. 选择nextUnitOfWork:

    1. 优先选择子节点。在上例中,div fiber结束渲染后,nextUnitOfWork应该为h1 fiber

    2. 其次选择兄弟节点。p fiber没有子节点,nextUnitOfWork应该为a fiber

    3. 然后选择“叔叔”节点(父节点的兄弟节点)。a fiber结束后,nextUnitOfWork应该为h2

    4. 向上递归到root节点。h2 fiber结束后,返回div root fiber,结束渲染流程

代码实现

createDom实现如下

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
}

render实现如下:

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

这里将原来的render拆分成createDom和render两个方法。createDom用来创建DOM节点,而render用来初始化nextUnitOfWork(要渲染的根节点)。

在workLoop中,会不断调用performUnitOfWork方法,通过上文提到的选择节点原则,更新nextUnitOfWork:

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

performUnitOfWork实现如下:

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
​  // 建立fiber tree
  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
  }
}

commit阶段

commit的基础实现

由前文可知,每次在切片时间渲染一部分DOM节点,但在整个DOM Tree渲染完成之前,浏览器可能打断我们的渲染过程。

因此,为了不让用户看到未完全渲染的UI,需要在整个DOM Tree渲染完成后,再提交渲染。

所谓commit,就是这个提交动作。其重点在于,何时判断整个DOM Tree的渲染已完成。

从代码实现上,我们需要记录fiber tree的root节点:

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

workLoop中,一旦完成完整DOM Tree的渲染,再通过commitRoot提交渲染:

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
​  // 当没有nextUnitOfWork且未提交过时,进行提交
  if (!nextUnitOfWork && wipRoot) {
    commitRoot()
  }
​
  requestIdleCallback(workLoop)
}

commitRoot中通过递归将整个DOM 节点的fibers进行提交:

function commitRoot() {
  commitWork(wipRoot.child)
  wipRoot = null
}
​
function commitWork(fiber) {
  if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  domParent.appendChild(fiber.dom)  // 将子节点挂到dom上
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

更新与删除

除了将节点添加到dom中,还会遇到节点更新和删除的情况。因此,我们需要保存上一次提交时的fiber tree,用于比较两次渲染的差异,从而实现最小化更新。

对于每个fiber节点,新增alternate属性,用于链接上次提交时的old fiber,这里的old fiber就是currentRoot

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

reconcile

performUnitOfWork方法中的建立fiber tree部分提取成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++
  }
}

接下来,我们对这段代码做一些修改。

首先,获取现在想要渲染的节点element和上次渲染的节点oldFiber

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
​
    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }
   ... 
  }
}

然后,对新旧节点做一些比较:

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
​
    const sameType =
      oldFiber &&
      element &&
      element.type == oldFiber.type
​
    if (sameType) {
      // TODO update the node
    }
    if (element && !sameType) {
      // TODO add this node
    }
    if (oldFiber && !sameType) {
      // TODO delete the oldFiber's node
    }
​
    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }
   ... 
  }
}

比较的逻辑如下:

  1. 如果旧节点和新节点的类型一样,我们保留旧节点,将其属性更新为新节点属性即可

  2. 如果旧节点和新节点的类型不一样

    1. 如果存在新节点,则创建新节点

    2. 如果存在旧节点,则删除旧节点

完成上述逻辑,进一步修改代码:

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
​
    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)
    }
​
    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }
   ... 
  }
}

可以看到更新时,保留了旧节点,并添加了新节点的属性;创建时,根据element的type和props进行创建;删除时,由于不需要新节点,则直接添加到deletions数组中,后续会移除该旧节点。

在commit阶段,我们通过wipRoot来提交更新,是拿不到old fiber的,所以需要一个deletions数组保存所有要删除的节点。(deletions是全局变量)

这里的effectTag用来标志更新的类别,在后续的commit阶段会用上。

接下来介绍commit是如何具体实现的。

首先,在commitRoot方法中,会去遍历deletions数组:

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

接下来,在commitWork方法中,会去判断effectTag,从而执行不同的操作:

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)
}

当类型为创建时,直接调用domParent.appendChild(fiber.dom)将新节点添加到domParent下。

当类型为删除时,直接调用domParent.removeChild(fiber.dom)将节点从domParent下删除。

当类型为更新时,调用updateDom方法:

const isProperty = key => key !== "children"
const isNew = (prev, next) => key =>
  prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
  // 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]
    })
}

该方法通过遍历旧节点的所有属性,当该属性在新节点下不存在,则移除;当该属性值在新节点下存在且不一致,则更新为新节点的值。

还存在一个特例:event listener也需要更新,逻辑为删除旧的listener,创建新的listener:

const isEvent = key => key.startsWith("on") // 根据on前缀判断是否是event listener
const isProperty = key =>
const isProperty = key => key !== "children"
const isNew = (prev, next) => key =>
  prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
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]
      )
    })
}