webpack2应用级构建配置方案

webpac2.0发布之后,我还保持了一段时间的1.0版本的使用,但当我打算跟上时代的时候,就不得不研究一下2.0。其实2.0和1.0的用法并没有非常大的不同,最大的不同莫过于配置时,一些选项更换了名字,所以1.0的配置文件是不能被2.0兼容的。

我之前的工作,大部分都是专注于component,而现在,我希望用webpack来构建我的应用。所谓“应用级构建”,就是说要直接打包完上线运行。我自己开发的componer在构建阶段,采用了js和css分开的方案,js用webpack打包,css则利用gulp-sass来分离,实际上,这是行得通的,因为一个项目理应把js和css分开管理。不过在这篇文章中,为了技术上的自鸣得意,我还是采用webpack来编译scss打包css。那么我们现在开始吧。

单文件入口

在这个项目中,我打算采用单文件入口。简单的说,就是entry只给一个文件,只有一个chunk。项目大致结构如下:

-app
 |-script
 |-style
 |-template
 |-index.js
 |-index.html
-components
-package.json
-webpack.config.babel.js
-.babelrc

app就是我们本文要构建的项目,components目录下将会放各种组件,这就要用到componer了。而index.js则是本文的入口文件,也就是说,我们只需要得到一个index.js即可。index.html将使用插件自动插入打包后的文件链接。

为什么要单文件入口呢?作为一个项目,你只需要关心你的项目内部的代码怎么组织就可以了,不需要关心构建打包的入口文件。入口文件是单独撰写的,在你的项目内部不依赖它,它只是作为打包入口使用。

// index.js
import './style/app.scss'

import 'jquery'
import 'bootstrap-sass'

import './script/app.js'

上面就是入口文件了,入口文件告诉构建工具,这个项目的样式、全局依赖、项目本身的脚本各是什么,直接把这个入口文件丢给webpack,webpack就可以得到最终你需要的build文件(js, css, assets分开)。

// webpack.config.babel.js
export default {
  entry: './app/index.js',
  output: {
    filename: 'dist/js/[name].[chunkhash:8].js',
    libraryTarget: 'umd',
  },
}

支持ES6

webpack2.x原生支持了import进行模块导入,之所以这样,是因为webpack2有一个令人惊喜的功能:tree shaking,也就是仅导出被其他模块导入的接口。举个例子:

// a.js
export function a() {}
export function b() {}

// b.js
import {a} from './a'

如果项目中只有这两个文件,你在b.js中只用到了a.js中的a(),那么在webpack打包后的文件里,你会发现导出接口只有a,没有b,再不使用UglifyJsPlugin优化的情况下,b()函数也不是全局注册,性能消耗比以前导出b而不用要小。如果你使用UglifyJsPlugin进行优化,会发现最终优化的结果代码里面没有函数b。

这和es6的模块化方案的期待结果是一致的。但是解决import, export的模块化问题之后,并不代表webpack已经可以编译es6代码了。实际上,和以前一样,我们还是需要使用babel来编译es6代码,保证得到的代码可以在更低版本的浏览器运行。

和之前一样,我们要配置一个loader,但是前面讲过,loader的配置名有变化:

{
  //...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        loader: 'babel-loader',
        options: {
          presets: [['es2015', {modules: false}]],
        },
      },
    ],
  },
}

webpack2默认情况下不允许省略-loader,不能像1.x时代一样直接使用babel作为loader值就行。注意上面红色代码,它是一个整体,注意是一个数组,数组元素类型还不一样。之所以要将modules设置为false,是防止webpack的tree shaking功能发挥之前,babel把import, export转码为require,一旦babel这样做了,tree shaking功能就实现不了了。

通过上面的配置,你就可以愉快的使用es6代码来写js了。

注意,你虽然可以使用es6代码,但是es6新加入的特性Symbol是浏览器本来没有的,也没有polyfill可以用,所以基于Symbol的for..of, ...等其实最好还是暂时不用,毕竟你还是想兼容一些稍微更低一点的浏览器版本,比如IE9. 当然,如果你想强迫用户使用高版本浏览器,也可以完全不管这个限制。

在js中直接使用bower components

基于组件的开发,components很多都通过bower发布,当然,现在很多公司都做了bower转npm,不过我认为bower称自己的包为component而非package或module是有道理的,bower本身是从component出发的。而npm则不管那么多,任何形式的包都可以。

如何配置在webpack中使用bower包呢?

import BowerResolvePlugin from 'bower-resolve-webpack-plugin'

export default {
  // ...
  resolve: {
    plugins: [
      new BowerResolvePlugin(),
    ],
    modules: [
      'node_modules',
      'bower_components',
      'components',
    ],
    descriptionFiles: [
      'package.json',
      'bower.json',
    ],
    mainFields: [
      'main',
    ],
  },
}

上面的这个配置有点奇怪的,就是在resolve里面还传入了一个plugins。不过你阅读文档就知道,这个plugins是为resolver准备的。通过一个叫bower-resolve-webpack-plugin来解决bower.json中main字段是数组的情况。你去看这个插件的源码,无非是通过对main字段进行检查,获取后缀是.js的文件作为main字段的值。所以这个地方其实还是不是很好,因为你可能发现有些bower包main字段不规范,把.min.js和.js放在一起了,如果把.min.js放前面,就惨了。所有bower包的发布者,都应该按照bower的specs文档来撰写bower.json,否则其实是互坑。

通过上面的配置之后,你可以像使用node_modules目录下的包一样使用bower包。

文件名hash注入

在前面的配置代码中,你发现在filename中,我们加入了[chunkhash:8],[hash]和[chunkhash]不同,[hash]是基于原始文件的,而[chunkhash]是基于chunk的。chunk的概念,你可以简单理解为“一捆文件”,也就是好几个文件如果打包在一起,就是一个chunk,webpack本来就是打包,所以只要打包,就永远都有chunk。

下面我们会讲到vendors分离的问题。为什么要使用带hash文件名的文件名呢?主要是为了实现CDN,比如你把你的文件打包成连个,一个是vendors.js一个是main.js,都走CDN。现在你更新了版本,vendors.js完全没变,而main.js改变了,这个时候,由于浏览器的缓存作用,用户只需要下载main.js就可以了。但是怎么实现呢?就是在文件名中加hash,比如main.xxwwxs2342.js,当你更新了版本,文件的hash值肯定就变了,文件名也就变成了main.newhashxxx.js。这样当用户请应用的时候,就会下载这个新的脚本到本地。vendors也是同样的道理,要是你没有加入新vendor,可以不用被用户重新下载,当你加入新vendor时,再通过改变hash注入到文件名中来实现更新客户端的脚本文件。

[hash]和[chunkhash]的不同就是,如果你的整体文件没有变,只不过改变了vendors的内容,比如你觉得vendors.js太大了,把里面几个vendor分到main.js里面,这个时候打包出来两个文件的[hash]是一样的(是原始文件的hash),[chunkhash]则不同(是各自chunk的hash)。

只需要在输出文件的字符串中使用[chunkhash]作为占位符即可。

实现vendors脚本分离

在《webpack将依赖和项目分开各自单独》一文中,我介绍了使用Dll方案将vendors从应用自己的逻辑代码中分离出去单独打包的一种方案。但是Dll方案更适合测试开发阶段,你想,当你测试的适合,其实更多是在写项目本身的逻辑代码,而且每次改完都希望自动刷新一下页码,看到效果。Dll方案分两步走,第一步打包所有指定的vendors,这时会得到一个json文件,第二步利用这个json文件,把项目内的代码中对应的那些vendors全部剔除掉,使用webpack内建的json机制让vendors依赖可以被后一个文件识别到。这样你每次修改完代码,只需要重复执行第二步即可,这样要打包的代码量就小很多了。

但是本文要介绍CommonsChunkPlugin这个插件,它就更适合产品阶段打包,因为它每次都会对所有代码进行打包,在打包过程中,通过插件来决定哪些vendors要分离出来。但是你可以很容易发现,就算webpack内部有缓存机制,CommonsChunkPlugin分离代码始终要读取所有文件,所以总体效率上讲,肯定还是只重复Dll方案第二步更高一些。不过在应用打包层面,这个就可以不考虑了,因为你现在都打算最后一次打包了,马上就要上线了,还会在乎这几秒钟吗?

接下来就正式进入CommonsChunkPlugin的讲解。

{
  // ...
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendors',
      minChunks: (mod, count) => {
        // this is used to pick out vendors
        let resource = mod.resource
        if(resource && (/^.*\.(css|scss)$/).test(resource)) {
          return false
        }
        let context = mod.context
        if(!context) return false
        if(context.indexOf('node_modules') === -1 && context.indexOf('bower_components') === -1) return false
        return true
      },
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest', //But since there are no more common modules between them we end up with just the runtime code included in the manifest file
    }),
  ],
}

这……配置量也太少了吧。就在plugins里面加了两个项目,而且还是重复使用了CommonsChunkPlugin。配置量虽然少,但是里面的学问却很大。

首先还是介绍一下CommonsChunkPlugin这个插件的理念吧。这里要涉及webpack的一个code splitting的概念。具体请阅读官方文档,我这里大致讲下。

所谓code splitting(代码切分)是开发中经常要遇到的问题。当你的项目代码全部打包在一个文件中时,这个文件可能撑的很大,可能到几M,甚至几十M,虽然我们说合并多个js文件的代码到单个文件里面,有利于减少http请求并行数量,提高页面打开速度。但是你这一个文件几十M,你以为中国网速都是千兆光纤吗?要是别人用4G,你这是要三两下把别人一个月套餐用完的节奏啊!而且文件的下载时长,虽然受http影响(http2就不会有这个问题了),但是文件的大小太大,我想下载时长肯定更长啊!所以,原本流行的合并代码的理念,到了webpack的时代,切分理念又开始盛行。把你的代码切分为2-3个文件,到了http2的时代,甚至可以按需加载,这样可以加快文件下载。

切分的依据,我简单的定为把所有静态不会修改的第三方依赖作为一个,项目本身的文件作为一个。所以,我们希望打包完之后,在我们的dist/js目录下,可以有两个js,一个是main.js一个是vendors.js。

我们现在再来看上面的配置。

  // ...  
  new webpack.optimize.CommonsChunkPlugin({
      name: 'vendors',
      minChunks: (mod, count) => {
        // this is used to pick out vendors
        let context = mod.context
        if(!context) return false
        if(context.indexOf('node_modules') === -1 && context.indexOf('bower_components') === -1) return false
        return true
      },
    }),

这是第一个common chunk。它有一个name和一个minChunks,name是指你打算把哪一个chunk切分出来,这里输入了vendors,但是你往前翻,发现我们采用的是单文件入口,没有vendors这个入口啊?没关系,我们来看minChunks。不过可以透露的是,vendors决定了最后切分出来的文件名。

minChunks本来的意思是,当一个模块在应用的所有代码中被引用了>=minChunks的时候,才会被split出来。也就是说,如果你把minChunks设置为Infinity,那么任何模块都不会被单独分出来了,因为引用次数肯定是有限次啊。如果你设置为1,那所有的模块都会被放到vendors.js中(除了入口文件本身)。总之,它的作用可以让webpack决定哪些module要被单独分离出来。

有了这个之后,我们实际上不考虑次数的问题,我们希望的是,所有在node_modules和bower_components目录下的包都被分出来。所以我们采用了给minChunks传一个函数的形式来处理。这个函数返回为true,则被分离出来,为false则保留在原来的chunk中。

所以,你现在应该可以看到那个函数了吧。

接下来,解释为什么有第二个CommonsChunkPlugin实例。

虽然CommonsChunkPlugin可以帮助我们分离代码出来,但是,我们还会采用hash文件的后缀,这样当你在html中引用这个bundle时,文件名不一样,就会下载新的bundle。但是如果你只使用上面的第一个CommonsChunkPlugin实例的话,你会发现,每次vendors的文件hash也不一样,所以到最后,你发现你的用户浏览器还是会下载main.[chunkhash].js和vendors.[chunkhash].js两个文件,本来打算只更新main.js的,这样用户可以更快获取最新代码。这是为什么呢?

网上有专门的文章来介绍这个问题,上面给的官方文档链接里也有说。大概意思就是webpack内部有缓存机制,每次CommonsChunkPlugin切分代码都会产生新的id号作为缓存,所以每次得到的vendors.js的内容都不同,所以每次chunkhash也不同,这就惨了,CDN的功效失效了。

解决办法就是再在plugins里面加一个new CommonsChunkPlugin。

    // 前一个new CommonChunkPlugin
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest', //But since there are no more common modules between them we end up with just the runtime code included in the manifest file
    }),

因为CommonsChunkPlugin会把这些新产生的id记录在最后一个产生的chunk里面,new一个新的会产生一个新文件,也就是说除了vendors.js和main.js之外,你会发现还有一个manifest.js文件。

只传了一个name,作为bundle文件名,没有给minChunks规则,这样在这个chunk里,不会存在实际的文件业务逻辑代码,只是一个非常小的文件,但是它是必须的,必须在vendors.js和main.js之前被引入到网页。

其实这个方案有一个不好的地方,是会产生三个文件,而且最后这个manifest.js根本不是我想要的,我只想要前两个问题。但是没办法,webpack内部的一些机制限制了,只能这么做,再说了最后这个文件非常小,下载速度会非常快。

有了第三个文件的出现,你会神奇的发现,怎么改app里面的业务逻辑代码,vendors.js的chunkhash都不变了。这样就起到了我们想要的CDN效果。

把脚本插入到html文档中

之所以要有这一步,是因为我们可以更快的使用[chunkhash]。如果不自动插入html,那么你必须去看dist/js目下生成的新的js文件,把文件名抄过来,填写到你的index.html中去。但是如果自动生成了html,那么最多ctrl c+v一下。如果处理的好,还根本不用自己动手,我们自己来写个模板,让webpack自动插入脚本到html中就可以了。

import HtmlWebpackPlugin from 'html-webpack-plugin'
// ...
export default {
  plugins: [
    // ...
    new HtmlWebpackPlugin({
        filename: 'dist/index.html',
        template: 'app/index.html',
        chunksSortMode: (chunk1, chunk2) => {
          let orders = ['manifest', 'vendors', 'main']
          let order1 = orders.indexOf(chunk1.names[0])
          let order2 = orders.indexOf(chunk2.names[0])
          return order1 > order2 ? 1 : order1 < order2 ? -1 : 0
        },
      }),
  ],
}

其他的都好理解chunksSortMode要解释一下。它的值是一个函数,将会被传入sort方法,Array.sort你应该了解它的效果。之所以要使用chunksSortMode参数,是因为如果不传,你会发现插入到index.html的脚本顺序是反的,main.js在最前面,如果直接运行index.html的话,浏览器会报错。

使用scss

接下来的内容,将会讨论style。在我的项目中,全部使用了scss,所以我需要对我的样式进行预编译和打包处理。配置也非常简单,使用sass-loader即可,当然,需要css-loader配合,开发模式下还需要style-loader配合。

{
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ['css-loader', 'style-loader', 'sass-loader'],
      },
    ],
  },
}

css-loader的作用是使得webpack可以正确识别scss文件路径,包括@import语法的引用路径。style-loader的作用是可以让最后的css样式直接通过js插入到页面头部,这在开发时非常好用,甚至可以实现热替换。sass-loader的作用不用说,当然是编译scss。

在scss中直接使用package modules

就像在js里面直接import' ;&+"vHE' ;&+"通过本节的方法,直接在scss中直接使用module,例如:

@import "bootstrap-sass";

这就很屌了,它跟js的用法非常像了。不过要这样用,还得要求被import的module的package.json文件中使用sass字段指明哪一个是你要用的scss文件,通过这个字段指明,解析成真正的scss文件路径。

其实sass-loader自己就提供了module检索的方法,但是需要在module前面加入一个~符号,比如:

@import "~bootstrap-sass/assets/stylesheets/_bootstrap.scss";

这种方式是sass-loader自带支持的,不需要插件。不过~只支持引用文件的形式,不支持直接传入module名称,所以就达不到我们的需求,我们希望@import像js中使用import一样方便。

而有幸,又有一个插件帮助我们完成这个目的,它是一个node-sass的importer插件:sass-module-importer。废话不多说,下面直接进入配置方法:

import SassModuleImporter from 'sass-module-importer'
// ...
export default {
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ['css-loader', 'style-loader', {
          loader: 'sass-loader',
          options: {
            importer: SassModuleImporter(),
          },
        }],
      },
    ],
  },
}

我们使用到了sass-module-importer这个插件,非常屌。你只需要像上面这样,传入importer作为sass-loader,sass-loader最终会把这个配置项传给node-sass,node-sass又传给libsass,总之它生效了,你直接可以像前面说的一样使用bootstrap-sass了。

但是在js里面还有另外一种引用方式啊,比如:

import 'module/subdir/subsubdir/file.js'

在sass里面怎么搞?如你想象,直接引用文件即可:

@import "bootstrap-sass/assets/stylesheets/_bootstrap.scss";

