原文地址

很有实际意义的一片优化文章,防止链接挂掉,特地复制下来

通过案例你可以学到什么

简单总结一下设计的知识点

  • onload
    • DOM 的解析受 JS 加载和执行的影响,尽量对 JS 进行压缩、拆分处理(HTTP2环境下),能减少 DOMContentLoaded 时间。
    • 图片、视频、iframe 等资源,会阻塞 onload 事件的触发,需要优化资源的加载时机,尽快触发 onload。
  • 性能指标
  • threeshaking
  • Splitchunks 拆分首屏js
  • 延迟加载非必要js prefetch
  • lazyload
  • 图片优化
  • coverage 查看首屏 js css使用比例
  • critters critters-webpack-plugin 为预渲染/SSR 的单页应用程序内联关键 CSS
  • CLF 页面布局抖动

项目背景

H5 项目是企鹅辅导的核心项目,已迭代四年多,包括了课程详情页/老师详情页/报名页/支付页面等页面,构建产物用于企鹅辅导 APP/H5(微信/QQ/浏览器),迭代过程中了也累积了一些性能问题导致页面加载、渲染速度变慢,为了提升用户体验,近期启动了 “H5 性能优化” 项目,针对页面加载速度,渲染速度做了专项优化,下面是对本次优化的总结,包括以下几部分内容:

  1. 性能优化效果展示
  2. 性能指标及数据采集
  3. 性能分析方法及环境准备
  4. 性能优化具体实践

一、性能指标及数据采集

企鹅辅导 H5 采用的性能指标包括:

\1. 页面加载时间 —— 页面以多快的速度加载和渲染元素到页面上,具体如下:

  • First contentful paint (FCP):测量页面开始加载到某一块内容显示在页面上的时间。
  • Largest contentful paint (LCP):测量页面开始加载到最大文本块内容或图片显示在页面中的时间。
  • DomContentLoaded Event:DOM 解析完成时间。
  • OnLoad Event:页面资源加载完成时间。

\2. 加载后响应时间 —— 页面加载和执行js代码后多久能响应用户交互。

  • First input delay (FID):测量用户首次与网站进行交互(例如点击一个链接、按钮、js自定义控件)到浏览器真正进行响应的时间。

\3. 视觉稳定性 —— 页面元素是否会以用户不期望的方式移动,并干扰用户的交互。

  • Cumulative layout shift (CLS):测量从页面开始加载到状态变为隐藏过程中,发生不可预期的 layout shifts 的累积分数。

项目使用了 IMLOG 进行数据上报,ELK 体系进行现网数据监控,Grafana 配置视图,观察现网情况。

根据指标的数据分布,能及时发现页面数据异常采取措施。

二、性能分析及环境准备

现网页面情况:

图片

可以看到进度条在页面已经展示后还在持续 loading,加载时间长达十几秒,比较影响了用户体验。

根据Google 开发文档对浏览器架构的解释:

当导航提交完成后,渲染进程开始着手加载资源以及渲染页面。一旦渲染进程“完成”(finished)渲染,它会通过 IPC 告知浏览器进程(注意这发生在页面上所有帧(frames) 的 onload 事件都已经被触发了而且对应的处理函数已经执行完成了的时候),然后 UI 线程就会停止导航栏上旋转的圈圈。

我们可以知道,进度条的加载时长和 onload 时间密切相关,要想进度条尽快结束就要减少 onload 时长。

根据现状,使用 ChromeDevTool 作为基础的性能分析工具,观察页面性能情况:

  • Network:观察网络资源加载耗时及顺序
  • Performace:观察页面渲染表现及JS执行情况
  • Lighthouse:对网站进行整体评分,找出可优化项

下面以企鹅辅导课程详情页为案例进行分析,找出潜在的优化项。

(注意使用 Chrome 隐身窗口并禁用插件,移除其他加载项对页面的影响。)

1. Network 分析

通常进行网络分析需要禁用缓存、启用网络限速(4g / 3g) 模拟移动端弱网情况下的加载情况,因为 wifi 网络可能会抹平性能差距。

图片

可以看到 DOMContentLoaded 的时间在 6.03s ,但 onload 的时间却在 20.92s。

先观察 DOMContentLoaded 阶段,发现最长请求路径在 vendor.js ,JS大小为 170kB,花费时间为 4.32s。

继续观察 DOMContentLoaded 到 onload 的这段时间:

图片

可以发现 onload 事件被大量媒体资源阻塞了,关于 onload 事件的影响因素,可以参考这篇文章

结论是浏览器认为资源完全加载完成(HTML解析的资源和动态加载的资源)才会触发 onload。

结合上图可以发现加载了图片、视频、iframe 等资源,阻塞了 onload 事件的触发。

Network 总结

  1. DOM 的解析受 JS 加载和执行的影响,尽量对 JS 进行压缩、拆分处理(HTTP2环境下),能减少 DOMContentLoaded 时间。
  2. 图片、视频、iframe 等资源,会阻塞 onload 事件的触发,需要优化资源的加载时机,尽快触发 onload。

2. Performance 分析

使用 Performance 模拟移动端注意手机处理器能力比 PC 差,所以一般将 CPU 设置为 4x slowdown 或 6x slowdown 进行模拟。

图片

观察几个核心的数据:

  1. Web Vitals ( FP / FCP / LCP / Layout Shift ) 核心页面指标 和 Timings 时长

可以看到 LCP、DCL和 Onload Event 时间较长,且出现了多次 Layout Shift。

要 LCP 尽量早触发,需要减少页面大块元素的渲染时间,观察 Frames 或ScreenShots 的截图,关注页面的元素渲染情况。

可以通过在 Experience 行点击Layout Shift ,在 Summary 面板找到具体的偏移内容。

图片

  1. Main Long Tasks 长任务数量和时长

可以看到页面有大量的 Long Tasks 需要进行优化,其中 couse.js (页面代码)的解析执行时间长达 800ms。

处理 Long Tasks,可以在开发环境进行录制,这样在 Main Timeline 能看到具体的代码执行文件和消耗时长。

Performance 总结

  1. 页面 LCP 触发时间较晚,且出现多次布局偏移,影响用户体验,需要尽早渲染内容和减少布局偏移。
  2. 页面 Long Tasks 较多,需要对 JS 进行合理拆分和加载,减少 Long Tasks 数量,特别是 影响 DCL 和 Onload Event 的 Task。

3. Lighthouse 分析

使用 ChromeDevTool 内置 lighthouse 对页面进行跑分:

图片

分数较低,可以看到 Metrics 给出了核心的数据指标,这边显示的是 TTI SI TBT 不合格,LCP 需要提升,FCP 和 CLS 达到了良好的标准,可以查看分数计算标准

同时 Lighthouse 会提供一些优化建议,在 Oppotunities 和 Diagnostics 项,能看到具体的操作指南,如图片大小、移除无用 JS 等,可以根据指南进行项目的优化。

Lighthouse 的评分内容是根据项目整体加载项目进行打分的,审查出的问题同样包含 Network、Performance 的内容,所以也可以看作是对 Network、Performance 问题的优化建议。

Lighthouse 总结

  1. 根据评分,可以看出 TTI、SI、TBT、LCP这四项指标需要提高,可以参考lighthouse 文档进行优化。
  2. Oppotunities 和 Diagnostics 提供了具体的优化建议,可以参考进行改善。

4. 环境准备

刚才是对线上网页进行初步的问题分析,要实际进行优化和观察,需要进行环境的模拟,让优化效果能更真实在测试环境中体现。

代理使用:whistle、charles、fiddler 等。

本地环境、测试环境模拟:nginx、nohost、stke 等。

数据上报:IMLOG、TAM、RUM 等(这三个工具均为团队内部的日志上报工具,类似业界 log4js、sentry)。

前端代码打包分析:webpack-bundle-analyzer 、rollup-plugin-visualizer 等。

分析问题时使用本地代码,本地模拟线上环境验证优化效果,最后再部署到测试环境验证,提高开发效率。

三、性能优化具体实践

PART1: 加载时间优化

Network 中对页面中加载的资源进行分类:图片

第一部分是影响 DOM 解析的 JS 资源,可以看到这里分类为关键 JS 和非关键 JS,是根据是否参与首面渲染划分的。

这里的非关键 JS 我们可以考虑延迟异步加载,关键 JS 进行拆分优化处理。

1. 关键 JS 打包优化

图片

JS 文件数量8个,总体积 460.8kB,最大文件 170KB

1.1 Splitchunks 的正确配置

vendor.js 170kB(gzipd) 是所有页面都会加载的公共文件,打包规则是 miniChunks: 3,引用超过3次的模块将被打进这个js。

图片

图片

分析vendor.js的具体构成(上图)

以 string-strip-html.umd.js 为例 大小为34.7KB,占了 vendor.js 的 20%体积,但只有一个页面多次使用到了这个包,触发了 miniChunks 的规则,被打进了 vendor.js。

同理对 vendor.js 的其他模块进行分析,iosSelect.js、howler.js、weixin-js-sdk 等模块都只有 3、4 个页面/组件依赖,但也同样打进了 vendor.js。

由上面的分析,我们可以得出结论:不能简单的依靠 miniChunks 规则对页面依赖模块进行抽离打包,要根据具体情况拆分公共依赖。

修改后的 vendor 根据业务具体的需求,提取不同页面和组件都有的共同依赖(imutils/imlog/qqapi)。

1
2
3
4
5
6
7
8
9
vendor: {
test({ resource }) {
return/[\\/]node_modules[\\/](@tencent\/imutils|imlog\/)|qqapi/.test(resource);
},
name: 'vendor',
priority: 50,
minChunks: 1,
reuseExistingChunk: true,
},

而其他未指定的公共依赖,新增一个 common.js,将阈值调高到 20 或更高(当前页面数76),让公共依赖成为大多数页面的依赖,提高依赖缓存利用率,调整完后,vendor.js 的大小减少到 30KB,common.js 大小为 42KB。

两个文件加起来大小为 72KB,相对于优化前体积减少了 60%(100KB)。

1.2 公共组件的按需加载

course.js 101kB (gzipd) 这个文件是页面业务代码的文件:

图片

观察上图,基本都是业务代码,除了一个巨大的 component Icon,占了 25k,页面文件1/4的体积,但在代码中使用到的 Icon 总共才8个。

分析代码,可以看到这里使用 require 加载 svg,Webpack 将 require 文件夹内的内容一并打包,导致页面 Icon 组件冗余。

图片

如何解决这类问题实现按需加载?

按需加载的内容应该为独立的组件,我们将之前的单一入口的 Icon 组件(动态 dangerouslySetInnerHTML)改成单文件组件模式直接引入使用图标。

图片

但实际开发中这样会有些麻烦,一般需要统一的 import 路径,指定需要的图标再加载,参考 babel-plugin-import,我们可以配置 babel 的依赖加载路径调整 Icon 的引入方式,这样就实现了图标的按需加载。

图片

按需加载后,重新编译,查看打包带来的收益,页面的 Icons 组件 stat size 由 74KB 降到了 20KB,体积减少了 70%。

