构建你自己的React

911 阅读15分钟

最近看到一篇好文Build your own React,内容精练。文章从基本的几个核心阶段讲述了React的工作方式,但感觉原文虽然很棒但对新手可能并不够“白话”。所以打算借鉴原文为基础,目的让中文读者更好理解,以补充的方式再来构建一次中文版我的React,致敬原作者

原文的UI效果,也被作者开源了出来,Make programming tutorials with markdown,大家可以康康!~简单制作类似的代码展示方式!很棒👍

基于 React 16.8,文章为了让不熟悉react或者并没有用过的伙伴也能理解,所以内置了大量废话,以第一人称叙事的方式讲述…

路线

虽然乍眼一看这些路线,并不能马上理解其中的关联,大家顺序阅读即可,文中会对每个部分做出解释。

  1. 前置
  2. createElement
  3. render
  4. Concurrent Mode
  5. Fibers
  6. Render and Commit Phases
  7. Reconciliation
  8. Function Components
  9. Hooks

配置项目

  1. 使用create-react-app创建一个项目,虽然其中大部分东西我们都不会用上,但他满足我们的基本开发条件。
npx create-react-app mini-react
cd mini-react
npm/yarn start
  1. 让我们来对index.js文件做一些修改,这是我们开始的第一步。
import React from 'react';
import ReactDOM from 'react-dom';

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

前置

index.js

import React from 'react';
import ReactDOM from 'react-dom';

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

这并不是一个有效的js代码,他借助了babel-plugin-transform-react-jsx来通过babel进行转换,从jsx到有效的js

const element = <h1 title="foo">Hello</h1>

让我们来看看转换后的代码。

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

它将dom元素换成了React.createElement方法的调用,它的数据结构是这样的。

const element = {
  type: "h1", //标签类型
  props: { 
    title: "foo", //dom属性
    children: "Hello", //嵌套内容
  },
}

现在我们尝试绕过babel,自己来翻译一下这段代码,让它能够以原本的逻辑执行。

除去import部分,一共三段,那我们一段一段翻译。

import React from 'react';
import ReactDOM from 'react-dom';

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

首先创建我们的元素,分别是h1和“Hello”。

const element = <h1 title="foo">Hello</h1>;

// 把他抽象为一种数据结构
const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}

// 根据结构中的内容创建h1
const node = document.createElement(element.type)
node["title"] = element.props.title

// 根据子元素的数据创建元素并插入,这里我们不会选择innerText的方式,为了让结构更加统一,我们希望子元素也是一个dom元素,并且展示的内容也是它的属性
const text = document.createTextNode("")
text["nodeValue"] = element.props.children

接下来是这一步(好吧,他没什么变化!~)

// const container = document.getElementById("root");

const container = document.getElementById("root");

把我们创建的元素塞到root里面

//ReactDOM.render(element, container);

// node为h1标签,text则为“hello”
node.appendChild(text)
// 加入我们的node
container.appendChild(node)

完整的代码在这里,试试看,我们完全没有使用React,他能工作对不对!:D 🎉 (很简单!)

const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}
 
const container = document.getElementById("root")
 
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

我们来换段代码看看,它包含了更复杂的嵌套,我们该尝试封装一下上一步的逻辑了,再来翻译一遍吧~

const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)
const container = document.getElementById("root")
ReactDOM.render(element, container)

首先让我们看看上一步频繁出现的代码,我们可以有一个函数,他接收要创建节点的type,节点的属性props以及可以包含的children元素。

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)

这个函数长这样

// 我们使用拓展运算符获取后续所有的children
function createElement(type,props,…children){
	// 他返回了我们抽象的数据结构
	return {
    type,
    props: {
      ...props,
      children,
  }
}

children这里我们需要注意,当子元素并不是一个标签时,我们并没有办法拿到他的标签type,但我们又希望children内部包含的都是标签,所以我们需要进行一步转换

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_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}

我们来用createElement替换一下这段代码,并给我们的React起个名字,MiniReact!(在这里我们只抽象了数据结构,并没有进行渲染的操作,请带着疑问往后看~)

/*
 * const element = (
 *	<div id="foo">
 *		<a>bar</a>
 *		<b />
 *	</div>
 */)

