作者:Erik

原文地址:efe.baidu.com/blog/san-

感謝 Erik 嘔心瀝血產出此文,大家可以關注他的 微博 和他交流

友情提示:此文很長,但含量極高,推薦大家收藏點贊後時長翻出來閱讀

希望大家給 SAN 點個 Star

baidu/san?

github.com圖標

一個 MVVM 框架的性能進化之路

性能一直是 框架選型 最重要的考慮因素之一。San 從設計之初就希望不要因為自身的短板(性能、體積、兼容性等)而成為開發者為難的理由,所以我們在性能上投入了很多的關注和精力,效果至少從 benchmark 看來,還不錯。

San non-keyed performance

將近 2 年以前,我發了一篇 San - 一個傳統的MVVM組件框架。對 San 設計初衷感興趣的同學可以翻翻。我一直覺得框架選型的時候,瞭解它的調性是非常關鍵的一點。

不過其實,大多數應用場景的框架選型中,知名度 是最主要的考慮因素,因為 知名度 意味著你可以找到更多的人探討、可以找到更多周邊、可以更容易招聘熟手或者以後自己找工作更有優勢。所以本文的目的並不是將你從三大陣營(React、Vue、Angular)拉出來,而是想把 San 的性能經驗分享給你。這些經驗無論在應用開發,還是寫一些基礎的東西,都會有所幫助。

在正式開始之前,慣性先厚臉皮求下 Star。

視圖創建

考慮下面這個還算簡單的組件:

const MyApp = san.defineComponent({
template: `
<div>
<h3>{{title}}</h3>
<ul>
<li s-for="item,i in list">{{item}} <a on-click="removeItem(i)">x</a></li>
</ul>
<h4>Operation</h4>
<div>
Name:
<input type="text" value="{=value=}">
<button on-click="addItem">add</button>
</div>
<div>
<button on-click="reset">reset</button>
</div>
</div>
`,

initData() {
return {
title: List,
list: []
};
},

addItem() {
this.data.push(list, this.data.get(value));
this.data.set(value, );
},

removeItem(index) {
this.data.removeAt(list, index);
},

reset() {
this.data.set(list, []);
}
});

在視圖初次渲染完成後,San 會生成一棵這樣子的樹:

Render Tree

那麼,在這個過程裏,San 都做了哪些事情呢?

模板解析

在組件第一個實例被創建時,template 屬性會被解析成 ANode。

ANode

ANode 的含義是抽象節點樹,包含了模板聲明的所有信息,包括標籤、文本、插值、數據綁定、條件、循環、事件等信息。對每個數據引用的聲明,也會解析出具體的表達式對象。

{
"directives": {},
"props": [],
"events": [],
"children": [
{
"directives": {
"for": {
"item": "item",
"value": {
"type": 4,
"paths": [
{
"type": 1,
"value": "list"
}
]
},
"index": "i",
"raw": "item,i in list"
}
},
"props": [],
"events": [],
"children": [
{
"textExpr": {
"type": 7,
"segs": [
{
"type": 5,
"expr": {
"type": 4,
"paths": [
{
"type": 1,
"value": "item"
}
]
},
"filters": [],
"raw": "item"
}
]
}
},
{
"directives": {},
"props": [],
"events": [
{
"name": "click",
"modifier": {},
"expr": {
"type": 6,
"name": {
"type": 4,
"paths": [
{
"type": 1,
"value": "removeItem"
}
]
},
"args": [
{
"type": 4,
"paths": [
{
"type": 1,
"value": "i"
}
]
}
],
"raw": "removeItem(i)"
}
}
],
"children": [
{
"textExpr": {
"type": 7,
"segs": [
{
"type": 1,
"literal": "x",
"value": "x"
}
],
"value": "x"
}
}
],
"tagName": "a"
}
],
"tagName": "li"
}
],
"tagName": "ul"
}

ANode 保存著視圖聲明的數據引用與事件綁定信息,在視圖的初次渲染與後續的視圖更新中,都扮演著不可或缺的作用。

無論一個組件被創建了多少個實例,template 的解析都只會進行一次。當然,預編譯是可以做的。但因為 template 是用才解析,沒有被使用的組件不會解析,所以就看實際使用中值不值,有沒有必要了。

preheat

在組件第一個實例被創建時,ANode 會進行一個 預熱 操作。看起來, 預熱template解析 都是發生在第一個實例創建時,那他們有什麼區別呢?

  1. template解析 生成的 ANode 是一個可以被 JSON stringify 的對象。
  2. 由於 1,所以 ANode 可以進行預編譯。這種情況下,template解析 過程會被省略。而 預熱 是必然會發生的。

接下來,讓我們看看預熱到底生成了什麼?

aNode.hotspot = {
data: {},
dynamicProps: [],
xProps: [],
props: {},
sourceNode: sourceNode
};

上面這個來自 preheat-a-node.js 的簡單代碼節選不包含細節,但是可以看出, 預熱 過程生成了一個 hotspot 對象,其包含這樣的一些屬性:

  • data - 節點數據引用的摘要信息
  • dynamicProps - 節點上的動態屬性
  • xProps - 節點上的雙向綁定屬性
  • props - 節點的屬性索引
  • sourceNode - 用於節點生成的 HTMLElement

預熱 的主要目的非常簡單,就是把在模板信息中就能確定的事情提前,只做一遍,避免在 渲染/更新 過程中重複去做,從而節省時間。預熱 過程更多的細節見 preheat-a-node.js。在接下來的部分,對 hotspot 發揮作用的地方也會進行詳細說明。

視圖創建過程

Render

視圖創建是個很常規的過程:基於初始的 數據 和 ANode,創建一棵對象樹,樹中的每個節點負責自身在 DOM 樹上節點的操作(創建、更新、刪除)行為。對一個組件框架來說,創建對象樹的操作無法省略,所以這個過程一定比原始地 createElement + appendChild 慢。

因為這個過程比較常規,所以接下來不會描述整個過程,而是提一些有價值的優化點。

cloneNode

預熱 階段,我們根據 tagName 創建了 sourceNode

if (isBrowser && aNode.tagName
&& !/^(template|slot|select|input|option|button)$/i.test(aNode.tagName)
) {
sourceNode = createEl(aNode.tagName);
}

ANode 中包含了所有的屬性聲明,我們知道哪些屬性是動態的,哪些屬性是靜態的。對於靜態屬性,我們可以在 預熱 階段就直接設置好。See preheat-a-node.js

each(aNode.props, function (prop, index) {
aNode.hotspot.props[prop.name] = index;
prop.handler = getPropHandler(aNode.tagName, prop.name);

// ......
if (prop.expr.value != null) {
if (sourceNode) {
prop.handler(sourceNode, prop.expr.value, prop.name, aNode);
}
}
else {
if (prop.x) {
aNode.hotspot.xProps.push(prop);
}
aNode.hotspot.dynamicProps.push(prop);
}
});

視圖創建過程 中,就可以從 sourceNode clone,並且只對動態屬性進行設置。See element.js#L115-L150

var sourceNode = this.aNode.hotspot.sourceNode;
var props = this.aNode.props;

if (sourceNode) {
this.el = sourceNode.cloneNode(false);
props = this.aNode.hotspot.dynamicProps;
}
else {
this.el = createEl(this.tagName);
}

// ...

for (var i = 0, l = props.length; i < l; i++) {
var prop = props[i];
var propName = prop.name;
var value = isComponent
? evalExpr(prop.expr, this.data, this)
: evalExpr(prop.expr, this.scope, this.owner);

// ...

prop.handler(this.el, value, propName, this, prop);

// ...
}

屬性操作

