跳到主要内容

· 阅读需 5 分钟
熊滔

使用 Vite 创建 React 项目,并且在 WebStorm 中调试 React 源码,效果如下:

可以直接调试源码,而不只是打包后的产物。

构建 React 源码

为了做到能够直接调试源码,而不是压缩、混肴的产物,我们需要自己构建 React 源码,并且生成 sourcemap 文件。

sourcemap(源映射)是一个文件,它提供了一种将压缩、合并或经过其他转换后的代码(如在生产环境中使用的代码)映射回原始源代码的方式。当 JavaScript 代码经过构建工具(如 Webpack、Rollup 等)处理后,代码可能会被压缩、丑化(变量名缩短等操作)或者合并成一个文件,这使得调试变得困难,因为调试工具显示的是转换后的代码,而不是开发人员编写的原始代码。

下载源码

git clone git@github.com:facebook/react.git

由于 React 最新的构建结果和之前不同,和找到的参考资料不一样,因此需要 reset 到之前的 commit 进行构建

git reset --hard 80f3d88190c07c2da11b5cac58a44c3b90fbc296

安装依赖

npm install

由于 React 构建脚本依赖于 Java,因此需要确保 Java 环境已准备就绪

前往 Oracle 官网下载

然后一直下一步就行,安装完成后不用手动设置环境变量,在命令行输入 java -version 即可验证是否安装成功

为了生成 sourcemap 文件,需要修改构建脚本的配置,脚本位于 scripts/rollup/build.js 中,首先需要将 sourcemap 为 true

这时如果直接运行构建脚本 npm run build,会发现报错

上述问题是因为构建的过程中会进行多次转换,会生成多次 sourcemap,然后把 sourcemap 串联起来就是最终的 sourcemap。如果中间有一步转换没有生成 sourcemap,那就断掉了,也就没法把 sourcemap 串联起来了。

这个问题的解决只要找出没有生成 sourcemap 的那几个插件注释掉就可以了

再次运行 npm run build 就可以正常构建了

构建完成后,可以在 build/node_modules 下面看到构建的产物,可以看到生成了 sourcemap 文件

我们把 reactreact-dom 链接到全局,方便我们在项目中引用调试

cd build/node_modules/react
npm link

cd ../react-dom
npm link

创建 React 项目并调试

使用 Vite 创建一个 React 项目

npm create vite@latest

进入项目,安装依赖

cd react-debug
npm install

这里不使用安装的 reactreact-dom,而是使用本地构建好并 link 到全局的包

npm link react
npm link react-dom

为了方便的在 WebStorm 中进行调试,需要进行一些配置,首先点击顶部栏的 Current File,打开一个下拉框,选择 Edit Configurations

会弹出一个配置管理弹窗,点击左上角的 + 并选择 JavaScript Debug

编辑调试配置,Name 就是该调试的名称,URL 就是前端项目的启动地址,这里使用的 Vite,默认启动在 http://localhost:5173

编辑完成后,启动项目

npm run dev

点击小虫的调试按钮

WebStorm 会自动打开一个 Chrome 页面

可以添加一个断点,给标题添加一个点击事件并添加断点

点击标题,这时会发现程序断住了,可以在 WebStorm 界面可以查看调用栈,变量等信息

参考

· 阅读需 7 分钟
熊滔

在 JavaScript 中有三种方式声明变量:

  • var
  • let
  • const

letconstES6 新引入的变量声明方式,解决 var 声明变量的很多问题,基本上已经不使用 var 来声明变量了。

var

var 声明变量有三个特点:

  • 声明提升

    正常情况下,变量需要先声明才能使用,如果在声明之前使用了还未定义的变量,那么会报错,但是在 JavaScript 中,对于使用 var 声明的变量,可以在声明之前访问,因为 var 声明的变量其作用域会提升到其所在作用域的顶部,不过只有声明会被提升,变量的初始化不会提升,因此在初始化之前访问的值是 undefined,在初始化之后访问才能获取到值

    console.log(a); // undefined,在变量 a 定义之前访问,不会报错,访问到的是 undefined
    var a = 10;
    console.log(a); // 10,在定义之后访问,可以访问到初始的值

    上面的语句相当于

    var a; // 声明提升
    console.log(a); // undefined
    a = 10;
    console.log(a); // 10
  • 无块级作用域

    var 声明的变量的作用域是离其最近的函数主体类静态初始化块,在除此之外的块作用域中,如 ifforwhiletry...catchswitch 等块级作用域中声明的变量可以在其外部访问到

    var a = true;

    if (a) {
    var b = 2;
    }

    console.log(b); // 2,如果 a 是 false,那么这里访问到的就是 undefined
    function func () {
    // 变量 a 的作用域在 func 内,因此在函数外无法访问到 a
    var a = 1;
    }

    func();

    console.log(a); // ReferenceError: a is not defined

    如果不是在函数或类静态初始化块中使用 var 声明变量,分为两种情况:

    • 如果在模块中,那么 var 的作用域就是整个模块

    • 如果是在脚本中,那么就是全局作用域

    如果是全局作用域,声明的变量将作为全局对象的不可配置属性(对于浏览器环境,对应于 windows 属性,对于 Node 环境,就是 global 属性)

    var a = 1;
    console.log(window.a); // 1

    delete window.a; // false,不可配置
  • 重复声明

    在同一个作用域中,var 声明的变量可重复声明,重新声明后值也不会丢失,除非重新进行了初始化

    var a = 1;
    console.log(a); // 1

    var a;
    console.log(a); // 1

    a = 10;
    console.log(a); // 10

    如果 varfunction 在同一作用域声明了同一变量,那么 var 初始化会覆盖 function 的值,因为 function 的声明会被提升到作用域的顶部,随后才是变量的初始化

    var a = 1;

    function a() {
    return 2;
    }

    console.log(a); // 1

let

let 声明的变量与其他语言声明的变量特性一致:

  • 在变量声明之前不能使用,从作用域顶部到变量声明之间的区域称为暂时性死区,在暂时性死区中不能访问变量,否则会报错

    console.log(a); // ReferenceError: Cannot access 'a' before initialization

    let a = 1;
  • 块作用域

    if (true) {
    let a = 1;
    }
    console.log(a) // ReferenceError: a is not defined

    let 声明的变量只在所在的块作用域内生效,作用域之外无法访问,否则会报错

  • 不可重复性声明

    在同一作用域内,不可能声明相同命名的变量

    let a = 1;
    let a = 2; // SyntaxError: Identifier 'a' has already been declared

const

constlet 具有相同的特点,有一个不同的是 let 声明的变量是可变的,可以被重新赋值,而 const 声明的变量是不可变的,不可以被重新赋值

let a = 1;
a = 2;
console.log(a); // 2
const a = 1;
a = 2; // TypeError: Assignment to constant variable.

因为 const 声明的变量不允许重新赋值,因此在声明时就必须初始化

const a; // SyntaxError: Missing initializer in const declaration

const 声明的变量不能被重新赋值,但是不代表值不可以改动

const obj = {
a: 1
}

obj.a = 2;
console.log(obj.a); // 2

变量 obj 指向的内存地址始终没有发生变化,因此上述操作是被允许的。

最佳实践:变量使用 const 定义,当变量需要改变时,在使用 let 定义。

循环中的变量声明

考虑下面的一个循环

const funcs = []
for(var i = 0; i < 10; i++) {
funcs.push(function() {
console.log(i)
})
}
funcs.forEach(function(func){
func()
})

输出结果为

10
10
10
10
10
10
10
10
10
10

因为变量 i 指向的是全局作用域中的 i,经过十次循环后,i 的值已经变为了 10,所以每次打印的都是 10

如果把 var 声明的变量替换为 letconst,因为每次都会重新声明变量 ifunc 中访问的是闭包捕获的重新声明的 i

const funcs = []
for(var i = 0; i < 10; i++) {
funcs.push(function() {
console.log(i)
})
}
funcs.forEach(function(func){
func()
})

输出为

0
1
2
3
4
5
6
7
8
9

参考

· 阅读需 7 分钟
熊滔

SVG 可以看作是一个无限大小的画布,而 viewBox 属性就是决定将画布中的哪一部分展示出来,viewBox 指定的这个范围称之为视口。viewBox 通过四个属性来定义视口:

  • 左上角坐标 (minX,minY)(\text{minX}, \text{minY})
  • 视口大小 (width,height)(\text{width}, \text{height})

