阅读 70

实现自己的react

0. 前言

最近偶尔看到一篇文章,讲解的内容是从零构建自己的React。文章从实际的react代码出发,用几百行代码重新构建了react的基本框架。我想,这篇文章对react源码的理解有很大的帮助,于是自己写一篇博客来总结一下文章的内容。原文请戳这里

1. react做了哪些事情

相信任何一个使用过react的朋友都知道,react作为一个前端的框架,他做的事情就是页面渲染,使得前端的开发组件化。我们正常创建一个react应用只需要如下三行代码即可:

const element = <h1 title="foo">Hello React</h>
const container = document.getElementById('root')
ReactDOM.render(element,container)
复制代码

第一行代码通过JSX语法创建了一个react组件,第二行代码获取了HTML文件中的一个容器元素,第三行代码将react组件渲染道容器元素中。这个简单的过程反映了react做的事情:

  1. 创建react组件
  2. 将组件渲染到HTML的容器元素中

当然第二步这个渲染是有条件的渲染,还要考虑其他的因素。渲染的条件在react中被称为state。此外,还需要考虑组件间通信的问题,这里我们会用到props

2. 构建react关键函数

构建react关键函数的这部分内容,我们从react代码聊起。

2.0 review

在上一部分提到的那段代码:

const element = <h1 title="foo">Hello React</h>
const container = document.getElementById('root')
ReactDOM.render(element,container)
复制代码

第一行代码并不是合法的JS代码,它是需要通过Babel转义的JSX代码。如果我们给他写成合法的JS代码,如下:

//const element = <h1 title="foo">Hello React</h>
const element = React.createElement(
    'h1',
    {title: 'foo'},
    'Hello React'
)
复制代码

这一行代码创建了一个element,也就是我们常说的虚拟DOM。在react中,实际的element有很多属性。我们这里只是搭建简单的react框架,因此只列出几个非常重要的属性。通过这个函数创建的element结果如下:

//const element = React.createElement(
//    'h1',
//    {title: 'foo'},
//    'Hello React'
//)
const element = {
    type: 'h1',
    props: {
        title: 'foo',
        children: 'hello',
    },
}
复制代码

在我们自己的react架构中,上述三种类型的代码是可以互换的。我们来分别看一下这些参数:

  • type是我们想要创建的元素类型,他得知一般是HTML的标签名称
  • props是对象类型的参数,包含了JSX属性的键值对。但是其中有一个特殊的键值对children
  • children的值在这个例子中是一个字符串,但是更多的情况它是一个包含了所有子组件的数组。

替换完第一行代码,接下来我们来替换第三行代码。这一行代码做的事情是根据element提供的信息创建HTML元素,并且将子元素append到父元素中。

const container = document.getElementById('root')
//ReactDOM.render(element,container)
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)
复制代码

首先我们创建node元素作为container的子节点,然后为其添加属性,并且利用document.createTextNode()创建text元素,并设置其nodeValue的值,作为node的内部文字元素。最后将其,append到父元素中。

现在,我们没有使用react就得到了相同的APP。

2.1 The createElement Function

在这一部分我们采用一个新的例子:

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

在这里,我们同样把JSX的代码变换成JS的标准语法:

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

由第一部分,我们可以得到createElement的输入和输出分别是:

  • 输入:
const element = React.createElement(
    'h1',                            // HTML标签
    {title: 'foo'},                  // JSX属性
    'Hello React'                    // 子节点
)
复制代码
  • 输出:
const element = {
    type: 'h1',                     // HTML标签,即元素的类型
    props: {                        // JSX属性
        title: 'foo',
        children: 'hello',          // 子节点
    },
}
复制代码

不难发现,在创建元素的函数中,实现的功能就是将输入变成element对象输出。因此,我们有如下的代码:

function createElement(type, props, ...children) {
    return {
        type,
        props: {
            ...props,
            children
        }
    }
}
复制代码

这段代码仔细看,还是存在问题的。children这个属性的值有两种类型的,string和react组件。但是我们在进行渲染的时候,需要渲染的是react组件。因此,这里需要对string类型的数据进行处理。处理的方法很简单,就是把它变成react组件元素。代码如下:

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: [],
        },
    }
}
复制代码