不同屬性對應 DOM 的操作方式是不同的,屬性的 預熱 提前保存了屬性操作函數(preheat-a-node.js#L133),屬性初始化或更新時就無需每次都重複獲取。

prop.handler = getPropHandler(aNode.tagName, prop.name);

對於 s-bind,對應的數據是 預熱 階段無法預知的,所以屬性操作函數只能在具體操作時決定。See element.js#L128-L137

for (var key in this._sbindData) {
if (this._sbindData.hasOwnProperty(key)) {
getPropHandler(this.tagName, key)( // 看這裡看這裡
this.el,
this._sbindData[key],
key,
this
);
}
}

所以,getPropHandler 函數的實現也進行了相應的結果緩存。See get-prop-handler.js

var tagPropHandlers = elementPropHandlers[tagName];
if (!tagPropHandlers) {
tagPropHandlers = elementPropHandlers[tagName] = {};
}

var propHandler = tagPropHandlers[attrName];
if (!propHandler) {
propHandler = defaultElementPropHandlers[attrName] || defaultElementPropHandler;
tagPropHandlers[attrName] = propHandler;
}

return propHandler;

創建節點

視圖創建過程中,San 通過 createNode 工廠方法,根據 ANode 上每個節點的信息,創建組件的每個節點。

ANode 上與節點創建相關的信息有:

  • if 聲明
  • for 聲明
  • 標籤名
  • 文本表達式

節點類型有:

  • IfNode
  • ForNode
  • TextNode
  • Element
  • Component
  • SlotNode
  • TemplateNode

因為每個節點都通過 createNode 方法創建,所以它的性能是極其重要的。那這個過程的實現,有哪些性能相關的考慮呢?

首先,預熱 過程提前選擇好 ANode 節點對應的實際類型。See preheat-a-node.js#L58 preheat-a-node.js#L170 preheat-a-node.jsL185preheat-a-node.jsL190

createNode 一開始就可以直接知道對應的節點類型。See create-node.js#L24-L26

if (aNode.Clazz) {
return new aNode.Clazz(aNode, parent, scope, owner);
}

另外,我們可以看到,除了 Component 之外,其他節點類型的構造函數參數簽名都是 (aNode, parent, scope, owner, reverseWalker),並沒有使用一個 Object 包起來,就是為了在節點創建過程避免創建無用的中間對象,浪費創建和回收的時間。

function IfNode(aNode, parent, scope, owner, reverseWalker) {}
function ForNode(aNode, parent, scope, owner, reverseWalker) {}
function TextNode(aNode, parent, scope, owner, reverseWalker) {}
function Element(aNode, parent, scope, owner, reverseWalker) {}
function SlotNode(aNode, parent, scope, owner, reverseWalker) {}
function TemplateNode(aNode, parent, scope, owner, reverseWalker) {}

function Component(options) {}

而 Component 由於使用者可直接接觸到,初始化參數的便利性就更重要些,所以初始化參數是一個 options 對象。

視圖更新

從數據變更到遍歷更新

考慮上文中展示過的組件:

const MyApp = san.defineComponent({
template: `
<div>
<h3>{{title}}</h3>
<ul>
<li s-for="item,i in list">{{item}} <a on-click="removeItem(i)">x</a></li>
</ul>
<h4>Operation</h4>
<div>
Name:
<input type="text" value="{=value=}">
<button on-click="addItem">add</button>
</div>
<div>
<button on-click="reset">reset</button>
</div>
</div>
`,

initData() {
return {
title: List,
list: []
};
},

addItem() {
this.data.push(list, this.data.get(value));
this.data.set(value, );
},

removeItem(index) {
this.data.removeAt(list, index);
},

reset() {
this.data.set(list, []);
}
});

let myApp = new MyApp();
myApp.attach(document.body);

當我們更改了數據,視圖就會自動刷新。

myApp.data.set(title, SampleList);

data

我們可以很容易的發現,data 是:

  • 組件上的一個屬性,組件的數據狀態容器
  • 一個對象,提供了數據讀取和操作的方法。See 數據操作文檔
  • Observable。每次數據的變更都會 fire,可以通過 listen 方法監聽數據變更。See data.js

data 是變化可監聽的,所以組件的視圖變更就有了基礎出發點。

視圖更新過程

San 最初設計的時候想法很簡單:模板聲明包含了對數據的引用,當數據變更時可以精準地只更新需要更新的節點,性能應該是很高的。從上面組件例子的模板中,一眼就能看出,title 數據的修改,只需要更新一個節點。但是,我們如何去找到它並執行視圖更新動作呢?這就是組件的視圖更新機制了。其中,有幾個關鍵的要素:

  • 組件在初始化的過程中,創建了 data 實例並監聽其數據變化。See component.js#L255
  • 視圖更新是非同步的。數據變化會被保存在一個數組裡,在 nextTick 時批量更新。See component.js#L782
  • 組件是個 children 屬性串聯的節點樹,視圖更新是個自上而下遍歷的過程。

在節點樹更新的遍歷過程中,每個節點通過 _update({Array}changes) 方法接收數據變化信息,更新自身的視圖,並向子節點傳遞數據變化信息。component.js#L688 是組件向下遍歷的起始,但從最典型的 Element的_update方法 可以看得更清晰些:

  1. 先看自身的屬性有沒有需要更新的
  2. 然後把數據變化信息通過 children 往下傳遞。

// 節選
Element.prototype._update = function (changes) {
// ......

// 先看自身的屬性有沒有需要更新的
var dynamicProps = this.aNode.hotspot.dynamicProps;
for (var i = 0, l = dynamicProps.length; i < l; i++) {
var prop = dynamicProps[i];
var propName = prop.name;

for (var j = 0, changeLen = changes.length; j < changeLen; j++) {
var change = changes[j];

if (!isDataChangeByElement(change, this, propName)
&& changeExprCompare(change.expr, prop.hintExpr, this.scope)
) {
prop.handler(this.el, evalExpr(prop.expr, this.scope, this.owner), propName, this, prop);
break;
}
}
}

// ......

// 然後把數據變化信息通過 children 往下傳遞
for (var i = 0, l = this.children.length; i < l; i++) {
this.children[i]._update(changes);
}
};

下面這張圖說明瞭在節點樹中,this.data.set(title, hello) 帶來的視圖刷新,遍歷過程與數據變化信息的傳遞經過了哪些節點。左側最大的點是實際需要更新的節點,紅色的線代表遍歷過程經過的路徑,紅色的小圓點代表遍歷到的節點。可以看出,雖然需要進行視圖更新的節點只有一個,但所有的節點都被遍歷到了。

Update Flow

節點遍歷中斷

從上圖中不難發現,與實際的更新行為相比,遍歷確定更新節點的消耗要大得多。所以為遍歷過程減負,是一個必要的事情。San 在這方面是怎麼做的呢?

首先,預熱 過程生成的 hotspot 對象中,有一項 data,包含了節點及其子節點對數據引用的摘要信息。See preheat-a-node.js

然後,在視圖更新的節點樹遍歷過程中,使用 hotspot.data 與數據變化信息進行比對。結果為 false 時意味著數據的變化不會影響當前節點及其子節點的視圖,就不會執行自身屬性的更新,也不會繼續向下遍歷。遍歷過程在更高層的節點被中斷,節省了下層子樹的遍歷開銷。See element.js#241 changes-is-in-data-ref.js

Element.prototype._update = function (changes) {
var dataHotspot = this.aNode.hotspot.data;
if (dataHotspot && changesIsInDataRef(changes, dataHotspot)) {
// ...
}
};

有了節點遍歷中斷的機制,title 數據修改引起視圖變更的遍歷過程如下。可以看到,灰色的部分都是由於中斷,無需到達的節點。

Update Flow

有沒有似曾相識的感覺?是不是很像 React 中的 shouldComponentUpdate?不過不同的是,由於模板聲明包含了對數據的引用,San可以在框架層面自動做到這一點,組件開發者不需要人工去幹這件事了。

屬性更新

在視圖創建過程的章節中,提到過在 預熱 過程中,我們得到了:

  • dynamicProps:哪些屬性是動態的。See preheat-a-node.js#L117
  • prop.handler:屬性的設置操作函數。See preheat-a-node.jsL119

<input type="text" value="{=value=}">

在上面這個例子中,dynamicProps 只包含 value,不包含 type

所以在節點的屬性更新時,我們只需要遍歷 hotspot.dynamicProps,並且直接使用 prop.handler 來執行屬性更新。See element.js#L259-L277

Element.prototype._update = function (changes) {
// ......

// 先看自身的屬性有沒有需要更新的
var dynamicProps = this.aNode.hotspot.dynamicProps;
for (var i = 0, l = dynamicProps.length; i < l; i++) {
var prop = dynamicProps[i];
var propName = prop.name;

for (var j = 0, changeLen = changes.length; j < changeLen; j++) {
var change = changes[j];

if (!isDataChangeByElement(change, this, propName)
&& changeExprCompare(change.expr, prop.hintExpr, this.scope)
) {
prop.handler(this.el, evalExpr(prop.expr, this.scope, this.owner), propName, this, prop);
break;
}
}
}

// ......
};

Immutable

Immutable 在視圖更新中最大的意義是,可以無腦認為 === 時,數據是沒有變化的。在很多場景下,對視圖是否需要更新的判斷變得簡單很多。否則判斷的成本對應用來說是不可接受的。

但是,Immutable 可能會導致開發過程的更多成本。如果開發者不藉助任何庫,只使用原始的 JavaScript,一個對象的賦值會寫的有些麻煩。

var obj = {
a: 1,
b: {
b1: 2,
b2: 3
},
c: 2
};

// mutable
obj.b.b1 = 5;

// immutable
obj = Object.assign({}, obj, {b: Object.assign({}, obj.b, {b1: 5})});

San 的數據操作是通過 data 上的方法提供的,所以內部實現可以天然 immutable,這利於視圖更新操作中的一些判斷。See data.js#L209

由於視圖刷新是根據數據變化信息進行的,所以判斷當數據沒有變化時,不產生數據變化信息就行了。See data.js#L204 for-node.jsL570 L595 L679 L731

San 期望開發者對數據操作細粒度的使用數據操作方法。否則,不熟悉 immutable 的開發者可能會碰到如下情況。

// 假設初始數據如下
/*
{
a: 1,
b: {
b1: 2,
b2: 3
}
}
*/

var b = this.data.get(b);
b.b1 = 5;

// 由於 b 對象引用不變,會導致視圖不刷新
this.data.set(b, b);

// 正確做法。set 操作在 san 內部是 immutable 的
this.data.set(b.b1, 5);

列表更新

列表數據操作方法

上文中我們提到,San 的視圖更新機制是基於數據變化信息的。數據操作方法 提供了一系列方法,會 fire changeObj。changeObj 只有兩種類型: SETSPLICE。See data-change-type.js data.js#L211 data.js#L352

// SET
changeObj = {
type: DataChangeType.SET,
expr,
value,
option
};

// SPLICE
changeObj = {
type: DataChangeType.SPLICE,
expr,
index,
deleteCount,
value,
insertions,
option
};

San 提供的數據操作方法裏,很多是針對數組的,並且大部分與 JavaScript 原生的數組方法是一致的。從 changeObj 的類型可以容易看出,最基礎的方法只有 splice 一個,其他方法都是 splice 之上的封裝。

  • push
  • pop
  • shift
  • unshift
  • remove
  • removeAt
  • splice

基於數據變化信息的視圖更新機制,意味著數據操作的粒度越細越精準,視圖更新的負擔越小性能越高。

// bad performance
this.data.set(list[0], {
name: san,
id: this.data.get(list[0].id)
});

// good performance
this.data.set(list[0].name, san);

更新過程

我們看個簡單的例子:下圖中,我們要把第一行的列表更新成第二行,需要插入綠色部分,更新黃色部分,刪除紅色部分。

List Update

San 的 ForNode 負責列表的渲染和更新。在更新過程裏:

  • _update 方法接收數據變化信息後,根據類型進行分發
  • _updateArray 負責處理數組類型的更新。其遍曆數據變化信息,計算得到更新動作,最後執行更新行為。

假設數據變化信息為:

[
// insert [2, 3], pos 1
// update 4
// remove 7
// remove 10
]

在遍曆數據變化信息前,我們先初始化一個和當前 children 等長的數組:childrenChanges。其用於存儲 children 裏每個子節點的數據變化信息。See for-node.js#L352

同時,我們初始化一個 disposeChildren 數組,用於存儲需要被刪除的節點。See for-node.js#L362

childrenChanges

接下來,_updateArray 循環處理數據變化信息。當遇到插入時,同時擴充 children 和 childrenChanges 數組。

childrenChanges

當遇到更新時,如果更新對應的是某一項,則對應該項的 childrenChanges 添加更新信息。

childrenChanges

當遇到刪除時,我們把要刪除的子節點從 children 移除,放入 disposeChildren。同時,childrenChanges 裏相應位置的項也被移除。

childrenChanges

遍曆數據變化信息結束後,執行更新行為分成兩步:See for-node.js#L772-L823

  1. 先執行刪除 disposeChildren
  2. 遍歷 children,對標記全新的子節點執行創建與插入,對存在的節點根據 childrenChanges 相應位置的信息執行更新

this._disposeChildren(disposeChildren, function () {
doCreateAndUpdate();
});

下面,我們看看常見的列表更新場景下, San 都有哪些性能優化的手段。

添加項

在遍曆數據變化信息時,遇到添加項,往 children 和 childrenChanges 中填充的只是 undefined0 的佔位值,不初始化新節點。See for-node.js#L518-L520

var spliceArgs = [changeStart + deleteCount, 0].concat(new Array(newCount));
this.children.splice.apply(this.children, spliceArgs);
childrenChanges.splice.apply(childrenChanges, spliceArgs);

由於 San 的視圖是非同步更新的,當前更新週期可能包含多個數據操作。如果這些數據操作中創建了一個項又刪除了的話,在遍曆數據變化信息過程中初始化新節點就是沒有必要的浪費。所以創建節點的操作放到後面 執行更新 的階段。

刪除項

前文中提過,視圖創建的過程,對於 DOM 的創建是挨個 createElementappendChildparentNode 中的。但是在刪除的時候,我們並不需要把整棵子樹上的節點都挨個刪除,只需要把要刪除子樹的根元素從 parentNoderemoveChild

所以,對於 Element、TextNode、ForNode、IfNode 等節點的 dispose 方法,都包含一個隱藏參數:noDetach。當接收到的值為 true 時,節點只做必要的清除操作(移除 DOM 上掛載的事件、清理節點樹的引用關係),不執行其對應 DOM 元素的刪除操作。See text-node.js#L118 node-own-simple-dispose.js#L22 element.js#L211 etc...

if (!noDetach) {
removeEl(this.el);
}

另外,在很多情況下,一次視圖更新週期中如果有數組項的刪除,是不會有對其他項的更新操作的。所以我們增加了 isOnlyDispose 變數用於記錄是否只包含數組項刪除操作。在 執行更新 階段,如果該項為 true,則完成刪除動作後不再遍歷 children 進行子項更新。See for-node.js#L787

if (isOnlyDispose) {
return;
}

// 對相應的項進行更新
// 如果不attached則直接創建,如果存在則調用更新函數
for (var i = 0; i < newLen; i++) {
}

length

數據變化(添加項、刪除項等)可能會導致數組長度變化,數組長度也可能會被數據引用。

<li s-for="item, index in list">{{index + 1}}/{{list.length}} item</li>

在這種場景下,即使只添加或刪除一項,整個列表視圖都需要被刷新。由於子節點的更新是在 執行更新 階段通過 _update 方法傳遞數據變化信息的,所以在 執行更新 前,我們根據以下兩個條件,判斷是否需要為子節點增加 length 變更信息。See for-node.js#L752-L767

  • 數組長度是否發生變化
  • 通過數據摘要判斷子項視圖是否依賴 length 數據。這個判斷邏輯上是多餘的,但是可以減少子項更新的成本

清空

首先,當數組長度為 0 時,顯然整個列表項直接清空就行了,數據變化信息可以完全忽略,不需要進行多餘的遍歷。See for-node.js#L248-L251

其次,如果一個元素裏的所有元素都是由列表項組成的,那麼元素的刪除可以暴力清除:通過一次 parentNode.textContent = 完成,無需逐項從父元素中移除。See for-node.js#L316-L332

// 代碼節選
var violentClear = !this.aNode.directives.transition
&& !children
// 是否 parent 的唯一 child
&& len && parentFirstChild === this.children[0].el && parentLastChild === this.el
;

// ......

if (violentClear) {
parentEl.textContent = ;
}

子項更新

想像下面這個列表數據子項的變更:

myApp.data.set(list[2], two);

對於 ForNode 的更新:

  • 首先使用 changeExprCompare 方法判斷數據變化對象與列表引用數據聲明之間的關係。See change-expr-compare.js
  • 如果屬於子項更新,則轉換成對應子項的數據變更信息,其他子項對該信息無感知。See for-node.js#L426

Update For Item

從上圖的更新過程可以看出,子項更新的更新過程能精確處理最少的節點。數據變更時精準地更新節點是 San 的優勢。

整列表變更

對於整列表變更,San 的處理原則是:儘可能重用當前存在的節點。原列表與新列表數據相比:

  • 原列表項更多
  • 新列表項更多
  • 一樣多

我們採用瞭如下的處理過程,保證原列表與新列表重疊部分節點執行更新操作,無需刪除再創建:

  1. 如果原列表項更多,從尾部開始把多餘的部分標記清除。See for-node.js#L717-L721
  2. 從起始遍歷新列表。如果在舊列表長度範圍內,標記更新(See for-node.js#L730-L740);如果是新列表多出的部分,標記新建(See for-node.js#L742)。

San 鼓勵開發者細粒度的使用數據操作方法,但總有無法精準進行數據操作,只能直接 set 整個數組。舉一個最常見的例子:數據是從服務端返回的 JSON。在這種場景下,就是 trackBy 發揮作用的時候了。

trackBy

我就是我,是顏色不一樣的煙火。 -- 張國榮《我》

<ul>
<li s-for="p in persons trackBy p.name">{{p.name}} - {{p.email}}</li>
</ul>

trackBy 也叫 keyed,其作用就是當列表數據 無法進行引用比較 時,告訴框架一個依據,框架就可以判斷出新列表中的項是原列表中的哪一項。上文提到的:服務端返回的數據,是 無法進行引用比較 的典型例子。

這裡我們不說 trackBy 的整個更新細節,只提一個優化手段。這個優化手段不是 San 獨有的,而是經典的優化手段。

TrackBy Optimize

可以看到,我們從新老列表的頭部和尾部進行分別遍歷,找出新老列表頭部和尾部的相同項,並把他們排除。這樣剩下需要進行 trackBy 的項可能就少多了。對應到常見的視圖變更場景,該優化手段都能發揮較好的作用。

  • 添加:無論在什麼位置添加幾項,該優化都能發揮較大作用
  • 刪除:無論在什麼位置刪除幾項,該優化都能發揮較大作用
  • 更新部分項:頭尾都有更新時,該優化無法發揮作用。也就是說,對於長度固定的列表有少量新增項時,該優化無用。不過 trackBy 過程在該場景下,性能消耗不高
  • 更新全部項:trackBy 過程在該場景下,性能消耗很低
  • 交換:相鄰元素的交換,該優化都能發揮較大作用。交換的元素間隔越小,該優化發揮作用越大

從 benchmark 的結果能看出來,San 在 trackBy 下也有較好的性能。

San keyed performance

吹毛求疵

在這個部分,我會列舉一些大多數人覺得知道、但又不會這麼去做的優化寫法。這些優化寫法貌似對性能沒什麼幫助,但是積少成多,帶來的性能增益還是不可忽略的。

避免 call 和 apply

call 和 apply 是 JavaScript 中的魔法,也是性能的大包袱。在 San 中,我們儘可能減少 call 和 apply 的使用。下面列兩個點:

比如,對 filter 的處理中,內置的 filter 由於都是 pure function,我們明確知道運行結果不依賴 this,並且參數個數都是確定的,所以無需使用 call。See eval-expr.js#L164-L172

if (owner.filters[filterName]) {
value = owner.filters[filterName].apply(
owner,
[value].concat(evalArgs(filter.args, data, owner))
);
}
else if (DEFAULT_FILTERS[filterName]) {
value = DEFAULT_FILTERS[filterName](value);
}

再比如,Component 和 Element 之間應該是繼承關係,create、attach、dispose、toPhase 等方法有很多可以復用的邏輯。基於性能的考慮,實現中並沒有讓 Component 和 Element 發生關係。對於復用的部分:

  • 復用邏輯較少的直接再寫一遍(See component.js#L355)
  • 復用邏輯多的,部分通過函數直接調用的形式復用(See element-get-transition.js etc...),部分通過函數掛載到 prototype 成為實例方法的形式復用(See element-own-dispose.js etc...)。場景和例子比較多,就不一一列舉了。

減少中間對象

看到這裡的你不知是否記得,在 創建節點 章節中,提到節點的函數簽名不合併成一個數組,就是為了防止中間對象的創建。中間對象不止是創建時有開銷,觸發 GC 回收內存也是有開銷的。在 San 的實現中,我們儘可能避免中間對象的創建。下面列兩個點:

數據操作的過程,直接傳遞表達式層級數組,以及當前指針位置。不使用 slice 創建表達式子層級數組。See data.js#L138

function immutableSet(source, exprPaths, pathsStart, pathsLen, value, data) {
if (pathsStart >= pathsLen) {
return value;
}

// ......
}

data 創建時如果傳入初始數據對象,以此為準,避免 extend 使初始數據對象變成中間對象。See data.js#L23

function Data(data, parent) {
this.parent = parent;
this.raw = data || {};
this.listeners = [];
}

減少函數調用

函數調用本身的開銷是很小的,但是調用本身也會初始化環境對象,調用結束後環境對象也需要被回收。San 對函數調用較為頻繁的地方,做了避免調用的條件判斷。下面列兩個點:

element 在創建子元素時,判斷子元素構造器是否存在,如果存在則無需調用 createNode 函數。See element.js#L167-L169

var child = childANode.Clazz
? new childANode.Clazz(childANode, this, this.scope, this.owner)
: createNode(childANode, this, this.scope, this.owner);

ANode 中對定值表達式(數字、bool、字元串字面量)的值保存在對象的 value 屬性中。evalExpr 方法開始時根據 expr.value != null 返回。不過在調用頻繁的場景(比如文本的拼接、表達式變化比對、等等),會提前進行一次判斷,減少 evalExpr 的調用。See eval-expr.js#L203 change-expr-compare.js#L77

buf += seg.value || evalExpr(seg, data, owner);

另外,還有很重要的一點:San 裏雖然實現了 each 方法,但是在視圖創建、視圖更新、變更判斷、表達式取值等關鍵性的過程中,還是直接使用 for 進行遍歷,就是為了減少不必要的函數調用開銷。See each.js eval-expr.js etc...

// bad performance
each(expr.segs.length, function (seg) {
buf += seg.value || evalExpr(seg, data, owner);
});

// good performance
for (var i = 0, l = expr.segs.length; i < l; i++) {
var seg = expr.segs[i];
buf += seg.value || evalExpr(seg, data, owner);
}

減少對象遍歷

使用 for...in 進行對象的遍歷是非常耗時的操作,San 在視圖創建、視圖更新等過程中,當運行過程明確時,儘可能不使用 for...in 進行對象的遍歷。一個比較容易被忽略的場景是對象的 extend,其隱藏了 for...in 遍歷過程。

function extend(target, source) {
for (var key in source) {
if (source.hasOwnProperty(key)) {
var value = source[key];
if (typeof value !== undefined) {
target[key] = value;
}
}
}

return target;
}

從一個對象創建一個大部分成員都一樣的新對象時,避免使用 extend。See for-node.jsL404

// bad performance
change = extend(
extend({}, change),
{
expr: createAccessor(this.itemPaths.concat(changePaths.slice(forLen + 1)))
}
);

// good performance
change = change.type === DataChangeType.SET
? {
type: change.type,
expr: createAccessor(
this.itemPaths.concat(changePaths.slice(forLen + 1))
),
value: change.value,
option: change.option
}
: {
index: change.index,
deleteCount: change.deleteCount,
insertions: change.insertions,
type: change.type,
expr: createAccessor(
this.itemPaths.concat(changePaths.slice(forLen + 1))
),
value: change.value,
option: change.option
};

將一個對象的成員賦予另一個對象時,避免使用 extend。See component.jsL113

// bad performance
extend(this, options);

// good performance
this.owner = options.owner;
this.scope = options.scope;
this.el = options.el;

最後

性能對於一個框架來說,是非常重要的事情。應用開發的過程通常很少會關注框架的實現;而如果框架實現有瓶頸,應用開發工程師其實是很難解決的。開發一時爽,調優火葬場的故事,發生得太多了。

San 在性能方面做了很多工作,但是看下來,其實沒有什麼非常深奧難以理解的技術。我們僅僅是覺得性能很重要,並且儘可能細緻的考慮和實現。因為我們不希望自己成為應用上的瓶頸,也不希望性能成為開發者在選型時猶豫的理由。

如果你看到這裡,覺得 San 還算有誠意,或者覺得有收穫,給個 Star 唄。


推薦閱讀:
相關文章