去实现一个 React.js

720 阅读6分钟

我们认为,React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。 --- React.

UI =  f(data).

制约快速响应因素:

1)CPU 瓶颈

  • 当遇到大计算量的操作或者设备性能不足使页面掉帧,导致卡顿。
function App() {
  const len = 3000;
  return (
    <ul>
      {Array(len).fill(0).map((_, i) => <li>{i}</li>)}
    </ul>
  );
}

const rootEl = document.querySelector("#root");
ReactDOM.render(<App/>, rootEl); 

主流浏览器刷新频率为 60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。

我们知道,JS可以操作DOM,GUI渲染线程与JS线程是互斥的。所以JS脚本执行和浏览器布局、绘制不能同时执行。

在每16.6ms时间内,需要完成如下工作:

当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了。

在Demo中,由于组件数量繁多(3000个),JS脚本执行时间过长,页面掉帧,造成卡顿。

可以从打印的执行堆栈图看到,JS执行时间为73.65ms,远远多于一帧的时间。

如何解决这个问题呢?

答案是:在浏览器每一帧的时间中,预留一些时间给 JS 线程,React利用这部分时间更新组件(可以看到,在 源码 (opens new window)中,预留的初始时间是5ms)。

当预留的时间不够用时,React将线程控制权交还给浏览器使其有时间渲染UI,React则等待下一帧时间到来继续被中断的工作。

这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片(time slice)

接下来我们开启 Concurrent Mode(开启后会启用时间切片):

// 通过使用ReactDOM.unstable_createRoot开启Concurrent Mode
// ReactDOM.render(<App/>, rootEl);  
ReactDOM.unstable_createRoot(rootEl).render(<App/>);

所以,解决CPU瓶颈的关键是实现时间切片,而时间切片的关键是:将同步的更新变为可中断的异步更新。

example.

2)IO 瓶颈

网络延迟是前端开发者无法解决的。如何在网络延迟客观存在的情况下,减少用户对网络延迟的感知?

React给出的答案是将人机交互研究的结果整合到真实的 UI 中 (opens new window)

这里我们以业界人机交互最顶尖的苹果举例,在IOS系统中:

点击“设置”面板中的“通用”,进入“通用”界面:

作为对比,再点击“设置”面板中的“Siri与搜索”,进入“Siri与搜索”界面:

你能感受到两者体验上的区别么?

事实上,点击“通用”后的交互是同步的,直接显示后续界面。而点击“Siri与搜索”后的交互是异步的,需要等待请求返回后再显示后续界面。但从用户感知来看,这两者的区别微乎其微。

这里的窍门在于:点击“Siri与搜索”后,先在当前页面停留了一小段时间,这一小段时间被用来请求数据。

当“这一小段时间”足够短时,用户是无感知的。如果请求时间超过一个范围,再显示loading的效果。

试想如果我们一点击“Siri与搜索”就显示loading效果,即使数据请求时间很短,loading效果一闪而过。用户也是可以感知到的。

为此,React实现了Suspense (opens new window)功能及配套的hook——useDeferredValue (opens new window)

而在源码内部,为了支持这些特性,同样需要将同步的更新变为可中断的异步更新。

Build Your Own React.

基础用法:

const element = <h1 title="foo">Hello</h1>;
const container = document.getElementById('root');
ReactDOM.render(element, container);
  • JSX

JSX 通过 Babel 等构建工具转化为 JS。用 createElement 替换标签内的代码,将 tag、props 和children作为参数传递。例如:

babel: JSX2String

React.createElement(
  "h1",
  { title: "foo" }, 
  "Hello"
);

// element 被转化为
const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}
  • render

根据 element 创建元素,将元素挂载到根节点。

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)

Step 1: CreateElement

JSX to JS对象。

// 声明自定义 React Obj.
const YReact = {
  createElement,
  render,
};

const createElement = (type, props, ...children) => {
  return {
    type,
    props: {
      ...props,
      children: children?.map((child) =>typeof children === 'object' ? child : createTextNode(child)
      ),
    },
  };
};

const createTextNode = (text) => {
  return {
    type: 'TEXT_ELEMENT',
    props: {
      nodeValue: text,
      children: [],
    },
  };
};

createElement("div") returns:
{
  "type": "div",
  "props": { "children": [] }
}

createElement("div", null, a) returns:
{
  "type": "div",
  "props": { "children": [a] }
}

createElement("div", null, a, b) returns:
{
  "type": "div",
  "props": { "children": [a, b] }
}

Step2: render

挂载 ReactDOM 树到真实 DOM节点

const render = (element, container) => {
  const dom =
    element.type === 'TEXT_ELEMENT'
      ? document.createTextNode('')
      : document.createElement(element.type);

  element.props.children.forEach((child) => {
    render(child, dom);
  });

  // why children in props?
  const isPropriety = (key) => key !== 'children';
  Object.keys(element.props)
    .filter(isPropriety)
    .forEach((name) => (dom[name] = element.props[name]));

  container.appendChild(dom);
};

