本文基于 https://pomb.us/build-your-own-react/ 实现简单版 React。javascript
本文学习思路来自 卡颂-b站-React源码,你在第几层。html
模拟的版本为 React 16.8。java
将实现如下功能:node
下面上正餐,请继续阅读。react
先来看看一个简单的 React Demo,代码以下:git
const element = <div title="foo">hello</div> const container = document.getElementById('container') ReactDOM.render(element, container);
本例完整源码见:reactDemogithub
在浏览器中打开 reactDemo.html,展现以下:web
咱们须要实现本身的 React,那么就须要知道上面的代码到底作了什么。面试
const element = <div>123</div>
其实是 JSX 语法。算法
React 官网 对 JSX 的解释以下:
JSX 是一个 JavaScript 语法扩展。它相似于模板语言,但它具备 JavaScript 的所有能力。JSX 最终会被 babel 编译为 React.createElement() 函数调用。
经过 babel 在线编译 const element = <div>123</div>
。
可知 const element = <div>123</div>
通过编译后的实际代码以下:
const element = React.createElement("div", { title: "foo" }, "hello");
再来看看上文的 React.createElement 实际生成了一个怎么样的对象。
在 demo 中打印试试:
const element = <div title="foo">hello</div> console.log(element) const container = document.getElementById('container') ReactDOM.render(element, container);
能够看到输出的 element 以下:
简化一下 element:
const element = { type: 'div', props: { title: 'foo', children: 'hello' } }
简单总结一下,React.createElement
其实是生成了一个 element 对象,该对象拥有如下属性:
ReactDOM.render()
将 element 添加到 id 为 container 的 DOM 节点中,下面咱们将简单手写一个方法代替 ReactDOM.render()
。
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)
本例完整源码见:reactDemo2
运行源码,结果以下,和引入 React 的结果一致:
上文经过模拟 React,简单代替了 React.createElement、ReactDOM.render 方法,接下来将真正开始实现 React 的各个功能。
上面有了解到 createElement 的做用是建立一个 element 对象,结构以下:
// 虚拟 DOM 结构 const element = { type: 'div', // 标签名 props: { // 节点属性,包含 children title: 'foo', // title 属性 children: 'hello' // 子节点,注:实际上这里应该是数组结构,帮助咱们存储更多子节点 } }
根据 element 的结构,设计了 createElement 函数,代码以下:
/** * 建立虚拟 DOM 结构 * @param {type} 标签名 * @param {props} 属性对象 * @param {children} 子节点 * @return {element} 虚拟 DOM */ function createElement (type, props, ...children) { return { type, props: { ...props, children: children.map(child => typeof child === 'object' ? child : createTextElement(child) ) } } }
这里有考虑到,当 children 是非对象时,应该建立一个 textElement 元素, 代码以下:
/** * 建立文本节点 * @param {text} 文本值 * @return {element} 虚拟 DOM */ function createTextElement (text) { return { type: "TEXT_ELEMENT", props: { nodeValue: text, children: [] } } }
接下来试一下,代码以下:
const myReact = { createElement } const element = myReact.createElement( "div", { id: "foo" }, myReact.createElement("a", null, "bar"), myReact.createElement("b") ) console.log(element)
本例完整源码见:reactDemo3
获得的 element 对象以下:
const element = { "type": "div", "props": { "id": "foo", "children": [ { "type": "a", "props": { "children": [ { "type": "TEXT_ELEMENT", "props": { "nodeValue": "bar", "children": [ ] } } ] } }, { "type": "b", "props": { "children": [ ] } } ] } }
JSX
实际上咱们在使用 react 开发的过程当中,并不会这样建立组件:
const element = myReact.createElement( "div", { id: "foo" }, myReact.createElement("a", null, "bar"), myReact.createElement("b") )
而是经过 JSX 语法,代码以下:
const element = ( <div id='foo'> <a>bar</a> <b></b> </div> )
在 myReact 中,能够经过添加注释的形式,告诉 babel 转译咱们指定的函数,来使用 JSX 语法,代码以下:
/** @jsx myReact.createElement */ const element = ( <div id='foo'> <a>bar</a> <b></b> </div> )
本例完整源码见:reactDemo4
render 函数帮助咱们将 element 添加至真实节点中。
将分为如下步骤实现:
/** * 将虚拟 DOM 添加至真实 DOM * @param {element} 虚拟 DOM * @param {container} 真实 DOM */ function render (element, container) { const dom = document.createElement(element.type) container.appendChild(dom) }
element.props.children.forEach(child => render(child, dom) )
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] })
以上咱们实现了将 JSX 渲染到真实 DOM 的功能,接下来试一下,代码以下:
const myReact = { createElement, render } /** @jsx myReact.createElement */ const element = ( <div id='foo'> <a>bar</a> <b></b> </div> ) myReact.render(element, document.getElementById('container'))
本例完整源码见:reactDemo5
结果如图,成功输出:
再来看看上面写的 render 方法中关于子节点的处理,代码以下:
/** * 将虚拟 DOM 添加至真实 DOM * @param {element} 虚拟 DOM * @param {container} 真实 DOM */ function render (element, container) { // 省略 // 遍历全部子节点,并进行渲染 element.props.children.forEach(child => render(child, dom) ) // 省略 }
这个递归调用是有问题的,一旦开始渲染,就会将全部节点及其子节点所有渲染完成这个进程才会结束。
当 dom tree 很大的状况下,在渲染过程当中,页面上是卡住的状态,没法进行用户输入等交互操做。
可分为如下步骤解决上述问题:
使用 requestIdleCallback 来解决容许中断渲染工做的问题。
window.requestIdleCallback 将在浏览器的空闲时段内调用的函数排队。这使开发者可以在主事件循环上执行后台和低优先级工做,而不会影响延迟关键事件,如动画和输入响应。
window.requestIdleCallback 详细介绍可查看文档:文档
代码以下:
// 下一个工做单元 let nextUnitOfWork = null /** * workLoop 工做循环函数 * @param {deadline} 截止时间 */ function workLoop(deadline) { // 是否应该中止工做循环函数 let shouldYield = false // 若是存在下一个工做单元,且没有优先级更高的其余工做时,循环执行 while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork( nextUnitOfWork ) // 若是截止时间快到了,中止工做循环函数 shouldYield = deadline.timeRemaining() < 1 } // 通知浏览器,空闲时间应该执行 workLoop requestIdleCallback(workLoop) } // 通知浏览器,空闲时间应该执行 workLoop requestIdleCallback(workLoop) // 执行单元事件,并返回下一个单元事件 function performUnitOfWork(nextUnitOfWork) { // TODO }
performUnitOfWork 是用来执行单元事件,并返回下一个单元事件的,具体实现将在下文介绍。
上文介绍了经过 requestIdleCallback 让浏览器在空闲时间渲染工做单元,避免渲染太久致使页面卡顿的问题。
注:实际上 requestIdleCallback 功能并不稳定,不建议用于生产环境,本例仅用于模拟 React 的思路,React 自己并非经过 requestIdleCallback 来实现让浏览器在空闲时间渲染工做单元的。
另外一方面,为了让渲染工做能够分离成一个个小单元,React 设计了 fiber。
每个 element 都是一个 fiber 结构,每个 fiber 都是一个渲染工做单元。
因此 fiber 既是一种数据结构,也是一个工做单元。
下文将经过简单的示例对 fiber 进行介绍。
假设须要渲染这样一个 element 树:
myReact.render( <div> <h1> <p /> <a /> </h1> <h2 /> </div>, container )
生成的 fiber tree 如图:
橙色表明子节点,黄色表明父节点,蓝色表明兄弟节点。
每一个 fiber 都有一个连接指向它的第一个子节点、下一个兄弟节点和它的父节点。这种数据结构可让咱们更方便的查找下一个工做单元。
上图的箭头也代表了 fiber 的渲染过程,渲染过程详细描述以下:
下面将渲染过程用代码实现。
/** * createDom 建立 DOM 节点 * @param {fiber} fiber 节点 * @return {dom} dom 节点 */ function createDom (fiber) { // 若是是文本类型,建立空的文本节点,若是不是文本类型,按 type 类型建立节点 const dom = fiber.type === 'TEXT_ELEMENT' ? document.createTextNode("") : document.createElement(fiber.type) // isProperty 表示不是 children 的属性 const isProperty = key => key !== "children" // 遍历 props,为 dom 添加属性 Object.keys(fiber.props) .filter(isProperty) .forEach(name => { dom[name] = fiber.props[name] }) // 返回 dom return dom }
fiber 根节点仅包含 children 属性,值为参数 fiber。
// 下一个工做单元 let nextUnitOfWork = null /** * 将 fiber 添加至真实 DOM * @param {element} fiber * @param {container} 真实 DOM */ function render (element, container) { nextUnitOfWork = { dom: container, props: { children: [element] } } }
/** * workLoop 工做循环函数 * @param {deadline} 截止时间 */ function workLoop(deadline) { // 是否应该中止工做循环函数 let shouldYield = false // 若是存在下一个工做单元,且没有优先级更高的其余工做时,循环执行 while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork( nextUnitOfWork ) // 若是截止时间快到了,中止工做循环函数 shouldYield = deadline.timeRemaining() < 1 } // 通知浏览器,空闲时间应该执行 workLoop requestIdleCallback(workLoop) } // 通知浏览器,空闲时间应该执行 workLoop requestIdleCallback(workLoop)
/** * performUnitOfWork 处理工做单元 * @param {fiber} fiber * @return {nextUnitOfWork} 下一个工做单元 */ function performUnitOfWork(fiber) { // TODO 添加 dom 节点 // TODO 新建 filber // TODO 返回下一个工做单元(fiber) }
4.1 添加 dom 节点
function performUnitOfWork(fiber) { // 若是 fiber 没有 dom 节点,为它建立一个 dom 节点 if (!fiber.dom) { fiber.dom = createDom(fiber) } // 若是 fiber 有父节点,将 fiber.dom 添加至父节点 if (fiber.parent) { fiber.parent.dom.appendChild(fiber.dom) } }
4.2 新建 filber
function performUnitOfWork(fiber) { // ~~省略~~ // 子节点 const elements = fiber.props.children // 索引 let index = 0 // 上一个兄弟节点 let prevSibling = null // 遍历子节点 while (index < elements.length) { const element = elements[index] // 建立 fiber const newFiber = { type: element.type, props: element.props, parent: fiber, dom: null, } // 将第一个子节点设置为 fiber 的子节点 if (index === 0) { fiber.child = newFiber } else if (element) { // 第一个以外的子节点设置为该节点的兄弟节点 prevSibling.sibling = newFiber } prevSibling = newFiber index++ } }
4.3 返回下一个工做单元(fiber)
function performUnitOfWork(fiber) { // ~~省略~~ // 若是有子节点,返回子节点 if (fiber.child) { return fiber.child } let nextFiber = fiber while (nextFiber) { // 若是有兄弟节点,返回兄弟节点 if (nextFiber.sibling) { return nextFiber.sibling } // 不然继续走 while 循环,直到找到 root。 nextFiber = nextFiber.parent } }
以上咱们实现了将 fiber 渲染到页面的功能,且渲染过程是可中断的。
如今试一下,代码以下:
const element = ( <div> <h1> <p /> <a /> </h1> <h2 /> </div> ) myReact.render(element, document.getElementById('container'))
本例完整源码见:reactDemo7
如预期输出 dom,如图:
因为渲染过程被咱们作了可中断的,那么中断的时候,咱们确定不但愿浏览器给用户展现的是渲染了一半的 UI。
对渲染提交阶段优化的处理以下:
function performUnitOfWork(fiber) { // 把这段删了 if (fiber.parent) { fiber.parent.dom.appendChild(fiber.dom) } }
// 根节点 let wipRoot = null function render (element, container) { wipRoot = { dom: container, props: { children: [element] } } // 下一个工做单元是根节点 nextUnitOfWork = wipRoot }
function workLoop (deadline) { // 省略 if (!nextUnitOfWork && wipRoot) { commitRoot() } // 省略 }
// 所有工做单元完成后,将 fiber tree 渲染为真实 DOM; function commitRoot () { commitWork(wipRoot.child) // 须要设置为 null,不然 workLoop 在浏览器空闲时不断的执行。 wipRoot = null } /** * performUnitOfWork 处理工做单元 * @param {fiber} fiber */ function commitWork (fiber) { if (!fiber) return const domParent = fiber.parent.dom domParent.appendChild(fiber.dom) // 渲染子节点 commitWork(fiber.child) // 渲染兄弟节点 commitWork(fiber.sibling) }
本例完整源码见:reactDemo8
源码运行结果如图:
当 element 有更新时,须要将更新前的 fiber tree 和更新后的 fiber tree 进行比较,获得比较结果后,仅对有变化的 fiber 对应的 dom 节点进行更新。
经过协调,减小对真实 DOM 的操做次数。
新增 currentRoot 变量,保存根节点更新前的 fiber tree,为 fiber 新增 alternate 属性,保存 fiber 更新前的 fiber tree;
let currentRoot = null function render (element, container) { wipRoot = { // 省略 alternate: currentRoot } } function commitRoot () { commitWork(wipRoot.child) currentRoot = wipRoot wipRoot = null }
将 performUnitOfWork 中关于新建 fiber 的逻辑,抽离到 reconcileChildren 函数;
/** * 协调子节点 * @param {fiber} fiber * @param {elements} fiber 的 子节点 */ function reconcileChildren (fiber, elements) { // 用于统计子节点的索引值 let index = 0 // 上一个兄弟节点 let prevSibling = null // 遍历子节点 while (index < elements.length) { const element = elements[index] // 新建 fiber const newFiber = { type: element.type, props: element.props, parent: fiber, dom: null, } // fiber的第一个子节点是它的子节点 if (index === 0) { fiber.child = newFiber } else if (element) { // fiber 的其余子节点,是它第一个子节点的兄弟节点 prevSibling.sibling = newFiber } // 把新建的 newFiber 赋值给 prevSibling,这样就方便为 newFiber 添加兄弟节点了 prevSibling = newFiber // 索引值 + 1 index++ } }
在 reconcileChildren 中对比新旧 fiber;
保留 dom,仅更新 props,设置 effectTag 为 UPDATE;
function reconcileChildren (wipFiber, elements) { // ~~省略~~ // oldFiber 能够在 wipFiber.alternate 中找到 let oldFiber = wipFiber.alternate && wipFiber.alternate.child while (index < elements.length || oldFiber != null) { const element = elements[index] let newFiber = null // fiber 类型是否相同 const sameType = oldFiber && element && element.type == oldFiber.type // 若是类型相同,仅更新 props if (sameType) { newFiber = { type: oldFiber.type, props: element.props, dom: oldFiber.dom, parent: wipFiber, alternate: oldFiber, effectTag: "UPDATE", } } // ~~省略~~ } // ~~省略~~ }
建立一个新的 dom 节点,设置 effectTag 为 PLACEMENT;
function reconcileChildren (wipFiber, elements) { // ~~省略~~ if (element && !sameType) { newFiber = { type: element.type, props: element.props, dom: null, parent: wipFiber, alternate: null, effectTag: "PLACEMENT", } } // ~~省略~~ }
删除旧 fiber,设置 effectTag 为 DELETION;
function reconcileChildren (wipFiber, elements) { // ~~省略~~ if (oldFiber && !sameType) { oldFiber.effectTag = "DELETION" deletions.push(oldFiber) } // ~~省略~~ }
新建 deletions 数组存储需删除的 fiber 节点,渲染 DOM 时,遍历 deletions 删除旧 fiber;
let deletions = null function render (element, container) { // 省略 // render 时,初始化 deletions 数组 deletions = [] } // 渲染 DOM 时,遍历 deletions 删除旧 fiber function commitRoot () { deletions.forEach(commitWork) }
在 commitWork 中对 fiber 的 effectTag 进行判断,并分别处理。
当 fiber 的 effectTag 为 PLACEMENT 时,表示是新增 fiber,将该节点新增至父节点中。
if ( fiber.effectTag === "PLACEMENT" && fiber.dom != null ) { domParent.appendChild(fiber.dom) }
当 fiber 的 effectTag 为 DELETION 时,表示是删除 fiber,将父节点的该节点删除。
else if (fiber.effectTag === "DELETION") { domParent.removeChild(fiber.dom) }
当 fiber 的 effectTag 为 UPDATE 时,表示是更新 fiber,更新 props 属性。
else if (fiber.effectTag === 'UPDATE' && fiber.dom != null) { updateDom(fiber.dom, fiber.alternate.props, fiber.props) }
updateDom 函数根据不一样的更新类型,对 props 属性进行更新。
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) { // 删除旧属性 Object.keys(prevProps) .filter(isProperty) .filter(isGone(prevProps, nextProps)) .forEach(name => { dom[name] = "" }) // 更新新属性 Object.keys(nextProps) .filter(isProperty) .filter(isNew(prevProps, nextProps)) .forEach(name => { dom[name] = nextProps[name] }) }
另外,为 updateDom 添加事件属性的更新、删除,便于追踪 fiber 事件的更新。
function updateDom(dom, prevProps, nextProps) { // ~~省略~~ const isEvent = key => key.startsWith("on") //删除旧的或者有变化的事件 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] ) }) // ~~省略~~ }
替换 creactDOM 中设置 props 的逻辑。
function createDom (fiber) { const dom = fiber.type === 'TEXT_ELEMENT' ? document.createTextNode("") : document.createElement(fiber.type) // 看这里鸭 updateDom(dom, {}, fiber.props) return dom }
新建一个包含输入表单项的例子,尝试更新 element,代码以下:
/** @jsx myReact.createElement */ const container = document.getElementById("container") const updateValue = e => { rerender(e.target.value) } const rerender = value => { const element = ( <div> <input onInput={updateValue} value={value} /> <h2>Hello {value}</h2> </div> ) myReact.render(element, container) } rerender("World")
本例完整源码见:reactDemo9
输出结果如图:
先来看一个简单的函数式组件示例:
myReact 还不支持函数式组件,下面代码运行会报错,这里仅用于比照函数式组件的常规使用方式。
/** @jsx myReact.createElement */ const container = document.getElementById("container") function App (props) { return ( <h1>hi~ {props.name}</h1> ) } const element = ( <App name='foo' /> ) myReact.render(element, container)
函数式组件和 html 标签组件相比,有如下两点不一样:
经过下列步骤实现函数组件:
function performUnitOfWork(fiber) { // 是不是函数类型组件 const isFunctionComponent = fiber && fiber.type && fiber.type instanceof Function // 若是是函数组件,执行 updateFunctionComponent 函数 if (isFunctionComponent) { updateFunctionComponent(fiber) } else { // 若是不是函数组件,执行 updateHostComponent 函数 updateHostComponent(fiber) } // 省略 }
非函数式组件可直接将 fiber.props.children 做为参数传递。
function updateHostComponent(fiber) { if (!fiber.dom) { fiber.dom = createDom(fiber) } reconcileChildren(fiber, fiber.props.children) }
函数组件须要运行来得到 fiber.children。
function updateFunctionComponent(fiber) { // fiber.type 就是函数组件自己,fiber.props 就是函数组件的参数 const children = [fiber.type(fiber.props)] reconcileChildren(fiber, children) }
4.1 修改 domParent 的获取逻辑,经过 while 循环不断向上寻找,直到找到有 dom 节点的父 fiber;
function commitWork (fiber) { // 省略 let domParentFiber = fiber.parent // 若是 fiber.parent 没有 dom 节点,则继续找 fiber.parent.parent.dom,直到有 dom 节点。 while (!domParentFiber.dom) { domParentFiber = domParentFiber.parent } const domParent = domParentFiber.dom // 省略 }
4.2 修改删除节点的逻辑,当删除节点时,须要不断向下寻找,直到找到有 dom 节点的子 fiber;
function commitWork (fiber) { // 省略 // 若是 fiber 的更新类型是删除,执行 commitDeletion else if (fiber.effectTag === "DELETION") { commitDeletion(fiber.dom, domParent) } // 省略 } // 删除节点 function commitDeletion (fiber, domParent) { // 若是该 fiber 有 dom 节点,直接删除 if (fiber.dom) { domParent.removeChild(fiber.dom) } else { // 若是该 fiber 没有 dom 节点,则继续找它的子节点进行删除 commitDeletion(fiber.child, domParent) } }
下面试一下上面的例子,代码以下:
/** @jsx myReact.createElement */ const container = document.getElementById("container") function App (props) { return ( <h1>hi~ {props.name}</h1> ) } const element = ( <App name='foo' /> ) myReact.render(element, container)
本例完整源码见:reactDemo10
运行结果如图:
下面继续为 myReact 添加管理状态的功能,指望是函数组件拥有本身的状态,且能够获取、更新状态。
一个拥有计数功能的函数组件以下:
function Counter() { const [state, setState] = myReact.useState(1) return ( <h1 onClick={() => setState(c => c + 1)}> Count: {state} </h1> ) } const element = <Counter />
已知须要一个 useState 方法用来获取、更新状态。
这里再重申一下,渲染函数组件的前提是,执行该函数组件,所以,上述 Counter 想要更新计数,就会在每次更新都执行一次 Counter 函数。
经过如下步骤实现:
// 当前工做单元 fiber let wipFiber = null function updateFunctionComponent(fiber) { wipFiber = fiber // 当前工做单元 fiber 的 hook wipFiber.hook = [] // 省略 }
// initial 表示初始参数,在本例中,initial=1 function useState (initial) { // 是否有旧钩子,旧钩子存储了上一次更新的 hook const oldHook = wipFiber.alternate && wipFiber.alternate.hook // 初始化钩子,钩子的状态是旧钩子的状态或者初始状态 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.hook = hook // 返回钩子的状态和设置钩子的函数 return [hook.state, setState] }
下面运行一下计数组件,代码以下:
function Counter() { const [state, setState] = myReact.useState(1) return ( <h1 onClick={() => setState(c => c + 1)}> Count: {state} </h1> ) } const element = <Counter />
本例完整源码见:reactDemo11
运行结果如图:
本章节简单实现了 myReact 的 hooks 功能。
撒花完结,react 还有不少实现值得咱们去学习和研究,但愿有下期,和你们一块儿手写 react 的更多功能。
本文参考 pomb.us 进行学习,实现了包括虚拟 DOM、Fiber、Diff 算法、函数式组件、hooks 等功能的自定义 React。
在实现过程当中小编对 React 的基本术语及实现思路有了大概的掌握,pomb.us 是很是适合初学者的学习资料,能够直接经过 pomb.us 进行学习,也推荐跟着本文一步步实现 React 的常见功能。
本文源码: github源码 。
建议跟着一步步敲,进行实操练习。
但愿能对你有所帮助,感谢阅读~
别忘了点个赞鼓励一下我哦,笔芯❤️
欢迎关注凹凸实验室博客:aotu.io
或者关注凹凸实验室公众号(AOTULabs),不定时推送文章: