【译】build you own react 学习笔记

174 阅读11分钟

原文地址:pomb.us/build-your-…

最近在学习react源码,在搜集资料的时候,看到这篇由react开发者写的文章,由浅入深的带你实现一个 react,里面提及到的很多概念、比如 fiber,reconsile(diff 算法),commitRoot,这些都是 react 有的理念,看完这篇文章再去学习 react 源码,会更容易理解一些,非常推荐大家去看这篇文章。

因为这篇文章主要还是编码为主,所以我觉得对于这篇文章最好的学习方式是,fork 一下原文章的 codeSandBox 然后把相关代码写一遍(为啥用 codeSandBox ,因为相关的环境都配置好了,拿来即用😊🤣),最后学习完了之后写一篇博客,这种方式可以帮助我们加强理解,同时也方便后续温故而知新。

step 0:回顾

首先,我们来回顾下基础概念,如果你已经很清楚的了解 reactjsxdom元素是如何工作的,可以跳过这个章节

我们使用如下的react app,它只有三行代码,第一行定义了一个 react 元素,第二行获取文档中的一个 dom 元素,第三行将这个 react 元素渲染到这个 dom 元素里

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

让我们移除react 特性的代码,使用原生的js来实现上面的功能 首先第一行,是一个用 jsx 语法定义的元素,我们使用 babeljsx 转化成 js 代码,转化后的代码如下

// 经过 babel 编译后,jsx代码会变成 React.createElement 函数的调用
const element = React.createElement(
    "h1",
    { title: "foo" },
    "Hello"
)

const container = document.getElementById("root")
ReactDOM.render(element, container)

现在有react特性的代码主要有两处,一处是 React.createElement, 另一处是ReactDom.render

Step 1: 实现 CreateElement

首先我们来实现 React.createElement

function createElement(type, props, ...children) {
  return {
    // 元素类型
    type,
    // 属性
    props: {
      ...props,
      // 子元素,如果类型是 text 类型,调用 createTextElement 方法
      // 原因是因为dom api创建文本类型的元素,与其他元素标签不一样
      children: children.map(child =>
        typeof child === "object" ? child : createTextElement(child)
      )
    }
  };
}


function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: []
    }
  };
}

我们来看下调用之后会得到什么?

// 调用 createElement
const element = createElement(
    "h1",
    { title: "foo" },
    "Hello"
)

// 输出
element = {
    type: 'h1',
    props: {
        title: 'foo',
        children: [
            {
                type: "TEXT_ELEMENT",
                props: {
                  nodeValue: 'Hello',
                  children: []
                }
            }
        ]
    }
}

如果我们使用复杂一点的结构呢?

// jsx
const element = (
    <div title={'11'}>
      <div>22</div>
      hello
    </div>
)

// babel转译后
const element = React.createElement(
    "div",
    {title: '11'},
    React.createElement("div", null, "22"),
    "hello"
);

// 把 React.createElement 替换成 step1 实现的createElement,得到输出
element = {
    type:"div",
    props: {
        title: "11",
        children: [
            {
                type: "div",
                props: {
                    children: [{
                        type: "TEXT_ELEMENT",
                        props: {
                            nodeValue: "22",
                            children: []
                        }
                    }]
                }
            },
            {
                type: "TEXT_ELEMENT",
                props: {
                    nodeValue: "hello",
                    children: []
                }
            }
        ]
    }
}

step 2:实现 Render 函数

我们尝试用原生js来替换 ReactDom.render

render函数接受两个参数,第一个参数是 虚拟dom元素(一种描述dom的数据结构,根据这种数据结构生成新的dom节点),第二个参数是真实的dom元素,作为element的父节点。

一句话描述当前版本的render函数的作用,将虚拟dom转化成真实dom,并渲染到节点中。

function render(element, container) {
   // 根据 element 创建 dom 元素
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type);

  // 遍历属性,添加到dom元素上
  const isProperty = key => key !== "children";
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name];
    });
  
  // 递归,渲染 children
  element.props.children.forEach(child => render(child, dom));
  
  // 将渲染好的元素,追加到 container 元素里
  container.appendChild(dom);
}

全部代码及执行结果:codesandbox.io/p/sandbox/d…

这里推荐一个工具,babel 在线转化工具:babeljs.io/ ,可以通过这个工具看到babel转化后的代码。

image.png

step 3:并发模式

在我们学习并发模式之前,我们需要重构下代码

目前我们实现的 render 是使用递归的方式,这样会导致,一旦我们启动了 render,直到一整棵dom树渲染完成,这期间是无法中断渲染的,如果这棵树太大,那么会阻塞主线程很长时间,如果这时浏览器有一些更高优先级的事务,比如处理用户的输入,动画流畅执行,那么这些高优先级的事务将会被一直阻塞,直到render完成

