Compare commits

..
78 Commits
Author SHA1 Message Date
ascoders b1a43d4843 update readme 2022-02-28 08:44:55 +08:00
ascoders 9cb731cb36 231 2022-02-28 08:43:47 +08:00
ascoders 93828e08b2 230 2022-02-28 08:38:01 +08:00
ascoders 0b06c714fa 230 2022-02-21 09:57:08 +08:00
ascoders cf5cc265cb 229 2022-02-14 09:05:48 +08:00
ascoders 366c6c90c3 fix typo 2022-02-07 14:22:35 +08:00
ascoders 98cc6ace48 Merge branch 'master' of https://github.com/ascoders/weekly 2022-02-07 09:28:50 +08:00
ascoders 751d78be0a 228 2022-02-07 09:28:34 +08:00
黄子毅 acfb7fd442 Merge pull request #393 from xch1029/patch-1
typo zustand 源码
2022-01-24 19:53:26 +08:00
xch1029 750e2cec32 Update 227. 精读《zustand 源码》.md 2022-01-24 11:18:15 +08:00
ascoders d01463fc9e Merge branch 'master' of https://github.com/ascoders/weekly 2022-01-24 08:50:23 +08:00
ascoders 534e5a0f49 227 2022-01-24 08:50:13 +08:00
黄子毅 3b2b2adde4 Merge pull request #391 from tianheg/main
fix typo
2022-01-17 17:08:31 +08:00
Jim Gao e905d37dab fix typo 2022-01-17 16:57:43 +08:00
ascoders 14dbde8615 Merge branch 'master' of https://github.com/ascoders/weekly 2022-01-17 09:08:26 +08:00
ascoders 62afd07694 226 2022-01-17 09:08:06 +08:00
黄子毅 933862c0cd Update 222.精读《深入了解现代浏览器四》.md 2022-01-11 22:09:34 +08:00
黄子毅 d4d87b029f Merge pull request #388 from 11os/patch-1
Update 72.精读《REST, GraphQL, Webhooks, & gRPC 如何选型》.md
2022-01-10 19:25:45 +08:00
11os ed6a7d10c2 Update 72.精读《REST, GraphQL, Webhooks, & gRPC 如何选型》.md 2022-01-10 16:13:08 +08:00
ascoders 6e36e81748 Merge branch 'master' of https://github.com/ascoders/weekly 2022-01-10 08:59:15 +08:00
ascoders f3a50ce6f7 225 2022-01-10 08:59:12 +08:00
黄子毅 c8c9f7c7d9 Update 221.精读《深入了解现代浏览器三》.md 2022-01-07 08:40:32 +08:00
黄子毅 7eeecc34d2 Merge pull request #386 from itLeizhou/patch-1
Update 1.精读《js 模块化发展》.md
2022-01-07 08:39:12 +08:00
itLeizhou c8448215c7 Update 1.精读《js 模块化发展》.md 2022-01-06 14:32:25 +08:00
ascoders 050f00b3e5 Merge branch 'master' of https://github.com/ascoders/weekly 2022-01-04 08:51:18 +08:00
ascoders b661e2329a 224 2022-01-04 08:51:11 +08:00
黄子毅 8691358638 Merge pull request #383 from chenjiahan/master
fix: typo in 精读《React 18》.md
2022-01-01 16:18:55 +08:00
ascoders 8116ed1f72 fix typo 2021-12-27 17:57:47 +08:00
ascoders 6ccc12acfe Merge branch 'master' of https://github.com/ascoders/weekly 2021-12-27 09:03:21 +08:00
ascoders 41a9d364a0 223 2021-12-27 09:03:18 +08:00
neverland 499a766252 fix: typo in 精读《React 18》.md 2021-12-23 11:01:35 +08:00
黄子毅 af0f3dd04d Merge pull request #382 from LiuL0703/patch-9
fix: typo
2021-12-22 10:48:29 +08:00
LiuL0703 3cd0bb8761 fix: typo 2021-12-22 09:56:12 +08:00
ascoders fa6d19f415 update readme 2021-12-20 10:21:47 +08:00
ascoders 7fce362c17 Merge branch 'master' of https://github.com/ascoders/weekly 2021-12-20 10:21:00 +08:00
ascoders fe93fce942 222 2021-12-20 10:19:04 +08:00
黄子毅 088df6eda6 Merge pull request #380 from ihavecoke/master
Update 221.精读《深入了解现代浏览器三》.md
2021-12-17 09:53:09 +08:00
mixbo 62ed443234 Update 221.精读《深入了解现代浏览器三》.md
doc: 修改文案中的错别字
2021-12-17 00:30:32 +08:00
ascoders 49fd947437 221 2021-12-13 09:13:53 +08:00
ascoders d4eb4ee606 update readme 2021-12-06 10:33:19 +08:00
ascoders 04c67e183d 220 2021-12-06 10:32:36 +08:00
ascoders 86ccf45f85 Merge branch 'master' of https://github.com/ascoders/weekly 2021-11-29 09:03:00 +08:00
ascoders 3f9febce36 219 2021-11-29 09:02:55 +08:00
黄子毅 140da78305 Merge pull request #373 from AmagiDDmxh/patch-1
Update 186.精读《设计模式 - State 状态模式》.md
2021-11-27 21:41:04 +08:00
AmagiDDmxh 12c85b9418 Update 186.精读《设计模式 - State 状态模式》.md
Provides a more comprehensive and work example
2021-11-27 10:12:59 +08:00
黄子毅 4cee1951da Merge pull request #372 from senfish/master
fix: typo
2021-11-22 21:38:23 +08:00
915016229 548fde2f85 fix: typo 2021-11-22 11:59:09 +08:00
ascoders 1ed6ac5c48 218 2021-11-22 09:13:34 +08:00
ascoders 4b1fa0c7c6 217 2021-11-15 09:36:07 +08:00
ascoders d0609b0e77 Merge branch 'master' of https://github.com/ascoders/weekly 2021-11-08 09:06:04 +08:00
ascoders 0cc317e9f1 216 2021-11-08 09:05:39 +08:00
黄子毅 8dad1ef1af Update 154. 精读《用 React 做按需渲染》.md 2021-11-07 21:17:32 +08:00
黄子毅 e81a26600f Update 85.精读《手写 SQL 编译器 - 智能提示》.md 2021-11-05 19:15:27 +08:00
ascoders 18a495ec39 Merge branch 'master' of https://github.com/ascoders/weekly 2021-11-01 09:07:05 +08:00
ascoders 713c88518f 215 2021-11-01 09:07:01 +08:00
黄子毅 a7fa58f620 Merge pull request #367 from XBIsland/fix#170
update 170.精读《设计模式\ -\ Prototype\ 原型模式》.md
2021-10-31 22:13:12 +08:00
黄子毅 50e042c90d Merge pull request #366 from XBIsland/fix#169
update 169.精读《设计模式 - Factory Method 工厂方法》.md
2021-10-31 22:12:58 +08:00
陈煜坚 b2c02f7892 update 170.精读《设计模式\ -\ Prototype\ 原型模式》.md 2021-10-31 16:53:16 +08:00
陈煜坚 b91e09f170 update 169.精读《设计模式 - Factory Method 工厂方法》.md 2021-10-31 15:42:46 +08:00
黄子毅 68cd8d1966 Merge pull request #364 from kongmoumou/patch-1
Update 214.精读《web streams》.md
2021-10-26 15:56:15 +08:00
kongmoumou fa28d1513c Update 214.精读《web streams》.md
根据上下文理解是否应该是「可读流」😂
2021-10-26 00:11:03 +08:00
ascoders 14f93ddd71 fix bad image 2021-10-25 21:59:17 +08:00
ascoders da81242d9b 214 2021-10-25 09:15:41 +08:00
ascoders 89c50fc032 Merge branch 'master' of https://github.com/ascoders/weekly 2021-10-18 09:19:33 +08:00
ascoders c81446eff5 213 2021-10-18 09:19:30 +08:00
黄子毅 d5f96ff5a3 Merge pull request #360 from sliuqin/patch-1
Update 212.精读《可维护性思考》.md
2021-10-14 10:36:12 +08:00
黄子毅 db3449ace1 Merge pull request #361 from kaichii/patch-2
错别字修改
2021-10-14 10:35:57 +08:00
Kai Qi 1dddebbb1f 错别字修改 2021-10-13 19:09:27 +08:00
蔡小伦 77e2da3f2d Update 212.精读《可维护性思考》.md
typo fixed
2021-10-13 15:44:04 +08:00
ascoders 019076e87f 212 2021-10-11 09:14:59 +08:00
ascoders 18e058af5b Merge branch 'master' of https://github.com/ascoders/weekly 2021-10-11 09:11:57 +08:00
ascoders fe7b930b95 212 2021-10-11 09:10:58 +08:00
黄子毅 1a9be6aaf8 Merge pull request #358 from xiangwan/patch-1
Update 2.精读《模态框的最佳实践》.md
2021-09-28 22:33:53 +08:00
向晚 3110990b90 Update 2.精读《模态框的最佳实践》.md
fixes  #357
2021-09-28 19:55:16 +08:00
黄子毅 e5e2d49ae1 Merge pull request #354 from fltenwall/master
fix:#103.精读《为什么专家不再关心技术细节》修正
2021-09-27 08:54:38 +08:00
ascoders 94381de60b update readme 2021-09-27 08:51:06 +08:00
fltenwall 70f20e6861 #103修正 2021-09-24 11:45:53 +08:00
ascoders fab11f31d2 210 2021-09-13 09:10:27 +08:00
34 changed files with 4978 additions and 38 deletions
+174
View File
@@ -0,0 +1,174 @@
本系列是 SQL 系列的开篇,介绍一些宏观与基础的内容。
## SQL 是什么?
SQL 是一种结构化查询语言,用于管理关系型数据库,我们 90% 接触的都是查询语法,但其实它包含完整的增删改查和事物处理功能。
## 声明式特性
SQL 属于声明式编程语言,而现代通用编程语言一般都是命令式的。但是不要盲目崇拜声明式语言,比如说它未来会代替低级的命令式语言,因为声明式本身也有它的缺点,它与命令式语言也有相通的地方。
为什么我们觉得声明式编程语言更高级?因为声明式语言抽象程度更高,比如 `select * from table1` 仅描述了要从 table1 查询数据,但查询的具体步骤的完全没提,这背后可能存在复杂的索引优化与锁机制,但我们都无需关心,这简直是编程的最高境界。
那为什么现在所有通用业务代码都是命令式呢?因为 **命令式给了我们描述具体实现的机会** ,而通用领域的编程正需要建立在严谨的实现细节上。比如校验用户权限这件事,即便 AI 编程提供了将 “登陆用户仅能访问有权限的资源” 转化为代码的能力,我们也不清楚资源具体指哪些,以及在权限转移过程中的资源所有权属于谁。
SQL 之所以能保留声明式特性,完全因为锁定了关系型数据管理这个特定领域,而恰恰对这个领域的需求是标准化且可枚举的,才使声明式成为可能。
基于命令式语言也完全可拓展出声明式能力,比如许多 ORM 提供了类似 `select({}).from({}).where({})` 之类的语法,甚至一个 `login()` 函数也是声明式编程的体现,因为调用者无需关心是如何登陆的,总之调用一下就完成了登陆,这不就是声明式的全部精髓吗?
## 语法分类
作为关系型数据库管理工具,SQL 需要定义、操纵与控制数据。
数据定义即修改数据库与表级别结构,这些是数据结构,或者是数据元信息,它不代表具体数据,但描述数据的属性。
数据操纵即修改一行行具体数据,增删改查。
数据控制即对事务、用户权限的管理与控制。
### 数据定义
DDLData Definition Language)数据定义,包括 `CREATE` `DROP` `ALTER` 方法。
### 数据操纵
DMLData Manipulation Language)数据操纵,包括 `SELECT` `INSERT` `UPDATE` `DELETE` 方法。
### 数据控制
DCLData Control Language)数据控制,包括 `COMMIT``ROLLBACK` 等。
所有 SQL 操作都围绕这三种类型,其中数据操纵几乎占了 90% 的代码量,毕竟数据查询的诉求远大于写,数据写入对应数据采集,而数据查询对应数据分析,数据分析领域能玩出的花样远比数据采集要多。
PS:有些情况下,会把最重要的 `SELECT` 提到 DQLData 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
OLAPOnLine 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)
+1
View File
@@ -12,6 +12,7 @@ const dirs = [
"源码解读",
"商业思考",
"算法",
"SQL"
];
dirs.forEach((dir) => {
+28 -1
View File
@@ -6,7 +6,7 @@
前端界的好文精读,每周更新!
最新精读:<a href="./前沿技术/209.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%8D%95%E8%8E%B7%E6%89%80%E6%9C%89%E5%BC%82%E6%AD%A5%20error%E3%80%8B.md">209.精读《捕获所有异步 error》</a>
最新精读:<a href="./SQL/231.SQL%20%E5%85%A5%E9%97%A8.md">231.SQL 入门</a>
素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2)
@@ -19,6 +19,8 @@
### 前沿技术
### 前沿技术
- <a href="./前沿技术/1.%E7%B2%BE%E8%AF%BB%E3%80%8Ajs%20%E6%A8%A1%E5%9D%97%E5%8C%96%E5%8F%91%E5%B1%95%E3%80%8B.md">1.精读《js 模块化发展》</a>
- <a href="./前沿技术/2.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%A8%A1%E6%80%81%E6%A1%86%E7%9A%84%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5%E3%80%8B.md">2.精读《模态框的最佳实践》</a>
- <a href="./前沿技术/3.%E7%B2%BE%E8%AF%BB%E3%80%8A%E5%89%8D%E5%90%8E%E7%AB%AF%E6%B8%B2%E6%9F%93%E4%B9%8B%E4%BA%89%E3%80%8B.md">3.精读《前后端渲染之争》</a>
@@ -167,6 +169,25 @@
- <a href="./前沿技术/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">207.精读《Typescript infer 关键字》</a>
- <a href="./前沿技术/208.%E7%B2%BE%E8%AF%BB%E3%80%8ATypescript%204.4%E3%80%8B.md">208.精读《Typescript 4.4》</a>
- <a href="./前沿技术/209.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%8D%95%E8%8E%B7%E6%89%80%E6%9C%89%E5%BC%82%E6%AD%A5%20error%E3%80%8B.md">209.精读《捕获所有异步 error》</a>
- <a href="./前沿技术/210.%E7%B2%BE%E8%AF%BB%E3%80%8Aclass%20static%20block%E3%80%8B.md">210.精读《class static block》</a>
- <a href="./前沿技术/211.%E7%B2%BE%E8%AF%BB%E3%80%8AMicrosoft%20Power%20Fx%E3%80%8B.md">211.精读《Microsoft Power Fx》</a>
- <a href="./前沿技术/212.%E7%B2%BE%E8%AF%BB%E3%80%8A%E5%8F%AF%E7%BB%B4%E6%8A%A4%E6%80%A7%E6%80%9D%E8%80%83%E3%80%8B.md">212.精读《可维护性思考》</a>
- <a href="./前沿技术/213.%E7%B2%BE%E8%AF%BB%E3%80%8APrisma%20%E7%9A%84%E4%BD%BF%E7%94%A8%E3%80%8B.md">213.精读《Prisma 的使用》</a>
- <a href="./前沿技术/214.%E7%B2%BE%E8%AF%BB%E3%80%8Aweb%20streams%E3%80%8B.md">214.精读《web streams》</a>
- <a href="./前沿技术/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">215.精读《什么是 LOD 表达式》</a>
- <a href="./前沿技术/216.%E7%B2%BE%E8%AF%BB%E3%80%8A15%20%E5%A4%A7%20LOD%20%E8%A1%A8%E8%BE%BE%E5%BC%8F%20-%20%E4%B8%8A%E3%80%8B.md">216.精读《15 大 LOD 表达式 - 上》</a>
- <a href="./前沿技术/217.%E7%B2%BE%E8%AF%BB%E3%80%8A15%20%E5%A4%A7%20LOD%20%E8%A1%A8%E8%BE%BE%E5%BC%8F%20-%20%E4%B8%8B%E3%80%8B.md">217.精读《15 大 LOD 表达式 - 下》</a>
- <a href="./前沿技术/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">218.精读《Rust 是 JS 基建的未来》</a>
- <a href="./前沿技术/219.%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%E4%B8%80%E3%80%8B.md">219.精读《深入了解现代浏览器一》</a>
- <a href="./前沿技术/220.%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%E4%BA%8C%E3%80%8B.md">220.精读《深入了解现代浏览器二》</a>
- <a href="./前沿技术/221.%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%E4%B8%89%E3%80%8B.md">221.精读《深入了解现代浏览器三》</a>
- <a href="./前沿技术/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">222.精读《深入了解现代浏览器四》</a>
- <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>
### 设计模式
@@ -221,6 +242,8 @@
- <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>
### 商业思考
@@ -244,6 +267,10 @@
- <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>
## 关注前端精读微信公众号
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
@@ -81,7 +81,7 @@ YUI3 的 sandbox 像极了差不多同时出现的 AMD 规范,但早期 yahoo
对于 js 模块化,最近出现的 `<script type="module">` 方式,虽然还没有得到浏览器原生支持,但也是我比较看好的未来趋势,这样就连 webpack 的拆包都不需要了,直接把源代码传到服务器,配合 http2.0 完美抛开预编译的枷锁。
上述三方案都不依赖预编译,分别实现了 html、css、js 模块化,相信这就是未来。
上述三方案都不依赖预编译,分别实现了 html、css、js 模块化,相信这就是未来。
### 模块化标准推进速度仍然缓慢
@@ -27,20 +27,12 @@ BI 平台是阿里数据中台团队非常重要的平台级产品,要保证
3. 如果切换到 active 后 props 没有变化,也不应该触发重渲染。
4. 从 active 切换到 inActive 后不应触发渲染,且立即阻塞后续重渲染。
目前 Function Component 做不到这一点,我们仍需借助 Class Component 的 `shouldComponentUpdate` 做到这一点,因为 Class Component 阻塞渲染时,会将最新 props 存储下来,而 Function Component 完全没有内部状态,目前还无法胜任这项工作。
我们可以写一个 `RenderWhenActive` 组件轻松实现此功能:
```jsx
class RenderWhenActive extends React.Component {
public shouldComponentUpdate(nextProps) {
return nextProps.active;
}
public render() {
return this.props.children
}
}
const RenderWhenActive = React.memo(({ children }) => children, (prevProps, nextProps) => (
!nextProps.active
))
```
### 获取组件 active 状态
@@ -11,7 +11,7 @@
# 2 内容概要
来自 Wikipedia 的定义:模态框是一个定位于应用视窗层的元素。它创造了一种模式让自身保持在一个最外层的子视察下显示,并让主视窗失效。用户必须在回到主视窗前在它上面做交互动作。
来自 Wikipedia 的定义:模态框是一个定位于应用视窗层的元素。它创造了一种模式让自身保持在一个最外层的子视察下显示,并让主视窗失效。用户必须在回到主视窗前在它上面做交互动作。
**模态框用处**
@@ -103,7 +103,7 @@ const TdElement = data.map(item => {
});
```
上面代码初始化执行了 N 个模态框初始化代码,显然不合适。对于 table 操作列中触发的模态框,所有行都对应一个模态框,通过父级中一个状态变量来控制展示的内容:
上面代码初始化执行了 N 个模态框初始化代码,显然不合适。对于 table 操作列中触发的模态框,所有行都复用同一个模态框,通过父级中一个状态变量来控制展示的内容:
```js
class Table extends Component {
+1 -1
View File
@@ -103,7 +103,7 @@ const root = ReactDOM.hydrateRoot(container, <App tab="home" />);
简单来说,Concurrent Mode 就是一种可中断渲染的设计架构。什么时候中断渲染呢?当一个更高优先级渲染到来时,通过放弃当前的渲染,立即执行更高优先级的渲染,换来视觉上更快的响应速度。
有人可能会说,不对啊,中断渲染后,之前渲染的 CPU 执行不就浪费了吗,换句话说,整体执行时增加了。这句话是对的,但实际上用户对页面交互及时性的感知是分为两种的,第一种是即时输入反馈,第二种是这个输入带来的副作用反馈,比如更新列表。其中,即使输入反馈只要能优先满足,即便副作用反馈更慢一些,也会带来更好的体验,更不用说副作用反馈大部分情况会因为即使输入反馈的变化而作废。
有人可能会说,不对啊,中断渲染后,之前渲染的 CPU 执行不就浪费了吗,换句话说,整体执行时增加了。这句话是对的,但实际上用户对页面交互及时性的感知是分为两种的,第一种是即时输入反馈,第二种是这个输入带来的副作用反馈,比如更新列表。其中,即使输入反馈只要能优先满足,即便副作用反馈更慢一些,也会带来更好的体验,更不用说副作用反馈大部分情况会因为即使输入反馈的变化而作废。
由于 React 将渲染 DOM 树机制改为两个双向链表,并且渲染树指针只有一个,指向其中一个链表,因此可以在更新完全发生后再切换指针指向,而在指针切换之前,随时可以放弃对另一颗树的修改。
@@ -0,0 +1,142 @@
[class-static-block](https://github.com/tc39/proposal-class-static-block) 提案于 [2021.9.1](https://github.com/tc39/proposal-class-static-block/commit/c0cabee0aa2d036a8d902fea7bc1d179e3de2477) 进入 stage4,是一个基于 Class 增强的提案。
本周我们结合 [ES2022 feature: class static initialization blocks](https://2ality.com/2021/09/class-static-block.html) 这篇文章一起讨论一下这个特性。
## 概述
为什么我们需要 class static block 这个语法呢?其中一个原因是对 Class 静态变量的灵活赋值需求。以下面为例,我们想在 Class 内部对静态变量做批量初始化,就不得不写一个无用的 `_` 变量用来做初始化的逻辑:
```typescript
class Translator {
static translations = {
yes: 'ja',
no: 'nein',
maybe: 'vielleicht',
};
static englishWords = [];
static germanWords = [];
static _ = initializeTranslator( // (A)
this.translations, this.englishWords, this.germanWords);
}
function initializeTranslator(translations, englishWords, germanWords) {
for (const [english, german] of Object.entries(translations)) {
englishWords.push(english);
germanWords.push(german);
}
}
```
而且我们为什么把 `initializeTranslator` 写在外面呢?就因为在 Class 内部不能写代码块,但这造成一个严重的问题,是外部函数无法访问 Class 内部属性,所以需要做一堆枯燥的传值。
从这个例子看出,我们为了自定义一段静态变量初始化逻辑,需要做出两个妥协:
1. 在外部定义一个函数,并接受大量 Class 成员变量传参。
2. 在 Class 内部定义一个无意义的变量 `_` 用来启动这个函数逻辑。
这实在太没有代码追求了,我们在 Class 内部做掉这些逻辑不就简洁了吗?这就是 class static block 特性:
```typescript
class Translator {
static translations = {
yes: 'ja',
no: 'nein',
maybe: 'vielleicht',
};
static englishWords = [];
static germanWords = [];
static { // (A)
for (const [english, german] of Object.entries(this.translations)) {
this.englishWords.push(english);
this.germanWords.push(german);
}
}
}
```
可以看到,`static` 关键字后面不跟变量,而是直接跟一个代码块,就是 class static block 语法的特征,在这个代码块内部,可以通过 `this` 访问 Class 所有成员变量,包括 `#` 私有变量。
原文对这个特性使用介绍就结束了,最后还提到一个细节,就是执行顺序。即所有 `static` 变量或区块都按顺序执行,父类优先执行:
```typescript
class SuperClass {
static superField1 = console.log('superField1');
static {
assert.equal(this, SuperClass);
console.log('static block 1 SuperClass');
}
static superField2 = console.log('superField2');
static {
console.log('static block 2 SuperClass');
}
}
class SubClass extends SuperClass {
static subField1 = console.log('subField1');
static {
assert.equal(this, SubClass);
console.log('static block 1 SubClass');
}
static subField2 = console.log('subField2');
static {
console.log('static block 2 SubClass');
}
}
// Output:
// 'superField1'
// 'static block 1 SuperClass'
// 'superField2'
// 'static block 2 SuperClass'
// 'subField1'
// 'static block 1 SubClass'
// 'subField2'
// 'static block 2 SubClass'
```
所以 Class 内允许有多个 class static block,父类和子类也可以有,不同执行顺序结果肯定不同,这个选择权交给了使用者,因为执行顺序和书写顺序一致。
## 精读
结合提案来看,class static block 还有一个动机,就是给了一个访问私有变量的机制:
```typescript
let getX;
export class C {
#x
constructor(x) {
this.#x = { data: x };
}
static {
// getX has privileged access to #x
getX = (obj) => obj.#x;
}
}
export function readXData(obj) {
return getX(obj).data;
}
```
理论上外部无论如何都无法访问 Class 私有变量,但上面例子的 `readXData` 就可以,而且不会运行时报错,原因就是其整个流程都是合法的,最重要的原因是,class static block 可以同时访问私有变量与全局变量,所以可以利用其做一个 “里应外合”。
不过我并不觉得这是一个好点子,反而像一个 "BUG",因为任何对规定的突破都会为可维护性埋下隐患,除非这个特性用在稳定的工具、框架层,用来做一些便利性工作,最终提升了应用编码的体验,这种用法是可以接受的。
最后要意识到,class static block 本质上并没有增加新功能,我们完全可以用普通静态变量代替,只是写起来很不自然,所以这个特性可以理解为对缺陷的补充,或者是语法完善。
## 总结
总的来说,class static block 在 Class 内创建了一个块状作用域,这个作用域内拥有访问 Class 内部私有变量的特权,且这个块状作用域仅在引擎调用时初始化执行一次,是一个比较方便的语法。
原文下方有一些反对声音,说这是对 JS 的复杂化,也有诸如 JS 越来越像 Java 的声音,不过我更赞同作者的观点,也就是 Js 中 Class 并不是全部,现在越来越多代码使用函数式语法,即便使用了 Class 的场景也会存在大量函数申明,所以 class static block 这个提案对开发者的感知实际上并不大。
> 讨论地址是:[精读《class static block》· Issue #351 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/351)
**如果你想参与讨论,请 [点击这里](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,122 @@
Power Fx 是一门语言,虽然它被推荐的场景是低代码,但我们必须以一门语言角度看待它,才能更好的理解。
Power Fx 的创建是为了更好的辅助非专业开发人员,因此这门语言被设计的足够简单,希望这门语言可以同时服务于专业与非专业开发者,这是个非常崇高的理想。
本周我们就随着 [Microsoft Power Fx 概述](https://docs.microsoft.com/zh-cn/power-platform/power-fx/overview) 这篇文章,详细了解一下这门语言是怎么做的。
## 概述
```javascript
Notify("this is a problem", Error)
```
这就是 Power Fx 语言的一个例子,乍一看没什么特别的。
Power Fx 描述的是画布应用公式语言,也就是说,这个编程语言是专门为画布引用设计的。
那什么是画布应用呢?低代码、网站搭建、BI、Web Excel 这些统统都是画布应用,所以 Power Fx 其实是一门适应画布场景的语言,直接面向用户。
那这种画布语言应该具备什么特性呢?Power Fx 团队已经有了一些思考:
- 简单:该语言设计本着简介简单的原则,这样才方便非开发人员上手。
- Excel 一致性:可以帮助 Excel 开发者做知识迁移,一部分是和微软 Excel 太成功了有关,另一方面 Excel 表达式在画布语言领域探索确实深入,有可取性。对不能满足的尝试借鉴 SQL 这种声明性语言。
- 声明性:这个最重要,即描述做什么,而不是如何或何时做。这个有点像 Jquery 转到 React 模式时,过程式代码与数据驱动代码的区别。
- 函数式:函数式在灵活性和易用性上有天然优势,且无副作用的特性也利于理解逻辑与编译优化。
- 组合:即利用函数式这个特性,推荐利用已有函数组合成新功能,而不是将比如 Sort、Filter 等功能在每个组件上重复实现或者重复配置一遍。
- 强类型:类型对可维护性至关重要,再强大的低代码语言,如果没有类型支持,都不能称为易上手。
- 类型推理:可以自动推断类型。这个和强类型一样,有点 TS 的感觉,主要方便书写简洁代码。
- 不推荐面向对象:既然推荐了函数式,当然不推荐面向对象了。
- 可推展:开发者要拥有拓展函数与组件的能力,还要支持通过 Javascript 来拓展。
- 对开发人员友好:这门语言还要在与前面原则不冲突的情况下,尽量对开发人员友好。
- 语言的迭代:即当语法变更时,要帮助用户平滑迁移,毕竟这门语言直接面向普通用户而非专业开发者。Power Fx 提供了这个能力,对每个文档进行版本标记,并在升级后,通过 “兼容转换器” 自动将老语法升级为新语法。
- 无 undefined 值:为了简化语言带来的理解成本,移除了 undefined 值这个特定。
所以,基于这些考虑的 Power Fx 设计出来是这样的:
1. 实时性
即无论任何 UI 或语法错误,都不会阻塞其它正常节点的工作,同时代码效果与错误信息实时反馈。这保证了在画布应用编写逻辑的良好体验,因为本身画布应用就是实时的,低代码能力本身也要与画布实时性浑然一体。
<img width=500 src="https://z3.ax1x.com/2021/09/25/4sqx1g.gif">
2. 低代码特征
即任何 UI 组件都不需要描述类似 `onChange` 之类的回调,它们只要申明使用的变量,当这些变量变化时,程序会自动、异步、按需的更新使用到的组件。
3. 与无代码结合
所谓无代码,就是通过 UI 表单可视化的对画布应用进行配置。
与无代码的结合方式是,任意属性都可以用低代码,即表达式编写,但也提供了 UI 表单供编辑,其中 UI 表单编辑后,可以用低代码二次加工,而用低代码编辑的属性,表单就无法编辑了,此时点击表单编辑会跳转到低代码编辑框。
## 精读
创建一门不用学习就能上手的编程语言,需要足够简单,即从用户角度来理解事物:比如用户不知道回调函数等概念,那就屏蔽所谓的回调函数概念,让一切都是表达式。
这些表达式看起来很简单,也符合直觉,并且会自动驱动 UI 重绘,即声明式编程。
下面我们来讨论几个有意思的点:
### 为什么不用 Js
大部分画布应用都是指 Web 应用了,即便是 Excel,现在也早已转型到 Web Excel,就微软来说,早早转型到 Office Online 就能看出来。
然而 Js 是浏览器内置支持的脚本语言,且上手成本也比较低,其实很多低代码平台内置的编程语言就是 Js,其好处是实现成本低(沙箱甚至 `new function`),而 Power Fx 在浏览器平台最终也要转换为 Js 执行,费这么大劲创造一门新语言,无非是觉得 Js 不够 “零门槛”。
首先第一点是不符合 Excel 表达式规范,我们不要忘了 Power Fx 也是有小心机的,它想利用 Excel 生态扩大用户群,所以第一目的是兼容 Excel 语法。比如 Excel 使用 & 链接字符串,而 Js 使用 + 连接,虽然我觉得显然 + 号更自然,但微软觉得还是要符合 Excel 用户习惯。说实话在这一点上,撇开 Excel 的语法,我很难看出为什么 & 连接字符串就 “更易上手”,而 + 连接字符串 “更适合程序员使用”。
但有些是认可的,比如移除了 undefined 值,确实让语言更好理解。
也许未来 Power Fx 会更进一步,引入类 SQL 描述性的语法,像写自然语言一样编程,在这种程度上,配合强类型提示,在特定场景会比 Js 更好用。
### 提供内置函数
Js 提供了大量内置函数,这似乎不是 Power Fx 的专利,但 Power Fx 提供了许多 UI 级别的函数,这可比 Js 点到为止的 `alert` 强多了。
Power Fx 提供了 Confirm、Notify 用于弹出提示窗供用户输入,并且就算要形成逻辑,也只需要几乎一行代码:
```text
If( Confirm( "Are you sure?", {Title: "Delete Confirmation"} ), Remove( ThisItem ) )
```
可以看到,这里充斥着异步操作:
- 等待用户输入。
- 删除元素。
但这些内置函数间的组合将异步效果转换为同步写法,这大大降低开发成本。
另一类内置函数则封装了业务属性,比如 `User` 可以获取当前用户信息。本来获取用户信息就需要代码开发,但低代码平台本身就实现了全套账号体系,因此低代码平台可以直接提供如 `User().Email` 函数访问当前用户的邮箱地址。
还有诸如 `Reset` 函数,可以重制控件为默认值,比如 `Reset( TextInput1 )`,这其实是把平台提供的所有上层能力抽象成低代码函数供用户调用,这样用户只要付出一点点学习成本,就可以获得比简单 UI 强大的多的应用编辑能力,这非常值得我们学习。
更多公式函数可以参考 [文档](https://docs.microsoft.com/zh-cn/powerapps/maker/canvas-apps/formula-reference)。
### 提供对表的操作
[对表的操作](https://docs.microsoft.com/zh-cn/power-platform/power-fx/tables) 让应用数据管理可以和 Excel 同一概念来看待了,这个统一方式就是,把数据抽象成表。Power Fx 提供了系列函数用于表处理:
```text
AddColumns(
Filter( Products, 'Quantity Requested' > 'Quantity Available' ),
"Quantity To Order", 'Quantity Requested' - 'Quantity Available'
)
```
这些函数可以跨语言操作 Excel、Sql Server 等数据源的数据,学习成本与 SQL 类似,其实到这一步,对低代码用户的要求也不低,至少和熟练使用计算公式的 Excel 使用者相当。
## 总结
UI 编辑能力局限但易上手,代码能力最强但难上手,Power Fx 给我们提供了一种折中方案,即提供一种 “高度封装的简化代码” 供用户使用。
纵观其它低代码平台,也有一类采用了另一种折中方案,即超强的复杂编辑 UI,登峰造极的产物便是逻辑编排,这个方向在特定领域也是不错的选择,参考: [精读《低代码逻辑编排》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/197.%E7%B2%BE%E8%AF%BB%E3%80%8A%E4%BD%8E%E4%BB%A3%E7%A0%81%E9%80%BB%E8%BE%91%E7%BC%96%E6%8E%92%E3%80%8B.md)。
> 讨论地址是:[精读《Microsoft Power Fx》· Issue #355 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/355)
**如果你想参与讨论,请 [点击这里](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,101 @@
> PS: 所有没给原文链接的精读都是原创,本篇也是原创。
前端精读之前写了 23 篇设计模式总结文,再加上 6 种设计原则,开闭、单一职责、依赖倒置、接口分离、迪米特法则、里氏替换原则,基本上对代码的可维护性有了全面深刻的理解。
但你我在工作中都会不断遇到烂代码,快要无法维护的大型项目,想一想,仅凭设计模式就能解决这些问题吗?为什么不断膨胀的大型项目总是变得越来越难以维护,而复杂度更高的真实世界,但没有人觉得快要崩塌了呢?
设计模式考虑的是代码之间的关系,设计原则考虑的是模块以及项目间的关系,那是否存在更上层的思考,解决大型项目越来越难维护的问题?
## 精读
先考虑一下,为什么真实世界没有可维护性问题?
### 真实世界为什么没有可维护问题
这个问题看起来有点傻,因为从来没有人会发出这样的抱怨 “我们的产品、科技、概念太多了,多到我觉得无法在这个世界活下去了”。但是在代码世界,程序员经常会抱怨,项目的概念太多、设计过于复杂,以至于他无法继续再维护下去了,是时候寻找下一份工作了。
一种显而易见的解释是,生活中,我们都是小角色,活在自己的天空下并不需要触及那么多概念,而程序员在项目中基本扮演了上帝的角色,必须为每一个细节操心。
但这并不完全解释得通。我们以为自己接触的东西不多,但实际上日常生活的知识太多了,就拿家电来说,每个人都会同时接触几十种家电,大到空调冰箱洗衣机,小到手机牙刷充电器,即便这些产品被大量标准化,但每个产品用起来都有大量细节的区别,但没有一个人觉得学习使用一个新剃须刀是一种负担,也并不觉得一款设计得不好的牙刷,会对整个牙刷行业造成怎样负面的冲击。
这背后的原因是:拷贝。正因为我们用的每一件东西都是拷贝,所以即使用坏了也不会对其它相同物品产生任何影响。但代码世界则不同,因为代码调用关系的存在,复用的越优雅,破坏力也就越大。一栋大楼断了几块钢筋尚可支撑,但换在代码世界,只要断了一块钢筋,就意味着这栋大楼所有钢筋都断了。这就是程序员最痛恨的问题之一,就是为什么改了一处看似人畜无害的代码,却导致一场故障。
从这个角度来说,代码世界是无法吸取真实世界经验的。而且代码世界的这种副作用,在商业上是有巨大正向价值的,即软件的边际成本几乎为零,这是实体产品做不到的,因此软件需要付出可维护性代价,似乎是这种极低边际成本的代价。
虽然通过借鉴真实世界的经验,使自己维护成本变成零是不可能的,但真实世界对软件世界确实有可借鉴之处,下面我们就来探讨几个有意思的点。
### 真实世界不断屏蔽复杂度
不知道你会不会有过这样的思考:面试官总是问原理,就是担心我只会用框架,而缺乏基础。但基础是什么呢?懂得 js,java 算是基础吗?也可以说不算,因为这些语言背后的编译原理好像才是基础,编译原理背后还有操作系统,操作系统运行在硬件上,而硬件的原理呢?从 CPU 设计到背后的硅是如何制作的,等等,这样下去,似乎永远也无法掌握原理。
但当我们从软件推导到硬件时,可以很自然的发现,没有人觉得掌握硅胶的制作过程是一件必须的事,我们可以一直使用硅胶制作的产品,但却可以不用了解硅胶制作的原理。
真实世界总是不断屏蔽复杂度,作为消费者时,我们面对的商品总是经过精心包装,简单易用的,只有我们工作时,才需要对某个专业领域的原理有所了解。
这个道理可以迁移到代码世界,即对于一个庞大而复杂的项目,不能指望每位开发者都了解全部原理后才能工作,我们需要在大多数时候把开发者当作消费者来看待,提供精美而稳定的接口。要做到这一点,需要一个类似下图的架构设计:
<img width=400 src="https://z3.ax1x.com/2021/10/08/5PTFBR.png">
从图中可以看出,即便是业务层代码,我也不需要关心过于底层的实现,底层的代码就像脚下被压实了的土地,只需要在上面走就行了。
然而最让人崩溃的是下面的设计:
<img width=400 src="https://z3.ax1x.com/2021/10/08/5P7Fxg.png">
为了解决一个问题,需要面对无穷无尽的上下文,这就是维护成本高的最主要原因。
### 为什么觉得维护成本高
作为开发者,已经习惯了评价代码维护成本高还是低,今天我们换个视角,想一想为什么你会觉得维护成本高?
对维护成本的感受不完全是客观的,我画了一个四象限图:
<img width=300 src="https://z3.ax1x.com/2021/10/09/5iGjZ6.png">
左边是和人相关部分,包括你对代码的理解能力,以及对项目的熟悉度。
理解能力越强,越不容易觉得维护成本高;对项目越熟悉,哪怕是屎山代码,也会觉得重构后可维护性并不会提高,因为自己对项目会变得不熟悉。
右边是和项目相关部分,包括业务本身的复杂度,以及这背后的技术抽象实现的质量。
业务本身越复杂,维护成本就会越高,因为信息量不可避免的增大了,我们永远不能只盯着 Hello World 的 Demo 研究框架;代码质量体现了技术对业务的抽象,抽象的好,复杂度曲线就会比较贴合业务真实复杂度,抽象的不好,Hello World Demo 也能够新人进来喝一壶。
在这四个关键词中,业务复杂度是几乎无法改变的,对项目熟悉也需要一个过程,所以重点应该放在理解能力与代码质量两部分。
无论是个人理解能力,还是代码质量,目标都是帮助我们快速理解项目,也就是说,只要能快速理解技术项目在做什么,我如何快速融入,就会觉得可维护性高,反之则觉得不好维护。
所以一个简单的项目,或者一个分层合理,文档清晰的大型项目都会让人觉得可维护性好。在这一点上,需要向真实世界学习的经验就是,即便在软件世界,也并不是了解所有原理,所有犄角旮旯的逻辑才表明技高一筹,带着这种思想工作只会让大家陷入无尽的内卷和理解焦虑。我们要给大家思想减负,不需要理解的模块、代码设计,就不要轻易展示出来,将每个模块开发所需了解的最小知识设定好,最大程度减少开发者的理解负担。
当然要补充一句,这并不意味着局限开发者的成长和学习空间,其它知识随时敞开大门,只是理解它们并不是日常开发所必要的,这些知识形成文档可以用完即弃,不用成为长期记忆。说到这,就引出了真实世界第二个有趣的地方,就是说明书。
### 真实世界的说明书
我回头想想也挺不可思议的,无论快递买来任何需要组装的东西,按照说明书的指引最终都可以组装好,而且装好之后就可以把说明书扔了,完全没有认知负担。
与其说快递包裹的说明书太完善了,不如说说明不完善,不好用的商品根本卖不出去。我们早已习惯极度易用的商品,及其详尽的说明书了,这是商业社会持续发展,长期博弈后的结果,而且会稳定持续下去。试想一下,如果我们参与维护的项目也有精巧的设计,完善的文档,那维护就不是什么问题,按照文档说的一步步来就行了。
那为什么大部分情况,我们接手的项目就像一个没有说明书的乐高呢?这应该是商品与代码的本质区别了,即商品质量好不好,是由买家用钞票投票的,做得好用,说明书完善的商品才能存活下来,但这背后的技术实现是看不到的,也没有人可以投票,即便技术人员吐槽代码无法维护,但如果项目取得了商业上的成功,也只会越做越大,技术债越滚越多。
技术项目的买家是程序员,但程序员没有拒签的办法,导致无论项目质量如何都要接受,没有市场机制的作用,就导致了烂代码随处可见。
要解决这个问题,首先要意识到这个问题,即技术项目质量本质上是无人长期、持续关心的,你可能会说,技术 Leader 会关心呀?但这和业务驱动相比实在是太弱了。产品有用户侧钞票的投票,无论管理者换多少人,还是会从源头持续提供动力,但项目质量总是要反复强调,间歇性整治,并且不同的 Leader 关心程度也不同,因为这背后没有源动力,除非项目质量影响到用户那头的现金供给了,但这种情况发生时,说明项目早已烂透了。
正是因为技术质量缺乏源动力,或者说源动力传导链路太长,我们才要人为的不断加强重视,重视文档、重视使用体验、重视是否符合设计模式。只有长期主义者才能坚持做代码质量治理,因为坚信总有一天,代码质量会影响到业务发展。
## 总结
这次从真实世界借鉴了一些经验到软件世界,我们从借鉴真实世界的屏蔽复杂度,谈到了为什么真实世界的说明书这么好用,但技术项目文档却总是缺胳膊少腿的问题。
我们总结出的经验是,设计原则与设计模式固然可以提升可维护性,但归根结底还是动力的问题,提升代码质量本身就是一件缺乏动力去做的事,或者长期被认为是重要不紧急的事,往往很难找出理由现在就去做,但没有人觉得不应该做。
所以想要提升可维护性,找到为什么现在,立刻,马上就要做技术优化的原因,并立即开始优化才是最重要的。
> 讨论地址是:[精读《可维护性思考》· Issue #359 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/359)
**如果你想参与讨论,请 [点击这里](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,569 @@
ORM(Object relational mappers) 的含义是,将数据模型与 Object 建立强力的映射关系,这样我们对数据的增删改查可以转换为操作 Object(对象)。
Prisma 是一个现代 Nodejs ORM 库,根据 [Prisma 官方文档](https://www.prisma.io/docs/concepts/overview/what-is-prisma) 可以了解这个库是如何设计与使用的。
## 概述
Prisma 提供了大量工具,包括 Prisma Schema、Prisma Client、Prisma Migrate、Prisma CLI、Prisma Studio 等,其中最核心的两个是 Prisma Schema 与 Prisma Client,分别是描述应用数据模型与 Node 操作 API。
与一般 ORM 完全由 Class 描述数据模型不同,Primsa 采用了一个全新语法 Primsa Schema 描述数据模型,再执行 `prisma generate` 产生一个配置文件存储在 `node_modules/.prisma/client` 中,Node 代码里就可以使用 Prisma Client 对数据增删改查了。
### Prisma Schema
Primsa Schema 是在最大程度贴近数据库结构描述的基础上,对关联关系进行了进一步抽象,并且背后维护了与数据模型的对应关系,下图很好的说明了这一点:
<img width=400 src="https://z3.ax1x.com/2021/10/17/5YwZoF.png">
可以看到,几乎与数据库的定义一模一样,唯一多出来的 `posts``author` 其实是弥补了数据库表关联外键中不直观的部分,将这些外键转化为实体对象,让操作时感受不到外键或者多表的存在,在具体操作时再转化为 join 操作。下面是对应的 Prisma Schema:
```prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Post {
id Int @id @default(autoincrement())
title String
content String? @map("post_content")
published Boolean @default(false)
author User? @relation(fields: [authorId], references: [id])
authorId Int?
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
}
```
`datasource db` 申明了链接数据库信息;`generator client` 申明了使用 Prisma Client 进行客户端操作,也就是说 Prisma Client 其实是可以替换实现的;`model` 是最核心的模型定义。
在模型定义中,可以通过 `@map` 修改字段名映射、`@@map` 修改表名映射,默认情况下,字段名与 key 名相同:
```prisma
model Comment {
title @map("comment_title")
@@map("comments")
}
```
字段由下面四种描述组成:
- 字段名。
- 字段类型。
- 可选的类型修饰。
- 可选的属性描述。
```prisma
model Tag {
name String? @id
}
```
在这个描述里,包含字段名 `name`、字段类型 `String`、类型修饰 `?`、属性描述 `@id`
#### 字段类型
字段类型可以是 model,比如关联类型字段场景:
```prisma
model Post {
id Int @id @default(autoincrement())
// Other fields
comments Comment[] // A post can have many comments
}
model Comment {
id Int
// Other fields
Post Post? @relation(fields: [postId], references: [id]) // A comment can have one post
postId Int?
}
```
关联场景有 1v1, nv1, 1vn, nvn 四种情况,字段类型可以为定义的 model 名称,并使用属性描述 `@relation` 定义关联关系,比如上面的例子,描述了 `Commenct``Post` 存在 nv1 关系,并且 `Comment.postId``Post.id` 关联。
字段类型还可以是底层数据类型,通过 `@db.` 描述,比如:
```prisma
model Post {
id @db.TinyInt(1)
}
```
对于 Prisma 不支持的类型,还可以使用 `Unsupported` 修饰:
```prisma
model Post {
someField Unsupported("polygon")?
}
```
这种类型的字段无法通过 ORM API 查询,但可以通过 `queryRaw` 方式查询。`queryRaw` 是一种 ORM 对原始 SQL 模式的支持,在 Prisma Client 会提到。
#### 类型修饰
类型修饰有 `?` `[]` 两种语法,比如:
```prisma
model User {
name String?
posts Post[]
}
```
分别表示可选与数组。
#### 属性描述
属性描述有如下几种语法:
```prisma
model User {
id Int @id @default(autoincrement())
isAdmin Boolean @default(false)
email String @unique
@@unique([firstName, lastName])
}
```
`@id` 对应数据库的 PRIMARY KEY。
`@default` 设置字段默认值,可以联合函数使用,比如 `@default(autoincrement())`,可用函数包括 `autoincrement()``dbgenerated()``cuid()``uuid()``now()`,还可以通过 `dbgenerated` 直接调用数据库底层的函数,比如 `dbgenerated("gen_random_uuid()")`
`@unique` 设置字段值唯一。
`@relation` 设置关联,上面已经提到过了。
`@map` 设置映射,上面也提到过了。
`@updatedAt` 修饰字段用来存储上次更新时间,一般是数据库自带的能力。
`@ignore` 对 Prisma 标记无效的字段。
所有属性描述都可以组合使用,并且还存在需对 model 级别的描述,一般用两个 `@` 描述,包括 `@@id``@@unique``@@index``@@map``@@ignore`
#### ManyToMany
Prisma 在多对多关联关系的描述上也下了功夫,支持隐式关联描述:
```prisma
model Post {
id Int @id @default(autoincrement())
categories Category[]
}
model Category {
id Int @id @default(autoincrement())
posts Post[]
}
```
看上去很自然,但其实背后隐藏了不少实现。数据库多对多关系一般通过第三张表实现,第三张表会存储两张表之间外键对应关系,所以如果要显式定义其实是这样的:
```prisma
model Post {
id Int @id @default(autoincrement())
categories CategoriesOnPosts[]
}
model Category {
id Int @id @default(autoincrement())
posts CategoriesOnPosts[]
}
model CategoriesOnPosts {
post Post @relation(fields: [postId], references: [id])
postId Int // relation scalar field (used in the `@relation` attribute above)
category Category @relation(fields: [categoryId], references: [id])
categoryId Int // relation scalar field (used in the `@relation` attribute above)
assignedAt DateTime @default(now())
assignedBy String
@@id([postId, categoryId])
}
```
背后生成如下 SQL
```sql
CREATE TABLE "Category" (
id SERIAL PRIMARY KEY
);
CREATE TABLE "Post" (
id SERIAL PRIMARY KEY
);
-- Relation table + indexes -------------------------------------------------------
CREATE TABLE "CategoryToPost" (
"categoryId" integer NOT NULL,
"postId" integer NOT NULL,
"assignedBy" text NOT NULL
"assignedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("categoryId") REFERENCES "Category"(id),
FOREIGN KEY ("postId") REFERENCES "Post"(id)
);
CREATE UNIQUE INDEX "CategoryToPost_category_post_unique" ON "CategoryToPost"("categoryId" int4_ops,"postId" int4_ops);
```
### Prisma Client
描述好 Prisma Model 后,执行 `prisma generate`,再利用 `npm install @prisma/client` 安装好 Node 包后,就可以在代码里操作 ORM 了:
```typescript
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
```
#### CRUD
使用 `create` 创建一条记录:
```typescript
const user = await prisma.user.create({
data: {
email: 'elsa@prisma.io',
name: 'Elsa Prisma',
},
})
```
使用 `createMany` 创建多条记录:
```typescript
const createMany = await prisma.user.createMany({
data: [
{ name: 'Bob', email: 'bob@prisma.io' },
{ name: 'Bobo', email: 'bob@prisma.io' }, // Duplicate unique key!
{ name: 'Yewande', email: 'yewande@prisma.io' },
{ name: 'Angelique', email: 'angelique@prisma.io' },
],
skipDuplicates: true, // Skip 'Bobo'
})
```
使用 `findUnique` 查找单条记录:
```typescript
const user = await prisma.user.findUnique({
where: {
email: 'elsa@prisma.io',
},
})
```
对于联合索引的情况:
```prisma
model TimePeriod {
year Int
quarter Int
total Decimal
@@id([year, quarter])
}
```
需要再嵌套一层由 `_` 拼接的 key
```typescript
const timePeriod = await prisma.timePeriod.findUnique({
where: {
year_quarter: {
quarter: 4,
year: 2020,
},
},
})
```
使用 `findMany` 查询多条记录:
```typescript
const users = await prisma.user.findMany()
```
可以使用 SQL 中各种条件语句,语法如下:
```typescript
const users = await prisma.user.findMany({
where: {
role: 'ADMIN',
},
include: {
posts: true,
},
})
```
使用 `update` 更新记录:
```typescript
const updateUser = await prisma.user.update({
where: {
email: 'viola@prisma.io',
},
data: {
name: 'Viola the Magnificent',
},
})
```
使用 `updateMany` 更新多条记录:
```typescript
const updateUsers = await prisma.user.updateMany({
where: {
email: {
contains: 'prisma.io',
},
},
data: {
role: 'ADMIN',
},
})
```
使用 `delete` 删除记录:
```typescript
const deleteUser = await prisma.user.delete({
where: {
email: 'bert@prisma.io',
},
})
```
使用 `deleteMany` 删除多条记录:
```typescript
const deleteUsers = await prisma.user.deleteMany({
where: {
email: {
contains: 'prisma.io',
},
},
})
```
使用 `include` 表示关联查询是否生效,比如:
```typescript
const getUser = await prisma.user.findUnique({
where: {
id: 19,
},
include: {
posts: true,
},
})
```
这样就会在查询 `user` 表时,顺带查询所有关联的 `post` 表。关联查询也支持嵌套:
```typescript
const user = await prisma.user.findMany({
include: {
posts: {
include: {
categories: true,
},
},
},
})
```
筛选条件支持 `equals``not``in``notIn``lt``lte``gt``gte``contains``search``mode``startsWith``endsWith``AND``OR``NOT`,一般用法如下:
```typescript
const result = await prisma.user.findMany({
where: {
name: {
equals: 'Eleanor',
},
},
})
```
这个语句代替 sql 的 `where name="Eleanor"`,即通过对象嵌套的方式表达语义。
Prisma 也可以直接写原生 SQL
```typescript
const email = 'emelie@prisma.io'
const result = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM User WHERE email = ${email}`
)
```
### 中间件
Prisma 支持中间件的方式在执行过程中进行拓展,看下面的例子:
```typescript
const prisma = new PrismaClient()
// Middleware 1
prisma.$use(async (params, next) => {
console.log(params.args.data.title)
console.log('1')
const result = await next(params)
console.log('6')
return result
})
// Middleware 2
prisma.$use(async (params, next) => {
console.log('2')
const result = await next(params)
console.log('5')
return result
})
// Middleware 3
prisma.$use(async (params, next) => {
console.log('3')
const result = await next(params)
console.log('4')
return result
})
const create = await prisma.post.create({
data: {
title: 'Welcome to Prisma Day 2020',
},
})
const create2 = await prisma.post.create({
data: {
title: 'How to Prisma!',
},
})
```
输出如下:
```text
Welcome to Prisma Day 2020
1
2
3
4
5
6
How to Prisma!
1
2
3
4
5
6
```
可以看到,中间件执行顺序是洋葱模型,并且每个操作都会触发。我们可以利用中间件拓展业务逻辑或者进行操作时间的打点记录。
## 精读
### ORM 的两种设计模式
ORM 有 Active Record 与 Data Mapper 两种设计模式,其中 Active Record 使对象背后完全对应 sql 查询,现在已经不怎么流行了,而 Data Mapper 模式中的对象并不知道数据库的存在,即中间多了一层映射,甚至背后不需要对应数据库,所以可以做一些很轻量的调试功能。
Prisma 采用了 Data Mapper 模式。
### ORM 容易引发性能问题
当数据量大,或者性能、资源敏感的情况下,我们需要对 SQL 进行优化,甚至我们需要对特定的 Mysql 的特定版本的某些内核错误,对 SQL 进行某些看似无意义的申明调优(比如在 where 之前再进行相同条件的 IN 范围限定),有的时候能取得惊人的性能提升。
而 ORM 是建立在一个较为理想化理论基础上的,即数据模型可以很好的转化为对象操作,然而对象操作由于屏蔽了细节,我们无法对 SQL 进行针对性调优。
另外,得益于对象操作的便利性,我们很容易通过 obj.obj. 的方式访问某些属性,但这背后生成的却是一系列未经优化(或者部分自动优化)的复杂 join sql,我们在写这些 sql 时会提前考虑性能因素,但通过对象调用时却因为成本低,或觉得 ORM 有 magic 优化等想法,写出很多实际上不合理的 sql。
### Prisma Schema 的好处
其实从语法上,Prisma Schema 与 Typeorm 基于 Class + 装饰器的拓展几乎可以等价转换,但 Prisma Schema 在实际使用中有一个很不错的优势,即减少样板代码以及稳定数据库模型。
减少样板代码比较好理解,因为 Prisma Schema 并不会出现在代码中,而稳定模型是指,只要不执行 `prisma generate`,数据模型就不会变化,而且 Prisma Schema 也独立于 Node 存在,甚至可以不放在项目源码中,相比之下,修改起来会更加慎重,而完全用 Node 定义的模型因为本身是代码的一部分,可能会突然被修改,而且也没有执行数据库结构同步的操作。
如果项目采用 Prisma,则模型变更后,可以执行 `prisma db pull` 更新数据库结构,再执行 `prisma generate` 更新客户端 API,这个流程比较清晰。
## 总结
Prisma Schema 是 Prisma 的一大特色,因为这部分描述独立于代码,带来了如下几个好处:
1. 定义比 Node Class 更简洁。
2. 不生成冗余的代码结构。
3. Prisma Client 更加轻量,且查询返回的都是 Pure Object。
至于 Prisma Client 的 API 设计其实并没有特别突出之处,无论与 [sequelize](https://sequelize.org/master/) 还是 [typeorm](https://typeorm.io/#/) 的 API 设计相比,都没有太大的优化,只是风格不同。
不过对于记录的创建,我更喜欢 Prisma 的 API
```typescript
// typeorm - save API
const userRepository = getManager().getRepository(User)
const newUser = new User()
newUser.name = 'Alice'
userRepository.save(newUser)
// typeorm - insert API
const userRepository = getManager().getRepository(User)
userRepository.insert({
name: 'Alice',
})
// sequelize
const user = User.build({
name: 'Alice',
})
await user.save()
// Mongoose
const user = await User.create({
name: 'Alice',
email: 'alice@prisma.io',
})
// prisma
const newUser = await prisma.user.create({
data: {
name: 'Alice',
},
})
```
首先存在 `prisma` 这个顶层变量,使用起来会非常方便,另外从 API 拓展上来说,虽然 Mongoose 设计得更简洁,但添加一些条件时拓展性会不足,导致结构不太稳定,不利于统一记忆。
Prisma Client 的 API 统一采用下面这种结构:
```typescript
await prisma.modelName.operateName({
// 数据,比如 create、update 时会用到
data: /** ... */,
// 条件,大部分情况都可以用到
where: /** ... */,
// 其它特殊参数,或者 operater 特有的参数
})
```
所以总的来说,Prisma 虽然没有对 ORM 做出革命性改变,但在微创新与 API 优化上都做得足够棒,github 更新也比较活跃,如果你决定使用 ORM 开发项目,还是比较推荐 Prisma 的。
在实际使用中,为了规避 ORM 产生笨拙 sql 导致的性能问题,可以利用 Prisma Middleware 监控查询性能,并对性能较差的地方采用 `prisma.$queryRaw` 原生 sql 查询。
> 讨论地址是:[精读《Prisma 的使用》· Issue #362 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/362)
**如果你想参与讨论,请 [点击这里](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)
+300
View File
@@ -0,0 +1,300 @@
Node stream 比较难理解,也比较难用,但 “流” 是个很重要而且会越来越常见的概念(`fetch` 返回值就是流),所以我们有必要认真学习 stream。
好在继 node stream 之后,又推出了比较好用,好理解的 web streams API,我们结合 [Web Streams Everywhere (and Fetch for Node.js)](https://css-tricks.com/web-streams-everywhere-and-fetch-for-node-js/)、[2016 - the year of web streams](https://jakearchibald.com/2016/streams-ftw/)、[ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)、[WritableStream](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream) 这几篇文章学一下。
> node stream 与 web stream 可以相互转换:`.fromWeb()` 将 web stream 转换为 node stream`.toWeb()` 将 node stream 转换为 web stream。
## 精读
stream(流)是什么?
stream 是一种抽象 API。我们可以和 promise 做一下类比,如果说 promise 是异步标准 API,则 stream 希望成为 I/O 的标准 API。
什么是 I/O?就是输入输出,即信息的读取与写入,比如看视频、加载图片、浏览网页、编码解码器等等都属于 I/O 场景,所以并不一定非要大数据量才算 I/O,比如读取一个磁盘文件算 I/O,同样读取 `"hello world"` 字符串也可以算 I/O。
stream 就是当下对 I/O 的标准抽象。
为了更好理解 stream 的 API 设计,以及让你理解的更深刻,我们先自己想一想一个标准 I/O API 应该如何设计?
### I/O 场景应该如何抽象 API
`read()``write()` 是我们第一个想到的 API,继续补充的话还有 `open()``close()` 等等。
这些 API 确实可以称得上 I/O 场景标准 API,而且也足够简单。但这些 API 有一个不足,就是缺乏对大数据量下读写的优化考虑。什么是大数据量的读写?比如读一个几 GB 的视频文件,在 2G 慢网络环境下访问网页,这些情况下,如果我们只有 `read``write` API,那么可能一个读取命令需要 2 个小时才能返回,而一个写入命令需要 3 个小时执行时间,同时对用户来说,不论是看视频还是看网页,都无法接受这么长的白屏时间。
但为什么我们看视频和看网页的时候没有等待这么久?因为看网页时,并不是等待所有资源都加载完毕才能浏览与交互的,许多资源都是在首屏渲染后再异步加载的,视频更是如此,我们不会加载完 30GB 的电影后再开始播放,而是先下载 300kb 片头后就可以开始播放了。
无论是视频还是网页,为了快速响应内容,资源都是 **在操作过程中持续加载的**,如果我们设计一个支持这种模式的 API,无论资源大还是小都可以覆盖,自然比 `read``wirte` 设计更合理。
这种持续加载资源的行为就是 stream(流)。
### 什么是 stream
stream 可以认为在形容资源持续流动的状态,我们需要把 I/O 场景看作一个持续的场景,就像把一条河的河水导流到另一条河。
做一个类比,我们在发送 http 请求、浏览网页、看视频时,可以看作一个南水北调的过程,把 A 河的水持续调到 B 河。
在发送 http 请求时,A 河就是后端服务器,B 河就是客户端;浏览网页时,A 河就是别人的网站,B 河就是你的手机;看视频时,A 河是网络上的视频资源(当然也可能是本地的),B 河是你的视频播放器。
所以流是一个持续的过程,而且可能有多个节点,不仅网络请求是流,资源加载到本地硬盘后,读取到内存,视频解码也是流,所以这个南水北调过程中还有许多中途蓄水池节点。
将这些事情都考虑到一起,最后形成了 web stream API。
一共有三种流,分别是:writable streams、readable streams、transform streams,它们的关系如下:
<img width=400 src="https://z3.ax1x.com/2021/10/25/55kQtP.png">
- readable streams 代表 A 河流,是数据的源头,因为是数据源头,所以只可读不可写。
- writable streams 代表 B 河流,是数据的目的地,因为要持续蓄水,所以是只可写不可读。
- transform streams 是中间对数据进行变换的节点,比如 A 与 B 河中间有一个大坝,这个大坝可以通过蓄水的方式控制水运输的速度,还可以安装滤网净化水源,所以它一头是 writable streams 输入 A 河流的水,另一头提供 readable streams 供 B 河流读取。
乍一看很复杂的概念,但映射到河水引流就非常自然了,stream 的设计非常贴近生活概念。
要理解 stream,需要思考下面三个问题:
1. readable streams 从哪来?
2. 是否要使用 transform streams 进行中间件加工?
3. 消费的 writable streams 逻辑是什么?
还是再解释一下,为什么相比 `read()``write()`stream 要多这三个思考:stream 既然将 I/O 抽象为流的概念,也就是具有持续性,那么读取的资源就必须是一个 readable 流,所以我们要构造一个 readable streams(未来可能越来越多函数返回值就是流,也就是在流的环境下工作,就不用考虑如何构造流了)。对流的读取是一个持续的过程,所以不是调用一个函数一次性读取那么简单,因此 writable streams 也有一定 API 语法。正是因为对资源进行了抽象,所以无论是读取还是消费,都被包装了一层 stream API,而普通的 `read` 函数读取的资源都是其本身,所以才没有这些额外思维负担。
好在 web streams API 设计都比较简单易用,而且作为一种标准规范,更加有掌握的必要,下面分别说明:
### readable streams
读取流不可写,所以只有初始化时才能设置值:
```typescript
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue('h')
controller.enqueue('e')
controller.enqueue('l')
controller.enqueue('l')
controller.enqueue('o')
controller.close()
}
})
```
`controller.enqueue()` 可以填入任意值,相当于是将值加入队列,`controller.close()` 关闭后,就无法继续 `enqueue` 了,并且这里的关闭时机,会在 writable streams 的 `close` 回调响应。
上面只是 mock 的例子,实际场景中,读取流往往是一些调用函数返回的对象,最常见的就是 `fetch` 函数:
```typescript
async function fetchStream() {
const response = await fetch('https://example.com')
const stream = response.body;
}
```
可见,`fetch` 函数返回的 `response.body` 就是一个 readable stream。
我们可以通过以下方式直接消费读取流:
```typescript
readableStream.getReader().read().then({ value, done } => {})
```
也可以 `readableStream.pipeThrough(transformStream)` 到一个转换流,也可以 `readableStream.pipeTo(writableStream)` 到一个写入流。
不管是手动 mock 还是函数返回,我们都能猜到,**读取流不一定一开始就充满数据**,比如 `response.body` 就可能因为读的比较早而需要等待,就像接入的水管水流较慢,而源头水池的水很多一样。我们也可以手动模拟读取较慢的情况:
```typescript
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue('h')
controller.enqueue('e')
setTimeout(() => {
controller.enqueue('l')
controller.enqueue('l')
controller.enqueue('o')
controller.close()
}, 1000)
}
})
```
上面例子中,如果我们一开始就用写入流对接,必然要等待 1s 才能得到完整的 `'hello'` 数据,但如果 1s 后再对接写入流,那么瞬间就能读取整个 `'hello'`。另外,写入流可能处理的速度也会慢,如果写入流处理每个单词的时间都是 1s,那么写入流无论何时执行,都比读取流更慢。
所以可以体会到,流的设计就是为了让整个数据处理过程最大程度的高效,无论读取流数据 ready 的多迟、开始对接写入流的时间有多晚、写入流处理的多慢,整个链路都是尽可能最高效的:
- 如果 readableStream ready 的迟,我们可以晚一点对接,让 readableStream 准备好再开始快速消费。
- 如果 writableStream 处理的慢,也只是这一处消费的慢,对接的 “水管” readableStream 可能早就 ready 了,此时换一个高效消费的 writableStream 就能提升整体效率。
### writable streams
写入流不可读,可以通过如下方式创建:
```typescript
const writableStream = new WritableStream({
write(chunk) {
return new Promise(resolve => {
// 消费的地方,可以执行插入 dom 等等操作
console.log(chunk)
resolve()
});
},
close() {
// 可读流 controller.close() 时,这里被调用
},
})
```
写入流不用关心读取流是什么,所以只要关心数据写入就行了,实现写入回调 `write`
`write` 回调需要返回一个 Promise,所以如果我们消费 `chunk` 的速度比较慢,写入流执行速度就会变慢,我们可以理解为 A 河流引水到 B 河流,就算 A 河流的河道很宽,一下就把河水全部灌入了,但 B 河流的河道很窄,无法处理那么大的水流量,所以受限于 B 河流河道宽度,整体水流速度还是比较慢的(当然这里不可能发生洪灾)。
那么 writableStream 如何触发写入呢?可以通过 `write()` 函数直接写入:
```typescript
writableStream.getWriter().write('h')
```
也可以通过 `pipeTo()` 直接对接 readableStream,就像本来是手动滴水,现在直接对接一个水管,这样我们只管处理写入就行了:
```typescript
readableStream.pipeTo(writableStream)
```
当然通过最原始的 API 也可以拼装出 `pipeTo` 的效果,为了理解的更深刻,我们用原始方法模拟一个 `pipeTo`
```typescript
const reader = readableStream.getReader()
const writer = writableStream.getWriter()
function tryRead() {
reader.read().then(({ done, value }) => {
if (done) {
return
}
writer.ready().then(() => writer.write(value))
tryRead()
})
}
tryRead()
```
### transform streams
转换流内部是一个写入流 + 读取流,创建转换流的方式如下:
```typescript
const decoder = new TextDecoder()
const decodeStream = new TransformStream({
transform(chunk, controller) {
controller.enqueue(decoder.decode(chunk, {stream: true}))
}
})
```
`chunk` 是 writableStream 拿到的包,`controller.enqueue` 是 readableStream 的入列方法,所以它其实底层实现就是两个流的叠加,API 上简化为 `transform` 了,可以一边写入读到的数据,一边转化为读取流,供后面的写入流消费。
当然有很多原生的转换流可以用,比如 `TextDecoderStream`
```typescript
const textDecoderStream = TextDecoderStream()
```
### readable to writable streams
下面是一个包含了编码转码的完整例子:
```typescript
// 创建读取流
const readableStream = new ReadableStream({
start(controller) {
const textEncoder = new TextEncoder()
const chunks = textEncoder.encode('hello', { stream: true })
chunks.forEach(chunk => controller.enqueue(chunk))
controller.close()
}
})
// 创建写入流
const writableStream = new WritableStream({
write(chunk) {
const textDecoder = new TextDecoder()
return new Promise(resolve => {
const buffer = new ArrayBuffer(2);
const view = new Uint16Array(buffer);
view[0] = chunk;
const decoded = textDecoder.decode(view, { stream: true });
console.log('decoded', decoded)
setTimeout(() => {
resolve()
}, 1000)
});
},
close() {
console.log('writable stream close')
},
})
readableStream.pipeTo(writableStream)
```
首先 readableStream 利用 `TextEncoder` 以极快的速度瞬间将 `hello` 这 5 个字母加入队列,并执行 `controller.close()`,意味着这个 readableStream 瞬间就完成了初始化,并且后面无法修改,只能读取了。
我们在 writableStream 的 `write` 方法中,利用 `TextDecoder``chunk` 进行解码,一次解码一个字母,并打印到控制台,然后过了 1s 才 `resolve`,所以写入流会每隔 1s 打印一个字母:
```shell
h
# 1s later
e
# 1s later
l
# 1s later
l
# 1s later
o
writable stream close
```
这个例子转码解码处理的还不够优雅,我们不需要将转码与解码写在流函数里,而是写在转换流中,比如:
```typescript
readableStream
.pipeThrough(new TextEncoderStream())
.pipeThrough(customStream)
.pipeThrough(new TextDecoderStream())
.pipeTo(writableStream)
```
这样 readableStream 与 writableStream 都不需要处理编码与解码,但流在中间被转化为了 Uint8Array,方便被其它转换流处理,最后经过解码转换流转换为文字后,再 `pipeTo` 给写入流,这样写入流拿到的就是文字了。
但也并不总是这样,比如我们要传输一个视频流,可能 readableStream 原始值就已经是 Uint8Array,所以具体要不要对接转换流看情况。
## 总结
streams 是对 I/O 抽象的标准处理 API,其支持持续小片段数据处理的特性并不是偶然,而是对 I/O 场景进行抽象后的必然。
我们通过水流的例子类比了 streams 的概念,当 I/O 发生时,源头的流转换是有固定速度的 x M/s,目标客户端比如视频的转换也是有固定速度的 y M/s,网络请求也有速度并且是个持续的过程,所以 `fetch` 天然也是一个流,速度时 z M/s,我们最终看到视频的速度就是 `min(x, y, z)`,当然如果服务器提前将 readableStream 提供好,那么 x 的速度就可以忽略,此时看到视频的速度是 `min(y, z)`
不仅视频如此,打开文件、打开网页等等都是如此,浏览器处理 html 也是一个流的过程:
```typescript
new Response(stream, {
headers: { 'Content-Type': 'text/html' },
})
```
如果这个 readableStream 的 `controller.enqueue` 过程被刻意处理的比较慢,网页甚至可以一个字一个字的逐步呈现:[Serving a string, slowly Demo](https://jakearchibald.github.io/isserviceworkerready/demos/simple-stream/)。
尽管流的场景如此普遍,但也没有必要将所有代码都改成流式处理,因为代码在内存中执行速度很快,变量的赋值是没必要使用流处理的,但如果这个变量的值来自于一个打开的文件,或者网络请求,那么使用流进行处理是最高效的。
> 讨论地址是:[精读《web streams》· Issue #363 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/363)
**如果你想参与讨论,请 [点击这里](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,147 @@
LOD 表达式在数据分析领域很常用,其全称为 Level Of Detail,即详细级别。
## 精读
什么是详细级别,为什么需要 LOD?你一定会有这个问题,我们来一步步解答。
### 什么是详细级别
可以尝试这么发问:你这个数据有多详细?
得到的回答可能是:
1. 数据是汇总的,抱歉看不到细节,不过如果您正好要看总销量的话,这儿都给您汇总好了。。
2. 详细?这直接就是原始表数据,30 亿条,这够详细了吧?如果觉得还不够详细,那只好把业务过程再拆分一下重新埋点了。
详细程度越高,数据量越大,详细程度越低,数据就越少,就越是汇总的数据。
人很难在详细程度很高的 30 亿条记录里看到有价值的信息,所以数据分析的过程也可以看作是 **对数据汇总计算的过程,这背后数据详细程度在逐渐降低**
### BI 工具的详细级别
如果没有 LOD 表达式,一个 BI 查询的详细程度是完全固定的:
- 如果表格拖入度量,没有维度,那就是最高详细级别,因为最终只会汇总出一条记录。
- 如果折线图拖入维度,那结果就是根据这个维度内分别聚合度量,数据更详细了,详细粒度为当前维度,比如日期。
如果我们要更详细的数据,就需要在维度上拖入更多字段,直到达到最详细的明细表级别的粒度。然而同一个查询不可能包含不同详细粒度,因为详细粒度由维度组合决定,不可改变,比如下面表格的例子:
```text
行:国家 省 城市
列:GDP
```
这个例子中,详细级别限定在了城市这一级汇总,城市下更细粒度的数据就看不到了,每一条数据都是城市粒度的,我们不可能让查询结果里出现按照国家汇总的 GDP,或者看到更详细粒度的每月 GDP 信息,更不可能让城市粒度的 GDP 与国家粒度 GDP 在一起做计算,算出城市 GDP 在国家中占比。
但是,类似上面例子的需求是很多的,而且很常见,BI 工具必须想出一种解法,因此诞生了 LOD:**LOD 就是一种表达式,允许我们在一个查询中描述不同的详细粒度**。
### 从表达式计算来看详细级别
表达式计算必须限定在同样的详细粒度,这是铁律,为什么呢?
试想一下下面两张不同详细粒度的表:
`总销售额`
```text
10000
```
`各城市销售额`
```text
北京 3000
上海 7000
```
如果我们想在各城市销售额中,计算贡献占比,那么就要写出 `[各城市销售额] / [总销售额]` 的计算公式,但显然这是不可能的,因为前者有两条数据,后者只有一条数据,根本无法计算。
我们能做的一定是数据行数相同,那么无论是 IF ELSE、CASE WHEN,还是加减乘除都可以按照行粒度进行了。
LOD 给了我们跨详细粒度计算的能力,其本质还是将数据详细粒度统一,但我们可以让某列数据来自于一个完全不同详细级别的计算:
```text
城市 销售额 总销售额
北京 3000 10000
上海 7000 10000
```
如图表,LOD 可以把数据加工成这样,即虽然总销售额与城市详细粒度不同,但还是添加到了每一行的末尾,这样就可以进行计算了。
**因此 LOD 可以按照任意详细级别进行计算,将最终产出 “贴合” 到当前查询的详细级别中。**
LOD 表达式分为三种能力,分别是 FIXED、INCLUDE、EXCLUDE。
### FIXED
```text
{ fixed [省份] : sum([GDP]) }
```
按照城市这个固定详细粒度,计算每个省份的 DGP,最后合并到当前详细粒度里。
假如现在的查询粒度是省份、城市,那么 LOD 字段的添加逻辑如下图所示:
![](https://z3.ax1x.com/2021/10/31/ISJ9JK.png)
可见,本质是两个不同 sql 查询后 join 的结果,内部的 `sum` 表示在 FIXED 表达式内的聚合方式,外部的 `sum` 表示,如果 FIXED 详细级别比当前视图详细级别低,应该如何聚合。在这个例子中,FIXED 详细级别较高,所以 `sum` 不起作用,换成 `avg` 效果也相同,因为合并详细级别是,是一对多关系,只有合并时多对一关系才需要聚合。
最外层聚合方式一般在 INCLUDE 表达式中发挥作用。
### EXCLUDE
```text
{ exclude [城市] : sum([GDP]) }
```
在当前查询粒度中,排除城市这个粒度后计算 GDP,最后合并到当前详细粒度中。
假如现在的查询粒度是省份、城市、季节,那么 LOD 字段的添加逻辑如下图所示:
![](https://z3.ax1x.com/2021/10/31/ISGzIx.png)
如图所示,EXCLUDE 在当前视图详细级别的基础上,排除一些维度,所得到的详细级别一定会更高。
### INCLUDE
```text
{ include [城乡] : avg([GDP]) }
```
在当前查询粒度中,额外加上城乡这个粒度后计算 GDP,最后合并到当前详细粒度中。
这类的例子比较难理解,且在 `sum` 情况下一般无实际意义,因为计算结果不会有差异,必须在类似 `avg` 场景下才有意义,我们还是结合下图来看:
![](https://z3.ax1x.com/2021/10/31/ISGvZR.png)
这就是 avg 算不准的问题,即不同详细级别计算的平均值是不同的,但 sum、count 等不会随着详细级别变化而影响计算结果,所以当涉及到 avg 计算时,可以通过 INCLUDE 表达式指定计算的详细级别,以保证数据口径准确性。
### LOD 字段怎么用
除了上面的例子中,直接查出来展示给用户外,LOD 字段更常用的是作为中间计算过程,比如计算省份 GDP 占在国内占比。因为 LOD 已经将不同详细粒度计算结果合并到了当前的详细粒度里,所以如下的计算表达式:
```text
sum([GDP]) / sum({ fixed [国家] : sum([GDP]) })
```
看似是跨详细粒度计算,其实没有,实际计算时还是一行一行来算的,后面的 **LOD 表达式只是在逻辑上按照指定的详细粒度计算,但最终会保持与当前视图详细粒度一致**,因此可以参与计算。
我们后面会继续解读 tableau 整理的 Top 15 LOD 表达式业务场景,更深入的理解 LOD 表达式。
## 总结
LOD 表达式让你轻松创建 “脱离” 当前视图详细级别的计算字段。
或许你会疑惑,为什么不主动改变当前视图详细级别来实现同样的效果?比如新增或减少一个维度。
原因是,LOD 往往用于跨详细级别的计算,比如算部分相对总体的占比,计算当条记录是否为用户首单等等,更多的场景会在下次精读中解读。
> 讨论地址是:[精读《什么是 LOD 表达式》· Issue #365 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/365)
**如果你想参与讨论,请 [点击这里](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,127 @@
通过上一篇 [精读《什么是 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) 的学习,你已经理解了什么是 LOD 表达式。为了巩固理解,结合场景复习是最有效的手段,所以这次我们结合 [Top 15 LOD Expressions](https://www.tableau.com/about/blog/LOD-expressions) 这篇文章学习 LOD 表达式的 15 大应用场景,因篇幅限制,本文介绍 1~8 场景。
## 1. 客户下单频次
**各下单次数的顾客数量是多少?**
柱状图的 Y 轴显然是 `count([customerID])`,因为要统计 **当前维度下的客户总数**
> 这里插一句,对于柱状图的 Y 轴,在 sql 里就是对 X 轴 `group by` 后的聚合,因此 Y 轴就是对 X 轴各项的汇总。
柱状图的 X 轴要表达的是以何种粒度拆解,比如我们是看各城市数据,还是看各省数据。在这个场景下也不例外,我们要看 **各下单次数下的数据**,那么如何把下单次数转化为维度呢?
我们需要用 FIX 表达式制作一个维度字段,表示各顾客下单次数。很显然数据库是没有这个维度的,而且这个维度需要按照客户 ID group by 后,按照订单 ID count 聚合才能得到,因此可以利用 FIX 表达式:`{ fixed [customerID] : count([orderId]) }` 描述。
![](https://z3.ax1x.com/2021/11/05/IKeDBV.png)
## 2. 阵列分析
当我们看年客户销售量时,即便是逐年增长的,我们也会有一个疑问:**每年销量中,首单在各年份的顾客分别贡献了多少?**
因为关系到老客忠诚度和新客拓展速度,新客与老客差距过大都不好,那我们如何让 2021 年的柱状图按照 2019、2020、2021 年首单的顾客分层呢?这就是阵列分析。
我们要画一个柱状图,X、Y 轴分别是 `[Year]``sum([Sales])`
为了让柱状图分层,我们需要一个表示颜色图例的维度字段,比如我们拖入已有的性别维度,每根柱子就会被划分为男、女两块。但问题是,我们制作并不存在的 “首单年份维度”?
答案是利用 FIX 表达式:`{ fixed [customerID] : min([orderDate]) }`
![](https://z3.ax1x.com/2021/11/05/IKnps1.png)
## 3. 日利润指标
分析 **每年各月份的盈利、亏损天数分布**。如下图:
![](https://z3.ax1x.com/2021/11/06/IQVeET.png)
列是年到月的下钻,比较好实现,只要拖入字段 `[year]` 并下钻到月粒度,移除季度粒度即可。
行是 “高收益”、“正收益”、“亏损” 的透视图,值是在当前月份中天数。
那么如何计算高收益、亏损状态呢?因为最终粒度是天,所以我们要按天计,首先就要得到每天的利润总和,这些中间过程可以利用 LOD 的字段来完成,即创建一个 **日利润字段(profitPerDay**`{ fixed [orderDate] : sum([profit]) }`
由于我们对利润总量不敏感,只希望拆分为三个阶段,所以利用 IF THEN 生成一个新字段 **日利润指标(dailyProfitKPI**`IF [profitPerDay] > 2000 THEN "Highly Profitable" ELSEIF [profitPerDay] <= 0 THEN "unprofitable" ELSE "profitable" END`
所以创建的 `[dailyProfitKPI]` 指标是个维度,即如果当前行所在的天利润汇总如果大于 2000,值就是 "Highly Profitable"。所以在行上拖入 `count(distinct [orderDate])`,把 `[dailyProfitKPI]` 拖入行的颜色透视即可。
## 4. 占总体百分比
LOD 表达式的一大特色就是计算跨详细级别的占比,比如我们要看 **欧洲各国的销量在全世界占比**
![](https://z3.ax1x.com/2021/11/06/IQmRPO.png)
显然这个图里所有国家之和不是 100%,因为欧洲加起来也才不到百分之二十,然而在当前详细级别下,是拿不到全球总销售量的,所以我们可以利用 FIX 表达式来实现:`sum([sales]) / max({ sum([sales]) })`
这里解释两点:
1. 之所以用 `max` 是因为 LOD 表达式只是一个字段,并没有聚合方式,运算必须在相同详细级别下进行,由于总销量只有一条数据,所以我们用 `max` 或者 `min` 甚至 `sum` 都行,结果都是一样的。
2. 如果不加维度限制,就可以省略 “fix” 申明,所以 `{ sum([sales]) }` 实际上就是 FIX 表达式,它表示 `{ fixed : sum([sales]) }`
## 5. 新客增长趋势
看着年客户增长趋势图,你有没有想过,这个趋势图肯定永远是向上的?也就是说,看着趋势图朝上走,不一定说明业务做得好。
如果公司每年都比去年发展的好,每年的新增新客数应该要比去年多,所以 **每年新客增长趋势图** 才比较有意义,如果你看到这个趋势图的趋势朝上,说明每年的新客都比去年多,说明公司摆脱了惯性,每年都获得了新的增长。
所以我们要加一个筛选条件。新增一个维度字段,当这一单客户是今年新客时为 true,否则为 false,这样我们筛选时,只看这个字段为 true 的结果就行了。
那么这个字段怎么来呢?思路是,获取客户首单年份,如果首单年份与当前下单年份相同,值为 true,否则为 false。
我们利用 LOD 创建首单年份字段 `[firstOrderDate]``{ fixed [customerId] : min([orderDate]) }`,然后创建筛选字段 `[newOrExist]`: `IFF([firstOrderDate] = [orderDate], 'true', 'false')`
## 6. 销量对比分析
入下图条形图所示,右侧是每项根据选择的分类的对比数据:
![](https://z3.ax1x.com/2021/11/06/IQ1mOe.png)
对比值计算方式是,用 **当前的销量减去当前选中分类的销量**。相信你可以猜到,但前分类的销量与当前视图详细级别无关,只与用户选择的 Category 有关。
如果我们已经有一个度量字段 - 选中分类销量 `selectedSales`,应该再排除当前 category 维度的干扰,所以可用 EXCLUDE 表达式描述 `selectedCategorySales`: `{ exclude [category] : sum([selectedSales]) }`
接下来是创建 `selectedSales` 字段。背景知识是 `[parameters].[category]` 可以获得当前选中的维度值,那我们可以写个 IF 表达式,在维度等于选中维度时聚合销量,不就是选中销量吗?所以公式是:`IF [category] = [parameters].[category] THEN sales ELSE 0 END`
最后对比差异,只要创建一个 `[diff]` 字段,表达式为 `sum(sales) - sum(selectedCategorySales)` 即可。
## 7. 平均最高交易额
如下图所示,当前的详细级别是国家,但我们却要展示每个国家平均最高交易额:
![](https://z3.ax1x.com/2021/11/06/IQGvN9.png)
显然,要求平均最高交易额,首先要计算每个销售代表的最高交易额,由于这个详细级别比国家低,我们可以利用 INCLUDE 表达式计算销售代表最高交易额 `largestSalesByRep`: `{ include [salesRep] : max([sales]) }`,并对这个度量字段求平均即可。
从这个例子可以看出,如果我们在一个较高的详细级别,比如国家,此时的 `sum([sales])` 是根据国家详细级别汇总的,而忽略了销售代表这个详细级别。但如果要展示每个国家的平均最高交易额,就必须在销售代表这个详细级别求 `max([sales])`,由于是各国家的,所以我们不用 `{ fixed [salesRep] }`,而是 `{ include [salesRep] }`,这样最终计算的详细级别是:`[country][salesRep]`,这样才能算出销售在每个国家的最高交易额(因为也许某些销售同时在不同国家销售)。
## 8. 实际与目标
在第六个例子 - 销量对比分析中,我们可以看到销量绝对值的对比,这次,我们需要计算实际销售额与目标的差距百分比:
![](https://z3.ax1x.com/2021/11/06/IQYHwF.png)
如上图所示,左上角展示了实际与目标的差值;右上角展示了每个地区产品目标完成率;下半部分展示了每个产品实际销量柱状图,并用黑色横线标记出目标值。
左上角非常简单,`[diffActualTraget]`: `[profit] - [targetProfit]`,只要将当前利润与目标利润相减即可。
右上角需要分为几步拆解。我们的最终目标是计算每个地区产品目标完成率,显然公式是 当前完成产品数/总产品数。总产品数比较简单,在已有地区维度拆解下,计算下产品总数就行了,即 `count(distinct [product])`;难点是当前完成产品数,这里我们又要用到 INCLUDE,为什么呢?因为地区粒度比产品粒度高,我们看地区汇总的时候,就不知道各产品的完成情况了,所以必须 INCLUDE product 维度计算利润目标差,公式是 `[diffProductActualTraget]` `{ include [product] : sum(diffActualTraget) }`,然后当这个值大于 0 就认为完成了目标,我们可以再创建一个字段,即完成目标数,如果达成目标就是 1,否则是 0,这样便于求 “当前完成产品数”:`aboveTargetProductCount`: `IFF([diffProductActualTraget] > 0, 1, 0)`,那么当前完成产品数就是 `sum([diffProductActualTraget])`,所以产品目标完成率就是 `sum([diffProductActualTraget]) / count(distinct [product])`,将这个字段拖入指标,按照百分比格式化,就得到结果了。
## 总结
通过上面的例子,我们可以总结出实际业务场景中几条使用心法:
1. 首先对计算公式进行拆解,判断拆解后的字段是否数据集里都有,如果都有的话就结束了,说明是个简单需求。
2. 如果数据集里没有,而且发现数据详细级别与当前不符(比如要得到每个国家销量,但当前维度是城市),就要用 FIXED 表达式固定详细级别。
3. 如果不是明确的按照某个详细级别计算,就不要使用 FIXED,因为不太灵活。
4. 当计算时要跳过某个指定详细级别,但又要保留视图里的详细级别时,使用 EXCLUDE 表达式。
5. 如果计算涉及到比视图低的详细级别,比如计算平均或者最大最小时,使用 INCLUDE 表达式。
6. 使用 FIXED 表达式创建的字段也可以进行二次计算,合理拆解多个计算字段并组合,会让逻辑更加清晰,易于理解。
> 讨论地址是:[精读《15 大 LOD 表达式 - 上》· Issue #369 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/369)
**如果你想参与讨论,请 [点击这里](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,183 @@
接着上一篇 [精读《15 大 LOD 表达式 - 上》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/216.%E7%B2%BE%E8%AF%BB%E3%80%8A15%20%E5%A4%A7%20LOD%20%E8%A1%A8%E8%BE%BE%E5%BC%8F%20-%20%E4%B8%8A%E3%80%8B.md) ,这次继续总结 [Top 15 LOD Expressions](https://www.tableau.com/about/blog/LOD-expressions) 这篇文章的 915 场景。
## 9. 某时间段内最后一天的值
如何实现股票平均每日收盘价与当月最后一天收盘价的对比趋势图?
![](https://z3.ax1x.com/2021/11/13/IrbcKe.png)
如图所示,要对比的并非是某个时间段,而是当月最后一天的收盘价,因此必须要借助 LOD 表达式。
设想原表如下:
| Date | Ticker | Adj Close |
| ---- | ---- | ---- |
| 29/08/2013 | SYMC | $1 |
| 28/08/2013 | SYMC | $2 |
| 27/08/2013 | SYMC | $3 |
我们按照月进行聚合作为横轴,求 `avg([Adj Close])` 作为纵轴即可。但计算对比我们需要一个 Max Date 字段如下:
| Date | Ticker | Adj Close | Max, Date |
| ---- | ---- | ---- | ---- |
| 29/08/2013 | SYMC | $1 | 29/08/2013 |
| 28/08/2013 | SYMC | $2 | 29/08/2013 |
| 27/08/2013 | SYMC | $3 | 29/08/2013 |
如果我们使用 `max(Date)` 表达式,在聚合后结果是可以看到 Max Date 的:
| Month of Date | Ticker | Avg, Adj Close | Max, Date
| ---- | ---- | ---- | ---- |
| 08/2013 | SYMC | $2 | 29/08/2013 |
原因是,`max(Date)` 是一个聚合表达式,只能在 group by 聚合 sql 下生效。但如果我们要计算最后一天的收盘价,就要执行 `sum([Close value on last day]`,表达式如下:
`[Close value on last day] = if [Max Date] = [Date] then [Adj Close] else 0 end`
但问题是,这个表达式计算的明细级别是以天为粒度的,我们 `max(Date)` 在天粒度下是算不出来的:
| Date | Ticker | Adj Close | Max, Date |
| ---- | ---- | ---- | ---- |
| 29/08/2013 | SYMC | $1 | |
| 28/08/2013 | SYMC | $2 | |
| 27/08/2013 | SYMC | $3 | |
原因就是上面说过的,聚合表达式不能在非聚合的明细级别中出现。因此我们利用 `{ include : max([Date]) }` 表达式就能轻松实现下面的效果了:
| Date | Ticker | Adj Close | { include : max([Date]) } |
| ---- | ---- | ---- | ---- |
| 29/08/2013 | SYMC | $1 | 29/08/2013 |
| 28/08/2013 | SYMC | $2 | 29/08/2013 |
| 27/08/2013 | SYMC | $3 | 29/08/2013 |
`{ include : max([Date]) }` 表达式没有给定 include 参数,意味着永远以当前视图的明细级别计算,因此这个字段下推到明细表做计算时,也可以出现在明细表的每一行。接着按照上面的思路组装表达式即可。
拓展一下,如果横轴我们按年进行聚合,那么对比值就是每年最后一天的收盘价。原因是 `{ include : max([Date]) }` 会以当前年这个粒度计算 `max([Date])`,自然是当年的最后一天,然后下推到明细表,整整一年 365 行数据中,`[Close value on last day]` 大概是这样:
| Date | Ticker | Adj Close | [Close value on last day] |
| ---- | ---- | ---- | ---- |
| 31/12/2013 | SYMC | $1 | $1 |
| 30/12/2013 | SYMC | $2 | $1 |
| ... | ... | ... | ... |
| 03/01/2013 | SYMC | $7 | $1 |
| 02/01/2013 | SYMC | $8 | $1 |
| 01/01/2013 | SYMC | $9 | $1 |
接着对比值按照 `sum([Close value on last day])` 聚合即可。
## 10. 复购阵列
如下图所示,希望查看客户第一次购买到第二次购买间隔季度的复购阵列:
![](https://z3.ax1x.com/2021/11/13/Is2FGd.png)
关键在于如何求第一次与第二次购买的季度时间差。首先可以通过 `[1st purchase] = { fixed [customer id] : min([order date]) }` 计算每位客户首次购买时间。
如何计算第二次购买时间?这里有个小技巧。首先利用 `[repeat purchase] = iif([order date] > [1st purchase], [order date], null)` 得到一个新列,首次购买的那一行值为 null,我们可以利用 `min` 函数计算时忽略 null 的特性,得到第二次购买时间:`[2nd purchase] = { fixed [customer id] : min([repeat purchase]) }`
最后利用 `datediff` 函数得到间隔的季度数:`[quarters repeat to purchase] = datediff('quarter', [1st prechase], [2nd purchase])`
## 11. 范围平均值差异百分比
如下图所示,我们希望将趋势图的每个点,与选定区域(图中两个虚线范围内)的均值做一个差异百分比,并生成一个新的折线图放在上方。
![](https://z3.ax1x.com/2021/11/13/IsIuXd.png)
重点是上面折线图 y 轴字段,差异百分比如何表示。首先我们要生成一个只包含指定区间的收盘值:
`[Close value in reference period] = IF [Date] >= [Start reference date] AND [Date] <= [End reference date] THEN [Adj close] END`,这段表达式只在日期在制定区间内时,才返回 `[Adj close]`,也就是只包含这个区间内的值。
第二步,计算制定区间的平均值,这个用 FIX 表达式即可:`[Average daily close value between ref date] = { fixed [Ticker] : AVG([Close value in reference period]) }`
第三步,计算百分比差异:`[percent different from ref period] = ([Adj close] - [Average daily close value between ref date]) / [Average daily close value between ref date]`
最后就是用 `[percent different from ref period]` 这个字段绘制上面的图形了。
## 12. 相对周期过滤
如果我们想对比两个周期数据差异,可能会遇到数据不全导致的错误。比如今年 3 月份数据只产出到 6 号,但却和去年 3 月整月的数据进行对比,显然是不合理的。我们可以利用 LOD 表达式解决这个问题:
![](https://z3.ax1x.com/2021/11/13/IsLJVH.png)
相对周期过滤的重点是,不能直接用日期进行对比,因为今年数据总是比去年大。比如因为今年最新数据到 11.11 号,那么去年 11.11 号之后的数据都要被过滤掉。
首先找到最新数据是哪一天,利用不包含条件的 FIX 表达式即可:`[max date] = { max([date]) }`
然后利用 datepart 函数计算当前日期是今年的第几天:
`[day of year of max date] = datepart('dayofyear', [max date])``[day of year of order date] = datepart('dayofyear', [order date])`
所以 `[day of year of max date]` 就是一个卡点,任何超过今年这么多天的数据都要过滤掉。因此我们创建一个过滤条件:`[period filter] = [day of year of order date] <= [day of year of max date]`
`[period filter]` 字段作为筛选条件即可。
## 13. 用户登陆频率
如何绘制一个用户每个月登陆频率?
![](https://z3.ax1x.com/2021/11/13/IyCfAK.png)
要计算这个指标,得用用户总活跃时间除以总登陆次数。
首先计算总活跃时间:利用 FIX 表达式计算用户最早、最晚的登陆时间:
- `[first login] = { fixed [user id] : min([log in date]) }`
- `[last login] = { fixed [user id] : max([log in date]) }`
计算其中月份 diff,就是用户活跃月数:
`[total months user is active] = datediff("month", [first login], [last login])`
总登录次数比较简单,也是固定用户 ID 后,对登陆日期计数即可:
`[numbers of logins per user] = { fixed [user id] : count([login date]) }`
最后,我们用两者相除,得到用户登陆频率:
`[login frequency] = [total months user is active] / [numbers of logins per user]`
制作图表就很简单了,把 `[login frequency]` 移到横轴,count distinct 用户 ID 作为纵轴即可。
## 14. 比例笔刷
这个是 LOD 最常见的场景,比如求各品类销量占此品类总销量的贡献占比?
![](https://z3.ax1x.com/2021/11/13/IyufEQ.png)
`sum(sales) / sum({ fixed [category] : sum(sales) })` 即可。
当前详细级别是 category + country,我们固定品类,就可以得到各品类在所有国家的累积销量。
## 15. 按客户群划分的年度购买频率
如何证明老客户忠诚度更高?
我们可以如下图,按照客户群(2011 年、2012 年客户)作为图例,观察他们每年购买频次分布。
![](https://z3.ax1x.com/2021/11/13/IyuICn.png)
如上图所示,我们发现顾客注册时间越早,各购买频次的比例都更高,所以证明了老顾客忠诚度更高这一结论。注意这里看的是至少购买 N 次,所以每条线相比才具有说服力。如果是购买 N 次,则可能老顾客购买 1 次较少,购买 10 次较多,难以直接对比。
首先我们生成图例字段,即按最早照购买年份划分顾客群:`[Cohort] = { fixed [customer id] : min(Year([order date])) }`
然后就和我们第一个例子类似,计算每个订单数量下,有多少顾客。唯一的区别是,我们不仅按照顾客 ID group,还要进一步对最早购买日期做拆分,即:`{ fixed [customer id], [Cohort] : count([order id]) }`
上面的字段作为 X 轴,Y 轴和第一个例子类似:`count(customer id)`,但我们想查看的是至少购买 N 次,也就是这个购买次数是累计值,即至少购买 9 次 = 购买 9 次 + 购买 10 次 + ... 购买 MAX 次。所以是一种 DESC 的 `windowsum`,整体表达式应该类似 `[Running Total] = WINDOW_SUM(count(customer id)), 0, LAST())`
最后,因为实际 Y 轴计算的是占比,所以用刚才计算的至少购买 N 次指标除以各 Cohort 下总购买次数,即 `[Running Total] / sum({ fixed [Cohort] : count([customer id]) })`
## 总结
上面的几个例子,都是基于 fixed、include、exclude 这几个基本 LOD 用法的叠加。但从实际例子来看,我们会发现真正的难点不在与 LOD 表达式的语法,而在于我们如何精确理解需求,拆解成合理的计算步骤,并在需要运行 LOD 的计算步骤正确的使用。
LOD 表达式看上去很神奇,似乎可以和数据 “神奇” 的贴合在一起,我们要理解到 LOD 背后就是表之间的 join,而不同明细级别就表示不同的 group by 规则这一背后原理,就能比较好的理解为什么 LOD 表达式能这么运作了。
> 讨论地址是:[精读《15 大 LOD 表达式 - 下》· Issue #370 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/370)
**如果你想参与讨论,请 [点击这里](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,301 @@
[Rust Is The Future of JavaScript Infrastructure](https://leerob.io/blog/rust) 这篇文章讲述了 Rust 正在 JS 基建圈流行的事实:[Webpack](https://github.com/webpack/webpack)、[Babel](https://github.com/babel/babel)、[Terser](https://github.com/terser/terser)、[Prettier](https://github.com/prettier/prettier)、[ESLint](https://github.com/eslint/eslint) 这些前些年才流行起来的工具都已有了 Rust 替代方案,且性能有着 10~100 倍的提升。
前端基建的迭代浪潮从未停歇,当上面这些工具给 Gulp、js-beautify、tslint 等工具盖上棺材盖时,基于 Rust 的新一代构建工具已经悄悄将棺材盖悬挂在 webpack、babel、prettier、terser、eslint 它们头上,不知道哪天就会盖上。
原文已经有了不错的 [中文翻译](https://mp.weixin.qq.com/s?__biz=MzkxNDIzNTg4MA==&mid=2247485792&idx=1&sn=682a4dee7ce4d3b47a81baf9ebd7a98a&chksm=c170c1e7f60748f17585d6bfca0cff6edbf71bab95f0a4a1ea0bcf2d43c16d1722666d9fadc1&token=1766743281&lang=zh_CN#rd),值得一提的是,原文一些英文名词对应着特定中文解释,记录如下:
- low-level programming~~低级编程~~ 底层编程。
- ergonomics~~人体工程学~~ 人机工程学。
- opinionated:~~自以为是,固执的~~ 开箱即用的。
- critical adoption~~批判性采用~~ 技术选型临界点。
## 精读
本文不会介绍 Rust 如何使用,而会重点介绍原文提到的 Rust 工具链的一些基本用法,如果你感兴趣,可以立刻替换现有的工具库!
### swc
[swc](https://swc.rs/) 是基于 Rust 开发的一系列编译、打包、压缩等工具,并且被广泛应用于更多更上层的 JS 基建,大大推动了 Rust 在 JS 基建的影响力,所以要第一个介绍。
swc 提供了一系列原子能力,涵盖构建与运行时:
#### @swc/cli
`@swc/cli` 可以同时构建 js 与 ts 文件:
```typescript
const a = 1
```
```bash
npm i -D @swc/cli
npx swc ./main.ts
# output:
# Successfully compiled 1 file with swc.
# var a = 1;
```
具体功能与 babel 类似,都可以让浏览器支持先进语法或者 ts,只是 `@swc/cli` 比 babel 快了至少 20 倍。可以通过 `.swcrc` 文件做 [自定义配置](https://swc.rs/docs/configuration/swcrc)。
#### @swc/core
你可以利用 `@swc/core` 制作更上层的构建工具,所以它是 `@swc/cli` 的开发者调用版本。基本 API 来自官网开发者文档:
```typescript
const swc = require("@swc/core");
swc
.transform("source code", {
// Some options cannot be specified in .swcrc
filename: "input.js",
sourceMaps: true,
// Input files are treated as module by default.
isModule: false,
// All options below can be configured via .swcrc
jsc: {
parser: {
syntax: "ecmascript",
},
transform: {},
},
})
.then((output) => {
output.code; // transformed code
output.map; // source map (in string)
});
```
其实就是把 cli 调用改成了 node 调用。
#### @swc/wasm-web
`@swc/wasm-web` 可以在浏览器运行时调用 wasm 版的 swc,以得到更好的性能。下面是官方的例子:
```typescript
import { useEffect, useState } from "react";
import initSwc, { transformSync } from "@swc/wasm-web";
export default function App() {
const [initialized, setInitialized] = useState(false);
useEffect(() => {
async function importAndRunSwcOnMount() {
await initSwc();
setInitialized(true);
}
importAndRunSwcOnMount();
}, []);
function compile() {
if (!initialized) {
return;
}
const result = transformSync(`console.log('hello')`, {});
console.log(result);
}
return (
<div className="App">
<button onClick={compile}>Compile</button>
</div>
);
}
```
这个例子可以在浏览器运行时做类似 babel 的事情,无论是低代码平台还是在线 coding 平台都可以用它做运行时编译。
#### @swc/jest
`@swc/jest` 提供了 Rust 版本的 jest 实现,让 jest 跑得更快。使用方式也很简单,首先安装:
```bash
npm i @swc/jest
```
然后在 `jest.config.js` 配置文件中,将 ts 文件 compile 指向 `@swc/jest` 即可:
```javascript
module.exports = {
transform: {
"^.+\\.(t|j)sx?$": ["@swc/jest"],
},
};
```
#### swc-loader
`swc-loader` 是针对 webpack 的 loader 插件,代替 `babel-loader`
```javascript
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules)/,
use: {
// `.swcrc` can be used to configure swc
loader: "swc-loader"
}
}
];
}
```
#### swcpack
增强了多文件 bundle 成一个文件的功能,基本可以认为是 swc 版本的 webpack,当然性能也会比 `swc-loader` 方案有进一步提升。
截至目前,该功能还在测试阶段,只要安装了 `@swc/cli` 就可使用,通过创建 `spack.config.js` 后执行 `npx spack` 即可运行,和 webpack 的使用方式一样。
### Deno
[Deno](https://deno.land/) 的 linter、code formatter、文档生成器采用 swc 构建,因此也算属于 Rust 阵营。
Deno 是一种新的 js/ts 运行时,所以我们总喜欢与 node 进行类比。[quickjs](https://bellard.org/quickjs/) 也一样,这三个都是一种对 js 语言的运行器,作为开发者,需求永远是更好的性能、兼容性与生态,三者几乎缺一不可,所以当下虽然不能完全代替 Nodejs,但作为高性能替代方案是很香的,可以基于他们做一些跨端跨平台的解析器,比如 [kraken](https://github.com/openkraken/kraken) 就是基于 quickjs + flutter 实现的一种高性能 web 渲染引擎,是 web 浏览器的替代方案,作为一种跨端方案。
### esbuild
[esbuild](https://esbuild.github.io/) 是较早被广泛使用的新一代 JS 基建,是 JS 打包与压缩工具。虽然采用 Go 编写,但性能与 Rust 不相上下,可以与 Rust 风潮放在一起看。
esbuild 目前有两个功能:编译和压缩,理论上分别可代替 babel 与 terser。
编译功能的基本用法:
```js
require('esbuild').transformSync('let x: number = 1', {
loader: 'ts',
})
// 'let x = 1;\n'
```
压缩功能的基本用法:
```js
require('esbuild').transformSync('fn = obj => { return obj.x }', {
minify: true,
})
// 'fn=n=>n.x;\n'
```
压缩功能比较稳定,适合用在生产环境,而编译功能要考虑兼容 webpack 的地方太多,在成熟稳定后才考虑能在生产环境使用,目前其实已经有不少新项目已经在生产环境使用 esbuild 的编译功能了。
编译功能与 `@swc` 类似,但因为 Rust 支持编译到 wasm,所以 `@swc` 提供了 web 运行时编译能力,而 esbuild 目前还没有看到这种特性。
### Rome
[Rome](https://rome.tools/blog/2020/08/08/introducing-rome) 是 Babel 作者做的基于 Nodejs 的前端基建全家桶,包含但不限于 Babel, ESLint, webpack, Prettier, Jest。目前 [计划使用 Rust 重构](https://rome.tools/blog/2021/09/21/rome-will-be-rewritten-in-rust),虽然还没有实现,但我们姑且可以把 Rome 当作 Rust 的一员。
`rome` 是个全家桶 API,所以你只需要 `yarn add rome` 就完成了所有环境准备工作。
- `rome bundle` 打包项目。
- `rome compile` 编译单个文件。
- `rome develop` 调试项目。
- `rome parse` 解析文件抽象语法树。
- `rome analyzeDependencies` 分析依赖。
Rome 还将文件格式化与 Lint 合并为了 `rome check` 命令,并提供了[友好 UI 终端提示](https://rome.tools/#command-usage)。
其实我并不太看好 Rome,因为它负担太重了,测试、编译、Lint、格式化、压缩、打包的琐碎事情太多,把每一块交给社区可能会做得更好,这不现在还在重构中,牵一发而动全身。
### NAPI-RS
[NAPI-RS](https://napi.rs/) 提供了高性能的 Rust 到 Node 的衔接层,可以将 Rust 代码编译后成为 Node 可调用文件。下面是官网的例子:
```rust
#[js_function(1)]
fn fibonacci(ctx: CallContext) -> Result<JsNumber> {
let n = ctx.get::<JsNumber>(0)?.try_into()?;
ctx.env.create_int64(fibonacci_native(n))
}
```
上面写了一个斐波那契数列函数,直接调用了 `fibonacci_native` 函数实现。为了让这个方法被 Node 调用,首先安装 CLI:`npm i @napi-rs/cli`
由于环境比较麻烦,因此需要利用这个脚手架初始化一个工作台,我们在里面写 Rust,然后再利用固定的脚本发布 npm 包。执行 `napi new` 创建一个项目,我们发现入口文件肯定是个 js,毕竟要被 node 引用,大概长这样(我创建了一个 `myLib` 包):
```js
const { loadBinding } = require('@node-rs/helper')
/**
* __dirname means load native addon from current dir
* 'myLib' is the name of native addon
* the second arguments was decided by `napi.name` field in `package.json`
* the third arguments was decided by `name` field in `package.json`
* `loadBinding` helper will load `myLib.[PLATFORM].node` from `__dirname` first
* If failed to load addon, it will fallback to load from `myLib-[PLATFORM]`
*/
module.exports = loadBinding(__dirname, 'myLib', 'myLib')
```
所以 loadBinding 才是入口,同时项目文件夹下存在三个系统环境包,分别供不同系统环境调用:
- `@cool/core-darwin-x64` macOS x64 平台。
- `@cool/core-win32-x64` Windows x64 平台。
- `@cool/core-linux-arm64-gnu` Linux aarch64 平台。
`@node-rs/helper` 这个包的作用是引导 node 执行预编译的二进制文件,`loadBinding` 函数会尝试加载当前平台识别的二进制包。
`src/lib.rs` 的代码改成上面斐波那契数列的代码后,执行 `npm run build` 编译。注意在编译前需要安装 rust 开发环境,只要一行脚本即可安装,具体看 [rustup.rs](https://rustup.rs/)。然后把当前项目整体当作 node 包发布即可。
发布后,就可以在 node 代码中引用啦:
```javascript
import { fibonacci } from 'myLib'
function hello() {
let result = fibonacci(10000)
console.log(result)
return result
}
```
NAPI-RS 作为 Rust 与 Node 的桥梁,很好的解决了 Rust 渐进式替换现有 JS 工具链的问题。
### Rust + WebAssembly
[Rust + WebAssembly](https://www.rust-lang.org/what/wasm) 说明 Rust 具备编译到 wasm 的能力,虽然编译后代码性能会变得稍慢,但还是比 js 快很多,同时由于 wasm 的可移植性,让 Rust 也变得可移植了。
其实 Rust 支持编译到 WebAssembly 也不奇怪,因为本来 WebAssembly 的定位之一就是作为其他语言的目标编译产物,然后它本身支持跨平台,这样它就很好的完成了传播的使命。
WebAssembly 是一个基于栈的虚拟机 ([stack machine](https://webassembly.github.io/spec/core/exec/index.html)),所以跨平台能力一流。
想要将 Rust 编译为 wasm,除了安装 Rust 开发环境外,还要安装 [wasm-pack](https://rustwasm.github.io/wasm-pack/installer/)。
安装后编译只需执行 `wasm-pack build` 即可。更多用法可以查看 [API 文档](https://rustwasm.github.io/wasm-pack/book/commands/build.html)。
### dprint
[dprint](https://github.com/dprint/dprint) 是用 rust 编写的 js/ts 格式化工具,并提供了 [dprint-node](https://github.com/devongovett/dprint-node) 版本,可以直接作为 node 包,通过 npm 安装使用,从 [源码](https://github.com/devongovett/dprint-node/blob/main/src/lib.rs) 可以看到,使用 [NAPI-RS](https://napi.rs/) 实现。
`dprint-node` 可以直接在 Node 中使用:
```js
const dprint = require('dprint-node');
dprint.format(filePath, code, options);
```
[参数文档](https://dprint.dev/plugins/typescript/config/)。
### Parcel
[Parcel](https://parceljs.org/) 严格来说算是上一代 JS 基建,它出现在 Webpack 之后,Rust 风潮之前。不过由于它已经[采用 SWC 重写](https://github.com/parcel-bundler/parcel/pull/6230),所以姑且算是跟上了时髦。
## 总结
前端全家桶已经有了一整套 Rust 实现,只是对于存量项目的编译准确性需要大量验证,我们还需要时间等待这些库的成熟度。
但毫无疑问的是,Rust 语言对 JS 基建支持已经较为完备了,剩下的只是工具层逻辑覆盖率的问题,都可以随时间而解决。而用 Rust 语言重写后的逻辑带来的巨幅性能提升将为社区注入巨大活力,就像原文说的,前端社区可以为了巨大性能提升而引入 Rust 语言,即便这可能导致为社区贡献门槛的提高。
> 讨论地址是:[精读《Rust 是 JS 基建的未来》· Issue #371 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/371)
**如果你想参与讨论,请 [点击这里](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,108 @@
[Inside look at modern web browser](https://developers.google.com/web/updates/2018/09/inside-browser-part1) 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第一篇。
虽然本文写于 2018 年,但如今依然值得学习,因为浏览器实现非常复杂,从细节开始学习很容易迷失方向,缺乏整体感,而这篇文章从宏观层面开始介绍,几乎没有涉及代码实现,全都是思路性的描述,非常适合培养对浏览器整体框架性思维。
原文有非常多形象的插图与动图,便于加深对知识的理解,所以也推荐直接阅读原文。
## 概述
文章先从 CPU、GPU、操作系统开始介绍,因为这些是浏览器运行的基座。
### CPU、GPU、操作系统、应用的关系
CPU 即中央处理器,可以处理几乎所有计算。以前的 CPU 是单核的,现在大部分笔记电脑都是多核的,专业服务器甚至有高达 100 多核的。CPU 计算能力很强,但只能一件件事处理,
GPU 一开始是为图像处理设计的,即主要处理像素点,所以拥有大量并行的处理简单事物的能力,非常适合用来做矩阵运算,而矩阵运算又是计算机图形学的基础,所以大量用在可视化领域。
CPU、GPU 都是计算机硬件,这些硬件各自都提供了一些接口供汇编语言调用;而操作系统则基于它们之上用 C 语言(如 linux)将硬件管理了起来,包括进程调度、内存分配、用户内核态切换等等;运行在操作系统之上的则是应用程序了,所以应用程序不直接和硬件打交道,而是通过操作系统间接操作硬件。
> 为什么应用程序不能直接操作硬件呢?这样做有巨大的安全隐患,因为硬件是没有任何抽象与安全措施的,这意味着理论上一个网页可以通过 js 程序,在你打开网页时直接访问你的任意内存地址,读取你的聊天记录,甚至读取历史输入的银行卡密码进行转账操作。
显然,浏览器作为一个应用程序,运行在操作系统之上。
### 进程与线程
为了让程序运行的更安全,操作系统创造了进程与线程的概念(linux 对进程与线程的实现是同一套),进程可以分配独立的内存空间,进程内可以创建多个线程进行工作,这些线程共享内存空间。
因为线程间共享内存空间,因此不需通信就能交流,但内存地址相互隔离的进程间也有通信需求,需通过 IPCInter Process Communication)进行通信。
进程之间相互独立,即一个进程挂了不会影响到其它进程,而在一个进程中可以创建一个新进程,并与之通信,所以浏览器就采用了这种策略,将 UI、网络、渲染、插件、存储等模块进程独立,并且任意挂掉后都可以被重新唤起。
### 浏览器架构
浏览器可以拆分为许多独立的模块,比如:
- 浏览器模块(Browser):负责整个浏览器内行为协调,调用各个模块。
- 网络模块(Network):负责网络 I/O。
- 存储模块(Storage):负责本地 I/O。
- 用户界面模块(UI):负责浏览器提供给用户的界面模块。
- GPU 模块:负责绘图。
- 渲染模块(Renderer):负责渲染网页。
- 设备模块(Device):负责与各种本地设备交互。
- 插件模块(Plugin):负责处理各类浏览器插件。
基于这些模块,浏览器有两种可用的架构设计,一种是少进程,一种是多进程。
少进程是指将这些模块放在一个或有限的几个进程里,也就是每个模块一个线程,这样做的好处是最大程度共享了内存空间,对设备要求较低,但问题是只要一个线程挂了都会导致整个浏览器挂掉,因此稳定性较差。
多进程是指为每个模块(尽量)开辟一个进程,模块间通过 IPC 通信,因此任何模块挂掉都不会影响其它模块,但坏处是内存占用较大,比如浏览器 js 解析与执行引擎 V8 就要在这套架构下拷贝多份实例运行在每个进程中。
### Chrome 多进程架构的优势
Chrome 尽量为每个 tab 单独创建一个进程,所以我们才能在某个 tab 未响应时,从容的关闭它,而其它 tab 不会受到影响。不仅是 tab 间,一个 tab 内的 iframe 间也会创建独立的进程,这样做是为了保护网站的安全性。
### 服务化 - 单/多进程弹性架构
Chrome 并不满足于采用一种架构,而是在不同环境下切换不同的架构。Chrome 将各功能模块化后,就可以自由决定当前将哪些模块放在一个进程中,将哪些模块启动独立进程,即可以在运行时决定采用哪套进程架构。
这样做的好处是,可以在资源受限的机器上开启单进程模式,以尽量节约内存开销,实际上在手机应用上就是这么做的;而在资源丰富、内核数量充足的机器上采用独立进程模式,虽然消耗了更多资源,但获得了更好的稳定性。
### Iframe 独占进程
[site-isolation](https://developers.google.com/web/updates/2018/07/site-isolation) 将同一个 tab 内不同 iframe 包裹在不同的进程内运行,以确保 iframe 间资源的独占性,以及安全性。该功能直到 2018.7 才更新,是因为背后有许多复杂的工作要处理,比如开发者工具的调试、网页的全局搜索功能,都不能因为进程的隔离而受到影响,Chrome 必须让每个进程单独响应这些操作,并最终聚合在一起,让用户感受不到进程间的阻隔。
## 精读
本文从浏览器如何基于操作系统提供的进程、线程概念构建自己的应用程序开始,从硬件、操作系统、软件的分层开始,介绍到浏览器是如何划分模块的,并且分配进程或线程给这些模块运行,这背后的思考非常有价值。
从宏观角度看,要设计一个安全稳定、高性能、具有拓展性的浏览器,首先要把各功能模块划分清楚,并定义好各模块的通信关系,在各业务场景下制定一套模块协作的流程。
### 浏览器的主从架构
类似应用程序的主从模式,浏览器的 Browser 模块可以看作主模块,它本身用于协调其它模块的运行,并维持其它各模块的正常工作,在其它模块失去响应时等待或重新唤起,或者在模块销毁时进行内存回收。
各从模块也分工明确,比如在浏览器敲击 URL 地址时,会先通过 UI 模块响应用户的输入,并判断输入是否为 URL 地址,因为输入的可能是其它非法参数,或一些查询或设置命令。若输入的确实是 URL 地址,则校验通过后,会通知 Network 网络模块发送请求,UI 模块就不再关心请求是如何处理了。Network 模块也是相对独立的,仅处理请求的发送与接收,如果接收到的是 HTML 网页,则交给 Renderer 模块进行渲染。
有了这些相对独立且分工明确的模块划分后,将这些模块作为线程或进程管理就都不会影响它们的业务逻辑了,唯一影响的就是内存是否共享,以及某个模块 crash 后是否会影响到其它模块了,所以基于这个架构,判断设备类型,以采用单进程或多进程模式就变得简单了很多,且这个进程弹性架构本身也不需要入侵各模块业务逻辑,本身就是一套独立的机制。
浏览器作为非常复杂的应用程序,想要持续维护,就必须对每个功能点都进行合理的设计,让模块间高内聚、低耦合,这样才不至于让任何修改牵一发而动全身。
### tab、iframe 进程隔离
微前端的沙箱隔离方案也比较火,这里可以和浏览器 tab/iframe 隔离做个对比。
基于 js 运行时的沙箱方案大多都因为吐槽 iframe 慢而诞生的,一般会基于 `with` 改变沙箱代码的上下文,修改访问的全局对象引用,但基于 js 原型链特征,为了阻断向原型链追溯到主应用代码,一般会采用 `proxy``with` mock 的变量进行访问阻断。
还有一些方案利用创建空 iframe 获取到 document 变量传递给沙箱,一定程度做到了访问隔离,且对 document 添加的监听会随 iframe 销毁而销毁,便于控制。
还有一些更加彻底的尝试,将 js 代码扔到 web worker 运行,并通过 mock 模拟了 worker 运行时缺失的 dom API。
对比这些方案可以发现,只有最后 worker 的方案是最彻底的,因为浏览器创建的 worker 进程是完全资源隔离的,想要和浏览器主线程通信只能利用 `postMessage`,虽然有一些基于 ArrayBuffer 的内存共享方案,但因为支持的数据类型具有针对性,也不会存在安全问题。
回到浏览器开发者的视角,为什么 iframe 隔离要花费九牛二虎之力拆分多进程,最后再费很大功夫拼接回来,还原出一个相对无缝的体验?浏览器厂商其实完全可以利用上面提到的 js 运行时能力,对 API 语法进行改造,创建一个逻辑上的沙盒环境。
我认为本质原因是浏览器要实现的沙盒必须是进程层面的,也就是对内存访问权限的绝对隔离,因为逻辑层面的隔离可能随着各浏览器厂商实现差异,或 API 本身存在的逻辑漏洞而导致越权情况的出现,所以如果需要构造一个完全安全的沙盒,最好利用浏览器提供的 API 创建新的进程处理沙盒代码。
## 总结
本文介绍了浏览器是如何基于操作系统做宏观架构设计的,主要就说了一件事,即对进程,线程模型的弹性使用。同时在 tab、iframe 的设计中也要考虑到安全性要求,在必要的时候采用进程,在浏览器自身模块间因为没有安全性问题,所以可对进程模型进行灵活切换。
> 讨论地址是:[精读《深入了解现代浏览器一》· Issue #374 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/374)
**如果你想参与讨论,请 [点击这里](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,77 @@
[Inside look at modern web browser](https://developers.google.com/web/updates/2018/09/inside-browser-part2) 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第二篇。
## 概述
本篇重点介绍了 **浏览器路由跳转后发生了什么**,下一篇会介绍浏览器的渲染进程是如何渲染网页的,环环相扣。
在上一篇介绍了,browser process 包含 UI thread、network thread 和 storage thread,当我们在浏览器菜单栏输入网址并敲击回车时,这套动作均由 browser process 的 UI thread 响应。
接下来,按照几种不同的路由跳转场景,分别介绍了内部流程。
### 普通的跳转
第一步,UI thread 响应输入,并判断是否为一个合法的网址,当然输入的也可能是个搜索协议,这就会导致分发到另外的服务处理。
第二步,如果第一步输入的是合法网址,则 UI thread 会通知 network thread 获取网页内容,network thread 会寻找合适的协议处理网络请求,一般会通过 [DNS 协议](https://en.wikipedia.org/wiki/Domain_Name_System) 寻址,通过 [TLS 协议](https://en.wikipedia.org/wiki/Transport_Layer_Security) 建立安全链接。如果服务器返回了比如 301 重定向信息,network thread 会通知 UI thread 这个信息,再启动一遍第二步。
第三步,读取响应内容,在这一步 network thread 会首先读取首部一些字节,即我们常说的响应头,其中包含 [Content-Type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) 告知返回内容是什么。如果返回内容是 HTML,则 network thread 会将数据传送给 renderer process。这一步还会校验安全性,比如 [CORB](https://www.chromium.org/Home/chromium-security/corb-for-developers) 或 [cross-site](https://en.wikipedia.org/wiki/Cross-site_scripting) 问题。
第四步,寻找 renderer process。一旦所有检查都完成,network thread 会通知 UI thread 已经准备好跳转了(注意此时并没有加载完所有数据,第三步只是检查了首字节),UI thread 会通知 renderer process 进行渲染。为了提升性能,UI thread 在通知 network thread 的同时就会实例化一个 renderer process 等着,一旦 network thread 完毕后就可以立即进入渲染阶段,如果检查失败则丢弃提前实例化的 renderer process。
第五步,确认导航。第四步后,browser process 通过 IPC 向 renderer process 传送 stream[精读《web streams》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/214.%E7%B2%BE%E8%AF%BB%E3%80%8Aweb%20streams%E3%80%8B.md))数据。此时导航会被确认,浏览器的各个状态(比如导航状态、前进后退历史)将会被修改,同时为了方便 tab 关闭后快速恢复,会话记录会被存储在硬盘。
额外步骤,加载完成。当 renderer process 加载完成后(具体做了什么下一篇会说明),会通知 browser process `onLoad` 事件,此时浏览器完成最终加载完毕状态,loading 圆圈也会消失,各类 onLoad 的回调触发。注意此时 js 可能会继续加载远程资源,但这都是加载状态完成后的事了。
### 跳转到别的网站
当你准备跳转到别的网站时,在执行普通跳转流程前,还会响应 [beforeunload](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) 事件,这个事件注册在 renderer process,所以 browser process 需要检查 renderer process 是否注册了这个响应。注册 `beforeunload` 无论如何都会拖慢关闭 tab 的速度,所以如无必要请勿注册。
如果跳转是 js 发出的,那么执行跳转就由 renderer process 触发,browser process 来执行,后续流程就是普通的跳转流程。要注意的是,当执行跳转时,会触发原网站 `unload` 等事件([网页生命周期](https://developers.google.com/web/updates/2018/07/page-lifecycle-api#overview_of_page_lifecycle_states_and_events)),所以这个由旧的 renderer process 响应,而新网站会创建一个新的 renderer process 处理,当旧网页全部关闭时,才会销毁旧的 renderer process。
也就是说,即便只有一个 tab,在跳转时,也可能会在短时间内存在多个 renderer process。
### Service Worker
[Service Worker](https://developers.google.com/web/fundamentals/primers/service-workers) 可以在页面加载前执行一些逻辑,甚至改变网页内容,但浏览器仍然把 Service Worker 实现在了 renderer process 中。
当 Service Worker 被注册后,会被丢到一个作用域中,当 UI thread 执行时会检查这个作用域是否注册了 Service Worker,如果有,则 network thread 会创建一个 renderer process 执行 Service Worker(因为是 js 代码)。然后网络响应会被 Service Worker 接管。
但这样会慢一步,所以 UI thread 往往会在注册 Service Worker 的同时告诉 network thread 发送请求,这就是 [Navigation Preload](https://developers.google.com/web/updates/2017/02/navigation-preload) 机制。
本文介绍了网页跳转时发生的步骤,涉及 browser process、UI thread、network thread、renderer process 的协同。
## 精读
也许你会有疑问,为什么是 renderer process 而不是 renderer thread?因为相比 process(进程)相比 thread(线程),之间数据是被操作系统隔离的,为了网页间无法相互读取数据(mysite.com 读取你 baidu.com 正在输入的账号密码),浏览器必须为每个 tab 创建一个独立的进程,甚至每个 iframe 都必须是独立进程。
读完第二篇,应该能更深切的感受到模块间合理分工的重要性。
UI thread 处理浏览器 UI 的展现与用户交互,比如当前加载的状态变化,历史前进后退,浏览器地址栏的输入、校验与监听按下 Enter 等事件,但不会涉及诸如发送请求、解析网页内容、渲染等内容。
network thread 也仅处理网络相关的事情,它主要关心通信协议、安全协议,目标就是快速准确的找到网站服务器,并读取其内容。network thread 会读取内容头做一些前置判断,读取内容和 renderer process 做的事情是有一定重合的,但 network thread 读取内容头仅为了判断内容类型,以便交给渲染引擎还是下载管理器(比如一个 zip 文件),所以为了不让渲染引擎知道下载管理器的存在,读取内容头必须由 network thread 来做。
与 renderer process 的通信也是由 browser process 来做的,也就是 UI thread、network thread 一旦要创建或与 renderer process 通信,都会交由它们所在的 browser process 处理。
renderer process 仅处理渲染逻辑,它不关心是从哪来的,比如是网络请求过来的,还是 Service Worker 拦截后修改的,也不关心当前浏览器状态是什么,它只管按照约定的接口规范,在指定的节点抛出回调,而修改应用状态由其它关心的模块负责,比如 `onLoad` 回调触发后,browser process 处理浏览器的状态就是一个例子。
再比如 renderer process 里点击了一个新的跳转链接,这个事情发生在 renderer process,但会交给 browser process 处理,因为每个模块解耦的非常彻底,所以任何复杂工作都能找到一个能响应它的模块,而这个模块也只要处理这个复杂工作的一部分,其余部分交给其它模块就好了,这就是大型应用维护的秘诀。
所以在浏览器运行周期里,有着非常清晰的逻辑链路,这些模块必须事先规划设计好,很难想象这些模块分工是在开发中逐渐形成的。
最后提到加速优化,Chrome 惯用技巧就是,用资源换时间。即宁可浪费潜在资源,也要让事物尽可能的并发,这些从提前创建 renderer process、提前发起 network process 都能看出来。
## 总结
深入了解现代浏览器二介绍了网页跳转时发生的,browser process 与 renderer process 是如何协同的。
也许这篇文章可以帮助你回答 “聊聊在浏览器地址栏输入 www.baidu.com 并回车后发生了什么事儿吧!”
> 讨论地址是:[精读《深入了解现代浏览器二》· Issue #375 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/375)
**如果你想参与讨论,请 [点击这里](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,93 @@
[Inside look at modern web browser](https://developers.google.com/web/updates/2018/09/inside-browser-part3) 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第三篇。
## 概述
本篇宏观的介绍 renderer process 做了哪些事情。
浏览器 tab 内 html、css、javascript 内容基本上都由 renderer process 的主线程处理,除了一些 js 代码会放在 web worker 或 service worker 内,所以浏览器主线程核心工作就是解析 web 三剑客并生成可交互的用户界面。
### 解析阶段
首先 renderer process 主线程会解析 HTML 文本为 DOMDocument Object Model),直译为中文就是文档对象模型,所以首先要把文本结构化才能继续处理。不仅是浏览器,代码的解析也得首先经历 Parse 阶段。
对于 HTML 的 link、img、script 标签需要加载远程资源的,浏览器会调用 network thread 优先并行处理,但遇到 script 标签就必须停下来优先执行,因为 js 代码可能会改变任何 dom 对象,这可能导致浏览器要重新解析。所以如果你的代码没有修改 dom 的副作用,可以添加 async、defer 标签,或 JS 模块的方式使浏览器不必等待 js 的执行。
### 样式计算
只有 DOM 是不够的,style 标签申明的样式需要作用在 DOM 上,所以基于 DOM,浏览器要生成 CSSOM,这个 CSSOM 主要是基于 css 选择器(selector)确定作用节点的。
### 布局
有了 DOM、CSSOM 仍然不足以绘制网页,因为我们仅知道结构和样式,但不知道元素的位置,这就需要生成 LayoutTree 以描述布局的结构。
LayoutTree 和 DOM 结构很像了,但比如 `display: none` 的元素不会出现在 LayoutTree 上,所以 LayoutTree 仅考虑渲染结构,而 DOM 是一个综合描述结构,它不适合直接用来渲染。
原文特别提到,LayoutTree 有个很大的技术难点,即排版,Chrome 专门有一整个团队在攻克这个技术难题。为什么排版这么难?可以从这几个例子中体会冰山一角:盒模型间碰撞、字体撑开内容导致换行,引发更大区域的重新排版、一个盒模型撑开挤压另一个盒模型,但另一个盒模型大小变化后内容排版也随之变化,导致盒模型再次变化,这个变化又导致了外部其它盒模型的布局变化。
布局最难的地方在于,需要对所有奇奇怪怪的布局定式做一个尽量合理的处理,而很多时候布局定式间规则是相互冲突的。而且这还不考虑布局引擎的修改在数亿网页上引发未知 BUG 的风险。
### 绘图
有了 DOM、CSSOM、LayoutTree 就够了吗?还不行,还缺少最后一环 PaintRecord,这个指绘图记录,它会记录元素的层级关系,以决定元素绘制的顺序。因为 LayoutTree 仅决定了物理结构,但不决定元素的上下空间结构。
有了 DOM、CSSOM、LayoutTree、PaintRecord 之后,终于可以绘图了。然而当 HTML 变化时,重绘的代价是巨大的,因为上面任何一步的计算结果都依赖前面一步,HTML 改变时,需要对 DOM、CSSOM、LayoutTree、PaintRecord 进行重新计算。
大部分时候浏览器都可以在 16ms 内完成,使 FPS 保持在 60 左右,但当页面结构过于复杂,这些计算本身超过了 16ms,或其中遇到 js 代码的阻塞,都会导致用户感觉到卡顿。当然对于 js 卡顿问题可以通过 `requestAnimationFrame` 把逻辑运算分散在各帧空闲时进行,也可以独立到 web worker 里。
### 合成
绘图的步骤称为 rasterizing(光栅化)。在 Chrome 最早发布时,采用了一种较为简单的光栅化方案,即仅渲染可视区域内的像素点,当滚动后,再补充渲染当前滚动位置的像素点。这样做会导致渲染永远滞后于滚动。
现在一般采用较为成熟的合成技术(compositing),即将渲染内容分层绘制与渲染,这可以大大提升性能,并可通过 CSS 属性 `will-change` 手动申明为一个新层(不要滥用)。
浏览器会根据 LayoutTree 分析后得到 LayerTree(层树),并根据它逐层渲染。
合成层会将绘图内容切分为多个栅格并交由 GPU 渲染,因此性能会非常好。
## 精读
### 从渲染分层看性能优化
本篇提到了浏览器渲染的 5 个重要环节:解析、样式、布局、绘图、合成,是前端开发者日常工作中对浏览器体感最深的部分,也是优化最长发生在的部分。
其实从性能优化角度来看,解析环节可以被替代为 JS 环节,因为现代 JS 框架往往没有什么 HTML 模版内容要解析,几乎全是 JS 操作 DOM,所以可以看作 5 个新环节:JS、样式、布局、绘图、合成。
值得注意的是,几乎每层的计算都依赖上层的结果,但并不是每层都一定会重复计算,我们需要尤其注意以下几种情况:
1. 修改元素几何属性(位置、宽高等)会触发所有层的重新计算,因为这是一个非常重量级的修改。
2. 修改某个元素绘图属性(比如颜色和背景色),并不影响位置,则会跳过布局层。
3. 修改比如 transform 属性会跳过布局与绘图层,这看上去很不可思议。
对于第三点,由于 transform 的内容会提升到合成层并交由 GPU 渲染,因此并不会与浏览器主线程的布局、绘图放在一起处理,所以视觉上这个元素的确产生了位移,但它和修改 `left``top` 的位移在实现上却有本质的不同。
所以站在浏览器开发者的角度,可以轻松理解为什么这种优化不是奇技淫巧了,因为本身浏览器的实现就把布局、绘图与合成层的行为分离开了,不同的代码底层方案不同,性能肯定会不同。你可以通过 [csstriggers](https://csstriggers.com/) 查看不同 css 属性会引发哪些层的重计算。
当然作为开发者还是可以吐槽,为什么浏览器不能 “自动把 `left` `top``transform` 的实现细节屏蔽,并自动进行合理的分层”,然而如果浏览器厂商做不到这一点,开发者还是主动去了解实现原理吧。
### 隐式合成层、层爆炸、层自动合并
除了 `transform``will-change` 属性外,还有很多种情况元素会提升到合成层,比如 `video``canvas``iframe`,或 `fixed` 元素,但这些都有明确的规则,所以属于显示合成。
而隐式合成是指元素没有被特别标记,但也被提升到合成层的情况,这种情况常见发生在 `z-index` 元素产生重叠时,下方的元素显示申明提升到合成层,则浏览器为了保证 `z-index` 覆盖关系,就要隐式把上方的元素提升到合成层。
层爆炸是指隐式合成的原因,当 css 出现一些复杂行为时(比如轨迹动画),浏览器无法实时捕捉哪些元素位于当前元素上方,所以只好把所有元素都提升到合成层,当合成层数量过多,主线程与 GPU 的通信可能会成为瓶颈,反而影响性能。
浏览器也会支持层自动合并,比如隐式提升到合成层时,多个元素会自动合并在一个合成层里。但这种方式也并不总是靠谱,自动处理毕竟猜不到开发者的意图,所以最好的优化方式是开发者主动干预。
我们只要注意将所有显示提升到合成层的元素放在 `z-index` 的上方,这样浏览器就有了判断依据,不用再担惊受怕会不会这个元素突然移动到某个元素的位置,导致压住了那个元素,于是又不得不把这个元素给隐式提升到合成层以保证它们之间顺序的正确性,因为这个元素本来就位于其它元素的最上方。
## 总结
读完这篇文章,希望你能根据浏览器在渲染进程的实现原理,总结出更多代码级别的性能优化经验。
最后想要吐槽的是,浏览器规范由于是逐步迭代的,因此看似都在描述位置的 css 属性其实背后实现原理是不同的,虽然这个规则体现在 W3C 规范上,但如果仅从属性名是很难看出来端倪的,因此想要做极致性能优化就必须了解浏览器实现原理。
> 讨论地址是:[精读《深入了解现代浏览器三》· Issue #379 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/379)
**如果你想参与讨论,请 [点击这里](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,149 @@
[Inside look at modern web browser](https://developers.google.com/web/updates/2018/09/inside-browser-part4) 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第四篇。
## 概述
前几章介绍了浏览器的基础进程、线程以及它们之间协同的关系,并重点说到了渲染进程是如何处理页面绘制的,那么最后一章也就深入到了浏览器是如何处理页面中事件的。
全篇站在浏览器实现的视角思考问题,非常有趣。
### 输入进入合成器
这是第一小节的标题。乍一看可能不明白在说什么,但这句话就是本文的核心知识点。为了更好的理解这句话,先要解释输入与合成器是什么:
- 输入:不仅包括输入框的输入,其实所有用户操作在浏览器眼中都是输入,比如滚动、点击、鼠标移动等等。
- 合成器:第三节说过的,渲染的最后一步,这一步在 GPU 进行光栅化绘图,如果与浏览器主线程解耦的化效率会非常高。
所以输入进入合成器的意思是指,在浏览器实际运行的环境中,合成器不得不响应输入,这可能会导致合成器本身渲染被阻塞,导致页面卡顿。
### "non-fast" 滚动区域
由于 js 代码可以绑定事件监听,而且事件监听中存在一种 `preventDefault()` 的 API 可以阻止事件的原生效果比如滚动,所以在一个页面中,浏览器会对所有创建了此监听的区块标记为 "non-fast" 滚动区域。
注意,只要创建了 `onwheel` 事件监听就会标记,而不是说调用了 `preventDefault()` 才会标记,因为浏览器不可能知道业务什么时候调用,所以只能一刀切。
为什么这种区域被称为 "non-fast"?因为在这个区域触发事件时,合成器必须与渲染进程通信,让渲染进程执行 js 事件监听代码并获得用户指令,比如是否调用了 `preventDefault()` 来阻止滚动?如果阻止了就终止滚动,如果没有阻止才会继续滚动,如果最终结果是不阻止,但这个等待时间消耗是巨大的,在低性能设备比如手机上,滚动延迟甚至有 10~100ms。
然而这并不是设备性能差导致的,因为滚动是在合成器发生的,如果它可以不与渲染进程通信,那么即便是 500 元的安卓机也可以流畅的滚动。
### 注意事件委托
更有意思的是,浏览器支持一种事件委托的 API,它可以将事件委托到其父节点一并监听。
这本是一个非常方便的 API,但对浏览器实现可能是一个灾难:
```js
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault();
}
});
```
如果浏览器解析到上面的代码,只能用无语来形容。因为这意味着必须对全页面都进行 "non-fast" 标记,因为代码委托的是整个 document!这会导致滚动非常慢,因为在页面任何地方滚动都要发生一次合成器与渲染进程的通信。
所以最好的办法就是不要写这种监听。但还有一种方案是,告诉浏览器你不会 `preventDefault()`,这是因为 chrome 通过对应用源码统计后发现,大约 80% 的事件监听没有 `preventDefault()`,而仅仅是做别的事情,所以合成器应该可以与渲染进程的事件处理并行进行,这样既不卡顿,逻辑也不会丢失。所以添加了一种 `passive: true` 的标记,标识当前事件可以并行处理:
```js
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
}, {passive: true});
```
这样就不会卡顿了,但 `preventDefault()` 也会失效。
### 检查事件是否可取消
对于 `passive: true` 的情况,事件就实际上变得不可取消了,所以我们最好在代码里做一层判断:
```js
document.body.addEventListener('touchstart', event => {
if (event.cancelable && event.target === area) {
event.preventDefault()
}
}, {passive: true});
```
然而这仅仅是阻止执行没有意义的 `preventDefault()`,并不能阻止滚动。这种情况下,最好的办法是通过 css 申明来阻止横向移动,因为这个判断不会发生在渲染进程,所以不会导致合成器与渲染进程的通信:
```css
#area {
touch-action: none;
}
```
### 事件合并
由于事件触发频率可能比浏览器帧率还要高(1 秒 120 次),如果浏览器坚持对每个事件都进行响应,而一次事件都必须在 js 里响应一次的话,会导致大量事件阻塞,因为当 FPS 为 60 时,一秒也仅能执行 60 次事件响应,所以事件积压是无法避免的。
为了解决这个问题,浏览器在针对可能导致积压的事件,比如滚动事件时,将多个事件合并到一次 js 中,仅保留最终状态。
如果不希望丢掉事件中间过程,可以使用 `getCoalescedEvents` 从合并事件中找回每一步事件的状态:
```js
window.addEventListener('pointermove', event => {
const events = event.getCoalescedEvents();
for (let event of events) {
const x = event.pageX;
const y = event.pageY;
// draw a line using x and y coordinates.
}
});
```
## 精读
只要我们认识到事件监听必须运行在渲染进程,而现代浏览器许多高性能 “渲染” 其实都在合成层采用 GPU 做,所以看上去方便的事件监听肯定会拖慢页面流畅度。
但就这件事在 React 17 中有过一次讨论 [Touch/Wheel Event Passiveness in React 17](https://github.com/facebook/react/issues/19651)(实际上在即将到来的 18 该问题还在讨论中 [React 18 not passive wheel / touch event listeners support](https://github.com/facebook/react/issues/22794)),因为 React 可以直接在元素上监听 Touch、Wheel 事件,但其实框架采用了委托的方式在 document(后在 app 根节点)统一监听,这就导致了用户根本无从决定事件是否为 `passive`,如果框架默认 `passive`,会导致 `preventDefault()` 失效,否则性能得不到优化。
就结论而言,React 目前还是对几个受影响的事件 `touchstart` `touchmove` `wheel` 采用 `passive` 模式,即:
```tsx
const Test = () => (
<div
// 没有用的,无法阻止滚动,因为委托处默认 passive
onWheel={event => event.preventDefault()}
>
...
</div>
)
```
虽然结论如此而且对性能友好,但并不是一个让所有人都能满意的方案,我们看看当时 Dan 是如何思考,并给了哪些解决方案的。
首先背景是,React 16 事件委托绑定在 document 上,React 17 事件委托绑定在 App 根节点上,而根据 chrome 的优化,绑定在 document 的事件委托默认是 `passive` 的,而其它节点的不会,因此对 React 17 来说,如果什么都不做,仅改变绑定节点位置,就会存在一个 Break Change。
1. 第一种方案是坚持 Chrome 性能优化的精神,委托时依然 pasive 处理。这样处理至少和 React 16 一样,`preventDefault()` 都是失效的,虽然不正确,但至少不是 BreakChange。
2. 第二种方案即什么都不做,这导致原本默认 `passive` 的因为绑定到非 document 节点上而 `non-passive` 了,这样做不仅有性能问题,而且 API 会存在 BreackChange,虽然这种做法更 “原生”。
3. touch/wheel 不再采用委托,意味着浏览器可以有更少的 "non-fast" 区域,而 `preventDefault()` 也可以生效了。
最终选择了第一个方案,因为暂时不希望在 React API 层面出现行为不一致的 BreakChange。
然而 React 18 是一次 BreakChange 的时机,目前还没有进一步定论。
## 总结
从浏览器角度看待问题会让你具备上帝视角而不是开发者视角,你不会再觉得一些奇奇怪怪的优化逻辑是 Hack 了,因为你了解浏览器背后是如何理解与实现的。
不过我们也会看到一些和实现强绑定的无奈,在前端开发框架实现时造成了不可避免的困扰。毕竟作为一个不了解浏览器实现的开发者,自然会认为 `preventDefault()` 绑定在滚动事件时,一定可以阻止默认滚动行为呀,但为什么因为:
- 浏览器分为合成层和渲染进程,通信成本较高导致滚动事件监听会引发滚动卡顿。
- 为了避免通信,浏览器默认为 document 绑定开启 `passive` 策略减少 "non-fast" 区域。
- 开启了 `passive` 的事件监听 `preventDefault()` 会失效,因为这层实现在 js 里而不是 GPU。
- React16 采用事件代理,把元素 `onWheel` 代理到 document 节点而非当前节点。
- React17 将 document 节点绑定下移到了 App 根节点,因此浏览器优化后的 `passive` 失效了。
- React 为了保持 API 不发生 BreakChange,因此将 App 根节点绑定的事件委托默认补上了 `passive`,使其表现与绑定在 document 一样。
总之就是 React 与浏览器实现背后的纠纷,导致滚动行为阻止失效,而这个结果链条传导到了开发者身上,而且有明显感知。但了解背后原因后,你应该能理解一下 React 团队的痛苦吧,因为已有 API 确实没有办法描述是否 `passive` 这个行为,所以这是个暂时无法解决的问题。
> 讨论地址是:[精读《深入了解现代浏览器四》· Issue #381 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/381)
**如果你想参与讨论,请 [点击这里](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,657 @@
immutablejs、immer 等库已经让 js 具备了 immutable 编程的可能性,但还存在一些无解的问题,即 “怎么保证一个对象真的不可变”。
如果不是拍胸脯担保,现在还真没别的办法。或许你觉得 `frozen` 是个 good idea,但它内部仍然可以增加非 `frozen` 的 key。
另一个问题是,当我们 debug 调试应用数据的时候,看到状态发生 `[]` -> `[]` 变化时,无论在控制台、断点、redux devtools 还是 `.toString()` 都看不出来引用有没有变化,除非把变量值分别拿到进行 `===` 运行时判断。但引用变与没变可是一个大问题,它甚至能决定业务逻辑的正确与否。
但现阶段我们没有任何处理办法,如果不能接受完全使用 Immutablejs 定义对象,就只能摆胸脯保证自己的变更一定是 immutable 的,这就是 js 不可变编程被许多聪明人吐槽的原因,觉得在不支持 immutable 的编程语言下强行应用不可变思维是一种很别扭的事。
[proposal-record-tuple](https://github.com/tc39/proposal-record-tuple) 解决的就是这个问题,它让 js 原生支持了 **不可变数据类型**(高亮、加粗)。
## 概述 & 精读
JS 有 7 种原始类型:string, number, bigint, boolean, undefined, symbol, null. 而 Records & Tuples 提案一下就增加了三种原始类型!这三种原始类型完全是为 immutable 编程环境服务的,也就是说,可以让 js 开出一条原生 immutable 赛道。
这三种原始类型分别是 Record, Tuple, Box
- Record: 类对象结构的深度不可变基础类型,如 `#{ x: 1, y: 2 }`
- Tuple: 类数组结构的深度不可变基础类型,如 `#[1, 2, 3, 4]`
- Box: 可以定义在上面两个类型中,存储对象,如 `#{ prop: Box(object) }`
核心思想可以总结为一句话:因为这三个类型为基础类型,所以在比较时采用值对比(而非引用对比),因此 `#{ x: 1, y: 2} === #{ x: 1, y: 2 }`。这真的解决了大问题!如果你还不了解 js 不支持 immutable 之痛,请不要跳过下一节。
### js 不支持 immutable 之痛
虽然很多人都喜欢 mvvm 的 reactive 特征(包括我也写了不少 mvvm 轮子和框架),但不可变数据永远是开发大型应用最好的思想,它可以非常可靠的保障应用数据的可预测性,同时不需要牺牲性能与内存,它使用起来没有 mutable 模式方便,但它永远不会出现预料外的情况,这对打造稳定的复杂应用至关重要,甚至比便捷性更加重要。当然可测试也是个非常重要的点,这里不详细展开。
然而 js 并不原生支持 immutable,这非常令人头痛,也造成了许多困扰,下面我试图解释一下这个困扰。
如果你觉得非原始类型按照引用对比很棒,那你一定一眼能看出下面的结果是正确的:
```js
assert({ a: 1 } !== { a: 1 })
```
但如果是下面的情况呢?
```js
console.log(window.a) // { a: 1 }
console.log(window.b) // { a: 1 }
assert(window.a === window.b) // ???
```
**结果是不确定**,虽然这两个对象长得一样,但我们拿到的 scope 无法推断其是否来自同一个引用,如果来自于相同的引用,则断言通过,否则即便看上去值一样,也会 throw error。
更大的麻烦是,即便这两个对象长得完全不一样,我们也不敢轻易下结论:
```js
console.log(window.a) // { a: 1 }
// do some change..
console.log(window.b) // { b: 1 }
assert(window.a === window.b) // ???
```
因为 b 的值可能在中途被修改,但确实与 a 来自同一个引用,我们无法断定结果到底是什么。
另一个问题则是应用状态变更的扑朔迷离。试想我们开发了一个树形菜单,结构如下:
```json
{
"id": "1",
"label": "root",
"children": [{
"id": "2",
"label": "apple",
}, {
"id": "3",
"label": "orange",
}]
}
```
如果我们调用 `updateTreeNode('3', { id: '3', title: 'banana' })`,在 immutable 场景下我们仅更新 id 为 "1", "3" 组件的引用,而 id 为 "2" 的引用不变,那么这棵树节点 "2" 就不会重渲染,这是血统纯正的 immutable 思维逻辑。
但当我们保存下这个新状态后,要进行 “状态回放”,会发现其实应用状态进行了一次变更,整个描述 json 变成了:
```json
{
"id": "1",
"label": "root",
"children": [{
"id": "2",
"label": "apple",
}, {
"id": "3",
"label": "banana",
}]
}
```
但如果我们拷贝上面的文本,把应用状态直接设置为这个结果,会发现与 “应用回放按钮” 的效果不同,这时 id "2" 也重渲染了,因为它的引用变化了。
问题就是我们无法根据肉眼观察出引用是否变化了,即便两个结构一模一样,也无法保证引用是否相同,进而导致无法推断应用的行为是否一致。如果没有人为的代码质量管控,出现非预期的引用更新几乎是难以避免的。
这就是 Records & Tuples 提案要解决问题的背景,我们带着这个理解去看它的定义,就更好学习了。
### Records & Tuples 在用法上与对象、数组保持一致
Records & Tuples 提案说明,不可变数据结构除了定义时需要用 `#` 符号申明外,使用时与普通对象、数组无异。
Record 用法与普通 object 几乎一样:
```js
const proposal = #{
id: 1234,
title: "Record & Tuple proposal",
contents: `...`,
// tuples are primitive types so you can put them in records:
keywords: #["ecma", "tc39", "proposal", "record", "tuple"],
};
// Accessing keys like you would with objects!
console.log(proposal.title); // Record & Tuple proposal
console.log(proposal.keywords[1]); // tc39
// Spread like objects!
const proposal2 = #{
...proposal,
title: "Stage 2: Record & Tuple",
};
console.log(proposal2.title); // Stage 2: Record & Tuple
console.log(proposal2.keywords[1]); // tc39
// Object functions work on Records:
console.log(Object.keys(proposal)); // ["contents", "id", "keywords", "title"]
```
下面的例子说明,Records 与 object 在函数内处理时并没有什么不同,这个在 FAQ 里提到是一个非常重要的特性,可以让 immutable 完全融入现在的 js 生态:
```js
const ship1 = #{ x: 1, y: 2 };
// ship2 is an ordinary object:
const ship2 = { x: -1, y: 3 };
function move(start, deltaX, deltaY) {
// we always return a record after moving
return #{
x: start.x + deltaX,
y: start.y + deltaY,
};
}
const ship1Moved = move(ship1, 1, 0);
// passing an ordinary object to move() still works:
const ship2Moved = move(ship2, 3, -1);
console.log(ship1Moved === ship2Moved); // true
// ship1 and ship2 have the same coordinates after moving
```
Tuple 用法与普通数组几乎一样:
```js
const measures = #[42, 12, 67, "measure error: foo happened"];
// Accessing indices like you would with arrays!
console.log(measures[0]); // 42
console.log(measures[3]); // measure error: foo happened
// Slice and spread like arrays!
const correctedMeasures = #[
...measures.slice(0, measures.length - 1),
-1
];
console.log(correctedMeasures[0]); // 42
console.log(correctedMeasures[3]); // -1
// or use the .with() shorthand for the same result:
const correctedMeasures2 = measures.with(3, -1);
console.log(correctedMeasures2[0]); // 42
console.log(correctedMeasures2[3]); // -1
// Tuples support methods similar to Arrays
console.log(correctedMeasures2.map(x => x + 1)); // #[43, 13, 68, 0]
```
在函数内处理时,拿到一个数组或 Tuple 并没有什么需要特别注意的区别:
```js
const ship1 = #[1, 2];
// ship2 is an array:
const ship2 = [-1, 3];
function move(start, deltaX, deltaY) {
// we always return a tuple after moving
return #[
start[0] + deltaX,
start[1] + deltaY,
];
}
const ship1Moved = move(ship1, 1, 0);
// passing an array to move() still works:
const ship2Moved = move(ship2, 3, -1);
console.log(ship1Moved === ship2Moved); // true
// ship1 and ship2 have the same coordinates after moving
```
由于 Record 内不能定义普通对象(比如定义为 # 标记的不可变对象),如果非要使用普通对象,只能包裹在 Box 里,并且在获取值时需要调用 `.unbox()` 拆箱,并且就算修改了对象值,在 Record 或 Tuple 层面也不会认为发生了变化:
```js
const myObject = { x: 2 };
const record = #{
name: "rec",
data: Box(myObject)
};
console.log(record.data.unbox().x); // 2
// The box contents are classic mutable objects:
record.data.unbox().x = 3;
console.log(myObject.x); // 3
console.log(record === #{ name: "rec", data: Box(myObject) }); // true
```
另外不能在 Records & Tuples 内使用任何普通对象或 new 对象实例,除非已经用转化为了普通对象:
```js
const instance = new MyClass();
const constContainer = #{
instance: instance
};
// TypeError: Record literals may only contain primitives, Records and Tuples
const tuple = #[1, 2, 3];
tuple.map(x => new MyClass(x));
// TypeError: Callback to Tuple.prototype.map may only return primitives, Records or Tuples
// The following should work:
Array.from(tuple).map(x => new MyClass(x))
```
### 语法
Records & Tuples 内只能使用 Record、Tuple、Box
```js
#{}
#{ a: 1, b: 2 }
#{ a: 1, b: #[2, 3, #{ c: 4 }] }
#[]
#[1, 2]
#[1, 2, #{ a: 3 }]
```
不支持空数组项:
```js
const x = #[,]; // SyntaxError, holes are disallowed by syntax
```
为了防止引用追溯到上层,破坏不可变性质,不支持定义原型链:
```js
const x = #{ __proto__: foo }; // SyntaxError, __proto__ identifier prevented by syntax
const y = #{ ["__proto__"]: foo }; // valid, creates a record with a "__proto__" property.
```
也不能在里面定义方法:
```js
#{ method() { } } // SyntaxError
```
同时,一些破坏不可变稳定结构的特性也是非法的,比如 key 不可以是 Symbol
```js
const record = #{ [Symbol()]: #{} };
// TypeError: Record may only have string as keys
```
不能直接使用对象作为 value,除非用 Box 包裹:
```js
const obj = {};
const record = #{ prop: obj }; // TypeError: Record may only contain primitive values
const record2 = #{ prop: Box(obj) }; // ok
```
### 判等
判等是最核心的地方,Records & Tuples 提案要求 == 与 === 原生支持 immutable 判等,是 js 原生支持 immutable 的一个重要表现,所以其判等逻辑与普通的对象判等大相径庭:
首先看上去值相等,就真的相等,因为基础类型仅做值对比:
```js
assert(#{ a: 1 } === #{ a: 1 });
assert(#[1, 2] === #[1, 2]);
```
这与对象判等完全不同,而且把 Record 转换为对象后,判等就遵循对象的规则了:
```js
assert({ a: 1 } !== { a: 1 });
assert(Object(#{ a: 1 }) !== Object(#{ a: 1 }));
assert(Object(#[1, 2]) !== Object(#[1, 2]));
```
另外 Records 的判等与 key 的顺序无关,因为有个隐式 key 排序规则:
```js
assert(#{ a: 1, b: 2 } === #{ b: 2, a: 1 });
Object.keys(#{ a: 1, b: 2 }) // ["a", "b"]
Object.keys(#{ b: 2, a: 1 }) // ["a", "b"]
```
Box 是否相等取决于内部对象引用是否相等:
```js
const obj = {};
assert(Box(obj) === Box(obj));
assert(Box({}) !== Box({}));
```
对于 `+0` `-0` 之间,`NaN``NaN` 对比,都可以安全判定为相等,但 `Object.is` 因为是对普通对象的判断逻辑,所以会认为 `#{ a: -0 }` 不等于 `#{ a: +0 }`,因为认为 `-0` 不等于 `+0`,这里需要特别注意。另外 Records & Tulpes 也可以作为 Map、Set 的 key,并且按照值相等来查找:
```js
assert(#{ a: 1 } === #{ a: 1 });
assert(#[1] === #[1]);
assert(#{ a: -0 } === #{ a: +0 });
assert(#[-0] === #[+0]);
assert(#{ a: NaN } === #{ a: NaN });
assert(#[NaN] === #[NaN]);
assert(#{ a: -0 } == #{ a: +0 });
assert(#[-0] == #[+0]);
assert(#{ a: NaN } == #{ a: NaN });
assert(#[NaN] == #[NaN]);
assert(#[1] != #["1"]);
assert(!Object.is(#{ a: -0 }, #{ a: +0 }));
assert(!Object.is(#[-0], #[+0]));
assert(Object.is(#{ a: NaN }, #{ a: NaN }));
assert(Object.is(#[NaN], #[NaN]));
// Map keys are compared with the SameValueZero algorithm
assert(new Map().set(#{ a: 1 }, true).get(#{ a: 1 }));
assert(new Map().set(#[1], true).get(#[1]));
assert(new Map().set(#[-0], true).get(#[0]));
```
### 对象模型如何处理 Records & Tuples
对象模型是指 `Object` 模型,大部分情况下,所有能应用于普通对象的方法都可无缝应用于 Record,比如 `Object.key``in` 都可与处理普通对象无异:
```js
const keysArr = Object.keys(#{ a: 1, b: 2 }); // returns the array ["a", "b"]
assert(keysArr[0] === "a");
assert(keysArr[1] === "b");
assert(keysArr !== #["a", "b"]);
assert("a" in #{ a: 1, b: 2 });
```
值得一提的是如果 wrapper 了 `Object` 在 Record 或 Tuple,提案还准备了一套完备的实现方案,即 `Object(record)``Object(tuple)` 会冻结所有属性,并将原型链最高指向 `Tuple.prototype`,对于数组跨界访问也只能返回 undefined 而不是沿着原型链追溯。
### Records & Tuples 的标准库支持
对 Record 与 Tuple 进行原生数组或对象操作后,返回值也是 immutable 类型的:
```js
assert(Object.keys(#{ a: 1, b: 2 }) !== #["a", "b"]);
assert(#[1, 2, 3].map(x => x * 2), #[2, 4, 6]);
```
还可通过 `Record.fromEntries``Tuple.from` 方法把普通对象或数组转成 Record, Tuple
```js
const record = Record({ a: 1, b: 2, c: 3 });
const record2 = Record.fromEntries([#["a", 1], #["b", 2], #["c", 3]]); // note that an iterable will also work
const tuple = Tuple(...[1, 2, 3]);
const tuple2 = Tuple.from([1, 2, 3]); // note that an iterable will also work
assert(record === #{ a: 1, b: 2, c: 3 });
assert(tuple === #[1, 2, 3]);
Record.from({ a: {} }); // TypeError: Can't convert Object with a non-const value to Record
Tuple.from([{}, {} , {}]); // TypeError: Can't convert Iterable with a non-const value to Tuple
```
此方法不支持嵌套,因为标准 API 仅考虑一层,递归一般交给业务或库函数实现,就像 `Object.assign` 一样。
Record 与 Tuple 也都是可迭代的:
```js
const tuple = #[1, 2];
// output is:
// 1
// 2
for (const o of tuple) { console.log(o); }
const record = #{ a: 1, b: 2 };
// TypeError: record is not iterable
for (const o of record) { console.log(o); }
// Object.entries can be used to iterate over Records, just like for Objects
// output is:
// a
// b
for (const [key, value] of Object.entries(record)) { console.log(key) }
```
`JSON.stringify` 会把 Record & Tuple 转化为普通对象:
```js
JSON.stringify(#{ a: #[1, 2, 3] }); // '{"a":[1,2,3]}'
JSON.stringify(#[true, #{ a: #[1, 2, 3] }]); // '[true,{"a":[1,2,3]}]'
```
但同时建议实现 `JSON.parseImmutable` 将一个 JSON 直接转化为 Record & Tuple 类型,其 API 与 `JSON.parse` 无异。
Tuple.prototype 方法与 Array 很像,但也有些不同之处,主要区别是不会修改引用值,而是创建新的引用,具体可看 [appendix](https://github.com/tc39/proposal-record-tuple/blob/main/NS-Proto-Appendix.md#tuple-prototype)。
由于新增了三种原始类型,所以 typeof 也会新增三种返回结果:
```js
assert(typeof #{ a: 1 } === "record");
assert(typeof #[1, 2] === "tuple");
assert(typeof Box({}) === "box");
```
Record, Tuple, Box 都支持作为 Map、Set 的 key,并按照其自身规则进行判等,即
```js
const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };
const map = new Map();
map.set(record1, true);
assert(map.get(record2));
```
```js
const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };
const set = new Set();
set.add(record1);
set.add(record2);
assert(set.size === 1);
```
但不支持 WeakMap、WeakSet
```js
const record = #{ a: 1, b: 2 };
const weakMap = new WeakMap();
// TypeError: Can't use a Record as the key in a WeakMap
weakMap.set(record, true);
```
```js
const record = #{ a: 1, b: 2 };
const weakSet = new WeakSet();
// TypeError: Can't add a Record to a WeakSet
weakSet.add(record);
```
原因是不可变数据没有一个可预测的垃圾回收时机,这样如果用在 Weak 系列反而会导致无法及时释放,所以 API 不匹配。
最后提案还附赠了理论基础与 FAQ 章节,下面也简单介绍一下。
### 理论基础
#### 为什么要创建新的原始类型,而不是像其他库一样在上层处理?
一句话说就是让 js 原生支持 immutable 就必须作为原始类型。假如不作为原始类型,就不可能让 ==, === 操作符原生支持这个类型的特定判等,也就会导致 immutable 语法与其他 js 代码仿佛处于两套逻辑体系下,妨碍生态的统一。
#### 开发者会熟悉这套语法吗?
由于最大程度保证了与普通对象与数组处理、API 的一致性,所以开发者上手应该会比较容易。
#### 为什么不像 Immutablejs 一样使用 `.get` `.set` 方法操作?
这会导致生态割裂,代码需要关注对象到底是不是 immutable 的。一个最形象的例子就是,当 Immutablejs 与普通 js 操作库配合时,需要写出类似如下代码:
```js
state.jobResult = Immutable.fromJS(
ExternalLib.processJob(
state.jobDescription.toJS()
)
);
```
这有非常强的割裂感。
#### 为什么不使用全局 Record, Tuple 方法代替 `#` 申明?
下面给了两个对比:
```js
// with the proposed syntax
const record = #{
a: #{
foo: "string",
},
b: #{
bar: 123,
},
c: #{
baz: #{
hello: #[
1,
2,
3,
],
},
},
};
// with only the Record/Tuple globals
const record = Record({
a: Record({
foo: "string",
}),
b: Record({
bar: 123,
}),
c: Record({
baz: Record({
hello: Tuple(
1,
2,
3,
),
}),
}),
});
```
很明显后者没有前者简洁,而且也打破了开发者对对象、数组 Like 的认知。
#### 为什么采用 #[]/#{} 语法?
采用已有关键字可能导致歧义或者兼容性问题,另外其实还有 `{| |}` `[| |]` 的 [提案](https://github.com/tc39/proposal-record-tuple/issues/10),但目前 `#` 的赢面比较大。
#### 为什么是深度不可变?
这个提案喷了一下 `Object.freeze`
```js
const object = {
a: {
foo: "bar",
},
};
Object.freeze(object);
func(object);
```
由于只保障了一层,所以 `object.a` 依然是可变的,既然要 js 原生支持 immutable,希望的肯定是深度不可变,而不是只有一层。
另外由于这个语法会在语言层面支持不可变校验,而深度不可变校验是非常重要的。
### FAQ
#### 如何基于已有不可变对象创建一个新不可变对象?
大部分语法都是可以使用的,比如解构:
```js
// Add a Record field
let rec = #{ a: 1, x: 5 }
#{ ...rec, b: 2 } // #{ a: 1, b: 2, x: 5 }
// Change a Record field
#{ ...rec, x: 6 } // #{ a: 1, x: 6 }
// Append to a Tuple
let tup = #[1, 2, 3];
#[...tup, 4] // #[1, 2, 3, 4]
// Prepend to a Tuple
#[0, ...tup] // #[0, 1, 2, 3]
// Prepend and append to a Tuple
#[0, ...tup, 4] // #[0, 1, 2, 3, 4]
```
对于类数组的 Tuple,可以使用 `with` 语法替换新建一个对象:
```js
// Change a Tuple index
let tup = #[1, 2, 3];
tup.with(1, 500) // #[1, 500, 3]
```
但在深度修改时也遇到了绕不过去的问题,目前有一个 [提案](https://github.com/rickbutton/proposal-deep-path-properties-for-record) 在讨论这件事,这里提到一个有意思的语法:
```js
const state1 = #{
counters: #[
#{ name: "Counter 1", value: 1 },
#{ name: "Counter 2", value: 0 },
#{ name: "Counter 3", value: 123 },
],
metadata: #{
lastUpdate: 1584382969000,
},
};
const state2 = #{
...state1,
counters[0].value: 2,
counters[1].value: 1,
metadata.lastUpdate: 1584383011300,
};
assert(state2.counters[0].value === 2);
assert(state2.counters[1].value === 1);
assert(state2.metadata.lastUpdate === 1584383011300);
// As expected, the unmodified values from "spreading" state1 remain in state2.
assert(state2.counters[2].value === 123);
```
`counters[0].value: 2` 看上去还是蛮新颖的。
#### 与 [Readonly Collections](https://github.com/tc39/proposal-readonly-collections) 的关系?
互补。
#### 可以基于 Class 创建 Record 实例吗?
目前不考虑。
#### TS 也有 Record 与 Tuple 关键字,之间的关系是?
熟悉 TS 的同学都知道只是名字一样而已。
#### 性能预期是?
这个问题挺关键的,如果这个提案性能不好,那也无法用于实际生产。
当前阶段没有对性能提出要求,但在 Stage4 之前会给出厂商优化的最佳实践。
## 总结
如果这个提案与嵌套更新提案一起通过,在 js 使用 immutable 就得到了语言层面的保障,包括 Immutablejs、immerjs 在内的库是真的可以下岗啦。
> 讨论地址是:[精读《Records & Tuples 提案》· Issue #384 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/384)
**如果你想参与讨论,请 [点击这里](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,258 @@
继前一篇 [精读《Records & Tuples 提案》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/223.%E7%B2%BE%E8%AF%BB%E3%80%8ARecords%20%26%20Tuples%20%E6%8F%90%E6%A1%88%E3%80%8B.md),已经有人在思考这个提案可以帮助 React 解决哪些问题了,比如这篇 [Records & Tuples for React](https://sebastienlorber.com/records-and-tuples-for-react),就提到了许多 React 痛点可以被解决。
其实我比较担忧浏览器是否能将 Records & Tuples 性能优化得足够好,这将是它能否大规模应用,或者说我们是否放心把问题交给它解决的最关键因素。本文基于浏览器可以完美优化其性能的前提,一切看起来都挺美好,我们不妨基于这个假设,看看 Records & Tuples 提案能解决哪些问题吧!
## 概述
[Records & Tuples Proposal](https://github.com/tc39/proposal-record-tuple) 提案在上一篇精读已经介绍过了,不熟悉可以先去看一下提案语法。
### 保证不可变性
虽然现在 React 也能用 Immutable 思想开发,但大部分情况无法保证安全性,比如:
```tsx
const Hello = ({ profile }) => {
// prop mutation: throws TypeError
profile.name = 'Sebastien updated';
return <p>Hello {profile.name}</p>;
};
function App() {
const [profile, setProfile] = React.useState(#{
name: 'Sebastien',
});
// state mutation: throws TypeError
profile.name = 'Sebastien updated';
return <Hello profile={profile} />;
}
```
归根结底,我们不会总使用 `freeze` 来冻结对象,大部分情况下需要人为保证引用不被修改,其中的潜在风险依然存在。但使用 Record 表示状态,无论 TS 还是 JS 都会报错,立刻阻止问题扩散。
### 部分代替 useMemo
比如下面的例子,为了保障 `apiFilters` 引用不变,需要对其 `useMemo`:
```tsx
const apiFilters = useMemo(
() => ({ userFilter, companyFilter }),
[userFilter, companyFilter],
);
const { apiData, loading } = useApiData(apiFilters);
```
但 Record 模式不需要 memo,因为 js 引擎会帮你做类似的事情:
```tsx
const {apiData,loading} = useApiData(#{ userFilter, companyFilter })
```
### 用在 useEffect
这段写的很啰嗦,其实和代替 useMemo 差不多,即:
```tsx
const apiFilters = #{ userFilter, companyFilter };
useEffect(() => {
fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);
```
你可以把 `apiFilters` 当做一个引用稳定的原始对象看待,如果它确实变化了,那一定是值改变了,所以才会引发取数。如果把上面的 `#` 号去掉,每次组件刷新都会取数,而实际上都是多余的。
### 用在 props 属性
可以更方便定义不可变 props 了,而不需要提前 useMemo
```tsx
<ExpensiveChild someData={#{ attr1: 'abc', attr2: 'def' }} />;
```
### 将取数结果转化为 Record
这个目前还真做不到,除非用性能非常差的 `JSON.stringify``deepEqual`,用法如下:
```tsx
const fetchUserAndCompany = async () => {
const response = await fetch(
`https://myBackend.com/userAndCompany`,
);
return JSON.parseImmutable(await response.text());
};
```
即利用 Record 提案的 `JSON.parseImmutable` 将后端返回值也转化为 Record,这样即便重新查询,但如果返回结果完全不变,也不会导致重渲染,或者局部变化也只会导致局部重渲染,而目前我们只能放任这种情况下全量重渲染。
然而这对浏览器实现 Record 的新能优化提出了非常严苛的要求,因为假设后端返回的数据有几十 MB,我们不知道这种内置 API 会导致多少的额外开销。
假设浏览器使用非常 Magic 的办法做到了几乎零开销,那么我们应该在任何时候都用 `JSON.parseImmutable` 解析而不是 `JSON.parse`
### 生成查询参数
也是利用了 `parseImmutable` 方法,让前端可以精确发送请求,而不是每次 `qs.parse` 生成一个新引用就发一次请求:
```tsx
// This is a non-performant, but working solution.
// Lib authors should provide a method such as qs.parseRecord(search)
const parseQueryStringAsRecord = (search) => {
const queryStringObject = qs.parse(search);
// Note: the Record(obj) conversion function is not recursive
// There's a recursive conversion method here:
// https://tc39.es/proposal-record-tuple/cookbook/index.html
return JSON.parseImmutable(
JSON.stringify(queryStringObject),
);
};
const useQueryStringRecord = () => {
const { search } = useLocation();
return useMemo(() => parseQueryStringAsRecord(search), [
search,
]);
};
```
还提到一个有趣的点,即到时候配套工具库可能提供类似 `qs.parseRecord(search)` 的方法把 `JSON.parseImmutable` 包装掉,也就是这些生态库想要 “无缝” 接入 Record 提案其实需要做一些 API 改造。
### 避免循环产生的新引用
即便原始对象引用不变,但我们写几行代码随便 `.filter` 一下引用就变了,而且无论返回结果是否变化,引用都一定会改变:
```tsx
const AllUsers = [
{ id: 1, name: 'Sebastien' },
{ id: 2, name: 'John' },
];
const Parent = () => {
const userIdsToHide = useUserIdsToHide();
const users = AllUsers.filter(
(user) => !userIdsToHide.includes(user.id),
);
return <UserList users={users} />;
};
const UserList = React.memo(({ users }) => (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
));
```
要避免这个问题就必须 `useMemo`,但在 Record 提案下不需要:
```tsx
const AllUsers = #[
#{ id: 1, name: 'Sebastien' },
#{ id: 2, name: 'John' },
];
const filteredUsers = AllUsers.filter(() => true);
AllUsers === filteredUsers;
// true
```
### 作为 React key
这个想法更有趣,如果 Record 提案保证了引用严格不可变,那我们完全可以拿 `item` 本身作为 `key`,而不需要任何其他手段,这样维护成本会大大降低。
```tsx
const list = #[
#{ country: 'FR', localPhoneNumber: '111111' },
#{ country: 'FR', localPhoneNumber: '222222' },
#{ country: 'US', localPhoneNumber: '111111' },
];
<>
{list.map((item) => (
<Item key={item} item={item} />
))}
</>
```
当然这依然建立在浏览器非常高效实现 Record 的前提,假设浏览器采用 `deepEqual` 作为初稿实现这个规范,那么上面这坨代码可能导致本来不卡的页面直接崩溃退出。
### TS 支持
也许到时候 ts 会支持如下方式定义不可变变量:
```tsx
const UsersPageContent = ({
usersFilters,
}: {
usersFilters: #{nameFilter: string, ageFilter: string}
}) => {
const [users, setUsers] = useState([]);
// poor-man's fetch
useEffect(() => {
fetchUsers(usersFilters).then(setUsers);
}, [usersFilters]);
return <Users users={users} />;
};
```
那我们就可以真的保证 `usersFilters` 是不可变的了。因为在目前阶段,编译时 ts 是完全无法保障变量引用是否会变化。
### 优化 css-in-js
采用 Record 与普通 object 作为 css 属性,对 css-in-js 的区别是什么?
```tsx
const Component = () => (
<div
css={#{
backgroundColor: 'hotpink',
}}
>
This has a hotpink background.
</div>
);
```
由于 css-in-js 框架对新的引用会生成新 className,所以如果不主动保障引用不可变,会导致渲染时 className 一直变化,不仅影响调试也影响性能,而 Record 可以避免这个担忧。
## 精读
总结下来,其实 Record 提案并不是解决之前无法解决的问题,而是用更简洁的原生语法解决了复杂逻辑才能解决的问题。这带来的优势主要在于 “不容易写出问题代码了”,或者让 Immutable 在 js 语言的上手成本更低了。
现在看下来这个规范有个严重担忧点就是性能,而 stage2 并没有对浏览器实现性能提出要求,而是给了一些建议,并在 stage4 之前给出具体性能优化建议方案。
其中还是提到了一些具体做法,包括快速判断真假,即对数据结构操作时的优化。
快速判真可以采用类似 hash-cons 快速判断结构相等,可能是将一些关键判断信息存在 hash 表中,进而不需要真的对结构进行递归判断。
快速判假可以通过维护散列表快速判断,或者我觉得也可以用上数据结构一些经典算法,比如布隆过滤器,就是用在高效快速判否场景的。
### Record 降低了哪些心智负担
其实如果应用开发都是 hello world 复杂度,那其实 React 也可以很好的契合 immutable,比如我们给 React 组件传递的 props 都是 boolean、string 或 number
```tsx
<ExpensiveChild userName="nick" age={18} isAdmin />;
```
比如上面的例子,完全不用关心引用会变化,因为我们用的原始类型本身引用就不可能变化,比如 `18` 不可能突变成 `19`,如果子组件真的想要 `19`,那一定只能创建一个新的,总之就是没办法改变我们传递的原始类型。
如果我们永远在这种环境下开发,那 React 结合 immutable 会非常美妙。但好景不长,我们总是要面对对象、数组的场景,然而这些类型在 js 语法里不属于原始类型,我们了解到还有 “引用” 这样一种说法,两个值不一样对象可能是 `===` 全等的。
可以认为,Record 就是把这个顾虑从语法层面消除了,即 `#{ a: 1 }` 也可以看作像 `18``19` 一样的数字,不可能有人改变它,所以从语法层面你就会像对 `19` 这个数字一样放心 `#{ a: 1 }` 不会被改变。
当然这个提案面临的最大问题就是 “如何将拥有子结构的类型看作原始类型”,也许 JS 引擎将它看作一种特别的字符串更贴合其原理,但难点是这又违背了整个语言体系对子结构的默认认知,Box 装箱语法尤其别扭。
## 总结
看了这篇文章的畅想,React 与 Records & Tulpes 结合的一定会很好,但前提是浏览器对其性能优化必须与 “引用对比” 大致相同才可以,这也是较为少见,对性能要求如此苛刻的特性,因为如果没有性能的加持,其便捷性将毫无意义。
> 讨论地址是:[精读《Records & Tuples for React》· Issue #385 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/385)
**如果你想参与讨论,请 [点击这里](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,103 @@
Excel 现在可利用 js 根据单元格数据生成图表、表格,或通过 js 拓展自定义函数拓展内置 Excel 表达式。
我们来学习一下 Excel js API 开放是如何设计的,从中学习到一些开放 API 设计经验。
API 文档:[Excel JavaScript API overview](https://docs.microsoft.com/en-us/office/dev/add-ins/reference/overview/excel-add-ins-reference-overview)
## 精读
Excel 将利用 JS API 开放了大量能力,包括用户能通过界面轻松做到的,也包括无法通过界面操作做到的。
### 为什么需要开放 JS API
Excel 已经具备了良好的易用性,以及 [formula](https://support.microsoft.com/en-us/office/overview-of-formulas-in-excel-ecfdc708-9162-49e8-b993-c311f47ca173) 这个强大的公式。在之前 [精读《Microsoft Power Fx》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/211.%E7%B2%BE%E8%AF%BB%E3%80%8AMicrosoft%20Power%20Fx%E3%80%8B.md) 提到过,formula 就是 Excel 里的 Power FX,属于画布低代码语言,不过在 Excel 里叫做 “公式” 更合适。
已经具备这么多能力,为何还需要 JS API 呢?一句话概括就是,在 JS API 内可以使用 formula,即 JS API 是公式能力的超集,它包含了对 Excel 工作簿的增删改查、数据的限制、RangeAreas 操作、图表、透视表,甚至可以自定义 formula 函数。
也就是说,JS API 让 Excel “可编程化”,即以开发者视角对 Excel 进行二次拓展,包括对公式进行二次拓展,使 Excel 覆盖更多场景。
### JS API 可以用在哪些地方
从 Excel 流程中最开始的工作薄、工作表环节,到最细节的单元格数据校验都可通过 JS API 支持,目前看来 Excel JS API 并没有设置能力边界,而且还会不断完善,将 Excel 全生命周期中一切可编程的地方开放出来。
首先是对工作薄、工作表的操作,以及对工作表用户操作的监听,或者对工作表进行只读设置。这一类 API 的目的是对 Excel 这个整体进行编程操作。
第二步就是对单元格级别进行操作,比如对单元格进行区域选中,获取选中区域,或者设置单元格属性、颜色,或者对单元格数据进行校验。自定义公式也在这个环节,因为单元格的值可以是公式,而公式可以利用 JS API 拓展。
最后一步是拓展行为,即在单元格基础上引入图表、透视表拓展。虽然这些功能在 UI 按钮上也可以操作出来,但 JS API 可以实现 UI 界面配置不出来的逻辑,对于非常复杂的逻辑行为,即便 UI 可以配置出来,可读性也远没有代码高。除了表格透视表外、还可以创建一些自定义形状,基本的几何图形、图片和 SVG 都支持。
### JS API 设计
比较有趣的是,Excel 并没有抽象 “单元格” 对象,即便我们所有人都认为单元格就是 Excel 的代表。
这么做是出于 API 设计的合理性,因为 Excel 使用 Range 概念表示连续单元格。比如:
```js
Excel.run(function (context) {
var sheet = context.workbook.worksheets.getActiveWorksheet();
var headers = [
["Product", "Quantity", "Unit Price", "Totals"]
];
var headerRange = sheet.getRange("B2:E2");
headerRange.values = headers;
headerRange.format.fill.color = "#4472C4";
headerRange.format.font.color = "white";
return context.sync();
});
```
可以发现,Range 让 Excel 聚焦在批量单元格 API,即把单元格看做一个范围,整体 API 都可以围绕一个范围去设计。这种设计理念的好处是,把范围局限在单格单元格,就可以覆盖 Cell 概念,而聚焦在多个单元格时,可以很方便的基于二维数据结构创建表格、折线图等分析图形,因为二维结构的数据才是结构化数据。
或者可以说,结构化数据是 Excel 最核心的概念,而单元格无法体现结构化。结构化数据的好处是,一张工作表就是一个可以用来分析的数据集,在其之上无论是基于单元格的条件格式,还是创建分析图表,都是一种数据二次分析行为,这都得益于结构化数据,所以 Excel JS API 必然围绕结构化数据进行抽象。
再从 API 语法来看,除了工作薄这个级别的 API 采用了 `Excel.createWorkbook();` 之外,其他大部分 API 都是以下形式:
```js
Excel.run(function (context) {
// var sheet = context.workbook.worksheets.getItem("Sample");
// 对 sheet 操作 ..
return context.sync();
});
```
最外层的函数 `Excel.run` 是注入 `context` 用的,而且也可以保证执行的时候 Excel context 已经准备好了。而 `context.sync()` 是同步操作,即使当前对 context 的操作生效。所以 Excel JS API 是命令式的,也不会做类似 MVVM 的双向绑定,所以在操作过程中数据和 Excel 状态不会发生变化,直到执行 `context.sync()`
注意到这点后,就可以理解为什么要把某些代码写在 `context.sync().then` 里了,比如:
```js
Excel.run(function (ctx) {
var pivotTable = context.workbook.worksheets.getActiveWorksheet().pivotTables.getItem("Farm Sales");
// Get the totals for each data hierarchy from the layout.
var range = pivotTable.layout.getDataBodyRange();
var grandTotalRange = range.getLastRow();
grandTotalRange.load("address");
return context.sync().then(function () {
// Sum the totals from the PivotTable data hierarchies and place them in a new range, outside of the PivotTable.
var masterTotalRange = context.workbook.worksheets.getActiveWorksheet().getRange("E30");
masterTotalRange.formulas = [["=SUM(" + grandTotalRange.address + ")"]];
});
}).catch(errorHandlerFunction);
```
这个从透视表获取数据的例子,只有执行 `context.sync()` 后才能拿到 `grandTotalRange.address`
## 总结
微软还在 Office 套件 Excel、Outlook、Word 中推出了 [ScriptLab](https://docs.microsoft.com/zh-cn/office/dev/add-ins/overview/explore-with-script-lab) 功能,就可以在 Excel 的 ScriptLab 里编写 Excel JS API。
在 Excel JS API 之上,还有一个 [通用 API](https://docs.microsoft.com/zh-cn/javascript/api/office?view=common-js-preview),定义为跨应用的通用 API,这样 Excel JS API 就可以把精力聚焦在 Excel 产品本身能力上。
> 讨论地址是:[精读《Excel JS API》· Issue #387 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/387)
**如果你想参与讨论,请 [点击这里](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,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) 和 next.js 很像,据我当时的了解,是因为 next.js 起步较慢,源码还不支持 ts,所以就有了这个更时髦的新框架。但实际上 next.js 早就全部改为 ts 了,而且正如整体榜单所说,现在已经开始引领潮流了,所以不怪 nest 定位重合,只能怪 next.js 后续发力太猛了。nest 的唯一特点就是没有绑定 UI 库。
第三名 [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) 即 vue3star 数量只有 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 OnceRun 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 标签:`<tabke></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 了,而是所见即所得的 WYSIWYGwhat 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)
@@ -36,7 +36,7 @@ RPC 主要用来做服务器之间的方法调用,影响其性能最重要因
gRPC 主要用于服务之间传输,这里拿 Nodejs 举例:
1. 定义接口。由于 gRPC 使用 protobufs,所以接口定义文件就是 `helloword.proto`:
1. 定义接口。由于 gRPC 使用 protobufs,所以接口定义文件就是 `helloworld.proto`:
```protobufs
// The greeting service definition.
@@ -41,7 +41,7 @@
**也就是学习技术细节是没有技术门槛,随着年龄的增加,如果只累积了大家都能学会的内容,那么当旧知识被淘汰后,学习新知识的速度又不如年轻人快,会逐渐失去经验优势。**
那么如何利用无门槛的特征,将其变为门槛呢?那就是任何年龄段学习技术细节都很容易,在你需要深入细节的时候再深入进去,不需要深入的时候把时间花在了解宏观架构上。
那么如何利用无门槛的特征,将其变为门槛呢?任何年龄段学习技术细节都很容易,应该在你需要深入细节的时候再深入进去,不需要深入的时候把时间花在了解宏观架构上。
就是培养高效的学习能力,能准确判断某个技术细节是否有必要掌握,如需要该如何快速掌握核心内容,并在掌握之后不留恋,可以快速抽身出来继续全局性思考。这种思维是有门槛的,技术专家都可以做到这一点。
@@ -53,7 +53,7 @@
这要看怎么理解业务与技术的关系,比如建设 “数据联邦”,光是了解各个不同的存储系统技术细节可能就要花很久,而实际上是没必要将所有技术细节都弄懂的,只要定好一个通用交互规范,各存储系统各自封装一套符合这个规范的交互接口即可。
做成事往往需要宏观的技术思维,需要将许多技术点链接在一起。举个例子,做成事就类似于军官指挥作战,做成的目的是通过制定打法赢得战争,而不是自己冲锋陷阵并测量敌人壕沟的宽度。关心技术细节只最终落实到每个人具体实施项中的一部分,技术细节的目标累加起来才做成事。
做成事往往需要宏观的技术思维,需要将许多技术点链接在一起。举个例子,做成事就类似于军官指挥作战,做成的目的是通过制定打法赢得战争,而不是自己冲锋陷阵并测量敌人壕沟的宽度。关心技术细节只最终落实到每个人具体实施项中的一部分,技术细节的目标累加起来才做成事。
## 2.2 搞清楚业务对技术的真实诉求
@@ -63,7 +63,7 @@
拥有技术思维的人,容易沉迷于解决不切实际的问题,或者是别人解决过的问题。这种思维对技术学习是非常有帮助的,但如果长期不能转变这种思维,对公司来说是无法创造什么价值的。
拥有业务思维的人,首先要懂业务,只有懂业务,跟着对的业务,才能对未来信心,知道自己的付出可以换来回报。
拥有业务思维的人,首先要懂业务,只有懂业务,跟着对的业务,才能对未来信心,知道自己的付出可以换来回报。
懂业务后,才知道如何通过技术帮助业务获得成功。
@@ -83,7 +83,7 @@
现在技术点越来越多,如果什么技术细节都要详细了解,最终一定不能有很好的全局视野。比较好的状态是找几个重点深入了解,其他的技术点在掌握了全局技术视野后再考虑深入。
在互联网初期,很多技术框架还不完善,技术借力的意义不大,毕竟也没有多少东西可用。
在互联网初期,很多技术框架还不完善,技术借力的意义不大,毕竟也没有多少东西可用。
但是现在无论前端还是后端的技术、轮子已经眼花缭乱了,能掌握这些已有技术的人,价值已经逐渐大于会完整了解某些技术细节的人。一个优秀的专家应该能快速定位要解决的业务问题是否有成熟的技术方案,如何以最小的投入产出比实现,同时保持良好的维护性应变业务维护。
@@ -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)
@@ -103,7 +103,7 @@ select c.$my_custom_symbol$ from ...
```sql
select a |from b;
# select a $my_custom_symbol$ b;
# select a $my_custom_symbol$ from b;
```
你会发现,“补全光标文字” 法,在关键字位置时,会把原本正确的语句变成错误的语句,根本解析不出语法树。
@@ -32,7 +32,7 @@ Factory Method(工厂方法)属于创建型模式,利用工厂方法创建
对卡牌对战的系统来说,**所有卡牌都应该实现同一种接口**,所以卡牌对战系统拿到的卡牌应该就是简单的 Card 类型,这种类型具备基本的卡片操作交互能力,系统就调用这些能力完成基本流程就好了,如果系统直接实例化具体的卡片,那不同的卡片类型会导致系统难以维护,卡片间操作也无法抽象化。
这种模式,使得我们可以在卡牌的具体实现上做一些特殊功能,比如修改卡片攻击时效果,修改卡牌销毁时效果。
这种模式,使得我们可以在卡牌的具体实现上做一些特殊功能,比如修改卡片攻击时效果,修改卡牌销毁时效果。
对图形拖拽系统来说,用到了 “连接平行的类层次” 这个特性,所谓连接平行的类层次,就是指一个图形,与其对应的操作类是一个平行抽象类,而一个具体的图形与具体的操作类则是另一个平行关系,系统只要关注最抽象的 “通用图形类” 与 “通用操作类” 即可,操作时,底层可能是某个具体的 “圆类” 与 “圆操作类” 结合使用,具体的类有不同的实现,但都符合同一种接口,因此操作系统才可以把它们一视同仁,统一操作。
@@ -25,7 +25,7 @@ Prototype(原型模式)属于创建型模式,既不是工厂也不是直
### 模版组件
通用搭建系统中,我们可以将某个拖拽到页面的区块设置为 “模版”,这个模版可以作为一个新组件被重新拖拽到任意为止,实例化任意次。实际上,这是一种分段式复制粘贴,你会如何实现这个功能呢?
通用搭建系统中,我们可以将某个拖拽到页面的区块设置为 “模版”,这个模版可以作为一个新组件被重新拖拽到任意位置,实例化任意次。实际上,这是一种分段式复制粘贴,你会如何实现这个功能呢?
## 意图解释
@@ -42,13 +42,25 @@ State(状态模式)属于行为型模式。
下面例子使用 typescript 编写。
```typescript
abstract class Context {
abstract setState(state: State): void;
}
// 定义状态接口
interface State {
// 模拟台灯点亮
show: () => string
}
class Light1 implements State {
interface Light {
click: () => void
}
type LightState = State & Light
class TurnOff implements State, Light {
context: Context;
constructor(context: Context) {
this.context = context
}
@@ -59,11 +71,13 @@ class Light1 implements State {
// 按下按钮
public click() {
this.context.setState(new Light2(this.context))
this.context.setState(new WeakLight(this.context))
}
}
class Light2 implements State {
class WeakLight implements State, Light {
context: Context;
constructor(context: Context) {
this.context = context
}
@@ -74,11 +88,13 @@ class Light2 implements State {
// 按下按钮
public click() {
this.context.setState(new Light3(this.context))
this.context.setState(new StandardLight(this.context))
}
}
class Light3 implements State {
class StandardLight implements State, Light {
context: Context;
constructor(context: Context) {
this.context = context
}
@@ -89,11 +105,13 @@ class Light3 implements State {
// 按下按钮
public click() {
this.context.setState(new Light4(this.context))
this.context.setState(new StrongLight(this.context))
}
}
class Light4 implements State {
class StrongLight implements State, Light {
context: Context;
constructor(context: Context) {
this.context = context
}
@@ -104,30 +122,37 @@ class Light4 implements State {
// 按下按钮
public click() {
this.context.setState(new Light1(this.context))
this.context.setState(new TurnOff(this.context))
}
}
// 台灯
public class Lamp {
class Lamp extends Context {
// 当前状态
private currentState = new Light1(this)
protected setState(state: State) {
this.currentState = state
#currentState: LightState = new TurnOff(this)
setState(state: LightState) {
this.#currentState = state
}
getState() {
return this.#currentState
}
// 按下按钮
public click() {
click() {
this.getState().click()
}
}
const lamp = new Lamp() // 关闭
console.log(lamp.getState().show()) // 关灯
lamp.click() // 弱光
console.log(lamp.getState().show()) // 弱光
lamp.click() // 亮
console.log(lamp.getState().show()) // 亮
lamp.click() // 强光
console.log(lamp.getState().show()) // 强光
lamp.click() // 关闭
console.log(lamp.getState().show()) // 关闭
```
其实有很多种方式来实现,不必拘泥于形式,大体上只要保证由多个类实现不同状态,每个类实现到下一个状态切换就好了。