Compare commits

...
13 Commits
Author SHA1 Message Date
ascoders 92a8dfc55c Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-06-08 09:53:32 +08:00
ascoders c59f46cc1d 154 2020-06-08 09:53:17 +08:00
黄子毅 ef3904a2f6 Merge pull request #253 from justjavac/patch-1
fix: snowpack issue 链接
2020-06-01 23:05:43 +08:00
迷渡 994c5844d9 fix: snowpack issue 链接 2020-06-01 21:02:00 +08:00
ascoders 3881e51b08 153 2020-06-01 09:49:07 +08:00
ascoders 815ae1367a fix typo 2020-05-25 18:20:56 +08:00
ascoders 84ed47dbdf Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-05-25 09:29:02 +08:00
ascoders 252980724a 152 2020-05-25 09:28:53 +08:00
黄子毅 225aab476d Merge pull request #250 from spiritree/patch-1
Update 148. 精读《React Error Boundaries》.md
2020-05-22 18:29:16 +08:00
深樹 f065539266 Update 148. 精读《React Error Boundaries》.md
FQA -> FAQ
2020-05-22 16:55:03 +08:00
ascoders f8d81fc3f5 151 2020-05-18 08:40:17 +08:00
ascoders 43e264cc07 update 2020-05-11 13:48:07 +08:00
ascoders 957737959f 150 2020-05-11 09:39:14 +08:00
6 changed files with 1570 additions and 1 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ const Child = (props) => {
> componentDidCatch and getDerivedStateFromError: There are no Hook equivalents for these methods yet, but they will be added soon.
所以这里的理解要注意一下,另外 React 官方文档 [Hooks FQA](https://reactjs.org/docs/hooks-faq.html#how-do-lifecycle-methods-correspond-to-hooks) 有很多宝藏,建议抽时间逐条阅读。
所以这里的理解要注意一下,另外 React 官方文档 [Hooks FAQ](https://reactjs.org/docs/hooks-faq.html#how-do-lifecycle-methods-correspond-to-hooks) 有很多宝藏,建议抽时间逐条阅读。
## 4 总结
@@ -0,0 +1,259 @@
## 1 引言
Deno 是什么?Deno 和 Node 有什么关系?Deno 和我有什么关系?
Deno 将于 2020-05-13 发布 1.0,如果你还有上面的疑惑,可以和我一起通过 [Deno 1.0: What you need to know](https://blog.logrocket.com/deno-1-0-what-you-need-to-know/) 这篇文章一起了解 Deno 基础知识。
希望你带着疑问思考,未来 10 年看今天,会不会出现 Deno 官方生态壮大,完全替代 Node 进而影响到 Web 生态的局面呢?这个思考结果会影响到你未来职业发展,你需要学会自己思考,并对这个思考结果负责。
## 2 介绍 & 精读
Deno 的作者是 Ryan Dahl,他是 Nodejs 背后的策划者,曾经说过 [我对 Nodejs 感到遗憾的 10 件事](https://www.youtube.com/watch?v=M3BM9TB-8yA)。这也是为什么新开一个坑的原因,但 Deno 并不定位为 Nodejs 的替代品,从整体功能来看,Deno 有更大的野心,据我的推测是想要取代现在陈旧的前后端开发模式,让 Deno 一统前后端开发全流程。
Nodejs 是由 C++ 写的,而 Deno 则是由 Rust 写的,并选择了 [Tokio](https://tokio.rs/) 这个异步编程框架,并使用 V8 引擎解析 Javascript,并内置了对 Ts 的解析。
### 安装
Deno 支持如下安装方式:
**Shell:**
```shell
curl -fsSL https://deno.land/x/install/install.sh | sh
```
**PowerShell:**
```shell
iwr https://deno.land/x/install/install.ps1 -useb | iex
```
**Homebrew:**
```shell
brew install deno
```
**Chocolatey:**
```shell
choco install deno
```
脚本执行方式为 `deno run`,可以类比为 `node`,但功能不同且支持远程文件,实际上远程依赖是 Deno 的一大特色,也是有争议的地方:
```shell
deno run https://deno.land/std/examples/welcome.ts
```
在 ts 文件中允许用远程脚本加载资源,这个后面还会提到:
```ts
import { serve } from "https://deno.land/std@v0.42.0/http/server.ts";
const s = serve({ port: 8000 });
console.log("http://localhost:8000/");
for await (const req of s) {
req.respond({ body: "Hello World\n" });
}
```
### 安全性
Deno 是默认安全的,这体现在默认没有环境、网络访问权限、文件读写权限、运行子进程的能力。所以如果直接运行一个依赖权限的文件会报错:
```shell
deno run file-needing-to-run-a-subprocess.ts
# error: Uncaught PermissionDenied: access to run a subprocess, run again with the --allow-run flag
```
可以通过参数方式允许权限的执行,有 `--allow-read``--allow-write``--allow-net` 等:
```shell
deno --allow-read=/etc
```
上面表示 `/etc` 文件夹下的文件拥有文件读权限。
除了直接加参数调用、Bash 脚本调用外,还可以用 Make 运行,或者使用类似的 [drake](https://deno.land/x/drake/) 启动。
或者使用 `deno install` 命令,将脚本转化为一个快捷指令:
```shell
deno install --allow-net --allow-read -n serve https://deno.land/std/http/file_server.ts
```
`-n` 表示 `--name`,可以对这个脚本进行重命名,比如上面的例子中,`serve` 命令就等同于 `deno run --allow-net --allow-read https://deno.land/std/http/file_server.ts`
### 标准库
Deno 在标准库上很有特点,对常用功能提供了官方版本,保证可用性与稳定性。原文中列出了一些与 Npm 三方库的对比:
| Deno Module | Description | npm | Equivalents |
| ----------- | --------------------------------------------------------------------------------- | --- | ------------------------ |
| colors | Adds color to the terminal | | chalk, kleur, and colors |
| datetime | Helps working with the JavaScript Date object | |
| encoding | Adds support for external data scructures like base32, binary, csv, toml and yaml | |
| flags | Helps working with command line arguments | | minimist |
| fs | Helps with manipulation of the file system | |
| http | Allows serving local files over HTTP | | http-server |
| log | Used for creating logs | | winston |
| testing | For unit testing assertion and benchmarking | | chai |
| uuid | UUID generation | | uuid |
| ws | Helps with creating WebSocket client/server | | ws |
从这个点上来看,Deno 既做运行环境又做基础生态,缓解了 Npm 生态下选择困难症,这件事需要辩证来看:集成了官方包对功能确定的模块来说是很有必要的,而且提高了底层库的稳定性;但 Deno 生态也有三方库,而且本质上三方库和官方库在功能上没有任何壁垒,因为实现代码都类似,唯一区别是谁能为其稳定性站台,假设微软和 Deno 同时出了基于 Npm 生态与 Deno 生态官方库,都保证会持续维护,你更相信谁呢?官方是否有优势要取决于官方自身的实力。
### 内置 Typescript
Deno 内置支持了 TS,因此不需要 `ts-node` 我们就可以用 `deno run test.ts` 运行 Typescript 文件。值得注意的是,Deno 内部也是利用 Typescript 引擎解析为 Js 后交由 V8 引擎解析,因此本质上没太大的变化,只是这样 Deno 的生态会更规范。
由于内置了 TS 支持,自然也不需要写 `tsconfig.json` 配置了,但你依然可以定制它:
```shell
deno run -c tsconfig.json [file-to-run.ts]
```
Deno 默认还开启了 TS 严格模式,所以看到这里,可以认为 Deno 是为了构建高质量理想库而诞生的运行环境,基于已有的生态来做,但做了更多内置技术选型,这和 Facebook 的 [rome](https://github.com/facebookexperimental/rome) 很像,但做的却更彻底。
其实从实现上来看,我们基于 Javascript 生态也能写出 `deno run test.ts` 这样类似的引擎,只不过是由 JS 驱动执行,可能编译还会选择 Webpack,但 Deno 本身基于 Rust 实现,并重新实现了一套模块加载标准,可以说从更底层的方式重新解读了 W3C 标准规范,以期望解决 Javascript 生态的各种痛点问题。
### 支持 Web 标准
Deno 还支持 W3C 标准规范,因此像 `fetch``setTimeout` 等 API 都可以被直接使用,如果你按照 Deno 支持的那几个函数写代码,可以保证在 Deno、Node、Web 三个平台实现跨平台运行。
虽然距离完全实现 W3C 所有标准规范还有一些路要走,但我们看到了 Deno 兼容规范的决心。
### ESModule
模块化是 Deno 的亮点,Deno 使用官方 ESModule 规范,但引用路径必须加上后缀:
```ts
import * as log from "https://deno.land/std/log/mod.ts";
import { outputToConsole } from "./view.ts";
```
Deno 不需要申明依赖,代码的引用路径就是依赖申明,会包括完整的路径以及文件后缀,也支持网络资源,可以摆脱 NPM 中心化的包管理模式,因为这个路径可以是任何网络地址。
### 包管理
对于 `import * as log from "https://deno.land/std/log/mod.ts";` 这行代码,Deno 会下载到一个缓存文件夹,用户不会感知到这个文件夹与这个过程的存在,也就是说,Deno 环境中是没有 `node_modules` 的。
也可以通过 `deno --reload` 的方式强制刷新缓存。
但这里也要辩证的看待 “Deno 去中心化” 这件事,虽然引用了网络源,但会引发下面几个问题:
1. 实际上还存在一个 "node_modules",只是用户看不到。
2. 网络下载速度放到运行时,第一次启动还是很慢。
3. 普通模式下无 lock,必须配合 `deps.ts` 使用,这个后面会提到。
即使被打上 “中心化恶人” 的 npm 也有去中心化的一面,因为 npm 支持私有化部署,无论是速度还是稳定性都可以由公司自己掌控,从稳定性来说还是 npm 拥有压倒性优势。
### 三方库
Deno 还有第三方库生态,截止目前共有 [221 个三方库](<[](https://deno.land/x/)>)。
由于 Deno 走网络资源,我们可以借助 [Pika](https://www.pika.dev/cdn) 提供的 CDN 服务直接引用网络资源包:
```jsx
import * as pkg from "https://cdn.pika.dev/preact@^10.3.0";
```
虽然这样看上去很轻量,但对公司来说还是需要自建一个 “Pika” 保障稳定性,以及做全球 CDN 缓存等的工作。
### 告别 package.json
npm 生态下包信息存放在 `package.json`,包含但不限于下面的内容:
- 项目元信息。
- 项目依赖和版本号。
- 依赖还进行分类,比如 `dependencies``devDependencies` 甚至 `peerDependencies`
- 标记入口,`main``module`,还有 TS 用的 `types``typings`,脚手架的 `bin` 等等。
- npm scripts。
随着标准的不断更新,`package.json` 信息已经非常臃肿了。
对于 Deno 来说,则使用 `deps.ts` 集中管理依赖:
```ts
export { assert } from "https://deno.land/std@v0.39.0/testing/asserts.ts";
export { green, bold } from "https://deno.land/std@v0.39.0/fmt/colors.ts";
```
`deps.ts` 就是一个普通文件,只是将项目的依赖精确描述出来,这样其他地方引用 `assert` 时,就可以这么写了:
```ts
// import { assert } from "https://deno.land/std@v0.39.0/testing/asserts.ts";
import { assert } from "./deps.ts";
```
如果需要锁定依赖,可以通过 `deno --lock=lock.json` 方式申明。
### deno doc
`deno doc <filename>` 命令可以根据文件按照 JS Doc 规则生成文档,同时也支持 TS 语法,比如下面这段代码:
```ts
/** Asynchronously fulfill a response with a file from the local file
* system. */
export async function send(
{ request, response }: Context,
path: string,
options: SendOptions = { root: "" }
): Promise<string | undefined> {
// ...
}
```
生成文档如下:
```text
function send(_: Context, path: string, options: SendOptions): Promise<string | undefined>
Asynchronously fulfill a response with a file from the local file system.
```
deno 本身文档就是用这个命令生成的,可以 [访问官方文档](https://doc.deno.land/) 查看使用效果。
### 内置工具链
前端 Javascript 工具链相当混乱,虽然业界已有 Umi 等框架做了开箱即用的封装,但回到 Javascript 设计的初衷就是可以在浏览器直接使用的,包括浏览器对不依赖构建工具的模块化支持,注定了未来 Webpack 一定会被消灭。
Deno 通过内置一套工具链的方式解决这个问题,包括:
- 测试:提供 `deno test` 命令与 `Deno.test()` 测试函数。
- 格式化:提供 [vscode 插件](https://marketplace.visualstudio.com/items?itemName=axetroy.vscode-deno)。
- 编译:提供 `deno bundle` 命令。
不过值得注意的是,在最重要的编译环节,`deno bundle` 目前提供的能力是相对欠缺的,比如还不支持 Tree Shaking。
用 Rust 等语言提升构建效率是业界一直在尝试的事,比如 @陈成 就基于 [esbuild](https://github.com/evanw/esbuild) 做了 [@umijs/plugin-esbuild](https://umijs.org/zh-CN/plugins/plugin-esbuild) 插件用于提升 Umi 构建速度,但为了防止生产构建产物与 Webpack 默认规则不一致,仅使用了其压缩(minifier)功能。
对 deno 来说也一样,目前其实没有任何证据表明 deno 的构建结果可以完美适配 webpack 环境,所以请勿认为 deno 发布了 1.0 版本就等于可以在生产环境使用。
## 3 总结
正如原文结尾所说的,Deno 虽然将要发布 1.0 版本,但仍不能完全替代 Nodejs,这背后的原因主要是历史兼容成本,也就是完整支持整个 Node 生态不只是设计的问题,更是一个体力活,需要一个个高地去攻克。
同样 Deno 对 Web 的支持也让人耳目一新,但仍不能放到生产环境使用,除了官方和三方生态还在逐渐完善外,`deno bundle` 对 Tree Shaking 能力的缺失以及构建产物无法保证与现在的 Webpack 完全相同,这样会导致对稳定性要求极高的大型应用迁移成本非常高。
最亮眼的改动是模块化部分,依赖完全去中心化从长远来看是一个非常好的设计,只是基础设施和生态要达到一个较为理想的水平。
最后,让我们站在一个预言者角度思考一下 Deno 到底会不会火吧:
Deno 做的初心是做一个更好的 Node,但很不幸,对于这种级别的生态底层工具来说,重新做一个并重新火起来的难度,不亚于重新做一个阿里巴巴并取代现在阿里的难度。也就是不同的时间点做同一件事,哪怕后者可以吸取教训,大概率也无法复制以前成功的路线。
从 Deno 的功能来看,解决了 Node 很多痛点,其中就包括去中心化管理,有点云开发的意思,但在 2020 年,基于 Nodejs 和 Webpack 的云开发都搞出来了,说实话是没有 Deno 什么空间的。从功能上来看,开篇就说了 Deno 基于 V8 解析 Javascript,对于性能和功能都没有革命性提升,从技术上作出突破也几乎不可能了。
Deno 的思想确实比 Node 先进,但不能说比 Node 好十倍,则无法撼动 Node 的生态,即便是 Node 作者自己可能也不行。
然而我上面说的可能都是错的。
> 讨论地址是:[精读《Deno 1.0 你需要了解的》 · Issue #248 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/248)
**如果你想参与讨论,请 [点击这里](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,453 @@
## 1 引言
与组件生命周期绑定的 Utils 非常适合基于 React Hooks 来做,比如可以将 “发请求” 这个功能与组件生命周期绑定,实现一些便捷的功能。
这次以 [@umijs/use-request](https://hooks.umijs.org/zh-CN/hooks/async) 为例子,分析其功能思路与源码。
## 2 简介
[@umijs/use-request](https://hooks.umijs.org/zh-CN/hooks/async) 支持以下功能:
- 默认自动请求:在组件初次加载时自动触发请求函数,并自动管理 `loading`, `data` , `error` 状态。
- 手动触发请求:设置 `options.manual = true` , 则手动调用 `run` 时才会取数。
- 轮询请求:设置 `options.pollingInterval` 则进入轮询模式,可通过 `run` / `cancel` 开始与停止轮询。
- 并行请求:设置 `options.fetchKey` 可以对请求状态隔离,通过 `fetches` 拿到所有请求状态。
- 请求防抖:设置 `options.debounceInterval` 开启防抖。
- 请求节流:设置 `options.throttleInterval` 开启节流。
- 请求缓存 & SWR:设置 `options.cacheKey` 后开启对请求结果缓存机制,下次请求前会优先返回缓存并在后台重新取数。
- 请求预加载:由于 `options.cacheKey` 全局共享,可以提前执行 `run` 实现预加载效果。
- 屏幕聚焦重新请求:设置 `options.refreshOnWindowFocus = true` 在浏览器 `refocus``revisible` 时重新请求。
- 请求结果突变:可以通过 `mutate` 直接修改取数结果。
- 加载延迟:设置 `options.loadingDelay` 可以延迟 `loading` 变成 `true` 的时间,有效防止闪烁。
- 自定义请求依赖:设置 `options.refreshDeps` 可以在依赖变动时重新触发请求。
- 分页:设置 `options.paginated` 可支持翻页场景。
- 加载更多:设置 `options.loadMore` 可支持加载更多场景。
一切 Hooks 的功能拓展都要基于 React Hooks 生命周期,我们可以利用 Hooks 做下面几件与组件相关的事:
1. 存储与当前组件实例绑定的 mutable、immutable 数据。
2. 主动触发调用组件 rerender。
3. 访问到组件初始化、销毁时机的钩子。
上面这些功能就可以基于这些基础能力拓展了:
**默认自动请求**
在组件初始时机取数。由于和组件生命周期绑定,可以很方便实现各组件相互隔离的取数顺序强保证:可以利用取数闭包存储 requestIndex,取数结果返回后与当前最新 requestIndex 进行比对,丢弃不一致的取数结果。
**手动触发请求**
将触发取数的函数抽象出来并在 CustomHook 中 return。
**轮询请求**
在取数结束后设定 `setTimeout` 重新触发下一轮取数。
**并行请求**
每次取数时先获取当前请求唯一标识 `fetchKey`,仅更新这个 key 下的状态。
**请求防抖、请求节流**
这个实现方式可以挺通用化,即取数调用函数处替换为对应 `debounce``throttle` 函数。
**请求预加载**
这个功能只要实现全局缓存就自然支持了。
**屏幕聚焦重新请求**
这个可以统一监听 window action 事件,并触发对应组件取数。可以全局统一监听,也可以每个组件分别监听。
**请求结果突变**
由于取数结果存储在 CustomHook 中,直接修改数据 data 值即可。
**加载延迟**
有加载延迟时,可以先将 `loading` 设置为 `false`,等延迟到了再设置为 `true`,如果此时取数提前完毕则销毁定时器,实现无 loading 取数。
**自定义请求依赖**
利用 `useEffect` 和自带的 deps 即可。
**分页**
基于通用取数 Hook 封装,本质上是多带了一些取数参数与返回值参数,并遵循 Antd Table 的 API。
**加载更多**
和分页类似,区别是加载更多不会清空已有数据,并且需要根据约定返回结构 `noMore` 判断是否能继续加载。
## 3 精读
接下来是源码分析。
首先定义了一个类 `Fetch`,这是因为一个 `useRequest``fetchKey` 特性可以通过多实例解决。
Class 的生命周期不依赖 React Hooks,所以将不依赖生命周期的操作收敛到 Class 中,不仅提升了代码抽象程度,也提升了可维护性。
```tsx
class Fetch<R, P extends any[]> {
// ...
// 取数状态存储处
state: FetchResult<R, P> = {
loading: false,
params: [] as any,
data: undefined,
error: undefined,
run: this.run.bind(this.that),
mutate: this.mutate.bind(this.that),
refresh: this.refresh.bind(this.that),
cancel: this.cancel.bind(this.that),
unmount: this.unmount.bind(this.that),
};
constructor(
service: Service<R, P>,
config: FetchConfig<R, P>,
// 外部通过这个回调订阅 state 变化
subscribe: Subscribe<R, P>,
initState?: { data?: any; error?: any; params?: any; loading?: any }
) {}
// 此 setState 非彼 setState,作用是更新 state 并通知订阅
setState(s = {}) {
this.state = {
...this.state,
...s,
};
this.subscribe(this.state);
}
// 实际取数函数,但下划线命名的带有一些历史气息啊
_run(...args: P) {}
// 对外暴露的取数函数,对防抖和节流做了分发处理
run(...args: P) {
if (this.debounceRun) {
// return ..
}
if (this.throttleRun) {
// return ..
}
return this._run(...args);
}
// 取消取数,考虑到了防抖、节流兼容性
cancel() {}
// 以上次取数参数重新取数
refresh() {}
// 轮询 starter
rePolling() {}
// 对应 mutate 函数
mutate(data: any) {}
// 销毁订阅
unmount() {}
}
```
**默认自动请求**
通过 `useEffect` 零依赖实现,需要:
1. 有缓存则不需响应,当对应缓存结束后会通知,同时也支持了请求预加载功能。
2. 为支持并行请求,所有请求都通过 `fetches` 独立管理。
```tsx
// 第一次默认执行
useEffect(() => {
if (!manual) {
// 如果有缓存
if (Object.keys(fetches).length > 0) {
/* 重新执行所有的 */
Object.values(fetches).forEach((f) => {
f.refresh();
});
} else {
// 第一次默认执行,可以通过 defaultParams 设置参数
run(...(defaultParams as any));
}
}
}, []);
```
默认执行第 11 行,并根据当前的 `fetchKey` 生成对应 `fetches`,如果初始化已经存在 `fetches`,则行为改为重新执行所有 **已存在的** 并行请求。
**手动触发请求**
上一节已经在初始请求时禁用了 `manual` 开启时的默认取数。下一步只要将封装的取数函数 `run` 定义出来并暴露给用户:
```tsx
const run = useCallback(
(...args: P) => {
if (fetchKeyPersist) {
const key = fetchKeyPersist(...args);
newstFetchKey.current = key === undefined ? DEFAULT_KEY : key;
}
const currentFetchKey = newstFetchKey.current;
// 这里必须用 fetchsRef,而不能用 fetches。
// 否则在 reset 完,立即 run 的时候,这里拿到的 fetches 是旧的。
let currentFetch = fetchesRef.current[currentFetchKey];
if (!currentFetch) {
const newFetch = new Fetch(
servicePersist,
config,
subscribe.bind(null, currentFetchKey),
{
data: initialData,
}
);
currentFetch = newFetch.state;
setFeches((s) => {
// eslint-disable-next-line no-param-reassign
s[currentFetchKey] = currentFetch;
return { ...s };
});
}
return currentFetch.run(...args);
},
[fetchKey, subscribe]
);
```
主动取数函数与内部取数函数共享一个,所以 `run` 函数要考虑多种情况,其中之一就是并行取数的情况,因此需要拿到当前取数的 `fetchKey`,并创建一个 `Fetch` 的实例,最终调用 `Fetch` 实例的 `run` 函数取数。
**轮询请求**
轮询取数在 `Fetch` 实际取数函数 `_fetch` 中定义,当取数函数 `fetchService`(对多种形态的取数方法进行封装后)执行完后,无论正常还是报错,都要进行轮询逻辑,因此在 `.finally` 时机里判断:
```tsx
fetchService.then().finally(() => {
if (!this.unmountedFlag && currentCount === this.count) {
if (this.config.pollingInterval) {
// 如果屏幕隐藏,并且 !pollingWhenHidden, 则停止轮询,并记录 flag,等 visible 时,继续轮询
if (!isDocumentVisible() && !this.config.pollingWhenHidden) {
this.pollingWhenVisibleFlag = true;
return;
}
this.pollingTimer = setTimeout(() => {
this._run(...args);
}, this.config.pollingInterval);
}
}
});
```
轮询还要考虑到屏幕是否隐藏,如果可以触发轮询则触发定时器再次调用 `_run`,注意这个定时器需要正常销毁。
**并行请求**
每个 `fetchKey` 对应一个 `Fetch` 实例,这个逻辑在 **手动触发请求** 介绍的 `run` 函数中已经实现。
这块的封装思路可以品味一下,从外到内分别是 React Hooks 的 fetch -> Fetch 类的 run -> Fetch 类的 \_run,并行请求做在 React Hooks 这一层。
**请求防抖、请求节流**
这个实现就在 Fetch 类的 `run` 函数中:
```tsx
function run(...args: P) {
if (this.debounceRun) {
this.debounceRun(...args);
return Promise.resolve(null as any);
}
if (this.throttleRun) {
this.throttleRun(...args);
return Promise.resolve(null as any);
}
return this._run(...args);
}
```
由于防抖和节流是 React 无关的,也不是最终取数无关的,因此实现在 `run` 这个夹层函数进行分发。
这里实现的比较简化,防抖后 `run` 拿到的 Promise 不再是有效的取数结果了,其实这块还是可以进一步对 Promise 进行封装,无论在防抖还是正常取数的场景都返回 Promise,只需 resolve 的时机由 `Fetch` 这个类灵活把控即可。
**请求预加载**
预加载就是缓存机制,首先利用 `useEffect` 同步缓存:
```tsx
// cache
useEffect(() => {
if (cacheKey) {
setCache(cacheKey, {
fetches,
newstFetchKey: newstFetchKey.current,
});
}
}, [cacheKey, fetches]);
```
在初始化 `Fetch` 实例时优先采用缓存:
```tsx
const [fetches, setFeches] = useState<Fetches<U, P>>(() => {
// 如果有 缓存,则从缓存中读数据
if (cacheKey) {
const cache = getCache(cacheKey);
if (cache) {
newstFetchKey.current = cache.newstFetchKey;
/* 使用 initState, 重新 new Fetch */
const newFetches: any = {};
Object.keys(cache.fetches).forEach((key) => {
const cacheFetch = cache.fetches[key];
const newFetch = new Fetch();
// ...
newFetches[key] = newFetch.state;
});
return newFetches;
}
}
return [];
});
```
**屏幕聚焦重新请求**
`Fetch` 构造函数实现监听并调用 `refresh` 即可,源码里采取全局统一监听的方式:
```tsx
function subscribe(listener: () => void) {
listeners.push(listener);
return function unsubscribe() {
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
};
}
let eventsBinded = false;
if (typeof window !== "undefined" && window.addEventListener && !eventsBinded) {
const revalidate = () => {
if (!isDocumentVisible()) return;
for (let i = 0; i < listeners.length; i++) {
// dispatch 每个 listener
const listener = listeners[i];
listener();
}
};
window.addEventListener("visibilitychange", revalidate, false);
// only bind the events once
eventsBinded = true;
}
```
`Fetch` 构造函数里注册:
```tsx
this.limitRefresh = limit(this.refresh.bind(this), this.config.focusTimespan);
if (this.config.pollingInterval) {
this.unsubscribe.push(subscribeVisible(this.rePolling.bind(this)));
}
```
并通过 `limit` 封装控制调用频率,并 push 到 `unsubscribe` 数组,一边监听可以随组件一起销毁。
**请求结果突变**
这个函数只要更新 `data` 数据结果即可:
```tsx
function mutate(data: any) {
if (typeof data === "function") {
this.setState({
data: data(this.state.data) || {},
});
} else {
this.setState({
data,
});
}
}
```
值得注意的是,`cancel``refresh``mutate` 都必须在初次请求完成后才有意义,所以初次返回的函数是一个抛错:
```tsx
const noReady = useCallback(
(name: string) => () => {
throw new Error(`Cannot call ${name} when service not executed once.`);
},
[]
);
return {
loading: !manual || defaultLoading,
data: initialData,
error: undefined,
params: [],
cancel: noReady("cancel"),
refresh: noReady("refresh"),
mutate: noReady("mutate"),
...(fetches[newstFetchKey.current] || {}),
} as BaseResult<U, P>;
```
等取数完成后会被 `...(fetches[newstFetchKey.current] || {})` 这一段覆盖为正常函数。
**加载延迟**
如果设置了加载延迟,请求发动时就不应该立即设置为 loading,这个逻辑写在 `_run` 函数中:
```tsx
function _run(...args: P) {
// 取消 loadingDelayTimer
if (this.loadingDelayTimer) {
clearTimeout(this.loadingDelayTimer);
}
this.setState({
loading: !this.config.loadingDelay,
params: args,
});
if (this.config.loadingDelay) {
this.loadingDelayTimer = setTimeout(() => {
this.setState({
loading: true,
});
}, this.config.loadingDelay);
}
}
```
启动一个 `setTimeout` 将 loading 设为 `true` 即可,这个 timeout 在下次执行 `_run` 时被 `clearTimeout` 清空。
**自定义请求依赖**
最明智的做法是利用 `useEffect` 实现,实际代码做了组件 unmount 保护:
```tsx
// refreshDeps 变化,重新执行所有请求
useUpdateEffect(() => {
if (!manual) {
/* 全部重新执行 */
Object.values(fetchesRef.current).forEach((f) => {
f.refresh();
});
}
}, [...refreshDeps]);
```
非手动条件下,依赖变化所有已存在的 `fetche` 执行 `refresh` 即可。
分页和加载更多就不解析了,原理是在 `useAsync` 这个基础请求 Hook 基础上再包一层 Hook,拓展取数参数与返回结果。
## 4 总结
目前还有 错误重试、请求超时管理、Suspense 没有支持,看完这篇精读后,相信你已经可以提 PR 了。
> 讨论地址是:[精读《@umijs/use-request》源码 · Issue #249 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/249)
**如果你想参与讨论,请 [点击这里](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)
+284
View File
@@ -0,0 +1,284 @@
## 1 引言
[Recoil](https://recoiljs.org/) 是 Facebook 公司出的数据流管理方案,有一定思考的价值。
Recoil 是基于 Immutable 的数据流管理方案,这也是它值得被拿出来看的最重要原因,如果要用 Mutable 方式管理 React 数据流,直接看 [mobx-react](https://github.com/mobxjs/mobx-react) 就足够了。
然而 React Immutable 特性带来的可预测性非常利于调试和维护:
1. 断点调试时变量的值与当前执行位置无关,已创建过的值不会突然 Mutable 突变,非常可预测。
2. 在 React 框架下组件更新机制单一,只有引用变化才触发重渲染,而没有 Mutable 模式下 ForceUpdate 的心智负担。
当然 Immutable 模式下存在一定编码心智负担,所以各有优劣。
> 但 Recoil 和 Redux 一样,并不代表 React 官方数据流管理方案,因此不用带着官方光环去看它。
## 2 简介
Recoil 解决 React 全局数据流管理的问题,采用分散管理原子状态的设计模式,支持派生数据与异步查询,在基本功能上可以覆盖 Redux。
### 状态作用域
和 Redux 一样,全局数据流管理需要存在作用域 `RecoilRoot`
```jsx
import React from "react";
import { RecoilRoot } from "recoil";
function App() {
return (
<RecoilRoot>
<CharacterCounter />
</RecoilRoot>
);
}
```
`RecoilRoot` 在被嵌套时,最内层的 `RecoilRoot` 会覆盖外层的配置及状态值。
### 定义数据
与 Redux 集中定义 `initState` 不同,Recoil 采用 `atom` 以分散方式定义数据:
```jsx
const textState = atom({
key: "textState",
default: "",
});
```
其中 `key` 必须在 `RecoilRoot` 作用域内唯一,也可以认为是 state 树打平时 key 必须唯一的要求。
`default` 定义默认值,既然数据定义分散了,默认值定义也是分散的。
### 读取数据
与 Redux 的 Connect 或 useSelector 类似,Recoil 采用 Hooks 方式读取数据:
```jsx
import { useRecoilValue } from "recoil";
function App() {
const text = useRecoilValue(textState);
}
```
`useRecoilValue``useSetRecoilState` 都可以获取数据,区别是 `useRecoilState` 还可以获取写数据的函数:
```jsx
import { useRecoilState } from "recoil";
function App() {
const [text, setText] = useRecoilState(useRecoilState);
}
```
### 修改数据
与 Redux 集中定义纯函数 `reducer` 修改数据不同,Recoil 采用 Hooks 方式写数据。
除了上面提到的 `useRecoilState` 之外,还有一个 `useSetRecoilState` 可以仅获取写函数:
```jsx
import { useSetRecoilState } from "recoil";
function App() {
const setText = useSetRecoilState(useRecoilState);
}
```
`useSetRecoilState``useRecoilState``useRecoilValue` 的不同之处在于,数据流的变化不会导致组件 Rerender,因为 `useSetRecoilState` 仅写不读。
这也导致 Recoil API 偏多被诟病,这也是 Immutable 模式下存的编码心智负担,虽然很好理解,但也只有 `useSelector` 或 Recoil 这样拆分 API 的方式可以解决。
> 另外还提供了 `useResetRecoilState` 重置到默认值并读取。
### 仅读不订阅
与 ReactRedux 的 `useStore` 类似,Recoil 提供了 `useRecoilCallback` 用于只读不订阅场景:
```jsx
import { atom, useRecoilCallback } from "recoil";
const itemsInCart = atom({
key: "itemsInCart",
default: 0,
});
function CartInfoDebug() {
const logCartItems = useRecoilCallback(async ({ getPromise }) => {
const numItemsInCart = await getPromise(itemsInCart);
console.log("Items in cart: ", numItemsInCart);
});
}
```
`useRecoilCallback` 通过回调方式定义要读取的数据,这个数据变化也不会导致当前组件重渲染。
### 派生值
与 Mobx `computed` 类似,recoil 提供了 `selector` 支持派生值,这是比较有特色的功能:
```jsx
import { atom, selector, useRecoilState } from "recoil";
const tempFahrenheit = atom({
key: "tempFahrenheit",
default: 32,
});
const tempCelcius = selector({
key: "tempCelcius",
get: ({ get }) => ((get(tempFahrenheit) - 32) * 5) / 9,
set: ({ set }, newValue) => set(tempFahrenheit, (newValue * 9) / 5 + 32),
});
function TempCelcius() {
const [tempF, setTempF] = useRecoilState(tempFahrenheit);
const [tempC, setTempC] = useRecoilState(tempCelcius);
}
```
`selector` 提供了 `get``set` 分别定义如何赋值与取值,所以其与 `atom` 定义一样可以被 `useRecoilState` 等三套 API 操作,这里甚至不用看源码就能猜到,`atom` 应该是基于 `selector` 的一个特定封装。
### 异步读取
基于 `selector` 可以实现异步数据读取,只要将 `get` 函数写成异步即可:
```jsx
const currentUserNameQuery = selector({
key: "CurrentUserName",
get: async ({ get }) => {
const response = await myDBQuery({
userID: get(currentUserIDState),
});
if (response.error) {
throw response.error;
}
return response.name;
},
});
function CurrentUserInfo() {
const userName = useRecoilValue(currentUserNameQuery);
return <div>{userName}</div>;
}
function MyApp() {
return (
<RecoilRoot>
<ErrorBoundary>
<React.Suspense fallback={<div>Loading...</div>}>
<CurrentUserInfo />
</React.Suspense>
</ErrorBoundary>
</RecoilRoot>
);
}
```
1. 异步状态可以被 `Suspense` 捕获。
2. 异步过程报错可以被 `ErrorBoundary` 捕获。
如果不想用 `Suspense` 阻塞异步,可以换 `useRecoilValueLoadable` 这个 API 在当前组件内管理异步状态:
```jsx
function UserInfo({ userID }) {
const userNameLoadable = useRecoilValueLoadable(userNameQuery(userID));
switch (userNameLoadable.state) {
case "hasValue":
return <div>{userNameLoadable.contents}</div>;
case "loading":
return <div>Loading...</div>;
case "hasError":
throw userNameLoadable.contents;
}
}
```
### 依赖外部变量
`reselect` 一样,Recoil 也面临状态管理不纯粹的问题,即数据读取依赖外部变量,这样会面临较为复杂的缓存计算问题,甚至还出现了 `re-reselect` 库。
因为 Recoil 本身是原子化状态管理的,所以这个问题相对好解决:
```jsx
const myMultipliedState = selectorFamily({
key: "MyMultipliedNumber",
get: (multiplier) => ({ get }) => {
return get(myNumberState) * multiplier;
},
});
function MyComponent() {
const number = useRecoilValue(myMultipliedState(100));
}
```
当外部传参 `multiplier` 与依赖值 `myNumberState` 不变时,就不会重新计算。
Recoil 在 `get``set` 函数定义 `Atom` 时,内部会自动生成依赖,这个部分做的比较好。
> 依赖外部变量使用了 Family 后缀,比如 selector -> selectorFamilyatom -> atomFamily。
## 3 精读
Recoil 以原子化方式对状态进行分离管理,确实比较契合 Immutable 的编程模式,尤其在缓存处理时非常亮眼,但编程领域中,优势换一个角度看往往就变成了劣势,我们还是要客观评价一下 Recoil。
### Immutable 心智负担
API 较多,在简介中也提到了,这可能是 Immutable 自带的硬伤,而不仅仅是 Recoil 的问题。
Immutable 模式中,对数据流只有读与写两种诉求,**而申明式编程讲究的是数据变化后 UI 自动 Rerender,那么对数据的读自然而然就被赋予了订阅其变化后触发 Rerender 的期待**,但是写与读不同,为什么 `setState` 强调用回调方式写数据?因为回调方式的写不依赖读,有写诉求的组件没必要与读挂上钩,也就是写组件的地方不一定要订阅对应数据。
Recoil 提供了 `useRecoilState` 作为读写双重 API,仅在既读又写的场景使用,而 `useRecoilValue` 仅仅是为了简化 API,替换为 `useRecoilState` 不会有性能损失,而 `useSetRecoilValue` 则必须认真对待,在仅写不读的场景必须严格使用这个 API。
`useState` 为什么默认是读写的?因为 `useState` 是单组件状态管理的场景,一个定义在组件内的状态不可能只写不读,但 Recoil 是全局状态解决方案,读写分离的场景下,对于只写的组件很有必要脱离对数据的订阅实现性能最大化。
### 条件访问数据
这也是 Hooks 的通病,由于 Hooks 不能写在条件语句中,因此要利用 Hooks 获取一个带有条件判断的数据时,必须回到 `selector` 模式:
```jsx
const articleOrReply = selectorFamily({
key: "articleOrReply",
get: ({ isArticle, id }) => ({ get }) => {
if (isArticle) {
return get(article(id));
}
return get(reply(id));
},
});
```
这样的代码其实挺冗余的,其实在 Mutable 模式下可以 `isArticle ? store.articles[id] : store.replies[id]` 就能搞定的模式,必须单独抽一个 `selector` 出来写上头十行代码,显得非常繁琐。
### Recoil 的本质
从 Hooks API 到派生值,这两个核心特点恰巧是对 Context 与 useMemo 的封装。
首先基于 Hooks 的 `useContext` 已经足够轻量易用,可以认为 `atom``useRecoilState``useRecoilValue``useSetRecoilValue` 分别对应封装后的 `createContext``useContext`
再看 `useMemo`,大部分情况我们可以利用 `useMemo` 造出派生值,这对应了 Recoil 的 `selector``selectorFamily`
所以 Recoil 本质更像一个模式化封装库,针对数据驱动易于数据原子化管理的场景,并做到高性能。
## 3 总结
无论你用不用 Recoil,我们都可以从 Recoil 这儿学到 React 状态管理的基本功:
1. 对象的读与写分离,做到最优按需渲染。
2. 派生的值必须严格缓存,并在命中缓存时引用保证严格相等。
3. 原子存储的数据相互无关联,所有关联的数据都使用派生值方式推导。
> 讨论地址是:[精读《recoil》· Issue #251 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/251)
**如果你想参与讨论,请 [点击这里](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)
+175
View File
@@ -0,0 +1,175 @@
## 1 引言
基于 webpack 构建的大型项目开发速度已经非常慢了,前端开发者已经逐渐习惯忍受超过 100 秒的启动时间,超过 30 秒的 reload 时间。即便被寄予厚望的 webpack5 内置了缓存机制也不会得到质的提升。但放到十年前,等待时间是几百毫秒。
好在浏览器支持了 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 模块化加载方案,终于原生支持了文件模块化,这使得本地构建不再需要处理模块化关系并聚合文件,这甚至可以将构建时间从 30 秒降低到 300 毫秒。
当然基于 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 的构建框架不止 [snowpack](https://www.snowpack.dev/) 一个,还有比如基于 vue 的 [vite](https://github.com/vitejs/vite),因为浏览器支持模块化是一个标准,而不与任何框架绑定,未来任何构建工具都会基于此特性开发,这意味着在未来的五年,前端构建一定会回到十年前的速度,这个趋势是明显、确定的。
[ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 带来的最直观的改变有下面三点:
1. `node_modules` 完全不需要参与到构建过程,仅这一点就足以让构建效率提升至少 10 倍。
2. 模块化交给浏览器管理,修改任何组件都只需做单文件编译,时间复杂度永远是 O(1),reload 时间与项目大小无关。
3. 浏览器完全模块化加载文件,不存在资源重复加载问题,这种原生的 TreeShaking 还可以做到访问文件时再编译,做到单文件级别的按需构建。
所以可以说 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 模式下的开发效率,能做到与十年前修改 HTML 单文件的零构建效率几乎相当。
## 2 简介 & 精读
snowpack 核心特征:
- 开发模式启动仅需 50ms 甚至更少。
- 热更新速度非常快。
- 构建时可以结合任何 bundler,比如 webpack。
- 内置支持 TS、JSX、CSS Modules 等。
- 支持自定义构建脚本以及三方插件。
### 安装
```bash
yarn add --dev snowpack
```
通过 `snowpack.config.json` 文件配置,并能自动读取 `babel.config.json` 生效 babel 插件。
### 开发调试
调试 `snowpack dev`,编译 `snowpack build`,会自动以 `src/index` 作为应用入口进行编译。
`snowpack dev` 命令几乎是零耗时的,因为文件仅会在被浏览器访问时进行按需编译,因此构建速度是理想的最快速。
当浏览器访问文件时,snowpack 会将文件做如下转换:
```jsx
// Your Code:
import * as React from "react";
import * as ReactDOM from "react-dom";
// Build Output:
import * as React from "/web_modules/react.js";
import * as ReactDOM from "/web_modules/react-dom.js";
```
目的就是生成一个相对路径,并启动本地服务让浏览器可以访问到这些被 import 的文件。其中 `web_modules` 是 snowpack 对 `node_modules` 构建的结果。
在这之前也会对 Typescript 文件做 tsc 编译,或者 babel 编译。
### 编译
编译命令 `snowpack build` 默认方式与 `snowpack dev` 相同:
<img width=500 src="https://img.alicdn.com/tfs/TB1QeckIuH2gK0jSZJnXXaT1FXa-1467-368.png">
也可以指定以 webpack 作为构建器:
```json
// snowpack.config.json
{
// Optimize your production builds with Webpack
"plugins": [
[
"@snowpack/plugin-webpack",
{
/* ... */
}
]
]
}
```
除了默认构建方式之外,还支持自定义文件处理,通过 `snowpack.config.json` 配置 `scripts` 指定:
```json
{
"extends": "@snowpack/app-scripts-react",
"scripts": {
"build:scss": "sass $FILE"
},
"plugins": []
}
```
比如上述语法支持了对 `scss` 文件编译的拓展。
**"build:\*": "..."**
对文件后缀进行编译,比如:`"build:js,jsx": "babel --filename $FILE"` 指定了对 `js,jsx` 后缀的文件进行 babel 构建。
**"run:\*": "..."**
仅执行一次,可以用来做 lint,也可以用来配合批量文件处理命令,比如 `tsc`: `"run:tsc": "tsc"`
**"mount:\*": "mount DIR [--to /PATH]"**
将文件部署到某个 URL 地址,比如 `"mount:public": "mount public --to /"` 意味着将 `public` 文件夹下的文件部署到 `/` 这个 URL 地址。
还有 `proxy` 等 API 就不一一列举了,详细可以见 [官方文档](https://www.snowpack.dev/)。
我们可以从构建命令体会到 snowpack 的理念,**将源码以流式方式编译后,直接部署到本地 server 提供的 URL 地址,浏览器通过一个 main 入口以 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 的方式加载这些文件。**
所以所有加载与构建逻辑都是按需的,snowpack 要做的只是将本地文件逐个构建好并启动本地服务给浏览器调用。
前端开发离不开 `node_modules`snowpack 通过 `snowpack install` 的方式支持了这一点。
### snowpack install
这个命令已经被 `snowpack dev` 内置了,所以 `snowpack install` 仅用来理解原理。
以下是 `snowpack install` 执行的结果:
```js
snowpack install complete. [0.88s]
⦿ web_modules/ size gzip brotli
react-dom.js 128.93 KB 39.89 KB 34.93 KB
react.js 0.54 KB 0.32 KB 0.28 KB
⦿ web_modules/common/ (Shared)
index-8961bd84.js 10.83 KB 3.96 KB 3.51 KB
```
可以看到,`snowpack` 遍历项目源码对 `node_modules` 的访问,并对 `node_modules` 进行了 Web 版 `install`,可以认为 `npm install` 是将 npm 包安装到了本地,而 `snowpack install` 是将 `node_modules` 安装到了 Web API,所以这个命令只需构建一次,`node_modules` 就变成了可以按需被浏览器加载的静态资源文件。
同时源码中对 npm 包的引用都会转换为对 `web_modules` 这个静态资源地址的引用:
```jsx
import * as ReactDOM from "react-dom";
// 转换
import * as React from "/web_modules/react.js";
```
但同时可以看到 snowpack 对前端生态的高要求,如果某些包通过 webpack 别名设置了一些 magic 映射,就无法通过文件路径直接映射,所以 snowpack 生态成熟需要一段时间,但模块标准化一定是趋势,不规范的包在未来几年内会逐步被淘汰。
### 2020 年适合使用 snowpack 吗
答案是还不适合用在生产环境。
当然用在开发环境还是可以的,但需要承担三个风险:
1. 开发与生产环境构建结果不一致的风险。
2. 项目生态存在非 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 模块化包而导致大量适配成本的风险。
3. 项目存在大量 webpack 插件的 magic 魔法,导致标准化后丢失定制打包逻辑的风险。
但可以看到,这些风险的原因都是非标准化造成的。我们站在 2020 年看以前浏览器非标准化 API 适配与兼容工作,可能会觉得不可思议,为什么要与那些陈旧非标准化的语法做斗争;相应的,2030 年看 2020 年的今天可能也觉得不可思议,为什么很多项目存在大量 magic 自定义构建逻辑,明明标准化构建逻辑已经完全够用了 :P。
所以我们要看到未来的趋势,也要理解当下存在的问题,不要在生态尚未成熟的时候贸然使用,但也要跟进前端规范化的步伐,在合适的时机跟上节奏,毕竟 bundleless 模式带来的开发效率提升是非常明显的。
## 3 总结
前端发展到 2020 年这个时间点,代码规范已经基本稳定,工程化要做的事情已经从新增功能逐渐转移到研发提效上了,因此提升开发时热更新速度、构建速度是当下前端工程化的重中之重。
snowpack 代表的 bundleless 方案肯定是光明的未来,带来的构建提效非常明显,人力充足的前端团队与不需要考虑浏览器兼容性的敏捷小团队都已经开始实践 bundleless 方案了。
但对于业务需要兼容各浏览器的大团队来说,目前 bundleless 方案仅可用于开发环境,生产环境还是需要 webpack 打包,因此 webpack 生态还可以继续繁荣几年,直到大的前端团队也抛弃它为止。
如果看未来十年,可能前端工程化构建脚本都不需要了,浏览器可以直接运行源码。在这一点上,以 snowpack 为代表的 bundleless 模式着实跨越了一大步。
> 讨论地址是:[精读《snowpack》· Issue #252 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/252)
**如果你想参与讨论,请 [点击这里](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,398 @@
## 1 引言
BI 平台是阿里数据中台团队非常重要的平台级产品,要保证报表编辑与浏览的良好体验,性能优化是必不可少的。
当前 BI 工具普遍是报表形态,要知道报表形态可不仅仅是一张张图表组件,与这些组件关联的筛选条件和联动关系错综复杂,任何一个筛选条件变化就会导致其关联项重新取数并重渲染组件,而报表数据量非常大,一个表格组件加载百万量级的数据稀松平常,为了维持这么大量级数据量下的正常展示,按需渲染是必须要做的功课。
这里说的按需渲染不是指 ListView 无限滚动,因为报表的布局模式有流式布局、磁贴布局和自由布局三套,每种布局风格差异很大,无法用固定的公式计算组件是否可见,因此我们选择初始化组件全量渲染,阻止非首屏内组件的重渲染。因为初始条件下还没有获取数据,全量渲染不会造成性能问题,这是这套方案成立的前提。
所以我今天就专门介绍如何利用 DOM 判断组件在画布中是否可见这个技术方案,从架构设计与代码抽象的角度一步步分解,不仅希望你能轻松理解这个技术方案如何实现,也希望你能掌握这其中的诀窍,学会举一反三。
## 2 精读
我们以 React 框架为例,做按需渲染的思维路径是这样的:
得到组件 `active` 状态 -> 阻塞非 `active` 组件的重渲染。
这里我选择从结果入手,先考虑如何阻塞组件渲染,再一步步推导出判断组件是否可见这个函数怎么写。
### 阻塞组件重渲染
我们需要一个 `RenderWhenActive` 组件,支持一个 `active` 参数,当 `active` 为 true 时这一层是透明的,当 `active` 为 false 时阻塞所有渲染。
再具体描述一下,其效果是这样的:
1. inActive 时,任何 props 变化都不会导致组件渲染。
2. 从 inActive 切换到 active 时,之前作用于组件的 props 要立即生效。
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
}
}
```
### 获取组件 active 状态
在进一步思考之前,我们先不要掉到 “如何判断组件是否显示” 这个细节中,可以先假设 “已经有了这样一个函数”,我们应该如何调用。
很显然我们需要一个自定义 Hook`useActive` 判断组件是否是激活态,并拿到 `active` 返回值传递给 `RenderWhenActive` 组件:
```jsx
const ComponentLoader = ({ children }) => {
const active = useActive();
return <RenderWhenActive active={active}>{children}</RenderWhenActive>;
};
```
这样,渲染引擎利用 `ComponentLoader` 渲染的任何组件就具备了按需渲染的功能。
### 实现 useActive
到现在,组件与 Hook 侧的流程已经完整串起来了,我们可以聚焦于如何实现 `useActive` 这个 Hook。
利用 Hooks 的 API,可以在组件渲染完毕后利用 `useEffect` 判断组件是否 Active,并利用 `useState` 存储这个状态:
```jsx
export function useActive(domId: string) {
// 所有元素默认 unActive
const [active, setActive] = React.useState(false);
React.useEffect(() => {
const visibleObserve = new VisibleObserve(domId, "rootId", setActive);
visibleObserve.observe();
return () => visibleObserve.unobserve();
}, [domId]);
return active;
}
```
初始化时,所有组件 active 状态都是 false,然而这种状态在 `shouldComponentUpdate` 并不会阻塞第一次渲染,因此组件的 dom 节点初始化仍会渲染出来。
`useEffect` 阶段注册了 `VisibleObserve` 这个自定义 Class,用来监听组件 dom 节点在其父级节点 `rootId` 内是否可见,并在状态变更时通过第三个回调抛出,这里将 `setActive` 作为第三个参数,可以及时改变当前组件 active 状态。
`VisibleObserve` 这个函数拥有 `observe``unobserve` 两个 API,分别是启动监听与取消监听,利用 `useEffect` 销毁时执行 return callback 的特性,监听与销毁机制也完成了。
下一步就是如何实现最核心的 `VisibleObserve` 函数,用来监听组件是否可见。
### 监听组件是否可见的准备工作
在实现 `VisibleObserve` 之前,想一下有几种方法实现呢?可能你脑海中冒出了很多种奇奇怪怪的方案。是的,判断组件在某个容器内是否可见有许多种方案,即便从功能上能找到最优解,但从兼容性角度来看也无法找到完美的方案,因此这是一个拥有多种实现可能性的函数,在不同版本的浏览器采用不同方案才是最佳策略。
处理这种情况的方法之一,就是做一个抽象类,让所有实际方法都继承并实现抽象类,这样我们就拥有了多套 “相同 API 的不同实现”,以便在不同场景随时切换使用。
利用 `abstract` 创建抽象类 `AVisibleObserve`,实现构造函数并申明两个 public 的重要函数 `observe``unobserve`
```jsx
/**
* 监听元素是否可见的抽象类
*/
abstract class AVisibleObserve {
/**
* 监听元素的 DOM ID
*/
protected targetDomId: string;
/**
* 可见范围根节点 DOM ID
*/
protected rootDomId: string;
/**
* Active 变化回调
*/
protected onActiveChange: (active?: boolean) => void;
constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
this.targetDomId = targetDomId;
this.rootDomId = rootDomId;
this.onActiveChange = onActiveChange;
}
/**
* 开始监听
*/
abstract observe(): void;
/**
* 取消监听
*/
abstract unobserve(): void;
}
```
这样我们就可以实现多套方案。稍加思索可以发现,我们只要两套方案,一套是利用 `setInterval` 实现的轮询检测的笨方法,一种是利用浏览器高级 API `IntersectionObserver` 实现的新潮方法,由于后者有兼容性要求,前者就作为兜底方案实现。
因此我们可以定义两套对应方法:
```jsx
class IntersectionVisibleObserve extends AVisibleObserve {
constructor(/**/) {
super(targetDomId, rootDomId, onActiveChange);
}
observe() {
// balabala..
}
unobserve() {
// balabala..
}
}
class SetIntervalVisibleObserve extends AVisibleObserve {
constructor(/**/) {
super(targetDomId, rootDomId, onActiveChange);
}
observe() {
// balabala..
}
unobserve() {
// balabala..
}
}
```
最后再做一个总类作为调用入口:
```jsx
/**
* 监听元素是否可见总类
*/
export class VisibleObserve extends AVisibleObserve {
/**
* 实际 VisibleObserve 类
*/
private actualVisibleObserve: AVisibleObserve = null;
constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
super(targetDomId, rootDomId, onActiveChange);
// 根据浏览器 API 兼容程度选用不同 Observe 方案
if ('IntersectionObserver' in window) {
// 最新 IntersectionObserve 方案
this.actualVisibleObserve = new IntersectionVisibleObserve(targetDomId, rootDomId, onActiveChange);
} else {
// 兼容的 SetInterval 方案
this.actualVisibleObserve = new SetIntervalVisibleObserve(targetDomId, rootDomId, onActiveChange);
}
}
observe() {
this.actualVisibleObserve.observe();
}
unobserve() {
this.actualVisibleObserve.unobserve();
}
}
```
在构造函数就判断了当前浏览器是否支持 `IntersectionObserver` 这个 API,然而无论何种方案创建的实例都继承于 `AVisibleObserve`,所以我们可以用统一的 `actualVisibleObserve` 成员变量存放。
`observe``unobserve` 阶段都可以无视具体类的实现,直接调用 `this.actualVisibleObserve.observe()``this.actualVisibleObserve.unobserve()` 这两个 API。
这里体现的思想是,父类关心接口层 API,子类关心基于这套接口 API 如何具体实现。
接下来我们看看低配版(兼容)与高配版(原生)分别如何实现。
### 监听组件是否可见 - 兼容版本
兼容版本模式中,需要定义一个额外成员变量 `interval` 存储 SetInterval 引用,在 `unobserve` 的时候 `clearInterval`
其判断可见函数我抽象到了 `judgeActive` 函数中,核心思想是判断两个矩形(容器与要判断的组件)是否存在包含关系,如果包含成立则代表可见,如果包含不成立则不可见。
下面是完整实现函数:
```jsx
class SetIntervalVisibleObserve extends AVisibleObserve {
/**
* Interval 引用
*/
private interval: number;
/**
* 检查是否可见的时间间隔
*/
private checkInterval = 1000;
constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
super(targetDomId, rootDomId, onActiveChange);
}
/**
* 判断元素是否可见
*/
private judgeActive() {
// 获取 root 组件 rect
const rootComponentDom = document.getElementById(this.rootDomId);
if (!rootComponentDom) {
return;
}
// root 组件 rect
const rootComponentRect = rootComponentDom.getBoundingClientRect();
// 获取当前组件 rect
const componentDom = document.getElementById(this.targetDomId);
if (!componentDom) {
return;
}
// 当前组件 rect
const componentRect = componentDom.getBoundingClientRect();
// 判断当前组件是否在 root 组件可视范围内
// 长度之和
const sumOfWidth =
Math.abs(rootComponentRect.left - rootComponentRect.right) + Math.abs(componentRect.left - componentRect.right);
// 宽度之和
const sumOfHeight =
Math.abs(rootComponentRect.bottom - rootComponentRect.top) + Math.abs(componentRect.bottom - componentRect.top);
// 长度之和 + 两倍间距(交叉则间距为负)
const sumOfWidthWithGap = Math.abs(
rootComponentRect.left + rootComponentRect.right - componentRect.left - componentRect.right,
);
// 宽度之和 + 两倍间距(交叉则间距为负)
const sumOfHeightWithGap = Math.abs(
rootComponentRect.bottom + rootComponentRect.top - componentRect.bottom - componentRect.top,
);
if (sumOfWidthWithGap <= sumOfWidth && sumOfHeightWithGap <= sumOfHeight) {
// 在内部
this.onActiveChange(true);
} else {
// 在外部
this.onActiveChange(false);
}
}
observe() {
// 监听时就判断一次元素是否可见
this.judgeActive();
this.interval = setInterval(this.judgeActive, this.checkInterval);
}
unobserve() {
clearInterval(this.interval);
}
}
```
根据容器 `rootDomId` 与组件 `targetDomId`,我们可以拿到其对应 DOM 实例,并调用 `getBoundingClientRect` 拿到其对应矩形的位置与宽高。
算法思路如下:
设容器为 root,组件为 component。
1. 计算 root 与 component 长度之和 `sumOfWidth` 与宽度之和 `sumOfHeight`
2. 计算 root 与 component 长度之和 + 两倍间距 `sumOfWidthWithGap` 与 宽度之和 + 两倍间距 `sumOfHeightWithGap`
3. `sumOfWidthWithGap - sumOfWidth` 的差值就是横向 gap 距离,`sumOfHeightWithGap - sumOfHeight` 的差值就是横向 gap 距离,两个值都为负数表示在内部。
其中的关键是,从横向角度来看,下面的公式可以理解为宽度之和 + 两倍的宽度间距:
```jsx
// 长度之和 + 两倍间距(交叉则间距为负)
const sumOfWidthWithGap = Math.abs(
rootComponentRect.left +
rootComponentRect.right -
componentRect.left -
componentRect.right
);
```
`sumOfWidth` 是宽度之和,这之间的差值就是两倍间距值,正数表示横向没有交集。当横纵两个交集都是负数时,代表存在交叉或者包含在内部。
### 监听组件是否可见 - 原生版本
如果浏览器支持 `IntersectionObserver` 这个 API 就好办多了,以下是完整代码:
```jsx
class IntersectionVisibleObserve extends AVisibleObserve {
/**
* IntersectionObserver 实例
*/
private intersectionObserver: IntersectionObserver;
constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
super(targetDomId, rootDomId, onActiveChange);
this.intersectionObserver = new IntersectionObserver(
changes => {
if (changes[0].intersectionRatio > 0) {
onActiveChange(true);
} else {
onActiveChange(false);
// 因为虚拟 dom 更新导致实际 dom 更新,也会在此触发,判断 dom 丢失则重新监听
if (!document.body.contains(changes[0].target)) {
this.intersectionObserver.unobserve(changes[0].target);
this.intersectionObserver.observe(document.getElementById(this.targetDomId));
}
}
},
{
root: document.getElementById(rootDomId),
},
);
}
observe() {
if (document.getElementById(this.targetDomId)) {
this.intersectionObserver.observe(document.getElementById(this.targetDomId));
}
}
unobserve() {
this.intersectionObserver.disconnect();
}
}
```
通过 `intersectionRatio > 0` 就可以判断元素是否出现在父级容器中,如果 `intersectionRatio === 1` 则表示组件完整出现在容器内,此处我们的要求是任意部分出现就 active。
有一点要注意的是,这个判断与 SetInterval 不同,由于 React 虚拟 DOM 可能会更新 DOM 实例,导致 `IntersectionObserver.observe` 监听的 DOM 元素被销毁后,导致后续监听失效,因此需要在元素隐藏时加入下面的代码:
```jsx
// 因为虚拟 dom 更新导致实际 dom 更新,也会在此触发,判断 dom 丢失则重新监听
if (!document.body.contains(changes[0].target)) {
this.intersectionObserver.unobserve(changes[0].target);
this.intersectionObserver.observe(document.getElementById(this.targetDomId));
}
```
1. 当元素判断不在可视区域时,也包含了元素被销毁。
2. 因此通过 `body.contains` 判断元素是否被销毁,如果被销毁则重新监听新的 DOM 实例。
## 3 总结
总结一下,按需渲染的逻辑的适用面不仅仅在渲染引擎,但对于 ProCode 场景直接编写的代码中,要加入这段逻辑就显得侵入性较强。
或许可视区域内按需渲染可以做到前端开发框架内部,虽然不属于标准框架功能,但也不完全属于业务功能。
这次留下一个思考题,如果让手写的 React 代码具备按需渲染功能,怎么设计更好呢?
> 讨论地址是:[精读《用 React 做按需渲染》· Issue #254 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/254)
**如果你想参与讨论,请 [点击这里](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)