likes
comments
collection
share

【译】React之旅:搭建一个自己的React

作者站长头像
站长
· 阅读数 87

原文链接:pomb.us/build-your-…

我们打算从头开始重写React,一步步地按照React实际代码结构进行,但除去了所有的优化和非必要的特性。

如果你已经阅读了我的任意一篇"build your own React"帖子,与此不同的是这篇基于React 16.8,因此我们现在可以使用hooks并且放弃所有与类相关的代码。

你可以在Didact repo中找到旧博客文章和代码的历史记录,也有一些相似内容的演讲,但这是一篇独立的文章。 从头开始,这些是我们将逐步添加到自己的React版本中的全部内容:

  • 步骤一: 创建元素函数
  • 步骤二: 渲染函数
  • 步骤三: 并发模式
  • 步骤四: Fibers
  • 步骤五: 渲染和提交阶段
  • 步骤六: 协调
  • 步骤七: 函数式组件
  • 步骤八: 钩子函数

步骤零:回顾

首先我们回顾一些基本概念,如果你已经对ReactJSXDOM元素如何工作有一些好的理解可以跳过这个步骤。

我们仅仅需要三行代码使用React应用,第一行定义一个React元素,下一行从DOM中获取节点,最后将元素渲染到容器中。

让我们移除所有React代码,使用原生JS开始吧!

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

第一行我们使用jsx创建了元素,它在JavaScript中是不合法的,为了用原生JS代替它,首先我们使用合法的JS去替换。

JSX通过类似Babel的工具转化为JS,转化通常是简单的:将标签中的代码替换成createElement的调用。将标签名、属性和子元素作为参数传递过去。

React.createElement 根据参数创建一个对象,除去一些校验,这就是它做的全部。因此我们可以安全地使用它的输出代替函数的调用。

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

一个Element是什么?

一个带有两个属性的对象:typeprops(也许有更多,但我们只关心这两个)。

Type 是我们创建的一个区分DOM节点类型的字符串,它是当你创建一个HTML元素时,通过CREATE ElEMENT创建的标签名,它也是一个方法,但我们将在第五步说。 Props 是另一个对象,它有JSX属性中所有的索引和值,它也包括一个特别的属性:children, children这种情况下是一个字符串,但它通常是一个有更多元素的数组,那也是为什么元素是树状的。

我们需要替换的另一部分代码块是ReactDOM.render,renderDOMReact改变的地方,因此我们自己来更新它。

const node = document.createElement(element.type);
node["title"] = element.props.title;

首先我们使用h1创建一个node; 然后我们将所有属性赋予node中,这里仅有title。 为避免冲突,我使用“element” 代指React元素,“node” 代指DOM元素,然后我们为这个节点创造子节点,我们创建一个文本节点时我们仅仅用一个字符串作为子节点。

const text = document.createTextNode("")
text["nodeValue"] = element.props.children
node.appendChild(text)
container.appendChild(node)

使用textNode而不是设置innerText将允许我们以后以相同的方式处理所有元素。注意我们如何设置nodeValue,就像我们在h1标题中所做的那样,几乎就像字符串有props: {nodeValue: "hello"}一样。 最后,我们将textNode追加到h1元素,然后将h1追加到容器中。 现在,我们拥有了与之前相同的应用程序,但不再使用React

步骤一:createElement函数

让我们从另一个应用开始,这次我们将使用我们自己版本的React代替React代码。

我们将从写自己的createElement开始。 我们把JSX转化为JS,以便于我们能够看到createElement的调用,正如我们在当前步骤看到的这样,一个元素是一个带有typeprops属性的对象,我们的函数唯一需要做的事创建这个对象。

element = React.createElement(
    "div",
    { id: "foo" },
    React.createElement("a", null, "bar"),
    React.createElement("b")
)

我们使用扩展运算符处理props,并使用剩余参数rest语法处理children,通过这个方式,子元素永远是个数组。

例如,createElement("div")返回

{
  "type": "div",
  "props": { "children": [] }
}

`createElement("div", null, a)`返回:

{
  "type": "div",
  "props": { "children": [a] }
}

`createElement("div", null, a, b)` 返回:

{
  "type": "div",
  "props": { "children": [a, b] }
}

子数组也可以包含原始数据类型的值像stringnumber,因此我们将除了对象以外的所有元素都装进自己的元素中并为它们创建一个特殊的类型TEXT_ELEMENT

React没有子节点时不装载基本数据类型和创建空数组,但是我们这样做能简化我们的代码,且对于我们的库来说,我们更喜欢简单的代码而不是高性能的代码。

我们仍然使用ReactcreateElement函数。

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

为了替代它,我们为我们的仓库命名,我们给我们仓库起一个名,我们需要这个名字听起俩像React但也暗示了它明确的目的。

我们叫它Diadact.

我们这里仍然想使用JSX,我们如何告诉babel使用DidactcreateELement代替React的呢?

如果我使用这样的注释,当babel转译JSX时,将使用我们定义的方法。

/** @jsx Didact.createElement */
const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)

步骤二:渲染函数

ReactDOM.render(element, container) 接下来,我们要编写自己版本的ReactDOM.render函数了,目前为止,我们只关心DOM的增加,后边再处理更新和删除。

我们从使用组件类型创建DOM节点开始,之后像容器中追加新节点。

我们对子节点递归执行相同操作。

我们也需要处理文本元素,如果元素类型为TEXT_ELEMENT,我们创建文本节点代替常规节点。

最后我们需要为节点赋予属性,这样,我们现在拥有了一个可以将JSX渲染到DOM中的库。

// 传入两个参数,第一个是react元素,第二个是dom元素
const render = (elem, container) => {
  // 创建dom元素
  const dom = elem.type === 'TEXT_ELEMENT' ? document.createTextNode(''): document.createElement(elem.type);


  //为dom元素赋予属性
  Object.keys(elem.props)
  .filter(key => key !== 'children')
  .forEach(prop => dom[prop] = elem.props[prop]);


  // 递归渲染子元素,递归停止:子元素没有子元素了
  elem.props.children.forEach(item => render(item, dom));


  // 将dom元素追加到父节点中
  container.append(dom);
}
export default render; 

步骤三:并发模式

一旦我们开始渲染,直到我们渲染完整的树形结构才能停止,如果元素树是庞大的,它会阻塞主线程太长时间,并且如果浏览器需要做像处理用户输入框或平滑地渲染动画这种高优先级的元素更新,需要等到渲染完成。因此我们打算将工作分成一些小的单元,每完成一个单元后,如果有一些其他事情需要做我们将中断浏览器的渲染。

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

我们使用requestIdleCallback 做循环,你可以把requestIdleCallback 当作setTimeOut,但不需要我们通知它什么时间启动,主线程空闲时浏览器将运行回调函数。

React不再使用requestIdleCallback ,现在它使用scheduler package。但在这个使用示例中,他们的概念是相同的。

requestIdleCallback 也提供给我们deadline参数,我们可以通过这个参数检查浏览器还有多久空余时间。

自从2019年11月起,并发模式已经在React中稳定了,稳定版本中的循环看起来像这样的。

while (nextUnitOfWork) {    
  nextUnitOfWork = performUnitOfWork(   
    nextUnitOfWork  
  ) 
}

开始使用循环,我们需要设置第一个工作单元,然后写一个`performUnitOfWork` 函数 不仅能渲染当前工作也会返回下一个工作单元。

// 渲染任务调度逻辑--Fiber升级
// 传入两个参数,第一个是react元素,第二个是dom元素
const render = (elem, container) => {
    // 创建dom元素
    const dom = elem.type === 'TEXT\_ELEMENT' ? document.createTextNode(''): document.createElement(elem.type);

    //为dom元素赋予属性
    Object.keys(elem.props)
    .filter(key => key !== 'children')
    .forEach(prop => dom\[prop] = elem.props\[prop]);

    // 删除递归,递归严重影响性能,且无脑无优先级更新
    // 将dom元素追加到父节点中
      container.append(dom);
}

