Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2a01dd0b | ||
|
|
c2897a2385 | ||
|
|
08ea922317 | ||
|
|
fa1e19c58f | ||
|
|
444e4daeec | ||
|
|
a95ae5ac5a | ||
|
|
0fa01c716b | ||
|
|
ef97b66383 | ||
|
|
0ca68bd61f | ||
|
|
33e9a77a1b | ||
|
|
fb492c7c6b | ||
|
|
dd0f3b3d8b | ||
|
|
b68d83f282 | ||
|
|
663eb89301 | ||
|
|
01d658e744 | ||
|
|
36349d365d | ||
|
|
61bbf1db05 | ||
|
|
688337f7c4 | ||
|
|
8ab24fca04 | ||
|
|
e043a91f07 | ||
|
|
448544786b | ||
|
|
f5859dbe08 | ||
|
|
c83a2cd983 | ||
|
|
b3c5ad948b | ||
|
|
a0cd51951e | ||
|
|
0198bcc55b | ||
|
|
35d659397a | ||
|
|
b08b26b691 | ||
|
|
4b56ab4ed6 | ||
|
|
5bfd19a828 | ||
|
|
5e75ab4be0 | ||
|
|
7f1ec4b1c6 | ||
|
|
2214f4faad | ||
|
|
a625f6f24c | ||
|
|
a66c2fb578 | ||
|
|
1662fad4ae | ||
|
|
600563222d | ||
|
|
cbc75a5353 | ||
|
|
4cb5a8400a | ||
|
|
b7c0ee0700 | ||
|
|
9455f3b7d3 | ||
|
|
a639f81e7b | ||
|
|
3bbea7136f | ||
|
|
868900fb76 | ||
|
|
9e470a441a | ||
|
|
b1a43d4843 | ||
|
|
9cb731cb36 | ||
|
|
93828e08b2 | ||
|
|
0b06c714fa | ||
|
|
cf5cc265cb | ||
|
|
366c6c90c3 | ||
|
|
98cc6ace48 | ||
|
|
751d78be0a | ||
|
|
acfb7fd442 | ||
|
|
750e2cec32 | ||
|
|
d01463fc9e | ||
|
|
534e5a0f49 | ||
|
|
3b2b2adde4 | ||
|
|
e905d37dab | ||
|
|
14dbde8615 | ||
|
|
62afd07694 | ||
|
|
933862c0cd | ||
|
|
d4d87b029f | ||
|
|
ed6a7d10c2 |
@@ -0,0 +1,174 @@
|
||||
本系列是 SQL 系列的开篇,介绍一些宏观与基础的内容。
|
||||
|
||||
## SQL 是什么?
|
||||
|
||||
SQL 是一种结构化查询语言,用于管理关系型数据库,我们 90% 接触的都是查询语法,但其实它包含完整的增删改查和事物处理功能。
|
||||
|
||||
## 声明式特性
|
||||
|
||||
SQL 属于声明式编程语言,而现代通用编程语言一般都是命令式的。但是不要盲目崇拜声明式语言,比如说它未来会代替低级的命令式语言,因为声明式本身也有它的缺点,它与命令式语言也有相通的地方。
|
||||
|
||||
为什么我们觉得声明式编程语言更高级?因为声明式语言抽象程度更高,比如 `select * from table1` 仅描述了要从 table1 查询数据,但查询的具体步骤的完全没提,这背后可能存在复杂的索引优化与锁机制,但我们都无需关心,这简直是编程的最高境界。
|
||||
|
||||
那为什么现在所有通用业务代码都是命令式呢?因为 **命令式给了我们描述具体实现的机会** ,而通用领域的编程正需要建立在严谨的实现细节上。比如校验用户权限这件事,即便 AI 编程提供了将 “登陆用户仅能访问有权限的资源” 转化为代码的能力,我们也不清楚资源具体指哪些,以及在权限转移过程中的资源所有权属于谁。
|
||||
|
||||
SQL 之所以能保留声明式特性,完全因为锁定了关系型数据管理这个特定领域,而恰恰对这个领域的需求是标准化且可枚举的,才使声明式成为可能。
|
||||
|
||||
基于命令式语言也完全可拓展出声明式能力,比如许多 ORM 提供了类似 `select({}).from({}).where({})` 之类的语法,甚至一个 `login()` 函数也是声明式编程的体现,因为调用者无需关心是如何登陆的,总之调用一下就完成了登陆,这不就是声明式的全部精髓吗?
|
||||
|
||||
## 语法分类
|
||||
|
||||
作为关系型数据库管理工具,SQL 需要定义、操纵与控制数据。
|
||||
|
||||
数据定义即修改数据库与表级别结构,这些是数据结构,或者是数据元信息,它不代表具体数据,但描述数据的属性。
|
||||
|
||||
数据操纵即修改一行行具体数据,增删改查。
|
||||
|
||||
数据控制即对事务、用户权限的管理与控制。
|
||||
|
||||
### 数据定义
|
||||
|
||||
DDL(Data Definition Language)数据定义,包括 `CREATE` `DROP` `ALTER` 方法。
|
||||
|
||||
### 数据操纵
|
||||
|
||||
DML(Data Manipulation Language)数据操纵,包括 `SELECT` `INSERT` `UPDATE` `DELETE` 方法。
|
||||
|
||||
### 数据控制
|
||||
|
||||
DCL(Data Control Language)数据控制,包括 `COMMIT`、`ROLLBACK` 等。
|
||||
|
||||
所有 SQL 操作都围绕这三种类型,其中数据操纵几乎占了 90% 的代码量,毕竟数据查询的诉求远大于写,数据写入对应数据采集,而数据查询对应数据分析,数据分析领域能玩出的花样远比数据采集要多。
|
||||
|
||||
PS:有些情况下,会把最重要的 `SELECT` 提到 DQL(Data Query Language)分类下,这样分类就变成了四个。
|
||||
|
||||
## 集合运算
|
||||
|
||||
SQL 世界的第一公民是集合,就像 JAVA 世界第一公民是对象。我们只有以集合的视角看待 SQL,才能更好的理解它。
|
||||
|
||||
何为集合视角,即所有的查询、操作都是二维数据结构中进行的,而非小学算术里的单个数字间加减乘除关系。
|
||||
|
||||
集合的运算一般有 `UNION` 并集、`EXCEPT` 差集、`INTERSECT` 交集,这些都是以行为单位的操作,而各种 JOIN 语句则是以列为单位的集合运算,也是后面提到的连接查询。
|
||||
|
||||
只要站在二维数据结构中进行思考,运算无非是横向或纵向的操作。
|
||||
|
||||
## 数据范式
|
||||
|
||||
数据范式分为五层,每层要求都比上一层更严苛,因此是一个可以逐步遵循的范式。数据范式要求数据越来越解耦,减少冗余。
|
||||
|
||||
比如第一范式要求每列都具有原子性,即都是不可分割的最小数据单元。如果数据采集时,某一列作为字符串存储,并且以 "|" 分割表示省市区,那么它就不具有原子性。
|
||||
|
||||
当然实际生产过程往往不都遵循这种标准,因为表不是孤立的,在数据处理流中,可能在某个环节再把列原子化,而原始数据为了压缩体积,进行列合并处理。
|
||||
|
||||
希望违反范式的还不仅是底层表,现在大数据处理场景下,越来越多的业务采用大宽表结构,甚至故意进行数据冗余以提升查询效率,列存储引擎就是针对这种场景设计的,所以数据范式在大数据场景下是可以变通的,但依然值得学习。
|
||||
|
||||
## 聚合
|
||||
|
||||
当采用 GROUP BY 分组聚合数据时,如希望针对聚合值筛选,就不能用 WHERE 限定条件了,因为 WHERE 是基于行的筛选,而不是针对组合的。(GROUP BY 对数据进行分组,我们称这些组为 “组合”),所以需要使用针对组合的筛选语句 HAVING:
|
||||
|
||||
```sql
|
||||
SELECT SUM(pv) FROM table
|
||||
GROUP BY city
|
||||
HAVING AVG(uv) > 100
|
||||
```
|
||||
|
||||
这个例子中,如果 HAVING 换成 WHERE 就没有意义,因为 WHERE 加聚合条件时,需要对所有数据进行合并,不符合当前视图的详细级别。(关于视图详细级别,在我之前写的 [精读《什么是 LOD 表达式》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/215.%E7%B2%BE%E8%AF%BB%E3%80%8A%E4%BB%80%E4%B9%88%E6%98%AF%20LOD%20%E8%A1%A8%E8%BE%BE%E5%BC%8F%E3%80%8B.md) 有详细说明)。
|
||||
|
||||
聚合如此重要,是因为我们分析数据必须在高 LEVEL 视角看,明细数据是看不出趋势的。而复杂的需求往往伴随着带有聚合的筛选条件,明白 SQL 是如何支持的非常重要。
|
||||
|
||||
## CASE 表达式
|
||||
|
||||
CASE 表达式分为简单与搜索 CASE 表达式,简单表达式:
|
||||
|
||||
```sql
|
||||
SELECT CASE pv WHEN 1 THEN 'low' ELSE 'high' END AS quality
|
||||
```
|
||||
|
||||
上面的例子利用 CASE 简单表达式形成了一个新字段,这种模式等于生成了业务自定义临时字段,在对当前表进行数据加工时非常有用。搜索 CASE 表达式能力完全覆盖简单 CASE 表达式:
|
||||
|
||||
```sql
|
||||
SELECT CASE WHEN pv < 100 THEN 'low' ELSE 'high' END AS quality
|
||||
```
|
||||
|
||||
可以看到,搜索 CASE 表达式可以用 “表达式” 描述条件,可以轻松完成更复杂的任务,甚至可以在表达式里使用子查询、聚合等手段,这些都是高手写 SQL 的惯用技巧,所以 CASE 表达式非常值得深入学习。
|
||||
|
||||
## 复杂查询
|
||||
|
||||
SELECT 是 SQL 最复杂的部分,其中就包含三种复杂查询模式,分别是连接查询与子查询。
|
||||
|
||||
### 连接查询
|
||||
|
||||
指 JOIN 查询,比如 LEFT JOIN、RIGHT JOIN、INNER JOIN。
|
||||
|
||||
在介绍聚合时我们提到了,连接查询本质上就是对列进行拓展,而两个表之间不会无缘无故合成一个,所以必须有一个外键作为关系纽带:
|
||||
|
||||
```sql
|
||||
SELECT A.pv, B.uv
|
||||
FROM table1 as t1 LEFT JOIN table2 AS P t2
|
||||
ON t1.productId = t2.productId
|
||||
```
|
||||
|
||||
连接查询不仅拓展了列,还会随之拓展行,而拓展方式与连接的查询的类型有关。除了连接查询别的表,还可以连接查询自己,比如:
|
||||
|
||||
```sql
|
||||
SELECT t1.pv AS pv1, P2.pv AS pv2
|
||||
FROM tt t1, tt t2
|
||||
```
|
||||
|
||||
这种子连接查询结果就是自己对自己的笛卡尔积,可通过 WHERE 筛选去重,后面会有文章专门介绍。
|
||||
|
||||
### 子查询与视图
|
||||
|
||||
子查询就是 SELECT 里套 SELECT,一般来说 SELECT 会从内到外执行,只有在关联子查询模式下,才会从外到内执行。
|
||||
|
||||
而如果把子查询保存下来,就是一个视图,这个视图并不是实体表,所以很灵活,且数据会随着原始表数据而变化:
|
||||
|
||||
```sql
|
||||
CREATE VIEW countryGDP (country, gdp)
|
||||
AS
|
||||
SELECT country, SUM(gdp)
|
||||
FROM tt
|
||||
GROUP BY country
|
||||
```
|
||||
|
||||
之后 `countryGDP` 这个视图就可以作为临时表来用了。
|
||||
|
||||
这种模式其实有点违背 SQL 声明式的特点,因为定义视图类似于定义变量,如果继续写下去,势必会形成一定命令式思维逻辑,但这是无法避免的。
|
||||
|
||||
## 事务
|
||||
|
||||
当 SQL 执行一连串操作时,难免遇到不执行完就会出现脏数据的问题,所以事务可以保证操作的原子性。一般来说每个 DML 操作都是一个内置事务,而 SQL 提供的 START TRANSACTION 就是让我们可以自定义事务范围,使一连串业务操作都可以包装在一起,成为一个原子性操作。
|
||||
|
||||
对 SQL 来说,原子性操作是非常安全的,即失败了不会留下任何痕迹,成功了会全部成功,不会存在中间态。
|
||||
|
||||
## OLAP
|
||||
|
||||
OLAP(OnLine Analytical Processing)即实时数据分析,是 BI 工具背后计算引擎实现的基础。
|
||||
|
||||
现在越来越多的 SQL 数据库支持了窗口函数实现,用于实现业务上的 runningSum 或 runningAvg 等功能,这些都是数据分析中很常见的。
|
||||
|
||||
以 runningSum 为例,比如双十一实时表的数据是以分钟为单位的实时 GMV,而我们要做一张累计到当前时间的 GMV 汇总折线图,Y 轴就需要支持 `running_sum(GMV)` 这样的表达式,而这背后可能就是通过窗口函数实现的。
|
||||
|
||||
当然也不是所有业务函数都由 SQL 直接提供,业务层仍需实现大量内存函数,在 JAVA 层计算,这其中一部分是需要下推到 SQL 执行的,只有内存函数与下推函数结合在一起,才能形成我们在 BI 工具看到的复杂计算字段效果。
|
||||
|
||||
## 总结
|
||||
|
||||
SQL 是一种声明式语言,一个看似简单的查询语句,在引擎层往往对应着复杂的实现,这就是 SQL 为何如此重要却又如此普及的原因。
|
||||
|
||||
虽然 SQL 容易上手,但要系统的理解它,还得从结构化数据与集合的概念开始进行思想转变。
|
||||
|
||||
不要小看 CASE 语法,它不仅与容易与编程语言的 CASE 语法产生混淆,本身结合表达式进行条件分支判断,是许多数据分析师在日常工作中最长用的套路。
|
||||
|
||||
现在使用简单 SQL 创建应用的场景越来越少了,但 BI 场景下,基于 SQL 的增强表达式场景越来越多了,本系列我就是以理解 BI 场景下查询表达式为目标创建的,希望能够学以致用。
|
||||
|
||||
> 讨论地址是:[精读《SQL 入门》· Issue #398 · ascoders/weekly](https://github.com/ascoders/weekly/issues/398)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,199 @@
|
||||
SQL 为什么要支持聚合查询呢?
|
||||
|
||||
这看上去是个幼稚的问题,但我们还是一步步思考一下。数据以行为粒度存储,最简单的 SQL 语句是 `select * from test`,拿到的是整个二维表明细,但仅做到这一点远远不够,出于以下两个目的,需要 SQL 提供聚合函数:
|
||||
|
||||
1. 明细数据没有统计意义,比如我想知道今天的营业额一共有多少,而不太关心某桌客人消费了多少。
|
||||
2. 虽然可以先把数据查到内存中再聚合,但在数据量非常大的情况下很容易把内存撑爆,可能一张表一天的数据量就有 10TB,而 10TB 数据就算能读到内存里,聚合计算可能也会慢到难以接受。
|
||||
|
||||
另外聚合本身也有一定逻辑复杂度,而 SQL 提供了聚合函数与分组聚合能力,可以方便快速的统计出有业务价值的聚合数据,这奠定了 SQL 语言的分析价值,因此大部分分析软件直接采用 SQL 作为直接面向用户的表达式。
|
||||
|
||||
## 聚合函数
|
||||
|
||||
常见的聚合函数有:
|
||||
|
||||
- COUNT:计数。
|
||||
- SUM:求和。
|
||||
- AVG:求平均值。
|
||||
- MAX:求最大值。
|
||||
- MIN:求最小值。
|
||||
|
||||
### COUNT
|
||||
|
||||
COUNT 用来计算有多少条数据,比如我们看 id 这一列有多少条:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(id) FROM test
|
||||
```
|
||||
|
||||
但我们发现其实查任何一列的 COUNT 都是一样的,那传入 id 有什么意义呢?没必要特殊找一个具体列指代呀,所以也可以写成:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM test
|
||||
```
|
||||
|
||||
但这两者存在微妙差异。SQL 存在一种很特殊的值类型 `NULL`,如果 COUNT 指定了具体列,则统计时会跳过此列值为 `NULL` 的行,而 `COUNT(*)` 由于未指定具体列,所以就算包含了 `NULL`,甚至某一行所有列都为 `NULL`,也都会包含进来。所以 `COUNT(*)` 查出的结果一定大于等于 `COUNT(c1)`。
|
||||
|
||||
当然任何聚合函数都可以跟随查询条件 WHERE,比如:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM test
|
||||
WHERE is_gray = 1
|
||||
```
|
||||
|
||||
### SUM
|
||||
|
||||
SUM 求和所有项,因此必须作用于数值字段,而不能用于字符串。
|
||||
|
||||
```sql
|
||||
SELECT SUM(cost) FROM test
|
||||
```
|
||||
|
||||
SUM 遇到 NULL 值时当 0 处理,因为这等价于忽略。
|
||||
|
||||
### AVG
|
||||
|
||||
AVG 求所有项均值,因此必须作用于数值字段,而不能用于字符串。
|
||||
|
||||
```sql
|
||||
SELECT AVG(cost) FROM test
|
||||
```
|
||||
|
||||
AVG 遇到 NULL 值时采用了最彻底的忽略方式,即 NULL 完全不参与分子与分母的计算,就像这一行数据不存在一样。
|
||||
|
||||
### MAX、MIN
|
||||
|
||||
MAX、MIN 分别求最大与最小值,与上面不同的是,也可以作用于字符串上,因此可以根据字母判断大小,从大到小依次对应 `a-z`,但即便能算,也没有实际意义且不好理解,因此不建议对字符串求极值。
|
||||
|
||||
```sql
|
||||
SELECT MAX(cost) FROM test
|
||||
```
|
||||
|
||||
### 多个聚合字段
|
||||
|
||||
虽然都是聚合函数,但 MAX、MIN 严格意义上不算是聚合函数,因为它们只是寻找了满足条件的行。可以看看下面两段查询结果的对比:
|
||||
|
||||
```sql
|
||||
SELECT MAX(cost), id FROM test -- id: 100
|
||||
SELECT SUM(cost), id FROM test -- id: 1
|
||||
```
|
||||
|
||||
第一条查询可以找到最大值那一行的 id,而第二条查询的 id 是无意义的,因为不知道归属在哪一行,所以只返回了第一条数据的 id。
|
||||
|
||||
当然,如果同时计算 MAX、MIN,那么此时 id 也只返回第一条数据的值,因为这个查询结果对应了复数行:
|
||||
|
||||
```sql
|
||||
SELECT MAX(cost), MIN(cost), id FROM test -- id: 1
|
||||
```
|
||||
|
||||
基于这些特性,最好不要混用聚合与非聚合,也就是一条查询一旦有一个字段是聚合的,那么所有字段都要聚合。
|
||||
|
||||
现在很多 BI 引擎的自定义字段都有这条限制,因为混用聚合与非聚合在自定义内存计算时处理起来边界情况很多,虽然 SQL 能支持,但业务自定义的函数可能不支持。
|
||||
|
||||
## 分组聚合
|
||||
|
||||
分组聚合就是 GROUP BY,其实可以把它当作一种高级的条件语句。
|
||||
|
||||
举个例子,查询每个国家的 GDP 总量:
|
||||
|
||||
```sql
|
||||
SELECT SUM(GDP) FROM amazing_table
|
||||
GROUP BY country
|
||||
```
|
||||
|
||||
返回的结果就会按照国家进行分组,这时,聚合函数就变成了在组内聚合。
|
||||
|
||||
其实如果我们只想看中、美的 GDP,用非分组也可以查,只是要分成两条 SQL:
|
||||
|
||||
```sql
|
||||
SELECT SUM(GDP) FROM amazing_table
|
||||
WHERE country = '中国'
|
||||
|
||||
SELECT SUM(GDP) FROM amazing_table
|
||||
WHERE country = '美国'
|
||||
```
|
||||
|
||||
所以 GROUP BY 也可理解为,将某个字段的所有可枚举的情况都查了出来,并整合成一张表,每一行代表了一种枚举情况,不需要分解为一个个 WHERE 查询了。
|
||||
|
||||
### 多字段分组聚合
|
||||
|
||||
GROUP BY 可以对多个维度使用,含义等价于表格查询时行/列拖入多个维度。
|
||||
|
||||
上面是 BI 查询工具视角,如果没有上下文,可以看下面这个递进描述:
|
||||
|
||||
- 按照多个字段进行分组聚合。
|
||||
- 多字段组合起来成为唯一 Key,即 `GROUP BY a,b` 表示 a,b 合在一起描述一个组。
|
||||
- `GROUP BY a,b,c` 查询结果第一列可能看到许多重复的 a 行,第二列看到重复 b 行,但在同一个 a 值内不会重复,c 在 b 行中同理。
|
||||
|
||||
下面是一个例子:
|
||||
|
||||
```sql
|
||||
SELECT SUM(GDP) FROM amazing_table
|
||||
GROUP BY province, city, area
|
||||
```
|
||||
|
||||
查询结果为:
|
||||
|
||||
```text
|
||||
浙江 杭州 余杭区
|
||||
浙江 杭州 西湖区
|
||||
浙江 宁波 海曙区
|
||||
浙江 宁波 江北区
|
||||
北京 .........
|
||||
```
|
||||
|
||||
### GROUP BY + WHERE
|
||||
|
||||
WHERE 是根据行进行条件筛选的。因此 GROUP BY + WHERE 并不是在组内做筛选,而是对整体做筛选。
|
||||
|
||||
但由于按行筛选,其实组内或非组内结果都完全一样,所以我们几乎无法感知这种差异:
|
||||
|
||||
```sql
|
||||
SELECT SUM(GDP) FROM amazing_table
|
||||
GROUP BY province, city, area
|
||||
WHERE industry = 'internet'
|
||||
```
|
||||
|
||||
然而,忽略这个差异会导致我们在聚合筛选时碰壁。
|
||||
|
||||
比如要筛选出平均分大于 60 学生的成绩总和,如果不使用子查询,是无法在普通查询中在 WHERE 加聚合函数实现的,比如下面就是一个语法错误的例子:
|
||||
|
||||
```sql
|
||||
SELECT SUM(score) FROM amazing_table
|
||||
WHERE AVG(score) > 60
|
||||
```
|
||||
|
||||
不要幻想上面的 SQL 可以执行成功,不要在 WHERE 里使用聚合函数。
|
||||
|
||||
### GROUP BY + HAVING
|
||||
|
||||
HAVING 是根据组进行条件筛选的。因此可以在 HAVING 使用聚合函数:
|
||||
|
||||
```sql
|
||||
SELECT SUM(score) FROM amazing_table
|
||||
GROUP BY class_name
|
||||
HAVING AVG(score) > 60
|
||||
```
|
||||
|
||||
上面的例子中可以正常查询,表示按照班级分组看总分,且仅筛选出平均分大于 60 的班级。
|
||||
|
||||
所以为什么 HAVING 可以使用聚合条件呢?因为 HAVING 筛选的是组,所以可以对组聚合后过滤掉不满足条件的组,这样是有意义的。而 WHERE 是针对行粒度的,聚合后全表就只有一条数据,无论过滤与否都没有意义。
|
||||
|
||||
但要注意的是,GROUP BY 生成派生表是无法利用索引筛选的,所以 WHERE 可以利用给字段建立索引优化性能,而 HAVING 针对索引字段不起作用。
|
||||
|
||||
## 总结
|
||||
|
||||
聚合函数 + 分组可以实现大部分简单 SQL 需求,在写 SQL 表达式时,需要思考这样的表达式是如何计算的,比如 `MAX(c1), c2` 是合理的,而 `SUM(c1), c2` 这个 `c2` 就是无意义的。
|
||||
|
||||
最后记住 WHERE 是 GROUP BY 之前执行的,HAVING 针对组进行筛选。
|
||||
|
||||
> 讨论地址是:[精读《SQL 聚合查询》· Issue #401 · ascoders/weekly](https://github.com/ascoders/weekly/issues/401)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,176 @@
|
||||
SQL 复杂查询指的就是子查询。
|
||||
|
||||
为什么子查询叫做复杂查询呢?因为子查询相当于查询嵌套查询,因为嵌套导致复杂度几乎可以被无限放大(无限嵌套),因此叫复杂查询。下面是一个最简单的子查询例子:
|
||||
|
||||
```sql
|
||||
SELECT pv FROM (
|
||||
SELECT pv FROM test
|
||||
)
|
||||
```
|
||||
|
||||
上面的例子等价于 `SELECT pv FROM test`,但因为把表的位置替换成了一个新查询,所以摇身一变成为了复杂查询!所以复杂查询不一定真的复杂,甚至可能写出和普通查询等价的复杂查询,要避免这种无意义的行为。
|
||||
|
||||
我们也要借此机会了解为什么子查询可以这么做。
|
||||
|
||||
### 理解查询的本质
|
||||
|
||||
当我们查一张表时,数据库认为我们在查什么?
|
||||
|
||||
这点很重要,因为下面两个语句都是合法的:
|
||||
|
||||
```sql
|
||||
SELECT pv FROM test
|
||||
|
||||
SELECT pv FROM (
|
||||
SELECT pv FROM test
|
||||
)
|
||||
```
|
||||
|
||||
为什么数据库可以把子查询当作表呢?为了统一理解这些概念,我们有必要对查询内容进行抽象理解:**任意查询位置都是一条或多条记录**。
|
||||
|
||||
比如 `test` 这张表,显然是多条记录(当然只有一行就是一条记录),而 `SELECT pv FROM test` 也是多条记录,然而因为 `FROM` 后面可以查询任意条数的记录,所以这两种语法都支持。
|
||||
|
||||
不仅是 `FROM` 可以跟单条或多条记录,甚至 `SELECT`、`GROUP BY`、`WHERE`、`HAVING` 后都可以跟多条记录,这个后面再说。
|
||||
|
||||
说到这,也就很好理解子查询的变种了,比如我们可以在子查询内使用 `WHERE` 或 `GROUP BY` 等等,因为无论如何,只要查询结果是多条记录就行了:
|
||||
|
||||
```sql
|
||||
SELECT sum(people) as allPeople, sum(gdp), city FROM (
|
||||
SELECT people, gdp, city FROM test
|
||||
GROUP BY city
|
||||
HAVING sum(gdp) > 10000
|
||||
)
|
||||
```
|
||||
|
||||
这个例子就有点业务含义了。子查询是从内而外执行的,因此我们先看内部的逻辑:按照城市分组,筛选出总 GDP 超过一万的所有地区的人口数量明细。外层查询再把人口数加总,这样就能对比每个 GDP 超过一万的地区,总人口和总 GDP 分别是多少,方便对这些重点城市做对比。
|
||||
|
||||
不过这个例子看起来还是不太自然,因为我们没必要写成复杂查询,其实简单查询也是等价的:
|
||||
|
||||
```sql
|
||||
SELECT sum(people) as allPeople, sum(gdp), city FROM test
|
||||
GROUP BY city
|
||||
HAVING sum(gdp) > 10000
|
||||
```
|
||||
|
||||
那为什么要多此一举呢?因为复杂查询的真正用法并不在这里。
|
||||
|
||||
### 视图
|
||||
|
||||
正因为子查询的存在,我们才可能以类似抽取变量的方式,抽取子查询,这个抽取出来的抽象就是视图:
|
||||
|
||||
```sql
|
||||
CREATE VIEW my_table(people, gdp, city)
|
||||
AS
|
||||
SELECT sum(people) as allPeople, sum(gdp), city FROM test
|
||||
GROUP BY city
|
||||
HAVING sum(gdp) > 10000
|
||||
|
||||
SELECT sum(people) as allPeople, sum(gdp), city FROM my_table
|
||||
```
|
||||
|
||||
这样的好处是,这个视图可以被多条 SQL 语句复用,不仅可维护性变好了,执行时也仅需查询一次。
|
||||
|
||||
要注意的是,SELECT 可以使用任何视图,但 INSERT、DELETE、UPDATE 用于视图时,需要视图满足一下条件:
|
||||
|
||||
1. 未使用 DISTINCT 去重。
|
||||
2. FROM 单表。
|
||||
3. 未使用 GROUP BY 和 HAVING。
|
||||
|
||||
因为上面几种模式都会导致视图成为聚合后的数据,不方便做除了查以外的操作。
|
||||
|
||||
另外一个知识点就是物化视图,即使用 MATERIALIZED 描述视图:
|
||||
|
||||
```sql
|
||||
CREATE MATERIALIZED VIEW my_table(people, gdp, city)
|
||||
AS ...
|
||||
```
|
||||
|
||||
这种视图会落盘,为什么要支持这个特性呢?因为普通视图作为临时表,无法利用索引等优化手段,查询性能较低,所以物化视图是较为常见的性能优化手段。
|
||||
|
||||
说到性能优化手段,还有一些比较常见的理念,即把读的复杂度分摊到写的时候,比如提前聚合新表落盘或者对 CASE 语句固化为字段等,这里先不展开。
|
||||
|
||||
### 标量子查询
|
||||
|
||||
上面说了,WHERE 也可以跟子查询,比如:
|
||||
|
||||
```sql
|
||||
SELECT city FROM test
|
||||
WHERE gdp > (
|
||||
SELECT avg(gdp) from test
|
||||
)
|
||||
```
|
||||
|
||||
这样可以查询出 gdp 大于平均值的城市。
|
||||
|
||||
那为什么不能直接这么写呢?
|
||||
|
||||
```sql
|
||||
SELECT city FROM test
|
||||
WHERE gdp > avg(gdp) -- 报错,WHERE 无法使用聚合函数
|
||||
```
|
||||
|
||||
看上去很美好,但其实第一篇我们就介绍了,WHERE 不能跟聚合查询,因为这样会把整个父查询都聚合起来。那为什么子查询可以?因为子查询聚合的是子查询啊,父查询并没有被聚合,所以这才符合我们的意图。
|
||||
|
||||
所以上面例子不合适的地方在于,直接在当前查询使用 `avg(gdp)` 会导致聚合,而我们并不想聚合当前查询,但又要通过聚合拿到平均 GDP,所以就要使用子查询了!
|
||||
|
||||
回过头来看,为什么这一节叫标量子查询?标量即单一值,因为 `avg(gdp)` 聚合出来的只有一个值,所以 WHERE 可以把它当做一个单一数值使用。反之,如果子查询没有使用聚合函数,或 GROUP BY 分组,那么就不能使用 `WHERE >` 这种语法,但可以使用 `WHERE IN`,这涉及到单条与多条记录的思考,我们接着看下一节。
|
||||
|
||||
### 单条和多条记录
|
||||
|
||||
介绍标量子查询时说到了,`WHERE >` 的值必须时单一值。但其实 WHERE 也可以跟返回多条记录的子查询结果,只要使用合理的条件语句,比如 IN:
|
||||
|
||||
```sql
|
||||
SELECT area FROM test
|
||||
WHERE gdp IN (
|
||||
SELECT max(gdp) from test
|
||||
GROUP BY city
|
||||
)
|
||||
```
|
||||
|
||||
上面的例子,子查询按照城市分组,并找到每一组 GDP 最大的那条记录,所以如果数据粒度是区域,那么我们就查到了每个城市 GDP 最大的那些记录,然后父查询通过 WHERE IN 找到 gdp 符合的复数结果,所以最后就把每个城市最大 gdp 的区域列了出来。
|
||||
|
||||
但实际上 `WHERE >` 语句跟复数查询结果也不会报错,但没有任何意义,所以我们要理解查询结果是单条还是多条,在 WHERE 判断时选择合适的条件。WHERE 适合跟复数查询结果的语法有:`WHERE IN`、`WHERE SOME`、`WHERE ANY`。
|
||||
|
||||
### 关联子查询
|
||||
|
||||
所谓关联子查询,即父子查询间存在关联,既然如此,子查询肯定不能单独优先执行,毕竟和父查询存在关联嘛,所以关联子查询是先执行外层查询,再执行内层查询的。要注意的是,对每一行父查询,子查询都会执行一次,因此性能不高(当然 SQL 会对相同参数的子查询结果做缓存)。
|
||||
|
||||
那这个关联是什么呢?关联的是每一行父查询时,对子查询执行的条件。这么说可能有点绕,举个例子:
|
||||
|
||||
```sql
|
||||
SELECT * FROM test where gdp > (
|
||||
select avg(gdp) from test
|
||||
group by city
|
||||
)
|
||||
```
|
||||
|
||||
对这个例子来说,想要查找 gdp 大于按城市分组的平均 gdp,比如北京地区按北京比较,上海地区按上海比较。但很可惜这样做是不行的,因为父子查询没有关联,SQL 并不知道要按照相同城市比较,因此只要加一个 WHERE 条件,就变成关联子查询了:
|
||||
|
||||
```sql
|
||||
SELECT * FROM test as t1 where gdp > (
|
||||
select avg(gdp) from test as t2 where t1.city = t2.city
|
||||
group by city
|
||||
)
|
||||
```
|
||||
|
||||
就是在每次判断 `WHERE gdp >` 条件时,重新计算子查询结果,将平均值限定在相同的城市,这样就符合需求了。
|
||||
|
||||
## 总结
|
||||
|
||||
学会灵活运用父子查询,就掌握了复杂查询了。
|
||||
|
||||
SQL 第一公民是集合,所以所谓父子查询就是父子集合的灵活组合,这些集合可以出现在几乎任何位置,根据集合的数量、是否聚合、关联条件,就派生出了标量查询、关联子查询。
|
||||
|
||||
更深入的了解就需要大量实战案例了,但万变不离其宗,掌握了复杂查询后,就可以理解大部分 SQL 案例了。
|
||||
|
||||
> 讨论地址是:[精读《SQL 复杂查询》· Issue #403 · ascoders/weekly](https://github.com/ascoders/weekly/issues/403)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,154 @@
|
||||
CASE 表达式分为简单表达式与搜索表达式,其中搜索表达式可以覆盖简单表达式的全部能力,我也建议只写搜索表达式,而不要写简单表达式。
|
||||
|
||||
简单表达式:
|
||||
|
||||
```sql
|
||||
SELECT CASE city
|
||||
WHEN '北京' THEN 1
|
||||
WHEN '天津' THEN 2
|
||||
ELSE 0
|
||||
END AS abc
|
||||
FROM test
|
||||
```
|
||||
|
||||
搜索表达式:
|
||||
|
||||
```sql
|
||||
SELECT CASE
|
||||
WHEN city = '北京' THEN 1
|
||||
WHEN city = '天津' THEN 2
|
||||
ELSE 0
|
||||
END AS abc
|
||||
FROM test
|
||||
```
|
||||
|
||||
明显可以看出,简单表达式只是搜索表达式 `a = b` 的特例,因为无法书写任何符号,只要条件换成 `a > b` 就无法胜任了,而搜索表达式不但可以轻松胜任,甚至可以写聚合函数。
|
||||
|
||||
## CASE 表达式里的聚合函数
|
||||
|
||||
为什么 CASE 表达式里可以写聚合函数?
|
||||
|
||||
因为本身表达式就支持聚合函数,比如下面的语法,我们不会觉得奇怪:
|
||||
|
||||
```sql
|
||||
SELECT sum(pv), avg(uv) from test
|
||||
```
|
||||
|
||||
本身 SQL 就支持多种不同的聚合方式同时计算,所以将其用在 CASE 表达式里,也是顺其自然的:
|
||||
|
||||
```sql
|
||||
SELECT CASE
|
||||
WHEN count(city) = 100 THEN 1
|
||||
WHEN sum(dau) > 200 THEN 2
|
||||
ELSE 0
|
||||
END AS abc
|
||||
FROM test
|
||||
```
|
||||
|
||||
只要 SQL 表达式中存在聚合函数,那么整个表达式都聚合了,此时访问非聚合变量没有任何意义。所以上面的例子,即便在 CASE 表达式中使用了聚合,其实也不过是聚合了一次后,按照条件进行判断罢了。
|
||||
|
||||
这个特性可以解决很多实际问题,比如将一些复杂聚合判断条件的结果用 SQL 结构输出,那么很可能是下面这种写法:
|
||||
|
||||
```sql
|
||||
SELECT CASE
|
||||
WHEN 聚合函数(字段) 符合什么条件 THEN xxx
|
||||
... 可能有 N 个
|
||||
ELSE NULL
|
||||
END AS abc
|
||||
FROM test
|
||||
```
|
||||
|
||||
这也可以认为是一种行转列的过程,即 **把行聚合后的结果通过一条条 CASE 表达式形成一个个新的列**。
|
||||
|
||||
## 聚合与非聚合不能混用
|
||||
|
||||
我们希望利用 CASE 表达式找出那些 pv 大于平均值的行,以下这种想当然的写法是错误的:
|
||||
|
||||
```sql
|
||||
SELECT CASE
|
||||
WHEN pv > avg(pv) THEN 'yes'
|
||||
ELSE 'no'
|
||||
END AS abc
|
||||
FROM test
|
||||
```
|
||||
|
||||
原因是,只要 SQL 中存在聚合表达式,那么整条 SQL 就都是聚合的,所以返回的结果只有一条,而我们期望查询结果不聚合,只是判断条件用到了聚合结果,那么就要使用子查询。
|
||||
|
||||
为什么子查询可以解决问题?因为子查询的聚合发生在子查询,而不影响当前父查询,理解了这一点,就知道为什么下面的写法才是正确的了:
|
||||
|
||||
```sql
|
||||
SELECT CASE
|
||||
WHEN pv > ( SELECT avg(pv) from test ) THEN 'yes'
|
||||
ELSE 'no'
|
||||
END AS abc
|
||||
FROM test
|
||||
```
|
||||
|
||||
这个例子也说明了 CASE 表达式里可以使用子查询,因为子查询是先计算的,所以查询结果在哪儿都能用,CASE 表达式也不例外。
|
||||
|
||||
## WHERE 中的 CASE
|
||||
|
||||
WHERE 后面也可以跟 CASE 表达式的,用来做一些需要特殊枚举处理的筛选。
|
||||
|
||||
比如下面的例子:
|
||||
|
||||
```sql
|
||||
SELECT * FROM demo WHERE
|
||||
CASE
|
||||
WHEN city = '北京' THEN true
|
||||
ELSE ID > 5
|
||||
END
|
||||
```
|
||||
|
||||
本来我们要查询 ID 大于 5 的数据,但我想对北京这个城市特别对待,那么就可以在判断条件中再进行 CASE 分支判断。
|
||||
|
||||
这个场景在 BI 工具里等价于,创建一个 CASE 表达式字段,可以拖入筛选条件生效。
|
||||
|
||||
## GROUP BY 中的 CASE
|
||||
|
||||
想不到吧,GROUP BY 里都可以写 CASE 表达式:
|
||||
|
||||
```sql
|
||||
SELECT isPower, sum(gdp) FROM test GROUP BY CASE
|
||||
WHEN isPower = 1 THEN city, area
|
||||
ELSE city
|
||||
END
|
||||
```
|
||||
|
||||
上面例子表示,计算 GDP 时,对于非常发达的城市,按照每个区粒度查看聚合结果,也就是看的粒度更细一些,而对于欠发达地区,本身 gdp 也不高,直接按照城市粒度看聚合结果。
|
||||
|
||||
这样,就按照不同的条件对数据进行了分组聚合。由于返回行结果是混在一起的,像这个例子,可以根据 isPower 字段是否为 1 判断,是否按照城市、区域进行了聚合,如果没有其他更显著的标识,可能导致无法区分不同行的聚合粒度,因此谨慎使用。
|
||||
|
||||
## ORDER BY 中的 CASE
|
||||
|
||||
同样,ORDER BY 使用 CASE 表达式,会将排序结果按照 CASE 分类进行分组,每组按照自己的规则排序,比如:
|
||||
|
||||
```sql
|
||||
SELECT * FROM test ORDER BY CASE
|
||||
WHEN isPower = 1 THEN gdp
|
||||
ELSE people
|
||||
END
|
||||
```
|
||||
|
||||
上面的例子,对发达地区采用 gdp 排序,否则采用人口数量排序。
|
||||
|
||||
## 总结
|
||||
|
||||
CASE 表达式总结一下有如下特点:
|
||||
|
||||
1. 支持简单与搜索两种写法,推荐搜索写法。
|
||||
2. 支持聚合与子查询,需要注意不同情况的特点。
|
||||
3. 可以写在 SQL 查询的几乎任何地方,只要是可以写字段的地方,基本上就可以替换为 CASE 表达式。
|
||||
4. 除了 SELECT 外,CASE 表达式还广泛应用在 INSERT 与 UPDATE,其中 UPDATE 的妙用是不用将 SQL 拆分为多条,所以不用担心数据变更后对判断条件的二次影响。
|
||||
|
||||
> 讨论地址是:[精读《SQL CASE 表达式》· Issue #404 · ascoders/weekly](https://github.com/ascoders/weekly/issues/404)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ const fs = require("fs");
|
||||
|
||||
const dirs = [
|
||||
"前沿技术",
|
||||
"TS 类型体操",
|
||||
"设计模式",
|
||||
"编译原理",
|
||||
"源码解读",
|
||||
"商业思考",
|
||||
"算法",
|
||||
"SQL"
|
||||
];
|
||||
|
||||
dirs.forEach((dir) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
前端界的好文精读,每周更新!
|
||||
|
||||
最新精读:<a href="./前沿技术/225.%E7%B2%BE%E8%AF%BB%E3%80%8AExcel%20JS%20API%E3%80%8B.md">225.精读《Excel JS API》</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)
|
||||
|
||||
@@ -183,6 +183,18 @@
|
||||
- <a href="./前沿技术/223.%E7%B2%BE%E8%AF%BB%E3%80%8ARecords%20%26%20Tuples%20%E6%8F%90%E6%A1%88%E3%80%8B.md">223.精读《Records & Tuples 提案》</a>
|
||||
- <a href="./前沿技术/224.%E7%B2%BE%E8%AF%BB%E3%80%8ARecords%20%26%20Tuples%20for%20React%E3%80%8B.md">224.精读《Records & Tuples for React》</a>
|
||||
- <a href="./前沿技术/225.%E7%B2%BE%E8%AF%BB%E3%80%8AExcel%20JS%20API%E3%80%8B.md">225.精读《Excel JS API》</a>
|
||||
- <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>
|
||||
|
||||
### 设计模式
|
||||
|
||||
@@ -237,6 +249,9 @@
|
||||
- <a href="./源码解读/151.%20%E7%B2%BE%E8%AF%BB%E3%80%8A%40umijs%20use-request%E3%80%8B%E6%BA%90%E7%A0%81.md">151. 精读《@umijs use-request》源码</a>
|
||||
- <a href="./源码解读/155.%20%E7%B2%BE%E8%AF%BB%E3%80%8Ause-what-changed%20%E6%BA%90%E7%A0%81%E3%80%8B.md">155. 精读《use-what-changed 源码》</a>
|
||||
- <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>
|
||||
|
||||
### 商业思考
|
||||
|
||||
@@ -260,6 +275,15 @@
|
||||
- <a href="./算法/201.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%20-%20%E4%BA%8C%E5%8F%89%E6%A0%91%E3%80%8B.md">201.精读《算法 - 二叉树》</a>
|
||||
- <a href="./算法/203.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%20-%20%E4%BA%8C%E5%8F%89%E6%90%9C%E7%B4%A2%E6%A0%91%E3%80%8B.md">203.精读《算法 - 二叉搜索树》</a>
|
||||
|
||||
### SQL
|
||||
|
||||
- <a href="./SQL/231.SQL%20%E5%85%A5%E9%97%A8.md">231.SQL 入门</a>
|
||||
- <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>
|
||||
|
||||
## 关注前端精读微信公众号
|
||||
|
||||
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
|
||||
|
||||
@@ -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" }`,在代码中就可以直接利用以下方式直接从对方应用调用模块:
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
通过了基础问题还远远不够。甚至当问一个复杂的问题的时候,如果候选人瞬间把答案完美流畅表达出来,说明这个问题基本上白问了。
|
||||
|
||||
**技术面更应该考察候选人的思考过程和基于此来表达出的技术能力和项目经验。**如果候选人基础没有落下太多,思维足够灵活,在过往项目中主动学习,并主导解决过项目问题,说明已经比较优秀了,我们招的每一人都应当拥有激情与学习能力。
|
||||
**技术面更应该考察候选人的思考过程和基于此来表达出的技术能力和项目经验**。如果候选人基础没有落下太多,思维足够灵活,在过往项目中主动学习,并主导解决过项目问题,说明已经比较优秀了,我们招的每一人都应当拥有激情与学习能力。
|
||||
|
||||
所以,当问到候选人不了解的知识点时,通过引导并挖掘出候选人拥有多少问题解决能力,才是最大的权重项,如果这个问题候选人也提前准备了,那说明准备对了。
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ LayoutTree 和 DOM 结构很像了,但比如 `display: none` 的元素不会
|
||||
|
||||
### 从渲染分层看性能优化
|
||||
|
||||
本篇提到了浏览器渲染的 5 个重要环节:解析、样式、布局、绘图、合成,是前端开发者日常工作中对浏览器体感最深的部分,也是优化最长发生在的部分。
|
||||
本篇提到了浏览器渲染的 5 个重要环节:解析、样式、布局、绘图、合成,是前端开发者日常工作中对浏览器体感最深的部分,也是优化最常发生在的部分。
|
||||
|
||||
其实从性能优化角度来看,解析环节可以被替代为 JS 环节,因为现代 JS 框架往往没有什么 HTML 模版内容要解析,几乎全是 JS 操作 DOM,所以可以看作 5 个新环节:JS、样式、布局、绘图、合成。
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ document.body.addEventListener('touchstart', event => {
|
||||
|
||||
```css
|
||||
#area {
|
||||
touch-action: pan-x;
|
||||
touch-action: none;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
[2021 JavaScript Rising Stars](https://risingstars.js.org/2021/en) 每年都会对前端开源项目进行点评,其依据是去年 Star 的增幅。Star 虽然只是一个维度,但至少反应了流行度,根据这个排行榜可以大体分析出前端社区的趋势。
|
||||
|
||||
## 精读
|
||||
|
||||
该榜单包含整体榜单、前端框架、Node 框架、构建工具、Vue 生态、React 生态、CSS-In-JS、测试、移动端、桌面、静态建站、状态管理、GraphQL 共 13 个子榜单,都是前端开源最活跃的几个领域,下面分别介绍。
|
||||
|
||||
### 整体榜单
|
||||
|
||||
第一名 [zx](https://github.com/google/zx) 是一个命令行工具,它基于 Node 语法拓展了 Bash 支持,可以非常方便的进行 Node 与 Bash 之间的输入输出,就像 Node 原生就支持 Bash 一样。它解决了离不开 Bash,但 Bash 写起大段逻辑不如 Node 自然的痛点。
|
||||
|
||||
第二名 [vite](https://github.com/vitejs/vite) 是去年最闪耀的星,它是一个 bundless 概念的前端构建工具,最初服务于 vue,后来进行框架无关升级后,在 react、angular 生态都大受欢迎。它解决了 webpack 编译太慢,其他 bundless 方案不够开箱即用且存在大量兼容问题的痛点。
|
||||
|
||||
第三名 [next.js](https://github.com/vercel/next.js) 2016 年开始的项目,是一个大而全的 React 全家桶,定位就是各大厂都会自己做一套的前端一体化框架,但它更时髦,不断加入许多流行功能比如 Server Component。这和 next.js 所在的明星公司 Vercel 有关,这家公司挖了大量开源知名人物,包括 Svelte 作者与 React 团队核心成员,所以也许未来社区的新玩具会先用在 next.js 再独立开源。它给出了前端最佳实践,并解决了没有精力持续给项目进行全方位优化,或追逐不上潮流的问题,因为 next.js 本身正在成为前端潮流的发源地。
|
||||
|
||||
第四名 [react](https://github.com/facebook/react) 不用多说了,数据驱动、响应式编程、函数式的领军框架,它改变了前端开发效率。
|
||||
|
||||
第五名 [tauri](https://github.com/tauri-apps/tauri) 比 electron 更轻量的桌面应用开发框架,基于任何前端框架。它解决了前端开发者遇到桌面应用开发场景时各平台巨大的原生开发学习成本的痛点。
|
||||
|
||||
第六名 [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) 是 css 框架,它提供了大量语义化 className,提供了许多最佳实践,让你有机会把 css 打理的井然有序。它解决了前端项目 css 杂乱无章又没有人真的在意的痛点。
|
||||
|
||||
第七名 [vscode](https://github.com/microsoft/vscode) 宇宙级 IDE,它解决了程序员没有真正趁手软件写代码的痛点。
|
||||
|
||||
第八名 [Slidev](https://github.com/slidevjs/slidev) 是一个把 markdown 渲染成 PPT 的框架,基于 vite + vue 等技术栈开发。用它开发的 PPT 非常简洁美观,非常适合在公开场合分享时使用,不仅看起来赏心悦目,还可以不经意间切换到 Markdown 源码 hotfix 一下小错误,展示出你的极客精神。它解决了你真的只想展示几句话,但又要以 PPT 方式 show 出来的痛点。
|
||||
|
||||
第九名 [NocoDB](https://github.com/nocodb/nocodb) 是一个支持多种数据源的数据库 UI 管理工具。但其实它有更大的格局,即对标 [airtable](https://www.airtable.com/),即用 NocoDB 连接数据库后,一切数据可视化的操作与功能都成为了可能,且提供了大量工作常用的甘特图、电子表格等视图,并可互相转换,最终其实数据存储到连接的数据库,但你无需关心细节。它解决了基于二维表格数据开发各类生产工具需投入大量研发资源的痛点。
|
||||
|
||||
第十名 [Vue](https://github.com/vuejs/vue) 和 React 一样不多说了。
|
||||
|
||||
### 前端框架
|
||||
|
||||
第一名 [react](https://github.com/facebook/react) 在整体榜单里了。
|
||||
|
||||
第二名 [Vue](https://github.com/vuejs/vue) 也在整体榜单里了。
|
||||
|
||||
第三名 [svelte](https://github.com/sveltejs/svelte) 是一个类似 vue 的框架,但特色是极度重视编译时,而忽略运行时,即运行时除了必要逻辑外是完全不引入任何 runtime 框架的。说实话我觉得和 vue、react 相比在正儿八经项目中并没有核心优势,因为它并没有那种魔法能力,可以极大的减少大型项目体积与提升性能,反而会受制于其语法与编译时的特性产生副作用。但唯一一个好处是框架无关,即利用 svelte 编译的组件几乎没有额外运行时框架代码,可以最低成本,最大隔离性的与其他项目结合。
|
||||
|
||||
第四名 [angular](https://github.com/angular/angular) 笔者已经很久没有关注 angular 框架了,无法给出什么点评。但从 svelte 新增热度超过 angular 来看,可能大部分开发者对 angular 的态度和我一样。
|
||||
|
||||
第五名 [solid](https://github.com/solidjs/solid) 类似 svelte,提前编译,按需打包,重要的是,其类似 React `useEffect` 的 API `createEffect` 在依赖变化后,仅该函数会重新执行,而不会导致整个组件重新执行,在点对点更新上做得更极致。
|
||||
|
||||
前端框架的亮点是 svelte 与 solid 的概念,即重编译时,轻运行时,更加原子化的更新粒度,与更直接的调用原生浏览器方法带来性能提升。很难不让人觉得这是一个前端框架新趋势,但我翻了不少资料发现,这种创新带来的收益在正常项目里微乎其微,所以实际上 2021 年前端框架还是没能跳出三巨头创造新的概念,而以 svelte 与 solid 为代表的 “静态化” 框架只能算微创新。
|
||||
|
||||
### Node 框架
|
||||
|
||||
第一名 [next.js](https://github.com/vercel/next.js) 在整体榜单里了,在 Node 框架一骑绝尘。
|
||||
|
||||
第二名 [nest](https://github.com/nestjs/nest) 是一个 node 版 server 框架,支持传统的 Controller、Module、Service,支持用装饰器申明路由、控制器等,语法上比较时髦。
|
||||
|
||||
第三名 [Strapi](https://github.com/strapi/strapi) 专门为 API 场景服务,提供了一个 API 管理后台,解决了只需要一个便捷 API 管理,而不希望了解一个大而全的后端框架的痛点。
|
||||
|
||||
第四名 [remix](https://github.com/remix-run/remix) 其实和 next.js 定位差不多,由 react-router 作者开发,才开源不久,需要进一步观察。
|
||||
|
||||
第五名 [nuxt.js](https://github.com/nuxt/nuxt.js) 是 vue 领域的 next.js。
|
||||
|
||||
值得一提的是,svelte 也有自己的专属框架 [sveltekit](https://kit.svelte.dev/),所以 Node 后端框架之争大部分其实在打全栈的牌,毕竟 Node 的优势就是支持 js 语言,而当前端应用基于某个框架编写时,如果有一个 Node 框架可以无缝集成这个前端框架,它就比非 Node 框架更优。
|
||||
|
||||
不过大厂几乎都是前后端分离的,所以这种全栈优势框架在国内没有太多出场机会,如果你是一个个人博主,还是首推使用全栈框架建站。
|
||||
|
||||
### 构建工具
|
||||
|
||||
第一名 [vite](https://github.com/vitejs/vite) 在整体榜单里了,在构建工具里也是一骑绝尘。
|
||||
|
||||
第二名 [esbuild](https://github.com/evanw/esbuild) 是用 go 编写的构建工具,适用使用范围更广,其压缩模块在 bundless 还未成熟时就被各大构建全家桶提前集成了,而 vite 也是基于 esbuild 进行编译的,但 vite 的火热度更高,说明了整体 bundless 方案已在 2021 年成熟了。
|
||||
|
||||
第三名 [swc](https://github.com/swc-project/swc) 因采用 rust 编写而知名,类似 esbuild,但因为依托 rust 编译到 wasm 的特性,支持了在线编译器,非常方便。swc 还被大量新生代构建工具作为基建,这在 [精读《Rust 是 JS 基建的未来》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/218.%E7%B2%BE%E8%AF%BB%E3%80%8ARust%20%E6%98%AF%20JS%20%E5%9F%BA%E5%BB%BA%E7%9A%84%E6%9C%AA%E6%9D%A5%E3%80%8B.md) 时提到过。
|
||||
|
||||
第四名 [turborepo](https://github.com/vercel/turborepo) 是用 go 写的 monorepo 项目管理工具,是 lerna 的替代品。
|
||||
|
||||
第五名 [nx](https://github.com/nrwl/nx) 也是一个 monorepo 管理工具。
|
||||
|
||||
与框架不同,构建工具往往呈现套娃结构,不是你中有我,就是我中有你,每个热门库都重点解决某一块关键问题,不断套娃套娃,最后套成一个很棒的全家桶。
|
||||
|
||||
### Vue 生态
|
||||
|
||||
第一名 [Slidev](https://github.com/slidevjs/slidev) 在整体榜单里了。
|
||||
|
||||
第二名 [Vue Element Admin](https://github.com/PanJiaChen/vue-element-admin) 基于 vue 的管理后台,在权限验证有一些最佳实践,使用 vuex 管理状态。
|
||||
|
||||
第三名 [Headless UI](https://github.com/tailwindlabs/headlessui) 是一个完全无样式的基础组件库,支持 React 与 Vue,官网的例子都是利用 [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) 内置样式组合而成的。它解决了 UI 组件库绑定样式后,自定义样式 “实际上非常恶心” 的痛点。
|
||||
|
||||
第四名 [Naive UI](https://github.com/TuSimple/naive-ui) 是一个 Vue 组件库,没有太多特别之处,但竟然上了排行榜。看了一下 star 趋势,在 2021.6 月份 star 涨幅是之后的十倍,估计刚开源推广了一波,后续涨幅很慢了,不出意外明年会跌出这个榜单。
|
||||
|
||||
第五名 [vue-next](https://github.com/vuejs/vue-next) 即 vue3,star 数量只有 vue2 的 13%,但今年 star 增幅有 vue2 的一半。
|
||||
|
||||
vue3 还自带了状态管理库 [pinia](https://github.com/vuejs/pinia),其生态已经非常完备。
|
||||
|
||||
### React 生态
|
||||
|
||||
第一名 [next.js](https://github.com/vercel/next.js) 在整体榜单里了。
|
||||
|
||||
第二名 [Ant Design](https://github.com/ant-design/ant-design) 虽然立志成为西湖区最好的 React 组件库,但事实上已经成为了全球最好的 React 组件库。
|
||||
|
||||
第三名 [MUI](https://github.com/mui-org/material-ui) 就是大名鼎鼎的 material design UI 组件库,我对它影响最深的是按钮点击后出现的水波纹,这是 material design 的一大特色。早在 2014 年就创建了,在 Ant Design 没火的时候,是开源组件库首选。
|
||||
|
||||
第四名 [remix](https://github.com/remix-run/remix) 在 Node 框架榜单里了,和 next.js 一样,是绑定了 React 生态的 Node 框架,所以也出现在 React 生态中。
|
||||
|
||||
第五名 [react-use](https://github.com/streamich/react-use) 是很小巧的 React Hook 库,提供了如 `usePrevious`、`useDebounce` 等常用的 Hook。
|
||||
|
||||
看完整个 React 生态榜单,无论是优质生态库数量,还是去年增长的 Star 数,都比 Vue 生态更胜一筹。这背后是无副作用的纯函数与自动依赖收集的响应式视图之争,甚至在 React 生态里也有比如 mobx-react 等优质 MVVM 库,这两种编程范式都会长期并存。
|
||||
|
||||
### CSS-In-JS
|
||||
|
||||
第一名 [vanilla-extract](https://github.com/seek-oss/vanilla-extract) 作为 2021 年的黑马,主打零运行时与 TS 支持。零运行时是通过 @vanilla-extract/webpack-plugin 插件在编译时就完成内容输出。
|
||||
|
||||
第二名 [styled-components](https://github.com/styled-components/styled-components) 是推出最早,也最成熟的一个 CSS-In-JS 框架,虽然版本间出现过运行时不兼容让我放弃过,但不得不说是这个方向的鼻祖。
|
||||
|
||||
第三名 [stitches](https://github.com/modulz/stitches) 和第一名很像,也主打零运行时,不过没有提对 TS 是否友好。
|
||||
|
||||
第四名 [Twin](https://github.com/ben-rogerson/twin.macro) 基于 [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) 实现了 CSS-In-JS 版的语法,可以认为是内置了一套最佳实践的 CSS-In-JS 库,也没解决太大的痛点,只是如果你同时喜欢 Tailwind CSS 与 CSS-In-JS,可能会爱屋及乌的选择 Twin。
|
||||
|
||||
第五名 [Emotion](https://github.com/emotion-js/emotion) 也是一个相对完备的库,基本上 CSS-In-JS 各类语法都能支持。
|
||||
|
||||
相比传统 CSS-In-JS 库,第一名 vanilla-extract 的零运行时是一大亮点,是这个方向的新趋势。
|
||||
|
||||
### 测试
|
||||
|
||||
第一名 [Playwright](https://github.com/microsoft/playwright) 是一个跨浏览器跨平台的测试框架,可以利用 js 代码打开任意 url 地址截图或者对比,解决了搭建自动化测试平台需要从零开始编写底层框架的痛点。
|
||||
|
||||
第二名 [Storybook](https://github.com/storybookjs/storybook) 是非常有名的文档工具,很多开源组件、项目的文档都基于 Storybook 创建。神奇的是它还支持[单元测试](https://storybook.js.org/docs/react/writing-tests/introduction),在你访问 UI 组件时进行测试并打印出测试结果。Storybook 已经变成了一个 all-in-one 的组件开发工具。
|
||||
|
||||
第三名 [Cypress](https://github.com/cypress-io/cypress) 与 Playwright 且诞生比较早,但由于不支持多 tab 页面,且仅支持 js,所以仅在前端流行,在测试工程师角度却不如支持多语言的 Playwright 好用。
|
||||
|
||||
第四名 [Puppeteer](https://github.com/puppeteer/puppeteer) 是 2017 年谷歌推出基于 Chrome 无头浏览器的测试工具,但 2020 年微软的 Playwright 具有跨浏览器特性还是更胜一筹。
|
||||
|
||||
第五名 [Jest](https://github.com/facebook/jest) 是代码级别单测工具的佼佼者,覆盖了全框架,只要你想对代码进行单元测试,选 Jest 是不会错的。
|
||||
|
||||
测试框架围绕单测与浏览器测试这两个子领域,2021 年在浏览器测试领域出现了跨浏览器这个特色方向,在单测领域没有太大变化,顶多出了一个 [Vitest](https://github.com/vitest-dev/vitest) 让单测跑得更快,这个库在 2022 年稳定后可能会大放异彩,甚至可能因为 Vite 流行的原因取代 Jest。
|
||||
|
||||
### 移动端
|
||||
|
||||
第一名 [ReactNative](https://github.com/facebook/react-native) 是基于 React 的 Mobile Native 开发框架,笔者用过一段时间,只能说不能抱有太大期待,因为极大的局限了 web 语法,如果你觉得仅掌握前端知识就可以轻松使用,那么一定会让你失望,不要一开始就抱着这种期待。另外跨端真是非常痛,比如 `SwitchAndroid`、`SwitchIOS` 让你感受不到 Write Once, Run everywhere(虽然官方也没这么说)。
|
||||
|
||||
第二名 [Ionic](https://github.com/ionic-team/ionic-framework) 是一个跨前端框架的跨平台构建工具,解决了 ReactNative 无法 Run everywhere 的痛点,但也带来了不够灵活的问题,即无法使用平台特定特性。
|
||||
|
||||
第三名 [Expo](https://github.com/expo/expo) 是基于 ReactNative 的一站式跨端开发工具,它的 App 使用非常傻瓜化,并且内置了调试能力,可以说是把 ReactNative 要踩的坑帮你踩完了。
|
||||
|
||||
第四名 [Quasar](https://github.com/quasarframework/quasar) 可以认为是 Vue 版的 ReactNative。
|
||||
|
||||
第五名 [Flipper](https://github.com/facebook/flipper) 是一个 Native 应用调试工具,可以认为是手机应用版本的 Chrome DevTools,支持连接远程终端,解决了手机应用难以用电脑调试的痛点。
|
||||
|
||||
其实还少了 [Flutter](https://github.com/flutter/flutter) 这个优秀框架,虽然不属于前端方向,但就像前端脚手架越来越多用 Rust、Go 写一样,Native 用 Dart 也是可以接受的。
|
||||
|
||||
从前端角度看移动端,唯一需求就是 Write Once,Run Anywhere,然后再把调试体验做好一些,Native 的兼容性、拓展性做强一些,就是一个完美方案了。
|
||||
|
||||
说到跨端,基于 Flutter 的 [kraken](https://github.com/openkraken/kraken) 也绝对值得一提,它利用 Flutter 高一执行渲染层能力,并解决了 Dart 生态对前端不友好的问题,做了一个 html+css+js 到 dart 的桥接层,如果明年可以在手淘稳定覆盖大量场景,那一定是个值得考虑的方案。
|
||||
|
||||
## 总结
|
||||
|
||||
还有更多榜单就不一一总结了,如果觉得不过瘾,可以去 [2021 JavaScript Rising Stars](https://risingstars.js.org/2021/en) 翻翻这些 top star 项目的介绍和源码深入了解一下。
|
||||
|
||||
最后总结一下 2021 前端领域的几个关键特征:
|
||||
|
||||
- 编程语言全面开花。以后 JS 开发者不等于前端开发者了,因为 Go、Rust、Dart、C++ 语言都可以为前端服务,并且 2021 年是真的有不少场景做到了生产环境可用,不论我们接不接受,前端不止有 JS 一种语言了。
|
||||
- 前端开发全家桶逐渐产生技术壁垒。在前几年,抄一个前端全家桶很容易,在过程中还可以学到很多底层知识,但现在前端全家桶的积累越来越多,涉及的领域越来越广,甚至 next.js 引入的特性会超越你自己调制的全家桶,这说明全家桶的知识量已经逐渐达到个人知识广度的极限,如果你没有足够精力持续学习,跟进时代步伐的最好方式是使用一个成熟的全家桶。
|
||||
|
||||
> 讨论地址是:[精读《2021 前端新秀回顾》· Issue #390 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/390)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,306 @@
|
||||
[Pipe Operator (|>) for JavaScript](https://github.com/tc39/proposal-pipeline-operator#tacit-unary-function-application-syntax) 提案给 js 增加了 Pipe 语法,这次结合 [A pipe operator for JavaScript: introduction and use cases](https://2ality.com/2022/01/pipe-operator.html) 文章一起深入了解这个提案。
|
||||
|
||||
## 概述
|
||||
|
||||
Pipe 语法可以将函数调用按顺序打平。如下方函数,存在三层嵌套,但我们解读时需要由内而外阅读,因为调用顺序是由内而外的:
|
||||
|
||||
```js
|
||||
const y = h(g(f(x)))
|
||||
```
|
||||
|
||||
Pipe 可以将其转化为正常顺序:
|
||||
|
||||
```js
|
||||
const y = x |> f(%) |> g(%) |> h(%)
|
||||
```
|
||||
|
||||
Pipe 语法有两种风格,分别来自 Microsoft 的 [F#](https://en.wikipedia.org/wiki/F_Sharp_(programming_language)) 与 Facebook 的 [Hack](https://en.wikipedia.org/wiki/Hack_(programming_language))。
|
||||
|
||||
之所以介绍这两个,是因为 js 提案首先要决定 “借鉴” 哪种风格。js 提案最终采用了 Hack 风格,因此我们最好把 F# 与 Hack 的风格都了解一下,并对其优劣做一个对比,才能知其所以然。
|
||||
|
||||
### Hack Pipe 语法
|
||||
|
||||
Hack 语法相对冗余,在 Pipe 时使用 `%` 传递结果:
|
||||
|
||||
```js
|
||||
'123.45' |> Number(%)
|
||||
```
|
||||
|
||||
这个 `%` 可以用在任何地方,基本上原生 js 语法都支持:
|
||||
|
||||
```js
|
||||
value |> someFunction(1, %, 3) // function calls
|
||||
value |> %.someMethod() // method call
|
||||
value |> % + 1 // operator
|
||||
value |> [%, 'b', 'c'] // Array literal
|
||||
value |> {someProp: %} // object literal
|
||||
value |> await % // awaiting a Promise
|
||||
value |> (yield %) // yielding a generator value
|
||||
```
|
||||
|
||||
### F# Pipe 语法
|
||||
|
||||
F# 语法相对精简,默认不使用额外符号:
|
||||
|
||||
```js
|
||||
'123.45' |> Number
|
||||
```
|
||||
|
||||
但在需要显式声明参数时,为了解决上一个 Pipe 结果符号从哪来的问题,写起来反而更为复杂:
|
||||
|
||||
```js
|
||||
2 |> $ => add2(1, $)
|
||||
```
|
||||
|
||||
### await 关键字 - Hack 优
|
||||
|
||||
F# 在 `await` `yield` 时需要特殊语法支持,而 Hack 可以自然的使用 js 内置关键字。
|
||||
|
||||
```js
|
||||
// Hack
|
||||
value |> await %
|
||||
// F#
|
||||
value |> await
|
||||
```
|
||||
|
||||
F# 代码看上去很精简,但实际上付出了高昂的代价 - `await` 是一个仅在 Pipe 语法存在的关键字,而非普通 `await` 关键字。如果不作为关键字处理,执行逻辑就变成了 `await(value)` 而不是 `await value`。
|
||||
|
||||
### 解构 - F# 优
|
||||
|
||||
正因为 F# 繁琐的变量声明,反而使得在应对解构场景时得心应手:
|
||||
|
||||
```js
|
||||
// F#
|
||||
value |> ({ a, b }) => someFunction(a, b)
|
||||
// Hack
|
||||
value |> someFunction(%.a, %.b)
|
||||
```
|
||||
|
||||
Hack 也不是没有解构手段,只是比较繁琐。要么使用立即调用函数表达式 IIFE:
|
||||
|
||||
```js
|
||||
value |> (({ a, b }) => someFunction(a, b))(%)
|
||||
```
|
||||
|
||||
要么使用 `do` 关键字:
|
||||
|
||||
```js
|
||||
value |> do { const { a, b } = %; someFunction(a, b) }
|
||||
```
|
||||
|
||||
但 Hack 虽败犹荣,因为解决方法都使用了 js 原生提供的语法,所以反而体现出与 js 已有生态亲和性更强,而 F# 之所以能优雅解决,全都归功于自创的语法,这些语法虽然甜,但割裂了 js 生态,这是 F# like 提案被放弃的重要原因之一。
|
||||
|
||||
### 潜在改进方案
|
||||
|
||||
虽然选择了 Hack 风格,但 F# 与 Hack 各有优劣,所以列了几点优化方案。
|
||||
|
||||
#### 利用 Partial Application Syntax 提案降低 F# 传参复杂度
|
||||
|
||||
F# 被诟病的一个原因是传参不如 Hack 简单:
|
||||
|
||||
```js
|
||||
// Hack
|
||||
2 |> add2(1, %)
|
||||
// F#
|
||||
2 |> $ => add2(1, $)
|
||||
```
|
||||
|
||||
但如果利用处于 stage1 的提案 [Partial Application Syntax](https://github.com/tc39/proposal-partial-application) 可以很好的解决问题。
|
||||
|
||||
这里就要做一个小插曲了。js 对柯里化没有原生支持,但 [Partial Application Syntax](https://github.com/tc39/proposal-partial-application) 提案解决了这个问题,语法如下:
|
||||
|
||||
```js
|
||||
const add = (x, y) => x + y;
|
||||
const addOne = add~(1, ?);
|
||||
addOne(2); // 3
|
||||
```
|
||||
|
||||
即利用 `fn~(?, arg)` 的语法,将任意函数柯里化。这个特性解决 F# 传参复杂问题简直绝配,因为 F# 的每一个 Pipe 都要求是一个函数,我们可以将要传参的地方记为 `?`,这样返回值还是一个函数,完美符合 F# 的语法:
|
||||
|
||||
```js
|
||||
// F#
|
||||
2 |> add~(1, ?)
|
||||
```
|
||||
|
||||
上面的例子拆开看就是:
|
||||
|
||||
```js
|
||||
const addOne = add~(1, ?)
|
||||
2 |> addOne
|
||||
```
|
||||
|
||||
想法很美好,但 [Partial Application Syntax](https://github.com/tc39/proposal-partial-application) 得先落地。
|
||||
|
||||
#### 融合 F# 与 Hack 语法
|
||||
|
||||
在简单情况下使用 F#,需要利用 `%` 传参时使用 Hack 语法,两者混合在一起写就是:
|
||||
|
||||
```js
|
||||
const resultArray = inputArray
|
||||
|> filter(%, str => str.length >= 0) // Hack
|
||||
|> map(%, str => '['+str+']') // Hack
|
||||
|> console.log // F#
|
||||
```
|
||||
|
||||
不过这个 [提案](https://github.com/tc39/proposal-smart-pipelines) 被废弃了。
|
||||
|
||||
#### 创造一个新的操作符
|
||||
|
||||
如果用 `|>` 表示 Hack 语法,用 `|>>` 表示 F# 语法呢?
|
||||
|
||||
```js
|
||||
const resultArray = inputArray
|
||||
|> filter(%, str => str.length >= 0) // Hack
|
||||
|> map(%, str => '['+str+']') // Hack
|
||||
|>> console.log // F#
|
||||
```
|
||||
|
||||
也是看上去很美好,但这个特性连提案都还没有。
|
||||
|
||||
### 如何用现有语法模拟 Pipe
|
||||
|
||||
即便没有 [Pipe Operator (|>) for JavaScript](https://github.com/tc39/proposal-pipeline-operator#tacit-unary-function-application-syntax) 提案,也可以利用 js 现有语法模拟 Pipe 效果,以下是几种方案。
|
||||
|
||||
#### Function.pipe()
|
||||
|
||||
利用自定义函数构造 pipe 方法,该语法与 F# 比较像:
|
||||
|
||||
```js
|
||||
const resultSet = Function.pipe(
|
||||
inputSet,
|
||||
$ => filter($, x => x >= 0)
|
||||
$ => map($, x => x * 2)
|
||||
$ => new Set($)
|
||||
)
|
||||
```
|
||||
|
||||
缺点是不支持 `await`,且存在额外函数调用。
|
||||
|
||||
#### 使用中间变量
|
||||
|
||||
说白了就是把 Pipe 过程拆开,一步步来写:
|
||||
|
||||
```js
|
||||
const filtered = filter(inputSet, x => x >= 0)
|
||||
const mapped = map(filtered, x => x * 2)
|
||||
const resultSet = new Set(mapped)
|
||||
```
|
||||
|
||||
没什么大问题,就是比较冗余,本来可能一行能解决的问题变成了三行,而且还声明了三个中间变量。
|
||||
|
||||
#### 复用变量
|
||||
|
||||
改造一下,将中间变量变成复用的:
|
||||
|
||||
```js
|
||||
let $ = inputSet
|
||||
$ = filter($, x => x >= 0)
|
||||
$ = map($, x => x * 2)
|
||||
const resultSet = new Set($)
|
||||
```
|
||||
|
||||
这样做可能存在变量污染,可使用 IIFE 解决。
|
||||
|
||||
## 精读
|
||||
|
||||
Pipe Operator 语义价值非常明显,甚至可以改变编程的思维方式,在串行处理数据时非常重要,因此命令行场景非常常见,如:
|
||||
|
||||
```bash
|
||||
cat "somefile.txt" | echo
|
||||
```
|
||||
|
||||
因为命令行就是典型的输入输出场景,而且大部分都是单输入、单输出。
|
||||
|
||||
在普通代码场景,特别是处理数据时也需要这个特性,大部分具有抽象思维的代码都进行了各种类型的管道抽象,比如:
|
||||
|
||||
```js
|
||||
const newValue = pipe(
|
||||
value,
|
||||
doSomething1,
|
||||
doSomething2,
|
||||
doSomething3
|
||||
)
|
||||
```
|
||||
|
||||
如果 [Pipe Operator (|>) for JavaScript](https://github.com/tc39/proposal-pipeline-operator#tacit-unary-function-application-syntax) 提案通过,我们就不需要任何库实现 pipe 动作,可以直接写成:
|
||||
|
||||
```js
|
||||
const newValue = value |> doSomething1(%) |> doSomething2(%) |> doSomething3(%)
|
||||
```
|
||||
|
||||
这等价于:
|
||||
|
||||
```js
|
||||
const newValue = doSomething3(doSomething2(doSomething1(value)))
|
||||
```
|
||||
|
||||
显然,利用 pipe 特性书写处理流程更为直观,执行逻辑与阅读逻辑是一致的。
|
||||
|
||||
### 实现 pipe 函数
|
||||
|
||||
即便没有 [Pipe Operator (|>) for JavaScript](https://github.com/tc39/proposal-pipeline-operator#tacit-unary-function-application-syntax) 提案,我们也可以一行实现 pipe 函数:
|
||||
|
||||
```js
|
||||
const pipe = (...args) => args.reduce((acc, el) => el(acc))
|
||||
```
|
||||
|
||||
但要实现 Hack 参数风格是不可能的,顶多实现 F# 参数风格。
|
||||
|
||||
### js 实现 pipe 语法的考虑
|
||||
|
||||
从 [提案](https://github.com/tc39/proposal-pipeline-operator#tc39-has-rejected-f-pipes-multiple-times) 记录来看,F# 失败有三个原因:
|
||||
|
||||
- 内存性能问题。
|
||||
- `await` 特殊语法。
|
||||
- 割裂 js 生态。
|
||||
|
||||
其中割裂 js 生态是指因 F# 语法的特殊性,如果有太多库按照其语法实现功能,可能导致无法被非 Pipe 语法场景所复用。
|
||||
|
||||
甚至还有部分成员反对 [隐性编程(Tacit programming)](https://en.wikipedia.org/wiki/Tacit_programming),以及柯里化提案 [Partial Application Syntax](https://github.com/tc39/proposal-partial-application),这些会使 js 支持的编程风格与现在差异过大。
|
||||
|
||||
看来处于鄙视链顶端的编程风格在 js 是否支持不是能不能的问题,而是想不想的问题。
|
||||
|
||||
### pipe 语法的弊端
|
||||
|
||||
下面是普通 `setState` 语法:
|
||||
|
||||
```ts
|
||||
setState(state => ({
|
||||
...state,
|
||||
value: 123
|
||||
}))
|
||||
```
|
||||
|
||||
如果改为 `immer` 写法如下:
|
||||
|
||||
```ts
|
||||
setState(produce(draft => draft.value = 123))
|
||||
```
|
||||
|
||||
得益于 ts 类型自动推导,在内层 `produce` 里就已经知道 `value` 是数值类型,此时如果输入字符串会报错,而如果其在另一个上下文的 `setState` 内,类型也会随着上下文的变化而变化。
|
||||
|
||||
但如果写成 pipe 模式:
|
||||
|
||||
```ts
|
||||
produce(draft => draft.value = 123) |> setState
|
||||
```
|
||||
|
||||
因为先考虑的是如何修改数据,此时还不知道后面的 pipe 流程是什么,所以 `draft` 的类型无法确定。所以 pipe 语法仅适用于固定类型的数据处理流程。
|
||||
|
||||
## 总结
|
||||
|
||||
pipe 直译为管道,潜在含义是 “数据像流水线一样被处理”,也可以形象理解为每个函数就是一个不同的管道,显然下一个管道要处理上一个管道的数据,并将结果输出到下一个管道作为输入。
|
||||
|
||||
合适的管道数量与体积决定了一条生产线是否高效,过多的管道类型反而会使流水线零散而杂乱,过少的管道会让流水线笨重不易拓展,这是工作中最大的考验。
|
||||
|
||||
> 讨论地址是:[精读《pipe operator for JavaScript》· Issue #395 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/395)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,143 @@
|
||||
Markdown 即便在 2022 年也非常常用,比如这篇文章依然采用 Markdown 编写。
|
||||
|
||||
但 Markdown 是否应该成为文本编辑领域的默认技术选型呢?答案是否定的。我找到了一篇批判无脑使用 Markdown 作为技术选型的好文 [Thoughts On Markdown](https://www.smashingmagazine.com/2022/02/thoughts-on-markdown/),它提到 Markdown 在标准化、结构化、组件化都存在硬伤,如果你真的想做一个现代化的文本结构编辑器,不要采用 Markdown。
|
||||
|
||||
## 概述
|
||||
|
||||
Markdown 流传甚广,甚至已成为我们的第二语言。Markdown 最早的解析器由 John Gruber 在 2004 年基于 Perl 编写发布,那时候 Markdown 只有一个目的,即为了方便网络写作。
|
||||
|
||||
网络写作必须基于 HTML 规范,而 HTML 规范对大部分人上手成本太高,因此 Markdown 就是基于文本创建的更易理解,或者说上手成本更低,甚至傻瓜化的一种语法,而要解析这个语法需要配套一个解析器,将这种语法文本最终转化为 HTML。
|
||||
|
||||
而数字化发展到今天,Markdown 已不再适合当下的写作场景了,主要原因有二:
|
||||
|
||||
1. Markdown 不再适合当下富交互、内容形态的编写。
|
||||
2. Markdown 纯文本的开发体验不再满足当代开发者日益提高的体验需求。
|
||||
|
||||
首先还是从 Markdown 思想开始介绍。
|
||||
|
||||
### Markdown 的核心思想
|
||||
|
||||
Markdown 最大优势就是好上手,不需要接触 HTML 这种复杂的嵌套语句(虽然对程序员来说 HTML 也简单到处于鄙视链底端)。原文抽象了三个优势:
|
||||
|
||||
1. 基于文本的合适抽象。虽然 HTML 甚至代码都是文本,但 “合适” 这个词很重要,即任何文本都可以是 Markdown,只要加一点点小标记就能描述专业结构,学习成本极低。
|
||||
2. 有大量生态工具。比如语法解析器、高亮、格式转换、格式化、渲染等工具完备。
|
||||
3. 编辑内容便于维护。比如 Markdown 很方便作为源码存储,而其他格式的富文本可能并不方便在源码里维护。
|
||||
|
||||
如果把 Markdown 与数据库表结构做比较,那数据库的理解成本真是太高了。
|
||||
|
||||
但是在如今后端即服务的时代,数据库访问越来越轻松,甚至出现大量如 AirTable 等 SAAS 产品将结构化数据快速转化为应用,其实接触了这些后才真正发现,结构化数据对开发者有多重要。Markdown 用来写写文章还是不错的,但用来表达逻辑结构最后一定会引发灾难后果,原文作者的团队就深受 Markdown 技术选型的困扰,被迫解决大量远超预期的难题。
|
||||
|
||||
如果真的要在 Markdown 的坑越走越深,就必须使用语法拓展来满足自定义诉求。
|
||||
|
||||
### Markdown 语法拓展
|
||||
|
||||
最初 Markdown 语法是不支持表格的,如果想用 Markdown 绘制一张表格,只能使用原生 HTML 标签:`<table></table>`,当然,这也说明了 Markdown 本质就是给 HTML 加强了便捷的语法,我们完全可以将其当 HTML 使用。
|
||||
|
||||
然而并不是所有创作平台都支持 `<table></table>` 语法的,笔者自己就经常受到困扰,比如有些平台会屏蔽原生 HTML 语法,已保障所谓的 “安全性” 或者内容体验的 “一致性”,而这些平台为了弥补缺失的绘制表格能力,往往会支持一些自定义语法,更糟糕的是不支持,这就说到了 Markdown 的语法拓展。
|
||||
|
||||
Markdown 有哪些拓展呢?比如:[multiMarkdown](https://fletcherpenney.net/multimarkdown/)、[commonMark](https://commonmark.org/)、[Github Flavored Markdown](https://docs.github.com/en/get-started/writing-on-github) 等等。
|
||||
|
||||
这里随便举个例子,比如标准 MD 格式,其实第一行最后要加两个空格才能换行,但 GFM 取消了这个限制。这虽然更方便了,但暴露出平台间规范的不一致性,导致 Markdown 跨平台基本一定被坑。
|
||||
|
||||
而各平台拓展的语法,我们是否有足够的精力学习和记忆呢?先不说能不能记得下来,首先值不值得学习就是个问题,为什么一个网络写作平台需要占用写手学习与认知成本,而不是想办法去简化写作流程呢?所以语法拓展看似很美好,但放在写手角度,或者整个互联网各平台林立的角度来看,这种非标准的做法一定不靠谱,没有用户觉得你的平台有资格 “教他语法”,除非你是微信,钉钉或者飞书。
|
||||
|
||||
原文提到的观点是:
|
||||
|
||||
1. 作为写手,你不知道 Markdown 哪些语法可用,哪些语法不可用。
|
||||
2. 标准规范存在一些 [模糊地带](https://johnmacfarlane.net/babelmark2/faq.html#what-is-this-for) 导致开发者实现时也会遇到各种纠结。
|
||||
|
||||
原文还提到一个语法拓展导致理解成本增高的例子:slack 平台自定义的 [mrkdown](https://api.slack.com/reference/surfaces/formatting#basics) 就不支持 `[link](https://slack.com)` 方式描述链接,而使用了 `<link|https://slack.com>` 语法。
|
||||
|
||||
总结来说,Markdown 语法拓展本应该是件好事,但实际无标准导致了标准的百花齐放,使 Markdown 成为了实际上没有标准的状态,整体来看弊端更多。
|
||||
|
||||
### Markdown 面向的用户群
|
||||
|
||||
Markdown 的对自己的定位其实很不清晰,这也导致了一直不想确定标准化语法。
|
||||
|
||||
最初 Markdown 是服务给熟悉 HTML 的人提供的标记语言,而后来面向用户群实质上转向了开发者,因为开发者才会想到拓展语法以满足更复杂的使用场景,Markdown 原生语法无法适应越来越复杂的视觉展示形态。
|
||||
|
||||
如今 Markdown 的主要用户已经是开发人员与对代码感兴趣的人了,这倒不是说开发者有多喜欢它,而是在说 Markdown 的受众变窄了。如今任何一款面向非开发者群体的文档编辑器都不会采用 Markdown 了,而是所见即所得的 WYSIWYG(what you see is what you want)模式。
|
||||
|
||||
这个转变的过程是痛苦的,但现在来看,富文本编辑器不应用用 Markdown 语法,而是 WYSIWYG 模式已经是共识了。
|
||||
|
||||
### 从段落到区块、从文章到应用
|
||||
|
||||
简单来说,即 Markdown 已经不适应当前 HTML 丰富的生态了,能轻松描述段落的标记语言,遇到富有交互的组件区块时,不得不引入例如 [MDX](https://mdxjs.com/) 等方案,但这样的方案根本只适合程序员群体,完全无法移植。
|
||||
|
||||
网络浏览形态也从简单的文章发展到具有整体性的应用,这些应用拥有复杂的布局、样式与交互,如果你尝试基于 Markdown 拓展语法来支持,最后可能发现还不如直接用原生 HTML。
|
||||
|
||||
### 对结构化内容的诉求
|
||||
|
||||
从编程角度理解就是 “组件复用”。Markdown 原生语法无法实现内容的复用,如果必须要复用内容,只能将其重复写在每一处,势必造成巨大同步成本。
|
||||
|
||||
比如 Jekyll 就提出了 [FrontMatter](https://jekyllrb.com/docs/front-matter/) 概念用来创建复用的变量:
|
||||
|
||||
```yml
|
||||
---
|
||||
food: Pizza
|
||||
---
|
||||
|
||||
<h1>{{ page.food }}</h1>
|
||||
```
|
||||
|
||||
### WYSIWYG 编辑器不应将 HTML 作为底层数据结构
|
||||
|
||||
虽然浏览器真正将 HTML 作为底层数据结构,但这并不代表所见即所得的编辑器也可以如此,这也是为什么浏览器只能提供从源码到 UI 的输出,而不能提供从 UI 编辑到源码的反向输入。
|
||||
|
||||
因为用户的输入与 HTML 并不是一一对应关系,其中存在大量模糊地带,比如当前光标处在粗体与细体文字中间,那下一个输入到底算加粗还是不加粗呢?从 UI 上看不到加粗标签。再有,如果 HTML 存在冗余,其实当前光标所在位置已经被加粗标签包裹了好几层,但因为光标所在区域又被另一个样式标签覆盖成非加粗模式,当再次输入时可能就跳出了覆盖范围,重新变成了加粗,这个过程符合用户预期吗?从技术上,这种复杂标签结构也几乎无法被处理,因为组合花样实在太多。
|
||||
|
||||
现代大多数编辑器都以 JSON 格式存储数据结构,就因为其结构化且易于检索。
|
||||
|
||||
结构化最重要的体现是,其生成的 HTML 结构可以是稳定的,即对于一个既加粗又标红的文字,一定包裹在一个 `<strong style="color: red">` 标签里,而不是 `<strong><div style="color: red">`,也就是这种模式根本没把 HTML 作为结构化数据去看待,自然就不会出现歧义。
|
||||
|
||||
Markdown 也是一样,其本身也会出现类似 HTML 标签的二义性,不适合作为底层数据结构存储。
|
||||
|
||||
## 精读
|
||||
|
||||
批判 Markdown 的文章不多见,笔者也是看了之后才恍然发现 Markdown 竟然有这么多缺点。笔者结合自己的经验谈谈 Markdown 的缺点吧。
|
||||
|
||||
### 不支持富交互的无奈
|
||||
|
||||
Markdown 仅能支持简单的 HTML 结构,而无法描述逻辑区块。Github 上大部分 Readme 都采用图片来实现这些功能,包括状态卡片、构建结果、个人信息名片等,可惜交互能力还是太弱,我觉得有朝一日 Github 应该会推出比如 Block 小区块的概念,让这些区块可以直接插入 Markdown 成为一个可交互的元素。
|
||||
|
||||
### MDX 解决了 Markdown 的痛点吗?
|
||||
|
||||
看似完美兼容 JSX 与 Markdown 的 MDX 曾经也是笔者写作的救命稻草,但该方案移植性是一大痛点,组件只能在自己部署的网站用,如果你想把文章发布到另一个平台,完全不可能。
|
||||
|
||||
这还仅是笔者的视角,如果从 Markdown 生态来看,MDX 面向用户仅是程序员群体,根本没有解决其使命 “方便网络写作”,而程序员最终也会抛弃 MDX 而转向开发所见即所得编辑器解决问题。
|
||||
|
||||
### Markdown 到 HTML 的转换存在逻辑问题
|
||||
|
||||
Markdown 本质上还是一种脱离 HTML 的文本表示结构,看上去解耦很优雅,实际上会遇到不少不一致的问题。
|
||||
|
||||
比如说连续敲击多个空格会出现什么情况呢?在 Markdown 会变成一个引用区块,那如何才能展示多个空格呢?谁也不知道,可能需要查阅具体平台提供的额外语法才可以做到。
|
||||
|
||||
这种大体上用起来方便,但细节无法定制,甚至用户无法控制的情况会大大伤害已经深度使用 Markdown 的用户,此时用户要么硬着头皮发明新语法解决这些漏洞,要么就完全放弃 Markdown 了。
|
||||
|
||||
### 结构化能力不足
|
||||
|
||||
看上去 Markdown 的语法挺具有结构化的,但实际上 Markdown 的结构化不具有强约束力。
|
||||
|
||||
拿 JSON 作对比,比如我们可以用 JSON 拓展出 [https://json-schema.org/](jsonSchema) 结构,这个结构甚至可以反推出一个完整的表单应用,其原因是 JSON 可以针对每一个 Key、层级下定义,首先有结构,其次才有内容。
|
||||
|
||||
而 Markdown 正好反过来,是先有内容,再有结构。比如我们可以在 Markdown 任何地方写任何 HTML 标签,或者任意段落的问题,这些内容是无法被序列化的,即便我们按照浏览器解析 HTML 的规则解析成 JSON,也无法从中方便的提取信息。
|
||||
|
||||
背后的根本原因是,Markdown 本身定位就是 “近乎于 UI 渲染结果” 的,而实际上浏览器渲染 UI 背后是需要一套严谨的 HTML 语法,因为 UI 与背后语法并不能一一建立映射,一个稳定的渲染逻辑只能是从源码推导到渲染,而不能从渲染反推出源码。Markdown 本身定位就近乎于渲染结果,所以结构化能力不足是天然的问题。
|
||||
|
||||
## 总结
|
||||
|
||||
记得语雀早期内部试用时,编辑态还是采用 Markdown 的,但后来很快就把 Markdown 的编辑入口下掉了,这件事还引发了不少开发者的不满,甚至还有一些 Markdown 编辑的插件被开发出来,一度很受欢迎。但渐渐的我们都习惯用所见即所得方式编辑了,Markdown 唯一留给我们的印象就是快捷键,比如 `###` 后敲入空格可以生成 `h3` 标题段落,而语雀编辑器也在富交互组件区块上越走越远,要是当年被 Markdown 锁定住了技术,也不可能有今天这么高级的编辑体验。
|
||||
|
||||
所以技术前瞻性真的很重要,Markdown 所有程序员都爱,但提前看到它在当前互联网发展阶段的局限性,并设计一套结构化数据代替 Markdown 结构不是所有人都能想到的,我们需要以动态的眼光看待技术,也要放下技术人的偏见,把偏爱让位于产品定位。
|
||||
|
||||
> 讨论地址是:[精读《对 Markdown 的思考》· Issue #397 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/397)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,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 只会变成更糟的 HOLEY,SMI 只会往更糟的 DOUBLE 和空类型变,且这两种变化都不可逆。
|
||||
|
||||
## 精读
|
||||
|
||||
为了验证文章的猜想,笔者使用 v8-debug 调试了一番。
|
||||
|
||||
### 使用 v8-debug 调试
|
||||
|
||||
先介绍一下 v8-debug,它是一个 v8 引擎调试工具,首先执行下面的命令行安装 `jsvu`:
|
||||
|
||||
```bash
|
||||
npm i -g jsvu
|
||||
```
|
||||
|
||||
然后执行 `jsvu`,根据引导选择自己的系统类型,第二步选择要安装的 js 引擎,选择 `v8` 和 `v8-debug`:
|
||||
|
||||
```bash
|
||||
jsvu
|
||||
// 选择 macos
|
||||
// 选择 v8,v8-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、DOUBLE;PACKED、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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const Title = styled.h1`
|
||||
|
||||
## css-modules
|
||||
|
||||
顾名思义,css-modules 将 css 代码模块化,可以很方面的避免本模块样式被污染。并且可以很方便的复用 css 代码。
|
||||
顾名思义,css-modules 将 css 代码模块化,可以很方便的避免本模块样式被污染。并且可以很方便的复用 css 代码。
|
||||
|
||||
```css
|
||||
// 全局变量
|
||||
|
||||
@@ -36,7 +36,7 @@ RPC 主要用来做服务器之间的方法调用,影响其性能最重要因
|
||||
|
||||
gRPC 主要用于服务之间传输,这里拿 Nodejs 举例:
|
||||
|
||||
1. 定义接口。由于 gRPC 使用 protobufs,所以接口定义文件就是 `helloword.proto`:
|
||||
1. 定义接口。由于 gRPC 使用 protobufs,所以接口定义文件就是 `helloworld.proto`:
|
||||
|
||||
```protobufs
|
||||
// The greeting service definition.
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
[zustand](https://github.com/pmndrs/zustand) 是一个非常时髦的状态管理库,也是 2021 年 Star 增长最快的 React 状态管理库。它的理念非常函数式,API 设计的很优雅,值得学习。
|
||||
|
||||
## 概述
|
||||
|
||||
首先介绍 [zustand](https://github.com/pmndrs/zustand) 的使用方法。
|
||||
|
||||
### 创建 store
|
||||
|
||||
通过 `create` 函数创建 store,回调可拿到 `get` `set` 就类似 Redux 的 `getState` 与 `setState`,可以获取 store 瞬时值与修改 store。返回一个 hook 可以在 React 组件中访问 store。
|
||||
|
||||
```typescript
|
||||
import create from 'zustand'
|
||||
|
||||
const useStore = create((set, get) => ({
|
||||
bears: 0,
|
||||
increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
|
||||
removeAllBears: () => set({ bears: 0 })
|
||||
}))
|
||||
```
|
||||
|
||||
上面例子是全局唯一的 store,也可以通过 `createContext` 方式创建多实例 store,结合 Provider 使用:
|
||||
|
||||
```tsx
|
||||
import create from 'zustand'
|
||||
import createContext from 'zustand/context'
|
||||
|
||||
const { Provider, useStore } = createContext()
|
||||
|
||||
const createStore = () => create(...)
|
||||
|
||||
const App = () => (
|
||||
<Provider createStore={createStore}>
|
||||
...
|
||||
</Provider>
|
||||
)
|
||||
```
|
||||
|
||||
### 访问 store
|
||||
|
||||
通过 `useStore` 在组件中访问 store。与 redux 不同的是,无论普通数据还是函数都可以存在 store 里,且函数也通过 selector 语法获取。因为函数引用不可变,所以实际上下面第二个例子不会引发重渲染:
|
||||
|
||||
```typescript
|
||||
function BearCounter() {
|
||||
const bears = useStore(state => state.bears)
|
||||
return <h1>{bears} around here ...</h1>
|
||||
}
|
||||
|
||||
function Controls() {
|
||||
const increasePopulation = useStore(state => state.increasePopulation)
|
||||
return <button onClick={increasePopulation}>one up</button>
|
||||
}
|
||||
```
|
||||
|
||||
如果嫌访问变量需要调用多次 `useStore` 麻烦,可以自定义 compare 函数返回一个对象:
|
||||
|
||||
```typescript
|
||||
const { nuts, honey } = useStore(state => ({ nuts: state.nuts, honey: state.honey }), shallow)
|
||||
```
|
||||
|
||||
### 细粒度 memo
|
||||
|
||||
利用 `useCallback` 甚至可以跳过普通 compare,而仅关心外部 id 值的变化,如:
|
||||
|
||||
```typescript
|
||||
const fruit = useStore(useCallback(state => state.fruits[id], [id]))
|
||||
```
|
||||
|
||||
原理是 id 变化时,`useCallback` 返回值才会变化,而 `useCallback` 返回值如果不变,`useStore` 的 compare 函数引用对比就会为 `true`,非常巧妙。
|
||||
|
||||
### set 合并与覆盖
|
||||
|
||||
`set` 函数第二个参数默认为 `false`,即合并值而非覆盖整个 store,所以可以利用这个特性清空 store:
|
||||
|
||||
```typescript
|
||||
const useStore = create(set => ({
|
||||
salmon: 1,
|
||||
tuna: 2,
|
||||
deleteEverything: () => set({ }, true), // clears the entire store, actions included
|
||||
}))
|
||||
```
|
||||
|
||||
### 异步
|
||||
|
||||
所有函数都支持异步,因为修改 store 并不依赖返回值,而是调用 `set`,所以是否异步对数据流框架来说都一样。
|
||||
|
||||
### 监听指定变量
|
||||
|
||||
还是用英文比较表意,即 `subscribeWithSelector`,这个中间件可以让我们把 selector 用在 subscribe 函数上,相比于 redux 传统的 subscribe,就可以有针对性的监听了:
|
||||
|
||||
```typescript
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
const useStore = create(subscribeWithSelector(() => ({ paw: true, snout: true, fur: true })))
|
||||
|
||||
// Listening to selected changes, in this case when "paw" changes
|
||||
const unsub2 = useStore.subscribe(state => state.paw, console.log)
|
||||
// Subscribe also exposes the previous value
|
||||
const unsub3 = useStore.subscribe(state => state.paw, (paw, previousPaw) => console.log(paw, previousPaw))
|
||||
// Subscribe also supports an optional equality function
|
||||
const unsub4 = useStore.subscribe(state => [state.paw, state.fur], console.log, { equalityFn: shallow })
|
||||
// Subscribe and fire immediately
|
||||
const unsub5 = useStore.subscribe(state => state.paw, console.log, { fireImmediately: true })
|
||||
```
|
||||
|
||||
后面还有一些结合中间件、immer、localstorage、redux like、devtools、combime store 就不细说了,都是一些细节场景。值得一提的是,所有特性都是正交的。
|
||||
|
||||
## 精读
|
||||
|
||||
其实大部分使用特性都在利用 React 语法,所以可以说 50% 的特性属于 React 通用特性,只是写在了 [zustand](https://github.com/pmndrs/zustand) 文档里,看上去像是 zustand 的特性,所以这个库真的挺会借力的。
|
||||
|
||||
### 创建 store 实例
|
||||
|
||||
任何数据流管理工具,都有一个最核心的 store 实例。对 zustand 来说,便是定义在 `vanilla.ts` 文件的 `createStore` 了。
|
||||
|
||||
`createStore` 返回一个类似 redux store 的数据管理实例,拥有四个非常常见的 API:
|
||||
|
||||
```typescript
|
||||
export type StoreApi<T extends State> = {
|
||||
setState: SetState<T>
|
||||
getState: GetState<T>
|
||||
subscribe: Subscribe<T>
|
||||
destroy: Destroy
|
||||
}
|
||||
```
|
||||
|
||||
首先 `getState` 的实现:
|
||||
|
||||
```typescript
|
||||
const getState: GetState<TState> = () => state
|
||||
```
|
||||
|
||||
就是这么简单粗暴。再看 `state`,就是一个普通对象:
|
||||
|
||||
```typescript
|
||||
let state: TState
|
||||
```
|
||||
|
||||
这就是数据流简单的一面,没有魔法,数据存储用一个普通对象,仅此而已。
|
||||
|
||||
接着看 `setState`,它做了两件事,修改 `state` 并执行 `listenser`:
|
||||
|
||||
```typescript
|
||||
const setState: SetState<TState> = (partial, replace) => {
|
||||
const nextState = typeof partial === 'function' ? partial(state) : partial
|
||||
if (nextState !== state) {
|
||||
const previousState = state
|
||||
state = replace ? (nextState as TState) : Object.assign({}, state, nextState)
|
||||
listeners.forEach((listener) => listener(state, previousState))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
修改 `state` 也非常简单,唯一重要的是 `listener(state, previousState)`,那么这些 `listeners` 是什么时候注册和声明的呢?其实 `listeners` 就是一个 Set 对象:
|
||||
|
||||
```typescript
|
||||
const listeners: Set<StateListener<TState>> = new Set()
|
||||
```
|
||||
|
||||
注册和销毁时机分别是 `subscribe` 与 `destroy` 函数调用时,这个实现很简单、高效。对应代码就不贴了,很显然,`subscribe` 时注册的监听函数会作为 `listener` 添加到 `listeners` 队列中,当发生 `setState` 时便会被调用。
|
||||
|
||||
最后我们看 `createStore` 的定义与结尾:
|
||||
|
||||
```typescript
|
||||
function createStore(createState) {
|
||||
let state: TState
|
||||
const setState = /** ... */
|
||||
const getState = /** ... */
|
||||
/** ... */
|
||||
const api = { setState, getState, subscribe, destroy }
|
||||
state = createState(setState, getState, api)
|
||||
return api
|
||||
}
|
||||
```
|
||||
|
||||
虽然这个 `state` 是个简单的对象,但回顾使用文档,我们可以在 `create` 创建 store 利用 callback 对 state 赋值,那个时候的 `set`、`get`、`api` 就是上面代码倒数第二行传入的:
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
|
||||
const useStore = create((set, get) => ({
|
||||
bears: 0,
|
||||
increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
|
||||
removeAllBears: () => set({ bears: 0 })
|
||||
}))
|
||||
```
|
||||
|
||||
至此,初始化 store 的所有 API 的来龙去脉就梳理清楚了,逻辑简单清晰。
|
||||
|
||||
### create 函数的实现
|
||||
|
||||
上面我们说清楚了如何创建 store 实例,但这个实例是底层 API,使用文档介绍的 `create` 函数在 `react.ts` 文件定义,并调用了 `createStore` 创建框架无关数据流。之所 `create` 定义在 `react.ts`,是因为返回的 `useStore` 是一个 Hooks,所以本身具有 React 环境特性,因此得名。
|
||||
|
||||
该函数第一行就调用 `createStore` 创建基础 store,因为对框架来说是内部 API,所以命名也叫 api:
|
||||
|
||||
```typescript
|
||||
const api: CustomStoreApi = typeof createState === 'function' ? createStore(createState) : createState
|
||||
|
||||
const useStore: any = <StateSlice>(
|
||||
selector: StateSelector<TState, StateSlice> = api.getState as any,
|
||||
equalityFn: EqualityChecker<StateSlice> = Object.is
|
||||
) => /** ... */
|
||||
```
|
||||
|
||||
接下来所有代码都在创建 `useStore` 这个函数,我们看下其内部实现:
|
||||
|
||||
简单来说就是利用 `subscribe` 监听变化,并在需要的时候强制刷新当前组件,并传入最新的 `state` 给到 `useStore`。所以第一步当然是创建 `forceUpdate` 函数:
|
||||
|
||||
```typescript
|
||||
const [, forceUpdate] = useReducer((c) => c + 1, 0) as [never, () => void]
|
||||
```
|
||||
|
||||
然后通过调用 API 拿到 `state` 并传给 selector,并调用 `equalityFn`(这个函数可以被定制)判断状态是否发生了变化:
|
||||
|
||||
```typescript
|
||||
const state = api.getState()
|
||||
newStateSlice = selector(state)
|
||||
hasNewStateSlice = !equalityFn(
|
||||
currentSliceRef.current as StateSlice,
|
||||
newStateSlice
|
||||
)
|
||||
```
|
||||
|
||||
如果状态变化了,就更新 `currentSliceRef.current`:
|
||||
|
||||
```typescript
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (hasNewStateSlice) {
|
||||
currentSliceRef.current = newStateSlice as StateSlice
|
||||
}
|
||||
stateRef.current = state
|
||||
selectorRef.current = selector
|
||||
equalityFnRef.current = equalityFn
|
||||
erroredRef.current = false
|
||||
})
|
||||
```
|
||||
|
||||
> `useIsomorphicLayoutEffect` 是同构框架常用 API 套路,在前端环境是 `useLayoutEffect`,在 node 环境是 `useEffect`:
|
||||
|
||||
说明一下 `currentSliceRef` 与 `newStateSlice` 的功能。我们看 `useStore` 最后的返回值:
|
||||
|
||||
```typescript
|
||||
const sliceToReturn = hasNewStateSlice
|
||||
? (newStateSlice as StateSlice)
|
||||
: currentSliceRef.current
|
||||
useDebugValue(sliceToReturn)
|
||||
return sliceToReturn
|
||||
```
|
||||
|
||||
发现逻辑是这样的:如果 state 变化了,则返回新的 state,否则返回旧的,这样可以保证 compare 函数判断相等时,返回对象的引用完全相同,这个是不可变数据的核心实现。另外我们也可以学习到阅读源码的技巧,即要经常跳读。
|
||||
|
||||
那么如何在 selector 变化时更新 store 呢?中间还有一段核心代码,调用了 `subscribe`,相信你已经猜到了,下面是核心代码片段:
|
||||
|
||||
```typescript
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const listener = () => {
|
||||
try {
|
||||
const nextState = api.getState()
|
||||
const nextStateSlice = selectorRef.current(nextState)
|
||||
if (!equalityFnRef.current(currentSliceRef.current as StateSlice, nextStateSlice)) {
|
||||
stateRef.current = nextState
|
||||
currentSliceRef.current = nextStateSlice
|
||||
forceUpdate()
|
||||
}
|
||||
} catch (error) {
|
||||
erroredRef.current = true
|
||||
forceUpdate()
|
||||
}
|
||||
}
|
||||
const unsubscribe = api.subscribe(listener)
|
||||
if (api.getState() !== stateBeforeSubscriptionRef.current) {
|
||||
listener() // state has changed before subscription
|
||||
}
|
||||
return unsubscribe
|
||||
}, [])
|
||||
```
|
||||
|
||||
这段代码要先从 `api.subscribe(listener)` 看,这使得任何 `setState` 都会触发 `listener` 的执行,而 `listener` 利用 `api.getState()` 拿到最新 `state`,并拿到上一次的 compare 函数 `equalityFnRef` 执行一下判断值前后是否发生了改变,如果改变则更新 `currentSliceRef` 并进行一次强制刷新(调用 `forceUpdate`)。
|
||||
|
||||
### context 的实现
|
||||
|
||||
注意到 context 语法,可以创建多个互不干扰的 store 实例:
|
||||
|
||||
```tsx
|
||||
import create from 'zustand'
|
||||
import createContext from 'zustand/context'
|
||||
|
||||
const { Provider, useStore } = createContext()
|
||||
|
||||
const createStore = () => create(...)
|
||||
|
||||
const App = () => (
|
||||
<Provider createStore={createStore}>
|
||||
...
|
||||
</Provider>
|
||||
)
|
||||
```
|
||||
|
||||
首先我们知道 `create` 创建的 store 是实例间互不干扰的,问题是 `create` 返回的 `useStore` 只有一个实例,也没有 `<Provider>` 声明作用域,那么如何构造上面的 API 呢?
|
||||
|
||||
首先 `Provider` 存储了 `create` 返回的 `useStore`:
|
||||
|
||||
```tsx
|
||||
const storeRef = useRef<TUseBoundStore>()
|
||||
storeRef.current = createStore()
|
||||
```
|
||||
|
||||
那么 `useStore` 本身其实并不实现数据流功能,而是将 `<Provider>` 提供的 `storeRef` 拿到并返回:
|
||||
|
||||
```typescript
|
||||
const useStore: UseContextStore<TState> = <StateSlice>(
|
||||
selector?: StateSelector<TState, StateSlice>,
|
||||
equalityFn = Object.is
|
||||
) => {
|
||||
const useProviderStore = useContext(ZustandContext)
|
||||
return useProviderStore(
|
||||
selector as StateSelector<TState, StateSlice>,
|
||||
equalityFn
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
所以核心逻辑还是是现在 `create` 函数里,`context.ts` 只是利用 ReactContext 将 `useStore` “注入” 到组件,且利用 ReactContext 特性,这个注入可以存在多个实例,且不会相互影响。
|
||||
|
||||
### 中间件
|
||||
|
||||
中间件其实不需要怎么实现。比如看这个 redux 中间件的例子:
|
||||
|
||||
```typescript
|
||||
import { redux } from 'zustand/middleware'
|
||||
const useStore = create(redux(reducer, initialState))
|
||||
```
|
||||
|
||||
可以将 zustand 用法改变为 reducer,实际上是利用了函数式理念,redux 函数本身可以拿到 `set, get, api`,如果想保持 API 不变,则原样返回 callback 就行了,如果想改变用法,则返回特定的结构,就是这么简单。
|
||||
|
||||
为了加深理解,我们看看 redux 中间件源码:
|
||||
|
||||
```typescript
|
||||
export const redux = ( reducer, initial ) => ( set, get, api ) => {
|
||||
api.dispatch = action => {
|
||||
set(state => reducer(state, action), false, action)
|
||||
return action
|
||||
}
|
||||
api.dispatchFromDevtools = true
|
||||
return { dispatch: (...a) => api.dispatch(...a), ...initial }
|
||||
}
|
||||
```
|
||||
|
||||
将 `set, get, api` 封装为 redux API:`dispatch` 本质就是调用 `set`。
|
||||
|
||||
## 总结
|
||||
|
||||
[zustand](https://github.com/pmndrs/zustand) 是一个实现精巧的 React 数据流管理工具,自身框架无关的分层合理,中间件实现巧妙,值得学习。
|
||||
|
||||
> 讨论地址是:[精读《zustand 源码》· Issue #392 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/392)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,308 @@
|
||||
[vue-lit](https://github.com/yyx990803/vue-lit) 基于 [lit-html](https://github.com/lit/lit/blob/main/packages/lit-html/README.md) + [@vue/reactivity](https://github.com/vuejs/vue-next/tree/master/packages/reactivity) 仅用 70 行代码就给模版引擎实现了 [Vue Composition API](https://vuejs.org/guide/extras/composition-api-faq.html),用来开发 web component。
|
||||
|
||||
## 概述
|
||||
|
||||
```html
|
||||
<my-component></my-component>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
defineComponent,
|
||||
reactive,
|
||||
html,
|
||||
onMounted,
|
||||
onUpdated,
|
||||
onUnmounted
|
||||
} from 'https://unpkg.com/@vue/lit'
|
||||
|
||||
defineComponent('my-component', () => {
|
||||
const state = reactive({
|
||||
text: 'hello',
|
||||
show: true
|
||||
})
|
||||
const toggle = () => {
|
||||
state.show = !state.show
|
||||
}
|
||||
const onInput = e => {
|
||||
state.text = e.target.value
|
||||
}
|
||||
|
||||
return () => html`
|
||||
<button @click=${toggle}>toggle child</button>
|
||||
<p>
|
||||
${state.text} <input value=${state.text} @input=${onInput}>
|
||||
</p>
|
||||
${state.show ? html`<my-child msg=${state.text}></my-child>` : ``}
|
||||
`
|
||||
})
|
||||
|
||||
defineComponent('my-child', ['msg'], (props) => {
|
||||
const state = reactive({ count: 0 })
|
||||
const increase = () => {
|
||||
state.count++
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('child mounted')
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
console.log('child updated')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
console.log('child unmounted')
|
||||
})
|
||||
|
||||
return () => html`
|
||||
<p>${props.msg}</p>
|
||||
<p>${state.count}</p>
|
||||
<button @click=${increase}>increase</button>
|
||||
`
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
上面定义了 `my-component` 与 `my-child` 组件,并将 `my-child` 作为 `my-component` 的默认子元素。
|
||||
|
||||
```js
|
||||
import {
|
||||
defineComponent,
|
||||
reactive,
|
||||
html,
|
||||
onMounted,
|
||||
onUpdated,
|
||||
onUnmounted
|
||||
} from 'https://unpkg.com/@vue/lit'
|
||||
```
|
||||
|
||||
`defineComponent` 定义 custom element,第一个参数是自定义 element 组件名,必须遵循原生 API [customElements.define](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements) 对组件名的规范,组件名必须包含中划线。
|
||||
|
||||
`reactive` 属于 [@vue/reactivity](https://github.com/vuejs/vue-next/tree/master/packages/reactivity) 提供的响应式 API,可以创建一个响应式对象,在渲染函数中调用时会自动进行依赖收集,这样在 Mutable 方式修改值时可以被捕获,并自动触发对应组件的重渲染。
|
||||
|
||||
`html` 是 [lit-html](https://github.com/lit/lit/blob/main/packages/lit-html/README.md) 提供的模版函数,通过它可以用 [Template strings](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) 原生语法描述模版,是一个轻量模版引擎。
|
||||
|
||||
`onMounted`、`onUpdated`、`onUnmounted` 是基于 [web component lifecycle](https://developers.google.com/web/fundamentals/web-components/customelements#reactions) 创建的生命周期函数,可以监听组件创建、更新与销毁时机。
|
||||
|
||||
接下来看 `defineComponent` 的内容:
|
||||
|
||||
```js
|
||||
defineComponent('my-component', () => {
|
||||
const state = reactive({
|
||||
text: 'hello',
|
||||
show: true
|
||||
})
|
||||
const toggle = () => {
|
||||
state.show = !state.show
|
||||
}
|
||||
const onInput = e => {
|
||||
state.text = e.target.value
|
||||
}
|
||||
|
||||
return () => html`
|
||||
<button @click=${toggle}>toggle child</button>
|
||||
<p>
|
||||
${state.text} <input value=${state.text} @input=${onInput}>
|
||||
</p>
|
||||
${state.show ? html`<my-child msg=${state.text}></my-child>` : ``}
|
||||
`
|
||||
})
|
||||
```
|
||||
|
||||
借助模版引擎 [lit-html](https://github.com/lit/lit/blob/main/packages/lit-html/README.md) 的能力,可以同时在模版中传递变量与函数,再借助 [@vue/reactivity](https://github.com/vuejs/vue-next/tree/master/packages/reactivity) 能力,让变量变化时生成新的模版,更新组件 dom。
|
||||
|
||||
## 精读
|
||||
|
||||
阅读源码可以发现,vue-lit 巧妙的融合了三种技术方案,它们配合方式是:
|
||||
|
||||
1. 使用 [@vue/reactivity](https://github.com/vuejs/vue-next/tree/master/packages/reactivity) 创建响应式变量。
|
||||
2. 利用模版引擎 [lit-html](https://github.com/lit/lit/blob/main/packages/lit-html/README.md) 创建使用了这些响应式变量的 HTML 实例。
|
||||
3. 利用 [web component](https://developers.google.com/web/fundamentals/web-components/customelements) 渲染模版引擎生成的 HTML 实例,这样创建的组件具备隔离能力。
|
||||
|
||||
其中响应式能力与模版能力分别是 [@vue/reactivity](https://github.com/vuejs/vue-next/tree/master/packages/reactivity)、[lit-html](https://github.com/lit/lit/blob/main/packages/lit-html/README.md) 这两个包提供的,我们只需要从源码中寻找剩下的两个功能:如何在修改值后触发模版刷新,以及如何构造生命周期函数的。
|
||||
|
||||
首先看如何在值修改后触发模版刷新。以下我把与重渲染相关代码摘出来了:
|
||||
|
||||
```js
|
||||
import {
|
||||
effect
|
||||
} from 'https://unpkg.com/@vue/reactivity/dist/reactivity.esm-browser.js'
|
||||
|
||||
customElements.define(
|
||||
name,
|
||||
class extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
const template = factory.call(this, props)
|
||||
const root = this.attachShadow({ mode: 'closed' })
|
||||
effect(() => {
|
||||
render(template(), root)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
可以清晰的看到,首先 `customElements.define` 创建一个原生 web component,并利用其 API 在初始化时创建一个 `closed` 节点,该节点对外部 API 调用关闭,即创建的是一个不会受外部干扰的 web component。
|
||||
|
||||
然后在 `effect` 回调函数内调用 `html` 函数,即在使用文档里返回的模版函数,由于这个模版函数中使用的变量都采用 `reactive` 定义,所以 `effect` 可以精准捕获到其变化,并在其变化后重新调用 `effect` 回调函数,实现了 “值变化后重渲染” 的功能。
|
||||
|
||||
然后看生命周期是如何实现的,由于生命周期贯穿整个实现流程,因此必须结合全量源码看,下面贴出全量核心代码,上面介绍过的部分可以忽略不看,只看生命周期的实现:
|
||||
|
||||
```js
|
||||
let currentInstance
|
||||
|
||||
export function defineComponent(name, propDefs, factory) {
|
||||
if (typeof propDefs === 'function') {
|
||||
factory = propDefs
|
||||
propDefs = []
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
name,
|
||||
class extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
const props = (this._props = shallowReactive({}))
|
||||
currentInstance = this
|
||||
const template = factory.call(this, props)
|
||||
currentInstance = null
|
||||
this._bm && this._bm.forEach((cb) => cb())
|
||||
const root = this.attachShadow({ mode: 'closed' })
|
||||
let isMounted = false
|
||||
effect(() => {
|
||||
if (isMounted) {
|
||||
this._bu && this._bu.forEach((cb) => cb())
|
||||
}
|
||||
render(template(), root)
|
||||
if (isMounted) {
|
||||
this._u && this._u.forEach((cb) => cb())
|
||||
} else {
|
||||
isMounted = true
|
||||
}
|
||||
})
|
||||
}
|
||||
connectedCallback() {
|
||||
this._m && this._m.forEach((cb) => cb())
|
||||
}
|
||||
disconnectedCallback() {
|
||||
this._um && this._um.forEach((cb) => cb())
|
||||
}
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
this._props[name] = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createLifecycleMethod(name) {
|
||||
return (cb) => {
|
||||
if (currentInstance) {
|
||||
;(currentInstance[name] || (currentInstance[name] = [])).push(cb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const onBeforeMount = createLifecycleMethod('_bm')
|
||||
export const onMounted = createLifecycleMethod('_m')
|
||||
export const onBeforeUpdate = createLifecycleMethod('_bu')
|
||||
export const onUpdated = createLifecycleMethod('_u')
|
||||
export const onUnmounted = createLifecycleMethod('_um')
|
||||
```
|
||||
|
||||
生命周期实现形如 `this._bm && this._bm.forEach((cb) => cb())`,之所以是循环,是因为比如 `onMount(() => cb())` 可以注册多次,因此每个生命周期都可能注册多个回调函数,因此遍历将其依次执行。
|
||||
|
||||
而生命周期函数还有一个特点,即并不分组件实例,因此必须有一个 `currentInstance` 标记当前回调函数是在哪个组件实例注册的,而这个注册的同步过程就在 `defineComponent` 回调函数 `factory` 执行期间,因此才会有如下的代码:
|
||||
|
||||
```js
|
||||
currentInstance = this
|
||||
const template = factory.call(this, props)
|
||||
currentInstance = null
|
||||
```
|
||||
|
||||
这样,我们就将 `currentInstance` 始终指向当前正在执行的组件实例,而所有生命周期函数都是在这个过程中执行的,**因此当调用生命周期回调函数时,`currentInstance` 变量必定指向当前所在的组件实例**。
|
||||
|
||||
接下来为了方便,封装了 `createLifecycleMethod` 函数,在组件实例上挂载了一些形如 `_bm`、`_bu` 的数组,比如 `_bm` 表示 `beforeMount`,`_bu` 表示 `beforeUpdate`。
|
||||
|
||||
接下来就是在对应位置调用对应函数了:
|
||||
|
||||
首先在 `attachShadow` 执行之前执行 `_bm` - `onBeforeMount`,因为这个过程确实是准备组件挂载的最后一步。
|
||||
|
||||
然后在 `effect` 中调用了两个生命周期,因为 `effect` 会在每次渲染时执行,所以还特意存储了 `isMounted` 标记是否为初始化渲染:
|
||||
|
||||
```js
|
||||
effect(() => {
|
||||
if (isMounted) {
|
||||
this._bu && this._bu.forEach((cb) => cb())
|
||||
}
|
||||
render(template(), root)
|
||||
if (isMounted) {
|
||||
this._u && this._u.forEach((cb) => cb())
|
||||
} else {
|
||||
isMounted = true
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
这样就很容易看懂了,只有初始化渲染过后,从第二次渲染开始,在执行 `render`(该函数来自 `lit-html` 渲染模版引擎)之前调用 `_bu` - `onBeforeUpdate`,在执行了 `render` 函数后调用 `_u` - `onUpdated`。
|
||||
|
||||
由于 `render(template(), root)` 根据 `lit-html` 的语法,会直接把 `template()` 返回的 HTML 元素挂载到 `root` 节点,而 `root` 就是这个 web component `attachShadow` 生成的 shadow dom 节点,因此这句话执行结束后渲染就完成了,所以 `onBeforeUpdate` 与 `onUpdated` 一前一后。
|
||||
|
||||
最后几个生命周期函数都是利用 web component 原生 API 实现的:
|
||||
|
||||
```js
|
||||
connectedCallback() {
|
||||
this._m && this._m.forEach((cb) => cb())
|
||||
}
|
||||
disconnectedCallback() {
|
||||
this._um && this._um.forEach((cb) => cb())
|
||||
}
|
||||
```
|
||||
|
||||
分别实现 `mount`、`unmount`。这也说明了浏览器 API 分层的清晰之处,只提供创建和销毁的回调,而更新机制完全由业务代码实现,不管是 [@vue/reactivity](https://github.com/vuejs/vue-next/tree/master/packages/reactivity) 的 `effect` 也好,还是 `addEventListener` 也好,都不关心,所以如果在这之上做完整的框架,需要自己根据实现 `onUpdate` 生命周期。
|
||||
|
||||
最后的最后,还利用 `attributeChangedCallback` 生命周期监听自定义组件 html attribute 的变化,然后将其直接映射到对 `this._props[name]` 的变化,这是为什么呢?
|
||||
|
||||
```js
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
this._props[name] = newValue
|
||||
}
|
||||
```
|
||||
|
||||
看下面的代码片段就知道原因了:
|
||||
|
||||
```js
|
||||
const props = (this._props = shallowReactive({}))
|
||||
const template = factory.call(this, props)
|
||||
effect(() => {
|
||||
render(template(), root)
|
||||
})
|
||||
```
|
||||
|
||||
早在初始化时,就将 `_props` 创建为响应式变量,这样只要将其作为 [lit-html](https://github.com/lit/lit/blob/main/packages/lit-html/README.md) 模版表达式的参数(对应 `factory.call(this, props)` 这段,而 `factory` 就是 `defineComponent('my-child', ['msg'], (props) => { ..` 的第三个参数),这样一来,只要这个参数变化了就会触发子组件的重渲染,因为这个 `props` 已经经过 Reactive 处理了。
|
||||
|
||||
## 总结
|
||||
|
||||
[vue-lit](https://github.com/yyx990803/vue-lit) 实现非常巧妙,学习他的源码可以同时了解一下几种概念:
|
||||
|
||||
- reative。
|
||||
- web component。
|
||||
- string template。
|
||||
- 模版引擎的精简实现。
|
||||
- 生命周期。
|
||||
|
||||
以及如何将它们串起来,利用 70 行代码实现一个优雅的渲染引擎。
|
||||
|
||||
最后,用这种模式创建的 web component 引入的 runtime lib 在 gzip 后只有 6kb,但却能享受到现代化框架的响应式开发体验,如果你觉得这个 runtime 大小可以忽略不计,那这就是一个非常理想的创建可维护 web component 的 lib。
|
||||
|
||||
> 讨论地址是:[精读《vue-lit 源码》· Issue #396 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/396)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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` 方法本身。
|
||||
|
||||
## 代码例子
|
||||
|
||||
|
||||
Reference in New Issue
Block a user