当前位置: 首页 > 工具软件 > Preact > 使用案例 >

从 Preact 源码一窥 React 原理(一):JSX 渲染

张毅
2023-12-01

系列文章:

  1. 从 Preact 源码一窥 React 原理(一):JSX 渲染(本文)
  2. 从 Preact 源码一窥 React 原理(二):Diff 算法
  3. 从 Preact 源码一窥 React 原理(三):组件

前言

Preact 是什么?
Preact 是 React 的轻量级实现,在 3KB 的量级提供了你所需要的功能:渲染 JSX、组件、虚拟 DOM、Legacy/New Context API,甚至还有一些 React 外的新特性。虽然不包含 React 16 所带来的例如 Fiber 等新特性,但是在这样小巧的体积下,还要什么自行车呢?
可以预见的是,在享受 Preact 的便利,压缩生成代码体积的同时,必不可免的会落到一些随之而来的坑里头去。但是本文的核心并不是 Preact 在工程实践中的例子,而是通过 Preact 来一窥 React 框架的实现原理(由于 React 庞大的体积,直接上手阅读源码实在是不够友好),Preact 便成为了绝佳的学习案例。

JSX 渲染

在一头扎入 Preact 源码里头之前,首先应当明确的是:我们在探求些什么?
漫无目的的寻找只会像没头的苍蝇一般处处碰壁。让我们从这一个最经典的用法出发:

import { h, render } from 'preact';

render((
	<div id="foo">
		<span>Hello, world!</span>
		<button onClick={ e => alert("hi!") }>Click Me</button>
	</div>
), document.body);

这是 Preact 官网上的一个示例,展示了 Preact 最基础的用法。
如果你曾使用过 React ,那么你对这一段代码应该很熟悉了。但你是否思考过这一段代码中实际上发生了什么事情呢?
在这里,赋给 render函数中的第一个参数是并不属于 JavaScript 标准的 JSX ,其是如何与 render函数相结合的呢?

是的,通过 Babel
如果你足够细心的话,你也许能注意到被 import 却没有被调用的 h函数。
Babel 能够将 JSX 语法提前转化为对 h函数的递归调用,上述代码的转换结果如下所示:

h("div", 
	{id: "foo"},
	h("div", {onClick: { e => alert("hi!") }}, "Click Me"),
	h("span", null, "Hello, world!")
)

不难推测, h函数(hyperscript的缩写,也就是 Preact 中的 createElement函数的别名)的所接受的参数为:

  • type 节点类型:上述例子中只存在简单的节点,因此其节点类型均为 string 值,事实上,对于组件节点,其节点类型则为函数;
  • props 节点属性:为 JSX 中对应节点所声明属性的集合;
  • children 子节点:从第三个参数开始,后续的参数均为子节点。子节点中除了递归调用 h函数返回的值以外,还可能是 string、boolean 等。

h函数将根据 Babel 转义的结果生成虚拟节点的树(以下将用createElement替代h称呼,因为createElement字面上更接近其函数的本意)。

VNode

createElement函数的返回值是 Preact 中的所使用的虚拟 DOM 的节点 VNode
VNode包含属性如下:

  • type:节点的类型,可能为 string (元素节点: node.nodeType === Node.ELEMENT_NODE)或 function(组件节点)或 null (值为 boolean 、string 等类型的文本节点: node.nodeType === Node.TEXT_NODE);
  • props:节点的属性。同时,props中包含了 children属性,即包含了所有的子 VNode 节点;
  • text:节点的文本属性,简单的节点解析并挂载到 DOM 上之后为文本节点,此类节点其他属性均为 null,只需要存一个文本属性值;
  • key:节点的键值,用于在 diff 算法中作为元素匹配的标记;
  • ref:React 的 ref属性;
  • _children:通过toChildArray属性将 props.children中的孩子节点展平,也就是将 props.children中的数组中的元素提取出来存入_children中;
  • _dom:虚拟 DOM 节点所对应的实际 DOM 节点;
  • _lastDomChildFragment节点的最后一个 DOM 子节点;
  • _component:组件节点所对应的组件实例。

