专栏首页前端技术江湖Webpack 实现 Tree shaking 的前世今生

Webpack 实现 Tree shaking 的前世今生

左琳,微医前端技术部前端开发工程师。身处互联网浪潮之中,热爱生活与技术。

前言

如果看过 rollup 系列的这篇文章 - 无用代码去哪了?项目减重之 rollup 的 Tree-shaking,那你一定对 tree-shaking 不陌生了。如果对 tree-shaking 相关知识不熟悉,请先点开上面这篇文章花 5 分钟了解一下:什么是 tree-shaking。

众所周知,原本不支持 tree-shaking 的 Webpack 在它的 2.x 版本也实现了 tree-shaking,好奇心又来了,rollup 从一开始就自实现了 tree-shaking,而 Webpack 则是看到 rollup 的打包瘦身效果之后,到了 2.x 才实现,那么二者实现 tree-shaking 的原理是一样的吗?

因为这样的疑问,就有了眼前这篇文章。

Tree-shaking 实现机制

快速浏览完官方文档和一众文章后,发现 webpack 实现 tree-shaking 的方式还不止一种!但是,都与 rollup 不同。

早期 webpack 的配置使用并不简单,也因此曾有 webpack 配置工程师的戏称,虽然现在 webpack 的配置被极大简化了,webpack4 也宣称 0 配置,但如果涉及复杂全面的打包功能,并非是 0 配置可以实现的。了解其功能原理及配置还是极为有用的,接下来就来了解一下 webpack 实现 tree-shaking 的原理吧。

Tree-shaking -- rollup VS Webpack

  • rollup 是在编译打包过程中分析程序流,得益于于 ES6 静态模块(exports 和 imports 不能在运行时修改),我们在打包时就可以确定哪些代码时我们需要的。
  • webpack 本身在打包时只能标记未使用的代码而不移除,而识别代码未使用标记并完成 tree-shaking 的 其实是 UglifyJS、babili、terser 这类压缩代码的工具。简单来说,就是压缩工具读取 webpack 打包结果,在压缩之前移除 bundle 中未使用的代码。

我们提到了标记未使用代码,也提到了 UglifyJS、babili、terser 等压缩工具,那么 webpack 与压缩工具是怎么实现 tree-shaking 的呢?先来了解下 webpack 中实现 tree-shaking 的前世今生吧!

Webpack 实现 tree-shaking 的 3 个阶段

第一阶段:UglifyJS

webpack 标记代码 + babel 转译 ES5 --> UglifyJS 压缩删除无用代码关于最早版本的 Webpack 实现 tree-shaking 可以参考这篇文章 如何在 Webpack 2 中使用 tree-shaking(链接地址见文末参考),掘金也有翻译版,当然如果不愿意花时间考古,也可以看下面这一段总结:

  • UglifyJS 不支持 ES6 及以上,需要用 Babel 将代码编译为 ES5,然后再用 UglifyJS 来清除无用代码;
  • 通过 Babel 将代码编译为 ES5,但又要让 ES6 模块不受 Babel 预设(preset)的影响:配置 Babel 预设不转换 module,对应地配置 Webpack 的 plugins 配置;
  • 为避免副作用,将其标记为 pure(无副作用),以便 UglifyJS 能够处理,主要是 webpack 的编译过程阻止了对类进行 tree-shaking,它仅对函数起作用,后来通过支持将类编译后的赋值标记为 @__PURE__解决了这个问题。
// .babelrc
{
  "presets": [
    ["env", {
      "loose": true, // 宽松模式
      "modules": false // 不转换 module,保持 ES6 语法
    }]
  ]
}
// webpack.config.js
module: {
  rules: [
    { test: /\.js$/, loader: 'babel-loader' }
  ]
},

plugins: [
  new webpack.LoaderOptionsPlugin({
    minimize: true,
    debug: false
  }),
  new webpack.optimize.UglifyJsPlugin({
    compress: {
      warnings: true
    },
    output: {
      comments: false
    },
    sourceMap: false
  })
]

第二阶段:BabelMinify

