强化:构建易用易扩展的工作流

一、回顾与思考

在上一节的【进阶:构建具备版本管理能力的项目】中我们讲解了如何用webpack去搭建一个工作流。

我们说webpack有很多的loader用来编译打包静态资源,而gulp也有很多的以gulp-*格式命名的工作模块用来处理各种资源文件,那webpack和gulp是什么样的联系?有什么样的区别?

这也是很多初学者没有搞明白的,webpack和gulp是不是同类型的工具?

webpack专注于处理各种资源,而gulp专注于任务管理,两者的职能是不同的。打个比方:

gulp好比是大boss,平时要管理包括运营、产品、设计、开发、财务、后勤等等各个部门的工作,但是其实大boss只想知道产品做成什么样、财务剩下多少钱,其他的部门他能管理,但是太琐碎了。现在来了一个叫做webpack的小伙子,告诉大boss说,我能帮你管理运营、设计、开发、后勤这几个部门的工作,你给我个副总当当,然后你就只需要管理产品、财务,还有我这三个对象就好了。两人一拍即合,从此过上了幸福的生活。

没错,我不骗你,就是这么狗血。

虽然单靠webpack也可以搭建一套像模像样的工作流出来,gulp没有webpack一样也活得很好。但是我们拨开表象看本质,gulp的任务管理能力很强,webpack处理资源很方便,为何不结合起来使用呢?

嗯,就这么干!这一节我们就来尝试使用gulp+webpack构建一个又好用又容易扩展功能模块的工作流。我们以gulp为大框架,整合webpack的方式来开展。

二、编译打包

我们先把第一节的gulpfile.js文件搬出来,老规矩,我们先来实现打包的工作,所以我们先把dev相关的内容剃掉:

var gulp = require('gulp')
var sass = require('gulp-sass')
var swig = require('gulp-swig')

gulp.task('sass', function () {
  return gulp.src('src/sass/*.scss')
  .pipe(sass({
    outputStyle: 'compressed'  // 此配置使文件编译并输出压缩过的文件
  }))
  .pipe(gulp.dest('dist/static'))
})

gulp.task('js', function () {
  return gulp.src('src/js/*.js')
  .pipe(gulp.dest('dist/static'))
})

gulp.task('tpl', function () {
  return gulp.src('src/tpl/*.swig')
  .pipe(swig({
    defaults: {
      cache: false  // 此配置强制编译文件不缓存
    }
  }))
  .pipe(gulp.dest('dist'))
})

gulp.task('build', ['sass', 'js', 'tpl'])

我们说好相关资源的处理工作是要交给webpack的,所以我们还需要把sassjstpl三个gulp任务给去掉,取而代之的是webpack的loader:

var gulp = require('gulp')
var webpack = require('webpack')

gulp.task('webpack', function () {
  // do something...
})

gulp.task('build', ['webpack'])

嗯,我们已经基本能够预料到接下来的步骤了,填充一下这个webpack任务,而我们在上一节已经有过直接使用webpack的api来工作的尝试,我们直接使用webpack_base里的webpack.config.js文件:

var gulp = require('gulp')
var webpack = require('webpack')
var config = require('./webpack.config.js')

gulp.task('webpack', function () {
  return webpack(config)
})

gulp.task('build', ['webpack'])

好像好简单的样子,一切都好顺利肿么办,心情好到飞起,于是迫不及待地在命令行输入:

gulp webpack

愉快地回车!

好,居然没有报错,完美地兼容了!

回头看一眼根目录!啊。。。。。。说好的dist目录呢?为啥什么都没有生成?!!!

由于webpack处理资源的时候是一系列的异步操作,而gulp并不知道你什么时候处理完了资源,所以对于gulp来说,你webpack的任务从开始到结束我默认当你是同步的,这个任务开始之后,不等你处理完我就已经结束掉了这个工作。所以webpack需要一个操作来告诉gulp,我webpack什么时候处理完了这些资源。

我们先来看最终的代码:

var gulp = require('gulp')
var webpack = require('webpack')
var config = require('./webpack.config.js')

gulp.task('webpack', function (cb) {
  webpack(config, function () {
    cb()
  })
})

gulp.task('build', ['webpack'])

这里的cb(callback简写)就是一个回调操作,我们通过在webpack完成编译之后的回调里,调用gulp的回调函数,来达到通知gulp任务完成的目的。这里可能一时不好理解,大家花点心思琢磨一下。