createElement 函数

createElement函数的实现很简单,根据上述介绍的 Babel 传入参数,提取出 VNode所需的属性即可。
具体的实现如下所示,附上了部分注释:

// src/create-element.js
// createElement 参数:节点类型,节点属性,孩子节点(可能有多个,因此需要从 arguments 中取)
export function createElement(type, props, children) {
	if (props==null) props = {};
	// 获取所有的孩子节点
	if (arguments.length>3) {
		children = [children];
		for (let i=3; i<arguments.length; i++) {
			children.push(arguments[i]);
		}
	}
	if (children!=null) {
		props.children = children;
	}

	// "type" may be undefined during development. The check is needed so that
	// we can display a nice error message with our debug helpers
	// 提取组件节点中的默认属性
	if (type!=null && type.defaultProps!=null) {
		for (let i in type.defaultProps) {
			if (props[i]===undefined) props[i] = type.defaultProps[i];
		}
	}
	// 提取出 props 中不需要的 ref 和 key 属性
	let ref = props.ref;
	if (ref) delete props.ref;
	let key = props.key;
	if (key) delete props.key;

	// 通过简单的构造创建 VNode
	return createVNode(type, props, null, key, ref);
}

createElement函数首先将 arguments中的孩子节点放入数组中,并赋值给 props.children
对于组件节点中可能存在的 defaultProps预设值,函数将其付给 props中对应的空缺属性。
执行完上述操作之后,函数提取出 props中可能存在的 key以及 ref值,因其不需要提供给开发者,将其从 props中删去。
最终,通过 createVNode函数创建 VNode的实例。 createVNode函数非常简单,仅仅通过字面量创建一个包含给定属性,并将未给定属性置为 null的对象。

coerceToVNode 函数

由于 Babel 仅仅会将元素节点或者组件节点的参数传入 createElement函数,因此,在渲染过程中还需要针对于其他类型的节点提供一定的处理。
这些节点仅仅存在于元素节点或者组件节点的 children参数中,Preact 中通过 coerceToVNode函数对其进行处理,例如 boolean 值的节点,抑或是 string 值的节点。
具体函数实现如下所示:

// src/create-element.js
export function coerceToVNode(possibleVNode) {
	// null / undefined / boolean 等值直接返回 null
	if (possibleVNode == null || typeof possibleVNode === 'boolean') return null;
	// string / number 则返回文本节点
	if (typeof possibleVNode === 'string' || typeof possibleVNode === 'number') {
		return createVNode(null, null, possibleVNode, null, null);
	}

	// 对于数组则返回 Fragment 节点
	if (Array.isArray(possibleVNode)) {
		return createElement(Fragment, null, possibleVNode);
	}

	// Clone vnode if it has already been used. ceviche/#57
	// 并非第一次解析的节点则进行一次克隆操作
	if (possibleVNode._dom!=null) {
		let vnode = createVNode(possibleVNode.type, possibleVNode.props, possibleVNode.text, possibleVNode.key, null);
		vnode._dom = possibleVNode._dom;
		return vnode;
	}

	return possibleVNode;
}

对于 null / undefined / boolean值的节点直接返回 null
对于 string / number值的节点则返回包含其内容的文本节点;
对于数组类型的节点则返回一个 Fragment节点,其是对于多个子元素的聚合;
对于非第一次解析的节点则返回该节点的克隆。

总结

本文作为 Preact 源码解析的第一篇,简单介绍了 JSX 到 Preact 中 VNode 的转化,还未涉及其中真正核心的部分,算是一碟开胃小菜。
后续的文章中将呈现 diff 算法,组件等真正的重头戏。

参考资料

  1. Preact 官网
  2. Peact - Github
 类似资料: