大厂性能的计算方式与优化方案

# 大厂性能的计算方式与优化方案

  • Q: 什么是前端性能优化?

前端性能是指⻚⾯信息加⼯(⽐如数据展现、动画、操作效率等)的效率。

优化是指借助相关技术⼿段提⾼这样的效率。

  • Q: 什么是网页性能指标?

既然前端页面性能如此之重要,也是用户体验的核心衡量标准。我们想要让网站的性能提升,具体指的是什么?如何去量化我们的优化?而性能体验这种东西往往是相对的,所以在讨论性能时,我们需要有精确的数据,需要有可以测量的客观的标准来定义,这些标准就是网页性能指标

# 网页性能指标

# Timing

页面运行的时间线(统计了从浏览器从网址开始导航到 window.onload事件触发的一系列关键的时间点):

  • 开始导航: startTime

即navigationStart, 导航开始的时间

  • Prompt for unload(上一个页面卸载)

    • unloadEventStart:表示窗口中的前一个网页(与当前页面同域)unload的时间戳。如果没有前一个网页,或者前一个网页和当前页面不是同域,则返回值为0。
    • unloadEventEnd:表示当unload事件结束时的时间戳。 如果没有前一个网页,或者前一个网页和当前页面不是同域,则返回值为0。
  • Redirect(重定向)

    • redirectStart:表示当第一个HTTP重定向开始时的时间戳。如果没有重定向,或者其中一个重定向不是同域,则返回值为0。
    • redirectEnd:表示当最后一个HTTP重定向完成时,即接收到HTTP响应的最后一个字节时的时间戳。如果没有重定向,或者其中一个重定向不是同域,则返回值为0。
    • fetchStart:表示当浏览器准备好使用HTTP请求获取文档时的时间戳。这个时刻是发生在检查任何应用程序缓存之前。
  • AppCache 查询缓存

  • DNS(DNS解析)

    • domainLookupStart:表示当DNS域名查询开始时的时间戳。如果使用了持久连接,或者信息存储在缓存或本地资源中(即无DNS查询),则该值将与fetchStart相同。
    • domainLookupEnd:表示当DNS域名查询完成时的时间戳。如果使用了持久连接,或者信息存储在缓存或本地资源中(即无DNS查询),则该值将与fetchStart相同。
  • TCP三次握手

    • connectStart:表示HTTP TCP开始建立连接的时间戳。如果传输层报告了一个错误,并且重新开始建立连接,则给出最后一次建立连接的开始时间戳。如果使用持久连接,则该值与fetchStart相同。
    • secureConnectionStart:表示当安全连接握手(HTTPS连接)开始时的时间戳。如果没有安全连接,则返回0。
    • connectEnd:表示HTTP TCP完成建立连接(完成握手)的时间戳。如果传输层报告了一个错误,并且重新开始建立连接,则给出最后建立连接的结束时间。如果使用持久连接,则该值与fetchStart相同。当所有安全连接握手或SOCKS身份验证都被终止时,该连接被视为已打开。
  • Request(请求数据)

    • requestStart:表示浏览器发送请求从服务器或本地缓存中获取实际文档的时间戳。如果传输层在请求开始后失败,并且连接重新打开,则此属性将被设置为与新请求对应的时间。
    • responseStart:表示当浏览器从服务器的缓存或本地资源接收到响应的第一个字节时的时间戳(⾸字节时间)。
    • responseEnd:表示当浏览器从服务器、缓存或本地资源接收到响应的最后一个字节时或者当连接被关闭时(如果这是首先发生的)的时间戳。
  • processing,JS执行、DOM解析渲染

    • domInteractive:表示解析器完成解析dom树的时间戳,这时document.readyState变为'interactive',相应的readystatechange事件被抛出。这时候只是解析完成DOM树,还没开始加载网页内的资源(可交互时间,测试点 )。

    • domContentLoadedEventStart:表示DOM解析完成后,网页内的资源开始加载的时间戳。就在解析器发送DOMContentLoaded事件之前。

    • domContentLoadedEventEnd:表示DOM解析完成后,网页内的资源加载完成的时间戳。即在所有需要尽快执行的脚本(按顺序或不按顺序)被执行之后。

    • domComplete:表示当解析器完成它在主文档上的工作时,也就是DOM解析完成,且资源也准备就绪的时间。document.readyState变为'complete',相应的readystatechange事件被抛出。

  • load事件开始

    • loadEventStart:表示为当当前文档发送load事件时,也就是load回调函数开始执行的时间。如果这个事件还没有被发送,它将返回0。
    • loadEventEnd:表示当load事件的回调函数执行完毕的时间,即加载事件完成时。如果这个事件还没有被发送,或者还没有完成,它将返回0(完全加载时间,测试点 )。

通过时间线上的各个时间点,可以得到的性能指标有:

// performance.timing
console.table([
['DNS解析耗时', performance.timing.domainLookupEnd - performance.timing.domainLookupStart],
['TCP连接耗时', performance.timing.connectEnd - performance.timing.connectStart],
['SSL连接耗时', performance.timing.connectEnd - performance.timing.secureConnectionStart],
['请求耗时', performance.timing.responseEnd - performance.timing.requestStart],
['解析DOM树耗时', performance.timing.domComplete - performance.timing.domInteractive],
['domready时间', performance.timing.domContentLoadedEventEnd - performance.timing.fetchStart],
['onload时间', performance.timing.loadEventEnd - performance.timing.fetchStart]
]);

# Performance API

Performance API (opens new window) 是一组用于衡量 web 应用性能的标准接口~

  • performance.timing 可以获取网页运行过程中每个时间点对应的时间戳(绝对时间,ms),但却即将废弃

  • performance.getEntries(),以对象数组的方式返回所有资源的数据,包括css,img,script,xmlhttprequest,link等等

  • performance.getEntriesByType(type:string),和上面的 getEntries 方法类似,不过是多了一层类型的筛选,常见性能类型可以有 **navigation(页面导航)、resource(资源加载)、paint(绘制指标)**等

