這是前端工程化實踐系列的第二篇綜合文章,主要內容包括如何設計gulp & webpack構建系統,如何設計gulp子任務,如何實現多項目構建等。所有內容均是基于好奇心日報的項目實踐。
想要看第一篇綜合文章,請移步 前端工程化實踐 之 整合gulp/webpack
為什么需要前端工程化?
前端工程化的意義在于讓前端這個行業由野蠻時代進化為正規軍時代,近年來很多相關的工具和概念誕生。好奇心日報在進行前端工程化的過程中,主要的挑戰在于解決如下問題:
? 如何管理多個項目的前端代碼?
? 如何同步修改復用代碼?
? 如何讓開發體驗更爽?
項目實在太多
之前寫過一篇博文 如何管理被多個項目引用的通用項目?,文中提到過好奇心日報的項目偏多(PC/Mobile/App/Pad),要為這么多項目開發前端組件并維護是一個繁瑣的工作,并且會有很多冗余的工作。
更好的管理前端代碼
前端代碼要適配后臺目錄的規范,本來可以很美好的前端目錄結構被拆得四分五裂,前端代碼分散不便于管理,并且開發體驗很不友好。
而有了前端工程化的概念,前端項目和后臺項目可以徹底分離,前端按自己想要的目錄結構組織代碼, 然后按照一定的方式構建輸出到后臺項目中,簡直完美(是不是有種后宮佳麗三千的感覺)。
技術選型
調研了市場主流的構建工具,其中包括gulp、webpack、fis,最后決定圍繞gulp打造前端工程化方案,同時引入webpack來管理模塊化代碼,大致分工如下:
gulp
:處理html壓縮/預處理/條件編譯,圖片壓縮,精靈圖自動合并等任務
webpack
:管理模塊化,構建js/css。
至于為什么選擇gulp & webpack,主要原因在于gulp相對來說更靈活,可以做更多的定制化任務,而webpack在模塊化方案實在太優秀(情不自禁的贊美)。
怎么設計gulp & webpack構建系統?
構建系統的目錄結構
我們用單獨的appfe目錄存放了構建系統的代碼,以及按前端開發習慣組織的項目代碼。
從上圖看出,gulp 構建系統主要分為以下幾個部分:
appfe/gulpfile.js
:gulp入口文件,除了引入gulp子任務不包含任何邏輯。
appfe/gulp/tasks/*
:構建系統的普通子任務和綜合子任務,每個文件包含邏輯相關的所有子任務。
appfe/gulp/config.xxx.js
:構建系統的配置文件,每個配置文件包含所有子任務需要的參數,不同的配置文件對應不同的項目。
appfe/gulp/libs/*
:構建系統的一些工具函數和輔助文件。
gulp入口文件不要包含任務邏輯
不要嘗試將所有任務的邏輯全部放到gulp入口文件中,那樣的話,隨著項目變得復雜,gulp入口文件將變得無法維護。
var requireDir = require('require-dir');
// 遞歸引入gulp/tasks目錄下的文件
requireDir('./gulp/tasks', { recurse: true });
拆分子任務到單獨的文件
將子任務拆分成單獨的文件,能夠加強其復用性。
此外,個人強烈推薦使用就近原則來組織代碼,這樣可以讓代碼更清晰,邏輯更集中,開發體驗更舒服。對于一個組件來說,就近原則就是將組件相關的文件全部放到一個目錄下,對于一個子任務來說,就近原則就是將相關的任務邏輯全部放到一個文件中。
項目的配置信息不應該放到子任務中
gulp/config.xxx.js
文件包含項目的配置信息,比如要處理的文件,處理后輸出到什么地方等。子任務不應該包含這些信息,而是通過配置文件傳入。這樣做是為了解耦子任務和項目之間的關系,也方便后續對多項目的支持。
抽離工具函數,放到單獨的目錄
工具函數應該是和子任務邏輯無關的通用邏輯,比如格式化時間,美化日志輸出,錯誤處理等,同樣也是為了提高工具函數的復用性。
var gutil = require("gulp-util")
var prettifyTime = require('./prettifyTime')
var handleErrors = require('./handleErrors')
// 美化webpack的日志輸出,強烈推薦!
module.exports = function(err, stats) {
if (err) throw new gutil.PluginError("webpack", err)
var statColor = stats.compilation.warnings.length < 1 ? 'green' : 'yellow'
if (stats.compilation.errors.length > 0) {
stats.compilation.errors.forEach(function(error) {
handleErrors(error)
statColor = 'red'
})
} else {
gutil.log(stats.toString({
colors: gutil.colors.supportsColor,
hash: false,
timings: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
version: false,
cached: false,
cachedAssets: false,
reasons: false,
source: false,
errorDetails: false
}));
}
}
// 防止錯誤中斷gulp任務,并且報錯時notify通知
var notify = require("gulp-notify")
module.exports = function(errorObject, callback) {
notify.onError(errorObject.toString().split(': ').join(':\n')).apply(this, arguments);
// 防止gulp進程掛掉
if (typeof this.emit === 'function') {
this.emit('end');
}
}
怎么設計gulp普通子任務?
子任務的設計嚴格遵循了上文提到了就近原則,這樣可以讓子任務的邏輯高度集中,便于維護,開發體驗也更流暢。
好奇心日報的構建系統覆蓋了常規的gulp子任務,包括:
fonts任務
:處理iconfonts文件。
images任務
:壓縮圖片,移動圖片。
rails任務
:初始化rails項目需要的一些helper/controller/config文件,通常是一些輔助且通用的文件,如果你是PHP項目或者JAVA項目,可以開發對應的輔助文件。
rev任務
:生成時間戳信息,解決瀏覽器JS/CSS/圖片的緩存問題。
sprites任務
:自動合并精靈圖,告別手工時代。
statics任務
:處理常規的靜態文件,比如404.html、500.html等。
views任務
:壓縮/預處理/條件編譯HTML、移動HTML。
webpack任務
:整合webpack到gulp構建系統,用來管理JS/CSS。
webpack子任務是所有子任務中最復雜的一部分,之前有一篇博文專門介紹過,強烈建議閱讀 前端工程化實踐 之 整合gulp/webpack
下面,我挑出幾個典型的gulp子任務來分析分析。為了讓代碼更可讀,我在代碼中添加了很多注釋,同時刪掉了不太重要的部分。
views子任務
該子任務很豐富,包含了很多功能:壓縮/預處理HTML,過濾HTML,多起點目錄輸入。
var gulp = require('gulp');
var gulpif = require('gulp-if');
var streamqueue = require('streamqueue');
var plumber = require('gulp-plumber');
var newer = require('gulp-newer');
var preprocess = require('gulp-preprocess');
var htmlmin = require('gulp-htmlmin');
var logger = require('gulp-logger');
var del = require('del');
var project = require('../lib/project')();
var config = require('../config.' + project).views;
var handleErrors = require('../lib/handleErrors');
// 構建視圖文件
gulp.task('views', function() {
/**
* 配合gulp.src的base屬性,streamqueue特別適合用來解決多起點目錄的問題。
* 比如:獲取src/components和src/pages下的文件,但是
* src/components需要從src開始獲取文件
* src/pages需要從src/pages開始獲取文件
*/
return streamqueue({ objectMode: true },
gulp.src(config.pagesSrc, { base: 'src/pages' }),
gulp.src(config.componentsSrc, { base: 'src' })
)
// 錯誤自啟動,徹底解決gulp錯誤中斷的問題【強烈推薦】
.pipe(plumber(handleErrors))
// 增量更新,加快gulp構建速度【強烈推薦】
.pipe(newer(config.dest))
// 變動日志輸出,和前面的錯誤自啟動、增量更新組成 必備三件套
.pipe(logger({ showChange: true }))
/**
* 根據傳入的參數做預處理或條件編譯,比如:
* 1. 不同項目編譯輸出不同的代碼。
* 2. 不同的開發模式編譯輸出不同的邏輯。
*/
.pipe(preprocess({ context: { PROJECT: project } }))
.pipe(gulp.dest(config.dest));
});
// 構建視圖文件-build版本
gulp.task('build:views', ['clean:views'], function() {
return streamqueue({ objectMode: true },
gulp.src(config.pagesSrc, { base: 'src/pages' }),
gulp.src(config.componentsSrc, { base: 'src' })
)
.pipe(plumber(handleErrors))
.pipe(logger({ showChange: true }))
.pipe(preprocess({ context: { PROJECT: project } }))
// 過濾gulp流中的文件
.pipe(gulpif(function(file) {
if (file.path.indexOf('.html') != -1) {
return true;
} else {
return false;
}
},
/**
* 壓縮html文件及內嵌于HTML中的JS/CSS
* 通過ignoreCustomFragments來適應不同的模板語言
*/
htmlmin({
removeComments: true,
collapseWhitespace: true,
minifyJS: true,
minifyCSS: true,
ignoreCustomFragments: [
/<%[\s\S]*?%>/,
/<\?[\s\S]*?\?>/,
/<meta[\s\S]*?name="viewport"[\s\S]*?>/
]
})))
.pipe(gulp.dest(config.dest));
});
// 清理視圖文件
gulp.task('clean:views', function() {
/**
* 刪除指定的文件或目錄
* force表示強制刪除,慎用
*/
return del([
config.dest + '/*'
], { force: true });
});
images子任務
該子任務比較簡單,主要就是壓縮圖片,然后將圖片輸出到指定目錄中。
var imagemin = require('gulp-imagemin');
// 圖片構建
gulp.task('images', function() {
return gulp.src(config.src)
.pipe(plumber(handleErrors))
.pipe(newer(config.dest))
.pipe(logger({ showChange: true }))
// 壓縮圖片
.pipe(imagemin())
.pipe(gulp.dest(config.dest));
});
// 圖片構建-build版本
gulp.task('build:images', ['images']);
// 清理圖片
gulp.task('clean:images', function() {
return del([
config.dest
], { force: true });
});
sprites子任務
該子任務就是自動合并精靈圖,生成的文件是作為中間產物,進一步提供給其他任務處理,所以該任務不需要build版本。
此外有個地方需要注意一下,每個任務都只能返回一個流,如果想處理多個返回多個流的情況,可以通過merge2合并然后返回,很棒的功能。
# css模板文件,指定了輸出的css規范
{{#sprites}}
.sprite-{{name}}:before {
content: ' ';
background-image: url({{{escaped_image}}});
background-position: {{px.offset_x}} {{px.offset_y}};
width: {{px.width}};
height: {{px.height}};
}
{{/sprites}}
var spritesmith = require('gulp.spritesmith');
var buffer = require('vinyl-buffer');
var merge = require('merge2');
// 構建視圖文件
gulp.task('sprites', function() {
var spriteData = gulp.src(config.src)
.pipe(plumber(handleErrors))
.pipe(newer(config.imgDest))
.pipe(logger({ showChange: true }))
// 自動合并精靈圖
.pipe(spritesmith({
cssName: 'sprites.css',
imgName: 'sprites.png',
// 指定css模板,根據模板生成對應的css代碼
cssTemplate: path.resolve('./gulp/lib/template.css.handlebars')
}));
var imgStream = spriteData.img
.pipe(buffer())
.pipe(gulp.dest(config.imgDest));
var cssStream = spriteData.css
.pipe(gulp.dest(config.cssDest));
// 將多個流合并,然后統一返回,這個是很重要功能
return merge([imgStream, cssStream]);
});
// 清理視圖文件
gulp.task('clean:sprites', function() {
return del([
config.imgDest + '/sprites.png',
config.cssDest + '/sprites.css'
], { force: true });
});
webpack子任務
該子任務相對來說復雜很多,有很多細節,比如:
為什么watch:webpack
子任務沒有callback()
回調?如何處理development/production
模式?等等...
更詳細的內容可以參考之前寫的一篇博客 前端工程化實踐 之 整合gulp/webpack
var _ = require('lodash');
var webpack = require('webpack');
// 生成js/css
gulp.task('webpack', ['clean:webpack'], function(callback) {
// webpack作為一個普通的node模塊使用
webpack(require('../webpack.config.js')(), function(err, stats) {
// 讓webpack的日志輸出更好看
compileLogger(err, stats);
// 這個callback是為了解決gulp異步任務的核心,強烈注意
callback();
});
});
// 生成js/css-監聽模式
gulp.task('watch:webpack', ['clean:webpack'], function() {
webpack(_.merge(require('../webpack.config.js')(), {
watch: true
})).watch(200, function(err, stats) {
compileLogger(err, stats);
// 該異步任務不需要結束,所以不需要callback
// 該任務不結束,所以webpack的增量更新由webpack自己完成
});
});
// 生成js/css-build模式
gulp.task('build:webpack', ['clean:webpack'], function(callback) {
// webpack.config.js返回值是一個函數,而不是一個簡單的json對象
// 接受production參數,可以得到production模式的配置信息
webpack(_.merge(require('../webpack.config.js')('production'), {
devtool: null
}), function(err, stats) {
compileLogger(err, stats);
callback();
});
});
// 清理js/css
gulp.task('clean:webpack', function() {
return del([
config.jsDest,
config.cssDest
], { force: true });
});
怎么設計gulp綜合子任務?
綜合子任務不包含邏輯,都是將前面的子任務拼裝起來,以供命令行使用,比如構建、清理,監聽。
簡而言之,普通子任務是構建任務的核心,包含了所有的構建邏輯。綜合子任務是基于gulp普通子任務的一個自定義套餐。通過拼湊普通子任務來實現綜合子任務的功能。
default任務
默認任務,直接在命令行中運行gulp就會執行該命令了。
// 并行執行sprites,images,views,webpack任務
gulp.task('default', [
'sprites',
'images',
'views',
'webpack'
]);
clean任務
清理任務,好奇心并沒有頻繁的清理圖片,讀者可以根據具體情況決定要不要清理圖片。
// 并行執行clean:sprites,clean:views,clean:webpack
gulp.task('clean', [
'clean:sprites',
'clean:views',
'clean:webpack'
]);
build任務
build任務,適用于production模式。為了保證代碼一致性,建議統一放到跳板機上執行。其中sequence
是為了讓控制任務的執行順序。
var sequence = require('gulp-sequence');
// 順序執行clean,sprites任務,接下來并行執行build:views,build:images,build:webpack任務
gulp.task('build', sequence(
'clean',
'sprites', [
'build:views',
'build:images',
'build:webpack'
]
));
watch任務
監聽任務,適用于開發模式。監聽文件的變化,并觸發指定子任務,增量更新。
var watch = require('gulp-watch');
var project = require('../lib/project')();
var config = require('../config.' + project);
// 先執行一遍,在回調函數中監聽變動
// 由于webpack子任務自己提供watch模式,所以回調中不觸發webpack子任務
gulp.task('watch', [
'views',
'sprites',
'images',
'watch:webpack'
], function() {
// 監聽指定文件的變動,然后出發指定子任務
watch([
config.views.pagesSrc,
config.views.componentsSrc,
], function() {
gulp.start('views');
});
watch(config.sprites.src, function() {
gulp.start('sprites');
});
watch(config.images.src, function() {
gulp.start('images');
});
});
怎么管理多項目?
抽離配置信息
配置信息主要包括要處理的文件,處理之后輸出到什么目錄。
那么抽離出配置信息有什么用呢?是為了讓構建任務和項目解耦,從而讓構建任務更靈活,復用性更好。
關于gulp輸入流的各種正則,可以查看官方文檔。
var feSrc = path.resolve('./src');
var projectDir = path.resolve('../');
module.exports = {
feSrc: feSrc,
projectDir: projectDir,
// webpack任務
webpack: {
context: feSrc + '/pages/@(web|cooperation|users)',
src: getFiles(feSrc + '/pages/@(web|cooperation|users)', 'js'),
jsDest: projectDir + '/app/assets/javascripts',
cssDest: projectDir + '/app/assets/stylesheets'
},
// views任務
views: {
pagesSrc: feSrc + '/pages/@(web|cooperation|users)/**/*+(erb|builder)',
componentsSrc: feSrc + '/components/@(web|cooperation|users)/**/*.erb',
dest: projectDir + '/app/views'
},
// images任務
images: {
src: [
feSrc + '/images/**/*+(jpg|jpeg|png|gif|svg)'
],
dest: projectDir + '/public/images'
},
// sprites任務
sprites: {
src: feSrc + '/sprites/web/**/*',
cssDest: feSrc + '/components/web/common',
imgDest: feSrc + '/images/web'
}
};
為了實現對多項目的支持,我們抽離了多個配置文件,每個配置文件對應一個單獨的項目,見下圖:
用npm統一項目命令
雖然通過多個配置文件,我們可以實現對多項目的支持,但是每次運行代碼的時候,還得傳參區分當前運行的項目,比如gulp watch --web
,還挺麻煩的。
為了解決上面的問題,我們可以通過npm來對gulp命令做一層封裝。只需要運行簡單命令即可:開發模式:npm run watch
、清除構建輸出:npm run clean
、本地build:npm run build
"scripts": {
"init": "cd appfe && npm install --local",
"clean": "cd appfe && gulp clean --web",
"dev": "cd appfe && gulp --web",
"watch": "cd appfe && gulp watch --web",
"build": "cd appfe && gulp build --web"
}
來個總結?
本文從如何設計一套基于gulp & webpack的構建系統出發,展示了好奇心日報項目中的具體方案和一些思路:
? 不要將任務邏輯全部寫到appfe/gulpfile.js
文件中,它只需簡單的引入全部子任務文件即可。
? 將所有的子任務文件保存到appfe/gulp
目錄,并且每個子任務采用就近原則維護代碼,即相關邏輯的任全部放到一個文件中。
? 子任務分為兩種,普通子任務包含構建任務的核心邏輯,綜合子任務是基于普通子任務的自定義套餐。
? 抽離工具函數到單獨的文件夾,便于復用。比如時間格式化,webpack日志美化,錯誤處理等。
? 抽離項目配置信息到單獨的文件,讓構建任務和項目解耦。
? 使用npm封裝gulp命令,讓命令變得更簡單可用。
總結就是承上啟下,告訴你這篇文章終于BB完了,然后還有下一篇文章等你來,多么痛的領悟!
如果覺得本文不錯,歡迎收藏點贊,同時也歡迎留言溝通。