Web开发编程网
分享Web开发相关技术

从Preact了解一个类React的框架是怎么实现的(三): 组件

前言

首先欢迎大家关注我的掘金账号和Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。
之前分享过几篇关于React的文章:

其实我在阅读React源码的时候,真的非常痛苦。React的代码及其复杂、庞大,阅读起来挑战非常大,但是这却又挡不住我们的React的原理的好奇。前段时间有人就安利过Preact,千行代码就基本实现了React的绝大部分功能,相比于React动辄几万行的代码,Preact显得别样的简洁,这也就为了我们学习React开辟了另一条路。本系列文章将重点分析类似于React的这类框架是如何实现的,欢迎大家关注和讨论。如有不准确的地方,欢迎大家指正。

在前两篇文章

我们分别了解了Preact中元素创建以及diff算法,其中就讲到了组件相关一部分内容。对于一个类React库,组件(Component)可能是最需要着重分析的部分,因为编写类React程序的过程中,我们几乎都是在写一个个组件(Component)并将其组合起来形成我们所需要的应用。下面我们就从头开始了解一下Preact中的组件是怎么实现的。

组件渲染

首先我们来了解组件返回的虚拟dom是怎么渲染为真实dom,来看一下Preact的组件是如何构造的:

//component.js
function Component(props, context) {
	this._dirty = true;

	this.context = context;

	this.props = props;

	this.state = this.state || {};
}

extend(Component.prototype, {

	setState(state, callback) {
		//......
	},

	forceUpdate(callback) {
		//......
	},

	render() {}

});

可能我们会想当然地认为组件Component的构造函数定义将会及其复杂,事实上恰恰相反,Preact的组件定义代码极少。组件的实例属性仅仅有四个:

  • _dirty: 用来表示存在脏数据(即数据与存在的对应渲染不一致),例如多次在组件实例调用setState,使得_dirtytrue,但因为该属性的存在,只会使得组件仅有一次才会被放入更新队列。
  • context: 组件的context属性
  • props: 组件的props属性
  • state: 组件的state属性

通过extends方法(原理类似于ES6中的Object.assign或者Underscore.js中的_.extends),我们给组件的构造函数的原型中创建一下几个方法:

  • setState: 与React的setState相同,用来更新组件的state
  • forceUpdate: 与React的forceUpdate相同,立刻同步重新渲染组件
  • render: 返回组件的渲染内容的虚拟dom,此处函数体为空

所以当我们编写组件(Component)类继承preact.Component时,也就仅仅只能继承上述的方法和属性,这样所以对于用户而言,不仅提供了及其简洁的API以供使用,而且最重要的是我们将组件内部的逻辑封装起来,与用户相隔离,避免用户无意间修改了组件的内部实现,造成不必要的错误。

对于阅读过从Preact了解一个类React的框架是怎么实现的(二): 元素diff的同学应该还记的preact所提供的render函数调用了内部的diff函数,而diff实际会调用idiff函数(更详细的可以阅读第二篇文章):

从上面的图中可以看到,在idiff函数内部中在开始如果vnode.nodeName是函数(function)类型,则会调用函数buildComponentFromVNode:

function buildComponentFromVNode(dom, vnode, context, mountAll) {
    //block-1
	let c = dom && dom._component,
		originalComponent = c,
		oldDom = dom,
		isDirectOwner = c && dom._componentConstructor===vnode.nodeName,
		isOwner = isDirectOwner,
		props = getNodeProps(vnode);
	//block-2	
	while (c && !isOwner && (c=c._parentComponent)) {
		isOwner = c.constructor===vnode.nodeName;
	}
    //block-3
	if (c && isOwner && (!mountAll || c._component)) {
		setComponentProps(c, props, ASYNC_RENDER, context, mountAll);
		dom = c.base;
	}
	else {
	//block-4
		if (originalComponent && !isDirectOwner) {
			unmountComponent(originalComponent);
			dom = oldDom = null;
		}

		c = createComponent(vnode.nodeName, props, context);
		if (dom && !c.nextBase) {
			c.nextBase = dom;
			oldDom = null;
		}
		setComponentProps(c, props, SYNC_RENDER, context, mountAll);
		dom = c.base;
		
		if (oldDom && dom!==oldDom) {
			oldDom._component = null;
			recollectNodeTree(oldDom, false);
		}
	}
	return dom;
}

函数buildComponentFromVNode的作用就是将表示组件的虚拟dom(VNode)转化成真实dom。参数分别是:

  • dom: 组件对应的真实dom节点
  • vnode: 组件的虚拟dom节点
  • context: 组件的中的context属性
  • mountAll: 表示组件的内容需要重新渲染而不是基于上一次渲染内容进行修改。

为了方便分析,我们将函数分解成几个部分,依次分析:

  • 第一段代码: dom是组件对应的真实dom节点(如果未渲染,则为undefined),在dom节点中的_component属性是组件实例的缓存。isDirectOwner用来指示用来标识原dom节点对应的组件类型是否与当前虚拟dom的组件类型相同。然后使用函数getNodeProps来获取虚拟dom节点的属性值。
getNodeProps(vnode) {
	let props = extend({}, vnode.attributes);
	props.children = vnode.children;

	let defaultProps = vnode.nodeName.defaultProps;
	if (defaultProps!==undefined) {
		for (let i in defaultProps) {
			if (props[i]===undefined) {
				props[i] = defaultProps[i];
			}
		}
	}
	
	return props;
}

函数getNodeProps的逻辑并不复杂,将vnodeattributeschidlren的属性赋值到props,然后如果存在组件中存在defaultProps的话,将defaultProps存在的属性并且对应props不存在的属性赋值进入了props中,并将props返回。

  • 第二段代码: 如果当前的dom节点对应的组件类型与当前虚拟dom对应的组件类型不一致时,会向上在父组件中查找到与虚拟dom节点类型相同的组件实例(但也有可能不存在)。其实这个只是针对于高阶组件,假设有高阶组件的顺序:
HOC  => component => DOM元素

上面HOC代表高阶组件,返回组件component,然后组件component渲染DOM元素。在Preact,这种高阶组件与返回的子组件之间存在属性标识,即HOC的组件实例中的_component指向compoent的组件实例而组件component实例的_parentComponent属性指向HOC实例。我们知道,DOM中的属性_component指向的是对应的组件实例,需要注意的是在上面的例子中DOM对应的_component指向的是HOC实例,而不是component实例。如果理解了上面的部分,就能理解为什么会存在这个循环了,其目的就是为了找到最开始渲染该DOM的高阶组件(防止某些情况下dom对应的_component属性指代的实例被修改),然后再判断该高阶组件是否与当前的vnode类型一致。

  • 第三段代码: 如果存在当前虚拟dom对应的组件实例存在,则直接调用函数setComponentProps,相当于基于组件的实例进行修改渲染,然后组件实例中的base属性即为最新的dom节点。
  • 第四段代码: 我们先不具体关心某个函数的具体实现细节,只关注代码逻辑。首先如果之前的dom节点对应存在组件,并且虚拟dom对应的组件类型与其不相同时,则卸载之前的组件(unmountComponent)。接着我们通过调用函数createComponent创建当前虚拟dom对应的组件实例,然后调用函数setComponentProps去创建组件实例的dom节点,最后如果当前的dom与之前的dom元素不相同时,将之前的dom回收(recollectNodeTree函数在diff的文章中已经介绍)。

其实如果之前就阅读过Preact的diff算法的同学来说,其实整个组件大致渲染的流程我们已经清楚了,但是如果想要更深层次的了解其中的细节我们必须去深究函数createComponentsetComponentProps的内部细节。

createComponent

关于函数createComponent,我们看一下component-recycler.js文件:

import { Component } from '../component';

const components = {};

export function collectComponent(component) {
	let name = component.constructor.name;
	(components[name] || (components[name] = [])).push(component);
}

