乐趣区

关于react.js:React-整体感知

当咱们由浅入深地认知一样新事物的时候,往往须要遵循 Why > What > How 这样一个认知过程。它们是相辅相成、缺一不可的。而理解了具体的 WhatHow 之后,往往可能更加具象地答复实践层面的 Why,因而,在进入 Why 的摸索之前,咱们先整体感知一下 WhatHow 两个过程。

What

关上 React 官网,第一眼便能看到官网给出的答复。

React 是用于构建用户界面的 JavaScript 库。

不晓得你有没有想过,构建用户界面的形式有千百种,为什么 React 会突出?同样,咱们能够从 React 哲学里失去回应。

咱们认为,React 是用 JavaScript 构建疾速响应的大型 Web 应用程序的首选形式。它在 Facebook 和 Instagram 上体现优良。

可见,要害是实现了 疾速响应 ,那么制约 疾速响应 的因素有哪些呢?React 是如何解决的呢?

How

让咱们带着下面的两个问题,在遵循实在的 React 代码架构的前提下,实现一个蕴含工夫切片、fiberHooks的繁难 React,并舍弃局部优化代码和非必要的性能,将其命名为 HuaMu

留神:为了和源码有点辨别,函数名首字母大写,源码是小写。

CreateElement 函数

在开始之前,咱们先简略的理解一下 JSX,如果你感兴趣,能够关注下一篇《JSX 背地的故事》。

JSX会被工具链 Babel 编译为 React.createElement(), 接着React.createElement() 返回一个叫作 React.ElementJS对象。

这么说有些形象,通过上面 demo 看下转换前后的代码:

// JSX 转换前
const el = <h1 title="el_title">HuaMu<h1>;

// 转换后的 JS 对象
const el = {
  type:"h1",
  props:{
    title:"el_title",
    children:"HuaMu",
  }
}

可见,元素是具备 typeprops 属性的对象,而 CreateElement 函数的次要工作就是创立该对象。

/**
 * @param {string} type    HTML 标签类型
 * @param {object} props   具备 JSX 属性中的所有键和值
 * @param {string | array} children 元素树
 */
function CreateElement(type, props, ...children) {
  return {
    type,
    props:{
      ...props,
      children,
    }
  }
}

阐明:咱们将残余参数赋予children,扩大运算符用于结构字面量对象props,对象表达式将依照 key-value 的形式开展,从而保障 props.children 始终是一个数组。接下来,咱们一起看下 demo

CreateElement("h1", {title:"el_title"}, 'hello', 'HuaMu')

// 返回的 JS 对象
{
  "type": "h1",
  "props": { 
    "title": "el_title"            // key-value
    "children": ["hello", "HuaMu"] // 数组类型
  }
}

留神:当 ...children 为空或为原始值时,React 不会创立 props.children,但为了简化代码,暂不思考性能,咱们为原始值创立非凡的类型TEXT_EL

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

function CreateTextElement(text) {
  return {
    type: "TEXT_EL",
    props: {
      nodeValue: text,
      children: []}
  }
}

Render 函数

CreateElement 函数将标签转化为对象输入,接着 React 进行一系列解决,Render 函数将解决好的节点依据标记进行增加、更新或删除内容,最初附加到容器中。上面简略的实现 Render 函数是如何实现增加内容的:

  • 首先创立对应的 DOM 节点,而后将新节点附加到容器中,并递归每个孩子节点做同样的操作。
  • 将元素的 props 属性调配给节点。

    function Render(el,container) {
    // 创立节点
    const dom = el.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(el.type);
    el.props.children.forEach(child => Render(child, dom))
    // 为节点调配 props 属性
    const isProperty = key => key !== 'children';
    const setProperty = name => dom[name] = el.props[name];
    Object.keys(el.props).filter(isProperty).forEach(setProperty)
    container.appendChild(dom);
    }

    留神 :文本节点应用textNode 而不是innerText,是为了保障以雷同的形式看待所有的元素。

到目前为止,咱们曾经实现了一个繁难的用于构建用户界面的 JavaScript 库。当初,让 Babel 应用自定义的 HuaMu 代替 React,将 /** @jsx HuaMu.CreateElement */ 增加到代码中,关上 codesandbox看看成果吧。

