webpack总结

154 阅读6分钟

webpack的概念

webpack 是前端静态资源打包器,通过指定一个入口文件,形成模块的依赖关系树,每一个依赖俗称 chunk,然后通过指定每一个 chunk 对应的打包方式,使之输出为浏览器可以解析的 bundle。我们得到了不同的bundle之后,再在html页面中分别引入,我们就可以看到呈现出来的页面了。

微信图片_20221103141059.jpg

webpack的五个核心的概念

Entry:指示webpack以哪个文件为入口起点开始打包。

Output:指示webpack打包之后的bundle输出到哪里去,以及如何命名。

Loaderwebpack 本身是只能处理js文件和json文件,而loader则充当了一个翻译官的角色。将别的类型的文件处理成webpack能够看懂的文件。

Plugins:用于执行范围更加广泛的任务,插件包括优化和压缩,一直到重新定义环境中的变量等等。

Mode:指示 webpack 使用相应的模式。development/production

webpack打包各种资源

打包样式资源

我们在项目中写样式的时候,通常不会直接写成css而是会借助less scss等样式资源的预处理器,那么我们在使用webpack进行打包的时候就要考虑处理.css .less .scss等后缀的文件。由于webpack只能处理jsjson结尾的文件,所以我们要借助loader来帮我们把其它后缀的文件“翻译”成webpack能够处理模块。

// 假设我们使用scss,应该进行如下webpack配置
module.exports = {
  entry: 'main.js',
  // ...
  // 在 webpack 配置中定义loader时,要定义在module.rules中
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          { loader: 'style-loader' },  // 第三步执行
          {
            loader: 'css-loader',  // 第二步执行
            options: {
              modules: true,
            },
          },
          {
            loader: 'scss-loader',  // 第一步执行
          },
        ],
      },
    ],
  },
}

上述配置的意思是:“嘿,webpack 编译器,当你碰到「在 require()/import 语句中被解析为 '.scss' 的路径」时,在你对它打包之前,先使用 scss-loader 转成css文件,然后在用css-loader将css文件翻译成webpack可处理的模块,然后在用style-loader将处理好的css插入至html的head标签中。”

可以发现loader的解析顺序是从数组的最后一项向前执行的,这是配置的规定,不能够乱序。正是因为有了loader的存在,我们才能够在js文件中大胆的引入scss文件,其它文件同理。,如:

// 在vue的主入口main.js中,我们时常这样写
import '@/style/index.scss'

而通常我们对css的处理不仅仅是这么简单,我们可能还要对它进行压缩,提取以及进行浏览器的兼容性处理等等

兼容性处理:postcss-loader与postcss-preset-env

     {
        loader: 'postcss-loader',
        options: {
          plugin: () => [
            require(postcss-preset-env)(), 
          ],
        },
      },

postcss-preset-env是帮助postcss找到package.json中的browsersList(也可能是在项目目录下的.browsersListrc)的配置,通过配置对css进行兼容处理。

提取cssmini-css-extract-plugin这是一个插件,这个插件的作用是将 css-loader 转化后的模块抽取成独立的css文件,打包之后通过link标签将样式引入进 html,我们可以指定打包后的css存在的文件路径,在插件中传递filename参数即可(显然这和style-loader的功能是冲突的,这个插件中有自己内置的loader。所以按照自己的需求进行配置)。

const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
  plugins: [new MiniCssExtractPlugin()],
  module: {
    rules: [
      {
        test: /.css$/i,
        use: [MiniCssExtractPlugin.loader, "css-loader"],
      },
    ],
  },
};

压缩css:optimize-css-assets-webpack-plugin

至此我们就完成了项目中对css文件的打包处理,过程大致如下:

  1. 当解析到import 'xxx.scss'文件时,首先我们需要配置loader,将scss转化成css-scss-loader
  2. 经过第一步我们得到了转化来的css,然后要将它转化成webpack可解析的模块-css-loader
  3. 使用style-loader将处理好的css以<style>标签的形式插入到html的head标签中
  4. 如果不想操作3步骤,我们选择将css提取出来,那么就用到了mini-css-extract-plugin
  5. 我们需要根据package.json中的browsersList选项的设置,来对css进行兼容性处理-postcss-loaderpostcss-preset-env
  6. 做好了兼容性的处理我们需要对css进行压缩,以减少项目包的体积-optimize-css-assets-webpack-plugin

