Build your own React 阅读笔记

96 阅读6分钟

pomb.us/build-your-…

了解react是如何渲染,阅读源码可能不是一件容易的事情,但是这篇文章作者用了不到400行代码构建mini React 用来介绍react的渲染过程,虽然是基于react16.8,但和现在渲染中心思想没有太大变化,学习完对react渲染整体流程会有一个比较清晰认识,为后续深入研究某一项过程有一个良好开端。

  React JSX 转为原生写法

React JSX 是一种声明式写法,无需命令式的方式(即直接操作DOM来更新UI)来构建UI。

const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)  //V18之前写法

注:V18使用ReactDOM.createRoot(root).render(<App />) 来渲染root节点以支持并发模式。

对于上面jsx 如何写法 通过babel调用React内置的createElement等方法,实际上会被转义一个对象记录节点的type类型props等基本信息 ,而ReactDOM.render 可以理解为事件根据传入React.element 对象生成实际dom挂载到container 容器节点上(当前真是react肯定会做很多优化和处理,比如root节点事件委托等内容,但这些都暂时不考虑)。

// jsx 对应js对象
const element = {
    type: "h1",
    props: {
        title: "foo",
        children: "Hello",
   }
},
const container = document.getElementById("root")

// 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)

写一个createElement和Render

在编译时让babel调用我们实现createElement转为对象,对着react.createElement实现看看参数列表,基本都是包含节点的type类型propschildren信息。

React.createElement('img',{src:'xxxx'})

这个就是最初到React.Element结构(需要渲染的结构),后续需要靠它的信息生成对应Fiber节点,再通过Fiber节点生成实际dom节点。

自定义的createElement

function createElement(type, props, ...children) {
 return {
   type,
   props: {
     ...props,
     children: children.map((child) =>
       typeof child === "object" ? child : createTextElement(child)
     ),
   },
 };
}

function createTextElement(text) {
 return {
   // 这里将文本类型,定义TEXT_ELEMENT
   type: "TEXT_ELEMENT",
   props: {
     nodeValue: text,
     children: [],
   },
 };
}

/** @jsx Didact.createElement */
const element = (
   <div id="foo">
       <span>图片描述</span>
       <img src='xxxx'/>
   </div>)
   
// 上面jsx会转义成下面的babel调用
const elementObj = Didact.createElement(
   "div",
   { id:"foo"},
   Didact.createElement("span", null, "图片描述"),
   Didact.createElement("img")
)

不完整的render方法

  拿到需要确认渲染结构elements对象,我们只需要传入render函数调用相应的document.createElement等原生相关方法好像就可以了?

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

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)
}

走到这里用自定义的createElement方法和不完整render也可以实现jsx 到生成dom节点。但是render由于是递归调用的,这意味着一旦开始渲染,就不会停止执行,如果元素树很大,可能会阻塞主线程太长时间。如果浏览器需要执行高优先级的操作,例如处理用户输入或保持动画流畅,则必须等到渲染完成。 所以还需要进行优化实现并发模式渲染

工作单元和Fiber Tree

render使用了递归调用的方式,一旦开始渲染,无法中断,React考虑将整体渲染工作划分成更小的工作单元,在完成工作单元后,允许去中断渲染来支持其它优先级更高的任务,这里作者使用了requestIdleCallback函数,该函数允许传入回调函数让浏览器中空闲状态下去执行回调函数,保障渲染过程不会阻塞浏览器其他响应,在react中使用是scheduler(调度包)来控制。

定义一个performUnitOfWork函数表示工作单元需要做的事情,渲染工作就像下面伪代码一样。

    /**
        @param deadline: 用于获取当前空闲时间对象
    */
    function workLoop(deadline){
       // 是否应该阻塞渲染
        let shouldYield = false
        // 这里没有下一个工作单元,渲染执行完,递归就结束
        while(nextUnitOfWork&& !shouldYield){
            nextUnitOfWork = performUnitOfWork(nextUnitOfWork) // 返回下一个需要执行工作
            
            shouldYield = deadline.timeRemaining()<1
        }
        // 尝试下一次空闲时去执行
        requestIdleCallback(workLoop)
    }
    // 开启渲染Loop
   requestIdleCallback(workLoop)

这种渲染模式,比最开始写的render相比就好像将一幅图像,拆成一块块拼图🧩(生成fiber),按卡槽位置一些策略拼接(比较fiber 节点优化策略)拼的过程比较费时,过程中你可以去喝水,上厕所(一些更高优先级事情),最后拼出完整图像发朋友圈🐶(挂载在root上)。

React.render(
 <div>
   <h1>
     <p />
     <a />
   </h1>
   <h2 />
 </div>,
 container
)

按照root->child-〉sibling-〉parent...->root(顺序遍历完)

遍历过程中,需要对每一个element对象转为fiber对象,还需要找到下一个元素。需要注意的是用不能在遍历中去修改对应dom元素,因为render过程是可以被中断的,所以中断后渲染内容是不可预料的内容(不知道你拼出个啥玩意),所以react在这里分开成两个阶段render,**commit过程

  • render过程:协调过程(reconcileChildren),计算好需要更新的fiber Tree。 (耗时长,可中断)
  • commit过程:提交实际操作dom结构 (不可中断,保证页面渲染一致性)

render渲染过程中,我们反复比较element Tree(需要渲染的内容)与最近一次提交的fiber Tree进行比较来复用原来内容,进行添加,更新和删除等操作这个过程就是协调,原文中作者介绍很简单,后面可以深入了解,先看看原文内容。

  • 如果旧的 Fiber 和新的元素具有相同的类型,我们可以保留 DOM 节点并用新的 props 更新它
  • 如果类型不同并且有新元素,则意味着我们需要创建一个新的 DOM 节点
  • 如果类型不同并且存在旧fiber,我们需要删除旧节点
 const reconcileFuntion = (elementTree,lasteFiberTree)=>{
     ...
    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom, //
        parent: wipFiber,
        alternate: oldFiber, //旧节点
        effectTag: "UPDATE", //commit阶段映射做对应操作
      };
    }
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      };
    }
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION"; 
      // 删除没有新fiber,只能在旧fiber打effectTag。
      deletions.push(oldFiber);
      // commit 删除时是没有旧fiber,额外使用deletions数组记录信息。
    }
     ...
 }

Commit 提交浏览器去渲染

原文也比较简单,通过effectTag去执行相应计算,根据effectTag,按照child->sibling...,递归的遍历直到没有节点为止。

// 递归的将所有节点追加到dom中
function commitWork(fiber) {
  if (!fiber) {
    return;
  }

  let domParentFiber = fiber.parent;
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.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") {
    commitDeletion(fiber, domParent);
  }

  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

总结:

文章过程清晰易懂,并且和完整demo可以调试学习,但是因为简练,所以没有深入介绍具体过程。并且由于内容比较久远,需要结合其他最新的文章一起看比较好,总的来说,还是可以了解阅读,对初学者友好,像讲故事一样展开。