export function createComponent(Ctor, props, context) {
	let list = components[Ctor.name],
		inst;

	if (Ctor.prototype && Ctor.prototype.render) {
		inst = new Ctor(props, context);
		Component.call(inst, props, context);
	}
	else {
		inst = new Component(props, context);
		inst.constructor = Ctor;
		inst.render = doRender;
	}

	if (list) {
		for (let i=list.length; i--; ) {
			if (list[i].constructor===Ctor) {
				inst.nextBase = list[i].nextBase;
				list.splice(i, 1);
				break;
			}
		}
	}
	return inst;
}

function doRender(props, state, context) {
	return this.constructor(props, context);
}

变量components的主要作用就是为了能重用组件渲染的内容而设置的共享池(Share Pool),通过函数collectComponent就可以实现回收一个组件以供以后重复利用。在函数collectComponent中通过组件名(component.constructor.name)分类将可重用的组件缓存在缓存池中。

函数createComponent主要作用就是创建组件实例。参数propscontext分别对应的是组件的中属性和context(与React一致),而Ctor组件则是需要创建的组件类型(函数或者是类)。我们知道如果我们的组件定义用ES6定义如下:

class App extends Component{}

我们知道class仅仅只是一个语法糖,上面的代码使用ES5去实现相当于:

function App(){}
App.prototype = Object.create(Component.prototype, {
    constructor: {
        value: App,
        enumerable: true,
        writable: true,
        configurable: true
    }
});

如果你对ES5中的Object.create也不熟悉的话,我简要的介绍一下,Object.create的作用就是实现原型继承(Prototypal Inheritance)来实现基于已有对象创建新对象。Object.create的第一个参数就是所要继承的原型对象,第二个参数就是新对象定义额外属性的对象(类似于Object.defineProperty的参数),如果要我自己实现一个简单的Object.create函数我们可以这样写:

function create(prototype, ...obj){
    function F(){}
    F.prototype = prototype;
    return Object.defineProperties(new F(), ...obj);
}

现在你肯定知道了如果你的组件继承了Preact中的Component的话,在原型中一定存在render方法,这时候通过new创建Ctor的实例inst(实例中已经含有了你自定义的render函数),但是如果没有给父级构造函数super传入propscontext,那么inst中的propscontext的属性为undefined,通过强制调用Component.call(inst, props, context)可以给instpropscontext进行初始化赋值。

如果组件中不存在render函数,说明该函数是PFC(Pure Function Component)类型,即是纯函数组件。这时直接调用函数Component创建实例,实例的constructor属性设置为传入的函数。由于实例中不存在render函数,则将doRender函数作为实例的render属性,doRender函数会将Ctor的返回的虚拟dom作为结果返回。

然后我们从组件回收的共享池中那拿到同类型组件的实例,从其中取出该实例之前渲染的实例(nextBase),然后将其赋值到我们的新创建组件实例的nextBase属性上,其目的就是为了能基于此DOM元素进行渲染,以更少的代价进行相关的渲染。

setComponentProps

function setComponentProps(component, props, opts, context, mountAll) {
	if (component._disable) return;
	component._disable = true;

	if ((component.__ref = props.ref)) delete props.ref;
	if ((component.__key = props.key)) delete props.key;

	if (!component.base || mountAll) {
		if (component.componentWillMount) component.componentWillMount();
	}
	else if (component.componentWillReceiveProps) {
		component.componentWillReceiveProps(props, context);
	}

	if (context && context!==component.context) {
		if (!component.prevContext) component.prevContext = component.context;
		component.context = context;
	}

	if (!component.prevProps) component.prevProps = component.props;
	component.props = props;

	component._disable = false;

	if (opts!==NO_RENDER) {
		if (opts===SYNC_RENDER || !component.base) {
			renderComponent(component, SYNC_RENDER, mountAll);
		}
		else {
			enqueueRender(component);
		}
	}

	if (component.__ref) component.__ref(component);
}

函数setComponentProps的主要作用就是为组件实例设置属性(props),其中props通常来源于JSX中的属性(attributes)。函数的参数componentpropscontextmountAll的含义从名字就可以看出来,值得注意地是参数opts,代表的是不同的刷新模式:

  • NO_RENDER: 不进行渲染
  • SYNC_RENDER: 同步渲染
  • FORCE_RENDER: 强制刷新渲染
  • ASYNC_RENDER: 异步渲染

