Hybrid性能采集和优化在脉脉APP中的应用

675 阅读33分钟

前言:

相信很多前端小伙伴都会或多或少的接触过「前端性能优化」相关的,可能是对理论有一些了解,可能是在项目中实践过优化,大家也会对性能优化有自己的见解,但是我相信,能系统化、全局对页面性能优化有了解的人少之又少,本篇文章,来自于一个在脉脉做性能优化的前端,在看文章前,可以先体验一下脉脉APP打开新页面的性能,再来看文章,我们是如何优化,本文章涵盖所有前端性能优化方案,提供系统性的解决方案,阅读完全文,你会解答心中的以下疑问:「如何衡量前端页面性能的好坏?」「做性能优化从哪些方面来做?」「如何衡量性能优化的产出?」;文章内容较多,我会标记内容重点记忆和了解两部分,欢迎小伙伴们对于文章中的错误进行评论和纠正,也欢迎补充~~

一、指标采集

1、在Hybrid APP中H5性能的采集时机

我们先从问题入手:

面试官:浏览器从输入URL到页面加载完成,都发生了什么? 相信很多小伙伴们面试会遇到过这样的提问,这篇文章我们从全局角度来解答, 先上一张流程图:

image.png 由H5页面加载流程可得,页面加载分为5个过程:

  1. DNS解析
    • 浏览器DNS缓存
    • DNS prefetch
  2. TCP连接
    • 长连接
    • 预连接
    • 接入SPDY协议
  3. 发送HTTP请求
  4. HTTP响应
  5. 浏览器拿到数据,开始解析内容,展示给用户
    • 资源加载优化
    • 服务器渲染
    • 浏览器缓存机制的利用
    • DOM树构建
    • 网页排版和渲染过程
    • 回流与重绘考量
    • DOM操作合理规避

我们的所谓的用户性能优化,要在这5个过程中水滴不漏的考虑优化方案,反复权衡,从而达到用户满意效果;

正常H5页面加载流程

提起性能指标采集,大家接触最多的莫过于performance.timing下的各个时间戳属性,从网上搜到各式各样的文章也是用这个属性作为示例,这里强调下:这个属性已经快废弃了,取而代之用 通过PerformanceTiming来收集

首先来介绍下PerformanceTiming的各个时间点,在「用户输入URL到看到页面」过程中对应的哪个时间点:

流程图 (7).jpg

Hybrid应用中H5页面加载流程(脉脉)

以上流程图是通用H5加载流程,以下附Hybrid应用中的加载流程

流程图 (9).jpg

2、Hybrid 性能数据采集

都有哪些指标?

这里直接粘贴网上整理比较好的,建议大家了解,不必太过记忆

  1. FP FP(First Paint),第一个描绘在界面的像素点,该指标指示网络请求之后开始的浏览器绘制的旅程。FP在用户的可视方面不一定是有效的,例如我们有可能先绘制一个空的div,也能算作FP,但是对于用户来说仍旧是空空荡荡的界面。它更多的是用来指示绘制流程的开始,也就是说HTTP加载或者js执行等影响界面绘制的因素都已经完全结束。

  2. FCP First Contentful Paint (FCP) ,初次有意义的渲染,所谓有意义,即需要包含部分人类能够识别的信息,例如文字,图片,视频以及非空的canvas内容。这个统计对用户的预期评估有部分重要意义。人们在网页上浏览是为了获取信息,而以上元素内容无疑包含了大量预期的信息。

  3. LCP(下面会详细介绍,这个划重点) Largest Content Paint,LCP该指标衡量的是网页上最大的指定类型的元素节点渲染出来的时间点。谷歌团队基于自己的研究和W3C Web Performance Working Group,得出了最大元素,尤其包含文字和图片的元素更容易吸引读者的目光。因此才把此标准纳入web-vitals。这些元素如下所示:

  4. CLS Cumulative Layout Shift,CLS表示从内容加载出来后整体网页的偏移量。大多数通过异步加载出来的网页上的元素会因为http的时间延迟,在结果被渲染出来之时产生界面位置偏移,这种偏移就叫做CLS。CLS确实对于用户的视觉感和体验是非常差的,有一个典型的示例就是对于延迟加载的广告会经常被用户误点到,这种体验非常差。你可以看下面这个短暂的视频,本来用户是打算去点“No, go back”的,结果,“啪”,很快啊,被上面加载的广告图坑了。

  5. FID

First Input Delay,FID首次输入延迟。检测的是首次开始参与输入的时间点,这段时间表示的是用户对网页的交互程度。FID的快慢影响这用户对用网页操作的预期。

  1. TBI Total Blocking Time,TBT总计被阻断的时间,指的其实是计算FCP到TTI之间的时间段,帮助开发者粗略地,分析相关的代码影响点在哪里。TBT主要是被长任务(long task)的影响阻断,和TTI有很强的关联关系。

  2. TTI

Time To Interactive,TTI可交互时间。指的是用户与网页交互的时间。很多时间,例如长任务,或者进行加载的网络请求会影响用户和网页的交互。

待优化排行榜用什么指标(LCP)?

这么多性能指标,我们最终用哪个指标来衡量我们页面的好坏,或者用什么指标来排列哪些急需优化? 我们最终使用的是LCP:

  • 什么是LCP?

    • 上面已经介绍过LCP,这里划重点,简而言之:Largest Contentful Paint 最大内容渲染,重点关注:LCP是一个变化的值,随着加载过程中最大元素的变化而变化image.png
  • 为什么用LCP作为最终指标?

    • 结论:LCP是能更好代表用户体验的指标中最不复杂的解决方案

      为什么是LCP.jpg

    • 简单计算指标: 例如DOMContentLoaded(DOM 内容加载完毕)这样的旧有指标并不是很好,因为这些指标不一定与用户在屏幕上看到的内容相对应。 而像First Contentful Paint 首次内容绘制 (FCP)这类以用户为中心的较新性能指标只会捕获加载体验最开始的部分。如果某个页面显示的是一段启动画面或加载指示,那么这些时刻与用户的关联性并不大。

    • 复杂计算指标: 例如Cumulative Layout Shift 累积布局偏移 (CLS),这些指标有助于捕获到更多初始绘制后的加载体验,但这些指标十分复杂、难以解释,而且常常出错,也就意味着这些指标仍然无法识别出页面主要内容加载完毕的时间点。

    • LCP是处于两者之间,计算较为简单,并且能初步的判断用户体验,简而言之:LCP是能更好代表用户体验的指标中最不复杂的解决方案

  • 第三方库如何收集?

    • sentry:sentry库中的性能收集也是基于web-vitals源码的,所以会着重说一下web-vitials;

    • web-vitials:性能收集评分最高的要属web-vitials了,但是其并不适用于移动端,尤其是Hybrid的;

    • 底层使用PerformanceObserver,该api不支持IOS,部分低端安卓机型也不支持;

      image.png

  • 我们是怎么计算的?

    • 改写web-vitials库,performanceObserver收集不到的,使用mutationObserver收集

    • 整体思路:

      采集实现流程图.jpg

    • mutationObserver是收集DOM变化的API,通过mutationObserver收集部分的代码改写如下:

      // 注册observer
      const mo = new MutationObserver(eventHandler);
      
      // 监听回调
        const eventHandler = (mutationsList: MutationRecord[]): void => {
          mutationsList.forEach((mutation: MutationRecord) => {
            // 循环mutation收到的元素变化,空元素过滤掉
            if (mutation.type !== 'childList') return;
            if (mutation.addedNodes.length === 0) return;
            const viewport_current = getNodeViewport(mutation);
            const area_current = getNodeArea(viewport_current.width, viewport_current.height);
            if (!isInScreen(mutation)) return;
            if (area_current === 0) return;
      
            // 使用页面中出现的文字字数来模拟 lcp
            if (biggest_area === 0 || area_current > biggest_area) {
              biggest_area = area_current;
              const value = performance.now();
              if (value < visibilityWatcher.firstHiddenTime) {
                metric.value = value;
                // web-vitials里面的上报方法
                metric.entries.push({
                  startTime: performance.now(),
                  duration: 0,
                  entryType: '',
                  name: '',
                  toJSON: function() {
                    throw new Error('Function not implemented.');
                  },
                });
              }
              if (report) {
                report();
              }
            }
          });
        };
      
      
      // 用户行为时断开observer
          ['keydown', 'click', 'touchmove'].forEach(type => {
            addEventListener(type, stopListening, { once: true, capture: true });
          });
      
  • 自己用mutationObserver收集到的性能数据是否准确?

    • 结论:偏差不大基本可信;

    • 数据支撑:在我们灰度上,同时开启mutationObserver和PerformanceObserver收集性能,标记LCP来源,停留了两周,拿到了2W PV得到的两个LCP差值绝对值,分布区域做对比得到下图,可得出,偏差在0~0.5s之间的占比达到了85%,所以可用。

      image.png

3、对于sentry/tracing源码的改写,使其适合Hybrid采集

  • 我们这边本身已经接入sentry,sentry接受性能收集,所以我们不单独接入web-vitials,但是sentry中性能收集也是使用的web-vitials源码,所以看你们自己的业务 ,决定接什么库;
  • 改写内容:
    • 改写sentry/tracing打包流程:首先需要拉取sentry-javascript源码,我们选取了sentry/tracing这个包进行改写,注意这里不是直接在node_modules产物中改写的,是fork的源码改写,打包中也遇到了问题,注意本地node和yarn版本(感兴趣的可以留言,我出这部分改写源码内容以及打包流程,这里不详述);
    • 改写LCP收集:在getLCP文件中对于不兼容PerformanceObserver的转到mutationObserver中收集;
    • 观测数据,修正问题,上线:

二、理论篇--前端通用优化方案

1、大纲

面试官:浏览器从输入URL到页面加载完成,都发生了什么?

image.png 由H5页面加载流程可得,页面加载分为5个过程:

  1. DNS解析:如何减少解析次数,或者把解析前置?
    • 浏览器DNS缓存
    • DNS prefetch
  2. TCP连接:TCP连接每次握手都急死人,减少时间?
    • 长连接
    • 预连接
    • 接入SPDY协议
  3. 发送HTTP请求:如何减少请求次数和体积?
  4. HTTP响应
  5. 浏览器拿到数据,开始解析内容,展示给用户
    • 资源加载优化
    • 服务器渲染
    • 浏览器缓存机制的利用
    • DOM树构建
    • 网页排版和渲染过程
    • 回流与重绘考量
    • DOM操作合理规避

那么要想提高用户体验,我们所谓的性能优化,必须在以上五个过程中水滴不漏的考虑在我们性能优化方案内,反复权衡优化,达到用户满意。

前端主要涉及两个部分,网络层和渲染层,接下来,我从这两大方面给大家提供优化方案;

image.png

2、网络层

减少请求资源的大小

1. 构建工具性能调优

  • 优化方向:

    • 构建速度太快(For提效目标);
    • 构建出来的包体积太大(For性能优化目标);
  • 优化示例:

    • 通过分析工具webpack-bundle-analyzer找出导致体积过大的原因;
    • 拆分资源
    • 用tree-shaking删除冗余代码 针对性比较强,适合用来处理模块级别的冗余代码,至于粒度更细的冗余代码的去除,往往会被整合进JS或CSS的压缩或分离过程中
    • 按需加载 比如用router来控制路由,十个路由对应了十个页面,每个页面都是非常复杂的,如果把整个项目打包成一个包,用户打开网站时,不是直接卡死了吗

    一次性不加载完所有文件内容,只加载此刻需要用到的那部分(会提前做拆分)当需要更多内容时,再对用到的内容进行即时加载

    需要思考的根本是:如何在正确的时机去触发相应的回调?

    React-Router4中,是使用 Code-Splitting ,本质还是通过require.ensure来实现的回调

    假设,不需要按需加载时,代码是这样的

    import BugComponent from '../pages/BugComponent'
    ...
    <Route path="/bug" component={BugComponent}>
    

    开启按需加载,代码是这样的

    // 配置
    output: {
        path: path.join(__dirname, '/../dist'),
        filename: 'app.js',
        publicPath: defaultSettings.publicPath,
        // 指定 chunkFilename
        chunkFilename: '[name].[chunkhash:5].chunk.js',
    },
    
    // 路由内容
    const getComponent => (location, cb) {
      // 核心位置
      require.ensure([], (require) => {
        cb(null, require('../pages/BugComponent').default)
      }, 'bug')
    },
    ...
    <Route path="/bug" getComponent={getComponent}>
    
    • 以UglifyJsPlugin为例,看一下如何在压缩过程中对碎片化的冗余代码进行自动化删除:
    const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
    module.exports = {
     plugins: [
       new UglifyJsPlugin({
         // 允许并发
         parallel: true,
         // 开启缓存
         cache: true,
         compress: {
           // 删除所有的console语句    
           drop_console: true,
           // 把使用多次的静态值自动定义为变量
           reduce_vars: true,
         },
         output: {
           // 不保留注释
           comment: false,
           // 使输出的代码尽可能紧凑
           beautify: false
         }
       })
     ]
    }
    

    手动引入是Webpack3的用法,在Webpack4中,已经默认使用 uglifyjs-webpack-plugin 对代码做压缩。 在 webpack4 中,我们是通过配置 optimization.minimize 与 optimization.minimizer 来自定义压缩相关的操作的。

  • 扩展问题(面试官深挖的点)

    • Vite为什么比webpack快?
    • tree-shaking如何实现的?
    • Webpack打包产物是什么?
    • 按需加载和同步加载的产物的差异?

