告别刀耕火种:浅谈VisMooc的前端工程化

机缘巧合,年初的时候,老师叫我找师兄交接一下VisMooc的代码,我本以为今年Deadline要负责相关开发,因为项目比较脏乱,于是便赶在春节前把整个系统重写了一遍,后将过程记录整理成文,欢迎交流探讨。

0.交待背景


  VisMooc是组里的一个大项目,主要是给在线教育网站的各种数据提供可视化的分析工具,可以算是一个webapp。最初由丛磊师兄带头创立并实现完成,从0.5版本发展到现在较为稳定的2.0版本,集合了屈老师以及很多师兄师姐的智慧和汗水,为组里赢得了无数的荣誉与名声,可以说是组里的王牌项目之一。
  由于特殊的生长环境,VisMooc的各种功能基本都是由不同的师兄在一个又一个的deadline中赶出来的,然后每年再对整个系统重构一遍,把各种功能整合上去。在这种特殊的“国情”下,VisMooc的代码野蛮生长,项目的性能必然是没有经过优化的,鲁棒性也不高,很多功能都是hack出来的。但是其实性能不好、鲁棒性不高也没关系,毕竟目前只是个演示用的Demo项目,并发访问量、日访问量都不怎么高,各种可视化的技术和概念能展示就行。
  但是VisMooc目前有一个无比巨大的坑,那就是代码毫无可维护性可言,灾难般的可维护性,许多部分相当混乱,开发目前的VisMooc,有种在茅房里吃佳肴的感觉,怎么都美味不起来。老实说,作为一个码农如果有一天你发现自己的开发效率奇低无比,你就要想想是不是自己的工作环境不够整洁,是不是自己的刀没有磨利。
  VisMooc的不可维护性集中体现在以下几点:

  1. 代码难以维护
    • 由于缺少统一的编码规范,并且不同的功能由不同的师兄开发,导致项目各部分的代码高度不统一,每个部分的代码都有不同的风格。
    • 很多代码的变量名都是随便起的,比如var a,b,kkk;,基本不能做到变量名自解释,注释比较稀少,在通读代码之前变量含义和函数功能主要靠猜。
    • 此外,模块化和组件化开发的特性较弱,目录结构和依赖关系较乱,一个组件的CSS文件可能在别的组件的目录下;有时候忽然在代码中看见了某个未曾谋面的对象,但是你却不知道它来自哪个组件。
    • 历史负担较重,许多陈年老码,有些早已不用的,有些依然在用,混在一起。
  2. 项目难以维护
    • 缺少项目管理工具,小作坊式编程。许多管理工具只是装上意思意思,但是并没有使用,比如Bitbucket的团队协作管理等。
    • 有用npm和bower来管理第三方依赖包,但是npm installbower install完了之后项目基本是运行不了的,经常是缺了某个依赖包,但是这个包不在dependence list里,要人肉去读码补全。
    • 有用Git来做版本控制,但是.gitignore是随便写的,比如会把自己写的代码给ignore掉囧rz,所以在别的机子上clone项目的时候,往往会报错显示某个变量no reference,debug一通最后发现原来是缺了代码文件。混乱的第三方js长达一脸
  3. 部署难以维护(这一点其实略为要求过高
    • 没有版本号、内容指纹的概念,无法长期缓存以及精确控制缓存内容。
    • 覆盖式发布,升级不够平滑。

  这些问题的产生无比自然,早期的小作坊式的开发基本都有这类的问题,而这些问题基本都可以用工程化自动化的方法来解决。我就用VisMooc来作为一个案例,学习实践了一把如何以前端工程化的理念来重构整个系统。首先来整理一下需求:

  • 开发
    1. 组件化开发。整个webapp由各个组件构成,一个组件的Js、CSS和HTML维护在一起,尽量松耦合。
    2. 模块化开发。Js模块化,CSS模块化,HTML模块化,所有资源都是模块,并采取就近引入原则,哪里用哪里引,同时要做到防止资源重复引入。
    3. 文件实时监听、浏览器自动刷新。
    4. 依赖包管理,版本控制。
    5. 规范编码。编码规范的事可以参考《编写可维护的JavaScript》,主要靠自觉。
  • 构建
    1. 可以编译中间语言。比如支持Stylus、Less、Typescript等的编译。
    2. 支持Js、CSS的压缩和混肴。
    3. 允许图片压缩后以base64编码形式嵌入到CSS、Js或HTML中。
    4. 支持代码按需加载。将代码根据页面需求进行分割与合并,优化Http请求数。
  • 部署
    1. 根据代码内容生成版本号,实现缓存控制,提示访问性能。
    2. 支持第三方类库的Js、CSS与自己所写的代码分离。

  整理了一下,大约有11项的主要需求,对于目前这么一个展示性项目来说基本足够了。如何优雅的把他们都实现了呢?我们一步一步来。

1.技术选型


前端类库们

前端类库们

  前端工程建设的第一项任务就是根据项目特征进行技术选型。VisMooc可以算是较为典型的Webapp,就目前的功能来看,还是一款只有一页的Single Page App。VisMooc的目标平台仅仅是PC,不支持在移动端使用(或许以后会支持在平板设备上使用),其页面逻辑也较为简单,类似于一个Dashboard应用。

前端渲染
  在VisMooc2.0中,我们采用了Angular1来现实前端的模块化和各种功能。但是对于VisMooc来说,Angular略显臃肿,并且Angular1在性能上较为不足。随着前端圈子的发展,Angular的开发团队宣布在下一版本的Angular2将不兼容Angular1,这显然是个好机会让我选一个新的框架来重写一遍VisMooc,顺便也对VisMooc进行一下升级。
  前端框架日新月异,而VisMooc项目的前端框架有这么几个特殊需求:1.只针对PC平台;2.上手要简单,学习曲线平滑。有这两点,大热的React框架基本可以排除了。而Angular2目前生态圈还相当不完善(几乎没有生态圈),于是一个轻量级的Vuejs抓住了我的眼球。Vuejs是一个非常“小而美”的前端渲染框架。简洁、轻量并且强大,数据驱动,模块友好,支持组件化。学习曲线很平滑并且不高,社区友好,作者是个华人前谷歌工程师,已婚程序员,人相当的nice,可以看作者自己对于该框架的评价。目前国内外已然有很多公司将Vus使用于实际生产环境中,国内知名的有阿里百度新浪小米等等。

构建打包
  为了更好的管理代码以及提升系统性能,我选用了最近很火的模块构建系统Webpack来完成系统的构建任务。老实说目前的Webpack有点四不像,即是打包工具,又是构建工具,又是模块加载工具,却又不是一个完全的前端工程化工具,但是不管怎么说Webpack实在是相当强大。在Webpack的帮助下,我们可以做到node端与浏览器端共用代码,模块依赖管理,代码拆分,通过插件加载其它类型资源等等。Webpack 提供了强大的 loader 机制和 plugin 机制,loader 机制支持载入各种各样的静态资源,不只是 js 脚本,连 html、 css、 images 等各种资源都有相应的 loader 来做依赖管理和打包。而 plugin 则可以对整个 webpack 的流程进行一定的控制。比如在安装并配置了 css-loader 和 less-loader 之后,就可以通过 require('./bootstrap.less') 这样的方式在任意需要的地方给网页载入一份用less编写的样式表,Webpack会自动在构建时生成css文件并按需加载,非常方便。

依赖包管理
  市面上有许许多多种包管理工具,常见的有npm和bower。一般来说,人们习惯用npm来管理nodejs相关的包,用bower来管理前端所依赖的类库。随着前端生态圈的高速发展,Browserify、Webpack 等支持 CommonJS 规范的构建工具的流行, npm在过去的一年里基本干掉了了bower、component、spm等一众前端包管理工具,大有一统前后的趋势。这对于我这种喜欢”大一统”的人来说简直不能再好,隧毫不犹豫的用npm来管理第三方包的依赖。
  利用npm来做第三方包管理工具无比简单,它会自动的在你的项目目录下创建一个叫package.json的文件,里面记录了项目相关的信息,以及该项目所依赖的各种类库。当你需要用到某个发布在npm上的第三方类库,假设叫super-lib,你只需要在项目文件夹下用命令行输入npm install super-lib --save-dev即可完成自动下载这个类库、自动将依赖关系写进package.json中的dependencies下。配合Webpack,可以用require等语句在任何需要的地方引入第三方类库。

1
2
3
4
5
6
7
8
9
10
11
12
//package.json file, which documents the dependent libs.
{
"name": "example",
"version": "0.0.1",
"private": true,
"dependencies": {
"super-lib": "^0.4.5",
"beautiful-lib": "^0.22.0",
"smart-css": "^0.7.0"
"vuejs": "^3.4.0"
}
}

  通过npm下载的第三方类库都不必push到git服务器上,别的开发者要重新下载项目所依赖的第三方类库只需在项目目录下执行npm install命令即可自动将项目所依赖的类库给补齐全。
  关于npm和bower的更多讨论可以参考这里(知乎),或者这里(github)

2. 项目目录的设计


  传统的前端项目按照文件类型来组织目录结构,所以常见的目录结构是这样的:

1
2
3
4
5
6
Project
|-src
|-HTML
|-CSS
|-JS
|-Images

  这样做的好处就是简单粗暴,非常直接,只要有一点点的编程经验的人也可以一眼看懂项目的组织方式。但是这样做的坏处也是很明显的,就是项目维护成本较高,并且为项目臃肿埋下了工程隐患:

  • 如果项目中的一个功能有了问题,维护的时候要在js目录下找到对应的逻辑修改,再到css目录下找到对应的样式文件修改一下,如果图片不对,还要再跑到images目录下找对应的开发资源。
  • CSS下的文件不知道哪些图片在用,哪些已经废弃了,谁也不敢删除,文件越来越多。Js/HTML亦然。
  • 多人合作时,很可能多个人改同一份代码文件,造成冲突。

除非项目代码量很少、并且只有1人负责开发,这种基于文件类型的项目结构都是应该被抛弃的。鉴于我们希望采取组件化开发,整个app由组件构成,因此我们可以根据代码成分来划分项目目录,比如:

1
2
3
4
Project
|-src
|-Components // 存放组件资源
|-Static // 存放非组件资源

由于我们采用npm来管理所依赖的第三方类库,其默认的文件夹名为node_modules,但是并非所有依赖的第三方类库都托管在npm上,有一些神秘的第三方资源我们需要自己管理。我们可以把它们放到src目录下,然后push到git服务器中来避免丢失文件。这样,我们的目录结构就变成:

1
2
3
4
5
6
Project
|-node_modules // npm上的第三方资源
|-src
|-Components // 存放组件资源
|-Static // 存放非组件资源
|-lib // 存放不在npm上的第三方资源

最后,由于我们采用Vuejs来开发前端,其中有一些其特有的代码组成部分,比如componentfilterdirective等。component放进Components目录十分自然,但是别的部分算不上真正的组件,放在Static里又觉得分外别扭。为了使开发目录更清晰,我给他们单独放在对应的目录中,最终我们的开发目录就变成:

1
2
3
4
5
6
7
8
Project
|-node_modules // npm上的第三方资源
|-src
|-Components // 存放自己实现的组件
|-Filters // 存放自己实现的filter
|-Directives // 存放自己实现的directives
|-Static // 存放非组件资源
|-lib // 存放不在npm上的第三方资源

3. 愉快的写码


  在做完种种的准备工作之后,我们终于要开始写代码了。需求中的第一条就是组件化开发,首先简单介绍一下什么是组件化开发。

components1.png

  组件化开发的基本理念如上图所示,引用一下张云龙大神的文字和图,所谓组件化开发可以理解成:

  • 页面上的每个 独立的 可视/可交互区域视为一个组件;
  • 每个组件对应一个工程目录,组件所需的各种资源都在这个目录下就近维护;
  • 每个组件相对独立,页面只不过是组件的容器,组件自由组合形成功能完整的界面;
  • 当不需要某个组件,或者想要替换组件时,可以整个目录删除/替换。

  组件化开发的核心优势在于其分治策略。从工程层面来看,它将开发任务划分至合适的粒度,便于给不同的开发者分配任务;从开发的角度来看,由于每个组件的相对独立性,开发者在开发期间不会产生依赖冲突,只需专注于自身的模块开发,提高开发效率;从维护的角度来看,于模块相关的资源均组织在一起,十分便于维护和整理。
  可以想象,在模块化的支持下,一个项目的开发可能是这样组织的(图引自云龙大神):
components2.png

  令人愉快的是,Vuejs的作者提供了一个插件vue-loader,可以将Vuejs的代码整合进Webpage的构建系统中,从而漂亮的实现了组件化开发的需求。通过使用Vuejs+Webpack这样的组合,我们可以把每一个组件的样式、模板和脚本集合成一个文件里。 一个组件就是一个文件,麻雀虽小五脏俱全,里面包含了组件之间的依赖关系,整个组件从外观到结构到特性再到依赖关系都一览无余。

Vue组件文件

  如上图所示,在一个example.vue文件里,包含了该example组件的Template(HTML)/JS/CSS。当然如果每个部分很长,也可以将内容分别写至对应的HTML/JS/CSS文件下,再引用到vue文件里。
  同时,我们的第二个需求模块化开发也在此一并实现了。在Webpack的帮助下,该组件所依赖的所有第三方资源都可以当作模块,直接就近引用(图中采用了ES6语法)—— 需要bootstrap的css,import进来,需要d3的js,也import进来,这个组件要什么,就在这里import就好,至于重复引入的问题Webpack会帮你解决。
  有了组件化和模块化开发,像往常的“之前用过但是现在不用的第三方类库由于不知道别人还要不要用所以不敢删除只能留着”这样的问题就能彻底解决了,还有像上文图中出现的“引用的js类库长达一脸”的情况也能大为改善。
  此外,如上图所示,我们还引入了一个less文件,只要我们在Webpack里开启了less-loader,任何less代码在最终输出的时候,会自动被Webpack预编译成CSS代码并插入到合适的地方。在Vue文件中,只要你开启了Webpack的各种loader,各种预编译方言都不是问题(比如sass、jade、typescript等):

Vue组件文件

  这样,支持中间语言的编译的需求也搞定了。
  这样再大的系统、再复杂的界面,也可以用这样的方式庖丁解牛,分成一件一件的模块来写。并且就像你能看到的,相当易于维护和适合多人共同开发

4. 潇洒的构建与部署

  • 开发
    1. 组件化开发。整个webapp由各个组件构成,一个组件的Js、CSS和HTML维护在一起,尽量松耦合。
    2. 模块化开发。Js模块化,CSS模块化,HTML模块化,所有资源都是模块,并采取就近引入原则,哪里用哪里引,同时要做到防止资源重复引入。
    3. 文件实时监听、浏览器自动刷新。
    4. 依赖包管理,版本控制。
    5. 规范编码。编码规范的事可以参考《编写可维护的JavaScript》,主要靠自觉。
  • 构建
    1. 可以编译中间语言。比如支持Stylus、Less、Typescript等的编译。
    2. 支持Js、CSS的压缩和混肴。
    3. 允许图片压缩后以base64编码形式嵌入到CSS、Js或HTML中。
    4. 支持代码按需加载。将代码根据页面需求进行分割与合并,优化Http请求数。
  • 部署
    1. 根据代码内容生成版本号,实现缓存控制,提示访问性能。
    2. 支持第三方类库的Js、CSS与自己所写的代码分离。

  到这里,剩下的任务主要都是构建和部署部分了,这些都将依靠Webpack来实现,而我们所需要做的,仅仅是配置Webpack的配置文件即可搞定一切。Webpack的配置文件是一个js文件webpack.config.js,但是内容就像package.json一样,基本就是一个javascript对象,所需要的一切都在里面。
  一些常规任务,比如支持文件实时监听、浏览器自动刷新,可以通过安装Webpack的官方配套的调试用服务器webpack-dev-server解决,而支持 JS/CSS/图片压缩混肴等任务可谓老生常谈,只需直接开启Webpack自带的优化插件即可解决:

1
2
3
4
5
6
7
8
9
{
//webpack.config.js
...
plugins:{
new webpack.optimize.UglifyJsPlugin(),
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.optimize.DedupePlugin()
}
}

  现代浏览器一般都默认开启本地缓存功能,当第二次打开同一个网站的时候,会根据网站内请求的链接来决定是否读取缓存,比如上回打开网页请求了一个"/plugin.js",下一次打开依然请求/plugin.js的话,浏览器会优先查找本地是缓存,从而提高了网页的加载速度。如果文件的内容发生了改变而文件名没有变化的话,便会导致读取缓存,加载过时的代码文件,造成网页显示错误。解决这个问题的方法有很多,其中一个相当不错的方法就是根据文件内容给文件名打上hash指纹。这样每当文件内容发送变化,文件名就会发生对应的改变。这里有一篇超赞的文章讲这个问题《大公司里怎样开发和部署前端代码?》,作者还是云龙大神。
  在Webpack里实现打hash指纹简单飞起,只需在Output选项里进行如下配置即可:

1
2
3
4
5
6
7
8
{
//webpack.config.js
...
output: {
...
filename: './js/[name].[hash].js'
},
}

  上面配置的意思就是输出一个文件,它的名字叫[name].[hash].js,其中[name]是文件的本命,Webpack会给你自动替换上,[hash]是文件的hash指纹,Webpack也会给你自动给计算出来替换上。
  Webpack那么强大,它有没有什么令人无语的地方呢?有一点可能算是不太好的地方,那就是Webpack默认会把引入的第三方代码和你自己写的代码一起打包成一个文件,这显然是不太好的:首先第三方库的代码往往并不会经常修改,用户缓存了一次之后很长一段时间内都不会再需要下载新的版本;其次第三方库的代码很可能会利用CDN渠道分发,你上别的网站缓存的第三方库,在下一个网站可能还能用,大大提高了整体浏览速度,而跟自己的代码打包在一起,就放弃了CDN的优势。
  想要将第三方类库的代码跟自己的代码分离依然很简单,也是配置一行就搞定,比如你想把引入的Bootstrapd3js资源抽出来,可以进行如下配置:

1
2
3
4
5
6
7
8
{
//webpack.config.js
...
entry:{
app:'index.js',
vendors:['bootstrap','d3js']
}
}

  上面的意思就是说,最终生成的文件的代码入口点有两个,其中一个是自己的代码入口点app.js,由自己的代码index.js及其中引用的资源文件组成,另一个代码入口点是vendors.js,里面包括出现在vendors列表里的第三方引用的模块代码。
  这样做还有一个问题,就是在vendors.js里的代码在app.js里也会有一份,这样就代码重复了。为了解决这个问题,要用到Webpack官方的一个叫CommonsChunk的插件,可以进行如下配置:

1
2
3
4
5
6
7
{
//webpack.config.js
...
plugins:[
new webpack.optimize.CommonsChunkPlugin('vendors', './js/vendors.[hash].js')
]
}

  上面这样设置之后,Webpack会检测各个入口文件生成的代码,如果里面引用的代码有出现在vendors列表里的话,会被自动抽出来放到./js/vendors.[hash].js里。(感谢评论中 @寒霭 的提醒)
  这样一来,所有你想单独抽出来的代码或者资源模块,你只需把他们添加进vendors数组就好了。由于支持模块化开发,如果是通过npm下载的模块资源,直接把模块的名字填进vendors数组即可,Webpack会自动解析。
  此时此刻,我们还剩最后一个需求就搞定了,即支持代码按需加载,根据页面将代码进行分割与合并。这个也是Webpack的主打卖点之一,涉及CommonsChunk更深入的内容,可以讲上好久。可惜的是我们的VisMooc中目前只有一个页面,是彻彻底底的单页应用,并不需要分割代码,暂时还用不上。目前因为缺乏实操经验,我对这个功能的理解也并不深,以后如果有机会用到,再讲讲好了。

5. 总结&后记

  • 开发
    1. 组件化开发。整个webapp由各个组件构成,一个组件的Js、CSS和HTML维护在一起,尽量松耦合。
    2. 模块化开发。Js模块化,CSS模块化,HTML模块化,所有资源都是模块,并采取就近引入原则,哪里用哪里引,同时要做到防止资源重复引入。
    3. 文件实时监听、浏览器自动刷新。
    4. 依赖包管理,版本控制。
    5. 规范编码。编码规范的事可以参考《编写可维护的JavaScript》,主要靠自觉。
  • 构建
    1. 可以编译中间语言。比如支持Stylus、Less、Typescript等的编译。
    2. 支持Js、CSS的压缩和混肴。
    3. 允许图片压缩后以base64编码形式嵌入到CSS、Js或HTML中。
    4. 支持代码按需加载。将代码根据页面需求进行分割与合并,优化Http请求数。
  • 部署
    1. 根据代码内容生成版本号,实现缓存控制,提示访问性能。
    2. 支持第三方类库的Js、CSS与自己所写的代码分离。

  到这里,所有的需求算是都已经实现了。经过这轮重写,整个项目变得无比清爽,开发起来顺畅自然,Linus有句名言叫“Talk is cheap,show me the code”,对比重写之前有多好,谁上谁知道。
  这次重写,充分感受到了技术选型的重要性。吃螃蟹就要蟹八件,吃核桃就该上核桃夹,技术栈选的好,解决问题削铁如泥,做起来又快又好。
  写工程代码,性能效率、拓展性、维护性等等都需要考虑到。鉴于做科研往往只需要一个能show的demo就行,所以要求也不能太高。但是如果平时养成了好习惯,不管是写再小的代码,写出来都会是很漂亮很优雅;假如平时养成了坏习惯,写再大的项目,投入再多的时间,都会写得不堪入目,最后产出不能。这其实也是个态度问题。
  前端工程化还有很长的路要走,云龙大神在这方面的贡献十分巨大,毕竟是国内前端工程第一人。希望前端圈子能越来越热,越来越好吧。

热评文章