首先如果组件component_disable属性为true时则直接退出,否则将属性_disable置为true,其目的相当于一个,保证修改过程的原子性。如果传入组件的属性props中存在refkey,则将其分别缓存在组件的__ref__key,并将其从props将其删除。

组件实例中的base中存放的是之前组件实例对应的真实dom节点,如果不存在该属性,说明是该组件的初次渲染,如果组件中定义了生命周期函数(钩子函数)componentWillMount,则在此处执行。如果不是首次执行,如果存在生命周期函数componentWillReceiveProps,则需要将最新的propscontext作为参数调用componentWillReceiveProps。然后分别将当前的属性contextprops缓存在组件的preContextprevProps属性中,并将contextprops属性更新为最新的contextprops。最后将组件的_disable属性置回false

如果组件更新的模式为NO_RENDER,则不需要进行渲染。如果是同步渲染(SYNC_RENDER)或者是首次渲染(base属性为空),则执行函数renderComponent,其余情况下(例如setState触发的异步渲染ASYNC_RENDER)均执行函数enqueueRender(enqueueRender函数将在setState处分析)。在函数的最后,如果存在ref函数,则将组件实例作为参数调用ref函数。在这里我们可以显然可以看出在Preact中是不支持React的中字符串类型的ref属性,不过这个也并不重要,因为React本身也不推荐使用字符串类型的ref属性,并表示可能会在将来版本中废除这一属性。

接下来我们还需要了解renderComponent函数(非常冗长)与enqueueRender函数的作用:

renderComponent

renderComponent(component, opts, mountAll, isChild) {
	if (component._disable) return;

	let props = component.props,
		state = component.state,
		context = component.context,
		previousProps = component.prevProps || props,
		previousState = component.prevState || state,
		previousContext = component.prevContext || context,
		isUpdate = component.base,
		nextBase = component.nextBase,
		initialBase = isUpdate || nextBase,
		initialChildComponent = component._component,
		skip = false,
		rendered, inst, cbase;
    // block-1
	if (isUpdate) {
		component.props = previousProps;
		component.state = previousState;
		component.context = previousContext;
		if (opts!==FORCE_RENDER
			&& component.shouldComponentUpdate
			&& component.shouldComponentUpdate(props, state, context) === false) {
			skip = true;
		}
		else if (component.componentWillUpdate) {
			component.componentWillUpdate(props, state, context);
		}
		component.props = props;
		component.state = state;
		component.context = context;
	}

	component.prevProps = component.prevState = component.prevContext = component.nextBase = null;
	component._dirty = false;
	if (!skip) {
	    // block-2
		rendered = component.render(props, state, context);

		if (component.getChildContext) {
			context = extend(extend({}, context), component.getChildContext());
		}
   
		let childComponent = rendered && rendered.nodeName,
			toUnmount, base;
        //block-3
		if (typeof childComponent==='function') {
			let childProps = getNodeProps(rendered);
			inst = initialChildComponent;

			if (inst && inst.constructor===childComponent && childProps.key==inst.__key) {
				setComponentProps(inst, childProps, SYNC_RENDER, context, false);
			}
			else {
				toUnmount = inst;

				component._component = inst = createComponent(childComponent, childProps, context);
				inst.nextBase = inst.nextBase || nextBase;
				inst._parentComponent = component;
				setComponentProps(inst, childProps, NO_RENDER, context, false);
				renderComponent(inst, SYNC_RENDER, mountAll, true);
			}

			base = inst.base;
		}
		else {
		//block-4
			cbase = initialBase;

			toUnmount = initialChildComponent;
			if (toUnmount) {
				cbase = component._component = null;
			}

			if (initialBase || opts===SYNC_RENDER) {
				if (cbase) cbase._component = null;
				base = diff(cbase, rendered, context, mountAll || !isUpdate, initialBase && initialBase.parentNode, true);
			}
		}
        // block-5
		if (initialBase && base!==initialBase && inst!==initialChildComponent) {
			let baseParent = initialBase.parentNode;
			if (baseParent && base!==baseParent) {
				baseParent.replaceChild(base, initialBase);

				if (!toUnmount) {
					initialBase._component = null;
					recollectNodeTree(initialBase, false);
				}
			}
		}

		if (toUnmount) {
			unmountComponent(toUnmount);
		}
        
        //block-6
		component.base = base;
		if (base && !isChild) {
			let componentRef = component,
				t = component;
			while ((t=t._parentComponent)) {
				(componentRef = t).base = base;
			}
			base._component = componentRef;
			base._componentConstructor = componentRef.constructor;
		}
	}
    //block-7
	if (!isUpdate || mountAll) {
		mounts.unshift(component);
	}
	else if (!skip) {

		if (component.componentDidUpdate) {
			component.componentDidUpdate(previousProps, previousState, previousContext);
		}
	}

	if (component._renderCallbacks!=null) {
		while (component._renderCallbacks.length) component._renderCallbacks.pop().call(component);
	}
	//block-8
	if (!diffLevel && !isChild) flushMounts();
}