到这步,React 核心流程已经完成。但有一个硬伤,这个递归调用有问题。

一旦开始渲染,将不会停止,直到我们渲染完完整的元素树。 如果元素树很大,它可能会阻塞主线程太久。 如果浏览器需要做高优先级的事情,比如处理用户输入或保持动画流畅,它必须等到渲染完成。

const createElement = (type, props, ...children) => {
    return {
      type,
      props: {
        ...props,
        children: children.map((child) =>typeof child === 'object' ? child : createTextNode(child)
        ),
      },
    };
};
  
  const createTextNode = (text) => {
    return {
      type: 'TEXT_ELEMENT',
      props: {
        nodeValue: text,
        children: [],
      },
    };
  };
  
  const render = (element, container) => {
    const dom =
      element.type === 'TEXT_ELEMENT'
        ? document.createTextNode('')
        : document.createElement(element.type);
  
    element.props.children.forEach((child) => {
      render(child, dom);
    });
  
    // why children in props?const isPropriety = (key) => key !== 'children';
    Object.keys(element.props)
      .filter(isPropriety)
      .forEach((name) => (dom[name] = element.props[name]));
  
    container.appendChild(dom);
  };
  
  const YReact = {
    createElement,
    render,
  };
  
  /** @jsx YReact.createElement */const element = (
    <div style="background: salmon"><h1>Hello World</h1><h2 style="text-align:right">from YReact</h2></div>
  );
  const container = document.getElementById('root');
  YReact.render(element, container);
  

Step3: ConcurrentMode

React 把工作分解成小的单元,当完成每个单元后,如果还有其他需要做的事情,会让浏览器中断渲染。

requestIdleCallback 可以视为 setTimeout,但不是我们告诉它何时运行,浏览器将在主线程空闲时运行回调。

React 不再使用 requestIdleCallback,而是基于此开发了 schedule

const nextUnitOfWork = null;

const workLoop = (deadline) => {
  let shouldStop = false;
  while (nextUnitToWork && !shouldStop) {
    nextUnitToWork = performerUnitOfWork(nextUnitOfWork);
    // 归还浏览器控制
    shouldStop = deadline.timeRemaining() < 1;
  }
  requestIdleCallback(workLoop);
};

requestIdleCallback(workLoop);

// 执行当前单元 job,返回下个执行单元
const performerUnitOfWork = () => {
    // todo.
};

Step4: fiber

为了组织工作单元,React 定义一个数据结构:fiber tree。支持任务不同优先级,可中断与恢复,并且恢复后可以复用之前的中间状态。每个元素都是一个 fiber 节点,每个 fiber 将成为一个工作单元。

const performerUnitOfWork = (fiber) => {
  // add dom noeif (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }

  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom);
  }

  // create new fiber.const elements = fiber.props.child;
  let idx = 0;
  const prevSibling = null;

  while (idx < element.length) {
    const el = elements[idx];

    const newFiber = {
      type: el.type,
      props: el.props,
      parent: fiber,
      dom: null,
    };

    if (idx === 0) {
      fiber.children = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }
    prevSibling = newFiber;
    idx++;
  }
  // 先遍历子节点,再遍历兄弟节点。if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = fiber.parent;
  }
};

Step5: render & commit

每次我们处理一个元素时,我们都会向 DOM 添加一个新节点。在我们完成渲染整棵 DOM 树之前,浏览器可能会中断我们的工作。 在这种情况下,用户将看到一个不完整的 UI。 我们不希望那样。

const render = (element, container) => {
  // 维护根节点const wipRoot = nextUnitOfWork({
    dom: container,
    props: {
      children: [element],
    },
  });
  nextUnitOfWork = wipRoot;
};

let wipRoot = null

// 渲染完 DOM Tree,挂载到真实节点。
const commitRoot = () => {
  commitWork(wipRoot.child);
  wipRoot = null;
};

const commitWork = (fiber) => {
  if (!fiber) {
    return;
  }
  const parent = fiber.parent.dom;
  parent.appendChildren(fiber.dom);
  commitWork(fiber.child);
  commitWork(fiber.sibling);
};

const performerUnitOfWork = (fiber) => {
  ...
  // 没有下个执行单元,进行 DOM 挂载if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }
  ...
}

Step6: Reconciler

我们构建了一个新的 DOM 树,但是更新或删除节点呢?

React 需要将在渲染函数上接收到的元素与我们提交给 DOM 的最后一个 fiber 树进行比较。

React 在完成 commit 会保存对最后一个 fiberTree 的引用,称之为 current。

并且还为每个 fiber 添加了 alternate 。 此属性是指向旧 fiber 。

const render = (element, container) => {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  }
  deletions = [];
	...
};
  
let deletions = null;

//比较新旧 fiber 的 props,移除消失的 props,并设置新的或更改的 props。
const updateDom = (dom, prevProps, nextProps) => {
  // 移除旧的propsObject.keys(prevProps)
    .filter(isPropriety)
    .filter(isGone(prevProps, nextProps))
    .forEach((name) => (dom[name] = ''));

  // 移除/更改 eventListenerObject.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]);
    });

  // 新增 addEventListenerObject.keys(prevProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, prevProps[name]);
    });

  // 更新 /新增Object.keys(prevProps)
    .filter(isPropriety)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => (dom[name] = nextProps[name]));
};


// 渲染完 DOM Tree,挂载到真实节点。
const commitRoot = () => {
  deletions.forEach(commitWork);
  commitWork(wipRoot.child);
  // 保留上个 fiber tree
  currentRoot = wipRoot;
  wipRoot = null;
};

const commitWork = (fiber) => {
  if (!fiber) {
    return;
  }
  const parent = fiber.parent.dom;
  // 处理 effectTagif (fiber.effectTag === 'PLACEMENT') {
    parent.appendChildren(fiber);
  } else if (fiber.effectTag === 'DELETION') {
    parent.removeChildren(fiber);
  } else if (fiber.effectTag === 'UPDATE' && fiber.dom !== null) {
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  }
  commitWork(fiber.child);
  commitWork(fiber.sibling);
};

const performerUnitOfWork = (fiber) => {
  ...
	// 协调新旧元素
  reconcileChildren(fiber, elements);
  ...
};

const reconcileChildren = (wipFiber, elements) => {
  let idx = 0;
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
  const prevSibling = null;

  while (idx < elements.length || oldFiber !== null) {
    const el = elements[idx];
    const newFiber = null;

    // compare fiber.const sameType = oldFiber && element && element.type == oldFiber.type;

    if (sameType) {
      // TODO update the node
      newFiber = {
        type: oldFiber.type,
        props: el.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: 'UPDATE',
      };
    }
    if (el && !sameType) {
      // TODO add this node
      newFiber = {
        type: el.type,
        props: el.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: 'PLACEMENT',
      };
    }
    if (oldFiber && !sameType) {
      // TODO delete the oldFiber's node
      oldFiber.effectTag = 'DELETION';
      deletions.push(oldFiber);
    }

    if (idx === 0) {
      wipFiber.children = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }
    prevSibling = newFiber;
    idx++;
  }
};

我们同时遍历旧fiber  (wipFiber.alternate) 的子节点和我们想要协调的元素数组。

忽略React里其他条件,那么 while 中最重要的东西:oldFiber 和 element。 element 是我们要渲染到 DOM 的东西,oldFiber 是我们上次渲染的东西。

React 使用 type 比较新旧 fiber 查看是否需要对 DOM 应用任何更改。

  • 如果旧的 fiber 和新的元素有相同的类型,我们可以保留 DOM 节点并用新的 props 更新它
  • 如果类型不同并且有一个新元素,则意味着我们需要创建一个新的 DOM 节点
  • 如果类型不同并且有旧 fiber,我们需要删除旧节点

这里 React 也使用键,这样可以更好地协调。 例如,它检测孩子何时更改元素数组中的位置。

我们需要更新的一种特殊道具是事件侦听器,因此如果道具名称以“on”前缀开头,我们将以不同方式处理它们。

step7: Function Component

函数式组件在两个方面有所不同:

  • 没有 DOM 节点
  • Children 只有执行完函数才能获得
const performerUnitOfWork = (fiber) => {
  const isFunctionComponent = fiber.type instanceof Function;
  if (isFunctionComponent) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }
  ...
}

  const updateFunctionComponent = (fiber) => {
    const children = [fiber.type(fiber.props)];
    reconcileChildren(fiber, children);
  };

  const updateHostComponent = (fiber) => {
    // add dom noeif (!fiber.dom) {
      fiber.dom = createDom(fiber);
    }

    reconcileChildren(fiber, fiber.props.children);
  };


 const commitWork = (fiber) => {
	...

  // 找到有dom 的父亲节点let domParentFiber = fiber.parent;
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent;
  }
  const domParent = domParentFiber.dom;

 ... 
  else if (fiber.effectTag === 'DELETION') {
    commitDeletion(fiber, domParent);
  } 
   ...
};
   
const commitDeletion = (fiber, domParent) => {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    commitDeletion(fiber.child, domParent);
  }
};
  
  function App(props) {
    return <h1>Hi {props.name}</h1>
  }
  const element = <App name="foo" />

Build-Your-Own-React

github.com/facebook/re…