这是一个生产环境的配置,如果开发环境考虑调试等因素,可以不做压缩等处理,根据团队需求进行配置。

打包js资源

语法检查eslint-webpack-plugin对js语法做校验,使用eslint进行代码检查,必须要先进行eslint的校验规则,现成的语法校验已经有很多种风格的规则,可以在github上面进行搜索查看。可以使用现成的,也可以是我们自己进行配置。这个配置可以是我们在项目目录下创建的.eslintrc文件,也可以是我们在package.json中的eslintConfig选项。如果我们使用vue与vue-cli等框架搭建项目,我们创建的时候会提供这个选项以及选项中的一些默认配置。

const ESLintPlugin = require('eslint-webpack-plugin');

module.exports = {
  // ...
  plugins: [new ESLintPlugin(options)],
  // ...
};

兼容性处理babel-loader babel @babel/core(babel的核心库)。

  • @babel/preset-env:只能够处理js新增的语法变化,不能够处理js的全局对象,如:Promise
  • @babel/polyfill:能够做全部的js兼容性处理,但它不是一个插件,直接在写js文件的时候第一行引用即可。但是如果只向处理部分兼容性的问题,缺引入了所有兼容性的代码,会导致代码体积太大!
  • core-js:按需加载

js压缩:生产环境下将mode设置为production即可

至此我们就完成了项目中对js文件的打包处理,大致过程如下:

  1. 先对js文件进行eslint语法检查
  2. 使用babel-loader对js语法做兼容性的处理,在其中需要设置presetsbabel会根据preset的设置对我们写的代码进行语法校验。preset会有三种值:@babel/preset-env @babel-polyfill core-jscore-js需要设置在@babel/preset-env之后。
 {
   userBuiltInt:'usage', // 指定按需加载
   corejs:{
     version:3 // 指定core-js使用的版本
   },
   target:{ // 具体的兼容性做到那个版本的浏览器
     Chrome:60,
    firefox:60
   }
 }
  1. 处理完成之后,考虑代码体积我们需要对js进行压缩。由于webpack本身就能够处理js文件。所以压缩js并不用使用任何loader与plugin。直接设置mode:production即可
module.exports = {
  mode: 'production'
};

打包图片资源

转化成webpack可处理的模块url-loader。但是它依赖于file-loader,所以要安装这两个依赖。url-loader可以设置limit参数,限制几kb以下就使用base64的处理方法。

module.exports = {
  module: {
    rules: [
      {
        test/.(png|jpg|gif)$/i,
        use: [
          {
            loader'url-loader',
            options: {
              limit8192,
            },
          },
        ],
      },
    ],
  },
};

处理html中的图片url-loader有一个缺点,就是处理不了html文件中通过img标签引用的图片,所以我们还需要借助html-loader来处理。html-loader默认采用的是commonjs的模块化规范,而url-loader默认是使用es6的模块化规范,所以使用html-loader的时候需要将url-loader中的options传入esModule:false选项

打包其它资源

在webpack4中可以使用file-loader,在webpack5中,可以直接设置Asset Modules的type,详情参考Asset Modules

对html的处理

使用html-webpack-plugin,这个插件主要作用是在最终生成的目录下创建一个空的html文件,然后引入所有打包好的资源。如果想要其不创建新的模板,而是使用我们自己写好的模板,那么就需要传入一个 template 选项

DevServer

作用:用来自动化,自动编译,自动打开浏览器,自动刷新浏览器等等。

特点:只会在内存中编译打包,不会有任何的本地输出。

webpack的优化项

开发环境的优化项

开发环境我们需要优化的打包项为:

  1. 优化打包构建的速度
  2. 优化代码调试

HMR(hot module replacement)模块热替换/热模块替换

作用:一个模块发生变化,只会重新打包这一个模块,而不是打包所有,这样会极大地帮我们提升代码的构建速度。

用法:

DevServer:{
   hot:true
}
  • css可以使用HMR功能,这全都依赖于style-loader,详情参考HMR with Stylesheets,我们想要在开发环境使css也发生热更新,则需要配置style-loader
  • js默认是不支持HMR的,想要支持HMR需要修改js的代码(只能处理非入口文件),我们需要在index.js文件中写下代码:
// index.js
if (module.hot) {
  module.hot.accept('./print.js', function() {  // 如果print.js文件发生了变化,只执行回调函数
    console.log('Accepting the updated printMe module!');
    printMe();
  })
}