2. Gzip压缩

  • 怎么实现?
    • request Header中加上一句:accept-encoding:gzip
  • 定义:
    • HTTP压缩,就是以缩小体积为目的,对HTTP内容进行重新编码的过程;
    • 是一种内置到网页服务器和网页客户端,以改进传输速度和带宽利用率的方式;
    • Gzip的内核就是Deflate,目前我们压缩文件用的最多的就是Gzip,可以说,Gzip就是HTTP压缩的经典案例;
  • 原理:
    • 在一个文本文件中,找出一些重复出现的字符串,临时替换他们,从而使整个文件变小;
    • 那么,文件中,代码重复率越高,压缩的效率就越高;
    • 服务器去消耗CPU和压缩时间开销,为代价,换取传输过程中的时间开销;
  • 你的项目是否需要使用?
    • 服务端压缩,需要花时间,浏览器解压,需要花时间,那么中间节省出来的传输时间,真的管用吗?
    • 由原理可得,如果你项目只有1k2k的,那是有点大材小用;
    • Gzip是高效的,压缩后,通常能帮我们节省70%的响应时间
  • 应用:
    • webpack中的Gzip压缩,就是为了在构建过程中去做一部分服务器的工作,为服务器分压

3. 图片优化

我的大部分性能优化工作都集中在 JavaScript 和 CSS 上,从早期的 Move Scripts to the Bottom 和 Put Stylesheets at the Top 规则。为了强调这些规则的重要性,我甚至说过,“JS 和 CSS 是页面上最重要的部分”。几个月后,我意识到这是错误的。图片才是页面上最重要的部分。 我关注 JS 和 CSS 的重点也是如何能够更快地下载图片。图片是用户可以直观看到的。他们并不会关注 JS 和 CSS。确实,JS 和 CSS 会影响图片内容的展示,尤其是会影响图片的展示方式(比如图片轮播,CSS 背景图和媒体查询)。但是我认为 JS 和 CSS 只是展示图片的方式。在页面加载的过程中,应当先让图片和文字先展示,而不是试图保证 JS 和 CSS 更快下载完成 ----- 引用自《高性能网站建设》

  • 优化本质

    • 我们虽然是在做图片优化,但是说白了,就是在压缩图片,牺牲图片质量,来换取性能,我们如何能找到质量和性能之间的那个平衡点
  • 图片方案选型

    图片类型特点简介优点缺点适用场景
    JPEG/JPG有损压缩、当我们把图片体积压缩至原有体积的50%以下,图片仍然可以保持住60%的品质,JPG格式是24位存储单个图,可以呈现多达1600万种颜色,足以应对大多数场景下对色彩的要求,这一点决定了它压缩前后的质量损耗并不容易被我们发现体积小、加载快不支持透明,当它处理矢量图形和Logo这种线条感较强、颜色对比强烈的图像时,人为压缩导致的图片模糊会相当明显大背景图、轮播图、Banner图使用JPG,体积减少同时,又保证了质量,150K以下最好
    PNG无损压缩支持透明质量高、体积大追求最佳显示效果,不在意文件大小,推荐使用 PNG-24,主Logo、小Logo、颜色简单、对比度明显的透明小图也在PNG格式下有着良好的表现
    SVG文本文件,是一种基于XML语法的图片格式,SVG对图像的处理不是基于像素点,而是基于对图像的形状描述。体积小,不失真,和PNG、JPG相比,压缩性更强,体积更小渲染成本比较高骨架图等矢量图
    Base64是一种用于传输8bit字节码的编码方式,通过对图片进行Base64编码,我们可以直接将编码结果写入HTML或者写入CSS,从而减少HTTP请求的次数,并非图片格式,而是一种编码方式依赖编码,小图标解决方案,字符串虽然非常长,但是不需要再去发送HTTP请求大图不适合换成Base64,如果把大图也编码到HTML或者CSS文件中,后者体积会明显增加,即使我们减少了HTTP请求,也无法弥补这庞大的体积带来的性能开销,得不偿失2K以下图片,节省掉的HTTP请求开销,是非常值得的,图片的更新频率非常低(这样就不需要我们频繁编码)降低维护成本,可以借助webpack url-loader
    WebP为了加快图片加载速度的图片格式,支持无损压缩和有损压缩,与PNG相比,WebP无损图像的尺寸缩小了26%,在等效的SSIM质量指数下,WebP有损图像比同类JPEG无损图像小25%-34%既支持透明,也可以显示动图,缺点:局限性,只有Chrome支持一个图片,兼容两种jpg和WebP两种格式,程序根据浏览器型号、以及该型号是否支持WebP这些信息来决定当前浏览器显示的是.webp还是.jpg
  • 面试官常问:拓展:

    • url-loader file-loader css-loader 做什么事情?以及加载顺序?

4. 动画选型

  • Gif:
    • 有毛边,如果UI可以接受,优先选这个;
  • CSS3帧动画
    • 不适合帧数过多(40帧以上)的长图,首帧会卡顿
  • Lottie动画
    • 高清帧数较多的动画,优先Lottie

减少网络请求

1. 充分利用缓存

