7

前言

作者去年就开始使用webpack, 最早的接触就来自于vue-cli。那个时候工作重点主要也是 vue 的使用,对webpack的配置是知之甚少,期间有问题也是询问大牛 @吕大豹。顺便说一句,对于前端知识体系迷茫的童鞋可以关注豹哥的微信公众号,《大豹杂说》。豹哥对于刚开始小白的自己(虽然现在也白)知无不谈,而且回复超快超认真。这里真的很感谢豹哥。前段时间工作不忙,自己就啃了啃webpack的官方文档,毕竟知识还是在自己脑袋里踏实。然后根据vue-cli的配置文件丰富了一点新的东西,发布出来大家共享,同时自己也有点疑问,也欢迎各位评论给小子指正。

webpack的学习

在前端领域我们总要面对各种的新框架新工具,那么怎么有效快速的学习掌握一门技能呢?作者的方法是实践是最好的老师,建议新东西了解一些核心的API啊功能啊立刻就上手使用,这个过程肯定会出现各种问题,在寻求解决问题的途径中逐渐也就加深了理解,带着问题学习总归会事半功倍。拿webpack来讲,了解他的一些核心概念,配置文件的入口输出解析loader,plugin等等就可以简单使用了。这里建议一点,学习新知识的时候建议大家最终还是从官网啊官方文档中学习,英文真的不是事,得试试才知道自己能看懂的。看博客主要都是别人消化之后的东西,再有基础之上再看这些文章当然能起到查漏补缺的功效,但是一开始就看,就很容易受到作者思路局限的影响。

当然这些都是自己的建议啊。所以本篇文章面对的是对webpack有一些简单使用的朋友,大家分享经验而已,如果对webpack还没开始使用的朋友,建议还是先了解一下webpack的核心知识。官网有中文版,翻译的也很好。

webpack环境的区别

webpack本质就是一个打包工具,是一种模块化开发的实现,它与gulp与grunt这一类的自动化构建工具不同,构建工具是优化我们自己的工作流程,将众多的手工方式改为自动化,比如压缩js、css,编译scss,less。当然webpack的loader与plugin也可以完成这些工作,工具使用看个人公司需求。webpack的主要工作是将我们我编写的模块化的文件打包编译为浏览器所能辨识的方式。
直白来讲,开发环境,就是你的代码在本地服务器上在测试、更改、运行,生产环境你的代码就是已经开始在真实服务器中使用。webpack 可以适用于开发环境主要是运用了node.js 搭建一个本地服务。记得去年我刚开始想需要一个本地服务的时候开始是使用Hbuilder,后来单独用了一个小工具名字好像叫webservice。

package.json

前面提到了nodejs,node.js是一个javascript运行的平台而不是什么js的框架,它实现的是js不仅可以开发客户端浏览器也可以开发服务端。现在的前端项目中都会发现一个package.json

clipboard.png

{
  "name": "webpack_environment",
  "version": "1.0.0",
  "description": "A webpack environment test",
  "author": "abzerolee",
  "scripts": {
    "dev": "node build/dev-server.js",
    "build": "node build/build.js"
  },
  "dependencies": {
    "nimble": "^0.0.2"
  },
  "devDependencies": {
    "autoprefixer": "^7.1.2",
    "babel-core": "^6.22.1",
    "babel-loader": "^7.1.1",
    "babel-preset-stage-2": "^6.22.0",
    "chalk": "^2.0.1",
    "clean-webpack-plugin": "^0.1.16",
    "connect-history-api-fallback": "^1.3.0",
    "css-loader": "^0.28.0",
    "eventsource-polyfill": "^0.9.6",
    "express": "^4.14.1",
    "extract-text-webpack-plugin": "^3.0.0",
    "file-loader": "^0.11.1",
    "glob": "^7.1.2",
    "html-webpack-plugin": "^2.28.0",
    "http-proxy-middleware": "^0.17.3",
    "less": "^2.7.2",
    "less-loader": "^4.0.5",
    "mockjs": "^1.0.1-beta3",
    "opn": "^5.1.0",
    "ora": "^1.3.0",
    "postcss-loader": "^2.0.6",
    "rimraf": "^2.6.1",
    "style-loader": "^0.18.2",
    "url-loader": "^0.5.8",
    "webpack": "^3.1.0",
    "webpack-dev-middleware": "^1.10.0",
    "webpack-hot-middleware": "^2.18.0",
    "webpack-merge": "^4.1.0"
  }
}

这个文件可以用npm 模块管理器生成的,它描述了一个项目的各种信息,注意到script这个属性他对应的dev build就是开发环境与生产环境了,我们运行命令的话是用 ‘npm run dev’或‘npm run build’其实执行的就是对应的node编译。可以发现这个配置文件告诉我们开发环境与生产环境的入口文件/build/dev-server.js,/build/build.js。剩下的dependencies / devDependencies则代表两种环境对应的依赖需要。

目录结构

clipboard.png

先介绍/node_modules 我们使用npm install 就是通过package.json中的依赖配置对应安装你需要的一些库,可以发先我在生产环境需要的是nimble。那么这些库存放的地方就是在/node_moudles中。当然你也可以用曾经古老的方法新建一个/lib 然后去官网下载对应js文件,再放入/lib。但是这样对于整个项目的管理并不十分友好,我们查看项目的依赖库只需要查看package.json就够了 而不是去html页面一个个找<script>标签。
接下来介绍一系列的.[文件名]这样的配置文件。.[文件名]都是一些你安装的依赖工具的配置文件,比如Babel的.babelrc postcss的.postcssrc ,最后就是一些[文件名].md的文件,md扩展名指的markdown 标记语言编写的文档。比如README.md 介绍的一般是项目的内容简介一些API的使用方法等等。
/build 是项目启动时的一些文件,如 webpack 的配置文件 开发环境服务配置文件 一些简单工具函数/utils.js等等。这里自己也有个问题就是关于dev-client.js的配置,dev-client是模块热加载的一个模块,应该就是当项目在开发环境运行之后命令行中新开的那个窗体的配置。不知道理解的对不对。当然我现在没用这个,项目跑起来也是可以的。

clipboard.png

/config 是关于整个项目的环境配置包括开发与生产。我们在node引入模块的时候可以直接引入目录,

require('./config');

他默认查找的就是该目录下的index.js文件。当然也可以不叫index.js这个需要一个/config目录下再去写一个package.json指定文件。
/dist与/src /dist目录下是将/src 目录下的源码编译之后生成的文件。一般项目部署就直接可以将/dist目录下的文件放在网站的根目录。/dist就对应生产环境的文件,/src对应开发环境的文件。
/mock 是前台开发的模拟数据接口的文件,里面就是一些后台接口的模拟数据

clipboard.png

var Mock = require('mockjs');

var User = {
  login: {
    code: 0,
    info: null,
    msg: '登录成功!'
  },
  getVerifyCode: {
    code: 0,
    info: Mock.mock('@string("lower", 4)'),
    msg: '操作成功'
  }
};

module.exports = User;

这里使用了mock.js 来生成模拟数据,用CommonJS规范中module.exports来暴露出数据。对于AMD,CMD,CommonJS这几种模块规范,大家还是应该有适当的理解,为什么要有模块,模块的工作方式有什么。当然这是一种规避跨域问题的模拟测试,项目中也通过http-proxy-middleware的方式解决跨域问题。但是如果后台的进度慢于前台的情况下,这种mock也是一种良好的开发方式。

开发环境 dev-server.js

作者最开始学习webpack的时候,也是从把a.js与b.js引入main.js最后打包生成bundle.js开始的。那个时候对node.js也是一知半解,当然现在了解的更多了,并不代表精通。总会好奇一个点就是 刚开始编译的时候是使用
webpack -config webpack.conf.js
后面怎么开始用node编译了。其实这是webpack提供了一个Node.js API,可以直接在Node.js运行时使用。这也就是为什么入口文件从webpack.conf.js变成了dev-server.js|build.js的原因。使用node编译的好处是可以更好的利用一下node的特性 读取文件,模拟API接口等等。

var config = require('../config');
if(!process.env.ENV) {
  process.env.ENV = config.dev.ENV;
}
var utils = require('./utils');
var opn = require('opn');
var path = require('path');
var fs = require('fs');
var express = require('express');
var webpack = require('webpack');
var proxyMiddleware = require('http-proxy-middleware');
var webpackConfig = require('./webpack.dev.conf');

var port = process.env.PORT || config.dev.port;

var autoOpenBrowser = config.dev.autoOpenBrowser;

var proxyTable = config.dev.proxyTable;

var app = express()
var compiler = webpack(webpackConfig);

var apiRouter = express.Router();

var apis = fs.readdirSync(utils.resolve('/mock'));
var apiClass = apis.map(it => it.replace(/\.js$/, ''));

apiRouter.route('/:apiClass/:apiName').all(function(req, res) {
  var params = req.params;
  var apiIndex = apiClass.indexOf(params.apiClass)

  var err = {code: 99,info: null, msg: 'no such api'}
  if(apis.length < 1 || apiIndex  === -1)
    return res.json(err);

  var klass = require('../mock/'+ apis[apiIndex]);
  if(klass[params.apiName]){
    res.json(klass[params.apiName]);
  }else{
    res.json(err);
  }
})

app.use('/api', apiRouter);

var devMiddleware = require('webpack-dev-middleware')(compiler, {
  publicPath: webpackConfig.output.publicPath,
  quiet: true
});

var hotMiddleware = require('webpack-hot-middleware')(compiler, {
  log: () => {},
  heartbeat: 2000
})

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

Object.keys(proxyTable).forEach(function (context) {
  var options = proxyTable[context]
  if (typeof options === 'string') {
    options = { target: options }
  }
  app.use(proxyMiddleware(options.filter || context, options))
});

// app.use(require('connect-history-api-fallback')())
app.use(devMiddleware)
app.use(hotMiddleware)

var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory)
app.use(staticPath, express.static('./static'));

var uri = 'http://localhost:'+ port;

var _resolve;

var readyPromise = new Promise(resolve => {
  _resolve = resolve
})

console.log('> Starting Server...');
devMiddleware.waitUntilValid(() => {
  console.log('> Listening at ' + uri + '\n')
  // when env is testing, don't need open it
  if (autoOpenBrowser && process.env.ENV !== 'testing') {
    opn(uri)
  }
  _resolve()
})

var server = app.listen(port);

module.exports = {
  ready: readyPromise,
  close: () => {
    server.close()
  }
}

上面的代码用过vue-cli的朋友应该很熟悉。对于vue-cli的介绍大家可以自己去官网查看。这里推荐一个对配置文件逐句注释的文章,细微之处还是有差异的,但是大体不离。
我们尽量用直白的语言来分析一下这个文件,

  1. 程序开始运行,引入环境的配置文件/config 这里前文提到为什么可以省略index.js。然后判断process.env表示的用户环境变量 ENV 为何种环境,官网翻译进程对象process是一个全局的,它提供有关当前Node.js进程的信息和控制。这个环境变量我们可以在命令行中启动程序时输入,当node无法判断环境时我们手动的设置为开发环境的变量,在/config/index.js config.dev.ENV <=> 'dev'。然后引入我们需要的库和文件,比如工具函数库utils 自启动浏览器opn(服务启动后自动打开浏览器) 文件系统fs nodejs框架express(用来启动本地服务器,部署静态服务,模拟路由接口)。
  2. 引入库之后便是定义我们的整个项目服务app,通过webpack的nodeAPI编译开发环境的配置文件,定义webpack提供的服务的中间件webpack-dev-middleware,将编译内容写入内存中,启用热加载的中间件,html模板template更新则强制刷新页面,以及配置跨域代理请求的中间件。中间件的概念其实就是工作流的思想,记得有一个例子很直白
    可乐的生成:水 -> 净化 -> 调配 -> 装瓶 -> 质检 -> 饮用可乐,水到可乐,每一个中间过程都认为是一个中间件
  3. 通过express.Router()来定义接口,所有本地请求的/api开头的url都解析之后的/api/:apiClass/:apiName,apiName对应/mock文件下的js文件名,apiName对应js文件暴露出的对象的属性也就是数据。。
  4. 这里因为配置了mock的原因我就去除了connect-history-api-fallback,它的作用因为找不到接口的话指定一个页面重定向,如果接口API找不到它就会默认定向到index.html。接下来是拼接/static文件路径,我的静态资源都是放在assets目录下就就删除了该文件夹。(对这点我也存有疑问就是vue-cli的这个/static文件夹到底是指哪些静态资源?)。之后是服务启动,监听端口打开浏览器。

到这里,我们就可以通过对src的源码进行修改开发了。

生产环境 build.js

process.env.ENV = 'prod';

var ora = require('ora');
var path = require('path')
var chalk = require('chalk')
var webpack = require('webpack')
var config = require('../config')
var webpackConfig = require('./webpack.prod.conf')

var spinner = ora('building for production...');
spinner.start()

webpack(webpackConfig, function (err, stats) {
  spinner.stop()
  if (err) throw err
  process.stdout.write(stats.toString({
    colors: true,
    modules: false,
    children: false,
    chunks: true,
    chunkModules: false
  }) + '\n\n')

  console.log(chalk.cyan('  Build complete.\n'))
  console.log(chalk.yellow(
    '  Tip: built files are meant to be served over an HTTP server.\n' +
    '  Opening index.html over file:// won\'t work.\n'
  ))
})

编译打包功能就不需要配置服务了,当然打包的时候需要一下提示,进度,就需要ora chalk这些模块了。打包这里和vue-cli不太一样得是我没有使用rmrf 而是用了一个插件CleanWebpackPlugin来清空/dist目录下的文件。当然也可以只清空某个文件而不是整个目录。

配置文件

/config/index.js && /build/utils.js

  1. /config/index.js主要暴露了两个对象一个属性
var path = require('path');

module.exports = {
  // 项目根目录
  _root_: path.resolve(__dirname, '../'),
  // 生产环境设置
  build: {
    ENV: 'prod',
    index: path.resolve(__dirname, '../dist/index.html'), // 编译完成首页
    assestsRoot: path.resolve(__dirname, '../dist'), // 静态根目录
    assetsSubDirectory: 'static',
    assetsPublicPath: '',
    prodSourceMap: false,
    productionGzip: false,
    productionGzipExtensions: ['js', 'css']
  },
  // 开发环境配置
  dev: {
    ENV: 'dev',
    port: '3000',
    autoOpenBrowser: false,
    assetsSubDirectory: 'static',
    assetsPublicPath: '/',
    cssSourceMap: false,
    proxyTable: {
      // '/api': {
      //   target: 'http://localhost:3100',
      //   changeOrigin: true
      // }
    }
  }
}

这里注意的一个点就是build.assetsPublicPath <=> 编译发布的根目录,可配置为资源服务器域名或 CDN 域名,那么很多朋友vue编译完本地File://打不开就是因为这里配置的是'/'指的是服务器的根目录,部署到服务器上是没有问题的,如果你要本地打开,设为空字符串即可。
第二个需要注意的就是dev.proxyTable的接口属性,如我的配置其实就是跨域请求'http://localhost:3100/api'注意接口名的对应。

  1. utils是在编写配置文件时你需要的一些函数,比如vue-cli中关于样式的loader都是在这里配置的
var path = require('path');
var config = require('../config');
var ExtractTextPlugin = require('extract-text-webpack-plugin');
var glob = require('glob');

exports.assetsPath = function(_path) {
  var assetsSubDirectory = process.env.ENV === 'prod' 
    ? config.build.assetsSubDirectory 
    : config.build.assetsSubDirectory;
  return path.posix.join(assetsSubDirectory, _path)
}

exports.resolve = function(dir) {
  return path.join(__dirname, '..', dir);
}

exports.cssLoaders = function(options) {
  var cssLoader = {
    loader: 'css-loader',
    options: {
      minmize: process.env.ENV === 'prod',
      sourceMap: options.sourceMap
    }
  }
  
  function generLoaders(loader, loaderOptions) {
    var loaders = [cssLoader, ];
    if(loader) {
      loaders.push({
        loader: loader +'-loader',
        options: Object.assign({}, loaderOptions, {
          sourceMap: options.sourceMap
        })
      })
    }

    if(options.extract) {
      return ExtractTextPlugin.extract({
        use: loaders,
        fallback: 'style-loader',
      })
    }else {
      return ['style-loader'].concat(loaders)
    }
  }

  return {
    css: generLoaders(),
    postcss: generLoaders(),
    less: generLoaders('less'),
    sass: generLoaders('sass', {indentedSyntax: true}),
    scss: generLoaders('sass')
  }
}

exports.styleLoader = function(option) {
  var output = [];
  var loaders = exports.cssLoaders(option);
  for(var extension in loaders){
    output.push({
      test: new RegExp('\\.'+ extension +'$'),
      use: loaders[extension]
    }) 
  }
  return output
}

