想要設計gulp & webpack構建系統?看這兒!

這是前端工程化實踐系列的第二篇綜合文章,主要內容包括如何設計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構建系統目錄結構

從上圖看出,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完了,然后還有下一篇文章等你來,多么痛的領悟!

如果覺得本文不錯,歡迎收藏點贊,同時也歡迎留言溝通。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,416評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370

推薦閱讀更多精彩內容