之后我们在命令行里执行编译操作,就能看到根目录下生成的dist文件夹了,里面也存放了被打包了的文件。

这里有个问题,我们在命令行里只能看到:

[23:35:49] Using gulpfile ~/gulp-webpack_base/gulpfile.js
[23:35:49] Starting 'webpack'...
[23:35:49] Finished 'webpack' after 20 ms

然后就没有其他信息了。我们期待什么呢?我们期待能够看到webpack的编译信息,上面我提到过,webpack没有任何的报错信息,事实上就算是真的有错误,也完全不会有任何提示信息出现,在gulp中如果需要输出模块自己的信息,我们需要借助于 gulp-util ,这个工具我们在创建ftp任务的时候已经见过面了,具体修改如下:

var gulp = require('gulp')
var webpack = require('webpack')
var config = require('./webpack.config.js')
var gutil = require('gulp-util')

gulp.task('webpack', function (cb) {
  webpack(config, function (err, stats) {
    if (err) {
      throw new gutil.PluginError('webpack', err)
    }

    gutil.log('[webpack]', stats.toString({
      colors: true,
      chunks: false
    }))

    cb()
  })
})

gulp.task('build', ['webpack'])

这样一来,打包的工作就完成了。

开发环境

不知道大家有没有听过express

Express 是一个基于 Node.js 平台的极简、灵活的 web 应用开发框架,它提供一系列强大的特性,帮助你创建各种 Web 和移动设备应用。 —— express中文网

简单点说,express就是一个基于nodejs的web框架,我们可以用它来很方便地构建一个web项目。而我们这一次使用它的目的是用它来构建一个本地开发服务器。事实上我们上次演示用到的 webpack-dev-server 底层就是用express+webpack-dev-middleware实现的,并且我们还要结合webpack的webpack-dev-middlewarewebpack-hot-middleware两个中间件,来了却之前我们没有完成的心愿——热更新。

如果没了解过express,可以在看完本节之后,再去官网补充一些知识点,本节只是用到了一些简单用法。

二话不说,我们先在gulpfile.js中把需要的几个依赖包引入,并且为了方便,我们也直接使用上一节中webpack的开发配置文件webpack.dev.config.js

...
var webpackHotMiddleware =  require('webpack-hot-middleware')
var webpackDevMiddleware =  require('webpack-dev-middleware')
var devConfig = require('./webpack.dev.config.js')
var express = require('express')
var app = express()
...

这里的app便是我们要使用的本地服务器,相对应上一节中的webpackDevServer,然后我们来书写一下server任务:

gulp.task('server', function () {
  app.listen(8080, function (err) {
    if (err) {
      console.log(err)
      return
    }
    console.log('listening at http://localhost:8080')
  })
})

其实这时候我们已经基本完成了一个服务器搭建,我们在命令行里输入:

gulp server

然后回车,然后在浏览器里输入http://localhost:8080,我们可以看到其实这个服务已经跑起来了,而我们只能在页面上看到Cannot GET /,是因为我们还没有定义路由规则,接下来,我们的工作基本就集中在定于路由规则上。

我们先来试试随便指定根路径,返回一个hello, world!试试:

gulp.task('server', function () {
  app.use('/', function (req, res) {
    res.send('hello, world!')
  })

  app.listen(8080, function (err) {
    if (err) {
      console.log(err)
      return
    }
    console.log('listening at http://localhost:8080')
  })
})

现在我们重复上面的操作,命令行输入gulp server然后回车一下,刷新浏览器,是不是就看到了hello, world!

聪明的人一下子就想到了express的另外一个用途:为项目写接口,返回一些假数据。这样做当然可以,大家可以自己去亲手尝试一遍。

回到正题,我们希望代码更加清晰,功能更加专一,我们把定义路由的工作,从任务server迁移出来,放到另外一个任务server:init去做,这样可以使得任务的功能更加单薄,更容易开发调试:

gulp.task('server:init', function () {
  app.use('/', function (req, res) {
    res.send('hello, world!')
  })
})

gulp.task('server', ['server:init'], function () {
  app.listen(8080, function (err) {
    if (err) {
      console.log(err)
      return
    }
    console.log('listening at http://localhost:8080')
  })
})

好了,我们来插播一段知识点,关于middleware(中间件):

