模块化打包工具-Webpack插件与其他功能

1.Webpack插件机制

上一篇提到的webpack的loader可以用来加载资源,包括各种css,图片文件资源,实现打包文件的功能,而webpack的插件则起到了加强webpack的作用,可以完成一些自动化的工作,比如自动清楚dist目录,自动生成html等等工作。有了插件的webpack基本可以实现绝大多数前端工程化的任务

2.常用插件举例与使用

自动清理目录输出-clean-webpack-plugin

对一个项目进行多次打包的时候,可能每一次打包的方式不同,产生的打包结果也不同,但是没有自动清理插件的话之前多次的打包结果文件会仍然留在dist文件夹下

比如此时,这里进行了2次打包,现在bundle.js才是需要的最后打包结果,而main.js只是之前的打包结果,需要手动删除。如果文件数量增多,手动删除不需要的文件将是十分复杂的,所以要一个自动清理目录输出的插件,在每一次执行打包命令的时候,先把dist目录清理一下,再去生成新的打包文件

安装插件

复制代码
npm i clean-webpack-plugin

使用插件

在webpack配置文件里引入clean-webpack-plugin插件,并且添加一个配置项plugins,值是一个数组,在数组里初始化一下CleanWebpackPlugin,然后再运行打包命令就可以看到,dist目录下只剩一个bundle.js文件了

复制代码
const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
    mode: 'none',
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                use: [
                    'style-loader',
                    'css-loader'
                ],
                test: /.css$/
            }
        ]
    },
    plugins:[
        new CleanWebpackPlugin()
    ]
}

运行打包命令后的结果

自动生成html的插件-html-webpack-plugin

默认情况下,打包的所有文件都不包括html文件,也就是说我们需要在项目根目录下的index.html里面引入所有dist目录下的内容,那么此时又存在2个模块化的老问题,多次打包后资源发生了改变忘记引入了怎么办?资源已经被删除了,但是还引入了怎么办?

也就是说,我们其实不希望直接去手动引入打包内容,而是希望webpack自动生成html,让它来帮我们处理引入的问题,这时候就需要用到自动生成html的插件html-webpack-plugin了。

安装插件

复制代码
npm i html-webpack-plugin

使用插件

在配置项plugins的数组后再加一个元素,同时改publicPath属性为空字符串,因为此时的html文件已经生成到dist目录下了,只需要指定打包后的结果的在网站根目录下即可

复制代码
const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
    publicPath: ''
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      },
      {
        test: /.png$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10 * 1024 // 10 KB
          }
        }
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin()
  ]
}

其他配置

使用默认配置生成的index.html只有一个空的模板,而我们开发的时候可能需要加入一些meta标签和修改一下网站的title,可能还需要加入一些特定的dom元素,除此之外可能不止输出一个页面,而是要输出多个html页面,这些事情都可以通过这个插件的配置项去完成

复制代码
  plugins: [
    new CleanWebpackPlugin(),
    // 用于生成 index.html
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample', //改变生成的html的title
      meta: {
        viewport: 'width=device-width' //增加视口相关标签
      },
      template: './src/index.html' // html使用模板来生成,里面有对应的特殊dom结构
    }),
    // 用于生成 about.html
    new HtmlWebpackPlugin({
      filename: 'about.html'
    })
  ]

拷贝静态资源的插件-copy-webpack-plugin

打包后的网站仍然需要一些静态资源,比如一些静态的图片或者网站的favicon.ico图标等,这些资源也需要跟着打包到dist文件夹才能正常使用。所以这里要用到拷贝静态资源的插件了。

安装插件

复制代码
npm i copy-webpack-plugin

使用插件

正确引入之后,在plugins配置项下创建一个对象,传的参数是一个数组,数组中指定了要拷贝的路径,比如这里要复制项目根目录下的public文件夹下的资源,就写路径public

复制代码
  plugins: [
    new CleanWebpackPlugin(),
    // 用于生成 index.html
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample',
      meta: {
        viewport: 'width=device-width'
      },
      template: './src/index.html'
    }),
    // 用于生成 about.html
    new HtmlWebpackPlugin({
      filename: 'about.html'
    }),
    new CopyWebpackPlugin([
      // 'public/**'
      'public'
    ])
  ]

3.Webpack其他功能

3.1 自动编译

之前每次修改代码之后都需要手动运行打包命令然后再查看效果,可以说是开发体验很不好,所以需要一个自动编译的功能,webpack已经提供了对应的指令。在运行打包命令的时候带上--watch就可以启动自动监视功能,当你的源文件发生修改时就会自动重新运行打包功能。

复制代码
npx webpack --watch

3.2 Webpack Dev Server

拥有了自动编译功能,但是每次更新完代码后仍然需要手动点击浏览器的刷新才能看到最新的效果,这里希望可以让浏览器根据打包结果自动刷新。webpack-dev-server就可以完成这个工作。

复制代码
npm i webpack-dev-server

有了这个包之后,直接使用命令即可完成自动编译,浏览器自动刷新了,加入--open参数可以让自动用浏览器打开打包后的结果

复制代码
npx webpack-dev-server --open

为了加快打包的效率,webpack-dev-server没有把打包结果直接写入磁盘,所以看不到有dist文件,而是把打包结果放在了内存里面

静态资源的访问

webpack-dev-server可以访问到所有通过webpack输出的资源,但是其他的需要使用的静态资源就需要通过配置来告诉server去哪里寻找文件。

例如public文件夹下有一些静态资源,例如网站图标和静态图片就需要通过devServer下的contentBase配置项来设置,这个配置项可以传一个数组或者传一个字符串,表示需要访问的静态资源的路径。这个功能和之前的copy-webpack-plugin插件的功能是相同的,但是在开发过程中一般只有最后上线前才会使用copy-webpack-plugin插件去完成打包,因为开发中会频繁执行打包任务,如果需要拷贝的文件比较大,那么每次使用这个插件去拷贝的开销也会较大,打包速度会降低

复制代码
module.exports = {
    mode: 'none',
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    devServer: {
        contentBase: './public'
    }
}

代理服务器

在开发过程中可能遇到一些跨域的问题,这个时候后端又没有配置CORS,就需要通过代理服务器来解决,此时需要创建一个与客户端同源的服务器,让同源服务器去请求后端。

配置里需要设置目标地址,需要重写的路径和允许修改请求的主机名

复制代码
  devServer: {
    contentBase: './public',
    proxy: {
      '/api': {
        // http://localhost:8080/api/users -> https://api.github.com/api/users
        target: 'https://api.github.com',
        // http://localhost:8080/api/users -> https://api.github.com/users
        pathRewrite: {
          '^/api': ''
        },
        // 不能使用 localhost:8080 作为请求 GitHub 的主机名
        changeOrigin: true
      }
    }
  }

3.3 Source Map

在开发过程中,一般避免不了出现报错,报错和调试的时候也需要查看源代码和出现错误的位置,但是问题是浏览器的报错和调试只会显示运行代码(打包后的代码)的错误位置,而不能显示源代码的位置,这就让定位错误变得非常的困难。source map则可以帮助找到源代码与运行代码之间的映射,找到源代码中出错的位置。

配置source map,添加devtool属性,并且选择要生成的source map类型

复制代码
module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist')
  },
  devtool: 'source-map'
}

使用了source map之后可以在浏览器里直接定位到错误位置,此时使用的值是source-map,所以还会产生.map文件

devtool可以选其他的值,所生成的source map都不同,生成的效率和最终效果也不一样,例如将这个值改成eval,就会通过eval的方式来实现source map。但是eval方式只会定位到出错的文件,无法定位到具体的某一行某一列,也不会产生.map文件,所以打包速度肯定是比前者更快的。

对比不同的devtool

将module.exports的值改成一个数组,就可以在一次打包时执行多个不同的打包任务。这里让不同的source-map去打包相同的文件,文件里有一个小错误,观察最后的效果

复制代码
const HtmlWebpackPlugin = require('html-webpack-plugin')

const allModes = [
    'eval',
    'cheap-eval-source-map',
    'cheap-module-eval-source-map',
    'eval-source-map',
    'cheap-source-map',
    'cheap-module-source-map',
    'inline-cheap-source-map',
    'inline-cheap-module-source-map',
    'source-map',
    'inline-source-map',
    'hidden-source-map',
    'nosources-source-map'
]

module.exports = allModes.map(item => {
    return {
        devtool: item,
        mode: 'none',
        entry: './src/main.js',
        output: {
            filename: `js/${item}.js`
        },
        module: {
            rules: [
                {
                    test: /\.js$/,
                    use: {
                        loader: 'babel-loader',
                        options: {
                            presets: ['@babel/preset-env']
                        }
                    }
                }
            ]
        },
        plugins: [
            new HtmlWebpackPlugin({
                filename: `${item}.html`
            })
        ]
    }
})

eval,只能定位到错误文件,没有生成source map文件

eval-source-map,能定位到错误的行和列,有source-map文件

cheap-eval-source-map,只能定位到错误的行,有source-map文件,定位到的是经过es6转换后的代码

