Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01d658e744 | ||
|
|
36349d365d | ||
|
|
61bbf1db05 |
@@ -6,7 +6,7 @@
|
||||
|
||||
前端界的好文精读,每周更新!
|
||||
|
||||
最新精读:<a href="./前沿技术/238.%E7%B2%BE%E8%AF%BB%E3%80%8A%E4%B8%8D%E5%86%8D%E9%9C%80%E8%A6%81%20JS%20%E5%81%9A%E7%9A%84%205%20%E4%BB%B6%E4%BA%8B%E3%80%8B.md">238.精读《不再需要 JS 做的 5 件事》</a>
|
||||
最新精读:<a href="./前沿技术/240.%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20useEvent%20RFC%E3%80%8B.md">240.精读《React useEvent RFC》</a>
|
||||
|
||||
素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2)
|
||||
|
||||
@@ -188,6 +188,8 @@
|
||||
- <a href="./前沿技术/230.%E7%B2%BE%E8%AF%BB%E3%80%8A%E5%AF%B9%20Markdown%20%E7%9A%84%E6%80%9D%E8%80%83%E3%80%8B.md">230.精读《对 Markdown 的思考》</a>
|
||||
- <a href="./前沿技术/237.%E7%B2%BE%E8%AF%BB%E3%80%8ATypescript%204.5-4.6%20%E6%96%B0%E7%89%B9%E6%80%A7%E3%80%8B.md">237.精读《Typescript 4.5-4.6 新特性》</a>
|
||||
- <a href="./前沿技术/238.%E7%B2%BE%E8%AF%BB%E3%80%8A%E4%B8%8D%E5%86%8D%E9%9C%80%E8%A6%81%20JS%20%E5%81%9A%E7%9A%84%205%20%E4%BB%B6%E4%BA%8B%E3%80%8B.md">238.精读《不再需要 JS 做的 5 件事》</a>
|
||||
- <a href="./前沿技术/239.%E7%B2%BE%E8%AF%BB%E3%80%8AJS%20%E6%95%B0%E7%BB%84%E7%9A%84%E5%86%85%E9%83%A8%E5%AE%9E%E7%8E%B0%E3%80%8B.md">239.精读《JS 数组的内部实现》</a>
|
||||
- <a href="./前沿技术/240.%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20useEvent%20RFC%E3%80%8B.md">240.精读《React useEvent RFC》</a>
|
||||
|
||||
### 设计模式
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
第一名 [next.js](https://github.com/vercel/next.js) 在整体榜单里了,在 Node 框架一骑绝尘。
|
||||
|
||||
第二名 [nest](https://github.com/nestjs/nest) 和 next.js 很像,据我当时的了解,是因为 next.js 起步较慢,源码还不支持 ts,所以就有了这个更时髦的新框架。但实际上 next.js 早就全部改为 ts 了,而且正如整体榜单所说,现在已经开始引领潮流了,所以不怪 nest 定位重合,只能怪 next.js 后续发力太猛了。nest 的唯一特点就是没有绑定 UI 库。
|
||||
第二名 [nest](https://github.com/nestjs/nest) 是一个 node 版 server 框架,支持传统的 Controller、Module、Service,支持用装饰器申明路由、控制器等,语法上比较时髦。
|
||||
|
||||
第三名 [Strapi](https://github.com/strapi/strapi) 专门为 API 场景服务,提供了一个 API 管理后台,解决了只需要一个便捷 API 管理,而不希望了解一个大而全的后端框架的痛点。
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
每个 JS 执行引擎都有自己的实现,我们这次关注 [V8](https://v8.dev/) 引擎是如何实现数组的。
|
||||
|
||||
本周主要精读的文章是 [How JavaScript Array Works Internally?](https://blog.gauravthakur.in/how-javascript-array-works-internally),比较简略的介绍了 V8 引擎的数组实现机制,笔者也会参考部分其他文章与源码结合进行讲解。
|
||||
|
||||
## 概述
|
||||
|
||||
JS 数组的内部类型有很多模式,如:
|
||||
0
|
||||
- PACKED_SMI_ELEMENTS
|
||||
- PACKED_DOUBLE_ELEMENTS
|
||||
- PACKED_ELEMENTS
|
||||
- HOLEY_SMI_ELEMENTS
|
||||
- HOLEY_DOUBLE_ELEMENTS
|
||||
- HOLEY_ELEMENTS
|
||||
|
||||
PACKED 翻译为打包,实际意思是 “连续有值的数组”;HOLEY 翻译为孔洞,表示这个数组有很多孔洞一样的无效项,实际意思是 “中间有孔洞的数组”,这两个名词是互斥的。
|
||||
|
||||
SMI 表示数据类型为 32 位整型,DOUBLE 表示浮点类型,而什么类型都不写,表示数组的类型还杂糅了字符串、函数等,这个位置上的描述也是互斥的。
|
||||
|
||||
所以可以这么去看数组的内部类型:`[PACKED, HOLEY]_[SMI, DOUBLE, '']_ELEMENTS`。
|
||||
|
||||
### 最高效的类型 PACKED_SMI_ELEMENTS
|
||||
|
||||
一个最简单的空数组类型默认为 PACKED_SMI_ELEMENTS:
|
||||
|
||||
```js
|
||||
const arr = [] // PACKED_SMI_ELEMENTS
|
||||
```
|
||||
|
||||
PACKED_SMI_ELEMENTS 类型是性能最好的模式,存储的类型默认是连续的整型。当我们插入整型时,V8 会给数组自动扩容,此时类型还是 PACKED_SMI_ELEMENTS:
|
||||
|
||||
```js
|
||||
const arr = [] // PACKED_SMI_ELEMENTS
|
||||
arr.push(1) // PACKED_SMI_ELEMENTS
|
||||
```
|
||||
|
||||
或者直接创建有内容的数组,也是这个类型:
|
||||
|
||||
```js
|
||||
const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
|
||||
```
|
||||
|
||||
### 自动降级
|
||||
|
||||
当我们对数组使用骚操作时,V8 会默默的进行类型降级。比如突然访问到第 100 项:
|
||||
|
||||
```js
|
||||
const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
|
||||
arr[100] = 4 // HOLEY_SMI_ELEMENTS
|
||||
```
|
||||
|
||||
如果突然插入一个浮点类型,会降级到 DOUBLE:
|
||||
|
||||
```js
|
||||
const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
|
||||
arr.push(4.1) // PACKED_DOUBLE_ELEMENTS
|
||||
```
|
||||
|
||||
当然如果两个骚操作一结合,HOLEY_DOUBLE_ELEMENTS 就成功被你造出来了:
|
||||
|
||||
```js
|
||||
const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
|
||||
arr[100] = 4.1 // HOLEY_DOUBLE_ELEMENTS
|
||||
```
|
||||
|
||||
再狠一点,插入个字符串或者函数,那就到了最最兜底类型,HOLEY_ELEMENTS:
|
||||
|
||||
```js
|
||||
const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
|
||||
arr[100] = '4' // HOLEY_ELEMENTS
|
||||
```
|
||||
|
||||
从是否有 Empty 情况来看,PACKED > HOLEY 的性能,Benchmark 测试结果大概快 23%。
|
||||
|
||||
从类型来看,SMI > DOUBLE > 空类型。原因是类型决定了数组每项的长度,DOUBLE 类型是指每一项可能为 SMI 也可能为 DOUBLE,而空类型的每一项类型完全不可确认,在长度确认上会花费额外开销。
|
||||
|
||||
因此,HOLEY_ELEMENTS 是性能最差的兜底类型。
|
||||
|
||||
### 降级的不可逆性
|
||||
|
||||
文中提到一个重点,表示降级是不可逆的,具体可以看下图:
|
||||
|
||||
<img width=500 src="https://s1.ax1x.com/2022/05/08/O3nzsf.png">
|
||||
|
||||
其实要表达的规律很简单,即 PACKED 只会变成更糟的 HOLEY,SMI 只会往更糟的 DOUBLE 和空类型变,且这两种变化都不可逆。
|
||||
|
||||
## 精读
|
||||
|
||||
为了验证文章的猜想,笔者使用 v8-debug 调试了一番。
|
||||
|
||||
### 使用 v8-debug 调试
|
||||
|
||||
先介绍一下 v8-debug,它是一个 v8 引擎调试工具,首先执行下面的命令行安装 `jsvu`:
|
||||
|
||||
```bash
|
||||
npm i -g jsvu
|
||||
```
|
||||
|
||||
然后执行 `jsvu`,根据引导选择自己的系统类型,第二步选择要安装的 js 引擎,选择 `v8` 和 `v8-debug`:
|
||||
|
||||
```bash
|
||||
jsvu
|
||||
// 选择 macos
|
||||
// 选择 v8,v8-debug
|
||||
```
|
||||
|
||||
然后随便创建一个 js 文件,比如 `test.js`,再通过 `~/.jsvu/v8-debug ./test.js` 就可以执行调试了。默认是不输出任何调试内容的,我们根据需求添加参数来输出要调试的信息,比如:
|
||||
|
||||
```bash
|
||||
~/.jsvu/v8-debug ./test.js --print-ast
|
||||
```
|
||||
|
||||
这样就会把 `test.js` 文件的语法树打印出来。
|
||||
|
||||
### 使用 v8-debug 调试数组的内部实现
|
||||
|
||||
为了观察数组的内部实现,使用 `console.log(arr)` 显然不行,我们需要用 `%DebugPrint(arr)` 以 debug 模式打印数组,而这个 `%DebugPrint` 函数式 V8 提供的 Native API,在普通 js 脚本是不识别的,因此我们要在执行时添加参数 `--allow-natives-syntax`:
|
||||
|
||||
```bash
|
||||
~/.jsvu/v8-debug ./test.js --allow-natives-syntax
|
||||
```
|
||||
|
||||
同时,在 `test.js` 里使用 `%DebugPrint` 打印我们要调试的数组,如:
|
||||
|
||||
```js
|
||||
const arr = []
|
||||
%DebugPrint(arr)
|
||||
```
|
||||
|
||||
输出结果为:
|
||||
|
||||
```test
|
||||
DebugPrint: 0x120d000ca0b9: [JSArray]
|
||||
- map: 0x120d00283a71 <Map(PACKED_SMI_ELEMENTS)> [FastProperties]
|
||||
```
|
||||
|
||||
也就是说,`arr = []` 创建的数组的内部类型为 `PACKED_SMI_ELEMENTS`,符合预期。
|
||||
|
||||
### 验证不可逆转换
|
||||
|
||||
不看源码的话,姑且相信原文说的类型转换不可逆,那么我们做一个测试:
|
||||
|
||||
```js
|
||||
const arr = [1, 2, 3]
|
||||
arr.push(4.1)
|
||||
|
||||
console.log(arr);
|
||||
%DebugPrint(arr)
|
||||
|
||||
arr.pop()
|
||||
|
||||
console.log(arr);
|
||||
%DebugPrint(arr)
|
||||
```
|
||||
|
||||
打印核心结果为:
|
||||
|
||||
```text
|
||||
1,2,3,4.1
|
||||
DebugPrint: 0xf91000ca195: [JSArray]
|
||||
- map: 0x0f9100283b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
|
||||
|
||||
1,2,3
|
||||
DebugPrint: 0xf91000ca195: [JSArray]
|
||||
- map: 0x0f9100283b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
|
||||
```
|
||||
|
||||
可以看到,即便 `pop` 后将原数组回退到完全整型的情况,DOUBLE 也不会优化为 SMI。
|
||||
|
||||
再看下长度的测试:
|
||||
|
||||
```js
|
||||
const arr = [1, 2, 3]
|
||||
arr[4] = 4
|
||||
|
||||
console.log(arr);
|
||||
%DebugPrint(arr)
|
||||
|
||||
arr.pop()
|
||||
arr.pop()
|
||||
|
||||
console.log(arr);
|
||||
%DebugPrint(arr)
|
||||
```
|
||||
|
||||
打印核心结果为:
|
||||
|
||||
```text
|
||||
1,2,3,,4
|
||||
DebugPrint: 0x338b000ca175: [JSArray]
|
||||
- map: 0x338b00283ae9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]
|
||||
|
||||
1,2,3
|
||||
DebugPrint: 0x338b000ca175: [JSArray]
|
||||
- map: 0x338b00283ae9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]
|
||||
```
|
||||
|
||||
也证明了 PACKED 到 HOLEY 的不可逆。
|
||||
|
||||
### 字典模式
|
||||
|
||||
数组还有一种内部实现是 Dictionary Elements,它用 HashTable 作为底层结构模拟数组的操作。
|
||||
|
||||
这种模式用于数组长度非常大的时候,不需要连续开辟内存空间,而是用一个个零散的内存空间通过一个 HashTable 寻址来处理数据的存储,这种模式在数据量大时节省了存储空间,但带来了额外的查询开销。
|
||||
|
||||
当对数组的赋值远大于当前数组大小时,V8 会考虑将数组转化为 Dictionary Elements 存储以节省存储空间。
|
||||
|
||||
做一个测试:
|
||||
|
||||
```js
|
||||
const arr = [1, 2, 3];
|
||||
%DebugPrint(arr);
|
||||
|
||||
arr[3000] = 4;
|
||||
%DebugPrint(arr);
|
||||
```
|
||||
|
||||
主要输出结果为:
|
||||
|
||||
```text
|
||||
DebugPrint: 0x209d000ca115: [JSArray]
|
||||
- map: 0x209d00283a71 <Map(PACKED_SMI_ELEMENTS)> [FastProperties]
|
||||
|
||||
DebugPrint: 0x209d000ca115: [JSArray]
|
||||
- map: 0x209d00287d29 <Map(DICTIONARY_ELEMENTS)> [FastProperties]
|
||||
```
|
||||
|
||||
可以看到,占用了太多空间会导致数组的内部实现切换为 DICTIONARY_ELEMENTS 模式。
|
||||
|
||||
实际上这两种模式是根据固定规则相互转化的,具体查了下 V8 源码:
|
||||
|
||||
字典模式在 V8 代码里叫 SlowElements,反之则叫 FastElements,所以要看转化规则,主要就看两个函数:`ShouldConvertToSlowElements` 和 `ShouldConvertToFastElements`。
|
||||
|
||||
下面是 `ShouldConvertToSlowElements` 代码,即什么时候转化为字典模式:
|
||||
|
||||
```c++
|
||||
static inline bool ShouldConvertToSlowElements(
|
||||
uint32_t used_elements,
|
||||
uint32_t new_capacity
|
||||
) {
|
||||
uint32_t size_threshold = NumberDictionary::kPreferFastElementsSizeFactor *
|
||||
NumberDictionary::ComputeCapacity(used_elements) *
|
||||
NumberDictionary::kEntrySize;
|
||||
return size_threshold <= new_capacity;
|
||||
}
|
||||
|
||||
static inline bool ShouldConvertToSlowElements(
|
||||
JSObject object,
|
||||
uint32_t capacity,
|
||||
uint32_t index,
|
||||
uint32_t* new_capacity
|
||||
) {
|
||||
STATIC_ASSERT(JSObject::kMaxUncheckedOldFastElementsLength <=
|
||||
JSObject::kMaxUncheckedFastElementsLength);
|
||||
if (index < capacity) {
|
||||
*new_capacity = capacity;
|
||||
return false;
|
||||
}
|
||||
if (index - capacity >= JSObject::kMaxGap) return true;
|
||||
*new_capacity = JSObject::NewElementsCapacity(index + 1);
|
||||
DCHECK_LT(index, *new_capacity);
|
||||
if (*new_capacity <= JSObject::kMaxUncheckedOldFastElementsLength ||
|
||||
(*new_capacity <= JSObject::kMaxUncheckedFastElementsLength &&
|
||||
ObjectInYoungGeneration(object))) {
|
||||
return false;
|
||||
}
|
||||
return ShouldConvertToSlowElements(object.GetFastElementsUsage(),
|
||||
*new_capacity);
|
||||
}
|
||||
```
|
||||
|
||||
`ShouldConvertToSlowElements` 函数被重载了两次,所以有两个判断逻辑。第一处 `new_capacity > size_threshold` 则变成字典模式,new_capacity 表示新尺寸,而 size_threshold 是根据 3 * 已有尺寸 * 2 计算出来的。
|
||||
|
||||
第二处 `index - capacity >= JSObject::kMaxGap` 时变成字典模式,其中 kMaxGap 是常量 1024,也就是新加入的 HOLEY(孔洞) 大于 1024,则转化为字典模式。
|
||||
|
||||
而由字典模式转化为普通模式的函数是 `ShouldConvertToFastElements`:
|
||||
|
||||
```c++
|
||||
static bool ShouldConvertToFastElements(
|
||||
JSObject object,
|
||||
NumberDictionary dictionary,
|
||||
uint32_t index,
|
||||
uint32_t* new_capacity
|
||||
) {
|
||||
// If properties with non-standard attributes or accessors were added, we
|
||||
// cannot go back to fast elements.
|
||||
if (dictionary.requires_slow_elements()) return false;
|
||||
|
||||
// Adding a property with this index will require slow elements.
|
||||
if (index >= static_cast<uint32_t>(Smi::kMaxValue)) return false;
|
||||
|
||||
if (object.IsJSArray()) {
|
||||
Object length = JSArray::cast(object).length();
|
||||
if (!length.IsSmi()) return false;
|
||||
*new_capacity = static_cast<uint32_t>(Smi::ToInt(length));
|
||||
} else if (object.IsJSArgumentsObject()) {
|
||||
return false;
|
||||
} else {
|
||||
*new_capacity = dictionary.max_number_key() + 1;
|
||||
}
|
||||
*new_capacity = std::max(index + 1, *new_capacity);
|
||||
|
||||
uint32_t dictionary_size = static_cast<uint32_t>(dictionary.Capacity()) *
|
||||
NumberDictionary::kEntrySize;
|
||||
|
||||
// Turn fast if the dictionary only saves 50% space.
|
||||
return 2 * dictionary_size >= *new_capacity;
|
||||
}
|
||||
```
|
||||
|
||||
重点是最后一行 `return 2 * dictionary_size >= *new_capacity` 表示字典模式仅节省了 50% 空间时,不如切换为普通模式(fast mode)。
|
||||
|
||||
具体就不测试了,感兴趣同学可以用上面介绍的方法使用 v8-debug 测试一下。
|
||||
|
||||
## 总结
|
||||
|
||||
JS 数组使用方法非常灵活,但 V8 使用 C++ 实现时,必须转化为更底层的类型,所以为了兼顾性能,就做了快慢模式,而快模式又分了 SMI、DOUBLE;PACKED、HOLEY 模式分别处理来尽可能提升速度。
|
||||
|
||||
也就是说,我们在随意创建数组的时候,V8 会分析数组的元素构成与长度变化,自动分发到各种不同的子模式处理,以最大化提升性能。
|
||||
|
||||
这种模式使 JS 开发者获得了更好的开发者体验,而实际上执行性能也和 C++ 原生优化相差无几,所以从这个角度来看,JS 是一种更高封装层次的语言,极大降低了开发者学习门槛。
|
||||
|
||||
当然 JS 还提供了一些相对原生的语法比如 ArrayBuffer,或者 WASM 让开发者直接操作更底层的特性,这可以使性能控制更精确,但带来了更大的学习和维护成本,需要开发者根据实际情况权衡。
|
||||
|
||||
> 讨论地址是:[精读《JS 数组的内部实现》· Issue #414 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/414)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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 @@
|
||||
useEvent 要解决一个问题:如何同时保持函数引用不变与访问到最新状态。
|
||||
|
||||
本周我们结合 [RFC](https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md) 原文与解读文章 [What the useEvent React hook is (and isn't)](https://typeofnan.dev/what-the-useevent-react-hook-is-and-isnt/) 一起了解下这个提案。
|
||||
|
||||
借用提案里的代码,一下就能说清楚 `useEvent` 是个什么东西:
|
||||
|
||||
```ts
|
||||
function Chat() {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
// ✅ Always the same function (even if `text` changes)
|
||||
const onClick = useEvent(() => {
|
||||
sendMessage(text);
|
||||
});
|
||||
|
||||
return <SendButton onClick={onClick} />;
|
||||
}
|
||||
```
|
||||
|
||||
`onClick` 既保持引用不变,又能在每次触发时访问到最新的 `text` 值。
|
||||
|
||||
为什么要提供这个函数,它解决了什么问题,在概述里慢慢道来。
|
||||
|
||||
## 概述
|
||||
|
||||
定义一个访问到最新 state 的函数不是什么难事:
|
||||
|
||||
```ts
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
const sayCount = () => {
|
||||
console.log(count)
|
||||
}
|
||||
|
||||
return <Child onClick={sayCount} />
|
||||
}
|
||||
```
|
||||
|
||||
但 `sayCount` 函数引用每次都会变化,这会直接破坏 `Child` 组件 memo 效果,甚至会引发其更严重的连锁反应(`Child` 组件将 `onClick` 回调用在 `useEffect` 里时)。
|
||||
|
||||
想要保证 `sayCount` 引用不变,我们就需要用 `useCallback` 包裹:
|
||||
|
||||
```ts
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
const sayCount = useCallback(() => {
|
||||
console.log(count)
|
||||
}, [count])
|
||||
|
||||
return <Child onClick={sayCount} />
|
||||
}
|
||||
```
|
||||
|
||||
但即便如此,我们仅能保证在 `count` 不变时,`sayCount` 引用不变。如果想保持 `sayCount` 引用稳定,就要把依赖 `[count]` 移除,这会导致访问到的 `count` 总是初始值,逻辑上引发了更大问题。
|
||||
|
||||
一种无奈的办法是,维护一个 countRef,使其值与 count 保持同步,在 `sayCount` 中访问 `countRef`:
|
||||
|
||||
```ts
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const countRef = React.useRef()
|
||||
countRef.current = count
|
||||
|
||||
const sayCount = useCallback(() => {
|
||||
console.log(countRef.current)
|
||||
}, [])
|
||||
|
||||
return <Child onClick={sayCount} />
|
||||
}
|
||||
```
|
||||
|
||||
这种代码能解决问题,但绝对不推荐,原因有二:
|
||||
|
||||
1. 每个值都要加一个配套 Ref,非常冗余。
|
||||
2. 在函数内直接同步更新 ref 不是一个好主意,但写在 `useEffect` 里又太麻烦。
|
||||
|
||||
另一种办法就是自创 hook,如 `useStableCallback`,这本质上就是这次提案的主角 - `useEvent`:
|
||||
|
||||
```ts
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
const sayCount = useEvent(() => {
|
||||
console.log(count)
|
||||
})
|
||||
|
||||
return <Child onClick={sayCount} />
|
||||
}
|
||||
```
|
||||
|
||||
所以 `useEvent` 的内部实现很可能类似于自定义 hook `useStableCallback`。在提案内也给出了可能的实现思路:
|
||||
|
||||
```ts
|
||||
// (!) Approximate behavior
|
||||
function useEvent(handler) {
|
||||
const handlerRef = useRef(null);
|
||||
|
||||
// In a real implementation, this would run before layout effects
|
||||
useLayoutEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
});
|
||||
|
||||
return useCallback((...args) => {
|
||||
// In a real implementation, this would throw if called during render
|
||||
const fn = handlerRef.current;
|
||||
return fn(...args);
|
||||
}, []);
|
||||
}
|
||||
```
|
||||
|
||||
其实很好理解,我们将需求一分为二看:
|
||||
|
||||
1. 既然要返回一个稳定引用,那最后返回的函数一定使用 `useCallback` 并将依赖数组置为 `[]`。
|
||||
2. 又要在函数执行时访问到最新值,那么每次都要拿最新函数来执行,所以在 Hook 里使用 Ref 存储每次接收到的最新函数引用,在执行函数时,实际上执行的是最新的函数引用。
|
||||
|
||||
注意两段注释,第一个是 `useLayoutEffect` 部分实际上要比 `layoutEffect` 执行时机更提前,这是为了保证函数在一个事件循环中被直接消费时,可能访问到旧的 Ref 值;第二个是在渲染时被调用时要抛出异常,这是为了避免 `useEvent` 函数被渲染时使用,因为这样就无法数据驱动了。
|
||||
|
||||
## 精读
|
||||
|
||||
其实 `useEvent` 概念和实现都很简单,下面我们聊聊提案里一些有意思的细节吧。
|
||||
|
||||
### 为什么命名为 useEvent
|
||||
|
||||
提案里提到,如果不考虑名称长短,完全用功能来命名的话,`useStableCallback` 或 `useCommittedCallback` 会更加合适,都表示拿到一个稳定的回调函数。但 `useEvent` 是从使用者角度来命名的,即其生成的函数一般都被用于组件的回调函数,而这些回调函数一般都有 “事件特性”,比如 `onClick`、`onScroll`,所以当开发者看到 `useEvent` 时,可以下意识提醒自己在写一个事件回调,还算比较直观。(当然我觉得主要原因还是为了缩短名称,好记)
|
||||
|
||||
### 值并不是真正意义上的实时
|
||||
|
||||
虽然 `useEvent` 可以拿到最新值,但和 `useCallback` 拿 `ref` 还是有区别的,这个差异体现在:
|
||||
|
||||
```ts
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
const sayCount = useEvent(async () => {
|
||||
console.log(count)
|
||||
await wait(1000)
|
||||
console.log(count)
|
||||
})
|
||||
|
||||
return <Child onClick={sayCount} />
|
||||
}
|
||||
```
|
||||
|
||||
`await` 前后输出值一定是一样的,在实现上,`count` 值仅是调用时的快照,所以函数内异步等待时,即便外部又把 `count` 改了,当前这次函数调用还是拿不到最新的 `count`,而 `ref` 方法是可以的。在理解上,为了避免夜长梦多,回调函数尽量不要写成异步的。
|
||||
|
||||
### useEvent 也救不了手残
|
||||
|
||||
如果你坚持写出 `onSomething={cond ? handler1 : handler2}` 这样的代码,那么 `cond` 变化后,传下去的函数引用也一定会变化,这是 `useEvent` 无论如何也避免不了的,也许解救方案是 Lint and throw error。
|
||||
|
||||
其实将 `cond ? handler1 : handler2` 作为一个整体包裹在 `useEvent` 就能解决引用变化的问题,但除了 Lint,没有人能防止你绕过它。
|
||||
|
||||
### 可以用自定义 hook 代替 useEvent 实现吗?
|
||||
|
||||
不能。虽然提案里给了一个近似解决方案,但实际上存在两个问题:
|
||||
|
||||
1. 在赋值 ref 时,`useLayoutEffect` 时机依然不够提前,如果值变化后理解访问函数,拿到的会是旧值。
|
||||
2. 生成的函数被用在渲染并不会给出错误提示。
|
||||
|
||||
## 总结
|
||||
|
||||
`useEvent` 显然又给 React 增加了一个官方概念,在结结实实增加了理解成本的同时,也补齐了 React Hooks 在实践中缺失的重要一环,无论你喜不喜欢,问题就在那,解法也给了,挺好。
|
||||
|
||||
> 讨论地址是:[精读《React useEvent RFC》· Issue #415 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/415)
|
||||
|
||||
**如果你想参与讨论,请 [点击这里](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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user