Webpack 初识

这篇文章给学校组织上课的一部分,同时也是为了帮我补充以前没注意的问题。

Webpack 是一个现代 JavaScript 应用程序的静态模块打包器 (module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图 (dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle

Webpack思想:一切皆模块

所有项目中使用到的依赖文件都被视为模块,webpack 做的就是把这些模块进行处理,进行一系列的转换、压缩、合成、混淆操作,把项目文件打包成最原始的静态资源。

🏄 简单体验

创建目录结构

mkdir webpack-demo
cd webpack-demo

npm init -y

mkdir dist
mkdir src

在 dist 目录下创建 index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<script src="main.js"></script>
<body>
    
</body>

</html>

在 src 目录下创建index.js

function timeout(time) {
  return new Promise(resolve => {
    setTimeout(resolve, time)
  })
}

async function asyncPrint(value, time) {
  await timeout(time)
  console.log(value)
}

asyncPrint('hello,world', 5000)

安装 webpack

在这之前你要安装好 nodejs,并保证她是 最新的版本 然后,进行以下步骤以保证你能位于中国pc 机上顺利的安装  npm 包。

  1. 设定中国镜像

    npm install -g mirror-config-china --registry=http://registry.npm.taobao.org
    # 检查是否安装成功 
    npm config list
  2. 安装 Windows build 环境(npm 上的有些包是 native 的,需要编译)

    npm install -g windows-build-tools
//全局安装
npm install webpack webpack-cli -g
// **推荐**  局部安装,写入开发环境依赖
npm install webpack webpack-cli -D

执行webpack

# 以下代码生效基于 webpack4 零配置,只用于演示

# 如果为局部安装
npx webpack
# 使用 npx 命令,相当于他会在当前目录的 node_modules 的 .bin 目录下执行命令
# 此处未加 mode 选项,应该会有 warning,默认 mode 为production

# 增加 mode 后
npx webpack --mode development

webpack 会默认 src/index.js 文件为入口,dist/main.js 为出口打包

Hash: ce7e1fea469219fad208 // 本次打包对应唯一一个hash值 
Version: webpack 4.42.1 // 本次打包对应webpack版本
Time: 69ms // 消耗的时间
Built at: 2020-03-27 11:25:08 // 构建的时刻
  Asset      Size  Chunks             Chunk Names // 打包后的文件名,大小,分包的 id,入口文件名
main.js  1.02 KiB       0  [emitted]  main  
Entrypoint main = main.js
[0] ./src/index.js 245 bytes {0} [built]

🎯 正式开始

webpack4 拥有一些默认配置但是他并不是以零配置作为宣传口号,如果喜欢零配置使用的话,可以去看 parceljs,或者 ncc pkg(nodejs),而且对于不同的项目,我们往往需要高度的可定制性,这时候就需要我们自己写配置文件。 在项目目录下创建 webpack.config.js (默认配置文件地址), 可以通过 --config <文件> 显式指定

//常用配置模块
module.exports = {
    entry: '',               // 入口文件
    output: {},	             // 出口文件
    devtool: '',             // 错误映射 (source-map)
    mode: '',                // 模式配置 (production / development )
    module: {},              // 处理对应模块 (对文件做些处理)
    plugins: [],             // 插件 (压缩/做其他事情的)
    devServer: {},           // 开发服务器配置
    optimization: {},        // 压缩和模块分离
    resolve: {},             // 模块如何解析,路径别名
}

👉 入口 (entry) 与出口 (output)

单入口单出口

以下就是 webpack 的默认配置

const path = require('path');

module.exports = {
	entry: './src/index.js',
	output: {
		path: path.resolve(__dirname, 'dist'),
    filename: 'main.js'
	}
};

写好配置文件后再次

npx webpack

效果不变借用 npm script,在 package.json 中 scripts 属性增加 script 如下

{
	...
    "scripts": {
        "dev": "webpack --mode development -w",
        "build": "webpack --mode production"
    }
    ...
}
  • npm run dev -> webpack --mode development -w
  • npm run build -> webpack --mode production

多入口与多出口

  • 多入口多出口:多页面应用(MPA),打包多个js文件,不同页面分别引入。
  • 单入口多出口:单页面应用(SPA),借助内置 splitChunksPlugins 模块进行代码分割,方便分离公共模块,

🛠 模式(mode)

module.exports = {
	...
    mode: 'development' || 'production'
    ...
}

mode 写入配置文件后,执行 webpack 时就不用再带 mode 选项

  • development 开发模式,即写代码的时候,在此模式下,为了提高开发效率,我们需要 提高编译速度,配置热更新和跨域,以及快速debug
  • production 生产模式,即项目上线后,在此模式下,我们要 打包一份可部署代码,需要对代码进行压缩,拆分公共代码以及第三方js库

理解这两种模式容易,关键是根据不同的模式对 webpack 做不同的配置,因为不同模式下我们对代码的需求不一样。 开发项目时,通常会写两套不同的配置,一套用于开发环境,一套用于生产环境,两套不同配置包括三个配置文件,分别为

  • 基础配置文件 webpack.config.js(包含开发与生产环境下都需要的配置)
  • 开发环境配置文件 webpack.dev.js
  • 生产环境配置文件 webpack.prod.js

relationship

以基础配置文件为入口,根据环境变量判断当前环境,使用 webpack-merge 插件融合相应环境配置文件。**

npm install -D webpack-merge
//webpack.config.js
const path = require('path')
const merge = require('webpack-merge')
const devConfig = require('./webpack.dev.js')
const prodConfig = require('./webpack.prod.js')

const commonConfig = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
  },
}

module.exports = env => {
  if (env && env.production) {
    return merge(commonConfig, prodConfig)
  } else {
    return merge(commonConfig, devConfig)
  }
}

// webpack.dev.js
module.exports = {
	mode: 'development',
	output: {
		filename: '[name].js',
	},
};

// webpack.prod.js
module.exports = {
  mode: 'production',
  output: {
    filename: '[name].[contenthash].js',
  },
}

对 scripts 字段改写如下 因为我需要经常修改配置文件, 我们需要监控文件修改,然后重启 webpack,所以需要先安装 nodemon

npm install -D nodemon
{
    "scripts": {
        "dev": "nodemon --watch webpack.*.js --exec \"webpack -w\"",
        "build": "webpack --env.production"
    }
}

🗺 devtool(错误映射)(source-map)

devtool 构建速度 重新构建速度 生产环境 品质(quality)
(none) +++ +++ yes 打包后的代码
eval +++ +++ no 生成后的代码
cheap-eval-source-map + ++ no 转换过的代码(仅限行)
cheap-module-eval-source-map o ++ no 原始源代码(仅限行)
eval-source-map -- + no 原始源代码
cheap-source-map + o yes 转换过的代码(仅限行)
cheap-module-source-map o - yes 原始源代码(仅限行)
inline-cheap-source-map + o no 转换过的代码(仅限行)
inline-cheap-module-source-map o - no 原始源代码(仅限行)
source-map -- -- yes 原始源代码
inline-source-map -- -- no 原始源代码
hidden-source-map -- -- yes 原始源代码
nosources-source-map -- -- yes 无源代码内容

在 webpack.dev.js 和 webpack.prod.js 中分别加入 source-map

//webpack.dev.js
module.exports = {
  mode: 'development',
  devtool: 'source-map',
  output: {
      filename: '[name].js',
  }
}

//webpack.prod.js
module.exports = {
    mode: 'production',
    devtool: 'cheap-module-source-map',
    output: {
        filename: '[name].[contenthash].js'
    }
}

📫 plugins(插件)

插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量。插件接口功能极其强大,可以用来处理各种各样的任务。想要使用一个插件,你只需要 require() 它,然后把它添加到 plugins 数组中。多数插件可以通过选项 (option) 自定义, 有些插件你也可以在 一个单独配置文件 中进行配置。 需要通过使用 new 操作符来创建它的一个实例。 在我看来,plugins的主要作用有:

  • 让打包过程更便捷 (提供一些帮助,如自动生成入口的 html 文件)
  • 开发环境对打包进行优化,加快打包速度 
  • 生产环境压缩代码

两个简单插件示例

  • html-webpack-plugin 这个插件可以在打包完成后自动生成index.html文件,并将打包生成的 js、css 文件引入。

    npm install html-webpack-plugin -D
  • 新建 public 文件夹并创建 index.html 作为模板文件 在 webpack.config.js 中

    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const commonConfig = {
      plugins: [
          new HtmlWepackPlugin({
            template: 'public/index.html',
          }),
      ]
    }
  • clean-webpack-plugin 自动清除上次打包生成的 dist 文件

    npm install clean-webpack-plugin -D
  • 在 webpack.prod.js 中

    const { CleanWebpackPlugin } = require('clean-webpack-plugin');
    module.exports = {
    mode: 'production',
    ...
    plugins: [new CleanWebpackPlugin()],
    }