webpack 标记代码 --> Babili(即 BabelMinify)压缩删除无用代码Babili 后来被重命名为 BabelMinify,是基于 Babel 的代码压缩工具,而 Babel 已经通过我们的解析器 Babylon 理解了新语法,同时又在 babili 中集成了 UglifyJS 的压缩功能,本质上实现了和 UglifyJS 一样的功能,但使用 babili 插件又不必再转译,而是直接压缩,使代码体积更小。

一般使用 Babili 替代 uglify 有 Babili 插件式和 babel-loader 预设两种方式。在官方文档最后有说明,Babel Minify 最适合针对最新的浏览器(具有完整的 ES6+ 支持),也可以与通常的 Babel es2015 预设一起使用,以首先向下编译代码。

在 webpack 中使用 babel-loader,然后再引入 minify 作为一个 preset 会比直接使用 BabelMinifyWebpackPlugin 插件(下一个就讲到)执行得更快。因为 babel-minify 处理的文件体积会更小。

第三阶段:Terser

webpack 标记代码 --> Terser 压缩删除无用代码 (webpack5 已内置)terser 是一个用于 ES6+ 的 JavaScript 解析器和 mangler/compressor 工具包。如果你看过这个 issue(https://github.com/webpack-contrib/terser-webpack-plugin/issues/15),就会知道放弃 uglify 而投向 terser 怀抱的人越来越多,其原因也很清楚:

  • uglify 不再进行维护且不支持 ES6+ 语法
  • webpack 默认内置配置了 terser 插件实现代码压缩关于副作用,从 webpack 4 正式版本扩展了未使用模块检测能力,通过 package.json 的 "sideEffects" 属性作为标记,向 compiler 提供提示,表明项目中的哪些文件是 "pure(纯正 ES2015 模块)",由此可以安全地删除文件中未使用的部分。

webpack4 的时候还要手动配置一下压缩插件,但最新的 webpack5 已经内置实现 tree-shaking 啦!在生产环境下无需配置即可实现 tree-shaking !

Webpack 的 Tree-shaking 流程

Webpack 标记代码

总的来说,webpack 对代码进行标记,主要是对 import & export 语句标记为 3 类:

  • 所有 import 标记为 /* harmony import */
  • 所有被使用过的 export 标记为/* harmony export ([type]) */,其中 [type] 和 webpack 内部有关,可能是 binding, immutable 等等
  • 没被使用过的 export 标记为/* unused harmony export [FuncName] */,其中 [FuncName] 为 export 的方法名称

首先我们要知道,为了正常运行业务项目,Webpack 需要将开发者编写的业务代码以及支撑、调配这些业务代码的运行时一并打包到产物(bundle)中。落到 Webpack 源码实现上,运行时的生成逻辑可以划分为打包阶段中的两个步骤:

  • 依赖收集:遍历代码模块并收集模块的特性依赖,从而确定整个项目对 Webpack runtime 的依赖列表;
  • 生成:合并 runtime 的依赖列表,打包到最终输出的 bundle。

显然,对代码的语句标记就发生在依赖收集的过程中。

在运行时环境标记所有 import:

const exportsType = module.getExportsType(
 chunkGraph.moduleGraph,
 originModule.buildMeta.strictHarmonyModule
);
runtimeRequirements.add(RuntimeGlobals.require);
const importContent = `/* harmony import */ ${optDeclaration}${importVar} = __webpack_require__(${moduleId});\n`;

// 动态导入语法分析
if (exportsType === "dynamic") {
 runtimeRequirements.add(RuntimeGlobals.compatGetDefaultExport);
 return [
  importContent, // 标记/* harmony import */
  `/* harmony import */ ${optDeclaration}${importVar}_default = /*#__PURE__*/${RuntimeGlobals.compatGetDefaultExport}(${importVar});\n` // 通过 /*#__PURE__*/ 注释可以告诉 webpack 一个函数调用是无副作用的
 ]; // 返回 import 语句和 compat 语句
}

在运行时环境标记所有被使用过的和未被使用的 export:

 // 在运行时状态定义 property getters
  generate() {
  const { runtimeTemplate } = this.compilation;
  const fn = RuntimeGlobals.definePropertyGetters;
  return Template.asString([
   "// define getter functions for harmony exports",
   `${fn} = ${runtimeTemplate.basicFunction("exports, definition", [
    `for(var key in definition) {`,
    Template.indent([
     `if(${RuntimeGlobals.hasOwnProperty}(definition, key) && !${RuntimeGlobals.hasOwnProperty}(exports, key)) {`,
     Template.indent([
      "Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });"
     ]),
     "}"
    ]),
    "}"
   ])};`
  ]);
 }
  
  // 输入为 generate 上下文
  getContent({ runtimeTemplate, runtimeRequirements }) {
  runtimeRequirements.add(RuntimeGlobals.exports);
  runtimeRequirements.add(RuntimeGlobals.definePropertyGetters);

  const unusedPart =
   this.unusedExports.size > 1
    ? `/* unused harmony exports ${joinIterableWithComma(
      this.unusedExports
      )} */\n`
    : this.unusedExports.size > 0
    ? `/* unused harmony export ${first(this.unusedExports)} */\n`
    : "";
  const definitions = [];
  for (const [key, value] of this.exportMap) {
   definitions.push(
    `\n/* harmony export */   ${JSON.stringify(
     key
    )}: ${runtimeTemplate.returningFunction(value)}`
   );
  }
  const definePart =
   this.exportMap.size > 0
    ? `/* harmony export */ ${RuntimeGlobals.definePropertyGetters}(${
      this.exportsArgument
      }, {${definitions.join(",")}\n/* harmony export */ });\n`
    : "";
  return `${definePart}${unusedPart}`; // 作为初始化代码包含的源代码
 }
}

压缩清除大法

UglifyJS

以 UglifyJS 为例,UglifyJS 是一个 js 解释器、最小化器、压缩器、美化器工具集(parser, minifier, compressor or beautifier toolkit)。具体介绍可以查看下 UglifyJS 中文手册。

如果不想浏览这么一大长篇文档,可以看干净利落、直指 tree-shaking 的压缩配置参数总结吧!

  • dead_code -- 移除没被引用的代码 // 是不是很眼熟!无用代码!
  • drop_debugger -- 移除 debugger
  • unused -- 干掉没有被引用的函数和变量。(除非设置"keep_assign",否则变量的简单直接赋值也不算被引用。)
  • toplevel -- 干掉顶层作用域中没有被引用的函数 ("funcs")和/或变量("vars") (默认是 false , true 的话即函数变量都干掉)
  • warnings -- 当删除没有用处的代码时,显示警告 // 还挺贴心有么有~
  • pure_getters -- 默认是 false. 如果你传入 true,UglifyJS 会假设对象属性的引用(例如 foo.bar 或 foo["bar"])没有函数副作用。
  • pure_funcs -- 默认 null. 你可以传入一个名字的数组,UglifyJS 会假设这些函数没有函数副作用。

举个栗子:

plugins: [
  new UglifyJSPlugin({
    uglifyOptions: {
      compress: {
          // 这样该函数会被认为没有函数副作用,整个声明会被废弃。在目前的执行情况下,会增加开销(压缩会变慢)。
          pure_funcs: ['Math.floor']
      }
    }
  })
],

Tip:假如名字在作用域中重新定义,不会再次检测。例如 var q = Math.floor(a/b),假如变量 q 没有被引用,UglifyJS 会干掉它,但 Math.floor(a/b)会被保留,没有人知道它是干嘛的。

  • side_effects -- 默认 true. 传 false 禁用丢弃纯函数。如果一个函数被调用前有一段/@PURE/ or /#PURE/ 注释,该函数会被标注为纯函数。例如 /@PURE/foo();

事实上,在这么多的压缩配置中,除了要解决副作用问题要手动配置以外,仅使用 UglifyJS 默认配置即可去除无用标记代码以实现 tree-shaking。

terser

以 terser 为例,terser 是一个用于 ES6+ 的 JavaScript 解析器和 mangler/compressor 工具包。具体可查看官方文档。虽然没有中文文档,但是一眼扫过去也可以看出来配置参数和 UglifyJS 没有太大区别。当然很明显地多了一些参数:

  • arrows -- 如果转换后的代码更短,类和对象字面量方法也将被转换为箭头表达式
  • ecma -- 通过 ES2015 或 更高版本来启用压缩选项,将 ES5 代码转换为更小的 ES6+等效形式显然是因为 terser 支持 ES6+ 语法,这也是它淘汰 UglifyJS 的优势之一。

压缩性能 PK

目前 Webpack 已经更新到了版本 5.X,已经将 terser 插件默认内置且无需配置,虽然生产环境下默认使用 TerserPlugin ,并且也是代码压缩方面比较好的选择,但是还有一些其他可选择项。等等,我们的主题不是 tree-shaking 吗?怎么在压缩工具的路上突然越走越远...

本质上,实现 tree-shaking 的还是压缩工具,所以我们来看压缩工具的性能好像也没毛病!

TIP:压缩是在生产环境中生效的,所以生产环境下才能 tree-shaking。下面 3 个可配置插件要求 webpack 版本至少在 V4+。

UglifyjsWebpackPlugin

基本的使用方式也更加简单:

// webpack.config.js
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
  optimization: {
    minimizer: [new UglifyJsPlugin()],
  },
};

const UglifyJsPlugin = require('uglifyjs-webpack-plugin')

module.exports = {
  plugins: [
    new UglifyJsPlugin()
  ]
}

BabelMinifyWebpackPlugin

一般使用 babili 替代 UglifyJS 有 Babili 插件式和 babel-loader 预设两种方式。

Babili 插件式

只要用 Babili 插件替代 uglify 即可,此时也不需要 babel-loader 了:

// webpack.config.js
const MinifyPlugin = require("babel-minify-webpack-plugin");
module.exports = {
  plugins: [
    new MinifyPlugin(minifyOpts, pluginOpts)
  ]
}

babel-loader 预设

在官方文档最后有说明,Babel Minify 最适合针对最新的浏览器(具有完整的 ES6+ 支持),也可以与通常的 Babel es2015 预设一起使用,以首先向下编译代码。

在 webpack 中使用 babel-loader,然后再引入 minify 作为一个 preset 会比直接使用 BabelMinifyWebpackPlugin 插件执行得更快。因为 babel-minify 处理的文件体积会更小。

即在.babelrc 中配置如下:

{
  "presets": ["es2015"],
  "env": {
    "production": {
      "presets": ["minify"]
    }
  }
}

但 BabelMinifyWebpackPlugin 插件存在必定有其无法替代的作用:

  • webpack loader 对单个文件进行操作, minify preset 作为一个 webpack loader 会把每个文件视为在浏览器全局范围内直接执行(默认情况下),并不会优化顶级作用域内的某些内容;
  • 当排除 node_modules 不通过 babel-loader 运行时,babel-minify 优化不会应用于被排除的文件;
  • 当使用 babel-loader 时,由 webpack 为模块系统生成的代码不会通过 babel-minify 进行优化;
  • webpack 插件可以在整个 chunk/bundle 输出上运行,并且可以优化整个 bundle。

采用第一种方式:

TerserWebpackPlugin

同 uglify 和 babelMinify 插件一样,terser 插件配置使用也十分简单。

webpack.config.js
const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()],
  },
};