到现在,我们已经完成了createElement()函数的功能开发,接下来,我们需要给自己的react库取一个名字,并且包含这个函数。在原文中,这个库的名字叫做Didact。 完整的代码如下:

const Didact = {
    createReact,
}

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

const element = Didact.createElement(
    "div",
    { id: "foo" },
    Didact.createElement("a", null, "bar"),
    Didact.createElement("b")
)
const container = document.getElementById("root")
ReactDOM.render(element,container)
复制代码

我们甚至还想在我们自己的Didact中使用JSX语法。我们只需要告诉Babel转义模块即可:

/** @jsx Didact.createElement */
const element = (
    <div id="foo">
        <a>bar</a>
        <b />
    </div>
)
复制代码

2.2 The render Function

在这一部分,我们替换ReactDOM.render(element,container)这行代码。完成react的渲染功能。首先,我们先把架子搭起来:

const Didact = {
    createElement,
    render
}

function render(element, container) {
    // 渲染DOM的代码
}
复制代码

按照2.0部分的渲染方式,根据element给的信息,创建HTML元素,并且将子元素append到父元素上就完成了组件的渲染。代码如下:

function render(element, container) {
    const dom = document.createElement(element.type)
    container.appendChild(dom)
}
复制代码

在上述代码中,我们只是将最外层的element渲染到了容器中,那么element中包含的子元素怎么办呢?这里我们需要采用DFS,将子元素渲染到父元素中。代码如下:

function render(element, container) {
    const dom = document.createElement(element.type)
    element.props.children.forEach(child => {
        render(child, dom)
    })
    container.appendChild(dom)
}
复制代码

别忘了,我们在创建element的时候,还对string类型的元素做了特殊的处理。在这里,我们也需要处理一下string类型的元素。

function render(element, container) {
    const dom = element.type === "TEXT_ELEMENT" 
        ? document.createTextNode("") 
        : 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)
}
复制代码

到此,我们的render()函数也写完了。关于这个render()函数,踩坑姐有话要说。

这段代码很完美的应用了函数式编程这个理念,它的每一行都是一个输入导致一个输出。而且他也很完美的体现了JS面向对象的特点,剥离了类模式那些乱七八糟的概念,我们只需要关注对象之间的关系就好了。看起来就很清爽,很舒服。

最后,我们把到目前为止的完整版代码整理出来如下:

const Didact = {
    createElement,
    render
}

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 render(element, container) {
    const dom = element.type === "TEXT_ELEMENT" 
        ? document.createTextNode("") 
        : 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)
}

/** @jsx Didact.createElement */
const element = (
    <div id="foo">
        <a>bar</a>
        <b />
    </div>
)

const container = document.getElementById("root")
Didact.render(element,container)
复制代码

2.3 并发模式

在前面的部分,我们已经完成了元素的生成和渲染工作。但是当需要渲染的组件很多的时候,我们采用的DFS迭代渲染这种方式容易造成浏览器进程的阻塞,影响用户的体验。为了解决这个问题,我们利用requsetIdleCallback()这个函数,将完整的渲染过程分成若干个小片,在浏览器空闲的时候调用这些小片段,完成渲染。在写代码之前,我们需要先了解一下requestIdleCallback()这个函数。

2.3.1 requestIdleCallback() 函数

requestIdleCallback是一个当浏览器处于闲置状态时,调度工作的新的性能相关的API。这个函数往往和requestAnimationFrame放到一起。要想了解他们的工作原理,我们需要知道浏览器是怎么呈现页面的。

image.png

如上图所示,我们所看到的网页都是浏览器一帧一帧绘制出来的,图片的一帧包含了用户的交互、js的执行、以及requestAnimationFrame的调用,布局计算以及页面的重绘等工作。假如某一帧里面要执行的任务不多,那么每一帧就会有一定的空闲时间,这段时间就恰好可以用来执行requestIdleCallback的回调,如下图所示:

image.png

当程序栈为空页面无需更新的时候,浏览器处于空闲状态,这时候留给requestIdleCallback执行的时间就可以适当拉长。

  • 参数配置:
    • callback:一个在时间循环空闲时即将被调用的函数的引用。函数会接收到一个名为IdleDeadLine的参数,这个参数可以获取当前的空闲时间以及回调是否在超时时间前已经执行的状态。
    • option(可选):包括可选的配置参数。
      • timeout:如果制定了timeout,并且有一个正值,而回调在timeout毫秒过后还没有被调用,那么回调任务将当如事件循环中排队。