📜 module (loader)(文件预处理)

loader 用于对模块的源代码进行转换。loader 可以使你在 import 或"加载"模块时预处理文件。因此,loader 类似于其他构建工具中“任务(task)”,并提供了处理前端构建步骤的强大方法。loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript 或将内联图像转换为 data URL。loader 甚至允许你直接在 JavaScript 模块中 import CSS文件!(官方) 在我看来,loader 的主要作用有:

  • 处理图片、字体等资源
  • 处理 css、预编译 sass/less/stylus
  • 把 ES6+ 代码转义为 ES5

下面会引入 css、图片依赖,为使目录结构清晰,分别打包进单独文件夹 加载CSS

npm install css-loader style-loader mini-css-extract-plugin -D

在配置文件中加入 loader 注意:

  • use字段下如果有多个 loader,从后至前依次执行
  • 开发环境下使用 css-loader 和 style-loader 会把 CSS 写进 JS,然后 JS 添加样式,写在内联 style 里
  • 生产环境下借助 webpack4 的 mini-css-extract-plugin 把CSS文件单独分离,link 引入
//webpack.dev.js
module.exports = {
  mode: 'development',
  devtool: 'cheap-module-eval-source-map',
  output: {
    filename: 'js/[name].js',
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
}

// webpack.prod.js
...
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = {
  mode: 'production',
  ...
  output: {
    filename: 'js/[name].[contenthash].js', // 这里修改成 js 文件夹下面
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              publicPath: '../',
            }, // 文件打包至dist/css目录下,需配置 publicPath,以防等会引入图片出错
          },
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[hash:8].css', // css 样式打包到 css 文件夹下面
    }),
    ...
  ],
}

处理 less

npm install less-loader less -D
// webpack.prod.js
...

module.exports = {
 	... 
  module: {
    rules: [
      {
        test: /\.less$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              publicPath: '../',
            }, // 文件打包至dist/css目录下,需配置publicPath,以防等会引入图片出错
          },
          'css-loader',
          'less-loader',
        ]
      }
    ],
  },
  ...
}

// webpack.dev.js
module.exports = {
	...
  module: {
    rules: [
      {
        test: /\.less$/,
        use: ['style-loader', 'css-loader', 'less-loader'],
      }
    ],
  },
}

打包图片

在CSS等文件引入图片

npm install file-loader url-loader -D

在 webpack.config.js 中

...
const commonConfig = {
	...
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif|svg)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
              outputPath: 'images/',
            },
          },
        ],
      },
    ],
  },
 	....
}
...

url-loader 配合 file-loader,在 options 中限制添加 limit 可以把指定大小图片编码成 base64,减少网络请求。 babel —— 转义ES6+代码 babel 默认只转换语法, 而不转换新的API, 如需使用新的API, 还需要使用对应的转换插件,例如,默认情况下babel可以将箭头函数,class 等语法转换为 ES5 兼容的形式,但是却不能转换 Map,Set,Promise等新的全局对象,这时候就需要使用 polyfill 去模拟这些新特性。

# babel 核心
npm install babel-loader @babel/core -D

# @babel/plugin-transform-runtime 这个会创建一些辅助函数,以防污染全局
# @babel/plugin-transform-regenerator async 转换
# @babel/runtime-corejs3 corejs 是一个 polyfill
npm install @babel/plugin-transform-runtime @babel/runtime-corejs3 @babel/plugin-transform-regenerator -D

在 webpack.config.js 中

...

const commonConfig = {
		...
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: '/node_modules/',
                use: 'babel-loader',
            },
            ...
    },
  	...
}

...

在项目目录下新建 .babelrc.json  文件,写入options这里只是为了演示方便而写的配置,并不符合实际,一般网页项目都和结合 @babel/preset-env 和 .browserslistrc  来使用,如果想看他们的区别请看 https://segmentfault.com/a/1190000021188054 写的比较清楚。

// .babelrc.json

{
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": 3,
        "proposals": true,
        "useESModules": true
      }
    ],
    ["@babel/plugin-transform-regenerator"]
  ]
}

🏗 devServer