是不是很神奇。这让我们非常轻松的在js和scss的module引入写法之间无缝切换。

有一个点需要稍微阐述一下,sass-module-importer这个插件会检索node_modules和bower_components目录下的所有包。当你只是import包名的时候,如果是在node_modules目录下,就会去找package.json,去查main, sass, style这几个字段,找到scss文件路径,如果package.json中这几个字段都没有对应的scss文件入口,那么编译就会报错。同样的情况,在bower_components中,因为我们可以让main字段以数组的形式展示,所以插件会先去查找bower.json中的main字段,如果没有找到对应的scss文件入口,也会报错。所以在你自己发布一个包的时候,应该注意这个点。

不过有些组件没有提供pacakge.json的sass字段,你也只能选这种方案。所以根据你自己的情况来选择吧。

补充:后来我发现,其实并不需要借助sass module importer插件,sass-loader的配置选项中有一个includePaths可以进行配置,这个选项告诉sass-loader把哪些文件目录作为查找的根目录,所以要实现这一小节的需求,不再需要插件,而是直接如下配置:

export default {
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ['css-loader', 'style-loader', {
          loader: 'sass-loader',
          options: {
            includePaths: [
              path.resolve(__dirname, 'node_modules'),
              path.resolve(__dirname, 'bower_components'),
            ],
          },
        }],
      },
    ],
  },
}

通过上面的配置,sass-loader可以直接在这两个文件夹中搜索要找的scss module,这样就不需要~符帮助。

将样式保存到独立的css文件

前面提到过,单文件入口的好处是,你可以直接从入口文件中知道你的项目所用到的样式有哪些,不过虽然入口是单一的,但是最终出来的文件可必须是分开的,这样才能充分发挥各自的优势。

前面已经实现了vendors js的切分,现在,我们要把所有css从js里面分离出来。前面忘记说两件事:

  1. 通过css-loader打包的css,会被全部放在js文件里面,当做一个模块,再由style-loader加载到页面中,这跟我们直接在js里面写css有点像
  2. 如果你的vendors里面也有样式,比如你的某个vendor就是使用require('./xx.css'),你怎么办?只能让它打包进js里面咯。

上面说的第2点再展开一下。之前在开发componer的时候使用了一个插件,可以把bower里面的css也引进来,跟本文的实现不一样,它可以把main字段里面的css文件也require进来,这样打包的时候,就不会把css漏掉,这其实挺好的。

但是,现在我们要把所有打包进来的css给分离出去,到一个单独的css文件中,作为静态文件保存。少说废话,直接看配置:

import ExtractTextPlugin from 'extract-text-webpack-plugin'

export default {
  entry: './app/index.js',
  output: {
    filename: 'dist/js/[name].[chunkhash:8].js',
    libraryTarget: 'umd',
  },
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ExtractTextPlugin.extract({
          use: ['css-loader', {
              loader: 'sass-loader',
              options: {
                importer: SassModuleImporter(),
              },
            },
          ],
          fallback: 'style-loader',
        }),
      },
    ],
  },
  // ...
  plugins: [
    new ExtractTextPlugin({
      filename: 'dist/css/[name].[chunkhash:8].css',
      allChunks: true,
    }),
  ],
}

我们用到了extract-text-webpack-plugin这个插件。它分两个步骤,一个是在plugins里面实例化,另一个是在loader中调用extract方法。extract方法的参数其实又是loaders,表示在提取css之前应该做什么预处理。要保存成什么文件,在new实例化的时候传入一个filename来确定。上面有一个fallback,把style-loader传给了它,这个动作的意思是,extract默认情况下先css-loader再sass-loader编译,如果一切顺利的话,结束之后把css导出到规定的文件去。但是如果不顺利怎么办,继续使用style-loader,把css混在js里面。

如果你的样式里面简单,没有设计其他问题,我想上面这个配置就可以满足你了,但是如果你的样式里面有使用图片、字体等资源,就麻烦了。

切分大型css文件

和code splitting一样,当你的css文件超大时,也会导致下载要花很长的时间。所以,切分这种超大型的css文件也是有必要的。比如我们前面的例子,把bootstrap-sass编译出来,本身也就很大了,虽然下文我们会讲压缩优化之内的,但是最后如果所有css都在一个文件,还是比较恐怖。所以,我们还要想办法进行split。幸好,我又找到了一个插件来实现:

