React 总体感知

当咱们由浅入深地认知同样新事物的时候,每每须要遵循 Why > What > How 这样一个认知过程。它们是相辅相成、缺一不可的。而了解了具体的 WhatHow 以后,每每可以更加具象地回答理论层面的 Why,所以,在进入 Why 的探索以前,咱们先总体感知一下 WhatHow 两个过程。html

What

打开 React 官网,第一眼便能看到官方给出的回答。前端

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

不知道你有没有想过,构建用户界面的方式有千百种,为何 React 会突出?一样,咱们能够从 React 哲学里获得回应。react

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

可见,关键是实现了 快速响应 ,那么制约 快速响应 的因素有哪些呢?React 是如何解决的呢?github

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 以下:

Fibers

若将上图转化到咱们的代码里,咱们第一件事得找到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/

转载请注明出处

相关文章
相关标签/搜索