Compare commits

...
131 Commits
136 ... v2
Author SHA1 Message Date
ascoders 1b03871be8 fix lint 2021-04-16 17:25:53 +08:00
ascoders 42e068aab7 update readme 2021-04-16 17:17:52 +08:00
ascoders 280efe4545 191 2021-04-12 09:51:09 +08:00
ascoders 9b54c138a7 fix bug 2021-04-07 14:01:02 +08:00
ascoders c253b7d60f rename 190 2021-04-06 09:47:32 +08:00
ascoders 367bba2692 189 2021-04-06 09:46:50 +08:00
ascoders e888c4e22c 189 2021-03-29 10:33:40 +08:00
ascoders 8a3425b5e3 188 2021-03-22 07:52:11 +08:00
ascoders 9081bde65e 187 2021-03-15 08:43:49 +08:00
ascoders 4f783678da 186 2021-03-08 09:36:35 +08:00
ascoders 299a60a881 185 2021-03-01 08:59:43 +08:00
ascoders 7e651219b0 184 2021-02-22 10:11:00 +08:00
ascoders e1e7bc6f57 183 2021-02-01 09:05:50 +08:00
ascoders beed8434ec 182 2021-01-25 10:32:08 +08:00
ascoders ab13166c0a Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2021-01-20 15:12:50 +08:00
ascoders 71a48cde3b fix type 2021-01-20 15:12:30 +08:00
黄子毅 c603ac0d1d Merge pull request #297 from sosamuel/patch-1
Fix 校正笔误
2021-01-19 21:10:59 +08:00
Samuel Chia 316d3d98aa Fix 校正笔误 2021-01-18 10:43:42 +08:00
ascoders 0465cf43db 181 2021-01-18 10:14:59 +08:00
ascoders 65de19ddb6 181 2021-01-18 10:08:02 +08:00
ascoders 9c0f11cc55 181 2021-01-18 10:07:20 +08:00
ascoders 754ceb3d5a fix: typo 2021-01-10 11:28:15 +08:00
ascoders dcc5247ba0 fix typo 2021-01-10 11:23:26 +08:00
ascoders 02de198a8b 180 2021-01-10 11:21:15 +08:00
ascoders b7cfc4c6e3 179 2020-12-26 10:50:01 +08:00
ascoders 46a094e711 178 2020-12-20 23:22:19 +08:00
ascoders b5cd1b2379 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-12-13 21:45:29 +08:00
ascoders cfe94e6aa7 177 2020-12-13 21:44:46 +08:00
黄子毅 3362f25775 Merge pull request #289 from wiolem/patch-1
docs: typo onDropOver
2020-12-09 17:59:49 +08:00
William 87f9704831 Update 140.精读《结合 React 使用原生 Drag Drop API》.md 2020-12-09 10:40:49 +08:00
ascoders 6c7084dfe1 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-12-06 21:50:03 +08:00
ascoders 164590159d 176 2020-12-06 21:49:38 +08:00
黄子毅 b8ab46d26b Merge pull request #287 from spiritree/patch-3
Update 168.精读《设计模式 - Builder 生成器》.md
2020-12-03 20:19:48 +08:00
深樹 183b4fc41a Update 168.精读《设计模式 - Builder 生成器》.md
maybe misspell `Persion`->`Person`
2020-12-01 15:28:53 +08:00
ascoders 2b7ab34978 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-11-29 18:04:36 +08:00
ascoders 2ca7132569 175 2020-11-29 18:04:27 +08:00
黄子毅 aac9aa34f8 Merge pull request #283 from JeromeLin/patch-3
Update 003.精读前后端渲染之争.md
2020-11-29 11:29:25 +08:00
黄子毅 ad8a645462 Merge pull request #285 from spiritree/patch-1
Update 171.精读《设计模式 - Singleton 单例模式》.md
2020-11-26 21:46:06 +08:00
深樹 2dbe4d6368 Update 171.精读《设计模式 - Singleton 单例模式》.md
class method: `instance` -> `getInstance`
2020-11-25 19:14:31 +08:00
ascoders d76253ef75 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-11-22 11:10:11 +08:00
ascoders 46b68f1933 174 2020-11-22 11:09:40 +08:00
JeromeLin 8aab27bc3f Update 003.精读前后端渲染之争.md 2020-11-20 09:51:01 +08:00
黄子毅 22f118e1b7 Merge pull request #282 from JeromeLin/patch-2
Update 001.精读 js 模块化发展.md
2020-11-19 21:21:20 +08:00
JeromeLin 922eb6dc59 Update 001.精读 js 模块化发展.md 2020-11-19 17:37:21 +08:00
黄子毅 20526273d3 Merge pull request #281 from giscafer/patch-1
fix: typo
2020-11-18 10:35:33 +08:00
Nickbing Lao f3222c646b fix: typo
错别字
2020-11-16 16:09:23 +08:00
ascoders 9ae28c5e85 set img size 2020-11-15 16:45:15 +08:00
ascoders e72318adb4 173 2020-11-15 16:37:09 +08:00
ascoders 2682dc0504 update 2020-11-08 20:16:33 +08:00
ascoders 8b4611b47a 172 2020-11-08 20:12:56 +08:00
ascoders 25938c60a6 171 2020-11-01 21:41:08 +08:00
ascoders 9920f0dbb7 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-10-24 20:50:18 +08:00
ascoders 90f7d89db7 170 2020-10-24 20:50:04 +08:00
黄子毅 6ddbdbbbb2 Merge pull request #276 from NaturelLee/patch-1
Correct Hooks implimentation
2020-10-20 15:44:25 +08:00
NaturelLee 3c8c9fd5ef Correct Hooks implimentation
Correct Hooks implimentation from Array to single linked list
2020-10-20 15:35:08 +08:00
ascoders 91a9bf82fd fix bug 2020-10-19 10:22:17 +08:00
ascoders 4603ac77d8 169 2020-10-18 11:25:55 +08:00
ascoders 998940af1b 168 2020-10-10 17:40:42 +08:00
ascoders 24ed724eab fix: 放大图片 2020-10-07 19:32:11 +08:00
ascoders 33b71628ab update title 2020-09-24 20:15:28 +08:00
ascoders 2dbf1429ba 167 2020-09-21 09:45:35 +08:00
ascoders 9867c1ee9d 166 2020-09-14 09:52:10 +08:00
ascoders e8a0539984 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-09-07 11:28:18 +08:00
ascoders 685e8ba32a 165 2020-09-07 11:27:07 +08:00
黄子毅 6c9493df7b Merge pull request #268 from spiritree/patch-1
Update 163.精读《Spring 概念》.md
2020-09-02 09:41:32 +08:00
深樹 1994438792 Update 163.精读《Spring 概念》.md
`lass` -> `class`
2020-09-01 16:04:21 +08:00
ascoders 60be3e354b fix 2020-08-31 10:25:00 +08:00
ascoders 9d0bb5c996 163 2020-08-31 10:22:21 +08:00
ascoders 304dc5fc36 163 2020-08-24 09:35:45 +08:00
ascoders 819b6d7452 162 2020-08-17 10:24:38 +08:00
ascoders b066c8a5d7 161 2020-08-10 10:11:03 +08:00
ascoders 80fb60c6fb 160 2020-07-27 09:37:27 +08:00
ascoders 6a2031899d 159 2020-07-20 09:55:34 +08:00
ascoders aedcc2fd56 158 2020-07-13 10:26:30 +08:00
ascoders 757eb403ac update 2020-07-06 09:50:38 +08:00
ascoders 87c2745395 156 2020-06-22 09:48:19 +08:00
ascoders 0d3d8ef54c fix bug 2020-06-15 22:56:52 +08:00
ascoders 53d5ee1960 155 2020-06-15 09:42:30 +08:00
ascoders 92a8dfc55c Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-06-08 09:53:32 +08:00
ascoders c59f46cc1d 154 2020-06-08 09:53:17 +08:00
黄子毅 ef3904a2f6 Merge pull request #253 from justjavac/patch-1
fix: snowpack issue 链接
2020-06-01 23:05:43 +08:00
迷渡 994c5844d9 fix: snowpack issue 链接 2020-06-01 21:02:00 +08:00
ascoders 3881e51b08 153 2020-06-01 09:49:07 +08:00
ascoders 815ae1367a fix typo 2020-05-25 18:20:56 +08:00
ascoders 84ed47dbdf Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-05-25 09:29:02 +08:00
ascoders 252980724a 152 2020-05-25 09:28:53 +08:00
黄子毅 225aab476d Merge pull request #250 from spiritree/patch-1
Update 148. 精读《React Error Boundaries》.md
2020-05-22 18:29:16 +08:00
深樹 f065539266 Update 148. 精读《React Error Boundaries》.md
FQA -> FAQ
2020-05-22 16:55:03 +08:00
ascoders f8d81fc3f5 151 2020-05-18 08:40:17 +08:00
ascoders 43e264cc07 update 2020-05-11 13:48:07 +08:00
ascoders 957737959f 150 2020-05-11 09:39:14 +08:00
ascoders 000e6511e6 149 2020-04-27 13:01:48 +08:00
ascoders a652284bbc 148 2020-04-20 09:31:08 +08:00
ascoders 1b88e5ac06 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-04-13 09:35:39 +08:00
ascoders 79ba9ec6a1 147 2020-04-13 09:35:36 +08:00
黄子毅 a4acf09d8b Merge pull request #244 from lz-lee/patch-1
Update 145.精读《React Router v6》.md
2020-04-10 14:57:48 +08:00
lz-lee 872ef2e346 Update 145.精读《React Router v6》.md 2020-04-09 10:44:37 +08:00
黄子毅 8795ae0aaa Merge pull request #243 from Kerminate/v2
fix: 修复引用传参
2020-04-07 11:22:51 +08:00
Kerminate 23c4190d7e fix: 修复引用传参 2020-04-07 10:25:59 +08:00
Kerminate 75d0109102 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-04-07 10:23:27 +08:00
Kerminate 893bb2d97a Merge branch 'master' of https://github.com/dt-fe/weekly into v2 2020-04-07 10:22:44 +08:00
ascoders 0c2c8c6c03 146 2020-04-07 09:32:13 +08:00
ascoders 97a313ca1e 145 2020-03-30 09:30:17 +08:00
黄子毅 3fd57e26b8 Merge pull request #240 from LiuL0703/patch-6
fix:typo
2020-03-24 11:08:49 +08:00
Linear-Enter aee6f6d6bb fix:typo 2020-03-23 10:03:02 +08:00
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
ascoders 21ba3e0640 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-02-17 09:22:38 +08:00
ascoders d5159b914e 139 2020-02-17 09:20:48 +08:00
黄子毅 c45a3d09d5 Merge pull request #232 from vivaxy/patch-1
Update link to 《mysql 上下文无关文法集合》
2020-02-12 12:12:36 +08:00
vivaxy c3ea6f2b17 Update link to 《mysql 上下文无关文法集合》 2020-02-12 10:02:35 +08:00
ascoders fb98d1febc fix typo 2020-02-11 13:07:25 +08:00
ascoders 036a9779eb Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-02-10 09:29:23 +08:00
ascoders beb89cf31b 138 2020-02-10 09:28:49 +08:00
黄子毅 5d65a777bd Merge pull request #231 from ihavecoke/patch-1
Update 002.精读模态框的最佳实践.md
2020-02-06 09:50:40 +08:00
coke 115d108404 Update 002.精读模态框的最佳实践.md
Model => Modal
2020-02-05 19:16:32 +08:00
黄子毅 f573e16473 Merge pull request #230 from Jack-Lo/v2
笔误修正
2020-01-22 11:15:12 +08:00
jack-Lo ac53e3a325 笔误修正 2020-01-21 20:34:57 +08:00
ascoders d3dad3b151 Merge branch 'v2' of https://github.com/dt-fe/weekly into v2 2020-01-13 09:18:33 +08:00
ascoders 7843682b1e 137 2020-01-13 09:18:24 +08:00
黄子毅 11c34a9edc Merge pull request #227 from Kerminate/v2
fix: 修复用词准确性
2020-01-06 22:21:21 +08:00
Kerminate b091d40925 fix: 修复用词准确性 2020-01-06 09:52:27 +08:00
黄子毅 d8f2e0977d Merge pull request #163 from vivaxy/patch-1
Update 64.精读《手写 SQL 编译器 - 词法分析》.md
2019-06-07 22:05:26 +08:00
vivaxy f4e75f5e21 Update 64.精读《手写 SQL 编译器 - 词法分析》.md
Fix syntax error
2019-06-07 14:30:27 +08:00
65 changed files with 11933 additions and 26 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ YUI3 的 sandbox 像极了差不多同时出现的 AMD 规范,但早期 yahoo
> 看到大家基本都提到了 HTTP/2,对这项技术解决前端模块化及资源打包等工程问题抱有非常大的期待。很多人也认为 HTTP/2 普及后,基本就没有 Webpack 什么事情了。
不过 Webpack 作者 @sokra 在他的文章 [webpack & HTTP/2](https://medium.com/webpack/webpack-http-2-7083ec3f3ce6#.zdo4juvgo) 里提到了一个新的 Webpack 插件 `AggressiveSplittingPlugin`。简单的说,这款插件就是为了充分利用 HTTP/2 的文件缓存能力,将你的业务代码自动拆分成若干个数十 KB 的小文件。后续若其中任意一个文件发生变化,可以保证其他的小 chunck 不需要重新下载。
不过 Webpack 作者 @sokra 在他的文章 [webpack & HTTP/2](https://medium.com/webpack/webpack-http-2-7083ec3f3ce6#.zdo4juvgo) 里提到了一个新的 Webpack 插件 `AggressiveSplittingPlugin`。简单的说,这款插件就是为了充分利用 HTTP/2 的文件缓存能力,将你的业务代码自动拆分成若干个数十 KB 的小文件。后续若其中任意一个文件发生变化,可以保证其他的小 chunk 不需要重新下载。
可见,**即使不断的有新技术出现,也依然需要配套的工具来将前端工程问题解决方案推向极致。**
+1 -1
View File
@@ -44,7 +44,7 @@
### 模态框定位
首先,Model 与 Toast、Notification、Message 以及 Popover 都会在某个时间点被触发弹出一个浮层,但与 Modal(模态框)还是有所不同的。定义上看,上述组件都不属于模态框,因为模态框有一个重要的特性,即阻塞原来主视窗下的操作,只能在框内作后续动作。也就是说模态框从界面上彻底打断了用户心流。
首先,Modal 与 Toast、Notification、Message 以及 Popover 都会在某个时间点被触发弹出一个浮层,但与 Modal(模态框)还是有所不同的。定义上看,上述组件都不属于模态框,因为模态框有一个重要的特性,即阻塞原来主视窗下的操作,只能在框内作后续动作。也就是说模态框从界面上彻底打断了用户心流。
当然,这也是我们需要讨论的问题,如果只是一般的消息提醒,可以用信息条、小红点等交互形式,至少是不阻塞用户操作的。在原文末引用的 10 Guidelines to Consider when using Overlays 一文中,第 8 条强调了模态框不到万不得以不应该使用。这时我们应该思考什么情况下你非常希望他不要离开页面,来读框内的信息或作操作呢?
+2 -2
View File
@@ -27,7 +27,7 @@
- 服务端渲染不需要先下载一堆 js 和 css 后才能看到页面(首屏性能)
- SEO
- 服务端渲染不用关心浏览器兼容性问题(随浏览器发展,这个优点逐渐消失)
- 服务端渲染不用关心浏览器兼容性问题(随浏览器发展,这个优点逐渐消失)
- 对于电量不给力的手机或平板,减少在客户端的电量消耗很重要
以上服务端优势其实只有首屏性能和 SEO 两点比较突出。但现在这两点也慢慢变得微不足道了。React 这类支持同构的框架已经能解决这个问题,尤其是 Next.js 让同构开发变得非常容易。还有静态站点的渲染,但这类应用本身复杂度低,很多前端框架已经能完全囊括。
@@ -142,4 +142,4 @@ Next.js 是时下非常流行的基于 React 的同构开发框架。作者之
> 讨论地址是:[前后端渲染之争 · Issue #5 · dt-fe/weekly](http://link.zhihu.com/?target=https%3A//github.com/dt-fe/weekly/issues/5)
> 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。
> 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。
@@ -80,7 +80,7 @@ SELECT * from bees WHERE bee = 'red';
但是当我们将文法粒度变细,将 `CASE WHEN``WHERE` 区块分别交由两块文法解决,将等号这个通用的表达式抽离出来,就可以不关心上下文了,这种方式称为 **上下文无关文法**
附上一个 [mysql 上下文无关文法集合](https://github.com/antlr/grammars-v4/blob/master/mysql/MySqlParser.g4)。
附上一个 [mysql 上下文无关文法集合](https://github.com/antlr/grammars-v4/blob/master/sql/mysql/Positive-Technologies/MySqlParser.g4)。
### 左推导与右推导
+1 -1
View File
@@ -276,7 +276,7 @@ Hook 函数必须以 "use" 命名开头,因为这样才方便 eslint 做检查
为什么不能用 condition 包裹 useHook 语句,详情可以见 [官方文档](https://reactjs.org/docs/hooks-rules.html#explanation),这里简单介绍一下。
React Hooks 并不是通过 Proxy 或者 getters 实现的(具体可以看这篇文章 [React hooks: not magic, just arrays](https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e)),而是通过数组实现的,每次 `useState` 都会改变下标,如果 `useState` 被包裹在 condition 中,那每次执行的下标就可能对不上,导致 `useState` 导出的 `setter` 更新错数据。
React Hooks 并不是通过 Proxy 或者 getters 实现的(具体可以看这篇文章 [React hooks: not magic, just arrays](https://medium.com/@ryardley/react-hooks-not-magic-just-arrays-cd4f1857236e)),而是通过链表实现的,每次 `useState` 都会改变下标,如果 `useState` 被包裹在 condition 中,那每次执行的下标就可能对不上,导致 `useState` 导出的 `setter` 更新错数据。
虽然有 [eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) 插件保驾护航,但这第一次将 “约定优先” 理念引入了 React 框架中,带来了前所未有的**代码命名和顺序限制**(函数命名遭到官方限制,JS 自由主义者也许会暴跳如雷),但带来的便利也是前所未有的(没有比 React Hooks 更好的状态共享方案了,约定带来提效,自由的代价就是回到 renderProps or HOC,各团队可以自行评估)。
@@ -1,6 +1,6 @@
## 1 引言
上周的 [精读《React Hooks》](https://github.com/dt-fe/weekly/blob/master/79.%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20Hooks%E3%80%8B.md) 已经实现了对 React Hooks 的基本认知,也许你也看了 React Hooks 基本实现剖析(就是数组),但理解实现原理就可以用好了吗?学的是知识,而用的是技能,看别人的用法就像刷抖音一样(哇,饭还可以这样吃?),你总会有新的收获。
上周的 [精读《React Hooks》](https://github.com/dt-fe/weekly/blob/master/79.%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20Hooks%E3%80%8B.md) 已经实现了对 React Hooks 的基本认知,也许你也看了 React Hooks 基本实现剖析(单向链表),但理解实现原理就可以用好了吗?学的是知识,而用的是技能,看别人的用法就像刷抖音一样(哇,饭还可以这样吃?),你总会有新的收获。
这篇文章将这些知识实践起来,看看广大程序劳动人民是如何发掘 React Hooks 的潜力的(造什么轮子)。
+1 -1
View File
@@ -456,7 +456,7 @@ function Article({ id }) {
## useEffect 还有什么优势
`useEffect` 在渲染结束时执行,所以不会阻塞浏览器渲染进程,所以使用 Function Component 写的项目一般都有更好的性能。
`useEffect` 在渲染结束时执行,所以不会阻塞浏览器渲染进程,所以使用 Function Component 写的项目一般都有更好的性能。
自然符合 React Fiber 的理念,因为 Fiber 会根据情况暂停或插队执行不同组件的 Render,如果代码遵循了 Capture Value 的特性,在 Fiber 环境下会保证值的安全访问,同时弱化生命周期也能解决中断执行时带来的问题。
+1 -1
View File
@@ -76,7 +76,7 @@
### 字节跳动
字节跳动的值几乎是百度的两倍了,为什么看似体量更大、资源更多的百度会被字节跳动超越?大家都很感兴趣这个话题。
字节跳动的值几乎是百度的两倍了,为什么看似体量更大、资源更多的百度会被字节跳动超越?大家都很感兴趣这个话题。
字节跳动核心能力是个 **性化推荐引擎**,旗下产品 “社交、自拍、咨询、教育、金融理财、短视频、问答、电商”都利用了技术中台输出的个性化推荐算法作为核心竞争力。
@@ -0,0 +1,77 @@
## 1 引言
很荣幸被评为公司年度十佳作者,被要求写了这篇命题作文。
虽然我写了几年文章,稍稍学会了如何总结,但从来没想过要给自己 “做分享” 这件事做一个总结。这次我决定挑战一下自己,应邀写下这篇文章,谈谈我自己做分享这件事。
我将从 Why、What、How 三个角度去说明做分享这件事,分别阐述为什么做分享,做什么分享,以及如何做分享。
## 2 精读
### Why - 为什么要分享
在构思《前端精读》这个专栏的时候,那时网上的聚合专栏有很多,一般是每周收集一些优秀的技术、思考文章汇聚成一个列表,特别是一些知名度较高的头部专栏,用户阅读量很大,内容又多质量又好。当时我很羡慕这种模式,因为这种模式不用自己写文章,只要收集文章就可以了,而且在用户的监督下,也会促进你多阅读、多思考。
当时还萌生了另一个想法,就是现在《前端精读》的模式,每周找到一篇文章精读,并分享文章和自己对文章的观点。萌生这个想法的原因是,当时看了一些文章,觉得还不过瘾,想着如果把一系列关联知识串起来文章会更有价值,可是我并不能要求原文作者做这件事,因此就决定自己写关于这些文章的精读,将自己融会贯通后的理解展现给读者。但这样做有一些风险,首先就是自己写文章的要求比较高,我不能确定自己是否能坚持下来,其次是一周只写一篇,总感觉接收的信息量不如做聚合模式的大,毕竟别人一周就能看二、三十篇文章,而自己只能看一篇。
让我下决定的原因是看了一篇商业文章,是著名商业顾问刘润的一个观点:商业世界存在点、线、面、体,比如做一家杂志社就是一个点,做互联网信息收集入口就是线,而微博、微信都是面,整个社交行业就是体,每高一个维度都会对下一维度造成降维打击,所以科技行业才演变这么快,实际上是所处维度的不同。但高维也有自己脆弱的一面,就是竞争非常激烈,一个行业体中,通常是容不下太多面的。
同理,对写作来说,聚合专栏就是线,就像淘宝连接买家与卖家一样,聚合专栏收集优秀作者的文章,利用自己的流量分发给读者,但这个领域必然竞争激烈,当读者有了更好的线,为什么还需要差一些的呢?但做点就不一样了,你可以被无数线和想做线的人需要,你产出自己原创的价值,不会受到太大竞争影响。实际上我的经历也是这样,我可以将文章投放到各个平台(各个线上),这些线都成为了放大我影响力的工具,有越多的线,点的价值就越大,毕竟,想做线的人太多了。
在这里稍稍插一句,反过来,如果所有人都做点,只有极少数人做线,那线必定形成垄断,就像品牌商垄断农民货物一样,因为农民无法直达消费者,只能以很低的价格把农产品卖给品牌商,同样,消费者也只能通过品牌商买到货,所以品牌商就可以肆意加价。但互联网的发展改变了这些,无论是社交电商还是直播带货,都让生产者有了直接触达消费者的机会,就不用担心被中间商赚取差价;再者,如果大家觉得中间商有利可图,大量的品牌出现,生产者完全可以同时给多个品牌商供货,而在互联网分享的信息不会因为在一个平台的传播而消失,我们可以说文章与知识传播的边际成本完全为零,所以可以最大化利用多平台给自己带来优势。
所以我决定做一个点,将《前端精读》这个招牌培养起来。
<img width=300 src="https://img.alicdn.com/tfs/TB1pTGFtRr0gK0jSZFnXXbRRXXa-1052-741.png">
### What - 做什么分享
因为我的爱好与职业是前端,所以看上去要做什么分享这件事很简单,只要分享前端技术相关内容就可以了。但这几年持续下来发现,事情远远没有这么简单。
在分享刚一开始的时候,肚子里憋着一堆想说的话,恨不得一天写一篇精读,但奈何精力与表达能力有限,勉强以一周一篇的节奏坚持下来。写作的内容都是自己最熟悉、最想表达的前端技术内容,而且过程中为了活跃团队气氛,还拉上大家一起参与,坚持了蛮长一段时间。然而很快就遇到了第一个问题,坚持力问题。
持续做一件事情总会觉得枯燥,加上业务变得更有前途,大家都越来越忙碌,逐渐出现了下周找不到人写精读的情况,此时我选择顶上空缺。但毕竟那时候精读没有多少人关注,成就感不高,加上没有养成写作习惯,写一篇文章往往要花费一整周的精力,连续写两周就觉得非常痛苦,毕竟把自己的知识与想法写成文章有着不小的成本,对自己非常熟悉的知识感觉写下来有些浪费时间,逐渐觉得枯燥。
在枯燥的过程中,我逐渐培养出更快的写作速度,但一个严重的问题也渐渐浮现出来,我渐渐发现自己的存量知识已经见底,有时间写文章,但却不知道写什么。每周我都会从网上的聚合专栏寻找优秀的文章,但与其说寻找还不如说是过滤,因为很多知识我并没有深入了解,特别是技术领域大部分是英文文章,光看下来就费劲了,更别说写下自己的精读理解。但周更的频率不能停,我只能逼着着自己啃英文文章,从一眼看下去脑袋全懵的状态硬是培养到一眼扫下去就能评估出文章是否值得精读,这是个漫长的习惯过程,因为初期效率很低,唯一坚持下去的理由就是我知道未来阅读速度会越来越快,读英文文章的速度最终是可以追平读中文文章速度的。
渐渐的我可以通过快速阅读,每周掌握一些新知识,并通过与存量知识进行碰撞产生出新的理解,这解决了 “无话可写” 的尴尬情况,毕竟没有人能保证自己的存量知识够自己写 50 篇、100 篇的文章,现在精读更新到 100 多篇,绝大多数内容都是我新学到的,这也是写作带给我无比受益的地方。
前端内容写多了,不免觉得自己知识面还是太狭隘了,每周不是捣鼓新设计模式,就是研究新语法,关注技术新进展,这只能把自己培养成一颗 “黄金螺丝钉”,如果我未来能坚持十年,写了十年基础技术知识,可能也最多成为一颗 “钻石螺丝钉” 而已。我第一次非技术细节文章的尝试是第一百零三期的 [精读《为什么专家不再关心技术细节》](https://github.com/dt-fe/weekly/blob/v2/103.%E7%B2%BE%E8%AF%BB%E3%80%8A%E4%B8%BA%E4%BB%80%E4%B9%88%E4%B8%93%E5%AE%B6%E4%B8%8D%E5%86%8D%E5%85%B3%E5%BF%83%E6%8A%80%E6%9C%AF%E7%BB%86%E8%8A%82%E3%80%8B.md),这篇文章也道出了我对个人成长的看法:你想发挥更大的价值,就要能影响更多的人,研究 100 年前端技术成为不了马云;同理,让马云写前端,他也不可能一个人写出阿里巴巴。
实际上写作就是一件价值放大的事情,你将自己的优秀理念输出给其他人,让别人写出的代码和你一样优秀,这就可以提升整个团队的工作效率。但这还远远不够,代码只是软件研发流程的一部分,我逐渐发现,把握业务方向、做好团队管理这两大能力才能最大化输出自己的价值,所以后面又写了一些例如 [精读《前端未来展望》](https://github.com/dt-fe/weekly/blob/v2/111.%E7%B2%BE%E8%AF%BB%E3%80%8A%E5%89%8D%E7%AB%AF%E6%9C%AA%E6%9D%A5%E5%B1%95%E6%9C%9B%E3%80%8B.md) 对前端进行综合展望,[精读《刷新》](https://github.com/dt-fe/weekly/blob/v2/116.%E7%B2%BE%E8%AF%BB%E3%80%8A%E5%88%B7%E6%96%B0%E3%80%8B.md) 对领导力进行领悟,以及一些极客公园系列文章增强对商业的理解,这些看似偏离前端技术的文章最终都是为前端服务,一个优秀的前端 Leader 具备的素质至少有:敏锐的商业嗅觉、清晰的理解业务方向、管理好团队,管理好人才、同时还是一个方向的技术大拿。
通过对商业、业务、管理的学习与写作,我并没有发现在专业知识上有多少延误,反而觉得自己以前认为是核心竞争力的 “技术思考” 变得越来越廉价,毕竟就前端技术甚至所有业务技术来说,理解任何一个技术点都没有绝对的壁垒,只要花费足够的时间就行了,难就难在需要花多久去理解,是否可以快速理解技术,理解业务。
<img width=220 src="https://img.alicdn.com/tfs/TB1x6uBtQL0gK0jSZFxXXXWHVXa-762-1004.png">
### How - 如何做分享
写作的时候,只要明确文章主旨,句句点题就不会写的太差,一定不要企图将你的想法在一篇文章中全部表达,毕竟你没写的不代表你不知道,而东拼西凑的文章对读者没什么益处,毕竟读者是为了某个明确目的来读文章的,如果内容和标题关系不大,读者大概率会选择离开。
上面是最基本的写作技巧,我就不继续展开了,接下来要重点聊聊的是前端精读是怎么做分享的。我会从如何写作、如何坚持、如何形成正循环三个方面谈谈自己的感受。
首先是写作方式,前端精读的命题很明确,就是基于某个文章或者观点进行精读,因此每篇文章都有一个明确的主题。第二步是摘要,讲文章内容精简的表达出来,这可以锻炼你的总结能力,也让读者能了解到背景知识。第三步是精读,这一步需要你有一些私藏干货,毕竟把文章直接翻译一遍是没有任何价值的,我在精读自己不熟悉领域的文章时经常遇到这个问题,此时我一般会找几篇类似的文章结合阅读,并找到一些可以互补的观点,这样的精读可以让文章的观点更加饱满。最后是总结,总结时可以点题,将重要内容再梳理一遍,也可以进行延伸,指出更进一步的思考方向。
为了让分享坚持下来,我在每周结束之前都会提前立好下周精读的 Flag,在 Github 开一个 issue,这样不仅可以提醒我周末的写作,还可以收获很多来自社区的讨论与反馈,让文章聚集了社区的智慧。这种提前立 Flag 的做法让我想到了自家小区物业费的收取方式,每年年初都会提前征收一整年的物业费,抛开商业手法不谈,这至少意味着物业对业务整整一年的承诺,这种承诺支撑了物业后续一整年的服务,也支撑了每周下一次的精读文章。
同时,我还找到了一种正循环模式促进写作,分别是让写作与工作、与分享、与生活结合。很自然的,我参与的数据中台工作本身就具有很大挑战性,工作中的内容与思考往往会成为精读内容的来源之一,比如之前写过的《手写 SQL 编译器》系列,因为数据工作中真的要用到这些知识。当我参加一些前端大会时,也可以顺便将分享稿整理成精读,将本来就要分享的内容分析得更彻底,有一种借力打力的感觉。在生活中,参加一些论坛,看过的书都可以成为精读的题材,无论是商业的,人文的,还是历史的,对多元化思维有帮助的内容都可以分享。
<img width=450 src="https://img.alicdn.com/tfs/TB1s5OAtHr1gK0jSZR0XXbP8XXa-1480-1136.png">
## 3 总结
回到主题,当我分享的时候,我在做什么?相信看完上面的内容,你已经得到了答案。
当我在分享时,我在传播知识,扩大自己的影响力,这是显而易见动作。但在这背后,我同时也在践行终身学习理念,每次分享都是一次新知识的学习,是一次知识边界的拓展。也许是一次对工作的思考,也许是一次对生活的感悟,然而每一次都是成长的记录。
思想不会因为传播给他人而减少,每一次分享都是在创造永不磨灭的价值,希望看到这篇文章的你也能认知到分享对自己、对他人的帮助,相信分享的力量,相信积累的力量。
> 讨论地址是:[精读《当我在分享的时候,我在做什么?》 · Issue #229 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/229)
**如果你想参与讨论,请 [点击这里](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)
+123
View File
@@ -0,0 +1,123 @@
## 1 引言
本周精读的文章是 [Mastering JS console.log like a Pro](https://medium.com/javascript-in-plain-english/mastering-js-console-log-like-a-pro-1c634e6393f9),一起来更全面的认识 console 吧!
## 2 概述 & 精读
console 的功能主要在于控制台打印,它可以打印任何字符、对象、甚至 DOM 元素和系统信息,下面一一介绍。
### console.log( ) | info( ) | debug( ) | warn( ) | error( )
直接打印字符,区别在于展示形态的不同:
<img width=400 src="https://img.alicdn.com/tfs/TB1xZ_WveH2gK0jSZFEXXcqMpXa-1492-566.png">
新版 chrome 控制台可以将打印信息分类:
<img width=200 src="https://img.alicdn.com/tfs/TB1fZ2Vvhn1gK0jSZKPXXXvUXXa-420-446.png">
`log()``info()` 都对应 `info``warn()` 对应 `warnings``error()` 对应 `errors`,而 `debug()` 对应 `verbose`,因此建议在合适的场景使用合适的打印习惯,这样排查问题时也可以有针对性的筛选。
比如调试信息可以用 `console.debug` 仅在调试环境下输出,调试者即便开启了调试参数也不会影响正常 `info` 的查看,因为调试信息都输出在 `verbose` 中。
### 使用占位符
- %o — 对象
- %s — 字符串
- %d — 数字
如下所示,可通过占位符在一行中插入不同类型的值:
<img width=400 src="https://img.alicdn.com/tfs/TB1GtL3vlr0gK0jSZFnXXbRRXXa-1840-504.png">
### 添加 CSS 样式
- %c - 样式
<img width=400 src="https://img.alicdn.com/tfs/TB1eK23vlr0gK0jSZFnXXbRRXXa-1832-978.png">
可以总结出,**console 支持输出复杂的内容,其输出能力堪比 HTML,但输入能力太弱,仅为字符串,因此采用了占位符 + 多入参修饰的设计模式解决这个问题。**
### console.dir( )
按 JSON 模式输出。笔者在这里也补充一句:`console.log()` 会自动判断类型,如果内容是 DOM 属性,则输出 DOM 树,但 `console.dir` 会强制以 JSON 模式输出,用在 DOM 对象时可强制转换为 JSON 输出。
<img width=400 src="https://img.alicdn.com/tfs/TB1KQY1vbj1gK0jSZFuXXcrHpXa-922-302.png">
### 输出 HTML 元素
按照 HTML ELements 结构输出:
<img width=400 src="https://img.alicdn.com/tfs/TB1mZ61va61gK0jSZFlXXXDKFXa-920-255.png">
这种输出结构和 Elements 打印形式是一致的,如果要看详细属性,可以使用 `console.dir()`
### console.table
在控制台打印一个表格,属于功能增强。虽然仅文本也可以在控制台打印出漂亮的表格,但浏览器调试控制台的功能更强大,`console.table` 只是其富文本能力的一个体现。
<img width=400 src="https://img.alicdn.com/tfs/TB1WldouKbviK0jSZFNXXaApXXa-928-742.png">
### console.group( ) & console.groupEnd( )
接下来是另一个富文本能力,按分组输出:
<img width=400 src="https://img.alicdn.com/tfs/TB1UV6UvXY7gK0jSZKzXXaikpXa-919-377.png">
这种带有副作用的 API 显然是为方便阅读而设计的,然而在需要输出大量动态结构化数据的场景下,还需要进行结构转换,是比较麻烦的地方。
### console.count( )
`count()` 用来打印调用次数,一般用在循环或递归函数中。接收一个 `label` 参数以定制输出,默认直接输出 `1 2 3` 数字。
<img width=400 src="https://img.alicdn.com/tfs/TB1ELLVveL2gK0jSZPhXXahvXXa-917-500.png">
### console.assert( )
`console` 版断言工具,当且仅当第一个参数值为 `false` 时才打印第二个参数作为输出。
<img width=400 src="https://img.alicdn.com/tfs/TB1HEDUvfb2gK0jSZK9XXaEgFXa-1842-548.png">
这种输出结果为 error,所以也可被 `console.error` + 代码级别断言所取代。
### console.trace( )
打印此时的调用栈,在打印辅助调试信息时非常有用。
<img width=400 src="https://img.alicdn.com/tfs/TB1Jh_YvkL0gK0jSZFAXXcA9pXa-1840-1096.png">
### console.time( )
打印代码执行时间,性能优化和监控场景比较常见。
<img width=400 src="https://img.alicdn.com/tfs/TB1wAT2vbj1gK0jSZFuXXcrHpXa-1612-524.png">
### console.memory
打印内存使用情况。
<img width=400 src="https://img.alicdn.com/tfs/TB1tPHYvkL0gK0jSZFAXXcA9pXa-1842-440.png">
### console.clear( )
清空控制台输出。
## 3 总结
`console` 提供了如此多的输出规范,其实也是在变相制定开发规范,毕竟离开发者最近的就是调试控制台,如果你的项目打印规范与标准规范有差异,那么调试时信息看起来就会很别扭。
可以看到,大部分开源库都良好的遵循了这套规范,比如三方库绝不会输出 `log()`,而且将错误、警告与调试信息正确分开,并尽量少的用 CSS 样式、分组、`table` 等功能,因为这些功能干扰性较强,不能保证所有用户都可接受。
相对的,项目源码就比较适合使用一些醒目的自定义规范,只要这套规则能被很好的执行起来。
最后留下一个讨论点:`console` 可以作为调试、招聘信息、隐藏菜单的投放点,你还看到过哪些有意思的 `console` 使用方式呢?欢迎留言。
> 讨论地址是:[精读《精通 console.log》 · Issue #228 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/228)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+279
View File
@@ -0,0 +1,279 @@
## 1 引言
`JSON.parse` 是浏览器内置的 API,但如果面试官让你实现一个怎么办?好在有人已经帮忙做了这件事,本周我们一起精读这篇 [JSON Parser with Javascript](https://lihautan.com/json-parser-with-javascript/) 文章吧,再温习一遍大学时编译原理相关知识。
## 2 概述 & 精读
要解析 JSON 首先要理解语法概念,之前的 [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) 系列也有介绍过,不过本文介绍的更形象,看下面这个语法图:
<img width=400 src="https://img.alicdn.com/tfs/TB1EbjfvQL0gK0jSZFtXXXQCXXa-1837-857.png">
这是关于 Object 类型的语法描述图,从左向右看,根据箭头指向只要能走出这个迷宫就属于正确语法。
比如第一行 `{``whitespace``}` 表示 `{ }` 属于合法的 JSON 语法。
再比如观察向下的一条最长路线:`{``whitespace``string``whitespace``:``value``}` 表示 `{ string : value }` 属于合法的 JSON 语法。
你可能会问,双引号去哪儿了?这就是语法树最核心的概念了,这张图是关于 Object 类型的 **产生式**,同理还有 string、value 的产生式,产生式中可以嵌套其他产生式,甚至形成环路,以此拥有描述纷繁多变语法的能力。
最后我们再看一个环路,即 `{``whitespace``string` ... `,``whitespace``string` ... `,` ... `}`,我们发现,只要不走回头路,这条路是可以一直 “绕圈” 下去的,因此 Object 类型拥有了任意数量子字段的能力,只是每形成一个子字段,必须经过 `,` 号分割。
### 实现 Parser
首先实现一个基本结构:
```js
function fakeParseJSON(str) {
let i = 0;
// TODO
}
```
`i` 表示访问字符的下标,当 `i` 走到字符串结尾表示遍历结束。
然后是下一步,用几个函数描述解析语法的过程:
```js
function fakeParseJSON(str) {
let i = 0;
function parseObject() {
if (str[i] === '{') {
i++;
skipWhitespace();
// if it is not '}',
// we take the path of string -> whitespace -> ':' -> value -> ...
while (str[i] !== '}') {
const key = parseString();
skipWhitespace();
eatColon();
const value = parseValue();
}
}
}
}
```
其中 `skipWhitespace` 表示匹配并跳过空格,所谓匹配意味着匹配成功,此时 `i` 下标可以继续后移,否则匹配失败。下一步则判断如果 `i` 不是结束标志 `}`,则按照 `parseString` 匹配字符串 → `skipWhitespace` 跳过空格 → `eatColon` 吃掉冒号 → `parseValue` 匹配值,这个链路循环。其中吃掉冒号表示 “匹配冒号但不会产生任何结果,所以就像吃掉了一样”,吃这个动作还可以用在其他场景,比如吃掉尾分号。
> 对于看到这儿的小伙伴,笔者要友情提示一下,原文的思路是一种定制语法解析思路,无论是 `eatColon` 还是 `parseValue` 都仅具备解析 JSON 的通用性,但不具备解析任意语法的通用性。如果你想做一个具备解析任何通用语法的解析器,读入的内容应该是语法描述,处理方式必须更加通用,如果感兴趣可以阅读 [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) 系列文章了解更多。
由于 Object 第一个元素前面不允许加逗号,因此可以利用 `initial` 做一个初始化判定,在初始时机不会吃掉逗号:
```js
function fakeParseJSON(str) {
let i = 0;
function parseObject() {
if (str[i] === '{') {
i++;
skipWhitespace();
let initial = true;
// if it is not '}',
// we take the path of string -> whitespace -> ':' -> value -> ...
while (str[i] !== '}') {
if (!initial) {
eatComma();
skipWhitespace();
}
const key = parseString();
skipWhitespace();
eatColon();
const value = parseValue();
initial = false;
}
// move to the next character of '}'
i++;
}
}
}
```
那么当第一个子元素前面存在逗号时,由于没有 “吃掉逗号” 这个功能,所以读到逗号会报错,语法解析提前结束。
吃逗号和吃冒号的代码都非常简单,即判断当前字符串必须是 “要吃的那个元素”,并且在吃掉后将 `i` 下标自增 1
```js
function fakeParseJSON(str) {
// ...
function eatComma() {
if (str[i] !== ',') {
throw new Error('Expected ",".');
}
i++;
}
function eatColon() {
if (str[i] !== ':') {
throw new Error('Expected ":".');
}
i++;
}
}
```
在有了基本判定功能后,`fakeParseJSON` 需要返回 Object,因此我们只需在每个循环中对 Object 赋值,最后一并 return 即可:
```js
function fakeParseJSON(str) {
let i = 0;
function parseObject() {
if (str[i] === '{') {
i++;
skipWhitespace();
const result = {};
let initial = true;
// if it is not '}',
// we take the path of string -> whitespace -> ':' -> value -> ...
while (str[i] !== '}') {
if (!initial) {
eatComma();
skipWhitespace();
}
const key = parseString();
skipWhitespace();
eatColon();
const value = parseValue();
result[key] = value;
initial = false;
}
// move to the next character of '}'
i++;
return result;
}
}
}
```
解析 Object 的代码就完成了。
接着试着解析 Array,下面是 Array 的语法图:
<img width=400 src="https://img.alicdn.com/tfs/TB1FvYjvKH2gK0jSZFEXXcqMpXa-1837-479.png">
我们只需要吃逗号和 `parseValue` 即可:
```js
function fakeParseJSON(str) {
// ...
function parseArray() {
if (str[i] === '[') {
i++;
skipWhitespace();
const result = [];
let initial = true;
while (str[i] !== ']') {
if (!initial) {
eatComma();
}
const value = parseValue();
result.push(value);
initial = false;
}
// move to the next character of ']'
i++;
return result;
}
}
}
```
接下来到了有趣的 `value` 语法图,可以看到 `value` 是许多种基础类型的 “或” 关系组成的:
<img width=400 src="https://img.alicdn.com/tfs/TB1uGrmvND1gK0jSZFyXXciOVXa-1836-1293.png">
我们只需要继续拆解分析即可:
```js
function fakeParseJSON(str) {
// ...
function parseValue() {
skipWhitespace();
const value =
parseString() ??
parseNumber() ??
parseObject() ??
parseArray() ??
parseKeyword('true', true) ??
parseKeyword('false', false) ??
parseKeyword('null', null);
skipWhitespace();
return value;
}
}
```
其中 `parseKeyword` 函数用来解析一些保留关键字,比如将 `"true"` 解析成布尔类型 `true`
```js
function fakeParseJSON(str) {
// ...
function parseKeyword(name, value) {
if (str.slice(i, i + name.length) === name) {
i += name.length;
return value;
}
}
}
```
如上所示,只要在 name 与对应字符相等时,返回第二个传入参数即可。
### 处理异常输入
一个完整的语法解析功能需要包含错误处理,错误的情况主要分两种:
1. 非法字符。
2. 非正常结尾。
原文提到的 JSON 错误提示优化非常棒,想想你在开发中突然看到下面的提示,是不是很蒙圈:
```text
Unexpected token "a"
```
既然我们是自己写的 JSON 解析器,就可以进行更友好的异常提示,比如:
```text
// show
{ "b"a
^
JSON_ERROR_001 Unexpected token "a".
Expecting a ":" over here, eg:
{ "b": "bar" }
^
You can learn more about valid JSON string in http://goo.gl/xxxxx
```
更多 Demo 可以查看 [原文](https://lihautan.com/json-parser-with-javascript/)。
## 3 总结
这篇文章通过一个具体的例子解释如何做语法分析,对于词法解析入门非常直观,如果你想更深入理解语法解析,或者写一个通用语法解析器,可以阅读语法解析系列入门文章,笔者通过实际例子带你一步一步做一个完备的词法解析工具!
语法解析入门系列文章,建议阅读顺序:
- [精读《手写 SQL 编译器 - 词法分析》](https://github.com/dt-fe/weekly/blob/v2/064.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%8D%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md)
- [精读《手写 SQL 编译器 - 文法介绍》](https://github.com/dt-fe/weekly/blob/v2/065.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%96%87%E6%B3%95%E4%BB%8B%E7%BB%8D%E3%80%8B.md)
- [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md)
- [精读《手写 SQL 编译器 - 回溯》](https://github.com/dt-fe/weekly/blob/v2/067.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E5%9B%9E%E6%BA%AF%E3%80%8B.md)
- [精读《手写 SQL 编译器 - 语法树》](https://github.com/dt-fe/weekly/blob/v2/070.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E6%A0%91%E3%80%8B.md)
- [精读《手写 SQL 编译器 - 错误提示》](https://github.com/dt-fe/weekly/blob/v2/071.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E9%94%99%E8%AF%AF%E6%8F%90%E7%A4%BA%E3%80%8B.md)
- [精读《手写 SQL 编译器 - 性能优化之缓存》](https://github.com/dt-fe/weekly/blob/v2/078.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E4%B9%8B%E7%BC%93%E5%AD%98%E3%80%8B.md)
- [精读《手写 SQL 编译器 - 智能提示》](https://github.com/dt-fe/weekly/blob/v2/085.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%99%BA%E8%83%BD%E6%8F%90%E7%A4%BA%E3%80%8B.md)
[syntax-parser](https://github.com/ascoders/syntax-parser) 这个零依赖的通用语法解析库就是根据上述文章一步一步完成的,看完了上面文章,就彻底理解了这个库的源码。
> 讨论地址是:[精读《手写 JSON Parser》 · Issue #233 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/233)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,233 @@
## 1 引言
拖拽是前端非常常见的交互操作,但显然拖拽是强 DOM 交互的,而 React 绕过了 DOM 这一层,那么基于 React 的拖拽方案就必定值得聊一聊。
结合 [How To Use The HTML Drag-And-Drop API In React](https://www.smashingmagazine.com/2020/02/html-drag-drop-api-react/) 这篇文章,让我们谈谈 React 拖拽这些事。
## 2 概述
原文说的比较简单,笔者先快速介绍其中重点部分。
首先拖拽主要的 API 有 4 个:`dragEnter` `dragLeave` `dragOver` `drop`,分别对应拖入、拖出、正在当前元素范围内拖拽、完成拖入动作。
基于这些 API,我们可以利用 React 实现一个拖入区域:
```jsx
import React from "react";
const DragAndDrop = props => {
const handleDragEnter = e => {
e.preventDefault();
e.stopPropagation();
};
const handleDragLeave = e => {
e.preventDefault();
e.stopPropagation();
};
const handleDragOver = e => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = e => {
e.preventDefault();
e.stopPropagation();
};
return (
<div
className={"drag-drop-zone"}
onDrop={e => handleDrop(e)}
onDragOver={e => handleDragOver(e)}
onDragEnter={e => handleDragEnter(e)}
onDragLeave={e => handleDragLeave(e)}
>
<p>Drag files here to upload</p>
</div>
);
};
export default DragAndDrop;
```
`preventDefault` 指的是阻止默认响应,这个响应可能是跳转页面之类的,`stopPropagation` 是阻止冒泡,这样同样监听了事件的父元素就不会收到响应,我们可以精准作用于嵌套的子元素。
接下来是拖拽状态管理,提到了 `useReducer`,顺便复习一下用法:
```jsx
...
const reducer = (state, action) => {
switch (action.type) {
case 'SET_DROP_DEPTH':
return { ...state, dropDepth: action.dropDepth }
case 'SET_IN_DROP_ZONE':
return { ...state, inDropZone: action.inDropZone };
case 'ADD_FILE_TO_LIST':
return { ...state, fileList: state.fileList.concat(action.files) };
default:
return state;
}
};
const [data, dispatch] = React.useReducer(
reducer, { dropDepth: 0, inDropZone: false, fileList: [] }
)
...
```
最后一个关键点在于拖入后的处理,利用 `dispatch` 增加拖入文件、设置拖入状态即可:
```js
const handleDrop = e => {
...
let files = [...e.dataTransfer.files];
if (files && files.length > 0) {
const existingFiles = data.fileList.map(f => f.name)
files = files.filter(f => !existingFiles.includes(f.name))
dispatch({ type: 'ADD_FILE_TO_LIST', files });
e.dataTransfer.clearData();
dispatch({ type: 'SET_DROP_DEPTH', dropDepth: 0 });
dispatch({ type: 'SET_IN_DROP_ZONE', inDropZone: false });
}
};
```
`e.dataTransfer.clearData` 函数用于清除拖拽过程中产生的临时变量,这些临时变量可以通过 `e.dataTransfer.xxx =` 的方式赋值,一般用于拖拽过程中值的传递。
总结一下,利用 HTML5 的 API 将拖拽转化为状态,最终通过状态映射到 UI。
原文内容还是比较简单的,笔者在精读部分再拓展一些更体系化的内容。
## 3 精读
现阶段拖拽主要分为两种,一种是 HTML5 原生规范的拖拽,这种方式在拖拽过程中不会影响 DOM 结构。另一种是完全所见即所得的拖拽方式,拖拽过程中 DOM 位置会随之变动,好处是可以立即反馈拖拽结果,当然缺点是华而不实,一旦用在生产环境,这种拖拽过程可能导致页面结构频繁跳动,反而看不清拖拽效果。
由于本文也采用了第一种拖拽方案,因为笔者再重新整理一遍自己的封装思路。
从使用角度反推,假设我们拥有一个拖拽库,那必定要拥有两个 API:
```jsx
import { DragContainer, DropContainer } from 'dnd'
const DragItem = (
<DragContainer>
{({ dragProps }) => (
<div {...dragProps} />
)}
</DragContainer>
)
const DropItem = (
<DropContainer>
{({ dropProps }) => (
<div {...dropProps} />
)}
</DropContainer>
)
```
`DragContainer` 包裹可以被拖拽的元素,`DropContainer` 包裹可以被拖入的元素,而至于 `dragProps``dropProps` 需要透传到子元素的 dom 节点,是为了利用 DOM API 控制拖拽效果,这也是拖拽唯一对 DOM 的要求,双方元素都需要有实体 DOM 承载。
而上面例子中给出 `dragProps``dropProps` 的方式属于 RenderProps,我们可以将 `children` 当作函数执行以达到效果:
```jsx
const DragContainer = ({ children, componentId }) => {
const { dragProps } = useDnd(componentId)
return children({
dragProps
})
}
const DropContainer = ({ children, componentId }) => {
const { dropProps } = useDnd(componentId)
return children({
dropProps
})
}
```
那么这里创建了一个自定义 Hook `useDnd` 接收 `dragProps``dropProps`,这个自定义 Hook 可以这么写:
```jsx
const useDnd = ({ componentId }) => {
const dragProps = {}
const dropProps = {}
return { dragProps, dropProps }
}
```
接下来,我们就要分别实现 `drag``drop` 了。
`drag` 来说,只要实现 `onDragStart``onDragEnd` 即可:
```jsx
const dragProps = {
onDragStart: ev => {
ev.stopPropagation()
ev.dataTransfer.setData('componentId', componentId)
},
onDragEnd: ev => {
// 做一些拖拽结束的清理工作
}
}
```
`stopPropagation` 的作用在原文简介中已经介绍过了,`setData` 则是通知拖拽方,当前拖拽的组件 id 是什么,**这是由于拖拽由 `drag` 发起而由 `drop` 响应,因此必须有个数据传输过程,而 `dataTransfer` 就最适合做这件事。**
对于 `drop` 来说,只要实现 `onDragOver``onDrop` 即可:
```jsx
const dropProps = {
onDragOver: 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_two: "app_two_remote" }`,在代码中就可以直接利用以下方式直接从对方应用调用模块:
```js
import { Search } from "app_two/Search";
```
这个 `app_two/Search` 来自于 `app_two` 的配置:
```js
// app_two 的 webpack 配置
export default {
plugins: [
new ModuleFederationPlugin({
name: "app_two",
library: { type: "var", name: "app_two" },
filename: "remoteEntry.js",
exposes: {
Search: "./src/Search"
},
shared: ["react", "react-dom"]
})
]
};
```
正是因为 `Search``exposes` 被导出,我们因此可以使用 `[name]/[exposes_name]` 这个模块,这个模块对于被引用应用来说是一个本地模块。
## 3 总结
模块联邦为更大型的前端应用提供了开箱解决方案,并已经作为 Webpack5 官方模块内置,可以说是继 Externals 后最终的运行时代码复用解决方案。
另外 Webpack5 还内置了大量编译时缓存功能,可以看到,无论是性能还是多项目组织,Webpack5 都在尝试给出自己的最佳思路,期待 Webpack5 正式发布,前端工程化会迈向一个新的阶段。
> 讨论地址是:[精读《Webpack5 新特性 - 模块联邦》 · Issue #239 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/239)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+384
View File
@@ -0,0 +1,384 @@
## 1 引言
[React Router v6](https://github.com/ReactTraining/react-router) alpha 版本发布了,本周通过 [A Sneak Peek at React Router v6](https://alligator.io/react/react-router-v6/) 这篇文章分析一下带来的改变。
## 2 概述
### <Switch> 更名为 <Routes>
一个不痛不痒的改动,使 API 命名更加规范。
```jsx
// v5
import { BrowserRouter, Switch, Route } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<Switch>
<Route exact path="/">
<Home />
</Route>
<Route path="/profile">
<Profile />
</Route>
</Switch>
</BrowserRouter>
);
}
```
在 React Router v6 版本里,直接使用 `Routes` 替代 `Switch`
```jsx
// v6
import { BrowserRouter, Routes, Route } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="profile/*" element={<Profile />} />
</Routes>
</BrowserRouter>
);
}
```
### <Route> 升级
在 v5 版本里,想要给组件传参数是不太直观的,需要利用 RenderProps 的方式透传 `routeProps`
```jsx
import Profile from './Profile';
// v5
<Route path=":userId" component={Profile} />
<Route
path=":userId"
render={routeProps => (
<Profile {...routeProps} animate={true} />
)}
/>
// v6
<Route path=":userId" element={<Profile />} />
<Route path=":userId" element={<Profile animate={true} />} />
```
而在 v6 版本中,`render``component` 方案合并成了 `element` 方案,可以轻松传递 props 且不需要透传 `roteProps` 参数。
### 更方便的嵌套路由
在 v5 版本中,嵌套路由需要通过 `useRouteMatch` 拿到 `match`,并通过 `match.path` 的拼接实现子路由:
```jsx
// v5
import {
BrowserRouter,
Switch,
Route,
Link,
useRouteMatch
} from "react-router-dom";
function App() {
return (
<BrowserRouter>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/profile" component={Profile} />
</Switch>
</BrowserRouter>
);
}
function Profile() {
let match = useRouteMatch();
return (
<div>
<nav>
<Link to={`${match.url}/me`}>My Profile</Link>
</nav>
<Switch>
<Route path={`${match.path}/me`}>
<MyProfile />
</Route>
<Route path={`${match.path}/:id`}>
<OthersProfile />
</Route>
</Switch>
</div>
);
}
```
在 v6 版本中省去了 `useRouteMatch` 这一步,支持直接用 `path` 表示相对路径:
```jsx
// v6
import { BrowserRouter, Routes, Route, Link, Outlet } from "react-router-dom";
// Approach #1
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="profile/*" element={<Profile />} />
</Routes>
</BrowserRouter>
);
}
function Profile() {
return (
<div>
<nav>
<Link to="me">My Profile</Link>
</nav>
<Routes>
<Route path="me" element={<MyProfile />} />
<Route path=":id" element={<OthersProfile />} />
</Routes>
</div>
);
}
// Approach #2
// You can also define all
// <Route> in a single place
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="profile" element={<Profile />}>
<Route path=":id" element={<MyProfile />} />
<Route path="me" element={<OthersProfile />} />
</Route>
</Routes>
</BrowserRouter>
);
}
function Profile() {
return (
<div>
<nav>
<Link to="me">My Profile</Link>
</nav>
<Outlet />
</div>
);
}
```
注意 `Outlet` 是渲染子路由的 Element。
### useNavigate 替代 useHistory
在 v5 版本中,主动跳转路由可以通过 `useHistory` 进行 `history.push` 等操作:
```jsx
// v5
import { useHistory } from "react-router-dom";
function MyButton() {
let history = useHistory();
function handleClick() {
history.push("/home");
}
return <button onClick={handleClick}>Submit</button>;
}
```
而在 v6 版本中,可以通过 `useNavigate` 直接实现这个常用操作:
```jsx
// v6
import { useNavigate } from "react-router-dom";
function MyButton() {
let navigate = useNavigate();
function handleClick() {
navigate("/home");
}
return <button onClick={handleClick}>Submit</button>;
}
```
react-router 内部对 history 进行了封装,如果需要 `history.replace`,可以通过 `{ replace: true }` 参数指定:
```jsx
// v5
history.push("/home");
history.replace("/home");
// v6
navigate("/home");
navigate("/home", { replace: true });
```
### 更小的体积 8kb
由于代码几乎重构,v6 版本的代码压缩后体积从 20kb 缩小到 8kb。
## 3 精读
react-router v6 源码中有一段比较核心的理念,笔者拿出来与大家分享,对一些框架开发是大有裨益的。我们看 `useRoutes` 这段代码节选:
```jsx
export function useRoutes(routes, basename = "", caseSensitive = false) {
let {
params: parentParams,
pathname: parentPathname,
route: parentRoute
} = React.useContext(RouteContext);
if (warnAboutMissingTrailingSplatAt) {
// ...
}
basename = basename ? joinPaths([parentPathname, basename]) : parentPathname;
let navigate = useNavigate();
let location = useLocation();
let matches = React.useMemo(
() => matchRoutes(routes, location, basename, caseSensitive),
[routes, location, basename, caseSensitive]
);
// ...
// Otherwise render an element.
let element = matches.reduceRight((outlet, { params, pathname, route }) => {
return (
<RouteContext.Provider
children={route.element}
value={{
outlet,
params: readOnly({ ...parentParams, ...params }),
pathname: joinPaths([basename, pathname]),
route
}}
/>
);
}, null);
return element;
}
```
可以看到,利用 `React.Context`,v6 版本在每个路由元素渲染时都包裹了一层 `RouteContext`
拿更方便的路由嵌套来说:
> 在 v6 版本中省去了 `useRouteMatch` 这一步,支持直接用 `path` 表示相对路径。
这就是利用这个方案做到的,因为给每一层路由文件包裹了 Context,所以在每一层都可以拿到上一层的 `path`,因此在拼接路由时可以完全由框架内部实现,而不需要用户在调用时预先拼接好。
再以 `useNavigate` 举例,有人觉得 `navigate` 这个封装仅停留在形式层,但其实在功能上也有封装,比如如果传入但是一个相对路径,会根据当前路由进行切换,下面是 `useNavigate` 代码节选:
```jsx
export function useNavigate() {
let { history, pending } = React.useContext(LocationContext);
let { pathname } = React.useContext(RouteContext);
let navigate = React.useCallback(
(to, { replace, state } = {}) => {
if (typeof to === "number") {
history.go(to);
} else {
let relativeTo = resolveLocation(to, pathname);
let method = !!replace || pending ? "replace" : "push";
history[method](relativeTo, state);
}
},
[history, pending, pathname]
);
return navigate;
}
```
可以看到,利用 `RouteContext` 拿到当前的 `pathname`,并根据 `resolveLocation``to``pathname` 进行路径拼接,而 `pathname` 就是通过 `RouteContext.Provider` 提供的。
### 巧用多层 Context Provider
很多时候我们利用 Context 停留在一个 `Provider`,多个 `useContext` 的层面上,这是 Context 最基础的用法,但相信读完 React Router v6 这篇文章,我们可以挖掘出 Context 更多的用法:多层 Context Provider。
**虽然说 Context Provider 存在多层会采取最近覆盖的原则,但这不仅仅是一条规避错误的功能,我们可以利用这个功能实现 React Router v6 这样的改良。**
为了更仔细说明这个特性,这里再举一个具体的例子:比如实现搭建渲染引擎时,每个组件都有一个 id,但这个 id 并不透出在组件的 props 上:
```jsx
const Input = () => {
// Input 组件在画布中会自动生成一个 id,但这个 id 组件无法通过 props 拿到
};
```
此时如果我们允许 Input 组件内部再创建一个子元素,又希望这个子元素的 id 是由 Input 推导出来的,我们可能需要用户这么做:
```jsx
const Input = ({ id }) => {
return <ComponentLoader id={id + "1"} />;
};
```
这样做有两个问题:
1. 将 id 暴露给 Input 组件,违背了之前设计的简洁性。
2. 组件需要对 id 进行拼装,很麻烦。
这里遇到的问题和 React Router 遇到的一样,我们可以将代码简化成下面这样,但功能不变吗?
```jsx
const Input = () => {
return <ComponentLoader id="1" />;
};
```
答案是可以做到,我们可以利用 Context 实现这种方案。关键点就在于,渲染 Input 但组件容器需要包裹一个 Provider:
```jsx
const ComponentLoader = ({ id, element }) => {
<Context.Provider value={{ id }}>{element}</Context.Provider>;
};
```
那么对于内部的组件来说,在不同层级下调用 `useContext` 拿到的 id 是不同的,这正是我们想要的效果:
```jsx
const ComponentLoader = ({id,element}) => {
const { id: parentId } = useContext(Context)
<Context.Provider value={{ id: parentId + id }}>
{element}
</Context.Provider>
}
```
这样我们在 `Input` 内部调用的 `<ComponentLoader id="1" />` 实际上拼接的实际 id 是 `01`,而这完全抛到了外部引擎层处理,用户无需手动拼接。
## 4 总结
React Router v6 完全利用 Hooks 重构后,不仅代码量精简了很多,还变得更好用了,等发正式版的时候可以快速升级一波。
另外从 React Router v6 做的这些优化中,我们从源码中挖掘到了关于 Context 更巧妙的用法,希望这个方法可以帮助你运用到其他更复杂的项目设计中。
> 讨论地址是:[精读《React Router v6》 · Issue #241 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/241)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+354
View File
@@ -0,0 +1,354 @@
## 1 引言
React Hooks 渐渐被国内前端团队所接受,但基于 Hooks 的数据流方案却还未固定,我们有 “100 种” 类似的选择,却各有利弊,让人难以取舍。
本周笔者就深入谈一谈对 Hooks 数据流的理解,相信读完文章后,可以从百花齐放的 Hooks 数据流方案中看到本质。
## 2 精读
基于 React Hooks 谈数据流,我们先从最不容易产生分歧的基础方案说起。
### 单组件数据流
单组件最简单的数据流一定是 `useState`
```jsx
function App() {
const [count, setCount] = useState();
}
```
`useState` 在组件内用是毫无争议的,那么下个话题就一定是跨组件共享数据流了。
### 组件间共享数据流
跨组件最简单的方案就是 `useContext`
```jsx
const CountContext = createContext();
function App() {
const [count, setCount] = useState();
return (
<CountContext.Provider value={{ count, setCount }}>
<Child />
</CountContext.Provider>
);
}
function Child() {
const { count } = useContext(CountContext);
}
```
用法都是官方 API,显然也是毫无争议的,但问题是数据与 UI 不解耦,这个问题 [unstated-next](https://github.com/jamiebuilds/unstated-next) 已经为你想好解决方案了。
### 数据流与组件解耦
[unstated-next](https://github.com/jamiebuilds/unstated-next) 可以帮你把上面例子中,定义在 `App` 中的数据单独出来,形成一个自定义数据管理 Hook:
```jsx
import { createContainer } from "unstated-next";
function useCounter() {
const [count, setCount] = useState();
return { count, setCount };
}
const Counter = createContainer(useCounter);
function App() {
return (
<Counter.Provider>
<Child />
</Counter.Provider>
);
}
function Child() {
const { count } = Counter.useContainer();
}
```
数据与 `App` 就解耦了,这下 `Counter` 再也不和 `App` 绑定了,`Counter` 可以和其他组件绑定作用了。
这个时候性能问题就慢慢浮出了水面,首当其冲的就是 `useState` 无法合并更新的问题,我们自然想到利用 `useReducer` 解决。
### 合并更新
`useReducer` 可以让数据合并更新,这也是 React 官方 API,毫无争议:
```jsx
import { createContainer } from "unstated-next";
function useCounter() {
const [state, dispath] = useReducer(
(state, action) => {
switch (action.type) {
case "setCount":
return {
...state,
count: action.setCount(state.count),
};
case "setFoo":
return {
...state,
foo: action.setFoo(state.foo),
};
default:
return state;
}
return state;
},
{ count: 0, foo: 0 }
);
return { ...state, dispatch };
}
const Counter = createContainer(useCounter);
function App() {
return (
<Counter.Provider>
<Child />
</Counter.Provider>
);
}
function Child() {
const { count } = Counter.useContainer();
}
```
这下即便要同时更新 `count``foo`,我们也能通过抽象成一个 `reducer` 的方式合并更新。
然而还有性能问题:
```jsx
function ChildCount() {
const { count } = Counter.useContainer();
}
function ChildFoo() {
const { foo } = Counter.useContainer();
}
```
更新 `foo` 时,`ChildCount``ChildFoo` 同时会执行,但 `ChildCount` 没用到 `foo` 呀?这个原因是 `Counter.useContainer` 提供的数据流是一个引用整体,其子节点 `foo` 引用变化后会导致整个 Hook 重新执行,继而所有引用它的组件也会重新渲染。
此时我们发现可以利用 Redux `useSelector` 实现按需更新。
### 按需更新
首先我们利用 Redux 对数据流做一次改造:
```jsx
import { createStore } from "redux";
import { Provider, useSelector } from "react-redux";
function reducer(state, action) {
switch (action.type) {
case "setCount":
return {
...state,
count: action.setCount(state.count),
};
case "setFoo":
return {
...state,
foo: action.setFoo(state.foo),
};
default:
return state;
}
return state;
}
function App() {
return (
<Provider store={store}>
<Child />
</Provider>
);
}
function Child() {
const { count } = useSelector(
(state) => ({ count: state.count }),
shallowEqual
);
}
```
`useSelector` 可以让 `Child``count` 变化时才更新,而 `foo` 变化时不更新,这已经接近较为理想的性能目标了。
`useSelector` 的作用仅仅是计算结果不变化时阻止组件刷新,但并不能保证返回结果的引用不变化。
### 防止数据引用频繁变化
对于上面的场景,拿到 `count` 的引用是不变的,**但对于其他场景就不一定了**。
举个例子:
```jsx
function Child() {
const user = useSelector((state) => ({ user: state.user }), shallowEqual);
return <UserPage user={user} />;
}
```
**假设 `user` 对象在每次数据流更新引用都会发生变化**,那么 `shallowEqual` 自然是不起作用,那我们换成 `deepEqual`深对比呢?结果是引用依然会变,只是重渲染不那么频繁了:
```jsx
function Child() {
const user = useSelector(
(state) => ({ user: state.user }),
// 当 user 值变化时才重渲染
deepEqual
);
// 但此处拿到的 user 引用还是会变化
return <UserPage user={user} />;
}
```
是不是觉得在 `deepEqual` 的作用下,没有触发重渲染,`user` 的引用就不会变呢?答案是会变,因为 `user` 对象在每次数据流更新都会变,`useSelector``deepEqual` 作用下没有触发重渲染,但因为全局 reducer 隐去组件自己的重渲染依然会重新执行此函数,此时拿到的 `user` 引用会不断变化。
因此 `useSelector` `deepEqual` 一定要和 `useDeepMemo` 结合使用,才能保证 `user` 引用不会频繁改变:
```jsx
function Child() {
const user = useSelector(
(state) => ({ user: state.user }),
// 当 user 值变化时才重渲染
deepEqual
);
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={userDeep} />;
}
```
当然这是比较极端的情况,只要看到 `deepEqual``useSelector` 同时作用了,就要问问自己其返回的值的引用会不会发生意外变化。
### 缓存查询函数
对于极限场景,即便控制了重渲染次数与返回结果的引用最大程度不变,还是可能存在性能问题,这最后一块性能问题就处在查询函数上。
上面的例子中,查询函数比较简单,但如果查询函数非常复杂就不一样了:
```jsx
function Child() {
const user = useSelector(
(state) => ({ user: verySlowFunction(state.user) }),
// 当 user 值变化时才重渲染
deepEqual
);
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={userDeep} />;
}
```
我们假设 `verySlowFunction` 要遍历画布中 1000 个组件的 n 3 次方次,那组件的重渲染时间消耗与查询时间相比完全不值一提,我们需要考虑缓存查询函数。
一种方式是利用 [reselect](https://github.com/reduxjs/reselect) 根据参数引用进行缓存。
想象一下,如果 `state.user` 的引用不频繁变化,但 `verySlowFunction` 非常慢,理想情况是 `state.user` 引用变化后才重新执行 `verySlowFunction`,但上面的例子中,`useSelector` 并不知道还能这么优化,只能傻傻的每次渲染重复执行 `verySlowFunction`,哪怕 `state.user` 没有变。
此时我们要告诉引用,`state.user` 是否变化才是重新执行的关键:
```jsx
import { createSelector } from "reselect";
const userSelector = createSelector(
(state) => state.user,
(user) => verySlowFunction(user)
);
function Child() {
const user = useSelector(
(state) => userSelector(state),
// 当 user 值变化时才重渲染
deepEqual
);
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={userDeep} />;
}
```
在上面的例子中,通过 `createSelector` 创建的 `userSelector` 会一层层进行缓存,当第一个参数返回的 `state.user` 引用不变时,会直接返回上一次执行结果,直到其应用变化了才会继续往下执行。
> 这也说明了函数式保持幂等的重要性,如果 `verySlowFunction` 不是严格幂等的,这种缓存也无法实施。
看上去很美好,然而实战中你可能发现没有那么美好,因为上面的例子都建立在 **Selector 完全不依赖外部变量**
### 结合外部变量的缓存查询
如果我们要查询的用户来自于不同地区,需要传递 `areaId` 加以识别,那么可以拆分为两个 Selector 函数:
```jsx
import { createSelector } from "reselect";
const areaSelector = (state, props) => state.areas[props.areaId].user;
const userSelector = createSelector(areaSelector, (user) =>
verySlowFunction(user)
);
function Child() {
const user = useSelector(
(state) => userSelector(state, { areaId: 1 }),
deepEqual
);
const userDeep = useDeepMemo(() => user, [user]);
return <UserPage user={userDeep} />;
}
```
所以为了不在组件函数内调用 `createSelector`,我们需要尽可能将用到外部变量的地方抽象成一个通用 Selector,并作为 `createSelector` 的一个先手环节。
`userSelector` 提供给多个组件使用时缓存会失效,原因是我们只创建了一个 Selector 实例,因此这个函数还需要再包装一层高阶形态:
```jsx
import { createSelector } from "reselect";
const userSelector = () =>
createSelector(areaSelector, (user) => verySlowFunction(user));
function Child() {
const customSelector = useMemo(userSelector, []);
const user = useSelector(
(state) => customSelector(state, { areaId: 1 }),
deepEqual
);
}
```
所以对于外部变量结合的环节,还需要 `useMemo``useSelector` 结合使用,`useMemo` 处理外部变量依赖的引用缓存,`useSelector` 处理 Store 相关引用缓存。
## 3 总结
基于 Hooks 的数据流方案不能算完美,我在写作这篇文章时就感觉到这种方案属于 “浅入深出”,简单场景还容易理解,随着场景逐步复杂,方案也变得越来越复杂。
但这种 Immutable 的数据流管理思路给了开发者非常自由的缓存控制能力,只要透彻理解上述概念,就可以开发出非常 “符合预期” 的数据缓存管理模型,只要精心维护,一切就变得非常有秩序。
> 讨论地址是:[精读《React Hooks 数据流》 · Issue #242 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/242)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,141 @@
## 1 引言
从 [@types/react](https://unpkg.com/browse/@types/react@16.9.34/index.d.ts) 源码中挖掘一些 Typescript 使用技巧吧。
## 2 精读
### 泛型 extends
泛型可以指代可能的参数类型,但指代任意类型范围太模糊,当我们需要对参数类型加以限制,或者确定只处理某种类型参数时,就可以对泛型进行 extends 修饰。
问题:`React.lazy` 需要限制返回值是一个 `Promise<T>` 类型,且 `T` 必须是 React 组件类型。
方案:
```typescript
function lazy<T extends ComponentType<any>>(
factory: () => Promise<{ default: T }>
): LazyExoticComponent<T>;
```
`T extends ComponentType` 确保了 T 这个类型一定符合 `ComponentType` 这个 React 组件类型定义,我们再将 T 用到 `Promise<{ default: T }>` 位置即可。
## 泛型 extends + infer
如果有一种场景,需要拿到一个类型,这个类型是当某个参数符合某种结构时,这个结构内的一种子类型,就需要结合 泛型 extends + infer 了。
问题:`React.useReducer` 第一个参数是 Reducer,第二个参数是初始化参数,其实第二个参数的类型是第一个参数中回调函数第一个参数的类型,那我们怎么将这两个参数的关系联系到一起呢?
方案:
```typescript
function useReducer<R extends Reducer<any, any>, I>(
reducer: R,
initializerArg: I & ReducerState<R>,
initializer: (arg: I & ReducerState<R>) => ReducerState<R>
): [ReducerState<R>, Dispatch<ReducerAction<R>>];
type ReducerState<R extends Reducer<any, any>> = R extends Reducer<infer S, any>
? S
: never;
```
`R extends Reducer<any, any>` 的意思在上面已经提过了,也就是 R 必须符合 `Reducer` 结构,也就是 `reducer` 必须符合这个结构,之后重点来了:`initializerArg` 利用 `ReducerState` 这个类型直接从 `reducer` 的类型 `R` 中将第一个回调参数挖了出来并返回。
`ReducerState` 定义中 `R extends Reducer<infer S, any> ? S : never` 的含义是:如果 R 符合 `Reducer<infer S, any>` 类型,则返回类型 `S`,这个 `S``Reducer<infer S>` 也就是 State 位置的类型,否则返回 `never` 类型。
所以 infer 表示待推断类型,是非常强大的功能,可以指定在任意位置代指其类型,并配合 extends 判断是否符合结构,可以使类型推断具备一定编程能力。
要用 extends 的另一个原因是,只有 extends 才能将结构描述出来,我们才能精确定义 infer 指代类型的位置。
### 类型重载
当一个类型拥有多种使用可能性时,可以采用类型重载定义复数类型,Typescript 作用时会逐个匹配并找到第一个满足条件的。
问题:`createElement` 第一个参数支持 FunctionComponent 与 ClassComponent,而且传入参数不同,返回值的类型也不同。
方案:
```typescript
function createElement<P extends {}>(
type: FunctionComponent<P>,
props?: (Attributes & P) | null,
...children: ReactNode[]
): FunctionComponentElement<P>;
function createElement<P extends {}>(
type: ClassType<
P,
ClassicComponent<P, ComponentState>,
ClassicComponentClass<P>
>,
props?: (ClassAttributes<ClassicComponent<P, ComponentState>> & P) | null,
...children: ReactNode[]
): CElement<P, ClassicComponent<P, ComponentState>>;
```
`createElement` 写两遍及以上,并配合不同的参数类型与返回值类型即可。
### 自定义类型收窄
我们可以通过 `typeof``instanceof` 做一些类型收窄工作,但有些类型甚至自定义类型的收窄判断函数需要自定义,我们可以通过 `is` 关键字定义自定义类型收窄判断函数。
问题:`isValidElement` 判断对象是否是合法的 React 元素,我们希望这个函数具备类型收窄的功能。
方案:
```typescript
function isValidElement<P>(
object: {} | null | undefined
): object is ReactElement<P>;
const element: string | ReactElement = "";
if (isValidElement(element)) {
element; // 自动推导类型为 ReactElement
} else {
element; // 自动推导类型为 string
}
```
基于这个方案,我们可以创建一些很有用的函数,比如 `isArray``isMap``isSet` 等等,通过 `is` 关键字时其被调用时具备类型收窄的功能。
### 用 Interface 定义函数
一般定义函数类型我们用 `type`,但有些情况下定义的函数既可被调用,也有一些默认属性值需要定义,我们可以继续用 Interface 定义。
问题:`FunctionComponent` 既可以当作函数调用,同时又能定义 `defaultProps` `displayName` 等固定属性。
方案:
```typescript
interface FunctionComponent<P = {}> {
(props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
propTypes?: WeakValidationMap<P>;
contextTypes?: ValidationMap<any>;
defaultProps?: Partial<P>;
displayName?: string;
}
```
`(props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null` 表示这种类型的变量可以作为函数执行:
```jsx
const App: FunctionComponent = () => <div />;
App.displayName = "App";
```
## 3 总结
看完文章内容,相信你已经可以独立读懂 [@types/react](https://unpkg.com/browse/@types/react@16.9.34/index.d.ts) 这个包的所有类型定义!
更多基础内容可以阅读 [精读《Typescript2.0 - 2.9》](https://github.com/dt-fe/weekly/blob/7de3c77c3bdd7304c9e4b0c0f70c3ba6968ebd29/058.%E7%B2%BE%E8%AF%BB%E3%80%8ATypescript2.0%20-%202.9%E3%80%8B.md) 与 [精读《Typescript 3.2 新特性》](https://github.com/dt-fe/weekly/blob/v2/084.%E7%B2%BE%E8%AF%BB%E3%80%8ATypescript%203.2%20%E6%96%B0%E7%89%B9%E6%80%A7%E3%80%8B.md),由于 TS 更新频繁,后续 TS 技巧可能继续以阅读源码方式进行,希望这次选用的 React 类型源码可以让你印象深刻。
> 讨论地址是:[精读《@types/react 值得注意的 TS 技巧》 · Issue #245 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/245)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+133
View File
@@ -0,0 +1,133 @@
## 1 引言
Error Boundaries 是 React16 提出来用来捕获渲染时错误的概念,今天我们一起读一读 [A Simple Guide to Error Boundaries in React](https://alligator.io/react/error-boundaries/) 这篇文章,了解一下这个重要机制。
## 2 概述
Error Boundaries 可以用来捕获渲染时错误,API 如下:
```jsx
class MyErrorBoundary extends Component {
state = {
error: null,
};
static getDerivedStateFromError(error) {
// 更新 state,下次渲染可以展示错误相关的 UI
return { error: error };
}
componentDidCatch(error, info) {
// 错误上报
logErrorToMyService(error, info);
}
render() {
if (this.state.error) {
// 渲染出错时的 UI
return <p>Something broke</p>;
}
return this.props.children;
}
}
```
- `static getDerivedStateFromError`: 在出错后有机会修改 state 触发最后一次错误 fallback 的渲染。
- `componentDidCatch`: 用于出错时副作用代码,比如错误上报等。
这两种方法中任意一个被定义时,这个组件就会成为 `Error Boundary` 组件,可以阻止子组件渲染时报错。
最后作者还提出一个建议,建议将 Error Boundary 单独作为一个组件,而不是将错误监听方法与业务组件耦合,一方面考虑到复用,另一方面则因为错误检测只对子组件生效。
好吧,其实 React 官方文档比这篇文章介绍的详细的多得多,原文介绍到此结束。
## 3 精读
[React Error Boundaries 官方文档](https://reactjs.org/docs/error-boundaries.html) 里提到了四种无法 Catch 的错误场景:
1. 回调事件。由于回调事件执行时机不在渲染周期内,因此无法被 Error Boundary Catch 住,如有必要得自行 try/catch。
2. 异步。比如 `setTimeout``requestAnimationFrame`,和第一条同理。
3. 服务端渲染。
4. Error Boundary 组件自身触发的错误。因为只能捕获其子组件的错误。
这也是使用 Error Boundaries 最容易有疑问的地方。除了上面的情况,笔者结合自身经验再列举几种异常边界场景。
### 无法捕获编译时错误
很明显,即便是 React 官方 API `Error Boundary` 也只能捕获运行时错误,而对编译时错误无能为力。
编译时错误包括不限于编译环境错误、运行前的框架错误检查提示、TS/Flow 类型错误等,这些都是 `Error Boundary` 无法捕获的,而且没有更好的办法 Catch 住,遇到编译错误就在编译时解决吧,仅关注运行时错误就好了。
### 可以作用于 Function Component
虽然函数式组件无法定义 `Error Boundary`,但 `Error Boundary` 可以捕获函数式组件的错误,因此可以曲线救国:
```jsx
// ErrorBoundary 组件
class ErrorBoundary extends React.Component {
// ...
}
// 可以捕获所有组件异常,包括 Function Component 的子组件
const App = () => {
return (
<ErrorBoundary>
<Child />
</ErrorBoundary>
);
};
```
### 对 Hooks 也可生效
对于 Hooks 中异常也可以生效,比如下面的代码:
```jsx
const Child = (props) => {
React.useEffect(() => {
console.log(1);
props.a.b;
console.log(2);
}, [props.a.b]);
return <div />;
};
```
要注意的是,出现在 deps 中的错误会立即被 Catch,导致 `console.log(1)` 都无法打印。但如果是下面的代码,则可以打印出 `console.log(1)`,无法打印出 `console.log(2)`:
```jsx
const Child = (props) => {
React.useEffect(() => {
console.log(1);
props.a.b;
console.log(2);
}, []);
return <div />;
};
```
所以 React 官网的这句话并不是指 `Error Boundary` 对 Hooks 不生效,而是指 `Error Boundary` 无法以 Hooks 方式指定,对功能是没有影响的:
> componentDidCatch and getDerivedStateFromError: There are no Hook equivalents for these methods yet, but they will be added soon.
所以这里的理解要注意一下,另外 React 官方文档 [Hooks FAQ](https://reactjs.org/docs/hooks-faq.html#how-do-lifecycle-methods-correspond-to-hooks) 有很多宝藏,建议抽时间逐条阅读。
## 4 总结
`Error Boundary` 可以捕获所有子元素渲染时异常,包括 render、各生命周期函数,但也有很多使用限制,希望你可以正确使用它。
错误捕获也不是万能的,更多时候我们要避免并及时修复错误,通过错误捕获降低出错时对用户体验的影响,并在第一时间内监控起来并快速修复。
最后,你有明明正确使用了 `Error Boundary` 却依然无法 Catch 住的错误 Case 吗?
> 讨论地址是:[精读《React Error Boundaries》 · Issue #246 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/246)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+199
View File
@@ -0,0 +1,199 @@
## 1 引言
在数据中台做 BI 工具经常面对海量数据的渲染处理,除了组件本身性能优化之外,经常要排查整体页面性能瓶颈点,尤其是维护一些性能做得并不好的旧代码时。
React 性能调试是面对这种问题的必修课,借助 [Profiling React.js Performance](https://addyosmani.com/blog/profiling-react-js/) 这篇文章一起学习一下这个技能吧。
## 2 精读
本文介绍了众多性能检测工具与方法。
### React Profiler
`Profiler` 这个 API 是一种运行时 Debug 的补充,可以通过其 callback 拿到组件渲染信息,用法如下:
```jsx
const Movies = ({ movies, addToQueue }) => (
<React.Profiler id="Movies" onRender={callback}>
<div />
</React.Profiler>
);
function callback(
id,
phase,
actualTime,
baseTime,
startTime,
commitTime,
interactions
) {}
```
这个 callback 会在每次渲染时执行,渲染分为初始化和更新阶段,通过 `phase` 区分,下面是参数详细说明:
- id: 传入的 id。
- phase: "mount" 或 "update",表示更新状态。
- actualDuration: 实际渲染耗时。
- baseDuration: 没有使用 memo 时的渲染预计耗时。
- startTime: 开始渲染的时间。
- commitTime: React 提交更新的时间
- interactions: 何种原因导致的渲染,比如 `setState` 或 hooks changed 之类。
注意尽量不要轻易使用 `Profiler` 检测性能,因为 `Profiler` 本身也会消耗性能。
如果不想获得这么详细的渲染耗时,或者不想提前在代码中埋点,可以利用 DevTools 的 Profiler 查看更直观更简洁的渲染耗时:
<img width=400 src="https://img.alicdn.com/tfs/TB1sPAuDuL2gK0jSZPhXXahvXXa-1846-1028.png">
其中 Ranked 可以展示按照渲染耗时排序后的结果,Interations 需要配合 Tracing API 使用,在后面会提到。
### Tracing API
利用 `scheduler/tracing` 提供的 `trace` API,我们可以记录某个动作的耗时,比如 “点击添加按钮收藏一个电影” 耗时多久:
```jsx
import { render } from "react-dom";
import { unstable_trace as trace } from "scheduler/tracing";
class MyComponent extends Component {
addMovieButtonClick = (event) => {
trace("Add To Movies Queue click", performance.now(), () => {
this.setState({ itemAddedToQueue: true });
});
};
}
```
在 Interations 中可以看到动作触发的耗时:
<img width=400 src="https://img.alicdn.com/tfs/TB1XR.FDAY2gK0jSZFgXXc5OFXa-1846-1010.png">
这个动作还可以是渲染,比如可以记录 ReactDOM 渲染的耗时:
```jsx
import { unstable_trace as trace } from "scheduler/tracing";
trace("initial render", performance.now(), () => {
ReactDom.render(<App />, document.getElementById("app"));
});
```
<img width=300 src="https://img.alicdn.com/tfs/TB18hyHfcKfxu4jSZPfXXb3dXXa-1846-740.png">
甚至还可以追踪异步的耗时:
```jsx
import {
unstable_trace as trace,
unstable_wrap as wrap,
} from "scheduler/tracing";
trace("Some event", performance.now(), () => {
setTimeout(
wrap(() => {
// 异步操作
})
);
});
```
有了 `Profiler``trace` 这两件武器,我们可以监控任意元素的渲染耗时与交互耗时,几乎可以涵盖所有性能监控需要。
### Puppeteer
我们还可以利用 Puppeteer 实现自动化操作并打印报告:
```jsx
const puppeteer = require("puppeteer");
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
const navigationPromise = page.waitForNavigation();
await page.goto("https://react-movies-queue.glitch.me/");
await page.setViewport({ width: 1276, height: 689 });
await navigationPromise;
const addMovieToQueueBtn =
"li:nth-child(3) > .card > .card__info > div > .button";
await page.waitForSelector(addMovieToQueueBtn);
// Begin profiling...
await page.tracing.start({ path: "profile.json" });
// Click the button
await page.click(addMovieToQueueBtn);
// Stop profliling
await page.tracing.stop();
await browser.close();
})();
```
首先利用 `puppeteer` 创建一个浏览器,新建一个页面并打开 `https://react-movies-queue.glitch.me/` 这个 URL,等待页面加载完毕后利用 DOM 选择器找到按钮,利用 `page.click` API 模拟点击这个按钮,并在前后利用 `page.tracing` 记录性能变化,并将这个文件上传到 DevTools Performance 面板,就会得到一份自动的性能检测报告:
<img width=400 src="https://img.alicdn.com/tfs/TB1623EDxz1gK0jSZSgXXavwpXa-2769-2289.png">
这张图相当重要,是浏览器综合运行开销分析的利器,最上面分为 4 个部分:
- FPS:每秒帧数,绿色竖线越高表示 FPS 越高,出现红线则表示出现了卡顿。
- CPU:CPU 资源,用面积图展示消耗 CPU 资源的事件。
- NET:网络消耗,每条横杠表示一种资源的加载。
- HEAP:内存水位,由于短时间内看不出来是否会内存溢出,一般只用来简单看看内存消耗是否符合预期,对于内存溢出的检测需要用持续监控上报的方式。
下面会有一张 Network 详细图解,比如这张图:
<img width=400 src="https://img.alicdn.com/tfs/TB1D.wKDxD1gK0jSZFyXXciOVXa-2868-750.png">
细线表示等待的时间,粗线表示实际加载的情况,其中浅色部分表示服务器等待时间,即从发送下载请求到服务器响应第一个字节的时间。这部分可以看出资源并行加载阻塞情况以及资源服务器响应时间是否存在问题。
Timings 展示了几个重要时间节点,这里列举一部分:
- FPFirst Paint,第一次绘制。
- FCPFirst Contentful Paint,第一次内容绘制。
- LCPLargest Contentful Paint,最大内容绘制。
- DCLDocument Content LoadedDOM 内容加载完毕。
再下面是 JS 计算消耗,用了一张火焰图,火焰图是性能分析的常用可视化工具。以下面这张图为例:
<img width=350 src="https://img.alicdn.com/tfs/TB1JecIDrr1gK0jSZFDXXb9yVXa-1404-616.png">
看火焰图首先看跨度最长的函数,也就是最长的那条线,这是最耗时的部分,从左到右是浏览器脚本的调用顺序,从上到下是函数嵌套的顺序。
我们可以看到鼠标位置的 34 这个函数虽然长,但并不是性能瓶颈,因为下面执行的 n 函数长度和它一样,表示 34 函数的性能几乎无损耗,其性能由其调用的 n 函数决定。
我们可以利用这种方式一步步排查到叶子结点,找到对性能影响最大的元子函数。
### User Timing API
我们还可以利用 `performance.mark` 自定义性能检测节点:
```jsx
// Record the time before running a task
performance.mark("Movies:updateStart");
// Do some work
// Record the time after running a task
performance.mark("Movies:updateEnd");
// Measure the difference between the start and end of the task
performance.measure("moviesRender", "Movies:updateStart", "Movies:updateEnd");
```
这些节点可以在上面介绍的 Performance 面板中展示出来用于自定义分析。
## 3 总结
利用 Performance 进行通用性能分析,利用 React Profiler 进行 React 定制性能分析,这两个结合在一起几乎可以完成任何性能检测。
一般来说,首先应该用 React Profiler 进行 React 层面的问题筛查,这样更直观,更容易定位问题。如果某些问题跳出了 React 框架范围,或者不再能以组件粒度进行度量,我们可以回到 Performance 面板进行通用性能分析。
> 讨论地址是:[精读《React 性能调试》 · Issue #247 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/247)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,259 @@
## 1 引言
Deno 是什么?Deno 和 Node 有什么关系?Deno 和我有什么关系?
Deno 将于 2020-05-13 发布 1.0,如果你还有上面的疑惑,可以和我一起通过 [Deno 1.0: What you need to know](https://blog.logrocket.com/deno-1-0-what-you-need-to-know/) 这篇文章一起了解 Deno 基础知识。
希望你带着疑问思考,未来 10 年看今天,会不会出现 Deno 官方生态壮大,完全替代 Node 进而影响到 Web 生态的局面呢?这个思考结果会影响到你未来职业发展,你需要学会自己思考,并对这个思考结果负责。
## 2 介绍 & 精读
Deno 的作者是 Ryan Dahl,他是 Nodejs 背后的策划者,曾经说过 [我对 Nodejs 感到遗憾的 10 件事](https://www.youtube.com/watch?v=M3BM9TB-8yA)。这也是为什么新开一个坑的原因,但 Deno 并不定位为 Nodejs 的替代品,从整体功能来看,Deno 有更大的野心,据我的推测是想要取代现在陈旧的前后端开发模式,让 Deno 一统前后端开发全流程。
Nodejs 是由 C++ 写的,而 Deno 则是由 Rust 写的,并选择了 [Tokio](https://tokio.rs/) 这个异步编程框架,并使用 V8 引擎解析 Javascript,并内置了对 Ts 的解析。
### 安装
Deno 支持如下安装方式:
**Shell:**
```shell
curl -fsSL https://deno.land/x/install/install.sh | sh
```
**PowerShell:**
```shell
iwr https://deno.land/x/install/install.ps1 -useb | iex
```
**Homebrew:**
```shell
brew install deno
```
**Chocolatey:**
```shell
choco install deno
```
脚本执行方式为 `deno run`,可以类比为 `node`,但功能不同且支持远程文件,实际上远程依赖是 Deno 的一大特色,也是有争议的地方:
```shell
deno run https://deno.land/std/examples/welcome.ts
```
在 ts 文件中允许用远程脚本加载资源,这个后面还会提到:
```ts
import { serve } from "https://deno.land/std@v0.42.0/http/server.ts";
const s = serve({ port: 8000 });
console.log("http://localhost:8000/");
for await (const req of s) {
req.respond({ body: "Hello World\n" });
}
```
### 安全性
Deno 是默认安全的,这体现在默认没有环境、网络访问权限、文件读写权限、运行子进程的能力。所以如果直接运行一个依赖权限的文件会报错:
```shell
deno run file-needing-to-run-a-subprocess.ts
# error: Uncaught PermissionDenied: access to run a subprocess, run again with the --allow-run flag
```
可以通过参数方式允许权限的执行,有 `--allow-read``--allow-write``--allow-net` 等:
```shell
deno --allow-read=/etc
```
上面表示 `/etc` 文件夹下的文件拥有文件读权限。
除了直接加参数调用、Bash 脚本调用外,还可以用 Make 运行,或者使用类似的 [drake](https://deno.land/x/drake/) 启动。
或者使用 `deno install` 命令,将脚本转化为一个快捷指令:
```shell
deno install --allow-net --allow-read -n serve https://deno.land/std/http/file_server.ts
```
`-n` 表示 `--name`,可以对这个脚本进行重命名,比如上面的例子中,`serve` 命令就等同于 `deno run --allow-net --allow-read https://deno.land/std/http/file_server.ts`
### 标准库
Deno 在标准库上很有特点,对常用功能提供了官方版本,保证可用性与稳定性。原文中列出了一些与 Npm 三方库的对比:
| Deno Module | Description | npm | Equivalents |
| ----------- | --------------------------------------------------------------------------------- | --- | ------------------------ |
| colors | Adds color to the terminal | | chalk, kleur, and colors |
| datetime | Helps working with the JavaScript Date object | |
| encoding | Adds support for external data scructures like base32, binary, csv, toml and yaml | |
| flags | Helps working with command line arguments | | minimist |
| fs | Helps with manipulation of the file system | |
| http | Allows serving local files over HTTP | | http-server |
| log | Used for creating logs | | winston |
| testing | For unit testing assertion and benchmarking | | chai |
| uuid | UUID generation | | uuid |
| ws | Helps with creating WebSocket client/server | | ws |
从这个点上来看,Deno 既做运行环境又做基础生态,缓解了 Npm 生态下选择困难症,这件事需要辩证来看:集成了官方包对功能确定的模块来说是很有必要的,而且提高了底层库的稳定性;但 Deno 生态也有三方库,而且本质上三方库和官方库在功能上没有任何壁垒,因为实现代码都类似,唯一区别是谁能为其稳定性站台,假设微软和 Deno 同时出了基于 Npm 生态与 Deno 生态官方库,都保证会持续维护,你更相信谁呢?官方是否有优势要取决于官方自身的实力。
### 内置 Typescript
Deno 内置支持了 TS,因此不需要 `ts-node` 我们就可以用 `deno run test.ts` 运行 Typescript 文件。值得注意的是,Deno 内部也是利用 Typescript 引擎解析为 Js 后交由 V8 引擎解析,因此本质上没太大的变化,只是这样 Deno 的生态会更规范。
由于内置了 TS 支持,自然也不需要写 `tsconfig.json` 配置了,但你依然可以定制它:
```shell
deno run -c tsconfig.json [file-to-run.ts]
```
Deno 默认还开启了 TS 严格模式,所以看到这里,可以认为 Deno 是为了构建高质量理想库而诞生的运行环境,基于已有的生态来做,但做了更多内置技术选型,这和 Facebook 的 [rome](https://github.com/facebookexperimental/rome) 很像,但做的却更彻底。
其实从实现上来看,我们基于 Javascript 生态也能写出 `deno run test.ts` 这样类似的引擎,只不过是由 JS 驱动执行,可能编译还会选择 Webpack,但 Deno 本身基于 Rust 实现,并重新实现了一套模块加载标准,可以说从更底层的方式重新解读了 W3C 标准规范,以期望解决 Javascript 生态的各种痛点问题。
### 支持 Web 标准
Deno 还支持 W3C 标准规范,因此像 `fetch``setTimeout` 等 API 都可以被直接使用,如果你按照 Deno 支持的那几个函数写代码,可以保证在 Deno、Node、Web 三个平台实现跨平台运行。
虽然距离完全实现 W3C 所有标准规范还有一些路要走,但我们看到了 Deno 兼容规范的决心。
### ESModule
模块化是 Deno 的亮点,Deno 使用官方 ESModule 规范,但引用路径必须加上后缀:
```ts
import * as log from "https://deno.land/std/log/mod.ts";
import { outputToConsole } from "./view.ts";
```
Deno 不需要申明依赖,代码的引用路径就是依赖申明,会包括完整的路径以及文件后缀,也支持网络资源,可以摆脱 NPM 中心化的包管理模式,因为这个路径可以是任何网络地址。
### 包管理
对于 `import * as log from "https://deno.land/std/log/mod.ts";` 这行代码,Deno 会下载到一个缓存文件夹,用户不会感知到这个文件夹与这个过程的存在,也就是说,Deno 环境中是没有 `node_modules` 的。
也可以通过 `deno --reload` 的方式强制刷新缓存。
但这里也要辩证的看待 “Deno 去中心化” 这件事,虽然引用了网络源,但会引发下面几个问题:
1. 实际上还存在一个 "node_modules",只是用户看不到。
2. 网络下载速度放到运行时,第一次启动还是很慢。
3. 普通模式下无 lock,必须配合 `deps.ts` 使用,这个后面会提到。
即使被打上 “中心化恶人” 的 npm 也有去中心化的一面,因为 npm 支持私有化部署,无论是速度还是稳定性都可以由公司自己掌控,从稳定性来说还是 npm 拥有压倒性优势。
### 三方库
Deno 还有第三方库生态,截止目前共有 [221 个三方库](<[](https://deno.land/x/)>)。
由于 Deno 走网络资源,我们可以借助 [Pika](https://www.pika.dev/cdn) 提供的 CDN 服务直接引用网络资源包:
```jsx
import * as pkg from "https://cdn.pika.dev/preact@^10.3.0";
```
虽然这样看上去很轻量,但对公司来说还是需要自建一个 “Pika” 保障稳定性,以及做全球 CDN 缓存等的工作。
### 告别 package.json
npm 生态下包信息存放在 `package.json`,包含但不限于下面的内容:
- 项目元信息。
- 项目依赖和版本号。
- 依赖还进行分类,比如 `dependencies``devDependencies` 甚至 `peerDependencies`
- 标记入口,`main``module`,还有 TS 用的 `types``typings`,脚手架的 `bin` 等等。
- npm scripts。
随着标准的不断更新,`package.json` 信息已经非常臃肿了。
对于 Deno 来说,则使用 `deps.ts` 集中管理依赖:
```ts
export { assert } from "https://deno.land/std@v0.39.0/testing/asserts.ts";
export { green, bold } from "https://deno.land/std@v0.39.0/fmt/colors.ts";
```
`deps.ts` 就是一个普通文件,只是将项目的依赖精确描述出来,这样其他地方引用 `assert` 时,就可以这么写了:
```ts
// import { assert } from "https://deno.land/std@v0.39.0/testing/asserts.ts";
import { assert } from "./deps.ts";
```
如果需要锁定依赖,可以通过 `deno --lock=lock.json` 方式申明。
### deno doc
`deno doc <filename>` 命令可以根据文件按照 JS Doc 规则生成文档,同时也支持 TS 语法,比如下面这段代码:
```ts
/** Asynchronously fulfill a response with a file from the local file
* system. */
export async function send(
{ request, response }: Context,
path: string,
options: SendOptions = { root: "" }
): Promise<string | undefined> {
// ...
}
```
生成文档如下:
```text
function send(_: Context, path: string, options: SendOptions): Promise<string | undefined>
Asynchronously fulfill a response with a file from the local file system.
```
deno 本身文档就是用这个命令生成的,可以 [访问官方文档](https://doc.deno.land/) 查看使用效果。
### 内置工具链
前端 Javascript 工具链相当混乱,虽然业界已有 Umi 等框架做了开箱即用的封装,但回到 Javascript 设计的初衷就是可以在浏览器直接使用的,包括浏览器对不依赖构建工具的模块化支持,注定了未来 Webpack 一定会被消灭。
Deno 通过内置一套工具链的方式解决这个问题,包括:
- 测试:提供 `deno test` 命令与 `Deno.test()` 测试函数。
- 格式化:提供 [vscode 插件](https://marketplace.visualstudio.com/items?itemName=axetroy.vscode-deno)。
- 编译:提供 `deno bundle` 命令。
不过值得注意的是,在最重要的编译环节,`deno bundle` 目前提供的能力是相对欠缺的,比如还不支持 Tree Shaking。
用 Rust 等语言提升构建效率是业界一直在尝试的事,比如 @陈成 就基于 [esbuild](https://github.com/evanw/esbuild) 做了 [@umijs/plugin-esbuild](https://umijs.org/zh-CN/plugins/plugin-esbuild) 插件用于提升 Umi 构建速度,但为了防止生产构建产物与 Webpack 默认规则不一致,仅使用了其压缩(minifier)功能。
对 deno 来说也一样,目前其实没有任何证据表明 deno 的构建结果可以完美适配 webpack 环境,所以请勿认为 deno 发布了 1.0 版本就等于可以在生产环境使用。
## 3 总结
正如原文结尾所说的,Deno 虽然将要发布 1.0 版本,但仍不能完全替代 Nodejs,这背后的原因主要是历史兼容成本,也就是完整支持整个 Node 生态不只是设计的问题,更是一个体力活,需要一个个高地去攻克。
同样 Deno 对 Web 的支持也让人耳目一新,但仍不能放到生产环境使用,除了官方和三方生态还在逐渐完善外,`deno bundle` 对 Tree Shaking 能力的缺失以及构建产物无法保证与现在的 Webpack 完全相同,这样会导致对稳定性要求极高的大型应用迁移成本非常高。
最亮眼的改动是模块化部分,依赖完全去中心化从长远来看是一个非常好的设计,只是基础设施和生态要达到一个较为理想的水平。
最后,让我们站在一个预言者角度思考一下 Deno 到底会不会火吧:
Deno 做的初心是做一个更好的 Node,但很不幸,对于这种级别的生态底层工具来说,重新做一个并重新火起来的难度,不亚于重新做一个阿里巴巴并取代现在阿里的难度。也就是不同的时间点做同一件事,哪怕后者可以吸取教训,大概率也无法复制以前成功的路线。
从 Deno 的功能来看,解决了 Node 很多痛点,其中就包括去中心化管理,有点云开发的意思,但在 2020 年,基于 Nodejs 和 Webpack 的云开发都搞出来了,说实话是没有 Deno 什么空间的。从功能上来看,开篇就说了 Deno 基于 V8 解析 Javascript,对于性能和功能都没有革命性提升,从技术上作出突破也几乎不可能了。
Deno 的思想确实比 Node 先进,但不能说比 Node 好十倍,则无法撼动 Node 的生态,即便是 Node 作者自己可能也不行。
然而我上面说的可能都是错的。
> 讨论地址是:[精读《Deno 1.0 你需要了解的》 · Issue #248 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/248)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,453 @@
## 1 引言
与组件生命周期绑定的 Utils 非常适合基于 React Hooks 来做,比如可以将 “发请求” 这个功能与组件生命周期绑定,实现一些便捷的功能。
这次以 [@umijs/use-request](https://hooks.umijs.org/zh-CN/hooks/async) 为例子,分析其功能思路与源码。
## 2 简介
[@umijs/use-request](https://hooks.umijs.org/zh-CN/hooks/async) 支持以下功能:
- 默认自动请求:在组件初次加载时自动触发请求函数,并自动管理 `loading`, `data` , `error` 状态。
- 手动触发请求:设置 `options.manual = true` , 则手动调用 `run` 时才会取数。
- 轮询请求:设置 `options.pollingInterval` 则进入轮询模式,可通过 `run` / `cancel` 开始与停止轮询。
- 并行请求:设置 `options.fetchKey` 可以对请求状态隔离,通过 `fetches` 拿到所有请求状态。
- 请求防抖:设置 `options.debounceInterval` 开启防抖。
- 请求节流:设置 `options.throttleInterval` 开启节流。
- 请求缓存 & SWR:设置 `options.cacheKey` 后开启对请求结果缓存机制,下次请求前会优先返回缓存并在后台重新取数。
- 请求预加载:由于 `options.cacheKey` 全局共享,可以提前执行 `run` 实现预加载效果。
- 屏幕聚焦重新请求:设置 `options.refreshOnWindowFocus = true` 在浏览器 `refocus``revisible` 时重新请求。
- 请求结果突变:可以通过 `mutate` 直接修改取数结果。
- 加载延迟:设置 `options.loadingDelay` 可以延迟 `loading` 变成 `true` 的时间,有效防止闪烁。
- 自定义请求依赖:设置 `options.refreshDeps` 可以在依赖变动时重新触发请求。
- 分页:设置 `options.paginated` 可支持翻页场景。
- 加载更多:设置 `options.loadMore` 可支持加载更多场景。
一切 Hooks 的功能拓展都要基于 React Hooks 生命周期,我们可以利用 Hooks 做下面几件与组件相关的事:
1. 存储与当前组件实例绑定的 mutable、immutable 数据。
2. 主动触发调用组件 rerender。
3. 访问到组件初始化、销毁时机的钩子。
上面这些功能就可以基于这些基础能力拓展了:
**默认自动请求**
在组件初始时机取数。由于和组件生命周期绑定,可以很方便实现各组件相互隔离的取数顺序强保证:可以利用取数闭包存储 requestIndex,取数结果返回后与当前最新 requestIndex 进行比对,丢弃不一致的取数结果。
**手动触发请求**
将触发取数的函数抽象出来并在 CustomHook 中 return。
**轮询请求**
在取数结束后设定 `setTimeout` 重新触发下一轮取数。
**并行请求**
每次取数时先获取当前请求唯一标识 `fetchKey`,仅更新这个 key 下的状态。
**请求防抖、请求节流**
这个实现方式可以挺通用化,即取数调用函数处替换为对应 `debounce``throttle` 函数。
**请求预加载**
这个功能只要实现全局缓存就自然支持了。
**屏幕聚焦重新请求**
这个可以统一监听 window action 事件,并触发对应组件取数。可以全局统一监听,也可以每个组件分别监听。
**请求结果突变**
由于取数结果存储在 CustomHook 中,直接修改数据 data 值即可。
**加载延迟**
有加载延迟时,可以先将 `loading` 设置为 `false`,等延迟到了再设置为 `true`,如果此时取数提前完毕则销毁定时器,实现无 loading 取数。
**自定义请求依赖**
利用 `useEffect` 和自带的 deps 即可。
**分页**
基于通用取数 Hook 封装,本质上是多带了一些取数参数与返回值参数,并遵循 Antd Table 的 API。
**加载更多**
和分页类似,区别是加载更多不会清空已有数据,并且需要根据约定返回结构 `noMore` 判断是否能继续加载。
## 3 精读
接下来是源码分析。
首先定义了一个类 `Fetch`,这是因为一个 `useRequest``fetchKey` 特性可以通过多实例解决。
Class 的生命周期不依赖 React Hooks,所以将不依赖生命周期的操作收敛到 Class 中,不仅提升了代码抽象程度,也提升了可维护性。
```tsx
class Fetch<R, P extends any[]> {
// ...
// 取数状态存储处
state: FetchResult<R, P> = {
loading: false,
params: [] as any,
data: undefined,
error: undefined,
run: this.run.bind(this.that),
mutate: this.mutate.bind(this.that),
refresh: this.refresh.bind(this.that),
cancel: this.cancel.bind(this.that),
unmount: this.unmount.bind(this.that),
};
constructor(
service: Service<R, P>,
config: FetchConfig<R, P>,
// 外部通过这个回调订阅 state 变化
subscribe: Subscribe<R, P>,
initState?: { data?: any; error?: any; params?: any; loading?: any }
) {}
// 此 setState 非彼 setState,作用是更新 state 并通知订阅
setState(s = {}) {
this.state = {
...this.state,
...s,
};
this.subscribe(this.state);
}
// 实际取数函数,但下划线命名的带有一些历史气息啊
_run(...args: P) {}
// 对外暴露的取数函数,对防抖和节流做了分发处理
run(...args: P) {
if (this.debounceRun) {
// return ..
}
if (this.throttleRun) {
// return ..
}
return this._run(...args);
}
// 取消取数,考虑到了防抖、节流兼容性
cancel() {}
// 以上次取数参数重新取数
refresh() {}
// 轮询 starter
rePolling() {}
// 对应 mutate 函数
mutate(data: any) {}
// 销毁订阅
unmount() {}
}
```
**默认自动请求**
通过 `useEffect` 零依赖实现,需要:
1. 有缓存则不需响应,当对应缓存结束后会通知,同时也支持了请求预加载功能。
2. 为支持并行请求,所有请求都通过 `fetches` 独立管理。
```tsx
// 第一次默认执行
useEffect(() => {
if (!manual) {
// 如果有缓存
if (Object.keys(fetches).length > 0) {
/* 重新执行所有的 */
Object.values(fetches).forEach((f) => {
f.refresh();
});
} else {
// 第一次默认执行,可以通过 defaultParams 设置参数
run(...(defaultParams as any));
}
}
}, []);
```
默认执行第 11 行,并根据当前的 `fetchKey` 生成对应 `fetches`,如果初始化已经存在 `fetches`,则行为改为重新执行所有 **已存在的** 并行请求。
**手动触发请求**
上一节已经在初始请求时禁用了 `manual` 开启时的默认取数。下一步只要将封装的取数函数 `run` 定义出来并暴露给用户:
```tsx
const run = useCallback(
(...args: P) => {
if (fetchKeyPersist) {
const key = fetchKeyPersist(...args);
newstFetchKey.current = key === undefined ? DEFAULT_KEY : key;
}
const currentFetchKey = newstFetchKey.current;
// 这里必须用 fetchsRef,而不能用 fetches。
// 否则在 reset 完,立即 run 的时候,这里拿到的 fetches 是旧的。
let currentFetch = fetchesRef.current[currentFetchKey];
if (!currentFetch) {
const newFetch = new Fetch(
servicePersist,
config,
subscribe.bind(null, currentFetchKey),
{
data: initialData,
}
);
currentFetch = newFetch.state;
setFeches((s) => {
// eslint-disable-next-line no-param-reassign
s[currentFetchKey] = currentFetch;
return { ...s };
});
}
return currentFetch.run(...args);
},
[fetchKey, subscribe]
);
```
主动取数函数与内部取数函数共享一个,所以 `run` 函数要考虑多种情况,其中之一就是并行取数的情况,因此需要拿到当前取数的 `fetchKey`,并创建一个 `Fetch` 的实例,最终调用 `Fetch` 实例的 `run` 函数取数。
**轮询请求**
轮询取数在 `Fetch` 实际取数函数 `_fetch` 中定义,当取数函数 `fetchService`(对多种形态的取数方法进行封装后)执行完后,无论正常还是报错,都要进行轮询逻辑,因此在 `.finally` 时机里判断:
```tsx
fetchService.then().finally(() => {
if (!this.unmountedFlag && currentCount === this.count) {
if (this.config.pollingInterval) {
// 如果屏幕隐藏,并且 !pollingWhenHidden, 则停止轮询,并记录 flag,等 visible 时,继续轮询
if (!isDocumentVisible() && !this.config.pollingWhenHidden) {
this.pollingWhenVisibleFlag = true;
return;
}
this.pollingTimer = setTimeout(() => {
this._run(...args);
}, this.config.pollingInterval);
}
}
});
```
轮询还要考虑到屏幕是否隐藏,如果可以触发轮询则触发定时器再次调用 `_run`,注意这个定时器需要正常销毁。
**并行请求**
每个 `fetchKey` 对应一个 `Fetch` 实例,这个逻辑在 **手动触发请求** 介绍的 `run` 函数中已经实现。
这块的封装思路可以品味一下,从外到内分别是 React Hooks 的 fetch -> Fetch 类的 run -> Fetch 类的 \_run,并行请求做在 React Hooks 这一层。
**请求防抖、请求节流**
这个实现就在 Fetch 类的 `run` 函数中:
```tsx
function run(...args: P) {
if (this.debounceRun) {
this.debounceRun(...args);
return Promise.resolve(null as any);
}
if (this.throttleRun) {
this.throttleRun(...args);
return Promise.resolve(null as any);
}
return this._run(...args);
}
```
由于防抖和节流是 React 无关的,也不是最终取数无关的,因此实现在 `run` 这个夹层函数进行分发。
这里实现的比较简化,防抖后 `run` 拿到的 Promise 不再是有效的取数结果了,其实这块还是可以进一步对 Promise 进行封装,无论在防抖还是正常取数的场景都返回 Promise,只需 resolve 的时机由 `Fetch` 这个类灵活把控即可。
**请求预加载**
预加载就是缓存机制,首先利用 `useEffect` 同步缓存:
```tsx
// cache
useEffect(() => {
if (cacheKey) {
setCache(cacheKey, {
fetches,
newstFetchKey: newstFetchKey.current,
});
}
}, [cacheKey, fetches]);
```
在初始化 `Fetch` 实例时优先采用缓存:
```tsx
const [fetches, setFeches] = useState<Fetches<U, P>>(() => {
// 如果有 缓存,则从缓存中读数据
if (cacheKey) {
const cache = getCache(cacheKey);
if (cache) {
newstFetchKey.current = cache.newstFetchKey;
/* 使用 initState, 重新 new Fetch */
const newFetches: any = {};
Object.keys(cache.fetches).forEach((key) => {
const cacheFetch = cache.fetches[key];
const newFetch = new Fetch();
// ...
newFetches[key] = newFetch.state;
});
return newFetches;
}
}
return [];
});
```
**屏幕聚焦重新请求**
`Fetch` 构造函数实现监听并调用 `refresh` 即可,源码里采取全局统一监听的方式:
```tsx
function subscribe(listener: () => void) {
listeners.push(listener);
return function unsubscribe() {
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
};
}
let eventsBinded = false;
if (typeof window !== "undefined" && window.addEventListener && !eventsBinded) {
const revalidate = () => {
if (!isDocumentVisible()) return;
for (let i = 0; i < listeners.length; i++) {
// dispatch 每个 listener
const listener = listeners[i];
listener();
}
};
window.addEventListener("visibilitychange", revalidate, false);
// only bind the events once
eventsBinded = true;
}
```
`Fetch` 构造函数里注册:
```tsx
this.limitRefresh = limit(this.refresh.bind(this), this.config.focusTimespan);
if (this.config.pollingInterval) {
this.unsubscribe.push(subscribeVisible(this.rePolling.bind(this)));
}
```
并通过 `limit` 封装控制调用频率,并 push 到 `unsubscribe` 数组,一边监听可以随组件一起销毁。
**请求结果突变**
这个函数只要更新 `data` 数据结果即可:
```tsx
function mutate(data: any) {
if (typeof data === "function") {
this.setState({
data: data(this.state.data) || {},
});
} else {
this.setState({
data,
});
}
}
```
值得注意的是,`cancel``refresh``mutate` 都必须在初次请求完成后才有意义,所以初次返回的函数是一个抛错:
```tsx
const noReady = useCallback(
(name: string) => () => {
throw new Error(`Cannot call ${name} when service not executed once.`);
},
[]
);
return {
loading: !manual || defaultLoading,
data: initialData,
error: undefined,
params: [],
cancel: noReady("cancel"),
refresh: noReady("refresh"),
mutate: noReady("mutate"),
...(fetches[newstFetchKey.current] || {}),
} as BaseResult<U, P>;
```
等取数完成后会被 `...(fetches[newstFetchKey.current] || {})` 这一段覆盖为正常函数。
**加载延迟**
如果设置了加载延迟,请求发动时就不应该立即设置为 loading,这个逻辑写在 `_run` 函数中:
```tsx
function _run(...args: P) {
// 取消 loadingDelayTimer
if (this.loadingDelayTimer) {
clearTimeout(this.loadingDelayTimer);
}
this.setState({
loading: !this.config.loadingDelay,
params: args,
});
if (this.config.loadingDelay) {
this.loadingDelayTimer = setTimeout(() => {
this.setState({
loading: true,
});
}, this.config.loadingDelay);
}
}
```
启动一个 `setTimeout` 将 loading 设为 `true` 即可,这个 timeout 在下次执行 `_run` 时被 `clearTimeout` 清空。
**自定义请求依赖**
最明智的做法是利用 `useEffect` 实现,实际代码做了组件 unmount 保护:
```tsx
// refreshDeps 变化,重新执行所有请求
useUpdateEffect(() => {
if (!manual) {
/* 全部重新执行 */
Object.values(fetchesRef.current).forEach((f) => {
f.refresh();
});
}
}, [...refreshDeps]);
```
非手动条件下,依赖变化所有已存在的 `fetche` 执行 `refresh` 即可。
分页和加载更多就不解析了,原理是在 `useAsync` 这个基础请求 Hook 基础上再包一层 Hook,拓展取数参数与返回结果。
## 4 总结
目前还有 错误重试、请求超时管理、Suspense 没有支持,看完这篇精读后,相信你已经可以提 PR 了。
> 讨论地址是:[精读《@umijs/use-request》源码 · Issue #249 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/249)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+284
View File
@@ -0,0 +1,284 @@
## 1 引言
[Recoil](https://recoiljs.org/) 是 Facebook 公司出的数据流管理方案,有一定思考的价值。
Recoil 是基于 Immutable 的数据流管理方案,这也是它值得被拿出来看的最重要原因,如果要用 Mutable 方式管理 React 数据流,直接看 [mobx-react](https://github.com/mobxjs/mobx-react) 就足够了。
然而 React Immutable 特性带来的可预测性非常利于调试和维护:
1. 断点调试时变量的值与当前执行位置无关,已创建过的值不会突然 Mutable 突变,非常可预测。
2. 在 React 框架下组件更新机制单一,只有引用变化才触发重渲染,而没有 Mutable 模式下 ForceUpdate 的心智负担。
当然 Immutable 模式下存在一定编码心智负担,所以各有优劣。
> 但 Recoil 和 Redux 一样,并不代表 React 官方数据流管理方案,因此不用带着官方光环去看它。
## 2 简介
Recoil 解决 React 全局数据流管理的问题,采用分散管理原子状态的设计模式,支持派生数据与异步查询,在基本功能上可以覆盖 Redux。
### 状态作用域
和 Redux 一样,全局数据流管理需要存在作用域 `RecoilRoot`
```jsx
import React from "react";
import { RecoilRoot } from "recoil";
function App() {
return (
<RecoilRoot>
<CharacterCounter />
</RecoilRoot>
);
}
```
`RecoilRoot` 在被嵌套时,最内层的 `RecoilRoot` 会覆盖外层的配置及状态值。
### 定义数据
与 Redux 集中定义 `initState` 不同,Recoil 采用 `atom` 以分散方式定义数据:
```jsx
const textState = atom({
key: "textState",
default: "",
});
```
其中 `key` 必须在 `RecoilRoot` 作用域内唯一,也可以认为是 state 树打平时 key 必须唯一的要求。
`default` 定义默认值,既然数据定义分散了,默认值定义也是分散的。
### 读取数据
与 Redux 的 Connect 或 useSelector 类似,Recoil 采用 Hooks 方式读取数据:
```jsx
import { useRecoilValue } from "recoil";
function App() {
const text = useRecoilValue(textState);
}
```
`useRecoilValue``useSetRecoilState` 都可以获取数据,区别是 `useRecoilState` 还可以获取写数据的函数:
```jsx
import { useRecoilState } from "recoil";
function App() {
const [text, setText] = useRecoilState(useRecoilState);
}
```
### 修改数据
与 Redux 集中定义纯函数 `reducer` 修改数据不同,Recoil 采用 Hooks 方式写数据。
除了上面提到的 `useRecoilState` 之外,还有一个 `useSetRecoilState` 可以仅获取写函数:
```jsx
import { useSetRecoilState } from "recoil";
function App() {
const setText = useSetRecoilState(useRecoilState);
}
```
`useSetRecoilState``useRecoilState``useRecoilValue` 的不同之处在于,数据流的变化不会导致组件 Rerender,因为 `useSetRecoilState` 仅写不读。
这也导致 Recoil API 偏多被诟病,这也是 Immutable 模式下存的编码心智负担,虽然很好理解,但也只有 `useSelector` 或 Recoil 这样拆分 API 的方式可以解决。
> 另外还提供了 `useResetRecoilState` 重置到默认值并读取。
### 仅读不订阅
与 ReactRedux 的 `useStore` 类似,Recoil 提供了 `useRecoilCallback` 用于只读不订阅场景:
```jsx
import { atom, useRecoilCallback } from "recoil";
const itemsInCart = atom({
key: "itemsInCart",
default: 0,
});
function CartInfoDebug() {
const logCartItems = useRecoilCallback(async ({ getPromise }) => {
const numItemsInCart = await getPromise(itemsInCart);
console.log("Items in cart: ", numItemsInCart);
});
}
```
`useRecoilCallback` 通过回调方式定义要读取的数据,这个数据变化也不会导致当前组件重渲染。
### 派生值
与 Mobx `computed` 类似,recoil 提供了 `selector` 支持派生值,这是比较有特色的功能:
```jsx
import { atom, selector, useRecoilState } from "recoil";
const tempFahrenheit = atom({
key: "tempFahrenheit",
default: 32,
});
const tempCelcius = selector({
key: "tempCelcius",
get: ({ get }) => ((get(tempFahrenheit) - 32) * 5) / 9,
set: ({ set }, newValue) => set(tempFahrenheit, (newValue * 9) / 5 + 32),
});
function TempCelcius() {
const [tempF, setTempF] = useRecoilState(tempFahrenheit);
const [tempC, setTempC] = useRecoilState(tempCelcius);
}
```
`selector` 提供了 `get``set` 分别定义如何赋值与取值,所以其与 `atom` 定义一样可以被 `useRecoilState` 等三套 API 操作,这里甚至不用看源码就能猜到,`atom` 应该是基于 `selector` 的一个特定封装。
### 异步读取
基于 `selector` 可以实现异步数据读取,只要将 `get` 函数写成异步即可:
```jsx
const currentUserNameQuery = selector({
key: "CurrentUserName",
get: async ({ get }) => {
const response = await myDBQuery({
userID: get(currentUserIDState),
});
if (response.error) {
throw response.error;
}
return response.name;
},
});
function CurrentUserInfo() {
const userName = useRecoilValue(currentUserNameQuery);
return <div>{userName}</div>;
}
function MyApp() {
return (
<RecoilRoot>
<ErrorBoundary>
<React.Suspense fallback={<div>Loading...</div>}>
<CurrentUserInfo />
</React.Suspense>
</ErrorBoundary>
</RecoilRoot>
);
}
```
1. 异步状态可以被 `Suspense` 捕获。
2. 异步过程报错可以被 `ErrorBoundary` 捕获。
如果不想用 `Suspense` 阻塞异步,可以换 `useRecoilValueLoadable` 这个 API 在当前组件内管理异步状态:
```jsx
function UserInfo({ userID }) {
const userNameLoadable = useRecoilValueLoadable(userNameQuery(userID));
switch (userNameLoadable.state) {
case "hasValue":
return <div>{userNameLoadable.contents}</div>;
case "loading":
return <div>Loading...</div>;
case "hasError":
throw userNameLoadable.contents;
}
}
```
### 依赖外部变量
`reselect` 一样,Recoil 也面临状态管理不纯粹的问题,即数据读取依赖外部变量,这样会面临较为复杂的缓存计算问题,甚至还出现了 `re-reselect` 库。
因为 Recoil 本身是原子化状态管理的,所以这个问题相对好解决:
```jsx
const myMultipliedState = selectorFamily({
key: "MyMultipliedNumber",
get: (multiplier) => ({ get }) => {
return get(myNumberState) * multiplier;
},
});
function MyComponent() {
const number = useRecoilValue(myMultipliedState(100));
}
```
当外部传参 `multiplier` 与依赖值 `myNumberState` 不变时,就不会重新计算。
Recoil 在 `get``set` 函数定义 `Atom` 时,内部会自动生成依赖,这个部分做的比较好。
> 依赖外部变量使用了 Family 后缀,比如 selector -> selectorFamilyatom -> atomFamily。
## 3 精读
Recoil 以原子化方式对状态进行分离管理,确实比较契合 Immutable 的编程模式,尤其在缓存处理时非常亮眼,但编程领域中,优势换一个角度看往往就变成了劣势,我们还是要客观评价一下 Recoil。
### Immutable 心智负担
API 较多,在简介中也提到了,这可能是 Immutable 自带的硬伤,而不仅仅是 Recoil 的问题。
Immutable 模式中,对数据流只有读与写两种诉求,**而申明式编程讲究的是数据变化后 UI 自动 Rerender,那么对数据的读自然而然就被赋予了订阅其变化后触发 Rerender 的期待**,但是写与读不同,为什么 `setState` 强调用回调方式写数据?因为回调方式的写不依赖读,有写诉求的组件没必要与读挂上钩,也就是写组件的地方不一定要订阅对应数据。
Recoil 提供了 `useRecoilState` 作为读写双重 API,仅在既读又写的场景使用,而 `useRecoilValue` 仅仅是为了简化 API,替换为 `useRecoilState` 不会有性能损失,而 `useSetRecoilValue` 则必须认真对待,在仅写不读的场景必须严格使用这个 API。
`useState` 为什么默认是读写的?因为 `useState` 是单组件状态管理的场景,一个定义在组件内的状态不可能只写不读,但 Recoil 是全局状态解决方案,读写分离的场景下,对于只写的组件很有必要脱离对数据的订阅实现性能最大化。
### 条件访问数据
这也是 Hooks 的通病,由于 Hooks 不能写在条件语句中,因此要利用 Hooks 获取一个带有条件判断的数据时,必须回到 `selector` 模式:
```jsx
const articleOrReply = selectorFamily({
key: "articleOrReply",
get: ({ isArticle, id }) => ({ get }) => {
if (isArticle) {
return get(article(id));
}
return get(reply(id));
},
});
```
这样的代码其实挺冗余的,其实在 Mutable 模式下可以 `isArticle ? store.articles[id] : store.replies[id]` 就能搞定的模式,必须单独抽一个 `selector` 出来写上头十行代码,显得非常繁琐。
### Recoil 的本质
从 Hooks API 到派生值,这两个核心特点恰巧是对 Context 与 useMemo 的封装。
首先基于 Hooks 的 `useContext` 已经足够轻量易用,可以认为 `atom``useRecoilState``useRecoilValue``useSetRecoilValue` 分别对应封装后的 `createContext``useContext`
再看 `useMemo`,大部分情况我们可以利用 `useMemo` 造出派生值,这对应了 Recoil 的 `selector``selectorFamily`
所以 Recoil 本质更像一个模式化封装库,针对数据驱动易于数据原子化管理的场景,并做到高性能。
## 3 总结
无论你用不用 Recoil,我们都可以从 Recoil 这儿学到 React 状态管理的基本功:
1. 对象的读与写分离,做到最优按需渲染。
2. 派生的值必须严格缓存,并在命中缓存时引用保证严格相等。
3. 原子存储的数据相互无关联,所有关联的数据都使用派生值方式推导。
> 讨论地址是:[精读《recoil》· Issue #251 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/251)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+175
View File
@@ -0,0 +1,175 @@
## 1 引言
基于 webpack 构建的大型项目开发速度已经非常慢了,前端开发者已经逐渐习惯忍受超过 100 秒的启动时间,超过 30 秒的 reload 时间。即便被寄予厚望的 webpack5 内置了缓存机制也不会得到质的提升。但放到十年前,等待时间是几百毫秒。
好在浏览器支持了 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 模块化加载方案,终于原生支持了文件模块化,这使得本地构建不再需要处理模块化关系并聚合文件,这甚至可以将构建时间从 30 秒降低到 300 毫秒。
当然基于 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 的构建框架不止 [snowpack](https://www.snowpack.dev/) 一个,还有比如基于 vue 的 [vite](https://github.com/vitejs/vite),因为浏览器支持模块化是一个标准,而不与任何框架绑定,未来任何构建工具都会基于此特性开发,这意味着在未来的五年,前端构建一定会回到十年前的速度,这个趋势是明显、确定的。
[ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 带来的最直观的改变有下面三点:
1. `node_modules` 完全不需要参与到构建过程,仅这一点就足以让构建效率提升至少 10 倍。
2. 模块化交给浏览器管理,修改任何组件都只需做单文件编译,时间复杂度永远是 O(1),reload 时间与项目大小无关。
3. 浏览器完全模块化加载文件,不存在资源重复加载问题,这种原生的 TreeShaking 还可以做到访问文件时再编译,做到单文件级别的按需构建。
所以可以说 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 模式下的开发效率,能做到与十年前修改 HTML 单文件的零构建效率几乎相当。
## 2 简介 & 精读
snowpack 核心特征:
- 开发模式启动仅需 50ms 甚至更少。
- 热更新速度非常快。
- 构建时可以结合任何 bundler,比如 webpack。
- 内置支持 TS、JSX、CSS Modules 等。
- 支持自定义构建脚本以及三方插件。
### 安装
```bash
yarn add --dev snowpack
```
通过 `snowpack.config.json` 文件配置,并能自动读取 `babel.config.json` 生效 babel 插件。
### 开发调试
调试 `snowpack dev`,编译 `snowpack build`,会自动以 `src/index` 作为应用入口进行编译。
`snowpack dev` 命令几乎是零耗时的,因为文件仅会在被浏览器访问时进行按需编译,因此构建速度是理想的最快速。
当浏览器访问文件时,snowpack 会将文件做如下转换:
```jsx
// Your Code:
import * as React from "react";
import * as ReactDOM from "react-dom";
// Build Output:
import * as React from "/web_modules/react.js";
import * as ReactDOM from "/web_modules/react-dom.js";
```
目的就是生成一个相对路径,并启动本地服务让浏览器可以访问到这些被 import 的文件。其中 `web_modules` 是 snowpack 对 `node_modules` 构建的结果。
在这之前也会对 Typescript 文件做 tsc 编译,或者 babel 编译。
### 编译
编译命令 `snowpack build` 默认方式与 `snowpack dev` 相同:
<img width=500 src="https://img.alicdn.com/tfs/TB1QeckIuH2gK0jSZJnXXaT1FXa-1467-368.png">
也可以指定以 webpack 作为构建器:
```json
// snowpack.config.json
{
// Optimize your production builds with Webpack
"plugins": [
[
"@snowpack/plugin-webpack",
{
/* ... */
}
]
]
}
```
除了默认构建方式之外,还支持自定义文件处理,通过 `snowpack.config.json` 配置 `scripts` 指定:
```json
{
"extends": "@snowpack/app-scripts-react",
"scripts": {
"build:scss": "sass $FILE"
},
"plugins": []
}
```
比如上述语法支持了对 `scss` 文件编译的拓展。
**"build:\*": "..."**
对文件后缀进行编译,比如:`"build:js,jsx": "babel --filename $FILE"` 指定了对 `js,jsx` 后缀的文件进行 babel 构建。
**"run:\*": "..."**
仅执行一次,可以用来做 lint,也可以用来配合批量文件处理命令,比如 `tsc`: `"run:tsc": "tsc"`
**"mount:\*": "mount DIR [--to /PATH]"**
将文件部署到某个 URL 地址,比如 `"mount:public": "mount public --to /"` 意味着将 `public` 文件夹下的文件部署到 `/` 这个 URL 地址。
还有 `proxy` 等 API 就不一一列举了,详细可以见 [官方文档](https://www.snowpack.dev/)。
我们可以从构建命令体会到 snowpack 的理念,**将源码以流式方式编译后,直接部署到本地 server 提供的 URL 地址,浏览器通过一个 main 入口以 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 的方式加载这些文件。**
所以所有加载与构建逻辑都是按需的,snowpack 要做的只是将本地文件逐个构建好并启动本地服务给浏览器调用。
前端开发离不开 `node_modules`snowpack 通过 `snowpack install` 的方式支持了这一点。
### snowpack install
这个命令已经被 `snowpack dev` 内置了,所以 `snowpack install` 仅用来理解原理。
以下是 `snowpack install` 执行的结果:
```js
snowpack install complete. [0.88s]
⦿ web_modules/ size gzip brotli
react-dom.js 128.93 KB 39.89 KB 34.93 KB
react.js 0.54 KB 0.32 KB 0.28 KB
⦿ web_modules/common/ (Shared)
index-8961bd84.js 10.83 KB 3.96 KB 3.51 KB
```
可以看到,`snowpack` 遍历项目源码对 `node_modules` 的访问,并对 `node_modules` 进行了 Web 版 `install`,可以认为 `npm install` 是将 npm 包安装到了本地,而 `snowpack install` 是将 `node_modules` 安装到了 Web API,所以这个命令只需构建一次,`node_modules` 就变成了可以按需被浏览器加载的静态资源文件。
同时源码中对 npm 包的引用都会转换为对 `web_modules` 这个静态资源地址的引用:
```jsx
import * as ReactDOM from "react-dom";
// 转换
import * as React from "/web_modules/react.js";
```
但同时可以看到 snowpack 对前端生态的高要求,如果某些包通过 webpack 别名设置了一些 magic 映射,就无法通过文件路径直接映射,所以 snowpack 生态成熟需要一段时间,但模块标准化一定是趋势,不规范的包在未来几年内会逐步被淘汰。
### 2020 年适合使用 snowpack 吗
答案是还不适合用在生产环境。
当然用在开发环境还是可以的,但需要承担三个风险:
1. 开发与生产环境构建结果不一致的风险。
2. 项目生态存在非 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 模块化包而导致大量适配成本的风险。
3. 项目存在大量 webpack 插件的 magic 魔法,导致标准化后丢失定制打包逻辑的风险。
但可以看到,这些风险的原因都是非标准化造成的。我们站在 2020 年看以前浏览器非标准化 API 适配与兼容工作,可能会觉得不可思议,为什么要与那些陈旧非标准化的语法做斗争;相应的,2030 年看 2020 年的今天可能也觉得不可思议,为什么很多项目存在大量 magic 自定义构建逻辑,明明标准化构建逻辑已经完全够用了 :P。
所以我们要看到未来的趋势,也要理解当下存在的问题,不要在生态尚未成熟的时候贸然使用,但也要跟进前端规范化的步伐,在合适的时机跟上节奏,毕竟 bundleless 模式带来的开发效率提升是非常明显的。
## 3 总结
前端发展到 2020 年这个时间点,代码规范已经基本稳定,工程化要做的事情已经从新增功能逐渐转移到研发提效上了,因此提升开发时热更新速度、构建速度是当下前端工程化的重中之重。
snowpack 代表的 bundleless 方案肯定是光明的未来,带来的构建提效非常明显,人力充足的前端团队与不需要考虑浏览器兼容性的敏捷小团队都已经开始实践 bundleless 方案了。
但对于业务需要兼容各浏览器的大团队来说,目前 bundleless 方案仅可用于开发环境,生产环境还是需要 webpack 打包,因此 webpack 生态还可以继续繁荣几年,直到大的前端团队也抛弃它为止。
如果看未来十年,可能前端工程化构建脚本都不需要了,浏览器可以直接运行源码。在这一点上,以 snowpack 为代表的 bundleless 模式着实跨越了一大步。
> 讨论地址是:[精读《snowpack》· Issue #252 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/252)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,398 @@
## 1 引言
BI 平台是阿里数据中台团队非常重要的平台级产品,要保证报表编辑与浏览的良好体验,性能优化是必不可少的。
当前 BI 工具普遍是报表形态,要知道报表形态可不仅仅是一张张图表组件,与这些组件关联的筛选条件和联动关系错综复杂,任何一个筛选条件变化就会导致其关联项重新取数并重渲染组件,而报表数据量非常大,一个表格组件加载百万量级的数据稀松平常,为了维持这么大量级数据量下的正常展示,按需渲染是必须要做的功课。
这里说的按需渲染不是指 ListView 无限滚动,因为报表的布局模式有流式布局、磁贴布局和自由布局三套,每种布局风格差异很大,无法用固定的公式计算组件是否可见,因此我们选择初始化组件全量渲染,阻止非首屏内组件的重渲染。因为初始条件下还没有获取数据,全量渲染不会造成性能问题,这是这套方案成立的前提。
所以我今天就专门介绍如何利用 DOM 判断组件在画布中是否可见这个技术方案,从架构设计与代码抽象的角度一步步分解,不仅希望你能轻松理解这个技术方案如何实现,也希望你能掌握这其中的诀窍,学会举一反三。
## 2 精读
我们以 React 框架为例,做按需渲染的思维路径是这样的:
得到组件 `active` 状态 -> 阻塞非 `active` 组件的重渲染。
这里我选择从结果入手,先考虑如何阻塞组件渲染,再一步步推导出判断组件是否可见这个函数怎么写。
### 阻塞组件重渲染
我们需要一个 `RenderWhenActive` 组件,支持一个 `active` 参数,当 `active` 为 true 时这一层是透明的,当 `active` 为 false 时阻塞所有渲染。
再具体描述一下,其效果是这样的:
1. inActive 时,任何 props 变化都不会导致组件渲染。
2. 从 inActive 切换到 active 时,之前作用于组件的 props 要立即生效。
3. 如果切换到 active 后 props 没有变化,也不应该触发重渲染。
4. 从 active 切换到 inActive 后不应触发渲染,且立即阻塞后续重渲染。
目前 Function Component 做不到这一点,我们仍需借助 Class Component 的 `shouldComponentUpdate` 做到这一点,因为 Class Component 阻塞渲染时,会将最新 props 存储下来,而 Function Component 完全没有内部状态,目前还无法胜任这项工作。
我们可以写一个 `RenderWhenActive` 组件轻松实现此功能:
```jsx
class RenderWhenActive extends React.Component {
public shouldComponentUpdate(nextProps) {
return nextProps.active;
}
public render() {
return this.props.children
}
}
```
### 获取组件 active 状态
在进一步思考之前,我们先不要掉到 “如何判断组件是否显示” 这个细节中,可以先假设 “已经有了这样一个函数”,我们应该如何调用。
很显然我们需要一个自定义 Hook`useActive` 判断组件是否是激活态,并拿到 `active` 返回值传递给 `RenderWhenActive` 组件:
```jsx
const ComponentLoader = ({ children }) => {
const active = useActive();
return <RenderWhenActive active={active}>{children}</RenderWhenActive>;
};
```
这样,渲染引擎利用 `ComponentLoader` 渲染的任何组件就具备了按需渲染的功能。
### 实现 useActive
到现在,组件与 Hook 侧的流程已经完整串起来了,我们可以聚焦于如何实现 `useActive` 这个 Hook。
利用 Hooks 的 API,可以在组件渲染完毕后利用 `useEffect` 判断组件是否 Active,并利用 `useState` 存储这个状态:
```jsx
export function useActive(domId: string) {
// 所有元素默认 unActive
const [active, setActive] = React.useState(false);
React.useEffect(() => {
const visibleObserve = new VisibleObserve(domId, "rootId", setActive);
visibleObserve.observe();
return () => visibleObserve.unobserve();
}, [domId]);
return active;
}
```
初始化时,所有组件 active 状态都是 false,然而这种状态在 `shouldComponentUpdate` 并不会阻塞第一次渲染,因此组件的 dom 节点初始化仍会渲染出来。
`useEffect` 阶段注册了 `VisibleObserve` 这个自定义 Class,用来监听组件 dom 节点在其父级节点 `rootId` 内是否可见,并在状态变更时通过第三个回调抛出,这里将 `setActive` 作为第三个参数,可以及时改变当前组件 active 状态。
`VisibleObserve` 这个函数拥有 `observe``unobserve` 两个 API,分别是启动监听与取消监听,利用 `useEffect` 销毁时执行 return callback 的特性,监听与销毁机制也完成了。
下一步就是如何实现最核心的 `VisibleObserve` 函数,用来监听组件是否可见。
### 监听组件是否可见的准备工作
在实现 `VisibleObserve` 之前,想一下有几种方法实现呢?可能你脑海中冒出了很多种奇奇怪怪的方案。是的,判断组件在某个容器内是否可见有许多种方案,即便从功能上能找到最优解,但从兼容性角度来看也无法找到完美的方案,因此这是一个拥有多种实现可能性的函数,在不同版本的浏览器采用不同方案才是最佳策略。
处理这种情况的方法之一,就是做一个抽象类,让所有实际方法都继承并实现抽象类,这样我们就拥有了多套 “相同 API 的不同实现”,以便在不同场景随时切换使用。
利用 `abstract` 创建抽象类 `AVisibleObserve`,实现构造函数并申明两个 public 的重要函数 `observe``unobserve`
```jsx
/**
* 监听元素是否可见的抽象类
*/
abstract class AVisibleObserve {
/**
* 监听元素的 DOM ID
*/
protected targetDomId: string;
/**
* 可见范围根节点 DOM ID
*/
protected rootDomId: string;
/**
* Active 变化回调
*/
protected onActiveChange: (active?: boolean) => void;
constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
this.targetDomId = targetDomId;
this.rootDomId = rootDomId;
this.onActiveChange = onActiveChange;
}
/**
* 开始监听
*/
abstract observe(): void;
/**
* 取消监听
*/
abstract unobserve(): void;
}
```
这样我们就可以实现多套方案。稍加思索可以发现,我们只要两套方案,一套是利用 `setInterval` 实现的轮询检测的笨方法,一种是利用浏览器高级 API `IntersectionObserver` 实现的新潮方法,由于后者有兼容性要求,前者就作为兜底方案实现。
因此我们可以定义两套对应方法:
```jsx
class IntersectionVisibleObserve extends AVisibleObserve {
constructor(/**/) {
super(targetDomId, rootDomId, onActiveChange);
}
observe() {
// balabala..
}
unobserve() {
// balabala..
}
}
class SetIntervalVisibleObserve extends AVisibleObserve {
constructor(/**/) {
super(targetDomId, rootDomId, onActiveChange);
}
observe() {
// balabala..
}
unobserve() {
// balabala..
}
}
```
最后再做一个总类作为调用入口:
```jsx
/**
* 监听元素是否可见总类
*/
export class VisibleObserve extends AVisibleObserve {
/**
* 实际 VisibleObserve 类
*/
private actualVisibleObserve: AVisibleObserve = null;
constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
super(targetDomId, rootDomId, onActiveChange);
// 根据浏览器 API 兼容程度选用不同 Observe 方案
if ('IntersectionObserver' in window) {
// 最新 IntersectionObserve 方案
this.actualVisibleObserve = new IntersectionVisibleObserve(targetDomId, rootDomId, onActiveChange);
} else {
// 兼容的 SetInterval 方案
this.actualVisibleObserve = new SetIntervalVisibleObserve(targetDomId, rootDomId, onActiveChange);
}
}
observe() {
this.actualVisibleObserve.observe();
}
unobserve() {
this.actualVisibleObserve.unobserve();
}
}
```
在构造函数就判断了当前浏览器是否支持 `IntersectionObserver` 这个 API,然而无论何种方案创建的实例都继承于 `AVisibleObserve`,所以我们可以用统一的 `actualVisibleObserve` 成员变量存放。
`observe``unobserve` 阶段都可以无视具体类的实现,直接调用 `this.actualVisibleObserve.observe()``this.actualVisibleObserve.unobserve()` 这两个 API。
这里体现的思想是,父类关心接口层 API,子类关心基于这套接口 API 如何具体实现。
接下来我们看看低配版(兼容)与高配版(原生)分别如何实现。
### 监听组件是否可见 - 兼容版本
兼容版本模式中,需要定义一个额外成员变量 `interval` 存储 SetInterval 引用,在 `unobserve` 的时候 `clearInterval`
其判断可见函数我抽象到了 `judgeActive` 函数中,核心思想是判断两个矩形(容器与要判断的组件)是否存在包含关系,如果包含成立则代表可见,如果包含不成立则不可见。
下面是完整实现函数:
```jsx
class SetIntervalVisibleObserve extends AVisibleObserve {
/**
* Interval 引用
*/
private interval: number;
/**
* 检查是否可见的时间间隔
*/
private checkInterval = 1000;
constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
super(targetDomId, rootDomId, onActiveChange);
}
/**
* 判断元素是否可见
*/
private judgeActive() {
// 获取 root 组件 rect
const rootComponentDom = document.getElementById(this.rootDomId);
if (!rootComponentDom) {
return;
}
// root 组件 rect
const rootComponentRect = rootComponentDom.getBoundingClientRect();
// 获取当前组件 rect
const componentDom = document.getElementById(this.targetDomId);
if (!componentDom) {
return;
}
// 当前组件 rect
const componentRect = componentDom.getBoundingClientRect();
// 判断当前组件是否在 root 组件可视范围内
// 长度之和
const sumOfWidth =
Math.abs(rootComponentRect.left - rootComponentRect.right) + Math.abs(componentRect.left - componentRect.right);
// 宽度之和
const sumOfHeight =
Math.abs(rootComponentRect.bottom - rootComponentRect.top) + Math.abs(componentRect.bottom - componentRect.top);
// 长度之和 + 两倍间距(交叉则间距为负)
const sumOfWidthWithGap = Math.abs(
rootComponentRect.left + rootComponentRect.right - componentRect.left - componentRect.right,
);
// 宽度之和 + 两倍间距(交叉则间距为负)
const sumOfHeightWithGap = Math.abs(
rootComponentRect.bottom + rootComponentRect.top - componentRect.bottom - componentRect.top,
);
if (sumOfWidthWithGap <= sumOfWidth && sumOfHeightWithGap <= sumOfHeight) {
// 在内部
this.onActiveChange(true);
} else {
// 在外部
this.onActiveChange(false);
}
}
observe() {
// 监听时就判断一次元素是否可见
this.judgeActive();
this.interval = setInterval(this.judgeActive, this.checkInterval);
}
unobserve() {
clearInterval(this.interval);
}
}
```
根据容器 `rootDomId` 与组件 `targetDomId`,我们可以拿到其对应 DOM 实例,并调用 `getBoundingClientRect` 拿到其对应矩形的位置与宽高。
算法思路如下:
设容器为 root,组件为 component。
1. 计算 root 与 component 长度之和 `sumOfWidth` 与宽度之和 `sumOfHeight`
2. 计算 root 与 component 长度之和 + 两倍间距 `sumOfWidthWithGap` 与 宽度之和 + 两倍间距 `sumOfHeightWithGap`
3. `sumOfWidthWithGap - sumOfWidth` 的差值就是横向 gap 距离,`sumOfHeightWithGap - sumOfHeight` 的差值就是横向 gap 距离,两个值都为负数表示在内部。
其中的关键是,从横向角度来看,下面的公式可以理解为宽度之和 + 两倍的宽度间距:
```jsx
// 长度之和 + 两倍间距(交叉则间距为负)
const sumOfWidthWithGap = Math.abs(
rootComponentRect.left +
rootComponentRect.right -
componentRect.left -
componentRect.right
);
```
`sumOfWidth` 是宽度之和,这之间的差值就是两倍间距值,正数表示横向没有交集。当横纵两个交集都是负数时,代表存在交叉或者包含在内部。
### 监听组件是否可见 - 原生版本
如果浏览器支持 `IntersectionObserver` 这个 API 就好办多了,以下是完整代码:
```jsx
class IntersectionVisibleObserve extends AVisibleObserve {
/**
* IntersectionObserver 实例
*/
private intersectionObserver: IntersectionObserver;
constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) {
super(targetDomId, rootDomId, onActiveChange);
this.intersectionObserver = new IntersectionObserver(
changes => {
if (changes[0].intersectionRatio > 0) {
onActiveChange(true);
} else {
onActiveChange(false);
// 因为虚拟 dom 更新导致实际 dom 更新,也会在此触发,判断 dom 丢失则重新监听
if (!document.body.contains(changes[0].target)) {
this.intersectionObserver.unobserve(changes[0].target);
this.intersectionObserver.observe(document.getElementById(this.targetDomId));
}
}
},
{
root: document.getElementById(rootDomId),
},
);
}
observe() {
if (document.getElementById(this.targetDomId)) {
this.intersectionObserver.observe(document.getElementById(this.targetDomId));
}
}
unobserve() {
this.intersectionObserver.disconnect();
}
}
```
通过 `intersectionRatio > 0` 就可以判断元素是否出现在父级容器中,如果 `intersectionRatio === 1` 则表示组件完整出现在容器内,此处我们的要求是任意部分出现就 active。
有一点要注意的是,这个判断与 SetInterval 不同,由于 React 虚拟 DOM 可能会更新 DOM 实例,导致 `IntersectionObserver.observe` 监听的 DOM 元素被销毁后,导致后续监听失效,因此需要在元素隐藏时加入下面的代码:
```jsx
// 因为虚拟 dom 更新导致实际 dom 更新,也会在此触发,判断 dom 丢失则重新监听
if (!document.body.contains(changes[0].target)) {
this.intersectionObserver.unobserve(changes[0].target);
this.intersectionObserver.observe(document.getElementById(this.targetDomId));
}
```
1. 当元素判断不在可视区域时,也包含了元素被销毁。
2. 因此通过 `body.contains` 判断元素是否被销毁,如果被销毁则重新监听新的 DOM 实例。
## 3 总结
总结一下,按需渲染的逻辑的适用面不仅仅在渲染引擎,但对于 ProCode 场景直接编写的代码中,要加入这段逻辑就显得侵入性较强。
或许可视区域内按需渲染可以做到前端开发框架内部,虽然不属于标准框架功能,但也不完全属于业务功能。
这次留下一个思考题,如果让手写的 React 代码具备按需渲染功能,怎么设计更好呢?
> 讨论地址是:[精读《用 React 做按需渲染》· Issue #254 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/254)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+154
View File
@@ -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) {
// 这一步会判定为 inViewfalse
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)
+481
View File
@@ -0,0 +1,481 @@
## 1 引言
随着 [Typescript 4 Beta](https://devblogs.microsoft.com/typescript/announcing-typescript-4-0-beta/) 的发布,又带来了许多新功能,其中 Variadic Tuple Types 解决了大量重载模版代码的顽疾,使得这次更新非常有意义。
## 2 简介
### 可变元组类型
考虑 `concat` 场景,接收两个数组或者元组类型,组成一个新数组:
```typescript
function concat(arr1, arr2) {
return [...arr1, ...arr2];
}
```
如果要定义 `concat` 的类型,以往我们会通过枚举的方式,先枚举第一个参数数组中的每一项:
```typescript
function concat<>(arr1: [], arr2: []): [A];
function concat<A>(arr1: [A], arr2: []): [A];
function concat<A, B>(arr1: [A, B], arr2: []): [A, B];
function concat<A, B, C>(arr1: [A, B, C], arr2: []): [A, B, C];
function concat<A, B, C, D>(arr1: [A, B, C, D], arr2: []): [A, B, C, D];
function concat<A, B, C, D, E>(arr1: [A, B, C, D, E], arr2: []): [A, B, C, D, E];
function concat<A, B, C, D, E, F>(arr1: [A, B, C, D, E, F], arr2: []): [A, B, C, D, E, F];)
```
再枚举第二个参数中每一项,如果要完成所有枚举,仅考虑数组长度为 6 的情况,就要定义 36 次重载,代码几乎不可维护:
```typescript
function concat<A2>(arr1: [], arr2: [A2]): [A2];
function concat<A1, A2>(arr1: [A1], arr2: [A2]): [A1, A2];
function concat<A1, B1, A2>(arr1: [A1, B1], arr2: [A2]): [A1, B1, A2];
function concat<A1, B1, C1, A2>(
arr1: [A1, B1, C1],
arr2: [A2]
): [A1, B1, C1, A2];
function concat<A1, B1, C1, D1, A2>(
arr1: [A1, B1, C1, D1],
arr2: [A2]
): [A1, B1, C1, D1, A2];
function concat<A1, B1, C1, D1, E1, A2>(
arr1: [A1, B1, C1, D1, E1],
arr2: [A2]
): [A1, B1, C1, D1, E1, A2];
function concat<A1, B1, C1, D1, E1, F1, A2>(
arr1: [A1, B1, C1, D1, E1, F1],
arr2: [A2]
): [A1, B1, C1, D1, E1, F1, A2];
```
如果我们采用批量定义的方式,问题也不会得到解决,因为参数类型的顺序得不到保证:
```typescript
function concat<T, U>(arr1: T[], arr2, U[]): Array<T | U>;
```
在 Typescript 4,可以在定义中对数组进行解构,通过几行代码优雅的解决可能要重载几百次的场景:
```typescript
type Arr = readonly any[];
function concat<T extends Arr, U extends Arr>(arr1: T, arr2: U): [...T, ...U] {
return [...arr1, ...arr2];
}
```
上面例子中,`Arr` 类型告诉 TS `T``U` 是数组类型,再通过 `[...T, ...U]` 按照逻辑顺序依次拼接类型。
再比如 `tail`,返回除第一项外剩下元素:
```typescript
function tail(arg) {
const [_, ...result] = arg;
return result;
}
```
同样告诉 TS `T` 是数组类型,且 `arr: readonly [any, ...T]` 申明了 `T` 类型表示除第一项其余项的类型,TS 可自动将 `T` 类型关联到对象 `rest`
```typescript
function tail<T extends any[]>(arr: readonly [any, ...T]) {
const [_ignored, ...rest] = arr;
return rest;
}
const myTuple = [1, 2, 3, 4] as const;
const myArray = ["hello", "world"];
// type [2, 3, 4]
const r1 = tail(myTuple);
// type [2, 3, ...string[]]
const r2 = tail([...myTuple, ...myArray] as const);
```
另外之前版本的 TS 只能将类型解构放在最后一个位置:
```typescript
type Strings = [string, string];
type Numbers = [number, number];
// [string, string, number, number]
type StrStrNumNum = [...Strings, ...Numbers];
```
如果你尝试将 `[...Strings, ...Numbers]` 这种写法,将会得到一个错误提示:
```text
A rest element must be last in a tuple type.
```
但在 Typescript 4 版本支持了这种语法:
```typescript
type Strings = [string, string];
type Numbers = number[];
// [string, string, ...Array<number | boolean>]
type Unbounded = [...Strings, ...Numbers, boolean];
```
对于再复杂一些的场景,例如高阶函数 `partialCall`,支持一定程度的柯里化:
```typescript
function partialCall(f, ...headArgs) {
return (...tailArgs) => f(...headArgs, ...tailArgs);
}
```
我们可以通过上面的特性对其进行类型定义,将函数 `f` 第一个参数类型定义为有顺序的 `[...T, ...U]`
```typescript
type Arr = readonly unknown[];
function partialCall<T extends Arr, U extends Arr, R>(
f: (...args: [...T, ...U]) => R,
...headArgs: T
) {
return (...b: U) => f(...headArgs, ...b);
}
```
测试效果如下:
```typescript
const foo = (x: string, y: number, z: boolean) => {};
// This doesn't work because we're feeding in the wrong type for 'x'.
const f1 = partialCall(foo, 100);
// ~~~
// error! Argument of type 'number' is not assignable to parameter of type 'string'.
// This doesn't work because we're passing in too many arguments.
const f2 = partialCall(foo, "hello", 100, true, "oops");
// ~~~~~~
// error! Expected 4 arguments, but got 5.
// This works! It has the type '(y: number, z: boolean) => void'
const f3 = partialCall(foo, "hello");
// What can we do with f3 now?
f3(123, true); // works!
f3();
// error! Expected 2 arguments, but got 0.
f3(123, "hello");
// ~~~~~~~
// error! Argument of type '"hello"' is not assignable to parameter of type 'boolean'
```
值得注意的是,`const f3 = partialCall(foo, "hello");` 这段代码由于还没有执行到 `foo`,因此只匹配了第一个 `x:string` 类型,虽然后面 `y: number, z: boolean` 也是必选,但因为 `foo` 函数还未执行,此时只是参数收集阶段,因此不会报错,等到 `f3(123, true)` 执行时就会校验必选参数了,因此 `f3()` 时才会提示参数数量不正确。
### 元组标记
下面两个函数定义在功能上是一样的:
```typescript
function foo(...args: [string, number]): void {
// ...
}
function foo(arg0: string, arg1: number): void {
// ...
}
```
但还是有微妙的区别,下面的函数对每个参数都有名称标记,但上面通过解构定义的类型则没有,针对这种情况,Typescript 4 支持了元组标记:
```typescript
type Range = [start: number, end: number];
```
同时也支持与解构一起使用:
```typescript
type Foo = [first: number, second?: string, ...rest: any[]];
```
### Class 从构造函数推断成员变量类型
构造函数在类实例化时负责一些初始化工作,比如为成员变量赋值,在 Typescript 4,在构造函数里对成员变量的赋值可以直接为成员变量推导类型:
```typescript
class Square {
// Previously: implicit any!
// Now: inferred to `number`!
area;
sideLength;
constructor(sideLength: number) {
this.sideLength = sideLength;
this.area = sideLength ** 2;
}
}
```
如果对成员变量赋值包含在条件语句中,还能识别出存在 `undefined` 的风险:
```typescript
class Square {
sideLength;
constructor(sideLength: number) {
if (Math.random()) {
this.sideLength = sideLength;
}
}
get area() {
return this.sideLength ** 2;
// ~~~~~~~~~~~~~~~
// error! Object is possibly 'undefined'.
}
}
```
如果在其他函数中初始化,则 TS 不能自动识别,需要用 `!:` 显式申明类型:
```typescript
class Square {
// definite assignment assertion
// v
sideLength!: number;
// ^^^^^^^^
// type annotation
constructor(sideLength: number) {
this.initialize(sideLength);
}
initialize(sideLength: number) {
this.sideLength = sideLength;
}
get area() {
return this.sideLength ** 2;
}
}
```
### 短路赋值语法
针对以下三种短路语法提供了快捷赋值语法:
```typescript
a &&= b; // a && (a = b)
a ||= b; // a || (a = b)
a ??= b; // a ?? (a = b)
```
### catch error unknown 类型
Typescript 4.0 之后,我们可以将 catch error 定义为 `unknown` 类型,以保证后面的代码以健壮的类型判断方式书写:
```typescript
try {
// ...
} catch (e) {
// error!
// Property 'toUpperCase' does not exist on type 'unknown'.
console.log(e.toUpperCase());
if (typeof e === "string") {
// works!
// We've narrowed 'e' down to the type 'string'.
console.log(e.toUpperCase());
}
}
```
PS:在之前的版本,`catch (e: unknown)` 会报错,提示无法为 `error` 定义 `unknown` 类型。
### 自定义 JSX 工厂
TS 4 支持了 `jsxFragmentFactory` 参数定义 Fragment 工厂函数:
```json
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
}
}
```
还可以通过注释方式覆盖单文件的配置:
```typescript
// Note: these pragma comments need to be written
// with a JSDoc-style multiline syntax to take effect.
/** @jsx h */
/** @jsxFrag Fragment */
import { h, Fragment } from "preact";
let stuff = (
<>
<div>Hello</div>
</>
);
```
以上代码编译后解析结果如下:
```typescript
// Note: these pragma comments need to be written
// with a JSDoc-style multiline syntax to take effect.
/** @jsx h */
/** @jsxFrag Fragment */
import { h, Fragment } from "preact";
let stuff = h(Fragment, null, h("div", null, "Hello"));
```
### 其他升级
其他的升级快速介绍:
**构建速度提升**,提升了 `--incremental` + `--noEmitOnError` 场景的构建速度。
**支持 `--incremental` + `--noEmit` 参数同时生效。**
**支持 `@deprecated` 注释,** 使用此注释时,代码中会使用 ~~删除线~~ 警告调用者。
**局部 TS Server 快速启动功能,** 打开大型项目时,TS Server 要准备很久,Typescript 4 在 VSCode 编译器下做了优化,可以提前对当前打开的单文件进行部分语法响应。
**优化自动导入,** 现在 `package.json` `dependencies` 字段定义的依赖将优先作为自动导入的依据,而不再是遍历 `node_modules` 导入一些非预期的包。
除此之外,还有几个 Break Change
`lib.d.ts` 类型升级,主要是移除了 `document.origin` 定义。
覆盖父 Class 属性的 getter 或 setter 现在都会提示错误。
通过 `delete` 删除的属性必须是可选的,如果试图用 `delete` 删除一个必选的 key,则会提示错误。
## 3 精读
Typescript 4 最大亮点就是可变元组类型了,但可变元组类型也不能解决所有问题。
拿笔者的场景来说,函数 `useDesigner` 作为自定义 React Hook 与 `useSelector` 结合支持 connect redux 数据流的值,其调用方式是这样的:
```typescript
const nameSelector = (state: any) => ({
name: state.name as string,
});
const ageSelector = (state: any) => ({
age: state.age as number,
});
const App = () => {
const { name, age } = useDesigner(nameSelector, ageSelector);
};
```
`name``age` 是 Selector 注册的,内部实现方式必然是 `useSelector` + reduce,但类型定义就麻烦了,通过重载可以这么做:
```typescript
import * as React from 'react';
import { useSelector } from 'react-redux';
type Function = (...args: any) => any;
export function useDesigner();
export function useDesigner<T1 extends Function>(
t1: T1
): ReturnType<T1> ;
export function useDesigner<T1 extends Function, T2 extends Function>(
t1: T1,
t2: T2
): ReturnType<T1> & ReturnType<T2> ;
export function useDesigner<
T1 extends Function,
T2 extends Function,
T3 extends Function
>(
t1: T1,
t2: T2,
t3: T3,
t4: T4,
): ReturnType<T1> &
ReturnType<T2> &
ReturnType<T3> &
ReturnType<T4> &
;
export function useDesigner<
T1 extends Function,
T2 extends Function,
T3 extends Function,
T4 extends Function
>(
t1: T1,
t2: T2,
t3: T3,
t4: T4
): ReturnType<T1> &
ReturnType<T2> &
ReturnType<T3> &
ReturnType<T4> &
;
export function useDesigner(...selectors: any[]) {
return useSelector((state) =>
selectors.reduce((selected, selector) => {
return {
...selected,
...selector(state),
};
}, {})
) as any;
}
```
可以看到,笔者需要将 `useDesigner` 传入的参数通过函数重载方式一一传入,上面的例子只支持到了三个参数,如果传入了第四个参数则函数定义会失效,因此业界做法一般是定义十几个重载,这样会导致函数定义非常冗长。
但参考 TS4 的例子,我们可以避免类型重载,而通过枚举的方式支持:
```typescript
type Func = (state?: any) => any;
type Arr = readonly Func[];
const useDesigner = <T extends Arr>(
...selectors: T
): ReturnType<T[0]> &
ReturnType<T[1]> &
ReturnType<T[2]> &
ReturnType<T[3]> => {
return useSelector((state) =>
selectors.reduce((selected, selector) => {
return {
...selected,
...selector(state),
};
}, {})
) as any;
};
```
可以看到,最大的变化是不需要写四遍重载了,但由于场景和 `concat` 不同,这个例子返回值不是简单的 `[...T, ...U]`,而是 `reduce` 的结果,所以目前还只能通过枚举的方式支持。
当然可能存在不用枚举就可以支持无限长度的入参类型解析的方案,因笔者水平有限,暂未想到更好的解法,如果你有更好的解法,欢迎告知笔者。
## 4 总结
Typescript 4 带来了更强类型语法,更智能的类型推导,更快的构建速度以及更合理的开发者工具优化,唯一的几个 Break Change 不会对项目带来实质影响,期待正式版的发布。
> 讨论地址是:[精读《Typescript 4》· Issue #259 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/259)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,115 @@
## 1 引言
在说低代码搭建之前,首先要理解什么是搭建(本文搭建指通过 Web 交互搭建一个自定义的新页面)。
**我认为搭建的本质是提效** ,而提效又分为对研发人员的提效,以及对客户的提效:
- 对研发人员的提效:相对于 Pro Code 模式,搭建的抽象程度更高,通过牺牲部分定制性换来更高效的开发方式。
- 对客户的提效:如果用户有任何搭建 Web 应用的诉求,本质上从阿里云购买服务器自建是最普适的方案,但由于专业性要求高,用户群会很窄,因此需要针对不同用户的诉求开发定制方案,本质上是通过降低通用性换取更低的上手成本,或者针对某个领域降低上手成本,比如 BI 搭建。
提效虽然被说烂了,但软件工程发展中,几乎大部分工作都能归结到在提效。比如 Vscode、Typescript 提升编码效率;React、Vue 框架提升程序研发效率;工作台、可持续集成提升协同开发效率,等等,连微软都称自己的使命是赋能全球每一人、每个组织成就不凡,很大程度上就是在说提升整个社会的生产效能。
低代码开发平台(Low-Code Development Platform)则更进一步,允许通过零代码或少量代码就可以快速创建应用。
从实践结果来看,完全零代码想要覆盖所有领域是不可能的,而 100% 全代码是可以覆盖所有领域,但研发成本太高,所以介于两者之间的低代码模式是值得尝试的,因为许多定制场景往往不需要太多高深的代码就能搞定,很多复杂逻辑可能几个简单的赋值语句、或者条件语句就可以搞定,但如果不允许写代码,其使用成本甚至比写少量代码还要高。
所以搭建本质解决的是提效问题,考虑提效就要看性价比,是使用者学习几行简单代码后,利用低代码平台效率更高,还是使用者坚持不写代码,使用繁琐的搭建交互成本更高?有人说代码学不会,但简单代码本质和搭建无异,都是对电脑指令的输入。
还有一些场景将背后复杂度转移到了其他链路,比如数据搭建场景,虽然搭建器没有低代码能力,但却能实现复杂业务逻辑,原因是这个复杂度被 SQL 层吃掉了,既然复杂度无法消除,那么哪一层实现的效率更高,就由哪一层去做才是合理的。
## 2 精读
低代码不仅仅包括 “能写代码”,主要具备如下四个特性:物料接入、编排能力、渲染能力、出码能力。
### 物料接入
通用搭建引擎要能够接入通用物料,即组件自身不关心搭建环境,就可以被搭建平台所使用。
这需要搭建平台本身不对组件代码实现有入侵,可以对组件暴露的 props 做完全控制,要做到自动识别组件有哪些 props 变量,并根据类型自动推荐编辑表单类型。
除了简单的文本、数字、下拉框等编辑器 Setter 之外,还有如下几种复杂编辑器:
- 回调函数编辑器。
- Node 节点编辑器。
- 文本国际化编辑器。
- 表达式编辑器。
回调函数编辑器与表达式编辑器都是低代码能力的体现,本质上就是利用代码描述某个变量值或者回调。
Node 节点编辑器专门处理节点类型 props 参数,比如 `props.header``propder.footer`,在代码模式描述为组件,在可视化模式需转化为画布下钻模式进行编辑。
### 编排能力
编排能力包含页面编排与逻辑编排,是低代码搭建的核心能力。
#### 页面编排
页面编排包含很多交互行为,比如拖拽组件、布局,其中布局大有可为,比如云凤蝶的编辑模式,通过自由拖拽布局,降低了使用者对 DOM 流式布局的理解成本,但通过自适应四周边距模拟出了流式布局自动撑开容器,容器间碰撞挤压的效果。
组件与组件形成的组合可以形成一个新的物料,一般称为模版,比如一个页面整体也可以称为模版,这个模版组件的 id 就是页面根节点的容器组件。但模版也有不能满足的场景,比如期望组件形成的组合拥有一套全新配置,此时就延伸出低代码业务组件的概念,可以认为将模版当作一个整体编辑,可以为模版设置任意的编辑表单,这个编辑表单的值可以透传到里面每个组件中读取。
#### 逻辑编排
逻辑编排是低代码能力的核心,在低代码引擎中,所有组件参数都可以用低代码描述,比如一个 `props.color` 可以通过颜色选择器选一个固定值,也可以转换为表达式模式写一段代码。
这段代码除了拥有普通 JS 能力外,还拥有基本状态管理的能力,即可以访问当前作用域下的状态 `this.state`,而状态作用域又被容器所分割,容器分为持有状态的容器与不持有状态的,一个持有状态容器内的子组件状态是互通的。
除了基本状态管理能力外,还拥有访问上下文能力,即调用引擎一些 API 对画布进行操作,一般都用于组件回调,在回调里调用 `this.setState` 设置状态也属于操作上下文的行为。除了上下文外,还有风格化、国际化、取数等能力可以通过 `this` 访问到,其中取数能力专门抽到引擎层做,就是为了让所有组件与取数逻辑解耦,组件只要拿到数据、isFetching,而不需要真正发送取数请求。
逻辑编排的另一个维度就是可视化,将上述低代码能力通过可视化方式表达为逻辑节点与线条,在描述与维护复杂逻辑时有一定优势。
### 渲染能力
搭建特殊之处在于,搭建过程几乎只能在 PC 端完成,但发布后的应用往往有多端渲染的诉求,比如越来越多的公司使用手机查看 BI 报表,甚至报表需要嵌入到微信、支付宝小程序中;PC 搭建的表单往往也有大量手机端填报的诉求。
所以编辑和渲染端应该是分离的,但为了保证逻辑一致性,核心代码需要复用,所以搭建引擎最好采用 UI 无关的内核 + 业务层拓展 UI 实现方式来做,UI 无关的内核只负责存储、操作画布数据,排除设计器附加的一堆 Panel 后,渲染时可以复用逻辑内核往往就足够了。
组件的跨端复用也是必须的,现在跨端渲染的技术方案也有不少。
### 出码能力
LowCode 与 ProCode 互转也是一大难题,首先互转的好处不必多说,可以自由的在提效与定制间切换,一定是最理想的开发模式,但实现起来有不少阻碍。
首先是 LowCode 转 ProCode,这个比较简单,原因是 LowCode 本身用 JSON 定义,代码是 JSON 的超集,从子集转换到超集本身没有技术障碍。
从 ProCode 转换到 LowCode 就麻烦了,一种方式是限定 ProCode 的能力,甚至用一种新的语法替代原生 JS,本质上都是通过将 ProCode 的能力范围限制住,使得 LowCode 可以接住。另一种方式是不对称转换,即从 ProCode 转换为 LowCode 后会存在功能缺失,或者即便功能不缺失,但 LowCode 无法对应的功能无法在搭建平台编辑。
### 运行时能力
只拥有上述低代码能力的搭建平台还是太通用了,虽然功能很强大,但在具体的业务场景不一定有多大的提效,具体的业务场景要有具体的解决方案,搭建本质是提效的,如果原子化、低代码的内容太多,就本末倒置,只是用另一种方式写代码罢了,并没有真正做到利用搭建提升开发效率。
通用的业务定制方式有如下三种:
- 定制业务组件:比如将某个复杂业务系统 80% 场景都要用到的组件固化为一个业务定制组件,省去了大部分配置时间,让使用者感受到提效。
- 定制业务模版和低代码业务组件:更进一步,将业务模版固化下来,本质上类似代码模版,或者利用低代码业务组件,在不开发新组件的前提下,制作一个针对某个业务场景的混合组件。
- 定制业务配置项:有些业务场景专业度很高,一方面是用户群不一样,一方面是搭建效率考虑,都应该提供一种基于业务角度出发的配置项,既符合业务思考逻辑,又节省配置步骤。
以上通用方式都是通过引擎已有的开放能力可以做到的,但对数据场景来说,有一些依赖引擎运行时能力场景,需要将引擎运行时能力抽象出来,配合低代码实现。
比如让当前页面所有配置相同数据集的组件自动建立筛选联动关联,虽然筛选联动关联可以通过低代码方式配置,但当画布组件数量变化时,或者有组件动态调用 API 新增组件时,静态的配置很难满足动态关联场景,此时我们可以拓展出一些全局运行时能力,让组件实现这些运行时能力时可以拿到画布信息,在引擎实际调用时再动态运行,而不是编辑生成一份静态 JSON 与渲染完全割裂。
运行时能力在不同平台针对不同垂直场景时会存在差异,如果希望打通底层引擎,可以提供拓展插槽,提供动态注册引擎运行时能力的机制。
## 3 总结
一个低代码搭建平台通吃一切场景是不可能的,只要有人愿意为垂直业务场景做 “量身定制”,用户就会立刻觉得搭建效率得到了提升,我们应当站在用户的角度,以用户利益最大化的方式做平台。
但搭建平台维护成本很高,每个业务场景都单独维护一套肯定不是长久之计,我们需要设计一套有弹性的低代码核心引擎,各个业务都可以基于他为自己的用户群 “量身定制” 一套专属设计器,共享搭建引擎通用的能力与协议,并自由拓展定制能力。
所以不仅渲染态是多态的,设计器也应该是多态的,其中可以被固化为标准的部分需要沉淀下来,比如物料接入规范、编排能力、出码能力、运行时能力,让各个搭建平台做到合而不同。
国内外都有非常多做的相当不错的搭建系统,但要不就太通用,具体场景提效不明显,要不就太垂直,换一个业务场景做不了。现在阿里中后台低代码搭建组织就在制定规范,将引擎通用能力固化为标准协议,让不同搭建平台可以对齐规范与功能,未来还会不断收敛核心引擎实现,基于它可以打造出千千万万个垂直领域的搭建平台,贴着业务做搭建提效,同时引擎内核与规范还能保持互通。
笔者所在阿里数据中台体验技术团队就是中后台低代码搭建组织的一员,将数据搭建领域做到极致。在技术上,我们在打通中后台搭建与数据搭建的技术方案,在产品上,我们正在逐渐统一阿里集团数据搭建平台,对外也携 QuickBI 成为国内唯一一家进入 Gartner 象限的 BI 产品,未来可期。
阿里数据中台体验技术团队正在火热招人中,如果感兴趣可以联系 ziyi.hzy@alibaba-inc.com 。
> 讨论地址是:[精读《对低代码搭建的理解》· Issue #260 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/260)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+308
View File
@@ -0,0 +1,308 @@
## 1 引言
函数缓存是重要概念,本质上就是用空间(缓存存储)换时间(跳过计算过程)。
对于无副作用的纯函数,在合适的场景使用函数缓存是非常必要的,让我们跟着 https://whatthefork.is/memoization 这篇文章深入理解一下函数缓存吧!
## 2 概述
假设又一个获取天气的函数 `getChanceOfRain`,每次调用都要花 100ms 计算:
```jsx
import { getChanceOfRain } from "magic-weather-calculator";
function showWeatherReport() {
let result = getChanceOfRain(); // Let the magic happen
console.log("The chance of rain tomorrow is:", result);
}
showWeatherReport(); // (!) Triggers the calculation
showWeatherReport(); // (!) Triggers the calculation
showWeatherReport(); // (!) Triggers the calculation
```
很显然这样太浪费计算资源了,当已经计算过一次天气后,就没有必要再算一次了,我们期望的是后续调用可以直接拿上一次结果的缓存,这样可以节省大量计算。因此我们可以做一个 `memoizedGetChanceOfRain` 函数缓存计算结果:
```jsx
import { getChanceOfRain } from "magic-weather-calculator";
let isCalculated = false;
let lastResult;
// We added this function!
function memoizedGetChanceOfRain() {
if (isCalculated) {
// No need to calculate it again.
return lastResult;
}
// Gotta calculate it for the first time.
let result = getChanceOfRain();
// Remember it for the next time.
lastResult = result;
isCalculated = true;
return result;
}
function showWeatherReport() {
// Use the memoized function instead of the original function.
let result = memoizedGetChanceOfRain();
console.log("The chance of rain tomorrow is:", result);
}
```
在每次调用时判断优先用缓存,如果没有缓存则调用原始函数并记录缓存。这样当我们多次调用时,除了第一次之外都会立即从缓存中返回结果:
```jsx
showWeatherReport(); // (!) Triggers the calculation
showWeatherReport(); // Uses the calculated result
showWeatherReport(); // Uses the calculated result
showWeatherReport(); // Uses the calculated result
```
然而对于有参数的场景就不适用了,因为缓存并没有考虑参数:
```jsx
function showWeatherReport(city) {
let result = getChanceOfRain(city); // Pass the city
console.log("The chance of rain tomorrow is:", result);
}
showWeatherReport("Tokyo"); // (!) Triggers the calculation
showWeatherReport("London"); // Uses the calculated answer
```
由于参数可能性很多,所以有三种解决方案:
### 1. 仅缓存最后一次结果
仅缓存最后一次结果是最节省存储空间的,而且不会有计算错误,但带来的问题就是当参数变化时缓存会立即失效:
```jsx
import { getChanceOfRain } from "magic-weather-calculator";
let lastCity;
let lastResult;
function memoizedGetChanceOfRain(city) {
if (city === lastCity) {
// Notice this check!
// Same parameters, so we can reuse the last result.
return lastResult;
}
// Either we're called for the first time,
// or we're called with different parameters.
// We have to perform the calculation.
let result = getChanceOfRain(city);
// Remember both the parameters and the result.
lastCity = city;
lastResult = result;
return result;
}
function showWeatherReport(city) {
// Pass the parameters to the memoized function.
let result = memoizedGetChanceOfRain(city);
console.log("The chance of rain tomorrow is:", result);
}
showWeatherReport("Tokyo"); // (!) Triggers the calculation
showWeatherReport("Tokyo"); // Uses the calculated result
showWeatherReport("Tokyo"); // Uses the calculated result
showWeatherReport("London"); // (!) Triggers the calculation
showWeatherReport("London"); // Uses the calculated result
```
在极端情况下等同于没有缓存:
```jsx
showWeatherReport("Tokyo"); // (!) Triggers the calculation
showWeatherReport("London"); // (!) Triggers the calculation
showWeatherReport("Tokyo"); // (!) Triggers the calculation
showWeatherReport("London"); // (!) Triggers the calculation
showWeatherReport("Tokyo"); // (!) Triggers the calculation
```
### 2. 缓存所有结果
第二种方案是缓存所有结果,使用 Map 存储缓存即可:
```jsx
// Remember the last result *for every city*.
let resultsPerCity = new Map();
function memoizedGetChanceOfRain(city) {
if (resultsPerCity.has(city)) {
// We already have a result for this city.
return resultsPerCity.get(city);
}
// We're called for the first time for this city.
let result = getChanceOfRain(city);
// Remember the result for this city.
resultsPerCity.set(city, result);
return result;
}
function showWeatherReport(city) {
// Pass the parameters to the memoized function.
let result = memoizedGetChanceOfRain(city);
console.log("The chance of rain tomorrow is:", result);
}
showWeatherReport("Tokyo"); // (!) Triggers the calculation
showWeatherReport("London"); // (!) Triggers the calculation
showWeatherReport("Tokyo"); // Uses the calculated result
showWeatherReport("London"); // Uses the calculated result
showWeatherReport("Tokyo"); // Uses the calculated result
showWeatherReport("Paris"); // (!) Triggers the calculation
```
这么做带来的弊端就是内存溢出,当可能参数过多时会导致内存无限制的上涨,最坏的情况就是触发浏览器限制或者页面崩溃。
### 3. 其他缓存策略
介于只缓存最后一项与缓存所有项之间还有这其他选择,比如 LRUleast recently used)只保留最小化最近使用的缓存,或者为了方便浏览器回收,使用 WeakMap 替代 Map。
最后提到了函数缓存的一个坑,必须是纯函数。比如下面的 CASE:
```jsx
// Inside the magical npm package
function getChanceOfRain() {
// Show the input box!
let city = prompt("Where do you live?");
// ... calculation ...
}
// Our code
function showWeatherReport() {
let result = getChanceOfRain();
console.log("The chance of rain tomorrow is:", result);
}
```
`getChanceOfRain` 每次会由用户输入一些数据返回结果,导致缓存错误,原因是 “函数入参一部分由用户输入” 就是副作用,我们不能对有副作用的函数进行缓存。
这有时候也是拆分函数的意义,将一个有副作用函数的无副作用部分分解出来,这样就能局部做函数缓存了:
```jsx
// If this function only calculates things,
// we would call it "pure".
// It is safe to memoize this function.
function getChanceOfRain(city) {
// ... calculation ...
}
// This function is "impure" because
// it shows a prompt to the user.
function showWeatherReport() {
// The prompt is now here
let city = prompt("Where do you live?");
let result = getChanceOfRain(city);
console.log("The chance of rain tomorrow is:", result);
}
```
最后,我们可以将缓存函数抽象为高阶函数:
```jsx
function memoize(fn) {
let isCalculated = false;
let lastResult;
return function memoizedFn() {
// Return the generated function!
if (isCalculated) {
return lastResult;
}
let result = fn();
lastResult = result;
isCalculated = true;
return result;
};
}
```
这样生成新的缓存函数就方便啦:
```jsx
let memoizedGetChanceOfRain = memoize(getChanceOfRain);
let memoizedGetNextEarthquake = memoize(getNextEarthquake);
let memoizedGetCosmicRaysProbability = memoize(getCosmicRaysProbability);
```
`isCalculated``lastResult` 都存储在 `memoize` 函数生成的闭包内,外部无法访问。
## 3 精读
### 通用高阶函数实现函数缓存
原文的例子还是比较简单,没有考虑函数多个参数如何处理,下面我们分析一下 Lodash `memoize` 函数源码:
```jsx
function memoize(func, resolver) {
if (
typeof func != "function" ||
(resolver != null && typeof resolver != "function")
) {
throw new TypeError(FUNC_ERROR_TEXT);
}
var memoized = function () {
var args = arguments,
key = resolver ? resolver.apply(this, args) : args[0],
cache = memoized.cache;
if (cache.has(key)) {
return cache.get(key);
}
var result = func.apply(this, args);
memoized.cache = cache.set(key, result) || cache;
return result;
};
memoized.cache = new (memoize.Cache || MapCache)();
return memoized;
}
```
原文有提到缓存策略多种多样,而 Lodash 将缓存策略简化为 key 交给用户自己管理,看这段代码:
```jsx
key = resolver ? resolver.apply(this, args) : args[0];
```
也就是缓存的 key 默认是执行函数时第一个参数,也可以通过 `resolver` 拿到参数处理成新的缓存 key。
在执行函数时也传入了参数 `func.apply(this, args)`
最后 `cache` 也不再使用默认的 Map,而是允许用户自定义 `lodash.memoize.Cache` 自行设置,比如设置为 WeakMap:
```jsx
_.memoize.Cache = WeakMap;
```
### 什么时候不适合用缓存
以下两种情况不适合用缓存:
1. 不经常执行的函数。
2. 本身执行速度较快的函数。
对于不经常执行的函数,本身就不需要利用缓存提升执行效率,而缓存反而会长期占用内存。对于本身执行速度较快的函数,其实大部分简单计算速度都很快,使用缓存后对速度没有明显的提升,同时如果计算结果比较大,反而会占用存储资源。
对于引用的变化尤其重要,比如如下例子:
```jsx
function addName(obj, name){
return {
...obj,
name:
}
}
```
`obj` 添加一个 key,本身执行速度是非常快的,但添加缓存后会带来两个坏处:
1. 如果 `obj` 非常大,会在闭包存储完整 `obj` 结构,内存占用加倍。
2. 如果 `obj` 通过 mutable 方式修改了,则普通缓存函数还会返回原先结果(因为对象引用没有变),造成错误。
如果要强行进行对象深对比,虽然会避免出现边界问题,但性能反而会大幅下降。
## 4 总结
函数缓存非常有用,但并不是所有场景都适用,因此千万不要极端的将所有函数都添加缓存,仅限于计算耗时、可能重复利用多次,且是纯函数的。
> 讨论地址是:[精读《函数缓存》· Issue #261 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/261)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,109 @@
## 1 引言
[「可视化搭建系统」——从设计到架构,探索前端的领域和意义](https://juejin.im/post/6854573220532748302) 这篇文章主要分析了现阶段可视化搭建的几种表现形式和实现原理,并重点介绍了基于富文本的可视化搭建思路,让人耳目一新。
基于富文本的可视化搭建看似很新颖,但其实早就被广泛使用了,任何一个富文本编辑器几乎都有插入表格功能,这就是一个典型插入自定义组件的场景。
使用过 [语雀](https://www.yuque.com/) 的同学应该知道,这个产品的富文本编辑器可以插入各种各样自定义区块,是 “最像搭建” 的富文本编辑器。
那么积木式搭建和富文本搭建存在哪些差异,除了富文本更倾向于记录静态内容外,还有哪些差异,两者是否可以结合?本文将围绕这两点进行讨论。
## 2 精读
还是先顺着原文谈谈对可视化搭建的理解:
可视化搭建是通过可视化方式代替开发。**前端代码开发主要围绕的是 html + js + css**,那么无论是 markdown 语法,还是创建另一套模版语言亦或 JSON 构成的 DSL,**都是用一种 dsl + 组件 + css 的方式代替 html + js + css**,可视化搭建则更进一步,用 ui 代替了 dsl + 组件,**即精简为 ui 操作 + css**。
可以看到,这种转换的推演过程存在一定瑕疵,因为每次转换都有部分损耗:
**用 dsl + 组件 代替 html + js。**
如果 dsl 拓展得足够好,理论上可以达到 html 的水平,尤其在垂直业务场景是不需要那么多特殊 html 标签的。
但用组件代替 js 就有点奇怪了,首先并不是所有 js 逻辑都沉淀在组件里,一定有组件间的联动逻辑是无法通过一个组件 js 完成的,另一方面如果将 js 逻辑寄托在组件代码里,本质上是没有提效的,用源码开发项目与开发搭建平台的组件都是 pro code,更极端一点来说,无论是组件间联动还是整个应用都可以用一个组件来写,那搭建平台就无事可做了,这个组件也成了整个应用,game over。
为了弥补这块缺憾,低代码能力的呼声越来越高,而低代码能力的核心在于设计是否合理,比如暴露哪些 API 可以覆盖大部分需求?写多少代码合适,如何以最小 API 透出最大弥补组件间缺失的 js 能力?目前来看,以状态数据驱动的低代码是相对优雅的。
**用 ui 操作 代替 dsl + 组件。**
UI 操作并不是标准的,相比直接操作模版或者 JSON DSL,UI 化后就仁者见仁智者见智了,但 UI 化带来的效率提升是巨大的,因为所见即所得是生产力的源泉,从直观的 UI 布局来看,就比维护代码更轻松。但 UI 化也存在两个问题,一个是可能有人觉得不如 markdown 效率高,另一个是功能有丢失。
对于第一点 UI 操作效率不如 markdown 高,可能很多程序员都崇尚用 markdown 维护文档而不是富文本,原因是觉得程序员维护代码的效率反而比所见即所得高,但那可能是错觉,原因是还没有遇到好用的富文本编辑器,体验过语雀富文本编辑器后,相信大部分程序员都不会再想回头写 markdown。当然语雀富文本战胜 markdown 的原因有很多,我觉得主要两点是吸收并兼容了 markdown 操作习惯,与支持了更多仅 UI 能做到的拓展能力,对 markdown 形成降维打击。
第二点功能丢失很好理解,markdown 有一套标准语法和解析器可以验证,但 UI 操作并没有标准化,也没有独立验证系统,如果无法回退到源码模式,UI 没有实现的功能就做不到。
回到富文本搭建上,其实富文本搭建和普通网页构建并没有本质区别。html 是超文本标记语言,富文本是跨平台文档格式,从逻辑上这两个格式是可以互转的,只要富文本规则作出足够多的拓展,就可以大致覆盖 html 的能力。
但富文本搭建有着显著的特征,就是光标。
### 积木式搭建和富文本搭建的区别
富文本以文本为中心,因此编辑文字的光标会常驻,编辑的核心逻辑是排版文字,并考虑如何在文字周围添加一些自定义区块。
有了光标后,圈选也非常重要,因为大家编辑文字时有一种很自然的想法是,任何文字圈选后复制,可以粘贴到任何地方,那么所有插入到富文本中的自定义组件也要支持被圈选,被复制。
实际上富文本内插入自定义区块也可以转换为积木式搭建方案解决,比如下面的场景:
```text
文本 A
图表 B
文本 C
```
我们在文本 A 与 文本 C 之间插入图表 B,也可以理解为拖拽了三个组件:文本组件 A + 图表组件 B + 文本组件 C,然后分别编辑这三个组件,微调样式后可以达到与富文本一样的编辑效果,甚至加上自由布局后,在布局能力上会超越富文本。
虽然功能层面上富文本略有输给积木式搭建,但富文本在编辑体验上是胜出的,对于文字较多的场景,我们还是会选择富文本方式编辑而不是积木式搭建拖拽 N 个文本组件。
所以微软 OneNote 也吸取了这个经验,毕竟笔记本主要还是记录文字,因此还是采用富文本的编辑模式,但创造性的加入了一个个独立区块,点击任何区域都会创造一个区块,整个文档可以由一个区块构成,也可以是多个区块组合而成,这样对于连贯性的文字场景可以采用一个富文本区块,对于自定义区块较多,比如大部分是图片和表格的,还可以回到积木式搭建的体验。由于 OneNote 采用绝对定位模拟流式布局的思路,当区块重叠时还可以自动挤压底部区块,因此多区块模式下编辑体验还是相对顺畅的。
可以看出来这是一种结合的尝试,从前端角度来看,富文本本质上是对一个 div 进行 contenteditable 申明,那么一个应用可以整体是 contenteditable 的,也可以局部几个区块是,这种代码层面的自由度体现在搭建上就是积木式搭建可以与富文本搭建自由结合。
### 积木式搭建与富文本搭建如何结合
对于积木式搭建来说,富文本只是其中一个组件,在不考虑有富文本组件时是完全没有富文本能力的。比如一个搭建平台只提供了几个图表和基础控件,你是不可能在其基础上使用富文本能力的,甚至连写静态文本都做不到。
所以富文本只是搭建中一个组件,就像 contenteditable 也只能依附于一个标签,整个网页还是由标签组成的。但对于一个提供了富文本组件的积木式搭建系统来说,文字与控件混排又是一个痛点,毕竟要以一个个区块组件的方式去拖拽文本节点,成本比富文本模式大得多。
所以理想情况是富文本与整个搭建系统使用同一套 DSL 描述结构,富文本只是在布局上有所简化,简化为简单的平铺模式即可,但因为 DSL 描述打通,富文本也可以描述使用搭建提供的任意组件嵌套在内,所以只要用户愿意,可以将富文本组件拉到最大,整个页面都基于富文本模式去搭建,这就变成了富文本搭建,也可以将富文本缩小,将普通控件以积木方式拖拽到画布中,走积木式搭建路线。
用代码方式描述积木式搭建:
```html
<bar-chart />
<div>
<p>header</p>
<line-chart />
<p>footer</p>
</div>
```
上述模式需要拖拽 `bar-chart``div``p``line-chart``p` 共 5 个组件。富文本模式则类似下面的结构:
```html
<bar-chart />
<div contenteditable>
<p>header</p>
<line-chart />
<p>footer</p>
</div>
```
只要拖拽 `bar-chart``div` 两个组件即可,`div` 内部的文字通过光标输入,`line-chart` 通过富文本某个按钮或者键盘快捷键添加。
可以看到虽然操作方式不同,但本质上描述协议并没有本质区别,我们理论上可以将任何容器标签切换为富文本模式。
## 3 总结
富文本是一种重要的交互模式,可以基于富文本模式做搭建,也可以在搭建系统中嵌入富文本组件,甚至还可以追求搭建与富文本的结合。
富文本组件既可以是搭建系统中一个组件,又可以在内部承载搭建系统的所有组件,做到这一步才算是真正发挥出富文本的潜力。
> 讨论地址是:[精读《可视化搭建思考 - 富文本搭建》· Issue #262 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/262)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,185 @@
## 1 引言
本周跟着 [Tasks, microtasks, queues and schedules](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/) 这篇文章一起深入理解这些概念间的区别。
先说结论:
- Tasks 按顺序执行,浏览器可能在 Tasks 之间执行渲染。
- Microtasks 也按顺序执行,时机是:
- 如果没有执行中的 js 堆栈,则在每个回调之后。
- 在每个 task 之后。
## 2 概述
### Event Loop
在说这些概念前,先要介绍 Event Loop。
首先浏览器是多线程的,每个 JS 脚本都在单线程中执行,每个线程都有自己的 Event Loop,同源的所有浏览器窗口共享一个 Event Loop 以便通信。
Event Loop 会持续循环的执行所有排队中的任务,浏览器会为这些任务划分优先级,按照优先级来执行,这就会导致 Tasks 与 Microtasks 执行顺序与调用顺序的不同。
### promise 与 setTimeout
看下面代码的输出顺序:
```js
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
Promise.resolve()
.then(function () {
console.log("promise1");
})
.then(function () {
console.log("promise2");
});
console.log("script end");
```
正确答案是 `script start`, `script end`, `promise1`, `promise2`, `setTimeout`,在线程中,同步脚本执行优先级最高,然后 promise 任务会存放到 MicrotaskssetTimeout 任务会存放到 TasksMicrotasks 会优先于 Tasks 执行。
Microtasks 中文可以翻译为微任务,只要有 Microtasks 插入,就会不断执行 Microtasks 队列直到结束,在结束前都不会执行到 Tasks。
### 点击冒泡 + 任务
下面给出了更复杂的例子,提前说明后面的例子 Chrome、Firefox、Safari、Edge 浏览器的结果完全不一样,但只有 Chrome 的运行结果是对的!为什么 Chrome 是对的呢,请看下面的分析:
```html
<div class="outer">
<div class="inner"></div>
</div>
```
```js
// Let's get hold of those elements
var outer = document.querySelector(".outer");
var inner = document.querySelector(".inner");
// Let's listen for attribute changes on the
// outer element
new MutationObserver(function () {
console.log("mutate");
}).observe(outer, {
attributes: true,
});
// Here's a click listener…
function onClick() {
console.log("click");
setTimeout(function () {
console.log("timeout");
}, 0);
Promise.resolve().then(function () {
console.log("promise");
});
outer.setAttribute("data-random", Math.random());
}
// …which we'll attach to both elements
inner.addEventListener("click", onClick);
outer.addEventListener("click", onClick);
```
点击 `inner` 区块后,正确输出顺序应该是:
```text
click
promise
mutate
click
promise
mutate
timeout
timeout
```
逻辑如下:
1. 点击触发 `onClick` 函数入栈。
2. 立即执行 `console.log('click')` 打印 `click`
3. `console.log('timeout')` 入栈 Tasks。
4. `console.log('promise')` 入栈 microtasks。
5. `outer.setAttribute('data-random')` 的触发导致监听者 `MutationObserver` 入栈 microtasks。
6. `onClick` 函数执行完毕,此时线程调用栈为空,开始执行 microtasks 队列。
7. 打印 `promise`,打印 `mutate`,此时 microtasks 已空。
8. 执行冒泡机制,outer div 也触发 `onClick` 函数,同理,打印 `promise`,打印 `mutate`
9. 都执行完后,执行 Tasks,打印 `timeout`,打印 `timeout`
### 模拟点击冒泡 + 任务
如果将触发 `onClick` 行为由点击改为:
```js
inner.click();
```
结果会不同吗?答案是会(单元测试与用户行为不符合,单测也有无解的时候)。然而四大浏览器的执行结果也是完全不一样,但从逻辑上讲仍然 Chrome 是对的,让我们看下 Chrome 的结果:
```text
click
click
promise
mutate
promise
timeout
timeout
```
逻辑如下:
1. `inner.click()` 触发 `onClick` 函数入栈。
2. 立即执行 `console.log('click')` 打印 `click`
3. `console.log('timeout')` 入栈 Tasks。
4. `console.log('promise')` 入栈 microtasks。
5. `outer.setAttribute('data-random')` 的触发导致监听者 `MutationObserver` 入栈 microtasks。
6. 由于冒泡改为 js 调用栈执行,所以此时 js 调用栈未结束,不会执行 microtasks,反而是继续执行冒泡,outer 的 `onClick` 函数入栈。
7. 立即执行 `console.log('click')` 打印 `click`
8. `console.log('timeout')` 入栈 Tasks。
9. `console.log('promise')` 入栈 microtasks。
10. `MutationObserver` 由于还没调用,因此这次 `outer.setAttribute('data-random')` 的改动实际上没有作用。
11. js 调用栈执行完毕,开始执行 microtasks,按照入栈顺序,打印 `promise``mutate``promise`
12. microtasks 执行完毕,开始执行 Tasks,打印 `timeout``timeout`
## 3 精读
基于任务调度这么复杂,且浏览器实现方式很不同,下面两件事是我很不推荐的:
1. 业务逻辑 “巧妙” 依赖了 microtasks 与 Tasks 执行逻辑的微妙差异。
2. 死记硬背调用顺序。
且不说依赖了调用顺序的业务逻辑本身就很难维护,不同浏览器之间对任务调用顺序还是不同的,这可能源于对 W3C 标准规范理解的偏差,也可能是 BUG,这会导致依赖于此的逻辑非常脆弱。
虽然上面两个例子非常复杂,但我们也不必把这个例子当作经典背诵,只要记住文章开头提到的执行逻辑就可以推导:
- Tasks 按顺序执行,浏览器可能在 Tasks 之间执行渲染。
- Microtasks 也按顺序执行,时机是:
- 如果没有执行中的 js 堆栈,则在每个回调之后。
- 在每个 task 之后。
记住 `Promise``Microtasks``setTimeout``Tasks`JS 一次 Event Loop 完毕后,即调用栈没有内容时才会执行 `Microtasks` -> `Tasks`,在执行 `Microtasks` 过程中插入的 `Microtasks` 会按顺序继续执行,而执行 `Tasks` 中插入的 `Microtasks` 得等到调用栈执行完后才继续执行。
上面说的内容都是指一次 Event Loop 时立即执行的优先级,不要和执行延迟时间弄混淆了。
把 JS 线程的 Event Loop 当作一个函数,函数内同步逻辑执行优先级是最高的,如果遇到 `Microtasks``Tasks` 就会立即记录下来,当一次 Event Loop 执行完后立即调用 `Microtasks`,等 `Microtasks` 队列执行完毕后可能进行一些渲染行为,等这些浏览器操作完成后,再考虑执行 `Tasks` 队列。
## 4 总结
最后,还是要强调一句,不要依赖 `Microtasks``Tasks` 的执行顺序,尤其在申明式编程环境中,我们可以把 `Microtasks``Tasks` 都当作是异步内容,在渲染时做好状态判断即可,不用关心先后顺序。
> 讨论地址是:[精读《Tasks, microtasks, queues and schedules》· Issue #264 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/264)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
+306
View File
@@ -0,0 +1,306 @@
[spring](https://spring.io/) 是 Java 非常重要的框架,且蕴含了一系列设计模式,非常值得研究,本期就通过 [Spring 学习](https://www.cnblogs.com/wmyskxz/p/8820371.html) 这篇文章了解一下 spring。
## spring 为何长寿
spring 作为一个后端框架,拥有 17 年历史,这在前端看来是不可思议的。前端几乎没有一个框架可以流行超过 5 年,就最近来看,react、angular、vue 三大框架可能会活的久一点,他们都是前端相对成熟阶段的产物,我们或多或少可以看出一些设计模式。然而这些前端框架与 spring 比起来还是差距很大,我们来看看 spring 到底强大在哪。
### 设计模式
设计模式是一种思想,不依附于任何编程语言与开发框架。比如你学会了工厂设计模式,可以在后端用,也可以转到前端用,可以在 Go 语言用,也可以在 Typescript 用,可以在 React 框架用,也可以在 Vue 里用,所以设计模式是一种具有迁移能力的知识,学会后可以受益整个职业生涯,而语言、框架则不具备迁移性,前端许多同学都把精力花在学习框架特性上,遇到前端技术迭代时期就尴尬了,这就是为什么大公司面试要问框架原理,就是看看你能否抓住一些不变的东西,所以洋洋洒洒的说上下文相关的细节也不是面试官想要的,真正想听到的是你抽象后对框架原理共性的总结。
spring 框架就用到了许多设计模式,包括:
工厂模式:用工厂生产对象实例来代替原始的 new。所谓工厂就是屏蔽实例话的细节,调用处无需关心实例化对象需要的环境参数,提升可维护性。spring 的 BeanFactory 创建 bean 对象就是工厂模式的体现。
代理模式:允许通过代理对象访问目标对象。Spring 实现 AOP 就是通过动态代理模式。
单例模式:单实例。spring 的 bean 默认都是单例。
包装器模式:将几个不同方法通用部分抽象出来,调用时通过包装器内部引导到不同的实现。比如 spring 连接多种数据库就使用了包装器模式简化。
观察者模式:这个前端同学很熟悉,就是事件机制,spring 中可以通过 ApplicationEvent 实践观察者模式。
适配器模式:通过适配器将接口转换为另一个格式的接口。spring AOP 的增强和通知就使用了适配器模式。
模板方法模式:父类先定义一些函数,这些函数之间存在调用关联,将某些设定为抽象函数等待子类继承时去重写。spring 的 `jdbcTemplate``hibernateTemplate` 等数据库操作类使用了模版方法模式。
### 全家桶
spring 作为一个全面的 java 框架,提供了系列全家桶满足各种场景需求:spring mvc、spring security、spring data、spring boot、spring cloud。
- spring boot:简化了 spring 应用配置,约定大于配置的思维。
- spring data:是一个数据操作与访问工具集,比如支持 jdbc、redis 等数据源操作。
- spring cloud:是一个微服务解决方案,基于 spring boot,集成了服务发现、配置管理、消息总线、负载均衡、断路器、数据监控等各种服务治理能力。
- spring security:支持一些安全模型比如单点登录、令牌中继、令牌交换等。
- spring mvcMVC 思想的 web 框架。
## IOC
IOCInverse of Control)控制反转。IOC 是 Spring 最核心部分,因为所有对象调用都离不开 IOC 模式。
假设我们有三个类:Country、Province、City,最大的类别是国家,其次是省、城市,国家类需要调用省类,省类需要调用城市类:
```java
public class Country {
private Province province;
public Country(){
this.province = new Province()
}
}
public class Province {
private City city;
public Province(){
this.city = new City()
}
}
public class City {
public City(){
// ...
}
}
```
假设来了一个需求,City 实例化时需增加人口(people)参数,我们就要改动所有类代码:
```java
public class Country {
private Province province;
public Country(int people){
this.province = new Province(people)
}
}
public class Province {
private City city;
public Province(int people){
this.city = new City(people)
}
}
public class City {
public City(int people){
// ...
}
}
```
那么在真实业务场景中,一个底层类可能被数以千计的类使用,这么改显然难以维护。IOC 就是为了解决这个问题,它使得我们可以只改动 City 的代码,而不用改动其他类的代码:
```java
public class Country {
private Province province;
public Country(Province province){
this.province = province
}
}
public class Province {
private City city;
public Province(City city){
this.city = city
}
}
public class City {
public City(int people){
// ...
}
}
```
可以看到,增加 `people` 属性只需要改动 city 类。然而这样做也是有成本的,就是类实例化步骤会稍微繁琐一些:
```java
City city = new City(1000);
Province province = new Province(city);
Country country = new Country(province);
```
这就是控制反转,由 Country 依赖 Province 变成了类依赖框架(上面的实例化代码)注入。
然而手动维护这种初始化依赖是繁琐的,spring 提供了 bean 容器自动做这件事,我们只需要利用装饰器 Autowired 就可以自动注入依赖:
```java
@Component
public class Country {
@Autowired
private Province province;
}
@Component
public class Province {
@Autowired
public City city;
}
@Component
public class City {
}
```
实际上这种自动分析并实例化的手段,不仅比手写方便,还能解决循环依赖的问题。在实际场景中,两个类相互调用是很常见的,假设现在有 A、B 类相互依赖:
```java
@Component
public class A {
@Autowired
private B b;
}
@Component
public class B {
@Autowired
public A a;
}
```
那么假设我们想获取 A 实例,会经历这样一个过程:
```text
获取 A 实例 -> 实例化不完整 A -> 检测到注入 B -> 实例化不完整 B -> 检测到注入 A -> 注入不完整 A -> 得到完整 B -> 得到完整 A -> 返回 A 实例
```
其实 spring 仅支持单例模式下非构造器的循环依赖,这是因为其内部有一套机制,让 bean 在初始化阶段先提前持有对方引用地址,这样就可以同时实例化两个对象了。
除了方便之外,IOC 配合 spring 容器概念还可以使获取实例时不用关心一个类实例化需要哪些参数,只需要直接申明获取即可,这样在类的数量特别多,尤其是大量代码不是你写的情况下,不需要阅读类源码也可以轻松获取实例,实在是大大提升了可维护性。
说到这就提到了 Bean 容器,在 spring 概念中,Bean 容器是对 class 的加强,如果说 Class 定义了类的基本含义,那 Bean 就是对类进行使用拓展,告诉我们应该如何实例化与使用这个类。
举个例子,比如利用注解描述的这段 Bean 类:
```java
@Configuration
public class CityConfig {
@Scope("prototype")
@Lazy
@Bean(initMethod = "init", destroyMethod = "destroy")
public City city() {
return new City()
}
}
```
可以看到,额外描述了是否延迟加载,是否单例,初始化与析构函数分别是什么等等。
下面给出一个从 Bean 获取实例的例子,采用比较古老的 xml 配置方式:
```java
public interface City {
Int getPeople();
}
```
```java
public class CityImpl implements City {
public Int getPeople() {
return 1000;
}
}
```
接下来用 xml 描述这个 bean:
```xml
<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd" default-autowire="byName">
<bean id="city" class="xxx.CityImpl"/>
</beans>
```
`bean` 支持的属性还有很多,由于本文并不做入门教学,就不一一列举了,总之 `id` 是一个可选的唯一标志,接下来我们可以通过 `id` 访问到 city 的实例。
```java
public class App {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("classpath:application.xml");
// 从 context 中读取 Bean,而不 new City()
City city = context.getBean(City.class);
System.out.println(city.getPeople());
}
}
```
可以看到,程序任何地方使用 city 实例,只需要调用 `getBean` 函数,就像一个工厂把实例化过程给承包了,我们不需要关心 City 构造函数要传递什么参数,不需要关心它依赖哪些其他的类,只要这一句话就可以拿到实例,是不是在维护项目时省心了很多。
## AOP
AOPAspect Oriented Program)面向切面编程。
AOP 是为了解决主要业务逻辑与次要业务逻辑之间耦合问题的。主要业务逻辑比如登陆、数据获取、查询等,次要业务逻辑比如性能监控、异常处理等等,次要业务逻辑往往有:不重要、和业务关联度低、贯穿多处业务逻辑的特性,如果没有好的设计模式,只能在业务代码里将主要逻辑与次要逻辑混合起来,但 AOP 可以做到主要、次要业务逻辑隔离。
使用 AOP 就是在定义在哪些地方(类、方法)切入,在什么地方切入(方法前、后、前后)以及做什么。
比如说,我们想在某个方法前后分别执行两个函数计算执行时间,下面是主要业务逻辑:
```java
@Component("work")
public class Work {
public void do() {
System.out.println("执行业务逻辑");
}
}
```
再定义切面方法:
```java
@Component
@Aspect
class Broker {
@Before("execution(* xxx.Work.do())")
public void before(){
// 记录开始时间
}
@After("execution(* xxx.Work.do())")
public void after(){
// 计算时间
}
}
```
再通过 xml 定义扫描下这两个 Bean,就可以在运行 `work.do()` 之前执行 `before()`,之后执行 `after()`
还可以完全覆盖原函数,利用 `joinPoint.proceed()` 可以执行原函数:
```java
@Component
@Aspect
class Broker {
@Around("execution(* xxx.Work.do())")
public void around(ProceedingJoinPoint joinPoint) {
// 记录开始时间
try {
joinPoint.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
// 计算时间
}
}
```
关于表达式 `"execution(* xxx.Work.do())"` 是用正则的方式匹配,`*` 表示任意返回类型的方法,后面就不用解释了。
可以看到,我们可以在不修改原方法的基础上,在其执行前后增加自定义业务逻辑,或者监控其报错,非常适合做次要业务逻辑,且由于不与主要业务逻辑代码耦合,保证了代码的简洁,且次要业务逻辑不容易遗漏。
## 总结
IOC 特别适合描述业务模型,后端天然需要这一套,然而随着前端越做越重,如果某个业务场景下需要将部分业务逻辑放到前端,也是非常推荐使用 IOC 设计模式来做,这是后端沉淀了近 20 年的经验,没有必要再另辟蹊径。
AOP 对前端有帮助但没有那么大,因为前端业务逻辑较为分散,如果要进行切面编程,往往用 `window` 事件监听来做会更彻底,可能这都是前端没有流行 AOP 的原因。当然前端约定大于配置的趋势下,比如打点或监控都集成到框架内部,往往也做到了业务代码无感,剩下的业务代码也就没有 AOP 的需求。
最后,spring 的低侵入式设计,使得业务代码不用关心框架,让业务代码能够快速在不同框架间切换,这不仅方便了业务开发者,更使得 spring 走向成功,这是前端还需要追赶的。
> 讨论地址是:[精读《Spring 概念》· Issue #265 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/265)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,783 @@
bi-designer 是阿里数据中台团队自研的前端搭建引擎,基于它开发了阿里内部最大的数据分析平台,以及阿里云上的 QuickBI。
> bi-designer 目前没有开源,因此文中使用的私有 npm 源 `@alife/bi-designer` 是无法在公网访问的。
本文介绍 bi-designer 设计器的使用 API。
bi-designer 设计有如下几个特点:
- **心智统一:编辑模式与渲染模式统一**。
- **通用搭建:支持接入任意通用 npm 组件**。
- **低入侵:围绕数据分析能力做了增强,但对组件代码无入侵**。
## 渲染画布
做搭建,第一步是将画布渲染出来,需要用到 `Designer``Canvas` 组件:
```jsx
import { Designer, Canvas } from '@alife/bi-designer'
export () => (
<Designer>
<Canvas />
</Designer>
)
```
- `Designer`:数据容器,用于管理渲染引擎数据流。
- 参数 `defaultPageSchema`:页面 DSL 默认值。
- 参数 `defaultMode`:控制编辑渲染状态,`edit` or `render`
- `Canvas`:渲染画布的所有组件,会根据 DSL 结构将组件一一渲染出来。
## 编辑模式
编辑模式 = 渲染画布(编辑模式)+ 拓展一些自定义面板。
```jsx
import { Designer, Canvas } from '@alife/bi-designer'
export () => (
<Designer defaultMode="edit">
<div>Header</div>
<Canvas />
<div>Footer</div>
</Designer>
)
```
编辑模式的拓展采用了 JSX 模式,没有增加任何新的语法,只要放置任意数量的组件,并将画布 `Canvas` 摆放在想要的位置即可。
`defaultMode` 描述了当前引擎所处状态,有 `edit``render` 两个可选值,可以通过 `{ mode } = useDesigner(modeSelector)` 获取。bi-designer 没有对 `mode` 做任何特殊处理,我们可以在 panel、组件中判断不同的 `mode` 走不同的逻辑,以此区分编辑与渲染态。
## 页面 DSL 结构
`pageSchema` 描述了页面 DSL 信息,其结构是一个 `Map<组件 id, 组件实例信息>`
这里统一一下名词:
- 组件实例信息:`componentInstance`
- 组件元信息:`componentMeta`
那么 `pageSchema` 的结构大致如下:
```json
{
"componentInstances": {
"1": {
"id": "1",
"componentName": "root",
},
"2": {
"id": "2",
"parentId": "1",
"componentName": "button",
}
}
}
```
根据 `id` `parentId` 关系描述了组件父子关系,对于同一个父节点在流式布局下的顺序,还会增加 `index` 标记顺序。
## 注册组件
DSL 描述信息中最重要的是 `componentName`,为了告诉渲染引擎这个组件是什么,我们需要将组件元信息(`componentMetas`)传递给 `Designer`
```jsx
import { Designer, Canvas, Interfaces } from '@alife/bi-designer'
export () => (
<Designer componentMetas={componentMetas}>
<Canvas />
</Designer>
)
const componentMetas: Interfaces.ComponentMetas = {
button: {
componentName: 'button',
element: Button
}
}
```
关于 `componentMeta` 会在下一篇精读详细介绍,这里只说明两个最重要的属性:
- `componentName`:组件名,唯一。
- `element`:组件 UI 对象,对应一个 React 组件实例。
注意这里就留下了不少拓展空间,`componentMetas` 可以存储在服务端,`element` 可以远程异步加载,也可以在项目代码中固化,但传递给渲染引擎的 API 是固定的。
## 布局
bi-designer 支持流式布局、磁贴布局、自由布局三种模式,通过 `Designer.layout` 属性定义:
```jsx
import { Designer, Canvas, Interfaces } from '@alife/bi-designer'
import { LayoutMover } from '@alife/bi-designer-stream-layout'
export () => (
<Designer layout={LayoutMover}>
<Canvas />
</Designer>
)
```
我们提供了三种不同的布局包,切换对应的包即可切换布局,你甚至可以再包裹一层,通过代码控制在运行时切换布局。
`layout` 会包裹在每个组件外层,无论是流式、磁贴还是自由布局,都可以通过附着在每个组件外层来实现。
## 操作/获取画布内容
只要在数据容器 `Designer` 下,就可以通过 `useDesigner()` 获取画布信息或者修改画布内容。
举个例子,比如实现组件配置面板,需要获取到 **当前选中组件**,以及实现操作 **更新 DSL 中某个组件信息**
```jsx
import { Designer, Canvas, useDesigner, selectedComponentsSelector } from '@alife/bi-designer';
const EditPanel = () => {
const { updateComponentById, selectedComponents } =
useDesigner(selectedComponentsSelector());
// 在合适的时候调用 updateComponentById 更新 selectedComponents
// 渲染组件配置表单..
}
export () => (
<Designer>
<Canvas />
<EditPanel />
</Designer>
)
```
我们在 `Canvas` 下面渲染了一个自定义组件 `EditPanel` 作为组件配置面板,这个配置面板中,最重要的是这块代码:
```jsx
import { useDesigner, selectedComponentsSelector } from '@alife/bi-designer';
const { updateComponentById, selectedComponents } =
useDesigner(selectedComponentsSelector());
```
- `useDesigner` 是 React Hook,导出的函数都是静态的,不会因为画布信息变更而导致组件重渲染。
- 如果需要监听一些会变化的元素,比如当前选中组件,就需要用 Selector 完成,当这些信息变更时,使用了这些 Selector 的组件也会重渲染,具体 Selector 有很多,比如:
- `selectedComponentsSelector`: 当前选中的组件。
- `pageSchemaSelector`: 当前画布 DSL。
- `modeSelector`: 当前渲染模式。等等。
- 对画布组件操作有几个重要的静态方法,包括:
- `updateComponentById`: 更新某个 id 组件信息。
- `addComponent`: 添加组件。
- `deleteComponent`: 删除组件。
- `moveComponent`: 移动组件。等等。
- 除此之外,`useDesigner` 还提供了很多有用的方法,在用到时再介绍。
## 主题风格
通过 `pageSchema.theme` 设置主题风格:
```jsx
import { Designer } from '@alife/bi-designer'
const App = () => (
<Designer
defaultPageSchema={{
theme: { primaryColor: '#333' }
}}
/>
)
```
我们也可以在运行时使用 `setTheme` 动态修改主题风格,做到动态切换主题:
```jsx
const { setTheme, theme } = useDesigner();
return <Button onClick={() => {
setTheme({
...theme,
primaryColor: '#ffffff'
})
}} />
```
这些主题颜色,组件可以通过 css 变量拿到:
```css
.ok-button {
color: var(--primaryColor);
}
```
## 获取组件数据
数据分析引擎中,组件是由数据驱动展示的,这些数据可能来自 OLAP 数据集,或者普通 URL 接口,但无论如何数据都是一个组件重要组成部分,因此对组件的取数与数据操作是 bi-designer 的一个重点。
可以利用 `fetchStateSelector` 获取任意组件的数据信息,包括取数状态、数据、是否有查询错误等:
```jsx
import { useDesigner, fetchStateSelector } from '@alife/bi-designer';
const App = () => {
const { fetchState } = useDesigner(fetchStateSelector(componentInstance.id));
console.log(
fetchState.isFetching, // 是否在取数中
fetchState.isFilterReady, // 筛选条件是否准备好了
fetchState.data, // 取数结果
fetchState.error, // 取数错误,如果取数阶段报错的话
)
}
```
bi-designer 将所有组件的取数状态统一管理,因此可以跨组件获取数据信息,实现一些复杂需求:比如某些组件配置面板要获取组件取数结果填充配置表单。
## 组件加载器
组件加载器 `ComponentLoader` 可以加载任意组件, `Canvas` 就是基于此实现的。
### 加载画布中已有组件
通过申明 id 加载一个画布中已有组件,与其共享同一套数据:
```jsx
import { ComponentLoader } from '@alife/bi-designer'
const App = () => {
return <ComponentLoader id="some-id-already-exist" />
}
```
### 加载一个额外的新组件
如果这个组件不需要响应事件,只是做简单的渲染,那就不需要记录到数据流中,此时仅申明 `componentName` 即可:
```jsx
import { ComponentLoader } from '@alife/bi-designer'
const App = () => {
return <ComponentLoader componentName="button" />
}
```
但这种方式加载的组件存在如下问题:
- 其组件 `id` 不会存储到 `pageSchema` ,后端可能无法做一些校验。
- 无法响应事件,因为事件响应前提是组件信息存在于 `pageSchema` 中。
### 加载一个有事件功能的额外新组件
通过申明 `id``componentName` 加载一个全新组件,为了在其销毁时做有效清理,请将其 id 记录到 `useKeepComponentLoaders` 中。
```jsx
import { ComponentLoader, useDesigner } from '@alife/bi-designer'
const App = () => {
const { useKeepComponentLoaders } = useDesigner();
useKeepComponentLoaders(["1"])
return <ComponentLoader id="1" componentName="button" />
}
```
通过此方式加载的组件会在其渲染时记录到 `pageSchema` 中。
> 注意,此时 id 与仅写一个 id 时含义不同,这个 id 在当前父组件作用域下唯一就可以。
## 全屏功能
所有组件实例都可以存在副本,共享一套状态数据,可以通过 `ComponentLoader` 随时渲染一个组件副本:
```jsx
import { ComponentLoader } from '@alife/bi-designer'
// ... 任意可拿到 componentInstance 处
return (
<ComponentLoader id={componentInstance.id} />
)
```
那么全屏就是将组件渲染到一个新容器内,非常 easy。
## 局部配置覆盖
可以通过 `DesignerProvider` 实现干涉其子元素 `useDesigner` 获取信息的能力:
```jsx
import { DesignerProvider, ComponentLoader } from '@alife/bi-designer';
// 某个组件内,或者某个 UI 内以 render 模式加载组件
// ...
return (
<DesignerProvider mode="render">
<ComponentLoader id={id} />
</DesignerProvider>
)
```
举个例子,比如在编辑模式下要全屏预览组件,可以通过 `ComponentLoader + id` 把某个画布组件实例渲染到弹出的 Modal 中,但问题是当前属于编辑模式,组件还可以被拖拽甚至响应编辑效果,我们只想让局部变成渲染状态,怎么做呢?
答案就是通过 `DesignerProvider` 包裹这个 Modal,这个 Modal 内部无论是组件还是其他 Panel 代码通过 `const { mode } = useDesigner(modeSelector)` 拿到的值都会被强制覆盖为 `render`
## 配置国际化
国际化信息在 `pageSchema.i18n` 定义:
```jsx
import { Designer } from '@alife/bi-designer'
const App = () => (
<Designer
defaultPageSchema={{
i18n: {
"zh-CN": {
你好: "你好",
中国: "中国"
},
"en-US": {
你好: "Hello",
中国: "China"
}
}
}}
defaultLocaleKey="zh-CN"
/>
)
```
- `defaultLocaleKey`: 默认国际化语言,可以通过 `{ setLocaleKey } = useDesigner()` 动态改变。
这样在 DSL 中通过描述 `JSExpression` 表达式的 `this.i18n` 访问:
```json
{
"componentInstances": {
"1": {
"id": "1",
"componentName": "button",
"props": {
"text": {
"type": "JSExpression",
"value": "this.i18n['你好']"
}
}
}
}
}
```
## 容器拓展组件 props
`componentMeta.container` 可以定义组件外层容器,但有的时候我们想在容器做一点事情,比如获取宽高,以 props 的方式传递给子组件。
因为子组件以 `children` 的方式书写不易拓展,因此提供了 `PropsProvider` 来拓展子组件拿到的 props
```jsx
import { Interfaces, PropsProvider } from '@alife/bi-designer'
const ComponentContainer = ({ children }) => {
return (
// 注入 width 和 height
<PropsProvider width={100} height={100}>
{children}
</PropsProvider>
)
}
const Element = ({ width, height }) => {
// width=100
// height=100
}
const componentMeta: Interfaces.ComponentMeta = {
element: Element,
container: ComponentContainer
};
```
上面的例子中,因为 `container` 注入了 `width`,因此组件可以通过 `props.width` 拿到容器注入的值。
## 撤销重做
撤销重做按钮在基于每个搭建系统都有,在 bi-designer 的使用方式是这样:
```jsx
import { useDesigner } from '@alife/bi-designer'
export default () => {
const { undo, redo } = useDesigner()
// 撤销调用 undo()
// 重做调用 redo()
}
```
是不是觉得很简单?是的,因为所有值得撤销重做的操作在引擎内部使用了 `HistoryManager` 管理,因此引擎知道每一个可以被撤销或者重做的操作,直接调用函数即可。
## 组件复制
执行 `copyComponent` 命令即可复制组件,比如:
```jsx
const App() {
const { copyComponent } = useDesigner()
// 复制组件 copyComponent(componentInstance)
}
```
`copyComponent` 的参数分别为:
```jsx
function copyComponent(
componentInstance?: ComponentInstance,
parentId?: string,
index?: number
)
```
- 如不指定 `parentId` ,默认复制到自己父元素下。
- 如不指定 `index` ,默认复制到当前元素下方。
## 组件模版
如果觉得某些组件配置可能被复用,可以在画布组件右上角增加一个 “添加到组件模版” 按钮,bi-designer 也提供了生成、添加组件模版的方法。
### 创建组件模版
利用 `createCombine` 函数从画布中已有组件创建出组件模版,也可以将其生成结果持久化,作为一个固定的组件模版:
```jsx
const ComponentContainer: Interfaces.InnerComponentElement = ({ componentInstance }) => {
const { createCombine } = useDesigner();
const setToCombine = React.useCallback(() => {
// 创建组件模版
const combine = createCombine(componentInstance.id)
}, [createCombine]);
}
```
`createCombine` 的参数就是画布中组件的 `id`
### 添加组件模版到画布
利用 `addCombine` 函数将组件模版添加到画布,第一个参数就是上面生成的 `combine` 对象:
```jsx
const App = () => {
const { addCombine } = useDesigner();
const addComponent = React.useCallback(() => {
// 创建组件模版
const combine = addCombine(combine, parentId)
}, [addCombine]);
}
```
## 渲染完成标识
当画布中所有组件都完成渲染了,可能要做一些监控上报,或者告诉截图软件可以截图了,bi-designer 提供了这种回调时机 `onRendered`:
```jsx
import { Designer } from '@alife/bi-designer'
const App = () => (
<Designer
onRendered={errors => {
errors.map(each => {
// 错误组件 id
console.log(each.id)
// 错误信息
console.log(each.error)
})
// 渲染完毕
}}
/>
)
```
- `errors`: 如果有组件代码报错,引擎会吞掉这个错误保证其他组件正常渲染,并把错误组件的 id 和错误信息返回到这里。
## 自定义数据流
如果 `useDesigner` 提供的数据流无法满足业务需要,可以通过进行自定义拓展。
### 1. 拓展字段
举个例子,我们需要新增一个 `edges` 字段描述当前画布中有哪些 “边节点”:
```jsx
import { Designer } from '@alife/bi-designer';
const App = ({ defaultPageSchema }) => (
<Designer defaultPageSchema={{
...defaultPageSchema,
edges: []
}} />
)
```
可以看到,只要任意拓展 `pageSchema` 即可。
### 2. 通过 useDesigner 拿到拓展字段
首先定义一个 `edgesSelector`
```jsx
import { DesignerState } from '@alife/bi-designer';
export const edgesSelector = () => (state: DesignerState) => {
return {
// 从 pageSchema.edges 读取 edges
edges: state.pageSchema?.edges as Edge[],
};
};
```
在需要读取的地方结合 `useDesigner`
```jsx
import { useDesigner } from '@alife/bi-designer';
import { edgesSelector } from './selector'
const Panel = () => {
// 自带类型
const { edges } = useDesigner(edgesSelector())
}
```
### 3. 通过 useDesigner 修改拓展字段
通过 `setPageSchema` 更新拓展字段:
```jsx
import { useDesigner } from '@alife/bi-designer';
const Panel = () => {
const { setPageSchema } = useDesigner()
const handleChangeEdges = React.useCallback(newEdges => {
setPageSchema(pageSchema => ({
...pageSchema,
newEdges
}))
}, [setPageSchema])
}
```
总结一下,这个拓展字段由业务定义,透过 `useDesigner` 读与改,使业务数据管理方式更聚合。
## 存储临时非结构化数据
对于非结构化数据比如组件 `ref` 是不能存储到数据流的,既不能使用 `setPageSchema`,也不能调用 `updateComponentId` 存储到 `componentInstance` 中。
此时可以利用 `temporary` 进行临时数据存取,要注意非结构化数据是无法监听变化的,引用永远保持不变:
```jsx
import { useDesigner } from '@alife/bi-designer';
const App = () => (
const { temporary } = useDesigner()
// 写
temporary.set('component1', ref)
// 读
console.log(temporary.get('component1'))
)
```
temporary 本质是个 Map,所以拥有 Map 类型所有语法。
## 拦截画布操作
如果你限制某个低配版本只能在画布使用最多 50 个组件,我们需要阻止画布超过 50 个组件的添加,这个场景可以通过 `DesignerProps` 生命周期可以对画布操作进行拦截。
`shouldAddComponents()` 返回 `false` 可以阻止画布添加组件:
```jsx
import { Designer } from '@alife/bi-designer'
const App = () => (
<Designer
shouldAddComponents={({addedComponentInstancesArray, pageSchema}) => {
// 阻止添加
return false
}}
/>
)
```
- `addedComponentInstancesArray` :添加的组件, `ComponentInstance[]` 类型。
`shouldMoveComponents()` 返回 `false` 可以阻止画布移动组件:
```jsx
import { Designer } from '@alife/bi-designer'
const App = () => (
<Designer
shouldmoveComponents={({movedComponentInstancesArray, targetComponentInstance, pageSchema}) => {
// 阻止移动
return false
}}
/>
)
```
- `movedComponentInstancesArray` :移动的组件,`ComponentInstance[]` 类型。
- `taragetComponentInstance` :要移动到的父组件实例信息, `ComponentInstance` 类型。
`shouldDeleteComponents()` 返回 `false` 可以阻止画布删除组件:
```jsx
import { Designer } from '@alife/bi-designer'
const App = () => (
<Designer
shouldDeleteComponents={({deletedComponentInstancesArray, pageSchema}) => {
// 阻止删除
return false
}}
/>
)
```
- `deletedComponentInstancesArray` :删除的组件, `ComponentInstance[]` 类型。
## 仅刷新可视区域组件
默认组件都会以按需加载的方式渲染,即对于不在可视区域的组件,不会触发任何重渲染,以此提升交互操作的效率,以及首屏速度。
对于筛选条件等可能影响到其他组件的组件,可以通过 `ComponentMeta.keepActive` 强制保持激活状态:
```jsx
import { Interfaces } from '@alife/bi-designer'
const componentMeta: Interfaces.ComponentMeta = {
keepActive: true
}
```
- `keepActive`:组件始终保持激活状态,即不出现在可视区域也会被渲染与响应刷新,默认关闭。
对于特殊场景比如截图,可能要求所有组件强制为 `active` 状态,可以通过 `forceActive` 函数实现:
```jsx
import { Interfaces, useDesigner } from '@alife/bi-designer'
const Test: Interfaces.ComponentElement = () => {
const { forceActive, cancelForceActive } = useDesigner()
// forceActive() 强制所有组件 active
// cancelForceActive() 取消强制 active,组件根据实际情况 active
};
```
可以通过 `getSnapshot().actives` 获取任意组件当前瞬时 `active` 状态:
```jsx
import { useDesigner } from '@alife/bi-designer'
const Test = () => {
const { getSnapshot, id } = useDesigner()
// 当前组件激活状态
const active = getSnapshot().actives[id]
};
```
## 上下文数据对象
组件 DSL 描述中,表达式类型(`JSExpression`)可以通过 `this.` 访问到上下文数据对象。上下文数据对象符合如下规则:
- 任何组件都通过配置 `ComponentMeta.stateful` 持有上下文。
- 画布根节点 `root` 一定是 `stateful` 的。
- `JSFunction``JSExpression` 都可通过 `this.state` 访问上下文, `this.setState` 修改上下文。
举例子:
```jsx
// 初始化 pageSchema
const defaultPageSchema: Interfaces.PageSchema = {
componentInstances: {
test1: {
id: 'test1',
componentName: 'test',
parentId: 'jtw4x8ns',
index: 0,
props: {
variable: {
type: 'JSExpression',
value: 'this.state.variable + "%"',
},
onClick: {
type: 'JSFunction',
value: 'function onClick() { this.setState({ variable: 5 }) }',
},
},
}
}
};
```
这个例子中,组件调用 `this.props.onClick` 会修改上下文 `a=5` ,触发后,其 `this.props.variable` 拿到的值会变为 5% 。
任何组件或容器只要设置了 `stateful` 就可以持有状态:
```jsx
import { Interfaces } from '@alife/bi-designer'
const statefulComponentMeta: Interfaces.ComponentMeta = {
stateful: true
}
```
被有状态的容器包裹的组件 `this.state``this.setState` 都局限在当前状态容器内,也就是当前状态容器内组件的 state 是互通的,且一个有状态容器与外部环境是隔离的,可以独立运行。
## 工具类拓展
工具类拓展可以通过上下文访问,如下是拓展方式:
```jsx
import { Interfaces } from '@alife/bi-designer'
// DSL 中增加 utils 描述
const defaultPageSchema: Interfaces.PageSchema = {
utils: [
{
name: 'format',
type: 'function',
content: `function format(str){ return str + '%' }`,
},
]
};
```
- `name` :工具函数名。
- `type` :类型,包括 `npm``umd``function`
- `content` :内容。
用法:
```jsx
JSFunction JSExpression 都可以通过 this.utils 访问工具类拓展函数比如
// DSL 中增加 Expression 描述
const defaultPageSchema: Interfaces.PageSchema = {
componentInstances: {
test: {
id: 'tg43g42f',
componentName: 'expressionComponent',
index: 0,
props: {
variable: {
type: 'JSExpression',
value: 'this.utils.format("100")',
}
},
},
},
};
```
上面的例子中,组件拿到的 `props.variable` 值为 100% 。
## 总结
如果你认真看完了全文,就会发现,bi-designer 是一个集成了数据流的开发框架,而不仅是一个渲染引擎,但却可以和你现有的业务代码友好相处,没有入侵性。
像渲染完成标识、按需渲染、组件加载器、局部配置覆盖等功能是强依赖渲染引擎存在的,因此较难在剥离渲染引擎的条件下转换为代码,因为做 BI 分析工具毕竟不是做研发提效用,业务上没有出码的必要,因此我们会做许多依赖渲染引擎的能力增强。
更多数据分析特性的功能将在下一个话题 API 之组件说明。
> 讨论地址是:[精读《数据搭建引擎 bi-designer API-设计器》· Issue #267 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/267)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
File diff suppressed because it is too large Load Diff
+250
View File
@@ -0,0 +1,250 @@
筛选条件是 BI 搭建的核心概念,我们大部分所说的探索式分析、图表联动也都属于筛选条件的范畴,**其本质就是一个组件对另一个组件的数据查询起到筛选作用**。
## 筛选组件是如何作用的
我们最常见的筛选条件就是表单场景的查询控件,如下图所示:
<img width=300 src="https://img.alicdn.com/tfs/TB107njjRFR4u4jSZFPXXanzFXa-724-302.png">
若干 “具有输出能力” 的组件作为筛选组件,点击查询按钮时触发其作用组件重新取数。
注意这里 “具有输出能力” 的组件不仅是输入框等具有输入性质的组件,其实所有具备交互能力的组件都可以,甚至可以由普通组件承担筛选触发的能力:
<img width=300 src="https://img.alicdn.com/tfs/TB1V.sVgSslXu8jSZFuXXXg7FXa-858-196.png">
一个表格的表头点击也可以触发筛选行为,或者柱状图的一个柱子被点击都可以,只要进行到这层抽象,**组件间联动本质也属于筛选行为**。
同样重要的,筛选作用的组件也可以是具备输入能力的组件:
<img width=450 src="https://img.alicdn.com/tfs/TB1qqrxUpT7gK0jSZFpXXaTkpXa-1280-198.png">
当目标组件是具备筛选能力组件时,这就是筛选联动场景了,所以 **筛选联动也属于普通筛选行为**。至于目标组件触发取数后,是否立即修改其筛选值,进而触发后续的筛选联动,就完全由业务特性决定了。
一个组件也可以自己联动自己筛选,比如折线图点击下钻的场景,就是自己触发了筛选,作用到自己的例子。
## 什么是筛选组件
**任何组件都可以是筛选组件**
可能最容易理解的是输入框、下拉框、日期选择器等具备输入特征的组件,这些组件只能说天然适合作为筛选组件,但不代表系统设计要为这些组件特殊处理。
扩大想一想,其实普通的按钮、表格、折线图等等 **具有展示属性的组件也具有输入特性的一面**,比如按钮被点击时触发查询、单元格被点击时想查询当前城市的数据趋势、折线图某条线被点击时希望自身从年下钻到月等等。
所以 **不存在筛选组件这概念,而是任何组件都具有筛选的能力**,因此筛选是一种任何组件都具有的能力,而不局限在某几个组件上,一旦这么设计,可以做到以下几点:
1. 实现输入类组件到展示类组件的筛选,符合基本筛选诉求。
2. 实现展示类组件到展示类组件的筛选,属于图表联动图表的高级功能。
3. 实现输入类组件到输入类组件的筛选,属于筛选联动功能。
4. 实现组件自身到自身的筛选,实现下钻功能。
下面介绍 bi-designer 的筛选条件设计。
## 筛选条件设计
基于上述分析,bi-designer 在组件元信息中没有增加所谓的筛选组件类型,而是将其设定为一种筛选能力,任何组件都能触发。
### 如何触发筛选
组件调用 `onFilterChange` 即可完成筛选动作:
```jsx
import { useDesigner } from "@alife/bi-designer";
const InputFilter = () => {
const { onFilterChange } = useDesigner();
return (
<input onChange={(event) => () => onFilterChange(event.target.value)} />
);
};
```
但这种开发方式违背了 **低侵入** 的设计理念,我们可以采用组件与引擎解构的方式,让输入框变更的时候直接调用 `props.onChange` ,这个组件保持了最大的独立性:
```jsx
const InputFilter = ({ onChange }) => {
return <input onChange={(event) => () => onChange(event.target.value)} />;
};
```
那渲染引擎怎么将 `onFilterChange` 映射到 `props.onChange` 呢?如下配置 DSL 即可:
```json
{
"props": {
"onChange": {
"type": "JSExpression",
"value": "this.onFilterChange"
}
}
}
```
### 筛选影响哪些组件
一般筛选组件会选择作用于的目标组件,类似下图:
<img width=300 src="https://img.alicdn.com/tfs/TB1RJHPUxD1gK0jSZFsXXbldVXa-768-486.png">
这些信息会存储在筛选组件的组件配置中,即 `componentInstance.props`,筛选目标组件在 `componentMeta.eventConfigs` 组件元信息的事件中配置:
```jsx
import { Interfaces } from "@alife/bi-designer";
const componentMeta: Interfaces.ComponentMeta = {
eventConfigs: ({ componentInstance }) =>
componentInstance.props.targets?.map((target) => ({
// 筛选取数
type: "filterFetch",
// 触发组件
source: componentInstance.id,
// 作用组件
target: target.id,
})),
};
```
如上所示,假设作用于组件存储在 `props.targets` 字段中,我们将其 `map` 一下都设置为 `filterFetch` 类型,表示筛选作用,`source` 触发源是自己,`target` 目标组件是存储的 `target.id`
这样当 `source` 组件调用了 `onFilterChange``target` 组件就会触发取数,并在取数参数中拿到作用于其的筛选组件信息与筛选值。
### 组件如何感知筛选条件
组件取数是结合了筛选条件一起的,只要如上设置了 `filterFetch`,渲染引擎会自动在计算取数参数的回调函数 `getFetchParam` 中添加 `filters` 代表筛选组件信息,组件可以结合自身 `componentInstance``filters` 推导出最终取数参数:
<img width=300 src="https://img.alicdn.com/tfs/TB1tDDTUuH2gK0jSZJnXXaT1FXa-870-434.png">
最终,组件元信息只要写一个 `getFetchParam` 回调函数即可,**可以自动拿到作用于它的筛选组件,而不用关心是哪些配置导致了关联,只要响应式的去处理筛选作用即可**。
```jsx
import { Interfaces } from "@alife/bi-designer";
const componentMeta: Interfaces.ComponentMeta = {
// 组装取数参数
getFetchParam: ({ componentInstance, filters }) => {
// 结合 componentInstance 与 filters.map... 返回取数参数
},
};
```
## 筛选组件间联动带来的频繁取数问题
对于筛选联动的复杂场景,会遇到频繁取数的问题。
假设国家、省、市三级联动筛选条件同时 `filterFetch` 作用于一个表格,这个表格取数的筛选条件需要同时包含国家、省、市三个参数,但我们又设置了 国家、省、市 这三个筛选组件之间的 `filterFetch` 作为筛选联动,那么国家切换后、省改变、联动市改变,这个过程筛选值会变化三次,但我们只想表格组件取数函数仅执行最后的一次,怎么办呢?
<img width=350 src="https://img.alicdn.com/tfs/TB1CFD9UBr0gK0jSZFnXXbRRXXa-984-656.png">
如上图所示,其实每个筛选条件在渲染引擎数据流中还存储了一个 `ready` 状态,表示筛选条件是否就绪,**一个组件关联的筛选条件只要有一个 `ready` 不为 `true`,组件就不会触发取数**。
因此我们需要在筛选变化的过程中,总是保证一个筛选组件的 `ready``false`,等筛选间联动完毕了,所有筛选器的 `ready``true`,组件才会取数,我们可以使用 `filterReady` 筛选依赖配置:
```jsx
import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer";
const componentMeta: Interfaces.ComponentMeta = {
eventConfigs: ({ componentInstance }) =>
componentInstance.props.targets?.map((target) => ({
// 筛选就绪依赖
type: "filterReady",
// 触发组件
source: componentInstance.id,
// 作用组件
target: target.id,
})),
};
```
这样配置后,当 `source` 组件触发 `onFilterChange` 后,`target` 组件的筛选 `ready` 会立即设置为 `false`,只有 `target` 组件取完数后主动触发 `onFilterChange` 才会将自己的 `ready` 重新置为 `true`。**That'a all,其他流程没有任何感知**。
## 若干筛选组件聚合成一个查询控件
除了联动外,也会存在防止频繁查询的诉求,希望将多个筛选条件绑定成一个大筛选组件,在点击 “查询” 按钮时再取数:
<img width=400 src="https://img.alicdn.com/tfs/TB1nmHVUuH2gK0jSZJnXXaT1FXa-972-174.png">
可以利用 **筛选作用域** 轻松实现此功能,只需要两步:
### 筛选组件设置独立筛选作用域
```jsx
import { Interfaces } from "@alife/bi-designer";
const componentMeta: Interfaces.ComponentMeta = {
// 通过 componentInstance 判断,如果是全局筛选器内部,则设置 filterScope
filterScope: ({ componentInstance }) => ["my-custom-scope-name"],
};
```
这样,这批筛选组件就与其作用的组件属于不同的 **筛选作用域** 了,所以筛选不会对其立即生效,功能实现了一半。
### 确认按钮点击时调用 `submitFilterScope`
```jsx
import { useDesigner } from '@alife/bi-designer'
const componentMeta: Interfaces.ComponentMeta = {
const { submitFilterScope } = useDesigner()
// 点击确认按钮时,调用 submitFilterScope('my-custom-scope-name')
};
```
你可以在点击查询按钮后调用 `submitFilterScope` 并传入对应作用域名称,这样作用域内筛选组件就会立即对其 `target` 组件生效了。
至于确认按钮、UI 上的聚合,这些你可以写一个自定义组件去做,利用 `ComponentLoader` 把筛选组件聚合到一起加载,总之功能与 UI 是解耦的。
如果你对原理感兴趣,可以再多看一下这张图:
<img width=400 src="https://img.alicdn.com/tfs/TB1eZPJhAcx_u4jSZFlXXXnUFXa-1082-645.png">
### 突破筛选作用域
然而实际场景中,可能存在更复杂的组合,见下面的例子:
<img width=350 src="https://img.alicdn.com/tfs/TB1cfGNiIVl614jSZKPXXaGjpXa-966-600.png">
筛选器 1 同时对 筛选器 2、表格 产生筛选作用 `filterFetch`,但对 表格 的作用希望通过查询按钮拦截住,而对 筛选器 2 的作用希望能立即生效,对于这个例子有两种方式解决:
最简单的方式就是将 筛选器 1、筛选器 2 设置为相同作用域 `group1`,这样就通过作用域分割自然实现了效果,**而且这本质上是两个筛选器 UI 不在一起,但筛选作用域相同的例子**:
<img width=350 src="https://img.alicdn.com/tfs/TB1_kn1UpT7gK0jSZFpXXaTkpXa-1056-660.png">
但是再变化一下,如果筛选器 2 也对表格产生筛选作用,那我们将 筛选器 1、筛选器 2 放入同一个 `group1` 等于对表格的查询都会受到 “查询” 按钮的控制,但 **我们又希望筛选器 2 可以立即作用于表格**
<img width=350 src="https://img.alicdn.com/tfs/TB1ftP3Urr1gK0jSZFDXXb9yVXa-968-602.png">
如图所示,我们只能将 筛选器 1 的筛选作用域设置为 `group1`,这样 筛选器 2 与 表格 属于同一个筛选作用域,他们之间筛选会立即生效,我们只要解决 筛选器 1 不能立即作用于 筛选器 2 的问题即可,可以通过 `ignoreFilterScope` 方式突破筛选作用域:
```jsx
import { Interfaces } from "@alife/bi-designer";
const componentMeta: Interfaces.ComponentMeta = {
eventConfigs: ({ componentInstance }) =>
componentInstance.props.targets?.map((target) => ({
// 筛选取数
type: "filterFetch",
// 触发组件
source: componentInstance.id,
// 作用组件
target: target.id,
// 突破筛选作用域
ignoreFilterFetch: true,
})),
};
```
我们只要在 `source: 筛选器1` `target: 筛选器2``filterFetch` 配置中,将 `ignoreFilterFetch` 设置为 `true`,这个 `filterFetch` 就会忽略筛选作用域,实现立即 筛选器 1 立即作用到 筛选器 2 的效果。
## 总结
你还有哪些特殊的筛选诉求?可以用这套筛选设计解决吗?
> 讨论地址是:[精读《BI 搭建 - 筛选条件》· Issue #270 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/270)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,115 @@
# Abstract Factory(抽象工厂)
Abstract Factory(抽象工厂)属于创建型模式,工厂类模式抽象程度从低到高分为:简单工厂模式 -> 工厂模式 -> 抽象工厂模式。
**意图:提供一个接口以创建一系列相关或相互依赖的对象,而无须指定它们具体的类。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 汽车工厂
我们都知道汽车有很多零部件,随着工业革命带来的分工,很多零件都可以被轻松替换。但实际生活中我们消费者不愿意这样,我们希望买来的宝马车所包含的零部件都是同一系列的,以保证最大的匹配度,从而带来更好的性能与舒适度。
所以消费者不愿意到轮胎工厂、方向盘工厂、车窗工厂去一个个采购,而是将需求提给了宝马工厂这家抽象工厂,由这家工厂负责组装。那你是这家工厂的老板,已知汽车的组成部件是固定的,只是不同配件有不同的型号,分别来自不同的制造厂商,你需要推出几款不同组合的车型来满足不同价位的消费者,你会怎么设计?
### 迷宫游戏
你做一款迷宫游戏,已知元素有房间、门、墙,他们之间的组合关系是固定的,你通过一套算法生成随机迷宫,这套算法调用房间、门、墙的工厂生成对应的实例。但随着新资料片的放出,你需要生成具有新功能的房间(可以回复体力)、新功能的门(需要魔法钥匙才能打开)、新功能的墙(可以被炸弹破坏),但修改已有的迷宫生成算法违背了开闭原则(需要在已有对象进行修改),如果你希望生成迷宫的算法完全不感知新材料的存在,你会怎么设计?
### 事件联动
假设我们做一个前端搭建引擎,现在希望做一套关联机制,以实现点击表格组件单元格,可以弹出一个模态框,内部展示一个折线图。已知业务方存在定制表格组件、模态框组件、折线图组件的需求,但组件之间联动关系是确定的,你会怎么设计?
## 意图解释
在汽车工厂的例子中,我们已知车子的构成部件,**为了组装成一辆车子,需要以一定方式拼装部件,而具体用什么部件是需要可拓展的**。
在迷宫游戏的例子中,我们已知迷宫的组成部分是房间、门、墙,**为了生成一个迷宫,需要以某种算法生成许多房间、门、墙的实例,而具体用哪种房间、哪种门、哪种墙是这个算法不关心的,是需要可被拓展的**。
在事件联动的例子中,我们已知这个表格弹出趋势图的交互场景基本组成元素是表格组件、模态框组件、折线图组件,**需要以某种联动机制让这三者间产生联动关系,而具体是什么表格、什么模态框组件、什么折线图组件是这个事件联动所不关心的,是需要可以被拓展的**,表格可以被替换为任意业务方注册的表格,只要满足点击 `onClick` 机制就可以。
> **意图:提供一个接口以创建一系列相关或相互依赖的对象,而无须指定它们具体的类。**
这三个例子不正是符合上面的意图吗?我们要设计的抽象工厂就是要 **创建一系列相关或相互依赖的对象**,在上面的例子中分别是汽车的组成配件、迷宫游戏的素材、事件联动的组件。**而无须指定它们具体的类**,也就说明了我们不关心车子方向盘用的是什么牌子,迷宫的房间是不是普通房间,联动机制的折线图是不是用 `Echarts` 画的,我们只要描述好他们之间的关系即可,**这带来的好处是,未来我们拓展新的方向盘、新的房间、新的折线图时,不需要修改抽象工厂。**
## 结构图
<img width=800 src="https://img.alicdn.com/tfs/TB1k8DVVkT2gK0jSZFkXXcIQFXa-1472-658.png">
`AbstractFactory` 就是我们要的抽象工厂,描述了创建产品的抽象关系,比如描述迷宫如何生成,表格和趋势图怎么联动。
至于具体用什么方向盘、用什么房间,是由 `ConcreteFactory` 实现的,所以我们可能有多个 `ConcreteFactory`,比如 `ConcreteFactory1` 实例化的墙壁是普通墙壁,`ConcreteFactory2` 实例化的墙壁是魔法墙壁,但其对 `AbstractFactory` 的接口是一致的,所以 `AbstractFactory` 不需要关心具体调用的是哪一个工厂。
`AbstractProduct` 是产品抽象类,描述了比如方向盘、墙壁、折线图的创建方法,而 `ConcreteProduct` 是具体实现产品的方法,比如 `ConcreteProduct1` 创建的表格是用 `canvas` 画的,折线图是用 `G2` 画的,而 `ConcreteProduct2` 创建的表格是用 `div` 画的,折线图是用 `Echarts` 画的。
这样,当我们要拓展一个用 `Rcharts` 画的折线图,用 `svg` 画的表格,用 `div` 画的模态框组成的事件机制时,只需要再创建一个 `ConcreteFactory3` 做相应的实现即可,再将这个 `ConcreteFactory3` 传递给 `AbstractFactory`,并不需要修改 `AbstractFactory` 方法本身。
## 代码例子
下面例子使用 javascript 编写。
```typescript
class AbstractFactory {
createProducts(concreteFactory: ConcreteFactory) {
const productA = concreteFactory.createProductA();
const productB = concreteFactory.createProductB();
// 建立 A 与 B 固定的关联,即便 A 与 B 实现换成任意实现都不受影响
productA.bind(productB);
}
}
```
`productA.bind(productB)` 是一种抽象表示:
- 对于汽车工厂的例子,表示组装汽车的过程。
- 对于迷宫游戏的例子,表示生成迷宫的过程。
- 对于事件联动的例子,表示创建组件间关联的过程。
假设我们的迷宫有两套素材,分别是普通素材与魔法素材,只要在分别创建普通素材工厂 `ConcreteFactoryA`,与魔法素材工厂 `ConcreteFactoryB`,调用 `createProducts` 时传入的是普通素材,则产出的就是普通素材搭建的迷宫,传入的是魔法素材,则产出的就是用魔法素材搭建的迷宫。
当我们要创建一套新迷宫材料,比如熔岩迷宫,我们只要创建一套熔岩素材(熔岩房间、熔岩门、熔岩墙壁),再组装一个 `ConcreteFactoryC` 熔岩素材生成工厂传递给 `AbstractFactory.createProducts` 即可。
我们可以发现,使用抽象工厂模式,我们可以轻松拓展新的素材,比如拓展一套新的汽车配件,拓展一套新的迷宫素材,拓展一套新的事件联动组件,**这个过程只需要新建类即可,不需要修改任何类,符合开闭原则**。
## 弊端
任何设计模式都有其适用场景,反过来也说明了在某些场景下不适用。
还是上面的例子,如果我们的需求不是拓展一个新轮子、新墙壁、新折线图,而是:
- 汽车工厂要给汽车加一个新部件:自动驾驶系统。
- 迷宫游戏要新增一个功能素材:陷阱。
- 事件联动要新增一个联动对象:明细趋势统计表格。
你看,这种情况不是为已有元素新增一套实现,而是实现一些新元素,就会非常复杂,因为我们不仅要为所有 `ConcreteFactory` 新增每一个元素,还要修改抽象工厂,以将新元素与旧元素间建立联系,违背了开闭原则。
因此,对于已有元素固定的系统,适合使用抽象工厂,反之不然。
## 总结
抽象工厂对新增已有产品的实现适用,对新增一个产品种类不适用,可以参考结合了例子的下图加深理解:
<img width=800 src="https://img.alicdn.com/tfs/TB1Fbn7Vlr0gK0jSZFnXXbRRXXa-1416-852.png">
拓展一个熔岩素材包是 **增加一种产品风格**,适合使用抽象工厂设计模式;拓展一个陷阱是 **增加一个产品种类**,不适合使用抽象工厂设计模式。为什么呢?看下图:
<img width=800 src="https://img.alicdn.com/tfs/TB12fL8VeL2gK0jSZFmXXc7iXXa-1696-640.png">
创建迷宫这个抽象工厂做的事情,**是把已有的房间、门、墙壁建立关联**,因为操作的是抽象类,所以拓展一套具体实现(熔岩素材包)对这个抽象工厂没有感知,这样做很容易。
但如果新增一个产品种类 - 陷阱,可以看到,抽象工厂必须将陷阱与前三者重新建立关联,这就要修改抽象工厂,不符合开闭原则。同时,如果我们已有素材包 1 ~素材包 999,就需要同时增加 999 个对应的陷阱实现(普通陷阱、魔法陷阱、熔岩陷阱),其工作量会非常大。
因此,只有产品种类稳定时,需要频繁拓展产品风格时才适合用抽象工厂设计模式。
> 讨论地址是:[精读《设计模式 - Abstract Factory 抽象工厂》· Issue #271 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/271)
**如果你想参与讨论,请 [点击这里](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,147 @@
# Builder(生成器)
Builder(生成器)属于创建型模式,针对的是单个复杂对象的创建。
**意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 搭乐高积木
乐高积木是很典型的随机拼装场景,你有很多乐高积木,要搭一个小房子都太复杂了,可能不得不看着说明书一步步操作,这就像创建一个复杂的对象,要传入非常多的参数,而且顺序还不能错。
如果不考虑拼装乐高过程中的乐趣,你只是想快速得到一个标准的房子,怎么样才可以最快最省事?
### 工厂流水线
制作一个罐头要经历许多步骤,而其中一些步骤比如制作罐头是通用的,可以用这个罐头装很多东西,比如红枣罐头、黄桃罐头,那工厂流水线是怎么做到灵活可拓展的呢?
### 创建数据库连接池
建立一个数据库连接池,我们需要传入数据库的地址、用户名与密码、还有要创建多少大小的连接池,缓存的位置等等。
考虑到数据库必须正确连接后才有效,创建时必须校验传入的数据库地址与密码的正确性,甚至存储方式与数据库类型还有关系,这是一个简单的 `new` 实例化可以解决的吗?
## 意图解释
在乐高积木的例子中,我们为了得到一个房子其实不需要关心每一个积木应该如何摆放,**我们只要交给组装工厂(一个人或者一个程序)产出标准房子就行了**,这其中参数可能是 `.setHouseType().build()` 设置房屋类型,而不需要 `new House(block1, block2, ... block999)` 传递这些没必要的参数。**其中组装工厂就是生成器**。
在工厂流水线的例子中,**流水线就是生成器,一个流水线可以不通过不同组合生成不同作用的工厂**,黄桃罐头的流水线可以理解为 `new Builder().组装罐头().放入黄桃().build()`,红枣罐头的流水线可以理解为 `new Builder().组装罐头().放入红枣().build()`,我们可以复用生成器最基础的函数 `组装罐头()` 将其用于创建不同的产品中,复用了组装基础能力。
在创建数据库例子中,我们可以先设置一些必要的参数再创建,比如 `new Builder().setUrl().setPassword().setType().build()`,这样在最终执行 `build` 函数的时候,可以对参数中存在关联的进行校验,而得到的对象也无法再被修改,这样比直接暴露数据库连接池对象,再一个值一个值 Set 多了如下好处:
1. 对象无法被修改,保护了程序稳定性,减少了维护复杂度。
2. 可以对参数关联进行一次性校验。
3. 在创建对象之前不会存在中间态,即创建了对象实例,但缺少部分参数,这可能导致对象无法正确 work。
**意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。**
我们再理解一次意图,所谓构建与表示分离,就是指一个对象 `Person` 并不是简单的 `new Person()` 就可以实例化出来的,如果可以,那就是构建与表示一体。**所谓构建与表示分离,就是指 `Person` 只能描述,而不能通过 `new Person()` 实例化,将实例化工作通过 Builder 实现,这样同样一个构建过程可以创建不同的 `Person` 实例。**
在乐高积木的例子中,通过乐高创建的房子并不是 `new House()` 出来,而是将构建与表示分离了,工厂流水线中我们创建一个黄桃罐头,不是通过 `new 黄桃罐头()`,而是通过流水线不同拼装方式来完成,在数据库例子中,我们没有通过 `new DB()` 的方式创建数据库,而是通过 Builder 来创建,这都体现了构建与表示的分离。
## 结构图
<img width=800 src="https://img.alicdn.com/tfs/TB14lOwYXT7gK0jSZFpXXaTkpXa-1382-466.png">
- `Director` 指导器,用来指导构建过程。
- `Builder` 生成器接口,用来提供一系列构建对象的方法,以及最终的 `build` 生成对象函数,这个函数里可以做一些参数校验。
- `ConcreteBuilder``Builder` 的具体实现。
实际上,Builder 模式抽象层次可高可低,我们上面三个例子都没有用到指导器与生成器接口,这是因为在代码不太复杂的情况下,可以使用简化模型。
## 代码例子
下面例子使用 javascript 编写。
```typescript
class Director {
create(concreteBuilder: ConcreteBuilder) {
// 创建了一些零件
concreteBuilder.buildA();
concreteBuilder.buildB();
// 校验参数已经生成实例
return concreteBuilder.build();
}
}
class HouseBuilder {
public buildA() {
// 创建房屋
// this.xxx = xxx
}
public buildB() {
// 刷油漆
}
public build() {
// 最终创建实例
return new House(/* ..一堆参数 this.xxx.. */);
}
}
// 接下来是正式使用
const director = new Director();
const builder = HouseBuilder();
const house = director.create(builder);
```
上面的例子是完整版本的 Builder 模式,抽象了指导器 `Director` 与生成器 `Builder`,只要两者都严格按照接口实现,我们可以:
1. 替换任意 `Director`,使创建的过程做任意修改。
2. 替换任意 `Builder`,使创建的实现做任意修改。
做了任意的改动,都可以得到不同的房子实现,这就是创建与表示分离的好处,我们可以通过同样的构建过程创建不同的表示。
这个 `director.create()`
- 在搭乐高积木的例子,表示用乐高搭建房屋的过程。
- 在工程流水线的例子,表示罐头的组装构成。
- 在创建数据库连接池的例子,表示数据库连接池的创建过程。
`Builder` 以及其函数 `buildA` `buildB` 等方法表示具体制造方法,比如:
- 在搭乐高积木的例子,表示如何盖房子,如何刷油漆。
- 在工程流水线的例子,表示如何做一个罐头,如何添加黄桃。
- 在创建数据库连接池的例子,表示如何设置数据库地址,如何设置用户名密码等。
对于数据库的例子中,我们不仅可以保证创建对象的便捷性,因为不需要传入过多参数,也保证了对象的正确校验,同时生成的实例也是不可变的。
更重要的是,如果使用完整模式,我们可以替换 `Director` 来修改创建数据库的方式,替换 `Builder` 来修改具体方法,比如 `.setUserName` 这个函数不做具体实现,而是统计性能,`build()` 函数创建的不是一个数据库连接实例,而是一个测试实例。
再比如前端同一个方法在 JS 和 Node 环境下运行效果不一样,我们可以实现 `BrowserBuild``NodeBuild`,实现相同的接口,这样可以共享相同的创建过程,创建不同环境可以运行的实例。
可以看到,使用 Builder 模式可以保证创建对象的便捷与稳定性,还留了足够的拓展空间改变对象的创建过程与创建方法,具有极强的拓展性。
## 弊端
任何设计模式都有其适用场景,反过来也说明了在某些场景下不适用。
- 实例化对象非常繁琐,重复定义了许多对象成员变量的 `set` 方法,而且也不如 `new` 看的直观,也就是场景足够简单时,不需要任何地方都用 Builder 实例化对象。
- 一个对象只有一种表示时,没必要做如此地步的抽象。
上面的例子都是相对复杂的,假设我们的搭房子的例子中,我们不是用乐高积木搭建,而是用两块半成品模板拼起来就得到一个房子,那就没有必要使用 Builder 模式,直接 `new House()` 即可。
再者,如果我们只需要生产各种罐头,而不需要生产汽车,那么就没必要过度抽象 Builder,把创建汽车的方法也囊括进去,最后,如果我们的对象只有一种表示时,没有必要抽象 Builder,也就是流水线如果只生产黄桃罐头,就没必要把各个生产环节变成可拆卸的,因为也没有重新组合的需要。
## 总结
Builder 模式对于创建一个复杂对象特别有用,可以看下图加深理解:
<img wdith=800 src="https://img.alicdn.com/tfs/TB109aLYoT1gK0jSZFrXXcNCXXa-1412-984.png">
最后总结一下何时适合用 Builder 模式:只有当创建过程允许被构造对象有不同表示,或者对象复杂到对象描述与创建对象过程值得分离时,才使用 Builder 设计模式。
> 讨论地址是:[精读《设计模式 - Builder 生成器》· Issue #273 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/273)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,115 @@
# Factory Method(工厂方法)
Factory Method(工厂方法)属于创建型模式,利用工厂方法创建对象实例而不是直接用 New 关键字实例化。
理解如何写出工厂方法很简单,但理解为什么要用工厂方法就需要动动脑子了。工厂方法看似简单的将 New 替换为一个函数,其实是体现了面向接口编程的思路,它创建的对象其实是一个符合通用接口的通用对象,这个对象的具体实现可以随意替换,以达到通用性目的。
**意图:定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method 使一个类的实例化延迟到其子类。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 换灯泡
我自己在家换过灯泡,以前我家里灯坏掉的时候,我看着这个奇形怪状的灯管,心里想,这种灯泡和这个灯座应该是一体的,市场上估计很难买到适配我这个灯座的灯泡了。结果等我把灯泡拧下来,跑到门口的五金店去换的时候,店员随便给了我一个灯泡,我回去随便拧了一下居然就能用了。
我买这个灯泡的过程就用到了工厂模式,而正是得益于这种模式,让我可以方便在家门口就买到可以用的灯泡。
### 卡牌对战游戏
卡牌对战中,卡牌有一些基本属性,比如攻防、生命值,也符合一些通用约定,比如一回合出击一起等等,那么对于战斗系统来说,应该怎样实例化卡牌呢?如何批量操作卡牌,而不是通用功能也要拿到每个卡牌的实例才能调用?另外每个卡牌有特殊能力,这些特殊能力又应该如何拓展呢?
### 实现任意图形拖拽系统
一个可以被交互操作的图形,它可以用鼠标进行拉伸、旋转或者移动,不同图形实现这些操作可能并不相同,要存储的数据也不一样,这些数据应该独立于图形存储,我们的系统如果要对接任意多的图形,具备强大拓展能力,对象关系应该如何设计呢?
## 意图解释
在使用工厂方法之前,我们就要创建一个 **用于创建对象的接口**,这个接口具备通用性,**所以我们可以忽略不同的实现来做一些通用的事情**。
换灯泡的例子来说,我去门口五金店买灯泡,而不是拿到灯泡材料自己 New 一个出来,就是因为五金店这个 “工厂” 提供给我的灯泡符合国家接口标准,而我家里的灯座也符合这个标准,所以灯座不需要知道对接的灯泡是具体哪个实例,什么颜色,什么形状,这些都无所谓,只要灯泡符合国家标准接口,就可以对接上。
对卡牌对战的系统来说,**所有卡牌都应该实现同一种接口**,所以卡牌对战系统拿到的卡牌应该就是简单的 Card 类型,这种类型具备基本的卡片操作交互能力,系统就调用这些能力完成基本流程就好了,如果系统直接实例化具体的卡片,那不同的卡片类型会导致系统难以维护,卡片间操作也无法抽象化。
正式这种模式,使得我们可以在卡牌的具体实现上做一些特殊功能,比如修改卡片攻击时效果,修改卡牌销毁时效果。
对图形拖拽系统来说,用到了 “连接平行的类层次” 这个特性,所谓连接平行的类层次,就是指一个图形,与其对应的操作类是一个平行抽象类,而一个具体的图形与具体的操作类则是另一个平行关系,系统只要关注最抽象的 “通用图形类” 与 “通用操作类” 即可,操作时,底层可能是某个具体的 “圆类” 与 “圆操作类” 结合使用,具体的类有不同的实现,但都符合同一种接口,因此操作系统才可以把它们一视同仁,统一操作。
**意图:定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method 使一个类的实例化延迟到其子类。**
所以接口是非常重要的,工厂方法第一句话就是 “定义一个用于创建对象的接口”,这个接口就是 `Creator`,让子类,也就是具体的创建类(`ConcreteCreator`)决定要实例化哪个类(`ConcreteProduct`)。
所谓使一个类的实例化延迟到其子类,是因为抽象类不知道要实例化哪个具体类,所以实例化动作只能由具体的子类去做,这样绕一圈的好处是,我们可以将任意多对象看作是同一类事物,做统一的处理,比如 **无论何种灯泡实例都满足通用的灯座接口**,**所有工厂实例化的卡牌都具备玩一局卡牌游戏的基本功能**,**任何图形与交互类都满足特定功能关系**,这种思想让生活和设计得到了大幅简化。
## 结构图
<img width=800 src="https://img.alicdn.com/tfs/TB1VjyZmsVl614jSZKPXXaGjpXa-1434-476.png">
`Creator` 就是工厂方法,`ConcreteCreator` 是实现了 `Creator` 的具体工厂方法,每一个具体工厂方法生产一个具体的产品 `ConcreteProduct`,每个具体的产品都实现通用产品的特性 `Product`
## 代码例子
下面例子使用 typescript 编写。
```typescript
// 产品接口
interface Product {
save: () => void;
}
// 工厂接口
interface Creator {
createProduct: () => Product;
}
// 具体产品
class ConcreteProduct implements Product {
save = () => {};
}
// 具体工厂
class ConcreteCreator implements Creator {
createProduct = () => {
return new ConcreteProduct();
};
}
```
创建一个 `Product` 的子类 `ConcreteCreator`,并返回一个实现了 `Product` 的具体实例 `ConcreteProduct`,这样我们就可以方便使用这个工厂了。
工厂方法并不是直接调用 `new ConcreteCreator().createProduct` 那么简单,这样体现不出任何抽象性,真正的场景是,在一个创建产品的流程中,我们只知道拿到的工厂是 `Creator`
```typescript
function main(anyCreator: Creator) {
const product = anyCreator.createProduct()
}
```
在外面调用 `main` 函数时,实际传进去的是一个具体工厂,比如 `myCreator`,但关键是 `main` 函数不用关心到底是哪一个具体工厂,只要知道是个工厂就行了,具体对象创建过程交给了其子类。
**你也许也发现了,这就是抽象工厂中其中的一步,所以抽象工厂使用了工厂方法。**
## 弊端
工厂方法中,每创建一种具体的子类,就要写一个对应的 `ConcreteCreate`,这相对比较笨重,但有意思的是,如果将创建多个对象放到一个 `ConcreteCreate` 中,就变成了 **简单工厂模式**,新增产品要修改已有类不符合开闭模式,反而推荐写成本文说的这种模式。
彼之毒药吾之蜜糖,要知道没有一种设计模式解决所有问题,没有一种设计模式没有弊端,**而这个弊端不代表这个设计模式不好,一个弊端的出现可能是为了解决另一个痛点。** 要接受不完美的存在,这么多种设计模式就是对应了不同的业务场景,**为合适的场景选择一种能将优势发扬光大,以至于能掩盖弊端,就算进行了合理的架构设计**。
## 总结
工厂方法并不是简单把 New 的过程换成了函数,而是抽象出一套面向接口的设计模式:
<img width=800 src="https://img.alicdn.com/tfs/TB1WKH.Zoz1gK0jSZLeXXb9kVXa-1480-786.png">
你看,我要做灯泡,可以直接做具体的灯泡,也可以定一个灯泡接口,通过灯泡工厂拿到具体灯泡,灯泡工厂对待所有灯泡的只做流程都是一样的,不管是中世纪风灯泡,还是复古灯泡,还是普通白织灯,都是一模一样的制作流程,具体怎么做由具体的子类去实现,这样我们可以统一管理 “灯泡” 这一个通用概念,而忽略不同灯泡之间不太重要的差别,程序的可维护性得到了大幅提升。
> 讨论地址是:[精读《设计模式 - Factory Method 工厂方法》· Issue #274 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/274)
**如果你想参与讨论,请 [点击这里](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,121 @@
# Prototype(原型模式)
Prototype(原型模式)属于创建型模式,既不是工厂也不是直接 New,而是以拷贝的方式创建对象。
**意图:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 做钥匙
很显然,为了房屋安全,要尽量做到一把钥匙只能开一扇门,每把钥匙结构都多多少少不一样,却又很相似,做钥匙的人按照你给的钥匙一模一样做一个新的,这属于什么模式呢?
### 两种状态表
当网站做不停机维护时,假设维护内容是给每个高级会员账户多打 100 元现金,现在需要改数据库表。已知:
1. 数据库表有几千万条数据,其中高级会员有几千位,为了方便调用已经缓存在中间层了,且数据库对应 ID 更新后对应缓存也会更新。
2. 几千条数据修改语句执行完需要几分钟,这几分钟内无法接受用户数据不同步的问题。
一种常见的做法是,我们生成一份高级会员列表的拷贝,代替数据库缓存的结果,数据库只要读到对应会员 ID 就从拷贝列表中获取,数据表新增一列状态标志,操作完后这个拷贝移除,更新高级会员缓存。
但是如何生成高级会员列表拷贝呢?如果直接从几千万条用户数据中重新查询,会有较高的数据库查询成本。
### 模版组件
通用搭建系统中,我们可以将某个拖拽到页面的区块设置为 “模版”,这个模版可以作为一个新组件被重新拖拽到任意为止,实例化任意次。实际上,这是一种分段式复制粘贴,你会如何实现这个功能呢?
## 意图解释
解决上面问题的办法都很简单,就是基于已有对象进行复制即可,效率比 New 一个,或者工厂模式都要高。
**意图:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。**
所谓原型实例,就是被选为拷贝模版的那个对象,比如做钥匙例子中,你给老板的样板钥匙;两种状态表中的已有缓存高级会员列表;模版组件中选中的那个组件。然后,通过拷贝这些原型创建你想要的对象即可。
我们抽象思考一下,如果每把钥匙都遵循 `Prototype` 接口,提供了 `clone()` 方法以复制自己,那就可以快速复制任意一把钥匙。钥匙工厂可无法解决每把钥匙不一样的问题,我们要的就是和某个钥匙一模一样的副本,复制一份钥匙最简单。
高级会员状态表例子中,查询数据库的成本是高昂的,但如果仅仅复制已经查询好的列表,时间可以忽略不计,因此最经济的方案是直接复制,而不是通过工厂模式重新连接数据库并执行查询。
模版组件更是如此,我们根本没有定义那么多组件实例的基类,只要每个组件提供一个 `clone()` 函数,就可以立即复制任意组件实例,这无疑是最经济实惠的方案。
看到这里,你应该知道了,原型模式的精髓是对象要提供 `clone()` 方法,而这个 `clone()` 方法实现难度有高有低。
一般来说,原型模式的拷贝建议用深拷贝,毕竟新对象最好不要影响到旧对象,**但是在深拷贝性能问题较大的情况下,可以考虑深浅拷贝结合,也就是将在新对象中,不会修改的数据使用浅拷贝,可能被修改的数据使用深拷贝。**
## 结构图
<img width=800 src="https://img.alicdn.com/tfs/TB1roQlZWL7gK0jSZFBXXXZZpXa-1328-596.png">
`Client` 是发出指令的客户端,`Prototype` 是一个接口,描述了一个对象如何克隆自身,比如必须拥有 `clone()` 方法,而 `ConcretePrototype` 就是克隆具体的实现,不同对象有不同的实现来拷贝自身。
## 代码例子
下面例子使用 typescript 编写。
```typescript
class Component implements Prototype {
/**
* 组件名
*/
private name: string
/**
* 组件版本
*/
private version: string
/**
* 拷贝自身
*/
public clone = () => {
// 构造函数省略了,大概就是传递 name 和 version
return new Component(this.name, this.version)
}
}
```
我们可以看到,实现了 `Prototype` 接口的 `Component` 必须实现 `clone` 方法,这样任意组件在执行复制时,就可以直接调用 `clone` 函数,而不用关心每个组件不同的实现方式了。
从这就能看出,原型模式与 Factory 与 Builder 模式还是有类似之处的,在隐藏创建对象细节这一点上。
使用的时候,我们就可以这样创建一个新对象:
```typescript
const newComponent = oldComponent.clone()
```
这里有两个注意点:一般来说,**如果要二次修改生成的对象,不建议给 `clone` 函数加参数,因为这样会导致接口的不一致。** 我们可以为对象实例提供一些 `set` 函数进行二次修改。另外,`clone` 函数要考虑性能,就像前面说过的,可以考虑深浅拷贝结合的方式,同时要注意当对象存在引用关系甚至循环引用时,甚至不一定能实现拷贝函数。
## 弊端
每个设计模式必有弊端,但就像每一期都说的,有弊端不代表设计模式不好用,而是指在某种场景喜爱存在问题,我们只要规避这些场景,在合理的场景使用对应设计模式即可。
原型模式的弊端:
1. 每个类都要实现 `clone` 方法,对类的实现是有一定入侵的,要修改已有类时,违背了开闭原则。
2. 当类又调用了其他对象时,如果要实现深拷贝,需要对应对象也实现 `clone` 方法,整体链路可能会特别长,实现起来比较麻烦。
## 总结
**原型模式一般与工厂模式搭配使用,一般工厂方法接收一个符合原型模式的实例,就可以调用它的 `clone` 函数创建返回新对象啦。** 代码大概是这样:
```typescript
// buildComponentFactory 内部通过 targetComponent.clone() 创建对象,而不是 New 或者调用其他工厂函数。
const newComponent = buildComponentFactory(new Component())
```
最后来一张图快速理解原型模式:
<img width=600 src="https://img.alicdn.com/tfs/TB1hBIdm6MZ7e4jSZFOXXX7epXa-982-486.png">
> 讨论地址是:[精读《设计模式 - Prototype 原型模式》· Issue #277 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/277)
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
> 关注 **前端精读微信公众号**
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)
@@ -0,0 +1,115 @@
# Singleton(单例模式)
Singleton(单例模式)属于创建型模式,提供一种对象获取方式,保证在一定范围内是唯一的。
**意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。**
其实单例模式在前端体会的不明显,原因有:
1. 前端代码本身在单机运行,创建的任何变量都是天然分布式的,不需要担心影响另一个用户。
2. 后端代码是一对多的,分辨出哪些资源是请求间共享的,哪些是请求内独有的很重要。
另外我们说到单例,是隐含了一个范围的,指的是在某个范围内单例,比如在一个上下文中,还是一个房间中,还是一个进程,一个线程中单例,不同场景范围会不同。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 多人游戏的共享物品
玩过游戏的同学都知道,我们在每局游戏中使用的公共物品在当前房间中是唯一的,但在游戏房间间却不是唯一的,所以这些公共物品肯定有不同的类去描述,那每局游戏中怎么拿公共物品,可以保证拿到的是当前局内唯一的?
### Redux 数据流
其实前端的 Redux 数据流本身就是单例模式,在一个应用中,数据是唯一的,但可以有不同的 UI 使用这份唯一的数据,甚至把一个表格组件展示在两个不同地方,比如全屏模式,但数据依然是一份,我们没有必要为了全屏展示表格,就让它再发一次取数请求,完全可以和原来的表格共享一份数据。
### 数据库连接池
每个 SQL 查询都依赖数据库连接池,如果每次查询都建立一次数据库连接池,则建立连接的速度会远远慢于 SQL 查询速度,因此你会怎么设计数据库连接池的获取方法?
## 意图解释
单例模式的意图很简单,几乎就是其字面含义:
**意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。**
对于多人游戏的共享物品,比如一口锅,要保证在一局游戏内唯一,就要提供一种方法访问到唯一实例。
Redux 数据流的 `connect` 装饰器就是全局访问点的一种设计。
数据库连接池可以提前初始化好,并通过固定 API 提供这个唯一实例。
## 结构图
<img width=600 src="https://img.alicdn.com/tfs/TB1qVf20QY2gK0jSZFgXXc5OFXa-1060-342.png">
`Singleton` 是单例模式的接口,客户只能通过其定义的 `instance()` 访问实例,以保证单例。
## 代码例子
下面例子使用 typescript 编写。
```typescript
class Ball {
private _instance = undefined
// 构造函数申明为 private,就可以阻止 new Ball() 行为
private constructor() {}
public static getInstance = () => {
if (this._instance === undefined) {
this._instance = new Ball()
}
return this._instance
}
}
// 使用
const ball = Ball.getInstance()
```
可以仔细想想,为什么这个例子把单例写成了静态方法,而不是一个全局变量?其实全局变量也能解决问题,但由于会污染全局,要尽可能通过模块化方式解决,上面的例子就是一个较好的封装方式。
当然这只是一个最简单的例子,实际上单例模式还有几种模式:
### 饿汉式
初始化时就生成一份实例,这样调用时直接就能获取。
### 懒汉式
就是代码例子中写的,按需实例化,即调用的时候再实例化。
> **要注意,按需不一定是什么好事,如果 New 的成本很高还按需实例化,可能把系统异常的风险留到随机的触发时机,导致难以排查 BUG,另外也会影响第一次实例化时的系统耗时。**
对 JAVA 来说,单例还需要考虑并发性,有 **双重检测、静态内部类、枚举** 等办法解决,这里不具体展开。
## 弊端
单例模式的问题有:
- 对面向对象不太友好。对封装、继承、多态支持不够友好。
- 不利于梳理类之间的依赖关系。毕竟单例是直接调用的,而不是在构造函数申明的,所以要梳理关系要看完每一行代码才能确定。
- 可拓展性不好。万一要支持多例就比较难拓展,比如全局数据流可能因为微前端方案改成多实例、数据库连接池为了分治 SQL 改成多实例,都是有可能的,在系统设计之初就要考虑到未来是否还会保持单例。
- 可测试性不好,因为单例是全局共享的,无法保证测试用例间的隔离。
- 无法使用构造函数传参。
另外单例模式还可以被工厂方法所替代,所以不用特别纠结一种设计模式,可以结合使用,工厂函数也可以内嵌单例模式。
## 总结
单例模式概念、用法都简单,是架构设计常用方案,但要充分理解到单例模式的弊端,防止不恰当的使用。
<img width=400 src="https://img.alicdn.com/tfs/TB15O3YmOpE_u4jSZKbXXbCUVXa-904-224.png">
> 讨论地址是:[精读《设计模式 - Singleton 单例模式》· Issue #278 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/278)
**如果你想参与讨论,请 [点击这里](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,149 @@
# Adapter(适配器模式)
Adapter(适配器模式)属于结构型模式,别名 `wrapper`,结构性模式关注的是如何组合类与对象,以获得更大的结构,我们平常工作大部分时间都在与这种设计模式打交道。
**意图:将一个类的接口转换成客户希望的另一个接口。Adapter 模式使得原本由于接口不兼容而不能在一起工作的那些类可以一起工作。**
这个设计模式的意图很好懂,就是把接口不兼容问题抹平。注意,也仅仅能解决接口不一致的问题,而不能解决功能不一致的问题。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 接口转换器
插座的种类很多,我们都用过许多适配器,将不同的插头进行转换,可以在不替换插座的情况下正常使用。
USB 接口转换也同样精彩,有将 TypeC 接口转换为 TypeA 的,也有将 TypeA 接口转换为 TypeC 的,支持双向转换。
接口转换器就是我们在生活中使用到的适配器模式,因为厂商并没有生产一个新的插座,我们也没有因为接口不适配而换一个手机,一切只需要一个接口转换器即可,这就是运用设计模式的收益。
### 数据库 ORM
ORM 屏蔽了 SQL 这一层,带来的好处是不需要理解不同 SQL 语法之间的区别,对于通用功能,ORM 会根据不同的平台,比如 Postgresql、Mysql 进行 SQL 的转换。
对 ORM 来说,屏蔽不同平台的差异,就是利用适配器模式做到的。
### API Deprecated
当一个广泛使用的库进行了含有 break change 的升级时,往往要留给开发者足够的时间去升级,而不能升级后就直接挂掉,因此被废弃的 API 要标记为 `deprecated`,而这种被废弃标记的 API 的实际实现,往往是使用新的 API 替代,这种场景正是使用了适配器模式,将新的 API 适配到旧的 API,实现 API Deprecated。
## 意图解释
上面三个例子都满足下面两个条件:
1. API 不兼容:因为接口的不同;数据库 SQL 语法的不同;框架 API 的不同。
2. 但能力已支持:插座都拥有充电或读取能力;不同的 SQL 都拥有查询数据库能力;新 API 覆盖了旧 API 的能力。
这样就可以通过适配器满足 Adapter 的意图:
**意图:将一个类的接口转换成客户希望的另一个接口。Adapter 模式使得原本由于接口不兼容而不能在一起工作的那些类可以一起工作。**
## 结构图
适配器的实现分为继承与组合模式。
下面是名词解释:
- `Adapter` 适配器,把 `Adeptee` 适配成 `Target`
- `Adaptee` 被适配的内容,比如不兼容的接口。
- `Target` 适配为的内容,比如需要用的接口。
继承:
<img width=400 src="https://img.alicdn.com/tfs/TB1iy7Gk4vbeK8jSZPfXXariXXa-1590-518.png">
适配器继承 `Adaptee` 并实现 `Target`,适用场景是 `Adaptee``Target` 结构类似的情况,因为这样只需要实现部分差异化即可。
组合:
<img width=400 src="https://img.alicdn.com/tfs/TB1SrW21EY1gK0jSZFMXXaWcVXa-1524-500.png">
组合的拓展性更强,但工作量更大,如果 `Target``Adaptee` 结构差异较大,适合用组合模式。
## 代码例子
下面例子使用 typescript 编写。
继承:
```typescript
interface ITarget {
// 标准方式是 hello
hello: () => void
}
class Adaptee {
// 要被适配的类方法叫 sayHello
sayHello() {
console.log('hello')
}
}
// 适配器继承 Adaptee 并实现 ITarget
class Adapter extends Adaptee implements ITarget {
hello() {
// 用 sayHello 对接到 hello
super.sayHello()
}
}
```
组合:
```typescript
interface ITarget {
// 标准方式是 hello
hello: () => void
}
class Adaptee {
// 要被适配的类方法叫 sayHello
sayHello() {
console.log('hello')
}
}
// 适配器继承 Adaptee 并实现 ITarget
class Adapter implements ITarget {
private adaptee: Adaptee
constructor(adaptee: Adaptee) {
this.adaptee = adaptee
}
hello() {
// 用 adaptee.sayHello 对接到 hello
this.adaptee.sayHello()
}
}
```
## 弊端
**使用适配器模式本身就可能是个问题**,因为一个好的系统内部不应该做任何侨界,模型应该保持一致性。只有在如下情况才考虑使用适配器模式:
1. 新老系统接替,改造成本非常高。
2. 三方包适配。
3. 新旧 API 兼容。
4. 统一多个类的接口。一般可以结合工厂方法使用。
## 总结
适配器模式也符合开闭原则,在不对原有对象改造的前提下,构造一个适配器就能完成模块衔接。
适配器模式的实现分为类与对象模式,类模式用继承,对象模式用组合,分别适用于 `Adaptee``Target` 结构相似与结构差异较大的场景,在任何情况下,组合模式都是灵活性最高的。
最后用一张图概括一下适配器模式的思维:
<img width=400 src="https://img.alicdn.com/tfs/TB16L2n1AY2gK0jSZFgXXc5OFXa-1254-630.png">
> 讨论地址是:[精读《设计模式 - Adapter 适配器模式》· Issue #279 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/279)
**如果你想参与讨论,请 [点击这里](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,111 @@
# Bridge(桥接模式)
Bridge(桥接模式)属于结构型模式,是一种解决继承后灵活拓展的方案。
**意图:将抽象部分与它的实现部分分离,使它们可以独立地变化。**
桥接模式比较难理解,我会一步步还原该设计模式的思考,让你体会这个设计模式是如何一步一步被提炼出来的。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 汽车生产线改造为新能源生产线
汽油车与新能源汽车的生产流程有很大相似之处,那么汽油车生产线能否快速改造为新能源汽车生产线呢?
如果汽油车生产线没有将内部实现解耦,只把生产汽油车的各部分独立了出来,对新能源车生产线是没什么用处的,但如果汽油车生产线提供了更底层的能力,比如加装轮胎,加装方向盘,那么这些步骤是可以同时被汽油车与新能源车所共享的。
在设计汽油车生产线时,就将生产过程与汽油车解耦,使其可以快速运用到新能源汽车的生产,这就是桥接模式的一种运用。
### 窗口(Window)类的派生
假设存在一个 Window 窗口类,其底层实现在不同操作系统是不一样的,假设对于操作系统 A 与 B,分别有 AWindow 与 BWindow 继承自 Window,现在要做一个新功能 ManageWindow(管理器窗口),就要针对操作系统 A 与 B 分别生成 AManageWindow 与 BManageWindow,这样显然不容易拓展。
无论我们新增支持 C 操作系统,还是新增支持一个 IconWindow,类的数量都会成倍提升,因为我们所做的 AMangeWindow 与 BMangeWindow 同时存在两个即以上的独立维度,这使得增加维度时,代码变得很冗余。
### 适配多个搭建平台的物料
做前端搭建平台时,经常出现一些物料(组件)因为固化了某个搭建平台的 API,因此无法迁移到另一个搭建平台,如果要迁移,就需要为不同的平台写不同的组件,而这些组件中大部分 UI 逻辑都是一样的,这使得产生大量代码冗余,如果再兼容一个新搭建平台,或者为已有的 10 个搭建平台再创建一个新组件,工作量都是写一个组件的好几倍。
## 意图解释
**意图:将抽象部分与它的实现部分分离,使它们可以独立地变化。**
“抽象” 部分与 “实现” 部分分离,这句话看起来很像接口与实现。确实,如果 “抽象” 指的是 接口(Interface),而 “实现” 指的是 类(Class) 的话,这就是简简单单的 `class MyWindow implements Window` 类实现过程而已。
但后半句话 “使它们可以独立地变化” 会让你难以和前半句联系起来,如果说 “抽象” 不变,“实现” 可以随意改变还好理解,但反过来就难以解释了。
**其实桥接模式中,抽象指的是一种接口(Abstraction),实现指的也是一种接口(Implementor),其中 Implementor 并不是直接实现了 Abstraction 定义的接口,而是提供更底层的方法,使 Abstraction 可以基于它们封装出自己的接口实现。**
这样一来,Abstraction 的接口可以随意变化,毕竟调用的是 Implementor 提供函数的组合,只要 Implementor 提供的功能全面,Implementor 可以不变;相应的,Implementor 的实现也可以随意变化,只要提供的底层函数不变,就不影响 Abstraction 对其的使用。
上面举的三个例子都是这样,我们应该把汽油车生产线的标准与通用汽车生产线标准分离、将具体功能窗口与适配不同操作系统的基础 GUI 能力隔离、将组件功能与平台功能隔离,只有做到了抽象部分与实现部分的隔离,才可以通过组合满足更多场景。
## 结构图
<img width=600 src="https://img.alicdn.com/tfs/TB1mZv52oH1gK0jSZSyXXXtlpXa-1726-696.png">
- Abstraction:定义抽象类的接口。
- RefinedAbstraction:扩充 Abstraction。
- Implementor:定义实现类的接口,该接口可以与 Abstraction 接口不一致。
- ConcreteImplementor:实现 Implementor 接口并定义它的具体实现。
抽象部分就是 Abstraction,实现部分就是 Implementor,在这个结构图中,它们是分离的,可以各自独立变化的,桥接模式,就是指 `imp` 这个桥,通过 Implementor 实现 Abstraction 接口,就算是桥接上了,这种组合的桥接相比普通的类实现更灵活,更具有拓展性。
## 代码例子
对于完全版桥接模式,Implementor 可以有多套实现,Abstraction 不需关心具体用的是哪一种实现,而是通过抽象工厂方式封装。下面举一个简单版的例子。
下面例子使用 typescript 编写。
```typescript
class Window {
private windowImp: WindowImp
public drawBox() {
// 通过画线生成 box
this.windowImp.drawLine(0, 1)
this.windowImp.drawLine(1, 1)
this.windowImp.drawLine(1, 0)
this.windowImp.drawLine(0, 0)
}
}
// 拓展 window 就非常容易
class SuperWindow extends Window {
public drawIcon {
// 通过自定义画线
this.windowImp.drawLine(0, 5)
this.windowImp.drawLine(3, 9)
}
}
```
桥接模式的精髓,通过上面的例子可以这么理解:
`Window` 的能力是 `drawBox`,那继承 `Window` 容易拓展 `drawIcon` 吗?默认是不行的,因为 `Window` 并没有提供这个能力。经分析可以看出,划线是一种基础能力,不应该与 `Window` 代码耦合,因此我们将基础能力放到 `windowImp` 中,这样 `drawIcon` 也可以利用其基础能力画线了。
## 弊端
不要过度抽象,桥接模式是为了让类的职责更单一,维护更便捷,但如果只是个小型项目,桥接模式会增加架构设计的复杂度,而且不正确的模块拆分,把本来关联的逻辑强制解耦,在未来会导致更大的问题。
另外桥接模式也有简单与复杂模式之分,只有一种实现的场景就不要用抽象工厂做过度封装了。
## 总结
桥接模式让我们重新审视类的设计是否合理,把类中不相关,或者说相互独立的维度抽出去,由桥接模式做桥接的方式使用,这样会使每个类功能更内聚,代码量更少更清晰,组合能力更强大,更容易做拓展。
下图做了一个简单的解释:
<img width=500 src="https://img.alicdn.com/tfs/TB1nossndTfau8jSZFwXXX1mVXa-1308-1078.png">
> 讨论地址是:[精读《设计模式 - Bridge 桥接模式》· Issue #280 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/280)
**如果你想参与讨论,请 [点击这里](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,111 @@
# Composite(组合模式)
Composite(组合模式)属于结构型模式,是一种统一管理树形结构的抽象方式。
**意图:将对象组合成树形结构以表示 “部分 - 整体” 的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 公司组织关系树
公司组织关系可能分为部门与人,其中人属于部门,有的人有下属,有的人没有下属。如果我们统一将部门、人抽象为组织节点,就可以方便的统计某个部门下有多少人、财务数据等等,而不用关心当前节点是部门还是人。
### 操作系统的文件夹与文件
操作系统的文件夹与文件也是典型的树状结构,为了方便递归出文件夹内文件数量或者文件总大小,我们最好设计的时候就将文件夹与文件抽象为文件,这样每个节点都拥有相同的方法添加、删除、查找子元素,而不需要关心当前节点是文件夹或是文件。
### 搭建平台的组件与容器
容器与组件的关系很小,用户常常认为容器也是一种组件,但搭建平台实现时,容器与组件稍有不同,不同之处在于容器可以嵌套子元素,而组件不可以。如果因此搭建平台就将组件分为容器与组件,会导致 API 割裂为两套,不利于组件开发者维护与用户理解,比较好的设计思路是将组件与容器统一看成组件,组件只是一种没有子元素的特殊容器,这样组件与容器就可以拥有相同的 API,统一理解与操作了。
## 意图解释
**意图:将对象组合成树形结构以表示 “部分 - 整体” 的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。**
比较好理解,组合是指多个对象虽然有一定差异,但共同组合成了一个树形结构,那么对象之间就一定存在 “部分 - 整体” 的关系,组合模式要求我们抽象一个对象 `Component` 作为统一操作模型,叶子结点与非叶子结点都实现了所有功能,即便是没有子元素的叶子结点,为了强调透明性,还是具备比如 `getChildren` 方法,只不过永远都返回 `null`
## 结构图
<img width=600 src="https://img.alicdn.com/tfs/TB19t0j27Y2gK0jSZFgXXc5OFXa-1504-678.png">
其中 `Component` 是组合中对象声明接口,一般会实现所有公共类的所有接口,还要提供一个接口管理其子组件。
`Leaf` 表示叶子结点,没有子结点,相应的 `Composite` 就是有子结点的节点。
可以看到,组合模式就是将树状结构中所有节点统一抽象了,**我们不需要关心叶子结点与非叶子结点的差异,而可以通过组合模式的抽象屏蔽掉这些差异,统一处理。**
## 代码例子
下面例子使用 typescript 编写。
```typescript
// 统一的抽象
class Component {
// 添加子元素
public add() {}
// 获取名称
public getName() {}
// 获取子元素
public getChildren() {}
}
// 非叶子结点
class Composite extends Component {
public add(component: Component) {
this.children.push(component)
}
public getName() {
return this.name
}
public getChildren() {
return this.children
}
}
// 叶子结点
class Leaf extends Component {
public add(component: Component) {
throw Error('叶子结点无法添加元素')
}
public getName() {
return this.name
}
public getChildren() {
return null
}
}
```
最后我们把对所有节点的操作都转为 `Component` 对象,而不用关心这个对象具体是 `Composite``Leaf`
## 弊端
组合模式进行了一层抽象,其实增加了复杂系统中业务复杂度。如果 `Composite``Leaf` 差异过大,那么统一抽象带来的理解成本是很高的。
同时,`Leaf` 不得不实现一些仅 `Composite` 存在的空函数,比如 `add` `delete`,即便这些方法对他们是无意义的,此时可能要进行统一的无效或错误处理,才能使业务层真正不用感知他们的区别,否则 `add` 可能会失败,其本质上还是将节点的区别暴露给了业务层。
## 总结
组合模式是针对树状结构这个特定场景的统一抽象方案,对降低系统复杂度有很重要的意义,同时也不要忘了过度抽象是有害的,我们要拿捏其中的度。
下图做了一个简单的解释:
<img width=500 src="https://img.alicdn.com/tfs/TB1_g24rvzO3e4jSZFxXXaP_FXa-1228-614.png">
程序中始终关注 `Component` 就行了,树状结构的差异已经被抹平。
> 讨论地址是:[精读《设计模式 - Composite 组合模式》· Issue #284 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/284)
**如果你想参与讨论,请 [点击这里](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,104 @@
# Decorator(装饰器模式)
Decorator(装饰器模式)属于结构型模式,是一种拓展对象额外功能的设计模式,别名 `wrapper`
**意图:动态地给一个对象添加一些额外的职责。就增加功能来说,Decorator 模式相比生成子类更为灵活。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 相框
照片 + 相框 = 带相框的照片,这背后就是一种装饰器模式:照片具有看的功能,相框具有装饰功能,在你看照片的基础上,还能看到精心设计的相框,增加了美感,同时相框还可以增加照片的保存时间与安全性。
相框与照片是一种组合关系,任何照片都可以放到相框中,而不是每个照片生成一个特定的相框,显然,组合的方式更加灵活。
### 带有缓存的文件读写
假设我们有一个类 `FileIO` 用来读写文件,但是没有缓存能力,此时是新建一个 `CachedFileIO` 子类好,还是创建一个 `CachedIO`?
一眼看上去好像 `CachedFileIO` 用起来更方便,而 `CachedIO` 的用法是 `new CachedIO(new FileIO())` 稍微麻烦一些,但如果我们增加一个网络读写类 `NetworkIO`,一个数据库读写类 `DBIO` 呢?
显然,继承的方式会使子类数量极速膨胀,而组合的方式则非常灵活,生成一个支持缓存的网络读写器,只需要 `new CachedIO(new NetworkIO())` 即可,这就是组合灵活的地方。
当然,为了实现这个能力,`CachedIO` 需要与 `FileIO``CachedFileIO``CachedIO` 继承自同一个类,具备相同的接口。
### 搭建平台的组件 wrapper
装饰器模式别名也叫 `wrapper``wrapper` 也经常在前端搭建场景中遇到,当搭建平台加载一个组件时,希望拓展其基础能力,一般会使用 `wrapper` 层对组件进行嵌套,`wrapper` 层就是在不改变 API 的基础上,对第三方组件进行增强。
## 意图解释
**意图:动态地给一个对象添加一些额外的职责。就增加功能来说,Decorator 模式相比生成子类更为灵活。**
不同于继承,组合可以在运行时进行,所以称之为 “动态添加”,这里的 “额外职责” 泛指一切功能,比如在按钮点击时进行一些 log 日志的打印,在绘制 text 文本框时,额外绘制一个滚动条和边框等等。
“就增加功能来说,Decorator 模式相比生成子类更为灵活” 这句话的含义是,组合比继承更灵活,当可拓展的功能很多时,继承方案会产生大量的子类,而组合可以提前写好处理函数,在需要时动态构造,显然是更灵活的。
## 结构图
<img width=600 src="https://img.alicdn.com/tfs/TB1cmhe3FY7gK0jSZKzXXaikpXa-1624-688.png">
`ConcreteComponent` 指的是需要被装饰的组件,可以看到,装饰器 `Decorator` 与他都继承同一个类,这样能保证 API 的一致,才保证无论装饰多少层,始终符合 `Component` 类型。
装饰器如果有多种,就要将 `Decorator` 申明为抽象类,`ConcreteDecoratorA``ConcreteDecoratorB` 分别实现它们,如果只有一种装饰器,可以退化到 `Decorator` 自身就是一种实现。
## 代码例子
下面例子使用 typescript 编写。
```typescript
class Component {
// 具有点击事件
public onClick = () => {}
}
class Decorator extends Component {
private _component
constructor(component) {
this._component = component
}
public onClick = () => {
log('打点')
this._component.onClick()
}
}
const component = new Component()
// 一个普通的点击
component.onClick()
const wrapperComponent = new Decorator(component)
// 一个具有打点功能的点击
wrapperComponent.onClick()
```
其实方法很简单,通过组合,我们得到了一个能力更强的组件,而实现的方式就是利用构造函数保存组件实例,并在复写函数时,增加一些增强实现。
## 弊端
装饰器的问题也是组合的问题,过多的组合会导致:
- 组合过程的复杂,要生成过多的对象。
- 包装器层次增多,会增加调试成本,我们比较难追溯到一个 bug 是在哪一层包装导致的。
## 总结
装饰器模式是非常常用的模式,Decorator 是一个透明的包装,只要保证包装的透明性,就可以最大限度发挥装饰器模式的优势。
最后总结一个装饰器应用图:
<img width=500 src="https://img.alicdn.com/tfs/TB1wlpgqPMZ7e4jSZFOXXX7epXa-1232-478.png">
> 讨论地址是:[精读《设计模式 - Decorator 装饰器模式》· Issue #286 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/286)
**如果你想参与讨论,请 [点击这里](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,99 @@
# Facade(外观模式)
Facade(外观模式)属于结构型模式,是一种日常开发中经常被使用到的设计模式。
**意图:为子系统中的一组接口提供一个一致的界面,Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
## 意图解释
### 图书管理员
图书馆是一个非常复杂的系统,虽然图书按照一定规则摆放,但也只有内部人员比较清楚,作为一位初次来的访客,想要快速找到一本书,最好的办法是直接问图书管理员,而不是先了解这个图书馆的设计,因为你可能要来回在各个楼宇间奔走,借书的流程可能也比较长。
图书管理员就起到了简化图书馆子系统复杂度的作用,我们只要凡事询问图书管理员即可,而不需要关心他是如何与图书馆内部系统打交道的。
### 最多跑一次便民服务
浙江省推出的最多跑一次服务非常方便,很多办事流程都简化了,无论是证件办理还是业务受理,几乎只要跑一次,而必须要持续几天的流程也会通过手机短信或者 App 操作完成后续流程。
这就相当于外观模式,因为政府系统内部的办事流程可能没有太大变化,但通过抽象出 Facade(外观),让普通市民可以直接与便民办事处连接,而不需要在车管所与驾校之间来回奔波,背后的事情没有少,只是便民办事处帮你做了。
### Iphone 快捷指令功能
手机的 App 非常多,而我们需要了解每个功能在哪个 App 上才能运用自如,而快捷指令功能可以将 App 的某些功能单独提取出来,形成一套新的功能组,我们可以只接触到 “拍照” “付款” “计算”,而不用管背后是调用了支付宝还是微信、系统内置摄像机还是其他摄像 App,也不用关心这个 App 内部功能的入口在哪里,这些对接都在快接指令中自动完成。
快捷指令也是一种外观模式。
## 意图解释
**意图:为子系统中的一组接口提供一个一致的界面,Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。**
为降低一个拥有多个接口的子系统内部复杂性,我们需要一个外观来屏蔽内部的复杂性,因此外观模式就是定义一个高层接口,这个接口直连子系统的内部实现,但调用这个高层接口的人不需要关心子系统内部的实现,这样,对于不想了解子系统内部实现的人来说,提高了易用度。
当然如果想要深度定制,就可以绕过外观模式,直接使用子系统提供的类,所以说并不是有了外观模式就必须通过外观调用,而是根据实际需要判断使用哪种调用方式。
## 结构图
<img width=600 src="https://img.alicdn.com/tfs/TB1j9gZ3.T1gK0jSZFrXXcNCXXa-1082-412.png">
可以看到,Facade 直接指向子系统中的类,**而子系统的类不会反向指向 Facade**。
## 代码例子
下面例子使用 typescript 编写。
```typescript
// 假设一个子系统是三个类结合使用的,为了抽象而解耦开了
class A {
constructor(b: B) {
this.b = b
}
}
class B {
constructor(c: C) {
this.c = c
}
}
class C {
}
// 它们组合成了一种常用功能,我们可以使用外观模式屏蔽子类的细节直接使用
class Compile {
public run() {
const parser = new A(new B(new C))
parser.run()
}
}
const compile = new Compile()
compile.run()
```
这样我们只要知道 `Compile` 类就可以了,而不需要了解背后的 `A` `B` `C` 以及其组合关系。
## 弊端
外观模式并不适合于所有场景,当子系统足够易用时,再使用外观模式就是画蛇添足。
另外,当系统难以抽象出通用功能时,外观模式的设计可能也无所适从,因为设计的高层接口可能适用范围很窄,此时外观模式的意义就比较小。
## 总结
其实抽象工厂模式也可以代替外观模式,来实现隐藏子类具体实现的效果,但外观模式描述更具有通用性。
> 讨论地址是:[精读《设计模式 - Facade 外观模式》· Issue #288 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/288)
**如果你想参与讨论,请 [点击这里](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,110 @@
# Flyweight(享元模式)
Flyweight(享元模式)属于结构型模式,是一种共享对象的设计模式。
**意图:运用共享技术有效地支持大量细粒度的对象。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 富文本编辑器的字母对象
富文本编辑器在英文环境下,其中的文本由大量字母组成,为了便于做统一的格式化、计算等处理,需要将每个字母都存储为对象,但这样存储的代价太大了。
已知英文字母一共 26 个,所以文档中存在大量重复使用的字母,而每个字母除了位置信息外,其它信息都是相同且只读的,那么有办法降低富文本场景巨大的字母对象数量吗?
### 网盘存储
当我们上传一部电影时,有时候几十 GB 的内容不到一秒就上传完了,这是网盘提示你,“已采用极速技术秒传”,你会不会心生疑惑,这么厉害的技术为什么不能每次都生效?
另外,网盘存储时,同一部电影可能都会存放在不同用户的不同文件夹中,而且电影文件又特别巨大,和富文本类似,电影文件也只有存放位置是不同的,而其余内容都特别巨大且只读,有什么办法能优化存储呢?
### 大型多人游戏
玩多人游戏时,为了防止外挂,一般对象的创建与计算是在服务器完成的,那如何保证一个玩家拾取物品后,另一个玩家看到的物品会消失?
其实道理已经不言而喻了,虽然在不同客户端之间,游戏对象是相互独立的,但在一局游戏中,所有玩家的对象在服务器是共享的。
## 意图解释
“共享” 就是享元模式的精髓,将那些大量的,具有很多内部状态而外部状态很少的对象进行共享,就是享元模式的使用方式。
**意图:运用共享技术有效地支持大量细粒度的对象。**
共享技术可以理解为缓存,当一个对象创建后,再次访问相同对象时,就不再创建新的对象了,而只有在访问没有被缓存过的对象时,才创建新对象,并立即缓存起来。
这样做可以有效支持大量细粒度的对象,在富文本例子中,**无数的字母就是大量细粒度对象**,在网盘存储中,**电影文件就是大量细粒度对象**,在大型多人游戏中,**每局游戏内存在大量细粒度对象**。
这些细粒度对象都拥有相同的特征:
- 量特别大,这个很容易理解。
- 具有大量内部状态,且不随着客户端的不同而改变。
- 富文本的字母,不因为展示到不同语句中而发生变化,变化的只有状态;电影文件,不因为放在不同用户的文件夹中而对电影内容产生变化,变化的只有属于哪些用户,放在哪些文件夹里;多人游戏中,同一把武器对象,不因为有多个人的电脑独立运行而拥有更多的弹药,变化的只有在哪些客户端被访问。
- 具有少量外部状态,甚至没有外部状态。在上面已经解释了,字母的位置、电影的位置、游戏对象的客户端都是外部状态,这些外部状态相比于其内部状态来说,大小微乎其微,且方便分离存储。
遇到这种情况,我们就可以将对象内部状态共享,外部状态独立存储,从而节省大量空间。
尤其是对于网盘的场景,承诺给用户 2 TB 的存储空间,这个用户看到其他人分享了 100 个电影,就点击 “下载到我的网盘”,**此时虽然占用了自己 1 TB 的网盘空间,但实际上网盘运营商并没有增加 1 TB 的存储空间,实际可能增加了 1kb 的存储空间,记录了存储位置**,这就是网盘鸡贼的地方,并不占用空间的内容,却占用了用户真金白银购买的存储空间。
当然,这就是享元模式的价值,对网盘公司来说,价值巨大,对用户来说,没有价值。所以享元模式的价值体现在全局,比如对整个富文本编辑器来说,减少了巨量字母对象数量,但对于每一个字母对象而言,并没有任何优化。
## 结构图
<img width=800 src="https://img.alicdn.com/tfs/TB1KMTY4UY1gK0jSZFMXXaWcVXa-1420-886.png">
对于 Client 而言,下图描述了如何共享 Flyweight:
<img width=800 src="https://img.alicdn.com/tfs/TB1JwLL4QL0gK0jSZFtXXXQCXXa-1460-542.png">
- Flyweight: 共享接口,通过这个接口可以操作对象的外部状态。
- ConcreteFlyweight: 实现 Flyweight 接口的对象,这个对象是可被共享的。
- UnsharedConcreteFlyweight: 不被共享的对象,因为在享元模式中,实际上并不是所有对象都可以被共享。
- FlyweightFactory: 创建并管理 Flyweight 对象,通过其返回的 Flyweight 对象,如果已创建,则会返回之前创建的那个,没有的话才会创建一个新的。
- Client: 使用 Flyweight 的客户端。
通过第二个图可以明显看到,两个不同的 Client 持有了相同 `aConcreteFlyweight` 引用。
## 代码例子
下面例子使用 typescript 编写。
```typescript
class FlyweightFactory {
public getFlyWeight(key) {
if (this.flyweight[key]) {
return this.flyweight[key]
}
const flyweight = new Flyweight()
this.flyweight[key] = flyweight
return flyweight
}
}
```
`FlyweightFactory` 提供的 `getFlyWeight` 方法,实际上是按照 `key``flyweight` 实例进行缓存,相同 `key` 下只存储一个 `flyweight` 实例。
## 弊端
如果细粒度对象不多,则没必要使用享元模式。
另外,就算细粒度对象很多,如果对象内部状态并不多,主要都是外部状态,那么享元模式就起不到什么作用了,**因为享元模式通过共享对象,只能节省内部状态,而不能节省外部状态。**
另外,如果享元模式映射到的共享对象数量并没有比原始对象少出数量级关系,使用的意义也不大。比如富文本编辑器的例子,对于英文来说,一共就 26 个字母,那么 1 万字的文章优化比例是 10000:26,但对于中文文章而言,文字实例本身就很多,可能 1 万字的文章中,汉字去重后依然有 3000 个,那么优化比例就是 10000:3000,此时享元模式的意义就没那么大了。
## 总结
享元模式的本质就是尽可能的共享对象,特别适用于存在大量细粒度对象,而这些对象内部状态特别多,外部状态较少的场景。
对于云存储来说,享元模式是必须使用的,因为云存储的场景决定了,存在大量细粒度文件对象,而存在大量只读的文件,就非常适合共享一个对象,每个用户存储的只是引用。
> 讨论地址是:[精读《设计模式 - Flyweight 享元模式》· Issue #290 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/290)
**如果你想参与讨论,请 [点击这里](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,100 @@
# Proxy(代理模式)
Proxy(代理模式)属于结构型模式,通过访问代理对象代替访问原始对象,以获得一些设计上的便捷。
**意图:为其他对象提供一种代理以控制这个对象的访问。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 获得文本对象长度
获得一个文本对象长度,必须要真正渲染出来,而渲染是比较耗时的,我们可能只在某些场景下需要访问文本对象长度,而更多时候只需要读取文本内容,这两种操作耗时是完全不同的,如何做到业务层调用无感知,来优化执行耗时呢?
代理模式可以解决这个问题,我们将业务层使用的文本对象替换为代理对象,这个代理对象初始化并不渲染文本,而是在调用文本长度时才渲染。
### 对象访问保护
某个大型系统开发完了,突然要求增加代码访问权限体系,不同模块对相同的底层对象拥有不同访问权限,此时这个权限控制逻辑如果写入底层对象,就违背了开闭原则,而对象本身的实现也不再纯粹,增加了维护成本,如何做到不修改对象本身,实现权限控制呢?
代理模式也能解决,将底层对象导出替换为代理对象,由代理对象控制访问权限即可。
### 对象与视图双向绑定
Angular 或 Vue 这类前端框架采用双向绑定视图更新技术,即对象修改后,使用到的视图会自动刷新,这就需要做到以下两点:
1. 在对象被访问时,记录调用的视图绑定。
2. 在对象被修改时,刷新调用它的视图。
问题是,在业务代码使用对象与修改对象的地方插入这段逻辑,显然会增加巨大的维护成本,如何做到业务层无感知呢?
代理模式可以很好的解决这个问题,其实业务层拿到的对象已经是代理对象了,它在被访问与被修改时,都会执行固定的钩子做视图绑定与视图刷新。
## 意图解释
**意图:为其他对象提供一种代理以控制这个对象的访问。**
代理模式的意图很容易理解,就是通过代理对象代替原始对象的访问。
这只是代理模式的实现方式,代理模式真正的难点不在于理解它是如何工作的,而是理解哪些场景适合用代理,或者说创建了代理对象,怎么用才能发挥它的价值。
在上面例子中,已经举出了几种常见代理使用场景:
1. 对开销大的对象使用代理,以按需使用。
2. 对需要保护的对象进行代理,在代理层做权限控制。
3. 在对象访问与修改时要执行一些其他逻辑,适合在代理层做。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i3/O1CN01eZHGHQ28t0oeHYzas_!!6000000007989-2-tps-1262-522.png">
使用时关系如下:
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN01iwyMKQ1KbOnR0N2AP_!!6000000001182-2-tps-1270-206.png">
Subject 定义的是 RealSubject 与 Proxy 共用的接口,这样任何使用 RealSubject 的地方都可以使用 Proxy。
RealSubject 指的是原始对象,Proxy 是一个代理实体。
关系图中可以看出,当客户端要访问 subject 时,第一层访问的是 Proxy 代理,由这个代理将 realSubject 转发给客户端。
## 代码例子
下面例子使用 typescript 编写。
```typescript
// 对象 obj
const proxy = new Proxy(obj, {
get(target,key) {}
set(target,key,value) {}
})
```
JS 创建代理还是蛮简单的,代理可以控制对象的所有成员属性,包括成员变量与成员方法的访问(get)与修改(set)。
## 弊端
代理模式会增加微弱的开销,因此请不要将所有对象都变成代理,没有意义的代理只会徒增程序开销。
另外代理对象过多,也会导致调试困难,因为代理层的存在,我们往往可能忽略这一层带来的影响,导致忘记这个对象其实是一个代理。
## 总结
代理和继承有足够多的相似之处,继承中,子类几乎可以人为是对父类的代理,子类可以重写父类的方法。但代理和继承还是有区别的:
如果你没有采用 `new Proxy` 这种 API 创建代理,而是采用继承的方式实现,你会一下子继承这个类的所有方法,而做不到按需控制访问权限的灵活效果,所以代理比继承更加灵活。
JS 的 `new Proxy` 对应了 Java 动态代理模式,一般认为动态代理比静态代理更强大。
最后,还要重申那句话,代理模式理解与运用并不难,难就难在能否在恰当的场合想到它,双向绑定几乎是代理模式最好的例子。
> 讨论地址是:[精读《设计模式 - Proxy 代理模式》· Issue #291 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/291)
**如果你想参与讨论,请 [点击这里](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 @@
# Chain of Responsibility(职责链模式)
Chain of Responsibility(职责链模式)属于行为型模式。行为型模式不仅描述对象或类的模式,还描述它们之间的通信模式,比如对操作的处理应该如何传递等等。
**意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。**
> 几乎所有设计模式,在了解到它之前,笔者就已经在实战中遇到过了,因此设计模式的确是从实践中得出的真知。但另一方面,如果没有实战的理解,单看设计模式是枯燥的,而且难以理解的,因此大家学习设计模式时,要结合实际问题思考。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 中间件机制
设想我们要为一个后端框架实现中间件(知道 Koa 的同学可以理解为 Koa 的洋葱模型),在代码中可以插入任意多个中间件,每个中间件都可以对请求与响应进行处理。
由于每个中间件只响应自己感兴趣的请求,因此只有运行时才知道这个中间件是否会处理请求,那么中间件机制应该如何设计,才能保证其功能和灵活性呢?
### 通用帮助文案
如果一个大型系统中,任何一个模块点击都会弹出帮助文案,但并不是每个模块都有帮助文案的,如果一个模块没有帮助文案,则显示其父级的帮助文案,如果再没有,就继续冒泡到整个应用,展示应用级别的兜底帮助文案。这种系统应该如何设计?
### JS 事件冒泡机制
其实 JS 事件冒泡机制就是个典型的职责链模式,因为任何 DOM 元素都可以监听比如 `onClick`,不仅可以自己响应事件,还可以使用 `event.stopPropagation()` 阻止继续冒泡。
## 意图解释
JS 事件冒泡机制对前端来说太常见了,但我们换个角度,站在点击事件的角度理解,就能重新发现其设计的精妙之处:
点击事件是叠加在每层 dom 上的,由于 dom 对事件的处理和绑定是动态的,浏览器本身不知道哪些地方会处理点击事件,但又要让每层 dom 拥有对点击事件的 “平等处理权”,所以就产生了冒泡机制,与事件阻止冒泡功能。
通用帮助文案和 JS 事件冒泡很类似,只是把点击事件换成了弹出帮助文案罢了,其场景机理是一样的。
说到这,我们可以再重新理解一下职责链模式的意图:
**意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。**
请求指的是某个触发机制产生的请求,是一个通用概念。“避免请求的发送者和接收者之间的耦合关系”,指的是如果我们只有一个对象有处理请求的机会,那接收者就与发送者之间耦合了,其他接收者必须通过这个接收者才能继续处理,这种模式不够灵活。
后半句描述的是如何设计,可以实现这个灵活的模式,即将对象连成一条链,沿着链条传递该请求,直到有一个对象处理它为止。还要理解到,任何一个对象都拥有阻断请求继续传递的能力。
在中间件机制的例子中,后端 Web 框架对 Http 请求的处理就是个运用职责链模式的典型案例,因为后端框架要处理的请求是平行关系,任何请求都可能要求被响应,但对请求的处理是通过插件机制拓展的,且对每个请求的处理都是一个链条,存在处理、加工、再处理的逻辑关系。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN01PUN6ed1FnhM2CeZDn_!!6000000000532-2-tps-1126-500.png">
Handler 就是对请求的处理,可以看到这里是一条环路,只要处理完之后就可以交给下一个 Handler 进行处理,可以在中途拦截后中断,也可以穿透整条链路。
`ConcreteHandler` 是具体 Handler 的实现,他们都需要继承 Handler 以具备相同的 `HandleRequest` 方法,这样每一个处理中间件就都拥有了处理能力,使得这些对象连成的链条可以对请求进行传递。
## 代码例子
职责链实现方式非常多,比如 Koa 的洋葱模型实现原理就值得再写一篇文章,感兴趣的同学可以阅读 [co 源码](https://github.com/tj/co)。这里仅介绍最简单场景的实现方案。
职责链的简单实现模式也分为两种,一种是每个对象本身维护到下一个对象的引用,另一种是由 Handler 维护后继者。
下面例子使用 typescript 编写。
```typescript
public class Handler {
private nextHandler: Handler
public handle() {
if(nextHandler) {
nextHandler.handle()
}
}
}
```
每个 Handler 的默认行为就是触发下一个链条的 `handle`,因此什么都不做的话,这个链条是完全打通的,因此我们可以在链条的任何一环进行处理。
处理的方式就是重写 `handle` 函数,我们在重写时,可以维持对 `nextHandler.handle()` 的调用,以使得链条继续向后传递,也可以不调用,从而终止链条向后传递。
## 弊端
职责链模式不保证每个中间件都有机会处理请求,因为中间件顺序的问题,后面中间件可能被前面的中间件阻断,因此当中间件之间存在不信任关系时,职责链模式并不能保证中间件调用的可靠性。
另外就是不要扩大设计模式的使用范围,对一堆对象的连续调用就没必要使用职责链模式,因为职责链适合处理对象数量不确定、是否处理请求由每个对象灵活决定的场景,而确定了对象数量以及是否调用的场景,就没必要使用职责链模式了。
## 总结
职责链模式是插件机制常用的设计模式,在事件机制、请求处理中有广泛的应用。
职责链模式还可以与组合模式组合使用,因为组合模式描述的是一种统一管理的树形结构,每个节点都可以把自己的父节点作为后继节点。实际上 dom 结构就是一种组合模式,事件冒泡就是在其基础上拓展的职责链模式。
> 讨论地址是:[精读《设计模式 - Chain of Responsibility(职责链模式)》· Issue #292 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/292)
**如果你想参与讨论,请 [点击这里](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,119 @@
# Command(命令模式)
Command(命令模式)属于行为型模式。
**意图:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 点菜是命令模式
为什么顾客会找服务员点菜,而不是直接冲到后厨盯着厨师做菜?因为做菜比较慢,肯定会出现排队的现象,而且有些菜可能是一起做效率更高,所以将点菜和做菜分离比较容易控制整体效率。
其实这个社会现象就对应编程领域的命令模式:点菜就是一个个请求,点菜员记录的菜单就是将请求生成的对象,点菜员不需要关心怎么做菜、谁来做,他只要把菜单传到后厨即可,由后厨统一调度。
### 大型软件系统的操作菜单
大型软件操作系统都有一个特点,即软件非常复杂,菜单按钮非常多。但由于菜单按钮本身并没有业务逻辑,所以通过菜单按钮点击后触发的业务行为不适合由菜单按钮完成,此时可利用命令模式生成一个或一系列指令,由软件系统的实现部分来真正执行。
### 浏览器请求排队
浏览器的请求不仅会排队,还会取消、重试,因此是个典型的命令模式场景。如果不能将 `window.fetch` 序列化为一个个指令放入到队列中,是无法实现请求排队、取消、重试的。
## 意图解释
**意图:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。**
一个请求指的是来自客户端的一个操作,比如菜单按钮点击。重点在点击后并不直接实现,而是将请求封装为一个对象,可以理解为从直接实现:
```typescript
function onClick() {
// ... balabala 实现逻辑
}
```
改为生成一个对象,序列化这个请求:
```typescript
function onClick() {
concreteCommand.push({
// ... 描述这个请求
})
// 执行所有命令队列
concreteCommand.executeAll()
}
```
看上去繁琐了一些,但得到了后面所说的好处:“从而使你可用不同的请求对客户进行参数化”,**也就是可以对任何请求进行参数化存储,我们可以在任意时刻调用。** 这相当于掌握了执行时机,可以在任意时刻调用,以实现排队或记录日志,如果再记录下反向操作信息,就可以实现撤销重做了。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i2/O1CN01preTih1iRMuH3oQYY_!!6000000004409-2-tps-1846-620.png">
Command 是命令的接口,一般固定有一个 `execute` 方法。
ConcreteCommand 是命令接口的实现,它会注入具体执行者 `Receiver`,它实现的 `execute` 方法会调用 `receiver.execute` 来具体执行。
`Invoker` 是执行请求的命令,其实上面都在推入命令,并没有真正执行,如果排队结束或点击撤销重做时,就触发了 Invoker 实际,就该调用对应的 Command 执行啦。
## 代码例子
下面例子使用 typescript 编写。
首先看最终执行态,最终执行需要先添加命令,再执行命令:
```typescript
const command1 = new Command('balabala1')
const command2 = new Command('balabala2')
const invoker = new Invoker()
invoker.push(command1)
invoker.push(command2)
invoker.execute()
```
`Invoker` 内部用一个队列维护,执行的时候其实是 `for` 循环执行了每个 `command.execute()`:
```typescript
class Invoker {
push(command) {
// 队列里推入命令
this.commands.push(command)
}
execute() {
this.commands.forEach(command => command.execute())
// 别忘了清空 this.commands
}
}
```
## 弊端
命令模式需要注意序列化大小,一般分为:
1. 仅记录操作。
2. 记录全量快照。
3. 全量快照共享内存。
记录操作是较为精细的管理方式,并且可以延伸出协同编辑功能。记录快照要注意尽量共享内存,防止快照过大,而且协同编辑场景因为快照无法做冲突处理,所以快照模式在协同编辑场景无法应用。
另外要识别没必要使用命令模式的场景,对于没有撤销重做的前端大部分场景来说,都无需改为命令模式。
## 总结
命令模式本质上就是将操作抽象为可序列化的命令,使操作可以在合适的时间执行,这种设计带来了许多额外好处。
利用命令模式可以达到高内聚低耦合的效果,提升代码可维护性,也可以实现撤销重做、协同编辑等功能性需求。
> 讨论地址是:[精读《设计模式 - Command(命令模式)》· Issue #295 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/295)
**如果你想参与讨论,请 [点击这里](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,125 @@
# Interpreter(解释器模式)
Interpreter(解释器模式)属于行为型模式。
**意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器。这个解释器使用该表示来解释语言中的句子。**
任何一门语言,无论是日常语言还是编程语言都有明确的语法,只要有语法就可以用文法描述,并通过语法解释器将字符串的语言结构化。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### SQL 解释器
SQL 是一种描述语言,所以也适用于解释器模式。不同的 SQL 方言有不同的语法,我们可以根据某种特定的 SQL 方言定制一套适配它的文法表达式,再利用 antlr 解析为一颗语法书。在这个例子中,antlr 就是解释器。
### 代码编译器
程序语言也因为其天然是字符串的原因,和 SQL、日常语言都类似,需要一种模式解析后才能工作。不同的语言有不同的文法表示,我们只需要一个类似 antlr 的通用解释器,通过传入不同的文法表示,返回不同的对象结构。
### 自然语言处理
自然语言处理也是解释器的一种,首先自然语言处理一般只能处理日常语言的子集,因此先定义好支持的范围,再定义一套分词系统与文法表达式,并将分词后的结果传入灌入了此文法表达式的解释器,这样解释器可以返回结构化数据,根据结构化数据再进行分析与加工。
## 意图解释
**意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器。这个解释器使用该表示来解释语言中的句子。**
对于给定的语言,可以是 SQL、代码或自然语言,“定义它的文法的一种表示” 即文法可以有多种表示,只需定义一种。要注意的是,不同文法执行效率会有差异。
“并定义一个解释器”,这个解释器就是类似 antlr 的东西,传给它一个文法表达式,就可以解析句子了。即:解释器(语言, 文法) = 抽象语法树。
我们可以直接把文法定义耦合到解释器里,但这样做会导致语法复杂时,解释器难以维护。比较好的方式是定义一套与解释器解耦的文法表达式,通过预处理器最终生成解释器。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN019y6R201yinq7xjJEK_!!6000000006613-2-tps-1530-776.png">
Context 是其他上下文变量,AbstractExpression 是抽象语法表达式。
可以看到,TerminalExpression(终结符)与 NonterminalExpression(非终结符) 都继承于 AbstractExpression,终结符指的是没有后续展开的符号,非终结符相反,所以非终结符又指向了 AbstractExpression,如此递归。
## 代码例子
下面例子使用 typescript 编写。
假设我们要实现以下文法:
```text
sum ::= number + number
number ::= 1 | 2
```
表达一个最简单的加法文法,其中加法表达式 sum 和 number 都是非终结符,而 +、1、2 是终结符。这个例子只能做到 1 与 2 的加法,通过这个简单例子,了解一下解释器模式的精髓吧:
```typescript
// 抽象表达式
class AbstractExpression {
interpret (text: string) {}
}
// 终结符表达式
class TerminalExpression extends AbstractExpression {
constructor(values: string[]) {
this.values = values
}
interpret(value: string) {
// 值必须是其中之一
return this.values.includes(value)
}
}
// 非终结符表达式
class NonterminalExpression extends AbstractExpression {
constructor(left: TerminalExpression, right: TerminalExpression) {
this.left = left
this.right = right
}
interpret(value: string) {
if (value.indexOf("+") === -1) {
// 必须包含 + 号
return false
}
const splitValue = value.split('+')
return this.left.interpret(splitValue[0])
&& this.right.interpret(splitValue[1])
}
}
// 调用
const context = new Context()
const terminal = new TerminalExpression(["1", "2"])
const add = new AddExpression(terminal, terminal)
add.interpreter("1 + 1") // true
add.interpreter("1 + 2") // true
add.interpreter("1 + 3") // false
add.interpreter("2 - 1") // false
```
遇到非终结符则继续调用,只有终结符才能直接判断,原理很简单。
## 弊端
上面的例子是比较低效场景,因为当语法复杂后,类的数目会明显增多,难以维护,此时需要用一个通用语法解析器,了解更多可以看笔者之前的文章:[精读《手写 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) 系列。
## 总结
解释器是一种思维,将复杂语法解析抽象为一个个独立的终结符与非终结符各自判断,只要每个文法自己的判断做好了,剩下的工作就是组装文法。
这种将单个逻辑判断与文法组装解耦的做法,可以使逻辑判断与文法组装独立变换,使复杂语法解析转化为一个个具体的简单问题。
> 讨论地址是:[精读《设计模式 - Interpreter 解释器模式》· Issue #296 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/296)
**如果你想参与讨论,请 [点击这里](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,156 @@
# Iterator(迭代器模式)
Iterator(迭代器模式)属于行为型模式。
**意图:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。**
这种设计模式要解决的根本问题是,聚合的种类有很多,比如对象、链表、数组、甚至自定义结构,但遍历这些结构时,不同结构的遍历方式又不同,所以我们必须了解每种结构的内部定义才能遍历。
比如数组我们可以利用 length + for 循环,对象我们可以 Object.keys,链表比较麻烦,需要内部暴露出元素的 `next` 以操作指向下一个元素。
迭代器模式可以做到用同一种 API 遍历任意类型聚合对象,且不用关心聚合对象的内部结构。
这种模式和 `Array.from` 有点像,但其实真正的迭代器在 JS 里是 `obj[Symbol.iterator]()`,也就是一个对象实现了 `[Symbol.interator]`,就认为是可遍历的。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
迭代器的例子非常简单,我们平时工作中有大量使用到。
### generator
`generator` 天生为迭代器的 API
```typescript
function* func () {
yield 'a';
yield 'b';
return 'c';
}
var run = func();
run.next() // {value: "a", done: false}
run.next() // {value: "b", done: false}
run.next() // {value: "c", done: true}
```
我们无需关心 generator 内部是何种存储结构,只需要调用 `.next()`,并根据返回的 `done` 来判断是否遍历完即可。在 generator 的场景中,迭代器不仅用来遍历聚合,还用于执行代码。
### 数组迭代器
我们可以用迭代器的方式遍历数组:
```typescript
const arr = [1, 2, 3]
const run = arr[Symbol.iterator]()
run.next() // {value: 1, done: false}
run.next() // {value: 2, done: false}
run.next() // {value: 2, done: false}
run.next() // {value: undefined, done: true}
```
可能有人觉得这是画蛇添足,因为毕竟遍历数组用 for 循环更方便,但这就是设计模式与非设计模式思维的区别,重要的不是用熟悉简单的 API 快速满足需求,**设计模式关注的是如何统一、抽象、低耦合的编码**。
### Map 迭代器
Map 对象也可以用迭代器方式遍历:
```typescript
const citys = new Map([['北京', 1], ['上海', 2], ['杭州', 3]])
const run = citys.entries()
run.next() // {value: ['北京', 1], done: false}
run.next() // {value: ['上海', 2], done: false}
run.next() // {value: ['杭州', 3], done: false}
run.next() // {value: undefined, done: true}
```
## 意图解释
从上面的例子可以看出,虽然用迭代器遍历数组看上去比 for 循环麻烦一点,但当我们把所有聚合类型放到一起看时,可以发现只有迭代器的 API 是最统一的,是唯一一个不需要关心聚合类型就可以完成遍历的方案。
**意图:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。**
再来看意图,就非常好理解了,我们无需关心 数组、generator、Map 内部是如何存储的,就可以进行遍历。实际上,深究 generator 内部的存储结构也没有意义,如果我们不用迭代器进行遍历,那么对于复杂结构的遍历成本是非常高的。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01lYSkni1DgFh6V3P85_!!6000000000245-2-tps-1658-754.png">
- `Aggregate`: 聚合,需要定义创建迭代器的接口。比如前端规范的 `[Symbol.iterator]()`,或者这里定义的 `CreateIterator()`
- `Iterator`: 迭代器,定义了访问与遍历的 API。
迭代器的定义很简单,实现时要考虑的因素可不少,包括:
- 健壮性。即迭代过程中增加、删除元素后,还能正常遍历。或者遍历空聚合时也要能正常工作。
- 外部控制迭代还是内部。即类似 KOA 由插件调用 `next()` 控制迭代,还是由外层统一控制迭代。
- 如何定义遍历算法。即便对于对象这种简单场景,也存在深度优先和广度优先、冒泡与捕获这几种遍历顺序,迭代器可以提供选择或者拓展的方式,自定义遍历算法。
## 代码例子
下面例子使用 typescript 编写。
```typescript
// 定义聚合接口
interface Aggregate{
getIterator: () => Iterator
}
// 定义迭代器接口
interface Iterator {
// 指向下一个
next: () => void
}
// 定义一个聚合
class List implements Aggregate {
// 存储元素
public values: string[]
// 游标
public index: number
getIterator() {
return new ConcreteIterator(this);
}
}
// List 的迭代器
class ConcreteIterator implements Iterator {
constructor(list: List) {
this.list = list
}
next() {
return this.list.values[this.list.index] // 注意边界情况,这里就不展开
this.list.index++
}
}
```
## 弊端
如果你只是遍历数组,直接用 for 循环会比迭代器方便很多,没必要为了用设计模式而用设计模式。迭代器仅在以下情况可以考虑用于数组:
1. 这个数组比较特殊,是 N 维数组,需要一次性遍历完,那么可以用迭代器。
2. 同时遍历数组和其他类型的聚合,则不论数组还是其他聚合,都用相同的迭代器模式遍历最好。
## 总结
迭代器模式比较好理解,这里补充几个相关设计模式:
- 迭代器可以和组合模式配合,在组合结构内进行递归,这样一个迭代器就可以遍历完所有组合。
- 可以用工厂模式 + 多态模式,实例化不同的迭代器的实例。
- 迭代器模式还可以与备忘录模式配合使用,当我们要还原迭代器状态时,适合在迭代器内部使用备忘录模式进行状态存储。
> 讨论地址是:[精读《设计模式 - Iterator 迭代器模式》· Issue #298 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/298)
**如果你想参与讨论,请 [点击这里](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 @@
# Mediator(中介者模式)
Mediator(中介者模式)属于行为型模式。
**意图:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。**
前端开发中,最常用的 “数据驱动” 其实就最好的诠释了中介者模式。
想一个这样的场景:
1. 按钮点击后,表单提交。按钮需要调用所有表单项获取表单值。
2. 表单关联,当勾选了城市后,才出现满意度 Input 框,此时城市勾选按钮需要引用满意度 Input 框。
3. 甚至会出现循环引用,两个输入框是互斥的,输入了一个,另一个输入框就要 Disable。
4. 当新增加一个表单项时,需要重新建立所有引用关系。
以上过程式编程方式,维护大型项目几乎是不可能的。然而数据驱动可以很好的解决这个问题,所有表单项都依赖数据,并修改数据,这样当 Input 框联动 Check 时,Input 并不需要感知 Checkbox 的存在,他只要关联数据、修改数据就行了,Checkbox 也只要关联数据和修改数据,这样不但逻辑可以独立完成,甚至可以解决循环引用的问题。
**在数据驱动的例子中,数据就是中介。** 所有 UI 之间都不会相互引用,而是通过数据这个中介来协同工作,这样做带来的明显好处是可以处理复杂项目,且易于维护。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 数据驱动
正如开篇说的,数据驱动是中介者非常经典的例子,正是因为引入了 “数据中介者”,才让前端项目的复杂度可以呈几何倍数递增,而代码的逻辑复杂度仅线性递增。因为 UI 是杂乱的且动态的,UI 间依赖会导致关系网非常复杂,且关系网一旦形成,增加一个新元素或修改就变得异常困难。
中介者模式则避开了 UI 间依赖的关系网,通过数据层统一调度,UI 受控响应,可以大大减少逻辑复杂度。
### 解决循环依赖
循环依赖几乎只能利用中介者模式解决:
```typescript
import { b } from './b'
export const a = 'a'
```
```typescript
import { a } from './a'
export const b = 'b'
```
当双方相互引用时,构成循环依赖,不仅对于模块化来说是有问题的,从逻辑上也是讲不通的,因为一定存在递归调用的问题。这是,引入第三方中介者就不仅仅是一种设计模式思维了,而是 a、b 模块中原本就有一些内容是两边公用的,一定需要提出来,而统一提出来的地方就是中介者模式的中介者部分。
### 企业组织架构
一个树状企业组织架构中,每个非叶子结点都是中介者,需要给他的子节点分配任务,并协调他们的工作,这样一来,叶子结点不需要有全局观即可工作,因为他们只需负责 “去做自己的事情”,而不需要关心 “是如何协同的”。
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01BPn79I1mzUt7yWRjB_!!6000000005025-2-tps-582-291.png">
如图所示,环境部不需要关心人事部做了什么,只要专注做好环境事物即可,他们之间的协调由总经理处理,这是一种分工协作的体现。
而只存在于理论中的网状企业管理模型,则是没有中介者的例子,所有节点都是非叶子结点,并相互引用,这样一来每个人既要做自己的工作,又要处理自己与公司里其他几万人的协同,几乎是一件不可能完成的事情,所以从设计模式角度来看,也更倾向于使用树状而不是网状模式管理企业。
## 意图解释
**意图:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。**
中介者模式非常好理解,直接看字面意思即可。所谓的对象交互,指的是对象之间是如何协同的,中介者做的是处理对象间协同的工作,而不是 “替每个对象干活”。
最后一句 “可以独立地改变他们之间的交互”,指的是对象之间协同方式不是一成不变的,比如一个输入框组件,只要实现自己的输入功能就行了,而不需要关心是如何与外界交互的。外界可以通过将其嵌入到表单中,成为表单项的一部分,也可以将其包裹一层符号后缀,成为一个专门输入金额的金额输入框。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN01FLHDqJ1c1MjH3fM4k_!!6000000003540-2-tps-1602-440.png">
- Mediator:中介者接口,定义一些通信 API。
- ConcreteMediator:具体的中介者,继承 Mediator,协调各个对象。
- Colleague:同事类,比如之前提到的输入框、文本框,每个同事之间只要知道中介者即可,他们之间不需要知道对方的存在。
## 代码例子
下面例子使用 typescript 编写。
```typescript
const memberA = new Member('美术')
const memberB = new Member('程序')
const picture = memberA.draw() // 美术画出图
const product = memberB.code(picture) // 程序按照美术画的图做产品
```
这个例子中,完成了程序与美术的协同,他们各自不需要知道对方的存在。如果后续又引入了产品、测试工种,他们之间不需要做复杂的关联,只需要在中介者增加对应协同逻辑即可。
## 弊端
中介者模式虽然好,但过度使用可能使中介者逻辑非常复杂。
我们常说管理者直接管理人数最好不要超过二十人,原因是协调本身也非常耗费精力,一个中介者节点如果管理的对象过多,可能会导致中介者本身难以维护,甚至出现 BUG。
另外则是不要过度解耦,当两个对象本身可以构成依赖关系时,使用中介者模式使其强行解耦,带来的只会是更重的理解负担。
## 总结
当一个系统对象很多,且之间关联关系很复杂,交叉引用容易产生混乱时,就可能适用中介者模式。
中介者模式也符合迪米特法则,做到了每个对象了解最少的内容,这样做对于大型程序来说是非常有益的。
> 讨论地址是:[精读《设计模式 - Mediator 中介者模式》· Issue #299 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/299)
**如果你想参与讨论,请 [点击这里](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,178 @@
# Memento(备忘录模式)
Memento(备忘录模式)属于行为型模式,是针对如何捕获与恢复对象内部状态的设计模式。
**意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。**
其实备忘录模式思想非常简单,其核心是定义了一个 Memoto(备忘录) 封装对象,由这个对象处理原始对象的状态捕获与还原,其他地方不需要感知其内部数据结构和实现原理,而且 Memoto 对象本身结构也非常简单,只有 `getState``setState` 一存一取两个方法,后面会详细讲解。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 撤销重做
如果撤销重做涉及到大量复杂对象,每个对象内部状态的存储结构都不同,如果一个一个处理,很容易写出 case by case 的冗余代码,而且在拓展一种新对象结构时(如嵌入 ppt),还需要在撤销重做时对相应结构做处理。备忘录思维相当于一种统一封装思维,不管这个对象结构如何,都可以保存在一个 Memoto 对象中,通过 `setState` 设置对象状态与 `getState` 获取对象状态,这样对于任何类型的对象,画布都可以通过统一的 API 操作进行存取了。
### 游戏保存
玩过游戏的同学都知道,许多游戏支持设置与读取多种存档,如果转换为代码模式,我们可能希望有这样一种 API 进行多存档管理:
```typescript
// 创建一盘游戏。
const game = new Game()
// 玩一会。
game.play()
// 设置一个存档(archive) 1。
const gameArchive1 = game.createArchive()
// 再玩一会。
game.play()
// 设置一个存档(archive) 2。
const gameArchive2 = game.createArchive()
// 再玩一会。
game.play()
// 这个时候角色挂了,提示 “请读取存档”,玩家此时选择了存档 1。
game.loadArchive(gameArchive1)
// 此时游戏恢复存档 1 状态,又可以愉快的玩耍了。
```
其实在游戏保存的例子中,存档就是备忘录(Memoto),而主进程管理游戏状态时,只是简单调用了 `createArchive` 创建存档,与 `load` 读取存档,即可实现复杂的游戏保存与读取功能,全程是不需要关心游戏内部状态到底有多少,以及这么多状态需要如何一一恢复的,这就是得益于备忘录模式的设计。
### 文章草稿保存
富文本编辑器的文档草稿保存也是一样的原理,简单一点只需要一个 Memoto 对象即可,如果要实现复杂一点的多版本状态管理,只需要类似游戏保存机制,存储多个 Memoto 存档即可。
## 意图解释
看到这里,会发现备忘录模式与前端状态管理的保存与恢复很像。以 Redux 类比:
`setState` 就像 `reducer` 处理的最终 `state` 状态一样,对 redux 全局状态来说,它不用关心业务逻辑(有多少 `reducer`,以及每个 `reducer` 做了什么),它只需要知道任何 `reducer` 最后处理完后都是一个 `state` 对象,将其生成出来并存下来即可。
恢复也是一样,`initState` 就类似 `getState`,只要将上一次生成的 `state` 灌进来,就可以完全还原某个时刻的状态,而不需要关心这个状态内部是怎样的。
所以其实备忘录模式早已得到广泛的应用,仔细去理解后,会发现没必要去扣的太细,以及原始设计模式是如何定义的,因为经过几十年的演化,这些设计模式思路早已融入了编程框架的方方面面。
但依照惯例,我们还是再咬文嚼字解释一下意图:
**意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。**
重点在于 “不破坏封装性” 这几个字上,程序的可维护性永远是设计模式关注的重点,无论是游戏存档的例子,还是 Redux 的例子,上层框架使用状态时,都不需要知道具体对象状态的细节,而实现这一点的就是 Memoto 这个抽象的备忘录类。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i2/O1CN01ByabMq1W05wDvuVYo_!!6000000002725-2-tps-1604-478.png">
- `Originator`:创建、读取备忘录的发起者。
- `Memento`:备忘录,专门存储原始对象状态,并且防止 Originator 之外的对象读取。
- `Caretaker`:备忘录管理者,一般用数组或链表管理一堆备忘录,在撤销重做或者版本管理时会用到。
## 代码例子
下面例子使用 typescript 编写。
下面是备忘录模式三剑客的定义:
```typescript
// 备忘录
class Memento {
public state: any
constructor(state: any) {
this.state = state
}
public getState() {
return this.state
}
}
// 备忘录管理者
class Caretaker {
private stack: Memento[] = []
public getMemento(){
return this.stack.pop()
}
public addMemento(memoto: Memento){
this.stack.push(memoto)
}
}
// 发起者
class Originator {
private state: any
public getState() {
return this.state
}
public setState(state: any) {
this.state = state
}
public createMemoto() {
return new Memoto(this.state)
}
public setMemoto(memoto: Memoto) {
this.state = memoto.getState()
}
public void setMemento(Memento memento) {
state = memento.getState();
}
}
```
下面是一个简化版客户端使用的例子:
```typescript
// 实例化发起者,比如画布、文章管理器、游戏管理器
const originator = new Originator()
// 实例化备忘录管理者
const caretaker = new Caretaker()
// 设置状态,分别对应:
// 画布的组件操作。
// 文章的输入。
// 游戏的 .play()
originator.setState('hello world')
// 备忘录管理者记录一次状态,分别对应:
// 画布的保存。
// 文章的保存。
// 游戏的保存。
caretaker.setMemento(originator.createMento())
// 从备忘录管理者还原状态,分别对应:
// 画布的还原。
// 文章的读取。
// 游戏读取存档。
originator.setMemento(caretaker.getMemento())
```
在上面例子中,备忘录管理者存储状态是数组,所以可以实现撤销重做,如果要实现任意读档,可以将备忘录变为 `Map` 结构,按照 `key` 来读取,如果没有这些要求,存一个单一的 `Memoto` 也够用了。
## 弊端
备忘录模式存储的是完整状态而非 Diff,所以可能会在运行时消耗大量内存(当然在 Immutable 模式下,通过引用共享可以极大程度缓解这个问题)。
另外就是,备忘录模式已经很大程度上被融合到现代框架中,你在使用状态管理工具时就已经使用了备忘录模式了,所以很多情况下,不需要机械的按照上面的代码例子使用。设计模式重点在于利用它优化了程序的可维护性,而不用强求使用方式和官方描述一模一样。
## 总结
备忘录模式通过备忘录对象,将对象内部状态封装了起来,简化了程序复杂度,这符合设计模式一贯遵循的 “高内聚、低耦合” 原则。
其实践行备忘录模式最好的例子就是 Redux,当项目所有状态都使用 Redux 管理时,你会发现无论是撤销重做,还是保存读取,都可以非常轻松完成,这时候,不要质疑为什么备忘录模式还在解决这种 “遇不到的问题”,因为 Redux 本身就包含了备忘录设计模式的理念。
> 讨论地址是:[精读《设计模式 - Memento 备忘录模式》· Issue #301 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/301)
**如果你想参与讨论,请 [点击这里](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,140 @@
# Observer(观察者模式)
Observer(观察者模式)属于行为型模式。
**意图:定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。**
拿项目的 npm 依赖举例子:npm 包与项目是一对多的关系(一个 npm 包被多个项目使用),当 npm 包发布新版本时,如果所有依赖于它的项目都能得到通知,并自动更新这个包的版本号,那么就解决了包版本更新的问题,这就是观察者模式要解决的基本问题。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 对象与视图双向绑定
在 [精读《设计模式 - Proxy 代理模式》](https://github.com/dt-fe/weekly/blob/v2/178.%E7%B2%BE%E8%AF%BB%E3%80%8A%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%20-%20Proxy%20%E4%BB%A3%E7%90%86%E6%A8%A1%E5%BC%8F%E3%80%8B.md) 中我们也提到了双向绑定概念,只不过代理是实现双向绑定的一个具体方案,而观察者模式才是在描述双向绑定这个概念。
观察者模式在最初提出的时候,就举了数据与 UI 相互绑定的例子。即同一份数据可以同时渲染为表格与柱状图,那么当操作表格更新数据时,如何让柱状图的数据也刷新?从这个场景引出了对观察者模式的定义,即 “数据” 与 “UI” 是一对多的关系,我们需要一种设计模式实现当 “数据” 变化时,所有依赖于它的 “UI” 都得到通知并自动更新。
### 拍卖
拍卖由一个拍卖员与多为拍卖者组成。拍卖时,由 A 同学喊出的竞价(我出 100)就是观察者向目标发出的 `setState` 同时,此时拍卖员喊出(有人出价 100,还有更高的吗?)就是一个 `notify` 通知行为,拍卖员通知了现场竞价全员,刷新了他们对当前最高价的信息。
### 聊天室
聊天室由一个中央服务器与多个客户端组成。客户端发送消息后,就是向中央服务器发送了 `setState` 更新请求,此时中央服务器通知所有处于同一聊天室的客户端,更新他们的信息,从而完成一次消息的发送。
## 意图解释
数据与 UI 的例子已经详细说明了其意图含义,这里就不赘述了。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN011HxE9E24luDnEQiqA_!!6000000007432-2-tps-1774-670.png">
- Subject: 目标,即例子中的 “数据”。
- Observer: 观察者,即例子中的 “表格”、“柱状图”。
还是以数据与 UI 同步为例,当表格发生操作修改数据时,表格这个 TableObserver 会调用 Subject(数据)的 `setState`,此时数据被更新了。然后数据这个 `Subject` 维护了所有监听(包括表格 `TableObserver` 与柱状图 `ColumnChartObserver`),此时 `setState` 内会调用 `notify` 遍历所有监听,并依次调用 `Update` 方法,每个监听的 `Update` 方法都会调用 `getState` 获取最新数据,从而实现表格更新后 -> 更新数据 -> 表格、柱状图同时刷新。
为了更好的理解,以这张协作图为例:
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01QuF29i1RpKAEcCPrX_!!6000000002160-2-tps-1578-728.png">
- `aConcreteSubject`: 对应例子中的数据。
- `aConcreteObserver`: 对应例子中的表格。
- `anotherConcreteObserver`: 对应例子中的柱状图。
## 代码例子
下面例子使用 typescript 编写。
> PS: 为了简化处理,就不定义 Subject 接口与 ConcreteSubject 了,而是直接用 Subject 类代替。Observer 也同理。
```typescript
// 目标,管理所有观察者
class Subject {
// 观察者数组
private observers: Observer[] = []
// 状态
private state: State
// 通知所有观察者
private notify() {
this.observers.forEach(eachObserver => {
eachObserver.update()
})
}
// 新增观察者
public addObserver(observer: Observer) {
this.observers.push(observer)
}
// 更新状态
public setState(state: State) {
this.state = state
this.notify()
}
// 读取状态
public getState() {
return this.state
}
}
// 观察者
class Observer {
// 维护目标
private subject: Subject
constructor(subject: Subject) {
this.subject = subject
this.subject.addObserver(this)
}
// 更新
public update() {
// 比如渲染表格 or 渲染柱状图
console.log(this.subject.getState())
}
}
// 客户端调用
const subject = new Subject()
// 创建观察者
const observer1 = new Observer(subject)
const observer2 = new Observer(subject)
// 更新状态
subject.setState(10)
```
## 弊端
不要拘泥于实现形式,比如上面代码中的例子,`subject``observer1``observer2` 是一对多的关系,但不一定非要用这种代码组织形式来实现观察者效果。我们也可以利用 Proxy 很轻松的实现:
```typescript
const obj = new Proxy(obj, {
get(target,key) {}
set(target,key,value) {}
})
renderTable(obj)
renderChart(obj)
```
我们可以在 `obj` 被任意一个组件访问时触发 `get`,进而对 UI 与视图进行绑定;被任意一个组件更新时触发 `set`,进而对所有使用到的视图进行刷新。使用设计模式切记不要死板,理解原理就行了,在不同平台有不同的更加优雅的实现方式。
## 总结
观察者模式是非常常用的设计模式,它描述了对象一对多依赖关系下,如何通知并更新的机制,这种机制可以用在前端的 UI 与数据映射、后端的请求与控制器映射,平台间的消息通知等大部分场景,无论现实还是程序中,存在依赖且需要通知的场景非常普遍。
> 讨论地址是:[精读《设计模式 - Observer 观察者模式》· Issue #302 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/302)
**如果你想参与讨论,请 [点击这里](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,151 @@
# State(状态模式)
State(状态模式)属于行为型模式。
**意图:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。**
简单来说,就是将 “一个大 class + 一堆 if else” 替换为 “一堆小 class”。一堆小 class 就是一堆状态,用一堆状态代替 if else 会更好拓展与维护。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 团队接口人
团队是由很多同学组成的,但有一位接口人 TL,这位 TL 可能一会儿和产品经理谈需求,一会儿和其他 TL 谈规划,一会儿和 HR 谈人事,总之要做很多事情,很显然一个人是忙不过来的。TL 通过将任务分发给团队中每个同学,而不让他们直接和产品经理、其他 TL、HR 接触,那么这位 TL 的办事效率就会相当高,因为每个同学只负责一块具体的业务,而 TL 在不同时刻叫上不同的同学,让他们出面解决他们负责的专业领域问题,那么在外面看,这位 TL 团队能力很广,在内看,每个人负责的事情也比较单一。
### 台灯按钮
我们经常会看到只有一个按钮的台灯,但是可以通过按钮调节亮度,大概是如下一个循环 “关 -> 弱光 -> 亮 -> 强光 -> 关”,那么每次按按钮后,要跳转到什么状态,其实和当前状态有关。我们可以用 if else 解决这个问题,也可以用状态模式解决。
用状态模式解决,就是将这四个状态封装为四个类,每个类都执行按下按钮后要跳转到的状态,这样未来新增一种模式,只要改变部分类即可。
### 数据库连接器
在数据库连接前后,这个连接器的状态显然非常不同,我们如果仅用一个类描述数据库连接器,则内部免不了写大量分支语句进行状态判断。那么此时有更好的方案吗?状态模式告诉我们,可以创建多个不同状态类,比如连接前、连接中、连接后三种状态类,在不同时刻内部会替换为不同的子类,它们都继承同样的父类,所以外面看上去不需要感知内部的状态变化,内部又可以进行状态拆分,进行更好的维护。
## 意图解释
**意图:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。**
重点在 “内部状态” 的理解,也就是状态改变是由对象内部触发的,而不是外部,所以 **外部根本无需关心对象是否用了状态模式**,拿数据库连接器的例子来说,不管这个类是用 if else 堆砌的,还是用状态模式做的,都完全不妨碍它对外提供的稳定 API(接口问题),所以状态模式实质上是一种内聚的设计模式。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01tbZ0bQ1w8xcUgbWTJ_!!6000000006264-2-tps-1350-486.png">
- State: 状态接口,类比为台灯状态。
- ConcreteState: 具体状态,都继承于 State,类比为台灯的强光、弱光状态。
## 代码例子
下面例子使用 typescript 编写。
```typescript
// 定义状态接口
interface State {
// 模拟台灯点亮
show: () => string
}
class Light1 implements State {
constructor(context: Context) {
this.context = context
}
show() {
return '关灯'
}
// 按下按钮
public click() {
this.context.setState(new Light2(this.context))
}
}
class Light2 implements State {
constructor(context: Context) {
this.context = context
}
show() {
return '弱光'
}
// 按下按钮
public click() {
this.context.setState(new Light3(this.context))
}
}
class Light3 implements State {
constructor(context: Context) {
this.context = context
}
show() {
return '亮'
}
// 按下按钮
public click() {
this.context.setState(new Light4(this.context))
}
}
class Light4 implements State {
constructor(context: Context) {
this.context = context
}
show() {
return '强光'
}
// 按下按钮
public click() {
this.context.setState(new Light1(this.context))
}
}
// 台灯
public class Lamp {
// 当前状态
private currentState = new Light1(this)
protected setState(state: State) {
this.currentState = state
}
// 按下按钮
public click() {
this.getState().click()
}
}
const lamp = new Lamp() // 关闭
lamp.click() // 弱光
lamp.click() // 亮
lamp.click() // 强光
lamp.click() // 关闭
```
其实有很多种方式来实现,不必拘泥于形式,大体上只要保证由多个类实现不同状态,每个类实现到下一个状态切换就好了。
## 弊端
该用 if else 的时候还是要用,不要但凡遇到 if else 就使用状态模式,那样就是书读傻了。一定要判断,是否各状态间差异很大,且使用状态模式后维护性比 if else 更好,才应该用状态模式。
## 总结
在合适场景下,状态模式可以使代码更符合开闭原则,每个类独立维护时,逻辑也更精简、聚焦,更易维护。
> 讨论地址是:[精读《设计模式 - State 状态模式》· Issue #303 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/303)
**如果你想参与讨论,请 [点击这里](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,86 @@
# Strategy(策略模式)
Strategy(策略模式)属于行为型模式。
**意图:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。**
策略是个形象的表述,所谓策略就是方案,我们都知道任何事情都有多种方案,而且不同方案都能解决问题,所以这些方案可以相互替换。我们将方案从问题中抽象出来,这样就可以抛开问题,单独优化方案了,这就是策略模式的核心思想。
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 地图导航
我们去任何地方都可以选择步行、骑车、开车、公交,不同的方案都可以帮助我们到达目的地,那么很明显应该将这些方案变成策略封装起来,接收的都是出发点和目的地,输出的都是路线。
### 布局方式
比如我们做一个报表系统,在 PC 使用珊格布局,在移动端使用流式布局,其实内容还是那些,只是布局方式会随着不同终端大小做不同的适配,那么布局的适配就是一种策略,它可以与报表内容无关。
我们可以将布局策略单独抽取出来,以后甚至可以适配电视机、投影仪等等不同尺寸的场景,而不需要对其他代码做任何改动,这就是将布局策略从代码中解耦出来的好处。
### 排序算法
当我们调用 `.sort` 时,使用的是什么排序算法?可能是冒泡、快速、插入排序?其实无论何种排序算法,本质上做的事情都是一样的,我们可以事先将排序算法封装起来,针对不同特性的数组调用不同的排序算法。
## 意图解释
**意图:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。**
算法可以理解为策略,我们制定许多解决某个场景的策略,这些策略都可以独立的解决这个场景的问题,这样下次遇到这个场景时,我们就可以选择任何策略来解决,而且我们还可以脱离场景,单独优化策略,只要接口不变即可。
这个意图本质上就是解耦,解耦之后才可以分工。想想一个复杂的系统,如果所有策略都耦合在业务逻辑里,那么只有懂业务的人才能小心翼翼的维护,但如果将策略与业务解耦,我们就可以独立维护这些策略,为业务带来更灵活的变化。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01oQ1Vvc1kHPXNk8vzD_!!6000000004658-2-tps-1578-480.png">
- Strategy: 策略公共接口。
- ConcreteStrategy: 具体策略,实现了上面这个接口。
只要你的策略符合接口,就满足策略模式的条件。
## 代码例子
下面例子使用 typescript 编写。
```typescript
interface Strategy {
doSomething: () => void
}
class Strategy1 implements Strategy {
doSomething: () => {
console.log('实现方案1')
}
}
class Strategy2 implements Strategy {
doSomething: () => {
console.log('实现方案2')
}
}
// 使用
new System(new Strategy1()) // 策略1实现的系统
new System(new Strategy2()) // 策略2实现的系统
```
## 弊端
不要走极端,不要每个分支走一个策略模式,这样会导致策略类过多。当分支逻辑简单清晰好维护时,不需要使用策略模式抽象。
## 总结
策略模式是很重要的抽象思维,我们首先要意识到问题有许多种解法,才能意识到策略模式的存在。当一个问题需要采取不同策略,且策略相对较复杂,且未来可能要拓展新策略时,可以考虑使用策略模式。
> 讨论地址是:[精读《设计模式 - Strategy 策略模式》· Issue #304 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/304)
**如果你想参与讨论,请 [点击这里](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,88 @@
# Template Method(模版模式)
Template Method(模版模式)属于行为型模式。
**意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。TemplateMethod 使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。**
## 举例子
如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。
### 模版文件
我们办事打印的文件就是模版文件,只需要写上个人基本信息再签字就可以了,我们不需要做太多的重复劳动,因为某些场景下大部分内容是可以固化下来的。比如买卖房屋,那大部分甲方乙方的条款是固定的,最大的变化是甲方与乙方的不同,我们在模版上签字时,就是利用了模版模式减少了大量写条款的时间。
### 实例化
实例化也可以认为是模版模式的某种表现形式,因为对于工厂方法,我们传入不同的初始值可能给出不同结果,那么实际上就是用很少的代码撬动了很大一块功能,起到了抽象作用。
### Vue 模版
Vue 模版更符合我们对模版直觉的理解。这个场景中,模版指的是 HTML 模版,我们只需要在模版中以 `{}` 形式描述一些变量,就可以生成一块只有局部变量变化的模版 DOM,非常方便。
## 意图解释
**意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。TemplateMethod 使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。**
这个设计模式初衷是用于面向对象的,所以考虑的是如何在类中运用模版模式。首先定义一个父类,实现了一些算法,再将需要被子类重载的方法提出来,子类重载这些部分方法后,即可利用父类实现好的算法做一些功能。
比如说父类方法 `function a() { b() + c() }`,此时子类只需要重定义 b 与 c 方法,即可复用 a 的算法(b 与 c 相加)。当然这个例子比较简单,当算法较为复杂时,模版模式的好处将凸显出来。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i1/O1CN01DLdURm1t90ovmVI1g_!!6000000005858-2-tps-1150-652.png">
- ConcreteClass: 具体的父类。可以看到父类中实现了 TemplateMethod,其调用了 primitiveOperation1 与 primitiveOperation2, 所以子类只需要重载这两个方法,即可享用 TemplateMethod 提供的算法。
假设 TemplateMethod 是 `OpenDocument` 打开文档的作用,那么 primitiveOperation1 可能是 `CanOpen` 校验,`primitiveOperation2` 可能是 `ReadDocument` 读取文档方法。
我们只要专心实现具体的细节方法,而不需要关心他们之间是如何相互作用的,父级会帮我们实现它。之后我们就可以调用子类的 `OpenDocument` 实现打开文档了。
## 代码例子
下面例子使用 typescript 编写。
```typescript
class View {
doDisplay(){}
display() {
this.setFocus()
this.doDisplay()
this.resetFocus()
}
}
class MyView extends View {
doDisplay(){
console.log('myDisplay')
}
}
const myView = new MyView()
myView.display()
```
这个例子中,`doDisplay` 表示父类希望子类重载的方法,一般以 `do` 约定打头。
## 弊端
模版模式用在类中,本质上是固定不可变的结构,进一步缩小重写方法的范围,重写的范围越小,代码可复用度就越高,所以一定要在具有通用算法可提取的情况下使用,而不要为了节省代码行数而过度使用。
另外前端开发中,HTML 本身就很契合模版模式,因为 HTML 中有大量标签描述千变万化的 UI 结构,可复用的地方实在太多太多,所以非常适合模版模式,所以不要认为模版模式仅能在类中使用,模版模式还能在脚手架使用呢,比如填入一些表单自动生成代码。
学习这个设计模式时,注意不要固化思维在其定义的类这个框子中,因为设计模式写于 1994 年,其中提到的模式已经被大量迁移运用,能否识别并做适当的知识迁移,是 20 多年后的今天学习设计模式的关键。
## 总结
模版模式与策略模式有一定相似处,模版模式是改变算法的一部分,而策略模式是将策略完全提取出来,所以可以改变算法的全部。
> 讨论地址是:[精读《设计模式 - Template Method 模版模式》· Issue #305 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/305)
**如果你想参与讨论,请 [点击这里](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,156 @@
# Visitor(访问者模式)
Visitor(访问者模式)属于行为型模式。
**意图:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。**
访问者,顾名思义,就是对象访问的一种设计模式,我们可以在不改变要访问对象的前提下,对访问对象的操作做拓展。
## 举例子
由于能应用访问者模式的场景很少,所以这里只举一个例子。
### 建造游戏中的资源设计
假设你制作一款城市建造游戏,游戏的基础资源只有毛皮、木材、铜矿、铁矿。你需要用这些资源建造各种,比如造楼房、做衣服、制作家具、门、空调、甚至锅、健身房、游泳馆等。记住一个前提,就是你想把游戏设计的非常逼真,所以每种资源的不同使用方法都非常定制,不是简单的消耗 N 个数量就能完成,比如制作家具时,需要用到毛皮和木材,此时毛皮和木材对环境、制作人、资金都有不同的要求。
常见的想法是,我们将资源的所有使用方法都枚举在资源类中,这样资源就在用到不同场景时,调用不同方法即可。但问题是资源本身其实较为固定,我们每增加一种用途就修改一次木材、铁矿的类会显得非常麻烦。
能不能在增加新用途时,不修改原始资源类呢?答案是可以用访问者模式。
## 意图解释
**意图:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。**
第一句话指明了 Visitor 的作用,即 “作用于某对象结构中的各元素的操作”,也就是 Visitor 是用于操作对象元素的。“它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作” 也就是说,你可以只修改 Visitor 本身完成新操作的定义,而不需要修改原本对象。
这看上去比较奇怪,给对象定义新的操作,竟然不用修改对象本身,而通过改另外一个对象就可以?这就是 Visitor 设计的奇妙之处,它将对象的操作权移交给了 Visitor。
## 结构图
<img width=600 src="https://img.alicdn.com/imgextra/i3/O1CN01uUsrEF1LACvBPBs7j_!!6000000001258-2-tps-1738-1346.png">
- Visitor:访问者接口。
- ConcreteVisitor:具体的访问者。
- Element 可以被访问者使用的元素,它必须定义一个 Accept 属性,接收 visitor 对象。这是实现访问者模式的关键。
- ObjectStructure:对象结构,存储了多个 Element,利用 Visitor 进行批量操作。
可以看到,要实现操作权转让到 Visitor,核心是元素必须实现一个 Accept 函数,将这个对象抛给 Visitor:
```typescript
class ConcreteElement implements Element {
public accept(visitor: Visitor) {
visitor.visit(this)
}
}
```
从上面代码可以看出这样一条链路:Element 通过 `accept` 函数接收到 Visitor 对象,并将自己的实例抛给 Visitor 的 `visit` 函数,**这样我们就可以在 Visitor 的 `visit` 方法中拿到对象实例,完成对对象的操作。**
## 代码例子
下面例子使用 typescript 编写。
```typescript
class ConcreteVisitorX implements Visitor{
public visit(element: ELement) {
element.accept(this);
}
public visit(concreteElementA: ConcreteElementA) {
console.log('X 操作 A')
}
public visit(concreteElementB: ConcreteElementB) {
console.log('X 操作 B')
}
}
class ConcreteVisitorY implements Visitor{
public visit(element: ELement) {
element.accept(this);
}
public visit(concreteElementA: ConcreteElementA) {
console.log('Y 操作 A')
}
public visit(concreteElementB: ConcreteElementB) {
console.log('Y 操作 B')
}
}
```
配合上面已经写过的 `Element`,可以看到,经历了如下过程:
```typescript
// 先创建元素
const element = new ConcreteElement()
// 访问者 X
const visitorX = new ConcreteVisitorX()
// 访问者 Y
const visitorY = new ConcreteVisitorY()
// 然后让访问者 visit 观察一下元素
visitorX.visit(element as Element)
visitorY.visit(element as Element)
```
要注意的是,访问者观察的 Element 一定要是通用类型 Element,而不是一个具体类型 ConcreteElement,否则访问者模式抽象性就无法体现了,因为 Visitor 可以访问任何类型的 Element,所以先把接口传进去。
到这里,我们看看下面经历了什么:首先 Visitor 定义的 `visit` 会被调用,由于符合了 Element 这个通用类型,所以会调用 Element 接口定义的 `accept` 函数,这是所有元素都有的方法。
接下来,每个具体元素都重写了 `accept` 方法:
```typescript
public accept(visitor: Visitor) {
visitor.visit(this)
}
```
所以又调用了 Visitor 的 `visit` 函数,不同的是,此时的参数是具体 Element 类型,所以可能调用到的是具体对某个元素处理的 `visit` 方法,比如:
```typescript
public visit(concreteElementA: ConcreteElementA) {
console.log('X 操作 A')
}
```
最终就输出了 “X 操作 A” 这段话。
我们可以看到这样的程序拓展性有这么些:
1. Element 元素的所有子类都不用频繁修改,只要修改 Visitor 即可。
2. 一个 Visitor 可以选择性的操作任何类型的 Element 子类,只要申明了处理函数即可处理,不申明就不会命中,比较方便。在城市建造的例子中,可以提现为锅需要用铁制作,但不需要消耗木材,所以不需要定义木材的 `visit` 方法。
3. 可以定义多种 Visitor,对同一种 Element 子类也可以有不同的操作,这在我们城市建造的例子中,可以体现为门和窗户,对铁矿的使用是不同的。
由此一来,我们就能在城市建造的例子中拓展出任意多种使用资源的场景,而无需让资源感知到这些场景的存在。
## 弊端
访问者模式使用场景非常有限,请确定你的场景满足以上情况再使用。如果资源并不需要频繁修改和拓展,那么就没必要使用访问者模式。
## 总结
访问者模式的精髓,就是在不断拓展的业务场景中,防止基础元素代码不断膨胀。
假设我们这款城市建造游戏有 20 人团队开发,每周发布 2 个版本,每个版本新增了几种资源的组合使用方式,由于资源一共就木材、铁矿、铜矿那么几种,如果你作为团队负责人,任大家随意修改这些资源基础类,过不了半年就会发现,木材类的成员方法突破了 100 种,而且以每天新增 2 种的速度不断增加,你会明显发现自己精心打造的程序即将变成一堆屎山。
更要命的是,你还搞不清楚哪些场景的用法是打包的,当一种使用场景下线时,已存在的成员方法还不敢删除。
假设你用了访问者模式,会发现,每天因为迭代而新增的那几个方法,都会放到一个新 Visitor 文件下,比如一种纳米材料的门板在游戏 V1.5 版本被引进,它对材料的使用会体现在新增一个 Visitor 文件,资源本身的类不会被修改,这既不会引发协同问题,也使功能代码按照场景聚合,不论维护还是删除的心智负担都非常小。
访问者模式背后的思考本质还是,基础的元素数量一般不会随着程序迭代产生太大变化,而对这些基础元素的使用方式或组合使用会随着程序迭代不断更新,我们将变化更快的通过 Visitor 打包提取出来,自然会更利于维护。
> 讨论地址是:[精读《设计模式 - Visitor 访问者模式》· Issue #306 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/306)
**如果你想参与讨论,请 [点击这里](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)
+161
View File
@@ -0,0 +1,161 @@
DOM diff 作为工程问题,需要具有一定算法思维,因此经常出现在面试场景中,毕竟这是难得出现在工程领域的算法问题。
无论出于面试目的,还是深入学习目的,都有必要将这个问题搞懂,因此前端精读我们就专门用一个章节说清楚此问题。
## 精读
Dom diff 是所有现在框架必须做的事情,这背后的原因是,由 Jquery 时代的面向操作过程转变为数据驱动视图导致的。
为什么 Jquery 时代不需要 Dom diff?因为 Dom diff 交给业务处理了,我们调用 `.append` 或者 `.move` 之类 Dom 操作函数,就是显式申明了如何做 Dom diff,这种方案是最高效的,因为怎么移动 Dom 只有业务最清楚。
但这样的问题也很明显,就是业务心智负担太重,对于复杂系统,需要做 Dom diff 的地方太多,不仅写起来繁琐,当状态存在交错时,面向过程的手动 Dom diff 容易出现状态遗漏,导致边界错误,就算你没有写出 bug,代码的可维护性也绝对算不上好。
解决方案就是数据驱动,我们只需要关注数据如何映射到 UI,这样无论业务逻辑再复杂,我们永远只需要解决局部状态的映射,这极大降低了复杂系统的维护复杂度,以前需要一个老手写的逻辑,现在新手就能做了,这是非常了不起的变化。
但有利也有弊,这背后 Dom diff 就要交给框架来做了,所以是否能高效的做 Dom diff,是一个数据驱动框架能否应用于生产环境的重要指标,接下来,我们来看看 Dom diff 是如何做的吧。
### 理想的 Dom diff
<img width=600 src="https://img.alicdn.com/imgextra/i4/O1CN01wmLt541W6xt9a1BPm_!!6000000002740-2-tps-1112-814.png">
如图所示,理想的 Dom diff 自然是滴水不漏的复用所有能复用的,实在遇到新增或删除时,才执行插入或删除。这样的操作最贴近 Jquery 时代我们手写的 Dom diff 性能。
可惜程序无法猜到你的想法,想要精确复用就必须付出高昂的代价:时间复杂度 O(n³) 的 diff 算法,这显然是无法接受的,因此理想的 Dom diff 算法无法被使用。
> 关于 O(n³) 的由来。由于左树中任意节点都可能出现在右树,所以必须在对左树深度遍历的同时,对右树进行深度遍历,找到每个节点的对应关系,这里的时间复杂度是 O(n²),之后需要对树的各节点进行增删移的操作,这个过程简单可以理解为加了一层遍历循环,因此再乘一个 n。
### 简化的 Dom diff
<img width=700 src="https://img.alicdn.com/imgextra/i3/O1CN01SNNr8d1EvedrTG4eN_!!6000000000414-2-tps-1252-800.png">
如图所示,只按层比较,就可以将时间复杂度降低为 O(n)。按层比较也不是广度遍历,其实就是判断某个节点的子元素间 diff,跨父节点的兄弟节点也不必比较。
这样做确实非常高效,但代价就是,判断的有点傻,比如 ac 明明是一个移动操作,却被误识别为删除 + 新增。
好在跨 DOM 复用在实际业务场景中很少出现,因此这种笨拙出现的频率实际上非常低,这时候我们就不要太追求学术思维上的严谨了,毕竟框架是给实际项目用的,实际项目中很少出现的场景,算法是可以不考虑的。
下面是同层 diff 可能出现的三种情况,非常简单,看图即可:
<img width=700 src="https://img.alicdn.com/imgextra/i2/O1CN016XhiF01H2BWRGocp8_!!6000000000699-2-tps-1232-1380.png">
那么同层比较是怎么达到 O(n) 时间复杂度的呢?我们来看具体框架的思路。
### Vue 的 Dom diff
Vue 的 Dom diff 一共 5 步,我们结合下图先看前三步:
<img width=700 src="https://img.alicdn.com/imgextra/i3/O1CN01BehuWP22DRBybJJU8_!!6000000007086-2-tps-1288-690.png">
如图所示,第一和第二步分别从首尾两头向中间逼近,尽可能跳过首位相同的元素,因为我们的目的是 **尽量保证不要发生 dom 位移**
这种算法一般采用双指针。如果前两步做完后,发现旧树指针重合了,新树还未重合,说明什么?说明新树剩下来的都是要新增的节点,批量插入即可。很简单吧?那如果反过来呢?如下图所示:
<img width=800 src="https://img.alicdn.com/imgextra/i3/O1CN01hFiUg71uJpnZzCjGh_!!6000000006017-2-tps-1430-582.png">
第一和第二步完成后,发现新树指针重合了,但旧树还未重合,说明什么?说明旧树剩下来的在新树都不存在了,批量删除即可。
当然,如果 1、2、3、4 步走完之后,指针还未处理完,那么就进入一个小小算法时间了,我们需要在 O(n) 时间复杂度内把剩下节点处理完。熟悉算法的同学应该很快能反映出,一个数组做一些检测操作,还得把时间复杂度控制在 O(n),得用一个 Map 空间换一下时间,实际上也是如此,我们看下图具体做法:
<img width=800 src="https://img.alicdn.com/imgextra/i3/O1CN01qRmBBb1VBFzHS1TFR_!!6000000002614-2-tps-1496-1038.png">
如图所示,1、2、3、4 步走完后,Old 和 New 都有剩余,因此走到第五步,第五步分为三小步:
1. 遍历 Old 创建一个 Map,这个就是那个换时间的空间消耗,它记录了每个旧节点的 index 下标,一会好在 New 里查出来。
2. 遍历 New,顺便利用上面的 Map 记录下下标,同时 Old 在 New 中不存在的说明被删除了,直接删除。
3. 不存在的位置补 0,我们拿到 `e:4 d:3 c:2 h:0` 这样一个数组,下标 0 是新增,非 0 就是移过来的,批量转化为插入操作即可。
最后一步的优化也很关键,我们不要看见不同就随便移动,为了性能最优,要保证移动次数尽可能的少,那么怎么才能尽可能的少移动呢?假设我们随意移动,如下图所示:
<img width=500 src="https://img.alicdn.com/imgextra/i3/O1CN01MZ1zs91uuTU8RWhPD_!!6000000006097-2-tps-906-484.png">
但其实最优的移动方式是下面这样:
<img width=500 src="https://img.alicdn.com/imgextra/i3/O1CN01D5MXxE1wvy0PfxUpY_!!6000000006371-2-tps-894-480.png">
为什么呢?因为移动的时候,其他元素的位置也在相对变化,可能做了 A 效果同时,也把 B 效果给满足了,也就是说,找到那些相对位置有序的元素保持不变,让那些位置明显错误的元素挪动即是最优的。
什么是相对有序?`a c e` 这三个字母在 Old 原始顺序 `a b c d e` 中是相对有序的,我们只要把 `b d` 移走,这三个字母的位置自然就正确了。因此我们只需要找到 New 数组中的 **最长子序列**。具体的找法可以当作一个小算法题了,由于知道每个元素的实际下标,比如这个例子中,下标是这样的:
`[b:1, d:3, a:0, c:2, e:4]`
肉眼看上去,连续自增的子串有 `b d``a c e`,由于 `a c e` 更长,所以选择后者。
换成程序去做,可以采用贪心 + 二分法进行查找,详细可以看这道题 [最长递增子序列](https://leetcode-cn.com/problems/longest-increasing-subsequence/),时间复杂度 O(nlogn)。由于该算法得出的结果顺序是乱的,Vue 采用提前复制数组的方式辅助找到了正确序列。
### React 的 Dom diff
<img width=500 src="https://img.alicdn.com/imgextra/i4/O1CN01YBUnOF28fjM4qW4v9_!!6000000007960-2-tps-882-158.png">
假设这么一种情况,我们将 a 移到了 c 后,那么框架从最终状态倒推,如何最快的找到这个动机呢?React 采用了 **仅右移策略**,即对元素发生的位置变化,只会将其移动到右边,那么右边移完了,其他位置也就有序了。
我们看图说明:
<img width=500 src="https://img.alicdn.com/imgextra/i4/O1CN01EeRPFd1Rnwz7WUpiC_!!6000000002157-2-tps-1002-610.png">
遍历 Old 存储 Map 和 Vue 是一样的,然后就到了第二步遍历 New,`b` 下标从原来的 `1` 变成了 `0`,需要左移才行,但我们不左移,我们只右移,因为所有右移做完后,左移就等于自动做掉了(前面的元素右移后,自己自然被顶到前面去了,实现了左移的效果)。
<img width=500 src="https://img.alicdn.com/imgextra/i4/O1CN01kRDF2P1ErX4vjTkGq_!!6000000000405-2-tps-998-412.png">
同理,c 下标从 `2` 变成了 `1`,需要左移才行,但我们继续不动。
<img width=500 src="https://img.alicdn.com/imgextra/i3/O1CN01zsDAtY1DmCrAAag0f_!!6000000000258-2-tps-978-418.png">
a 的下标从 `0` 变成 `2`,终于可以右移了!
<img width=500 src="https://img.alicdn.com/imgextra/i1/O1CN01LCmhdG1TDQdQKEx2E_!!6000000002348-2-tps-980-488.png">
后面的 d、e 下标没变,就不用动。我们纵观整体可以发现,b 和 c 因为前面的 a 被抽走了,自然发生了左移。这就是用一个右移代替两个左移的高效操作。
同时我们发现,这也确实找到了我们开始提到的最佳位移策略。
那这个算法真的有这么聪明吗?显然不是,这个算法只是歪打误撞碰对了而已,**有用右移替代左移的算法,就有用左移替代右移的算法**,既然选择了右移替代左移,那么一定丢失了左移代替右移的效率。
什么时候用左移代替右移效率最高?就是把数组最后一位移到第一位的场景:
<img width=500 src="https://img.alicdn.com/imgextra/i2/O1CN01kAkNxD1YAHzyOTg7I_!!6000000003018-2-tps-904-144.png">
显然左移只要一步,那么右移就是 n-1 步,在这个例子就是 4 步,我们看右移算法图解:
<img width=500 src="https://img.alicdn.com/imgextra/i2/O1CN01UNjJcv1m64yfQz6wS_!!6000000004904-2-tps-974-400.png">
首先找到 e,位置从 `4` 变成了 `0`,但我们不能左移!所以只能保持不动,悲剧从此开始。
<img width=800 src="https://img.alicdn.com/imgextra/i3/O1CN01xmOwVd1TJqB2Hakpk_!!6000000002362-2-tps-1696-374.png">
虽然算法已经不是最优了,但该做的还是要做,其实之前有一个 lastIndex 概念没有说,因为 e 已经在 `4` 的位置了,所以再把 a 从 `0` 挪到 `1` 已经不够了,此时 a 应该从 `0` 挪到 `5`
方法就是记录 `lastIndex = max(oldIndex, newIndex)` => `lastIndex = max(4, 0)`,下一次移动到 `lastIndex + 1` 也就是 `5`
<img width=800 src="https://img.alicdn.com/imgextra/i2/O1CN01ylI1wb1FrowPwcROD_!!6000000000541-2-tps-1704-376.png">
发现 a 从 `0` 变成了 `5`(注意,此时考虑到 lastIndex 因素),所以右移。
<img width=800 src="https://img.alicdn.com/imgextra/i3/O1CN01UZ2Wf71SxrAQfLZ48_!!6000000002314-2-tps-1704-378.png">
同理,b、c、d 也一样。我们最后发现,发生了 4 次右移,e 也因为自然左移了 4 次到达了首位,符合预期。
所以这是一个有利有弊的算法。新增和删除比较简单,和 Vue 差不多。
PS:最新版 React Dom diff 算法如有更新,欢迎在评论区指出,因为这种算法看来不如 Vue 的高效。
## 总结
Dom diff 总结有这么几点考虑:
1. 完全对比 O(n³) 无法接受,故降级为同层对比的 O(n) 方案。
2. 为什么降级可行?因为跨层级很少发生,可以忽略。
3. 同层级也不简单,难点是如何高效位移,即最小步数完成位移。
4. Vue 为了尽量不移动,先左右夹击跳过不变的,再找到最长连续子串保持不动,移动其他元素。
5. React 采用仅右移方案,在大部分从左往右移的业务场景中,得到了较好的性能。
> 讨论地址是:[精读《DOM diff 原理详解》· Issue #308 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/308)
**如果你想参与讨论,请 [点击这里](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)
+138
View File
@@ -0,0 +1,138 @@
每个前端都想做一个完美的表格,业界也在持续探索不同的思路,比如钉钉表格、语雀表格。
笔者所在数据中台团队也对表格有着极高的要求,尤其是自助分析表格,需要兼顾性能与交互功能,本文便是记录自助分析表格高性能的研发思路。
## 精读
要做表格首先要选择基于 DOM 还是 Canvas,这是技术选型的第一步。比如钉钉表格就是 [基于 Canvas 实现的](https://zhuanlan.zhihu.com/p/340423350),当然这不代表 Canvas 实现就比 DOM 实现要好,从技术上各有利弊:
- Canvas 渲染效率比 DOM 高,这是浏览器实现导致的。
- DOM 可拓展性比 Canvas 好,渲染自定义内容首选 DOM 而非 Canvas。
技术选型要看具体的业务场景,钉钉表格其实就是在线 Excel,Excel 这种形态决定了单元格内一定是简单文本加一些简单图标,因此不用考虑渲染自定义内容的场景,所以选择 Canvas 渲染在未来也不会遇到不好拓展的麻烦。
而自助分析表格天然可能拓展图形、图片、操作按钮到单元格中,对轴的拖拽响应交互也非常复杂,为了不让 Canvas 成为以后拓展的瓶颈,还是选择 DOM 实现比较妥当。
那问题来了,既然 DOM 渲染效率天然比 Canvas 低,我们应该如何用 DOM 实现一个高性能表格呢?
其实业界已经有许多 DOM 表格优化方案了,主要以按需渲染、虚拟滚动为主,即预留一些 Buffer 区域用于滑动时填充,表格仅渲染可视区域与 Buffer 区域部分。但这些方案都不可避免的存在快速滑动时白屏问题,笔者通过不断尝试终于发现了一种完美解决的方案,我们一起往下看吧!
### 单元格使用 DIV 绝对定位
即每个单元格都是用绝对定位的 DIV 实现,整个表格都是有独立计算位置的 DIV 拼接而成的:
<img width=300 src="https://img.alicdn.com/imgextra/i3/O1CN01bMSq5o1O6UYaRnq44_!!6000000001656-2-tps-592-498.png">
这样做的前提是:
1. 所有单元格位置都要提前计算,这里可以利用 web worker 做并行计算。
2. 单元格合并仅是产生一个更大的单元格,它的定位方式与小单元格并无差异。
带来的好处是:
1. 滚动时,单元格可以最大程度实现复用。
2. 对于合并的单元格,只会让可视区域渲染的总单元格数更小,更利于性能提升,而不是带来性能负担。
<img width=300 src="https://img.alicdn.com/imgextra/i3/O1CN01jBdpGe1G4Bddh9AX6_!!6000000000568-2-tps-690-520.png">
如图所示有 16 个单元格,当我们向右下滑动一格时,中间 3x3 即 9 个格子的区域是完全不会重新渲染的,这样零散的绝对定位分布可以最大程度维持单元格本来的位置。我们可以认为,任何一格单元格只要自身不超出屏幕范围,就不会随着滚动而重渲染。
如果你采用 React 框架来实现,只要将每个格子的 key 设置为唯一的即可,比如当前行列号。
### 模拟滚动而非原生滚动
一般来说,轴因为逻辑特殊,其渲染逻辑和单元格会分开维护,因此我们将表格分为三个区域:横轴、纵轴、单元格。
显然,常识是横轴只能纵向滚动,纵轴只能横向滚动,单元格可以横纵向滚动,那么横向和纵向滚动条就只能出现在单元格区域:
<img width=500 src="https://img.alicdn.com/imgextra/i4/O1CN01KNbNZd1P2erBdrwto_!!6000000001783-2-tps-1102-666.png">
这样会存在三个问题:
1. 单元格使用原生滚动,横纵轴只能在单元格区域监听滚动后,通过 `.scroll` 模拟滚动,这必然会导致单元格与轴滚动有一定错位,即轴的滚动有几毫秒的滞后感。
2. 鼠标放在轴上时无法滚动,因为只有单元格是 `overflow: auto` 的,而轴区域 `overflow: hidden` 无法触发滚动。
3. 快速滚动出现白屏,即便留了 Buffer 区域,在快速滚动时也无能为力,这是因为渲染速度跟不上滚动导致的。
经过一番思考,我们只要将方案稍作调整,就能同时解决上面三个问题:即不要使用原生的滚动条,而是使用 `.scroll` 代替滚动,用 `mousewheel` 监听滚动的触发:
<img width=300 src="https://img.alicdn.com/imgextra/i2/O1CN01EbHR0X1Zy2dgLaNXl_!!6000000003262-2-tps-688-498.png">
这样做带来什么变化呢?
1. 轴、单元格区域都使用 `.scroll` 触发滚动,使得轴和单元格不会出现错位,因为轴和单元格都是用 `.scroll` 触发的滚动。
2. 任何位置都能监听滚动,使得轴上也能滚动了,我们不再依赖 `overflow` 属性。
3. 快速滚动时惊喜的发现不会白屏了,原因是用 `js` 控制触发的滚动发生在渲染完成之后,所以浏览器会在滚动发生前现完成渲染,这相当有趣。
模拟滚动时,实际上整个表格都是 `overflow: hidden` 的,浏览器就不会给出自带滚动条了,我们需要用 DIV 做出虚拟滚动条代替,这个相对容易。
### 零 buffer 区域
当我们采用模拟滚动方案时,相当于采用了在滚动时 “高频渲染” 的方案,因此不需要使用截留,更不要使用 Buffer 区域,因为更大的 Buffer 区域意味着更大的渲染开销。
当我们把 Buffer 区域移除时,发现整个屏幕内渲染单元格在 1000 个以内时,现代浏览器甚至配合 Windows 都能快速完成滚动前刷新,并不会影响滚动的流畅性。
当然,滚动过快依然不是一件好事,既然滚动是由我们控制的,可以稍许控制下滚动速度,控制在每次触发 `mousewheel` 位移不超过 200 左右最佳。
### 预计算
像单元格合并、行列隐藏、单元格格式化等计算逻辑,最好在滚动前提前算掉,否则在快速滚动时实时计算必然会带来额外的计算成本损耗。
但是这种预计算也有弊端,当单元格数量超过 10w 时,计算耗时一般会超过 1 秒,单元格数量超过 100w 时,计算耗时一般会超过 10 秒,用预计算的牺牲换来滚动的流畅,还是有些遗憾,我们可以再思考以下,能否降低预计算的损耗?
### 局部预计算
局部预计算就是一种解决方案,即便单元格数量有一千万个,但我们如果仅计算前 1w 个单元格呢?那无论数据量有多大,都不会出现丝毫卡顿。
但局部预计算有着明显缺点,即表格渲染过程中,局部计算结果并不总等价于全局计算结果,典型的有列宽、行高、跨行跨列的计算字段。
我们需要针对性解决,对于单元格宽高计算,必须采用局部计算,因为全量计算的损耗非常大。但局部计算肯定是不准确的,如下图所示:
<img width=300 src="https://img.alicdn.com/imgextra/i4/O1CN01wCELBj1LX6j8k9ZJs_!!6000000001308-2-tps-678-476.png">
但出于性能考虑,我们初始化可能仅能计算前三行的高度,此时,我们需要在滚动时做两件事情:
1. 在快速滚动的时候,向 web worker 发送预计要滚动到的位置,增量计算这些位置文字宽度,并实时修正列总宽。(因为列总宽算完只要存储最大值,所以已计算的数量级会被压缩为 O(1))。
2. 宽度计算完毕后,快速刷新当前屏幕单元格宽度,但在宽度校准的同时,维持可视区域内左对齐不变,如下图所示:
<img width=350 src="https://img.alicdn.com/imgextra/i3/O1CN01iId6Sl21tkA9tDTv6_!!6000000007043-2-tps-838-1024.png">
这样滚动过程中虽然单元格会被突然撑开,但位置并不会产生相对移动,与提前全量撑开后视觉内容相同,因此用户体验并不会有实际影响,但计算时间却由 O(row * column) 下降到 O(1),只要计算一个常数量级的单元格数目。
计算字段也是同理,可以在滚动时按片预计算,但要注意仅能在计算涉及局部单元格的情况下进行,如果这个计算是全局性质的,比如排名,那么局部排序的排名肯定是错误的,我们必须进行全量计算。
好在,即便是全量计算,我们也只需要考虑一部分数据,假设行列数量都是 n,可以将计算复杂度由 O(n²) 降低为 O(n):
<img width=800 src="https://img.alicdn.com/imgextra/i2/O1CN015bremD1en7Gnl7dGX_!!6000000003915-2-tps-1706-1310.png">
这种计算字段的处理无法保证支持无限数量级的数据,但可以大大降低计算时间,假设 1000w 单元格计算时间开销是 60s,这是一个几乎不能忍受的时间,假设 1000w 单元格是 1w 行 * 1k 列形成的,我们局部计算的开销是 1w 行(100ms + 1k 列(10ms) = 0.1s,对用户来说几乎感受不到 1000w 单元格的卡顿。
在 10w 行 * 10w 列的情况下,等待时间是 1+1 = 2s,用户会感受到明显卡顿,但总单元格数量可是惊人的 100 亿,光数据可能就几 TB 了,不可能出现这种规模的聚合数据。
### Map Reduce
前端计算还可以采用多个 web worker 加速,总之不要让用户电脑的 CPU 闲置。我们可以通过 `window.navigator.hardwareConcurrency` 获取硬件并行能支持的最大 web worker 数量,我们就实例化等量的 web worker 并行计算。
拿刚才排名的例子来说,同样 1000w 单元格数量,如果只有一列呢?那行数就是扎扎实实的 1000w,这种情况下,即便 O(n) 复杂度计算耗时也可能突破 60s,此时我们就可以分段计算。我的电脑 `hardwareConcurrency` 值为 8,那么就实例化 8 个 web worker,分别并行计算第 `0 ~ 125w`, `125w ~ 250w` ..., `875w ~ 1000w` 段的数据分别进行排序,最后得到 8 段有序序列,在主 worker 线程中进行合并。
我们可以采用分治合并,即针对依次收到的排序结果 x1, x2, x3, x4...,将收到的结果两两合并成 x12, x34, ...,再次合并为 x1234 直到合并为一个数组为止。
当然,Map Reduce 并不能解决所有问题,假设 1000w 数据计算耗时 60s,我们分为 8 段并行,每一段平均耗时 7.5s,那么第一轮排序总耗时为 7.5s。分治合并时间复杂度为 O(kn logk),其中 k 是分段数,这里是 8 段,logk 约等于 3,每段长度 125w 是 n,那么一个 125w 数量级的二分排序耗时大概是 4.5s,时间复杂度是 O(n logn),所以等价为 logn = 4.5s, k x logk 等于几?这里由于 k 远小于 n,所以时间消耗会远小于 4.5s,加起来耗时不会超过 10s。
## 总结
如果你想打造高性能表格,DIV 性能足够了,只要注意实现的时候稍加技巧即可。你可以用 DIV 实现一个兼顾性能、拓展性的表格,是时候重新相信 DOM 了!
笔者建议读完本文的你,按照这样的思路做一个小 Demo,同时思考,这样的表格有哪些通用功能可以抽象?如何设计 API 才能成为各类业务表格的基座?如何设计功能才能满足业务层表格繁多的拓展诉求?
> 讨论地址是:[精读《高性能表格》· Issue #309 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/309)
**如果你想参与讨论,请 [点击这里](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,165 @@
在 [精读《DOM diff 原理》](https://github.com/ascoders/weekly/blob/v2/190.%E7%B2%BE%E8%AF%BB%E3%80%8ADOM%20diff%20%E5%8E%9F%E7%90%86%E8%AF%A6%E8%A7%A3%E3%80%8B.md) 一文中,我们提到了 Vue 使用了一种贪心 + 二分的算法求出最长上升子序列,但并没有深究这个算法的原理,因此特别开辟一章详细说明。
另外,最长上升子序列作为一道算法题,是非常经典的,同时在工业界具有实用性,且有一定难度的,因此希望大家务必掌握。
## 精读
什么是最长上升子序列?就是求一个数组中,最长连续上升的部分,如下图所示:
<img width=400 src="https://img.alicdn.com/imgextra/i4/O1CN01VAEEcg25wjCsjsQAj_!!6000000007591-2-tps-900-310.png">
如果序列本身就是上升的,那就直接返回其本身;如果序列没有任何一段是上升的,则返回任何一个数字都可以。图中可以看到,虽然 `3, 7, 22` 也是上升的,但因为 `22` 之后接不下去了,所以其长度是有 3,与 `3, 7, 8, 9, 11, 12` 比起来,肯定不是最长的,因此找起来并不太容易。
在具体 DOM diff 场景中,为了保证尽可能移动较少的 DOM,我们需要 **保持最长上升子序** 不动,只移动其他元素。为什么呢?因为最长上升子序列本身就相对有序,只要其他元素移动完了,答案也就出来了。还是这个例子,假设原本的 DOM 就是这样一个递增顺序(当然应该是 1 2 3 4 连续的下标,不过对算法来说是否连续间隔不影响,只要递增即可):
<img width=400 src="https://img.alicdn.com/imgextra/i2/O1CN01QH5F8j23hxCVcnYgx_!!6000000007288-2-tps-910-140.png">
如果保持最长上升子序不变,只需要移动三次即可还原:
<img width=400 src="https://img.alicdn.com/imgextra/i2/O1CN01m9E97v1Fv1iUJ2ZQm_!!6000000000548-2-tps-934-146.png">
其他任何移动方式都不会小于三步,**因为我们已经最大程度保持已经有序的部分不动了**。
那么问题是,如何将这个最长上升子序列找出来?比较容易想到的解法分别有:暴力、动态规划。
### 暴力解法
> 时间复杂度: O(2ⁿ)
我们最终要生成一个最长子序列长度,那么就来模拟生成这个子序列的过程吧,只不过这个过程是暴力的。
暴力模拟生成一个子序列怎么做呢?就是从 [0,n] 范围内每次都尝试选或不选当前数,前提是后选的数字要比前面的大。由于数组长度为 n,每个数字都可以选或不选,也就是每个数字有两种选择,所以最多会生成 2ⁿ 个结果,从里面找到最长的长度,即为答案:
<img width=500 src="https://img.alicdn.com/imgextra/i2/O1CN01xdPLqX1uNxMiu1Kzn_!!6000000006026-2-tps-1166-1132.png">
这么傻试下去,必然能试出最长的那一段,在遍历过程中记录最长的那一段即可。
由于这个方法效率太低了,所以并不推荐,但这种暴力思维还是要掌握的。
### 动态规划
> 时间复杂度: O(n²)
如果用动态规划思路考虑此问题,那么 DP(i) 的定义按照经验为:以第 i 个字符串结尾时,最长子序列长度。
这里有个经验,就是动规一般 DP 返回值就是答案,字符串问题常常是以第 i 个字符串结尾,这样扫描一遍即可。而且最长子序列是有重复子问题的,即第 i 个的答案运算中,包括了前面一些的计算,为了不重复计算,才使用动态规划。
那么就看第 i 项的结果和前面哪些结果有关系了,为了方便理解如图所示:
<img width=400 src="https://img.alicdn.com/imgextra/i3/O1CN01qqNHXb1VnjG4iMhwQ_!!6000000002698-2-tps-900-164.png">
假设我们看 8 这个数字,也就是 DP(4) 是多少。由于此时前面的 DP(0), DP(1) ... DP(3) 都已经算出来了,我们看看 DP(4) 和前面的计算结果有什么关系。
简单观察可以发现,如果 `nums[i] > nums[i-1]`,那么 DP(i) 就等于 `DP(i-1) + 1`,这个是显而易见的,即如果 8 比 4 大,那么 8 这个位置的答案,就是 4 这个位置的答案长度 + 1,如果 8 这个位置数值是 3,小于 4,那么答案就是 1,因为前面的不满足上升关系,只能用 3 这个数字孤军奋战啦。
但仔细想想会发现,这个子序列不一定非要是连续的,万一第 i 项和第 i-2, i-3 项组合一下,也许会比与第 i-1 项组合起来更长哦?我们可以举个反例:
<img width=250 src="https://img.alicdn.com/imgextra/i4/O1CN01W1uPCR1sWXYUvgQgz_!!6000000005774-2-tps-510-164.png">
很显然,`1, 2, 3, 4` 组合起来是最长的上升子序列,如果你只看 `5, 4`,那么得出的答案只能是 `4`
正是由于不连续这个特点,我们对于第 i 项,需要和第 j 项依次对比,其中 `j=[0,i-1]`,只有和所有前项都比一遍,我们才放心,第 i 项找到的结果确实是最长的:
<img width=400 src="https://img.alicdn.com/imgextra/i4/O1CN01wQ4iDy1BvFRejScwt_!!6000000000007-2-tps-918-676.png">
那么时间复杂度怎么算呢?动态规划解法中,我们首先从 0 循环到 n,然后对于其中每个 i,都做了一遍 `[0,i-1]` 的额外循环,所以计算次数是 `1 + 2 + ... + n = n * (n + 1) / 2`,剔除常数后,数量级是 O(n²)。
### 贪心 + 二分
> 时间复杂度: O(nlogn)
说实话,一般能想到动态规划解法就很不错了,再进一步优化时间复杂度就非常难想了。如果你没做过这道题,并且想挑战一下,读到这里就可以停止了。
好,公布答案了,说实话这个方法不像正常人类思维想出来的,具有很大的思维跳跃性,因此我也无法给出思维推导过程,直接说结论吧:贪心 + 二分法。
如果非要说是怎么想的,我们可以从时间复杂度上事后诸葛亮一下,一般 n² 时间复杂度再优化就会变成 nlogn,而一次二分查找的时间复杂度是 logn,所以就拼命想办法结合吧。
具体方案就一句话:**用栈结构,如果值比栈内所有值都大则入栈,否则替换比它大的最小数,最后栈的长度就是答案**:
<img width=500 src="https://img.alicdn.com/imgextra/i4/O1CN01f1Ovif1vabvAE1yU0_!!6000000006189-2-tps-1058-1060.png">
先解释下时间复杂度,因为操作原因,栈内存储的数字都是升序的,因此可以采用二分法比较与插入,复杂度为 logn,外层 n 循环,所以整体时间复杂度为 O(nlogn)。另外这个方案的问题是,答案的长度是准确的,但栈内数组可能是错误的。如果要完全理解这句话,就得完全理解这个算法的原理,理解了原理才知道如何改进以得到正确的子序列。
接着要解释原理了,**开始的思考并不复杂,可以边喝茶边看**。首先我们要有个直观的认识,就是为了让最长上升子序列尽可能的长,我们就要尽可能保证挑选的数字增速尽可能的慢,反之就尽可能的快。比如如果我们挑选的数字是 `0, 1, 2, 3, 4` 那么这种贪心就贪的比较稳,因为已经尽可能增长缓慢了,后面遇到的大概率可以放进来。但如果我们挑选的是 `0, 1, 100` 那挑到 `100` 的时候就该慌了,因为一下增加到 `100`,后面 `100` 以内的数字不就都放弃了吗?这个时候要 `100` 不见得是明智的选择,丢掉反而可能未来空间更大,这其实就是贪心的思考,所谓局部最优解就是全局最优解。
但上面的思路显然不完整,我们继续想,如果读到 `0, 1, 100` 的时候,万一后面没有数字了,那么 `100` 还是可以放进来的嘛,虽然 `100` 很大,但毕竟是最后一个,还是有用的。**所以从左到右遍历的时候,遇到更大的数字优先要放进来**,重点在于,如果继续往后读取,读到了比 `100` 还小的数字,怎么办?
到这里如果无法做出思维的跳跃,分析就只能止步于此了。你可能觉得还能继续分析,比如遇到 `5` 的时候,显然要把 `100` 挤掉啊,因为 `0, 1, 5``0, 1, 100` 长度都是 3,但 `0, 1, 5` 的 “潜力” 明显比 `0, 1, 100` 大,所以长度不变,一个潜力更大,肯定要替换!这个思路是对的,但换一个场景,如果遇到的是 `3, 7, 11, 15`, 此时你遇到了 `9`,怎么换?如果出于潜力考虑,`3, 7, 9` 的潜力最好,但长度从 4 牺牲到了 3,你也搞不清楚后面是不是就没有比 `9` 大的了,如果没有了,这个长度反而没有原来 4 来的更优;如果出于长度考虑,留着 `3, 7, 11, 15`,那万一后面连续来几个 `10, 12, 13, 14` 也傻眼了,有点鼠目寸光的感觉。
所以问题就是,遇到下一个数字要怎么处理,才不至于在未来产生鼠目寸光的情况,要 “抓住稳稳的幸福”。**这里开始出现跳跃性思维了**,答案就是上面方案里提到的 “如果值比栈内所有值都大则入栈,否则替换比它大的最小数”。这里体现出跳跃思维,实现现在和未来两手抓的核心就是:**牺牲栈内容的正确性,保证总长度正确的情况下,每一步都能抓住未来最好的机遇。** 只有总长度正确了,才能保证得到最长的序列,至于牺牲栈内容的正确性,确实付出了不小的代价,但换来了未来的可能性,至少长度上可以得到正确结果,如果内容也要正确的话,可以加一些辅助手段解决,这个后面再说。所以总的来说,这个牺牲非常值得,下面通过图来介绍,为什么牺牲栈内容正确性可以带来长度的正确以及抓住未来机遇。
我们举一个极端的例子:`3, 7, 11, 15, 9, 11, 12`,如果固守一开始找到的 `3, 7, 11, 15`,那长度只有 4,但如果放弃 `11, 15`,把 `3, 7, 9, 11, 12` 连起来,长度更优。按照贪心算法,我们首先会依次遇到 `3` `7` `11` `15`,由于每个数字都比之前的大,所以没什么好思考的,直接塞到栈里:
<img width=300 src="https://img.alicdn.com/imgextra/i3/O1CN01SSCBG51IOSOiCPOUI_!!6000000000883-2-tps-704-256.png">
**遇到 `9` 的时候精彩了**,此时 `9` 不是最大的,我们为了抓住稳稳的幸福,干脆把比 `9` 稍大一点的 `11` 替换了,这样会产生什么结果?
<img width=300 src="https://img.alicdn.com/imgextra/i1/O1CN01ACsWDj27OUq1oFzOa_!!6000000007787-2-tps-704-360.png">
首先数组长度没变,因为替换操作不会改变数组长度,此时如果 `9` 后面没有值了,我们也不亏,此时输出的长度 4 依然是最优的答案。我们继续,下一步遇到 `11`,我们还是把比它稍大的 `15` 替换掉:
<img width=300 src="https://img.alicdn.com/imgextra/i1/O1CN01ihQKvo1UxyVHA8rOe_!!6000000002585-2-tps-704-456.png">
此时我们替换了最后一个数字,发现 `3, 7, 9, 11` 终于是个合理的顺序了,而且长度和 `3, 7, 11, 15` 一样,**但是更有潜力**,接下来 `12` 就理所应当的放到最后,拿到了最终答案:5。
到这里其实并没有说清楚这个算法的精髓,我们还是回到 `3, 7, 9, 15` 这一步,搞清楚 `9` 为什么可以替换掉 `11`
假设 `9` 后面是一个很大的 `99`,那么下一步 `99` 会直接追加到后面:
<img width=300 src="https://img.alicdn.com/imgextra/i2/O1CN01qYv5tB27FnJJreD16_!!6000000007768-2-tps-604-458.png">
此时我们拿到的是 `3, 7, 9, 15, 99`,但是你仔细看会发现,原序列里 `9``15` 后面的,因为我们的插入导致 `9` 放到 `15` 前面了,所以这显然不是正确答案,但长度却是正确的,因为这个答案就相当于我们选择了 `3, 7, 11, 15, 99`!为什么可以这么理解呢?因为 **只要没有替换到最后一个数,我们心里的那个队列其实还是原始队列。**
<img width=300 src="https://img.alicdn.com/imgextra/i2/O1CN011YrND21QyecxRUvu7_!!6000000002045-2-tps-604-610.png">
**即,只要栈没有被替换完,新插入的值永远只起到一个占位作用,目的是为了让新来的值好插入,但如果真的没有新来的值可插入了,那虽然栈内容不对,但至少长度是对的,因为 `9` 在没替换完的时候其实不是 `9`,它只是一个占位,背后的值还是 `11`**。所以不管怎么换,只要没替换掉最后一个,这个替换操作都是无效的,我们再拿一个例子来看:
<img width=400 src="https://img.alicdn.com/imgextra/i1/O1CN01vcMrcW1aChJSLWlYW_!!6000000003294-2-tps-904-652.png">
可见,`1, 2, 3, 4` 不能把 `7, 8, 9, 10, 11` 都替换完,因此最后结果是 `1, 2, 3, 4, 11`,但这没关系,只要没替换完,答案就是 `7, 8, 9, 10, 11`,只是我们没有记录下来罢了,但仅看长度的话,这两个没有任何区别啊,所以是没问题的。那如果 `1, 2, 3, 4, 5, 6` 呢?我们看看能替换完是什么情况:
<img width=400 src="https://img.alicdn.com/imgextra/i1/O1CN013K3Ta51FrMXxKypfY_!!6000000000540-2-tps-1102-842.png">
可见,当替换到 `5` 的时候,这个序列顺序就正确了,因为 `1, 2, 3, 4, 5` 已经完全能代替 `7, 8, 9, 10, 11` 了,而且潜力比它大,我们找到了最优局部解。所以 `1, 2, 3, 4, 11` 这里的 `1, 2, 3, 4` 就像卧底一样,在 `11` 还在的时候,还忍气吞声的称 `7, 8, 9, 10, 11` 为老大(其实是 `1``7` 为老大,`2``8` 为老大,依此类推),但当 `5` 进来的时候,`1, 2, 3, 4, 5` 就可以和 `7, 8, 9, 10, 11` 翻脸了,因为它的实力已经超出原来老大实力了。
那我们前面看似无关紧要的替换,其实就为了不断寻找未来可能的最优解,直到有出头之日那一天,如果没有出头之日,做一个小弟也挺好,长度还是对的;如果有出头之日,那最大长度就更新了,所以这种贪心可以同时兼顾正确性与效率。
最后我们看看,如何在找到答案的同时,还能找到正确的序列呢?
其实读到这里,不用说你应该也能猜出来,前面已经说过了,**只要替换了最后一个或者插入的时候,栈顺序就是正确的**。所以我们可以在替换最后一个或者插入的时候,存储下当前栈的拷贝,这样最后留下来的拷贝就是最终正确的顺序。
那为什么是这样呢?我们最后用一个例子强化一下理解,因为已经很熟练了,因此前几步合并了一下:
<img width=400 src="https://img.alicdn.com/imgextra/i3/O1CN01Mi7fPY1FLlDhiuGSC_!!6000000000471-2-tps-1200-344.png">
到目前为止,`7, 8, 9, 13` 是不存在的,但实际上它指代的是 `10, 11, 12, 13`,这个前面已经解释过,就不再赘述。我们此时已经存了队列 `10, 11, 12, 13`,因此此时结束的话,这个队列输出是正确的。我们看下一步:
<img width=400 src="https://img.alicdn.com/imgextra/i1/O1CN01ZtAMAR1V30rKrchB2_!!6000000002596-2-tps-1204-444.png">
为了方便识别,我给不同分组数字加了背景色,这样更容易观察:我们发现,由于每次替换的都是比它稍大的数字,一旦遇到了一个更小的开始 `1, 2, 3, 4, 5`,即便上一轮 `7, 8, 9` 还没有完全替换完 `10, 11, 12, 13`,更小的也一定从最左边开始替换,因为栈内数字是单调递增的。那么全部替换完,或者从某个数字开始,向右替换完,此时队列中的数字一定都是相对顺序正确的。从这里例子来看,`2, 3` 一定会优先替换掉 `8, 9`,等 `13` 被替换的时候,栈的相对顺序一定符合原数组的相对顺序。
最后看一个更复杂的例子加深印象:
<img width=400 src="https://img.alicdn.com/imgextra/i1/O1CN01GXWX6G1jaoiMJWC9h_!!6000000004565-2-tps-1102-768.png">
读到这里,恭喜你已经大功告成,完全理解这个 DOM diff 算法啦。
## 总结
那么 Vue 最终采用贪心计算最长上升子序列,付出了多少代价呢?其实就是 O(n) 与 O(nlogn) 的关系,我们看图:
<img width=500 src="https://img.alicdn.com/imgextra/i3/O1CN01ztHvIs1azFIPVTzJY_!!6000000003400-2-tps-1200-824.png">
可以看到,O(nlogn) 时间复杂度增长趋势勉强可以接受,特别是在工程场景中,一个父节点的子节点个数不可能太多的情况下,不会占用太多分析的时间,带来的好处就是最少的 DOM 移动次数。是比较完美的算法与工程结合的实践。
> 讨论地址是:[精读《DOM diff 最长上升子序列》· Issue #310 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/310)
**如果你想参与讨论,请 [点击这里](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)
+12 -17
View File
@@ -1,27 +1,22 @@
# 前端精读
<a href="https://travis-ci.org/dt-fe/weekly">
<img src="https://travis-ci.org/dt-fe/weekly.svg?branch=v2" alt="CircleCI Status">
<a href="https://travis-ci.org/ascoders/weekly">
<img src="https://travis-ci.org/ascoders/weekly.svg?branch=v2" alt="CircleCI Status">
</a>
前端界的好文精读,每周更新!
- [周刊参考池](https://github.com/dt-fe/weekly/issues/2)
素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2)
现已涵盖:
- 前端最前沿的技术、框架解读。
- 笔者在大厂的项目实战经验。
- 编译原理、设计模式两大基础模块。
- 逐渐加入一些后端技术解读。
- 偶尔更新一些商业思考。
## 关注前端精读微信公众号
<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>