2.3.2 重构 render 函数

废话不多说,先贴代码:

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 函数,使得回调函数 workLoop 在浏览器空闲的时候执行。和requestIdleCallback 的参数介绍保持一致,workLoop 接收一个名为 deadline 参数,用来获取当前的空闲时间以及回调是否在超时时间前已经执行的状态。workLoop 函数的第一行定义了一个shouldYeild参数,表示代码是否应该被执行,它通过deadline.timeRemaining()来获取浏览器用来执行任务的空余时间。如果剩余时间大于1,则执行任务;否则,不执行。nextUnitOfWork表示接下来要执行的任务。这段代码的关键部分是while循环,当满足有任务执行且剩余空闲时间大于1时执行函数名为performUnitOfWork() 的操作,并且更新while循环的两个判断条件。换言之,页面渲染的操作需要定义到函数performUnitOfWork()中。

讲了半天,我们需要解决的主要问题如何把递归渲染的进程切片还是没有解决啊。各位看官不要着急,我们往下看。

2.4 Fibers

在这一部分,我们需要一个更复杂的虚拟DOM树。示例代码如下:

Didact.render(
    (
        <div>
            <h1>
                <p />
                <a />
            </h1>
            <h2 />
        </div>
    ),
    container
)
复制代码

组织进程片段的数据结构,我们称之为Fiber Tree。上述代码的Fiber Tree如下图所示:

image.png

在渲染的过程中,我们创建一个 root Fiber 并且将它设置为 nextUnitOfWork。然后就可以把剩下的工作交给函数 performUnitOfWork 来完成。在这个函数中,只需要完成三件事:

  1. 在 DOM 中添加元素
  2. 创建新的 fiber 作为元素的子节点
  3. 选择下一个进程片段

现在,问题的焦点集中在如何选择进程片段上。我们知道,在DFS中,如果某个节点存在子节点,那么遍历的下一个节点是它的第一个子节点;如果该节点不存在子节点,那么遍历的下一个节点时它的兄弟节点;如果该节点不存在子节点以及兄弟节点,那么便利的下一个节点是他的父节点的兄弟节点。虽然我们将DFS的过程切片了,但是渲染的方法还是采用的DFS,因此,下一个进程片段的选择应该和DFS保持一致,即在子节点、兄弟节点、父节点的兄弟节点三个中间选择一个。完整的代码如下:

// 根据 fiber 创建 DOM 的函数
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) {
    // 设置 nextUnitOfWork 作为 render 遍历的起点
    nextUnitOfWork = {
        dom : container,
        props: {
            children: [element],
        }
    }
}

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) {
    // 1. 在 DOM 中添加元素
    if(!fiber.dom) {
        fiber.dom = createDom(fiber)
    }
    if(fiber.parent) {
        fiber.parent.dom.appendChild(fiber.dom)
    }
    // 2. 创建新的 fiber 作为元素的子节点
    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++
    }
    // 3. 选择下一个进程片段
    if(fiber.child) {
        return fiber.child
    } 
    let nextFiber = fiber
    while(nextFiber){
        if(nextFiber.sibling){
            return nextFiber.sibling
        }
        nextFiber = nextFiber.parent
    }    
}
复制代码

2.5 Render 和 Commit 阶段

现在,我们还有一个问题。我们的渲染过程是在浏览器的空闲时间完成的,也就是说浏览器可以随时打断我们的渲染过程,这个时候用户将看到不完整的UI。因此,我们需要在performUnitOfWork中移除改变DOM的部分。然后,我们将保存fiber tree的root为wipRoot。一旦我们完成所有的工作,就将整个fiber Tree commit 到 DOM中。改变后的代码如下:

function commitRoot() {
    // 向 dom 中添加节点
    commitWork(wipRoot.child)
    wipRoot = null
}

