React的源码多达几万行,对于我们想要快速阅读并看懂是相当有难度的,而Preact是一个轻量级的类react库,几千行代码就实现了react的大部分功能。因此阅读preact源码,对于我们学习react的思想并加强认识是非常有用的。
本文仓库github
下面是正文部分
源码结构
Preact导出的函数结构
import { h, h as createElement } from './h';
import { cloneElement } from './clone-element';
import { Component } from './component';
import { render } from './render';
import { rerender } from './render-queue';
import options from './options';
/**
* h函数和createElement函数是同一个函数
*
* */
export default {
h,
createElement,
cloneElement,
Component,
render,
rerender,
options
};
export {
h,
createElement,
cloneElement,
Component,
render,
rerender,
options
};
jsx是如何转化成virtualDOM的
jsx要转化成virtualDOM,首先经过babel,再经过h函数的调用形成virtualDOM。具体如下
源码链接 src/h.js
相当于react得createElement(),jsx经过babel转码后是h的循环调用,生成virtualDOM。
// jsx
<div>
<span className="sss" fpp="xxx">123</span>
<Hello/>
<span>xxx</span>
</div>
// h结果
h(
"div",
null,
h(
"span",
{ className: "sss", fpp: "xxx" },
"123"
),
h(Hello, null),
h(
"span",
null,
"xxx"
)
);
通过源码中h的函数定义也可以看见。h的函数第一个参数是标签名(如果是组件类型的化就是组件名)、第二个参数是属性值的key-value对象,后面的参数是所有子组件。
vnode的结构
h函数会根据子组件的不同类型进行封装,具体如下
- bool 返回 null
- null 返回 ""
- number 返回 String(number)
最后赋值给child变量并存进childdren数组中,再封装成下面的vnode结构并返回
{
nodeName:"div",//标签名
children:[],//子组件组成的数组,每一项也是一个vnode
key:"",//key
attributes:{}//jsx的属性
}
virtualDOM如何变为真实dom
// 一个简单的Preact demo
import { h, render, Component } from 'preact';
class Clock extends Component {
render() {
let time = new Date().toLocaleTimeString();
return <span>{ time }</span>;
}
}
render(<Clock />, document.body);
调用了preact的render方法将virtualDOM渲染到真实dom。
// render.js
import { diff } from './vdom/diff';
export function render(vnode, parent, merge) {
return diff(merge, vnode, {}, false, parent, false);
}
可见,render方法的第一个参数一个vnode,第二个参数是要挂载到的dom的节点,这里暂时不考虑第三个参数。而render方法实际上又是 去调用/vdom/diff.js下的diff方法
//diff函数的定义
export function diff(dom, vnode, context, mountAll, parent, componentRoot) {}
render函数使vnode转换成真实dom主要进行了以下操作
- render函数实际上调用了diff方法,diff方法进而调用了idiff。
- idiff方法会返回真实的html。idiff内将vnode分为4大类型进行处理封装在html
- 然后调用diffAttributes,将vnode上的属性值更新到html domnode的属性上。(通过setAccessor)
- 初次render时,下面if条件恒为真,所以真实html就这样被装进了。
if (parent && ret.parentNode !== parent) parent.appendChild(ret);
这样初次的vnode转化成真实html就完成了
流程图如下
tips:在diff中会见到很多的out[ATTR_KEY]
,这个是用来将dom的attributrs数组每一项的name value转化为键值对存进 out[ATTR_KEY]。
组件的buildComponentFromNode是怎样的?
buildComponentFromNode的定义
/** Apply the Component referenced by a VNode to the DOM.
* @param {Element} dom The DOM node to mutate
* @param {VNode} vnode A Component-referencing VNode
* @returns {Element} dom The created/mutated element
* @private
*/
export function buildComponentFromVNode(dom, vnode, context, mountAll) {}
初次调用时 buildComponentFromNode(undefined,vnode,{},false)。因此,初次render时的buildComponentFromVNode内部只是调用了如下的逻辑(不执行的代码去掉了)
export function buildComponentFromVNode(dom, vnode, context, mountAll) {
let c = dom && dom._component, // undefined
originalComponent = c,//undefined
oldDom = dom,// undefined
isDirectOwner = c && dom._componentConstructor===vnode.nodeName,//undefined
props = getNodeProps(vnode);// 这个函数除了一般的props获取外,还会加上defaultProps。
c = createComponent(vnode.nodeName, props, context);// 创建组件
setComponentProps(c, props, SYNC_RENDER, context, mountAll);
dom = c.base;
return dom;
}
紧接上节,Preact组件从vnode到真实html的过程发生了什么?
...
// buildComponentFromVNode方法内部
// buildComponentFromVNode(undefined, vnode, {}, false);
c = createComponent(vnode.nodeName, props, context);// 创建组件
setComponentProps(c, props, SYNC_RENDER, context, mountAll);
dom = c.base;
return dom;
....
从上节组件变成真实dom的过程中最重要的函数就是createComponent
和setComponentProps
。我们可以发现,在先后执行了createComponent
和setComponentProps
后,真实dom就是c.base了。那么
这个createComponent
干了什么?去掉一些初始渲染时不会去执行的代码,简化后的代码如下:
// 如果是用class定义的那种有生命周期的组件,上文代码中的```vnode.nodeName```其实就是我们定义的那个class。
export function createComponent(Ctor, props, context) {
let inst;
if (Ctor.prototype && Ctor.prototype.render) {
// 正常的组件 class xxx extends Component{} 定义的
//首先是对自己的组件实例化
inst = new Ctor(props, context);
//然后再在我们实例化的组件,去获得一些Preact的内置属性(props、state,这两个是挂在实例上的)和一些内置方法(setState、render之类的,这些方法是挂在原型上的)
Component.call(inst, props, context);
} else {
// 无状态组件
//无状态组件是没有定义render的,它的render方法就是这个无状态组件本身
inst = new Component(props, context);
inst.constructor = Ctor;
inst.render = doRender;
}
return inst;
}
function doRender(props, state, context) {
// 无状态组件的render方法就是自己本身
return this.constructor(props, context);
}
Component的定义如下。通过上面和下面的代码可以知道,createComponent
的主要作用就是让我们编写的class型和无状态型组件实例化,
这个实例是具有相似的结构。并供后面的setComponentProps
去使用产生真实dom。
// Component的定义
export function Component(props, context) {
this._dirty = true;// 这个东西先不管,应该是和diff有关
this.context = context;// context这个东西我也暂时不知道有什么用
this.props = props;
this.state = this.state || {};
}
// 这里的extend就是一个工具函数,把setState、forceUpdate、render方法挂载到原型上
extend(Component.prototype,{
setState(state,callback){},
forceUpdate(callback){},
render() {}
})
setComponentProps
产生真实dom的过程。
setComponentProps(c, props, SYNC_RENDER, {}, false);
export function setComponentProps(component, props, opts, context, mountAll) {
// 同理去除条件不成立的代码,只保留首次渲染时运行的关键步骤
if (!component.base || mountAll) {
// 可见。componentWillMount生命周期方法只会在未加载之前执行,
if (component.componentWillMount) component.componentWillMount();
}
renderComponent(component, SYNC_RENDER, mountAll);
}
由上面代码可见,setComponentProps
内部,实际上关键是调用了renderComponent
方法。renderComponent
逻辑有点绕,
精简版代码如下。
renderComponent
主要逻辑简单来说如下:
1、调用组件实例的render方法去产生vnode。
2、如果这个组件产生的vnode不再是组件了。则通过diff
函数去产生真实dom并挂载(前面已经分析过)diff(cbase, rendered, context, mountAll || !isUpdate, initialBase && initialBase.parentNode, true);
。
3、如果这个组件的子vnode还是子组件的话。则再次调用setComponentProps
、renderComponent
去进一步生成真实dom,直到2中条件成立。(判断步骤和2、3类似),但是有点区别的是。这种调用代码是
setComponentProps(inst, childProps, NO_RENDER, context, false);// 不渲染。只是去执行下生命周期方法,在这个setComponentProps内部是不调用 renderComponent的。 至于为啥。。暂时我也不知道。NO_RENDER标志位
renderComponent(inst, SYNC_RENDER, mountAll, true);
精简版代码
export function renderComponent(component, opts, mountAll, isChild) {
// 这个函数其实很长有点复杂的,只保留了初次渲染时执行的部分和关键的部分。
// 调用组件的render方法,返回vnode
rendered = component.render(props, state, context);//*****
let childComponent = rendered && rendered.nodeName,base;
if (typeof childComponent === 'function') {
// 子节点也是自定义组件的情况
let childProps = getNodeProps(rendered);
component._component = inst = createComponent(childComponent, childProps, context);
setComponentProps(inst, childProps, NO_RENDER, context, false);// 不渲染啊。只是去执行下生命周期方法
renderComponent(inst, SYNC_RENDER, mountAll, true);// 对比 renderComponent(component, SYNC_RENDER, mountAll);
} else {
base = diff(。。。);// 挂载
}
component.base = base; //把真实dom挂载到base属性上
if (!diffLevel && !isChild) flushMounts();
}
前面看到了componentWillMount
生命周期了,那么componentDidMount
这个生命周期呢?它就是在flushMounts
。这个if语句成立的条件是在祖先组件并且初次渲染时才执行(初次渲染的diffLevel值为0)。
export function flushMounts() {
let c;
while ((c = mounts.pop())) {
if (options.afterMount) options.afterMount(c);
if (c.componentDidMount) c.componentDidMount();
}
}
flushMounts中的mounts就是当前挂载的组件的实例。它是一个栈的结构并依次出栈执行componentDidMount。所以, 这就能说明了Preact(React也一样)父子组件的生命周期执行顺序了 parentWillMount -> parentRender -> childWillMount -> childRender -> childDidMount -> parentDidParent。
至此组件类型的vnode产生真实dom的分析就结束了。
流程图如下
setState发生了什么
setState(state, callback) {
let s = this.state;
if (!this.prevState) this.prevState = extend({}, s);
extend(s, typeof state==='function' ? state(s, this.props) : state);// 语句3
if (callback) (this._renderCallbacks = (this._renderCallbacks || [])).push(callback);
enqueueRender(this);
},
setState的定义如上,代码逻辑很容易看出
1、prevState若不存在,将要更新的state合并到prevState上
2、可以看出Preact中setState参数也是可以接收函数作为参数的。将要更新的state合并到当前的state
3、如果提供了回调函数,则将回调函数放进_renderCallbacks
队列
4、调用enqueueRender进行组件更新
why?我刚看到setState的第2、3行代码的时候也是一脸蒙蔽。为什么它要这样又搞一个this.prevState
又搞一个this.state
,又有个state
呢?WTF。
通过理清Preact的setState的执行原理。
应该是用于处理一个组件在一次流程中调用了两次setState的情况。
// 例如这里的handleClick是绑定click事件
handleClick = () =>{
// 注意,preact中setState后state的值是会马上更新的
this.setState({a:this.state.a+1});
console.log(this.state.a);
this.setState({a:this.state.a+1});
console.log(this.state.a);
}
基本上每一个学react的人,都知道上述代码函数在react中执行之后a的值只会加一,but!!!!在Preact中是加2的!!!!通过分析Preact的setState可以解释这个原因。 在上面的语句3,extend函数调用后,当前的state值已经改变了。但是即使state的值改变了,但是多次setState仍然是会只进行一次组件的更新(通过setTimeout把更新操作放在当前事件循环的最后),以最新的state为准。所以,这里的prevState应该是用于记录当前setState之前的上一次state的值,用于后面的diff计算。在enqueueRender执行diff时比较prevState和当前state的值
关于enqueueRender的相关定义
let items = [];
export function enqueueRender(component) {
// dirty 为true表明这个组件重新渲染
if (!component._dirty && (component._dirty = true) && items.push(component) == 1) {//语句1
// 只会执行一遍
(options.debounceRendering || defer)(rerender); // 相当于setTimeout render 语句2
}
}
export function rerender() {
let p, list = items;
items = [];
while ((p = list.pop())) {
if (p._dirty) renderComponent(p);
}
}
enqueueRender的逻辑主要是
1、语句1: 将调用了setState
的组件的_dirty
属性设置为false。通过这段代码我们还可以发现,
如果在一次流程中,调用了多次setState,rerender函数实际上还是只执行了一遍(通过判断component._dirty的值来保证一个组件内的多次setState只执行一遍rerender和判断items.push(component) == 1
确保如果存在父组件调用setState,然后它的子组件也调用了setState,还是只会执行一次rerender)。items队列是用来存放当前所有dirty组件。
2、语句2。可以看作是setTimeout
,将rerender
函数放在本次事件循环结束后执行。rerender
函数对所有的dirty组件执
行renderComponent
进行组件更新。
在renderComponent中将会执行的代码。只列出和初次渲染时有区别的主要部分
export function renderComponent(component, opts=undefined, mountAll=undefined, isChild=undefined) {
....
if (isUpdate) {
component.props = previousProps;
component.state = previousState;
component.context = previousContext;
if (opts !== FORCE_RENDER && // FORCE_RENDER是在调用组件的forceUpdate时设置的状态位
component.shouldComponentUpdate &&
component.shouldComponentUpdate(props, state, context) === false) {
skip = true;// 如果shouldComponentUpdate返回了false,设置skip标志为为true,后面的渲染部分将会被跳过
} else if (component.componentWillUpdate) {
component.componentWillUpdate(props, state, context);//执行componentWillUpdate生命周期函数
}
// 更新组件的props state context。因为componentWillUpdate里面有可能再次去修改它们的值
component.props = props;
component.state = state;
component.context = context;
}
....
component._dirty = false;
....
// 省略了diff渲染和dom更新部分代码
...
if (!skip) {
if (component.componentDidUpdate) {
//componentDidUpdate生命周期函数
component.componentDidUpdate(previousProps, previousState, previousContext);
}
}
if (component._renderCallbacks != null) {
// 执行setState的回调
while (component._renderCallbacks.length) component._renderCallbacks.pop().call(component);
}
}
逻辑看代码注释就很清晰了。先shouldComponentUpdate
生命周期,根据返回值决定是都否更新(通过skip标志位)。然后将组件的_dirty设置为true表明已经更新了该组件。然后diff组件更新,执行componentDidUpdate
生命周期,最后执行setState传进的callback。
流程图如下:
下一步,就是研究setState组件进行更新时的diff算法干了啥
非组件节点的diff分析
diff的流程,我们从简单到复杂进行分析
通过前面几篇文章的源码阅读,我们也大概清楚了diff函数参数的定义和component各参数的作用
/**
* @param dom 初次渲染是undefinde,第二次起是指当前vnode前一次渲染出的真实dom
* @param vnode vnode,需要和dom进行比较
* @param context 类似与react的react
* @param mountAll
* @param parent
* @param componentRoot
* **/
function diff(dom, vnode, context, mountAll, parent, componentRoot){}
// component
{
base,// dom
nextBase,//dom
_component,//vnode对应的组件
_parentComponent,// 父vnode对应的component
_ref,// props.ref
_key,// props.key
_disable,
prevContext,
context,
props,
prevProps,
state,
previousState
_dirty,// true表示该组件需要被更新
__preactattr_// 属性值
/***生命周期方法**/
.....
}
diff不同类型的vnode也是不同的。Preact的diff算法,是将setState后的vnode与前一次的dom进行比较的,边比较边更新。diff主要进行了两步操作(对于非文本节点来说),
先diff内容innerDiffNode(out, vchildren, context, mountAll, hydrating || props.dangerouslySetInnerHTML != null);
,再diff属性diffAttributes(out, vnode.attributes, props);
1、字符串或者布尔型
如果之前也是一个文本节点,则直接修改节点的nodeValue的值;否则,创建一个新节点,并取代旧节点。并调用recollectNodeTree
对旧的dom进行腊鸡回收。
2、html的标签类型
- 如果vnode的标签对比dom发生了改变(例如原来是span,后来是div),则新建一个div节点,然后把span的子元素都添加到新的div节点上,把新的div节点替换掉旧的span节点,然后回收旧的(回收节点的操作主要是把这个节点从dom中去掉,从vdom中也去掉)
if (!dom || !isNamedNode(dom, vnodeName)) {
// isNamedNode方法就是比较dom和vnode的标签类型是不是一样
out = createNode(vnodeName, isSvgMode);
if (dom) {
while (dom.firstChild) out.appendChild(dom.firstChild);
if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
recollectNodeTree(dom, true);//recollectNodeTree
}
}
-
对于子节点的diff
- Preact对于只含有一个的子字符串节点直接进行特殊处理
if (!hydrating && vchildren && vchildren.length === 1 && typeof vchildren[0] === 'string' && fc != null && fc.splitText !== undefined && fc.nextSibling == null) { if (fc.nodeValue != vchildren[0]) { fc.nodeValue = vchildren[0]; } }
- 对于一般情况
/****/ innerDiffNode(out, vchildren, context, mountAll, hydrating || props.dangerouslySetInnerHTML != null);
那么,
innerDiffNode
函数做了什么? 首先,先解释下函数内定义的一些关键变量到底干了啥let originalChildren = dom.childNodes,// 旧dom的子node集合 children = [],// 用来存储旧dom中,没有提供key属性的dom node keyed = {},// 用来存旧dom中有key的dom node,
首先,第一步的操作就是对旧的dom node进行分类。将含有key的node存进
keyed
变量有,这是一个键值对结构; 将无key的存进children
中,这是一个数组结构。然后,去循环遍历
vchildren
的每一项,用vchild
表示每一项。若有key属性,则取寻找keyed中是否有该key对应的真实dom;若无,则去遍历children 数据,寻找一个与其类型相同(例如都是div标签这样)的节点进行diff(用child这个变量去存储)。然后执行idiff函数child = idiff(child, vchild, context, mountAll);
。通过前面分析idiff
函数,我们知道如果传进idiff的child为空,则会新建一个节点。所以对于普通节点的内容的diff就完成了。然后把这个返回新的dom node去取代旧的就可以了,代码如下f = originalChildren[i]; if (child && child !== dom && child !== f) { if (f == null) { dom.appendChild(child); } else if (child === f.nextSibling) { removeNode(f); } else { dom.insertBefore(child, f); } }
当对vchildren遍历完成diff操作后,把
keyed
和children
中剩余的dom节点清除。因为他们在新的vnode结构中已经不存在了然后对于属性进行diff就可以了。
diffAttributes
的逻辑就比较简单了,取出新vnode 的 props和旧dom的props进行比较。新无旧有的去除,新有旧有的替代,新有旧无的添加。setAccessor
是对于属性值设置时一些保留字和特殊情况进行一层封装处理function diffAttributes(dom, attrs, old) { let name; for (name in old) { if (!(attrs && attrs[name] != null) && old[name] != null) { setAccessor(dom, name, old[name], old[name] = undefined, isSvgMode); } } for (name in attrs) { if (name !== 'children' && name !== 'innerHTML' && (!(name in old) || attrs[name] !== (name === 'value' || name === 'checked' ? dom[name] : old[name]))) { setAccessor(dom, name, old[name], old[name] = attrs[name], isSvgMode); } } }
至此,对于非组件节点的内容的diff完成了