严格的模板引擎的定义,输入模板字符串 + 数据,得到渲染过的字符串。实现上,从正则替换到拼 function 字符串到正经的 AST 解析各种各样,但从定义上来说都是差不多的。字符串渲染的性能其实也就在后端比较有意义,毕竟每一次渲染都是在消耗服务器资源,但在前端,用户只有一个,几十毫秒的渲染时间跟请求延迟比起来根本不算瓶颈。倒是前端的后续更新是字符串模板引擎的软肋,因为用渲染出来的字符串整个替换 innerHTML 是一个效率很低的更新方式。所以这样的模板引擎如今在纯前端情境下已经不再是好的选择,意义更多是在于方便前后端共用模板。
相比之下 Angular 是 DOM-based templating,直接解析 live DOM 来提取绑定,如果是字符串模板则是先转化成 live DOM 再解析。数据更新的时候直接通过绑定做局部更新。其他 MVVM 如 Knockout, Vue, Avalon 同理。缺点是没有现成的服务端渲染,要做服务端渲染基本等于重写一个字符串模板引擎。不过其实也不难,因为 DOM-based 的模板都是合法的 HTML,直接用现成的 HTML parser 预处理一下,后面的工作就相对简单了。
框架里面也有在前端解析字符串模板到静态 AST 再生成 live DOM 做局部更新的,比如 Ractive 和 Regular。这一类的实现因为解析到 AST 的这步已经在框架内部完成了,所以做服务端渲染几乎是现成的,另外也可以在构建时进行预编译。
然后说说 React,JSX 根本就不是模板,它就是带语法糖的手写 AST,并且把语法糖的处理放到了构建阶段。因为运行时不需要解析,所以 virtual DOM 可以每次渲染都重新生成整个 AST,在客户端用 diff + patch,在服务端则直接 serialize 成字符串。所有其他 virtual DOM 类方案同理。像 virtual-dom,mithril 之类的连语法糖都不带。
最后说下我的看法:如果是静态内容为主,那就直接服务端渲染好了,首屏加载速度快。如果是动态的应用界面,那就不应该用拼模板的思路去做,而是用做应用的架构(MV*,组件树)思路去做。3. MVVM vs. Virtual DOM
相比起 React,其他 MVVM 系框架比如 Angular, Knockout 以及 Vue、Avalon 采用的都是数据绑定:通过 Directive/Binding 对象,观察数据变化并保留对实际 DOM 元素的引用,当有数据变化时进行对应的操作。MVVM 的变化检查是数据层面的,而 React 的检查是 DOM 结构层面的。MVVM 的性能也根据变动检测的实现原理有所不同:Angular 的脏检查使得任何变动都有固定的 O(watcher count) 的代价;Knockout/Vue/Avalon 都采用了依赖收集,在 js 和 DOM 层面都是 O(change):- 脏检查:scope digest O(watcher count) + 必要 DOM 更新 O(DOM change)
- 依赖收集:重新收集依赖 O(data change) + 必要 DOM 更新 O(DOM change)
可以看到,Angular 最不效率的地方在于任何小变动都有的和 watcher 数量相关的性能代价。但是!当所有数据都变了的时候,Angular 其实并不吃亏。依赖收集在初始化和数据变化的时候都需要重新收集依赖,这个代价在小量更新的时候几乎可以忽略,但在数据量庞大的时候也会产生一定的消耗。
MVVM 渲染列表的时候,由于每一行都有自己的数据作用域,所以通常都是每一行有一个对应的 ViewModel 实例,或者是一个稍微轻量一些的利用原型继承的 "scope" 对象,但也有一定的代价。所以,MVVM 列表渲染的初始化几乎一定比 React 慢,因为创建 ViewModel / scope 实例比起 Virtual DOM 来说要昂贵很多。这里所有 MVVM 实现的一个共同问题就是在列表渲染的数据源变动时,尤其是当数据是全新的对象时,如何有效地复用已经创建的 ViewModel 实例和 DOM 元素。假如没有任何复用方面的优化,由于数据是 “全新” 的,MVVM 实际上需要销毁之前的所有实例,重新创建所有实例,最后再进行一次渲染!这就是为什么题目里链接的 angular/knockout 实现都相对比较慢。相比之下,React 的变动检查由于是 DOM 结构层面的,即使是全新的数据,只要最后渲染结果没变,那么就不需要做无用功。
Angular 和 Vue 都提供了列表重绘的优化机制,也就是 “提示” 框架如何有效地复用实例和 DOM 元素。比如数据库里的同一个对象,在两次前端 API 调用里面会成为不同的对象,但是它们依然有一样的 uid。这时候你就可以提示 track by uid 来让 Angular 知道,这两个对象其实是同一份数据。那么原来这份数据对应的实例和 DOM 元素都可以复用,只需要更新变动了的部分。或者,你也可以直接 track by $index 来进行 “原地复用”:直接根据在数组里的位置进行复用。在题目给出的例子里,如果 angular 实现加上 track by $index 的话,后续重绘是不会比 React 慢多少的。甚至在 dbmonster 测试中,Angular 和 Vue 用了 track by $index 以后都比 React 快: (注意 Angular 默认版本无优化,优化过的在下面)
顺道说一句,React 渲染列表的时候也需要提供 key 这个特殊 prop,本质上和 track-by 是一回事。
4. 性能比较也要看场合
在比较性能的时候,要分清楚初始渲染、小量数据更新、大量数据更新这些不同的场合。Virtual DOM、脏检查 MVVM、数据收集 MVVM 在不同场合各有不同的表现和不同的优化需求。Virtual DOM 为了提升小量数据更新时的性能,也需要针对性的优化,比如 shouldComponentUpdate 或是 immutable data。
- 初始渲染:Virtual DOM > 脏检查 >= 依赖收集
- 小量数据更新:依赖收集 >> Virtual DOM + 优化 > 脏检查(无法优化) > Virtual DOM 无优化
- 大量数据更新:脏检查 + 优化 >= 依赖收集 + 优化 > Virtual DOM(无法/无需优化)>> MVVM 无优化
不要天真地以为 Virtual DOM 就是快,diff 不是免费的,batching 么 MVVM 也能做,而且最终 patch 的时候还不是要用原生 API。在我看来 Virtual DOM 真正的价值从来都不是性能,而是它 1) 为函数式的 UI 编程方式打开了大门;2) 可以渲染到 DOM 以外的 backend,比如 ReactNative。
5. 总结
以上这些比较,更多的是对于框架开发研究者提供一些参考。主流的框架 + 合理的优化,足以应对绝大部分应用的性能需求。如果是对性能有极致需求的特殊情况,其实应该牺牲一些可维护性采取手动优化:比如 Atom 编辑器在文件渲染的实现上放弃了 React 而采用了自己实现的 tile-based rendering;又比如在移动端需要 DOM-pooling 的虚拟滚动,不需要考虑顺序变化,可以绕过框架的内置实现自己搞一个。任何情况下你问『我们应不应该用框架 X 换掉框架 Y』,这都不是单纯的比较 X 和 Y 的问题,而得先问以下问题:
1. 现有的项目已经开发了多久?代码量多大? 2. 现有的项目是否已经投入生产环境? 3. 现有的项目是否遇到了框架相关的问题,比如开发效率、可维护性、性能?换框架是否能解决这些问题?
(1) 事关替换的成本,(2) 事关替换的风险,(3) 事关替换的收益。把这些具体信息放在台面上比较,才有可能得出一个相对靠谱的结论。
--- (1) 跟 (2) 要具体情况具体分析,所以就不谈了。至于 (3),以下是 Vue 有而 ko 没有的:
更好的性能,CLI,Webpack 深度整合,热更新,模板预编译,中文文档,官方路由方案,官方大规模状态管理方案,服务端渲染,render function / JSX 支持,Chrome 开发者插件,更多的社区组件和教程,尤其是中文内容。
这里没有什么说 ko 不好的意思。作为前端 mvvm 的鼻祖,ko 对 Vue 的设计显然有很多启发,但是今天的 Vue 在各方面都实实在在地比 ko 强。如果上新项目,我想不出什么继续用 ko 的理由。
people.forEach(function (dude) { dude.pickUpSoap();});复制代码
var wallets = people.map(function (dude) { return dude.wallet;});复制代码
var totalMoney = wallets.reduce(function (countedMoney, wallet) { return countedMoney + wallet.money;}, 0);复制代码
var fatWallets = wallets.filter(function (wallet) { return wallet.money > 100;});复制代码
要说简单,async 是最简单的,只是在 callback 上加了一些语法糖而已。在不是很复杂的用例下够用了,前提是你已经习惯了 callback 风格的写法。
then.js 上手也是比较简单的,因为也是基于 callback 和 continuation passing,并不引入额外的概念,比起 async,链式 API 更流畅,个人挺喜欢的。我挺久以前写过一个在 Node 里面跑 shell 命令的小工具,思路差不多:
Callback-based 方案的最大问题在于异常处理,每个 callback 都得额外接受一个异常参数,发生异常就得一个一个往后传,异常发生后的定位很麻烦。
ES6 Promise, Q, Bluebird 核心都是 Promise,缺点嘛就是必须引入这个新概念并且要用就得所有的地方都用 Promise。对于 Node 的原生 API,需要进行二次封装。Q 和 Bluebird 都是在实现 Promise A+ 标准的基础上提供了一些封装和帮助方法,比如 Promise.map 来进行并行操作等等。Promise 的一个问题就是性能,而 Bluebird 号称速度是所有 Promise 库里最快的。ES6 Promise 则是把 Promise 的包括进 js 标准库里,这样你就不需要依赖第三方实现了。
关于 Promise 能够如何改进异步流程,建议阅读:
co 是 TJ 大神基于 ES6 generator 的异步解决方案。要理解 co 你得先理解 ES6 generator,这里就不赘述了。co 最大的好处就是能让你把异步的代码流程用同步的方式写出来,并且可以用 try/catch:
co(function *(){ try { var res = yield get('http://badhost.invalid'); console.log(res); } catch(e) { console.log(e.code) // ENOTFOUND }})()复制代码
但用 co 的一个代价是 yield 后面的函数必须返回一个 Thunk 或者一个 Promise,对于现有的 API 也得进行一定程度的二次封装。另外,由于 ES6 generator 的支持情况,并不是所以地方都能用。想用的话有两个选择:
1. 用支持 ES6 generator 的引擎。比如 Node 0.11+ 开启 --harmony flag,或者直接上 iojs;
2. 用预编译器。比如 Babel () , Traceur () 或是 Regenerator () 把带有 generator 的 ES6 代码编译成 ES5 代码。(延伸阅读:基于 ES6 generator 还可以模拟 go 风格的、基于 channel 的异步协作:)
但是 generator 的本意毕竟是为了可以在循环过程中 yield 进程的控制权,用 yield 来表示 “等待异步返回的值” 始终不太直观。因此 ES7 中可能会包含类似 C# 的 async/await :
async function showStuff () { var data = await loadData() // loadData 返回一个 Promise console.log(data) // data 已经加载完毕}async function () { await showStuff() // async 函数默认返回一个 Promise, 所以可以 await 另一个 async 函数 // 这里 showStuff 已经执行完毕}复制代码
可以看到,和用 co 写出来的代码很像,但语意上更清晰。因为本质上 ES7 async/await 就是基于 Promise + generator 的一套语法糖。深入阅读:
想要今天就用 ES7 async/await 也是可以的!Babel 的话可以用配套的 asyncToGenerator transform:
Traceur 和 Regenerator 对其也已经有实验性的支持了。#!/usr/bin/env node复制代码
"bin": { "mytool": "main.js"}复制代码