要看更多的文章,欢迎访问我的个人博客: http://oldli.net
现在的前端早已不是几年前的前端,再也不是jQuery加一个插件就能解决问题的时代。
最近对公司前端的开发进行了一系列的改造,初步达到了我想要的效果,但是未来还需要更多的改进。最终要完美的实现目标:工程化,模块化,组件化。
这是一个艰难的,持续的,不断进化的过程!
先说下我司前端改造前的情况:
开始的时候,只有微信公众号开发,以及在APP中嵌入的Web页面,只需要考虑微信端的问题,以及跟原生APP的交互处理,一切都好像很完美。
几个月后,要开发手机网页版,接着百度直达号也来了。原来微信端的功能已经比较多,不可能针对这2个端再重新开发,于是把微信端的代码拷贝2份,然后修改一下就上线了(初创公司功能要快速上线,有时不会考虑太多的技术架构,都懂的)。
这样就出现了6个不同的项目文件夹,为什么会是6个呢?因为也分别拷贝出了各自的测试目录:
/wap
/waptest
/m
/mtest
/baidu
/baidutest
于是,问题就来了,开发的时候,都在其中一个test目录下开发,比如waptest
,开发测试没问题了,就拷贝修改的代码到其它的目录。这样开发的痛苦,可想而知了。
不仅仅是各个端的js,css,images文件是分别存放,还有各个端的页面模板也是在各自的目录下。
另外,一直以来,公司的前端美女会使用grunt
做一些简单的前端构建,比如sass编译,css合并等,但离我想要的前端自动化/工程化还是有点远。
为了提高前端的工作效率,最近终于有一点时间腾出手来处理这些问题。
PS:我们团队组建一年多,项目也从0开始,到现在为止,产品/开发/项目管理等都在逐渐完善。走专业化道路,是我一直以来的目标,有兴趣的可以加我一起交流!
问题总结
先来总结一下改造前前端开发存在的问题:
同时存在多端,造成开发效率不高
项目没有模块化,组件化的概念,代码复用率低
部署困难,没有自动生成版本号,每次都要手动修改js的版本号
面条式的代码,开发任务重,没有做很好的规划
改进目标
有问题,那就想办法去解决它:
解决多端统一的问题,一处修改,多端同时生效
模块化开发,使代码逻辑更加清晰,更好维护
组件化开发,增强扩展性
按需打包,以及自动构建
自动更新js版本号,实现线上自动更新缓存资源
紧跟发展趋势,使用ES6进行开发
在改进的过程中,会用到2个工具: Gulp
和Webpack
。用这2个工具,也是有原因的。
本来我想在Grunt
的基础上利用Browserify
进行模块化打包,但是发现打包的速度太慢,我的Linux系统编译要4s以上,美女前端的Widnows系统一般都要7s以上,这简直不能忍受。在试用Gulp
之后发现速度杠杠的,不用想了,立刻替换Grunt。至于Webpack,是因为用browserify打包多个入口的文件配置比较麻烦,在试用了Webpack之后,发现Webpack的功能比browserify强大很多,于是就有了这2个工具的组合。Webpack的配置比较灵活,但是带来的结果就是比较复杂,在项目中,我也仅仅用到了它的模块化打包。
于是,最终初步实现前端构建的方案是:
Gulp进行JS/CSS压缩,代码合并,代码检查,sass编译,js版本替换等,Webpack只用来进行ES6的模块化打包。
现在前端的操作很简单:
开发的时候,执行以下命令,监听文件,自动编译:
$ gulp build:dev
开发测试完成,执行以下命令,进行编译打包,压缩,js版本替换等:
$ gulp build:prod
从此,前端开发可以专心地去写代码了!
方案实现
项目结构
整个项目是基于Yii2
这个框架,相关的目录结构如下:
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类,各个端都可以指定是否调用公共模板。
public function actionIndex()
{
$this->layout = '/main-new';
return $this->render('index', [
'_common' => true, // 通过该值的设置,调用公共模板
]);
}
模板一处修改,多端生效。
另外,其它文件通过gulp去拷贝到不同的目录,例如:
$ gulp copy:dist -f waptest -t wap
这里有一个前提就是,所有编译打包出来的文件都是在dist文件夹,包含了js代码,css代码,图片文件等。
组件化开发
这个只能说是未来努力的一个目标。现阶段还没能很好地实现。这里单独列出这一点,是希望给大家一点启发,或者有哪路高手给我一点建议。
看了网上诸路大神的言论,总结了下前端的组件化开发思想:
页面上的每个独立的可视/可交互区域视为一个组件
每个组件对应一个工程目录,组件所需的各种资源都在这个目录下就近维护
组件与组件之间可以 自由组合
页面只不过是组件的容器,负责组合组件形成功能完整的界面
当不需要某个组件,或者想要替换组件时,可以整个目录删除/替换
其中,各个组件单独的目录,包含了js代码,样式,页面结构。这样就把各个功能单元独立出来了。不仅维护方便,而且通用性高。
最终,整个Web应用自上而下是这样的结构:
模块化开发
前端开发的代码从开始到现在,经历了3个阶段:
第一阶段,面条式代码,在每个模板页面写上一堆js代码,执行的代码跟函数代码交替出现,多重复代码
第二阶段,进化到了Object,每个模板页面的js都用Object进行封装一次
第三阶段,引入ES6的模块化写法
在之前,前端都按下面的目录存放文件:
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
是编译打包出来的文件存放的目录。如:
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配合使用。
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的配置如下:
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.js
,pay.js
。
在webpack.config.js
文件,可以通过getEntry
函数来统一处理入口,并得到entry配置对象。如果你是多页面多入口的项目,建议你使用统一的命名规则,比如页面叫index.html
,那么你的js和css入口文件也应该叫index.js
和index.css
。
资源映射记录
由于编译出来的文件是带有版本号的,如select-car.b9cdba5e.js
,每次更改JS发布,都必须要替换模板页面的script包含的js文件名。
我用到了assets-webpack-plugin
这个插件,webpack在编译的时候,会生成一个assets.json
文件,里边记录了所有编译的文件编译前后的关联。如:
{
"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:dev
和build:prod
命令里边,会设置一个环境变量:
gulp.task('build:dev', function(cb){
// 设置当前应用环境为开发环境
process.env.BUILD_ENV = 'dev';
//... ...
});
gulp.task('build:prod', function(cb){
// 设置当前应用环境为生产环境
process.env.BUILD_ENV = 'prod';
//... ...
});
然后在webpack里边根据不同的环境变量,来进行不同的配置:
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提交发布的时候,都需要手动去修改一下版本号,如:
当前线上版本:
待发布版本:
这样现在看起来好像没有什么问题,唯有的问题就是每次都要手动改版本号。
但是,如果以后要对静态资源进行CDN部署的时候,就会有问题。一般动态页面会部署在我们的服务器,静态资源比如js,css,图片等会使用CDN,那这时候是先发布页面呢,还是先发布静态资源到CDN呢?无论哪个先后,都会有个时间间隔,会导致用户访问的时候拿到的静态资源会跟页面有不一致的情况。
以上这种是覆盖式更新静态资源带来的问题,要解决这个问题,可以使用非覆盖式更新,也就是每次发布的文件都是一个新的文件,新旧文件同时存在。这时可以先发布静态资源,再发布动态页面。可以完美地解决这个问题。
那么,我们要实现的就是每次开发修改js文件,都会打包出一个新的js,并且带上版本号。
webpack中可以通过配置output的filename来生成不同的版本号:
webpackConfig.output.filename = '[name].[chunkhash:8].js';
有了带版本号的js,同时也生成了资源映射记录,那就可以执行版本替换了。
在网上看了下别人的解决方案,基本上都说是用到webpack的html-webpack-plugin
这个插件来处理,或者用gulp的gulp-rev
和gulp-rev-collector
这2个插件处理。但是我感觉都不是很符合我们项目的情况,而且这个应该不难,就自己写了一个版本替换的代码去处理。这些插件后续有时间再研究研究。
在页面模板上,我们通过下面的方式来注册当前页面的使用的js文件到页面底部:
registerJsFile('/dist/annual2/index/index.js');
?>
每次用gulp执行版本替换的时候, 会先读取资源映射文件assets.json
,拿到所有js的映射记录。
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
项目一般会用到第三方库,比如我们会用到zeptojs
,art-template
,crypto-js
等。
单独把这些库打包成一个文件lib.js
,在页面上用script
标签引入。
这可以通过在webpack中配置externals
来处理。
sourcemap
在开发环境下,可以设置webpack的sourcemap
,方便调试。 但是webpack的sourcemap模式非常多,哪个比较好,还没什么时间去细看。 可以参考官方文档
最后
至此,前端项目的第一阶段的改造算是完成了。 我不是前端开发,gulp
与webpack
都是第一次接触然后使用,中间踩了不少的坑,为了解决各种问题,差不多把google
都翻烂了。不过庆幸的是,现在前端开发可以比较顺畅地去写代码了。整个结构看起来比以前赏心悦目了不少。
我觉得这次改造最大的变化不是使用了2个工具使到开发更自动化,而是整个开发的思想与模式都从根本上发生了变化。未来还会继续去做更多的探索与改进。
各位看官对前端开发有更好的建议或者做法,欢迎随时跟我交流。