import CSSSplitWebpackPlugin from 'css-split-webpack-plugin'

{
  plugins: [
    new CSSSplitWebpackPlugin({
      size: 4000,
      filename: 'dist/css/[name]-part[part].css',
      preserve: true,
    }),
  ],
}

使用方法也很简单,而且可以和extract-text-webpack-plugin一起使用,sourcemap也会自己按照切分进行切分。上面蓝色的配置项需要注意一下,size: 4000不是说split后的文件大小为4000,而是说一个part的文件包含多少条css规则。filename里面不能使用[hash][chunkhash]等,只有[name]和[part]两个可选项。preserve: true的意思是,保留原始文件,为false的话,只有所有part文件,原始文件和原始sourcemap会被删除。

补充:有没有办法不打包vendors的style呢?比如说,我不想把vendors的scss打包到我的项目样式里面去,而是单独引用vendors里面自己提供的css。毕竟css和js是不一样的,css是一次性加载,不驻留内存的,' ;&+"vHE' ;&+"移除可以实现移除样式的目的,但是js不行,执行完之后就驻留内存,删除js引用没有半毛作用。那么实际上,切分css反而变得简单了,它不存在相互之间的引用问题,随便在样式里面的哪个地方,都可以断开来。

但是,想要通过webpack把vendors的scss单独打包却不是很容易。scss有一个重要的机制,就是继承。倘若你要从某个vendor中继承某些特性,那么必须引用拥有该特性的文件,而且在sass编译的时候,这个引用必须存在,而编译完之后整个文件就已经出来了。所以,实际上,sass-loader调用node-sass,node-sass调用libsass,sass的编译过程跟webpack没有半毛钱关系,所以你不可能写一个webpack插件来实现sass编译过程中的某些控制。所以,想要在编译的时候,保持这个@import的继承源,同时在编译结束的时候又把两者分开,是比较困难的。

我今早写了一个Note,就是讲解决这个问题的思路。简单的说就是,不能直接@import "module",而是应该import一个具体的入口scss文件,而这个scss文件只提供变量、函数等的出口,而不产生实际的css规则。这样,当你import这个入口scss文件之后,虽然编译实际上还是会引用这个scss,但是编译的结果中没有任何module的css输出,因为你只是引入了当前你的项目文件中需要的一些scss全局变量之类的。

分离font、image等assets

当你的样式里面有字体、图片等,就很复杂了,特别是当这些图片、字体的引用路径跟你的实际路径发生错位时,真是一点办法都没有。举一个例子:

// style/index.scss
@import "libs/_fonts.scss";
@import "libs/_icons.scss";

// style/libs/_icons.scss
...
.icon-tel {
  background: url(../img/icons.png);
}

你可以发现这里引用了一张图片,而且它的路径是相对于_icons.scss的,而非index.scss的,我们可以使用file-loader来引用这个图片文件,但是问题是,路径问题怎么解决。这可能你还可以通过自己在写代码的时候注意,但是一些第三方的组件你怎么办?比如bootstrap-sass里面,有大量的字体文件引用,而且它也是使用的相对路径,你真是没办法。

不幸中的万幸,有大神开发了一个resolve-url-loader解决了这个问题。使用了这个loader,就可以实现无论你的样式引用路径怎么变,都是基于你被引用的那个文件来查找资源。这样就不会出现上面的路径问题了。不过,它的配置稍微有点复杂。

下面我们就结合file-loader和resolve-url-loader,实现本节分离assets的目的:

import ExtractTextPlugin from 'extract-text-webpack-plugin'

export default {
  entry: './app/index.js',
  output: {
    filename: 'dist/js/[name].[chunkhash:8].js',
    libraryTarget: 'umd',
  },
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ExtractTextPlugin.extract({
          use: [
            {
              loader: 'css-loader',
              options: {
                sourceMap: true,
              },
            },
            {
              loader: 'resolve-url-loader',
              options: {
                sourceMap: true,
                keepQuery: true,
              },
            },
            {
              loader: 'sass-loader',
              options: {
                sourceMap: true,
                importer: SassModuleImporter(),
              },
            },
          ],
          fallback: 'style-loader',
        }),
      },
      {
        test: /\.woff2?$|\.(ttf|eot)$/,
        loader: 'file-loader',
        options: {
          name: '[hash:16].[ext]',
          outputPath: 'dist/font/',
          publicPath: url => url.replace('dist/font/', '../font/'),
        },
      },
      {
        test: /\.(svg|png|jpg|jpeg|gif)$/,
        loader: 'file-loader',
        options: {
          name: '[hash:16].[ext]',
          outputPath: 'dist/img/',
          publicPath: url => url.replace('dist/img/', '../img/'),
        },
      },
    ],
  },
  plugins: [
    new ExtractTextPlugin({
      filename: 'dist/css/[name].[chunkhash:8].css',
      allChunks: true,
    }),
  ],
}