这里使用的坐标均是 SVG 中的坐标系统,SVG 坐标系统中的一个单位长度与一像素并不相等,二者之间的换算关系取决于视口的大小与 SVG 元素的 CSS 大小

<svg width="200" height="200" viewBox="0 0 100 100"></svg>

如上 SVG 的 CSS 大小为 200200 像素,而视口的大小为 100100 SVG 单位,也就是说 100100 SVG 单位等价于 200200 像素,所以一个单位等两个像素大小。

信息

实际上 xx 轴和 yy 轴方向可以有不同的换算比例,为了简单,只考虑二者的换算比例相同。

通过改变视口的左上角坐标即可实现画布拖拽,而改变 SVG 单位与像素单位的比例即可实现缩放。

拖拽

考虑点 (x,y)(x, y),要实现在视口上移动了 offsetX\text{offsetX} 像素和 offsetY\text{offsetY} 像素,求 newMinX\text{newMinX}newMinY\text{newMinY}

考虑简单的情况,没有缩放,那么一像素等与一个 SVG 单位,那么有

x2x1=(xnewMinX)(xminX)=offsetXy2y1=(ynewMinY)(yminY)=offsetY\begin{aligned} x_2 - x_1 &= (x - \text{newMinX}) - (x - \text{minX}) &= \text{offsetX} \\ y_2 - y_1 &= (y - \text{newMinY}) - (y - \text{minY}) &= \text{offsetY} \end{aligned}

得到

newMinX=minXoffsetXnewMinY=minYoffsetY\begin{aligned} \text{newMinX} &= \text{minX} - \text{offsetX} \\ \text{newMinY} &= \text{minY} - \text{offsetY} \end{aligned}

现在考虑缩放的情况,此时画布的缩放比例为 zoom\text{zoom},也就是说一个 SVG 单位等于 zoom\text{zoom} 像素,换句话说,一个像素大小为 1zoom\dfrac{1}{\text{zoom}} 个 SVG 单位,那么

x2x1=(xnewMinX)(xminX)=offsetXzoomy2y1=(ynewMinY)(yminY)=offsetYzoom\begin{aligned} x_2 - x_1 &= (x - \text{newMinX}) - (x - \text{minX}) &= \cfrac{\text{offsetX}}{\text{zoom}} \\ y_2 - y_1 &= (y - \text{newMinY}) - (y - \text{minY}) &= \cfrac{\text{offsetY}}{\text{zoom}} \end{aligned}

得到

newMinX=minXoffsetXzoomnewMinY=minYoffsetYzoom\begin{aligned} \text{newMinX} &= \text{minX} - \cfrac{\text{offsetX}}{\text{zoom}} \\ \text{newMinY} &= \text{minY} - \cfrac{\text{offsetY}}{\text{zoom}} \end{aligned}

缩放

要实现缩放,只需要改变视口大小与 CSS 大小的比例关系即可。设 SVG 的 CSS 宽高为(width,height)(\text{width}, \text{height}),要实现 zoom\text{zoom} 倍的缩放,那么只要将视口的宽度和高度设置为 (widthzoom,heightzoom)(\dfrac{\text{width}}{\text{zoom}}, \dfrac{\text{height}}{\text{zoom}})即可。

如果我们希望在进行缩放时,某个点相对于视口保持不变呢,考虑一个场景,在滚动滚动时我们缩放画布,但是希望此时鼠标所在的点相对于视口是不变的

假设以点 (x,y)(x, y) 进行缩放,在缩放完成后,点 (x,y)(x, y) 相对于画布应保持不变,即该点始终距离画布左侧为 offsetX\text{offsetX} 像素,距离画布顶部 offsetY\text{offsetY} 像素在缩放前后都是一样的。

设缩放前的缩放比例为 zoom\text{zoom},缩放后的缩放比例为 newZoom\text{newZoom},缩放前左上角的坐标为 (minX,minY)(\text{minX}, \text{minY}),求缩放后左上角的坐标 (newMinX,newMinY)(\text{newMinX}, \text{newMinY})。

考虑 xx 方向,可以得到两个等式

(xminX)zoom=offsetX(xnewMinX)newZoom=offsetX\begin{aligned} (x - \text{minX}) \cdot \text{zoom} &= \text{offsetX} \\ (x - \text{newMinX}) \cdot \text{newZoom} &= \text{offsetX} \\ \end{aligned}