为了方便阅读,我们将代码分成了八个部分,不过为了更方便的阅读代码,我们首先看一下函数开始处的变量声明:

所要渲染的component实例中的propscontextstate属性表示的是最新的所要渲染的组件实例属性。而对应的prePropspreContextpreState代表的是渲染之前上一个状态组件实例属性。变量isUpdate代表的是当前是处于组件更新的过程还是组件渲染的过程(mount),我们通过之前组件实例是否对应存在真实DOM节点来判断,如果存在则认为是更新的过程,否则认为是渲染(mount)过程。nextBase表示可以基于此DOM元素进行修改(可能来源于上一次渲染或者是回收之前同类型的组件实例),以寻求最小的渲染代价。
组件实例中的_component属性表示的组件的子组件,仅仅只有当组件返回的是组件时(也就是当前组件为高阶组件),才会存在。变量skip用来标志是否需要跳过更新的过程(例如: 生命周期函数shouldComponentUpdate返回false)。

  • 第一段代码: 如果存在component.base存在,说明该组件之前对应的真实dom元素,说明组件处于更新的过程。要将propsstatecontext替换成之前的previousPropspreviousStatepreviousContext,这是因为在生命周期函数shouldComponentUpdatecomponentWillUpdate中的this.propsthis.statethis.context仍然是更新前的状态。如果不是强制刷新(FORCE_RENDER)并存在生命周期函数shouldComponentUpdate,则以最新的propsstatecontext作为参数执行shouldComponentUpdate,如果返回的结果为false表明要跳过此次的刷新过程,即置标志位skip为true。否则如果生命周期shouldComponentUpdate返回的不是false(说明如果不返回值或者其他非false的值,都会执行更新),则查看生命周期函数componentWillUpdate是否存在,存在则执行。最后则将组件实例的propsstatecontext替换成最新的状态,并置空组件实例中的prevPropsprevStateprevContext的属性,以及将_dirty属性置为false。需要注意的是只有_dirtyfalse才会被放入更新队列,然后_dirty会被置为true,这样组件实例就不会被多次放入更新队列。
  • 如果没有跳过更新的过程(即skipfalse),则执行到第二段代码。首先执行组件实例的render函数(相比于React中的render函数,Preact中的render函数执行时传入了参数propsstatecontext),执行render函数的返回值rendered则是组件实例对应的虚拟dom元素(VNode)。如果组件存在函数getChildContext,则生成当前需要传递给子组件的context。我们从代码extend(extend({}, context), component.getChildContext())可以看出,如果父组件存在某个context属性并且当前组件实例中getChildContext函数返回的context也存在相同的属性时,那么当前组件实例getChildContext返回的context中的属性会覆盖父组件的context中的相同属性。
  • 接下来到第三段代码,childComponent是组件实例render函数返回的虚拟dom的类型(rendered.nodeName),如果childComponent的类型为函数,说明该组件为高阶组件(High Order Component),如果你不了解高阶组件,可以戳这篇文章。如果是高阶组件的情况下,首先通过getNodeProps函数获得虚拟dom中子组件的属性。如果组件存在子组件的实例并且子组件实例的构造函数与当前组件返回的子组件虚拟dom类型相同(inst.constructor===childComponent)而且前后的key值相同时(childProps.key==inst.__key),仅需要以同步渲染(SYNC_RENDER)的模式递归调用函数setComponentProps来更新子组件的属性props。之所以这样是因为如果满足前面的条件说明,前后两次渲染的子组件对应的实例不发生改变,仅改变传入子组件的参数(props)。这时子组件仅需要根据当前最新的props对应渲染真实dom即可。否则如果之前的子组件实例的构造函数与当前组件返回的子组件虚拟dom类型不相同时或者根据key值标定两个组件实例不相同时,则需要渲染的新的子组件,不仅需要调用createComponent创建子组件的实例(createComponent(childComponent, childProps, context))并为当前的子组件和组件设置相关关系(即_component_parentComponent属性)而且用toUnmount指示待卸载的组件实例。然后通过调用setComponentProps来设置组件的refkey等,以及调用组件的相关生命周期函数(例如:componentWillMount),需要注意的是这里的调用模式是NO_RENDER,不会进行渲染。而在下一句调用renderComponent(inst, SYNC_RENDER, mountAll, true)去同步地渲染子组件。所以我们就要注意为什么在调用函数setComponentProps时没有采用SYNC_RENDER模式,SYNC_RENDER模式也本身就会触发renderComponent去渲染组件,其原因就是为了在调用renerComponent赋予isChild值为true,这个标志量的作用我们后面可以看到。调用完renderComponent之后,inst.base中已经是我们子组件渲染的真实dom节点。
  • 在第四段代码中,处理的是当前组件需要渲染的虚拟dom类型是非组件类型(即普通的DOM元素)。首先赋值cbase = initialBase,我们知道initialBase来自于initialBase = isUpdate || nextBase,也就是说如果当前是更新的模式,则initialBase等于isUpdate,即为上次组件渲染的内容。否则,如果组件实例存在nextBase(从回收池得到的DOM结构),也可以基于其进行修改,总的目的是为了以更少的代价去渲染。如果之前的组件渲染的是函数类型的元素(即组件),但现在却渲染的是非函数类型的,赋值toUnmount = initialChildComponent,用来存储之后需要卸载的组件,并且由于cbase对应的是之前的组件的dom节点,因此就无法使用了,需要赋值cbase = null以使得重新渲染。而component._component = null目的就是切断之前组件间的父子关系,毕竟现在返回的都不是组件。如果是同步渲染(SYNC_RENDER),则会通过调用idiff函数去渲染组件返回的虚拟dom(详情见第二篇文章diff)。我们来看看调用idiff函数的形参和实参:
  1. cbase对应的是diffdom参数,表示用来渲染的VNode之前的真实dom。可以看到如果之前是组件类型,那么cbase值为undefined,我们就需要重新开始渲染。否则我们就可以在之前的渲染基础上更新以寻求最小的更新代价。
  2. rendered对应diff中的vnode参数,表示需要渲染的虚拟dom节点。
  3. context对应diff中的context参数,表示组件的context属性。
  4. mountAll || !isUpdate对应的是diff中的mountAll参数,表示是否是重新渲染DOM节点而不是基于之前的DOM修改,!isUpdate表示的就是非更新状态。
  5. initialBase && initialBase.parentNode对应的是diff中的parent参数,表示的是当前渲染节点的父级节点。
  6. diff函数的第六个参数为componentRoot,实参为true表示的是当前diff是以组件中render函数的渲染内容的形式调用,也可以说当前的渲染内容是属于组件类型的。