const MiniReact = {
	createElement,
}

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

接下来为了让我们的代码更像一个三方的库,我们来还原代码,让babel-plugin-transform-react-jsx去调用我们写的“翻译方法”!~

const MiniReact = {
  createElement,
}
 
// 这是babel-plugin-transform-react-jsx文档中说到的写法,具体可以参考上面的链接,查看所有设定,这将会调用我们MiniReact的createElement方法。
/** @jsx MiniReact.createElement */
const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)
const container = document.getElementById("root")
ReactDOM.render(element, container)

在这一步我们完成了React中关键方法createElement的编写,但其实我们只创建了目标结构,并没有将其渲染出来,接下来我们将会在render中进行渲染的步骤。

render

在这一步中我们已经可以拿到一颗完整的dom结构树啦,它包含了每一层dom的类型、属性和子元素,现在我们需要使用它的描述进行创建,并挂载。

function render(element,container){
	// TODO 创建dom节点
}

// 加到我们的MiniReact中
const MiniReact = {
	createElement,
	render
}

MiniReact.render(element,container)

完善render中创建元素的逻辑(element为createElement创建的数据结构,container则为dom节点)

function render(element, container){
	const dom = document.createElement(element.type);
	// 为每个字元素执行同样的render操作,渲染他们每一个,同样的,第一个参数为元素数据,第二个参数为要挂载的目标节点
	element.props.children.forEach(child => 
		render(child, dom)
	)
	container.appendChild(dom)
}
…

现在我们已经可以根据type去制作各种节点啦,但还有一种不可以!上面我有为text自定义了一种类型TEXT_ELEMENT,让我们再补充下这里的逻辑,让render可以支持这个类型。

function render(element, container) {
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)
 
  element.props.children.forEach(child =>
    render(child, dom)
 	)
	container.appendChild(dom)
}
	

到这里我们只使用了数据结构(type/props/children)中的type,我们还需要将props挂载到创建的dom上。

function render(element, container) {
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)
 	// 因为children同样存在于props中,我们要将其过滤
  const isProperty = key => key !== "children"
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
		 // 将过滤后的props对dom进行设置
      dom[name] = element.props[name]
    })
 
  element.props.children.forEach(child =>
    render(child, dom)
  )
	container.appendChild(dom)
}

到现在我们已经可以用自己写的MiniReact仿照jsx的语法进行渲染啦!🎉

但还有个小问题❕❕❕

现在我们会同步的去构建这颗树,并且逐步的创建dom、设置属性并挂载,那如果这颗树过大怎么办,他会占用js主线程过多的时间,会发生无法响应用户操作的情况,那我们如何让它可以暂停-去处理更高优先级事件-继续渲染呢?

我们来看看React是怎么解决的,并且借鉴到我们的MiniReact中来。

concurrent mode

Concurrent mode 可以用一句话来理解可帮助应用保持响应,并根据用户的设备性能和网速进行适当的调整,这句话的解释就是我们这部分的期望,拆分执行单元。

首先我们需要一套逻辑,他可以把整个遍历工作暂停或开始。所以我们需要将任务分成一个个小的工作单元(unitOfWork),我们完成一个工作单元后检查一下是否还有时间可以继续执行下一个工作单元,如果可以的话就继续执行。

可以完成这一步的前提是我们需要浏览器来提醒我们,当前是否有空闲的执行时间,我们可以通过requestIdleCallback方法来进行操作(后续版本的React已经替换为scheduler package来代替这步操作),当浏览器有空闲时则会调用这个方法传入的回调,那我们来写一下代码。

let nextUnitOfWork = null
// 进行循环工作的主要逻辑
function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
	  // 2.performUnitOfWork是我们执行工作单元的主要逻辑,他完成后会根据寻找的逻辑返回下一个工作单元
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
	  // 3.deadline是requestIdleCallback回调给到的参数,在这里我们用它来检测是否还有剩余时间
    shouldYield = deadline.timeRemaining() < 1
  }
	// 4.没有可操作的工作单元或者剩余时间不足时则会再次设置回调,等待下一次浏览器的调用
  requestIdleCallback(workLoop)
}
// 1. 设置通知的回调函数
requestIdleCallback(workLoop)
// 处理工作单元的方法
function performUnitOfWork(nextUnitOfWork) {
  // TODO
}

现在我们已经可以分步处理每一个工作单元了,并且接收到浏览器的通知后我们可以停止手头的工作,让浏览器去处理更重要的任务,这样已经解决了一半的问题啦!

那什么又是工作单元呢?

fibers

有这样一个dom树

  dom = <div>
    <h1>
      <P />
      <a />
    </h1>
    <h2 />
  </div>
	
	dom => root

它对应着

image.png

所以很容易看出,这里的每一个单元都分别是一个dom节点,它包含着一些基本信息

  • 类型(type)
  • 属性(props)
    • 子元素(children)
    • 有效的属性,节点上的具体设置(…props)

所以目前我们可以称工作单元Fiber,反之亦然。

加下来让我们回忆一下刚才做了什么。

  1. 编写了render方法,他是渲染的起始步骤,接收到dom的描述结构与目标节点后,逐层创建元素,并向父节点添加,完成所有部分。
  2. 设置好了可以中断的执行流程,它的条件是需要有下一个工作单元足够执行任务的时间

细心点可以发现,第一步是没有办法停下的,所以我们需要修改render方法,让他能够配合第二步工作。

原方法

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

首先将创建节点的逻辑摘出来,这里我们有两点没有写入,后续我们将会处理。

  1. 递归子元素的逻辑
  2. 向父元素插入子元素
// 它的职责很单一,收到工作单元的数据(fiber)生成dom
function createDom(fiber) {
  const dom =
    fiber.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type)
 
  const isProperty = key => key !== "children"
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = fiber.props[name]
    })
 
  return dom
}
 
function render(element, container) {
  // TODO 设置下一个即将处理的工作单元
}
…

render现在要做的事情仅仅是开启任务即可

function render(element, container) {
	// 设置好下一个工作单元后,浏览器空闲时则会通知我们可以干活了,于是我们就会检查是否有下一个工作单元以及时间是否够用,顺利的话我们就开启了任务!
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],
    },
  }
}
 
let nextUnitOfWork = null

那任务开始了,我们需要解决一个新问题和刚刚没有理会的两个小问题。

  1. 谁来调用createDom呢?
  2. 递归子元素的逻辑
  3. 向父元素插入子元素

这部分逻辑都会在处理工作单元(performUnitOfWork)的函数中完成。

function performUnitOfWork(fiber){
	// TODO 创建/添加dom元素
	// TODO 创建新的fiber元素
	// TODO return下一个工作单元
}

让我们来分别完成performUnitOfWork中的三步。

  1. 创建/添加dom元素(调用createDom,插入子元素)
function performUnitOfWork(fiber) {
	// 我们可能会多次遍历同一颗fiber树,所以在这里第一次没有dom时要创建dom
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
 
	// 如果检查到有父节点,则可插入
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }
 
	// TODO 创建新的fiber元素
	// TODO return下一个工作单元
}
  1. 创建新的fiber元素
function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
 
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }
 
	/*
	* element原数据,结构类似。
	* element = {
	* 	type:”p”,
	* 	props:{
	* 		children:[]
	*	}
	* }
	* 原数据并不是一个方便操作的数据结构,所以我们要对其进行修改,改成我们所认为的fiber,后面会具体讲到其中包含什么
	*/ 
	const elements = fiber.props.children
  let index = 0
  let prevSibling = null
 
  while (index < elements.length) {
    const element = elements[index]
 	  // 转换原数据为fiber
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }
 
	  // 父fiber只会关联第一个子fiber
	  // 但所有子fiber都可以找到父fiber
    if (index === 0) {
      fiber.child = newFiber
    } else {
		 // 其余fiber均指向兄弟fiber
      prevSibling.sibling = newFiber
    }
 
    prevSibling = newFiber
    index++
  }

	// TODO return下一个工作单元
}
  1. return下一个工作单元,选择的优先级分别是
    1. 查看是否有子fiber
    2. 查看是否有兄弟fiber
    3. 查看是否有父fiber的兄弟fiber

image.png

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
 
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }
 
	const elements = fiber.props.children
  let index = 0
  let prevSibling = null
 
  while (index < elements.length) {
    const element = elements[index]
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }
 
    if (index === 0) {
      fiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }
 
    prevSibling = newFiber
    index++
  }

	// 如果有子fiber则返回子fiber
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
	  // 如果有兄弟fiber则返回兄弟fiber
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
	  // 当再也没有兄弟fiber时,返回父fiber的兄弟fiber,然后重复
    nextFiber = nextFiber.parent
  }
}

完成!到这里我们已经完成了重构后的代码,现在不但可以渲染创建元素,还可以中断执行,继续渲染。🎉

但又遇到了另外一个问题,我们注意到在这个方法中,每完成一个节点的创建,就会进行插入,那当任务被打断停止时就会出现十分怪异的情况,有些元素渲染了一部分,并没有完全完成。

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

所以我们要调整一下代码,让他可以从头至尾在一颗工作dom树中完成fiber的创建与dom绘制,那就需要来区分两个阶段render为工作节点计算的过程,commit才是最终的绘制过程。

render/commit phases

首先为了不让它在每一步都去修改dom,我们需要删掉这段代码。

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

再回到render,创建我们工作中的root数据(wip root = work in progress root)

function render(element, container) {
	// 存储整个wipRoot
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
  }

	// 一开始的wipRoot既是第一个工作单元
  nextUnitOfWork = wipRoot
}
 
let nextUnitOfWork = null
let wipRoot = null

然后增加提交阶段

function commitRoot(){
	// TODO 把节点添加进dom
}
…

修改提交的执行时机,在workLoop函数中

…
function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
 
	// 当没有下一个工作节点且wipRoot存在时才会提交渲染
  + if (!nextUnitOfWork && wipRoot) {
  +   commitRoot()
  + }
 
  requestIdleCallback(workLoop)
}
…

现在我们已经去分开了render和commit两个阶段,接下来我们要完善commit的逻辑!

reconciliation

在这个阶段我们需要注意两个规则(react的启发式算法)

  1. 两个不同type(类型)的元素将会产生出不同的tree
  2. 开发者可以通过key来指出那些子元素在每次render下能保持不变(但在此处的例子中我们并没有实现这一条)
function commitRoot() {
	// 我们按照下一个工作节点的查找规则,传入第一个子fiber
  commitWork(wipRoot.child)
	// 防止提交阶段发生多次,在此处清除wipRoot的引用
  wipRoot = null
}

function commitWork(fiber) {
  if (!fiber) {
    return
  }
	// 找到父节点并插入子元素
  const domParent = fiber.parent.dom
  domParent.appendChild(fiber.dom)
	// 递归渲染子节点与兄弟节点
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

在上面我们只处理了增加dom的操作,现在我们需要根据一些规则来向其中修改与删除节点。

但在修改与删除之前,同样我们不能再遍历工作节点时就对他进行操作,所以我们需要对工作单元进行标记,最后去进行操作。

修改commitRoot方法

let currentRoot = null; 
function commitRoot() {
  commitWork(wipRoot.child)
  + currentRoot = wipRoot
  wipRoot = null
}

修改render方法


function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
	  // 因为提交时会将完成且待提交的wipRoot赋值给currentRoot
	  // 所以此处的alternate将会是上一次提交的wipRoot
	  // 我们用它来做对比!~
    + alternate: currentRoot,
  }
  nextUnitOfWork = wipRoot
}

接下来让我们把处理工作单元performUnitOfWork的方法进行拆分

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
 
  const elements = fiber.props.children
	// 把中间创建子fiber的逻辑封装至这个函数
  reconcileChildren(fiber, elements)
 
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}

//创建子fiber
function reconcileChildren(wipFiber, elements) {
  let index = 0
  let oldFiber =
    wipFiber.alternate && wipFiber.alternate.child
  let prevSibling = null
 
  while (
    index < elements.length ||
    oldFiber != null
  ) {
	  // 切换至下一个fiber
    const element = elements[index]
    let newFiber = null

    const sameType =
      oldFiber &&
      element &&
      element.type == oldFiber.type
 
    if (sameType) {
      // TODO 更新节点
    }
    if (element && !sameType) {
      // TODO 添加新的节点
    }
    if (oldFiber && !sameType) {
      // TODO 删除旧的fiber与node
    }
 
    // TODO 使用旧的fiber与当前element数据进行对比
 
    if (oldFiber) {
		 // 同样的旧的fiber,也会切换至下一个
      oldFiber = oldFiber.sibling
	    // 为子元素创建新的fiber
    	 const newFiber = {
      	type: element.type,
      	props: element.props,
      	parent: wipFiber,
      	dom: null,
    	 }
 
		 // 后续操作相同…
    	 if (index === 0) {
      	wipFiber.child = newFiber
    	 } else {
      	prevSibling.sibling = newFiber
    	 }
 
	     prevSibling = newFiber
	     index++
    }
}

中间我们会对每个工作单元进行链接,把旧的工作单元连接到当前的工作单元中,比较时对当前工作单元做标记,比较的过程遵循几种规则。

  1. 比较两者type,如果相同则只需更新dom的属性(props) “UPDATE”
// TODO 更新节点
	  if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      }
    }
…
  1. 如果type不同,并且有新的element,那意味着我们需要创建并替换一个新的dom “PLACEMENT”
      // TODO 添加新的节点
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      }
  1. 如果type不同,并且有旧的元素,我们需要把旧的删除 “DELETION”

在这一步我们并没有新的fiber,所以我们将标记加在旧的fiber上面,并且在提交时我们并不会遍历旧的fiber树,所以在这里我们将它置入一个待删除的数组中,后续通过遍历此数组中的dom来进行删除。

      // TODO 删除旧的fiber与node
		 oldFiber.effectTag = "DELETION”
		 deletions.push(oldFiber)

与此同时我们需要加一个全局变量deletions并且在首次render时清空数组

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  }
  + deletions = []
  nextUnitOfWork = wipRoot
}
 
let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
+ let deletions = null

最后我们需要为每种类型("UPDATE"/"PLACEMENT"/"DELETION”)执行相应的操作,这一步就是在commitWork中完成。

首先单独为待删除的fiber执行一遍commitWork。

function commitRoot(){
	+ deletions.forEach(commitWork);
	commitWork(wipRoot.child);
	currentRoot = wipRoot;
	wipRoot = null;
}

完善commitWork逻辑。

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
  ) {
	  // 更新dom时我们需要传入旧的props,去进行props的对比
    updateDom(
      fiber.dom,
      fiber.alternate.props,
      fiber.props
    )
	// 删除操作
  } else if (fiber.effectTag === "DELETION") {
    domParent.removeChild(fiber.dom)
  }
 
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}  


// 是否为事件
const isEvent = key => key.startsWith("on")
// 检查是否为有效的props
const isProperty = key =>
  key !== "children" && !isEvent(key)
// 是否为新的props
const isNew = (prev, next) => key =>
  prev[key] !== next[key]
// 在新的props中他是否被去掉了
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
	// 移除掉旧的监听事件
	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]
      )
    })
 
  // 删除掉旧的props
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = ""
    })
 
  // 增加新的props与修改原有的props
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name]
    })

	 
  // 增加新的监听事件
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.addEventListener(
        eventType,
        nextProps[name]
      )
    })
}

到这里我们已经完全实现了一个我们自己的MiniReact,麻雀虽小五脏俱全,但是他还没有支持函数式组件与函数式组件特有的Hooks,感兴趣的伙伴可以继续看!~🎉

function components

首先来看下我们的fiber,直到现在我们都还使用着babel插件返回给我们的节点type,它是一个字符串。但函数组件我们该如何知道他的type是什么呢?

fiber = {
	type:”xxx”,
	props:{},
	dom:null,
	…
}

函数组件与之前的写法有两处不同

  1. 函数组件的fiber没有dom节点(我们无法根据函数来拿到它的type,再紧接着创建dom写入fiber)
  2. children来自于运行函数时,而不是直接来自props(之前我们可以通过babel插件直接解析语法获得)

紧接着有这样一个例子…

/** @jsx MiniReact.createElement */
function App(props) {
  return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
const container = document.getElementById("root")
MiniReact.render(element, container)

我们需要修改一下执行工作单元的函数performUnitOfWork

function performUnitOfWork(fiber) {
	// 首先将直接创建dom的方法抽出来,因为createDom无法作用于函数组件
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
 
  const elements = fiber.props.children
  reconcileChildren(fiber, elements)
	…
}


function performUnitOfWork(fiber) {
  const isFunctionComponent =
    fiber.type instanceof Function
	// 这里我们对函数类型做特殊处理,非函数类型执行逻辑不变
  if (isFunctionComponent) {
    updateFunctionComponent(fiber)
  } else {
    updateHostComponent(fiber)
	}
	…
}

function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)]
	// 到这里我们的处理逻辑都和之前一样
  reconcileChildren(fiber, children)
}
 
function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  reconcileChildren(fiber, fiber.props.children)
}

修改一下commitWork文件

function commitWork(fiber) {
  if (!fiber) {
    return
  }
 
	// 在这里我们没有办法再保证每个fiber都有dom,所以这里我们需要修改一下
  let domParentFiber = fiber.parent.dom
	…
}

首先要找到dom节点的父节点,我们需要上层的fiber,直到找到具有dom的fiber

function commitWork(fiber) {
  if (!fiber) {
    return
  }

	let domParentFiber = fiber.parent
  while (!domParentFiber.dom) {
   domParentFiber=domParentFiber.parent
  }
  const domParent = domParentFiber.dom
	…
}

删除操作同理,我们需要逐层翻到有dom的fiber

function commitWork(fiber){
…
	// 删除操作
  } else if (fiber.effectTag === "DELETION") {
    - domParent.removeChild(fiber.dom)
	  + commitDeletion(fiber, domParent)
  }
…
}

function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom)
  } else {
    commitDeletion(fiber.child, domParent)
  }
}

这样我们就完全兼容了函数组件的情况!:P

hooks

最后一步,既然我们有了函数组件,同样我们也应该为他添加Hooks,增加状态。

现在我们更改示例为一个计数器组件,当我们每次点击它,他都会将state + 1。

const MiniReact = {
	…,
	useState
}

function Counter() {
  const [state, setState] = MiniReact.useState(1)
  return (
    <h1 onClick={() => setState(c => c + 1)}>
      Count: {state}
    </h1>
  )
}
const element = <Counter />

每次在useState调用之前,我们需要在updateFunctionComponent方法中初始化一些全局变量,以便我们可以在useState函数内部使用它们。

let wipFiber = null
let hookIndex = null
 
function updateFunctionComponent(fiber) {
	// 保存旧的fiber,供后面通过它来找出旧的hook
  + wipFiber = fiber
	// 标记着当前hook执行到了第几个,每一次这个方法执行都代表一次update,即使hookIndex清零
  + hookIndex = 0
	// 清空旧的fiber hook
  + wipFiber.hooks = []
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}
function useState(initial) {
	// 招出旧的fiber hook
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex]

	// useState每次render但state不会被重置就是因为此处
	// 我们可以通过旧的hook来拿到上一次的state,复用它
  const hook = {
    state: oldHook ? oldHook.state : initial,
	  // 每一次setState为一次action,所有action都会存在此处
    queue: [],
  }

	// 每次render,我们会拿到所有未执行的actions,然后执行一遍更新state
  const actions = oldHook ? oldHook.queue : []
  actions.forEach(action => {
    hook.state = action(hook.state)
  })
 
	// 在这里我们每一次更新,都会保存一个事件,推入这个工作单元的hook队列中
	// 并且设置好当前的fiber,与旧的fiber(当前的工作单元 currentRoot)
  const setState = action => {
    hook.queue.push(action)
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
	  // 设置下一个工作单元,表示浏览器空闲时就可以开始工作了,执行更新逻辑,让state更新
    nextUnitOfWork = wipRoot
    deletions = []
  }
 
	// 给旧的fiber增加一个hook
  wipFiber.hooks.push(hook)
	// 下标+1,这样能保证我们每次都能拿到正确的hook
  hookIndex++
  return [hook.state, setState]
}

若发现文章不足,望大家指出。

感谢Build your own React的启发。