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