preact原始碼分析

利維亞的傑洛特發表於2019-04-07

image

前言

前兩個星期花了一些時間學習preact的原始碼, 並寫了幾篇部落格。但是現在回頭看看寫的並不好,而且原始碼的有些地方(diffChildren的部分)我還理解?錯了。實在是不好意思。所以這次準備重新寫一篇部落格重新做下分析。

preact雖然是react的最小實現, 很多react的特性preact裡一點都沒有少, 比如contextAPI, Fragment等。我們分析時更注重實現過程,會對一些API的實現進行忽略。請見諒

preact是什麼?

⚛️ Fast 3kB React alternative with the same modern API. Components & Virtual DOM

preact可以說是類react框架的最小實現

虛擬DOM

關於jsx

我們首先看下preact官網上的demo。


import { h, render } from 'preact';

render((
  <h1 id="title" >Hello, world!</h1>
), document.body);
複製程式碼

其實上面?的jsx程式碼,本質是下面?程式碼的語法糖


h(
  'h1',
  { id: 'title' },
  'Hello, world!'
)
複製程式碼

preact是如何做到的呢?preact本身並沒有實現這個語法轉換的功能,preact是依賴transform-react-jsx的babel外掛做到的。

createElement

前面我們看到了jsx的程式碼會被轉換為用h函式包裹的程式碼, 我們接下來看下h函式是如何實現的。createElement函式位於create-element.js這個檔案中。

檔案中主要為3個函式, createElement和createVNode, 以及coerceToVNode。

createElement和createVNode是一對的, createElement會將children掛載到VNode的props中。既props.children的陣列中。createVNode則會將根據這些引數返回一個物件, 這個物件就是虛擬DOM。

在createElement中我們還可以看到對defaultProps的處理, 而defaultProps可以為我們設定props的預設的初始值。


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;
  }

	if (type!=null && type.defaultProps!=null) {
		for (let i in type.defaultProps) {
			if (props[i]===undefined) props[i] = type.defaultProps[i];
		}
	}
	let ref = props.ref;
	if (ref) delete props.ref;
	let key = props.key;
	if (key) delete props.key;

	return createVNode(type, props, null, key, ref);
}

export function createVNode(type, props, text, key, ref) {

	const vnode = {
		type,
		props,
		text,
		key,
		ref,
		_children: null,
		_dom: null,
		_lastDomChild: null,
		_component: null
	};

	return vnode;
}

複製程式碼

而coerceToVNode函式的作用則是將一些沒有type型別的節點。比如一段字串, 一個數字強制轉換為VNode節點, 這些節點的type值為null, text屬性中保留了字串和數字的值。

export function coerceToVNode(possibleVNode) {
	if (possibleVNode == null || typeof possibleVNode === 'boolean') return null;
	if (typeof possibleVNode === 'string' || typeof possibleVNode === 'number') {
		return createVNode(null, null, possibleVNode, null, null);
	}

	if (Array.isArray(possibleVNode)) {
		return createElement(Fragment, null, possibleVNode);
	}

	if (possibleVNode._dom!=null) {
		return createVNode(possibleVNode.type, possibleVNode.props, possibleVNode.text, possibleVNode.key, null);
	}

	return possibleVNode;
}
複製程式碼

到這裡create-element的這個模組我們就介紹完了。這是一個非常簡單的模組, 做的功能就是根據對應的jsx->虛擬DOM。我們這裡還沒有涉及如何渲染出真正的DOM節點, 這是因為preact中渲染的過程是直接在diff演算法中實現,一邊比對一邊跟更新真實的dom。

元件

preact中有一個通用Component類, 元件的實現需要繼承這個通用的Component類。我們來看下preact中Component類是如何實現的。它位於component.js檔案?中。

我們首先看下Component類的建構函式,非常的簡單。只有兩個屬性props, context。因為通用的Component類實現了props屬性,所以我們的元件類在繼承Component類後,需要顯式的使用super作為函式呼叫,並將props傳入。


export function Component(props, context) {
	this.props = props
	this.context = context
}
複製程式碼