exports.getEntries = function(_path) {
  var entries = {};
  glob.sync(_path).forEach(function(entry) {
    var basename = path.basename(entry, path.extname(entry));
    var pathname = entry.split('/').splice(-3).splice(0, 1) +'/'+ basename;
    entries[basename] = entry;
  });

  return entries;
}
  1. assetsPath(_path)是返回静态资源_path的全路径,
  2. resolve(dir)是返回dir的绝对路径,为什么会单独写resolve主要是webpack的配置文件不在项目根目录而是在/build下。
  3. getEntries(_path) 是通过glob(路径模式匹配模块)匹配多页面入口文件的函数,最终返回一个入口对象,在这里网上很多其他得例子都是

    {
    'module/index': ...
    'module/user': ...
    }

    这导致开发环境下需要在url去添加http://localhost:3000/module/...才能查看文件,生产环境编译之后的文件也是在/dist/module/index.html 这里直接将basename 作为属性名则会解决。

  4. styleLoader() 返回一个webpack配置文件中moudle.rules对应的数组,内部调用cssLoader(来生成对应的sass、less加载编译) 这里不太明白的朋友建议可以在vscode下断点调试一下,看他每次生成对象对应的一些配置。

webpack.*.conf.js

webpack的配置文件各种各样,这是因为他高度自定义决定的,你可以配置任何你想要的loader plugin来完成你的工作。像vue-cli便是定义了一个基础的base配置,之后区分开发与生产需要的不同插件,都是代码复用。base.conf中应该注意的是多入口与单入口的配置

...
var entries = utils.getEntries('./src/modules/**/*.js');

module.exports = {
  // entry: {
  //   app: utils.resolve('/src/main.js'),
  // },
  entry: entries,
  output: {
    path: config.build.assestsRoot,
    filename: '[name].js',
    publicPath: process.env.ENV === 'prod' ? config.build.assetsPublicPath : config.dev.assetsPublicPath,
  },
...
dev.conf的配置
module.exports = merge(baseWebpackConfig, {
  module: {
    rules: utils.styleLoader({
      sourceMap: config.dev.cssSourceMap
    })
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env': config.dev.ENV,
      dev_port: '"http://localhost:3000/api"'
    }),
    new webpack.HotModuleReplacementPlugin(),
    // spa 则应用如下配置
    // new HtmlWebpackPlugin({
    //   title: 'Single-Page'+pathname,
    //   filename: 'index.html',
    //   template: utils.resolve('/src/index.html'),
    //   inject: true
    // })
  ]
})
// 多页面应用配置 根据modules 动态生成html
var pages = utils.getEntries('./src/modules/**/*.html');

for(var pathname in pages){
  var conf = {
    filename: pathname +'.html',
    template: pages[pathname],
    chunks: [pathname],
    inject: true
  }
  module.exports.plugins.push(new HtmlWebpackPlugin(conf))
}

该配置只使用了三个插件 DefinePlugin这个插件可以用来定义全局变量,在编译时将你的引用的dev_port 转换为 "http://locahost:3000/api" 要注意的是他转化的是值,比如 dev_port <=> 'b' 那么你在编写代码时 引用了dev_port实际上他是将变量名替换为b而不是'b'字符串,可以看如下报错,所以要使用字符串时需要外层包裹单引号。

// dev.conf
...
 new webpack.DefinePlugin({
      'process.env': config.dev.ENV,
      dev_port: 'b'
    }),
...
// /src/modules/index.js
...
console.log(dev_port);
...

clipboard.png

HotModuleReplacementPlugint插件在页面进行变更的时候只会重绘对应的页面模块,不会重绘整个html文件。HtmlWebpackPlugin有几个页面则对应生成几个配置。

prod.conf

与dev.conf类似的有,
DefinePlugin 但是这个时候要把dev_port切换后台接口所在服务器的域名。这样不用每次编译前再去修改 当然叫host可能更准确(忽略我的瞎起名字)。HtmlWebpackPlugin就是一些生成html文件是否压缩是否去除属性引用的配置。

不同之处有配置了CommonsChunkPlugin提取公共模块,(要注意minChunks最少引用次数的配置),ExtractTextPlugin提取CSS文件 而不是style标签插入html。

结语

啃文档啃了一个星期多,边啃边练一个星期,构思写作三天,起码现在对weback的配置再恐惧了,文章有点过长能看到这的朋友首先谢谢你的阅读,源码在github 这个环境也是当时用来打包一个以前用jquery的项目的所以没有配框架vue react之类的。过段时间啃完了create-react-app 的实现应该还会出一期关于 webpack 原理的学习笔记。还希望继续关注。文中如有一些问题也希望大家及时指正。


autozerolee
276 声望7 粉丝

If you want to do something, try five minutes first.