cheap-module-eval-source-map,与cheap-eval-source-map相似,但是定位到的是源代码(下图可以看到var和const的差别)

通过以上几个值和结果,可以从值的名字总结出

eval- 是否使用eval 执行模块代码
cheap - Source Map 是否只包含行信息
module- 是否能够得到 Loader 处理之前的源代码

开发时常用的取值:cheap-module-eval-source-map。因为一般来说一行的长度不会特别长,定位到行即可方便查找错误,同时es6转换后的代码相比源代码差异较大,所以最好看源代码,同时在开发的时候因为频繁打包更关注再次打包的速度,这种方式的rebuild速度又是最快的(见下图)

不同的devtool取值的总结

3.4 Hot Module Replacement(热模块替换)

在使用webpack-dev-server开发的过程中,仍然会有问题,因为修改文件后再次自动打包会刷新页面,也就是会清除页面的状态,但其实我们的修改可能只是一小部分,这样每次都丢失页面状态(尤其是页面上有输入文字的时候)还是很麻烦,所以我们需要一个热模块替换的功能,只是去实时地替换修改过的地方,而不去刷新整个页面。

webpack-dev-server自带了这个功能,只需要在运行命令的时候加上--hot即可开启热模块替换

复制代码
npx webpack-dev-server --hot

如果不想改命令,也可以在webpack配置文件里做出相应配置,在devServer下加入hot配置,在插件配置项下载入webpack内置的HMR插件

复制代码
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development',
  entry: './src/main.js',
  output: {
    filename: 'js/bundle.js'
  },
  devtool: 'source-map',
  devServer: {
    hot: true
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      },
      {
        test: /\.(png|jpe?g|gif)$/,
        use: 'file-loader'
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'Webpack Tutorial',
      template: './src/index.html'
    }),
    new webpack.HotModuleReplacementPlugin()
  ]
}

手动处理热模块替换逻辑

webpack中的HMR是不会自动开启的,这个必须要手动编写处理逻辑,尤其是js文件,一般都需要自己来编写替换的逻辑,因为js文件每一次导出的内容都是不同的,可能是导出一个普通的变量,也有可能是导出一个函数或者导出一个对象,这样就无法用一个通用的方式去处理所有的情况。

使用HMR 插件提供的API可以处理热模块替换的逻辑,使用module.hot.accept()方法来设置热替换后所需要做的事情,这个方法一共要传2个参数:

  1. 第一个参数是一个字符串,表示需要热替换的模块的路径
  2. 第二个参数是一个回调函数,回调函数里表示热替换的逻辑

举例,现在希望在替换图片文件的时候自动完成网页上的更新,只要直接在回调函数里设置图片元素的src即可

复制代码
import background from './better.png'
const img = new Image()
img.src = background
document.body.appendChild(img)  
module.hot.accept('./better.png', () => {
    img.src = background
    
  })

举例,对于一个js文件,希望可以在发生更改的时候保留输入框的值,并且热更新

复制代码
  import createEditor from './editor'
  const editor = createEditor()
  document.body.appendChild(editor)
  let lastEditor = editor
  module.hot.accept('./editor', () => {
    const value = lastEditor.innerHTML
    document.body.removeChild(lastEditor)
    const newEditor = createEditor()
    newEditor.innerHTML = value
    document.body.appendChild(newEditor)
    lastEditor = newEditor
  })

每一次处理的情况都是不同的,因此必须自己手动处理更新逻辑。当然在更新css文件的时候,style-loader已经写过了热更新的逻辑,只需要引入对应插件开启热更新即可体验到HMR的功能了。而现在一些现成的脚手架也都已经内置了这样的功能(因为在脚手架里都是按照约定好的规则写代码的)

这样写HMR逻辑仍有问题,比如在HMR处理逻辑里写错了东西,处理HMR的代码报错会导致浏览器自动刷新,这样直接就看不到报错信息了。处理办法是在webpack的配置文件里把devServer配置项下的hot改成hotOnly,这样它就不会自动回退到自动刷新,起码可以看到错误的信息

复制代码
  devServer: {
    // hot: true
    hotOnly: true // 只使用 HMR,不会 fallback 到 live reloading
  }

3.5 生产环境优化

之前的用法和特性都注重了提高开发的效率,然而这对于生产环境可能是不利的,因为一些插件和多余的代码会降低运行的效率,比如我们在生产环境的时候就不需要使用热模块替换,不需要处理source map。所以需要对生产环境进行单独的配置。

分别的配置打包文件

生产模式和开发模式注重的东西不同,打包的需求自然也不同,所以需要对生产模式和开发模式分别配置打包文件