let nextUnitOfWork = null;
const workLoop = (deadLine) => {
// 是否应该终止
let shouldYield = false;
while(nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadLine.timeRmatining() < 1;
}
// 判断是否有足够的时间执行当前任务,若没有,则等系统空闲后执行workhook
requestIdleCallback(workLoop);
}
// 第一次发起请求
requestIdleCallback(workLoop);
const performUnitOfWork = () => {

}
export default render;

步骤四:FIbers

为了组织工作单元,我们需要一个数据结构:a fiber Tree;

每个元素都有一个fiber,并且每个fiber都是一个工作单元,

举个例子:假设我们想渲染一个下面这样的元素树

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

在渲染过程中,我们将创建root fiber,并且将它设为第一个工作单元,其余的工作将发生在performUnitOfWork 函数中,在这里,我们将为每个fiber做三件事情:

  1. 添加DOM元素;
  2. 为元素子元素创建;
  3. 选择下一个工作单元。

这个数据结构的目标之一是让找到下一个工作单元更简单,这就是为什么每一个fiber都有指向其第一个子节点,下一个兄弟节点和父节点的链接。

当我们结束一个fiber的渲染工作,如果他有子元素,那么fiber将是下一个工作单元。

从我们的例子中可以看出,当我们结束div这个工作单元,下一个工作单元将是h1 fiber,如果fiber c没有子节点,我们使用兄弟节点作为下一个工作单元,例如p, fiber没有子节点,我们在它工作完成后移动到a fiber。如果fiber没有子节点或兄弟节点,我们走到“叔叔”-兄弟节点的父节点,像例子中的ah2 fiber

并且,如果父节点没有兄弟节点,我们继续向上遍历父节点,直到找到有兄弟元素的父节点或到达了根节点,如果我们到达了根节点,意味着所有渲染工作已经完成。

现在让我们将其转化为代码,首先,我们从render中删除这个代码,保留在自己的函数中创建DOM节点的部分,我们一会儿继续使用它。

render函数中,我们将设置nextUnitOfWorkfiber tree的根节点。

然后,当浏览器准备好了,它将调用我们的workLoop我们将开始运行根节点。

首先,我们创建一个新节点并且把它加入到DOM中。我们在fiber.dom中持续追踪DOM节点属性;

然后为每一个子元素创建一个新的fiber,我们将其添加到fiber tree中,作为一个子元素或兄弟元素,取决于它是否有第一个子节点。

最后我们搜寻下一个工作单元,我们首先尝试子节点,然后是兄弟节点,然后是叔叔节点,以此类推。

这就是我们的performUnitOfWork