我们知道idiff函数返回的是虚拟dom对应渲染后的真实dom节点,所以变量base存储的就是本次组件渲染的真实DOM元素。

  • 代码第五部分: 如果组件前后返回的虚拟dom节点对应的真实DOM节点不相同,或者前后返回的虚拟DOM节点对应的前后组件实例不一致时,则在父级的DOM元素中将之前的DOM节点替换成当前对应渲染的DOM节点(baseParent.replaceChild(base, initialBase)),如果没有需要卸载的组件实例,则调用函数recollectNodeTree回收该DOM节点。否则如果之前组件渲染的是函数类型的元素,但需要废弃,则调用函数unmountComponent进行卸载(调用相关的生命周期函数)。
function unmountComponent(component) {
	let base = component.base;
	component._disable = true;

	if (component.componentWillUnmount) component.componentWillUnmount();

	component.base = null;

	let inner = component._component;
	if (inner) {
		unmountComponent(inner);
	}
	else if (base) {
		if (base[ATTR_KEY] && base[ATTR_KEY].ref) base[ATTR_KEY].ref(null);
		component.nextBase = base;
		removeNode(base);
		collectComponent(component);
		removeChildren(base);
	}
	if (component.__ref) component.__ref(null);
}

来看unmountComponent函数的作用,首先将函数实例中的_disable置为true表示组件禁用,如果组件存在生命周期函数componentWillUnmount进行调用。然后递归调用函数unmountComponent递归卸载组件。如果之前组件渲染的DOM节点,并且最外层节点存在ref函数,则以参数null执行(和React保持一致,ref函数会执行两次,第一次是mount会以DOM元素或者组件实例回调,第二次是unmount会回调null表示卸载)。然后将DOM元素存入nextBase用以回收。调用removeNode函数作用是将base节点的父节点脱离出来。函数removeChildren的目的是用递归遍历所有的子DOM元素,回收节点(之前的文章已经介绍过,其中就涉及到子元素的ref调用)。最后如果组件本身存在ref属性,则直接以null为参数调用。

  • 代码第六部分:component.base = base用来将当前的组件渲染的dom元素存储在组件实例的base属性中。下面的代码我们先举个例子,假如有如下的结构:
HOC1 => HOC2 => component => DOM元素

其中HOC代表高阶组件,component代表自定义组件。你会发现HOC1HOC2compoentbase属性都指向最后的DOM元素,而DOM元素的中的_component是指向HOC1的组价实例的。看懂了这个你就能明白为什么会存在下面这个循环语句,其目的就是为了给父组件赋值正确的base属性以及为DOM节点的_component属性赋值正确的组件实例。

  • 在第七段代码中,如果是非更新模式,则需要将当前组件存入mounts(unshift方法存入,pop方法取出,实质上是相当于队列的方式,并且子组件先于父组件存储队列mounts,因此可以保证正确的调用顺序),方便在后期调用组件对应类似于componentDidMount生命周期函数和其他的操作。如果没有跳过更新过程(skip === false),则在此时调用组件对应的生命周期函数componentDidUpdate。然后如果存在组件存在_renderCallbacks属性(存储对应的setState的回调函数,因为setState函数实质也是通过renderComponent实现的),则在此处将其弹出并执行。
  • 在第八段代码中,如果diffLevel0并且isChildfalse时,对应执行flushMounts函数
function flushMounts() {
	let c;
	while ((c=mounts.pop())) {
		if (c.componentDidMount) c.componentDidMount();
	}
}

其实flushMounts也是非常的简单,就是将队列mounts中取出组件实例,然后如果存在生命周期函数componentDidMount,则对应执行。

其实如果阅读了之前diff的文章的同学应该记得在diff函数中有:

function diff(dom, vnode, context, mountAll, parent, componentRoot) {
    //......
	if (!--diffLevel) {
	    // ......
		if (!componentRoot) flushMounts();
	}
}

上面有两处调用函数flushMounts,一个是在renderComponent内部①,一个是在diff函数②。那么在什么情况下触发上下两段代码呢?首先componentRoot表示的是当前diff是不是以组件中渲染内容的形式调用(比如组件中render函数返回HTML类型的VNode),那么preact.render函数调用时肯定componentRootfalsediffLevel表示渲染的层次,diffLevel回减到0说明已经要结束diff的调用,所以在使用preact.render渲染的最后肯定会使用上面的代码去调用函数flushMounts。但是如果其中某个已经渲染的组件通过setState或者forceUpdate的方式导致了重新渲染并且致使子组件创建了新的实例(比如前后两次返回了不同的组件类型),这时,就会采用第一种方式在调用flushMounts函数。

setState

对于Preact的组件而言,state是及其重要的部分。其中涉及到的API为setState,定义在函数Component的原型中,这样所有的继承于Component的自定义组件实例都可以引用到函数setState