所以,我们将把渲染整棵树的工作,拆分成一个个小的单元,执行完每个单元后,如果有更高优先级的事务插入,我们让浏览器中断渲染,去处理更高优先级的工作

我们使用 requestIdleCallback 方法来实现,可以把这个方法理解成 setTimeout, 当浏览器的主线程闲置时,会执行 requestIdleCallback,它接受一个参数 deadline, 通过这个参数来判断我们还有多少时间可以用来渲染,什么时候浏览器会夺回控制权

React 目前不再使用 requestIdleCallback,而是使用 scheduler package,但是概念是一样的。

截至2019年11月,并发模式在 react 中尚不稳定。循环的稳定版本看起来更像是这样:

while (nextUnitOfWork) {    
  nextUnitOfWork = performUnitOfWork(   
    nextUnitOfWork  
  ) 
}

在开启这个循环之前,我们需要确定第一个工作单元,我们使用 performUnitOfWork 函数,这个函数会执行当前的工作单元,并返回下一个工作单元。

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
}

step 4: Fibers

为了组织每个工作单元,我们需要一个新的数据结构,fiber

每一个元素都有一个 fiber 节点,且每一个 fiber 都会作为一个工作单元

举个🌰,我们想要渲染下面这棵树

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

在渲染过程中,我们需要创建一个root fiber, 并将它设为下一个工作单元,后续的工作会在 performUnitOfWork方法中运行,我们会对每个 fiber 节点做三个事情

  1. 将这个元素添加到 dom 树中
  2. 为这个节点的 chidren 创建 fiber 节点
  3. 选出下一个工作单元

设计 fiber树 这种数据结构,其中一个目标是,能够很方便选出下一个工作单元,这也是为啥每一个 fiber 节点都与它的第一个子节点(这里注意,父节点的child只指向第一个子节点)、父节点、下一个兄弟节点有连接。

fiber作为一个工作单元,如何选取下一个工作单元?

  1. 优先选择当前 fiber 节点 的 child fiber
  2. 如果当前fiber节点没有child fiber,那么选择 subling fiber(兄弟节点)
  3. 如果当前fiber节点既没有child fiber 也 没有 subling fiber,那么下一个工作单元是 “uncle”,父 fiber 节点的 subling 兄弟节点,如果没有 “uncle”,那么会顺着父节前往上找,直到找到 subling 节点。

fiber树的结构如下图所示:

image.png

了解了fiber相关知识后,开始编码实现它

首先,我们把render函数重构下,把创建 dom 元素从 render中剥离,变成一个单独的方法

在render 函数中,我们确定第一个工作单元

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
}

let nextUnitOfWork = null

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

当浏览器空闲的时候,会执行 requestIdleCallback,该方法中会调用 performUnitOfWork 方法,在 performUnitOfWork 方法中我们知道当前的fiber节点信息

function workLoop(deadline) {
    let shouldYield = false
    while (nextUnitOfWork && !shouldYield) {
        // 执行到这里,运行 performUnitOfWork 
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
        shouldYield = deadline.timeRemaining() < 1
    }
    requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(fiber) {
    // 为当前fiber节点创建 dom
    if (!fiber.dom) {
        fiber.dom = createDom(fiber)
    }
    
    // 添加到dom树
    if (fiber.parent) {
        fiber.parent.dom.appendChild(fiber.dom)
    }
    
    const elements = fiber.props.children
    let index = 0
    let prevSibling = null
    
    // 遍历子节点,求出fiber树
    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
    }
}

step 5: render 阶段 和 commit 阶段

这里有一个问题,就是我们每运行一个工作单元时,我们都添加了一个新节点到dom树中,当浏览器打断我们渲染时,我们就会让用户看到一个不完整的UI,所以我们需要移除这个对dom的突变(mutates)。

我们追踪这个fiber树的根节点,我们将还在构建中的fiber树,叫做 wipRoot (work in progress root)

let nextUnitOfWork = null
let wipRoot = null

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

当我们完成了 wip fiber 树的构建,我们提交整棵fiber树到 dom中
我们新建 一个 commitRoot 方法,在这个方法中将全部节点 追加到dom中,如下

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

step 6: Reconciliation 协调器

到目前为止,我们实现了将节点添加到dom,但是 update(更新) 和 delete(删除) 呢?我们需要去对比render中的fiber树,与最近一次提交到dom中的fiber树,我们将最新一次提交到dom中的fiber树保存到 currentRoot 变量中,当前正在渲染的fiber树我们叫做 wipRoot。对于每一个fiber节点,用 alternate属性连接

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

function commitRoot() {
    commitWork(wipRoot.child)
    // 保存最新提交到 dom 中的 fiber 树为 currentRoot
    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 属性连接新旧 fiber 树
        alternate: currentRoot,
    }
    nextUnitOfWork = wipRoot
}