Component類中實現了setState方法, forceUpdate方法,render方法,以及其他的一些輔助函式。forceUpdate涉及到了setState的非同步更新, 我們將在setState一節中專門介紹。這裡暫不做介紹。我們接下來看看setState的實現。


Component.prototype.setState = function(update, callback) {
	let s = (this._nextState!==this.state && this._nextState) || (this._nextState = assign({}, this.state));

	if (typeof update!=='function' || (update = update(s, this.props))) {
		assign(s, update);
	}

	if (update==null) return;

	if (this._vnode) {
		if (callback) this._renderCallbacks.push(callback);
		enqueueRender(this);
	}
};

// src/util.js
export function assign(obj, props) {
	for (let i in props) obj[i] = props[i];
	return obj;
}
複製程式碼

在preact的setState方法, 同react一樣支援函式或者Object兩種方式更新state, 並且支援setState的回撥。我們這裡看到了兩個個私有屬性_nextState, _renderCallbacks。_renderCallbacks則是儲存了setState回撥的佇列。

_nextState裡儲存了最新的state, 為什麼我們不去直接更新state呢?因為我們要實現生命週期, 比如getDerivedStateFromProps生命週期中元件的state並沒有更新呢。我們需要使用_nextState儲存最新的state?。enqueueRender函式涉及到了state的非同步更新, 我們在本節先不介紹。

// src/component.js
export function Fragment() { }

Component.prototype.render = Fragment;
複製程式碼

基類的render方法本身是一個空函式, 需要繼承的子類自己具體實現。

?component.js的模組的部分內容,我們已經介紹完成了, 同樣不是很複雜。component.js的模組的其他的內容因為涉及了setState非同步更新佇列,所以我們將在setState一節中。回過頭來介紹它。

diff演算法

image

ps: ?我們只需要比較同級的節點(相同顏色框內的), 如果兩個節點type不一致, 我們會銷燬當前的節點。不進行比較子節點的操作。

在preact中diff演算法以及真實dom的更新和渲染是雜糅在一起的。所以本節內容會比較多。

preact會儲存上一次的渲染的VNode(儲存在_prevVNode的私有屬性上)。而本次渲染過程中我們會比較本次的VNode上前一次的_prevVNode。判斷是否需要生成新的Dom, 解除安裝Dom的操作, 更新真實dom的操作(我們將VNode對應的真實的dom儲存在VNode的私有屬性_dom, 可以實現在diff的過程中更新dom的操作)。

render

對比文字節點

我們首先回憶一下文字節點的VNode的結構是怎麼樣的

// 文字節點VNode
{
  type: null,
  props: null,
  text: '你的文字'
  _dom: TextNode
}
複製程式碼

我們首先進入diff方法。diff方法中會對VNode型別進行判斷, 如果不是function型別(元件型別), 和Fragment型別。我們的會呼叫diffElementNodes函式。

// src/diff/index.js
// func diff

// 引數很多, 我們來說下幾個引數的具體含義
// dom為VNode對應的真實的Dom節點
// newVNode新的VNode
// oldVNode舊的VNode
// mounts儲存掛載元件的列表
dom = diffElementNodes(dom, newVNode, oldVNode, context, isSvg, excessDomChildren, mounts, ancestorComponent)
複製程式碼

如果此時dom還沒有建立。初次渲染, 那麼我們根據VNode型別建立對應的真實dom節點。文字型別會使用createTextNode建立文字節點。

接下來我們會標籤之前VNode的text的內容, 如果新舊不相等。我們將新VNode的text屬性,賦值給dom節點。完成對dom的更新操作。


// src/diff/index.js
// func diffElementNodes

if (dom==null) {
	dom = newVNode.type===null ? document.createTextNode(newVNode.text) : isSvg ? document.createElementNS('http://www.w3.org/2000/svg', newVNode.type) : document.createElement(newVNode.type);

	excessDomChildren = null;
}

newVNode._dom = dom;

if (newVNode.type===null) {
	if ((d===null || dom===d) && newVNode.text!==oldVNode.text) {
		dom.data = newVNode.text;
	}
}
複製程式碼

對比非文字DOM節點

非文字DOM節點?️的是那些type為div, span, h1的VNode節點。這些型別的節點在diff方法中, 我們依舊會呼叫diffElementNodes函式去處理。


// src/diff/index.js
// func diff

dom = diffElementNodes(dom, newVNode, oldVNode, context, isSvg, excessDomChildren, mounts, ancestorComponent)
複製程式碼

進入diffElementNodes方法後, 如果是初次渲染我們會使用createElement建立真實的dom節點掛載到VNode的_dom屬性上。

接下來我們會比較新舊VNode的屬性props。但是之前會呼叫diffChildren方法, 對當前的VNode子節點進行比較。我們這裡先不進入diffChildren函式中。**我們只需要知道我們在更新當前節點屬性的時候, 我們已經通過遞迴形式, 完成了對當前節點的子節點的更新操作。**接下來我們進入diffProps函式中。


// src/diff/index.js
// func diffElementNodes

if (dom==null) {
	dom = newVNode.type===null ? document.createTextNode(newVNode.text) : isSvg ? document.createElementNS('http://www.w3.org/2000/svg', newVNode.type) : document.createElement(newVNode.type);
}

newVNode._dom = dom;

if (newVNode !== oldVNode) {
	let oldProps = oldVNode.props;
	let newProps = newVNode.props;

	if (oldProps == null) {
		oldProps = {};
	}
	diffChildren(dom, newVNode, oldVNode, context, newVNode.type === 'foreignObject' ? false : isSvg, excessDomChildren, mounts, ancestorComponent);
	diffProps(dom, newProps, oldProps, isSvg);
}
複製程式碼

在diffProps函式中我們會做兩件事。設定, 更新屬性。刪除新的props中不存在的屬性。setProperty在preact中的具體實現, 我們往下看。


// src/diff/props.js

export function diffProps(dom, newProps, oldProps, isSvg) {
  // 設定或更新屬性值
	for (let i in newProps) {
		if (i!=='children' && i!=='key' && (!oldProps || ((i==='value' || i==='checked') ? dom : oldProps)[i]!==newProps[i])) {
			setProperty(dom, i, newProps[i], oldProps[i], isSvg);
		}
  }
  // 刪除屬性
	for (let i in oldProps) {
		if (i!=='children' && i!=='key' && (!newProps || !(i in newProps))) {
			setProperty(dom, i, null, oldProps[i], isSvg);
		}
	}
}
複製程式碼

在setProperty方法中, 如果value(新的屬性值)為null, 我們會刪除對應的屬性。如果不為null, 我們將會更新或者設定新的屬性。同時還會對事件進行處理, 例如onClick屬性, 我們會使用addEventListener新增原生的click事件。


// src/diff/props.js

function setProperty(dom, name, value, oldValue, isSvg) {
  let v;
  // 對class處理
	if (name==='class' || name==='className') name = isSvg ? 'class' : 'className';

  // 對style處理, style傳入Object或者字串都會得到相容的處理
	if (name==='style') {

		let s = dom.style;

    // 如果style是string型別
		if (typeof value==='string') {
			s.cssText = value;
		}
		else {
      // 如果style是object型別
			if (typeof oldValue==='string') s.cssText = '';
			else {
				for (let i in oldValue) {
					if (value==null || !(i in value)) s.setProperty(i.replace(CAMEL_REG, '-'), '');
				}
			}
			for (let i in value) {
				v = value[i];
				if (oldValue==null || v!==oldValue[i]) {
					s.setProperty(i.replace(CAMEL_REG, '-'), typeof v==='number' && IS_NON_DIMENSIONAL.test(i)===false ? (v + 'px') : v);
				}
			}
		}
	}
	else if (name==='dangerouslySetInnerHTML') {
		return;
	}
	else if (name[0]==='o' && name[1]==='n') {
    // 對事件處理
		let useCapture = name !== (name=name.replace(/Capture$/, ''));
		let nameLower = name.toLowerCase();
		name = (nameLower in dom ? nameLower : name).substring(2);

		if (value) {
			if (!oldValue) dom.addEventListener(name, eventProxy, useCapture);
		}
		else {
			dom.removeEventListener(name, eventProxy, useCapture);
		}
		(dom._listeners || (dom._listeners = {}))[name] = value;
	}
	else if (name!=='list' && name!=='tagName' && !isSvg && (name in dom)) {
		dom[name] = value==null ? '' : value;
	}
	else if (value==null || value===false) {
    // 刪除以及為null的屬性
		if (name!==(name = name.replace(/^xlink:?/, ''))) dom.removeAttributeNS('http://www.w3.org/1999/xlink', name.toLowerCase());
		else dom.removeAttribute(name);
	}
	else if (typeof value!=='function') {
    // 更新或設定新的屬性
		if (name!==(name = name.replace(/^xlink:?/, ''))) dom.setAttributeNS('http://www.w3.org/1999/xlink', name.toLowerCase(), value);
		else dom.setAttribute(name, value);
	}
}
複製程式碼

對比元件

image

如果VNode是元件型別。在diff函式中, 會在不同的時刻執行元件的生命週期。在diff中, 執行元件例項的render函式。我們將會拿到元件返回的VNode, 然後再將VNode再一次帶入diff方法中進行diff比較。大致的流程可以如上圖所示。


// src/diff/index.js
// func diff

let c, p, isNew = false, oldProps, oldState, snapshot,
  newType = newVNode.type;
  
let cxType = newType.contextType;
let provider = cxType && context[cxType._id];
let cctx = cxType != null ? (provider ? provider.props.value : cxType._defaultValue) : context;

if (oldVNode._component) {
	c = newVNode._component = oldVNode._component;
	clearProcessingException = c._processingException;
}
else {
	isNew = true;

  // 建立元件的例項
	if (newType.prototype && newType.prototype.render) {
		newVNode._component = c = new newType(newVNode.props, cctx);
	}
	else {
		newVNode._component = c = new Component(newVNode.props, cctx);
		c.constructor = newType;
		c.render = doRender;
  }
  
	c._ancestorComponent = ancestorComponent;
	if (provider) provider.sub(c);

  // 初始化,元件的state, props的屬性
	c.props = newVNode.props;
	if (!c.state) c.state = {};
	c.context = cctx;
	c._context = context;
	c._dirty = true;
	c._renderCallbacks = [];
}

// 元件的例項上掛載元件所對應的VNode節點
c._vnode = newVNode;

let s = c._nextState || c.state;

// 執行getDerivedStateFromProps生命週期函式, 返回只會更新元件的state
if (newType.getDerivedStateFromProps != null) {
	oldState = assign({}, c.state);
	if (s === c.state) s = c._nextState = assign({}, s);
	assign(s, newType.getDerivedStateFromProps(newVNode.props, s));
}

if (isNew) {
  // 執行componentWillMount生命週期
  if (newType.getDerivedStateFromProps == null && c.componentWillMount != null) c.componentWillMount();
  // 將需要執行componentDidMount生命週期的元件, push到mounts佇列中
	if (c.componentDidMount != null) mounts.push(c);
}
else {
  // 執行componentWillReceiveProps生命週期
	if (newType.getDerivedStateFromProps == null && force == null && c.componentWillReceiveProps != null) {
		c.componentWillReceiveProps(newVNode.props, cctx);
		s = c._nextState || c.state;
	}

  // 執行shouldComponentUpdate生命週期, 並將_dirty設定為false, 當_dirty被設定為false時, 執行的更新操作將會被暫停
	if (!force && c.shouldComponentUpdate != null && c.shouldComponentUpdate(newVNode.props, s, cctx) === false) {
		c.props = newVNode.props;
		c.state = s;
    c._dirty = false;
    // break後,不在執行以下的程式碼
		break outer;
	}

  // 執行componentWillUpdate生命週期
	if (c.componentWillUpdate != null) {
		c.componentWillUpdate(newVNode.props, s, cctx);
	}
}

oldProps = c.props;
if (!oldState) oldState = c.state;

