Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9867c1ee9d | ||
|
|
e8a0539984 | ||
|
|
685e8ba32a | ||
|
|
6c9493df7b | ||
|
|
1994438792 | ||
|
|
60be3e354b | ||
|
|
9d0bb5c996 | ||
|
|
304dc5fc36 | ||
|
|
819b6d7452 | ||
|
|
b066c8a5d7 | ||
|
|
80fb60c6fb | ||
|
|
6a2031899d | ||
|
|
aedcc2fd56 | ||
|
|
757eb403ac | ||
|
|
87c2745395 | ||
|
|
0d3d8ef54c | ||
|
|
53d5ee1960 | ||
|
|
92a8dfc55c | ||
|
|
c59f46cc1d | ||
|
|
ef3904a2f6 | ||
|
|
994c5844d9 | ||
|
|
3881e51b08 | ||
|
|
815ae1367a | ||
|
|
84ed47dbdf | ||
|
|
252980724a | ||
|
|
225aab476d | ||
|
|
f065539266 | ||
|
|
f8d81fc3f5 | ||
|
|
43e264cc07 | ||
|
|
957737959f | ||
|
|
000e6511e6 | ||
|
|
a652284bbc | ||
|
|
1b88e5ac06 | ||
|
|
79ba9ec6a1 | ||
|
|
a4acf09d8b | ||
|
|
872ef2e346 | ||
|
|
8795ae0aaa | ||
|
|
23c4190d7e | ||
|
|
75d0109102 | ||
|
|
893bb2d97a | ||
|
|
0c2c8c6c03 | ||
|
|
97a313ca1e | ||
|
|
3fd57e26b8 | ||
|
|
aee6f6d6bb | ||
|
|
00fd6e541a | ||
|
|
6cc1af4ca9 | ||
|
|
ed27df0fe2 | ||
|
|
14ebdf19ca | ||
|
|
e89a1b0c7e | ||
|
|
a1bd0ec7a5 | ||
|
|
82d3665606 | ||
|
|
67a289e750 | ||
|
|
1b0a7ed8ea | ||
|
|
21ba3e0640 | ||
|
|
d5159b914e | ||
|
|
c45a3d09d5 | ||
|
|
c3ea6f2b17 | ||
|
|
fb98d1febc | ||
|
|
036a9779eb | ||
|
|
beb89cf31b | ||
|
|
5d65a777bd | ||
|
|
115d108404 | ||
|
|
f573e16473 | ||
|
|
ac53e3a325 | ||
|
|
d8f2e0977d | ||
|
|
f4e75f5e21 |
+1
-1
@@ -44,7 +44,7 @@
|
||||
|
||||
### 模态框定位
|
||||
|
||||
首先,Model 与 Toast、Notification、Message 以及 Popover 都会在某个时间点被触发弹出一个浮层,但与 Modal(模态框)还是有所不同的。定义上看,上述组件都不属于模态框,因为模态框有一个重要的特性,即阻塞原来主视窗下的操作,只能在框内作后续动作。也就是说模态框从界面上彻底打断了用户心流。
|
||||
首先,Modal 与 Toast、Notification、Message 以及 Popover 都会在某个时间点被触发弹出一个浮层,但与 Modal(模态框)还是有所不同的。定义上看,上述组件都不属于模态框,因为模态框有一个重要的特性,即阻塞原来主视窗下的操作,只能在框内作后续动作。也就是说模态框从界面上彻底打断了用户心流。
|
||||
|
||||
当然,这也是我们需要讨论的问题,如果只是一般的消息提醒,可以用信息条、小红点等交互形式,至少是不阻塞用户操作的。在原文末引用的 10 Guidelines to Consider when using Overlays 一文中,第 8 条强调了模态框不到万不得以不应该使用。这时我们应该思考什么情况下你非常希望他不要离开页面,来读框内的信息或作操作呢?
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ SELECT * from bees WHERE bee = 'red';
|
||||
|
||||
但是当我们将文法粒度变细,将 `CASE WHEN` 与 `WHERE` 区块分别交由两块文法解决,将等号这个通用的表达式抽离出来,就可以不关心上下文了,这种方式称为 **上下文无关文法**。
|
||||
|
||||
附上一个 [mysql 上下文无关文法集合](https://github.com/antlr/grammars-v4/blob/master/mysql/MySqlParser.g4)。
|
||||
附上一个 [mysql 上下文无关文法集合](https://github.com/antlr/grammars-v4/blob/master/sql/mysql/Positive-Technologies/MySqlParser.g4)。
|
||||
|
||||
### 左推导与右推导
|
||||
|
||||
|
||||
@@ -456,7 +456,7 @@ function Article({ id }) {
|
||||
|
||||
## useEffect 还有什么优势
|
||||
|
||||
`useEffect` 在渲染结束时执行,所以不会阻塞浏览器渲染进程,所以使用 Function Component 写的项目一般都有用更好的性能。
|
||||
`useEffect` 在渲染结束时执行,所以不会阻塞浏览器渲染进程,所以使用 Function Component 写的项目一般都拥有更好的性能。
|
||||
|
||||
自然符合 React Fiber 的理念,因为 Fiber 会根据情况暂停或插队执行不同组件的 Render,如果代码遵循了 Capture Value 的特性,在 Fiber 环境下会保证值的安全访问,同时弱化生命周期也能解决中断执行时带来的问题。
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
## 1 引言
|
||||
|
||||
本周精读的文章是 [Mastering JS console.log like a Pro](https://medium.com/javascript-in-plain-english/mastering-js-console-log-like-a-pro-1c634e6393f9),一起来更全面的认识 console 吧!
|
||||
|
||||
## 2 概述 & 精读
|
||||
|
||||
console 的功能主要在于控制台打印,它可以打印任何字符、对象、甚至 DOM 元素和系统信息,下面一一介绍。
|
||||
|
||||
### console.log( ) | info( ) | debug( ) | warn( ) | error( )
|
||||
|
||||
直接打印字符,区别在于展示形态的不同:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1xZ_WveH2gK0jSZFEXXcqMpXa-1492-566.png">
|
||||
|
||||
新版 chrome 控制台可以将打印信息分类:
|
||||
|
||||
<img width=200 src="https://img.alicdn.com/tfs/TB1fZ2Vvhn1gK0jSZKPXXXvUXXa-420-446.png">
|
||||
|
||||
`log()` 与 `info()` 都对应 `info`,`warn()` 对应 `warnings`,`error()` 对应 `errors`,而 `debug()` 对应 `verbose`,因此建议在合适的场景使用合适的打印习惯,这样排查问题时也可以有针对性的筛选。
|
||||
|
||||
比如调试信息可以用 `console.debug` 仅在调试环境下输出,调试者即便开启了调试参数也不会影响正常 `info` 的查看,因为调试信息都输出在 `verbose` 中。
|
||||
|
||||
### 使用占位符
|
||||
|
||||
- %o — 对象
|
||||
- %s — 字符串
|
||||
- %d — 数字
|
||||
|
||||
如下所示,可通过占位符在一行中插入不同类型的值:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1GtL3vlr0gK0jSZFnXXbRRXXa-1840-504.png">
|
||||
|
||||
### 添加 CSS 样式
|
||||
|
||||
- %c - 样式
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1eK23vlr0gK0jSZFnXXbRRXXa-1832-978.png">
|
||||
|
||||
可以总结出,**console 支持输出复杂的内容,其输出能力堪比 HTML,但输入能力太弱,仅为字符串,因此采用了占位符 + 多入参修饰的设计模式解决这个问题。**
|
||||
|
||||
### console.dir( )
|
||||
|
||||
按 JSON 模式输出。笔者在这里也补充一句:`console.log()` 会自动判断类型,如果内容是 DOM 属性,则输出 DOM 树,但 `console.dir` 会强制以 JSON 模式输出,用在 DOM 对象时可强制转换为 JSON 输出。
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1KQY1vbj1gK0jSZFuXXcrHpXa-922-302.png">
|
||||
|
||||
### 输出 HTML 元素
|
||||
|
||||
按照 HTML ELements 结构输出:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1mZ61va61gK0jSZFlXXXDKFXa-920-255.png">
|
||||
|
||||
这种输出结构和 Elements 打印形式是一致的,如果要看详细属性,可以使用 `console.dir()`。
|
||||
|
||||
### console.table
|
||||
|
||||
在控制台打印一个表格,属于功能增强。虽然仅文本也可以在控制台打印出漂亮的表格,但浏览器调试控制台的功能更强大,`console.table` 只是其富文本能力的一个体现。
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1WldouKbviK0jSZFNXXaApXXa-928-742.png">
|
||||
|
||||
### console.group( ) & console.groupEnd( )
|
||||
|
||||
接下来是另一个富文本能力,按分组输出:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1UV6UvXY7gK0jSZKzXXaikpXa-919-377.png">
|
||||
|
||||
这种带有副作用的 API 显然是为方便阅读而设计的,然而在需要输出大量动态结构化数据的场景下,还需要进行结构转换,是比较麻烦的地方。
|
||||
|
||||
### console.count( )
|
||||
|
||||
`count()` 用来打印调用次数,一般用在循环或递归函数中。接收一个 `label` 参数以定制输出,默认直接输出 `1 2 3` 数字。
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1ELLVveL2gK0jSZPhXXahvXXa-917-500.png">
|
||||
|
||||
### console.assert( )
|
||||
|
||||
`console` 版断言工具,当且仅当第一个参数值为 `false` 时才打印第二个参数作为输出。
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1HEDUvfb2gK0jSZK9XXaEgFXa-1842-548.png">
|
||||
|
||||
这种输出结果为 error,所以也可被 `console.error` + 代码级别断言所取代。
|
||||
|
||||
### console.trace( )
|
||||
|
||||
打印此时的调用栈,在打印辅助调试信息时非常有用。
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1Jh_YvkL0gK0jSZFAXXcA9pXa-1840-1096.png">
|
||||
|
||||
### console.time( )
|
||||
|
||||
打印代码执行时间,性能优化和监控场景比较常见。
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1wAT2vbj1gK0jSZFuXXcrHpXa-1612-524.png">
|
||||
|
||||
### console.memory
|
||||
|
||||
打印内存使用情况。
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1tPHYvkL0gK0jSZFAXXcA9pXa-1842-440.png">
|
||||
|
||||
### console.clear( )
|
||||
|
||||
清空控制台输出。
|
||||
|
||||
## 3 总结
|
||||
|
||||
`console` 提供了如此多的输出规范,其实也是在变相制定开发规范,毕竟离开发者最近的就是调试控制台,如果你的项目打印规范与标准规范有差异,那么调试时信息看起来就会很别扭。
|
||||
|
||||
可以看到,大部分开源库都良好的遵循了这套规范,比如三方库绝不会输出 `log()`,而且将错误、警告与调试信息正确分开,并尽量少的用 CSS 样式、分组、`table` 等功能,因为这些功能干扰性较强,不能保证所有用户都可接受。
|
||||
|
||||
相对的,项目源码就比较适合使用一些醒目的自定义规范,只要这套规则能被很好的执行起来。
|
||||
|
||||
最后留下一个讨论点:`console` 可以作为调试、招聘信息、隐藏菜单的投放点,你还看到过哪些有意思的 `console` 使用方式呢?欢迎留言。
|
||||
|
||||
> 讨论地址是:[精读《精通 console.log》 · Issue #228 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/228)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,279 @@
|
||||
## 1 引言
|
||||
|
||||
`JSON.parse` 是浏览器内置的 API,但如果面试官让你实现一个怎么办?好在有人已经帮忙做了这件事,本周我们一起精读这篇 [JSON Parser with Javascript](https://lihautan.com/json-parser-with-javascript/) 文章吧,再温习一遍大学时编译原理相关知识。
|
||||
|
||||
## 2 概述 & 精读
|
||||
|
||||
要解析 JSON 首先要理解语法概念,之前的 [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) 系列也有介绍过,不过本文介绍的更形象,看下面这个语法图:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1EbjfvQL0gK0jSZFtXXXQCXXa-1837-857.png">
|
||||
|
||||
这是关于 Object 类型的语法描述图,从左向右看,根据箭头指向只要能走出这个迷宫就属于正确语法。
|
||||
|
||||
比如第一行 `{` → `whitespace` → `}` 表示 `{ }` 属于合法的 JSON 语法。
|
||||
|
||||
再比如观察向下的一条最长路线:`{` → `whitespace` → `string` → `whitespace` → `:` → `value` → `}` 表示 `{ string : value }` 属于合法的 JSON 语法。
|
||||
|
||||
你可能会问,双引号去哪儿了?这就是语法树最核心的概念了,这张图是关于 Object 类型的 **产生式**,同理还有 string、value 的产生式,产生式中可以嵌套其他产生式,甚至形成环路,以此拥有描述纷繁多变语法的能力。
|
||||
|
||||
最后我们再看一个环路,即 `{` → `whitespace` → `string` ... `,` → `whitespace` → `string` ... `,` ... `}`,我们发现,只要不走回头路,这条路是可以一直 “绕圈” 下去的,因此 Object 类型拥有了任意数量子字段的能力,只是每形成一个子字段,必须经过 `,` 号分割。
|
||||
|
||||
### 实现 Parser
|
||||
|
||||
首先实现一个基本结构:
|
||||
|
||||
```js
|
||||
function fakeParseJSON(str) {
|
||||
let i = 0;
|
||||
// TODO
|
||||
}
|
||||
```
|
||||
|
||||
`i` 表示访问字符的下标,当 `i` 走到字符串结尾表示遍历结束。
|
||||
|
||||
然后是下一步,用几个函数描述解析语法的过程:
|
||||
|
||||
```js
|
||||
function fakeParseJSON(str) {
|
||||
let i = 0;
|
||||
function parseObject() {
|
||||
if (str[i] === '{') {
|
||||
i++;
|
||||
skipWhitespace();
|
||||
|
||||
// if it is not '}',
|
||||
// we take the path of string -> whitespace -> ':' -> value -> ...
|
||||
while (str[i] !== '}') {
|
||||
const key = parseString();
|
||||
skipWhitespace();
|
||||
eatColon();
|
||||
const value = parseValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中 `skipWhitespace` 表示匹配并跳过空格,所谓匹配意味着匹配成功,此时 `i` 下标可以继续后移,否则匹配失败。下一步则判断如果 `i` 不是结束标志 `}`,则按照 `parseString` 匹配字符串 → `skipWhitespace` 跳过空格 → `eatColon` 吃掉冒号 → `parseValue` 匹配值,这个链路循环。其中吃掉冒号表示 “匹配冒号但不会产生任何结果,所以就像吃掉了一样”,吃这个动作还可以用在其他场景,比如吃掉尾分号。
|
||||
|
||||
> 对于看到这儿的小伙伴,笔者要友情提示一下,原文的思路是一种定制语法解析思路,无论是 `eatColon` 还是 `parseValue` 都仅具备解析 JSON 的通用性,但不具备解析任意语法的通用性。如果你想做一个具备解析任何通用语法的解析器,读入的内容应该是语法描述,处理方式必须更加通用,如果感兴趣可以阅读 [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) 系列文章了解更多。
|
||||
|
||||
由于 Object 第一个元素前面不允许加逗号,因此可以利用 `initial` 做一个初始化判定,在初始时机不会吃掉逗号:
|
||||
|
||||
```js
|
||||
function fakeParseJSON(str) {
|
||||
let i = 0;
|
||||
function parseObject() {
|
||||
if (str[i] === '{') {
|
||||
i++;
|
||||
skipWhitespace();
|
||||
|
||||
let initial = true;
|
||||
// if it is not '}',
|
||||
// we take the path of string -> whitespace -> ':' -> value -> ...
|
||||
while (str[i] !== '}') {
|
||||
if (!initial) {
|
||||
eatComma();
|
||||
skipWhitespace();
|
||||
}
|
||||
const key = parseString();
|
||||
skipWhitespace();
|
||||
eatColon();
|
||||
const value = parseValue();
|
||||
initial = false;
|
||||
}
|
||||
// move to the next character of '}'
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
那么当第一个子元素前面存在逗号时,由于没有 “吃掉逗号” 这个功能,所以读到逗号会报错,语法解析提前结束。
|
||||
|
||||
吃逗号和吃冒号的代码都非常简单,即判断当前字符串必须是 “要吃的那个元素”,并且在吃掉后将 `i` 下标自增 1:
|
||||
|
||||
```js
|
||||
function fakeParseJSON(str) {
|
||||
// ...
|
||||
function eatComma() {
|
||||
if (str[i] !== ',') {
|
||||
throw new Error('Expected ",".');
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
function eatColon() {
|
||||
if (str[i] !== ':') {
|
||||
throw new Error('Expected ":".');
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在有了基本判定功能后,`fakeParseJSON` 需要返回 Object,因此我们只需在每个循环中对 Object 赋值,最后一并 return 即可:
|
||||
|
||||
```js
|
||||
function fakeParseJSON(str) {
|
||||
let i = 0;
|
||||
function parseObject() {
|
||||
if (str[i] === '{') {
|
||||
i++;
|
||||
skipWhitespace();
|
||||
|
||||
const result = {};
|
||||
|
||||
let initial = true;
|
||||
// if it is not '}',
|
||||
// we take the path of string -> whitespace -> ':' -> value -> ...
|
||||
while (str[i] !== '}') {
|
||||
if (!initial) {
|
||||
eatComma();
|
||||
skipWhitespace();
|
||||
}
|
||||
const key = parseString();
|
||||
skipWhitespace();
|
||||
eatColon();
|
||||
const value = parseValue();
|
||||
result[key] = value;
|
||||
initial = false;
|
||||
}
|
||||
// move to the next character of '}'
|
||||
i++;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
解析 Object 的代码就完成了。
|
||||
|
||||
接着试着解析 Array,下面是 Array 的语法图:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1FvYjvKH2gK0jSZFEXXcqMpXa-1837-479.png">
|
||||
|
||||
我们只需要吃逗号和 `parseValue` 即可:
|
||||
|
||||
```js
|
||||
function fakeParseJSON(str) {
|
||||
// ...
|
||||
function parseArray() {
|
||||
if (str[i] === '[') {
|
||||
i++;
|
||||
skipWhitespace();
|
||||
|
||||
const result = [];
|
||||
let initial = true;
|
||||
while (str[i] !== ']') {
|
||||
if (!initial) {
|
||||
eatComma();
|
||||
}
|
||||
const value = parseValue();
|
||||
result.push(value);
|
||||
initial = false;
|
||||
}
|
||||
// move to the next character of ']'
|
||||
i++;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
接下来到了有趣的 `value` 语法图,可以看到 `value` 是许多种基础类型的 “或” 关系组成的:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1uGrmvND1gK0jSZFyXXciOVXa-1836-1293.png">
|
||||
|
||||
我们只需要继续拆解分析即可:
|
||||
|
||||
```js
|
||||
function fakeParseJSON(str) {
|
||||
// ...
|
||||
function parseValue() {
|
||||
skipWhitespace();
|
||||
const value =
|
||||
parseString() ??
|
||||
parseNumber() ??
|
||||
parseObject() ??
|
||||
parseArray() ??
|
||||
parseKeyword('true', true) ??
|
||||
parseKeyword('false', false) ??
|
||||
parseKeyword('null', null);
|
||||
skipWhitespace();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中 `parseKeyword` 函数用来解析一些保留关键字,比如将 `"true"` 解析成布尔类型 `true`:
|
||||
|
||||
```js
|
||||
function fakeParseJSON(str) {
|
||||
// ...
|
||||
function parseKeyword(name, value) {
|
||||
if (str.slice(i, i + name.length) === name) {
|
||||
i += name.length;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如上所示,只要在 name 与对应字符相等时,返回第二个传入参数即可。
|
||||
|
||||
### 处理异常输入
|
||||
|
||||
一个完整的语法解析功能需要包含错误处理,错误的情况主要分两种:
|
||||
|
||||
1. 非法字符。
|
||||
2. 非正常结尾。
|
||||
|
||||
原文提到的 JSON 错误提示优化非常棒,想想你在开发中突然看到下面的提示,是不是很蒙圈:
|
||||
|
||||
```text
|
||||
Unexpected token "a"
|
||||
```
|
||||
|
||||
既然我们是自己写的 JSON 解析器,就可以进行更友好的异常提示,比如:
|
||||
|
||||
```text
|
||||
// show
|
||||
{ "b"a
|
||||
^
|
||||
JSON_ERROR_001 Unexpected token "a".
|
||||
Expecting a ":" over here, eg:
|
||||
{ "b": "bar" }
|
||||
^
|
||||
You can learn more about valid JSON string in http://goo.gl/xxxxx
|
||||
```
|
||||
|
||||
更多 Demo 可以查看 [原文](https://lihautan.com/json-parser-with-javascript/)。
|
||||
|
||||
## 3 总结
|
||||
|
||||
这篇文章通过一个具体的例子解释如何做语法分析,对于词法解析入门非常直观,如果你想更深入理解语法解析,或者写一个通用语法解析器,可以阅读语法解析系列入门文章,笔者通过实际例子带你一步一步做一个完备的词法解析工具!
|
||||
|
||||
语法解析入门系列文章,建议阅读顺序:
|
||||
|
||||
- [精读《手写 SQL 编译器 - 词法分析》](https://github.com/dt-fe/weekly/blob/v2/064.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%8D%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md)
|
||||
- [精读《手写 SQL 编译器 - 文法介绍》](https://github.com/dt-fe/weekly/blob/v2/065.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%96%87%E6%B3%95%E4%BB%8B%E7%BB%8D%E3%80%8B.md)
|
||||
- [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md)
|
||||
- [精读《手写 SQL 编译器 - 回溯》](https://github.com/dt-fe/weekly/blob/v2/067.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E5%9B%9E%E6%BA%AF%E3%80%8B.md)
|
||||
- [精读《手写 SQL 编译器 - 语法树》](https://github.com/dt-fe/weekly/blob/v2/070.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E6%A0%91%E3%80%8B.md)
|
||||
- [精读《手写 SQL 编译器 - 错误提示》](https://github.com/dt-fe/weekly/blob/v2/071.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E9%94%99%E8%AF%AF%E6%8F%90%E7%A4%BA%E3%80%8B.md)
|
||||
- [精读《手写 SQL 编译器 - 性能优化之缓存》](https://github.com/dt-fe/weekly/blob/v2/078.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E4%B9%8B%E7%BC%93%E5%AD%98%E3%80%8B.md)
|
||||
- [精读《手写 SQL 编译器 - 智能提示》](https://github.com/dt-fe/weekly/blob/v2/085.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%99%BA%E8%83%BD%E6%8F%90%E7%A4%BA%E3%80%8B.md)
|
||||
|
||||
[syntax-parser](https://github.com/ascoders/syntax-parser) 这个零依赖的通用语法解析库就是根据上述文章一步一步完成的,看完了上面文章,就彻底理解了这个库的源码。
|
||||
|
||||
> 讨论地址是:[精读《手写 JSON Parser》 · Issue #233 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/233)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,233 @@
|
||||
## 1 引言
|
||||
|
||||
拖拽是前端非常常见的交互操作,但显然拖拽是强 DOM 交互的,而 React 绕过了 DOM 这一层,那么基于 React 的拖拽方案就必定值得聊一聊。
|
||||
|
||||
结合 [How To Use The HTML Drag-And-Drop API In React](https://www.smashingmagazine.com/2020/02/html-drag-drop-api-react/) 这篇文章,让我们谈谈 React 拖拽这些事。
|
||||
|
||||
## 2 概述
|
||||
|
||||
原文说的比较简单,笔者先快速介绍其中重点部分。
|
||||
|
||||
首先拖拽主要的 API 有 4 个:`dragEnter` `dragLeave` `dragOver` `drop`,分别对应拖入、拖出、正在当前元素范围内拖拽、完成拖入动作。
|
||||
|
||||
基于这些 API,我们可以利用 React 实现一个拖入区域:
|
||||
|
||||
```jsx
|
||||
import React from "react";
|
||||
|
||||
const DragAndDrop = props => {
|
||||
const handleDragEnter = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
const handleDragLeave = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
const handleDragOver = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
const handleDrop = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={"drag-drop-zone"}
|
||||
onDrop={e => handleDrop(e)}
|
||||
onDragOver={e => handleDragOver(e)}
|
||||
onDragEnter={e => handleDragEnter(e)}
|
||||
onDragLeave={e => handleDragLeave(e)}
|
||||
>
|
||||
<p>Drag files here to upload</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default DragAndDrop;
|
||||
```
|
||||
|
||||
`preventDefault` 指的是阻止默认响应,这个响应可能是跳转页面之类的,`stopPropagation` 是阻止冒泡,这样同样监听了事件的父元素就不会收到响应,我们可以精准作用于嵌套的子元素。
|
||||
|
||||
接下来是拖拽状态管理,提到了 `useReducer`,顺便复习一下用法:
|
||||
|
||||
```jsx
|
||||
...
|
||||
const reducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'SET_DROP_DEPTH':
|
||||
return { ...state, dropDepth: action.dropDepth }
|
||||
case 'SET_IN_DROP_ZONE':
|
||||
return { ...state, inDropZone: action.inDropZone };
|
||||
case 'ADD_FILE_TO_LIST':
|
||||
return { ...state, fileList: state.fileList.concat(action.files) };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
const [data, dispatch] = React.useReducer(
|
||||
reducer, { dropDepth: 0, inDropZone: false, fileList: [] }
|
||||
)
|
||||
...
|
||||
```
|
||||
|
||||
最后一个关键点在于拖入后的处理,利用 `dispatch` 增加拖入文件、设置拖入状态即可:
|
||||
|
||||
```js
|
||||
const handleDrop = e => {
|
||||
...
|
||||
let files = [...e.dataTransfer.files];
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const existingFiles = data.fileList.map(f => f.name)
|
||||
files = files.filter(f => !existingFiles.includes(f.name))
|
||||
|
||||
dispatch({ type: 'ADD_FILE_TO_LIST', files });
|
||||
e.dataTransfer.clearData();
|
||||
dispatch({ type: 'SET_DROP_DEPTH', dropDepth: 0 });
|
||||
dispatch({ type: 'SET_IN_DROP_ZONE', inDropZone: false });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
`e.dataTransfer.clearData` 函数用于清除拖拽过程中产生的临时变量,这些临时变量可以通过 `e.dataTransfer.xxx =` 的方式赋值,一般用于拖拽过程中值的传递。
|
||||
|
||||
总结一下,利用 HTML5 的 API 将拖拽转化为状态,最终通过状态映射到 UI。
|
||||
|
||||
原文内容还是比较简单的,笔者在精读部分再拓展一些更体系化的内容。
|
||||
|
||||
## 3 精读
|
||||
|
||||
现阶段拖拽主要分为两种,一种是 HTML5 原生规范的拖拽,这种方式在拖拽过程中不会影响 DOM 结构。另一种是完全所见即所得的拖拽方式,拖拽过程中 DOM 位置会随之变动,好处是可以立即反馈拖拽结果,当然缺点是华而不实,一旦用在生产环境,这种拖拽过程可能导致页面结构频繁跳动,反而看不清拖拽效果。
|
||||
|
||||
由于本文也采用了第一种拖拽方案,因为笔者再重新整理一遍自己的封装思路。
|
||||
|
||||
从使用角度反推,假设我们拥有一个拖拽库,那必定要拥有两个 API:
|
||||
|
||||
```jsx
|
||||
import { DragContainer, DropContainer } from 'dnd'
|
||||
|
||||
const DragItem = (
|
||||
<DragContainer>
|
||||
{({ dragProps }) => (
|
||||
<div {...dragProps} />
|
||||
)}
|
||||
</DragContainer>
|
||||
)
|
||||
|
||||
const DropItem = (
|
||||
<DropContainer>
|
||||
{({ dropProps }) => (
|
||||
<div {...dropProps} />
|
||||
)}
|
||||
</DropContainer>
|
||||
)
|
||||
```
|
||||
|
||||
`DragContainer` 包裹可以被拖拽的元素,`DropContainer` 包裹可以被拖入的元素,而至于 `dragProps` 与 `dropProps` 需要透传到子元素的 dom 节点,是为了利用 DOM API 控制拖拽效果,这也是拖拽唯一对 DOM 的要求,双方元素都需要有实体 DOM 承载。
|
||||
|
||||
而上面例子中给出 `dragProps` 与 `dropProps` 的方式属于 RenderProps,我们可以将 `children` 当作函数执行以达到效果:
|
||||
|
||||
```jsx
|
||||
const DragContainer = ({ children, componentId }) => {
|
||||
const { dragProps } = useDnd(componentId)
|
||||
|
||||
return children({
|
||||
dragProps
|
||||
})
|
||||
}
|
||||
|
||||
const DropContainer = ({ children, componentId }) => {
|
||||
const { dropProps } = useDnd(componentId)
|
||||
|
||||
return children({
|
||||
dropProps
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
那么这里创建了一个自定义 Hook `useDnd` 接收 `dragProps` 与 `dropProps`,这个自定义 Hook 可以这么写:
|
||||
|
||||
```jsx
|
||||
const useDnd = ({ componentId }) => {
|
||||
const dragProps = {}
|
||||
const dropProps = {}
|
||||
|
||||
return { dragProps, dropProps }
|
||||
}
|
||||
```
|
||||
|
||||
接下来,我们就要分别实现 `drag` 与 `drop` 了。
|
||||
|
||||
对 `drag` 来说,只要实现 `onDragStart` 与 `onDragEnd` 即可:
|
||||
|
||||
```jsx
|
||||
const dragProps = {
|
||||
onDragStart: ev => {
|
||||
ev.stopPropagation()
|
||||
ev.dataTransfer.setData('componentId', componentId)
|
||||
},
|
||||
onDragEnd: ev => {
|
||||
// 做一些拖拽结束的清理工作
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`stopPropagation` 的作用在原文简介中已经介绍过了,`setData` 则是通知拖拽方,当前拖拽的组件 id 是什么,**这是由于拖拽由 `drag` 发起而由 `drop` 响应,因此必须有个数据传输过程,而 `dataTransfer` 就最适合做这件事。**
|
||||
|
||||
对于 `drop` 来说,只要实现 `onDragOver` 与 `onDrop` 即可:
|
||||
|
||||
```jsx
|
||||
const dropProps = {
|
||||
onDropOver: ev => {
|
||||
// 做一些样式处理,提示用户此时松手会将元素防止在何处
|
||||
},
|
||||
onDrop: ev => {
|
||||
ev.stopPropagation()
|
||||
const componentId = ev.dataTransfer.getData('componentId')
|
||||
// 通过 componentId 修改数据,通过 React Rerender 刷新 UI
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
重点在 `onDrop`,它是实现拖拽效果的 “真正执行处”,最终通过修改 UI 的方式更新数据。
|
||||
|
||||
存在一种场景,一个容器既可以被拖动,也可以被拖入,这种情况一般这个组件是个容器,但这个容器可以被拖入到其他容器中,可以自由嵌套。
|
||||
|
||||
实现这种场景的方式就是将 `DragContainer` 与 `DropContainer` 作用到一个组件上:
|
||||
|
||||
```jsx
|
||||
const Box = (
|
||||
<DragContainer>
|
||||
{({ dragProps }) => (
|
||||
<DropContainer>
|
||||
{({ dropProps }) => {
|
||||
<div {...dragProps} {...dropProps} />
|
||||
}}
|
||||
</DropContainer>
|
||||
)}
|
||||
</DragContainer>
|
||||
)
|
||||
```
|
||||
|
||||
之所以能嵌套,在于 HTML5 的 API 允许一个元素同时拥有 `onDragStart`、`onDrop` 这两种属性,而上面的语法不过是同时将这两种属性传给组件 DOM。
|
||||
|
||||
所以,动手实现一个拖拽库就是这么简单,只要活用 HTML5 的拖拽 API,结合 React 一些特殊语法便够了。
|
||||
|
||||
## 4 总结
|
||||
|
||||
最后留下一个思考题,许多具有拖拽功能的系统都具备 “拖拽 placeholder” 的功能,即拖拽元素的过程中,在其 “落点” 位置展示一条横线或竖线,引导出松手后元素位置落点,如图所示:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB11H04wbY1gK0jSZTEXXXDQVXa-1434-384.png">
|
||||
|
||||
那么这条辅助线是通过什么方式实现的呢?欢迎在评论区留言!如果你有辅助线实现方案解析的文章,欢迎分享,也可以期待笔者未来专门写一篇 “拖拽 placeholder” 实现剖析的精读。
|
||||
|
||||
> 讨论地址是:[精读《手写 JSON Parser》 · Issue #233 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/233)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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 @@
|
||||
## 1 引言
|
||||
|
||||
`useRef` 是常用的 API,但还有一个 `createRef` 的 API,你知道他们的区别吗?通过 [React.useRef and React.createRef: The Difference](https://blog.bitsrc.io/react-useref-and-react-createref-the-difference-afedb9877d0f) 这篇文章,你可以了解到何时该使用它们。
|
||||
|
||||
## 2 概述
|
||||
|
||||
其实原文就阐述了这样一个事实:`useRef` 仅能用在 FunctionComponent,`createRef` 仅能用在 ClassComponent。
|
||||
|
||||
第一句话是显然的,因为 Hooks 不能用在 ClassComponent。
|
||||
|
||||
第二句话的原因是,`createRef` 并没有 Hooks 的效果,其值会随着 FunctionComponent 重复执行而不断被初始化:
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
// 错误用法,永远也拿不到 ref
|
||||
const valueRef = React.createRef();
|
||||
return <div ref={valueRef} />;
|
||||
}
|
||||
```
|
||||
|
||||
上述 `valueRef` 会随着 App 函数的 Render 而重复初始化,**这也是 Hooks 的独特之处,虽然用在普通函数中,但在 React 引擎中会得到超出普通函数的表现,比如初始化仅执行一次,或者引用不变**。
|
||||
|
||||
为什么 `createRef` 可以在 ClassComponent 正常运行呢?这是因为 ClassComponent 分离了生命周期,使例如 `componentDidMount` 等初始化时机仅执行一次。
|
||||
|
||||
原文完。
|
||||
|
||||
## 3 精读
|
||||
|
||||
那么知道如何正确创建 Ref 后,还知道如何正确更新 Ref 吗?
|
||||
|
||||
由于 Ref 是贯穿 FunctionComponent 所有渲染周期的实例,理论上在任何地方都可以做修改,比如:
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
const valueRef = React.useRef();
|
||||
|
||||
valueRef.current += 1;
|
||||
|
||||
return <div />;
|
||||
}
|
||||
```
|
||||
|
||||
但其实上面的修改方式是不规范的,React 官方文档里要求我们避免在 Render 函数中直接修改 Ref,请先看下面的 FunctionComponent 生命周期图:
|
||||
|
||||
<img width=600 src="https://img.alicdn.com/tfs/TB12aHDwQL0gK0jSZFtXXXQCXXa-3300-2550.png">
|
||||
|
||||
从图中可以发现,在 `Render phase` 阶段是不允许做 “side effects” 的,也就是写副作用代码,这是因为这个阶段可能会被 React 引擎随时取消或重做。
|
||||
|
||||
修改 Ref 属于副作用操作,因此不适合在这个阶段进行。我们可以看到,在 `Commit phase` 阶段可以做这件事,或者在回调函数中做(脱离了 React 生命周期)。
|
||||
|
||||
当然有一种情况是可以的,即 [懒初始化](https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily):
|
||||
|
||||
```ts
|
||||
function Image(props) {
|
||||
const ref = useRef(null);
|
||||
|
||||
// ✅ IntersectionObserver is created lazily once
|
||||
function getObserver() {
|
||||
if (ref.current === null) {
|
||||
ref.current = new IntersectionObserver(onIntersect);
|
||||
}
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
// When you need it, call getObserver()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
懒初始化的情况下,副作用最多执行一次,而且仅用于初始化赋值,所以这种行为是被允许的。
|
||||
|
||||
为什么对副作用限制的如此严格?因为 FunctionComponent 增加了内置调度系统,为了优先响应用户操作,可能会暂定某个 React 组件的渲染,具体可以看第 99 篇精读:[精读《Scheduling in React》](https://github.com/dt-fe/weekly/blob/v2/099.%E7%B2%BE%E8%AF%BB%E3%80%8AScheduling%20in%20React%E3%80%8B.md)
|
||||
|
||||
Ref 不仅可以拿到组件引用、创建一个 Mutable 副作用对象,还可以配合 `useEffect` 存储一个较老的值,最常用来拿到 `previousProps`,React 官方利用 Ref 封装了一个简单的 Hooks 拿到上一次的值:
|
||||
|
||||
```tsx
|
||||
function usePrevious(value) {
|
||||
const ref = useRef();
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
}
|
||||
```
|
||||
|
||||
由于 `useEffect` 在 Render 完毕后才执行,因此 `ref` 的值在当前 Render 中永远是上一次 Render 时候的,我们可以利用它拿到上一次 Props:
|
||||
|
||||
```tsx
|
||||
function App(props) {
|
||||
const preProps = usePrevious(props);
|
||||
}
|
||||
```
|
||||
|
||||
要实现这个功能,还是要归功于 `ref` 可以将值 “在各个不同的 Render 闭包中传递的特性”。最后,不要滥用 Ref,Mutable 引用越多,对 React 来说可维护性一般会越差。
|
||||
|
||||
## 4 总结
|
||||
|
||||
你还挖掘了 `useRef` 哪些有意思的使用方式?欢迎在评论区留言。
|
||||
|
||||
> 讨论地址是:[精读《useRef 与 createRef 的区别》 · Issue #236 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/236)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,97 @@
|
||||
## 1 引言
|
||||
|
||||
任何软件都是协同开发的,所以 CodeReview 非常重要,它可以帮助你减少代码质量问题,提高开发效率,提升稳定性,同时还能保证软件架构的稳定性,防止代码结构被恶意破坏导致难以维护。
|
||||
|
||||
所以 CodeReview 机制是否健全是一个工程团队能否长期健康发展的决定因素之一,这次我们读一篇关于 CodeReview 如何做得更好的文章: [how-to-make-good-code-reviews-better](https://stackoverflow.blog/2019/09/30/how-to-make-good-code-reviews-better/)。
|
||||
|
||||
## 2 概述 & 精读
|
||||
|
||||
作者结合自己在 Uber、微软的工作经历介绍了自己对如何做好 CodeReview 的看法。
|
||||
|
||||
### CodeReview 的覆盖范围
|
||||
|
||||
**Good CodeReview** 会检查代码的正确性、测试覆盖率、功能变化、是否遵循代码规范与最佳实践、可以指出一些较为明显的改进点,比如难以阅读的写法、未使用到变量、一些边界问题、commit 数量过大需要拆分等等。
|
||||
|
||||
**Better CodeReview** 会检查引入代码的必要性,与已有系统是否适配,是否具有可维护性,从抽象角度思考代码是否与已有系统逻辑能够自洽。
|
||||
|
||||
> Better CodeReview 会关注在可维护性层面,并具有全局性,往往几个局部正确的代码组合在一起会产生错误的结果,或者是没必要的代码,或者是相互冲突的逻辑。Better CodeReview 更多用在底层架构场景,因为架构底层模块关联比较紧密,需要有整体视角,而业务上层模块间最好采用解耦模式,这样不仅不需要更耗费精力的 Better CodeReview,也是一种更正确的架构设计。
|
||||
|
||||
### CodeReview 的语气
|
||||
|
||||
**Good CodeReview** 会给出建设性意见,而不是发表强硬措辞要求对方改正,或认为自己的意见是唯一正确的答案,因为这样的评论其实具有一定攻击性,激发对方的防御心理,产生敌对心态,这样会从内部瓦解一个团队。最好能给出建议,或者多个选择,给对方留有余地。
|
||||
|
||||
**Better CodeReview** 永远是考虑全面且正向积极的,会对写的好的地方进行鼓励,对写的不好的地方也体现出善解人意的关怀,考虑到对方可能花费了很多心血,以一种换位思考的鼓励心态进行评论。
|
||||
|
||||
> 其实读到语气这一章节,逐渐发现 CodeReview 不仅是一个技术专业行为,还是一个人与人相处的社交行为,有的人平时与人打交道非常谦逊,但在 CodeReview 中就变得尖酸刻薄,显然是只关注到了 CodeReview 的专业性这一面,忽略了社交性这一点。而要做到 Better CodeReview 还要学会换位思考,体现出包容、正向积极的态度,因为你技术经验更丰富,能指出别人的问题很正常,但能保持谦逊,让别人容易接受并受到鼓励,可以让你成为一个有气度的技术专家。
|
||||
|
||||
### 如何完成 CodeReview 的审阅
|
||||
|
||||
**Good CodeReview** 不会轻易通过那些开放式 PR,至少在其被得到充分讨论前,但每个 Review 者对自己关注的部分完成 Review 后需要进行反馈,无论是 “看起来不错” 或者用缩写单词 “LGTM”,之后需要有明确的跟进,比如通过协作软件通知作者进行进一步反馈。
|
||||
|
||||
**Better CodeReview** 实际执行中会更加灵活一些,对于一些比较紧急的改动会留下改进建议,但快速通过,让作者通过后续代码提交解决遗留的问题。
|
||||
|
||||
> 实际工作场景会遇到一些开放式或紧急的提交,良好的 CodeReview 习惯自然是要严谨一些,讨论清楚再通过,并且要及时反馈。但某些比较紧急的提交就要区别对待了,更好的态度是在实践中灵活对待,但及时紧急通过了,也要保证问题在后续得以修复,比如在代码中留一些 "TODO" 或 "FIXME" 的标记,写上对应的负责人与预期解决时间。
|
||||
|
||||
### 从 CodeReview 到直接交流
|
||||
|
||||
**Good CodeReview** 会给出完整的评论和修改建议,如果后续提交的代码不符合预期,Review 者可以直接与代码提交者面对面交流,这样可以避免后续花费更多沟通时间。
|
||||
|
||||
**Better CodeReview** 会在第一次给出完整的评论和修改建议,如果后续提交代码不符合预期,会立即与代码提交者当面沟通,避免异步沟通带来更多的理解偏差。
|
||||
|
||||
> 补充一下,在 PR 内容过多时也可以选择直接与提交者当面沟通,这样可以更多理解作者的想法,使 Review 准确性更高。另外并不要每次都直接交流,异步的 CodeReview 本身就是一种提效方案,这会使你工作节奏把握在自己手中,仅在这种方案出现沟通问题时再选择当面交流。
|
||||
|
||||
### 区分重点
|
||||
|
||||
**Good CodeReview** 可以区分提示的重要程度,并在不太重要的改动前面加上 “nit:” 标记,这样可以使提交者的注意力集中在重要的问题上。
|
||||
|
||||
**Better CodeReview** 会采取工具手段解决这些问题,比如一些代码 lint 工具,因为这些问题往往是可以被工具自动化解决的。
|
||||
|
||||
> 代码自动化工具的目的,很大一部分也是为了保证代码一致性,从而降低 CodeReview 成本,也减少不重要的评论信息出现,让 CodeReview 尽可能反馈逻辑问题而不是格式问题。
|
||||
|
||||
### 针对新人的 CodeReview
|
||||
|
||||
**Good CodeReview** 对任何人都是用相同评判标准,可以遵循上面几点注意事项。
|
||||
|
||||
**Better CodeReview** 会对新人区分对待,对新人给予对多的耐心、解释和评论,甚至给出解决方法,并更积极的给出鼓励。
|
||||
|
||||
> 任何人到一家新公司都有适应过程,一视同仁是 base 要求,但如果能给予新人更多关怀就更好啦。
|
||||
|
||||
### 跨办公区、时区的 CodeReview
|
||||
|
||||
**Good CodeReview** 仅在工作时间有重叠的时间范围内进行 CodeReview,这样能保证对方可以积极响应,在必要时进行语音、视频沟通。
|
||||
|
||||
**Better CodeReview** 会注意到更本质的问题,留意跨团队协作的必要性,如果某个模块经常被另一个时区同时修改,也许可以将这个模块交给对方维护,或者将 CodeReview 交给对方团队内部进行会更加高效。
|
||||
|
||||
> 笔者所在公司也有跨时区协作情况,但绝大部分场景会避免跨时区的 CodeReview,因为 CodeReview 一般会在同一时区团队内部进行,这样效率更高,应对跨时区协作时,往往也是电话、视频会议优先。
|
||||
|
||||
### 公司支持
|
||||
|
||||
**Good CodeReview** 会得到公司组织支持,公司能意识到这么做虽然看起来占用开发时间,但长远来看提升了开发效率,因此能任何 CodeReview 价值。
|
||||
|
||||
**Better CodeReview** 会得到公司进一步支持,公司甚至不断研发并完善 CodeReview 系统与流程,通过系统化方案保证上面几项 CodeReview 注意事项是否有在团队内落实,可以全员参与。
|
||||
|
||||
> CodeReview 也是一种团队文化和公司文化,公司文化带来的是规章制度与系统工具,团队文化带来的是良好 CodeReview 氛围与更高 CodeReview 的效率。
|
||||
|
||||
## 3 总结
|
||||
|
||||
总结一下,良好的 CodeReview 需要做到以下几点:
|
||||
|
||||
1. 更全面,从正确性到系统影响评估。
|
||||
2. 注意语气,从给出建设性一觉到换位思考。
|
||||
3. 及时完成审阅,从充分讨论到随机应变。
|
||||
4. 加强交流,从面对面交流到灵活选择最高效的沟通方式。
|
||||
5. 区分重点,从添加标记到利用工程化工具自动解决。
|
||||
6. 对新人要更友好。
|
||||
7. 尽量避免跨时区协作,必要时选择视频会议。
|
||||
|
||||
最后,希望 CodeReview 能够得到公司的支持,如果你们公司还没有认可 CodeReview 的价值,可以将这篇文章分享给你的领导。
|
||||
|
||||
> 讨论地址是:[精读《如何做好 CodeReview》 · Issue #237 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/237)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,302 @@
|
||||
## 1 引言
|
||||
|
||||
很多人都用过 React Suspense,但如果你认为它只是配合 React.lazy 实现异步加载的蒙层,就理解的太浅了。实际上,React Suspense 改变了开发规则,要理解这一点,需要作出思想上的改变。
|
||||
|
||||
我们结合 [Why React Suspense Will Be a Game Changer](https://medium.com/react-in-depth/why-react-suspense-will-be-a-game-changer-37b40fea71ec) 这篇文章,带你重新认识 React Suspense。
|
||||
|
||||
## 2 概述
|
||||
|
||||
异步加载是前端开发的重要环节,也是一直以来样板代码最严重的场景之一,原文通过三种取数方案的对比,逐渐找到一种最佳的异步取数方式。
|
||||
|
||||
在讲解这三种取数方案之前,首先通过下面这张图说明了 Suspense 的功能:
|
||||
|
||||

|
||||
|
||||
从上图可以看出,子元素在异步取数时会阻塞父组件渲染,并一直冒泡到最外层第一个 Suspense,此时 Suspense 不会渲染子组件,而是渲染 `fallback`,当所有子组件异步阻塞取消后才会正常渲染。
|
||||
|
||||
下面介绍文中给出的三种取数方式,首先是最原始的本地状态管理方案。
|
||||
|
||||
### 本地异步状态管理,直白但不利于维护
|
||||
|
||||
在 Suspense 方案出来之前,我们一般都在代码中利用本地状态管理异步数据。
|
||||
|
||||
即便代码做了一定抽象,那也只是把逻辑从一个文件移到了另一个问题,可维护性与可拓展性都没有本质的改变,因此基本可以用下面的结构说明:
|
||||
|
||||
```javascript
|
||||
class DynamicData extends Component {
|
||||
state = {
|
||||
loading: true,
|
||||
error: null,
|
||||
data: null
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
fetchData(this.props.id)
|
||||
.then(data => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
data
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.id !== prevProps.id) {
|
||||
this.setState({ loading: true }, () => {
|
||||
fetchData(this.props.id)
|
||||
.then(data => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
data
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, error, data } = this.state;
|
||||
return loading ? (
|
||||
<p>Loading...</p>
|
||||
) : error ? (
|
||||
<p>Error: {error}</p>
|
||||
) : (
|
||||
<p>Data loaded ?</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如上所述,首先申明本地状态管理至少三种数据:异步状态、异步结果与异步错误,其次在不同的生命周期中处理初始化发请求与重新发请求的问题,最后在渲染函数中根据不同的状态渲染不同的结果,所以实际上我们写了三个渲染组件。
|
||||
|
||||
从下面几个角度对上述代码进行评价:
|
||||
|
||||
- **冗余的三种状态 - 糟糕的开发体验**
|
||||
- 很明显,存储了三套数据,渲染三种结果,不利于开发维护。
|
||||
- **冗余的样板代码 - 糟糕的开发体验**
|
||||
- 为了管理异步状态,上述代码非常冗长,显然这个问题是存在的。
|
||||
- **数据与状态封闭性 - 糟糕的用户体验 + 开发体验**
|
||||
- 所有数据与状态管理都存储在每一个这种组件中,将取数状态与组件绑定的结果就是,我们只能忍受组件独立运行的 Loading 逻辑,而无法对他们进行统一管理。
|
||||
- **重新取数 - 糟糕的开发体验**
|
||||
- 需要在另一个生命周期中申明重新取数,很明显是个麻烦的行为。
|
||||
- **一闪而过的短暂 Loading - 糟糕的用户体验**
|
||||
- 如果用户网速足够快,则 Loading 时间会非常短,此时一闪而过的 Loading 反而比没有 Loading 更烦人,我们应该在用户感知到卡的时候再出现 Loading 状态。
|
||||
|
||||
### Context 管理状态,有进步但问题依然很多
|
||||
|
||||
如果利用 Context 做状态共享,我们将取数的数据管理与逻辑代码写在父组件,子组件专心用于展示,效果会好一些,代码如下:
|
||||
|
||||
```javascript
|
||||
const DataContext = React.createContext();
|
||||
|
||||
class DataContextProvider extends Component {
|
||||
// We want to be able to store multiple sources in the provider,
|
||||
// so we store an object with unique keys for each data set +
|
||||
// loading state
|
||||
state = {
|
||||
data: {},
|
||||
fetch: this.fetch.bind(this)
|
||||
};
|
||||
|
||||
fetch(key) {
|
||||
if (this.state[key] && (this.state[key].data || this.state[key].loading)) {
|
||||
// Data is either already loaded or loading, so no need to fetch!
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
[key]: {
|
||||
loading: true,
|
||||
error: null,
|
||||
data: null
|
||||
}
|
||||
},
|
||||
() => {
|
||||
fetchData(key)
|
||||
.then(data => {
|
||||
this.setState({
|
||||
[key]: {
|
||||
loading: false,
|
||||
data
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
this.setState({
|
||||
[key]: {
|
||||
loading: false,
|
||||
error: e.message
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <DataContext.Provider value={this.state} {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
class DynamicData extends Component {
|
||||
static contextType = DataContext;
|
||||
|
||||
componentDidMount() {
|
||||
this.context.fetch(this.props.id);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.id !== prevProps.id) {
|
||||
this.context.fetch(this.props.id);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { id } = this.props;
|
||||
const { data } = this.context;
|
||||
|
||||
const idData = data[id];
|
||||
|
||||
return idData.loading ? (
|
||||
<p>Loading...</p>
|
||||
) : idData.error ? (
|
||||
<p>Error: {idData.error}</p>
|
||||
) : (
|
||||
<p>Data loaded ?</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`DataContextProvider` 组件承担了状态管理与异步逻辑工作,而 `DynamicData` 组件只需要从 Context 获取异步状态渲染即可,这样来看至少解决了一部分问题,我们还是从之前的角度进行评价:
|
||||
|
||||
- **冗余的三种状态 - 糟糕的开发体验**
|
||||
- 问题依然存在,只不过代码的位置转移了一部分到父组件。
|
||||
- **冗余的样板代码 - 糟糕的开发体验**
|
||||
- 将展示与逻辑分离,成功降低了样板代码数量,至少当一个异步数据复用于多个组件时,不需要写多份样板代码了。
|
||||
- **数据与状态封闭性 - 糟糕的用户体验 + 开发体验**
|
||||
- 这个问题得到一定程度解决,但是引入了新问题,即这个子组件仅在特定环境下可以正常运行。但在一个良好的设计下,组件运行不应该依赖于它所处的位置。
|
||||
- **重新取数 - 糟糕的开发体验**
|
||||
- 问题依然存在。
|
||||
- **一闪而过的短暂 Loading - 糟糕的用户体验**
|
||||
- 问题依然存在。
|
||||
|
||||
### Suspense 管理状态,最棒的方案
|
||||
|
||||
利用 Suspense 进行异步处理,代码处理大概是这样的:
|
||||
|
||||
```javascript
|
||||
import createResource from "./magical-cache-provider";
|
||||
const dataResource = createResource(id => fetchData(id));
|
||||
|
||||
class DynamicData extends Component {
|
||||
render() {
|
||||
const data = dataResource.read(this.props.id);
|
||||
return <p>Data loaded ?</p>;
|
||||
}
|
||||
}
|
||||
|
||||
class App extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<DeepNesting>
|
||||
<DynamicData />
|
||||
</DeepNesting>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在原文写作的时候,Suspense 仅能对 React.lazy 生效,但现在已经可以对任何异步状态生效了,只要符合 Pending 中 throw promise 的规则。
|
||||
|
||||
我们再审视一下上面的代码,可以发现代码量减少了很多,其中和转换成 Function Component 的写法也有关系。
|
||||
|
||||
最后还是从如下几个角度进行评价:
|
||||
|
||||
- **冗余的三种状态 - 糟糕的开发体验** - ⭐️
|
||||
- 可以看到,组件只要处理成功得到数据的状态即可,三种状态合并成了一种状态。
|
||||
- **冗余的样板代码 - 糟糕的开发体验** - ⭐️
|
||||
- 展示与逻辑完全分离,展示只要拿到数据展示 UI 即可。
|
||||
- **数据与状态封闭性 - 糟糕的用户体验 + 开发体验** - ⭐️
|
||||
- 这个问题得到了完美的解决,具体看下面详细介绍。
|
||||
- **重新取数 - 糟糕的开发体验** - ⭐️
|
||||
- 不需要关心何时需要重新取数,当数据变化时会自动执行。
|
||||
- **一闪而过的短暂 Loading - 糟糕的用户体验**
|
||||
- 问题依然存在。
|
||||
|
||||
为了进一步说明 Suspense 的魔力,笔者特意把这段代码单独拿出来说明:
|
||||
|
||||
```javascript
|
||||
class App extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<DeepNesting>
|
||||
<MaybeSomeAsycComponent />
|
||||
<Suspense fallback={<p>Loading content...</p>}>
|
||||
<ThereMightBeSeveralAsyncComponentsHere />
|
||||
</Suspense>
|
||||
<Suspense fallback={<p>Loading footer...</p>}>
|
||||
<DeeplyNestedFooterTree />
|
||||
</Suspense>
|
||||
</DeepNesting>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上面代码表明了逻辑与展示的完美分离。
|
||||
|
||||
从代码结构上来看,我们可以在任何需要异步取数的组件父级添加 Suspense 达到 Loading 的效果,也就是说,如果只在最外层加一个 Suspense,那么整个应用所有 Loading 都结束后才会渲染,然而我们也能随心所欲的在任何层级继续添加 Suspense,那么对应作用域内的 Loading 就会首先执行完毕,并由当前的 Suspense 控制。
|
||||
|
||||
**这意味着我们可以自由决定 Loading 状态的范围组合。** 试想当 Loading 状态交由组件控制的方案一与方案二,是不可能做到合并 Loading 时机的,而 Suspense 方案做到了将 Loading 状态与 UI 分离,我们可以通过添加 Suspense 自由控制 Loading 的粒度。
|
||||
|
||||
## 3 精读
|
||||
|
||||
Suspense 对所有子组件异步都可以作用,因此无论是 React.lazy 还是异步取数,都可以通过 Suspense 进行 Pending。
|
||||
|
||||
异步时机被 Suspense pending 需要遵循一定规则,这个规则在之前的 [精读《Hooks 取数 - swr 源码》](https://github.com/dt-fe/weekly/blob/v2/128.%E7%B2%BE%E8%AF%BB%E3%80%8AHooks%20%E5%8F%96%E6%95%B0%20-%20swr%20%E6%BA%90%E7%A0%81%E3%80%8B.md) 有介绍过,即 Suspense 要求代码 suspended,即抛出一个可以被捕获的 Promise 异常,在这个 Promise 结束后再渲染组件,因此取数函数需要在 Pending 状态时抛出一个 Promise,使其可以被 Suspense 捕获到。
|
||||
|
||||
另外,关于文中提到的 fallback 最小出现时间的保护间隔,目前还是一个 [Open Issue](https://github.com/facebook/react/issues/17351),也许有一天 React 官方会提供支持。
|
||||
|
||||
不过即便官方不支持,我们也有方式实现,即让这个逻辑由 fallback 组件实现:
|
||||
|
||||
```jsx
|
||||
<Suspense fallback={MyFallback} />;
|
||||
|
||||
const MyFallback = () => {
|
||||
// 计时器,200 ms 以内 return null,200 ms 后 return <Spin />
|
||||
};
|
||||
```
|
||||
|
||||
## 4 总结
|
||||
|
||||
之所以说 Suspense 开发方式改变了开发规则,是因为它做到了将异步的状态管理与 UI 组件分离,所有 UI 组件都无需关心 Pending 状态,而是当作同步去执行,这本身就是一个巨大的改变。
|
||||
|
||||
另外由于状态的分离,我们可以利用纯 UI 组件拼装任意粒度的 Pending 行为,以整个 App 作为一个大的 Suspense 作为兜底,这样 UI 彻底与异步解耦,哪里 Loading,什么范围内 Loading,完全由 Suspense 组合方式决定,这样的代码显然具备了更强的可拓展性。
|
||||
|
||||
> 讨论地址是:[精读《Suspense 改变开发方式》 · Issue #238 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/238)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,132 @@
|
||||
## 1 引言
|
||||
|
||||
先说结论:Webpack5 模块联邦让 Webpack 达到了线上 Runtime 的效果,让代码直接在项目间利用 CDN 直接共享,不再需要本地安装 Npm 包、构建再发布了!
|
||||
|
||||
我们知道 Webpack 可以通过 DLL 或者 Externals 做代码共享时 Common Chunk,但不同应用和项目间这个任务就变得困难了,我们几乎无法在项目之间做到按需热插拔。
|
||||
|
||||
模块联邦是 Webpack5 新内置的一个重要功能,可以让跨应用间真正做到模块共享,所以这周让我们通过 [webpack-5-module-federation-a-game-changer-in-javascript-architecture](https://indepth.dev/webpack-5-module-federation-a-game-changer-in-javascript-architecture/#its-important-to-note-these-are-special-entry-points-they-are-only-a-few-kb-in-size-containing-a-special-webpack-runtime-that-can-interface-with-the-host-it-is-not-a-standard-entry-point--7/) 这篇文章了解什么是 “模块联邦” 功能。
|
||||
|
||||
## 2 概述 & 精读
|
||||
|
||||
### NPM 方式共享模块
|
||||
|
||||
想象一下正常的共享模块方式,对,就是 NPM。
|
||||
|
||||
如下图所示,正常的代码共享需要将依赖作为 Lib 安装到项目,进行 Webpack 打包构建再上线,如下图:
|
||||
|
||||
<img width=500 src="https://img.alicdn.com/tfs/TB1MoLPy.z1gK0jSZLeXXb9kVXa-2494-1478.png">
|
||||
|
||||
对于项目 Home 与 Search,需要共享一个模块时,最常见的办法就是将其抽成通用依赖并分别安装在各自项目中。
|
||||
|
||||
虽然 Monorepo 可以一定程度解决重复安装和修改困难的问题,但依然需要走本地编译。
|
||||
|
||||
### UMD 方式共享模块
|
||||
|
||||
真正 Runtime 的方式可能是 UMD 方式共享代码模块,即将模块用 Webpack UMD 模式打包,并输出到其他项目中。这是非常普遍的模块共享方式:
|
||||
|
||||
<img width=500 src="https://img.alicdn.com/tfs/TB1rQnSy4n1gK0jSZKPXXXvUXXa-2404-1484.png">
|
||||
|
||||
对于项目 Home 与 Search,直接利用 UMD 包复用一个模块。但这种技术方案问题也很明显,就是包体积无法达到本地编译时的优化效果,且库之间容易冲突。
|
||||
|
||||
### 微前端方式共享模块
|
||||
|
||||
微前端:micro-frontends (MFE) 也是最近比较火的模块共享管理方式,微前端就是要解决多项目并存问题,多项目并存的最大问题就是模块共享,不能有冲突。
|
||||
|
||||
<img width=500 src="https://img.alicdn.com/tfs/TB1vqvTy1T2gK0jSZFvXXXnFXXa-2410-1520.png">
|
||||
|
||||
由于微前端还要考虑样式冲突、生命周期管理,所以本文只聚焦在资源加载方式上。微前端一般有两种打包方式:
|
||||
|
||||
1. 子应用独立打包,模块更解耦,但无法抽取公共依赖等。
|
||||
2. 整体应用一起打包,很好解决上面的问题,但打包速度实在是太慢了,不具备水平扩展能力。
|
||||
|
||||
### 模块联邦方式
|
||||
|
||||
终于提到本文的主角了,作为 Webpack5 内置核心特性之一的 Federated Module:
|
||||
|
||||
<img width=500 src="https://img.alicdn.com/tfs/TB1qLz1yYj1gK0jSZFuXXcrHpXa-2414-1474.png">
|
||||
|
||||
从图中可以看到,这个方案是直接将一个应用的包应用于另一个应用,同时具备整体应用一起打包的公共依赖抽取能力。
|
||||
|
||||
让应用具备模块化输出能力,其实开辟了一种新的应用形态,即 “中心应用”,这个中心应用用于在线动态分发 Runtime 子模块,并不直接提供给用户使用:
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/tfs/TB1ymbWy7Y2gK0jSZFgXXc5OFXa-1346-1442.png">
|
||||
|
||||
对微前端而言,这张图就是一个完美的主应用,因为所有子应用都可以利用 Runtime 方式复用主应用的 Npm 包和模块,更好的集成到主应用中。
|
||||
|
||||
模块联邦的使用方式如下:
|
||||
|
||||
```js
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
|
||||
|
||||
module.exports = {
|
||||
// other webpack configs...
|
||||
plugins: [
|
||||
new ModuleFederationPlugin({
|
||||
name: "app_one_remote",
|
||||
remotes: {
|
||||
app_two: "app_two_remote",
|
||||
app_three: "app_three_remote"
|
||||
},
|
||||
exposes: {
|
||||
AppContainer: "./src/App"
|
||||
},
|
||||
shared: ["react", "react-dom", "react-router-dom"]
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./public/index.html",
|
||||
chunks: ["main"]
|
||||
})
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
模块联邦本身是一个普通的 Webpack 插件 `ModuleFederationPlugin`,插件有几个重要参数:
|
||||
|
||||
1. `name` 当前应用名称,需要全局唯一。
|
||||
2. `remotes` 可以将其他项目的 `name` 映射到当前项目中。
|
||||
3. `exposes` 表示导出的模块,只有在此申明的模块才可以作为远程依赖被使用。
|
||||
4. `shared` 是非常重要的参数,制定了这个参数,可以让远程加载的模块对应依赖改为使用本地项目的 React 或 ReactDOM。
|
||||
|
||||
比如设置了 `remotes: { app_two: "app_two_remote" }`,在代码中就可以直接利用以下方式直接从对方应用调用模块:
|
||||
|
||||
```js
|
||||
import { Search } from "app_two/Search";
|
||||
```
|
||||
|
||||
这个 `app_two/Search` 来自于 `app_two` 的配置:
|
||||
|
||||
```js
|
||||
// app_two 的 webpack 配置
|
||||
export default {
|
||||
plugins: [
|
||||
new ModuleFederationPlugin({
|
||||
name: "app_two",
|
||||
library: { type: "var", name: "app_two" },
|
||||
filename: "remoteEntry.js",
|
||||
exposes: {
|
||||
Search: "./src/Search"
|
||||
},
|
||||
shared: ["react", "react-dom"]
|
||||
})
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
正是因为 `Search` 在 `exposes` 被导出,我们因此可以使用 `[name]/[exposes_name]` 这个模块,这个模块对于被引用应用来说是一个本地模块。
|
||||
|
||||
## 3 总结
|
||||
|
||||
模块联邦为更大型的前端应用提供了开箱解决方案,并已经作为 Webpack5 官方模块内置,可以说是继 Externals 后最终的运行时代码复用解决方案。
|
||||
|
||||
另外 Webpack5 还内置了大量编译时缓存功能,可以看到,无论是性能还是多项目组织,Webpack5 都在尝试给出自己的最佳思路,期待 Webpack5 正式发布,前端工程化会迈向一个新的阶段。
|
||||
|
||||
> 讨论地址是:[精读《Webpack5 新特性 - 模块联邦》 · Issue #239 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/239)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,384 @@
|
||||
## 1 引言
|
||||
|
||||
[React Router v6](https://github.com/ReactTraining/react-router) alpha 版本发布了,本周通过 [A Sneak Peek at React Router v6](https://alligator.io/react/react-router-v6/) 这篇文章分析一下带来的改变。
|
||||
|
||||
## 2 概述
|
||||
|
||||
### <Switch> 更名为 <Routes>
|
||||
|
||||
一个不痛不痒的改动,使 API 命名更加规范。
|
||||
|
||||
```jsx
|
||||
// v5
|
||||
import { BrowserRouter, Switch, Route } from "react-router-dom";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Home />
|
||||
</Route>
|
||||
<Route path="/profile">
|
||||
<Profile />
|
||||
</Route>
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
在 React Router v6 版本里,直接使用 `Routes` 替代 `Switch`:
|
||||
|
||||
```jsx
|
||||
// v6
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="profile/*" element={<Profile />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### <Route> 升级
|
||||
|
||||
在 v5 版本里,想要给组件传参数是不太直观的,需要利用 RenderProps 的方式透传 `routeProps`:
|
||||
|
||||
```jsx
|
||||
import Profile from './Profile';
|
||||
|
||||
// v5
|
||||
<Route path=":userId" component={Profile} />
|
||||
<Route
|
||||
path=":userId"
|
||||
render={routeProps => (
|
||||
<Profile {...routeProps} animate={true} />
|
||||
)}
|
||||
/>
|
||||
|
||||
// v6
|
||||
<Route path=":userId" element={<Profile />} />
|
||||
<Route path=":userId" element={<Profile animate={true} />} />
|
||||
```
|
||||
|
||||
而在 v6 版本中,`render` 与 `component` 方案合并成了 `element` 方案,可以轻松传递 props 且不需要透传 `roteProps` 参数。
|
||||
|
||||
### 更方便的嵌套路由
|
||||
|
||||
在 v5 版本中,嵌套路由需要通过 `useRouteMatch` 拿到 `match`,并通过 `match.path` 的拼接实现子路由:
|
||||
|
||||
```jsx
|
||||
// v5
|
||||
import {
|
||||
BrowserRouter,
|
||||
Switch,
|
||||
Route,
|
||||
Link,
|
||||
useRouteMatch
|
||||
} from "react-router-dom";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Switch>
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="/profile" component={Profile} />
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function Profile() {
|
||||
let match = useRouteMatch();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav>
|
||||
<Link to={`${match.url}/me`}>My Profile</Link>
|
||||
</nav>
|
||||
|
||||
<Switch>
|
||||
<Route path={`${match.path}/me`}>
|
||||
<MyProfile />
|
||||
</Route>
|
||||
<Route path={`${match.path}/:id`}>
|
||||
<OthersProfile />
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
在 v6 版本中省去了 `useRouteMatch` 这一步,支持直接用 `path` 表示相对路径:
|
||||
|
||||
```jsx
|
||||
// v6
|
||||
import { BrowserRouter, Routes, Route, Link, Outlet } from "react-router-dom";
|
||||
|
||||
// Approach #1
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="profile/*" element={<Profile />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function Profile() {
|
||||
return (
|
||||
<div>
|
||||
<nav>
|
||||
<Link to="me">My Profile</Link>
|
||||
</nav>
|
||||
|
||||
<Routes>
|
||||
<Route path="me" element={<MyProfile />} />
|
||||
<Route path=":id" element={<OthersProfile />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Approach #2
|
||||
// You can also define all
|
||||
// <Route> in a single place
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="profile" element={<Profile />}>
|
||||
<Route path=":id" element={<MyProfile />} />
|
||||
<Route path="me" element={<OthersProfile />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function Profile() {
|
||||
return (
|
||||
<div>
|
||||
<nav>
|
||||
<Link to="me">My Profile</Link>
|
||||
</nav>
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
注意 `Outlet` 是渲染子路由的 Element。
|
||||
|
||||
### useNavigate 替代 useHistory
|
||||
|
||||
在 v5 版本中,主动跳转路由可以通过 `useHistory` 进行 `history.push` 等操作:
|
||||
|
||||
```jsx
|
||||
// v5
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
function MyButton() {
|
||||
let history = useHistory();
|
||||
function handleClick() {
|
||||
history.push("/home");
|
||||
}
|
||||
return <button onClick={handleClick}>Submit</button>;
|
||||
}
|
||||
```
|
||||
|
||||
而在 v6 版本中,可以通过 `useNavigate` 直接实现这个常用操作:
|
||||
|
||||
```jsx
|
||||
// v6
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
function MyButton() {
|
||||
let navigate = useNavigate();
|
||||
function handleClick() {
|
||||
navigate("/home");
|
||||
}
|
||||
return <button onClick={handleClick}>Submit</button>;
|
||||
}
|
||||
```
|
||||
|
||||
react-router 内部对 history 进行了封装,如果需要 `history.replace`,可以通过 `{ replace: true }` 参数指定:
|
||||
|
||||
```jsx
|
||||
// v5
|
||||
history.push("/home");
|
||||
history.replace("/home");
|
||||
|
||||
// v6
|
||||
navigate("/home");
|
||||
navigate("/home", { replace: true });
|
||||
```
|
||||
|
||||
### 更小的体积 8kb
|
||||
|
||||
由于代码几乎重构,v6 版本的代码压缩后体积从 20kb 缩小到 8kb。
|
||||
|
||||
## 3 精读
|
||||
|
||||
react-router v6 源码中有一段比较核心的理念,笔者拿出来与大家分享,对一些框架开发是大有裨益的。我们看 `useRoutes` 这段代码节选:
|
||||
|
||||
```jsx
|
||||
export function useRoutes(routes, basename = "", caseSensitive = false) {
|
||||
let {
|
||||
params: parentParams,
|
||||
pathname: parentPathname,
|
||||
route: parentRoute
|
||||
} = React.useContext(RouteContext);
|
||||
|
||||
if (warnAboutMissingTrailingSplatAt) {
|
||||
// ...
|
||||
}
|
||||
|
||||
basename = basename ? joinPaths([parentPathname, basename]) : parentPathname;
|
||||
|
||||
let navigate = useNavigate();
|
||||
let location = useLocation();
|
||||
let matches = React.useMemo(
|
||||
() => matchRoutes(routes, location, basename, caseSensitive),
|
||||
[routes, location, basename, caseSensitive]
|
||||
);
|
||||
|
||||
// ...
|
||||
|
||||
// Otherwise render an element.
|
||||
let element = matches.reduceRight((outlet, { params, pathname, route }) => {
|
||||
return (
|
||||
<RouteContext.Provider
|
||||
children={route.element}
|
||||
value={{
|
||||
outlet,
|
||||
params: readOnly({ ...parentParams, ...params }),
|
||||
pathname: joinPaths([basename, pathname]),
|
||||
route
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}, null);
|
||||
|
||||
return element;
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,利用 `React.Context`,v6 版本在每个路由元素渲染时都包裹了一层 `RouteContext`。
|
||||
|
||||
拿更方便的路由嵌套来说:
|
||||
|
||||
> 在 v6 版本中省去了 `useRouteMatch` 这一步,支持直接用 `path` 表示相对路径。
|
||||
|
||||
这就是利用这个方案做到的,因为给每一层路由文件包裹了 Context,所以在每一层都可以拿到上一层的 `path`,因此在拼接路由时可以完全由框架内部实现,而不需要用户在调用时预先拼接好。
|
||||
|
||||
再以 `useNavigate` 举例,有人觉得 `navigate` 这个封装仅停留在形式层,但其实在功能上也有封装,比如如果传入但是一个相对路径,会根据当前路由进行切换,下面是 `useNavigate` 代码节选:
|
||||
|
||||
```jsx
|
||||
export function useNavigate() {
|
||||
let { history, pending } = React.useContext(LocationContext);
|
||||
let { pathname } = React.useContext(RouteContext);
|
||||
|
||||
let navigate = React.useCallback(
|
||||
(to, { replace, state } = {}) => {
|
||||
if (typeof to === "number") {
|
||||
history.go(to);
|
||||
} else {
|
||||
let relativeTo = resolveLocation(to, pathname);
|
||||
|
||||
let method = !!replace || pending ? "replace" : "push";
|
||||
history[method](relativeTo, state);
|
||||
}
|
||||
},
|
||||
[history, pending, pathname]
|
||||
);
|
||||
|
||||
return navigate;
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,利用 `RouteContext` 拿到当前的 `pathname`,并根据 `resolveLocation` 对 `to` 与 `pathname` 进行路径拼接,而 `pathname` 就是通过 `RouteContext.Provider` 提供的。
|
||||
|
||||
### 巧用多层 Context Provider
|
||||
|
||||
很多时候我们利用 Context 停留在一个 `Provider`,多个 `useContext` 的层面上,这是 Context 最基础的用法,但相信读完 React Router v6 这篇文章,我们可以挖掘出 Context 更多的用法:多层 Context Provider。
|
||||
|
||||
**虽然说 Context Provider 存在多层会采取最近覆盖的原则,但这不仅仅是一条规避错误的功能,我们可以利用这个功能实现 React Router v6 这样的改良。**
|
||||
|
||||
为了更仔细说明这个特性,这里再举一个具体的例子:比如实现搭建渲染引擎时,每个组件都有一个 id,但这个 id 并不透出在组件的 props 上:
|
||||
|
||||
```jsx
|
||||
const Input = () => {
|
||||
// Input 组件在画布中会自动生成一个 id,但这个 id 组件无法通过 props 拿到
|
||||
};
|
||||
```
|
||||
|
||||
此时如果我们允许 Input 组件内部再创建一个子元素,又希望这个子元素的 id 是由 Input 推导出来的,我们可能需要用户这么做:
|
||||
|
||||
```jsx
|
||||
const Input = ({ id }) => {
|
||||
return <ComponentLoader id={id + "1"} />;
|
||||
};
|
||||
```
|
||||
|
||||
这样做有两个问题:
|
||||
|
||||
1. 将 id 暴露给 Input 组件,违背了之前设计的简洁性。
|
||||
2. 组件需要对 id 进行拼装,很麻烦。
|
||||
|
||||
这里遇到的问题和 React Router 遇到的一样,我们可以将代码简化成下面这样,但功能不变吗?
|
||||
|
||||
```jsx
|
||||
const Input = () => {
|
||||
return <ComponentLoader id="1" />;
|
||||
};
|
||||
```
|
||||
|
||||
答案是可以做到,我们可以利用 Context 实现这种方案。关键点就在于,渲染 Input 但组件容器需要包裹一个 Provider:
|
||||
|
||||
```jsx
|
||||
const ComponentLoader = ({ id, element }) => {
|
||||
<Context.Provider value={{ id }}>{element}</Context.Provider>;
|
||||
};
|
||||
```
|
||||
|
||||
那么对于内部的组件来说,在不同层级下调用 `useContext` 拿到的 id 是不同的,这正是我们想要的效果:
|
||||
|
||||
```jsx
|
||||
const ComponentLoader = ({id,element}) => {
|
||||
const { id: parentId } = useContext(Context)
|
||||
|
||||
<Context.Provider value={{ id: parentId + id }}>
|
||||
{element}
|
||||
</Context.Provider>
|
||||
}
|
||||
```
|
||||
|
||||
这样我们在 `Input` 内部调用的 `<ComponentLoader id="1" />` 实际上拼接的实际 id 是 `01`,而这完全抛到了外部引擎层处理,用户无需手动拼接。
|
||||
|
||||
## 4 总结
|
||||
|
||||
React Router v6 完全利用 Hooks 重构后,不仅代码量精简了很多,还变得更好用了,等发正式版的时候可以快速升级一波。
|
||||
|
||||
另外从 React Router v6 做的这些优化中,我们从源码中挖掘到了关于 Context 更巧妙的用法,希望这个方法可以帮助你运用到其他更复杂的项目设计中。
|
||||
|
||||
> 讨论地址是:[精读《React Router v6》 · Issue #241 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/241)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,354 @@
|
||||
## 1 引言
|
||||
|
||||
React Hooks 渐渐被国内前端团队所接受,但基于 Hooks 的数据流方案却还未固定,我们有 “100 种” 类似的选择,却各有利弊,让人难以取舍。
|
||||
|
||||
本周笔者就深入谈一谈对 Hooks 数据流的理解,相信读完文章后,可以从百花齐放的 Hooks 数据流方案中看到本质。
|
||||
|
||||
## 2 精读
|
||||
|
||||
基于 React Hooks 谈数据流,我们先从最不容易产生分歧的基础方案说起。
|
||||
|
||||
### 单组件数据流
|
||||
|
||||
单组件最简单的数据流一定是 `useState`:
|
||||
|
||||
```jsx
|
||||
function App() {
|
||||
const [count, setCount] = useState();
|
||||
}
|
||||
```
|
||||
|
||||
`useState` 在组件内用是毫无争议的,那么下个话题就一定是跨组件共享数据流了。
|
||||
|
||||
### 组件间共享数据流
|
||||
|
||||
跨组件最简单的方案就是 `useContext`:
|
||||
|
||||
```jsx
|
||||
const CountContext = createContext();
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState();
|
||||
return (
|
||||
<CountContext.Provider value={{ count, setCount }}>
|
||||
<Child />
|
||||
</CountContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Child() {
|
||||
const { count } = useContext(CountContext);
|
||||
}
|
||||
```
|
||||
|
||||
用法都是官方 API,显然也是毫无争议的,但问题是数据与 UI 不解耦,这个问题 [unstated-next](https://github.com/jamiebuilds/unstated-next) 已经为你想好解决方案了。
|
||||
|
||||
### 数据流与组件解耦
|
||||
|
||||
[unstated-next](https://github.com/jamiebuilds/unstated-next) 可以帮你把上面例子中,定义在 `App` 中的数据单独出来,形成一个自定义数据管理 Hook:
|
||||
|
||||
```jsx
|
||||
import { createContainer } from "unstated-next";
|
||||
|
||||
function useCounter() {
|
||||
const [count, setCount] = useState();
|
||||
return { count, setCount };
|
||||
}
|
||||
|
||||
const Counter = createContainer(useCounter);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Counter.Provider>
|
||||
<Child />
|
||||
</Counter.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Child() {
|
||||
const { count } = Counter.useContainer();
|
||||
}
|
||||
```
|
||||
|
||||
数据与 `App` 就解耦了,这下 `Counter` 再也不和 `App` 绑定了,`Counter` 可以和其他组件绑定作用了。
|
||||
|
||||
这个时候性能问题就慢慢浮出了水面,首当其冲的就是 `useState` 无法合并更新的问题,我们自然想到利用 `useReducer` 解决。
|
||||
|
||||
### 合并更新
|
||||
|
||||
`useReducer` 可以让数据合并更新,这也是 React 官方 API,毫无争议:
|
||||
|
||||
```jsx
|
||||
import { createContainer } from "unstated-next";
|
||||
|
||||
function useCounter() {
|
||||
const [state, dispath] = useReducer(
|
||||
(state, action) => {
|
||||
switch (action.type) {
|
||||
case "setCount":
|
||||
return {
|
||||
...state,
|
||||
count: action.setCount(state.count),
|
||||
};
|
||||
case "setFoo":
|
||||
return {
|
||||
...state,
|
||||
foo: action.setFoo(state.foo),
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
return state;
|
||||
},
|
||||
{ count: 0, foo: 0 }
|
||||
);
|
||||
|
||||
return { ...state, dispatch };
|
||||
}
|
||||
|
||||
const Counter = createContainer(useCounter);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Counter.Provider>
|
||||
<Child />
|
||||
</Counter.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Child() {
|
||||
const { count } = Counter.useContainer();
|
||||
}
|
||||
```
|
||||
|
||||
这下即便要同时更新 `count` 和 `foo`,我们也能通过抽象成一个 `reducer` 的方式合并更新。
|
||||
|
||||
然而还有性能问题:
|
||||
|
||||
```jsx
|
||||
function ChildCount() {
|
||||
const { count } = Counter.useContainer();
|
||||
}
|
||||
|
||||
function ChildFoo() {
|
||||
const { foo } = Counter.useContainer();
|
||||
}
|
||||
```
|
||||
|
||||
更新 `foo` 时,`ChildCount` 和 `ChildFoo` 同时会执行,但 `ChildCount` 没用到 `foo` 呀?这个原因是 `Counter.useContainer` 提供的数据流是一个引用整体,其子节点 `foo` 引用变化后会导致整个 Hook 重新执行,继而所有引用它的组件也会重新渲染。
|
||||
|
||||
此时我们发现可以利用 Redux `useSelector` 实现按需更新。
|
||||
|
||||
### 按需更新
|
||||
|
||||
首先我们利用 Redux 对数据流做一次改造:
|
||||
|
||||
```jsx
|
||||
import { createStore } from "redux";
|
||||
import { Provider, useSelector } from "react-redux";
|
||||
|
||||
function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case "setCount":
|
||||
return {
|
||||
...state,
|
||||
count: action.setCount(state.count),
|
||||
};
|
||||
case "setFoo":
|
||||
return {
|
||||
...state,
|
||||
foo: action.setFoo(state.foo),
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Child />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Child() {
|
||||
const { count } = useSelector(
|
||||
(state) => ({ count: state.count }),
|
||||
shallowEqual
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`useSelector` 可以让 `Child` 在 `count` 变化时才更新,而 `foo` 变化时不更新,这已经接近较为理想的性能目标了。
|
||||
|
||||
但 `useSelector` 的作用仅仅是计算结果不变化时阻止组件刷新,但并不能保证返回结果的引用不变化。
|
||||
|
||||
### 防止数据引用频繁变化
|
||||
|
||||
对于上面的场景,拿到 `count` 的引用是不变的,**但对于其他场景就不一定了**。
|
||||
|
||||
举个例子:
|
||||
|
||||
```jsx
|
||||
function Child() {
|
||||
const user = useSelector((state) => ({ user: state.user }), shallowEqual);
|
||||
|
||||
return <UserPage user={user} />;
|
||||
}
|
||||
```
|
||||
|
||||
**假设 `user` 对象在每次数据流更新引用都会发生变化**,那么 `shallowEqual` 自然是不起作用,那我们换成 `deepEqual`深对比呢?结果是引用依然会变,只是重渲染不那么频繁了:
|
||||
|
||||
```jsx
|
||||
function Child() {
|
||||
const user = useSelector(
|
||||
(state) => ({ user: state.user }),
|
||||
// 当 user 值变化时才重渲染
|
||||
deepEqual
|
||||
);
|
||||
|
||||
// 但此处拿到的 user 引用还是会变化
|
||||
|
||||
return <UserPage user={user} />;
|
||||
}
|
||||
```
|
||||
|
||||
是不是觉得在 `deepEqual` 的作用下,没有触发重渲染,`user` 的引用就不会变呢?答案是会变,因为 `user` 对象在每次数据流更新都会变,`useSelector` 在 `deepEqual` 作用下没有触发重渲染,但因为全局 reducer 隐去组件自己的重渲染依然会重新执行此函数,此时拿到的 `user` 引用会不断变化。
|
||||
|
||||
因此 `useSelector` `deepEqual` 一定要和 `useDeepMemo` 结合使用,才能保证 `user` 引用不会频繁改变:
|
||||
|
||||
```jsx
|
||||
function Child() {
|
||||
const user = useSelector(
|
||||
(state) => ({ user: state.user }),
|
||||
// 当 user 值变化时才重渲染
|
||||
deepEqual
|
||||
);
|
||||
|
||||
const userDeep = useDeepMemo(() => user, [user]);
|
||||
|
||||
return <UserPage user={userDeep} />;
|
||||
}
|
||||
```
|
||||
|
||||
当然这是比较极端的情况,只要看到 `deepEqual` 与 `useSelector` 同时作用了,就要问问自己其返回的值的引用会不会发生意外变化。
|
||||
|
||||
### 缓存查询函数
|
||||
|
||||
对于极限场景,即便控制了重渲染次数与返回结果的引用最大程度不变,还是可能存在性能问题,这最后一块性能问题就处在查询函数上。
|
||||
|
||||
上面的例子中,查询函数比较简单,但如果查询函数非常复杂就不一样了:
|
||||
|
||||
```jsx
|
||||
function Child() {
|
||||
const user = useSelector(
|
||||
(state) => ({ user: verySlowFunction(state.user) }),
|
||||
// 当 user 值变化时才重渲染
|
||||
deepEqual
|
||||
);
|
||||
|
||||
const userDeep = useDeepMemo(() => user, [user]);
|
||||
|
||||
return <UserPage user={userDeep} />;
|
||||
}
|
||||
```
|
||||
|
||||
我们假设 `verySlowFunction` 要遍历画布中 1000 个组件的 n 3 次方次,那组件的重渲染时间消耗与查询时间相比完全不值一提,我们需要考虑缓存查询函数。
|
||||
|
||||
一种方式是利用 [reselect](https://github.com/reduxjs/reselect) 根据参数引用进行缓存。
|
||||
|
||||
想象一下,如果 `state.user` 的引用不频繁变化,但 `verySlowFunction` 非常慢,理想情况是 `state.user` 引用变化后才重新执行 `verySlowFunction`,但上面的例子中,`useSelector` 并不知道还能这么优化,只能傻傻的每次渲染重复执行 `verySlowFunction`,哪怕 `state.user` 没有变。
|
||||
|
||||
此时我们要告诉引用,`state.user` 是否变化才是重新执行的关键:
|
||||
|
||||
```jsx
|
||||
import { createSelector } from "reselect";
|
||||
|
||||
const userSelector = createSelector(
|
||||
(state) => state.user,
|
||||
(user) => verySlowFunction(user)
|
||||
);
|
||||
|
||||
function Child() {
|
||||
const user = useSelector(
|
||||
(state) => userSelector(state),
|
||||
// 当 user 值变化时才重渲染
|
||||
deepEqual
|
||||
);
|
||||
|
||||
const userDeep = useDeepMemo(() => user, [user]);
|
||||
|
||||
return <UserPage user={userDeep} />;
|
||||
}
|
||||
```
|
||||
|
||||
在上面的例子中,通过 `createSelector` 创建的 `userSelector` 会一层层进行缓存,当第一个参数返回的 `state.user` 引用不变时,会直接返回上一次执行结果,直到其应用变化了才会继续往下执行。
|
||||
|
||||
> 这也说明了函数式保持幂等的重要性,如果 `verySlowFunction` 不是严格幂等的,这种缓存也无法实施。
|
||||
|
||||
看上去很美好,然而实战中你可能发现没有那么美好,因为上面的例子都建立在 **Selector 完全不依赖外部变量**。
|
||||
|
||||
### 结合外部变量的缓存查询
|
||||
|
||||
如果我们要查询的用户来自于不同地区,需要传递 `areaId` 加以识别,那么可以拆分为两个 Selector 函数:
|
||||
|
||||
```jsx
|
||||
import { createSelector } from "reselect";
|
||||
|
||||
const areaSelector = (state, props) => state.areas[props.areaId].user;
|
||||
|
||||
const userSelector = createSelector(areaSelector, (user) =>
|
||||
verySlowFunction(user)
|
||||
);
|
||||
|
||||
function Child() {
|
||||
const user = useSelector(
|
||||
(state) => userSelector(state, { areaId: 1 }),
|
||||
deepEqual
|
||||
);
|
||||
|
||||
const userDeep = useDeepMemo(() => user, [user]);
|
||||
|
||||
return <UserPage user={userDeep} />;
|
||||
}
|
||||
```
|
||||
|
||||
所以为了不在组件函数内调用 `createSelector`,我们需要尽可能将用到外部变量的地方抽象成一个通用 Selector,并作为 `createSelector` 的一个先手环节。
|
||||
|
||||
但 `userSelector` 提供给多个组件使用时缓存会失效,原因是我们只创建了一个 Selector 实例,因此这个函数还需要再包装一层高阶形态:
|
||||
|
||||
```jsx
|
||||
import { createSelector } from "reselect";
|
||||
|
||||
const userSelector = () =>
|
||||
createSelector(areaSelector, (user) => verySlowFunction(user));
|
||||
|
||||
function Child() {
|
||||
const customSelector = useMemo(userSelector, []);
|
||||
|
||||
const user = useSelector(
|
||||
(state) => customSelector(state, { areaId: 1 }),
|
||||
deepEqual
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
所以对于外部变量结合的环节,还需要 `useMemo` 与 `useSelector` 结合使用,`useMemo` 处理外部变量依赖的引用缓存,`useSelector` 处理 Store 相关引用缓存。
|
||||
|
||||
## 3 总结
|
||||
|
||||
基于 Hooks 的数据流方案不能算完美,我在写作这篇文章时就感觉到这种方案属于 “浅入深出”,简单场景还容易理解,随着场景逐步复杂,方案也变得越来越复杂。
|
||||
|
||||
但这种 Immutable 的数据流管理思路给了开发者非常自由的缓存控制能力,只要透彻理解上述概念,就可以开发出非常 “符合预期” 的数据缓存管理模型,只要精心维护,一切就变得非常有秩序。
|
||||
|
||||
> 讨论地址是:[精读《React Hooks 数据流》 · Issue #242 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/242)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,141 @@
|
||||
## 1 引言
|
||||
|
||||
从 [@types/react](https://unpkg.com/browse/@types/react@16.9.34/index.d.ts) 源码中挖掘一些 Typescript 使用技巧吧。
|
||||
|
||||
## 2 精读
|
||||
|
||||
### 泛型 extends
|
||||
|
||||
泛型可以指代可能的参数类型,但指代任意类型范围太模糊,当我们需要对参数类型加以限制,或者确定只处理某种类型参数时,就可以对泛型进行 extends 修饰。
|
||||
|
||||
问题:`React.lazy` 需要限制返回值是一个 `Promise<T>` 类型,且 `T` 必须是 React 组件类型。
|
||||
|
||||
方案:
|
||||
|
||||
```typescript
|
||||
function lazy<T extends ComponentType<any>>(
|
||||
factory: () => Promise<{ default: T }>
|
||||
): LazyExoticComponent<T>;
|
||||
```
|
||||
|
||||
`T extends ComponentType` 确保了 T 这个类型一定符合 `ComponentType` 这个 React 组件类型定义,我们再将 T 用到 `Promise<{ default: T }>` 位置即可。
|
||||
|
||||
## 泛型 extends + infer
|
||||
|
||||
如果有一种场景,需要拿到一个类型,这个类型是当某个参数符合某种结构时,这个结构内的一种子类型,就需要结合 泛型 extends + infer 了。
|
||||
|
||||
问题:`React.useReducer` 第一个参数是 Reducer,第二个参数是初始化参数,其实第二个参数的类型是第一个参数中回调函数第一个参数的类型,那我们怎么将这两个参数的关系联系到一起呢?
|
||||
|
||||
方案:
|
||||
|
||||
```typescript
|
||||
function useReducer<R extends Reducer<any, any>, I>(
|
||||
reducer: R,
|
||||
initializerArg: I & ReducerState<R>,
|
||||
initializer: (arg: I & ReducerState<R>) => ReducerState<R>
|
||||
): [ReducerState<R>, Dispatch<ReducerAction<R>>];
|
||||
|
||||
type ReducerState<R extends Reducer<any, any>> = R extends Reducer<infer S, any>
|
||||
? S
|
||||
: never;
|
||||
```
|
||||
|
||||
`R extends Reducer<any, any>` 的意思在上面已经提过了,也就是 R 必须符合 `Reducer` 结构,也就是 `reducer` 必须符合这个结构,之后重点来了:`initializerArg` 利用 `ReducerState` 这个类型直接从 `reducer` 的类型 `R` 中将第一个回调参数挖了出来并返回。
|
||||
|
||||
`ReducerState` 定义中 `R extends Reducer<infer S, any> ? S : never` 的含义是:如果 R 符合 `Reducer<infer S, any>` 类型,则返回类型 `S`,这个 `S` 是 `Reducer<infer S>` 也就是 State 位置的类型,否则返回 `never` 类型。
|
||||
|
||||
所以 infer 表示待推断类型,是非常强大的功能,可以指定在任意位置代指其类型,并配合 extends 判断是否符合结构,可以使类型推断具备一定编程能力。
|
||||
|
||||
要用 extends 的另一个原因是,只有 extends 才能将结构描述出来,我们才能精确定义 infer 指代类型的位置。
|
||||
|
||||
### 类型重载
|
||||
|
||||
当一个类型拥有多种使用可能性时,可以采用类型重载定义复数类型,Typescript 作用时会逐个匹配并找到第一个满足条件的。
|
||||
|
||||
问题:`createElement` 第一个参数支持 FunctionComponent 与 ClassComponent,而且传入参数不同,返回值的类型也不同。
|
||||
|
||||
方案:
|
||||
|
||||
```typescript
|
||||
function createElement<P extends {}>(
|
||||
type: FunctionComponent<P>,
|
||||
props?: (Attributes & P) | null,
|
||||
...children: ReactNode[]
|
||||
): FunctionComponentElement<P>;
|
||||
function createElement<P extends {}>(
|
||||
type: ClassType<
|
||||
P,
|
||||
ClassicComponent<P, ComponentState>,
|
||||
ClassicComponentClass<P>
|
||||
>,
|
||||
props?: (ClassAttributes<ClassicComponent<P, ComponentState>> & P) | null,
|
||||
...children: ReactNode[]
|
||||
): CElement<P, ClassicComponent<P, ComponentState>>;
|
||||
```
|
||||
|
||||
将 `createElement` 写两遍及以上,并配合不同的参数类型与返回值类型即可。
|
||||
|
||||
### 自定义类型收窄
|
||||
|
||||
我们可以通过 `typeof` 或 `instanceof` 做一些类型收窄工作,但有些类型甚至自定义类型的收窄判断函数需要自定义,我们可以通过 `is` 关键字定义自定义类型收窄判断函数。
|
||||
|
||||
问题:`isValidElement` 判断对象是否是合法的 React 元素,我们希望这个函数具备类型收窄的功能。
|
||||
|
||||
方案:
|
||||
|
||||
```typescript
|
||||
function isValidElement<P>(
|
||||
object: {} | null | undefined
|
||||
): object is ReactElement<P>;
|
||||
|
||||
const element: string | ReactElement = "";
|
||||
|
||||
if (isValidElement(element)) {
|
||||
element; // 自动推导类型为 ReactElement
|
||||
} else {
|
||||
element; // 自动推导类型为 string
|
||||
}
|
||||
```
|
||||
|
||||
基于这个方案,我们可以创建一些很有用的函数,比如 `isArray`,`isMap`,`isSet` 等等,通过 `is` 关键字时其被调用时具备类型收窄的功能。
|
||||
|
||||
### 用 Interface 定义函数
|
||||
|
||||
一般定义函数类型我们用 `type`,但有些情况下定义的函数既可被调用,也有一些默认属性值需要定义,我们可以继续用 Interface 定义。
|
||||
|
||||
问题:`FunctionComponent` 既可以当作函数调用,同时又能定义 `defaultProps` `displayName` 等固定属性。
|
||||
|
||||
方案:
|
||||
|
||||
```typescript
|
||||
interface FunctionComponent<P = {}> {
|
||||
(props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
|
||||
propTypes?: WeakValidationMap<P>;
|
||||
contextTypes?: ValidationMap<any>;
|
||||
defaultProps?: Partial<P>;
|
||||
displayName?: string;
|
||||
}
|
||||
```
|
||||
|
||||
`(props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null` 表示这种类型的变量可以作为函数执行:
|
||||
|
||||
```jsx
|
||||
const App: FunctionComponent = () => <div />;
|
||||
App.displayName = "App";
|
||||
```
|
||||
|
||||
## 3 总结
|
||||
|
||||
看完文章内容,相信你已经可以独立读懂 [@types/react](https://unpkg.com/browse/@types/react@16.9.34/index.d.ts) 这个包的所有类型定义!
|
||||
|
||||
更多基础内容可以阅读 [精读《Typescript2.0 - 2.9》](https://github.com/dt-fe/weekly/blob/7de3c77c3bdd7304c9e4b0c0f70c3ba6968ebd29/058.%E7%B2%BE%E8%AF%BB%E3%80%8ATypescript2.0%20-%202.9%E3%80%8B.md) 与 [精读《Typescript 3.2 新特性》](https://github.com/dt-fe/weekly/blob/v2/084.%E7%B2%BE%E8%AF%BB%E3%80%8ATypescript%203.2%20%E6%96%B0%E7%89%B9%E6%80%A7%E3%80%8B.md),由于 TS 更新频繁,后续 TS 技巧可能继续以阅读源码方式进行,希望这次选用的 React 类型源码可以让你印象深刻。
|
||||
|
||||
> 讨论地址是:[精读《@types/react 值得注意的 TS 技巧》 · Issue #245 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/245)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,133 @@
|
||||
## 1 引言
|
||||
|
||||
Error Boundaries 是 React16 提出来用来捕获渲染时错误的概念,今天我们一起读一读 [A Simple Guide to Error Boundaries in React](https://alligator.io/react/error-boundaries/) 这篇文章,了解一下这个重要机制。
|
||||
|
||||
## 2 概述
|
||||
|
||||
Error Boundaries 可以用来捕获渲染时错误,API 如下:
|
||||
|
||||
```jsx
|
||||
class MyErrorBoundary extends Component {
|
||||
state = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// 更新 state,下次渲染可以展示错误相关的 UI
|
||||
return { error: error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
// 错误上报
|
||||
logErrorToMyService(error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
// 渲染出错时的 UI
|
||||
return <p>Something broke</p>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `static getDerivedStateFromError`: 在出错后有机会修改 state 触发最后一次错误 fallback 的渲染。
|
||||
- `componentDidCatch`: 用于出错时副作用代码,比如错误上报等。
|
||||
|
||||
这两种方法中任意一个被定义时,这个组件就会成为 `Error Boundary` 组件,可以阻止子组件渲染时报错。
|
||||
|
||||
最后作者还提出一个建议,建议将 Error Boundary 单独作为一个组件,而不是将错误监听方法与业务组件耦合,一方面考虑到复用,另一方面则因为错误检测只对子组件生效。
|
||||
|
||||
好吧,其实 React 官方文档比这篇文章介绍的详细的多得多,原文介绍到此结束。
|
||||
|
||||
## 3 精读
|
||||
|
||||
[React Error Boundaries 官方文档](https://reactjs.org/docs/error-boundaries.html) 里提到了四种无法 Catch 的错误场景:
|
||||
|
||||
1. 回调事件。由于回调事件执行时机不在渲染周期内,因此无法被 Error Boundary Catch 住,如有必要得自行 try/catch。
|
||||
2. 异步。比如 `setTimeout` 或 `requestAnimationFrame`,和第一条同理。
|
||||
3. 服务端渲染。
|
||||
4. Error Boundary 组件自身触发的错误。因为只能捕获其子组件的错误。
|
||||
|
||||
这也是使用 Error Boundaries 最容易有疑问的地方。除了上面的情况,笔者结合自身经验再列举几种异常边界场景。
|
||||
|
||||
### 无法捕获编译时错误
|
||||
|
||||
很明显,即便是 React 官方 API `Error Boundary` 也只能捕获运行时错误,而对编译时错误无能为力。
|
||||
|
||||
编译时错误包括不限于编译环境错误、运行前的框架错误检查提示、TS/Flow 类型错误等,这些都是 `Error Boundary` 无法捕获的,而且没有更好的办法 Catch 住,遇到编译错误就在编译时解决吧,仅关注运行时错误就好了。
|
||||
|
||||
### 可以作用于 Function Component
|
||||
|
||||
虽然函数式组件无法定义 `Error Boundary`,但 `Error Boundary` 可以捕获函数式组件的错误,因此可以曲线救国:
|
||||
|
||||
```jsx
|
||||
// ErrorBoundary 组件
|
||||
class ErrorBoundary extends React.Component {
|
||||
// ...
|
||||
}
|
||||
|
||||
// 可以捕获所有组件异常,包括 Function Component 的子组件
|
||||
const App = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Child />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 对 Hooks 也可生效
|
||||
|
||||
对于 Hooks 中异常也可以生效,比如下面的代码:
|
||||
|
||||
```jsx
|
||||
const Child = (props) => {
|
||||
React.useEffect(() => {
|
||||
console.log(1);
|
||||
props.a.b;
|
||||
console.log(2);
|
||||
}, [props.a.b]);
|
||||
|
||||
return <div />;
|
||||
};
|
||||
```
|
||||
|
||||
要注意的是,出现在 deps 中的错误会立即被 Catch,导致 `console.log(1)` 都无法打印。但如果是下面的代码,则可以打印出 `console.log(1)`,无法打印出 `console.log(2)`:
|
||||
|
||||
```jsx
|
||||
const Child = (props) => {
|
||||
React.useEffect(() => {
|
||||
console.log(1);
|
||||
props.a.b;
|
||||
console.log(2);
|
||||
}, []);
|
||||
|
||||
return <div />;
|
||||
};
|
||||
```
|
||||
|
||||
所以 React 官网的这句话并不是指 `Error Boundary` 对 Hooks 不生效,而是指 `Error Boundary` 无法以 Hooks 方式指定,对功能是没有影响的:
|
||||
|
||||
> componentDidCatch and getDerivedStateFromError: There are no Hook equivalents for these methods yet, but they will be added soon.
|
||||
|
||||
所以这里的理解要注意一下,另外 React 官方文档 [Hooks FAQ](https://reactjs.org/docs/hooks-faq.html#how-do-lifecycle-methods-correspond-to-hooks) 有很多宝藏,建议抽时间逐条阅读。
|
||||
|
||||
## 4 总结
|
||||
|
||||
`Error Boundary` 可以捕获所有子元素渲染时异常,包括 render、各生命周期函数,但也有很多使用限制,希望你可以正确使用它。
|
||||
|
||||
错误捕获也不是万能的,更多时候我们要避免并及时修复错误,通过错误捕获降低出错时对用户体验的影响,并在第一时间内监控起来并快速修复。
|
||||
|
||||
最后,你有明明正确使用了 `Error Boundary` 却依然无法 Catch 住的错误 Case 吗?
|
||||
|
||||
> 讨论地址是:[精读《React Error Boundaries》 · Issue #246 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/246)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,199 @@
|
||||
## 1 引言
|
||||
|
||||
在数据中台做 BI 工具经常面对海量数据的渲染处理,除了组件本身性能优化之外,经常要排查整体页面性能瓶颈点,尤其是维护一些性能做得并不好的旧代码时。
|
||||
|
||||
React 性能调试是面对这种问题的必修课,借助 [Profiling React.js Performance](https://addyosmani.com/blog/profiling-react-js/) 这篇文章一起学习一下这个技能吧。
|
||||
|
||||
## 2 精读
|
||||
|
||||
本文介绍了众多性能检测工具与方法。
|
||||
|
||||
### React Profiler
|
||||
|
||||
`Profiler` 这个 API 是一种运行时 Debug 的补充,可以通过其 callback 拿到组件渲染信息,用法如下:
|
||||
|
||||
```jsx
|
||||
const Movies = ({ movies, addToQueue }) => (
|
||||
<React.Profiler id="Movies" onRender={callback}>
|
||||
<div />
|
||||
</React.Profiler>
|
||||
);
|
||||
|
||||
function callback(
|
||||
id,
|
||||
phase,
|
||||
actualTime,
|
||||
baseTime,
|
||||
startTime,
|
||||
commitTime,
|
||||
interactions
|
||||
) {}
|
||||
```
|
||||
|
||||
这个 callback 会在每次渲染时执行,渲染分为初始化和更新阶段,通过 `phase` 区分,下面是参数详细说明:
|
||||
|
||||
- id: 传入的 id。
|
||||
- phase: "mount" 或 "update",表示更新状态。
|
||||
- actualDuration: 实际渲染耗时。
|
||||
- baseDuration: 没有使用 memo 时的渲染预计耗时。
|
||||
- startTime: 开始渲染的时间。
|
||||
- commitTime: React 提交更新的时间
|
||||
- interactions: 何种原因导致的渲染,比如 `setState` 或 hooks changed 之类。
|
||||
|
||||
注意尽量不要轻易使用 `Profiler` 检测性能,因为 `Profiler` 本身也会消耗性能。
|
||||
|
||||
如果不想获得这么详细的渲染耗时,或者不想提前在代码中埋点,可以利用 DevTools 的 Profiler 查看更直观更简洁的渲染耗时:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1sPAuDuL2gK0jSZPhXXahvXXa-1846-1028.png">
|
||||
|
||||
其中 Ranked 可以展示按照渲染耗时排序后的结果,Interations 需要配合 Tracing API 使用,在后面会提到。
|
||||
|
||||
### Tracing API
|
||||
|
||||
利用 `scheduler/tracing` 提供的 `trace` API,我们可以记录某个动作的耗时,比如 “点击添加按钮收藏一个电影” 耗时多久:
|
||||
|
||||
```jsx
|
||||
import { render } from "react-dom";
|
||||
import { unstable_trace as trace } from "scheduler/tracing";
|
||||
|
||||
class MyComponent extends Component {
|
||||
addMovieButtonClick = (event) => {
|
||||
trace("Add To Movies Queue click", performance.now(), () => {
|
||||
this.setState({ itemAddedToQueue: true });
|
||||
});
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
在 Interations 中可以看到动作触发的耗时:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1XR.FDAY2gK0jSZFgXXc5OFXa-1846-1010.png">
|
||||
|
||||
这个动作还可以是渲染,比如可以记录 ReactDOM 渲染的耗时:
|
||||
|
||||
```jsx
|
||||
import { unstable_trace as trace } from "scheduler/tracing";
|
||||
|
||||
trace("initial render", performance.now(), () => {
|
||||
ReactDom.render(<App />, document.getElementById("app"));
|
||||
});
|
||||
```
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/tfs/TB18hyHfcKfxu4jSZPfXXb3dXXa-1846-740.png">
|
||||
|
||||
甚至还可以追踪异步的耗时:
|
||||
|
||||
```jsx
|
||||
import {
|
||||
unstable_trace as trace,
|
||||
unstable_wrap as wrap,
|
||||
} from "scheduler/tracing";
|
||||
|
||||
trace("Some event", performance.now(), () => {
|
||||
setTimeout(
|
||||
wrap(() => {
|
||||
// 异步操作
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
有了 `Profiler` 与 `trace` 这两件武器,我们可以监控任意元素的渲染耗时与交互耗时,几乎可以涵盖所有性能监控需要。
|
||||
|
||||
### Puppeteer
|
||||
|
||||
我们还可以利用 Puppeteer 实现自动化操作并打印报告:
|
||||
|
||||
```jsx
|
||||
const puppeteer = require("puppeteer");
|
||||
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch();
|
||||
const page = await browser.newPage();
|
||||
const navigationPromise = page.waitForNavigation();
|
||||
await page.goto("https://react-movies-queue.glitch.me/");
|
||||
await page.setViewport({ width: 1276, height: 689 });
|
||||
await navigationPromise;
|
||||
|
||||
const addMovieToQueueBtn =
|
||||
"li:nth-child(3) > .card > .card__info > div > .button";
|
||||
await page.waitForSelector(addMovieToQueueBtn);
|
||||
|
||||
// Begin profiling...
|
||||
await page.tracing.start({ path: "profile.json" });
|
||||
// Click the button
|
||||
await page.click(addMovieToQueueBtn);
|
||||
// Stop profliling
|
||||
await page.tracing.stop();
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
```
|
||||
|
||||
首先利用 `puppeteer` 创建一个浏览器,新建一个页面并打开 `https://react-movies-queue.glitch.me/` 这个 URL,等待页面加载完毕后利用 DOM 选择器找到按钮,利用 `page.click` API 模拟点击这个按钮,并在前后利用 `page.tracing` 记录性能变化,并将这个文件上传到 DevTools Performance 面板,就会得到一份自动的性能检测报告:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1623EDxz1gK0jSZSgXXavwpXa-2769-2289.png">
|
||||
|
||||
这张图相当重要,是浏览器综合运行开销分析的利器,最上面分为 4 个部分:
|
||||
|
||||
- FPS:每秒帧数,绿色竖线越高表示 FPS 越高,出现红线则表示出现了卡顿。
|
||||
- CPU:CPU 资源,用面积图展示消耗 CPU 资源的事件。
|
||||
- NET:网络消耗,每条横杠表示一种资源的加载。
|
||||
- HEAP:内存水位,由于短时间内看不出来是否会内存溢出,一般只用来简单看看内存消耗是否符合预期,对于内存溢出的检测需要用持续监控上报的方式。
|
||||
|
||||
下面会有一张 Network 详细图解,比如这张图:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1D.wKDxD1gK0jSZFyXXciOVXa-2868-750.png">
|
||||
|
||||
细线表示等待的时间,粗线表示实际加载的情况,其中浅色部分表示服务器等待时间,即从发送下载请求到服务器响应第一个字节的时间。这部分可以看出资源并行加载阻塞情况以及资源服务器响应时间是否存在问题。
|
||||
|
||||
Timings 展示了几个重要时间节点,这里列举一部分:
|
||||
|
||||
- FP:First Paint,第一次绘制。
|
||||
- FCP:First Contentful Paint,第一次内容绘制。
|
||||
- LCP:Largest Contentful Paint,最大内容绘制。
|
||||
- DCL:Document Content Loaded,DOM 内容加载完毕。
|
||||
|
||||
再下面是 JS 计算消耗,用了一张火焰图,火焰图是性能分析的常用可视化工具。以下面这张图为例:
|
||||
|
||||
<img width=350 src="https://img.alicdn.com/tfs/TB1JecIDrr1gK0jSZFDXXb9yVXa-1404-616.png">
|
||||
|
||||
看火焰图首先看跨度最长的函数,也就是最长的那条线,这是最耗时的部分,从左到右是浏览器脚本的调用顺序,从上到下是函数嵌套的顺序。
|
||||
|
||||
我们可以看到鼠标位置的 34 这个函数虽然长,但并不是性能瓶颈,因为下面执行的 n 函数长度和它一样,表示 34 函数的性能几乎无损耗,其性能由其调用的 n 函数决定。
|
||||
|
||||
我们可以利用这种方式一步步排查到叶子结点,找到对性能影响最大的元子函数。
|
||||
|
||||
### User Timing API
|
||||
|
||||
我们还可以利用 `performance.mark` 自定义性能检测节点:
|
||||
|
||||
```jsx
|
||||
// Record the time before running a task
|
||||
performance.mark("Movies:updateStart");
|
||||
// Do some work
|
||||
|
||||
// Record the time after running a task
|
||||
performance.mark("Movies:updateEnd");
|
||||
|
||||
// Measure the difference between the start and end of the task
|
||||
performance.measure("moviesRender", "Movies:updateStart", "Movies:updateEnd");
|
||||
```
|
||||
|
||||
这些节点可以在上面介绍的 Performance 面板中展示出来用于自定义分析。
|
||||
|
||||
## 3 总结
|
||||
|
||||
利用 Performance 进行通用性能分析,利用 React Profiler 进行 React 定制性能分析,这两个结合在一起几乎可以完成任何性能检测。
|
||||
|
||||
一般来说,首先应该用 React Profiler 进行 React 层面的问题筛查,这样更直观,更容易定位问题。如果某些问题跳出了 React 框架范围,或者不再能以组件粒度进行度量,我们可以回到 Performance 面板进行通用性能分析。
|
||||
|
||||
> 讨论地址是:[精读《React 性能调试》 · Issue #247 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/247)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,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))
|
||||
@@ -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 -> selectorFamily;atom -> 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))
|
||||
@@ -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))
|
||||
@@ -0,0 +1,154 @@
|
||||
## 1 引言
|
||||
|
||||
使用 React Hooks 的时候,经常出现执行次数过多甚至死循环的情况,我们可以利用 [use-what-changed](https://github.com/simbathesailor/use-what-changed) 进行依赖分析,找到哪个变量引用一直在变化。
|
||||
|
||||
据一个例子,比如你尝试在 Class 组件内部渲染 Function 组件,Class 组件是这么写的:
|
||||
|
||||
```jsx
|
||||
class Parent extends React.PureComponent {
|
||||
state = {
|
||||
text: "text",
|
||||
};
|
||||
|
||||
render() {
|
||||
return <Child setText={(text) => this.setState({ text })} />;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
子组件是这么写的:
|
||||
|
||||
```jsx
|
||||
const Child = ({ setText }) => {
|
||||
useEffect(() => {
|
||||
setText("ok");
|
||||
}, [setText]);
|
||||
|
||||
return null;
|
||||
};
|
||||
```
|
||||
|
||||
那么恭喜你,写出了一个最简单的死循环。这个场景里,我们本意是利用 `useEffect` 调用 `props.setText` 更新父组件的 `text`,但执行 `props.setText` 会导致父组件重渲染,由于父级 `setText={(text) => this.setState({ text })}` 的写法,每次重渲染拿到的 `props.setText` 引用都会变化,因此再次触发了 `useEffect` 回调执行,进而触发死循环。
|
||||
|
||||
仅仅打印出值是看不出变化的,引用的改变很隐蔽,为了判断是否变化还得存储上一次的值做比较,非常麻烦,use-what-changed 就是为了解决这个麻烦的。
|
||||
|
||||
## 2 精读
|
||||
|
||||
use-what-changed 使用方式如下:
|
||||
|
||||
```jsx
|
||||
function App() {
|
||||
useWhatChanged([a, b, c, d]); // debugs the below useEffect
|
||||
|
||||
React.useEffect(() => {
|
||||
// console.log("some thing changed , need to figure out")
|
||||
}, [a, b, c, d]);
|
||||
}
|
||||
```
|
||||
|
||||
将参数像依赖数组一样传入,刷新页面就可以在控制台看到引用或值是否变化,如果变化,对应行会展示 ✅ 并打印出上次的值与当前值:
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/tfs/TB1SN7JKbj1gK0jSZFOXXc7GpXa-908-460.png">
|
||||
|
||||
第一步是存储上一次依赖项的值,利用 `useRef` 实现:
|
||||
|
||||
```jsx
|
||||
function useWhatChanged(dependency?: any[]) {
|
||||
const dependencyRef = React.useRef(dependency);
|
||||
}
|
||||
```
|
||||
|
||||
然后利用 `useEffect`,对比 `dependency` 与 `dependencyRef` 的引用即可找到变化项:
|
||||
|
||||
```jsx
|
||||
React.useEffect(() => {
|
||||
let changed = false;
|
||||
const whatChanged = dependency
|
||||
? dependency.reduce((acc, dep, index) => {
|
||||
if (dependencyRef.current && dep !== dependencyRef.current[index]) {
|
||||
changed = true;
|
||||
|
||||
const oldValue = dependencyRef.current[index];
|
||||
dependencyRef.current[index] = dep;
|
||||
acc[`"✅" ${index}`] = {
|
||||
"Old Value": getPrintableInfo(oldValue),
|
||||
"New Value": getPrintableInfo(dep),
|
||||
};
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[`"⏺" ${index}`] = {
|
||||
"Old Value": getPrintableInfo(dep),
|
||||
"New Value": getPrintableInfo(dep),
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
|
||||
if (isDevelopment) {
|
||||
console.table(whatChanged);
|
||||
}
|
||||
}, [dependency]);
|
||||
```
|
||||
|
||||
1. 直接对比 deps 引用,不想等则将 `changed` 设为 true。
|
||||
2. 调试模式下,利用 console.table 打印出表格。
|
||||
3. 依赖项是 dependency,当依赖项变化时才打印 whatChanged。
|
||||
|
||||
以上就是其源码的核心逻辑,当然我们还可以简化输出,仅当有引用变化时才打印表格,否则只输出简单的 Log 信息:
|
||||
|
||||
```jsx
|
||||
if (isDevelopment) {
|
||||
if (changed) {
|
||||
console.table(whatChanged);
|
||||
} else {
|
||||
console.log(whatChanged);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### babel 插件
|
||||
|
||||
最后 use-what-changed 还提供了 babel 插件,只通过注释就能打印 `useMemo`、`useEffect` 等依赖变化信息。babel 配置如下:
|
||||
|
||||
```js
|
||||
{
|
||||
"plugins": [
|
||||
[
|
||||
"@simbathesailor/babel-plugin-use-what-changed",
|
||||
{
|
||||
"active": process.env.NODE_ENV === "development" // boolean
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
使用方式简化为:
|
||||
|
||||
```jsx
|
||||
// uwc-debug
|
||||
React.useEffect(() => {
|
||||
// console.log("some thing changed , need to figure out")
|
||||
}, [a, b, c, d]);
|
||||
```
|
||||
|
||||
将 Hooks 的 deps 数组直接转化为 use-what-changed 的入参。
|
||||
|
||||
## 3 总结
|
||||
|
||||
[use-what-changed](https://github.com/simbathesailor/use-what-changed) 补充了 Hooks 依赖变化的调试方法,对于 React 组件重渲染分析可以利用 React Dev Tool,可以参考 [精读《React 性能调试》](https://github.com/dt-fe/weekly/blob/v2/149.%20%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20%E6%80%A7%E8%83%BD%E8%B0%83%E8%AF%95%E3%80%8B.md)。
|
||||
|
||||
还有哪些实用的 Hooks 调试工具呢?欢迎分享。
|
||||
|
||||
> 讨论地址是:[精读《use-what-changed 源码》· Issue #256 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/256)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,272 @@
|
||||
## 1 引言
|
||||
|
||||
[IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) 可以轻松判断元素是否可见,在之前的 [精读《用 React 做按需渲染》](https://github.com/dt-fe/weekly/blob/v2/154.%20%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%94%A8%20React%20%E5%81%9A%E6%8C%89%E9%9C%80%E6%B8%B2%E6%9F%93%E3%80%8B.md) 中介绍了原生 API 的方法,这次刚好看到其 React 封装版本 [react-intersection-observer](https://github.com/thebuilder/react-intersection-observer),让我们看一看 React 封装思路。
|
||||
|
||||
## 2 简介
|
||||
|
||||
[react-intersection-observer](https://github.com/thebuilder/react-intersection-observer) 提供了 Hook `useInView` 判断元素是否在可视区域内,API 如下:
|
||||
|
||||
```jsx
|
||||
import React from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
const Component = () => {
|
||||
const [ref, inView] = useInView();
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<h2>{`Header inside viewport ${inView}.`}</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
由于判断元素是否可见是基于 dom 的,所以必须将 `ref` 回调函数传递给 **代表元素轮廓的 DOM 元素**,上面的例子中,我们将 `ref` 传递给了最外层 DIV。
|
||||
|
||||
`useInView` 还支持下列参数:
|
||||
|
||||
- `root`:检测是否可见基于的视窗元素,默认是整个浏览器 viewport。
|
||||
- `rootMargin`:root 边距,可以在检测时提前或者推迟固定像素判断。
|
||||
- `threshold`:是否可见的阈值,范围 0 ~ 1,0 表示任意可见即为可见,1 表示完全可见即为可见。
|
||||
- `triggerOnce`:是否仅触发一次。
|
||||
|
||||
## 3 精读
|
||||
|
||||
首先从入口函数 `useInView` 开始解读,这是一个 Hook,利用 `ref` 存储上一次 DOM 实例,`state` 则存储 `inView` 元素是否可见的 boolean 值:
|
||||
|
||||
```jsx
|
||||
export function useInView(
|
||||
options: IntersectionOptions = {},
|
||||
): InViewHookResponse {
|
||||
const ref = React.useRef<Element>()
|
||||
const [state, setState] = React.useState<State>(initialState)
|
||||
|
||||
// 中间部分..
|
||||
|
||||
return [setRef, state.inView, state.entry]
|
||||
}
|
||||
```
|
||||
|
||||
当组件 ref 被赋值时会调用 `setRef`,回调 `node` 是新的 DOM 节点,因此先 `unobserve(ref.current)` 取消旧节点的监听,再 `observe(node)` 对新节点进行监听,最后 `ref.current = node` 更新旧节点:
|
||||
|
||||
```jsx
|
||||
// 中间部分 1
|
||||
const setRef = React.useCallback(
|
||||
(node) => {
|
||||
if (ref.current) {
|
||||
unobserve(ref.current);
|
||||
}
|
||||
|
||||
if (node) {
|
||||
observe(
|
||||
node,
|
||||
(inView, intersection) => {
|
||||
setState({ inView, entry: intersection });
|
||||
|
||||
if (inView && options.triggerOnce) {
|
||||
// If it should only trigger once, unobserve the element after it's inView
|
||||
unobserve(node);
|
||||
}
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
// Store a reference to the node, so we can unobserve it later
|
||||
ref.current = node;
|
||||
},
|
||||
[options.threshold, options.root, options.rootMargin, options.triggerOnce]
|
||||
);
|
||||
```
|
||||
|
||||
另一段是,当 `ref` 不存在时会清空 `inView` 状态,毕竟当不存在监听对象时,inView 值只有重设为默认 false 才合理:
|
||||
|
||||
```jsx
|
||||
// 中间部分 2
|
||||
useEffect(() => {
|
||||
if (!ref.current && state !== initialState && !options.triggerOnce) {
|
||||
// If we don't have a ref, then reset the state (unless the hook is set to only `triggerOnce`)
|
||||
// This ensures we correctly reflect the current state - If you aren't observing anything, then nothing is inView
|
||||
setState(initialState);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
这就是入口文件的逻辑,我们可以看到还有两个重要的函数 `observe` 与 `unobserve`,这两个函数的实现在 [intersection.ts](https://github.com/thebuilder/react-intersection-observer/blob/master/src/intersection.ts) 文件中,这个文件有三个核心函数:`observe`、`unobserve`、`onChange`。
|
||||
|
||||
- `observe`:监听 element 是否在可视区域。
|
||||
- `unobserve`:取消监听。
|
||||
- `onChange`:处理 `observe` 变化的回调。
|
||||
|
||||
先看 `observe`,对于同一个 root 下的监听会做合并操作,因此需要生成 `observerId` 作为唯一标识,这个标识由 `getRootId`、`rootMargin`、`threshold` 共同决定。
|
||||
|
||||
对于同一个 root 的监听下,拿到 `new IntersectionObserver()` 创建的 `observerInstance` 实例,调用 `observerInstance.observe` 进行监听。这里存储了两个 Map - `OBSERVER_MAP` 与 `INSTANCE_MAP`,前者是保证同一 root 下 `IntersectionObserver` 实例唯一,后者存储了组件 `inView` 以及回调等信息,在 `onChange` 函数使用:
|
||||
|
||||
```jsx
|
||||
export function observe(
|
||||
element: Element,
|
||||
callback: ObserverInstanceCallback,
|
||||
options: IntersectionObserverInit = {}
|
||||
) {
|
||||
// IntersectionObserver needs a threshold to trigger, so set it to 0 if it's not defined.
|
||||
// Modify the options object, since it's used in the onChange handler.
|
||||
if (!options.threshold) options.threshold = 0;
|
||||
const { root, rootMargin, threshold } = options;
|
||||
// Validate that the element is not being used in another <Observer />
|
||||
invariant(
|
||||
!INSTANCE_MAP.has(element),
|
||||
"react-intersection-observer: Trying to observe %s, but it's already being observed by another instance.\nMake sure the `ref` is only used by a single <Observer /> instance.\n\n%s"
|
||||
);
|
||||
/* istanbul ignore if */
|
||||
if (!element) return;
|
||||
// Create a unique ID for this observer instance, based on the root, root margin and threshold.
|
||||
// An observer with the same options can be reused, so lets use this fact
|
||||
let observerId: string =
|
||||
getRootId(root) +
|
||||
(rootMargin
|
||||
? `${threshold.toString()}_${rootMargin}`
|
||||
: threshold.toString());
|
||||
|
||||
let observerInstance = OBSERVER_MAP.get(observerId);
|
||||
if (!observerInstance) {
|
||||
observerInstance = new IntersectionObserver(onChange, options);
|
||||
/* istanbul ignore else */
|
||||
if (observerId) OBSERVER_MAP.set(observerId, observerInstance);
|
||||
}
|
||||
|
||||
const instance: ObserverInstance = {
|
||||
callback,
|
||||
element,
|
||||
inView: false,
|
||||
observerId,
|
||||
observer: observerInstance,
|
||||
// Make sure we have the thresholds value. It's undefined on a browser like Chrome 51.
|
||||
thresholds:
|
||||
observerInstance.thresholds ||
|
||||
(Array.isArray(threshold) ? threshold : [threshold]),
|
||||
};
|
||||
|
||||
INSTANCE_MAP.set(element, instance);
|
||||
observerInstance.observe(element);
|
||||
|
||||
return instance;
|
||||
}
|
||||
```
|
||||
|
||||
对于 `onChange` 函数,因为采用了多元素监听,所以需要遍历 `changes` 数组,并判断 `intersectionRatio` 超过阈值判定为 `inView` 状态,通过 `INSTANCE_MAP` 拿到对应实例,修改其 `inView` 状态并执行 `callback`。
|
||||
|
||||
这个 `callback` 就对应了 `useInView` Hook 中 `observe` 的第二个参数回调:
|
||||
|
||||
```jsx
|
||||
function onChange(changes: IntersectionObserverEntry[]) {
|
||||
changes.forEach((intersection) => {
|
||||
const { isIntersecting, intersectionRatio, target } = intersection;
|
||||
const instance = INSTANCE_MAP.get(target);
|
||||
|
||||
// Firefox can report a negative intersectionRatio when scrolling.
|
||||
/* istanbul ignore else */
|
||||
if (instance && intersectionRatio >= 0) {
|
||||
// If threshold is an array, check if any of them intersects. This just triggers the onChange event multiple times.
|
||||
let inView = instance.thresholds.some((threshold) => {
|
||||
return instance.inView
|
||||
? intersectionRatio > threshold
|
||||
: intersectionRatio >= threshold;
|
||||
});
|
||||
|
||||
if (isIntersecting !== undefined) {
|
||||
// If isIntersecting is defined, ensure that the element is actually intersecting.
|
||||
// Otherwise it reports a threshold of 0
|
||||
inView = inView && isIntersecting;
|
||||
}
|
||||
|
||||
instance.inView = inView;
|
||||
instance.callback(inView, intersection);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
最后是 `unobserve` 取消监听的实现,在 `useInView` `setRef` 灌入新 Node 节点时,会调用 `unobserve` 对旧节点取消监听。
|
||||
|
||||
首先利用 `INSTANCE_MAP` 找到实例,调用 `observer.unobserve(element)` 销毁监听。最后销毁不必要的 `INSTANCE_MAP` 与 `ROOT_IDS` 存储。
|
||||
|
||||
```jsx
|
||||
export function unobserve(element: Element | null) {
|
||||
if (!element) return;
|
||||
const instance = INSTANCE_MAP.get(element);
|
||||
|
||||
if (instance) {
|
||||
const { observerId, observer } = instance;
|
||||
const { root } = observer;
|
||||
|
||||
observer.unobserve(element);
|
||||
|
||||
// Check if we are still observing any elements with the same threshold.
|
||||
let itemsLeft = false;
|
||||
// Check if we still have observers configured with the same root.
|
||||
let rootObserved = false;
|
||||
/* istanbul ignore else */
|
||||
if (observerId) {
|
||||
INSTANCE_MAP.forEach((item, key) => {
|
||||
if (key !== element) {
|
||||
if (item.observerId === observerId) {
|
||||
itemsLeft = true;
|
||||
rootObserved = true;
|
||||
}
|
||||
if (item.observer.root === root) {
|
||||
rootObserved = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!rootObserved && root) ROOT_IDS.delete(root);
|
||||
if (observer && !itemsLeft) {
|
||||
// No more elements to observe for threshold, disconnect observer
|
||||
observer.disconnect();
|
||||
}
|
||||
|
||||
// Remove reference to element
|
||||
INSTANCE_MAP.delete(element);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
从其实现角度来看,为了保证正确识别到子元素存在,一定要保证 `ref` 能持续传递给组件最外层 DOM,如果出现传递断裂,就会判定当前组件不在视图内,比如:
|
||||
|
||||
```jsx
|
||||
const Component = () => {
|
||||
const [ref, inView] = useInView();
|
||||
|
||||
return <Child ref={ref} />;
|
||||
};
|
||||
|
||||
const Child = ({ loading, ref }) => {
|
||||
if (loading) {
|
||||
// 这一步会判定为 inView:false
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
return <div ref={ref}>Child</div>;
|
||||
};
|
||||
```
|
||||
|
||||
如果你的代码基于 `inView` 做了阻止渲染的判定,那么这个组件进入 loading 后就无法改变状态了。为了避免这种情况,要么不要让 `ref` 的传递断掉,要么当没有拿到 `ref` 对象时判定 `inView` 为 true。
|
||||
|
||||
## 4 总结
|
||||
|
||||
分析了这么多 React- 类的库,其核心思想有两个:
|
||||
|
||||
1. 将原生 API 转换为框架特有 API,比如 React 系列的 Hooks 与 ref。
|
||||
2. 处理生命周期导致的边界情况,比如 dom 被更新时先 `unobserve` 再重新 `observe`。
|
||||
|
||||
看过 [react-intersection-observer](https://github.com/thebuilder/react-intersection-observer) 的源码后,你觉得还有可优化的地方吗?欢迎讨论。
|
||||
|
||||
> 讨论地址是:[react-intersection-observer 源码》· Issue #257 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/257)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,233 @@
|
||||
## 1 引言
|
||||
|
||||
Object 类型的比较是非常重要的基础知识,通过 [How to Compare Objects in JavaScript](https://dmitripavlutin.com/how-to-compare-objects-in-javascript/) 这篇文章,我们可以学到四种对比方法:引用对比、手动对比、浅对比、深对比。
|
||||
|
||||
## 2 简介
|
||||
|
||||
### 引用对比
|
||||
|
||||
下面三种对比方式用于 Object,皆在引用相同是才返回 `true`:
|
||||
|
||||
- `===`
|
||||
- `==`
|
||||
- `Object.is()`
|
||||
|
||||
```js
|
||||
const hero1 = {
|
||||
name: "Batman",
|
||||
};
|
||||
const hero2 = {
|
||||
name: "Batman",
|
||||
};
|
||||
|
||||
hero1 === hero1; // => true
|
||||
hero1 === hero2; // => false
|
||||
|
||||
hero1 == hero1; // => true
|
||||
hero1 == hero2; // => false
|
||||
|
||||
Object.is(hero1, hero1); // => true
|
||||
Object.is(hero1, hero2); // => false
|
||||
```
|
||||
|
||||
### 手动对比
|
||||
|
||||
写一个自定义函数,按照对象内容做自定义对比也是一种方案:
|
||||
|
||||
```js
|
||||
function isHeroEqual(object1, object2) {
|
||||
return object1.name === object2.name;
|
||||
}
|
||||
|
||||
const hero1 = {
|
||||
name: "Batman",
|
||||
};
|
||||
const hero2 = {
|
||||
name: "Batman",
|
||||
};
|
||||
const hero3 = {
|
||||
name: "Joker",
|
||||
};
|
||||
|
||||
isHeroEqual(hero1, hero2); // => true
|
||||
isHeroEqual(hero1, hero3); // => false
|
||||
```
|
||||
|
||||
如果要对比的对象 key 不多,或者在特殊业务场景需要时,这种手动对比方法其实还是蛮实用的。
|
||||
|
||||
但这种方案不够自动化,所以才有了浅对比。
|
||||
|
||||
### 浅对比
|
||||
|
||||
浅对比函数写法有很多,不过其效果都是标准的,下面给出了一种写法:
|
||||
|
||||
```js
|
||||
function shallowEqual(object1, object2) {
|
||||
const keys1 = Object.keys(object1);
|
||||
const keys2 = Object.keys(object2);
|
||||
|
||||
if (keys1.length !== keys2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let key of keys1) {
|
||||
if (object1[key] !== object2[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,浅对比就是将对象每个属性进行引用对比,算是一种性能上的平衡,尤其在 redux 下有特殊的意义。
|
||||
|
||||
下面给出了使用例子:
|
||||
|
||||
```js
|
||||
const hero1 = {
|
||||
name: "Batman",
|
||||
realName: "Bruce Wayne",
|
||||
};
|
||||
const hero2 = {
|
||||
name: "Batman",
|
||||
realName: "Bruce Wayne",
|
||||
};
|
||||
const hero3 = {
|
||||
name: "Joker",
|
||||
};
|
||||
|
||||
shallowEqual(hero1, hero2); // => true
|
||||
shallowEqual(hero1, hero3); // => false
|
||||
```
|
||||
|
||||
如果对象层级再多一层,浅对比就无效了,此时需要使用深对比。
|
||||
|
||||
### 深对比
|
||||
|
||||
深对比就是递归对比对象所有简单对象值,遇到复杂对象就逐个 key 进行对比,以此类推。
|
||||
|
||||
下面是一种实现方式:
|
||||
|
||||
```js
|
||||
function deepEqual(object1, object2) {
|
||||
const keys1 = Object.keys(object1);
|
||||
const keys2 = Object.keys(object2);
|
||||
|
||||
if (keys1.length !== keys2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of keys1) {
|
||||
const val1 = object1[key];
|
||||
const val2 = object2[key];
|
||||
const areObjects = isObject(val1) && isObject(val2);
|
||||
if (
|
||||
(areObjects && !deepEqual(val1, val2)) ||
|
||||
(!areObjects && val1 !== val2)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isObject(object) {
|
||||
return object != null && typeof object === "object";
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,只要遇到 Object 类型的 key,就会递归调用一次 `deepEqual` 进行比较,否则对于简单类型直接使用 `!==` 引用对比。
|
||||
|
||||
值得注意的是,数组类型也满足 `typeof object === "object"` 的条件,且 `Object.keys` 可以作用于数组,且 `object[key]` 也可作用于数组,因此数组和对象都可以采用相同方式处理。
|
||||
|
||||
有了深对比,再也不用担心复杂对象的比较了:
|
||||
|
||||
```js
|
||||
const hero1 = {
|
||||
name: "Batman",
|
||||
address: {
|
||||
city: "Gotham",
|
||||
},
|
||||
};
|
||||
const hero2 = {
|
||||
name: "Batman",
|
||||
address: {
|
||||
city: "Gotham",
|
||||
},
|
||||
};
|
||||
|
||||
deepEqual(hero1, hero2); // => true
|
||||
```
|
||||
|
||||
但深对比会造成性能损耗,不要小看递归的作用,在对象树复杂时,深对比甚至会导致严重的性能问题。
|
||||
|
||||
## 3 精读
|
||||
|
||||
### 常见的引用对比
|
||||
|
||||
引用对比是最常用的,一般在做 props 比较时,只允许使用引用对比:
|
||||
|
||||
```js
|
||||
this.props.style !== nextProps.style;
|
||||
```
|
||||
|
||||
如果看到有深对比的地方,一般就要有所警觉,这里是真的需要深对比吗?是不是其他地方写法有问题导致的。
|
||||
|
||||
比如在某处看到这样的代码:
|
||||
|
||||
```js
|
||||
deepEqual(this.props.style, nextProps.style);
|
||||
```
|
||||
|
||||
可能是父组件一处随意拼写导致的:
|
||||
|
||||
```jsx
|
||||
const Parent = () => {
|
||||
return <Child style={{ color: "red" }} />;
|
||||
};
|
||||
```
|
||||
|
||||
一个只解决局部问题的同学可能会采用 `deepEqual`,OK 这样也能解决问题,但一个有全局感的同学会这样解决问题:
|
||||
|
||||
```js
|
||||
this.props.style === nextProps.style;
|
||||
```
|
||||
|
||||
```jsx
|
||||
const Parent = () => {
|
||||
const style = useMemo(() => ({ color: "red" }), []);
|
||||
return <Child style={style} />;
|
||||
};
|
||||
```
|
||||
|
||||
从性能上来看,`Parent` 定义的 `style` 只会执行一次且下次渲染几乎没有对比损耗(依赖为空数组),子组件引用对比性能最佳,这样的组合一定优于 `deepEqual` 的例子。
|
||||
|
||||
### 常见的浅对比
|
||||
|
||||
浅对比也在判断组件是否重渲染时很常用:
|
||||
|
||||
```jsx
|
||||
shouldComponentUpdate(nextProps) {
|
||||
return !shallowEqual(this.props, nextProps)
|
||||
}
|
||||
```
|
||||
|
||||
原因是 `this.props` 这个对象引用的变化在逻辑上是无需关心的,因为应用只会使用到 `this.props[key]` 这一层级,再考虑到 React 组件生态下,Immutable 的上下文保证了任何对象子属性变化一定导致对象整体引用变化,可以放心的进行浅对比。
|
||||
|
||||
最少见的就是手动对比和深对比,如果你看到一段代码中使用了深对比,大概率这段代码可以被优化为浅对比。
|
||||
|
||||
## 4 总结
|
||||
|
||||
虽然今天总结了 4 种比较 Object 对象的方式,但在实际项目中,应该尽可能使用引用对比,其次是浅对比和手动对比,最坏的情况是使用深对比。
|
||||
|
||||
> 讨论地址是:[精读《如何比较 Object 对象》· Issue #258 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/258)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,481 @@
|
||||
## 1 引言
|
||||
|
||||
随着 [Typescript 4 Beta](https://devblogs.microsoft.com/typescript/announcing-typescript-4-0-beta/) 的发布,又带来了许多新功能,其中 Variadic Tuple Types 解决了大量重载模版代码的顽疾,使得这次更新非常有意义。
|
||||
|
||||
## 2 简介
|
||||
|
||||
### 可变元组类型
|
||||
|
||||
考虑 `concat` 场景,接收两个数组或者元组类型,组成一个新数组:
|
||||
|
||||
```typescript
|
||||
function concat(arr1, arr2) {
|
||||
return [...arr1, ...arr2];
|
||||
}
|
||||
```
|
||||
|
||||
如果要定义 `concat` 的类型,以往我们会通过枚举的方式,先枚举第一个参数数组中的每一项:
|
||||
|
||||
```typescript
|
||||
function concat<>(arr1: [], arr2: []): [A];
|
||||
function concat<A>(arr1: [A], arr2: []): [A];
|
||||
function concat<A, B>(arr1: [A, B], arr2: []): [A, B];
|
||||
function concat<A, B, C>(arr1: [A, B, C], arr2: []): [A, B, C];
|
||||
function concat<A, B, C, D>(arr1: [A, B, C, D], arr2: []): [A, B, C, D];
|
||||
function concat<A, B, C, D, E>(arr1: [A, B, C, D, E], arr2: []): [A, B, C, D, E];
|
||||
function concat<A, B, C, D, E, F>(arr1: [A, B, C, D, E, F], arr2: []): [A, B, C, D, E, F];)
|
||||
```
|
||||
|
||||
再枚举第二个参数中每一项,如果要完成所有枚举,仅考虑数组长度为 6 的情况,就要定义 36 次重载,代码几乎不可维护:
|
||||
|
||||
```typescript
|
||||
function concat<A2>(arr1: [], arr2: [A2]): [A2];
|
||||
function concat<A1, A2>(arr1: [A1], arr2: [A2]): [A1, A2];
|
||||
function concat<A1, B1, A2>(arr1: [A1, B1], arr2: [A2]): [A1, B1, A2];
|
||||
function concat<A1, B1, C1, A2>(
|
||||
arr1: [A1, B1, C1],
|
||||
arr2: [A2]
|
||||
): [A1, B1, C1, A2];
|
||||
function concat<A1, B1, C1, D1, A2>(
|
||||
arr1: [A1, B1, C1, D1],
|
||||
arr2: [A2]
|
||||
): [A1, B1, C1, D1, A2];
|
||||
function concat<A1, B1, C1, D1, E1, A2>(
|
||||
arr1: [A1, B1, C1, D1, E1],
|
||||
arr2: [A2]
|
||||
): [A1, B1, C1, D1, E1, A2];
|
||||
function concat<A1, B1, C1, D1, E1, F1, A2>(
|
||||
arr1: [A1, B1, C1, D1, E1, F1],
|
||||
arr2: [A2]
|
||||
): [A1, B1, C1, D1, E1, F1, A2];
|
||||
```
|
||||
|
||||
如果我们采用批量定义的方式,问题也不会得到解决,因为参数类型的顺序得不到保证:
|
||||
|
||||
```typescript
|
||||
function concat<T, U>(arr1: T[], arr2, U[]): Array<T | U>;
|
||||
```
|
||||
|
||||
在 Typescript 4,可以在定义中对数组进行解构,通过几行代码优雅的解决可能要重载几百次的场景:
|
||||
|
||||
```typescript
|
||||
type Arr = readonly any[];
|
||||
|
||||
function concat<T extends Arr, U extends Arr>(arr1: T, arr2: U): [...T, ...U] {
|
||||
return [...arr1, ...arr2];
|
||||
}
|
||||
```
|
||||
|
||||
上面例子中,`Arr` 类型告诉 TS `T` 与 `U` 是数组类型,再通过 `[...T, ...U]` 按照逻辑顺序依次拼接类型。
|
||||
|
||||
再比如 `tail`,返回除第一项外剩下元素:
|
||||
|
||||
```typescript
|
||||
function tail(arg) {
|
||||
const [_, ...result] = arg;
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
同样告诉 TS `T` 是数组类型,且 `arr: readonly [any, ...T]` 申明了 `T` 类型表示除第一项其余项的类型,TS 可自动将 `T` 类型关联到对象 `rest`:
|
||||
|
||||
```typescript
|
||||
function tail<T extends any[]>(arr: readonly [any, ...T]) {
|
||||
const [_ignored, ...rest] = arr;
|
||||
return rest;
|
||||
}
|
||||
|
||||
const myTuple = [1, 2, 3, 4] as const;
|
||||
const myArray = ["hello", "world"];
|
||||
|
||||
// type [2, 3, 4]
|
||||
const r1 = tail(myTuple);
|
||||
|
||||
// type [2, 3, ...string[]]
|
||||
const r2 = tail([...myTuple, ...myArray] as const);
|
||||
```
|
||||
|
||||
另外之前版本的 TS 只能将类型解构放在最后一个位置:
|
||||
|
||||
```typescript
|
||||
type Strings = [string, string];
|
||||
type Numbers = [number, number];
|
||||
|
||||
// [string, string, number, number]
|
||||
type StrStrNumNum = [...Strings, ...Numbers];
|
||||
```
|
||||
|
||||
如果你尝试将 `[...Strings, ...Numbers]` 这种写法,将会得到一个错误提示:
|
||||
|
||||
```text
|
||||
A rest element must be last in a tuple type.
|
||||
```
|
||||
|
||||
但在 Typescript 4 版本支持了这种语法:
|
||||
|
||||
```typescript
|
||||
type Strings = [string, string];
|
||||
type Numbers = number[];
|
||||
|
||||
// [string, string, ...Array<number | boolean>]
|
||||
type Unbounded = [...Strings, ...Numbers, boolean];
|
||||
```
|
||||
|
||||
对于再复杂一些的场景,例如高阶函数 `partialCall`,支持一定程度的柯里化:
|
||||
|
||||
```typescript
|
||||
function partialCall(f, ...headArgs) {
|
||||
return (...tailArgs) => f(...headArgs, ...tailArgs);
|
||||
}
|
||||
```
|
||||
|
||||
我们可以通过上面的特性对其进行类型定义,将函数 `f` 第一个参数类型定义为有顺序的 `[...T, ...U]`:
|
||||
|
||||
```typescript
|
||||
type Arr = readonly unknown[];
|
||||
|
||||
function partialCall<T extends Arr, U extends Arr, R>(
|
||||
f: (...args: [...T, ...U]) => R,
|
||||
...headArgs: T
|
||||
) {
|
||||
return (...b: U) => f(...headArgs, ...b);
|
||||
}
|
||||
```
|
||||
|
||||
测试效果如下:
|
||||
|
||||
```typescript
|
||||
const foo = (x: string, y: number, z: boolean) => {};
|
||||
|
||||
// This doesn't work because we're feeding in the wrong type for 'x'.
|
||||
const f1 = partialCall(foo, 100);
|
||||
// ~~~
|
||||
// error! Argument of type 'number' is not assignable to parameter of type 'string'.
|
||||
|
||||
// This doesn't work because we're passing in too many arguments.
|
||||
const f2 = partialCall(foo, "hello", 100, true, "oops");
|
||||
// ~~~~~~
|
||||
// error! Expected 4 arguments, but got 5.
|
||||
|
||||
// This works! It has the type '(y: number, z: boolean) => void'
|
||||
const f3 = partialCall(foo, "hello");
|
||||
|
||||
// What can we do with f3 now?
|
||||
|
||||
f3(123, true); // works!
|
||||
|
||||
f3();
|
||||
// error! Expected 2 arguments, but got 0.
|
||||
|
||||
f3(123, "hello");
|
||||
// ~~~~~~~
|
||||
// error! Argument of type '"hello"' is not assignable to parameter of type 'boolean'
|
||||
```
|
||||
|
||||
值得注意的是,`const f3 = partialCall(foo, "hello");` 这段代码由于还没有执行到 `foo`,因此只匹配了第一个 `x:string` 类型,虽然后面 `y: number, z: boolean` 也是必选,但因为 `foo` 函数还未执行,此时只是参数收集阶段,因此不会报错,等到 `f3(123, true)` 执行时就会校验必选参数了,因此 `f3()` 时才会提示参数数量不正确。
|
||||
|
||||
### 元组标记
|
||||
|
||||
下面两个函数定义在功能上是一样的:
|
||||
|
||||
```typescript
|
||||
function foo(...args: [string, number]): void {
|
||||
// ...
|
||||
}
|
||||
|
||||
function foo(arg0: string, arg1: number): void {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
但还是有微妙的区别,下面的函数对每个参数都有名称标记,但上面通过解构定义的类型则没有,针对这种情况,Typescript 4 支持了元组标记:
|
||||
|
||||
```typescript
|
||||
type Range = [start: number, end: number];
|
||||
```
|
||||
|
||||
同时也支持与解构一起使用:
|
||||
|
||||
```typescript
|
||||
type Foo = [first: number, second?: string, ...rest: any[]];
|
||||
```
|
||||
|
||||
### Class 从构造函数推断成员变量类型
|
||||
|
||||
构造函数在类实例化时负责一些初始化工作,比如为成员变量赋值,在 Typescript 4,在构造函数里对成员变量的赋值可以直接为成员变量推导类型:
|
||||
|
||||
```typescript
|
||||
class Square {
|
||||
// Previously: implicit any!
|
||||
// Now: inferred to `number`!
|
||||
area;
|
||||
sideLength;
|
||||
|
||||
constructor(sideLength: number) {
|
||||
this.sideLength = sideLength;
|
||||
this.area = sideLength ** 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果对成员变量赋值包含在条件语句中,还能识别出存在 `undefined` 的风险:
|
||||
|
||||
```typescript
|
||||
class Square {
|
||||
sideLength;
|
||||
|
||||
constructor(sideLength: number) {
|
||||
if (Math.random()) {
|
||||
this.sideLength = sideLength;
|
||||
}
|
||||
}
|
||||
|
||||
get area() {
|
||||
return this.sideLength ** 2;
|
||||
// ~~~~~~~~~~~~~~~
|
||||
// error! Object is possibly 'undefined'.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果在其他函数中初始化,则 TS 不能自动识别,需要用 `!:` 显式申明类型:
|
||||
|
||||
```typescript
|
||||
class Square {
|
||||
// definite assignment assertion
|
||||
// v
|
||||
sideLength!: number;
|
||||
// ^^^^^^^^
|
||||
// type annotation
|
||||
|
||||
constructor(sideLength: number) {
|
||||
this.initialize(sideLength);
|
||||
}
|
||||
|
||||
initialize(sideLength: number) {
|
||||
this.sideLength = sideLength;
|
||||
}
|
||||
|
||||
get area() {
|
||||
return this.sideLength ** 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 短路赋值语法
|
||||
|
||||
针对以下三种短路语法提供了快捷赋值语法:
|
||||
|
||||
```typescript
|
||||
a &&= b; // a = a && b
|
||||
a ||= b; // a = a || b
|
||||
a ??= b; // a = a ?? b
|
||||
```
|
||||
|
||||
### catch error unknown 类型
|
||||
|
||||
Typescript 4.0 之后,我们可以将 catch error 定义为 `unknown` 类型,以保证后面的代码以健壮的类型判断方式书写:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
// ...
|
||||
} catch (e) {
|
||||
// error!
|
||||
// Property 'toUpperCase' does not exist on type 'unknown'.
|
||||
console.log(e.toUpperCase());
|
||||
|
||||
if (typeof e === "string") {
|
||||
// works!
|
||||
// We've narrowed 'e' down to the type 'string'.
|
||||
console.log(e.toUpperCase());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
PS:在之前的版本,`catch (e: unknown)` 会报错,提示无法为 `error` 定义 `unknown` 类型。
|
||||
|
||||
### 自定义 JSX 工厂
|
||||
|
||||
TS 4 支持了 `jsxFragmentFactory` 参数定义 Fragment 工厂函数:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "commonjs",
|
||||
"jsx": "react",
|
||||
"jsxFactory": "h",
|
||||
"jsxFragmentFactory": "Fragment"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
还可以通过注释方式覆盖单文件的配置:
|
||||
|
||||
```typescript
|
||||
// Note: these pragma comments need to be written
|
||||
// with a JSDoc-style multiline syntax to take effect.
|
||||
/** @jsx h */
|
||||
/** @jsxFrag Fragment */
|
||||
|
||||
import { h, Fragment } from "preact";
|
||||
|
||||
let stuff = (
|
||||
<>
|
||||
<div>Hello</div>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
以上代码编译后解析结果如下:
|
||||
|
||||
```typescript
|
||||
// Note: these pragma comments need to be written
|
||||
// with a JSDoc-style multiline syntax to take effect.
|
||||
/** @jsx h */
|
||||
/** @jsxFrag Fragment */
|
||||
import { h, Fragment } from "preact";
|
||||
let stuff = h(Fragment, null, h("div", null, "Hello"));
|
||||
```
|
||||
|
||||
### 其他升级
|
||||
|
||||
其他的升级快速介绍:
|
||||
|
||||
**构建速度提升**,提升了 `--incremental` + `--noEmitOnError` 场景的构建速度。
|
||||
|
||||
**支持 `--incremental` + `--noEmit` 参数同时生效。**
|
||||
|
||||
**支持 `@deprecated` 注释,** 使用此注释时,代码中会使用 ~~删除线~~ 警告调用者。
|
||||
|
||||
**局部 TS Server 快速启动功能,** 打开大型项目时,TS Server 要准备很久,Typescript 4 在 VSCode 编译器下做了优化,可以提前对当前打开的单文件进行部分语法响应。
|
||||
|
||||
**优化自动导入,** 现在 `package.json` `dependencies` 字段定义的依赖将优先作为自动导入的依据,而不再是遍历 `node_modules` 导入一些非预期的包。
|
||||
|
||||
除此之外,还有几个 Break Change:
|
||||
|
||||
`lib.d.ts` 类型升级,主要是移除了 `document.origin` 定义。
|
||||
|
||||
覆盖父 Class 属性的 getter 或 setter 现在都会提示错误。
|
||||
|
||||
通过 `delete` 删除的属性必须是可选的,如果试图用 `delete` 删除一个必选的 key,则会提示错误。
|
||||
|
||||
## 3 精读
|
||||
|
||||
Typescript 4 最大亮点就是可变元组类型了,但可变元组类型也不能解决所有问题。
|
||||
|
||||
拿笔者的场景来说,函数 `useDesigner` 作为自定义 React Hook 与 `useSelector` 结合支持 connect redux 数据流的值,其调用方式是这样的:
|
||||
|
||||
```typescript
|
||||
const nameSelector = (state: any) => ({
|
||||
name: state.name as string,
|
||||
});
|
||||
|
||||
const ageSelector = (state: any) => ({
|
||||
age: state.age as number,
|
||||
});
|
||||
|
||||
const App = () => {
|
||||
const { name, age } = useDesigner(nameSelector, ageSelector);
|
||||
};
|
||||
```
|
||||
|
||||
`name` 与 `age` 是 Selector 注册的,内部实现方式必然是 `useSelector` + reduce,但类型定义就麻烦了,通过重载可以这么做:
|
||||
|
||||
```typescript
|
||||
import * as React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
type Function = (...args: any) => any;
|
||||
|
||||
export function useDesigner();
|
||||
export function useDesigner<T1 extends Function>(
|
||||
t1: T1
|
||||
): ReturnType<T1> ;
|
||||
export function useDesigner<T1 extends Function, T2 extends Function>(
|
||||
t1: T1,
|
||||
t2: T2
|
||||
): ReturnType<T1> & ReturnType<T2> ;
|
||||
export function useDesigner<
|
||||
T1 extends Function,
|
||||
T2 extends Function,
|
||||
T3 extends Function
|
||||
>(
|
||||
t1: T1,
|
||||
t2: T2,
|
||||
t3: T3,
|
||||
t4: T4,
|
||||
): ReturnType<T1> &
|
||||
ReturnType<T2> &
|
||||
ReturnType<T3> &
|
||||
ReturnType<T4> &
|
||||
;
|
||||
export function useDesigner<
|
||||
T1 extends Function,
|
||||
T2 extends Function,
|
||||
T3 extends Function,
|
||||
T4 extends Function
|
||||
>(
|
||||
t1: T1,
|
||||
t2: T2,
|
||||
t3: T3,
|
||||
t4: T4
|
||||
): ReturnType<T1> &
|
||||
ReturnType<T2> &
|
||||
ReturnType<T3> &
|
||||
ReturnType<T4> &
|
||||
;
|
||||
export function useDesigner(...selectors: any[]) {
|
||||
return useSelector((state) =>
|
||||
selectors.reduce((selected, selector) => {
|
||||
return {
|
||||
...selected,
|
||||
...selector(state),
|
||||
};
|
||||
}, {})
|
||||
) as any;
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,笔者需要将 `useDesigner` 传入的参数通过函数重载方式一一传入,上面的例子只支持到了三个参数,如果传入了第四个参数则函数定义会失效,因此业界做法一般是定义十几个重载,这样会导致函数定义非常冗长。
|
||||
|
||||
但参考 TS4 的例子,我们可以避免类型重载,而通过枚举的方式支持:
|
||||
|
||||
```typescript
|
||||
type Func = (state?: any) => any;
|
||||
type Arr = readonly Func[];
|
||||
|
||||
const useDesigner = <T extends Arr>(
|
||||
...selectors: T
|
||||
): ReturnType<T[0]> &
|
||||
ReturnType<T[1]> &
|
||||
ReturnType<T[2]> &
|
||||
ReturnType<T[3]> => {
|
||||
return useSelector((state) =>
|
||||
selectors.reduce((selected, selector) => {
|
||||
return {
|
||||
...selected,
|
||||
...selector(state),
|
||||
};
|
||||
}, {})
|
||||
) as any;
|
||||
};
|
||||
```
|
||||
|
||||
可以看到,最大的变化是不需要写四遍重载了,但由于场景和 `concat` 不同,这个例子返回值不是简单的 `[...T, ...U]`,而是 `reduce` 的结果,所以目前还只能通过枚举的方式支持。
|
||||
|
||||
当然可能存在不用枚举就可以支持无限长度的入参类型解析的方案,因笔者水平有限,暂未想到更好的解法,如果你有更好的解法,欢迎告知笔者。
|
||||
|
||||
## 4 总结
|
||||
|
||||
Typescript 4 带来了更强类型语法,更智能的类型推导,更快的构建速度以及更合理的开发者工具优化,唯一的几个 Break Change 不会对项目带来实质影响,期待正式版的发布。
|
||||
|
||||
> 讨论地址是:[精读《Typescript 4》· Issue #259 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/259)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,115 @@
|
||||
## 1 引言
|
||||
|
||||
在说低代码搭建之前,首先要理解什么是搭建(本文搭建指通过 Web 交互搭建一个自定义的新页面)。
|
||||
|
||||
**我认为搭建的本质是提效** ,而提效又分为对研发人员的提效,以及对客户的提效:
|
||||
|
||||
- 对研发人员的提效:相对于 Pro Code 模式,搭建的抽象程度更高,通过牺牲部分定制性换来更高效的开发方式。
|
||||
- 对客户的提效:如果用户有任何搭建 Web 应用的诉求,本质上从阿里云购买服务器自建是最普适的方案,但由于专业性要求高,用户群会很窄,因此需要针对不同用户的诉求开发定制方案,本质上是通过降低通用性换取更低的上手成本,或者针对某个领域降低上手成本,比如 BI 搭建。
|
||||
|
||||
提效虽然被说烂了,但软件工程发展中,几乎大部分工作都能归结到在提效。比如 Vscode、Typescript 提升编码效率;React、Vue 框架提升程序研发效率;工作台、可持续集成提升协同开发效率,等等,连微软都称自己的使命是赋能全球每一人、每个组织成就不凡,很大程度上就是在说提升整个社会的生产效能。
|
||||
|
||||
低代码开发平台(Low-Code Development Platform)则更进一步,允许通过零代码或少量代码就可以快速创建应用。
|
||||
|
||||
从实践结果来看,完全零代码想要覆盖所有领域是不可能的,而 100% 全代码是可以覆盖所有领域,但研发成本太高,所以介于两者之间的低代码模式是值得尝试的,因为许多定制场景往往不需要太多高深的代码就能搞定,很多复杂逻辑可能几个简单的赋值语句、或者条件语句就可以搞定,但如果不允许写代码,其使用成本甚至比写少量代码还要高。
|
||||
|
||||
所以搭建本质解决的是提效问题,考虑提效就要看性价比,是使用者学习几行简单代码后,利用低代码平台效率更高,还是使用者坚持不写代码,使用繁琐的搭建交互成本更高?有人说代码学不会,但简单代码本质和搭建无异,都是对电脑指令的输入。
|
||||
|
||||
还有一些场景将背后复杂度转移到了其他链路,比如数据搭建场景,虽然搭建器没有低代码能力,但却能实现复杂业务逻辑,原因是这个复杂度被 SQL 层吃掉了,既然复杂度无法消除,那么哪一层实现的效率更高,就由哪一层去做才是合理的。
|
||||
|
||||
## 2 精读
|
||||
|
||||
低代码不仅仅包括 “能写代码”,主要具备如下四个特性:物料接入、编排能力、渲染能力、出码能力。
|
||||
|
||||
### 物料接入
|
||||
|
||||
通用搭建引擎要能够接入通用物料,即组件自身不关心搭建环境,就可以被搭建平台所使用。
|
||||
|
||||
这需要搭建平台本身不对组件代码实现有入侵,可以对组件暴露的 props 做完全控制,要做到自动识别组件有哪些 props 变量,并根据类型自动推荐编辑表单类型。
|
||||
|
||||
除了简单的文本、数字、下拉框等编辑器 Setter 之外,还有如下几种复杂编辑器:
|
||||
|
||||
- 回调函数编辑器。
|
||||
- Node 节点编辑器。
|
||||
- 文本国际化编辑器。
|
||||
- 表达式编辑器。
|
||||
|
||||
回调函数编辑器与表达式编辑器都是低代码能力的体现,本质上就是利用代码描述某个变量值或者回调。
|
||||
|
||||
Node 节点编辑器专门处理节点类型 props 参数,比如 `props.header`、`propder.footer`,在代码模式描述为组件,在可视化模式需转化为画布下钻模式进行编辑。
|
||||
|
||||
### 编排能力
|
||||
|
||||
编排能力包含页面编排与逻辑编排,是低代码搭建的核心能力。
|
||||
|
||||
#### 页面编排
|
||||
|
||||
页面编排包含很多交互行为,比如拖拽组件、布局,其中布局大有可为,比如云凤蝶的编辑模式,通过自由拖拽布局,降低了使用者对 DOM 流式布局的理解成本,但通过自适应四周边距模拟出了流式布局自动撑开容器,容器间碰撞挤压的效果。
|
||||
|
||||
组件与组件形成的组合可以形成一个新的物料,一般称为模版,比如一个页面整体也可以称为模版,这个模版组件的 id 就是页面根节点的容器组件。但模版也有不能满足的场景,比如期望组件形成的组合拥有一套全新配置,此时就延伸出低代码业务组件的概念,可以认为将模版当作一个整体编辑,可以为模版设置任意的编辑表单,这个编辑表单的值可以透传到里面每个组件中读取。
|
||||
|
||||
#### 逻辑编排
|
||||
|
||||
逻辑编排是低代码能力的核心,在低代码引擎中,所有组件参数都可以用低代码描述,比如一个 `props.color` 可以通过颜色选择器选一个固定值,也可以转换为表达式模式写一段代码。
|
||||
|
||||
这段代码除了拥有普通 JS 能力外,还拥有基本状态管理的能力,即可以访问当前作用域下的状态 `this.state`,而状态作用域又被容器所分割,容器分为持有状态的容器与不持有状态的,一个持有状态容器内的子组件状态是互通的。
|
||||
|
||||
除了基本状态管理能力外,还拥有访问上下文能力,即调用引擎一些 API 对画布进行操作,一般都用于组件回调,在回调里调用 `this.setState` 设置状态也属于操作上下文的行为。除了上下文外,还有风格化、国际化、取数等能力可以通过 `this` 访问到,其中取数能力专门抽到引擎层做,就是为了让所有组件与取数逻辑解耦,组件只要拿到数据、isFetching,而不需要真正发送取数请求。
|
||||
|
||||
逻辑编排的另一个维度就是可视化,将上述低代码能力通过可视化方式表达为逻辑节点与线条,在描述与维护复杂逻辑时有一定优势。
|
||||
|
||||
### 渲染能力
|
||||
|
||||
搭建特殊之处在于,搭建过程几乎只能在 PC 端完成,但发布后的应用往往有多端渲染的诉求,比如越来越多的公司使用手机查看 BI 报表,甚至报表需要嵌入到微信、支付宝小程序中;PC 搭建的表单往往也有大量手机端填报的诉求。
|
||||
|
||||
所以编辑和渲染端应该是分离的,但为了保证逻辑一致性,核心代码需要复用,所以搭建引擎最好采用 UI 无关的内核 + 业务层拓展 UI 实现方式来做,UI 无关的内核只负责存储、操作画布数据,排除设计器附加的一堆 Panel 后,渲染时可以复用逻辑内核往往就足够了。
|
||||
|
||||
组件的跨端复用也是必须的,现在跨端渲染的技术方案也有不少。
|
||||
|
||||
### 出码能力
|
||||
|
||||
LowCode 与 ProCode 互转也是一大难题,首先互转的好处不必多说,可以自由的在提效与定制间切换,一定是最理想的开发模式,但实现起来有不少阻碍。
|
||||
|
||||
首先是 LowCode 转 ProCode,这个比较简单,原因是 LowCode 本身用 JSON 定义,代码是 JSON 的超集,从子集转换到超集本身没有技术障碍。
|
||||
|
||||
从 ProCode 转换到 LowCode 就麻烦了,一种方式是限定 ProCode 的能力,甚至用一种新的语法替代原生 JS,本质上都是通过将 ProCode 的能力范围限制住,使得 LowCode 可以接住。另一种方式是不对称转换,即从 ProCode 转换为 LowCode 后会存在功能缺失,或者即便功能不缺失,但 LowCode 无法对应的功能无法在搭建平台编辑。
|
||||
|
||||
### 运行时能力
|
||||
|
||||
只拥有上述低代码能力的搭建平台还是太通用了,虽然功能很强大,但在具体的业务场景不一定有多大的提效,具体的业务场景要有具体的解决方案,搭建本质是提效的,如果原子化、低代码的内容太多,就本末倒置,只是用另一种方式写代码罢了,并没有真正做到利用搭建提升开发效率。
|
||||
|
||||
通用的业务定制方式有如下三种:
|
||||
|
||||
- 定制业务组件:比如将某个复杂业务系统 80% 场景都要用到的组件固化为一个业务定制组件,省去了大部分配置时间,让使用者感受到提效。
|
||||
- 定制业务模版和低代码业务组件:更进一步,将业务模版固化下来,本质上类似代码模版,或者利用低代码业务组件,在不开发新组件的前提下,制作一个针对某个业务场景的混合组件。
|
||||
- 定制业务配置项:有些业务场景专业度很高,一方面是用户群不一样,一方面是搭建效率考虑,都应该提供一种基于业务角度出发的配置项,既符合业务思考逻辑,又节省配置步骤。
|
||||
|
||||
以上通用方式都是通过引擎已有的开放能力可以做到的,但对数据场景来说,有一些依赖引擎运行时能力场景,需要将引擎运行时能力抽象出来,配合低代码实现。
|
||||
|
||||
比如让当前页面所有配置相同数据集的组件自动建立筛选联动关联,虽然筛选联动关联可以通过低代码方式配置,但当画布组件数量变化时,或者有组件动态调用 API 新增组件时,静态的配置很难满足动态关联场景,此时我们可以拓展出一些全局运行时能力,让组件实现这些运行时能力时可以拿到画布信息,在引擎实际调用时再动态运行,而不是编辑生成一份静态 JSON 与渲染完全割裂。
|
||||
|
||||
运行时能力在不同平台针对不同垂直场景时会存在差异,如果希望打通底层引擎,可以提供拓展插槽,提供动态注册引擎运行时能力的机制。
|
||||
|
||||
## 3 总结
|
||||
|
||||
一个低代码搭建平台通吃一切场景是不可能的,只要有人愿意为垂直业务场景做 “量身定制”,用户就会立刻觉得搭建效率得到了提升,我们应当站在用户的角度,以用户利益最大化的方式做平台。
|
||||
|
||||
但搭建平台维护成本很高,每个业务场景都单独维护一套肯定不是长久之计,我们需要设计一套有弹性的低代码核心引擎,各个业务都可以基于他为自己的用户群 “量身定制” 一套专属设计器,共享搭建引擎通用的能力与协议,并自由拓展定制能力。
|
||||
|
||||
所以不仅渲染态是多态的,设计器也应该是多态的,其中可以被固化为标准的部分需要沉淀下来,比如物料接入规范、编排能力、出码能力、运行时能力,让各个搭建平台做到合而不同。
|
||||
|
||||
国内外都有非常多做的相当不错的搭建系统,但要不就太通用,具体场景提效不明显,要不就太垂直,换一个业务场景做不了。现在阿里中后台低代码搭建组织就在制定规范,将引擎通用能力固化为标准协议,让不同搭建平台可以对齐规范与功能,未来还会不断收敛核心引擎实现,基于它可以打造出千千万万个垂直领域的搭建平台,贴着业务做搭建提效,同时引擎内核与规范还能保持互通。
|
||||
|
||||
笔者所在阿里数据中台体验技术团队就是中后台低代码搭建组织的一员,将数据搭建领域做到极致。在技术上,我们在打通中后台搭建与数据搭建的技术方案,在产品上,我们正在逐渐统一阿里集团数据搭建平台,对外也携 QuickBI 成为国内唯一一家进入 Gartner 象限的 BI 产品,未来可期。
|
||||
|
||||
阿里数据中台体验技术团队正在火热招人中,如果感兴趣可以联系 ziyi.hzy@alibaba-inc.com 。
|
||||
|
||||
> 讨论地址是:[精读《对低代码搭建的理解》· Issue #260 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/260)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
|
||||
|
||||
> 关注 **前端精读微信公众号**
|
||||
|
||||
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
|
||||
|
||||
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh))
|
||||
@@ -0,0 +1,308 @@
|
||||
## 1 引言
|
||||
|
||||
函数缓存是重要概念,本质上就是用空间(缓存存储)换时间(跳过计算过程)。
|
||||
|
||||
对于无副作用的纯函数,在合适的场景使用函数缓存是非常必要的,让我们跟着 https://whatthefork.is/memoization 这篇文章深入理解一下函数缓存吧!
|
||||
|
||||
## 2 概述
|
||||
|
||||
假设又一个获取天气的函数 `getChanceOfRain`,每次调用都要花 100ms 计算:
|
||||
|
||||
```jsx
|
||||
import { getChanceOfRain } from "magic-weather-calculator";
|
||||
function showWeatherReport() {
|
||||
let result = getChanceOfRain(); // Let the magic happen
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
|
||||
showWeatherReport(); // (!) Triggers the calculation
|
||||
showWeatherReport(); // (!) Triggers the calculation
|
||||
showWeatherReport(); // (!) Triggers the calculation
|
||||
```
|
||||
|
||||
很显然这样太浪费计算资源了,当已经计算过一次天气后,就没有必要再算一次了,我们期望的是后续调用可以直接拿上一次结果的缓存,这样可以节省大量计算。因此我们可以做一个 `memoizedGetChanceOfRain` 函数缓存计算结果:
|
||||
|
||||
```jsx
|
||||
import { getChanceOfRain } from "magic-weather-calculator";
|
||||
let isCalculated = false;
|
||||
let lastResult;
|
||||
// We added this function!
|
||||
function memoizedGetChanceOfRain() {
|
||||
if (isCalculated) {
|
||||
// No need to calculate it again.
|
||||
return lastResult;
|
||||
}
|
||||
// Gotta calculate it for the first time.
|
||||
let result = getChanceOfRain();
|
||||
// Remember it for the next time.
|
||||
lastResult = result;
|
||||
isCalculated = true;
|
||||
return result;
|
||||
}
|
||||
function showWeatherReport() {
|
||||
// Use the memoized function instead of the original function.
|
||||
let result = memoizedGetChanceOfRain();
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
```
|
||||
|
||||
在每次调用时判断优先用缓存,如果没有缓存则调用原始函数并记录缓存。这样当我们多次调用时,除了第一次之外都会立即从缓存中返回结果:
|
||||
|
||||
```jsx
|
||||
showWeatherReport(); // (!) Triggers the calculation
|
||||
showWeatherReport(); // Uses the calculated result
|
||||
showWeatherReport(); // Uses the calculated result
|
||||
showWeatherReport(); // Uses the calculated result
|
||||
```
|
||||
|
||||
然而对于有参数的场景就不适用了,因为缓存并没有考虑参数:
|
||||
|
||||
```jsx
|
||||
function showWeatherReport(city) {
|
||||
let result = getChanceOfRain(city); // Pass the city
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
showWeatherReport("London"); // Uses the calculated answer
|
||||
```
|
||||
|
||||
由于参数可能性很多,所以有三种解决方案:
|
||||
|
||||
### 1. 仅缓存最后一次结果
|
||||
|
||||
仅缓存最后一次结果是最节省存储空间的,而且不会有计算错误,但带来的问题就是当参数变化时缓存会立即失效:
|
||||
|
||||
```jsx
|
||||
import { getChanceOfRain } from "magic-weather-calculator";
|
||||
let lastCity;
|
||||
let lastResult;
|
||||
function memoizedGetChanceOfRain(city) {
|
||||
if (city === lastCity) {
|
||||
// Notice this check!
|
||||
// Same parameters, so we can reuse the last result.
|
||||
return lastResult;
|
||||
}
|
||||
// Either we're called for the first time,
|
||||
// or we're called with different parameters.
|
||||
// We have to perform the calculation.
|
||||
let result = getChanceOfRain(city);
|
||||
// Remember both the parameters and the result.
|
||||
lastCity = city;
|
||||
lastResult = result;
|
||||
return result;
|
||||
}
|
||||
function showWeatherReport(city) {
|
||||
// Pass the parameters to the memoized function.
|
||||
let result = memoizedGetChanceOfRain(city);
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
showWeatherReport("Tokyo"); // Uses the calculated result
|
||||
showWeatherReport("Tokyo"); // Uses the calculated result
|
||||
showWeatherReport("London"); // (!) Triggers the calculation
|
||||
showWeatherReport("London"); // Uses the calculated result
|
||||
```
|
||||
|
||||
在极端情况下等同于没有缓存:
|
||||
|
||||
```jsx
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
showWeatherReport("London"); // (!) Triggers the calculation
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
showWeatherReport("London"); // (!) Triggers the calculation
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
```
|
||||
|
||||
### 2. 缓存所有结果
|
||||
|
||||
第二种方案是缓存所有结果,使用 Map 存储缓存即可:
|
||||
|
||||
```jsx
|
||||
// Remember the last result *for every city*.
|
||||
let resultsPerCity = new Map();
|
||||
function memoizedGetChanceOfRain(city) {
|
||||
if (resultsPerCity.has(city)) {
|
||||
// We already have a result for this city.
|
||||
return resultsPerCity.get(city);
|
||||
}
|
||||
// We're called for the first time for this city.
|
||||
let result = getChanceOfRain(city);
|
||||
// Remember the result for this city.
|
||||
resultsPerCity.set(city, result);
|
||||
return result;
|
||||
}
|
||||
function showWeatherReport(city) {
|
||||
// Pass the parameters to the memoized function.
|
||||
let result = memoizedGetChanceOfRain(city);
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
|
||||
showWeatherReport("Tokyo"); // (!) Triggers the calculation
|
||||
showWeatherReport("London"); // (!) Triggers the calculation
|
||||
showWeatherReport("Tokyo"); // Uses the calculated result
|
||||
showWeatherReport("London"); // Uses the calculated result
|
||||
showWeatherReport("Tokyo"); // Uses the calculated result
|
||||
showWeatherReport("Paris"); // (!) Triggers the calculation
|
||||
```
|
||||
|
||||
这么做带来的弊端就是内存溢出,当可能参数过多时会导致内存无限制的上涨,最坏的情况就是触发浏览器限制或者页面崩溃。
|
||||
|
||||
### 3. 其他缓存策略
|
||||
|
||||
介于只缓存最后一项与缓存所有项之间还有这其他选择,比如 LRU(least recently used)只保留最小化最近使用的缓存,或者为了方便浏览器回收,使用 WeakMap 替代 Map。
|
||||
|
||||
最后提到了函数缓存的一个坑,必须是纯函数。比如下面的 CASE:
|
||||
|
||||
```jsx
|
||||
// Inside the magical npm package
|
||||
function getChanceOfRain() {
|
||||
// Show the input box!
|
||||
let city = prompt("Where do you live?");
|
||||
// ... calculation ...
|
||||
}
|
||||
// Our code
|
||||
function showWeatherReport() {
|
||||
let result = getChanceOfRain();
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
```
|
||||
|
||||
`getChanceOfRain` 每次会由用户输入一些数据返回结果,导致缓存错误,原因是 “函数入参一部分由用户输入” 就是副作用,我们不能对有副作用的函数进行缓存。
|
||||
|
||||
这有时候也是拆分函数的意义,将一个有副作用函数的无副作用部分分解出来,这样就能局部做函数缓存了:
|
||||
|
||||
```jsx
|
||||
// If this function only calculates things,
|
||||
// we would call it "pure".
|
||||
// It is safe to memoize this function.
|
||||
function getChanceOfRain(city) {
|
||||
// ... calculation ...
|
||||
}
|
||||
// This function is "impure" because
|
||||
// it shows a prompt to the user.
|
||||
function showWeatherReport() {
|
||||
// The prompt is now here
|
||||
let city = prompt("Where do you live?");
|
||||
let result = getChanceOfRain(city);
|
||||
console.log("The chance of rain tomorrow is:", result);
|
||||
}
|
||||
```
|
||||
|
||||
最后,我们可以将缓存函数抽象为高阶函数:
|
||||
|
||||
```jsx
|
||||
function memoize(fn) {
|
||||
let isCalculated = false;
|
||||
let lastResult;
|
||||
return function memoizedFn() {
|
||||
// Return the generated function!
|
||||
if (isCalculated) {
|
||||
return lastResult;
|
||||
}
|
||||
let result = fn();
|
||||
lastResult = result;
|
||||
isCalculated = true;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
这样生成新的缓存函数就方便啦:
|
||||
|
||||
```jsx
|
||||
let memoizedGetChanceOfRain = memoize(getChanceOfRain);
|
||||
let memoizedGetNextEarthquake = memoize(getNextEarthquake);
|
||||
let memoizedGetCosmicRaysProbability = memoize(getCosmicRaysProbability);
|
||||
```
|
||||
|
||||
`isCalculated` 与 `lastResult` 都存储在 `memoize` 函数生成的闭包内,外部无法访问。
|
||||
|
||||
## 3 精读
|
||||
|
||||
### 通用高阶函数实现函数缓存
|
||||
|
||||
原文的例子还是比较简单,没有考虑函数多个参数如何处理,下面我们分析一下 Lodash `memoize` 函数源码:
|
||||
|
||||
```jsx
|
||||
function memoize(func, resolver) {
|
||||
if (
|
||||
typeof func != "function" ||
|
||||
(resolver != null && typeof resolver != "function")
|
||||
) {
|
||||
throw new TypeError(FUNC_ERROR_TEXT);
|
||||
}
|
||||
var memoized = function () {
|
||||
var args = arguments,
|
||||
key = resolver ? resolver.apply(this, args) : args[0],
|
||||
cache = memoized.cache;
|
||||
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
var result = func.apply(this, args);
|
||||
memoized.cache = cache.set(key, result) || cache;
|
||||
return result;
|
||||
};
|
||||
memoized.cache = new (memoize.Cache || MapCache)();
|
||||
return memoized;
|
||||
}
|
||||
```
|
||||
|
||||
原文有提到缓存策略多种多样,而 Lodash 将缓存策略简化为 key 交给用户自己管理,看这段代码:
|
||||
|
||||
```jsx
|
||||
key = resolver ? resolver.apply(this, args) : args[0];
|
||||
```
|
||||
|
||||
也就是缓存的 key 默认是执行函数时第一个参数,也可以通过 `resolver` 拿到参数处理成新的缓存 key。
|
||||
|
||||
在执行函数时也传入了参数 `func.apply(this, args)`。
|
||||
|
||||
最后 `cache` 也不再使用默认的 Map,而是允许用户自定义 `lodash.memoize.Cache` 自行设置,比如设置为 WeakMap:
|
||||
|
||||
```jsx
|
||||
_.memoize.Cache = WeakMap;
|
||||
```
|
||||
|
||||
### 什么时候不适合用缓存
|
||||
|
||||
以下两种情况不适合用缓存:
|
||||
|
||||
1. 不经常执行的函数。
|
||||
2. 本身执行速度较快的函数。
|
||||
|
||||
对于不经常执行的函数,本身就不需要利用缓存提升执行效率,而缓存反而会长期占用内存。对于本身执行速度较快的函数,其实大部分简单计算速度都很快,使用缓存后对速度没有明显的提升,同时如果计算结果比较大,反而会占用存储资源。
|
||||
|
||||
对于引用的变化尤其重要,比如如下例子:
|
||||
|
||||
```jsx
|
||||
function addName(obj, name){
|
||||
return {
|
||||
...obj,
|
||||
name:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
为 `obj` 添加一个 key,本身执行速度是非常快的,但添加缓存后会带来两个坏处:
|
||||
|
||||
1. 如果 `obj` 非常大,会在闭包存储完整 `obj` 结构,内存占用加倍。
|
||||
2. 如果 `obj` 通过 mutable 方式修改了,则普通缓存函数还会返回原先结果(因为对象引用没有变),造成错误。
|
||||
|
||||
如果要强行进行对象深对比,虽然会避免出现边界问题,但性能反而会大幅下降。
|
||||
|
||||
## 4 总结
|
||||
|
||||
函数缓存非常有用,但并不是所有场景都适用,因此千万不要极端的将所有函数都添加缓存,仅限于计算耗时、可能重复利用多次,且是纯函数的。
|
||||
|
||||
> 讨论地址是:[精读《函数缓存》· Issue #261 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/261)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,109 @@
|
||||
## 1 引言
|
||||
|
||||
[「可视化搭建系统」——从设计到架构,探索前端的领域和意义](https://juejin.im/post/6854573220532748302) 这篇文章主要分析了现阶段可视化搭建的几种表现形式和实现原理,并重点介绍了基于富文本的可视化搭建思路,让人耳目一新。
|
||||
|
||||
基于富文本的可视化搭建看似很新颖,但其实早就被广泛使用了,任何一个富文本编辑器几乎都有插入表格功能,这就是一个典型插入自定义组件的场景。
|
||||
|
||||
使用过 [语雀](https://www.yuque.com/) 的同学应该知道,这个产品的富文本编辑器可以插入各种各样自定义区块,是 “最像搭建” 的富文本编辑器。
|
||||
|
||||
那么积木式搭建和富文本搭建存在哪些差异,除了富文本更倾向于记录静态内容外,还有哪些差异,两者是否可以结合?本文将围绕这两点进行讨论。
|
||||
|
||||
## 2 精读
|
||||
|
||||
还是先顺着原文谈谈对可视化搭建的理解:
|
||||
|
||||
可视化搭建是通过可视化方式代替开发。**前端代码开发主要围绕的是 html + js + css**,那么无论是 markdown 语法,还是创建另一套模版语言亦或 JSON 构成的 DSL,**都是用一种 dsl + 组件 + css 的方式代替 html + js + css**,可视化搭建则更进一步,用 ui 代替了 dsl + 组件,**即精简为 ui 操作 + css**。
|
||||
|
||||
可以看到,这种转换的推演过程存在一定瑕疵,因为每次转换都有部分损耗:
|
||||
|
||||
**用 dsl + 组件 代替 html + js。**
|
||||
|
||||
如果 dsl 拓展得足够好,理论上可以达到 html 的水平,尤其在垂直业务场景是不需要那么多特殊 html 标签的。
|
||||
|
||||
但用组件代替 js 就有点奇怪了,首先并不是所有 js 逻辑都沉淀在组件里,一定有组件间的联动逻辑是无法通过一个组件 js 完成的,另一方面如果将 js 逻辑寄托在组件代码里,本质上是没有提效的,用源码开发项目与开发搭建平台的组件都是 pro code,更极端一点来说,无论是组件间联动还是整个应用都可以用一个组件来写,那搭建平台就无事可做了,这个组件也成了整个应用,game over。
|
||||
|
||||
为了弥补这块缺憾,低代码能力的呼声越来越高,而低代码能力的核心在于设计是否合理,比如暴露哪些 API 可以覆盖大部分需求?写多少代码合适,如何以最小 API 透出最大弥补组件间缺失的 js 能力?目前来看,以状态数据驱动的低代码是相对优雅的。
|
||||
|
||||
**用 ui 操作 代替 dsl + 组件。**
|
||||
|
||||
UI 操作并不是标准的,相比直接操作模版或者 JSON DSL,UI 化后就仁者见仁智者见智了,但 UI 化带来的效率提升是巨大的,因为所见即所得是生产力的源泉,从直观的 UI 布局来看,就比维护代码更轻松。但 UI 化也存在两个问题,一个是可能有人觉得不如 markdown 效率高,另一个是功能有丢失。
|
||||
|
||||
对于第一点 UI 操作效率不如 markdown 高,可能很多程序员都崇尚用 markdown 维护文档而不是富文本,原因是觉得程序员维护代码的效率反而比所见即所得高,但那可能是错觉,原因是还没有遇到好用的富文本编辑器,体验过语雀富文本编辑器后,相信大部分程序员都不会再想回头写 markdown。当然语雀富文本战胜 markdown 的原因有很多,我觉得主要两点是吸收并兼容了 markdown 操作习惯,与支持了更多仅 UI 能做到的拓展能力,对 markdown 形成降维打击。
|
||||
|
||||
第二点功能丢失很好理解,markdown 有一套标准语法和解析器可以验证,但 UI 操作并没有标准化,也没有独立验证系统,如果无法回退到源码模式,UI 没有实现的功能就做不到。
|
||||
|
||||
回到富文本搭建上,其实富文本搭建和普通网页构建并没有本质区别。html 是超文本标记语言,富文本是跨平台文档格式,从逻辑上这两个格式是可以互转的,只要富文本规则作出足够多的拓展,就可以大致覆盖 html 的能力。
|
||||
|
||||
但富文本搭建有着显著的特征,就是光标。
|
||||
|
||||
### 积木式搭建和富文本搭建的区别
|
||||
|
||||
富文本以文本为中心,因此编辑文字的光标会常驻,编辑的核心逻辑是排版文字,并考虑如何在文字周围添加一些自定义区块。
|
||||
|
||||
有了光标后,圈选也非常重要,因为大家编辑文字时有一种很自然的想法是,任何文字圈选后复制,可以粘贴到任何地方,那么所有插入到富文本中的自定义组件也要支持被圈选,被复制。
|
||||
|
||||
实际上富文本内插入自定义区块也可以转换为积木式搭建方案解决,比如下面的场景:
|
||||
|
||||
```text
|
||||
文本 A
|
||||
图表 B
|
||||
文本 C
|
||||
```
|
||||
|
||||
我们在文本 A 与 文本 C 之间插入图表 B,也可以理解为拖拽了三个组件:文本组件 A + 图表组件 B + 文本组件 C,然后分别编辑这三个组件,微调样式后可以达到与富文本一样的编辑效果,甚至加上自由布局后,在布局能力上会超越富文本。
|
||||
|
||||
虽然功能层面上富文本略有输给积木式搭建,但富文本在编辑体验上是胜出的,对于文字较多的场景,我们还是会选择富文本方式编辑而不是积木式搭建拖拽 N 个文本组件。
|
||||
|
||||
所以微软 OneNote 也吸取了这个经验,毕竟笔记本主要还是记录文字,因此还是采用富文本的编辑模式,但创造性的加入了一个个独立区块,点击任何区域都会创造一个区块,整个文档可以由一个区块构成,也可以是多个区块组合而成,这样对于连贯性的文字场景可以采用一个富文本区块,对于自定义区块较多,比如大部分是图片和表格的,还可以回到积木式搭建的体验。由于 OneNote 采用绝对定位模拟流式布局的思路,当区块重叠时还可以自动挤压底部区块,因此多区块模式下编辑体验还是相对顺畅的。
|
||||
|
||||
可以看出来这是一种结合的尝试,从前端角度来看,富文本本质上是对一个 div 进行 contenteditable 申明,那么一个应用可以整体是 contenteditable 的,也可以局部几个区块是,这种代码层面的自由度体现在搭建上就是积木式搭建可以与富文本搭建自由结合。
|
||||
|
||||
### 积木式搭建与富文本搭建如何结合
|
||||
|
||||
对于积木式搭建来说,富文本只是其中一个组件,在不考虑有富文本组件时是完全没有富文本能力的。比如一个搭建平台只提供了几个图表和基础控件,你是不可能在其基础上使用富文本能力的,甚至连写静态文本都做不到。
|
||||
|
||||
所以富文本只是搭建中一个组件,就像 contenteditable 也只能依附于一个标签,整个网页还是由标签组成的。但对于一个提供了富文本组件的积木式搭建系统来说,文字与控件混排又是一个痛点,毕竟要以一个个区块组件的方式去拖拽文本节点,成本比富文本模式大得多。
|
||||
|
||||
所以理想情况是富文本与整个搭建系统使用同一套 DSL 描述结构,富文本只是在布局上有所简化,简化为简单的平铺模式即可,但因为 DSL 描述打通,富文本也可以描述使用搭建提供的任意组件嵌套在内,所以只要用户愿意,可以将富文本组件拉到最大,整个页面都基于富文本模式去搭建,这就变成了富文本搭建,也可以将富文本缩小,将普通控件以积木方式拖拽到画布中,走积木式搭建路线。
|
||||
|
||||
用代码方式描述积木式搭建:
|
||||
|
||||
```html
|
||||
<bar-chart />
|
||||
<div>
|
||||
<p>header</p>
|
||||
<line-chart />
|
||||
<p>footer</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
上述模式需要拖拽 `bar-chart`、`div`、`p`、`line-chart`、`p` 共 5 个组件。富文本模式则类似下面的结构:
|
||||
|
||||
```html
|
||||
<bar-chart />
|
||||
<div contenteditable>
|
||||
<p>header</p>
|
||||
<line-chart />
|
||||
<p>footer</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
只要拖拽 `bar-chart`、`div` 两个组件即可,`div` 内部的文字通过光标输入,`line-chart` 通过富文本某个按钮或者键盘快捷键添加。
|
||||
|
||||
可以看到虽然操作方式不同,但本质上描述协议并没有本质区别,我们理论上可以将任何容器标签切换为富文本模式。
|
||||
|
||||
## 3 总结
|
||||
|
||||
富文本是一种重要的交互模式,可以基于富文本模式做搭建,也可以在搭建系统中嵌入富文本组件,甚至还可以追求搭建与富文本的结合。
|
||||
|
||||
富文本组件既可以是搭建系统中一个组件,又可以在内部承载搭建系统的所有组件,做到这一步才算是真正发挥出富文本的潜力。
|
||||
|
||||
> 讨论地址是:[精读《可视化搭建思考 - 富文本搭建》· Issue #262 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/262)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,185 @@
|
||||
## 1 引言
|
||||
|
||||
本周跟着 [Tasks, microtasks, queues and schedules](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/) 这篇文章一起深入理解这些概念间的区别。
|
||||
|
||||
先说结论:
|
||||
|
||||
- Tasks 按顺序执行,浏览器可能在 Tasks 之间执行渲染。
|
||||
- Microtasks 也按顺序执行,时机是:
|
||||
- 如果没有执行中的 js 堆栈,则在每个回调之后。
|
||||
- 在每个 task 之后。
|
||||
|
||||
## 2 概述
|
||||
|
||||
### Event Loop
|
||||
|
||||
在说这些概念前,先要介绍 Event Loop。
|
||||
|
||||
首先浏览器是多线程的,每个 JS 脚本都在单线程中执行,每个线程都有自己的 Event Loop,同源的所有浏览器窗口共享一个 Event Loop 以便通信。
|
||||
|
||||
Event Loop 会持续循环的执行所有排队中的任务,浏览器会为这些任务划分优先级,按照优先级来执行,这就会导致 Tasks 与 Microtasks 执行顺序与调用顺序的不同。
|
||||
|
||||
### promise 与 setTimeout
|
||||
|
||||
看下面代码的输出顺序:
|
||||
|
||||
```js
|
||||
console.log("script start");
|
||||
|
||||
setTimeout(function () {
|
||||
console.log("setTimeout");
|
||||
}, 0);
|
||||
|
||||
Promise.resolve()
|
||||
.then(function () {
|
||||
console.log("promise1");
|
||||
})
|
||||
.then(function () {
|
||||
console.log("promise2");
|
||||
});
|
||||
|
||||
console.log("script end");
|
||||
```
|
||||
|
||||
正确答案是 `script start`, `script end`, `promise1`, `promise2`, `setTimeout`,在线程中,同步脚本执行优先级最高,然后 promise 任务会存放到 Microtasks,setTimeout 任务会存放到 Tasks,Microtasks 会优先于 Tasks 执行。
|
||||
|
||||
Microtasks 中文可以翻译为微任务,只要有 Microtasks 插入,就会不断执行 Microtasks 队列直到结束,在结束前都不会执行到 Tasks。
|
||||
|
||||
### 点击冒泡 + 任务
|
||||
|
||||
下面给出了更复杂的例子,提前说明后面的例子 Chrome、Firefox、Safari、Edge 浏览器的结果完全不一样,但只有 Chrome 的运行结果是对的!为什么 Chrome 是对的呢,请看下面的分析:
|
||||
|
||||
```html
|
||||
<div class="outer">
|
||||
<div class="inner"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```js
|
||||
// Let's get hold of those elements
|
||||
var outer = document.querySelector(".outer");
|
||||
var inner = document.querySelector(".inner");
|
||||
|
||||
// Let's listen for attribute changes on the
|
||||
// outer element
|
||||
new MutationObserver(function () {
|
||||
console.log("mutate");
|
||||
}).observe(outer, {
|
||||
attributes: true,
|
||||
});
|
||||
|
||||
// Here's a click listener…
|
||||
function onClick() {
|
||||
console.log("click");
|
||||
|
||||
setTimeout(function () {
|
||||
console.log("timeout");
|
||||
}, 0);
|
||||
|
||||
Promise.resolve().then(function () {
|
||||
console.log("promise");
|
||||
});
|
||||
|
||||
outer.setAttribute("data-random", Math.random());
|
||||
}
|
||||
|
||||
// …which we'll attach to both elements
|
||||
inner.addEventListener("click", onClick);
|
||||
outer.addEventListener("click", onClick);
|
||||
```
|
||||
|
||||
点击 `inner` 区块后,正确输出顺序应该是:
|
||||
|
||||
```text
|
||||
click
|
||||
promise
|
||||
mutate
|
||||
click
|
||||
promise
|
||||
mutate
|
||||
timeout
|
||||
timeout
|
||||
```
|
||||
|
||||
逻辑如下:
|
||||
|
||||
1. 点击触发 `onClick` 函数入栈。
|
||||
2. 立即执行 `console.log('click')` 打印 `click`。
|
||||
3. `console.log('timeout')` 入栈 Tasks。
|
||||
4. `console.log('promise')` 入栈 microtasks。
|
||||
5. `outer.setAttribute('data-random')` 的触发导致监听者 `MutationObserver` 入栈 microtasks。
|
||||
6. `onClick` 函数执行完毕,此时线程调用栈为空,开始执行 microtasks 队列。
|
||||
7. 打印 `promise`,打印 `mutate`,此时 microtasks 已空。
|
||||
8. 执行冒泡机制,outer div 也触发 `onClick` 函数,同理,打印 `promise`,打印 `mutate`。
|
||||
9. 都执行完后,执行 Tasks,打印 `timeout`,打印 `timeout`。
|
||||
|
||||
### 模拟点击冒泡 + 任务
|
||||
|
||||
如果将触发 `onClick` 行为由点击改为:
|
||||
|
||||
```js
|
||||
inner.click();
|
||||
```
|
||||
|
||||
结果会不同吗?答案是会(单元测试与用户行为不符合,单测也有无解的时候)。然而四大浏览器的执行结果也是完全不一样,但从逻辑上讲仍然 Chrome 是对的,让我们看下 Chrome 的结果:
|
||||
|
||||
```text
|
||||
click
|
||||
click
|
||||
promise
|
||||
mutate
|
||||
promise
|
||||
timeout
|
||||
timeout
|
||||
```
|
||||
|
||||
逻辑如下:
|
||||
|
||||
1. `inner.click()` 触发 `onClick` 函数入栈。
|
||||
2. 立即执行 `console.log('click')` 打印 `click`。
|
||||
3. `console.log('timeout')` 入栈 Tasks。
|
||||
4. `console.log('promise')` 入栈 microtasks。
|
||||
5. `outer.setAttribute('data-random')` 的触发导致监听者 `MutationObserver` 入栈 microtasks。
|
||||
6. 由于冒泡改为 js 调用栈执行,所以此时 js 调用栈未结束,不会执行 microtasks,反而是继续执行冒泡,outer 的 `onClick` 函数入栈。
|
||||
7. 立即执行 `console.log('click')` 打印 `click`。
|
||||
8. `console.log('timeout')` 入栈 Tasks。
|
||||
9. `console.log('promise')` 入栈 microtasks。
|
||||
10. `MutationObserver` 由于还没调用,因此这次 `outer.setAttribute('data-random')` 的改动实际上没有作用。
|
||||
11. js 调用栈执行完毕,开始执行 microtasks,按照入栈顺序,打印 `promise`,`mutate`,`promise`。
|
||||
12. microtasks 执行完毕,开始执行 Tasks,打印 `timeout`,`timeout`。
|
||||
|
||||
## 3 精读
|
||||
|
||||
基于任务调度这么复杂,且浏览器实现方式很不同,下面两件事是我很不推荐的:
|
||||
|
||||
1. 业务逻辑 “巧妙” 依赖了 microtasks 与 Tasks 执行逻辑的微妙差异。
|
||||
2. 死记硬背调用顺序。
|
||||
|
||||
且不说依赖了调用顺序的业务逻辑本身就很难维护,不同浏览器之间对任务调用顺序还是不同的,这可能源于对 W3C 标准规范理解的偏差,也可能是 BUG,这会导致依赖于此的逻辑非常脆弱。
|
||||
|
||||
虽然上面两个例子非常复杂,但我们也不必把这个例子当作经典背诵,只要记住文章开头提到的执行逻辑就可以推导:
|
||||
|
||||
- Tasks 按顺序执行,浏览器可能在 Tasks 之间执行渲染。
|
||||
- Microtasks 也按顺序执行,时机是:
|
||||
- 如果没有执行中的 js 堆栈,则在每个回调之后。
|
||||
- 在每个 task 之后。
|
||||
|
||||
记住 `Promise` 是 `Microtasks`,`setTimeout` 是 `Tasks`,JS 一次 Event Loop 完毕后,即调用栈没有内容时才会执行 `Microtasks` -> `Tasks`,在执行 `Microtasks` 过程中插入的 `Microtasks` 会按顺序继续执行,而执行 `Tasks` 中插入的 `Microtasks` 得等到调用栈执行完后才继续执行。
|
||||
|
||||
上面说的内容都是指一次 Event Loop 时立即执行的优先级,不要和执行延迟时间弄混淆了。
|
||||
|
||||
把 JS 线程的 Event Loop 当作一个函数,函数内同步逻辑执行优先级是最高的,如果遇到 `Microtasks` 或 `Tasks` 就会立即记录下来,当一次 Event Loop 执行完后立即调用 `Microtasks`,等 `Microtasks` 队列执行完毕后可能进行一些渲染行为,等这些浏览器操作完成后,再考虑执行 `Tasks` 队列。
|
||||
|
||||
## 4 总结
|
||||
|
||||
最后,还是要强调一句,不要依赖 `Microtasks` 与 `Tasks` 的执行顺序,尤其在申明式编程环境中,我们可以把 `Microtasks` 与 `Tasks` 都当作是异步内容,在渲染时做好状态判断即可,不用关心先后顺序。
|
||||
|
||||
> 讨论地址是:[精读《Tasks, microtasks, queues and schedules》· Issue #264 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/264)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
|
||||
|
||||
> 关注 **前端精读微信公众号**
|
||||
|
||||
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
|
||||
|
||||
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh))
|
||||
@@ -0,0 +1,306 @@
|
||||
[spring](https://spring.io/) 是 Java 非常重要的框架,且蕴含了一系列设计模式,非常值得研究,本期就通过 [Spring学习](https://www.cnblogs.com/wmyskxz/p/8820371.html) 这篇文章了解一下 spring。
|
||||
|
||||
## spring 为何长寿
|
||||
|
||||
spring 作为一个后端框架,拥有 17 年历史,这在前端看来是不可思议的。前端几乎没有一个框架可以流行超过 5 年,就最近来看,react、angular、vue 三大框架可能会活的久一点,他们都是前端相对成熟阶段的产物,我们或多或少可以看出一些设计模式。然而这些前端框架与 spring 比起来还是差距很大,我们来看看 spring 到底强大在哪。
|
||||
|
||||
### 设计模式
|
||||
|
||||
设计模式是一种思想,不依附于任何编程语言与开发框架。比如你学会了工厂设计模式,可以在后端用,也可以转到前端用,可以在 Go 语言用,也可以在 Typescript 用,可以在 React 框架用,也可以在 Vue 里用,所以设计模式是一种具有迁移能力的知识,学会后可以受益整个职业生涯,而语言、框架则不具备迁移性,前端许多同学都把精力花在学习框架特性上,遇到前端技术迭代时期就尴尬了,这就是为什么大公司面试要问框架原理,就是看看你能否抓住一些不变的东西,所以洋洋洒洒的说上下文相关的细节也不是面试官想要的,真正想听到的是你抽象后对框架原理共性的总结。
|
||||
|
||||
spring 框架就用到了许多设计模式,包括:
|
||||
|
||||
工厂模式:用工厂生产对象实例来代替原始的 new。所谓工厂就是屏蔽实例话的细节,调用处无需关心实例化对象需要的环境参数,提升可维护性。spring 的 BeanFactory 创建 bean 对象就是工厂模式的体现。
|
||||
代理模式:允许通过代理对象访问目标对象。Spring 实现 AOP 就是通过动态代理模式。
|
||||
单例模式:单实例。spring 的 bean 默认都是单例。
|
||||
包装器模式:将几个不同方法通用部分抽象出来,调用时通过包装器内部引导到不同的实现。比如 spring 连接多种数据库就使用了包装器模式简化。
|
||||
观察者模式:这个前端同学很熟悉,就是事件机制,spring 中可以通过 ApplicationEvent 实践观察者模式。
|
||||
适配器模式:通过适配器将接口转换为另一个格式的接口。spring AOP 的增强和通知就使用了适配器模式。
|
||||
模板方法模式:父类先定义一些函数,这些函数之间存在调用关联,将某些设定为抽象函数等待子类继承时去重写。spring 的 `jdbcTemplate`、`hibernateTemplate` 等数据库操作类使用了模版方法模式。
|
||||
|
||||
### 全家桶
|
||||
|
||||
spring 作为一个全面的 java 框架,提供了系列全家桶满足各种场景需求:spring mvc、spring security、spring data、spring boot、spring cloud。
|
||||
|
||||
- spring boot:简化了 spring 应用配置,约定大于配置的思维。
|
||||
- spring data:是一个数据操作与访问工具集,比如支持 jdbc、redis 等数据源操作。
|
||||
- spring cloud:是一个微服务解决方案,基于 spring boot,集成了服务发现、配置管理、消息总线、负载均衡、断路器、数据监控等各种服务治理能力。
|
||||
- spring security:支持一些安全模型比如单点登录、令牌中继、令牌交换等。
|
||||
- spring mvc:MVC 思想的 web 框架。
|
||||
|
||||
## IOC
|
||||
|
||||
IOC(Inverse of Control)控制反转。IOC 是 Spring 最核心部分,因为所有对象调用都离不开 IOC 模式。
|
||||
|
||||
假设我们有三个类:Country、Province、City,最大的类别是国家,其次是省、城市,国家类需要调用省类,省类需要调用城市类:
|
||||
|
||||
```java
|
||||
public class Country {
|
||||
private Province province;
|
||||
public Country(){
|
||||
this.province = new Province()
|
||||
}
|
||||
}
|
||||
|
||||
public class Province {
|
||||
private City city;
|
||||
public Province(){
|
||||
this.city = new City()
|
||||
}
|
||||
}
|
||||
|
||||
public class City {
|
||||
public City(){
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
假设来了一个需求,City 实例化时需增加人口(people)参数,我们就要改动所有类代码:
|
||||
|
||||
```java
|
||||
public class Country {
|
||||
private Province province;
|
||||
public Country(int people){
|
||||
this.province = new Province(people)
|
||||
}
|
||||
}
|
||||
|
||||
public class Province {
|
||||
private City city;
|
||||
public Province(int people){
|
||||
this.city = new City(people)
|
||||
}
|
||||
}
|
||||
|
||||
public class City {
|
||||
public City(int people){
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
那么在真实业务场景中,一个底层类可能被数以千计的类使用,这么改显然难以维护。IOC 就是为了解决这个问题,它使得我们可以只改动 City 的代码,而不用改动其他类的代码:
|
||||
|
||||
```java
|
||||
public class Country {
|
||||
private Province province;
|
||||
public Country(Province province){
|
||||
this.province = province
|
||||
}
|
||||
}
|
||||
|
||||
public class Province {
|
||||
private City city;
|
||||
public Province(City city){
|
||||
this.city = city
|
||||
}
|
||||
}
|
||||
|
||||
public class City {
|
||||
public City(int people){
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,增加 `people` 属性只需要改动 city 类。然而这样做也是有成本的,就是类实例化步骤会稍微繁琐一些:
|
||||
|
||||
```java
|
||||
City city = new City(1000);
|
||||
Province province = new Province(city);
|
||||
Country country = new Country(province);
|
||||
```
|
||||
|
||||
这就是控制反转,由 Country 依赖 Province 变成了类依赖框架(上面的实例化代码)注入。
|
||||
|
||||
然而手动维护这种初始化依赖是繁琐的,spring 提供了 bean 容器自动做这件事,我们只需要利用装饰器 Autowired 就可以自动注入依赖:
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class Country {
|
||||
@Autowired
|
||||
private Province province;
|
||||
}
|
||||
@Component
|
||||
public class Province {
|
||||
@Autowired
|
||||
public City city;
|
||||
}
|
||||
@Component
|
||||
public class City {
|
||||
}
|
||||
```
|
||||
|
||||
实际上这种自动分析并实例化的手段,不仅比手写方便,还能解决循环依赖的问题。在实际场景中,两个类相互调用是很常见的,假设现在有 A、B 类相互依赖:
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class A {
|
||||
@Autowired
|
||||
private B b;
|
||||
}
|
||||
@Component
|
||||
public class B {
|
||||
@Autowired
|
||||
public A a;
|
||||
}
|
||||
```
|
||||
|
||||
那么假设我们想获取 A 实例,会经历这样一个过程:
|
||||
|
||||
```text
|
||||
获取 A 实例 -> 实例化不完整 A -> 检测到注入 B -> 实例化不完整 B -> 检测到注入 A -> 注入不完整 A -> 得到完整 B -> 得到完整 A -> 返回 A 实例
|
||||
```
|
||||
|
||||
其实 spring 仅支持单例模式下非构造器的循环依赖,这是因为其内部有一套机制,让 bean 在初始化阶段先提前持有对方引用地址,这样就可以同时实例化两个对象了。
|
||||
|
||||
除了方便之外,IOC 配合 spring 容器概念还可以使获取实例时不用关心一个类实例化需要哪些参数,只需要直接申明获取即可,这样在类的数量特别多,尤其是大量代码不是你写的情况下,不需要阅读类源码也可以轻松获取实例,实在是大大提升了可维护性。
|
||||
|
||||
说到这就提到了 Bean 容器,在 spring 概念中,Bean 容器是对 class 的加强,如果说 Class 定义了类的基本含义,那 Bean 就是对类进行使用拓展,告诉我们应该如何实例化与使用这个类。
|
||||
|
||||
举个例子,比如利用注解描述的这段 Bean 类:
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class CityConfig {
|
||||
@Scope("prototype")
|
||||
@Lazy
|
||||
@Bean(initMethod = "init", destroyMethod = "destroy")
|
||||
public City city() {
|
||||
return new City()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,额外描述了是否延迟加载,是否单例,初始化与析构函数分别是什么等等。
|
||||
|
||||
下面给出一个从 Bean 获取实例的例子,采用比较古老的 xml 配置方式:
|
||||
|
||||
```java
|
||||
public interface City {
|
||||
Int getPeople();
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
public class CityImpl implements City {
|
||||
public Int getPeople() {
|
||||
return 1000;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
接下来用 xml 描述这个 bean:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://www.springframework.org/schema/beans"
|
||||
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd" default-autowire="byName">
|
||||
|
||||
<bean id="city" class="xxx.CityImpl"/>
|
||||
</beans>
|
||||
```
|
||||
|
||||
`bean` 支持的属性还有很多,由于本文并不做入门教学,就不一一列举了,总之 `id` 是一个可选的唯一标志,接下来我们可以通过 `id` 访问到 city 的实例。
|
||||
|
||||
```java
|
||||
public class App {
|
||||
public static void main(String[] args) {
|
||||
ApplicationContext context = new ClassPathXmlApplicationContext("classpath:application.xml");
|
||||
|
||||
// 从 context 中读取 Bean,而不 new City()
|
||||
City city = context.getBean(City.class);
|
||||
|
||||
System.out.println(city.getPeople());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,程序任何地方使用 city 实例,只需要调用 `getBean` 函数,就像一个工厂把实例化过程给承包了,我们不需要关心 City 构造函数要传递什么参数,不需要关心它依赖哪些其他的类,只要这一句话就可以拿到实例,是不是在维护项目时省心了很多。
|
||||
|
||||
## AOP
|
||||
|
||||
AOP(Aspect Oriented Program)面向切面编程。
|
||||
|
||||
AOP 是为了解决主要业务逻辑与次要业务逻辑之间耦合问题的。主要业务逻辑比如登陆、数据获取、查询等,次要业务逻辑比如性能监控、异常处理等等,次要业务逻辑往往有:不重要、和业务关联度低、贯穿多处业务逻辑的特性,如果没有好的设计模式,只能在业务代码里将主要逻辑与次要逻辑混合起来,但 AOP 可以做到主要、次要业务逻辑隔离。
|
||||
|
||||
使用 AOP 就是在定义在哪些地方(类、方法)切入,在什么地方切入(方法前、后、前后)以及做什么。
|
||||
|
||||
比如说,我们想在某个方法前后分别执行两个函数计算执行时间,下面是主要业务逻辑:
|
||||
|
||||
```java
|
||||
@Component("work")
|
||||
public class Work {
|
||||
public void do() {
|
||||
System.out.println("执行业务逻辑");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
再定义切面方法:
|
||||
|
||||
```java
|
||||
@Component
|
||||
@Aspect
|
||||
class Broker {
|
||||
@Before("execution(* xxx.Work.do())")
|
||||
public void before(){
|
||||
// 记录开始时间
|
||||
}
|
||||
|
||||
@After("execution(* xxx.Work.do())")
|
||||
public void after(){
|
||||
// 计算时间
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
再通过 xml 定义扫描下这两个 Bean,就可以在运行 `work.do()` 之前执行 `before()`,之后执行 `after()`。
|
||||
|
||||
还可以完全覆盖原函数,利用 `joinPoint.proceed()` 可以执行原函数:
|
||||
|
||||
```java
|
||||
@Component
|
||||
@Aspect
|
||||
class Broker {
|
||||
@Around("execution(* xxx.Work.do())")
|
||||
public void around(ProceedingJoinPoint joinPoint) {
|
||||
// 记录开始时间
|
||||
|
||||
try {
|
||||
joinPoint.proceed();
|
||||
} catch (Throwable throwable) {
|
||||
throwable.printStackTrace();
|
||||
}
|
||||
|
||||
// 计算时间
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关于表达式 `"execution(* xxx.Work.do())"` 是用正则的方式匹配,`*` 表示任意返回类型的方法,后面就不用解释了。
|
||||
|
||||
可以看到,我们可以在不修改原方法的基础上,在其执行前后增加自定义业务逻辑,或者监控其报错,非常适合做次要业务逻辑,且由于不与主要业务逻辑代码耦合,保证了代码的简洁,且次要业务逻辑不容易遗漏。
|
||||
|
||||
## 总结
|
||||
|
||||
IOC 特别适合描述业务模型,后端天然需要这一套,然而随着前端越做越重,如果某个业务场景下需要将部分业务逻辑放到前端,也是非常推荐使用 IOC 设计模式来做,这是后端沉淀了近 20 年的经验,没有必要再另辟蹊径。
|
||||
|
||||
AOP 对前端有帮助但没有那么大,因为前端业务逻辑较为分散,如果要进行切面编程,往往用 `window` 事件监听来做会更彻底,可能这都是前端没有流行 AOP 的原因。当然前端约定大于配置的趋势下,比如打点或监控都集成到框架内部,往往也做到了业务代码无感,剩下的业务代码也就没有 AOP 的需求。
|
||||
|
||||
最后,spring 的低侵入式设计,使得业务代码不用关心框架,让业务代码能够快速在不同框架间切换,这不仅方便了业务开发者,更使得 spring 走向成功,这是前端还需要追赶的。
|
||||
|
||||
> 讨论地址是:[精读《Spring 概念》· Issue #265 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/265)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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,783 @@
|
||||
bi-designer 是阿里数据中台团队自研的前端搭建引擎,基于它开发了阿里内部最大的数据分析平台,以及阿里云上的 QuickBI。
|
||||
|
||||
> bi-designer 目前没有开源,因此文中使用的私有 npm 源 `@alife/bi-designer` 是无法在公网访问的。
|
||||
|
||||
本文介绍 bi-designer 设计器的使用 API。
|
||||
|
||||
bi-designer 设计有如下几个特点:
|
||||
|
||||
- **心智统一:编辑模式与渲染模式统一**。
|
||||
- **通用搭建:支持接入任意通用 npm 组件**。
|
||||
- **低入侵:围绕数据分析能力做了增强,但对组件代码无入侵**。
|
||||
|
||||
## 渲染画布
|
||||
|
||||
做搭建,第一步是将画布渲染出来,需要用到 `Designer` 与 `Canvas` 组件:
|
||||
|
||||
```jsx
|
||||
import { Designer, Canvas } from '@alife/bi-designer'
|
||||
export () => (
|
||||
<Designer>
|
||||
<Canvas />
|
||||
</Designer>
|
||||
)
|
||||
```
|
||||
|
||||
- `Designer`:数据容器,用于管理渲染引擎数据流。
|
||||
- 参数 `defaultPageSchema`:页面 DSL 默认值。
|
||||
- 参数 `defaultMode`:控制编辑渲染状态,`edit` or `render`。
|
||||
- `Canvas`:渲染画布的所有组件,会根据 DSL 结构将组件一一渲染出来。
|
||||
|
||||
## 编辑模式
|
||||
|
||||
编辑模式 = 渲染画布(编辑模式)+ 拓展一些自定义面板。
|
||||
|
||||
```jsx
|
||||
import { Designer, Canvas } from '@alife/bi-designer'
|
||||
|
||||
export () => (
|
||||
<Designer defaultMode="edit">
|
||||
<div>Header</div>
|
||||
<Canvas />
|
||||
<div>Footer</div>
|
||||
</Designer>
|
||||
)
|
||||
```
|
||||
|
||||
编辑模式的拓展采用了 JSX 模式,没有增加任何新的语法,只要放置任意数量的组件,并将画布 `Canvas` 摆放在想要的位置即可。
|
||||
|
||||
`defaultMode` 描述了当前引擎所处状态,有 `edit` 与 `render` 两个可选值,可以通过 `{ mode } = useDesigner(modeSelector)` 获取。bi-designer 没有对 `mode` 做任何特殊处理,我们可以在 panel、组件中判断不同的 `mode` 走不同的逻辑,以此区分编辑与渲染态。
|
||||
|
||||
## 页面 DSL 结构
|
||||
|
||||
`pageSchema` 描述了页面 DSL 信息,其结构是一个 `Map<组件 id, 组件实例信息>`。
|
||||
|
||||
这里统一一下名词:
|
||||
|
||||
- 组件实例信息:`componentInstance`。
|
||||
- 组件元信息:`componentMeta`。
|
||||
|
||||
那么 `pageSchema` 的结构大致如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"componentInstances": {
|
||||
"1": {
|
||||
"id": "1",
|
||||
"componentName": "root",
|
||||
},
|
||||
"2": {
|
||||
"id": "2",
|
||||
"parentId": "1",
|
||||
"componentName": "button",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
根据 `id` `parentId` 关系描述了组件父子关系,对于同一个父节点在流式布局下的顺序,还会增加 `index` 标记顺序。
|
||||
|
||||
## 注册组件
|
||||
|
||||
DSL 描述信息中最重要的是 `componentName`,为了告诉渲染引擎这个组件是什么,我们需要将组件元信息(`componentMetas`)传递给 `Designer`:
|
||||
|
||||
```jsx
|
||||
import { Designer, Canvas, Interfaces } from '@alife/bi-designer'
|
||||
|
||||
export () => (
|
||||
<Designer componentMetas={componentMetas}>
|
||||
<Canvas />
|
||||
</Designer>
|
||||
)
|
||||
|
||||
const componentMetas: Interfaces.ComponentMetas = {
|
||||
button: {
|
||||
componentName: 'button',
|
||||
element: Button
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关于 `componentMeta` 会在下一篇精读详细介绍,这里只说明两个最重要的属性:
|
||||
|
||||
- `componentName`:组件名,唯一。
|
||||
- `element`:组件 UI 对象,对应一个 React 组件实例。
|
||||
|
||||
注意这里就留下了不少拓展空间,`componentMetas` 可以存储在服务端,`element` 可以远程异步加载,也可以在项目代码中固化,但传递给渲染引擎的 API 是固定的。
|
||||
|
||||
## 布局
|
||||
|
||||
bi-designer 支持流式布局、磁贴布局、自由布局三种模式,通过 `Designer.layout` 属性定义:
|
||||
|
||||
```jsx
|
||||
import { Designer, Canvas, Interfaces } from '@alife/bi-designer'
|
||||
import { LayoutMover } from '@alife/bi-designer-stream-layout'
|
||||
|
||||
export () => (
|
||||
<Designer layout={LayoutMover}>
|
||||
<Canvas />
|
||||
</Designer>
|
||||
)
|
||||
```
|
||||
|
||||
我们提供了三种不同的布局包,切换对应的包即可切换布局,你甚至可以再包裹一层,通过代码控制在运行时切换布局。
|
||||
|
||||
`layout` 会包裹在每个组件外层,无论是流式、磁贴还是自由布局,都可以通过附着在每个组件外层来实现。
|
||||
|
||||
## 操作/获取画布内容
|
||||
|
||||
只要在数据容器 `Designer` 下,就可以通过 `useDesigner()` 获取画布信息或者修改画布内容。
|
||||
|
||||
举个例子,比如实现组件配置面板,需要获取到 **当前选中组件**,以及实现操作 **更新 DSL 中某个组件信息**:
|
||||
|
||||
```jsx
|
||||
import { Designer, Canvas, useDesigner, selectedComponentsSelector } from '@alife/bi-designer';
|
||||
|
||||
const EditPanel = () => {
|
||||
const { updateComponentById, selectedComponents } =
|
||||
useDesigner(selectedComponentsSelector());
|
||||
|
||||
// 在合适的时候调用 updateComponentById 更新 selectedComponents
|
||||
|
||||
// 渲染组件配置表单..
|
||||
}
|
||||
|
||||
export () => (
|
||||
<Designer>
|
||||
<Canvas />
|
||||
<EditPanel />
|
||||
</Designer>
|
||||
)
|
||||
```
|
||||
|
||||
我们在 `Canvas` 下面渲染了一个自定义组件 `EditPanel` 作为组件配置面板,这个配置面板中,最重要的是这块代码:
|
||||
|
||||
```jsx
|
||||
import { useDesigner, selectedComponentsSelector } from '@alife/bi-designer';
|
||||
const { updateComponentById, selectedComponents } =
|
||||
useDesigner(selectedComponentsSelector());
|
||||
```
|
||||
|
||||
- `useDesigner` 是 React Hook,导出的函数都是静态的,不会因为画布信息变更而导致组件重渲染。
|
||||
- 如果需要监听一些会变化的元素,比如当前选中组件,就需要用 Selector 完成,当这些信息变更时,使用了这些 Selector 的组件也会重渲染,具体 Selector 有很多,比如:
|
||||
- `selectedComponentsSelector`: 当前选中的组件。
|
||||
- `pageSchemaSelector`: 当前画布 DSL。
|
||||
- `modeSelector`: 当前渲染模式。等等。
|
||||
- 对画布组件操作有几个重要的静态方法,包括:
|
||||
- `updateComponentById`: 更新某个 id 组件信息。
|
||||
- `addComponent`: 添加组件。
|
||||
- `deleteComponent`: 删除组件。
|
||||
- `moveComponent`: 移动组件。等等。
|
||||
- 除此之外,`useDesigner` 还提供了很多有用的方法,在用到时再介绍。
|
||||
|
||||
## 主题风格
|
||||
|
||||
通过 `pageSchema.theme` 设置主题风格:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
|
||||
const App = () => (
|
||||
<Designer
|
||||
defaultPageSchema={{
|
||||
theme: { primaryColor: '#333' }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
我们也可以在运行时使用 `setTheme` 动态修改主题风格,做到动态切换主题:
|
||||
|
||||
```jsx
|
||||
const { setTheme, theme } = useDesigner();
|
||||
|
||||
return <Button onClick={() => {
|
||||
setTheme({
|
||||
...theme,
|
||||
primaryColor: '#ffffff'
|
||||
})
|
||||
}} />
|
||||
```
|
||||
|
||||
这些主题颜色,组件可以通过 css 变量拿到:
|
||||
|
||||
```css
|
||||
.ok-button {
|
||||
color: var(--primaryColor);
|
||||
}
|
||||
```
|
||||
|
||||
## 获取组件数据
|
||||
|
||||
数据分析引擎中,组件是由数据驱动展示的,这些数据可能来自 OLAP 数据集,或者普通 URL 接口,但无论如何数据都是一个组件重要组成部分,因此对组件的取数与数据操作是 bi-designer 的一个重点。
|
||||
|
||||
可以利用 `fetchStateSelector` 获取任意组件的数据信息,包括取数状态、数据、是否有查询错误等:
|
||||
|
||||
```jsx
|
||||
import { useDesigner, fetchStateSelector } from '@alife/bi-designer';
|
||||
|
||||
const App = () => {
|
||||
const { fetchState } = useDesigner(fetchStateSelector(componentInstance.id));
|
||||
|
||||
console.log(
|
||||
fetchState.isFetching, // 是否在取数中
|
||||
fetchState.isFilterReady, // 筛选条件是否准备好了
|
||||
fetchState.data, // 取数结果
|
||||
fetchState.error, // 取数错误,如果取数阶段报错的话
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
bi-designer 将所有组件的取数状态统一管理,因此可以跨组件获取数据信息,实现一些复杂需求:比如某些组件配置面板要获取组件取数结果填充配置表单。
|
||||
|
||||
## 组件加载器
|
||||
|
||||
组件加载器 `ComponentLoader` 可以加载任意组件, `Canvas` 就是基于此实现的。
|
||||
|
||||
### 加载画布中已有组件
|
||||
|
||||
通过申明 id 加载一个画布中已有组件,与其共享同一套数据:
|
||||
|
||||
```jsx
|
||||
import { ComponentLoader } from '@alife/bi-designer'
|
||||
const App = () => {
|
||||
return <ComponentLoader id="some-id-already-exist" />
|
||||
}
|
||||
```
|
||||
|
||||
### 加载一个额外的新组件
|
||||
|
||||
如果这个组件不需要响应事件,只是做简单的渲染,那就不需要记录到数据流中,此时仅申明 `componentName` 即可:
|
||||
|
||||
```jsx
|
||||
import { ComponentLoader } from '@alife/bi-designer'
|
||||
const App = () => {
|
||||
return <ComponentLoader componentName="button" />
|
||||
}
|
||||
```
|
||||
|
||||
但这种方式加载的组件存在如下问题:
|
||||
|
||||
- 其组件 `id` 不会存储到 `pageSchema` ,后端可能无法做一些校验。
|
||||
- 无法响应事件,因为事件响应前提是组件信息存在于 `pageSchema` 中。
|
||||
|
||||
### 加载一个有事件功能的额外新组件
|
||||
|
||||
通过申明 `id` 与 `componentName` 加载一个全新组件,为了在其销毁时做有效清理,请将其 id 记录到 `useKeepComponentLoaders` 中。
|
||||
|
||||
```jsx
|
||||
import { ComponentLoader, useDesigner } from '@alife/bi-designer'
|
||||
const App = () => {
|
||||
const { useKeepComponentLoaders } = useDesigner();
|
||||
useKeepComponentLoaders(["1"])
|
||||
|
||||
return <ComponentLoader id="1" componentName="button" />
|
||||
}
|
||||
```
|
||||
|
||||
通过此方式加载的组件会在其渲染时记录到 `pageSchema` 中。
|
||||
|
||||
> 注意,此时 id 与仅写一个 id 时含义不同,这个 id 在当前父组件作用域下唯一就可以。
|
||||
|
||||
## 全屏功能
|
||||
|
||||
所有组件实例都可以存在副本,共享一套状态数据,可以通过 `ComponentLoader` 随时渲染一个组件副本:
|
||||
|
||||
```jsx
|
||||
import { ComponentLoader } from '@alife/bi-designer'
|
||||
|
||||
// ... 任意可拿到 componentInstance 处
|
||||
return (
|
||||
<ComponentLoader id={componentInstance.id} />
|
||||
)
|
||||
```
|
||||
|
||||
那么全屏就是将组件渲染到一个新容器内,非常 easy。
|
||||
|
||||
## 局部配置覆盖
|
||||
|
||||
可以通过 `DesignerProvider` 实现干涉其子元素 `useDesigner` 获取信息的能力:
|
||||
|
||||
```jsx
|
||||
import { DesignerProvider, ComponentLoader } from '@alife/bi-designer';
|
||||
|
||||
// 某个组件内,或者某个 UI 内以 render 模式加载组件
|
||||
// ...
|
||||
return (
|
||||
<DesignerProvider mode="render">
|
||||
<ComponentLoader id={id} />
|
||||
</DesignerProvider>
|
||||
)
|
||||
```
|
||||
|
||||
举个例子,比如在编辑模式下要全屏预览组件,可以通过 `ComponentLoader + id` 把某个画布组件实例渲染到弹出的 Modal 中,但问题是当前属于编辑模式,组件还可以被拖拽甚至响应编辑效果,我们只想让局部变成渲染状态,怎么做呢?
|
||||
|
||||
答案就是通过 `DesignerProvider` 包裹这个 Modal,这个 Modal 内部无论是组件还是其他 Panel 代码通过 `const { mode } = useDesigner(modeSelector)` 拿到的值都会被强制覆盖为 `render`。
|
||||
|
||||
## 配置国际化
|
||||
|
||||
国际化信息在 `pageSchema.i18n` 定义:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
|
||||
const App = () => (
|
||||
<Designer
|
||||
defaultPageSchema={{
|
||||
i18n: {
|
||||
"zh-CN": {
|
||||
你好: "你好",
|
||||
中国: "中国"
|
||||
},
|
||||
"en-US": {
|
||||
你好: "Hello",
|
||||
中国: "China"
|
||||
}
|
||||
}
|
||||
}}
|
||||
defaultLocaleKey="zh-CN"
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
- `defaultLocaleKey`: 默认国际化语言,可以通过 `{ setLocaleKey } = useDesigner()` 动态改变。
|
||||
|
||||
这样在 DSL 中通过描述 `JSExpression` 表达式的 `this.i18n` 访问:
|
||||
|
||||
```json
|
||||
{
|
||||
"componentInstances": {
|
||||
"1": {
|
||||
"id": "1",
|
||||
"componentName": "button",
|
||||
"props": {
|
||||
"text": {
|
||||
"type": "JSExpression",
|
||||
"value": "this.i18n['你好']"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 容器拓展组件 props
|
||||
|
||||
`componentMeta.container` 可以定义组件外层容器,但有的时候我们想在容器做一点事情,比如获取宽高,以 props 的方式传递给子组件。
|
||||
|
||||
因为子组件以 `children` 的方式书写不易拓展,因此提供了 `PropsProvider` 来拓展子组件拿到的 props:
|
||||
|
||||
```jsx
|
||||
import { Interfaces, PropsProvider } from '@alife/bi-designer'
|
||||
|
||||
const ComponentContainer = ({ children }) => {
|
||||
return (
|
||||
// 注入 width 和 height
|
||||
<PropsProvider width={100} height={100}>
|
||||
{children}
|
||||
</PropsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const Element = ({ width, height }) => {
|
||||
// width=100
|
||||
// height=100
|
||||
}
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
element: Element,
|
||||
container: ComponentContainer
|
||||
};
|
||||
```
|
||||
|
||||
上面的例子中,因为 `container` 注入了 `width`,因此组件可以通过 `props.width` 拿到容器注入的值。
|
||||
|
||||
## 撤销重做
|
||||
|
||||
撤销重做按钮在基于每个搭建系统都有,在 bi-designer 的使用方式是这样:
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer'
|
||||
|
||||
export default () => {
|
||||
const { undo, redo } = useDesigner()
|
||||
|
||||
// 撤销调用 undo()
|
||||
// 重做调用 redo()
|
||||
}
|
||||
```
|
||||
|
||||
是不是觉得很简单?是的,因为所有值得撤销重做的操作在引擎内部使用了 `HistoryManager` 管理,因此引擎知道每一个可以被撤销或者重做的操作,直接调用函数即可。
|
||||
|
||||
## 组件复制
|
||||
|
||||
执行 `copyComponent` 命令即可复制组件,比如:
|
||||
|
||||
```jsx
|
||||
const App() {
|
||||
const { copyComponent } = useDesigner()
|
||||
|
||||
// 复制组件 copyComponent(componentInstance)
|
||||
}
|
||||
```
|
||||
|
||||
`copyComponent` 的参数分别为:
|
||||
|
||||
```jsx
|
||||
function copyComponent(
|
||||
componentInstance?: ComponentInstance,
|
||||
parentId?: string,
|
||||
index?: number
|
||||
)
|
||||
```
|
||||
|
||||
- 如不指定 `parentId` ,默认复制到自己父元素下。
|
||||
- 如不指定 `index` ,默认复制到当前元素下方。
|
||||
|
||||
## 组件模版
|
||||
|
||||
如果觉得某些组件配置可能被复用,可以在画布组件右上角增加一个 “添加到组件模版” 按钮,bi-designer 也提供了生成、添加组件模版的方法。
|
||||
|
||||
### 创建组件模版
|
||||
|
||||
利用 `createCombine` 函数从画布中已有组件创建出组件模版,也可以将其生成结果持久化,作为一个固定的组件模版:
|
||||
|
||||
```jsx
|
||||
const ComponentContainer: Interfaces.InnerComponentElement = ({ componentInstance }) => {
|
||||
const { createCombine } = useDesigner();
|
||||
|
||||
const setToCombine = React.useCallback(() => {
|
||||
// 创建组件模版
|
||||
const combine = createCombine(componentInstance.id)
|
||||
}, [createCombine]);
|
||||
}
|
||||
```
|
||||
|
||||
`createCombine` 的参数就是画布中组件的 `id`。
|
||||
|
||||
### 添加组件模版到画布
|
||||
|
||||
利用 `addCombine` 函数将组件模版添加到画布,第一个参数就是上面生成的 `combine` 对象:
|
||||
|
||||
```jsx
|
||||
const App = () => {
|
||||
const { addCombine } = useDesigner();
|
||||
|
||||
const addComponent = React.useCallback(() => {
|
||||
// 创建组件模版
|
||||
const combine = addCombine(combine, parentId)
|
||||
}, [addCombine]);
|
||||
}
|
||||
```
|
||||
|
||||
## 渲染完成标识
|
||||
|
||||
当画布中所有组件都完成渲染了,可能要做一些监控上报,或者告诉截图软件可以截图了,bi-designer 提供了这种回调时机 `onRendered`:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
|
||||
const App = () => (
|
||||
<Designer
|
||||
onRendered={errors => {
|
||||
errors.map(each => {
|
||||
// 错误组件 id
|
||||
console.log(each.id)
|
||||
|
||||
// 错误信息
|
||||
console.log(each.error)
|
||||
})
|
||||
// 渲染完毕
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
- `errors`: 如果有组件代码报错,引擎会吞掉这个错误保证其他组件正常渲染,并把错误组件的 id 和错误信息返回到这里。
|
||||
|
||||
## 自定义数据流
|
||||
|
||||
如果 `useDesigner` 提供的数据流无法满足业务需要,可以通过进行自定义拓展。
|
||||
|
||||
### 1. 拓展字段
|
||||
|
||||
举个例子,我们需要新增一个 `edges` 字段描述当前画布中有哪些 “边节点”:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer';
|
||||
const App = ({ defaultPageSchema }) => (
|
||||
<Designer defaultPageSchema={{
|
||||
...defaultPageSchema,
|
||||
edges: []
|
||||
}} />
|
||||
)
|
||||
```
|
||||
|
||||
可以看到,只要任意拓展 `pageSchema` 即可。
|
||||
|
||||
### 2. 通过 useDesigner 拿到拓展字段
|
||||
|
||||
首先定义一个 `edgesSelector` :
|
||||
|
||||
```jsx
|
||||
import { DesignerState } from '@alife/bi-designer';
|
||||
export const edgesSelector = () => (state: DesignerState) => {
|
||||
return {
|
||||
// 从 pageSchema.edges 读取 edges
|
||||
edges: state.pageSchema?.edges as Edge[],
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
在需要读取的地方结合 `useDesigner` :
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer';
|
||||
import { edgesSelector } from './selector'
|
||||
const Panel = () => {
|
||||
// 自带类型
|
||||
const { edges } = useDesigner(edgesSelector())
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 通过 useDesigner 修改拓展字段
|
||||
|
||||
通过 `setPageSchema` 更新拓展字段:
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer';
|
||||
const Panel = () => {
|
||||
const { setPageSchema } = useDesigner()
|
||||
|
||||
const handleChangeEdges = React.useCallback(newEdges => {
|
||||
setPageSchema(pageSchema => ({
|
||||
...pageSchema,
|
||||
newEdges
|
||||
}))
|
||||
}, [setPageSchema])
|
||||
}
|
||||
```
|
||||
|
||||
总结一下,这个拓展字段由业务定义,透过 `useDesigner` 读与改,使业务数据管理方式更聚合。
|
||||
|
||||
## 存储临时非结构化数据
|
||||
|
||||
对于非结构化数据比如组件 `ref` 是不能存储到数据流的,既不能使用 `setPageSchema`,也不能调用 `updateComponentId` 存储到 `componentInstance` 中。
|
||||
|
||||
此时可以利用 `temporary` 进行临时数据存取,要注意非结构化数据是无法监听变化的,引用永远保持不变:
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer';
|
||||
const App = () => (
|
||||
const { temporary } = useDesigner()
|
||||
// 写
|
||||
temporary.set('component1', ref)
|
||||
// 读
|
||||
console.log(temporary.get('component1'))
|
||||
)
|
||||
```
|
||||
|
||||
temporary 本质是个 Map,所以拥有 Map 类型所有语法。
|
||||
|
||||
## 拦截画布操作
|
||||
|
||||
如果你限制某个低配版本只能在画布使用最多 50 个组件,我们需要阻止画布超过 50 个组件的添加,这个场景可以通过 `DesignerProps` 生命周期可以对画布操作进行拦截。
|
||||
|
||||
`shouldAddComponents()` 返回 `false` 可以阻止画布添加组件:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
const App = () => (
|
||||
<Designer
|
||||
shouldAddComponents={({addedComponentInstancesArray, pageSchema}) => {
|
||||
// 阻止添加
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
- `addedComponentInstancesArray` :添加的组件, `ComponentInstance[]` 类型。
|
||||
|
||||
`shouldMoveComponents()` 返回 `false` 可以阻止画布移动组件:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
const App = () => (
|
||||
<Designer
|
||||
shouldmoveComponents={({movedComponentInstancesArray, targetComponentInstance, pageSchema}) => {
|
||||
// 阻止移动
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
- `movedComponentInstancesArray` :移动的组件,`ComponentInstance[]` 类型。
|
||||
- `taragetComponentInstance` :要移动到的父组件实例信息, `ComponentInstance` 类型。
|
||||
|
||||
`shouldDeleteComponents()` 返回 `false` 可以阻止画布删除组件:
|
||||
|
||||
```jsx
|
||||
import { Designer } from '@alife/bi-designer'
|
||||
const App = () => (
|
||||
<Designer
|
||||
shouldAddComponents={({deletedComponentInstancesArray, pageSchema}) => {
|
||||
// 阻止删除
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
- `deletedComponentInstancesArray` :删除的组件, `ComponentInstance[]` 类型。
|
||||
|
||||
## 仅刷新可视区域组件
|
||||
|
||||
默认组件都会以按需加载的方式渲染,即对于不在可视区域的组件,不会触发任何重渲染,以此提升交互操作的效率,以及首屏速度。
|
||||
|
||||
对于筛选条件等可能影响到其他组件的组件,可以通过 `ComponentMeta.keepActive` 强制保持激活状态:
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from '@alife/bi-designer'
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
keepActive: true
|
||||
}
|
||||
```
|
||||
|
||||
- `keepActive`:组件始终保持激活状态,即不出现在可视区域也会被渲染与响应刷新,默认关闭。
|
||||
|
||||
对于特殊场景比如截图,可能要求所有组件强制为 `active` 状态,可以通过 `forceActive` 函数实现:
|
||||
|
||||
```jsx
|
||||
import { Interfaces, useDesigner } from '@alife/bi-designer'
|
||||
const Test: Interfaces.ComponentElement = () => {
|
||||
const { forceActive, cancelForceActive } = useDesigner()
|
||||
|
||||
// forceActive() 强制所有组件 active
|
||||
// cancelForceActive() 取消强制 active,组件根据实际情况 active
|
||||
};
|
||||
```
|
||||
|
||||
可以通过 `getSnapshot().actives` 获取任意组件当前瞬时 `active` 状态:
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer'
|
||||
const Test = () => {
|
||||
const { getSnapshot, id } = useDesigner()
|
||||
|
||||
// 当前组件激活状态
|
||||
const active = getSnapshot().actives[id]
|
||||
};
|
||||
```
|
||||
|
||||
## 上下文数据对象
|
||||
|
||||
组件 DSL 描述中,表达式类型(`JSExpression`)可以通过 `this.` 访问到上下文数据对象。上下文数据对象符合如下规则:
|
||||
|
||||
- 任何组件都通过配置 `ComponentMeta.stateful` 持有上下文。
|
||||
- 画布根节点 `root` 一定是 `stateful` 的。
|
||||
- `JSFunction` 与 `JSExpression` 都可通过 `this.state` 访问上下文, `this.setState` 修改上下文。
|
||||
|
||||
举例子:
|
||||
|
||||
```jsx
|
||||
// 初始化 pageSchema
|
||||
const defaultPageSchema: Interfaces.PageSchema = {
|
||||
componentInstances: {
|
||||
test1: {
|
||||
id: 'test1',
|
||||
componentName: 'test',
|
||||
parentId: 'jtw4x8ns',
|
||||
index: 0,
|
||||
props: {
|
||||
variable: {
|
||||
type: 'JSExpression',
|
||||
value: 'this.state.variable + "%"',
|
||||
},
|
||||
onClick: {
|
||||
type: 'JSFunction',
|
||||
value: 'function onClick() { this.setState({ variable: 5 }) }',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
这个例子中,组件调用 `this.props.onClick` 会修改上下文 `a=5` ,触发后,其 `this.props.variable` 拿到的值会变为 5% 。
|
||||
|
||||
任何组件或容器只要设置了 `stateful` 就可以持有状态:
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from '@alife/bi-designer'
|
||||
const statefulComponentMeta: Interfaces.ComponentMeta = {
|
||||
stateful: true
|
||||
}
|
||||
```
|
||||
|
||||
被有状态的容器包裹的组件 `this.state` 与 `this.setState` 都局限在当前状态容器内,也就是当前状态容器内组件的 state 是互通的,且一个有状态容器与外部环境是隔离的,可以独立运行。
|
||||
|
||||
## 工具类拓展
|
||||
|
||||
工具类拓展可以通过上下文访问,如下是拓展方式:
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from '@alife/bi-designer'
|
||||
// DSL 中增加 utils 描述
|
||||
const defaultPageSchema: Interfaces.PageSchema = {
|
||||
utils: [
|
||||
{
|
||||
name: 'format',
|
||||
type: 'function',
|
||||
content: `function format(str){ return str + '%' }`,
|
||||
},
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
- `name` :工具函数名。
|
||||
- `type` :类型,包括 `npm` 、 `umd` 、 `function` 。
|
||||
- `content` :内容。
|
||||
|
||||
用法:
|
||||
|
||||
```jsx
|
||||
JSFunction 与 JSExpression 都可以通过 this.utils 访问工具类拓展函数,比如
|
||||
// DSL 中增加 Expression 描述
|
||||
const defaultPageSchema: Interfaces.PageSchema = {
|
||||
componentInstances: {
|
||||
test: {
|
||||
id: 'tg43g42f',
|
||||
componentName: 'expressionComponent',
|
||||
index: 0,
|
||||
props: {
|
||||
variable: {
|
||||
type: 'JSExpression',
|
||||
value: 'this.utils.format("100")',
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
上面的例子中,组件拿到的 `props.variable` 值为 100% 。
|
||||
|
||||
## 总结
|
||||
|
||||
如果你认真看完了全文,就会发现,bi-designer 是一个集成了数据流的开发框架,而不仅是一个渲染引擎,但却可以和你现有的业务代码友好相处,没有入侵性。
|
||||
|
||||
像渲染完成标识、按需渲染、组件加载器、局部配置覆盖等功能是强依赖渲染引擎存在的,因此较难在剥离渲染引擎的条件下转换为代码,因为做 BI 分析工具毕竟不是做研发提效用,业务上没有出码的必要,因此我们会做许多依赖渲染引擎的能力增强。
|
||||
|
||||
更多数据分析特性的功能将在下一个话题 API 之组件说明。
|
||||
|
||||
> 讨论地址是:[精读《数据搭建引擎 bi-designer API-设计器》· Issue #267 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/267)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
筛选条件是 BI 搭建的核心概念,我们大部分所说的探索式分析、图表联动也都属于筛选条件的范畴,**其本质就是一个组件对另一个组件的数据查询起到筛选作用**。
|
||||
|
||||
## 筛选组件是如何作用的
|
||||
|
||||
我们最常见的筛选条件就是表单场景的查询控件,如下图所示:
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/tfs/TB107njjRFR4u4jSZFPXXanzFXa-724-302.png">
|
||||
|
||||
若干 “具有输出能力” 的组件作为筛选组件,点击查询按钮时触发其作用组件重新取数。
|
||||
|
||||
注意这里 “具有输出能力” 的组件不仅是输入框等具有输入性质的组件,其实所有具备交互能力的组件都可以,甚至可以由普通组件承担筛选触发的能力:
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/tfs/TB1V.sVgSslXu8jSZFuXXXg7FXa-858-196.png">
|
||||
|
||||
一个表格的表头点击也可以触发筛选行为,或者柱状图的一个柱子被点击都可以,只要进行到这层抽象,**组件间联动本质也属于筛选行为**。
|
||||
|
||||
同样重要的,筛选作用的组件也可以是具备输入能力的组件:
|
||||
|
||||
<img width=450 src="https://img.alicdn.com/tfs/TB1qqrxUpT7gK0jSZFpXXaTkpXa-1280-198.png">
|
||||
|
||||
当目标组件是具备筛选能力组件时,这就是筛选联动场景了,所以 **筛选联动也属于普通筛选行为**。至于目标组件触发取数后,是否立即修改其筛选值,进而触发后续的筛选联动,就完全由业务特性决定了。
|
||||
|
||||
一个组件也可以自己联动自己筛选,比如折线图点击下钻的场景,就是自己触发了筛选,作用到自己的例子。
|
||||
|
||||
## 什么是筛选组件
|
||||
|
||||
**任何组件都可以是筛选组件**。
|
||||
|
||||
可能最容易理解的是输入框、下拉框、日期选择器等具备输入特征的组件,这些组件只能说天然适合作为筛选组件,但不代表系统设计要为这些组件特殊处理。
|
||||
|
||||
扩大想一想,其实普通的按钮、表格、折线图等等 **具有展示属性的组件也具有输入特性的一面**,比如按钮被点击时触发查询、单元格被点击时想查询当前城市的数据趋势、折线图某条线被点击时希望自身从年下钻到月等等。
|
||||
|
||||
所以 **不存在筛选组件这概念,而是任何组件都具有筛选的能力**,因此筛选是一种任何组件都具有的能力,而不局限在某几个组件上,一旦这么设计,可以做到以下几点:
|
||||
|
||||
1. 实现输入类组件到展示类组件的筛选,符合基本筛选诉求。
|
||||
2. 实现展示类组件到展示类组件的筛选,属于图表联动图表的高级功能。
|
||||
3. 实现输入类组件到输入类组件的筛选,属于筛选联动功能。
|
||||
4. 实现组件自身到自身的筛选,实现下钻功能。
|
||||
|
||||
下面介绍 bi-designer 的筛选条件设计。
|
||||
|
||||
## 筛选条件设计
|
||||
|
||||
基于上述分析,bi-designer 在组件元信息中没有增加所谓的筛选组件类型,而是将其设定为一种筛选能力,任何组件都能触发。
|
||||
|
||||
### 如何触发筛选
|
||||
|
||||
组件调用 `onFilterChange` 即可完成筛选动作:
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from "@alife/bi-designer";
|
||||
|
||||
const InputFilter = () => {
|
||||
const { onFilterChange } = useDesigner();
|
||||
|
||||
return (
|
||||
<input onChange={(event) => () => onFilterChange(event.target.value)} />
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
但这种开发方式违背了 **低侵入** 的设计理念,我们可以采用组件与引擎解构的方式,让输入框变更的时候直接调用 `props.onChange` ,这个组件保持了最大的独立性:
|
||||
|
||||
```jsx
|
||||
const InputFilter = ({ onChange }) => {
|
||||
return <input onChange={(event) => () => onChange(event.target.value)} />;
|
||||
};
|
||||
```
|
||||
|
||||
那渲染引擎怎么将 `onFilterChange` 映射到 `props.onChange` 呢?如下配置 DSL 即可:
|
||||
|
||||
```json
|
||||
{
|
||||
"props": {
|
||||
"onChange": {
|
||||
"type": "JSExpression",
|
||||
"value": "this.onFilterChange"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 筛选影响哪些组件
|
||||
|
||||
一般筛选组件会选择作用于的目标组件,类似下图:
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/tfs/TB1RJHPUxD1gK0jSZFsXXbldVXa-768-486.png">
|
||||
|
||||
这些信息会存储在筛选组件的组件配置中,即 `componentInstance.props`,筛选目标组件在 `componentMeta.eventConfigs` 组件元信息的事件中配置:
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from "@alife/bi-designer";
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
eventConfigs: ({ componentInstance }) =>
|
||||
componentInstance.props.targets?.map((target) => ({
|
||||
// 筛选取数
|
||||
type: "filterFetch",
|
||||
// 触发组件
|
||||
source: componentInstance.id,
|
||||
// 作用组件
|
||||
target: target.id,
|
||||
})),
|
||||
};
|
||||
```
|
||||
|
||||
如上所示,假设作用于组件存储在 `props.targets` 字段中,我们将其 `map` 一下都设置为 `filterFetch` 类型,表示筛选作用,`source` 触发源是自己,`target` 目标组件是存储的 `target.id`。
|
||||
|
||||
这样当 `source` 组件调用了 `onFilterChange`,`target` 组件就会触发取数,并在取数参数中拿到作用于其的筛选组件信息与筛选值。
|
||||
|
||||
### 组件如何感知筛选条件
|
||||
|
||||
组件取数是结合了筛选条件一起的,只要如上设置了 `filterFetch`,渲染引擎会自动在计算取数参数的回调函数 `getFetchParam` 中添加 `filters` 代表筛选组件信息,组件可以结合自身 `componentInstance` 与 `filters` 推导出最终取数参数:
|
||||
|
||||
<img width=300 src="https://img.alicdn.com/tfs/TB1tDDTUuH2gK0jSZJnXXaT1FXa-870-434.png">
|
||||
|
||||
最终,组件元信息只要写一个 `getFetchParam` 回调函数即可,**可以自动拿到作用于它的筛选组件,而不用关心是哪些配置导致了关联,只要响应式的去处理筛选作用即可**。
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from "@alife/bi-designer";
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
// 组装取数参数
|
||||
getFetchParam: ({ componentInstance, filters }) => {
|
||||
// 结合 componentInstance 与 filters.map... 返回取数参数
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 筛选组件间联动带来的频繁取数问题
|
||||
|
||||
对于筛选联动的复杂场景,会遇到频繁取数的问题。
|
||||
|
||||
假设国家、省、市三级联动筛选条件同时 `filterFetch` 作用于一个表格,这个表格取数的筛选条件需要同时包含国家、省、市三个参数,但我们又设置了 国家、省、市 这三个筛选组件之间的 `filterFetch` 作为筛选联动,那么国家切换后、省改变、联动市改变,这个过程筛选值会变化三次,但我们只想表格组件取数函数仅执行最后的一次,怎么办呢?
|
||||
|
||||
<img width=350 src="https://img.alicdn.com/tfs/TB1CFD9UBr0gK0jSZFnXXbRRXXa-984-656.png">
|
||||
|
||||
如上图所示,其实每个筛选条件在渲染引擎数据流中还存储了一个 `ready` 状态,表示筛选条件是否就绪,**一个组件关联的筛选条件只要有一个 `ready` 不为 `true`,组件就不会触发取数**。
|
||||
|
||||
因此我们需要在筛选变化的过程中,总是保证一个筛选组件的 `ready` 为 `false`,等筛选间联动完毕了,所有筛选器的 `ready` 为 `true`,组件才会取数,我们可以使用 `filterReady` 筛选依赖配置:
|
||||
|
||||
```jsx
|
||||
import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer";
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
eventConfigs: ({ componentInstance }) =>
|
||||
componentInstance.props.targets?.map((target) => ({
|
||||
// 筛选就绪依赖
|
||||
type: "filterReady",
|
||||
// 触发组件
|
||||
source: componentInstance.id,
|
||||
// 作用组件
|
||||
target: target.id,
|
||||
})),
|
||||
};
|
||||
```
|
||||
|
||||
这样配置后,当 `source` 组件触发 `onFilterChange` 后,`target` 组件的筛选 `ready` 会立即设置为 `false`,只有 `target` 组件取完数后主动触发 `onFilterChange` 才会将自己的 `ready` 重新置为 `true`。**That'a all,其他流程没有任何感知**。
|
||||
|
||||
## 若干筛选组件聚合成一个查询控件
|
||||
|
||||
除了联动外,也会存在防止频繁查询的诉求,希望将多个筛选条件绑定成一个大筛选组件,在点击 “查询” 按钮时再取数:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1nmHVUuH2gK0jSZJnXXaT1FXa-972-174.png">
|
||||
|
||||
可以利用 **筛选作用域** 轻松实现此功能,只需要两步:
|
||||
|
||||
### 筛选组件设置独立筛选作用域
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from "@alife/bi-designer";
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
// 通过 componentInstance 判断,如果是全局筛选器内部,则设置 filterScope
|
||||
filterScope: ({ componentInstance }) => ["my-custom-scope-name"],
|
||||
};
|
||||
```
|
||||
|
||||
这样,这批筛选组件就与其作用的组件属于不同的 **筛选作用域** 了,所以筛选不会对其立即生效,功能实现了一半。
|
||||
|
||||
### 确认按钮点击时调用 `submitFilterScope`
|
||||
|
||||
```jsx
|
||||
import { useDesigner } from '@alife/bi-designer'
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
const { submitFilterScope } = useDesigner()
|
||||
// 点击确认按钮时,调用 submitFilterScope('my-custom-scope-name')
|
||||
};
|
||||
```
|
||||
|
||||
你可以在点击查询按钮后调用 `submitFilterScope` 并传入对应作用域名称,这样作用域内筛选组件就会立即对其 `target` 组件生效了。
|
||||
|
||||
至于确认按钮、UI 上的聚合,这些你可以写一个自定义组件去做,利用 `ComponentLoader` 把筛选组件聚合到一起加载,总之功能与 UI 是解耦的。
|
||||
|
||||
如果你对原理感兴趣,可以再多看一下这张图:
|
||||
|
||||
<img width=400 src="https://img.alicdn.com/tfs/TB1eZPJhAcx_u4jSZFlXXXnUFXa-1082-645.png">
|
||||
|
||||
### 突破筛选作用域
|
||||
|
||||
然而实际场景中,可能存在更复杂的组合,见下面的例子:
|
||||
|
||||
<img width=350 src="https://img.alicdn.com/tfs/TB1cfGNiIVl614jSZKPXXaGjpXa-966-600.png">
|
||||
|
||||
筛选器 1 同时对 筛选器 2、表格 产生筛选作用 `filterFetch`,但对 表格 的作用希望通过查询按钮拦截住,而对 筛选器 2 的作用希望能立即生效,对于这个例子有两种方式解决:
|
||||
|
||||
最简单的方式就是将 筛选器 1、筛选器 2 设置为相同作用域 `group1`,这样就通过作用域分割自然实现了效果,**而且这本质上是两个筛选器 UI 不在一起,但筛选作用域相同的例子**:
|
||||
|
||||
<img width=350 src="https://img.alicdn.com/tfs/TB1_kn1UpT7gK0jSZFpXXaTkpXa-1056-660.png">
|
||||
|
||||
但是再变化一下,如果筛选器 2 也对表格产生筛选作用,那我们将 筛选器 1、筛选器 2 放入同一个 `group1` 等于对表格的查询都会受到 “查询” 按钮的控制,但 **我们又希望筛选器 2 可以立即作用于表格**:
|
||||
|
||||
<img width=350 src="https://img.alicdn.com/tfs/TB1ftP3Urr1gK0jSZFDXXb9yVXa-968-602.png">
|
||||
|
||||
如图所示,我们只能将 筛选器 1 的筛选作用域设置为 `group1`,这样 筛选器 2 与 表格 属于同一个筛选作用域,他们之间筛选会立即生效,我们只要解决 筛选器 1 不能立即作用于 筛选器 2 的问题即可,可以通过 `ignoreFilterScope` 方式突破筛选作用域:
|
||||
|
||||
```jsx
|
||||
import { Interfaces } from "@alife/bi-designer";
|
||||
|
||||
const componentMeta: Interfaces.ComponentMeta = {
|
||||
eventConfigs: ({ componentInstance }) =>
|
||||
componentInstance.props.targets?.map((target) => ({
|
||||
// 筛选取数
|
||||
type: "filterFetch",
|
||||
// 触发组件
|
||||
source: componentInstance.id,
|
||||
// 作用组件
|
||||
target: target.id,
|
||||
// 突破筛选作用域
|
||||
ignoreFilterFetch: true,
|
||||
})),
|
||||
};
|
||||
```
|
||||
|
||||
我们只要在 `source: 筛选器1` `target: 筛选器2` 的 `filterFetch` 配置中,将 `ignoreFilterFetch` 设置为 `true`,这个 `filterFetch` 就会忽略筛选作用域,实现立即 筛选器 1 立即作用到 筛选器 2 的效果。
|
||||
|
||||
## 总结
|
||||
|
||||
你还有哪些特殊的筛选诉求?可以用这套筛选设计解决吗?
|
||||
|
||||
> 讨论地址是:[精读《BI 搭建 - 筛选条件》· Issue #270 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/270)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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))
|
||||
@@ -11,17 +11,3 @@
|
||||
## 关注前端精读微信公众号
|
||||
|
||||
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
|
||||
|
||||
## Special Sponsors
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://e.coding.net/?utm_source=weekly" target="_blank">
|
||||
<img width="300" src="https://img.alicdn.com/tfs/TB107D.QbrpK1RjSZTEXXcWAVXa-1000-332.png">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user