1.3 业务组件的代码拆分 (Code Splitting)

观察页面,可以看到”课程大纲“、”课程详情“、”购课须知“这三个模块并不在页面的首屏渲染内容里:

图片

我们可以考虑对页面这几部分组件进行拆分再延迟加载,减少业务代码 JS 大小和执行时长。

拆分的方式很多,可以使用 react-loadable、@loadable/component 等库实现,也可以使用React 官方提供的 React.lazy。

拆分后的代码:

图片

代码拆分会导致组件会有渲染的延迟,所以在项目中使用应该综合用户体验和性能再做决定,通过拆分也能使部分资源延后加载优化加载时间。

1.4 Tree Shaking 优化

项目中使用了 TreeShaking 的优化,用时候要注意 sideEffects 的使用场景,以免打包产物和开发不一致。

经过上述优化步骤,整体打包内容:

图片

JS 文件数量6个,总体积 308KB,最大文件体积 109KB

关键 JS 优化数据对比:

文件总体积 最大文件体积
优化前 460.8 kb 170 kb
优化后 308 kb 109 kb
优化效果 总体积减少 50% 最大文件体积减少 56%

2.非关键 JS 延迟加载

页面中包含了一些上报相关的 JS 如 sentry,beacon(灯塔 SDK)等,对于这类资源,如果在弱网情况,可能会成为影响 DOM 解析的因素。

为了减少这类非关键 JS 的影响,可以在页面完成加载后再加载非关键 JS,如 sentry 官方也提供了延迟加载的方案

在项目中还发现了一部分非关键 JS,如验证码组件,为了在下一个页面中能利用缓存尽快加载,所以在上一个页面提前加载一次生成缓存。

图片

如果不访问下一个页面,可以认为这是一次无效加载,这类的提前缓存方案反而会影响到页面性能。

针对这里资源,我们可以使用 Resource Hints,针对资源做 Prefetch 处理。

检测浏览器是否支持 Prefech,支持的情况下我们可以创建 Prefetch 链接,不支持就使用旧逻辑直接加载,这样能更大程度保证页面性能,为下一个页面提供提前加载的支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const isPrefetchSupported = () => {
const link = document.createElement('link');
const { relList } = link;

if (!relList || !relList.supports) {
returnfalse;
}
return relList.supports('prefetch');
};
const prefetch = () => {
const isPrefetchSupport = isPrefetchSupported();
if (isPrefetchSupport) {
const link = document.createElement('link');
link.rel = 'prefetch';
link.as = type;
link.href = url;
document.head.appendChild(link);
} elseif (type === 'script') {
// load script
}
};

优化效果:非关键JS不影响页面加载

图片

图片

3.媒体资源加载优化

3.1 加载时序优化

可以观察到 onload 被大量的图片资源和视频资源阻塞了,但是页面上并没有展示对应的图片或视频,这部分内容应该进行懒加载处理。

图片

处理方式主要是要控制好图片懒加载的逻辑(如 onload 后再加载),可以借助各类 lazyload 的库去实现。H5项目用的是位置检测(getBoundingClientRect )图片到达页面可视区域再展示。

但要注意懒加载不能阻塞业务的正常展示,应该做好超时处理、重试等兜底措施。

3.2 大小尺寸优化

课程详情页 每张详情图的宽为 1715px,以 6s 为基准(375px)已经是 4x 图了,大图片在弱网情况下会影响页面加载和渲染速度。

图片

使用 CDN 图床尺寸大小压缩功能,根据不同的设备渲染不同大小的图片调整图片格式,根据网络情况,渲染不同清晰度的图。

图片

可以看到在弱网(移动 3G 网络)的情况下,同一张图片不同尺寸加载速度最高和最低相差接近 6 倍,给用户的体验截然不同。

CDN 配合业务具体实现:使用 img 标签 srcset/sizes 属性和 picutre 标签实现响应式图片,具体可参考文档

使用 URL 动态拼接方式构造 URL 请求,根据机型宽度和网络情况,判断当前图片宽度倍数进行调整(如 iPhone 1x,iPad 2x,弱网 0.5x)。

优化效果:移动端 正常网络情况下图片体积减小 220%、弱网情况下图片体积减小 13 倍。

注意实际业务中需要视觉同学参与,评估图片的清晰度是否符合视觉标准,避免反向优化!

3.3 其他类型资源优化

iframe

加载 iframe 有可能会对页面的加载产生严重的影响,在 onload 之前加载会阻塞 onload 事件触发,从而阻塞 loading,但是还存在另一个问题。

如下图所示,页面在已经 onload 的情况下触发 iframe 的加载,进度条仍然在不停的转动,直到 iframe 的内容加载完成。

图片

可以将 iframe 的时机放在 onload 之后,并使用 setTimeout 触发异步加载 iframe,可避免 iframe 带来的 loading 影响。

数据上报

项目中使用 image 的数据上报请求,在正常网络情况下可能感受不到对页面性能的影响。

但在一些特殊情况,如其中一个图片请求的耗时特别长就会阻塞页面 onload 事件的触发,延长 loading 时间。

图片

解决上报对性能的影响问题有以下方案:

  1. 延迟合并上报
  2. 使用 Beacon API
  3. 使用 post 上报

H5项目采用了延迟合并上报的方案,业务可根据实际需要进行选择。

优化效果:全部数据上报在 onload 后处理,避免对性能产生影响。

图片

字体优化

项目中可能会包含很多视觉指定渲染的字体,当字体文件比较大的时候,也会影响到页面的加载和渲染,可以使用 fontmin 将字体资源进行压缩,生成精简版的字体文件。

优化前:20kB => 优化后:14kB:

图片

PART2: 页面渲染优化

1. 直出页面 TTFB 时间优化

名词解释:

NGW:内部网关,基于 Node.js。

STKE:公司内部 TKE。

目前我们在 STKE 部署了直出服务,通过监控发现直出平均耗时在 300+ms。

TTFB 时间在 100 ~ 200 之间波动,影响了直出页面的渲染。

图片

通过日志打点、查看 Nginx Accesslog 日志、网关监控耗时,得出以下数据(如图)

  • STKE 直出程序耗时是 20ms 左右
  • 直出网关 NGW -> STKE 耗时 60ms 左右
  • 反向代理网关 Nginx -> NGW 耗时 60ms 左右

登录 所在机器,ping STKE 机器,有以下数据

平均时延在 32ms,tcp 三次握手+返回数据(最后一次 ack 时发送数据)= 2个 rtt,约 64ms,和日志记录的数据一致

查看 NGW 机器所在区域为天津,STKE 机器所在区域为南京,可以初步判断是由机房物理距离导致的网络时延,如下图所示:

图片

切换 NGW 到南京机器 ping STKE 南京的机器,有以下数据:

图片

同区域机器 ping 的网络时延只有 0.x毫秒,如下图所示:图片

综合上述分析,直出页面TTFB时间过长的根本原因是:NGW 网关部署和 Nginx、STKE 不在同一区域,导致网络时延的产生。

解决方案是让网关和直出服务机房部署在同一区域,执行了以下操作:

  • NGW扩容
  • 北极星开启就近访问

优化前:图片

优化后:图片

优化效果如上图:

七天网关平均耗时
优化前 153 ms
优化后 31 ms 优化 80%(120 ms)

2. 页面渲染时间优化

模拟弱网情况(slow 3g)Performance 录制页面渲染情况,从下图 Screenshot 中可以发现:图片

  1. DOM 开始解析,但页面还未渲染
  2. CSS 文件下载完成后页面才正常渲染

CSS 不会阻塞页面解析,但会阻塞页面渲染,如果 CSS 文件较大或弱网情况,会影响到页面渲染时间,影响用户体验。

借助 ChromeDevTool 的 Coverage 工具(More Tools 里面),录制页面渲染时 CSS 的使用率:

图片

发现首屏的 CSS 使用率才 15%,可以考虑对页面首屏的关键 CSS 进行内联让页面渲染不被CSS 阻塞,再把完整 CSS 加载进来。

实现 Critial CSS 的优化可以考虑使用 critters

优化后效果:

CSS 资源正在下载时,页面已经能正常渲染显示了,对比优化前,渲染时间上提升了 1~2 个 CSS 文件加载的时间。

图片

3. 页面布局抖动优化

观察页面的元素变化:

图片

优化前(左图):图标缺失、背景图缺失、字体大小改变导致页面抖动、出现非预期页面元素导致页面抖动。

优化后:内容相对固定, 页面元素出现无突兀感。

图片

主要优化内容:

  1. 确定直出页面元素出现位置,根据直出数据做好布局
  2. 页面小图可以通过 base64 处理,页面解析的时候就会立即展示
  3. 减少动态内容对页面布局的影响,使用脱离文档流的方式或定好宽高

四、性能优化效果展示

优化效果由以下指标量化:

  • 首次内容绘制时间FCP(First Contentful Paint):标记浏览器渲染来自 DOM 第一位内容的时间点。
  • 视窗最大内容渲染时间LCP(Largest Contentful Paint):代表页面可视区域接近完整渲染。
  • 加载进度条时间:浏览器 onload 事件触发时间,触发后导航栏进度条显示完成。

Chrome 模拟器 4G 无缓存对比(左优化前、右优化后):

图片

首屏最大内容绘制时间 进度条加载(onload)时间
优化前 1067 ms 6.18s
优化后 31 ms 优化 80%(120 ms) 1.19s 优化 81%

**
**

Lighthouse 跑分对比:

优化前:

图片

优化后:图片

性能得分
优化前 平均 40 ~ 50
优化后 平均 75 ~ 85 提升 47%

srobot 性能检测一周数据

srobot 是团队内的性能检测工具,使用 TRobot 指令一键创建页面健康检测,定时自动化检测页面性能及异常。

优化前:图片

优化后:图片

进度条平均加载(onload)时间(4G)
优化前 4632ms
优化后 2581ms 提升45%

五、优化总结和未来规划

  1. 以上优化手段主要是围绕首次加载页面的耗时和渲染优化,但二次加载还有很大的优化空间 如 PWA 的使用、非直出页面骨架屏处理、CSR 转 SSR等
  2. 对比竞品发现我们 CDN 的下载耗时较长,近期准备启动 CDN 上云,期待上云后 CDN 的效果提升。
  3. 项目迭代一直在进行,需要思考在工程上如何持续保障页面性能
  4. 上文是围绕课程详情页进行的分析和优化处理,虽然对项目整体做了优化处理,但性能优化没有银弹,不同页面的优化要根据页面具体需求进行,需要开发同学主动关注。

最详细、最权威的方法见js高级程序设计 6.2 Array(推荐阅读)

数组的遍历方法

for 循环

  • 可以响应 break continue, return报错
  • 改变原数组
    使用临时变量,将长度缓存起来,避免重复获取数组长度,当数组较大时优化效果才会比较明显
1
2
3
for(var j = 0,len=arr.length; j < len; j++) {

}

for-in 循环(不建议用来遍历数组)

  • 可以响应 break continue, return报错
  • 改变原数组
  • 能遍历到实例的属性或者是原型上的属性(所以不推荐数组使用)
    for-in循环是为了遍历对象而设计的,事实上for-in也能用来遍历数组,但定义的索引i是字符
1
2
3
4
5
6
7
8
9
10
//for-in遍历数组
for(var i in arrTmp){
console.log(i+": "+arrTmp[i])
}
//for-in会遍历到数组的可枚举属性
arrTmp.name="myTest";
for(var i in arrTmp){
console.log(i+":"+arrTmp[i])
}
//输出 0:value1 1:value2 2:value3 name:myTest

for…of遍历

(ES6语法,推荐使用) 不能用于遍历对象,详细见[Iterator 和 for…of 循环]

  • 可以正确响应break、continue和return语句
  • 改变原始数组
  • 只能读取键值item,如有需要可以使用索引index
    • 经典 for循环,foreach,array.entries(),array.keys()

数组的实例方法

entries(),keys()和 values(),经常和for…of 配合使用

它们都返回一个遍历器对象,可以用for…of循环进行遍历,唯一的区别是keys()是对键名的遍历、values()是对键值的遍历,entries()是对键值对的遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (let index of ['a', 'b'].keys()) {
console.log(index);
}
// 0
// 1

for (let elem of ['a', 'b'].values()) {
console.log(elem);
}
// 'a'
// 'b'

for (let [index, elem] of ['a', 'b'].entries()) {
console.log(index, elem);
}
// 0 "a"
// 1 "b"

foreach 循环

  • 不响应break、continue和return
  • 没有返回值
  • 改变原数组
1
2
3
4
arr.forEach((item,index,array)=>{
//执行代码
})
//参数:item数组中的当前项, index当前项的索引, array原始数组;

map循环

映射,对数组中的每一项运行给定函数,返回每次函数调用的结果组成的数组。

  • 不响应break、continue
  • 返回经过处理的新数组
  • 不改变原始数组
1
2
3
4
5
6
var ary = [12,23,24,42,1]; 
var res = ary.map(function (item,index,ary ) {
return item*10;
})
console.log(res);//-->[120,230,240,420,10]; 原数组拷贝了一份,并进行了修改
console.log(ary);//-->[12,23,24,42,1]; 原数组并未发生变化

filter循环

过滤,数组中的每一项运行给定函数,返回满足过滤条件组成的数组。

  • 不响应break、continue
  • 返回经过处理的新数组
  • 不改变原始数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//示例功能,筛选偶数
const arr = [1,2,3,4,5,6,7,8]
const arrHandle1 = arr.filter(item => {
if(item%2===0){
return item
}
})
const arrHandle2 = arr.filter(item => {
if(item%2===0){
return item
}
return false
})
const arrHandle3 = arr.filter(item => {
if(item%2===0){
return true
}
return false
})
console.log(arrHandle1); [ 2, 4, 6, 8 ]
console.log(arrHandle2); [ 2, 4, 6, 8 ]
console.log(arrHandle3); [ 2, 4, 6, 8 ]

every

判断数组中每一项都是否满足条件,只有所有项都满足条件,才会返回true

  • 不响应break、continue
  • 不改变原始数组
  • 返回Boolean
1
2
3
4
var arr = [1,2,3,1,6,4,6]
var brr = arr.every(item => item%2==0)
console.log(arr) //[1,2,3,1,6,4,6]
console.log(brr) //false

some

判断数组中是否存在满足条件的项,只要有一项满足条件,就会返回true

  • 不响应break、continue
  • 不改变原始数组
  • 返回Boolean
1
2
3
4
var arr = [1,2,3,1,6,4,6]
var brr = arr.some(item => item%2==0)
console.log(arr) //[1,2,3,1,6,4,6]
console.log(brr) //true

reduce.reduceRight

这两个方法都会迭代数 组的所有项,并在此基础上构建一个终返回值

数组的操作方法

改变原始数组的方法

  • pop(): 删除 array 的最后一个元素,把数组长度减 1,并且返回它删除的元素的值。如果数组已经为空,则 pop() 不改变数组,并返回 undefined 值。
    • 返回它删除的元素的值
  • push(): 可以接收任意数量的参数,把它们逐个添加到数组末尾,并返回修改后数组的长度。
    • 返回修改后数组的长度
  • unshift(): 将参数添加到原数组开头,并返回数组的长度
    • 返回修改后数组长度
  • shift(): 数组的第一个元素从其中删除,并返回第一个元素的值,如果数组是空的,那么 shift() 方法将不进行任何操作,并返回 undefined 值。
    • 返回第一个元素的值
  • reverse(): 用于颠倒数组中元素的顺序。
    • 返回原数组
  • sort(): 排序,默认为升序排列,如果想按照其他标准进行排序,sort()方法是可以传入一个函数,函数通过返回一个值来决定
  • splice(): 功能强大的数组方法,向从数组中添加删除项目,然后返回被删除的项目
    • 返回被删除的项目
    • 语法 arrayObject.splice(index,howmany,item1,.....,itemX)
      • index 必需。整数,规定添加/删除项目的位置,使用负数可从数组结尾处规定位置。
      • howmany 必需。要删除的项目数量。如果设置为 0,则不会删除项目。
      • item1, …, itemX 可选。向数组添加的新项目。
  • copyWithin() 在当前数组内部,将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组
  • fill()
    • fill方法使用给定值,填充一个数组,fill方法还可以接受第二个和第三个参数,用于指定填充的起始位置和结束位置
1
2
3
4
5
6
7
8
['a', 'b', 'c'].fill(7)
// [7, 7, 7]

new Array(3).fill(7)
// [7, 7, 7]

['a', 'b', 'c'].fill(7, 1, 2)
// ['a', 7, 'c']

不改变原始数组的方法

// TODO

  • join
  • toLocalString
  • toString
  • valueOf
  • slice
  • cancat
  • indexOf
  • lastIndexOf
  • includes
  • flat
  • flatMap
  • find
  • findIndex

类数组转换为数组

直接上代码

1
2
3
4
5
6
7
8
9
var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 }
// 1. slice
Array.prototype.slice.call(arrayLike); // ["name", "age", "sex"]
// 2. splice
Array.prototype.splice.call(arrayLike, 0); // ["name", "age", "sex"]
// 3. ES6 Array.from
Array.from(arrayLike); // ["name", "age", "sex"]
// 4. apply
Array.prototype.concat.apply([], arrayLike)

如果是有iterator接口的类数组,还可以用扩展运算符进行转换

1
[...NodeList]

其他方法

数组的方法详细见js高程第四版6.2 Array

克隆一个数组(浅拷贝),不会改变原始数组

  • concat()
    • array.concat()
  • slice() 另一个常用方法:Array.prototype.slice.call(arguments,0?)能将具有length属性的对象转成数组,功能类似于Array.from()
    • array.slice(0?)
  • Array.from() 将两类对象转为真正的数组:类似数组的对象(array-like object 有length属性就可以)和可遍历(iterable)的对象(包括 ES6 新增的数据结构 Set 和 Map)
  • 扩展运算符 …
    • [...array]
  • Object.assign()
    • Object.assign([],arr)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let arr = [{ b: 1 },{ c: 1 },{ d: 1 },{ e: 1 }]
let brr = arr.concat([])
brr[0].b = 2
let crr = arr.slice()
crr[1].c = 2
let drr = [...arr]
drr[2].d = 2
let err = Object.assign([],arr)
err[3].e = 2
console.log(arr) //[{ b: 2 },{ c: 2 },{ d: 2 },{ e: 2 }]
console.log(brr) //[{ b: 2 },{ c: 2 },{ d: 2 },{ e: 2 }]
console.log(crr) //[{ b: 2 },{ c: 2 },{ d: 2 },{ e: 2 }]
console.log(drr) //[{ b: 2 },{ c: 2 },{ d: 2 },{ e: 2 }]
console.log(err) //[{ b: 2 },{ c: 2 },{ d: 2 },{ e: 2 }]

原型方法

  • Number.isFinite()
    用来检查一个数值是否为有限的(finite),即不是Infinity。

  • Number.isNaN()
    用来检查一个值是否为NaN。

  • Number.parseInt()

  • Number.parseFloat()

    ES6 将全局方法parseInt()parseFloat(),移植到Number对象上面,行为完全保持不变。

  • Number.isInteger()

    用来判断一个数值是否为整数。

    JavaScript 内部,整数和浮点数采用的是同样的储存方法,所以 25 和 25.0 被视为同一个值。

    1
    2
    Number.isInteger(25) // true
    Number.isInteger(25.0) // true
  • Number.isSafeInteger()

    JavaScript 能够准确表示的整数范围在-2^532^53之间(不含两个端点),超过这个范围,无法精确表示这个值。ES6 引入了Number.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER这两个常量,用来表示这个范围的上下限。

    Number.isSafeInteger()则是用来判断一个整数是否落在这个范围之内。

Math对象

  • Math.trunc()

Math.trunc方法用于去除一个数的小数部分,返回整数部分。

​ 对于没有部署这个方法的环境,可以用下面的代码模拟。

1
2
3
Math.trunc = Math.trunc || function(x) {
return x < 0 ? Math.ceil(x) : Math.floor(x);
};

简洁易懂

详细介绍

https加密过程详细介绍,其中用到堆成加密和非对称加密和CA数字证书

  • 用户在浏览器发起HTTPS请求(如 juejin.cn),默认使用服务端的443端口进行连接;

  • HTTPS需要使用一套CA数字证书,证书内会附带一个公钥Pub,而与之对应的私钥Private保留在服务端不公开;

  • 服务端收到请求,返回配置好的包含公钥Pub的证书给客户端;

  • 客户端收到证书,校验合法性,主要包括是否在有效期内、证书的域名与请求的域名是否匹配,上一级证书是否有效(递归判断,直到判断到系统内置或浏览器配置好的根证书),如果不通过,则显示HTTPS警告信息,如果通过则继续;

  • 客户端生成一个用于对称加密的随机Key,并用证书内的公钥Pub进行加密,发送给服务端;

  • 服务端收到随机Key的密文,使用与公钥Pub配对的私钥Private进行解密,得到客户端真正想发送的随机Key

  • 服务端使用客户端发送过来的随机Key对要传输的HTTP数据进行对称加密,将密文返回客户端;

  • 客户端使用随机Key对称解密密文,得到HTTP数据明文;

  • 后续HTTPS请求使用之前交换好的随机Key进行对称加解密。

如何保证数字证书的可靠性?

  1. CA机构拥有自己的一对公钥和私钥
  2. CA机构在颁发证书时对证书明文信息进行哈希
    1. 包含有证书持有者、证书有效期、公钥等信息
  3. 将哈希值用私钥进行加签,得到数字签名
  4. 明文数据和数字签名组成证书,传递给客户端。
  5. 客户端得到证书,分解成明文部分Text和数字签名Sig1
  6. 用CA机构的公钥进行解签,得到Sig2(也就是CA机构对证书明文进行hash后的值)(由于CA机构是一种公信身份,因此在系统或浏览器中会内置CA机构的证书和公钥信息)
  7. 用证书里声明的哈希算法对明文Text部分进行哈希得到T
  8. 当自己计算得到的哈希值T与解签后的Sig2相等,表示证书可信,没有被篡改