c.context = cctx;
c.props = newVNode.props;
// 將更新後的state的s,賦予元件的state
c.state = s;

// prev為上一次渲染時對應的VNode節點
let prev = c._prevVNode;
// 呼叫元件的render方法獲取元件的VNode
let vnode = c._prevVNode = coerceToVNode(c.render(c.props, c.state, c.context));
c._dirty = false;

if (c.getChildContext != null) {
	context = assign(assign({}, context), c.getChildContext());
}

// 執行getSnapshotBeforeUpdate生命週期
if (!isNew && c.getSnapshotBeforeUpdate != null) {
	snapshot = c.getSnapshotBeforeUpdate(oldProps, oldState);
}

// 更新元件所對應的VNode,返回對應的dom
c.base = dom = diff(dom, parentDom, vnode, prev, context, isSvg, excessDomChildren, mounts, c, null);

if (vnode != null) {
	newVNode._lastDomChild = vnode._lastDomChild;
}

c._parentDom = parentDom;
複製程式碼

在diff函式的頂部有這樣一段程式碼上面有一句英文註釋(If the previous type doesn't match the new type we drop the whole subtree), 如果oldVNode和newVNode型別不同,我們將會解除安裝整個子樹?。


if (oldVNode==null || newVNode==null || oldVNode.type!==newVNode.type) {
  // 如果newVNode為null, 我們將會解除安裝整個元件, 並刪除對應的dom節點 
	if (oldVNode!=null) unmount(oldVNode, ancestorComponent);
	if (newVNode==null) return null;
	dom = null;
	oldVNode = EMPTY_OBJ;
}
複製程式碼

對比子節點


export function diffChildren(parentDom, newParentVNode, oldParentVNode, context, isSvg, excessDomChildren, mounts, ancestorComponent) {
	let childVNode, i, j, p, index, oldVNode, newDom,
		nextDom, sibDom, focus,
		childDom;

	let newChildren = newParentVNode._children || toChildArray(newParentVNode.props.children, newParentVNode._children=[], coerceToVNode);
	let oldChildren = oldParentVNode!=null && oldParentVNode!=EMPTY_OBJ && oldParentVNode._children || EMPTY_ARR;

	let oldChildrenLength = oldChildren.length;

  childDom = oldChildrenLength ? oldChildren[0] && oldChildren[0]._dom : null;
  
	for (i=0; i<newChildren.length; i++) {
		childVNode = newChildren[i] = coerceToVNode(newChildren[i]);
		oldVNode = index = null;

    p = oldChildren[i];
    
    // 
		if (p != null && (childVNode.key==null && p.key==null ? (childVNode.type === p.type) : (childVNode.key === p.key))) {
			index = i;
		}
		else {
			for (j=0; j<oldChildrenLength; j++) {
				p = oldChildren[j];
				if (p!=null) {
					if (childVNode.key==null && p.key==null ? (childVNode.type === p.type) : (childVNode.key === p.key)) {
						index = j;
						break;
					}
				}
			}
		}

		if (index!=null) {
			oldVNode = oldChildren[index];
			oldChildren[index] = null;
		}

    nextDom = childDom!=null && childDom.nextSibling;
    
		newDom = diff(oldVNode==null ? null : oldVNode._dom, parentDom, childVNode, oldVNode, context, isSvg, excessDomChildren, mounts, ancestorComponent, null);

		if (childVNode!=null && newDom !=null) {
			focus = document.activeElement;

			if (childVNode._lastDomChild != null) {
				newDom = childVNode._lastDomChild;
			}
			else if (excessDomChildren==oldVNode || newDom!=childDom || newDom.parentNode==null) {

				outer: if (childDom==null || childDom.parentNode!==parentDom) {
					parentDom.appendChild(newDom);
				}
				else {
					sibDom = childDom;
					j = 0;
					while ((sibDom=sibDom.nextSibling) && j++<oldChildrenLength/2) {
						if (sibDom===newDom) {
							break outer;
						}
					}
					parentDom.insertBefore(newDom, childDom);
				}
			}

			if (focus!==document.activeElement) {
				focus.focus();
			}

			childDom = newDom!=null ? newDom.nextSibling : nextDom;
		}
	}


	for (i=oldChildrenLength; i--; ) {
		if (oldChildren[i]!=null) {
			unmount(oldChildren[i], ancestorComponent);
		}
	}
}
複製程式碼

diffChildren是最為複雜的一部分內容。子VNode作為一個陣列, 陣列中的內容可能改變了順序或者數目, 很難確定新的VNode要和那一箇舊的VNode比較。所以preact中當面對列表時,我們將要求使用者提供key, 幫助我們比較VNode。達到複用Dom的目的。

在diffChildren中,我們會首先通過toChildArray函式將子節點以陣列的形式儲存在_children屬性上。

childDom為第一個子節點真實的dom(這很有用, 我們在後面將通過它來判斷是使用appendChild插入newDom還是使用insertBefore插入newDom,或者什麼都不做)

接下來遍歷_children屬性。如果VNode有key屬性, 則找到key與key相等的舊的VNode。如果沒有key, 則找到最近的type相等的舊的VNode。然後將oldChildren對應的位置設定null, 避免重複的查詢。使用diff演算法對比, 新舊VNode。返回新的dom。

如果childDom為null, 則將新dom, append的到父DOM中。如果找到了與新的dom相等的dom(引用型別), 我們則不做任何處理(props已經在diffElementNode中更新了)。如果在childDom的nextSibling沒有找到和新的dom相等的dom, 我們將dom插入childDom的前面。接著更新childom。

遍歷剩餘沒有使用到oldChildren, 解除安裝這些節點或者元件。

非同步setState

preact除了使用diff演算法減少dom操作優化效能外, preact會將一段時間內的多次setState合併減少元件渲染的次數。

我們首先在setState中, 並沒有直接更新state, 或者直接重新渲染函式函式。而是將元件的例項帶入到了enqueueRender函式中。


Component.prototype.setState = function(update, callback) {
	let s = (this._nextState!==this.state && this._nextState) || (this._nextState = assign({}, this.state));

	if (typeof update!=='function' || (update = update(s, this.props))) {
		assign(s, update);
	}

	if (update==null) return;

	if (this._vnode) {
		if (callback) this._renderCallbacks.push(callback);
		enqueueRender(this);
	}
};

複製程式碼

在enqueueRender函式中, 我們將元件push到佇列q中。

同時使用_dirty控制, 避免q佇列中被push了相同的元件。我們應該在多長時間內清空q佇列呢?

我們該如何定義這麼一段時間呢?比較好的做法是使用Promise.resolve()。在這一段時間的setState操作都會被push到q佇列中。_nextState將會被合併在清空佇列的時候,一併更新到state上,避免了重複的渲染。


let q = [];

export function enqueueRender(c) {
	if (!c._dirty && (c._dirty = true) && q.push(c) === 1) {
		(options.debounceRendering || defer)(process);
	}
}

function process() {
	let p;
	while ((p=q.pop())) {
		if (p._dirty) p.forceUpdate(false);
	}
}

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

複製程式碼

在巨集任務完成後,我們執行微任務Promise.resolve(), 清空q佇列,使用diff方法更新佇列中的元件。

Component.prototype.forceUpdate = function(callback) {
	let vnode = this._vnode, dom = this._vnode._dom, parentDom = this._parentDom;
	if (parentDom) {
		const force = callback!==false;

		let mounts = [];
		dom = diff(dom, parentDom, vnode, vnode, this._context, parentDom.ownerSVGElement!==undefined, null, mounts, this._ancestorComponent, force);
		if (dom!=null && dom.parentNode!==parentDom) {
			parentDom.appendChild(dom);
		}
		commitRoot(mounts, vnode);
	}
	if (callback) callback();
};
複製程式碼

結語

到這裡我們已經吧preact的原始碼大致瀏覽了一遍。我們接下來可以參考preact的原始碼,實現自己的react。話說我還給preact的專案提交了pr?,不過還沒有merge?。

相關文章