工具化与工程化
工具化与工程化
大部分时候我们谈论到工程化这个概念的时候,往往指的是工具化。但是任何一个通向工程化的道路上都不可避免的会走过一段工具化的道路。笔者最早的接触 Java 的时候用的是 Eclipse,那个时候不懂什么构建工具,不懂发布与部署,每次要用类库都要把 jar 包拷贝到 Libs 目录下。以至于多人协作的时候经常出现依赖相互冲突的问题。后来学会了用 Maven、Gradle、Jenkins 这些构建和 CI 工具,慢慢的才形成了一套完整的工作流程。前端工程化的道路,目标就是希望能用工程化的方法规范构建和维护有效、实用和高质量的软件。
笔者个人感觉的工程化的要素,会有以下几个方面:
- 统一的开发规范(语法/流程/工程结构)与编译工具。实际上考虑到浏览器的差异性,本身我们在编写前端代码时,就等于在跨了 N 个“平台”。在早期没有编译工具的时候,我们需要依赖自己去判断浏览器版本(当然也可以用 jQuery 这样人家封装好的),然后根据不同的版本写大量的重复代码。最简单的例子,就是 CSS 的属性,需要加不同的譬如
-o-
、-moz-
这样的前缀。而这样开发时的判断无疑是浪费时间并且存在了大量的冗余代码。开发规范也是这样一个概念,JavaScript 本身作为脚本语言,语法的严谨性一直比较欠缺,而各个公司都有自己的规范,就像当年要实现个类都有好几种写法,着实蛋疼。 - 模块化/组件化开发。在一个真正的工程中,我们往往需要进行协作开发,之前往往是按照页面来划分,但是会造成大量的重复代码,并且维护起来会非常麻烦。这里的模块化/组件化开发的要素与上面的第一点都是属于开发需求。
- 统一的组件发布与仓库。笔者在使用 Maven 前后会有很大的一个对比感,没有一个统一的中央仓库与版本管理工具,简直就是一场灾难。这样也无法促进开发者之间的沟通与交流,会造成大量的重复造轮子的现象。
- 性能优化与项目部署。前端的错误追踪与调试在早期一直是个蛋疼的问题,笔者基本上每次都要大量的交互才能重现错误场景。另一方面,前端会存在着大量的图片或者其他资源,这些的发布啊命名啊也是个很蛋疼的问题。当我们在构建一个 webapp 的完整的流程时,我们需要一套自动化的代码质量检测方案来提高系统的可靠性,需要一套自动化以及高度适应的项目发布/部署方案来提高系统的伸缩性和灵活性。最后,我们需要减少冗余的接口、冗余的资源请求、提高缓存命中率,最终达到近乎极致的性能体验。
架构的选择是工程化的重要组成部分,并且会深刻影响到工程化中工具的选型与使用。
前端应用程序架构
基本概念
模块化
组件化
状态管理
工程化的前端
选择合适的架构、合适的工具进行项目开发。
工具化与工程化
选择合适的架构
工程化的开发流程
调试与构建
发布与更新
这里面有好几个方面的问题。
- 原生 DOM 操作 vs. 通过框架封装操作。
这是一个性能 vs. 可维护性的取舍。框架的意义在于为你掩盖底层的 DOM 操作,让你用更声明式的方式来描述你的目的,从而让你的代码更容易维护。没有任何框架可以比纯手动的优化 DOM 操作更快,因为框架的 DOM 操作层需要应对任何上层 API 可能产生的操作,它的实现必须是普适的。针对任何一个 benchmark,我都可以写出比任何框架更快的手动优化,但是那有什么意义呢?在构建一个实际应用的时候,你难道为每一个地方都去做手动优化吗?出于可维护性的考虑,这显然不可能。框架给你的保证是,你在不需要手动优化的情况下,我依然可以给你提供过得去的性能。
- 对 React 的 Virtual DOM 的误解。
React 从来没有说过 “React 比原生操作 DOM 快”。React 的基本思维模式是每次有变动就整个重新渲染整个应用。如果没有 Virtual DOM,简单来想就是直接重置 innerHTML。很多人都没有意识到,在一个大型列表所有数据都变了的情况下,重置 innerHTML 其实是一个还算合理的操作… 真正的问题是在 “全部重新渲染” 的思维模式下,即使只有一行数据变了,它也需要重置整个 innerHTML,这时候显然就有大量的浪费。
我们可以比较一下 innerHTML vs. Virtual DOM 的重绘性能消耗: innerHTML: render html string O(template size) + 重新创建所有 DOM 元素 O(DOM size) Virtual DOM: render Virtual DOM + diff O(template size) + 必要的 DOM 更新 O(DOM change)
Virtual DOM render + diff 显然比渲染 html 字符串要慢,但是!它依然是纯 js 层面的计算,比起后面的 DOM 操作来说,依然便宜了太多。可以看到,innerHTML 的总计算量不管是 js 计算还是 DOM 操作都是和整个界面的大小相关,但 Virtual DOM 的计算量里面,只有 js 计算和界面大小相关,DOM 操作是和数据的变动量相关的。前面说了,和 DOM 操作比起来,js 计算是极其便宜的。这才是为什么要有 Virtual DOM:它保证了 1)不管你的数据变化多少,每次重绘的性能都可以接受;2) 你依然可以用类似 innerHTML 的思路去写你的应用。
- 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 快: dbmon (注意 Angular 默认版本无优化,优化过的在下面)
顺道说一句,React 渲染列表的时候也需要提供 key 这个特殊 prop,本质上和 track-by 是一回事。
- 性能比较也要看场合
在比较性能的时候,要分清楚初始渲染、小量数据更新、大量数据更新这些不同的场合。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。
- 总结
以上这些比较,更多的是对于框架开发研究者提供一些参考。主流的框架 + 合理的优化,足以应对绝大部分应用的性能需求。如果是对性能有极致需求的特殊情况,其实应该牺牲一些可维护性采取手动优化:比如 Atom 编辑器在文件渲染的实现上放弃了 React 而采用了自己实现的 tile-based rendering;又比如在移动端需要 DOM-pooling 的虚拟滚动,不需要考虑顺序变化,可以绕过框架的内置实现自己搞一个。
Frontend Architect: 前端架构师
好的程序员能够独立的解决某个技术难题,主动的关心项目进度与潜在瓶颈,能够负责模块小组,合理地分配任务,与项目经理、产品经理、美工、测试、服务端的同事高效包容地沟通。而衡量好的架构师的标准则是整个项目的工程化程度。众所周知,现在前端进入了一种爆炸期,各种技术框架百花齐放,各种应用场景天差地别,前端工程化个人感觉不仅仅是选定某个技术框架、选定代码规范、选定测试方案等等,工程化的根本目标即是以尽可能快的速度实现可信赖的产品。尽可能短的时间包括开发速度、部署速度与重构速度,而可信赖又在于产品的可测试性、可变性以及 Bug 的重现与定位。笔者感觉遇见的最大的问题在于需求的不明确、接口的不稳定与开发人员素质的参差不齐。先不论技术层面,项目开发中我们在组织层面的希望能让每个参与的人无论水平高低都能最大限度的发挥其价值,每个人都会写组件,都会写实体类,但是他们不一定能写出合适的优质的代码。另一方面,好的架构都是衍化而来,不同的行业领域、应用场景、界面交互的需求都会引发架构的衍化。我们需要抱着开放的心态,不断地提取公共代码,保证合适的复用程度。同时也要避免过度抽象而带来的一系列问题。当我们落地到前端时,笔者在历年的实践中感受到以下几个突出的问题:
-
前后端业务逻辑衔接:在前后端分离的情况下,前后端是各成体系与团队,那么前后端的沟通也就成了项目开发中的主要矛盾之一。前端在开发的时候往往是根据界面来划分模块,命名变量,而后端是习惯根据抽象的业务逻辑来划分模块,根据数据库定义来命名变量。最简单而是最常见的问题譬如二者可能对于同意义的变量命名不同,并且考虑到业务需求的经常变更,后台接口也会发生频繁变动。此时就需要前端能够建立专门的接口层对上屏蔽这种变化,保证界面层的稳定性。
-
多业务系统的组件复用:当我们面临新的开发需求,或者具有多个业务系统时,我们希望能够尽量复用已有代码,不仅是为了提高开发效率,还是为了能够保证公司内部应用风格的一致性。
-
多平台适配与代码复用:在移动化浪潮面前,我们的应用不仅需要考虑到 PC 端的支持,还需要考虑微信小程序、微信内 H5、WAP、ReactNative、Weex、Cordova 等等平台内的支持。这里我们希望能够尽量的复用代码来保证开发速度与重构速度,这里需要强调的是,笔者觉得移动端和 PC 端本身是不同的设计风格,笔者不赞同过多的考虑所谓的响应式开发来复用界面组件,更多的应该是着眼于逻辑代码的复用,虽然这样不可避免的会影响效率。鱼与熊掌,不可兼得,这一点需要因地制宜,也是不能一概而论。