写点什么

San 为什么会这么快

  • 2019-11-28
  • 本文字数:13572 字

    阅读完需:约 45 分钟

San 为什么会这么快

前言


一个 MVVM 框架的性能进化之路 https://github.com/baidu/san/


性能一直是 框架选型 最重要的考虑因素之一。San 从设计之初就希望不要因为自身的短板(性能、体积、兼容性等)而成为开发者为难的理由,所以我们在性能上投入了很多的关注和精力,效果至少从 benchmark 看来,还不错。



近 2 年以前,我发了一篇 San - 一个传统的 MVVM 组件框架。对 San 设计初衷感兴趣的同学可以翻翻。我一直觉得框架选型的时候,了解它的调性是非常关键的一点。


不过其实,大多数应用场景的框架选型中,知名度 是最主要的考虑因素,因为 知名度 意味着你可以找到更多的人探讨、可以找到更多周边、可以更容易招聘熟手或者以后自己找工作更有优势。所以本文的目的并不是将你从三大阵营(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 解析 都是发生在第一个实例创建时,那他们有什么区别呢?


template 解析 生成的 ANode 是一个可以被 JSON stringify 的对象。


由于 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 发挥作用的地方也会进行详细说明。

  • 视图创建过程


图创建是个很常规的过程:基于初始的 数据 和 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.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 是:


  • 组件上的一个属性,组件的数据状态容器

  • 一个对象,提供了数据读取和操作的方法。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’) 带来的视图刷新,遍历过程与数据变化信息的传递经过了哪些节点。左侧最大的点是实际需要更新的节点,红色的线代表遍历过程经过的路径,红色的小圆点代表遍历到的节点。可以看出,虽然需要进行视图更新的节点只有一个,但所有的节点都被遍历到了。



点遍历中断


从上图中不难发现,与实际的更新行为相比,遍历确定更新节点的消耗要大得多。所以为遍历过程减负,是一个必要的事情。San 在这方面是怎么做的呢?


首先,预热 过程生成的 hotspot 对象中,有一项 data,包含了节点及其子节点对数据引用的摘要信息。See preheat-a-node.js


然后,在视图更新的节点树遍历过程中,使用 hotspot.data 与数据变化信息进行比对。结果为 false 时意味着数据的变化不会影响当前节点及其子节点的视图,就不会执行自身属性的更新,也不会继续向下遍历。遍历过程在更高层的节点被中断,节省了下层子树的遍历开销。


See element.js#241 changes-is-in-data-ref.jsElement.prototype._update = function (changes) {var dataHotspot = this.aNode.hotspot.data;if (dataHotspot && changesIsInDataRef(changes, dataHotspot)) {// ...    }};
复制代码


有了节点遍历中断的机制,title 数据修改引起视图变更的遍历过程如下。可以看到,灰色的部分都是由于中断,无需到达的节点。


有没有似曾相识的感觉?是不是很像 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};
// mutableobj.b.b1 = 5;
// immutableobj = 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


// SETchangeObj = {    type: DataChangeType.SET,    expr,value,    option};
// SPLICEchangeObj = { type: DataChangeType.SPLICE, expr, index, deleteCount,value, insertions, option};
复制代码


San 提供的数据操作方法里,很多是针对数组的,并且大部分与 JavaScript 原生的数组方法是一致的。从 changeObj 的类型可以容易看出,最基础的方法只有 splice 一个,其他方法都是 splice 之上的封装。


  • push

  • pop

  • shift

  • unshift

  • remove

  • removeAt

  • splice

  • 基于数据变化信息的视图更新机制,意味着数据操作的粒度越细越精准,视图更新的负担越小性能越高。


// bad performancethis.data.set('list[0]', {    name: 'san',    id: this.data.get('list[0].id')});
// good performancethis.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


先执行删除 disposeChildren遍历 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#L22element.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

  • 从上图的更新过程可以看出,子项更新的更新过程能精确处理最少的节点。数据变更时精准地更新节点是 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 的项可能就少多了。对应到常见的视图变更场景,该优化手段都能发挥较好的作用。


  • 添加:无论在什么位置添加几项,该优化都能发挥较大作用

  • 删除:无论在什么位置删除几项,该优化都能发挥较大作用

  • 更新部分项:头尾都有更新时,该优化无法发挥作用。也就是说,对于长度固定的列表有少量新增项时,该优化无用。不过 trackBy 过程在该场景下,性能消耗不高

  • 更新全部项:trackBy 过程在该场景下,性能消耗很低

  • 交换:相邻元素的交换,该优化都能发挥较大作用。交换的元素间隔越小,该优化发挥作用越大

  • 从 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_FILTERSfilterName;

  • }

  • 再比如,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 performanceeach(expr.segs.length, function (seg) {    buf += seg.value || evalExpr(seg, data, owner);});