改写下 performanceUnitOfWork 方法,在该方法中调用 reconcileChildren 函数,在该函数完成新旧 fiber 节点的对比(reconcile),也就是 diff 算法。

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

    const elements = fiber.props.children
    // 调用 reconcileChildren 函数
    reconcileChildren(fiber, elements)

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

reconcileChildren 函数中,我们同时遍历新老fiber节点的子节点,老fiber节点通过 wipFiber.alternate 的方式获取。我们通过对比他们的差异,来判断最终渲染到dom上的元素是否需要变更。

对比新旧fiber节点的异同,我们使用下面的规则:

  1. 如果新旧fiber是相同的类型,保留dom节点,替换属性
  2. 如果类型不同,且新fiber是一个新元素,那么我们需要创建一个dom节点
  3. 如果类型不同,且存在一个旧fiber节点,我们需要移除这个旧fiber节点

react 也使用 key 值进行对比,使得该算法的性能更好


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

    // 我们同时遍历新老老fiber节点的子节点
    while (index < elements.length || oldFiber != null) {
        const element = elements[index]
        
        let newFiber = null
        const sameType = oldFiber && element && element.type == oldFiber.type

        if (sameType) {
            // 如果新旧fiber是相同的类型,保留dom节点,替换属性
            newFiber = {
                type: oldFiber.type,
                props: element.props, // 替换属性
                dom: oldFiber.dom, // 保留dom节点
                parent: wipFiber,
                alternate: oldFiber,
                effectTag: "UPDATE", // 添加一个 effectTag 属性,在commit 阶段生效
            }
        }

        if (element && !sameType) {
            // 如果类型不同,新建一个fiber 节点
            newFiber = {
                type: element.type,
                props: element.props,
                dom: null,
                parent: wipFiber,
                alternate: null,
                effectTag: "PLACEMENT", // 添加一个 effectTag 属性,在commit 阶段生效
            }
        }

        if (oldFiber && !sameType) {
            // 如果存在旧的 fiber 节点,需要删除旧的fiber 节点
            oldFiber.effectTag = "DELETION" // 对旧fiber树添加标签
            deletions.push(oldFiber)
        }

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

        prevSibling = newFiber
        index++
    }
}

step 7: 函数组件

我们需要支持下函数组件,下面是一个简单的函数组件的例子,看看它经过babel编译后长啥样?

function App(props) {
    return <h1>Hi {props.name}</h1>
}

const element = <App name="foo" />
const container = document.getElementById("root")
Didact.render(element, container)

step 8: Hooks

结语

下面就是全部的代码

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: [],
        }
    }
}

function createDom(fiber) {
    const dom = fiber.type == "TEXT_ELEMENT"
        ? document.createTextNode("")
        : document.createElement(fiber.type)

    updateDom(dom, {}, fiber.props)
    return dom
}

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)

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

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

function commitWork(fiber) {
    if (!fiber) {
        return
    }

    let domParentFiber = fiber.parent
    while (!domParentFiber.dom) {
        domParentFiber = domParentFiber.parent
    }
    const domParent = domParentFiber.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") {
        commitDeletion(fiber, domParent)
    }
    commitWork(fiber.child)
    commitWork(fiber.sibling)
}

function commitDeletion(fiber, domParent) {
    if (fiber.dom) {
        domParent.removeChild(fiber.dom)
    } else {
        commitDeletion(fiber.child, domParent)
    }
}


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


let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
let deletions = 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)

function performUnitOfWork(fiber) {
    const isFunctionComponent = fiber.type instanceof Function
    if (isFunctionComponent) {
        updateFunctionComponent(fiber)
    } else {
        updateHostComponent(fiber)
    }
    
    if (fiber.child) {
        return fiber.child
    }
    let nextFiber = fiber
    while (nextFiber) {
        if (nextFiber.sibling) {
            return nextFiber.sibling
        }
        nextFiber = nextFiber.parent
    }
}

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 useState(initial) {
    const oldHook = 
        wipFiber.alternate 
        && wipFiber.alternate.hooks
        && wipFiber.alternate.hooks[hookIndex]

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

function updateHostComponent(fiber) {
    if (!fiber.dom) {
        fiber.dom = createDom(fiber)
    }
    reconcileChildren(fiber, fiber.props.children)
}


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
        }

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

        prevSibling = newFiber
        index++
    }
}


const Didact = {
    createElement,
    render,
    useState,
}

/** @jsx Didact.createElement */
function Counter() {
    const [state, setState] = Didact.useState(1)
    return (
        <h1 onClick={() => setState(c => c + 1)}>
        Count: {state}
        </h1>
    )
}

const element = <Counter />
const container = document.getElementById("root")
Didact.render(element, container)