浏览器缓存是一种操作简单、效果显著的性能优化手段,缓存、并 重复利用之前获取资源的能力,变得非常重要(增量发布)

  • 不要让loader做太多事

    • 以babel-loader为例,babel-loader无疑是强大的,但它也是慢的;

      // 规避了对庞大的node_modules|bower_components的处理
      module: {
        rules: [
          {
            test: /\.js$/,
            exclude: /(node_modules|bower_components)/,
            use: {
              loader: 'babel-loader',
              options: {
                presets: ['@babel/preset-env']
              }
            }
          }
        ]
      }
      

      但是仅仅通过限制文件范围带来的性能提升是有限的; 还可以,选择开启缓存将转义结果缓存至文件系统,则至少可以将babel-loader的工作效率提升两倍

      loader: 'babel-loader?cacheDirectory=true'
      

      局限性:以上规则仅作用域babel-loader,像是UglifyJsPlugin 的webpack插件在工作时,依然会被这些庞大的第三方库拖累,所以还需要想其他办法来解决第三方库的问题

  • dllPlugin

    • 背景:node_module是典型的第三方库,必不可少,却庞大的可怕;

    • 什么是dllplugin?是基于Windows动态链接库(dll)的思想被创建出来的,会把第三方的库单独打包到一个文件中,这个文件就是一个单纯的依赖库,这个依赖库不会跟着你代码的更改而重新的打包更改,只有当依赖自身发生版本变化时,才会重新打包;

    • DllPlugin 处理文件步骤:基于dll专属的配置文件,打包dll库;基于webpack.config.js文件,打包业务代码

    • 如何配置?(运行配置文件后,dist文件夹会出现两个文件 vendor.js、vendor-manifest.json)

      const path = require('path')
      const webpack = require('webpack')
      module.exports = {
      entry: {
      // 依赖的库数组
      vendor: [
      'prop-types',
      'babel-polyfill',
      'react',
      'react-dom',
      'react-router-dom',
      ]
      },
      output: {
      path: path.join(__dirname, 'dist'),
      filename: '[name].js',
      library: '[name]_[hash]',
      },
      plugins: [
      new webpack.DllPlugin({
      // DllPlugin的name属性需要和libary保持一致
      name: '[name]_[hash]',
      path: path.join(__dirname, 'dist', '[name]-manifest.json'),
      // context需要和webpack.config.js保持一致
      context: __dirname,
      }),
      ],
      
      const path = require('path');
      **const** webpack = require('webpack')
      module.exports = {
      mode: 'production',
      // 编译入口
      entry: {
      main: './src/index.js'
      },
      // 目标文件
      output: {
      path: path.join(__dirname, 'dist/'),
      filename: '[name].js'
      },
      // dll相关配置
      plugins: [
      new webpack.DllReferencePlugin({
      context: __dirname,
      // manifest就是我们第一步中打包出来的json文件
      manifest: require('./dist/vendor-manifest.json'),
      })
      ]
      }
      
  • Happypack--将loader由单进程转为多进程

    • 由于webpack是单进程的,任务太多,你就得排队执行;

    • CPU是多核的,Happypack会充分释放CPU在多核方面的优势,帮我们把任务分解给多个子进程去并发执行;

    • 转移loader的配置到HappyPack中,并且可以手动告诉它,我们需要多少个并发的进程;

      const HappyPack = require('happypack')
      // 手动创建进程池
      const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length })
      module.exports = {
      module: {
      rules: [
      ...
      {
      test: /\.js$/,
      // 问号后面的查询参数指定了处理这类文件的HappyPack实例的名字
      loader: 'happypack/loader?id=happyBabel',
      ...
      },
      ],
      },
      plugins: [
      ...
      new HappyPack({
      // 这个HappyPack的“名字”就叫做happyBabel,和楼上的查询参数遥相呼应
      id: 'happyBabel',
      // 指定进程池
      threadPool: happyThreadPool,
      loaders: ['babel-loader?cacheDirectory']
      })
      ],
      }
      

2. 浏览器存储技术

  • Memory cache(内存存储)

    • 概念:内存中的缓存,响应速度最快的一种缓存,浏览器最先尝试去命中;
    • 缺点:快,但是短暂,和渲染进程生死相依,tab关闭后,内存里数据将不复存在;
    • 存储时机:这个划分规则,一直没有定论,以下可能会被存储进入内存中:
      • 节约原则,内存有限,缓存前需要考虑余量,大文件不存储在这里;
      • Base64格式的图片,几乎永远可以被塞进Memory Cache,可是视作浏览器为节省渲染开销的自保行为;
      • 渲染引擎:在渲染当前文档时,抓取的所有资源都存储在内存中,并且在文档生命周期内一直存在;
      • 预加载:<link rel="preload">指令;
      • 之前的DOM阶段或者CSS规则引起的请求,
      • 网络请求在disk cache中存储的位置会被加入到memory cache中;
    • 读取时机:取决于优先级,如果都查找不到,就走网络请求,Service Worker->Memory Cache->->Disk Cache->网络请求
  • Disk cache(硬盘存储) 即 HTTPcache

    • Http Cache产生的缓存,存储在disk cache 中;

    • 强缓存(优先级较高)

      • Cache-Control

      通过max-age字段来控制资源有效时间长度,可以视为是对expires的补充和替换,并且可以有效规避Expires带来的时间差不同导致的问题;

      HTTP1.1中,想要把缓存相关都收敛到Cache-Control中;

      但是如果你的页面有强向下兼容的需求,需要使用Expires

      Cache-Control和Expires同时出现时,Cache-Control优先级更高

      配置信息解析
      max-age
      s-maxage优先级高于max-age,为了解决代理服务器的缓存问题,用于表示在cache服务器上(比如cache CDN)的缓存的有效时间的,并只对public缓存有效,如果s-maxage未过期,则向代理服务器请求其缓存内容,仅仅在代理服务器中生效,客户端中我们只考虑max-age
      public与private针对资源是否能够被代理服务缓存而存在的一个概念,private:只能被浏览器缓存(默)、public:浏览器+代理服务器缓存都可
      no-store与no-cacheno-cache:绕开了浏览器,每一次发起请求,都不会再去询问浏览器的缓存情况,而是直接向服务端确认该资源是否过期(即,协商缓存路线)只绕开本地缓存,询问服务器,no-store:比较绝情,不使用任何缓存策略,绕开本地缓存、绕开向服务端询问,直接拿新的资源
    • 协商缓存 在命中强缓存失败情况下,才会走协商缓存

    如果服务端提示缓存资源没改动,请求会被重定向到浏览器缓存,这种情况下对应statuscode为304;

    浏览器会向服务器去询问,缓存的相关信息,从而判断是否重新发起请求、下载完整的响应,还是从本地读取;

    是浏览器和服务器合作之下的缓存策略,依赖于服务端和浏览器的通信;

    image.png

    HTTP缓存决策方案.png

  • ServiceWorker cache

    • 概念:Service Worker 是一种独立于主线程之外的JS线程,生命周期包括install、active、working三个阶段,一旦Service Worker被install,它将始终存在,只会在active与working之间切换,除非我们主动终止它,也是我们用来实现离线存储的重要先决条件。

    • 脱离浏览器窗体,无法直接访问DOM,所以它无法干扰页面的性能,可以帮我们实现离线缓存、消息推送、网络代理等功能,我们通过Service Worker实现的离线缓存就称为ServiceWorker Cache,和Memory Cache不同的是,没有任何预设的规则,完全取决于开发者,是持久化的,即使tab关闭和浏览器重启,都会存在,使用前提:必须是https协议;

    • 实现方式 举例

      // 首先在入口文件插入一段JS代码,用于判断和引入Service Worker
      window.navigator.serviceWorker.register('/test.js').then(
         function () {
            console.log('注册成功')
          }).catch(err => {
            console.error("注册失败")
      })
      // 然后在test.js中,进行缓存处理,假设我们需要缓存的文件是test.html test.css test.js
      // Service Worker会监听 install事件,我们在其对应的回调里可以实现初始化的逻辑  
      self.addEventListener('install', event => {
        event.waitUntil(
          // 考虑到缓存也需要更新,open内传入的参数为缓存的版本号
          caches.open('test-v1').then(cache => {
            return cache.addAll([
              // 此处传入指定的需缓存的文件名
              '/test.html',
              '/test.css',
              '/test.js'
            ])
          })
        )
      })
      // Service Worker会监听所有的网络请求,网络请求的产生触发的是fetch事件,我们可以在其对应的监听函数中实现对请求的拦截,进而判断是否有对应到该请求的缓存,实现从Service Worker中取到缓存的目的
      self.addEventListener('fetch', event => {
        event.respondWith(
          // 尝试匹配该请求对应的缓存值
          caches.match(event.request).then(res => {
            // 如果匹配到了,调用Server Worker缓存
            if (res) {
              return res;
            }
            // 如果没匹配到,向服务端发起这个资源请求
            return fetch(event.request).then(response => {
              if (!response || response.status !== 200) {
                return response;
              }
              // 请求成功的话,将请求缓存起来。
              caches.open('test-v1').then(function(cache) {
                cache.put(event.request, response);
              });
              return response.clone();
            });
          })
        );
      });
      
  • Push cache

    • 概念:Push Cache是指HTTP2在server push阶段存在的缓存,不是很健壮
    • 特性:Push Cache是缓存的最后一道防线,浏览器只有在Memory Cache/HTTP Cache/Service Worker Cache均未命中的情况下才去询问Push Cache,Push Cache是一种存在于会话阶段的缓存,当session终止时,缓存也随之释放,不同页面只要共享HTTP2连接,就可以共享一个Push Cache。

3. 离线存储技术

  • Cookie

    • 特点:小,4K,紧跟域名,也就是,同以一域名下的所有请求,都会携带Cookie值;
    • 其实把cookie罗列在这里,属实有点牵强,它是“维持状态”,并不是用来存储,但是早起Cookie出现,是会存储一些信息的;
    • 属性和方法:Http only、domain、expires/Max-age、Size、secure、path、sameSite、Priority;
  • Web Storage

    • 目的:为了解决Cookie存储问题,WebStorage是HTML5专门为浏览器存储而提供的数据存储机制;
    • 分类:Local Storage、Session Storage
    分类特点存储时长跨页面共享应用
    local Storage不与服务器端发生通信持久化本地存储,只有手动删除时候才会没同源策略可以用来存储一些Base64格式的图片字符串
    Session Storage不与服务器端发生通信临时性本地存储,关了页面就没了同源策略,但,相同域名下的两个页面,只要不在同一窗口打开,就永远不能共享可以用来存储一些会话级别的信息
  • IndexedDB

    • 运行在浏览器中的非关系型数据库;

    • 应用场景?当数据的复杂度和规模上升到了LocalStorage无法解决的程度,可以用IndexDB来帮忙,创建多个数据库,一个数据库可以创建多张表,一个表中存储多条数据等等。

    • 如何使用?

      • 1、创建/打开 一个IndexedDB数据库
        // 后面的回调中,我们可以通过event.target.result拿到数据库实例
        let db
        // 参数1位数据库名,参数2为版本号
        const request = window.indexedDB.open("xiaoceDB", 1)
        // 使用IndexedDB失败时的监听函数
        request.onerror = function(event) {
           console.log('无法使用IndexedDB')
         }
        // 成功
        request.onsuccess  = function(event){
          // 此处就可以获取到db实例
          db = event.target.result
          console.log("你打开了IndexedDB")
        }
      
      • 2、创建一个object store
      // onupgradeneeded事件会在初始化数据库/版本发生更新时被调用,我们在它的监听函数中创建object store
      request.onupgradeneeded = function(event){
        let objectStore
        // 如果同名表未被创建过,则新建test表
        if (!db.objectStoreNames.contains('test')) {
          objectStore = db.createObjectStore('test', { keyPath: 'id' })
        }
      }  
      
      • 3、构建事务来执行一些数据库操作,像增加或提取数据等
        // 创建事务,指定表格名称和读写权限
        const transaction = db.transaction(["test"],"readwrite")
        // 拿到Object Store对象
        const objectStore = transaction.objectStore("test")
        // 向表格写入数据
        objectStore.add({id: 1, name: 'xiuyan'})
      
      • 4、通过监听正确类型的事件以等待操作完成
        // 操作成功时的监听函数
        transaction.oncomplete = function(event) {
          console.log("操作成功")
        }
        // 操作失败时的监听函数
        transaction.onerror = function(event) {
          console.log("这里有一个Error")
        }
      
      
  • 扩展:面试官常问

    • 单点登录的实现方式?
    • CORS如何携带cookie?
    • 怎么监听local Storage的变化?
    • 通过window.open打开当前页,新开的标签页是否携带session storage的值?
    • window.open和<a href="">跳转的区别?

