Compare commits

...
25 Commits
Author SHA1 Message Date
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
14 changed files with 1390 additions and 29 deletions
+9 -1
View File
@@ -6,7 +6,7 @@
前端界的好文精读,每周更新!
最新精读:<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="./前沿技术/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>
素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2)
@@ -171,6 +171,14 @@
- <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>
### 设计模式
@@ -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 状态
+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)
@@ -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()) // 关闭
```
其实有很多种方式来实现,不必拘泥于形式,大体上只要保证由多个类实现不同状态,每个类实现到下一个状态切换就好了。