function commitWork(fiber) {
    if(!fiber) {
        return
    }
    const domParent = fiber.parent.dom
    domParent.appentChild(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
    }
    
    // 满足 wipRoot 不为空,nextUnitOfWork为空,代表 fiberTree 的构建工作完成了
    if(!nextUnitOfWork && wipRoot) {
        commitRoot()
    }
    
    requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
复制代码

代码写到这里,我们已经完成了首屏渲染。我们来总结一下:

  1. render函数完成首屏渲染的核心是树的生成和遍历, 对应render的两个阶段 —— render阶段和commit阶段;也对应render的两个步骤 —— fiberTree的创建和将fiberTree挂载到DOM上
  2. 在render阶段中创建的fiberTree本身就携带遍历的顺序信息,child属性和sibling属性都是下一个需要遍历的节点
  3. 我们把两个阶段分开的目的是不让用户看到不完整的页面。如果把两个阶段放到一起,渲染一个节点花费的时间很长。由于浏览器随时有可能打断渲染工作,会出现渲染一半就被打断的情况,用户会看到不完整的界面。

2.6 Reconciliation

在这一部分,我们需要完成的功能是在首屏渲染完成后,当部分页面发生改变时,实现局部重绘的功能。想要实现这个功能,需要将在render函数中接收到的元素与commit给DOM的最后一个fiberTree进行比较。为了方便进行比较,需要将最后一个fiberTree保存到currentRoot这个变量中。同时,我们也在每个fiber Tree中添加了一个名叫alternate的属性。代码如下:

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

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

    nextUnitOfWork = wipRoot
}

let nextUnitOfWork = null
let wipRoot = null
let currentRoot = null
复制代码

进行元素对比的函数我们称他为reconcileChildren,这个函数在performUnitOfWork中进行调用。代码如下:

function performUnitOfWork(nextUnitOfWork) {
    // 1. 在 DOM 中添加元素
    if(!fiber.dom) {
        fiber.dom = createDom(fiber)
    }
    
    /*** 这段代码已经在commit阶段被分出去了
    ** if(fiber.parent) {   
    ** fiber.parent.dom.appendChild(fiber.dom)
    ** }
    **/
    
    // 进行对比更新的代码,并且添加节点的代码
    const elements = fiber.props.children
    reconcileChildren(fiber,elements)
    
    // 3. 选择下一个进程片段
    if(fiber.child) {
        return fiber.child
    } 
    let nextFiber = fiber
    while(nextFiber){
        if(nextFiber.sibling){
            return nextFiber.sibling
        }
        nextFiber = nextFiber.parent
    }    
}

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++
    }
}
复制代码

reconcileChildren函数的功能是进行比对更新,具体代码如下:

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
        
        // 比较 element 和 oldFiber
        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
    }
}
复制代码

然后我们需要添加一个数组deletion来表示删除的fiber。

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 deletaions = null
复制代码

最后在 commit 阶段根据我们预先设置的 effectTag 将创建好的 FiberTree 挂载到 HTML容器中即可。

function commitRoot() {
    deletions.forEach(commitWork)
    commiWork(wipRoot.child)
    currentRoot = wipRoot
    wipRoot = null
}
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 commitWork() {
    if(!fiber) {
        return
    }
    const domParent = fiber.parent.dom
    if(fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
        domParent.appentChild(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.dom)
    commitWork(fiber.sibling)
}
复制代码

2.7 Function Component

我们接下来需要做的事情是支持component函数。首先,我们需要使用更加简单的函数组件的例子来简化问题。例子如下:

function App(props) {
    return <h1>Hi {props.name}</h1>
}
const element = <App name="foo"/>
const container = document.getElementById("root")
Didact.render(element, container)
复制代码

用工厂函数创建的组件和用createElement方法创建的组件在两个方面有所不同:

  • 用工厂函数创建的组件不存在DOM节点
  • 用工厂函数创建的组件得到子节点需要调用函数,而不是直接从props获得

在组件渲染的时候,我们需要检测组件的类型是否是由工厂函数创建的,并且单独处理由工厂函数创建的组件。首先,我们需要修的是perfomUnitOfWork的代码:

function performUnitOfWork(fiber) {
    // if(!fiber.dom) {
    //     fiber.dom = createDom(fiber)
    // }
    // const elements = fiber.props.children
    // reconcileChildren(fiber, elements)
    
    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
    } 
}