并发模式

在持续向下摸索之前,咱们先思考一下下面的代码中,有哪些代码制约 疾速响应 了呢?

是的,在 Render 函数中递归每个孩子节点,即这句代码 el.props.children.forEach(child => Render(child, dom)) 存在问题。一旦开始渲染,便不会进行,直到渲染了整棵元素树,咱们晓得,GUI渲染线程与 JS 线程是互斥的,JS 脚本执行和浏览器布局、绘制不能同时执行。如果元素树很大,JS 脚本执行工夫过长,可能会阻塞主线程,导致页面掉帧,造成卡顿,且障碍浏览器执行高优作业。

那如何解决呢?

通过工夫切片的形式,行将工作合成为多个工作单元,每实现一个工作单元,判断是否有高优作业,若有,则让浏览器中断渲染。上面通过 requestIdleCallback 模仿实现:

简略阐明一下:

  • window.requestIdleCallback(cb[, options]):浏览器将在主线程闲暇时运行回调。函数会接管到一个 IdleDeadline 的参数,这个参数能够获取以后闲暇工夫(timeRemaining)以及回调是否在超时前曾经执行的状态(didTimeout)。
  • React 已不再应用requestIdleCallback,目前应用 scheduler package。但在概念上是雷同的。

根据下面的剖析,代码构造如下:

// 当浏览器准备就绪时,它将调用 WorkLoop
requestIdleCallback(WorkLoop)

let nextUnitOfWork = null;

