• 首页
  • vue
  • TypeScript
  • JavaScript
  • scss
  • css3
  • html5
  • php
  • MySQL
  • redis
  • jQuery
  • 性能优化

    概述

    Vue 的设计对于大多数常见的使用情况来说都是性能优秀的,不需要太多的手动优化。然而,总有一些具有挑战性的场景需要进行额外的细微调整。在本节中,我们将讨论当涉及到 Vue 应用程序的性能时,你应该注意什么。

    首先,让我们讨论一下网络性能的两个主要方面:

    • 页面加载性能:应用展示出内容与首次访问时变为可交互的速度。这通常是使用网络的重要指标来衡量,如最大的最大内容绘制(LCP)和首次输入延迟。
    • 更新性能:应用响应用户输入更新的速度。举个例子,当用户在一个搜索框中输入时列表的更新速度,或者用户在一个单页面应用(SPA)中点击链接跳转页面时的切换速度。

    虽然最理想的情况是将两者都最大化,但是不同的前端架构往往会影响到在这些方面是否能达到更理想的性能。此外,你所构建的应用程序的类型极大地影响了你在性能方面应该优先考虑的问题。因此,确保最佳性能的第一步是为你的应用类型挑选合适的架构:

    • 查看使用 Vue 的多种方式这一章看看如何用不同的方式围绕 Vue 组织架构。
    • Jason Miller 在应用标本一文中讨论了 Web 应用的类型以及它们各自的理想的实现/交付方式。

    分析选项

    为了提高性能,我们首先需要知道如何衡量它。在这方面,有一些很棒的工具可以提供帮助:

    用于生产部署的负载性能分析:

    用于本地开发期间的性能分析:

    • Chrome 开发者工具“性能”面板
      • app.config.performance将会开启 Vue 特有的性能标记,标记在 Chrome 开发者工具的性能时间线上。
    • Vue 开发者扩展也提供了性能分析的功能。

    页面加载优化

    页面加载优化有许多跟框架无关的方面-这份web.dev 指南提供了一个全面的总结。这里,我们将主要关注和 Vue 相关的技巧。


    包体积与 Tree-shaking 优化

    一个最有效的提升页面加载速度的方法就是压缩 JavaScript 打包产物的体积。当使用 Vue 时有下面一些办法来减小打包产物体积:

    • 尽可能地采用构建步骤

      • 如果使用的是相对现代的打包工具,许多 Vue 的 API 都是可以被tree-shake的。举个例子,如果你根本没有使用到内置的<Transition>组件,它将不会被打包进入最终的产物里。Tree-shaking 也可以移除你源代码中其他未使用到的模块。
      • 当使用了构建步骤时,模板会被预编译,因此我们无须在浏览器中载入 Vue 编译器。这在同样最小化加上 gzip 优化下会相对缩小 14kb 并避免运行时的编译开销。
    • 在引入新的依赖项时要小心包体积膨胀!在现实的应用中,包体积膨胀通常因为无意识地引入了过重的依赖导致的。

      • 如果使用了构建步骤,应当尽量选择提供 ES 模块格式的依赖,它们对 tree-shaking 更友好。举个例子,选择lodash-eslodash更好。
      • 查看依赖的体积,并评估与其所提供的功能之间的性价比。如果依赖对 tree-shaking 友好,实际增加的体积大小将取决于你从它之中导入的 API。像bundle.js.org这样的工具可以用来做快速的检查,但是根据实际的构建设置来评估总是最准确的。
    • 如果你基本上是以渐进式集成的模式使用 Vue,并选择避免使用构建步骤,请考虑使用petite-vue(只有 6kb)来代替。


    代码拆分

    代码拆分是指构建工具将应用程序包拆分为多个较小的块,然后可以按需或并行加载。通过适当的代码拆分,页面加载时需要的功能可以立即下载,而额外的块只在需要时才加载,从而提高性能。

    RollupVite就是基于它之上开发的)或者Webpack这样的打包器可以通过探测 ESM 动态导入的语法来自动拆分代码块:

    // lazy.js 及其依赖会被拆分到一个单独的块中
    // 并只在 `loadLazy()` 调用时才加载
    function loadLazy() {
      return import('./lazy.js')
    }
    

    懒加载对于页面初次加载时的优化帮助极大,它帮助应用暂时略过了那些不是立即需要的功能。在 Vue 应用中,这常常与 Vue 的异步组件搭配使用,为组件树创建分离的代码块:

    import { defineAsyncComponent } from 'vue'
    
    // 会为 Foo.vue 及其依赖创建单独的一个块
    // 它只会按需加载
    //(即该异步组件在页面中被渲染时)
    const Foo = defineAsyncComponent(() => import('./Foo.vue'))
    

    如果在客户端侧通过 Vue Router构建了路由,那么强烈建议使用异步组件作为路由组件。查看懒加载路由了解更多细节。


    SSR / SSG

    纯粹的客户端渲染存在内容到达时间缓慢的问题。这可以通过采用服务端渲染(SSR)或者静态站点生成(SSG)来进行优化。你可以查看 SSR 指引了解更多细节。


    更新优化

    props 稳定性

    在 Vue 之中,一个子组件只会在其至少一个 props 改变时才会更新。我们来思考以下示例:

    <ListItem
       v-for="item in list"
       :id="item.id"
       :active-id="activeId" />
    

    <ListItem>组件中,它使用了idactiveId两个 props 来确定它是否是当前活跃的那一项。虽然这是可行的,但问题是每当activeId更新时,列表中的每一个<ListItem>都会跟着更新!

    理想情况下,只有活跃状态发生改变的项才应该更新。我们可以将活跃状态比对的逻辑移入父组件来实现这一点,然后让<ListItem>改为接收一个active prop:

    <ListItem
       v-for="item in list"
       :id="item.id"
       :active="item.id === activeId" />
    

    现在,对与大多数的组件来说,activeId改变时,它们的active prop 都会保持不变,因此它们无需再更新。总而言之,核心要义就是尽量保持传给子组件的 props 稳定。

    v-once

    v-once是一个内置的指令,可以用来渲染依赖运行时数据但无需再更新的内容。它的整个子树都会在未来的更新中被跳过。查看它的 API 参考手册可以了解更多细节。

    v-memo

    v-memo是一个内置指令,可以用来有条件地跳过某些大型子树或者v-for列表的更新。查看它的 API 参考手册可以了解更多细节。

    总体优化

    以下提示会同时影响页面加载和更新性能。

    大型虚拟列表

    所有的前端应用中最常见的性能问题就是渲染大型列表。无论一个框架性能有多好,渲染成千上万个列表项都会变得很慢,因为浏览器需要处理大量的 DOM 节点。

    但是,我们并不需要立刻渲染出全部的列表。在大多数场景中,用户的屏幕尺寸只会展示这个巨大列表中的一小部分。我们可以通过列表虚拟化来提升性能,这项技术使我们只需要渲染用户视口中能看到的部分。

    要实现列表虚拟化并不简单,幸运的是,你可以直接使用现有的社区库:

    • vue-virtual-scroller
    • vue-virtual-scroll-grid


    减少大型不可变结构的响应性开销

    Vue 的响应性系统默认是深度的。虽然这让状态管理变得更直观,但在数据变大时它也的确创造了不小的性能负担,因为每个属性访问都将触发代理的依赖追踪。试想一下当一次渲染需要访问 100,000+属性的时候,这个开销在处理大型数组或层级很深的对象时变得无法忽略,因此,我们应该控制它只影响非常具体的使用情况。

    Vue 确实也为此提供了一种解决方案,通过使用shallowRef()shallowReactive()来选择退出深度响应。浅层式 API 创建的状态只在其顶层是响应式的,并原封不动地显示所有下面层级的对象。这使得对深层级属性的访问变得更快,但代价是,我们现在必须将所有深层级对象视为不可变的,并且只能通过替换整个根状态来触发更新:

    const shallowArray = shallowRef([
      /* 巨大的列表,里面包含深层的对象 */
    ])
    
    // 这不会触发更新...
    shallowArray.value.push(newObject)
    // 这才会触发更新
    shallowArray.value = [...shallowArr.value, newObject]
    
    // 这不会触发更新...
    shallowArray.value[0].foo = 1
    // 这才会触发更新
    shallowArray.value = [
      {
        ...shallowArray.value[0],
        foo: 1
      },
      ...shallowArray.value.slice(1)
    ]
    


    避免不必要的组件抽象

    有些时候我们会去创建无渲染组件或高阶组件(用来渲染具有额外 props 的其他组件)来实现更好的抽象或代码组织。虽然这并没有什么问题,但请记住,组件实例比普通 DOM 节点要昂贵得多,而且为了逻辑抽象创建太多组件实例将会导致性能损失。

    请注意,只减少几个实例不会有明显的效果,所以如果该组件在应用程序中只渲染了几次,就不用担心了。考虑这种优化的最佳场景还是在大型列表中。想象一下一个有 100 项的列表,每项的组件都包含许多子组件。在这里去掉一个不必要的组件抽象,可能会减少数百个组件实例的无谓性能消耗。


    tree-shaking

    tree-shaking,本质是消除无用的 js 代码。消除无用代码,广泛存在于传统的编程语言编译器中,编译器可以判断出某些代码根本不影响输出,然后消除这些代码,这个称之为 DCE(dead code elimination)。

    在编程语言中有分为:动态语言(Dynamic Language)以及静态语言(Static Language),被归类在 Dynamic Language 中比较常见的有 JavaScript、PHP、Python 等语言,至于被归类在 Static Language 比较常见的有 C++、Java 等语言。

    在 Dynamic Language中由于我们可以动态的载入非常多东西,例如 function、object 等,对于 Tree Shaking 来说这种会动态载入的东西实在是太难捉摸了,这也让 Dynamic Language 的 Tree Shaking 很难达到最完美。

    在 compiler 的领域中,为了达到执行时间的优化,在代码编译的过程中 compiler 会将对于最终结果没有影响到的代码删除,进而达到执行时间的优化,这段过程称之为 Dead Code Elimination。

    Tree Shaking 其实是 Dead Code Elimination 的一种新的实现原理,在上面的 Dynamic Language 的观念中提到 Dynamic Language的特性就是可以动态载入任何东西,因为这个特性让 Dead Code Elimination 相当难实现,因为 complier 永远不知道到底哪些程代码是对最终结果不会有影响的。所以 Tree Shaking 其实要做到的不会像 Dead Code Elimination 那样死板板的要删除对结果不会有影响的程式码,而是要保留会需要用到的代码,这样也可以达到类似 Dead Code Elimination 的效果,只是两者的原理还是有一些差异,而这就是 Tree Shaking 的原理。


    ES6 module v.s commonJS

    Tree Shaking 的原理最主要的目的就是要保留会需要用到的代码,而这点在早期的 JavaScript 其实是无法实现的,但是在 ES6 诞生后有一个非常重要的概念叫:ES6 modules

    由于 ES6 modules 的诞生,我们可以在每个文件的最上方先引用即将会需要用到的东西,所以这些 bunbler 就可以藉由这些 import file 很快速的知道可以保留哪些文件,进而达到 Tree Shaking 的效果。

    这时候读者可能会有另一个问题了,在 ES6 module 还没诞生以前我们也可以利用 commonJS 来进行 module 的导入,为什麽 ES6 module 可以做到 Tree Shaking 可是 commonJS 无法呢?

    其实是因为 ES6 module 有著非常多的特性,让 bundler 可以针对这些特性来进行静态的分析:

    • module 必须要在顶层被 import。
    • module 内部会自动被定义为 strict mode。
    • module name 不能动态改变。
    • module 内容为 immutable 无法在其他文件中被动态新增或删除内容。

    因为这些强限制在,所以 ES6 module 就可以让 bundler 做到 Tree Shaking 的效果,而 commonJS 则无法达到此点。

    tree-shaking 只能在静态 modules 下工作,在 ES6 之前我们使用 CommonJS 规范引入模块,具体采用 require()的方式动态引入模块,这个特性可以通过判断条件解决按需记载的优化问题。但是 CommonJS 规范无法确定在实际运行前需要或者不需要某些模块,所以 CommonJS 不适合 tree-shaking 机制。

    ES6 Module:import()引入模块的方式采用静态导入,可以采用一次导入所以的依赖包再根据条件判断的方式,获取不需要的包,然后执行删除操作。uglify:判断程序流,判断变量是否被使用和引用,进而删除代码。


    webpack

    Tree-shaking (树摇)最早是由 Rollup 实现,是一种采用删除不需要的额外代码的方式优化代码体积的技术,webpack2 借鉴了这个特性也增加了 tree-shaking 的功能。

    webpack 里面自带的 Tree Shaking 功能来帮我们实现,消除无用代码。当我引入一个模块的时候,我不引入这个模块的所有代码,我只引入我需要的代码。

    在 webpack 项目中,有一个入口文件,相当于一棵树的主干,入口文件有很多依赖的模块,相当于树枝。实际情况中,虽然依赖了某个模块,但其实只使用其中的某些功能。通过 Tree-Shaking,将没有使用的模块摇掉,这样来达到删除无用代码的目的。

    webpack5 在环境 production 下打包的话,默认开启 tree-shaking

    开发环境配置webpack.config.js

    module.exports = {
      // ...
      mode: 'development',
      optimization: {
        usedExports: true,
      }
    };
    

    生产环境下的配置webpack.config.js

    module.exports = {
      // ...
      mode: 'production',
    };
    


    rollup

    rollup,另外一个打包工具。配置:

    import babel from 'rollup-plugin-babel'
    import uglify from 'rollup-plugin-uglify'
    
    export default {
      entry: 'src/main.js',
      dest: 'rollup.bundle.js',
      format: 'cjs',
      treeshake: true,
      plugins: [
        babel(),
        uglify()
      ]
    }
    

    上篇:安全

    下篇:无障碍访问