中间件是一种独立的系统软件或服务程序,分布式应用软件借助这种软件在不同的技术之间共享资源。 —— 百度百科

说的有点抽象,简单说来,middleware就是一个提供某种服务的组件,它遵循某种公共的协议或约定,可以整合到各种框架当中。

webpack-dev-middleware和webpack-hot-middleware就是两个中间件,前者提供webpack编译打包的服务,后者提供热更新的服务,两者组合在一起,就是我们之前接触过的webpack-dev-server。

我们单独拿出来,是因为我们需要去订制一套自己的『webpack-dev-server』。上一节我们讲到webpack-dev-server的热更新的时候,遇到一个问题,那就是html模板无法自动刷新的问题,所以我们只能放弃hot-replacement的功能,使用reload的形式,虽然效果也不会差到哪里去,但是既然我们用着webpack,那就没理由舍弃这个特性,我们的目标是:装逼装到底 送佛送到西。

我们已经借助于express搭建了一个本地服务器,我们把测试的路由给去掉,接下来我们来试着整合webpack-dev-middleware:

gulp.task('server:init', function () {
  var compiler = webpack(devConfig)
  var devMiddleware = webpackDevMiddleware(compiler, {
    stats: {
      colors: true,
      chunks: false
    }
  })

  app.use(devMiddleware)
})

是不是跟webDevServer的配置十分相似?

我们命令行里跑一下,刷新浏览器,效果出来啦啦啦啦啦!完美。

我们接着整合webpack-hot-middleware,文档是这么要求的:

  1. 增加以下plugin:new webpack.optimize.OccurenceOrderPlugin()new webpack.HotModuleReplacementPlugin()new webpack.NoErrorsPlugin()
  2. 每个入口增加webpack-hot-middleware/client?reload=true

操作下来,得到:

gulp.task('server:init', function () {
  for (var key in devConfig.entry) {
    var entry = devConfig.entry[key]
    entry.unshift('webpack-hot-middleware/client?reload=true')
  }

  devConfig.plugins.unshift(
    new webpack.optimize.OccurenceOrderPlugin(),
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoErrorsPlugin()
  )

  var compiler = webpack(devConfig)
  var devMiddleware = webpackDevMiddleware(compiler, {
    hot: true,
    stats: {
      colors: true,
      chunks: false
    }
  })

  var hotMiddleware = webpackHotMiddleware(compiler)

  app.use(devMiddleware)
  app.use(hotMiddleware)
})

我们命令行跑一下,然后修改一下index.scss,保存,我们可以在浏览器里直接看到修改了!

但是我们如果修改index.swig,保存后,还是看不到浏览器刷新,这跟我们上次使用webpackDevServer遇到的情况一样。

解决这个问题的方式比较曲折,我们需要解决两个问题:

  1. 如何知道用户修改并保存了html;
  2. 如何手动通知浏览器刷新页面。

问题一
第一个问题比较好解决,那就是为html-webpack-plugin在修改文件并保存之后,注册一个回调,用来告诉我们文件被修改了:

compiler.plugin('compilation', function(compilation) {
  compilation.plugin('html-webpack-plugin-after-emit', function(data, callback) {
    // 需要在这里通知浏览器刷新页面
    callback()
  })
})

详细用法可以html-webpack-plugin的参考官方文档,内容太多,这里不详细介绍。

问题二
第二个问题,我们首先需要了解webpack-hot-middleware/client?reload=true到底是什么?

为了方便我们就简称它为client

事实上,client就是我们注入到浏览器中的脚本,我们在编辑器里进行的一系列修改,浏览器自动更新,这中间的通讯过程就是由它来完成的,简单来说,我项目的文件修改了,服务器(hotMiddleware)便发送指令给client,client在接收到指令之后,根据指令的内容,相对应地完成工作,如刷新页面,更新资源等等。

我们假设它有一个指令叫做 reload ,那我们可以这样操作:

compiler.plugin('compilation', function(compilation) {
  compilation.plugin('html-webpack-plugin-after-emit', function(data, callback) {
    hotMiddleware.publish({ action: 'reload' })
    callback()
  })
})

我们通过使用hotMiddleware来发布(publish)一个action为reload的指令。嗯,这样是可行的,接下来我们需要来实现这个 reload

因为client本没有这个指令的相关内容,所以我们需要来对它进行扩展,我们在根目录下新建一个 client.js 文件,内容如下:

var client = require('webpack-hot-middleware/client?reload=true')

client.subscribe(function (obj) {
  if (obj.action === 'reload') {
    window.location.reload()
  }
})

我们引入了之前在入口的时候配置的client,然后对它扩展了一个action为reload的类型,并且定义了刷新的脚本。这样我们就完成了对client的功能扩展,以及在修改html的时候,对client发布一个reload的指令这样一个过程。

最后一步,我们把之前我们引入的client(也就是webpack-hot-middleware/client?reload=true)替换成我们自己的client,得到最终的server:init

gulp.task('server:init', function () {
  for (var key in devConfig.entry) {
    var entry = devConfig.entry[key]
    entry.unshift('./client.js')
  }

  devConfig.plugins.unshift(
    new webpack.optimize.OccurenceOrderPlugin(),
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoErrorsPlugin()
  )

  var compiler = webpack(devConfig)
  var devMiddleware = webpackDevMiddleware(compiler, {
    hot: true,
    stats: {
      colors: true,
      chunks: false
    }
  })

  var hotMiddleware = webpackHotMiddleware(compiler)

  compiler.plugin('compilation', function(compilation) {
    compilation.plugin('html-webpack-plugin-after-emit', function(data, callback) {
      hotMiddleware.publish({ action: 'reload' })
      callback()
    })
  })

  app.use(devMiddleware)
  app.use(hotMiddleware)
})

ok,我们命令行里输入gulp server,然后回车一下!

修改文件,保存,浏览器自动刷新了!css是热更新的,swig文件也可以自动刷新页面了!

到这里为止,我们的开发环境也已经是构建完成了,可以应付开发与打包的工作。

但是由于我的字数还没达到要求不能交卷,所以我需要继续扯下去。

既然我们标题已经说好了是要构建一个 易用易扩展 的工作流,那怎么的也得扩展点东西来看看吧?

嗯,好吧,自己装的逼,怎么的也得自圆其说才行。

mock&proxy

我们来谈谈,项目开发中,如何mock数据。

我们现在讨论的是 基于通过api获取数据的前后端分离模式 。假设我们当前有以下两种情况:

  1. 后端还没写好接口,我们需要自己来生成一些假数据;
  2. 后端已经写好接口,我们本地开发调用的时候需要解决跨域问题。

第一个问题很好解决,我们在之前整合express的时候已经稍微提到了一下,我们可以自己写路由来满足调用,我们首先拦截路由/api/:method,然后写一个mock-middleware来专门处理它的请求,任务变成了这样(注:这里我们为了专注于mock,把前面middleware的内容给省略掉):

var mockMiddleware = require('./mock-middleware.js')

gulp.task('server:init', function () {
  app.use('/api/:method', mockMiddleware)
})

我们来实现这个mock-middleware,其实很简单:

var map = {
  hello: {
    data: [1, 2, 3],
    msg: null,
    status: 0
  }
}

module.exports = function (req, res, next) {
  var apiKey = req.params.method

  if (apiKey in map) {
    res.json(map[apiKey])
  } else {
    res.status(404).send('api no found!')
  }
}

代码也不多,而且都是字面上的意思,咱们简单一点介绍过去:我们首先获取url中的method保存为apiKey,然后我们预先定义好一个map,这个map包含了所有的mock数据,我们定义了一个hello的接口,最后拿apiKey匹配map,如果匹配则返回预设的数据,如果不匹配则返回一个404页面。

第一个问题我们就这么解决了,我们看第二个问题,重点在于:解决跨域问题。

如何解决跨域问题?用服务器代理(proxy)接口。

代理(英语:Proxy),也称网络代理,是一种特殊的网络服务,允许一个网络终端(一般为客户端)通过这个服务与另一个网络终端(一般为服务器)进行非直接的连接。 —— 百度百科

百度这解释太晦涩难懂了,还是我来说吧。比如你需要访问服务器A的数据,但是某些原因导致你无法直接访问到或者访问很困难,那么这时候有一台服务器B,你访问B没有障碍,而B访问A也没有障碍,那么我就让B帮我去访问A,我只要访问B,B接收我的这次访问内容,然后去A上面相对应地取数据,取回数据之后返回给我。这个B就是传说中的 黄牛党 代理服务器。

现在我本地页面去访问另外一台服务器上的后端接口,遇到了跨域问题,那么我可以通过服务器代理接口的方式,把接口代理到我本地,我访问本地的接口,就相当于访问了后端服务器的接口,并且没有跨域问题。

