关于前端性能优化你了解多少?很多人都知道雅虎军规,但是你知道为什么吗?
Vitaly Friedman写了一篇关于前端性能优化的清单,里面引用了至少六七百篇参考链接,详细介绍了前端性能优化相关知识,强烈建议大家好好阅读一下,包括引用的文章。英语阅读有困难的,去年京东的WecTeam团队也针对此篇文件进行了翻译,当然,那是去年的版本了。
Front-End Performance Checklist 2021[1]
https://www.smashingmagazine....
我学到了很多,希望你也一样,如有任何错误欢迎指出。
一、标准目标
性能优化,优化到什么程度,标准是什么?
(一)标准
● 至少比你最快的竞争对手快20%
● 最大内容绘制(LCP)控制在2.5s内
● 首屏展现平均值(SI)控制在3s内
● 首次输入延时(FID)控制在100ms内
● 累计阻塞时长(TBT)控制在300ms内
● 累积布局变化量(CLS)控制在0.1内
(二)概念
1、比你最快的竞争对手快至少20%[2]
研究表明,只有当你的页面比你的竞争对手快至少20%时,用户才能真正感知到你的页面真的比竞争对手的页面快。
与竞争对手相比,如果你的页面加载时长是5s,而竞争对手的是2s,就算你提升了20%也是不行的,这个时候就需要与竞争对手作比较。如果我们做不到优化到2s,那我们至少要优化到2s + 2 * 20% = 2.4s,这样至少用户不会感知到差异。
如果2.4s也做不到,这里还有一个心理阀值。以刚刚的2s、5s为例,大于此阈值的持续时间将被用户感知为接近5秒。持续时间小于该阈值的持续时间将被视为接近2秒。通过这种概念,我们可以找到它的一个几何平均值:√(A × B),例子中就是:√(2 × 5) ≈ 3.2 seconds,如果加载时间小于3.2s,用户能注意到差异,但是这对他们来说此差异对他们如何选择服务并不重要。
2、最大内容绘制Largest Contentful Paint (LCP) [3]
LCP是一个页面加载时长的技术指标,用于表示当前页面中最重要/占比最大的内容显示出来的时间点。LCP低于2.5s则表示页面加载速度优良。
为了计算最大的元素,LCP会考虑以下元素:
● <img>, <image> or <video> elements
● Elements with background images. E.g: background-image: url()
● Block level elements containing text
计算方式
可使用Largest Contentful Paint API
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
console.log('LCP candidate:', entry.startTime, entry);
}
}).observe({type: 'largest-contentful-paint', buffered: true});
还可使用第三方库web-vitals
import { getLCP } from 'web-vitals';
// 可以发送到监控系统
getLCP(console.log);
为了衡量用户感知页面加载进度的关键时间,我们还有一些其他的指标,但都多多少少有些缺陷:
指标 | 定义 | 缺点 |
---|---|---|
首次内容渲染(FCP)First Contentful Paint | 浏览器呈现第一块DOM内容的时间 | 通常与用户无关(如:loading、导航栏) |
首次有效渲染(FMP)First Meaningful Paint | 在页面加载期间,计算的最大的布局更改后的绘制时间 | 1、没有标准化,很难跨浏览器实现;2、大约20%的情况不准确与SI相比,它更容易理解,与SI的结果相似,且在RUM工具中更易于计算和报告。 |
影响因素:缓慢的服务器响应时间,阻塞的CSS、JavaScript,网页字体加载,昂贵的渲染或绘画操作,延迟加载的图像,骨架屏幕或客户端渲染
优化方式:
● 消除阻塞渲染资源
● 通过加载具有正确优先级和正确顺序的资源来最小化关键请求链
● 压缩图像,并为不同的设备提供不同的图像大小
● 通过最小化文件和提取关键的CSS来优化CSS
● 使用字体加载策略来避免不可见文本(FOIT)的闪现
LCP评分低的主要原因通常是图像。要在Fast 3G上以小于2.5s的速度交付LCP——托管在一个优化良好的服务器上,所有静态的没有客户端渲染,并且有一个来自专用图像CDN的图像——意味着最大的理论图像大小只有大约144KB。这就是为什么响应式图像很重要,以及提前预加载关键图像(使用预加载)的原因。
在DevTools中,你可以将鼠标悬停在Performance面板“Timing”下的LCP栏上,以发现什么元素触发了LCP。
3、首屏展现平均值Speed Index(SI)
Speed Index是衡量页面性能的指标,显示页面的可见部分的平均时间。该值越低越好,SI低于3s则表示页面加载速度良好。
计算方式:需要能够计算在页面加载期间,各个时间点“完成”了多少部分。在WebPagetest中,通过捕获在浏览器中加载页面的视频并检查每个视频帧(在启用视频捕获的测试中,每秒10帧)来完成的。计算比较复杂,有兴趣的同学可以翻阅资料。
它是一个复杂的指标,不容易解释;且计算密集,所以它不能用于任何主流浏览器的真实用户监控(RUM)。
优化方式:
● 优化内容效率:消除不必要的下载,使用压缩技术优化每个资源的传输编码,尽可能利用缓存消除冗余下载
● 优化关键渲染路径:只加载当前页面渲染所需的必要资源,将次要资源放在页面渲染完成后加载
注意,随着LCP成为一个更相关的指标,它变得不那么重要了。
4、首次输入延时First Input Delay(FID)[4]
FID衡量的是用户第一次与页面交互到页面真正响应的时间。注:滚动与缩放不包括在此指标中。延时越长,用户体验越差,最好控制在100ms内。减少页面初始化时间,消除长任务可以有效帮助消除首次输入延时。
计算方式:
可以使用Event Timing API
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const delay = entry.processingStart - entry.startTime;
console.log('FID candidate:', delay, entry);
}
}).observe({type: 'first-input', buffered: true});
还可借助第三方库web-vitals
import { getFID } from 'web-vitals';
// 可以发送到监控系统
getFID(console.log);
优化方式:
● 分解长任务
● 使用 Web Worker
● 减少JS执行时间
● 优化数据获取
● 延迟第三方脚本执行
● 在SPA中采用渐进式注水
简单来说,获得更好的FID分数的可靠策略是将主线程上的工作最小化,方法是将较大的捆绑包分解成较小的捆绑包,并在用户需要时为其提供服务,这样用户交互就不会被延迟。
5、累计阻塞时长Total Blocking Time(TBT)[5]
TBT是一个衡量用户事件响应的指标。TBT会统计在FP和TTI时间之间,主线程被阻塞的时间总和。当主线程被阻塞超过50ms导致用户事件无法响应,这样的阻塞时长就会被统计到TBT中。TBT越小说明页面能够更好的快速响应用户事件。当页面TBT在300ms内时被认为良好,如果超过600ms则被认为慢了。
计算方式:统计任务超过50ms部分,如下图,3个任务,分别执行了120ms、30ms、75ms,总的TBT是95ms。
优化方式:
● 对JavaScript包进行代码拆分,并延迟加载那些对初始加载不重要的包
● 在可能的情况下,将代码分解成工作更少、执行更快的函数
● 减少过多的DOM查询
● 将计算密集型任务卸载给Service Worker或Web Worker
6、累计布局变化Cumulative Layout Shift (CLS)[6]
CLS是一个衡量页面内容是否稳定的指标,量化了页面加载期间viewport内移动的元素数量,帮助确定页面上发生意外移动的频率。CLS的分数越低,表明页面的布局稳定性越高,通常低于0.1表示页面稳定性良好。
计算方式:CLS = 距离分数 * 冲击分数
● 距离分数:不稳定元素移动的距离
● 冲击分数:受不稳定元素影响的视口表面积
如:元素向下移动了三分之一的视口高度,所以距离分数为0.33,元素在初始位置和移动后占据的区域构成了视口表面的⅔,所以影响分数是0.66。所以,布局移位得分为0.33 × 0.66 = 0.2178。
可使用Layout Instability API
let cls = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
cls += entry.value;
console.log('Current CLS value:', cls, entry);
}
}
}).observe({type: 'layout-shift', buffered: true});
还可以借助第三方库web-vitals
import {getCLS} from 'web-vitals';
// Measure and log CLS in all situations
// where it needs to be reported.
getCLS(console.log);
影响因素:后备字体和web字体有不同的字体指标,或广告,嵌入或iframes来晚了,或图像/视频尺寸没有保留,或晚期CSS强制重绘,或更改被晚期JavaScript注入。
优化方式:
● img元素:添加宽度和高度属性
● 广告:提前定义广告空间的尺寸
● 动态内容:使用内容占位符,这样一旦真正的内容加载,布局就不会发生剧烈变化
● 动画:使用transform属性
● 具有font-display: swap属性的web字体:页面布局通常会在web字体加载和替换回退字体时发生变化,因为它们之间的大小不同,要避免这个问题,请使用字体样式匹配器选择具有相似尺寸的后备字体
7、可交互时间Time to Interactive(TTI)
TTI度量主线程有5秒没有网络活动或JavaScript长任务的时间,长任务指耗时超过50ms的JavaScript任务。在这个时间点上,布局已经稳定,关键的webfonts是可见的,并且主线线程已经足够处理用户输入-基本上是用户可以与UI交互的时间标记。是了解用户在使用站点时要经历多少等待而没有延迟的关键指标。它主要标识主线程何时空闲,而TBT量化主线程在空闲之前的繁忙程度。
良好的页面性能需要在慢3G中TTI要控制在5s内,二次访问时TTI控制在2s内。
计算方式:最简单的方式是通过 Performation Api。
const timing = performance.timing
const TTI = timing.loadEventEnd - timing.navigationStart
优化方式:
● 最小化主线程工作
● 减少JavaScript执行时间
注意:FID和TTI都不考虑滚动行为;滚动可以独立发生,因为它是非主线程。
(三)核心Web指标Core Web Vitals[7]
谷歌推荐一系列可接受的速度目标,至少75%的页面浏览量应该超过“良好”范围,才能通过这个评估。这些指标迅速获得了关注,并在2021年5月成为谷歌搜索的排名信号
1、LCP < 2.5s on Fast 3G
2、FID < 100ms
3、CLS < 0.1
二、现实目标
(一)响应时间100ms,每秒60帧
为了让交互感觉流畅,页面响应用户的输入动作最好小于100ms。
为了达到<100毫秒的响应时间,页面必须每隔<50毫秒将控制权交还给主线程。输入延迟可以告诉我们是否达到该阈值,理想情况下,它应该低于50ms。对于像动画这样的敏感点,最好在你执行动画的时候什么都不做。
另外,动画的每一帧都应在16毫秒内完成,从而达到每秒60帧(1秒÷60 = 16.6毫秒)- 由于浏览器需要时间才能在屏幕上绘制新的帧,而你的代码应在16.6毫秒之前完成执行,所以最好在10毫秒以内。
(二)3G环境下 FID < 100ms, LCP < 2.5s, TTI < 5s,关键文件大小 < 170KB (gzip压缩后)
虽然很难实现,但一个好的目标是将TTI控制在5s内,二次访问时,将TTI控制在2s内。LCP的目标是2.5s以下,同时最大限度地减少TBT和CLS。一个可以接收的FID需要控制在100-70ms内。
为了网络快速传输内容,有2个限制因素。一方面由于TCP慢启动,我们受网络传输限制:HTML的前14KB是最关键的有效传输块,并且是内容中唯一可以在第一次网络往返就传输完成的部分( 加上移动网络唤醒时间的原因,这就是您在RTT为400ms网络的环境下在1s内能获得的最多内容)。另一方面,由于JavaScript解析需要时间,我们在内存和CPU方面存在硬件限制,为了实现我们的目标,必须考虑JS关键文件大小,压缩到170KB JavaScript的文件已经需要花费了1s才能在普通手机上进行解析和编译。假设170KB的文件在解压缩时扩展到原来的3倍(0.7MB),那在Moto G4 / G5 Plus上这类机型上就已经很大达到“体面的”用户体验了。针对懒加载路由,Addy Osmani也建议控制在35KB以内。
注:为什么是14KB?当TCP启动时,它允许发送10个TCP包,然后它们必须被确认。当这些数据包被确认后,它就允许发送更多的数据包,加倍后允许下一次发送20个数据包,然后是40个,然后是80个……随着它逐渐建立到网络可以处理的全部容量。14kb的神奇数字是因为每个TCP包最多可以有1500个字节,但是其中40个字节是留给TCP使用的(TCP头等),剩下1460个字节留给实际数据。其中的10个数据包意味着您可以交付14600字节或大约14kb(实际上是14.25 KB)。当然,这都是理论上的,实际如何有兴趣的可以阅读博客[8]。
三、选择设备
为了收集准确的数据,我们需要彻底全面地选择要测试的设备。
根据IDC的数据,到2020年,全球84.8%的手机出货量为Android设备。普通消费者每两年更换一次手机,而美国的手机更换周期是33个月。全球最畅销手机的平均售价不到200美元。
那么,一个代表性的设备,是一个Android设备,至少用了24个月,价格为200美元或更少,运行在缓慢的3G, 400ms RTT和400kbps传输。当然,这比较悲观了。虽然这可能是符合大部分的用户,但我们还是要以公司实际的客户为准。
针对以上情况,在开放式设备实验室中,我们可以选择稍老一点的Moto G4/G5 Plus,中档三星设备(Galaxy A50, S8),不错的中档设备,如Nexus 5X,小米Mi A3或小米红米Note 7,以及速度较慢的设备,如阿尔卡特1X或Cubot X19。如果你想在较慢的热调节设备上测试,你也可以买一台Nexus 4,价格仅为100美元左右。
此外,检查每个设备使用的芯片组,机型组合不要过度集中在一个芯片组中: 建议包含几代的骁龙CPU(Snapdragon)和苹果CPU(Apple)以及低端的Rockchip、联发科(Mediatek)
如果您手头没有设备,可以先在在台式机上模拟移动体验:节流的3G网络(例如300ms RTT、1.6 Mbps下行、0.8 Mbps上行)和CPU限速(减速5倍)上进行测试。最终切换到常规3G、低速4G(例如170ms RTT、9 Mbps下行、9 Mbps上行)和Wi-Fi。
四、选择环境
(一)构建工具
不要过于关注那些被认为很酷的工具,坚持在你的开发流程中进行构建优化,无论是Grunt、Gulp、Webpack、Parcel还是这些工具的组合。只要获得了想要的结果,并且维护构建过程没有问题,那就足够了。
在所有构建工具中,Rollup不断获得关注,Snowpack也一样,但Webpack似乎是最成熟的一个,它有数百个插件可用来优化构建的大小。查看Webpack 2021路线图。如果想了解更多Webpack,可以阅读原文,提供了一系列学习资源。
(二)框架
由于有非常多的未知因素影响加载性能-网络、负载均衡、缓存覆盖、第三方脚本、解析器阻塞模式、磁盘I/O、IPC延迟、安装的扩展、防病毒软件和防火墙、后台CPU任务、硬件和内存限制、二级/三级缓存的差异、RTTS。JavaScript是其中影响最多、成本最高的,仅次于默认情况下阻止页面呈现的Web字体和经常消耗太多内存的图像。
之前提到的170KB的预算已经包含关键路径HTML/CSS/JavaScript、路由器、状态管理、实用程序、框架和应用程序逻辑,所以我们必须彻底检查我们选择的框架的网络传输成本、解析/编译时间和运行时成本。
现在,不是每个web应用都需要前端框架,在单页应用程序中也不是每个页面都需要加载框架。在Netflix的场景中,“从客户端删除React,一些第三方库和相应的应用程序代码可以将JavaScript的总量减少200KB以上,从而使Netflix首页登录的可互动时间减少了50%以上。”然后,该团队利用用户在登录页面上花费的时间来预取React,这样做在用户可能访问的后续页面中就不用继续加载React了。
一旦选择好了框架,我们通常会至少使用几年,所以一定要确保我们的选择是了解并经过深思熟虑的。
数据表明,框架本身就相当昂贵:58.6% 的React 页面JavaScript超过1 MB, 36% 的Vue.js 页面加载FCP小于1.5s。根据Ankur Sethi研究结果:"在印度,React应用程序无论你怎么优化,在普通手机上加载速度都不可能超过1.1s,Angular应用程序需要花至少2.7s才能完成启动,Vue应用程序需要用户至少等待1s才能开始使用。虽然印度并非你的主要市场,但是使用次优网络添加访问你网站的用户会有类似的体验。
如果选择框架?至少要考虑框架的大小以及框架的初始执行时间。Seb Markbège指出,衡量框架启动成本的一个好方法是先渲染视图,然后删除视图再二次渲染,这样就可以知道框架绘制的成本。因此首次渲染视图之前往往需要预热一堆延迟编译的代码,更大DOM树在绘制时受益更多。二次渲染则是模拟页面上的代码复用度是如何随着页面复杂度的增加而影响性能特征。
Sacha Greif的12分制评分系统[9]也可帮助你评估一个框架(或任何JavaScript库)包括:探索特性、可访问性、稳定性、性能、包生态系统、社区、学习曲线、文档、工具、跟踪记录、团队、兼容性、安全性等。还可以依赖Perf Track[10],长期大规模跟踪框架性能,收集Angular,React,Vue,Polymer,Preact,Ember,Svelte和AMP在构建的网站的原始汇总的Core Web Vitals分数。
(三)渲染方式[11]
最终的方法是设置某种渐进式引导:使用服务端渲染来获得一个快速的FCP,但也包括一些必要的JS来保持TTI接近FCP。如果JS在FCP之后出现得太晚,浏览器将在解析、编译和执行迟发现的JS时锁定主线程,从而限制站点或应用程序的交互性。为了避免此类问题发生,将任务划分为独立、异步的,可以考虑使用import()动态加载的方式,只有在用户真正需要时才去加载、解析、编译。
关于渲染方式,我们有几种选择:
● Full Server-Side Rendering (SSR)
所有请求都在服务器上完成。类似以前的JSP、PHP。
优点:请求的内容作为完成的HTML页面返回,浏览器可以立即呈现它。因为HTML是流到浏览器,FCP和TTI之间的差距通常很小。由于它是在浏览器获得响应之前处理的,同时避免了客户端数据获取和模板的额外往返。
缺点:SSR应用程序不能真正利用DOM API;服务器响应时间较长,没有利用现代应用程序的响应性和丰富的特性。
● Static Rendering
构建时发生,单页应用程序,所有页面都预先呈现为静态HTML,构建步骤中使用最少的JS。
优点:页面的HTML不需要动态生成,服务器响应时间快,可以快速获取第一个字节。
缺点:必须为每个可能的URL生成单独的HTML文件。当你无法提前预测是哪些URL时或用于具有大量独特页面的站点时,这可能具有挑战性,甚至是不可行的。
● Server-Side Rendering With (Re)Hydration (Universal Rendering, SSR + CSR)
一次呈现多个请求,将生成内容时以块的形式发送内容。(服务器将应用程序呈现为HTML,然后在客户端上请求JavaScript和用于呈现的数据再次渲染来“修补” 。)
优点:获得客户端应用的完全灵活性,同时提供更快的服务器端渲染,改进了First Paint。由于使用了SSR,所有也有其优点,实现快速的FCP。
缺点:FCP与TTI之间产生更长的间隔,同时增加了FID。SSR的页面通常看起来具有欺骗性,在执行客户端JS并附加事件处理程序之前,无法响应输入。
● Streaming Server-Side Rendering With Progressive Hydration (SSR + CSR)
与上一个类似,内容以流式形式生成并发送。
优点:不必等待完整的HTML字符串后再将内容发送到浏览器,因此改进了Time to First Byte。在客户端,采用逐步启动组件,减少FID与TTI,同时缩小FCP与TTI直接的间隔。
● Trisomorphic Rendering
为初始/非js导航提供流服务器渲染,然后让service worker在安装后为导航提供HTML渲染。
● CSR With Prerendering
预渲染类似于服务器端呈现,但不是在服务器上动态呈现页面,而是在构建时将应用程序渲染为静态HTML。它在构建时将客户端应用程序的初始状态捕获为静态HTML,而预渲染应用程序必须在客户端上启动,以便页面是交互式的。
优点:更好的Time To First Byte和FCP,减少了TTI和FCP之间的差距。
缺点:如果预期内容会有很大变化,我们就不能使用这种方法。另外,必须提前知道所有url才能生成所有页面。
● Full Client-Side Rendering (CSR)
使用JavaScript在浏览器中直接渲染页面。所有逻辑,数据获取,模板和路由都在客户端而不是服务器上处理。
缺点:TTI与FCP差距较大。由于整个应用程序必须在客户端上启动才能呈现内容,所以整个应用程序会比较慢。随着应用程序的增长,所需的JavaScript数量趋于增长。
总的来说,SSR比CSR要快,仅仅只是SSR,或仅仅只是CSR都不是一个好的方式,最好是结合两者。如果您的页面变化不大,请考虑预渲染,并尽可能推迟框架的启动。使用服务器端呈现的HTML块流,并为客户端呈现实现循序渐进的水化——并在可见性、交互或空闲时间水化,以获得两者的最佳效果。
当然,如果可以,我们还是需要尽可能的静态化,提前预构建更多的内容,而不是在请求时在服务器或客户端上生成页面视图,我们将获得更好的性能。
另外,还可以考虑使用PRPL模式和骨架屏,其想法非常简单:将实现初始路由交互所需的最小代码推入,以便快速呈现,然后使用service worker缓存和预缓存资源,然后异步地惰性加载所需的路由。
(四)其他
1、尽可能的从CDN静态提供内容
我们预先构建的内容越多,而不是在请求时在服务器或客户端上生成页面视图,我们将获得更好的性能。
2、考虑使用PRPL模式和App Shell架构
PRPL模式[12]
它描述一种用于使网页加载并变得交互式,更快的模式:
● 推送(或预加载)最重要的资源。
● 尽快渲染初始路线。
● 预缓存剩余资产。
● 延迟加载其他路由和非关键资产。
App Shell 架构[13]
是支持用户界面的最小HTML,CSS和JavaScript。App Shell架构应:
● 快速加载
● 被缓存
● 动态显示内容
3、优化API的性能
GraphQL[14]是API的一种查询语言,并且是服务器端运行时的,用于通过使用为数据定义的类型系统执行查询。与REST不同,GraphQL可以在单个请求中检索所有数据,并且只响应所需的内容,而不会像REST通常那样过度或不足地获取数据。
此外,因为GraphQL使用模式(schema,定义数据结构的元数据),所以它可以提前将数据组织成所需的结构,因此使用GraphQL,我们可以删除用于处理状态和数据结果的JavaScript代码,从而产生在客户端上运行更快更干净的应用程序代码。
4、使用AMP还是Instant Articles?
AMP[15]:是一个Web组件框架,可轻松为Web创建用户至上的体验。
Instant Article[16]:一种原生格式,可供发行商创建加载迅速的 Facebook 互动式文章。
Apple News[17]: 是专业新闻出版物的平台。
5、合理选择CDN
仔细检查你的CDN是否支持执行压缩和转换(例如,图像优化和调整大小),是否为Service Worker提供支持,A/B测试,将在CDN层面组合页面的静态和动态部分(距离用户最近的服务器)以及其他支持的特性。此外,检查你的CDN是否支持HTTP over QUIC(HTTP/3)。
如何选择CDN,可以参考Katie Hempenius关于CDN的指导[18]。通常,最好是尽可能积极地缓存内容并启用CDN性能功能(如Brotli,TLS 1.3,HTTP / 2和HTTP / 3)。原文还有很多关于CDN比较的网站,有兴趣的也可以看一下。
欢迎关注我的个人公众号:
参考资料
Front-End Performance Checklist 2021[1]:https://www.smashingmagazine....
Why Perceived Performance Matters, Part 1: The Perception Of Time[2]:https://www.smashingmagazine....
Largest Contentful Paint (LCP)[3]:https://web.dev/lcp/
First Input Delay[4]:https://web.dev/fid/
Total Blocking Time (TBT) [5]:https://web.dev/tbt/
Cumulative Layout Shift (CLS)[6]:https://web.dev/cls/
Web Vitals[7]:https://web.dev/vitals/
web-vitals library:https://github.com/GoogleChro...
Optimize for Core Web Vitals:https://www.youtube.com/watch...
Critical Resources and the First 14 KB[8]:https://www.tunetheweb.com/bl...
12-point scale scoring system[9]:https://www.freecodecamp.org/...
Perf Track[10]:https://perf-track.web.app/
Rendering on the Web[11]:https://developers.google.com...
PRPL pattern[12]:https://web.dev/apply-instant...
App shell architecture[13]:https://developers.google.com...
GraphQL[14]:https://graphql.org/
AMP[15]:https://amp.dev/
Instant Articles[16]:https://www.facebook.com/form...
Apple News[17]:https://developer.apple.com/n...
Content delivery networks (CDNs)[18]:https://web.dev/content-deliv...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。