build-your-own-react 注释版

原文链接 | 中文翻译版
这是一篇很经典的文章,通过循序渐进开发一个迷你版 react,让你明白 react 的基本原理。
本文只是将文章中的代码敲了一遍,通过注释描述各功能的作用,加上自己的理解。

注:代码中的 dom 变量表示通过 document.createElement 等方法创建的真实 DOM;element 变量表示虚拟 DOM 对象;fiber 是对虚拟 DOM 的增强


/**
 * 
 * JSX 通过 babel 编译后就是调用 createElement
 *
 * @param {string} type 节点类型,如 div, p
 * @param {object} props 节点属性,如 id, class, width, height, style
 * @param {array} children 子节点,包括 元素节点、文本节点
 * @returns
 */
function createElement(type, props, ...children) {
    return {
        type,
        props: {
            ...props,
            children: children.map(child => {
                typeof child === 'object'
                ? child
                : createTextElement(child)
            })
        }
    };
}

// 创建文本节点。对于 sting, number 这样的基本类型
function createTextElement(text) {
    return {
        type: 'TEXT_ELEMENT',  // 文本节点自定义 type 为 TEXT_ELEMENT
        props: {
            nodeValue: text,
            children: []
        }
    };
}

// fiber 就是一个增强的虚拟 dom 节点。这里通过 fiber 创建一个真实的 dom 节点
function createDom(fiber) {
    const dom =
        fiber.type == "TEXT_ELEMENT"
        ? document.createTextNode("")
        : document.createElement(fiber.type);

    // 获取 element.props 上的属性赋值给 dom。(排除 children 属性)
    const isProperty = key => key !== "children";
    Object.keys(fiber.props)
        .filter(isProperty)
        .forEach(name => {
            dom[name] = element.props[name]
        });

    return dom;
}

// 更新 dom
const isEvent = key => key.startsWith("on")  // on 开头的为事件
const isProperty = key => key !== "children" && !isEvent(key)  // children 和 onXxx 不为属性
const isNew = (prev, next) => key => prev[key] !== next[key]  // 属性值不相等则为新属性
const isGone = (prev, next) => key => !(key in next)  // key 不在新属性中则该移除
function updateDom(dom, prevProps, nextProps) {
    // 对事件的处理
    // 旧的事件不在新属性中,或者同名事件的值不相等,则移除该事件
    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]
            )
        })

    // 对普通属性的处理
    // 旧属性不在新属性中,则移除
    Object.keys(prevProps)
        .filter(isProperty)
        .filter(isGone(prevProps, nextProps))
        .forEach(name => {
            dom[name] = ""
        })
​
    // 旧属性的 key 值与新属性不同,则修改/添加
    Object.keys(nextProps)
        .filter(isProperty)
        .filter(isNew(prevProps, nextProps))
        .forEach(name => {
            dom[name] = nextProps[name]
        })
}

// 将提交的修改更新到真实 dom。只会在 fiber 全部构建完成后才会提交,然后一次性把所有改动进行渲染
function commitRoot() {
    deletions.forEach(commitWork)  // 删除 dom 节点
    commitWork(wipRoot.child)
    currentRoot = wipRoot
    wipRoot = null
}
function commitWork(fiber) {
    if (!fiber) {
        return
    }

    let hasDomParentFiber = fiber.parent
    // 父 fiber 是函数组件生成的 fiber 则没有 dom,需要一直往上找到有 dom 的 fiber 节点,
    // 再在这个父 fiber 的 dom 中对当前 fiber 进行更改操作
    while (!hasDomParentFiber.dom) {
        hasDomParentFiber = hasDomParentFiber.parent
    }
    const domParent = hasDomParentFiber.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)
}
// 移除节点需要移除该 fiber 下第一个有 dom 节点的 fiber 节点。
// (添加节点调用 appendChild,即使没有 dom 也无所谓,可以不处理。更新节点是更新属性,也不需要真实的 dom 节点。当然按理也是需要处理的)
function commitDeletion(fiber, domParent) {
    if (fiber.dom) {
        domParent.removeChild(fiber.dom)
    } else {
        commitDeletion(fiber.child, domParent)
    }
}

// 渲染页面
function render(element, container) {
    // 这里自己构造了一个 wipRoot,结构和 fiber 相同,可以看作是”根 fiber“
    wipRoot = {
        dom: container,
        props: {
            children: [element]
        },
        alternate: currentRoot,  // 旧 fiber 节点的引用
    }
    deletions = [];
    nextUnitOfWork = wipRoot;  // 将”根 fiber“赋值给 nextUnitOfWork 准备调用
}


