关于javascript:从-html-实现一个-react🎅

前言 📝

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

react 的理念是在于对大型项目的疾速响应,对于新版的 react 16.8 而言更是带来的全新的理念fiber去解决网页疾速响应时所随同的问题,即 CPU 的瓶颈,传统网页浏览受制于浏览器刷新率、js 执行工夫过长等因素会造成页面掉帧,甚至卡顿

react 因为本身的底层设计从而躲避这一问题的产生,所以 react16.8 的面世对于前端畛域只办三件事:疾速响应、疾速响应、还是 Tmd 疾速响应 !,这篇文章将会从一个 html 登程,追随 react 的 fiber 理念,仿一个十分根底的 react


一开始的筹备工作 🤖

html

咱们须要一个 html 去撑起来整个页面,撑持 react 运行,页面中增加<div id="root"></div>,之后增加一个 script 标签,因为须要应用import进行模块化构建,所以须要为 script 增加 type 为module的属性

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <div id="root"></div>
  <script type="module" src="./index.js" ></script>
</body>

</html>

举荐装置一个 Live Server 插件,有助于咱们对代码进行调试,接下来的操作也会用到

JavaScript

咱们会仿写一个如下的 react,实现一个根底的操作,在 <input/> 绑定事件,将输出的值插入在 <h2/> 标签内:

...
function App() {
  return (
    <div>
      <input onInput={updateValue} value={value} />
      <h2>Hello {value}</h2>
      <hr />
    </div>
  );
}
...

在 react 进行 babel 编译的时候,会将 JSX 语法转化为 React.createElement() 的模式,如上被 retuen 的代码就会被转换成

...
React.createElement(
  "div",
  null,
  React.createElement("input", {
    onInput: updateValue,
    value: value,
  }),
  React.createElement("h2", null, "Hello ", value),
  React.createElement("hr", null)
);
...

在线地址

从转换后的代码咱们能够看出 React.createElement 反对多个参数:

  1. type,节点类型
  2. config, 节点上的属性,比方 id 和 href
  3. children, 子元素了,子元素能够有多个,类型能够是简略的文本,也能够还是 React.createElement,如果是 React.createElement,其实就是子节点了,子节点上面还能够有子节点。这样就用 React.createElement 的嵌套关系实现了 HTML 节点的树形构造。

咱们能够依照 React.createElement 的模式仿写一个能够实现同样性能的 createElement 将 jsx 通过一种简略的数据结构展现进去即 虚构DOM 这样在更新时,新旧节点的比照也能够转化为虚构 DOM 的比照

{
  type:'节点标签',
  props:{
    props:'节点上的属性,包含事件、类...',
    children:'节点的子节点'
  }
}

这里咱们能够写一个函数实现下列需要

  • 准则是将所有的参数返回到一个对象上
  • children 也要放到 props 外面去,这样咱们在组件外面就能通过 props.children 拿到子元素
  • 当子组件是文本节点时,通过结构一种 type 为 TEXT_ELEMENT 的节点类型示意
/**
 * 创立虚构 DOM 构造
 * @param {type} 标签名
 * @param {props} 属性对象
 * @param {children} 子节点
 * @return {element} 虚构 DOM
 */
const createElement = (type, props, ...children) => ({
  type,
  props: {
    ...props,
    children: children.map(child =>
      typeof child === "object"
        ? child
        : {
            type: "TEXT_ELEMENT",
            props: {
              nodeValue: child,
              children: [],
            },
          }
    ),
  },
});

react 中 createElement 源码实现

实现 createElement 之后咱们能够拿到虚构 DOM,然而还须要 render 将代码渲染到页面,此时咱们须要对 index.js 进行解决,增加输出事件,将 createElementrender 通过 import 进行引入,render 时传入被编译后的虚构 DOM 和页面的根元素 root, 最初再进行executeRender调用,页面被渲染,在页面更新的时候再次调用executeRender进行更新渲染

import {createElement,render} from "./mini/index.js";
const updateValue = e => executeRender(e.target.value);
const executeRender = (value = "World") => {
  const element = createElement(
    "div",
    null,
    createElement("input", {
      onInput: updateValue,
      value: value,
    }),
    createElement("h2", null, "Hello ", value),
    createElement("hr", null)
  );
  render(element, document.getElementById("root"));
};

executeRender();

render 的时候做了什么 🥔

before 版本

render 函数帮忙咱们将 element 增加至实在节点中,首先它承受两个参数:

  1. 根组件,其实是一个 JSX 组件,也就是一个 createElement 返回的虚构 DOM
  2. 父节点,也就是咱们要将这个虚构 DOM 渲染的地位

在 react 16.8 之前,渲染的办法是通过一下几步进行的

  1. 创立 element.type 类型的 dom 节点,并增加到 root 元素下(文本节点非凡解决)
  2. 将 element 的 props 增加到对应的 DOM 上,事件进行非凡解决,挂载到 document 上(react17 调整为挂在到 container 上)
  3. 将 element.children 循环增加至 dom 节点中;

拿到虚构 dom 进行如上三步的递归调用,渲染出页面 相似于如下流程

/**
 * 将虚构 DOM 增加至实在 DOM
 * @param {element} 虚构 DOM
 * @param {container} 实在 DOM
 */
const render = (element, container) => {
  let dom;
  /*
      解决节点(包含文本节点)
  */
  if (typeof element !== "object") {
    dom = document.createTextNode(element);
  } else {
    dom = document.createElement(element.type);
  }
  /*
      解决属性(包含事件属性)
  */
  if (element.props) {
    Object.keys(element.props)
      .filter((key) => key != "children")
      .forEach((item) => {
        dom[item] = element.props[item];
      });
    Object.keys(element.props)
      .filter((key) => key.startsWith("on"))
      .forEach((name) => {
        const eventType = name.toLowerCase().substring(2);
        dom.addEventListener(eventType, nextProps[name]);
      });
  }
  if (
    element.props &&
    element.props.children &&
    element.props.children.length
  ) {
    /*
      循环增加到dom
  */
    element.props.children.forEach((child) => render(child, dom));
  }
  container.appendChild(dom);
};

after 版本(fiber)

当咱们写完如上的代码,会发现这个递归调用是有问题的

如上这部分工作被 React 官网称为 renderer,renderer 是第三方能够本人实现的一个模块,还有个外围模块叫做 reconsiler,reconsiler 的一大性能就是 diff 算法,他会计算出应该更新哪些页面节点,而后将须要更新的节点虚构 DOM 传递给 renderer,renderer 负责将这些节点渲染到页面上,然而然而他却是同步的,一旦开始渲染,就会将所有节点及其子节点全副渲染实现这个过程才会完结。

React 的官网演讲中有个例子,能够很显著的看到这种同步计算造成的卡顿:

当 dom tree 很大的状况下,JS 线程的运行工夫可能会比拟长,在这段时间浏览器是不会响应其余事件的,因为 JS 线程和 GUI 线程是互斥的,JS 运行时页面就不会响应,这个工夫太长了,用户就可能看到卡顿,

此时咱们能够分为两步解决这个问题

  • 容许中断渲染工作,如果有优先级更高的工作插入,则临时中断浏览器渲染,待实现该工作后,复原浏览器渲染;
  • 将渲染工作进行合成,分解成一个个小单元;

solution I 引入一个新的 Api

requestIdleCallback 接管一个回调,这个回调会在浏览器闲暇时调用,每次调用会传入一个 IdleDeadline,能够拿到以后还空余多久, options 能够传入参数最多等多久,等到了工夫浏览器还不空就强制执行了。

window.requestIdleCallback 将在浏览器的闲暇时段内调用的函数排队。这使开发者可能在主事件循环上执行后盾和低优先级工作,而不会影响提早要害事件

然而这个 API 还在试验中,兼容性不好,所以 React 官网本人实现了一套。本文会持续应用 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
}

solution II 创立 fiber 的数据结构

Fiber 之前的数据结构是一棵树,父节点的 children 指向了子节点,然而只有这一个指针是不能实现中断持续的。比方我当初有一个父节点 A,A 有三个子节点 B,C,D,当我遍历到 C 的时候中断了,从新开始的时候,其实我是不晓得 C 上面该执行哪个的,因为只晓得 C,并没有指针指向他的父节点,也没有指针指向他的兄弟。

Fiber 就是革新了这样一个构造,加上了指向父节点和兄弟节点的指针:

  • child 指向子组件
  • sibling 指向兄弟组件
  • return 指向父组件

每个 fiber 都有一个链接指向它的第一个子节点、下一个兄弟节点和它的父节点。这种数据结构能够让咱们更不便的查找下一个工作单元,假设 A 是挂在 root 上的节点 fiber 的渲染程序也如下步骤

  1. 从 root 开始,找到第一个子节点 A;
  2. 找到 A 的第一个子节点 B
  3. 找到 B 的第一个子节点 E
  4. 找 E 的第一个子节点,如无子节点,则找下一个兄弟节点,找到 E 的兄弟节点 F
  5. 找 F 的第一个子节点,如无子节点,也无兄弟节点,则找它的父节点的下一个兄弟节点,找到 F 的 父节点的兄弟节点 C;
  6. 找 C 的第一个子节点,找不到,找兄弟节点,D
  7. 找 D 的第一个子节点,G
  8. 找 G 的第一个子节点,找不到,找兄弟节点,找不到,找父节点 D 的兄弟节点,也找不到,持续找 D 的父节点的兄弟节点,找到 root;
  9. 上一步曾经找到了 root 节点,渲染已全副实现。

咱们通过这个数据结构实现一个 fiber

//创立最后的根fiber
 wipRoot = {
  dom: container,
  props: { children: [element] },
};
performUnitOfWork(wipRoot);

随后调用performUnitOfWork自上而下结构整个 fiber 树

/**
 * performUnitOfWork用来执行工作
 * @param {fiber} 咱们的以后fiber工作
 * @return {fiber} 下一个工作fiber工作
 */
const  performUnitOfWork = fiber => {
  if (!fiber.dom) fiber.dom = createDom(fiber); // 创立一个DOM挂载下来
  const elements = fiber.props.children; //以后元素下的所有同级节点
  // 如果有父节点,将以后节点挂载到父节点上
  if (fiber.return) {
    fiber.return.dom.appendChild(fiber.dom);
  }

  let prevSibling = null;
  /*
      之后代码中咱们将把此处的逻辑进行抽离
  */
  if (elements && elements.length) {
    elements.forEach((element, index) => {
      const newFiber = {
        type: element.type,
        props: element.props,
        return: fiber,
        dom: null,
      };
      // 父级的child指向第一个子元素
      if (index === 0) {
        fiber.child = newFiber;
      } else {
        // 每个子元素领有指向下一个子元素的指针
        prevSibling.sibling = newFiber;
      }
      prevSibling = fiber;
    });
  }
  // 先找子元素,没有子元素了就找兄弟元素
  // 兄弟元素也没有了就返回父元素
  // 最初到根节点完结
  // 这个遍历的程序是从上到下,从左到右
  if (fiber.child) {
    return fiber.child;
  } else {
    let nextFiber = fiber;
    while (nextFiber) {
      if (nextFiber.sibling) {
        return nextFiber.sibling;
      }
      nextFiber = nextFiber.return;
    }
  }
}

after 版本(reconcile)

currentRoot

reconcile 其实就是虚构 DOM 树的 diff 操作,将更新前的 fiber tree 和更新后的 fiber tree 进行比拟,失去比拟后果后,仅对有变动的 fiber 对应的 dom 节点进行更新。

  • 删除不须要的节点
  • 更新批改过的节点
  • 增加新的节点

新增 currentRoot 变量,保留根节点更新前的 fiber tree,为 fiber 新增 alternate 属性,保留 fiber 更新前的 fiber tree

let currentRoot = null
function render (element, container) {
    wipRoot = {
        // 省略
        alternate: currentRoot
    }
}
function commitRoot () {
    commitWork(wipRoot.child)
    /*
        更改fiber树的指向,将缓存中的fiber树替换到页面中的fiber tree
    */
    currentRoot = wipRoot
    wipRoot = null
}
  1. 如果新老节点类型一样,复用老节点 DOM,更新 props
  2. 如果类型不一样,而且新的节点存在,创立新节点替换老节点
  3. 如果类型不一样,没有新节点,有老节点,删除老节点

reconcileChildren

  1. 将 performUnitOfWork 中对于新建 fiber 的逻辑,抽离到 reconcileChildren 函数
  2. 在 reconcileChildren 中比照新旧 fiber;

在比照 fiber tree 时

  • 当新旧 fiber 类型雷同时 保留 dom,仅更新 props,设置 effectTag 为 UPDATE
  • 当新旧 fiber 类型不同,且有新元素时 创立一个新的 dom 节点,设置 effectTag 为 PLACEMENT
  • 当新旧 fiber 类型不同,且有旧 fiber 时 删除旧 fiber,设置 effectTag 为 DELETION
/**
 * 协调子节点
 * @param {fiber} fiber
 * @param {elements} fiber 的 子节点
 */
function reconcileChildren(wipFiber, elements) {
  let index = 0;// 用于统计子节点的索引值
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child; //更新时才会产生
  let prevSibling;// 上一个兄弟节点
  while (index < elements.length || oldFiber) {
    /**
     * 遍历子节点
     * oldFiber判断是更新触发还是首次触发,更新触发时为元素下所有节点
     */
    let newFiber;
    const element = elements[index];
    const sameType = oldFiber && element && element.type == oldFiber.type; // fiber 类型是否相同点
    /**
     * 更新时
     * 同标签不同属性,更新属性
     */
    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props, //只更新属性
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      };
    }
    /**
     * 不同标签,即替换了标签 or 创立新标签
     */
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      };
    }
    /**
     * 节点被删除了
     */
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION";
      deletions.push(oldFiber);
    }

    if (oldFiber) oldFiber = oldFiber.sibling;
    // 父级的child指向第一个子元素
    if (index === 0) {
      // fiber的第一个子节点是它的子节点
      wipFiber.child = newFiber;
    } else {
      // fiber 的其余子节点,是它第一个子节点的兄弟节点
      prevSibling.sibling = newFiber;
    }
    // 把新建的 newFiber 赋值给 prevSibling,这样就不便为 newFiber 增加兄弟节点了
    prevSibling = newFiber;
    //  索引值 + 1
    index++;
  }
}

在 commit 时,依据 fiber 节点上effectTag的属性执行不同的渲染操作

after 版本(commit)

在 commitWork 中对 fiber 的 effectTag 进行判断,解决真正的 DOM 操作。

  1. 当 fiber 的 effectTag 为 PLACEMENT 时,示意是新增 fiber,将该节点新增至父节点中。
  2. 当 fiber 的 effectTag 为 DELETION 时,示意是删除 fiber,将父节点的该节点删除。
  3. 当 fiber 的 effectTag 为 UPDATE 时,示意是更新 fiber,更新 props 属性。
/**
 * @param {fiber} fiber 构造的虚构dom
 */
function commitWork(fiber) {
  if (!fiber) return;
  const domParent = fiber.parent.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") {
    domParent.removeChild(fiber.dom);
  }

  // 递归操作子元素和兄弟元素
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

此时咱们着重来看updateDom产生了什么,咱们拿到 dom 上被扭转的新旧属性,进行操作

/*
    isEvent :拿到事件属性
    isProperty :拿到非节点、非事件属性
    isNew :拿到前后扭转的属性
*/
const isEvent = key => key.startsWith("on");
const isProperty = key => key !== "children" && !isEvent(key);
const isNew = (prev, next) => key => prev[key] !== next[key];


/**
 * 更新dom属性
 * @param {dom} fiber dom
 * @param {prevProps} fiber dom上旧的属性
 * @param {nextProps} fiber dom上新的属性
 */
function updateDom(dom, prevProps, nextProps) {
  /**
   * 便当旧属性
   * 1、拿到on结尾的事件属性
   * 2、拿到被删除的事件
   * 3、已删除的事件勾销监听
   */
  Object.keys(prevProps)
    .filter(isEvent)
    .filter(key => !(key in nextProps))
    .forEach(name => {
      const eventType = name.toLowerCase().substring(2);
      dom.removeEventListener(eventType, prevProps[name]);
    });

  /**
   * 便当旧属性
   * 1、拿到非事件属性和非子节点的属性
   * 2、拿到被删除的属性
   * 3、删除属性
   */
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(key => !(key in nextProps))
    .forEach(key => delete dom[key]);

  /**
   * 便当新属性
   * 1、拿到非事件属性和非子节点的属性
   * 2、拿到前后扭转的属性
   * 3、增加属性
   */
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name];
    });

  /**
   * 便当新属性
   * 1、拿到on结尾的事件属性
   * 2、拿到前后扭转的事件属性
   * 3、为新增的事件属性增加监听
   */
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[name]);
    });
}

实现了一系列对 dom 的操作,咱们将新扭转的 dom 渲染到页面,当 input 事件执行时,页面又会进行渲染,但此时会进入更新 fiber 树的逻辑,
alternate 指向之前的 fiber 节点进行复用,更快的执行 Update 操作,如图:

功败垂成!

残缺代码能够看我github。

论断与总结 💢

论断

  • 咱们写的 JSX 代码被 babel 转化成了 React.createElement。
  • React.createElement 返回的其实就是虚构 DOM 构造。
  • 虚构 DOM 的和谐和渲染能够简略粗犷的递归,然而这个过程是同步的,如果须要解决的节点过多,可能会阻塞用户输出和动画播放,造成卡顿。
  • Fiber 是 16.x 引入的新个性,用途是将同步的和谐变成异步的。
  • Fiber 革新了虚构 DOM 的构造,具备 父->第一个子, 子->兄, 子->父这几个指针,有了这几个指针,能够从任意一个 Fiber 节点找到其余节点。
  • Fiber 将整棵树的同步工作拆分成了每个节点能够独自执行的异步执行构造。
  • Fiber 能够从任意一个节点开始遍历,遍历是深度优先遍历,程序是 父->子->兄->父,也就是从上往下,从左往右。
  • Fiber 的和谐阶段能够是异步的小工作,然而提交阶段( commit)必须是同步的。因为异步的 commit 可能让用户看到节点一个一个接连呈现,体验不好。

总结

  • react hook 实现 ✖
  • react 合成事件 ✖
  • 还有很多没有实现 😤…

至此,谢谢各位在百忙之中点开这篇文章,心愿对你们能有所帮忙,如有问题欢送各位大佬斧正。工作起因这篇文章大略断断续续写了有一个月,工作上在忙一个基于 腾讯云TRTC+websocket 的小程序电话性能,有工夫也会写成文章分享一下,当然 react 的实现文章也会持续

👋:跳转 github 欢送给个 star,谢谢大家了

参考文献

  • 🍑:手写系列-实现一个铂金段位的 React
  • 🍑:build-your-own-react(强烈推荐)
  • 🍑:手写 React 的 Fiber 架构,深刻了解其原理
  • 🍑:手写一个简略的 React
  • 🍑:妙味课堂大圣老师 手写 react 的 fiber 和 hooks 架构
  • 🍑:React Fiber 架构
  • 🍑:手写一个简略的 React

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理