Webpack 基础

webpack作为前端现在比较热门的一个基于js应用的打包工具,在打包过程中会根据配置中提供的入口文件递归的去寻找依赖,根据配置文件来决定是否将某一项依赖打包进一个块中,webpack是根据所提供的配置文件来进行打包,它的可配置项很多,这意味着它的灵活性很大,这里没有详细了解过其他打包工具,所以也就不做不专业的对比了。就说一说它里面的一些基础概念和一些实践经验。

核心概念

  • entry: < string | object | array > | < function | promise >

entry这个field作为webpack的打包入口,相当于一个根节点,wepack从entry开始可以构建一个依赖树,打包这些依赖为一个chunk。

module.exports = {
    entry: './index.js',
    output: {
        filename: 'index.bundle.js',
        path: path.resolve(__dirname, 'dist')
    }
}
//entry 此时是一个字符串,意思是以index.js作为入口文件递归此文件中的依赖,构建依赖树,然后生成名为index.bundle.js的文件,被页面引用

entry的可接收值为字符串对象数组,当有一个入口的时候使用可使用字符串,多个入口文件时可以使用对象。

// 一个入口文件
module.exports = {
    entry: './index.js',
    ... ... 
}

数组是可以将多个不相互依赖的文件作为一个chunk来打包,例如打包第三方依赖的时候

// 一个入口文件包含多个文件的情况
module.exports = {
    entry: ['./index1.js', './index2.js'],
    ... ... 
}
//打包会生成一个块文件,包含了index1和index2

使用对象时,对象中属性的key值作为打包之后生成的chunk的名字,当使用字符串或者数组直接作为entry的值时,默认的chunk名字是main

// 多个入口文件
module.exports = {
    entry: {
        a: './index1.js',
        b: './index2.js'
    },
    ... ... 
}
//打包会生成多个块文件,名字和entry对象中的key值对应

entry可接收的值还有函数,函数可以返回一个promise

  • output: <object>

output 是用来描述如何输出打包后的文件,包括文件名,文件目录等等配置。
里面的属性有很多,这里只提三个:filename, path, publicPath
filename是必须要有的field,说明了你的打包输出文件的名字该怎么定义。名字定义中有[name],会跟entry对象中的key值对应,若entry值为字符串或数组,[name]对应为main。一般会在文件名中包含'bundle'来和未打包文件进行区分

module.exports = {
    entry: {
        a: './index1.js',
        b: './index2.js'
    },
    output: {
      filename: '[name].bundle.js'
    }
    ... ... 
}
// 输出文件为 a.bundle.js, b.bundle.js

module.exports = {
    entry: './index2.js', 
    output: {
      filename: '[name].bundle.js'
    }
    ... ... 
}
module.exports = {
    entry: ['./index1.js', './index2.js'] 
    output: {
      filename: '[name].bundle.js'
    }
    ... ... 
}
// 输出文件为 main.bundle.js

path就是你打包后的文件应该在的目录位置。注意path应为一个绝对路径,通常我们会接住path来解析生成绝对路径

const path = require('path');
module.exports = {
    ... ...
    output: {
       ... ...
       path: path.resolve(__dirname, 'dist'),
    }
    ... ...
}

publicPath更像是一个访问路径,当服务启动之后,访问页面资源应使用的路径是 publicPath + loader或插件等配置路径
publicPath有相对路径和绝对路径,绝对路径一般用于线上生产环境,相对路径的话就是在开发环境使用,相对路径根据值的不同有相对于html页面,相对于服务,相对于协议这三种情况
关于publicPath, 我个人在看文档的时候觉得还是不太好理解,大家可以自己去写几个demo或者结合实际项目去理解,不必过分纠结,实在不行踩个坑就知道了

... ...
 output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/assets/'
    // publicPath: 'http://localhost:4050/'
  },
... ...
  • loaders: <object>

loaders 在webpack中是相当重要的一个概念,webpack打包过程中对各种文件的处理都是依靠着loader来进行的。官网首页最醒目的一幅图,是将各种文件经过webpack最后打包成静态资源文件,原文件中不乏coffeejs,ts,jsx,sass,jpg等各种,最终输出的只有js,css和图片这几种静态资源的类型,中间经过的就是各种loader的转换。