source-map

提供源代码到构建后代码映射的技术,如果构建后代码出错了,可以通过映射追踪到源代码

用法:在 webpack 的配置中配置devtool:'source-map'即可

source-map 的取值:[inline-][eval-][hidden-][nosources-][cheap-[module-]]source-map

  • source-map:外部的,会提示到代码的错误位置和错误信息。
  • inline-source-map:内联的,不会生成 map 文件,会内联在打包后的 js 文件中,会提示到代码的错误位置和错误信息。
  • eval-source-map:内联的,不会生成map文件,每一个文件都生成对应的source-map文件,放在eval中,会提示到代码的错误位置和错误信息,指示会多出一个hash值。
  • hidden-source-map:外部的,会生成 map 文件,会告诉你错误原因,但是不能追踪到源代码的错误。它所指示的错误位置还是构建后的代码。
  • nosources-source-map:外部的,能够找到错误代码的准确信息,但是不能找到源代码。
  • cheap-source-map:外部的,也能够找到准确的信息,和源代码,但是只能精确到行,其它的是能够精确到行和列的
  • cheap-module-source-map:外部的

内联和外部的区别:外部生成了 map 文件,而内联没有,内联的构建速度相对来说会快一些

开发环境考虑速度快,调试更友好

  • 构建速度:eval>inline>cheap>...
    • eval-cheap-source-map
    • eval-source-map
  • 调试更友好
    • source-map:精确到行和列
    • cheap-module-source-map:module 会将 loader 的一些 source-map 也融合进来,它更加的全面具体
    • cheap-source-map:精确到行
  • 最佳选择: eval-source-map > eval-cheap-mudule-source-map

生产环境考虑源代码要不要隐藏,调试要不要更友好?

  • 源代码隐藏:
    • nosources-source-map 源代码和构建后代码都隐藏
    • hidden-source-map 只隐藏源代码,不提示构建后的代码错误
    • eval-source-map 因为它是内联的,生产环境下我们不考虑内联的这种方式,因为内联会让包的体积变得非常的大
  • 考虑调试的话:
    • source-map
    • cheap-mudule-source-map

生产环境的优化项

生产环境我们需要优化的打包项为:

  1. 优化构建速度
  2. 优化代码性能

OneOf

这个配置项是处理loader的,通常情况下很多个loader会挨个匹配,直到命中。加了oneof配置项之后,就设置了底下的loader只会命中一个。

注意:Oneof 的配置项中,不能够同时写两个相同的test匹配规则,否则只会生效优先级高的,优先级低的都不生效。假设我们写了eslint-loaderbabel-loader,那么babel-loader就不生效了。因为eslint-loader权重高一些。这时候需要将eslint-loader写在与oneof同级并在其配置项之前。这样我们就可以使 loader正常生效了。

缓存

babel缓存

我们在编译了代码之后,可能想要修改某一个 js 模块,但是 babel 在此编译的时候会将所有模块在处理一遍,这样子性能也是十分低下的。所以这时候我们可能需要为 babel 设置一下缓存。

方法:在babel-loaderoptions中添加cacheDirectory:true。这样就设置了babel再次编译的时候取缓存,只重新编译改动的模块

静态资源缓存

我们打包编译后的代码,被浏览器请求访问的时候,可以设置缓存。也就是强缓存协商缓存

  • 强缓存资源后,在没有过期之前不会重新发送请求获取
  • 协商缓存是在资源发生变化了之后,重新发送请求确认是否需要走缓存。如果需要就使用本地缓存,如果不需要就重新请求

我们使用webpack打包之后,是可以通过配置打包的规则,来协助处理请求资源时的缓存。

chunkHash:根据chunk生成hash值,如果打包的来源是同一个chunk,那么生成的hash值还是一样的。这样导致的问题是:css是从js被引入的,他们来源是同一个 chunk。如果只改变了css文件或者是js文件,js和css文件会一起重新请求,不会命中缓存,因为二者是使用的同一个 hash 值,一个变了,另外一个就会发生变化。为了解决这个问题,webpack 提出了 chunkHash 的配置项。

contentHash:根据文件内容生成的 hash,不同的文件生成的 hash 一定是不一样的。

所以我们更新迭代的时候,如果hash值发生了变化就代表是更新后的代码。浏览器请求时会重新请求新资源,如果没有发生变化,那么就走缓存。