// 调度器的实现
// 一旦开始进行构建虚拟 dom 进行渲染,这过程中构建虚拟 dom 可能会耗费很多时间,出现性能问题。所以需要将构建任务分成一些小块(即 fiber),
// 每当完成其中一块任务后,就把控制权交给浏览器,让浏览器判断是否有更高优先级的任务需要完成。
let nextUnitOfWork = null  // 下一次构建的 fiber 树
let currentRoot = null  // 保存上次提交到 DOM 节点的 fiber 树的引用,用于对虚拟 DOM 进行比较
let wipRoot = null  // wipRoot(work in progress root)。一棵树用来记录对 DOM 节点的修改,用于一次性提交进行 DOM 的修改。
let deletions = []  // 需要移除的 fiber 数组
function workLoop(deadline) {
    let shouldYield = false;
    // 每次 while 循环构建一个 fiber,被中断后可以回来继续构建
    while (nextUnitOfWork && !shouldYield) {
        // 构建当前 fiber,返回下一个待构建的 fiber
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
        // deadline.timeRemaining() 返回当前闲置周期的预估剩余毫秒数。小于1则说明没有时间了,停止 while 循环终止 fiber 的构建
        shouldYield = deadline.timeRemaining() < 1
    }

    // 当下一个 fiber 节点为 undefined 即所有 fiber 都构建完成,并且 DOM 修改记录树有的,则进行提交修改 DOM
    // fiber 没完全构建则等浏览器执行完其它任务后再回来,继续上面的 while 循环,从 nextUnitOfWork 继续构建
    if (!nextUnitOfWork && wipRoot) {
        commitRoot()
    }

    requestIdleCallback(workLoop)
}

// requestIdleCallback 浏览器内置方法!!!有兼容问题,react 是自己实现的 scheduler,原理相通。
// requestIdleCallback 类似 setTimeout,只不过这次是浏览器来决定什么时候运行回调函数,而不是 setTimeout 里通过我们指定的一个时间。
// 浏览器会在主线程有空闲的时候运行回调函数。
// requestIdleCallback 会给我们一个 deadline 参数。我们可以通过它来判断离浏览器再次拿回控制权还有多少时间。
requestIdleCallback(workLoop)

// 构建 fiber,并返回下一个 fiber。
// 在构建 fiber 的时候至少会完成当前 fiber 的构建,所以我们返回下一个待构建的 fiber 存储下来,当中断的时候就可以继续从下一个 fiber 开始。
function performUnitOfWork(fiber) {
    const isFunctionComponent = fiber.type instanceof Function;
    // 函数组件和基础组件不同,基础组件就是一个基本的 dom 元素,而函数组件需要通过运算后获得
    if (isFunctionComponent) {
        updateFunctionComponent(fiber)
    } else {
        updateHostComponent(fiber)
    }

    // 返回下一个待构建的 fiber 节点
    // 首先获取 child,没有 child 获取 sibling,没有 sibling 则获取 parent 然后获取 parent 的 sibling。直到所有元素都被遍历,返回 undefined。
    if (fiber.child) {
        return fiber.child
    }
    let nextFiber = fiber
    while (nextFiber) {
        if (nextFiber.sibling) {
            return nextFiber.sibling
        }
        nextFiber = nextFiber.parent
    }
}
// 提供给 hooks 使用的变量
let wipFiber = null  // 当前执行的 fiber
let hookIndex = null  // 当前执行的 hook 索引
// 更新函数组件
function updateFunctionComponent(fiber) {
    wipFiber = fiber
    hookIndex = 0
    wipFiber.hooks = []  // useState 可以多次调用,需要使用一个数组来维护

    // fiber.type 获取到函数并执行,返回 return 的基础组件虚拟 dom(类组件则应该实例化后调用 render 方法)。fiber.props 是函数组件接收的属性。
    const element = fiber.type(fiber.props);
    const children = [element]
    reconcileChildren(fiber, children)
}
// ​更新基础组件,如 div, p, span...
function updateHostComponent(fiber) {
    // 基础组件直接和 dom 关联
    if (!fiber.dom) {
        fiber.dom = createDom(fiber);  // 我们通过 fiber.dom 这个属性来维护创建的 DOM 节点。
    }
    // 为每个子节点创建对应的新的 fiber 节点
    const elements = fiber.props.children
    reconcileChildren(fiber, elements)
}

// 初始化值,每次函数组件执行的时候,没旧 hook 则获取初始值;有旧 hook 则获取旧 hook 上的值
function useState(initial) {
    // 获取当前 fiber 对应的旧 fiber 上的旧 hook
    const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]
    const hook = {
        state: oldHook ? oldHook.state : initial,  // 旧 hook 存在,则将旧 hook 的值复制给新 hook,否则初始化值
        queue: []  // 存储 setState 调用时传入的函数。setState 可以连续多次调用,所以使用队列保存
    }

    // setState 被调用的时候并不会立即执行,而是将接收到的 action 保存在当前 hook 的 queue 中。待下次渲染的时候再执行
    const setState = action => {
        hook.queue.push(action)
        // 修改 wipRoot 为当前组件,当调用 setState 的时候,执行页面的更新操作就会从当前组件开始,而不是从根组件
        wipRoot = {
            dom: currentRoot.dom,
            props: currentRoot.props,
            alternate: currentRoot,
        }
        nextUnitOfWork = wipRoot
        deletions = []
    }
    // 当组件每次渲染的时候就会按流程执行 useState,然后会执行对应 hook 中的所有 action
    const actions = oldHook ? oldHook.queue : []
    actions.forEach(action => {
        hook.state = action(hook.state)
    })

    // 将 hook 添加到 wipFiber.hooks
    wipFiber.hooks.push(hook)
    // hook 索引加1,多次执行 useState 的时候就是对下一个索引进行操作
    // 函数组件执行的时候 useState 也是按顺序执行的,每个 useState 对应执行顺序的索引
    hookIndex++
    return [hook.state, setState]
}