这里随便找了一个proxy-middleware,这类包相当多,大家可以自行选择,实现如下:

var url = require('url')
var proxy = require('proxy-middleware')

app.use(proxy(url.parse('http://tx2.biz.lizhi.fm')))

跑起来之后,试着在浏览器访问localhost:8080/audio/hot?page=1,可以看到结果:

{
  data: {
    content: [
      {
        coverBig: "http://cdn103.img.lizhi.fm/audio_cover/2016/07/29/30278047895714567.jpg",
        coverThumb: "http://cdn103.img.lizhi.fm/audio_cover/2016/07/29/30278047895714567_80x80.jpg",
        createTime: "2016-07-29 11:48:39",
        duration: 49,
        file: "http://cdn5.lizhi.fm/audio/2016/07/29/2548065522170814982_hd.mp3",
        id: 9,
        mediaId: "2548065522170814982",
        name: "#天下骄傲#伏羲",
        status: 0,
        type: "app",
        uid: "2543827817207562796",
        vote: 42559
      },
      ...
    ],
    pageIndex: 1,
    pageSize: 10,
    queryAll: false,
    totalCount: 200,
    totalPage: 20
  },
  msg: null,
  status: 0
}

搞定!(这接口别玩得太过啊,万一我项目服务器挂了我怨你们,建议拿百度的练手~)

附上最终server:init代码:

gulp.task('server:init', function () {
  for (var key in devConfig.entry) {
    var entry = devConfig.entry[key]
    entry.unshift('./client.js')
  }

  devConfig.plugins.unshift(
    new webpack.optimize.OccurenceOrderPlugin(),
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoErrorsPlugin()
  )

  var compiler = webpack(devConfig)
  var devMiddleware = webpackDevMiddleware(compiler, {
    hot: true,
    stats: {
      colors: true,
      chunks: false
    }
  })

  var hotMiddleware = webpackHotMiddleware(compiler)

  compiler.plugin('compilation', function(compilation) {
    compilation.plugin('html-webpack-plugin-after-emit', function(data, callback) {
      hotMiddleware.publish({ action: 'reload' })
      callback()
    })
  })

  app.use('/api/:method', mockMiddleware)
  app.use(proxy(url.parse('http://tx2.biz.lizhi.fm')))

  app.use(devMiddleware)
  app.use(hotMiddleware)
})

好,到这里为止,我们已经完成了大部分的使用场景,基本上是啥需求都能满足了,最后再加上个我们在介绍gulp的时候就已经讲到过的ftp任务,那就算功德圆满了。

总结

抛开我们中间的一些扩展的知识点不讲,我们只看大框架gulp+webpack,这样一个组合是不是具有很多的优点?我们可以看到,单单对于gulp项目来说,项目对资源的处理能力提升了,而对于webpack的项目来说,项目的功能更加齐备了而且扩展也相当方便。

事到如今,你还会觉得gulp和webpack是一回事吗?你还会感到困惑吗?

会的话我也没法怎么着了,你自己看着办吧。

最后的最后,再给大家布置一些任务,由于篇幅关系,我们很多细节其实没有做好:

  1. 随着功能越来越多,根目录下的配置文件也越来越多,像gulpfile.js、webpack.config.js、webpack.dev.config.js、server.js等等,有些散乱,而且这些都是项目无关的文件,属于工具,我们可以建个 build 文件夹来收纳一下,这里相对应的就有很多路径需要修改,这操劳的事就大家自觉去做了;
  2. 除了第一节的gulp讲解之外,我为了省事都没有提醒大家把诸如gulp buildgulp server等这些命令封装在package.json中作为预设脚本,一定程度上影响了雅观,事实上我自己是做了的,也希望大家要自觉去做;
  3. 每次运行编译打包(build)相关命令之前,都要加上rimraf dist,清除过期的内容;
  4. 有一些类型的资源我没有讲到,比如image、font,甚至是react,等等,其实这是留给大家自己去尝试的,大家要能举一反三,何况这又是很简单的事。

那这一系列就到此完结了,希望大家看完最终都能有所收获。

本次演示项目的git地址:gulp-webpack_base

【上一篇:进阶:构建具备版本管理能力的项目】

(文章有任何谬误之处,欢迎留言指出)

推荐阅读更多精彩内容