前端工程化可以自動化處理一些繁複的工作,提高開發效率,減少低級錯誤。 更重要的是,還是文章開頭的說的,前端工程化最大的意義在於給我們新的視角去看待前端開發,讓前端開發可以做更複雜、更有挑戰的事情! ...
為什麼需要前端工程化?
前端工程化的意義在於讓前端這個行業由野蠻時代進化為正規軍時代,近年來很多相關的工具和概念誕生。好奇心日報在進行前端工程化的過程中,主要的挑戰在於解決如下問題:
✦ 如何管理多個項目的前端代碼?
✦ 如何同步修改復用代碼?
✦ 如何讓開發體驗更爽?
項目實在太多
之前寫過一篇博文 如何管理被多個項目引用的通用項目?,文中提到過好奇心日報的項目偏多(PC/Mobile/App/Pad),要為這麼多項目開發前端組件並維護是一個繁瑣的工作,並且會有很多冗餘的工作。
更好的管理前端代碼
前端代碼要適配後臺目錄的規範,本來可以很美好的前端目錄結構被拆得四分五裂,前端代碼分散不便於管理,並且開發體驗很不友好。
而有了前端工程化的概念,前端項目和後臺項目可以徹底分離,前端按自己想要的目錄結構組織代碼, 然後按照一定的方式構建輸出到後臺項目中,簡直完美(是不是有種後宮佳麗三千的感覺)。
技術選型
調研了市場主流的構建工具,其中包括gulp、webpack、fis,最後決定圍繞gulp打造前端工程化方案,同時引入webpack來管理模塊化代碼,大致分工如下:gulp
:處理html壓縮/預處理/條件編譯,圖片壓縮,精靈圖自動合併等任務webpack
:管理模塊化,構建js/css。
至於為什麼選擇gulp & webpack,主要原因在於gulp相對來說更靈活,可以做更多的定製化任務,而webpack在模塊化方案實在太優秀(情不自禁的贊美)。
怎麼設計前端項目目錄結構?
抽離出來的前端項目目錄結構如下

前端項目結構
appfe目錄
:appfe就是前面提到的前端項目,這個項目主要包含兩部分:前端代碼、構建任務appfe > gulp目錄
:包含了所有的gulp子任務,每個子任務包含相關任務的所有邏輯。appfe > src目錄
:包含了所有前端代碼,比如頁面、組件、圖片、字體文件等等。appfe > package.json
:這個不用說了吧。appfe > gulpfile.js
:gulp入口文件,引入了所有的gulp子任務。
理想很豐滿,現實卻很骨感,這麼美好的願望,在具體實踐過程中,註定要花不少心思,要踩不少坑。
好奇心日報這次升級改造即將上線,終於也有時間把之前零零碎碎的博文整合在一起,並且結合自己的體會分享給大家,當然未來可能還會有較大的調整,這兒拋磚引玉,大家可以參考思路。
gulp 是什麼?
gulp是一個基於流的構建工具,相對其他構件工具來說,更簡潔更高效。
Tip:之前寫過一篇gulp 入門,可以參考下,如果對gulp已經有一定的瞭解請直接跳過。
webpack 是什麼?
webpack是模塊化管理的工具,使用webpack可實現模塊按需載入,模塊預處理,模塊打包等功能。
Tip:之前寫過一篇webpack 入門,可以參考下,如果對webpack已經有一定的瞭解請直接跳過。
如何整合gulp & webpack
webpack是眾多gulp子任務中比較複雜的部分,主要對JS/CSS進行相關處理。
包括:模塊分析、按需載入、JS代碼壓縮合併、抽離公共模塊、SourceMap、PostCSS、CSS代碼壓縮等等...
webpack-stream方案[不推薦]
使用webpack-stream雖然可以很方便的將webpack整合到gulp中,但是有致命的問題存在:
如果關閉webpack的監聽模式,那麼每次文件變動就會全量編譯JS/CSS文件,非常耗時。
如果打開webpack的監聽模式,那麼會阻塞其他gulp任務,導致其他gulp任務的監聽失效。
所以這種方案幾乎不可用!
webpack原生方案
直接使用webpack原生方案,相對來說更靈活。
Tip:代碼較複雜,裡面涉及的知識點也很多,建議看看形狀就好,如果真有興趣,可以好好研究研究,畢竟花了很長時間去思考這些方案。
// webpack.config.js 關鍵地方都有大致註釋
var _ = require('lodash');
var path = require('path');
var webpack = require('webpack');
var ExtractTextPlugin = require("extract-text-webpack-plugin");
var autoprefixer = require('autoprefixer');
var flexibility = require('postcss-flexibility');
var sorting = require('postcss-sorting');
var color_rgba_fallback = require('postcss-color-rgba-fallback');
var opacity = require('postcss-opacity');
var pseudoelements = require('postcss-pseudoelements');
var will_change = require('postcss-will-change');
var cssnano = require('cssnano');
var project = require('./lib/project')();
var config = require('./config.' + project).webpack;
// loaders配置
var getLoaders = function(env) {
return [{
test: /\.jsx?$/,
exclude: /(node_modules|bower_components|vendor)/,
loader: 'babel?presets[]=es2015&cacheDirectory=true!preprocess?PROJECT=' + project
}, {
test: /\.css$/,
loader: ExtractTextPlugin.extract("style-loader", "css-loader!postcss-loader")
}, {
test: /\.less$/,
loader: ExtractTextPlugin.extract("style-loader", "css-loader!postcss-loader!less-loader")
}, {
test: /\/jquery\.js$/,
loader: 'expose?$!expose?jQuery!expose?jquery'
}, {
test: /\.xtpl$/,
loader: 'xtpl'
}, {
test: /\.modernizrrc$/,
loader: "modernizr"
}];
};
// 別名配置
var getAlias = function(env) {
return {
// 特殊
'jquery': path.resolve(__dirname, '../src/vendor/jquery2/jquery.js'),
// 正常第三方庫
'jquery.js': path.resolve(__dirname, '../src/vendor/jquery2/jquery.js'),
};
};
// 插件配置
var getPlugins = function(env) {
var defaultPlugins = [
// 這個不僅是別名,還可以在遇到別名的時候自動引入模塊
new webpack.ProvidePlugin({
'$': 'jquery.js',
'jquery': 'jquery.js',
'jQuery': 'jquery.js',
}),
// 抽離公共模塊
new webpack.optimize.CommonsChunkPlugin('common', 'common.js'),
new ExtractTextPlugin(
path.join('../../stylesheets', project, '/[name].css'), {
allChunks: true
}
)
];
if (env == 'production') {
// 線上模式的配置,去除依賴中重覆的插件/壓縮js/排除報錯的插件
plugins = _.union(defaultPlugins, [
new webpack.optimize.DedupePlugin(),
new webpack.optimize.UglifyJsPlugin({
sourceMap: false,
mangle: {
except: ['$', 'jQuery']
}
}),
new webpack.NoErrorsPlugin()
]);
} else {
plugins = _.union(defaultPlugins, []);
}
return plugins;
};
// postcss配置
var getPostcss = function(env) {
var postcss = [
autoprefixer({ browers: ['last 2 versions', 'ie >= 9', '> 5% in CN'] }),
flexibility,
will_change,
color_rgba_fallback,
opacity,
pseudoelements,
sorting
];
if (env == 'production') {
// 線上模式的配置,css壓縮
return function() {
return _.union([
cssnano({
// 關閉cssnano的autoprefixer選項,不然會和前面的autoprefixer衝突
autoprefixer: false,
reduceIdents: false,
zindex: false,
discardUnused: false,
mergeIdents: false
})
], postcss);
};
} else {
return function() {
return _.union([], postcss);
}
}
};
// 作為函數導出配置,代碼更簡潔
module.exports = function(env) {
return {
context: config.context,
entry: config.src,
output: {
path: path.join(config.jsDest, project),
filename: '[name].js',
chunkFilename: '[name].[chunkhash:8].js',
publicPath: '/assets/' + project + '/'
},
devtool: "eval",
watch: false,
profile: true,
cache: true,
module: {
loaders: getLoaders(env)
},
resolve: {
alias: getAlias(env)
},
plugins: getPlugins(env),
postcss: getPostcss(env)
};
}
// webpack任務
var _ = require('lodash');
var del = require('del');
var webpack = require('webpack');
var gulp = require('gulp');
var plumber = require('gulp-plumber');
var newer = require('gulp-newer');
var logger = require('gulp-logger');
var project = require('../lib/project')();
var config = require('../config.' + project).webpack;
var compileLogger = require('../lib/compileLogger');
var handleErrors = require('../lib/handleErrors');
// 生成js/css
gulp.task('webpack', ['clean:webpack'], function(callback) {
webpack(require('../webpack.config.js')(), function(err, stats) {
compileLogger(err, stats);
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);
});
});
// 生成js/css-build模式
gulp.task('build:webpack', ['clean:webpack'], function(callback) {
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任務較多,並且每個核心任務都有關聯任務,比如webpack的關聯任務就有webpack
/watch:webpack
/build:webpack
/clean:webpack
,如何組織這些子任務是一個需要很小心的事情,出於一直以來的習慣:把關聯的邏輯放在一起,所以我的方案是webpack相關的任務放到一個文件,然後定義了default
/clean
/watch
/build
四個入口任務來引用對應的子任務。

webpack任務結構
gulp怎麼實現錯誤自啟動
使用watch模式可以更高效的開發,監聽到改動就自動執行任務,但是如果過程中遇到錯誤,gulp就會報錯並終止watch模式,必須重新啟動gulp,簡直神煩!
利用gulp-plumber可以實現錯誤自啟動,這樣就能開心的在watch模式下開發且不用擔心報錯了。
進一步結合gulp-notify,在報錯時可以得到通知,便於發現問題。
// 錯誤處理
var notify = require("gulp-notify")
module.exports = function(errorObject, callback) {
// 錯誤通知
notify.onError(errorObject.toString().split(': ').join(':\n'))
.apply(this, arguments);
// Keep gulp from hanging on this task
if (typeof this.emit === 'function') {
this.emit('end');
}
}
// 任務
var gulp = require('gulp');
var plumber = require('gulp-plumber');
var project = require('../lib/project')(); // 得到當前的後臺項目
var config = require('../config.' + project).views; // 讀取配置文件
var handleErrors = require('../lib/handleErrors');
gulp.task('views', function() {
return gulp.src(config.src)
.pipe(plumber(handleErrors)) // 錯誤自啟動
.pipe(gulp.dest(config.dest));
});
gulp怎麼處理同步任務和非同步任務
同步任務
:gulp通過return stream
的方式來結束當前任務並且把stream
傳遞到下一個任務,大多數gulp任務都是同步模式。非同步任務
:實際項目中,有些任務的邏輯是非同步函數執行的,這種任務的return時機並不能準確把控,通常需要在非同步函數中調用callback()
來告知gulp該任務結束,而這個callback
什麼都不是,就是傳到該任務中的一個參數,沒有實際意義。
// 同步任務
gulp.task('views', function() {
return gulp.src(config.src)
.pipe(plumber(handleErrors))
.pipe(gulp.dest(config.dest));
});
// 非同步任務
gulp.task('webpack', function(callback) {
webpack(config, function(err, stats) {
compileLogger(err, stats);
callback(); //非同步任務的關鍵之處,如果沒有這行,任務會一直阻塞
});
});
webpack怎麼抽出獨立的css文件
webpack預設是將css直接註入到html中,這種方法並不具有通用性,不推薦使用。
結合使用extract-text-webpack-plugin
,可以生成一個獨立的css文件,extract-text-webpack-plugin
會解析每一個require('*.css')
然後處理輸出一個獨立的css文件。
// webpack.config.js
var ExtractTextPlugin = require("extract-text-webpack-plugin");
module.exports = {
entry: {
'homes/index': 'pages/homes/index.js'
},
output: {
filename: "[name].js"
},
module: {
loaders: [{
test: /\.css$/,
loader: ExtractTextPlugin.extract("style-loader", "css-loader")
}]
},
plugins: [
new ExtractTextPlugin("[name].css")
]
}
webpack怎麼抽出通用邏輯和樣式
沒有webpack之前,想要抽離出公共模塊完全需要手動維護,因為js是動態語言,所有依賴都是運行時才能確定,webpack可以做靜態解析,分析文件之間的依賴關係,使用CommonsChunkPlugin
就可以自動抽離出公共模塊。
// webpack.config.js
var webpack = require('webpack');
var ExtractTextPlugin = require("extract-text-webpack-plugin");
module.exports = {
entry: {
'homes/index': 'pages/homes/index.js'
},
output: {
filename: "[name].js"
},
module: {
loaders: [{
test: /\.css$/,
loader: ExtractTextPlugin.extract("style-loader", "css-loader")
}]
},
plugins: [
//抽離公共模塊,包含js和css
new webpack.optimize.CommonsChunkPlugin("commons", "commons.js"),
new ExtractTextPlugin("[name].css")
]
}
webpack的watch模式
webpack相對來說比較耗時,尤其是項目較複雜時,需要解析的文件較多。好奇心日報web項目首次全量執行webpack任務大概需要10s,所以必須引入增量構建。增量構建只需要簡單的給webpack配置添加watch參數即可。

webpack任務輸出日誌
但是問題在於,如果給webpack-stream添加watch參數,webpack-stream的任務會阻塞其他的watch任務,最後導致其他任務的增量構建失效。
所以如果要使用webpack的增量構建,需要使用原生的webpack方案!
靈活的webpack入口文件
webpack入口文件接收三種格式:字元串,數組,對象,對於多頁應用場景,只有對象能夠滿足條件,所以我們把所有的入口文件全部列出來即可。
但這種方案極不靈活,借鑒gulp的方案,是否可以讀取某個文件下的所有入口文件呢?為瞭解決這個問題,自定義了一個函數來實現該功能。
//獲取文件夾下麵的所有的文件(包括子文件夾)
var path = require('path'),
glob = require('glob');
module.exports = function(dir, ext) {
var files = glob.sync(dir + '/**/*.' + ext),
res = {};
files.forEach(function(file) {
var relativePath = path.relative(dir, file),
relativeName = relativePath.slice(0, relativePath.lastIndexOf('.'));
res[relativeName] = './' + relativePath;
});
return res;
};
webpack的development/production配置合併
webpack任務的development配置和production配置差異巨大,並且各自擁有專屬的配置。
由於webpack.config.js
預設寫法是返回一個對象,對象並不能根據不同條件有不同的輸出,所以將webpack.config.js
改成函數,通過傳入參數來實現不同的輸出。
// 其中定義了getLoaders,getAlias,getPlugins,getPostcss函數
// 都是為瞭解決development配置和production配置的差異問題
// 既最大程度的復用配置,又允許差異的存在
module.exports = function(env) {
return {
context: config.context,
entry: config.src,
output: {
path: path.join(config.jsDest, project),
filename: '[name].js',
chunkFilename: '[name].[chunkhash:8].js',
publicPath: '/assets/' + project + '/'
},
devtool: "eval",
watch: false,
profile: true,
cache: true,
module: {
loaders: getLoaders(env)
},
resolve: {
alias: getAlias(env)
},
plugins: getPlugins(env),
postcss: getPostcss(env)
};
}
webpack怎麼線上模式非同步載入js文件
webpack可以將js代碼分片,把入口文件依賴的所有模塊打包成一個文件,但是有些場景下的js代碼並不需要打包到入口文件中,更適合非同步延遲載入,這樣能最大程度的提升首屏載入速度。
比如好奇心日報的登錄浮層,這裡麵包含了複雜的圖片上傳,圖片裁剪,彈框的邏輯,但是它沒必要打包在入口文件中,反倒很適合非同步延遲載入,只有當需要登錄/註冊的時候才去請求。

圖片上傳裁剪
我們可以通過webpack提供的require
及require.ensure
來實現非同步載入,值得一提的是,除了指定的非同步載入文件列表,webpack還會自動解析回調函數的依賴及指定列表的深層次依賴,並打包成一個文件。
但是實際項目中還得解決瀏覽器緩存的問題,因為這些非同步JS文件的時間戳是rails生產的,對於webpack是不可知的,也就是說請求這個非同步JS文件並不會命中。
為瞭解決這個問題,我們在rails4中自定義了一個rake任務:生產沒有時間戳版本的非同步JS文件。

rake任務
上圖中還有一個小細節就是,這些非同步JS文件有兩個時間戳,前者為webpack時間戳,後者為rails時間戳,之所以有兩個時間戳,是為瞭解決瀏覽器緩存的問題。
簡而言之就是:
通過require
/require.ensure
,來生成非同步JS文件,解決非同步載入的問題。
通過自定義rake任務,來生成沒有rails時間戳的非同步JS文件,解決webpack不識別rails時間戳的問題。
通過webpack的chunkFileName配置,給非同步JS文件加上webpack時間戳,解決瀏覽器緩存的問題。
學習過程中遇到什麼問題或者想獲取學習資源的話,歡迎加入學習交流群
343599877,我們一起學前端!