想要设计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阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容