// good performancefor (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 performancechange = extend(    extend({}, change),    {        expr: createAccessor(this.itemPaths.concat(changePaths.slice(forLen + 1)))    });
// good performancechange = 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 performanceextend(this, options);
// good performancethis.owner = options.owner;this.scope = options.scope;this.el = options.el;
复制代码


最后


性能对于一个框架来说,是非常重要的事情。应用开发的过程通常很少会关注框架的实现;而如果框架实现有瓶颈,应用开发工程师其实是很难解决的。开发一时爽,调优火葬场的故事,发生得太多了。


San 在性能方面做了很多工作,但是看下来,其实没有什么非常深奥难以理解的技术。我们仅仅是觉得性能很重要,并且尽可能细致的考虑和实现。因为我们不希望自己成为应用上的瓶颈,也不希望性能成为开发者在选型时犹豫的理由。


如果你看到这里,觉得 San 还算有诚意,或者觉得有收获,给个 Star 呗。 https://github.com/baidu/san/


Baidu App Tech


丨更多推荐


百度 App 网络深度优化系列《一》DNS 优化


百度 App 网络深度优化系列《二》连接优化


百度 App Android H5 首屏优化实践


百度 App iOS 工程化实践: EasyBox 破冰之旅


本文转载自百度 App 技术。


原文链接:


https://mp.weixin.qq.com/s/9wFwEqyID-P4IiASqjZq4g


2019-11-28 08:003733

评论

发布
暂无评论
发现更多内容

智慧党建平台搭建,组织部干部任免系统开发

4.16-17 | 阿里云技术大咖分享新内容新交互时代下的新技术、新机会

阿里云CloudImagine

阿里云 音视频 WebRTC 直播架构

Flume拦截器实战

大数据技术指南

flume 4月日更

金三拿到5个offer,全靠这份Alibaba内部Java面试指南

Java 编程 程序员 架构 面试

颠覆微服务认知:深入思考微服务的七个主流观点

xcbeyond

微服务 4月日更

ThreadLocal超深度源码解读,为什么要注意内存泄漏?不要道听途说,源码底下见真知!

徐同学呀

ThreadLocal Java源码

百度搜索与推荐引擎的云原生改造 | Geek大咖说第一期

百度Geek说

MOOM集团模式

飞亚科技

干货 | Redis进阶笔记

ninetyhe

redis 缓存 原理分析

混战的低代码江湖,如何区分「李逵」和「李鬼」?

ToB行业头条

yyds!用飞桨玩明日方舟

百度大脑

百度 飞桨

聪明人的训练(十三)

Changing Lin

4月日更

一文搞懂MySQL体系架构!!

冰河

MySQL 数据库 程序员 系统架构 数据存储

cat监控http请求-CatFilter

Java个体户

监控 cat

思码逸Merico 完成 A 轮融资,发布企业版 3.0 新产品,拓展研发效能边界

xiaotan

InfoQ 的朋友们

阿里P9纯手打的JDK文档太吊了,微软都开始学习了!

Java架构师迁哥

BOE(京东方)2020年报发布:营收1355.53亿元  净利润大幅增长162.46%

爱极客侠

阿里内部疯传的《JDK源码剖析手册》!在GitHub上已高达百万访问量!

Java架构之路

Java 程序员 架构 面试 编程语言

2021年金三银四跳槽季,呕心沥血整理出Java10W字面经,首次公布!

Java架构之路

Java 程序员 架构 面试 编程语言

线上500万数据查询时间在37秒,作者将问题解决了,我却看到了更大的坑

谙忆

活久见,58同城居然也有这么牛的Java内部教程

Java架构师迁哥

阿里P8总结的1530页Java编程核心思想笔记,Github访问破百万!

Java架构之路

Java 程序员 架构 面试 编程语言

消息队列(如 Kafka 等)的应用场景

五分钟学大数据

kafka 消息队列 4月日更

如何完成日千万级别以上的订单对账(二)

谙忆

魔晶面膜

飞亚科技

java基础集合之HashMap

false℃

大数据文件浏览器

聚变

缓存系统设计精要

比伯

Java 互联网 面试 程序人生 技术宅

龙归科技|邀您参与全球「身份管理日」

龙归科技

如何完成日千万级别以上的订单对账(一)

谙忆

低代码之所以能火,离不开这些原因!

优秀

低代码

San 为什么会这么快_语言 & 开发_百度App技术_InfoQ精选文章