const performUnitOfWork = (fiber) => {
  // 初次渲染时,不存在dom,因此将fiber转化为dom渲染到页面中
  console.log('fiber', fiber);
  if(!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  // 追加到父节点
  if(fiber.parent) {
    fiber.parent.dom.append(fiber.dom);
  }
  // 给children 新建fiber
  const elements = fiber.props.children;
  // preSibling用于记录非亲儿子情况下的前一个兄弟组件
  let preSibling = null;
  for(let i = 0; i < elements.length; i++) {
    const newFiber = {
      type: elements[i].type,
      props: elements[i].props,
      parent: fiber,
      dom: null,
      child: null,
      sibiling: null 
    }
    //为fiber之间构建关系,构建Fiber tree
    // 如果是children的第一个,就是唯一儿子,同时将preSibling指向当前newFiber
    if(i === 0) {
      fiber.child = newFiber; 
    } else {
      preSibling.sibiling = newFiber;
    }
    preSibling = newFiber;
  }
  // 返回下一个fiber
  if(fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
   while(nextFiber) {
    if(nextFiber.sibiling) {
      return nextFiber.sibiling;
    }
    nextFiber = nextFiber.parent;
   }
}

步骤五:渲染和提交阶段

我们有另一个问题,每次元素执行时我们增加一个新节点到DOM中,并且浏览器可以打断我们、结束渲染整颗树的工作,用户将看到一个不完整的UI

我们不想要这样,因此我们需要从这里删掉修改DOM的部分。

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

因此,我们保持追踪这个fiber树的根节点,我们称之为 work in progress root 或者 wipRoot。一旦我们完成所有工作(我们知道这一点是因为没有下一个工作单元),我们就会将整个 fiber 树提交到 DOM 中。

// commit 阶段
if(!nextUnitOfWork && wipRoot) {
    commitRoot();
}

我们在commitRoot函数中做这些,这里我们递归地向DOM中添加节点。

// 提交
const commitRoot = () => {
  // 入口是child,、需要将其插入父节点的dom中
  commitWork(wipRoot.child);
  wipRoot = null;
}

// 递归提交所有fiber
const commitWork = (fiber) => {
  if(!fiber) {
    return;
  }
  const parentDom = fiber.parent.dom;
  parentDom.append(fiber.dom);
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

步骤六:重建

目前为止,我们仅仅向DOM中添加了一些内容,但关于更新和删除节点呢?

我们现在打算做这个,我们需要比较render函数中接收到的元素和最后提交到DOM中的fiber tree,因此,我们需要在完成提交后保存最后提交到DOM中的fiber tree的引用。我们叫它currentRoot。 我们也向每个fiber增加alternate属性,这个属性是旧的fiber和我们现在的提交阶段要提交到DOM中的fiber之间的一个关联。

现在我们将performUnitTiWork中创建新节点的代码提取出来,放到一个新的reconcilenChildren函数中,在这里,我们将对旧的 fiber 与新元素进行协调(reconcile)。我们同时迭代旧的fiberwipFiber.alternate )和我们想要重建的元素数组。

如果我们同时忽略了迭代数组和链表中所有的脚手架代码,我们最关注的是while循环内最重要的部分:oldFiberelementelement是我们想要渲染到DOM上的东西,oldFIber是我们最后一次渲染的内容,我们需要比较它们看是否有需要提交到DOM中的修改。

我们使用type比较它们:

  • 如果旧的fiber和新元素有相同类型,我们可以保留DOM节点只需更新新的属性
  • 如果类型不同且有一个新元素,意味着我们需要新建一个元素;
  • 如果类型不同且有一个旧的fiber,我们需要删除旧fiber

React也使用了keys,能够更好进行重建,例如,它会检测到当子元素在元素数组中改变位置的情况,当旧的fiber和元素有相同类型,我们创建一个新的fiber,保留旧的fiberDOM节点挤属性。

我们也为fiber增加了一个新的属性effectTag,我们将使用这个属性在提交阶段。

然后对于需要新的节点的情况,我们使用PLACEMENT 标记新的fiber,对于需要删掉节点的情况,我们没有新的fiber,因此我们对旧的fiber进行标记。

当我们提交fiber treeDOM时 我们在正在渲染的根中处理它,不需要旧的fibers。

因此我们需要一个数组标记我们想删除的节点,然后当我们向DOM中提交改变时,我们使用数组中的fibers

现在,我们改变commitWork函数去处理新的effectTags

如果fiberPLACEMENT标签,我们和之前做的一样,想父元素fiber添加DOM节点;

如它是DELETION,我们做相反的操作,移除子节点;如果是UPDATE,我们需要用改变的属性更新已经存在的DOM节点;我们实现更新在updateDom函数中。我们对比新旧fiber的属性,移除掉已经消失的属性,设置新的或改变了的属性。

if(sameType) {
    // 更新
    newFiber =  {
        type: oldFiber.type,
        props: elements\[index].props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: 'UPDATE'
    }
}
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);
}

我们需要更新一个新的属性类型是事件监听,如果属性名称是以on前缀,我们用不同的方式处理它。

如果事件处理改变,我们从nodes中移除它然后添加新的处理。

// Add event listeners

Object.keys(nextProps)

.filter(isEvent)

.filter(isNew(prevProps, nextProps))

.forEach(name => {

    const eventType = name

    .toLowerCase()

    .substring(2)

    dom.addEventListener(

        eventType,

        nextProps[name]

      )
   })
}

步骤七:函数式组件

接下来我们需要让其支持函数式组件,首先,让我们改变这个例子,我们将使用单独的函数式组件返回一个h1元素,

请注意,如果我们将 JSX 转换为 JavaScript,它将变为:

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

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

  • fiber来源于函数组件而不是一个DOM节点
  • 子元素来源于运行后的函数而不是直接从props获取

我们检查fiber类型是一个function,根据这个条件我们进入不同的更新函数,在updteHostComponent我们和之前做的一样,在updateFunctionComponent我们执行函数获取子元素。

const updateFunctionComponent = (fiber) => {
  // 运行函数式组件,同时将props作为参数传入
  const children = [fiber.type(fiber.props)];
  // 新建newFiber,构建fiber树
  reconcileChildren(fiber, children);
}

对于我们的例子,fiber.typeApp函数,当我们执行它时,返回的是h1元素。

然后,一旦有孩子,重构以相同方式工作,我们不希望有任何改动。

我们需要在commitWork函数中改变一些,现在我们有了没有DOM节点的fiber,我们需要改变两个事情。

首先,找到DOM节点的父节点,我们需要向上查找直到找到有DOM节点的fiber,然后删除节点时,我们需要一直进行,直到找到有子节点的DOM节点为止。

// 删除函数式组件commit时dom中的子组件
const commitDeletion = (fiber, domParent) => {
   if(fiber.dom) {
    domParent.removeChild(fiber.dom);
   } else {
    commitDeletion(fiber.child, domParent);
   }
}

步骤八:钩子函数

最后一步,现在我们有函数式组件,我们也来添加状态。

我们用经典的计数器改变组件的例子,每当我们点击,它的状态就加一。

请注意,我们使用Didact.useState去获取和更新计数器的值。

这是我们例子中调用counter函数的地方,在函数内部我们叫useState

我们需要在调用函数式组件之前初始化一些全局变量,以使我们可以使用useState函数内部使用它们。

首先我们设置work in progress fiber;

我们在fiber中添加了一个hooks数组支持多次在相同的组件中调用useState,同时我们追踪当前hook的索引。

当函数式组件调用useState时,我们检查是否有旧的hook,我们使用 hook 索引在 fiber alternate 中进行检查。如果存在旧的hook,将旧的hook复制到新的hook,如果不存在的话,初始化这个state。然后向fiber中添加新的hook,按属性递增增加,并且返回这个state

unction 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也应该返回一个更新state的函数,因此我们定义一个setState方法接受一个action(对于计数器例子,这个方法时递增state的方法)。

const setState = action => {

    hook.queue.push(action)

    wipRoot = {

        dom: currentRoot.dom,

        props: currentRoot.props,

        alternate: currentRoot,

    }

    nextUnitOfWork = wipRoot

    deletions = []
}

我们推动这个action到我们新增的hook中,然后我们做和render函数相似的工作,设置a new work in progress作为下一个工作单元以便于这个工作循环可以开始一个新渲染阶段。

但是我们还没有运行这个action

我们渲染这个组件在下一个时间片中,我们从旧的hook queue中获取到所有actions,然后逐个将它们应用到新的hook中,我们返回的state是更新后的state了,这就是所有,我们搭建了自己版本的React。

结语

除了帮助你理解React是如何工作的之外,这篇文章的目的之一是让你更容易更深入地理解React代码库,这也是为什么我们全局使用相同的变量和函数名。

例如,如果你在一个真实的react应用中,在任意一个函数式组件中增加一个断点,调用堆栈应该会展示:

  • workLoop
  • performUnitOfWork
  • updateFunctionComponent

我们没有包含大部分React特性和优化,例如,有一些和React做的不同的:

  • Didact,我们在渲染阶段遍历整颗树,如果子树没有改变的话,React会通过一个启示或方法跳过这些部分。
  • 我们在提交阶段也遍历整颗树,React为有相互作用和只访问的fibers维护了一个链表。
  • 每当我们为进行中的树创建了work in progress树,我们为每个fiber创建新的对象,React回收之前的fiber放到当前树上。
  • 当Didact在渲染阶段接收到一些新的更新时,他会丢掉正在进行中的工作从根节点重新开始,React还会为每个更新添加一个过期时间戳,并使用它来决定哪个更新具有更高的优先级。
  • 还有很多...

你还可以轻松地添加一些功能:

  • 使用对象作为样式属性
  • 拍平子数组
  • useEffect 钩子
  • 通过`key进行协调
转载自:https://juejin.cn/post/7241465246463918140
评论
请登录