作者:接水怪
链接:https://juejin.cn/post/6844904127420432391
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

递归链表翻转

给定单链表的头节点 head ,请反转链表,并返回反转后的链表的头节点。

示例 1:

1
2
输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]

示例 2:

1
2
输入:head = [1,2]
输出:[2,1]

示例 3:

1
2
输入:head = []
输出:[]

基础递归公式如下

前置递归

1
2
3
4
5
6
7
8
9
10
11
12
13
function reverse(head){
if(!head || head.next === null){
console.log("last--", head)
return head;
}
const last = reverse(head.next)
console.log("head--", head)
head.next.next = head
head.next = null

console.log("last--", last)
return last
}

使用[1,2,3,4,5]作为测试用例,输出结果如下

1
2
3
4
5
6
7
8
9
last--  [5]
head-- [4,5]
last-- [5,4]
head-- [3,4]
last-- [5,4,3]
head-- [2,3]
last-- [5,4,3,2]
head-- [1,2]
last-- [5,4,3,2,1]

做一个灵魂画手,帮助记忆。

图像2

由于是前置递归,代码由下而上进行运行,每一步都会将head节点与上一个节点进行翻转,并将head节点指向null

head节点信息由调用栈的函数持有

后置递归

1
2
3
4
5
6
7
8
9
10
11
// 后置递归
function df(pre, cur) {
if (cur === null) {
return pre;
} else {
var temp = cur.next;
cur.next = pre;
return df(cur, temp);
}
}
return df(null, head);

灵魂画手又来了

图像3

迭代

