gulp & webpack整合,鱼与熊掌我都要!

为什么需要前端工程化?

前端工程化的意义在于让前端这个行业由野蛮时代进化为正规军时代,近年来很多相关的工具和概念诞生。好奇心日报在进行前端工程化的过程中,主要的挑战在于解决如下问题:
✦ 如何管理多个项目的前端代码?
✦ 如何同步修改复用代码?
✦ 如何让开发体验更爽?

项目实在太多

之前写过一篇博文 如何管理被多个项目引用的通用项目?,文中提到过好奇心日报的项目偏多(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提供的requirerequire.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时间戳,解决浏览器缓存的问题。

总结说点啥?

前端工程化可以自动化处理一些繁复的工作,提高开发效率,减少低级错误。
更重要的是,还是文章开头的说的,前端工程化最大的意义在于给我们新的视角去看待前端开发,让前端开发可以做更复杂、更有挑战的事情!

这是前端工程化实践的第一篇博文,后续还有对之前零零散散的博文的总结。不过整体来说,webpack任务是最复杂、涵盖知识最多的一个任务。
文章若有纰漏,欢迎大家指正。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容