Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2a01dd0b | ||
|
|
c2897a2385 | ||
|
|
08ea922317 | ||
|
|
fa1e19c58f | ||
|
|
444e4daeec | ||
|
|
a95ae5ac5a | ||
|
|
0fa01c716b | ||
|
|
ef97b66383 | ||
|
|
0ca68bd61f | ||
|
|
33e9a77a1b | ||
|
|
fb492c7c6b | ||
|
|
dd0f3b3d8b | ||
|
|
b68d83f282 | ||
|
|
663eb89301 | ||
|
|
01d658e744 | ||
|
|
36349d365d |
@@ -0,0 +1,364 @@
|
||||
TS 强类型非常好用,但在实际运用中,免不了遇到一些难以描述,反复看官方文档也解决不了的问题,至今为止也没有任何一篇文档,或者一套教材可以解决所有犄角旮旯的类型问题。为什么会这样呢?因为 TS 并不是简单的注释器,而是一门图灵完备的语言,所以很多问题的解决方法藏在基础能力里,但你学会了基础能力又不一定能想到这么用。
|
||||
|
||||
解决该问题的最好办法就是多练,通过实际案例不断刺激你的大脑,让你养成 TS 思维习惯。所以话不多说,我们今天从 [type-challenges](https://github.com/type-challenges/type-challenges) 的 Easy 难度题目开始吧。
|
||||
|
||||
## 精读
|
||||
|
||||
### [Pick](https://github.com/type-challenges/type-challenges/blob/main/questions/00004-easy-pick/README.md)
|
||||
|
||||
手动实现内置 `Pick<T, K>` 函数,返回一个新的类型,从对象 T 中抽取类型 K:
|
||||
|
||||
```ts
|
||||
interface Todo {
|
||||
title: string
|
||||
description: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
type TodoPreview = MyPick<Todo, 'title' | 'completed'>
|
||||
|
||||
const todo: TodoPreview = {
|
||||
title: 'Clean room',
|
||||
completed: false,
|
||||
}
|
||||
```
|
||||
|
||||
结合例子更容易看明白,也就是 `K` 是一个字符串,我们需要返回一个新类型,仅保留 `K` 定义的 Key。
|
||||
|
||||
第一个难点在如何限制 `K` 的取值,比如传入 `T` 中不存在的值就要报错。这个考察的是硬知识,只要你知道 `A extends keyof B` 这个语法就能联想到。
|
||||
|
||||
第二个难点在于如何生成一个仅包含 `K` 定义 Key 的类型,你首先要知道有 `{ [A in keyof B]: B[A] }` 这个硬知识,这样可以重新组合一个对象:
|
||||
|
||||
```ts
|
||||
// 代码 1
|
||||
type Foo<T> = {
|
||||
[P in keyof T]: T[P]
|
||||
}
|
||||
```
|
||||
|
||||
只懂这个语法不一定能想出思路,原因是你要打破对 TS 的刻板理解,`[K in keyof T]` 不是一个固定模板,其中 `keyof T` 只是一个指代变量,它可以被换掉,如果你换掉成另一个范围的变量,那么这个对象的 Key 值范围就变了,这正好契合本题的 `K`:
|
||||
|
||||
```ts
|
||||
// 代码 2(本题答案)
|
||||
type MyPick<T, K in keyof T> = {
|
||||
[P in K]: T[P]
|
||||
}
|
||||
```
|
||||
|
||||
这个题目别看知道答案后简单,回顾下还是有收获的。对比上面两个代码例子,你会发现,只不过是把代码 1 的 `keyof T` 从对象描述中提到了泛型定义里而已,所以功能上没有任何变化,但因为泛型可以由用户传入,所以代码 1 的 `P in keyof T` 因为没有泛型支撑,这里推导出来的就是 `T` 的所有 Keys,而代码 2 虽然把代码挪到了泛型,但因为用的是 `extends` 描述,所以表示 `P` 的类型被约束到了 `T` 的 Keys,至于具体是什么,得看用户代码怎么传。
|
||||
|
||||
所以其实放到泛型里的 `K` 是没有默认值的,而写到对象里作为推导值就有了默认值。泛型里给默认值的方式如下:
|
||||
|
||||
```ts
|
||||
// 代码 3
|
||||
type MyPick<T, K extends keyof T = keyof T> = {
|
||||
[P in K]: T[P]
|
||||
}
|
||||
```
|
||||
|
||||
也就是说,这样 `MyPick<Todo>` 就也可以正确工作并原封不动返回 `Todo` 类型,也就是说,代码 3 在不传第二个参数时,与代码 1 的功能完全一样。仔细琢磨一下共同点与区别,为什么代码 3 可以做到和代码 1 功能一样,又有更强的拓展性,你对 TS 泛型的实战理解就上了一个台阶。
|
||||
|
||||
### [Readonly](https://github.com/type-challenges/type-challenges/blob/main/questions/00007-easy-readonly/README.md)
|
||||
|
||||
手动实现内置 `Readonly<T>` 函数,将对象所有属性设置为只读:
|
||||
|
||||
```ts
|
||||
interface Todo {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const todo: MyReadonly<Todo> = {
|
||||
title: "Hey",
|
||||
description: "foobar"
|
||||
}
|
||||
|
||||
todo.title = "Hello" // Error: cannot reassign a readonly property
|
||||
todo.description = "barFoo" // Error: cannot reassign a readonly property
|
||||
```
|
||||
|
||||
这道题反而比第一题简单,只要我们用 `{ [A in keyof B]: B[A] }` 重新声明对象,并在每个 Key 前面加上 `readonly` 修饰即可:
|
||||
|
||||
```ts
|
||||
// 本题答案
|
||||
type MyReadonly<T> = {
|
||||
readonly [K in keyof T]: T[K]
|
||||
}
|
||||
```
|
||||
|
||||
根据这个特性我们可以做很多延伸改造,比如将对象所有 Key 都设定为可选:
|
||||
|
||||
```ts
|
||||
type Optional<T> = {
|
||||
[K in keyof T]?: T[K]
|
||||
}
|
||||
```
|
||||
|
||||
`{ [A in keyof B]: B[A] }` 给了我们描述每一个 Key 属性细节的机会,限制我们发挥的只有想象力。
|
||||
|
||||
### [First Of Array](https://github.com/type-challenges/type-challenges/blob/main/questions/00014-easy-first/README.md)
|
||||
|
||||
实现类型 `First<T>`,取到数组第一项的类型:
|
||||
|
||||
```ts
|
||||
type arr1 = ['a', 'b', 'c']
|
||||
type arr2 = [3, 2, 1]
|
||||
|
||||
type head1 = First<arr1> // expected to be 'a'
|
||||
type head2 = First<arr2> // expected to be 3
|
||||
```
|
||||
|
||||
这题比较简单,很容易想到的答案:
|
||||
|
||||
```ts
|
||||
// 本题答案
|
||||
type First<T extends any[]> = T[0]
|
||||
```
|
||||
|
||||
但在写这个答案时,有 10% 脑细胞提醒我没有判断边界情况,果然看了下答案,有空数组的情况要考虑,空数组时返回类型 `never` 而不是 `undefined` 会更好,下面几种写法都是答案:
|
||||
|
||||
```ts
|
||||
type First<T extends any[]> = T extends [] ? never : T[0]
|
||||
type First<T extends any[]> = T['length'] extends 0 ? never : T[0]
|
||||
type First<T> = T extends [infer P, ...infer Rest] ? P : never
|
||||
```
|
||||
|
||||
第一种写法通过 `extends []` 判断 `T` 是否为空数组,是的话返回 `never`。
|
||||
|
||||
第二种写法通过长度为 0 判断空数组,此时需要理解两点:1. 可以通过 `T['length']` 让 TS 访问到值长度(类型的),2. `extends 0` 表示是否匹配 0,即 `extends` 除了匹配类型,还能直接匹配值。
|
||||
|
||||
第三种写法是最省心的,但也使用了 `infer` 关键字,即使你充分知道 `infer` 怎么用([精读《Typescript infer 关键字》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/207.%E7%B2%BE%E8%AF%BB%E3%80%8ATypescript%20infer%20%E5%85%B3%E9%94%AE%E5%AD%97%E3%80%8B.md)),也很难想到它。用 `infer` 的理由是:该场景存在边界情况,最便于理解的写法是 “如果 T 形如 `<P, ...>`” 那我就返回类型 `P`,否则返回 `never`”,这句话用 TS 描述就是:`T extends [infer P, ...infer Rest] ? P : never`。
|
||||
|
||||
### [Length of Tuple](https://github.com/type-challenges/type-challenges/blob/main/questions/00018-easy-tuple-length/README.md)
|
||||
|
||||
实现类型 `Length<T>` 获取元组长度:
|
||||
|
||||
```ts
|
||||
type tesla = ['tesla', 'model 3', 'model X', 'model Y']
|
||||
type spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT']
|
||||
|
||||
type teslaLength = Length<tesla> // expected 4
|
||||
type spaceXLength = Length<spaceX> // expected 5
|
||||
```
|
||||
|
||||
经过上一题的学习,很容易想到这个答案:
|
||||
|
||||
```ts
|
||||
type Length<T extends any[]> = T['length']
|
||||
```
|
||||
|
||||
对 TS 来说,元组和数组都是数组,但元组对 TS 来说可以观测其长度,`T['length']` 对元组来说返回的是具体值,而对数组来说返回的是 `number`。
|
||||
|
||||
### [Exclude](https://github.com/type-challenges/type-challenges/blob/main/questions/00043-easy-exclude/README.md)
|
||||
|
||||
实现类型 `Exclude<T, U>`,返回 `T` 中不存在于 `U` 的部分。该功能主要用在联合类型场景,所以我们直接用 `extends` 判断就行了:
|
||||
|
||||
```ts
|
||||
// 本题答案
|
||||
type Exclude<T, U> = T extends U ? never : T
|
||||
```
|
||||
|
||||
实际运行效果:
|
||||
|
||||
```ts
|
||||
type C = Exclude<'a' | 'b', 'a' | 'c'> // 'b'
|
||||
```
|
||||
|
||||
看上去有点不那么好理解,这是因为 TS 对联合类型的执行是分配率的,即:
|
||||
|
||||
```ts
|
||||
Exclude<'a' | 'b', 'a' | 'c'>
|
||||
// 等价于
|
||||
Exclude<'a', 'a' | 'c'> | Exclude<'b', 'a' | 'c'>
|
||||
```
|
||||
|
||||
### [Awaited](https://github.com/type-challenges/type-challenges/blob/main/questions/00189-easy-awaited/README.md)
|
||||
|
||||
实现类型 `Awaited`,比如从 `Promise<ExampleType>` 拿到 `ExampleType`。
|
||||
|
||||
首先 TS 永远不会执行代码,所以脑子里不要有 “await 得等一下才知道结果” 的念头。该题关键就是从 `Promise<T>` 中抽取类型 `T`,很适合用 `infer` 做:
|
||||
|
||||
```ts
|
||||
type MyAwaited<T> = T extends Promise<infer U> ? U : never
|
||||
```
|
||||
|
||||
然而这个答案还不够标准,标准答案考虑了嵌套 `Promise` 的场景:
|
||||
|
||||
```ts
|
||||
// 该题答案
|
||||
type MyAwaited<T extends Promise<unknown>> = T extends Promise<infer P>
|
||||
? P extends Promise<unknown> ? MyAwaited<P> : P
|
||||
: never
|
||||
```
|
||||
|
||||
如果 `Promise<P>` 取到的 `P` 还形如 `Promise<unknown>`,就递归调用自己 `MyAwaited<P>`。这里提到了递归,也就是 TS 类型处理可以是递归的,所以才有了后面版本做尾递归优化。
|
||||
|
||||
### [If](https://github.com/type-challenges/type-challenges/blob/main/questions/00268-easy-if/README.md)
|
||||
|
||||
实现类型 `If<Condition, True, False>`,当 `C` 为 `true` 时返回 `T`,否则返回 `F`:
|
||||
|
||||
```ts
|
||||
type A = If<true, 'a', 'b'> // expected to be 'a'
|
||||
type B = If<false, 'a', 'b'> // expected to be 'b'
|
||||
```
|
||||
|
||||
之前有提过,`extends` 还可以用来判定值,所以果断用 `extends true` 判断是否命中了 `true` 即可:
|
||||
|
||||
```ts
|
||||
// 本题答案
|
||||
type If<C, T, F> = C extends true ? T : F
|
||||
```
|
||||
|
||||
### [Concat](https://github.com/type-challenges/type-challenges/blob/main/questions/00533-easy-concat/README.md)
|
||||
|
||||
用类型系统实现 `Concat<P, Q>`,将两个数组类型连起来:
|
||||
|
||||
```ts
|
||||
type Result = Concat<[1], [2]> // expected to be [1, 2]
|
||||
```
|
||||
|
||||
由于 TS 支持数组解构语法,所以可以大胆的尝试这么写:
|
||||
|
||||
```ts
|
||||
type Concat<P extends any[], Q extends any[]> = [...P, ...Q]
|
||||
```
|
||||
|
||||
考虑到 `Concat` 函数应该也能接收非数组类型,所以做一个判断,为了方便书写,把 `extends` 从泛型定义位置挪到 TS 类型推断的运行时:
|
||||
|
||||
```ts
|
||||
// 本题答案
|
||||
type Concat<P, Q> = [
|
||||
...P extends any[] ? P : [P],
|
||||
...Q extends any[] ? Q : [Q],
|
||||
]
|
||||
```
|
||||
|
||||
解决这题需要信念,相信 TS 可以像 JS 一样写逻辑。这些能力都是版本升级时渐进式提供的,所以需要不断阅读最新 TS 特性,快速将其理解为固化知识,其实还是有一定难度的。
|
||||
|
||||
### [Includes](https://github.com/type-challenges/type-challenges/blob/main/questions/00898-easy-includes/README.md)
|
||||
|
||||
用类型系统实现 `Includes<T, K>` 函数:
|
||||
|
||||
```ts
|
||||
type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'> // expected to be `false`
|
||||
```
|
||||
|
||||
由于之前的经验,很容易做下面的联想:
|
||||
|
||||
```ts
|
||||
// 如果题目要求是这样
|
||||
type isPillarMen = Includes<'Kars' | 'Esidisi' | 'Wamuu' | 'Santana', 'Dio'>
|
||||
// 那我就能用 extends 轻松解决了
|
||||
type Includes<T, K> = K extends T ? true : false
|
||||
```
|
||||
|
||||
可惜第一个输入是数组类型,`extends` 可不支持判定 “数组包含” 逻辑,此时要了解一个新知识点,即 TS 判断中的 `[number]` 下标。不仅这道题,以后很多困难题都需要它作为基础知识。
|
||||
|
||||
`[number]` 下标表示任意一项,而 `extends T[number]` 就可以实现数组包含的判定,因此下面的解法是有效的:
|
||||
|
||||
```ts
|
||||
type Includes<T extends any[], K> = K extends T[number] ? true : false
|
||||
```
|
||||
|
||||
但翻答案后发现这并不是标准答案,还真找到一个反例:
|
||||
|
||||
```ts
|
||||
type Includes<T extends any[], K> = K extends T[number] ? true : false
|
||||
type isPillarMen = Includes<[boolean], false> // true
|
||||
```
|
||||
|
||||
原因很简单,`true`、`false` 都继承自 `boolean`,所以 `extends` 判断的界限太宽了,题目要求的是精确值匹配,故上面的答案理论上是错的。
|
||||
|
||||
标准答案是每次判断数组第一项,并递归(讲真觉得这不是 easy 题),分别有两个难点。
|
||||
|
||||
第一如何写 Equal 函数?比较流行的方案是这个:
|
||||
|
||||
```ts
|
||||
type Equal<X, Y> =
|
||||
(<T>() => T extends X ? 1 : 2) extends
|
||||
(<T>() => T extends Y ? 1 : 2) ? true : false
|
||||
```
|
||||
|
||||
关于如何写 Equal 函数还引发了一次 [小讨论](https://github.com/microsoft/TypeScript/issues/27024#issuecomment-421529650),上面的代码构造了两个函数,这两个函数内的 `T` 属于 deferred(延迟)判断的类型,该类型判断依赖于内部 `isTypeIdenticalTo` 函数完成判断。
|
||||
|
||||
有了 `Equal` 后就简单了,我们用解构 + `infer` + 递归的方式做就可以了:
|
||||
|
||||
```ts
|
||||
// 本题答案
|
||||
type Includes<T extends any[], K> =
|
||||
T extends [infer F, ...infer Rest] ?
|
||||
Equal<F, K> extends true ?
|
||||
true
|
||||
: Includes<Rest, K>
|
||||
: false
|
||||
```
|
||||
|
||||
每次取数组第一个值判断 `Equal`,如果不匹配则拿剩余项递归判断。这个函数组合了不少 TS 知识,比如:
|
||||
|
||||
- 递归
|
||||
- 解构
|
||||
- `infer`
|
||||
- `extends true`
|
||||
|
||||
可以发现,就为了解决 `true extends boolean` 为 `true` 的问题,我们绕了一大圈使用了更复杂的方式来实现,这在 TS 体操中也算是常态,解决问题需要耐心。
|
||||
|
||||
|
||||
### [Push](https://github.com/type-challenges/type-challenges/blob/main/questions/03057-easy-push/README.md)
|
||||
|
||||
实现 `Push<T, K>` 函数:
|
||||
|
||||
```ts
|
||||
type Result = Push<[1, 2], '3'> // [1, 2, '3']
|
||||
```
|
||||
|
||||
这道题真的很简单,用解构就行了:
|
||||
|
||||
```ts
|
||||
// 本题答案
|
||||
type Push<T extends any[], K> = [...T, K]
|
||||
```
|
||||
|
||||
可见,想要轻松解决一个 TS 简单问题,首先你需要能解决一些困难问题 😁。
|
||||
|
||||
### [Unshift](https://github.com/type-challenges/type-challenges/blob/main/questions/03060-easy-unshift/README.md)
|
||||
|
||||
实现 `Unshift<T, K>` 函数:
|
||||
|
||||
```ts
|
||||
type Result = Unshift<[1, 2], 0> // [0, 1, 2,]
|
||||
```
|
||||
|
||||
在 `Push` 基础上改下顺序就行了:
|
||||
|
||||
```ts
|
||||
// 本题答案
|
||||
type Unshift<T extends any[], K> = [K, ...T]
|
||||
```
|
||||
|
||||
### [Parameters](https://github.com/type-challenges/type-challenges/blob/main/questions/03312-easy-parameters/README.md)
|
||||
|
||||
实现内置函数 `Parameters`:
|
||||
|
||||
`Parameters` 可以拿到函数的参数类型,直接用 `infer` 实现即可,也比较简单:
|
||||
|
||||
```ts
|
||||
type Parameters<T> = T extends (...args: infer P) => any ? P : []
|
||||
```
|
||||
|
||||
`infer` 可以很方便从任何具体的位置取值,属于典型难懂易用的语法。
|
||||
|
||||
## 总结
|
||||
|
||||
学会 TS 基础语法后,活用才是关键。
|
||||
|
||||
> 讨论地址是:[精读《type challenges - easy》· Issue #422 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/422)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
|
||||
|
||||
> 关注 **前端精读微信公众号**
|
||||
|
||||
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
|
||||
|
||||
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const fs = require("fs");
|
||||
|
||||
const dirs = [
|
||||
"前沿技术",
|
||||
"TS 类型体操",
|
||||
"设计模式",
|
||||
"编译原理",
|
||||
"源码解读",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
前端界的好文精读,每周更新!
|
||||
|
||||
最新精读:<a href="./前沿技术/239.%E7%B2%BE%E8%AF%BB%E3%80%8AJS%20%E6%95%B0%E7%BB%84%E7%9A%84%E5%86%85%E9%83%A8%E5%AE%9E%E7%8E%B0%E3%80%8B.md">239.精读《JS 数组的内部实现》</a>
|
||||
最新精读:<a href="./TS 类型体操/243.%E7%B2%BE%E8%AF%BB%E3%80%8Atype%20challenges%20-%20easy%E3%80%8B.md">243.精读《type challenges - easy》</a>
|
||||
|
||||
素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2)
|
||||
|
||||
@@ -189,6 +189,12 @@
|
||||
- <a href="./前沿技术/237.%E7%B2%BE%E8%AF%BB%E3%80%8ATypescript%204.5-4.6%20%E6%96%B0%E7%89%B9%E6%80%A7%E3%80%8B.md">237.精读《Typescript 4.5-4.6 新特性》</a>
|
||||
- <a href="./前沿技术/238.%E7%B2%BE%E8%AF%BB%E3%80%8A%E4%B8%8D%E5%86%8D%E9%9C%80%E8%A6%81%20JS%20%E5%81%9A%E7%9A%84%205%20%E4%BB%B6%E4%BA%8B%E3%80%8B.md">238.精读《不再需要 JS 做的 5 件事》</a>
|
||||
- <a href="./前沿技术/239.%E7%B2%BE%E8%AF%BB%E3%80%8AJS%20%E6%95%B0%E7%BB%84%E7%9A%84%E5%86%85%E9%83%A8%E5%AE%9E%E7%8E%B0%E3%80%8B.md">239.精读《JS 数组的内部实现》</a>
|
||||
- <a href="./前沿技术/240.%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20useEvent%20RFC%E3%80%8B.md">240.精读《React useEvent RFC》</a>
|
||||
- <a href="./前沿技术/242.%E7%B2%BE%E8%AF%BB%E3%80%8Aweb%20reflow%E3%80%8B.md">242.精读《web reflow》</a>
|
||||
|
||||
### TS 类型体操
|
||||
|
||||
- <a href="./TS 类型体操/243.%E7%B2%BE%E8%AF%BB%E3%80%8Atype%20challenges%20-%20easy%E3%80%8B.md">243.精读《type challenges - easy》</a>
|
||||
|
||||
### 设计模式
|
||||
|
||||
@@ -245,6 +251,7 @@
|
||||
- <a href="./源码解读/156.%20%E7%B2%BE%E8%AF%BB%E3%80%8Areact-intersection-observer%20%E6%BA%90%E7%A0%81%E3%80%8B.md">156. 精读《react-intersection-observer 源码》</a>
|
||||
- <a href="./源码解读/227.%20%E7%B2%BE%E8%AF%BB%E3%80%8Azustand%20%E6%BA%90%E7%A0%81%E3%80%8B.md">227. 精读《zustand 源码》</a>
|
||||
- <a href="./源码解读/229.%E7%B2%BE%E8%AF%BB%E3%80%8Avue-lit%20%E6%BA%90%E7%A0%81%E3%80%8B.md">229.精读《vue-lit 源码》</a>
|
||||
- <a href="./源码解读/241.%E7%B2%BE%E8%AF%BB%E3%80%8Areact-snippets%20-%20Router%20%E6%BA%90%E7%A0%81%E3%80%8B.md">241.精读《react-snippets - Router 源码》</a>
|
||||
|
||||
### 商业思考
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ LayoutTree 和 DOM 结构很像了,但比如 `display: none` 的元素不会
|
||||
|
||||
### 从渲染分层看性能优化
|
||||
|
||||
本篇提到了浏览器渲染的 5 个重要环节:解析、样式、布局、绘图、合成,是前端开发者日常工作中对浏览器体感最深的部分,也是优化最长发生在的部分。
|
||||
本篇提到了浏览器渲染的 5 个重要环节:解析、样式、布局、绘图、合成,是前端开发者日常工作中对浏览器体感最深的部分,也是优化最常发生在的部分。
|
||||
|
||||
其实从性能优化角度来看,解析环节可以被替代为 JS 环节,因为现代 JS 框架往往没有什么 HTML 模版内容要解析,几乎全是 JS 操作 DOM,所以可以看作 5 个新环节:JS、样式、布局、绘图、合成。
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
第一名 [next.js](https://github.com/vercel/next.js) 在整体榜单里了,在 Node 框架一骑绝尘。
|
||||
|
||||
第二名 [nest](https://github.com/nestjs/nest) 和 next.js 很像,据我当时的了解,是因为 next.js 起步较慢,源码还不支持 ts,所以就有了这个更时髦的新框架。但实际上 next.js 早就全部改为 ts 了,而且正如整体榜单所说,现在已经开始引领潮流了,所以不怪 nest 定位重合,只能怪 next.js 后续发力太猛了。nest 的唯一特点就是没有绑定 UI 库。
|
||||
第二名 [nest](https://github.com/nestjs/nest) 是一个 node 版 server 框架,支持传统的 Controller、Module、Service,支持用装饰器申明路由、控制器等,语法上比较时髦。
|
||||
|
||||
第三名 [Strapi](https://github.com/strapi/strapi) 专门为 API 场景服务,提供了一个 API 管理后台,解决了只需要一个便捷 API 管理,而不希望了解一个大而全的后端框架的痛点。
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
useEvent 要解决一个问题:如何同时保持函数引用不变与访问到最新状态。
|
||||
|
||||
本周我们结合 [RFC](https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md) 原文与解读文章 [What the useEvent React hook is (and isn't)](https://typeofnan.dev/what-the-useevent-react-hook-is-and-isnt/) 一起了解下这个提案。
|
||||
|
||||
借用提案里的代码,一下就能说清楚 `useEvent` 是个什么东西:
|
||||
|
||||
```ts
|
||||
function Chat() {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
// ✅ Always the same function (even if `text` changes)
|
||||
const onClick = useEvent(() => {
|
||||
sendMessage(text);
|
||||
});
|
||||
|
||||
return <SendButton onClick={onClick} />;
|
||||
}
|
||||
```
|
||||
|
||||
`onClick` 既保持引用不变,又能在每次触发时访问到最新的 `text` 值。
|
||||
|
||||
为什么要提供这个函数,它解决了什么问题,在概述里慢慢道来。
|
||||
|
||||
## 概述
|
||||
|
||||
定义一个访问到最新 state 的函数不是什么难事:
|
||||
|
||||
```ts
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
const sayCount = () => {
|
||||
console.log(count)
|
||||
}
|
||||
|
||||
return <Child onClick={sayCount} />
|
||||
}
|
||||
```
|
||||
|
||||
但 `sayCount` 函数引用每次都会变化,这会直接破坏 `Child` 组件 memo 效果,甚至会引发其更严重的连锁反应(`Child` 组件将 `onClick` 回调用在 `useEffect` 里时)。
|
||||
|
||||
想要保证 `sayCount` 引用不变,我们就需要用 `useCallback` 包裹:
|
||||
|
||||
```ts
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
const sayCount = useCallback(() => {
|
||||
console.log(count)
|
||||
}, [count])
|
||||
|
||||
return <Child onClick={sayCount} />
|
||||
}
|
||||
```
|
||||
|
||||
但即便如此,我们仅能保证在 `count` 不变时,`sayCount` 引用不变。如果想保持 `sayCount` 引用稳定,就要把依赖 `[count]` 移除,这会导致访问到的 `count` 总是初始值,逻辑上引发了更大问题。
|
||||
|
||||
一种无奈的办法是,维护一个 countRef,使其值与 count 保持同步,在 `sayCount` 中访问 `countRef`:
|
||||
|
||||
```ts
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const countRef = React.useRef()
|
||||
countRef.current = count
|
||||
|
||||
const sayCount = useCallback(() => {
|
||||
console.log(countRef.current)
|
||||
}, [])
|
||||
|
||||
return <Child onClick={sayCount} />
|
||||
}
|
||||
```
|
||||
|
||||
这种代码能解决问题,但绝对不推荐,原因有二:
|
||||
|
||||
1. 每个值都要加一个配套 Ref,非常冗余。
|
||||
2. 在函数内直接同步更新 ref 不是一个好主意,但写在 `useEffect` 里又太麻烦。
|
||||
|
||||
另一种办法就是自创 hook,如 `useStableCallback`,这本质上就是这次提案的主角 - `useEvent`:
|
||||
|
||||
```ts
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
const sayCount = useEvent(() => {
|
||||
console.log(count)
|
||||
})
|
||||
|
||||
return <Child onClick={sayCount} />
|
||||
}
|
||||
```
|
||||
|
||||
所以 `useEvent` 的内部实现很可能类似于自定义 hook `useStableCallback`。在提案内也给出了可能的实现思路:
|
||||
|
||||
```ts
|
||||
// (!) Approximate behavior
|
||||
function useEvent(handler) {
|
||||
const handlerRef = useRef(null);
|
||||
|
||||
// In a real implementation, this would run before layout effects
|
||||
useLayoutEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
});
|
||||
|
||||
return useCallback((...args) => {
|
||||
// In a real implementation, this would throw if called during render
|
||||
const fn = handlerRef.current;
|
||||
return fn(...args);
|
||||
}, []);
|
||||
}
|
||||
```
|
||||
|
||||
其实很好理解,我们将需求一分为二看:
|
||||
|
||||
1. 既然要返回一个稳定引用,那最后返回的函数一定使用 `useCallback` 并将依赖数组置为 `[]`。
|
||||
2. 又要在函数执行时访问到最新值,那么每次都要拿最新函数来执行,所以在 Hook 里使用 Ref 存储每次接收到的最新函数引用,在执行函数时,实际上执行的是最新的函数引用。
|
||||
|
||||
注意两段注释,第一个是 `useLayoutEffect` 部分实际上要比 `layoutEffect` 执行时机更提前,这是为了保证函数在一个事件循环中被直接消费时,不可能访问到旧的 Ref 值;第二个是在渲染时被调用时要抛出异常,这是为了避免 `useEvent` 函数被渲染时使用,因为这样就无法数据驱动了。
|
||||
|
||||
## 精读
|
||||
|
||||
其实 `useEvent` 概念和实现都很简单,下面我们聊聊提案里一些有意思的细节吧。
|
||||
|
||||
### 为什么命名为 useEvent
|
||||
|
||||
提案里提到,如果不考虑名称长短,完全用功能来命名的话,`useStableCallback` 或 `useCommittedCallback` 会更加合适,都表示拿到一个稳定的回调函数。但 `useEvent` 是从使用者角度来命名的,即其生成的函数一般都被用于组件的回调函数,而这些回调函数一般都有 “事件特性”,比如 `onClick`、`onScroll`,所以当开发者看到 `useEvent` 时,可以下意识提醒自己在写一个事件回调,还算比较直观。(当然我觉得主要原因还是为了缩短名称,好记)
|
||||
|
||||
### 值并不是真正意义上的实时
|
||||
|
||||
虽然 `useEvent` 可以拿到最新值,但和 `useCallback` 拿 `ref` 还是有区别的,这个差异体现在:
|
||||
|
||||
```ts
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
const sayCount = useEvent(async () => {
|
||||
console.log(count)
|
||||
await wait(1000)
|
||||
console.log(count)
|
||||
})
|
||||
|
||||
return <Child onClick={sayCount} />
|
||||
}
|
||||
```
|
||||
|
||||
`await` 前后输出值一定是一样的,在实现上,`count` 值仅是调用时的快照,所以函数内异步等待时,即便外部又把 `count` 改了,当前这次函数调用还是拿不到最新的 `count`,而 `ref` 方法是可以的。在理解上,为了避免夜长梦多,回调函数尽量不要写成异步的。
|
||||
|
||||
### useEvent 也救不了手残
|
||||
|
||||
如果你坚持写出 `onSomething={cond ? handler1 : handler2}` 这样的代码,那么 `cond` 变化后,传下去的函数引用也一定会变化,这是 `useEvent` 无论如何也避免不了的,也许解救方案是 Lint and throw error。
|
||||
|
||||
其实将 `cond ? handler1 : handler2` 作为一个整体包裹在 `useEvent` 就能解决引用变化的问题,但除了 Lint,没有人能防止你绕过它。
|
||||
|
||||
### 可以用自定义 hook 代替 useEvent 实现吗?
|
||||
|
||||
不能。虽然提案里给了一个近似解决方案,但实际上存在两个问题:
|
||||
|
||||
1. 在赋值 ref 时,`useLayoutEffect` 时机依然不够提前,如果值变化后立即访问函数,拿到的会是旧值。
|
||||
2. 子组件 layout effect 在父组件之前执行,拿到的也是旧值。
|
||||
3. 生成的函数被用在渲染并不会给出错误提示。
|
||||
|
||||
## 总结
|
||||
|
||||
`useEvent` 显然又给 React 增加了一个官方概念,在结结实实增加了理解成本的同时,也补齐了 React Hooks 在实践中缺失的重要一环,无论你喜不喜欢,问题就在那,解法也给了,挺好。
|
||||
|
||||
> 讨论地址是:[精读《React useEvent RFC》· Issue #415 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/415)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
|
||||
|
||||
> 关注 **前端精读微信公众号**
|
||||
|
||||
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
|
||||
|
||||
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh))
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
网页重排(回流)是阻碍流畅性的重要原因之一,结合 [What forces layout / reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) 这篇文章与引用,整理一下回流的起因与优化思考。
|
||||
|
||||
借用这张经典图:
|
||||
|
||||
<img width=500 src="https://s1.ax1x.com/2022/05/28/XKCCZ9.png">
|
||||
|
||||
网页渲染会经历 DOM -> CSSOM -> Layout(重排 or reflow) -> Paint(重绘) -> Composite(合成),其中 Composite 在 [精读《深入了解现代浏览器四》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/222.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%B7%B1%E5%85%A5%E4%BA%86%E8%A7%A3%E7%8E%B0%E4%BB%A3%E6%B5%8F%E8%A7%88%E5%99%A8%E5%9B%9B%E3%80%8B.md) 详细介绍过,是在 GPU 进行光栅化。
|
||||
|
||||
那么排除 JS、DOM、CSSOM、Composite 可能导致的性能问题外,剩下的就是我们这次关注的重点,reflow 了。从顺序上可以看出来,重排后一定重绘,而重绘不一定触发重排。
|
||||
|
||||
## 概述
|
||||
|
||||
什么时候会触发 Layout(reflow) 呢?一般来说,当元素位置发生变化时就会。但也不尽然,因为浏览器会自动合并更改,在达到某个数量或时间后,会合并为一次 reflow,而 reflow 是渲染页面的重要一步,打开浏览器就一定会至少 reflow 一次,所以我们不可能避免 reflow。
|
||||
|
||||
那为什么要注意 reflow 导致的性能问题呢?这是因为某些代码可能导致浏览器优化失效,即明明能合并 reflow 时没有合并,这一般出现在我们用 js API 访问某个元素尺寸时,为了保证拿到的是精确值,不得不提前触发一次 reflow,即便写在 for 循环里。
|
||||
|
||||
当然也不是每次访问元素位置都会触发 reflow,在浏览器触发 reflow 后,所有已有元素位置都会记录快照,只要不再触发位置等变化,第二次开始访问位置就不会触发 reflow,关于这一点会在后面详细展开。现在要解释的是,这个 ”触发位置等变化“,到底有哪些?
|
||||
|
||||
根据 [What forces layout / reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) 文档的总结,一共有这么几类:
|
||||
|
||||
### 获得盒子模型信息
|
||||
|
||||
- `elem.offsetLeft`, `elem.offsetTop`, `elem.offsetWidth`, `elem.offsetHeight`, `elem.offsetParent`
|
||||
- `elem.clientLeft`, `elem.clientTop`, `elem.clientWidth`, `elem.clientHeight`
|
||||
- `elem.getClientRects()`, `elem.getBoundingClientRect()`
|
||||
|
||||
获取元素位置、宽高的一些手段都会导致 reflow,不存在绕过一说,因为只要获取这些信息,都必须 reflow 才能给出准确的值。
|
||||
|
||||
### 滚动
|
||||
|
||||
- `elem.scrollBy()`, `elem.scrollTo()`
|
||||
- `elem.scrollIntoView()`, `elem.scrollIntoViewIfNeeded()`
|
||||
- `elem.scrollWidth`, `elem.scrollHeight`
|
||||
- `elem.scrollLeft`, `elem.scrollTop` 访问及赋值
|
||||
|
||||
对 `scrollLeft` 赋值等价于触发 `scrollTo`,所有导致滚动产生的行为都会触发 reflow,笔者查了一些资料,目前主要推测是滚动条出现会导致可视区域变窄,所以需要 reflow。
|
||||
|
||||
### focus()
|
||||
|
||||
- `elem.focus()` ([源码](https://source.chromium.org/chromium/chromium/src/+/master:third_party/blink/renderer/core/dom/element.cc;l=4206-4225;drc=d685ea3c9ffcb18c781bc3a0bdbb92eb88842b1b))
|
||||
|
||||
可以根据源码看一下注释,主要是这一段:
|
||||
|
||||
```c++
|
||||
// Ensure we have clean style (including forced display locks).
|
||||
GetDocument().UpdateStyleAndLayoutTreeForNode(this)
|
||||
```
|
||||
|
||||
即在聚焦元素时,虽然没有拿元素位置信息的诉求,但指不定要被聚焦的元素被隐藏或者移除了,此时必须调用 `UpdateStyleAndLayoutTreeForNode` 重排重绘函数,确保元素状态更新后才能继续操作。
|
||||
|
||||
还有一些其他 element API:
|
||||
|
||||
- `elem.computedRole`, `elem.computedName`
|
||||
- `elem.innerText` ([源码](https://source.chromium.org/chromium/chromium/src/+/master:third_party/blink/renderer/core/editing/element_inner_text.cc;l=462-468;drc=d685ea3c9ffcb18c781bc3a0bdbb92eb88842b1b))
|
||||
|
||||
`innerText` 也需要重排后才能拿到正确内容。
|
||||
|
||||
### 获取 window 信息
|
||||
|
||||
- `window.scrollX`, `window.scrollY`
|
||||
- `window.innerHeight`, `window.innerWidth`
|
||||
- `window.visualViewport.height` / `width` / `offsetTop` / `offsetLeft` ([源码](https://source.chromium.org/chromium/chromium/src/+/master:third_party/blink/renderer/core/frame/visual_viewport.cc;l=435-461;drc=a3c165458e524bdc55db15d2a5714bb9a0c69c70?originalUrl=https:%2F%2Fcs.chromium.org%2F))
|
||||
|
||||
和元素级别一样,为了拿到正确宽高和位置信息,必须重排。
|
||||
|
||||
### document 相关
|
||||
|
||||
- `document.scrollingElement` 仅重绘
|
||||
- `document.elementFromPoint`
|
||||
|
||||
`elementFromPoint` 因为要拿到精确位置的元素,必须重排。
|
||||
|
||||
### Form 相关
|
||||
|
||||
- `inputElem.focus()`
|
||||
- `inputElem.select()`, `textareaElem.select()`
|
||||
|
||||
`focus`、`select` 触发重排的原因和 `elem.focus` 类似。
|
||||
|
||||
### 鼠标事件相关
|
||||
|
||||
- `mouseEvt.layerX`, `mouseEvt.layerY`, `mouseEvt.offsetX`, `mouseEvt.offsetY` ([源码](https://source.chromium.org/chromium/chromium/src/+/master:third_party/blink/renderer/core/events/mouse_event.cc;l=476-487;drc=52fd700fb07a43b740d24595d42d8a6a57a43f81))
|
||||
|
||||
鼠标相关位置计算,必须依赖一个正确的排布,所以必须触发 reflow。
|
||||
|
||||
### getComputedStyle
|
||||
|
||||
`getComputedStyle` 通常会导致重排和重绘,是否触发重排取决于是否访问了位置相关的 key 等因素。
|
||||
|
||||
### Range 相关
|
||||
|
||||
- `range.getClientRects()`, `range.getBoundingClientRect()`
|
||||
|
||||
获取选中区域的大小,必须 reflow 才能保障精确性。
|
||||
|
||||
### SVG
|
||||
|
||||
大量 SVG 方法会引发重排,就不一一枚举了,总之使用 SVG 操作时也要像操作 dom 一样谨慎。
|
||||
|
||||
### contenteditable
|
||||
|
||||
被设置为 `contenteditable` 的元素内,包括将图像复制到剪贴板在内,大量操作都会导致重排。([源码](https://source.chromium.org/search?q=UpdateStyleAndLayout%20-f:test&ss=chromium%2Fchromium%2Fsrc:third_party%2Fblink%2Frenderer%2Fcore%2Fediting%2F))
|
||||
|
||||
## 精读
|
||||
|
||||
[What forces layout / reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) 下面引用了几篇关于 reflow 的相关文章,笔者挑几个重要的总结一下。
|
||||
|
||||
### repaint-reflow-restyle
|
||||
|
||||
[repaint-reflow-restyle](http://www.phpied.com/rendering-repaint-reflowrelayout-restyle/) 提到现代浏览器会将多次 dom 操作合并,但像 IE 等其他内核浏览器就不保证有这样的实现了,因此给出了一个安全写法:
|
||||
|
||||
```js
|
||||
// bad
|
||||
var left = 10,
|
||||
top = 10;
|
||||
el.style.left = left + "px";
|
||||
el.style.top = top + "px";
|
||||
|
||||
// better
|
||||
el.className += " theclassname";
|
||||
|
||||
// or when top and left are calculated dynamically...
|
||||
|
||||
// better
|
||||
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
|
||||
```
|
||||
|
||||
比如用一次 className 的修改,或一次 `cssText` 的修改保证浏览器一定触发一次重排。但这样可维护性会降低很多,不太推荐。
|
||||
|
||||
### avoid large complex layouts
|
||||
|
||||
[avoid large complex layouts](https://web.dev/avoid-large-complex-layouts-and-layout-thrashing/) 重点强调了读写分离,首先看下面的 bad case:
|
||||
|
||||
```js
|
||||
function resizeAllParagraphsToMatchBlockWidth() {
|
||||
// Puts the browser into a read-write-read-write cycle.
|
||||
for (var i = 0; i < paragraphs.length; i++) {
|
||||
paragraphs[i].style.width = box.offsetWidth + 'px';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 for 循环中不断访问元素宽度,并修改其宽度,会导致浏览器执行 N 次 reflow。
|
||||
|
||||
虽然当 JavaScript 运行时,前一帧中的所有旧布局值都是已知的,但当你对布局做了修改后,前一帧所有布局值缓存都会作废,因此当下次获取值时,不得不重新触发一次 reflow。
|
||||
|
||||
而读写分离的话,就代表了集中读,虽然读的次数还是那么多,但从第二次开始就可以从布局缓存中拿数据,不用触发 reflow 了。
|
||||
|
||||
另外还提到 flex 布局比传统 float 重排速度快很多(3ms vs 16ms),所以能用 flex 做的布局就尽量不要用 float 做。
|
||||
|
||||
### really fixing layout thrashing
|
||||
|
||||
[really fixing layout thrashing](https://mattandre.ws/2014/05/really-fixing-layout-thrashing/) 提到了用 [fastdom](https://github.com/wilsonpage/fastdom) 实践读写分离:
|
||||
|
||||
```js
|
||||
ids.forEach(id => {
|
||||
fastdom.measure(() => {
|
||||
const top = elements[id].offsetTop
|
||||
fastdom.mutate(() => {
|
||||
elements[id].setLeft(top)
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
`fastdom` 是一个可以在不分离代码的情况下,分离读写执行的库,尤其适合用在 reflow 性能优化场景。每一个 `measure`、`mutate` 都会推入执行队列,并在 [window.requestAnimationFrame](https://developer.mozilla.org/en-US/docs/web/api/window/requestanimationframe) 时机执行。
|
||||
|
||||
## 总结
|
||||
|
||||
回流无法避免,但需要控制在正常频率范围内。
|
||||
|
||||
我们需要学习访问哪些属性或方法会导致回流,能不使用就不要用,尽量做到读写分离。在定义要频繁触发回流的元素时,尽量使其脱离文档流,减少回流产生的影响。
|
||||
|
||||
> 讨论地址是:[精读《web reflow》· Issue #420 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/420)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
|
||||
|
||||
> 关注 **前端精读微信公众号**
|
||||
|
||||
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
|
||||
|
||||
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh))
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
造轮子就是应用核心原理 + 周边功能的堆砌,所以学习成熟库的源码往往会受到非核心代码干扰,[Router](https://github.com/ashok-khanna/react-snippets/blob/main/Router.js) 这个 repo 用不到 100 行源码实现了 React Router 核心机制,很适合用来学习。
|
||||
|
||||
## 精读
|
||||
|
||||
[Router](https://github.com/ashok-khanna/react-snippets/blob/main/Router.js) 快速实现了 React Router 3 个核心 API:`Router`、`navigate`、`Link`,下面列出基本用法,配合理解源码实现会更方便:
|
||||
|
||||
```tsx
|
||||
const App = () => (
|
||||
<Router
|
||||
routes={[
|
||||
{ path: '/home', component: <Home /> },
|
||||
{ path: '/articles', component: <Articles /> }
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
const Home = () => (
|
||||
<div>
|
||||
home, <Link href="/articles">go articles</Link>,
|
||||
<span onClick={() => navigate('/details')}>or jump to details</span>
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
首先看 `Router` 的实现,在看代码之前,思考下 `Router` 要做哪些事情?
|
||||
|
||||
- 接收 routes 参数,根据当前 url 地址判断渲染哪个组件。
|
||||
- 当 url 地址变化时(无论是用户触发还是自己的 `navigate` `Link` 触发),渲染新 url 对应的组件。
|
||||
|
||||
所以 `Router` 是一个路由渲染分配器与 url 监听器:
|
||||
|
||||
```tsx
|
||||
export default function Router ({ routes }) {
|
||||
// 存储当前 url path,方便其变化时引发自身重渲染,以返回新的 url 对应的组件
|
||||
const [currentPath, setCurrentPath] = useState(window.location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
const onLocationChange = () => {
|
||||
// 将 url path 更新到当前数据流中,触发自身重渲染
|
||||
setCurrentPath(window.location.pathname);
|
||||
}
|
||||
|
||||
// 监听 popstate 事件,该事件由用户点击浏览器前进/后退时触发
|
||||
window.addEventListener('popstate', onLocationChange);
|
||||
|
||||
return () => window.removeEventListener('popstate', onLocationChange)
|
||||
}, [])
|
||||
|
||||
// 找到匹配当前 url 路径的组件并渲染
|
||||
return routes.find(({ path, component }) => path === currentPath)?.component
|
||||
}
|
||||
```
|
||||
|
||||
最后一段代码看似每次都执行 `find` 有一定性能损耗,但其实根据 `Router` 一般在最根节点的特性,该函数很少因父组件重渲染而触发渲染,所以性能不用太担心。
|
||||
|
||||
但如果考虑做一个完整的 React Router 组件库,考虑了更复杂的嵌套 API,即 `Router` 套 `Router` 后,不仅监听方式要变化,还需要将命中的组件缓存下来,需要考虑的点会逐渐变多。
|
||||
|
||||
下面该实现 `navigate` `Link` 了,他俩做的事情都是跳转,有如下区别:
|
||||
|
||||
1. API 调用方式不同,`navigate` 是调用式函数,而 `Link` 是一个内置 `navigate` 能力的 `a` 标签。
|
||||
2. `Link` 其实还有一种按住 `ctrl` 后打开新 tab 的跳转模式,该模式由浏览器对 `a` 标签默认行为完成。
|
||||
|
||||
所以 `Link` 更复杂一些,我们先实现 `navigate`,再实现 `Link` 时就可以复用它了。
|
||||
|
||||
既然 `Router` 已经监听 `popstate` 事件,我们显然想到的是触发 url 变化后,让 `popstate` 捕获,自动触发后续跳转逻辑。但可惜的是,我们要做的 React Router 需要实现单页跳转逻辑,而单页跳转的 API `history.pushState` 并不会触发 `popstate`,为了让实现更优雅,我们可以在 `pushState` 后手动触发 `popstate` 事件,如源码所示:
|
||||
|
||||
```tsx
|
||||
export function navigate (href) {
|
||||
// 用 pushState 直接刷新 url,而不触发真正的浏览器跳转
|
||||
window.history.pushState({}, "", href);
|
||||
|
||||
// 手动触发一次 popstate,让 Route 组件监听并触发 onLocationChange
|
||||
const navEvent = new PopStateEvent('popstate');
|
||||
window.dispatchEvent(navEvent);
|
||||
}
|
||||
```
|
||||
|
||||
接下来实现 `Link` 就很简单了,有几个考虑点:
|
||||
|
||||
1. 返回一个正常的 `<a>` 标签。
|
||||
2. 因为正常 `<a>` 点击后就发生网页刷新而不是单页跳转,所以点击时要阻止默认行为,换成我们的 `navigate`(源码里没做这个抽象,笔者稍微优化了下)。
|
||||
3. 但按住 `ctrl` 时又要打开新 tab,此时用默认 `<a>` 标签行为就行,所以此时不要阻止默认行为,也不要继续执行 `navigate`,因为这个 url 变化不会作用于当前 tab。
|
||||
|
||||
```tsx
|
||||
export function Link ({ className, href, children }) {
|
||||
const onClick = (event) => {
|
||||
// mac 的 meta or windows 的 ctrl 都会打开新 tab
|
||||
// 所以此时不做定制处理,直接 return 用原生行为即可
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 否则禁用原生跳转
|
||||
event.preventDefault();
|
||||
|
||||
// 做一次单页跳转
|
||||
navigate(href)
|
||||
};
|
||||
|
||||
return (
|
||||
<a className={className} href={href} onClick={onClick}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
这样的设计,既能兼顾 `<a>` 标签默认行为,又能在点击时优化为单页跳转,里面对 `preventDefault` 与 `metaKey` 的判断值得学习。
|
||||
|
||||
## 总结
|
||||
|
||||
从这个小轮子中可以学习到一下几个经验:
|
||||
|
||||
- 造轮子之前先想好使用 API,根据使用 API 反推实现,会让你的设计更有全局观。
|
||||
- 实现 API 时,先思考 API 之间的关系,能复用的就提前设计好复用关系,这样巧妙的关联设计能为以后维护减少很多麻烦。
|
||||
- 即便代码无法复用的地方,也要尽量做到逻辑复用。比如 `pushState` 无法触发 `popstate` 那段,直接把 `popstate` 代码复用过来,或者自己造一个状态沟通就太 low 了,用浏览器 API 模拟事件触发,既轻量,又符合逻辑,因为你要做的就是触发 `popstate` 行为,而非只是更新渲染组件这个动作,万一以后再有监听 `popstate` 的地方,你的触发逻辑就能很自然的应用到那儿。
|
||||
- 尽量在原生能力上拓展,而不是用自定义方法补齐原生能力。比如 `Link` 的实现是基于 `<a>` 标签拓展的,如果采用自定义 `<span>` 标签,不仅要补齐样式上的差异,还要自己实现 `ctrl` 后打开新 tab 的行为,甚至 `<a>` 默认访问记录行为你也得花高成本补上,所以错误的设计方向会导致事倍功半,甚至无法实现。
|
||||
|
||||
> 讨论地址是:[精读《react-snippets - Router 源码》· Issue #418 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/418)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
|
||||
|
||||
> 关注 **前端精读微信公众号**
|
||||
|
||||
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
|
||||
|
||||
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user