上面的红色部分必须注意,其中sourceMap必须true,它是resolver-url-loader找到正确路径的依据,如果不设置为true,就找不到正确的路径。而且是sass-loader一定要设置。

file-loader的publicPath配置项比较复杂。一般,我们会传一个字符串给它,但是这里不能传字符串,因为有一个路径问题,如果你传字符串,它会跟outputPath组合起来,成为资源的url的前缀。而我们这里传入了一个函数,通过替换的方式,把url前缀给处理掉了,这样,css文件里面的各个资源url就可以正确引用了。

蓝色字所在的配置,让不同的资源放在不同的文件夹下面,字体放font文件夹,图片放img文件夹。

优化压缩代码

在产品上线的时候,我们会提前把代码优化压缩后再上线。优化js,我们可以使用webpack自带的UglifyJsPlugin,这个比较好办:

{
  plugins: [
    new webpack.optimize.UglifyJsPlugin({
        minimize: true,
        comments: false,
      }),
  ]
}

优化提炼出来的css比较难办,因为css是从脚本里面提炼出来的,很难进行处理。还好,我们有插件可以帮忙,我们使用一个叫optimize-css-assets-webpack-plugin的插件来处理:

import OptimizeCssAssetsPlugin from 'optimize-css-assets-webpack-plugin'
// ...
export default {
  plugins: [
    new OptimizeCssAssetsPlugin(),
  ]
}

optimize-css-assets-webpack-plugin内部使用了cssnano来优化css,而cssnano内部又调用了postcss,所以如果你对这两个工具比较熟的话,可以在后文的配置的地方更清楚一点。

总之,通过上面两步操作,js被优化了,而且还享受了webpack的tree shaking功能,css也优化了,享受cssnano带来的至极体验。

sourcemap问题

原本,如果是产品上线的话,是不需要sourcemap的,但是如果你在本地先跑一下看看,那么可以先把sourcemap加进去,这样就可以在本地跑的时候,利用sourcemap进行调试了。

启用sourcemap涉及的地方还挺多:

export default {
  output: {
    sourceMapFilename: '[file].map',
  },
  devtool: 'source-map',
  plugins: [
    new webpack.optimize.UglifyJsPlugin({
        minimize: true,
        comments: false,
        sourceMap: true,
      }),
    new OptimizeCssAssetsPlugin({
        cssProcessorOptions: {
          map: {
            inline: false,
          },
          discardComments: {
            remove: comment => comment[0] !== '#',
          },
        },
      }),
  ],
}

蓝色的[file]要注意,因为我们需要js和css的sourcemap分开,所以,我们不能把sourcemapFilename设置为[name].js.map,这样的话你会发现,[name].js.map最终其实是css的sourcemap,js的sourcemap被这个文件覆盖了。而使用[file].map的话,css的sourcemap会和css同一个目录。

下面两个插件配置中的红色部分也很重要,特别是optimize-css-assets-webpack-plugin这个插件,它最终会调用postcss,而postcss对sourcemap又非常敏感,不按照我红色标记的配置,会导致你的css中丢失sourcemap的引用。

当然,实际上,当你进行产品发布的时候,sourcemap是非必须的,甚至是不应该提供的,提供sourcemap反而会暴露你的机器路径。

小结

本文主要涉及的是webpack在应用层面进行打包时的一些相对复杂的问题,而不是开发过程中的问题,所以可能也比较偏门。但是对于工具使用越熟悉,对我们的开发效率也越有帮助。我把上面所讲到的所有点融合在一起:

/**
 * webpack.config.babel.js
 * this config file should be used to build production application
 */

import BowerResolvePlugin from 'bower-resolve-webpack-plugin'
import ExtractTextPlugin from 'extract-text-webpack-plugin'
import HtmlWebpackPlugin from 'html-webpack-plugin'
import shell from 'shelljs'
import path from 'path'
import webpack from 'webpack'
import OptimizeCssAssetsPlugin from 'optimize-css-assets-webpack-plugin'
import SassModuleImporter from 'sass-module-importer'

shell.exec('cd dist && rm -rf *') // 删除上一次打包结果

export default (env, defaults, options = {}) => { //导出函数形式,还可以直接在其他模块中引用
  let config = {
    entry: './app/index.js',
    output: {
      filename: 'dist/js/[name].[chunkhash:8].js',
      libraryTarget: 'umd',
      sourceMapFilename: '[file].map',
    },
    devtool: 'source-map',
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /(node_modules|bower_components)/,
          loader: 'babel-loader',
          options: {
            presets: [['es2015', {modules: false}]],
          },
        },
        {
          test: /\.scss$/,
          use: ExtractTextPlugin.extract({
            use: [
              {
                loader: 'css-loader',
                options: {
                  sourceMap: true,
                },
              },
              {
                loader: 'resolve-url-loader',
                options: {
                  sourceMap: true,
                  keepQuery: true,
                },
              },
              {
                loader: 'sass-loader',
                options: {
                  sourceMap: true,
                  importer: SassModuleImporter(),
                },
              },
            ],
            fallback: 'style-loader',
          }),
        },
        {
          test: /\.woff2?$|\.(ttf|eot)$/,
          loader: 'file-loader',
          options: {
            name: '[hash:16].[ext]',
            outputPath: 'dist/font/',
            publicPath: url => url.replace('dist/font/', '../font/'),
          },
        },
        {
          test: /\.(svg|png|jpg|jpeg|gif)$/,
          loader: 'file-loader',
          options: {
            name: '[hash:16].[ext]',
            outputPath: 'dist/img/',
            publicPath: url => url.replace('dist/img/', '../img/'),
          },
        },
      ],
    },
    resolve: {
      plugins: [
        new BowerResolvePlugin(),
      ],
      modules: [
        'node_modules',
        'bower_components',
      ],
      descriptionFiles: [
        'package.json',
        'bower.json',
      ],
      mainFields: [
        'main',
        'browser',
      ],
      alias: {
        _: 'underscore',
      }, // 这是我项目中的一个处理,仅做参考
    },
    plugins: [
      new webpack.ProvidePlugin({
        jQuery: 'jquery', // for bootstrap-sass,这一点在前面没有讲到过,如果你用bootstrap-sass,需要注意这点
        'window.jQuery': 'jquery', // for angular
      }),
      new webpack.optimize.CommonsChunkPlugin({
        name: 'vendors',
        minChunks: (mod, count) => {
          // this is used to pick out vendors
          let resource = mod.resource
          if(resource && (/^.*\.(css|scss)$/).test(resource)) {
            return false
          }
          let context = mod.context
          if(!context) return false
          if(context.indexOf('node_modules') === -1 && context.indexOf('bower_components') === -1) return false
          return true
        },
      }),
      new webpack.optimize.CommonsChunkPlugin({
        name: 'manifest', //But since there are no more common modules between them we end up with just the runtime code included in the manifest file
      }),
      new webpack.optimize.UglifyJsPlugin({
        minimize: true,
        comments: false,
        sourceMap: true,
      }),
      new ExtractTextPlugin({
        filename: 'dist/css/[name].[chunkhash:8].css',
        allChunks: true,
      }),
      new OptimizeCssAssetsPlugin({
        cssProcessorOptions: {
          map: {
            inline: false,
          },
          discardComments: {
            remove: comment => comment[0] !== '#',
          },
        },
      }),
      new HtmlWebpackPlugin({
        filename: 'dist/index.html',
        template: 'app/index.html',
        chunksSortMode: (chunk1, chunk2) => {
          let orders = ['manifest', 'vendors', 'main']
          let order1 = orders.indexOf(chunk1.names[0])
          let order2 = orders.indexOf(chunk2.names[0])
          return order1 > order2 ? 1 : order1 < order2 ? -1 : 0
        },
      }),
    ],
  }
  return config
}

这就是我写的一个demo,把前文所述的所有知识点集中在了一起。你可以根据自己的项目实际情况来选择使用其中的某个点。

当然,我的表述也可能存在胡扯的地方,你要是发现了,就在下面留言吧!

2017-05-11 | ,