企业微信截图_16247735356260.png

看上去结果是符合预期的,又因为我的文件代码本身体积就小,所以压缩包体积上的优势其实并不明显,但压缩时间上还是比较明显的。

官方数据性能对比

再来康康 bableMinify 文档 中给出的对比吧:

打包 react:

打包 vue:

打包 lodash:

打包 three.js:

小结

先让我们来看看 issue 区网友们是怎么说的:

大意是 terser 压缩性能相较于 uglify 提升了三倍!Nice!

大意是说:鉴于 terser-webpack-plugin 得到维护并且有更多的正确性修复,绝对是首选 -- 即使没有性能改进(事实上还是有所改进的),也值得切换。最后一句话总结:webpack 打包 + terser 压缩才是最终的不二之选!webpack5 内置 terser 说明了一切!

处理 Side Effects

「副作用」的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。举例说明,例如 polyfill,它影响全局作用域,并且通常不提供 export。

关于副作用在 rollup 中也已经介绍过。有些模块导入,只要被引入,就会对应用程序产生重要的影响。比如全局样式表,或者设置全局配置的 JavaScript 文件就是很好的例子。

Webpack 认为这样的文件有“副作用”,具有副作用的文件不应该做 tree-shaking,因为这将破坏整个应用程序。webpack 的 tree-shaking 在副作用处理方面稍显逊色,它可以简单的判断变量后续是否被引用、修改,但是不能判断一个变量完整的修改过程,不知道它是否已经指向了外部变量,所以很多有可能会产生副作用的代码,都只能保守的不删除。

幸运的是,我们可以通过配置项目,告诉 Webpack 哪些代码是没有副作用的,可以进行 tree-shaking。

配置参数

在项目的 package.json 文件中,添加 "sideEffects" 属性。package.json 有一个特殊的属性 sideEffects,就是为处理副作用而存在的 -- 向 webpack 的 compiler 提供提示哪些代码是“纯粹部分”。它有三个可能的值:

  • true 是默认值,如果不指定其他值的话。这意味着所有的文件都有副作用,也就是没有一个文件可以 tree-shaking。
  • false 告诉 Webpack 没有文件有副作用,所有文件都可以 tree-shaking。
  • 第三个值 […] 是文件路径数组。它告诉 webpack,除了数组中包含的文件外,你的任何文件都没有副作用。因此,除了指定的文件之外,其他文件都可以安全地进行 tree-shaking。
{
  "name": "your-project",
  "sideEffects": false
  // "sideEffects": [ // 数组方式支持相关文件的相对路径、绝对路径和 glob 模式
  //  "./src/some-side-effectful-file.js",
  //  "*.css"
  //]
}

每个项目都必须将 sideEffects 属性设置为 false 或文件路径数组,如果你的代码确实有一些副作用,那么可以改为提供一个数组,在工作中需要正确配置 sideEffects 标记。

