Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aedcc2fd56 | ||
|
|
757eb403ac | ||
|
|
87c2745395 | ||
|
|
0d3d8ef54c | ||
|
|
53d5ee1960 | ||
|
|
92a8dfc55c | ||
|
|
c59f46cc1d | ||
|
|
ef3904a2f6 | ||
|
|
994c5844d9 | ||
|
|
3881e51b08 | ||
|
|
815ae1367a | ||
|
|
84ed47dbdf | ||
|
|
252980724a | ||
|
|
225aab476d | ||
|
|
f065539266 |
@@ -112,7 +112,7 @@ const Child = (props) => {
|
||||
|
||||
> componentDidCatch and getDerivedStateFromError: There are no Hook equivalents for these methods yet, but they will be added soon.
|
||||
|
||||
所以这里的理解要注意一下,另外 React 官方文档 [Hooks FQA](https://reactjs.org/docs/hooks-faq.html#how-do-lifecycle-methods-correspond-to-hooks) 有很多宝藏,建议抽时间逐条阅读。
|
||||
所以这里的理解要注意一下,另外 React 官方文档 [Hooks FAQ](https://reactjs.org/docs/hooks-faq.html#how-do-lifecycle-methods-correspond-to-hooks) 有很多宝藏,建议抽时间逐条阅读。
|
||||
|
||||
## 4 总结
|
||||
|
||||
|
||||
@@ -0,0 +1,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))
|
||||
@@ -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