function PerformUnitOfWork(nextUnitOfWork) {// TODO}

function WorkLoop(deadline) {
  // 以后线程的闲置工夫是否能够在完结前执行更多的工作
  let shouldYield = false;
  while(nextUnitOfWork && !shouldYield) {nextUnitOfWork = PerformUnitOfWork(nextUnitOfWork) // 赋值下一个工作单元 
    shouldYield = deadline.timeRemaining() < 1;  // 如果 idle period 曾经完结,则它的值是 0}
  requestIdleCallback(WorkLoop)
}

咱们在 PerformUnitOfWork 函数里实现当前工作的执行并返回下一个执行的工作单元,可下一个工作单元如何疾速查找呢?让咱们初步理解 Fibers 吧。

Fibers

为了组织工作单元,即不便查找下一个工作单元,需引入 fiber tree 的数据结构。即每个元素都有一个 fiber,链接到其第一个子节点,下一个兄弟姐妹节点和父节点,且每个fiber 都将成为一个工作单元。

// 假如咱们要渲染的元素树如下
const el = (
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>
)

其对应的 fiber tree 如下:

若将上图转化到咱们的代码里,咱们第一件事得找到 root fiber,即在Render 中,设置 nextUnitOfWork 初始值为root fiber,并将创立节点局部独立进去。

function Render(el,container) {
  // 设置 nextUnitOfWork 初始值为 root fiber
  nextUnitOfWork = {
    dom: container,
    props:{children:[el],
    }
  }
}

// 将创立节点局部独立进去
function CreateDom(fiber) {const dom = fiber.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(fiber.type);
  // 为节点调配 props 属性
  const isProperty = key => key !== 'children';
  const setProperty = name => dom[name] = fiber.props[name];
  Object.keys(fiber.props).filter(isProperty).forEach(setProperty)
  return dom
}

残余的 fiber 将在 performUnitOfWork 函数上执行以下三件事:

  • 为元素创立节点并增加到 dom
  • 为元素的子代创立 fiber
  • 抉择下一个执行工作单元

    function PerformUnitOfWork(fiber) {
    // 为元素创立节点并增加到 dom
    if(!fiber.dom) {fiber.dom = CreateDom(fiber)
    }
    // 若元素存在父节点,则挂载
    if(fiber.parent) {fiber.parent.dom.appendChild(fiber.dom)
    }
    
    // 为元素的子代创立 fiber
    const els = fiber.props.children;
    let index = 0;
    // 作为一个容器,存储兄弟节点
    let prevSibling = null;
    while(index < els.length) {const el = els[index];
        const newFiber = {
        type: el.type,
        props: el.props,
        parent: fiber,
        dom: null
        }
    
        // 子代在 fiber 树中的地位是 child 还是 sibling,取决于它是否第一个
        if(index === 0){fiber.child = newFiber;} else {prevSibling.sibling = newFiber;}
        prevSibling = newFiber;
        index++;
    } 
    
    // 抉择下一个执行工作单元,优先级是 child -> sibling -> parent
    if(fiber.child){return fiber.child;}
    let nextFiber = fiber;
    while(nextFiber) {if(nextFiber.sibling) {return nextFiber.sibling;}
        nextFiber = nextFiber.parent;
    }
    }

RenderCommit 阶段

在下面的代码中,咱们退出了工夫切片,但它还存在一些问题,上面咱们来看看:

  • performUnitOfWork 函数里,每次为元素创立节点之后,都向 dom 增加一个新节点,即

    if(fiber.parent) {fiber.parent.dom.appendChild(fiber.dom)
    }
  • 咱们都晓得,支流浏览器刷新频率为 60Hz,即每(1000ms / 60Hz)16.6ms 浏览器刷新一次。当 JS 执行工夫过长,超出了 16.6ms,这次刷新就没有工夫执行款式布局和款式绘制了。也就是在渲染完整棵树之前,浏览器可能会中断,导致用户看不到残缺的 UI。

那该如何解决呢?

  • 首先将创立一个节点就向 dom 进行增加解决的形式更改为跟踪 fiber root,也被称为progress root 或者 wipRoot
  • 一旦实现所有的工作,即没有下一个工作单元时,才将 fiber 提交给dom

    // 跟踪根节点
    let wipRoot = null;
    function Render(el,container) {
    wipRoot = {
        dom: container,
        props:{children:[el],
        }
    }
    nextUnitOfWork = wipRoot;
    }
    
    // 一旦实现所有的工作,将整个 fiber 提交给 dom
    function WorkLoop(deadline) {
    ...
    if(!nextUnitOfWork && wipRoot) {CommitRoot()
    }
    requestIdleCallback(WorkLoop)
    }
    
    // 将残缺的 fiber 提交给 dom
    function CommitRoot() {CommitWork(wipRoot.child)
    wipRoot = null
    }
    
    // 递归将每个节点增加进去
    function CommitWork(fiber) {if(!fiber) return;
    const parentDom = fiber.parent.dom;
    parentDom.appendChild(fiber.dom);
    CommitWork(fiber.child);
    CommitWork(fiber.sibling);
    }

Reconciliation

到目前为止,咱们优化了下面自定义的 HuaMu 库,但下面只实现了增加内容,当初,咱们把更新和删除内容也加上。而要实现更新、删除性能,须要将 render 函数中收到的元素与提交给 dom 的最初的 fiber tree 进行比拟。因而,须要保留最初一次提交给 fiber tree 的援用currentRoot。同时,为每个fiber 增加 alternate 属性,记录上一阶段提交的old fiber

let currentRoot = null;
function Render(el,container) {
  wipRoot = {
    ...
    alternate: currentRoot
  }
  ...
}

function CommitRoot() {
  ...
  currentRoot = wipRoot;
  wipRoot = null
}
  • 为元素的子代创立 fiber 的同时,将 old fibernew fiber进行reconcile
  • 通过以下三个维度进行比拟

    1. 如果 old fibernew fiber具备雷同的 type,保留dom 节点并更新其 props,并设置标签effectTagUPDATE
    2. type不同,且为 new fiber,意味着要创立新的dom 节点,设置标签 effectTagPLACEMENT;若为 old fiber,则须要删除节点,设置标签effectTagDELETION

      留神:为了更好的Reconciliation,React 还应用了key,比方更疾速的检测到子元素何时更改了在元素数组中的地位,这里为了简洁,暂不思考。

    let deletions = null;
    function PerformUnitOfWork(fiber) {
    ...
    const els = fiber.props.children;
    // 提取 为元素的子代创立 fiber 的代码
    ReconcileChildren(fiber, els);
    }
    
    function ReconcileChildren(wipFiber, els) {
    let index = 0;
    let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
    let prevSibling = null;
    // 为元素的子代创立 fiber 的同时 遍历旧的 fiber 的子级
    // undefined != null; // false
    // undefined !== null; // true
    while(index < els.length || oldFiber != null) {const el = els[index];
        const sameType = oldFiber && el && el.type === oldFiber.type;
        let newFiber = null;
    
        // 更新节点
        if(sameType) {
        newFiber = {
            type: el.type,
            props: el.props,
            parent: wipFiber,
            dom: oldFiber.dom, // 应用 oldFiber
            alternate: oldFiber,
            effectTag: "UPDATE",
        }
        } 
    
        // 新增节点
        if(!sameType && el){
        newFiber = {
            type: el.type,
            props: el.props,
            parent: wipFiber,
            dom: null,        // dom 设置为 null
            alternate: null,
            effectTag: "PLACEMENT",
        }
        }
        // 删除节点
        if(!sameType && oldFiber) {// 删除节点没有新的 fiber,因而将标签设置在旧的 fiber 上,并退出删除队列 [commit 阶段提交时,执行 deletions 队列,render 阶段执行完清空 deletions 队列]
        oldFiber.effectTag = "DELETION";
        deletions.push(oldFiber)
        }
    
        if(oldFiber) {oldFiber = oldFiber.sibling;}
    
        if(index === 0) {wipFiber.child = newFiber;} else if(el) {prevSibling.sibling = newFiber;}
        prevSibling = newFiber;
        index++;
    }
    }
  • CommitWork 函数里,依据 effectTags 进行节点解决

    1. PLACEMENT – 跟之前一样,将 dom 节点增加进父节点
    2. DELETION – 删除节点
    3. UPDATE – 更新 dom 节点的 props
    function CommitWork(fiber) {if (!fiber) return;
    const parentDom = fiber.parent.dom;
    if (fiber.effectTags === 'PLACEMENT' && fiber.dom !== null){parentDom.appendChild(fiber.dom);
    } else if (fiber.effectTags === 'DELETION') {parentDom.removeChild(fiber.dom)
    } else if(fiber.effectTags === 'UPDATE' && fiber.dom !== null) {
        UpdateDom(
        fiber.dom,
        fiber.alternate.props,
        fiber.props
        )
    }
    
    CommitWork(fiber.child);
    CommitWork(fiber.sibling);
    }

重点剖析一下 UpdateDom 函数:

  • 一般属性

    1. 删除旧的属性
    2. 设置新的或更改的属性
  • 非凡解决以 on为前缀的事件属性

    1. 删除旧的或更改的事件属性
    2. 增加新的事件属性

      const isEvent = key => key.startsWith("on");
      const isProperty = key => key !== 'children' && !isEvent(key);
      const isNew = (prev, next) => key => prev[key] !== next[key];
      const isGone = (prev, next) => key => !(key in next);
      
      /**
      * 更新 dom 节点的 props
      * @param {object} dom    
      * @param {object} prevProps   之前的属性
      * @param {object} nextProps   以后的属性
      */ 
      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]
          })
      
      // 删除旧的或更改的事件属性
      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]
          )
          })
      }