// 页面导航时间
performance.getEntriesByType('navigation')

// 静态资源
performance.getEntriesByType('resource')

// 绘制指标
performance.getEntriesByType('paint')


/*需要定时轮询, 才能持续获取性能指标*/
  • performance.getEntriesByName(name: string, type?:string),理,和上面的 getEntries 方法类似,多了一层名字的筛选,也可以传第二个参数再加一层类型的筛选
performance.getEntriesByName('https://i0.hdslb.com/bfs/svg-next/BDC/danmu_square_line/v1.json')
performance.getEntriesByName('https://cloud.tencent.com/developer/api/user/session')

/*需要定时轮询, 才能持续获取性能指标*/
  • performance.now(),返回当前时间与performance.timing.navigationStart的时间差

# PerformanceObserver

  • PerformanceObserver (观察者模式)推荐, 主要用于监测性能度量事件
/* 写法一 */
//直接往 PerformanceObserver() 入参匿名回调函数,成功 new 了一个 PerformanceObserver 类的,名为 observer 的对象
var observer = new PerformanceObserver(function (list, obj) {
  var entries = list.getEntries();
  for (var i = 0; i < entries.length; i++) {
    //处理“navigation”和“resource”事件
  }
});
//调用 observer 对象的 observe() 方法
observer.observe({ entryTypes: ["navigation", "resource"] });

/* 写法二 */
//预先声明回调函数 perf_observer
function perf_observer(list, observer) {
  //处理“navigation”事件
}
//再将其传入 PerformanceObserver(),成功 new 了一个 PerformanceObserver 类的,名为 observer2 的对象
var observer2 = new PerformanceObserver(perf_observer);
//调用 observer2 对象的 observe() 方法
observer2.observe({ entryTypes: ["navigation"] });



// 实例化 PerformanceObserver 对象,observe方法的entryTypes主要性能类型:
console.log(PerformanceObserver.supportedEntryTypes)

/*
  ['element', 'event', 'first-input', 'largest-contentful-paint', 'layout-shift', 
  'longtask', 'mark', 'measure', 'navigation', 'paint', 'resource', 'visibility-state']

*/
element 元素加载时间,实例项是 PerformanceElementTiming 对象。
event 事件延迟,实例项是 PerformanceEventTiming 对象。
first-input 用户第一次与网站交互(即点击链接、点击按钮或使用自定义的JavaScript控件时)到浏览器实际能够响应该交互的时间,称之为First input delay – FID
largest-contentful-paint 屏幕上触发的最大绘制元素,实例项是 LargestContentfulPaint 对象。
layout-shift 元素移动时候的布局稳定性,实例项是 LayoutShift对象。
long-animation-frame 长动画关键帧。
longtask 长任务实例,归属于 PerformanceLongTaskTiming 对象。
mark 用户自定义的性能标记。实例项是 PerformanceMark 对象。
measure 用户自定义的性能测量。实例项是 PerformanceMeasure 对象。
navigation 页面导航出去的时间,实例项是 PerformancePaintTiming 对象。
paint 页面加载时内容渲染的关键时刻(第一次绘制,第一次有内容的绘制,实例项是 PerformancePaintTiming 对象。
resource 页面中资源的加载时间信息,实例项是 PerformanceResourceTiming 对象。
visibility-state 页面可见性状态更改的时间,即选项卡何时从前台更改为后台,反之亦然。实例项是 VisibilityStateEntry 对象。
soft-navigation -

# 用户为导向性能指标介绍

# 首次绘制(First Paint)和首次内容绘制(First Contentful Paint)

首次绘制(FP)和首次内容绘制(FCP)。在浏览器导航并渲染出像素点后,这些性能指标点立即被标记。 这些点对于用户而言十分重要,直乎感官体验!

  • 首次绘制(FP),首次渲染的时间点。FP和FCP有点像,但FP一定先于FCP发生,例如一个页面加载时,第一个DOM还没绘制完成,但是可能这时页面的背景颜色已经出来了,这时FP指标就被记录下来了。而FCP会在页面绘制完第一个 DOM 内容后记录。

  • 首次内容绘制(FCP),首次内容绘制的时间,指页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间。

/* PerformanceObserver监控  */
const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  entries.forEach((entry) => {
    if (entry.name === 'first-paint') {
      console.log('FP(首次绘制):', entry.startTime);
    } else if (entry.name === 'first-contentful-paint') {
      console.log('FCP(首次内容绘制):', entry.startTime);
    }
  });
});
observer.observe({ entryTypes: ['paint'] });


/* performance.getEntriesByName*/
console.log(
    "FP(首次绘制):" + performance.getEntriesByName("first-paint")[0].startTime
);

console.log(
    "FCP(首次内容绘制):" +
      performance.getEntriesByName("first-contentful-paint")[0].startTime
);

# 首次有效绘制(First Meaningful Paint)

有效内容,这种一般很难清晰地界定哪些元素的加载是「有用」的(因此目前尚无规范),但对于开发者他们自己而言,他们更知道页面的哪些部分对于用户而言是最为有用的,所以这样的衡量标准更多的时候是掌握在开发者手上!

const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  entries.forEach((entry) => {
    if (entry.name === 'XXX') {
      console.log(entry);
    } 
  });
});
observer.observe({ entryTypes: ['resource'] }); // 可以是图片、某个Dom元素

# 可交互时间(TTI)

指标测量页面从开始加载(FCP)到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间。阻塞会影响正常可交互的时间,浏览器主线程一次只能处理一个任务,如果主线程长时间被占用,那么可交互时间也会变长,所以更多的TTI都是发生在主线程处于空闲的时间点

