从零到一搭建 React 项目

从零到一搭建 react 项目系列之(十四)

2020-10-18  本文已影响0人  越前君

前面的文章介绍了 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、functionPromise,本项目将使用导出 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 表示正在执行脚本的文件名

需要注意的是,它是两个下划线,两者均返回一个绝对路径

它的选项很多,主要介绍常用的几个:

所有输出文件的目标路径。它是一个绝对路径,默认是项目根目录下的 dist 路径。

例如,打包后的 JS 文件、url-loader 解析的图片,html-webpack-plugin 生成的 HTML 文件等都会存放到该路径下(或相对于该路径的子目录)

若非绝对路径,它将会构建失败并报错:configuration.output.path: The provided value "xxx" is not an absolute path!

publicPath 并不会对生成文件的路径造成影响,主要是对你的页面里面引入的资源的路径做对应的补全,常见的就是 CSS 文件里面引入的图片。

其中某些 loader(例如 file-loader) 的 publicPath 选项会覆盖掉 output.publicPath 的。

关于 pathpublicPath 很多人容易混淆,官方的描述我看起来是模糊的,所以下面我通俗地描述一下。

通俗地讲,path 就是打包文件存放在硬盘上的路径,它不会因为 publicPath 的设置而改变。

publicPath 会影响项目中引用的资源路径并重写。它只会修改项目中的相对路径和绝对路径,而完整的绝对路径将不受影响(例如 https://cdn.example.com/assets/ 这种形式不会被修改)。最常见的就是图片资源、打包产出的 JavaScript 文件在 HTML 中的引用路径等。这些文件的路径目录将被 publicPath 替换重写(除了文件名不变,其他被替换)。常被用来指定上线后的 cdn 域名。

此选项决定了每个(入口 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 的 hashchunkhashcontenthash 的区别,可以看下这篇文章

此选项决定了非入口(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)

这些选项决定了如何处理项目中的不同类型的模块

它的作用是防止 webpack 解析那些任意与给定正则表达式项匹配的文件。因为它们被忽略了,所以不会被 Babel 等做语法转换以兼容低版本的浏览器,故它们不应该含有 import、require、define 的调用

module.exports = {
  module: {
    // 支持 RegExp、[RegExp]、function(resource)、string、[string] 的形式
    noParse: /jquery|loadsh/
    // noParse: content => /jquery|lodash/.test(content)
  }
}

创建模块时,匹配请求的规则数组。这些规则能够修改模块的创建方式。这些规则能够对模块(module)应用 loader,或者修改解析器(parser)。

module.rules 是数组形式,支持一个或多个规则,而每个规则(Rule)可以分为三部分:条件(condition)、结果(result)、嵌套规则(nested rule)。

  • Rule 条件

    条件有两种输入值:
     1. resource:请求文件的绝对路径。(它已经根据 resolve 规则解析)
     2. issuer:被请求资源的模块文件的绝对路径,它是导入时的路径。

    如果看起来有点懵,没关系,下面举例说明。

    在规则中,resource 由属规则属性 testincludeexcluderesource 对其进行匹配。而 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 选项:用于为模块创建挤下去的选项对象。

    这些规则属性loaderoptionsuse 会影响 loader。(queryloaders 也会影响,但它们也被废弃)
    enforce 属性会影响 loader 种类。
    parser 属性会影响 parser 选项。

  • 嵌套的 Rule

    可以使用属性 rulesoneOf 指定嵌套规则。
    这些规则用于在规则条件(rule condition)匹配时进行取值。

不知道你们第一次看到上面这些概率描述,会不会有点发懵,反正我开始看的时候是会的。

接下来,介绍规则(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.testRule.includeRule.exclude

它们分别是 Rule.resource: { test, inclued, exclued } 的缩写。实际中,很多开发的朋友都说采用缩写的写法。

条件可以是这些之一:

  • 字符串:匹配输入必须以提供的字符串开始。是的。目录绝对路径或文件绝对路径。
  • 正则表达式:test 输入值。
  • 函数:调用输入的函数,必须返回一个真值(truthy value)以匹配。
  • 条件数组:至少一个匹配条件。
  • 对象:匹配所有属性。每个属性都有一个定义行为。

test:匹配特定条件。一般是提供一个正则表达式或正则表达式的数组,但这不是强制的。
include:匹配特定条件。一般是提供一个字符串或者字符串数组,但这不是强制的。
exclude:排除特定条件。一般是提供一个字符串或字符串数组,但这不是
强制的。

匹配条件每个选项都接收一个正则表达式或字符串。testinclude 具有相同的作用,都是必须匹配选项。exclude 是必不匹配选项(优先于 testinclude

最佳实践:

  • 只在 test文件名匹配 中使用正则表达式。
  • includeexclude 中使用绝对路径数组。
  • 尽量避免 exclude,更倾向于使用 include

(2) Rule.use

支持 UseEntriesfunction(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-loaderbabel-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) 其他属性

4. 解析(resolve)

该选项用于配置模块如何解析。例如,当在 ES6 中调用 import 'lodash'resolve 选项能够对 webpack 查找 lodash 的方式去做修改。

这一块内容已在另外一篇文章详细介绍了,请移步至文章 Webpack 如何解析模块路径

5. 模式(mode)

提供 mode 配置选项,告知 webpack 使用响应环境的内置优化。可选值有:nonedevelopmentproduction

如果没有设置,mode 默认设置为 production。可通过以下方式设定:

// webpack.config.js
module.exports = {
  mode: 'production'
}

或者从 CLI 传递参数:

// package.json
{
  "scripts": {
    "build": "webpack --mode production"
  }
}
  • development
    它会将 DefinePlugin 中的 process.env.NODE_ENV 的值设置为 development。启用 NamedChunksPluginNamedModulesPlugin

  • production
    它会将 DefinePlugin 中的 process.env.NODE_ENV 的值设置为 production。启用 FlagDependencyUsagePluginFlagIncludedChunksPluginModuleConcatenationPluginNoEmitOnErrorsPluginOccurrenceOrderPluginSideEffectsFlagPluginTerserPlugin

  • 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 选项在内部添加过这些插件,所以你最终将应用两次插件。

7. 插件(plugins)

该选项用于已各种方式自定义 webpack 构建过程。webpack 附带了各种内置的插件,可以通过 webpack.[plugin-name] 访问这些插件。

可以查看插件页面获取插件列表和对应的文档,这只是其中一部分,社区中还有很多插件。

每个插件都是一个构造函数,使用它的时候需要用 new 实例化。

以下是此前系列文章使用过的插件,后续文章还将会用到其他插件,比如 copy-webpack-pluginhappypack 等,用到再介绍。

// 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 modewebpack-dev-serverwebpack-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.jsdevServer.publicPath 默认值是 /,所以你的包(bundle)可以通过 http://localhost:8080/bundle.js 访问。

module.exports = {
  //...
  devServer: {
    publicPath: '/assets/'
  }
}

修改配置,将 bundle 放置指定的目录下。现在通过 http://localhost:8080/assets/bundle.js 访问到 bundle。


未完待续...

参考

上一篇下一篇

猜你喜欢

热点阅读