Tree Shaking去除打包后的无用代码

使用前提:

  • 项目使用的是es6的模块(tree shaking本质就是对静态模块进行分析)
  • 需要设置mode:prodution

如果不想某一些文件进行tree shaking,可以在package.json中设置sideEffets:['*.css'],那么webpack在处理这个文件的时候就不会对其进行tree shaking,如果sideEffects:false就表示所有代码都没有副作用,可以进行tree shaking

code split 代码分割

代码分割分为三种情况:

  • 多入口打包
  • webpack的优化选项
  • 动态导入

多入口打包

Entry选项中设置为对象或数组。对象属性值和数组的每一项为一个入口,有几项就有几个值。webpack打包的时候会从这几个入口分别构建关系树,对代码进行分割。

webpack的优化选项

在webpack的optimization选项中配置splitChunks:{chunks:'all'},这样配置的好处:

  • 在多入口打包的时候自行分析多入口的chunk文件看是否含有公共文件,如果有的话也会单独打包成一个chunk
  • webpack把node_modules中引入的包单独打包。这样做的好处是我们在很多js文件中都引入了同一个公共模块的时候。公共模块会被单独打包一次,然后进行引用,而不是每次加载一个文件,公共模块就重新加载一次

动态导入

指定某一个模块单独打包。

方式:import('./test.js')

特点:打包后的文件名为webpack自动生成的id,如果以后动态引入了其他模块,这个id是会随着打包的顺序发生变化的。所以如果我们想要对这一点进行控制,就需要在引入的时候自行设置这个模块打包之后的名字:

  • import(/*webpackChunName:'test'*/./test.js)

懒加载和预加载

前提:进行了代码分割,分割成为了单独的模块之后才能实现懒加载或者是预加载(浏览器正常的加载顺序是,资源并行加载,同一个时间可以加载多个文件)

  • 懒加载:使用 js 动态引入的方法,让资源不去挤最初的加载通道。需要的时候再使用 import()加载
  • 预加载:在 import(/* prefetch:true */'./test'),动态引入中加入注释,注释资源为预加载资源。那么浏览器正常加载完其它资源之后,就会偷偷的加载标记为预加载资源的内容。(兼容性问题,慎用!)

PWA

让网页像一些 app 一样离线也可以访问。渐进式网络开发应用程序。

PWA的使用一定要借助serviceWork,在webpack中的配置需要使用work-box-plugin,这个插件帮助serviceWork快速启动并且我们在打包后的根目录生成一个service-work.js的文件。我们需要在index.js 中写下如下代码:

if(serviceWork in navigator){
  window.addEventListener('load',()=>{       
  navigitor.serviceWork.register('./serviceWork').then().catch() 
  }) 
}

这样我们就注册了serviceWork文件。但是serviceWorker代码的运行必须是在服务器上,所以我们需要启动一个服务来运行service-worker

多进程打包thread-loader

使用场景:只有在工作消耗时间长才需要多进程打包,不然进程的开启和通信都是需要时间的,默认开启cpu核数-1个进程来处理内容。 thread-loader放在某一个loader的后面,它就会来启动进程打包,通常在配置中我们会选择放在babel-loader的后面。

使用cdn的方式引入,不需要打包

有时候我们用cdn引入的文件不想让webpack打包,使用external把它排除掉。

比如我们引用了JQuerycdn,但是不想让它被webpack处理,就在external选项中屏蔽它:

 externals:{
   jQuery:jQuery, // 库名:npm 对应的包名
 }

dll动态链接库

使用场景:不使用CDN的方式引入,需要打包。但只需要打包一次,后面的打包只要当前的库没有发生变化,就不用再重新打包。例如:我们没有通过CDN引用JQ,在本地放入了JQ的包,引用使用,我们在打包的时候希望webpack单独打包。这时候就可以使用dll来灵活配置它。以jQuery为例:

  1. 创建一个名为webpack.dll.js的文件来单独打包第三方的库。设置:
module.exports = {
  entry:['...','...','...'],
  output:{
    library:'jQuery', // 设置我们通过哪一个变量访问Jquery
  }
  // ...
}
  1. 借助dllPlugin生成一个manifest.json映射表,只要webpack在这个映射表中查找到了某一个包的映射,就不会再去打包它了。
const path = require('path');

new webpack.DllPlugin({
  context: __dirname,
  name: 'jQuery', // 要与library的值相对应
  path: path.join(__dirname, 'manifest.json'),
});
  1. 借助dllReferencePlugin插件,让webpack去查找manifest映射表,告诉webpack在这个映射表下的第三方模块都是不需要打包的。
  2. webpack 忽略了映射表中的第三方模块,但是没有与开发者的业务代码建立关联关系,所以我们需要使用 add-assets-html-webpack-plugin,这个插件会将某个文件打包输出出去,并在 html 中引入

手写一个mini版的webpack

webpack处理的过程首先是通过指定的入口文件,进行模块解析,生成一份模块之间的依赖关系图。根据这一份依赖关系图生成对应环境能够执行的代码。

首先我们可以创建一些模块,让这些模块之间有一些引用关系,例如:

  1. npm init -y
  2. 创建name.js,写入export default "yeyeye"
  3. 创建message.js,写入import name from "./name.js" export default 'Hello-${name}'
  4. 创建entry.js,写入import message from "./message.js" console.log(message)

其次,创建一个webpack.js文件来实现我们的mini-webpack

/**
 * 手写webpack
 */

const fs = require('fs')
const babylon = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
const path = require('path')

// 读取模块内容
let ID = 0
function createAsset(filename) {
  const content = fs.readFileSync(filename, 'utf-8')

  // 生成ast
  const ast = babylon.parse(content, {
    sourceType: 'module',
  })

  const dependencies = []

  // 遍历当前ast
  traverse(ast, {
    // 找到所有import语法对应的节点
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value)
    },
  })

  const id = ID++

  const { code } = babel.transformFromAstSync(ast, null, {
    presets: ['@babel/preset-env'],
  })

  return {
    id,  // 当前所解析的模块的id
    filename, // 当前这个模块的名称
    dependencies, // 当前这个模块所依赖的模块
    code, // 当前这个模块的源码
  }
}

// 从入口开始分析依赖,生成依赖关系图
function createGraph(entry) {
  const mainAsset = createAsset(entry)

  // 既然要广度遍历,那么一定需要一个队列,而队列的第一项就是createAssets返回的信息
  const queue = [mainAsset]

  // 开始进行遍历
  for (let asset of queue) {
    const dirname = path.dirname(asset.filename)

    // 新增一个属性来保存自已来项的数据
    // 保存一个类似 这样的数据结构------>{'./message.js':1}

    asset.mapping = {}

    asset.dependencies.forEach(relativePath => {
      // 得到当前依赖项的绝对路径
      const absolutePath = path.join(dirname, relativePath)

      // 获取依赖项的具体信息
      const child = createAsset(absolutePath)

      // 将子依赖项的id与信息存储起来
      asset.mapping[relativePath] = child.id

      // 在接着遍历子依赖项
      queue.push(child)
    })
  }
  // 这个是广度遍历之后的结果,项目中的每一个模块的应用都扁平化的存在其中
  return queue
}

// 根据依赖关系图,生成对应环境能执行的代码,目前是生产浏览器可以执行的
function bundle(graph) {
  let modules = ''

  // 循环关系图,并把每个模块的代码存在function作用域中
  graph.forEach(mod => {
    modules += `${mod.id}:[
      function(require,module,exports){
         ${mod.code}
      },
      ${JSON.stringify(mod.mapping)}
    ],`
  })

  // require,module,exports是cjs的标准不能再浏览器中直接使用,所以这里模拟common.js的模块加载,执行,导出操作
  const result = `
  (function(modules){
    // 创建一个require函数,它接受一个模块ID,它会根据模块ID寻找对应的模块
    function require(id){
      const [fn,mapping] = modules[id]
      function localRequire(relativePath){
        // 根据模块的路径在mapping中找到对应的模块id
        return require(mapping[relativePath])
      }
      const module = {exports:{}}
      //执行每个模块的代码
      fn(localRequire,module,module.exports)
    }
    // 执行入口文件
    require(0)
  })(${modules})
  `

  return result
}

const graph = createGraph('./entry.js')
const ret = bundle(graph)

fs.writeFileSync('./bundle.js', ret)

执行node webpack.js即可在当前目录下生成一个bundle.js,我们将其在在html页面中引入,在浏览器中打开控制台,即可看到hello-yeyeye。以上就是webpack处理我们代码的大致流程。

错误之处请多指正,遗漏之处欢迎补充