自己动手写个react

109 阅读4分钟

先来一个最简单的react代码:

_const element = <h1 title="foo">Hello</h1>_

_const container = document.getElementById("root")_

_ReactDOM.render(element, container)_

第一行是jsx,jsx并不是有效的JavaScript代码,一般由构建工具如babel进行一个转义,大概的流程是将对应的代码属性(比如tag name)拿出来传入createElement中,这里的createElement就是由react提供的。

React.createElement通过传入的参数输出一个对象:

const element = React.createElement(
 "h1",
 { title: "foo" },
 "Hello"
)

元素具备2个基本的属性:type和props(暂时只算这2个)

const element = {
    type:'div',
    props:{
        class:'nb',
        children:'hello'
    }
}

type表示元素标签(tagName)如div.h1之类。

props表示元素的属性如children.class.style之类。

上面的children属性是一个string,但是一般情况下是一个元素数组。

然后就是React.render(下划线代码第三行),它将我们创建的元素放到DOM里面。

所以我们只要用我们自己的方法代替createElement和render的功能,我们就实现了一个最基本的react。

接下来我们用babel转义传入的参数:

const element = {
    type:'div',
    props:{
        class:'nb',
        children:'hello',   
    }
}

基于上面的条件创建一个dom元素:

const node = document.createElement(element.type)

node['class'] = element.props.class

然后再给children也创建一个文本元素:

const text = document.createTextNode("")

text['nodeValue'] = element.props.children

下一步,我们将text放到node里面,再将node放到dom中:

node.appendChild(text)

container.appendChild(node)

到这里,我们就已经脱离react的方法自己完成了同样的功能(简易)。

接下来进一步解析,createElement拿到的参数是这样的:

const element = React.createElement(
    'div',
    {id:'nb'},
    React.createElement('h1')
)

我们需要的参数是这样的(其实和react的createElement是一样的):

function createElement(type, props, ...children) {
    return {
        type,
        prosp:{
            ...props,
            children
        }
    }
}

这里面的children是一个元素数组,我们要对字符作特殊处理:

function createElement(type, props, ...children) {
    return {
        type,
        prosp:{
            ...props,
            children:children.map(child=>{
                typeof child === 'object'
                   ?child:createTextElement(child)
            })
        }
    }
}
function createTextElement(child){
    return {
        type:'TEXT_ELEMENT',
        props:{
            nodeValue:text,
            children:[]
        }
    }
}

现在我们基本的方法创建成功,来试着代替掉React吧!

const myReact = {
    createElement
}

const element = myReact.createElement('div', {id:'nb'}, myReact.createElement('h1'))

到这里,我们目前还是用这js实现,如果用JSX的话,我们就得告诉babel来做转义。

/** @jsx myReact.createElement */

我们在代码的最上面加这一行,那么babel就会将我们myReact.createElement参数里面的jsx转化成type.props形式啦。

额,上面createElement差不多了,来看看render函数。

ReactDOM.render(element, container)

先写个自己的框架:

/** @jsx myReact.createElement */
function render(element, container){
    //....
}
const myReact = {
    createElement, 
    render
}
const element = (
    <div id='nb'><h1>nnn</h1></div>
)
const container = document.getElementById('root')
myReact.render(element, container)

render函数作用是将元素挂到dom上面:

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

这里考虑element是不是字符元素:

function render(element, container){
    const dom = element.type === 'TEXT_ELEMENT'
    ?document.createTextNode("") : document.createElement(element.type)
    container.appendChild(dom)
}

再考虑element里面的children:

function render(element, container){
    const dom = element.type === 'TEXT_ELEMENT'    ?document.createTextNode("") : document.createElement(element.type)    
   element.props.children.forEach(child=>        render(child)
    )
    container.appendChild(dom)
}

然后我们将props里面的属性除了children全部都挂到对应的element上:

function render(element, container){
    const dom = element.type === 'TEXT_ELEMENT'   
     ?document.createTextNode("") : document.createElement(element.type)  
    Object.keys(element.props)
           .filter(key=>key!=='children')            
            .forEach(name=>{
                dom[name] = element.props[name]
            })
    element.props.children.forEach(child=>
        render(child)
    )

    container.appendChild(dom)
}

差不多,简单的一个render就完成啦!

但是现在有一个问题,一旦dom树太大,造成dom渲染占主线程时间过长,浏览器就会卡顿,不能做到无感知渲染dom,所以我们将细分每个节点的渲染为一个单位,只在浏览器空闲的时候执行单位任务。

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(){...}
     

这样的话,只有浏览器有空闲时间的情况下才会执行更新dom节点了,这里面的performUnitOfWork函数需要返回下一个渲染unit。

接下来就是unit怎么个执行流程(顺序)了。我们可以引用fiber tree模式,每个unit是一个fiber(也就是每个节点是一个fiber)。

这里我们重新定义render函数(不进行递归渲染rest节点):

function render(element, container){ ... }
function createDom (fiber) {
    const dom = fiber.type === 'TEXT_ELEMENT'
                ?document.createTextNode('')
                :document.createElement(fiber.type)
    Object.keys(fiber.type).filter(key=> key !=== children )
          .forEach(name => {dom[name] = fiber.props[name]}) 
    return dom
}

在render函数里面我们开启一个root fiber:

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

然后我们在浏览器有空闲时间的时候调用workLoop函数时,nextUnitOfWork就有值了:

requestIdleCallback(workLoop)

来看下performUnitOfWork函数:

function performUnitOfWork ( fiber ) {
    if(!fiber.dom) { //如果没有dom就用前面的函数create一个
        fiber.dom = createDom(fiber)
    }
    if(fiber.parent) {
        fiber.parent.dom.appendChild(fiber.dom)//这里挂载dom节点(root节点除外)
    }
    //这里要给fiber(nextUnitOfWork)赋值还有给新的fiber赋parent属性
    //然后返回
    const elements = fiber.props.children //子元素集合
    let index = 0
    let prevSibling = null //放置children中除第一个之外的兄弟元素
    while( index < elements.length ) {
        const element = elements[index]
        const newFiber = {
            type:element.type,
            props:element.props,
            parent:fiber//上面是parent.dom添加所以这里直接写fiber
            dom:null//这里也可以用create创建但是上面已经写了
        }
        if(index === 0){
            fiber.child = newFiber
        } else {
            prevSibling.sibling = newFiber
        }
        prevSibling = newFiber //?这一步很迷惑1
        index++
    }
    if(fiber.child) {
        return fiber.child
    }
    //?这里迷惑2
    let nextFiber = fiber
    while (nextFiber) {
        if(nextFiber.sibling) {
            return nextFiber.sibling
        }
        nextFiber = nextFiber.parent
    }
}

至此,performUnitOfWork完成!

我们每一个unit渲染一个节点,当浏览器没有多余时间给我们的时候渲染就会被中断,这会出现不完整的UI。我们先把appendChild这里删除,再新增一个wipRoot:

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

在我们完成所有节点的添加之后全部一起渲染:

function workLoop (deadline) {
    let shouldYield = false
    while(nextUnitOfWork && !shouldYield){
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
        shouldYield = deadline.timeRemaining() < 1
    }
    if(!nextUnitOfWork && wipRoot){
        commitRoot();
    }
    requestIdleCallback(workLoop)
}
function commitRoot() { ... }//在这里将所有节点添加到dom中