解此方程可以得到

newMinX=offsetX(1zoom1newZoom)+minX\text{newMinX} = \text{offsetX} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minX}

同理可以得到

newMinY=offsetY(1zoom1newZoom)+minY\text{newMinY} = \text{offsetY} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minY}

所以为了在缩放的过程中保持点的位置不变,除了需要将视口的大小设置为 (widthnewZoom,heightnewZoom)(\cfrac{\text{width}}{\text{newZoom}}, \cfrac{\text{height}}{\text{newZoom}}),还需要将视口左上角坐标调整为 (offsetX(1zoom1newZoom)+minX,offsetY(1zoom1newZoom)+minY)(\text{offsetX} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minX}, \text{offsetY} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minY})

const { useRef, useEffect } = React;

let zoom = 1;
let minX = 0;
let minY = 0;
let startPosition = null;

const Component = () => {
const containerRef = useRef(null);
const svgRef = useRef(null);

useEffect(() => {
const boardContainer = containerRef.current;
const board = svgRef.current;
if (!boardContainer || !board) return;

const { width, height } = boardContainer.getBoundingClientRect();
board.setAttribute('viewBox', `${minX} ${minY} ${width / zoom} ${height /zoom}`);

const handlePointerDown = (e) => {
startPosition = {
x: e.clientX,
y: e.clientY,
}
}

const handlePointerMove = (e) => {
if (!startPosition || !boardContainer || !board) return;
const { width, height } = boardContainer.getBoundingClientRect();
const endPosition = {
x: e.clientX,
y: e.clientY
}
const offsetX = endPosition.x - startPosition.x;
const offsetY = endPosition.y - startPosition.y;
const newMinX = minX - offsetX / zoom;
const newMinY = minY - offsetY / zoom;
board.setAttribute('viewBox', `${newMinX} ${newMinY} ${width / zoom} ${height /zoom}`);
}

const handlePointerUp = (e) => {
if (!startPosition || !boardContainer || !board) return;
const { width, height } = boardContainer.getBoundingClientRect();
const endPosition = {
x: e.clientX,
y: e.clientY
}
const offsetX = endPosition.x - startPosition.x;
const offsetY = endPosition.y - startPosition.y;
const newMinX = minX - offsetX / zoom;
const newMinY = minY - offsetY / zoom;
board.setAttribute('viewBox', `${newMinX} ${newMinY} ${width / zoom} ${height /zoom}`);
minX = newMinX;
minY = newMinY;
startPosition = null;
}

const handleWheel = (e) => {
if (!boardContainer || !board) return;
e.preventDefault();
const { width, height, x: containerX, y: containerY } = boardContainer.getBoundingClientRect();

const x = e.clientX;
const y = e.clientY;

const offsetX = x - containerX;
const offsetY = y - containerY;
let newZoom;
if (e.deltaY < 0) {
newZoom = Math.min(zoom * 1.1, 10);
} else {
newZoom = Math.max(zoom * 0.9, 0.1);
}
const newMinX = minX + offsetX * (1 / zoom - 1 / newZoom);
const newMinY = minY + offsetY * (1 / zoom - 1 / newZoom);
board.setAttribute('viewBox', `${newMinX} ${newMinY} ${width / newZoom} ${height / newZoom}`);
zoom = newZoom;
minX = newMinX;
minY = newMinY;
}

boardContainer.addEventListener('pointerdown', handlePointerDown);
boardContainer.addEventListener('pointermove', handlePointerMove);
boardContainer.addEventListener('pointerup', handlePointerUp);
boardContainer.addEventListener('wheel', handleWheel);

return () => {
if (boardContainer) {
boardContainer.removeEventListener('pointerdown', handlePointerDown);
boardContainer.removeEventListener('pointermove', handlePointerMove);
boardContainer.removeEventListener('pointerup', handlePointerUp);
boardContainer.removeEventListener('wheel', handleWheel);
}
}
}, [])

return (
<div ref={containerRef} style={{ width: '100%', height: 500, border: '2px dashed #aaa' }}>
<svg ref={svgRef} width="100%" height="100%">
<rect x="100" y="100" width="200" height="100" fill="#e77c8e" fill-opacity="0.5" />
</svg>
</div>
)
}

· 阅读需 5 分钟
熊滔

