前端性能优化 24 条建议(2024)(1)

const CompressionPlugin = require(‘compression-webpack-plugin’);

module.exports = {

plugins: [new CompressionPlugin()],

}

node 配置

const compression = require(‘compression’)

// 在其他中间件前使用

app.use(compression())

9. 图片优化

(1). 图片延迟加载

在页面中,先不给图片设置路径,只有当图片出现在浏览器的可视区域时,才去加载真正的图片,这就是延迟加载。对于图片很多的网站来说,一次性加载全部图片,会对用户体验造成很大的影响,所以需要使用图片延迟加载。

首先可以将图片这样设置,在页面不可见时图片不会加载:

等页面可见时,使用 JS 加载图片:

const img = document.querySelector(‘img’)

img.src = img.dataset.src

这样图片就加载出来了,完整的代码可以看一下参考资料。

参考资料:

  • web 前端图片懒加载实现原理
(2). 响应式图片

响应式图片的优点是浏览器能够根据屏幕大小自动加载合适的图片。

通过 picture 实现

通过 @media 实现

@media (min-width: 769px) {

.bg {

background-image: url(bg1080.jpg);

}

}

@media (max-width: 768px) {

.bg {

background-image: url(bg768.jpg);

}

}

(3). 调整图片大小

例如,你有一个 1920 * 1080 大小的图片,用缩略图的方式展示给用户,并且当用户鼠标悬停在上面时才展示全图。如果用户从未真正将鼠标悬停在缩略图上,则浪费了下载图片的时间。

所以,我们可以用两张图片来实行优化。一开始,只加载缩略图,当用户悬停在图片上时,才加载大图。还有一种办法,即对大图进行延迟加载,在所有元素都加载完成后手动更改大图的 src 进行下载。

(4). 降低图片质量

例如 JPG 格式的图片,100% 的质量和 90% 质量的通常看不出来区别,尤其是用来当背景图的时候。我经常用 PS 切背景图时, 将图片切成 JPG 格式,并且将它压缩到 60% 的质量,基本上看不出来区别。

压缩方法有两种,一是通过 webpack 插件 image-webpack-loader,二是通过在线网站进行压缩。

以下附上 webpack 插件 image-webpack-loader 的用法。

npm i -D image-webpack-loader

webpack 配置

{

test: /.(png|jpe?g|gif|svg)(?.*)?$/,

use:[

{

loader: ‘url-loader’,

options: {

limit: 10000, /* 图片大小小于1000字节限制时会自动转成 base64 码引用*/

name: utils.assetsPath(‘img/[name].[hash:7].[ext]’)

}

},

/对图片进行压缩/

{

loader: ‘image-webpack-loader’,

options: {

bypassOnDebug: true,

}

}

]

}

(5). 尽可能利用 CSS3 效果代替图片

有很多图片使用 CSS 效果(渐变、阴影等)就能画出来,这种情况选择 CSS3 效果更好。因为代码大小通常是图片大小的几分之一甚至几十分之一。

参考资料:

  • img图片在webpack中使用
(6). 使用 webp 格式的图片

WebP 的优势体现在它具有更优的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量;同时具备了无损和有损的压缩模式、Alpha 透明以及动画的特性,在 JPEG 和 PNG 上的转化效果都相当优秀、稳定和统一。

参考资料:

  • WebP 相对于 PNG、JPG 有什么优势?

10. 通过 webpack 按需加载代码,提取第三库代码,减少 ES6 转为 ES5 的冗余代码

懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。

根据文件内容生成文件名,结合 import 动态引入组件实现按需加载

通过配置 output 的 filename 属性可以实现这个需求。filename 属性的值选项中有一个 [contenthash],它将根据文件内容创建出唯一 hash。当文件内容发生变化时,[contenthash] 也会发生变化。

output: {

filename: ‘[name].[contenthash].js’,

chunkFilename: ‘[name].[contenthash].js’,

path: path.resolve(__dirname, ‘…/dist’),

},

提取第三方库

由于引入的第三方库一般都比较稳定,不会经常改变。所以将它们单独提取出来,作为长期缓存是一个更好的选择。

这里需要使用 webpack4 的 splitChunk 插件 cacheGroups 选项。

optimization: {

runtimeChunk: {

name: ‘manifest’ // 将 webpack 的 runtime 代码拆分为一个单独的 chunk。

},

splitChunks: {

cacheGroups: {

vendor: {

name: ‘chunk-vendors’,

test: /[\/]node_modules[\/]/,

priority: -10,

chunks: ‘initial’

},

common: {

name: ‘chunk-common’,

minChunks: 2,

priority: -20,

chunks: ‘initial’,

reuseExistingChunk: true

}

},

}

},

  • test: 用于控制哪些模块被这个缓存组匹配到。原封不动传递出去的话,它默认会选择所有的模块。可以传递的值类型:RegExp、String和Function;

  • priority:表示抽取权重,数字越大表示优先级越高。因为一个 module 可能会满足多个 cacheGroups 的条件,那么抽取到哪个就由权重最高的说了算;

  • reuseExistingChunk:表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的。

  • minChunks(默认是1):在分割之前,这个代码块最小应该被引用的次数(译注:保证代码块复用性,默认配置的策略是不需要多次引用也可以被分割)

  • chunks (默认是async) :initial、async和all

  • name(打包的chunks的名字):字符串或者函数(函数可以根据条件自定义名字)

减少 ES6 转为 ES5 的冗余代码

Babel 转化后的代码想要实现和原来代码一样的功能需要借助一些帮助函数,比如:

class Person {}

会被转换为:

“use strict”;

function _classCallCheck(instance, Constructor) {

if (!(instance instanceof Constructor)) {

throw new TypeError(“Cannot call a class as a function”);

}

}

var Person = function Person() {

_classCallCheck(this, Person);

};

这里 _classCallCheck 就是一个 helper 函数,如果在很多文件里都声明了类,那么就会产生很多个这样的 helper 函数。

这里的 @babel/runtime 包就声明了所有需要用到的帮助函数,而 @babel/plugin-transform-runtime 的作用就是将所有需要 helper 函数的文件,从 @babel/runtime包 引进来:

“use strict”;

var _classCallCheck2 = require(“@babel/runtime/helpers/classCallCheck”);

var _classCallCheck3 = _interopRequireDefault(_classCallCheck2);

function _interopRequireDefault(obj) {

return obj && obj.__esModule ? obj : { default: obj };

}

var Person = function Person() {

(0, _classCallCheck3.default)(this, Person);

};

这里就没有再编译出 helper 函数 classCallCheck 了,而是直接引用了 @babel/runtime 中的 helpers/classCallCheck

安装

npm i -D @babel/plugin-transform-runtime @babel/runtime

使用

在 .babelrc 文件中

“plugins”: [

“@babel/plugin-transform-runtime”

]

参考资料:

  • Babel 7.1介绍 transform-runtime polyfill env

  • 懒加载

  • Vue 路由懒加载

  • webpack 缓存

  • 一步一步的了解webpack4的splitChunk插件

11. 减少重绘重排

浏览器渲染过程

  1. 解析HTML生成DOM树。

  2. 解析CSS生成CSSOM规则树。

  3. 将DOM树与CSSOM规则树合并在一起生成渲染树。

  4. 遍历渲染树开始布局,计算每个节点的位置大小信息。

  5. 将渲染树每个节点绘制到屏幕。

重排

当改变 DOM 元素位置或大小时,会导致浏览器重新生成渲染树,这个过程叫重排。

重绘

当重新生成渲染树后,就要将渲染树每个节点绘制到屏幕,这个过程叫重绘。不是所有的动作都会导致重排,例如改变字体颜色,只会导致重绘。记住,重排会导致重绘,重绘不会导致重排 。

重排和重绘这两个操作都是非常昂贵的,因为 JavaScript 引擎线程与 GUI 渲染线程是互斥,它们同时只能一个在工作。

什么操作会导致重排?

  • 添加或删除可见的 DOM 元素

  • 元素位置改变

  • 元素尺寸改变

  • 内容改变

  • 浏览器窗口尺寸改变

如何减少重排重绘?

  • 用 JavaScript 修改样式时,最好不要直接写样式,而是替换 class 来改变样式。

  • 如果要对 DOM 元素执行一系列操作,可以将 DOM 元素脱离文档流,修改完成后,再将它带回文档。推荐使用隐藏元素(display:none)或文档碎片(DocumentFragement),都能很好的实现这个方案。

12. 使用事件委托