1
2
3
4
5
6
7
8
9
10
11
// 迭代
var reverseList = function (head) {
var pre = null, cur = head, next;
while (cur) {
next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
return pre;
};

翻转链表前N个节点

1
2
输入 [1,2,3,4,5] 3
输出 [3,2,1,4,5]

前置递归

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function ListNode(val, next) {
this.val = (val===undefined ? 0 : val)
this.next = (next===undefined ? null : next)
}
const list = new ListNode(1)
list.next = new ListNode(2)
list.next.next = new ListNode(3)
list.next.next.next = new ListNode(4)
list.next.next.next.next = new ListNode(5)
list.next.next.next.next.next = null


let middleHead = null
function reverseN(head, n) {
if (n === 1) {
middleHead = head.next
return head
}

const last = reverseN(head.next, n - 1)
head.next.next = head
head.next = middleHead
return last
}
console.log(reverseN(list, 3))

灵魂画图,有点难以理解啊

图像4

转载

三次握手和四次挥手是各个公司常见的考点,也具有一定的水平区分度,也被一些面试官作为热身题。很多小伙伴说这个问题刚开始回答的挺好,但是后面越回答越冒冷汗,最后就歇菜了。

见过比较典型的面试场景是这样的:

面试官:请介绍下三次握手

求职者:第一次握手就是客户端给服务器端发送一个报文,第二次就是服务器收到报文之后,会应答一个报文给客户端,第三次握手就是客户端收到报文后再给服务器发送一个报文,三次握手就成功了。

面试官:然后呢?

求职者:这就是三次握手的过程,很简单的。

面试官:。。。。。。

(番外篇:一首凉凉送给你)

记住猿人谷一句话:面试时越简单的问题,一般就是隐藏着比较大的坑,一般都是需要将问题扩展的。上面求职者的回答不对吗?当然对,但距离面试官的期望可能还有点距离。

希望大家能带着如下问题进行阅读,收获会更大。

  1. 请画出三次握手和四次挥手的示意图
  2. 为什么连接的时候是三次握手?
  3. 什么是半连接队列?
  4. ISN(Initial Sequence Number)是固定的吗?
  5. 三次握手过程中可以携带数据吗?
  6. 如果第三次握手丢失了,客户端服务端会如何处理?
  7. SYN攻击是什么?
  8. 挥手为什么需要四次?
  9. 四次挥手释放连接时,等待2MSL的意义?

三次握手和四次挥手.png

tcp标志位含义

搞懂位码含义,方便理解。位码即tcp标志位,有6种标示:

  • SYN(synchronous建立联机)
  • ACK(acknowledgement 确认)
  • PSH(push传送)
  • FIN(finish结束)
  • RST(reset重置)
  • URG(urgent紧急)
  • seq Sequence number(顺序号码)
  • ack Acknowledge number(确认号码)

1. 三次握手

三次握手(Three-way Handshake)其实就是指建立一个TCP连接时,需要客户端和服务器总共发送3个包。进行三次握手的主要作用就是为了确认双方的接收能力和发送能力是否正常、指定自己的初始化序列号为后面的可靠性传送做准备。实质上其实就是连接服务器指定端口,建立TCP连接,并同步连接双方的序列号和确认号,交换TCP窗口大小信息。

刚开始客户端处于 Closed 的状态,服务端处于 Listen 状态。 进行三次握手:

  • 第一次握手:客户端给服务端发一个 SYN 报文,并指明客户端的初始化序列号 ISN(c)。此时客户端处于 SYN_SEND 状态。

    首部的同步位SYN=1,初始序号seq=x,SYN=1的报文段不能携带数据,但要消耗掉一个序号。

  • 第二次握手:服务器收到客户端的 SYN 报文之后,会以自己的 SYN 报文作为应答,并且也是指定了自己的初始化序列号 ISN(s)。同时会把客户端的 ISN + 1 作为ACK 的值,表示自己已经收到了客户端的 SYN,此时服务器处于 SYN_RCVD 的状态。

    在确认报文段中SYN=1,ACK=1,确认号ack=x+1,初始序号seq=y。

  • 第三次握手:客户端收到 SYN 报文之后,会发送一个 ACK 报文,当然,也是一样把服务器的 ISN + 1 作为 ACK 的值,表示已经收到了服务端的 SYN 报文,此时客户端处于 ESTABLISHED 状态。服务器收到 ACK 报文之后,也处于 ESTABLISHED 状态,此时,双方已建立起了连接。

    确认报文段ACK=1,确认号ack=y+1,序号seq=x+1(初始为seq=x,第二个报文段所以要+1),ACK报文段可以携带数据,不携带数据则不消耗序号。

发送第一个SYN的一端将执行主动打开(active open),接收这个SYN并发回下一个SYN的另一端执行被动打开(passive open)。

在socket编程中,客户端执行connect()时,将触发三次握手。

三次握手.png

![图像 2](assets/三次握手四次挥手/图像 2.jpeg)

1.1 为什么需要三次握手,两次不行吗?

弄清这个问题,我们需要先弄明白三次握手的目的是什么,能不能只用两次握手来达到同样的目的。

  • 第一次握手:客户端发送网络包,服务端收到了。 这样服务端就能得出结论:客户端的发送能力、服务端的接收能力是正常的。
  • 第二次握手:服务端发包,客户端收到了。 这样客户端就能得出结论:服务端的接收、发送能力,客户端的接收、发送能力是正常的。不过此时服务器并不能确认客户端的接收能力是否正常。
  • 第三次握手:客户端发包,服务端收到了。 这样服务端就能得出结论:客户端的接收、发送能力正常,服务器自己的发送、接收能力也正常。

因此,需要三次握手才能确认双方的接收与发送能力是否正常。

试想如果是用两次握手,则会出现下面这种情况:

如客户端发出连接请求,但因连接请求报文丢失而未收到确认,于是客户端再重传一次连接请求。后来收到了确认,建立了连接。数据传输完毕后,就释放了连接,客户端共发出了两个连接请求报文段,其中第一个丢失,第二个到达了服务端,但是第一个丢失的报文段只是在某些网络结点长时间滞留了,延误到连接释放以后的某个时间才到达服务端,此时服务端误认为客户端又发出一次新的连接请求,于是就向客户端发出确认报文段,同意建立连接,不采用三次握手,只要服务端发出确认,就建立新的连接了,此时客户端忽略服务端发来的确认,也不发送数据,则服务端一致等待客户端发送数据,浪费资源。

1.2 什么是半连接队列?

服务器第一次收到客户端的 SYN 之后,就会处于 SYN_RCVD 状态,此时双方还没有完全建立其连接,服务器会把此种状态下请求连接放在一个队列里,我们把这种队列称之为半连接队列

当然还有一个全连接队列,就是已经完成三次握手,建立起连接的就会放在全连接队列中。如果队列满了就有可能会出现丢包现象。

这里在补充一点关于SYN-ACK 重传次数的问题: 服务器发送完SYN-ACK包,如果未收到客户确认包,服务器进行首次重传,等待一段时间仍未收到客户确认包,进行第二次重传。如果重传次数超过系统规定的最大重传次数,系统将该连接信息从半连接队列中删除。 注意,每次重传等待的时间不一定相同,一般会是指数增长,例如间隔时间为 1s,2s,4s,8s……

1.3 ISN(Initial Sequence Number)是固定的吗?

当一端为建立连接而发送它的SYN时,它为连接选择一个初始序号。ISN随时间而变化,因此每个连接都将具有不同的ISN。ISN可以看作是一个32比特的计数器,每4ms加1 。这样选择序号的目的在于防止在网络中被延迟的分组在以后又被传送,而导致某个连接的一方对它做错误的解释。

三次握手的其中一个重要功能是客户端和服务端交换 ISN(Initial Sequence Number),以便让对方知道接下来接收数据的时候如何按序列号组装数据。如果 ISN 是固定的,攻击者很容易猜出后续的确认号,因此 ISN 是动态生成的。

1.4 三次握手过程中可以携带数据吗?

其实第三次握手的时候,是可以携带数据的。但是,第一次、第二次握手不可以携带数据

为什么这样呢?大家可以想一个问题,假如第一次握手可以携带数据的话,如果有人要恶意攻击服务器,那他每次都在第一次握手中的 SYN 报文中放入大量的数据。因为攻击者根本就不理服务器的接收、发送能力是否正常,然后疯狂着重复发 SYN 报文的话,这会让服务器花费很多时间、内存空间来接收这些报文。

也就是说,第一次握手不可以放数据,其中一个简单的原因就是会让服务器更加容易受到攻击了。而对于第三次的话,此时客户端已经处于 ESTABLISHED 状态。对于客户端来说,他已经建立起连接了,并且也已经知道服务器的接收、发送能力是正常的了,所以能携带数据也没啥毛病。

1.5 SYN攻击是什么?

服务器端的资源分配是在二次握手时分配的,而客户端的资源是在完成三次握手时分配的,所以服务器容易受到SYN洪泛攻击。SYN攻击就是Client在短时间内伪造大量不存在的IP地址,并向Server不断地发送SYN包,Server则回复确认包,并等待Client确认,由于源地址不存在,因此Server需要不断重发直至超时,这些伪造的SYN包将长时间占用未连接队列,导致正常的SYN请求因为队列满而被丢弃,从而引起网络拥塞甚至系统瘫痪。SYN 攻击是一种典型的 DoS/DDoS 攻击。

检测 SYN 攻击非常的方便,当你在服务器上看到大量的半连接状态时,特别是源IP地址是随机的,基本上可以断定这是一次SYN攻击。在 Linux/Unix 上可以使用系统自带的 netstats 命令来检测 SYN 攻击。

1
2
netstat -n -p TCP | grep SYN_RECV
复制代码

常见的防御 SYN 攻击的方法有如下几种:

  • 缩短超时(SYN Timeout)时间
  • 增加最大半连接数
  • 过滤网关防护
  • SYN cookies技术

2. 四次挥手

建立一个连接需要三次握手,而终止一个连接要经过四次挥手(也有将四次挥手叫做四次握手的)。这由TCP的半关闭(half-close)造成的。所谓的半关闭,其实就是TCP提供了连接的一端在结束它的发送后还能接收来自另一端数据的能力。

TCP 的连接的拆除需要发送四个包,因此称为四次挥手(Four-way handshake),客户端或服务器均可主动发起挥手动作。

刚开始双方都处于 ESTABLISHED 状态,假如是客户端先发起关闭请求。四次挥手的过程如下:

  • 第一次挥手:客户端发送一个 FIN 报文,报文中会指定一个序列号。此时客户端处于 FIN_WAIT1 状态。 即发出连接释放报文段(FIN=1,序号seq=u),并停止再发送数据,主动关闭TCP连接,进入FIN_WAIT1(终止等待1)状态,等待服务端的确认。
  • 第二次挥手:服务端收到 FIN 之后,会发送 ACK 报文,且把客户端的序列号值 +1 作为 ACK 报文的序列号值,表明已经收到客户端的报文了,此时服务端处于 CLOSE_WAIT 状态。 即服务端收到连接释放报文段后即发出确认报文段(ACK=1,确认号ack=u+1,序号seq=v),服务端进入CLOSE_WAIT(关闭等待)状态,此时的TCP处于半关闭状态,客户端到服务端的连接释放。客户端收到服务端的确认后,进入FIN_WAIT2(终止等待2)状态,等待服务端发出的连接释放报文段。
  • 第三次挥手:如果服务端也想断开连接了,和客户端的第一次挥手一样,发给 FIN 报文,且指定一个序列号。此时服务端处于 LAST_ACK 的状态。 即服务端没有要向客户端发出的数据,服务端发出连接释放报文段(FIN=1,ACK=1,序号seq=w,确认号ack=u+1),服务端进入LAST_ACK(最后确认)状态,等待客户端的确认。
  • 第四次挥手:客户端收到 FIN 之后,一样发送一个 ACK 报文作为应答,且把服务端的序列号值 +1 作为自己 ACK 报文的序列号值,此时客户端处于 TIME_WAIT 状态。需要过一阵子以确保服务端收到自己的 ACK 报文之后才会进入 CLOSED 状态,服务端收到 ACK 报文之后,就处于关闭连接了,处于 CLOSED 状态。 即客户端收到服务端的连接释放报文段后,对此发出确认报文段(ACK=1,seq=u+1,ack=w+1),客户端进入TIME_WAIT(时间等待)状态。此时TCP未释放掉,需要经过时间等待计时器设置的时间2MSL后,客户端才进入CLOSED状态。

收到一个FIN只意味着在这一方向上没有数据流动。客户端执行主动关闭并进入TIME_WAIT是正常的,服务端通常执行被动关闭,不会进入TIME_WAIT状态。

在socket编程中,任何一方执行close()操作即可产生挥手操作。

image.png

![图像 3](assets/三次握手四次挥手/图像 3-8931236.jpeg)

2.1 挥手为什么需要四次?

因为当服务端收到客户端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当服务端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉客户端,”你发的FIN报文我收到了”。只有等到我服务端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四次挥手。

2.2 2MSL等待状态

TIME_WAIT状态也成为2MSL等待状态。每个具体TCP实现必须选择一个报文段最大生存时间MSL(Maximum Segment Lifetime),它是任何报文段被丢弃前在网络内的最长时间。这个时间是有限的,因为TCP报文段以IP数据报在网络内传输,而IP数据报则有限制其生存时间的TTL字段。

对一个具体实现所给定的MSL值,处理的原则是:当TCP执行一个主动关闭,并发回最后一个ACK,该连接必须在TIME_WAIT状态停留的时间为2倍的MSL。这样可让TCP再次发送最后的ACK以防这个ACK丢失(另一端超时并重发最后的FIN)。

这种2MSL等待的另一个结果是这个TCP连接在2MSL等待期间,定义这个连接的插口(客户的IP地址和端口号,服务器的IP地址和端口号)不能再被使用。这个连接只能在2MSL结束后才能再被使用。

2.3 四次挥手释放连接时,等待2MSL的意义?

MSL是Maximum Segment Lifetime的英文缩写,可译为“最长报文段寿命”,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。

为了保证客户端发送的最后一个ACK报文段能够到达服务器。因为这个ACK有可能丢失,从而导致处在LAST-ACK状态的服务器收不到对FIN-ACK的确认报文。服务器会超时重传这个FIN-ACK,接着客户端再重传一次确认,重新启动时间等待计时器。最后客户端和服务器都能正常的关闭。假设客户端不等待2MSL,而是在发送完ACK之后直接释放关闭,一但这个ACK丢失的话,服务器就无法正常的进入关闭连接状态。

两个理由

  1. 保证客户端发送的最后一个ACK报文段能够到达服务端。 这个ACK报文段有可能丢失,使得处于LAST-ACK状态的B收不到对已发送的FIN+ACK报文段的确认,服务端超时重传FIN+ACK报文段,而客户端能在2MSL时间内收到这个重传的FIN+ACK报文段,接着客户端重传一次确认,重新启动2MSL计时器,最后客户端和服务端都进入到CLOSED状态,若客户端在TIME-WAIT状态不等待一段时间,而是发送完ACK报文段后立即释放连接,则无法收到服务端重传的FIN+ACK报文段,所以不会再发送一次确认报文段,则服务端无法正常进入到CLOSED状态。
  2. 防止“已失效的连接请求报文段”出现在本连接中。 客户端在发送完最后一个ACK报文段后,再经过2MSL,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失,使下一个新的连接中不会出现这种旧的连接请求报文段。

2.4 为什么TIME_WAIT状态需要经过2MSL才能返回到CLOSE状态?

理论上,四个报文都发送完毕,就可以直接进入CLOSE状态了,但是可能网络是不可靠的,有可能最后一个ACK丢失。所以TIME_WAIT状态就是用来重发可能丢失的ACK报文

3. 总结

《TCP/IP详解 卷1:协议》有一张TCP状态变迁图,很具有代表性,有助于大家理解三次握手和四次挥手的状态变化。如下图所示,粗的实线箭头表示正常的客户端状态变迁,粗的虚线箭头表示正常的服务器状态变迁。

TCP状态变迁图.jpg

以后面试官再问你三次握手和四次挥手,直接把这一篇文章丢给他就可以了,他想问的都在这里。

https交换证书

https握手

转载自

使用 Preload&Prefetch 优化前端页面的资源加载

对于前端页面来说,静态资源的加载对页面性能起着至关重要的作用。本文将介绍浏览器提供的两个资源指令-preload/prefetch,它们能够辅助浏览器优化资源加载的顺序和时机,提升页面性能。

一、从一个实例开始

img

如上图所示,我们开发了一个简单的收银台,支付过程中可以展开优惠券列表选择相应的券。从动图可以看到,列表第一次展开时,优惠券背景有一个逐渐显示的过程,体验上不是很好。

问题的原因也很明显,由于背景使用了视觉特意设计的图片,优惠券列表展开时需要去加载图片,背景渐显的过程实际上就是图片加载的过程;当网速慢的时候,这个问题会更加明显。那么,怎样解决这个问题呢?

仔细分析一下,我们会发现问题的原因在于背景图的加载时机太晚。

如果能在优惠券列表渲染前加载好背景图,这个问题就不会出现。从这个思路出发,我们可能想到以下两个方案:

  1. 使用内联图片,也就是将图片转换为base64编码的data-url。这种方式,其实是将图片的信息集成到css文件中,避免了图片资源的单独加载。但图片内联会增加css文件的大小,增加首屏渲染的时间。
  2. 使用js代码对图片进行预加载
1
2
3
4
5
6
7
8
9
10
11
preloadImage() {
const imgList = [
require('@/assets/imgs/error.png'),
require('@/assets/imgs/ticket_bg.png')
];
for (let i = 0; i < imgList.length; i++) {
const newIMG = new Image();
newIMG.src = imgList[i];
}
}
复制代码

img点击并拖拽以移动这种方案主要是利用浏览器的缓存机制,由js代码在特定时机提前加载相应图片,优惠券列表渲染时就可以直接从缓存获取。不过,这种方案增加了额外的代码,需要自己控制好加载时机,并且将图片的url硬编码在了逻辑中。

可以看出,以上两种方案能够解决我们的问题,但都存在一些缺点。

那么,有没有更好的解决方案呢?答案是prefetch-一种由浏览器原生提供的预加载方案。

二、什么是prefetch?

prefetch(链接预取)是一种浏览器机制,其利用浏览器空闲时间来下载或预取用户在不久的将来可能访问的文档。网页向浏览器提供一组预取提示,并在浏览器完成当前页面的加载后开始静默地拉取指定的文档并将其存储在缓存中。当用户访问其中一个预取文档时,便可以快速的从浏览器缓存中得到。–MDN

具体来说,浏览器通过标签来实现预加载。

其中rel=”prefetch”被称为Resource-Hints(资源提示),也就是辅助浏览器进行资源优化的指令。

类似的指令还有rel=”preload”,我们会在后文提及。

1
2
3
4
5
6
<head>
...
<link rel="prefetch" href="static/img/ticket_bg.a5bb7c33.png">
...
</head>
复制代码

查看现在优惠券列表的加载效果。

img

果然,成功达成了我们期望的效果。那么浏览器是如何做的呢?我们打开Chrome的Network面板一探究竟:

img

img

可以看到,在首屏的请求列表中已经出现了优惠券背景图ticket_bg.png的加载请求,请求本身看起来和普通请求没什么不同;展开优惠券列表后,network中增加了一次新的ticket_bg.png访问请求,我们很快发现,这个请求的status虽然也是200,但有一个特殊的标记—prefetch cache,表明这次请求的资源来自prefetch缓存。这个表现验证了上文中prefetch的定义,即浏览器在空闲时间预先加载资源,真正使用时直接从浏览器缓存中快速获取。

三、Preload

从上面的案例,我们体会到了浏览器预加载资源的强大能力。实际上,预加载是一个广义的概念,prefetch只是具体实现方式之一,本节我们介绍下另外一种预加载方式preload。上文我们提到,preload与prefetch同属于浏览器的Resource-Hints,用于辅助浏览器进行资源优化。为了对两者进行区分,prefetch通常翻译为预提取,preload则翻译为预加载。

元素的rel属性的属性值preload能够让你在你的HTML页面中元素内部书写一些声明式的资源获取请求,可以指明哪些资源是在页面加载完成后即刻需要的。对于这种即刻需要的资源,你可能希望在页面加载的生命周期的早期阶段就开始获取,在浏览器的主渲染机制介入前就进行预加载。这一机制使得资源可以更早的得到加载并可用,且更不易阻塞页面的初步渲染,进而提升性能。

简单来说,就是通过标签显式声明一个高优先级资源,强制浏览器提前请求资源,同时不阻塞文档正常onload。我们同样用一个实际案例进行详细介绍。

img

上图是我们开发的另外一个收银台,出于本地化的考虑,设计上使用了自定义字体。开发完成后我们发现,页面首次加载时文字会出现短暂的字体样式闪动(FOUT,Flash of Unstyled Text),在网络情况较差时比较明显(如动图所示)。究其原因,是字体文件由css引入,在css解析后才会进行加载,加载完成之前浏览器只能使用降级字体。也就是说,字体文件加载的时机太迟,需要告诉浏览器提前进行加载,这恰恰是preload的用武之地。

我们在入口html文件head加入preload标签:

1
2
3
4
5
6
7
<head>
...
<link rel="preload" as="font" href="<%= require('/assets/fonts/AvenirNextLTPro-Demi.otf') %>" crossorigin>
<link rel="preload" as="font" href="<%= require('/assets/fonts/AvenirNextLTPro-Regular.otf') %>" crossorigin>
...
</head>
复制代码

img点击并拖拽以移动再次查看页面首次加载的效果:

img

字体样式闪动的现象没有了!我们对比下使用preload前后的network面板。

使用前:

img

使用后:

img

可以发现字体文件的加载时机明显提前了,在浏览器接收到html后很快就进行了加载。

注意:preload link必须设置as属性来声明资源的类型(font/image/style/script等),否则浏览器可能无法正确加载资源。

四、Preload 和 Prefetch 的具体实践

1、preload-webpack-plugin

前文中我们举的两个例子,都是在入口html手动添加相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
<head>
...
<link rel="prefetch" href="static/img/ticket_bg.a5bb7c33.png">
...
</head>
复制代码
<head>
...
<link rel="preload" as="font" href="<%= require('/assets/fonts/AvenirNextLTPro-Demi.otf') %>" crossorigin>
<link rel="preload" as="font" href="<%= require('/assets/fonts/AvenirNextLTPro-Regular.otf') %>" crossorigin>
...
</head>
复制代码

img点击并拖拽以移动这显然不够方便,而且将资源路径硬编码在了页面中(实际上,ticket_bg.a5bb7c33.png后缀中的hash是构建过程自动生成的,所以硬编码的方式很多场景下本身就行不通)。webpack插件preload-webpack-plugin可以帮助我们将该过程自动化,结合htmlWebpackPlugin在构建过程中插入link标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const PreloadWebpackPlugin = require('preload-webpack-plugin');
...
plugins: [
new PreloadWebpackPlugin({
rel: 'preload',
as(entry) { //资源类型
if (/\.css$/.test(entry)) return 'style';
if (/\.woff$/.test(entry)) return 'font';
if (/\.png$/.test(entry)) return 'image';
return 'script';
},
include: 'asyncChunks', // preload模块范围,还可取值'initial'|'allChunks'|'allAssets',
fileBlacklist: [/\.svg/] // 资源黑名单
fileWhitelist: [/\.script/] // 资源白名单
})
]
复制代码

img点击并拖拽以移动PreloadWebpackPlugin配置总体上比较简单,需要注意的是include属性。该属性默认取值’asyncChunks’,表示仅预加载异步js模块;如果需要预加载图片、字体等资源,则需要将其设置为’allAssets’,表示处理所有类型的资源。

但一般情况下我们不希望把预加载范围扩得太大,所以需要通过fileBlacklist或fileWhitelist进行控制。

对于异步加载的模块,还可以通过webpack内置的/webpackPreload: true/标记进行更细粒度的控制。

以下面的代码为例,webpack会生成标签添加到html页面头部。

1
2
import(/* webpackPreload: true */ 'AsyncModule');
复制代码

备注:prefetch的配置与preload类似,但无需对as属性进行设置。

2、使用场景

从前文的介绍可知,preload的设计初衷是为了尽早加载首屏需要的关键资源,从而提升页面渲染性能。

目前浏览器基本上都具备预测解析能力,可以提前解析入口html中外链的资源,因此入口脚本文件、样式文件等不需要特意进行preload。

但是一些隐藏在CSS和JavaScript中的资源,如字体文件,本身是首屏关键资源,但当css文件解析之后才会被浏览器加载。这种场景适合使用preload进行声明,尽早进行资源加载,避免页面渲染延迟。

与preload不同,prefetch声明的是将来可能访问的资源,因此适合对异步加载的模块、可能跳转到的其他路由页面进行资源缓存;对于一些将来大概率会访问的资源,如上文案例中优惠券列表的背景图、常见的加载失败icon等,也较为适用。

3、最佳实践

基于上面对使用场景的分享,我们可以总结出一个比较通用的最佳实践:

  • 大部分场景下无需特意使用preload
  • 类似字体文件这种隐藏在脚本、样式中的首屏关键资源,建议使用preload
  • 异步加载的模块(典型的如单页系统中的非首页)建议使用prefetch
  • 大概率即将被访问到的资源可以使用prefetch提升性能和体验

4、vue-cli3的默认配置

  • preload

默认情况下,一个Vue CLI应用会为所有初始化渲染需要的文件自动生成preload提示。这些提示会被@vue/preload-webpack-plugin注入,并且可以通过chainWebpack的config.plugin(‘preload’)进行修改和删除。

  • prefetch

默认情况下,一个Vue CLI应用会为所有作为async chunk生成的JavaScript文件(通过动态import()按需code splitting的产物)自动生成prefetch提示。这些提示会被@vue/preload-webpack-plugin注入,并且可以通过chainWebpack的config.plugin(‘prefetch’)进行修改和删除。

五、总结和踩坑

1、preload和prefetch的本质都是预加载,即先加载、后执行,加载与执行解耦。

2、preload和prefetch不会阻塞页面的onload。

3、preload用来声明当前页面的关键资源,强制浏览器尽快加载;而prefetch用来声明将来可能用到的资源,在浏览器空闲时进行加载。

4、不要滥用preload和prefetch,需要在合适的场景中使用。

5、preload的字体资源必须设置crossorigin属性,否则会导致重复加载。

原因是如果不指定crossorigin属性(即使同源),浏览器会采用匿名模式的CORS去preload,导致两次请求无法共用缓存。

6、关于preload和prefetch资源的缓存,在Google开发者的一篇文章中是这样说明的:如果资源可以被缓存(比如说存在有效的cache-control和max-age),它被存储在HTTP缓存(也就是disk cache)中,可以被现在或将来的任务使用;如果资源不能被缓存在HTTP缓存中,作为代替,它被放在内存缓存中直到被使用。

然而我们在Chrome浏览器(版本号80)中进行测试,结果却并非如此。将服务器的缓存策略设置为no-store,观察下资源加载情况。

img

可以发现ticket_bg.png第二次加载并未从本地缓存获取,仍然是从服务器加载。因此,如果要使用prefetch,相应的资源必须做好合理的缓存控制。

7、没有合法https证书的站点无法使用prefetch,预提取的资源不会被缓存(实际使用过程中发现,原因未知)。

8、最后我们来看下preload和prefetch的浏览器兼容性。

imgimg可以看到,两者的兼容性目前都还不是太好。好在不支持preload和prefetch的浏览器会自动忽略它,因此可以将它们作为一种渐进增强功能,优化我们页面的资源加载,提升性能和用户体验。

转载于 https://github.com/mqyqingfeng/Blog

执行上下文栈

顺序执行?

如果要问到 JavaScript 代码执行顺序的话,想必写过 JavaScript 的开发者都会有个直观的印象,那就是顺序执行,毕竟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var foo = function () {

console.log('foo1');

}

foo(); // foo1

var foo = function () {

console.log('foo2');

}

foo(); // foo2

然而去看这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo() {

console.log('foo1');

}

foo(); // foo2

function foo() {

console.log('foo2');

}

foo(); // foo2

打印的结果却是两个 foo2

刷过面试题的都知道这是因为 JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行。当执行一段代码的时候,会进行一个“准备工作”,比如第一个例子中的变量提升,和第二个例子中的函数提升。

但是本文真正想让大家思考的是:这个“一段一段”中的“段”究竟是怎么划分的呢?

到底JavaScript引擎遇到一段怎样的代码时才会做“准备工作”呢?

可执行代码

这就要说到 JavaScript 的可执行代码(executable code)的类型有哪些了?

其实很简单,就三种,全局代码、函数代码、eval代码。

举个例子,当执行到一个函数的时候,就会进行准备工作,这里的“准备工作”,让我们用个更专业一点的说法,就叫做”执行上下文(execution context)”。

执行上下文栈

接下来问题来了,我们写的函数多了去了,如何管理创建的那么多执行上下文呢?

所以 JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文

为了模拟执行上下文栈的行为,让我们定义执行上下文栈是一个数组:

1
ECStack = [];

试想当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前, ECStack 最底部永远有个 globalContext:

1
2
3
ECStack = [
globalContext
];

现在 JavaScript 遇到下面的这段代码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
function fun3() {
console.log('fun3')
}

function fun2() {
fun3();
}

function fun1() {
fun2();
}

fun1();

当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。知道了这样的工作原理,让我们来看看如何处理上面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 伪代码

// fun1()
ECStack.push(<fun1> functionContext);

// fun1中竟然调用了fun2,还要创建fun2的执行上下文
ECStack.push(<fun2> functionContext);

// 擦,fun2还调用了fun3!
ECStack.push(<fun3> functionContext);

// fun3执行完毕
ECStack.pop();

// fun2执行完毕
ECStack.pop();

// fun1执行完毕
ECStack.pop();

// javascript接着执行下面的代码,但是ECStack底层永远有个globalContext

解答思考题

好啦,现在我们已经了解了执行上下文栈是如何处理执行上下文的,所以让我们看看上篇文章《JavaScript深入之词法作用域和动态作用域》最后的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();

两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?

答案就是执行上下文栈的变化不一样。

让我们模拟第一段代码:

1
2
3
4
ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();

让我们模拟第二段代码:

1
2
3
4
ECStack.push(<checkscope> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();

是不是有些不同呢?

当然了,这样概括的回答执行上下文栈的变化不同,是不是依然有一种意犹未尽的感觉呢,为了更详细讲解两个函数执行上的区别,我们需要探究一下执行上下文到底包含了哪些内容,所以欢迎阅读下一篇《JavaScript深入之变量对象》。

变量对象

前言

在上篇《JavaScript深入之执行上下文栈》中讲到,当 JavaScript 代码执行一段可执行代码(executable code)时,会创建对应的执行上下文(execution context)。

对于每个执行上下文,都有三个重要属性:

  • 变量对象(Variable object,VO)
  • 作用域链(Scope chain)
  • this

今天重点讲讲创建变量对象的过程。

变量对象

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。

因为不同执行上下文下的变量对象稍有不同,所以我们来聊聊全局上下文下的变量对象和函数上下文下的变量对象。

全局上下文

我们先了解一个概念,叫全局对象。在 W3School 中也有介绍:

全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。

在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。

例如,当JavaScript 代码引用 parseInt() 函数时,它引用的是全局对象的 parseInt 属性。全局对象是作用域链的头,还意味着在顶层 JavaScript 代码中声明的所有变量都将成为全局对象的属性。

如果看的不是很懂的话,容我再来介绍下全局对象:

1.可以通过 this 引用,在客户端 JavaScript 中,全局对象就是 Window 对象。

1
console.log(this);

2.全局对象是由 Object 构造函数实例化的一个对象。

1
console.log(this instanceof Object);

3.预定义了一堆,嗯,一大堆函数和属性。

1
2
3
// 都能生效
console.log(Math.random());
console.log(this.Math.random());

4.作为全局变量的宿主。

1
2
var a = 1;
console.log(this.a);

5.客户端 JavaScript 中,全局对象有 window 属性指向自身。

1
2
3
4
5
var a = 1;
console.log(window.a);

this.window.b = 2;
console.log(this.b);

花了一个大篇幅介绍全局对象,其实就想说:

全局上下文中的变量对象就是全局对象呐!

函数上下文

在函数上下文中,我们用活动对象(activation object, AO)来表示变量对象。

活动对象和变量对象其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫 activation object 呐,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。

活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性值是 Arguments 对象。

执行过程

执行上下文的代码会分成两个阶段进行处理:分析和执行,我们也可以叫做:

  1. 进入执行上下文
  2. 代码执行

进入执行上下文

当进入执行上下文时,这时候还没有执行代码,

变量对象会包括:

  1. 函数的所有形参 (如果是函数上下文)
    • 由名称和对应值组成的一个变量对象的属性被创建
    • 没有实参,属性值设为 undefined
  2. 函数声明
    • 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  3. 变量声明
    • 由名称和对应值(undefined)组成一个变量对象的属性被创建;
    • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

举个例子:

1
2
3
4
5
6
7
8
9
10
function foo(a) {
var b = 2;
function c() {}
var d = function() {};

b = 3;

}

foo(1);

在进入执行上下文后,这时候的 AO 是:

1
2
3
4
5
6
7
8
9
10
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
c: reference to function c(){},
d: undefined
}

代码执行

在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值

还是上面的例子,当代码执行完后,这时候的 AO 是:

1
2
3
4
5
6
7
8
9
10
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 3,
c: reference to function c(){},
d: reference to FunctionExpression "d"
}

