大白话也能讲清楚的 React17 原理（1）—— 搭建可调试源码的项目
### 前言 如果不想麻烦，可以直接在[我的git地址][1]中克隆项目至本地，使用`npm i`安装依赖包，然后`npm run start`启动即可。大 TODO：大白话讲React原理目录： ### 创建 React17 项目 #### 先使用官方脚手架`create-react-app`创建项目 ``` npx create-react-app learn-react-source-code ``` #### 降级 React 版本 我们需要把 React 版本从 18 降到 17 。 ``` "@testing-library/react": "^12.1.5", "react": "^17.0.2", "react-dom": "^17.0.2", ``` ![构建2.jpg][2] #### 修改src/index.js ``` import React from "react"; import ReactDOM from "react-dom"; import "./index.css"; import App from "./App"; import reportWebVitals from "./reportWebVitals"; const root = document.getElementById("root"); ReactDOM.render( , root ); ``` ![构建3.jpg][3] #### 尝试运行项目 **删除**根目录下`package-lock.json`，重新安装依赖 `npm i` ，最后 `npm run start`。 此时应该能成功运行项目了，但是目前开发者工具`sources`面板里文件还是打包后的react代码。 为了调试源码，我们还需要做以下步骤 ### 创建带源码的项目 #### 在 src 目录下，克隆 React17 源码 ``` cd src git clone https://github.com/facebook/react.git -b 17.0.2 ``` #### 根目录下，释放 webpack 配置 ``` npm run eject or yarn eject ``` 因为此时 `webpack` 配置全部集成在`react-script`中，我们需要将他释放开来，让我们自己配置 `webpack` 。 命令执行完毕后在新增的 `config` 文件夹下可以看到 `webpack` 的配置文件： ![构建8.jpg][4] #### 根目录下，配置 webpack 使其引用源码 往`config/webpack.config.js` 添加 `alias` ``` // ./config/webpack.config.js ... alias: { ... ...(modules.webpackAliases || {}), + react: path.resolve(__dirname, "../src/react/packages/react"), + "react-dom": path.resolve(__dirname, "../src/react/packages/react-dom"), + shared: path.resolve(__dirname, "../src/react/packages/shared"), + "react-reconciler": path.resolve( __dirname, "../src/react/packages/react-reconciler" ), + "legacy-events": path.resolve( __dirname, "../src/react/packages/legacy-events" ), + scheduler: path.resolve(__dirname, "../src/react/packages/scheduler"), }, ``` ![构建5.jpg][5] #### 关闭 EsLint ``` // ./config/webpack.config.js ... const disableESLintPlugin = true; ``` ![构建4.jpg][6] #### `config/env.js`添加环境变量 ``` // ./config/env.js ... // Stringify all values so we can feed into webpack DefinePlugin const stringified = { + __DEV__: true, + __PROFILE__: true, + __UMD__: true, + __EXPERIMENTAL__: true, "process.env": Object.keys(raw).reduce((env, key) => { env[key] = JSON.stringify(raw[key]); return env; }, {}), }; ``` ![构建6.jpg][7] #### 修改index.js中 react 和 react-dom 的引入 ``` import * as React from "react"; import * as ReactDOM from "react-dom"; ``` ![构建7.jpg][8] #### 修改报错文件 可以直接[参考此次Commit][9] 如果此时尝试运行项目，webpack会告诉你有 300 多个错误，不要怕，我们马上解决。 `webpack compiled with 336 errors` ##### 1. ./src/react/packages/scheduler/index.js ``` // 添加 export { unstable_flushAllWithoutAsserting, unstable_flushNumberOfYields, unstable_flushExpired, unstable_clearYields, unstable_flushUntilNextPaint, unstable_flushAll, unstable_yieldValue, unstable_advanceTime } from './src/SchedulerHostConfig.js'; ``` ![构建9.jpg][10] ##### 2. ./src/react/packages/shared/invariant.js ``` // 添加 if (condition) { return; } ``` ![构建10.jpg][11] ##### 3. ./src/react/packages/react-reconciler/src/RectFiberHostConfig.js ``` // 注释文件并在最后导出 export * from './forks/ReactFiberHostConfig.dom'; ``` ![构建11.jpg][12] ##### 4. ./src/react/packages/shared/RectSharedInternals.js ``` // 注释文件并添加导入 import ReactSharedInternals from '../react/src/ReactSharedInternals' ``` ![构建12.jpg][13] ##### 5. ./src/react/packages/scheduler/src/SchedulerFeatureFlags.js ``` // 修改 export const enableProfiling = true; ``` ![构建13.jpg][14] ##### 6. ./src/react/packages/scheduler/src/SchedulerHostConfig.js ``` // 注释文件并添加 export { unstable_flushAllWithoutAsserting, unstable_flushNumberOfYields, unstable_flushExpired, unstable_clearYields, unstable_flushUntilNextPaint, unstable_flushAll, unstable_yieldValue, unstable_advanceTime } from './forks/SchedulerHostConfig.mock.js'; export { requestHostCallback, requestHostTimeout, cancelHostTimeout, shouldYieldToHost, getCurrentTime, forceFrameRate, requestPaint } from './forks/SchedulerHostConfig.default.js'; ``` ![构建14.jpg][15] ### 运行项目 此时使用`npm run start`就可以正常运行项目了，通过`debugger`或者`console.log`我们就可以开始调试源码啦。 比如 ![构建1.jpg][16] [1]: https://github.com/232295311/learn-react-source-code [2]: http://120.25.166.245/usr/uploads/2024/03/3108046133.jpg [3]: http://120.25.166.245/usr/uploads/2024/03/2342834140.jpg [4]: http://120.25.166.245/usr/uploads/2024/03/1752848700.jpg [5]: http://120.25.166.245/usr/uploads/2024/03/2043372383.jpg [6]: http://120.25.166.245/usr/uploads/2024/03/3085103012.jpg [7]: http://120.25.166.245/usr/uploads/2024/03/631557534.jpg [8]: http://120.25.166.245/usr/uploads/2024/03/1656241588.jpg [9]: https://github.com/232295311/learn-react-source-code/commit/c968ffd285bd02e45bbfc708ba85279c1590c581 [10]: http://120.25.166.245/usr/uploads/2024/03/387793252.jpg [11]: http://120.25.166.245/usr/uploads/2024/03/3662571732.jpg [12]: http://120.25.166.245/usr/uploads/2024/03/1142517594.jpg [13]: http://120.25.166.245/usr/uploads/2024/03/2628396643.jpg [14]: http://120.25.166.245/usr/uploads/2024/03/2433840008.jpg [15]: http://120.25.166.245/usr/uploads/2024/03/2242156174.jpg [16]: http://120.25.166.245/usr/uploads/2024/03/2318368381.jpg
### 前言 前些天被人问到了 BFC ，在回答的时候俺支支吾吾半天只能说个大概，没办法有条理性的告诉他 BFC 是什么有什么用。查阅资料，发现也没有能够从源头上系统化梳理BFC的文章，遂自己查阅文档写出本文。 ### 什么是BFC？ BFC 全称是`Block Formatting Context`，即**块格式化上下文**。它是 CSS2.1 规范定义的，关于 CSS 渲染定位的一个概念。要明白 BFC 到底是什么，还得首先聊聊 CSS 中的**视觉格式化模型**。 ### 什么是视觉格式化模型、什么是盒子类型 写了这么久Web的我们都知道：块级元素换行、行内元素不换行、浮动和绝对定位脱离文档流等等。 以上都代表了浏览器对某种节点类型的处理方式，这些处理方式就是由**视觉格式化模型**定义的。 **视觉格式化模型**，即 `Visual Formatting Model`，是用来处理文档并将它显示在视觉媒体（浏览器、移动端）上的机制，它也是CSS中的一个概念，或者说，**一种规则**。 那么它是如何工作的呢? > In the visual formatting model, each element in the document tree generates zero or more boxes according to the box model. 首先，**视觉格式化模型**定义了**盒子**（Box）这个概念，————文档树中的每个元素，都属于某种盒子。它可用于文档元素的**定位、布局和格式化**，盒子类型主要包括了： 1. 块盒子 2. 行内盒子 3. 匿名盒子（没有名字不能被选择器选中的盒） 这边的术语非常多，而且不能乱用，[具体可参考MDN文档](https://developer.mozilla.org/zh-CN/docs/Web/CSS/Visual_formatting_model) 。以下只提取最大级的概念。 [CSS的盒子模型](https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_box_model/Introduction_to_the_CSS_box_model)就是我们熟知的`content-box`和`border-box`那些，这里就不多展开了。 盒子的类型由元素的 `display`属性决定： ``` display: block;//list-item table display: inline;//inline-block inline-table display: inherit; ``` #### 块盒子 Block Box 1. 当元素的 `display`为 `block`、`list-item` 或 `table` 时，该元素将成为**块级元素**( block-level element )。 2. 每个**块级元素**都会至少生成一个**块级盒子**（ block-level box ）。 3. 每个**块级盒子**都会参与[块格式化上下文](https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_display/Block_formatting_context)的创建，即BFC。 4. 还有一种类型：**块容器盒子**（ block container box ）：它要么只包含其他**块级盒子**，要么只包含**行内盒子**并同时创建一个[行内格式化上下文](https://developer.mozilla.org/zh-CN/docs/Web/CSS/Inline_formatting_context)，即IFC。。 5. 块盒子 = 既是**块级盒子**又是**块容器盒子**的盒子。 简单理解：每个**块级元素**至少生成一个**块级盒子**，参与**BFC**，在视觉上表现为：呈现为块，竖直排列。 #### 行内盒子 Inline Box 1. 如果一个元素的 `display`属性为 `inline`、`inline-block` 或 `inline-table`，则称该元素为**行内级元素**( inline-level element )。 2. **行内级元素**生成**行内级盒子** （ inline-level box ）。 3. **行内级盒子**可能会参与[行内格式化上下文（ inline formatting context ）](https://developer.mozilla.org/zh-CN/docs/Web/CSS/Inline_formatting_context)的创建，即IFC。 4. **行内盒子** = 参与了行内格式化上下文创建的**行内级盒子**。 5. **原子行内级盒**(atomic inline-level boxes) = 没参与IFC创建的行内级盒子，如`display`为 `inline-block` 或 `inline-table`。 简单理解：**行内级元素**会生成**行内盒子**，参与**IFC**，在视觉上表现为：将内容与行内级元素排列为多行；例如包含文本、图片等多种行内级元素的段落。 #### 匿名盒子 Anonymous Box 1. 在某些情况下进行视觉格式化时，需要添加一些增补性的盒子，这些盒子**不能用 CSS 选择符**选中，因此称为**匿名盒子** 。 2. 此时它的所有可继承的 CSS 属性值都为 `inherit`。 3. 它也分为匿名块盒与匿名行内盒 简单理解：某些元素（如纯文本元素），在视觉呈现的时候，会自动创建出一个不能用CSS选择符选中的盒子，就称这个盒子为匿名盒子。 ![boxes_types.png][1] ### 浏览器根据盒子类型形成定位方案 浏览器在进行视觉呈现的时候，主要步骤分为：生成盒子 -> 形成**定位方案** -> 完成布局。 **定位方案**指的就是定位盒子的方案，它又分为三种： 1. 普通流。 2. 浮动。 3. 绝对定位。 #### 普通流 Normal flow 1. 当 CSS 的 `position` 属性为 `static` 或 `relative`，并且 `float` 为 `none` 时，其布局方式为普通流。 2. 在普通流中，会按照次序依次定位每个盒子： 1. 在 BFC 中，盒子在**垂直方向**依次排列。 2. 在 IFC 中，盒子在**水平方向**依次排列 3. 当`position` 为 `static` 时，为静态定位，盒子位置 = 普通流布局中位置。 4. 当 `position` 为 `relative` 时，为相对定位，盒子位置 = 普通流布局中位置 + `top`、`bottom`、`left`和 `right` 各个属性产生的偏移量。但仍然**占据原有的空间 ，其它常规流不能占用这个位置**。 #### 浮动 Floats 1. 一个盒子的 `float` 值不为 `none`，并且其 `position` 为 `static` 或 `relative` 时，该盒子为浮动定位。 2. 在浮动定位中，盒子会浮动到当前行的开始或尾部位置。 3. 其他普通流定位的盒子会**环绕在它的周边，除非清除浮动** #### 绝对定位 Absolute positioning 1. 如果元素的 `position` 为 `absolute` 或 `fixed`，该元素为绝对定位。 2. 对于 `position: absolute`，元素定位将相对于最近的一个 `relative`、`fixed`或 `absolute`的父元素，如果没有则相对于 `body`。 3. 对于 `position: fixed`，元素定位将相对于**屏幕视口**（viewport）的位置，且不会因为屏幕滚动而改变。 4. 在绝对定位中，盒子会**完全从当前常规流中移除**，并**不占据原有的空间，** 5. 盒子位置 = 定位起点 + `top`，`bottom`，`left` 和 `right` 各个属性产生的偏移 ### 回到块格式化上下文 经过上面概念的梳理，我们现在知道什么是 BFC 了————它是 Web 页面 CSS 视觉渲染的一部分， 用于**决定块盒子的布局** 以及 **浮动相互影响范围** 的一个区域 。 有一句话我觉得概括得很好———— ** BFC 就是 CSS 里的块级作用域。总之它是一个范围、一个框、一个区域。** #### 哪些元素会生成BFC： 1. **根元素**。即`HTML`元素。 2. **浮动元素**。`float`的值不为`none`。 3. **绝对定位元素**。`position`的值为`absolute`或`fixed`。 4. `overflow`的值不为`visible`。 5. **行内块元素**或者**表格单元格**。 `display:inline-block`、`table-cell`或 `table-caption`。 6. **弹性盒元素**。`display: flex`或 `inline-flex` 最常见的是 `overflow:hidden`、`display: flex`、`float:left/right`、`position:absolute`。也就是说，每次看到这些属性的时候，就代表了该元素已经创建了一个 BFC 了。 #### BFC 的范围 BFC 的范围在 MDN 中是这样描述的。 > A block formatting context contains everything inside of the element creating it that is not also inside a descendant element that creates a new block formatting context. 意思是一个 BFC 包含创建该上下文元素的所有子元素，但不包括创建了新 BFC 的子元素的内部元素。 这段看上去有点奇怪，我是这么理解的，加入有下面代码，`class`名为 `.BFC`代表创建了新的 BFC ： ``` ``` 这段代码表示，`#div_1`创建了一个块格式上下文，这个上下文包括了 `#div_2`、`#div_3`、`#div_4`、`#div_5`。即 `#div_2`中的子元素也属于 `#div_1`所创建的BFC。但由于 `#div_5`创建了新的BFC，所以 `#div_6`和 `#div_7`就被排除在外层的BFC之外。 我认为，这从另一方角度说明， **一个元素不能同时存在于两个BFC中** 。 BFC 的一个最重要的效果是，让处于 BFC 内部的元素与外部的元素**相互隔离**，使内外元素的**定位不会相互影响**。 所以，如果一个元素能够同时处于两个 BFC 中，那么就意味着这个元素能与两个 BFC 中的元素发生作用，就违反了 BFC 的隔离作用，所以这个假设就不成立了。 #### BFC 的布局规则： 1. 内部的盒子会在**垂直方向**上一个接一个的放置。（相当于内部也有一个常规流） 2. 盒子垂直方向的距离由`margin`决定。属于同一个 BFC 的两个**相邻盒子**的`margin`会发生重叠（即`margin塌陷`）。 3. 每个元素的`margin左边界`，都与**容器块**的**左边界**相接触。即使是浮动元素也是如此。 4. BFC 就是页面上的**一个隔离的独立容器**，容器里面的子元素不会影响到外面的元素，反之亦然。 5. 计算 BFC 的高度时，考虑 BFC 所包含的所有元素，连浮动元素也参与计算。 6. 浮动盒区域不叠加到 BFC 上。 看到以上的几条约束，想想我们学习 CSS 时的几条规则 1. 块级元素会扩展到与父元素同宽，所以块级元素会垂直排列。 2. 垂直方向上的两个相邻块级元素的`margin`会塌陷，而水平方向不会。 3. 浮动元素会尽量接近往左上方（或右上方）。 4. 为父元素设置 `overflow：hidden`或 `float:left`，则会包含浮动元素。 #### BFC的作用 1. 防止`margin`塌陷，包括垂直相邻塌陷、嵌套元素塌陷。 2. 清除内部浮动，防止高度塌陷，或者防止浮动元素与其他元素重叠。 3. 自适应双栏、多栏布局 ### Demo1：防止margin塌陷 #### 问题描述： M3C文档里是这样描述`margin塌陷`的: > In CSS, the adjoining margins of two or more boxes (which might or might not be siblings) can combine to form a single margin. Margins that combine this way are said to collapse, and the resulting combined margin is called a collapsed margin. 在一个 BFC 里，两个或多个**相邻盒子**的上外边距和下外边距可能会塌陷（折叠）为一个外边距，其大小会取其中外边距值大的那个，这种现象就是`margin塌陷`。需要注意的是，**浮动的元素**和**绝对定位**这种脱离文档流的元素的外边距不会折叠，因为他们属于其他两种的定位方案。**`margin塌陷`只会出现在垂直方向**。 W3C文档对于**“相邻”**（ adjoining ）的定义如下： > Two margins are adjoining if and only if: > - both belong to in-flow block-level boxes that participate in ** the same block formatting context** > - **no line boxes, no clearance, no padding and no border separate them** (Note that certain zero-height line boxes (see 9.4.2) are ignored for this purpose.) > - both belong to vertically-adjacent box edges, i.e. form one of the following pairs: > - top margin of a box and top margin of its first in-flow child > - bottom margin of box and top margin of its next in-flow following sibling > - bottom margin of a last in-flow child and bottom margin of its parent if the parent has 'auto' computed height > - top and bottom margins of a box that does not establish a new block formatting context and that has zero computed 'min-height', zero or 'auto' computed 'height', and no in-flow children > >A collapsed margin is considered adjoining to another margin if any of its component margins is adjoining to that margin. 简单概括：两者属于同一个 BFC 且它两之间没有行盒子，没有间隙，没有`padding`也没有`border`，那他们之间就属于**相邻**。 #### 计算原则： 出现`margin塌陷`时合并外边距的计算原则如下： ● 如果两者都是正数，那么就取**最大值**。 ● 如果是一正一负，就会取正值减去负值的绝对值。 ● 两个都是负值时，用 0 减去两个数中绝对值大的那个。 #### 问题1：兄弟之间发生margin塌陷 ``` Document * { margin: 0; padding: 0; } .first { margin: 100px; background: lightgreen; border: 1px solid; width: 100px; height: 100px; } ul { /* display: inline-block; */ /* position: fixed; */ /* float: left; */ margin: 100px; background: lightblue; border: 1px solid; width: 200px; } li { margin: 10px 20px; } item1 item2 item3 ``` 如图，以上代码创建两个相邻的块级元素，并都将他们的`margin`设置为`100px`。可以观察到两个块级元素发生`marin塌陷`。 ![demo1_1.jpg][2] ##### 解决办法： 1.利用绝对定位、浮动盒子、`inlne-block`盒子都不会发生`margin塌陷`的特性。[参考CSS2文档][3] > - Margins between a **floated** box and any other box do not collapse (not even between a float and its in-flow children). > - Margins of elements that establish new block formatting contexts (such as floats and elements with 'overflow' other than 'visible') do not collapse with their in-flow children. > - Margins of **absolutely** positioned boxes do not collapse (not even with their in-flow children). > - Margins of **inline-block** boxes do not collapse (not even with their in-flow children). 设置下方元素`float:left`和`position:absolute`使下方元素脱离普通流，或设置'display:inline-block'使其成为`inline-block`盒子。 **注意**：如果此时只是给``设置`overflow:hidden`，`display:flex`，` display:table-cell `等，使其生成 BFC 都不会发生作用。因为此时虽然**对内已经生成了BFC**，但是对外它还是为一个**块级盒子**，两相邻**块级盒子**之间就是会`margin塌陷`。回去看看“相邻”的定义 > Vertical margins between adjacent block-level boxes in a block formatting context collapse. ![demo1_2.jpg][4] 2.在底部元素外部包裹一个新的元素，使其生成 BFC 。利用`margin塌陷`只会发生在同一个 BFC 下的两相邻**块级盒子**之间的特性。 ![demo1_3.jpg][5] #### 问题2：父子之间发生margin塌陷 其实上面说的，相邻兄弟**块级盒子**之间会发生`margin塌陷`是有利于我们实际开发的，一般可以不修改，而这个发生在父子之间的，几乎就是必改的了。 ``` ... ul { /* display: inline-block; */ /* position: fixed; */ /* float: left; */ margin: 100px; background: lightblue; /* 注释下一行，会发生父子间margin塌陷 */ /* border: 1px solid; */ width: 200px; } ``` 如代码，我们注释掉``元素的`boder`，此时会发生父子块级盒子之间的`margin塌陷`。如图，我们给每个``设置了`margin`，但是会发现此时``与``之间的垂直距离，取`、、`三者之间的最大外边距,为`100px`。 ![demo1_4.jpg][6] 为什么注释掉``元素的`boder`就会引起这个现象呢？？因为注释掉的话，``和``之间就没有任何阻隔了，根据上面提到的**相邻**两个或多个**盒子**之间就会产生`margin塌陷`的规则，就会出现这个现象。回去看看“相邻”的定义 ##### 解决方法: 1. 给``添加`boder`、`padding`等属性，使`、、`三者不满足“相邻”条件。 ![demo1_5.jpg][7] 2.使``内部变成 BFC，利用 BFC 之间互不影响的特性。 ![demo1_6.jpg][8] ### Demo2：防止高度塌陷或防止浮动元素与其他元素重叠。 #### 问题1：高度塌陷 ``` * { margin: 0; padding: 0; } .parent { border: 2px solid black; width: 400px; /* overflow: hidden; */ } .child1 { float: left; background-color: lightblue; width: 100px; height: 300px; } .child2 { background-color: lightgreen; width: 300px; height: 100px; } ``` 如图，以上代码在一个`父`里创建了两个`子`，并将第一个`子`设置为浮动。可以观察到`父`的高度并没有计算浮动`子`的高度，发生了`高度塌陷`。 ![demo2_1.jpg][9] ##### 解决办法： 使`父`生成 BFC 即可，利用：**BFC 计算高度时，考虑 BFC 所包含的所有元素（不只是子元素），连浮动元素也参与计算**这一特性。就如上一张图中的`HTML`元素，因为它也是BFC，所以能计算到浮动元素的高度。 当然清除浮动也可以使用一个包含`clear:both`的空元素，或者设置`父`的伪元素`:after`为`display:block;clear:both`来实现，相比BFC，设置伪元素效果更好，更多人使用。 ![demo2_2.jpg][10] #### 问题2：浮动元素与其他元素重叠 此时，观察图片，我们还发现两个`子`之间还会发生重叠，这是因为浮动元素会**脱离普通流**并不占据原本空间，而其他普通流定位的盒子会**环绕在它的周边**。 ![demo2_3.jpg][11] ##### 解决办法： 使发生重叠的`子`生成 BFC 即可，利用：**浮动盒区域不叠加到 BFC 上**这一特性。 ![demo2_4.jpg][12] ### Demo：自适应双栏、三栏布局。 ``` 自适应双栏布局和三栏布局 * { margin: 0; padding: 0; } .aside { float: left; width: 100px; min-height: 300px; background: lightcoral; } .main { height: 200px; background: lightgreen; display: flex; overflow: hidden; } .left { background: pink; float: left; width: 180px; } .center { background: lightyellow; overflow: hidden; } .right { background: lightblue; width: 180px; float: right; } .BFC { overflow: hidden; } .item { width: 50px; height: 50px; margin: 20px; background-color: lightgray; } 我是aside .left{ background:pink; float: left; width:180px; } .right{ background:lightblue; width:180px; float:right; } .center{ background:lightyellow; overflow:hidden; height:116px; } ``` 原理上文已经说过了，这里就不再重复了，我也写在了图中。 ![demo3_1.jpg][13] ![demo3_2.jpg][14] ### 总结 至此，我们对BFC的学习就结束啦。在写这篇文章的过程中，我才发现原来外边距塌陷和BFC并不可以混淆而谈，外边距塌陷有它自己非常明显的充分必要条件，它的发生与**“相邻”的盒子**有关，而 BFC 只是解决它的一个办法而已，并不是只要生成 BFC 就能解决。这篇文章还是查阅了非常多资料的，上面的Demo也都可以在[我的git地址][15]中找到，如果恰好对你们有所帮助的话请不要吝啬你们的点赞和收藏哦~谢谢。 [1]: http://120.25.166.245/usr/uploads/2024/03/3873987992.png [2]: http://120.25.166.245/usr/uploads/2024/03/3922133295.jpg [3]: https://www.w3.org/TR/CSS21/box.html#collapsing-margins [4]: http://120.25.166.245/usr/uploads/2024/03/371289040.jpg [5]: http://120.25.166.245/usr/uploads/2024/03/3527031881.jpg [6]: http://120.25.166.245/usr/uploads/2024/03/2482920802.jpg [7]: http://120.25.166.245/usr/uploads/2024/03/774208045.jpg [8]: http://120.25.166.245/usr/uploads/2024/03/3799098106.jpg [9]: http://120.25.166.245/usr/uploads/2024/03/1072714288.jpg [10]: http://120.25.166.245/usr/uploads/2024/03/1469980201.jpg [11]: http://120.25.166.245/usr/uploads/2024/03/4047192120.jpg [12]: http://120.25.166.245/usr/uploads/2024/03/2199067581.jpg [13]: http://120.25.166.245/usr/uploads/2024/03/2900843260.jpg [14]: http://120.25.166.245/usr/uploads/2024/03/750709272.jpg [15]: https://github.com/232295311/learn-css/tree/master/learn-BFC
### 前言： 本文的行文思路来自最近看的一本书[《图解HTTP》][1]，所有流程图均由自己在[Excalidraw][2]上原创，如需引用，请标明出处，也欢迎在本文评论区留言探讨，如果对您有帮助，请不要吝啬您的点赞和收藏~ ### 背景 都3202年了，互联网如此发达的现在，我们经历过的网站和 Web 应用也越来越多，而细心的你可能会发现，几乎每个我们经常使用的网站都使用 HTTPS 协议传输数据，原因在于 HTTPS 相较于 HTTP 能够提供更加安全的服务。 浏览器对于使用 HTTP 协议的网站都会加上⚠︎标志表示数据传输不安全，而对于使用 HTTPS 协议的网站则会表示安全。 ![差别1.jpg][3] 那么是什么让各个“有头有脸”的大公司都纷纷选用 HTTPS 呢？这就不得不提 HTTP 协议在安全性方面上的三个缺点了： ### HTTP的缺点： 1. 通信使用明文（不加密），**内容可能会被窃听。** 2. 不验证通信方的身份，**通信方有可能遭遇伪装。** 3. 无法证明报文的完整性，**有可能接收到的报文已遭篡改。** ### HTTPS来解决HTTP的安全性问题： 针对上文提到的三个方面的问题，我们的 HTTPS 协议堂堂诞生！那么它是怎么解决这三个问题的呢？ 1. 会被窃听？**数据加密！**： HTTPS 通信不再使用明文，而是使用加密后的**密文**。 2. 会被伪装？**数字证书！**： HTTPS 通信双方通过数字证书机构（CA）颁布的**数字证书**来证明双方身份。 3. 会被篡改？**完整性保护！**： HTTPS 通过**摘要算法**得到传输数据的摘要，如果数据被篡改，接收方算出的摘要将发生改变，接收方就知道传输数据已被篡改。 通过上面的介绍，我们也就可以回答这个经典问题了————**“什么是HTTPS？”** 答：**HTTP + 加密 + 认证 + 完整性保护 = HTTPS** ### HTTPS 是身披 SSL/TLS 外壳的 HTTP 其实 HTTPS 并非是应用层的一种新协议，只是在 HTTP 通信部分接口用 SSL （安全套接层）和 TLS （安全传输层协议）代替了而已： 1. 通常情况下，HTTP 直接和 TCP 通信。 2. 当使用 HTTPS 时，则变成 HTTP 先和 SSL/TLS 通信，再由 SSL 和 TCP 通信。所以也可以说：`HTTPS = SSL/TLS + HTTP` ![差别2.jpg][4] SSL/TLS 是世界上应用最为广泛的网络安全技术，在 HTTP 采用了他们之后，也就拥有了**加密 + 认证 + 完整性保护**这些功能。而且他们还不仅可以用在 HTTP 上，他们也可以用在任何需要加密的应用层协议上，如 SMTP 、Telnet 等 ### 数据加密解决明文传输被窃听问题 > 在对 SSL/TLS 进行讲解之前，我们先来浅浅地了解一下加密算法们，如有错误请一定指出。 > ps:从本章开始，我们都将通信双方假定为客户端和服务器，并且通常由客户端充当发送方，服务器充当接收方。 为了防止传输数据被黑客所窃听，客户端与服务器之间需要对数据进行加解密处理，如图所示，**客户端**使用**加密算法**将**明文**转变为**密文**，而**服务器**接收到**密文**后，使用**解密算法**将密文转变回**明文**，这样即使在通信中途密文被窃听了，黑客也**无法获得有效的信息**。 ![加密算法1.jpg][5] 近代的加密方法中，**加密算法是公开的，而`密钥`却是保密的**。加密和解密都会用到`密钥`，没有`密钥`就无法对密文进行解密，反过来说，任何人只需要持有`密钥`，就能进行解密了。我们一般将加密算法分为两大类： 1. 对称加密：加密和解密时，使用相同的密钥。图中**密钥A = 密钥B**。 2. 非对称加密：加密和解密时，使用不同的密钥。图中**密钥A ≠ 密钥B**。 #### 对称加密 对称加密在加密和解密时，使用的是相同的`密钥`。 凯撒加密是一种最简单且最广为人知的加密算法，它是一种替换加密的技术：明文中的所有字母都在字母表上向后（或向前）按照一个**固定数目**进行偏移后被替换成密文。 ![凯撒加密.jpg][6] 如上图，每个字母**都向后偏移了4个字母**的位置。那对于对称加密来说，这个**偏移量**就是`密钥`。 ![凯撒加密2.jpg][7] 当然，凯撒加密过于简单了，肯定不算是行之有效的加密算法。 为了增加解密的难度，对称加密内部又细分为**流加密**和**分组加密**两种类型，现在常用的对称加密算法有** DES 、3DES 、 AES 、RC4 **等。这里就不过多展开了。 ##### 对称加密的优缺点 - 优点：简单快捷、计算量小、加密速度快、加密效率高，一般是非对称加密算法的 100-10000 倍。 - 缺点：**在数据传送前，发送方和接收方必须商定好秘钥**，然后双方保存好秘钥。**如果一方的秘钥被泄露，那么加密信息也就不安全了**。所以在**商定阶段**，就很容易泄露`密钥`。 ![对称加密弊端.jpg][8] #### 非对称加密 对称加密在加密和解密时，使用的是**一对**不同的`密钥`，分别是公开密钥（简称`公钥`）和私有密钥（简称`私钥`） 1. `私钥`只能由一方安全保管，不能泄露。而`公钥`则可以发给任何请求它的人。 2. 非对称加密使用这对密钥中的**一个进行加密**，**另一个则用来解密**。 ![非对称加密1.jpg][9] ![非对称加密2.jpg][10] 如图所示，在商定阶段，客户端请求服务器的公钥，在数据传输节点，客户端用公钥加密，服务器用私钥解密。与对称加密不同的是，即使在商定阶段公钥被窃听了，黑客也几乎不可能根据公钥和密文解出明文。现在常用的非对称加密算法有：**RSA 、ECC（移动设备用）、Diffie-Hellman 、El Gamal 、DSA（数字签名用）**等。 ##### 非对称加密的优缺点 - 优点：**安全性更好**，即使公钥暴露了也无所谓。 - 缺点：存在大量的指数运算，加密和解密速度都非常慢，只适合对少量数据进行加密。 #### HTTPS 采用混合加密 前文已经介绍过了，如果使用对称加密，`密钥`容易泄露。如果使用不对称加密，`密钥`安全了，但速度可太慢了，完全满足不了当今网络传输数据量的需要。所以机智的 HTTPS 充分利用了两者各自的优势，采用了**混合加密**的方法： 1. 在**商定阶段**使用**非对称加密**：用`公钥`加密，`私钥`解密，确保下个阶段的`密钥`安全到达双方。 2. 在**传输数据阶段**使用**对称加密**：在确保`密钥`安全的情况下，就可以大胆使用对称加密提速了。 也就是说，用**非对称加密算法传输密钥，用对称加密算法传输实际数据**。为了方便描述，我们将商定阶段传输的`密钥`称为`共享密钥`。 ![混合加密.jpg][11] ![混合加密2.jpg][12] ### 数字证书解决 HTTP 通信方有可能遭遇伪装问题 #### 只有混合加密还不够 在前文中，我们已经了解了`混合加密`的方式和`共享密钥`的存在。但是万一客户端和服务器之间，有黑客伪装成中间者（如 DNS服务器），即使是`混合加密`也会让密文加密的真实内容暴露，如图： 1. 客户端发送的请求被黑客伪装的 DNS服务器 劫持，客户端向中间者请求`公钥1`。 2. 中间者正常转发请求`公钥1`的请求到服务器。 3. 服务器响应`公钥1`给中间者。 4. 中间者假装自己是服务器，给客户端响应原属于自己的`公钥2`。 5. 客户端用`公钥2`加密接下来要传输的`共享密钥`，向服务器（其实是中间者）发出。 6. 中间者使用自己的`私钥2`解密得到`共享密钥`，至此**共享密钥已经泄露**。 7. 中间者使用先前获得的`公钥1`加密`共享密钥`。 8. 中间者将加密后的`共享密钥`发至服务器。 9. 服务器用`私钥1`解密后也得到`共享密钥`。 10. 此后客户端和服务器之间再用`共享密钥`加密传输数据，**由于`共享密钥`泄露，中间者将清清楚楚地知道传输内容**，也就能随心所欲地**篡改**和**窃听**了。 ![中间人攻击1.jpg][13] ![中间人攻击2.jpg][14] 那么有没有办法能让客户端在**第4步**得到`公钥`的时候，**证明它是来自服务器而不是其他人的呢？** 答：有！解决办法就是1数字证书`! #### 数字证书 如果有人突然问你一个问题，`如何证明你是你自己呢？`，那当然是靠`身份证`了。对于网站也是一样，`如何证明你的网站是合法的而不是黑客假冒的呢？`，这个时候就要靠`数字证书`啦。 就如同`身份证`的有效性是由`国家权威机构`保证的一样，我们网站的`数字证书`也需要`权威的第三方机构`来保证书的有效性，这样的机构我们就称为`数字证书机构`（简称`CA机构`或`CA`）。 所以我们目前给自己网站申请使用 HTTPS 协议的时候，都需要去`CA`申请一份`数字证书`。**申请`数字证书`需要服务器提供申请者、域名、`公钥`等信息。** 有了`数字证书`，当浏览器向目标网站服务器发起请求时，服务器只需要把`数字证书`返回给浏览器就能证明自己的合法性了。但是还不够，如果`数字证书`在传输过程中被挟持并篡改了，还是会出现中间者问题。 ![数字证书1.jpg][15] #### 带数字签名的数字证书 为了解决上述问题，`CA机构`机智地使用了`数字签名`：`CA机构`在向网站颁布`数字证书`之前，会在`数字证书`上签名，这个签名相当于————来自**`CA机构的私钥`加密后的`数字证书摘要`**。而这个被加密后的`摘要`，在客户端浏览器中可以通过`CA机构的公钥`和`摘要算法`验证其有效性。如图： 1. 网站服务器用自己的`公钥1`向`CA机构`申请`数字证书`。 2. `CA机构`通过审核之后，会生成一份`证书明文`，其中就包括了`申请者的公钥`和其他信息。 3. `CA机构`利用一种`摘要算法`对`证书明文`进行摘要，得到`证书摘要`。 4. `CA机构`用自己的`私钥`加密`证书摘要`得到`数字签名` 5. `CA机构`将`数字签名`与`证书明文`合并，得到`数字证书`，并颁布给对应网站服务器。 6. 客户端向服务器请求得到`数字证书`后，自行在浏览器上验证。 ![数字证书2.jpg][16] 浏览器是怎么验证数字证书的呢？如图： 1. 浏览器拿到`数字证书`之后，将其拆分为`数字签名`与`证书明文`。 2. 用`CA机构公钥`解密`数字签名`，得到`证书摘要1`。 3. 用`CA机构同种摘要算法`处理`证书明文`，得到`证书摘要2`。 4. 比较以上两次操作得到的摘要，如果相同，则证明有效。反之则无效。 ![数字证书3.jpg][17] 而 Chrome 浏览器一旦发现证书无效或者证书过期，则会提示如下警告： ![数字证书4.jpg][18] 至此，HTTP 通信方有可能遭遇伪装的问题也就解决了！ ### 摘要算法解决传输数据被篡改问题 摘要算法又称哈希算法、散列算法。`摘要`也称哈希值，表示**输入任意长度的数据，都会输出固定长度的数据**。 摘要算法具有以下特点： 1. 压缩性：任意长度的数据，计算出的`摘要`**长度都是固定**的（如 MD5(32) 结果长度固定为128位）。 2. 容易计算：从原数据计算出`摘要`很容易，**消耗时间短**。 3. 抗修改性：对原数据进行任何改动，哪怕只修改1个字节，所得到的`摘要`都有很大区别。即：不同输入，**绝大概率**计算出不同的`摘要`。 4. 同源性：相同的输入，相同的摘要算法，**一定**会计算出相同的`摘要`。 5. 强抗碰撞：已知原数据和其`摘要`，想通过组合碰撞找到一个具有相同`摘要`的数据（即破解原数据）是非常困难的。 6. 不可逆性：无法根据`摘要`反推出原文。 现在常用的摘要算法有：**MD5、SHA1 、SHA256 、HAMC 、CMAC** 等。其中** MD5 和 SHA1** 都已经被破解了，要注意使用。 #### 在数据传输中加入摘要 在 HTTPS 的数据传输过程中，客户端会将`明文`与通过明文生成的`摘要1`一齐发送至服务器，服务器收到`明文`后，会用**与客户端相同的摘要算法**生成`摘要2`，比较以上两个`摘要`，就可以看出`明文`是否有被篡改过了。 上一句提到的“相同的摘要算法”是在通信双方 `TLS 握手`阶段商定的，我们之后会说到。 ![摘要算法1.jpg][19] #### 引入 HMAC 防止摘要也被篡改 通过上面的做法，我们大致可以防止明文被篡改了，但这样做还是存在一个问题：**如果黑客在篡改`明文`的同时，也篡改了`摘要`，这要怎么办呢？** ![摘要算法2.jpg][20] 这个时候，所以 HTTPS 还引入一种优化方案：**HMAC（消息认证码）**。定义 HMAC 需要一个散列函数(即 **MD5 SHA** 之类)和一个密钥（比如**一个随机数密钥**），也就是说此时摘要算法的**输入变成了两部分：`数据明文` + `随机数密钥` **。需要注意的一点是，这个`随机数密钥`只能有通信双方知道，黑客是不可能知道的，所以该`随机数密钥`应该在`商定阶段`被确定。如图：由于黑客不可能这个`随机数密钥`，篡改的`摘要3`也就不正确了。 ![摘要算法3.jpg][21] ### 小结，引出 SSL/TLS 握手 说了这么多，让我们来总结一下 HTTPS 需要用到算法/密钥总共有什么吧： 1. 传输数据，需要**对称加密算法**和`共享密钥`。 2. 传输`共享密钥`需要**非对称加密算法**。 3. 防止被伪装身份，需要**数字证书**。（数字证书中能取出`服务器公钥`） 4. 保证报文完整性，需要**摘要算法**。 5. 防止摘要被篡改，需要 HMAC 的**随机数密钥**。 有这么多东西**需要被商定**，这些工作都是靠谁完成的呢？诶，这些全都是由 `SSL/TLS` 完成的。那 `SSL/TLS` 又是怎么完成商定的呢？我们继续往下看。 ### SSL/TLS 发展进程 `SSL`（**Secure Sockets Layer**） 是网景公司（Netscape）设计的主要用于 Web 的安全传输协议，这种协议在 Web 上获得了广泛的应用。以下是摘取自权威文档部分发展历史： 1. 1996 年，SSL3.0 问世，得到大规模应用（已于 2015 年弃用）。 2. 1999 年，SSL3.1 被标准化，更新为 TLS1.0（**Transport Layer Security**）。所以有，TLS1.0 = SSL3.1。 3. 现在主流的版本是 TLS1.2（2008 年发布）。 4. 未来，可能是 TLS1.3 的时代（2018 年发布）。 > 所以在之后的篇章中，我统一用 `TLS` 称呼 `SSL/TLS` 协议和 `TLS1.2` 协议。 > 后文出现的`WireShark`抓包图片，需要下载`WireShark`抓包并进行环境变量配置。 ### TLS 握手 上文已经说过了，**HTTPS = HTTP + TLS**。 这里的 `TLS` 指的就是 `TLS 握手`，它类似于 **TCP 三次握手**。 在 `TLS 握手`的过程中，通信双方交换消息以相互**确认对方身份**，并确立它们所要使用的 **TLS 版本**、**密码套件**等等。 `TLS 四次握手` 发生在 `TCP 三次握手`之后，`HTTP 传输数据`之前。 ![四次握手.jpg][22] #### 第一次握手 ##### 1. Client Hello ![抓包1.jpg][23] Client Hello 是 `TLS 四次握手`的第一个步骤，表示客户端向服务端发起了一个 `TLS握手` 请求，本次报文内容主要包括： 1. **客户端支持的 TLS 版本**：客户端将自己所支持的`TLS`版本告知服务器，服务器做选择，如 `TLS 1.2`。 2. **客户端随机数**：客户端随机生成的一个数，后续用来生成`共享密钥`和`随机数密钥`。 3. **客户端支持的加密套件**（**Cipher suite**）：客户端将自己支持的**所有**加密套件做成**列表**告知服务器，服务器做选择，每个加密套件格式如下图： ![四次握手2.jpg][24] #### 第二次握手 ##### 2. Server Hello ![抓包2.jpg][25] 服务器接收到 `"Client Hello"` 后，也进入`TLS握手`流程，连着发送三个响应客户端的响应，第一个响应内容主要包括： 1. **服务器选择的 TLS 版本 **：服务器做出选择。 2. **服务器随机数**：服务器随机生成的一个数，后续用来生成`共享密钥`和`随机数密钥`。 3. **服务器选择的加密套件**：服务器选择**一个**加密套件。 ##### 3. Certificate ![抓包3.jpg][26] 服务器主持的`第二次握手`过程中，还需要将包含`服务器公钥`的`数字证书`也发给客户端。 ##### 4. Server Hello Done ![抓包4.jpg][27] 服务器发送此报文表示：自己已将所有预计商定的信息都发送完毕，`第二次握手`结束。 ##### 第二次握手补充: Server Key Exchange ![抓包5.jpg][28] 经提醒，如果服务器`数字证书`没有足够的信息，不能让客户端完成`pre-master`的密钥交换时，服务器会在 `"Server Hello"` 后，立即发送 `"Server Key Exchange"`来补充信息， 主要是面向安全的几种密钥协商算法，列表如下： 1. DHE_DSS 2. DHE_RSA 3. DH_anon 4. ECDHE_ECDSA 5. ECDHE_RSA 6. ECDH_anon #### 第三次握手（需先验证`数字证书`） 客户端收到服务器`第二次握手`的信息后，会验证其`数字证书`是否合法 ，如果证书合法，则进行`第三次握手`。 ##### 5. Client Key Exchange 证书验证通过之后，客户端会生成`pre-master`（前主密钥），不过会因不同的算法套件而有差异： 1. `ECDHE`会传递一个随机生成的`client params`。 2. `RSA`会随机生成`pre-master`，然后用`服务器公钥`加密后传递。 ![抓包6.jpg][29] 此时，客户端已经拥有：`客户端随机数`，`服务器随机数`，`pre-master`或`client params`，客户端将用此三者结合生成`master secret`（主密钥），`master secret`会被用来生成`共享密钥`和`随机数密钥`。 ##### 6. Change Cipher Spec 通知服务器，后续客户端发送的消息都将采用对称加密通信。 ##### 7. Finished 客户端会将此前生成的`共享密钥`和`随机数密钥`用在此处加密报文，送给服务器解读。如果服务器能解读，说明此前的商定阶段成功完成。 至此，`第三次握手`结束。 ![抓包7.jpg][30] #### 第四次握手 在`第四次握手`之前，服务器需要从之前第5步：`"Client Key Exchange"`中，提取出来`client params`或用`服务器私钥`解密得到`pre-master`。 此时，服务器也拥有：`客户端随机数`，`服务器随机数`，`pre-master`或`client params`。那它也能将三者结合生成**和客户端一模一样**的`master secret`，再用它生成`共享密钥`和`随机数密钥`。 然后服务器进入`第四次握手`流程，它也会执行和客户端`第三次握手`时一模一样的操作，将加密报文送给客户端解读。如果客户端能解读，说明 `TLS 四次握手` 全阶段完成。 ##### 8. Change Cipher Spec 与第6步一致。 ##### 9. Finished 与第7步一致。 ### TLS握手结束 至此 `TLS 握手`过程圆满结束！如果还看不明白的同学，建议重看上面每一步的解释并结合着这张图慢慢理解，相信你们可以的。其实不难看出，`TLS 握手`的整个流程就是为了： 1. 确定所使用的TLS版本，加密算法，摘要算法。 2. 确保通信双方能**安全**地生成一模一样的`共享密钥`和`随机数密钥`。 ### 总结： 写这篇文章的主要原因是，最近在看书时发现自己以前整理的 HTTPS 笔记真的非常片面，所以才诞生了系统地重新整理一遍 HTTPS知识的念头。当然，本篇文章的内容也仅够科普使用，如果想要更深入了解 HTTPS ，可以去详细读读[《HTTPS权威指南》][31]。 仅当抛砖引玉，有错误请指出！有帮助请不要吝啬你们的**点赞和收藏**~ [1]: https://book.douban.com/subject/25863515/ [2]: https://excalidraw.com/ [3]: http://120.25.166.245/usr/uploads/2022/HTTPS/diff_1.jpg [4]: http://120.25.166.245/usr/uploads/2022/HTTPS/diff_2.jpg [5]: http://120.25.166.245/usr/uploads/2022/HTTPS/encryption_algorithm_1.jpg [6]: http://120.25.166.245/usr/uploads/2022/HTTPS/caesar_encryption_1.jpg [7]: http://120.25.166.245/usr/uploads/2022/HTTPS/caesar_encryption_2.jpg [8]: http://120.25.166.245/usr/uploads/2022/HTTPS/symmetric_encryption_disadv.jpg [9]: http://120.25.166.245/usr/uploads/2022/HTTPS/asymmetric_encryption_1.jpg [10]: http://120.25.166.245/usr/uploads/2022/HTTPS/asymmetric_encryption_2.jpg [11]: http://120.25.166.245/usr/uploads/2022/HTTPS/hybrid_encryption_1.jpg [12]: http://120.25.166.245/usr/uploads/2022/HTTPS/hybrid_encryption_2.jpg [13]: http://120.25.166.245/usr/uploads/2022/HTTPS/MITM_1.jpg [14]: http://120.25.166.245/usr/uploads/2022/HTTPS/MITM_2.jpg [15]: http://120.25.166.245/usr/uploads/2022/HTTPS/digital_certificate_1.jpg [16]: http://120.25.166.245/usr/uploads/2022/HTTPS/digital_certificate_2.jpg [17]: http://120.25.166.245/usr/uploads/2022/HTTPS/digital_certificate_3.jpg [18]: http://120.25.166.245/usr/uploads/2022/HTTPS/digital_certificate_4.jpg [19]: http://120.25.166.245/usr/uploads/2022/HTTPS/digest_algorithm_1.jpg [20]: http://120.25.166.245/usr/uploads/2022/HTTPS/digest_algorithm_2.jpg [21]: http://120.25.166.245/usr/uploads/2022/HTTPS/digest_algorithm_3.jpg [22]: http://120.25.166.245/usr/uploads/2022/HTTPS/four_handshake_1.jpg [23]: http://120.25.166.245/usr/uploads/2022/HTTPS/package_capture_1.jpg [24]: http://120.25.166.245/usr/uploads/2022/HTTPS/four_handshake_2.jpg [25]: http://120.25.166.245/usr/uploads/2022/HTTPS/package_capture_2.jpg [26]: http://120.25.166.245/usr/uploads/2022/HTTPS/package_capture_3.jpg [27]: http://120.25.166.245/usr/uploads/2022/HTTPS/package_capture_4.jpg [28]: http://120.25.166.245/usr/uploads/2022/HTTPS/package_capture_5.jpg [29]: http://120.25.166.245/usr/uploads/2022/HTTPS/package_capture_6.jpg [30]: http://120.25.166.245/usr/uploads/2022/HTTPS/package_capture_7.jpg [31]: https://book.douban.com/subject/26869219/
本文出现的所有代码都可以从[我的git地址][1]中找到，如果对你有帮助，还请点点`star`，欢迎提出您宝贵的意见。 你也可以在此[狠狠地尝试Demo][2]（记得打开谷歌开发者工具查看NetWork） ### 背景 先前已经在我的另一篇文章：[浏览器缓存策略详解][3]中提到了`Service Worker`，其实它是一个特别大的概念，我们今天就来稍微深入地学习一下他的`cacheStorage`缓存功能。相信大家都听说过PWA（Progressive Web APP）——渐进式网页；致力于实现与原生 APP 相似的交互体验。PWA总体具有以下特点：（以下特点来自[知乎][4]） - **渐进式**：适用于选用任何浏览器的所有用户，因为它是以渐进式增强作为核心宗旨来开发的。 - **自适应**：适合**任何机型**：桌面设备、移动设备、平板电脑或任何未来设备。 - **连接无关性**：能够借助于服务工作线程在**离线**或低质量网络状况下工作。 - **离线推送**：使用推送消息通知，能够让我们的应用像`Native App`一样，提升用户体验。 - **及时更新**：在服务工作线程更新进程的作用下时刻保持最新状态。 - **安全性**：通过 HTTPS 提供，以防止窥探和确保内容不被篡改。 总结下来，PWA的实现其实主要依赖于以下三点： 1. `manifest` 实现手机主界面的`web app`图标、添加进桌面、标题、`icon`等； 2. `Service Worker`实现离线缓存请求、更新缓存、删除缓存；（用插件实现文件更新即版本号更新从而缓存更新） 3. 前端`registration`实现用户订阅，后端`web-push`实现消息推送，前端`Service Woker`监听`push`实现消息通知，但是Chrome需要能够连接到外网，因为push用的是谷歌的云服务。 这里就主要谈谈如何使用`Service Worker`实现离线缓存： ### 什么是Service Worker? 众所周知， js 是被设计为单线程语言，因为主要用途是与用户互动和操作`DOM`，单线程设计可以简化并发问题，避免多线程并发时的竞态条件、死锁和其他问题。但单线程存在的问题是，**GUI线程**和**js线程**需要抢占资源，在 js 执行比较耗时的逻辑时，容易造成页面假死，用户体验较差。后来`html5`开放了`Web Worker`可以在浏览器后台挂载新线程。它无法直接操作`DOM`，无法访问`window`、`document`等对象。而`Service Worker`可以说是`Web Worker`进一步发展后的产物。**`SW`也是运行在浏览器背后的独立线程，**主要用于代理网页请求，可缓存请求结果；可实现离线缓存功能；可跨页面通信。也拥有单独的作用域范围和运行环境 ![浏览器进程.jpg][5] ### Service Worker的特点 在`Service Worker`诞生之前，`Web Worker`就已经“服役”很久了，他们都是独立于js线程外的线程，但是`Web Worker`有个特点就是：当网页关闭时，`Web Worker`就失效了，而`Service Worker`的诞生就是为了解决这个问题的，它具有以下特点： 1. 一旦被`install`，就永远存在，除非被手动`unregister`。 2. 拥有自己独立的**worker线程**，独立于当前网页进程，有自己独立的**worker上下文**(context)。 3. 用到的时候就可以直接唤醒，不用的时候自动睡眠。 4. 可拦截代理`fetch`请求和响应，不支持`xmlHttpRequest`请求。 5. 可操作缓存文件，且缓存文件可以被网页进程取到（包括网路离线状态）。 6. 能向客户推送消息。 7. 不能直接操作`DOM`、`window`、`parent`等 。（但是它有自己的**`self`**对象来代替`window`） 8. 必须在`HTTPS`环境下才能工作。(本地调试可以用`localhost`) 9. 异步实现，内部大都是通过`Promise`实现，以防止浏览器卡顿。**所以`Service Worker`的各类操作都被设计为异步**，我们在调用的时候要**使用`Promise`语法**。 ### Service Worker的生命周期 当我们注册了`Service Worker`后，它会经历生命周期的各个阶段，同时会触发相应的事件。整个生命周期包括了：`installing` --> `installed` --> `activating` --> `activated` --> `redundant`。当`Service Worker`**installed**完毕后，会触发**`install`**事件；而**activated**完毕后，则会触发**`activate`**事件。 ![生命周期.jpg][6] `Service Worker`同时提供了事件监听函数对这些状态进行捕获，例如： ``` self.addEventListener('install', function(event) { /* 安装后... */ }); self.addEventListener('activate', function(event) { /* 激活后... */ }); self.addEventListener('fetch', function(event) { /* 请求后... */ }); //用来响应和拦截各种请求。 ``` 基本上，`Service Worker`的所有应用都是基于上面3个事件的，例如，我们接下来的实战内容。`install`用来缓存文件，`activate`用来缓存更新，`fetch`用来拦截请求直接返回缓存数据。三者齐心，构成了完成的离线缓存控制结构。 ### 实战1. 创建项目， 项目目录结构如下： ``` ├── README.md ├── app.html ├── sw.js ├── src │ └── index.js ├── assets │ └── css │ └── style.css │ └── images │ └── background1.jpg │ └── background2.jpg ``` 先在`app.html`中引入图片资源，`index.js`和`style.css`并在`html`中随便写点内容： ``` HTTPS - Learn SW OffLine 图片1： 图片2： Service Worker注册： ``` ### 实战2. 注册`Service Worker` 如果注册成功，`Service Worker`就会被下载到客户端并尝试安装或激活，这将作用于整个域内用户可访问的URL，或者其特定子集。这里我们将sw.js文件注册为一个Service Worker，注意文件的路径不要写错了。 ``` navigator.serviceWorker.register(url, options); //url:service worker文件的路径，路径是相对于 Origin ，而不是当前文件的目录的 //options: scope:表示定义service worker注册范围的URL; //默认值是基于当前的location（./），并以此来解析传入的路径。 //假设你的sw文件放在根目录下位于/src/sw.js路径的话，那么你的sw就只能监听/src/*下面的请求。 //如果想要监听所有请求有两个办法，一个是将sw.js放在根目录下，或者是在注册是时候设置scope。 ``` ``` // index.js window.addEventListener("load", () => { if (navigator.serviceWorker) { navigator.serviceWorker // scope是自定义sw的作用域范围为根目录，默认作用域为当前sw.js所在目录的页面 .register("./sw.js", { scope: "./" }) .then(function (registration) { // 注册成功后会返回registration对象，指代当前服务线程实例 document.getElementById("register").innerHTML = "成功!"; }) .catch(function (err) { console.error(e); document.getElementById("register").innerHTML = "失败!"; }); } else { console.log("当前浏览器不支持service worker"); } }); ``` ### 实战3. 缓存静态资源 注册完`Service Worker`后，下一步就是把我们需要缓存的文件缓存下来。我们**需要添加事件监听**，来在合适的时机触发`Service Worker`的相应操作。现在，要使我们的Web离线可用，就需要将所需资源缓存下来。我们需要一个资源列表，当`Service Worker`被激活时，会将该列表内的资源缓存进`cache`。 ``` //sw.js // 定义缓存空间名称 const CACHE_NAME = "tuland-1"; //修改此值可以强制更新缓存 // 定义需要缓存的文件目录 const FILE_TO_CACHE= [ "./app.html", "./src/index.js", "./assets/css/style.css", "./assets/images/background1.jpg", "./assets/images/background2.jpg", ]; // 监听install事件，回调中缓存所需文件 self.addEventListener("install", (e) => { console.log("Service Worker 状态： instal"); e.waitUntil( // cacheStorage API 可直接用caches来替代 // open方法创建/打开缓存空间，并会返回promise实例 // then来接收返回的cache对象索引 caches.open(CACHE_NAME).then(function (cache) { // cache对象addAll方法解析（同fetch）并缓存所有的文件 return cache.addAll(FILE_TO_CACHE); }) ); }); ``` 可以看到，首先在`FILE_TO_CACHE`中我们列出了所有的静态资源依赖。当`Service Worker install`时，我们就会通过`caches.open()`与`cache.addAll()`方法将资源缓存起来。`open(CACHE_NAME)`这里`CACHE_NAME`会成为这些缓存的key值。 上面这段代码中，caches是一个全局变量，通过它我们操作的其实是`CacheStorage`相关接口。[CacheStorage MDN文档][7] ### 实战4：使用缓存的静态资源 到目前为止，我们仅仅是注册了一个`Service Worker`，并在其`install`时缓存了一些静态资源，但我们还没有使用这些缓存下来的资源。那么要如何才能使用呢？答案是拦截`fetch`： 1. 浏览器发起请求，请求各类静态资源（`html`/`js`/`css`/`img`）。 2. `Service Worker`拦截浏览器请求，并查询当前cache。 3. 若存在`cache`则直接返回，结束。 4. 若不存在`cache`，则通过`fetch`方法向服务端发起请求，并返回请求结果给浏览器。 ``` // 拦截所有请求事件 // 如果缓存中已经有数据就直接用缓存，否则去请求数据 self.addEventListener("fetch", (e) => { console.log("处理fetch事件:", e.request.url); e.respondWith( caches .match(e.request) .then(function (response) { if (response) { console.log("缓存匹配到res:", response.url); return response; } console.log("缓存未匹配对应request,准备从network获取", caches); return fetch(e.request); }) .catch((err) => { console.error(err); return fetch(e.request); }) ); }); ``` fetch事件会监听所有浏览器的请求。`e.respondWith()`方法接受Promise作为参数，通过它让`Service Worker`向浏览器返回数据。`caches.match(e.request)`则可以查看当前的请求是否有一份本地缓存：**如果有缓存，则直接向浏览器返回`cache`**；否则`Service Worker`会向后端服务发起一个`fetch(e.request)`的请求，并将请求结果返回给浏览器。 到目前为止，运行我们的`demo`： 1. 当一次打开网页时，所依赖的静态资源就会被缓存在本地； 2. 刷新浏览器，在`network`选项中可以看到缓存内容的请求已经被拦截了，从sw缓存中获取了。 3. 在`chrome`控制台中，把`network`状态改成`offline`，再次刷新浏览器，虽然没网，但是还是可以从本地缓存读取内容。 第一次进入页面和第二次进入页面，如图： ![NetWork1.jpg][8] ![NetWork2.jpg][9] 普通情况离线加载和使用`Service Worker`后离线加载，如图： ![NetWork3.jpg][10] ![NetWork4.jpg][11] ### 实战5：更新静态缓存资源 #### 更新sw.js 然而，一旦我们将资源缓存后，除非注销（**unregister**）`Service Worker`或者手动清除缓存，否则新的静态资源将无法缓存。在仅有上述代码的情况下，我们修改`sw.js`，我们会发现，在上个`Service Worker`的有效时长内：**浏览器用的永远是上一次缓存下来的sw.js**。 解决这个问题的一个简单方法就是修改`CACHE_NAME`。由于浏览器判断`sw.js`是否更新是通过字节方式，因此修改`CACHE_NAME`会重新触发`install`并缓存资源。此外，在`activate`事件中，我们需要检查`CACHE_NAME`是否变化，如果变化则表示有了新的缓存资源，原有缓存需要删除。 ``` //sw.js const CACHE_NAME = "tuland-2"; //修改此值可以强制更新缓存 ... this.addEventListener("install", (event) => { this.skipWaiting();// 强制更新sw.js ... }) // 监听active事件 self.addEventListener("activate", (event) => { // 获取所有的缓存key值，将需要被缓存的路径加载放到缓存空间下 const cacheDeletePromise = caches.keys().then((keyList) => { console.log("keyList:", keyList); Promise.all( keyList.map((key) => { if (key !== CACHE_NAME) { const deletePromise = caches.delete(key); return deletePromise; } else { Promise.resolve(); } }) ); }); // 等待所有的缓存都被清除后，直接启动新的缓存机制 event.waitUntil( Promise.all([cacheDeletePromise]).then((res) => { this.clients.claim(); }) ); }); ``` 我们在这里添加`skipWaiting()`，同时把缓存`CACHE_NAME`改为`tuland-2`，再次刷新浏览器。查看控制台`log`，新的Service Worker安装完成之后立即被激活了，我们也可以看到`activate`事件在更新我们的缓存文件。 ![NetWork8.jpg][12] #### 更新app.html 在上面的流程中，我们使用`skipWaiting`完成了`sw.js`的更新，当**下一次**用户访问Web时候，则直接获取并使用新的缓存。但这时还存在着一个很重要的问题：用户**这一次**访问的Web网页，还是上一次缓存中的Web网页！ 我们此时修改`app.html` ``` //app.html添加 我更新啦！ ``` 在更改`sw.js`中的`CACHE_NAME`为`tuland-3` ``` //sw.js const CACHE_NAME = "tuland-3"; //修改此值可以强制更新缓存 可以用版本控制工具自动更新 ``` 刷新浏览器，新的`Service Worker`已经激活了，可是页面上还是之前的内容，**再次刷新才能出现“我更新啦！”**。 ![NetWork9.jpg][13] ![NetWork10.jpg][14] #### 解决方案： 这边会有很多解决方案，我在`demo`里使用的是让`主线程`和`Service Worker`互相通信，实现弹窗来通知用户刷新页面： ``` //app.html ... 检查到网页存在更新，请立即刷新 确定 ``` ``` //index.js navigator.serviceWorker.onmessage = function (event) { var data = event.data; if (data.command == "reload") { console.log(data); const myDialog = document.querySelector("#myDialog"); myDialog.showModal(); } }; ``` ``` //sw.js self.addEventListener("activate", (event) => { // 获取所有的缓存key值，将需要被缓存的路径加载放到缓存空间下 const cacheDeletePromise = caches.keys().then((keyList) => { console.log("keyList:", keyList); Promise.all( keyList.map((key) => { if (key !== CACHE_NAME) { const deletePromise = caches.delete(key); //TODO:告诉用户需要重新刷新 console.log("need reload"); self.clients.matchAll().then(function (clients) { clients.forEach(function (client) { client.postMessage({ command: "reload", message: "blablablablabla", }); }); }); return deletePromise; } else { Promise.resolve(); } }) ); }); // 等待所有的缓存都被清除后，直接启动新的缓存机制 event.waitUntil( Promise.all([cacheDeletePromise]).then((res) => { this.clients.claim(); }) ); }); ``` ![NetWork11.jpg][15] 这种方法虽然可行，但是通知用户刷新浏览器结果并不可控。而如果直接刷新页面，又显得太暴力，从而让用户体验非常差。知乎的这位老哥也遇到了诸如此类的问题，它最终是选择了[拦截fetch并比较url标识的方法][16] 但我们在经过搜查资料和组内讨论后，最终得出了一个方案：**在`Service Worker`控制的页面中，优先使用在线资源，`Service Worker`充当面向客户端的代理服务器角色；当在线资源获取出错（服务器宕机，网络不可用等情况），则使用``Service Worker``本地缓存。** 私以为这是比较可靠的。 ### 总结： 本文出现的所有代码都可以从[我的git地址][17]中找到，如果对你有帮助，还请点点`star`，欢迎提出您宝贵的意见。 截止到目前，就算浅浅完成了一个可以实现离线缓存的`demo`了，但要当成PWA上线，还需要非常多的工作，比如每次都修改`CACHE_NAME`是不现实的，我们要结合`webpack`实现自动打包生成资源号；比如我们还有CDN，还有推送消息功能，甚至包括`Service Worker`本身，我们可以挖掘的东西也还有很多，这些都留着下次再细细讲吧。 [1]: https://github.com/232295311/learn-sw [2]: https://232295311.github.io/learn-sw/app.html [3]: http://120.25.166.245/index.php/archives/6/ [4]: https://zhuanlan.zhihu.com/p/144512343 [5]: http://120.25.166.245/usr/uploads/2022/05/1898033692.jpg [6]: http://120.25.166.245/usr/uploads/2022/05/1891590781.jpg [7]: https://developer.mozilla.org/zh-CN/docs/Web/API/CacheStorage/keys [8]: http://120.25.166.245/usr/uploads/2022/05/2755679102.jpg [9]: http://120.25.166.245/usr/uploads/2022/05/2657161385.jpg [10]: http://120.25.166.245/usr/uploads/2022/05/412486908.jpg [11]: http://120.25.166.245/usr/uploads/2022/05/169326627.jpg [12]: http://120.25.166.245/usr/uploads/2024/03/1641758470.jpg [13]: http://120.25.166.245/usr/uploads/2024/03/3939313176.jpg [14]: http://120.25.166.245/usr/uploads/2024/03/3200581011.jpg [15]: http://120.25.166.245/usr/uploads/2024/03/4270045727.jpg [16]: https://zhuanlan.zhihu.com/p/680932147 [17]: https://github.com/232295311/learn-sw
# 浏览器知识系列之本地存储 ### 什么是“本地存储”？ 浏览器本地存储是一种机制，允许Web应用程序在浏览器端**存储数据**，以便用户在下次访问时可以快速获取和使用这些数据。 ### 浏览器本地存储的种类 浏览器的本地存储主要分为： 1. **Cookie** 2. **WebStorage** ——分为`localStorage`和`sessionStorage` 3. **IndexedDB** ### Cookie 其实从起源上来说，`Cookie`的诞生是为了解决`HTTP`是**无状态协议**这个问题的，最开始用`HTTP`通信，由于没法记录状态，服务器不能清楚地知道客户端是谁，也就无法帮他省下复杂操作（如多次登陆），`Cookie`就是用来解决这个问题的。 #### Cookie的构成： `Cookie`本质上只是一小段文本字符串，该字符串一般保存在用户计算机特定文件夹下的某个文件中，它的内部格式是键值对。其中又有一些比较重要的属性： 1. **name=value**：键值对，可以设置要保存的Key/Value。 2. **expires和max-age**：`Cookie`的过期时间，如果这个属性不指定的话，就是`session cookie`，也就是这个`session`在关闭浏览器后就会被删除。 1. `expires`是一个`GMT`格式的时间串。 2. `max-age`在`HTTP/1.1`中代替`expires`，是以**秒为单位**的时间段。`Cookie`失效时刻=创建时刻+`max-age` 3. **path**：相当于`Cookie`的作用域，子文件夹下的网页可以访问父文件夹下的网页生成的`Cookie`，但是反过来却不能，例如： 1. 路径`/`：生成`cookie_a` 2. 路径`/sub`生成`cookie_b` 3. 则`/`路径只能读取`cookie_a`，而`/sub`路径可以读取`cookie_a`和`cookie_b`。 注意，如果不设置这个属性的话，默认使用的是当前的`url`的相对路径。一般我们设置`Cookie`，就是让网站中所有其他的网页都能读取到的，所以应该将其设置为`'/'`。 4. **domain**：`Cookie`的所属域名，默认是全域名，也可以自己指定根域名。例如在`www.tuland.com`上设置`Cookie`，`domin`就是`tuland.com`，这样的话如果当前网站旗下有子域名网站的话，例如`a.tuland.com`，`b.tuland.com`之类的子域名，那么在子域名所在网站中也能读取到这个`Cookie`。 5. **secure**：这个属性是针对`HTTPS`来说的，如果设定其为`true`的话，那么只有在`HTTPS`协议下才会携带`Cookie`。默认为空。 6. **httponly**：这个属性设置`js`对于当前`cookie`的读取权限，如果为`true`，则`js`无法**读取、修改、删除**当前`Cookie`。默认为空 ![Cookie1.jpg][1] #### Cookie的使用和设置 可以分为三个步骤： 1. **服务器发送`Cookie`**：当客户端第一次访问服务器时，服务器会通过**响应头**要求客户端设置`Cookie`。例如：`**Set-Cookie**:name=tuland; Path=/` 2. **客户端接收并保存`Cookie`**：客户端接收到`Cookie`后，会将其保存在本地。 3. **客户端再次请求时携带`Cookie`**：当客户端再次请求服务器时，会将之前保存的`Cookie`发送到服务器端，例如 `Cookie:name=tuland;`。此外，客户端JS脚本也可以通过`document.cooke = "name='tuland'"`等方式来设置`Cookie`。 #### Cookie的一些缺点 1. 存储大小只有**4kb**。 2. `HTTP`请求时会携带`Cookie`，会增加请求的数据量。 3. 只能用`document.cooke = ...`方式修改，太过简陋。 4. 不同的浏览器，`Cookie`有不同的上限。（`Chrome`没有） #### Cookie的安全性 虽然`Cookie`在WEB应用中非常有用，但是我们在使用过程中也需要注意一些安全问题： 1. `Cookie`可能被客户端篡改，所以在使用前我们需要验证其合法性。 2. 不要在`Cookie`中存储敏感数据，比如用户密码，账户余额。 3. 使用`httpOnly`属性可以防止通过`JS`脚本读取到`Cookie`信息，从而防止`XSS`攻击。 4. 尽量减少`Cookie`的体积，这样可以减少数据传输的时间和带宽消耗。 #### Cookie和Seesion的区别 1. `Cookie`存在客户端上；`Session`会话存在服务器上。 2. `Cookie`有预先设置的生存周期或永久保存于本地；`Session`在页面关闭时就消失了。 3. 单个`Cookie`不超过`4KB`，只能存键值对；`Session`对象没有对存储数据量的限制，其中可以保存更为复杂的数据类型。 4. 设置`Cookie`属性可以使`Cookie`过期；但是使用`session-destory()`，将销毁会话。 5. `cookie`不是很安全，但可以加密；`session`比较安全，但容易失效。 ### localStorage `localStorage`有一点跟`Cookie`一样，就是针对一个域名，即在同一个域名下，会存储相同的一段`localStorage`。 不过它相对`Cookie`还是有相当多的区别的: 1. **容量**：`localStorage`的容量上限为`5MB`，相比于`Cookie`的`4KB`大大增加。当然这个`5MB`是针对一个域名的，因此对于一个域名是持久存储的。 2. 只存在客户端，**默认不参与**与服务端的通信。这样就很好地避免了`Cookie`带来的性能问题和安全问题。 3. 接口封装简单易用。通过它的`setItem`、`getItem`、`removeItem`、`clear`等方法进行操作，非常方便。 #### 操作方式 接下来我们来具体看看如何来操作localStorage。 ``` let obj = { name: "tuland", age: 18 }; localStorage.setItem("name", "tuland"); localStorage.setItem("info", JSON.stringify(obj)); ``` 接着进入**相同的域名**或符合`path`规则的子域名下就能拿到相应的值: ``` let name = localStorage.getItem("name"); let info = JSON.parse(localStorage.getItem("info")); ``` 从这里可以看出，localStorage其实存储的都是字符串，如果是存储对象需要调用JSON的stringify方法，并且用JSON.parse来解析成对象。 应用场景 利用localStorage的较大容量和持久特性，可以利用localStorage存储一些内容稳定的资源，比如官网的logo，存储Base64格式的图片资源，因此利用localStorage ### sessionStorage `sessionStorage`和`localStorage`非常相似，存储空间和API都一模一样，但只有一个本质的区别，**那就是前者只是会话级别的存储，并不是持久化存储。**会话结束，也就是页面关闭，这部分`sessionStorage`就不复存在了。 ### WebStorage 和 Cookie的区别 | 特性 | Cookie | localStorage | sessionStorage | | ---- | ---- |---- | ---- | | 生命周期| 由服务器生成，可设置失效时间。默认是关闭服务器后失效 |除非被清除，否则永久保存 |仅在当前会话有效，关闭标签页或浏览器后失效 | | 存储空间大小 | 4KB |5MB |5MB | | 与服务端通信 | 每次都会携带在http请求头中 |仅在客户端中保存，默认不参与通信 | 同左 | | 共享规则 | 在同源且符合path规则下的域名间共享 |在同源文档间共享，即协议、域名、端口相同，不同子域名可以用同一个父域名的端口 |受同源策略影响，还需要同个窗口 | | 用途 | 用于标识用户身份 |用于缓存数据，保存用户习惯信息，如：最近的搜索项、最近访问页面 |表单信息进行维护，保证页面即使刷新也不会让之前的表单信息丢失 | 相同点 ：三者都可以用来在浏览器端存储数据，而且都是字符串类型的**键值对**形式 ### IndexedDB `IndexedDB`是运行在浏览器中的非关系型数据库, 本质上是数据库，绝不是和刚才WebStorage的 5M 一个量级，理论上这个容量是没有上限的。 关于它的使用， MDN 上的[教程文档][2]已经非常详尽。 接着我们来分析一下`IndexedDB`的一些重要特性，除了拥有数据库本身的特性，比如**支持事务**，**存储二进制数据**，还有这样一些特性需要格外注意： 1. 键值对存储。内部采用**对象仓库**存放数据，在这个对象仓库中数据采用**键值对**的方式来存储。 2. 异步操作。数据库的读写属于 I/O 操作, 浏览器中对异步 I/O 提供了支持。 3. 受同源策略限制，即无法访问跨域的数据库。 [1]: http://120.25.166.245/usr/uploads/2022/03/919913199.jpg [2]: https://developer.mozilla.org/zh-CN/docs/Web/API/IndexedDB_API/Using_IndexedDB
![缓存头图.jpg][1] # 浏览器知识系列之浏览器缓存 ### 什么是缓存? 浏览器缓存是一种用于提高网站性能的技术，它通过在**本地磁盘，内存或缓存服务器**中存储网站的HTML、CSS、JavaScript、图片等文件副本，以便在用户再次访问相同的URL时，浏览器可以直接从本地或缓存服务器中加载这些资源，而**无需重新从服务器下载**。这样做可以减少网络带宽消耗，降低服务器的负担，并提升用户体验。 ### 说得这么玄乎，我要怎么看到缓存呢？ 查看缓存的办法很简单，以`Chrome`浏览器为例子，打开开发者工具，进入`NetWork`页面，在`Size`这一栏我们就可以看到这样的资源加载情况： ![缓存1.jpg][2] 如果`size`是`memory cache`和`disk cache`就表示：浏览器并没有向服务器发送请求，而是直接读取了本地的缓存资源文件。至于什么是`memery cache`什么是`disk cache`，我们暂且按下不表，之后在介绍缓存位置的时候会介绍到。 ### 从3个方面入手 现在我们知道了，缓存，作为一个能极大提高用户浏览体验的存在，在我们上网的过程中几乎无时无刻在发生着作用。那么，理解浏览器的缓存机制对于开发同学来说就是非常重要的知识点，这篇文章将会从以下三个方面入手介绍浏览器缓存，以便大家都能构建出自己的一份浏览器缓存体系： 1. 强缓存 2. 协商缓存 3. 缓存位置 ### 1.强缓存 强缓存是一种浏览器缓存的策略，它指的是：**如果缓存资源有效，则直接使用缓存资源，不必再向服务器发起请求。** #### 设置强缓存的两种方式： ##### HTTP/1.0使用Expires `Expires`即过期时间，存在于服务端返回的**HTTP请求响应头**中，告诉浏览器在这个过期时间之前可以直接从缓存里面获取数据，无需再次请求。比如下面这样: `Expires: Mon, 04 Mar 2022 04:01:32 GMT` 表示资源在2012年03月04号4点01分过期，过期了就得向服务端发请求。这个方式看上去没什么问题，其实潜藏了一个问题：这个时间是一个**绝对时间**，它是服务器的时间，如果**客户端的时间和服务器端的时间不一致**，或者**用户修改了客户端的时间**，这样就会影响缓存命中的效果。针对这个问题，在HTTP/1.1中，浏览器有了新的解决方案。 ##### HTTP/1.1使用Cache-Control 在HTTP1.1中，新增了一个非常关键的字段：`Cache-Control`。这个字段也是存在于**HTTP请求响应头**中，它和`Expires`本质的不同在于它并没有采用具体的过期时间点这个方式，而是采用过期时长来控制缓存，对应的字段是`max-age`。比如这个例子：`Cache-Control:max-age=3600` 代表这个响应返回后在 3600 秒，也就是一个小时之内可以直接使用缓存。而且不止于此，`Cache-Control`还可以搭配其他不同属性来控制更多更复杂的场景下的缓存，比如： 1. **`public`：客户端和代理服务器都可以缓存**。因为一个请求可能要经过不同的**代理服务器**最后才到达目标服务器，那么结果就是不仅仅浏览器可以缓存数据，中间的任何代理节点都可以进行缓存。 2. `private`：表示响应资源**仅仅只能被获取它的浏览器端**缓存。它不允许任何中间者缓存响应的资源。 3. `no-cache`: 跳过当前的强缓存，发送HTTP请求，即直接进入**协商缓存**阶段。 4. `no-store`：非常粗暴，不进行任何形式的缓存。**(注意这里要和`no-cache`区分)** 5. `s-maxage`：与上文提到的max-age类似，这里的“s”代表共享，并且，这个指令一般仅用于控制代理服务器或CDN之类的中间者的缓存。 6. `no-transform`:中间代理有时会改变图片以及文件的格式，从而达到提高性能的效果。`no-transform`指令告诉中间代理不要改变资源的格式。 值得注意的是，当`Expires`和`Cache-Control`同时存在的时候，`Cache-Control`会被优先考虑。 ![缓存2.jpg][3] 那么，假如我们已经不在强缓存要求的缓存时间里了，也就是说**强缓存失效了**，接下来怎么办呢？别急，浏览器还有第二道缓存策略————协商缓存。 ### 协商缓存 协商缓存就是强制缓存失效后，浏览器携带缓存标识**(Tag)**向服务器发起请求，由服务器根据缓存标识**(Tag)**决定是否使用缓存的过程。 #### 设置协商缓存的两种方式： ##### HTTP/1.0使用Last-Modified和If-Modified-Since `Last-Modified`即最后修改时间，在浏览器请求了可缓存的资源后，服务器会在**响应头**中加入`​​Last-Modified​​`字段来指出资源最后一次修改的时间。 浏览器接收到后，如果再次请求，会在请求头中携带`If-Modified-Since`字段，这个字段的值也就是服务器上一次传来的`Last-Modified`的值。 当请求发送到服务器后，服务器会使用这个字段来**和资源的最后一次的修改时间来进行比较**，以此来判断资源是否做了修改： - 如果资源没有被修改，那么返回`304`状态码，让客户端使用本地的缓存。 - 如果资源已经被修改了，则返回修改后的资源。 ##### HTTP/1.1使用Etag和If-None-Match `ETag`是服务器根据当前文件的内容，给文件生成的**唯一标识**，只要里面的内容有改动，这个值就会变。和`Last-Modified`一样，服务器通过**响应头**字段把这个值给浏览器。 浏览器接收到后，如果再次请求，会在请求头中携带`If-None-Match`字段，这个字段的值也就是服务器上一次传来的`ETag`的值。 当请求发送到服务器后，服务器会使用这个字段来**和资源的最新的`ETag`进行比较**，以此来判断资源是否做了修改： - 如果资源没有被修改，那么返回`304`状态，让客户端使用本地的缓存。 - 如果资源已经被修改了，则返回修改后的资源。 ![缓存3.jpg][4] ##### 两者对比 1. 在**精准度**上，`ETag`优于`Last-Modified`。由于`ETag`是按照内容给资源上标识，因此能准确感知资源的变化。而`Last-Modified`就不一样了，它在一些特殊的情况并不能准确感知资源变化，主要有两种情况: 1. 编辑了资源文件，但是文件内容并没有发生改变，这样也会造成缓存失效。 2. `Last-Modified`能够感知的单位时间是秒，如果文件在 1 秒内改变了多次，那么这时候的` Last-Modified`就不能体现出修改了。 2. 在性能上，`Last-Modified`优于`ETag`，也很简单理解，`Last-Modified`仅仅只是记录一个时间点，而`Etag`需要根据文件的具体内容生成哈希值。 另外，如果两种方式都支持的话，服务器会优先考虑`ETag`。 ### 缓存位置 前面我们已经提到，当**强缓存命中**或者**协商缓存中服务器返回304**的时候，我们直接从缓存中获取资源。那这些资源究竟缓存在什么位置呢？ 浏览器中的缓存位置一共有四种，按优先级从高到低排列分别是： 1. Service Worker 2. Memory Cache 3. Disk Cache 4. Push Cache 5. 【新增】prefetch cache 通过``标签预加载获得的内容，会出现在这个缓存位置里。可以在wbepack里异步引入+魔法注释配置。 #### Service Worker `Service Worker`是`Web Worker`进一步发展后的产物。SW是运行在浏览器背后的独立线程，一般可以用来实现缓存功能。使用SW的话，传输协议必须为HTTPS，因为它涉及请求拦截。SW的缓存与浏览器其他内置缓存不同，它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存，并且它的缓存是持续性的。针对于Service Worker的理解，可以看我这一篇文章：[ServiceWorker实现离线缓存][5] #### Memory Cache `Memory Cache`指的是内存缓存，**从效率上讲它是最快的**，但是**从存活时间来讲又是最短的**。 一旦关闭标签页，内存中的缓存也就释放了，经过测试： 1. 第一次打开`baidu.com`，大部分资源从服务器中请求 2. 刷新页面，大部分资源从`Memory Cache`或者`Disk Cache`中获取 3. **关闭**该标签页，打开`baidu.com`, 大部分资源从`Disk Cache`中获取。 内存缓存主要包含当前页面中已经抓取到的资源，例如页面上已经下载好的**样式、脚本、图片**等。 #### Disk Cache `Disk Cache`就是存储在磁盘中的缓存，从**存取效率上讲是比内存缓存慢的**，但是他的优势在于存储容量和存储时长。绝大部分缓存来自`Disk Cache`，对于大文件，大概率存进磁盘，当前内存使用率高，也优先存入磁盘。 #### Push Cache `Push Cache`即推送缓存，这是浏览器缓存的最后一道防线。当以上三种缓存都没有命中时才会被使用。它只在会话中存在，一旦会话结束就释放了，缓存时间只有5分钟左右，同时也不严格执行HTTP头中的缓存指令。它是`HTTP/2.0`中的内容，虽然现在应用的并不广泛，但随着`HTTP/2.0`的推广，它的应用越来越广泛。这里就不多赘述了。 #### 以上四者的优先级？ 我们先来看标准定义的资源请求遵循的顺序： ![普通优先级.png][6] 看起来是`Service Worker`优先于`Memory Cache`优先于`Disk Cache`。 但这是标准定义的资源请求流程，但是有追求的浏览器还会在`Service Worker`上面加一层 内存缓存层」，以 Chrome 为例。 ![Chrome优先级.png][7] `Memory Cache`作为第一公民，位于`Service Worker`之上。也就是命中了`Memory Cache`，就不会触发`Service Worker`的 `fetch`事件。而`Disk Cache`则位于原来的 HTTP 缓存层。 ### 总结 浏览器缓存机制其实很简单，流程就如这篇文章里的第一张图片一样： 1. 首先尝试确认强缓存，如果命中，则直接从缓存中拿取资源。 2. 如果没有命中强缓存，则发送请求自服务器，尝试协商缓存，如果命中，则会拿到304状态码，浏览器再从缓存中拿取资源。 3. 如果也没有命中协商缓存，那会从服务器中拿取最新资源。 4. 一般来说`Service Worker`优先于`Memory Cache`优先于`Disk Cache`优先于`Push Cache`。 [1]: http://120.25.166.245/usr/uploads/2022/03/2687907129.jpg [2]: http://120.25.166.245/usr/uploads/2022/03/2751135840.jpg [3]: http://120.25.166.245/usr/uploads/2022/03/1811237764.jpg [4]: http://120.25.166.245/usr/uploads/2022/03/361321031.jpg [5]: http://120.25.166.245/index.php/archives/15/ [6]: http://120.25.166.245/usr/uploads/2024/03/305250067.png [7]: http://120.25.166.245/usr/uploads/2024/03/3127579991.png
原文链接Does shadow DOM improve style performance$$^{[1]}$$，翻译在2021年10月9日。本译文也收录在公众号大前端从入门到跑路(https://mp.weixin.qq.com/s/wxYkp_DcxW52YTKhj6P8yA "[大前端从入门到跑路]")。 我的一个简短的回答是：能提高一点。虽然它可能还不足以在一般的 Web APP 中产生很大的差异，但我们值得去理解为什么它能提高样式性能。 首先，让我们回顾一下浏览器的渲染流程 $$^{[2]}$$ (`rendering pipeline`)，以及为什么我们可以推测 `shadow DOM` 能提高样式性能。我们知道，浏览器渲染过程的两个基本部分是 **样式计算** (`style calculation`)和 **布局计算** (`layout calculation`)，或者简称为“样式”和“布局”。前者的工作是：确定哪些 DOM 节点具有哪些样式（基于 CSS），后者则是：确定这些 DOM 节点实际放置在页面上的位置（使用上一步中计算出的样式）。 ![](http://120.25.166.245/usr/uploads/2024/05/3246465073.png) Chrome DevTools 中的性能跟踪，显示了基本的 JavaScript → 样式(Style) → 布局(Layout) → 绘制(Paint)流程 浏览器很复杂，但一般来说，页面上的 DOM 节点和 CSS 规则越多，运行样式和布局步骤所需要的时间就越长。提高此过程性能的一个方法是将工作分解为更小的块，即封装(`encapsulation`)——这是 shadow DOM 能提高样式性能的原因。 对于布局(`Layout`)的封装，我们有 CSS Containment $$^{[3]}$$ 。这个在其他文章 $$^{[4]}$$ 里已经讲过了，这里就不赘述了。可以这么说，我认为有足够的证据表明 `CSS containement`可以提高性能，所以如果你还没有尝试将 `contains: content` 置放在 `UI`里面以查看它是否可以提高布局性能，那么你绝对应该去试一试。 至于样式(`Style`)的封装，我们有一些新奇玩意儿：shadow DOM $$^{[5]}$$ 。就像 `CSS Containment`能提高布局性能一样，`shadow DOM`理论上 应该也能提高样式性能。让我们考虑一下原因： ## 什么是样式计算 如前文所述，样式计算不同于布局计算。布局计算与页面的几何布局有关，而样式计算则更明确地与 CSS 相关。这是采用如下规则的过程： ``` div > button { color: blue; } ``` 还有一个 DOM 树： ``` ``` ...例子如上，我们可以确定 ``应该具有 `color:blue`，因为它的父元素是 ``。粗略地说，这是计算 CSS 选择器（在本例中为 `div > button`）的过程。 现在，在最坏的情况下，这是一个 时间复杂度为 $$O(n * m)$$ 操作，其中 n 是 DOM 节点的数量，m 是 CSS 规则的数量。（即遍历每个 DOM 节点和每个 CSS 规则，弄清楚它们是否相互匹配。）显然，浏览器不会这样做，否则任何大小合适的 Web 应用程序都会变得非常缓慢。浏览器在这方面有很多优化，这也是我们经常建议不要过分担心 CSS 选择器性能的部分原因。（请参阅这篇文章 $$^{[6]}$$ ，了解该主题的最新处理） 也就是说，如果你使用过有着大量 CSS 代码量的代码库，你可能会注意到，在 Chrome 性能配置文件中，样式成本不是零。根据 CSS 的大小或复杂程度，你可能会发现实际上花在样式计算上的时间多于布局计算。因此，研究样式性能并不是完全没有价值的。 ## Shadow DOM 和样式计算 为什么 `shadow DOM` 会提高样式性能？这里再说一次，是因为封装！如果你有一个包含 1,000 个规则的 CSS 文件和一个包含 1,000 个节点的 DOM 树，浏览器在运行前，并不会知道哪些规则适用于哪些节点。即便你使用 CSS Modules $$^{[7]}$$ 、Vue scoped CSS$$^{[8]}$$ 或 Svelte scoped CSS$$^{[9]}$$ 来创作你的 CSS，最终你也只会得到一个隐式映射到 DOM 树的样式表，因此浏览器必须在运行时找出 CSS 和 DOM 树之间的联系（例如使用类或属性选择器）。`Shadow DOM` 则不同。使用 shadow DOM，浏览器不必猜测哪些规则适用于哪些节点——因为它就在 DOM 节点中： ``` #shadow-root div { color: green; } #shadow-root div { color: blue; } ``` 在这种情况下，浏览器不需要针对 DOM 中的每个节点测试 `div {color: green} `规则——它知道它的范围是 ``。`` 中的 `div {color: blue}`规则同上。理论上，这可以加快样式计算过程，因为浏览器可以依赖于通过 shadow DOM 的显式范围，而不是通过类或属性的隐式范围。 ## 基准测试 这是理论，但当然，在实践中事情总是更复杂。所以我整理了一个 benchmark$$^{[10]}$$ 来衡量 `shadow DOM`的样式计算性能。某些 CSS 选择器往往比其他选择器更快，因此为了获得不错的覆盖率，我测试了以下选择器： * id (`#foo`) * class (`.foo`) * attribute (`[foo]`) * attribute value (`[foo="bar"]`) * “silly” (`[foo="bar"]:nth-of-type(1n): last-child:not(:nth-of-type(2n)):not(:empty)`) 粗略地说，我希望 `id` 和 `class` 选择器是最快的，其次是 `attribute`和 `attribute value`选择器，然后是 `“silly”`选择器（添加一些东西以真正让样式引擎工作）。 为了测量，我使用了一个简单的 requestPostAnimationFrame$$^{[11]}$$ 插件，它可以测量在样式、布局和绘制上所花费的时间。这是 `Chrome DevTools` 中正在测量的内容的屏幕截图（注意 `Timings` 部分下的 `“total”`）： ![](http://120.25.166.245/usr/uploads/2024/05/1289353260.png) 为了运行实际的基准测试，我使用了 Tachometer $$^{[12]}$$ ，这是一个很好的浏览器微基准测试工具。在这种情况下，我只取了 51 次迭代测试中的中位数。 基准测试创建了几个自定义元素，并且要么使用自己的 ``（`shadow DOM “on”`）附加一个 `shadow root`，要么使用具有隐式范围（`shadow DOM “off”`）的全局 ``。通过这种方式，我想对 `shadow DOM` 本身和 `shadow DOM “polyfills”`（即不依赖于 shadow DOM 的 CSS 作用域系统）进行公平的比较。 每个 CSS 规则看起来像这样： ``` #foo { color: #000000; } ``` 每个组件的 DOM 结构看起来像这样 ``` hello ``` （当然，对于属性和类选择器，DOM 节点将有一个属性或类。） ## 基准测试的结果 以下是 Chrome 中 1,000 个组件和每个组件 1 个 CSS 规则的结果： ![统计图1](http://120.25.166.245/usr/uploads/2024/05/3892543692.jpg) | `` | id | class | attribute | attribute-value | silly | | ------------- | ----- | ----- | --------- | --------------- | ------ | | Shadow DOM | 67.90 | 67.20 | 67.30 | 67.70 | 69.90 | | No Shadow DOM | 57.50 | 56.20 | 120.40 | 117.10 | 130.50 | 如你所见，`classes` 和 `id` 选择器在打开或关闭 `shadow DOM` 时大致相同（实际上，没有 `shadow DOM` 更会快一点）。但是一旦选择器变得更复杂（ `attribute`、`attribute value` 和 `“silly”` 选择器），`shadow DOM` 就大致保持不变，而非 `shadow DOM` 版本时间消耗就更多。 如果我们将每个组件增加到 10 个 CSS 规则，我们可以更清楚地看到这种效果： ![统计图2](http://120.25.166.245/usr/uploads/2024/05/4085247755.jpg) | `` | id | class | attribute | attribute-value | silly | | ------------- | ----- | ----- | --------- | --------------- | ------ | | Shadow DOM | 70.80 | 70.60 | 71.10 | 72.70 | 81.50 | | No Shadow DOM | 58.20 | 58.50 | 597.10 | 608.20 | 740.30 | 上面的结果是针对 Chrome 的，但我们在 Firefox 和 Safari 中也看到了相似的数字。这是具有 1,000 个组件和 1 个 CSS 规则的 Firefox： ![统计图3](http://120.25.166.245/usr/uploads/2024/05/3249615779.jpg) | `` | id | class | attribute | attribute-value | silly | | ------------- | -- | ----- | --------- | --------------- | ----- | | Shadow DOM | 27 | 25 | 25 | 25 | 25 | | No Shadow DOM | 18 | 18 | 32 | 32 | 32 | Firefox 有 1,000 个组件，每个组件有 10 条 CSS 规则： ![统计图 safari-1](http://120.25.166.245/usr/uploads/2024/05/968264598.jpg) | `` | id | class | attribute | attribute-value | silly | | ------------- | -- | ----- | --------- | --------------- | ----- | | Shadow DOM | 30 | 30 | 30 | 30 | 34 | | No Shadow DOM | 22 | 22 | 143 | 150 | 153 | 这是带有 1,000 个组件和 1 个 CSS 规则的 Safari： ![统计图 safari-1](http://120.25.166.245/usr/uploads/2024/05/209029208.png) | `` | id | class | attribute | attribute-value | silly | | ------------- | -- | ----- | --------- | --------------- | ----- | | Shadow DOM | 57 | 58 | 61 | 63 | 64 | | No Shadow DOM | 52 | 52 | 126 | 126 | 177 | Safari 有 1,000 个组件，每个有 10 条 CSS 规则： ![统计图 safari-2](http://120.25.166.245/usr/uploads/2024/05/1605610946.jpg) | `` | id | class | attribute | attribute-value | silly | | ------------- | -- | ----- | --------- | --------------- | ----- | | Shadow DOM | 60 | 61 | 81 | 81 | 92 | | No Shadow DOM | 56 | 56 | 710 | 716 | 1157 | 所有基准测试都在 2015 年的 MacBook Pro 上运行，每个浏览器都是最新版本（Chrome 92、Firefox 91、Safari 14.1）。 ## 结论和未来的工作 我们可以从这些数据中得出一些结论。首先，`shadow DOM` 确实可以提高样式性能，所以我们关于样式封装的理论是成立的。然而，`id` 和 `classes` 选择器足够快，以至于实际上是否使用 `shadow DOM` 并不重要——事实上，没有 `shadow DOM` 时它们甚至会稍微更快一点。这表明像 `Svelte`、`CSS Modules` 或优秀的老式 BEM$$^{[13]}$$ 这样的系统正在使用性能方面的最佳方法。 这也表明，与 `classes` 相比，使用 `attribute` 进行样式封装不能很好地进行扩展。所以，也许像 Vue 这样的框架，切换到 `classes` 会更好。 另一个有趣的问题是，为什么在所有三个浏览器引擎中，`classes`和 `id` 选择器在使用 `shadow DOM` 时都稍慢。这对浏览器供应商本身来说可能是一个更好的问题，我不会再进行推测。不过，我要说的是，这些差异的绝对值很小，我认为不值得偏向其中任何一个。数据中最清晰的信号是 `shadow DOM` 有助于保持样式成本大致恒定，而如果没有 `shadow DOM`，你可应该坚持使用简单的选择器，如 `classes` 和 `id`，以避免遇到性能悬崖。 至于未来的工作：这是一个非常简单的基准测试，有很多方法可以扩展它。例如，基准测试每个组件只有一个内部 DOM 节点，并且它只测试平面选择器——没有后代或兄弟选择器（例如 `div div`、`div > div`、`div ~ div` 和 `div + div`）。理论上，这些场景也应该有利于 `shadow DOM`，特别是因为这些选择器不能跨越 `shadow` 边界，所以浏览器不需要在 `shadow root` 之外寻找相关的祖先或兄弟姐妹。（尽管浏览器的 Bloom 过滤器 $$^{[14]}$$ 使这变得更加复杂 - 请参阅这些文档 $$^{[15]}$$ 以很好地解释这种优化是如何工作的。） 不过总的来说，我认为上述数字还没有大到足以让普通的网络开发者开始担心优化他们的 CSS 选择器，或者将他们的整个网络应用迁移到 `shadow DOM`。这些基准测试结果可能仅在以下情况下才有意义：1) 你正在写一个框架，因此你选择的任何模式都会被放大数倍，或者 2) 你已经对你的 Web 应用程序进行了分析，并且看到了大量高额的样式计算成本。但是对于其他人，我希望你能懂得：至少这些结果是有趣的，它揭示了一些 `shadow DOM` 工作的原理。 **更新** ：Thomas Steiner$$^{[16]}$$ 也想知道标签选择器的测试结果（例如 `div {}`），所以我修改了基准测试 $$^{[17]}$$ 来测试它。我只会列出 `Shadow DOM` 版本的结果，因为基准测试使用 `div`，在非 `shadow DOM` 情况下，不可能单独使用标签来区分不同的 `div`。从绝对值来看，这些数字看起来非常接近 `id` 选择器和 `classes`选择器的数字（甚至在 Chrome 和 Firefox 中更快一点）： | `` | Chrome | Firefox | Safari | | -------------------------- | ------ | ------- | ------ | | 1,000 components, 1 rule | 53.9 | 19 | 56 | | 1,000 components, 10 rules | 62.5 | 20 | 58 | ### 参考资料 [1]Does shadow DOM improve style performance: https://nolanlawson.com/2021/08/15/does-shadow-dom-improve-style-performance/ [2]渲染流程: https://developers.google.com/web/fundamentals/performance/rendering/ [3]CSS Containment: *https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_Containment* [4]其他文章: *https://developers.google.com/web/updates/2016/06/css-containment* [5]shadow DOM: *https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM* [6]这篇文章: *https://www.sitepoint.com/optimizing-css-id-selectors-and-other-myths/* [7]CSS Modules: *https://github.com/css-modules/css-modules* [8]Vue scoped CSS: *https://vue-loader.vuejs.org/guide/scoped-css.html* [9]Svelte scoped CSS: *https://svelte.dev/docs#style* [10]benchmark: *https://gist.github.com/nolanlawson/19f4eca995730f69fd394e72a4a8147c* [11]requestPostAnimationFrame: *https://github.com/andrewiggins/afterframe/* [12]Tachometer: *https://github.com/Polymer/tachometer* [13]BEM: *http://getbem.com/* [14]Bloom 过滤器: *https://calendar.perfplanet.com/2011/css-selector-performance-has-changed-for-the-better/* [15]这些文档: *https://github.com/servo/servo/wiki/Css-selector-matching-meeting-2013-07-19* [16]Thomas Steiner: `https://toot.cafe/@tomayac/106764317075534622 ` [17]修改了基准测试: *https://gist.github.com/nolanlawson/eabc06356be4eb67d105a53a69c716a1*