Compare commits

..
9 Commits
Author SHA1 Message Date
ascoders 00fd6e541a 144 2020-03-23 09:37:11 +08:00
ascoders 6cc1af4ca9 143 2020-03-23 09:36:17 +08:00
ascoders ed27df0fe2 update 2020-03-16 09:02:46 +08:00
ascoders 14ebdf19ca 142 2020-03-09 09:13:28 +08:00
ascoders e89a1b0c7e Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-03-02 09:16:52 +08:00
ascoders a1bd0ec7a5 141 2020-03-02 09:16:38 +08:00
黄子毅 82d3665606 Merge pull request #235 from changyuqing/patch-1
Update 140.精读《结合 React 使用原生 Drag Drop API》.md
2020-02-26 18:36:51 +08:00
changyuqing 67a289e750 Update 140.精读《结合 React 使用原生 Drag Drop API》.md 2020-02-26 15:16:28 +08:00
ascoders 1b0a7ed8ea finish 140 2020-02-24 09:08:57 +08:00
6 changed files with 873 additions and 1 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ function fakeParseJSON(str) {
}
```
其中 `skipWhitespace` 表示匹配并跳过空格,所谓匹配意味着匹配成功,此时 `i` 下标可以继续后移,否则匹配失败。下一步则判断如果 `i` 不是结束标志 `}`,则按照 `parseString` 匹配字符串 → `skipWhitespace` 跳过空格 → `eatColon` 吃掉号 → `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) 系列文章了解更多。
@@ -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 的功能:
![](https://img.alicdn.com/tfs/TB12.npyoz1gK0jSZLeXXb9kVXa-1024-808.gif)
从上图可以看出,子元素在异步取数时会阻塞父组件渲染,并一直冒泡到最外层第一个 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 null200 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_twp: "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)