npm install webpack-dev-server -D

每次编写完代码后,都要重新 npm run dev,为了提高开发效率,我们借助 webpack-dev-server 配置本地开发服务,主要字段如下:

// webpack.config.js

{
  	...
    devServer: {
        contentBase: './dist',    //配置开发服务运行时的文件根目录
        port: 3000,       //端口
        hot: true, //是否启用热更新
        open: false, //是否自动打开浏览器
    },
    ...
}

借助devServer,我们可以

  • 方便的跑起本地服务而不用自己再去做资源处理
  • 开发时借助代理实现跨域请求
  • 开箱即用的 HMR(模块热更新)(需要 loader 支持,或者自己编写)
"scripts": {
    "dev": "nodemon --watch webpack.*.js --exec \"webpack-dev-server\"",
    "build": "webpack --env.production"
  },
// webpack.dev.js

const webpack = require('webpack');

...
plugins: [
    new webpack.HotModuleReplacementPlugin()
]
...
// ./src/test.js

export default () => {
    console.log(1)
}

// ./src/index.js

import test from './test';
...

if (module.hot) {
  module.hot.accept('./test.js', function() {
    test();
  })
}

test();

...

📦 原理

下面是怎么自己实现一个类似于 webpack 的打包工具

// ./bundler.js


// nodejs 文件处理
const fs = require('fs');
// nodejs 文件路径
const path = require('path');
// 生成 ast 的库
const parser = require('@babel/parser');
// 遍历 ast
const traver = require('@babel/traverse').default;
// babel es6 -> es5
const babel = require('@babel/core');

/**
 * 生成转义后的代码以及依赖关系
 * @param filePath
 * @returns {{code: string, filePath: string, dependencies: {}}}
 */
const moduleAnalyser = filePath => {
	// 拿到文件内容
	const content = fs.readFileSync(filePath, 'utf-8');
	// 生成 ast
	const ast = parser.parse(content, {
		// 使用 es module
		sourceType: 'module',
	});
	// 建立一个对象来接遍历的依赖
	// key: relativePath -> 也就是 import xx from '<relativePath>'
	// value: 唯一的绝对路径
	const dependencies = {};
	// 遍历 ast
	traver(ast, {
		ImportDeclaration ({ node }) {
			const relativePath = node.source.value;
			dependencies[relativePath] = path.join(path.dirname(filePath) + relativePath.slice(1));
		}
	});
	// 转义之后的代码
	const { code } = babel.transformSync(content, {
		presets: ['@babel/preset-env']
	});
	return {
		filePath,
		dependencies,
		code,
	};
};

/**
 * 生成依赖关系图
 * @param entry
 * @returns {{
 *   dependencies: {}
 *   code: string
 * }}
 */
const makeDependenciesGraph = entry => {
	// 入口模块
	const entryModule = moduleAnalyser(entry);
	// 关系图
	const graphArray = [entryModule];
	for (let i = 0; i < graphArray.length; i++) {
		// 拿到本次的 dependencies
		const { dependencies } = graphArray[i];
		if (dependencies) {
			// 遍历 dependencies,推到关系图中
			for (const j in dependencies) {
				graphArray.push(moduleAnalyser(dependencies[j]));
			}
		}
	}
	const graph = {};
	// 转换结构成对象
	// key: 绝对路径
	// value: dependencies, code
	graphArray.forEach(item => {
		graph[item.filePath] = {
			dependencies: item.dependencies,
			code: item.code
		};
	});
	return graph;
};

/**
 * 生成代码
 * @param entry
 * @returns {string}
 */
const generatorCode = entry => {
	const graph = makeDependenciesGraph(entry);
	console.log(graph);
	return `
	(function(graph){
		function require(module) {
			function localRequire(relativePath){
				return require(graph[module].dependencies[relativePath])
			}
			var exports = {};
			(function(require, code, exports){
				eval(code)
			})(localRequire, graph[module].code, exports);
			return exports;
		};
		require(${JSON.stringify(entry)});
	})(${JSON.stringify(graph)});
	`
};

console.log(generatorCode(path.resolve(__dirname, 'src', 'index.js')));
// src/index.js
import message from './message.js';

console.log(message);

// src/message.js
import { word } from './word.js';

const message = `hello ${word}`;

export default message;

// src/word.js
export const word = 'world';

参考 https://juejin.im/post/5cb0948ce51d456e6154b3f4