从零到一搭建 react 项目系列之(十四)
前面的文章介绍了 Webpack、HMR、React、Redux、ESLint、Prettier 等内容。
但其实 Webpack 4 部分内容是没有比较详细的讲述的,那这篇文章就来介绍它吧。
编写本文的时候,最新版本是 webpack 5.1.3
。而本文要介绍的时候 webpack 4.x
相关接口。
提供两个链接:
请注意本文所指 Webpack 中文文档由印记中文翻译。
一、前言
在此前的系列文章,多多少少都涉及到 webpack 的相关配置,主要有这几项。
今天就每一个知识点,尽可能地都详细介绍一下。
webpack 支持所有符合 ES5 标准 的浏览器(不支持 IE8 及以下版本)。webpack 的
import()
和require.ensure()
需要Promise
。如果你想要支持旧版本浏览器,在使用这些表达式之前,还需要提前加载 polyfill。
{
mode, // 模式
entry, // 入口
deServer, // 开发
optimization, // 优化
plugins, // 插件
resolve, // 解析
module // 模块
}
即使有些内容前面已经介绍过,这里还是再啰嗦简单介绍一下。
安装依赖包
不推荐全局安装 webpack。这会将你项目中的 webpack 锁定到指定版本,并且在使用不同的 webpack 版本的项目中,可能会导致构建失败。
$ yarn add --dev webpack@4.41.2
$ yarn add --dev webpack-cli@3.3.10
webpack 配置文件
webpack 开箱即用,可以无需使用任何配置文件。然而,默认情况下 webpack 会假定项目的入口起点为 src/index
,然后会在 dist/main.js
输出结果,并且在生产环境开启压缩和优化。
通常,我们的项目还需要继续扩展此能力,为此我们可以在项目根目录下创建一个 webpack.config.js
文件,webpack 会自动使用它。
Webpack 配置文件是标准的 Node.js CommonJS 模块(所以不能使用 ESM 标准导出),可以导出为 object、function 或 Promise,本项目将使用导出 object 的形式。虽然可行,但不建议通过 CLI 形式指定过多参数,会导致编写很长的脚本命令,推荐使用配置文件的形式。
使用自定义配置文件,则可通过 Webpack CLI 命令
--config
来指定。
// package.json
// 假定项目根目录下有两个配置文件 dev.config.js 和 prod.config.js,分别对应开发模式和生产模式的两种不同配置,这样我们就可以通过 --config 来指定了。
{
"script": {
"webpack:dev": "webpack --config dev.config.js --mode development",
"webpack:build": "webpack --config prod.config.js"
}
}
*附上一个 Webpack 配置文件选项详解。
*附上一个前端构建配置生成器 Create App。
常用 Webpack CLI 接口参数
需要注意的是,命令行接口参数的优先级是高于配置文件参数的。
参数 | 说明 | 默认值 |
---|---|---|
--config |
配置文件的路径 | webpack.config.js 或者 webpackfile.js |
--mode |
用到的模式,development 或 production | |
--hot |
开启模块热替换 | |
--progress |
打印出编译进度的百分比值 | false |
--debug |
将 loader 设置为 debug 模式 | false |
--color , --colors
|
强制在控制台开启颜色 | |
--watch , -w
|
观察文件系统的变化 | |
--env |
配置文件导出一个函数时,会将此环境变量传给该函数 |
二、Webpack API
1. 入口(entry)
项目的入口文件,从这个入口文件开始,应用程序启动执行。如果传递一个数组,那么数组的每一项都会执行。
不配置入口文件的情况下,Webpack 会默认取 src/index.js
作为启动文件。若不存在,则打包失败并报错:
ERROR in Entry module not found: Error: Can't resolve './src' in 'xxx'
// 仅举例说明,实际情况取其一,下同
module.exports = {
// 支持 string | array | object | function 形式,常用的是数组和对象的形式
// 字符串形式,chunk 被命名为 'main'
entry: 'string',
// 字符串数组形式,chunk 被命名为 'main'
entry: ['string'],
// 对象形式,每个 key 作为 chunk 名称
entry: {
main: 'string or array',
vendor: 'string or array'
},
// 动态入口(dynamic entry)可使用函数形式,比如从服务器获取等,我暂时未用过
entry: () => {
// 还可以返回 Promise。
return 'string | [string]'
}
}
2. 输出(output)
它包括了一组选项,指示 Webpack 如何去输出,以及在哪里输出你的 bundle、asset 和其他你所打包或者使用 Webpack 载入的任何内容。
注意整个配置中我们使用 Node 内置的 path 模块,并在它前面加上 __dirname 这个全局变量。可以防止不同操作系统之间的文件路径问题,并且可以使相对路径按照预期工作。我们在很多地方将会使用到它。
__dirname
指当前文件所在的目录
__filename
表示正在执行脚本的文件名需要注意的是,它是两个下划线,两者均返回一个绝对路径。
它的选项很多,主要介绍常用的几个:
- path
所有输出文件的目标路径。它是一个绝对路径,默认是项目根目录下的 dist
路径。
例如,打包后的 JS 文件、url-loader
解析的图片,html-webpack-plugin
生成的 HTML 文件等都会存放到该路径下(或相对于该路径的子目录)
若非绝对路径,它将会构建失败并报错:configuration.output.path: The provided value "xxx" is not an absolute path!
- publicPath
publicPath 并不会对生成文件的路径造成影响,主要是对你的页面里面引入的资源的路径做对应的补全,常见的就是 CSS 文件里面引入的图片。
其中某些 loader(例如 file-loader
) 的 publicPath
选项会覆盖掉 output.publicPath
的。
关于
path
和publicPath
很多人容易混淆,官方的描述我看起来是模糊的,所以下面我通俗地描述一下。通俗地讲,
path
就是打包文件存放在硬盘上的路径,它不会因为publicPath
的设置而改变。而
publicPath
会影响项目中引用的资源路径并重写。它只会修改项目中的相对路径和绝对路径,而完整的绝对路径将不受影响(例如 https://cdn.example.com/assets/ 这种形式不会被修改)。最常见的就是图片资源、打包产出的 JavaScript 文件在 HTML 中的引用路径等。这些文件的路径目录将被 publicPath 替换重写(除了文件名不变,其他被替换)。常被用来指定上线后的 cdn 域名。
- filename
此选项决定了每个(入口 chunk 文件)输出 bundle 的名称。这些 bundle 将写入到 output.path
选项指定的目录下。
注意,此选项不会影响那些「按需加载 chunk」的输出文件。对于这些文件,请使用
output.chunkFilename
选项来控制输出。通过 loader 创建的文件也不受影响。在这种情况下,你必须尝试 loader 特定的可用选项。
可以使用以下替换模板字符串:
模板 | 描述 |
---|---|
[hash] |
模块标识符(module identifier)的 hash
|
[chunkhash] |
chunk 内容的 hash
|
[name] |
模块名称(即入口文件名称),默认为 main
|
[id] |
模块标识符(module identifier) |
[query] |
模块的 query ,例如文件名 ? 后面的字符串 |
[function] |
The function, which can return filename [string] |
*[hash]
和 [chunkhash]
的长度可以使用 [hash:16]
(默认为 20)来指定。
*如果将这个选项设为一个函数,函数将返回一个包含上面表格中替换信息的对象。
*注意此选项被称为文件名,但是你还是可以使用像 js/[name]/bundle.js
这样的文件夹结构。
关于 Webpack 的
hash
、chunkhash
、contenthash
的区别,可以看下这篇文章。
- chunkFilename
此选项决定了非入口(non-entry)chunk 文件的名称。
注意,这些文件名需要在 runtime 根据 chunk 发送的请求去生成。因此,需要在 webpack runtime 输出 bundle 值时,将 chunk id 的值对应映射到占位符(如 [name]
和 [chunkhash]
)。这会增加文件大小,并且在任何 chunk 的占位符值修改后,都会使 bundle 失效。
默认使用 [id].js
或从 output.filename
中推断出的值([name]
会被预先替换为 [id]
或 [id].
),所以它的可读性很差。
默认 [id]
和 [name]
是一样的。
chunkFileName
不能灵活自定义,这谁能忍,于是便有了webpackChunkName
,可以看下这篇文章。
const path = require('path')
module.exports = {
output: {
// 指定打包输出路径为 dist,
// 它必须绝对路径,为了避免不同操作系统之间文件路径问题,这里借助 Node.js 内置的 path 模块以及 __dirname 全局变量
// __dirname 是两个下划线
path: path.resolve(__dirname, 'dist'),
// 它通常是以 '/' 结束,避免出现访问不到生成之后的静态资源的问题
// 实际场景,根据项目本身设置
publicPath: '',
// publicPath: 'https://cdn.example.com/assets/', // CDN(总是 HTTPS 协议)
// publicPath: '//cdn.example.com/assets/', // CDN(协议相同)
// publicPath: '/assets/', // 相对于服务(server-relative)
// publicPath: 'assets/', // 相对于 HTML 页面
// publicPath: '../assets/', // 相对于 HTML 页面
// publicPath: '', // 相对于 HTML 页面(目录相同),默认
// 入口文件输出 bundle 的名称
filename: 'bundle.js', // 静态名称
// filename: '[name].bundle.js', // 使用入口名称
// filename: 'js/[name].bundle.js', // 支持文件夹结构
// filename: '[id].bundle.js', // 使用内部 chunk id
// filename: '[name].[hash].bundle.js', // 使用每次构建过程中,唯一的 hash 生成
// filename: '[chunkhash].bundle.js', // 使用基于每个 chunk 内容的 hash
// filename: '[contenthash].bundle.css', // Using hashes generated for extracted content
// filename: (chunkData) => { // Using function to return the filename
// // 如果将这个选项设为一个函数,函数将返回一个包含上面表格中替换信息的对象。
// return chunkData.chunk.name === 'main' ? '[name].js' : '[name]/[name].js'
// },
// 非入口文件,但参与构建的 bundle
chunkFilename: '[chunkhash].bundle.js' // 可取的值与 filename 一致
}
}
一句话总结:
filename
指列在entry
中,打包后输出的文件的名称。
chunkFilename
指未列在entry
中,却又需要被打包出来的文件的名称。
3. 模块(module)
这些选项决定了如何处理项目中的不同类型的模块。
- noParse
它的作用是防止 webpack 解析那些任意与给定正则表达式项匹配的文件。因为它们被忽略了,所以不会被 Babel 等做语法转换以兼容低版本的浏览器,故它们不应该含有 import、require、define 的调用。
module.exports = {
module: {
// 支持 RegExp、[RegExp]、function(resource)、string、[string] 的形式
noParse: /jquery|loadsh/
// noParse: content => /jquery|lodash/.test(content)
}
}
- rules(重要)
创建模块时,匹配请求的规则数组。这些规则能够修改模块的创建方式。这些规则能够对模块(module)应用 loader,或者修改解析器(parser)。
module.rules
是数组形式,支持一个或多个规则,而每个规则(Rule
)可以分为三部分:条件(condition)、结果(result)、嵌套规则(nested rule)。
Rule 条件
条件有两种输入值:
1. resource:请求文件的绝对路径。(它已经根据 resolve 规则解析)
2. issuer:被请求资源的模块文件的绝对路径,它是导入时的路径。如果看起来有点懵,没关系,下面举例说明。
在规则中,resource 由属规则属性
test
、include
、exclude
、resource
对其进行匹配。而 issuer 则由规则属性issuer
对其进行匹配。
// 假如我们在入口文件 index.js 导入 app.css
import './styles/app.css?inline'
// webpack 匹配
module.exports = {
module: {
rules: [
{
test: /\.css$/,
exclude: /node_modules/,
use: info => {
// info 是正在加载模块的一些参数
// 包括 resource、issuer、realResource、compiler
console.log(info)
return ['style-loader', 'css-loader']
}
}
]
}
}
// info 打印结果如下:
{
resource: '/Users/frankie/Desktop/Web/Temp/temp_webpack/src/styles/app.css',
realResource: '/Users/frankie/Desktop/Web/Temp/temp_webpack/src/styles/app.css',
resourceQuery: '?inline',
issuer: '/Users/frankie/Desktop/Web/Temp/temp_webpack/src/index.js',
compiler: undefined
}
结合概念和例子,其实已经很清楚了。app.css
是我们的目标文件,而 index.js
则是导入目标文件的位置。因此,resource 就是目标文件的绝对路径,而 issuer 则是 index.js
的绝对路径。
Rule 结果
规则结果只有在规则条件匹配时使用。
规则有两种输入值:
1. 应用的 loader:应用在 resource 上的 loader 数组。
2. Parser 选项:用于为模块创建挤下去的选项对象。这些规则属性
loader
、options
、use
会影响 loader。(query
、loaders
也会影响,但它们也被废弃)
enforce
属性会影响 loader 种类。
parser
属性会影响 parser 选项。
不知道你们第一次看到上面这些概率描述,会不会有点发懵,反正我开始看的时候是会的。
接下来,介绍规则(Rule)的属性,先看下有哪些:
module.exports = {
module: {
rules: [
// Rule
{
resource: {
test,
include,
exclude
},
use: [
{
loader,
options
}
],
loaders, // 此选项已废弃,请使用 Rule.use
query, // 此选项已废弃,请使用 Rule.use.options
issuer,
enforce,
oneOf,
parser,
resourceQuery,
rules,
type,
sideEffects
},
{
// 可能你们看到更多是长这样的,但其实它们只是简写罢了。
// 后面添加配置,我可能使用简写多一些。
test, // Rule.resource.test 的简写
include, // Rule.resource.include 的简写
exclude, // Rule.resource.exclude 的简写
loader, // Rule.use: [ { loader } ] 的简写
options // Rule.use: [ { options } ] 的简写
}
]
}
}
(1) Rule.test、Rule.include、Rule.exclude
它们分别是 Rule.resource: { test, inclued, exclued }
的缩写。实际中,很多开发的朋友都说采用缩写的写法。
条件可以是这些之一:
- 字符串:匹配输入必须以提供的字符串开始。是的。目录绝对路径或文件绝对路径。
- 正则表达式:test 输入值。
- 函数:调用输入的函数,必须返回一个真值(truthy value)以匹配。
- 条件数组:至少一个匹配条件。
- 对象:匹配所有属性。每个属性都有一个定义行为。
test
:匹配特定条件。一般是提供一个正则表达式或正则表达式的数组,但这不是强制的。
include
:匹配特定条件。一般是提供一个字符串或者字符串数组,但这不是强制的。
exclude
:排除特定条件。一般是提供一个字符串或字符串数组,但这不是
强制的。
匹配条件每个选项都接收一个正则表达式或字符串。
test
和include
具有相同的作用,都是必须匹配选项。exclude
是必不匹配选项(优先于test
和include
)最佳实践:
- 只在
test
和文件名匹配
中使用正则表达式。- 在
include
和exclude
中使用绝对路径数组。- 尽量避免
exclude
,更倾向于使用include
。
(2) Rule.use
支持 UseEntries 和 function(info) 两种方式。
其中 UseEntry 是一个对象,要求必须有一个 loader 属性是字符串。
也可以有一个 options 属性为字符串或对象,其值可以传递到 loader 中,将其理解为 loader 选项。
由于兼容性原因,也有可能有 query 属性,它是 options 属性的别名。请使用 options 属性替代。
传递字符串(如:use: [ 'style-loader' ]
)是 loader 属性的简写方式(如:use: [ { loader: 'style-loader' } ]
)
它还可以传递多个 loader,但要注意 loader 的加载顺序是从右往左(从下往上)。
Rule.use 也可以是一个函数,该函数接收描述正在加载的模块的 object 参数,并且必须返回 UseEntry 项的数组。
该函数 function(info)
的参数 info
包含以下几个字段 { compiler, issuer, realResource, resource }
。
那这几个字段究竟是什么呢,其实上面讲述 Rule 条件的时候,就有打印出来,可以往上翻翻,或者看下官网的介绍。
关于此我不展开赘述,因为也不知道要利用它解决什么实际的场景问题,所以其实没用过。那说明我目前是不需要它的,使用 UseEntry 即可满足我的需求。
我在写 Redux 篇的时候,引用过一句话,用着这里也是同理的。
如果你不知道是否需要 Redux,那就是不需要它。
module.exports = {
module: {
rules: [
{
// ...
// 单个 loader,可以使用简写形式
loader: 'file-loader',
options: {
name: '[name].[ext]'
}
},
{
// ...
// 多个 loader,不含 options 简写形式
use: ['style-loader', 'css-loader'],
},
{
// ...
// 多个 loader,且含 options 简写形式
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1
}
},
{
loader: 'less-loader',
options: {
noIeCompat: true
}
}
]
}
]
}
}
(3) Rule.enforce
该属性指定 loader 种类,其值可以是 pre
或者 post
(字符串),没有值表示普通 loader。
所有一个接一个地进入的 loader,都有两个阶段:
1. Pitching 阶段:loader 上的 pitch 方法,按照 后置(post)
、行内(inline)
、普通(normal)
、前置(pre)
的顺序调用。更多详细信息,请查看 pitching loader。
2. Normal 阶段:loader 上的常规方法,按照 前置(pre)
、普通(normal)
、行内(inline)
、后置(post)
的顺序调用。模块源码的转换,发生在这个阶段。
所有普通 loader 可以通过在请求中加上 !
前缀来忽略(覆盖)。
所有普通和前置 loader 可以通过在请求中加上 -!
前缀来忽略(覆盖)。
所有普通,后置和前置 loader 可以通过在请求中加上 !!
前缀来忽略(覆盖)。
不应该使用
行内 loader
和!
前缀,因为它们是非标准的。PS:我没使用过行内 loader 的方式,也不太了解它这样做的目的是什么。设置成前置 loader 倒是用过,前面文章讲解
eslint-loader
与babel-loader
顺序先后问题用过。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: path.resolve(__dirname, 'node_modules'),
loader: 'babel-loader'
},
// 由于 eslint-loader 要于 babel-loader 之前执行,且 loader 执行顺序是从下往上执行的,所以 eslint-loader 要写在下面
// 但出于安全谨慎考虑,添加 enforce: 'pre' 属性,使其无论写在 babel-loader 前后都能优先执行。
{
test: /\.js$/,
enforce: 'pre',
exclude: path.resolve(__dirname, 'node_modules'),
loader: 'eslint-loader',
options: {
fix: true
cache: true
}
}
]
}
}
(4) 其他属性
- 请注意 Rule.loaders、Rule.query 属性已废弃,请分别使用 Rule.use、Rule.options 替代。
- 以下属性一般较少使用,这里不展开细说,可点击进一步了解。 Rule.issuer、Rule.oneOf、Rule.parser、Rule.resourceQuery、Rule.type、Rule.sideEffects。
4. 解析(resolve)
该选项用于配置模块如何解析。例如,当在 ES6 中调用 import 'lodash'
,resolve
选项能够对 webpack 查找 lodash
的方式去做修改。
这一块内容已在另外一篇文章详细介绍了,请移步至文章 Webpack 如何解析模块路径。
5. 模式(mode)
提供 mode
配置选项,告知 webpack 使用响应环境的内置优化。可选值有:none
、development
或 production
。
如果没有设置,mode
默认设置为 production
。可通过以下方式设定:
// webpack.config.js
module.exports = {
mode: 'production'
}
或者从 CLI 传递参数:
// package.json
{
"scripts": {
"build": "webpack --mode production"
}
}
development
它会将DefinePlugin
中的process.env.NODE_ENV
的值设置为development
。启用NamedChunksPlugin
和NamedModulesPlugin
。production
它会将DefinePlugin
中的process.env.NODE_ENV
的值设置为production
。启用FlagDependencyUsagePlugin
、FlagIncludedChunksPlugin
、ModuleConcatenationPlugin
、NoEmitOnErrorsPlugin
、OccurrenceOrderPlugin
、SideEffectsFlagPlugin
、TerserPlugin
。none
它会退出任何默认优化选项。注意,设置了
NODE_ENV
并不会自动地设置mode
。
6. devtool
此选项控制是否生成,以及如何生成 Source Map。不同的值会明显影响到构建(build)和重新构建(rebuild)的速度。
建议:开发环境使用
eval-cheap-module-source-map
,而生产环境多数只需要知道报错的模块和行号就可以了,所以使用的是nosources-source-map
。
你可以直接使用 SourceMapDevToolPlugin
/EvalSourceMapDevToolPlugin
来替代使用 devtool
选项,因为它有更多的选项。切勿同时使用 devtool
选项和 SourceMapDevToolPlugin
/EvalSourceMapDevToolPlugin
插件。devtool
选项在内部添加过这些插件,所以你最终将应用两次插件。
- Devtool 可选值有很多,看这里:Webpack Devtool。
- 了解 Source Map 请看这篇文章:一文彻底搞懂 Webpack Devtool。
7. 插件(plugins)
该选项用于已各种方式自定义 webpack 构建过程。webpack 附带了各种内置的插件,可以通过 webpack.[plugin-name]
访问这些插件。
可以查看插件页面获取插件列表和对应的文档,这只是其中一部分,社区中还有很多插件。
每个插件都是一个构造函数,使用它的时候需要用 new
实例化。
以下是此前系列文章使用过的插件,后续文章还将会用到其他插件,比如 copy-webpack-plugin
、happypack
等,用到再介绍。
// webpack.config.js
const webpack = require('webpack')
// 导入非 webpack 自带默认插件
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
// ...
plugins: [
// 创建 HTML 文件
new HtmlWebpackPlugin({
title: 'webpack demo',
template: './src/index.html',
filename: 'index.html',
inject: 'body',
hash: true,
favicon: './src/favicon.ico'
}),
// 新版无需再指定删除目录,默认删除 output 的目录
new CleanWebpackPlugin(),
// 通过它启用 HMR 之后,它的接口将被暴露在 module.hot 属性下面
new webpack.HotModuleReplacementPlugin(),
// 允许在编译时(compile time)配置的全局常量
new webpack.DefinePlugin({
// 注意,因为这个插件直接执行文本替换,给定的值必须包含字符串本身内的实际引号。通常,有两种方式来达到这个效果,使用 '"production"', 或者使用 JSON.stringify('production')。
'process.env.NODE_ENV': JSON.stringify('development')
})
]
}
8. 开发环境
关于 watch mode
、webpack-dev-server
、webpack-dev-middleware
的选择,写在这篇 Webpack 开发环境选择文章了。
文章中提到了 webpack-dev-server 生成的包并没有存储在你的硬盘中,而是放到了内存里。
接下来介绍的是 webpack-dev-server 选项。
*若想通过 Node.js API 来使用它,此处有一个简单示例。
webpack-dev-server 支持两种模式来刷新页面:
- iframe:页面放在
<iframe>
标签中,当文件发生更改会重新刷新页面,设置方式有两种,如下:
module.exports = {
devServer: {
inline: false, // 启用 iframe 模式
open: true // 在 server 启动后打开浏览器
}
}
或者通过 CLI 方式:
{
"scripts": {
"dev": "webpack-dev-server --inline=false"
}
}
启动之后,打开的 URL 格式如下:
http://«host»:«port»/webpack-dev-server/«path»
# 比如
http://localhost:8080/webpack-dev-server/
*我看过的项目好像还没有人用这种方式的,我也没用过,不展开说了。(PS:我尝试过这种方式好像只能 Live Reload,不能 HMR。我不知道是我配置问题,还是其他原因?后面有时间再研究一下,研究明白了再回来更新这块内容)
- inline:默认是
inline mode
。
配置方式有三种,看这篇别人踩坑的文章。我怕我说越多越乱,记住它是默认的模式就好了。
注意接着,往下的内容将基于 inline 模式介绍。
告诉服务器从哪个目录中提供内容。只有在你需要提供静态文件(如图片,数据等一些不受 webpack 控制的资源文件)时才需要。devServer.publicPath
将用于确定应该从哪里提供 bundle,并且此选项优先。
推荐使用一个绝对路径。
默认情况下,将使用当前工作目录作为提供内容的目录,将其设置为 false
以禁用 contentBase
。
// webpack.config.js
const path = require('path')
module.exports = {
devServer: {
// 单个目录
contentBase: path.join(__dirname, 'public'),
// 多个目录
contentBase: [
path.join(__dirname, 'public'),
path.join(__dirname, 'assets')
]
}
}
*CLI 用法不介绍了,下同。
此路径下的打包文件可在浏览器中访问。devServer.publicPath
默认值是 /
。
假设服务器运行在 http://localhost:8080
并且 output.filename
被设置为 bundle.js
。devServer.publicPath
默认值是 /
,所以你的包(bundle)可以通过 http://localhost:8080/bundle.js
访问。
module.exports = {
//...
devServer: {
publicPath: '/assets/'
}
}
修改配置,将 bundle 放置指定的目录下。现在通过 http://localhost:8080/assets/bundle.js
访问到 bundle。
未完待续...