3、渲染层

服务端/客户端渲染的探索与实践

1. 运行机制

  • 客户端渲染:服务器把需要渲染的静态文件给客户端,客户端渲染后给webview;
  • 服务端渲染:由服务器把需要的组件或者字符串渲染成功后给浏览器;

2. 解决了什么性能问题

  • 搜索引擎优化
  • 首屏加载速度过慢

3. 服务端渲染的应用实例与使用场景:

  • 实际应用:分担给服务器的压力太大了,如果不是对性能由非常高要求的页面,建议使用其他方案去优化性能,而不是把渲染压力直接给到稀少的服务器;
  • 拓展应用:如果在移动端这种情况,可以考虑由移动端渲染;

浏览器渲染机制解析

  • 浏览器内核

    • 作用:渲染引擎、JS引擎 image.png
    • 分类:

    image.png

  • 浏览器渲染

    • 注意:Render Tree一旦生成,如果再插入元素,会重新走生成DOM tree到paint的过程 image.png
  • 基于CSS优化,能做些什么?

    • 规则:
      • CSS引擎查找样式表,是从右到左进行
      • #myList li {}:实际执行顺序是,遍历页面所有的li,看下每个li的id是不是myList
      • * {}:实际执行顺序是,浏览器必须遍历每个元素,赋予你给的样式
    • 建议:
      • 避免使用通配符;
      • 关注通过继承实现的属性,避免重复匹配,重复定义;
      • 少用标签选择器,用类选择器替换;
      • id和calss选择器不应该被多余的标签选择器拖后腿;
      • 减少嵌套,后代选择器是开销最高的,尽量将选择器的深度降到最低,最好不超过三层;
  • CSS和JS的加载顺序优化

    • 前提:HTML、JS、CSS都会阻塞渲染,浏览器为了让用户看到已经渲染完后的完整页面,所以会让CSSOM的解析,完成后再进行渲染,多数情况下HTML生成DOM tree后,会等待CSSOM的解析,
    • JS阻塞:JS会阻塞CSSOM,在我们不做显示声明情况下,它也会阻塞DOM的生成,浏览器不让js和渲染过程同时进行,是因为浏览器不知道你在js里面做了什么事情,如果做的事情跟即将进行的渲染有了冲突,那就会造成混乱,浏览器为了避免混乱,
    • 建议:
      • 减少HTML的变更;
      • 尽快、尽早执行CSS:将CSS放到head标签中,把CSS静态资源放到CDN中;
      • 我们写的js,如果已知其不会影响渲染过程,可以通过defer和async来避免不必要的阻塞;
    • JS加载模式:
      • 正常模式:阻塞渲染过程
      • async模式:JS不阻止浏览器做其他事情,加载过程是异步,加载结束后,js会立即执行,当我们的脚本和其他依赖不强,会选择async;
      • defer模式:JS不阻止浏览器做其他事情,加载过程是异步的,执行是被推迟的,等整个文档解析完成、DOMContentLoaded 事件即将被触发时,被标记了 defer 的 JS 文件才会开始依次执行,当我们的脚本依赖最终DOM生成结果时,会选择defer;

DOM优化

1. 原理

  • DOM操作为什么慢?

  • 优化方案?

    尽量减少DOM操作 --- 雅虎军规

  • DocumentFragment介绍:

    • 定义:表示一个没有父级文件的最小文档对象,它被当作一个轻量版的Document对象使用,用来存储尚未排版好或者尚未打理好的XML片段,它的变化不会引起DOM的改变,因为它不是DOM的一部分,且不会导致性能问题,用于缓存批量DOM操作, 当我们试图将其 append 进真实 DOM 时,它会在乖乖交出自身缓存的所有后代节点后全身而退,完美地完成一个容器的使命,而不会出现在真实的 DOM 结构中,这一点在 jQuery、Vue 等优秀前端框架的源码中均有体现。
  • 举例:假如有个需要,想往 container 元素里写 10000 句一样的话:

    //bad
    for(var count=0;count<10000;count++){ 
      document.getElementById('container').innerHTML+='<span>我是一个小测试</span>'
    } 
    
    // not bad
    // 只获取一次container
    let container = document.getElementById('container')
    for(let count=0;count<10000;count++){ 
      container.innerHTML += '<span>我是一个小测试</span>'
    } 
    
    // good
    let container = document.getElementById('container')
    let content = ''
    for(let count=0;count<10000;count++){ 
      // 先对内容进行操作
      content += '<span>我是一个小测试</span>'
    } 
    // 内容处理好了,最后再触发DOM的更改
    container.innerHTML = content
    
    
    // better
    let container = document.getElementById('container')
    // 创建一个DOM Fragment对象作为容器
    let content = document.createDocumentFragment()
    for(let count=0;count<10000;count++){
      // span此时可以通过DOM API去创建
      let oSpan = document.createElement("span")
      oSpan.innerHTML = '我是一个小测试'
      // 像操作真实DOM一样操作DOM Fragment对象
      content.appendChild(oSpan)
    }
    // 内容处理好了,最后再触发真实DOM的更改
    container.appendChild(content)
    

2. 回流与重绘

  • 定义:
    • 回流:
      • 操作元素几何属性:width、height、top等;
      • 改变DOM树的操作;
      • 获取一些特定属性的值(一些需要浏览器即时计算才能给你的值)时会导致回流;
    • 重绘:
      • 样式的更改,并未影响到几何属性,比如更换了背景图,更改了颜色;
      • 重绘不一定导致回流,但是回流一定会引起重绘;
      • 回流开销更大;
  • 规避回流
    • 缓存,使用JS变量缓存 举例;

      原始:

        // 获取el元素
        const el = document.getElementById('el')
        // 这里循环判定比较简单,实际中或许会拓展出比较复杂的判定需求
        for(let i=0;i<10;i++) {
            el.style.top  = el.offsetTop  + 10 + "px";
            el.style.left = el.offsetLeft + 10 + "px";
        }
      

      优化后

      // 获取el元素
      const el = document.getElementById('el')
      let offLeft = el.offsetLeft, offTop = el.offsetTop
       // 在JS层面进行计算
      for(let i=0;i<10;i++) {
        offLeft += 10
        offTop  += 10
      }
      // 一次性将计算结果应用到DOM上
      el.style.left = offLeft + "px"
      el.style.top = offTop  + "px"
      
    • 避免逐条改变样式,使用类名去合并样式;

      原始:

      const container = document.getElementById('container')
      container.style.width = '100px'
      container.style.height = '200px'
      container.style.border = '10px solid red'
      container.style.color = 'red'
      

      优化后

        <style>
          .basic_style {
            width: 100px;
            height: 200px;
            border: 10px solid red;
            color: red;
          }
        </style>
        <script>
        const container = document.getElementById('container')
        container.classList.add('basic_style')
        </script>
      
    • 将DOM离线:我们所谓的回流或者是重绘,都是基于“该元素在在页面上”,所谓的离线化,就是把DOM元素设置为display:none,让它离开页面,再去进行操作,我们所谓的回流或者是重绘,都是基于“该元素在在页面上”,所谓的离线化,就是把DOM元素设置为display:none,让它离开页面,再去进行操作

      原始:

      const container = document.getElementById('container')
      container.style.width = '100px'
      container.style.height = '200px'
      container.style.border = '10px solid red'
      container.style.color = 'red'
      ...(省略了许多类似的后续操作)
      

      优化后:

      let container = document.getElementById('container')
      container.style.display = 'none'
      container.style.width = '100px'
      container.style.height = '200px'
      container.style.border = '10px solid red'
      container.style.color = 'red'
      ...(省略了许多类似的后续操作)
      container.style.display = 'block'
      
  • Flush队列
    • 浏览器自己缓存了一个Flush队列,把我们触发的回流和重绘塞进去,待到队列里的任务多起来、或者达到了一定的时间间隔,再将这些一起去更新,
    • 更新时机:在不得已的时候更新,获取属性的时候会更新;
    • 我们做优化的必要性就是,虽然浏览器提供了flush队列,但并不是所有浏览器都是聪明的,而且,每个浏览器的更新策略不尽相同,我们需要有自己的缓存策略和优化方案学会从根本解决问题
  • 扩展:面试官常问:
    • Vue中的v-if和v-show什么区别?

3. EventLoop与异步更新策略

  • 实践: 在Vue和React中都有实践,虽然实现方式不尽相同,但是都达到了减少DOM操作、避免过度渲染的目的;
  • 原理
    • 微任务与 宏任务;
      • 微任务:Promise、process.nextTick、MutationObserver
      • 宏任务:setTimeout/setInterval、setImmediate、script(整体代码)、I/O操作、UI渲染;
    • 过程解析
      • 1 初始化态:调用栈空,微任务队列空,宏任务队列只有一个script脚本;
      • 2 全局上下文被推入调用栈,同步代码执行,执行过程中,会将微任务和宏任务推入各自任务队列里,这个过程本质上是宏任务的执行和出队的过程;
      • 3 上一步我们出队的是一个 macro-task,这一步我们处理的是 micro-task,处理微任务队列:宏任务是一个一个执行,执行后出队,微任务是一队一队执行,直到栈清空为止;
      • 4 执行渲染页面,更新界面;
      • 5 检查是否存在 Web worker 任务,如果有,则对其进行处理。
    • 问题思考:假如我想要在异步任务里进行DOM更新,我该把它包装成 micro 还是 macro 呢?
      • 如果是macro:现在 task 被推入的 macro 队列。但因为 script 脚本本身是一个 macro 任务,所以本次执行完 script 脚本之后,下一个步骤就要去处理 micro 队列了,再往下就去执行了一次 render。一次render并没有执行我刚才的DOM更新;
      • 如果是micro:那么我们结束了对 script 脚本的执行,是不是紧接着就去处理 micro-task 队列了?micro-task 处理完,DOM 修改好了,紧接着就可以走 render 流程了——不需要再消耗多余的一次渲染,不需要再等待一轮事件循环,直接为用户呈现最即时的更新结果。
      • 结论:当我们需要在异步任务中实现 DOM 修改时,把它包装成 micro 任务是相对明智的选择。
    • 生产实践(Vue中的异步更新)
      • 什么是异步更新?当我们使用Vue或者React去更新DOM的时候,并不会立即更新,而是会被推到一个队列中,等待适应的时机,队列中的更新任务会被批量触发

      • 异步更新的特性在于,它只看结果,其中的过程,不需要渲染引擎为我买单;

      • 使用:

        image.png

  • 拓展:面试官常问
    • Node和浏览器中的事件循环的区别?
    • Vue中proces.nexttick怎么实现,通过什么封装?

首屏渲染提速

1. 懒加载

  • 什么是懒加载?针对图片加载时机的优化,一下子将所有图片都加载并且渲染完成,避免不了会有白屏的现象;
  • 怎么实现?
    // 获取所有的图片标签
    const imgs = document.getElementsByTagName('img')
    // 获取可视区域的高度
    const viewHeight = window.innerHeight || document.documentElement.clientHeight
    // num用于统计当前显示到了哪一张图片,避免每次都从第一张图片开始检查是否露出
    let num = 0
    function lazyload(){
        for(let i=num; i<imgs.length; i++) {
            // 用可视区域高度减去元素顶部距离可视区域顶部的高度
            let distance = viewHeight - imgs[i].getBoundingClientRect().top
            // 如果结果大于等于0,表示元素露出来了
            if(distance >= 0 ){
                // 给元素写入真实的src,展示图片
                imgs[i].src = imgs[i].getAttribute('data-src')
                // 前i张图片已经加载完毕,下次从第i+1张开始检查是否露出
                num = i + 1
            }
        }
    }
    // 监听Scroll事件
    window.addEventListener('scroll', lazyload, false);

  <style>
    .img {
      width: 200px;
      height:200px;
      background-color: gray;
    }
    .pic {
      // 必要的img样式
    }
  </style>
 <div class="container">
    <div class="img">
      // 注意我们并没有为它引入真实的src
      <img v-for="item in 100" class="pic" alt="加载中" data-src="./images/1.png">
    </div>
</div>

2. 预渲染

事件节流与防抖

  • 为什么使用

    • 频繁触发回调导致的大量计算会引发页面的抖动甚至卡顿
  • throttle

    • 定义:所谓的“节流”,是通过在一段时间内无视后来产生的回调请求来实现;
    • 怎么做?
    const trottle = (fn, wait, immediate) => {
      if (immediate) {
        var pre = 0;
      } else {
        var timeout;
      }
      return function () {
        if (immediate) {
          let now = Date.now();
          if (now - pre >= wait) {
            fn.apply(this, arguments);
            pre = now;
          }
        } else {
          if (!timeout) {
            timeout = setTiemout(function () {
              timeout = null;
              fn.apply(this, arguments);
            }, wait);
          }
        }
      };
    };
    
  • debounce

    • 怎么做?
    const debounce = (fn, wait, immediate) => {
      let timeout;
      return function () {
        if (timeout) clearTimeout(timeout);
        if (immediate) {
          let run = !timeout;
          timeout = setTimeout(() => {
            timeout = null;
          }, wait);
          if (run) fn.apply(this, arguments);
        } else {
          timeout = setTimeout(function () {
            fn.apply(this, arguments);
          }, wait);
        }
      };
    };
    
  • lock实现:

/**
 * 锁对象
 */
export const lockAgroup = {};
/**
 *  加锁,3s后自动解锁
 */
export const lock = (name) => {
  lockAgroup[name] = 1;
  setTimeout(() => {
    lockAgroup[name] = 0;
  }, 2000);
};

/**
 * 判断是否锁上
 */
export const islock = (name) => {
  let l = 0;
  lockAgroup[name] ? (l = 1) : lock(name);
  return l;
};

/**
 * 解锁 想提前解锁自行调用
 */
export const unlock = (name) => {
  lockAgroup[name] = 0;
};

三、实践篇--性能优化在脉脉APP中的实践

1、实验室测速

原文链接:zhuanlan.zhihu.com/p/62045330

测速工具

  1. PageSpeed Insights
  2. Pingdom
  3. webpagetest
  4. GTmetrix
  5. uptrends

测速工具对比

优化建议是否收费支持PC和移动端国外测速其他
pageSpeed非常详细是(对强制登录页面访问的不能测速)
Pingdom可以监视网站的正常运行时间、性能和交互,从而获得更好的最终用户体验
webpagetest能根据手机型号、地理位置进行网页加载速度的测试
GTmetrix非常详细经验丰富的老牌的托管提供商,了解网站和网络应用程序
uptreds能根据地域、屏幕大小、浏览器类型、手机类型、宽带类型进行网页加载速度测试

2、制定优化方案

这部分示例偏脉脉代码,实际每个团队在优化的时候都需要考虑两个方向,一个是通用优化方向,另外一个是针对你们业务页面的性能瓶颈是哪里,做一个分析;

通过pagespeed工具对脉脉职位详情页面进行分析,总结出以下优化点,并实践,大家可以体验一下脉脉APP内职位详情页性能,然后再来看这里的优化方案;

  • 拆包;
    • 单独打包UI组件库;
    • 优点:实际应用时,业务代码更新频率要高于UI库组件,单独打包,当业务代码更新,UI库未更新时,UI库对应js文件还可以使用上一次缓存,用户只需要单独拉取业务代码对应js文件即可。
    image.png
  • 剔除无用CSS文件:
    • 由于职位详情页既在PC端投放也在H5端投放,所以一个比较大的css文件只在PC端依赖,但是在移动端也会加载,严重影响性能;
    • 所以在build过程中,判断移动端还是PC端,移动端的CSS文件剔除;
  • 缩短初始化接口耗时:
    • 我们的初始化接口会在node层封装一次后提供前端使用;
    • 对初始化接口中所有请求的后端接口进行分析,发现其中一个后端接口严重拖慢了其他数据,所以将该接口拆分出来,由前端页面单独加载;

3、代码实现

偏业务代码,这里不给附录了,具体想要有什么诉求,可以评论区讨论我们的具体实现方案~~

4、观察数据

前端优化,最重要的就是上线后观察数据,因为忙忙活活做了这么多,如何衡量产出是一件很重要的事情,这个一定不要忽略,也是每个做性能优化必须要做的事情,这里划重点,具体怎么观察数据,要看你们业务线使用的什么工具来收集数据,我这边的举例偏适用脉脉,所以不详述。

四、优化产出衡量

作为前端,对于数据统计以及数据收益不会很熟练,做了半年的数据抓取,推荐大家两个维度做数据分析:均值、峰值;前端性能优化,直到拿到以下描述的这部分数据后,才算是一个闭环

以下太偏脉脉技术,所以这里不做更多的描述,大家在做性能优化,一定不要忽略这部分,最重要最重要的事情~

1、技术产出

  1. FCP

    我取的是FCP周均值和周峰值的下降;

  2. LCP

    我取的是FCP周均值和周峰值的下降;

2、业务产出

性能优化可能影响的业务指标

强烈建议以下指标一定要抓取到一个,对于性能优化来讲,业务产出是最重要的一部分,这些才是领导和产品最关心的事情,能对业务有影响的技术数据,非常重要。

产出举例:

  • 优化后数据 周均值X1% * PV 峰值Y1%;
  • 优化前数据 周均值 X2% * PV 峰值Y2%;
  • 周均值提升 (X1-X2)% 对应提升周均值PV为:周均值PV*(X1-X2)% ;
  • 周峰值提升 (Y1-Y2)% 对应提升周峰值PV为:周峰值PV*(Y1-Y2)%;
  1. 页面打开成功率(职位详情页用的是这个)
    • 定义:用户从上一个页面入口点击,进入下一个页面,如果进入下一个页面成功了,记一次,未成功退出,则视为未成功;
    • 计算方式:(打开页面PV) / (页面pageShowPV)
  2. 用户次日留存
    • 由于招聘业务太依赖于用户意向,所以对于用户停留时长与次日留存影响不大;
    • 但是我一定要罗列出来提供给其他H5页面,这个是通用的用户体验方案;
  3. 用户停留时长
    • 由于招聘业务太依赖于用户意向,所以对于用户停留时长与次日留存影响不大
    • 但是我一定要罗列出来提供给其他H5页面,这个是通用的用户体验方案;