Compare commits

..
20 Commits
Author SHA1 Message Date
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
黄子毅 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
9 changed files with 1615 additions and 15 deletions
+8 -1
View File
@@ -6,7 +6,7 @@
前端界的好文精读,每周更新!
最新精读:<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="./前沿技术/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>
素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2)
@@ -174,6 +174,13 @@
- <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>
### 设计模式
@@ -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: pan-x;
}
```
### 事件合并
由于事件触发频率可能比浏览器帧率还要高(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)
@@ -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()) // 关闭
```
其实有很多种方式来实现,不必拘泥于形式,大体上只要保证由多个类实现不同状态,每个类实现到下一个状态切换就好了。