0

React 整体感知

 3 years ago
source link: https://my.oschina.net/u/4430337/blog/4877581
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

React 整体感知 - 涂鸦智能技术团队的个人空间 - OSCHINA - 中文开源技术交流社区

当我们由浅入深地认知一样新事物的时候,往往需要遵循 Why > What > How 这样一个认知过程。它们是相辅相成、缺一不可的。而了解了具体的 WhatHow 之后,往往能够更加具象地回答理论层面的 Why,因此,在进入 Why 的探索之前,我们先整体感知一下 WhatHow 两个过程。

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

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

不知道你有没有想过,构建用户界面的方式有千百种,为什么 React 会突出?同样,我们可以从 React 哲学里得到回应。

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

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

让我们带着上面的两个问题,在遵循真实的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/

转载请注明出处


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK