很荣幸有机会和大家分享自己在前端工作中的一些经验。更高兴能邀请我的同事颜海镜同我一起做这件事情。其实经验说不上,只是希望能更多的和大家一起交流、学习。
为什么要讲“面对前端六年历史代码,如何接入并应用ES6解放开发效率”这个主题呢?其实相信很多人认为ES6已经不再新鲜。在前端迭代迅速的今天,会不会有些“老生常谈”?我理解并不是这样的,因为很多人其实对ES6的理解主要集中在新特性、语言用法等层面上。这些内容是大部分学习者都能通过共享得到的。但是,对于ES6往往缺少了在实际大型工程中的接入和应用。尤其维护开发PV亿级以上的产品,并不是每个人都有机会的。
所以,相比于ES6语言本身,我更希望能介绍一些工程上、设计上的想法,以及接入ES6过程中不为人知,但又至关重要的的“细枝末节”上。同时,希望想了解BAT公司技术项目流程从立项到落地的读者、参与者能有所收获。
全篇文章分为五个部分:
-
对前端发展的态度和看法。
-
大型互联网公司对新技术的前期调研和评估。
-
我们面临的历史背景。
-
当我们说调研时到底在说什么。
-
为什么非要折腾ES6。
-
正确解锁ES6开发姿势。
-
使用Babel进行ES6编译。
-
传说中的“最佳实践”。
-
一个设计实例。
-
ES6带来的困扰和展望。
篇幅并不短,其中还有一些ES6“黑魔法”和Babel编译分析,以及兼容性处理等内容。
第一部分:对前端发展的态度和看法
借用查尔斯·狄更斯在《双城记》中的不朽开篇来形容如今的前端开发,我觉得再合适不过了:
这是最好的时代,这是最坏的时代,这是智慧的时代,这是愚蠢的时代;这是信仰的时期,这是怀疑的时期;这是光明的季节,这是黑暗的季节;这是希望之春,这是失望之冬;人们面前有着各样事物,人们面前一无所有;人们正在直登天堂;人们正在直下地狱。
没错,我们脱离了之前“刀耕火种”的“脚本玩具”时期。伴随着nodeJS的强势崛起,社区交流的如火如荼,模块化开发的如虎添翼,HTML5的攻城掠地,彻底迎来了 前端“工业革命”的爆发。
同时,这也意味着大量的技术更迭。即便没有“南朝四百八十寺,多少楼台烟雨中”那般夸张,也足以让各阶段前端开发者疲于奔命,应接不暇。举个例子,想想我们也许刚熟悉了CommonJS,又要去了解AMD、CMD,稍不留神,就在2017年5月这个初夏:ES6 module要开始在浏览器端实现了!
好吧,也正好以此“ES6发展的标志性事件”来为这次分享拉开序幕,我们今天就要谈谈:ES6在大型项目中的接入和发展的方方面面。所谓“沉舟侧畔千帆过,病树前头万木春”,古诗中以“沉舟”、“病树”比喻纷扰和困惑,但却并不尤怨,反而表现的是一种对世事变迁和潮起潮落的豁达开朗。同样我们认为ES6代表了未来,对未来理应拥抱。
全文看下来,也许你会理解“所有的发展都是站在历史的基础上”,停止不前的“沉舟”也有指引千帆航向的意义。“合抱之木,生于毫末;九层之台,起于垒土”。技术更迭中,深厚的基础是多么重要。
这次分享,我们不会去把时间浪费在ES6新特性的讲解和语法细节层面上,这些内容毕竟都可以轻松且“免费”地找到。比如阮一峰老师的书中讲解就很透彻了。
我们会把重点放在ES6工程接入和开发维护上,背靠大流量的产品,这些不是所有人都能接触到的。
第二部分:新技术的前期调研和评估
我们面临的历史背景
先从背景说起,我们负责的项目是百度知识搜索部某明星产品,该产品代码历史在6年以上。在很多大型互联网公司里,这种“历史负担”其实屡见不鲜。也就不奇怪为什么知乎上会有人质疑:“QQ空间的前端技术水平如何?”,“为什么很牛的互联网公司代码却不能看?”等等。
在我们这边,历史问题主要集中在以下几点:
-
使用古老Tangram类库,开发体验不友好。
-
构建工具以FIS为主,但是版本不统一。
-
模块设计不合理,内外耦合严重。
-
JS,需要兼容IE6+。
这些问题都会对ES6接入,造成一些潜在障碍。这就需要对新技术进行更加合理的评估和调研。
当我们说调研时到底在说什么?
也许会有一些读者认为“这有什么好调研评估的,不就是新的特性学习吗?”,其实在大型工程中这样的想法是片面的。
首先,对于新特性的熟悉,当然是最基本的。
此外,对于保证PV过亿的大型线上产品,就要求对ES6的方方面面面要足够了解。会一些let,const,箭头函数,模块化等语言层面知识还是不够的。
这就说明,在新技术前期调研工作当中,新特性、新语法的学习仅仅是很小的一方面。同样重要的是执行环境保障、生产配置、线下开发流程、线上bug跟踪等各环节内容。
比如,这个项目的前期调研就包括但不限于:
-
如何兼容旧版本浏览器(IE6+)?
-
编译器/转换器是否真能摆平一切,应用是否完全可靠?
-
编译器/转换器面临版本更新怎么办?
-
编译器/转换器的接入对于现有的代码是否有影响?
-
编译器/转换器的编译结果对于现有的代码是否有影响,能否完全兼容?
-
引入ES6后开发效率是否真的可以提升?
-
就算开发效率确实提升了,上线的代码量是不是更大了?对于产品性能是否有影响?
-
所有可能产生的负面影响如何回滚?谁来担责?
-
ES6现在处于什么阶段,是否会被废除,就像第四版本一样?
-
对于ECMAScript语言标准的提案分为哪几个阶段?
等等一切可能影响产品稳定或存在潜在Bug的问题……
为什么非要折腾ES6?
这个问题其实就是“如何评估ES6?”,“ES6的接入能带来哪些收益”。或者更直白一些:“你靠什么来说服技术经理,分配给你时间、人力去搞ES6?”毕竟大公司里的资源申(争)请(夺),都要拿收益来说话。
这就需要以自己所负责的业务为背景,在充分调研的基础上做出合理评估。
最终我们认为从以下几个角度来看,ES6的推广势在必行:
-
解放开发效率。
-
新特性的合理使用,优雅而简洁。
-
减少第三方库的依赖。
-
可维护性提升,代码量减少。
-
-
面向未来。
-
向标准靠拢。
-
官方支持。
-
“迟早要学”。
-
-
其他方面。
-
提升技术先进性。
-
促进技术交流,提高技术氛围。
-
“编程激情”。
-
整合部分历史代码的好机会。
-
面试中的加分项。
-
以上是出于我们自身产品开发的角度。同时,整个前端在ES6发展环境和普及率上,我们参考了ponyfoo.com在2015年底做的一个知名调查:JavaScript Developer Survey Results,该调查以5千多个前端开发者为背景,得出以下结论:
-
ES6普及率?
-
ES6是否是一个很重大的版本进步?
-
你都使用哪些ES6新特性?
所以,不管是因为大势所趋还是从自身收益出发,我们决定了ES6接入作为该年度最大的技术项目之一。
第三部分:正确解锁ES6开发姿势
使用Babel进行ES6编译
目前各大浏览器和开发环境对ES6的支持情况参差不齐,我们的产品对浏览器兼容性要求又比较高。所以,当然不能荒谬地“裸写”ES6代码,发布上线。因此,在实际项目开发中,需要降级为ES5语法以兼容各平台。
幸好有几款工具可以将ES6语法转换成ES5,让我们在使用ES6新特性编写代码的同时,不需要考虑具体的兼容性情况。比较知名的两款编译器为:
-
Babel
-
Traceur
我们选择了Babel 5.x版本,主要是因为以下几个原因:
-
Babel对ES6的支持程度比其它同类更高或相当。
-
Babel拥有完善的文档和较好体验的在线编译环境。
-
Babel使用广泛,用户基础好。
关于第一点原因的主要数据支持可以在Bebel官网,我们可以看到不同版本Babel对ES6跟进和支持的情况;
另外,关于在线编译平台,可以访问官网:进行体验,这对于研究Babel编译结果十分方便。
关于Babel的接入和使用方法,社区上的资料很多,这里不再进行科普浪费时间了。以下,从几个关键性的工程问题进行延伸。
配合构建工具
首先,因为我们使用的是百度自己的FIS来做前端构建工具,所以只需要在FIS的配置文件中加入依赖,并安装插件就可直接使用。这一切,就像社区上使用更多的webpack一样。
babel-polyfill
同样需要说明的是,Babel默认只转换新的JavaScript语法(syntax),而不转换新的API。
比如:Babel可以编译let, const等特性,但是诸如Iterator、Generator、Reflect、Promise等全局对象,或者数组实例的find这些新的方法并不会得到编译。如果想让这个方法运行,必须使用babel-polyfill,同时要保证这个polyfill在你的所有其他脚本之前就要加载执行。同时,因为编译产出为ES5代码,所以又要处在ES5垫片ES5-shim,ES5-sham之后。
实际情况中,我们放弃了使用babel-polyfill,这是出于减少JS引用的考虑。我们页面已经加载很多JS了,并且babel-polyfill由于其特殊性(抢先执行),难以和其他业务脚本打包。再者,我们认为ES6新增的这些方法的必要性并不绝对。就像上图统计的那样,ES6新特性被广泛使用的大多是let, const, 解构,箭头函数等,这些使用默认Babel编译就已经可以达到要求了。
当然,Promise这个使用广泛的特性我们专门引入了单独的polyfill来处理。这样的定制化完全可以满足需求。
babel-runtime
babel-runtime是为了减少重复代码而生的。Babel编译生成的代码,可能会用到一些_extend(),classCallCheck()之类的工具函数(后文在分析编译结果部分会有介绍)。默认情况下,这些工具函数的代码会被引入在编译后的文件中。如果存在多个文件,那每个文件都有可能含有一份重复工具函数的代码。
这种冗余一定是我们不能忍的。
babel-runtime插件能够将这些工具函数的代码转换成require语句,指向为对babel-runtime的引用,如 :
require('babel-runtime/helpers/classCallCheck');
这样,classCallCheck这个工具函数的代码就不需要在每个文件中都存在了。当然,最终你需要利用webpack之类的打包工具,将runtime代码打包到目标文件中。
但是要注意,这是Babel 6版本才引入的,对我们来说,这就面临一个关于“Babel版本升级部署”的问题。
关于这个插件的更多介绍,同样可以在官方网站中找到。
Babel的部署和升级
在真正部署Babel的前前后后,我和我的同事针对每一个ES6特性的编译稳定性都进行了严密的测试。测试包括了验证黑盒输出情况和不同浏览器的支持情况,以确保上线后的万无一失。
另一方面,我们在使用的Babel版本就如上所说,为5.x,当然Babel在社区的蓬勃发展和自身定位的调整,使得自身版本更新换代也非常频繁。同时,随着越来越多的库升级至babel6,将我们的项目升级至babel6似乎也有必要。这样的升级工作想想也确实头疼,尤其是要保证线上代码的稳定运行。
截止目前为止,我们还未对Babel进行升级,因为这个需求还并不迫在眉睫。但是,着眼于未来还是很有必要的。我们及时关注了Babel 6.x版本带来的新变化。这方面对于大家的建议其实只有一个,就是紧盯官网+快速调研。点击这里会把大家链接到Babel官方博客,里面同步了每一次更新的细枝末节,内容非常详尽。
同时,你可能会问,那我们就保持初始版本不去升级,岂不是一劳永逸了吗?
当然不是这样,我认为,每一个版本的迭代和演进自然有其原因。如果一直固守成规,不管是在代码组织上和工程化上都会吃亏。除了刚才提到的babel-runtime插件,新版本的Babel(5.x-6.x)收益还体现在:
-
性能提升:据说compile速度提升20%。
-
可配置的插件:更强的灵活性,以及更简单的插件API.
-
更简洁的配置。
选择编译和其他
在进行ES6编译的同时,对于大量的历史代码文件,我们不会进行ES6的翻新重写。这些历史代码因此就不需要使用Babel进行编译。为此,我们使用了文件后缀名来进行区分,并在构建工具的配置文件中进行正则匹配,达到选择性编译的效果。最终的规范是,ES6代码统一以.es为后缀名。
最后,Babel社区的蓬勃发展,导致“你以为的Babel”其实已经不再是那个Babel了;同时,Babel知识的广泛性远远超乎了很多人的想象,比如Babel编译的loose模式、normal模式;比如Babel依赖的引擎babylon;比如babylon fork的acorn;比如Babel将源码转换AST的理解等等。很多东西其实我研究的还只是皮毛,但是不到浏览器广泛支持ES6的一天,不到摆脱兼容性需求的一天,恐怕我们是脱离不开Babel了。
传说中的“最佳实践”
在ES6大量的新特性中,我们推荐并有广泛应用的包括但不限于:
-
默认参数
-
模版表达式
-
多行字符串
-
解构赋值
-
改进的对象表达式
-
箭头函数 =>
-
Promise
-
块级作用域的let和const
-
类
-
模块化
当然还有很多优秀的新特性,但是在应用中频率相对较少,不再一一列出。
我认为,一切所谓的最佳实践都要依赖基础。在抛出几个“奇技异巧“之前,我想从一个简单的例子说起。
const例子:
举一个简单的例子(出自阮一峰ES6一书),可能大家都了解const声明一个只读的常量。一旦声明,常量的值就不能改变。
const a = 4;
a; // 4
a = 3;
// TypeError: Assignment to constant variable.
为此,我们可以延伸出:const声明的变量不得改变值,这意味着,const一旦声明变量,就必须立即初始化,不能留到以后赋值。
const b;
// SyntaxError: Missing initializer in const declaration
同时,我们还要注意:const的作用域与let命令相同:只在声明所在的块级作用域内有效。
因而,它也不存在常量提升的概念。
但是,还需要了解的是:
const实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址不得改动。
对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。
但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指针,const只能保证这个指针是固定的,至于它指向的数据结构是不是可变的,就完全不能控制了。
const foo = {};
// 为 foo 添加一个属性,可以成功
foo.prop = 123;
foo.prop // 123
// 将 foo 指向另一个对象,就会报错
foo = {}; // TypeError: "foo" is read-only
上面代码中,常量foo储存的是一个地址,这个地址指向一个对象。不可变的只是这个地址,即不能把foo指向另一个地址,但对象本身是可变的,所以依然可以为其添加新属性。
所以,仅仅就是一个声明常量的const,里边牵扯出的基础内容却很多。这就需要在掌握ES6基本用法的同时,需要有更强大的基础概念才能进一步提升理解。
这里,给大家留一个思考题目: 如何真的讲一个对象冻结?
ES6黑魔法:
其实我想大家对ES6特性越来越熟悉,以及社区的大力宣传,一些ES6黑魔法已经“非常平常”了。
比如,扩展运算符结合解构赋值,除了“你想象的那种用法”外,它还可以优雅完成:
-
合并数组
arr1.push(...arr2); // 把arr2合并到arr1之后
arr1.unshift(...arr2); // 把arr2合并到arr1之前
let arr2 = [1, 2, ...arr1, 4]; // 数组内合并数组
复制数组
let arr2 = [...arr1]; // 相当于arr1.slice()
把伪数组转为数组
[...document.querySelectorAll('div')]
交换两个变量值
[a, b] = [b, a]; // 不再需要中间变量
等等。。。
具体可见这里。或者英文好的可以戳这里: Six nifty ES6 tricks
Babel到底编译成了什么?
这是一个很关键的问题。也是正确使用ES6的高难度姿势。
因为我们所有的ES6代码都依赖Babel编译,所以如果你不去了解它的编译产出,那么最后上线的代码都是“心里没底”的。
举例来说,我刚才提到的const,在经过Babel编译后其实一律换成var;
可能你紧接着会问:“那如何保证不变性呢?”,原因就在于如果你在源码中第二次修改const常量的值,babel编译会直接报错。
这是一个比较轻量甚至取巧的例子。接下来, 我们再来看看class+extends的编译情况。
Javascript实现OOP其实一直以来都是热门话题,这些争议性的内容我们不去讨论。先来看看Babel的实现过程。
class Person {
constructor (){
this.type = 'person'
}
}
会被编译为:
var Person = function Person() {
_classCallCheck(this, Person);
thistype = 'person';
};
我们看到,还是用了构造函数来完成。同时,上文提到过的_classCallCheck也出现了,他作为工具函数,保障class调用的正确性:
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
关于继承:
class Student extends Person {
constructor(){
super()
}
}
编译结果:
// 实现定义Student构造函数,它是一个自执行函数,接受父类构造函数为参数
var Student = (function(_Person) {
// 实现对父类原型链属性的继承
_inherits(Student, _Person);
// 将会返回这个函数作为完整的Student构造函数
function Student() {
// 使用检测
_classCallCheck(this, Student);
// _get的返回值可以先理解为父类构造函数
_get(Object.getPrototypeOf(Student.prototype), 'constructor', this).call(this);
}
return Student;
})(Person);
上面_inherits方法的本质其实就是让Student子类继承Person父类原型链上的方法。它实现原理可以归结为一句话:
Student.prototype = Object.create(Person.prototype);
Object.setPrototypeOf(Student, Person)
所以说到底,还是“构造函数+原型原型链”内容,并且辅助Object.create等ES5功能实现。
我建议大家对于编译源码尝试去进行了解,对于自己的基础也是一种提高。
了解了这些,对于ES6的接入是很有帮助的。试想一下,我们在ES6环境下声明的类,如何在历史代码中(ES5环境下)实现继承呢?
通过对Babel编译结果的研究,我也实现了一个工具函数,用来完成这两种开发环境下类的衔接和过渡。具体代码实现难度不大,可以简要参考:
function inherits(childClass, superClass) {
childClass.prototype = Object.create(superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
// 这里注意兼容性,IE11以上才完全支持
if (Object.setPrototypeOf) {
Object.setPrototypeOf(childClass, superClass)
}
else {
childClass.__proto__ = superClass;
}
}
如果您有兴趣,可以看我的系列文章:揭秘babel的魔法之class魔法处理。
打通两种开发环境的任督二脉
这里还是聊聊上面展示inherits工具方法,其实这属于“打通ES5和ES6环境”。同样还是在ES6环境下定义的Person Class,ES6环境代码:
class Person {
constructor(){
this.type = 'person'
}
}
在ES5环境中就可以直接进行引用并继承Person类,ES5环境代码:
funtion Student () {
...
}
inherits(Student, Person);
这当然是极其有必要的。想象一下6年代码历史,ES5环境的代码量是多么的庞大,这样我们在维护过程中,便可直接获利于ES6的特性。
同样,对于模块化上,我们也存在两种环境共生的问题:之前的代码我们遵循了commonjs规范,并通过打包工具(FIS部分功能),来保证浏览器端的支持。接入ES6之后,自然也就有了ES6模块化的写法。
那么JS文件内如何兼容这两种模块化写法的表达方式呢?
也很简单,同样依赖于Babel的实现。我们在Babel官网上可以找到关于模块化插件的内容:
其中有一个es2015_modules_commonjs,就是将ES6 Modules编译转换成commonjs形式的。我们当然选用这种编译方式。
对ES next支持
截止目前,ES7也已经取得了重大进展。很多最新一代的ES特性已经被广大开发者熟知并应用。那么在我们的环境中,对于ES next的支持也自然要跟进。这又回到了Babel的话题,我们当然还是离不开这个神器。
同时,你首先要知道,ES7不同阶段语法提案包括:
-
Stage 0:Strawman: just an idea, possible Babel plugin.
-
Stage 1:Proposal: this is worth working on.
-
Stage 2:Draft: initial spec.
-
Stage 3:Candidate: complete spec and initial browser implementations.
-
Stage 4:Finished: will be added to the next yearly release.
对应的,官方提供以下的规则集来对不同阶段的特性进行编译,你可以根据需要安装:
-
$ npm install —save-dev babel-preset-stage-0
-
$ npm install —save-dev babel-preset-stage-1
-
$ npm install —save-dev babel-preset-stage-2
-
$ npm install —save-dev babel-preset-stage-3
需要注意的是,这些工作应当在初期调研设计时,就要有规划和方案。而不是,今天头脑一热,想应用async/await ES7新特性,再去花费时间进行了解。因为,在公司内成熟的开发体系中,严谨的排期需求与个人私下的学习了解完全是两码事。
这些年踩过的兼容性的坑
我们代码能够兼容到IE6+,接入ES6之后,对于兼容性的保证是个挑战。在实际情况中,我们也踩过这方面的坑。
Babel对于ES6的编译是在ES5之上的,那么想要兼容IE6,自然编译产出的ES5代码是无法满足要求的。为此,解决方式只有提供ES5的polyfill,并保证在所有其他脚步加载之前执行。
我们采用了最出名的ES5-shim+ES5-sham来进行ES5代码的“降级”。期间各种IE版本兼容性的测试,那可谓是“一把鼻涕一把泪”。
同时,这里所指的兼容性也不仅仅是浏览器兼容性。也要考虑到引用社区上第三方组件库、类库的问题,如果这些源代码是基于ES6的,那就要慎重考虑是否符合我们使用的Babel版本,我们是否保证并兼容了Babel插件进行编译等相关性问题。这当然也是不小的工作量。
第四部分:一个设计实例
这个实例充分反映了ES6 class带来的便捷之处。
我们产品当中,一个页面可能存在多处“收藏”组件:点击按钮,对页面进行收藏,成功收藏之后,按钮的状态会变为“已收藏”,再点击不会有响应。
这样就出现页面中多处“收藏”组件之间通信问题,点击页面顶部收藏按钮成功收藏之后,页面底部的收藏按钮状态也需要变化,进行同步。
其实实现这个功能很简单,但是历史代码实现方式较为落后,耦合严重。良好的设计和肆意而为的实现差别是巨大的。
在利用ES6设计之后,我们的所有组件(包括收藏组件)都会继承UIBase:
class Widget extends UIBase {
constructor() {
super();
...
}
}
而UIBase本身会产生一个全局唯一的UUID,这样使得所有组件都有一个唯一的ID标识。同时,UIBase又继承“EventEmitter”这个pub/sub模式组件:
class UIBase extends EventEmitter{
constructor() {
super();
this.guid = guid();
}
}
因此,所有的组件也同样拥有了pub/sub模式,既事件发布订阅功能。这就相对完美的解决了组件之间的通信问题。达到了“高内聚、低耦合”的效果。
具体来说,我们的任何组件,当然包括收藏按钮在发起收藏行为时:
widget.fire('favorAction');
同时,其他的收藏组件:
widget.on('favorAction', function () {
... // toggle status
})
具体的实现结构如图:
这样的组件行为在一些先进的MVVM、MVC等框架中可以良好的实现。比如优秀的react框架中,我们可以对组件的state设计并切换。但是,我们的技术栈还停留在传统的操作DOM中,jquery类似类库可以满足我们的业务需求。我认为,所有抛离业务场景和需求的的谈新框架,也是一种“耍流氓”。
第五部分:那些年ES6带来的困扰和展望
不可否认,ES6的接入并不是百利而无一害的,我们要正确客观地看待它。伴随着开发效率提升的同时,它还带来了以下困扰:
-
额外的编译流程。
-
编译代码排错追错成本。
-
Babel版本升级是个负担。
-
api转换还需shim.
-
潜在的bug.
-
很多特性面向node.js,浏览器端并不实用。
-
学习成本。
然而,未来已来,接下来我们又该做什么呢?
-
更大范围的重构。
-
紧盯ES6实现和ES next发展。
同时,需要指出的是ES6的先进性,还体现在和React框架的配合上。去年,我们团队也接入了React开发栈,ES6+React让我们更加面向未来。
最后,呼应一下本文开篇,谈一下想法和总结。每一名前端开发者可能都会感觉到处在前端发展的历史时刻。面对未来,我们也许正在感受着葡萄牙诗人安德拉德的诗句:
我同样不知道什么是海,
赤脚站在沙滩上,
急切地等待着黎明的到来。
ES6注定载入开发史册,最后也许也难逃被替代的命运,完成承前启后的使命。同样是葡萄牙人的诗句:
大陆,在这里是尽头;大海,在这里才开头(陆止于此,海始于斯)。
在技术的海洋里,这一站,既是一种结束,更是一个开始。