良好的TTI应该控制在5秒以内。

测量TTI的最佳方法是在网站上运行Lighthouse性能审核

console.log(performance.timing.domInteractive); // 可交互时间点

# 长任务(Long Task)

浏览器主线程一次只能处理一个任务。 某些情况下,一些任务将可能会花费很长的时间来执行,持续占用主进程资源,如果这种情况发生了,主线程阻塞,剩下的任务只能在队列中等待。

用户所感知到的可能是输入的延迟,或者是哐当一下全部出现。这些是当今网页糟糕体验的主要来源之一。

Long Tasks API认为任何超过50毫秒的任务(Task)都可能存在潜在的问题,并将这些任务相关信息回调给给前端。

把 long task 时间定义为 50ms 的主要理论依据是 Chrome 提出的 RAIL 模型,RAIL 认为事件响应应该在 100ms 以内,滚动和动画处理应该在 16ms 以内,才能保证好的用户体验,而如果一个 task 执行超过 50ms,则很有可能让体验达不到 RAIL 的标准,故我们需要重点关注执行时间超过 50ms 的任务。

const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  entries.forEach((entry) => {
    console.log('Long Task(长任务):', entry);
  });
});
observer.observe({ entryTypes: ['longtask'] });

// 案例: index-longTask.html

# Core Web Vitals

Q: 什么是Core Web Vitals?

衡量一个 Web 页面的体验和质量有着非常多的指标,我们又不是性能专家,所以为了简化场景,帮助网站专注于最重要的指标,所以在新一代的谷歌浏览器官方推出了核心 Web 指标(LCP FID CLS),协助我们能够有更好的聚焦关注度和降低我们得理解成本

# LCP: Largest Contentful Paint

最大内容绘制 (LCP) 指标会根据页面首次开始加载(FCP)的时间点来报告可视区域内可见的最大图像或文本块(<img>、<video>、url())完成渲染的相对时间。

通常Web 页面是分阶段加载的,所以,页面上最大的元素可能会发生变化。例如上图,在一个带有文本和图像的页面上,浏览器最初可能只是呈现文本,而此时浏览器会分派一个性能类型(entryType)为largest-contentful-paint 的entry。稍后,图像完成加载完成,会分派第二个性能类型(entryType)为largest-contentful-paint 的entry。

const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  entries.forEach((entry) => {
    console.log('LCP(最大内容绘制):', entry.startTime);
  });
});
observer.observe({ entryTypes: ['largest-contentful-paint'] });

# FID:First Input Delay

首次输入延迟: FID( First Input Delay) 测量从用户第一次与页面交互,例如当他们单击链接、点按按钮或使用由 JavaScript 驱动的自定义控件,直到浏览器对交互作出响应,并实际能够开始处理事件处理程序所经过的时间。

由此图可以看出,FID值越小越好,FID 是发生在 FCP 和 TTI 之间,这个阶段虽然页面已经显示出部分内容,却不具备完全的可交互性。这个阶段用户和页面交互,往往会有较大延迟。如下图所示,浏览器接收到用户输入操作时,主线程正在忙于执行一个 Long Task,只有当这个 Task 执行完成后,浏览器才能响应用户的输入操作。


//FID 的计算需要用户真实操作页面,可以创建 PerformanceObserver 对象,监听 *first-input* 事件,
// 监听到 *first-input* 事件后,然后通过事件的开始处理时间,减去事件的发生时间,即为 FID。
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    const delay = entry.processingStart - entry.startTime;
    console.log('FID 首次输入延迟:', delay, entry);
  }
}).observe({entryTypes: ['first-input']});

# CLS:Cumulative Layout Shift 累积布局偏移

CLS 测量整个页面生命周期内发生的所有意外布局偏移量中最大一连串的布局偏移分数。每当一个可见元素的位置从一个已渲染帧变更到下一个已渲染帧时,就发生了布局偏移 。

简单地说,你是否曾经历过在网上阅读一篇文章,结果页面上的某些内容突然发生改变?文本在毫无预警的情况下移位,导致您找不到先前阅读的位置。或者更糟糕的情况:您正要点击一个链接或一个按钮,但在你手指落下的瞬间,诶?链接移位了,结果点到了别的东西,这个就是意外偏移。

布局偏移分数 = 影响比例 * 距离分数

影响比例: 比如上图文字块(不稳定元素)占可视区域50%,出现意外布局后,向下偏移25%,那么我们求并集,50% + 25% = 75%

距离分数: 因为向下25%,那么距离分数就是25%

布局偏移分数, 0.75 * 0.25 = 0.1875
  • 例:
    let clsValue = 0;
    let clsEntries = [];
    let sessionValue = 0;
    let sessionEntries = [];
    new PerformanceObserver((entryList) => {
        for (const entry of entryList.getEntries()) {
            // 仅计算最近没有用户输入的布局变化。
            if (!entry.hadRecentInput) {
                const firstSessionEntry = sessionEntries[0];
                const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

            // 如果当前entry发生在前一个entry之后不到 1 秒且第一个entry之后不到 5 秒,
            // 那么会对entry.value进行累加。否则,重新赋值
            // 注: 我们把这个时间区间范围内发生的偏移进行累计,称做一个会话窗口,
            // 对layout-shift收集到的entry进行清洗,得到最大的CLS分数
            if (sessionValue
            && entry.startTime - lastSessionEntry.startTime < 1000
            && entry.startTime - firstSessionEntry.startTime < 5000) {
                sessionValue += entry.value;
                sessionEntries.push(entry);
            } else {
                sessionValue = entry.value;
                sessionEntries = [entry];
            }
            // 如果当前sessionValue大于当前CLS值
            // 更新 CLS。
            if (sessionValue > clsValue) {
                clsValue = sessionValue;
                clsEntries = sessionEntries;
                // Log the updated value (and its entries) to the console. 
                console.log('Cumulative Layout Shift 累积布局偏移:', clsValue, clsEntries)
            }
        }
    }}).observe({ entryTypes: ['layout-shift'] });