这里直接分3个文件,一个公共的配置文件,一个专用于开发,一个专用于生产

公共配置

复制代码
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'js/bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      },
      {
        test: /\.(png|jpe?g|gif)$/,
        use: {
          loader: 'file-loader',
          options: {
            outputPath: 'img',
            name: '[name].[ext]'
          }
        }
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'Webpack Tutorial',
      template: './src/index.html'
    })
  ]
}

生产环境的配置,这里使用webpack-merge这个库来实现公共部分和生产环境部分的合并

注:不能直接使用Object.assign方法,因为Object.assign方法会对相同的键进行值的覆盖(common里的plugins部分直接被prod的完全覆盖),这里我们想实现的效果是在common的plugins数组里再加入一些新的配置

复制代码
const merge = require('webpack-merge')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const common = require('./webpack.common')

module.exports = merge(common, {
  mode: 'production',
  plugins: [
    new CleanWebpackPlugin(),
    new CopyWebpackPlugin(['public'])
  ]
})

此时已经没有默认的webpack配置文件(webpack.config.js)了,运行生产环境打包需要加入--config参数

复制代码
npx webpack --config webpack.prod.js

Tree shaking

对于一些没有被引用的,无效的代码,在webpack生产环境下会被自动删除,提高运行效率,这也就是tree shaking功能。Tree shaking会在生产环境打包的时候自动开启,不需要进行配置。

在非生产模式下实现Tree shaking功能

这里只有Button被引用了,其他2个组件都没被引用,普通的开发模式下,这2个未被引用的组件仍会被写在bundle.js中

修改配置文件,增加Tree shaking相关功能配置,加入optimization配置项,其中usedExports会让打包的结果中只导出被使用到的成员(可以看作标记枯树叶),而minimize则会压缩代码,压缩的过程中也会把没用到的内容压缩掉(可以看作摇树),concatenateModules会把多个模块的代码合并在一个函数中(正常情况下是一个模块对应打包后的一个函数,如果模块过多,打包后的函数也会很多)

复制代码
module.exports = {
  mode: 'none',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  },
  optimization: {
    // 模块只导出被使用的成员
    usedExports: true,
    // 尽可能合并每一个模块到一个函数中
    concatenateModules: true,
    // 压缩输出结果
    minimize: true
  }
}

代码分割

当我们项目比较庞大的时候,打包内容也会很庞大并且只有一个文件,但是我们在一开始启动网页的时候不需要所有的内容,所以可以使用代码分割,提升运行效率

多入口打包

为了实现可以使用代码分割,可以使用多入口打包,比如一个页面对应一个打包文件,只要在配置文件里做一些修改即可。

  1. 将原来的单入口的entry变成一个对象,指定打包名和入口文件路径
  2. 在file那么处使用[name]的占位符来替换文件名
  3. 设置optimization里的splictChunks为'all'提取所有打包文件里的公共代码
  4. 对htmlWebpackPlugin加入chunks属性,指定要导入的模块(不指定的话默认会导入所有打包模块)
复制代码
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'none',
  entry: {
    index: './src/index.js',
    album: './src/album.js'
  },
  output: {
    filename: '[name].bundle.js'
  },
  optimization: {
    splitChunks: {
      // 自动提取所有公共模块到单独 bundle
      chunks: 'all'
    }
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/index.html',
      filename: 'index.html',
      chunks: ['index']
    }),
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/album.html',
      filename: 'album.html',
      chunks: ['album']
    })
  ]
}

动态导入

对于组件和网页,有的时候不需要一次性全部加载,当用户点击切换进入新组件的时候再加载即可。这时候就需要用到ES6提供的impot函数,import函数返回一个promise,通过then方法可以获取到整个模块的module对象,将posts和album通过default的方式解构出来,并且渲染即可实现动态导入// import posts from './posts/posts' 普通的导入

复制代码
// import album from './album/album'

const render = () => {
  const hash = window.location.hash || '#posts'

  const mainElement = document.querySelector('.main')

  mainElement.innerHTML = ''

  if (hash === '#posts') {
    //动态导入
    // mainElement.appendChild(posts())
  //魔法注释,可以给打包出来的文件命名

    import(/* webpackChunkName: 'components' */'./posts/posts').then(({ default: posts }) => {
      mainElement.appendChild(posts())
    })
  } else if (hash === '#album') {
    // mainElement.appendChild(album())
    import(/* webpackChunkName: 'components' */'./album/album').then(({ default: album }) => {
      mainElement.appendChild(album())
    })
  }
}

render()

window.addEventListener('hashchange', render)