新版本中loader存在于module这个大的模块中,定义跟之前稍有不同

module.exports = {
    ... ...
    module: {
         rules: [
            { 
              test: /\.css$/,
              use: [
                'style-loader',
                {
                  loader: 'css-loader',
                  options: {
                    importLoaders: 1
                  }
                }
             ] 
           },
          { // rule.resource: {}
            test: /\.jsx$/,
            include: [path.resolve(__dirname, "app")],
            exclude: [path.resolve(__dirname, "app/demo-files")],
            loader: 'babel-loader',
            options: {
               presets: ["es2015"]
            },
            //and:[], or: [], not: []
            issuer: {test: ..., include: ..., exclude: ...}
          }
       ]
    }
... ...
}

module中有一个rules属性,这个数组里包含了许多case,每个case对应了一种或几种文件类型,case中还包含了对match到的文件应该做何处理的配置。
例如,第一个case中是对css文件的处理,test属性用来匹配文件中的依赖项,test: /\.css$/是说匹配文件后缀是.css的依赖项,use数组中可以放多个loader来处理匹配到的文件,上例中用到了style-loader,和css-loader,通常情况下use: ['style-loader', 'css-loader'],这样的写法就可以达到使用多个loader处理的效果,这里因为要给css-loader做额外的配置,所以用一个对象进行详细说明

{
  loader: 'css-loader',
  options: {
    importLoaders: 1
  }
}

对象中的loader属性,值为loader名, options里放的是这个loader自己的一些配置项,因loader而异, 需要注意的是在新版本中loader必须要带上-loader 的后缀,不会再帮你补全
Condition:这里除了loader之外额外的有一些对文件筛选的配置,也就是条件约束 ,条件约束包含了对依赖项文件包含依赖项文件的两种条件约束的配置,配置项都一样
例如:在index.js文件中

import A from './a.js'

此时的a.js就是依赖项文件, index.js是包含依赖项文件

rules: [
    {
        resource: ...,
        issuer: ...
    }
]

rules数组里的项是rule,
rule.resource中的条件限制对应的是依赖项文件
rule.issuer中的条件限制对应的是包含依赖项文件
默认情况下rule中的直接写的test, include, exclude等等这些条件都是rule.resource中的,是对依赖项文件的约束,rule.issuer中的条件约束则是对应包含依赖项文件

test 接收的是正则表达式或正则表达式的数组,来匹配对应的文件
include白名单,满足test的条件之后,如果包含在include文件中,会被对应的loader处理
exclude黑名单,满足test条件之后,也满足exclude的文件,loader处理的时候就会跳过它
除此之外,还有and:满足所有条件, or:满足其中一个条件, not:不满足该条件这三个condition的字段。

  • plugins: <object>

plugins 跟loader类似,也是丰富webpack的功能,但是plugin负责的部分是webpack打包处理的过程,而不是针对文件

plugins: [
  new webpack.optimize.CommonsChunkPlugin({
    ...
  })
]

使用的话就是直接在plugins数组中添加,例如上面代码中CommonsChunkPlugin这个插件可以把每个chunk中重复引用的依赖抽取出来,还有一个功能就是把webpack的进行打包时引导代码,和我们的代码剥离开来,这个在缓存文件时候剥离开来会避免不必要的重新加载

开发相关

  • devtool:
devtool: "source-map" // "inline-source-map", "cheap-module-source-map"...

devtool在开发过程中需要填入配置的一个选项,devtool中设置打包文件的source map。webpack打包文件之后涉及到了压缩,代码转化,例如jsx转js,ts转js等,这会让开发在debug的时候看到的代码跟自己写的的代码对应不起来,source map的作用就是解决这个问题,让开发在debug的时候看到的出错代码和自己写的代码对应起来,找到问题。
webpack提供了多种source map的类型,大家可以根据自己的需要在文档中看自己需要的source map的类型,这里就不做详述了
值得一提的是在我们打包生产环境的时候devtool这个属性记得去掉,一是生产环境不需要source map,二是这个source map会使文件内容变的很大

  • devServer:
devServer: {
  contentBase: path.join(__dirname, "dist"),
  compress: true,
  port: 9000
}

devServer是当webpack用web-dev-server 启动的时候它会去找的参考配置项,里面包含了一些是否压缩,指定监听请求的端口,还有提供静态资源的目录

  • WebpackDevServer:

还能以使用node api的的方式启用webpack dev server,比如express的话可以加一个这个中间件
new WebpackDevServer(compiler, {...})
用这种方式加入额外配置

代码分离

在webpack打包过程中会跟据entry来输出对应的打包后的模块,这个时候就存在有多个模块都有一些公共的第三方依赖,比如loadsh之类的工具库,如果放之不管,那打包后的模块中就出现了一些冗余重复的代码,浏览器本身单线程的原因,资源的加载速度就会影响页面的响应时间。
常用的代码分离的方式官网说了三种:

  • 使用entry添加公共模块入口,手动分离
  • 使用插件CommonsChunkPlugin提取公共代码
  • 使用动态引入依赖的方式来分离模块

这里讲一讲动态引入依赖的方式,使用插件的方法,我们在随后讲

function getComponent() {
    return import(/*webpackChunkName: "lodash"*/'lodash').then(_ => {
    var element = document.createElement('div');

    element.innerHTML = _.join(['Hello', 'webpack'], ' ');

    return element;

  }).catch(error => 'An error occurred while loading the component');
}

getComponent().then(component => {
    document.body.appendChild(component);
})

这个是官网提到的一个推荐的方式,使用ECMAScript提出得import()的语法,来动态引入第三方模块,除此之外还可以用async, await的方式,还有require.ensure的方式

// require.ensure的方式
function appendComponent() {
    require.ensure(['lodash'], function () {
        var element = document.createElement('div');
        element.innerHTML = _.join(['Hello', 'webpack'], ' ');
        document.body.appendChild(element);
    });
}

//async,await的方式其实和import()的一样,import返回的是promise,这种写法会更易读,但是还是要对他们的语法做兼容
async function getComponent() {
  var element = document.createElement('div');
  const _ = await import(/*webpackChunkName: "lodash"*/ 'lodash');
  element.innerHTML = _.join(['Hello', 'webpack'], ' ');
  return element;
}

代码实现上是这样操作的,那配置中需要做的处理是什么呢?

const path = require('path');

module.exports = {
    entry: {
        index: './index.js'
    },
    output: {
        filename: '[name].bundle.js',
        chunkFilename: '[name].chunk.js',
        path: path.resolve(__dirname, 'dist')
    }
}

这里的index.js中已经有了动态引入依赖的代码,入口文件中只有一个,然后重要的是在output中我们加了一个chunkFilename的属性,这个属性是用来定义没有显示引用的依赖文件打包后的文件名,这里的[name]占位符会被预先替换成chunk的id,当在引入依赖的时候有定义,才会替换成对应的名字。

例如: require.ensure(['lodash'], function () {}, 'lodash' ),ensure最后一个参数是定义的chunk名,这样在打包之后,生成的文件名就是'lodash.bundle.js'否则会使用chunk id,如果它是第一个chunk的话,文件名为'0.bundle.js'

缓存

经过webpack打包之后的文件部署放到服务器之后,浏览器通过访问网站,会去请求这些打包之后的资源文件,访问资源文件的过程会比较耗时,比如我们常常遇到的访问页面时间过长的问题,所以浏览器有一个缓存的机制,若要请求的文件在本地有缓存,则浏览器不会重新去请求新的文件,从而使网站的访问速度变快,但是相应的问题是,如果在部署新版本的时候文件名没有变化,浏览器还是会用自己缓存下来的旧文件,不会去更新资源。所以一般文件名都会带上类似版本号的额外字符串,这样每次有新版本部署的时候,版本号发生变化就会去请求新的文件,而平时没有新版本更新的时候,仍然去请求缓存,加快访问速度。

webpack中对文件打包输出时,可以对文件做这种命名处理

output: {
        filename: '[name].[hash].js',
        path: path.resolve(__dirname, 'assets')
    }

webpack在输出打包文件的时候可以给它的名字加上一段哈希,哈希的长度可以通过[hash:8]这样的方式来配置,默认是20,这样的文件名结构在生成的时候会根据entry中的key值来作为[name], [hash]会跟每次的打包有关,也就是每次打包之后hash的值都会重新生成,也就是说就算你的文件没有发生修改,但因为其他文件有修改的原因导致的重新打包,也会使没有发生修改的文件的名字发生改变,这显然不是我们想要的

output: {
        filename: '[name].[chunkhash].js',
        path: path.resolve(__dirname, 'assets')
    }

[hash]相关的还有[chunkhash], chunkhash也会在文件输出的时候,在文件名上附带上一串哈希值,但是chunkhash是跟文件内容相关的,也就是说它是跟着每个文件自己走的,用chunkhash就可以避免刚才说的使用hash会存在的统一处理的问题
但是有些人在使用chunkhash的时候会发现有时候还是会出现,这个文件没有被修改,但是它的hash值还是变了。为什么呢?

chunkhash是跟着文件自己走这个没问题,文件自身除了自己包含的内容之外,还有它的引入顺序,还包含了webpack 在入口 chunk 中加入的某些样板(boilerplate),特别是 runtime 和 manifest,

当有新的依赖文件加入的情况部分其他依赖文件引入顺序可能会受到影响,所以引入的依赖文件的Id也变了,虽然该文件内容没变,但是chunkhash的值也会发生变化,还有提到的webpack的引导代码也会在某些时候导致这种情况

module.exports = {
    entry: {
        print: './print.js',
        index: './index.js',
        vendor: ['lodash'] //also need to use commonsChunkPlugin
    },
    output: {
        filename: '[name].[chunkhash].js',
        path: path.resolve(__dirname, 'assets')
    },
    plugins: [
        new webpack.HashedModuleIdsPlugin(),
        // new webpack.NamedModulesPlugin(),
        new webpack.optimize.CommonsChunkPlugin({
            name: ['vendor', 'runtime']// order 
        })
    ]
}

上面代码中首先是使用了HashedModuleIdsPlugin这个插件,这个插件可以让模块的Id变成跟模块名字相关的字符串,只要依赖的文件名字不变这个hash值就不会变,当引入新的依赖的时候,由于依赖文件的id没有发生变化,所以chunkhash的值,不会由于这个原因发生改变, NamedModulesPlugin的作用跟它类似。
另外这里还用了CommonsChunkPlugin这个插件,这个插件之前提到过是用来提取重复的依赖模块的,使用的时候需要在entry中加入会重复的依赖模块的入口,一般会使用vendor: []数组的形式来定义,这样会把多个第三方的模块都打包到vendor这个模块中,然后还要在这个插件中用:

new webpack.optimize.CommonsChunkPlugin({
       name: ['vendor', 'runtime']// order 
})

这种方式来声明一下要提取的共有模块,最后加一了一个entry中没有的runtime模块,CommonsChunkPlugin插件有一个隐藏功能就是可以把webpack的引导代码从各个文件中剥离抽取出来,在它里面声明一个在entry没有的模块的名字,就会作为webpack引导代码的名字来输出模块,你也可以使用manifest来命名,都可以,但是这里需要注意的是顺序,引导代码的剥离一定要放到共有模块的后面,也就是这个插件中声明的模块的最后,保证可以把每个entry在打包时加入的那些样板都剥离出来,避免不必要的缓存更新

做完这两点,就基本能够保证,在我文件内容发生修改之后,只有对应的文件hash值会发生变化,从而避免不必要的页面重新请求已存在的资源。

关于webpack这里就讲这么多了,之前项目上遇到的一些点,拿出来对照文档说了一下,肯定不会特别全面,有哪里说的有问题的,欢迎大家指出!

推荐阅读更多精彩内容