/**
 * 协调器:比较新旧虚拟 DOM 构建 fiber 树
 * @param {object} wipFiber fiber 节点
 * @param {array} elements 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]
    ​
        // const newFiber = {  // 可以看出 fiber 相对于 mReact.createElement() 生成的数据结构有所不同
        //     type: oldFiber.type,  // 节点类型
        //     props: element.props,  // 节点属性
        //     parent: wipFiber,  // 每个子节点的 parent 指向当前 fiber
        //     dom: oldFiber.dom,  // 关联的 dom 元素
        //     alternate: oldFiber,  // 旧 fiber 节点的引用
        //     effectTag: 'UPDATE',  // DOM 修改类型
        // }
        let newFiber = null;

        const sameType = oldFiber && element && element.type == oldFiber.type
    ​
        // 对于新旧节点类型是相同的情况,我们可以复用旧的 DOM,仅修改上面的属性
        if (sameType) {
            newFiber = {
                type: oldFiber.type,
                props: element.props,
                dom: oldFiber.dom,
                parent: wipFiber,
                alternate: oldFiber,
                effectTag: "UPDATE",
            }
        }
        // 如果类型不同,意味着我们需要创建一个新的 DOM 节点
        if (element && !sameType) {
            newFiber = {
                type: element.type,
                props: element.props,
                dom: null,
                parent: wipFiber,
                alternate: null,
                effectTag: "PLACEMENT",
            }
        }
        // 如果类型不同,并且旧节点存在的话,需要把旧节点的 DOM 给移除
        if (oldFiber && !sameType) {
            oldFiber.effectTag = "DELETION"  // 不会创建新的 fiber,对旧 fiber 进行标记
            deletions.push(oldFiber)
        }
        ​
        if (oldFiber) {
            oldFiber = oldFiber.sibling
        }


        if (index === 0) {  // 当前 fiber 的 child 指向子节点的第一个节点。当前 fiber 和子节点就是双向链表
            wipFiber.child = newFiber
        } else {  // 不是第一个节点,则将上一个节点的 sibling 指向此节点。同级节点就是一个单向链表
            prevSibling.sibling = newFiber
        }
    ​
        prevSibling = newFiber  // 当前子节点赋值给上一个子节点
        index++
    }
}


const mReact = {
    createElement,
    render,
    useState
}



// const element = (
//     <div id="foo">
//         <a>bar</a>
//         <b />
//     </div>
// )
const element = mReact.createElement(
    'div',
    { id: 'foo' },
    mReact.createElement("a", null, "bar"),
    mReact.createElement("b")
);


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



// 函数组件:1.函数组件的 fiber 没有 DOM 节点;2.子节点由函数运行得来而不是直接从 props 属性中获取
// function App(props) {
//     return <h1>Hi {props.name}</h1>
// }
// const element = <App name="foo" />
function App(props) {
    return mReact.createElement(
        "h1",
        null,
        "Hi ",
        props.name
    )
}
const element = mReact.createElement(App, {
    name: "foo",
})
const container = document.getElementById("root")
mReact.render(element, container)



// hooks
function Counter() {
    const [state, setState] = mReact.useState(1)
    return (
        <h1 onClick={() => setState(c => c + 1)}>
            Count: {state}
        </h1>
    )
}
const element = <Counter />
const container = document.getElementById("root")
mReact.render(element, container)

在帮你理解 React 是如何工作的同时,这篇文章的另一个任务是让你更容易地深入 React 源码。因此文章中的许多变量和函数命名和 React 中的一致。

比如,你在真实的 React 应用的函数组件中打断点的时候,调用栈会告诉你

  • workLoop
  • performUnitOfWork
  • updateFunctionComponent

我们并没有涉及很多 React 的功能和优化。以下是一些 React 和我们的不同点:

  • 在 Didact 的 render 阶段中,我们遍历了整颗 fiber 树。 React 通过一些启发式算法跳过没有发生变更的子树。
  • 在 commit 阶段,我们遍历了整颗 fiber 树。React 维护了一个列表用于记录变化的 fiber 并且只访问这些 fiber。
  • 每次我们新建一个 wipTree,我们对每个 fiber 新建了一个对象。React 会尽可能复用之前 fiber 树的对象。
  • 当 Didact 在 render 阶段接受了一个更新(update)的时候, 他会重新从根节点开始新的 wipTree 并且丢掉之前的 wipTree。React用期望时间戳记录每一个更新并且以此决定哪个 update 有更高的优先级。
  • 更多…
原文地址:https://www.cnblogs.com/3body/p/15424233.html