首先欢迎大家关注我的掘金账号和Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。
之前分享过几篇关于React的文章:
其实我在阅读React源码的时候,真的非常痛苦。React的代码及其复杂、庞大,阅读起来挑战非常大,但是这却又挡不住我们的React的原理的好奇。前段时间有人就安利过Preact,千行代码就基本实现了React的绝大部分功能,相比于React动辄几万行的代码,Preact显得别样的简洁,这也就为了我们学习React开辟了另一条路。本系列文章将重点分析类似于React的这类框架是如何实现的,欢迎大家关注和讨论。如有不准确的地方,欢迎大家指正。
关于Preact,官网是这么介绍的:
Fast 3kb React alternative with the same ES6 API. Components & Virtual DOM.
我们用Preact编写代码就雷同于React,比如举个例子:
import { Component , h } from 'preact'
export default class TodoList extends Component {
state = { todos: [], text: '' };
setText = e => {
this.setState({ text: e.target.value });
};
addTodo = () => {
let { todos, text } = this.state;
todos = todos.concat({ text });
this.setState({ todos, text: '' });
};
render({ }, { todos, text }) {
return (
<form onSubmit={this.addTodo} action="javascript:">
<input value={text} onInput={this.setText} />
<button type="submit">Add</button>
<ul>
{ todos.map( todo => (
<li>{todo.text}</li>
)) }
</ul>
</form>
);
}
}
上面就是用Preact编写TodoList的例子,掌握React的你是不是感觉再熟悉不过了,上面的例子和React不太相同的地方是render
函数有参数传入,分别是render(props,state,context)
,其目的是为了你解构赋值方便,当然你仍然可以render
函数中通过this
来引用props
、state
和context
。语法方面我们不再多做赘述,现在正式开始我们的内容。
本人还是非常推崇React这一套机制的,React这套机制提我们完成了数据和视图的绑定,使得开发人员只需要关注数据和数据流的改变,从而极大的降低的开发的关注度,使得我们能够集中精力于数据本身。而且React引入了虚拟DOM(virtual-dom)的机制,从而提升渲染性能。在开始接触React时,觉得虚拟DOM机制十分的高大上,但经过一段时间的学习,开始对虚拟DOM有了进一步的认识。虚拟DOM从本质上将就是将复杂的DOM转化成轻量级的JavaScript对象,不同的渲染中会生成同的虚拟DOM对象,然后通过高效优化过的Diff算法,比较前后的虚拟DOM对象,以最小的变化去更新真实DOM。
正如上面的图,其实类React的框架的代码都基本可以分为两部分,组件到虚拟DOM的转化、以及虚拟DOM到真实DOM的映射。当然细节性的东西还有非常多,比如生命周期、事件机制(代理)、批量刷新等等。其实Preact精简了React中的很多部分,比如React中采用的是事件代理机制,Preact就没这么做。这篇文章将着重于叙述Preact的JSX与组件相关的部分代码。
最开始学习React的时候,以为JSX是React的所独有的,现在其实明白了JSX语法并不是某个库所独有的,而是一种JavaScript函数调用的语法糖。我们举个例子,假如有下面的代码:
import ReactDOM from 'react-dom'
const App = (props)=>(<div>Hello World</div>)
ReactDOM.render(<APP />, document.body);
请问可以执行吗?事实上是不能只能的,浏览器会告诉你:
Uncaught ReferenceError: React is not defined
如果你不了解JSX你就会感觉奇怪,因为没有地方显式地调用React,但是事实上上面的代码确实用到了React模块,奥秘就在于JSX。JSX其实相当于JavaScript + HTML(也被称为hyperscript,即hyper + script,hyper是HyperText超文本的简写,而script是JavaScript的简写)。JSX并不属于新的语法,其目的也只是为了在JavaScript脚本中更方便的构建UI视图,相比于其他的模板语言更加的易于上手,提升开发效率。上面的实例如果经过Babel转化其实会得到下面结果:
var App = function App(props) {
return React.createElement(
'div',
null,
'Hello World'
);
};
我们可以看到,之前的JSX语法都被转换成函数React.createElement
的调用方式。这就是为什么在React中有JSX的地方都需要显式地引入React的原因,也是为什么说JSX只是JavaScript的语法糖。但是按照上面的说法,所有的JSX语法都会被转化成React.createElement
,那岂不是JSX只是React所独有的?当然不是,比如下面代码:
/** @jsx h */
let foo = <div id="foo">Hello!</div>;
我们通过为JSX添加注释@jsx
(这也被成为Pragma,即编译注释),可以使得Babel在转化JSX代码时,将其装换成函数h
的调用,转化结果成为:
/** @jsx h */
var foo = h(
"div",
{ id: "foo" },
"Hello!"
);
当然在每个JSX上都设置Pragma是没有必要的,我们可以在工程全局进行配置,比如我们可以在Babel6中的.babelrc
文件中设置:
{
"plugins": [
["transform-react-jsx", { "pragma":"h" }]
]
}
这样工程中所有用到JSX的地方都是被Babel转化成使用h
函数的调用。
说了这么多,我们开始了解一下Preact是怎么构造h
函数的(关于为什么Preact将其称为h
函数,是因为作为hyperscript
的缩写去命名的),Preact对外提供两个接口: h
与createElement
,都是指向函数h
:
import {VNode} from './vnode';
const stack = [];
const EMPTY_CHILDREN = [];
export function h(nodeName, attributes) {
let children = EMPTY_CHILDREN, lastSimple, child, simple, i;
for (i = arguments.length; i-- > 2;) {
stack.push(arguments[i]);
}
if (attributes && attributes.children != null) {
if (!stack.length) stack.push(attributes.children);
delete attributes.children;
}
while (stack.length) {
if ((child = stack.pop()) && child.pop !== undefined) {
for (i = child.length; i--;) stack.push(child[i]);
}
else {
if (typeof child === 'boolean') child = null;
if ((simple = typeof nodeName !== 'function')) {
if (child == null) child = '';
else if (typeof child === 'number') child = String(child);
else if (typeof child !== 'string') simple = false;
}
if (simple && lastSimple) {
children[children.length - 1] += child;
}
else if (children === EMPTY_CHILDREN) {
children = [child];
}
else {
children.push(child);
}
lastSimple = simple;
}
}
let p = new VNode();
p.nodeName = nodeName;
p.children = children;
p.attributes = attributes == null ? undefined : attributes;
p.key = attributes == null ? undefined : attributes.key;
return p;
}
函数h
接受两个参数节点名nodeName
,与属性attributes
。然后将除了前两个之外的参数都压如栈stack。这种写法挺令人吐槽的,写成h(nodeName, attributes, ...children)
不是一目了然吗?因为h
的参数是不限的,从第三个参数起的所有参数都是节点的子元素,所以栈存储的是当前元素的子元素。然后会再排除一下第二个参数(其实就是props
)中是否含有children
属性,有的话也将其压如栈中,并且从attributes
中删除。然后循环遍历栈中的每一个子元素:
- 首先判别该元素是不是数组类型,这里采用的就是鸭子类型(duck type),即看起来来一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子,我们在这里通过是否含有函数
pop
去判别是否是一个数组,如果子元素是一个数组,就将其全部压入栈中。为什么这么做呢?因为子元素有可能是数组,比如:
render(){
return(
<ul>
{
[1,2,3].map((val)=><li>{val}</li>)
}
</ul>
)
}
- 因为子元素是不支持布尔类型的,因此将其置为:
null
。 如果传入的节点不是函数的话,分别判断如果是null
,则置为空字符,如果是数字的话,将其转化成字符串类型。变量simple
用来记录节点是否是简单类型,比如dom
名称或者函数就不属于,如果是字符串或者是数字,就会被认为是简单类型 - 然后代码
if (simple && lastSimple) {
children[children.length - 1] += child;
}
其实做的就是一个字符串拼接,lastSimple是用来记录上次的节点是否是简单类型。之所以这么做,是因为某些编译器会将下面代码
let foo = <div id="foo">Hello World!</div>;
转化为:
var foo = h(
"div",
{ id: "foo" },
"Hello",
"World!"
);
这是时候h
函数就会将后两个参数拼接成一个字符串。
- 最后将处理子节点的传入数组
children
中,现在传入children
中的节点有三种类型: 纯字符串、代表dom
节点的字符串以及代表组件的函数(或者是类)
函数结束循环遍历之后,创建了一个VNODE
,并将nodeName
、children
、attributes
、key
都赋值到节点中。需要注意的是,VNODE
只是一个普通的构造函数:
function VNode() {}
说了这么多,我们看几个转化之后的例子:
//jsx
let foo = <div id="foo">Hello World!</div>;
//js
var Element = h(
"div",
{ id: "foo" },
"Hello World!"
);
//转化为的元素节点
{
nodeName: "div",
children: [
"Hello World!"
],
attributes: {
id: "foo"
},
key: undefined
}
/* jsx
class App extends Component{
//....
}
class Child extends Component{
//....
}
*/
let Element = <App><Child>Hello World!</Child></App>
//js
var Element = h(
App,
null,
h(
Child,
null,
"Hello World!"
)
);
//转化为的元素节点
{
nodeName: ƒ App(argument),
children: [
{
nodeName: ƒ Child(argument),
children: ["Hello World!"],
attributes: undefined,
key: undefined
}
],
attributes: undefined,
key: undefined
}
上面JSX元素转化成的JavaScript对象就是DOM在内存中的表现。在Preact中不同的数据会生成不同的虚拟DOM节点,通过比较前后的虚拟DOM节点,Preact会找出一种最简单的方式去更新真实DOM,以使其匹配当前的虚拟DOM节点,当然这会在后面的系列文章讲到,我们会将源码和概念分割成一块块内容,方便大家理解,这篇文章着重讲述了Preact的元素创建与JSX,之后的文章会继续围绕Preact类似于diff、组件设计等概念展开,欢迎大家关注我的账号获得最新的文章动态。