Compare commits

...
32 Commits
Author SHA1 Message Date
ascoders cf2a01dd0b update 2022-06-06 09:42:13 +08:00
ascoders c2897a2385 Merge branch 'master' of https://github.com/ascoders/weekly 2022-06-06 09:40:11 +08:00
ascoders 08ea922317 243 2022-06-06 09:40:07 +08:00
黄子毅 fa1e19c58f Merge pull request #421 from xuzhanhh/patch-1
fix: typo
2022-06-01 21:23:28 +08:00
xuzhanhh 444e4daeec fix: typo 2022-05-30 11:05:55 +08:00
ascoders a95ae5ac5a Merge branch 'master' of https://github.com/ascoders/weekly 2022-05-30 10:18:36 +08:00
ascoders 0fa01c716b 242 2022-05-30 10:18:29 +08:00
黄子毅 ef97b66383 Merge pull request #419 from reactjser/patch-1
Update 241.精读《react-snippets - Router 源码》.md
2022-05-24 10:27:40 +08:00
reactjser 0ca68bd61f Update 241.精读《react-snippets - Router 源码》.md
成语使用有误
2022-05-23 13:53:19 +08:00
ascoders 33e9a77a1b 241 2022-05-23 09:20:56 +08:00
ascoders fb492c7c6b Merge branch 'master' of https://github.com/ascoders/weekly 2022-05-18 14:16:08 +08:00
ascoders dd0f3b3d8b fix: typo 2022-05-18 14:16:05 +08:00
黄子毅 b68d83f282 Merge pull request #417 from kongmoumou/patch-2
Update 240.精读《React useEvent RFC》.md
2022-05-17 20:44:37 +08:00
kongmoumou 663eb89301 Update 240.精读《React useEvent RFC》.md 2022-05-16 14:52:42 +08:00
ascoders 01d658e744 240 2022-05-16 09:14:17 +08:00
ascoders 36349d365d fix: 修复 nestjs 描述 2022-05-12 23:38:36 +08:00
ascoders 61bbf1db05 239 2022-05-09 09:26:58 +08:00
ascoders 688337f7c4 Merge branch 'master' of https://github.com/ascoders/weekly 2022-04-18 09:54:36 +08:00
ascoders 8ab24fca04 238 2022-04-18 09:52:43 +08:00
黄子毅 e043a91f07 Merge pull request #412 from Trojan0523/patch-1
fix: typo
2022-04-16 17:23:21 +08:00
trojan0523 448544786b fix: typo 2022-04-16 16:46:57 +08:00
黄子毅 f5859dbe08 Merge pull request #411 from FrankKai/master
fix: typo in 144.精读《Webpack5 新特性 - 模块联邦》
2022-04-16 15:17:00 +08:00
frankkai c83a2cd983 fix: typo in 144.精读《Webpack5 新特性 - 模块联邦》 2022-04-15 16:28:09 +08:00
黄子毅 b3c5ad948b Merge pull request #409 from careteenL/patch-2
fix: typo in 精读《Typescript 4.5-4.6 新特性》
2022-04-11 10:58:36 +08:00
Careteen a0cd51951e fix: typo in 精读《Typescript 4.5-4.6 新特性》 2022-04-11 09:37:04 +08:00
ascoders 0198bcc55b Merge branch 'master' of https://github.com/ascoders/weekly 2022-04-11 09:17:47 +08:00
ascoders 35d659397a 237 2022-04-11 09:16:30 +08:00
黄子毅 b08b26b691 Merge pull request #407 from october-rain/shiyu-fix
fix: spelling mistake in 167
2022-04-06 09:38:44 +08:00
ascoders 4b56ab4ed6 236 2022-04-06 09:34:09 +08:00
october-rain 5bfd19a828 fix: spelling mistake in 167 2022-04-05 20:54:13 +08:00
ascoders 5e75ab4be0 235 2022-03-28 10:13:58 +08:00
ascoders 7f1ec4b1c6 fix typo 2022-03-23 14:50:19 +08:00
16 changed files with 2158 additions and 8 deletions
+3 -3
View File
@@ -96,7 +96,7 @@ SELECT MAX(cost), MIN(cost), id FROM test -- id: 1
举个例子,查询每个国家的 GDP 总量:
```sql
SELECT COUNT(GDP) FROM amazing_table
SELECT SUM(GDP) FROM amazing_table
GROUP BY country
```
@@ -105,10 +105,10 @@ GROUP BY country
其实如果我们只想看中、美的 GDP,用非分组也可以查,只是要分成两条 SQL:
```sql
SELECT COUNT(GDP) FROM amazing_table
SELECT SUM(GDP) FROM amazing_table
WHERE country = '中国'
SELECT COUNT(GDP) FROM amazing_table
SELECT SUM(GDP) FROM amazing_table
WHERE country = '美国'
```
+124
View File
@@ -0,0 +1,124 @@
窗口函数形如:
```sql
OVER (PARTITION BY ORDER BY )
```
有两个能力:
1. 当表达式为 `rank()` `dense_rank()` `row_number()` 时,拥有分组排序能力。
2. 当表达式为 `sum()` 等聚合函数时,拥有累计聚合能力。
无论何种能力,**窗口函数都不会影响数据行数,而是将计算平摊在每一行**。
这两种能力需要区分理解。
## 底表
<img width=500 src="https://s1.ax1x.com/2022/03/26/qdUL60.png">
以上是示例底表,共有 8 条数据,城市1、城市2 两个城市,下面各有地区1~4,每条数据都有该数据的人口数。
## 分组排序
如果按照人口排序,`ORDER BY people` 就行了,但如果我们想在城市内排序怎么办?
此时就要用到窗口函数的分组排序能力:
<img width=500 src="https://s1.ax1x.com/2022/03/26/qddfIg.png">
```sql
SELECT *, rank() over (PARTITION BY city ORDER BY people) FROM test
```
该 SQL 表示在 city 组内按照 people 进行排序。
其实 PARTITION BY 也是可选的,如果我们忽略它:
```sql
SELECT *, rank() over (ORDER BY people) FROM test
```
也是生效的,但该语句与普通 ORDER BY 等价,因此利用窗口函数进行分组排序时,一般都会使用 PARTITION BY。
### 各分组排序函数的差异
我们将 `rank()` `dense_rank()` `row_number()` 的结果都打印出来:
```sql
SELECT *,
rank() over (PARTITION BY city ORDER BY people),
dense_rank() over (PARTITION BY city ORDER BY people),
row_number() over (PARTITION BY city ORDER BY people)
FROM test
```
<img width=500 src="https://s1.ax1x.com/2022/03/26/qd0uh4.png">
其实从结果就可以猜到,这三个函数在处理排序遇到相同值时,对排名统计逻辑有如下差异:
1. `rank()`: 值相同时排名相同,但占用排名数字。
2. `dense_rank()`: 值相同时排名相同,但不占用排名数字,整体排名更加紧凑。
3. `row_number()`: 无论值是否相同,都强制按照行号展示排名。
上面的例子可以优化一下,因为所有窗口逻辑都是相同的,我们可以利用 WINDOW AS 提取为一个变量:
```sql
SELECT *,
rank() over wd, dense_rank() over wd, row_number() over wd
FROM test
WINDOW wd as (PARTITION BY city ORDER BY people)
```
## 累计聚合
我们之前说过,凡事使用了聚合函数,都会让查询变成聚合模式。如果不用 GROUP BY,聚合后返回行数会压缩为一行,即使用了 GROUP BY,返回的行数一般也会大大减少,因为分组聚合了。
然而使用窗口函数的聚合却不会导致返回行数减少,那么这种聚合是怎么计算的呢?我们不如直接看下面的例子:
```sql
SELECT *,
sum(people) over (PARTITION BY city ORDER BY people)
FROM test
```
<img width=500 src="https://s1.ax1x.com/2022/03/26/qdsDJS.png">
可以看到,在每个 city 分组内,按照 people 排序后进行了 **累加**(相同的值会合并在一起),这就是 BI 工具一般说的 RUNNGIN_SUM 的实现思路,当然一般我们排序规则使用绝对不会重复的日期,所以不会遇到第一个红框中合并计算的问题。
累计函数还有 `avg()` `min()` 等等,这些都一样可以作用于窗口函数,其逻辑可以按照下图理解:
<img width=400 src="https://s1.ax1x.com/2022/03/26/qd6or9.png">
你可能有疑问,直接 `sum(上一行结果,下一行)` 不是更方便吗?为了验证猜想,我们试试 `avg()` 的结果:
<img width=400 src="https://s1.ax1x.com/2022/03/26/qdciIP.png">
可见,如果直接利用上一行结果的缓存,那么 avg 结果必然是不准确的,所以窗口累计聚合是每行重新计算的。当然也不排除对于 sum、max、min 做额外性能优化的可能性,但 avg 只能每行重头计算。
### 与 GROUP BY 组合使用
窗口函数是可以与 GROUP BY 组合使用的,遵循的规则是,窗口范围对后面的查询结果生效,所以其实并不关心是否进行了 GROUP BY。我们看下面的例子:
<img width=500 src="https://s1.ax1x.com/2022/03/26/qdgMOH.png">
按照地区分组后进行累加聚合,是对 GROUP BY 后的数据行粒度进行的,而不是之前的明细行。
## 总结
窗口函数在计算组内排序或累计 GVM 等场景非常有用,我们只要牢记两个知识点就行了:
1. 分组排序要结合 PARTITION BY 才有意义。
2. 累计聚合作用于查询结果行粒度,支持所有聚合函数。
> 讨论地址是:[精读《SQL 窗口函数》· Issue #405 · ascoders/weekly](https://github.com/ascoders/weekly/issues/405)
**如果你想参与讨论,请 [点击这里](https://github.com/ascoders/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)
+151
View File
@@ -0,0 +1,151 @@
SQL grouping 解决 OLAP 场景总计与小计问题,其语法分为几类,但要解决的是同一个问题:
ROLLUP 与 CUBE 是封装了规则的 GROUPING SETS,而 GROUPING SETS 则是最原始的规则。
为了方便理解,让我们从一个问题入手,层层递进吧。
## 底表
<img width=500 src="https://s1.ax1x.com/2022/03/26/qdUL60.png">
以上是示例底表,共有 8 条数据,城市1、城市2 两个城市,下面各有地区1~4,每条数据都有该数据的人口数。
现在想计算人口总计,以及各城市人口小计。在没有掌握 grouping 语法前,我们只能通过两个 select 语句 union 后得到:
```sql
SELECT city, sum(people) FROM test GROUP BY city
union
SELECT '合计' as city, sum(people) FROM test
```
<img width=500 src="https://s1.ax1x.com/2022/04/04/qbKPRs.png">
但两条 select 语句聚合了两次,性能是一个不小的开销,因此 SQL 提供了 GROUPING SETS 语法解决这个问题。
## GROUPING SETS
GROUP BY GROUPING SETS 可以指定任意聚合项,比如我们要同时计算总计与分组合计,就要按照空内容进行 GROUP BY 进行一次 sum,再按照 city 进行 GROUP BY 再进行一次 sum,换成 GROUPING SETS 描述就是:
```sql
SELECT
city, area,
sum(people)
FROM test
GROUP BY GROUPING SETS((), (city, area))
```
其中 `GROUPING SETS((), (city, area))` 表示分别按照 `()``(city, area)` 聚合计算总计。返回结果是:
<img width=500 src="https://s1.ax1x.com/2022/04/04/qbRnWF.png">
可以看到,值为 NULL 的行就是我们要的总计,其值是没有任何 GROUP BY 限制算出来的。
类似的,我们还可以写 `GROUPING SETS((), (city), (city, area), (area))` 等任意数量、任意组合的 GROUP BY 条件。
通过这种规则计算的数据我们称为 “超级分组记录”。我们发现 “超级分组记录” 产生的 NULL 值很容易和真正的 NULL 值弄混,所以 SQL 提供了 GROUPING 函数解决这个问题。
## 函数 GROUPING
对于超级分组记录产生的 NULL,是可以被 `GROUPING()` 函数识别为 1 的:
```sql
SELECT
GROUPING(city),
GROUPING(area),
sum(people)
FROM test
GROUP BY GROUPING SETS((), (city, area))
```
具体效果见下图:
<img width=500 src="https://s1.ax1x.com/2022/04/04/qbRLpF.png">
可以看到,但凡是超级分组计算出来的字段都会识别为 1,我们利用之前学习的 [SQL CASE 表达式](https://github.com/ascoders/weekly/blob/master/SQL/234.SQL%20CASE%20%E8%A1%A8%E8%BE%BE%E5%BC%8F.md) 将其转换为总计、小计字样,就可以得出一张数据分析表了:
```sql
SELECT
CASE WHEN GROUPING(city) = 1 THEN '总计' ELSE city END,
CASE WHEN GROUPING(area) = 1 THEN '小计' ELSE area END,
sum(people)
FROM test
GROUP BY GROUPING SETS((), (city, area))
```
<img width=500 src="https://s1.ax1x.com/2022/04/04/qbRz01.png">
然后前端表格展示时,将第一行 “总计”、“小计” 单元格合并为 “总计”,就完成了总计这个 BI 可视化分析功能。
## ROLLUP
ROLLUP 是卷起的意思,是一种特定规则的 GROUPING SETS,以下两种写法是等价的:
```sql
SELECT sum(people) FROM test
GROUP BY ROLLUP(city)
-- 等价于
SELECT sum(people) FROM test
GROUP BY GROUPING SETS((), (city))
```
再看一组等价描述:
```sql
SELECT sum(people) FROM test
GROUP BY ROLLUP(city, area)
-- 等价于
SELECT sum(people) FROM test
GROUP BY GROUPING SETS((), (city), (city, area))
```
发现规律了吗?ROLLUP 会按顺序把 GROUP BY 内容 “一个个卷起来”。用 GROUPING 函数判断超级分组记录对 ROLLUP 同样适用。
## CUBE
CUBE 又有所不同,它对内容进行了所有可能性展开(所以叫 CUBE)。
类比上面的例子,我们再写两组等价的展开:
```sql
SELECT sum(people) FROM test
GROUP BY CUBE(city)
-- 等价于
SELECT sum(people) FROM test
GROUP BY GROUPING SETS((), (city))
```
上面的例子因为只有一项还看不出来,下面两项分组就能看出来了:
```sql
SELECT sum(people) FROM test
GROUP BY CUBE(city, area)
-- 等价于
SELECT sum(people) FROM test
GROUP BY GROUPING SETS((), (city), (area), (city, area))
```
所谓 CUBE,是一种多维形状的描述,二维时有 2^1 种展开,三维时有 2^2 种展开,四维、五维依此类推。可以想象,如果用 CUBE 描述了很多组合,复杂度会爆炸。
## 总结
学习了 GROUPING 语法,以后前端同学的你不会再纠结这个问题了吧:
> 产品开启了总计、小计,我们是额外取一次数还是放到一起获取啊?
这个问题的标准答案和原理都在这篇文章里了。PS:对于不支持 GROUPING 语法数据库,要想办法屏蔽,就像前端 polyfill 一样,是一种降级方案。至于如何屏蔽,参考文章开头提到的两个 SELECT + UNION。
> 讨论地址是:[精读《SQL grouping》· Issue #406 · ascoders/weekly](https://github.com/ascoders/weekly/issues/406)
**如果你想参与讨论,请 [点击这里](https://github.com/ascoders/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,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)
+1
View File
@@ -7,6 +7,7 @@ const fs = require("fs");
const dirs = [
"前沿技术",
"TS 类型体操",
"设计模式",
"编译原理",
"源码解读",
+13 -1
View File
@@ -6,7 +6,7 @@
前端界的好文精读,每周更新!
最新精读:<a href="./SQL/234.SQL%20CASE%20%E8%A1%A8%E8%BE%BE%E5%BC%8F.md">234.SQL CASE 表达式</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)
@@ -186,6 +186,15 @@
- <a href="./前沿技术/226.%E7%B2%BE%E8%AF%BB%E3%80%8A2021%20%E5%89%8D%E7%AB%AF%E6%96%B0%E7%A7%80%E5%9B%9E%E9%A1%BE%E3%80%8B.md">226.精读《2021 前端新秀回顾》</a>
- <a href="./前沿技术/228.%E7%B2%BE%E8%AF%BB%E3%80%8Apipe%20operator%20for%20JavaScript%E3%80%8B.md">228.精读《pipe operator for JavaScript》</a>
- <a href="./前沿技术/230.%E7%B2%BE%E8%AF%BB%E3%80%8A%E5%AF%B9%20Markdown%20%E7%9A%84%E6%80%9D%E8%80%83%E3%80%8B.md">230.精读《对 Markdown 的思考》</a>
- <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>
### 设计模式
@@ -242,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>
### 商业思考
@@ -271,6 +281,8 @@
- <a href="./SQL/232.SQL%20%E8%81%9A%E5%90%88%E6%9F%A5%E8%AF%A2.md">232.SQL 聚合查询</a>
- <a href="./SQL/233.SQL%20%E5%A4%8D%E6%9D%82%E6%9F%A5%E8%AF%A2.md">233.SQL 复杂查询</a>
- <a href="./SQL/234.SQL%20CASE%20%E8%A1%A8%E8%BE%BE%E5%BC%8F.md">234.SQL CASE 表达式</a>
- <a href="./SQL/235.SQL%20%E7%AA%97%E5%8F%A3%E5%87%BD%E6%95%B0.md">235.SQL 窗口函数</a>
- <a href="./SQL/236.SQL%20grouping.md">236.SQL grouping</a>
## 关注前端精读微信公众号
@@ -86,7 +86,7 @@ module.exports = {
1. `name` 当前应用名称,需要全局唯一。
2. `remotes` 可以将其他项目的 `name` 映射到当前项目中。
3. `exposes` 表示导出的模块,只有在此申明的模块才可以作为远程依赖被使用。
4. `shared` 是非常重要的参数,定了这个参数,可以让远程加载的模块对应依赖改为使用本地项目的 React 或 ReactDOM。
4. `shared` 是非常重要的参数,定了这个参数,可以让远程加载的模块对应依赖改为使用本地项目的 React 或 ReactDOM。
比如设置了 `remotes: { app_two: "app_two_remote" }`,在代码中就可以直接利用以下方式直接从对方应用调用模块:
@@ -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,502 @@
## 新增 Awaited 类型
Awaited 可以将 Promise 实际返回类型抽出来,按照名字可以理解为:等待 Promise resolve 了拿到的类型。下面是官方文档提供的 Demo:
```ts
// A = string
type A = Awaited<Promise<string>>;
// B = number
type B = Awaited<Promise<Promise<number>>>;
// C = boolean | number
type C = Awaited<boolean | Promise<number>>;
```
## 捆绑的 dom lib 类型可以被替换
TS 因开箱即用的特性,捆绑了所有 dom 内置类型,比如我们可以直接使用 Document 类型,而这个类型就是 TS 内置提供的。
也许有时不想随着 TS 版本升级而升级连带的 dom 内置类型,所以 TS 提供了一种指定 dom lib 类型的方案,在 `package.json` 申明 `@typescript/lib-dom` 即可:
```json
{
"dependencies": {
"@typescript/lib-dom": "npm:@types/web"
}
}
```
这个特性提升了 TS 的环境兼容性,但一般情况还是建议开箱即用,省去繁琐的配置,项目更好维护。
## 模版字符串类型也支持类型收窄
```ts
export interface Success {
type: `${string}Success`;
body: string;
}
export interface Error {
type: `${string}Error`;
message: string;
}
export function handler(r: Success | Error) {
if (r.type === "HttpSuccess") {
// 'r' has type 'Success'
let token = r.body;
}
}
```
模版字符串类型早就支持了,但现在才支持按照模版字符串在分支条件时,做类型收窄。
## 增加新的 --module es2022
虽然可以使用 --module esnext 保持最新特性,但如果你想使用稳定的版本号,又要支持顶级 await 特性的话,可以使用 es2022。
## 尾递归优化
TS 类型系统支持尾递归优化了,拿下面这个例子就好理解:
```ts
type TrimLeft<T extends string> =
T extends ` ${infer Rest}` ? TrimLeft<Rest> : T;
// error: Type instantiation is excessively deep and possibly infinite.
type Test = TrimLeft<" oops">;
```
在没有做尾递归优化前,TS 会因为堆栈过深而报错,但现在可以正确返回执行结果了,因为尾递归优化后,不会形成逐渐加深的调用,而是执行完后立即退出当前函数,堆栈数量始终保持不变。
JS 目前还没有做到自动尾递归优化,但可以通过自定义函数 TCO 模拟实现,下面放出这个函数的实现:
```js
function tco(f) {
var value;
var active = false;
var accumulated = [];
return function accumulator(...rest) {
accumulated.push(rest);
if (!active) {
active = true;
while (accumulated.length) {
value = f.apply(this, accumulated.shift());
}
active = false;
return value;
}
};
}
```
核心是把递归变成 while 循环,这样就不会产生堆栈。
## 强制保留 import
TS 编译时会把没用到的 import 干掉,但这次提供了 `--preserveValueImports` 参数禁用这一特性,原因是以下情况会导致误移除 import:
```ts
import { Animal } from "./animal.js";
eval("console.log(new Animal().isDangerous())");
```
因为 TS 无法分辨 eval 里的引用,类似的还有 vue 的 `setup` 语法:
```html
<!-- A .vue File -->
<script setup>
import { someFunc } from "./some-module.js";
</script>
<button @click="someFunc">Click me!</button>
```
## 支持变量 import type 声明
之前支持了如下语法标记引用的变量是类型:
```ts
import type { BaseType } from "./some-module.js";
```
现在支持了变量级别的 type 声明:
```ts
import { someFunc, type BaseType } from "./some-module.js";
```
这样方便在独立模块构建时,安全的抹去 `BaseType`,因为单模块构建时,无法感知 `some-module.js` 文件内容,所以如果不特别指定 `type BaseType`,TS 编译器将无法识别其为类型变量。
## 类私有变量检查
包含两个特性,第一是 TS 支持了类私有变量的检查:
```ts
class Person {
#name: string;
}
```
第二是支持了 `#name in obj` 的判断,如:
```ts
class Person {
#name: string;
constructor(name: string) {
this.#name = name;
}
equals(other: unknown) {
return other &&
typeof other === "object" &&
#name in other && // <- this is new!
this.#name === other.#name;
}
}
```
该判断隐式要求了 `#name in other``other` 是 Person 实例化的对象,因为该语法仅可能存在于类中,而且还能进一步类型缩窄为 Person 类。
## Import 断言
支持了导入断言提案:
```ts
import obj from "./something.json" assert { type: "json" };
```
以及动态 import 的断言:
```ts
const obj = await import("./something.json", {
assert: { type: "json" }
})
```
TS 该特性支持了任意类型的断言,而不关心浏览器是否识别。所以该断言如果要生效,需要以下两种支持的任意一种:
- 浏览器支持。
- 构建脚本支持。
不过目前来看,构建脚本支持的语法并不统一,比如 Vite 对导入类型的断言有如下两种方式:
```ts
import obj from "./something?raw"
// 或者自创的语法 blob 加载模式
const modules = import.meta.glob(
'./**/index.tsx',
{
assert: { type: 'raw' },
},
);
```
所以该导入断言至少在未来可以统一构建工具的语法,甚至让浏览器原生支持后,就不需要构建工具处理 import 断言了。
其实完全靠浏览器解析要走的路还有很远,因为一个复杂的前端工程至少有 3000~5000 个资源文件,目前生产环境不可能使用 bundless 一个个加载这些资源,因为速度太慢了。
## const 只读断言
```ts
const obj = {
a: 1
} as const
obj.a = 2 // error
```
通过该语法指定对象所有属性为 `readonly`
## 利用 realpathSync.native 实现更快加载速度
对开发者没什么感知,就是利用 `realpathSync.native` 提升了 TS 加载速度。
## 片段自动补全增强
在 Class 成员函数与 JSX 属性的自动补全功能做了增强,在使用了最新版 TS 之后应该早已有了体感,比如 JSX 书写标签输入回车后,会自动根据类型补全内容,如:
```tsx
<App cla />
// ↑回车↓
// <App className="|" />
// ↑光标自动移到这里
```
## 代码可以写在 super() 前了
JS 对 `super()` 的限制是此前不可以调用 this,但 TS 限制的更严格,在 `super()` 前写任何代码都会报错,这显然过于严格了。
现在 TS 放宽了校验策略,仅在 `super()` 前调用 this 会报错,而执行其他代码是被允许的。
这点其实早就该改了,这么严格的校验策略让我一度以为 JS 就是不允许 `super()` 前调用任何函数,但想想也觉得不合理,因为 `super()` 表示调用父类的 `constructor` 函数,之所以不自动调用,而需要手动调用 `super()` 就是为了开发者可以灵活决定哪些逻辑在父类构造函数前执行,所以 TS 之前一刀切的行为实际上导致 `super()` 失去了存在的意义,成为一个没有意义的模版代码。
## 类型收窄对解构也生效了
这个特性真的很厉害,即解构后类型收窄依然生效。
此前,TS 的类型收窄已经很强大了,可以做到如下判断:
```ts
function foo(bar: Bar) {
if (bar.a === '1') {
bar.b // string 类型
} else {
bar.b // number 类型
}
}
```
但如果提前把 a、b 从 bar 中解构出来就无法自动收窄了。现在该问题也得到了解决,以下代码也可以正常生效了:
```ts
function foo(bar: Bar) {
const { a, b } = bar
if (a === '1') {
b // string 类型
} else {
b // number 类型
}
}
```
## 深度递归类型检查优化
下面的赋值语句会产生异常,原因是属性 prop 的类型不匹配:
```ts
interface Source {
prop: string;
}
interface Target {
prop: number;
}
function check(source: Source, target: Target) {
target = source;
// error!
// Type 'Source' is not assignable to type 'Target'.
// Types of property 'prop' are incompatible.
// Type 'string' is not assignable to type 'number'.
}
```
这很好理解,从报错来看,TS 也会根据递归检测的方式查找到 prop 类型不匹配。但由于 TS 支持泛型,如下写法就是一种无限递归的例子:
```ts
interface Source<T> {
prop: Source<Source<T>>;
}
interface Target<T> {
prop: Target<Target<T>>;
}
function check(source: Source<string>, target: Target<number>) {
target = source;
}
```
实际上不需要像官方说明写的这么复杂,哪怕是 `props: Source<T>` 也足以让该例子无限递归下去。TS 为了确保该情况不会出错,做了递归深度判断,过深的递归会终止判断,但这会带来一个问题,即无法识别下面的错误:
```ts
interface Foo<T> {
prop: T;
}
declare let x: Foo<Foo<Foo<Foo<Foo<Foo<string>>>>>>;
declare let y: Foo<Foo<Foo<Foo<Foo<string>>>>>;
x = y;
```
为了解决这一问题,TS 做了一个判断:递归保护仅对递归写法的场景生效,而上面这个例子,虽然也是很深层次的递归,但因为是一个个人肉写出来的,TS 也会不厌其烦的一个个递归下去,所以该场景可以正确 Work。
这个优化的核心在于,TS 可以根据代码结构解析哪些是 “非常抽象/启发式” 写法导致的递归,哪些是一个个枚举产生的递归,并对后者的递归深度检查进行豁免。
## 增强的索引推导
下面的官方文档给出的例子,一眼看上去比较复杂,我们来拆解分析一下:
```ts
interface TypeMap {
"number": number;
"string": string;
"boolean": boolean;
}
type UnionRecord<P extends keyof TypeMap> = { [K in P]:
{
kind: K;
v: TypeMap[K];
f: (p: TypeMap[K]) => void;
}
}[P];
function processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) {
record.f(record.v);
}
// This call used to have issues - now works!
processRecord({
kind: "string",
v: "hello!",
// 'val' used to implicitly have the type 'string | number | boolean',
// but now is correctly inferred to just 'string'.
f: val => {
console.log(val.toUpperCase());
}
})
```
该例子的目的是实现 `processRecord` 函数,该函数通过识别传入参数 `kind` 来自动推导回调函数 `f``value` 的类型。
比如 `kind: "string"`,那么 `val` 就是字符串类型,`kind: "number"`,那么 `val` 就是数字类型。
因为 TS 这次更新解决了之前无法识别 `val` 类型的问题,我们不需要关心 TS 是怎么解决的,只要记住 TS 可以正确识别该场景(有点像围棋的定式,对于经典例子最好逐一学习),并且理解该场景是如何构造的。
如何做到呢?首先定义一个类型映射:
```ts
interface TypeMap {
"number": number;
"string": string;
"boolean": boolean;
}
```
之后定义最终要的函数 `processRecord`:
```ts
function processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) {
record.f(record.v);
}
```
这里定义了一个泛型 K`K extends keyof TypeMap` 等价于 `K extends 'number' | 'string' | 'boolean'`,所以这里是限定了以下泛型 K 的取值范围,值为这三个字符串之一。
重点来了,参数 `record` 需要根据传入的 `kind` 决定 `f` 回调函数参数类型。我们先想象以下 `UnionRecord` 类型怎么写:
```ts
type UnionRecord<K extends keyof TypeMap> = {
kind: K;
v: TypeMap[K];
f: (p: TypeMap[K]) => void;
}
```
如上,自然的想法是定义一个泛型 K,这样 `kind``f`, `p` 类型都可以表示出来,这样 `processRecord<K extends keyof TypeMap>(record: UnionRecord<K>)``UnionRecord<K>` 就表示了将当前接收到的实际类型 K 传入 `UnionRecord`,这样 `UnionRecord` 就知道实际处理什么类型了。
本来到这里该功能就已经结束了,但官方给的 `UnionRecord` 定义稍有些不同:
```ts
type UnionRecord<P extends keyof TypeMap> = { [K in P]:
{
kind: K;
v: TypeMap[K];
f: (p: TypeMap[K]) => void;
}
}[P];
```
这个例子特意提升了一个复杂度,用索引的方式绕了一下,可能之前 TS 就无法解析这种形式吧,总之现在这个写法也被支持了。我们看一下为什么这个写法与上面是等价的,上面的写法简化一下如下:
```ts
type UnionRecord<P extends keyof TypeMap> = {
[K in P]: X
}[P];
```
可以解读为,`UnionRecord` 定义了一个泛型 P,该函数从对象 `{ [K in P]: X }` 中按照索引(或理解为下标) `[P]` 取得类型。而 `[K in P]` 这种描述对象 Key 值的类型定义,等价于定义了复数个类型,由于正好 `P extends keyof TypeMap`,你可以理解为类型展开后是这样的:
```ts
type UnionRecord<P extends keyof TypeMap> = {
'number': X,
'string': X,
'boolean': X
}[P];
```
而 P 是泛型,由于 `[K in P]` 的定义,所以必定能命中上面其中的一项,所以实际上等价于下面这个简单的写法:
```ts
type UnionRecord<K extends keyof TypeMap> = {
kind: K;
v: TypeMap[K];
f: (p: TypeMap[K]) => void;
}
```
## 参数控制流分析
这个特性字面意思翻译挺奇怪的,还是从代码来理解吧:
```ts
type Func = (...args: ["a", number] | ["b", string]) => void;
const f1: Func = (kind, payload) => {
if (kind === "a") {
payload.toFixed(); // 'payload' narrowed to 'number'
}
if (kind === "b") {
payload.toUpperCase(); // 'payload' narrowed to 'string'
}
};
f1("a", 42);
f1("b", "hello");
```
如果把参数定义为元组且使用或并列枚举时,其实就潜在包含了一个运行时的类型收窄。比如当第一个参数值为 `a` 时,第二个参数类型就确定为 `number`,第一个参数值为 `b` 时,第二个参数类型就确定为 `string`
值得注意的是,这种类型推导是从前到后的,因为参数是自左向右传递的,所以是前面推导出后面,而不能是后面推导出前面(比如不能理解为,第二个参数为 `number` 类型,那第一个参数的值就必须为 `a`)。
## 移除 JSX 编译时产生的非必要代码
JSX 编译时干掉了最后一个没有意义的 `void 0`,减少了代码体积:
```js
- export const el = _jsx("div", { children: "foo" }, void 0);
+ export const el = _jsx("div", { children: "foo" });
```
由于改动很小,所以可以借机学习一下 TS 源码是怎么修改的,这是 [PR DIFF 地址](https://github.com/microsoft/TypeScript/pull/47467/files#)。
可以看到,修改位置是 `src/compiler/transformers/jsx.ts` 文件,改动逻辑为移除了 `factory.createVoidZero()` 函数,该函数正如其名,会创建末尾的 `void 0`,除此之外就是大量的 tests 文件修改,其实理解了源码上下文,这种修改并不难。
## JSDoc 校验提示
JSDoc 注释由于与代码是分离的,随着不断迭代很容易与实际代码产生分叉:
```ts
/**
* @param x {number} The first operand
* @param y {number} The second operand
*/
function add(a, b) {
return a + b;
}
```
现在 TS 可以对命名、类型等不一致给出提示了。顺便说一句,用了 TS 就尽量不要用 JSDoc,毕竟代码和类型分离随时有不一致的风险产生。
## 总结
从这两个更新来看,TS 已经进入成熟期,但 TS 在泛型类的问题上依然还处于早期阶段,有大量复杂的场景无法支持,或者没有优雅的兼容方案,希望未来可以不断完善复杂场景的类型支持。
> 讨论地址是:[精读《Typescript 4.5-4.6 新特性》· Issue #408 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/408)
**如果你想参与讨论,请 [点击这里](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,172 @@
关注 JS 太久,会养成任何功能都用 JS 实现的习惯,而忘记了 HTML 与 CSS 也具备一定的功能特征。其实有些功能用 JS 实现吃力不讨好,我们要综合使用技术工具,而不是只依赖 JS。
[5 things you don't need Javascript for](https://lexoral.com/blog/you-dont-need-js/) 这篇文章就从 5 个例子出发,告诉我们哪些功能不一定非要用 JS 做。
## 概述
### 使用 css 控制 svg 动画
原文绘制了一个放烟花的 [例子](https://lexoral.com/blog/you-dont-need-js/),本质上是用 css 控制 svg 产生动画效果,核心代码:
```css
.trail {
stroke-width: 2;
stroke-dasharray: 1 10 5 10 10 5 30 150;
animation-name: trail;
animation-timing-function: ease-out;
}
@keyframes trail {
from,
20% {
stroke-width: 3;
stroke-dashoffset: 80;
}
100%,
to {
stroke-width: 0.5;
stroke-dashoffset: -150;
}
}
```
可以看到,主要使用 `stroke-dasharray` 控制线条实虚线的样式,再利用动画效果对 `stroke-dashoffset` 产生变化,从而实现对线条起始点进行位移,实现线条 “绘图” 的效果,且该 css 样式对 svg 绘制的路径是生效的。
### sidebar
可以完全使用 css 实现 hover 时才出现的侧边栏:
```css
nav {
position: 'absolute';
right: 100%;
transition: 0.2s transform;
}
nav:hover,
nav:focus-within {
transform: translateX(100%);
}
```
核心在于 `hover` 时设置 `transform` 属性可以让元素偏移,且 `translateX(100%)` 可以位移当前元素宽度的身位。
另一个有意思的是,如果使用 TABS 按键聚焦到 sidebar 内元素也要让 sidebar 出来,可以直接用 `:focus-within` 实现。如果需要 hover 后延迟展示可以使用 `transition-delay` 属性。
### sticky position
使用 `position: sticky` 来黏住一个元素:
```css
.square {
position: sticky;
top: 2em;
}
```
这样该元素会始终展示在其父容器内,但一旦其出现在视窗时,当 top 超过 `2em` 后就会变为 `fixed` 定位并保持原位。
使用 JS 判断还是挺复杂的,你得设法监听父元素滚动,并且在定位切换时可能产生一些抖动,因为 JS 的执行与 CSS 之间是异步关系。但当我们只用 CSS 描述这个行为时,浏览器就有办法解决转换时的抖动问题。
### 手风琴菜单
使用 `<details>` 标签可以实现类似一个简易的折叠手风琴效果:
```html
<details>
<summary>title</summary>
<p>1</p>
<p>2</p>
</details>
```
`<details>` 标签内的 `<summary>` 标签内容总是会展示,且点击后会切换 `<details>` 内其他元素的显隐藏。虽然这做不了特殊动画效果,但如果只为了做一个普通的展开折叠功能,用 HTML 标签就够了。
### 暗色主题
虽然直觉上暗色主题好像是一种定制业务逻辑,但其实因为暗色主题太过于普遍,以至于操作系统和浏览器都内置实现了,而 CSS 也实现了对应的方法判断当前系统的主题到底是亮色还是暗色:[prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme)。
所以如果系统要实现暗色系主题,最好可以和操作系统设置保持一致,这样用户体验也会更好:
```css
@media (prefers-color-scheme: light) {
/** ... */
}
@media (prefers-color-scheme: dark) {
/** ... */
}
@media (prefers-color-scheme: no-preference) {
/** ... */
}
```
如果使用 Checkbox 勾选是否开启暗色主题,也可以仅用 CSS 变量判断,核心代码是:
```css
#checkboxId:checked ~ .container {
background-color: black;
}
```
`~` 这个符号表示,`selector1 ~ selector2` 时,为选择器 `selector1` 之后满足 `selector2` 条件的兄弟节点设置样式。
## 精读
除了上面例子外,笔者再追加几个例子。
### 幻灯片滚动
幻灯片滚动即每次滚动有固定的步长,把子元素完整的展示在可视区域,不可能出现上下或者左右两个子元素各出现一部分的 “割裂” 情况。
该场景除了用浏览器实现幻灯片外,在许多网站首页也被频繁使用,比如将首页切割为 5 个纵向滚动的区块,每个区块展示一个产品特性,此时滚动不再是连续的,而是从一个区块到另一个区块的完整切换。
其实这种效果无需 JS 实现:
```css
html {
scroll-snap-type: y mandatory;
}
.child {
scroll-snap-align: start;
}
```
这样便将页面设置为精准捕捉子元素滚动位置,在滚轮触发、鼠标点击滚动条松手或者键盘上下按键时,`scroll-snap-type: y mandatory` 可以精准捕捉这一垂直滚动行为,并将子元素完全滚动到可视区域。
### 颜色选择器
使用 HTML 原生就能实现颜色选择器:
```html
<input type="color" value="#000000">
```
<img width=500 src="https://s1.ax1x.com/2022/04/17/LNUv7t.png">
该选择器的好处是性能、可维护性都非常非常的好,甚至可以捕捉桌面的颜色,不好的地方是无法对拾色器进行定制。
## 总结
关于 CSS 可以实现哪些原本需要 JS 做的事,有很多很好的文章,比如:
- [youmightnotneedjs](http://youmightnotneedjs.com/)。
- [You-Dont-Need-JavaScript](https://github.com/you-dont-need/You-Dont-Need-JavaScript)。
- 以及本文简介里介绍的 [5 things you don't need Javascript for](https://lexoral.com/blog/you-dont-need-js/)。
但并不是读了这些文章,我们就要尽量用 CSS 实现所有能做的事,那样也没有必要。CSS 因为是描述性语言,它可以精确控制样式,但却难以精确控制交互过程,对于标准交互行为比如幻灯片滑动、动画可以使用 CSS,对于非标准交互行为,比如自定义位置弹出 Modal、用 svg 绘制完全自定义路径动画尽量还是用 JS。
另外对于交互过程中的状态,如果需要传递给其他元素响应,还是尽量使用 JS 实现。虽然 CSS 伪类可以帮我们实现大部分这种能力,但如果我们要监听状态变化发一个请求什么的,CSS 就无能为力了,或者我们需要非常 trick 的利用 CSS 实现,这也违背了 CSS 技术选型的初衷。
最后,能否在合适的场景选择 CSS 方案,也是技术选型能力的一种,不要忘了 CSS 适用的领域,不要什么功能都用 JS 实现。
> 讨论地址是:[精读《不再需要 JS 做的 5 件事》· Issue #413 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/413)
**如果你想参与讨论,请 [点击这里](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,335 @@
每个 JS 执行引擎都有自己的实现,我们这次关注 [V8](https://v8.dev/) 引擎是如何实现数组的。
本周主要精读的文章是 [How JavaScript Array Works Internally?](https://blog.gauravthakur.in/how-javascript-array-works-internally),比较简略的介绍了 V8 引擎的数组实现机制,笔者也会参考部分其他文章与源码结合进行讲解。
## 概述
JS 数组的内部类型有很多模式,如:
- PACKED_SMI_ELEMENTS
- PACKED_DOUBLE_ELEMENTS
- PACKED_ELEMENTS
- HOLEY_SMI_ELEMENTS
- HOLEY_DOUBLE_ELEMENTS
- HOLEY_ELEMENTS
PACKED 翻译为打包,实际意思是 “连续有值的数组”;HOLEY 翻译为孔洞,表示这个数组有很多孔洞一样的无效项,实际意思是 “中间有孔洞的数组”,这两个名词是互斥的。
SMI 表示数据类型为 32 位整型,DOUBLE 表示浮点类型,而什么类型都不写,表示数组的类型还杂糅了字符串、函数等,这个位置上的描述也是互斥的。
所以可以这么去看数组的内部类型:`[PACKED, HOLEY]_[SMI, DOUBLE, '']_ELEMENTS`
### 最高效的类型 PACKED_SMI_ELEMENTS
一个最简单的空数组类型默认为 PACKED_SMI_ELEMENTS
```js
const arr = [] // PACKED_SMI_ELEMENTS
```
PACKED_SMI_ELEMENTS 类型是性能最好的模式,存储的类型默认是连续的整型。当我们插入整型时,V8 会给数组自动扩容,此时类型还是 PACKED_SMI_ELEMENTS
```js
const arr = [] // PACKED_SMI_ELEMENTS
arr.push(1) // PACKED_SMI_ELEMENTS
```
或者直接创建有内容的数组,也是这个类型:
```js
const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
```
### 自动降级
当我们对数组使用骚操作时,V8 会默默的进行类型降级。比如突然访问到第 100 项:
```js
const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
arr[100] = 4 // HOLEY_SMI_ELEMENTS
```
如果突然插入一个浮点类型,会降级到 DOUBLE:
```js
const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
arr.push(4.1) // PACKED_DOUBLE_ELEMENTS
```
当然如果两个骚操作一结合,HOLEY_DOUBLE_ELEMENTS 就成功被你造出来了:
```js
const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
arr[100] = 4.1 // HOLEY_DOUBLE_ELEMENTS
```
再狠一点,插入个字符串或者函数,那就到了最最兜底类型,HOLEY_ELEMENTS
```js
const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
arr[100] = '4' // HOLEY_ELEMENTS
```
从是否有 Empty 情况来看,PACKED > HOLEY 的性能,Benchmark 测试结果大概快 23%。
从类型来看,SMI > DOUBLE > 空类型。原因是类型决定了数组每项的长度,DOUBLE 类型是指每一项可能为 SMI 也可能为 DOUBLE,而空类型的每一项类型完全不可确认,在长度确认上会花费额外开销。
因此,HOLEY_ELEMENTS 是性能最差的兜底类型。
### 降级的不可逆性
文中提到一个重点,表示降级是不可逆的,具体可以看下图:
<img width=500 src="https://s1.ax1x.com/2022/05/08/O3nzsf.png">
其实要表达的规律很简单,即 PACKED 只会变成更糟的 HOLEYSMI 只会往更糟的 DOUBLE 和空类型变,且这两种变化都不可逆。
## 精读
为了验证文章的猜想,笔者使用 v8-debug 调试了一番。
### 使用 v8-debug 调试
先介绍一下 v8-debug,它是一个 v8 引擎调试工具,首先执行下面的命令行安装 `jsvu`
```bash
npm i -g jsvu
```
然后执行 `jsvu`,根据引导选择自己的系统类型,第二步选择要安装的 js 引擎,选择 `v8``v8-debug`
```bash
jsvu
// 选择 macos
// 选择 v8v8-debug
```
然后随便创建一个 js 文件,比如 `test.js`,再通过 `~/.jsvu/v8-debug ./test.js` 就可以执行调试了。默认是不输出任何调试内容的,我们根据需求添加参数来输出要调试的信息,比如:
```bash
~/.jsvu/v8-debug ./test.js --print-ast
```
这样就会把 `test.js` 文件的语法树打印出来。
### 使用 v8-debug 调试数组的内部实现
为了观察数组的内部实现,使用 `console.log(arr)` 显然不行,我们需要用 `%DebugPrint(arr)` 以 debug 模式打印数组,而这个 `%DebugPrint` 函数式 V8 提供的 Native API,在普通 js 脚本是不识别的,因此我们要在执行时添加参数 `--allow-natives-syntax`
```bash
~/.jsvu/v8-debug ./test.js --allow-natives-syntax
```
同时,在 `test.js` 里使用 `%DebugPrint` 打印我们要调试的数组,如:
```js
const arr = []
%DebugPrint(arr)
```
输出结果为:
```test
DebugPrint: 0x120d000ca0b9: [JSArray]
- map: 0x120d00283a71 <Map(PACKED_SMI_ELEMENTS)> [FastProperties]
```
也就是说,`arr = []` 创建的数组的内部类型为 `PACKED_SMI_ELEMENTS`,符合预期。
### 验证不可逆转换
不看源码的话,姑且相信原文说的类型转换不可逆,那么我们做一个测试:
```js
const arr = [1, 2, 3]
arr.push(4.1)
console.log(arr);
%DebugPrint(arr)
arr.pop()
console.log(arr);
%DebugPrint(arr)
```
打印核心结果为:
```text
1,2,3,4.1
DebugPrint: 0xf91000ca195: [JSArray]
- map: 0x0f9100283b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
1,2,3
DebugPrint: 0xf91000ca195: [JSArray]
- map: 0x0f9100283b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
```
可以看到,即便 `pop` 后将原数组回退到完全整型的情况,DOUBLE 也不会优化为 SMI。
再看下长度的测试:
```js
const arr = [1, 2, 3]
arr[4] = 4
console.log(arr);
%DebugPrint(arr)
arr.pop()
arr.pop()
console.log(arr);
%DebugPrint(arr)
```
打印核心结果为:
```text
1,2,3,,4
DebugPrint: 0x338b000ca175: [JSArray]
- map: 0x338b00283ae9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]
1,2,3
DebugPrint: 0x338b000ca175: [JSArray]
- map: 0x338b00283ae9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]
```
也证明了 PACKED 到 HOLEY 的不可逆。
### 字典模式
数组还有一种内部实现是 Dictionary Elements,它用 HashTable 作为底层结构模拟数组的操作。
这种模式用于数组长度非常大的时候,不需要连续开辟内存空间,而是用一个个零散的内存空间通过一个 HashTable 寻址来处理数据的存储,这种模式在数据量大时节省了存储空间,但带来了额外的查询开销。
当对数组的赋值远大于当前数组大小时,V8 会考虑将数组转化为 Dictionary Elements 存储以节省存储空间。
做一个测试:
```js
const arr = [1, 2, 3];
%DebugPrint(arr);
arr[3000] = 4;
%DebugPrint(arr);
```
主要输出结果为:
```text
DebugPrint: 0x209d000ca115: [JSArray]
- map: 0x209d00283a71 <Map(PACKED_SMI_ELEMENTS)> [FastProperties]
DebugPrint: 0x209d000ca115: [JSArray]
- map: 0x209d00287d29 <Map(DICTIONARY_ELEMENTS)> [FastProperties]
```
可以看到,占用了太多空间会导致数组的内部实现切换为 DICTIONARY_ELEMENTS 模式。
实际上这两种模式是根据固定规则相互转化的,具体查了下 V8 源码:
字典模式在 V8 代码里叫 SlowElements,反之则叫 FastElements,所以要看转化规则,主要就看两个函数:`ShouldConvertToSlowElements``ShouldConvertToFastElements`
下面是 `ShouldConvertToSlowElements` 代码,即什么时候转化为字典模式:
```c++
static inline bool ShouldConvertToSlowElements(
uint32_t used_elements,
uint32_t new_capacity
) {
uint32_t size_threshold = NumberDictionary::kPreferFastElementsSizeFactor *
NumberDictionary::ComputeCapacity(used_elements) *
NumberDictionary::kEntrySize;
return size_threshold <= new_capacity;
}
static inline bool ShouldConvertToSlowElements(
JSObject object,
uint32_t capacity,
uint32_t index,
uint32_t* new_capacity
) {
STATIC_ASSERT(JSObject::kMaxUncheckedOldFastElementsLength <=
JSObject::kMaxUncheckedFastElementsLength);
if (index < capacity) {
*new_capacity = capacity;
return false;
}
if (index - capacity >= JSObject::kMaxGap) return true;
*new_capacity = JSObject::NewElementsCapacity(index + 1);
DCHECK_LT(index, *new_capacity);
if (*new_capacity <= JSObject::kMaxUncheckedOldFastElementsLength ||
(*new_capacity <= JSObject::kMaxUncheckedFastElementsLength &&
ObjectInYoungGeneration(object))) {
return false;
}
return ShouldConvertToSlowElements(object.GetFastElementsUsage(),
*new_capacity);
}
```
`ShouldConvertToSlowElements` 函数被重载了两次,所以有两个判断逻辑。第一处 `new_capacity > size_threshold` 则变成字典模式,new_capacity 表示新尺寸,而 size_threshold 是根据 3 * 已有尺寸 * 2 计算出来的。
第二处 `index - capacity >= JSObject::kMaxGap` 时变成字典模式,其中 kMaxGap 是常量 1024,也就是新加入的 HOLEY(孔洞) 大于 1024,则转化为字典模式。
而由字典模式转化为普通模式的函数是 `ShouldConvertToFastElements`
```c++
static bool ShouldConvertToFastElements(
JSObject object,
NumberDictionary dictionary,
uint32_t index,
uint32_t* new_capacity
) {
// If properties with non-standard attributes or accessors were added, we
// cannot go back to fast elements.
if (dictionary.requires_slow_elements()) return false;
// Adding a property with this index will require slow elements.
if (index >= static_cast<uint32_t>(Smi::kMaxValue)) return false;
if (object.IsJSArray()) {
Object length = JSArray::cast(object).length();
if (!length.IsSmi()) return false;
*new_capacity = static_cast<uint32_t>(Smi::ToInt(length));
} else if (object.IsJSArgumentsObject()) {
return false;
} else {
*new_capacity = dictionary.max_number_key() + 1;
}
*new_capacity = std::max(index + 1, *new_capacity);
uint32_t dictionary_size = static_cast<uint32_t>(dictionary.Capacity()) *
NumberDictionary::kEntrySize;
// Turn fast if the dictionary only saves 50% space.
return 2 * dictionary_size >= *new_capacity;
}
```
重点是最后一行 `return 2 * dictionary_size >= *new_capacity` 表示字典模式仅节省了 50% 空间时,不如切换为普通模式(fast mode)。
具体就不测试了,感兴趣同学可以用上面介绍的方法使用 v8-debug 测试一下。
## 总结
JS 数组使用方法非常灵活,但 V8 使用 C++ 实现时,必须转化为更底层的类型,所以为了兼顾性能,就做了快慢模式,而快模式又分了 SMI、DOUBLEPACKED、HOLEY 模式分别处理来尽可能提升速度。
也就是说,我们在随意创建数组的时候,V8 会分析数组的元素构成与长度变化,自动分发到各种不同的子模式处理,以最大化提升性能。
这种模式使 JS 开发者获得了更好的开发者体验,而实际上执行性能也和 C++ 原生优化相差无几,所以从这个角度来看,JS 是一种更高封装层次的语言,极大降低了开发者学习门槛。
当然 JS 还提供了一些相对原生的语法比如 ArrayBuffer,或者 WASM 让开发者直接操作更底层的特性,这可以使性能控制更精确,但带来了更大的学习和维护成本,需要开发者根据实际情况权衡。
> 讨论地址是:[精读《JS 数组的内部实现》· Issue #414 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/414)
**如果你想参与讨论,请 [点击这里](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,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)
+184
View File
@@ -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)
@@ -44,7 +44,7 @@ Abstract Factory(抽象工厂)属于创建型模式,工厂类模式抽象
`AbstractProduct` 是产品抽象类,描述了比如方向盘、墙壁、折线图的创建方法,而 `ConcreteProduct` 是具体实现产品的方法,比如 `ConcreteProduct1` 创建的表格是用 `canvas` 画的,折线图是用 `G2` 画的,而 `ConcreteProduct2` 创建的表格是用 `div` 画的,折线图是用 `Echarts` 画的。
这样,当我们要拓展一个用 `Rcharts` 画的折线图,用 `svg` 画的表格,用 `div` 画的模态框组成的事件机制时,只需要再创建一个 `ConcreteFactory3` 做相应的实现即可,再将这个 `ConcreteFactory3` 传递给 `AbstractFactory`,并不需要修改 `AbstractFactory` 方法本身。
这样,当我们要拓展一个用 `Echarts` 画的折线图,用 `svg` 画的表格,用 `div` 画的模态框组成的事件机制时,只需要再创建一个 `ConcreteFactory3` 做相应的实现即可,再将这个 `ConcreteFactory3` 传递给 `AbstractFactory`,并不需要修改 `AbstractFactory` 方法本身。
## 代码例子