当初,咱们曾经实现了一个蕴含工夫切片、fiber的繁难 React。关上 codesandbox看看成果吧。

Function Components

组件化对于前端的同学应该不生疏,而实现组件化的根底就是函数组件,绝对与下面的标签类型,函数组件有哪些不一样呢?让咱们来啾啾

function App(props) {return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />

若由下面实现的 Huamu 库进行转换,应该等价于:

function App(props) {return Huamu.CreateElement("h1",null,"Hi",props.name)
}
const element = Huamu.CreateElement(App, {name:"foo"})

由此,可见 Function Componentsfiber是没有 dom 节点的,而且其 children 是来自于函数的运行而不是props。基于这两个不同点,咱们将其划分为UpdateFunctionComponentUpdateHostComponent 进行解决

function PerformUnitOfWork(fiber) {
  const isFunctionComponent = fiber.type instanceof Function;
  if(isFunctionComponent) {UpdateFunctionComponent(fiber)
  } else {UpdateHostComponent(fiber)
  }
  // 抉择下一个执行工作单元,优先级是 child -> sibling -> parent
  ...
}

function UpdateFunctionComponent(fiber) {// TODO}

function UpdateHostComponent(fiber) {if (!fiber.dom) = fiber.dom = CreateDom(fiber);
  const els = fiber.props.children;
  ReconcileChildren(fiber, els);
}
  • children来自于函数的运行而不是props,即运行函数获取children

    function UpdateFunctionComponent(fiber) {const children = [fiber.type(fiber.props)];
    ReconcileChildren(fiber,children);
    }
  • 没有 dom 节点的fiber

    1. 在增加节点时,得沿着 fiber 树向上挪动,直到找到带有 dom 节点的父级fiber
    2. 在删除节点时,得持续向下挪动,直到找到带有 dom 节点的子级fiber

      function CommitWork(fiber) {if (!fiber) return;
      // 优化:const domParent = fiber.parent.dom;
      let domParentFiber = fiber.parent;
      while(!domParentFiber.dom) {domParentFiber = domParentFiber.parent;}
      const domParent = domParentFiber.dom;
      if (fiber.effectTags === 'PLACEMENT' && fiber.dom!=null){domParent.appendChild(fiber.dom);
      } else if (fiber.effectTags === 'DELETION') {// 优化:domParent.removeChild(fiber.dom)
          CommitDeletion(fiber, domParent)
      } else if(fiber.effectTags === 'UPDATE' && fiber.dom!=null) {
          UpdateDom(
          fiber.dom,
          fiber.alternate.props,
          fiber.props
          )
      }
      
      CommitWork(fiber.child);
      CommitWork(fiber.sibling);
      }
      
      function CommitDeletion(fiber,domParent){if(fiber.dom){domParent.removeChild(fiber.dom)
      } else {CommitDeletion(fiber.child, domParent)
      }
      }

最初,咱们为 Function Components 增加状态。

Hooks

fiber 增加一个 hooks 数组,以反对 useState 在同一组件中屡次调用,且跟踪以后的 hooks 索引。

let wipFiber = null
let hookIndex = null

function UpdateFunctionComponent(fiber) {
  wipFiber = fiber;
  hookIndex = 0
  wipFiber.hooks = []
  const children = [fiber.type(fiber.props)]
  ReconcileChildren(fiber, children)
}
  • Function Components 组件调用 UseState 时,通过 alternate 属性检测 fiber 是否有old hook
  • 若有 old hook,将状态从old hook 复制到new hook,否则,初始化状态。
  • new hook 增加 fiberhook index 递增,返回状态。

    function UseState(initial) {
    const oldHook =
        wipFiber.alternate &&
        wipFiber.alternate.hooks &&
        wipFiber.alternate.hooks[hookIndex]
    const hook = {state: oldHook ? oldHook.state : initial,}
    
    wipFiber.hooks.push(hook)
    hookIndex++
    return [hook.state]
    }
  • UseState还需返回一个可更新状态的函数,因而,须要定义一个接管 actionsetState函数。
  • action 增加到队列中,再将队列增加到fiber
  • 在下一次渲染时,获取 old hookaction队列,并代入 new state 逐个执行,以保障返回的状态是已更新的。
  • setState 函数中,执行跟 Render 函数相似的操作,将 currentRoot 设置为下一个工作单元,以便开始新的渲染。

    function UseState(initial) {
    ...
    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.hooks.push(hook)
    hookIndex++
    return [hook.state, setState]
    }

当初,咱们曾经实现一个蕴含工夫切片、fiberHooks 的繁难 React。关上 codesandbox 看看成果吧。

结语

到目前为止,咱们从 What > How 梳理了大略的 React 常识链路,前面的章节咱们对文中所提及的知识点进行 Why 的摸索,置信会反哺到 What 的了解和 How 的实际。

本文原创公布于涂鸦智能技术博客

https://tech.tuya.com/react-zheng-ti-gan-zhi/

转载请注明出处

退出移动版