代码中标记

可以通过 /#PURE/ 注释可以告诉 webpack 一个函数调用是无副作用的。在函数调用之前,用来标记它们是无副作用的(pure)。传到函数中的入参是无法被刚才的注释所标记,需要单独每一个标记才可以。如果一个没被使用的变量定义的初始值被认为是无副作用的(pure),它会被标记为死代码,不会被执行且会被压缩工具清除掉。当 optimization.innerGraph 被设置成 true 这个行为被会开启,而在 webpack5.x 中optimization.innerGraph 默认为 true

语法使用层面

  • 首先,mode 为 production 模式下才会启用更多优化项,包括我们本文讲的压缩代码与 tree shaking;
  • 使用 ES2015 模块语法(即 import 和 export);
  • 确保没有编译器将 ES2015 模块语法转换为 CommonJS 的,把 presets 中的 modules 设置为 false,告诉 babel 不要编译模块代码。

总结

  • 如果是开发 JavaScript 库,使用 rollup!并且提供 ES6 module 的版本,入口文件地址设置到 package.json 的 module 字段;
  • 使用 webpack 哪怕是旧版本可以优先考虑 terser 插件作为压缩工具;
  • 为避免副作用,尽量不写带有副作用的代码,使用 ES2015 模块语法;
  • 在项目 package.json 文件中,添加一个 sideEffects 入口,设置 sideEffects 属性为 false,也可以通过 /#PURE/ 注释强制删除一些认为不会产生副作用的代码;
  • 在 Webpack 中还要额外引入一个能够删除未引用代码(dead code)的压缩工具(eg. Terser)。

参考资料

  • 如何在 Webpack 2 中使用 tree-shaking()https://blog.craftlab.hu/how-to-do-proper-tree-shaking-in-webpack-2-e27852af8b21
  • 你的 Tree-Shaking 并没什么卵用(https://zhuanlan.zhihu.com/p/32831172)
  • UglifyJS 中文手册(https://github.com/LiPinghai/UglifyJSDocCN/blob/master/README.md)
  • Webpack 4 Tree Shaking 终极优化指南(https://juejin.cn/post/6844903998634328072#heading-5)
  • Webpack 中文文档 Tree-shaking(https://www.webpackjs.com/guides/tree-shaking/)

本文分享自微信公众号 - 前端技术江湖(bigerfe)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2021-07-04

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 4-1 Tree Shaking 概念详解

    tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块系统中的静...

    love丁酥酥
  • Tree Shaking概念详解

    Tree Shaking 值的就是当我引入一个模块的时候,我不引入这个模块的所有代码,我只引入我需要的代码,这就需要借助 webpack 里面自带的 Tree ...

    我不是费圆
  • 配置Tree Shaking来减少JavaScript的打包体积

    译者按: 用Tree Shaking技术来减少JavaScript的Payload大小

    Fundebug
  • 九:CSS-Tree-Shaking

    是滴,随着 webpack 的兴起,css 也可以进行 Tree Shaking: 以去除项目代码中用不到的 CSS 样式,仅保留被使用的样式代码。

    心谭博客
  • webpack2 的 tree-shaking 好用吗?

    下面是一个使用 react 的业务的代码依赖,但是实际上业务代码中并没有对依赖图中标识的模块,也就是说构建工具将不需要的代码打包到了最终的代码当中。显然,这是很...

    IMWeb前端团队
  • 16、webpack从0到1-tree shaking

    Ewall
  • Import 方式对 Tree-shaking 的影响

    最近从小被子那里学了不少 Tree-shaking 的知识,Tree-shaking 译作“摇树优化”,是 DCE(Dead Code Elimination)...

    猫哥学前班
  • 八:JS Tree Shaking

    是的,在webpack v4中,不再需要配置UglifyjsWebpackPlugin。(详情请见:文档) 取而代之的是,更加方便的配置方法。

    心谭博客
  • Vue.js前后端同构方案之准备篇:代码优化

    目前Vue.js的火爆不亚于当初的React,本人对写代码有洁癖,代码也是艺术。此篇是准备篇,工欲善其事,必先利其器。我们先在代码层面进行优化,对我们完成整个技...

    王鹤
  • Webpack 4教程 - 第七部分 减少打包体积与Tree Shaking

    在本次Webpack 4教程中,我们会更进一步讲述项目优化。我们会学习什么是tree shaking以及如何使用它。你会找到让Webpack 4中tree sh...

    葡萄城控件
  • 聊一聊面试中经常被问到的Tree Shaking

    天下武功,唯快不破!最新版的 antd 以及 vue 都对 Tree Shaking 提供了支持。我们内部的组件在支持这部分功能时,也专门梳理了相关的特性。这是...

    前端迷
  • webpack2 的 tree-shaking 好用吗?

    代码压缩的现状 下面是一个使用 react 的业务的代码依赖,但是实际上业务代码中并没有对依赖图中标识的模块,也就是说构建工具将不需要的代码打包到了最终的代码当...

    IMWeb前端团队
  • webpack中tree-shaking技术介绍

    之前介绍过webpack3的新特性,里面提到webpack2支持了ES6的import和export,不需要将ES6的模块先转成CommonJS模块,然后再进行...

    用户1217459
  • Tree-shaking

    Tree-shaking是webpack内置的一个优化,主要功能就是去除没有用到代码。因为JavaScript大多数是要通过加载的,加载的文件越小,性能越好,所...

    wade
  • 「知识拾遗」Tree-Shaking与构建工具选择

    Tree-Shaking,它代表的大意就是删除没用到的代码。这样的功能对于构建大型应用时是非常好的,因为日常开发经常需要引用各种库。但大多时候仅仅使用了这些库的...

    winty
  • Webpack 的 Tree Shaking 概念详解

    webpack 2.0 开始引入 tree shaking 技术,翻译过来的中文意思就是摇树,它可以在打包时忽略没有用到的代码。

    张张
  • webpack4使用笔记

    如果要使用import scss的用法 ,可以在css-loader上添加 options属性 importLoaders 让import进来的css也能使用到...

    lilugirl
  • webpack实战——打包优化【下】

    这是webpack打包优化【下】篇。前几篇针对性能要求高的项目从加快打包速度、减小资源体积方面入手,提出了一些优化政策,然后测试都可起到一定优化效果。本篇描述死...

    流眸
  • webpack(4.8.3)总结之一

    前言:webpack4从入门到高阶配置,本文先讲述webpack4的安装、基础配置、进阶配置,高阶配置放置下篇讲述。

    wade

扫码关注云+社区

领取腾讯云代金券