San 爲何會這麼快

本文做者:yanxin1563javascript

本文做者:html

errorrikjava

 

前言node

一個 MVVM 框架的性能進化之路 https://github.com/baidu/san/react

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

近 2 年之前,我發了一篇 San - 一個傳統的MVVM組件框架。對 San 設計初衷感興趣的同窗能夠翻翻。我一直以爲框架選型的時候,瞭解它的調性是很是關鍵的一點。github

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

在正式開始以前,慣性先厚臉皮求下 Star。https://github.com/baidu/san/緩存

視圖建立性能優化

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

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 會生成一棵這樣子的樹:

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

模板解析

在組件第一個實例被建立時,template 屬性會被解析成 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 對象,其包含這樣的一些屬性:

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

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

視圖建立過程

視圖建立是個很常規的過程:基於初始的 數據 和 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 上與節點建立相關的信息有:

  1. if 聲明
  2. for 聲明
  3. 標籤名
  4. 文本表達式

節點類型有:

  1. IfNode
  2. ForNode
  3. TextNode
  4. Element
  5. Component
  6. SlotNode
  7. TemplateNode

由於每一個節點都經過 createNode 方法建立,因此它的性能是極其重要的。那這個過程的實現,有哪些性能相關的考慮呢?

首先,預熱 過程提早選擇好 ANode 節點對應的實際類型。See preheat-a-node.js#L58 preheat-a-node.js#L170 preheat-a-node.jsL185 preheat-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 是:

  1. 組件上的一個屬性,組件的數據狀態容器
  2. 一個對象,提供了數據讀取和操做的方法。See 數據操做文檔
  3. Observable。每次數據的變動都會 fire,能夠經過 listen 方法監聽數據變動。See data.js

data 是變化可監聽的,因此組件的視圖變動就有了基礎出發點。

視圖更新過程

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

  1. 組件在初始化的過程當中,建立了 data 實例並監聽其數據變化。See component.js#L255
  2. 視圖更新是異步的。數據變化會被保存在一個數組裏,在 nextTick 時批量更新。See component.js#L782
  3. 組件是個 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') 帶來的視圖刷新,遍歷過程與數據變化信息的傳遞通過了哪些節點。左側最大的點是實際須要更新的節點,紅色的線表明遍歷過程通過的路徑,紅色的小圓點表明遍歷到的節點。能夠看出,雖然須要進行視圖更新的節點只有一個,但全部的節點都被遍歷到了。

節點遍歷中斷

從上圖中不難發現,與實際的更新行爲相比,遍歷肯定更新節點的消耗要大得多。因此爲遍歷過程減負,是一個必要的事情。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 數據修改引發視圖變動的遍歷過程以下。能夠看到,灰色的部分都是因爲中斷,無需到達的節點。

有沒有似曾相識的感受?是否是很像 React 中的 shouldComponentUpdate?不過不一樣的是,因爲模板聲明包含了對數據的引用,San 能夠在框架層面自動作到這一點,組件開發者不須要人工去幹這件事了。

屬性更新

在視圖建立過程的章節中,提到過在 預熱 過程當中,咱們獲得了:

  1. dynamicProps:哪些屬性是動態的。See preheat-a-node.js#L117
  2. 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 只有兩種類型: SET 和 SPLICE。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 之上的封裝。

  1. push
  2. pop
  3. shift
  4. unshift
  5. remove
  6. removeAt
  7. 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');

更新過程

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

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

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

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

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

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

  1. 先執行刪除 disposeChildren
  2. 遍歷 children,對標記全新的子節點執行建立與插入,對存在的節點根據 childrenChanges 相應位置的信息執行更新
this._disposeChildren(disposeChildren, function () {
    doCreateAndUpdate();
});

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

添加項

在遍歷數據變化信息時,遇到添加項,往 children 和 childrenChanges 中填充的只是 undefined 或 0 的佔位值,不初始化新節點。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 的建立是挨個 createElement 並 appendChild 到 parentNode 中的。可是在刪除的時候,咱們並不須要把整棵子樹上的節點都挨個刪除,只須要把要刪除子樹的根元素從 parentNode 中 removeChild

因此,對於 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

  1. 數組長度是否發生變化
  2. 經過數據摘要判斷子項視圖是否依賴 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 的更新:

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

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

整列表變動

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

  1. 原列表項更多
  2. 新列表項更多
  3. 同樣多

咱們採用了以下的處理過程,保證原列表與新列表重疊部分節點執行更新操做,無需刪除再建立:

  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 的項可能就少多了。對應到常見的視圖變動場景,該優化手段都能發揮較好的做用。

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

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

吹毛求疵

在這個部分,我會列舉一些大多數人以爲知道、但又不會這麼去作的優化寫法。這些優化寫法貌似對性能沒什麼幫助,可是聚沙成塔,帶來的性能增益仍是不可忽略的。

避免 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 發生關係。對於複用的部分:

  1. 複用邏輯較少的直接再寫一遍(See component.js#L355
  2. 複用邏輯多的,部分經過函數直接調用的形式複用(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 唄。

 

---------------------------------

在微信-搜索頁面中輸入「百度App技術」,便可關注微信官方帳號;

原文連接地址:https://developer.baidu.com/topic/show/290274

相關文章
相關標籤/搜索