到这里变量对象的创建过程就介绍完了,让我们简洁的总结我们上述所说:

  1. 全局上下文的变量对象初始化是全局对象
  2. 函数上下文的变量对象初始化只包括 Arguments 对象
  3. 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
  4. 在代码执行阶段,会再次修改变量对象的属性值

思考题

最后让我们看几个例子:

1.第一题

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
console.log(a);
a = 1;
}

foo(); // ???

function bar() {
a = 1;
console.log(a);
}
bar(); // ???

第一段会报错:Uncaught ReferenceError: a is not defined

第二段会打印:1

这是因为函数中的 “a” 并没有通过 var 关键字声明,所有不会被存放在 AO 中。

第一段执行 console 的时候, AO 的值是:

1
2
3
4
5
AO = {
arguments: {
length: 0
}
}

没有 a 的值,然后就会到全局去找,全局也没有,所以会报错。

当第二段执行 console 的时候,全局对象已经被赋予了 a 属性,这时候就可以从全局找到 a 的值,所以会打印 1。

2.第二题

1
2
3
4
5
6
7
console.log(foo);

function foo(){
console.log("foo");
}

var foo = 1;

会打印函数,而不是 undefined 。

这是因为在进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。

作用域链

补充:极客时间

前言

《JavaScript深入之执行上下文栈》中讲到,当JavaScript代码执行一段可执行代码(executable code)时,会创建对应的执行上下文(execution context)。