一个值可能同时具有多种可能的类型,在断言为具体的类型之前只能使用公共的属性和方法

const strOrNum: string | number = "a";
// ❎
strOrNum.toLowerCase();
// ✅
(strOrNum as string).toLowerCase();

通过 as 断言为主动断言,还可以通过一些方法判断类型,ts 可以帮助将类型进行收缩

const strOrNum: string | number = "a";
if (typeof strOrNum === 'string') {
// 经过 typeof 判断后,类型收缩到 string 类型
// 不用手动断言即可直接使用 string 下的方法
strOrNum.toLowerCase();
} else {
// 此处会将 string 这种可能性去掉,因此只能是 number
// 可以使用 number 独有的方法
strOrNum.toFixed(2);
}

根据分支的判断条件,ts 会尝试推导类型,将值可能的类型进行尽可能的收窄,这种类型推断的行为称之为类型守卫typeof 是一种常见的类型守卫,除此之外,还有其它的类型守卫,如:

  • ===

  • in

  • instanceof

  • 类型守卫函数

  • 断言函数

===!== 一般用于字面量类型的判断

const oneOr1: "one" | 1 = 1;

if (oneOr1 === 1) {
// 是数字 1
} else {
// 是英文 one
}

也可用于对象属性的字面量判断,从而区分类型

enum EType {
DOG = 1,
CAT = 2
}

interface Dog {
type: EType.DOG;
name: string;
}

interface Cat {
type: EType.CAT;
name: string;
}

const dogOrCat = {
type: EType.DOG,
name: '旺财'
}

if (dogOrCat.type === EType.DOG) {
// 是 Dog 类型
} else {
// 是 Cat 类型
}
备注

使用 === 判断对象的属性来收缩对象类型时,要确保该属性是==字面量类型==,比如我将 DogCat 的类型都声明为 EType 就无法进行类型推断

interface Dog {
type: EType;
name: string;
}

interface Cat {
type: EType;
name: string;
}

这种情况下可以使用自定义类型守卫,见下。

in 操作符可以判断某个属性是否存在于对象中,如果某个对象具有独一无二的属性,通过此判断可以将类型收缩到此对象类型

interface Foo {
fooOnly: boolean;
shared: number;
}

interface Bar {
barOnly: boolean;
shared: number;
}

declare const i: Foo | Bar;
if ('barOnly' in i) {
// 收缩为 Bar 类型
} else {
// 收缩为 Foo 类型
}

instanceof 是用来判断值是否是某个类的示例

class Foo {
foo();
}

class Bar {
bar();
}

declare const input: Foo | Bar;

if (input instanceof Foo) {
// 收缩为 Foo 的实例,可以调用 foo 方法
input.foo();
} else {
// 收缩为 Bar 的实例
input.bar();
}

考虑如下两个类型

interface Foo {
type: string;
}

interface Bar {
type: number;
}

FooBar 都包含有 type 类型,并且是不同的类型,但是我们却不能通过判断 type 的类型使得类型进行收缩

declare const a: Foo | Bar;
if (typeof a.type === 'string') {
// 不能将 a 收缩到 Foo 类型
}

这个时候可以使用自定义的类型守卫

function isFoo(val: Foo | Bar) val is Foo  {
return typeof val.type === 'string';
}

if (isFoo(a)) {
// 可以收缩到 Foo 类型
}

标准库的 Array.isArray 方法就是通过这种方式将类型收缩到数组的:

还有一种方法可以收缩类型,那就是断言,断言一般常见于测试中,一旦断言失败,就表示未达到预期,抛出异常,退出程序。因此能运行到断言后面的代码,就表示断言通过了,可以通过这一特性来收缩类型

function assertIsNumber(val: any): assert val is number {
if (!typeof val !== 'number') {
throw new Error();
}
}

const val: number | string = 12;
assertIsNumber(val);

// 运行到这里,说明上面的断言通过了,此时 val 的类型一定是 number
val * 100;

· 阅读需 14 分钟
熊滔

如果你使用 HTTP 访问网站或者进行通信,你的一切都毫无秘密 ,因为你的所有信息都是明文传输的,并且你收到的信息都可能是伪造的,想象你访问一个需要输入私密信息的网站,但是网站的内容已经被别人偷偷的篡改过了,你提交的信息都提交到别人的服务器上了,信息由此泄露。