// 性能类型layout-shift用于测量JavaScript 中的布局偏移

# Performance

Performance是Google浏览器自带的页面性能插件,可以记录站点在运行过程中的性能数据,有了这些性能数据,我们就可以回放整个页面的执行过程,这样就方便我们来定位和诊断每个时间段内页面的运行情况,从而有效帮助我们找出页面的性能瓶颈。

# Performance面板

这里以掘金首页 (opens new window)为例分析下~

  • 录制:点击开始录制;
  • 重制:点击重新加载页面,并开始录制;
  • 清除:清除录制记录
  • 上传/下载:支持上传或下载record文件,下载可以把录制生成的json文件下载到本地;

录制成功后会生成如下录制分析报告:

  • 概览面板:由页面帧速 (FPS)、CPU 资源消耗、网络请求流量、V8 内存使用量 (堆内存)几项指标按照时间顺序记录变化的面板;

    • 时间线:显示录制时长,可缩放范围;

    概览面板和性能面板都是依赖于时间线的,假如我们录制了10000毫秒,那么它们的时间线就是10000毫秒~

    • 红点:FPS 图表上出现了红色块,那么就表示红色块附近渲染出一帧所需时间过久,帧的渲染时间过久,就有可能导致页面卡顿,你可以点击该红色块,那就可以把时间线聚焦到该问题区域。

    红色下面的区域:CPU 资源消耗、网络请求流量、V8 内存使用量 (堆内存)

    • Memory: 显示JS, Documents, GPU等内存使用情况;
  • 性能面板:概览面板是用来定位到可能存在问题的时间节点,如果需要更进一步的数据,来分析导致性能问题的原因,需要从性能面板入手。我们介绍一下有哪些常见性能指标项:

    • Network指标:该指标展示了页面中的每个网络请求所消耗的时长,并以瀑布流的形式展现。

    点击请求,下方详情面板会展示对应请求信息:蓝色是html请求、紫色是css请求、黄色是js请求、绿色是图片~

    1. 左侧的一条线:请求准备;
    2. 左侧浅色区域:发送请求,并等待服务器返回;
    3. 中间深色区域:返回内容,下载;
    4. 最右侧的一条线:线程空闲时间;
    • Frames:帧信息,也就是浏览器生成每帧的记录;点击可在下方看到CPU渲染时长等信息~

    我们知道页面所展现出来的画面都是由渲染进程一帧一帧渲染出来的,帧记录就是用来记录渲染进程生成所有帧信息,包括了渲染出每帧的时长、每帧的图层构造等信息,你可以点击对应的帧,然后在详细信息面板里面查看具体信息。

    • Animations: 动画渲染分析;

    • Timings:用来记录一些关键的时间节点在何时产生的数据信息,像FP、LCP、DOMContentLoaded、Onload 等事件产生的时间点,它们体现在在几条不同颜色的竖线上。

    • Main记录渲染主线程的任务执行过程,大部分流程、Jacvascirpt执行、V8垃圾回收、定时设置回调任务等等也是均跑在主线程的, 所以这也是我们最需要关注的一个指标

    • Layout shifts: 意外布局偏移分析,记录页面加载过程中发生的视觉不稳定性

    • Compositor:记录了合成线程的任务执行过程

    • GPU: 记录了 GPU 进程主线程的任务执行过程

    • Chrome_ChildIOThread: 渲染进程维护着一个IO线程,主要用来接收用户输入事件、网络事件、设备相关等事件,如果事件需要渲染主线程来处理,那么 IO 线程还会将这些事件转发给渲染主线程。在性能面板上,Chrome_ChildIOThread 指标对应的就是 IO 线程的任务记录。

  • 详情面板

    • Summary: 统计表,展示当前任务具体信息;
    • Bttom-Up: 会展示当前任务的所有活动,按时间倒序排列;
    • Call Tree: 当前任务的所有子任务;
    • Event Log: 事件日志,活动在记录过程中先后顺序

# Main指标分析

Main 指标就记录渲染主线上所执行的全部任务,以及每个任务的详细执行过程,所以了解任务和过程在Performance面板的体现是很有必要的。

观察上图,图上几段灰色横条,灰色横条就对应了一个任务Task,灰色长条的长度对应了任务的执行时长。渲染主线程上的任务都是比较复杂的,灰线下面的横条就是一个个任务的过程, 同样这些横条的长度就代表这些过程执行的时长。

我们可以把任务看成是一个 Task 函数,在执行 Task 函数的过程中,它会调用一系列的子函数,这些子函数就是我们所提到的过程。

一般网页加载的Main指标都比较复杂, 这里写一个简单的页面分析下:

<html>

<head>
    <title>Main指标分析</title>
    <style>
        .area {
            border: 2px black solid;
        }

        .block {
            background-color: green;
            height: 100px;
            margin: 10px;
            width: 100px;
        }
    </style>
</head>

<body>
    <div class="area">
        <div class="block"></div>
    </div> <br>
    <script> 
        function setBlockArea() { 
            var el = document.createElement('div'); 
            el.setAttribute('class', 'area');
            el.innerHTML = '<div class="block"></div>';
            document.body.append(el); 
        } 
        setBlockArea() 
    </script>
</body>
</html>
{/* 
首先页面渲染div;
然后执行setBlockArea方法,再插入一个div
 */}

本地直接在浏览器打开上面的html文件后,打开控制台Performance,点击重制录制页面重新加载流程,查看Main指标下的分析:

  • 主要分为三个Task阶段:
  1. 导航阶段:
  2. 解析HTML数据阶段(DOM跟CSS):
  3. 绘制阶段

# 导航阶段

点击每个Task,发现下面都会执行一些事件,首先点击第一个导航Task并放大:

会在下方详细面板的Event Log看到具体的活动,依次执行pagehide > visibilitychange > webkitvisibilitychange > unload > unloadEventEnd > Send request > Receive response > ...

  1. 首先是触发pagehide事件,上一个页面隐藏;之后卸载上一个页面;
  2. 页面卸载完成后发起请求,接受响应体,标志导航结束,同时开始下载HTML;
  3. 接着接收返回的html文档(Recieve data);

这些事件在前面的Timing页面运行时间线中都会有涉及~

# 解析HTML数据阶段

点击第二个Task,发现这一步主要是解析接收的HTML文档~

在这一步首先执行Parse HTML方法;在该方法里会执行Evaluate script流程编译JS代码;接着执行setBlockArea,然后再次执行Parse HTML...

上图中的DCLDOMContentLoaded Event, LOnload event~

# 绘制阶段

DOM生成后进入下一个任务: 绘制阶段:

  • Recalculate style:样式计算;在DCL事件后执行~

之后在Onload event事件后执行绘制操作:

  • Layout: 布局
  • Pre-paint: 预备绘制
  • Paint: 绘制
  • Layerise

Paint会切分成很多绘制命令,Layerise会把绘制命令给到合成线程去绘制,合成线程会利用GPU来执行绘制命令;

完成阶段

绘制完成后,会执行DOMContentLoaded事件,接着执行:domComplete、readystatechange、loadEventStart、load、loadEventEnd、pageshow这些事件~

  • 总结如下图:

# 总结

  1. 通过 Network分析网络请求时长,看下是否有优化空间;

  2. 通过Main指标下每个TaskEvent Log,分析每个任务详细过程,子任务情况;

# 常见优化手段

# 异步加载

  • 同步加载: 同步模式又称阻塞模式,会阻止浏览器的后续处理,停止了后续的文件的解析,执行,如图像的渲染。

Q: 浏览器为什么会采用同步模式?

是因为加载的js文件中有对dom的操作,重定向,输出document等默认行为,所以同步才是最安全的。所以一般我们都会把script标签放置在body结束标签之前,减少阻塞。

  • 异步加载其实就是一种非阻塞加载模式的方式,就是浏览器在下载执行js的同时,还会继续进行后续页面的处理。

几种常见的异步加载脚本方式:

// defer要等到html解析完成之后执行脚本
<script src="main.js" defer></script>

// async异步加载脚本后便会执行脚本
<script src="main.js" async></script>

// js代码中动态添加script标签,并将其插入页面
const script = document.createElement("script");
script.src = "a.js"; 
document.head.appendChild(script);


// 通过XHR异步加载js
let xhr = new XMLHttpRequest();
xhr.open('get', 'js/main.js', true); // true代表我们需要异步加载该脚本
xhr.onreadystatechange = function() {
    if(xhr.readyState === 4 ) {
        if(xhr.status === 200) {
            console.log(xhr.responseText)
        }
    }
}

# 按需打包

按需打包表示的是针对第三方依赖库及业务模块。只打包真正在运行时可能会用到的代码。

# Tree Shaking

  • 使用ES Module支持的Tree Shaking方案,使用构建工具时候完成按需打包。
import { Button } from 'antd';

// 假设我们的业务使用了Button组件,同时该组件库没有提供ES Module版本,
// 那么这样的引用会导致最终打包的代码是所有antd导出的内容,这样会大大增加代码的体积

// 但是如果我们组件库提供了ES Module版本(静态分析能力),并且开启了Tree Shaking功能,
// 那么我们就可以通过“摇树”特性
// 将不会被使用的代码在构建阶段移除。
  • antd组件库使用Tree Shaking的姿势:
// package.json
{
    // ...
  "main": "lib/index.js", // 暴露CommonJS规范代码lib/index.js
  "module": "es/index.js", // 非package.json标准字段,打包工具专用字段,指定符合ESM规范的入口文件
  
  // 副作用配置字段,告诉打包工具遇到sideEffects匹配到的资源,均为无副作用的模块
  "sideEffects": [
    "*.css",
    "example.js" // 具名配置好后,下方 b 就会被摇掉
  ],
}

// expample.js
const b = 2 
export const a = 1

console.log(b)

Tree Shaking一般与Babel搭配使用,需要在项目里面配置Babel,因为Babel默认会把ESM规范打包成CommonJs代码,所以需要通过配置babel-preset-env#moudles编译降级:

production: {
  presets: [
      '@babel/preset-env',
      {
          modules: false
      }
  ]
}

webpack4.0以上在mode为production的时候会自动开启Tree Shaking,实际就是依赖了、UglifyJS等压缩插件,默认配置~

const config = {
    mode: 'production',
    optimization: {
        usedExports: true, // 使用usedExports进行标记
        minimizer: {
            new TerserPlugin({...}) // 支持删除未引用代码的压缩器
        }
    }
}

# babel-plugin-import

使用以babel-plugin-import为主的Babel插件完成按需打包。

[
  {
    libraryName: 'antd',
    libraryDirectory: 'lib', // default: lib
    style: true
  },
  {
    libraryName: 'antd'
  }
];


import { TimePicker } from "antd"
↓ ↓ ↓ ↓ ↓ ↓
var _button = require('antd/lib/time-picker');

# 按需加载

按需加载表示的是代码模块在交互的时候需要动态导入。

动态导入import(module) 方法加载模块并返回一个 promise,该 promise resolve 为一个包含其所有导出的模块对象。我们可以在代码中的任意位置调用这个表达式。

不兼容浏览器,可以用Babel进行转换(@babel/plugin-syntax-dynamic-import

// say.js

export function hi() {
  alert(`你好`);
}

export function bye() {
  alert(`拜拜`);
}

export default function() {
  alert("默认到处");
}


// html
<!doctype html>
<script>async function load() {
    let say = await import('./say.js');
    say.hi(); // 你好
    say.bye(); // 拜拜
    say.default(); // 默认导出
</script>
<button onclick="load()">Click me</button>
  • 手写一个不考虑兼容性的import(module)方法:
// 利用ES6模块化来实现
const dynamicImport = (url) => {
    return new Promise((resolve, reject) => {
        // 创建script标签
        const script = document.createElement("script");
        const tempGlobal = "__tempModuleVariable" + Math.random().toString(32).substring(2);
        // 通过设置 type="module",告诉浏览器该脚本是一个 ES6 模块,需要按照模块规范进行导入和导出
        script.type = "module";
        script.crossorigin="anonymous"; // 跨域
        // 设置js内容
        script.textContent = `import * as m from "${url}"; window.${tempGlobal} = m;`;
        // load 回调
        script.onload = () => {
            resolve(window[tempGlobal]);
            delete window[tempGlobal];
            script.remove();
        };
        
        // error回调
        script.onerror = () => {
            reject(new Error(`Fail to load module script with URL: ${url}`));
            delete window[tempGlobal];
            script.remove();
        };

        document.documentElement.appendChild(script);
    });
}

// 另外一种实现方式:
const dynamicImportV2 = (url) => {
    return new Promise((resolve, reject) => {
        const script = document.createElement("script");
        script.type = "module";
        script.src = url;

        script.onload = () => {
            resolve(script.module);
            script.remove();
        };
        
        script.onerror = () => {
            reject(new Error("Failed to load module script with URL " + url));
            script.remove();
        };
        
        document.documentElement.appendChild(script);
    });
}

# Bigpipe技术

BigPipe 最早上 FaceBook 用来提升自家网站性能的一个秘密武器。其核心思想在于将页面分成若干小的构件,我们称之为 pagelet。每一个构件之间并行执行。

那么 BigPipe 做了什么?和传统方式有什么不同呢?我们知道浏览器处理我们的 HTML 文档以及其中包含的 CSS,JS 等资源的时候是从上到下串行执行的。如果我们把浏览器处理的过程划分为若干阶段(stage),那么这些阶段之间有着明显的时间先后关系。那么我们能不能将其并行化,从而减少时间呢?这就是 BigPipe 的基本思想。

# 浏览器原理

网页性能指标一节中,我们了解了一个重要的指标叫做FP,页面加载到首次开始绘制的时长,而影响该指标其中有一个重要因素就是是网络加载速度,所以我们要想更好地优化 Web 页面的加载速度,学习浏览器工作原理相关知识是有必要的,其中涉及了网络、操作系统、Web等一系列知识,可以让你更清楚如何去优化 Web 性能,或者能更轻松地定位 Web 问题。

单进程浏览器

单进程浏览器里面有多个线程:网路线程、页面线程等...,

单进程浏览器特点:

  1. 进程中的任意一线程执行出错,都会导致整个进程的崩溃。
  2. 线程之间共享进程中的数据。
  3. 当一个进程关闭之后,操作系统会回收进程所占用的内存。
  4. 进程之间的内容相互隔离

多进程浏览器

  • 浏览器进程,主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。

  • 渲染进程,核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页。

  • GPU 进程, 使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制。

  • 网络进程,主要负责页面的网络资源加载。

  • 插件进程,主要是负责插件的运行。

# 浏览器工作

  • 数据包传输

一个的大的数据文件不是一次性传输的,是会被拆成一个个小的数据包来传输的。

  • HTTP协议

HTTP 是一种允许浏览器向服务器获取资源的协议,是 Web 的基础,通常由浏览器发起请求,用来获取不同类型的文件,例如 HTML 文件、CSS 文件、JavaScript 文件、图片、视频等,浏览器使用 HTTP 协议作为应用层协议,用来封装请求的文本信息;建立好TCP连接,我们常说的TCP三次握手,并使用 TCP/IP 作传输层协议将它发到网络上。

# http请求发起

  1. 构建请求

首先,浏览器构建请求行信息,构建好后,浏览器准备发起网络请求: GET /index.html HTTP1.1

  1. 查找是否有缓存

该阶段存在真正发起网络请求之前,浏览器会先在浏览器缓存中查询是否有要请求的文件。

其中,浏览器缓存是一种在本地保存资源副本,以供下次请求时直接使用的技术。如果浏览器缓存中存有副本,它会拦截请求,返回该资源的副本,并直接结束请求,而不会再去源服务器重新下载。

  • 强缓存(200):不会向服务器发送请求,直接读取客户端本地资源;Expires, Cache-control
  • 协商缓存(304):向服务端核实本地缓存是否还能用;Last-modified, Etag, ...
  1. 准备 IP 地址和端口

我们在浏览器输入的是URL,这里浏览器会通过请求 DNS (域名系统)返回域名对应的 IP。

这里有一点需要关注的是浏览器还提供了 DNS 客户端缓存策略,如果某个域名已经解析过了,那么浏览器会缓存解析的结果,以供下次查询时直接使用,这样也会减少一次网络请求。

HTTP协议默认URL, 端口号是80~

  1. 等待 TCP 队列

Chrome 有个机制,同一个域名同时最多只能建立 6 个 TCP 连接,如果当前已经开启6个TCP连接正在进行数据包传输了,那么该http请求就需要等待,等待正在进行的请求结束。

  1. 建立 TCP 连接

三次握手。。。

  1. 发送 HTTP 请求

一旦建立了 TCP 连接,浏览器就可以和服务器进行通信了 HTTP 请求里面会有请求行、请求头、请求体(最终需要传递的数据)

# http请求响应

  1. 返回请求

接下来,服务器会根据浏览器的请求信息来准备相应的内容: 响应行,响应头,响应体; “200”为成功的状态码,“404”为没有找到页面的状态码,对应的还有许多http状态码。

  1. 断开TCP连接

一旦服务器向客户端返回了请求数据,它就要关闭 TCP 连接,但是在HTTP1.1, 如果头信息有Connection:Keep-Alive, 那我们会继续复用这个TCP连接,也就节省了建立TCP连接的时间。

  1. 重定向

响应行返回的状态码是 301,状态 301 就是告诉浏览器,我需要重定向到另外一个网址,而需要重定向的网址正是包含在响应头的 Location 字段中,接下来,浏览器获取 Location 字段中的地址,并使用该地址重新导航,这就是一个完整重定向的执行流程~

# 浏览器渲染

  • 首先,浏览器进程接收到用户输入的 URL 请求,浏览器进程便将该 URL 转发给网络进程。
  • 然后,在网络进程中发起真正的 URL 请求。
  • 网络进程接收到了响应头数据,便解析响应头数据,并将数据转发给浏览器进程。
  • 浏览器进程接收到网络进程的响应头数据之后,发送“提交导航 (CommitNavigation)”消息到渲染进程;
  • 渲染进程接收到“提交导航”的消息之后,便开始准备接收 HTML 数据,接收数据的方式是直接和网络进程建立数据管道;
  • 最后渲染进程会向浏览器进程“确认提交”,这是告诉浏览器进程:“已经准备好接受和解析页面数据了”。
  • 浏览器进程接收到渲染进程“提交文档”的消息之后,便开始移除之前旧的文档,然后更新浏览器进程中的页面状态。

可以看出渲染进程主要负责整个页面的渲染流程,按顺序额分别是构建Dom树、样式计算、布局阶段、分层、绘制、分块、光栅化、合成和显示,我们用一张图来总结一下整个渲染流程:

  • 分层(Layer)

页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)

z-index, transform, position: absolute; 如:transform: tranlateZ(0)就会将它单独提到一个层,可以使用GPU加速,从而实现性能优化,不然就会跟父节点在一个层,就可能出现加载比较慢的情况~

  • 绘制(Paint)

会形成绘制列表,然后commit给合成线程,合成线程会先根据可视窗口分块(tile),优先绘制可视窗口中的图块,图块需要转成位图,便由光栅化去做处理,渲染引擎维护这一个光栅化线程池来进行执行,而光栅化线程池又会调用GPU来加速生成位图,此时此刻GPU进程也介入进来,等所有光栅化后,会调取一个总的图层绘制命令,将页面内容绘制到内容,再从内存读出,浏览器显示。

# React性能优化常见策略

  • 【render过程】避免不必要的Render
  1. 类组件跳过没有必要的组件更新, 对应的技巧手段:PureComponent、React.memo、shouldComponentUpdate
PureComponent 是对类组件的 Props 和 State 进行浅比较;
React.memo 是对函数组件的 Props 进行浅比较;
shouldComponentUpdate 是React类组件的钩子,在该钩子函数我们可以对前后props进行深比对,返回false可以禁止更新组件,我们可以手动控制组件的更新;
  1. Hook的useMemo、useCallback 获得稳定的 Props 值

传给子组件的派生状态或函数,每次都是新的引用,这样会导致子组件重新刷新~

import { useCallback, useState, useMemo } from 'react';

const [count, setCount] = useState(0);
// 保证函数引用是一样的,在将该函数作为props往下传递给其他组件的时候,不会导致
// 其他组件像PureComponent、shouldComponentUpdate、React.memo等相关优化失效
// const oldFunc = () => setCount(count => count + 1)
const newFunc useCallback(() => setCount(count => count + 1), [])

// useMemo与useCallback 几乎是99%相似,只是useMemo一般用于密集型计算大的一些缓存,
// 它得到的是函数执行的结果
  const calcValue = React.useMemo(() => {
    return Array(100000).fill('').map(v => /*耗时计算*/ v);
  }, [count]);
  1. state状态下沉,减小影响范围

如果一个P组件,它有4个子组件ABCD,本身有个状态state p, 该状态只影响到AB ,那么我们可以把AB组件进行封装, state p 维护里面,那么state p变化了,也不会影响到CD组件的渲染

  1. redux、React上下文ContextAPI 跳过中间组件Render
  2. 避免使用内联函数;
  3. 使用 Immutable,减少渲染的次数。。。
  • 【Diff 过程】减少比对

列表项使用 key 属性,React 官方推荐将每项数据的 ID 作为组件的 key;

  • 组件懒加载,可以是通过 Webpack 的动态导入和 React.lazy 方法

  • 虚拟滚动,react-windowreact-virtualized

  • debounce、throttle 优化触发的回调,如input组件onChange防抖

  • 善用缓存,如上面用的useMemo,可以做一些耗时计算并保持引用不变,减少重新渲染

  • ....

# Vue性能优化常见策略

  • 路由懒加载:有效拆分App体积大小,访问时异步加载
const router = createRouter({
  routes: [
    // 借助webpack的import()实现异步组件
    { path: '/foo', component: () => import('./Foo.vue') }
  ]
})
  • keep-alive缓存页面:避免重复创建组件实例,且能保留缓存组件状态

  • 使用v-show复用DOM:避免重复创建组件; 不再变化的数据使用v-once

  • 长列表性能优化:如果是大数据长列表,可采用虚拟滚动,只渲染少部分区域的内容,第三库vue-virtual-scroller、vue-virtual-scroll-grid

  • 图片懒加载

  • 第三方插件按需引入

  • 服务端渲染/静态网站生成:SSR/SSG

  • ...

# 性能优化监控工具

  • DevTools-chrome performance

  • Charles-抓包分析神器

  • Whistle

  • Lighthouse-知名测评⼯具

  • webpagetest-知名测评⽹站

  • ⾏为打点-⾃定义数据监控

真实用户访问 》 提取性能指标 》 数据清洗加工 》 性能分析监控

# PWA

PWA的中文名叫做渐进式网页应用,它的诞生了是为了在用户体验和用户留存两方面提供了更好的解决方案,将 Web 和 App 各自的优势融合在一起:渐进式、可响应、可离线、实现类似 App 的交互、即时更新、安全、可以被搜索引擎检索、可推送、可安装、可链接。

  • 多项技术组成: HTTPS, App Mainifest, Web Push, Service Worker, ...

# App Mainifest

// 声明manifest.json,在html引入,这样我们在高版本浏览器访问我们的网站,可以将我们的网站入口形成一个图标放置在浏览器的主屏幕中
<link  rel="mainifest" href="./manifest.json" />


// manifest.json
{
   // 必须的字段3个
   "name": "MyExtension", // 扩展名称
   "version": "1.0", // 版本。由1到4个整数构成。多个整数间用"."隔开
   "manifest_version": 2, // manifest文件版本号。Chrome18开始必须为2
   // 建议提供的字段3个
   "description": "", // 描述。132个字符以内
   "icons": {
      "16": "image/icon-16.png",
      "48": "image/icon-48.png",
      "128": "image/icon-128.png"
   }, //扩展图标。推荐大小16,48,128
   ...
}

# Service Worker

所谓的Service Worker,本质上也是浏览器缓存资源用的,一个服务器与浏览器之间的中间人角色。

如果网站中注册了service worker那么它可以拦截当前网站所有的请求,进行判断(需要编写相应的判断程序),如果需要向服务器发起请求的就转给服务器,如果可以直接使用缓存的就直接返回缓存不再转给服务器。从而大大提高浏览体验。

特点:

  • 单独web worker独立线程,在这里跑程序,不影响主线程执行任务
  • 可以访问cacheindexDB,可以管理自己的缓存
  • 事件驱动的,具有生命周期
  • 必须是https协议
// 1、主入口html注册service worker
/* 判断当前浏览器是否支持serviceWorker */
if ('serviceWorker' in navigator) {
    /* 当页面加载完成就创建一个serviceWorker */
    window.addEventListener('load', function () {
        /* 编写serviceWorker.js文件,注册我们的serviceWorker */
        navigator.serviceWorker.register('./serviceWorker.js')
            .then(function (registration) {

                console.log('ServiceWorker 注册成功,范围: ', registration.scope);
            })
            .catch(function (err) {

                console.log('ServiceWorker 注册失败: ', err);
            });
    });
}


// 2、serviceWorker.js 安装与监听

/* 监听安装事件,install 事件一般是被用来设置你的浏览器的离线缓存逻辑 */
this.addEventListener('install', function (event) {
    
    /* 通过这个方法可以防止缓存未完成,就关闭serviceWorker */
    event.waitUntil(
        /* 创建一个名叫V1的缓存版本 */
        caches.open('v1').then(function (cache) {
            /* 指定要缓存的内容,地址为相对于跟域名的访问路径 */
            return cache.addAll([
                './index.html',
                './a.css',
                './b.css'
            ]);
        })
    );
});

// 删除旧cache
function deletePreCaches() {
    // ...
}

//service worker激活阶段,说明上一sw已失效
this.addEventListener('activate', function(event) {
    
    event.waitUntil(
        // 遍历 caches 里所有缓存的 keys 值
        caches.keys().then(deletePreCaches)
    );
});

/* 注册fetch事件,拦截全站的请求 */
this.addEventListener('fetch', function(event) {
    event.respondWith(
      // magic goes here
        
        /* 在缓存中匹配对应请求资源直接返回 */
      caches.match(event.request)
    );
  });

参考:谷歌官方制作自己的第一个service worker应用 (opens new window)

  • Web Push

PWA中的另一个重要功能——消息推送与提醒(Push & Notification)。这个能力让我们可以从服务端向用户推送各类消息并引导用户触发相应交互

# 备注

  • 浏览器Performnace使用?

  • js异步加载有哪些方案?asyncdefer的区别?

  • 在浏览器中从输入 URL 到页面展示,这中间发生了什么?

  • 虚拟滚动,1000数据加载,页面怎么不卡顿?

  • 导航的时候,做哪些优化?

1. DNS查询,`<link ref="dns-prefetch" href="xxxx">`
2. http1.1;connection: keep-alive;减少tcp三次握手连接;
3. 使用http2,并行传输,头部压缩;
4. 强缓存,协商缓存;
5. CDN;
6. GZIP压缩;
7. 预加载:`<link rel="preload" href="xxx" />`; 预加载TCP链接:`<link rel="preconnect" href="xxx" />`
8. defer, async
9. 减少资源大小;
10. 图片优化:雪碧图,iconFont
11. webpack TeserPlugin压缩js,摇树
  • 代码层面优化?
1. loading骨架屏,减少等待
2. 减少无意义的回流,重绘;
> 调整窗口大小,改变字体,js操作dom变化,计算使用offsetWidth,...都会触发回流重绘

3. tree-shaking
4. 压缩js
5. 动态import导入,按需加载
6. 分层概念,GPU加速渲染

# 参考

上次更新: 9/19/2024, 7:59:59 PM
最近更新
01
taro开发实操笔记
09-29
02
前端跨端技术调研报告
07-28
03
Flutter学习笔记
07-15
更多文章>