事件委托利用了事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。所有用到按钮的事件(多数鼠标事件和键盘事件)都适合采用事件委托技术, 使用事件委托可以节省内存。

    • 苹果
    • 香蕉
    • 凤梨
    • // good

      document.querySelector(‘ul’).onclick = (event) => {

      const target = event.target

      if (target.nodeName === ‘LI’) {

      console.log(target.innerHTML)

      }

      }

      // bad

      document.querySelectorAll(‘li’).forEach((e) => {

      e.onclick = function() {

      console.log(this.innerHTML)

      }

      })

      13. 注意程序的局部性

      一个编写良好的计算机程序常常具有良好的局部性,它们倾向于引用最近引用过的数据项附近的数据项,或者最近引用过的数据项本身,这种倾向性,被称为局部性原理。有良好局部性的程序比局部性差的程序运行得更快。

      局部性通常有两种不同的形式:

      • 时间局部性:在一个具有良好时间局部性的程序中,被引用过一次的内存位置很可能在不远的将来被多次引用。

      • 空间局部性 :在一个具有良好空间局部性的程序中,如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近的一个内存位置。

      时间局部性示例

      function sum(arry) {

      let i, sum = 0

      let len = arry.length

      for (i = 0; i < len; i++) {

      sum += arry[i]

      }

      return sum

      }

      在这个例子中,变量sum在每次循环迭代中被引用一次,因此,对于sum来说,具有良好的时间局部性

      空间局部性示例

      具有良好空间局部性的程序

      // 二维数组

      function sum1(arry, rows, cols) {

      let i, j, sum = 0

      for (i = 0; i < rows; i++) {

      for (j = 0; j < cols; j++) {

      sum += arry[i][j]

      }

      }

      return sum

      }

      空间局部性差的程序

      // 二维数组

      function sum2(arry, rows, cols) {

      let i, j, sum = 0

      for (j = 0; j < cols; j++) {

      for (i = 0; i < rows; i++) {

      sum += arry[i][j]

      }

      }

      return sum

      }

      看一下上面的两个空间局部性示例,像示例中从每行开始按顺序访问数组每个元素的方式,称为具有步长为1的引用模式。

      如果在数组中,每隔k个元素进行访问,就称为步长为k的引用模式。

      一般而言,随着步长的增加,空间局部性下降。

      这两个例子有什么区别?区别在于第一个示例是按行扫描数组,每扫描完一行再去扫下一行;第二个示例是按列来扫描数组,扫完一行中的一个元素,马上就去扫下一行中的同一列元素。

      数组在内存中是按照行顺序来存放的,结果就是逐行扫描数组的示例得到了步长为 1 引用模式,具有良好的空间局部性;而另一个示例步长为 rows,空间局部性极差。

      性能测试

      运行环境:

      • cpu: i5-7400

      • 浏览器: chrome 70.0.3538.110

      对一个长度为9000的二维数组(子数组长度也为9000)进行10次空间局部性测试,时间(毫秒)取平均值,结果如下:

      所用示例为上述两个空间局部性示例

      | 步长为 1 | 步长为 9000 |

      | — | — |

      | 124 | 2316 |

      从以上测试结果来看,步长为 1 的数组执行时间比步长为 9000 的数组快了一个数量级。

      总结:

      • 重复引用相同变量的程序具有良好的时间局部性

      • 对于具有步长为 k 的引用模式的程序,步长越小,空间局部性越好;而在内存中以大步长跳来跳去的程序空间局部性会很差

      参考资料:

      • 深入理解计算机系统

      14. if-else 对比 switch

      当判断条件数量越来越多时,越倾向于使用 switch 而不是 if-else。

      if (color == ‘blue’) {

      } else if (color == ‘yellow’) {

      } else if (color == ‘white’) {

      } else if (color == ‘black’) {

      } else if (color == ‘green’) {

      } else if (color == ‘orange’) {

      } else if (color == ‘pink’) {

      }

      switch (color) {

      case ‘blue’:

      break

      case ‘yellow’:

      break

      case ‘white’:

      break

      case ‘black’:

      break

      case ‘green’:

      break

      case ‘orange’:

      break

      case ‘pink’:

      break

      }

      像以上这种情况,使用 switch 是最好的。假设 color 的值为 pink,则 if-else 语句要进行 7 次判断,switch 只需要进行一次判断。

      从可读性来说,switch 语句也更好。从使用时机来说,当条件值大于两个的时候,使用 switch 更好。

      不过,switch 只能用于 case 值为常量的分支结构,而 if-else 更加灵活。

      15. 查找表

      当条件语句特别多时,使用 switch 和 if-else 不是最佳的选择,这时不妨试一下查找表。查找表可以使用数组和对象来构建。

      switch (index) {

      case ‘0’:

      return result0

      case ‘1’:

      return result1

      case ‘2’:

      return result2

      case ‘3’:

      return result3

      case ‘4’:

      return result4

      case ‘5’:

      return result5

      case ‘6’:

      return result6

      case ‘7’:

      return result7

      case ‘8’:

      return result8

      case ‘9’:

      return result9

      case ‘10’:

      return result10

      case ‘11’:

      return result11

      }

      可以将这个 switch 语句转换为查找表

      const results = [result0,result1,result2,result3,result4,result5,result6,result7,result8,result9,result10,result11]

      return results[index]

      如果条件语句不是数值而是字符串,可以用对象来建立查找表

      const map = {

      red: result0,

      green: result1,

      }

      return map[color]

      16. 避免页面卡顿

      60fps 与设备刷新率

      目前大多数设备的屏幕刷新率为 60 次/秒。因此,如果在页面中有一个动画或渐变效果,或者用户正在滚动页面,那么浏览器渲染动画或页面的每一帧的速率也需要跟设备屏幕的刷新率保持一致。

      其中每个帧的预算时间仅比 16 毫秒多一点 (1 秒/ 60 = 16.66 毫秒)。但实际上,浏览器有整理工作要做,因此您的所有工作需要在 10 毫秒内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。此现象通常称为卡顿,会对用户体验产生负面影响。

      假如你用 JavaScript 修改了 DOM,并触发样式修改,经历重排重绘最后画到屏幕上。如果这其中任意一项的执行时间过长,都会导致渲染这一帧的时间过长,平均帧率就会下降。假设这一帧花了 50 ms,那么此时的帧率为 1s / 50ms = 20fps,页面看起来就像卡顿了一样。

      对于一些长时间运行的 JavaScript,我们可以使用定时器进行切分,延迟执行。

      for (let i = 0, len = arry.length; i < len; i++) {

      process(arry[i])

      }

      假设上面的循环结构由于 process() 复杂度过高或数组元素太多,甚至两者都有,可以尝试一下切分。

      const todo = arry.concat()

      setTimeout(function() {

      process(todo.shift())

      if (todo.length) {

      setTimeout(arguments.callee, 25)

      } else {

      callback(arry)

      }

      }, 25)

      如果有兴趣了解更多,可以查看一下高性能JavaScript第 6 章和高效前端:Web高效编程与优化实践第 3 章。

      参考资料:

      • 渲染性能

      17. 使用 requestAnimationFrame 来实现视觉变化

      从第 16 点我们可以知道,大多数设备屏幕刷新率为 60 次/秒,也就是说每一帧的平均时间为 16.66 毫秒。在使用 JavaScript 实现动画效果的时候,最好的情况就是每次代码都是在帧的开头开始执行。而保证 JavaScript 在帧开始时运行的唯一方式是使用 requestAnimationFrame

      /**

      • If run as a requestAnimationFrame callback, this

      • will be run at the start of the frame.

      */

      function updateScreen(time) {

      // Make visual updates here.

      }

      requestAnimationFrame(updateScreen);

      如果采取 setTimeout 或 setInterval 来实现动画的话,回调函数将在帧中的某个时点运行,可能刚好在末尾,而这可能经常会使我们丢失帧,导致卡顿。

      参考资料:

      • 优化 JavaScript 执行

      18. 使用 Web Workers

      Web Worker 使用其他工作线程从而独立于主线程之外,它可以执行任务而不干扰用户界面。一个 worker 可以将消息发送到创建它的 JavaScript 代码, 通过将消息发送到该代码指定的事件处理程序(反之亦然)。

      Web Worker 适用于那些处理纯数据,或者与浏览器 UI 无关的长时间运行脚本。

      创建一个新的 worker 很简单,指定一个脚本的 URI 来执行 worker 线程(main.js):

      var myWorker = new Worker(‘worker.js’);

      // 你可以通过postMessage() 方法和onmessage事件向worker发送消息。

      first.onchange = function() {

      myWorker.postMessage([first.value,second.value]);

      console.log(‘Message posted to worker’);

      }

      second.onchange = function() {

      myWorker.postMessage([first.value,second.value]);

      console.log(‘Message posted to worker’);

      }

      在 worker 中接收到消息后,我们可以写一个事件处理函数代码作为响应(worker.js):

      onmessage = function(e) {

      console.log(‘Message received from main script’);

      var workerResult = 'Result: ’ + (e.data[0] * e.data[1]);

      console.log(‘Posting message back to main script’);

      postMessage(workerResult);

      }

      onmessage处理函数在接收到消息后马上执行,代码中消息本身作为事件的data属性进行使用。这里我们简单的对这2个数字作乘法处理并再次使用postMessage()方法,将结果回传给主线程。

      回到主线程,我们再次使用onmessage以响应worker回传的消息:

      myWorker.onmessage = function(e) {

      result.textContent = e.data;

      console.log(‘Message received from worker’);

      }

      在这里我们获取消息事件的data,并且将它设置为result的textContent,所以用户可以直接看到运算的结果。

      不过在worker内,不能直接操作DOM节点,也不能使用window对象的默认方法和属性。然而你可以使用大量window对象之下的东西,包括WebSockets,IndexedDB以及FireFox OS专用的Data Store API等数据存储机制。

      参考资料:

      • Web Workers

      19. 使用位操作

      JavaScript 中的数字都使用 IEEE-754 标准以 64 位格式存储。但是在位操作中,数字被转换为有符号的 32 位格式。即使需要转换,位操作也比其他数学运算和布尔操作快得多。

      取模

      由于偶数的最低位为 0,奇数为 1,所以取模运算可以用位操作来代替。

      if (value % 2) {

      // 奇数

      } else {

      // 偶数

      }

      // 位操作

      if (value & 1) {

      // 奇数

      } else {

      // 偶数

      }

      取反

      ~~10.12 // 10

      ~~10 // 10

      ~~‘1.5’ // 1

      ~~undefined // 0

      ~~null // 0

      位掩码

      const a = 1

      const b = 2

      const c = 4

      const options = a | b | c

      通过定义这些选项,可以用按位与操作来判断 a/b/c 是否在 options 中。

      // 选项 b 是否在选项中

      if (b & options) {

      }

      20. 不要覆盖原生方法

      无论你的 JavaScript 代码如何优化,都比不上原生方法。因为原生方法是用低级语言写的(C/C++),并且被编译成机器码,成为浏览器的一部分。当原生方法可用时,尽量使用它们,特别是数学运算和 DOM 操作。

      21. 降低 CSS 选择器的复杂性

      (1). 浏览器读取选择器,遵循的原则是从选择器的右边到左边读取。

      看个示例

      #block .text p {

      color: red;

      }

      1. 查找所有 P 元素。

      2. 查找结果 1 中的元素是否有类名为 text 的父元素

      3. 查找结果 2 中的元素是否有 id 为 block 的父元素

      (2). CSS 选择器优先级

      内联 > ID选择器 > 类选择器 > 标签选择器

      根据以上两个信息可以得出结论。

      1. 选择器越短越好。

      2. 尽量使用高优先级的选择器,例如 ID 和类选择器。

      3. 避免使用通配符 *。

      最后要说一句,据我查找的资料所得,CSS 选择器没有优化的必要,因为最慢和慢快的选择器性能差别非常小。

      最后

      自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

      深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

      因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

      img

      既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

      如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

      由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
      // 1

      ~~undefined // 0

      ~~null // 0

      位掩码

      const a = 1

      const b = 2

      const c = 4

      const options = a | b | c

      通过定义这些选项,可以用按位与操作来判断 a/b/c 是否在 options 中。

      // 选项 b 是否在选项中

      if (b & options) {

      }

      20. 不要覆盖原生方法

      无论你的 JavaScript 代码如何优化,都比不上原生方法。因为原生方法是用低级语言写的(C/C++),并且被编译成机器码,成为浏览器的一部分。当原生方法可用时,尽量使用它们,特别是数学运算和 DOM 操作。

      21. 降低 CSS 选择器的复杂性

      (1). 浏览器读取选择器,遵循的原则是从选择器的右边到左边读取。

      看个示例

      #block .text p {

      color: red;

      }

      1. 查找所有 P 元素。

      2. 查找结果 1 中的元素是否有类名为 text 的父元素

      3. 查找结果 2 中的元素是否有 id 为 block 的父元素

      (2). CSS 选择器优先级

      内联 > ID选择器 > 类选择器 > 标签选择器

      根据以上两个信息可以得出结论。

      1. 选择器越短越好。

      2. 尽量使用高优先级的选择器,例如 ID 和类选择器。

      3. 避免使用通配符 *。

      最后要说一句,据我查找的资料所得,CSS 选择器没有优化的必要,因为最慢和慢快的选择器性能差别非常小。

      最后

      自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

      深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

      因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

      [外链图片转存中…(img-36S3r2X8-1715886983943)]

      [外链图片转存中…(img-evnJ3V2j-1715886983943)]

      [外链图片转存中…(img-IjXOIxfq-1715886983944)]

      既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

      如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

      由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

    • 29
      点赞
    • 11
      收藏
      觉得还不错? 一键收藏
    • 0
      评论
    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值