由于 HTTP 是明文传输的,所有的内容没有经过任何的加密,截获到你传输流量的人,都可以从其中获取传递的信息,对付这种窃听,我们只需要对消息进行加密即可。

加密和解密都需要用到密钥,根据加密和解密使用的密钥是否相同,加密分为两种:

  • 对称加密:加解密使用同一个密钥

  • 非对称加密:加密和解密使用的不同的密钥

非对称加密会生成两个密钥,使用其中一个加密,另外一个可以解密,且必须使用另外一个才能解密。这两个密钥其中一个是公开的,称之为公钥,另一个是绝不能在互联网上传播的,称之为私钥。

假设我们对消息使用对称加密的方式进行加密,窃听者没有密钥,就无法解密了,但是你有没有想过一个问题,密钥要怎么传递,如果是明文传递,那么密钥也可以被窃听到,那么加密这件事就毫无意义。你可能会说,我们对密钥也进行加密,那么加密密钥的密钥如何传递,又回到了原点。

这个时候非对称加密要出场了,通信双方首先在本地生成公钥和私钥,然后互相把公钥发送给对方,让对方使用接收到的公钥加密消息,即使有窃听者,也只能获取到公钥,由于没有私钥,也无法解密消息。

sequenceDiagram
actor A
actor B
A->>A: 本地生成公钥 A 和 私钥 A
B->>B: 本地生成公钥 B 和 私钥 B
A->>B: 公钥 A
B->>A: 公钥 B
A->>A: 使用公钥 B 对 message1 加密 → Emessage1
A->>B: Emessage1
B->>B: 使用私钥 B 对 Emessage1 解密 → message1
B->>B: 使用公钥 A 对 message2 加密 → Emessage2
B->>A: Emessage2
A->>A: 使用私钥 A 对 Emessage2 解密 → message2

非对称加密相对于对称加密的成本要高很多,要进行很复杂的运算,如果通信过程一直使用非对称加密,会使得通信时间变长,于是就有了混合加密,即通过非对称加密传递对称加密的密钥,然后使用对称加密的方式进行通信,因为对称加密的密钥是加密过的,因此窃听者无法得知,这种方式使成本和保密性都得到了保证。

sequenceDiagram
actor A
actor B
B->>A: 公钥
A->>A: 生成对称加密的密钥 key,并使用公钥加密 → EKey
A->>B: Ekey
B->>B: 使用私钥解密 → key

但是非对称加密只是解决加密的问题,但无法确定对方的身份,即你怎么确认公钥一定是 B 发给你的呢?假设存在这么一个人,它劫持双方通信的流量,并且假冒是通信的对方,例如对于 A,它假冒是 B 与其交换密钥进行通信,对于 B,它假冒是 A 与其通信,所有的消息经过它进行了一次转发。

sequenceDiagram
actor A
actor C
actor B
A-->C: key-AC
C-->B: key-CB
A->>C: key-AC + message1 → EMessage1
C->>C: key-AC + EMessage1 → message1
C->>B: key-CB + message1 → Emessage2
B->>B: key-CB + Emessage2 → message1

A 试图与 B 通信,但是被 C 劫持了,C 假冒 B 与 A 交换了密钥,同时假冒 A 与 B 交换了密钥,这样 A 与 B 之间的所有消息都会被 C 看到,这种攻击方式称为中间人攻击,很形象。

之所以中间人有机可乘,是因为通信双方无法验证对方的身份,那如何证明身份呢,这就需要用到数字证书。数字证书需要向专门的机构(Certificate Authorities 机构,简称 CA)申请,我们需要提交我们的公钥、域名等信息给 CA 机构,并且还需要证明你的确是这个域名的持有者,CA 机构验证完成之后,会生成一个数字证书。

这时如果 A 向 B 请求信息,B 不应该直接返回它的公钥,而是返回申请的证书,浏览器会验证证书的合法性:

  1. 证书是否是由可信任的 CA 机构颁发的

  2. 证书中的域名信息和当前请求的域名相吻合