对于每个执行上下文,都有三个重要属性:

  • 变量对象(Variable object,VO)
  • 作用域链(Scope chain)
  • this

今天重点讲讲作用域链。

作用域链

《JavaScript深入之变量对象》中讲到,当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

下面,让我们以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的。

函数创建

《JavaScript深入之词法作用域和动态作用域》中讲到,函数的作用域在函数定义的时候就决定了。

这是因为函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链!

举个例子:

1
2
3
4
5
function foo() {
function bar() {
...
}
}

函数创建时,各自的[[scope]]为:

1
2
3
4
5
6
7
8
foo.[[scope]] = [
globalContext.VO
];

bar.[[scope]] = [
fooContext.AO,
globalContext.VO
];

函数激活

当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用链的前端。

这时候执行上下文的作用域链,我们命名为 Scope:

1
Scope = [AO].concat([[Scope]]);

至此,作用域链创建完毕。

捋一捋

以下面的例子为例,结合着之前讲的变量对象和执行上下文栈,我们来总结一下函数执行上下文中作用域链和变量对象的创建过程:

1
2
3
4
5
6
var scope = "global scope";
function checkscope(){
var scope2 = 'local scope';
return scope2;
}
checkscope();

执行过程如下:

1.checkscope 函数被创建,保存作用域链到内部属性[[scope]],所以说函数创建的时候已经确定了哪些变量可以被访问到

1
2
3
checkscope.[[scope]] = [
globalContext.VO
];

2.执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈

1
2
3
4
ECStack = [
checkscopeContext,
globalContext
];

3.checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链

1
2
3
checkscopeContext = {
Scope: checkscope.[[scope]],
}

4.第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明

1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: checkscope.[[scope]],
}

5.第三步:将活动对象压入 checkscope 作用域链顶端

1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: [AO, [[Scope]]]
}

6.准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值

1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: 'local scope'
},
Scope: [AO, [[Scope]]]
}

7.查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出

1
2
3
ECStack = [
globalContext
];

this

可以看这个

CLS

Cumulative Layout Shift 累积布局偏移

最常见的影响CLS的分数的有:

  • 未指定尺寸的图片

  • 未指定尺寸的广告、嵌入元素、iframe

  • 动态插入内容

  • 自定义字体(引发FOIT/FOUT)

  • 在更新DOM之前等待网络响应的操作

未指定尺寸的图片

总而言之:在

历史
在web的早期,开发者会给 标签加上 width 和 height 属性,以确保浏览器开始获取图片之前可以分配好空间,这样可以减少 reflow 和 re-layout。

你也许会注意到这两个属性没有带单位。这些像素尺寸会确保保留640 * 360的区域。图片最终会平铺在这个区域,不管原始尺寸是否一致。

当响应式设计来临的时候,开发者开始忽略 width 和 height,开始使用css来调整图片大小。

这种方法的缺点是,只有图片下载的时候,浏览器才知道图片的宽高并且分配好空间。图片下载完了,每张图片出现在屏幕上的时候,页面都会 reflow 一次,会导致页面频繁的往下弹。这对于用户体验来说非常不友好。

因此而诞生了 aspect ratio。aspect ratio 是图片的宽高比。比如,x:y的宽高比,指的是宽度x单位,高度y单位。

这也意味着只要我们知道宽高之一,就能计算出另一个属性。对于一个16:9的宽高比而言:

如果图片有360px的高度,则宽度为 360 x (16 / 9) = 640px

如果图片有640px的宽度,则高度为 640 x (9 / 16) = 360px

现代浏览器最佳体验
现代浏览器可以基于 width 和 height 属性设定默认宽高比,这样就能避免布局偏移。开发者只需要如下设置:

这样一来,图片加载之前,浏览器就可以根据宽高属性分配好空间。图片加载之后,就可以根据宽度或者高度属性,按照宽高比来分配实际空间。

图片的 aspect-ratio 属性在chrome和firefox上已经可以使用了,safari也快支持了。

如果图片位于容器内,可以设置宽度为容器宽度,高度为auto,避免高度被固定位360px。

响应式图片
在使用响应式图片的时候,srcset 定义了图片可以供浏览器选择的尺寸。为了确保图片 width 和 height 可以被设置,每张图片的宽高比必须一致。

有时候我们希望展示图片的剪切部分,比如长图的中间正方形区域,为了视觉好看。

这样一来图片宽高比就不一致了,浏览器可能更需要针对每一个资源设置特定宽高比。但目前还没有好的解决方案,re-layout 依然存在。

未指定尺寸的广告、嵌入元素、iframe
广告
广告是造成布局偏移的罪魁祸首之一。经常性,这些广告会有动态尺寸,这样会导致糟糕的用户体验,当你在往下浏览页面的时候,广告突然插入一些可见内容。

在广告的生命周期里,很多点可以导致布局偏移:

广告容器插入到dom的时候

本站代码调整广告容器尺寸的时候

广告代码库加载的时候(导致容器尺寸改变)

广告内容填充容器的时候(如果最终广告的尺寸不一样,导致容器尺寸变化)

好消息是网站可以采用最佳体验,来减少布局偏移。

为广告位静态保留空间。

换句话说,在广告代码库加载之前,就给容器加好样式。

如果要在内容流中插入广告,在插入之前确保通过保留尺寸来消除布局偏移。如果这些广告在屏幕外加载,则没有这个问题。

在视图顶部插入非粘性广告的时候要特别注意。

避免折叠预留的空间,如果广告没有返回,可以在该空间展示占位符。

通过预留广告所需最大尺寸,来避免布局偏移。

这很有效,不过如果广告很小,可能会有大片空白。

根据历史数据,给广告加上合适的尺寸。

如果广告不太可能填满,一些网站会发现在初始的时候折叠广告位可以减少布局偏移。很难做到每一次都能给广告位精准的尺寸,除非这个广告是你自己提供的。

为广告位静态保留空间
给广告容器设置固定的样式,避免代码库加载的时候,重新调整广告的尺寸。

要额外注意一下小尺寸的广告,如果预留很大的空间,会导致大片空白。

避免在视图顶部插入广告
根据CLS的计算规则,在顶部插入广告比在中间插入,造成的影响更大。

嵌入元素和iframe
可嵌入的挂件可以允许你在页面上嵌入web内容(例如,youtube视频、谷歌地图、社交媒体的帖子等)。这些嵌入元素可以采用多种形式。

html fallback,然后js将该fallback转换成嵌入元素

内联html代码块

iframe嵌入

这些嵌入通常不会事先知道嵌入的大小(例如,社交媒体帖子,是否包含图片?视频?或者多行文本?)。结果就是提供嵌入元素的平台经常无法保证预留足够的空间,导致布局偏移。

为了应对这种情况,你可以通过提前计算嵌入元素的足够空间,以最小化CLS。以下工作流可以参考:

使用开发者工具检查最终嵌入的高度

一旦嵌入元素加载,iframe容器根据内容重新调整尺寸。

记下尺寸,并相应设置嵌入元素占位符的样式。你可能还会用到媒体查询来考虑不同的因素。