function updateFunctionComponent(fiber) {
    // 更新函数组件
    const children = [fiber.type(fiber.props)]
    reconcileChildren(fiber, children)
}

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

然后需要修改commit函数的代码:

function commit(fiber) {
    if(!fiber) {
        return
    }   
    
    //const domParent = fiber.parent.dom
    
    let domParentFiber = fiber.parent
    while(!domParentFiber.dom) {
        domParentFiber = domPrarentFiber.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") {
        //domParent.removeChild(fiber.dom)
        
        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)
    }
}
复制代码

最后,我们使用我们自己的render函数:

/** @jsx Didact.createElement */
function App(props) {
    return <h1>Hi {props.name} </h1>
}
const element = <App name="foo" />
const container = document.getElementById("root")
Didact.render(element, container  )
复制代码

在这部分的最后,我们对上面的代码做一个总结和分析:这部分代码主要是想让我们的Didact库能够支持Function Component。想让代码支持Function Component,那么,我们首先需要搞清楚Function Component和普通的DOM组件之间的区别。首先,我们把开始的JSX代码转义成JS代码:

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

const element = <App name="foo" />
复制代码
  • js:
function App(props) {
    return Didact.createElement(
        "h1",
        null,
        "Hi",
        props.name
    )
}
const element = Didact.crerateElement(
    App,
    {name: "foo"}
)
复制代码

从这个对比可以看出来,直接使用Didact.createElement()创建的组件对象,它的类型一般是HTML元素,type的类型是string。使用component组件函数创建的对象,其类型不是HTML元素,type的类型是function,即我们自己定义的组件函数。除此之外,compnent组件函数创建的对象无法直接获取DOM组件和props属性列表,需要调用一下这个函数才行。因此,我们在这一部分做的工作就是通过对组件type属性的类型进行判断,如果是function,则调用函数,将其变成普通的DOM组件进行处理。

2.8 Hooks

这部分是构建Didact库的最后一部分。在这一部分,我们要做的事情是让我们的库能够支持Hooks。主要支持的一个功能是useState。我们首先需要改变一下我们的例子,给他添加一个state属性。代码如下:

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

这段代码所描述的含义已经十分清楚了,计数器的初始数值是1,每点击一次,计数器的数值会增加1.在这个部分中,我们需要调用Didact.useState()来获取并且更新计数器的值。首先,定义这个函数:

const Didact = {
    createElement, 
    render,
    useState
}
function useState(initial) {
    // TODO
}
复制代码

在这一部分,我们先把代码写完,然后再做分析:

let wipFiber = null
let hookIndex = null

function updateFunctionComponent(fiber) {
    wipFiber = fiber
    hookIndex = 0
    wipFiber.hooks = []
    const children = [fiber.type(fiper.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]
}

复制代码

useState的相关代码所描述的是和状态相关的页面渲染的更新。它其实给我们打了一个时间差,除了代码初始化之外,每一次状态更新用到的都是上一次保存在queue中的action。调用这些函数更新完状态之后,又把新的action赋值过来。用它原文的话来讲就是

But we haven’t run the action yet. We do it the next time we are rendering the component, we get all the actions from the old hook queue, and then apply them one by one to the new hook state, so when we return the state it’s updated.

3. 总结

Build your own React这篇文章通过自己从0构建一个React的可替代库,让读者能够深入理解React的工作原理,并且能够更容易地深入划分React代码库。从这篇文章,我们也能看出来,react的代码库的主干是render相关的workLoop。而workLoop被分为了两个阶段render阶段和commit阶段。除此之外,还有用来对比更新组件的diff算法(reconciliation部分的内容)。为了支持组件函数,将DOM元素和组件函数创建的元素分开处理。最后,还提供了Hook的支持。以上是本篇文章的内容概览。如果我们需要在一个真实的React应用中添加断点调试代码的话,那么以下几个地方可以添加:

  • workLoop
  • perfomUintOfWork
  • updateFunctionComponent

虽然这篇文章讲述了React的大概工作原理,但是React的很多特性和性能优化,这里都没有体现。如果需要详细了解React的工作原理,只看这一篇文章是不够的,还需要进一步的了解相关资料。

文章分类
前端