extend(Component.prototype,{
    //.......

    setState(state, callback) {
		let s = this.state;
		if (!this.prevState) this.prevState = extend({}, s);
		extend(s, typeof ··==='function' ? state(s, this.props) : state);
		if (callback) (this._renderCallbacks = (this._renderCallbacks || [])).push(callback);
		enqueueRender(this);
	}
	
	//......
});

首先我们看到setState接受两个参数: 新的state以及state更新后的回调函数,其中state既可以是对象类型的部分对象,也可以是函数类型。首先使用函数extend生成当前state的拷贝prevState,存储之前的state的状态。然后如果
state类型为函数时,将函数的生成值覆盖进入state,否则直接将新的state覆盖进入state,此时this.state已经成为了新的state。如果setState存在第二个参数callback,则将其存入实例属性_renderCallbacks(如果不存在_renderCallbacks属性,则需要初始化)。然后执行函数enqueueRender

enqueueRender

接下来我们看一下神奇的enqueueRender函数:

let items = [];

function enqueueRender(component) {
	if (!component._dirty && (component._dirty = true) && items.push(component) == 1) {
		defer(rerender);
	}
}

function rerender() {
	let p, list = items;
	items = [];
	while ((p = list.pop())) {
		if (p._dirty) renderComponent(p);
	}
}

const defer = typeof Promise=='function' ? Promise.resolve().then.bind(Promise.resolve()) : setTimeout;

我们可以看到当组件实例中的_dirty属性为false时,会将属性_dirty置为true,并将其放入items中。当更新队列第一次被items时,则延迟异步执行函数rerender。这个延迟异步函数在支持Promise的浏览器中,会使用Promise.resolve().then,否则会使用setTimeout

rerender函数就是将items中待更新的组件,逐个取出,并对其执行renderComponent。其实renderComponentopt参数不传入ASYNC_RENDER,而是传入undefined两者之间并无区别。唯一要注意的是:

//renderComponent内部
if (initialBase || opts===SYNC_RENDER) {
    base = diff(//...;
}

我们渲染过程一定是要执行diff,那就说明initialBase一定是个非假值,这也是可以保证的。

initialBase = isUpdate || nextBase

其实因为之前组件已经渲染过,所以是可以保证isUpdate一定为非假值,因为isUpdate = component.base并且component.base是一定存在的并且为上次渲染的内容。大家可能会担心如果上次组件render函数返回的是null该怎么办?其实阅读过第二篇文章的同学应该知道在idiff函数内部

if (vnode==null || typeof vnode==='boolean') vnode = '';

即使render返回的是null也会被当做一个空文本去控制,对应会渲染成DOM中的Text类型。

 

forceUpdate

extend(Component.prototype,{
    //.......

	forceUpdate(callback) {
		if (callback) (this._renderCallbacks = (this._renderCallbacks || [])).push(callback);
		renderComponent(this, FORCE_RENDER);
	}
	
	//......
});

执行forceUpdate所需要做的就是将回调函数放入组件实例中的_renderCallbacks属性并调用函数renderComponent强制刷新当前的组件。需要注意的是,我们渲染的模式是FORCE_RENDER强制刷新,与其他的模式到的区别就是不需要经过生命周期函数shouldComponentUpdate的判断,直接进行刷新。

结语

至此我们已经看完了Preact中的组件相关的代码,可能并没有对每一个场景都进行讲解,但是我也尽量尝试去覆盖所有相关的部分。代码相对比较长,看起来也经常令人头疼,有时候为了搞清楚某个变量的部分不得不数次回顾。但是你会发现你多次地、反复性的阅读、仔细地推敲,代码的含义会逐渐清晰。书读百遍其义自见,其实对代码来说也是一样的。文章若有不正确的地方,欢迎指出,共同学习。

未经允许不得转载:WEB开发编程网 » 从Preact了解一个类React的框架是怎么实现的(三): 组件

WEB开发编程网

谢谢支持,我们一直在努力

安全提示:您正在对WEB开发编程网进行赞赏操作,一但支付,不可返还。