公司前端开发架构改造

现在的前端早已不是几年前的前端,再也不是jQuery加一个插件就能解决问题的时代。

最近对公司前端的开发进行了一系列的改造,初步达到了我想要的效果,但是未来还需要更多的改进。最终要完美的实现目标:工程化模块化组件化

这是一个艰难的,持续的,不断进化的过程!

先说下我司前端改造前的情况:

开始的时候,只有微信公众号开发,以及在APP中嵌入的Web页面,只需要考虑微信端的问题,以及跟原生APP的交互处理,一切都好像很完美。

几个月后,要开发手机网页版,接着百度直达号也来了。原来微信端的功能已经比较多,不可能针对这2个端再重新开发,于是把微信端的代码拷贝2份,然后修改一下就上线了(初创公司功能要快速上线,有时不会考虑太多的技术架构,都懂的)。

这样就出现了6个不同的项目文件夹,为什么会是6个呢?因为也分别拷贝出了各自的测试目录:

1
2
3
4
5
6
/wap
/waptest
/m
/mtest
/baidu
/baidutest

于是,问题就来了,开发的时候,都在其中一个test目录下开发,比如waptest,开发测试没问题了,就拷贝修改的代码到其它的目录。这样开发的痛苦,可想而知了。

不仅仅是各个端的js,css,images文件是分别存放,还有各个端的页面模板也是在各自的目录下。

另外,一直以来,公司的前端美女会使用grunt做一些简单的前端构建,比如sass编译,css合并等,但离我想要的前端自动化/工程化还是有点远。

为了提高前端的工作效率,最近终于有一点时间腾出手来处理这些问题。

PS:我们团队组建一年多,项目也从0开始,到现在为止,产品/开发/项目管理等都在逐渐完善。走专业化道路,是我一直以来的目标,有兴趣的可以加我一起交流!

问题总结

先来总结一下改造前前端开发存在的问题:

  1. 同时存在多端,造成开发效率不高
  2. 项目没有模块化,组件化的概念,代码复用率低
  3. 部署困难,没有自动生成版本号,每次都要手动修改js的版本号
  4. 面条式的代码,开发任务重,没有做很好的规划

改进目标

有问题,那就想办法去解决它:

  1. 解决多端统一的问题,一处修改,多端同时生效
  2. 模块化开发,使代码逻辑更加清晰,更好维护
  3. 组件化开发,增强扩展性
  4. 按需打包,以及自动构建
  5. 自动更新js版本号,实现线上自动更新缓存资源
  6. 紧跟发展趋势,使用ES6进行开发

在改进的过程中,会用到2个工具: GulpWebpack。用这2个工具,也是有原因的。

本来我想在Grunt的基础上利用Browserify进行模块化打包,但是发现打包的速度太慢,我的Linux系统编译要4s以上,美女前端的Widnows系统一般都要7s以上,这简直不能忍受。在试用Gulp之后发现速度杠杠的,不用想了,立刻替换Grunt。至于Webpack,是因为用browserify打包多个入口的文件配置比较麻烦,在试用了Webpack之后,发现Webpack的功能比browserify强大很多,于是就有了这2个工具的组合。Webpack的配置比较灵活,但是带来的结果就是比较复杂,在项目中,我也仅仅用到了它的模块化打包。

于是,最终初步实现前端构建的方案是:

Gulp进行JS/CSS压缩,代码合并,代码检查,sass编译,js版本替换等,Webpack只用来进行ES6的模块化打包。

现在前端的操作很简单:

开发的时候,执行以下命令,监听文件,自动编译:

1
$ gulp build:dev

开发测试完成,执行以下命令,进行编译打包,压缩,js版本替换等:

1
$ gulp build:prod

从此,前端开发可以专心地去写代码了!

方案实现

项目结构

整个项目是基于Yii2这个框架,相关的目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
common/
pages/
user/
index/
cart/
wap/
modules/
user/
index/
cart/
web/
dev/
index/
user/
cart/
common/
lib/
dist/
logs/
gulp/
tasks/
utils/
config.js
config.rb
node_modules/
index.php
package.json
gulpfile.js
webpack.config.js
.eslintrc

  • common/pages存放公共模板,各个端统一调用
  • web/dev是开发的源码,包含了js代码,css代码,图片文件,sass代码
  • web/dist是编译打包的输出目录

统一多端的问题

由于多端的存在,导致开发一个功能,要开发人员去手动拷贝代码到不同的目录,同时还要针对不同的端再做修改。

js文件,css文件,图片文件,还有相关的控制器文件,模板文件都分散在不同的目录,要拷贝,耗时间不说,而且容易出错遗漏。

要解决这个问题,有2种方法:

  • 所有端调用公共的文件
  • 在某个端开发,开发完成之后,用工具自动拷贝文件,并且自动替换相关调用

在综合考虑了之后,这2种方法同时使用,模板文件多端公共调用,其它的文件,通过命令自动拷贝到其它端的目录。

公共模板放到目录common/pages,按模块进行划分,重写了下Yii2的View类,各个端都可以指定是否调用公共模板。

1
2
3
4
5
6
7
public function actionIndex()
{

$this->layout = '/main-new';
return $this->render('index', [
'_common' => true, // 通过该值的设置,调用公共模板
]);
}

模板一处修改,多端生效。

另外,其它文件通过gulp去拷贝到不同的目录,例如:

1
$ gulp copy:dist -f waptest -t wap

这里有一个前提就是,所有编译打包出来的文件都是在dist文件夹,包含了js代码,css代码,图片文件等。

组件化开发

这个只能说是未来努力的一个目标。现阶段还没能很好地实现。这里单独列出这一点,是希望给大家一点启发,或者有哪路高手给我一点建议。

看了网上诸路大神的言论,总结了下前端的组件化开发思想:

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

其中,各个组件单独的目录,包含了js代码,样式,页面结构。这样就把各个功能单元独立出来了。不仅维护方便,而且通用性高。

最终,整个Web应用自上而下是这样的结构:

模块化开发

前端开发的代码从开始到现在,经历了3个阶段:

  • 第一阶段,面条式代码,在每个模板页面写上一堆js代码,执行的代码跟函数代码交替出现,多重复代码
  • 第二阶段,进化到了Object,每个模板页面的js都用Object进行封装一次
  • 第三阶段,引入ES6的模块化写法

在之前,前端都按下面的目录存放文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js/
goods-order.js
package-order.js
index.js
user.js
zepto.js
crypto-js.js
images/
goods.jpg
logo.png
footer.png
css/
header.css
footer.css
goods-order-index.css

这样会导致一个目录下会有很多文件,找起来非常不方便,而且结构不清晰,比较杂乱。

现在,在目录web/dev分开不同的目录存放各个模块的代码以及相关文件,web/dist是编译打包出来的文件存放的目录。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
dev/
lib/
zepto.js
crypto-js.js
common/
js/
request.js
url.js
scss/
head.scss
order/
goods-order/
index.js
index.scss
a.png
package-order/
index.js
index.scss
dist/
lib/
lib.js
order/
goods-order/
index.50a80dxf.js
a.png

其中,有2个重要的目录:

  • lib/目录存放第三方库,编译的时候合并并压缩为一个文件,在页面上直接引入,服务端开启gzip以及缓存
  • common/目录存放公共的模块,包括js的,还有sass等,其它模块目录的js,sass可以调用这些公共的模块

其它的目录都是单独的一个模块目录,根据业务的情况划分,每个模块目录把js,sass,图片文件都放一起。

这样的结构清晰明了,极大地提高了可维护性。

至于JS代码的模块化,刚好去年发布了ES6,而且各大框架/类库/工具等都支持ES6的特性,显然这是未来的一种趋势,相比以前的CMD/AMD/CommonJS规范,选择ES6会更加的符合时代的发展。

ES6支持Class以及继承,以及使用import来引入其他的模块,使用起来很简单。

至于CSS的模块化,之前是使用Compass来写CSS,在本次改造中,还没做太多的处理,只是由原来的grunt编译该为用gulp编译。但是compass已经很久没有更新了,而且不建议使用它。以后会逐渐替换掉。

模块化打包

由于使用了ES6的模块化写法,需要引入Webpack进行编译打包,我是gulp与webpack配合使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
var gulp = require('gulp');
var webpack = require('webpack-stream');
var changed = require('gulp-changed');
var handleError = require('../utils/handleError');
var config = require('../config');

gulp.task('webpack', function() {
return gulp.src(config.paths.js.src)
.pipe(changed(config.paths.js.dest))
.pipe(webpack( require('./../../webpack.config.js') ))
.on('error', handleError)
.pipe(gulp.dest(config.paths.js.dest));
});

webpack的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
var webpack = require('webpack');
var path = require('path');
var fs = require('fs');
var assetsPlugin = require('assets-webpack-plugin');
var config = require('./gulp/config');
var webpackOpts = config.webpack;

var assetsPluginInstance = new assetsPlugin({
filename: 'assets.json',
path: path.join(__dirname, '', 'logs'),
prettyPrint: true
});

var node_modules_dir = path.resolve(__dirname, 'node_modules');
var DEV_PATH = config.app.src; // 模块代码路径,开发的时候改这里的文件
var BUILD_PATH = config.app.dest; // webpack打包生成文件的目录

/**
* get entry files for webpack
*/

function getEntry()
{

var entryFiles = {};
readFile(DEV_PATH, entryFiles);
return entryFiles;
}

function readFile(filePath, fileList)
{

var dirs = fs.readdirSync(filePath);
var matchs = [];
dirs.forEach(function (item) {
if(fs.statSync(filePath+'/'+item).isDirectory()){
readFile(filePath+'/'+item, fileList);
}else{
matchs = item.match(/(.+)\.js$/);
if (matchs) {
key = filePath.replace(DEV_PATH+'/', '').replace(item, '');
if(!key.match(/^lib(.*)/) && !key.match(/^common(.*)/)){
fileList[key+'/'+matchs[1]] = path.resolve(filePath, '', item);
}
}
}
});
}

var webpackConfig = {
cache: true,
node: {
fs: "empty"
},
entry: getEntry(),
output: {
path: BUILD_PATH,
filename: '',
// publicPath: '/static/assets/',
},

externals : webpackOpts.externals,

resolve: {
extensions: ["", ".js"],
modulesDirectories: ['node_modules'],
alias: webpackOpts.alias,
},

plugins: [
assetsPluginInstance,
new webpack.ProvidePlugin(webpackOpts.ProvidePlugin),
],

module: {
noParse: webpackOpts.noParse,
loaders: [
{
test: /\.js$/,
loader: 'babel',
exclude: [node_modules_dir],
query: {
presets: ['es2015'],
}
},
]
}
};

if(process.env.BUILD_ENV=='prod'){
webpackConfig.output.filename = '[name].[chunkhash:8].js';
}else{
webpackConfig.output.filename = '[name].js';
webpackConfig.devtool = "cheap-module-eval-source-map";
}

module.exports = webpackConfig;

入口文件

项目的入口文件都放在/web/dev下面,根据业务特点来命名,比如:index.jspay.js

webpack.config.js文件,可以通过getEntry函数来统一处理入口,并得到entry配置对象。如果你是多页面多入口的项目,建议你使用统一的命名规则,比如页面叫index.html,那么你的js和css入口文件也应该叫index.jsindex.css

资源映射记录

由于编译出来的文件是带有版本号的,如select-car.b9cdba5e.js,每次更改JS发布,都必须要替换模板页面的script包含的js文件名。

我用到了assets-webpack-plugin这个插件,webpack在编译的时候,会生成一个assets.json文件,里边记录了所有编译的文件编译前后的关联。如:

1
2
3
4
5
6
7
8
9
10
11
{
"store/select-store": {
"js": "store/select-store.54caf1d3.js"
},
"user/annual2/index": {
"js": "user/annual2/index.2ff2c11d.js"
},
"user/user-car/select-car": {
"js": "user/user-car/select-car.cd0f5f41.js"
}
}

这个插件只是生成映射文件,还需要用这个文件去执行js版本替换。看下面的自动更新缓存。

定义环境变量

在开发的时候,编译打包的文件跟发布编译打包出来的文件肯定不一样,具体可以参考构建优先的原则

在gulp的build:devbuild:prod命令里边,会设置一个环境变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
gulp.task('build:dev', function(cb){

// 设置当前应用环境为开发环境
process.env.BUILD_ENV = 'dev';

//... ...
});

gulp.task('build:prod', function(cb){

// 设置当前应用环境为生产环境
process.env.BUILD_ENV = 'prod';

//... ...
});

然后在webpack里边根据不同的环境变量,来进行不同的配置:

1
2
3
4
5
6
if(process.env.BUILD_ENV=='prod'){
webpackConfig.output.filename = '[name].[chunkhash:8].js';
}else{
webpackConfig.output.filename = '[name].js';
webpackConfig.devtool = "cheap-module-eval-source-map";
}

自动更新缓存

一直以来,我们修改js提交发布的时候,都需要手动去修改一下版本号,如:

当前线上版本:

1
<script src="/js/user-car.js?v=1.0.1"></script>

待发布版本:

1
<script src="/js/user-car.js?v=1.1.0"></script>

这样现在看起来好像没有什么问题,唯有的问题就是每次都要手动改版本号。

但是,如果以后要对静态资源进行CDN部署的时候,就会有问题。一般动态页面会部署在我们的服务器,静态资源比如js,css,图片等会使用CDN,那这时候是先发布页面呢,还是先发布静态资源到CDN呢?无论哪个先后,都会有个时间间隔,会导致用户访问的时候拿到的静态资源会跟页面有不一致的情况。

以上这种是覆盖式更新静态资源带来的问题,要解决这个问题,可以使用非覆盖式更新,也就是每次发布的文件都是一个新的文件,新旧文件同时存在。这时可以先发布静态资源,再发布动态页面。可以完美地解决这个问题。

那么,我们要实现的就是每次开发修改js文件,都会打包出一个新的js,并且带上版本号。

webpack中可以通过配置output的filename来生成不同的版本号:

1
webpackConfig.output.filename = '[name].[chunkhash:8].js';

有了带版本号的js,同时也生成了资源映射记录,那就可以执行版本替换了。

在网上看了下别人的解决方案,基本上都说是用到webpack的html-webpack-plugin这个插件来处理,或者用gulp的gulp-revgulp-rev-collector这2个插件处理。但是我感觉都不是很符合我们项目的情况,而且这个应该不难,就自己写了一个版本替换的代码去处理。这些插件后续有时间再研究研究。

在页面模板上,我们通过下面的方式来注册当前页面的使用的js文件到页面底部:

1
2
3
<?php
$this->registerJsFile('/dist/annual2/index/index.js');
?>

每次用gulp执行版本替换的时候, 会先读取资源映射文件assets.json,拿到所有js的映射记录。

1
2
3
4
5
6
7
8
9
10
11
var assetMap = config.app.root + '/logs/assets.json';
var fileContent = fs.readFileSync(assetMap);
var assetsJson = JSON.parse(fileContent);
function getStaticMap(suffix){
var map = {};
for(var item in assetsJson){
map[item] = assetsJson[item][suffix];
}
return map;
}
var mapList = getStaticMap('js');

然后再读取模板文件,用正则分析出上面注册的js文件,最后执行版本替换就行了。

一些要点

使用externals

项目一般会用到第三方库,比如我们会用到zeptojsart-templatecrypto-js等。

单独把这些库打包成一个文件lib.js,在页面上用script标签引入。

这可以通过在webpack中配置externals来处理。

sourcemap

在开发环境下,可以设置webpack的sourcemap,方便调试。 但是webpack的sourcemap模式非常多,哪个比较好,还没什么时间去细看。 可以参考官方文档

最后

至此,前端项目的第一阶段的改造算是完成了。 我不是前端开发,gulpwebpack都是第一次接触然后使用,中间踩了不少的坑,为了解决各种问题,差不多把google都翻烂了。不过庆幸的是,现在前端开发可以比较顺畅地去写代码了。整个结构看起来比以前赏心悦目了不少。

我觉得这次改造最大的变化不是使用了2个工具使到开发更自动化,而是整个开发的思想与模式都从根本上发生了变化。未来还会继续去做更多的探索与改进。

各位看官对前端开发有更好的建议或者做法,欢迎随时跟我交流。

文章目录
  1. 1. 问题总结
  2. 2. 改进目标
  3. 3. 方案实现
    1. 3.1. 项目结构
    2. 3.2. 统一多端的问题
    3. 3.3. 组件化开发
    4. 3.4. 模块化开发
    5. 3.5. 模块化打包
      1. 3.5.1. 入口文件
      2. 3.5.2. 资源映射记录
      3. 3.5.3. 定义环境变量
    6. 3.6. 自动更新缓存
    7. 3.7. 一些要点
      1. 3.7.1. 使用externals
      2. 3.7.2. sourcemap
  4. 4. 最后
,