动态内容
总而言之,避免在已存在的内容上方插入新内容,除非为了响应用户交互。这样可以保证任何布局偏移都是可预期的。

你可能经常会遇到从顶部或者底部弹出的一些内容。这经常发生在banner或者表单的地方,让页面的剩余内容产生偏移。

“注册即可领取会员大礼包!”

“最近发表的文章”

“安装我们的APP”

“我们还在接受订单”

“GDPR提示,是否允许使用cookie”

如果你需要展示以上的UI内容,请提前预留好空间,避免产生布局偏移。

自定义字体(引发FOIT/FOUT)
下载并渲染自定义字体会引发布局偏移,通过以下两种方式:

fallback字体切换到新字体(FOUT - flash of unstyled text)

从不可见变成可见,因为新字体的渲染缘故(FOIT - flash of invisible text)

以下工具可以帮你最小化影响:

font-display 属性可以让你修改自定义字体的渲染表现,通过使用可选值:auto, swap, block, fallback 和 optional。不幸的是,除了 optional 之外的属性都会引发 re-layout,通过以上的其中一种方式。

Font Loading API 可以减少获取必要字体的时间。

Chrome 83版本之后,可以采取以下方案:

针对关键字体使用 ,提高优先级,让字体下载有更高概率赶在fcp之前,这样就能避免布局偏移。

和 font-display: optional 结合使用。

动画
总而言之,优先考虑 transform,而非会影响布局改变的属性。

在更新DOM之前等待网络响应的操作
尽可能的在网络请求时,给一个loading,或者占位符提示,避免用户在这段时间内进行操作。

开发者工具
可以使用lighthouse和performce检测CLS。

总结

图片的尺寸,以及其他嵌入元素的尺寸,最开始就设定好,或者预留足够空间,这样可以有效避免布局偏移。

利用图片宽高比的属性,可以在优化CLS的同时,做响应式布局。

尽可能不要往已存在内容上方添加新内容。

web字体尽可能早的加载,避免产生FOIT和FOUT

与UI同事配合在交互上避免布局偏移

参考
https://web.dev/optimize-cls/

性能指标

在chrome的devtools里有很多性能指标,下面简单介绍一下这些指标

首先是可以在chrome的performance中标识的指标

  • DCL (DOMContentLoaded Event)
    • 当初始的 HTML 文档被完全加载和解析完成之后,**DOMContentLoaded** 事件被触发,而无需等待样式表、图像和子框架的完全加载。(MDN的概念)
    • 更加清晰的结论是,DOMContentLoaded 事件在 html文档加载完毕,并且 html 所引用的内联 js、以及外链 js 的同步代码都执行完毕后触发。
  • L (Onload Event)
    • load 应该仅用于检测一个完全加载的页面 当一个资源及其依赖资源已完成加载时,将触发load事件
    • 更加清晰的结论是,当页面 DOM 结构中的 js、css、图片,以及 js 异步加载的 js、css 、图片都加载完成之后,才会触发 load 事件。

      页面中引用的 js 代码如果有异步加载的 js、css、图片,是会影响 load 事件触发的。
      video、audio、flash 不会影响 load 事件触发。

  • FP (First Paint)
    • 首次绘制: 标记浏览器渲染任何在视觉上不同于导航前屏幕内容之内容的时间点,简而言之就是浏览器第一次发生变化的时间
  • FCP (First Contentful Paint)
    • 首次内容绘制 标记浏览器渲染来自 DOM 第一位内容的时间点,该内容可能是文本、图像、SVG 甚至 元素.
  • LCP (Largest Contentful Paint)
    • 最大内容渲染: 代表在viewport中最大的页面元素加载的时间. LCP的数据会通过PerformanceEntry对象记录, 每次出现更大的内容渲染, 则会产生一个新的PerformanceEntry对象.(2019年11月新增)

然后是在性能分析 lighthouse中出现的六个指标,前两个在performance中也存在

  • FCP (First Contentful Paint)
  • LCP (Largest Contentful Paint)
  • SI (Speed Index)
    • 指标用于显示页面可见部分的显示速度, 单位是时间
  • TTI (Time to Interactive)
    • 可交互时间: 指标用于标记应用已进行视觉渲染并能可靠响应用户输入的时间点.
  • TBT (Total Blocking Time)
    • 页面阻塞总时长: TBT汇总所有加载过程中阻塞用户操作的时长,在FCP和TTI之间任何long task中阻塞部分都会被汇总(超过50ms的长任务)
  • CLS (Cumulative Layout Shift)
    • 累积布局偏移: 总结起来就是一个元素初始时和其hidden之间的任何时间如果元素偏移了, 则会被计算进去,说简单点就是用户不期望的元素位置偏移。
    • 根据 Google 的介绍,CLS 问题产生的原因一般包括:
      • 图片没有宽高
      • 无尺寸的广告、嵌入式和iframes
      • 动态注入的内容
      • 导致FOIT/FOUT的Web字体
      • 在更新DOM之前等待网络响应的操作

LCP

LCP (Largest Contentful Paint) 是一个以用户为中心的性能指标,可以测试用户感知到的页面加载速度,是一个相当重要的性能指标

如何确定元素类型?

Largest Contentful Paint API 里面定义的一致,包含以下几种元素类型:

  • <img> 元素
  • <svg> 中的 <image> 元素
  • <video> 元素(如果定义了封面图,会影响LCP)
  • url() 背景图的元素
  • 块级元素带有文本节点或者内联文本子元素

要注意的是,限制元素在这些范围内只是为了一开始简单一点,以后可能会加入更多的元素。

如何确定元素的大小

LCP中元素尺寸的定义就是用户视窗所见到的尺寸。如果元素在视窗外面,或者如果元素被overflow裁剪了,这些部分不计算入LCP的元素尺寸。

  • 对于已经被设置过大小的图片元素而言,LCP的尺寸就是设置的尺寸,并非图片原始尺寸。
  • 对于文本元素而言,只有包含所有文本节点的最小矩形才是LCP的尺寸。
  • 对于其他元素而言,css样式里的margin、padding和border都不算。

LCP什么时候上报

由于Web页面都是分阶段加载的,所以最大元素可能随时会发生变化。

为了捕获这种变化,浏览器会派发一个类型是 largest-contentful-paintPerformanceEntry 对象,表示浏览器绘制第一帧的时候最大的元素。在后来的渲染帧中,如果最大元素发生变化,会再次派发一个 PerformanceEntry 对象。

DCL

DCL,当初始的 HTML 文档被完全加载和解析完成之后,**DOMContentLoaded** 事件被触发,而无需等待样式表、图像和子框架的完全加载。

简单的说几个注意点,

  • 无需等待样式表的加载仅适用于head中的css,body的css会阻塞DCL
  • DCL始终等待js解析加载完成后触发

LOAD

参考

先放结论

  • onload的触发时机: JS 加载并执行完毕且页面中所有外链资源加载完成之后大约 3 - 4ms(这个值跟机型和浏览器有关)

  • 最佳实践: JS代码的执行要放到onload里。如果是服务端渲染带图片的列表,图片最好由JS异步加载, 避免阻塞onload。

1、onload不是立即触发的

请问下面哪个alert先触发?

1
2
3
4
5
6
window.onload = function() {
alert('onload');
}
setTimeout(function(){
alert('timeout');
}, 2)

答案是 timeout 先触发。而在我的电脑上,把 timeout 的值调成 5 或 5 以上,就是 onload 先触发了。

2、JS的执行对onload有影响

1
2
3
4
5
6
7
window.onload = function() {
alert('onload');
}
var a
for (var i = 0; i < 100000000; i++) {
a = a + i;
}

你会发现 onload 会等很久才触发,因此JS的执行最好放在onload之后。

3、动态加载的资源可能对onload产生影响

1
2
3
4
5
6
7
<body></body>
<script>
window.onload = function() {
alert('onload');
}
document.body.innerHTML = '<img src="a.png"> .... <img src="z.png">';
</script>

我们把网速调的慢一点,我们会很清晰的发现这种 JS 动态加进去的图片也会阻塞 onload,只有 a-z 图片都加载完成,onload 才会触发。而我们改成下面:

1
2
3
4
5
6
window.onload = function() {
setTimeout(function() {
document.body.innerHTML = '<img src="a.png"> .... <img src="z.png">';
}, 10)
alert('onload');
}

这时就会发现,onload 马上就触发了,不必等待图片加载完成。

4、再讲一个更实际的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<img src="aaa.png">
<script>
window.onload = function() {
alert('onload');
}
$.ajax({
url: imgList,
success: function(arr) {
arr.forEach(function() {
$('body').append('<img src="' + arr.imgUrl + '">')
})
}
})
</script>

假设 aaa.png 加载时间为 100ms,ajax 接口返回时间为 50ms,那么假设 imgList 中有 100 张图片,那么 onload 的时间就会被推迟到这 100 张图片都加载完成之后。

而如果 aaa.png 加载时间为 50ms,接口请求为 100ms 的时候,就不会有这个问题。但是我们没法保证接口请求一定慢于图片请求。

因此带图片的列表请求需要放在 window.onload 之后执行

CLS

写的很不错,第一个网址是对第二个网址的总结

https://www.bilibili.com/read/cv7415934/

https://web.dev/optimize-cls/

google性能模型RAIL

了解下谷歌提出的性能模型 RAIL:

图片

1.响应:输入延迟时间(从点按到绘制)小于 100 毫秒。用户点按按钮(例如打开导航)。

2.动画:每个帧的工作(从 JS 到绘制)完成时间小于 16 毫秒。用户滚动页面,拖动手指(例如,打开菜单)或看到动画。拖动时,应用的响应与手指位置有关(例如,拉动刷新、滑动轮播)。此指标仅适用于拖动的持续阶段,不适用于开始阶段。

3.空闲:主线程 JS 工作分成不大于 50 毫秒的块。用户没有与页面交互,但主线程应足够用于处理下一个用户输入。

4.加载:页面可以在 1000 毫秒内就绪。用户加载页面并看到关键路径内容。

performance介绍

performance 对象是专门用来用于性能监控的对象,内置了一些前端需要的性能参数。

performance.now()方法

performance.now()返回performance.navigationStart至当前的毫秒数。performance.navigationStart是下文将介绍到的可以说是浏览器访问最初的时间测量点。

performance.timing

图片

performance.getEntries()方法

浏览器获取网页时,会对网页中每一个对象(脚本文件、样式表、图片文件等等)发出一个 HTTP 请求。performance.getEntries() 方法以数组形式,返回一个 PerformanceEntry 列表,这些请求的时间统计信息,有多少个请求,返回数组就会有多少个成员。

name :资源名称,是资源的绝对路径或调用mark方法自定义的名称 startTime :开始时间 duration :加载时间 entryType :资源类型,entryType 类型不同数组中的对象结构也不同!具体见下 initiatorType :谁发起的请求,具体见下:

描述
mark 通过 mark() 方法添加到数组中的对象
paint 通过 measure() 方法添加到数组中的对象
measure first-contentful-paint 首次内容绘制
resource 所有资源加载时间,用处最多

图片

0%