但是有一种可能,证书可能会被篡改,比如把证书里面的公钥替换为自己的,有什么办法可以知道消息被篡改了呢,那就是数字签名。CA 机构在颁发数字证书,还会生成一个数字签名,当浏览器收到证书后会计算证书的签名与下发的签名是否相同,如果相同则表示没有被修改,值得信任。那这个时候你就会问,数字签名不可以被篡改吗,还真的不能,只能由 CA 机构生成,其它生成的都无效,这又是为什么,这就要看数字签名是如何生成的:

  1. CA 机构会生成一个公钥和私钥

  2. CA 机构会使用私钥对数字证书进行加密,得到的就是签名

  3. 浏览器使用 CA 机构的公钥进行解密,与签名进行比较,如果一样说明没被篡改

因为签名是使用 CA 机构的私钥加密的,而私钥只有 CA 机构有,别人无法生成签名,即使随便生成了一个,浏览器计算得到的签名肯定和这个签名对不上,就知道被篡改了。

事实上,使用私钥对全部的消息进行加密比较耗费时间,一般是根据消息生成哈希值(相同的内容可以生成相同的哈希值,对内容篡改后,哈希值会大不相同),然后对哈希值加密得到数字签名。

只有这三个条件都符合,我们才认可对方的身份,而不是第三方进行了伪造,然后就会提取证书中的域名提供的公钥。

那么中间人有没有可能伪造数字证书:

  1. 申请一个别的域名的证书,替换为自己的

    浏览器会验证证书里面的域名与请求的域名是否吻合,域名信息不对,会被认为是不安全的

  2. 也向机构申请一个证书,并且声明自己是这个域名的持有者

    无法证明是域名的持有者,CA 会给一个记录让你配置在域名相关信息中,因为你不是域名的所有制,自然无法配置,所以不会颁发证书

  3. 篡改证书信息

    证书使用 CA 机构的私钥进行了签名,篡改了信息就无法使签名与篡改后的签名一致

    把签名也篡改了?没有 CA 机构的私钥,无法篡改

所以 HTTPS 的总体通信如下

sequenceDiagram
actor 浏览器
actor 服务器
浏览器->>服务器: Hello
服务器->>浏览器: 证书
浏览器->>浏览器: 验证证书合法性,提取服务器的公钥 pubKey
浏览器->>浏览器: 生成对称加密的密钥 key,使用公钥加密 pubKey + key → Ekey
浏览器->>服务器: Ekey
服务器->>服务器: 使用私钥解密 priKey + Ekey → key
Note over 浏览器,服务器: 愉快的使用 key 进行加密通信

最后还有一个值得注意的问题,浏览器是如何获得 CA 机构的公钥的,你无法保证 CA 机构的公钥不是伪造的,其实 CA 机构的公钥也是以证书的形式交付给浏览器的,那么问题来了,这个证书是谁生成的,其实 CA 机构也是有层级的,这个证书是由上一级的 CA 机构颁发,处于最上级的 CA 机构称为根 CA 机构,它们的证书是谁颁发的呢,是它们自己,自己给自己颁发证书,这种证书称为自签名的证书。

如果你收到了一个自签名的证书,但不是根 CA 机构的,那么你就要注意了,如果信任该证书,且该机构包含恶意,你的信息可能被其监听到,甚至被修改,所以对于不信任的证书不要添加到信任列表中。

  1. 有了 HTTPS,使用公共场所的 WiFi 还安全吗

    如果你使用的 HTTPS 那就没问题,虽然能看到你发送的所有字节,但是不知道你们交流了什么。但是如果你使用了 HTTP,你的内容可以被看到,甚至返回的内容都会被修改,使得你提交的内容会指向一个不安全的网站

  2. 公司能监控到你的 HTTPS 内容吗

    在每个信任链的根部都有一个隐含的受信任的 CA,这些权威机构的列表存储在你的浏览器中。你的公司可以利用他们对你的机器的访问,将他们自己的自签名证书添加到这个 CA 列表中。然后,他们可以拦截你所有的 HTTPS 请求,对于一些网站的证书,由他们的假 CA 签署。由于你使用的是公司提供的证书中的公钥对你的所有 HTTPS 请求进行加密,他们可以使用相应的私钥来解密和检查(甚至修改)你的请求,然后将其发送到预定地点。他们可能不会这样做,但他们可以。

  3. 棱镜门事件

    法官要求公司交出它们的私钥,有了私钥,就可以解密出加密使用的密钥,所有的消息都会被看到。

参考资料: