From 5850fd49e97b0573008d5e12790d5f3b107a3aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=9F=E6=84=8F?= Date: Mon, 25 Sep 2023 14:43:32 +0800 Subject: [PATCH] =?UTF-8?q?=E7=88=B1=E6=9D=A5=E8=87=AA=20Github=20Action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 404.html | 2 +- Clash For Linux/index.html | 2 +- CrossOrigin/index.html | 2 +- FlomoToMemos/index.html | 2 +- Linux-Add-Device/index.html | 2 +- .../index.html" | 2 +- .../index.html" | 2 +- PartTimeREADME/index.html | 2 +- .../index.html" | 2 +- Stellar-Timeline-More/index.html | 11 +- .../index.html" | 2 +- .../index.html" | 2 +- .../index.html" | 2 +- .../index.html" | 2 +- .../index.html" | 2 +- Tencent-WxRead-Cookies/index.html | 2 +- Tencent-WxRead-Daily/index.html | 2 +- U-Fix/index.html | 2 +- UOS-Nvidia/index.html | 2 +- Vercel Proxy/index.html | 2 +- .../index.html" | 2 +- animes/index.html | 286 +++++++++--------- archives/2020/09/index.html | 2 +- archives/2020/index.html | 2 +- archives/2022/01/index.html | 2 +- archives/2022/07/index.html | 2 +- archives/2022/08/index.html | 2 +- archives/2022/10/index.html | 2 +- archives/2022/11/index.html | 2 +- archives/2022/12/index.html | 2 +- archives/2022/index.html | 2 +- archives/2023/01/index.html | 2 +- archives/2023/02/index.html | 2 +- archives/2023/03/index.html | 2 +- archives/2023/05/index.html | 2 +- archives/2023/06/index.html | 2 +- archives/2023/07/index.html | 2 +- archives/2023/08/index.html | 2 +- archives/2023/09/index.html | 2 +- archives/2023/index.html | 2 +- archives/2023/page/2/index.html | 2 +- archives/2023/page/3/index.html | 2 +- archives/index.html | 2 +- archives/page/2/index.html | 2 +- archives/page/3/index.html | 2 +- archives/page/4/index.html | 2 +- atom.xml | 4 +- baidusitemap.xml | 8 +- categories/index.html | 2 +- .../\345\210\206\344\272\253/index.html" | 2 +- .../\345\240\206\346\240\210/index.html" | 2 +- .../page/2/index.html" | 2 +- .../\347\224\237\346\264\273/index.html" | 2 +- .../index.html" | 2 +- chat/index.html | 2 +- cinemas/index.html | 60 ++-- daily/Deep Sea/index.html | 2 +- .../index.html" | 2 +- game/Genshin Impact/index.html | 2 +- game/Outer Wilds/index.html | 2 +- game/sky/index.html | 2 +- search.json | 2 +- sitemap.xml | 74 ++--- tags/Clash/index.html | 2 +- tags/GalGame/index.html | 2 +- tags/Game/index.html | 2 +- tags/Linux/index.html | 2 +- tags/Memos/index.html | 2 +- tags/Stellar/index.html | 2 +- tags/Tencent/index.html | 2 +- tags/Vercel/index.html | 2 +- tags/Window/index.html | 2 +- tags/index.html | 18 +- "tags/\345\233\276\345\272\212/index.html" | 2 +- "tags/\345\275\261\345\211\247/index.html" | 2 +- "tags/\350\267\250\345\237\237/index.html" | 2 +- "tags/\351\202\256\344\273\266/index.html" | 2 +- "tags/\351\232\217\347\254\224/index.html" | 2 +- ...7\253\257\344\270\216 BI\343\200\213.html" | 2 +- ...\347\220\206\350\247\243\343\200\213.html" | 2 +- ...\345\211\215\347\253\257\343\200\213.html" | 2 +- ...\345\233\236\346\272\257\343\200\213.html" | 2 +- .../index.html" | 2 +- .../index.html" | 6 +- .../index.html" | 6 +- .../index.html" | 2 +- .../index.html" | 2 +- .../index.html" | 2 +- 88 files changed, 318 insertions(+), 313 deletions(-) diff --git a/404.html b/404.html index 5910edf2f..73dccb80a 100644 --- a/404.html +++ b/404.html @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git a/Clash For Linux/index.html b/Clash For Linux/index.html index c14d6c606..26a615da6 100644 --- a/Clash For Linux/index.html +++ b/Clash For Linux/index.html @@ -142,7 +142,7 @@

Ubuntu 安装使用 Cl -
最近更新
+
最近更新
diff --git a/CrossOrigin/index.html b/CrossOrigin/index.html index ce53c061f..fc1216d6c 100644 --- a/CrossOrigin/index.html +++ b/CrossOrigin/index.html @@ -145,7 +145,7 @@

浅谈跨域-就你小 -
最近更新
+
最近更新
diff --git a/FlomoToMemos/index.html b/FlomoToMemos/index.html index 8c4b31aca..21cc35593 100644 --- a/FlomoToMemos/index.html +++ b/FlomoToMemos/index.html @@ -141,7 +141,7 @@

Flomo浮墨数据迁 -
最近更新
+
最近更新
diff --git a/Linux-Add-Device/index.html b/Linux-Add-Device/index.html index 7caf0c6ff..ea4d2950b 100644 --- a/Linux-Add-Device/index.html +++ b/Linux-Add-Device/index.html @@ -140,7 +140,7 @@

Linux 挂载磁盘

最近更新
+
最近更新
diff --git "a/Lsky\345\205\260\347\251\272\345\233\276\345\272\212\346\220\255\345\273\272/index.html" "b/Lsky\345\205\260\347\251\272\345\233\276\345\272\212\346\220\255\345\273\272/index.html" index 5628e6987..58d96d80b 100644 --- "a/Lsky\345\205\260\347\251\272\345\233\276\345\272\212\346\220\255\345\273\272/index.html" +++ "b/Lsky\345\205\260\347\251\272\345\233\276\345\272\212\346\220\255\345\273\272/index.html" @@ -141,7 +141,7 @@

Lsky兰空图床搭建 -
最近更新
+
最近更新
diff --git "a/MongoDB \350\242\253\346\224\273\345\207\273/index.html" "b/MongoDB \350\242\253\346\224\273\345\207\273/index.html" index a1d972f1d..e2be02b41 100644 --- "a/MongoDB \350\242\253\346\224\273\345\207\273/index.html" +++ "b/MongoDB \350\242\253\346\224\273\345\207\273/index.html" @@ -142,7 +142,7 @@

我数据价值 2082 -
最近更新
+
最近更新
diff --git a/PartTimeREADME/index.html b/PartTimeREADME/index.html index c5453a19a..c37b4a0ce 100644 --- a/PartTimeREADME/index.html +++ b/PartTimeREADME/index.html @@ -159,7 +159,7 @@

基本运行环境

最近更新
+
最近更新
diff --git "a/QQ\351\237\263\344\271\220\346\237\245\346\211\276QQ\345\217\267/index.html" "b/QQ\351\237\263\344\271\220\346\237\245\346\211\276QQ\345\217\267/index.html" index 71c7264c7..f93aedd8b 100644 --- "a/QQ\351\237\263\344\271\220\346\237\245\346\211\276QQ\345\217\267/index.html" +++ "b/QQ\351\237\263\344\271\220\346\237\245\346\211\276QQ\345\217\267/index.html" @@ -143,7 +143,7 @@

QQ音乐找QQ号

-
最近更新
+
最近更新
diff --git a/Stellar-Timeline-More/index.html b/Stellar-Timeline-More/index.html index f8a80edee..1ed12f6aa 100644 --- a/Stellar-Timeline-More/index.html +++ b/Stellar-Timeline-More/index.html @@ -36,7 +36,7 @@ - + @@ -141,11 +141,11 @@

Stellar 提高时间 @@ -416,6 +416,11 @@

参考代码
### 网易云memos微信读书联合测试

{% timeline api:https://netease.thatapi.cn/user/event?uid=134968139&limit=10 type:custom config:"[{ 'type': 'root', 'src': 'events' }, { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'user.nickname' }, { 'type': 'avatar', 'src': 'user.avatarUrl' }, { 'type': 'msg', 'src': 'json.msg' }, { 'type': 'netease', 'src': 'json.song.id' }, { 'type': 'tags', 'src': 'map:bottomActivityInfos|name|exclude:JUU5JUJCJTkxJUU4JTgzJUI2' }, { 'type': 'pics', 'src': 'map:pics|originUrl' }, { 'type': 'timestamp', 'src': 'showTime' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRiVFNyVCRCU5MSVFNiU5OCU5MyVFNCVCQSU5MSVFOSU5RiVCMyVFNCVCOSU5MC5zdmc='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwJUU3JUJEJTkxJUU2JTk4JTkzJUU0JUJBJTkxJUU5JTlGJUIzJUU0JUI5JTkw' } ]" %}
{% endtimeline %}

{% timeline api:https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 type:custom config:"[{ 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw==' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]" %}
{% endtimeline %}

{% timeline api:https://blog.thatcoder.cn/custom/test/ThatRead.json type:custom config:"[{ 'type': 'root', 'src': 'data' }, { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'ideaAuthor' }, { 'type': 'avatar', 'src': 'ideaAvtar' }, { 'type': 'msg', 'src': 'ideaContent' }, { 'type': 'quote', 'src': 'ideaQuote' }, { 'type': 'timestamp', 'src': 'ideaTime' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRiVFNSVCRSVBRSVFNCVCRiVBMSVFOCVBRiVCQiVFNCVCOSVBNi5zdmc='}, { 'type': 'origin', 'src': 'default:LS0lMjBGcm9tJTIwJUU1JUJFJUFFJUU0JUJGJUExJUU4JUFGJUJCJUU0JUI5JUE2' } ]" %}
{% endtimeline %}


### memos单个测试
标识符不同应该不会混淆进去
{% timeline api:https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 type:custom config:"[{ 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw==' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]" %}
{% endtimeline %}
+

侧边栏使用

+

效果是主页侧边栏的 近期动态

+
+
参考代码
memosLife:
layout: timeline
title: 近期动态
api: https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20
type: custom
config: "[{ 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmF1dGhvci5qcGc=' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]"
+

结语

我再也不想写这种代码, 简直是屎山, 不 这就是屎山!

虽说是屎山, 但至少能让我随意对接接口了, 不是吗。 钟意你依然是个喜欢一劳永逸的人呢。

如果多一个人使用, 这屎山又发挥了作用。

diff --git "a/Stellar\344\273\243\347\240\201\345\235\227\344\270\252\344\272\272\345\220\221\347\276\216\345\214\226/index.html" "b/Stellar\344\273\243\347\240\201\345\235\227\344\270\252\344\272\272\345\220\221\347\276\216\345\214\226/index.html" index cf3fb615e..4eb8207b0 100644 --- "a/Stellar\344\273\243\347\240\201\345\235\227\344\270\252\344\272\272\345\220\221\347\276\216\345\214\226/index.html" +++ "b/Stellar\344\273\243\347\240\201\345\235\227\344\270\252\344\272\272\345\220\221\347\276\216\345\214\226/index.html" @@ -138,7 +138,7 @@

Stellar代码块个人 -
最近更新
+
最近更新
diff --git "a/Stellar\345\217\257\346\216\247\345\244\234\351\227\264\346\250\241\345\274\217/index.html" "b/Stellar\345\217\257\346\216\247\345\244\234\351\227\264\346\250\241\345\274\217/index.html" index 381d32794..fde4930b8 100644 --- "a/Stellar\345\217\257\346\216\247\345\244\234\351\227\264\346\250\241\345\274\217/index.html" +++ "b/Stellar\345\217\257\346\216\247\345\244\234\351\227\264\346\250\241\345\274\217/index.html" @@ -140,7 +140,7 @@

Stellar可控夜间模 -
最近更新
+
最近更新
diff --git "a/Stellar\346\226\207\347\253\240\347\233\256\345\275\225\344\270\252\344\272\272\345\220\221\347\276\216\345\214\226/index.html" "b/Stellar\346\226\207\347\253\240\347\233\256\345\275\225\344\270\252\344\272\272\345\220\221\347\276\216\345\214\226/index.html" index 5b405b728..5c72a9248 100644 --- "a/Stellar\346\226\207\347\253\240\347\233\256\345\275\225\344\270\252\344\272\272\345\220\221\347\276\216\345\214\226/index.html" +++ "b/Stellar\346\226\207\347\253\240\347\233\256\345\275\225\344\270\252\344\272\272\345\220\221\347\276\216\345\214\226/index.html" @@ -138,7 +138,7 @@

Stellar文章目录个 -
最近更新
+
最近更新
diff --git "a/Stellar\350\207\252\347\224\250\346\216\222\347\211\210\347\211\207\346\226\255/index.html" "b/Stellar\350\207\252\347\224\250\346\216\222\347\211\210\347\211\207\346\226\255/index.html" index 7d7ce24e6..bb2b2607c 100644 --- "a/Stellar\350\207\252\347\224\250\346\216\222\347\211\210\347\211\207\346\226\255/index.html" +++ "b/Stellar\350\207\252\347\224\250\346\216\222\347\211\210\347\211\207\346\226\255/index.html" @@ -140,7 +140,7 @@

Stellar自用排版片 -
最近更新
+
最近更新
diff --git "a/Stellar\350\207\252\347\224\250\351\255\224\346\224\271\350\256\260\345\275\225/index.html" "b/Stellar\350\207\252\347\224\250\351\255\224\346\224\271\350\256\260\345\275\225/index.html" index b10ee7da5..e7ce4ee51 100644 --- "a/Stellar\350\207\252\347\224\250\351\255\224\346\224\271\350\256\260\345\275\225/index.html" +++ "b/Stellar\350\207\252\347\224\250\351\255\224\346\224\271\350\256\260\345\275\225/index.html" @@ -141,7 +141,7 @@

Stellar自用魔改存 -
最近更新
+
最近更新
diff --git a/Tencent-WxRead-Cookies/index.html b/Tencent-WxRead-Cookies/index.html index 973071b8d..8fbd156f8 100644 --- a/Tencent-WxRead-Cookies/index.html +++ b/Tencent-WxRead-Cookies/index.html @@ -141,7 +141,7 @@

微信读书Cookies续 -
最近更新
+
最近更新
diff --git a/Tencent-WxRead-Daily/index.html b/Tencent-WxRead-Daily/index.html index 7e1012de9..316918bba 100644 --- a/Tencent-WxRead-Daily/index.html +++ b/Tencent-WxRead-Daily/index.html @@ -138,7 +138,7 @@

微信读书自动阅 -
最近更新
+
最近更新
diff --git a/U-Fix/index.html b/U-Fix/index.html index d7a87dd99..1f2c6db80 100644 --- a/U-Fix/index.html +++ b/U-Fix/index.html @@ -140,7 +140,7 @@

修复、格式化U盘 -
最近更新
+
最近更新
diff --git a/UOS-Nvidia/index.html b/UOS-Nvidia/index.html index efb5b39a5..500adca3a 100644 --- a/UOS-Nvidia/index.html +++ b/UOS-Nvidia/index.html @@ -144,7 +144,7 @@

Ubuntu UOS统信 双 -
最近更新
+
最近更新
diff --git a/Vercel Proxy/index.html b/Vercel Proxy/index.html index 164ef5709..7facef981 100644 --- a/Vercel Proxy/index.html +++ b/Vercel Proxy/index.html @@ -141,7 +141,7 @@

Vervel反向代理功 -
最近更新
+
最近更新
diff --git "a/Waline\344\270\216Lsky\345\205\260\347\251\272\345\233\276\345\272\212/index.html" "b/Waline\344\270\216Lsky\345\205\260\347\251\272\345\233\276\345\272\212/index.html" index 44f4209d5..c32b92e7f 100644 --- "a/Waline\344\270\216Lsky\345\205\260\347\251\272\345\233\276\345\272\212/index.html" +++ "b/Waline\344\270\216Lsky\345\205\260\347\251\272\345\233\276\345\272\212/index.html" @@ -142,7 +142,7 @@

Waline评论与Lsky兰 -
最近更新
+
最近更新
diff --git a/animes/index.html b/animes/index.html index a45cfa786..cfc4be513 100644 --- a/animes/index.html +++ b/animes/index.html @@ -223,7 +223,7 @@

钟意在追番

-
最近更新
+
最近更新
@@ -287,7 +287,7 @@

钟意在追番

- 总播放 2512.2 万 + 总播放 2515.1 万 追番人数 480.8 万 @@ -329,10 +329,10 @@

钟意在追番

- 总播放 6240.9 万 + 总播放 6247.1 万 - 追番人数 222.8 万 + 追番人数 223.0 万 硬币数 36.4 万 @@ -377,16 +377,16 @@

钟意在追番

- 总播放 1819.7 万 + 总播放 1823.7 万 - 追番人数 137.0 万 + 追番人数 137.1 万 硬币数 9.3 万 - 弹幕总数 33.4 万 + 弹幕总数 33.5 万 评分 9.9 @@ -431,16 +431,16 @@

钟意在追番

- 总播放 4214.7 万 + 总播放 4584.4 万 - 追番人数 1233.7 万 + 追番人数 1234.3 万 - 硬币数 12.1 万 + 硬币数 12.7 万 - 弹幕总数 9.5 万 + 弹幕总数 10.2 万 评分 9.4 @@ -478,16 +478,16 @@

钟意在追番

- 总播放 6927.7 万 + 总播放 7153.6 万 - 追番人数 132.2 万 + 追番人数 134.3 万 - 硬币数 23.6 万 + 硬币数 24.1 万 - 弹幕总数 16.3 万 + 弹幕总数 16.6 万 评分 9.2 @@ -520,7 +520,7 @@

钟意在追番

- 总播放 633.7 万 + 总播放 642.0 万 追番人数 131.8 万 @@ -529,7 +529,7 @@

钟意在追番

硬币数 2.6 万
- 弹幕总数 2.8 万 + 弹幕总数 2.9 万 评分 9.7 @@ -570,16 +570,16 @@

钟意在追番

- 总播放 8640.9 万 + 总播放 8684.4 万 - 追番人数 137.1 万 + 追番人数 137.3 万 硬币数 33.0 万 - 弹幕总数 17.1 万 + 弹幕总数 17.2 万 评分 9.6 @@ -612,10 +612,10 @@

钟意在追番

- 总播放 502.2 万 + 总播放 502.9 万 - 追番人数 102.0 万 + 追番人数 102.1 万 硬币数 16.5 万 @@ -659,16 +659,16 @@

钟意在追番

- 总播放 6839.3 万 + 总播放 6850.4 万 - 追番人数 154.0 万 + 追番人数 154.1 万 硬币数 63.1 万 - 弹幕总数 202.7 万 + 弹幕总数 202.8 万 评分 9.9 @@ -701,10 +701,10 @@

钟意在追番

- 总播放 5818.3 万 + 总播放 5821.6 万 - 追番人数 438.3 万 + 追番人数 438.4 万 硬币数 30.0 万 @@ -743,10 +743,10 @@

钟意在追番

- 总播放 4560.3 万 + 总播放 4566.3 万 - 追番人数 157.5 万 + 追番人数 157.6 万 硬币数 11.0 万 @@ -786,10 +786,10 @@

钟意在追番

- 总播放 2367.8 万 + 总播放 2371.9 万 - 追番人数 879.1 万 + 追番人数 878.9 万 硬币数 2.8 万 @@ -829,7 +829,7 @@

钟意在追番

- 总播放 318.4 万 + 总播放 320.1 万 追番人数 332.3 万 @@ -871,10 +871,10 @@

钟意在追番

- 总播放 965.4 万 + 总播放 965.7 万 - 追番人数 100.6 万 + 追番人数 100.7 万 硬币数 12.2 万 @@ -913,13 +913,13 @@

钟意在追番

- 总播放 4218.8 万 + 总播放 4224.2 万 追番人数 545.0 万 - 硬币数 13.6 万 + 硬币数 13.7 万 弹幕总数 12.7 万 @@ -957,7 +957,7 @@

钟意在追番

- 总播放 4050.3 万 + 总播放 4052.0 万 追番人数 141.9 万 @@ -999,10 +999,10 @@

钟意在追番

- 总播放 3624.0 万 + 总播放 3626.5 万 - 追番人数 253.3 万 + 追番人数 253.4 万 硬币数 7.3 万 @@ -1087,10 +1087,10 @@

钟意在追番

- 总播放 216.9 万 + 总播放 218.3 万 - 追番人数 551.7 万 + 追番人数 551.6 万 硬币数 2.0 万 @@ -1132,7 +1132,7 @@

钟意在追番

- 总播放 284.3 万 + 总播放 285.6 万 追番人数 556.9 万 @@ -1180,13 +1180,13 @@

钟意在追番

总播放 2.0 亿
- 追番人数 598.0 万 + 追番人数 598.2 万 - 硬币数 142.3 万 + 硬币数 142.4 万 - 弹幕总数 1316.4 万 + 弹幕总数 1316.5 万 评分 9.7 @@ -1225,10 +1225,10 @@

钟意在追番

追番人数 1250.6 万
- 硬币数 407.0 万 + 硬币数 407.1 万 - 弹幕总数 109.6 万 + 弹幕总数 109.7 万 评分 9.2 @@ -1261,16 +1261,16 @@

钟意在追番

- 总播放 8262.2 万 + 总播放 8278.7 万 - 追番人数 401.2 万 + 追番人数 401.3 万 硬币数 39.6 万 - 弹幕总数 132.3 万 + 弹幕总数 132.4 万 评分 9.9 @@ -1303,7 +1303,7 @@

钟意在追番

- 总播放 914.2 万 + 总播放 914.5 万 追番人数 80.2 万 @@ -1345,16 +1345,16 @@

钟意在追番

- 总播放 2986.8 万 + 总播放 2999.7 万 - 追番人数 165.2 万 + 追番人数 165.5 万 硬币数 8.2 万 - 弹幕总数 96.7 万 + 弹幕总数 96.8 万 评分 9.5 @@ -1387,16 +1387,16 @@

钟意在追番

- 总播放 1708.3 万 + 总播放 1713.8 万 - 追番人数 62.8 万 + 追番人数 62.9 万 硬币数 6.2 万 - 弹幕总数 70.2 万 + 弹幕总数 70.3 万 评分 9.7 @@ -1429,16 +1429,16 @@

钟意在追番

- 总播放 2582.8 万 + 总播放 2588.8 万 - 追番人数 135.7 万 + 追番人数 135.9 万 硬币数 12.4 万 - 弹幕总数 35.1 万 + 弹幕总数 35.2 万 评分 9.5 @@ -1473,16 +1473,16 @@

钟意在追番

- 总播放 4375.3 万 + 总播放 4379.0 万 - 追番人数 181.5 万 + 追番人数 181.6 万 硬币数 25.7 万 - 弹幕总数 77.1 万 + 弹幕总数 77.2 万 评分 9.8 @@ -1517,7 +1517,7 @@

钟意在追番

- 总播放 647.2 万 + 总播放 647.9 万 追番人数 85.8 万 @@ -1559,7 +1559,7 @@

钟意在追番

- 总播放 479.4 万 + 总播放 479.9 万 追番人数 44.4 万 @@ -1604,7 +1604,7 @@

钟意在追番

总播放 1.2 亿
- 追番人数 525.4 万 + 追番人数 525.5 万 硬币数 42.1 万 @@ -1643,7 +1643,7 @@

钟意在追番

- 总播放 404.2 万 + 总播放 405.0 万 追番人数 357.5 万 @@ -1687,7 +1687,7 @@

钟意在追番

- 总播放 2189.9 万 + 总播放 2190.7 万 追番人数 21.3 万 @@ -1732,13 +1732,13 @@

钟意在追番

总播放 1.2 亿
- 追番人数 598.1 万 + 追番人数 598.2 万 - 硬币数 39.5 万 + 硬币数 39.6 万 - 弹幕总数 32.3 万 + 弹幕总数 32.4 万 评分 9.8 @@ -1775,13 +1775,13 @@

钟意在追番

总播放 1.7 亿
- 追番人数 817.2 万 + 追番人数 817.6 万 - 硬币数 90.6 万 + 硬币数 90.7 万 - 弹幕总数 708.9 万 + 弹幕总数 709.0 万 评分 9.8 @@ -1814,16 +1814,16 @@

钟意在追番

- 总播放 8189.1 万 + 总播放 8201.4 万 - 追番人数 257.4 万 + 追番人数 257.6 万 - 硬币数 73.0 万 + 硬币数 73.1 万 - 弹幕总数 74.4 万 + 弹幕总数 74.5 万 评分 9.9 @@ -1857,7 +1857,7 @@

钟意在追番

- 总播放 7022.8 万 + 总播放 7026.8 万 追番人数 587.9 万 @@ -1902,7 +1902,7 @@

钟意在追番

- 总播放 1566.1 万 + 总播放 1567.5 万 追番人数 273.8 万 @@ -1948,13 +1948,13 @@

钟意在追番

总播放 4.0 亿
- 追番人数 344.2 万 + 追番人数 344.4 万 - 硬币数 150.1 万 + 硬币数 150.2 万 - 弹幕总数 232.7 万 + 弹幕总数 232.9 万 评分 9.9 @@ -1987,16 +1987,16 @@

钟意在追番

- 总播放 17.8 万 + 总播放 18.0 万 - 追番人数 20.9 万 + 追番人数 21.0 万 - 硬币数 1373 + 硬币数 1383 - 弹幕总数 1262 + 弹幕总数 1270 评分 9.8 @@ -2032,7 +2032,7 @@

钟意在追番

总播放 1.2 亿
- 追番人数 534.9 万 + 追番人数 535.1 万 硬币数 52.8 万 @@ -2072,13 +2072,13 @@

钟意在追番

- 总播放 1300.2 万 + 总播放 1325.3 万 - 追番人数 30.4 万 + 追番人数 30.5 万 - 硬币数 1.7 万 + 硬币数 1.8 万 弹幕总数 3.4 万 @@ -2116,10 +2116,10 @@

钟意在追番

- 总播放 4944.3 万 + 总播放 4947.2 万 - 追番人数 274.6 万 + 追番人数 274.7 万 硬币数 21.1 万 @@ -2160,16 +2160,16 @@

钟意在追番

- 总播放 6755.2 万 + 总播放 6761.5 万 - 追番人数 410.7 万 + 追番人数 410.8 万 - 硬币数 71.2 万 + 硬币数 71.3 万 - 弹幕总数 188.0 万 + 弹幕总数 188.1 万 评分 9.9 @@ -2202,7 +2202,7 @@

钟意在追番

- 总播放 3463.1 万 + 总播放 3463.7 万 追番人数 119.8 万 @@ -2247,7 +2247,7 @@

钟意在追番

- 总播放 6613.6 万 + 总播放 6619.6 万 追番人数 199.3 万 @@ -2290,7 +2290,7 @@

钟意在追番

- 总播放 7017.1 万 + 总播放 7019.9 万 追番人数 250.4 万 @@ -2336,7 +2336,7 @@

钟意在追番

- 总播放 3310.8 万 + 总播放 3311.7 万 追番人数 321.7 万 @@ -2381,7 +2381,7 @@

钟意在追番

- 总播放 207.1 万 + 总播放 207.2 万 追番人数 16.4 万 @@ -2472,7 +2472,7 @@

钟意在追番

- 总播放 5643.0 万 + 总播放 5644.3 万 追番人数 180.6 万 @@ -2516,7 +2516,7 @@

钟意在追番

- 总播放 6118.6 万 + 总播放 6120.4 万 追番人数 343.6 万 @@ -2564,7 +2564,7 @@

钟意在追番

- 总播放 1257.2 万 + 总播放 1257.4 万 追番人数 43.1 万 @@ -2618,7 +2618,7 @@

钟意在追番

- 总播放 8362.4 万 + 总播放 8364.9 万 追番人数 365.4 万 @@ -2665,10 +2665,10 @@

钟意在追番

总播放 2.0 亿
- 追番人数 869.0 万 + 追番人数 869.3 万 - 硬币数 240.1 万 + 硬币数 240.2 万 弹幕总数 356.3 万 @@ -2707,10 +2707,10 @@

钟意在追番

- 总播放 1955.9 万 + 总播放 1957.9 万 - 追番人数 928.2 万 + 追番人数 927.9 万 硬币数 3.5 万 @@ -2756,7 +2756,7 @@

钟意在追番

总播放 6.0 亿
- 追番人数 692.1 万 + 追番人数 692.0 万 硬币数 229.8 万 @@ -2845,13 +2845,13 @@

钟意在追番

总播放 7.3 亿
- 追番人数 1209.3 万 + 追番人数 1209.5 万 硬币数 303.8 万 - 弹幕总数 307.3 万 + 弹幕总数 307.4 万 评分 9.7 @@ -2891,13 +2891,13 @@

钟意在追番

总播放 1.2 亿
- 追番人数 314.4 万 + 追番人数 315.1 万 - 硬币数 122.0 万 + 硬币数 122.2 万 - 弹幕总数 66.0 万 + 弹幕总数 66.1 万 评分 9.9 @@ -2930,10 +2930,10 @@

钟意在追番

- 总播放 4115.6 万 + 总播放 4136.2 万 - 追番人数 41.9 万 + 追番人数 42.0 万 硬币数 8.9 万 @@ -2983,13 +2983,13 @@

钟意在追番

总播放 5.9 亿
- 追番人数 897.2 万 + 追番人数 897.4 万 - 硬币数 673.1 万 + 硬币数 673.2 万 - 弹幕总数 670.7 万 + 弹幕总数 670.8 万 评分 9.6 @@ -3022,16 +3022,16 @@

钟意在追番

- 总播放 6693.5 万 + 总播放 6703.6 万 - 追番人数 307.2 万 + 追番人数 307.4 万 - 硬币数 59.1 万 + 硬币数 59.2 万 - 弹幕总数 190.2 万 + 弹幕总数 190.3 万 评分 9.9 @@ -3068,13 +3068,13 @@

钟意在追番

总播放 2.7 亿
- 追番人数 1409.5 万 + 追番人数 1409.4 万 - 硬币数 96.8 万 + 硬币数 96.9 万 - 弹幕总数 104.2 万 + 弹幕总数 104.3 万 评分 9.7 @@ -3112,13 +3112,13 @@

钟意在追番

总播放 2.9 亿
- 追番人数 575.3 万 + 追番人数 575.5 万 - 硬币数 284.2 万 + 硬币数 284.3 万 - 弹幕总数 71.8 万 + 弹幕总数 71.9 万 评分 9.9 @@ -3151,7 +3151,7 @@

钟意在追番

- 总播放 1566.6 万 + 总播放 1567.7 万 追番人数 522.2 万 @@ -3199,13 +3199,13 @@

钟意在追番

总播放 5.0 亿
- 追番人数 904.3 万 + 追番人数 904.2 万 硬币数 263.4 万 - 弹幕总数 256.2 万 + 弹幕总数 256.3 万 评分 7 @@ -3245,7 +3245,7 @@

钟意在追番

总播放 4.0 亿
- 追番人数 312.0 万 + 追番人数 312.1 万 硬币数 126.4 万 @@ -3284,7 +3284,7 @@

钟意在追番

- 总播放 245.0 万 + 总播放 246.0 万 追番人数 344.5 万 @@ -3327,16 +3327,16 @@

钟意在追番

- 总播放 4.6 亿 + 总播放 4.7 亿 - 追番人数 185.7 万 + 追番人数 186.0 万 - 硬币数 65.0 万 + 硬币数 65.1 万 - 弹幕总数 142.0 万 + 弹幕总数 142.2 万 评分 9.9 @@ -3369,16 +3369,16 @@

钟意在追番

- 总播放 138.4 万 + 总播放 139.8 万 追番人数 26.6 万 - 硬币数 2130 + 硬币数 2147 - 弹幕总数 2516 + 弹幕总数 2527 评分 8.1 diff --git a/archives/2020/09/index.html b/archives/2020/09/index.html index d9fc467a1..756e34e3c 100644 --- a/archives/2020/09/index.html +++ b/archives/2020/09/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2020/index.html b/archives/2020/index.html index 21ba21a6c..391c35016 100644 --- a/archives/2020/index.html +++ b/archives/2020/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2022/01/index.html b/archives/2022/01/index.html index c7d064b74..05c9c5231 100644 --- a/archives/2022/01/index.html +++ b/archives/2022/01/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2022/07/index.html b/archives/2022/07/index.html index 40f597d8d..13309d9d8 100644 --- a/archives/2022/07/index.html +++ b/archives/2022/07/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2022/08/index.html b/archives/2022/08/index.html index 7e1bec532..d88965415 100644 --- a/archives/2022/08/index.html +++ b/archives/2022/08/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2022/10/index.html b/archives/2022/10/index.html index 28e00eebb..b20de35aa 100644 --- a/archives/2022/10/index.html +++ b/archives/2022/10/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2022/11/index.html b/archives/2022/11/index.html index 1644fab7a..1b1788c55 100644 --- a/archives/2022/11/index.html +++ b/archives/2022/11/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2022/12/index.html b/archives/2022/12/index.html index c22458f33..b2e03936e 100644 --- a/archives/2022/12/index.html +++ b/archives/2022/12/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2022/index.html b/archives/2022/index.html index 39b281b6d..bbbff8d47 100644 --- a/archives/2022/index.html +++ b/archives/2022/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2023/01/index.html b/archives/2023/01/index.html index 87d001b6b..7fee603a9 100644 --- a/archives/2023/01/index.html +++ b/archives/2023/01/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2023/02/index.html b/archives/2023/02/index.html index 3323eb08a..abe4e8c89 100644 --- a/archives/2023/02/index.html +++ b/archives/2023/02/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2023/03/index.html b/archives/2023/03/index.html index d48f96bbd..8017f5843 100644 --- a/archives/2023/03/index.html +++ b/archives/2023/03/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2023/05/index.html b/archives/2023/05/index.html index 5160c86d6..cbc1a0589 100644 --- a/archives/2023/05/index.html +++ b/archives/2023/05/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2023/06/index.html b/archives/2023/06/index.html index 528fba1ed..7e55ab345 100644 --- a/archives/2023/06/index.html +++ b/archives/2023/06/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2023/07/index.html b/archives/2023/07/index.html index 9c433f53c..8d78de09c 100644 --- a/archives/2023/07/index.html +++ b/archives/2023/07/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2023/08/index.html b/archives/2023/08/index.html index 912246409..14a96ac41 100644 --- a/archives/2023/08/index.html +++ b/archives/2023/08/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2023/09/index.html b/archives/2023/09/index.html index 95e3bc14b..f378af11c 100644 --- a/archives/2023/09/index.html +++ b/archives/2023/09/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2023/index.html b/archives/2023/index.html index b8e694074..1870fbc8a 100644 --- a/archives/2023/index.html +++ b/archives/2023/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2023/page/2/index.html b/archives/2023/page/2/index.html index 419ee8963..a3bdb9e30 100644 --- a/archives/2023/page/2/index.html +++ b/archives/2023/page/2/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/2023/page/3/index.html b/archives/2023/page/3/index.html index 931d338be..30dcece8a 100644 --- a/archives/2023/page/3/index.html +++ b/archives/2023/page/3/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/index.html b/archives/index.html index eaa14626e..32218c7c4 100644 --- a/archives/index.html +++ b/archives/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/page/2/index.html b/archives/page/2/index.html index 63198173e..877648a8e 100644 --- a/archives/page/2/index.html +++ b/archives/page/2/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/page/3/index.html b/archives/page/3/index.html index 520346170..a45e1f569 100644 --- a/archives/page/3/index.html +++ b/archives/page/3/index.html @@ -150,7 +150,7 @@

归档

diff --git a/archives/page/4/index.html b/archives/page/4/index.html index 894766c7d..60fd52c9e 100644 --- a/archives/page/4/index.html +++ b/archives/page/4/index.html @@ -150,7 +150,7 @@

归档

diff --git a/atom.xml b/atom.xml index cc9afae31..bcea042af 100644 --- a/atom.xml +++ b/atom.xml @@ -84,9 +84,9 @@ https://blog.thatcoder.cn/Stellar-Timeline-More/ 2023-08-19T02:00:00.000Z - 2023-09-19T17:14:55.024Z + 2023-09-25T06:42:47.301Z - 前言

某天想把其它app的动态放进时间线, 但每个app接口都返回不同的json数据格式, 即使同一个app不同提取项目也是不同的json数据格式,
便不了了之。
直到前几天萌生一个想法: 通过传入有效路径匹配提取对应的json数据。但是这样代码太长就不推送了, 也需要的人自己加进去(
不影响主题升级)

目前成果

  • 编写路径即可匹配数据
  • 编写路径时赋予路径类型可生成对应类型组件
  • 允许多个api聚合到一个时间线展示
  • 有时间字段可按照时间排序
  • 排除包含的内容、正则匹配替换内容
聚合的时间线
聚合的时间线

加入主题

经常使用git的coder直接看提交吧 [add] 添加timeline功能: api自适应
注意最后一个custom.js非终版, 以下方的为准

路径以stellar主题为根

  1. 文件路径: _config.yml 一处
_config.yml
plugins:
stellar:
......
+ custom: /js/plugins/custom.js
  1. 文件路径: layout/_partial/widgets/timeline.ejs 15行一处
timeline.ejs
-      ['api', 'user', 'hide', 'limit'].forEach(key => {
+ ['api', 'user', 'hide', 'limit', 'config'].forEach(key => {
  1. 文件路径: scripts/tags/lib/timeline.js 38,45行两处
timeline.js
# 38行
- args = ctx.args.map(args, ['api', 'user', 'type', 'limit', 'hide')
+ args = ctx.args.map(args, ['api', 'user', 'type', 'limit', 'hide', 'config'])
# 45行
- el += ' ' + ctx.args.joinTags(args, ['api', 'user', 'limit', 'hide']).join(' ')
+ el += ' ' + ctx.args.joinTags(args, ['api', 'user', 'limit', 'hide', 'config']).join(' ')
  1. 文件路径: source/js/plugins/custom.js 添加一整个JS文件

食用方法

作为一个timeline插件形式, 所以使用和正常的timeline一样, 只是多了一个config。

有点抽象, 我尽能力表述清楚

示例

以下是一个基本使用格式

xxx.md
{% timeline api:https://blog.thatcoder.cn/custom/test/timetest1.json type:custom config:"[{ 'type': 'root', 'src': 'data' }, { 'type': 'msg', 'src': 'content|markdown:true' }, { 'type': 'tags', 'src': 'map:talkTags' },{ 'type': 'timestamp', 'src': 'time时间戳' }]" %}
{% endtimeline %}
timetest1.json
{
"id": "timetest1",
"data": [
{
"talkTags": ["测试", "BUG制造者"],
"content": "这是timetest1的**第一个数据**, 时间为2023-08-11",
"time时间戳": "1691740257"
},
{
"talkTags": ["摆烂", "佛祖保佑", "永无BUG"],
"content": "这是timetest1的第二个数据, 时间为2023-06-06",
"time时间戳": "1686037857"
},
{
"talkTags": ["再看一眼","就会爆炸"],
"content": "这是timetest1的第三个数据, 时间为2023-07-06 \n再看一眼就会爆炸, 应该排除",
"time时间戳": "1688629857"
}
]
}

关于config

我们现在把config单独拿出来, 它就是一个数组, 里面有每个配置对象。

xxx.md
[
{'type': '组件名', 'src':'指令:参数|指令:参数' }
]

组件名和指令细分在下文
现在需要注意的是以下几点:

  • {% timeline ... %} 不能分行, 必须一行。
  • config整体用双引号包裹, 里面的内容用单引号包裹, 都是英文的!
  • 暂时就这些

指令

指令其实就是调用什么方法去处理指令附属的内容
指令之间是协同的 (比如使用1、2搭配拿到数据,再使用其余指令加以处理补充)
default比较特殊, 一般用了default就不需要使用其余的
主指令是1、2, 常用指令是3、4

  1. filter (可省略, 默认指令)
  • 用途: 匹配数据的方法之一, 匹配的内容为单个
  • 参数: 填写对应的路径, 路径指向的地方是字符串、数值之类
  • 提示: 字符串形式的json或数值也能匹配, 请大胆写路径
  1. map
  • 用途: 匹配数据的方法之一, 匹配的内容为复数
  • 参数: 填写对应的路径, 路径指向的地方是数组之类的集合
  1. default (编码)
  • 用途: 放弃匹配, 使用默认值
  • 参数: 填写组件显示的默认值
  • 提示: 常用来补充作者名、作者头像、来源、来源icon等
  1. base (编码)
  • 用途: 给匹配到的内容追加前缀
  • 参数: 填写需要追加的前缀
  • 提示: 常用来根据ID拼凑源链接、给图片拼凑基础URL。 后缀的话…没写!
  1. markdown
  • 用途: 简易的markdown转义
  • 参数: 填写 true
  • 提示: Memos的内容就是markdown
  1. exclude (编码)
  • 用途: 若包含内容关键字, 则放弃这条数据
  • 参数: 填写需要匹配的跳过循环的内容
  • 提示: 比如我网易云动态有分享黑胶礼品卡, 我就填写的黑胶
  1. regex (编码)
  • 用途: 正则替换
  • 参数: 第一个参数为正则规则, 第二个参数为替换内容(不写就是替换为空字符串)
  • 提示: memos去标签的实现 ‘…|regex:#[\d\u4e00-\u9fa5a-zA-Z]+[\s\n]‘ (方便展示, 记得编码)
  • 注意事项: 我忘了要注意什么, 但开发时候依稀记得regex第一个正则参数需要注意点什么…私密马赛

组件

组件其实就是用对应的已经准备好的div和样式去装载内容

  • root (很重要, 要写在最前面)
    1. 组件内容: 接口数据真正的主体
    2. 参数类型: 基础路径
    3. 提示: 这不是组件, 是一个特殊的配置。指向数据真正的主体(一般指向的是array), 不然其它路径很长且重复
  • author
    1. 组件内容: 时间节点上显示的作者名称
    2. 参数类型: 字符串
  • avatar
    1. 组件内容: 时间节点上显示的作者头像
    2. 参数类型: 链接
  • avatar
    1. 组件内容: 时间节点上显示的时间
    2. 参数类型: 时间戳
    3. 提示: 没写多少解析,尽量是标准的时间戳或其字符串, 11位13位均可
  • tags
    1. 组件内容: 内容主体右上角的小标签
    2. 参数类型: 字符串或数组
    3. 提示: 类似话题之类的
  • title
    1. 组件内容: 内容主体上方居中的标题
    2. 参数类型: 字符串或数组
    3. 提示: 一般用不上啦
  • msg
    1. 组件内容: 内容主体内容
    2. 参数类型: 字符串
    3. 提示: 类似\n之类的已经解析了, 更多解析记得开启markdown
  • quote
    1. 组件内容: 内容主体msg下面的引用
    2. 参数类型: 字符串
    3. 提示: 类似于回复的原内容, 我是因为微信读书笔记有引用
  • pics
    1. 组件内容: 内容主体msg下面的图片
    2. 参数类型: 链接 (字符串或数组)
    3. 提示: 即使是数组也是显示数组的第一张图片, 不然很丑的! 预留了多张, 请设计一个方案给我.
  • netease
    1. 组件内容: 内容主体msg下面的音乐
    2. 参数类型: 网易云音乐歌曲ID
    3. 提示: QQ音乐请先打钱, 私密马赛QAQ
  • link
    1. 组件内容: 左下角的小火箭, 点击跳转动态源链接
    2. 参数类型: 链接
    3. 提示: 一般动态之类的只有ID, 记得加base补充完整
  • origin
    1. 组件内容: 右下角的文字
    2. 参数类型: 字符串
    3. 提示: 我一般用来写 ‘– Form XXX’, 已经赋予了斜体
  • icon
    1. 组件内容: 右下角的图标
    2. 参数类型: 链接
    3. 提示: 我一般用来放来源的icon, 至于你呢, 你喜欢便好

编码

因为涉及到正则、冒号、竖杠等特殊字符, 有编码标注的地方需使用下面的编码, 在浏览器控制台即可使用

  • 编码
    window.btoa(window.encodeURIComponent(String.raw'输入编码内容')); (编码里面不是单引号, 是常用来包裹代码的符号)

  • 解码
    window.decodeURIComponent(window.atob('输入解码内容'))

指令教程

匹配单个

匹配目标集合

没了

不知道写什么, 有问题再问吧!

进阶

已经写成屎山了, 我还在乎多来几个for循环 ?
这里虽然是进阶, 但毫无难度, 只是可能有bug, 排了bug记得告诉我!

timelines一定要紧接在root组件后面, root没有就写在最前面。

  • sort
    1. 用途: 全节点排序
    2. 参数: timestamp 顺序 | pmatsemit 逆序 (目前只支持时间排序)
  • identifier
    1. 用途: 集合标识符
    2. 参数: 随便一个单词
    3. 提示: 需要集合在一起的timeline的标识符是一样的
  • num
    1. 用途: 这个标识符集合的数量
    2. 参数: 数值
    3. 提示: 考虑到api请求耗时不一样, 还是加一个num为妥, 不满则等待
      { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }

代码参考

看着json数据和对应config代码, 相信你就能明白一切, 并且大喊一声: 狗屁设计!!!

参考代码
### 网易云memos微信读书联合测试

{% timeline api:https://netease.thatapi.cn/user/event?uid=134968139&limit=10 type:custom config:"[{ 'type': 'root', 'src': 'events' }, { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'user.nickname' }, { 'type': 'avatar', 'src': 'user.avatarUrl' }, { 'type': 'msg', 'src': 'json.msg' }, { 'type': 'netease', 'src': 'json.song.id' }, { 'type': 'tags', 'src': 'map:bottomActivityInfos|name|exclude:JUU5JUJCJTkxJUU4JTgzJUI2' }, { 'type': 'pics', 'src': 'map:pics|originUrl' }, { 'type': 'timestamp', 'src': 'showTime' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRiVFNyVCRCU5MSVFNiU5OCU5MyVFNCVCQSU5MSVFOSU5RiVCMyVFNCVCOSU5MC5zdmc='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwJUU3JUJEJTkxJUU2JTk4JTkzJUU0JUJBJTkxJUU5JTlGJUIzJUU0JUI5JTkw' } ]" %}
{% endtimeline %}

{% timeline api:https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 type:custom config:"[{ 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw==' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]" %}
{% endtimeline %}

{% timeline api:https://blog.thatcoder.cn/custom/test/ThatRead.json type:custom config:"[{ 'type': 'root', 'src': 'data' }, { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'ideaAuthor' }, { 'type': 'avatar', 'src': 'ideaAvtar' }, { 'type': 'msg', 'src': 'ideaContent' }, { 'type': 'quote', 'src': 'ideaQuote' }, { 'type': 'timestamp', 'src': 'ideaTime' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRiVFNSVCRSVBRSVFNCVCRiVBMSVFOCVBRiVCQiVFNCVCOSVBNi5zdmc='}, { 'type': 'origin', 'src': 'default:LS0lMjBGcm9tJTIwJUU1JUJFJUFFJUU0JUJGJUExJUU4JUFGJUJCJUU0JUI5JUE2' } ]" %}
{% endtimeline %}


### memos单个测试
标识符不同应该不会混淆进去
{% timeline api:https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 type:custom config:"[{ 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw==' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]" %}
{% endtimeline %}

结语

我再也不想写这种代码, 简直是屎山, 不 这就是屎山!

虽说是屎山, 但至少能让我随意对接接口了, 不是吗。 钟意你依然是个喜欢一劳永逸的人呢。

如果多一个人使用, 这屎山又发挥了作用。

毕竟它就好比, 用头起飞的鸽子

如果你不知道我想表达什么, 不知道用头起飞的鸽子, 请一定往下看
























再往下 default































懂了叭🕊

]]>
+ 前言

某天想把其它app的动态放进时间线, 但每个app接口都返回不同的json数据格式, 即使同一个app不同提取项目也是不同的json数据格式,
便不了了之。
直到前几天萌生一个想法: 通过传入有效路径匹配提取对应的json数据。但是这样代码太长就不推送了, 也需要的人自己加进去(
不影响主题升级)

目前成果

  • 编写路径即可匹配数据
  • 编写路径时赋予路径类型可生成对应类型组件
  • 允许多个api聚合到一个时间线展示
  • 有时间字段可按照时间排序
  • 排除包含的内容、正则匹配替换内容
聚合的时间线
聚合的时间线

加入主题

经常使用git的coder直接看提交吧 [add] 添加timeline功能: api自适应
注意最后一个custom.js非终版, 以下方的为准

路径以stellar主题为根

  1. 文件路径: _config.yml 一处
_config.yml
plugins:
stellar:
......
+ custom: /js/plugins/custom.js
  1. 文件路径: layout/_partial/widgets/timeline.ejs 15行一处
timeline.ejs
-      ['api', 'user', 'hide', 'limit'].forEach(key => {
+ ['api', 'user', 'hide', 'limit', 'config'].forEach(key => {
  1. 文件路径: scripts/tags/lib/timeline.js 38,45行两处
timeline.js
# 38行
- args = ctx.args.map(args, ['api', 'user', 'type', 'limit', 'hide')
+ args = ctx.args.map(args, ['api', 'user', 'type', 'limit', 'hide', 'config'])
# 45行
- el += ' ' + ctx.args.joinTags(args, ['api', 'user', 'limit', 'hide']).join(' ')
+ el += ' ' + ctx.args.joinTags(args, ['api', 'user', 'limit', 'hide', 'config']).join(' ')
  1. 文件路径: source/js/plugins/custom.js 添加一整个JS文件

食用方法

作为一个timeline插件形式, 所以使用和正常的timeline一样, 只是多了一个config。

有点抽象, 我尽能力表述清楚

示例

以下是一个基本使用格式

xxx.md
{% timeline api:https://blog.thatcoder.cn/custom/test/timetest1.json type:custom config:"[{ 'type': 'root', 'src': 'data' }, { 'type': 'msg', 'src': 'content|markdown:true' }, { 'type': 'tags', 'src': 'map:talkTags' },{ 'type': 'timestamp', 'src': 'time时间戳' }]" %}
{% endtimeline %}
timetest1.json
{
"id": "timetest1",
"data": [
{
"talkTags": ["测试", "BUG制造者"],
"content": "这是timetest1的**第一个数据**, 时间为2023-08-11",
"time时间戳": "1691740257"
},
{
"talkTags": ["摆烂", "佛祖保佑", "永无BUG"],
"content": "这是timetest1的第二个数据, 时间为2023-06-06",
"time时间戳": "1686037857"
},
{
"talkTags": ["再看一眼","就会爆炸"],
"content": "这是timetest1的第三个数据, 时间为2023-07-06 \n再看一眼就会爆炸, 应该排除",
"time时间戳": "1688629857"
}
]
}

关于config

我们现在把config单独拿出来, 它就是一个数组, 里面有每个配置对象。

xxx.md
[
{'type': '组件名', 'src':'指令:参数|指令:参数' }
]

组件名和指令细分在下文
现在需要注意的是以下几点:

  • {% timeline ... %} 不能分行, 必须一行。
  • config整体用双引号包裹, 里面的内容用单引号包裹, 都是英文的!
  • 暂时就这些

指令

指令其实就是调用什么方法去处理指令附属的内容
指令之间是协同的 (比如使用1、2搭配拿到数据,再使用其余指令加以处理补充)
default比较特殊, 一般用了default就不需要使用其余的
主指令是1、2, 常用指令是3、4

  1. filter (可省略, 默认指令)
  • 用途: 匹配数据的方法之一, 匹配的内容为单个
  • 参数: 填写对应的路径, 路径指向的地方是字符串、数值之类
  • 提示: 字符串形式的json或数值也能匹配, 请大胆写路径
  1. map
  • 用途: 匹配数据的方法之一, 匹配的内容为复数
  • 参数: 填写对应的路径, 路径指向的地方是数组之类的集合
  1. default (编码)
  • 用途: 放弃匹配, 使用默认值
  • 参数: 填写组件显示的默认值
  • 提示: 常用来补充作者名、作者头像、来源、来源icon等
  1. base (编码)
  • 用途: 给匹配到的内容追加前缀
  • 参数: 填写需要追加的前缀
  • 提示: 常用来根据ID拼凑源链接、给图片拼凑基础URL。 后缀的话…没写!
  1. markdown
  • 用途: 简易的markdown转义
  • 参数: 填写 true
  • 提示: Memos的内容就是markdown
  1. exclude (编码)
  • 用途: 若包含内容关键字, 则放弃这条数据
  • 参数: 填写需要匹配的跳过循环的内容
  • 提示: 比如我网易云动态有分享黑胶礼品卡, 我就填写的黑胶
  1. regex (编码)
  • 用途: 正则替换
  • 参数: 第一个参数为正则规则, 第二个参数为替换内容(不写就是替换为空字符串)
  • 提示: memos去标签的实现 ‘…|regex:#[\d\u4e00-\u9fa5a-zA-Z]+[\s\n]‘ (方便展示, 记得编码)
  • 注意事项: 我忘了要注意什么, 但开发时候依稀记得regex第一个正则参数需要注意点什么…私密马赛

组件

组件其实就是用对应的已经准备好的div和样式去装载内容

  • root (很重要, 要写在最前面)
    1. 组件内容: 接口数据真正的主体
    2. 参数类型: 基础路径
    3. 提示: 这不是组件, 是一个特殊的配置。指向数据真正的主体(一般指向的是array), 不然其它路径很长且重复
  • author
    1. 组件内容: 时间节点上显示的作者名称
    2. 参数类型: 字符串
  • avatar
    1. 组件内容: 时间节点上显示的作者头像
    2. 参数类型: 链接
  • avatar
    1. 组件内容: 时间节点上显示的时间
    2. 参数类型: 时间戳
    3. 提示: 没写多少解析,尽量是标准的时间戳或其字符串, 11位13位均可
  • tags
    1. 组件内容: 内容主体右上角的小标签
    2. 参数类型: 字符串或数组
    3. 提示: 类似话题之类的
  • title
    1. 组件内容: 内容主体上方居中的标题
    2. 参数类型: 字符串或数组
    3. 提示: 一般用不上啦
  • msg
    1. 组件内容: 内容主体内容
    2. 参数类型: 字符串
    3. 提示: 类似\n之类的已经解析了, 更多解析记得开启markdown
  • quote
    1. 组件内容: 内容主体msg下面的引用
    2. 参数类型: 字符串
    3. 提示: 类似于回复的原内容, 我是因为微信读书笔记有引用
  • pics
    1. 组件内容: 内容主体msg下面的图片
    2. 参数类型: 链接 (字符串或数组)
    3. 提示: 即使是数组也是显示数组的第一张图片, 不然很丑的! 预留了多张, 请设计一个方案给我.
  • netease
    1. 组件内容: 内容主体msg下面的音乐
    2. 参数类型: 网易云音乐歌曲ID
    3. 提示: QQ音乐请先打钱, 私密马赛QAQ
  • link
    1. 组件内容: 左下角的小火箭, 点击跳转动态源链接
    2. 参数类型: 链接
    3. 提示: 一般动态之类的只有ID, 记得加base补充完整
  • origin
    1. 组件内容: 右下角的文字
    2. 参数类型: 字符串
    3. 提示: 我一般用来写 ‘– Form XXX’, 已经赋予了斜体
  • icon
    1. 组件内容: 右下角的图标
    2. 参数类型: 链接
    3. 提示: 我一般用来放来源的icon, 至于你呢, 你喜欢便好

编码

因为涉及到正则、冒号、竖杠等特殊字符, 有编码标注的地方需使用下面的编码, 在浏览器控制台即可使用

  • 编码
    window.btoa(window.encodeURIComponent(String.raw'输入编码内容')); (编码里面不是单引号, 是常用来包裹代码的符号)

  • 解码
    window.decodeURIComponent(window.atob('输入解码内容'))

指令教程

匹配单个

匹配目标集合

没了

不知道写什么, 有问题再问吧!

进阶

已经写成屎山了, 我还在乎多来几个for循环 ?
这里虽然是进阶, 但毫无难度, 只是可能有bug, 排了bug记得告诉我!

timelines一定要紧接在root组件后面, root没有就写在最前面。

  • sort
    1. 用途: 全节点排序
    2. 参数: timestamp 顺序 | pmatsemit 逆序 (目前只支持时间排序)
  • identifier
    1. 用途: 集合标识符
    2. 参数: 随便一个单词
    3. 提示: 需要集合在一起的timeline的标识符是一样的
  • num
    1. 用途: 这个标识符集合的数量
    2. 参数: 数值
    3. 提示: 考虑到api请求耗时不一样, 还是加一个num为妥, 不满则等待
      { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }

代码参考

看着json数据和对应config代码, 相信你就能明白一切, 并且大喊一声: 狗屁设计!!!

参考代码
### 网易云memos微信读书联合测试

{% timeline api:https://netease.thatapi.cn/user/event?uid=134968139&limit=10 type:custom config:"[{ 'type': 'root', 'src': 'events' }, { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'user.nickname' }, { 'type': 'avatar', 'src': 'user.avatarUrl' }, { 'type': 'msg', 'src': 'json.msg' }, { 'type': 'netease', 'src': 'json.song.id' }, { 'type': 'tags', 'src': 'map:bottomActivityInfos|name|exclude:JUU5JUJCJTkxJUU4JTgzJUI2' }, { 'type': 'pics', 'src': 'map:pics|originUrl' }, { 'type': 'timestamp', 'src': 'showTime' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRiVFNyVCRCU5MSVFNiU5OCU5MyVFNCVCQSU5MSVFOSU5RiVCMyVFNCVCOSU5MC5zdmc='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwJUU3JUJEJTkxJUU2JTk4JTkzJUU0JUJBJTkxJUU5JTlGJUIzJUU0JUI5JTkw' } ]" %}
{% endtimeline %}

{% timeline api:https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 type:custom config:"[{ 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw==' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]" %}
{% endtimeline %}

{% timeline api:https://blog.thatcoder.cn/custom/test/ThatRead.json type:custom config:"[{ 'type': 'root', 'src': 'data' }, { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'ideaAuthor' }, { 'type': 'avatar', 'src': 'ideaAvtar' }, { 'type': 'msg', 'src': 'ideaContent' }, { 'type': 'quote', 'src': 'ideaQuote' }, { 'type': 'timestamp', 'src': 'ideaTime' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRiVFNSVCRSVBRSVFNCVCRiVBMSVFOCVBRiVCQiVFNCVCOSVBNi5zdmc='}, { 'type': 'origin', 'src': 'default:LS0lMjBGcm9tJTIwJUU1JUJFJUFFJUU0JUJGJUExJUU4JUFGJUJCJUU0JUI5JUE2' } ]" %}
{% endtimeline %}


### memos单个测试
标识符不同应该不会混淆进去
{% timeline api:https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 type:custom config:"[{ 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw==' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]" %}
{% endtimeline %}

侧边栏使用

效果是主页侧边栏的 近期动态

参考代码
memosLife:
layout: timeline
title: 近期动态
api: https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20
type: custom
config: "[{ 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmF1dGhvci5qcGc=' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]"

结语

我再也不想写这种代码, 简直是屎山, 不 这就是屎山!

虽说是屎山, 但至少能让我随意对接接口了, 不是吗。 钟意你依然是个喜欢一劳永逸的人呢。

如果多一个人使用, 这屎山又发挥了作用。

毕竟它就好比, 用头起飞的鸽子

如果你不知道我想表达什么, 不知道用头起飞的鸽子, 请一定往下看
























再往下 default































懂了叭🕊

]]>
企图减少个人去适配时间线api的成本 diff --git a/baidusitemap.xml b/baidusitemap.xml index 4a4a1b48a..aa92d194b 100644 --- a/baidusitemap.xml +++ b/baidusitemap.xml @@ -1,5 +1,9 @@ + + https://blog.thatcoder.cn//Stellar-Timeline-More/ + 2023-09-25 + https://blog.thatcoder.cn//Linux-Add-Device/ 2023-09-19 @@ -24,10 +28,6 @@ https://blog.thatcoder.cn//Stellar%E6%96%87%E7%AB%A0%E7%9B%AE%E5%BD%95%E4%B8%AA%E4%BA%BA%E5%90%91%E7%BE%8E%E5%8C%96/ 2023-09-19 - - https://blog.thatcoder.cn//Stellar-Timeline-More/ - 2023-09-19 - https://blog.thatcoder.cn//Stellar%E5%8F%AF%E6%8E%A7%E5%A4%9C%E9%97%B4%E6%A8%A1%E5%BC%8F/ 2023-09-19 diff --git a/categories/index.html b/categories/index.html index 4a91f5eca..0706a39e6 100644 --- a/categories/index.html +++ b/categories/index.html @@ -150,7 +150,7 @@

分类

diff --git "a/categories/\345\210\206\344\272\253/index.html" "b/categories/\345\210\206\344\272\253/index.html" index 3cbb1a6d5..ce7c27bc6 100644 --- "a/categories/\345\210\206\344\272\253/index.html" +++ "b/categories/\345\210\206\344\272\253/index.html" @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git "a/categories/\345\240\206\346\240\210/index.html" "b/categories/\345\240\206\346\240\210/index.html" index 574d095d7..21aba28b1 100644 --- "a/categories/\345\240\206\346\240\210/index.html" +++ "b/categories/\345\240\206\346\240\210/index.html" @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git "a/categories/\345\240\206\346\240\210/page/2/index.html" "b/categories/\345\240\206\346\240\210/page/2/index.html" index 759b362c4..7d808c4a9 100644 --- "a/categories/\345\240\206\346\240\210/page/2/index.html" +++ "b/categories/\345\240\206\346\240\210/page/2/index.html" @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git "a/categories/\347\224\237\346\264\273/index.html" "b/categories/\347\224\237\346\264\273/index.html" index ff50025fe..dd22dbf3d 100644 --- "a/categories/\347\224\237\346\264\273/index.html" +++ "b/categories/\347\224\237\346\264\273/index.html" @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git "a/categories/\347\254\254\344\271\235\350\211\272\346\234\257/index.html" "b/categories/\347\254\254\344\271\235\350\211\272\346\234\257/index.html" index 2cbfbfb01..57399b1d5 100644 --- "a/categories/\347\254\254\344\271\235\350\211\272\346\234\257/index.html" +++ "b/categories/\347\254\254\344\271\235\350\211\272\346\234\257/index.html" @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git a/chat/index.html b/chat/index.html index 4ddc6a8f1..83db5c0e5 100644 --- a/chat/index.html +++ b/chat/index.html @@ -152,7 +152,7 @@

那个码农-钟意博
-
最近更新
+
最近更新
diff --git a/cinemas/index.html b/cinemas/index.html index b96d1a1c5..b08eaec61 100644 --- a/cinemas/index.html +++ b/cinemas/index.html @@ -168,7 +168,7 @@

钟意在追剧

@@ -244,16 +244,16 @@

钟意在追剧

- 总播放 104.5 万 + 总播放 109.1 万 追剧人数 242.3 万 - 硬币数 5641 + 硬币数 5825 - 弹幕总数 9233 + 弹幕总数 9524 评分 - @@ -286,13 +286,13 @@

钟意在追剧

- 总播放 146.0 万 + 总播放 150.3 万 追剧人数 242.5 万 - 硬币数 6000 + 硬币数 6115 弹幕总数 1.2 万 @@ -328,10 +328,10 @@

钟意在追剧

- 总播放 2489.7 万 + 总播放 2496.0 万 - 追剧人数 61.5 万 + 追剧人数 61.6 万 硬币数 4.0 万 @@ -370,16 +370,16 @@

钟意在追剧

- 总播放 112.1 万 + 总播放 113.7 万 追剧人数 832.7 万 - 硬币数 1758 + 硬币数 1778 - 弹幕总数 209 + 弹幕总数 210 评分 9.2 @@ -415,7 +415,7 @@

钟意在追剧

总播放 1.2 亿
- 追剧人数 139.0 万 + 追剧人数 139.1 万 硬币数 28.8 万 @@ -454,7 +454,7 @@

钟意在追剧

- 总播放 375.2 万 + 总播放 377.1 万 追剧人数 12.5 万 @@ -463,7 +463,7 @@

钟意在追剧

硬币数 3.6 万
- 弹幕总数 4.2 万 + 弹幕总数 4.3 万 评分 9.7 @@ -496,7 +496,7 @@

钟意在追剧

- 总播放 2034.0 万 + 总播放 2037.4 万 追剧人数 29.6 万 @@ -550,10 +550,10 @@

钟意在追剧

- 总播放 727.3 万 + 总播放 728.8 万 - 追剧人数 12.1 万 + 追剧人数 12.2 万 硬币数 7.0 万 @@ -592,16 +592,16 @@

钟意在追剧

- 总播放 1973.6 万 + 总播放 1981.7 万 - 追剧人数 820.5 万 + 追剧人数 820.4 万 硬币数 7.3 万 - 弹幕总数 4.7 万 + 弹幕总数 4.8 万 评分 9.6 @@ -634,7 +634,7 @@

钟意在追剧

- 总播放 51.4 万 + 总播放 51.5 万 追剧人数 1.9 万 @@ -643,7 +643,7 @@

钟意在追剧

硬币数 1054
- 弹幕总数 1580 + 弹幕总数 1581 评分 - @@ -676,10 +676,10 @@

钟意在追剧

- 总播放 6361.0 万 + 总播放 6382.4 万 - 追剧人数 52.2 万 + 追剧人数 52.3 万 硬币数 29.2 万 @@ -718,7 +718,7 @@

钟意在追剧

- 总播放 2987.0 万 + 总播放 2992.5 万 追剧人数 27.6 万 @@ -760,7 +760,7 @@

钟意在追剧

- 总播放 2804.0 万 + 总播放 2805.6 万 追剧人数 139.4 万 @@ -802,7 +802,7 @@

钟意在追剧

- 总播放 763.5 万 + 总播放 764.2 万 追剧人数 8.9 万 @@ -811,7 +811,7 @@

钟意在追剧

硬币数 6491
- 弹幕总数 7865 + 弹幕总数 7869 评分 7.8 @@ -886,10 +886,10 @@

钟意在追剧

- 总播放 4736.3 万 + 总播放 4749.4 万 - 追剧人数 38.1 万 + 追剧人数 38.2 万 硬币数 7.1 万 diff --git a/daily/Deep Sea/index.html b/daily/Deep Sea/index.html index 94154035b..301b95780 100644 --- a/daily/Deep Sea/index.html +++ b/daily/Deep Sea/index.html @@ -161,7 +161,7 @@

观《深海》的奇 -
最近更新
+
最近更新

diff --git "a/daily/\345\214\241\345\272\220\346\270\270\350\256\260/index.html" "b/daily/\345\214\241\345\272\220\346\270\270\350\256\260/index.html" index 0349c9c98..5938489ad 100644 --- "a/daily/\345\214\241\345\272\220\346\270\270\350\256\260/index.html" +++ "b/daily/\345\214\241\345\272\220\346\270\270\350\256\260/index.html" @@ -138,7 +138,7 @@

匡庐游记

-
最近更新
+
最近更新
diff --git a/game/Genshin Impact/index.html b/game/Genshin Impact/index.html index 4946ca189..62aad5e0b 100644 --- a/game/Genshin Impact/index.html +++ b/game/Genshin Impact/index.html @@ -146,7 +146,7 @@

《原神》私有服 -
最近更新
+
最近更新
diff --git a/game/Outer Wilds/index.html b/game/Outer Wilds/index.html index 0dcc80030..5e436d34e 100644 --- a/game/Outer Wilds/index.html +++ b/game/Outer Wilds/index.html @@ -152,7 +152,7 @@

《星际拓荒》

最近更新
+
最近更新
diff --git a/game/sky/index.html b/game/sky/index.html index 5f5c31b8f..60879d550 100644 --- a/game/sky/index.html +++ b/game/sky/index.html @@ -166,7 +166,7 @@

《SKY·光遇》

-
最近更新
+
最近更新
diff --git a/search.json b/search.json index badc035d4..6ade61e29 100644 --- a/search.json +++ b/search.json @@ -1 +1 @@ -[{"title":"微信读书自动阅读","path":"//Tencent-WxRead-Daily/","content":"前言本文章实现需要服务器, 无可视化界面亦可。使用的Cookie获取上一篇文章有介绍, 顺手写了这篇。 每日一问: 我为什么要实现这个功能??? 微信读书Cookie续活https://blog.thatcoder.cn/Tencent-WxRead-Cookies/ 机制分析网页版状态下阅读, 每分钟左右会有一个read请求, 通过回执可以判断是否阅读成功。具体参数我不想耗费时间去逆向, 但是可以通过模拟浏览阅读页面来等待read响应进行read重播,进而轻易实现自动阅读。 稳定性服务器测试了24小时, 阅读时间也是相应增加24。 有趣的是, 经测试, 每次程序运行5min, 增加的时长可能是 5min、6min、8min、11min、13min 甚至是 21min。但是总时长是稳定的, 也就是说会回归一天能拉满的时间24h。 实现代码虽说是浏览器模拟事件, 到了python的表演时间, 但是我采用了JS去写, 辅佐包是 Playwright 。总体是一次有趣的尝试。 准备事项开始吧, 安装 Playwright # 先创建一个文件夹mkdir /server/auto/wxread && cd /server/auto/wxread# 安装 playwrightnpm install playwrightnpx playwright install# 下面这个可能需要点时间# 因为有浏览器的下载npx playwright install-deps# 当然少不了 axiosnpm install axios# 好的, 一切准备就绪, 创建代码吧 代码wxread.jsconst { firefox } = require('playwright');const axios = require('axios');// 获取命令行参数const args = process.argv.slice(2);const params = {};args.forEach((arg) => { const [key, value] = arg.split('='); if (key && value) { params[key] = value; }});const url1 = 'https://weread.qq.com/web/reader/8f5329e0813ab7d1eg012feake4d32d5015e4da3b7fbb1fa';const url2 = 'https://weread.qq.com/web/book/read';let capturedResponse = null;let browser = null;const scrollInterval = 10000; // 上下滑动间隔时间 单位毫秒const totalTime = 400000; // 单次阅读时间 单位毫秒const getXHR = async () => { console.log("Success: 启动 Playwright 浏览器"); browser = await firefox.launch({ headless: true, }); const page = await browser.newPage(); await page.setExtraHTTPHeaders({ cookie: (await axios.get("https://sijnzx.laf.thatcoder.cn/tencent-weread-refcookie?key="+params['key'])).data["data"]["cookies"] }); await page.goto(url1, { waitUntil: 'networkidle', }); console.log("Success: 打开内容页面"); page.on('response', async (response) => { if (response.url() === url2) { const data = await response.json(); if (data['succ'] === 1) { console.log("Success: 目标URL响应成功"); } else { console.log("Error: 目标URL响应失败"); } capturedResponse = data['succ'] === 1 ? response : null; await repeatXHR(100); // 不要关闭浏览器 } });// 定期上下滑动 let scrollCount = 0; // 计数器 let scrollDirection = 1; // 1表示向下滑动,-1表示向上滑动 setInterval(async () => { await page.evaluate((scrollDirection) => { const windowHeight = window.innerHeight; window.scrollBy(0, scrollDirection * windowHeight); // 向上或向下滑动一个屏幕高度 }, scrollDirection); scrollCount++; // 如果达到了五次滑动,切换方向并重置计数器 if (scrollCount === 5) { scrollDirection *= -1; // 切换方向 scrollCount = 0; // 重置计数器 } }, scrollInterval); // 设置浏览器关闭定时器 setTimeout(async () => { console.log("Success: 关闭浏览器"); await browser.close(); }, totalTime);};const repeatXHR = async (count) => { if (!capturedResponse) { console.log("Failed: 没有捕获到响应,无法重放"); return; } const request = capturedResponse.request(); for (let i = 0; i < count; i++) { try { const response = await axios({ method: request.method(), url: request.url(), headers: request.headers(), params: request.params, data: request.postData(), }); if (response.data.succ !== 1) { console.log(`Failed: 重放响应 ${i + 1}: 失败, succ!==1`); return; } } catch (error) { console.error(`Failed: 重放响应 ${i + 1}: 失败, ${error.message}`); } } console.log(`Success: 重放响应 ${count} 次完毕`)};(async () => { await getXHR();})(); 运行代码会启动一个无头浏览器, 所以没有可视化也不需要担心。个人测试24小时, 无任何问题, 使用的内存为300MB左右, CPU占用率为0.1%左右。对了, 带上key参数是我接口的鉴权, 也就是上一篇文章的参数(个人有所修改)。你实现了上一篇文章的获取可以使用你的接口。保证cookie是有效的即可。 node wxread.js key=xxxx# 成功运行大概输出如下# Success: 启动 Playwright 浏览器# Success: 打开内容页面# Success: 目标URL响应成功# Success: 重放响应 100 次完毕# Success: 目标URL响应成功# Success: 重放响应 100 次完毕# Success: 浏览器关闭 (400秒后)","tags":["Tencent"],"categories":["堆栈"]},{"title":"微信读书Cookies续活","path":"//Tencent-WxRead-Cookies/","content":"机制分析很多优秀的文章分析了延期机制, 这里列举两个 Hank's Blog 微信读书延期机制分析https://zhaohongxuan.github.io/2022/05/16/how-to-relong-cookies-in-weread/ 陈虚渊 微信读书数据内容接口逆向https://blog.csdn.net/paycho/article/details/132796745 稳定性目前跑了几天, Cookies都能自动刷新保活 每小时自动刷新回执 主要代码续活我没使用代理服务器, 直接请求了 refCookie// 刷新 Cookie 的函数,模拟发送请求获取新 Cookieconst refCookie = async (uid: string) => { try { const response = await axios.head('https://weread.qq.com', { headers: globalHeaders() }); if (response.status === 200 || response.headers['set-cookie']) { globalCookies = CookieUtil.WebArrayToString(response.headers['set-cookie'], globalCookies); return (await upUserCookie(uid)) ? true : false }else { return false } } catch (e) { return false }} 全部代码 运行在自己搭建的 Laf 云函数, 不能无脑抄。 代码虽烂 但已写注释。需要临时使用我的接口可以联系我 代码结构图这样也许清晰一点 Serverless Codeimport axios from 'axios';import cloud from "@/cloud-sdk";/** * API请求入口方法 */exports.main = async function (ctx: FunctionContext) { try { const { cookies, uid, refresh } = ctx.method === 'GET' ? ctx.query || ctx.params : ctx.body; if (verifyData(uid)) { // 用户获取cookie请求 if ((await userServer.verifyUser(uid))) { !(await CookiesApi.verifyRefresh(uid)) || (await CookiesApi.refCookie(refresh)) return msgServer.success("获取Cookie成功", { cookies: globalCookies }) } else { return msgServer.failed("搞咩! " + uid + " 不存在!"); } } else if (verifyData(cookies)) { // 新增用户请求 const uid = CookieUtil.StringToJson(cookies)['wr_vid'] globalCookies = cookies const userInfo = (await CookiesApi.getUserInfo(uid)) if (!userInfo['name']) { return msgServer.failed("搞咩! cookies 不能用!"); } const userData: WxReadUser = { 'userVid': uid, 'userInfo': userInfo, 'cookies': globalCookies, 'cookies_uptime': (new Date()).valueOf(), 'cookies_life': true } const add = (await userServer.addUser(userData)) if (add.answer) { return msgServer.success( `存入cookies成功, 未来取用cookies请通过以下方式${' '}[ https://sijnzx.laf.thatcoder.cn/tencent-weread-refcookie?uid=您的userVid ]`, { userVid: uid, userInfo } ) } else { return msgServer.error() } } else if (verifyData(refresh)) { // 刷新请求 let req: any if (!(await CookiesApi.verifyRefresh(refresh))) { return msgServer.success("Cookie不需要刷新") } else { return (await CookiesApi.refCookie(refresh)) ? msgServer.success("刷新Cookie成功") : msgServer.error() } } else { return msgServer.failed("搞咩! 传的什么狗屁参数!"); } } catch (error) { return msgServer.error() }};/** * 获取数据库访问器 */const db = cloud.database().collection('tc_tencent_wxread');/** * cookies格式工具 */const CookieUtil = { StringToJson: (cookiesString: string) => { const cookieGroup = cookiesString.split('; ') const cookieJson = {} for (let i = 0; i < cookieGroup.length; i++) { const cookieGroupJson = cookieGroup[i].split('=') cookieJson[cookieGroupJson[0]] = cookieGroupJson.length === 1 ? '' : cookieGroupJson[1] } return cookieJson }, JsonToString: (cookiesJson: object) => { const keyValuePairs = []; for (const key in cookiesJson) { if (cookiesJson.hasOwnProperty(key)) { const value = cookiesJson[key]; keyValuePairs.push(`${key}=${value}`); } } return keyValuePairs.join('; '); }, WebArrayToString: (cookiesArray: Array<string>, cookiesString: string) => { let cookieJson = CookieUtil.StringToJson(cookiesString) for (const cookie of cookiesArray) { const refresh: Array<string> = cookie.split('; ')[0].split('=') cookieJson[refresh[0]] = refresh[1] } return CookieUtil.JsonToString(cookieJson) }, StringToArray: (cookiesString: string) => { return cookiesString.split('; ') }}// 全局变量。不合理, 但是能减少云函数单文件代码量var globalCookies = ""var globalHeaders = () => { return { Cookie: CookieUtil.StringToArray(globalCookies), // 传入的 Cookie 数组 Referer: 'https://weread.qq.com/', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': '*', }}/** * 微信读书API方法 */const CookiesApi = { /** * 获取用户信息 * @uid: 微信Cookie['wr_vid'] */ getUserInfo: async (uid: string) => { let userInfo = { 'userVid': "" } await axios.get('https://weread.qq.com/web/user?userVid=' + uid, { headers: globalHeaders() }).then(e => { userInfo = e.data }) return userInfo }, /** * 验证Cookie是否存活 * @uid: 微信Cookie['wr_vid'] */ verifyAlive: async (uid: string) => { const cookie = (await userServer.getUserCookie(uid)) globalCookies = cookie let userInfo = await CookiesApi.getUserInfo(uid) return (String)(userInfo['userVid']).includes(uid) }, // 判断是否需要刷新 Cookie verifyRefresh: async (uid: string) => { const time = (await userServer.getUserCookieTime(uid)) if ( !(await CookiesApi.verifyAlive(uid)) || (new Date()).valueOf() - time >= 3600000) { return true } return false }, // 刷新 Cookie 的函数,模拟发送请求获取新 Cookie refCookie: async (uid: string) => { try { const response = await axios.head('https://weread.qq.com', { headers: globalHeaders() }); // if (response.status === 200) { // return true // } const newCookies = response.headers['set-cookie'] if (!newCookies) { return false } globalCookies = CookieUtil.WebArrayToString(newCookies, globalCookies); return (await userServer.upUserCookie(uid)) ? true : false } catch (e) { return false } }}/** * 数据库服务层。 呵, JS哪来的服务层 */const userServer = { /** * 验证数据库是否存在用户 * @uid: 微信Cookie['wr_vid'] * @return: {boolean} */ verifyUser: async (uid: string) => { return (await db.where({ 'userVid': uid }).count()).total > 0 }, /** * 获取用户Cookie * @uid: 微信Cookie['wr_vid'] */ getUserCookie: async (uid: string) => { const get = (await db.where({ 'userVid': uid }).limit(1).get()) return get.data[0]['cookies'] }, /** * 获取用户CookieTime * @uid: 微信Cookie['wr_vid'] */ getUserCookieTime: async (uid: string) => { const get = (await db.where({ 'userVid': uid }).limit(1).get()) return get.data[0]['cookies_uptime'] }, /** * 更新用户Cookie */ upUserCookie: async (uid: string) => { return (await db.where({'userVid': uid}).limit(1).update({ cookies: globalCookies, cookies_uptime: (new Date()).valueOf(), cookies_life: true })).ok }, /** * 新增数据库用户信息 */ addUser: async (userData: WxReadUser) => { let add: any if ((await userServer.verifyUser(userData.userVid))) { add = (await db.where({'userVid': userData.userVid}).limit(1).update(userData)) } else { add = (await db.add(userData)) } return { answer: add.ok, id: add.upsertId } }}/** * 回执服务层 */const msgServer = { success: (msg: string, data: any = {}) => { return JSON.stringify({ statusCode: 200, event: "操作成功", message: msg, data }) }, failed: (msg: string) => { return JSON.stringify({ statusCode: 400, event: "操作失败", message: msg }) }, error: () => { return JSON.stringify({ statusCode: 500, event: "程序错误", message: "请联系钟意, 必应搜索钟意博客。" }) }}/** * 微信用户对象接口 */interface WxReadUser { 'userVid': string, 'userInfo'?: any, 'cookies': string, 'cookies_uptime'?: number, 'cookies_life'?: boolean}const verifyData = (data: any) => { if (data === null || data === [] || data === {} || data === undefined || data === '') { return false } else if (data.length > 0) { return true } else { return false }} 实际应用 获取自己的微信读书信息 下载微信读书的书籍 导出书单信息 带出读书笔记 自动阅读( 这个功能有什么用? ) 自动阅读微信读书https://blog.thatcoder.cn/Tencent-WxRead-Daily/","tags":["Tencent"],"categories":["堆栈"]},{"title":"Linux 挂载磁盘","path":"//Linux-Add-Device/","content":"大致步骤 准备挂载目录 磁盘分区 格式化分区 挂载磁盘 创建目录没啥好说的, 看你喜欢啥名字 创建目录mkdir -p /extra 磁盘分区先查看磁盘是否需要分区 磁盘信息fdisk -l 查看需要分区的 `Device Boot`fdisk -l 打印信息 开始分区# 根据你的 Device Boot 更改 /dev/vdafdisk /dev/vda# 根据提示依次进行以下输入# n、p、1、回车、回车、wq 再次打印磁盘信息会有多一个区 格式化分区格式化分区# 这里填多出来的那个 Device Bootmkfs.ext4 /dev/vda1 挂载这样修改/etc/fstab下次重启就不会丢失挂载信息 挂载# /dev/vda1 和 /extra 还是根据你的来echo "/dev/vda1 /extra ext4 defaults 0 0" >> /etc/fstab Extra顺手记录几种查看磁盘UUID方法 查看UUID# 块设备信息 树形lsblk -o name,mountpoint,size,uuid# 查看/etc/fstab 文件cat /etc/fstab# 块设备信息blkidls -lh /dev/disk/by-uuid/","tags":["Linux"],"categories":["堆栈"]},{"title":"Stellar 提高时间线适配范围","path":"//Stellar-Timeline-More/","content":"前言某天想把其它app的动态放进时间线, 但每个app接口都返回不同的json数据格式, 即使同一个app不同提取项目也是不同的json数据格式,便不了了之。直到前几天萌生一个想法: 通过传入有效路径匹配提取对应的json数据。但是这样代码太长就不推送了, 也需要的人自己加进去(不影响主题升级) 目前成果 编写路径即可匹配数据 编写路径时赋予路径类型可生成对应类型组件 允许多个api聚合到一个时间线展示 有时间字段可按照时间排序 排除包含的内容、正则匹配替换内容 聚合的时间线 加入主题 经常使用git的coder直接看提交吧 [add] 添加timeline功能: api自适应注意最后一个custom.js非终版, 以下方的为准 路径以stellar主题为根 文件路径: _config.yml 一处 _config.ymlplugins: stellar: ......+ custom: /js/plugins/custom.js 文件路径: layout/_partial/widgets/timeline.ejs 15行一处 timeline.ejs- ['api', 'user', 'hide', 'limit'].forEach(key => {+ ['api', 'user', 'hide', 'limit', 'config'].forEach(key => { 文件路径: scripts/tags/lib/timeline.js 38,45行两处 timeline.js# 38行- args = ctx.args.map(args, ['api', 'user', 'type', 'limit', 'hide')+ args = ctx.args.map(args, ['api', 'user', 'type', 'limit', 'hide', 'config'])# 45行- el += ' ' + ctx.args.joinTags(args, ['api', 'user', 'limit', 'hide']).join(' ')+ el += ' ' + ctx.args.joinTags(args, ['api', 'user', 'limit', 'hide', 'config']).join(' ') 文件路径: source/js/plugins/custom.js 添加一整个JS文件custom.js-持续更新https://kedao.thatcoder.cn/#s/9kZW_6Eg 食用方法作为一个timeline插件形式, 所以使用和正常的timeline一样, 只是多了一个config。 有点抽象, 我尽能力表述清楚 示例以下是一个基本使用格式 简单使用示例代码数据代码xxx.md{% timeline api:https://blog.thatcoder.cn/custom/test/timetest1.json type:custom config:"[{ 'type': 'root', 'src': 'data' }, { 'type': 'msg', 'src': 'content|markdown:true' }, { 'type': 'tags', 'src': 'map:talkTags' },{ 'type': 'timestamp', 'src': 'time时间戳' }]" %}{% endtimeline %}timetest1.json{ "id": "timetest1", "data": [ { "talkTags": ["测试", "BUG制造者"], "content": "这是timetest1的**第一个数据**, 时间为2023-08-11", "time时间戳": "1691740257" }, { "talkTags": ["摆烂", "佛祖保佑", "永无BUG"], "content": "这是timetest1的第二个数据, 时间为2023-06-06", "time时间戳": "1686037857" }, { "talkTags": ["再看一眼","就会爆炸"], "content": "这是timetest1的第三个数据, 时间为2023-07-06 再看一眼就会爆炸, 应该排除", "time时间戳": "1688629857" } ]} 关于config 我们现在把config单独拿出来, 它就是一个数组, 里面有每个配置对象。 xxx.md[ {'type': '组件名', 'src':'指令:参数|指令:参数' }] 组件名和指令细分在下文现在需要注意的是以下几点: {% timeline ... %} 不能分行, 必须一行。 config整体用双引号包裹, 里面的内容用单引号包裹, 都是英文的! 暂时就这些 指令 指令其实就是调用什么方法去处理指令附属的内容指令之间是协同的 (比如使用1、2搭配拿到数据,再使用其余指令加以处理补充)default比较特殊, 一般用了default就不需要使用其余的主指令是1、2, 常用指令是3、4 filter (可省略, 默认指令) 用途: 匹配数据的方法之一, 匹配的内容为单个 参数: 填写对应的路径, 路径指向的地方是字符串、数值之类 提示: 字符串形式的json或数值也能匹配, 请大胆写路径 map 用途: 匹配数据的方法之一, 匹配的内容为复数 参数: 填写对应的路径, 路径指向的地方是数组之类的集合 default (编码) 用途: 放弃匹配, 使用默认值 参数: 填写组件显示的默认值 提示: 常用来补充作者名、作者头像、来源、来源icon等 base (编码) 用途: 给匹配到的内容追加前缀 参数: 填写需要追加的前缀 提示: 常用来根据ID拼凑源链接、给图片拼凑基础URL。 后缀的话…没写! markdown 用途: 简易的markdown转义 参数: 填写 true 提示: Memos的内容就是markdown exclude (编码) 用途: 若包含内容关键字, 则放弃这条数据 参数: 填写需要匹配的跳过循环的内容 提示: 比如我网易云动态有分享黑胶礼品卡, 我就填写的黑胶 regex (编码) 用途: 正则替换 参数: 第一个参数为正则规则, 第二个参数为替换内容(不写就是替换为空字符串) 提示: memos去标签的实现 ‘…|regex:#[\\d\\u4e00-\\u9fa5a-zA-Z]+[\\s ]‘ (方便展示, 记得编码) 注意事项: 我忘了要注意什么, 但开发时候依稀记得regex第一个正则参数需要注意点什么…私密马赛 组件 组件其实就是用对应的已经准备好的div和样式去装载内容 root (很重要, 要写在最前面) 组件内容: 接口数据真正的主体 参数类型: 基础路径 提示: 这不是组件, 是一个特殊的配置。指向数据真正的主体(一般指向的是array), 不然其它路径很长且重复 author 组件内容: 时间节点上显示的作者名称 参数类型: 字符串 avatar 组件内容: 时间节点上显示的作者头像 参数类型: 链接 avatar 组件内容: 时间节点上显示的时间 参数类型: 时间戳 提示: 没写多少解析,尽量是标准的时间戳或其字符串, 11位13位均可 tags 组件内容: 内容主体右上角的小标签 参数类型: 字符串或数组 提示: 类似话题之类的 title 组件内容: 内容主体上方居中的标题 参数类型: 字符串或数组 提示: 一般用不上啦 msg 组件内容: 内容主体内容 参数类型: 字符串 提示: 类似 之类的已经解析了, 更多解析记得开启markdown quote 组件内容: 内容主体msg下面的引用 参数类型: 字符串 提示: 类似于回复的原内容, 我是因为微信读书笔记有引用 pics 组件内容: 内容主体msg下面的图片 参数类型: 链接 (字符串或数组) 提示: 即使是数组也是显示数组的第一张图片, 不然很丑的! 预留了多张, 请设计一个方案给我. netease 组件内容: 内容主体msg下面的音乐 参数类型: 网易云音乐歌曲ID 提示: QQ音乐请先打钱, 私密马赛QAQ link 组件内容: 左下角的小火箭, 点击跳转动态源链接 参数类型: 链接 提示: 一般动态之类的只有ID, 记得加base补充完整 origin 组件内容: 右下角的文字 参数类型: 字符串 提示: 我一般用来写 ‘– Form XXX’, 已经赋予了斜体 icon 组件内容: 右下角的图标 参数类型: 链接 提示: 我一般用来放来源的icon, 至于你呢, 你喜欢便好 编码 因为涉及到正则、冒号、竖杠等特殊字符, 有编码标注的地方需使用下面的编码, 在浏览器控制台即可使用 编码window.btoa(window.encodeURIComponent(String.raw'输入编码内容')); (编码里面不是单引号, 是常用来包裹代码的符号) 解码window.decodeURIComponent(window.atob('输入解码内容')) 编码 解码 复制 function encodeText(inputId, resultId) { const inputText = document.getElementById(inputId).value; document.getElementById(resultId).value = window.btoa(window.encodeURIComponent(String.raw`${inputText}`)); util.copy(resultId, '复制成功!') } function decodeText(inputId, resultId) { const inputText = document.getElementById(inputId).value; document.getElementById(resultId).value = window.decodeURIComponent(window.atob(inputText)); util.copy(resultId, '复制成功!') } 指令教程匹配单个 匹配目标集合 没了不知道写什么, 有问题再问吧! 进阶 已经写成屎山了, 我还在乎多来几个for循环 ?这里虽然是进阶, 但毫无难度, 只是可能有bug, 排了bug记得告诉我! timelines一定要紧接在root组件后面, root没有就写在最前面。 sort 用途: 全节点排序 参数: timestamp 顺序 | pmatsemit 逆序 (目前只支持时间排序) identifier 用途: 集合标识符 参数: 随便一个单词 提示: 需要集合在一起的timeline的标识符是一样的 num 用途: 这个标识符集合的数量 参数: 数值 提示: 考虑到api请求耗时不一样, 还是加一个num为妥, 不满则等待{ 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' } 代码参考看着json数据和对应config代码, 相信你就能明白一切, 并且大喊一声: 狗屁设计!!! 网易接口: https://netease.thatapi.cn/user/event?uid=134968139&limit=10 Memos接口: https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 微信读书接口: https://blog.thatcoder.cn/custom/test/ThatRead.json (需要提取微信读书数据可留言) 代码参考渲染结果https://blog.thatcoder.cn/邮箱模板集/#组装时间线测试 参考代码### 网易云memos微信读书联合测试{% timeline api:https://netease.thatapi.cn/user/event?uid=134968139&limit=10 type:custom config:"[{ 'type': 'root', 'src': 'events' }, { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'user.nickname' }, { 'type': 'avatar', 'src': 'user.avatarUrl' }, { 'type': 'msg', 'src': 'json.msg' }, { 'type': 'netease', 'src': 'json.song.id' }, { 'type': 'tags', 'src': 'map:bottomActivityInfos|name|exclude:JUU5JUJCJTkxJUU4JTgzJUI2' }, { 'type': 'pics', 'src': 'map:pics|originUrl' }, { 'type': 'timestamp', 'src': 'showTime' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRiVFNyVCRCU5MSVFNiU5OCU5MyVFNCVCQSU5MSVFOSU5RiVCMyVFNCVCOSU5MC5zdmc='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwJUU3JUJEJTkxJUU2JTk4JTkzJUU0JUJBJTkxJUU5JTlGJUIzJUU0JUI5JTkw' } ]" %}{% endtimeline %}{% timeline api:https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 type:custom config:"[{ 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw==' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]" %}{% endtimeline %}{% timeline api:https://blog.thatcoder.cn/custom/test/ThatRead.json type:custom config:"[{ 'type': 'root', 'src': 'data' }, { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'ideaAuthor' }, { 'type': 'avatar', 'src': 'ideaAvtar' }, { 'type': 'msg', 'src': 'ideaContent' }, { 'type': 'quote', 'src': 'ideaQuote' }, { 'type': 'timestamp', 'src': 'ideaTime' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRiVFNSVCRSVBRSVFNCVCRiVBMSVFOCVBRiVCQiVFNCVCOSVBNi5zdmc='}, { 'type': 'origin', 'src': 'default:LS0lMjBGcm9tJTIwJUU1JUJFJUFFJUU0JUJGJUExJUU4JUFGJUJCJUU0JUI5JUE2' } ]" %}{% endtimeline %}### memos单个测试标识符不同应该不会混淆进去{% timeline api:https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 type:custom config:"[{ 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw==' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]" %}{% endtimeline %} 结语我再也不想写这种代码, 简直是屎山, 不 这就是屎山! 虽说是屎山, 但至少能让我随意对接接口了, 不是吗。 钟意你依然是个喜欢一劳永逸的人呢。 如果多一个人使用, 这屎山又发挥了作用。 毕竟它就好比, 用头起飞的鸽子。 如果你不知道我想表达什么, 不知道用头起飞的鸽子, 请一定往下看再往下 default 懂了叭🕊","tags":["Stellar"],"categories":["堆栈"]},{"title":"我数据价值 2082 元的 MongoDB 被攻击","path":"//MongoDB 被攻击/","content":"一个小故事2023.08.05 凌晨随手在闲置服务器安装了一个 MongoDB用于临时测试给QQ机器人添加的Key功能是否有效测试完便下机睡觉2023.08.05 上午十点在我前往另一个城市的时候, 发生了2023.08.05 晚上和朋友聚完回来准备完善测试再push发现携带key方法挂了, 开始排查代码…就刚写没一点的破代码有个屁BUG排查数据库的key, key没了…不!!! 是库没了!!! 生活的小插曲啦 这是攻击者留的唯一库(代码直观展示)‘您的所有数据都已备份。您必须支付0.01 比特币至—博主和谐—在48小时内,您的数据将被公开披露和删除。(更多信息:转到—博主和谐—)付款后发送邮件给我们:—博主和谐—我们将提供一个链接供您下载您的数据。您的DBCODE是:—博主和谐—‘db.getCollection("READ__ME_TO_RECOVER_YOUR_DATA").insert([ {_id: ObjectId("64cdcb2a0f2c98e1b7c19017"),content: "All your data is backed up. You must pay 0.01 BTC to ---博主和谐--- In 48 hours, your data will be publicly disclosed and deleted. (more information: go to ---博主和谐---)After paying send mail to us: ---博主和谐--- and we will provide a link for you to download your data. Your DBCODE is: ---博主和谐---"} ]); 这是日志留下的痕迹 算上东八区, 老贼, 在我上高铁时候下手, 怪不得车上没睡好 这是攻击者IP(当然是IP伪造欺骗) 这是去给定网址的回执(已翻译) 请注意以下几点:我们知道您已经访问了本指南。恢复您的数据的唯一方法是付款。我们不会免费或打折提供数据。如果您决定不检索数据,我们可能会在在线市场上出售您的数据库,向您的用户披露并要求他们付款,在在线违规论坛中披露,或删除它。如果适用,我们将联系您所在国家的欧盟数据保护法机构。如果您无法联系我们,请访问https://xxxxxxx/并下载会话信使。使用以下ID添加我们,以进行流畅的对话和更好的谈判,***不要忘记提及分配给您的DBCODE***:xxxxxxxxxxxxxxxx 结个尾代码虽然开源但config里面还是127.0.0.1, 应该是自动化主机端口扫描的结果(临时用数据库没给密码) 好在是临时用的服务器与数据库, 0.01比特币(2082元)算起步价了吧, 没有比我这更廉价的数据了哈哈哈 大家记得做好安全措施 有趣的是, 我没在日志看到任何那个时间段有关数据库的备份相关操作, 哪怕是查询","tags":["随笔"],"categories":["生活"]},{"title":"Ubuntu 安装使用 Clash","path":"//Clash For Linux/","content":"前言适用范围建立在使用过 window IOS 下的 Clash 为基础的半安装教程 步骤 取出之前设备配置 安装配置Clash 注册为系统服务 在线管理Clash 取出配置备好两个文件 Country.mmdb profiles/xxxxxxxx.yml 打开目录找到两个文件 安配 Clash安装 Clash下载解压并命名为 clash 解压 重命名 移动到 /usr/local/bin/ 目录下 (方便在任何位置调用Clash) 查看版本 下载地址 配置 Clash 首次启动命令行输入clash即可, 一般会提示失败(不重要), 目的是生成配置文件 找到配置目录 一般在 /用户/.config/clash/ 即 /root/.config/clash 放置全球IP库把之前准备的 Country.mmdb 放进去 写配置文件创建一个 config.yaml config.yaml# port of HTTP# port: 7890 ## 解释掉该行,使用mixed-port# port of SOCKS5# socks-port: 7891 ## 解释掉该行,使用mixed-portmixed-port: 52443 ## 提供统一的端口authentication: ## 增加配置,设置账号和密码 - "username:password"# web ui 配置external-controller: 0.0.0.0:52444 # web ui 监听地址secret: "xxxxxxxxxxxxx" # web ui 密钥# allow-lan: falseallow-lan: true ## 允许局域网连接# Rule / Global/ DIRECT (default is Rule)mode: rule# external-ui: dashboard ## 关闭external## 以下贴订阅的配置 接着把之前准备的 /profiles/xxxxxxxx.yml 的文件dns开始到结尾的配置贴到 config.yaml 后面.window与linux配置不同的是前者读取profiles下的列表, 后者直接读取 config.yaml. 注册为系统服务在/etc/systemd/system目录下创建clash.service文件 clash.service[Unit]Description=Clash ServiceAfter=network.target[Service]Type=simpleUser=rootExecStart=/usr/local/bin/clashRestart=on-failure[Install]WantedBy=multi-user.target 以后就能直接使用熟悉的服务命令 systemctl enable clash # 开机自启systemctl start clashsystemctl restart clashsystemctl status clashsystemctl stop clash 在线管理有两个选择, 根据config文件的 web ui 配置, 使用在线网站管理 yacdyacdhttp://yacd.haishan.me/ 如果你不用IP,已经反向代理使用域名并且使用Https, 也可以使用下面的https的yacd 博主的搭建的yacdhttps://clash.thatcoder.cn/ razordrazord提供的(也许要科学上网)http://clash.razord.top/ 结语注意端口自行定义与放行","tags":["Clash"],"categories":["堆栈"]},{"title":"《原神》私有服务器搭建","path":"//game/Genshin Impact/","content":"碎碎念退坑卖号两年, 最近网上冲浪的我看到宵宫传说任务二想来过剧情, 遂想起 grasscutter(开源的原神私服项目,简称 割草机). 不得不说grasscutter 相比之前已经进步很多. 大致步骤1.安装mongodb数据库 2. 配置cultivation3. 配置config (搭建在服务器或本地的区别就在这里)4. 下载游戏本体5. 启动cultivation grasscutter: 相当于游戏的服务器 cultivation: 相当于游戏的代理启动器 下载游戏本体下载游戏本体是最后一步, 放在第一步考虑的是下载太久, 但放在最后是前面都没耐心配置就没必要下载了不是吗之前官服也可以, 不用额外下载. 但现版本grasscutter对应的是3.7版本资源, 官服已经迈入3.8, 所以需要下载3.7版本的国际服. 国际服3.7https://d3ln624mszu7ty.cloudfront.net/client_app/download/pc_zip/20230513200104_2odHBzbUAP5IOIvE/GenshinImpact_3.7.0.zip 安装mongodb官网自行解决 https://www.mongodb.com/try/download/communityMongoDB社区 配置cultivation下载cultivation下载后缀msi的包 cultivation最新版https://github.com/Grasscutters/Cultivation/releases/latest 配置cultivation下载grasscutter(本地运行) 大约要下载400MB左右, 下载出错可以关掉重新来.下载一体化 点击设置 下载grasscutter(服务器运行)服务器跑通自行研究, 其实可以本地编译完上传到服务器运行, 缺少resource文件夹可以走上一步本地运行的方式下载到资源. 路径大概在C:\\Users\\Administrator\\AppData\\Roaming\\cultivation\\grasscutter\\resources.zip Grasscutter最新版https://github.com/Grasscutters/Grasscutter/releases/latest Windows Windowsgit clone --recurse-submodules https://github.com/Grasscutters/Grasscutter.gitcd Grasscutter.\\gradlew.bat # 设置开发环境.\\gradlew jar # 编译 Linux(GNU) Linuxgit clone --recurse-submodules https://github.com/Grasscutters/Grasscutter.gitcd Grasscutterchmod +x gradlew./gradlew jar # 编译 你可以在项目的根目录找到输出的jar。 还有, 尊贵的Coder, Grasscutter是一个Gradle的Java项目, 您可以自定义服务器内容(目前能运营的私服就是这么干的) 配置config如果是在自己电脑当服务器,自己一个人玩, 就不需要配置, 请跳过这步。 因为是一体化下载的grasscutter, 所以路径大概在 C:\\Users\\Administrator\\AppData\\Roaming\\cultivation\\grasscutter\\config.json需要修改几个参数 config.json"bindAddress": "127.0.0.1" //有两个, 都改成 0.0.0.0"accessAddress": "127.0.0.1" //有两个, 都改成服务器IP或者能解析到IP的域名"port": 443 //有两个, 不想撞443的话改成你想要的端口, 记得端口开放 启动cultivation启动游戏 免责声明开此博客纯属积累相关经验记录,而且我需要有记录实证。所有记录的内容均未经专业人士证实,请大家在查看时自行甄别,切勿随意传播。若有违背,本人不承担任何责任!有问题找grasscutters咩! 我只负责和万叶喝茶!最后祝原神越做越好, 米哈游生意兴隆! 问题归纳 Q: 启动grasscutter报错缺失resource资源?A: 下载放到grasscutter的文件夹 https://gitlab.com/YuukiPS/GC-Resources Q: 我能当原神服务器上帝咩?A: 你要的这里都有, 甚至自定义圣遗物 自定义技能. https://github.com/jie65535/GrasscutterCommandGenerator Q: 还有其它问题来频道交流A: 点击链接加入频道【钟意博客】:https://pd.qq.com/s/6h7wytr8a","tags":["Game"],"categories":["第九艺术"]},{"title":"《SKY·光遇》","path":"//game/sky/","content":"谨以此文记录光遇 开端 寒冬, 大一上学年的收尾。寒冬, 世界级瘟疫的开端。落叶捎来讯息, 美好的大学生活埋葬在疫情之下, 比覆雪更严实。 暖春, 好在阴霾里裂开的间隙, 一束光影悄然降临, 光遇。 初遇 第一次听说光遇是年前室友询问我, 怎样在国内玩光遇。我研究了一下当时只有国际服, 发现单纯游玩可以但涉及更新需要手机有谷歌套件,否则更新有丢失账号的风险, 室友嫌麻烦就此作罢。我也没多少兴趣便继续投入到 Rockstar Games 的游戏《Grand Theft Auto V》和《RedDead Redemption 2》。 第二次便是入坑。 疫情下游戏荒时期, 2020.03.05日无意间看到《纪念碑谷》, 便想起陈星汉,进而想起光遇。这便是快乐与遗憾的开始。很符合当天的节气: 惊蛰。 抛开游戏货币蜡烛, 回想起第一周目的游戏体验, 就像是《星际拓荒》般纯粹、干净且美好。但与太空探索的震撼与孤寂不同的是,一周目碰到了几个指引我的”大佬”, 或许是加拿大人, 亦或是日本人、国人, 谁知道呢。唯一能确定的是过客, 因为没解锁聊天, 甚至没加好友。 一周目通关 友人 这是个主打社交的游戏, 遇到数十位性情相投的固玩直接拉满游戏体验。在疫情下大家似乎一天25小时高强度在线, 只可惜一个房间只能8人。 第一个正式好友是”阳菜”, 一位同年级团支书。也许她刚看完《天气之子》。 第二位是”秋刀鱼”, 带我度过了新手时期。 第三位是现实高中室友LQ, 游戏里叫”朔风”, 陪我到一起淡游。 后来加了一位up的群, 认识了很多伙伴, 也在群里一起负责游戏攻略管理。有同年级吐槽光遇乐器不是88键的音乐生”病病”(测试服好搭档), 同年级爱画画和找游戏bug的”小昭”, 后来好像当兵去了的”小新”, 开内衣工厂的”Atlantis.峰”, 弹琴很厉害的”婷婷”,古灵精怪的”鱼鱼”(感谢给我占卜)…后面的再去回忆, 只剩下昵称…”乌拉”、”飞哥”、”小奕”、”姜妤”、”雾”、”yaa”、”陈君泽”(唯一一个游戏上真名的)、”久”、”诺诺”、一些全家一起玩光遇的家庭…还有一些昵称都回忆不起, 尤其是外国友人(笑死, 太长了根本记不住,甚至有些国家不是用英文) 有些印象深刻的记忆碎片: 对线台独分子(其实少部分是台独); 凌晨五点时日本妹子说”窗外的阳光有点刺眼, 我已经一个月没出门”(一小时时差); 很多祝我国战胜疫情的外国玩家(虽然后来成了我们祝福他们); 疫情只能在游戏见面的爸妈和孩子(亲子玩家);发黄黑脸表情就能互相确认身份的国人玩家…… 游戏中后期无趣且重复, 辛有他们带来欢乐与音乐, 以此可抵疫情漫长。 日常音乐会 日常跑图 所剩不多的截图 召唤神狗 这两张插图诠释了光遇内核 这两张插图诠释了光遇内核 羁绊 那段时间家庭情况不好, 经济亦如此。因光遇国际服充值不便, 遂干光遇代氪两个月的收入也帮助我度过这段岁月。 在咸鱼代氪 光遇也让我第一次尝试游戏二创, 不过是音乐方面, 很高兴通过二创能与一些玩家有所共鸣, 同时有些收入。当然现在无法满足催更的玩家了,毕竟离开光遇太久。 QQ音乐 国服 2020.07.09光遇国服开启, 安利给了两个堂妹和同学珞。 陪她们玩的差不多我也就撤了, 国服体验不太纯粹, 就不展开吐槽啦。当然也碰到些难忘的人。 国服记忆 国服记忆 好像我GTA5的游轮喷漆是 SKY-20200709 存档 删除上万张相册之前, 我居然备份了这个视频在网易云音乐。 尾声 频频落笔却一直不知如何写, 就像我想不起什么时候退游的。很遗憾没好好告别, 也许正是想写下此篇的原因。故事开头总是这样,适逢其会,猝不及防。 故事的结局总是这样,花开两朵,天各一方。 售出时间2020.08.13 关于光遇. 想起什么会再回来补充。感谢陈星汉团队与光遇友人。 ——幼稚鬼","tags":["Game"],"categories":["第九艺术"]},{"title":"Ubuntu UOS统信 双显卡外接屏显示问题","path":"//UOS-Nvidia/","content":"前言23年农历年初把电脑双系统的Ubuntu换成统信UOS。安装方法是官网的安装工具。安装过程异常顺利, 但完成后遇到显示屏只亮笔记本的, 原因显卡只使用了核显(知道原因还是能救的)。并尝试如下补救方法: 走原Ubuntu安装N卡驱动流程只亮了外接屏幕, 笔记本屏幕黑屏! 加入官方微信群交流, 工作人员建议尝试UOS软件商店的驱动工具, 还是只亮笔记本的! 最后在第一种方法的基础上手动添加xorg.conf文件得已解决。适用基于Ubuntu与其分支系统。 xorg.conf xorg.conf文件是Linux中用来配置X Window系统的配置文件,它通常存储在/etc/X11/目录下。它的主要目的是控制您的图形卡及其连接显示器的设置和选项。PS: Ubuntu系统中在目录/etc/X11下默认已经没有了文件xorg.conf,为了方便调整显示器的分辨率,可以通过重新生文件xorg.conf来达到目的 如下是我的xorg.conf配置, 仅作参考 xorg.conf# 定义了布局信息,包含一个名为“layout”的标识符# 并设置为使用"NVIDIA"作为屏幕0,同时“intel”处于非活动状态。Section "ServerLayout" Identifier "layout" Screen 0 "nvidia" Inactive "intel"EndSection# 定义了"NVIDIA"设备、标识符、驱动程序和总线ID等信息Section "Device" Identifier "nvidia" Driver "nvidia" BusID "PCI:1:0:0"EndSection# 将"NVIDIA"设备和标识符链接到一起Section "Screen" Identifier "nvidia" Device "nvidia" Option "AccelMethod" "sna" Option "TearFree" "True" Option "Tiling" "True" Option "SwapbuffersWait" "True"EndSection# 定义了"Intel"设备,并将驱动程序设置为modesettingSection "Device" Identifier "intel" Driver "modesetting" BusID "PCI:0:2:0" Option "AllowEmptyInitialConfiguration" "Yes"EndSection# 将"Intel"设备和标识符链接到一起Section "Screen" Identifier "intel" Device "intel"EndSection Section "Files"EndSection 其中BusID等信息可以通过命令获取, BusID就是开头的诸如 1:0,0:2 之类的 同时这个配置能直接操作输出信息等, 修改不当易黑屏, 慎用! 何算成功成功配置输出后会使用N卡, 打印N卡信息即可复查 N卡信息 设置面板有读取信息常为成功。命令: nvidia-settings GPU信息 GPU有使用率定为成功。命令: nvidia-smi 尾声这篇本打算当时写完, 但解决问题后愉快的使用系统去了, 昨天看了一眼UOS发送给Window的文件夹有上面两张图片才想起。","tags":["Linux"],"categories":["堆栈"]},{"title":"Flomo浮墨数据迁移至Memos","path":"//FlomoToMemos/","content":"碎碎念 以前喜欢捣腾笔记软件, 然在两年前遇到 Flomo (一款功能相当简约毫不起眼的APP)。一年后我发现我使用它的频率是所有笔记APP里最高的! (最长是Obsidian) 然后被 Cubox 取代, 诚然也有可能是 Flomo 过期我没续费。 今年初看到memos项目, 便萌生了继续使用Flomo(用memos代替)。因为 Cubox 更多的是琐碎时间浏览到需要的资料或者感兴趣的资料,就转发到 Cubox 里面, 抽空再整理 Cubox 即可。Cubox 不太适合记录突发奇想、文摘、待办事项、感悟等内容。 这篇便是实现年初的想法, 把flomo全部数据转到memos! 开工! 2023.8.18修改适配Memos的0.14版本 2023.8.18修改支持创建时间一致 迁移思路 实现挺简单的, 但在git没看到完整的轮子, 便自己完善 将flomo浮墨导出的数据转成json文件 (这步其实有一个轮子flomoParse,但让使用的人不用折腾两个不同语言项目就一起写成了python代码) 读取json文件将内容和附件图片等通过API上传到自己的memos 实现方法实现在这里就不赘述, 代码比较明了。中途倒是遇到一个 python 实现 multipart/form-data; boundary={boundary} 切片上传(直接上传整个图片文件会限制大小)的小问题 有空记录一下。 multipart切片def upFile(filePath): boundary = '----ThatCoder.cn' # 切片标识符 fileName = filePath.split('/')[-1] with open("flomo/" + filePath, "rb") as f: # 读取二进制文件内容 file_data = f.read() # payload的encode()一个也不能删!!! payload = f'--{boundary}\\r Content-Disposition: form-data; name="file";'.encode() payload += f'filename="{fileName}"\\r Content-Type: {getType(fileName)}\\r \\r '.encode() payload += file_data payload += f'\\r --{boundary}--'.encode() headers = Headers headers['Content-Length'] = str(os.path.getsize("flomo/" + filePath)) headers['Content-Type'] = f'multipart/form-data; boundary={boundary}' response = requests.post(ApiBlob, headers=headers, data=payload) # files参数上传方案 requests_toolbelt包 return response.json() 使用方法项目README有图文讲解, 本篇用来防止提问的人(大概率没有)找不到地方。项目地址: FlomoToMemos 浮墨浅谈 昔者时光溢畅,余悠然自得,好炼煉微型软件。遇上浮墨,其简洁明了,且颜值甚高,遂投身其共修群聊。见开发者努力谋取,且妙趣横生,群友问题皆一一回复,群谈也和蔼可亲。证明喜欢一项产品,一部分为赏识开发团队之风采与行事方式。惟后来,再无后续之因缘。( GPT)","tags":["Memos"],"categories":["堆栈"]},{"title":"《星际拓荒》","path":"//game/Outer Wilds/","content":"Little Nightmares 7+ 太空 解密 游戏封面与音乐 前言 我知道这是一款很神奇的游戏, 但玩后我还是想说: 真TMD牛逼! 这才是第九艺术! 游戏发展史 2012年一个硕士的学生项目南加州大学Alex通过游戏展示海森堡提出的不确定性原理2013年初次会面在demo day冈政伟一见钟情星际拓荒demo, 但Alex去了微软2014年Alex回归Alex加入Mobius2015年公开Alpha版本获麦克纳利大奖, 3万刀, fig众筹2019年05月30日游戏发行感谢马丁的美术 游戏介绍 任何剧情的介绍都是在浪费这款游戏, 如果您没有 晕3D、深海恐惧症, 并且有一颗探索宇宙的心。 答案在最危险的太空中等着您。 尾声 拨开很多游戏光鲜亮丽的美术外衣, 本质是从这个地图位置到另一个地图位置击杀一个单位完成一个问号的过程, 亦或是纯数值游戏。 而《星际拓荒》让我回到了游戏最开始的纯粹, 没有数值没有升级没有装备没有金币没有地图问号。 甚至问NPC我该干嘛 他会回答:”你是去月球还是碎空星,还是去木炉星的另一侧都无所谓。对我来说都一样。快去吧,好好玩儿!”, 我猜他还想补一句: “别打扰老子烤棉花!” 很想分享游戏途中的一些惊奇, 但非常影响初玩者体验, 作罢! 如果你要游玩, 我想转告你: “这里充满了恐惧与孤独,但是一点浪漫即可将其全部驱散。” 下载地址 密码栏下载栏云盘密码星际拓荒https://cloud.189.cn/web/share?code=VV3uAbNfUj2u 有能力一定入正喔! 星际拓荒-steam正版https://store.steampowered.com/app/753640/Outer_Wilds/","tags":["Game"],"categories":["第九艺术"]},{"title":"您名下已备案网站目前涉及违法信息","path":"//您名下已备案网站目前涉及违法信息/","content":"故事的开始 2号写单写的有点晚, 次日九点在睡梦中被电话惊醒, 一看是天翼就给他挂了继续睡 (已经忘记服务器就在天翼 ) 3号下午收到一封邮件说这件事 3号晚上发现这封邮件时我的服务器已经被封了端口 80 和 443 解决办法 按邮件查找相应内容并删除 删除完致电邮件里的联系方式并告诉客服原因与IP 等待客服审核对应内容与回电 (客服声音真好听 ) 原因懒得排查了, 那个服务器坐等过期.可能是wordpress主题的原因, 有些链接被攻击成了成人网站, 导致我站包含链接网站. 总之碰到类似情况不要慌, 按邮件做即可.","tags":["随笔"],"categories":["生活"]},{"title":"邮件样式模板集","path":"//邮箱模板集/","content":"薇尔莉特 动漫来源出自: 紫罗兰永恒花园邮件模板作者: 旧版作者未知, 我是在Akilar看到的改版. 薇尔莉特<head> <base target="_blank"/> <style id="scrollbar" type="text/css">::-webkit-scrollbar { width: 0 !important } pre { white-space: pre-wrap !important; word-wrap: break-word !important; *white-space: normal !important } pre { white-space: pre-wrap !important; word-wrap: break-word !important; *white-space: normal !important } #letter img { max-width: 300px }</style> <style id="from-wrapstyle" type="text/css">#form-wrap { overflow: hidden; height: 447px; position: relative; top: 0px; transition: all 1s ease-in-out .3s; z-index: 0 }</style> <style id="from-wraphoverstyle" type="text/css">#form-wrap:hover { height: 1300px; top: -200px }</style></head><body><div style="width: 530px;margin: 20px auto 0;height: 1000px;"> <div id="form-wrap"><img src="https://upyun.thatcdn.cn/public/web/email_template/head_before.png" alt="before" style="position: absolute;bottom: 126px;left: 0px;background-repeat: no-repeat;width: 530px;height: 317px;z-index:-100"> <div style="position: relative;overflow: visible;height: 1500px;width: 500px;margin: 0px auto;transition: all 1s ease-in-out .3s;padding-top:200px;" <form> <div style="background: white;width: 95%;max-width: 800px;margin: auto auto;border-radius: 5px;border: 1px solid;overflow: hidden;-webkit-box-shadow: 0px 0px 20px 0px rgba(0, 0, 0, 0.12);box-shadow: 0px 0px 20px 0px rgba(0, 0, 0, 0.18);"> <img style="width:100%;overflow: hidden;" src="https://upyun.thatcdn.cn/public/web/email_template/head_wely.jpg"/> <div style="padding: 5px 20px;"><br> <div><h3 style="text-decoration: none; color: rgb(246, 214, 175);">{{ parent.nick }},见信安:</h3> </div> <br> <div id="letter" style="overflow:auto;height:300px;width:100%;display:block;word-break: break-all;word-wrap: break-word;"> <p style="display: inline-block;">您在<a style="text-decoration: none;color: rgb(246, 214, 175)" target="_blank" href="{{site.url}}">⟬{{ site.name }}⟭</a>上发表的评论: </p> <div id="parentC" style="border-bottom: #ddd 1px solid;border-left: #ddd 1px solid;padding-bottom: 20px;background-color: #eee;margin: 15px 0px;padding-left: 20px;padding-right: 20px;border-top: #ddd 1px solid;border-right: #ddd 1px solid;padding-top: 20px;font-family: 'Arial', 'Microsoft YaHei' , '黑体' , '宋体' , sans-serif;"> {{ parent.comment }} </div> <p>收到了来自{{ self.nick }}的回复:</p> <div id="selfC" style="border-bottom: #ddd 1px solid;border-left: #ddd 1px solid;padding-bottom: 20px;background-color: #eee;margin: 15px 0px;padding-left: 20px;padding-right: 20px;border-top: #ddd 1px solid;border-right: #ddd 1px solid;padding-top: 20px;font-family: 'Arial', 'Microsoft YaHei' , '黑体' , '宋体' , sans-serif;"> {{ self.comment }} </div> </div> <br> <div style="text-align: center;margin-top: 40px;"><img src="https://upyun.thatcdn.cn/public/web/email_template/footer_bilibili.png" alt="hr" style="width:100%; margin:5px auto 5px auto; display: block;"/><a style="text-transform: uppercase;text-decoration: none;font-size: 14px;border: 2px solid #6c7575;color: #2f3333;padding: 10px;display: inline-block;margin: 10px auto 0;background-color: rgb(246, 214, 175);" target="_blank" href="{{site.postUrl}}">{{ parent.nick }}|请您点击签收~</a></div> <p style="font-size: 12px;text-align: center;color: #999;"><br>薇尔莉特·伊芙加登<br>自动书记人偶竭诚为您服务!<br>©2020-2023<a style="text-decoration:none; color:rgb(246, 214, 175)" href="{{site.url}}">{{ site.name }}</a></p></div> </div> </form> </div> <img src="https://upyun.thatcdn.cn/public/web/email_template/head_after.png" alt="after" style=" position: absolute;bottom: -2px;left: 0;background-repeat: no-repeat;width: 530px;height: 259px;z-index:100"></div></div></body> 简洁渐变 邮箱模板作者: 未知, 以前PHP站点扒的. 简洁渐变<div style="border-radius: 10px 10px 10px 10px;font-size:14px;color: #555555;width: 666px;font-family:'Century Gothic','Trebuchet MS','Hiragino Sans GB',微软雅黑,'Microsoft Yahei',Tahoma,Helvetica,Arial,'SimSun',sans-serif;margin:50px auto;border:1px solid #eee;max-width:100%;background: #ffffff repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);">\t<div style="width:100%;background:#49BDAD;color:#ffffff;border-radius: 10px 10px 0 0;background-image: -moz-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));background-image: -webkit-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));height: 66px;">\t<p style="font-size:15px;word-break:break-all;padding: 23px 32px;margin:0;background-color: hsla(0,0%,100%,.4);border-radius: 10px 10px 0 0;">您在<a style="text-decoration:none;color: #ffffff;" href="{{site.url}}" target="_blank">{{site.name}}</a>上的留言有新评论啦!</p>\t</div> <div style="margin:40px auto;width:90%"><p>{{self.nick}} 回复说:</p> <div style="background: #fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:14px;color:#555555;">{{self.comment | safe}}</div> <p>您可以点击<a style="text-decoration:none; color:#12addb" href="{{site.postUrl}}" target="_blank">查看回复的完整內容</a>。<hr /> </p><style type="text/css">a:link{text-decoration:none}a:visited{text-decoration:none}a:hover{text-decoration:none}a:active{text-decoration:none}</style> </div>\t</div>`;\tmailSubject: '{{parent.nick | safe}},『{{site.name | safe}}』上的评论收到了回复', mailTemplate: `<div style="border-radius: 10px 10px 10px 10px;font-size:14px;color: #555555;width: 666px;font-family:'Century Gothic','Trebuchet MS','Hiragino Sans GB',微软雅黑,'Microsoft Yahei',Tahoma,Helvetica,Arial,'SimSun',sans-serif;margin:50px auto;border:1px solid #eee;max-width:100%;background: #ffffff repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);">\t<div style="width:100%;background:#49BDAD;color:#ffffff;border-radius: 10px 10px 0 0;background-image: -moz-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));background-image: -webkit-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));height: 66px;">\t<p style="font-size:15px;word-break:break-all;padding: 23px 32px;margin:0;background-color: hsla(0,0%,100%,.4);border-radius: 10px 10px 0 0;">您在<a style="text-decoration:none;color: #ffffff;" href="{{site.url}}" target="_blank">{{site.name}}</a>上的留言有新回复啦!</p>\t</div> <div style="margin:40px auto;width:90%"><p>Hi, {{parent.nick}},您曾在文章上发表评论:</p> <div style="background: #fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:14px;color:#555555;">{{self.comment | safe}}</div> <p><strong>{{self.nick}}</strong> 给您的回复如下:</p> <div style="background: #fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:14px;color:#555555;">{{self.comment | safe}}</div> <p>您可以点击<a style="text-decoration:none; color:#12addb" href="{{site.postUrl}}" target="_blank">查看回复的完整內容</a>,欢迎再次光临<a style="text-decoration:none; color:#12addb" href="{{site.url}}" target="_blank">{{site.name}}</a>。<hr /> <p style="font-size:12px;color:#b7adad">本邮件为系统自动发送,请勿直接回复邮件哦,可到博文内容回复。</p> </p><style type="text/css">a:link{text-decoration:none}a:visited{text-decoration:none}a:hover{text-decoration:none}a:active{text-decoration:none}</style> </div>\t</div> 简洁头图 邮件模板作者: SaraKale根据上面改的. 简洁头图<div style="background-image: url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/bg.jpg);;padding:20px 0px 20px;margin:0px;background-color:#ded8ca;width:100%;">\t<div style="background: url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/leisi-714x62.png) repeat-y scroll top;"> <div style="border-radius: 10px 10px 10px 10px;font-size:14px;color: #555555;width: 666px;font-family:'Century Gothic','Trebuchet MS','Hiragino Sans GB',微软雅黑,'Microsoft Yahei',Tahoma,Helvetica,Arial,'SimSun',sans-serif;margin:50px auto;border:1px solid #eee;max-width:100%;background: #ffe8dd61;box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);margin:auto"> <img class="headerimg no-lightbox entered loaded" src="https://npm.elemecdn.com/sarakale-assets@v1/bg/bg3.jpg" style="width:100%;overflow:hidden;pointer-events:none" data-ll-status="loaded"> <div style="width:100%;color:#9d2850;border-radius: 10px 10px 0 0;background-image: -moz-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));height: 66px;background: url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/line034_666x66.png) left top no-repeat;"> <p style="font-size:16px;font-weight: bold;text-align:center;word-break:break-all;padding: 23px 32px;margin:0;border-radius: 10px 10px 0 0;">您在<a style="text-decoration:none;color: #9d2850;" href="{{site.url}}"target="_blank">{{site.name}}</a>上的文章有了新的评论</p> </div> <div style="margin:40px auto;width:90%;"><p><strong>{{self.nick}}</strong> 回复说:</p> <div style="background: #fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:15px;color:#555555;">{{self.comment | safe}} </div> <p>您可以点击<a style="text-decoration:none; color:#cf5c83" href="{{site.postUrl}}" target="_blank">查看回复的完整內容</a></p> </div> </div>\t</div></div>`, mailSubject: '{{parent.nick}},您在『{{site.name}}』上发表的评论收到了来自 {{self.nick}} 的回复', mailTemplate: `<div style="background-image:url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/bg.jpg);;padding:20px 0px 20px;margin:0px;background-color:#ded8ca;width:100%;"><div style="background:url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/leisi-714x62.png) repeat-y scroll top;">\t<div style="border-radius:10px 10px 10px 10px;font-size:14px;color:#555555;width:666px;font-family:'Century Gothic','Trebuchet MS','Hiragino Sans GB',微软雅黑,'Microsoft Yahei',Tahoma,Helvetica,Arial,'SimSun',sans-serif;margin:50px auto;border:1px solid #eee;max-width:100%;background:#ffe8dd61;box-shadow:0 1px 5px rgba(0,0,0,0.15);margin:auto">\t<img class="headerimg no-lightbox entered loaded" src="https://npm.elemecdn.com/sarakale-assets@v1/bg/bg3.jpg" style="width:100%;overflow:hidden;pointer-events:none" data-ll-status="loaded"> <div style="width:100%;border-radius:10px 10px 0 0;background-image:-moz-linear-gradient(0deg,rgb(67,198,184),rgb(255,209,244));height:66px;background:url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/line034_666x66.png) left top no-repeat;color:#9d2850;"> <p style="font-size:16px;font-weight: bold;text-align:center;word-break:break-all;padding:23px 32px;margin:0;border-radius:10px 10px 0 0;">您在<a style="text-decoration:none;color:#9d2850;" href="{{site.url}}">『{{site.name | safe}}』</a>上的留言有新回复啦!</p> </div> <div style="margin:40px auto;width:90%;"><p>Hi,{{parent.nick}},您曾在文章上发表评论:</p> <div style="background:#fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow:0 2px 5px rgba(0,0,0,0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:15px;color:#555555;">{{parent.comment | safe}}</div> <p><strong>{{self.nick}}</strong> 给您的回复如下:</p> <div style="background:#fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow:0 2px 5px rgba(0,0,0,0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:15px;color:#555555;">{{self.comment | safe}}</div> <p>您可以点击<a style="text-decoration:none;color:#cf5c83" href="{{site.postUrl}}" target="_blank"> 查看回复的完整內容 </a>,欢迎再次光临<a style="text-decoration:none;color:#cf5c83" href="{{site.url}}" target="_blank"> {{site.name}} </a>。 <hr /><p style="font-size:14px;color:#b7adad">本邮件为系统自动发送,请勿直接回复邮件哦,可到博文内容回复。<br />https://sarakale.top/blog</p></p> </div>\t</div></div></div> 组装时间线测试网易云memos微信读书联合测试 memos单个测试标识符不同应该不会混淆进去","tags":["邮件"],"categories":["分享"]},{"title":"观《深海》的奇妙联想","path":"//daily/Deep Sea/","content":"观前 多位朋友安利 被影评博主吹爆 特效看着不赖 疫情管控放开后爷单纯想看电影 观感 首先,我有部分原因是奔着画面去的。《深海》也没令我失望, 呈现了一道国风视觉盛宴。颜狗党摊牌了。不过《深海》的特效也不是纯炫技, 极致的色彩对于参宿梦境的构建与情绪的表达都是锦上添花,极具表现力。 至于呈现的剧情, 整体而言没有把握好因果, 或者说是导演的大胆想法导致必须舍弃良好的因果回归,以至于将故事的真相与参宿的和解滞后到结尾。导演的放飞自我也会导致没对影片没共情的人带来更加不良好的观感。不过不得不说,这滞后的后劲真大。 联想 其实剧情没什么内容, 倒是让我产生了一些联想。 开头参宿的情况我想起《我的姐姐》,当然影片重点亦不是家庭情况。 整部剧情我想起游戏《古树旋律DEEMO》。海精灵和丧气鬼就像deemo里的神秘女孩,是自我意愿的具化, 不想让自己离开梦境; 影片中后暗示的光与声想起deemo右侧房间的病房心电图声;一道白光拉回现实的雷同; 牺牲的南河与deemo… 还想起数句话: 抑郁症患者自杀是想通了还是没想通。 抑郁症是病, 是缺神经质, 不是单纯的心理问题, 不是笑一笑就能解决的。 醒醒了,该散场了。 尾声 总的我不安利这部电影, 正如网评一般: 影片最大的缝合,是将虚构的动画世界与现实的离异家庭子女问题、抑郁症等“丧文化”情绪进行对接,试图以超越性的情感共鸣唤起观众的价值共振,实现动画干预现实的诉求。然而,来源复杂、牵涉广泛的要素堆积,并没有为影片带来蒸汽朋克式的别样审美,反而因为过于强烈和刻意的表现欲,将上述要素降格为机械拼盘,未能产生应有的艺术效果。 当然无聊可以看看, 也许你是小众狂欢里的一员。 美图 深海古树旋律 咳咳, 其实也想分享古树旋律歌曲的, 大部分要VIP淦 DEEMO歌单","tags":["影剧"],"categories":["生活"]},{"title":"浅谈跨域-就你小子不让我跨域","path":"//CrossOrigin/","content":"何为跨域全称: “跨来源资源共享” “Cross-origin resource sharing” 跨域范畴: 不同主域名 不同二级域名 不同端口 http和https协议不同 域名访问和直接访问其解析IP 造成影响: Cookie、LocalStorage和IndexDB无法获取 DOM无法获得 AJAX请求不能发送 … 为何制定跨域W3C的搞事佬制定的标准, 出发点当然是安全问题.不妨思考一下古老钓鱼网站的行为, 与我的抽象代码. 第一步通过个人通信方式把人骗到钓鱼网站第二步钓鱼网站已经嵌入了目标官方网站第三步(营销语气) 注意看, 这个男人叫王小帅, 他在钓鱼网站上输入了账号密码.第四步获取嵌入的官网的DOM节点获取账号密码. 抽象钓鱼代码...<iframe name="diaoyu" src="www.xxbank.com"></iframe>...<script> const iframe = window.frames['diaoyu'] const count = iframe.document.getElementById('count') const pwd = iframe.document.getElementById('password') console.log("账号:${count}, 密码:${pwd}")</script> 解决方案 CORS需要浏览器和服务器同时支持。所有浏览器都支持该功能,IE浏览器不能低于IE10。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求。因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。 了解请求与响应 简单请求: 请求method是get、head或者post 除了用户代理自动设置的一些头部,开发工程师手动设置的头部是如下头部之一: Accept, Accept-Language, Content-Language,Content-Type, Last-Event-ID, DPR, Save-Data, Viewport-Width, Width content-type是application/x-www-form-urlencoded、 multipart/form-data或者text/plain 没有事件注册到XMLHttpRequestUpload上 在请求时没有使用ReadableStream 简单请求主要是解决Access-Control-Allow-Origin是否包含在通行域 简单响应//指定允许其他域名访问'Access-Control-Allow-Origin:http://172.20.0.206'//一般用法(*,指定域,动态设置),3是因为*不允许携带认证头和cookies//是否允许后续请求携带认证信息(cookies),该值只能是true,否则不返回'Access-Control-Allow-Credentials:true' 复杂请求:没错,不满足上面的,都是我啦!浏览器会先发送option(预检)请求,option请求多了2个字段 Access-Control-Request-Method, Access-Control-Request-Headers 复杂响应//指定允许其他域名访问'Access-Control-Allow-Origin:http://172.20.0.206'//一般用法(*,指定域,动态设置),3是因为*不允许携带认证头和cookies//是否允许后续请求携带认证信息(cookies),该值只能是true,否则不返回'Access-Control-Allow-Credentials:true'//预检结果缓存时间,也就是上面说到的缓存啦'Access-Control-Max-Age: 1800'//允许的请求类型'Access-Control-Allow-Methods:GET,POST,PUT,POST'//允许的请求头字段'Access-Control-Allow-Headers:x-requested-with,content-type' 前端 祖传JSONP同源策略是根据脚本(js)的来源判断是否限制, jsonp是通过 <script> 标签冒充同源.缺点是只能发送get请求(聊胜于无?) 原生JS原生JS跨域问题示例代码 原生JSvar script = document.createElement('script'); script.type = 'text/javascript'; // 传参一个回调函数名给后端,方便后端返回时执行这个在前端定义的回调函数 script.src = 'http://www.domain2.com:8080/login?user=admin&callback=handleCallback'; document.head.appendChild(script); // 回调执行函数 function handleCallback(res) { alert(JSON.stringify(res)); } JQJQ跨域问题示例代码 Ajax$.ajax({ url: 'http://www.domain2.com:8080/login', type: 'get', dataType: 'jsonp', // 请求方式为jsonp jsonpCallback: "handleCallback", // 自定义回调函数名 data: {}}); VueVue Axios跨域问题示例代码 Axiosthis.$http = axios;this.$http.jsonp('http://www.domain2.com:8080/login', { params: {}, jsonp: 'handleCallback' }).then((res) => { console.log(res);}) Node.jsNodeJS跨域问题示例代码 Nodevar querystring = require('querystring');var http = require('http');var server = http.createServer();server.on('request', function(req, res) { var params = querystring.parse(req.url.split('?')[1]); var fn = params.callback; // jsonp返回设置 res.writeHead(200, { 'Content-Type': 'text/javascript' }); res.write(fn + '(' + JSON.stringify(params) + ')'); res.end();});server.listen('8080');console.log('Server is running at port 8080...'); 前端配置 原生Ajax原生Ajax跨域问题示例代码 Ajaxvar xhr = new XMLHttpRequest(); // IE8/9需用window.XDomainRequest兼容// 前端设置是否带cookiexhr.withCredentials = true;xhr.open('post', 'http://www.domain2.com:8080/login', true);xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');xhr.send('user=admin');xhr.onreadystatechange = function() { if (xhr.readyState == 4 && xhr.status == 200) { alert(xhr.responseText); }}; jQ AjaxJQ Ajax跨域问题配置示例代码 JQAjax$.ajax({...xhrFields: { withCredentials: true // 前端设置是否带cookie},crossDomain: true, // 会让请求头中包含跨域的额外信息,但不会含cookie...}); Vue配置Vue跨域跨域问题axios,vue-resource配置示例代码 axios设置:axios.defaults.withCredentials = true vue-resource设置:Vue.http.options.credentials = true 后端 java 方案一: WebCrosConfigjava跨域问题addCorsMappings方案示例代码 配置类@Configurationpublic class WebCrosConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET","HEAD","POST","PUT","DELETE","OPTIONS") .allowCredentials(true) .maxAge(3600) .allowedHeaders("*"); }} 方案二: CrosFilterjava跨域问题CrosFilter方案示例代码友人南山客补充方案 CrosFilter/* * @author 南山客 友情赞助代码 * @email nansker@163.com * @create 2022/10/10 17:31 * @description */package cn.nansk.takeout.config;import cn.nansk.takeout.common.JacksonObjectMapper;import lombok.extern.slf4j.Slf4j;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.http.converter.HttpMessageConverter;import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;import org.springframework.web.cors.CorsConfiguration;import org.springframework.web.cors.UrlBasedCorsConfigurationSource;import org.springframework.web.filter.CorsFilter;import org.springframework.web.servlet.config.annotation.CorsRegistry;import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;import java.util.List;@Slf4j@Configurationpublic class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { log.info("开始进行静态资源映射..."); } // FIXME: 2022/11/2 跨域问题没有得到解决 //解决跨域问题 //@Override //public void addCorsMappings(CorsRegistry registry){ // registry.addMapping("/**") // .allowedOriginPatterns("*") // .allowedMethods("GET","HEAD","POST","PUT","DELETE","OPTIONS") // .allowCredentials(true) // .maxAge(3600) // .allowedHeaders("*"); //} /*** * @author Nansker * @date 2023/2/17 23:17 * @return org.springframework.web.filter.CorsFilter * @description 允许跨域调用过滤器 * 这里不能使用Override addCorsMappings()方法解决跨域问题,具体原因未知 */ @Bean public CorsFilter corsFilter(){ CorsConfiguration config = new CorsConfiguration(); //允许白名单域名进行跨域调用 config.addAllowedOrigin("*"); //允许跨越发送cookie config.setAllowCredentials(true); //放行全部原始头信息 config.addAllowedHeader("*"); //允许所有请求方法跨域调用 config.addAllowedMethod("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); }} Node配置var http = require('http');var server = http.createServer();var qs = require('querystring');server.on('request', function(req, res) { var postData = ''; // 数据块接收中 req.addListener('data', function(chunk) { postData += chunk; }); // 数据接收完毕 req.addListener('end', function() { postData = qs.parse(postData); // 跨域后台设置 res.writeHead(200, { 'Access-Control-Allow-Credentials': 'true', // 后端允许发送Cookie 'Access-Control-Allow-Origin': 'http://www.domain1.com', // 允许访问的域(协议+域名+端口) /* * 此处设置的cookie还是domain2的而非domain1,因为后端也不能跨域写cookie(nginx反向代{过}{滤}理可以实现), * 但只要domain2中写入一次cookie认证,后面的跨域接口都能从domain2中获取cookie,从而实现所有的接口都能跨域访问 */ 'Set-Cookie': 'l=a123456;Path=/;Domain=www.domain2.com;HttpOnly' // HttpOnly的作用是让js无法读取cookie }); res.write(JSON.stringify(postData)); res.end(); });});server.listen('8080');console.log('Server is running at port 8080...'); 服务器 Nginx通过Nginx配置一个代理服务器域名与domain1相同,端口不同)做跳板机,反向代理访问domain2接口,并且可以顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域访问。 站点配置#proxy服务器server { listen 81; server_name www.domain1.com; location / { proxy_pass http://www.domain2.com:8080; #反向代理 proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名 index index.html index.htm; # 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用 add_header Access-Control-Allow-Origin http://www.domain1.com; #当前端只跨域不带cookie时,可为* add_header Access-Control-Allow-Credentials true; }} Nodenode中间件实现跨域代理,原理大致与nginx相同,都是通过启一个代理服务器,实现数据的转发,也可以通过设置cookieDomainRewrite参数修改响应头中cookie中域名,实现当前域的cookie写入,方便接口登录认证。 fetch跨域get请求FG前端代码前端getfetch('http://localhost:6888/test_get',{ method: 'GET', mode: 'cors',}).then(res => { return res.json();}).then(json => { console.log('获取的结果', json.data); return json;}).catch(err => { console.log('请求错误', err);}) 服务端服务端配置c.Header("Access-Control-Allow-Origin", "*")c.Header("Access-Control-Allow-Methods", "GET, POST") post请求FP前端代码前端postfetch('http://localhost:6888/test_post',{ method: 'POST', body: JSON.stringify({name: 'zaozuo'}), mode: 'cors',}).then(res => { return res.json();}).then(json => { console.log('获取的结果', json.data); return json;}).catch(err => { console.log('请求错误', err);}) 后端代码同get相同 put请求把post请求模式改成put即可, 其它一致.不同于get、post请求的地方是请求有个预检查(OPTIONS请求),然后再发put请求;上面的头部信息都是options请求相关的,put请求跟平时普通http请求一样。 头部补充 request跨域头部介绍 Access-Control-Allow-Origin:可以允许哪些客户端来访问,指可以是*,也可以是某个域名或者用逗号隔开的域名列表。 Access-Control-Expose-Headers: 浏览器可以访问的一些头部。 Access-Control-Max-Age:预检查结果可以缓存的问题 Access-Control-Allow-Methods:指定客户端发请求可以使用的方法 Access-Control-Allow-Headers:指定客户端发请求可以使用的头部。 Access-Control-Allow-Credentials: 指定客户端是否可以携带cookie等认证信息(前端fetch设置withCredentials:true进行发送cookie),如果是简单请求等跨域得确保此response头设置为true。 response头部 Access-Control-Allow-Origin:可以允许哪些客户端来访问,指可以是*,也可以是某个域名或者用逗号隔开的域名列表。 Access-Control-Expose-Headers: 浏览器可以访问的一些头部。 Access-Control-Max-Age:预检查结果可以缓存的问题 Access-Control-Allow-Methods:指定客户端发请求可以使用的方法 Access-Control-Allow-Headers:指定客户端发请求可以使用的头部。 Access-Control-Allow-Credentials: 指定客户端是否可以携带cookie等认证信息(前端fetch设置withCredentials:true进行发送cookie),如果是简单请求等跨域得确保此response头设置为true。 奇技淫巧方案同主域不同子域实现原理:两个页面都通过js强制设置document.domain为基础主域,就实现了同域。 父窗口 <iframe id="iframe" src="http://child.domain.com/b.html"></iframe><script> document.domain = 'domain.com'; var user = 'admin';</script> 子窗口 <script> document.domain = 'domain.com'; // 获取父窗口中变量 alert('get js data from parent ---> ' + window.parent.user);</script> 不同主域方案一实现原理:a欲与b跨域相互通信,通过中间页c来实现。三个页面,不同域之间利用iframe的location.hash传值,相同域之间直接js访问来通信。具体实现:A域:a.html -> B域:b.html -> A域:c.html,a与b不同域只能通过hash值单向通信,b与c也不同域也只能单向通信,但c与a同域,所以c可通过parent.parent访问a页面所有对象。 a.html<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe><script> var iframe = document.getElementById('iframe'); // 向b.html传hash值 setTimeout(function() { iframe.src = iframe.src + '#user=admin'; }, 1000); // 开放给同域c.html的回调方法 function onCallback(res) { alert('data from c.html ---> ' + res); }</script> b.html<iframe id="iframe" src="http://www.domain1.com/c.html" style="display:none;"></iframe><script> var iframe = document.getElementById('iframe'); // 监听a.html传来的hash值,再传给c.html window.onhashchange = function () { iframe.src = iframe.src + location.hash; };</script> c.html<script> // 监听b.html传来的hash值 window.onhashchange = function () { // 再通过操作同域a.html的js回调,将结果传回 window.parent.parent.onCallback('hello: ' + location.hash.replace('#user=', '')); };</script> 方案二window.name属性的独特之处:name值在不同页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的name值(2MB).代理b.html的数据通过iframe的src属性由外域转向本地域,跨域数据即由iframe的window.name从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。 doamin1/a.html doamin1/proxy.html domain2/b.html a.htmlvar proxy = function(url, callback) { var state = 0; var iframe = document.createElement('iframe'); // 加载跨域页面 iframe.src = url; // onload事件会触发2次,第1次加载跨域页,并留存数据于window.name iframe.onload = function() { if (state === 1) { // 第2次onload(同域proxy页)成功后,读取同域window.name中数据 callback(iframe.contentWindow.name); destoryFrame(); } else if (state === 0) { // 第1次onload(跨域页)成功后,切换到同域代{过}{滤}理页面 iframe.contentWindow.location = 'http://www.domain1.com/proxy.html'; state = 1; } }; document.body.appendChild(iframe); // 获取数据以后销毁这个iframe,释放内存;这也保证了安全(不被其他域frame js访问) function destoryFrame() { iframe.contentWindow.document.write(''); iframe.contentWindow.close(); document.body.removeChild(iframe); }};// 请求跨域b页面数据proxy('http://www.domain2.com/b.html', function(data){ alert(data);}); proxy.html中间代理页,与a.html同域,内容为空即可。 b.html<script> window.name = 'This is domain2 data!';</script> postMessagepostMessage是HTML5 XMLHttpRequest Level 2中的API,且是为数不多可以跨域操作的window属性之一,它可用于解决以下方面的问题: 页面和其打开的新窗口的数据传递 多窗口之间消息传递 页面与嵌套的 iframe 消息传递 上面三个场景的跨域数据传递 用法:postMessage(data,origin)方法接受两个参数: data:html5规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用JSON.stringify()序列化。 origin:协议+主机+端口号,也可以设置为”*“,表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为”/“。 a.html<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe><script> var iframe = document.getElementById('iframe'); iframe.onload = function() { var data = { name: 'aym' }; // 向domain2传送跨域数据 iframe.contentWindow.postMessage(JSON.stringify(data), 'http://www.domain2.com'); }; // 接受domain2返回数据 window.addEventListener('message', function(e) { alert('data from domain2 ---> ' + e.data); }, false);</script> b.html<script> // 接收domain1的数据 window.addEventListener('message', function(e) { alert('data from domain1 ---> ' + e.data); var data = JSON.parse(e.data); if (data) { data.number = 16; // 处理后再发回domain1 window.parent.postMessage(JSON.stringify(data), 'http://www.domain1.com'); } }, false);</script> WebSocketWebSocket protocol是HTML5一种新的协议。它实现了浏览器与服务器全双工通信,同时允许跨域通讯,是server push技术的一种很好的实现。原生WebSocket API使用起来不太方便,我们使用Socket.io,它很好地封装了webSocket接口,提供了更简单、灵活的接口,也对不支持webSocket的浏览器提供了向下兼容。 前端代码<div>user input:<input type="text"></div><script src="./socket.io.js"></script><script>var socket = io('http://www.domain2.com:8080');// 连接成功处理socket.on('connect', function() { // 监听服务端消息 socket.on('message', function(msg) { console.log('data from server: ---> ' + msg); }); // 监听服务端关闭 socket.on('disconnect', function() { console.log('Server socket has closed.'); });});document.getElementsByTagName('input')[0].onblur = function() { socket.send(this.value);};</script> Node-socket后台var http = require('http');var socket = require('socket.io');// 启http服务var server = http.createServer(function(req, res) { res.writeHead(200, { 'Content-type': 'text/html' }); res.end();});server.listen('8080');console.log('Server is running at port 8080...');// 监听socket连接socket.listen(server).on('connection', function(client) { // 接收信息 client.on('message', function(msg) { client.send('hello:' + msg); console.log('data from client: ---> ' + msg); }); // 断开处理 client.on('disconnect', function() { console.log('Client socket has closed.'); });});","tags":["跨域"],"categories":["堆栈"]},{"title":"Waline评论与Lsky兰空图床","path":"//Waline与Lsky兰空图床/","content":"前言giscus改为waline后不能上传图片, 配置好后还是会弹图片大于128KB, 可能我哪里搞错了吧.查阅waline源码有一个defaultUpdateImage限制图片文件大小128KB, 但自定义后还是会走到这里判断.遂自己改造了一下, 并记录一路碰到的许多问题. 2023.02.06 破案了, 感谢是非题提醒主题后续将主题参数 url 改成 api,详见2023.1.12的commit.好细节!这个故事告诉我们要及时拉取.所以我把Stellar的提交历史放到了便签关注 获取兰空Token搭建LskyPro兰空搭建https://blog.thatcoder.cn/Lsky%E5%85%B0%E7%A9%BA%E5%9B%BE%E5%BA%8A%E6%90%AD%E5%BB%BA/ 获取Token一个Post请求就可以获取Token, 但前端的一切都是公开的, 意味着你的Token一定暴露.所以建一个专门用来当博客评论图床的账号, 给少点鉴权限. Post请求https://你的部署地址/api/v1/tokens 你可以使用ApiPost网页版 ApiPost网页版请求方法成功响应填装body参数 `email` `password` 请求成功得到一个类似 3|xxxxxxxxxxxxxxxxxxxxxx 的响应参数, 前面的竖杠和数字不要漏了. 搭建waline这个不用服务器, vercel一步到位, 详情参考官方教程. waline之vercel部署https://waline.js.org/guide/get-started/#vercel-%E9%83%A8%E7%BD%B2-%E6%9C%8D%E5%8A%A1%E7%AB%AF hexo启用waline不同主题不一样, 如果主题没适配waline可以自己在生成文章的地方适当位置添加一个div给上唯一id, 等下会用到.这里给Stellar主题评论启用waline 根目录/_config.stellar.yml# 评论 twikoo服务comments: service: waline waline: serverURL: https://你部署的地址/ # waline 地址 locale: placeholder: "" # 输入框内提示文字 # Custom emoji emoji: - https://unpkg.com/@waline/emojis@1.1.0/bilibili - https://unpkg.com/@waline/emojis@1.1.0/qq - https://fastly.jsdelivr.net/gh/norevi/waline-blobcatemojis@1.0/blobs imageUploader: # 适配了兰空图床V1、V2版本 # 以兰空图床V1为例,下列填写内容为: fileName: file tokenName: Authorization api: https://你的兰空地址/api/v1/upload token: Bearer 1|xxxxxxx你的token resp: data.links.url Stellar主题用户配置完要是可以上传文件就不用往下看了. 自定义js路径: themes/stellar/layout/_partial/plugins/comments/waline/script.ejs我的这个文件肯定是加载了, 但到上传图片时会限制128KB, 按理用了imageUploader就不应该还是走的数据库存储base64策略, 唉自己动手吧.以下代码参考waline官网和xaoxuu, 所以不同主题也适用(不同的是看你代码放哪里, 要是主题不是js模板引擎即ejs结尾,就改成js代码) 路径在上面<script type="module">import { init } from '/custom/package/waline/dist/waline.mjs'; //md 我这里用CDN还是会提示大于128KB, 所以直接引入了const el = document.getElementById("waline_container"); //这里是你的评论div#idvar idPath = el.getAttribute('comment_id');if (!idPath) { idPath = decodeURI(window.location.pathname); // 给评论div加上唯一标识, 不如评论乱串文章.}const waline = init({ el: '#waline_container', //这里是你的评论div#id search: false, //关闭表情查找 不大好用 // 设置 emoji 为微博与哔哩哔哩 emoji: [ 'https://unpkg.com/@waline/emojis@1.1.0/bilibili', 'https://unpkg.com/@waline/emojis@1.1.0/qq', 'https://fastly.jsdelivr.net/gh/norevi/waline-blobcatemojis@1.0/blobs' ], reaction: true, // 开启反应 comment: true, // 评论数统计 // pageview: true, // 浏览量统计 serverURL: 'https://你的waline地址', // 记得改自己的waline地址 path: idPath, imageUploader: (file) => { let formData = new FormData(); let headers = new Headers(); formData.append('file', file); headers.append("Access-Control-Allow-Headers", "*"); headers.append("Access-Control-Allow-Origin", "*"); headers.set('Authorization', 'Bearer 1|xxxxxxx你的兰空tokens'); // 记得改自己的token headers.set('Accept', 'application/json'); // headers.set("Content-Type","multipart/form-data"); return fetch('https://你的兰空地址/api/v1/upload', { // 记得改自己的兰空 method: 'POST', headers: headers, body: formData, mode: 'cors', }) .then((resp) => resp.json()) .then((resp) => resp.data.links.url); },});</script> 结语至此结束了, 要是也碰到奇葩的fetch跨域问题, 不妨试试下面的文章.","tags":["Stellar"],"categories":["堆栈"]},{"title":"Lsky兰空图床搭建","path":"//Lsky兰空图床搭建/","content":"前言 感谢兰空图床开源作者 Wisp X及其它贡献者 环境需求 一台服务器 PHP 8.0.2+ 及系列拓展(万恶的PHP!) Mysql 5.7+ 最新版下载 建站环节新建站点有了Vercel好久没自己建站了 ,久违的感觉.直接新建一个PHP8的站点即可, 域名记得解析. 站点设置 把下载好的压缩包解压到站点根目录. 站点目录所有者改为 www,权限改为 0755 网站运行目录选择为 public, 宝塔用户如下图 宝塔用户不知道哪里修改看这里 伪静态策略我给的伪静态代码和官网不太一样, 我用官网的不能跨域, 响应会出现两个Access-Control-Allow-Origin, 所以只能自己改写一个伪静态规则.如果你的跨域有问题就试试我的.官网钟意伪静态location / { try_files $uri $uri/ /index.php?$query_string;}伪静态location /{ try_files $uri $uri/ /index.php?s=$1; add_header Access-Control-Allow-Origin "*";} 建数据库新建一个数据库叫什么名字都行, 其实配置默认即可. 安装Lsky浏览你的网站会自动进入域名/install.一路检查下来应该问题出在PHP拓展和禁用函数, 根据提示去安装拓展和开放函数. 宝塔用户开放函数软件商店->已安装->php8->设置 安装完成后根据自己需求配置用户组存储策略等. 结语储存策略记得加一个服务并设置为默认, 然后删除本地储存(服务器哪扛得住)","tags":["图床"],"categories":["堆栈"]},{"title":"Stellar代码块个人向美化","path":"//Stellar代码块个人向美化/","content":"前言增加主题控制后代码块样式有些唐突, 遂改之. 思路不改变主题代码情况下思路的主旋律按以下走: 代码块随主题颜色变更 增加复制代码功能 (来源whbbit) 增加代码过长折叠 代码下面直接成品, 有需求自定义修改 代码块样式ZYCode.css:root{ --code-autor: '© 钟意博客🌙'; --code-tip: "优雅借鉴";} /*语法高亮*/ .hljs { position: relative; display: block; overflow-x: hidden; /*背景跟随Stellar*/ background: var(--block); color: #9c67a1; padding: 30px 5px 2px 5px; box-shadow: 0 10px 30px 0px rgb(0 0 0 / 40%) } .hljs::before { content: var(--code-tip); position: absolute; left: 15px; top: 10px; overflow: visible; width: 12px; height: 12px; border-radius: 16px; box-shadow: 20px 0 #a9a6a1, 40px 0 #999; -webkit-box-shadow: 20px 0 #999, 40px 0 #999; background-color: #999; white-space: nowrap; text-indent: 75px; font-size: 16px; line-height: 12px; font-weight: 700; color: #999 } .highlight:hover .hljs::before { color: #35cd4b; box-shadow: 20px 0 #fdbc40, 40px 0 #35cd4b; -webkit-box-shadow: 20px 0 #fdbc40, 40px 0 #35cd4b; background-color: #fc625d; } .hljs-ln { display: inline-block; overflow-x: auto; padding-bottom: 5px } .hljs-ln td { padding: 0; background-color: var(--block) } .hljs-ln::-webkit-scrollbar { height: 10px; border-radius: 5px; background: #333; } .hljs-ln::-webkit-scrollbar-thumb { background-color: #bbb; border-radius: 5px; } .hljs-ln::-webkit-scrollbar-thumb:hover { background: #ddd; } .hljs table tbody tr { border: none } .hljs .hljs-ln-line { padding: 1px 10px; border: none } td.hljs-ln-line.hljs-ln-numbers { border-right: 1px solid #666; } .hljs-keyword, .hljs-literal, .hljs-symbol, .hljs-name { color: #c78300 } .hljs-link { color: #569cd6; text-decoration: underline } .hljs-built_in, .hljs-type { color: #4ec9b0 } .hljs-number, .hljs-class { color: #2094f3 } .hljs-string, .hljs-meta-string { color: #4caf50 } .hljs-regexp, .hljs-template-tag { color: #9a5334 } .hljs-subst, .hljs-function, .hljs-title, .hljs-params, .hljs-formula { color: #c78300 } .hljs-property { color: #9c67a1; } .hljs-comment, .hljs-quote { color: #57a64a; font-style: italic } .hljs-doctag { color: #608b4e } .hljs-meta, .hljs-meta-keyword, .hljs-tag { color: #9b9b9b } .hljs-variable, .hljs-template-variable { color: #bd63c5 } .hljs-attr, .hljs-attribute, .hljs-builtin-name { color: #d34141 } .hljs-section { color: gold } .hljs-emphasis { font-style: italic } .hljs-strong { font-weight: bold } .hljs-bullet, .hljs-selector-tag, .hljs-selector-id, .hljs-selector-class, .hljs-selector-attr, .hljs-selector-pseudo { color: #c78300 } .hljs-addition { background-color: #144212; display: inline-block; width: 100% } .hljs-deletion { background-color: #600; display: inline-block; width: 100% } .hljs.language-html::before, .hljs.language-xml::before { content: "HTML/XML" } .hljs.language-javascript::before { content: "JavaScript" } .hljs.language-c::before { content: "C" } .hljs.language-cpp::before { content: "C++" } .hljs.language-java::before { content: "Java" } .hljs.language-asp::before { content: "ASP" } .hljs.language-actionscript::before { content: "ActionScript/Flash/Flex" } .hljs.language-bash::before { content: "Bash" } .hljs.language-css::before { content: "CSS" } .hljs.language-asp::before { content: "ASP" } .hljs.language-cs::before, .hljs.language-csharp::before { content: "C#" } .hljs.language-d::before { content: "D" } .hljs.language-golang::before, .hljs.language-go::before { content: "Go" } .hljs.language-json::before { content: "JSON" } .hljs.language-lua::before { content: "Lua" } .hljs.language-less::before { content: "LESS" } .hljs.language-md::before, .hljs.language-markdown::before, .hljs.language-mkdown::before, .hljs.language-mkd::before { content: "Markdown" } .hljs.language-mm::before, .hljs.language-objc::before, .hljs.language-obj-c::before, .hljs.language-objective-c::before { content: "Objective-C" } .hljs.language-php::before { content: "PHP" } .hljs.language-perl::before, .hljs.language-pl::before, .hljs.language-pm::before { content: "Perl" } .hljs.language-python::before, .hljs.language-py::before, .hljs.language-gyp::before, .hljs.language-ipython::before { content: "Python" } .hljs.language-r::before { content: "R" } .hljs.language-ruby::before, .hljs.language-rb::before, .hljs.language-gemspec::before, .hljs.language-podspec::before, .hljs.language-thor::before, .hljs.language-irb::before { content: "Ruby" } .hljs.language-sql::before { content: "SQL" } .hljs.language-sh::before, .hljs.language-shell::before, .hljs.language-Session::before, .hljs.language-shellsession::before, .hljs.language-console::before { content: "Shell" } .hljs.language-swift::before { content: "Swift" } .hljs.language-vb::before { content: "VB/VBScript" } .hljs.language-yaml::before { content: "YAML" } /*stellar主题补偿*/ .md-text pre>.hljs { padding-top: 2rem !important; } .md-text pre { padding: 0 !important; } code { background-image: linear-gradient(90deg, rgba(60, 10, 30, .04) 3%, transparent 0), linear-gradient(1turn, rgba(60, 10, 30, .04) 3%, transparent 0) !important; background-size: 20px 20px !important; background-position: 50% !important; } figure::after { content: var(--code-autor); text-align: right; font-size: 10px; float: right; margin-top: 3px; padding-right: 15px; padding-bottom: 8px; color: #999 } figcaption span { border-radius: 0px 0px 12px 12px !important; } /* 复制代码按钮 */ .highlight { position: relative; } .highlight .code .copy-btn { position: absolute; top: 0; right: 0; padding: 4px 0.5rem; opacity: 0.25; font-weight: 700; color: var(--theme); cursor: pointer; transination: opacity 0.3s; } .highlight .code .copy-btn:hover { color: var(--text-code); opacity: 0.75; } .highlight .code .copy-btn.success { color: var(--swiper-theme-color); opacity: 0.75; } /* 描述 */ .md-text .highlight figcaption span { font-size: small; } /* 折叠 */ code.hljs { display: -webkit-box; overflow: hidden; text-overflow: ellipsis; -webkit-box-orient: vertical; /*-webkit-line-clamp: 6;*/ padding: 1rem 1rem 0 1rem; /* chino建议 */ } .hljsOpen { -webkit-line-clamp: 99999 !important; } .CodeCloseDiv { color: #999; background: var(--block); display: flex; justify-content: center; margin-top: inherit; margin-bottom: -18px; } .CodeClose { color: #999; margin-top: 3px; background: var(--block); } .highlight button:hover, .highlight table:hover+button { color: var(--swiper-theme-color); opacity: 0.75; } 执行函数 原作者复制代码会因为tabs这种标签的display:none而与代码语言重合, 已修复(也不算修复, 我把它写死了) ZYCode.js// 这四个常量是复制,复制成功,展开,收缩// 我使用的是 https://fontawesome.com/ 图标, 不用可以改为文字.const copyText = '<i class="fa-regular fa-copy" style="color: #aa69ec;"></i>';const copySuccess = '<i class="fa-regular fa-circle-check" style="color: limegreen;"></i>';const openText = '<i class="fa-solid fa-angles-down fa-beat-fade"></i>';const closeText = '<i class="fa-solid fa-angles-up fa-beat-fade"></i>';const codeElements = document.querySelectorAll('td.code');codeElements.forEach((code, index) => { const preCode = code.querySelector('pre'); // 设置id和样式 preCode.id = `ZYCode${index+1}`; preCode.style.webkitLineClamp = '6'; // 添加展开/收起按钮 if (preCode.innerHTML.split('<br>').length > 6) { const codeCopyDiv = document.createElement('div'); codeCopyDiv.classList.add('CodeCloseDiv'); code.parentNode.parentNode.parentNode.parentNode.appendChild(codeCopyDiv); const codeCopyOver = document.createElement('button'); codeCopyOver.classList.add('CodeClose'); codeCopyOver.innerHTML = openText; const parent = code.parentNode.parentNode.parentNode.parentNode; const description = parent.childNodes.length === 3 ? parent.children[2] : parent.children[1]; description.appendChild(codeCopyOver); codeCopyOver.addEventListener('click', () => { if (codeCopyOver.innerHTML === openText) { const scrollTop = document.documentElement.scrollTop; const codeHeight = code.clientHeight; if (scrollTop < codeHeight) { document.documentElement.scrollTop += codeHeight - scrollTop; } preCode.style.webkitLineClamp = '99999'; codeCopyOver.innerHTML = closeText; } else { preCode.style.webkitLineClamp = '6'; codeCopyOver.innerHTML = openText; } }); } // 添加复制按钮 const codeCopyBtn = document.createElement('div'); codeCopyBtn.classList.add('copy-btn'); codeCopyBtn.innerHTML = copyText; code.appendChild(codeCopyBtn); // 添加复制功能 codeCopyBtn.addEventListener('click', async () => { const currentCodeElement = code.querySelector('pre')?.innerText; await copyCode(currentCodeElement); codeCopyBtn.innerHTML = copySuccess; codeCopyBtn.classList.add('success'); setTimeout(() => { codeCopyBtn.innerHTML = copyText; codeCopyBtn.classList.remove('success'); }, 3000); });});async function copyCode(currentCode) { if (navigator.clipboard) { try { await navigator.clipboard.writeText(currentCode); } catch (error) { console.error(error); } } else { console.error('当前浏览器不支持此API'); }} 引入函数根目录/_config.yml# 自定义引入css,jsinject: script: - <script type="text/javascript" src="/custom/js/ZYCode.js"></script> 引入样式根目录/_config.stellar.ymlstyle: codeblock: highlightjs_theme: /custom/css/ZYCode.css 结语你备份了吗?","tags":["Stellar"],"categories":["堆栈"]},{"title":"Stellar文章目录个人向美化","path":"//Stellar文章目录个人向美化/","content":"前言用习惯之前的无银百两网站目录, 很想念.那就改成熟悉的样子! 思路好像没什么好说的, 关于目录一共就两个文件, 纯Stylus硬改了.就是不能变成css引入式覆盖有点可惜, 还是得改主题文件(有什么能覆盖的引入方法请务必告诉我).需要的看着修改即可. 代码 替换位置1 stellar/source/css/_layout/widgets/toc_common.styl.widget-wrapper.toc .widget-header margin-top: 1rem.widget-wrapper.toc .widget-header font-weight: 500 font-size: $fs-12 >span margin: 0.5rem 0.widget-wrapper.toc.single .widget-body margin-top: 0 border-left: 2.5px dashed var(--block-hover) ul ul, ul ol padding-left: 0 ol ul, ol ol padding-left: 0 .doc-tree margin: 4px 0 margin-left: 10.5px .toc padding: 0 margin: 0 //padding-left: 0.25rem .toc-item .toc-link //padding: 0.5rem font-weight: 500 font-size: $fs-13 color: var(--text-p2) .toc-child .toc-item .toc-link padding: 0.25rem 0.5rem 0.25rem 1.3rem font-weight: 400 color: var(--text-p2) .toc-child .toc-child .toc-item .toc-link padding-left: 2.1rem font-size: $fs-12 color: var(--text-p3) .toc-child .toc-child .toc-child .toc-item .toc-link padding-left: 2.9rem.widget-wrapper.toc.single .toc-item span display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;.widget-wrapper.toc .toc-item color: var(--text-p2) font-size: $fs-12 padding: 0 list-style: '' //ul样式 &:has(> a.toc-link) &::marker content: '🌸' color: #E9979C17 &:has(> a.toc-link:hover) &::marker content: '🌸' color: #E9979C5C &:has(> a.toc-link.active) &::marker content: '🌸' !important color: #f1404b !important .widget-wrapper.toc.single .toc-item &.active color: #fff; background: #f1404b; margin-top: 2px; margin-bottom: 2px; -webkit-box-shadow: 0 8px 15px rgb(240 65 76 / 30%); box-shadow: 0 8px 15px rgb(240 65 76 / 30%); .toc-child .toc-item padding: 0 &:after content: none// 二级目录颜色加深.toc-level-2 &::marker content: '🌸' color: #E9979C5C !important.widget-wrapper.toc.single a.toc-link position:relative; color:#738192; background:transparent; line-height:20px; border-radius:10px; display:inline-grid; padding:4px 20px 4px 10px; margin:-2px 0 -2px 12px; text-decoration:none; transition:.3s; margin-left :0 left: 10px; &:before content:""; position:absolute; transition:.3s; border-right:0px solid transparent; border-top:6px solid transparent; border-bottom:6px solid transparent; top: 8px; left:0px; &:hover color:#fff !important; background:#f1404bBF; margin-top:2px; margin-bottom:2px; -webkit-box-shadow:0 8px 15px rgba(240,65,76,0.3) !important; box-shadow:0 8px 15px rgba(240,65,76,0.3) !important; &::before border-right:6px solid #f1404bBF !important;left:-6px; &.active color:#fff !important; background:#f1404b !important; margin-top:2px; margin-bottom:2px; -webkit-box-shadow:0 8px 15px rgba(240,65,76,0.3) !important; box-shadow:0 8px 15px rgba(240,65,76,0.3) !important; &:before border-right:6px solid #f1404b !important;left:-6px;//激活上级目录时显示子目录.toc-item a.toc-link+ol display: none.toc a.toc-link.active+ol display: blockol:has(> .toc-item a.active) display: block.doc-tree:hover a.toc-link+ol display: block// wiki样式保持.widget-wrapper.toc.multi .widget-body margin-top: 0 ul ul, ul ol padding-left: 0 ol ul, ol ol padding-left: 0 .doc-tree margin: 4px 0 .toc padding: 0 margin: 0 padding-left: 0.25rem .toc-item .toc-link padding: 0.5rem font-weight: 500 font-size: $fs-13 color: var(--text-p2) .toc-child .toc-item .toc-link padding: 0.25rem 0.5rem 0.25rem 1.3rem font-weight: 400 color: var(--text-p2) .toc-child .toc-child .toc-item .toc-link padding-left: 2.1rem font-size: $fs-12 color: var(--text-p3) .toc-child .toc-child .toc-child .toc-item .toc-link padding-left: 2.9rem.widget-wrapper.toc.multi .toc-item color: var(--text-p2) font-size: $fs-12 padding: 0 list-style: none &.active color: $color-theme border-left-color: @color .toc-child .toc-item padding: 0.widget-wrapper.toc.multi a.toc-link color: inherit display: block line-height: 1.2 border-radius: 4px position: relative &:before content: '' position: absolute left: -6px top: 'calc(50% - %s)' % 6px bottom: 'calc(50% - %s)' % 6px width: 2px border-radius: 2px background: $color-theme visibility: hidden &:hover background: var(--block-hover) &.active color: $color-theme !important &:before visibility: visible 替换位置2 stellar/source/css/_layout/widgets/toc_blog.styltoc_blog.styl里面注释掉就行, 就两三行. 结语你备份了吗?","tags":["Stellar"],"categories":["堆栈"]},{"title":"Stellar可控夜间模式","path":"//Stellar可控夜间模式/","content":"前言可能习惯了主题能自己改变黑夜白昼, 所以打算做访客控制的配置.吃怕了换主题和更新主题的苦, 所以尽量抽离出来, 尽量不修改主题文件.这篇文章也是记录本次修改, 怕下次忘记改, 修改遵循原则: 尽量不修改主题文件 尽量与主题样式一致 尽量做到便携可移植 思路 抽离夜间样式 增加我们CSS文件优先级 网页添加主题按钮 评论主题跟随 后续优化 抽离夜间样式查阅主题配置文件可以看到博主控制昼夜是通过style.darkmode: false # auto / always / false来控制stylus生成整个网站main.css再查阅主题样式代码可以看到if hexo-config('style.darkmode') == 'always'包裹的就是夜间主题代码我们把它抽离出一个单独的ZYDark.css文件 增加我们CSS文件优先级我的想法是通过给html标签一个ID来取得优先级, 抽离的ZYDark.css都赋予这个ID.比如:root{--site-bg: #1c1e21;}变成#ZYDark:root{--site-bg: #1c1e21;} 网页添加主题按钮想了很多种方案都达不到主题样式一致原则.最后发现这里有7个位置!就拿他来当切换按钮吧! 储存与功能实现用户变量就扔到localStorage储存,反正不清空浏览器缓存就是永久储存.功能实现函数操作全都是一个JS执行, 包括给html标签一个ID. 黑夜闪白优化因为一些渲染顺序原因这个js只能放到网页靠末尾地方, 可能不是控制主题功能我还有其它功能方法, 所以结果是黑暗模式下刷新有点闪白色.解决办法是在head引入一个提前js,即判断localStorage是黑暗就马上给html加黑色ID, 后续渲染就没问题了!!! 评论主题跟随评论按这个思路去改吧, 加几句css的事情, 不会可以问博主.但我用的giscus就有点麻烦, 主题没有给giscus样式是引入的, 所以我的js里面有关于giscus的方法, 不用可以删除. 2023.2.4: 很棒, 我受够了引入式的giscus不太跟随主题变化(虽然它真的很棒). 投靠waline(香). 代码样式提取的stellar黑夜样式,一般无需修改, 你也可以自定义 ZYDark.css#ZYDark:root { --site-bg: #1c1e21; --card: #373d43; --block: #26292c; --block-border: #383d42; --block-hover: #2f3337; --text-p0: #fff; --text-p1: #ccc; --text-p2: #b3b3b3; --text-p3: #858585; --text-p4: #707070; --text-meta: #4d4d4d; --text-code: #ff6333;}@media screen and (max-width: 667px) { #ZYDark:root { --site-bg: #000; }}#ZYDark:root { --blur-bg: rgba(0,0,0,0.5);}#ZYDark .float-panel { --blur-bg: rgba(0,0,0,0.4);}#ZYDark .tag-plugin.tag { --theme: #ff6333; --theme-bg1: #3d1e14; --theme-bg2: #2f2522; --theme-border: #5c2d1f; --text-p0: #ffc4b3; --text-p1: #dfae9f; --text-p2: #f1997e;}#ZYDark .tag-plugin[color='red'] { --theme: #f44336; --theme-bg1: #3d1714; --theme-bg2: #2f2322; --theme-border: #5c231f; --text-p0: #ffb8b3; --text-p1: #dfa49f; --text-p2: #f1867e;}#ZYDark .tag-plugin[color='orange'] { --theme: #fa6400; --theme-bg1: #3d2514; --theme-bg2: #2f2722; --theme-border: #5c371f; --text-p0: #ffd1b3; --text-p1: #dfb99f; --text-p2: #f1ac7e;}#ZYDark .tag-plugin[color='yellow'] { --theme: #ffbd2b; --theme-bg1: #3d3014; --theme-bg2: #2f2b22; --theme-border: #5c491f; --text-p0: #ffe7b3; --text-p1: #dfcb9f; --text-p2: #f1cd7e;}#ZYDark .tag-plugin[color='green'] { --theme: #3dc550; --theme-bg1: #143d1a; --theme-bg2: #222f24; --theme-border: #1f5c27; --text-p0: #b3ffbd; --text-p1: #9fdfa8; --text-p2: #7ef18e;}#ZYDark .tag-plugin[color='cyan'] { --theme: #1bcdfc; --theme-bg1: #14353d; --theme-bg2: #222d2f; --theme-border: #1f4f5c; --text-p0: #b3efff; --text-p1: #9fd2df; --text-p2: #7ed9f1;}#ZYDark .tag-plugin[color='blue'] { --theme: #2196f3; --theme-bg1: #142b3d; --theme-bg2: #222a2f; --theme-border: #1f415c; --text-p0: #b3ddff; --text-p1: #9fc3df; --text-p2: #7ebef1;}#ZYDark .tag-plugin[color='purple'] { --theme: #9c27b0; --theme-bg1: #37143d; --theme-bg2: #2d222f; --theme-border: #531f5c; --text-p0: #f4b3ff; --text-p1: #d69fdf; --text-p2: #e07ef1;}#ZYDark .tag-plugin[color='light'] { --theme-border: #fff; --theme-bg1: #e0e0e0; --theme-bg2: #fff; --text-p0: #000; --text-p1: #111; --text-p2: #1f1f1f; --text-p3: #555; --text-code: #fff;}#ZYDark .tag-plugin[color='dark'] { --theme-border: #000; --theme-bg1: #1f1f1f; --theme-bg2: #111; --text-p0: #fff; --text-p1: #fff; --text-p2: #e0e0e0; --text-p3: #ddd; --text-code: #fff;}#ZYDark .tag-plugin[color='warning'],#ZYDark .tag-plugin[color='light'] { --text-p0: #000; --text-p1: #111; --text-p2: #1f1f1f; --text-p3: #555; --text-code: #fff;}#ZYDark .social-wrap a.social:hover { box-shadow: none;}/* waline评论样式 */#ZYDark .wl-count{ padding: .375em; font-weight: bold; font-size: 1.25em; color: #fff;}#ZYDark .cmt-body.waline{ --waline-white: #000; --waline-light-grey: #666; --waline-dark-grey: #999; /* 布局颜色 */ --waline-color: #fff; --waline-bgcolor: var(--block); --waline-bgcolor-light: #272727; --waline-border-color: #333; --waline-disable-bgcolor: #444; --waline-disable-color: #272727; /* 特殊颜色 */ --waline-bq-color: #272727; /* 其他颜色 */ --waline-info-bgcolor: #272727; --waline-info-color: #666;} 函数比如我的按钮在网页左下角第5开始是: dark, light, Moss(流浪地球AI的意思), 对应是下面这个代码的567.如果按钮按我的顺序而且是giscus评论模块则无需修改代码. 但giscus默认评论样式改成light. 2023.2.4: 离开giscus的我每晚睡的很好。如果你是giscus请使用这里面的代码。 Giscus版JShttps://cloud.thatcoder.cn/%E5%B7%B2%E5%85%AC%E5%BC%80%E6%96%87%E4%BB%B6/CoderSpace/Stellar/ZYDark.js ZYDark.js/** * 监听系统主题 * @type {MediaQueryList} */var OSTheme = window.matchMedia('(prefers-color-scheme: dark)');OSTheme.addListener(e => { if (window.localStorage.getItem('ZYI_Theme_Mode') === 'Moss') { ThemeChange('Moss'); }})/** * 修改博客主题 * @param theme 亮为light,暗为dark,自动为auto * @constructor */const ThemeChange = (theme) => { if (theme === 'light' || (theme === 'Moss' && !OSTheme.matches)) { document.querySelector("html").id = "ZYLight"; document.querySelector("#start > aside > footer > div > a:nth-child(6)").style.filter= 'grayscale(0%)'; document.querySelector("#start > aside > footer > div > a:nth-child(5)").style.filter= 'grayscale(100%)'; } else { document.querySelector("html").id = "ZYDark"; document.querySelector("#start > aside > footer > div > a:nth-child(5)").style.filter= 'grayscale(0%)'; document.querySelector("#start > aside > footer > div > a:nth-child(6)").style.filter= 'grayscale(100%)'; } if (theme==='Moss'){document.querySelector("#start > aside > footer > div > a:nth-child(7)").style.filter= 'grayscale(0%)';} else {document.querySelector("#start > aside > footer > div > a:nth-child(7)").style.filter= 'grayscale(100%)';} window.localStorage.setItem('ZYI_Theme_Mode', theme);}/** * 初始化博客主题 */switch (window.localStorage.getItem('ZYI_Theme_Mode')) { case 'light': ThemeChange('light'); break; case 'dark': ThemeChange('dark'); break; default: ThemeChange('Moss');}/** * 切换主题模式 */document.querySelector("#start > aside > footer > div > a:nth-child(5)").onclick = () => { ThemeChange('dark');}document.querySelector("#start > aside > footer > div > a:nth-child(6)").onclick = () => { ThemeChange('light');}document.querySelector("#start > aside > footer > div > a:nth-child(7)").onclick = () => { ThemeChange('Moss');} 提前量就一句js,你也可以打包成文件. 就这样写的话别漏了那个 | 竖 根目录/_config.yml# 自定义引入css,jsinject: head: - | <script> if (window.localStorage.getItem('ZYI_Theme_Mode')==='dark' || (window.localStorage.getItem('ZYI_Theme_Mode')==='Moss' && window.matchMedia('(prefers-color-scheme: dark)').matches)){ document.querySelector("html").id = "ZYDark"; } </script> 引入样式与函数看你自定义代码文件放哪咯, 我的在根目录/source/custom/里面if you like 你也可以挂成CDN链接引入 博客目录/_config.yml# 自定义引入css,jsinject: head: - <link rel="stylesheet" href="/custom/css/ZYDark.css"> # 黑夜样式 script: - <script type="text/javascript" src="/custom/js/ZYDark.js"></script> # 黑夜控制 自定义博主配置darkmode用false意味对主题而言保持永远白昼(才有了我们的操作空间)然后footer.social这东西我对应是567, 懒得改JS的可以前面也加四个社交按钮. 博客目录/_config.stellar.ymlstyle: darkmode: false # auto / always / false# 页尾footer: social: github: icon: '<img src="https://upyun.thatcdn.cn/public/img/icon/github-logo2.png"/>' url: https://github.com/ThatCoders music: icon: '<img src="https://upyun.thatcdn.cn/public/img/icon/neteasemusic-icon.png"/>' url: https://music.163.com/#/user/home?id=134968139 bili: icon: '<img src="https://upyun.thatcdn.cn/public/img/icon/bilibili-icon.png"/>' url: https://space.bilibili.com/1664687779 card: icon: '<img src="https://upyun.thatcdn.cn/public/img/icon/weChat.png"/>' url: https://muselink.cc/naive Moon: icon: '<img id="ThemeM" src="https://upyun.thatcdn.cn/public/img/icon/Moon.png"/>' url: javaScript:void('永夜'); Sun: icon: '<img id="ThemeL" src="https://upyun.thatcdn.cn/public/img/icon/Sun.png"/>' url: javaScript:void('永昼'); AI: icon: '<img id="ThemeAI" src="https://upyun.thatcdn.cn/public/img/icon/AI.png"/>' url: javaScript:void('跟随系统'); 修改主题文件waline评论才要这步, 其它评论自己看一下comments文件夹 文件路径: 根目录/themes/stellar/source/css/_plugins/comments/waline.styl 注释掉 41-64 行 结语至此结束了, 有什么问题可以评论留言.方案我会一直优化下去. 2023.01.29 文章发行钟意发表了此篇文章.2023.02.04午 修复001修复没有giscus评论页面导致切换主题代码停止运行.2023.02.04晚 修复002不能完美解决问题, 就把问题解决!评论插件更换为waline.","tags":["Stellar"],"categories":["堆栈"]},{"title":"Stellar自用魔改存档","path":"//Stellar自用魔改记录/","content":"前言 当前主题版本: 1.18.5 钟意博客提醒您, 魔改不备份, 亲人两行泪. 文章目录样式效果代码文章目录样式https://blog.thatcoder.cn/Stellar%E6%96%87%E7%AB%A0%E7%9B%AE%E5%BD%95%E4%B8%AA%E4%BA%BA%E5%90%91%E7%BE%8E%E5%8C%96/ 昼夜模式切换用户主题切换功能https://blog.thatcoder.cn/Stellar%E5%8F%AF%E6%8E%A7%E5%A4%9C%E9%97%B4%E6%A8%A1%E5%BC%8F/ 根据分类排版 这是大致思路, 根据自己需求修改 博客目录/_config.yml>inject.script引入// 判断是否为首页类。有很多种方法, 有空我找一下最优解const isIndex=()=>{ const href = window.location.href; const protocol = window.location.protocol+"//"+window.location.host+"/"; return href === protocol || href === protocol + "categories/" || href === protocol + "tags/" || href === protocol + "archives/" || href === protocol + "rss/" || href === protocol + "wiki/" || href === protocol + "links/" || href === protocol + "about/";}if (!isIndex()){ var ThisCategory = document.querySelector("#breadcrumb > a.cap.breadcrumb-link").text; if (ThisCategory == "第九艺术") { document.querySelector("#start > div > article > h1").style.display = 'none'; document.querySelector("#start > div > article > div:nth-child(3)").style.textAlign = 'center'; }} 全局复制提示引入JS博客目录/_config.ymlinject: head: - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/izitoast@1.4.0/dist/css/iziToast.min.css"> script: - <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/izitoast@1.4.0/dist/js/iziToast.min.js"></script>博客目录/_config.yml>inject.script引入// 复制提示document.body.oncopy = function () {iziToast.info({timeout: 4000, // 关闭弹窗的时间// icon: 'Fontawesome', // 图标类别closeOnEscape: 'true', // 允许使用Esc键关闭弹窗transitionIn: 'bounceInLeft', // 弹窗打开动画transitionOut: 'fadeOutRight', // 弹窗关闭动画displayMode: 'replace', // 替换已经打开的弹窗layout: '2', // Medium模式position: 'topRight', // 弹窗位置//icon: 'fad fa-copy', // 图标类名iconUrl: 'https://upyun.thatcdn.cn/hexo/stellar/image/favicon.webp',backgroundColor: '#fff', // 弹窗背景色title: '复制成功', // 通知标题message: '请遵守 CC BY-NC-SA 4.0 协议' // 通知消息内容});} 修改网页字体引入配置博客目录/_config.ymlinject: head: - <link rel="stylesheet" href="https://upyun.thatcdn.cn/hexo/stellar/css/font.css">博客目录/_config.stellar.ymlstyle: font-size: root: 19.3px body: .9999rem # 15px code: 85% # 14px codeblock: 0.8125rem # 13px font-family: logo: 'ZFonts, system-ui, "Microsoft Yahei", "Segoe UI", -apple-system, Roboto, Ubuntu, "Helvetica Neue", Arial, "WenQuanYi Micro Hei", sans-serif' body: 'ZFonts, system-ui, "Microsoft Yahei", "Segoe UI", -apple-system, Roboto, Ubuntu, "Helvetica Neue", Arial, "WenQuanYi Micro Hei", sans-serif' code: 'Menlo, Monaco, Consolas, system-ui, "Courier New", monospace, sans-serif' codeblock: 'Menlo, Monaco, Consolas, system-ui, "Courier New", monospace, sans-serif' 侧边栏欢迎图 API采用IP签名档, 主要是代码的img标签. 效果代码其实字有点小博客目录/source/_data/widgets.ymlwelcome: layout: markdown title: '🎉欢迎, 先生亦或是姑娘: ' content: | 这是一个成分复杂的小站,建于二十一世纪初,至今练习时长两年半,将会继续长期维护和更新。<br>🙏本站评论与动态在Github托管, 显示不全是不能裸连Github。 <img id="ZYTheme" style="border-radius: 10px;" src="https://api.szfx.top/info-card/?word=感谢来访钟意博客, 懈怠轻忽."/> 自定义Fancybox范围好在主题已经给出相关配置, 我们只需要找到自己要开启的地方.我开的地方除了自带image标签还有文章里所有img, 和waline评论里的img. _config.stellar.ymlplugins: fancybox: # 可以处理评论区的图片(不支持 iframe 类评论系统)例如: # 使用twikoo评论可以写: .tk-content img:not([class*="emo"]) # 使用waline评论可以写: #waline_container .vcontent img selector: .swiper-slide img, .md-text.content p>img, .md-text.content li img , .wl-content img # 多个选择器用英文逗号隔开 代码主题样式效果代码效果body{display:none;}ZYCode.csshttps://cloud.thatcoder.cn/%E5%B7%B2%E5%85%AC%E5%BC%80%E6%96%87%E4%BB%B6/CoderSpace/Stellar/ZYCode.css 博客目录/_config.stellar.ymlstyle: codeblock: highlightjs_theme: https://upyun.thatcdn.cn/public/web/stellar-1.18.5/ZYCode.css 字体切片cn-font-split 切片方法第一步安装npm包: 第二步新建一个xxx.mjs文件, 填入以下代码.切片函数import { fontSplit } from "@konghayao/cn-font-split";fontSplit({ FontPath: "./xxx.ttf", // 把字体放到本文件同级下 destFold: "./FontOrigin", // 生成的文件夹位置 targetType: "ttf", // ttf woff woff2;注意 eot 文件在浏览器中的支持度非常低,所以不进行支持 testHTML: true, // 输出一份 html 报告文件 reporter: true, // 输出 json 报告});第三步把字体放到平级目录, 根据字体名称与类型修改函数第四步运行这个函数. 运行命令很多选择 比如: 第五步等待片刻成功的话, 会有FontOrigin目录 使用方法引入FontOrigin目录里面的css即可, 并把网站字体选成css里面的font-family 测试TimeLIne自定义模板还在测试…","tags":["Stellar"],"categories":["堆栈"]},{"title":"Stellar自用排版片断","path":"//Stellar自用排版片断/","content":"视频分集视频分集<video id="postVideo" src="https://upyun.thatcdn.cn/blog/wp-public/wp-daily/blog/2022/08/20220815152828271.webm" width="100%" controls></video><div class="tag-plugin navbar"><nav class="cap"> <a onclick="myVid.src ='https://upyun.thatcdn.cn/blog/wp-public/wp-daily/blog/2022/08/20220815152828271.webm'">预告1</a> <a onclick="myVid.src ='https://upyun.thatcdn.cn/blog/wp-public/wp-daily/blog/2022/08/20220815152919397.webm'">预告2</a> <a onclick="myVid.src ='https://upyun.thatcdn.cn/blog/wp-public/wp-daily/blog/2022/08/20220815153006675.webm'">预告3</a></nav></div><script> let myVid=document.getElementById("postVideo");</script> 效果 预告1 预告2 预告3 let myVid=document.getElementById(\"postVideo\"); 隐藏开头标题隐藏开头艺术标题, 自定义h1, 并把本文类型tag居中 {% quot el:h1 PLASTIC MEMORIES %}<div class="MyTag">{% tag 15+ color:green %}&nbsp;{% tag 恋爱 color:pick %}&nbsp;{% tag 养成 color:pick %}</div><style>.article-title{display:none;}.MyTag{text-align: center}</style> 效果","tags":["Stellar"],"categories":["堆栈"]},{"title":"Vervel反向代理功能","path":"//Vercel Proxy/","content":"前言使用Vercel反向代理有以下优点 域名不需要备案 隐藏源主机地址 可以充当缓存机 还赠送免费的SSL 需要环境 npm Vercel账号 配置模块打开命令行执行以下 安装所需模块: npm i-g vercel 登入: vercel login 选择相应的登入方式登入即可. 实现反代 假设我有一个博客和一个未备案域名(bilibili.com), 博客运行在主机 123.123.123.123 里面, 运行端口是9000 .我需要这个未备案域名指向我的博客(123.123.123.123:9000).碰巧国外主机不需要备案, 碰巧vercel是国外服务器, 还碰巧未备案解析商(腾讯)是国内, 就碰巧能解决这个需求, 步骤如下. 新建一个JSON文件, 比如这个叫 blog.json, 编辑内容如下: { "version": 2, "routes": [ {"src": "/(.*)","dest": "http://123.123.123.123:9000/$1"} ]} 打开命令行 cd 到这个json文件的目录, 执行部署: vercel -A blog.json --prod 根据提示完成部署, 你会得到一个默认域名, 域名指向http://123.123.123.123:9000,此时打开 Vercel官网 就能看到这个项目.我们接下来把未备案域名解析到这个项目 域名解析 我需要这个未备案域名指向我的博客(123.123.123.123:9000). 打开 Vercel官网 点击刚才的项目 找到 setting->domains->add 根据提示去域名商那里完成解析即可, 你就会得到一个免费的SSL. 忠告不要手贱去反向代理github,google等知名网站, 会判定你为钓鱼网站, 最后发一封邮件告诉你你在钓鱼违反规定然后封号斗罗. (不要问我怎么知道的呜呜呜)","tags":["Vercel"],"categories":["堆栈"]},{"title":"匡庐游记","path":"//daily/匡庐游记/","content":"诗词鉴赏题西林壁宋·苏轼横看成岭侧成峰,远近高低各不同。不识庐山真面目,只缘身在此山中。诗词节选望庐山瀑布唐·李白朝日照香炉生紫烟,遥看瀑布挂前川。飞流直下三千尺,疑是银河落九天。诗词节选 游记首次2019.12.31 首次是大一那年, 邓小生还在浪迹天涯, 浪到九江找我跨年. 便于老王、老煌一同前往. 在庐山找了个音乐餐厅享受跨年氛围, 为时1晚.餐厅屏幕上轮播着历史, 关于餐厅游客的深情投稿历史. 有祝愿、 有期盼、 有告白…在驻唱演奏完进行了赛马项目(摇手机), 我和老王夺冠喜提红酒. 拍照留念就让他女朋友陪她.元旦早晨便下山, 老邓继续浪迹, 我去了老煌学校布置元旦晚会(我们学校没有!!!)首次去庐山没有怎么看它, 我知道我们来日方长. 二顾2021.01.12 第二次和蕾宝, 在寒假回家前, 为时3天.这次是游玩了大部分主要景点, 去时抱着看庐山雪景, 但雪正在消融.芦林湖、 五老峰、 如琴湖、含鄱口日出很美, 但三叠泉要被冻住了(三叠泉:别说了,说多了都是泪,别人现在说我水龙头没关)下山我们在九江甘棠公园、海韵沙滩等地游玩回家. 待完成","tags":["随笔"],"categories":["生活"]},{"title":"八一广场记","path":"//八一广场记/","content":"因为学校自选的德育实践活动地点我选择了南昌八一广场, 而且不想让每个我经手的事情过于形式化, 就有了此篇记录一下本次 “最忆当年·红色绵延” 的地点.毕竟在时间洪流里 “没有记录就等于没有发生“ 历史沿革明清时期明清时期,该地域是顺化门外的护城河和沼泽地。清朝光绪年间(1875年至1908年)清政府在沼泽地开辟出训练新兵的大校场。宣统三年(1911年)革命军于大校场整集,推翻了清政府在江西的统治,建立中华民国江西政府。民国元年(1912年)10月28日孙中山在大校场检阅了李烈钧部的江西革命军;民国十七年(1928年),南昌城市改造,拆去顺化门,填塞护城河,修建绕城公路(今八一大道),使之具备了广场的雏形。1956年人民政府在城建工程中,正式命名“人民广场”,并加扩充拓展,广场作为城市中心的地位得到确定。半个世纪以来,广场经过多次改造和扩建,一直是省会城市中政治、经济、文化活动的重要场所。1968年广场西侧新建“毛泽东思想胜利万岁馆”。20世纪50年代各个地方都在兴建万岁馆,其中八一广场“万岁馆”正是现在江西省美术馆大楼的前身,随着历史时代的变迁,万岁馆的功能也随之发生改变。1969年八一广场进行第一次改造。1977年8月1日广场上开始兴建“八一南昌起义纪念塔”。同时,广场进行大整修,人民广场改名为“八一广场”。1979年1月8日八一起义纪念塔建设落成,成为南昌英雄城的标志性建筑。1983年八一广场进行第二次改造。1993年八一广场进行了第三次改造。1995年八一广场新增东西两侧二块绿色游园,扩大广场绿地面积47700平方米。2001至2004年在中共南昌市委和市政府的主持下,实施大规模的扩建改造工程,使之成为突现八一南昌起义中心主题,包括纪念性、标志性、群众性和休闲性多项功能的大型现代化城市广场。广场核心区面积扩至七万八千平方米,周边面积扩至三十多万平方米。2005年1月1日南昌市人民政府实施《南昌市八一广场管理规定》。2017年03月10日为迎接南昌起义90周年庆,八一广场实施改造扩建。八一广场提升改造工程动工,涉及广场景观改造、广场周边建筑立面美化及广告,同时还有万达广场、百货大楼、省移动大厦等建筑外立面进行改造。以展览馆为核心,保留原建筑风貌,以淡黄色为主背景的基调就这么定下了。 这些年那些事在浏览整个互联网对八一广场的记忆中, 伴随着上面的历史沿革对比, 让人唏嘘不已. 以下可能是一段历史事件, 或者是一段那个时代的评语. 1961年,从庐山开完会议的各省领导在总理的带领下,来到省政府大楼的顶楼露台。 远眺南昌八一大道以及人民广场周边的建设,听着邵式平省长的介绍,大家称赞不已,总理说:“江西老俵,气魄不小”。 那时的人民广场及其周边已经初具雏形,南昌的中心从老城东移已成定局。 十九世纪七十年代, 曾经让南昌人无比骄傲,国内仅次于北京天安门广场,属全国第二大的广场,叠加了南昌几代人乃至全省人民的深刻记忆。 随便到老南昌的家中,翻箱倒柜都有几张广场主席台、展览馆的老照片,照相地点不是【服务大楼】就是【东方红】。那时的广场就是南昌城的中心地标,全市全省人民的网红打卡点,也承载了一个城市的集体记忆。 谈起万岁馆, 南昌上了年纪的老人都记忆犹新, 他们也有很多人当年都参加了建设万岁馆的义务劳动。按那个年代流行的话语就是“献忠”,有些人还因为没有被单位安排去劳动而“眼泪汪汪”,痛恨、懊恼自己的家庭出身。 老人们在回忆修建“万岁馆”时, 人民广场人山人海的场景,唏嘘不已! 九十年代,无论从地理位置还是商业服务设施的繁华集中度。八一广场成为南昌当仁不让,舍我其谁的中心。 三十年河东,三十年河西。财富广场于是在文化宫旧址上横空出世,成为广场地标。 过去人人喊打的“财富”二字, 成为时代无可替代的香饽饽。八一广场迎来了南昌商业史上最风光的时刻。 2000年,南昌第一家肯德基店在广场开业, 当日就创造了全球单店单日营业额的新纪录; 2003年,沃尔玛超市在八一广场盛大开业; 2012年,星巴克南昌首店落户南昌百货大楼,再度引发排队热潮。 上世纪的八、九十年代,一批共和国的同龄人也开始在南昌商贸服务业崭露头角,叱咤风云。 令人称奇的是: 三个南昌商业学校66届的同班同学在广场周边开始了属于他们自己的“时区”。 “涂世明”执掌服务大楼,“周勤生”一手打造了洪城大厦, “夏泰吉”掌管华侨友谊商店。 顺带一提的是,曾经的南昌商业学校. 简直就是本土商业系统的黄埔军校。 过去南昌诸多商场、餐饮、服务行业的老总几乎都是出自这个学校。 它现在仿佛是一幅画, 只能看不能就去. 多去广场逛逛,是对老南昌最起码的尊重. 如果说八一桥一带是老南昌近代百年开枝散叶的“根”,那八一广场就是建国后南昌人心中的“魂”。但这个魂走着走着就走丢了。 一些思考这些纪念塔、纪念广场之类的建筑, 说出来大家都知道, 但并不会过多探究这个建筑有什么寓意,为什么会建立起来?是什么时候建立起来的?大多数人也许更多是一个旁观者的角色。 我认为它存在的意义与本篇一样, 真的就怕没有记录就像没有发生一样. 它就像一个历史的书签🔖, 当你不经意间看到它, 当你想了解它, 就能拨开这枚历史书签去翻阅这段快要让人遗忘的、不再被人打扰的时光. 历史的重量真实可感, 多沉淀下来去感受. 多去广场逛逛,是对老南昌最起码的尊重.","tags":["随笔"],"categories":["生活"]},{"title":"基本运行环境","path":"//PartTimeREADME/","content":"IDEA破解版资源下载 官网: JetBrains 本站: 钟意云盘 使用教程 下载 IDEA.exe IDEA.7z.001 使用 运行 IDEA.exe, 选择解压地址. 解压完成去到解压地址的IDEA文件夹里面, 点击 绿化.bat 运行文件在 bin 文件夹的 idea64.exe Mysql免安装版资源下载 官网: MySQL 本站: 钟意云盘 使用教程 解压到随意一个你不敢乱删的文件夹 在文件夹新建 my.ini my.ini[mysqld]# 设置3306端口port=3306# 设置mysql的安装目录,一定要与上面的安装路径保持一致basedir=D:\\\\devSoft\\\\mysql# 设置mysql数据库的数据的存放目录datadir=D:\\devSoft\\mysql\\\\Data# 允许最大连接数max_connections=200# 允许连接失败的次数。max_connect_errors=10# 服务端使用的字符集默认为utf8mb4character-set-server=utf8mb4# 创建新表时将使用的默认存储引擎default-storage-engine=INNODB# 默认使用“mysql_native_password”插件认证#mysql_native_passworddefault_authentication_plugin=mysql_native_password[mysql]# 设置mysql客户端默认字符集default-character-set=utf8mb4[client]# 设置mysql客户端连接服务端时默认使用的端口 可以根据实际情况进行修改port=3306default-character-set=utf8mb4 在数据库文件夹bin目录下打开管理员命令符执行初始化, 获取密码 管理员窗口mysqld --initialize --console 开启mysql net start mysql # 启动mysql的服务net stop mysql # 关闭mysql服务 登入并修改密码 mysql -u root -p 初始化的密码 #登录mysqlALTER USER 'root'@'localhost' IDENTIFIED BY '新密码'; # 修改密码exit; # 退出当前命令行 添加环境变量(添加bin目录的绝对路径到环境变量的path即可) Android Studio 官网: 官方下载 PyCharm相关资源下载 官网: MySQL 本站: 钟意云盘 pip换源升级pippython -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple --upgrade pip # 临时使用清华源升级pip 修改默认源pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple # 修改默认源为清华源 下面一般用不上 配置多个镜像源平衡负载,可在已经替换 index-url 的情况下通过以下方式继续增加源站: 多源平衡负载pip config set global.extra-index-url "源1 源2..." # 请自行替换引号内的内容,源地址之间需要有空格","categories":["堆栈"]},{"title":"小小梦魇","path":"//小小梦魇/","content":"Little Nightmares 16+ 恐怖 解密 IP发展史 2015年04月27日 一作PC发行一作PC发行小小梦魇1本作的故事背景发生在一艘神秘的、名为“胃”邮轮上,玩家扮演身着黄色小雨衣,从睡梦中醒来的小女孩——小六。玩家将在巨大的邮轮中进行探索,逃脱恐怖怪物的追捕,寻找一线生机。可是小女孩在逃亡的过程中却陷入了迷茫。2015年5月18日 一作NS发行一作NS发行《小小梦魇 完全版》移植至NS平台,于5月18日发售海外版。另外,NS版本收录迄今为止所有DLC。啊, 和我没关系。2021年02月11日 二作PC发行二作PC发行小小梦魇2呜呜呜呜呜, 小六放手了, 成为了老六...2022年09月11日 手游安卓IOS发行手游安卓IOS发行我是没看到。 游戏简介 一作:让自己沉浸在噩梦,一个黑暗的异想天开的故事,面对你和你的童年的恐惧! 帮助六个逃跑的——一个巨大的,神秘的船居住着的灵魂在寻找他们的下一顿饭。你进展你的旅程,探索最令人不安的玩偶之家提供一间监狱,逃离和一个操场充满秘密的发现。伴着你的内在小孩释放你的想象力并寻找出路! 二作:重回那令人着迷的恐怖世界── 在《小小梦魇2》的悬疑冒险旅途中,您将化身为小男孩摩诺,身处在电波笼罩之下,因此变得扭曲的世界里。 摩诺将与穿着黄色雨衣的女孩──小六携手合作,揭发讯号塔暗藏的秘密。 然而这绝对不是一趟轻松的旅程:潜藏在这个世界中的未知威胁,正等着他们。 准备好面对另一场儿时梦魇了吗? 不错的小小梦魇解说 游戏画面 下载地址 密码栏下载栏云盘密码解压密码小小梦魇12https://cloud.thatcoder.cn/%E5%B7%B2%E5%85%AC%E5%BC%80%E6%96%87%E4%BB%B6/%E7%AC%AC%E4%B9%9D%E8%89%BA%E6%9C%AF/%E5%B0%8F%E4%BC%97/%E5%B0%8F%E5%B0%8F%E6%A2%A6%E9%AD%871-2/ 有能力一定入正喔! 小小梦魇1-steam正版https://store.steampowered.com/app/424840/Little_Nightmares/?curator_clanid=42309150/ 小小梦魇2-steam正版https://store.steampowered.com/app/860510/2/","tags":["Game"],"categories":["第九艺术"]},{"title":"切尔诺贝利特","path":"//切尔诺贝利/","content":"Chernobylite 18+ 恐怖 生化 .article-title{display:none;}.MyTag{text-align: center} IP发展史 2021年07月28日 一作PC发行一作PC发行《Chernobylite》是 The Farm 51 开发的科幻生存恐怖角色扮演游戏。故事设定在超现实的切尔诺贝利隔离区,在这片基于 3D扫描的荒弃土地上,探索非线性的故事,揭露你饱受煎熬的过去中隐藏的真相。 游戏介绍 食之无味,弃之可惜  《切尔诺贝利人》作为一款科幻生存恐怖角色扮演游戏,除了游戏名和切尔诺贝利有关系,其内核则是以切尔诺贝利核电站作为一个切入点,讲述了核灾害发生后禁区内一种名为“切尔诺贝利特”的物质所引发的种种超自然现象的故事。在禁区内生存、探索、收集物资,招募队友并建立营地,以及解开这一切谜团背后的真相。   作为一款实景扫描当做卖点的游戏,该作展现了切尔诺贝利浓郁的东欧风格和苏联美学的构造,艺术风格也是令人眼前一亮。但是本作的剧情虽是无伤大雅,却也缺乏亮点。开头和结尾处场景的首尾呼应和结尾处的反转都算是不错的亮点,但是并不能掩盖该作剧情疲软的缺点,以及玩家能猜测到后续剧情的发展。剧情的推进主要靠任务的发展和主人公的“回忆演练”,但是单调的任务甚至到了后期很多跑腿任务都为本作的剧情大打折扣。相对于《地铁》系列中的塑造非常成功的安娜,该作中的未婚妻塔蒂阿娜则更像是一个冰冷的任务引导器,互动感基本为零。虽说是剧情需要,但是可以在回忆部分添加一些和主人公的互动,很可惜这部分并没有。 建造系统也是该作的卖点之一。令人遗憾的是,除了在营地内布置一些可以提升队友“心情”的摆件,大部分时间还是更专注于对战斗力提升的设备建造。并不能像《辐射》等其他开放类型游戏一样建造房屋等建筑,只是在一个较为狭小的空间内放置各种工具台或者是家具,并且被完全限制在营地内,开放世界里无法建造任何东西。    本作的队友也都是非黑即白的二元思维,大多数时候都是“做”与“不做”的选项。当你面临选择时,听从某位队友的建议并选择提升其好感度的选项时,其它建议选项的队友对你的好感度必定会下降,如果好感度到达“糟糕”的话,这名队友就会离开你的队伍。这时你就需要重置时间线,改变剧情的关键选择点。包括营地内的居住环境也会影响队友的战斗力和心情,不达标的环境很可能会造成队友的不满意,降低他们委托任务的成功率,他们还会因任务失败而死在外面。    恐怖的气氛全靠低级的scare jump和怪物的传送机制,明明清理完了一片区域,当你再次返回来的时候,总有几个怪物在你背后准备给你一次转角遇到爱。多次的使用scare jump使得整个游戏的恐怖感很廉价,只是单纯的吓人而并没有一种恐怖的氛围。   《切尔诺贝利人》并没有达到一线游戏的水准,看的出制作组什么都想要的心情,与其“我全都要”,不如老老实实的做好其中的某一项。游戏的体量保证了它有足够的内容,但是其质量却不敢令人恭维。    最后说一句,这个游戏真是成就党的福音。 游戏画面 游戏预告 预告1 预告2 预告3 let myVid=document.getElementById(\"postVideo\"); 系统配置 系统配置 下载地址 密码栏下载栏云盘密码解压密码切尔诺贝利https://cloud.thatcoder.cn/%E5%B7%B2%E5%85%AC%E5%BC%80%E6%96%87%E4%BB%B6/%E7%AC%AC%E4%B9%9D%E8%89%BA%E6%9C%AF/3A/%E5%88%87%E5%B0%94%E8%AF%BA%E8%B4%9D%E5%88%A9%E4%BA%BA 有能力记得入正喔! 切尔诺贝利https://store.steampowered.com/app/1016800/Chernobylite_Enhanced_Edition/","tags":["Game"],"categories":["第九艺术"]},{"title":"可塑性记忆","path":"//可塑性记忆/","content":"PLASTIC MEMORIES 15+ 恋爱 养成 IP发展史 2014年8月 Twitter入住Twitter入住Twitter2015年3月27日 网络广播网络广播B站UP有留存《满和扎克的PLAMEMO广播》, 每周五在HiBiKi Radio Station播出. 听得懂你就来, 给你链接可塑性记忆广播https://space.bilibili.com/275246/search/video?keyword=%E5%8F%AF%E5%A1%91%E6%80%A72015年4月4日 电视动画电视动画《可塑性记忆》原创电视动画由ANIPLEX公司企划,由负责过5pb.公司开发的《命运石之门》等“科学ADV系列”游戏剧本的林直孝担当编剧。2015年4月24日 外传漫画外传漫画这部前日谈性质的外传漫画由祐佑作画,以绢岛满为主人公,主要讲述发生在动画剧情前的故事。与动画同步进行描写不同视角故事的本传漫画则于《电击G's Comic》2015年6月号开始连载。2016年9月10日 外传小说ISBN: 4048654098《プラスティック・メモリーズ -Heartfelt Thanks》由原作者林直孝亲自执笔的外传小说于2016年9月10日发售。电击文库欸嘿2016年10月13日 平台游戏PS Vita「プラスティック・メモリーズ」公式サイト由5pb.制作的PSV平台ADV游戏 游戏简介 故事发生在一个比现在的科学要进步的世界。18岁的水柿司高考失败,多亏父母找关系得以进入世界大企业SAI社工作。SAI社是制造管理拥有感情的人形智能机器人(通称:Giftia)的企业,司在其中被安排到终端服务部门工作。这个部门其实就是回收即将到期的Giftia,是所谓的“窗边部门(不被重视的部门)”。于是司和打杂的Giftia少女“艾拉”组成搭档,一起开始了工作…… 游戏画面 一段游戏PV 下载地址 密码栏下载栏云盘密码解压密码可塑性记忆汉化版https://cloud.thatcoder.cn/%E5%B7%B2%E5%85%AC%E5%BC%80%E6%96%87%E4%BB%B6/%E7%AC%AC%E4%B9%9D%E8%89%BA%E6%9C%AF/galgame/%E5%8F%AF%E5%A1%91%E6%80%A7%E8%AE%B0%E5%BF%86.7z/","tags":["GalGame"],"categories":["第九艺术"]},{"title":"QQ音乐找QQ号","path":"//QQ音乐查找QQ号/","content":"先决条件 不是音乐人 空间不是隐私仅自己可见 QQ号登入 切勿用来对线谢谢 大致步骤 登入自己的QQ号 进入目标的主页 打开开发者模式的网络模式 第一次查关键字确定链接 第二次查关键字确定账号 壹、登入官方的 QQ音乐, 进去先登入一个账号啥都行. 因为查看别人必须登入, 贰、目标主页 找到那个人点击头像就进去了, 大概长这样子. 点击[我喜欢] 叁、网络模式 在上面的目标页面按键盘上的 F12, 把开发者窗口拉大一点. 像我这样. 然后在打开的窗口上面一栏找到网络, 英文应该是叫Web什么的 点击网络后按下键盘 Ctrl+F, 会打开一个搜索栏 有搜索栏之后刷新目标主页(一定要刷新不然网络里面没内容) 肆、找链接 在搜索栏搜索 musicid, 一般会出现两个结果, 我们一般选第二个.点击后右边有内容. 需要再次查找. 伍、找账号 QAQ、失败答疑 Q: 网络选项卡搜索不到 musicid A: 没刷新 Q: 搜索出来的账号是10几位数字或者是null A: 此人不是QQ登入 Q: 能不能博主帮找 A: 方法已给出,不想动请付费, 勿白嫖劳动力, 联系方式Q: 2297813468 Q: 找到了QQ号, 但QQ搜索不到 A: 对方设置了不能QQ号查找, 可以使用我的API试试看, 把2297813468改成你找到的的QQ号: https://api.seclusion.work/api/qq/?qq=2297813468","tags":["Tencent"],"categories":["堆栈"]},{"title":"修复、格式化U盘不可读取状态","path":"//U-Fix/","content":"前言总有些奇怪的操作, 能把U盘干废, 导致都能不可读取。以下命令基于 Window10 Cmd 运行 代码 输入 diskpart 会新开一个窗口 New Diskpartdiskpart 大概这样diskpart窗口 查看问题的磁盘是第几个 查询磁盘list disk 修复 Fix# 根据第二步知道的编号进行选择select disk 2cleancreate partition primaryactive","tags":["Window"],"categories":["堆栈"]},{"path":"/chat/index.html","content":"友人帐博主动态"},{"title":"笔名钟意","path":"/about/index.html","content":"笔名钟意一位即将失业的某不知名本科生性格分析为: INFP-P感谢你的阅读, 让我们拥有一段对彼此都有意义的时光友人帐博主 网站动态拍下来行路难写下就要干! 没记录就会没发生 2023.01.25厦门鼓浪屿沙滩LQ想海了,和他来厦门看海(说走咱就走)2023.01.1广州番禺区番禺大道和ZFP自驾来广州找DZQ、LL游玩(说走咱就走)2022.12.31CZF老家跨年喝白酒加柠檬.(柠檬还是奶茶里面的)2022.10.05南昌东湖区佑民寺国庆与WJX、ZCY逛南昌2022.09.08CHQ毕业经常网易云听歌的好友毕业了,毕业后应该没什么时间听歌了.(她说我毕业送我花, 记下来.)2022.07.23赣州龙南安基山林洞ZFP非要去避暑,自驾来了安基山.山里藏着好多麦田.2022.07.04九江职业技术学院不出意外, 这是我最后一眼看这个学校. 不管是知识还是感情三年收获还是良多. 再见了相互嫌弃的课本与同学.2022.06.23房间窗口在家除了学习就是滚出去玩, 沉浸学习了一段时间才发现我房间窗口的景色还不错, 希望我忙碌之余多抬头望望天.2022.06.15家乡邻镇老家旁边被洪水淹没2022.03.13杭州西湖区云栖竹径闲暇散步云栖竹径2022.03.12杭州西湖区郭庄闲暇散步郭庄2022.03.07杭州西湖区宝石山闲暇散步宝石山2022.03.05杭州西湖区太子湾公园闲暇散步太子湾公园2022.03.12杭州西湖区某处闲暇散步无目的2022.02.27杭州上城区白塔公园闲暇散步白塔公园2022.02.22杭州上城区河坊下班和LQ逛街吃饭财物2022.01.27杭州上城区万象城回家过年前一天和LPS溜冰(果然摔倒第一眼先看有没有人发现)2022.01.15上班的日子今天工作不用审账!配小姐姐挖哈根达斯就行!2022.01.09下班的路灯提醒自己忙碌之余多留意生活中的美好2022.01.01杭州西湖区环城西路1号和LYX漫步西湖区2021.12.12杭州西湖区苏堤和LPS游玩西湖区2021.12.09温州洞头列岛半屏大桥上校招的第一份实习工作, 一周就和小伙伴们跑路, 吃住报销纯当旅游了。看到的小伙伴不要去垃圾学校的校招!2021.10.05庐山五老峰第四峰和WJJ彻底游完庐山2021.05.01九江游乐场劳动节和WJJ兼职游乐场(实现了一个童年小愿望)2021.03.29九江濂溪区天花井和WJJ登顶天花井(虽然并不高, 但每次没爬完.)2021.01.13九江浔阳区长江边和ZL在长江的日落2021.01.12庐山含鄱口观景台和ZL在庐山的日出2021.01.05九江濂溪区南山公园YJQ要去参军和我告别, 那天在南山顶聊了很久, 风也很大.2020.12.31学校宿舍元旦了,让PYY给我们整了一副对联,然后班导被领导骂了.(当然我也被骂)2020.08.29我家江边家乡的河边刚修建完过道2019.12.31庐山牯岭镇瓦的音乐餐厅与WJJ、DZQ、YXH去庐山跨年, 我和WJJ赛马冠军一路不过寥寥几笔 2022.08.28进入南昌交通学院.2022.07.03专升本惜败, 选择独立院校.2021.06.25很荣幸有了第二个生日.2019.08.28进入九江职业技术学院2019.06.7复读失败, 选择专科2018.06.7高考失败, 选择复读. div>article>div.tag-plugin.note{margin-top: 0}.bread-nav,.article-title{display:none;}h5{margin-top: 0;padding-top: 0;} document.title = '笔名钟意';"},{"path":"/friends/index.html","content":"document.title = '友人帐'; 友人帐互联网的魅力在于不远千里总能遇到志同道合的你们友人帐博主Q版友人 互关 南山客十织のblog若歆Moeyy's Blog一缕阳光别抢我小鱼干云晓晨Sara一蓑烟雨星日语CAYZLH张洪Heo安知鱼`BlogxaoxuuCIRCUIT雾时之森TomyJan平头哥神邸-Zendee杜老师说carrot·鸿HeiYing’s Blog 擅自订阅 林木木"},{"title":"钟意的友人帐","path":"/links/index.html","content":"友人帐互联网的魅力在于不远千里总能遇到志同道合的你们友人帐博主 互关 南山客HELLO WORLD·BUG征服者十织のblog萌部图片API作者若歆她说:一个群的群友罢了Moeyy's Blog一个小小的博客一缕阳光活得像诗一样别抢我小鱼干鱼干虽香,切勿贪吃云晓晨未来路长 · 勿忘初心Sara生活倒影一蓑烟雨竹杖芒鞋轻胜马,谁怕?一蓑烟雨任平生。星日语星语日点灯CAYZLHCODE IS POETRY张洪Heo一名设计师、产品经理、独立开发者、博主安知鱼`Blog生活明朗,万物可爱xaoxuuStellar主题作者, 当然还有很多其它优秀项目CIRCUIT鸯飞漫冬山雾时之森一个爱折腾爱作死的人建立的无名小站TomyJan一只菜的要死还每天不努力只知道bbll娱乐至死的废柴平头哥平头哥分享社区神邸-Zendee加入神邸,精彩由你!杜老师说杜老师! 传道,授业,解惑!carrot·鸿实践是检验真理的唯一标准。HeiYing’s Blog游龙当归海,海不迎我自来也。 擅自订阅 林木木木木木木木 div>article>div.tag-plugin.note{margin-top: 0}.bread-nav,.article-title{display:none;}h5{margin-top: 0;padding-top: 0;} document.title = '友人帐';"},{"title":"钟意的便签","path":"/notes/index.html","content":"便签目录书签开发助手写作Stellar渲染样式代码Ubuntu命令关注StellarCommits"},{"title":"开发助手","path":"/notes/书签/开发.html","content":"在线IDEAliyunIDEGithubSpacecloudstudio"},{"title":"Ubuntu命令","path":"/notes/代码/Ubuntu.html","content":"常用 查询端口或进程: netstat -ap | grep 端口号/进程名 杀死进程: kill -9 进程号 进程守护: nohup 启动命令 启动进程: systemctl start 进程名 换源 清华大学开源软件镜像站 备份资源: sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak 换源 换源sudo sed -i "s@http://.*archive.ubuntu.com@https://mirrors.tuna.tsinghua.edu.cn@g" /etc/apt/sources.listsudo sed -i "s@http://.*security.ubuntu.com@https://mirrors.tuna.tsinghua.edu.cn@g" /etc/apt/sources.list 更新软件源: sudo apt-get update 升级软件源: sudo apt-get upgrade"},{"title":"Stellar渲染样式","path":"/notes/写作/Stellar样式.html","content":"FrontMatter文章配图文章配图---# 只选cover会在图下方显示title与descriptioncover: /assets/xaoxuu/blog/2020-0927a@1x.svgposter: # 海报模式 全图封面 headline: 大标题 topic: 标题上方的小字 caption: 标题下方的小字 color: 标题颜色 # 可选,默认为跟随主题的动态颜色 # white,red...# 文章页面顶部区域横幅banner: /assets/xaoxuu/blog/2020-0927a@1x.svg--- 内容摘要内容摘要---......---这里是描述,前后要空一行<!-- more -->"},{"title":"Laf 部署之 docker-compose","path":"/wiki/Laf/start/laf-docker-compose-start.html","content":"文章部署环境 Ubuntu: 22.04 LTS Docker: 24.0.5 Docker Compose: 2.20.2 Laf: laf-1.0.0-alpha.4 下载解压项目laf-1.0.0-alpha.4最新本版的部署目录已经没有docker-compose.yml, 所以给了我的1.0a测的备注备份版本, 还保留了编排。其实0.8之后就本该去除的。 laf-1.0.0-alpha.4https://kedao.thatcoder.cn/#s/9k9mH0vg laf-1.0.0-beta.0这是官方最后一个有docker-compose.yml的版本 laf-1.0.0-beta.0https://github.com/labring/laf/releases/tag/v1.0.0-beta.0 解压tar -zcvf test.tar.gz 文件名 lafcd /laf/laf-1.0.0-alpha.4/deploy/docker-compose 修改配置/deploy/docker-compose底下有.env配置文件, 按自己情况编辑就行, 我已经备注了参数含义 需要注意的是先不要改APP_SERVICE_DEPLOY_URL_SCHEMA, 因为涉及的域名比较多, 冒然开启https会进不去。 启动服务## 去docker-compose目录cd /laf/laf-1.0.0-alpha.4/deploy/docker-compose## 创建docker网络docker network create laf_shared_network --driver bridge || true## 拉取镜像docker pull lafyun/app-service:latest## 启动所有服务docker-compose up -d 启动所有服务会拉取镜像比较久, 可以先设置docker加速源启动后如果有可视化管理可以看到多了11个容器, 没有就docker ps -a | grep laf 测试服务打开你在.env填的SYS_CLIENT_HOS, 登入账号密码新建一个世界级函数测试一下,写完记得发布。 exports.main = function () { return "hello world!";};"},{"title":"Laf 部署之 开启HTTPS/SSL","path":"/wiki/Laf/start/laf-https-start.html","content":"只看解决方法: 点击定位 项目ISSUE方法 1.0之前: 通过修改容器网关openresty、nginx等实现 1.0之后: 新建文件夹 cert, gateway-controller会定时检测证书信息(俺的cert文件夹挂载不进去啊,检查了编排文件与laf/packages/gateway-controller/src/support/apisix-gateway-init.ts 文件输出成功后都没反应) 也参考了下面的ISSUE Laf issue 证书相关https://github.com/labring/laf/issues?q=is%3Aissue+%E8%AF%81%E4%B9%A6 既然方法都没用, 就自己折腾一个方法。 尝试引入代理解决实在折腾不了laf自身https, 我开始琢磨熟悉的nginx反向代理。中途碰了点坑记录一下。 尝试两个域名 A域名: nginx代理给用户https使用 B域名: 给Laf使用 问题: 一次请求后变成用户用B域名通讯 总结: 白给, 我不该这么想当然。 一次请求后变成用户用B域名通讯 尝试一个域名 A域名: nginx代理给用户https使用, 并且与Laf通讯(Laf是8000端口不冲突) 问题: Laf会携带.env的请求方式与端口号, 导致有些请求变成http被nginx拦截, 部分功能不可用 总结: 多巧妙的解决方式, 可惜有内鬼。 部分功能不可用 完善一个域名 总结: 纵观Laf模块功能, 把内鬼干掉, 还好内鬼只有一个文件 很奈斯 解决方法步骤如下: 配置nginx 修改server容器的返回值 配置nginx准备一个nginx, 服务器上的, docker上的都可以。 新建一个和Laf域名一致的网站, 注意Laf用到的域名有四种, 所以四种都要在你的域名列表里, 举例给你参考: 控制台域名: laf.thatcoder.cn 容器通配域名: *.laf.thatcoder.cn OSS域名: oss.laf.thatcoder.cn OSS通配域名: *.oss.laf.thatcoder.cn 其中 oss.laf.thatcoder.cn 与 *.laf.thatcoder.cn 重叠, 所以需要 1、2、4 三个域名的解析与证书所以上面的域名都代理在这个nginx的网站上, 并且需要1、2、4组合证书来开启SSL 开启SSL后配置反向代理, 配置文件如下: 反向代理location ^~ / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_http_version 1.1; proxy_cache_bypass $http_upgrade; # 处理OSS通配 https://*.oss.laf.thatcoder.cn if ($host ~* ^(.*?)\\.oss\\.laf\\.thatcoder\\.cn$) { proxy_pass http://$1.oss.laf.thatcoder.cn:9000/; } # 捕获容器通配 https://*.laf.thatcoder.cn if ($host ~* ^(.*?)\\.laf\\.thatcoder\\.cn$) { set $subdomain $1; } # 处理容器通配 if ($subdomain) { proxy_pass http://$subdomain.laf.thatcoder.cn:8000/; } # 默认发送给主控制台 proxy_pass http://laf.thatcoder.cn:8000/; add_header X-Cache $upstream_cache_status; # 设置Nginx缓存 set $static_filequKUAdsO 0; if ($uri ~* "\\.(gif|png|jpg|css|js|woff|woff2)$") { set $static_filequKUAdsO 1; expires 1m; } if ($static_filequKUAdsO = 0) { add_header Cache-Control no-cache; }} 修改server容器进入容器 lafyun/system-server 找到 /app/dist/handler/application/get.js修改文件最后的数据返回异步方法handleGetApplicationByAppid(), 将整个方法替换为 替换方法async function handleGetApplicationByAppid(req, res) { var _a; const uid = (_a = req['auth']) === null || _a === void 0 ? void 0 : _a.uid; if (!uid) return res.status(401).send(); const appid = req.params.appid; const app = await application_1.getApplicationByAppid(appid); if (!app) return res.status(422).send('invalid appid'); const roles = application_1.getUserGroupsOfApplication(uid, app); if (!roles.length) { return res.status(403).send(); } const permissions = permission_1.getActionsOfRoles(roles); const exp = Math.floor(Date.now() / 1000) + 60 * 60 * config_1.default.TOKEN_EXPIRED_TIME; let debug_token = undefined; if (permissions.includes(actions_1.FunctionActionDef.InvokeFunction)) { debug_token = token_1.getToken({ appid, type: 'debug', exp }, app.config.server_secret_salt); } let export_port = config_1.default.APP_SERVICE_DEPLOY_URL_SCHEMA === 'http' ? config_1.default.PUBLISH_PORT : config_1.default.PUBLISH_HTTPS_PORT; const app_deploy_host = config_1.default.APP_SERVICE_DEPLOY_HOST + ':' + export_port; const app_deploy_url_schema = config_1.default.APP_SERVICE_DEPLOY_URL_SCHEMA; const oss_external_endpoint = config_1.default.MINIO_CONFIG.endpoint.external; const oss_internal_endpoint = config_1.default.MINIO_CONFIG.endpoint.internal; const spec = await application_spec_1.ApplicationSpecSupport.getValidAppSpec(appid); app.config = undefined; return res.send({ data: { application: app, permissions, roles, debug_token, app_deploy_host: thatUrl(app_deploy_host), app_deploy_url_schema: thatUrl(app_deploy_url_schema), oss_external_endpoint: thatUrl(oss_external_endpoint), oss_internal_endpoint: thatUrl(oss_internal_endpoint), spec } });}function thatUrl(originUrl) { let modifiedString = originUrl.replace(/http/g, "https"); modifiedString = modifiedString.replace(/:8000/g, ""); return modifiedString;} 这样返回的值就是https, 也没用端口号, 是正常的https请求。记得重启 lafyun/system-server 容器。改天在编排地方加上挂载, 就能在外面修改了。"},{"title":"Laf 简介","path":"/wiki/Laf/start/start.html","content":"👀 laf 是什么 laf 是云开发平台,可以快速的开发应用 laf 是一个开源的 BaaS 开发平台(Backend as a Service) laf 是一个开箱即用的 serverless 开发平台 laf 是一个集「函数计算」、「数据库」、「对象存储」等于一身的一站式开发平台 laf 可以是开源版的腾讯云开发、开源版的 Google Firebase、开源版的 UniCloud laf 让每个开发团队都可以随时拥有一个自己的云开发平台! 🎉 laf 有什么 多应用管理,新建、启停应用,无需折腾服务器,一分钟上线应用 云函数,laf 提供的函数计算服务,可以快速的实现后端业务 云数据库,为应用开发提供开箱即用的数据库服务 云存储,为应用开发提供专业的文件对象存储服务,兼容 S3 和其他存储服务接口 WebIDE,在线写代码,完善的类型提示、代码自动完成,像写博客一样写函数,随手发布上线! 静态托管,支持静态网站的托管,可以快速的上线静态网站,无需折腾 nginx Client Db,支持客户端使用 laf-client-sdk“直连”数据库,通过访问策略控制访问权限,极大程度提升应用开发效率 WebSocket,应用支持长连接,业务无死角 👨‍💻 谁适合使用 laf ? 前端开发者 + laf = 全栈开发者,前端秒变全栈,成为真正的大前端 laf 为前端提供了 laf-client-sdk,适用于任何 js运行环境 laf 云函数使用 js/ts 开发,前后端代码无隔裂,无门槛快速上手 laf 提供了静态网站托管,可将前端构建的网页直接同步部署上来,无需再配置服务器、nginx、域名等 laf 后续会提供多种客户端的 SDK(Flutter/Android/iOS 等),为所有客户端开发者提供后端开发服务和一致的开发体验 后端开发者,可以从琐事中解放出来,专注于业务本身,提升开发效率 laf 可以节约服务器运维、多环境部署和管理精力 laf 可以让你告别配置、调试 nginx laf 可以让你告别「为每个项目手动部署数据库、安全顾虑等重复性工作」 laf 可以让你告别「修改一次、发布半天」的重复繁琐的迭代体验 laf 可以让你随时随地在 Web 上查看函数的运行日志,不必再连接服务器,费神费眼翻找 laf 可以让你「像写博客一样写一个函数」,招之即来,挥之即去,随手发布! 云开发用户,若你是微信云开发用户,你不仅可以获得更强大、快速的开发体验,还不被微信云开发平台锁定 你可以为客户提供源码交付,为客户私有部署一套 laf + 你的云开发应用,而使用闭源的云开发服务,无法交付可独立运行的源码 你可以根据未来的需要,随时将自己的产品部署到自己的服务器上,laf 是开源免费的 你甚至可以修改、订制自己的云开发平台,laf 是开源的、高度可扩展的 Node.js 开发者,laf 是使用 Node.js 开发的,你可以把 laf 当成一个更方便的 Node.js 开发平台 or 框架 你可以在线编写、调试函数,不用重启服务,一键发布即可用 你可以在线查看、检索函数调用日志 你可以不必折腾数据库、对象存储、nginx,随时随地让你的应用上线 你可以随手将一段 Node.js 代码上云,比如一段爬虫,一段监控代码,像写博客一样写 Node! 独立开发者、初创创业团队,节约成本,快速开始,专注业务 减少启动项目开发的流程,快速启动,缩短产品验证周期 极大程度提高迭代速度,随时应对变化,随时发布 专注于产品业务本身,快速推出最小可用产品 (MVP),快速进行产品、市场验证 一个人 + laf = 团队 life is short, you need laf:) 💥 laf 能用来做什么 laf 是应用的后端开发平台,理论上可以做任何应用! 使用 laf 快速开发微信小程序/公众号:电商、社交、工具、教育、金融、游戏、短视频、社区、企业等应用! 微信小程序强要求 https 访问,可直接使用 laf.run 创建应用,为小程序提供 https 的接口服务 可将应用的 h5 页面和管理端 (admin) 直接部署到可由 laf 静态托管 将 h5 直接托管到 laf 上,将分配的专用域名配置到公众号即可在线访问 使用云函数实现微信授权、支付等业务 使用云存储存储视频、头像等用户数据 开发 Android or iOS 应用 使用云函数、云数据库、云存储进行业务处理 应用的后端管理 (admin) 直接部署到可由 laf 静态托管 可使用云函数实现微信授权、支付、热更新等业务 部署个人博客、企业官网 将 vuepress / hexo / hugo 等静态生成的博客,一键部署到 laf静态托管上,见 laf-cli 可使用云函数来处理用户留言、评论、访问统计等业务 可使用云函数扩展博客的其它能力,如课程、投票、提问等 可使用云存储存储视频、图片 可使用云函数做爬虫、推送等功能 企业信息化建设:企业私有部署一套 laf 云开发平台 快速开发企业内部信息化系统,可快速上线、修改、迭代,降成本 支持多应用、多账户,不同部门、不同系统,即可隔离,亦可连通 可借助 laf 社区生态,直接使用现存的 laf 应用,开箱即用,降成本 laf 开源免费,没有技术锁定的顾虑,可自由订制和使用 个人开发者的「手边云」 laf 让开发者随手写的一段代码,瞬间具备随手上云的能力 就像在你手机的备忘录随手敲下一段文字,自动同步到云端,且可被全网访问和执行 laf 是每个开发者的“烂笔头”,像记事一样写个函数 laf 是每个开发者的“私人助理”,比如随时可以写一个定时发送短信、邮件通知的函数 其它 有用户把 laf 云存储当网盘使用 有用户把 laf 应用当成一个日志服务器,收集客户端日志数据,使用云函数做分析统计 有用户用 laf 来跑爬虫,抓取三方新闻和咨讯等内容 有用户使用 laf 云函数做 webhook,监听 Git 仓库提交消息,推送到钉钉、企业微信群 有用户使用 laf 云函数做拨测,定时检查线上服务的健康状态 … 🏘️ 社群 论坛 微信群 QQ 群:603059673 官方公众号:laf-dev"},{"title":"Laf 部署之 k8s","path":"/wiki/Laf/start/laf-k8s-start.html","content":"私密马赛博主是docker编排部署的, k8s改天吧"},{"title":"夏日花火","path":"/wiki/GalGames/CERO-A/ 夏日花火.html","content":"简介阿巴阿巴"},{"title":"全年龄段","path":"/wiki/GalGames/CERO-A/CERO-A.html","content":"介绍这是一个存档Gal的文档, 博主目前还没建设…"},{"title":"SQL CASE 表达式","path":"/wiki/WebWeekly/SQL/SQL CASE 表达式.html","content":"当前期刊数: 234 CASE 表达式分为简单表达式与搜索表达式,其中搜索表达式可以覆盖简单表达式的全部能力,我也建议只写搜索表达式,而不要写简单表达式。 简单表达式: SELECT CASE cityWHEN '北京' THEN 1WHEN '天津' THEN 2ELSE 0END AS abcFROM test 搜索表达式: SELECT CASEWHEN city = '北京' THEN 1WHEN city = '天津' THEN 2ELSE 0END AS abcFROM test 明显可以看出,简单表达式只是搜索表达式 a = b 的特例,因为无法书写任何符号,只要条件换成 a > b 就无法胜任了,而搜索表达式不但可以轻松胜任,甚至可以写聚合函数。 CASE 表达式里的聚合函数为什么 CASE 表达式里可以写聚合函数? 因为本身表达式就支持聚合函数,比如下面的语法,我们不会觉得奇怪: SELECT sum(pv), avg(uv) from test 本身 SQL 就支持多种不同的聚合方式同时计算,所以将其用在 CASE 表达式里,也是顺其自然的: SELECT CASEWHEN count(city) = 100 THEN 1WHEN sum(dau) > 200 THEN 2ELSE 0END AS abcFROM test 只要 SQL 表达式中存在聚合函数,那么整个表达式都聚合了,此时访问非聚合变量没有任何意义。所以上面的例子,即便在 CASE 表达式中使用了聚合,其实也不过是聚合了一次后,按照条件进行判断罢了。 这个特性可以解决很多实际问题,比如将一些复杂聚合判断条件的结果用 SQL 结构输出,那么很可能是下面这种写法: SELECT CASEWHEN 聚合函数(字段) 符合什么条件 THEN xxx... 可能有 N 个ELSE NULLEND AS abcFROM test 这也可以认为是一种行转列的过程,即 把行聚合后的结果通过一条条 CASE 表达式形成一个个新的列。 聚合与非聚合不能混用我们希望利用 CASE 表达式找出那些 pv 大于平均值的行,以下这种想当然的写法是错误的: SELECT CASEWHEN pv > avg(pv) THEN 'yes'ELSE 'no'END AS abcFROM test 原因是,只要 SQL 中存在聚合表达式,那么整条 SQL 就都是聚合的,所以返回的结果只有一条,而我们期望查询结果不聚合,只是判断条件用到了聚合结果,那么就要使用子查询。 为什么子查询可以解决问题?因为子查询的聚合发生在子查询,而不影响当前父查询,理解了这一点,就知道为什么下面的写法才是正确的了: SELECT CASEWHEN pv > ( SELECT avg(pv) from test ) THEN 'yes'ELSE 'no'END AS abcFROM test 这个例子也说明了 CASE 表达式里可以使用子查询,因为子查询是先计算的,所以查询结果在哪儿都能用,CASE 表达式也不例外。 WHERE 中的 CASEWHERE 后面也可以跟 CASE 表达式的,用来做一些需要特殊枚举处理的筛选。 比如下面的例子: SELECT * FROM demo WHERECASEWHEN city = '北京' THEN trueELSE ID > 5END 本来我们要查询 ID 大于 5 的数据,但我想对北京这个城市特别对待,那么就可以在判断条件中再进行 CASE 分支判断。 这个场景在 BI 工具里等价于,创建一个 CASE 表达式字段,可以拖入筛选条件生效。 GROUP BY 中的 CASE想不到吧,GROUP BY 里都可以写 CASE 表达式: SELECT isPower, sum(gdp) FROM test GROUP BY CASEWHEN isPower = 1 THEN city, areaELSE cityEND 上面例子表示,计算 GDP 时,对于非常发达的城市,按照每个区粒度查看聚合结果,也就是看的粒度更细一些,而对于欠发达地区,本身 gdp 也不高,直接按照城市粒度看聚合结果。 这样,就按照不同的条件对数据进行了分组聚合。由于返回行结果是混在一起的,像这个例子,可以根据 isPower 字段是否为 1 判断,是否按照城市、区域进行了聚合,如果没有其他更显著的标识,可能导致无法区分不同行的聚合粒度,因此谨慎使用。 ORDER BY 中的 CASE同样,ORDER BY 使用 CASE 表达式,会将排序结果按照 CASE 分类进行分组,每组按照自己的规则排序,比如: SELECT * FROM test ORDER BY CASEWHEN isPower = 1 THEN gdpELSE peopleEND 上面的例子,对发达地区采用 gdp 排序,否则采用人口数量排序。 总结CASE 表达式总结一下有如下特点: 支持简单与搜索两种写法,推荐搜索写法。 支持聚合与子查询,需要注意不同情况的特点。 可以写在 SQL 查询的几乎任何地方,只要是可以写字段的地方,基本上就可以替换为 CASE 表达式。 除了 SELECT 外,CASE 表达式还广泛应用在 INSERT 与 UPDATE,其中 UPDATE 的妙用是不用将 SQL 拆分为多条,所以不用担心数据变更后对判断条件的二次影响。 讨论地址是:精读《SQL CASE 表达式》· Issue ##404 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"SQL grouping","path":"/wiki/WebWeekly/SQL/SQL grouping.html","content":"当前期刊数: 236 SQL grouping 解决 OLAP 场景总计与小计问题,其语法分为几类,但要解决的是同一个问题: ROLLUP 与 CUBE 是封装了规则的 GROUPING SETS,而 GROUPING SETS 则是最原始的规则。 为了方便理解,让我们从一个问题入手,层层递进吧。 底表 以上是示例底表,共有 8 条数据,城市1、城市2 两个城市,下面各有地区1~4,每条数据都有该数据的人口数。 现在想计算人口总计,以及各城市人口小计。在没有掌握 grouping 语法前,我们只能通过两个 select 语句 union 后得到: SELECT city, sum(people) FROM test GROUP BY cityunionSELECT '合计' as city, sum(people) FROM test 但两条 select 语句聚合了两次,性能是一个不小的开销,因此 SQL 提供了 GROUPING SETS 语法解决这个问题。 GROUPING SETSGROUP BY GROUPING SETS 可以指定任意聚合项,比如我们要同时计算总计与分组合计,就要按照空内容进行 GROUP BY 进行一次 sum,再按照 city 进行 GROUP BY 再进行一次 sum,换成 GROUPING SETS 描述就是: SELECT city, area,sum(people)FROM testGROUP BY GROUPING SETS((), (city, area)) 其中 GROUPING SETS((), (city, area)) 表示分别按照 ()、(city, area) 聚合计算总计。返回结果是: 可以看到,值为 NULL 的行就是我们要的总计,其值是没有任何 GROUP BY 限制算出来的。 类似的,我们还可以写 GROUPING SETS((), (city), (city, area), (area)) 等任意数量、任意组合的 GROUP BY 条件。 通过这种规则计算的数据我们称为 “超级分组记录”。我们发现 “超级分组记录” 产生的 NULL 值很容易和真正的 NULL 值弄混,所以 SQL 提供了 GROUPING 函数解决这个问题。 函数 GROUPING对于超级分组记录产生的 NULL,是可以被 GROUPING() 函数识别为 1 的: SELECT GROUPING(city),GROUPING(area),sum(people)FROM testGROUP BY GROUPING SETS((), (city, area)) 具体效果见下图: 可以看到,但凡是超级分组计算出来的字段都会识别为 1,我们利用之前学习的 SQL CASE 表达式 将其转换为总计、小计字样,就可以得出一张数据分析表了: SELECT CASE WHEN GROUPING(city) = 1 THEN '总计' ELSE city END,CASE WHEN GROUPING(area) = 1 THEN '小计' ELSE area END,sum(people)FROM testGROUP BY GROUPING SETS((), (city, area)) 然后前端表格展示时,将第一行 “总计”、“小计” 单元格合并为 “总计”,就完成了总计这个 BI 可视化分析功能。 ROLLUPROLLUP 是卷起的意思,是一种特定规则的 GROUPING SETS,以下两种写法是等价的: SELECT sum(people) FROM testGROUP BY ROLLUP(city)-- 等价于SELECT sum(people) FROM testGROUP BY GROUPING SETS((), (city)) 再看一组等价描述: SELECT sum(people) FROM testGROUP BY ROLLUP(city, area)-- 等价于SELECT sum(people) FROM testGROUP BY GROUPING SETS((), (city), (city, area)) 发现规律了吗?ROLLUP 会按顺序把 GROUP BY 内容 “一个个卷起来”。用 GROUPING 函数判断超级分组记录对 ROLLUP 同样适用。 CUBECUBE 又有所不同,它对内容进行了所有可能性展开(所以叫 CUBE)。 类比上面的例子,我们再写两组等价的展开: SELECT sum(people) FROM testGROUP BY CUBE(city)-- 等价于SELECT sum(people) FROM testGROUP BY GROUPING SETS((), (city)) 上面的例子因为只有一项还看不出来,下面两项分组就能看出来了: SELECT sum(people) FROM testGROUP BY CUBE(city, area)-- 等价于SELECT sum(people) FROM testGROUP BY GROUPING SETS((), (city), (area), (city, area)) 所谓 CUBE,是一种多维形状的描述,二维时有 2^1 种展开,三维时有 2^2 种展开,四维、五维依此类推。可以想象,如果用 CUBE 描述了很多组合,复杂度会爆炸。 总结学习了 GROUPING 语法,以后前端同学的你不会再纠结这个问题了吧: 产品开启了总计、小计,我们是额外取一次数还是放到一起获取啊? 这个问题的标准答案和原理都在这篇文章里了。PS:对于不支持 GROUPING 语法数据库,要想办法屏蔽,就像前端 polyfill 一样,是一种降级方案。至于如何屏蔽,参考文章开头提到的两个 SELECT + UNION。 讨论地址是:精读《SQL grouping》· Issue ##406 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"SQL 入门","path":"/wiki/WebWeekly/SQL/SQL 入门.html","content":"当前期刊数: 231 本系列是 SQL 系列的开篇,介绍一些宏观与基础的内容。 SQL 是什么?SQL 是一种结构化查询语言,用于管理关系型数据库,我们 90% 接触的都是查询语法,但其实它包含完整的增删改查和事物处理功能。 声明式特性SQL 属于声明式编程语言,而现代通用编程语言一般都是命令式的。但是不要盲目崇拜声明式语言,比如说它未来会代替低级的命令式语言,因为声明式本身也有它的缺点,它与命令式语言也有相通的地方。 为什么我们觉得声明式编程语言更高级?因为声明式语言抽象程度更高,比如 select * from table1 仅描述了要从 table1 查询数据,但查询的具体步骤的完全没提,这背后可能存在复杂的索引优化与锁机制,但我们都无需关心,这简直是编程的最高境界。 那为什么现在所有通用业务代码都是命令式呢?因为 命令式给了我们描述具体实现的机会 ,而通用领域的编程正需要建立在严谨的实现细节上。比如校验用户权限这件事,即便 AI 编程提供了将 “登陆用户仅能访问有权限的资源” 转化为代码的能力,我们也不清楚资源具体指哪些,以及在权限转移过程中的资源所有权属于谁。 SQL 之所以能保留声明式特性,完全因为锁定了关系型数据管理这个特定领域,而恰恰对这个领域的需求是标准化且可枚举的,才使声明式成为可能。 基于命令式语言也完全可拓展出声明式能力,比如许多 ORM 提供了类似 select({}).from({}).where({}) 之类的语法,甚至一个 login() 函数也是声明式编程的体现,因为调用者无需关心是如何登陆的,总之调用一下就完成了登陆,这不就是声明式的全部精髓吗? 语法分类作为关系型数据库管理工具,SQL 需要定义、操纵与控制数据。 数据定义即修改数据库与表级别结构,这些是数据结构,或者是数据元信息,它不代表具体数据,但描述数据的属性。 数据操纵即修改一行行具体数据,增删改查。 数据控制即对事务、用户权限的管理与控制。 数据定义DDL(Data Definition Language)数据定义,包括 CREATE DROP ALTER 方法。 数据操纵DML(Data Manipulation Language)数据操纵,包括 SELECT INSERT UPDATE DELETE 方法。 数据控制DCL(Data Control Language)数据控制,包括 COMMIT、ROLLBACK 等。 所有 SQL 操作都围绕这三种类型,其中数据操纵几乎占了 90% 的代码量,毕竟数据查询的诉求远大于写,数据写入对应数据采集,而数据查询对应数据分析,数据分析领域能玩出的花样远比数据采集要多。 PS:有些情况下,会把最重要的 SELECT 提到 DQL(Data Query Language)分类下,这样分类就变成了四个。 集合运算SQL 世界的第一公民是集合,就像 JAVA 世界第一公民是对象。我们只有以集合的视角看待 SQL,才能更好的理解它。 何为集合视角,即所有的查询、操作都是二维数据结构中进行的,而非小学算术里的单个数字间加减乘除关系。 集合的运算一般有 UNION 并集、EXCEPT 差集、INTERSECT 交集,这些都是以行为单位的操作,而各种 JOIN 语句则是以列为单位的集合运算,也是后面提到的连接查询。 只要站在二维数据结构中进行思考,运算无非是横向或纵向的操作。 数据范式数据范式分为五层,每层要求都比上一层更严苛,因此是一个可以逐步遵循的范式。数据范式要求数据越来越解耦,减少冗余。 比如第一范式要求每列都具有原子性,即都是不可分割的最小数据单元。如果数据采集时,某一列作为字符串存储,并且以 “|” 分割表示省市区,那么它就不具有原子性。 当然实际生产过程往往不都遵循这种标准,因为表不是孤立的,在数据处理流中,可能在某个环节再把列原子化,而原始数据为了压缩体积,进行列合并处理。 希望违反范式的还不仅是底层表,现在大数据处理场景下,越来越多的业务采用大宽表结构,甚至故意进行数据冗余以提升查询效率,列存储引擎就是针对这种场景设计的,所以数据范式在大数据场景下是可以变通的,但依然值得学习。 聚合当采用 GROUP BY 分组聚合数据时,如希望针对聚合值筛选,就不能用 WHERE 限定条件了,因为 WHERE 是基于行的筛选,而不是针对组合的。(GROUP BY 对数据进行分组,我们称这些组为 “组合”),所以需要使用针对组合的筛选语句 HAVING: SELECT SUM(pv) FROM tableGROUP BY cityHAVING AVG(uv) > 100 这个例子中,如果 HAVING 换成 WHERE 就没有意义,因为 WHERE 加聚合条件时,需要对所有数据进行合并,不符合当前视图的详细级别。(关于视图详细级别,在我之前写的 精读《什么是 LOD 表达式》 有详细说明)。 聚合如此重要,是因为我们分析数据必须在高 LEVEL 视角看,明细数据是看不出趋势的。而复杂的需求往往伴随着带有聚合的筛选条件,明白 SQL 是如何支持的非常重要。 CASE 表达式CASE 表达式分为简单与搜索 CASE 表达式,简单表达式: SELECT CASE pv WHEN 1 THEN 'low' ELSE 'high' END AS quality 上面的例子利用 CASE 简单表达式形成了一个新字段,这种模式等于生成了业务自定义临时字段,在对当前表进行数据加工时非常有用。搜索 CASE 表达式能力完全覆盖简单 CASE 表达式: SELECT CASE WHEN pv < 100 THEN 'low' ELSE 'high' END AS quality 可以看到,搜索 CASE 表达式可以用 “表达式” 描述条件,可以轻松完成更复杂的任务,甚至可以在表达式里使用子查询、聚合等手段,这些都是高手写 SQL 的惯用技巧,所以 CASE 表达式非常值得深入学习。 复杂查询SELECT 是 SQL 最复杂的部分,其中就包含三种复杂查询模式,分别是连接查询与子查询。 连接查询指 JOIN 查询,比如 LEFT JOIN、RIGHT JOIN、INNER JOIN。 在介绍聚合时我们提到了,连接查询本质上就是对列进行拓展,而两个表之间不会无缘无故合成一个,所以必须有一个外键作为关系纽带: SELECT A.pv, B.uvFROM table1 as t1 LEFT JOIN table2 AS P t2ON t1.productId = t2.productId 连接查询不仅拓展了列,还会随之拓展行,而拓展方式与连接的查询的类型有关。除了连接查询别的表,还可以连接查询自己,比如: SELECT t1.pv AS pv1, P2.pv AS pv2FROM tt t1, tt t2 这种子连接查询结果就是自己对自己的笛卡尔积,可通过 WHERE 筛选去重,后面会有文章专门介绍。 子查询与视图子查询就是 SELECT 里套 SELECT,一般来说 SELECT 会从内到外执行,只有在关联子查询模式下,才会从外到内执行。 而如果把子查询保存下来,就是一个视图,这个视图并不是实体表,所以很灵活,且数据会随着原始表数据而变化: CREATE VIEW countryGDP (country, gdp)ASSELECT country, SUM(gdp)FROM ttGROUP BY country 之后 countryGDP 这个视图就可以作为临时表来用了。 这种模式其实有点违背 SQL 声明式的特点,因为定义视图类似于定义变量,如果继续写下去,势必会形成一定命令式思维逻辑,但这是无法避免的。 事务当 SQL 执行一连串操作时,难免遇到不执行完就会出现脏数据的问题,所以事务可以保证操作的原子性。一般来说每个 DML 操作都是一个内置事务,而 SQL 提供的 START TRANSACTION 就是让我们可以自定义事务范围,使一连串业务操作都可以包装在一起,成为一个原子性操作。 对 SQL 来说,原子性操作是非常安全的,即失败了不会留下任何痕迹,成功了会全部成功,不会存在中间态。 OLAPOLAP(OnLine Analytical Processing)即实时数据分析,是 BI 工具背后计算引擎实现的基础。 现在越来越多的 SQL 数据库支持了窗口函数实现,用于实现业务上的 runningSum 或 runningAvg 等功能,这些都是数据分析中很常见的。 以 runningSum 为例,比如双十一实时表的数据是以分钟为单位的实时 GMV,而我们要做一张累计到当前时间的 GMV 汇总折线图,Y 轴就需要支持 running_sum(GMV) 这样的表达式,而这背后可能就是通过窗口函数实现的。 当然也不是所有业务函数都由 SQL 直接提供,业务层仍需实现大量内存函数,在 JAVA 层计算,这其中一部分是需要下推到 SQL 执行的,只有内存函数与下推函数结合在一起,才能形成我们在 BI 工具看到的复杂计算字段效果。 总结SQL 是一种声明式语言,一个看似简单的查询语句,在引擎层往往对应着复杂的实现,这就是 SQL 为何如此重要却又如此普及的原因。 虽然 SQL 容易上手,但要系统的理解它,还得从结构化数据与集合的概念开始进行思想转变。 不要小看 CASE 语法,它不仅与容易与编程语言的 CASE 语法产生混淆,本身结合表达式进行条件分支判断,是许多数据分析师在日常工作中最长用的套路。 现在使用简单 SQL 创建应用的场景越来越少了,但 BI 场景下,基于 SQL 的增强表达式场景越来越多了,本系列我就是以理解 BI 场景下查询表达式为目标创建的,希望能够学以致用。 讨论地址是:精读《SQL 入门》· Issue ##398 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"SQL 窗口函数","path":"/wiki/WebWeekly/SQL/SQL 窗口函数.html","content":"当前期刊数: 235 窗口函数形如: 表达式 OVER (PARTITION BY 分组字段 ORDER BY 排序字段) 有两个能力: 当表达式为 rank() dense_rank() row_number() 时,拥有分组排序能力。 当表达式为 sum() 等聚合函数时,拥有累计聚合能力。 无论何种能力,窗口函数都不会影响数据行数,而是将计算平摊在每一行。 这两种能力需要区分理解。 底表 以上是示例底表,共有 8 条数据,城市1、城市2 两个城市,下面各有地区1~4,每条数据都有该数据的人口数。 分组排序如果按照人口排序,ORDER BY people 就行了,但如果我们想在城市内排序怎么办? 此时就要用到窗口函数的分组排序能力: SELECT *, rank() over (PARTITION BY city ORDER BY people) FROM test 该 SQL 表示在 city 组内按照 people 进行排序。 其实 PARTITION BY 也是可选的,如果我们忽略它: SELECT *, rank() over (ORDER BY people) FROM test 也是生效的,但该语句与普通 ORDER BY 等价,因此利用窗口函数进行分组排序时,一般都会使用 PARTITION BY。 各分组排序函数的差异我们将 rank() dense_rank() row_number() 的结果都打印出来: SELECT *, rank() over (PARTITION BY city ORDER BY people),dense_rank() over (PARTITION BY city ORDER BY people),row_number() over (PARTITION BY city ORDER BY people)FROM test 其实从结果就可以猜到,这三个函数在处理排序遇到相同值时,对排名统计逻辑有如下差异: rank(): 值相同时排名相同,但占用排名数字。 dense_rank(): 值相同时排名相同,但不占用排名数字,整体排名更加紧凑。 row_number(): 无论值是否相同,都强制按照行号展示排名。 上面的例子可以优化一下,因为所有窗口逻辑都是相同的,我们可以利用 WINDOW AS 提取为一个变量: SELECT *, rank() over wd, dense_rank() over wd, row_number() over wdFROM testWINDOW wd as (PARTITION BY city ORDER BY people) 累计聚合我们之前说过,凡事使用了聚合函数,都会让查询变成聚合模式。如果不用 GROUP BY,聚合后返回行数会压缩为一行,即使用了 GROUP BY,返回的行数一般也会大大减少,因为分组聚合了。 然而使用窗口函数的聚合却不会导致返回行数减少,那么这种聚合是怎么计算的呢?我们不如直接看下面的例子: SELECT *, sum(people) over (PARTITION BY city ORDER BY people)FROM test 可以看到,在每个 city 分组内,按照 people 排序后进行了 累加(相同的值会合并在一起),这就是 BI 工具一般说的 RUNNGIN_SUM 的实现思路,当然一般我们排序规则使用绝对不会重复的日期,所以不会遇到第一个红框中合并计算的问题。 累计函数还有 avg() min() 等等,这些都一样可以作用于窗口函数,其逻辑可以按照下图理解: 你可能有疑问,直接 sum(上一行结果,下一行) 不是更方便吗?为了验证猜想,我们试试 avg() 的结果: 可见,如果直接利用上一行结果的缓存,那么 avg 结果必然是不准确的,所以窗口累计聚合是每行重新计算的。当然也不排除对于 sum、max、min 做额外性能优化的可能性,但 avg 只能每行重头计算。 与 GROUP BY 组合使用窗口函数是可以与 GROUP BY 组合使用的,遵循的规则是,窗口范围对后面的查询结果生效,所以其实并不关心是否进行了 GROUP BY。我们看下面的例子: 按照地区分组后进行累加聚合,是对 GROUP BY 后的数据行粒度进行的,而不是之前的明细行。 总结窗口函数在计算组内排序或累计 GVM 等场景非常有用,我们只要牢记两个知识点就行了: 分组排序要结合 PARTITION BY 才有意义。 累计聚合作用于查询结果行粒度,支持所有聚合函数。 讨论地址是:精读《SQL 窗口函数》· Issue ##405 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"SQL 复杂查询","path":"/wiki/WebWeekly/SQL/SQL 复杂查询.html","content":"当前期刊数: 233 SQL 复杂查询指的就是子查询。 为什么子查询叫做复杂查询呢?因为子查询相当于查询嵌套查询,因为嵌套导致复杂度几乎可以被无限放大(无限嵌套),因此叫复杂查询。下面是一个最简单的子查询例子: SELECT pv FROM ( SELECT pv FROM test) 上面的例子等价于 SELECT pv FROM test,但因为把表的位置替换成了一个新查询,所以摇身一变成为了复杂查询!所以复杂查询不一定真的复杂,甚至可能写出和普通查询等价的复杂查询,要避免这种无意义的行为。 我们也要借此机会了解为什么子查询可以这么做。 理解查询的本质当我们查一张表时,数据库认为我们在查什么? 这点很重要,因为下面两个语句都是合法的: SELECT pv FROM testSELECT pv FROM ( SELECT pv FROM test) 为什么数据库可以把子查询当作表呢?为了统一理解这些概念,我们有必要对查询内容进行抽象理解:任意查询位置都是一条或多条记录。 比如 test 这张表,显然是多条记录(当然只有一行就是一条记录),而 SELECT pv FROM test 也是多条记录,然而因为 FROM 后面可以查询任意条数的记录,所以这两种语法都支持。 不仅是 FROM 可以跟单条或多条记录,甚至 SELECT、GROUP BY、WHERE、HAVING 后都可以跟多条记录,这个后面再说。 说到这,也就很好理解子查询的变种了,比如我们可以在子查询内使用 WHERE 或 GROUP BY 等等,因为无论如何,只要查询结果是多条记录就行了: SELECT sum(people) as allPeople, sum(gdp), city FROM ( SELECT people, gdp, city FROM test GROUP BY city HAVING sum(gdp) > 10000) 这个例子就有点业务含义了。子查询是从内而外执行的,因此我们先看内部的逻辑:按照城市分组,筛选出总 GDP 超过一万的所有地区的人口数量明细。外层查询再把人口数加总,这样就能对比每个 GDP 超过一万的地区,总人口和总 GDP 分别是多少,方便对这些重点城市做对比。 不过这个例子看起来还是不太自然,因为我们没必要写成复杂查询,其实简单查询也是等价的: SELECT sum(people) as allPeople, sum(gdp), city FROM testGROUP BY cityHAVING sum(gdp) > 10000 那为什么要多此一举呢?因为复杂查询的真正用法并不在这里。 视图正因为子查询的存在,我们才可能以类似抽取变量的方式,抽取子查询,这个抽取出来的抽象就是视图: CREATE VIEW my_table(people, gdp, city)ASSELECT sum(people) as allPeople, sum(gdp), city FROM testGROUP BY cityHAVING sum(gdp) > 10000SELECT sum(people) as allPeople, sum(gdp), city FROM my_table 这样的好处是,这个视图可以被多条 SQL 语句复用,不仅可维护性变好了,执行时也仅需查询一次。 要注意的是,SELECT 可以使用任何视图,但 INSERT、DELETE、UPDATE 用于视图时,需要视图满足一下条件: 未使用 DISTINCT 去重。 FROM 单表。 未使用 GROUP BY 和 HAVING。 因为上面几种模式都会导致视图成为聚合后的数据,不方便做除了查以外的操作。 另外一个知识点就是物化视图,即使用 MATERIALIZED 描述视图: CREATE MATERIALIZED VIEW my_table(people, gdp, city)AS ... 这种视图会落盘,为什么要支持这个特性呢?因为普通视图作为临时表,无法利用索引等优化手段,查询性能较低,所以物化视图是较为常见的性能优化手段。 说到性能优化手段,还有一些比较常见的理念,即把读的复杂度分摊到写的时候,比如提前聚合新表落盘或者对 CASE 语句固化为字段等,这里先不展开。 标量子查询上面说了,WHERE 也可以跟子查询,比如: SELECT city FROM testWHERE gdp > ( SELECT avg(gdp) from test) 这样可以查询出 gdp 大于平均值的城市。 那为什么不能直接这么写呢? SELECT city FROM testWHERE gdp > avg(gdp) -- 报错,WHERE 无法使用聚合函数 看上去很美好,但其实第一篇我们就介绍了,WHERE 不能跟聚合查询,因为这样会把整个父查询都聚合起来。那为什么子查询可以?因为子查询聚合的是子查询啊,父查询并没有被聚合,所以这才符合我们的意图。 所以上面例子不合适的地方在于,直接在当前查询使用 avg(gdp) 会导致聚合,而我们并不想聚合当前查询,但又要通过聚合拿到平均 GDP,所以就要使用子查询了! 回过头来看,为什么这一节叫标量子查询?标量即单一值,因为 avg(gdp) 聚合出来的只有一个值,所以 WHERE 可以把它当做一个单一数值使用。反之,如果子查询没有使用聚合函数,或 GROUP BY 分组,那么就不能使用 WHERE > 这种语法,但可以使用 WHERE IN,这涉及到单条与多条记录的思考,我们接着看下一节。 单条和多条记录介绍标量子查询时说到了,WHERE > 的值必须时单一值。但其实 WHERE 也可以跟返回多条记录的子查询结果,只要使用合理的条件语句,比如 IN: SELECT area FROM testWHERE gdp IN ( SELECT max(gdp) from test GROUP BY city) 上面的例子,子查询按照城市分组,并找到每一组 GDP 最大的那条记录,所以如果数据粒度是区域,那么我们就查到了每个城市 GDP 最大的那些记录,然后父查询通过 WHERE IN 找到 gdp 符合的复数结果,所以最后就把每个城市最大 gdp 的区域列了出来。 但实际上 WHERE > 语句跟复数查询结果也不会报错,但没有任何意义,所以我们要理解查询结果是单条还是多条,在 WHERE 判断时选择合适的条件。WHERE 适合跟复数查询结果的语法有:WHERE IN、WHERE SOME、WHERE ANY。 关联子查询所谓关联子查询,即父子查询间存在关联,既然如此,子查询肯定不能单独优先执行,毕竟和父查询存在关联嘛,所以关联子查询是先执行外层查询,再执行内层查询的。要注意的是,对每一行父查询,子查询都会执行一次,因此性能不高(当然 SQL 会对相同参数的子查询结果做缓存)。 那这个关联是什么呢?关联的是每一行父查询时,对子查询执行的条件。这么说可能有点绕,举个例子: SELECT * FROM test where gdp > ( select avg(gdp) from test group by city) 对这个例子来说,想要查找 gdp 大于按城市分组的平均 gdp,比如北京地区按北京比较,上海地区按上海比较。但很可惜这样做是不行的,因为父子查询没有关联,SQL 并不知道要按照相同城市比较,因此只要加一个 WHERE 条件,就变成关联子查询了: SELECT * FROM test as t1 where gdp > ( select avg(gdp) from test as t2 where t1.city = t2.city group by city) 就是在每次判断 WHERE gdp > 条件时,重新计算子查询结果,将平均值限定在相同的城市,这样就符合需求了。 总结学会灵活运用父子查询,就掌握了复杂查询了。 SQL 第一公民是集合,所以所谓父子查询就是父子集合的灵活组合,这些集合可以出现在几乎任何位置,根据集合的数量、是否聚合、关联条件,就派生出了标量查询、关联子查询。 更深入的了解就需要大量实战案例了,但万变不离其宗,掌握了复杂查询后,就可以理解大部分 SQL 案例了。 讨论地址是:精读《SQL 复杂查询》· Issue ##403 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"SQL 聚合查询","path":"/wiki/WebWeekly/SQL/SQL 聚合查询.html","content":"当前期刊数: 232 SQL 为什么要支持聚合查询呢? 这看上去是个幼稚的问题,但我们还是一步步思考一下。数据以行为粒度存储,最简单的 SQL 语句是 select * from test,拿到的是整个二维表明细,但仅做到这一点远远不够,出于以下两个目的,需要 SQL 提供聚合函数: 明细数据没有统计意义,比如我想知道今天的营业额一共有多少,而不太关心某桌客人消费了多少。 虽然可以先把数据查到内存中再聚合,但在数据量非常大的情况下很容易把内存撑爆,可能一张表一天的数据量就有 10TB,而 10TB 数据就算能读到内存里,聚合计算可能也会慢到难以接受。 另外聚合本身也有一定逻辑复杂度,而 SQL 提供了聚合函数与分组聚合能力,可以方便快速的统计出有业务价值的聚合数据,这奠定了 SQL 语言的分析价值,因此大部分分析软件直接采用 SQL 作为直接面向用户的表达式。 聚合函数常见的聚合函数有: COUNT:计数。 SUM:求和。 AVG:求平均值。 MAX:求最大值。 MIN:求最小值。 COUNTCOUNT 用来计算有多少条数据,比如我们看 id 这一列有多少条: SELECT COUNT(id) FROM test 但我们发现其实查任何一列的 COUNT 都是一样的,那传入 id 有什么意义呢?没必要特殊找一个具体列指代呀,所以也可以写成: SELECT COUNT(*) FROM test 但这两者存在微妙差异。SQL 存在一种很特殊的值类型 NULL,如果 COUNT 指定了具体列,则统计时会跳过此列值为 NULL 的行,而 COUNT(*) 由于未指定具体列,所以就算包含了 NULL,甚至某一行所有列都为 NULL,也都会包含进来。所以 COUNT(*) 查出的结果一定大于等于 COUNT(c1)。 当然任何聚合函数都可以跟随查询条件 WHERE,比如: SELECT COUNT(*) FROM testWHERE is_gray = 1 SUMSUM 求和所有项,因此必须作用于数值字段,而不能用于字符串。 SELECT SUM(cost) FROM test SUM 遇到 NULL 值时当 0 处理,因为这等价于忽略。 AVGAVG 求所有项均值,因此必须作用于数值字段,而不能用于字符串。 SELECT AVG(cost) FROM test AVG 遇到 NULL 值时采用了最彻底的忽略方式,即 NULL 完全不参与分子与分母的计算,就像这一行数据不存在一样。 MAX、MINMAX、MIN 分别求最大与最小值,与上面不同的是,也可以作用于字符串上,因此可以根据字母判断大小,从大到小依次对应 a-z,但即便能算,也没有实际意义且不好理解,因此不建议对字符串求极值。 SELECT MAX(cost) FROM test 多个聚合字段虽然都是聚合函数,但 MAX、MIN 严格意义上不算是聚合函数,因为它们只是寻找了满足条件的行。可以看看下面两段查询结果的对比: SELECT MAX(cost), id FROM test -- id: 100SELECT SUM(cost), id FROM test -- id: 1 第一条查询可以找到最大值那一行的 id,而第二条查询的 id 是无意义的,因为不知道归属在哪一行,所以只返回了第一条数据的 id。 当然,如果同时计算 MAX、MIN,那么此时 id 也只返回第一条数据的值,因为这个查询结果对应了复数行: SELECT MAX(cost), MIN(cost), id FROM test -- id: 1 基于这些特性,最好不要混用聚合与非聚合,也就是一条查询一旦有一个字段是聚合的,那么所有字段都要聚合。 现在很多 BI 引擎的自定义字段都有这条限制,因为混用聚合与非聚合在自定义内存计算时处理起来边界情况很多,虽然 SQL 能支持,但业务自定义的函数可能不支持。 分组聚合分组聚合就是 GROUP BY,其实可以把它当作一种高级的条件语句。 举个例子,查询每个国家的 GDP 总量: SELECT SUM(GDP) FROM amazing_tableGROUP BY country 返回的结果就会按照国家进行分组,这时,聚合函数就变成了在组内聚合。 其实如果我们只想看中、美的 GDP,用非分组也可以查,只是要分成两条 SQL: SELECT SUM(GDP) FROM amazing_tableWHERE country = '中国'SELECT SUM(GDP) FROM amazing_tableWHERE country = '美国' 所以 GROUP BY 也可理解为,将某个字段的所有可枚举的情况都查了出来,并整合成一张表,每一行代表了一种枚举情况,不需要分解为一个个 WHERE 查询了。 多字段分组聚合GROUP BY 可以对多个维度使用,含义等价于表格查询时行/列拖入多个维度。 上面是 BI 查询工具视角,如果没有上下文,可以看下面这个递进描述: 按照多个字段进行分组聚合。 多字段组合起来成为唯一 Key,即 GROUP BY a,b 表示 a,b 合在一起描述一个组。 GROUP BY a,b,c 查询结果第一列可能看到许多重复的 a 行,第二列看到重复 b 行,但在同一个 a 值内不会重复,c 在 b 行中同理。 下面是一个例子: SELECT SUM(GDP) FROM amazing_tableGROUP BY province, city, area 查询结果为: 浙江 杭州 余杭区浙江 杭州 西湖区浙江 宁波 海曙区浙江 宁波 江北区北京 ......... GROUP BY + WHEREWHERE 是根据行进行条件筛选的。因此 GROUP BY + WHERE 并不是在组内做筛选,而是对整体做筛选。 但由于按行筛选,其实组内或非组内结果都完全一样,所以我们几乎无法感知这种差异: SELECT SUM(GDP) FROM amazing_tableGROUP BY province, city, areaWHERE industry = 'internet' 然而,忽略这个差异会导致我们在聚合筛选时碰壁。 比如要筛选出平均分大于 60 学生的成绩总和,如果不使用子查询,是无法在普通查询中在 WHERE 加聚合函数实现的,比如下面就是一个语法错误的例子: SELECT SUM(score) FROM amazing_tableWHERE AVG(score) > 60 不要幻想上面的 SQL 可以执行成功,不要在 WHERE 里使用聚合函数。 GROUP BY + HAVINGHAVING 是根据组进行条件筛选的。因此可以在 HAVING 使用聚合函数: SELECT SUM(score) FROM amazing_tableGROUP BY class_nameHAVING AVG(score) > 60 上面的例子中可以正常查询,表示按照班级分组看总分,且仅筛选出平均分大于 60 的班级。 所以为什么 HAVING 可以使用聚合条件呢?因为 HAVING 筛选的是组,所以可以对组聚合后过滤掉不满足条件的组,这样是有意义的。而 WHERE 是针对行粒度的,聚合后全表就只有一条数据,无论过滤与否都没有意义。 但要注意的是,GROUP BY 生成派生表是无法利用索引筛选的,所以 WHERE 可以利用给字段建立索引优化性能,而 HAVING 针对索引字段不起作用。 总结聚合函数 + 分组可以实现大部分简单 SQL 需求,在写 SQL 表达式时,需要思考这样的表达式是如何计算的,比如 MAX(c1), c2 是合理的,而 SUM(c1), c2 这个 c2 就是无意义的。 最后记住 WHERE 是 GROUP BY 之前执行的,HAVING 针对组进行筛选。 讨论地址是:精读《SQL 聚合查询》· Issue ##401 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"查无此人","path":"/wiki/MySql/basics/select.html","content":"基本单字段查询SELECT first_name FROM user; 多字段查询SELECT first_name,gender,class_id... FROM user; 全字段查询 不可定义顺序 且性能不友好 SELECT * FROM user; 条件条件运算符 < > = != <>(官方推荐的不等于) >= <= SELECT name_id FROM user WHERE age>=18; 年龄大于等于18 SELECT name_id FROM user WHERE age<>18; 年龄不等于18 逻辑运算符 && || ! and or not SELECT name_id FROM user WHERE (age>=18 AND age<=65) OR salary>=20000; 年龄18-65,或者工资大于2w 模糊条件 like : 模糊匹配 %任意字符可零 _单个字符 between and : 查询区间范围 包含区间值 in : 查询等于列表的值 不支持模糊匹配 <=> : 安全等于 is null : 不能判断 age=null 只能 age IS NULL 或者 age <=> null is not null SELECT name_id FROM user WHERE full_name LIKE '_柒%'; 全名第二个字是柒,其它随意 SELECT name_id FROM user WHERE age BETWEEN 18 AND 65; 年龄18-65 SELECT name_id FROM user WHERE age IN (18,20,22); 年龄18,20,22 排序 order by 字段(可使用别名) DESC|ASC DESC 降序 ASC 升序(默认可不写) 单个排序SELECT *,salary(IFNULL(salary,0)) AS 年薪 FROM user WHERE age >= 18 ORDER BY 年薪 DESC; 多个排序SELECT *,salary(IFNULL(salary,0)) AS 年薪 FROM user WHERE age >= 18 ORDER BY 年薪 DESC,age ASC; 连接查询 sql-92 标准: 仅支持内连接 sql-99 标准: 内连接+外连接(左外,右外,全外)+交叉连接 - 内连接 A∩B 左外连接 A∩B∪A A为主表 右外连接 A∩B∪B B为主表 全外连接 A∪B 内连接等值连接 两张表意义一样的字段,以此建立等值连接 两张表的交集 n表等值连接至少需要n-1个连接条件 n表顺序没有要求 一般需要取别名 简单使用例: 查询用户对应的全部订单号SELECT custom_name,buy_id FORM customs,buys WHERE customs.buy_id = buys.id 表取别名 提高语句简洁度 区分多个重名字段 如果取别名则不可使用源表名 案例例: 查询每个街道对应的区市省 SELECT p.name 省,ci.name 市,co.name 县,t.name 街道FROM town t,country co,city ci,province p WHERE t.country_id = co.country_id AND co.city_id = ci.city_id AND ci.province_id = p.province_id LIMIT 20; 非等值连接 和等值连接基本一致,条件非等而已,条件取一定关系 连接规则由等号以外的运算符组成。>,=,<,,>=,<=,<>,!=,between等 案例 自连接 类似等值连接,两表有同意字段可当一张表使用 案例 外连接 用于查询主表中有 从表中没有的记录 select 查询列表 from 表1 别名[连接类型]join 表2 别名on 连接条件[where筛选条件][group by. 分组][having筛选条件][order by排序列表] 左外连接 以左边的表为主,查询的数据包括左边表所有的数据以及左右表有交集的数据 还有左表不符合条件的记录,并在右表相应列中填NULL。 left [outer]可省 右外连接 以右边的表为主,查询的数据包括右边表所有的数据以及左右表有交集的数据 还有右表不符合条件的记录,并在左表相应列中填NULL。 right [outer]可省 全连接 AUB 查询出所有单身狗,单身女,和cp组 full [outer]可省 或者 union 左右 达到全外连接 (SELECT * FROM user1 t1) UNION (SELECT * FROM user1_copy t2); -- 注:union会对相同的结果进行去重(SELECT * FROM user1 t1) UNION ALL (SELECT * FROM user1_copy t2); -- 注:union all则查询的是两边全部的数据,不会对数据进行去重 自连接 连接操作不仅可以在两个表之间进行,也可以是一个表与其自己进行连接,成为表的自身连接,也就是所谓的自连接。 **自连接查询其实等同于连接查询,需要两张表,只不过它的左表(父表)和右表(子表)都是自己。做自连接查询的时候,是自己和自己连接,分别给父表和子表取两个不同的别名,然后附上连接条件。** 实例 普通查询SELECT a.*,b.nameFROM SUBJECT a , SUBJECT bWHERE a.`pno`=b.`cno`; 显然没有先行课的被忽略掉了,因此我们可以用左关联结合自连接来查询,便于观察。 自查询SELECT a.*,b.nameFROM SUBJECT a LEFT JOIN SUBJECT bON a.`pno`=b.`cno`; 交叉连接 就是用99标准的语法实现的笛卡尔乘积 cross SELECT b.* ,bo.*FROM beauty bCROSS JOIN boys boON bo.id=b.boyfriend_id; Noteon 和 where条件的区别如下 on条件是在生成临时表时使用的条件,它不管on中的条件是否为真,都会返回左边表中的记录。 where条件是在临时表生成好后,再对临时表进行过滤的条件。 而 inner join(内连接)没这个特殊性,则条件放在on中和where中,返回的结果集是相同的。 等值连接列名相同 使用等值连接 使用using"},{"title":"只寻常道","path":"/wiki/MySql/basics/usually.html","content":"常用命令 查看所有数据库show databases 打开指定的库use 库名 查看当前库的所有表show tables 查看其它库的所有表show tables from 库名 创建表create table 表名{name type, name type...} 查看表结构desc 表名 查看服务器版本select Version() 单行函数字符函数长度:length 获取参数的字节个数 UTF-8:汉字三字节 GBK:汉字两字节 SELECT LENGTH("柒拾柒Web"); 返回12 拼接:concat 拼接字符串 SELECT CONCAT("2","_","33"); 返回”2_33” 大小写:upper|lower 前者变大写,后者小写 SELECT UPPER("webgray"); 返回”WEBGRAY” 裁剪:substr 裁剪字符串,字符串首个索引从 1 开始 (str,索引起始) (str,索引起始,步长) SELECT SUBSTR("阿珍爱上了阿强",3,3); 返回”爱上了” 索引instr **查找第一次起始索引,无则返回 0 ** (源数据,查找值) select instr(13145556,4); select instr("阿珍爱上了阿强","爱"); 去空:trim 默认去前后空格,可指定内容 (元数据) (指定内容 FROM 源数据) select trim(" 阿珍 "); select trim("-" FROM "------阿珍---"); select trim(1 FROM 11122211); 填充:lpad|rpad 左右填充补齐 (源数据,长度,填充内容) SELECT LPAD("阿强",10,"珍"); 替换:replace 替换 (源数据,旧数据,新数据) SELECT REPLACE("111靓仔111",1,""); 字符例题 查询员工姓名,工资,以及工资提高20%的结果SELECT e_name , e_salary , e_salary*1.2 "new salary" FROM employees; 将员工的姓名以首字母排序,并写出姓名长度SELECT LENGTH(e_name) 长度, SUBSTR(e_name,1,1) 首字符, e_name FROM employees; 数学函数四舍五入:round 四舍五入 (源数据,保留小数点位数) select round(3.1415926535 , 7); 取整:ceil|floor 向上向下取整 小数:truncate 截断小数 (数据,截断位数) 取余:mod 取余: a-a/b*b (被除数,除数) 日期函数now 当前sql语句的 日期+时间 例如: 2022-09-10 17:29:54 sysdate 当前函数的耗时 日期+时间 (时间精度参数 0~6) 例如: 2022-09-10 17:29:54 curdate 日期 例如: 2022-09-10 curtime 时间 例如: 17:29:54 时间比较 时间输入合法即可比较 DATE_FORMAT 将数据库中的date数据格式化为String类型(常用) (date,format) STR_TO_DATE 将指定的时间格式的字符串按照格式转换为 DATETIME 类型的值。str要与format的格式保持一致,否则会报错。 (string,format) 流程控制如果:if 类似三元运算符,只能if else (条件 , 成立 , 不成立) 选择:case 类似 switch case 语境一: 处理等值 当switch case CASE 条件WHEN 常量1 THEN 语句….END 语境二: 处理区间 当多重if CASEWHEN 条件1 THEN 语句….END 其它函数常量SELECT 100; SELECT 'Web Gray'; 运算表达式SELECT 3*4; 版本SELECT VERSION(); 取别名 便于理解 便于区分重复字段 SELECT first_name FORM user AS 姓名; SELECT first_name 姓名,gender "性 别" FROM user; 去重SELECT DISTINCT class_id from user; 加号SELECT '123'+4; 若强转成功作加法,失败为零 SELECT null+4; 有null为null 判断NullIFNULL 函数判断并赋予默认值 SELECT IFNULL(class_id,1903) AS 班级,student_id FROM student; 转义 默认脏转义符为 ‘\\‘ 可通过 ESCAPE 自定义 SELECT name_id FROM user WHERE full_name LIKE '$_柒%' ESCAPE '$'; 转义符变为’$’ 分组聚合函数简单使用SELECT SUM(salary) 求和, AVG(salary) 平均, MAX(salary) 最高, MIN(salary) 最低, COUNT(DISTINCT salary) 个数 FROM employees; 参数支持 SUM , AVG 数值类型 MAX , MIN , COUNT 任何类型 都忽略 null 都支持 DISTINCT 去重 分组函数详情count 统计行数 : SELECT COUNT(*) FROM table_name; SELECT COUNT(1) FORM table_name; SELECT COUNT(字段名) FROM table_name; 统计效率 : MYISAM 存储引擎下 , COUNT(*)最优, 引擎有对应计数器. INNODB 存储引擎下 , COUNT(*)|COUNT(1) 都差不多. 比 COUNT(字段名) 效率高. group by 位于语句末尾,因为分组函数查询的值为一行,协同查询的字段是 group by 后的字段 例: 查找男女同学中各自的最大年龄SELECT MIN(brithday),gender FROM student_test GROUP BY gender; having能分组前筛选就尽量用,相比having性能差 分组后筛选时使用,位于 group by 语句末尾,筛选的数据源是分组. 分组案例 例: 查找男女同学中各自的最大年龄SELECT MIN(brithday),gender FROM student_test GROUP BY gender; 例: 查找姓”唐”男女同学中各自的最大年龄SELECT MIN(brithday),gender FROM student_test WHERE student_name LIKE "唐%" GROUP BY gender; 例: 统计名字字数大于1的其余各字数的人数SELECT COUNT(*) 人数,LENGTH(student_name) 名字长度 FROM student_test GROUP BY LENGTH(student_name) HAVING 名字长度 > 3;"},{"title":"《Diff, AnyOf, IsUnion","path":"/wiki/WebWeekly/TS 类型体操/《Diff, AnyOf, IsUnion.html","content":"当前期刊数: 247 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 25~32 题。 精读Diff实现 Diff<A, B>,返回一个新对象,类型为两个对象类型的 Diff: type Foo = { name: string age: string}type Bar = { name: string age: string gender: number}Equal<Diff<Foo, Bar> // { gender: number } 首先要思考 Diff 的计算方式,A 与 B 的 Diff 是找到 A 存在 B 不存在,与 B 存在 A 不存在的值,那么正好可以利用 Exclude<X, Y> 函数,它可以得到存在于 X 不存在于 Y 的值,我们只要用 keyof A、keyof B 代替 X 与 Y,并交替 A、B 位置就能得到 Diff: // 本题答案type Diff<A, B> = { [K in Exclude<keyof A, keyof B> | Exclude<keyof B, keyof A>]: K extends keyof A ? A[K] : ( K extends keyof B ? B[K]: never )} Value 部分的小技巧我们之前也提到过,即需要用两套三元运算符保证访问的下标在对象中存在,即 extends keyof 的语法技巧。 AnyOf实现 AnyOf 函数,任意项为真则返回 true,否则返回 false,空数组返回 false: type Sample1 = AnyOf<[1, '', false, [], {}]> // expected to be true.type Sample2 = AnyOf<[0, '', false, [], {}]> // expected to be false. 本题有几个问题要思考: 第一是用何种判定思路?像这种判断数组内任意元素是否满足某个条件的题目,都可以用递归的方式解决,具体是先判断数组第一项,如果满足则继续递归判断剩余项,否则终止判断。这样能做但比较麻烦,还有种取巧的办法是利用 extends Array<> 的方式,让 TS 自动帮你遍历。 第二个是如何判断任意项为真?为真的情况很多,我们尝试枚举为假的 Case:0 undefined '' undefined null never []。 结合上面两个思考,本题作如下解答不难想到: type Falsy = '' | never | undefined | null | 0 | false | []type AnyOf<T extends readonly any[]> = T extends Falsy[] ? false : true 但会遇到这个测试用例没通过: AnyOf<[0, '', false, [], {}]> 如果此时把 {} 补在 Falsy 里,会发现除了这个 case 外,其他判断都挂了,原因是 { a: 1 } extends {} 结果为真,因为 {} 并不表示空对象,而是表示所有对象类型,所以我们要把它换成 Record<PropertyKey, never>,以锁定空对象: // 本题答案type Falsy = '' | never | undefined | null | 0 | false | [] | Record<PropertyKey, never>type AnyOf<T extends readonly any[]> = T extends Falsy[] ? false : true IsNever实现 IsNever 判断值类型是否为 never: type A = IsNever<never> // expected to be truetype B = IsNever<undefined> // expected to be falsetype C = IsNever<null> // expected to be falsetype D = IsNever<[]> // expected to be falsetype E = IsNever<number> // expected to be false 首先我们可以毫不犹豫的写下一个错误答案: type IsNever<T> = T extends never ? true :false 这个错误答案离正确答案肯定是比较近的,但错在无法判断 never 上。在 Permutation 全排列题中我们就认识到了 never 在泛型中的特殊性,它不会触发 extends 判断,而是直接终结,致使判断无效。 而解法也很简单,只要绕过 never 这个特性即可,包一个数组: // 本题答案type IsNever<T> = [T] extends [never] ? true :false IsUnion实现 IsUnion 判断是否为联合类型: type case1 = IsUnion<string> // falsetype case2 = IsUnion<string|number> // truetype case3 = IsUnion<[string|number]> // false 这道题完全是脑筋急转弯了,因为 TS 肯定知道传入类型是否为联合类型,并且会对联合类型进行特殊处理,但并没有暴露联合类型的判断语法,所以我们只能对传入类型进行测试,推断是否为联合类型。 我们到现在能想到联合类型的特征只有两个: 在 TS 处理泛型为联合类型时进行分发处理,即将联合类型拆解为独立项一一进行判定,最后再用 | 连接。 用 [] 包裹联合类型可以规避分发的特性。 所以怎么判定传入泛型是联合类型呢?如果泛型进行了分发,就可以反推出它是联合类型。 难点就转移到了:如何判断泛型被分发了?首先分析一下,分发的效果是什么样: A extends A// 如果 A 是 1 | 2,分发结果是:(1 extends 1 | 2) | (2 extends 1 | 2) 也就是这个表达式会被执行两次,第一个 A 在两次值分别为 1 与 2,而第二个 A 在两次执行中每次都是 1 | 2,但这两个表达式都是 true,无法体现分发的特殊性。 此时要利用包裹 [] 不分发的特性,即在分发后,由于在每次执行过程中,第一个 A 都是联合类型的某一项,因此用 [] 包裹后必然与原始值不相等,所以我们在 extends 分发过程中,再用 [] 包裹 extends 一次,如果此时匹配不上,说明产生了分发: type IsUnion<A> = A extends A ? ( [A] extends [A] ? false : true) : false 但这段代码依然不正确,因为在第一个三元表达式括号内,A 已经被分发,所以 [A] extends [A] 即便对联合类型也是判定为真的,此时需要用原始值代替 extends 后面的 [A],骚操作出现了: type IsUnion<A, B = A> = A extends A ? ( [B] extends [A] ? false : true) : false 虽然我们申明了 B = A,但过程中因为 A 被分发了,所以运行时 B 是不等于 A 的,才使得我们达成目的。[B] 放 extends 前面是因为,B 是未被分发的,不可能被分发后的结果包含,所以分发时此条件必定为假。 最后因为测试用例有一个 never 情况,我们用刚才的 IsNever 函数提前判否即可: // 本题答案type IsUnion<A, B = A> = IsNever<A> extends true ? false : ( A extends A ? ( [B] extends [A] ? false : true ) : false) 从该题我们可以深刻体会到 TS 的怪异之处,即 type X<T> = T extends ... 中 extends 前面的 T 不一定是你看到传入的 T,如果是联合类型的话,会分发为单个类型分别处理。 ReplaceKeys实现 ReplaceKeys<Obj, Keys, Targets> 将 Obj 中每个对象的 Keys Key 类型转化为符合 Targets 对象对应 Key 描述的类型,如果无法匹配到 Targets 则类型置为 never: type NodeA = { type: 'A' name: string flag: number}type NodeB = { type: 'B' id: number flag: number}type NodeC = { type: 'C' name: string flag: number}type Nodes = NodeA | NodeB | NodeCtype ReplacedNodes = ReplaceKeys<Nodes, 'name' | 'flag', {name: number, flag: string}> // {type: 'A', name: number, flag: string} | {type: 'B', id: number, flag: string} | {type: 'C', name: number, flag: string} // would replace name from string to number, replace flag from number to string.type ReplacedNotExistKeys = ReplaceKeys<Nodes, 'name', {aa: number}> // {type: 'A', name: never, flag: number} | NodeB | {type: 'C', name: never, flag: number} // would replace name to never 本题别看描述很吓人,其实非常简单,思路:用 K in keyof Obj 遍历原始对象所有 Key,如果这个 Key 在描述的 Keys 中,且又在 Targets 中存在,则返回类型 Targets[K] 否则返回 never,如果不在描述的 Keys 中则用在对象里本来的类型: // 本题答案type ReplaceKeys<Obj, Keys, Targets> = { [K in keyof Obj] : K extends Keys ? ( K extends keyof Targets ? Targets[K] : never ) : Obj[K]} Remove Index Signature实现 RemoveIndexSignature<T> 把对象 <T> 中 Index 下标移除: type Foo = { [key: string]: any; foo(): void;}type A = RemoveIndexSignature<Foo> // expected { foo(): void } 该题思考的重点是如何将对象字符串 Key 识别出来,可以用 `${infer P}` 是否能识别到 P 来判断当前是否命中了字符串 Key: // 本题答案type RemoveIndexSignature<T> = { [K in keyof T as K extends `${infer P}` ? P : never]: T[K]} Percentage Parser实现 PercentageParser<T>,解析出百分比字符串的符号位与数字: type PString1 = ''type PString2 = '+85%'type PString3 = '-85%'type PString4 = '85%'type PString5 = '85'type R1 = PercentageParser<PString1> // expected ['', '', '']type R2 = PercentageParser<PString2> // expected ["+", "85", "%"]type R3 = PercentageParser<PString3> // expected ["-", "85", "%"]type R4 = PercentageParser<PString4> // expected ["", "85", "%"]type R5 = PercentageParser<PString5> // expected ["", "85", ""] 这道题充分说明了 TS 没有正则能力,尽量还是不要做正则的事情 ^_^。 回到正题,如果非要用 TS 实现,我们只能枚举各种场景: // 本题答案type PercentageParser<A extends string> = // +/-xxx% A extends `${infer X extends '+' | '-'}${infer Y}%`? [X, Y, '%'] : ( // +/-xxx A extends `${infer X extends '+' | '-'}${infer Y}` ? [X, Y, ''] : ( // xxx% A extends `${infer X}%` ? ['', X, '%'] : ( // xxx 包括 ['100', '%', ''] 这三种情况 A extends `${infer X}` ? ['', X, '']: never ) ) ) 这道题运用了 infer 可以无限进行分支判断的知识。 Drop Char实现 DropChar 从字符串中移除指定字符: type Butterfly = DropChar<' b u t t e r f l y ! ', ' '> // 'butterfly!' 这道题和 Replace 很像,只要用递归不断把 C 排除掉即可: // 本题答案type DropChar<S, C extends string> = S extends `${infer A}${C}${infer B}` ? `${A}${DropChar<B, C>}` : S 总结写到这,越发觉得 TS 虽然具备图灵完备性,但在逻辑处理上还是不如 JS 方便,很多设计计算逻辑的题目的解法都不是很优雅。 但是解决这类题目有助于强化对 TS 基础能力组合的理解与综合运用,在解决实际类型问题时又是必不可少的。 讨论地址是:精读《Diff, AnyOf, IsUnion…》· Issue ##429 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Get return type, Omit, ReadOnly","path":"/wiki/WebWeekly/TS 类型体操/《Get return type, Omit, ReadOnly.html","content":"当前期刊数: 244 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 1~8 题。 精读Get Return Type实现非常经典的 ReturnType<T>: const fn = (v: boolean) => { if (v) return 1 else return 2}type a = MyReturnType<typeof fn> // should be "1 | 2" 首先不要被例子吓到了,觉得必须执行完代码才知道返回类型,其实 TS 已经帮我们推导好了返回类型,所以上面的函数 fn 的类型已经是这样了: const fn = (v: boolean): 1 | 2 => { ... } 我们要做的就是把函数返回值从内部抽出来,这非常适合用 infer 实现: // 本题答案type MyReturnType<T> = T extends (...args: any[]) => infer P ? P : never infer 配合 extends 是解构复杂类型的神器,如果对上面代码不能一眼理解,说明对 infer 熟悉度还是不够,需要多看。 Omit实现 Omit<T, K>,作用恰好与 Pick<T, K> 相反,排除对象 T 中的 K key: interface Todo { title: string description: string completed: boolean}type TodoPreview = MyOmit<Todo, 'description' | 'title'>const todo: TodoPreview = { completed: false,} 这道题比较容易尝试的方案是: type MyOmit<T, K extends keyof T> = { [P in keyof T]: P extends K ? never : T[P]} 其实仍然包含了 description、title 这两个 Key,只是这两个 Key 类型为 never,不符合要求。 所以只要 P in keyof T 写出来了,后面怎么写都无法将这个 Key 抹去,我们应该从 Key 下手: type MyOmit<T, K extends keyof T> = { [P in (keyof T extends K ? never : keyof T)]: T[P]} 但这样写仍然不对,我们思路正确,即把 keyof T 中归属于 K 的排除,但因为前后 keyof T 并没有关联,所以需要借助 Exclude 告诉 TS,前后 keyof T 是同一个指代(上一讲实现过 Exclude): // 本题答案type MyOmit<T, K extends keyof T> = { [P in Exclude<keyof T, K>]: T[P]}type Exclude<T, U> = T extends U ? never : T 这样就正确了,掌握该题的核心是: 三元判断还可以写在 Key 位置。 JS 抽不抽函数效果都一样,但 TS 需要推断,很多时候抽一个函数出来就是为了告诉 TS “是同一指代”。 当然既然都用上了 Exclude,我们不如再结合 Pick,写出更优雅的 Omit 实现: // 本题优雅答案type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> Readonly 2实现 MyReadonly2<T, K>,让指定的 Key K 成为 ReadOnly: interface Todo { title: string description: string completed: boolean}const todo: MyReadonly2<Todo, 'title' | 'description'> = { title: "Hey", description: "foobar", completed: false,}todo.title = "Hello" // Error: cannot reassign a readonly propertytodo.description = "barFoo" // Error: cannot reassign a readonly propertytodo.completed = true // OK 该题乍一看蛮难的,因为 readonly 必须定义在 Key 位置,但我们又没法在这个位置做三元判断。其实利用之前我们自己做的 Pick、Omit 以及内置的 Readonly 组合一下就出来了: // 本题答案type MyReadonly2<T, K extends keyof T> = Readonly<Pick<T, K>> & Omit<T, K> 即我们可以将对象一分为二,先 Pick 出 K Key 部分设置为 Readonly,再用 & 合并上剩下的 Key,正好用到上一题的函数 Omit,完美。 Deep Readonly实现 DeepReadonly<T> 递归所有子元素: type X = { x: { a: 1 b: 'hi' } y: 'hey'}type Expected = { readonly x: { readonly a: 1 readonly b: 'hi' } readonly y: 'hey' }type Todo = DeepReadonly<X> // should be same as `Expected` 这肯定需要用类型递归实现了,既然要递归,肯定不能依赖内置 Readonly 函数,我们需要将函数展开手写: // 本题答案type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends Object> ? DeepReadonly<T[K]> : T[K]} 这里 Object 也可以用 Record<string, any> 代替。 Tuple to Union实现 TupleToUnion<T> 返回元组所有值的集合: type Arr = ['1', '2', '3']type Test = TupleToUnion<Arr> // expected to be '1' | '2' | '3' 该题将元组类型转换为其所有值的可能集合,也就是我们希望用所有下标访问这个数组,在 TS 里用 [number] 作为下标即可: // 本题答案type TupleToUnion<T extends any[]> = T[number] Chainable Options直接看例子比较好懂: declare const config: Chainableconst result = config .option('foo', 123) .option('name', 'type-challenges') .option('bar', { value: 'Hello World' }) .get()// expect the type of result to be:interface Result { foo: number name: string bar: { value: string }} 也就是我们实现一个相对复杂的 Chainable 类型,拥有该类型的对象可以 .option(key, value) 一直链式调用下去,直到使用 get() 后拿到聚合了所有 option 的对象。 如果我们用 JS 实现该函数,肯定需要在当前闭包存储 Object 的值,然后提供 get 直接返回,或 option 递归并传入新的值。我们不妨用 Class 来实现: class Chain { constructor(previous = {}) { this.obj = { ...previous } } obj: Object get () { return this.obj } option(key: string, value: any) { return new Chain({ ...this.obj, [key]: value }) }}const config = new Chain() 而本地要求用 TS 实现,这就比较有趣了,正好对比一下 JS 与 TS 的思维。先打个岔,该题用上面 JS 方式写出来后,其实类型也就出来了,但用 TS 完整实现类型也另有其用,特别在一些复杂函数场景,需要用 TS 系统描述类型,JS 真正实现时拿到 any 类型做纯运行时处理,将类型与运行时分离开。 好我们回到题目,我们先把 Chainable 的框架写出来: type Chainable = { option: (key: string, value: any) => any get: () => any} 问题来了,如何用类型描述 option 后还可以接 option 或 get 呢?还有更麻烦的,如何一步一步将类型传导下去,让 get 知道我此时拿的类型是什么呢? Chainable 必须接收一个泛型,这个泛型默认值是个空对象,所以 config.get() 返回一个空对象也是合理的: type Chainable<Result = {}> = { option: (key: string, value: any) => any get: () => Result} 上面的代码对于第一层是完全没问题的,直接调用 get 返回的就是空对象。 第二步解决递归问题: // 本题答案type Chainable<Result = {}> = { option: <K extends string, V>(key: K, value: V) => Chainable<Result & { [P in K]: V }> get: () => Result} 递归思维大家都懂就不赘述了。这里有个看似不值得一提,但确实容易坑人的地方,就是如何描述一个对象仅包含一个 Key 值,这个值为泛型 K 呢? // 这是错的,因为描述了一大堆类型{ [K] : V}// 这也是错的,这个 K 就是字面量 K,而非你希望的类型指代{ K: V} 所以必须使用 TS “习惯法” 的 [K in keyof T] 的套路描述,即便我们知道 T 只有一个固定的类型。可见 JS 与 TS 完全是两套思维方式,所以精通 JS 不必然精通 TS,TS 还是要大量刷题培养思维的。 Last of Array实现 Last<T> 获取元组最后一项的类型: type arr1 = ['a', 'b', 'c']type arr2 = [3, 2, 1]type tail1 = Last<arr1> // expected to be 'c'type tail2 = Last<arr2> // expected to be 1 我们之前实现过 First,类似的,这里无非是解构时把最后一个描述成 infer: // 本题答案type Last<T> = T extends [...infer Q, infer P] ? P : never 这里要注意,infer Q 有人第一次可能会写成: type Last<T> = T extends [...Others, infer P] ? P : never 发现报错,因为 TS 里不可能随便使用一个未定义的泛型,而如果把 Others 放在 Last<T, Others> 里,你又会面临一个 TS 大难题: type Last<T, Others extends any[]> = T extends [...Others, infer P] ? P : never// 必然报错Last<arr2> 因为 Last<arr2> 仅传入了一个参数,必然报错,但第一个参数是用户给的,第二个参数是我们推导出来的,这里既不能用默认值,又不能不写,无解了。 如果真的硬着头皮要这么写,必须借助 TS 还未通过的一项特性:部分类型参数推断,举个例子,很可能以后的语法是: type Last<T, Others extends any[] = infer> = T extends [...Others, infer P] ? P : never 这样首先传参只需要一个了,而且还申明了第二个参数是一个推断类型。不过该提案还未支持,而且本质上和把 infer 写到表达式里面含义和效果也都一样,所以对这道题来说就不用折腾了。 Pop实现 Pop<T>,返回去掉元组最后一项之后的类型: type arr1 = ['a', 'b', 'c', 'd']type arr2 = [3, 2, 1]type re1 = Pop<arr1> // expected to be ['a', 'b', 'c']type re2 = Pop<arr2> // expected to be [3, 2] 这道题和 Last 几乎完全一样,返回第一个解构值就行了: // 本题答案type Pop<T> = T extends [...infer Q, infer P] ? Q : never 总结从题目中很明显能看出 TS 思维与 JS 思维有很大差异,想要真正掌握 TS,大量刷题是必须的。 讨论地址是:精读《Get return type, Omit, ReadOnly…》· Issue ##422 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Flip, Fibonacci, AllCombinations","path":"/wiki/WebWeekly/TS 类型体操/《Flip, Fibonacci, AllCombinations.html","content":"当前期刊数: 250 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 49~56 题。 精读Flip实现 Flip<T>,将对象 T 中 Key 与 Value 对调: Flip<{ a: "x", b: "y", c: "z" }>; // {x: 'a', y: 'b', z: 'c'}Flip<{ a: 1, b: 2, c: 3 }>; // {1: 'a', 2: 'b', 3: 'c'}Flip<{ a: false, b: true }>; // {false: 'a', true: 'b'} 在 keyof 描述对象时可以通过 as 追加变形,所以这道题应该这样处理: type Flip<T> = { [K in keyof T as T[K]]: K} 由于 Key 位置只能是 String or Number,所以 T[K] 描述 Key 会显示错误,我们需要限定 Value 的类型: type Flip<T extends Record<string, string | number>> = { [K in keyof T as T[K]]: K} 但这个答案无法通过测试用例 Flip<{ pi: 3.14; bool: true }>,原因是 true 不能作为 Key。只能用字符串 'true' 作为 Key,所以我们得强行把 Key 位置转化为字符串: // 本题答案type Flip<T extends Record<string, string | number | boolean>> = { [K in keyof T as `${T[K]}`]: K} Fibonacci Sequence用 TS 实现斐波那契数列计算: type Result1 = Fibonacci<3> // 2type Result2 = Fibonacci<8> // 21 由于测试用例没有特别大的 Case,我们可以放心用递归实现。JS 版的斐波那契非常自然,但 TS 版我们只能用数组长度模拟计算,代码写起来自然会比较扭曲。 首先需要一个额外变量标记递归了多少次,递归到第 N 次结束: type Fibonacci<T extends number, N = [1]> = N['length'] extends T ? ( // xxx) : Fibonacci<T, [...N, 1]> 上面代码每次执行都判断是否递归完成,否则继续递归并把计数器加一。我们还需要一个数组存储答案,一个数组存储上一个数: // 本题答案type Fibonacci< T extends number, N extends number[] = [1], Prev extends number[] = [1], Cur extends number[] = [1]> = N['length'] extends T ? Prev['length'] : Fibonacci<T, [...N, 1], Cur, [...Prev, ...Cur]> 递归时拿 Cur 代替下次的 Prev,用 [...Prev, ...Cur] 代替下次的 Cur,也就是说,下次的 Cur 符合斐波那契定义。 AllCombinations实现 AllCombinations<S> 对字符串 S 全排列: type AllCombinations_ABC = AllCombinations<'ABC'>// should be '' | 'A' | 'B' | 'C' | 'AB' | 'AC' | 'BA' | 'BC' | 'CA' | 'CB' | 'ABC' | 'ACB' | 'BAC' | 'BCA' | 'CAB' | 'CBA' 首先要把 ABC 字符串拆成一个个独立的联合类型,进行二次组合才可能完成全排列: type StrToUnion<S> = S extends `${infer F}${infer R}` ? F | StrToUnion<R> : never infer 描述字符串时,第一个指向第一个字母,第二个指向剩余字母;对剩余字符串递归可以将其逐一拆解为单个字符并用 | 连接: StrToUnion<'ABC'> // 'A' | 'B' | 'C' 将 StrToUnion<'ABC'> 的结果记为 U,则利用对象转联合类型特征,可以制造出 ABC 在三个字母时的全排列: { [K in U]: `${K}${AllCombinations<never, Exclude<U, K>>}` }[U] // `ABC${any}` | `ACB${any}` | `BAC${any}` | `BCA${any}` | `CAB${any}` | `CBA${any}` 然而只要在每次递归时巧妙的加上 '' | 就可以直接得到答案了: type AllCombinations<S extends string, U extends string = StrToUnion<S>> = | '' | { [K in U]: `${K}${AllCombinations<never, Exclude<U, K>>}` }[U] // '' | 'A' | 'B' | 'C' | 'AB' | 'AC' | 'BA' | 'BC' | 'CA' | 'CB' | 'ABC' | 'ACB' | 'BAC' | 'BCA' | 'CAB' | 'CBA' 为什么这么神奇呢?这是因为每次递归时都会经历 ''、'A'、'AB'、'ABC' 这样逐渐累加字符的过程,而每次都会遇到 '' | 使其自然形成了联合类型,比如遇到 'A' 时,会自然形成 'A' 这项联合类型,同时继续用 'A' 与 Exclude<'A' | 'B' | 'C', 'A'> 进行组合。 更精妙的是,第一次执行时的 '' 填补了全排列的第一个 Case。 最后注意到上面的结果产生了一个 Error:”Type instantiation is excessively deep and possibly infinite”,即这样递归可能产生死循环,因为 Exclude<U, K> 的结果可能是 never,所以最后在开头修补一下对 never 的判否,利用之前学习的知识,never 不会进行联合类型展开,所以我们用 [never] 判断来规避: // 本题答案type AllCombinations<S extends string, U extends string = StrToUnion<S>> = [ U] extends [never] ? '' : '' | { [K in U]: `${K}${AllCombinations<never, Exclude<U, K>>}` }[U] Greater Than实现 GreaterThan<T, U> 判断 T > U: GreaterThan<2, 1> //should be trueGreaterThan<1, 1> //should be falseGreaterThan<10, 100> //should be falseGreaterThan<111, 11> //should be true 因为 TS 不支持加减法与大小判断,看到这道题时就应该想到有两种做法,一种是递归,但会受限于入参数量限制,可能堆栈溢出,一种是参考 MinusOne 的特殊方法,用巧妙的方式构造出长度符合预期的数组,用数组 ['length'] 进行比较。 先说第一种,递归肯定要有一个递增 Key,拿 T U 先后进行对比,谁先追上这个数,谁就是较小的那个: // 本题答案type GreaterThan<T, U, R extends number[] = []> = T extends R['length'] ? false : U extends R['length'] ? true : GreaterThan<T, U, [...R, 1]> 另一种做法是快速构造两个长度分别等于 T U 的数组,用数组快速判断谁更长。构造方式不再展开,参考 MinusOne 那篇的方法即可,重点说下如何快速判断 [1, 1] 与 [1, 1, 1] 谁更大。 因为 TS 没有大小判断能力,所以拿到了 ['length'] 也没有用,我们得考虑 arr1 extends arr2 这种方式。可惜的是,长度不相等的数组,extends 永远等于 false: [1,1,1,1] extends [1,1,1] ? true : false // false[1,1,1] extends [1,1,1,1] ? true : false // false[1,1,1] extends [1,1,1] ? true : false // true 但我们期望进行如下判断: ArrGreaterThan<[1,1,1,1],[1,1,1]> // trueArrGreaterThan<[1,1,1],[1,1,1,1]> // falseArrGreaterThan<[1,1,1],[1,1,1]> // false 解决方法非常体现 TS 思维:既然俩数组相等才返回 true,那我们用 [...T, ...any] 进行补充判定,如果能判定为 true,就说明前者长度更短(因为后者补充几项后可以判等): type ArrGreaterThan<T extends 1[], U extends 1[]> = U extends [...T, ...any] ? false : true 这样一来,第二种答案就是这样的: // 本题答案type GreaterThan<T extends number, U extends number> = ArrGreaterThan< NumberToArr<T>, NumberToArr<U>> Zip实现 TS 版 Zip 函数: type exp = Zip<[1, 2], [true, false]> // expected to be [[1, true], [2, false]] 此题同样配合辅助变量,进行计数递归,并额外用一个类型变量存储结果: // 本题答案type Zip< T extends any[], U extends any[], I extends number[] = [], R extends any[] = []> = I['length'] extends T['length'] ? R : U[I['length']] extends undefined ? Zip<T, U, [...I, 0], R> : Zip<T, U, [...I, 0], [...R, [T[I['length']], U[I['length']]]]> [...R, [T[I['length']], U[I['length']]]] 在每次递归时按照 Zip 规则添加一条结果,其中 I['length'] 起到的作用类似 for 循环的下标 i,只是在 TS 语法中,我们只能用数组的方式模拟这种计数。 IsTuple实现 IsTuple<T> 判断 T 是否为元组类型(Tuple): type case1 = IsTuple<[number]> // truetype case2 = IsTuple<readonly [number]> // truetype case3 = IsTuple<number[]> // false 不得不吐槽的是,无论是 TS 内部或者词法解析都是更有效的判断方式,但如果用 TS 来实现,就要换一种思路了。 Tuple 与 Array 在 TS 里的区别是前者长度有限,后者长度无限,从结果来看,如果访问其 ['length'] 属性,前者一定是一个固定数字,而后者返回 number,用这个特性判断即可: // 本题答案type IsTuple<T> = [T] extends [never] ? false : T extends readonly any[] ? number extends T['length'] ? false : true : false 其实这个答案是根据单测一点点试出来的,因为存在 IsTuple<{ length: 1 }> 单测用例,它可以通过 number extends T['length'] 的校验,但因为其本身不是数组类型,所以无法通过 T extends readonly any[] 的前置判断。 Chunk实现 TS 版 Chunk: type exp1 = Chunk<[1, 2, 3], 2> // expected to be [[1, 2], [3]]type exp2 = Chunk<[1, 2, 3], 4> // expected to be [[1, 2, 3]]type exp3 = Chunk<[1, 2, 3], 1> // expected to be [[1], [2], [3]] 老办法还是要递归,需要一个变量记录当前收集到 Chunk 里的内容,在 Chunk 达到上限时释放出来,同时也要注意未达到上限就结束时也要释放出来。 type Chunk< T extends any[], N extends number = 1, Chunked extends any[] = []> = T extends [infer First, ...infer Last] ? Chunked['length'] extends N ? [Chunked, ...Chunk<T, N>] : Chunk<Last, N, [...Chunked, First]> : [Chunked] Chunked['length'] extends N 判断 Chunked 数组长度达到 N 后就释放出来,否则把当前数组第一项 First 继续塞到 Chunked 数组,数组项从 Last 开始继续递归。 我们发现 Chunk<[], 1> 这个单测没过,因为当 Chunked 没有项目时,就无需成组了,所以完整的答案是: // 本题答案type Chunk< T extends any[], N extends number = 1, Chunked extends any[] = []> = T extends [infer Head, ...infer Tail] ? Chunked['length'] extends N ? [Chunked, ...Chunk<T, N>] : Chunk<Tail, N, [...Chunked, Head]> : Chunked extends [] ? Chunked : [Chunked] Fill实现 Fill<T, N, Start?, End?>,将数组 T 的每一项替换为 N: type exp = Fill<[1, 2, 3], 0> // expected to be [0, 0, 0] 这道题也需要用递归 + Flag 方式解决,即定义一个 I 表示当前递归的下标,一个 Flag 表示是否到了要替换的下标,只要到了这个下标,该 Flag 就永远为 true: type Fill< T extends unknown[], N, Start extends number = 0, End extends number = T['length'], I extends any[] = [], Flag extends boolean = I['length'] extends Start ? true : false> 由于递归会不断生成完整答案,我们将 T 定义为可变的,即每次仅处理第一条,如果当前 Flag 为 true 就采用替换值 N,否则就拿原本的第一个字符: type Fill< T extends unknown[], N, Start extends number = 0, End extends number = T['length'], I extends any[] = [], Flag extends boolean = I['length'] extends Start ? true : false> = I['length'] extends End ? T : T extends [infer F, ...infer R] ? Flag extends false ? [F, ...Fill<R, N, Start, End, [...I, 0]>] : [N, ...Fill<R, N, Start, End, [...I, 0]>] : T 但这个答案没有通过测试,仔细想想发现 Flag 在 I 长度超过 Start 后就判定失败了,为了让超过后维持 true,在 Flag 为 true 时将其传入覆盖后续值即可: // 本题答案type Fill< T extends unknown[], N, Start extends number = 0, End extends number = T['length'], I extends any[] = [], Flag extends boolean = I['length'] extends Start ? true : false> = I['length'] extends End ? T : T extends [infer F, ...infer R] ? Flag extends false ? [F, ...Fill<R, N, Start, End, [...I, 0]>] : [N, ...Fill<R, N, Start, End, [...I, 0], Flag>] : T 总结勤用递归、辅助变量可以解决大部分本周遇到的问题。 讨论地址是:精读《Flip, Fibonacci, AllCombinations…》· Issue ##432 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《ObjectEntries, Shift, Reverse","path":"/wiki/WebWeekly/TS 类型体操/《ObjectEntries, Shift, Reverse.html","content":"当前期刊数: 249 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 41~48 题。 精读ObjectEntries实现 TS 版本的 Object.entries: interface Model { name: string; age: number; locations: string[] | null;}type modelEntries = ObjectEntries<Model> // ['name', string] | ['age', number] | ['locations', string[] | null]; 经过前面的铺垫,大家应该熟悉了 TS 思维思考问题,这道题看到后第一个念头应该是:如何先把对象转换为联合类型?这个问题不解决,就无从下手。 对象或数组转联合类型的思路都是类似的,一个数组转联合类型用 [number] 作为下标: ['1', '2', '3']['number'] // '1' | '2' | '3' 对象的方式则是 [keyof T] 作为下标: type ObjectToUnion<T> = T[keyof T] 再观察这道题,联合类型每一项都是数组,分别是 Key 与 Value,这样就比较好写了,我们只要构造一个 Value 是符合结构的对象即可: type ObjectEntries<T> = { [K in keyof T]: [K, T[K]]}[keyof T] 为了通过单测 ObjectEntries<{ key?: undefined }>,让 Key 位置不出现 undefined,需要强制把对象描述为非可选 Key: type ObjectEntries<T> = { [K in keyof T]-?: [K, T[K]]}[keyof T] 为了通过单测 ObjectEntries<Partial<Model>>,得将 Value 中 undefined 移除: // 本题答案type RemoveUndefined<T> = [T] extends [undefined] ? T : Exclude<T, undefined>type ObjectEntries<T> = { [K in keyof T]-?: [K, RemoveUndefined<T[K]>]}[keyof T] Shift实现 TS 版 Array.shift: type Result = Shift<[3, 2, 1]> // [2, 1] 这道题应该是简单难度的,只要把第一项抛弃即可,利用 infer 轻松实现: // 本题答案type Shift<T> = T extends [infer First, ...infer Rest] ? Rest : never Tuple to Nested Object实现 TupleToNestedObject<T, P>,其中 T 仅接收字符串数组,P 是任意类型,生成一个递归对象结构,满足如下结果: type a = TupleToNestedObject<['a'], string> // {a: string}type b = TupleToNestedObject<['a', 'b'], number> // {a: {b: number}}type c = TupleToNestedObject<[], boolean> // boolean. if the tuple is empty, just return the U type 这道题用到了 5 个知识点:递归、辅助类型、infer、如何指定对象 Key、PropertyKey,你得全部知道并组合起来才能解决该题。 首先因为返回值是个递归对象,递归过程中必定不断修改它,因此给泛型添加第三个参数 R 存储这个对象,并且在递归数组时从最后一个开始,这样从最内层对象开始一点点把它 “包起来”: type TupleToNestedObject<T, U, R = U> = /** 伪代码 T extends [...infer Rest, infer Last]*/ 下一步是如何描述一个对象 Key?之前 Chainable Options 例子我们学到的 K in Q,但需要注意直接这么写会报错,因为必须申明 Q extends PropertyKey。最后再处理一下递归结束条件,即 T 变成空数组时直接返回 R: // 本题答案type TupleToNestedObject<T, U, R = U> = T extends [] ? R : ( T extends [...infer Rest, infer Last extends PropertyKey] ? ( TupleToNestedObject<Rest, U, { [P in Last]: R }> ) : never) Reverse实现 TS 版 Array.reverse: type a = Reverse<['a', 'b']> // ['b', 'a']type b = Reverse<['a', 'b', 'c']> // ['c', 'b', 'a'] 这道题比上一题简单,只需要用一个递归即可: // 本题答案type Reverse<T extends any[]> = T extends [...infer Rest, infer End] ? [End, ...Reverse<Rest>] : T Flip Arguments实现 FlipArguments<T> 将函数 T 的参数反转: type Flipped = FlipArguments<(arg0: string, arg1: number, arg2: boolean) => void> // (arg0: boolean, arg1: number, arg2: string) => void 本题与上题类似,只是反转内容从数组变成了函数的参数,只要用 infer 定义出函数的参数,利用 Reverse 函数反转一下即可: // 本题答案type Reverse<T extends any[]> = T extends [...infer Rest, infer End] ? [End, ...Reverse<Rest>] : Ttype FlipArguments<T> = T extends (...args: infer Args) => infer Result ? (...args: Reverse<Args>) => Result : never FlattenDepth实现指定深度的 Flatten: type a = FlattenDepth<[1, 2, [3, 4], [[[5]]]], 2> // [1, 2, 3, 4, [5]]. flattern 2 timestype b = FlattenDepth<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, [[5]]]. Depth defaults to be 1 这道题比之前的 Flatten 更棘手一些,因为需要控制打平的次数。 基本想法就是,打平 Deep 次,所以需要实现打平一次的函数,再根据 Deep 值递归对应次: type FlattenOnce<T extends any[], U extends any[] = []> = T extends [infer X, ...infer Y] ? ( X extends any[] ? FlattenOnce<Y, [...U, ...X]> : FlattenOnce<Y, [...U, X]>) : U 然后再实现主函数 FlattenDepth,因为 TS 无法实现 +、- 号运算,我们必须用数组长度判断与操作数组来辅助实现: // FlattenOncetype FlattenDepth< T extends any[], U extends number = 1, P extends any[] = []> = P['length'] extends U ? T : ( FlattenDepth<FlattenOnce<T>, U, [...P, any]>) 当递归没有达到深度 U 时,就用 [...P, any] 的方式给数组塞一个元素,下次如果能匹配上 P['length'] extends U 说明递归深度已达到。 但考虑到测试用例 FlattenDepth<[1, [2, [3, [4, [5]]]]], 19260817> 会引发超长次数递归,需要提前终止,即如果打平后已经是平的,就不用再继续递归了,此时可以用 FlattenOnce<T> extends T 判断: // 本题答案// FlattenOncetype FlattenDepth< T extends any[], U extends number = 1, P extends any[] = []> = P['length'] extends U ? T : ( FlattenOnce<T> extends T ? T : ( FlattenDepth<FlattenOnce<T>, U, [...P, any]> )) BEM style string实现 BEM 函数完成其规则拼接: Expect<Equal<BEM<'btn', [], ['small', 'medium', 'large']>, 'btn--small' | 'btn--medium' | 'btn--large' >>, 之前我们了解了通过下标将数组或对象转成联合类型,这里还有一个特殊情况,即字符串中通过这种方式申明每一项,会自动笛卡尔积为新的联合类型: type BEM<B extends string, E extends string[], M extends string[]> = `${B}__${E[number]}--${M[number]}` 这是最简单的写法,但没有考虑项不存在的情况。不如创建一个 SafeUnion 函数,当传入值不存在时返回空字符串,保证安全的跳过: type IsNever<TValue> = TValue[] extends never[] ? true : false;type SafeUnion<TUnion> = IsNever<TUnion> extends true ? "" : TUnion; 最终代码: // 本题答案// IsNever, SafeUniontype BEM<B extends string, E extends string[], M extends string[]> = `${B}${SafeUnion<`__${E[number]}`>}${SafeUnion<`--${M[number]}`>}` InorderTraversal实现 TS 版二叉树中序遍历: const tree1 = { val: 1, left: null, right: { val: 2, left: { val: 3, left: null, right: null, }, right: null, },} as consttype A = InorderTraversal<typeof tree1> // [1, 3, 2] 首先回忆一下二叉树中序遍历 JS 版的实现: function inorderTraversal(tree) { if (!tree) return [] return [ ...inorderTraversal(tree.left), res.push(val), ...inorderTraversal(tree.right) ]} 对 TS 来说,实现递归的方式有一点点不同,即通过 extends TreeNode 来判定它不是 Null 从而递归: // 本题答案interface TreeNode { val: number left: TreeNode | null right: TreeNode | null}type InorderTraversal<T extends TreeNode | null> = [T] extends [TreeNode] ? ( [ ...InorderTraversal<T['left']>, T['val'], ...InorderTraversal<T['right']> ] ): [] 你可能会问,问什么不能像 JS 一样,用 null 做判断呢? type InorderTraversal<T extends TreeNode | null> = [T] extends [null] ? [] : ( [ // error ...InorderTraversal<T['left']>, T['val'], ...InorderTraversal<T['right']> ] ) 如果这么写会发现 TS 抛出了异常,因为 TS 不能确定 T 此时符合 TreeNode 类型,所以要执行操作时一般采用正向判断。 总结这些类型挑战题目需要灵活组合 TS 的基础知识点才能破解,常用的包括: 如何操作对象,增减 Key、只读、合并为一个对象等。 递归,以及辅助类型。 infer 知识点。 联合类型,如何从对象或数组生成联合类型,字符串模板与联合类型的关系。 讨论地址是:精读《ObjectEntries, Shift, Reverse…》· Issue ##431 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Permutation, Flatten, Absolute","path":"/wiki/WebWeekly/TS 类型体操/《Permutation, Flatten, Absolute.html","content":"当前期刊数: 246 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 17~24 题。 精读Permutation实现 Permutation 类型,将联合类型替换为可能的全排列: type perm = Permutation<'A' | 'B' | 'C'>; // ['A', 'B', 'C'] | ['A', 'C', 'B'] | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C', 'A', 'B'] | ['C', 'B', 'A'] 看到这题立马联想到 TS 对多个联合类型泛型处理是采用分配律的,在第一次做到 Exclude 题目时遇到过: Exclude<'a' | 'b', 'a' | 'c'>// 等价于Exclude<'a', 'a' | 'c'> | Exclude<'b', 'a' | 'c'> 所以这题如果能 “递归触发联合类型分配率”,就有戏解决啊。但触发的条件必须存在两个泛型,而题目传入的只有一个,我们只好创造第二个泛型,使其默认值等于第一个: type Permutation<T, U = T> 这样对本题来说,会做如下展开: Permutation<'A' | 'B' | 'C'>// 等价于Permutation<'A' | 'B' | 'C', 'A' | 'B' | 'C'>// 等价于Permutation<'A', 'A' | 'B' | 'C'> | Permutation<'B', 'A' | 'B' | 'C'> | Permutation<'C', 'A' | 'B' | 'C'> 对于 Permutation<'A', 'A' | 'B' | 'C'> 来说,排除掉对自身的组合,可形成 'A', 'B','A', 'C' 组合,之后只要再递归一次,再拼一次,把已有的排除掉,就形成了 A 的全排列,以此类推,形成所有字母的全排列。 这里要注意两点: 如何排除掉自身?Exclude<T, P> 正合适,该函数遇到 T 在联合类型 P 中时,会返回 never,否则返回 T。 递归何时结束?每次递归时用 Exclude<U, T> 留下没用过的组合,最后一次组合用完一定会剩下 never,此时终止递归。 // 本题答案type Permutation<T, U = T> = [T] extends [never] ? [] : T extends U ? [T, ...Permutation<Exclude<U, T>>] : [] 验证一下答案,首先展开 Permutation<'A', 'B', 'C'>: 'A' extends 'A' | 'B' | 'C' ? ['A', ...Permutation<'B' | 'C'>] : []'B' extends 'A' | 'B' | 'C' ? ['B', ...Permutation<'A' | 'C'>] : []'C' extends 'A' | 'B' | 'C' ? ['C', ...Permutation<'A' | 'B'>] : [] 我们再展开第一行 Permutation<'B' | 'C'>: 'B' extends 'B' | 'C' ? ['B', ...Permutation<'C'>] : []'C' extends 'B' | 'C' ? ['C', ...Permutation<'B'>] : [] 再展开第一行的 Permutation<'C'>: 'C' extends 'C' ? ['C', ...Permutation<never>] : [] 此时已经完成全排列,但我们还要处理一下 Permutation<never>,使其返回 [] 并终止递归。那为什么要用 [T] extends [never] 而不是 T extends never 呢? 如果我们用 T extends never 代替本题答案,输出结果是 never,原因如下: type X = never extends never ? 1 : 0 // 1type Custom<T> = T extends never ? 1 : 0type Y = Custom<never> // never 理论上相同的代码,为什么用泛型后输出就变成 never 了呢?原因是 TS 在做 T extends never ? 时,会对联合类型进行分配,此时有一个特例,即当 T = never 时,会跳过分配直接返回 T 本身,所以三元判断代码实际上没有执行。 [T] extends [never] 这种写法可以避免 TS 对联合类型进行分配,继而绕过上面的问题。 Length of String实现 LengthOfString<T> 返回字符串 T 的长度: LengthOfString<'abc'> // 3 破解此题你需要知道一个前提,即 TS 访问数组类型的 [length] 属性可以拿到长度值: ['a','b','c']['length'] // 3 也就是说,我们需要把 'abc' 转化为 ['a', 'b', 'c']。 第二个需要了解的前置知识是,用 infer 指代字符串时,第一个指代指向第一个字母,第二个指向其余所有字母: 'abc' extends `${infer S}${infer E}` ? S : never // 'a' 那转换后的数组存在哪呢?类似 js,我们弄第二个默认值泛型存储即可: // 本题答案type LengthOfString<S, N extends any[] = []> = S extends `${infer S}${infer E}` ? LengthOfString<E, [...N, S]> : N['length'] 思路就是,每次把字符串第一个字母拿出来放到数组 N 的第一项,直到字符串被取完,直接拿此时的数组长度。 Flatten实现类型 Flatten: type flatten = Flatten<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, 5] 此题一看就需要递归: // 本题答案type Flatten<T extends any[], Result extends any[] = []> = T extends [infer Start, ...infer Rest] ? ( Start extends any[] ? Flatten<Rest, [...Result, ...Flatten<Start>]> : Flatten<Rest, [...Result, Start]>) : Result 这道题看似答案复杂,其实还是用到了上一题的套路:递归时如果需要存储临时变量,用泛型默认值来存储。 本题我们就用 Result 这个泛型存储打平后的结果,每次拿到数组第一个值,如果第一个值不是数组,则直接存进去继续递归,此时 T 自然是剩余的 Rest;如果第一个值是数组,则将其打平,此时有个精彩的地方,即 ...Start 打平后依然可能是数组,比如 [[5]] 就套了两层,能不能想到 ...Flatten<Start> 继续复用递归是解题关键。 Append to object实现 AppendToObject: type Test = { id: '1' }type Result = AppendToObject<Test, 'value', 4> // expected to be { id: '1', value: 4 } 结合之前刷题的经验,该题解法很简单,注意 K in Key 可以给对象拓展某些指定 Key: // 本题答案type AppendToObject<Obj, Key extends string, Value> = Obj & { [K in Key]: Value} 当然也有不用 Obj & 的写法,即把原始对象和新 Key, Value 合在一起的描述方式: // 本题答案type AppendToObject<T, U extends number | string | symbol, V> = { [key in (keyof T) | U]: key extends U ? V : T[Exclude<key, U>]} Absolute实现 Absolute 将数字转成绝对值: type Test = -100;type Result = Absolute<Test>; // expected to be "100" 该题重点是把数字转成绝对值字符串,所以我们可以用字符串的方式进行匹配: // 本题答案type Absolute<T extends number> = `${T}` extends `-${infer R}` ? R : `${T}` 为什么不用 T extends 来判断呢?因为 T 是数字,这样写无法匹配符号的字符串描述。 String to Union实现 StringToUnion 将字符串转换为联合类型: type Test = '123';type Result = StringToUnion<Test>; // expected to be "1" | "2" | "3" 还是老套路,用一个新的泛型存储答案,递归即可: // 本题答案type StringToUnion<T, P = never> = T extends `${infer F}${infer R}` ? StringToUnion<R, P | F> : P 当然也可以不依托泛型存储答案,因为该题比较特殊,可以直接用 |: // 本题答案type StringToUnion<T> = T extends `${infer F}${infer R}` ? F | StringToUnion<R> : never Merge实现 Merge 合并两个对象,冲突时后者优先: type foo = { name: string; age: string;}type coo = { age: number; sex: string}type Result = Merge<foo,coo>; // expected to be {name: string, age: number, sex: string} 这道题答案甚至是之前题目的解题步骤,即用一个对象描述 + keyof 的思维: // 本题答案type Merge<A extends object, B extends object> = { [K in keyof A | keyof B] : K extends keyof B ? B[K] : ( K extends keyof A ? A[K] : never )} 只要知道 in keyof 支持元组,值部分用 extends 进行区分即可,很简单。 KebabCase实现驼峰转横线的函数 KebabCase: KebabCase<'FooBarBaz'> // 'foo-bar-baz' 还是老套路,用第二个参数存储结果,用递归的方式遍历字符串,遇到大写字母就转成小写并添加上 -,最后把开头的 - 干掉就行了: // 本题答案type KebabCase<S, U extends string = ''> = S extends `${infer F}${infer R}` ? ( Lowercase<F> extends F ? KebabCase<R, `${U}${F}`> : KebabCase<R, `${U}-${Lowercase<F>}`>) : RemoveFirstHyphen<U>type RemoveFirstHyphen<S> = S extends `-${infer Rest}` ? Rest : S 分开写就非常容易懂了,首先 KebabCase 每次递归取第一个字符,如何判断这个字符是大写呢?只要小写不等于原始值就是大写,所以判断条件就是 Lowercase<F> extends F 的 false 分支。然后再写个函数 RemoveFirstHyphen 把字符串第一个 - 干掉即可。 总结TS 是一门编程语言,而不是一门简单的描述或者修饰符,很多复杂类型问题要动用逻辑思维来实现,而不是查查语法就能简单实现。 讨论地址是:精读《Permutation, Flatten, Absolute…》· Issue ##426 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《MinusOne, PickByType, StartsWith","path":"/wiki/WebWeekly/TS 类型体操/《MinusOne, PickByType, StartsWith.html","content":"当前期刊数: 248 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 33~40 题。 精读MinusOne用 TS 实现 MinusOne 将一个数字减一: type Zero = MinusOne<1> // 0type FiftyFour = MinusOne<55> // 54 TS 没有 “普通” 的运算能力,但涉及数字却有一条生路,即 TS 可通过 ['length'] 访问数组长度,几乎所有数字计算都是通过它推导出来的。 这道题,我们只要构造一个长度为泛型长度 -1 的数组,获取其 ['length'] 属性即可,但该方案有一个硬伤,无法计算负值,因为数组长度不可能小于 0: // 本题答案type MinusOne<T extends number, arr extends any[] = []> = [ ...arr, '']['length'] extends T ? arr['length'] : MinusOne<T, [...arr, '']> 该方案的原理不是原数字 -1,而是从 0 开始不断加 1,一直加到目标数字减一。但该方案没有通过 MinusOne<1101> 测试,因为递归 1000 次就是上限了。 还有一种能打破递归的思路,即: type Count = ['1', '1', '1'] extends [...infer T, '1'] ? T['length'] : 0 // 2 也就是把减一转化为 extends [...infer T, '1'],这样数组 T 的长度刚好等于答案。那么难点就变成了如何根据传入的数字构造一个等长的数组?即问题变成了如何实现 CountTo<N> 生成一个长度为 N,每项均为 1 的数组,而且生成数组的递归效率也要高,否则还会遇到递归上限的问题。 网上有一个神仙解法,笔者自己想不到,但是可以拿出来给大家分析下: type CountTo< T extends string, Count extends 1[] = []> = T extends `${infer First}${infer Rest}` ? CountTo<Rest, N<Count>[keyof N & First]> : Counttype N<T extends 1[] = []> = { '0': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T] '1': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1] '2': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1] '3': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1] '4': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1] '5': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1 ] '6': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1 ] '7': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1, 1 ] '8': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1, 1, 1 ] '9': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1, 1, 1, 1 ]} 也就是该方法可以高效的实现 CountTo<'1000'> 产生长度为 1000,每项为 1 的数组,更具体一点,只需要遍历 <T> 字符串长度次数,比如 1000 只要递归 4 次,而 10000 也只需要递归 5 次。 CountTo 函数体的逻辑是,如果字符串 T 非空,就拆为第一个字符 First 与剩余字符 Rest,然后拿剩余字符递归,但是把 First 一次性生成到了正确的长度。最核心的逻辑就是函数 N<T> 了,它做的其实是把 T 的数组长度放大 10 倍再追加上当前数量的 1 在数组末尾。 而 keyof N & First 也是神来之笔,此处本意就是访问 First 下标,但 TS 不知道它是一个安全可访问的下标,而 keyof N & First 最终值还是 First,也可以被 TS 安全识别为下标。 拿 CountTo<'123'> 举例: 第一次执行 First='1'、Rest='23': CountTo<'23', N<[]>['1']>// 展开时,...[] 还是 [],所以最终结果为 ['1'] 第二次执行 First='2'、Rest='3' CountTo<'3', N<['1']>['2']>// 展开时,...[] 有 10 个,所以 ['1'] 变成了 10 个 1,追加上 N 映射表里的 2 个 1,现在一共有 12 个 1 第三次执行 First='3'、Rest='' CountTo<'', N<['1', ...共 12 个]>['3']>// 展开时,...[] 有 10 个,所以 12 个 1 变成 120 个,加上映射表中 3,一共有 123 个 1 总结一下,就是将数字 T 变成字符串,从最左侧开始获取,每次都把已经积累的数组数量乘以 10 再追加上当前值数量的 1,实现递归次数极大降低。 PickByType实现 PickByType<P, Q>,将对象 P 中类型为 Q 的 key 保留: type OnlyBoolean = PickByType< { name: string count: number isReadonly: boolean isEnable: boolean }, boolean> // { isReadonly: boolean; isEnable: boolean; } 本题很简单,因为之前碰到 Remove Index Signature 题目时,我们用了 K in keyof P as xxx 来对 Key 位置进行进一步判断,所以只要 P[K] extends Q 就保留,否则返回 never 即可: // 本题答案type PickByType<P, Q> = { [K in keyof P as P[K] extends Q ? K : never]: P[K]} StartsWith实现 StartsWith<T, U> 判断字符串 T 是否以 U 开头: type a = StartsWith<'abc', 'ac'> // expected to be falsetype b = StartsWith<'abc', 'ab'> // expected to be truetype c = StartsWith<'abc', 'abcd'> // expected to be false 本题也比较简单,用递归 + 首字符判等即可破解: // 本题答案type StartsWith< T extends string, U extends string> = U extends `${infer US}${infer UE}` ? T extends `${infer TS}${infer TE}` ? TS extends US ? StartsWith<TE, UE> : false : false : true 思路是: U 如果为空字符串则匹配一切场景,直接返回 true;否则 U 可以拆为以 US(U Start) 开头、UE(U End) 的字符串进行后续判定。 接着上面的判定,如果 T 为空字符串则不可能被 U 匹配,直接返回 false;否则 T 可以拆为以 TS(T Start) 开头、TE(T End) 的字符串进行后续判定。 接着上面的判定,如果 TS extends US 说明此次首字符匹配了,则递归匹配剩余字符 StartsWith<TE, UE>,如果首字符不匹配提前返回 false。 笔者看了一些答案后发现还有一种降维打击方案: // 本题答案type StartsWith<T extends string, U extends string> = T extends `${U}${string}` ? true : false 没想到还可以用 ${string} 匹配任意字符串进行 extends 判定,有点正则的意思了。当然 ${string} 也可以被 ${infer X} 代替,只是拿到的 X 不需要再用到了: // 本题答案type StartsWith<T extends string, U extends string> = T extends `${U}${infer X}` ? true : false 笔者还试了下面的答案在后缀 Diff 部分为 string like number 时也正确: // 本题答案type StartsWith<T extends string, U extends string> = T extends `${U}${number}` ? true : false 说明字符串模板最通用的指代是 ${infer X} 或 ${string},如果要匹配特定的数字类字符串也可以混用 ${number}。 EndsWith实现 EndsWith<T, U> 判断字符串 T 是否以 U 结尾: type a = EndsWith<'abc', 'bc'> // expected to be truetype b = EndsWith<'abc', 'abc'> // expected to be truetype c = EndsWith<'abc', 'd'> // expected to be false 有了上题的经验,这道题不要太简单: // 本题答案type EndsWith<T extends string, U extends string> = T extends `${string}${U}` ? true : false 这可以看出 TS 的技巧掌握了就非常简单,但不知道就几乎无解,或者用很笨的递归来解决。 PartialByKeys实现 PartialByKeys<T, K>,使 K 匹配的 Key 变成可选的定义,如果不传 K 效果与 Partial<T> 一样: interface User { name: string age: number address: string}type UserPartialName = PartialByKeys<User, 'name'> // { name?:string; age:number; address:string } 看到题目要求是不传参数时和 Partial<T> 行为一直,就应该能想到应该这么起头写个默认值: type PartialByKeys<T, K = keyof T> = {} 我们得用可选与不可选分别描述两个对象拼起来,因为 TS 不支持同一个对象下用两个 keyof 描述,所以只能写成两个对象: type PartialByKeys<T, K = keyof T> = { [Q in keyof T as Q extends K ? Q : never]?: T[Q]} & { [Q in keyof T as Q extends K ? never : Q]: T[Q]} 但不匹配测试用例,原因是最终类型正确,但因为分成了两个对象合并无法匹配成一个对象,所以需要用一点点 Magic 行为合并: // 本题答案type PartialByKeys<T, K = keyof T> = { [Q in keyof T as Q extends K ? Q : never]?: T[Q]} & { [Q in keyof T as Q extends K ? never : Q]: T[Q]} extends infer R ? { [Q in keyof R]: R[Q] } : never 将一个对象 extends infer R 再重新展开一遍看似无意义,但确实让类型上合并成了一个对象,很有意思。我们也可以将其抽成一个函数 Merge<T> 来使用。 本题还有一个函数组合的答案: // 本题答案type Merge<T> = { [K in keyof T]: T[K]}type PartialByKeys<T, K extends PropertyKey = keyof T> = Merge< Partial<T> & Omit<T, K>> 利用 Partial & Omit 来合并对象。 因为 Omit<T, K> 中 K 有来自于 keyof T 的限制,而测试用例又包含 unknown 这种不存在的 Key 值,此时可以用 extends PropertyKey 处理此场景。 RequiredByKeys实现 RequiredByKeys<T, K>,使 K 匹配的 Key 变成必选的定义,如果不传 K 效果与 Required<T> 一样: interface User { name?: string age?: number address?: string}type UserRequiredName = RequiredByKeys<User, 'name'> // { name: string; age?: number; address?: string } 和上题正好相反,答案也呼之欲出了: type Merge<T> = { [K in keyof T]: T[K]}type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge< Required<T> & Omit<T, K>> 等等,一个测试用例都没过,为啥呢?仔细想想发现确实暗藏玄机: Merge<{ a: number} & { a?: number}> // 结果是 { a: number } 也就是同一个 Key 可选与必选同时存在时,合并结果是必选。上一题因为将必选 Omit 掉了,所以可选不会被必选覆盖,但本题 Merge<Required<T> & Omit<T, K>>,前面的 Required<T> 必选优先级最高,后面的 Omit<T, K> 虽然本身逻辑没错,但无法把必选覆盖为可选,因此测试用例都挂了。 解法就是破解这一特征,用原始对象 & 仅包含 K 的必选对象,使必选覆盖前面的可选 Key。后者可以 Pick 出来: type Merge<T> = { [K in keyof T]: T[K]}type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge< T & Required<Pick<T, K>>> 这样就剩一个单测没通过了: Expect<Equal<RequiredByKeys<User, 'name' | 'unknown'>, UserRequiredName>> 我们还要兼容 Pick 访问不存在的 Key,用 extends 躲避一下即可: // 本题答案type Merge<T> = { [K in keyof T]: T[K]}type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge< T & Required<Pick<T, K extends keyof T ? K : never>>> Mutable实现 Mutable<T>,将对象 T 的所有 Key 变得可写: interface Todo { readonly title: string readonly description: string readonly completed: boolean}type MutableTodo = Mutable<Todo> // { title: string; description: string; completed: boolean; } 把对象从可写变成不可写: type Readonly<T> = { readonly [K in keyof T]: T[K]} 从不可写改成可写也简单,主要看你是否记住了这个语法:-readonly: // 本题答案type Mutable<T extends object> = { -readonly [K in keyof T]: T[K]} OmitByType实现 OmitByType<T, U> 根据类型 U 排除 T 中的 Key: type OmitBoolean = OmitByType< { name: string count: number isReadonly: boolean isEnable: boolean }, boolean> // { name: string; count: number } 本题和 PickByType 正好反过来,只要把 extends 后内容对调一下即可: // 本题答案type OmitByType<T, U> = { [K in keyof T as T[K] extends U ? never : K]: T[K]} 总结本周的题目除了 MinusOne 那道神仙解法比较难以外,其他的都比较常见,其中 Merge 函数的妙用需要领悟一下。 讨论地址是:精读《MinusOne, PickByType, StartsWith…》· Issue ##430 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Promise","path":"/wiki/WebWeekly/TS 类型体操/《Promise.html","content":"当前期刊数: 245 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 9~16 题。 精读Promise.all实现函数 PromiseAll,输入 PromiseLike,输出 Promise<T>,其中 T 是输入的解析结果: const promiseAllTest1 = PromiseAll([1, 2, 3] as const)const promiseAllTest2 = PromiseAll([1, 2, Promise.resolve(3)] as const)const promiseAllTest3 = PromiseAll([1, 2, Promise.resolve(3)]) 该题难点不在 Promise 如何处理,而是在于 { [K in keyof T]: T[K] } 在 TS 同样适用于描述数组,这是 JS 选手无论如何也想不到的: // 本题答案declare function PromiseAll<T>(values: T): Promise<{ [K in keyof T]: T[K] extends Promise<infer U> ? U : T[K]}> 不知道是 bug 还是 feature,TS 的 { [K in keyof T]: T[K] } 能同时兼容元组、数组与对象类型。 Type Lookup实现 LookUp<T, P>,从联合类型 T 中查找 type 为 P 的项并返回: interface Cat { type: 'cat' breeds: 'Abyssinian' | 'Shorthair' | 'Curl' | 'Bengal'}interface Dog { type: 'dog' breeds: 'Hound' | 'Brittany' | 'Bulldog' | 'Boxer' color: 'brown' | 'white' | 'black'}type MyDog = LookUp<Cat | Dog, 'dog'> // expected to be `Dog` 该题比较简单,只要学会灵活使用 infer 与 extends 即可: // 本题答案type LookUp<T, P> = T extends { type: infer U} ? ( U extends P ? T : never) : never 联合类型的判断是一个个来的,所以我们只要针对每一个单独写判断就行了。上面的解法中,我们先利用 extend + infer 锁定 T 的类型是包含 type key 的对象,且将 infer U 指向了 type,所以在内部再利用三元运算符判断 U extends P ? 就能将 type 命中的类型挑出来。 笔者翻了下答案,发现还有一种更高级的解法: // 本题答案type LookUp<U extends { type: any }, T extends U['type']> = U extends { type: T } ? U : never 该解法更简洁,更完备: 在泛型处利用 extends { type: any }、extends U['type'] 直接锁定入参类型,让错误校验更早发生。 T extends U['type'] 精确缩小了参数 T 范围,可以学到的是,之前定义的泛型 U 可以直接被后面的新泛型使用。 U extends { type: T } 是一种新的思考角度。在第一个答案中,我们的思维方式是 “找到对象中 type 值进行判断”,而第二个答案直接用整个对象结构 { type: T } 判断,是更纯粹的 TS 思维。 Trim Left实现 TrimLeft<T>,将字符串左侧空格清空: type trimed = TrimLeft<' Hello World '> // expected to be 'Hello World ' 在 TS 处理这类问题只能用递归,不能用正则。比较容易想到的是下面的写法: // 本题答案type TrimLeft<T extends string> = T extends ` ${infer R}` ? TrimLeft<R> : T 即如果字符串前面包含空格,就把空格去了继续递归,否则返回字符串本身。掌握该题的关键是 infer 也能用在字符串内进行推导。 Trim实现 Trim<T>,将字符串左右两侧空格清空: type trimmed = Trim<' Hello World '> // expected to be 'Hello World' 这个问题简单的解法是,左右都 Trim 一下: // 本题答案type Trim<T extends string> = TrimLeft<TrimRight<T>>type TrimLeft<T extends string> = T extends ` ${infer R}` ? TrimLeft<R> : Ttype TrimRight<T extends string> = T extends `${infer R} ` ? TrimRight<R> : T 这个成本很低,性能也不差,因为单写 TrimLeft 与 TrimRight 都很简单。 如果不采用先 Left 后 Right 的做法,想要一次性完成,就要有一些 TS 思维了。比较笨的思路是 “如果左边有空格就切分左边,或者右边有空格就切分右边”,最后写出来一个复杂的三元表达式。比较优秀的思路是利用 TS 联合类型: // 本题答案type Trim<T extends string> = T extends ` ${infer R}` | `${infer R} ` ? Trim<R> : T extends 后面还可以跟联合类型,这样任意一个匹配都会走到 Trim<R> 递归里。这就是比较难说清楚的 TS 思维,如果没有它,你只能想到三元表达式,但一旦理解了联合类型还可以在 extends 里这么用,TS 帮你做了 N 元表达式的能力,那么写出来的代码就会非常清秀。 Capitalize实现 Capitalize<T> 将字符串第一个字母大写: type capitalized = Capitalize<'hello world'> // expected to be 'Hello world' 如果这是一道 JS 题那就简单到爆,可题目是 TS 的,我们需要再度切换为 TS 思维。 首先要知道利用基础函数 Uppercase 将单个字母转化为大写,然后配合 infer 就不用多说了: type MyCapitalize<T extends string> = T extends `${infer F}${infer U}` ? `${Uppercase<F>}${U}` : T Replace实现 TS 版函数 Replace<S, From, To>,将字符串 From 替换为 To: type replaced = Replace<'types are fun!', 'fun', 'awesome'> // expected to be 'types are awesome!' 把 From 夹在字符串中间,前后用两个 infer 推导,最后输出时前后不变,把 From 换成 To 就行了: // 本题答案type Replace<S extends string, From extends string, To extends string,> = S extends `${infer A}${From}${infer B}` ? `${A}${To}${B}` : S ReplaceAll实现 ReplaceAll<S, From, To>,将字符串 From 替换为 To: type replaced = ReplaceAll<'t y p e s', ' ', ''> // expected to be 'types' 该题与上题不同之处在于替换全部,解法肯定是递归,关键是何时递归的判断条件是什么。经过一番思考,如果 infer From 能匹配到不就说明还可以递归吗?所以加一层三元判断 From extends '' 即可: // 本题答案type ReplaceAll<S extends string, From extends string, To extends string> = From extends '' ? S : ( S extends `${infer A}${From}${infer B}` ? ( From extends '' ? `${A}${To}${B}` : `${A}${To}${ReplaceAll<B, From, To>}` ) : S ) 补充一些细节: 如果替换文本为空字符串需要跳过,否则会匹配第二个任意字符。 为了防止替换完后结果可以再度匹配,对递归形式做一下调整,下次递归直接从剩余部分开始。 Append Argument实现类型 AppendArgument<F, E>,将函数参数拓展一个: type Fn = (a: number, b: string) => numbertype Result = AppendArgument<Fn, boolean> // expected be (a: number, b: string, x: boolean) => number 该题很简单,用 infer 就行了: // 本题答案type AppendArgument<F, E> = F extends (...args: infer T) => infer R ? (...args: [...T, E]) => R : F 总结这几道题都比较简单,主要考察对 infer 和递归的熟练使用。 讨论地址是:精读《Promise.all, Replace, Type Lookup…》· Issue ##425 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Pick, Awaited, If","path":"/wiki/WebWeekly/TS 类型体操/《Pick, Awaited, If.html","content":"当前期刊数: 243 TS 强类型非常好用,但在实际运用中,免不了遇到一些难以描述,反复看官方文档也解决不了的问题,至今为止也没有任何一篇文档,或者一套教材可以解决所有犄角旮旯的类型问题。为什么会这样呢?因为 TS 并不是简单的注释器,而是一门图灵完备的语言,所以很多问题的解决方法藏在基础能力里,但你学会了基础能力又不一定能想到这么用。 解决该问题的最好办法就是多练,通过实际案例不断刺激你的大脑,让你养成 TS 思维习惯。所以话不多说,我们今天从 type-challenges 的 Easy 难度题目开始吧。 精读Pick手动实现内置 Pick<T, K> 函数,返回一个新的类型,从对象 T 中抽取类型 K: interface Todo { title: string description: string completed: boolean}type TodoPreview = MyPick<Todo, 'title' | 'completed'>const todo: TodoPreview = { title: 'Clean room', completed: false,} 结合例子更容易看明白,也就是 K 是一个字符串,我们需要返回一个新类型,仅保留 K 定义的 Key。 第一个难点在如何限制 K 的取值,比如传入 T 中不存在的值就要报错。这个考察的是硬知识,只要你知道 A extends keyof B 这个语法就能联想到。 第二个难点在于如何生成一个仅包含 K 定义 Key 的类型,你首先要知道有 { [A in keyof B]: B[A] } 这个硬知识,这样可以重新组合一个对象: // 代码 1type Foo<T> = { [P in keyof T]: T[P]} 只懂这个语法不一定能想出思路,原因是你要打破对 TS 的刻板理解,[K in keyof T] 不是一个固定模板,其中 keyof T 只是一个指代变量,它可以被换掉,如果你换掉成另一个范围的变量,那么这个对象的 Key 值范围就变了,这正好契合本题的 K: // 代码 2(本题答案)type MyPick<T, K extends keyof T> = { [P in K]: T[P]} 这个题目别看知道答案后简单,回顾下还是有收获的。对比上面两个代码例子,你会发现,只不过是把代码 1 的 keyof T 从对象描述中提到了泛型定义里而已,所以功能上没有任何变化,但因为泛型可以由用户传入,所以代码 1 的 P in keyof T 因为没有泛型支撑,这里推导出来的就是 T 的所有 Keys,而代码 2 虽然把代码挪到了泛型,但因为用的是 extends 描述,所以表示 P 的类型被约束到了 T 的 Keys,至于具体是什么,得看用户代码怎么传。 所以其实放到泛型里的 K 是没有默认值的,而写到对象里作为推导值就有了默认值。泛型里给默认值的方式如下: // 代码 3type MyPick<T, K extends keyof T = keyof T> = { [P in K]: T[P]} 也就是说,这样 MyPick<Todo> 就也可以正确工作并原封不动返回 Todo 类型,也就是说,代码 3 在不传第二个参数时,与代码 1 的功能完全一样。仔细琢磨一下共同点与区别,为什么代码 3 可以做到和代码 1 功能一样,又有更强的拓展性,你对 TS 泛型的实战理解就上了一个台阶。 Readonly手动实现内置 Readonly<T> 函数,将对象所有属性设置为只读: interface Todo { title: string description: string}const todo: MyReadonly<Todo> = { title: "Hey", description: "foobar"}todo.title = "Hello" // Error: cannot reassign a readonly propertytodo.description = "barFoo" // Error: cannot reassign a readonly property 这道题反而比第一题简单,只要我们用 { [A in keyof B]: B[A] } 重新声明对象,并在每个 Key 前面加上 readonly 修饰即可: // 本题答案type MyReadonly<T> = { readonly [K in keyof T]: T[K]} 根据这个特性我们可以做很多延伸改造,比如将对象所有 Key 都设定为可选: type Optional<T> = { [K in keyof T]?: T[K]} { [A in keyof B]: B[A] } 给了我们描述每一个 Key 属性细节的机会,限制我们发挥的只有想象力。 First Of Array实现类型 First<T>,取到数组第一项的类型: type arr1 = ['a', 'b', 'c']type arr2 = [3, 2, 1]type head1 = First<arr1> // expected to be 'a'type head2 = First<arr2> // expected to be 3 这题比较简单,很容易想到的答案: // 本题答案type First<T extends any[]> = T[0] 但在写这个答案时,有 10% 脑细胞提醒我没有判断边界情况,果然看了下答案,有空数组的情况要考虑,空数组时返回类型 never 而不是 undefined 会更好,下面几种写法都是答案: type First<T extends any[]> = T extends [] ? never : T[0]type First<T extends any[]> = T['length'] extends 0 ? never : T[0]type First<T> = T extends [infer P, ...infer Rest] ? P : never 第一种写法通过 extends [] 判断 T 是否为空数组,是的话返回 never。 第二种写法通过长度为 0 判断空数组,此时需要理解两点:1. 可以通过 T['length'] 让 TS 访问到值长度(类型的),2. extends 0 表示是否匹配 0,即 extends 除了匹配类型,还能直接匹配值。 第三种写法是最省心的,但也使用了 infer 关键字,即使你充分知道 infer 怎么用(精读《Typescript infer 关键字》),也很难想到它。用 infer 的理由是:该场景存在边界情况,最便于理解的写法是 “如果 T 形如 <P, ...>” 那我就返回类型 P,否则返回 never”,这句话用 TS 描述就是:T extends [infer P, ...infer Rest] ? P : never。 Length of Tuple实现类型 Length<T> 获取元组长度: type tesla = ['tesla', 'model 3', 'model X', 'model Y']type spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT']type teslaLength = Length<tesla> // expected 4type spaceXLength = Length<spaceX> // expected 5 经过上一题的学习,很容易想到这个答案: type Length<T extends any[]> = T['length'] 对 TS 来说,元组和数组都是数组,但元组对 TS 来说可以观测其长度,T['length'] 对元组来说返回的是具体值,而对数组来说返回的是 number。 Exclude实现类型 Exclude<T, U>,返回 T 中不存在于 U 的部分。该功能主要用在联合类型场景,所以我们直接用 extends 判断就行了: // 本题答案type Exclude<T, U> = T extends U ? never : T 实际运行效果: type C = Exclude<'a' | 'b', 'a' | 'c'> // 'b' 看上去有点不那么好理解,这是因为 TS 对联合类型的执行是分配律的,即: Exclude<'a' | 'b', 'a' | 'c'>// 等价于Exclude<'a', 'a' | 'c'> | Exclude<'b', 'a' | 'c'> Awaited实现类型 Awaited,比如从 Promise<ExampleType> 拿到 ExampleType。 首先 TS 永远不会执行代码,所以脑子里不要有 “await 得等一下才知道结果” 的念头。该题关键就是从 Promise<T> 中抽取类型 T,很适合用 infer 做: type MyAwaited<T> = T extends Promise<infer U> ? U : never 然而这个答案还不够标准,标准答案考虑了嵌套 Promise 的场景: // 该题答案type MyAwaited<T extends Promise<unknown>> = T extends Promise<infer P> ? P extends Promise<unknown> ? MyAwaited<P> : P : never 如果 Promise<P> 取到的 P 还形如 Promise<unknown>,就递归调用自己 MyAwaited<P>。这里提到了递归,也就是 TS 类型处理可以是递归的,所以才有了后面版本做尾递归优化。 If实现类型 If<Condition, True, False>,当 C 为 true 时返回 T,否则返回 F: type A = If<true, 'a', 'b'> // expected to be 'a'type B = If<false, 'a', 'b'> // expected to be 'b' 之前有提过,extends 还可以用来判定值,所以果断用 extends true 判断是否命中了 true 即可: // 本题答案type If<C, T, F> = C extends true ? T : F Concat用类型系统实现 Concat<P, Q>,将两个数组类型连起来: type Result = Concat<[1], [2]> // expected to be [1, 2] 由于 TS 支持数组解构语法,所以可以大胆的尝试这么写: type Concat<P extends any[], Q extends any[]> = [...P, ...Q] 考虑到 Concat 函数应该也能接收非数组类型,所以做一个判断,为了方便书写,把 extends 从泛型定义位置挪到 TS 类型推断的运行时: // 本题答案type Concat<P, Q> = [ ...P extends any[] ? P : [P], ...Q extends any[] ? Q : [Q],] 解决这题需要信念,相信 TS 可以像 JS 一样写逻辑。这些能力都是版本升级时渐进式提供的,所以需要不断阅读最新 TS 特性,快速将其理解为固化知识,其实还是有一定难度的。 Includes用类型系统实现 Includes<T, K> 函数: type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'> // expected to be `false` 由于之前的经验,很容易做下面的联想: // 如果题目要求是这样type isPillarMen = Includes<'Kars' | 'Esidisi' | 'Wamuu' | 'Santana', 'Dio'>// 那我就能用 extends 轻松解决了type Includes<T, K> = K extends T ? true : false 可惜第一个输入是数组类型,extends 可不支持判定 “数组包含” 逻辑,此时要了解一个新知识点,即 TS 判断中的 [number] 下标。不仅这道题,以后很多困难题都需要它作为基础知识。 [number] 下标表示任意一项,而 extends T[number] 就可以实现数组包含的判定,因此下面的解法是有效的: type Includes<T extends any[], K> = K extends T[number] ? true : false 但翻答案后发现这并不是标准答案,还真找到一个反例: type Includes<T extends any[], K> = K extends T[number] ? true : falsetype isPillarMen = Includes<[boolean], false> // true 原因很简单,true、false 都继承自 boolean,所以 extends 判断的界限太宽了,题目要求的是精确值匹配,故上面的答案理论上是错的。 标准答案是每次判断数组第一项,并递归(讲真觉得这不是 easy 题),分别有两个难点。 第一如何写 Equal 函数?比较流行的方案是这个: type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false 关于如何写 Equal 函数还引发了一次 小讨论,上面的代码构造了两个函数,这两个函数内的 T 属于 deferred(延迟)判断的类型,该类型判断依赖于内部 isTypeIdenticalTo 函数完成判断。 有了 Equal 后就简单了,我们用解构 + infer + 递归的方式做就可以了: // 本题答案type Includes<T extends any[], K> = T extends [infer F, ...infer Rest] ? Equal<F, K> extends true ? true : Includes<Rest, K> : false 每次取数组第一个值判断 Equal,如果不匹配则拿剩余项递归判断。这个函数组合了不少 TS 知识,比如: 递归 解构 infer extends true 可以发现,就为了解决 true extends boolean 为 true 的问题,我们绕了一大圈使用了更复杂的方式来实现,这在 TS 体操中也算是常态,解决问题需要耐心。 Push实现 Push<T, K> 函数: type Result = Push<[1, 2], '3'> // [1, 2, '3'] 这道题真的很简单,用解构就行了: // 本题答案type Push<T extends any[], K> = [...T, K] 可见,想要轻松解决一个 TS 简单问题,首先你需要能解决一些困难问题 😁。 Unshift实现 Unshift<T, K> 函数: type Result = Unshift<[1, 2], 0> // [0, 1, 2,] 在 Push 基础上改下顺序就行了: // 本题答案type Unshift<T extends any[], K> = [K, ...T] Parameters实现内置函数 Parameters: Parameters 可以拿到函数的参数类型,直接用 infer 实现即可,也比较简单: type Parameters<T> = T extends (...args: infer P) => any ? P : [] infer 可以很方便从任何具体的位置取值,属于典型难懂易用的语法。 总结学会 TS 基础语法后,活用才是关键。 讨论地址是:精读《Pick, Awaited, If…》· Issue ##422 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Trim Right, Without, Trunc","path":"/wiki/WebWeekly/TS 类型体操/《Trim Right, Without, Trunc.html","content":"当前期刊数: 251 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 57~62 题。 精读Trim Right实现 TrimRight 删除右侧空格: type Trimed = TrimRight<' Hello World '> // expected to be ' Hello World' 用 infer 找出空格前的字符串递归一下即可: type TrimRight<S extends string> = S extends `${infer R}${' '}` ? TrimRight<R> : S 再补上测试用例的边界情况, 与 \\t 后就是完整答案了: // 本题答案type TrimRight<S extends string> = S extends `${infer R}${' ' | ' ' | '\\t'}` ? TrimRight<R> : S Without实现 Without<T, U>,从数组 T 中移除 U 中元素: type Res = Without<[1, 2], 1> // expected to be [2]type Res1 = Without<[1, 2, 4, 1, 5], [1, 2]> // expected to be [4, 5]type Res2 = Without<[2, 3, 2, 3, 2, 3, 2, 3], [2, 3]> // expected to be [] 该题最难的点在于,参数 U 可能是字符串或字符串数组,我们要判断是否存在只能用 extends,这样就存在两个问题: 既是字符串又是数组如何判断,合在一起判断还是分开判断? [1] extends [1, 2] 为假,数组模式如何判断? 可以用数组转 Union 的方式解决该问题: type ToUnion<T> = T extends any[] ? T[number] : T 这样无论是数字还是数组,都会转成联合类型,而联合类型很方便判断 extends 包含关系: // 本题答案type Without<T, U> = T extends [infer H, ...infer R] ? H extends ToUnion<U> ? Without<R, U> : [H, ...Without<R, U>] : [] 每次取数组第一项,判断是否被 U 包含,是的话就丢弃(丢弃的动作是把 H 抛弃继续递归),否则包含(包含的动作是形成新的数组 [H, ...] 并把递归内容解构塞到后面)。 Trunc实现 Math.trunc 相同功能的函数 Trunc: type A = Trunc<12.34> // 12 如果入参是字符串就很简单了: type Trunc<T> = T extends `${infer H}.${infer R}` ? H : '' 如果不是字符串,将其转换为字符串即可: // 本题答案type Trunc<T extends string | number> = `${T}` extends `${infer H}.${infer R}` ? H : `${T}` IndexOf实现 IndexOf 寻找元素所在下标,找不到返回 -1: type Res = IndexOf<[1, 2, 3], 2>; // expected to be 1type Res1 = IndexOf<[2,6, 3,8,4,1,7, 3,9], 3>; // expected to be 2type Res2 = IndexOf<[0, 0, 0], 2>; // expected to be -1 需要用一个辅助变量存储命中下标,递归的方式一个个判断是否匹配: type IndexOf<T, U, Index extends any[] = []> = T extends [infer F, ...infer R] ? F extends U ? Index['length'] : IndexOf<R, U, [...Index, 0]> : -1 但没有通过测试用例 IndexOf<[string, 1, number, 'a'], number>,原因是 1 extends number 结果为真,所以我们要换成 Equal 函数判断相等: // 本题答案type IndexOf<T, U, Index extends any[] = []> = T extends [infer F, ...infer R] ? Equal<F, U> extends true ? Index['length'] : IndexOf<R, U, [...Index, 0]> : -1 Join实现 TS 版 Join<T, P>: type Res = Join<["a", "p", "p", "l", "e"], "-">; // expected to be 'a-p-p-l-e'type Res1 = Join<["Hello", "World"], " ">; // expected to be 'Hello World'type Res2 = Join<["2", "2", "2"], 1>; // expected to be '21212'type Res3 = Join<["o"], "u">; // expected to be 'o' 递归 T 每次拿第一个元素,再使用一个辅助字符串存储答案,拼接起来即可: // 本题答案type Join<T, U extends string | number> = T extends [infer F extends string, ...infer R extends string[]] ? R['length'] extends 0 ? F : `${F}${U}${Join<R, U>}` : '' 唯一要注意的是处理到最后一项时,不要再追加 U 了,可以通过 R['length'] extends 0 来判断。 LastIndexOf实现 LastIndexOf 寻找最后一个匹配的下标: type Res1 = LastIndexOf<[1, 2, 3, 2, 1], 2> // 3type Res2 = LastIndexOf<[0, 0, 0], 2> // -1 和 IndexOf 类似,从最后一个下标往前判断即可。需要注意的是,我们无法用常规办法把 Index 下标减一,但好在 R 数组长度可以代替当前下标: // 本题答案type LastIndexOf<T, U> = T extends [...infer R, infer L] ? Equal<L, U> extends true ? R['length'] : LastIndexOf<R, U> : -1 总结本周六道题都没有刷到新知识点,中等难题还剩 6 道,如果学到这里能有种索然无味的感觉,说明前面学习的很扎实。 讨论地址是:精读《Trim Right, Without, Trunc…》· Issue ##433 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Unique, MapTypes, Construct Tuple","path":"/wiki/WebWeekly/TS 类型体操/《Unique, MapTypes, Construct Tuple.html","content":"当前期刊数: 252 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 63~68 题。 精读Unique实现 Unique<T>,对 T 去重: type Res = Unique<[1, 1, 2, 2, 3, 3]> // expected to be [1, 2, 3]type Res1 = Unique<[1, 2, 3, 4, 4, 5, 6, 7]> // expected to be [1, 2, 3, 4, 5, 6, 7]type Res2 = Unique<[1, 'a', 2, 'b', 2, 'a']> // expected to be [1, "a", 2, "b"]type Res3 = Unique<[string, number, 1, 'a', 1, string, 2, 'b', 2, number]> // expected to be [string, number, 1, "a", 2, "b"]type Res4 = Unique<[unknown, unknown, any, any, never, never]> // expected to be [unknown, any, never] 去重需要不断递归产生去重后结果,因此需要一个辅助变量 R 配合,并把 T 用 infer 逐一拆解,判断第一个字符是否在结果数组里,如果不在就塞进去: type Unique<T, R extends any[] = []> = T extends [infer F, ...infer Rest] ? Includes<R, F> extends true ? Unique<Rest, R> : Unique<Rest, [...R, F]> : R 那么剩下的问题就是,如何判断一个对象是否出现在数组中,使用递归可以轻松完成: type Includes<Arr, Value> = Arr extends [infer F, ...infer Rest] ? Equal<F, Value> extends true ? true : Includes<Rest, Value> : false 每次取首项,如果等于 Value 直接返回 true,否则继续递归,如果数组递归结束(不构成 Arr extends [xxx] 的形式)说明递归完了还没有找到相等值,直接返回 false。 把这两个函数组合一下就能轻松解决本题: // 本题答案type Unique<T, R extends any[] = []> = T extends [infer F, ...infer Rest] ? Includes<R, F> extends true ? Unique<Rest, R> : Unique<Rest, [...R, F]> : Rtype Includes<Arr, Value> = Arr extends [infer F, ...infer Rest] ? Equal<F, Value> extends true ? true : Includes<Rest, Value> : false MapTypes实现 MapTypes<T, R>,根据对象 R 的描述来替换类型: type StringToNumber = { mapFrom: string; // value of key which value is string mapTo: number; // will be transformed for number}MapTypes<{iWillBeANumberOneDay: string}, StringToNumber> // gives { iWillBeANumberOneDay: number; } 因为要返回一个新对象,所以我们使用 { [K in keyof T]: ... } 的形式描述结果对象。然后就要对 Value 类型进行判断了,为了防止 never 的作用,我们包一层数组进行判断: type MapTypes<T, R extends { mapFrom: any; mapTo: any }> = { [K in keyof T]: [T[K]] extends [R['mapFrom']] ? R['mapTo'] : T[K]} 但这个解答还有一个 case 无法通过: MapTypes<{iWillBeNumberOrDate: string}, StringToDate | StringToNumber> // gives { iWillBeNumberOrDate: number | Date; } 我们需要考虑到 Union 分发机制以及每次都要重新匹配一次是否命中 mapFrom,因此需要抽一个函数: type Transform<R extends { mapFrom: any; mapTo: any }, T> = R extends any ? T extends R['mapFrom'] ? R['mapTo'] : never : never 为什么要 R extends any 看似无意义的写法呢?原因是 R 是联合类型,这样可以触发分发机制,让每一个类型独立判断。所以最终答案就是: // 本题答案type MapTypes<T, R extends { mapFrom: any; mapTo: any }> = { [K in keyof T]: [T[K]] extends [R['mapFrom']] ? Transform<R, T[K]> : T[K]}type Transform<R extends { mapFrom: any; mapTo: any }, T> = R extends any ? T extends R['mapFrom'] ? R['mapTo'] : never : never Construct Tuple生成指定长度的 Tuple: type result = ConstructTuple<2> // expect to be [unknown, unkonwn] 比较容易想到的办法是利用下标递归: type ConstructTuple< L extends number, I extends number[] = []> = I['length'] extends L ? [] : [unknown, ...ConstructTuple<L, [1, ...I]>] 但在如下测试用例会遇到递归长度过深的问题: ConstructTuple<999> // Type instantiation is excessively deep and possibly infinite 一种解法是利用 minusOne 提到的 CountTo 方法快捷生成指定长度数组,把 1 替换为 unknown 即可: // 本题答案type ConstructTuple<L extends number> = CountTo<`${L}`>type CountTo< T extends string, Count extends unknown[] = []> = T extends `${infer First}${infer Rest}` ? CountTo<Rest, N<Count>[keyof N & First]> : Counttype N<T extends unknown[] = []> = { '0': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T] '1': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown] '2': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown ] '3': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown ] '4': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown ] '5': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown ] '6': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown ] '7': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown, unknown ] '8': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown ] '9': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown ]} Number Range实现 NumberRange<T, P>,生成数字为从 T 到 P 的联合类型: type result = NumberRange<2, 9> // | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 以 NumberRange<2, 9> 为例,我们需要实现 2 到 9 的递增递归,因此需要一个数组长度从 2 递增到 9 的辅助变量 U,以及一个存储结果的辅助变量 R: type NumberRange<T, P, U extends any[] = 长度为 T 的数组, R> 所以我们先实现 LengthTo 函数,传入长度 N,返回一个长度为 N 的数组: type LengthTo<N extends number, R extends any[] = []> = R['length'] extends N ? R : LengthTo<N, [0, ...R]> 然后就是递归了: // 本题答案type NumberRange<T extends number, P extends number, U extends any[] = LengthTo<T>, R extends number = never> = U['length'] extends P ? ( R | U['length'] ) : ( NumberRange<T, P, [0, ...U], R | U['length']> ) R 的默认值为 never 非常重要,否则默认值为 any,最终类型就会被放大为 any。 Combination实现 Combination<T>: // expected to be `"foo" | "bar" | "baz" | "foo bar" | "foo bar baz" | "foo baz" | "foo baz bar" | "bar foo" | "bar foo baz" | "bar baz" | "bar baz foo" | "baz foo" | "baz foo bar" | "baz bar" | "baz bar foo"`type Keys = Combination<['foo', 'bar', 'baz']> 本题和 AllCombination 类似: type AllCombinations_ABC = AllCombinations<'ABC'>// should be '' | 'A' | 'B' | 'C' | 'AB' | 'AC' | 'BA' | 'BC' | 'CA' | 'CB' | 'ABC' | 'ACB' | 'BAC' | 'BCA' | 'CAB' | 'CBA' 还记得这题吗?我们要将字符串变成联合类型: type StrToUnion<S> = S extends `${infer F}${infer R}` ? F | StrToUnion<R> : never 而本题 Combination 更简单,把数组转换为联合类型只需要 T[number]。所以本题第一种组合解法是,将 AllCombinations 稍微改造下,再利用 Exclude 和 TrimRight 删除多余的空格: // 本题答案type AllCombinations<T extends string[], U extends string = T[number]> = [ U] extends [never] ? '' : '' | { [K in U]: `${K} ${AllCombinations<never, Exclude<U, K>>}` }[U]type TrimRight<T extends string> = T extends `${infer R} ` ? TrimRight<R> : Ttype Combination<T extends string[]> = TrimRight<Exclude<AllCombinations<T>, ''>> 还有一种非常精彩的答案在此分析一下: // 本题答案type Combination<T extends string[], U = T[number], A = U> = U extends infer U extends string ? `${U} ${Combination<T, Exclude<A, U>>}` | U : never; 依然利用 T[number] 的特性将数组转成联合类型,再利用联合类型 extends 会分组的特性递归出结果。 之所以不会出现结尾出现多余的空格,是因为 U extends infer U extends string 这段判断已经杜绝了 U 消耗完的情况,如果消耗完会及时返回 never,所以无需用 TrimRight 处理右侧多余的空格。 至于为什么要定义 A = U,在前面章节已经介绍过了,因为联合类型 extends 过程中会进行分组,此时访问的 U 已经是具体类型了,但此时访问 A 还是原始的联合类型 U。 Subsequence实现 Subsequence<T> 输出所有可能的子序列: type A = Subsequence<[1, 2]> // [] | [1] | [2] | [1, 2] 因为是返回数组的全排列,只要每次取第一项,与剩余项的递归构造出结果,| 上剩余项本身递归的结果就可以了: // 本题答案type Subsequence<T extends number[]> = T extends [infer F, ...infer R extends number[]] ? ( Subsequence<R> | [F, ...Subsequence<R>]) : T 总结对全排列问题有两种经典解法: 利用辅助变量方式递归,注意联合类型与字符串、数组之间转换的技巧。 直接递归,不借助辅助变量,一般在题目返回类型容易构造时选择。 讨论地址是:精读《Unique, MapTypes, Construct Tuple…》· Issue ##434 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"ComponentLoader 与动态组件","path":"/wiki/WebWeekly/可视化搭建/ComponentLoader 与动态组件.html","content":"当前期刊数: 278 组件通过 <Canvas /> 渲染在画布上,内容完全由组件树 componentTree 驱动,但也有一些情况我们需要把某个组件实例渲染到组件树之外,比如全屏、置顶等场景,甚至有些时候我们要渲染一个不在组件树中的临时组件,却要拥有一系列画布能力。 为了让组件渲染更灵活,我们暴露出 <ComponentLoader> API: import { createDesigner } from 'designer'const { Designer, Canvas, ComponentLoader } = createDesigner()const App = () => { return ( <Designer componentTree={/** ... */}> <Canvas /> {/** 任意位置,甚至 Canvas 的组件实例内使用 ComponentLoader 加载任意组件 */} <ComponentLoader /> </Designer> )} 组件加载器有三种用法:按组件 ID 加载、按组件树路径加载、动态组件,下面分别介绍。 按组件 ID 加载将组件树上的某个组件渲染到任何地方,即一个组件实例渲染到 N 个地方,实例级别信息共享,渲染为 N 份: <ComponentLoader componentId="input1" /> 如上例子,将组件 ID 为 input1 的组件渲染到目标位置。 甚至可以在组件内套组件,比如我们定义一个容器组件,内置渲染 ID 为 input1 的子组件: const container: ComponentMeta = { componentName: 'container', // 组件 props 会自动注入 ComponentLoader element: ({ ComponentLoader, children }) => { return ( <div> <ComponentLoader componentId="input1" /> {children} </div> ) }} 当该组件 ID 在组件树中被移除时,<ComponentLoader componentId="input1" /> 返回 null。 按组件树路径加载如果组件在组件树上没有 ID,或者你希望固定渲染某个位置的组件,而无论组件树如何变化,那么就可以采用按组件树路径的加载模式,将 componentId 替换为 treePath 即可: <ComponentLoader treePath="children.0" /> 如上例子,渲染的是 componentTree 根节点 children.0 位置的子组件,同样,但组件不存在时返回 null。 动态组件如果要渲染一个不存在于组件树的组件实例,还可以这么用 <ComponentLoader />: <ComponentLoader standalone componentName="card" /> 即添加 standalone 表示它为一个 “孤立” 组件,即不存在于组件树的组件,以及 componentName 指定组件名。 之所以不需要指定 componentId,是因为每个 ComponentLoader 此时都是一个唯一的实例,在 designer 内部会自动分配一个固定的组件 ID。 这么设计非常灵活,但实现起来难度是有一些,主要注意两点: 动态组件不存在于组件树,但我们之前设计在组件元信息的所有功能都要可以响应,这就要求框架代码不能依赖组件树产生作用,而是将所有组件独立存储计算,包括组件树上的,以及动态组件。 性能,独立组件加载器之间的执行并无关联,因为框架本身为响应式,为了防止频繁刷新或频繁计算需要设计一套自动批处理机制,类似 React 自动 batch 的实现。 对于动态组件,我们还可以传递更多参数: <ComponentLoader standalone componentName="chart" props={{ color: 'red' }}> <button>click</button></ComponentLoader> 如上例子,我们传了额外 props 属性,以及一个子元素给 chart 组件实例。 特别的,如果传递了 componentId,可以将该动态组件的 ID 固定下来,方便进行联动: <ComponentLoader standalone componentName="chart" componentId="abc" /> 但动态组件也有一些限制,如下: 该方式渲染的组件元信息定义的 defaultProps、props 不会生效,因为不存在于组件树中。 该组件无法通过 deleteComponent 删除,也无法通过 setProps、setComponent 等修改,因为渲染完全由父组件控制,而不由组件树控制。 不能用 setParent 改变这种组件的位置,因为其位置在代码中被固定了。 总结其实 <Canvas /> 根节点本质上等价于 <ComponentLoader treePath="" />,即从根节点开始渲染一个组件实例。 所以提供 ComponentLoader 势必会让业务能力更灵活,在任意位置渲染组件,甚至渲染一个不存在于组件树的动态组件。 讨论地址是:精读《ComponentLoader 与动态组件》· Issue ##482 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"keepAlive 模式","path":"/wiki/WebWeekly/可视化搭建/keepAlive 模式.html","content":"当前期刊数: 276 由于 React 的特点,组件改变所在父级后会产生 Remount,而在可视化搭建场景存在两个特点: 自由、磁贴、流式布局都可以通过拖拽轻松改变组件父元素。 大数据量下组件 Remount 的消耗不容忽视。 结合上面两个特点,拖拽过程中或者松手时不可避免会产生卡顿,这就是我们这篇文章要解决的问题。 利用 createPortal 解决 Remount 问题createPortal 可以将 React 实例渲染到任意指定 DOM 上,所以我们利用这个 API,将组件树的组件打平,但通过 createPortal 生成到嵌套的 DOM 树上,就同时实现了以下两点: 在 dom 结构上依然符合组件树的嵌套描述。 在 React 实例角度,没有嵌套关系。 实现分为三步: 遍历组件树,根据组件树嵌套结构生成 createPortal 的目标 dom,我们姑且称为 keepElement,对需要挂载 keepElement 的容器位置生成 dom,称为 keepContainer。对于没有渲染的容器,可以先不挂载 keepElement,而是等到父容器 mount 后再将 keepElement 移过去,后面再展开说明。 遍历组件树,一次性打平渲染所有树中 React 组件实例,并利用 createPortal 挂载到对应的 keepElement 上。 当数据流产生变化导致父级变化,或者布局插件拖动改变父级时,我们仅利用 dom api 将 keepElement 在不同的 keepContainer 之间移动,而在 React 实例视角没有发生任何变化。 协议做到用户无感知因为实现了 dom 结构与 React 实例结构分离,因此开启 keepAlive 模式不需要改变 componentTree 描述,也不会影响任何逻辑功能,我们只需要标记一下 keepAlive 参数即可开启: import { createDesigner } from 'designer'const { Designer, Canvas, useDesigner } = createDesigner()const App = () => { <Designer keepAlive={true} />} 渲染增加了额外 dom 嵌套keepAlive 模式唯一对功能产生的影响是增加了额外 dom 嵌套,分别是 keepContainer 与 keepElement,产生这两层 dom 的原因分别是: keepElement: 因为 React 实例 Remount 的作用范围是该组件自身 return 的所有虚拟 dom 最终映射的真实 dom,为了保证 React 映射 dom 与 React 树结构的对应,为了不产生 Remount 就必须要用额外的游离态 dom 作为 createPortal 的挂载节点。 keepContainer: 由于不仅要知道组件产生移动时,应该将 keepElement 移动到哪个 keepContainer 下,还需要在比如容器代码 return children 位置突然 return null 并恢复时,重新构建 keepElement,所以我们需要监听每一个 keepContainer 生命周期,所以需要额外生成一个 dom。 因此 keepAlive 模式势必会打乱原有应用的 dom 结构,新增的 dom 结构在比如流式布局时可能产生意外的定位错误,所以 keepAlive 模式尽量与绝对定位的布局方式结合。 总结keepAlive 模式可以在不改变任何协议、应用代码的情况下,解决跨父级移动导致的 Remount 问题,但这种设计也会引入新增 dom 结构的问题,只要尽量采用绝对定位的布局策略,就可以避免负面影响。 讨论地址是:精读《可视化搭建 - keepAlive 模式》· Issue ##475 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"场景实战","path":"/wiki/WebWeekly/可视化搭建/场景实战.html","content":"当前期刊数: 280 接下来用实战来说明该可视化搭建框架是否好用,以下几条原则需要始终贯穿在下面每个实战场景中: 复杂的业务场景,背后使用的框架 API 是简单的。 底层 API 并不为业务场景特殊编写,而是具有很强的抽象性,很容易挖掘出其他业务场景的用法。 所有场景都是基于有限的几条基础规则实现,即背后实现的复杂度不随着业务场景复杂度提升而提升。 上卷下钻上卷下钻其实是 组件作用于自身的筛选。 所以上卷下钻背后的实现原理应该与筛选、联动一样。利用 setValue 在点击下钻按钮时,修改组件自己的 value,然后通过 valueRelates 让该组件的联动作用于自身,剩下的逻辑就和普通筛选、联动没有太多区别了,区别仅仅是联动触发源是自己: import { ComponentMeta } from "designer";const chart: ComponentMeta = { componentName: "chart", element: Chart, // 利用 runtimeProps 将组件 value 映射到 props.value,将 props.onChange 映射为 setValue 修改自身 value runtimeProps: ({ selector, setValue, componentId }) => ({ value: selector(({ value }) => value), onChange: (value: string) => setValue(componentId, value), }), // 自己联动自己 valueRelates: ({ componentId }) => [ { sourceComponentId: componentId, targetComponentId: componentId, }, ], fetcher: ({ selector }) => { // relates 可能来自自己、其他筛选器组件实例,或者其他图表组件实例 const relates = selector(({ relates }) => relates); // 根据 relates 下钻 ... },}; 上卷下钻就是作用于自身的联动。 Tabs 组件利用组件树解析规则,我们任意找一个 Key 存放每个 TabPanel 的子元素就可以了。 我们利用 props.tabs 存放 tabs 配置,props.content 存放每项 TabPanel 的子组件,因为其顺序永远和 props.tabs 保持一致,我们可以简单的使用下标匹配。 const tabs = { componentName: "tabs", element: TabsComponent, defaultProps: { // 存放 tabPanel 配置 tabs: [ { title: "tab1", key: "1", }, ], // 存放每个 tabPanel 内子画布的组件实例 content: [ { componentName: "gridLayout", }, ], },}; 而 TabsComponent 组件实现就完全与平台解耦了,即使用 props.tabs 与 props.content 渲染即可: const TabsComponent = ({ content, handleAddTab, handleDeleteTab, tabs }) => ( <Tabs editable defaultActiveTab="1" onAddTab={handleAddTab} onDeleteTab={handleDeleteTab} > {tabs.map((tab, index) => ( <TabPane key={tab.key} title={tab.title}> {content[index]} </TabPane> ))} </Tabs>); tabs 使用 treeLike 结构,按照下标存储组件实例。 富文本内嵌组件实例与 tabs 很像,区别是富文本内嵌入的组件实例数量是不固定的,每一个组件实例都对应富文本某个 block id. 下面是富文本实现代码的一部分: const SomeRichTextLibrary = (props) => { // 自定义渲染 block 槽位 const RenderCustomBlock = useCallback( (blockId: string) => { // 渲染组件实例 return props.blockElements.find( (componentInstance) => componentInstance.componentId === blockId ); }, [props.blockElements] );}; 富文本一般拥有自定义 block 区块的能力,我们只要将 block id 与组件实例 id 绑定,然后将组件实例存储在 props.blockElements,就可以轻松匹配到对应组件实例了。 其中 props.blockElements 的结构如下: { "blockElements": [ { "componentId": "block1", "componentName": "chart" }, { "componentId": "block2", "componentName": "radar" } ]} 富文本的结构可能如下: { "type": "rich_text", "content": [ { "type": "paragraph", "text": "This is a paragraph of rich text." }, { "type": "heading", "level": 2, "text": "This is a heading" }, { "type": "block", "blockId": "block1" }, { "type": "block", "blockId": "block2" } ]} 最后两个 block 是自定义区块,通过自定义 RenderCustomBlock 来渲染,我们正好可以通过 blockId 对应到 componentId,在 props.blockElements 中找到。 富文本的实现思路和 tabs 基本一样,只是查找组件实例的逻辑不同。 实现任意协议我们也许为了进一步抽象,或对指定业务场景降低配置门槛,在组件树拓展一些额外的 json 结构协议做一些特定功能。 以拓展事件配置为例,假如我们需要实现如下协议:每个组件实例信息上拓展了 events 属性,通过配置这个属性可以实现一些内置动作,如打开 Modal。这个协议至少要定义触发源是什么 trigger、做什么事情 type 以及作用的目标组件 targetId: { "componentName": "button", "events": [ { "trigger": "onClick", "type": "openModal", "targetId": "123" } ]} 如上面的例子,只要定义好触发源、类型和目标组件,就可以在按钮组件 onClick 时将目标组件 visible 设为 true,实现弹出 Modal 的效果。 实现思路是,利用 onReadComponentMeta,在所有组件的元信息做拓展。比如要拓展这种事件,一般 Trigger 都要绑定在组件 Props 的回调上(如果是全局监听,可以绑定在全局并利用事件机制通信给组件),那就可以通过 runtimeProps 进行绑定: const App = () => ( <Designer onReadComponentMeta={(meta) => ({ ...meta, runtimeProps: (options) => { const result = meta.runtimeProps?.(options) ?? {}; const events = options.selector( ({ componentInstance }) => componentInstance.events ); events?.forEach((event) => { switch (event.type) { case "openModal": // 给组件添加新的 trigger 绑定 result[event.trigger] = options.setRuntimeProps( event.targetId, (props) => ({ ...props, visible: true, }) ); break; } }); return result; }, })} />); 除此之外,我们还可以想象有更多的协议可以通过这种方式处理响应,无论何种协议,背后都是基于组件元信息的实现,易懂且单测有保障。 总结本文我们总结了三个场景实战: 利用 treeLike 结构在组件内渲染任意数量的子组件实例,如 tabs 或富文本。 利用组件联动的 API,实现筛选、联动以及上卷下钻。 利用 onReadComponentMeta 为所有组件元信息统一增加逻辑,用来解读如 props 属性中定义的某些规则,进而实现任意协议。 讨论地址是:精读《可视化搭建 - 场景实战》· Issue ##485 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"可视化搭建内置 API","path":"/wiki/WebWeekly/可视化搭建/可视化搭建内置 API.html","content":"当前期刊数: 271 在设计好画布与组件数据流体系后,理论上主体功能已经完成,但缺乏方便易用的 API,所以还需要内置一些状态与方法。 但是内置状态与方法必须寻求业务的最大公约数,极具抽象性,添加需慎重。 接下来我们从必须有与建议有的角度,看看一个可视化搭建需要内置哪些 API。 状态状态是可变的,引用方式有如下两种。 第一种在任意 React 组件内通过 useDesigner 访问,当状态变化时会触发所在组件重渲染: const { componentTree } = useDesigner((state) => ({ componentTree: state.componentTree,})); 第二种在任意组件元信息内通过 selector 访问,当状态变化时会触发不同行为,比如在 runtimeProps 会触发组件重渲染,在 fetcher 会触发重新查询: const tableMeta = { /** ... */ runtimeProps: ({ selector }) => { const { componentTree } = selector(({ state }) => ({ componentTree: state.componentTree, })); return { componentTree }; },}; componentTree 评价:必须有 类型:ComponentInstance 描述完整组件树 JSON 结构。在非受控模式下,组件树就存储在 <Designer /> 实例内部,而受控模式下,组件树存储在外部状态。 但我们允许这两种模式都可以访问此状态,这样在开发可视化搭建应用的过程中,就不用关心受控或非受控模式了,即一套代码同时兼容受控与非受控模式。 selectedComponentIds 评价:建议有 类型:string[] 定义当前选中组件实例 id 列表。 虽然这个状态业务也可以定义,但选中组件在可视化搭建是一种常见行为,以后定义插件、自定义组件也许都会读取当前选中的组件,如果框架定义了此通用 key,那么插件和自定义组件就可无缝结合到任意业务代码里。反之如果在业务层定义该状态,插件或者自定义组件也不知道如何标准的读取到当前选中的组件。 canUndo, canRedo 评价:建议有 类型:boolean 描述当前状态是否能撤销或重做。 该状态需要结合内置方法 undo() redo() 一起提供,属于 “有了更好” 的状态。但有时候也会产生困扰,比如你的应用分了多个 sheet,每个 sheet 内是一个画布实例,而你希望撤销重做可以跨 sheet,那就不适合用单实例提供的方法了。 方法状态引用不可变,引用方式有如下两种。 第一种在任意 React 组件内通过 useDesigner 访问,它不会变化,因此不会导致组件重渲染: const { addComponent } = useDesigner(); 第二种在任意组件元信息内通过回调访问: const tableMeta = { /** ... */ runtimeProps: ({ addComponent }) => {},}; getState() 评价:必须有 类型:() => State 获取应用全部状态,包括内置与业务自定义。 setState() 评价:必须有 类型:(state: State) => void 更新应用全部状态,包括内置与业务自定义。 getComponentTree() 评价:必须有 类型:() => ComponentInstance 返回当前组件树。 并不是有了 componentTree 状态就万事大吉了,很多回调函数并不依赖组件树重渲染,而仅仅在触发时获取其瞬时值必须调用此方法。 虽然该方法一定程度上可以用 getState().componentTree 代替,但组件树概念太重要了,以至于单独定义一个方法不会增加理解成本。另外在受控模式下,getState().componentTree 不一定等价于 getComponentTree(),因为前者是从 <Designer /> 拿组件树,而后者直接请求外部状态最新的组件树,当组件树受控模式没有及时触发渲染同步时,后者值会比前者更新。 setComponentTree() 评价:必须有 类型:(callback: (now: ComponentInstance) => ComponentInstance) => boolean 更新当前组件树。 在非受控模式下等价于 setState() 修改 componentTree,但在非受控模式下,会直接透传到外部状态,直接修改一手组件树,因此极端情况下表现更稳定。 addComponent() 评价:必须有 类型 (componentInstance, parentIdPath?, index?, position?) => void 添加组件实例。 基于 setComponentTree() 实现,但因为其太常见且意图较为复杂,抽成一个独立函数还是很有必要的。 componentInstance 必选,默认把组件实例添加到根节点的 children 位置。 parentIdPath 可选,描述要添加到的父节点 ID,当父节点没定义组件 ID 时,也可以用例如 children.0 这种组件树路径代替,所以名称不叫 parentId,而是 parentIdPath。 index 可选,描述要添加到父节点子元素下标,比如添加到 children 的第几项。 position 可选,描述要添加到父节点 children 还是 props.header 等位置,毕竟组件实例并不只有 children 一个地方。 deleteComponent() 评价:必须有 类型:(componentIdPath: string) => boolean 删除组件实例。 基于 setComponentTree() 实现,但同理太常用,所以单独提供。 这里还有个细节,就是 componentIdPath 指可传组件 ID,也可传组件树路径,而真正删除肯定要从树上删,框架内部为了快速从组件 ID 定位到 treePath,维护了一个映射表,因此使用该函数无论何时都是 O(1) 的时间复杂度。 getComponent() 评价:必须有 类型:(componentIdPath: string) => ComponentInstance 查询组件实例。 基于 getComponentTree() 实现。“增删” 都有了,“查” 还能没有吗? setComponent() 评价:必须有 类型:(componentIdPath, callback) => boolean 修改组件实例。 基于 setComponentTree() 实现,“增改查” 都有了,就差一个 “改” 了。 setProps() 评价:建议有 类型:(componentIdPath, callback) => boolean 修改组件实例的 props。 基于 setComponent() 实现,因为修改组件 props 属性比修改整个组件实例常见,建议实现。 getProps() 评价:建议有 类型:(componentIdPath) => any 获取组件实例的 props。 基于 getComponent() 实现,同理,调用可能比 getComponent() 更常见,因此建议实现。 getComponents() 评价:建议有 类型:() => ComponentInstance[] 获取全量组件实例数组。 因为组件树是树状结构,业务除了用递归方式遍历外,还可以提供这种获取打平形式的组件树以备不时之需。 getParentId() 评价:必须有 类型:(componentIdPath: string) => string 获取组件的父组件 ID。 以为 componentTree 为树状结构,所以直接从组件实例上找不到父节点,因此提供一个快速找父节点的函数是非常必要的。 当然框架内部实现寻找父节点肯定不会用遍历,而是提前解析组件树时就建立好关联映射表,所有内置方法时间复杂度都是 O(1) 的。 getParentBy() 评价:建议有 类型:(componentIdPath: string, finder: (parent: ComponentInstance) => boolean) => string 一直向上寻找父节点,直到找到为止。 基于 getParentId() 实现,方便业务向上寻找符合条件的父节点。 setParent() 评价:必须有 类型:(componentIdPath, parentIdPath, index, position) => boolean 调整某个组件的父节点。参数和 addComponent() 很像,只是把第一个从组件实例改为了组件 ID,参数含义相同。 当画布涉及组件跨父节点移动时,这个方法就显得很关键了,虽然底层也是基于 setComponentTree 实现的。一个比较复杂的场景是,当组件跨节点移动时,在组件树上操作还是比较复杂的,因为移除 + 添加无论先做哪个,都会导致组件树变化,从而导致后一个操作位置可能错误。如果每次都重新寻址性能会较差,如果想用聪明的方法绕过,逻辑还是比较复杂的,因此有必要内置该方法。 setComponentMeta() 评价:必须有 类型:(componentName: string, componentMeta: ComponentMeta) => void 更新组件元信息。 提供这个方法其实对框架的挑战比较大,在提供很多生命周期的情况下,随时可能发生组件实例的更新,要保证整体逻辑符合预期,需要仔细设计一下。 getComponentMeta() 评价:必须有 类型:(componentName: string) => ComponentMeta 获取组件元信息。 既然可以注册组件元信息,就可以获取它。注意通过 <Designer /> 受控或者非受控模式注册,或者直接调用 setComponentMeta 注册的组件元信息都应该可以正常获取到。 getComponentMetas() 评价:建议有 类型:() => ComponentMeta[] 批量获取所有已注册的组件元信息。 说不定业务会有什么特别的用途,建议提供。 clearComponentMetas() 评价:建议有 类型:() => void 清空所有组件元信息。 说不定业务会有什么特别的用途,建议提供。 setSelectedComponentIds() 评价:建议有 类型:(ids: string[]) => void 修改内置状态 selectedComponentIds。 如果你提供了 selectedComponentIds 这个内置状态,那提供对应的修改方法就是强烈建议了。虽然也可通过 setState() 更新 selectedComponentIds Key 来实现。 getTreePath() 评价:建议有 类型:(componentIdPath: string) => string 根据组件 ID 查找在组件树上的路径。 也许业务想要自己操作组件树,那么框架提供根据组件 ID 找到组件树路径的方法就挺合适。 undo(), redo() 评价:建议有 类型:() => void 撤销,重做。 如果提供了 canUndo、canRedo 内置状态,那么一定要提供 undo()、redo() 内置函数。 getMergedProps() 评价:建议有 类型:(componentIdPath: string) => any 返回组件最终混合后的 props。 由于组件 props 可能来自组件树,也可能来自 runtimeProps,为了防止傻傻分不清,因此规定 getProps() 仅获取组件树上序列化的 props,而 getMergedProps() 获取了包含 runtimeProps 处理后的最终 props。 getComponentDom() 评价:建议有 类型:(componentIdPath: string) => HTMLElement 根据组件 ID 获取 DOM 实例。 框架最好通过一些技巧,让组件即便不用 forwardRef 也能拿到 DOM,那么组件只要存在 DOM,就可以通过该方法拿到,非常方便。 afterDomRender() 评价:建议有 类型:(componentIdPath: string, callback: () => void) => Promise 当组件 ID 的 DOM 实例挂载后,执行 callback。 因为组件 DOM 依赖渲染,所以不能保证 getComponentDom 时 DOM 真的完成了渲染,因此可以将时机放在 afterDomRender() 后,保证一定可以拿到 DOM。 总结这一章我们设计了内置 API,设计思路总结如下: 从组件树这个核心概念散开,设置了必要的 API,以及一些逻辑复杂,或者使用很方便的推荐 API。 虽然组件树是树状结构,但内置 API 需要考虑易用性,所有操作都以组件 ID 作为参数,在内部实现时转化为操作组件树,并内置好 O(1) 时间复杂度的优化措施。 核心 API 只有寥寥几个,其余 API 都以便利性为目的提供,且都以核心 API 为基础实现,这样框架核心会更稳定,框架大部分 API 只是一种实现规则,业务利用核心 API 拥有更大的实现自由。 讨论地址是:精读《可视化搭建内置 API》· Issue ##467 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"如何抽象可视化搭建","path":"/wiki/WebWeekly/可视化搭建/如何抽象可视化搭建.html","content":"当前期刊数: 268 在做任何可视化搭建项目时,第一步都要思考如何抽象。 如果不抽象,当搭建项目做到后期可能会出现 API 杂乱,难以维护的问题;做到一半甚至会怀疑为什么需要一个搭建框架,怀疑把框架去掉会不会效率更高;在后期发现不能自然的水平拓展到仪表盘、大屏、表单搭建场景等。 所以如果在维护一套可视化搭建系统时,不管这个系统的上层是 BI、大屏、表单填报,还是脑图也好,无论是什么,都要先思考一下这些系统背后的底层是什么,需不需要抽象,抽象的意义和价值在哪。 以下结合笔者的经验,尝试给出一种思考角度。 精读什么是可视化搭建表单搭建、中后台应用搭建、BI 仪表盘搭建、大屏搭建都算可视化搭建,因为它们都是在一个画布上拖拖拽拽完成的。 那么组件配置表单算搭建吗?聚焦单组件分析的可视化探索呢?幻灯片呢? 比如组件配置表单,它基于 UI 组件树抽象的话,就是可视化搭建,但如果基于表单结构抽象,就是 JsonSchema,但真的所有业务场景都是数据完全映射 UI 吗?不一定,因为 UI 可以为了用户操作方便而加入更多辅助元素,甚至把一个属性拆成多个 UI 填写,所以基于可视化搭建,也就是 UI 组件树抽象的一定可以覆盖所有表单场景,但不一定是描述效率最高的方式。 如果每种可视化搭建场景都定义一套协议与实现,那按照搭建平台的复杂度,想同时维护两个类搭建平台的成本一定是两倍,而且不同维护人员很难交流。又或者某些可以按照搭建思路解决的场景,因为实现时经验不足,没有进行抽象,甚至进行了另一套定制抽象,回过头来看可能积重难返,团队不得不接受多套笨重实现的现状。 所以建议将这些场景都视为可视化搭建场景,用一套接口描述结构、API 方法,让看似百花齐放的编辑器之下拥有统一的上下文与实现。 可视化搭建的分层对于不同种类的可视化搭建平台,我们尝试寻找其分层设计的最大公约数。如果把可视化搭建底层设定为逻辑层,即这个层是 UI 无关的,仅关心组件树结构、逻辑功能,那么对于每种平台的分层应该是这样的: 表单搭建:逻辑层、表单联动协议层、表单控件、业务层。 中后台应用搭建:逻辑层、应用联动协议层、应用控件、业务层。 BI 仪表盘:逻辑层、筛选联动协议层、可视化控件、业务层。 大屏搭建:逻辑层、画布编辑控制器层、可视化控件和基础图形控件、业务层。 最底层的逻辑层应该可以统一所有类型搭建系统,并成为开发人员统一上下文的。它可以包含以下基础能力: 定义组件树结构。 定义组件元信息。 按照组件树结构递归渲染画布。 支持布局、取数、联动、筛选、校验等一系列拓展能力,业务可根据需要定制。 提供所有业务层都需要的能力,比如性能优化的组件冻结、状态管理、对组件树增删改查的 API。 在逻辑层完备后,再开发上层应用就会轻松很多,只要注册组件、根据业务需要在组件树初始化或组件初始化,或组件元信息注册时添加定制逻辑,与系统功能对接,并补充业务特色的如自定义布局能力,这样就可以用简单的三言两语说清楚整个系统是如何设计的。 逻辑层存在的必要性再回到问题的根源:对逻辑层做统一的抽象到底是不是多余的? 要回答这个问题,需要先了解我们手头里有哪些工具:基础开发工具 html、js、css,并且 html 也提供了一套标准化的 xml 结构;vue、react 等开发框架,基础组件、应用生命周期与事件定义。理论上基于这些,我们就可以直接上手写一个可视化搭建平台了,似乎也可以不抽象。但真正要上手时,一定会遇到以下几个通用问题需要处理: 定义组件树结构 无论做表单搭建、报表搭建、大屏搭建还是脑图画布,第一个想到的肯定是如何描述这个画布结构,而无论画布是横着排还是竖着排,横竖都是一棵树。HTML 树不能直接搬过来,一是 HTML 树的完整结构太大而我们需要的更精简的结构,二是业务层框架一般都先有一套虚拟树再转化为 dom 树,因果关系也没法反过来。而这棵树也完全可以做最大程度的抽象,即定义组件 ID、组件名、属性(Props)、子节点。 定义对组件树增删改查函数 有了组件树肯定需要对其进行增删改查操作,因为无法基于 document API,上层框架如 vue、react 也不提供对任何标准组件树的增删改查 API,这部分能力势必要手动实现。 生命周期 假设完全依赖 React 框架提供的组件生命周期,是可以完成大部分业务逻辑,但这意味着定义不够精细化。比方说,我们在组件 Mount 的实际监听了联动、实现取数、设置冻结等等效果,虽然也可以实现,但会遇到要不要抽象的问题: 如果不抽象,业务代码就会乱糟糟的,比较难读。 如果抽象,就要把联动、取数、冻结等等模块归类,封装成函数,甚至可以提供主动调用机制,UI 与逻辑解耦,但当业务层精细的去做这件事就会发现,这就是在做框架层的抽象工作,所以还不如一开始就把这些生命周期抽象到框架里。 逻辑层有两个核心结构,第一个是组件树结构,包含了对每个组件实例的定义;第二个是组件元信息结构,包含了对每个组件的元信息描述,大概如下图所示: 逻辑层的难点就是在元信息定义足够多、足够通用的生命周期回调函数,并且这些回调函数还能尽可能的功能正交。 组件渲染 通常一棵树按照 json 结构描述自顶向下自动渲染就可以了,但也有一些时候,比如内嵌一个富文本组件,而富文本内又嵌入一些画布组件,这些组件需要像普通画布组件一样可交互,此时就有 渲染一个不存在于组件树的组件实例 的需求,而这样的动态组件又要无感知的满足上面所说的各类生命周期,这也是不小的工作量。 功能的拓展抽象 等可视化搭建平台正式维护时,就至少会遇到组件版本升级、不同类型的布局方案对接、三方组件注册等需求,这些功能如何加入到现有的搭建平台,而不让其他功能感知,是需要精心设计的。如果逻辑层把这一点抽象好,在每个功能设计一个钩子,实现一个功能时无需感知其他功能,那平台的功能拓展就会保持一个恒定的速度,不随功能增加而变得难以维护。 可见,可视化搭建不断迭代的过程就是自身不断抽象的过程,逻辑层实现的好坏直接影响到后期的维护性与拓展性,所以好好设计逻辑层可以让开发事半功倍。 组件配置表单要不要用搭建方案做组件配置直接用表单方案而不是搭建,似乎是最容易想到的。但当每个组件都要自定义配置,我们就不得不选择基于 JsonSchema 描述的表单方案,但这与搭建应用本身的技术栈割裂了,随着联动功能的要求越来越多,会越来越发现小小的表单渲染引擎维护得越来越复杂,甚至复杂度与画布不分上下,此时再叹息两边技术栈不统一就已经晚了。 换个角度想一下,搭建应用不也要考虑组件间联动吗?从表单值能力来看,搭建场景并不要求每个组件都拥有一个值,反倒是可以将组件任意 props 属性看作表单值更具有 “弹性”,我们可以拓展任意 Key 作为表单值。 另外,从数据结构触发来描述表单看似很美好,但当表单变得越来越复杂,UI 越来越定制后,势必引入新的 UI 节点或者新的结构描述,与其后期拓展到一个不纯净的 JsonSchema 结构,不如一开始就放弃这个幻想,用 UI 组件树结构描述表单,这样事情就变得简单了:“先描述组件树,再定义每个节点分别用什么组件渲染,响应表单的哪部分 Key”。 总结总结一下,回到主题,抽象可视化搭建的方法是分层:以逻辑层打底,提供一套标准规范与 API 接口,上层注册组件、实现布局,一切围绕着标准化的逻辑层进行拓展。 而可视化搭建的每一层都可以分别写单元测试,保证最终变化的代码只有业务层的对接部分,应用的稳定性就提高了。 最后提一个思考题:你是觉得可视化搭建应该如何抽象?如果想要做到每一层独立正交,你会如何设计 API 呢? 讨论地址是:精读《如何抽象可视化搭建》· Issue ##463 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"定义联动协议","path":"/wiki/WebWeekly/可视化搭建/定义联动协议.html","content":"当前期刊数: 274 虽然底层框架提供了通用的组件值与联动配置,可以建立对组件任意 props 的映射,但这只是一个能力,还不是协议。 业务层是可以确定一个协议的,还要让这个协议具有拓展性。 我们先从使用者角度设计 API,再看看如何根据已有的组件值与联动能力去实现。 设计联动协议首先,不同的业务方会定义不同的联动协议,因此该联动协议需要通过拓展的方式注入: import { createDesigner } from 'designer'import { onReadComponentMeta } from 'linkage-protocol'return <Designer onReadComponentMeta={onReadComponentMeta} /> 首先可视化搭建框架支持 onReadComponentMeta 属性,用于拓展所有已注册的组件元信息,而联动协议的拓展就是基于组件值与组件联动能力的,因此这种是最合理的拓展方式。 之后我们就注册了一个固定的联动协议,它形如下: { "componentName": "input", "linkage": [{ "target": "input1", "do": { "value": "{{ $self.value + 'hello' }}" } }]} 只要在组件实例上定义 linkage 属性,就可以生效联动。比如上面的例子: target: 联动目标。 do: 联动效果,比如该例子为,组件 ID 为 input1 的组件,组件值同步为当前组件实例的组件值 + 'hello'。 $self: 描述自己实例,比如可以从 $self.value 拿到自己的组件值,从 $self.props 拿到自己的 props。 更近一步,target 还可以支持数组,就表示同时对多个组件生效相同规则。 我们还可以支持更复杂的语法,比如让该组件可以同步其他组件值: { "componentName": "input", "linkage": [{ "deps": ["input1", "input2"] "props": { "text": "{{ $deps[0].value + deps[1].value }}" } }]} 上面的例子表示,该组件实例的 props.text 同步为 input1 + input2 的组件值: deps: 描述依赖列表,每个依赖实例都可以在表达式里用 $deps[] 访问到,比如 $deps[0].props 可以访问组件 ID 为 input1 组件的 props。 props: 同步组件的 props。 如果定义了 target 则作用于目标组件,未定义 target 则作用于自身。但无论如何,表达式的 $self 都指向自己实例。 总结一下,该联动协议允许组件实例实现以下效果: 设定组件值、组件 props 的联动效果。 可以将自己的组件值同步给组件实例,也可以将其他组件值同步给自己。 基本上,可以满足任意组件联动到任意组件的诉求。而且甚至支持组件间传递,比如 A 组件的组件值同步组件 B, B 组件的组件值同步组件 C,那么 A 组件 setValue() 后,组件 B 和 组件 C 的组件值会同时更新。 实现联动协议以上联动协议只是一种实现,我们可以基于组件值与组件联动设定任意协议,因此实现联动协议的思维具备通用性,但为了方便,我们以上面说的这个协议为例子,说明如何用可视化搭建框架的基础功能实现协议。 首先解读组件实例的 linkage 属性,将联动定义转化为组件联动关系,因为联动协议本质上就是产生了组件联动。接下来代码片段比较长,因此会尽量使用代码注释来解释: const extendMeta = { // 定义 valueRelates 关系,就是我们上一节提到的定义组件联动关系的 key valueRelates: ({ componentId, selector }) => { // 利用 selector 读取组件实例 linkage 属性 // 由于 selector 的特性,会实时更新,因此联动协议变化后,联动状态也会实时更新 const linkage = selector(({ componentInstance }) => componentInstance.linkage) // 返回联动数组,结构: [{ sourceComponentId, targetComponentId, payload }] return linkage.map(relation => { const result = []; // 定义此类联动类型,就叫做 simpleRelation const payload = { type: 'simpleRelation', do: JSON.parse( JSON.stringify(relation.do) // 将 $deps[index] 替换为 $deps[componentId] .replace( /\\$deps\\[([0-9]+)\\]/g, (match: string, index: string) => `$deps['${relation.deps[Number(index)]}']`, ) // 将 $self 替换为 $deps[componentId] .replace(/\\$self/g, () => `$deps['${componentId}']`), ), }; // 经过上面的代码,表达式里无论是 $self. 还是 $deps[0]. 都转化为了 // $deps[componentId] 这个具体组件 ID,这样后面处理流程会简单而统一 // 读取 deps,并定义 dep 组件作为 source,target 作为目标组件 // 这是最关键的一步,将 dep -> target 关系绑定上 relation.target.forEach((targetComponentId) => { if (relation.deps) { relation.deps.forEach((depIdPath: string) => { result.push({ sourceComponentId: depIdPath, targetComponentId, }); }); } // 定义自己到 target 目标组件的联动关系 result.push({ sourceComponentId: componentId, targetComponentId, payload, }); }); return result; }).flat() }} 上述代码利用 valueRelates,将联动协议的关联关系提取出来,转化为值联动关系。 接着,我们要实现 props 同步功能,实现这个功能自然是利用 runtimeProps 以及 selector.relates,将关联到当前组件的组件值,按照联动协议的表达式执行,并更新到对应 key 上,下面是大致实现思路: const extendMeta = { runtimeProps: ({ componentId, selector, getProps, getMergedProps }) => { // 拿到作用于自己的值关联信息: relates const relates = selector(({ relates }) => relates); // 记录最终因为值联动而影响的 props let relationProps: any = {}; // 记录关联到自己的组件此时组件值 const $deps = relates?.reduce( (result, next) => ({ ...result, [next.componentId]: { value: next.value, }, }), {}, ); // 为了让每个依赖变化都能生效,多对一每一项 do 都带过来了,需要按照 relationIndex 先去重 relates .filter((relate) => relate.payload?.type === 'simpleRelation') .forEach((relate) => { const expressionArgs = { // $deps[].value 指向依赖的 value $deps, get, getProps: relate.componentId === componentId ? getProps : getMergedProps, }; // 处理 props 联动 if (isObject(relate.payload?.do?.props)) { Object.keys(relate.payload?.do?.props).forEach((propsKey) => { relationProps = set( propsKey, selector( () => // 这个函数是关键,传入组件 props 与表达式,返回新的 props 值 getExpressionResult( get(propsKey, relate.payload?.do?.props), expressionArgs, ), { compare: equals, // 根据表达式数量可能不同,所以不启用缓存 cache: false, }, ), relationProps, ); }); } }); return relationProps }} 其中比较复杂函数就是 getExpressionResult,它要解析表达式并执行,原理就是利用代码沙盒执行字符串函数,并利用正则替换变量名以匹配上下文中的变量,大致代码如下: // 代码执行沙盒,传入字符串 js 函数,利用 new Function 执行function sandBox(code: string) { // with 是关键,利用 with 定制代码执行的上下文 const withStr = `with(obj) { ${code} }`; const fun = new Function('obj', withStr); return function (obj: any) { return fun(obj); };}// 获取沙盒代码执行结果,可以传入参数覆盖沙盒内上下文function getSandBoxReturnValue(code: string, args = {}) { try { return sandBox(code)(args); } catch (error) { // eslint-disable-next-line no-console console.warn(error); }}// 如果对象是字符串则直接返回,是 {{}} 表达式则执行后返回function getExpressionResult(code: string, args = {}) { if (code.startsWith('{{') && code.endsWith('}}')) { // {{}} 内的表达式 let codeContent = code.slice(2, code.length - 2); // 将形如 $deps['id'].props.a.b.c // 转换为 get('a.b.c', getProps('id')) codeContent = codeContent.replace( /\\$deps\\[['"]([a-zA-Z0-9]*)['"]\\]\\.props\\.([a-zA-Z0-9.]*)/g, (str: string, componentId: string, propsKeyPath: string) => { return `get('${propsKeyPath}', getProps('${componentId}'))`; }, ); return getSandBoxReturnValue(`return ${codeContent}`, args); } return code;} 其中 with 是沙盒执行时替换代码上下文的关键。 总结componentMeta.valueRelates 与 componentMeta.runtimeProps 可以灵活的定义组件联动关系,与更新组件 props,利用这两个声明式 API,甚至可以实现组件联动协议。总结一下,包含以下几个关键点: 将 deps 和 target 利用 valueRelates 转化为组件值关联关系。 将联动协议定义的相对关系(比较容易写于容易记)转化为绝对关系(利用 componentId 定位),方便框架处理。 利用 with 执行表达式上下文。 利用 runtimeProps + selector 实现注入组件 props 与响应联动值 relates 变化,从而实现按需联动。 讨论地址是:精读《定义联动协议》· Issue ##471 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"容器组件设计","path":"/wiki/WebWeekly/可视化搭建/容器组件设计.html","content":"当前期刊数: 272 可视化搭建会遇到如下三类容器组件: 简单容器:以 children 容纳子组件的容器。 卡片容器:以 props.header 加上 props.header 等多个插槽容纳子组件的容器。 Tab 容器:以 props.tabPanel[x] 等动态数量插槽容纳子组件的容器。 画布本身也是一个容器组件,所以可视化搭建离不开容器。 另一方面,我们应该允许给组件 props 传入 React 组件实例,但组件树是可序列化的 JSON 结构,因此需要一种定义方式,将某些属性转化为 React 组件实例传给组件实例。 容器的定义任何组件都可能是容器组件,只要它将 props.children 或 props.footer 等任何属性作为 ReactNode 渲染。因此我们不需要特殊声明组件是否为容器,而仅需将某些组件 Key 声明为 ReactNode 节点。 Childrenchildren 因为太常用因此单独强调出来,可以只在组件实例定义 children 属性,它是一个数组: import { ComponentInstance } from "designer";const componentTree: ComponentInstance = { componentName: "div", children: [ { componentName: "input", }, ],}; 对于这个组件,Designer 会将 children 定义的属性理解为组件实例,并真正解析为 React 实例传递给 props.children,因此组件渲染代码可以直接使用 children 渲染: import { ComponentMeta } from "designer";const divMeta: ComponentMeta = { componentName: "div", element: ({ children }) => <div>{children}</div>,}; 这种约定的好处是直观自然,组件代码也没有关心到框架逻辑,自然而然实现了容器功能。 treeLike 结构只要将任意组件 props 定义为数组模式,并且包含 componentName,Designer 就认为应该解析为 ReactNode。 如下面的例子,我们定义的 div 组件初始化就会渲染一个 input 组件在 props.header 位置: import { ComponentMeta } from "designer";const divMeta: ComponentMeta = { componentName: "div", element: ({ header }) => <div>{header}</div>, defaultProps: { header: [ { componentName: "input", }, ], },}; 也可以在描述组件树时直接写在对应 props 位置: import { ComponentInstance } from "designer";const componentTree: ComponentInstance = { componentName: "div", props: { header: [ { componentName: "input", }, ], },}; 这种约定的好处是直观的支持了任意 props key 为组件实例,但依然存在限制,因此 Designer 还需要支持一种用户 100% 掌控的申明式定义:propTypes。 PropTypes在组件元信息 propTypes 属性定义更细致的容器插槽位置,比如: const tabMeta = { componentName: "tab", propTypes: { tabs: [ { panel: "element", }, ], },}; 那么当组件实例如下定义时: const componentInstance = { componentName: "tab", props: { tabs: [ { title: "tab1", panel: { componentName: "card", }, }, { title: "tab2", panel: { componentName: "text", }, }, ], },}; 组件拿到的 props.tabs[0].panel 就是一个可以直接渲染的 React 组件实例,因为在 propTypes 定义了 tabs[].panel 路径是一个组件实例。 这样设计需要考虑组件树遍历的问题,因为组件实例位置定义在组件元信息上,因此仅靠组件树无法做遍历(因为遍历父节点时,不结合 componentMeta 就无法确认哪些 props 位置是子组件实例),这样会带来两个问题: 遍历组件非常麻烦,极端情况下,如果大量组件是远程注册的三方组件,会导致需要一层层串行远程拉取组件实例,导致遍历过程变慢。 更极端的场景是,当组件版本升级导致 propTypes 变化,一些原本不是组件实例的位置成为了组件实例,或者反之,此时拉取最新组件元信息读取的 propTypes 可能就是错的。 因为以上两个原因,实现方案应该是将组件元信息定义的 propTypes 拷贝一份到组件实例,这样就可以仅凭组件树自身来遍历组件树了,而且定义在组件树上的 propTypes 一定对应当前组件树的结构。 总结我们通过 children 与 props 上 treeLike 这两个约定,实现了业务基本够用的容器定义能力,仅凭这两个约定就可以实现几乎所有容器需要的效果。 propTypes 定义补全了约定拓展性的不足,让 props 任何位置都可能成为组件实例,只需要付出额外定义 propTypes 的代价。 阅读到这,相信你已经理解到,可视化搭建其实不存在容器组件的概念,因为这个组件之所以是容器,仅仅因为它的某个 prop 属性是组件实例,而它恰好将该属性渲染到某个位置(甚至用 createPortal 挂载到其他 dom 节点),所以它仅仅是一种 prop 属性的体现,因此对容器组件,我们没有设计一种新 type,而是允许任意位置属性定义为实例。 下一节我们会介绍为组件元信息添加取数与筛选联动的钩子,让筛选器 + 查询场景可以轻松被实现。 讨论地址是:精读《容器组件设计》· Issue ##468 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"画布与组件元信息数据流","path":"/wiki/WebWeekly/可视化搭建/画布与组件元信息数据流.html","content":"当前期刊数: 270 接下来需要解决两个问题: 可视化搭建的其他业务元素如何与画布交互。比如拓展属性配置面板、图层列表、拖拽添加组件、定位锚点、主题等等。 runtimeProps 如何访问到当前组件实例的 props。 这两个问题非常重要,而恰好又可以通过良好的数据流设计一次性解决,接下来让我们分别分析讨论一下。 问题一:可视化搭建的其他业务元素如何与画布交互。比如拓展属性配置面板、图层列表、拖拽添加组件、定位锚点、主题等等 需要设计一个 Hooks API,可以访问到画布提供的方法、数据。在 React 设计中,访问 Hooks API 需要在一定上下文内,所以可以将 <Designer> 拆为 <Designer> 与 <Canvas>,其中 <Designer> 提供 Hooks 上下文,<Canvas> 负责渲染画布。这样开发者的使用方式就变成了这样: import { createDesigner } from 'designer'const { Designer, Canvas, useDesigner } = createDesigner()const EditPanel = { const { addComponent } = useDesigner() return <button onClick={() => addComponent(/** ... */)}>创建组件</button>}const App = () => { <Designer> <Canvas /> <EditPanel /> </Designer>} 为了支持多个 Designer 实例间隔离,通过 createDesigner 创建一套上下文独立的 API,这样就可以让画布、配置面板同时用 Designer 实现,用一套技术方案同时实现画布与配置表单,这样学习上下文、组件规范都可以统一为一套,表单、画布能力也可以共享。 在 <Designer> 内的组件可以通过 useDesigner 直接访问数据与方法,比如上面例子在直接访问内置方法 addComponent 时,不需要附加任何参加,而 addComponent 方法也永远保持引用不变,此时 useDesigner 不会导致 EditPanel 重渲染。 如果需要访问当前组件树,并在组件树变化时重渲染,可以通过如下方式访问: const EditPanel = { const { componentTree } = useDesigner(state => ({ componentTree: state.componentTree }))} 该写法的效果是,当 state.componentTree 变化了,会触发 EditPanel 重新渲染,并拿到最新值。 同时也可以传入第二个参数 compare 自定义对比方法,默认为 shallowEqual: useDesigner( (state) => ({ componentTree: state.componentTree, }), isEqual); 如此一来,无论给画布拓展多少 UI 元素都没有问题,而且 UI 元素可以自由的访问画布方法与数据。 问题二:runtimeProps 如何访问到当前组件实例的 props 在 componentMeta.runtimeProps 中,我们构造一个 selector 函数用于访问当前组件 props: const divMeta = { componentName: "div", runtimeProps: ({ selector }) => { const name = selector(({ props }) => props.name) return { fullName: `full-${name}` } } element: /** ... */}; 首先支持从 runtimeProps 回调里拿到 selector,并且该 selector 支持传入一个回调函数,该回调函数的参数中 props 指向当前组件实例的 props,通过该方法就可以访问组件 props 了。 该 selector 仅在 props.name 改变时重新执行,并且也遵循 compare 对比规则,即当 props.name 变化时,selector 回调函数的返回值通过 compare 与上一次值进行对比,如果没有变化就返回上一次的旧值,变化了则返回新值。默认对比函数为 shallowEqual,与 useDesigner 类似,也可以在第二个参数位置覆写 compare 方法。 那组件元信息如何访问内置静态方法呢?由于静态方法引用不变,因此可以在 selector 同级直接传入: const divMeta = { componentName: "div", runtimeProps: ({ addComponent }) => { return { add: () => { /** addComponent(...) */ } } } element: /** ... */}; 如此一来,我们就将数据流与组件元信息打通了,即 UI 可以通过 useDesigner 访问与操作数据流,组件元信息也可以直接拿到方法,或通过 selector 拿到数据,相应的也可以访问与操作数据流。这样的设计在以后拓展更多组件元信息函数时,都可以继承下来,开发者只要学习一次语法,就可以获得非常强力的拓展性。 拓展应用状态与静态方法刚才介绍了一些内置的状态(componentTree)与方法(addComponent),在下一接会系统介绍笔者梳理了哪些内置状态与方法。首先抛开内置状态与方法不谈,应用肯定需要定义自己的状态与方法,我们可以提供两种模式给用户。 第一种是应用的状态与方法定义在外部,对应受控模式。 假设你的应用在对接 Designer 之前就已经用 Redux、Dva、Zustand 等状态管理库,那么就可以使用受控模式直接接入: const App = () => { // 伪代码,不管是 useState 还是其他数据流管理状态,假这里拿到了数据与方法 const { getAppInfo } = useSomeLib(); const { userName } = useSomeLib("userName"); return <Designer actions={{ getAppInfo }} state={{ userName }} />;}; 将方法传给 actions,状态传给 state。 第二种是应用的状态与方法通过 <Designer> 定义,对应非受控模式。 假设你的应用之前没有使用任何数据流,那么也可以直接将 Designer 的数据流作为项目数据流使用: import { createMiddleware, createDesigner } from "designer";const middleware1 = createMiddleware({ state: { userName: "bob " }, actions: { getAppInfo: () => {} },});const { Designer } = createDesigner(middleware1);const App = () => { return <Designer />;}; 通过 createMiddleware 创建一个中间件定义状态与函数,传入 createDesigner 即可生效。 也可以在 createMiddleware 里通过第二个参数定义自定义 hooks,或者拿到方法更改 State: const middleware1 = createMiddleware( { state: { userName: "bob " }, }, ({ setState }) => { const setUserName = React.useCallback((newName: string) => { setState((state) => ({ ...state, userName: newName, })); }); return { setUserName }; }); Designer 内部采用最朴素的 Redux 管理状态,提供了最基础的 getState 与 setState 获取与修改状态,基于它们封装业务函数即可。 无论是受控模式,还是非受控模式(亦或两种模式同时使用),定义的状态与方法都可以在以下两个位置访问,第一个位置是 useDesigner: const { /** 自定义函数 */, setUserName, /** 自定义函数 */ getAppInfo, /** 内置函数 */ addComponent, // 内置变量 componentTree, // 自定义变量 userNamee} = useDesigner(state => ({ componentTree: state.componentTree, userName: state.userName})) 第二个位置是组件元信息上的回调函数,比如 runtimeProps: const divMeta = { componentName: "div", runtimeProps: ({ selector, /** 自定义函数 */, setUserName, /** 自定义函数 */ getAppInfo, /** 内置函数 */ addComponent }) => { const { /** 内置变量 */ componentTree, /** 自定义变量 */ userName } = selector(({ state }) => ({ componentTree: state.componentTree, userName: state.userName })) return { componentTree, userName } } element: /** ... */}; 至此,我们实现了一套完整的数据流定义,包括: 不同 Designer 之间上下文隔离。 可无缝对接项目数据流,也可作为独立数据流方案提供。 内置变量与函数与自定义变量、函数混合。 无论在 UI 通过 useDesigner,还是在组件元信息通过 selector 都可访问这些变量与函数。 总结一个基本可用的可视化搭建框架在本章就算设计完了。但这只是可视化搭建问题的冰山一角,未来的章节,笔者会逐渐为大家介绍更多可视化搭建的设计。 但无论框架未来怎么发展,也永远会基于这前三章的基本设定,总结一下,这三章的基本设定就是:设计一个逻辑与 UI 分离的可视化搭建协议,数据流、组件元信息、组件实例是永远的铁三角,数据流可以对接任意已存在的实现,或基于 Designer 规范实现,组件元信息与组件实例仅存储最基本信息,得益于数据流的自定义能力,以及无论何处都有完全的数据流访问能力,使业务框架既遵循规则,又可以千变万化。 抛开具体 API 设计或者命名不谈,一个有简洁、抽象,又提供极少量 API 却能满足所有业务定制诉求,是可视化搭建永远追求的目标。只要熟悉了这套规范,就可以几乎仅根据业务表现,一眼猜出是基于哪些 API 封装实现的,那么维护成本与理解成本将大大降低,规范的意义就体现在这里。 也许有同学会觉得,现在各个大厂都有无数可视化搭建的实现,可视化搭建概念都已经烂大街了,为什么还要重新设计一个呢? 因为也许数量不代表质量,维护的时间越久,参与的同学越多,越容易使设计变得冗余,概念变得复杂,要对抗这些递增的熵,唯有不断重新设计,从零开始反思方案。 下一讲理论思考会少一些,介绍可视化搭建框架会考虑内置哪些变量与方法。 讨论地址是:精读《画布与组件元信息数据流》· Issue ##466 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"组件值与联动","path":"/wiki/WebWeekly/可视化搭建/组件值与联动.html","content":"当前期刊数: 273 组件联动是指几个组件相互关联。也就是当一个组件状态变化时,其他组件可以响应。 组件联动是多对多关系的,且目的分为一次性与持续性: 多对多关系:即一个组件可以同时被多个组件联动;多个组件可以同时联动一个组件。 一次性与持续性:一次性事件可以被覆盖,持续性事件会同时生效,且要考虑叠加关系。 一定程度上,持续性事件可以覆盖一次性事件的场景:组件永远响应最后一个过来的事件即可。 接下来我们引入 组件值 与 值联动 两个概念,来实现持续性联动功能。 组件值每个组件实例都有一个唯一的组件值。 我们可以通过 getValue(componentId) 与 setValue(componentId, value) 访问或更新组件值: const table = { componentName: "table", runtimeProps: ({ componentId, setValue }) => ({ // 给组件注入 onChange 函数,在其触发时更新当前组件实例的组件值 onChange: (value) => setValue(componentId, value), }),}; 也可以通过 componentMeta.value 声明组件值,比如下面的例子,让组件值与 props.value 同步: const table = { componentName: "table", // 声明 value 的值为组件 props.value 的返回值,并随着组件 props.value 的更新而更新 value: ({ selector }) => selector(({ props }) => props.value),}; 以上两种方式任选一种使用即可。 为什么一个组件实例只有一个组件值? 一个组件可能同时拥有多个状态,比如该组件内部有一个输入框,还有一个按钮,可能输入框的值,与按钮的点击状态都会对其他组件产生联动效果。但这并不意味着一个组件实例需要多个组件值,我们可以将组件值定义为对象,并合理规划不同的 key 描述不同维度的值: // 组件值结构{ // 组件内输入框的值 text: '123', // 组件内按钮被按下的次数 buttonClickTimes: false} 为什么不用 props.value 代替组件值? 理论上可以,但这样限定了组件对 props 的定义。也许有的组件用 props.value 描述输入框的值,但也有比如 Check 组件,用 props.checked 表示当前选中状态。只有抽象一个定义与组件元信息的规则,让业务自由对接,才可以让组件值适配任意类型的组件。 值联动有了组件值这个概念,就可以以组件实例为粒度,设计组件的关联关系了。 为了让组件关联更加灵活,我们的设计需要满足以下几种能力: 联动关系支持多对多。 可以随着全局数据状态变化,或者组件自身 props 变化,随时改变组件关联关系。 一个组件可以定义其他几个组件的关联关系,哪怕自己不参与到联动关系链中。 当组件实例被删除时,由它定义的联动关系立刻失效。 估我们采用 componentMeta.valueRelates 声明式定义值联动关系: const table = { componentName: "table", valueRelates: ({ componentId, selector }) => { return [ { sourceComponentId: componentId, // 自己为触发源 targetComponentId: selector(({ props }) => props.targetComponentId), // 目标组件 ID 为 props.targetComponentId }, ]; },}; 这样设计可以同时满足以上四个要求,解释如下: 可以在任意组件实例定义多个联动关系,自然可以实现多对多联动。 valueRelates 引入 selector 可以响应 state 或 props 的变化,可以由任意状态驱动联动关系更新。 如果 source 与 target 都不指向自己,则自己不参与到联动关系链中。 声明式定义方式,自然在组件实例被销毁时失效。 那么组件如何响应联动呢?重点就在这里,组件可以通过 selector(({ relates }) =>) 的 relates 拿到自己当前的联动状态,比如: const table = { componentName: "table", runtimeProps: ({ selector }) => { // relates 结构如下,对于每一个作用于自己的组件实例 ID 与最新 value 值都可以拿到 // [{ // sourceComponentId: 'abc', // value: '123' // }] const relates = selector(({ relates }) => relates); return { status: relates.length > 0 ? "linked" : "free", }; },}; 如果我们在 runtimeProps 里使用 selector 监听 relates,就可以在联动状态变化时,驱动组件渲染,并传入联动相关状态;如果在 fetcher 里使用 selector 监听 relates,就可以在联动状态变化时,驱动组件触发查询,等等。 以后我们拓展越来越多的组件元信息回调函数,支持了 selector 之后,都可以声明式的响应 relates 变化,也就是组件可以声明式灵活响应联动,真正意义上让联动可以用在任何场景。 框架没有对联动做太多的联动内置行为,实现的都是灵活规则,虽然业务需要补全不少声明,但胜在灵活与用法统一。 描述联动行为不同的联动可能做不同的事,比如一个输入框组件,可能同时有以下两种作用: 让另一个组件查询条件增加 “where name=” 当前输入框的值。 当组件的值为 “delete” 时,让画布另一个组件隐藏。 为了区分联动的功能,可以在 valueRelates 增加 payload 参数,描述该联动的目的: const table = { componentName: "table", valueRelates: ({ componentId, selector }) => { return [ { sourceComponentId: componentId, targetComponentId: selector(({ props }) => props.targetComponentId), // 作用为目标组件的查询筛选条件 payload: "filter", }, { sourceComponentId: componentId, targetComponentId: selector(({ props }) => props.targetComponentId), // 作用为目标组件是否隐藏 payload: "hide", }, ]; },}; 然后目标组件就可以根据实际情况,在 fetcher 过滤 relates 中 payload="filter" 的值,在 runtimeProps 过滤 relates 中 payload="hide" 的值。 用持续联动实现一次性联动每一次组件更新 value 值后,都会刷新对目标组件 relates 的位置,具体来说,会将其置顶,所以目标组件可以根据 relates 先来后到顺序判断,比如在联动效果冲突时,让排在前面的优先生效。 比如: const table = { componentName: "table", runtimeProps: ({ selector }) => { // 找到最初生效的,payload 为 color 的联动,覆盖 props.color const relateColor = selector(({ relates }) => relates.find((each) => each.payload === "color") ); return { color: relateColor, }; },}; 当另一个组件触发 value 变化时,它会排在目标组件 relates 最前面,这样的话,如果目标组件按照如上方式编写响应代码,就总会响应最后一次生效的联动。 总结这一节介绍了如何设置联动,并引出了组件值概念。 在框架层定义抽象的组件值概念,并通过声明式或调用式对接到 state 状态或组件 props,这种抽象理念会贯穿整个框架的设计过程。相似的 valueRelates 也具有声明式能力,并将联动作用通过 selector 的 relates 对象传递给组件实例使用,让联动的消费灵活度大大增加。 可视化搭建框架设计思路可能都大同小异,但可惜的是,许多搭建框架都对比如联动、查询等场景做了定制化约束,使每个框架或多或少存在着私有协议,而我在这个系列想强调的是,可以进一步抽象,让框架提供业务自由定义协议的能力,而不是提供某个固定的协议。 讨论地址是:精读《组件值与联动》· Issue ##469 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"组件注册与画布渲染","path":"/wiki/WebWeekly/可视化搭建/组件注册与画布渲染.html","content":"当前期刊数: 269 接着可视化搭建的理论抽象,我们开始勾勒一个具体的 React 可视化搭建器。 精读假如我们将可视化搭建整体定义为 <Designer>,那么 API 可能是这样的: <Designer componentMetas={[]} componentTree={} /> componentMetas: 定义组件元信息的数组。 componentTree: 定义组件树结构。 只要注册了组件元信息与组件树,可视化搭建的画布就可以渲染出来了,这很好理解。 我们先看组件树如何定义: 组件树组件树里有各组件的实例,那么最好的设计是,组件树与组件实例结构是同构的,称为 ComponentInstance - 组件实例: { "componentName": "container", "children": [ { "componentName": "text", "props": { "name": "我是一个文本组件" } } ]} 上面的结构既可以当做单个组件的 组件实例信息,也可以认为是一个 组件树,也就是组件树的任何组件节点都可以拎出来成为一个新组件树,这就是同构的含义。 我们定义了最最基础的组件树结构,以后所有功能都基于这三个要素来拓展: componentName: 组件名,描述组件类型,比如是个文本、图片还是表格。 props: 该组件实例的所有配置信息,透传给组件 props。 children: 子组件,类型为 ComponentInstance[]。 每一个概念都不可或缺,让我们从概念必要性再分析一下这三个属性: componentName: 必须拥有的属性,否则怎么渲染该节点都无从谈起。所以相应的,我们需要组件元信息来定义每个组件名应该如何渲染。 props: 即便是相同组件名的不同实例,也可能拥有不同配置,这些配置放在 props 里足够了,没必要开额外的其他属性存储各种各样的业务配置。 children: 理论上可以合并到 props.children,但因为子组件概念太常见,建议 children 与 props.children 这两种位置同时支持,同时定义时,前者优先级更高。 除此之外,还有一个可选属性 componentId,即组件唯一 ID。我们从可选性与必要性两个角度分析一下这个属性: componentId 的可选性:组件实例在 组件树的路径 就是天然的组件唯一 ID,比如上面的文本组件的组件唯一 ID 可以认为是 children.0。 componentId 的必要性:用组件树路径代替组件唯一 ID 的坏处是,组件在组件树上移动后其唯一性就会消失,此时就要用上 componentId 了。 一个好的可视化搭建实现是支持 componentId 的可选性。 组件元信息接着上面说的,至少要定义一个组件名是如何渲染的,所以组件元信息(ComponentMeta)的必要结构如下: const textMeta = { componentName: "text", element: ({ name }) => <span>{name}</span>,}; componentName: 定义哪个组件名的元信息。 element: 该组件的渲染函数。 实现这些最基础功能后,虽然该可视化搭建器没有任何实质性的功能,但至少完成了一个核心基础工作:将组件树结构的描述与实现分开了。哪怕以后什么功能也不再增加,也永久的改变了开发模式,我们需要先定义组件元信息,再将其放置在组件树上。 对于画板工具软件,如果不考虑布局等复杂的画布功能,该结构描述足以完成大部分工作的技术抽象:配置面板修改组件实例的 props 属性,甚至布局位置也可以存储在 props 上。 对于 element 的命名,可能会产生分歧,比如还有其他命名风格如 render、renderer、reactNode 等等,但不管叫什么名字,只要是基于 React 响应式定义的,最终应该都殊途同归,最多对于各类 Key 的名称定义有所不同,这块可以保留自己的观点。 我们继续聚焦组件元信息的 element 属性,看以下 element 代码: const divMeta = { componentName: "div", element: ({ children, header }) => ( <div> {children} {header} </div> ),}; 上面的例子中,我们可以识别出 children 与 header 类型吗?可以识别一部分: children: 一定是 React 实例,可以是一个或多个组件实例。 header: 可能是数字、字符串,也可能是 React 实例。 props.children 对应了 componentInstance.children 描述,那么如何识别 header 是一个普通对象还是 React 实例呢? Props 上的 ComponentTreeLike 属性ComponentTreeLike 指的是:组件 props 属性上,识别出 “像组件实例的属性”,并将其转换为真正的组件实例传给组件。 假设一个正常的 props.header 值为 "some text",那么组件 props 实际拿到的 props.header 值也是字符串 "some text": { "componentName": "div", "props": { "header": "some text" }} const divMeta = { componentName: "div", element: ({ header }) => ( <div> {header} {/** 字符串 "some text" */} </div> ),}; 如果将 props.header 写成类 children 结构,可视化搭建框架就会识别为组件实例,将其转化为真正的 React 实例再传给组件: { "componentName": "div", "props": { "header": [ { "componentName": "text" } ] }} const divMeta = { componentName: "div", element: ({ header }) => ( <div> {header} {/** React 组件实例,此时会渲染出组件实例 */} </div> ),}; 这样设计是基于一个原则:组件树应该能描述出任何组件想要的 props 属性。我们反过来站在 element 角度来看,假设你注入了一个 Antd 等框架组件,如果在不改一行源码的情况下,就希望接入平台,那平台必须满足可配置出任何 props 的能力。除了基础变量外,更复杂的还有 React 组件实例与函数,现在我们解决了传组件实例的问题,至于如何传函数,我们下一小节再讲。 这样设计存在两个缺陷: 由于 ComponentTreeLike 会自动转成实例,所以没有办法让组件拿到 ComponentTreeLike 的原始值。 由于 ComponentTreeLike 位置不确定,为了避免深层解析产生的性能损耗,只解析 props 的第一级节点会导致嵌套层级较深的 ComponentTreeLike 无法被解析到。 如果要解决这两个缺陷,就需要在组件元信息上定义 Props 的类型,比如: const divMeta = { componentName: "div", propTypes: { header: "element", content: ["element"], tabs: [ { panel: "element", }, ], },}; 解释一下上面的例子代表的含义: header: 是单个 React Element。 content: 是 React Element 数组。 tabs: 是一个数组结构,每一项是对象,其中 panel 是 React Element。 这样配合以下组件树的描述,就可以精确的将对应 element 类型转化为组件实例了,而对于基本类型 primitive 保持原样传给组件: { "componentName": "div", "props": { "header": { "componentName": "text" }, "names": ["a", "b", "c"], "content": [ { "componentName": "text" }, { "componentName": "text" } ], "tabs": [ { "title": "tab1", "panel": { "componentName": "text" } } ] }} 如此一来,没有定义为 Element 的属性不会处理成 React 实例,第一个问题就自然解决了。通过配置更深层嵌套的结构,第二个问题也自然解决。 componentMeta.propTypes 之所以不采用 JSONSchema 结构,是因为框架没必要内置对 props 类型校验的能力,这个能力可以交给业务层来处理,所以这里就可以采用简化版结构,方便书写,也容易阅读。 注意:propTypes 中 {} 表示 value 是对象,而 [] 表示 value 是数组。为数组时,仅支持单个子元素,因为单项即是对数组每一项类型的定义。 给组件注入函数现在已经能给 componentMeta.element 传入任意基础类型、React 实例的 props 了,现在还缺函数类型或者 Set、Map 等复杂类型问题需要解决。 由于组件树结构需要序列化入库,所以必须为一个可以序列化的 JSON 结构,而这个结构又需要暴露给开发者,所以也不适合定义一些 hack 的序列化、反序列化规则。因此要给组件 props 注入函数,需要定义在组件元信息上,由于其定义了额外的 props 属性,且不在组件树中,所以我们将其命名为 runtimeProps: const divMeta = { componentName: "div", runtimeProps: () => ({ onClick: () => { console.log('click') } }) element: ({ onClick }) => ( <button onClick={onClick}> 点击我 </button> ),}; 点击按钮后,会打印出 click。这是因为 runtimeProps 定义了函数类型 onClick 在运行时传入了组件 props。 当组件树与 componentMeta.runtimeProps 同时定义了同一个 key 时,runtimeProps 优先级更高。 总结本节我们介绍了组件注册与画布渲染的基础内容,我们再重新梳理一下。 首先定义了 <Designer /> API,并支持传入 componentTree 与 componentMetas,有了组件树与组件元信息,就可以实现可视化搭建画布的渲染了。 我们还介绍了如何在组件元信息定义组件的渲染函数,如何给渲染函数 props 传入基本变量、React 实例以及函数,让渲染函数可以对接任何成熟的组件库,而不需要组件库做任何适配工作。 但这只是可视化搭建的第一步,在真正开始做项目后,你还会遇到越来越多的问题,比如除了渲染画布,还要在业务层定义属性配置面板、组件拖拽列表、图层列表、撤销重做等等功能,这些功能如何拿到画布属性?如何与画布交互?runtimeProps 如何基于项目数据流给组件注入不同的属性或函数?如何根据组件 props 的变化动态注入不同函数?如何保证注入的函数引用不变? 要解决这些问题,需要在本章的基础上实现一套系统的数据流规则以及配套 API,这也是下一讲的内容。 讨论地址是:精读《组件注册与画布渲染》· Issue ##464 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"组件值校验","path":"/wiki/WebWeekly/可视化搭建/组件值校验.html","content":"当前期刊数: 275 组件值校验,即在组件值变化时判断是否满足校验逻辑,若不满足校验逻辑,可以拿到校验错误信息进行错误提示或其他逻辑处理。 声明 valueValidator 可开启值校验: import { ComponentMeta } from "designer";const input: ComponentMeta = { componentName: "input", element: Input, valueValidator: () => ({ required: true, maximum: 10, }),}; 如上面的例子,相当于对组件值做了 “不能为 undefined 且最大值为 10” 的限制。 可以内置 JSONSchema validate 的全部校验规则作为内置规则。 支持拓展自定义校验规则。 支持异步校验。 可以用 selector 绑定任意变量(如全局状态 state 或者当前组件实例的 props 来灵活定义组件值校验规则)。 当校验出错时,框架也不会做任何处理,而是将错误抛给业务,由业务来判断如何处理错误。 接下来我们来详细说说每一项特征。 错误处理定义了组件值校验后,当校验错误出现时,可以通过 selector 的 validateError 拿到错误信息: const input: ComponentMeta = { componentName: "input", element: Input, valueValidator: () => ({ required: true, maximum: 10, }), runtimeProps: ({ selector }) => ({ errorName: selector(({ validateError }) => validateError.ruleName), errorMessage: selector(({ validateError }) => validateError.payload), }),}; ruleName: 校验规则名称。 payload: 该规则未命中时的返回值,校验函数返回什么,这里拿到的就是什么。内置的校验函数返回的是错误信息文案。 拿到校验错误后,通过 runtimeProps 传给组件,我们可通过组件自身或 element 增加统一的组件 React 容器层处理并展示这些错误信息。 也可以使用 fetcher 接收这个错误,并调整取数参数。总之支持 selector 的地方都可以响应校验错误,如何使用完全由你决定。 自定义校验规则createDesigner 传递的中间件可以拓展自定义校验规则: import { createMiddleware } from "designer";const myMiddleware = createMiddleware({ validateRules: { // 自定义校验规则,判断是否为空字符串 isEmptyString: (value, options?: { errorMessage?: string }) => { if (value === "") { return true; } return options.errorMessage; }, },}); 通过 validateRules 定义自定义校验规则后,就可以在 valueValidator 中使用了: const input: ComponentMeta = { componentName: "input", element: Input, valueValidator: () => ({ isEmptyString: { errorMessage: "字符串必须为空", }, }),}; 用 selector 绑定校验规则利用 selector 将校验规则绑定到任意状态,比如: const input: ComponentMeta = { componentName: "input", element: Input, valueValidator: ({ selector }) => selector(({ props }) => props.validator),}; 上面的例子,将所有组件名为 input 组件的校验规则绑定到当前组件实例的 props.validator 上。 const input: ComponentMeta = { componentName: "input", element: Input, valueValidator: ({ selector }) => selector(({ state }) => state.validatorInfo),}; 上面的例子,将所有组件名为 input 组件的校验规则绑定绑定到全局状态 state.validatorInfo 上。 异步校验将自定义校验函数定义为异步函数,就可以定义异步校验。 const myMiddleware = createMiddleware({ validateRules: { isEmptyString: async (value, options?: { errorMessage?: string }) => { await wait(1000); if (value === "") { return true; } return options.errorMessage; }, },}); 如上所示,定义了 isEmptyString 的错误校验规则,那么当校验函数执行完后,在 1s 后将会出现校验信息。 总结组件值校验依然提供了强大的灵活拓展性,以下几种定制能力相互正交,将灵活性成倍放大: valueValidator 利用 selector 绑定任意值,这样既可以定义固定的校验规则,也可以定义跟随全局状态变化的校验规则,也可定义跟随当前组件实例 props 变化的校验规则。 在此基础上,还可以自定义校验规则,且支持异步校验。 更精彩的是,对值校验失败时,如何处理校验失败的表现交给了业务层。我们再次依托强大的 selector 设计,将校验错误传给 selector,就让校验错误的用法产生了无限可能。比如用在 runtimeProps 可以让渲染响应校验错误,用在 fetcher 可以让查询响应校验错误。 讨论地址是:精读《组件值校验》· Issue ##473 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"自动批处理与冻结","path":"/wiki/WebWeekly/可视化搭建/自动批处理与冻结.html","content":"当前期刊数: 279 性能在可视化搭建也是极为重要的,如何尽可能减少业务感知,最大程度的提升性能是关键。 其实声明式一定程度上可以说是牺牲了性能换来了可维护性,所以在一个完全声明式的框架下做性能优化还是非常有挑战的。我们采取了两种策略来优化性能,分别是自动批处理与冻结。 自动批处理首先,框架内任何状态更新都不会立即触发响应,而是统一收集起来后,一次性触发响应,如下面的例子: const divMeta: ComponentMeta = { // ... fetcher: ({ selector, setRuntimeProps, componentId }) => { const name = selector(({ props }) => props.name) const email = selector(({ props }) => props.email) fetch('...', { data: { name, email } }).then((res) => { setRuntimeProps(componentId, old => ({ ...old ?? {}, data: res.data })) }) }}const App = () => { const { setProps } = useDesigner() const onClick = useCallback(() => { setProps('1', props => ({ ...props, name: 'bob' })) setProps('1', props => ({ ...props, email: '666@qq.com' })) }, [])} 上面例子中,fetcher 通过 selector 监听了 props.name 与 props.email,当连续调用两次 setProps 分别修改 props.name 与 props.email 时,只会合并触发一次 fetcher 而不是两次,这种设计让业务代码减少了重复执行的次数,简化了业务逻辑复杂度。 另一方面,在自动批处理的背后,还有一个框架如何执行 selector 的性能优化点,即框架是否能感知到 fetcher 依赖了 props.name 与 props.email?如果框架知道,那么当比如 props.appId 或者其他 state. 状态变化时,根本不需要执行 fetcher 内的 selector 判断返回引用是否变化,这能减少巨大的碎片化堆栈时间。 一个非常有效的收集方式是利用 Proxy,将 selector 内用到的数据代理化,利用代理监听哪些函数绑定了哪些变量,并在这些变量变化时按需重新执行。 笔者用一段较为结构化的文字描述这背后的性能优化是如何发生的。 一、组件元信息声明式依赖了某些值 比如下面的代码,在 meta.fetcher 利用 selector 获取了 props.name 与 props.email 的值,并在这些值变化时重新执行 fetcher。 const divMeta: ComponentMeta = { // ... fetcher: ({ selector, setRuntimeProps, componentId }) => { const name = selector(({ props }) => props.name) const email = selector(({ props }) => props.email) }} 在这背后,其实 selector 内拿到的 props 或者 state 都已经是 Proxy 代理对象,框架内部会记录这些调用关系,比如这个例子中,会记录组件 ID 为 1 的组件,fetcher 绑定了 props.name 与 props.email。 二、状态变化 当任何地方触发了状态变化,都不会立刻计算,而是在 nextTick 时机触发清算。比如: setProps('1', props => ({ ...props, name: 'bob' }))setProps('1', props => ({ ...props, email: '666@qq.com' })) 虽然连续触发了两次 setProps,但框架内只会在 nextTick 时机总结出发生了一次变化,此时组件 ID 为 1 的组件实例 props.name 与 props.email 发生了变化。 接着,会从内部 selector 依赖关系的缓存中找到,发现只有 fetcher 函数依赖了这两个值,所以就会精准的执行 fetcher 中两个 selector,执行结果发现相比之前的值引用变化了,最后判定需要重新执行 fetcher,至此响应式走完了一次流程。 当然在 fetcher 函数内可能再触发 setProps 等函数修改状态,此时会立刻进入判定循环直到所有循环走完。另外假设此次状态变化没有任何 meta 声明式函数依赖了,那么即便画布有上千个组件,每个组件实例绑定了十几个 meta 声明式函数,此时都不会触发任何一个函数的执行,性能不会随着画布组件增加而恶化。 冻结冻结可以把组件的状态凝固,从而不再响应任何事件,也不会重新渲染。 const chart: ComponentMeta = { /** 默认 false */, defaultFreeze: true} 或者使用 setFreeze 修改冻结状态: const { setFreeze } = useDesigner()// 设置 id 1 的组件为冻结态setFreeze('1', true) 为什么要提供冻结能力?当仪表盘内组件数量过多时,业务上会考虑做按需加载,或者按需查询。但因为组件间存在关联关系,可视化搭建框架(我们用 Designer 指代)在初始化依然会执行一些初始函数,比如 init,同时组件依然会进行一次初始化渲染,虽然业务层会做一些简化处理,比如提前 Return null, 但组件数量多了之后想要扣性能依然还有优化空间。 所以 Designer 就提供了冻结能力,从根本上解决视窗外组件造成的性能影响。为什么可以根本解决性能影响呢?因为处于冻结态的组件: 前置性。通过 defaultFreeze 在组件元信息初始化设置为 false,那么所有初始化逻辑都不会执行。 不会响应任何状态变更,连内置的 selector 执行都会直接跳过,完全屏蔽了这个组件的存在,可以让 Designer 内部调度逻辑大大提效。 不会触发重渲染。如果组件初始化就设置为冻结,那么初始化渲染也不会执行。 怎么使用冻结能力?建议统一把所有组件 defaultFreeze 设置为 true,然后找一个地方监听滚动或者视窗的变化,通过 setFreeze 响应式的把视窗内组件解冻,把移除视窗的组件冻结。 特别注意,如果有组件联动,冻结了触发组件会导致联动失效,因此业务最好把那些 即便不在视窗内,也要作用联动 的组件保持解冻状态。 总结总结一下,首先因为声明式代码中修改状态的地方很分散,甚至执行时机都交由框架内部控制,因此手动 batch 肯定是不可行的,基于此得到了更方便,性能全方面优化了的自动 batch。 其次是业务层面的优化,当组件在视窗外后,对其所有响应监听都可以停止,所以我们想到定义出冻结的概念,让业务自行决定哪些组件处于冻结态,同时冻结的组件从元信息的所有回调函数,到渲染都会完全停止,可以说,画布即便存在一万个冻结状态的组件,也仅仅只有内存消耗,完全可以做到 0 CPU 消耗。 讨论地址是:精读《自动批处理与冻结》· Issue ##484 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Tableau 入门》","path":"/wiki/WebWeekly/商业思考/《Tableau 入门》.html","content":"当前期刊数: 115 1. 引言引用著名瑞典统计学家 Hans Rosling 的一句话:想法来源于数字、信息,再到理解。 分析数据的最好方式是可视化,因为可视化承载的信息密度更高,甚至可以从不同维度对数据进行交互式分析。今天要精读的文章就分析了经典可视化分析工具 Tableau:data-visualisation-made-easy。 2. 精读Tableau 是一款广泛用于智能商业的强大数据分析工具,通过不同可交互的图表和仪表盘帮助你获得业务洞见。 安装Tableau 提供了三种使用方式: Tableau Desktop 拥有 14 天免费试用的桌面版,可以将工作数据存储在计算机本地,如果你是学生或老师可以获得一年的免费使用权。 Tableau Public 公开版完全免费,和桌面版的唯一区别是,所有数据都无法保存在本地,只能保存在 Tableau 服务器的云端,而且是公开的。 Tableau Online 网页版也完全免费,是 Tableau Public 的网页版。 连接数据源安装好 Tableau 后,第一步就是连接数据源。它支持连接本地或云端的数据源,本地最常用的数据源可以从 Excel 转换。这里是一份 样例数据,包含了一个超市几年内的销售情况,我们可以用这份数据练手。 下载好这份数据后,选择从 Excel 导入,确认后将 Orders 表拖拽到右侧区域,如下图所示: 可以看到,导入的数据格式有些问题,这是因为这份 Excel 文件表头有一些描述信息干扰。勾选 Use Data Interpreter 后,可以开启数据解析功能,自动分析出你想要的表结构: 可以看到表结构已经正常了,在数据清洗的过程中,Tableau 强大的数据分析功能已经初见端倪。你甚至可以点击 Review ths results 看看它是如何清洗数据的:点击后会下载一份分析 Excel,其中过滤掉的数据会被标记,自动分析出的表结构会被高亮。 数据可视化在页面最底部有几个切换项,依次是 Data Source:数据源、Sheet:工作簿,后面跟随的三个按钮可以继续创建多个 Sheet、Dashboard、Story,这些后面都会讲到。首先点击 Sheet 进入可视化分析的工作簿: 可以看到,Orders 表的字段已经被自动分析成 维度 度量 了。维度和度量是数据分析中重要的概念: 维度: 维度是不能被计数的字段,一般为字符串或离散的值,用来描述数据的维度。 度量: 度量是可以被计数的字段,一般为数字、日期等连续的值,用来描述数据的量。 右侧空白区域是图表展示区域,可以响应拖拽交互,顶部的 Columns、Rows 表示列与行,Filters 是过滤器,拖拽字段上去可以对此字段进行过滤,Marks 是标记,Tableau 将图表所有辅助标记功能都抽象为:颜色、大小、文本、具体值、工具提示。举个例子,如果将销量 Sales 字段拖拽到大小区域,那么任何能描述大小的图表,都会以销量的多少来决定大小,比如散点图。 右上角的 Show Me 是图表自动推荐区域,当你拖拽不同字段的时候,Tableau 会自动展示合适的图表,但你也可以点击 Show Me 进行图表切换。 那么开始动手吧!首先我们要看看大盘数据如何,也就是这家超市的总利润、质量、销量: 在左侧维度栏目下,最后一个字段 Measure Names 表示所有度量的集合。 将 Measure Names 拖拽到画布的空白区域。 移除我们不关心的 Row ID, Discount 等字段。 可以看到,总利润大概是总销量的 10%。如果想展示横向表格,将 Measure Names 从 Rows 拖拽到 Columns 即可。 Tips: 为了方便区分,Tableau 贴心的将维度标记为蓝色,度量标记为绿色。同时可以看到,Tableau 对于单指标拖拽,默认采取表格方式渲染。 接下来我们要看每一年的详细销量与利润: 将 Order Date 与 Sales 拖拽到 Rows。 右键 Sales,将类型从连续改成非连续,这样就会自动变成表格展示。 为了展示利润,将 Profit 字段拖拽到 Marks 的 Text 字段上。 我们可以看到,无论是销量还是利润都在逐年上升。接下来我们想具体看看每个月份的数据: 右键 Order Date,将日期维度从年切换到月。 我们可以看到,销量较高的月份分布在:3、9、11、12 月。注意由于没有对年份做筛选,这里的每月统计数据是整合了 2013~2016 四年份的。也就是 1 月的数据其实代表了 2013.1 + 2014.1 + 2015.1 + 2016.1 共四个 1 月份数据的总和。 接下来我们想了解销量与利润增长的趋势: 将 Order Date 拖拽到 Columns。 将 Sales 拖拽到 Rows,此时会出现一条线。接下来将 Profit 拖拽到 左 Y 轴。 这里就涉及到线图拖拽交互设计了,线图一共有三种拖拽方式。如果将一个新字段拖拽到左 Y 轴,就会在左 Y 轴多出一条线;如果拖拽到中间图表区域,则这个字段会当作已有字段的工具提示;如果拖拽到右 Y 轴,则会自动变成双轴图。 从上图中能看到,销量增长明显,但利润增长缓慢,看来经营是存在一定问题的,还要继续分析问题在哪。 我们再看看数据按月分布情况,同样右击 Order Date,选择 月 粒度: 上图可以明显看到三个峰值出现在 3、9、11 月份,然而这段期间利润增长幅度却不大,可以看出这段期间采取了薄利多销的手段。 再从地区维度分析数据: 将 Regions 和 Sales 拖拽到 Columns。 切换到饼图。 将 Sales 拖拽到 Marks Pane 的 Label 上。 可以看到东西部地区是销量最高的区域。接下来我们想看具体城市的销量: 将 States 拖拽到画布空白区域,此时会自动出现地图并定位到美国。将 Profits 拖拽到 Color。 将地区切换到 Filled Map,将 Profits 拖拽到 Label。 这样就绘制了一张地区,颜色越深利润越高,数字表示销量。 可以看到数值越大的区域一般颜色也越深,但这不是分析利润/销量性价比的最佳方式,我们先只看到加州和纽约是销售业绩最好的区域,而科罗拉多州虽然销量不错,但利润却是负的。 上面的地图对地形比较直观,但要分析销售健康度,还是用散点图更合适。我们想看看城市销量/利润的健康度分布: Profit 拖拽到 Columns,Sales 拖拽到 Rows,此时散点图出现,但只有一个点(之所以出现散点图,是因为横纵轴拖拽的都是度量)。 我们想按城市下钻,只要把 State 拖拽到 Detail 即可。 可以看到,遥遥领先的城市有三个,加州是销售之王。 由于还没有介绍到筛选条件,这里简略介绍一下,其实还可以将年份拖拽到筛选条件,只看 2013 年的分布图,也可以点击或圈选其中某些点选择排除某些城市。 现在需要进一步分析明细数据,将不同商品种类按年份细分,看按月的销量,并看看这些月份的利润如何: 此时需要用到高亮表格。首先将 Category 和 Order Date 拖拽到 Rows,简单的表格出现了。 将 Order Date 再拖拽到 Columns,并右键将其粒度改为月。 在 Show Me 中切换为 Highlight Table,重新将 Order Date(Year)拖拽回 Rows。 为了展示颜色与文字,将 Profit 拖拽到 Color,Sales 拖拽到 Label。 可以看到,办公套件和科技产品业绩最好,其中办公套件在 2015 年 12 月销量利润双丰收,科技产品在 2015 年 10 月与 2016 年 3 月销量利润双丰收。整体来看前半年是淡季。 但这张图无法看到销量与利润性价比关系,我们要找出利润率最高的商品和利润率最低的商品: 将 Proft 拖拽到 Columns。 将 Sub-Category 拖拽到 Rows。 切换到 Horizontal Bars。 将销量 Sales 拖拽到 Color。 可以明显看到 Copiers 就是性价比之王,拥有最高的利润,但销量却不是很高(颜色深度中等),而桌子是性价比最低的,利润为负,而且销量不低。 其他功能除了上面基本可视化分析能力之外,Tableau 还有许多辅助功能。 筛选器在按月分布的折线图中,如果我们只想看某一年的,可以将 Order Date 拖拽到 Filters 区域,只勾选想要保留的年份: Tablueau 这种交互等价于 Sql 中 in 语句,当然 Tablueau 还支持更复杂的条件或代码表达式,这里只是将更友好的筛选方式优先展示区来。 上卷下钻Tableau 支持任意维度之间的上卷下钻,只要你将他们分好组。 比如将 Order Date、Order ID、Ship Date、Ship Mode 拖拽到一起,成为 Orders 组;将 Category、Sub-Category、Product ID Product Name 形成 Product 组: 我们就可以将 Product 直接拖拽到画布区域,并选择矩形树图,通过点击指标上的 “+” “-” 号进行上卷或下钻: 上卷下钻是顺序相关的,比如 Product - Order Date 表示在产品类目基础上,对每个类目按日期下钻。而 Order Date - Product 这个顺序,表示在日期分布的基础上,对日期按产品类目下钻,了解不同日期下每个产品的分布情况。 趋势线为使用趋势线,先制作一个双轴图: 将 Sales 与 Profit 拖拽到 Rows。 将 Order Date 拖拽到 Columns 并切换到月维度。 选择 Show Me 的 Dual Combination 即混合图。 点击 Analytics Tab,将 Trend Line 拖入 chart 中: 趋势图有几种算法,比如线性,Log 或指数,因此在做趋势分析前,首先要判断自己的业务属于哪种增长阶段,如果是爆发期可以选择指数,平稳期可以选择线性等等。 预测回到按月分布的图表,如果我们想预测未来销量和利润的走势,可以使用预测功能: 切换到 Analytics Tab,并将 Forecast 拖拽到图表中。 可以点击右键配置预测参数。 预测趋势有一个浅色区域,表示预测范围。 聚类象限图的四象限是多维度综合判断的法则,然而 Tableau 支持的聚类分析可以自动做到这些: 切换到 Analytics Tab,选择 Clusters。 可以选择自动聚类个数,也可以手动指定个数。 从上图可以看到,指定了 4 个分类,最右上角加州就是最突出的一组,整个聚类只有它一个元素,而画面偏左下角的也是一类,这些是业绩较差的一组数据。使用了 K 均值聚类算法,并且当你点击右键查看详细星系时,还能把组间、组内方差展示出来: 仪表板仪表板可以将多个 Sheets 内容聚合在一起并自由布局,但仪表板最精髓的功能是图表联动功能: 点击任意图表,选择 “作为筛选条件”。 Tableau 的所有图表都支持点选,排除等操作,那么点选这类操作本质上其实是个筛选的过程,比如柱状图点击了某根柱子,可以认为是选择了这根柱子当前的维度值作为筛选条件。 当一个 Sheet 作为筛选条件后,类似点选这种操作产生的筛选就会作用于其他同数据集的图表,因此如上图所示,当点击了条形图的某一根柱子时,上面的销量地图也自动做了筛选,仅展示当前选中的产品的销量分布。 故事Story 更像是 PPT,将分析后有价值或有意义的图表组合在一起,再配合上说明,得出一些结论: 如上图所示,比如得到这家超市的大盘数据,这一般也是数据分析的最后一步,最后生成报表。 3. 总结Tableau 的交互式分析思路印证了这句话: 数字、信息,再到理解最终才能产生 Idea。我们从拿到 Excel 导入数据集开始,数据就已经变成了维度和度量的信息,再经过主动思考,将同一份数据进行不同维度的展示,最终得出加州销量最好、家具销售业绩最差、而桌子是负利润的主要来源等等洞见。 通过原文对 Tablueau 功能的分析能看到,Tableau 的核心资产是具备交互式分析能力的图表,这些图表通过智能推荐的方式展示出来,可以在不知道如何分析数据时找到一些灵感,真正做到以数据角度思考,图表展示只是辅助的视觉效果。 目前国内还处于报表制作的时代,即先选择报表再配数据集,这种使用思路是展示数据优先,而不是分析数据优先,笔者认为原因在于国内大部分做报表的业务场景都处于最末端,也就是数据洞见已经有了,再使用 BI 将这个洞见还原出来。而 BI 工具真正想做的还是在前面 “分析洞见” 这一步,希望数据分析师能可以通过 BI 平台挖掘出商业洞见。 要走到这一步,需要国内 BI 平台与使用 BI 的人都发展到下一阶段,而这种探索式数据分析功能早在 2012 年就在国外由 Tableau 团队实现,相信未来三年内国内一定能迎来一波探索式数据分析浪潮! 讨论地址是:精读《Tableau 入门》 · Issue ##192 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《为什么专家不再关心技术细节》","path":"/wiki/WebWeekly/商业思考/《为什么专家不再关心技术细节》.html","content":"当前期刊数: 103 1. 引言本周的精读是有感而发。 笔者接触前端已有八年,观察了不少前端大牛的发展路径,发现成功的人都具有相似的经历: 初期技术热情极大 -> 大量标志性技术项目 -> 转向综合性思考 -> 带团队/关注方法论 也就是专家们变得越来越不关心技术细节。需要说明是的,这里说的专家不再关心细节,不代表成为专家后学不会细节,也不代表专家不了解细节。 早期挺难理解这种转变的,笔者在学校里的知名度来自于前端做得精深,一根筋钻研技术的人眼里是容不下沙子的,所以当初为一些前辈转到管理特别不理解,认为他们背叛了前端。 不过笔者的观念也在逐渐发生转变,渐渐自己也在朝着当初反感的方向发展,觉得这一定不是偶然,所以就整理了一下感悟,希望可以证明这个发展路径的必然性。 2. 精读 Warn:本文所说的技术专家,仅针对研究上层技术的专家,不包括底层技术专家。在 Google 底层专家人数极少,大部分专家都要走业务技术的路线。 首先我们要明确技术员与科学家的区别,为业务提供技术支持都是技术员,所以前端是一门技术,不是科学。 另外,技术的发展需要商业推动,没有使用场景的国家是很难推动技术进步的,科学除外。 所以业务技术是具备可持续发展的路线,毕竟大家都要吃饭,有业务价值的项目会活下来,附着在业务上的技术才能活下来,才有可能开枝散叶。 本文将从三个点去解释,为什么专家看上去越来越远离技术细节。 2.1 技术细节对个人的重要性是在变化的随着工作年限增加,技术细节重要性在慢慢降低,反之技术视野重要性在慢慢增加。 在找工作初期,技术细节是重要的敲门砖大学毕业的那段时间,技术细节是一块重要的敲门砖,只有掌握好技术,才会有公司愿意要你。 这也是为什么说毕业生不要一进公司就谈战略,因为时机不对。 技术不是科学,普通人下功夫可以学会学习技术不需要很聪明的头脑,只要肯下功夫,拥有不错的理解能力,任何人都可以把技术细节搞清楚。 也就是学习技术细节是没有技术门槛,随着年龄的增加,如果只累积了大家都能学会的内容,那么当旧知识被淘汰后,学习新知识的速度又不如年轻人快,会逐渐失去经验优势。 那么如何利用无门槛的特征,将其变为门槛呢?任何年龄段学习技术细节都很容易,应该在你需要深入细节的时候再深入进去,不需要深入的时候把时间花在了解宏观架构上。 就是培养高效的学习能力,能准确判断某个技术细节是否有必要掌握,如需要该如何快速掌握核心内容,并在掌握之后不留恋,可以快速抽身出来继续全局性思考。这种思维是有门槛的,技术专家都可以做到这一点。 做成事不一定要搞懂细节乍一看有点匪夷所思:不了解细节怎么能做成事? 虽然理解技术细节可以做成事,但做成事不一定需要理解业务细节。 这要看怎么理解业务与技术的关系,比如建设 “数据联邦”,光是了解各个不同的存储系统技术细节可能就要花很久,而实际上是没必要将所有技术细节都弄懂的,只要定好一个通用交互规范,各存储系统各自封装一套符合这个规范的交互接口即可。 做成事往往需要宏观的技术思维,需要将许多技术点链接在一起。举个例子,做成事就类似于军官指挥作战,做成的目的是通过制定打法赢得战争,而不是自己冲锋陷阵并测量敌人壕沟的宽度。关心技术细节只是最终落实到每个人具体实施项中的一部分,技术细节的目标累加起来才能做成事。 2.2 搞清楚业务对技术的真实诉求业务期望通过技术实现功能,所以技术专家要做的是如何更好的实现业务需求,这就意味着理解业务需求是第一重要的能力。试想一个不能理解业务要做什么的人,即便懂得再多技术细节,对业务也是没有价值的。 业务思维是解决问题,技术思维是创造问题拥有技术思维的人,容易沉迷于解决不切实际的问题,或者是别人解决过的问题。这种思维对技术学习是非常有帮助的,但如果长期不能转变这种思维,对公司来说是无法创造什么价值的。 拥有业务思维的人,首先要懂业务,只有懂业务,跟着对的业务,才能对未来有信心,知道自己的付出可以换来回报。 懂业务后,才知道如何通过技术帮助业务获得成功。 比如在一家创业公司,老板的眼光很准,进入的时机较早,市场是一片蓝海。你通过分析后,发现要帮助业务占领市场,只要利用某个成熟技术框架快速迭代,就可以在短期帮助业务赢得市场。但是这个框架定制能力不强,如果新需求来了可能需要花时间重构掉。此时技术思维的人只会考虑代码维护性,提出自研一套框架,而拥有业务思维的技术专家会决定先用成熟的技术快速作出原型,等业务稳定后再重构掉。 当然现在互联网市场竞争很激烈,低技术门槛的蓝海基本已都变成了红海,上面提到的场景可能比较少见,我们更多需要决策的是未来几年内业务的收益是否值得现在投入的研发资源。 两个会写框架的人,不如一个能决策的人另一个简单的例子就是,假如技术专家只会一头扎在技术细节里,对各种前端框架的实现了如指掌,大家都能造出优雅、易用、可维护,而且还带有各自 “特色优势” 的框架或者轮子,那么团队很容易陷入两个专家屁股决定脑袋的技术纷争中。这种情况下,两名技术专家的产出甚至不如一个实习生大,毕竟实习生直接拿来开源框架上手,99% 的情况可靠性比前端专家自己造的轮子更好。 从另一个方面来说,现阶段前端界能写出 React、Vue 框架的人太多了,已经写出来的类 React、Vue 的框架也数不过来。去掉为了练手而做的项目,真正希望推广出去给别人用的还占绝大多数,这是开源界典型的问题:重复低水平造轮子不需要理由,推广给你用也不需要负责任。由于框架属于互联网虚拟资产,边界成本为零,这决定了框架市场一定是个大寡头市场,不可能有类似的项目通过一些不痛不痒的特色分一杯羹。那么就算招 10 个会写框架的人进入公司架构组,最后只有两种可能:要么架构臃肿,每个人都把自己的一部分功劳加入进去;要么就是选择一个更不好的方案,这样不会损害任何一位架构师的利益。 所以现在公司更倾向于内部培养人才,因为内部的人了解业务需要什么,创造的价值往往比空降的架构师更大。 宽广的技术视野更容易借力现在技术点越来越多,如果什么技术细节都要详细了解,最终一定不能有很好的全局视野。比较好的状态是找几个重点深入了解,其他的技术点在掌握了全局技术视野后再考虑深入。 在互联网初期,很多技术框架还不完善,技术借力的意义不大,毕竟也没有多少东西可用。 但是现在无论前端还是后端的技术、轮子已经眼花缭乱了,能掌握这些已有技术的人,价值已经逐渐大于会完整了解某些技术细节的人。一个优秀的专家应该能快速定位要解决的业务问题是否有成熟的技术方案,如何以最小的投入产出比实现,同时保持良好的维护性应变业务维护。 2.3 仅仅技术好是无法成为专家的技术专家真的代表技术壁垒很强的人吗?是的,但只有技术能力是不够的。 为什么开源项目后期要寻找协作者?我做开源项目的初期,所有框架和源码都事必躬亲,觉得自己有更好的点子可以胜过其他框架。初期很少有贡献者参与,当然我也不愿意其他贡献者参与,毕竟他们不了解设计理念,只有我自己的修改可以让我满意。 还有谁比作者更了解他的开源项目呢?那为什么一个大型开源项目运作到后期,基本都是协作者在维护? 因为开源是一件系统化的事情,如果你想长期维护他,必须建立好文档系统,让你的思路可复制,让他人可参与。如果开源项目只有你一个人懂,那么同时维护两个、四个、六个的时候,你定会发现力不从心。 至于一些开源大神一人维护几百甚至上千 Repo,背后一定有更多的贡献者支持,一个人就算辞职在家专职做开源,也很难同时维护超过 10 个开源项目。你需要拥有开放的心态让更多人加入进来,将成就感和荣誉感分一些给贡献者,他们才会持续为项目贡献。 能够调用资源才能成为专家开源界就是项目抢占关注度的游戏。假设开源社区总人数为 100,你的项目能够吸引到 10 个人浏览,5 个人使用,2 个人贡献,基本就能存活下来。而开源社区至少有 100 个项目,社区总人数不足以支持每一个项目,只有获得足够关注度的项目才能保持长青。 公司内也是如此,专家级以上的 Title 会要求协作能力,可以调动身边甚至其他部门资源的人才能在公司发挥更大的价值。 CEO 通过顶层设计调动了全公司资源,而业务线总裁通过任务拆解调动了整个业务线的人,通过层层目标拆解,并保证每一层都能充分调动下一层所有资源,公司才能高效的运转。 如果一直关心技术细节,你永远是一个孤立节点,在任何维度的组织中都是最底层,就算 24 小时不睡觉,也最多算两个人力资源。想要突破一天 24 小时的限制,就要花时间让别人认同你的设计,并朝着一个方向努力,你的节点才能上移,但随之而来的是承担更多风险,比如分配给别人的任务给弄砸了,为公司带来了不良影响,那么负责人就要背锅。 3. 总结总结一下,本文的观点是: 技术细节学习难度不大,在需要深入的时候再深入了解最佳。 想要做成事,需要更宏观的技术思维,所以专家渐渐变得眼光宽阔,格局很大。 专家拥有快速学习技术细节的能力,只是这已不是其核心竞争力,所以与其写技术细节的文章,不如写方法论的思考带来的价值更大。 指引方向比走路更重要,专家都要逐渐成为引路人。 技术最终为业务服务,懂技术细节和让业务先赢没有必然的关系,所以在深入技术细节之前,要先理解业务,把握方向,防止技术细节出现路线问题。 讨论地址是:精读《为什么专家不再关心技术细节》 · Issue ##153 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《从 0 到 1》","path":"/wiki/WebWeekly/商业思考/《从 0 到 1》.html","content":"当前期刊数: 131 1 引言《从 0 到 1》是一本创业经典,创业非常有魅力,需要多种维度的商业知识,包括基础经济学、公司经济学、商业学、公司金融学、甚至历史学等等。 为什么要懂历史学?因为《从 0 到 1》这本书的作者是 彼得·蒂尔,他是 Paypal 的创始人和投资家,想读懂他的书就必须读懂他自己的创业经历,而 Paypal 的成长经历需要以考究历史的思维学习,了解什么是 Paypal 黑帮,他与其他公司的关系,为什么 Paypal 是继英特尔时隔 20 年之后的互联网黄埔军校。 为什么要懂商业学?本书第一句话就是 “在商业上机会只有一次”,这是商业基本准则之一。商业不是物理学,没有必然因果关系,没有商业必胜法。同时,商业也是训练多维度思考的战场,对一个商业结果的解读多种多样,我们需要避免对结果的简单归因、过度解读、甚至是本末倒置。《从 0 到 1》这本书抓住了创业成功的精髓。 《从 0 到 1》这本书,就是在商业这种复杂环境下,尝试总结一套通用的成功经验。然而前面我也说了,商业没有必胜法,那什么才是驱动成功与发展的根本引擎?就是创新。 2 概述 & 精读未来的挑战什么人能胜任未来的挑战?彼得蒂尔认为,有创新能力的人可以,所以他面试时喜欢问:“有什么你与其他人有不同看法,但你觉得却很重要的事”。能真正回答好这个问题的人才算具备了基本创新能力。 人类技术演进分为 水平进步与垂直进步,水平进步是从 1 到 N 的规模化应用,而垂直进步是从 0 到 1 的创造,虽然水平进步可以给发展中国家带来巨大发展速度,但真正推动历史变革的还在于垂直进步。 对于创业团队,独立思考与速度很重要,因此团队规模要尽量小。彼得蒂尔对 Paypal 的管理理念重点有二:招人越像越好、极端聚焦,Paypal 在早期时隔工程师都是 UIUC 毕业的,5 个非技术人员都是彼得蒂尔在斯坦福校友网络认识的,背景非常趋同,因此沟通成本非常低,决策效率很高。彼得蒂尔要求员工的年终总结必须明确写出 “对公司最有价值的一个贡献”,只能写一个。 根据 Paypal 发展经历来看,难怪《从 0 到 1》这本书会强调小团队高灵活的重要程度,因为 Paypal 就是这么起家的。 像 1999 年那样狂欢1993 年网景公司的成立拉开互联网时代的序幕,Paypal 就是这个时代成立的。 互联网狂欢兴起: 互联网泡沫破裂: 自 1999 年之后,市场学会了保守,主要有四条: 循序渐进的发展。 保持精简和灵活。 不要贸然开辟新市场。 专注产品而不是营销。 显然,1999 年互联网泡沫破裂后的美国企业家害怕了,逐渐走向了保守。然而彼得蒂尔认为,1999 年互联网泡沫破裂的虽然惨烈,但正因如此才带来了美国未来几十年的增长。 保守无法带来成功,相反,这四条的反面反而更正确: 大胆尝试胜过平庸保守。 坏计划也好过没有计划。 竞争性市场对收益有负面影响。 营销和产品同样重要。 狂妄自大的尝试必定导致大部分人悲惨的失败,但我们别无选择,创业必须创新,必须实现从 0 到 1。 所以彼得蒂尔反直觉的观点就是,我们不能因为吸取 1999 年的教训就变得保守,反而美国需要 1999 年那股狂热驱动新的创新。 所有成功的企业都是不同的彼得蒂尔完美解释了垄断的价值。 市场分为充分竞争与完全垄断,看上去充分竞争的市场更有活力,更健康,但实则不然。充分竞争将利润完全吞噬,只有完全垄断才能获得持久价值,最终对市场有利。 对创业者来说也一样,如果你相信充分竞争,你只会创建一家同质化的公司,扎到红海里拼命挣扎,这不会给你带来持久的利益,也不会给市场带来真正的发展。 垄断者为了逃避垄断保护法,会竭尽全力证明自己没有取得垄断地位(甚至随时会被市场吃掉),同理,竞争者为了自我麻痹或争取到投资,也会竭尽全力证明自己还有机会,市场并未形成垄断。 然而无论怎么说,真正为市场创造独一无二价值的还是垄断者,虽然他们看起来很可恶。 不仅在商业如此,互联网公司内部技术竞争也一样:低水平的重复竞争挑战者会竭尽全力证明自己所在的领域不存在垄断,然后投入人力做一个注定会失败的项目,不仅无法为公司产生新的价值,还带来了资源内耗。相反,那个垄断者才是为公司源源不断带来价值的引擎,虽然竞争者们都厌恶它。这也是为什么阿里鼓励高水平竞争,禁止低水平重复轮子。 竞争意识大家觉得竞争理所应当,但其实竞争更多带来的是伤害。 在奇葩说里听到薛兆丰这么一句话:“求职者你们的竞争对手不是企业,而是其他求职者”。说的很有道理,真正的伤害是在竞争中产生的,而存在供需关系的公司与求职者之间哪存在什么竞争?直白一点说,如果整个市场只有一个应聘者,哪怕小学没毕业,阿里腾讯也会抢着要。 竞争使我们过度看中过去的机会,而忽略创造新的可能性。 就像 Paypal 与 X 合并一样,彼得蒂尔发现这两家公司的竞争关系是恶性的,只有合并后形成垄断才能创造新的价值。而 X 公司的创始人就是埃隆·马斯克,虽然最后因为极力推广 X 品牌被合并后的 Paypal 请出局后,依然在 Paypal 被 20 多亿美元收购后,获得了一亿多美元回报,才创建了特斯拉和太空探索公司,真正为社会创造新的价值。 后发优势既然垄断如此重要,那么如何打造垄断? 首先一个企业的价值是它未来创造利润的总和。也许你会奇怪,为什么企业现在的资产不算做企业价值呢?企业价值一般指的是企业市值,企业市值描述的企业价值其实是它的 当前投资价值,一个不能在未来创造利润的企业,就算现在坐拥几千亿美元的资产,对你来说也是没有投资价值的。 建立企业垄断,可以建立企业的护城河,比如专利技术或者网络效应;或者先进入小市场,逐步扩大范围,就像亚马逊从图书在线交易切入,随后扩张到全品类。与你的对手产生放大收益,你不能仅仅取代你的对手,最好能为它赋能。这些都是企业的后发优势。 成功不是中彩票虽然大部分成功创业者都会将一半功劳归功于运气,但你最好不要真的相信,否则为什么有那么多连续失败的创业者呢?如果创业需要运气,那为什么彼得蒂尔要写《从 0 到 1》这本书,为什么我还要精读它呢? 成功者的运气是靠努力换来的。 国家就是一个巨大的创业,彼得蒂尔对当下各国对未来看法划出了四象限图: 明确乐观的未来:1950~1970 的美国,当时美国创新能力和工程应用都在上升期,未来是明确且乐观的。 不明确乐观的未来:1982 至今的美国,由于技术发展遇到了瓶颈,比如生物制药和医疗都有巨大不确定性,人们只知道未来是美好的,但不知道何时可以到来。 明确悲观的未来:现在的中国,由于缺乏核心创新能力,现在中国迅猛发展其实在吃发达国家创新的红利,只是将这些技术规模化应用,所以发展方向是明确的,但一旦红利吃完,不确定自己是否能找到新的突破点,因此对未来是悲观的。 不明确悲观的未来:现在的欧洲,技术红利和规模化都吃完了,不知道未来该怎么走,也不知道走向哪里。 不论国家还是公司,在这个时代想要拥有最好的未来,就是不明确乐观的未来,虽然这个乐观是不明确的,也就是需要运气,但只要在正确的方向努力,总是可能会成功。如果你真的相信比尔盖兹成功来源于运气,那请理解这是一个明确的运气,而不是不明确的运气,并不是所有方向的创业都可能走向成功。 向钱看当爱因斯坦宣称复利是“世界第八大奇迹”,因为钱可以生钱,本质原因是指数级增长。指数级增长之所以如此可怕,还因为并没有证据表明爱英斯坦说过这句话,但因为他的影响力有指数级影响力,所有有影响力的话可能都会 “归功给他”。 风险投资领域也是如此,一家风投最成功的项目带来的收益可能超过其他所有项目的总和,所以风投才会不断给有发展潜力的企业加注,这都是因为指数级效应。 所以如果你创业的公司不能成为幂次法则指数增长的类型,最好尽快换一个项目,因为做一个平庸的项目是没有意义的,世界的天枰都会为头部项目加码。 秘密企业只有创新才能获得成功,那一定是发现了新的 “商业秘密”。 但现在社会发展遇到了瓶颈,大家都不愿意探索新的秘密,主要有四个原因: 认为已经没有新的秘密。就像探索世界一样,当地球完全被开发,已经没有探索的必要。 规避风险。害怕没有找到秘密而耽误自己的人生。 自满。安于现状,认为不需要探寻新的秘密。 扁平化。由于互联网对社会的连接,我们更容易觉得竞争是全球化的,如果有新的秘密,一定会更优秀的人发现,而显然我不是最优秀的人,所以我没有必要去发觉秘密,那些最优秀的人会帮我做到。 想要扭转这个悲观思想,你需要意识到现代分工是极度专业化的,不同领域间往往很难竞争,一个物理学家可能难于解决情感问题,要相信还有许多未被关注的细分领域可能存在蓝海。 基础决定命运就像宪法决定了国家基础一样,企业最初决定的重要思想对未来发展起到决定因素,比如行业方向与招聘要求。 因此初创公司一定要确保创始人团队之间是否有默契,所有权、经营权和控制权是否分配合理,不要有兼职员工,最好以股权激励员工。 在技术领域做架构设计也是如此,架构基础决定了未来发展命运,我们必须尽可能保证早期架构设计的合理性,并坚持这些原则,就像坚持宪法一样。 黑手党式的机制为什么 Paypal 早期员工被称为 Paypal 黑帮?其实彼得蒂尔创建的 Paypal 由于触及到金融领域,相关利益方非常复杂,对于没有政府背景的他来说几乎是不可能做成的。 Paypal 招来的早期员工必须极度认同其企业文化,认同 “创造虚拟货币代替美元” 这个疯狂的想法。 Paypal 黑帮对公司的使命有着近乎于 “邪教” 般的信仰,唯一区别是,他们做的事情本身并不坏。 顾客不会自动上门销售和技术同样重要。 在工程技术界,技术打造的产品功能界限清晰,不是生效就是失效,而销售界,需要通过精心设计活动来打动用户的芳心,但却不能改变产品的实质性内容。技术内容是务实的,销售内容是务虚的,但我们不能说务实一定比务虚重要。 销售的技巧也随着业务场景的不同而不同。 复杂营销。当面对大企业客户时,甚至要克服政治惰性说服政府太空飞船采用你们公司的技术,而一旦完成协议的签署,哪怕只有几单,也足够维持公司后续发展了。 人员营销。和复杂营销相反,需要从具体场景逐渐深入,比如 Box 公司的云存储服务,首先卖给了斯坦福睡眠诊所,之后逐步扩展到整个斯坦福大学,但如果 Box 一开始就和斯坦福的校长洽谈整个学校的云服务方案,可能一开始就会失败。 病毒式营销。Paypal 的增长过程就是病毒式营销的范例,通过邀请机制传播给好友,并给最多 20 美元的奖励,也就是获客成本 20 元支撑了 Paypal 病毒式营销的成立。 然而 Paypal 也不是漫无目的的砸钱,首先它砸钱有自己的原因,因为 Paypal 是一个拥有网络效应的项目,因此拥有越多的用户就能带来越多的未来价值,这是 Paypal 可以选择烧钱营销的最大原因。 其次 Paypal 也选择了两个聪明的营销方式,第一是通过邮箱营销,由于当时世界上拥有邮箱的用户很少,都是一些对新技术持有开放态度的用户,因此邮件营销的人群就比较正确。后来 Paypal 发现,eBay 有部分商家甚至主动在商户页面贴出注册 Paypal 的链接,不仅是为了赚取佣金,更因为 Paypal 网络支付的最大场景就是电商交易平台,因此后续 Paypal 重点转向 eBay 推广。 人类和机器机器未来并不是为了取代人类,而是辅助人类更高效工作。 在 精读《刷新》 中,微软 CEO 萨提亚·纳德拉也提到了人与机器的关系 - “机器替代人类工作的过程,也是人类逐渐拾回作为人的尊严的过程。人本就应该将时间用于思考与创造,而不是重复性劳动。” 有意思的是,彼得蒂尔在创立 Paypal 过程中由于遇到不法分子盗刷信用卡的问题,因此专门研究网络安全并研发出验证码、数据分析等一直沿用至今的重要网络安全技术,甚至在 Paypal 被 eBay 收购后,彼得蒂尔还专门成立了 Clarium Capital 公司为政府提供安全服务,其核心技术就是在 Paypal 期间为了对抗支付安全问题时打下基础的。 所以彼得蒂尔在思考机器和人类关系时,会重点关注机器帮助人类提升价值的领域。其中有一句话触达了问题本质:“机器不会有利己的诉求,因此价值最终会转移至人类”。 只要机器永远不要求自我价值的实现,人类和机器就能和平共处下去。 绿色能源与特斯拉由于彼得蒂尔与埃隆·马斯克曾经互为敌友关系,因此就关注到了特斯拉与绿色能源的问题。 彼得蒂尔认为,绿色能源技术要思考好如下 7 个问题: 工程问题,如果一个新技术不能带来本质的突破,那么其未来增长价值就不明显,公司的未来也不够清晰,狂热的投资注定引发泡沫。新能源技术目前带来的提升不是数倍的,因此前景不明确,无法说服大家一定去用这个产品。 时机问题,目前新能源领域技术并没有质的突破,现在进入注定面临技术储备不足的问题。 垄断问题,新能源技术是否能够垄断?新能源公司可能在故意隐瞒自己在市场中的渺小程度,其实相对于全球能源市场,新能源只是很小的子版块,整个行业总市值可能都不大。 人员问题。新能源是个技术问题,但现在融资需要 CEO 们西装革履的到处募集资金,这是严重的人员问题。 销售问题。人们对新能源领域、新能源汽车的接受程度有多大?是否足够便捷? 持久问题。随着中国在新能源市场的加入,导致美国新能源企业增长疲软,所以指责中国的声音很多。这是个危险的信号,如果成为垄断者需要以指责的方式进行,注定会失败。另外化石燃料随着液压破碎法的成熟,导致 2008 年天然气价格下降了 70% 多,新能源已不再是解决能源问题的唯一破局方式。 秘密问题。节省能源是一个政治正确的问题,大家都在呼吁要环保,那么这就证明环保项目一定有市场?不一定。 特斯拉的成功是因为解决了这 7 个问题,并且从实际的小领域切入,并且和政府以及其他企业达成了技术合作。这说明,在能源 2.0 市场中,企业面临的主要挑战是如何找到一个正确的小型市场。 创始人的悖论这个章节,彼得蒂尔分析了各种名人或创业者的特质,内容非常丰富,由于篇幅限制就不展开了,而且由于笔者在这方面缺乏相应的阅历,很难原汁原味的还原出他对每个名人的评价,因此细节还是推荐阅读原文。 以下只能做简单的总结,只能理解到其中部分思想: 伟人都拥有矛盾的两面性,企业需要极端的创始人,平庸的人往往很难成为好的创始人。 伟人的两面性与其成功路径存在相互塑造的过程,很难说是因为存在矛盾才导致了其成功,还是在成功的过程中塑造了其矛盾的性格。 伟人往往都会亲手终结自己的良好形象,除非英年早逝。 当然,这并不是说为了成功,我们必须成为这样的人,这个章节只是对创始人悖论这个现象的一种解读,可能这是一种自然现象,我们不需要模仿,只需要理解。 对未来的预期哲学家尼克·博斯特罗姆描述了四种预测未来的理论: 兴衰交替。由于历史总是呈现繁荣与衰败的交替,因此未来也很可能逃不出这个循环。 未来稳定发展。按照当今世界发展节奏,最后所有国家都进入发达国家行列,人民生活水平整体提高。 毁灭性衰落。由于地缘政治原因,未来不可避免会发生毁灭性冲突,人类文明可能呈断崖式下跌。 奇点。非常难以预测的加速发展,以至于发展到现在人类难以理解的高度。因为这个概念本身突出的就是 “发展到难以理解的高度”,因此试图去理解它的思考都反而会偏题,因此把它当作一种无法预测的未来吧。 笔者发现,现代大师人物写的书,最后都有对未来的预测,而且大家对未来的预测不同与书籍观点间的差异,往往都是很趋同的,这到底是英雄所见略同还是人类顶级大脑能到达的高度已经达到天花板?这是一个开放问题。 最后,保持独立思考是我们能重构世界的最佳方式。 4 总结那到底什么是创新?巴菲特说过,商业最重要的是护城河,护城河不是什么产品质量、高素质员工、巨大的市场份额。真正的护城河是:企业无形资产比如品牌、高客户转换成本、成本优势、网络效应。Paypal 创新的找到了符合网络效应的业务场景:“网络货币”。 为什么 “网络货币” 拥有网络效应呢?所谓网络效应是指,每新增一个用户,就会对产品价值带来指数级提升。支付网络每增加一个人,不但你可以参与交易,还让交易网络变得更大,让更多交易成为可能,甚至成为全球通用货币,获得比国家货币更强的流通性,而这个质变只需要更多的用户加入即可,这就是它的网络效应。 《从 0 到 1》是一本创新思维的启蒙书,但想要深入理解这本书提供的概念,基本的经济学、商业知识是必不可少的,至少要理解到创新指的是为企业构筑护城河,而网络效应是 Paypal 的一个重要护城河。 类似拥有网络效应的还有 Uber 和 Airbnb,但他们创新思维不同,导致网络效应的大小也不同。Airbnb 的网络效应是全球的,因为场景天然是 “旅游时自有房屋出租”,每成交一对商家与客户,都可能是跨地区的,而且客户也有自己的房子,可能下次自己就会成为商家。而 Uber 业务场景天然是同城的叫车服务,因此无法形成全球的网络效应壁垒,这也是为什么 Uber 无法竞争过中国的滴滴,但 Airbnb 的全球市场地位无人能撼动。 商业领域远远不止于此,研究商业就像研究历史,每个公司都能给我们带来巨大启发。而商业最迷人的地方就在它的非必然性,就算你反复研究历史,熟读《从 0 到 1》这本书,他也无法给你带来必胜的商业操作路径。但这本书真正能带来的是正确而成功的信念,只要确定你的方向是正确的 “创新”,至少你可以正视失败,坦然开启下一段创业旅程,而说不定哪一次就成功了呢。 讨论地址是:精读《从 0 到 1》 · Issue ##219 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《刷新》","path":"/wiki/WebWeekly/商业思考/《刷新》.html","content":"当前期刊数: 116 1. 引言微软的市值已经突破一万亿美元了,我们很难想象当年僵化而封闭的微软是怎么涅槃重生的。从仅支持自家 Windows 到收购 Github、从失去移动操作系统市场到与 AWS 平分云服务市场、从 Windows 收费升级到 Win10 限时免费升级、从咒骂 Linux 是癌症到大部分云服务都跑在 Linux 操作系统上、从反垄断、数据隐私被诉讼大户,到 Facebook Google 被监管部门调查时却可以置身事外,微软一定从内部发生了彻底的变革。 新微软的变革经验值得我们学习,《刷新》 就是一本介绍这场变革的书,它的作者是领导这场变革的现任微软 CEO 萨提亚·纳德拉。 这本书的关键词是:同理心、文化变革、成长型思维。 2. 精读本书围绕家庭与事业两个层面展开,从家庭中得到的领悟帮助作者更好的工作。 萨提亚的家庭作者萨提亚·纳德拉的母亲是一名教师,父亲是一个勤奋的印度高级官员,然而他的成长环境相当宽松,使作者从小就懂得独立思考并按照自己的意愿做事。母亲难以兼顾事业与家庭而选择放弃工作,让作者体会到女性工作的不公平,父亲追求上进心态与行为帮助作者得到更多职业发展机会。而孩子扎因天生的重度大脑性瘫痪使作者学着站在孩子的角度思考问题,学会真正理解同理心。 作者爱好的运动是板球,这是印度最受欢迎的运动。这项运动带给作者的除了热血沸腾之外,还有对团队合作的理解:好的领导不仅自己能力要出色,还要能帮助队员提升信心,发挥队员的潜力,而专业技能优秀的球员,如果不能进行良好的团队合作,最坏的情况甚至会损害团队整体利益。 微软面对怎样的危机危机往往是多个维度体现的,且相辅相成。微软面临的两大主要危机分别是 员工失去信心 与 业绩下滑,员工失去信心是内因,引发了业绩下滑的外因。 在最糟糕的时候,微软内部帮派林立,各部门负责人只想巩固自己的地盘,这让微软失去了创新领域竞争的机会。科技行业的业务趋势总是处于 三浪叠加状态: 旧的领域业绩已经在下滑,但基数大,往往也是公司发家的根基,对部门负责人自己来说,再吃几年老本对自己的利益最大,但这终将导致公司走向失败。 当前领域增长已经逐渐放慢,但未来仍有很大增长空间,这些业务被寄予了厚望。 新的领域尚不清晰,但一旦探索到正确的方向,增长速度甚至会年年翻番,这些业务会在未来几年内成为公司的收入支柱。 微软的个人计算机 Windows 操作系统太过成功,使微软在移动端浪潮下没能将足够的资源投入到移动端业务中,真正的创新部门被边缘化,旧领域部门掌握着绝对话语权,如果 CEO 不能作出改变,公司将走向不可逆的衰亡。 业务上,微软也在这三个主要方向全面落后: 操作系统领域:微软个人计算机出货量和财务增长已陷入停滞,而苹果、谷歌的智能手机和平板电脑销量正在上升。 搜索领域:谷歌的搜索和在线广告收入也在持续增长,而微软的搜索技术才刚起步,市场份额只有竞争对手的零头。 云技术领域:亚马逊推出的 AWS 已经在市场建立起领导地位,微软由于 Windows 原因,不愿意接受云计费模式,还在固守一次性买卖思维,甚至连云产品都没有。 微软是如何转型的站在首席执行官视角,转型一定是从文化转型开始的,只有转变了企业文化,才能充分激发每一个人的潜力,使公司朝着正确方向发展。作者在成为微软 CEO 后,在文化上作出的改变主要分为三点: 找到微软公司的新使命。显然,让每个人都拥有一台电脑这个目标已经达成了,为了推动微软继续前进,作者将新的目标设定为:赋能大众,通过做平台、工具,来提升全社会各组织、团体的工作效率、医疗效率、组织效率等等。 建立耳目一新、出人意料的伙伴关系。不论是 Linux 、苹果公司还是亚马逊,一方面是强劲竞争对手,但另一些领域也有合作的价值,比如将微软办公套件通过 IOS 平台普惠到大众这种部分领域合作的心态是不可或缺的。微软封闭的文化也在这一点上真正转向了开放,独占的思维模式如果走不通,合作能带来更多的机会。 同理心。微软高级副总裁沈向洋在 2019 年极客大会的分享也提到了这一点,微软通过制造辅助设备帮助帕金森患者正常完成写字、绘画。从广义上说,微软正式通过同理心,站在用户角度思考,才领悟到如何才能真正的帮助用户,比如一位安卓用户需要在手机查看 Word 文档,那么让 Word 支持安卓平台,推出基于云平台的 Office 365 就是一个自然的行为。 在文化转型的推动下,微软在业务上也进行了一系列积极的调整: 将云业务放到核心位置。这一点和阿里的云战略转型很像。云业务一开始都不怎么赚钱,需要大量资金和人才投入,在数年后才能看到回报,微软最大的问题是如何打破公司内资源分配不均匀的问题。通过一系列人事调整与战略制定,微软的云业务走上了正规,现在已经与 AWS 平分市场份额。 在可能的领域与竞争对手达成合作。除了推出 IOS 平台的 Office 套件外,必应还成为了雅虎搜索的搜索引擎,微软甚至放弃了排他性条款,允许雅虎同时使用其他公司的搜索引擎服务,即便如此,必应引擎现在仍驱动着大部分雅虎搜索功能,而良好的开放心态也加速必应搜索引擎能力的迭代。 推动部门之间员工的协作。随着文化变革,微软内部部门孤岛的情况有了好转,从不接收其他部门意见的 Windows 研发部门开始采纳其他部门员工提出的建议。笔者了解到 Facebook 的大部分源码每个员工都有充分权限参与修改,维护一个系统不只是相应业务线员工的特权,来自其他部门的创意往往更优秀。 三条领导原则无论是推动文化变革,还是推动业务增长,都需要高级、中层管理人员的实施,作者给出了三点领导原则: 向共事的人传递明确信息。传达信息是领导者每天都在做的事情,领导者应该把信息交流重点放在事情上,而不是人上,也就是关注如何把事情做好,而不是讨论谁更聪明。 领导者要产生能量,不仅在自己团队中,还要在整个公司中。领导者身处在多个圈子中,有自己管理的团队的圈子,也有来自上级组织的圈子,有来自公司级横向委员会的圈子,也有核心管理层的圈子,作者站在 CEO 的角度,要求领导者要将最高一层圈子放在首要地位,也就是整体利益大于局部利益。 找到取得成功和让事情发生的方式。也就是正确的做事,懂得平衡长期利益与短期利益,不走极端;让团队成员找到自己热爱的工作方式;能跨越边界,全球化思维。 其它本文要突出的介绍的内容已经结束,本书还有最后几个部分笔者简要带过: 三大变革: 作者提出未来可能由技术引领行业变革的三个方向:混合现实、人工智能和量子计算。这就是跨越边界的思维方式,微软积极布局的这三个前沿领域,对准的是未来的 “第三浪”。 隐私、安全和言论自由: 捍卫隐私、安全与言论自由也是微软转型的重要内容,微软通过积极与监管部门合作,通过实际行动捍卫言论自由,使得微软从政府监管对象逐渐转变为监管原则的捍卫者,这也是近年来科技巨头纷纷作出一个改变。 人与机器的关系: 不要把机器与人想成竞争关系,要理解为机器辅助人类的关系。同时机器也是释放人类创造力的最重要方式,虽然在变革前期会导致大量失业,但消失的旧行业都是重复性高的,创造出来的新行业更能激发人类的创造力。有一句话笔者印象最深刻:机器替代人类工作的过程,也是人类逐渐拾回作为人的尊严的过程。人本就应该将时间用于思考与创造,而不是重复性劳动。 3. 总结引导微软一系列变革的源泉可以认为是 “同理心”,因为同理心可以练就开放的性格,指引正确的方向。微软 CEO 萨提亚从家庭与生活中养成了同理心,并将其运用在公司的变革上,最终让微软每一位员工都能换位思考,利用同理心做正确的事,这种思想的传导是最难的一步,作者做到了。 对于我们的思考是,无论是公司的管理者,还是基层员工,都应该培养自己的同理心,因为有同理心的人不仅能更好的工作,在生活中也能更融洽的与人相处。 在工作中,同理心也是突破职业天花板的能力之一,想要提升为客户带来的价值,首先要接触并理解客户,站在客户视角思考问题,在面临内部矛盾或外部竞争时,仍能坚守为客户创造价值的目标,下一步改革的方向就会变得清晰,矛盾会逐渐化解,竞争也不会是一个问题,用户想要的不是竞争,而是被赋能,持有这种心态做事,与竞争对手合作就是利益最大化的选择了。 微软的首席执行官萨提亚正因为抱有同理心,才能作出超越竞争、封闭的决策,这对还没能掌握这一心智的公司来说,是种降维打击。一个用一切手段赋能用户、在核心能力不惧竞争(云计算)、在可合作领域充分合作的公司是极其强大的。 讨论地址是:精读《刷新》 · Issue ##196 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《当我在分享的时候,我在做什么?》","path":"/wiki/WebWeekly/商业思考/《当我在分享的时候,我在做什么?》.html","content":"当前期刊数: 137 1 引言很荣幸被评为公司年度十佳作者,被要求写了这篇命题作文。 虽然我写了几年文章,稍稍学会了如何总结,但从来没想过要给自己 “做分享” 这件事做一个总结。这次我决定挑战一下自己,应邀写下这篇文章,谈谈我自己做分享这件事。 我将从 Why、What、How 三个角度去说明做分享这件事,分别阐述为什么做分享,做什么分享,以及如何做分享。 2 精读Why - 为什么要分享在构思《前端精读》这个专栏的时候,那时网上的聚合专栏有很多,一般是每周收集一些优秀的技术、思考文章汇聚成一个列表,特别是一些知名度较高的头部专栏,用户阅读量很大,内容又多质量又好。当时我很羡慕这种模式,因为这种模式不用自己写文章,只要收集文章就可以了,而且在用户的监督下,也会促进你多阅读、多思考。 当时还萌生了另一个想法,就是现在《前端精读》的模式,每周找到一篇文章精读,并分享文章和自己对文章的观点。萌生这个想法的原因是,当时看了一些文章,觉得还不过瘾,想着如果把一系列关联知识串起来文章会更有价值,可是我并不能要求原文作者做这件事,因此就决定自己写关于这些文章的精读,将自己融会贯通后的理解展现给读者。但这样做有一些风险,首先就是自己写文章的要求比较高,我不能确定自己是否能坚持下来,其次是一周只写一篇,总感觉接收的信息量不如做聚合模式的大,毕竟别人一周就能看二、三十篇文章,而自己只能看一篇。 让我下决定的原因是看了一篇商业文章,是著名商业顾问刘润的一个观点:商业世界存在点、线、面、体,比如做一家杂志社就是一个点,做互联网信息收集入口就是线,而微博、微信都是面,整个社交行业就是体,每高一个维度都会对下一维度造成降维打击,所以科技行业才演变这么快,实际上是所处维度的不同。但高维也有自己脆弱的一面,就是竞争非常激烈,一个行业体中,通常是容不下太多面的。 同理,对写作来说,聚合专栏就是线,就像淘宝连接买家与卖家一样,聚合专栏收集优秀作者的文章,利用自己的流量分发给读者,但这个领域必然竞争激烈,当读者有了更好的线,为什么还需要差一些的呢?但做点就不一样了,你可以被无数线和想做线的人需要,你产出自己原创的价值,不会受到太大竞争影响。实际上我的经历也是这样,我可以将文章投放到各个平台(各个线上),这些线都成为了放大我影响力的工具,有越多的线,点的价值就越大,毕竟,想做线的人太多了。 在这里稍稍插一句,反过来,如果所有人都做点,只有极少数人做线,那线必定形成垄断,就像品牌商垄断农民货物一样,因为农民无法直达消费者,只能以很低的价格把农产品卖给品牌商,同样,消费者也只能通过品牌商买到货,所以品牌商就可以肆意加价。但互联网的发展改变了这些,无论是社交电商还是直播带货,都让生产者有了直接触达消费者的机会,就不用担心被中间商赚取差价;再者,如果大家觉得中间商有利可图,大量的品牌出现,生产者完全可以同时给多个品牌商供货,而在互联网分享的信息不会因为在一个平台的传播而消失,我们可以说文章与知识传播的边际成本完全为零,所以可以最大化利用多平台给自己带来优势。 所以我决定做一个点,将《前端精读》这个招牌培养起来。 What - 做什么分享因为我的爱好与职业是前端,所以看上去要做什么分享这件事很简单,只要分享前端技术相关内容就可以了。但这几年持续下来发现,事情远远没有这么简单。 在分享刚一开始的时候,肚子里憋着一堆想说的话,恨不得一天写一篇精读,但奈何精力与表达能力有限,勉强以一周一篇的节奏坚持下来。写作的内容都是自己最熟悉、最想表达的前端技术内容,而且过程中为了活跃团队气氛,还拉上大家一起参与,坚持了蛮长一段时间。然而很快就遇到了第一个问题,坚持力问题。 持续做一件事情总会觉得枯燥,加上业务变得更有前途,大家都越来越忙碌,逐渐出现了下周找不到人写精读的情况,此时我选择顶上空缺。但毕竟那时候精读没有多少人关注,成就感不高,加上没有养成写作习惯,写一篇文章往往要花费一整周的精力,连续写两周就觉得非常痛苦,毕竟把自己的知识与想法写成文章有着不小的成本,对自己非常熟悉的知识感觉写下来有些浪费时间,逐渐觉得枯燥。 在枯燥的过程中,我逐渐培养出更快的写作速度,但一个严重的问题也渐渐浮现出来,我渐渐发现自己的存量知识已经见底,有时间写文章,但却不知道写什么。每周我都会从网上的聚合专栏寻找优秀的文章,但与其说寻找还不如说是过滤,因为很多知识我并没有深入了解,特别是技术领域大部分是英文文章,光看下来就费劲了,更别说写下自己的精读理解。但周更的频率不能停,我只能逼着着自己啃英文文章,从一眼看下去脑袋全懵的状态硬是培养到一眼扫下去就能评估出文章是否值得精读,这是个漫长的习惯过程,因为初期效率很低,唯一坚持下去的理由就是我知道未来阅读速度会越来越快,读英文文章的速度最终是可以追平读中文文章速度的。 渐渐的我可以通过快速阅读,每周掌握一些新知识,并通过与存量知识进行碰撞产生出新的理解,这解决了 “无话可写” 的尴尬情况,毕竟没有人能保证自己的存量知识够自己写 50 篇、100 篇的文章,现在精读更新到 100 多篇,绝大多数内容都是我新学到的,这也是写作带给我无比受益的地方。 前端内容写多了,不免觉得自己知识面还是太狭隘了,每周不是捣鼓新设计模式,就是研究新语法,关注技术新进展,这只能把自己培养成一颗 “黄金螺丝钉”,如果我未来能坚持十年,写了十年基础技术知识,可能也最多成为一颗 “钻石螺丝钉” 而已。我第一次非技术细节文章的尝试是第一百零三期的 精读《为什么专家不再关心技术细节》,这篇文章也道出了我对个人成长的看法:你想发挥更大的价值,就要能影响更多的人,研究 100 年前端技术成为不了马云;同理,让马云写前端,他也不可能一个人写出阿里巴巴。 实际上写作就是一件价值放大的事情,你将自己的优秀理念输出给其他人,让别人写出的代码和你一样优秀,这就可以提升整个团队的工作效率。但这还远远不够,代码只是软件研发流程的一部分,我逐渐发现,把握业务方向、做好团队管理这两大能力才能最大化输出自己的价值,所以后面又写了一些例如 精读《前端未来展望》 对前端进行综合展望,精读《刷新》 对领导力进行领悟,以及一些极客公园系列文章增强对商业的理解,这些看似偏离前端技术的文章最终都是为前端服务,一个优秀的前端 Leader 具备的素质至少有:敏锐的商业嗅觉、清晰的理解业务方向、管理好团队,管理好人才、同时还是一个方向的技术大拿。 通过对商业、业务、管理的学习与写作,我并没有发现在专业知识上有多少延误,反而觉得自己以前认为是核心竞争力的 “技术思考” 变得越来越廉价,毕竟就前端技术甚至所有业务技术来说,理解任何一个技术点都没有绝对的壁垒,只要花费足够的时间就行了,难就难在需要花多久去理解,是否可以快速理解技术,理解业务。 How - 如何做分享写作的时候,只要明确文章主旨,句句点题就不会写的太差,一定不要企图将你的想法在一篇文章中全部表达,毕竟你没写的不代表你不知道,而东拼西凑的文章对读者没什么益处,毕竟读者是为了某个明确目的来读文章的,如果内容和标题关系不大,读者大概率会选择离开。 上面是最基本的写作技巧,我就不继续展开了,接下来要重点聊聊的是前端精读是怎么做分享的。我会从如何写作、如何坚持、如何形成正循环三个方面谈谈自己的感受。 首先是写作方式,前端精读的命题很明确,就是基于某个文章或者观点进行精读,因此每篇文章都有一个明确的主题。第二步是摘要,将文章内容精简的表达出来,这可以锻炼你的总结能力,也让读者能了解到背景知识。第三步是精读,这一步需要你有一些私藏干货,毕竟把文章直接翻译一遍是没有任何价值的,我在精读自己不熟悉领域的文章时经常遇到这个问题,此时我一般会找几篇类似的文章结合阅读,并找到一些可以互补的观点,这样的精读可以让文章的观点更加饱满。最后是总结,总结时可以点题,将重要内容再梳理一遍,也可以进行延伸,指出更进一步的思考方向。 为了让分享坚持下来,我在每周结束之前都会提前立好下周精读的 Flag,在 Github 开一个 issue,这样不仅可以提醒我周末的写作,还可以收获很多来自社区的讨论与反馈,让文章聚集了社区的智慧。这种提前立 Flag 的做法让我想到了自家小区物业费的收取方式,每年年初都会提前征收一整年的物业费,抛开商业手法不谈,这至少意味着物业对业务整整一年的承诺,这种承诺支撑了物业后续一整年的服务,也支撑了每周下一次的精读文章。 同时,我还找到了一种正循环模式促进写作,分别是让写作与工作、与分享、与生活结合。很自然的,我参与的数据中台工作本身就具有很大挑战性,工作中的内容与思考往往会成为精读内容的来源之一,比如之前写过的《手写 SQL 编译器》系列,因为数据工作中真的要用到这些知识。当我参加一些前端大会时,也可以顺便将分享稿整理成精读,将本来就要分享的内容分析得更彻底,有一种借力打力的感觉。在生活中,参加一些论坛,看过的书都可以成为精读的题材,无论是商业的,人文的,还是历史的,对多元化思维有帮助的内容都可以分享。 3 总结回到主题,当我分享的时候,我在做什么?相信看完上面的内容,你已经得到了答案。 当我在分享时,我在传播知识,扩大自己的影响力,这是显而易见动作。但在这背后,我同时也在践行终身学习理念,每次分享都是一次新知识的学习,是一次知识边界的拓展。也许是一次对工作的思考,也许是一次对生活的感悟,然而每一次都是成长的记录。 思想不会因为传播给他人而减少,每一次分享都是在创造永不磨灭的价值,希望看到这篇文章的你也能认知到分享对自己、对他人的帮助,相信分享的力量,相信积累的力量。 讨论地址是:精读《当我在分享的时候,我在做什么?》 · Issue ##229 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《数据之上·智慧之光 - 2018》","path":"/wiki/WebWeekly/商业思考/《数据之上·智慧之光 - 2018》.html","content":"当前期刊数: 106 1. 引言本周精读内容是:《数据之上 智慧之光》,由帆软软件公司出品。 帆软公司是国内一家做大数据 BI 和分析平台的提供商,主打产品是 FineBI。笔者所在阿里数据中台也处于数据分析应用的前沿,本次精读的文章就是帆软公司的 《数据之上 智慧之光 2018》,感谢提供这份国内数据市场研究报告,让我们更深入全面的了解国内数据市场的发展方向。 随着 5G 的逐渐推行,网速比 4G 提高了 100 倍,将会为物联网打下通信基础,未来的世界将人与物、物与物进行互联。随着越来越多的设备接入网络,产生数据,而未来还有 6G、7G 将网速继续提高至 1 万倍、1 百万倍,利用卫星实现全球网络覆盖,将现实与虚拟融合等等,无不需要强大的数据处理分析技术才能掌握。 数据的总量将呈几何倍数上升,如果不能提前对数据的存储、处理、挖掘和分析提出一套解决方案,那么 5G 时代的海量数据就是人类社会的累赘,如果有一套数据处理与分析的方案,我们就有可能掌握海量的数据为自己所用,利用数据进一步推动人类社会向前发展。 上面是对未来的畅想,那么我国现阶段国内的数据市场的容量、需求是什么样呢?《数据之上 智慧之光》这本书给了我们答案。 PS:本文使用 2018 年的数据。 2. 精读大数据行业发展趋势2018 年中国大数据产业规模预计 329 亿元人民币,同比增长 39.4%。可以看到增长速度逐年增加,预计在 2020 年数据市场规模可达 586 亿元人民币。 笔者查了一下,2018 年全国网上零售额为 90065 亿元,比数据市场规模多了一个数量级,所以我国的数据产业其实还在萌芽期,可能还需要 5 到 10 年才能完全成熟,这也意味着目前数据市场是一片蓝海,从后面的数据和国内数据应用使用情况也可以看出来。 另外,各企业在大数据领域的投入资金与部门组织都同比 2017 年有所增加,其中接近四成的受访企业已经在应用大数据,较 2016 年提升了 4.5%,暂不考虑大数据的企业从 2016 年 7.8% 下降到 6.8%。 从微观角度观察社会也能发现这样的趋势,近些年研究大数据的公司明显增多,许多公司都逐渐设立了 “数据分析” 岗位和部门,可视化大屏在 toB 与 toG 领域都越来越得到重视。 企业数据应用情况数据应用分为数据采集、数据治理、数据处理、数据分析这四大阶段,其中数据采集是获取数据的最重要方式,而数据治理是将分散在各种不同形态数据库的文件用统一方式管理起来,比如形成数据联邦,这是数据使用前最重要的一步治理。数据处理就是将数据按照业务需求进行计算,而不同量级的数据计算方式会不同,特别是大数据场景要分为离线计算与实时计算,只有极为重要、实时性要求强的指标才进行实时计算,现在正处于离线与实时计算混合的混合计算转型期。数据分析一般通过 BI 平台完成,也是分析数据最重要的一步,BI 也经历了漫长的版本迭代,第一阶段是数据报表阶段,第二阶段是具备分析能力与数据挖掘能力的分析阶段,第三阶段是机器自动识别用户意图的智能化分析阶段。 从智慧之光的调查结果来看,只有 22.47% 的企业实用了 BI 系统,而使用 BI 系统的企业中,超过七成认为 BI 项目能较好的满足现在的需求。说明未来还会有更多企业使用 BI,BI 的市场还有 4 倍的增长空间。 在数据应用成熟度方面,仅有 3.5% 的企业处于数据盈利阶段,也就是大部分企业对数据的治理还在投入阶段,但无需质疑,持续对数据进行投入一定能得到回报,但短期来看会拖累财务报表。 再看目前企业的数据价值需求,看看业务方对 BI 工具的期望有哪些。 期望从高到低分别是: (72.8%) 整合多系统数据,打通数据壁垒 (69.1%) 提高报表数据效率,更快更准更省事 (53.7%) 辅助管理预测,提高决策成功率 (51.4%) 提高生产效率,降低人力成本 (50.0%) 数据结合管理,优化管理方式 (47.8%) 业务监管分析,促进业务增至 这个排列顺序基本上也是 BI 平台迭代的顺序。 BI 刚起步时都要先做数据整合,对于大部分公司,数据孤岛的情况还是很普遍的,甚至有大量数据分散在各自工作人员电脑的 Excel 文件中,已存在的各业务平台见数据无法打通也很普遍,如果不能将多套系统间数据打通,你就没有对数据的掌控力。像阿里云的 Dataphin 就可以帮助企业建立数仓,建立一套数据资产管理体系,其中第一步就是帮助你打通数据壁垒。 解决取数问题后,就可以建设 BI 平台了,BI 平台初期基本以构建报表为主,而构建报表的方式根据发展阶段也各有不同,下面是智慧之光中一张很经典的 BI 发展阶段: 在 IT-完全主导型阶段,主要任务就是制作报表,而业务人员能配置的部分只有 BI 模版的 5%,剩余 95% 都需要 IT 人员参与开发,不仅浪费人力资源,而且对业务线的时间成本也很高。 IT-强主导型阶段,BI 平台具有一定的配置能力,业务有 20% 的自主配置权,而 IT 仍需完成 80% 的工作。 在业务强主导型阶段,BI 层 80% 的工作都可以由业务方完成,IT 人员只参与 20%,这 20% 可能包括复杂场景的定制,比如电子表格或者复杂的分析功能。这个阶段真正实现了更快更准更省事。 业务完全主导型阶段,基本上 BI 层不需要 IT 人员参与,业务同学可以完全主导对 BI 平台的拓展,或者 BI 平台已经能满足业务线几乎所有的诉求,同时业务还能参与数据模型的控制,让业务能力下沉到数据层。到这个阶段的企业已经非常少了,也许只有少数互联网巨头可以达到这个阶段。 智能自助型,这个阶段不需要 IT 人员参与,业务仅需参与 1%,原因是 99% 的需求都有人工智能自动分析出来,也就是将业务数据拿到后,计算机已经知道该怎么看这份数据了。智能自主型在国内还处于概念阶段,在国外 BI 工具比如 PowerBI 与 Tableau 已经在这个领域深耕多年了,然而门槛比较高,目前效果应该还不太理想,因为这个阶段一旦成熟,国内的 BI 企业将面临巨大冲击,之所以国内处于业务强主导阶段的 BI 平台依然存在,除了数据安全的理由之外,只能认为国外智能自助型 BI 平台依然 “不够智能”。 通过上面的分析可以总结出,BI 平台不仅业务发展阶段迥异,对技术人才的要求在不同阶段也不一样,技术层面需要以 后端 -> 前端 -> ETL -> AI 人才 的递进态势演变,对技术人员来说,如何在 BI 技术演变的过程中不断自我学习,满足下个阶段的技术要求,是非常严峻的挑战。 另一个值得关注的是企业数据来源,根据 2016 与 2017 年的对比,来自企业内部的数据正在逐渐增多,从外部购买的数据从 16.7% 降低到 15.1%,而从政府免费开放的数据比例从 13.5% 提升到了 14.6%。这表示企业正在逐渐摆脱对外部购买数据的依赖,转而产生更多自己业务的数据,而政府也在逐渐加强开放数据建设,努力减少各企业间数据资源的壁垒。 企业数据使用方式根据调查显示: (70.0%)使用传统的 SQL + Excel 分析数据 (64.8%)使用业务系统自带的报表或分析功能 (35.6%)使用 BI 工具 (10.8%)手工写代码 首先频繁的手工写代码只有 10% 不到的比例,这是因为稍稍有点长远打算的企业,都会打造一支技术团队,而业务也会给技术团队打造一些生产效能提升的工具,只有 10% 左右的企业无法割舍短期利益,导致所有数据分析需求都要手工写代码。 大部分企业依然采用 SQL + Excel 分析数据,这个结果在情理之中,因为 SQL + Excel 都是现成的工具,不需要研发成本,而 Excel 的强大分析能力也基本满足了业务需求。但这种模式无法共享分析结果,存在数据安全隐患,且无法进入分析与智能阶段。 使用业务系统自带的报表或分析功能也占了 64.8% 的比例,笔者所了解到的中小型公司也的确属于这个阶段,公司内不同业务线都有自己的业务平台,每个业务平台内都有或多或少的数据分析和报表能力,这对大部分企业来说够用了,但对于要建立 数据中台 的企业来说,分散在各业务系统的数据与报表能力,反而是一种阻碍。PS:阿里数据中台已进入 2.0 阶段,但对大部分企业来说,是不可能越过数据中台 1.0,直接进入 2.0 的,就像不可能跳过 5G 做 6G 一样。 只有 35.6% 的企业在使用 BI 工具,因为使用 BI 工具需要一定门槛,比如做数据治理等,当然也可以直接订购阿里云的 Dataphin 快速接入 QuickBI。 在企业使用 BI 时,选型的考虑因素也很有意思: (69.1%)产品是否高效易用 (59.2%)产品是否稳定性高,性能好 (58.5%)产品是否拥有丰富强大的功能 (51.4%)产品是否具备大数据分析能力 (33.6%)采购成本 (31.2%)生态与学习资源 (24.4%)厂商本身的实力 可以看到,BI 工具靠自身实力吃饭的,而不依赖公司光环,因为业务方对实用性要求更大。 69.1% 的企业看中是否高效易用,说明目前国内企业对 BI 培训能力较弱,希望有高投入产出比,同时也说明了 BI 自身的特性,它是面向非技术人员的产品,如果易用性不强,只是功能强大是没有用的。 59.2% 的企业看中稳定性和性能,这是因为对数据分析来说,看报表是高频操作,业务方会使用 BI 查看 KPI 报表,发日报或月报,用户是无法忍受频繁使用的产品稳定性出现问题的。 第三点就是功能是否强大,对一款面向用户的工具来说,如果功能有欠缺,就意味着无法满足业务需求。比如对折线图做归一化,如果 BI 平台的折线图自身不支持这个功能,使用者也没办法立马拉上一名前端同学拓展出这个功能,因为 BI 平台表面看上去易用,但底层设计复杂,一旦遇到功能不支持,除了等待更新外,没有更好的办法。 最后一个超过 50% 的用户期待就是具备大数据分析能力,这是因为企业数据量级普遍都很大,而 BI 平台底层的多维建模一般采用 OLAP 查询,遇到海量数据可能要等上几十分钟,需要 BI 平台内置一些数据加速的功能。ROLAP 给予关系型数据库,特点是兼容性强、灵活性强,但查询速度慢,而 MOLAP 是实现将各维度数据计算好,查询时直接映射到多为数据库访问,性能好,但是对存储空间的依赖极高,需要付出大量的金钱才能支撑这种模式的查询。 下面是企业对 BI 功能要求: 可以看到,对报表能力需求量最大,说明报表是 BI 工具基础的要求,也说明我国对数据的使用方式还停留在最初级的阶段。 另一个就是移动 BI 需求,在移动端看报表,PC 端做报表已经非常普遍了。 之所以数据填报排到了第三名,是因为不同公司并不是所有数据都统一管理,BI 支持数据填报,就可以将遗漏的数据录入进去。 相信在未来,这个条形图最长边会逐渐移动到腰部。 最后是企业面临的综合挑战: (64.8%)数据的整合与治理 (58.1%)与管理层及业务部门的配合 (51.8%)数据人才的培养 (49.8%)数据分析工具的选择 (42.4%)IT 部门自身的能力提升 (38.1%)衡量数据分析的价值产出 (27.6%)公司重视程度或预算投入 (14.1%)项目风险的控制 数据整合与治理是最大问题再次反映了我国数据可视化处于较为初级阶段,第二名的 “与管理层及业务部门的配合”,也印证了这一点,如何将数据价值传达给管理层,让管理层认可前期投入在未来是可以得到回报的,是在企业里做数据分析比较头疼的问题,而其他业务部门如果不予配合,不将数据交给数据中台部门,又难以解决数据整合的问题,而这个往往又依赖管理层的决定,因此管理层与业务部门的配合问题是相辅相成的。 第三名是数据人才培养的问题,这个问题笔者认为还好,前几年流行大数据人才,近几年流行 AI 人才,我国数据人才应该有不少的储备。 后面几项最重要的就是 衡量数据分析的价值产出,任何做数据的部门,如果不能让数据为公司带来价值,这件事件就没有可持续性。笔者建议从数据整合后的管理提效,节省机器成本的角度计算出收益,从数据分析平台为其他业务部门提供的决策依据,计算出为业绩提高作出的贡献,再从对公司内部做报表、邮件的研发人力节省,管理层快速查看公司整体实时数据分析的角度计算出软贡献价值。 3. 总结尽管 BI 平台与数据分析可以为公司带来巨大的价值,但制作 BI 平台的成本是相当大的,而且 BI 平台具有马太效应,目前国际第一梯队的 Tableau、PowerBI 无论是吸引的人才,投入的资源,市场份额都远超追赶者的总和。 从 17-18,18-19 的 BI 四维度对比可以看出,低端 BI 的角逐正在越来越激烈,行业龙头 PowerBI 与 Tableau 位置越来越稳,国内 BI 龙头 FineBI,以及正在逐渐发力的 QuickBI 希望能挤进国际梯队,在 BI 技术领域拉平与发达国家的差距。 PS:目前国内市场的情况,反而不适应 PowerBI 与 Tableau 阶段的 BI 工具,给国产 BI 工具创造了发展机遇,我们要抓住这次机遇带领中国数据市场走向第三代增强分析型,并使国内 BI 工具在国际市场占有一席之地。 讨论地址是:精读《数据之上·智慧之光 - 2018》 · Issue ##162 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《智能商业》","path":"/wiki/WebWeekly/商业思考/《智能商业》.html","content":"当前期刊数: 108 1. 引言智能商业 是阿里巴巴前总参谋长曾鸣于 2018-11 出版的商业图书,对最近 20 年中国商业以及互联网发展有着深刻的总结,并描述了未来智能商业的蓝图。 笔者之所以读这本书,是因为笔者所在阿里巴巴数据中台,需要更深刻的理解数据,而《智能商业》就提到了数据时代的变革,对笔者工作有所帮助。 但读完这本书后,笔者发现不同人站在不同视角会有不同的理解:如果你是一名数据行业从业者,你可以理解数据在当今行业发展中如何起到作用;如果你是企业高管,你会领悟到商业平台发展的规则;如果你是一名创业者,你能体会到点线面体的存在,找到自己的定位;如果你是一名管理者,你能领域到管理模式正在发生的变化;如果你是一名传统行业从业者,你能体会到为什么互联网会对传统行业带来这么大的冲击;如果你是一名社会评论家,你会找到衡量智能时代对人类社会带来影响的标尺,等等。商业是推动人类社会发展的源动力,甚至也是文化与战争的源头,智能商业正因为将商业讲的通透,才摆脱了普通商业书籍枯燥的理论体系,从社会实践中总结理论,最终能上升到富有哲理的思考。 智能商业一书中有许多关键词,比如 “三浪叠加” “网络协同” “数据智能” “C2B” “S2B2C” “点线面体” “创造力革命” “网红” “互联网 X” 等等,能将这些关键词串起来的,笔者认为是 “商业演化”,在近几十年范围内,商业模式存在一些不变底层逻辑(“三浪叠加” “网络协同” “数据智能”),而在大趋势下存在不断演变的商业模式(“C2B” “S2B2C” “点线面体” “创造力革命” “网红” “互联网 X”)。 读完书后会发现,这么多的关键词,最终都为了实现 “C2B” 这个商业最终演化目标,即便是远在十八世纪的工业革命,也在为 C2B 模式打下让物质资源极大丰富的生产力基础,而网络协同和数据智能,都为了让商业规模更大,精准度更强,可以个性化识别每个用户的需求。新的组织模式也是为了更高效服务用户,整合社会 “点线面体” 的生态关系最终可以形成 “C2B” 的服务网络,而网红、互联网 X 都是 C2B 转型在不同阶段、不同行业的尝试。 2. 精读智能商业全书分为六个章节,分别是 “智能商业”、“商业模式变革”、“战略变革”、“组织变革”、“案例分析”、“关于未来”。 笔者看过一些类似的书评,将书中的观点一一枚举出来,这样的解读笔者认为是难以抓到重点的。看似把重点一一提取了出来,但没有一条 “逻辑线” 将其贯穿,分散的理解任何一个知识点都不会有太大的帮助。而这条 “逻辑线” 其实就是作者的目录组织结构。 任何一本书,写作的目的是作者为了全面阐述一个观点,书中的重点都是一个个割裂的小观点,作者会通过目录方式组织一条最合理的逻辑路线,将这些重点串联起来,最终引出作者想阐述的大观点(智能商业),因此请跟着笔者从这本书的章节结构开始,有一个连贯的理解。 前言 前言笔者认为是最精彩的部分,因为提到了一个核心概念 “三浪叠加”,中国人口众多,土地广袤,互联网发展程度不均衡,因此任何互联网模式都可能存在,再加上互联网自身演化很快,当第二浪盖过第一浪时,第三浪已经悄然形成了,只从规模上可能难以分辨处于尾声的第一浪与处于巅峰的第二浪,更难分辨出还没有起色的第三浪在哪。读完这本书如果能看清楚中国商业发展的前三浪,并预测出未来三浪,目的就达到了。 智能商业 第一章的名字和书名一样,表示我们现在正处于智能商业时代。通过对中国社会的分析,解释了为什么商业时代发展的这么快,而且为什么创业方向那么多,有些行业快速崛起,有些行业快速衰退,而想要抓住未来,就要把握住互联网机遇,利用网络协同与数据智能实现智能商业,然后为什么这样的智能商业模式可以胜出。 商业模式变革 第二章讲的是商业模式由传统的 B2C 逐渐演变到 C2B,而在 C2B 演变的过程中,一种过渡阶段 S2B2C 正在快速崛起,而这些名词并非人为创造,而是商业发展自然演化而来的,能理解到 S2B2C 是通向 C2B 的自然演化路径,自然就能理解现在一些企业模式(比如网红、大搜车)等,也能自然理解 S2B2C 的不足(毕竟是过渡阶段),未来的战略方向自然就清晰了。 战略变革 前两章分别介绍了什么是智能商业,为什么要做智能商业,以及商业模式的演变,那第三章就自然要介绍企业战略变革了。第三章介绍了企业战略如何转型才能应对智能商业的节奏,比如何制定战略计划,以及通过 点-线-面-体 理解企业在市场中的定位,理解了这一点,不仅能理解各企业在市场中定位,还能理解之间相互关系,以及 点-线-面-体 的定位是可以改变的,抓住机遇的企业会逐渐向上发展,失去机遇的企业会逐渐向下退化。 理解了 点-线-面-体 的特性,可以更好的找准自己的定位,越往上资源越多,但排他性就越强,大部分时候,做一个深耕垂直行业的点,虽然同质化可能很多,但竞争不是排他性的,而且有线与面的平台支撑,特别是合理利用多个 “面” 后,可能爆发出强劲的商业价值,比如网红就是同时利用多个 “面” 的典型例子。 从战略变革这一章可以看到,这本书虽然前两章站在 BAT 高级战略的视角俯瞰商业演化,看似与普通企业,普通个人没什么关系,但读到战略变革这一章时,可以明显体会到理解 “面” 与 “体” 角度下商业思维后,可以给 “点” 与 “线” 带来巨大的战略价值。 组织变革 第四章是组织变革,因为当战略变革后,必须轮到组织变革了。工业革命带来了生产力的极大提高,那互联网则带来了创造力革命的浪潮,没有统一机器的约束,每个人都能充分发挥自己的创造力 - 前提是组织管理模式要支持。一个新的组织管理模式不是自上而下的分配任务,而是自下而上,充分发挥每个人创造力的 “赋能” 管理模式。都是互联网的管理模式是打平的,其实这是终极的理想情况,通过形成自组织协同网络,充分调动每一个人的创造力。 案例分析 读到第五章就没有多少新概念了,但第五章是真正把前四章理论映射到现实案例的实战环节,这一章我们能看懂许多企业战略背后的战略模式,都可以归纳到网络协同、数据智能的布局,商业模式都在向 C2B 转型,旧的面被新的面取代而下降为线,线抓住了机遇逐渐发展成面,多个面相互协同逐渐形成了 “体” 等多个维度的变化。 关于未来 第六章是对未来的判断,重点在互联网与传统产业如何碰撞,提出的 互联网 x 概念背后有着更深刻的含义。如果你今年听说了 “产业物联网” 这个名词,可以甄别一下相应的企业,是仅仅将互联网技术运用到了传统行业,还是将传统行业从底层的运作逻辑就互联网化了呢?互联网不仅是一种技术,更是一种思维,互联网思维可以将被传统行业束缚住的各个流程逐渐还原到最原始、高效的模样。 比如说传统工程需要提前计算销量固化产能,但加入了互联网快速反馈的网络,就可以实时调整产能,当然这需要整个生产流程的互联网化,将整个环节都做到快速反馈。 结语 - 新文明:感受未来已来 印象最深的是引用了经济学家周其仁的一句话:“文明的一次次传承和复兴,就是一步步找回对人的尊重”。害怕机器取代人类的思想还是被局限在现有的世界观、价值观之中的,将工人固定在工厂流水线,或者程序员每天写着相似的业务逻辑,本身就是一种践踏人类尊严的行为,而计算机可以逐步取代这些低创造性的工作,可以理解为抢了那些人的饭碗,但站在历史长河的角度,何不是还给人类以尊严? 智能商业首先是分析互联网巨头都至少做对了这三个方向中的两个:在线化、智能化、网络化。 在线化是指将业务都搬到互联网上,这基本是必备的一条。智能化是利用算法打造竞争优势,比如谷歌搜索算法。网络化就是形成多方共赢的协作网络,比如广告主与网站主通过谷歌搜索形成网络化协作。 简介提到的 网络协同与数据智能 就是指后两者,它们之间要形成一种反馈闭环就形成了智能商业的双螺旋: 网络协同 产生数据,通过 数据智能 进行学习,进一步优化 网络协同。 网络协同 需要建立起一张多角色之间的协同网,比如优步组织的司机与乘客的协同网。协同网络越复杂,经济效益越大、门槛越高,比如淘宝的协同网络非常复杂,体现在协同者多(买家,卖家,物流,客服,淘女郎)等等,他们之间也有相互关联,各角色对网络需求粘性强,网络的不可替代性就高。 数据智能 现在所有企业都没有充分利用数据,数据的潜在价值是无穷的,理论上可以利用数据做任何战略决策、管理决策。 而网络化与智能化叠加,会产生黑洞效应,也就是数据越多越吸附数据,网络协同越多就越容易扩张出新的协同。 作者对 互 联 网 这三个字的拆字解读也更容易让我们理解互联网的本质: 联: 联接,从 PC 互联网开始,到移动互联网,再到万物互联,联接内容越来越多。 互: 交互,从一对多的门户时代,到通过关注方式的微博时代,再到社交朋友圈时代,交互越来越简单,越来越频繁,也越来越精准。 网: 网络协同。 看了这么多概念,不知道你是否能理解智能商业的概念呢?也许每个人都有自己的体会,也许智能商业概念难以被定义,但 网络协同、数据智能 一定是核心,谁能充分利用这两股力量,将其充分发挥黑洞效应,形成一套更广泛的“互”,更多的“联”,更复杂的“网”络协同,谁就能更好利用互联网实现智能商业。 商业模式变革商业领域较为常见的模式有 B2B、B2C、C2C。 B2B 代表企业是阿里巴巴、中化网,阿里巴巴是水平 B2B,是指企业与客户之间是平行关系;而中化网属于垂直 B2B,帮助企业寻找上下游合作伙伴。 B2C 代表企业是亚马逊、天猫、京东,也就是直接把商品卖给消费者。 C2C 代表企业是易贝、淘宝,即个人用户服务与个人,淘宝主要是个人用户开网店卖给个人。 而商业模式的变革,是指这些模式最终都要演化为 C2B 模式,即个人提出需求,企业快速满足。按照笔者理解,C2B 是由客户驱动的模式,虽然只是简单的单词调整位置,但背后需要企业做巨大的转型,不仅组织结构需要调整,还需要企业具有第一章说的 “智能商业” 属性,因为只有将服务在线化,通过数据智能与网络协同,才能精准触达每一位消费者,了解每个人的需求,快速服务与消费者。 然而快速服务消费者的需求还需要背后的供应链平台支持,所以 C2B 将以客户驱动的模式一直改造到背后的供应链逻辑。 然而 C2B 模式跨度太大,最近还诞生了一种过渡模式,就是 S2B2C 的模式,S 指的是供应平台,通过对小 B 的赋能,让小 B 直接服务于 C。这种模式是看场景的,因为只有 S2B 的价值大于单纯的 B,这个模式才行得通,所以在比如汽车、医药行业,小 B 急需 S 赋的业务场景可以做起来,而在本身就有大 B 存在的行业,就算有 S 赋能,小 B 依然竞争不过大 B,就不适合 S2B2C 这种模式。 另外 S2B2C 的模式也在升级,未来的产品可能会同时透出 S 于 B 的品牌,因为只透出 B 的品牌,可能导致 S 不能很好的掌握消费者需求,只透出 S 的品牌,就变成了传统加盟模式,而加盟模式最大的问题是无法发挥每个小 B 的积极性触达客户,加盟本质上还是 B2C,比如肯德基,一个大品牌对应每个消费者,就算加盟再多店铺也不会改变这一点,但是 S2B2C 比如网红模式,淘宝平台给网红赋能,网红通过自己的品牌吸引能力圈住一批客户,带来非常高的转化率,这就结合了两者优势。 另外也提到了云集,笔者以前认为云集是一种传销模式,和微商差不多,但其实云集要做的事情就是 S2B2C,将供应链完全打通后,包括网络系统一并提供给小 B,云集的小 B 就是任何有微信的用户,用户的资源就是他的朋友(朋友圈),所以云集号称没有商品就能做卖家,因为它的 S 服务做得好,集成性高,给小 B 带来的便利性就高。但问题是 小 B 到 C 环节是云集的弱势环节,拥有朋友圈的普通人与网红有本质的区别,普通人随意转发消息也许会带来朋友的反感与屏蔽,而普通人也不能为客户带来更大的价值,反观网红,他们可以得到粉丝的认可,成为粉丝的榜样,但是你愿意认可朋友圈里随便一个人成为你的榜样吗? 第二章总的来说解读了目前出现的网红现象,以及一些做的较好的独角兽(比如土巴兔、大搜车),其实他们都属于 S2B2C 的模式,而他们最终的目的地是 C2B。 战略变革既然商业模式变革了,战略也要变革。之前也说过互联网处于三浪叠加状态,从 B2B 开始产生了很多新模式,从 C2B 到 S2B2C,比如 S2B2C 的模式也是在发展过程中逐渐发现的新模式,因此企业对战略的制定要采取一种高效反馈闭环,核心在于做战略实验。 首先确定几个未来可能的战略方向,各投入一些人力尝试,尝试一年后自然会发现正确的方向,此时再将其他方向合并到正确方向。比如 2011 年阿里巴巴独立了三个子公司 - 淘宝、天猫、一淘,是为了赌未来的局势到底是 B2C,还是 C2C,还是一个搜索引擎指向无数小 B2C。最终发现由于中国网络基础设施还不成熟,导致独立 B2C 成本太高,因此 一淘 就回到了阿里巴巴。 因此当你发现公司在同时做几个相似的业务时,先不要急着觉得公司傻,这样做是在浪费资源,但你是否能看清楚这几个业务间微妙的差别?也许你不能猜到哪一个才是未来方向(能猜到你就当 CEO 吧),但至少能理解公司这样做的战略意图,而不是做什么都是淘宝。 对于企业战略选择,作者给出的建议是 点-线-面-体。也就是企业一定要在这其中找到自己的定位。 根据笔者读后的理解,点就是各种各样服务的角色,比如卖家、模特、独立开发者都属于点。线就是连接点与面沟通桥梁,比如微商或微博大 V 都属于线,原因是他们联接了平台与点。面就是指平台,比如淘宝属于面,因为它撬动了整个行业的资源,对上面无数个点赋能,联接了无数个点,面也是竞争最激烈的一环,也就是所谓的生态竞争,如果面对点的赋能力度不够,点也许就被其他的面吸引过去了。体是最大的概念,由多个 相互协同的面 组成,比如物流平台、网购平台、支付平台这三个面之间相互协作,才能逐渐形成体。 顺带一提,体不是一开始就形成,面也不是谁设计出来的,而是先有一个简单构想,根据市场需求逐步演化过来的,比如淘宝就是由 BBS 演化过来的,那 BBS 就是淘宝的基因,因此淘宝可以协同那么多点,可以快速反馈用户需求,可以演化出支付、物流、云业务并各自独立发展成新的面。 点-线-面-体 定位越上升,拥有的资源就越多,但面对的变化挑战就越多,其中“面”的竞争最为激烈,比如传统媒体本来是面,但在门户网站出现有,就降维到了点,微博的出现又使门户网站降为成线,而微信的出现使微博降为成线。 所以看似风光的 BAT 都选择了最为艰难的 “体” 的打造,而笔者认为,到了体这个级别,将撬动巨量的社会资源,带来巨大的回报,但排他性也是最强的。一个最完整的 “体” 本质上就是一个全面的协同网络 - 国家,国家与国家之间的排斥性大家可以想象,因此留给体的位置并不多,而新体的出现必然会与旧体展开生死决战。因此如果创业,将自己定位为“点”是比较靠谱的,因为有大量的“面”资源可用,只要能找到自己的亮点,就算有竞争,也不会收到太大的影响。 组织变革战略变革后,就轮到组织变革了。组织变革的目的是最大程度激发员工的创造力,因此自上而下的结构是不适合了,需要一种新的组织形态与管理思路。 这种新的管理思路就是 “赋能” 的思路,一方面,赋能的思路可以提升员工的自主程度,充分发挥其创造力,一方面,赋能可以转变管理者的管理方式,使一个经理能管理十几、二十几个下属。互联网行业的工资都很高,尤其是顶尖人才,对于金钱的渴望已经不是找工作的最大决定因素,“成就感” “使命感” 更容易受这些顶尖人才的青睐,因此 “赋能” 的管理思路也是招募到顶尖人才的方法。 最后作者提到了 “自组织协同网”,这是一个非常超前的概念,也源于企业最大的痛点 - 如何衡量 KPI。 随着商业环境复杂性提高,几个核心指标远不能反应一个企业真实情况。有句话说,如果你只看一个指标,那最后达成的方式一定是你最不愿意看到的,比如淘宝为了冲刺销量 KPI,出了全年免网购费用的年卡,也许一天就能完成全年 KPI,但未来一年内可能会亏空整个公司老本。因此利用数据,从多个维度衡量指标是唯一的解法,换个说法,就是用复杂性对抗复杂性。 通过将公司所有业务数据化,训练出一个逐步优化的模型,是可能从所有维度逐渐趋向最真实反馈公司表现的多维度指标的,衡量员工工作绩效方式也同理。 读完这一段,笔者感受到数据最终也会被用在员工身上这句话,简单来说就是晋升答辩不用写 PPT 了,年底通过上千、上万种维度对你进行综合测评,直接出结果。现在已经能感受到公司在这个方向发力了,第一步是将所有开发过程数据化,也许离这一天已经不远。 案例分析案例分析十分精彩,由于篇幅限制,笔者就不洋洋洒洒的转述了,如果感兴趣强烈推荐读原文,笔者至少还会再读一遍。 从案例分析中,有两个核心观点笔者在此处提一下。 第一个是平台演化的自然性,作者以淘宝的发展历程作为案例,说明了淘宝并不是顶层设计的产物,而是根据市场反馈的产物,唯有如此才能在高速变化的时代搭建一个平台。 第二个是网红案例,网红不仅完成了点到线的演化,而且是综合利用了多个“面”的案例,通过综合利用社交平台(微博),电商平台(淘宝),快速反应供应链平台(由网红推动产生的新型供应链),结合这三个平台,网红这个线被赋予前所未有的能量,带来了巨大收益。 关于未来读完本书的目的,不仅是了解当下的智能商业,更是为了思考未来。 在这个大变革时代,未来战略是难以预测的,所以凭空去勾勒未来蓝图没有什么意义,我们要在通过战略实验快速试探出未来几年的方向,在第二浪即将到达巅峰时,找到第三浪并积极布局。 其实本书只能给出寻找战略方向的方法论,而不能给出具体的未来发展方向是什么,因为这套方法论本身就是通过战略实验快速寻找方向的过程,唯有投入资源去做尝试,仔细观察身边发生的变化,才能逐渐找到未来的新商业模式。未来的商业模式也是在逐步演变的,受到的影响因素太多,因此大概处于一种 “不可观测” 的状态,但至少未来十年内 C2B 的模式,笔者认为是一个固定的大方向,而传统行业与互联网结合的产业互联网也是新的发展机遇,利用互联网优化传统行业的各个环节,是一个确定的方向标。 无论未来商业怎么发展,都会为消费者带来越来越好的体验,这是一个消费为王的时代,根据消费者的需求,掀起从平台到供应链的全方位改造,目的是带来更好的消费体验。 3. 总结读完了智能商业,笔者留下一个思考题:尝试站在智能商业的角度,分析你熟悉的公司各处于什么发展阶段,走的是什么商业模式? 讨论地址是:精读《智能商业》 · Issue ##169 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《极客公园 2019》","path":"/wiki/WebWeekly/商业思考/《极客公园 2019》.html","content":"当前期刊数: 90 1 引言上周参加的 极客公园 2019 充满了科技前沿的思考,而且给 “互联网寒冬” 带来了未来的期望中,可以看到前端将发挥越来越重要的作用。 这篇文章将以前端的视角解读这次极客公园。 本次极客公园的主题是 WHY NOT: 一些人看到世界现在的样子,会选择「就这样吧」而另一些人看到世界可能的样子,会思考「为什么不能更好一些?」 多问问 WHY NOT,少说多做,不妥协,去改变,将会帮你、创业者、甚至中国渡过这次互联网寒冬。 2 精读极客公园持续三天,每天有十几场来自互联网一线企业的嘉宾演讲。本文按照顺序介绍笔者在现场得到的感悟。 DAY1 看看世界的改变第一天围绕着 2018 年互联网与社会的改变,介绍了许多优秀的项目。通过几个公司的案例,让我们了解现在互联网的发展阶段。 最大的感触是:最近几年热炒的概念与口号,真的有不少踏踏实实的人做到了。 WHY NOT? Louis Rossetto - WIRED《连线》杂志创始人 Louis Rossetto 介绍了它创办《连线》杂志的回忆。 《连线》杂志的创办过程就是一个 WHY NOT 的过程:在最早创业时,没有场地,没有员工,也没有钱,仅凭借一腔热情从零打造了团队,并且利用热情与理想拉到了风投。 也许这个故事在当下已经不再适用,但 Louis Rossetto 的创业历程确实体现了 WHY NOT 精神,想让世界变得更好,你会感染周围的人,最后成就事业。 未来城市:数据的信仰与 AI 的社会责任 郑 宇 京东副总裁 - 京东数字科技首席数据科学家 郑宇 介绍了京东数字科技在智能城市做的努力。 京东抓住了数据、算法的机遇,将原有的京东金融、京东城市等组合成为 京东数字科技 的子品牌,这次重点介绍了京东城市操作系统。 随着云技术的普及,云计算已经成为基础设施,京东智能城市做的是基于任意云的“城市操作系统”,并可承载许多业务“软件”: 任意云服务 > 城市操作系统 > 城市服务 城市操作系统提供了一系列数据处理算法与人工智能技术,创业者或者合作企业可以利用这些技术实现城市业务,比如预测城市交通、空气指数、人流量等。人工智能技术的结合,让这些业务更为智能,不仅仅是数据检测与统计,更能预测未来变化,以及更精准的划区预测。 同时 郑宇 还对人才提出了新的期待,他们需要同时懂算法、人工智能与城市规划的人才,而现有的教育几乎不会产生这种跨学科,跨专业的人才。最大的感触就是,随着互联网科技与现实的紧密结合,对复合型人才的要求会越来越高,这对人才教育,与我们自身学习都带来了巨大挑战。 从沃尔玛看零售的创新与进化 Ben Hassing 沃尔玛中国 - 电子商务及科技高级副总裁 沃尔玛包括其子品牌 山姆会员店 都在寻求与中外企业的合作,以建立互惠互利的生态,目的就是帮助自己实现互联网转型,或者说电商转型。 基本上可以看作 一个传统行业大佬如何机智面对互联网转型挑战,但就从支付合作风波事件来看,国外巨头可能也在奉行借力打力的原则,这有点像中国巨头在印度、东南亚市场搞的那一套,扶持一个,打一群。 后流量时代:分布式 AI 与零售新增长 陈 磊 - 拼多多 CTO 核心概念是分布式云计算。现在云计算基本是集中化云计算,你的数据存在云端,但不属于你,你不仅无权查看云服务商采集了哪些数据,也无权查看他们存储了哪些数据,更无权查看他们如何将这些数据结合到算法,并怎样服务于你。 这确实戳到了当代人的一个痛点。无论你是用百度、头条、还是其他软件刷信息流的时候,如果出来一条你很感兴趣,但只想看一次的信息,你也许不敢点开,因为你猜点开后下次会给你推荐更多。这就体现了用户对于无权修改云服务商算法时的自卑。 陈磊 希望未来云计算可以是分布式的,算法开源出来,并且各大服务商允许用户自行上传自己的算法。这可以理解为用户有权自定义对作用于自己的数据的处理方式,并且根据自己的意愿随时调整算法,比如什么时候点击意图算是 “我感兴趣”,什么时候 “只看一下而已,不需要给我打标签”。 数字经济下半场,我们需要什么样的「组织」? 王慧文 - 美团联合创始人 & 高级副总裁 王慧文是个段子手,但同时思想像刀一样锐利,考虑问题像程序员一样有逻辑。 这次印象最深的是 “愚昧之巅” 与 “绝望之谷” 的概念。 人的成长可能有一段转折,也就是在达到愚昧巅峰时,需要跌入绝望之谷,才能爬向真正的智慧之巅。 但人的成长往往卡在 “愚昧之巅”,自以为达到了智慧之巅,而如果没有人推他一把,可能十几年都走不到 “绝望之谷”。 王慧文 提到了公司领导的担当之一,就是将处在愚昧之巅的下属推下去。但由于大家都不希望被泼冷水,领导可能担心伤了和气而选择不作为,长期来看对下属的成长是没有用的,但领导也没有义务推你,所以这需要有担当的领导去做。 同时在推别人时,还要当心因你处在愚昧之巅,别人处在智慧之巅,而闹出笑话来。 必然 张 鹏 极客公园 - 创始人 & 总裁 创新来自于灵感,而这个时代机会又只留给创新者,那么人为成功仅靠运气肯定是消极的。 为了让我们看到在随机的创新中,潜藏的必然,极客公园在第一天下午的论坛展示了那些 “不靠运气” 的创业者的经历,希望我们可以学到他们洞察必然的经验。 第一章:为何你觉得机会在减少,他们却抓得住时代? 刘梦媛 - 衣二三创始人 & CEO 衣二三的商业模式非常有趣,每月缴纳固定金额,就可以免费试穿任何衣服,甚至可以一天换一件。如今服装行业快消大行其道,就是因为快消收益高。她抓住机会,将快消转成慢消,提高消费者体验的同时,提升商家利润。 衣二三是会员制,对会员: 每月缴纳几百元就可以享受无数衣服的使用权,如果发现喜欢的衣服,也可以以低价折扣买回来。 每件衣服都是千元以上的高端服饰,但作为普通白领,也可以一天换一件穿。 不合适可以自然退回,生活就是试衣间。 对品牌商: 高端衣服收取不小的租金。 大概 4 个月左右衣服就可卖出。 通过试穿频率预测爆款。 总收入大幅提升。 而衣二三也可以通过自动化流水线自动清洗衣物,等于做了一个共享衣橱。 她成功的 必然 在于抓住了用户和品牌商的痛点。用户的痛点是:“穷”,但永远想低价穿高价衣服,还想天天换。品牌商的痛点是:不卖出去无法预测爆款,用户购买价格高导致顾虑心强,且难以体验真正穿在日常生活中的感受,只能降价降品做快消。 她通过将衣服的 “所有权” 转换为 “使用权” 巧妙的解决了这个问题。 黄 峥 - 拼多多创始人 & CEO 笔者不止一次在思考,拼多多到底是如何快速从青铜变成王者的,这次大会给了我一点启发。 首先黄峥的团队已经创建十年了,期间不断试水许多创业项目,拼多多只是我们看到的最后一个,所以并不能说他的成功来的突然。 其次黄峥的普通家庭背景,让他对基层人民拥有更强的同理心,他知道消费降级与消费升级并不是一个矛盾的事情,事实上这个矛盾经常同时发生在一个人身上:比如你在花几百万买个学区房的同时,在吃完火锅会选择团购省几十块钱的零头。 所以他抓住了人们在日常消费品上追求 “省” 的刚需,将团购提升到战略层次。 另外团购也不是简单的砸钱补贴,而是通过量与供应商直接谈价,将品牌商抬高的价格挤压掉。 作为一个前端,移动互联网对我来说,不过就是 PC 网页变成了移动页面,如果不看 APP,移动的 HTML5 本质上还是 PC 那一套技术。移动互联网对我来说和 PC 在技术上没有区别。 但 黄峥 看到的移动互联网则不同,他看到的是三四线城市,原本没有网线的地方,可以通过 4G 网络联网,移动互联网拉平了城市通信,电商又拉平了城市物流,这是一个新的时代,旧的事情也许可以重新做一遍。 在旧时代,品牌商与工厂建立关系,通过广告将工厂制作的产品投放给消费者。而移动互联网时代消费者已经有机会通过移动网络直接触达工厂,通过 “团购” 建立起的熟人信任链的坚固程度可能会超过对 “品牌” 的信任程度,那么就可以在部分领域将品牌商挤出市场,让消费者与工厂直连,将品牌商榨取的利润重新返还给消费者,从而让我们看到不可思议的团购价。 虽然补贴、假货可能是确实存在的情况,但 黄峥 能从移动互联网看到的这些 必然 让我非常敬佩。 第二章:当我们在谈产业互联网时,我们在谈什么? 翟学魂 - G7 创始人 & CEO G7 是货车智能兼容系统,目前它的体量可能足以整合中国的货车智能体系。 货车司机是比较危险的职业,首先货车在高速上一旦发生事故基本上都是很严重的,其次货车运途长,人难免会困或者分神,加上高速公路环境,更容易产生事故。另外高速公路上长时间开车非常单调,有些货车司机会忍不住一边看电影一边开车,这后果不用说了,但让一个人长期精神紧绷的盯着高速路也确实挺痛苦的。 G7 最新的进展,是通过在货车上安装智能硬件,解决一系列自动化问题。最重要的是通过人脸识别自动监控司机是否有危险行为,这样就可以实时监控到可能发生的异常,转而让人工监督员打电话去提醒。在最近的几年内,接入这个平台的货车没有发生一次车毁人亡的事件。 G7 抓住的 必然 就是将互联网结合到货车这个垂直的产业,不仅提升了效率,还挽救了许多货车司机的生命。 第三章:大家都在说的数据,到底有什么价值? 张 鹏 - 极客公园创始人 & 总裁 简单来说,数据就是能源,阿里也在说数据是 “新能源”。 张鹏打了一个很形象的比方。 在石油开采的初期,人类不知道如何有效利用石油,只能作为燃料销售。但现在我们的基本化工原料就是石油,石油转化为肥料,肥料产生玉米,玉米转化为我们生活中 90% 以上的糖制品等等,这种产业链将石油的价值指数放大。 数据也是一样,数据在初期就是流量,甚至可以打包出售(比如卖身份证信息等黑产)。但随着我们对数据挖掘能力的提高,是不是也可以像石油一样,将数据结合算法与 AI,转化为决策依据,转化为自动价值,转化为健康预测等等呢?数据的挖掘方式还有许多等待我们去发现。 “大数据时代” 反而是数据挖掘的初级阶段,因为我们的数据处理方式有限,就像挤海绵一样,一大块海绵只能挤出几滴水。在未来的高级数据挖掘时代,可能是 “小数据时代”,通过少量数据就能提取许多有效信息。 第四章:科技从业者们将面临怎样的空前挑战? 周 航 - 顺为资本投资合伙人 周航 基本上是站在投资者的角度看待 2019 的互联网寒冬。笔者最近也很困惑,为什么互联网会突然进入寒冬,周航 的话回答了我的困惑。 ofo 可以说是互联网寒冬的导火索。三级火箭是互联网企业利用资本运作的基本模式,小米就是很经典的例子。互联网公司通过免费产品吸引用户,这是第一级火箭,之后通过互动产品留住用户,这是第二级火箭,最后通过将用户分发到游戏、商品等内容,榨取利润。 所以之前很多互联网公司都在不计后果的烧钱,给投资人讲的就是自己的三级火箭。因为只要吸引了流量,未来就可以通过第三级火拿到回报,那么投资人投入越多,未来的收益也就越大,所以投资人会疯狂投资,公司也会疯狂融资,抢占市场,而且希望能垄断用户。 但微信的活跃用户已达到 10 亿意味着中国有能力使用互联网的人群都接入了互联网,也就意味着互联网流量红利消失了,直接导致了第三级火箭赚取的收益已经抵不上拉新流量的成本了,那这种利用资本滚雪球的商业模式也就玩不转了,因此这个商业模式就宣告破产,同时投资人手里的钱也损失了不少,创业者暂时找不到其他短期高回报的项目,两者夹击导致了互联网寒冬的到来。 知道了寒冬的原因,解决方案就不难想了。 最近比较热的产业互联网就是一条路,摒弃资本的炒作,回归到价值上,将互联网技术应用到各个垂直产业,带来实实在在的效率提升,是走出互联网寒冬的基本方法。 最后一个观点就是顺势而为。所有成功的创业公司都是在国家发展路线中踩对了点,通过观察环境,让自身跟着大趋势走,才能得到成功。这个点在后面的 小鱼在家、大疆无人机里都有提到。 DAY2 聊聊创新的本质这个时代比的是创新速度,只有快速创新才可能取得成功,那么第二天就围绕着如何创新,介绍了大量理论知识与实践经验。 创新相对论 王小川 - 搜狗 CEO 主要从 “感性” 与 “理性” 理解创新。主要讲的是,现在互联网不要过于注重理性的功能堆积,而要用感性去优化用户体验。 感性的是主观的,而理性是客观的,但人们需要的感动与创新,恰恰只有主观能做到。 因为看到所以相信,说明你是客观的人;因为相信所以看到,说明你是主观的人,主观的人更可能改变世界。 另外搜狗去年发布的 AI 合成主播是比较惊艳的,只需要录入话语,就可以自动生成主播视频,这可以进一步解放人类,让人类时间投入更有价值的创造性活动中去,这个在后面的嘉宾中也有提到。 AI 科技创新的本质是什么? 李志飞 - 出门问问创始人 & CEO 李志飞 从三个层次说明了创新与产品的关系: 产品需求 -> 创新 技术创新 -> 新产品 多产品抽象需求 -> 平台级创新 第一点是最自然的,也是中小企业最适合做的,因为业务驱动创新是最务实的做法。 第二点最难做,因为技术驱动的创新需要前期投入很多,比如最早做无人车的公司,投入了几十亿美金,走了许多弯路,最后还不一定能拿到结果,转化为商品。 第三点适合大公司,由多条业务线产品需求做整合与抽象,整理出了平台级的创新。比如上面说的 “京东城市操作系统”,就是在多条城市业务线需求上层做的抽象创新,可以赋能更多业务。 另外劝解了创业公司不要拿来主义,因为拿来主义可以低成本弯道超车,久而久之,就没有人愿意做创新的领头羊。 机器人成为人类伙伴之前的「必修课」 熊友军 - 优必选 CTO 最大感触就是说到了 人形机器人 是未来最有价值的机器人形态。 人形机器人首先对人类友好,其次可以复用现有社会为人类建造的各种设施,比如楼梯,门 等基础设施。现代社会的环境接口都是以人为交互对象设计的,所以人形机器人可以天然利用这些环境接口。 现在优必选的人形机器人已经可以画画、端茶送水了,其核心控制系统不仅要保证功能的实现,还要保证动作的 “柔韧性”,防止误伤了人类。 一个明显的突破是,当机器人手臂在做动作时,如果人的手碰上去,机器人的手会以你按压的角度进行动作倾斜。如果继续保持原有动作,可能与人的触碰产生直接碰撞,导致伤到人,但优必选的柔韧性设计让机器人运动路径考虑到了外界触碰,并作出反馈,这个在我看来是很大的进步。 如何让无人驾驶变成「老司机」? 王京傲 百度执行总监 - Apollo 平台研发总经理 百度的 Apollo 已经踏踏实实做了两年,从最初我们的怀疑,到现在稳定版本迭代,量产,百度如果继续保持这个节奏,确实可能在无人驾驶领域合作生态中独树一帜。 Apollo 1.0 实现封闭场地循迹自动驾驶,这个版本比较 low,一是封闭场地,一是根据路线来跑。 Apollo 1.5 安装了雷达,可以自动躲避障碍物。 Apollo 2.0 可以在简单路况下自动驾驶,可以识别信号灯。 Apollo 2.5 实现限定区域高速自动驾驶。 Apollo 3.0 主要是量产了,以班车作为业务场景去突破,班车是很好的固定路线试验田。 Apollo 3.5 支持城市路况自动驾驶,支持了复杂路况,而且是拥有量产能力的。 可以看到,百度的无人车确实在摸着石头过河,一步一个脚印,从跑 Demo 到灰度,再批量发布。相信未来 Apollo 还会发布 4.0 5.0 等重量级版本,百度无人车开源是一个杀手锏,只要功能做的好,帮助到未来智能造车的中小企业,将是一个巨大的市场。 我们平时都聚焦在大车厂的智能车计划,但就像阿里巴巴的理念,帮助中小企业一样,中小企业才是市场的中坚力量,未来无人驾驶行业一定会涌入大量中小企业玩家,谁服务好他们,谁就是下一个平台。 AutoML:让机器学习可以为人人所用 卢一峰 - Google 资深工程师 AutoML 可以自动完成 AI 算法和模型训练。 AutoML 分为算法机器人与执行机器人,算法机器人负责写出算法,然后交给执行机器人执行,执行结果反馈到算法机器人那用来改进算法,由此完成一个训练闭环,通过不断训练,得到一个相对较好的算法。 卢一峰 提到的关键点是,未来数据不会缺,算力不会算,缺的是算法专家,所以现在尝试通过 AutoML 解决算法专家的瓶颈,并且获得了比人类编写的算法更高效的算法。 未来让每个人都理解算法原理是不可能的,至少几十年内不太可能,但十几年内,算法就可能成为整个社会的基础设施,其实我们只要学会利用算法解决问题就行了。 AutoML 已经帮助各个行业自动识别图像、文字和意图,做到了将 AI 赋能给普通大众,降低了 AI 的使用门槛。 另外也引发了我的思考,为什么门槛最高的算法专家是第一个被证明可以取代的呢?或者说顶尖算法专家不会被取代,但至少入门或中级的算法工程师将极有可能不再需要。 也许是因为深度学习比较模式化,或者说过于理性化,不需要感性的人或者业务参与,这样就导致了无论算法还是训练都可以被完整抽象出来。而普通的技术工种其实是在和业务,在和人打交道,人是最大的变量,能被完全抽象的领域其实很少。 中国式经济魔方中潜藏的创新机会 汪 华 创新工场 - 联合创始人 & 管理合伙人 中国经济之所以比喻为魔方,是为了说明中国市场有多个维度,中国是多元经济,有个多个层次的机会。 这个话题非常大,更详细内容推荐查看 文字记录。 主要分为四个维度说,分别是 人口地域、前端后端、发展阶段、行业分化,这四个维度在中国是不均匀的。 在西方国家,发展进程是线性的,比如从个人纺织发展到品牌经济,再发展到去品牌化。而中国等发展中国家由于领土过大,且受到外来经济、文化影响,各个层次发展都不均匀,这也带来了中国式的潜力,比如为什么有了淘宝和京东,还可以创造出 “拼多多”。 人口地域的差距:核心互联网网民、小城青年、小城主流这三种人分布在一线到四五线的城市中,大家对消费的认知处在不同层次。 前端后端的差距:移动互联网是中国互联网的前端,移动支付普及率中国已经远超其他发达国家,但在物流、自动化的后端领域,中国还是远远落后于发达国家。所以上面说的 G7 等产业互联网就有机会加入改造中国的大后端。 行业分化的差距:交通、教育、文化娱乐、医疗这些行业在加速发展,而食品,服装等行业整体来看处于下降阶段,因此如果你进入了一个上升的行业,将有更广阔的发展空间。 发展阶段的差距:国内外、发展中国家和发达国家的发展阶段差距很大,同为发展中国家的中国、东南亚也有很大区别,所以将眼光投入海外市场也是新的机会。 所以整体看来下,中国可能是目前地球上最有创新、创业机会的国家,我们都是幸运的。 人类量化自我后,可穿戴的下一步在哪里? 黄 汪 华米科技 - 创始人、董事长 & CEO 华米是一家硬件制造公司,给众多智能硬件制造企业做设备,其中小米品牌生产线的小米手环出货总量达到 5000 万台。 但这家公司没有止步于此,他看到了智能硬件收集数据背后的巨大值,通过数据采集整理出了 《运动白皮书》、《睡眠白皮书》等大数据报告,得出的数据可以用于医疗健康等有价值的领域。 一个核心观点是:从数据量化世界,但量化自我。华米等企业都逐渐将数据使用的重点,从城市数字化转化到我们 “人” 的身上,无论是现在取得的各种数据分析报告,还是未来的潜力都很巨大,果然 “人” 才是最重要的服务对象。 后面讲到的 Magic Leap 公司所做的事情,也同样体现了将科技力量运用于人的例子。 传统企业在消亡,传统行业在崛起 徐 琨 - Testin 云测总裁 Testin 是一家云测试公司,拥有很多机房和几乎所有移动设备机型,通过自动跑任务的方式完成测试,有许多政府企业客户。 不过徐琨分享的主题,则与他公司天然线上线下结合的属性有关。 他提出的重要观点是:互联网+ 几乎等于烧钱,现在已经不适用了,而真正有机会的是传统企业通过 传统行业 x 互联网 取得更大的价值。 互联网企业在资本与流量红利的推动下快速发展,但现在已到了尾声,传统企业的路还要重新走一遍,比如经验、管理理念。但在互联网企业走进线下时,我们发现传统企业走进互联网的速度更快。 他举了一个 传统行业 x 互联网 的例子:现在各电商巨头都在布局新零售,在线下开店,似乎规模很大。但其实传统线下零售巨头也在更快速的接入互联网,现在一个简单的线下超市基本已经用上和新零售体验店一样的技术,更不要说上文提到的沃尔玛等巨头,他们都在积极与互联网公司合作,快速实现自我转型。 作为一个最大电商公司的员工,我有感受到来自传统企业快速转型带来的压力。传统企业并不是双手举过头顶,缴械投降地等待接受互联网公司的改造,而是已经从内部驱动开始互联网化,这就形成了 线下 -> 线上 vs 线上 -> 线下 的两股强大力量,现在正处在转型过渡阶段,偶尔有摩擦,但合作与相互赋能是主旋律,但当转型进入尾声,传统企业是否愿意与互联网公司一起瓜分线下市场的蛋糕?除非这种合作带来了共赢,否则如果是一个零和博弈,最后一定会打起来。 不过笔者还是相信,线上线下整合后,可以进一步促进消费,扩大市场,产生的额外利润应该足以稳固传统企业与互联网企业的合作。 Keep Evolving 王 宁 Keep - 创始人 & CEO Keep 的创始人王宁口才非常好,现在 Keep 已经从我脑海中一个健身视频公司,变身为一家推动全新生活方式的富有活力的公司。 Keep 应该是从做健身视频开始的,健身视频包含了一些互动特性,提高了很多人健身频率,但 Keep 远不止于此。 王宁 一直在强调健身数据、社交互动带来的改变。大家通过健身的方式可以相互认识,相互督促,相互 PK,而 Keep 也在致力让其 App 走出手机,收集用户更多的数据,因此推出了三个生活场景: 面向家庭的 Keepkit,面向城市的 Keepland,面向生活的 Keepup。 面向家庭的 Keepkit:Keep 终于制造了诸如跑步机、手环、体脂智能称秤等硬件设备,拓展业务边界的同时,带来了更好健身体验,也利于收集更多用户数据。 面向城市的 Keepland:有点像公共 KTV 空间之类的理念,通过包下一大块布置了大量 Keepkit 设备的场地,用户就像去健身房一样按时计费,而不需要买下设备或寻找空间,同时这种线下多人强互动的场景让 Keep 走出了 App,走向了生活。 面向生活的 Keepup:没有详细展开,大致是一种科技运动设备。 就这么自然的,Keep 与智能硬件结合了起来,也完成了与线下的打通,这是 Keep 最正确的发展路线。 白手起家创业指南:忘掉大趋势,沉迷小创造 猫 助 多抓鱼 - 创始人 多抓鱼是一个微信起家的二手书交易工具。和拼多多一样,猫助 抓住了现在 4G 与物流 基础设施的能力,把以前做不了的事情重新做了一遍,并取得了成功。 很神奇的是,多抓鱼二手书是全上门收取的,而且卖书的人不需要付快递费,毕竟书本身就不贵。但让我吃惊的是,现在上门收书的成本竟然只有 2 块多。 十年前的许多不靠谱想法,现在是可以重新审视一遍了,同时未来 5G 时代的来临也必将带来新的机会。 工具的价值演进 张海龙 CODING - 创始人 & CEO Coding 最早的印象是做代码托管服务的,由此产生了一些周边的尝试,比如项目买卖平台等。但今年可以看到,Coding 已经有了自己的核心价值定位:云开发。 从项目管理、持续集成、测试管理、部署管理都全部在云端,Coding 还提供了云代码编辑器,可以直接在云环境下写代码,共享云端的环境,从一定程度上是提高了开发效率。 其中触动比较大的一点是:有些大公司的产品经理还在用 Excel 管理项目计划,这一点还是蛮戳中痛点的。开发的环节很多,从需求到项目管理,再到研发,每一步的自动化程度都完全不同,有的团队也许在用最先进的协同编辑与云构建,但 PM 还在用电子表格缓慢的统计项目进展。 将项目生命周期整体来看,自动化每个环节,并且搬到云上,是未来一个大趋势。 顺带一提,运维工程师在很多大型公司已经高度自动化了,部署流程正在下沉到开发工程师人群。 洞察:产业深处需要什么样的计算机视觉? 柯 严 扩博智能 - CTO 扩博智能在机器视觉领域有所建树,利用这些技术解决新零售行业与风电行业的问题。 主要说到利用无人机 + 视觉识别,完成风机叶片的自动巡检,提高了大约 20 倍的巡检效率。 可以看到,机器学习、智能硬件、图形处理这几个随机组合,可以造就许多创业机会。现在流行说产业互联网,互联网技术为产业赋能,通过智能硬件 + 图形处理的 扩博智能 就是一个典型例子。 新造车到底有没有在认真造车? 戴 雷 拜腾 - 联合创始人 & 总裁 智能造车也是这几年很热的话题,也许在未来 5 ~ 10 年,智能造车可以有突破性进展。 智能造车的最大局限,在于生产流水线的改进速度远低于软件的改进速度,也许 5 年内都难以修改造车流程的某个磨具,所以智能造车是一个需要时间的行业,也是一个传统工程与互联网软件结合与碰撞的行业。 戴雷 将智能造车分为三大流派:互联网造车派,传统造车转型派,传统造车“叛逃”派。他就是一个从德国造车巨头企业出来的创业者,因为传统车厂体系太庞大,想要转型非常困难。所以他选择了到中国创业,同时拥有传统车厂的造车经验与互联网团队的他,在 19 年将会造出一些可以投放到市场的智能汽车。 现在到了互联网与传统行业深度融合的时代,可喜的是,看到了双方都在积极的拥抱对方,从整体上看,线上线下结合的速度正在越来越快。 聊聊 XR 的新世界 John Gaeta Magic Leap - 创意策略 SVP 这又是一个烧脑的话题。Magic Leap 是一家做增强现实的前沿科技公司,之前网上热传的一个虚拟现实技术 - 一个篮球场的鲸鱼 动画,就出自这家公司。 Magic Leap 公司技术很前沿,所以说起来有一种很魔幻的感觉。这次演讲的主题 XR 就表示了,这家公司会利用 VR、AR、MR、CR 等技术(篇幅限制,不介绍这些概念,此处可以自行查阅资料),将数字与现实更好的结合,并服务于个人。 这场主要有四个重要概念:空间计算、感知场、生活流、个人 AI。 空间计算指的是下一代计算机计算对象是空间,也就是为我们人类感知的空间做计算。比如你戴上了一个可穿戴设备,那计算机算法就会对针对你在这个空间中的方位,你的目光,你的动作,与周围进行的交互进行计算,利用 MR 技术增强显示世界的显示内容,辅助你更便捷的生活在现实世界。 感知场指我们解读现实世界的能力,通过计算机可以增强虚拟与现实的互动,比如你通过 MR 眼镜在桌子上放了一个球,当你用手把它弹开时,球会飞走,而你的手也有触感。 生活流指的是你生活产生的全部信息,就像流计算一样实时上传与计算,最后更好的服务于你。 个人 AI 便是字面意思,为个人服务的 AI,或者说仅为你服务的 AI。这个 AI 将会像机器猫一样全方位照顾你,帮助你更好的生活。但这方面还在探索中,所能想想到的一切未来机器助力人类的场景都包含在 个人 AI 含义中。 最直观的震撼是,现在 Magic Leap 的 MR 眼镜,已经可以比较真实的模拟 “篮球场的鲸鱼” 画面了,而几年前的宣传视频还是后期合成的。他们很早就想象到了未来,并以后期处理的效果展示出来。现在,他们完成了部分承诺,我们可以用 MR 眼镜看电影,而电影的主人公与场景会直接出现在你的客厅或卧室,看起来几乎没有违和感。 至少在看电影场景下,就非常令人激动。从 2D 电子版上看到的电影就足以令人激动了,现在我们可以身处电影的环境中,而且改造的场景就在你的客厅! DAY3 谈谈人和企业持续成长的方法论互联网企业已经发展到一个瓶颈,ofo 事件后,大家都知道烧钱没有用了,因为流量红利消失后,流量成本已经超过收益,同时互联网企业与传统企业的摩擦加剧,资本和风口难以再使互联网企业披荆斩棘。 想要继续增长,可能视角要回到人与管理上面。 打造机器人时代的 OS 傅 盛 - 猎豹移动董事长 & CEO 傅盛 的核心观点是,利用 AI 帮助更多人脱离生产力工作,转向创造性工作。 猎豹做了 AI 主播,提高了主播服务效率,但可能却替代许多主播的职业,因此他才会谈到这个观点。这个观点笔者也在《刷新》一书中看到类似的描述。 每次工业革命,或者机器人革命,都有大量人类工作岗位被替代,但放在长期来看,最终其实会导致人类岗位的增加。因为机器肯定都在解放重复性的岗位,或者聪明一点的机器人也是从比较没有创造性的岗位开始替代人类,随着生产力的提高,人们拥有更多的时间做更有意义的事情,就会自然催生难度更高的岗位,需要的人才也会更多。 比如在农业时代,人们需要大量劳作才能吃饱,那人才只要满足农田这个市场即可。但工业革命后,农业不需要那么多人了,人类才有机会创造出计算机市场,把人才投向计算机市场。而计算机市场的工作难读大于农业市场,所以需要更多的人才,更高的要求,最终创造的就业比农业时代多得多。 印度市场的成长观察 许达来 - 顺为资本创始合伙人 & CEO 印度内部出于相对割裂状态,有 20 多种语言,这是它与中国最大的区别。因此印度的本土化很重要,同一个区域可能就有数个讲着不同语言的印度人,他们彼此之间可能还无法交流。 不同的语言也导致了不同的文化差异,所以去印度创办企业,必须找印度本地人合伙,才有可能作出符合印度文化的产品。而去印度投资,也最好投资本土企业,因为印度的环境复杂,本土企业成功的概率相对较大。 比较有感触的是,提到了最近两年印度的飞速发展,印度从网线安装率很低的时代,一下跨越到移动互联网 4G 时代,开车的司机都可以看在车上看视频了。这说明相对落后的国家与地区,已经实现跨越式发展,可能直接跳过 PC 时代直接进入移动互联网时代。 如果对印度市场布局,一定要意识到印度是个割裂的市场,与本地企业合作,同时做好拥抱变化的准备,印度的发展肯定比十年前的中国快。 科技 × 创意 新娱乐时代的成长法则 刘文峰 爱奇艺 - CTO 爱奇艺运用 AI 的方式非常有趣,在人工智能领域,他们主打两个战略:zoomAI 与 homeAI。 zoomAI 主要是利用机器学习进行画质修复,将比较老的 480p 电影转成 720p,画质上得到了大幅提升(让我想到了 魔兽争霸 3 重制版,现在如果是视频领域,为了高清分辨率已经不需要重新开发了)。 homeAI 核心是读懂视频。它可以读懂视频中的人物、场景、情节,并结合语音交互,快速跳转到某个情节,或者查找演员信息,或只看某个人,这个确实大幅提升了看电视剧的体验。 就在几年前,视频技术的核心还在前端的视频解码与后端的负载均衡,如今已经将战场蔓延到 画质修复与读懂情节,视频领域的门槛实现了跨越式提高,我希望这些 AI 技术可以开放出来,赋能每一家视频提供商,因为这些新技术背后的研发成本太过巨大,以后若成为每一家视频网站公司的功能标配,则这项技术必须实现平台化赋能,或者服务化。 Think 的长期主义 赵 泓 ThinkPad - 联想集团副总裁,中国区中小企业事业部总经理 核心话题就是 “以不变应万变”,主要在说 ThinkPad 系列在不断变化的市场中,一直坚持以自己的节奏打磨产品,最后用户很买单。 值得提炼的是,ThinkPad 根据用户需求去做产品,根据不同的用户场景,制造了不同系列的电脑,比如适合商务旅行的 X 系列,或者工程师专用的 T 系列。其中提到了为什么不把 ThinkPad 边框做小,原因是要考虑防摔。 其实可以看出来,我们每个人都要具有接受两种相反价值观的能力。像 ThinkPad 推崇的长期主义,我们可以看到好的地方,因为这个给 ThinkPad 带来了 26 年不衰的竞争力。但同时也要知道企业的 S 型生命成长曲线,许多公司没有跨国这个曲线就彻底没落了。也许在未来人机交互迁移到 MR 时,坚守智能电脑的坚持就要被打破,但如果长期来看你的赛道是安全的,那就坚持下去。 激进还是保守?看透创业的「快与慢」 方三文 雪球 - 创始人 & 董事长 雪球是一个投资交流社区。因为这个节目是座谈,所以聊的内容比较琐碎。 一个有意思的点是,方三文提到了雪球社区会经常冒出一些出自 “不知名” 用户的专业评论文章,进而提到了一个概念:社区资源重组。也就是在大家能平等交流的互联网环境下,非头部流量因为有发声的机会,因此会获得自己的机会。 在时代切换中,重新理解技术的力量 沈向洋 微软 - 全球执行副总裁 沈向洋作为微软全球执行副总裁,是非常重量级嘉宾,他讲到了微软的转型,收购 Github,以及微软的文化,以及几年前对人工智能的准确预测,内容非常有价值。 结合他推荐的《刷新》一书,我得以更好得理解他所说的微软。 微软是一家老牌巨头,几乎在九十年代的互联网企业中,微软是活到最后的。由于没有赶上移动互联网浪潮,中间一度掉队,但现在又迎头赶上了,这中间做了不少努力。 微软以前是一个领地意识很强的公司,产权的官司没有少打,但在更换新的 CEO 后,为了弥补错过的移动互联网带来的损失,微软变得更加开放了。 微软通过与竞争伙伴建立长期合作关系,在赋能生产效率领域又重新回到了巅峰。收购领英有助于微软开拓职场关系的边疆,这与服务开发者是密不可分的,同时微软也在想办法提高对女性雇员的平等待遇,领英的数据也有助于这项分析。收购 Github 就更体现了微软赋能开发者的意图,虽然网上有许多逃离 Github 的负面言论,但实际上在微软收购 Github 后,Github 用户增加了 800 万,这比过去 6 年的总和还要多。 现在微软期待的未来蓝图是,让世界变成计算机,让计算无处不在。其实这些与其他科技巨头的愿景差不多,最打动我的是微软关注的人文情怀。 微软现在确实越来越关注科技造福人类的方向,不仅是帮助普通人提高办公效率,还要帮助患有先天疾病,或残障人士无障碍的使用技术。微软最近技术公平性,平等为人类赋能的领域做了很多,这可能与微软 CEO 萨提亚的出身有关,他知道自己是赶上了美国对印度人才敞开大门的黄金时期才获得了就业机会,它对家乡,对世界都拥有平等获取知识与成就的同理心,大公司的 CEO 都拥有这种担当。 技术型公司的成长启示录 高欣欣 将门 - 创始合伙人 & CEO赵 勇 格灵深瞳 - 创始人 & CEO宋晨枫 小鱼在家 - 创始人 & CEO 高欣欣 作为主持人采访了 赵勇 与 宋晨枫。 格灵深瞳是一家技术驱动的公司,拥有一批机器学习的专家,但在创业初期并没有找好业务方向,以至于后来团队重组,重新聚焦到摄像头与人的识别、数据分析上,才渐渐实现了盈利。 从格灵深瞳身上吸取的教训是,在创业初期,得到融资后容易迷失方向,业务遍地开花,但最后难以商业落地。专注做一件事是关键词。 小鱼在家与百度合作的小度在家发展的很好,宋晨枫 讲到创业公司寻找方向阶段,与成熟后,与大公司的竞合关系。 创业公司初期其实是在下赌注,如果你赌的风口对了,就能顺利进入下个阶段 - 大佬的台桌。上了大佬的台桌,你会看到三座大山,以及脱颖而出的竞争对手,你要选择与谁合作,与谁竞争。听下来这个问题是没有标准答案的,不同公司有不同的选择,而 小鱼在家 选择了与百度合作。 后面的访谈提到了团队管理经验,基本上是找到底层操作系统(学习能力、素质)与业务能力与当前阶段所匹配的人。同时也再次强调了创业团队要招比自己更优秀的人,这与 BAT 的招人标准不谋而合。 如何用 30 年的时间实现一个最初的想法? 葛 珂 金山办公 - CEO 核心词是 时间的沉淀。 办公软件领域需要耐得住寂寞,而且非常需要技术驱动,金山办公的 WPS 系列从支持中文,到现在通过模版满足用户需求以打通市场,一共走了 30 年。 这个例子与 ThinkPad 那场分享比较像,虽然我很尊敬微软,但办公软件方面,中国必须有自己的核心技术,否则在国家安全方面是得不到保障的。 创新的偶然与必然 谢阗地 大疆创新 - 品牌负责人 大疆无人机已经是智能硬件的代表了,现在最新一代的大疆无人机 2.0 搭载了强大的人工智能系统,甚至可以识别不同的植物喷洒不同的农药。 大疆的分享有亮点启发: 第一是智能硬件创业市场非常广阔,因为之前 扩博智能 分享的无人机案例其实与大疆无人机使用的技术很想,只是服务的业务场景不同。同样的底层技术运用到不同的行业,可以成就不同的伟大公司。硬件领域相对来说寡头比较少,小玩家都比较有机会占领属于自己的细分领域市场。 第二是关于大疆为什么会成功,这个成功很偶然,来源于大疆团队早期对无人机技术的研究,等无人机应用市场成熟了,就自然而然的推进了市场。正因为有前几年的技术沉淀,所以大疆无人机技术上领先竞争对手好几年。 这个第二点和 小鱼在家 的 “创业公司在赌未来方向” 挺像,小鱼在家 与 大疆都在早期赌对了方向,所以在市场成熟起来后可以快速实现规模化。 这个顺势而为的理念与前面的 顺为资本 谈到的类似,国家和时代需要什么样的技术,做这个技术的人就能取得成功。 50 后 VS 90 后:创业改变了我们什么? 曾德钧 猫王收音机 - 创始人齐俊元 Teambition - 创始人 & CEO 猫王收音机是一款音响产品,在智能音响时代,幸好没有参与到其中,恰恰坚守住了自己的特色,反而对古典美的追求成为了稀缺的东西。我在想,智能音响在互联网大佬眼里其实都是入口,大家都在补贴,砸开用户家中智能硬件的切入点,如果猫王收音机也去竞争,这将是两个维度的碰撞,拿你的核心与别人的诱饵碰,一定会失败的。 猫王收音机表达的也是长期主义,和 Thinkpad 演讲的很像,其精髓是,在这个新事物快速取代旧事物的时代,我们还可以发现一些可以被留下来的东西。 Teambition 是提高团队工作效率的工具,比如任务管理、协同等功能,和 Coding 的云开发平台类似,不过这个更注重于点子的记录与管理,项目进展管理。 比较有感触的点是:创始人对团队产品决策时要拿捏好力度,这对大公司的领导层同样适用。管理层要参与到产品设计中,产品才会更有活力,员工对产品的重视程度会更高,但管理层如果急于证明自己的正确性,往往会扼杀其他人的思考,所以一名睿智的管理者既要参与到产品设计中,又要客观评价事情,最大程度激发每一个员工的创造力。 一个 30 多年始终保持创造力的组织,经历了什么? Ed Catmull 皮克斯动画 - 联合创始人 & 总裁, 迪士尼动画工作室 - 总裁 迪士尼动画的创意给我们的印象深刻,这次迪士尼的总裁 Ed Catmull 带给我们最有启发的一点,就是迪士尼的创新来自于快速试错。 迪士尼很多创意在初期都是非常糟糕的,但敢于承认自己会犯错,且积极改正,造就了迪士尼的成功。 笔者想到一个不太恰当的比方,就好比写前端页面样式时,完美的动画都是一步步试出来的。一个好的动画,都是通过最原始,最简单的代码一步步尝试和改进,每一个时间参数都要微调,最后用户看到的只是经过无数次调试后的效果,当然会惊讶为什么我们能做的这么棒,其实创造的过程需要尝试。 3 总结微软的转型、投资人的建议、产业互联网,都需要 WHY NOT 的精神。 我们需要冷静下来,理解为什么中国有拼多多式的机会,为什么互联网会进入寒冬,新的时代为什么由数据驱动,互联网为什么要与产业结合。以上的解读可以回答这些问题,我们每个互联网从业者都需要认真思考世界正在发生变化的原因。 讨论地址是:精读《极客公园 2019》 · Issue ##126 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《极客公园 IFX - 下》","path":"/wiki/WebWeekly/商业思考/《极客公园 IFX - 下》.html","content":"当前期刊数: 136 1 引言这次是极客大会十周年,也正好告别了 2019 年,因此主题是总结互联网前 10 年的发展,并预测下一个 10 年的变化。 这次是后半部分的大会感悟。 2 精读一头在慢赛道下奔跑的大象大象保险是一个互联网保险公司,可能在大家印象中保险公司是一个老而慢的行业,复杂的条款,繁琐的理赔流程,精心规划的商业套路等等。顺带一提,巴菲特就是利用收购的众多保险公司收集的保费进行杠杆投资,才取得了平均年化 20% 左右的神话。所以保险是一个比较难做,且在慢赛道的行业。 大象保险则利用科技的手段对保险流程进行优化,在初期尝试了几种有意思的互联网保险业务,比如上班下雨保险、堵车保险等,也取得过一些成果,现在尝试将自己平台化,将沉淀的互联网保险能力提供给其他互联网保险公司。 大象保险沉淀了一个业务中台,包括各种保险险种库、智能化投保决策、以及沉淀了大量数据,基于这个业务中台拓展出一个新品牌“象保保”,这是一个代理人数字化营销平台,集成险种库、出单管理、海报、计划书、课程、健康服务等一系列互联网保险基础能力,既服务于自己,又能服务于其他保险公司。 这种商业思路确实是很棒的,亚马逊的 AWS(亚马逊云)、FBA(第三方物流服务)、Amazon Go(即拿即走线下超市)都是前期研发投入高 + 固定成本很高的项目,这些项目诞生之初就服务于亚马逊自己这个大客户,等成熟后就拿到市场上检验,赋能其他行业。阿里内部服务成熟后上云也是一样的思维。 bilibili CEO 专访从专访中了解到,bilibili CEO 陈睿不是 b 站最早的创始人,而是后面加入的,一开始他兼职管理 b 站业务,并承诺自己公司上市后就加入,结果所在公司猎豹真的上市了,而他也正式加入 b 站,追求他的爱好。 b 站独特魅力在于 UGC 内容持续的创作,这样公司不用花功夫进行内容创作,而用户的智慧聚集起庞大的创作力量影响力又非常之大,同时用户自己创作的内容会更容易得到用户自己的认可,所以 b 站用户付费的意愿都很高。 所以陈睿一直强调的是一种社区文化生态,这种生态可以带来很强的归属感与认同感。 b 站的品类很多,按照热度排序可能是:动画、番剧、游戏、娱乐、国创、数码、科技、音乐、生活、舞蹈、放映厅、时尚等等,而 b 站的收入来源游戏业务占了一半,2019 Q3 游戏收入达 9.3 亿元,其余收入来源分别是直播和增值服务业务、广告业务、电商以及其他业务。这种收入模型对社区类创业者来说比较有借鉴意义。 如何把读书这件小事做到极致樊登读书的 CEO 樊登过来了,樊登是知识付费四天王之一,知识付费的四天王分别是:吴晓波、罗振宇、樊登和李善友。 樊登真的很有演讲功力,笔者觉得樊登是极客大会 3 天所有演讲中讲的最好的没有之一,与他同台演讲的都是各大独角兽 CEO 级别人物,也包括百度等大公司事业部总经理,但就演讲能力而言,距离樊登还是差得太远。 这次樊登演讲主题是复杂体系 vs 简单体系,总结后其实就是一句话:复杂体系是自然生长出来的,简单体系是规划出来的,现在创业环境不适合简单体系,只有复杂体系才能应对这个世界的复杂性。 其中提到一个有意思的点:所有 KPI 都是错的,因为 KPI 是预测未来的工具,所有对未来的预测都是不准确的。在 KPI 压力下人的动作会产生变形,比如只追求结果不追求过程, 最终导致饮鸩止渴,不利于长期发展。樊登解决问题的方法挺有意思的,他对线下门店指定的 KPI 长达 100 多条,非常非常细致,但想要一一检验是不可能的,每到发奖金的时候,就随机抽取三条进行检验,由于不知道最终会检验哪一条,这样门店想要拿到奖金就需要本本分分做好每一点细节,做真正产生价值的事情。 樊登读书这款 App 笔者也听了一个星期,里面讲的内容很有针对性,都是职场、心灵、生活相关的,与完善自我紧密相关,特别是一款《逆商》的解读,非常有意思,推荐大家读一读。 大组织土壤中创新如何发芽结果主讲人是阿里创新事业部总裁朱顺炎,大公司总是被诟病创新能力差,毕竟层级复杂体系庞大,看上去好像创新确实很困难。 阿里创新事业部有四个法宝: 大家没有生存压力,不需要为了短期变现而产生动作的变形。 CEO 深知创新是从小应用成长起来的,所以让创新项目从小开始独立孵化。之所以要独立孵化,也是认识到组合的产品创新能力是很脆弱的,真正成功的产品必须要独立撑起一片天。 给更有创造力的年轻人机会。 没有不变的业务,只有不变的文化,通过培养文化进行企业传承。 一起期待拥有长线计划的阿里创新事业部可以给市场带来更多有价值的产品吧! 面对不确定的未来,我们应该如何决策大众汽车中国的 CEO 介绍到,中国已经成为世界最大的汽车市场之一。 PS1:其实中国不仅正在成为汽车最大的市场,中国其实在各个维度都在成为全球最大的消费市场。 PS2:大众汽车的历史很有意思,尤其是保时捷和大众的收购大战以保时捷发起,最终却被大众反收购,这段历史非常有趣。 这次分享讨论了三个问题: 电动汽车出行肯定会实现吗? 关于这个问题,大众汽车的答案是肯定的。这句话很有意思,我记得去年参加这个大会时,许多初创新能源汽车制造公司就自己与老牌车场相比有什么优势时提到,老牌车场虽然实力雄厚,但航空母舰转身非常困难,这些大厂其实难以很快投入电动汽车的研发。从现在阶段来看,行业又发生了变化,老牌大厂纷纷加入实现了“掉头”,进入电动车行业,并且针对自动驾驶领域开始做技术合作与整合。 软件公司和汽车公司谁将引领汽车行业的未来? 大众中国 CEO 通过四个力:责任里、靠谱力、盈利力、可持续力四个方面对大众汽车进行了全面夸赞,总之想表达的观点就是,汽车公司实力雄厚,可以通过再造一个规模一万人的软件公司,对互联网造车公司进行降维打击。 出行服务会颠覆传统汽车制造商吗? 自行车厂商倒可以有这种担心,但汽车厂商不必有,因为每个人其实都梦想有一辆属于自己的车。在之前共享出行行业里也提到了,交通分为公共交通与私人交通,对于两点一线比如上班场景,就非常适合私人交通,因为大家对时间和稳定性要求非常强烈,毕竟谁都不想上班迟到。对于临时的交通需求,大家对公共交通需求更大,毕竟公共交通便捷性更强,特别是人在国外时,总不能在国外给自己也买辆车吧。 产业物联网中的机制成长从何而来G7 去年成长了 5 倍,这是一家智能物流服务公司,提供货车智能服务。 去年的极客大会有介绍过 G7,几年就不再详细介绍了。G7 之所以有这么快速的成长,一方面是自己产品做的好,另一方面可能离不开整个中国产业互联网的腾飞,由于物流行业这几年快速发展,各个物流公司都在不断融资买货车提升自己的运力,对智能货车的服务需求才会不断增加,同时中国经济也进入了互联网广泛赋能各产业的阶段,这就是去年一直提的“产业互联网”,G7 作为一个平台,横向服务中国所有物流公司,享受到了中国发展的红利,得以快速发展。 也许未来 10 年还会迎来更加巨大的产业互联网机会,那些既做软件也做硬件的公司可以迟到这波趋势的红利。互联网将成为线下产业的钢铁侠外衣,对线下产业来说,得到互联网的加持可以大大提高运作效率,对互联网来说,线下产业发展的红利将带来极高的自然增速。 VIPKID 鹏友说VIPKID 创始人米雯娟谈了 VIPKID 最近的运营情况,比较有感触的是教育这块拉新的方式,一般教育领域花费都是比较高的,而且不仅仅是钱的问题,将孩子的成长托付给任何一家机构,家长都会特别谨慎,这是人之常情,所以大部分培训班很多新客都要通过老客推荐的方式获取。 VIPKID 起步是依靠朋友圈传播,但随着项目的起量,需要通过广告方式推广,最高的推广费用达到平均获客成本 8000 元,不过现在已经回归到正常水平,大概 4000 元左右,现在有 50% 的新客是通过老客推广的,无需费用。 一加手机一加手机在国外销售非常火爆,最近一款 90 HZ 屏的产品使其又火了一把。之所以做 90 HZ 屏就是为了“更”流畅的使用体验,当被问及这么做性价比如何时,刘作虎回答的是:这就是高端品牌的极致追求,有的时候体验就提升那么一点,用户就会选择你。 一加手机做的是高端手机,操作系统主打的是简洁,不会有任何广告,盈利方式则是其较高的定价。而相比手机大厂,一加手机的突破点在于集中力量做旗舰手机,通过集中投入研发资源达到单点突破。 最近一加也在做电视了,目的是为了占领客厅市场,可能因为手机卖的比较火,资金链比较充裕所以做了更大的布局。 解题 - 社区零售新物种的进化之道每日优鲜的 CFO 王珺带来的一场分享,介绍每日优鲜是如何利用新技术实现新零售突破的。 每日优鲜业务的难度有三点: 社区零售中最难的业态:大规模分布式连锁。 社区零售中最难的品类:生鲜非标品。 社区零售的三大挑战:体验、成本、复制。 生鲜零售难度确实很大:生鲜对保存时间短,运输过程中易磨损,品质管理层次不齐,我们来看每日优鲜是如何解决这些问题的。 每日优鲜通过部署 “前置仓” 解决物流问题。几乎所有物流业务想要提效,比如推出次日达甚至当日达业务,几乎都必须用前置仓解决。每日优鲜的前置仓甚至可以实现平均送达时间 36 分钟,而且价格比线下超市便宜 10%,这是怎么做到的呢? 每日优鲜分别从租金、人工、损耗三个方案解决问题。 首先是租金,每日优鲜专门租一些高性价比的地段,租金便宜但距离配送地点也不远的地方。 其次是人工,通过智能化的调度中心,减少了店员数量,但能保持服务效率。 最后的损耗,比如仓储管理,也通过合理的计算提升货物周转率,在配送服务方面,通过聚合订单,本来一个骑手一天只能送 20 单,但每日优鲜的骑手一天可以送 70 单,这是因为平均出车一次可以覆盖 10 位客户,这都取决于平台派单算法的优化。 对于规模化扩张方式,每日优鲜也有自己的做法。1.0 信息化阶段,利用系统辅助人,达到现在的高效率。未来 2.0 是智能化时代,用系统取代人,将成本压缩到极致。 智能汽车的白银时代小鹏汽车的 CEO 何小鹏认为 2020-2025 年是电动车的白银时代,即拥有高度辅助功能(L3),2025 年之后是黄金时代,即受限场景的无人驾驶时代(准 L4)。 值得注意的是,小鹏汽车去年累计交付 1.3 万辆,虽然和传统汽车厂不在一个数量级,但其智能化数据还是比较亮眼的。 小鹏汽车明年要发行的新款有两大特色。基础能力包括:超长续航 + 超快充电 + 安全。特色能力是:主打高端的超级轿跑,配合顶级音响设备,再加上支持 L3 级别的智能化,看上去还是有一定竞争力的。 之前大众中国区 CEO 的分享也提到,传统汽车公司也开始进入电动车、自动驾驶领域了,纷纷开始组建硬件、软件子公司与团队,在软件上能否快速赶超走在前面的互联网造车公司是关键,如果传统汽车公司像华为入局手机制造业一样,以碾压性的资源投入快速实现 L4,并拉拢一批生态厂商制定标准规范,创业公司就比较难了,现在这个阶段正是互联网创业公司打时间差的最后时机。 智能新物种带来的智慧生态新体验这次分享的嘉宾是美的集团 IOT 事业部总经理余尚锋,讲了关于未来家电的畅想。简单来说,未来的家电会万物互联,手机将不再是唯一入口,任何屏幕都可以是入口,任何家电都拥有智能,都可以拥有所有计算能力。 这具有很强的启发意义,未来家庭中可能会存在一个计算中心,所有设备都只是屏幕,是这个计算中心人机交互的输出界面,正因为如此,你的手机才屏幕才可以被卫生间镜子自动替代,就连煤气灶的显示屏也可以刷微信、玩游戏。 AI 落地产业的这一年分别由三角兽、杉树科技、文远知行三家公司的创始人谈一谈 AI 落地产业,这三家公司都是做的比较好的垂直领域公司,其中三角兽做的自然语言理解技术已经广泛运用于许多 Top 互联网公司,像百度语音助手也调用了其服务;杉树科技通过深度学习、机器学习、运筹学帮助滴滴、顺丰、京东等等公司做最优的决策;文远知行是一家做 L4 自动驾驶技术的公司,今年也在北京投放了十几辆限定区域的自动驾驶载客汽车试运行。 可以发现,这些公司都掌握核心 AI 技术,并成为互联网头部大公司坚实合作伙伴,通过对某个领域的极致钻研“坐在了大公司旁边”。 水滴公司 鹏友说水滴公司的 CEO 沈鹏之前曾在美团就职,担任美团外卖全国业务负责人,可谓年少有为。在美团担任高管期间经历了许多磨练,也曾降职到地区负责人锤炼自己业务能力与管理能力,但即便如此,创立水滴公司后依然遇到许多挫折,沈鹏的感悟是,管理创业团队的难度比在公司当高管要难多了。 水滴公司的业务是帮助有困难的人,业务板块分为水滴商城与水滴互助,水滴商城提供一些高性价比的事前保障,水滴互助则是帮助遭遇重大疾病或变故的人筹集资金,通过参加水滴互助也让更多人了解到事前保证的重要性,促进了水滴商城的业务量。 3 总结互联网真真切切渗透到社会每一个角落,从纯线上到与产业结合,从提升社会效率到关注人类健康,涉及到生活的方方面面,每一位公司的 CEO 都非常聪明,让互联网技术最大程度在各自领域发挥着价值。 商业领域如果有唯一不变的真理,那就是为人类带来价值的公司才能基业长青。你还了解哪些利用互联网给人类创造价值的公司吗?欢迎留言。 讨论地址是:精读《极客公园 IFX - 下》 · Issue ##226 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《极客公园 IFX - 上》","path":"/wiki/WebWeekly/商业思考/《极客公园 IFX - 上》.html","content":"当前期刊数: 135 1 引言这次是极客大会十周年,也正好告别了 2019 年,因此主题是总结互联网前 10 年的发展,并预测下一个 10 年的变化。 这次是前半部分的大会感悟。 2 精读微信的成功腾讯和米聊分别在 2010.12、2011.01 上线,起初他们的用户基数相当,每天都有恐怖的 10% 用户量增长,然而这两家的差距在 2011.07 开始拉大,之后微信便占有绝对优势,米聊彻底失败。 所以有人说微信抄袭米聊,毕竟微信起步比米聊晚了一个月,然而微信的胜出有更深层的原因。 大家都知道移动端即时通讯是一个唯一寡头市场,因此当米聊看到微信开始反超的时候,就已经知道这场战争已经结束。当时小米重点业务还在手机,米聊是团队试水的一款产品,但看到歪打误撞进入一个如此蓝海的市场,小米自己也很纠结要不要把资源都投入到米聊上。 反观微信,当时手机 QQ 也在做,本来怎么也轮不到微信出场,但张小龙、马化腾、张志东在微信建立了深夜小组,每天晚上都即时同步微信的进展,这让微信即时获取到了腾讯内部资源,在各种关键节点帮了很多忙,甚至让手机 QQ 技术大牛直接支持微信改善高并发问题,快速完成 QQ 好友导入功能。 雷军总结到 “如果腾讯一年后才有所反应,米聊胜率是 50%,如果是腾讯两三个月就有反应,米聊应该 100% 会死掉”。 很巧的是,张志东事后也总结过一句话 “如果我们当初没有看清这个趋势,没有在微信起量的事后,看清这个本质,微信胜出概率也只有 50%”。 然而腾讯的反应实在太快了,米聊之后只好走差异化社区路线。 微信后面的发展也非常精彩,通过源于用户需求的少量功能,比如微信红包,不断引爆微信的增长。夸张的是,微信装机量的增长率始终与智能手机渗透率持平,这说明 微信吃掉了所有新增的流量红利。 微信的发展,是一个 工具到平台,平台到生态的演进过程。 真正让微信建立生态的是小程序。小程序是一个去中心化模式,当大家都想像公众号一样抢一波风口红利时,微信做的正是去中心化,微信不给任何小程序导量,每个小程序的流量入口都需要开发者自己经营,这种商业模式才可持续发展。 对公众号也是一样的态度:公众号要持续创造价值,没有初始红利。理解了这一点才理解了现在微信生态一系列做法,只有每位贡献者持续创造价值的生态才是可持续的,生态绝不是在创建之初让抢到先手的用户瓜分平台流量红利,这样是不可持续的。 移动终端的中场战事前十年,手机设备制造厂商的格局发生了很大变化。国内经历了从小米,到 OPPO、VIVO,再到华为的演化。 印象深刻的是看了一个雷军创办小米前夕的访谈视频,雷军说 “大家看到苹果的成功,却没有看到这片蓝海的机会,现在手机制造领域竞争太不激烈了”。同时为了对抗苹果,谷歌开源了安卓源代码,小米利用这个机会打造一款符合中国人口味的手机操作系统,并借助用户社区与性价比优势一举占领了早期市场。 2015-2018 年出现了 OV 领跑的情况,即 OPPO、VIVO 后来居上,有两点原因:小米还在强调各项参数指标,但 OV 宣传的概念很易懂 “充电五分钟,通话两小时”;同时 OV 还注意到了下沉市场,通过各种综艺节目冠名与 平均 25 万家线下门店布局,超越了小米。 上面两点分别对应了创业的早期与扩张期,然而 2019 年产业进入成熟期,手机出货量开始下降,市场逐渐进入零和博弈阶段,此时大玩家华为入场,华为的入场姿势是投入数万名研发资源进行饱和式攻击,成熟的市场比拼的不是营销而是技术,从争夺用户变成留存用户,这个阶段华为胜出了。 值得关注的是,从苹果收入年报来看,其中软件服务收入占比正在逐年升高,这也代表了一种未来发展趋势,在垄断了硬件后将收入来源逐渐转化为软件和服务。第三天的 OnePlus 手机恰恰是反其道而行之,仅通过硬件赚钱,商业模式也运转的很好,这个到后面再细说。这就是商业的有趣之处,第一商业历史的精彩程度不亚于国家战争史,第二商业模式没有万能法则,两种完全相反的模式都能活得很好,这是它最有魅力的地方。 支付宝支付宝是典型的工具场景,这次分享核心观点是:只要把工具分内的事情做好,自然会赢得用户,赢得市场。 第一个例子是早期 PC 支付时代,由于支付需要跳转到各大银行网银页面,整个链路长达 7 次跳转,用户整体付款成功率只有 60%,马云为此在年会上把支付宝团队狠批了一顿,这也促使支付宝在次年研发了快捷支付,将银行支付流程替换为支付宝自己的支付流程,支付成功率提高到了 95%。但这个改动是艰苦的,有一句话印象深刻:“为了用户体验,能做的都做了,不能做的也都做了”。 无论是二维码支付、芝麻信用还是小程序,都是由用户对工具的需求催生出来的。其中芝麻信用是因为支付宝解决了淘宝上买家与卖家的信任问题,但社会依然存在大量信任问题,芝麻信用的初心就是将淘宝信用解决方案推广到全社会。不积跬步,无以至千里,任何了不起的方案起步都是解决一个具体的问题。 拼多多拼多多给人的刻板印象是“下沉市场”,然而这既不是拼多多的起点,也不是拼多多的终点。 在创立拼多多之前,黄峥创建了一个“拼好货”的应用,这个应用瞄准城市人群,本来可以在这个垂直领域深耕,但在拼好多过程中,黄峥发现微信用户已经达到 7 亿日活,有一大半人群还没有网购习惯,但具备了网购能力,因为正好赶上微信红包培养了用户付款习惯。 为什么淘宝、京东不在微信里卖货? 原因是担心成为微信的货架。为什么淘宝当初要切断百度搜索入口?因为一旦用户培养了在百度搜索淘宝的习惯,淘宝就无法成为第一级用户触达者,一旦百度推荐自家电商产品或者切断淘宝流量,淘宝将遭受灭顶之灾。 在微信也一样,淘宝和京东都不希望被微信扼住喉咙。但这毕竟是“巨头”担心的事情,就一个创业公司来说,成为微信的货架又如何?这是个很大的市场空白,迟早有人补位。 拼多多切入点是下沉市场,下沉市场的特点是“有用户,没商品”,因此拼团很好的解决了这个问题,既提高了购买量,提升了物流、供应商效率,大量的订单量也提升了拼多多对供应商谈判的筹码,导致拼多多可以以低价提供给买家,低价又促使买家下更多的单,形成一个小飞轮。 下沉市场只是拼多多的第一刀,举一个爆品的例子:拼多多与商家合作推出了爆品玻璃碗,又大、又厚、耐高温,一下子成为了爆品,让商家与拼多多双赢。重点在于,打造爆品对促进飞轮运作太有用了,爆品意味着大量单一订单,拼多多对单一商品谈价能力提高到极限,商家制作成本压低到极限,爆品是效率最高的社会生产和消费方式。 在这个过程中,拼多多主动帮助商家打造爆品,“平台”干预商家带来双赢可能是未来一个强有力的竞争武器。 美团的商业逻辑“不设限”是对美团比较好的理解。大家都觉得美团什么都做,其实美团就是坚信“按照规律做事”,从模仿美国的 facebook - 校内网、twitter - 饭否、groupon - 美团,好的借鉴也是一种成功哲学。 四纵三横的思想,更透彻理解不同平台做的事情: 咨询 通信 娱乐 电商 搜索 百度 QQ 热血传奇 淘宝网 社交 新浪微博 人人网 开心网 蘑菇街 移动 今日头条 微信 练好基本功,提升工作效率,管理层按规律做事,合适的事找合适的人,没做过的事就自己探索,这是美团总结的经验。 字节跳动字节跳动的估值几乎是百度的两倍了,为什么看似体量更大、资源更多的百度会被字节跳动超越?大家都很感兴趣这个话题。 字节跳动核心能力是个 性化推荐引擎,旗下产品 “社交、自拍、咨询、教育、金融理财、短视频、问答、电商”都利用了技术中台输出的个性化推荐算法作为核心竞争力。 字节跳动推出的成功产品很多,像今日头条、抖音、火山、西瓜,背后的方法论就是“产品、技术、文化”。 产品上,地毯式孵化许多产品,并且根据上面总结的领域乘以个性化推荐进行了许多尝试,比如社交 X 个性化推荐,短视频 X 个性化推荐,咨询 X 个性化推荐。产品迭代也是个逐步的过程,比如抖音从直播,到小学生短视频工具,最终找到了城市潮人工具这个最合适的定位。 技术上,首先是大量从百度挖人,而且挖的都是核心技术架构骨干。其次,打造了技术中台:技术部分为“算法组、互娱组、产品技术组、垂直产品组”,最核心的技术人员在算法组,为所有产品横向赋能。总结一下就是豪华技术团队 + 技术能力中台化。 文化上,字节跳动保持很大的信息透明度,比如新员工可以查看所有历史工作资料与聊天记录,公司所有决定都是透明可查询的,公司管理扁平化。 共享出行与共享经济滴滴2010 ~ 2019 年,共享出行的代表就是滴滴,这个话题从滴滴开始剖析了整个共享经济行业,非常有意思。 切入点是 融资。BAT 上市融资额度分别是:百度:1.112 亿美元、阿里巴巴 69.88 亿美元、腾讯 0.2188 亿美元,总额 71.2 亿美元。而滴滴到目前为止的融资已经达到 208 亿美元, 滴滴融资超过 BAT 总和,这说明了什么?这说明滴滴走了一条不正常的商业路线,即先疯狂再冷静的烧钱路线。 当一个行业增长速度极速增加时,老玩家将失去优势和壁垒,所以谁能更快扩张谁就能成为最终赢家,此时如果有大量资本投入快速占领市场,让企业成为这个领域的绝对霸主,投资者就可以通过上市退出的方式把之前烧的钱赚回来。然而这种烧钱商业模式是有前提的,即 极度充裕的资本 + 清晰的结构性机会,滴滴的结构性机会非常清晰,先垄断再收割。 传统商业模式:融资 -> 赚钱。 非常态的商业模式:融资 -> 烧钱 -> 烧钱 -> 烧钱… -> 赚大钱。 Uber 创始人 特拉维斯·卡兰尼克 说了一句很经典的话,翻译过来就是:一个赛道上只要出现一个 “疯子”,所有人都必须变成 “疯子”。即一旦你所在的领域开始有公司利用融资 + 烧钱的方式运作时,你也必须这么做,否则你的市场会被对手抢走。 然而也可以看到这几年大量烧钱的公司开始合并,比如滴滴和快的打车、同城和赶集网、美团和大众点评、携程和去哪儿,这些公司合并的背后都是投资人运作的,那为什么要合并呢?道理很简单,双方投资人都在砸钱,谁也扳不倒谁,此时投资人会计算现在烧的钱在垄断市场后能否收回来,如果收不回来,双方投资人都不傻,大家为了不赔本,一定会促使两家公司合并,这样才能停止烧钱,即时上市止血。 有意思的是,滴滴从抢单模式变成派单模式,就体现了烧钱抢市场到精细化运营考虑盈利的一种转变。 摩拜和 OFO摩拜和 OFO 的发展本应该比较平静的,因为共享单车要解决的问题是 “看得见和愿意骑”,投放更多的车可以解决看得见问题,提升骑行体验可以解决愿意骑的问题,然而大量投资人从滴滴大战中大赚了一笔,想要把模式复制到共享单车领域,战斗就开始了。 由于资本的投入,摩拜和 OFO 重点都放在了“投更多的车”上,但这种抢占市场的方式并不像滴滴一样合理: 滴滴将大量私家车借给没车的人使用,本质是将“私人交通工具”变成“公共交通工具”,提升了“私人交通工具”的利用效率,对社会有益的事情自然能站得住脚。 共享单车的问题在于,大家不会把自家自行车骑出来借给别人用,毕竟开着汽车可以带乘客,但骑着自行车带人变成服务也太奇怪了。 所以各公司大量制造新的自行车投入市场,要解决的是公共交通问题,但这些自行车并没总在路上跑着,而是在街头大量闲置, 这样其实降低了自行车的工具利用效率,从根本来看没有创造剩余价值,因此盈利模式不太明朗。 更多共享模式后来出现的共享充电宝、共享车位、共享雨伞等等细分领域的创业,本来资本也想走烧钱模式,但发现走不通,还是回到了最初健康的模式。根本原因可能是这些行业无法产生寡头垄断,无法通过烧钱的方式快速占领市场并回收资本。 产业互联网与衰退期看未来十年,互联网也许进入了一个“衰退周期”,互联网从纯线上变成与产业结合,比如软硬件都做,或者线上线下结合才能继续破局,反过来说,以前纯线上一本万利的高速扩张模式一去不复返了,互联网要深度与社会结合,发挥更多实际的价值才能得到自身成长,这是一个泡沫破裂的过程,也是互联网回归到真实价值的过程。 如果资本不充裕了,对创业者来说也还有机会,比如相应的会带来低人力成本与低广告投放成本。 最后,周航宣传了一个创业孵化项目,即投资人与创业者深度交流几个月,在这几个月内让创业者得到成长,让投资人能看清创业者是否具备潜力,这种投资者与创业者培养感情的孵化方式是比较新颖的,相对面试来说,有更多机会呆在一起可以看人看得更清楚,投资者与创业者更容易建立信任关系。 语言 AI 的未来构想搜狗在 AI 语音布局很久了,我们熟悉的搜狗产品有“搜狗输入法”和“搜狗搜索”,这两个都是语言入口,所以搜狗基于语言来布局。 语言 AI 的发展方向是自然交互 + 知识计算。自然交互指人机自然的语言交互,利用语音技术、图像技术、视觉技术识别;知识计算指的是利用知识对语言进行处理,比如翻译、问答、对话。综合两者有可能产生未来的智能助理。 语音皮肤在知识付费领域就有应用场景,通过识别人的声音,将其特征提取后把另一个人的声音音色覆盖掉,这样就能让任何人代替讲师录制音频了。同样在导航语音也有类似适用场景,后面百度地图的分享会提到。 发生在边缘的 AI 计算革命所谓边缘计算指的是去中心化的本地分散运算,比如自动驾驶,就是发生在每个车上的本地计算。为什么不是云计算?因为本地计算一般都需要即时响应,尤其是自动驾驶只有几百毫秒的生命线,万一网络出现延迟,后果是谁也承担不起的。 边缘计算产生的数据量非常庞大,一辆自动驾驶汽车平均每天产生 600-1000 TB 量的计算,而且自动驾驶 L1 - L5 需要的算力也是呈指数级增长的,要解决这个问题,自研芯片与算法的软硬配合是一种突破方式。 地平线公司要做的是智能互联的底层,做手机领域的思科,做智能化时代的底层基础设施。 通往人机交互“终极自由”的 AI 之路报告显示全球有 26% 的手机用户每天使用手机超过 7 小时,35 岁以下人群平均每天解锁手机,人类都要成为手机的奴隶了,看似拓展了人类生活自由,但反而感觉人类被手机束缚住了。 原因有几块: 交互方式不自然:按键和触屏都不方便。 智能手机不智能:appStore 就是智能手机了?就算有语音助手加持,也无法理解连续语义。 解决办法就是更自然的,让人类感受不到的电子设备交互方式,比如微型音频设备,AR 眼镜,体内芯片等外挂方式,交互上需要进化为语音交互、手势交互、脑波信号等。 目前这个阶段,智能手表和智能耳机都是较能符合这个进步趋势的尝试。 地图的破局比较有意思的是利用 20 秒对话训练,可以产生一个你自己语音包,用你自己的声音导航。 另一个功能是预测第二天路况,并根据到达时间推荐一个合适的出发时间。 百度地图不止于导航,在如何挖掘地图额外价值方面也在做积极的尝试。 一起创造【所见及所能】的平行世界外号科技介绍了一款产品:远距离二维码。 我们现在看到的二维码基本都是近距离的,近距离二维码可以:支付、加好友、账号登录、近距离信息获取等。 而远距离二维码是相对于近距离二维码的,在极端情况下甚至可以达到一公里的距离。 远距离二维码的适用场景有四种: 远距离信息获取:服务机器人定位导航、无人机遂窗配送、电子围栏。 高精度定位:实时物流、室内定位报警。 增强现实:景区 AR 改造、AR 多人游戏、室内沉浸式导航、机场电子指示牌。 数据重建:室内测距和建模。 当科技拉近我们与世界的距离这个演讲者是一名了不起的盲人曹军,他创立了保益科技帮助盲人像明眼人一样生活。 记忆最深刻的一句话是:不要总以为帮助盲人就是出一款盲人专用手机、盲人专用 App,其实盲人最大诉求是像普通人一样享受科技的便利,普通人能用的手机、能用的 App、能开的车,盲人也都想用, 普通人应该想办法把自己用的手机、软件改造成盲人可以使用的版本。这是最大的换位思考。 鹏友说 - 傅盛傅盛带领的猎豹做智能机器人已经有几年了,今年有了最新进展,出货量达到 5000 台。 傅盛提到一点非常关键,就是机器人这个名字起的很不好,总让人觉得机器就应该拥有人一样的智慧,其实我们这个阶段还做不到,而且行业也不需要那样聪明的机器,要的而是一个服务工具。 举个例子,博物馆的导游可以被机器人替代,因为一方面机器人信息储备量大,工作效率高,而且还能听懂任何国家语言,这样一个机器人甚至能胜过好几位资深导游,而导游这种场景也相对局限,容易实现。 机器人也不一定要长得像人,在不同领域可以做出不同体型,适配不同的工作场景。机器在某些垂直领域完全可以超越人类。 探秘人工智能背后的【硬核英雄】未来 10 年定制化数据服务领域可能分为 5 大块: 设备的定制化 比如无人车的场景,从多摄像头到摄像头 + 激光雷达的方案,随着业务场景不断多元化,对设备定制要求也会不断提高。 场景的定制化 还是无人驾驶场景,为了保证在多场景的安全性,需要模拟出许多情况下的交通场景,比如不同光线强度、角度、不同车道、不同车型、不同类似司机、人群和环境。 样本的定制化 今天很多 AI 是以人为中心,人群可以根据不同肤色、不同语言、不同年龄段、不同爱好等进行区分,所以根据基于样本的定制也是一大趋势。 工作的协同化 和 工作的专业化,即随着分工不断细化,协同度与专业化程度都会提高。 智能交通九号机器人这家公司为了解决开车与步行之间存在的空白的问题,九号机器人提供了大量代步机器,比如智能滑板车,智能电动车,所有车辆都是“电动化、网络化”的,预测下一个 10 年会 加上“智能化”。 开车与步行之间的机器人除了代步,还有快递和配送这个巨大场景,而这个场景的优势在于,低速场景的机器人自动驾驶危险系数小,技术上较容易实现,因此可以快速投入到线下上进快速迭代。 未来十年可能是去智能化的十年,即所有的硬件都是智能硬件,所有车辆都是机器人,即智能化会极大的普及。 可折叠手机介绍了联想集团出的一款可折叠手机,据说是全球首款无痕的可折叠手机 Moto Razr。 从视频来看,无痕可折叠的最大秘密在于,并没有将屏幕折叠到 180 度这个死角,折叠到 180 度目前没有任何一个屏幕材料不产生折痕,这款手机通过非常精巧的设计,让 在外部折叠到 180 度时,内部屏幕仅折叠 100 度左右。 下一个十年:科技链接健康下一个十年,科技会更加关注健康领域,比如手环检测心跳是否异常,或者通过智能设备检测健康是否达标,以决定是否要去医院就诊,甚至以此决定医保的折扣率。 未来的年轻人吃什么?这个标题有点标题党嫌疑,其实说的是一个减肥棒产品,吃了可以减肥。 一个原始年轻人的食谱,碳水化合物、脂肪、蛋白质含量分别占 22%-40%、28%-58%、19%-35%,总结起来就是低糖、优质蛋白质。 而进入农耕时代,一个年轻人的食谱,脂肪、蛋白质、碳水化合物分别占 10%-20%,10%-20%,50%-70%,即碳水化合物太多,糖分过多,而摄入蛋白质的量严重不足,这带来了大量肥胖问题。 解决办法就是做一个低糖、优脂、优蛋白的产品,所以这款产品最终效果就是“无糖、易吸收的小分子蛋白、好吃”,至于好吃是怎么做到的,因为做了这两个方面的优化: 食材可见:比如大块杏仁碎、大块黄桃粒等。 口味丰富:芝士、椰子、巧克力、曲奇。 我一位朋友当场就订购了几箱,说实话还是蛮有诱惑力的,产品叫 ffit8,可以天猫自行搜索。 极客大会每个人都送了几袋,尝了一下还是蛮好吃的,有甜味,但为什么说无糖呢,查了一下原因,原来用的是低聚异麦芽糖,这种麦芽糖难以被吸收,所以也就可以认为是无糖的啦。 3 总结前十年,无论巨头还是创业公司都经历了起起伏伏,商业路上哪有一帆风顺,唯有真正为社会创造价值,为用户解决问题的企业才可能成功。 最后留下一道思考题,你对互联网上个十年有什么感悟吗? 讨论地址是:精读《极客公园 IFX - 上》 · Issue ##225 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《谁在世界中心》","path":"/wiki/WebWeekly/商业思考/《谁在世界中心》.html","content":"当前期刊数: 114 1. 引言谁在世界中心 是一本介绍地缘政治的书,这本书以海洋为连接世界的主要桥梁,介绍了当今全球视野下海洋争霸的政治格局。 谁征服了海洋,谁就征服了世界。陆地霸权注定无法拥有全球视野,只有海洋霸权才能征服世界,如今中国已成为海洋贸易霸主,但海洋的武力霸主仍然是美国,如果中国想成为新的全球霸主,就要突破旧的海洋霸权封锁,成为新的海洋霸主。 当然想成为海洋霸主是非常困难的,这涉及到多方政治力量的博弈,但我们可以通过《谁在世界中心》这本书了解地缘政治关系,让我们看清当下,布局未来。 《谁在世界中心》共五章,分别介绍了当下谁在主宰世界、东亚与西太平洋、东南亚与南海、南亚与印度洋、俄罗斯与北冰洋。 之所以标题都是地区与海的关系,是因为陆地与海洋的博弈就是海洋霸权的逻辑。本书需要结合地图理解,因此笔者会贴一些书中地图,围绕着地图讲解本书。 2. 精读谁在主宰世界现在 美、俄、欧、中 是这个舞台的主角,但谁也不能仅凭一个地区征服世界,因此与一些重要地区结盟,并成为地区的领导者才可能成为世界的霸主。边缘地带理论 就是指,控制了大陆板块的边缘地区,就可以对大陆进行封锁,进而控制大陆。在将眼光放到边缘地区之前,先看看现在世界舞台上的主要政治力量: 俄罗斯 - 大陆的征服者。俄罗斯一直有扩张的野心,但是在苏联解体后,值保有大部分欧亚大陆中心地带,目前已经失去领衔主演的资格。 欧盟 - 世界的发现者。作为大航海时代的开启者,欧洲史就是浓缩的世界史,并且随着疯狂的资本掠夺积累了大量原始资本。但由于英国在欧洲板块处于海洋势力,无法完全控制大陆,因此极力避免欧洲土地上出现一家独大的情况,这导致了美国的崛起。当然现在欧盟的成立也标志着欧洲进入了漫长的整合时期,德国由于其较差的地缘位置(二战后海外利益尽失),更愿意以裹挟欧盟的方式让自己成为主角。 印度 - 低纬度地区的代言人。由于低纬度炎热的气候,印度人并不热衷于国际事务,但和美国一样,印度也发展了自己的地缘优势以及人口优势,希望代表低纬度地区参与大国游戏。但是要承受另一个边缘地区国家 - 中国的压力。 中国 - 世界中心最有力的挑战者。中国拥有极大的战略纵深,集体主义文化,拥有挑战世界霸主的潜力,但在这个道路上还需解决许多问题,尤其是如何突破由美国主导的 “新世界岛俱乐部” 的封锁。 美国 - “新世界岛俱乐部” 的缔造者。北美是新世界岛的中心地区,英国和日本是新世界岛的外围地区,分别用来控制 “欧亚大陆西边缘地区(西欧)” 与 “欧亚大陆东边缘地区(中国)”。 为什么拥有海洋就拥有了世界?“海权论” 有三个主要观点: 谁掌握了世界核心的咽喉航道、运河和航线,谁就掌握了世界经济和能源运输之门。 谁掌握了世界经济和能源运输之门,谁就掌握了世界各国的经济和安全命脉。 谁掌握了世界各国的经济和安全命脉,谁就控制了全世界。 但独霸海洋非常困难,因此美国奉行的是 “边缘地带理论”。也就是通过控制欧亚大陆东西两端的边缘地带,进而控制了欧亚大陆核心地区,封锁住欧亚大陆的强国,以此保证美国世界霸主的地位。 从上图可以看出,以北美为 “新世界岛” 的中心地区,通过控制日本与英国,牵制住西欧与中国。美国实际上也做到了这一点。而随着印度的崛起,美国也找到了澳大利亚作为遏制印度的桥头堡。 那么中国怎么崛起呢?很显然,中国需要组建属于自己的 “世界岛俱乐部”,取代由美国主导的 “旧世界岛俱乐部”: 与欧亚大陆中心地带的大部分国家(主要是俄罗斯)结盟。 将 “欧亚大陆南边缘地区”(印度)拉入同盟。 寻找可能的 “世界岛外围地区”,并使之倒向同盟(日本、韩国、朝鲜等)。 但就目前状况来看,中印关系竞争与合作同时上升,俄国由于前苏联的老大地位暂时不愿意放下身段,日本更处于美国为中心的俱乐部中,因此这条路困难重重。之所以将日本拉进来,一方面是因为与印、俄结盟不足以取得与 “旧世界岛俱乐部” 竞争的优势,一方面是中日地缘距离近,且日本国民性格敬仰强大的对手,另一方面日本是美国牵制中国的力量,拉拢过来不仅可以打消美国的算盘,还能增强东亚整体实力。 第一章总览了世界地缘政治关系的全貌,并为中国崛起指出了道路。后面几章则具体介绍各个存在联动的政治板块间的具体博弈情况,做到知己知彼。 东亚与西太平洋 参与东亚与西太平洋博弈的主要国家有:中国、朝鲜、韩国、日本、俄罗斯。中国是参与板块博弈的核心,比如俄罗斯会在朝鲜半岛问题方面发表意见,但不会干涉钓鱼岛问题,而中国都参与其中。 东亚平原如此广袤,以至于东亚地区的民族都认为控制了这片核心区就控制了世界中心。但随着西方殖民者从海路上到来,中国人才明白自己并不是世界的中心,但长期 “中央之国的心态” 影响着我们每一个人。 中国农耕区域总是受到来自北方三个势力的威胁:“东北森林渔猎民族”、“蒙古高原草原游牧民族”、“青藏高原高原游牧民族”,这是由于农耕的生产方式稳定,创造的财富大,因此源源不断吸引这些外来者的入侵,有趣的是,每一次农耕区域都能同化外来的入侵者,而 “中国” 的传统观念也是同化他们的重要因素。所以到后面会讲到为何印度人进取心不如中国强,原因就在中国需要长期与北方威胁斗争,而印度不需要,印度由于地缘位置,导致不会受到太多来自边远民族的入侵,这个在分析印度时会讲到。 日本、朝鲜半岛由于地理阻隔,在东亚大陆统一时得保持独立。而朝鲜半岛与大陆相连却一直没有被征服的原因是,从地图上看,想要入侵朝鲜半岛必须沿着海岸线,但通过辽西走廊进入辽河平原时,辽河平原地理气候的不稳定性容易切断朝鲜半岛与东亚核心区脆弱的地缘联系,导致渗入半岛的人口要么退回,要么融于当地族群。 东亚面临西太平洋区域被外包包夹形成四个 “内海”,可以形容为 “第一岛链” 与 “第二岛链”,美国正是通过控制这些岛链来控制 “欧亚大陆东边缘地区” 的。 第一岛链包括:日本群岛、琉球群岛、冲绳岛、台湾岛、南至菲律宾群岛、大巽他群岛。其中日本是势力最大的岛链,在日本 “大东亚共荣圈” 计划中,极盛时期控制的范围如下图所示: 而中国想要成为世界霸主,就要构建以中国为主导核心的 “东亚核心圈 + 东盟十国”,见下图。 对日本来说,如今已没有实力做这个核心圈的老大,但最起码希望和中国共同主导,但核心圈只有一家独大才能发挥称霸世界的力量,中国与日本还有很多问题需要解决。相比欧盟,虽然也在融合(3 + 10 模式,即三个核心 - 法、德、英 + 10 个其他国家 不包含俄罗斯),但由于地缘特点不可能出现一家独大的情况。 第二岛链包括:从日本岛作为起点,南经小笠原诸国、火山列岛、马里亚纳群岛、关岛、雅浦岛、帕劳群岛,直至哈马黑拉岛等岛群。 不过第二岛链的威胁远没有第一岛链大。 从西太平洋向东看看美国。对美国来说,太平洋所有岛屿都是他进攻的跳板。如上图所示,美国通过诸多岛屿作为跳板进攻,在二战中,甚至跳过了对某些战略要地的争夺,通过前沿岛屿作为基地,直接攻击日本本岛。 东南亚与南海 东南亚区域分位:中南半岛(缅甸、越南与印度支那、泰国)、南洋群岛、文莱、巴厘岛以及东帝汶、马六甲海峡等重要区域。 首先看中南半岛: 中南半岛由 5 个国家组成,从西到东分别是:缅甸、泰国、柬埔寨、老挝、越南,其中缅、老、 越与中国接壤,除了老挝外都有足够的海岸线。这些国家大部分是殖民时代的遗产,英法分别在缅甸、越南发力,将泰国定位缓冲国。法国人曾将柬埔寨、老挝、越南合并成 “印支联邦” 与英国对抗,虽然现在又分裂成三个国家,因此却为越南埋下了大国梦。 缅甸在位置上,可以在陆地及海洋延伸中国的地缘影响力,而且也曾成为支持中国抗战的重要援助物资运输线。在缅甸东边是 “金三角地区”: 金三角地区 盛产鸦片,首先是金三角环境适合种植鸦片,其次由于所处缅甸、老挝、泰国交界处特别适合逃避法律打击。解决问题的办法就是联合执法,在 “湄公河惨案” 后,由中国主导的联合执法开发形成常态,金三角成为中国拓展自己地缘影响力的重要抓手。 中国与中南半岛虽然地缘上存在天然阻隔,但在云贵高原与克钦邦之间存在的南方丝绸之路、中印缅之间存在因抗日战争运输物资而修建了史迪威公路。这些重要的交通枢纽对维系中、缅两国的共同利益有着推动作用。 越南 一直想成为中南半岛的强国,但先后被清朝打压、后与法美中几大国相继开战,始终没有得到什么实际利益。越南狭长的地形使其一直存在南北分裂的风险。 泰国 之所以能在西方殖民者将土地瓜分完毕时仍保持独立,是凭借其高超的平衡技巧,成为了英法殖民地之间的缓冲国。 克拉地峡是继马六甲海峡后另一个有价值的航线,是否能开挖取决于各方利益平衡,尤其是这样会切断泰国南北,导致加大泰国南部的分裂倾向。 南洋群岛由 6 个国家组成,分别是:印尼、马拉西亚、菲律宾、文莱、新加坡、东帝汶。 “下南洋” 期间,在西方殖民者的推动下,大量华人下南洋开发,因此南洋群岛留着部分华夏民族血液。在新加坡,甚至因为华人占据了 75% 的人口,马来西亚为了在脱离英国殖民统治后保证马来人获得多数票,因此将新加坡排除在马来西亚联邦之外,才使得新加坡独立建国。 接着看文莱、巴厘岛和东帝汶: 文莱 在马来西亚中是个弹丸小国,但因为在西方殖民者接入之前,文莱的前身 “渤泥国” 的势力范围很大,因此在被殖民者打碎野心的情况下,文莱有着强烈独立的愿望,从争取 “保护国” 的地位到最终独立,文莱一路走来很不容易。但是文莱被 “林梦地区” 一分为二,马来西亚也不会容忍文莱有更多的领土要求,两者僵持不下。但我们相信,身处这种状况的文莱更希望获得外部力量的支持,作为与南海隔海相望的中国将会是其理想的盟友。 巴厘岛 不仅是度假胜地,在 14 世纪末至 15 世纪初,在伊斯兰教强大压力下,坚守印度教的少数马来人从爪哇岛移民至巴厘岛,因为宗教信仰的不同,这里引起恐怖分子的关注。 东帝汶 是欧洲殖民者划分殖民地的产物,南部的澳大利亚觊觎其丰富油矿资源而积极干涉东帝汶的事物。反过来想,如果中国控制了东帝汶区域,就可以对澳大利亚施加政治影响力。 相比南洋群岛,南海 离中国更近。如果要控制南海,就要分别控制位于南海五个方向的:东沙群岛、西沙群岛、黄岩岛、中沙大环礁、南沙群岛。 中国想要经略南海,首先要提升自己的综合实力。最近能够在南海问题上有所突破,本质上还是中国综合实力得到了提高。但经略南海不代表占领南海,而是要与南海周边的国家进行博弈,合纵连横。搁置争议,共同开发是最好的策略,如果中国能够掌握深海石油勘采技术,至少能在投资、技术层面让多方面获益。 从中国海上突围角度来看,有三条航线可选:南海-马六甲海峡-印度洋航线、印尼通道-印度洋航线、西太平洋-南太平洋-印度洋航线 马六甲海峡 是南海的咽喉,被新加坡控制,且战时容易被封锁。备选方案印尼通道是个不错的选择,而且相比马六甲海峡三国(新加坡、马来西亚、印度尼西亚),印尼通道 只要和印尼搞好关系即可。然而印尼也可能被日本拉拢,但由于印尼与中国没有直接利益冲突,站队日本对印尼来说得不到什么好处。 南亚与印度洋 南亚包括 7 个国家,分别是南亚次大陆的:尼泊尔、不丹、巴基斯坦、印度、孟加拉国 和印度洋上的岛国:斯里兰卡、马尔代夫。 由于 “印巴分治”,巴基斯坦于 1947 年独立,但由于东西距离太远,中间隔着印度,因此东边独立成了孟加拉国。不丹处于印度保护国状态,而斯里兰卡除了地理阻隔外,有意识的选择了不同的宗教,也是一直保持独立的重要原因。 再往南的马尔代夫给人留下的印象就是度假胜地,但这个海拔只有 1.2 米的岛国,随着气候变暖可能是最先消失的国家。 印度 之所以走上与中国不同的道路,主要因为外部压力相对较小。之前也介绍了中国长期受到北方民族的入侵,是因为中国北方有足够大的阶梯地形让北方民族适应 “低原反应”,而印度北方的山脉没有足够的缓冲区,为印度形成了天然的防护屏障。 虽然热带气候可以让文明较早发展,但没有边缘民族入侵压力,会让文明变得非常脆弱,也缺乏扩张的动力。从融合的角度来说,印度虽然也融合了其他民族,但相比 中国的 “家天下”,印度属于 “种姓” 文明框架。 “家天下” 的模式每个人都有平等的机会,而 “种姓” 制度确保了阶级固化,加上热带地区物产丰富,不至于出现被饿死的情况,因此这种制度得以稳定下来。 克什米尔 是印度河的上游,在工业化时代,掌握了上游就可以控制下游的水资源,现在印巴两国共享上印度河平原,任何一方都不会轻易放弃这块战略要地。 中国想要扩大自己在印度洋的影响力,就需要找到 缅甸、巴基斯坦、斯里兰卡、东帝汶、肯尼亚 这五个点做支持。如今中国一带一路计划,为东亚各国修筑高铁等基础设施,就是拓展中国外交空间的良好手段,加深经济的合作才有可能迎来政治合作。 俄罗斯与北冰洋俄国 虽然北临北冰洋,但是没有不冻港是无法通航的。俄国人通过不平等条约使中国东部边界从 库页岛 移到了 乌苏里江,因此俄国成为了第二个同时可以对三个洋(太平洋、大西洋、北冰洋)施加地缘影响力的国家。 不过好在中俄存在 “背靠背” 的战略伙伴关系,因此有合作的空间(俄国要应对西欧,中国要应对东南亚)。但俄罗斯的海岸线很短,这导致俄国在海权争霸的舞台只能当配角,但这个地缘结构不是一成不变的,如果全球变暖导致北冰洋融化,看到的将是另一个格局: 如果北冰洋融化后可以通航,俄罗斯将成为北冰洋地缘势力最强大的国家,其次是加拿大与阿拉斯加。如果俄国没有短视将阿拉斯加卖给美国,俄国将为成为北冰洋唯一的霸主。 3. 总结《谁在世界中心》这本书一定要看着地图读,这样会发现板块运动随机产生的变化竟然会对世界政治格局产生这么重大的影响,一个国家能否独立最重要的还是看地缘位置。 这本书更是一本中国崛起的地缘解决方案指南,其中一些解决方案在商业逻辑中可以拿来借鉴: 竞争是一个过程,唯有不断参与其中,才有可能掌握话语权,主导权,最终达到政治目的。任何领土都是通过与周边地区漫长博弈后逐渐确立下来的,想得到利益首先得参与到游戏中。 各玩家实力是动态变化的,即便是无法通航的北冰洋,都可能因为温室效应变成不冻港,因此提前看到趋势并提前准备是必须的。 想从对方获得利益,首先要了解对方想获得什么利益,自己有什么筹码,这样才容易促成合作。在寻找盟友前,先站在对方角度掂量一下自己是否合适。 不可能一家独大,想成为霸主,必须建立一个生态。以前是小弟听大哥的话,现在大哥得给小弟好处,才能得到小弟的忠诚。 已有霸主的地位不是一朝一夕就能摧毁的,就像中国想突破马六甲海峡的封锁,在不突破整体海洋封锁的前提下是不可能的,因为海洋霸权是一个全球化整体,美国封锁亚洲有完整的第一岛链、第二岛链逻辑,解决问题的视角要全面。 如今处于大变革的和平时代,国家之间看似和平,实则在进行经济扩张,大国之间要学会不撕破脸的竞争方式。而这个多方博弈的复杂性,使得阴谋几乎不可能得逞,大国的政策都是阳谋,比的是谁更能顺势而为,拉拢更多合作者。 讨论地址是:精读《谁在世界中心》 · Issue ##189 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《@umijs use-request》源码","path":"/wiki/WebWeekly/源码解读/《@umijs use-request》源码.html","content":"当前期刊数: 151 1 引言与组件生命周期绑定的 Utils 非常适合基于 React Hooks 来做,比如可以将 “发请求” 这个功能与组件生命周期绑定,实现一些便捷的功能。 这次以 @umijs/use-request 为例子,分析其功能思路与源码。 2 简介@umijs/use-request 支持以下功能: 默认自动请求:在组件初次加载时自动触发请求函数,并自动管理 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 做下面几件与组件相关的事: 存储与当前组件实例绑定的 mutable、immutable 数据。 主动触发调用组件 rerender。 访问到组件初始化、销毁时机的钩子。 上面这些功能就可以基于这些基础能力拓展了: 默认自动请求 在组件初始时机取数。由于和组件生命周期绑定,可以很方便实现各组件相互隔离的取数顺序强保证:可以利用取数闭包存储 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 中,不仅提升了代码抽象程度,也提升了可维护性。 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 零依赖实现,需要: 有缓存则不需响应,当对应缓存结束后会通知,同时也支持了请求预加载功能。 为支持并行请求,所有请求都通过 fetches 独立管理。 // 第一次默认执行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 定义出来并暴露给用户: 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 时机里判断: 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 函数中: 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 同步缓存: // cacheuseEffect(() => { if (cacheKey) { setCache(cacheKey, { fetches, newstFetchKey: newstFetchKey.current, }); }}, [cacheKey, fetches]); 在初始化 Fetch 实例时优先采用缓存: 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 即可,源码里采取全局统一监听的方式: 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 构造函数里注册: 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 数据结果即可: function mutate(data: any) { if (typeof data === "function") { this.setState({ data: data(this.state.data) || {}, }); } else { this.setState({ data, }); }} 值得注意的是,cancel、refresh、mutate 都必须在初次请求完成后才有意义,所以初次返回的函数是一个抛错: 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 函数中: 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 保护: // 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Epitath 源码 - renderProps 新用法》","path":"/wiki/WebWeekly/源码解读/《Epitath 源码 - renderProps 新用法》.html","content":"当前期刊数: 75 1 引言很高兴这一期的话题是由 epitath 的作者 grsabreu 提供的。 前端发展了 20 多年,随着发展中国家越来越多的互联网从业者涌入,现在前端知识玲琅满足,概念、库也越来越多。虽然内容越来越多,但作为个体的你的时间并没有增多,如何持续学习新知识,学什么将会是个大问题。 前端精读通过吸引优质的用户,提供最前沿的话题或者设计理念,虽然每周一篇文章不足以概括这一周的所有焦点,但可以保证你阅读的这十几分钟没有在浪费时间,每一篇精读都是经过精心筛选的,我们既讨论大家关注的焦点,也能找到仓库角落被遗忘的珍珠。 2 概述在介绍 Epitath 之前,先介绍一下 renderProps。 renderProps 是 jsx 的一种实践方式,renderProps 组件并不渲染 dom,但提供了持久化数据与回调函数帮助减少对当前组件 state 的依赖。 RenderProps 的概念react-powerplug 就是一个 renderProps 工具库,我们看看可以做些什么: <Toggle initial={true}> {({ on, toggle }) => <Checkbox checked={on} onChange={toggle} />}</Toggle> Toggle 就是一个 renderProps 组件,它可以帮助控制受控组件。比如仅仅利用 Toggle,我们可以大大简化 Modal 组件的使用方式: class App extends React.Component { state = { visible: false }; showModal = () => { this.setState({ visible: true }); }; handleOk = e => { this.setState({ visible: false }); }; handleCancel = e => { this.setState({ visible: false }); }; render() { return ( <div> <Button type="primary" onClick={this.showModal}> Open Modal </Button> <Modal title="Basic Modal" visible={this.state.visible} onOk={this.handleOk} onCancel={this.handleCancel} > <p>Some contents...</p> <p>Some contents...</p> <p>Some contents...</p> </Modal> </div> ); }}ReactDOM.render(<App />, mountNode); 这是 Modal 标准代码,我们可以使用 Toggle 简化为: class App extends React.Component { render() { return ( <Toggle initial={false}> {({ on, toggle }) => ( <Button type="primary" onClick={toggle}> Open Modal </Button> <Modal title="Basic Modal" visible={on} onOk={toggle} onCancel={toggle} > <p>Some contents...</p> <p>Some contents...</p> <p>Some contents...</p> </Modal> )} </Toggle> ); }}ReactDOM.render(<App />, mountNode); 省掉了 state、一堆回调函数,而且代码更简洁,更语义化。 renderProps 内部管理的状态不方便从外部获取,因此只适合保存业务无关的数据,比如 Modal 显隐。 RenderProps 嵌套问题的解法renderProps 虽然好用,但当我们想组合使用时,可能会遇到层层嵌套的问题: <Counter initial={5}> {counter => { <Toggle initial={false}> {toggle => { <MyComponent counter={counter.count} toggle={toggle.on} />; }} </Toggle>; }}</Counter> 因此 react-powerplugin 提供了 compose 函数,帮助聚合 renderProps 组件: import { compose } from 'react-powerplug'const ToggleCounter = compose( <Counter initial={5} />, <Toggle initial={false} />)<ToggleCounter> {(toggle, counter) => ( <ProductCard {...} /> )}</ToggleCounter> 使用 Epitath 解决嵌套问题Epitath 提供了一种新方式解决这个嵌套的问题: const App = epitath(function*() { const { count } = yield <Counter /> const { on } = yield <Toggle /> return ( <MyComponent counter={count} toggle={on} /> )})<App /> renderProps 方案与 Epitath 方案,可以类比为 回调 方案与 async/await 方案。Epitath 和 compose 都解决了 renderProps 可能带来的嵌套问题,而 compose 是通过将多个 renderProps merge 为一个,而 Epitath 的方案更接近 async/await 的思路,利用 generator 实现了伪同步代码。 3 精读Epitath 源码一共 40 行,我们分析一下其精妙的方式。 下面是 Epitath 完整的源码: import React from "react";import immutagen from "immutagen";const compose = ({ next, value }) => next ? React.cloneElement(value, null, values => compose(next(values))) : value;export default Component => { const original = Component.prototype.render; const displayName = `EpitathContainer(${Component.displayName || "anonymous"})`; if (!original) { const generator = immutagen(Component); return Object.assign( function Epitath(props) { return compose(generator(props)); }, { displayName } ); } Component.prototype.render = function render() { // Since we are calling a new function to be called from here instead of // from a component class, we need to ensure that the render method is // invoked against `this`. We only need to do this binding and creation of // this function once, so we cache it by adding it as a property to this // new render method which avoids keeping the generator outside of this // method's scope. if (!render.generator) { render.generator = immutagen(original.bind(this)); } return compose(render.generator(this.props)); }; return class EpitathContainer extends React.Component { static displayName = displayName; render() { return <Component {...this.props} />; } };}; immutagenimmutagen 是一个 immutable generator 辅助库,每次调用 .next 都会生成一个新的引用,而不是自己发生 mutable 改变: import immutagen from "immutagen";const gen = immutagen(function*() { yield 1; yield 2; return 3;})(); // { value: 1, next: [function] }gen.next(); // { value: 2, next: [function] }gen.next(); // { value: 2, next: [function] }gen.next().next(); // { value: 3, next: undefined } compose看到 compose 函数就基本明白其实现思路了: const compose = ({ next, value }) => next ? React.cloneElement(value, null, values => compose(next(values))) : value; const App = epitath(function*() { const { count } = yield <Counter />; const { on } = yield <Toggle />;}); 通过 immutagen,依次调用 next,生成新组件,且下一个组件是上一个组件的子组件,因此会产生下面的效果: yield <A>yield <B>yield <C>// 等价于<A> <B> <C /> </B></A> 到此其源码精髓已经解析完了。 存在的问题crimx 在讨论中提到,Epitath 方案存在的最大问题是,每次 render 都会生成全新的组件,这对内存是一种挑战。 稍微解释一下,无论是通过 原生的 renderProps 还是 compose,同一个组件实例只生成一次,React 内部会持久化这些组件实例。而 immutagen 在运行时每次执行渲染,都会生成不可变数据,也就是全新的引用,这会导致废弃的引用存在大量 GC 压力,同时 React 每次拿到的组件都是全新的,虽然功能相同。 4 总结epitath 巧妙的利用了 immutagen 的不可变 generator 的特性来生成组件,并且在递归 .next 时,将顺序代码解析为嵌套代码,有效解决了 renderProps 嵌套问题。 喜欢 epitath 的同学赶快入手吧!同时我们也看到 generator 手动的步骤控制带来的威力,这是 async/await 完全无法做到的。 是否可以利用 immutagen 解决 React Context 与组件相互嵌套问题呢?还有哪些其他前端功能可以利用 immutagen 简化的呢?欢迎加入讨论。 5 更多讨论 讨论地址是:精读《Epitath - renderProps 新用法》 · Issue ##106 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《Htm - Hyperscript 源码》","path":"/wiki/WebWeekly/源码解读/《Htm - Hyperscript 源码》.html","content":"当前期刊数: 82 1 引言htm 是 preact 作者的新尝试,利用原生 HTML 规范支持了类 JSX 的写法。 2 概要htm 没有特别的文档,假如你用过 JSX,那只需要记住下面三个不同点: className -> class。 标签引号可选(回归 html 规范):<div class=foo>。 支持 HTML 模式的注释:<div><!-- don't delete this! --></div>。 另外支持了可选结束标签、快捷组件 End 标签,不过这些自己发明的语法不建议记忆。 用法也没什么特别的地方,你可以利用 HTML 原生规范,用直觉去写 JSX: html` <div class="app"> <${Header} name="ToDo's (${page})" /> <ul> ${todos.map( todo => html` <li>${todo}</li> ` )} </ul> <button onClick=${() => this.addTodo()}>Add Todo</button> <${Footer}>footer content here<//> </div>`; 很显然,由于跳过了 JSX 编译,换成了原生的 Template Strings ,所以所有组件、属性部分都需要改成 ${} 语法,比如: <${Header}> 这种写法略显别扭,但整体上还是蛮直观的。 你不一定非要用在项目环境中,但当你看到这种语法时,内心一定情不自禁的 WoW,竟然还有这种写法! 下面将带你一起分析 htm 的源码,看看作者是如何做到的。 3 精读你可以先自己尝试阅读,源码加上注释一共 90 行:源码。 好了,欢迎继续阅读。 首先你要认识到, htm + vhtml 才等于你上面看到的 DEMO。 HtmHtm 是一个 dom template 解析器,它可以将任何 dom template 解析成一颗语法树,而这个语法树的结构是: interface VDom { tag: string; props: { [attrKey: string]: string; }; children: VDom[];} 我们看一个 demo: function h(tag, props, ...children) { return { tag, props, children };}const html = htm.bind(h);html` <div>123</div>`; // { tag: "div", props: {}, children: ["123"] } 那具体是怎么做语法解析的呢? 其实实现方式有点像脑经急转弯,毕竟解析 dom template 是浏览器引擎做的事,规范也早已定了下来,有了规范和实现,当然没必要重复造轮子,办法就是利用 HTML 的 AST 生成我们需要的 AST。 首先创建一个 template 元素: const TEMPLATE = document.createElement("template"); 再装输入的 dom template 字符串塞入(作者通过正则,机智的将自己支持的额外语法先转化为标准语法,再交给 HTML 引擎): TEMPLATE.innerHTML = str; 最后我们会发现进入了 walk 函数,通过 localName 拿到标签名;attributes 拿到属性值,通过 firstChild 与 nextSibling 遍历子元素继续走 walk,最后 tag props children 三剑客就生成了。 可能你还没看完,就已经结束了。笔者分析这个库,除了告诉你作者的机智思路,还想告诉你的是,站在巨人的肩膀造轮子,真的事半功倍。 VDomVDom 是个抽象概念,它负责将实体语法树解析为 DOM。这个工具可以是 preact、vhtml,或者由你自己来实现。 当然,你也可以利用这个 AST 生成 JSON,比如: import htm from "htm";import jsxobj from "jsxobj";const html = htm.bind(jsxobj);console.log(html` <webpack watch mode=production> <entry path="src/index.js" /> </webpack>`);// {// watch: true,// mode: 'production',// entry: {// path: 'src/index.js'// }// } 读到这,你觉得还有哪些 “VDom” 可以写呢?其实任何可以根据 tag props children 推导出的结构都可以写成解析插件。 4 总结htm 是一个教科书般借力造论子案例: 利用 innerHTML 会自动生成的标准 AST,解析出符合自己规范的 AST,这其实是进一步抽象 AST。 利用原有库进行 DOM 解析,比如 preact 或 vhtml。 基于第二点,所以可以生成任何目标代码,比如 json,pdf,excel 等等。 不过这也带来了一个问题:依赖原生 DOM API 会导致无法运行在 NodeJS 环境。 想一想你现在开发的工具库,有没有可以借力的地方呢?有哪些点可以通过借力做得更好从而实现双赢呢?欢迎留下你的思考。 讨论地址是:精读《Htm - Hyperscript 源码》 · Issue ##114 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《Hooks 取数 - swr 源码》","path":"/wiki/WebWeekly/源码解读/《Hooks 取数 - swr 源码》.html","content":"当前期刊数: 128 1 引言取数是前端业务的重要部分,也经历过几次演化: fetch 的兼容性已经足够好,足以替换包括 $.post 在内的各种取数封装。 原生用得久了,发现拓展性更好、支持 ssr 的同构取数方案也挺好,比如 isomorphic-fetch、axios。 对于数据驱动场景还是不够,数据流逐渐将取数封装起来,同时针对数据驱动状态变化管理进行了 data isLoading error 封装。 Hooks 的出现让组件更 Reactive,我们发现取数还是优雅回到了组件里,swr 就是一个教科书般的例子。 swr 在 2019.10.29 号提交,仅仅 12 天就攒了 4000+ star,平均一天收获 300+ star!本周精读就来剖析这个库的功能与源码,了解这个 React Hooks 的取数库的 Why How 与 What。 2 概述首先介绍 swr 的功能。 为了和官方文档有所区别,笔者以探索式思路介绍这个它,但例子都取自官方文档。 2.1 为什么用 Hooks 取数首先回答一个根本问题:为什么用 Hooks 替代 fetch 或数据流取数? 因为 Hooks 可以触达 UI 生命周期,取数本质上是 UI 展示或交互的一个环节。 用 Hooks 取数的形式如下: import useSWR from "swr";function Profile() { const { data, error } = useSWR("/api/user", fetcher); if (error) return <div>failed to load</div>; if (!data) return <div>loading...</div>; return <div>hello {data.name}!</div>;} 首先看到的是,以同步写法描述了异步逻辑,这是因为渲染被执行了两次。 useSWR 接收三个参数,第一个参数是取数 key,这个 key 会作为第二个参数 fetcher 的第一个参数传入,普通场景下为 URL,第三个参数是配置项。 Hooks 的威力还不仅如此,上面短短几行代码还自带如下特性: 可自动刷新。 组件被销毁再渲染时优先启用本地缓存。 在列表页中浏览器回退可以自动记忆滚动条位置。 tabs 切换时,被 focus 的 tab 会重新取数。 当然,自动刷新或重新取数也不一定是我们想要的,swr 允许自定义配置。 2.2 配置上面提到,useSWR 还有第三个参数作为配置项。 独立配置 通过第三个参数为每个 useSWR 独立配置: useSWR("/api/user", fetcher, { revalidateOnFocus: false }); 配置项可以参考 文档。 可以配置的有:suspense 模式、focus 重新取数、重新取数间隔/是否开启、失败是否重新取数、timeout、取数成功/失败/重试时的回调函数等等。 第二个参数如果是 object 类型,则效果为配置项,第二个 fetcher 只是为了方便才提供的,在 object 配置项里也可以配置 fetcher。 全局配置 SWRConfig 可以批量修改配置: import useSWR, { SWRConfig } from "swr";function Dashboard() { const { data: events } = useSWR("/api/events"); // ...}function App() { return ( <SWRConfig value={{ refreshInterval: 3000 }}> <Dashboard /> </SWRConfig> );} 独立配置优先级高于全局配置,在精读部分会介绍实现方式。 最重量级的配置项是 fetcher,它决定了取数方式。 2.3 自定义取数方式自定义取数逻辑其实分几种抽象粒度,比如自定义取数 url,或自定义整个取数函数,而 swr 采取了相对中间粒度的自定义 fetcher: import fetch from "unfetch";const fetcher = url => fetch(url).then(r => r.json());function App() { const { data } = useSWR("/api/data", fetcher); // ...} 所以 fetcher 本身就是一个拓展点,我们不仅能自定义取数函数,自定义业务处理逻辑,甚至可以自定义取数协议: import { request } from "graphql-request";const API = "https://api.graph.cool/simple/v1/movies";const fetcher = query => request(API, query);function App() { const { data, error } = useSWR( `{ Movie(title: "Inception") { releaseDate actors { name } } }`, fetcher ); // ...} 这里回应了第一个参数称为取数 Key 的原因,在 graphql 下它则是一段语法描述。 到这里,我们可以自定义取数函数,但却无法控制何时取数,因为 Hooks 写法使取数时机与渲染时机结合在一起。swr 的条件取数机制可以解决这个问题。 2.4 条件取数所谓条件取数,即 useSWR 第一个参数为 null 时则会终止取数,我们可以用三元运算符或函数作为第一个参数,使这个条件动态化: // conditionally fetchconst { data } = useSWR(shouldFetch ? "/api/data" : null, fetcher);// ...or return a falsy valueconst { data } = useSWR(() => (shouldFetch ? "/api/data" : null), fetcher); 上例中,当 shouldFetch 为 false 时则不会取数。 第一个取数参数推荐为回调函数,这样 swr 会 catch 住内部异常,比如: // ... or throw an error when user.id is not definedconst { data, error } = useSWR(() => "/api/data?uid=" + user.id, fetcher); 如果 user 对象不存在,user.id 的调用会失败,此时错误会被 catch 住并抛到 error 对象。 实际上,user.id 还是一种依赖取数场景,当 user.id 发生变化时需要重新取数。 2.5 依赖取数如果一个取数依赖另一个取数的结果,那么当第一个数据结束时才会触发新的取数,这在 swr 中不需要特别关心,只需按照依赖顺序书写 useSWR 即可: function MyProjects() { const { data: user } = useSWR("/api/user"); const { data: projects } = useSWR(() => "/api/projects?uid=" + user.id); if (!projects) return "loading..."; return "You have " + projects.length + " projects";} swr 会尽可能并行没有依赖的请求,并按依赖顺序一次发送有依赖关系的取数。 可以想象,如果手动管理取数,当依赖关系复杂时,为了确保取数的最大可并行,往往需要精心调整取数递归嵌套结构,而在 swr 的环境下只需顺序书写即可,这是很大的效率提升。优化方式在下面源码解读章节详细说明。 依赖取数是自动重新触发取数的一种场景,其实 swr 还支持手动触发重新取数。 2.6 手动触发取数trigger 可以通过 Key 手动触发取数: import useSWR, { trigger } from "swr";function App() { return ( <div> <Profile /> <button onClick={() => { // set the cookie as expired document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; // tell all SWRs with this key to revalidate trigger("/api/user"); }} > Logout </button> </div> );} 大部分场景不必如此,因为请求的重新触发由数据和依赖决定,但遇到取数的必要性不由取数参数决定,而是时机时,就需要用手动取数能力了。 2.7 乐观取数特别在表单场景时,数据的改动是可预期的,此时数据驱动方案只能等待后端返回结果,其实可以优化为本地先修改数据,等后端结果返回后再刷新一次: import useSWR, { mutate } from "swr";function Profile() { const { data } = useSWR("/api/user", fetcher); return ( <div> <h1>My name is {data.name}.</h1> <button onClick={async () => { const newName = data.name.toUpperCase(); // send a request to the API to update the data await requestUpdateUsername(newName); // update the local data immediately and revalidate (refetch) mutate("/api/user", { ...data, name: newName }); }} > Uppercase my name! </button> </div> );} 通过 mutate 可以在本地临时修改某个 Key 下返回结果,特别在网络环境差的情况下加快响应速度。乐观取数,表示对取数结果是乐观的、可预期的,所以才能在结果返回之前就预测并修改了结果。 2.8 Suspense 模式在 React Suspense 模式下,所有子模块都可以被懒加载,包括代码和请求都可以被等待,只要开启 suspense 属性即可: import { Suspense } from "react";import useSWR from "swr";function Profile() { const { data } = useSWR("/api/user", fetcher, { suspense: true }); return <div>hello, {data.name}</div>;}function App() { return ( <Suspense fallback={<div>loading...</div>}> <Profile /> </Suspense> );} 2.9 错误处理onErrorRetry 可以统一处理错误,包括在错误发生后重新取数等: useSWR(key, fetcher, { onErrorRetry: (error, key, option, revalidate, { retryCount }) => { if (retryCount >= 10) return; if (error.status === 404) return; // retry after 5 seconds setTimeout(() => revalidate({ retryCount: retryCount + 1 }), 5000); }}); 3 精读3.1 全局配置在 Hooks 场景下,包装一层自定义 Context 即可实现全局配置。 首先 SWRConfig 本质是一个定制 Context Provider: const SWRConfig = SWRConfigContext.Provider; 在 useSWR 中将当前配置与全局配置 Merge 即可,通过 useContext 拿到全局配置: config = Object.assign({}, defaultConfig, useContext(SWRConfigContext), config); 3.2 useSWR 的一些细节从源码可以看到更多细节用心,useSWR 真的比手动调用 fetch 好很多。 兼容性 useSWR 主体代码在 useEffect 中,但是为了将请求时机提前,放在了 UI 渲染前(useLayoutEffect),并兼容了服务端场景: const useIsomorphicLayoutEffect = IS_SERVER ? useEffect : useLayoutEffect; 非阻塞 请求时机在浏览器空闲时,因此请求函数被 requestIdleCallback 包裹: window["requestIdleCallback"](softRevalidate); softRevalidate 是开启了去重的 revalidate: const softRevalidate = () => revalidate({ dedupe: true }); 即默认 2s 内参数相同的重复取数会被取消。 性能优化 由于 swr 的 data、isValidating 等数据状态是利用 useState 分开管理的: let [data, setData] = useState( (shouldReadCache ? cacheGet(key) : undefined) || config.initialData);// ...let [isValidating, setIsValidating] = useState(false); 而取数状态变化时往往 data 与 isValidating 要一起更新,为了仅触发一次更新,使用了 unstable_batchedUpdates 将更新合并为一次: unstable_batchedUpdates(() => { setIsValidating(false); // ... setData(newData);}); 其实还有别的解法,比如使用 useReducer 管理数据也能达到相同性能效果。目前源码已经从unstable_batchedUpdates切换为 useReducer管理 dispatch(newState); 3.3 初始缓存当页面切换时,可以暂时以上一次数据替换取数结果,即初始化数据从缓存中拿: const shouldReadCache = config.suspense || !useHydration();// stale: get from cachelet [data, setData] = useState( (shouldReadCache ? cacheGet(key) : undefined) || config.initialData); 上面一段代码在 useSWR 的初始化期间,useHydration 表示是否为初次加载: let isHydration = true;export default function useHydration(): boolean { useEffect(() => { setTimeout(() => { isHydration = false; }, 1); }, []); return isHydration;} 3.4 支持 suspenseSuspense 分为两块功能:异步加载代码与异步加载数据,现在提到的是异步加载数据相关的能力。 Suspense 要求代码 suspended,即抛出一个可以被捕获的 Promise 异常,在这个 Promise 结束后再渲染组件。 核心代码就这一段,抛出取数的 Promise: throw CONCURRENT_PROMISES[key]; 等取数完毕后再返回 useSWR API 定义的结构: return { error: latestError, data: latestData, revalidate, isValidating}; 如果没有上面 throw 的一步,在取数完毕前组件就会被渲染出来,所以 throw 了请求的 Promise 使得这个请求函数支持了 Suspense。 3.5 依赖的请求翻了一下代码,没有找到对循环依赖特别处理的逻辑,后来看了官方文档才恍然大悟,原来是通过 try/catch 并巧妙结合 React 的 UI=f(data) 机制实现依赖取数的。 看下面这段代码: const { data: user } = useSWR("/api/user");const { data: projects } = useSWR(() => "/api/projects?uid=" + user.id); 怎么做到智能按依赖顺序请求呢?我们看 useSWR 取数函数的主体逻辑: const revalidate = useCallback( async() => { try { // 设置 isValidation 为 true // 取数、onSuccess 回调 // 设置 isValidation 为 false // 设置缓存 // unstable_batchedUpdates } catch (err) { // 撤销取数、缓存等对象 // 调用 onError回调 } }, [key])useIsomorphicLayoutEffect( ()=>{ .... }, [key,revalidate,...]) 每次渲染的时候,SWR 会试着执行 key 函数(例如 () => “/api/projects?uid=” + user.id),如果这个函数抛出异常,那么就意味着它的依赖还没有就绪(user === undefined),SWR 将暂停这个数据的请求。在任一数据完成加载时,由于 setState 触发重渲染,上述 Hooks 会被重选执行一遍(再次检查数据依赖是否就绪)然后对就绪的数据发起新的一轮请求。 另外对于一些正常请求碰到 error(shouldRetryOnError 默认为 true)的情况下,下次取数的时机是: const count = Math.min(opts.retryCount || 0, 8);const timeout = ~~((Math.random() + 0.5) * (1 << count)) * config.errorRetryInterval; 重试时间基本按 2 的指数速度增长。 所以 swr 会优先按照并行方式取数,存在依赖的取数会重试,直到上游 Ready。这种简单的模式稍稍损失了一些性能(没有在上游 Ready 后及时重试下游),但不失为一种巧妙的解法,而且最大化并行也使得大部分场景性能反而比手写的好。 4 总结笔者给仔细阅读本文的同学留下两道思考题: 关于 Hooks 取数还是在数据流中取数,你怎么看呢? swr 解决依赖取数的方法还有更好的改进办法吗? 讨论地址是:精读《Hooks 取数 - swr 源码》 · Issue ##216 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Immer","path":"/wiki/WebWeekly/源码解读/《Immer.html","content":"当前期刊数: 48 本周精读的仓库是 immer。 1 引言Immer 是最近火起来的一个项目,由 Mobx 作者 Mweststrate 研发。 了解 mobx 的同学可能会发现,Immer 就是更底层的 Mobx,它将 Mobx 特性发扬光大,得以结合到任何数据流框架,使用起来非常优雅。 2 概述麻烦的 ImmutableImmer 想解决的问题,是利用元编程简化 Immutable 使用的复杂度。举个例子,我们写一个纯函数: const addProducts = products => { const cloneProducts = products.slice() cloneProducts.push({ text: "shoes" }) return cloneProducts} 虽然代码并不复杂,但写起来内心仍隐隐作痛。我们必须将 products 拷贝一份,再调用 push 函数修改新的 cloneProducts,再返回它。 如果 js 原生支持 Immutable,就可以直接使用 push 了!对,Immer 让 js 现在就支持: const addProducts = produce(products => { products.push({ text: "shoes" })}) 很有趣吧,这两个 addProducts 函数功能一模一样,而且都是纯函数。 别扭的 setState我们都知道,react 框架中,setState 支持函数式写法: this.setState(state => ({ ...state, isShow: true})) 配合解构语法,写起来仍是如此优雅。那数据稍微复杂些呢?我们就要默默忍受 “糟糕的 Immutable” 了: this.setState(state => { const cloneProducts = state.products.slice() cloneProducts.push({ text: "shoes" }) return { ...state, cloneProducts }}) 然而有了 Immer,一切都不一样了: this.setState(produce(state => (state.isShow = true)))this.setState(produce(state => state.products.push({ text: "shoes" }))) 方便的柯里化上面讲述了 Immer 支持柯里化带来的好处。所以我们也可以直接把两个参数一次性消费: const oldObj = { value: 1 }const newObj = produce(oldObj, draft => (draft.value = 2)) 这就是 Immer:Create the next immutable state by mutating the current one. 3 精读虽然笔者之前在这方面已经有所研究,比如做出了 Mutable 转 Immutable 的库:dob-redux,但 Immer 实在是太惊艳了,Immer 是更底层的拼图,它可以插入到任何数据流框架作为功能增强,不得不赞叹 Mweststrate 真的是非常高瞻远瞩。 所以笔者认真阅读了它的源代码,带大家从原理角度认识 Immer。 Immer 是一个支持柯里化,仅支持同步计算的工具,所以非常适合作为 redux 的 reducer 使用。 Immer 也支持直接 return value,这个功能比较简单,所以本篇会跳过所有对 return value 的处理。PS: mutable 与 return 不能同时返回不同对象,否则弄不清楚到哪种修改是有效的。 柯里化这里不做拓展介绍,详情查看 curry。我们看 produce 函数 callback 部分: produce(obj, draft => { draft.count++}) obj 是个普通对象,那黑魔法一定出现在 draft 对象上,Immer 给 draft 对象的所有属性做了监听。 所以整体思路就有了:draft 是 obj 的代理,对 draft mutable 的修改都会流入到自定义 setter 函数,它并不修改原始对象的值,而是递归父级不断浅拷贝,最终返回新的顶层对象,作为 produce 函数的返回值。 生成代理第一步,也就是将 obj 转为 draft 这一步,为了提高 Immutable 运行效率,我们需要一些额外信息,因此将 obj 封装成一个包含额外信息的代理对象: { modified, // 是否被修改过 finalized, // 是否已经完成(所有 setter 执行完,并且已经生成了 copy) parent, // 父级对象 base, // 原始对象(也就是 obj) copy, // base(也就是 obj)的浅拷贝,使用 Object.assign(Object.create(null), obj) 实现 proxies, // 存储每个 propertyKey 的代理对象,采用懒初始化策略} 在这个代理对象上,绑定了自定义的 getter setter,然后直接将其扔给 produce 执行。 getterproduce 回调函数中包含了用户的 mutable 代码。所以现在入口变成了 getter 与 setter。 getter 主要用来懒初始化代理对象,也就是当代理对象子属性被访问的时候,才会生成其代理对象。 这么说比较抽象,举个例子,下面是原始 obj: { a: {}, b: {}, c: {}} 那么初始情况下,draft 是 obj 的代理,所以访问 draft.a draft.b draft.c 时,都能触发 getter setter,进入自定义处理逻辑。可是对 draft.a.x 就无法监听了,因为代理只能监听一层。 代理懒初始化就是要解决这个问题,当访问到 draft.a 时,自定义 getter 已经悄悄生成了新的针对 draft.a 对象的代理 draftA,因此 draft.a.x 相当于访问了 draftA.x,所以能递归监听一个对象的所有属性。 同时,如果代码中只访问了 draft.a,那么只会在内存生成 draftA 代理,b c 属性因为没有访问,因此不需要浪费资源生成代理 draftB draftC。 当然 Immer 做了一些性能优化,以及在对象被修改过(modified)获取其 copy 对象,为了保证 base 是不可变的,这里不做展开。 setter当对 draft 修改时,会对 base 也就是原始值进行浅拷贝,保存到 copy 属性,同时将 modified 属性设置为 true。这样就完成了最重要的 Immutable 过程,而且浅拷贝并不是很消耗性能,加上是按需浅拷贝,因此 Immer 的性能还可以。 同时为了保证整条链路的对象都是新对象,会根据 parent 属性递归父级,不断浅拷贝,直到这个叶子结点到根结点整条链路对象都换新为止。 完成了 modified 对象再有属性被修改时,会将这个新值保存在 copy 对象上。 生成 Immutable 对象当执行完 produce 后,用户的所有修改已经完成(所以 Immer 没有支持异步),如果 modified 属性为 false,说明用户根本没有改这个对象,那直接返回原始 base 属性即可。 如果 modified 属性为 true,说明对象发生了修改,返回 copy 属性即可。但是 setter 过程是递归的,draft 的子对象也是 draft(包含了 base copy modified 等额外属性的代理),我们必须一层层递归,拿到真正的值。 所以在这个阶段,所有 draft 的 finalized 都是 false,copy 内部可能还存在大量 draft 属性,因此递归 base 与 copy 的子属性,如果相同,就直接返回;如果不同,递归一次整个过程(从这小节第一行开始)。 最后返回的对象是由 base 的一些属性(没有修改的部分)和 copy 的一些属性(修改的部分)最终拼接而成的。最后使用 freeze 冻结 copy 属性,将 finalized 属性设置为 true。 至此,返回值生成完毕,我们将最终值保存在 copy 属性上,并将其冻结,返回了 Immutable 的值。 Immer 因此完成了不可思议的操作:Create the next immutable state by mutating the current one。 源码读到这里,发现 Immer 其实可以支持异步,只要支持 produce 函数返回 Promise 即可。最大的问题是,最后对代理的 revoke 清洗,需要借助全局变量,这一点阻碍了 Immer 对异步的支持。 4 总结读到这,如果觉得不过瘾,可以看看 redux-box 这个库,利用 immer + redux 解决了 reducer 冗余 return 的问题。 同样我们也开始思考并设计新的数据流框架,笔者在 2018.3.24 的携程技术沙龙将会分享 《mvvm 前端数据流框架精讲》,分享这几年涌现的各套数据流技术方案研究心得,感兴趣的同学欢迎报名参加。 5 更多讨论 讨论地址是:精读《Immer.js》源码》 · Issue ##68 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《React PowerPlug 源码》","path":"/wiki/WebWeekly/源码解读/《React PowerPlug 源码》.html","content":"当前期刊数: 92 1. 引言React PowerPlug 是利用 render props 进行更好状态管理的工具库。 React 项目中,一般一个文件就是一个类,状态最细粒度就是文件的粒度。然而文件粒度并非状态管理最合适的粒度,所以有了 Redux 之类的全局状态库。 同样,文件粒度也并非状态管理的最细粒度,更细的粒度或许更合适,因此有了 React PowerPlug。 比如你会在项目中看到这种眼花缭乱的 state: class App extends React.PureComponent { state = { name: 1, isLoading: false, isFetchUser: false, data: {}, disableInput: false, validate: false, monacoInputValue: "", value: "" }; render() { /**/ }} 其实真正 App 级别的状态并没有那么多,很多 诸如受控组件 onChange 临时保存的无意义 Value 找不到合适的地方存储。 这时候可以用 Value 管理局部状态: <Value initial="React"> {({ value, set, reset }) => ( <> <Select label="Choose one" options={["React", "Preact", "Vue"]} value={value} onChange={set} /> <Button onClick={reset}>Reset to initial</Button> </> )}</Value> 可以看到,这个问题本质上应该拆成新的 React 类解决,但这也许会导致项目结构更混乱,因此 RenderProps 还是必不可少的。 今天我们就来解读一下 React PowerPlug 的源码。 2. 精读2.1. Value这是一个值操作的工具,功能与 Hooks 中 useState 类似,不过多了一个 reset 功能(Hooks 其实也未尝不能有,但 Hooks 确实没有 Reset)。 用法<Value initial="React"> {({ value, set, reset }) => ( <> <Select label="Choose one" options={["React", "Preact", "Vue"]} value={value} onChange={set} /> <Button onClick={reset}>Reset to initial</Button> </> )}</Value> 源码 源码地址 原料:无 State 只存储一个属性 value,并赋初始值为 initial: export default { state = { value: this.props.initial };} 方法有 set reset。 set 回调函数触发后调用 setState 更新 value。 reset 就是调用 set 并传入 this.props.initial 即可。 2.2. ToggleToggle 是最直接利用 Value 即可实现的功能,因此放在 Value 之后说。Toggle 值是 boolean 类型,特别适合配合 Switch 等组件。 既然 Toggle 功能弱于 Value,为什么不用 Value 替代 Toggle 呢?这是个好问题,如果你不担心自己代码可读性的话,的确可以永远不用 Toggle。 用法<Toggle initial={false}> {({ on, toggle }) => <Checkbox onClick={toggle} checked={on} />}</Toggle> 源码 源码地址 原料:Value 核心就是利用 Value 组件,value 重命名为 on,增加了 toggle 方法,继承 set reset 方法: export default { toggle: () => set(on => !on);} 理所因当,将 value 值限定在 boolean 范围内。 2.3. Counter与 Toggle 类似,这也是继承了 Value 就可以实现的功能,计数器。 用法<Counter initial={0}> {({ count, inc, dec }) => ( <CartItem productName="Lorem ipsum" unitPrice={19.9} count={count} onAdd={inc} onRemove={dec} /> )}</Counter> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 count,增加了 inc dec incBy decBy 方法,继承 set reset 方法。 与 Toggle 类似,Counter 将 value 限定在了数字,那么比如 inc 就会这么实现: export default { inc: () => set(value => value + 1);} 这里用到了 Value 组件 set 函数的多态用法。一般 set 的参数是一个值,但也可以是一个函数,回调是当前的值,这里返回一个 +1 的新值。 2.4. List操作数组。 用法<List initial={['##react', '##babel']}> {({ list, pull, push }) => ( <div> <FormInput onSubmit={push} /> {list.map({ tag }) => ( <Tag onRemove={() => pull(value => value === tag)}> {tag} </Tag> )} </div> )}</List> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 list,增加了 first last push pull sort 方法,继承 set reset 方法。 export default { list: value, first: () => value[0], last: () => value[Math.max(0, value.length - 1)], set: list => set(list), push: (...values) => set(list => [...list, ...values]), pull: predicate => set(list => list.filter(complement(predicate))), sort: compareFn => set(list => [...list].sort(compareFn)), reset}; 为了利用 React Immutable 更新的特性,因此将 sort 函数由 Mutable 修正为 Immutable,push pull 同理。 2.5. Set存储数组对象,可以添加和删除元素。类似 ES6 Set。和 List 相比少了许多功能函数,因此只承担添加、删除元素的简单功能。 用法需要注意的是,initial 是数组,而不是 Set 对象。 <Set initial={["react", "babel"]}> {({ values, remove, add }) => ( <TagManager> <FormInput onSubmit={add} /> {values.map(tag => ( <Tag onRemove={() => remove(tag)}>{tag}</Tag> ))} </TagManager> )}</Set> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 values 且初始值为 [],增加了 add remove clear has 方法,保留 reset 方法。 实现依然很简单,add remove clear 都利用 Value 提供的 set 进行赋值,只要实现几个操作数组方法即可: const unique = arr => arr.filter((d, i) => arr.indexOf(d) === i);const hasItem = (arr, item) => arr.indexOf(item) !== -1;const removeItem = (arr, item) => hasItem(arr, item) ? arr.filter(d => d !== item) : arr;const addUnique = (arr, item) => (hasItem(arr, item) ? arr : [...arr, item]); has 方法则直接复用 hasItem。核心还是利用 Value 的 set 函数一招通吃,将操作目标锁定为数组类型罢了。 2.6. mapMap 的实现与 Set 很像,类似 ES6 的 Map。 用法与 Set 不同,Map 允许设置 Key 名。需要注意的是,initial 是对象,而不是 Map 对象。 <Map initial={{ sounds: true, music: true, graphics: "medium" }}> {({ set, get }) => ( <Tings> <ToggleCheck checked={get("sounds")} onChange={c => set("sounds", c)}> Game Sounds </ToggleCheck> <ToggleCheck checked={get("music")} onChange={c => set("music", c)}> Bg Music </ToggleCheck> <Select label="Graphics" options={["low", "medium", "high"]} selected={get("graphics")} onSelect={value => set("graphics", value)} /> </Tings> )}</Map> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 values 且初始值为 {},增加了 set get clear has delete 方法,保留 reset 方法。 由于使用对象存储数据结构,操作起来比数组方便太多,已经不需要再解释了。 值得吐槽的是,作者使用了 != 判断 has: export default { has: key => values[key] != null;} 这种代码并不值得提倡,首先是不应该使用二元运算符,其次比较推荐写成 values[key] !== undefined,毕竟 set('null', null) 也应该算有值。 2.7. stateState 纯粹为了替代 React setState 概念,其本质就是换了名字的 Value 组件。 用法值得注意的是,setState 支持函数和值作为参数,是 Value 组件本身支持的,State 组件额外适配了 setState 的另一个特性:合并对象。 <State initial={{ loading: false, data: null }}> {({ state, setState }) => { const onStart = data => setState({ loading: true }); const onFinish = data => setState({ data, loading: false }); return ( <DataReceiver data={state.data} onStart={onStart} onFinish={onFinish} /> ); }}</State> 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 state 且初始值为 {},增加了 setState 方法,保留 reset 方法。 setState 实现了合并对象的功能,也就是传入一个对象,并不会覆盖原始值,而是与原始值做 Merge: export default { setState: (updater, cb) => set( prev => ({ ...prev, ...(typeof updater === "function" ? updater(prev) : updater) }), cb );} 2.8. Active这是一个内置鼠标交互监听的容器,监听了 onMouseUp 与 onMouseDown,并依此判断 active 状态。 用法<Active> {({ active, bind }) => ( <div {...bind}> You are {active ? "clicking" : "not clicking"} this div. </div> )}</Active> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 active 且初始值为 false,增加了 bind 方法。 bind 方法也巧妙利用了 Value 提供的 set 更新状态: export default { bind: { onMouseDown: () => set(true), onMouseUp: () => set(false) }}; 2.9. Focus与 Active 类似,Focus 是当 focus 时才触发状态变化。 用法<Focus> {({ focused, bind }) => ( <div> <input {...bind} placeholder="Focus me" /> <div>You are {focused ? "focusing" : "not focusing"} the input.</div> </div> )}</Focus> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 focused 且初始值为 false,增加了 bind 方法。 bind 方法与 Active 如出一辙,仅是监听时机变成了 onFocus 和 onBlur。 2.10. FocusManager不知道出于什么考虑,FocusManager 的官方文档是空的,而且 Help wanted。。 正如名字描述的,这是一个 Focus 控制器,你可以直接调用 blur 来取消焦点。 用法笔者给了一个例子,在 5 秒后自动失去焦点: <FocusFocusManager> {({ focused, blur, bind }) => ( <div> <input {...bind} placeholder="Focus me" onClick={() => { setTimeout(() => { blur(); }, 5000); }} /> <div>You are {focused ? "focusing" : "not focusing"} the input.</div> </div> )}</FocusFocusManager> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 focused 且初始值为 false,增加了 bind blur 方法。 blur 方法直接调用 document.activeElement.blur() 来触发其 bind 监听的 onBlur 达到更新状态的效果。 By the way, 还监听了 onMouseDown 与 onMouseUp: export default { bind: { tabIndex: -1, onBlur: () => { if (canBlur) { set(false); } }, onFocus: () => set(true), onMouseDown: () => (canBlur = false), onMouseUp: () => (canBlur = true) }}; 可能意图是防止在 mouseDown 时触发 blur,因为 focus 的时机一般是 mouseDown。 2.11. Hover与 Focus 类似,只是触发时机为 Hover。 用法<Hover> {({ hovered, bind }) => ( <div {...bind}> You are {hovered ? "hovering" : "not hovering"} this div. </div> )}</Hover> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 hovered 且初始值为 false,增加了 bind 方法。 bind 方法与 Active、Focus 如出一辙,仅是监听时机变成了 onMouseEnter 和 onMouseLeave。 2.12. Touch与 Hover 类似,只是触发时机为 Hover。 用法<Touch> {({ touched, bind }) => ( <div {...bind}> You are {touched ? "touching" : "not touching"} this div. </div> )}</Touch> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 touched 且初始值为 false,增加了 bind 方法。 bind 方法与 Active、Focus、Hover 如出一辙,仅是监听时机变成了 onTouchStart 和 onTouchEnd。 2.13. Field与 Value 组件唯一的区别,就是支持了 bind。 用法这个用法和 Value 没区别: <Field> {({ value, set }) => ( <ControlledField value={value} onChange={e => set(e.target.value)} /> )}</Field> 但是用 bind 更简单: <Field initial="hello world"> {({ bind }) => <ControlledField {...bind} />}</Field> 源码 源码地址 原料:Value 依然利用 Value 组件,value 保留不变,初始值为 '',增加了 bind 方法,保留 set reset 方法。 与 Value 的唯一区别是,支持了 bind 并封装 onChange 监听,与赋值受控属性 value。 export default { bind: { value, onChange: event => { if (isObject(event) && isObject(event.target)) { set(event.target.value); } else { set(event); } } }}; 2.14. Form这是一个表单工具,有点类似 Antd 的 Form 组件。 用法<Form initial={{ firstName: "", lastName: "" }}> {({ field, values }) => ( <form onSubmit={e => { e.preventDefault(); console.log("Form Submission Data:", values); }} > <input type="text" placeholder="Your First Name" {...field("firstName").bind} /> <input type="text" placeholder="Your Last Name" {...field("lastName").bind} /> <input type="submit" value="All Done!" /> </form> )}</Form> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 values 且初始值为 {},增加了 setValues field 方法,保留 reset 方法。 表单最重要的就是 field 函数,为表单的每一个控件做绑定,同时设置一个表单唯一 key: export default { field: id => { const value = values[id]; const setValue = updater => typeof updater === "function" ? set(prev => ({ ...prev, [id]: updater(prev[id]) })) : set({ ...values, [id]: updater }); return { value, set: setValue, bind: { value, onChange: event => { if (isObject(event) && isObject(event.target)) { setValue(event.target.value); } else { setValue(event); } } } }; }}; 可以看到,为表单的每一项绑定的内容与 Field 组件一样,只是 Form 组件的行为是批量的。 2.15. IntervalInterval 比较有意思,将定时器以 JSX 方式提供出来,并且提供了 stop resume 方法。 用法<Interval delay={1000}> {({ start, stop }) => ( <> <div>The time is now {new Date().toLocaleTimeString()}</div> <button onClick={() => stop()}>Stop interval</button> <button onClick={() => start()}>Start interval</button> </> )}</Interval> 源码 源码地址 原料:无 提供了 start stop toggle 方法。 实现方式是,在组件内部维护一个 Interval 定时器,实现了组件更新、销毁时的计时器更新、销毁操作,可以认为这种定时器的生命周期绑定了 React 组件的生命周期,不用担心销毁和更新的问题。 具体逻辑就不列举了,利用 setInterval clearInterval 函数基本上就可以了。 2.16. ComposeCompose 也是个有趣的组件,可以将上面提到的任意多个组件组合使用。 用法<Compose components={[Counter, Toggle]}> {(counter, toggle) => ( <ProductCard {...productInfo} favorite={toggle.on} onFavorite={toggle.toggle} count={counter.count} onAdd={counter.inc} onRemove={counter.dec} /> )}</Compose> 源码 源码地址 原料:无 通过递归渲染出嵌套结构,并将每一层结构输出的值存储到 propsList 中,最后一起传递给组件。这也是为什么每个函数 value 一般都要重命名的原因。 在 精读《Epitath 源码 - renderProps 新用法》 文章中,笔者就介绍了利用 generator 解决高阶组件嵌套的问题。 在 精读《React Hooks》 文章中,介绍了 React Hooks 已经实现了这个特性。 所以当你了解了这三种 “compose” 方法后,就可以在合适的场景使用合适的 compose 方式简化代码。 3. 总结看完了源码分析,不知道你是更感兴趣使用这个库呢,还是已经跃跃欲试开始造轮子了呢?不论如何,这个库的思想在日常的业务开发中都应该大量实践。 另外 Hooks 版的 PowerPlug 已经 4 个月没有更新了(非官方):react-powerhooks,也许下一个维护者/贡献者 就是你。 讨论地址是:精读《React PowerPlug 源码》 · Issue ##129 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《react-easy-state 源码》","path":"/wiki/WebWeekly/源码解读/《react-easy-state 源码》.html","content":"当前期刊数: 98 1. 引言react-easy-state 是个比较有趣的库,利用 Proxy 创建了一个非常易用的全局数据流管理方式。 import React from "react";import { store, view } from "react-easy-state";const counter = store({ num: 0 });const increment = () => counter.num++;export default view(() => <button onClick={increment}>{counter.num}</button>); 上手非常轻松,通过 store 创建一个数据对象,这个对象被任何 React 组件使用时,都会自动建立双向绑定,任何对这个对象的修改,都会让使用了这个对象的组件重渲染。 当然,为了实现这一点,需要对所有组件包裹一层 view。 2. 精读这个库利用了 nx-js/observer-util 做 Reaction 基础 API,其他核心功能分别是 store view batch,所以我们就从这四个点进行解读。 Reaction这个单词名叫 “反应”,是实现双向绑定库的最基本功能单元。 拥有最基本的两个单词和一个概念:observable observe 与自动触发执行的特性。 import { observable, observe } from "@nx-js/observer-util";const counter = observable({ num: 0 });const countLogger = observe(() => console.log(counter.num));// 会自动触发 countLogger 函数内回调函数的执行。counter.num++; 在第 35 期精读 精读《dob - 框架实现》 “抽丝剥茧,实现依赖追踪” 一节中有详细介绍实现原理,这里就不赘述了。 有了一个具有反应特性的函数,与一个可以 “触发反应” 的对象,那么实现双向绑定更新 View 就不远了。 storereact-easy-state 的 store 就是 observable(obj) 包装一下,唯一不同是,由于支持本地数据: import React from 'react'import { view, store } from 'react-easy-state'export default view(() => { const counter = store({ num: 0 }) const increment = () => counter.num++ return <button={increment}>{counter.num}</div>}) 所以当监测到在 React 组件内部创建 store 且是 Hooks 环境时,会返回: return useMemo(() => observable(obj), []); 这是因为 React Hooks 场景下的 Function Component 每次渲染都会重新创建 Store,会导致死循环。因此利用 useMemo 并将依赖置为 [] 使代码在所有渲染周期内,只在初始化执行一次。 更多 Hooks 深入解读,可以阅读 精读《useEffect 完全指南》。 view根据 Function Component 与 Class Component 的不同,分别进行两种处理,本文主要介绍对 Function Component 的处理方式,因为笔者推荐使用 Function Component 风格。 首先最外层会套上 memo,这类似 PureComponent 的效果: return memo(/**/); 然后构造一个 forceUpdate 用来强制渲染组件: const [, forceUpdate] = useState(); 之后,只要利用 observe 包裹组件即可,需要注意两点: 使用刚才创建的 forceUpdate 在 store 修改时调用。 observe 初始化不要执行,因为初始化组件自己会渲染一次,再渲染一次就会造成浪费。 所以作者通过 scheduler lazy 两个参数完成了这两件事: const render = useMemo( () => observe(Comp, { scheduler: () => setState({}), lazy: true }), []);return render; 最后别忘了在组件销毁时取消监听: useEffect(() => { return () => unobserve(render);}, []); batch这也是双向绑定数据流必须解决的经典问题,批量更新合并。 由于修改对象就触发渲染,这个过程太自动化了,以至于我们都没有机会告诉工具,连续的几次修改能否合并起来只触发一次渲染。 尤其是 For 循环修改变量时,如果不能合并更新,在某些场景下代码几乎是不可用的。 所以 batch 就是为解决这个问题诞生的,让我们有机会控制合并更新的时机: import React from "react";import { view, store, batch } from "react-easy-state";const user = store({ name: "Bob", age: 30 });function mutateUser() { // this makes sure the state changes will cause maximum one re-render, // no matter where this function is getting invoked from batch(() => { user.name = "Ann"; user.age = 32; });}export default view(() => ( <div> name: {user.name}, age: {user.age} </div>)); react-easy-state 通过 scheduler 模块完成 batch 功能,核心代码只有五行: export function batch(fn, ctx, args) { let result; unstable_batchedUpdates(() => (result = fn.apply(ctx, args))); return result;} 利用 unstable_batchedUpdates,可以保证在其内执行的函数都不会触发更新,也就是之前创建的 forceUpdate 虽然被调用,但是失效了,等回调执行完毕时再一起批量更新。 同时代码里还对 setTimeout setInterval addEventListener WebSocket 等公共方法进行了 batch 包装,让这些回调函数中自带 batch 效果。 4. 总结好了,react-easy-state 神奇的效果解释完了,希望大家在使用第三方库的时候都能理解背后的原理。 PS:最后,笔者目前不推荐在 Function Component 模式下使用任何三方数据流库,因为官方功能已经足够好用了! 讨论地址是:精读《react-easy-state》 · Issue ##144 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《react-intersection-observer 源码》","path":"/wiki/WebWeekly/源码解读/《react-intersection-observer 源码》.html","content":"当前期刊数: 156 1 引言IntersectionObserver 可以轻松判断元素是否可见,在之前的 精读《用 React 做按需渲染》 中介绍了原生 API 的方法,这次刚好看到其 React 封装版本 react-intersection-observer,让我们看一看 React 封装思路。 2 简介react-intersection-observer 提供了 Hook useInView 判断元素是否在可视区域内,API 如下: 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 值: 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 更新旧节点: // 中间部分 1const 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 才合理: // 中间部分 2useEffect(() => { 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 文件中,这个文件有三个核心函数: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 函数使用: 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. Make sure the `ref` is only used by a single <Observer /> instance. %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 的第二个参数回调: 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 存储。 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,如果出现传递断裂,就会判定当前组件不在视图内,比如: const Component = () => { const [ref, inView] = useInView(); return <Child ref={ref} />;};const Child = ({ loading, ref }) => { if (loading) { // 这一步会判定为 inView:false return <Spin />; } return <div ref={ref}>Child</div>;}; 如果你的代码基于 inView 做了阻止渲染的判定,那么这个组件进入 loading 后就无法改变状态了。为了避免这种情况,要么不要让 ref 的传递断掉,要么当没有拿到 ref 对象时判定 inView 为 true。 4 总结分析了这么多 React- 类的库,其核心思想有两个: 将原生 API 转换为框架特有 API,比如 React 系列的 Hooks 与 ref。 处理生命周期导致的边界情况,比如 dom 被更新时先 unobserve 再重新 observe。 看过 react-intersection-observer 的源码后,你觉得还有可优化的地方吗?欢迎讨论。 讨论地址是:react-intersection-observer 源码》· Issue ##257 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《react-snippets - Router 源码》","path":"/wiki/WebWeekly/源码解读/《react-snippets - Router 源码》.html","content":"当前期刊数: 241 造轮子就是应用核心原理 + 周边功能的堆砌,所以学习成熟库的源码往往会受到非核心代码干扰,Router 这个 repo 用不到 100 行源码实现了 React Router 核心机制,很适合用来学习。 精读Router 快速实现了 React Router 3 个核心 API:Router、navigate、Link,下面列出基本用法,配合理解源码实现会更方便: const App = () => ( <Router routes={[ { path: '/home', component: <Home /> }, { path: '/articles', component: <Articles /> } ]} />)const Home = () => ( <div> home, <Link href="/articles">go articles</Link>, <span onClick={() => navigate('/details')}>or jump to details</span> </div>) 首先看 Router 的实现,在看代码之前,思考下 Router 要做哪些事情? 接收 routes 参数,根据当前 url 地址判断渲染哪个组件。 当 url 地址变化时(无论是用户触发还是自己的 navigate Link 触发),渲染新 url 对应的组件。 所以 Router 是一个路由渲染分配器与 url 监听器: export default function Router ({ routes }) { // 存储当前 url path,方便其变化时引发自身重渲染,以返回新的 url 对应的组件 const [currentPath, setCurrentPath] = useState(window.location.pathname); useEffect(() => { const onLocationChange = () => { // 将 url path 更新到当前数据流中,触发自身重渲染 setCurrentPath(window.location.pathname); } // 监听 popstate 事件,该事件由用户点击浏览器前进/后退时触发 window.addEventListener('popstate', onLocationChange); return () => window.removeEventListener('popstate', onLocationChange) }, []) // 找到匹配当前 url 路径的组件并渲染 return routes.find(({ path, component }) => path === currentPath)?.component} 最后一段代码看似每次都执行 find 有一定性能损耗,但其实根据 Router 一般在最根节点的特性,该函数很少因父组件重渲染而触发渲染,所以性能不用太担心。 但如果考虑做一个完整的 React Router 组件库,考虑了更复杂的嵌套 API,即 Router 套 Router 后,不仅监听方式要变化,还需要将命中的组件缓存下来,需要考虑的点会逐渐变多。 下面该实现 navigate Link 了,他俩做的事情都是跳转,有如下区别: API 调用方式不同,navigate 是调用式函数,而 Link 是一个内置 navigate 能力的 a 标签。 Link 其实还有一种按住 ctrl 后打开新 tab 的跳转模式,该模式由浏览器对 a 标签默认行为完成。 所以 Link 更复杂一些,我们先实现 navigate,再实现 Link 时就可以复用它了。 既然 Router 已经监听 popstate 事件,我们显然想到的是触发 url 变化后,让 popstate 捕获,自动触发后续跳转逻辑。但可惜的是,我们要做的 React Router 需要实现单页跳转逻辑,而单页跳转的 API history.pushState 并不会触发 popstate,为了让实现更优雅,我们可以在 pushState 后手动触发 popstate 事件,如源码所示: export function navigate (href) { // 用 pushState 直接刷新 url,而不触发真正的浏览器跳转 window.history.pushState({}, "", href); // 手动触发一次 popstate,让 Route 组件监听并触发 onLocationChange const navEvent = new PopStateEvent('popstate'); window.dispatchEvent(navEvent);} 接下来实现 Link 就很简单了,有几个考虑点: 返回一个正常的 <a> 标签。 因为正常 <a> 点击后就发生网页刷新而不是单页跳转,所以点击时要阻止默认行为,换成我们的 navigate(源码里没做这个抽象,笔者稍微优化了下)。 但按住 ctrl 时又要打开新 tab,此时用默认 <a> 标签行为就行,所以此时不要阻止默认行为,也不要继续执行 navigate,因为这个 url 变化不会作用于当前 tab。 export function Link ({ className, href, children }) { const onClick = (event) => { // mac 的 meta or windows 的 ctrl 都会打开新 tab // 所以此时不做定制处理,直接 return 用原生行为即可 if (event.metaKey || event.ctrlKey) { return; } // 否则禁用原生跳转 event.preventDefault(); // 做一次单页跳转 navigate(href) }; return ( <a className={className} href={href} onClick={onClick}> {children} </a> );}; 这样的设计,既能兼顾 <a> 标签默认行为,又能在点击时优化为单页跳转,里面对 preventDefault 与 metaKey 的判断值得学习。 总结从这个小轮子中可以学习到一下几个经验: 造轮子之前先想好使用 API,根据使用 API 反推实现,会让你的设计更有全局观。 实现 API 时,先思考 API 之间的关系,能复用的就提前设计好复用关系,这样巧妙的关联设计能为以后维护减少很多麻烦。 即便代码无法复用的地方,也要尽量做到逻辑复用。比如 pushState 无法触发 popstate 那段,直接把 popstate 代码复用过来,或者自己造一个状态沟通就太 low 了,用浏览器 API 模拟事件触发,既轻量,又符合逻辑,因为你要做的就是触发 popstate 行为,而非只是更新渲染组件这个动作,万一以后再有监听 popstate 的地方,你的触发逻辑就能很自然的应用到那儿。 尽量在原生能力上拓展,而不是用自定义方法补齐原生能力。比如 Link 的实现是基于 <a> 标签拓展的,如果采用自定义 <span> 标签,不仅要补齐样式上的差异,还要自己实现 ctrl 后打开新 tab 的行为,甚至 <a> 默认访问记录行为你也得花高成本补上,所以错误的设计方向会导致事倍功半,甚至无法实现。 讨论地址是:精读《react-snippets - Router 源码》· Issue ##418 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Inject Instance 源码》","path":"/wiki/WebWeekly/源码解读/《Inject Instance 源码》.html","content":"当前期刊数: 110 1. 引言本周精读的源码是 inject-instance 这个库。 这个库的目的是为了实现 Class 的依赖注入。 比如我们通过 inject 描述一个成员变量,那么在运行时,这个成员变量的值就会被替换成对应 Class 的实例。这等于让 Class 具备了申明依赖注入的能力: import {inject} from 'inject-instance'import B from './B'class A { @inject('B') private b: B public name = 'aaa' say() { console.log('A inject B instance', this.b.name) }} 试想一下,如果成员函数 b 是通过 New 出来的: class A { private b = new B() say() { console.log('A inject B instance', this.b.name) }} 这个 b 就不具备依赖注入的特点,因为被注入的 b 是外部已经初始化好的,而不是实例化 A 时动态生成的。 需要依赖注入的一般都是框架级代码,比如定义数据流,存在三个 Store 类,他们之间需要相互调用对方实例: class A { @inject('B') private b: B}class B { @inject('C') private c: C}class C { @inject('A') private a: A} 那么对于引用了数据流 A、B、C 的三个组件,要保证它们访问到的是同一组实例 A B C 该怎么办呢? 这时候我们需要通过 injectInstance 函数统一实例化这些类,保证拿到的实例中,成员变量都是属于同一份实例: import injectInstance from 'inject-instance'const instances = injectInstance(A, B, C)instances.get('A')instances.get('B')instances.get('C') 那么框架底层可以通过调用 injectInstance 方式初始化一组 “正确注入依赖关系的实例”,拿 React 举例,这个动作可以发生在自定义数据流的 Provider 函数里: <Provider stores={{ A, B, C }}> <Root /></Provider> 那么在 Provider 函数内部通过 injectInstance 实例化的数据流,可以保证 A B C 操作的注入实例都是当前 Provider 实例中的那一份。 2. 精读那么开始源码的解析,首先是整体思路的分析。 我们需要准备两个 API: inject 与 injectInstance。 inject 用来描述要注入的类名,值是与 Class 名相同的字符串,injectInstance 是生成一系列实例的入口函数,需要生成最终生效的实例,并放在一个 Map 中。 injectinject 是个装饰器,它的目的有两个: 修改 Class 基类信息,使其实例化的实例能拿到对应字段注入的 Class 名称。 增加一个字段描述注入了那些 Key。 const inject = (injectName: string): any => (target: any, propertyKey: string, descriptor: PropertyDescriptor): any => { target[propertyKey] = injectName // 加入一个标注变量 if (!target['_injectDecorator__injectVariables']) { target['_injectDecorator__injectVariables'] = [propertyKey] } else { target['_injectDecorator__injectVariables'].push(propertyKey) } return descriptor} target[propertyKey] = injectName 这行代码中,propertyKey 是申明了注入的成员变量名称,比如 Class A 中,propertyKey 等于 b,而 injectName 表示这个值需要的对应实例的 Class 名,比如 Class A 中,injectName 等于 B。 而 _injectDecorator__injectVariables 是个数组,为 Class 描述了这个类参与注入的 key 共有哪些,这样可以在后面 injectInstance 函数中拿到并依次赋值。 injectInstance这个函数有两个目的: 生成对应的实例。 将实例中注入部分的成员变量替换成对应实例。 代码不长,直接贴出来: const injectInstance = (...classes: Array<any>) => { const classMap = new Map<string, any>() const instanceMap = new Map<string, any>() classes.forEach(eachClass => { if (classMap.has(eachClass.name)) { throw `duplicate className: ${eachClass.name}` } classMap.set(eachClass.name, eachClass) }) // 遍历所有用到的类 classMap.forEach((eachClass: any) => { // 实例化 instanceMap.set(eachClass.name, new eachClass()) }) // 遍历所有实例 instanceMap.forEach((eachInstance: any, key: string) => { // 遍历这个类的注入实例类名 if (eachInstance['_injectDecorator__injectVariables']) { eachInstance['_injectDecorator__injectVariables'].forEach((injectVariableKey: string) => { const className = eachInstance.__proto__[injectVariableKey]; if (!instanceMap.get(className)) { throw Error(`injectName: ${className} not found!`); } // 把注入名改成实际注入对象 eachInstance[injectVariableKey] = instanceMap.get(className); }); } // 删除这个临时变量 delete eachInstance['_injectDecorator__injectVariables']; }); return instanceMap} 可以看到,首先我们将传入的 Class 依次初始化: // 遍历所有用到的类classMap.forEach((eachClass: any) => { // 实例化 instanceMap.set(eachClass.name, new eachClass())}) 这是必须提前完成的,因为注入可能存在循环依赖,我们必须在解析注入之前就生成 Class 实例,此时需要注入的字段都是 undefined。 第二步就是将这些注入字段的 undefined 替换为刚才实例化 Map instanceMap 中对应的实例了。 我们通过 __proto__ 拿到 Class 基类在 inject 函数中埋下的 injectName,配合 _injectDecorator__injectVariables 拿到 key 后,直接遍历所有要替换的 key, 通过类名从 instanceMap 中提取即可。 __proto__ 仅限框架代码中使用,业务代码不要这么用,造成额外理解成本。 所以总结一下,就是提前实例化 + 根据 inject 埋好的信息依次替换注入的成员变量为刚才实例化好的实例。 3. 总结希望读完这篇文章,你能理解依赖注入的使用场景,使用方式,以及一种实现思路。 框架实现依赖注入都是提前收集所有类,统一初始化,通过注入函数打标后全局替换,这是一种思维套路。 如果有其他更有意思的依赖注入实现方案,欢迎讨论。 讨论地址是:精读《Inject Instance 源码》 · Issue ##176 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《robot 源码 - 有限状态机》","path":"/wiki/WebWeekly/源码解读/《robot 源码 - 有限状态机》.html","content":"当前期刊数: 122 1 概述本期精读的是有限状态机管理工具 robot 源码。 有限状态机是指有限个数的状态之间相互切换的数学模型,在业务与游戏开发中有限状态都很常见,包括发请求也是一种有限状态机的模型。 笔者将在简介中介绍这个库的使用方式,在精读中介绍实现原理,最后总结在业务中使用的价值。 2 简介这个库的核心就是利用 createMachine 创建一个有限状态机: import { createMachine, state, transition } from 'robot3';const machine = createMachine({ inactive: state( transition('toggle', 'active') ), active: state( transition('toggle', 'inactive') )});export default machine; 如上图所示,我们创建了一个有限状态机 machine,包含了两种状态:inactive 与 active,并且可以通过 toggle 动作在两种状态间做切换。 与 React 结合则有 react-robot: import { useMachine } from 'react-robot';import React from 'react';import machine from './machine' function App() { const [current, send] = useMachine(machine); return ( <button type="button" onClick={() => send('toggle')}> State: {current.name} </button> )} 通过 useMachine 拿到的 current.name 表示当前状态值,send 用来发送改变状态的指令。 至于为什么要用有限状态机管理工具,官方文档举了个例子 - 点击编辑后进入编辑态,点击保存后返回原始状态的例子: 点击 Edit 按钮后,将进入下图的状态,点击 Save 后如果输入的内容校验通过保存后再回到初始状态: 如果不用有限状态机,我们首先会创建两个变量存储是否处于编辑态,以及当前输入文本是什么: let editMode = false;let title = ''; 如果再考虑和后端的交互,就会增加三个状态 - 保存中、校验、保存是否成功: let editMode = false;let title = '';let saving = false;let validating = false;let saveHadError = false; 就算使用 React、Vue 等框架数据驱动 UI,我们还是免不了对复杂状态进行管理。如果使用有限状态机实现,将是这样的: import { createMachine, guard, immediate, invoke, state, transition, reduce } from 'robot3';const machine = createMachine({ preview: state( transition('edit', 'editMode', // Save the current title as oldTitle so we can reset later. reduce(ctx => ({ ...ctx, oldTitle: ctx.title })) ) ), editMode: state( transition('input', 'editMode', reduce((ctx, ev) => ({ ...ctx, title: ev.target.value })) ), transition('cancel', 'cancel'), transition('save', 'validate') ), cancel: state( immediate('preview', // Reset the title back to oldTitle reduce(ctx => ({ ...ctx, title: ctx.oldTitle }) ) ), validate: state( // Check if the title is valid. If so go // to the save state, otherwise go back to editMode immediate('save', guard(titleIsValid)), immediate('editMode') ) save: invoke(saveTitle, transition('done', 'preview'), transition('error', 'error') ), error: state( // Should we provide a retry or...? )}); 其中 immediate 表示直接跳到下一个状态,reduce 则可以对状态机内部数据进行拓展。比如 preview 返回了 oldTitle,那么 cancle 时就可以通过 ctx.oldTitle 拿到;invoke 表示调用第一个函数后,再执行 state。 通过上面的代码我们可以看到使用状态机的好处: 状态清晰,先罗列出某个业务逻辑的全部状态,避免遗漏。 状态转换安全。比如 preview 只能切换到 edit 状态,这样就算在错误的状态发错指令也不会产生异常情况。 3 精读robot 重要的函数有 createMachine, state, transition, immediate,下面一一拆解说明。 createMachinecreateMachine 表示创建状态机: export function createMachine(current, states, contextFn = empty) { if(typeof current !== 'string') { contextFn = states || empty; states = current; current = Object.keys(states)[0]; } if(d._create) d._create(current, states); return create(machine, { context: valueEnumerable(contextFn), current: valueEnumerable(current), states: valueEnumerable(states) });} 可以看到,如果传递了一个对象,通过 Object.keys(states)[0] 拿到第一个状态作为当前状态(标记在 current),最终将保存三个属性: context 当前状态机内部属性,初始化是空的。 current 当前状态。 states 所有状态,也就是 createMachine 传递的第一个参数。 再看 create 函数: let create = (a, b) => Object.freeze(Object.create(a, b)); 也就是创建了一个不修改的对象作为状态机。 这个是 machine 对象: let machine = { get state() { return { name: this.current, value: this.states[this.current] }; }}; 也就是说,状态机内部的状态管理是通过对象完成的,并提供了 state() 函数拿到当前的状态名和状态值。 statestate 用来描述状态支持哪些转换: export function state(...args) { let transitions = filter(transitionType, args); let immediates = filter(immediateType, args); let desc = { final: valueEnumerable(args.length === 0), transitions: valueEnumerable(transitionsToMap(transitions)) }; if(immediates.length) { desc.immediates = valueEnumerable(immediates); desc.enter = valueEnumerable(enterImmediate); } return create(stateType, desc);} transitions 与 immediates 表示从 args 里拿到 transition 或 immediate 的结果。 方法是通过如下方式定义 transition 与 immediate: export let transition = makeTransition.bind(transitionType);export let immediate = makeTransition.bind(immediateType, null);function filter(Type, arr) { return arr.filter(value => Type.isPrototypeOf(value));} 那么如果一个函数是通过 immediate 创建的,就可以通过 immediateType.isPrototypeOf() 的校验,此方法适用范围很广,在任何库里都可以用来校验拿到对应函数创建的对象。 如果参数数量为 0,表示这个状态是最终态,无法进行转换。最后通过 create 创建一个对象,这个对象就是状态的值。 transitiontransition 是写在 state 中描述当前状态可以如何变换的函数,其实际函数是 makeTransistion: function makeTransition(from, to, ...args) { let guards = stack(filter(guardType, args).map(t => t.fn), truthy, callBoth); let reducers = stack(filter(reduceType, args).map(t => t.fn), identity, callForward); return create(this, { from: valueEnumerable(from), to: valueEnumerable(to), guards: valueEnumerable(guards), reducers: valueEnumerable(reducers) });} 由于: export let transition = makeTransition.bind(transitionType);export let immediate = makeTransition.bind(immediateType, null); 可见 from 为 null 即表示立即转换到状态 to。transition 最终返回一个对象,其中 guards 是从 transition 或 immediate 参数中找到的,由 guards 函数创建的对象,当这个对象回调函数执行成功时此状态才生效。 ...args 对应 transition('toggle', 'active') 或 immediate('save', guard(titleIsValid)),而 stack(filter(guardType, args).map(t => t.fn), truthy, callBoth) 这句话就是从 ...args 中寻找是否有 guards,reducers 同理。 最后看看状态是如何改变的,设置状态改变的函数是 transitionTo: function transitionTo(service, fromEvent, candidates) { let { machine, context } = service; for(let { to, guards, reducers } of candidates) { if(guards(context)) { service.context = reducers.call(service, context, fromEvent); let original = machine.original || machine; let newMachine = create(original, { current: valueEnumerable(to), original: { value: original } }); let state = newMachine.state.value; return state.enter(newMachine, service, fromEvent); } }} 可以看到,如果存在 guards,则需要在 guards 执行返回成功时才可以正确改变状态。同时 reducers 可以修改 context 也在 service.context = reducers.call(service, context, fromEvent); 这一行体现了出来。最后通过生成一个新的状态机,并将 current 标记为 to。 最后我们看 state.enter 这个函数,这个函数在 state 函数中有定义,其本质是继承了 stateType: let stateType = { enter: identity }; 而 identity 这个函数就是立即执行函数: let identity = a => a; 因此相当于返回了新的状态机。 4 总结有限状态机相比普通业务描述,其实是增加了一些状态间转化的约束来达到优化状态管理的目的,并且状态描述也会更规范一些,在业务中具有一定的实用性。 当然并不是所有业务都适用有限状态机,因为新框架还是有一些学习成本要考虑。最后通过源码的学习,我们又了解到一些新的框架级小技巧,可以灵活应用到自己的框架中。 讨论地址是:精读《robot 源码 - 有限状态机》 · Issue ##209 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《sqorn 源码》","path":"/wiki/WebWeekly/源码解读/《sqorn 源码》.html","content":"当前期刊数: 73 1 引言前端精读《手写 SQL 编译器系列》 介绍了如何利用 SQL 生成语法树,而还有一些库的作用是根据语法树生成 SQL 语句。 除此之外,还有一种库,是根据编程语言生成 SQL。sqorn 就是一个这样的库。 可能有人会问,利用编程语言生成 SQL 有什么意义?既没有语法树规范,也不如直接写 SQL 通用。对,有利就有弊,这些库不遵循语法树,但利用简化的对象模型快速生成 SQL,使得代码抽象程度得到了提高。而代码抽象程度得到提高,第一个好处就是易读,第二个好处就是易操作。 数据库特别容易抽象为面向对象模型,而对数据库的操作语句 - SQL 是一种结构化查询语句,只能描述一段一段的查询,而面向对象模型却适合描述一个整体,将数据库多张表串联起来。 举个例子,利用 typeorm,我们可以用 a 与 b 两个 Class 描述两张表,同时利用 ManyToMany 装饰器分别修饰 a 与 b 的两个字段,将其建立起 多对多的关联,而这个映射到 SQL 结构是三张表,还有一张是中间表 ab,以及查询时涉及到的 left join 操作,而在 typeorm 中,一条 find 语句就能连带查询处多对多关联关系。 这就是这种利用编程语言生成 SQL 库的价值,所以本周我们分析一下 sqorn 这个库的源码,看看利用对象模型生成 SQL 需要哪些步骤。 2 概述我们先看一下 sqorn 的语法。 const sq = require("sqorn-pg")();const Person = sq`person`, Book = sq`book`;// SELECTconst children = await Person`age < ${13}`;// "select * from person where age < 13"// DELETEconst [deleted] = await Book.delete({ id: 7 })`title`;// "delete from book where id = 7 returning title"// INSERTawait Person.insert({ firstName: "Rob" });// "insert into person (first_name) values ('Rob')"// UPDATEawait Person({ id: 23 }).set({ name: "Rob" });// "update person set name = 'Rob' where id = 23" 首先第一行的 sqorn-pg 告诉我们 sqorn 按照 SQL 类型拆成不同分类的小包,这是因为不同数据库支持的方言不同,sqorn 希望在语法上抹平数据库间差异。 其次 sqorn 也是利用面向对象思维的,上面的例子通过 sq`person` 生成了 Person 实例,实际上也对应了 person 表,然后 Person`age < ${13}` 表示查询:select * from person where age < 13 上面是利用 ES6 模板字符串的功能实现的简化 where 查询功能,sqorn 主要还是利用一些函数完成 SQL 语句生成,比如 where delete insert 等等,比较典型的是下面的 Example: sq.from`book`.return`distinct author` .where({ genre: "Fantasy" }) .where({ language: "French" });// select distinct author from book// where language = 'French' and genre = 'Fantsy' 所以我们阅读 sqorn 源码,探讨如何利用实现上面的功能。 3 精读我们从四个方面入手,讲明白 sqorn 的源码是如何组织的,以及如何满足上面功能的。 方言为了实现各种 SQL 方言,需要在实现功能之前,将代码拆分为内核代码与拓展代码。 内核代码就是 sqorn-sql 而拓展代码就是 sqorn-pg,拓展代码自身只要实现 pg 数据库自身的特殊逻辑, 加上 sqorn-sql 提供的核心能力,就能形成完整的 pg SQL 生成功能。 实现数据库连接 sqorn 不但生成 query 语句,也会参与数据库连接与运行,因此方言库的一个重要功能就是做数据库连接。sqorn 利用 pg 这个库实现了连接池、断开、查询、事务的功能。 覆写接口函数 内核代码想要具有拓展能力,暴露出一些接口让 sqorn-xx 覆写是很基本的。 context内核代码中,最重要的就是 context 属性,因为人类习惯一步一步写代码,而最终生成的 query 语句是连贯的,所以这个上下文对象通过 updateContext 存储了每一条信息: { name: 'limit', updateContext: (ctx, args) => { ctx.lim = args }}{ name: 'where', updateContext: (ctx, args) => { ctx.whr.push(args) }} 比如 Person.where({ name: 'bob' }) 就会调用 ctx.whr.push({ name: 'bob' }),因为 where 条件是个数组,因此这里用 push,而 limit 一般仅有一个,所以 context 对 lim 对象的存储仅有一条。 其他操作诸如 where delete insert with from 都会类似转化为 updateContext,最终更新到 context 中。 创建 builder不用太关心下面的 sqorn-xx 包名细节,这一节主要目的是说明如何实现 Demo 中的链式调用,至于哪个模块放在哪并不重要(如果要自己造轮子就要仔细学习一下作者的命名方式)。 在 sqorn-core 代码中创建了 builder 对象,将 sqorn-sql 中创建的 methods merge 到其中,因此我们可以使用 sq.where 这种语法。而为什么可以 sq.where().limit() 这样连续调用呢?可以看下面的代码: for (const method of methods) { // add function call methods builder[name] = function(...args) { return this.create({ name, args, prev: this.method }); };} 这里将 where delete insert with from 等 methods merge 到 builder 对象中,且当其执行完后,通过 this.create() 返回一个新 builder,从而完成了链式调用功能。 生成 query上面三点讲清楚了如何支持方言、用户代码内容都收集到 context 中了,而且我们还创建了可以链式调用的 builder 对象方便用户调用,那么只剩最后一步了,就是生成 query。 为了利用 context 生成 query,我们需要对每个 key 编写对应的函数做处理,拿 limit 举例: export default ctx => { if (!ctx.lim) return; const txt = build(ctx, ctx.lim); return txt && `limit ${txt}`;}; 从 context.lim 拿取 limit 配置,组合成 limit xxx 的字符串并返回就可以了。 build 函数是个工具函数,如果 ctx.lim 是个数组,就会用逗号拼接。 大部分操作比如 delete from having 都做这么简单的处理即可,但像 where 会相对复杂,因为内部包含了 condition 子语法,注意用 and 拼接即可。 最后是顺序,也需要在代码中确定: export default { sql: query(sql), select: query(wth, select, from, where, group, having, order, limit, offset), delete: query(wth, del, where, returning), insert: query(wth, insert, value, returning), update: query(wth, update, set, where, returning)}; 这个意思是,一个 select 语句会通过 wth, select, from, where, group, having, order, limit, offset 的顺序调用处理函数,返回的值就是最终的 query。 4 总结通过源码分析,可以看到制作一个这样的库有三个步骤: 创建 context 存储结构化 query 信息。 创建 builder 供用户链式书写代码同时填充 context。 通过若干个 SQL 子处理函数加上几个主 statement 函数将其串联起来生成最终 query。 最后在设计时考虑到 SQL 方言的话,可以将模块拆成 核心、SQL、若干个方言库,方言库基于核心库做拓展即可。 5 更多讨论 讨论地址是:精读《sqorn 源码》 · Issue ##103 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《syntax-parser 源码》","path":"/wiki/WebWeekly/源码解读/《syntax-parser 源码》.html","content":"当前期刊数: 93 1. 引言syntax-parser 是一个 JS 版语法解析器生成器,具有分词、语法树解析的能力。 通过两个例子介绍它的功能。 第一个例子是创建一个词法解析器 myLexer: import { createLexer } from "syntax-parser";const myLexer = createLexer([ { type: "whitespace", regexes: [/^(\\s+)/], ignore: true }, { type: "word", regexes: [/^([a-zA-Z0-9]+)/] }, { type: "operator", regexes: [/^(\\+)/] }]); 如上,通过正则分别匹配了 “空格”、“字母或数字”、“加号”,并将匹配到的空格忽略(不输出)。 分词匹配是从左到右的,优先匹配数组的第一项,依此类推。 接下来使用 myLexer: const tokens = myLexer("a + b");// tokens:// [// { "type": "word", "value": "a", "position": [0, 1] },// { "type": "operator", "value": "+", "position": [2, 3] },// { "type": "word", "value": "b", "position": [4, 5] },// ] 'a + b' 会按照上面定义的 “三种类型” 被分割为数组,数组的每一项都包含了原始值以及其位置。 第二个例子是创建一个语法解析器 myParser: import { createParser, chain, matchTokenType, many } from "syntax-parser";const root = () => chain(addExpr)(ast => ast[0]);const addExpr = () => chain(matchTokenType("word"), many(addPlus))(ast => ({ left: ast[0].value, operator: ast[1] && ast[1][0].operator, right: ast[1] && ast[1][0].term }));const addPlus = () => chain("+"), root)(ast => ({ operator: ast[0].value, term: ast[1] }));const myParser = createParser( root, // Root grammar. myLexer // Created in lexer example.); 利用 chain 函数书写文法表达式:通过字面量的匹配(比如 + 号),以及 matchTokenType 来模糊匹配我们上面词法解析出的 “三种类型”,就形成了完整的文法表达式。 syntax-parser 还提供了其他几个有用的函数,比如 many optional 分别表示匹配多次和匹配零或一次。 接下来使用 myParser: const ast = myParser("a + b");// ast:// [{// "left": "a",// "operator": "+",// "right": {// "left": "b",// "operator": null,// "right": null// }// }] 2. 精读按照下面的思路大纲进行源码解读: 词法解析 词汇与概念 分词器 语法解析 词汇与概念 重新做一套 “JS 执行引擎” 实现 Chain 函数 引擎执行 何时算执行完 “或” 逻辑的实现 many, optional, plus 的实现 错误提示 & 输入推荐 First 集优化 词法解析词法解析有点像 NLP 中分词,但比分词简单的时,词法解析的分词逻辑是明确的,一般用正则片段表达。 词汇与概念 Lexer:词法解析器。 Token:分词后的词素,包括 value:值、position:位置、type:类型。 分词器分词器 createLexer 函数接收的是一个正则数组,因此思路是遍历数组,一段一段匹配字符串。 我们需要这几个函数: class Tokenizer { public tokenize(input: string) { // 调用 getNextToken 对输入字符串 input 进行正则匹配,匹配完后 substring 裁剪掉刚才匹配的部分,再重新匹配直到字符串裁剪完 } private getNextToken(input: string) { // 调用 getTokenOnFirstMatch 对输入字符串 input 进行遍历正则匹配,一旦有匹配到的结果立即返回 } private getTokenOnFirstMatch({ input, type, regex }: { input: string; type: string; regex: RegExp; }) { // 对输入字符串 input 进行正则 regex 的匹配,并返回 Token 对象的基本结构 }} tokenize 是入口函数,循环调用 getNextToken 匹配 Token 并裁剪字符串直到字符串被裁完。 语法解析语法解析是基于词法解析的,输入是 Tokens,根据文法规则依次匹配 Token,当 Token 匹配完且完全符合文法规范后,语法树就出来了。 词法解析器生成器就是 “生成词法解析器的工具”,只要输入规定的文法描述,内部引擎会自动做掉其余的事。 这个生成器的难点在于,匹配 “或” 逻辑失败时,调用栈需要恢复到失败前的位置,而 JS 引擎中调用栈不受代码控制,因此代码需要在模拟引擎中执行。 词汇与概念 Parser:语法解析器。 ChainNode:连续匹配,执行链四节点之一。 TreeNode:匹配其一,执行链四节点之一。 FunctionNode:函数节点,执行链四节点之一。 MatchNode:匹配字面量或某一类型的 Token,执行链四节点之一。每一次正确的 Match 匹配都会消耗一个 Token。 重新做一套 “JS 执行引擎”为什么要重新做一套 JS 执行引擎?看下面的代码: const main = () => chain(functionA(), tree(functionB1(), functionB2()), functionC());const functionA = () => chain("a");const functionB1 = () => chain("b", "x");const functionB2 = () => chain("b", "y");const functionC = () => chain("c"); 假设 chain('a') 可以匹配 Token a,而 chain(functionC)) 可以匹配到 Token c。 当输入为 a b y c 时,我们该怎么写 tree 函数呢? 我们期望匹配到 functionB1 时失败,再尝试 functionB2,直到有一个成功为止。 那么 tree 函数可能是这样的: function tree(...funs) { // ... 存储当前 tokens for (const fun of funs) { // ... 复位当前 tokens const result = fun(); if (result === true) { return result; } }} 不断尝试 tree 中内容,直到能正确匹配结果后返回这个结果。由于正确的匹配会消耗 Token,因此需要在执行前后存储当前 Tokens 内容,在执行失败时恢复 Token 并尝试新的执行链路。 这样看去很容易,不是吗? 然而,下面这个例子会打破这个美好的假设,让我们稍稍换几个值吧: const main = () => chain(functionA(), tree(functionB1(), functionB2()), functionC());const functionA = () => chain("a");const functionB1 = () => chain("b", "y");const functionB2 = () => chain("b");const functionC = () => chain("y", "c"); 输入仍然是 a b y c,看看会发生什么? 线路 functionA -> functionB1 是 a b y 很显然匹配会通过,但连上 functionC 后结果就是 a b y y c,显然不符合输入。 此时正确的线路应该是 functionA -> functionB2 -> functionC,结果才是 a b y c! 我们看 functionA -> functionB1 -> functionC 链路,当执行到 functionC 时才发现匹配错了,此时想要回到 functionB2 门也没有!因为 tree(functionB1(), functionB2()) 的执行堆栈已退出,再也找不回来了。 所以需要模拟一个执行引擎,在遇到分叉路口时,将 functionB2 保存下来,随时可以回到这个节点重新执行。 实现 Chain 函数用链表设计 Chain 函数是最佳的选择,我们要模拟 JS 调用栈了。 const main = () => chain(functionA, [functionB1, functionB2], functionC)();const functionA = () => chain("a")();const functionB1 = () => chain("b", "y")();const functionB2 = () => chain("b")();const functionC = () => chain("y", "c")(); 上面的例子只改动了一小点,那就是函数不会立即执行。 chain 将函数转化为 FunctionNode,将字面量 a 或 b 转化为 MatchNode,将 [] 转化为 TreeNode,将自己转化为 ChainNode。 我们就得到了如下的链表: ChainNode(main) └── FunctionNode(functionA) ─ TreeNode ─ FunctionNode(functionC) │── FunctionNode(functionB1) └── FunctionNode(functionB2) 至于为什么 FunctionNode 不直接展开成 MatchNode,请思考这样的描述:const list = () => chain(',', list)。直接展开则陷入递归死循环,实际上 Tokens 数量总有限,用到再展开总能匹配尽 Token,而不会无限展开下去。 那么需要一个函数,将 chain 函数接收的不同参数转化为对应 Node 节点: const createNodeByElement = ( element: IElement, parentNode: ParentNode, parentIndex: number, parser: Parser): Node => { if (element instanceof Array) { // ... return TreeNode } else if (typeof element === "string") { // ... return MatchNode } else if (typeof element === "boolean") { // ... true 表示一定匹配成功,false 表示一定匹配失败,均不消耗 Token } else if (typeof element === "function") { // ... return FunctionNode }}; createNodeByElement 函数源码 引擎执行引擎执行其实就是访问链表,通过 visit 函数是最佳手段。 const visit = tailCallOptimize( ({ node, store, visiterOption, childIndex }: { node: Node; store: VisiterStore; visiterOption: VisiterOption; childIndex: number; }) => { if (node instanceof ChainNode) { // 调用 `visitChildNode` 访问子节点 } else if (node instanceof TreeNode) { // 调用 `visitChildNode` 访问子节点 visitChildNode({ node, store, visiterOption, childIndex }); } else if (node instanceof MatchNode) { // 与当前 Token 进行匹配,匹配成功则调用 `visitNextNodeFromParent` 访问父级 Node 的下一个节点,匹配失败则调用 `tryChances`,这会在 “或” 逻辑里说明。 } else if (node instanceof FunctionNode) { // 执行函数节点,并替换掉当前节点,重新 `visit` 一遍 } }); 由于 visit 函数执行次数至多可能几百万次,因此使用 tailCallOptimize 进行尾递归优化,防止内存或堆栈溢出。 visit 函数只负责访问节点本身,而 visitChildNode 函数负责访问节点的子节点(如果有),而 visitNextNodeFromParent 函数负责在没有子节点时,找到父级节点的下一个子节点访问。 function visitChildNode({ node, store, visiterOption, childIndex}: { node: ParentNode; store: VisiterStore; visiterOption: VisiterOption; childIndex: number;}) { if (node instanceof ChainNode) { const child = node.childs[childIndex]; if (child) { // 调用 `visit` 函数访问子节点 `child` } else { // 如果没有子节点,就调用 `visitNextNodeFromParent` 往上找了 } } else { // 对于 TreeNode,如果不是访问到了最后一个节点,则添加一次 “存档” // 调用 `addChances` // 同时如果有子元素,`visit` 这个子元素 }}const visitNextNodeFromParent = tailCallOptimize( ( node: Node, store: VisiterStore, visiterOption: VisiterOption, astValue: any ) => { if (!node.parentNode) { // 找父节点的函数没有父级时,下面再介绍,记住这个位置叫 END 位。 } if (node.parentNode instanceof ChainNode) { // A B <- next node C // └── node <- current node // 正如图所示,找到 nextNode 节点调用 `visit` } else if (node.parentNode instanceof TreeNode) { // TreeNode 节点直接利用 `visitNextNodeFromParent` 跳过。因为同一时间 TreeNode 节点只有一个分支生效,所以它没有子元素了 } }); 可以看到 visitChildNode 与 visitNextNodeFromParent 函数都只处理好了自己的事情,而将其他工作交给别的函数完成,这样函数间职责分明,代码也更易懂。 有了 vist visitChildNode 与 visitNextNodeFromParent,就完成了节点的访问、子节点的访问、以及当没有子节点时,追溯到上层节点的访问。 visit 函数源码 何时算执行完当 visitNextNodeFromParent 函数访问到 END 位 时,是时候做一个了结了: 当 Tokens 正好消耗完,完美匹配成功。 Tokens 没消耗完,匹配失败。 还有一种失败情况,是 Chance 用光时,结合下面的 “或” 逻辑一起说。 “或” 逻辑的实现“或” 逻辑是重构 JS 引擎的原因,现在这个问题被很好解决掉了。 const main = () => chain(functionA, [functionB1, functionB2], functionC)(); 比如上面的代码,当遇到 [] 数组结构时,被认为是 “或” 逻辑,子元素存储在 TreeNode 节点中。 在 visitChildNode 函数中,与 ChainNode 不同之处在于,访问 TreeNode 子节点时,还会调用 addChances 方法,为下一个子元素存储执行状态,以便未来恢复到这个节点继续执行。 addChances 维护了一个池子,调用是先进后出: function addChances(/* ... */) { const chance = { node, tokenIndex, childIndex }; store.restChances.push(chance);} 与 addChance 相对的就是 tryChance。 下面两种情况会调用 tryChances: MatchNode 匹配失败。节点匹配失败是最常见的失败情况,但如果 chances 池还有存档,就可以恢复过去继续尝试。 没有下一个节点了,但 Tokens 还没消耗完,也说明匹配失败了,此时调用 tryChances 继续尝试。 我们看看神奇的存档回复函数 tryChances 是如何做的: function tryChances( node: Node, store: VisiterStore, visiterOption: VisiterOption) { if (store.restChances.length === 0) { // 直接失败 } const nextChance = store.restChances.pop(); // reset scanner index store.scanner.setIndex(nextChance.tokenIndex); visit({ node: nextChance.node, store, visiterOption, childIndex: nextChance.childIndex });} tryChances 其实很简单,除了没有 chances 就失败外,找到最近的一个 chance 节点,恢复 Token 指针位置并 visit 这个节点就等价于读档。 addChance 源码 tryChances 源码 many, optional, plus 的实现这三个方法实现的也很精妙。 先看可选函数 optional: export const optional = (...elements: IElements) => { return chain([chain(...elements)(/**/)), true])(/**/);}; 可以看到,可选参数实际上就是一个 TreeNode,也就是: chain(optional("a"))();// 等价于chain(["a", true])(); 为什么呢?因为当 'a' 匹配失败后,true 是一个不消耗 Token 一定成功的匹配,整体来看就是 “可选” 的意思。 进一步解释下,如果 'a' 没有匹配上,则 true 一定能匹配上,匹配 true 等于什么都没匹配,就等同于这个表达式不存在。 再看匹配一或多个的函数 plus: export const plus = (...elements: IElements) => { const plusFunction = () => chain(chain(...elements)(/**/), optional(plusFunction))(/**/); return plusFunction;}; 能看出来吗?plus 函数等价于一个新递归函数。也就是: const aPlus = () => chain(plus("a"))();// 等价于const aPlus = () => chain(plusFunc)();const plusFunc = () => chain("a", optional(plusFunc))(); 通过不断递归自身的方式匹配到尽可能多的元素,而每一层的 optional 保证了任意一层匹配失败后可以及时跳到下一个文法,不会失败。 最后看匹配多个的函数 many: export const many = (...elements: IElements) => { return optional(plus(...elements));}; many 就是 optional 的 plus,不是吗? 这三个神奇的函数都利用了已有功能实现,建议每个函数留一分钟左右时间思考为什么。 optional plus many 函数源码 错误提示 & 输入推荐错误提示与输入推荐类似,都是给出错误位置或光标位置后期待的输入。 输入推荐,就是给定字符串与光标位置,给出光标后期待内容的功能。 首先通过光标位置找到光标的 **上一个 Token**,再通过 findNextMatchNodes 找到这个 Token 后所有可能匹配到的 MatchNode,这就是推荐结果。 那么如何实现 findNextMatchNodes 呢?看下面: function findNextMatchNodes(node: Node, parser: Parser): MatchNode[] { const nextMatchNodes: MatchNode[] = []; let passCurrentNode = false; const visiterOption: VisiterOption = { onMatchNode: (matchNode, store, currentVisiterOption) => { if (matchNode === node && passCurrentNode === false) { passCurrentNode = true; // 调用 visitNextNodeFromParent,忽略自身 } else { // 遍历到的 MatchNode nextMatchNodes.push(matchNode); } // 这个是画龙点睛的一笔,所有推荐都当作匹配失败,通过 tryChances 可以找到所有可能的 MatchNode tryChances(matchNode, store, currentVisiterOption); } }; newVisit({ node, scanner: new Scanner([]), visiterOption, parser }); return nextMatchNodes;} 所谓找到后续节点,就是通过 Visit 找到所有的 MatchNode,而 MatchNode 只要匹配一次即可,因为我们只要找到第一层级的 MatchNode。 通过每次匹配后执行 tryChances,就可以找到所有 MatchNode 节点了! 再看错误提示,我们要记录最后出错的位置,再采用输入推荐即可。 但光标所在的位置是期望输入点,这个输入点也应该参与语法树的生成,而错误提示不包含光标,所以我们要 执行两次 visit。 举个例子: select | from b; | 是光标位置,此时语句内容是 select from b; 显然是错误的,但光标位置应该给出提示,给出提示就需要正确解析语法树,所以对于提示功能,我们需要将光标位置考虑进去一起解析。因此一共有两次解析。 findNextMatchNodes 函数源码 First 集优化构建 First 集是个自下而上的过程,当访问到 MatchNode 节点时,其值就是其父节点的一个 First 值,当父节点的 First 集收集完毕后,,就会触发它的父节点 First 集收集判断,如此递归,最后完成 First 集收集的是最顶级节点。 篇幅原因,不再赘述,可以看 这张图。 generateFirstSet 函数源码 3. 总结这篇文章是对 《手写 SQL 编译器》 系列的总结,从源码角度的总结! 该系列的每篇文章都以图文的方式介绍了各技术细节,可以作为补充阅读: 精读《手写 SQL 编译器 - 词法分析》 精读《手写 SQL 编译器 - 文法介绍》 精读《手写 SQL 编译器 - 语法分析》 精读《手写 SQL 编译器 - 回溯》 精读《手写 SQL 编译器 - 语法树》 精读《手写 SQL 编译器 - 错误提示》 精读《手写 SQL 编译器 - 性能优化之缓存》 精读《手写 SQL 编译器 - 智能提示》 讨论地址是:精读《syntax-parser 源码》 · Issue ##133 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《unstated 与 unstated-next 源码》","path":"/wiki/WebWeekly/源码解读/《unstated 与 unstated-next 源码》.html","content":"当前期刊数: 130 1 引言unstated 是基于 Class Component 的数据流管理库,unstated-next 是针对 Function Component 的升级版,且特别优化了对 Hooks 的支持。 与类 redux 库相比,这个库设计的别出心裁,而且这两个库源码行数都特别少,与 180 行的 unstated 相比,unstated-next 只有不到 40 行,但想象空间却更大,且用法符合直觉,所以本周精读就会从用法与源码两个角度分析这两个库。 2 概述首先问,什么是数据流?React 本身就提供了数据流,那就是 setState 与 useState,数据流框架存在的意义是解决跨组件数据共享与业务模型封装。 还有一种说法是,React 早期声称自己是 UI 框架,不关心数据,因此需要生态提供数据流插件弥补这个能力。但其实 React 提供的 createContext 与 useContext 已经能解决这个问题,只是使用起来稍显麻烦,而 unstated 系列就是为了解决这个问题。 unstatedunstated 解决的是 Class Component 场景下组件数据共享的问题。 相比直接抛出用法,笔者还原一下作者的思考过程:利用原生 createContext 实现数据流需要两个 UI 组件,且实现方式冗长: const Amount = React.createContext(1);class Counter extends React.Component { state = { count: 0 }; increment = amount => { this.setState({ count: this.state.count + amount }); }; decrement = amount => { this.setState({ count: this.state.count - amount }); }; render() { return ( <Amount.Consumer> {amount => ( <div> <span>{this.state.count}</span> <button onClick={() => this.decrement(amount)}>-</button> <button onClick={() => this.increment(amount)}>+</button> </div> )} </Amount.Consumer> ); }}class AmountAdjuster extends React.Component { state = { amount: 0 }; handleChange = event => { this.setState({ amount: parseInt(event.currentTarget.value, 10) }); }; render() { return ( <Amount.Provider value={this.state.amount}> <div> {this.props.children} <input type="number" value={this.state.amount} onChange={this.handleChange} /> </div> </Amount.Provider> ); }}render( <AmountAdjuster> <Counter /> </AmountAdjuster>); 而我们要做的,是将 setState 从具体的某个 UI 组件上剥离,形成一个数据对象实体,可以被注入到任何组件。 这就是 unstated 的使用方式: import React from "react";import { render } from "react-dom";import { Provider, Subscribe, Container } from "unstated";class CounterContainer extends Container { state = { count: 0 }; increment() { this.setState({ count: this.state.count + 1 }); } decrement() { this.setState({ count: this.state.count - 1 }); }}function Counter() { return ( <Subscribe to={[CounterContainer]}> {counter => ( <div> <button onClick={() => counter.decrement()}>-</button> <span>{counter.state.count}</span> <button onClick={() => counter.increment()}>+</button> </div> )} </Subscribe> );}render( <Provider> <Counter /> </Provider>, document.getElementById("root")); 首先要为 Provider 正名:Provider 是解决单例 Store 的最佳方案,当项目与组件都是用了数据流,需要分离作用域时,Provider 便派上了用场。如果项目仅需单 Store 数据流,那么与根节点放一个 Provider 等价。 其次 CounterContainer 成为一个真正数据处理类,只负责存储与操作数据,通过 <Subscribe to={[CounterContainer]}> RenderProps 方法将 counter 注入到 Render 函数中。 unstated 方案本质上利用了 setState,但将 setState 与 UI 剥离,并可以很方便的注入到任何组件中。 类似的是,其升级版 unstated-next 本质上利用了 useState,利用了自定义 Hooks 可以与 UI 分离的特性,加上 useContext 的便捷性,利用不到 40 行代码实现了比 unstated 更强大的功能。 unstated-nextunstated-next 用 40 行代码号称 React 数据管理库的终结版,让我们看看它是怎么做到的! 还是从思考过程说起,笔者发现其 README 也提供了对应思考过程,就以其 README 里的代码作为案例。 首先,使用 Function Component 的你会这样使用数据流: function CounterDisplay() { let [count, setCount] = useState(0); let decrement = () => setCount(count - 1); let increment = () => setCount(count + 1); return ( <div> <button onClick={decrement}>-</button> <p>You clicked {count} times</p> <button onClick={increment}>+</button> </div> );} 如果想将数据与 UI 分离,利用 Custom Hooks 就可以完成,这不需要借助任何框架: function useCounter() { let [count, setCount] = useState(0); let decrement = () => setCount(count - 1); let increment = () => setCount(count + 1); return { count, decrement, increment };}function CounterDisplay() { let counter = useCounter(); return ( <div> <button onClick={counter.decrement}>-</button> <p>You clicked {counter.count} times</p> <button onClick={counter.increment}>+</button> </div> );} 如果想将这个数据分享给其他组件,利用 useContext 就可以完成,这不需要借助任何框架: function useCounter() { let [count, setCount] = useState(0); let decrement = () => setCount(count - 1); let increment = () => setCount(count + 1); return { count, decrement, increment };}let Counter = createContext(null);function CounterDisplay() { let counter = useContext(Counter); return ( <div> <button onClick={counter.decrement}>-</button> <p>You clicked {counter.count} times</p> <button onClick={counter.increment}>+</button> </div> );}function App() { let counter = useCounter(); return ( <Counter.Provider value={counter}> <CounterDisplay /> <CounterDisplay /> </Counter.Provider> );} 但这样还是显示使用了 useContext 的 API,并且对 Provider 的封装没有形成固定模式,这就是 usestated-next 要解决的问题。 所以这就是 unstated-next 的使用方式: import { createContainer } from "unstated-next";function useCounter() { let [count, setCount] = useState(0); let decrement = () => setCount(count - 1); let increment = () => setCount(count + 1); return { count, decrement, increment };}let Counter = createContainer(useCounter);function CounterDisplay() { let counter = Counter.useContainer(); return ( <div> <button onClick={counter.decrement}>-</button> <p>You clicked {counter.count} times</p> <button onClick={counter.increment}>+</button> </div> );}function App() { return ( <Counter.Provider> <CounterDisplay /> <CounterDisplay /> </Counter.Provider> );} 可以看到,createContainer 可以将任何 Hooks 包装成一个数据对象,这个对象有 Provider 与 useContainer 两个 API,其中 Provider 用于对某个作用域注入数据,而 useContainer 可以取到这个数据对象在当前作用域的实例。 对 Hooks 的参数也进行了规范化,我们可以通过 initialState 设定初始化数据,且不同作用域可以嵌套并赋予不同的初始化值: function useCounter(initialState = 0) { let [count, setCount] = useState(initialState); let decrement = () => setCount(count - 1); let increment = () => setCount(count + 1); return { count, decrement, increment };}const Counter = createContainer(useCounter);function CounterDisplay() { let counter = Counter.useContainer(); return ( <div> <button onClick={counter.decrement}>-</button> <span>{counter.count}</span> <button onClick={counter.increment}>+</button> </div> );}function App() { return ( <Counter.Provider> <CounterDisplay /> <Counter.Provider initialState={2}> <div> <div> <CounterDisplay /> </div> </div> </Counter.Provider> </Counter.Provider> );} 可以看到,React Hooks 已经非常适合做状态管理,而生态应该做的事情是尽可能利用其能力进行模式化封装。 有人可能会问,取数和副作用怎么办?redux-saga 和其他中间件都没有,这个数据流是不是阉割版? 首先我们看 Redux 为什么需要处理副作用的中间件。这是因为 reducer 是一个同步纯函数,其返回值就是操作结果中间不能有异步,且不能有副作用,所以我们需要一种异步调用 dispatch 的方法,或者一个副作用函数来存放这些 “脏” 逻辑。 而在 Hooks 中,我们可以随时调用 useState 提供的 setter 函数修改值,这早已天然解决了 reducer 无法异步的问题,同时也实现了 redux-chunk 的功能。 而异步功能也被 useEffect 这个 React 官方 Hook 替代。我们看到这个方案可以利用 React 官方提供的能力完全覆盖 Redux 中间件的能力,对 Redux 库实现了降维打击,所以下一代数据流方案随着 Hooks 的实现是真的存在的。 最后,相比 Redux 自身以及其生态库的理解成本(笔者不才,初学 Redux 以及其周边 middleware 时理解了好久),Hooks 的理解学习成本明显更小。 很多时候,人们排斥一个新技术,并不是因为新技术不好,而是这可能让自己多年精通的老手艺带来的 “竞争优势” 完全消失。可能一个织布老专家手工织布效率是入门学员的 5 倍,但换上织布机器后,这个差异很快会被抹平,老织布专家面临被淘汰的危机,所以维护这份老手艺就是维护他自己的利益。希望每个团队中的老织布工人都能主动引入织布机。 再看取数中间件,我们一般需要解决 取数业务逻辑封装 与 取数状态封装,通过 redux 中间件可以封装在内,通过一个 dispatch 解决。 其实 Hooks 思维下,利用 swr useSWR 一样能解决: function Profile() { const { data, error } = useSWR("/api/user");} 取数的业务逻辑封装在 fetcher 中,这个在 SWRConfigContext.Provider 时就已注入,还可以控制作用域!完全利用 React 提供的 Context 能力,可以感受到实现底层原理的一致性和简洁性,越简单越优美的数学公式越可能是真理。 而取数状态已经封装在 useSWR 中,配合 Suspense 能力,连 Loading 状态都不用关心了。 3 精读unstated我们再梳理一下 unstated 这个库做了哪些事情。 利用 Provider 申明作用范围。 提供 Container 作为可以被继承的类,继承它的 Class 作为 Store。 提供 Subscribe 作为 RenderProps 用法注入 Store,注入的 Store 实例由参数 to 接收到的 Class 实例决定。 对于第一点,Provider 在 Class Component 环境下要初始化 StateContext,这样才能在 Subscribe 中使用: const StateContext = createReactContext(null);export function Provider(props) { return ( <StateContext.Consumer> {parentMap => { let childMap = new Map(parentMap); if (props.inject) { props.inject.forEach(instance => { childMap.set(instance.constructor, instance); }); } return ( <StateContext.Provider value={childMap}> {props.children} </StateContext.Provider> ); }} </StateContext.Consumer> );} 对于第二点,对于 Container,需要提供给 Store setState API,按照 React 的 setState 结构实现了一遍。 值得注意的是,还存储了一个 _listeners 对象,并且可通过 subscribe 与 unsubscribe 增删。 _listeners 存储的其实是当前绑定的组件 onUpdate 生命周期,然后在 setState 时主动触发对应组件的渲染。onUpdate 生命周期由 Subscribe 函数提供,最终调用的是 this.setState,这个在 Subscribe 部分再说明。 以下是 Container 的代码实现: export class Container<State: {}> { state: State; _listeners: Array<Listener> = []; constructor() { CONTAINER_DEBUG_CALLBACKS.forEach(cb => cb(this)); } setState( updater: $Shape<State> | ((prevState: $Shape<State>) => $Shape<State>), callback?: () => void ): Promise<void> { return Promise.resolve().then(() => { let nextState; if (typeof updater === "function") { nextState = updater(this.state); } else { nextState = updater; } if (nextState == null) { if (callback) callback(); return; } this.state = Object.assign({}, this.state, nextState); let promises = this._listeners.map(listener => listener()); return Promise.all(promises).then(() => { if (callback) { return callback(); } }); }); } subscribe(fn: Listener) { this._listeners.push(fn); } unsubscribe(fn: Listener) { this._listeners = this._listeners.filter(f => f !== fn); }} 对于第三点,Subscribe 的 render 函数将 this.props.children 作为一个函数执行,并把对应的 Store 实例作为参数传递,这通过 _createInstances 函数实现。 _createInstances 利用 instanceof 通过 Class 类找到对应的实例,并通过 subscribe 将自己组件的 onUpdate 函数传递给对应 Store 的 _listeners,在解除绑定时调用 unsubscribe 解绑,防止不必要的 renrender。 以下是 Subscribe 源码: export class Subscribe<Containers: ContainersType> extends React.Component< SubscribeProps<Containers>, SubscribeState> { state = {}; instances: Array<ContainerType> = []; unmounted = false; componentWillUnmount() { this.unmounted = true; this._unsubscribe(); } _unsubscribe() { this.instances.forEach(container => { container.unsubscribe(this.onUpdate); }); } onUpdate: Listener = () => { return new Promise(resolve => { if (!this.unmounted) { this.setState(DUMMY_STATE, resolve); } else { resolve(); } }); }; _createInstances( map: ContainerMapType | null, containers: ContainersType ): Array<ContainerType> { this._unsubscribe(); if (map === null) { throw new Error( "You must wrap your <Subscribe> components with a <Provider>" ); } let safeMap = map; let instances = containers.map(ContainerItem => { let instance; if ( typeof ContainerItem === "object" && ContainerItem instanceof Container ) { instance = ContainerItem; } else { instance = safeMap.get(ContainerItem); if (!instance) { instance = new ContainerItem(); safeMap.set(ContainerItem, instance); } } instance.unsubscribe(this.onUpdate); instance.subscribe(this.onUpdate); return instance; }); this.instances = instances; return instances; } render() { return ( <StateContext.Consumer> {map => this.props.children.apply( null, this._createInstances(map, this.props.to) ) } </StateContext.Consumer> ); }} 总结下来,unstated 将 State 外置是通过自定义 Listener 实现的,在 Store setState 时触发收集好的 Subscribe 组件的 rerender。 unstated-nextunstated-next 这个库只做了一件事情: 提供 createContainer 将自定义 Hooks 封装为一个数据对象,提供 Provider 注入与 useContainer 获取 Store 这两个方法。 正如之前解析所说,unstated-next 可谓将 Hooks 用到了极致,认为 Hooks 已经完全具备数据流管理的全部能力,我们只要包装一层规范即可: export function createContainer(useHook) { let Context = React.createContext(null); function Provider(props) { let value = useHook(props.initialState); return <Context.Provider value={value}>{props.children}</Context.Provider>; } function useContainer() { let value = React.useContext(Context); if (value === null) { throw new Error("Component must be wrapped with <Container.Provider>"); } return value; } return { Provider, useContainer };} 可见,Provider 就是对 value 进行了约束,固化了 Hooks 返回的 value 直接作为 value 传递给 Context.Provider 这个规范。 而 useContainer 就是对 React.useContext(Context) 的封装。 真的没有其他逻辑了。 唯一需要思考的是,在自定义 Hooks 中,我们用 useState 管理数据还是 useReducer 管理数据的问题,这个是个仁者见仁的问题。不过我们可以对自定义 Hooks 进行嵌套封装,支持一些更复杂的数据场景,比如: function useCounter(initialState = 0) { const [count, setCount] = useState(initialState); const decrement = () => setCount(count - 1); const increment = () => setCount(count + 1); return { count, decrement, increment };}function useUser(initialState = {}) { const [name, setName] = useState(initialState.name); const [age, setAge] = useState(initialState.age); const registerUser = userInfo => { setName(userInfo.name); setAge(userInfo.age); }; return { user: { name, age }, registerUser };}function useApp(initialState) { const { count, decrement, increment } = useCounter(initialState.count); const { user, registerUser } = useUser(initialState.user); return { count, decrement, increment, user, registerUser };}const App = createContainer(useApp); 4 总结借用 unstated-next 的标语:“never think about React state management libraries ever again” - 用了 unstated-next 再也不要考虑其他 React 状态管理库了。 而有意思的是,unstated-next 本身也只是对 Hooks 的一种模式化封装,Hooks 已经能很好解决状态管理的问题,我们真的不需要 “再造” React 数据流工具了。 讨论地址是:精读《unstated 与 unstated-next 源码》 · Issue ##218 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《use-what-changed 源码》","path":"/wiki/WebWeekly/源码解读/《use-what-changed 源码》.html","content":"当前期刊数: 155 1 引言使用 React Hooks 的时候,经常出现执行次数过多甚至死循环的情况,我们可以利用 use-what-changed 进行依赖分析,找到哪个变量引用一直在变化。 据一个例子,比如你尝试在 Class 组件内部渲染 Function 组件,Class 组件是这么写的: class Parent extends React.PureComponent { state = { text: "text", }; render() { return <Child setText={(text) => this.setState({ text })} />; }} 子组件是这么写的: 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 使用方式如下: 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]);} 将参数像依赖数组一样传入,刷新页面就可以在控制台看到引用或值是否变化,如果变化,对应行会展示 ✅ 并打印出上次的值与当前值: 第一步是存储上一次依赖项的值,利用 useRef 实现: function useWhatChanged(dependency?: any[]) { const dependencyRef = React.useRef(dependency);} 然后利用 useEffect,对比 dependency 与 dependencyRef 的引用即可找到变化项: 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]); 直接对比 deps 引用,不想等则将 changed 设为 true。 调试模式下,利用 console.table 打印出表格。 依赖项是 dependency,当依赖项变化时才打印 whatChanged。 以上就是其源码的核心逻辑,当然我们还可以简化输出,仅当有引用变化时才打印表格,否则只输出简单的 Log 信息: if (isDevelopment) { if (changed) { console.table(whatChanged); } else { console.log(whatChanged); }} babel 插件最后 use-what-changed 还提供了 babel 插件,只通过注释就能打印 useMemo、useEffect 等依赖变化信息。babel 配置如下: { "plugins": [ [ "@simbathesailor/babel-plugin-use-what-changed", { "active": process.env.NODE_ENV === "development" // boolean } ] ]} 使用方式简化为: // uwc-debugReact.useEffect(() => { // console.log("some thing changed , need to figure out")}, [a, b, c, d]); 将 Hooks 的 deps 数组直接转化为 use-what-changed 的入参。 3 总结use-what-changed 补充了 Hooks 依赖变化的调试方法,对于 React 组件重渲染分析可以利用 React Dev Tool,可以参考 精读《React 性能调试》。 还有哪些实用的 Hooks 调试工具呢?欢迎分享。 讨论地址是:精读《use-what-changed 源码》· Issue ##256 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《zustand 源码》","path":"/wiki/WebWeekly/源码解读/《zustand 源码》.html","content":"当前期刊数: 227 zustand 是一个非常时髦的状态管理库,也是 2021 年 Star 增长最快的 React 状态管理库。它的理念非常函数式,API 设计的很优雅,值得学习。 概述首先介绍 zustand 的使用方法。 创建 store通过 create 函数创建 store,回调可拿到 get set 就类似 Redux 的 getState 与 setState,可以获取 store 瞬时值与修改 store。返回一个 hook 可以在 React 组件中访问 store。 import create from 'zustand'const useStore = create((set, get) => ({ bears: 0, increasePopulation: () => set(state => ({ bears: state.bears + 1 })), removeAllBears: () => set({ bears: 0 })})) 上面例子是全局唯一的 store,也可以通过 createContext 方式创建多实例 store,结合 Provider 使用: import create from 'zustand'import createContext from 'zustand/context'const { Provider, useStore } = createContext()const createStore = () => create(...)const App = () => ( <Provider createStore={createStore}> ... </Provider>) 访问 store通过 useStore 在组件中访问 store。与 redux 不同的是,无论普通数据还是函数都可以存在 store 里,且函数也通过 selector 语法获取。因为函数引用不可变,所以实际上下面第二个例子不会引发重渲染: function BearCounter() { const bears = useStore(state => state.bears) return <h1>{bears} around here ...</h1>}function Controls() { const increasePopulation = useStore(state => state.increasePopulation) return <button onClick={increasePopulation}>one up</button>} 如果嫌访问变量需要调用多次 useStore 麻烦,可以自定义 compare 函数返回一个对象: const { nuts, honey } = useStore(state => ({ nuts: state.nuts, honey: state.honey }), shallow) 细粒度 memo利用 useCallback 甚至可以跳过普通 compare,而仅关心外部 id 值的变化,如: const fruit = useStore(useCallback(state => state.fruits[id], [id])) 原理是 id 变化时,useCallback 返回值才会变化,而 useCallback 返回值如果不变,useStore 的 compare 函数引用对比就会为 true,非常巧妙。 set 合并与覆盖set 函数第二个参数默认为 false,即合并值而非覆盖整个 store,所以可以利用这个特性清空 store: const useStore = create(set => ({ salmon: 1, tuna: 2, deleteEverything: () => set({ }, true), // clears the entire store, actions included})) 异步所有函数都支持异步,因为修改 store 并不依赖返回值,而是调用 set,所以是否异步对数据流框架来说都一样。 监听指定变量还是用英文比较表意,即 subscribeWithSelector,这个中间件可以让我们把 selector 用在 subscribe 函数上,相比于 redux 传统的 subscribe,就可以有针对性的监听了: import { subscribeWithSelector } from 'zustand/middleware'const useStore = create(subscribeWithSelector(() => ({ paw: true, snout: true, fur: true })))// Listening to selected changes, in this case when "paw" changesconst unsub2 = useStore.subscribe(state => state.paw, console.log)// Subscribe also exposes the previous valueconst unsub3 = useStore.subscribe(state => state.paw, (paw, previousPaw) => console.log(paw, previousPaw))// Subscribe also supports an optional equality functionconst unsub4 = useStore.subscribe(state => [state.paw, state.fur], console.log, { equalityFn: shallow })// Subscribe and fire immediatelyconst unsub5 = useStore.subscribe(state => state.paw, console.log, { fireImmediately: true }) 后面还有一些结合中间件、immer、localstorage、redux like、devtools、combime store 就不细说了,都是一些细节场景。值得一提的是,所有特性都是正交的。 精读其实大部分使用特性都在利用 React 语法,所以可以说 50% 的特性属于 React 通用特性,只是写在了 zustand 文档里,看上去像是 zustand 的特性,所以这个库真的挺会借力的。 创建 store 实例任何数据流管理工具,都有一个最核心的 store 实例。对 zustand 来说,便是定义在 vanilla.ts 文件的 createStore 了。 createStore 返回一个类似 redux store 的数据管理实例,拥有四个非常常见的 API: export type StoreApi<T extends State> = { setState: SetState<T> getState: GetState<T> subscribe: Subscribe<T> destroy: Destroy} 首先 getState 的实现: const getState: GetState<TState> = () => state 就是这么简单粗暴。再看 state,就是一个普通对象: let state: TState 这就是数据流简单的一面,没有魔法,数据存储用一个普通对象,仅此而已。 接着看 setState,它做了两件事,修改 state 并执行 listenser: const setState: SetState<TState> = (partial, replace) => { const nextState = typeof partial === 'function' ? partial(state) : partial if (nextState !== state) { const previousState = state state = replace ? (nextState as TState) : Object.assign({}, state, nextState) listeners.forEach((listener) => listener(state, previousState)) }} 修改 state 也非常简单,唯一重要的是 listener(state, previousState),那么这些 listeners 是什么时候注册和声明的呢?其实 listeners 就是一个 Set 对象: const listeners: Set<StateListener<TState>> = new Set() 注册和销毁时机分别是 subscribe 与 destroy 函数调用时,这个实现很简单、高效。对应代码就不贴了,很显然,subscribe 时注册的监听函数会作为 listener 添加到 listeners 队列中,当发生 setState 时便会被调用。 最后我们看 createStore 的定义与结尾: function createStore(createState) { let state: TState const setState = /** ... */ const getState = /** ... */ /** ... */ const api = { setState, getState, subscribe, destroy } state = createState(setState, getState, api) return api} 虽然这个 state 是个简单的对象,但回顾使用文档,我们可以在 create 创建 store 利用 callback 对 state 赋值,那个时候的 set、get、api 就是上面代码倒数第二行传入的: import { create } from 'zustand'const useStore = create((set, get) => ({ bears: 0, increasePopulation: () => set(state => ({ bears: state.bears + 1 })), removeAllBears: () => set({ bears: 0 })})) 至此,初始化 store 的所有 API 的来龙去脉就梳理清楚了,逻辑简单清晰。 create 函数的实现上面我们说清楚了如何创建 store 实例,但这个实例是底层 API,使用文档介绍的 create 函数在 react.ts 文件定义,并调用了 createStore 创建框架无关数据流。之所 create 定义在 react.ts,是因为返回的 useStore 是一个 Hooks,所以本身具有 React 环境特性,因此得名。 该函数第一行就调用 createStore 创建基础 store,因为对框架来说是内部 API,所以命名也叫 api: const api: CustomStoreApi = typeof createState === 'function' ? createStore(createState) : createStateconst useStore: any = <StateSlice>( selector: StateSelector<TState, StateSlice> = api.getState as any, equalityFn: EqualityChecker<StateSlice> = Object.is) => /** ... */ 接下来所有代码都在创建 useStore 这个函数,我们看下其内部实现: 简单来说就是利用 subscribe 监听变化,并在需要的时候强制刷新当前组件,并传入最新的 state 给到 useStore。所以第一步当然是创建 forceUpdate 函数: const [, forceUpdate] = useReducer((c) => c + 1, 0) as [never, () => void] 然后通过调用 API 拿到 state 并传给 selector,并调用 equalityFn(这个函数可以被定制)判断状态是否发生了变化: const state = api.getState()newStateSlice = selector(state)hasNewStateSlice = !equalityFn( currentSliceRef.current as StateSlice, newStateSlice) 如果状态变化了,就更新 currentSliceRef.current: useIsomorphicLayoutEffect(() => { if (hasNewStateSlice) { currentSliceRef.current = newStateSlice as StateSlice } stateRef.current = state selectorRef.current = selector equalityFnRef.current = equalityFn erroredRef.current = false}) useIsomorphicLayoutEffect 是同构框架常用 API 套路,在前端环境是 useLayoutEffect,在 node 环境是 useEffect: 说明一下 currentSliceRef 与 newStateSlice 的功能。我们看 useStore 最后的返回值: const sliceToReturn = hasNewStateSlice ? (newStateSlice as StateSlice) : currentSliceRef.currentuseDebugValue(sliceToReturn)return sliceToReturn 发现逻辑是这样的:如果 state 变化了,则返回新的 state,否则返回旧的,这样可以保证 compare 函数判断相等时,返回对象的引用完全相同,这个是不可变数据的核心实现。另外我们也可以学习到阅读源码的技巧,即要经常跳读。 那么如何在 selector 变化时更新 store 呢?中间还有一段核心代码,调用了 subscribe,相信你已经猜到了,下面是核心代码片段: useIsomorphicLayoutEffect(() => { const listener = () => { try { const nextState = api.getState() const nextStateSlice = selectorRef.current(nextState) if (!equalityFnRef.current(currentSliceRef.current as StateSlice, nextStateSlice)) { stateRef.current = nextState currentSliceRef.current = nextStateSlice forceUpdate() } } catch (error) { erroredRef.current = true forceUpdate() } } const unsubscribe = api.subscribe(listener) if (api.getState() !== stateBeforeSubscriptionRef.current) { listener() // state has changed before subscription } return unsubscribe}, []) 这段代码要先从 api.subscribe(listener) 看,这使得任何 setState 都会触发 listener 的执行,而 listener 利用 api.getState() 拿到最新 state,并拿到上一次的 compare 函数 equalityFnRef 执行一下判断值前后是否发生了改变,如果改变则更新 currentSliceRef 并进行一次强制刷新(调用 forceUpdate)。 context 的实现注意到 context 语法,可以创建多个互不干扰的 store 实例: import create from 'zustand'import createContext from 'zustand/context'const { Provider, useStore } = createContext()const createStore = () => create(...)const App = () => ( <Provider createStore={createStore}> ... </Provider>) 首先我们知道 create 创建的 store 是实例间互不干扰的,问题是 create 返回的 useStore 只有一个实例,也没有 <Provider> 声明作用域,那么如何构造上面的 API 呢? 首先 Provider 存储了 create 返回的 useStore: const storeRef = useRef<TUseBoundStore>()storeRef.current = createStore() 那么 useStore 本身其实并不实现数据流功能,而是将 <Provider> 提供的 storeRef 拿到并返回: const useStore: UseContextStore<TState> = <StateSlice>( selector?: StateSelector<TState, StateSlice>, equalityFn = Object.is) => { const useProviderStore = useContext(ZustandContext) return useProviderStore( selector as StateSelector<TState, StateSlice>, equalityFn )} 所以核心逻辑还是是现在 create 函数里,context.ts 只是利用 ReactContext 将 useStore “注入” 到组件,且利用 ReactContext 特性,这个注入可以存在多个实例,且不会相互影响。 中间件中间件其实不需要怎么实现。比如看这个 redux 中间件的例子: import { redux } from 'zustand/middleware'const useStore = create(redux(reducer, initialState)) 可以将 zustand 用法改变为 reducer,实际上是利用了函数式理念,redux 函数本身可以拿到 set, get, api,如果想保持 API 不变,则原样返回 callback 就行了,如果想改变用法,则返回特定的结构,就是这么简单。 为了加深理解,我们看看 redux 中间件源码: export const redux = ( reducer, initial ) => ( set, get, api ) => { api.dispatch = action => { set(state => reducer(state, action), false, action) return action } api.dispatchFromDevtools = true return { dispatch: (...a) => api.dispatch(...a), ...initial }} 将 set, get, api 封装为 redux API:dispatch 本质就是调用 set。 总结zustand 是一个实现精巧的 React 数据流管理工具,自身框架无关的分层合理,中间件实现巧妙,值得学习。 讨论地址是:精读《zustand 源码》· Issue ##392 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《vue-lit 源码》","path":"/wiki/WebWeekly/源码解读/《vue-lit 源码》.html","content":"当前期刊数: 229 vue-lit 基于 lit-html + @vue/reactivity 仅用 70 行代码就给模版引擎实现了 Vue Composition API,用来开发 web component。 概述<my-component></my-component><script type="module"> import { defineComponent, reactive, html, onMounted, onUpdated, onUnmounted } from 'https://unpkg.com/@vue/lit' defineComponent('my-component', () => { const state = reactive({ text: 'hello', show: true }) const toggle = () => { state.show = !state.show } const onInput = e => { state.text = e.target.value } return () => html` <button @click=${toggle}>toggle child</button> <p> ${state.text} <input value=${state.text} @input=${onInput}> </p> ${state.show ? html`<my-child msg=${state.text}></my-child>` : ``} ` }) defineComponent('my-child', ['msg'], (props) => { const state = reactive({ count: 0 }) const increase = () => { state.count++ } onMounted(() => { console.log('child mounted') }) onUpdated(() => { console.log('child updated') }) onUnmounted(() => { console.log('child unmounted') }) return () => html` <p>${props.msg}</p> <p>${state.count}</p> <button @click=${increase}>increase</button> ` })</script> 上面定义了 my-component 与 my-child 组件,并将 my-child 作为 my-component 的默认子元素。 import { defineComponent, reactive, html, onMounted, onUpdated, onUnmounted} from 'https://unpkg.com/@vue/lit' defineComponent 定义 custom element,第一个参数是自定义 element 组件名,必须遵循原生 API customElements.define 对组件名的规范,组件名必须包含中划线。 reactive 属于 @vue/reactivity 提供的响应式 API,可以创建一个响应式对象,在渲染函数中调用时会自动进行依赖收集,这样在 Mutable 方式修改值时可以被捕获,并自动触发对应组件的重渲染。 html 是 lit-html 提供的模版函数,通过它可以用 Template strings 原生语法描述模版,是一个轻量模版引擎。 onMounted、onUpdated、onUnmounted 是基于 web component lifecycle 创建的生命周期函数,可以监听组件创建、更新与销毁时机。 接下来看 defineComponent 的内容: defineComponent('my-component', () => { const state = reactive({ text: 'hello', show: true }) const toggle = () => { state.show = !state.show } const onInput = e => { state.text = e.target.value } return () => html` <button @click=${toggle}>toggle child</button> <p> ${state.text} <input value=${state.text} @input=${onInput}> </p> ${state.show ? html`<my-child msg=${state.text}></my-child>` : ``} `}) 借助模版引擎 lit-html 的能力,可以同时在模版中传递变量与函数,再借助 @vue/reactivity 能力,让变量变化时生成新的模版,更新组件 dom。 精读阅读源码可以发现,vue-lit 巧妙的融合了三种技术方案,它们配合方式是: 使用 @vue/reactivity 创建响应式变量。 利用模版引擎 lit-html 创建使用了这些响应式变量的 HTML 实例。 利用 web component 渲染模版引擎生成的 HTML 实例,这样创建的组件具备隔离能力。 其中响应式能力与模版能力分别是 @vue/reactivity、lit-html 这两个包提供的,我们只需要从源码中寻找剩下的两个功能:如何在修改值后触发模版刷新,以及如何构造生命周期函数的。 首先看如何在值修改后触发模版刷新。以下我把与重渲染相关代码摘出来了: import { effect} from 'https://unpkg.com/@vue/reactivity/dist/reactivity.esm-browser.js'customElements.define( name, class extends HTMLElement { constructor() { super() const template = factory.call(this, props) const root = this.attachShadow({ mode: 'closed' }) effect(() => { render(template(), root) }) } }) 可以清晰的看到,首先 customElements.define 创建一个原生 web component,并利用其 API 在初始化时创建一个 closed 节点,该节点对外部 API 调用关闭,即创建的是一个不会受外部干扰的 web component。 然后在 effect 回调函数内调用 html 函数,即在使用文档里返回的模版函数,由于这个模版函数中使用的变量都采用 reactive 定义,所以 effect 可以精准捕获到其变化,并在其变化后重新调用 effect 回调函数,实现了 “值变化后重渲染” 的功能。 然后看生命周期是如何实现的,由于生命周期贯穿整个实现流程,因此必须结合全量源码看,下面贴出全量核心代码,上面介绍过的部分可以忽略不看,只看生命周期的实现: let currentInstanceexport function defineComponent(name, propDefs, factory) { if (typeof propDefs === 'function') { factory = propDefs propDefs = [] } customElements.define( name, class extends HTMLElement { constructor() { super() const props = (this._props = shallowReactive({})) currentInstance = this const template = factory.call(this, props) currentInstance = null this._bm && this._bm.forEach((cb) => cb()) const root = this.attachShadow({ mode: 'closed' }) let isMounted = false effect(() => { if (isMounted) { this._bu && this._bu.forEach((cb) => cb()) } render(template(), root) if (isMounted) { this._u && this._u.forEach((cb) => cb()) } else { isMounted = true } }) } connectedCallback() { this._m && this._m.forEach((cb) => cb()) } disconnectedCallback() { this._um && this._um.forEach((cb) => cb()) } attributeChangedCallback(name, oldValue, newValue) { this._props[name] = newValue } } )}function createLifecycleMethod(name) { return (cb) => { if (currentInstance) { ;(currentInstance[name] || (currentInstance[name] = [])).push(cb) } }}export const onBeforeMount = createLifecycleMethod('_bm')export const onMounted = createLifecycleMethod('_m')export const onBeforeUpdate = createLifecycleMethod('_bu')export const onUpdated = createLifecycleMethod('_u')export const onUnmounted = createLifecycleMethod('_um') 生命周期实现形如 this._bm && this._bm.forEach((cb) => cb()),之所以是循环,是因为比如 onMount(() => cb()) 可以注册多次,因此每个生命周期都可能注册多个回调函数,因此遍历将其依次执行。 而生命周期函数还有一个特点,即并不分组件实例,因此必须有一个 currentInstance 标记当前回调函数是在哪个组件实例注册的,而这个注册的同步过程就在 defineComponent 回调函数 factory 执行期间,因此才会有如下的代码: currentInstance = thisconst template = factory.call(this, props)currentInstance = null 这样,我们就将 currentInstance 始终指向当前正在执行的组件实例,而所有生命周期函数都是在这个过程中执行的,因此当调用生命周期回调函数时,currentInstance 变量必定指向当前所在的组件实例。 接下来为了方便,封装了 createLifecycleMethod 函数,在组件实例上挂载了一些形如 _bm、_bu 的数组,比如 _bm 表示 beforeMount,_bu 表示 beforeUpdate。 接下来就是在对应位置调用对应函数了: 首先在 attachShadow 执行之前执行 _bm - onBeforeMount,因为这个过程确实是准备组件挂载的最后一步。 然后在 effect 中调用了两个生命周期,因为 effect 会在每次渲染时执行,所以还特意存储了 isMounted 标记是否为初始化渲染: effect(() => { if (isMounted) { this._bu && this._bu.forEach((cb) => cb()) } render(template(), root) if (isMounted) { this._u && this._u.forEach((cb) => cb()) } else { isMounted = true }}) 这样就很容易看懂了,只有初始化渲染过后,从第二次渲染开始,在执行 render(该函数来自 lit-html 渲染模版引擎)之前调用 _bu - onBeforeUpdate,在执行了 render 函数后调用 _u - onUpdated。 由于 render(template(), root) 根据 lit-html 的语法,会直接把 template() 返回的 HTML 元素挂载到 root 节点,而 root 就是这个 web component attachShadow 生成的 shadow dom 节点,因此这句话执行结束后渲染就完成了,所以 onBeforeUpdate 与 onUpdated 一前一后。 最后几个生命周期函数都是利用 web component 原生 API 实现的: connectedCallback() { this._m && this._m.forEach((cb) => cb())}disconnectedCallback() { this._um && this._um.forEach((cb) => cb())} 分别实现 mount、unmount。这也说明了浏览器 API 分层的清晰之处,只提供创建和销毁的回调,而更新机制完全由业务代码实现,不管是 @vue/reactivity 的 effect 也好,还是 addEventListener 也好,都不关心,所以如果在这之上做完整的框架,需要自己根据实现 onUpdate 生命周期。 最后的最后,还利用 attributeChangedCallback 生命周期监听自定义组件 html attribute 的变化,然后将其直接映射到对 this._props[name] 的变化,这是为什么呢? attributeChangedCallback(name, oldValue, newValue) { this._props[name] = newValue} 看下面的代码片段就知道原因了: const props = (this._props = shallowReactive({}))const template = factory.call(this, props)effect(() => { render(template(), root)}) 早在初始化时,就将 _props 创建为响应式变量,这样只要将其作为 lit-html 模版表达式的参数(对应 factory.call(this, props) 这段,而 factory 就是 defineComponent('my-child', ['msg'], (props) => { .. 的第三个参数),这样一来,只要这个参数变化了就会触发子组件的重渲染,因为这个 props 已经经过 Reactive 处理了。 总结vue-lit 实现非常巧妙,学习他的源码可以同时了解一下几种概念: reative。 web component。 string template。 模版引擎的精简实现。 生命周期。 以及如何将它们串起来,利用 70 行代码实现一个优雅的渲染引擎。 最后,用这种模式创建的 web component 引入的 runtime lib 在 gzip 后只有 6kb,但却能享受到现代化框架的响应式开发体验,如果你觉得这个 runtime 大小可以忽略不计,那这就是一个非常理想的创建可维护 web component 的 lib。 讨论地址是:精读《vue-lit 源码》· Issue ##396 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法 - 二叉搜索树》","path":"/wiki/WebWeekly/算法/《算法 - 二叉搜索树》.html","content":"当前期刊数: 203 二叉搜索树的特性是,任何一个节点的值: 都大于左子树任意节点。 都小于右子树任意节点。 因为二叉搜索树的特性,我们可以更高效的应用算法。 精读还记得 《算法 - 二叉树》 提到的 二叉树的最近公公祖先 问题吗?如果这是一颗二叉搜索树,是不是存在更巧妙的解法?你可以暂停先思考一下。 二叉搜索树的最近公共祖先二叉搜索树的最近公共祖先是一道简单题,题目如下: 给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。 百度百科中最近公共祖先的定义为:“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。” 第一个判断条件是相同的,即当前节点值等于 p 或 q 任意一个,则当前节点就是其最近公共祖先。 如果不是呢?同时考虑二叉搜索树与公共祖先的特性可以发现: 如果 p q 两个节点分别位于当前节点的左 or 右边,则当前节点符合要求。 如果 p q 值一个大于,一个小于当前节点,说明 p q 分布在当前节点左右两侧。 基于以上考虑,可以仅通过值大小来判断,因此题目就被简化了。 接下来看一道入门题,即如何验证一颗二叉树是二叉搜索树。 验证二叉搜索树验证二叉搜索树是一道中等题,题目如下: 给定一个二叉树,判断其是否是一个有效的二叉搜索树。 假设一个二叉搜索树具有如下特征: 节点的左子树只包含小于当前节点的数。 节点的右子树只包含大于当前节点的数。 所有左子树和右子树自身必须也是二叉搜索树。 这道题看上去就应该用非常优雅的递归来实现。 二叉搜索树最重要的就是对节点值的限制,我们如果能正确卡住每个节点的值,就可以判断了。 如何判断节点值是否正确呢?我们可以用递归的方式倒推,即从根节点开始,假设根节点值为 x,那么左树节点的值就必须小于 x,再往左,那么值就要小于(假设第一个左节点值为 x1) x1,右树也是一样判断,因此就可以写出答案: function isValidBST(node: TreeNode, min = -Infinity, max = Infinity) { if (node === null) return true // 判断值范围是否合理 if (node.val < min || node.val > max) return false // 继续递归,并且根据二叉搜索树特定,进一步缩小最大、最小值的锁定范围 return // 左子树值 max 为当前节点值 isValidBST(node.left, min, node.val) && // 右子树值 min 为当前节点值 isValidBST(node.right, node.val, max) &&} 接下来看一些简单的二叉搜索树操作问题,比如删除二叉搜索树中的节点。 删除二叉搜索树中的节点删除二叉搜索树中的节点是一道中等题,题目如下: 给定一个二叉搜索树的根节点 root 和一个值 key,删除二叉搜索树中的 key 对应的节点,并保证二叉搜索树的性质不变。返回二叉搜索树(有可能被更新)的根节点的引用。 一般来说,删除节点可分为两个步骤: 首先找到需要删除的节点; 如果找到了,删除它。 说明: 要求算法时间复杂度为 O(h),h 为树的高度。 要删除二叉搜索树的节点,找到节点本身并不难,因为如果值小了,就从左子树找;如果值大了,就从右子树找,这本身查找起来是非常简单的。难点在于,如何保证删除元素后,这棵树还是一颗二叉搜索树? 假设我们删除的是叶子结点,很显然,二叉搜索树任意子树都是二叉搜索树,我们又没有破坏其他节点的关系,因此直接删除就行了,最简单。 如果删除的不是叶子结点,那么谁来 “上位” 代替这个节点呢?题目要求复杂度为 O(h) 显然不能重新构造,我们需要仔细考虑。 假设删除的节点存在右节点,那么肯定从右节点找到一个代替值移上来,找谁呢?找右节点的最小值呀,最小值很好找的,找完代替后,相当于 问题转移为删除这个最小值节点,递归就完事了。 假设删除的节点存在左节点,但是没有右节点,那就从左节点找一个最大的替换掉,同理递归删除找到的节点。 可以看到,删除二叉搜索树,为了让二叉搜索树性质保持不变,需要不断进行重复子问题的递归删除节点。 当你掌握二叉搜索树特性后,可以尝试构造二叉搜索树了,下面就是一道让你任意构造二叉搜索树的题目:不同的二叉搜索树。 不同的二叉搜索树不同的二叉搜索树是一道中等题,题目如下: 给你一个整数 n ,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。 这道题重点在于动态规划思维 + 笛卡尔积组合的思维。 需要将所有可能性想象为确定了根节点后,左右子树到底有几种组合方式? 举个例子,假设 n=10,那么这 10 个节点,假设我取第 3 个节点为根节点,那么左子树有 2 个节点,右子树有 7 个节点,这种组合情况就有 DP(2) * DP(7) 这么多,假设 DP(n) 表示 n 个节点能组成任意二叉搜索树的数量。 这仅是第 3 个节点为根节点的情况,实际上每个节点作为根节点都是不同的树(轴对称也算不同的),那么我们就要从第 1 个节点计算到第 n 个节点。 因此答案就出来了,我们先考虑特殊情况 DP(0)=1 DP(1)=1,所以: function numTrees(n: number) { const dp: number[] = [1, 1] for (let i = 2; i <= n; i++) { for (let j = 1; j <= i; j++) { dp[i] += dp[j - 1] * dp[i - j] } } return dp[n]} 最后再看一道找值题,并不是找最大值,而是找第 k 大值。 二叉搜索树的第 K 大节点二叉搜索树的第 K 大节点是一道简单题,题目如下: 给定一棵二叉搜索树,请找出其中第 k 大的节点。 这道题之所以简单,是因为二叉搜索树的中序遍历是从小到大的,因此只要倒序中序遍历,就可以找到第 k 大的节点。 倒序中序遍历,即右、根、左。 这道题就解决啦。 总结二叉搜索树的特性很简单,就是根节点值夹在左右子树中间,利用这个特性几乎可以解决一切相关问题。 但通过上面几个例子可以发现,仅熟悉二叉搜索树特性还是不够的,一些题目需要结合二叉树中序遍历、公共祖先特征等通用算法思路结合来解决,因此学会融会贯通很重要。 讨论地址是:精读《算法 - 二叉搜索树》· Issue ##337 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法 - 二叉树》","path":"/wiki/WebWeekly/算法/《算法 - 二叉树》.html","content":"当前期刊数: 201 二叉树是一种数据结构,并且拥有种类复杂的分支,本文作为入门篇,只介绍一些基本二叉树的题型,像二叉搜索树等等不在此篇介绍。 二叉树其实是链表的升级版,即链表同时拥有两个 Next 指针,就变成了二叉树。 二叉树可以根据一些特性,比如搜索二叉树,将查找的时间复杂度降低为 logn,而且堆这种数据结构,也是一种特殊的二叉树,可以以 O(1) 的时间复杂度查找最大值或者最小值。所以二叉树的变种很多,都可以很好的解决具体场景的问题。 精读要入门二叉树,就必须理解二叉树的三种遍历策略,分别是:前序遍历、中序遍历、后序遍历,这些都属于深度优先遍历。 所谓前中后,就是访问节点值在什么时机,其余时机按先左后右访问子节点。比如前序遍历,就是先访问值,再访问左右;后续遍历就是先访问左右,再访问值;中序遍历就是左,值,右。 用递归方式遍历树非常简单: function visitTree(node: TreeNode) { // 三选一:前序遍历 // console.log(node.val) visitTree(node.left) // 三选一:中序遍历 // console.log(node.val) visitTree(node.right) // 三选一:后序遍历 // console.log(node.val)} 当然题目需要我们巧妙利用二叉树三种遍历的特性来解题,比如重建二叉树。 重建二叉树重建二叉树是一道中等题,题目如下: 输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。 例如 前序遍历 preorder = [3,9,20,15,7] 中序遍历 inorder = [9,3,15,20,7] 先给你二叉树前序与中序遍历结果,让你重建二叉树,这种逆向思维的题目就难了不少。 仔细观察遍历特性可以看出,我们也许能推测出一些关键节点的位置,再通过数组切割递归一下就能解题。 前序遍历第一个访问的一定是根节点,因此 3 一定是根节点,然后我们在中序遍历找到 3,这样 左边就是所有左子树的中序遍历结果,右边就是所有右子树的中序遍历结果,我们只要再找到 左子树的前序遍历结果与右子树的前序遍历结果,就可以递归了,终止条件是左或右子树只有一个值,那样就代表叶子节点。 那么怎么找左右子树的前序遍历呢?上面例子中,我们找到了 3 的左右子树的中序遍历结果,由于前序遍历优先访问左子树,因此我们数一下中序遍历中,3 左边的数量,只有一个 9,那么我们从前序遍历的 3,9,20,15,7 在 3 之后推一位,那么 9 就是左子树前序遍历结果,9 后面的 20,15,7 就是右子树的前序遍历结果。 最后只要递归一下就能解题了,我们将输入不断拆解为左右子树的的输入,直到达到终止条件。 解决此题的关键是,不仅要知道如何写前中后序遍历,还要知道前序遍历第一个节点是根节点,后序遍历最后一个节点是根节点,中序遍历以根节点为中心,左右分别是其左右子树,这几个重要延伸特征。 说完了反向,我们说正向,即递归一棵二叉树。 其实二叉树除了递归,还有一种常见的遍历方法是利用栈进行广度优先遍历,典型题目有从上到下打印二叉树。 从上到下打印二叉树从上到下打印二叉树是一道简单题,题目如下: 从上到下按层打印二叉树,同一层的节点按从左到右的顺序打印,每一层打印到一行。 这道题要求从左到右顺序打印,完全遵循广度优先遍历,我们可以在二叉树递归时,先不要急着读取值,而是按照左、中、右,遇到左右子树节点,就推入栈的末尾,利用 while 语句不断循环,直到栈空为止。 利用展开时追加到栈尾,并不断循环处理栈元素的方式非常优雅,而且符合栈的特性。 当然如果题目要求倒序打印,你就可以以 右、中、左 的顺序进行处理。 接下来看看深度优先遍历,典型题目是二叉树的深度。 二叉树的深度二叉树的深度是一道简单题,题目如下: 输入一棵二叉树的根节点,求该树的深度。从根节点到叶节点依次经过的节点(含根、叶节点)形成树的一条路径,最长路径的长度为树的深度。 由于二叉树有多种分支,在遍历前,我们并不知道哪条路线是最深的,所以必须利用递归尝试。 我们可以转换一下思路,用函数式语义方式来理解。假设我们有了这样一个函数 deep 来求二叉树深度,那么这个函数内容是什么呢?二叉树只可能存在左右子树,所以 deep 必然是左右子树的最大深度的最大值 +1(它自己)。 而求左右子树深度可以复用 deep 函数形成递归,我们只需要考虑边界情况,即访问节点不存在时,返回深度 0 即可,因此代码如下: function deep(node: TreeNode) { if (!node) return 0 return Math.max(deep(node.left), deep(node.right)) + 1} 从这可以看出,二叉树一般能用比较优雅的递归函数解决,如果你的解题思路不包含递归,往往就不是最优雅的解法。 类似优雅的题目还有,平衡二叉树。 平衡二叉树平衡二叉树是一道简单题,题目如下: 输入一棵二叉树的根节点,判断该树是不是平衡二叉树。如果某二叉树中任意节点的左右子树的深度相差不超过 1,那么它就是一棵平衡二叉树。 同理,我们设函数 isBalance 就是答案函数,那么一个平衡二叉树的特征,必然是其左右子树也是平衡的,所以可以写成: function isBalance(node: TreeNode) { if (root == null) return true return isBalance(node.left) && isBalance(node.right)} 但是哪里不对,左右子树平衡还不够啊,万一左右子树之间深度相差超过 1 就坏了,所以还要求一下左右子树的深度,我们复用上题的函数 deep,整理一下如下: function isBalance(node: TreeNode) { if (root == null) return true return isBalance(root.left) && isBalance(root.right) && Math.abs(deep(root.left) - deep(root.right)) < 2} 这道题提醒我们,不是所有递归都能完美写成仅自己调用自己的模式,不同题目要辅以其他函数,要敏锐的察觉到还缺少哪些条件。 还有一种递归,不是简单的函数自身递归自身,而是要构造出另一个函数进行递归,原因是递归参数不同。典型的题目有对称的二叉树。 对称的二叉树对称的二叉树是一道简单题,题目如下: 请实现一个函数,用来判断一棵二叉树是不是对称的。如果一棵二叉树和它的镜像一样,那么它是对称的。 我们要注意,一颗二叉树的镜像比较特殊,比如最左节点与最右节点互为镜像,但它们的父节点并不相同,因此 isSymmetric(tree) 这样的参数是无法子递归的,我们必须拆解为左右子树作为参数,让它们进行相等判断,在传参时,将父级不同,但互为镜像的左右节点传入即可。 所以我们必须起一个新函数 isSymmetricNew(left, right),将 left.left 与 right.right 对比,将 left.right 与 right.left 对比即可。 具体代码就不写了,然后注意一下边界情况即可。 这道题的重点是,由于镜像的关系,并不拥有相同的父节点,因此必须用一个新参数的函数进行递归。 那如果这道题反过来呢?要求构造一个二叉树镜像呢? 二叉树的镜像二叉树的镜像是一道简单题,题目如下: 请完成一个函数,输入一个二叉树,该函数输出它的镜像。 判断镜像比较容易,但构造镜像就要想一想了: 例如输入: 4 / \\ 2 7 / \\ / \\1 3 6 9镜像输出: 4 / \\ 7 2 / \\ / \\9 6 3 1 观察发现,其实镜像可以理解为左右子树互换,同时 其各子树的左右子树再递归互换,这就构成了一个递归: function mirrorTree(node: TreeNode) { if (node === null) return null const left = mirrorTree(node.left) const right = mirrorTree(node.right) node.left = right node.right = left return node} 我们要从下到上,因此先生成递归好的左右子树,再进行当前节点的互换,最后返回根节点即可。 接下来介绍一些有一定难度的经典题。 二叉树的最近公共祖先二叉树的最近公共祖先是一道中等题,题目如下: 给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。 题目很简短,也很明确,就是寻找最近的公共祖先。显然,根节点是所有节点的公共祖先,但不一定是最近的。 我们还是用递归,先考虑特殊情况:如果任意节点等于当前节点,那么当前节点一定就是最近公共祖先,因为另一个节点一定在其子节点中。 然后,利用递归思想思考,假设我们利用 lowestCommonAncestor 函数分别找到左右子节点的最近公共祖先会怎样? function lowestCommonAncestor(node, a, b) { const left = lowestCommonAncestor(node.left) const right = lowestCommonAncestor(node.right)} 如果左右节点都找不到,说明只可能当前节点是最近公共子节点: if (!left && !right) return node 如果左节点找不到,则右节点就是答案,否则相反: if (!left) return rightreturn left 这里巧妙利用了函数语义进行结果判断。 二叉树的右视图二叉树的右视图是一道中等题,题目如下: 给定一棵二叉树,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。 想象一束光照,从二叉树右侧向左照射,自上而下读取即是答案。 其实这道题可以认为是一道融合题。右侧的光束可以认为是分层照射的,那么当我们用广度优先算法遍历时,对于每一层,都找到最后一个节点打印,并且按顺序打印就是最终答案。 有一道二叉树的题目,是根据树的深度,按照广度优先遍历打印成二维数组,记录树的深度其实也有巧妙办法,即在栈尾追加元素时,增加一个深度 key,那么访问时自然就可以读到深度值。 完全二叉树的节点个数完全二叉树的节点个数是一道中等题,题目如下: 给你一棵 完全二叉树 的根节点 root ,求出该树的节点个数。 完全二叉树 的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1 ~ 2^h 个节点。 用递归解决这道题的话,关键要分几种情况探讨完全二叉树。 由于最底层可能没有填满,但最底层一定有节点,而且是按照从左到右填的,那么递归遍历左节点就可以获取树的最大深度,通过最大深度我们可以快速计算出节点个树,前提是二叉树必须是满的。 但最底层节点可能不满,那怎么办呢?分情况即可,首先,如果一直按照 node.right....right 递归获得右侧节点深度,发现和最大深度相同,那么就是一个满二叉树,直接计算出结果即可。 我们再看 node.right...left 的深度如果等于最大深度,说明 node.left 也就是左子树是个满二叉树,可以通过数学公式 2^n-1 快速算出节点个树。 如果不等于最大深度呢?则说明右子树深度减 1 是满二叉树,也可以通过数学公式快速计算节点个数,再通过递归计算另一边即可。 总结从题目中可以感受到,二叉树的解题魅力在于递归,二叉树问题中,我们可以同时追求优雅与答案。 讨论地址是:精读《算法 - 二叉树》· Issue ##331 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法 - 回溯》","path":"/wiki/WebWeekly/算法/《算法 - 回溯》.html","content":"当前期刊数: 200 如何尝试走迷宫呢?遇到障碍物就从头 “回溯” 继续探索,这就是回溯算法的形象解释。 更抽象的,可以将回溯算法理解为深度遍历一颗树,每个叶子结点都是一种方案的终态,而对某条路线的判断可能在访问到叶子结点之前就结束。 相比动态规划,回溯可以解决的问题更复杂,尤其是针对具有后效性的问题。 动态规划之所以无法处理有后效性问题,原因是其 dp(i)=F(dp(j)) 其中 0<=j<i 导致的,因为 i 通过 i-1 推导,如果 i-1 的某种选择会对 i 的选择产生影响,那么这个推导就是无效的。 而回溯,由于每条分支判断是相互独立的,互不影响,所以即便前面的选择具有后效性,这个后效性也可以在这条选择线路持续影响下去,而不影响其他分支。 所以回溯是一种适用性更广的算法,但相对的,其代价(时间复杂度)也更高,所以只有当没有更优算法时,才应当考虑回溯算法。 精读经过上述思考,回溯算法的实现思路就清晰了:递归或迭代。由于两者可以相互转换,而递归理解成本较低,因此我更倾向于递归方式解决问题。 这里必须提到一点,即工作与算法竞赛思维的区别:由于递归调用堆栈深度较大,整体性能不如迭代好,且迭代写法不如递归自然,所以做算法题时,为了提升那么一点儿性能,以及不经意间流露自己的实力,可能大家更倾向用迭代方式解决问题。 但工作中,大部分是性能不敏感场景,可维护性反而是更重要的,所以工程代码建议用更易理解的递归方式解决问题,把堆栈调用交给计算机去做。 其实算法代码追求更简短,能写成一行的绝不换行也是同样的道理,希望大家能在不同环境里自由切换习惯,而不要拘泥于一种风格。 用递归解决回溯的套路不止一种,我介绍一下自己常用的 TS 语言方法: function func(params: any[], results: any[] = []) { // 消耗 params 生成 currentResult const { currentResult, restParams } = doSomething(params); // 如果 params 还有剩余,则递归消耗,直到 params 耗尽为止 if (restParams.length > 0) func(restParams, results.concat(currentResult));} 这里 params 就类似迷宫后面的路线,而 results 记录了已走的最佳路线,当 params 路线消耗完了,就走出了迷宫,否则终止,让其它递归继续走。 所以回溯逻辑其实挺好写的,难在如何判断这道题应该用回溯做,以及如何优化算法复杂度。 先从两道入门题讲起,分别是电话号码的字母组合与复原 IP 地址。 电话号码的字母组合电话号码的字母组合是一道中等题,题目如下: 给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。 给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。 电话号码数字对应的字母其实是个映射表,比如 2 映射 a,b,c,3 映射 d,e,f,那么 2,3 能表示的字母组合就有 3x3=9 种,而要打印出比如 ad、ae 这种组合,肯定要用穷举法,穷举法也是回溯的一种,只不过每一种可能性都要而已,而复杂点儿的回溯可能并不是每条路径都符合要求。 所以这道题就好做了,只要构造出所有可能的组合就行。 接下来我们看一道类似,但有一定分支合法判断的题目,复原 IP 地址。 复原 IP 地址复原 IP 地址是一道中等题,题目如下: 给定一个只包含数字的字符串,用以表示一个 IP 地址,返回所有可能从 s 获得的 有效 IP 地址 。你可以按任何顺序返回答案。 有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 ‘.’ 分隔。 例如:”0.1.2.201” 和 “192.168.1.1” 是 有效 IP 地址,但是 “0.011.255.245”、”192.168.1.312” 和 “192.168@1.1“ 是 无效 IP 地址。 首先肯定一个一个字符读取,问题就在于,一个字符串可能表示多种可能的 IP,比如 25525511135 可以表示为 255.255.11.135 或 255.255.111.35,原因在于,11.135 和 111.35 都是合法的表示,所以我们必须用回溯法解决问题,只是回溯过程中,会根据读取数据动态判定增加哪些新分支,以及哪些分支是非法的。 比如读取到 [1,1,1,3,5] 时,由于 11 和 111 都是合法的,因为这个位置的数字只要在 0~255 之间即可,而 1113 超过这个范围,所以被忽略,所以从这个场景中分叉出两条路: 当前项:11,余项 135。 当前项:111,余项 35。 之后再递归,直到非法情况终止,比如以及满了 4 项但还有剩余数字,或者不满足 IP 范围等。 可见,只要梳理清楚合法与非法的情况,直到如何动态生成新的递归判断,这道题就不难。 这道题输入很直白,直接给出来了,其实不是每道题的输入都这么容易想,我们看下一道全排列。 全排列全排列是一道中等题,题目如下: 给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。 与还原 IP 地址类似,我们也是消耗给的输入,比如 123,我们可以先消耗 1,余下 23 继续组合。但与 IP 复原不同的是,第一个数字可以是 1 2 3 中的任意一个,所以其实在生成当前项时有所不同:当前项可以从所有余项里挑选,然后再递归即可。 比如 123 的第一次可以挑选 1 或 2 或 3,对于 1 的情况,还剩 23,那么下次可以挑选 2 或 3,当只剩一项时,就不用挑了。 全排列的输入虽然不如还原 IP 地址的输入直白,但好歹是基于给出的字符串推导而出的,那么再复杂点的题目,输入可能会拆解为多个,这需要你灵活思考,比如括号生成题目。 括号生成括号生成是一道中等题,题目如下: 数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。 示例:输入:n = 3 输出:[“((()))”,”(()())”,”(())()”,”()(())”,”()()()”] 这道题基本思路与上一题很像,而且由于题目问的是所有可能性,而不是最优解,所以无法用动规,所以我们考虑回溯算法。 上一道 IP 题目的输入是已知字符串,而这道题的输入就要你动动脑经了。这道题的输入是字符串吗?显然不是,因为输入是括号数量,那么只有一个括号数量就够了吗?不够,因为题目要求有效括号,那什么是有效括号?闭合的才是,所以我们想到用左右括号数量表示这个数字,即输入是 n,那么转化为 open=n, close=n。 有了输入,如何消耗输入呢?我们每一步都可以用一个左括号 open 或一个右括号 close,但第一个必须是 open,且当前已消耗 close 数量必须小于已消耗 open 数量时,才可以加上 close,因为一个 close 左边必须有个 open 形成合法闭合。 所以这道题就迎刃而解了。回顾来看,回溯的入参要能灵活思考,而这个思考取决于你的经验,比如遇到括号问题,下意识就直到拆解为左右括号。所以算法之间是相通的,适当的知识迁移可以事半功倍。 好了,在此我们先打住,其实不是所有题目都可以用回溯解决,但有些题目看上去只是回溯题目的变种,但其实不然。我们回到上一道全排列题,与之比较像的是 下一个排列,这道题看上去好像是基于全排列衍生的,但却无法用回溯算法解决,我们看看这道题。 下一个排列下一个排列是一道中等题,题目如下: 实现获取 下一个排列 的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。 如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。 必须 原地 修改,只允许使用额外常数空间。 比如: 输入:nums = [1,2,3] 输出:[1,3,2] 输入:nums = [3,2,1] 输出:[1,2,3] 如果你在想,能否借鉴全排列的思想,在全排列过程中自然推导出下一个排列,那大概率是想不通的,因为从整体推导到局部的效率太低,这道题直接给出一个局部值,我们必须用相对 “局部的方法” 快速推导出下一个值,所以这道题无法用回溯算法解决。 \b 对于 3,2,1 的例子,由于已经是最大排列了,所以下个排列只能是初始化的 1,2,3 升序,这个是特例。除此之外,都有下一个更大排列,以 1,2,3 为例,更大的是 1,3,2 而不是 2,1,3。 我们再观察长一点的例子,比如 3,2,1,4,5,6,可以发现,无论前面如何降序,只要最后几个是升序的,只要把最后两个扭转即可:3,2,1,4,6,5。 如果是 3,2,1,4,5,6,9,8,7 呢?显然 9,8,7 任意相邻交换都会让数字变得更小,不符合要求,我们还是要交换 5,6 .. 不 6,9,因为 65x 比 596 要大更多。到这里我们得到几个规律: 尽可能交换后面的数。交换 5,6 会比交换 6,9 更大,因为 6,9 更靠后,位数更小。 我们将 3,2,1,4,5,6,9,8,7 分为两段,分别是前段 3,2,1,4,5,6 和后段 9,8,7,我们要让前段尽可能大的数和后段尽可能小的数交换,同时还要保证,后段尽可能小的数比前段尽可能大的数还要 大。 为了满足第二点,我们必须从后向前查找,如果是升序就跳过,直到找到一个数字 j 比 j-1 小,那么前段作为交换的就是第 j 项,后段要找一个最小的数与之交换,由于搜索的算法导致后段一定是降序的,因此从后向前找到第一个比 j 大的项交换即可。 最后我们发现,交换后也不一定是完美下一项,因为后段是降序的,而我们已经把前面一个尽可能最小的 “大” 位改大了,后面一定要升序才满足下一个排列,因此要把后段进行升序排列。 因为后段已经满足降序了,因此采用双指针交换法相互对调即可变成升序,这一步千万不要用快排,会导致整体时间复杂度提高 O(nlogn)。 最后由于只扫描了一次 + 反转后段一次,所以算法复杂度是 O(n)。 从这道题可以发现,不要轻视看似变种的题目,从全排列到下一个排列,可能要完全换一个思路,而不是对回溯进行优化。 我们继续回到回溯问题,回溯最经典的问题就是 N 皇后,也是难度最大的题目,与之类似的还有解决数独问题,不过都类似,我们这次还是以 N 皇后作为代表来理解。 N 皇后问题N 皇后问题是一道困难题,题目如下: n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。 给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。 每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。 皇后的攻击范围非常广,包括横、纵、斜,所以当 n<4 时是无解的,而神奇的时,n>=4 时都有解,比如下面两个图: 这道题显然具有 “强烈的” 后效性,因为皇后攻击范围是由其位置决定的,换而言之,一个皇后位置确定后,其他皇后的可能摆放位置会发生变化,因此只能用回溯算法。 那么如何识别合法与非法位置呢?核心就是根据横、纵、斜三种攻击方式,建立四个数组,分别存储哪些行、列、撇、捺位置是不能放置的,然后将所有合法位置都作为下一次递归的可能位置,直到皇后放完,或者无位置可放为止。 容易想到的就是四个数组,分别存储被占用的下标,这样的话,只是递归中条件判断分支复杂一些,其它其实并无难度。 这道题的空间复杂度进阶算法是,利用二进制方式,使用 4 个数字 代替四个下标数组,每个数组转化为二进制时,1 的位置代表被占用,0 的位置代表未占用,通过位运算,可以更快速、低成本的进行位置占用,与判断当前位置是否被占用。 这里只提一个例子,就可以感受到二进制魅力: 由于按照行看,一行只能放一个皇后,所以每次都从下一行看起,因此行限制就不用看了(至少下一行不可能和前面的行冲突),所以我们只要记录列、撇、捺三个位置即可。 不同之处在于,我们采用二进制的数字,只要三个数字即可表示列、撇、捺。二进制位中的 1 表示被占用,0 表示不被占用。 比如列、撇、捺分别是变量 x,y,z,对应二进制可能是: 0000001 0010000 0001100 “非” 逻辑是任意为 1 就是 1,因此 “非” 逻辑可以将所有 1 合并,即 x | y | z 即 0011101。 然后将这个结果取反,用非逻辑,即 ~(x | y | z),结果是 1100010,那这里所有的 1 就表示可放的位置,我们记这个变量为 p,通过 p & -p 不断拿最后一位 1 得到安放位置,即可调用递归了。 从这道题可以发现,N 皇后难度不在于回溯算法,而在于如何利用二进制写出高效的回溯算法。所以回溯算法考察的比较综合,因为算法本身很模式化,而且相对比较 “笨拙”,所以需要将更多重心放在优化效率上。 总结回溯算法本质上是利用计算机高速计算能力,将所有可能都尝试一遍,唯一区别是相对暴力解法,可能在某个分支提前终止(枝剪),所以其实是一个较为笨重的算法,当题目确实具有后效性,且无法用贪心或者类似下一排列这种巧妙解法时,才应该采用。 最后我们要总结对比一下回溯与动态规划算法,其实动态规划算法的暴力递归过程就与回溯相当,只是动态规划可以利用缓存,存储之前的结果,避免重复子问题的重复计算,而回溯因为面临的问题具有后效性,不存在重复子问题,所以无法利用缓存加速,所以回溯算法高复杂度是无法避免的。 回溯算法被称为 “通用解题方法”,因为可以解决许多大规模计算问题,是利用计算机运算能力的很好实践。 讨论地址是:精读《算法 - 回溯》· Issue ##331 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法 - 滑动窗口》","path":"/wiki/WebWeekly/算法/《算法 - 滑动窗口》.html","content":"当前期刊数: 199 滑动窗口算法是较为入门题目的算法,一般是一些有规律数组问题的最优解,也就是说,如果一个数组问题可以用动态规划解,但又可以使用滑动窗口解决,那么往往滑动窗口的效率更高。 双指针也并不局限在数组问题,像链表场景的 “快慢指针” 也属于双指针的场景,其快慢指针滑动过程中本身就会产生一个窗口,比如当窗口收缩到某种程度,可以得到一些结论。 因此掌握滑动窗口非常基础且重要,接下来按照我的经验给大家介绍这个算法。 精读滑动窗口使用双指针解决问题,所以一般也叫双指针算法,因为两个指针间形成一个窗口。 什么情况适合用双指针呢?一般双指针是暴力算法的优化版,所以: 如果题目较为简单,且是数组或链表问题,往往可以尝试双指针是否可解。 如果数组存在规律,可以尝试双指针。 如果链表问题限制较多,比如要求 O(1) 空间复杂度解决,也许只有双指针可解。 也就是说,当一个问题比较有规律,或者较为简单,或较为巧妙时,可以尝试双指针(滑动窗口)解法。 我们还是拿例子说明,首先是两数之和。 两数之和两数之和是一道简单题,实际上和滑动窗口没什么关系,但为了引出三数之和,还是先讲这道题。题目如下: 给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。 你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。 暴力解法就是穷举所有两数之和,发现和为 target 结束,显然这种做法有点慢,我们换一种思路。 由于可以用空间换时间,又只有两个数,我们可以对题目进行转化,即通过一次遍历,将 nums 每一项都减去 target,然后找到后面任意一项值为前面的结果,即表示它们和为 target。 可以用哈希表 map 加速查询,即将每一项 target - num 作为 key,如果后面任何一个 num 作为 key 可以在 map 中找到,则得解,且上一个数的原始值可以存在 map 的 value 中。这要仅需遍历一次,时间复杂度为 O(n)。 之所以说这道题,是因为这道题是单指针,即只有一个指针在数组中移动,并配合哈希表快速求解。对于稍微复杂的问题,单指针就不够了,需要用双指针解决(一般来说不会用到三或以上指针),那复杂点的题目就是三数之和了。 三数之和三数之和是一道中等题,别以为只是两数之和的加强版,其思路完全不同。题目如下: 给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。 由于超过了两个数,所以不能像双指针一样求解了,因为即便用了哈希表存储,也会在遍历时遇到 “两数之和” 的问题,而哈希表方案无法继续嵌套使用,即无法进一步降低复杂度。 为了降低时间复杂度,我们希望只遍历一次数组,这就需要数组满足一定条件我们才能用滑动窗口,所以我们对数组进行排序,使用快排的时间复杂度为 O(nlogn),时间复杂度已超出两数之和,不过因为题目复杂,这个牺牲是无法避免的。 假设从小到大排序,那我们就拿到一个递增数组了,此时经典滑动窗口方法就可用了!怎么滑动呢?首先创建两个指针,分别叫 left 与 right,通过不断修改 left 与 right,让它们在数组间滑动,这个窗口大小就是符合题目要求的,当滑动完毕时,返回所有满足条件的窗口即可,记录其实很简单,只要在滑动过程中记录一下就行。 首先排除异常值,即数组长度过小,然后对于常规情况,我们拿一个全局变量存储当前窗口数的和,这样 right + 1 只要累加 nums[right+1],left + 1 只要减去 nums[left] 即可快速拿到求和。 由于需要考虑所有情况,所以需要一次数组遍历,对于每次遍历的起始点 i,如果 nums[i] > 0 则直接跳过,因为数组排序后是递增的,后面的和只会永远大于 0;否则进行窗口滑动,先形成三个点 [i, i+1, n-1],这样保持 i 不动,不断包夹后两个数字即可,只要它们的和大于 0,就将第三个点左移(数字会变小),否则将第二个点右移(数字会变大),其实第二个和第三个数就是滑动窗口。 这样的话时间复杂度是 O(n²),因为存在两次遍历,忽略快排较小的时间复杂度。 那么四数之和,五数之和呢? 四数之和该题和三数之和完全一样,除了要求变成四个数。 首先还是排序,然后双重递归,即确定前两个数不变,不断包夹后两个数,后两个数就是 i+1 和 n-1,算法和三数之和一样,所以最终时间复杂度为 O(n³)。 那么 N 数之和(N > 2)都可以采用这个思路解决。 为什么没有更优的方法呢?我想可能因为: 无论几数之和,快排一次时间复杂度都是固定的,所以沿用三数之和的方案其实占了排序算法便宜。 滑动窗口只能用两个指针进行移动,而没有三指针但又保持时间复杂度不变的窗口滑动算法存在。 所以对于 N 数之和,通过排序付出了 O(nlogn) 时间复杂度之后,可以用滑动窗口,将 2 个数时间复杂度优化为 O(n),所以整体时间复杂度就是 O(N - 2 + 1 个 n),即 O(N-1 个 n),而最小的时间复杂度 O(n²) 比 O(nlogn) 大,所以总是忽略快排的时间复杂度,所以三数之和时间复杂度是 O(n²),四数之和时间复杂度为 O(n³),依此类推。 可以看到,我们从最简单的两数之和,到三数之和、四数之和,跨入了滑动窗口的门槛,本质上是利用排序后数组有序的特性,让我们在不用遍历数组的前提下,可以对窗口进行滑动,这是滑动窗口算法的核心思想。 为了加强这个理解,再看一道类似的题目,无重复字符的最长子串。 无重复字符的最长子串无重复字符的最长子串是一道中等题,题目如下: 给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。 由于最长子串是连续的,所以显然可以考虑滑动窗口解法。其实确定了滑动窗口解法后,问题很简单,只要设定 left 和 right,并用一个哈希 Set 记录哪些元素存在过,在过程中记录最大长度,并尝试 right 右移,如果右移过程中发现出现重复字符,则 left 右移,直到消除这个重复字符为止。 解法并不难,但问题是,我们要想清楚,为什么用滑动窗口遍历一次就可以做到 不重不漏?即这道题时间复杂度只有 O(n) 呢? 只要想明白两个问题: 由于子串是连续的,既然不存在跳跃的情况,只要一次滑动窗口内能包含所有解,就涵盖了所有情况。 一次滑动窗口内不包含什么?由于我们只将 right 右移,且出现重复后尝试将 left 右移到不重复后,right 再继续右移,这忽略了出现重复后, right 左移的情况。 我们重点看二个问题,显然,如果 abcd 这四个连续的字符不重复,那么 left 右移后,bcd 也显然不重复,所以如果此时就可以将 right 右移形成 bcda 的窗口继续找下去,而不需要尝试 bc 这种情况,因为这种情况虽然不重复,但一定不是最优解。 好了,通过这个例子我们看到,滑动窗口如何缩小窗口范围其实不难,但更要注重的是,背后对于为什么可以用滑动窗口的思考,滑动窗口有没有做到不重不漏,如果没有想清楚,可能整个思路都错了。 那么滑动窗口的应用已经说透了?其实没有,我们上面只说了缩小窗口这种比较单一的脑回路,其实双指针构成的滑动窗口不一定都是那么正常滑的,一种有意思的场景是快慢指针,即是以相对速度决定窗口如何滑动。 关于快慢指针,经典的题目有环形链表、删除有序数组中的重复项。 环形链表环形链表是一道简单题,题目如下: 给定一个链表,判断链表中是否有环。 如果不是进阶要求空间复杂度 O(1),我们可以在遍历时稍稍 “污染” 一下原始链表,这样总能发现是否走了回头路。 但要求空间开销必须是常数,我们不得不考虑快慢指针。说实话第一次看到这道题时,如果能想到快慢指针的解法,绝对是相当聪明的,因为必须要有知识迁移的能力。怎么迁移呢?想象学校在开运动会,相信每次都有一个跑的最慢的同学,慢到被最快的同学追了一圈。 等等,操场不就是环形链表吗?只要有人跑得慢,就会被跑得快的追上,追上不就是相遇了吗? 所以快慢指针分别跑,只要相遇则判定为环形链表,否则不是环形链表,且一定有一个指针先走完。 那么细枝末节就是优化效率了,慢指针到底慢多少呢? 有人会说,运动会上,跑步慢的人如果想被快的人追上,最好就不要跑。对,但环形链表问题中,链表不是操场,可能只有某一段是环,也就是跑步慢的人至少要跑到环里,才可能与跑得快人的相遇,但跑得慢的人又不知道哪里开始成环,这就是难点。 你有没有想过,为什么快排用二分法,而不是三分法?为什么每次中间来一刀,可以最快排完?原因是二分可以用最小的 “深度” 将数组切割为最小粒度。那么同理,快慢指针中,慢指针要想被尽快追上,速度可能最好是快指针的一半。那从逻辑上分析,为什么呢? 直观来看,如果慢指针太慢,可能大部分时间都在进入环形之前的位置转悠,快指针虽然快,但永远在环里跑,所以总是无法遇到慢指针,这给我们的启示是,慢指针不能太慢;如果慢指针太快,几乎速度和快指针一样,就像两个运动员都互不相让的争夺第一一样,他们真的想相遇,估计得连续跑几个小时吧,所以慢指针也不能过快。所以这样分析下来,慢指针只能取折中的一半速度。 但用一半的慢速真的能最快相遇吗?不一定,举一个例子,假设链表是完美环形,一共有 [1,6] 共 6 个节点,那么慢指针一次走 1 步,快指针一次走 2 步,那么一共是 2,3 3,5 4,1 5,3 6,5 1,1 共走 6 步,但如果快指针一次走 3 步呢?一共是 2,4 3,1 4,4 3 步。这么说一般速度不一定最优?其实不是的,计算机在链表寻址时,节点访问的消耗也要考虑进去,后者虽然看上去更快,但其实访问链表 next 的次数更多,对计算机来说,还不如第一种来得快。 所以准确来说,不是快指针比慢指针快一倍速度,而是慢指针一次走一步,快指针一次走两步最优,因为相遇时,总移动步数最少。 再说一个简单问题,即用快慢指针判断链表中倒数第k个节点或者链表中点。 判断链表中点快指针是慢指针速度 2 倍,当快指针到达尾部,慢指针的位置就是链表中点。 链表中倒数第k个节点链表中倒数第k个节点是一道简单题,题目如下: 输入一个链表,输出该链表中倒数第 k 个节点。为了符合大多数人的习惯,本题从 1 开始计数,即链表的尾节点是倒数第 1 个节点。 这道题就是判断链表中点的变种,只要让慢指针比快指针慢 k 个节点,当快指针到达末尾时,慢指针就指向倒数第 k+1 个节点了。这道题注意一下数数别数错了即可。 接下来终于说道快慢指针的另一种经典用法题型,删除有序数组中的重复项了。 删除有序数组中的重复项删除有序数组中的重复项是一道简单题,题目如下: 给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。 这道题,要原地删除重复元素,并返回长度,所以只能用快慢指针。但怎么用呢?快多少慢多少? 其实这道题快多少慢多少并不像前面题目一样预设好了,而是根据遇到的实际数字来判断。 我们假设慢指针是 slow 快指针是 fast,注意变量命名也有意思,同样是双指针问题,有的是 slow right,有的是 slow fast,重点在于用何种方法移动指针。 我们只要让 fast 扫描完全表,把所有不重复的挪到一起就好了,这样时间复杂度是 O(n),具体做法是: 让 slow 和 fast 初始都指向 index 0。 由于是 有序数组,所以就算有重复也一定连在一起,所以可以让 fast 直接往后扫描,只有遇到和 slow 不同的值,才把其和 slow+1 交换,然后 slow 自增,继续递归,直到 fast 走到数组尾部结束。 做完这套操作后,slow 的下标值就是答案。 可以看到,这道题对于慢指针要如何慢,其实是根据值来判断的,如果 fast 的值与 slow\b 一样,那么 slow 就一直等着,因为相同的值要被忽略掉,让 fast 走就是在跳过重复值。 说完了常见的双指针用法,我们再来看一些比较难啃的特殊问题,这里主要讲两个,分别是 盛最多水的容器 与 接雨水。 盛最多水的容器盛最多水的容器\b是一道中等题,题目如下: 给你 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。 建议先仔细读一读题目再继续,这道题相对比较复杂。 好了,为什么说这是一道双指针题目呢?因为我们看怎么计算容纳水的体积?其实这道题就简化为长乘宽。 长度就是选取的两个柱子的间距,宽就是其中最短柱子的高度。问题就是,虽然柱子间距越远,长度越大,但宽度不一定最大,一眼是没法看出来最优解的。 所以还是得多次尝试,那怎么样可以用最少的尝试次数,但又不重不漏呢?定义 left right 两个指针,分别指向 0 与 n-1 即首尾两个位置,此时长度是最大的(柱子间距离是最远的),接下来尝试一下别的柱子,试哪个呢? 较长的那个?如果新的比较短的更短,那么宽度更短了;如果新的比较短的更长,也没用,因为较短的决定了水位。 较短的那个?如果新的较长,那么才有机会整体体积更大。 所以我们移动较短的那个,并每次计算一下体积,最后当两根柱子相遇时结束,过程中最大体积就是全局最大体积。 这道题双指针的移动规则比较巧妙,与上面普通题目不一样,重点不是在是否会运用滑动窗口算法,而是能否找到移动指针的规则。 当然你可能会说,为什么两个指针要定义在最两端,而非别的地方?因为这样就无法控制变量了。 如果指针选在中间位置,那么指针外移时,柱子的间距与柱子长度同时变化,就很难找到一条完美路线。比如我们移动较短的柱子,是因为较短的柱子确定了最低水位,改变它,可能让最低水位变高,但问题是两根柱子的间距也在变大,这样移动较短还是较长的柱子哪个更优就说不准了。 说实话这种方法不太容易想到,需要多找几种选择尝试才能发现。当然,算法如果按照固定套路就能推导出来,也就没有难度了,所以要接受这种思维跳跃。 接下来我们看一道更特殊的滑动窗口问题,接雨水,它甚至分为多段滑动窗口。 接雨水接雨水是一道困难题,题目如下: 给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。 与盛雨水不同,这道接雨水看的是整体,我们要算出能接的所有水的数量。 其实相比上一道题,这道题还算比较好切入,因为我们从左到右计算即可。思考发现,只有产生了 “凹槽” 才能接到雨水,而凹槽由它两边最高的柱子决定,那什么范围算一段凹槽呢? 显然凹槽是可以明确分组的,一个凹槽也无法被分割为多个凹槽,就像你看水坑一样,无论有多少,多深的坑在一起,总能一个一个数清楚,所以我们就从左到右开始数。 怎么数凹槽呢?用滑动窗口办法,每个窗口就是一个凹槽,那么窗口的起点 left 就是左边第一根柱子,有以下情况: 如果直接相邻的右边柱子更高(或一样高),那从它开始向右看,根本无法接雨水,所以直接抛弃,left++。 如果直接相邻的右边柱子更矮,那就有产生凹槽的机会。 那么继续往右看,如果右边一直都更矮,那也接不到雨水。 如果右边出现一个高一些的,就可以接到雨水,那问题是怎么算能接多少,以及找到哪结束呢? 只要记录最左边柱子高度,右边柱子的结束判断条件是 “遇到一个与最左边一样高的柱子”,因为一个凹槽能接多少水,取决于最短的柱子。当然,如果右边没有柱子了,虽然比最左边低一点,但只要比最深的高,也算一个结束点。 这道题,一旦遇到凹槽结束点,left 就会更新,开始新的一轮凹槽计算,所以存在多个滑动窗口。从这道题可以看出,滑动窗口题型相当灵活,不仅判断条件因题而异,窗口数量可能也有多个。 总结滑动窗口本质是双指针的玩法,不同题目有不同的套路,从最简单的按照规律包夹,到快慢指针,再到无固定套路的因题而异的特殊算法。 其实按照规律包夹的套路属于碰撞指针范畴,一般对于排序好的数组,可以一步一步判断,或者用二分法判断,总之不用根据整体遍历来判断,效率自然高。 快慢指针也有套路可循,但具体快多少,或者慢多少,可能具体场景要具体看。 对于无固定套路的滑动窗口,就要根据题目仔细品味啦,如果所有套路都能总结出来,算法也少了乐趣。 讨论地址是:精读《算法 - 滑动窗口》· Issue ##328 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法题 - 地下城游戏》","path":"/wiki/WebWeekly/算法/《算法题 - 地下城游戏》.html","content":"当前期刊数: 286 今天我们看一道 leetcode hard 难度题目:地下城游戏。 恶魔们抓住了公主并将她关在了地下城 dungeon 的 右下角 。地下城是由 m x n 个房间组成的二维网格。我们英勇的骑士最初被安置在 左上角 的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。 骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。 有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为负整数,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为正整数,则表示骑士将增加健康点数)。 为了尽快解救公主,骑士决定每次只 向右 或 向下 移动一步。 返回确保骑士能够拯救到公主所需的最低初始健康点数。 注意:任何房间都可能对骑士的健康点数造成威胁,也可能增加骑士的健康点数,包括骑士进入的左上角房间以及公主被监禁的右下角房间。 输入:dungeon = [[-2,-3,3],[-5,-10,1],[10,30,-5]] 输出:7 解释:如果骑士遵循最佳路径:右 -> 右 -> 下 -> 下 ,则骑士的初始健康点数至少为 7 。 思考挺像游戏的一道题,首先只能向下或向右移动,所以每个格子可以由上面或左边的格子移动而来,很自然想到可以用动态规划解决。 再想一想,该题必须遍历整个地下城而无法取巧,因为最低健康点数无法由局部数据算出,这是因为如果不把整个地下城走完,肯定不知道是否有更优路线。 动态规划二维迷宫用两个变量 i j 定位,其中 dp[i][j] 描述第 i 行 j 列所需的最低 HP。 但最低所需 HP 无法推断出是否能继续前进,我们还得知道当前 HP 才行,比如: // 从左到右走3 -> -5 -> 6 -> -9 在数字 6 的位置所需最低 HP 是 3,但我们必须知道在 6 时勇者剩余 HP 才能判断 -9 会不会直接导致勇者挂了,因此我们将 dp[i][j] 结果定义为一个数组,第一项表示当前 HP,第二项表示初始所需最低 HP。 代码实现如下: function calculateMinimumHP(dungeon: number[][]): number { // dp[i][j] 表示 i,j 位置 [当前HP, 所需最低HP] const dp = Array.from(dungeon.map(item => () => [0, 0])) // dp[i][j] = 所需最低HP最低(dp[i-1][j], dp[i][j-1]) dp[0][0] = [ dungeon[0][0] > 0 ? 1 + dungeon[0][0] : 1, dungeon[0][0] > 0 ? 1 : 1 - dungeon[0][0] ] for (let i = 0; i < dungeon.length; i++) { for (let j = 0; j < dungeon[0].length; j++) { if (i === 0 && j === 0) { continue } const paths = [] if (i > 0) { paths.push([i - 1, j]) } if (j > 0) { paths.push([i, j - 1]) } const pathResults = paths.map(path => { let leftMaxHealth = dp[path[0]][path[1]][0] + dungeon[i][j] // 剩余HP大于 0 则无需刷新最低HP,否则尝试刷新取最大值 let lowestNeedHealth = dp[path[0]][path[1]][1] if (leftMaxHealth <= 0) { // 最低要求HP补上差价 lowestNeedHealth += 1 - leftMaxHealth // 最低需要HP已补上,所以剩余HP也变成了 1 leftMaxHealth = 1 } return [leftMaxHealth, lowestNeedHealth] }) // 找到 pathResults 中 lowestNeedHealth 最小项 let minLowestNeedHealth = Infinity let minIndex = 0 pathResults.forEach((pathResult, index) => { if (pathResult[1] < minLowestNeedHealth) { minLowestNeedHealth = pathResult[1] minIndex = index } }) dp[i][j] = [pathResults[minIndex][0], pathResults[minIndex][1]] } } return dp[dungeon.length - 1][dungeon[0].length - 1][1]}; 首先计算初始位置 dp[0][0],因为只看这一个点,因此如果有恶魔,最少初始 HP 为能击败恶魔后自己剩 1 HP 就行了,如果房间是空的,至少自己 HP 得是 1(否则勇者进迷宫之前就挂了),如果有魔法球,那么初始 HP 为 1(一样防止进迷宫前挂了)。 初始 HP 稍有不同,如果房间是空的或者有恶魔,那打完恶魔之后最多剩 1 HP 最经济,所以此时 HP 初始值就是 1,如果有魔法球,那么一方面为了防止进入迷宫前自己就挂了,得有个初始 1 的 HP,魔法球又必须得吃,所以 HP 是 1 + 魔法球。 接着就是状态转移方程了,由于 dp[i][j] 可以由 dp[i-1][j] 或 dp[i][j-1] 移动得到(注意 i 或 j 为 0 时的场景),因此我们判断一下从哪条路过来的最低初始 HP 最低就行了。 如果进入当前房间后,房间是空的,有魔法球,或者当前 HP 可以打败恶魔,则不影响最低初始 HP,如果当前 HP 不足以击败恶魔,则我们把缺的 HP 给勇者在初始时补上,此时极限一些还剩 1 HP,得到一个最经济的结果。 然后我们提交代码发现,无法 AC!下面是一个典型挂掉的例子: 1 -3 30 -2 0-3 -3 -3 我们把 DP 中间过程输出,发现右下角的 5 大于最优答案 3. [ [ 2, 1 ], [ 1, 3 ], [ 4, 3 ] [ 2, 1 ], [ 1, 2 ], [ 1, 2 ] [ 1, 3 ], [ 1, 5 ], [ 1, 5 ]] 观察发现,勇者先往右走到头,再往下走到头答案就是 3,问题出在 i=1,j=2 处,也就是中间行最右列的 [1, 2]。但从这一点来看,勇者从左边过来比从上面过来需要的初始 HP 少,因为左边是 [1, 2] 上面是 [4, 3],但这导致了答案不是最优解,因为此时剩余 HP 不够,右下角是一个攻击为 3 的恶魔,而如果此时我们选择了初始 HP 高一些的 [4, 3],换来了更高的当前 HP,在不用补初始 HP 的情况就能把右下角恶魔干掉,整体是更划算的。 如果此时我们在玩游戏,读读档也就能找到最优解了,但悲剧的是我们在写一套算法,我们发现当前 DP 项居然还可能由后面的值(攻击力为 3 的恶魔)决定! 用专业的话来说就是有后效性导致无法使用 DP。 我们在判断每一步最优解时,其实有两个同等重要的因素影响判断,一个是初始最少所需 HP,它的重要度不言而喻,我们最终就希望这个答案尽可能小;但还有当前 HP 呢,当前 HP 高意味着后面的路会更好走,但我们如果不往后看,就不知道后面是否有恶魔,自然也不知道要不要留着高当前 HP 的路线,所以根本就无法根据前一项下结论。 因为考虑的因素太多了,我们得换成游戏制作者的视角,假设作为游戏设计者,而不是玩家,你会真的从头玩一遍吗?如果真的要设计这种条件很极限的地下城,设计者肯定从结果倒推啊,结果我们勇者就只剩 1 HP 了,至于路上会遇到什么恶魔或者魔法球,反过来倒推就一切尽在掌握了。所以我们得采用从右下角开始走的逆向思维。 逆向思维为什么从结果倒推,DP 判断条件就没有后效性了呢? 先回忆一下从左上角出发的情况,为什么除了最低初始 HP 外还要记录当前 HP?原因是当前 HP 决定了当前房间的怪物勇者能否打得过,如果打不过,我们得扩大最低初始 HP 让勇者能在仅剩 1 HP 的情况险胜当前房间的恶魔。但这个当前 HP 值不仅要用来辅助计算最低初始 HP,它还有一个越大越好的性质,因为后面房间可能还有恶魔,得留一些 HP 预防风险,而 “最低初始 HP” 尽可能低与 “当前 HP” 尽可能高,这两个因素无法同时考虑。 那为什么从右下角,以终为始的考虑就可以少判断一个条件了呢?首先最低初始 HP 我们肯定要判断的,因为答案要的就是这个,那当前 HP 呢?当前 HP 重要吗?不重要,因为你已经拯救到公主了,而且是以最低 HP 1 点的状态救到了公主,按故事路线逆着走,遇到恶魔房间,恶魔攻击是多少我就给你加多少初始 HP,遇到魔法球恢复了我就给你扣对应初始 HP,总之能让你正好战胜恶魔,魔法球补给你的 HP 我也扣掉,就可以了。核心区别是,此时当前 HP 已经不会影响最低初始 HP 了,因为初始 HP 就是从头推的,我们反着走地下城,每次实际上都是在判断这个点作为起点时的状态,所以与之前的路径无关。 代码很简单,如下: function calculateMinimumHP(dungeon: number[][]): number { // dp[i][j] 表示 i,j 位置最少HP const dp = Array.from(dungeon.map(item => () => [0, 0])) // 右下角起始 HP 1,遇到怪物加血,遇到魔法球扣血,实际上就是 -dungeon 计算 const si = dungeon.length - 1 const sj = dungeon[0].length - 1 dp[si][sj] = dungeon[si][sj] > 0 ? 1 : 1 - dungeon[si][sj] for (let i = si; i >= 0; i--) { for (let j = sj; j >= 0; j--) { if (i === si && j === sj) { continue } const paths = [] if (i < si) { paths.push([i + 1, j]) } if (j < sj) { paths.push([i, j + 1]) } const pathResults = paths.map(path => dp[path[0]][path[1]] - dungeon[i][j]) // 选出最小 HP 作为 dp[i][j],但不能小于 1 dp[i][j] = Math.max(Math.min(...pathResults), 1) } } return dp[0][0]}; 逆向思维为什么就能减少当前 HP(或者说路径和,或者说所有之前节点的影响)判断呢?我猜你大概率还是没彻底明白。因为这个思考非常关键,可以说是这道题 99% 的困难所在,还是画个图解释一下: 上图是勇者正常探险的思路,下面是逆向(或公主救勇者)的思路。 总结该题很容易想到使用动态规划解决,但因为目标是求最低的初始健康点需求,所以按照勇者路径走的话,后续未探索的路径会影响到目标,所以我们需要从公主角度反向寻找勇者,才可以保证动态规划的每个判断点都只考虑一个影响因素。 讨论地址是:精读《算法 - 地下城游戏》· Issue ##498 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法题 - 最小覆盖子串》","path":"/wiki/WebWeekly/算法/《算法题 - 最小覆盖子串》.html","content":"当前期刊数: 285 今天我们看一道 leetcode hard 难度题目:最小覆盖子串。 题目给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。 注意: 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。如果 s 中存在这样的子串,我们保证它是唯一的答案。 示例 1: 输入:s = "ADOBECODEBANC", t = "ABC"输出:"BANC"解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。 思考最容易想到的思路是,s 从下标 0~n 形成的子串逐个判断是否满足条件,如: ADOBEC.. DOBECO.. OBECOD.. 因为最小覆盖子串是连续的,所以该方法可以保证遍历到所有满足条件的子串。代码如下: function minWindow(s: string, t: string): string { // t 剩余匹配总长度 let tLeftSize = t.length // t 每个字母对应出现次数表 const tCharCountMap = {} for (const char of t) { if (!tCharCountMap[char]) { tCharCountMap[char] = 0 } tCharCountMap[char]++ } let globalResult = '' for (let i = 0; i < s.length; i++) { let currentResult = '' let currentTLeftSize = tLeftSize const currentTCharCountMap = { ...tCharCountMap } // 找到以 i 下标开头,满足条件的字符串 for (let j = i; j < s.length; j++) { currentResult += s[j] // 如果这一项在 t 中存在,则减 1 if (currentTCharCountMap[s[j]] !== undefined && currentTCharCountMap[s[j]] !== 0) { currentTCharCountMap[s[j]]-- currentTLeftSize-- } // 匹配完了 if (currentTLeftSize === 0) { if (globalResult === '') { globalResult = currentResult } else if (currentResult.length < globalResult.length) { globalResult = currentResult } break } } } return globalResult}; 我们用 tCharCountMap 存储 t 中每个字符出现的次数,在遍历时每次找到出现过的字符就减去 1,直到 tLeftSize 变成 0,表示 s 完全覆盖了 t。 这个方法因为执行了 n + n-1 + n-2 + … + 1 次,所以时间复杂度是 O(n²),无法 AC,因此我们要寻找更快捷的方案。 滑动窗口追求性能的降级方案是滑动窗口或动态规划,该题目计算的是字符串,不适合用动态规划。 那滑动窗口是否合适呢? 该题要计算的是满足条件的子串,该子串肯定是连续的,滑动窗口在连续子串匹配问题上是不会遗漏结果的,所以肯定可以用这个方案。 思路也很容易想,即:如果当前字符串覆盖 t,左指针右移,否则右指针右移。就像一个窗口扫描是否满足条件,需要右指针右移判断是否满足条件,满足条件后不一定是最优的,需要左指针继续右移找寻其他答案。 这里有一个难点是如何高效判断当前窗口内字符串是否覆盖 t,有三种想法: 第一种想法是对每个字符做一个计数器,再做一个总计数器,每当匹配到一个字符,当前字符计数器与总计数器 +1,这样直接用总计数器就能判断了。但这个方法有个漏洞,即总计数器没有包含字符类型,比如连续匹配 100 个 b,总计数器都 +1,此时其实缺的是 c,那么当 c 匹配到了之后,总计数器的值并不能判定出覆盖了。 第一种方法的优化版本可能是二进制,比如用 26 个 01 表示,但可惜每个字符出现的次数会超过 1,并不是布尔类型,所以用这种方式取巧也不行。 第二种方法是笨方法,每次递归时都判断下 s 字符串当前每个字符收集的数量是否超过 t 字符串每个字符出现的数量,坏处是每次递归都至多多循环 25 次。 笔者想到的第三种方法是,还是需要一个计数器,但这个计数器 notCoverChar 是一个 Set<string> 类型,记录了每个 char 是否未 ready,所谓 ready 即该 char 在当前窗口内出现的次数 >= 该 char 在 t 字符串中出现的次数。同时还需要有 sCharMap、tCharMap 来记录两个字符串每个字符出现的次数,当右指针右移时,sCharMap 对应 char 计数增加,如果该 char 出现次数超过 t 该 char 出现次数,就从 notCoverChar 中移除;当左指针右移时,sCharMap 对应 char 计数减少,如果该 char 出现次数低于 t 该 char 出现次数,该 char 重新放到 notCoverChar 中。 代码如下: function minWindow(s: string, t: string): string { // s 每个字母出现次数表 const sCharMap = {} // t 每个字母对应出现次数表 const tCharMap = {} // 未覆盖的字符有哪些 const notCoverChar = new Set<string>() // 计算各字符在 t 出现次数 for (const char of t) { if (!tCharMap[char]) { tCharMap[char] = 0 } tCharMap[char]++ notCoverChar.add(char) } let leftIndex = 0 let rightIndex = -1 let result = '' let currentStr = '' // leftIndex | rightIndex 超限才会停止 while (leftIndex < s.length && rightIndex < s.length) { // 未覆盖的条件:notCoverChar 长度 > 0 if (notCoverChar.size > 0) { // 此时窗口没有 cover t,rightIndex 右移寻找 rightIndex++ const nextChar = s[rightIndex] currentStr += nextChar if (sCharMap[nextChar] === undefined) { sCharMap[nextChar] = 0 } sCharMap[nextChar]++ // 如果 tCharMap 有这个 nextChar, 且已收集数量超过 t 中数量,此 char ready if ( tCharMap[nextChar] !== undefined && sCharMap[nextChar] >= tCharMap[nextChar] ) { notCoverChar.delete(nextChar) } } else { // 此时窗口正好 cover t,记录最短结果 if (result === '') { result = currentStr } else if (currentStr.length < result.length) { result = currentStr } // leftIndex 即将右移,将 sCharMap 中对应 char 数量减 1 const previousChar = s[leftIndex] sCharMap[previousChar]-- // 如果 previousChar 在 sCharMap 数量少于 tCharMap 数量,则不能 cover if (sCharMap[previousChar] < tCharMap[previousChar]) { notCoverChar.add(previousChar) } // leftIndex 右移 leftIndex++ currentStr = currentStr.slice(1, currentStr.length) } } return result}; 其中还用了一些小缓存,比如 currentStr 记录当前窗口内字符串,这样当可以覆盖 t 时,随时可以拿到当前字符串,而不需要根据左右指针重新遍历。 总结该题首先要排除动态规划,并根据连续子串特性第一时间想到滑动窗口可以覆盖到所有可能性。 滑动窗口方案想到后,需要想到如何高性能判断当前窗口内字符串可以覆盖 t,notCoverChar 就是一种不错的思路。 讨论地址是:精读《算法 - 最小覆盖子串》· Issue ##496 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"path":"/friends/rss/index.html","content":"document.title = '友人文章'; 天青色等烟雨而我在等你们更新"},{"title":"《算法 - 动态规划》","path":"/wiki/WebWeekly/算法/《算法 - 动态规划》.html","content":"当前期刊数: 198 很多人觉得动态规划很难,甚至认为面试出动态规划题目是在为难候选人,这可能产生一个错误潜意识:认为动态规划不需要掌握。 其实动态规划非常有必要掌握: 非常锻炼思维。动态规划是非常锻炼脑力的题目,虽然有套路,但每道题解法思路差异很大,作为思维练习非常合适。 非常实用。动态规划听起来很高级,但实际上思路和解决的问题都很常见。 动态规划用来解决一定条件下的最优解,比如: 自动寻路哪种走法最优? 背包装哪些物品空间利用率最大? 怎么用最少的硬币凑零钱? 其实这些问题乍一看都挺难的,毕竟都不是一眼能看出答案的问题。但得到最优解又非常重要,谁能忍受游戏中寻路算法绕路呢?谁不希望背包放的东西更多呢?所以我们一定要学好动态规划。 精读动态规划不是魔法,它也是通过暴力方法尝试答案,只是方式更加 “聪明”,使得实际上时间复杂度并不高。 动态规划与暴力、回溯算法的区别上面这句话也说明了,所有动态规划问题都能通过暴力方法解决!是的,所有最优解问题都可以通过暴力方法尝试(以及回溯算法),最终找出最优的那个。 暴力算法几乎可以解决一切问题。回溯算法的特点是,通过暴力尝试不同分支,最终选择结果最优的线路。 而动态规划也有分支概念,但不用把每条分支尝试到终点,而是在走到分叉路口时,可以直接根据前面各分支的表现,直接推导出下一步的最优解!然而无论是直接推导,还是前面各分支判断,都是有条件的。动态规划可解问题需同时满足以下三个特点: 存在最优子结构。 存在重复子问题。 无后效性。 存在最优子结构即子问题的最优解可以推导出全局最优解。 什么是子问题?比如寻路算法中,走完前几步就是相对于走完全程的子问题,必须保证走完全程的最短路径可以通过走完前几步推导出来,才可以用动态规划。 不要小看这第一条,动态规划就难在这里,你到底如何将最优子结构与全局最优解建立上关系? 对于爬楼梯问题,由于每层台阶都是由前面台阶爬上来的,因此必然存在一个线性关系推导。 如果变成二维平面寻路呢?那么就升级为二维问题,存在两个变量 i,j 与上一步之间关系了。 如果是背包问题,同时存在物品数量 i、物品重量 j 和物品质量 k 三个变量呢?那就升级为三位问题,需要寻找三个之间的关系。 依此类推,复杂度可以上升到 N 维,维度越高思考的复杂度就越高,空间复杂度就越需要优化。 存在重复子问题即同一个子问题在不同场景下存在重复计算。 比如寻路算法中,同样两条路线的计算中,有一段路线是公共的,是计算的必经之路,那么只算一次就好了,当计算下一条路时,遇到这个子路,直接拿第一次计算的缓存即可。典型例子是斐波那契数列,对于 f(3) 与 f(4),都要计算 f(1) 与 f(2),因为 f(3) = f(2) + f(1),而 f(4) = f(3) + f(2) = f(2) + f(1) + f(2)。 这个是动态规划与暴力解法的关键区别,动态规划之所以性能高,是因为 不会对重复子问题进行重复计算,算法上一般通过缓存计算结果或者自底向上迭代的方式解决,但核心是这个场景要存在重复子问题。 当你觉得暴力解法可能很傻,存在大量重复计算时,就要想想是哪里存在重复子问题,是否可以用动态规划解决了。 无后效性即前面的选择不会影响后面的游戏规则。 寻路算法中,不会因为前面走了 B 路线而对后面路线产生影响。斐波那契数列因为第 N 项与前面的项是确定关联,没有选择一说,所以也不存在后效性问题。 什么场景存在后效性呢?比如你的人生是否能通过动态规划求最优解?其实是不行的,因为你今天的选择可能影响未来人生轨迹,比如你选择了计算机这个职业,会直接影响到工作的领域,接触到的人,后面的人生路线因此就完全变了,所以根本无法与选择了土木工程的你进行比较,因为人生赛道都变了。 有同学可能觉得这样局限是不是很大?其实不然,无后效性的问题仍然很多,比如背包放哪件物品、当前走哪条路线、用了哪些零钱,都不会影响整个背包大小、整张地图的地形、以及你最重要付款的金额。 解法套路 - 状态转移方程解决动态规划问题的核心就是写出状态转移方程,所谓状态转移,即通过某些之前步骤推导出未来步骤。 状态转移方程一般写为 dp(i) = 一系列 dp(j) 的计算,其中 j < i。 其中 i 与 dp(i) 的含义很重要,一般 dp(i) 直接代表题目的答案,i 就有技巧了。比如斐波那契数列,dp(i) 表示的答案就是最终结果,i 表示下标,由于斐波那契数列直接把状态转移方程告诉你了 f(x) = f(x-1) + f(x-2),那么根本连推导都不必了。 对于复杂问题,难在如何定义 i 的含义,以及下一步状态如何通过之前状态推导。 这个做多了题目就有体会,如果没有,那即便再如何解释也难以说明,所以后面还是直接看例子吧。 先举一个最简单的动态规划例子 - 爬楼梯来说明问题。 爬楼梯问题爬楼梯是一道简单题,题目如下: 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?(给定 n 是一个正整数) 首先 dp(i) 就是问题的答案(解法套路,dp(i) 大部分情况就是答案,这样解题思路会最简化),即爬到第 i 阶台阶的方法数量,那么 i 自然就是要爬到第几阶台阶。 我们首先看是否存在 最优子结构?因为只能往上爬,所以第 i 阶台阶有几种爬方完全取决于前面有几种爬方,而一次只能爬 1 或 2 个台阶,所以第 i 阶台阶只可能从第 i-1 或 i-2 个台阶爬上来的,所以第 i 个台阶的爬法就是 i-1 与 i-2 总爬法之和。所以显然有最优子结构,连状态转移方程都呼之欲出了。 再看是否存在 存在重复子问题,其实爬楼梯和斐波那契数列类似,最终的状态转移方程是一样的,所以显然存在重复子问题。当然直观来看也容易分析出,10 阶台阶的爬法包含了 8、9 阶的爬法,而 9 阶台阶爬法包含了 8 阶的,所以存在重复子问题。 最后看是否 无后效性?由于前面选择一次爬 1 个或 2 个台阶并不会影响总台阶数,也不会影响你下一次能爬的台阶数,所以无后效性。如果你爬了 2 个台阶,因为太累,下次只能爬 1 个台阶,就属于有后效性了。或者只要你一共爬了 3 次 2 阶,就会因为太累而放弃爬楼梯,直接下楼休息,那么问题提前结束,也属于有后效性。 所以爬楼梯的状态转移方程为: dp(i) = dp(i-1) + dp(i-2) dp(1) = 1 dp(2) = 2 注意,因为 1、2 阶台阶无法应用通用状态转移方程,所以要特殊枚举。这种枚举思路在代码里其实就是 递归终结条件,也就是作为函数 dp(i) 不能无限递归,当 i 取值为 1 或 2 时直接返回枚举结果(对这道题而言)。所以在写递归时,一定要优先写上递归终结条件。 然后我们考虑,对于第一阶台阶,只有一种爬法,这个没有争议吧。对于第二阶台阶,可以直接两步跨上来,也可以走两个一步,所以有两种爬法,也很容易理解,到这里此题得解。 关于代码部分,仅这道题写一下,后面的题目如无特殊原因就不写代码了: function dp(i: number) { switch (i) { case 1: return 1; case 2: return 2; default: return dp(i - 1) + dp(i - 2); }}return dp(n); 当然这样写重复计算了子结构,所以我们不要每次傻傻的执行 dp(i - 1)(因为这样计算了超多重复子问题),我们需要用缓存兜底: const cache: number[] = [];function dp(i: number) { switch (i) { case 1: cache[i] = 1; break; case 2: cache[i] = 2; break; default: cache[i] = cache[i - 1] + cache[i - 2]; } return cache[i];}// 既然用了缓存,最好子底向上递归,这样前面的缓存才能优先算出来for (let i = 1; i <= n; i++) { dp(i);}return cache[n]; 当然这只是简单的一维线性缓存,更高级的缓存模式还有 滚动缓存。我们观察发现,这道题缓存空间开销是 O(n),但每次缓存只用了上两次的值,所以计算到 dp(4) 时,cache[1] 就可以扔掉了,或者说,我们可以滚动利用缓存,让 cache[3] 占用 cache[1] 的空间,那么整体空间复杂度可以降低到 O(1),具体做法是: const cache: [number, number] = [];function dp(i: number) { switch (i) { case 1: cache[i % 2] = 1; break; case 2: cache[i % 2] = 2; break; default: cache[i % 2] = cache[(i - 1) % 2] + cache[(i - 2) % 2]; } return cache[i % 2];}for (let i = 1; i <= n; i++) { dp(i);}return cache[n % 2]; 通过取余,巧妙的让缓存永远交替占用 cache[0] 与 cache[1],达到空间利用最大化。当然,这道题因为状态转移方程是连续用了前两个,所以可以这么优化,如果遇到用到之前所有缓存的状态转移方程,就无法使用滚动缓存方案了。然而还有更高级的多维缓存,这个后面提到的时候再说。 接下来看一个进阶题目,最大子序和。 最大子序和最大子序和是一道简单题,题目如下: 给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。 首先按照爬楼梯的套路,dp(i) 就表示最大和,由于整数数组可能存在负数,所以越多数相加,和不一定越大。 接着看 i,对于数组问题,大部分 i 都可以代表以第 i 位结尾的字符串,那么 dp(i) 就表示以第 i 位结尾的字符串的最大和。 可能你觉得以 i 结尾,就只能是 [0-i] 范围的值,那么 [j-i] 范围的字符串不就被忽略了?其实不然,[j-i] 如果是最大和,也会被包含在 dp(i) 里,因为我们状态转移方程可以选择不连上 dp(i-1)。 现在开始解题:首先题目是最大和的连续子数组,一般连续的都比较简单,因为对于 dp(i),要么和前面连上,要么和前面断掉,所以状态转移方程为: dp(i) = dp(i-1) + nums[i] 如果 dp(i-1) > 0。 dp(i) = nums[i] 如果 dp(i-1) <= 0。 怎么理解呢?就是第 i 个状态可以直接由第 i-1 个状态推导出来,既然 dp(i) 是指以第 i 个字符串结尾的最大和,那么 dp(i-1) 就是以第 i-1 个字符串结尾的最大和,而且此时 dp(i-1) 已经算出来了,那么 dp(i) 怎么推导就清楚了: 因为字符串是连续的,所以 dp(i) 要么是 dp(i-1) + nums[i],要么就直接是 nums[i],所以选择哪种,取决于前面的 dp(i-1) 是否是正数,因为以 i 结尾一定包含 nums[i],所以 nums[i] 不管是正还是负,都一定要带上。 所以容易得知,dp(i-1) 如果是正数就连起来,否则就不连。 好了,经过这么详细的解释,相信你已经完全了解动态规划的解题套路,后面的题目解释方式我就不会这么啰嗦了! 这道题如果再复杂一点,不连续怎么办呢?让我们看看最长递增子序列问题吧。 最长递增子序列最长递增子序列是一道中等题,题目如下: 给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。 子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。 其实之前的 精读《DOM diff 最长上升子序列》 有详细解析过这道题,包括还有更优的贪心解法,不过我们这次还是聚焦在动态规划方法上。 这道题与上一道的区别就是,首先递增,其次不连续。 按照套路,dp(i) 就表示以第 i 个字符串结尾的最长上升子序列长度,那么重点是,dp(i) 怎么通过之前的推导出来呢? 由于是不连续的,因此不能只看 dp(i-1) 了,因为 nums[i] 项与 dp(j)(其中 0 <= j < i)组合后都可能达到最大长度,因此需要遍历所有 j,尝试其中最大长度的组合。 所以状态转移方程为: dp[i] = max(dp[j]) + 1,其中 0<=j<i 且 num[j]<num[i]。 这道题的出现,预示着较为复杂的状态转移方程的出现,即第 i 项不是简单由 i-1 推导,而是由之前所有 dp(j) 推导,其中 0<=j<i。 除此之外,还有推导变种,即根据 dp(dp(i)) 推导,即函数里套函数,这类问题由于加深了一层思考脑回路,所以相对更难。我们看一道这样的题目:最长有效括号。 最长有效括号最长有效括号是道困难题,题目如下: 给你一个只包含 '(' 和 ')' 的字符串,找出最长有效(格式正确且连续)括号子串的长度。 这道题之所以是困难题,就因为状态转移方程存在嵌套思维。 我们首先按套路定义 dp(i) 为答案,即以第 i 下标结尾的字符串中最长有效括号长度。看出来了吗?一般字符串题目中,i 都是以字符串下标结尾来定义,很少有定义为开头或者别的定义行为。当然非字符串问题就不是这样了,这个在后面再说。 我们继续题目,如果 s[i] 是 (,那么不可能组成有效括号,因为最右边一定不闭合,所以考虑 s[i] 为 ) 的场景。 如果 s[i-1] 为 (,那么构成了 ...() 之势,最后两个自成合法闭合,所以只要看前面的即可,即 dp(i-2),所以这种场景的状态转移方程为: dp(i) = dp(i-2) + 2 如果 s[i-1] 是 ) 呢?构成了 ...)) 的状态,那么只有 i-1 是合法闭合的,且这个合法闭合段之前必须是 ( 与第 i 项形成闭合,才构成此时最长有效括号长度,所以这种场景的状态转移方程为: dp(i) = dp(i-1) + dp(i - dp(i-1) - 2) + 2,你可以结合下面的图来理解: 可以看到,dp(i-1) 就是第二条横线的长度,然后如果红色括号匹配的话,长度又 +2,最后别忘了最左边如果有满足匹配的也要带上,这就是 dp(i - dp(i-1) - 2),所以加到一起就是这种场景的括号最大长度。 到这里,一维动态规划问题深度基本上探索完了,在进入多维动态规划问题前,还有一类一维动态规划问题,属于表达式不难,也没有这题这么复杂的嵌套 DP,但是思维复杂度极高,你一定不要盯着全流程看,那样复杂度太高,你需要充分认可 dp(i-x) 已经算出来部分的含义,进行高度抽象的思考。 栅栏涂色栅栏涂色是一道困难题,题目如下: 有 k 种颜色的涂料和一个包含 n 个栅栏柱的栅栏,每个栅栏柱可以用其中一种颜色进行上色。 你需要给所有栅栏柱上色,并且保证其中相邻的栅栏柱 最多连续两个 颜色相同。然后,返回所有有效涂色的方案数。 这道题 k 和 n 都非常巨大,常规暴力解法甚至普通 DP 都会超时。选择 i 的含义也很重要,这里 i 到底代表用几种颜色还是几个栅栏呢?选择栅栏会好做一些,因为栅栏是上色的主体。这样 dp(i) 就表示上色前 i 个栅栏的所有涂色方案。 首先看下递归终止条件。由于最多连续两个颜色相同,因此 dp(0) 与 dp(1) 分别是 k 与 k*k,因为每个栅栏随便刷颜色,自由组合。那么 dp(2) 有三个栅栏,非法情况是三个栅栏全同色,所以用所有可能减掉非法即可,非法场景只有 k 中,所以结果是 k*k*k - k。 那么考虑一般情况,对于 dp(i) 有几种涂色方案呢?直接思考情况太多,我们把情况一分为二,考虑 i 与 i-1 颜色相同与不同两种情况考虑。 如果 i 与 i-1 颜色相同,那么为了合法,i-1 肯定不能与 i-2 颜色相同了,否则就三个同色,这样的话,不管 i-2 是什么颜色,i-1 与 i 都只能少取一种颜色,少取的颜色就是 i-2 的颜色,因此 [i-1,i] 这个区间有 k-1 中取色方案,前面有 dp(i-2) 种取色方案,相乘就是最终方案数:dp(i-2) * (k-1)。 这背后其实存在动态思维,即每种场景的 k-1 都是不同的颜色组合,只是无论前面 dp(i-2) 是何种组合,后面两个栅栏一定有 k-1 种取法,虽然颜色组合的色值不同,但颜色组合数量是不变的,所以可以统一计算。理解这一点非常关键。 如果 i 与 i-1 颜色不同,那么第 i 项只有 k-1 种取法,一样也是动态的,因为永远不能和 i-1 颜色相同。最后乘上 dp(i-1) 的取色方案,就是总方案数:dp(i-1) * (k-1)。 所以最后总方案数就是两者之和,即 dp(i) = dp(i-2) * (k-1) + dp(i-1) * (k-1)。 这道题的不同之处在于,变化太多,任何一个栅栏取的颜色都会影响后面栅栏要取的颜色,乍一看觉得是个有后效性的题目,无法用动态规划解决。但实际上,虽然有后效性,但如果进行合理的拆解,后面栅栏的总可能性 k-1 是不变的,所以考虑总可能性数量,是无后效性的,因此站在方案总数上进行抽象思考,才可能破解此题。 接下来介绍多维动态规划,从二维开始。二维动态规划就是用两个变量表示 DP,即 dp(i,j),一般在二维数组场景出现较多,当然也有一些两个数组之间的关系,也属于二维动态规划,为了继续探讨字符串问题,我选择了字符串问题的二维动态规划范例,编辑距离这道题来说明。 编辑距离编辑距离是一道困难题,题目如下: 给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数。 你可以对一个单词进行如下三种操作: 插入一个字符 删除一个字符 替换一个字符 只要是字符串问题,基本上 i 都表示以第 i 项结尾的字符串,但这道题有两个单词字符串,为了考虑任意匹配场景,必须用两个变量表示,即 i j 分别表示 word1 与 word2 结尾下标时,最少操作次数。 那么对于 dp(i,j) 考虑 word1[i] 与 word2[j] 是否相同,最后通过双重递归,先递归 i,在递归内再递归 j,答案就出来了。 假设最后一个字符相同,即 word1[i] === word2[j] 时,由于最后一个字符不用改就相同了,所以操作次数就等价于考虑到前一个字符,即 dp(i,j) = dp(i-1,j-1) 假设最后一个字符不同,那么 最后一步 有三种模式可以得到: 假设是替换,即 dp(i,j) = dp(i-1,j-1) + 1,因为替换最后一个字符只要一步,并且和前面字符没什么关系,所以前面的最小操作次数直接加过来。 假设是插入,即 word1 插入一个字符变成 word2,那么只要变换到这一步再 +1 插入操作就行了,变换到这一步由于插入一个就行了,因此 word1 比 word2 少一个单词,其它都一样,要变换到这一步,就要进行 dp(i,j-1) 的变换,因此 dp(i,j) = dp(i,j-1) + 1。。 假设是删除,即 word1 删除一个字符变成 word2,同理,要进行 dp(i-1,j) 的变化后多一步删除,因此 dp(i,j) = dp(i-1,j) + 1。 由于题目取操作最少次数,所以这三种情况取最小即可,即 dp(i,j) = min(dp(i-1,j-1), dp(i,j-1), dp(i-1,j)) + 1。 所以同时考虑了最后一个字符是否相同后,合并了的状态转移方程就是最终答案。 我们再考虑终止条件,即 i 或 j 为 -1 时的情况,因为状态转移方程 i 和 j 不断减小,肯定会减少到 0 或 -1,因为 0 是字符串还有一个字符,相对比如考虑 -1 字符串为空时方便,因此我们考虑 -1 时作为边界条件。 当 i 为 -1 时,即 word1 为空,此时要变换为 word2 很显然,只有插入 j 次是最小操作次数,因此此时 dp(i,j) = j;同理,当 j 为 -1 时,即 word2 为空,此时要删除 i 次,因此操作次数为 i,所以 dp(i,j) = i。 非字符串问题说到这,相信你在字符串动规问题上已经如鱼得水了,我们再看看非字符串场景的动规问题。非字符串场景的动规比较经典的有三个,第一是矩形路径最小距离,或者最大收益;第二是背包问题以及变种;第三是打家劫舍问题。 这些问题解决方式都一样,只是对于 dp(i) 的定义略有区别,比如对于矩形问题来说,dp(i,j) 表示走到 i,j 格子时的最小路径;对于背包问题,dp(i,j) 表示装了第 i 个物品时,背包还剩 j 空间时最大价格;对于打家劫舍问题,dp(i) 表示打劫到第 i 个房间时最大收益。 因为篇幅问题这里就不一详细介绍了,只简单说明一下矩形问题于打家劫舍问题。 对于矩形问题,状态转移方程重点看上个状态是如何转移过来的,一般矩形只能向右或者向下移动,路途可能有一些障碍物不能走,我们要做分支判断,然后选择一条符合题目最值要求的路线作为当前 dp(i) 的转移方程即可。 对于打家劫舍问题,由于不能同时打劫相邻的房屋,所以对于 dp(i),要么为了打劫 i-1 而不打劫第 i 间,或者打劫 i-2 于第 i 间,取这两种终态的收益最大值即可,即 dp(i) = max(dp(i-1), dp(i-2) + coins[i])。 总结动态规划的核心分为三步,首先定义清楚状态,即 dp(i) 是什么;然后定义状态转移方程,这一步需要一些思考技巧;最后思考验证一下正确性,即尝试证明你写的状态转移方程是正确的,在这个过程要做到状态转移的不重不漏,所有情况都被涵盖了进来。 动态规划最经典的还是背包问题,由于篇幅原因,可能下次单独出一篇文章介绍。 讨论地址是:精读《算法 - 动态规划》· Issue ##327 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法题 - 统计可以被 K 整除的下标对数目》","path":"/wiki/WebWeekly/算法/《算法题 - 统计可以被 K 整除的下标对数目》.html","content":"当前期刊数: 284 今天我们看一道 leetcode hard 难度题目:统计可以被 K 整除的下标对数目。 题目给你一个下标从 0 开始、长度为 n 的整数数组 nums 和一个整数 k ,返回满足下述条件的下标对 (i, j) 的数目: 0 <= i < j <= n - 1 且 nums[i] * nums[j] 能被 k 整除。 示例 1: 输入:nums = [1,2,3,4,5], k = 2输出:7解释:共有 7 对下标的对应积可以被 2 整除:(0, 1)、(0, 3)、(1, 2)、(1, 3)、(1, 4)、(2, 3) 和 (3, 4)它们的积分别是 2、4、6、8、10、12 和 20 。其他下标对,例如 (0, 2) 和 (2, 4) 的乘积分别是 3 和 15 ,都无法被 2 整除。 思考首先想到的是动态规划,一个长度为 n 的数组结果与长度为 n-1 的关系是什么? 首先 n-1 时假设算好了一个结果 result,那么长度为 n 时,新产生的匹配是下标 [0, n-1] 与下标 n 数字的匹配关系,假设这些关系中有 q 个满足题设,则最终答案是 result + q。 这种想法适合 (i, j) 满足任意关系的题目,代码如下: function countPairs(nums: number[], k: number): number { if (nums.length < 2) { return 0 } const dpCache: Record<number, number> = {} for (let i = 1; i < nums.length; i++) { switch (i) { case 1: if (nums[0] * nums[1] % k === 0) { dpCache[1] = 1 } else { dpCache[1] = 0 } break default: // [0,i-1] 洗标范围内与 i 下标组合,看看有多少种可能 let currentCount = 0 for (let j = 0; j <= i - 1; j++) { if (nums[j] * nums[i] % k === 0) { currentCount++ } } dpCache[i] = dpCache[i - 1] + currentCount } } return dpCache[nums.length - 1]}; 很可惜超时了,因为回头想想,虽然思路是 dp,但本质上是暴力解法,时间复杂度是 O(n²)。 为了 AC,必须采用更低复杂度的算法。 利用最大公约数解题如果只循环一次数组,那么必须在循环到数组每一项的时候,就能立刻知道该项与其他哪几项的乘积符合 nums[i] * nums[j] 能被 k 整除,这样的话累加一下就能得到答案。 也就是说,拿到数字 nums[i] 与 k,我们要知道有哪些 nums[j] 是满足要求的。 当然,如果把所有剩余数字循环一遍来找满足条件的 nums[j],那时间复杂度就还是 O(n²),但不循环似乎无法继续思考了,这道题很容易在这里陷入僵局。 接下来就要发散思维了,先想这个问题:满足条件的 nums[j] 要满足 nums[i] * nums[j] % k === 0,那除了通过遍历把每一项 nums[j] 拿到真正的算一遍之外,还有什么更快的办法呢? 除了真的算一下之外,想想 nums[j] 还要具备什么特性?这个特性最好和倍数有关,因为如果我们计算所有数字倍数出现的个数,时间复杂度会比较低。 nums[i] 与 k 的最大公约数就满足这个条件,因为我们希望的是 nums[j] * nums[i] 是 k 的倍数,那么 nums[j] 最小的值就是 k / nums[i],但这个除出来可能不是整数,那必须保证 k 除以的数字是一个整数,这个除数用 nums[i] 与 k 的最大公约数最划算。nums[j] 可以更大,只要是这个结果的倍数就行了,总结一下,nums[j] 要满足是 k / gcd(nums[i], k) 的倍数。 再重点解释下原因,我们假设 nums[i] = 2, k=100,此时是 k 比较大的情况,那么其最大公约数一定小于等于 nums[i],因此 k / 最大公约数 * nums[j] 得到的数字一定大于 k / nums[i] * nums[j],毕竟最大公约数比 nums[i] 小嘛,而 k / nums[i] * nums[j] 就是不考虑 nums[j] 是整数情况下让 k 可以整除 nums[i] * nums[j] 时,nums[j] 取的最小值的情况,因此 nums[j] 只要是 k / 最大公约数 的倍数就行了。 反之,如果 k 比 nums[i] 小,比如 nums[i] = 100, k=2,此时最大公约数是小于等于 k 的,但用一个比 k 还要大的 nums[i] 作为乘法的一边,乘出来的结果肯定大于 k,所以不用担心 nums[i] * nums[j] < k 的情况,所以 nums[j] 只要是 k / 最大公约数 的倍数就行了。 综上,无论如何 nums[j] 只要是 k / 最大公约数 的倍数就行了。 所以对于每一个 nums[i],我们能快速计算出 x = k / gcd(nums[i], k),接下来只要找到 nums 所有数字中,是 x 倍数的有多少累加起来就行了。这一步也不能鲁莽,因为数组长度非常大,性能更好的方案是:先从1开始到最大值,计算出每个数字的倍数有几个,存在一个 map 表里,之后找倍数有几个直接从 map 表里获取就行了。 比如有数字 1 ~ 10,我们要计算每个数字的倍数出现了几次,大概是这么算的: 1,2,3… 数到 10,那么 1 的倍数有 10 个数字。 2,4,6,8,10 数 5 次,那么 2 的倍数有 5 个数字。 3,6,9 数 3 次,那么 3 的倍数有 3 个数字。 以此类推,我们发现一个规律,即对于长度为 n 的数组,要数的总次数为 n + n/2 + n/3 + ... + 1,这是一个调和数列,具体怎么证明的笔者已经忘了,但可以记住它的值趋向于欧拉常数 + ln(n+1),这就是要数的次数,所以用这个方案,整体时间复杂度是 O(nlnn),比 O(n²) 小了很多。 所以我们只要 “暴力” 的从 1 开始到 nums 最大的数字,把所有数字的倍数都提前计算出来,最后的时间复杂度反而会更小,这是非常神奇的结论。为了避免计算多余的倍数关系,反而时间复杂度是 O(n²),而暴力计算所有数字倍数的时间复杂度竟然是 O(nlnn),这个可以背下来。 接下来就简单了,直接上代码。 用 js 实现 gcd(最大公约数)计算可以用辗转相除法: function gcd(left: number, right: number) { return right === 0 ? left : gcd(right ,left % right)} 整体代码实现: function countPairs(nums: number[], k: number): number { // nums 最大的数字 let max = 0 nums.forEach(num => max = Math.max(num, max)) // Map<数字x, 数字x 倍数在 nums 中出现的次数> const mutipleMap: Record<number, number> = {} // 先遍历一次 nums,将其倍数次自增 nums.forEach(num => { if (mutipleMap[num] === undefined) { mutipleMap[num] = 1 } else { mutipleMap[num]++ } }) // 按以下规律数倍数出现的次数,但忽略自身 // 1,2,3...,max // 2,4,6...,max // 3,6,9...,max for (let i = 1; i <= max; i++) { for (let j = i * 2; j <= max; j+=i) { if (mutipleMap[i] === undefined) { mutipleMap[i] = 0 } mutipleMap[i] += mutipleMap[j] ?? 0 } } // 答案 let result = 0 // k / gcd(num, k) 的数组出现的次数累加 nums.forEach(num => { const targetMutiple = k / gcd(num, k) result += mutipleMap[targetMutiple] ?? 0 }) // 排除自己乘以自己满足条件的情况 nums.forEach(num => { if (num * num % k === 0) { result-- } }) return result / 2}; 有几个注意要点。 第一个是 for (let j = i * 2,之所以要乘以 2,是因为在前面遍历 nums 时,自己的倍数已经被算过一次,比如 3,6,9 的 3 已经被初始化算过一次,所以从 3*2=6 开始就行了。 第二个是 mutipleMap[i] += mutipleMap[j],比如 i=3,j=9 时,因为 9 是 3 的倍数,所以此时 3 的倍数可以继承 9 的倍数的数量,而数字是不断变大的,所以不会重复。 第三个是 if (num * num % k === 0) { result-- },因为题目要求 0 <= i < j <= n - 1,但我们计算倍数时,比如 9 是 3 的倍数,但 9 可以通过 3 * 3 得到,这种不合规的数据要过滤掉。 第四个是 return result / 2,因为在最后累加次数时,把每个数字与其他数字都判断了一遍,假设 1, 3 是合法的,那么 3, 1 也肯定是合法的,但因为 i < j 的要求,我们要把 3, 1 干掉,所有合法的结果都存在顺序颠倒的 case,所以除以 2. 总结这道题很容易栽在动态规划超时的坑上面,要解决此题需要跨越两座大山: 想到最大公约数与另一个数字之间的关系。 意识到暴力计算倍数的时间复杂度是 O(nlnn)。 最后,本题还隐含了 n + n/2 + n/3 + ... + 1 为什么极限是 O(nlnn) 的知识,背后有一个 调和数列 的大知识背景,感兴趣的同学可以深入了解。 讨论地址是:精读《算法 - 统计可以被 K 整除的下标对数目》· Issue ##495 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法题 - 通配符匹配》","path":"/wiki/WebWeekly/算法/《算法题 - 通配符匹配》.html","content":"当前期刊数: 283 今天我们看一道 leetcode hard 难度题目:通配符匹配。 题目给你一个输入字符串 (s) 和一个字符模式 (p) ,请你实现一个支持 '?' 和 '*' 匹配规则的通配符匹配: '?' 可以匹配任何单个字符。 '*' 可以匹配任意字符序列(包括空字符序列)。判定匹配成功的充要条件是:字符模式必须能够 完全匹配 输入字符串(而不是部分匹配)。 示例 1: 输入:s = "aa", p = "a"输出:false解释:"a" 无法匹配 "aa" 整个字符串。 思考最直观的思考是模拟匹配过程,以 s = “abc”, p = “abd” 为例,匹配过程是这样的: “a” 匹配 “a”,通过 “b” 匹配 “b”,通过 “c” 不匹配 “d”,失败 只要匹配过程有任何一个字符匹配失败,则整体匹配失败。如果没有 '?' 与 '*' 号,题目则异常简单,只要一个指针按顺序扫描,扫描过程每个字符必须相等,且同时结束才算成功,否则判断失败。 加上 '?' 依然很简单,因为 '?' 号一定会消耗掉,只是它可以匹配任何字符,所以还是一个指针扫描,遇到 p 中 '?' 号时,跳过判等继续向后扫描即可。 加上 '*' 号时该题成为 hard 的第一个原因。由于 '*' 可以匹配空字符,也可以匹配任意多个字符,所以遇到 p 中 '*' 时有三种处理可能性: 当做没见过 '*',直接判等,不消耗 s,并匹配 p 的下一个字符。此时对应 '*' 不匹配任何字符。 直接消耗掉 '*' 判等,同时消耗 s 与 p。此时 '*' 与 '?' 的作用等价。 不消耗 '*',但是消耗 s。此时对应 '*' 匹配多个字符而可以不消耗自己的特性。 很容易想到写一个递归的实现,代码如下: function isMatch(s: string, p: string): boolean { return myIsMatch(s.split(''), p.split(''))};function myIsMatch(sArr: string[], pArr: string[]): boolean { // 如果 s p 都匹配完了,或 p 还剩任意数量的 *,都算匹配通过 if ( (sArr.length === 0 && pArr.length === 0) || (sArr.length === 0 && pArr.every(char => char === '*')) ) { return true } // 如果任意一项长度为 0,另一项不为 0,则匹配失败 if ( (sArr.length === 0 && pArr.length !== 0) || (sArr.length !== 0 && pArr.length === 0) ) { return false } const newSArr = [...sArr] const newPArr = [...pArr] const sShfit = newSArr.shift() const pShift = newPArr.shift() // 此时 sShfit、pShift 一定都存在 switch(pShift) { case '?': // 无条件判过 return myIsMatch(newSArr, newPArr) case '*': // 无条件判过,其中有以下几种情况 // 消耗 *、消耗 sShfit // 消耗 *、不消耗 sShfit // 不消耗 *、消耗 sShfit return ( myIsMatch(newSArr, newPArr) || myIsMatch([sShfit, ...newSArr], newPArr) || myIsMatch(newSArr, [pShift, ...newPArr]) ) default: if (sShfit !== pShift) { return false } else { return myIsMatch(newSArr, newPArr) } }} 非常简洁清晰的代码,即判断 pShfit(p 下一个字符)的状态,根据我们分析的可能性判断匹配命中的条件,比如当 pShfit 为 '?' 时直接判定下一组字符,而为 '*' 时,三种可能性都可以判对,其余情况必须在当前字符相等时,才继续判断下一组字符。 然而上面的代码无法 AC,原因是性能不达标,无论如何优化都无法 AC,这是该题成为 hard 的第二个原因。 遇到思路正确,但遇到比较复杂的用例超时,此时 99% 的情况应该换到动态规划思路,而该题动态规划思路是比较难想到的。 动态规划思路之所以动态规划思路难想到,是因为我们大脑的局限性造成的。因为人类最自然理解事物的方式是线性还原该场景的每一幕,对于这道题,我们自然会假设匹配是从第一个字符开始的,匹配完后进行下一个字符的匹配,直到判断失败。 但动态规划的思路是寻找 dp(i) 与 dp(i-1) 甚至 i-n 的关系,这使得直观上觉得不可能,因为想到 '*' 号的匹配可能存在不消耗 '*' 号的情况,此时向前回溯感觉就像字符串从后向前匹配了一样。但仔细想想会发现,从后向前匹配的结果与从前向后的匹配结果是相同的,因此这条路是可行的。 之所以从前向后与从后向前判断是等价的,最简单的理由是把 s 与 p 字符串倒序,此时从前向后匹配在逻辑上完全等价于倒序前的从后向前匹配。 接下来要思考的是状态转移方程,首先由于 '*' 的存在,导致 s 与 p 的游标可能不同,所以我们要定义两个游标,分别是 si、pi。 所以 dp(si, pi) 可以确定下来了。 接下来要如何转移,取决于 p[pi] 的值: 为非 '?' 或 '*' 时,如果 s[si] === p[pi],则整体能否 match 取决于 dp(si-1, pi-1) 能否 match。 展开说一下,因为此时 s 与 p 字符都会消耗,所以上一个状态是 si, pi 同时减 1。 为 '?' 时,不用判断当前字符是否相同,整体能否 match 取决于 dp(si-1, pi-1) 能否 match。 为 '*' 时: 如果该 '*' 不匹配任何字符,则可以认为这个字符不存在,pi 回退一位,所以整体能否 match 取决于 dp(si, pi-1) 的结果。 如果该 '*' 匹配字符,则当前肯定能匹配上,但整体能否 match 取决于之前的结果,之前结果分两种: 消耗该 '*',则等价于 dp(si-1, pi-1) 的结果。 不消耗该 '*',则等价于 dp(si-1, pi) 的结果。 由于所有的分支包含了所有可能性,因此上面逻辑梳理是不重不漏的。 特别的,消耗该 '*' 等价于 dp(si-1, pi-1) 的 case 可以忽略,因为已经被上述逻辑覆盖了,具体是怎么覆盖的呢?见下面的表达: 消耗该 '*' 等价于 dp(si-1, pi-1) 这个场景等价于: 不消耗该 '*',等价于 dp(si-1, pi)。 接着该 '*' 不匹配任何字符。 看到了吗,如果不消耗该 '*' 匹配字符后,接着再让其不匹配任何字符,就等价于消耗该 '*' 匹配字符! 所以这块是一个性能优化点,看你能不能意识到,这样可以少一个逻辑分支的执行。 代码如下: function isMatch(s: string, p: string): boolean { // key 为 si_pi const resultSet = new Set<string>() // 初始值 // 俩空字符串 match resultSet.add('0_0') // 为了让 0_0 命中空字符串,在 s,p 前面补上空字符串 s = ' ' + s p = ' ' + p for (let si = 0; si < s.length; si++) { for (let pi = 0; pi < p.length; pi++) { switch(p[pi]) { case '?': // 只要 [si-1, pi-1] match, [si, pi] 就 match if (resultSet.has(`${si-1}_${pi-1}`)) { resultSet.add(`${si}_${pi}`) } break case '*': // * 可以匹配空字符,则等价于 [si, pi-1] // * 可以匹配 1~oo 个字符, 如果 [si-1, pi-1] match & si > 0, 可以等价于 [si-1, pi] if ( resultSet.has(`${si}_${pi-1}`) || (si > 0 && resultSet.has(`${si-1}_${pi}`)) ) { resultSet.add(`${si}_${pi}`) } break default: // [si-1, pi-1] match & 最后一个字符也相等, [si, pi] 就 match if (resultSet.has(`${si-1}_${pi-1}`) && s[si] === p[pi]) { resultSet.add(`${si}_${pi}`) } } } } return resultSet.has(`${s.length-1}_${p.length-1}`)}; 其中我们用 Set 结构很方便的定义 dp 缓存,然后给字符串前缀塞了空格,目的是方便在 si = 0, pi = 0 时收敛到 match 的情况,这样 dp 就能转起来了,否则 s[0] 和 p[0] 可能不匹配,让 dp(0, 0) 找不到一个稳定的落点(服务很到位)。 动态规划 * 号处理详解dp 思路中,可能有些同学不好理解 p[pi] = '*' 时的推演逻辑,我们展开画个图就清楚了: s = a b c dp = a b c d * 如果 * 不用于匹配,则结果等价于 s = a b c dp = a b c d 这个例子显然符合 p 可以匹配 s 的直觉。 如果 * 用于匹配,且消耗 * 比较好理解,s 与 p 各退一个字符;但不消耗 * 还是要画个图说明: s = a b c dp = a b c d * '*' 匹配了 s 最后一个字符 d,但自己又不消耗,则等价于: s = a b cp = a b c d * 从左到右看不太好理解,但从右到左看就比较容易了,可以认为 '*' 把 s 的最后一个字符 d “吃掉了”,但自己没有被消耗。要理解到这一步,还需要理解到 '*' 从左到右与从右到左匹配都是等价的这个事实。 如果非要从左到右看,也可以解释得通:既然 '*' 已经确定要在不消耗自己的情况下把 s 最后一个 d “吃掉”,那么这个 d 写于不写是等价的,所以可以把它从末尾 “抹去”。 总结从这道题可以看出,该题 hard 点不在于动态规划,不然理解了动态规划大家都能秒杀 hard 题了,这与面试时大部分面试者实际反应不符。 本题真正难点在于: 首先为了能 AC,正匹配的思路走不通,如果你不能抛下从左到右匹配字符串的成见,就没办法逼自己试试动态规划,因为动态规划是向前推导的,很多人过不去这个坎。 短时间内很难理解到 '*' 号匹配从左向右吃,与从右向左吃最终结果是等价的,所以潜意识会觉得 dp 思路无法处理 '*' 号匹配规则,非得整出个 dp(i+1) 才能理解,这样就迟迟无法下笔了。 不得不说 p[pi] = '*' 时结果等价于 dp(si-1, pi) 是具有思维跳跃的,因为它满足 dp 利用历史结果推导的结构,同时在匹配逻辑上又确实是等价的,能否想到这一步是这道题解题的关键。 如果你在其他地方看到本题的题解,但是在 p[pi] = '*' 时等价于 dp(si-1, pi) 这一步没看懂,大概率是那个题解忽略了这个 “神之细节”,而这个 “神之细节” 却是你在做题时真正的思维卡点,请确保这一点可以在你正序思考时推导出来,而不是看了答案后觉得这个转移方程有道理,从答案反推总是轻而易举的,但解题时却需要跳跃性思维。 最后,本文的实现还留了一些优化项可以更进一步,留给阅读本文的你探索: dp 缓存是否可以用滚动数组优化空间消耗。 两层 for 循环还是比较笨拙的,在某些情况下其实可以提前终止。 当字符串 p 存在多个连续 * 时效果与单个 * 是一样的,可以提前简化 p 的复杂度。 讨论地址是:精读《算法 - 二叉搜索树》· Issue ##493 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《12 个评估 JS 库你需要关心的事》","path":"/wiki/WebWeekly/前沿技术/《12 个评估 JS 库你需要关心的事》.html","content":"当前期刊数: 74 1 引言作者给出了从 12 个角度全面分析 JS 库的可用性,分别是: 特性。 稳定性。 性能。 包生态。 社区。 学习曲线。 文档。 工具。 发展历史。 团队。 兼容性。 趋势。 下面总结一下作者的观点。 2 概述 & 精读特性当你调研一个 JS 库,功能当然是最重要的,就好比 React 的用于开发 UI 界面非常方便,这是流行起来的一部分因素。 但同时 React 解决的问题很聚焦,于是把例如 Router 和 Store 部分交给社区给解决方案,这就让 Vue 的官方维护生态模式发展了起来。但这更多取决于你的偏好,像 lodash 这种精简的库也会长盛不衰,重要的是这个库提供的能力是否解决了你的业务问题。 评分:A - 化腐朽为神奇。B - 更优雅的解决方案。C - 比现有方案差。 稳定性这个库如果经常出 BUG,那显然无法在生产环境使用。最好经过严格的测试,保证这个库一定不会出错,这样我们就可以专心排查业务的问题了。 评分:A - BUG 很少,方便调试。B - 不会影响你的稳定性,比如出 BUG 概率和你的业务代码相近。C - 引入该库会让你背线上故障。 性能如果让用户 15 秒才能打开网页,那一切都是徒劳。 拿 PReact 为例子,为什么 API 相同的轮子可以活下来?因为体积小,而且 PReact 把宣传重点放在性能上。 如何一句话说明白你不是在造无用的轮子?性能更好。 评分:A - 小体积,高性能,支持各种黑科技特性比如 Tree shaking。B - 对性能没有影响。C - 导致性能降低。 包生态用过 monaco-editor 吗?大家都在用 webpack 但它却走 amd 路线,我不知道你用什么方法让它支持 commonjs 的,但这一定耽误了你不少时间。 包生态包括第三方包的成熟度,包的使用难易度,支持多少种模块化方案,是否支持 TS,有没有管理好自己的依赖等等。 开箱即用是最好的,有长期维护组织的更佳。 同时不要有太多相互竞争的社区方案为佳。比如工具库用 lodash 这很容易,但 React 数据流方案选择哪个?太多的竞争对手不断写软文抢夺用户(程序员)的注意力,试图说服他们加班重构。 评分:A - 方案唯一且生态运作良好,维护记录标准规范且顺畅。B - 很多新晋网红包,且竞争选择多。C - 没有人给你做包,想用要自己封装。 社区能否快速在 Stack Overflow 搜到问题的答案能反映出社区的活跃度,不论是官方文档还是第三方进行的问答。 社区越活跃,帮你提前踩的坑就越多,如果你遇到一个大家都没有遇到过的问题,并不代表你用得有多深度,而可能你根本就用错库了。 评分:A - 各种论坛每日都很活跃,Github issue 问题日清。B - 论坛/聊天室不太活跃。C - 除了作者自吹的文档,再也找不到任何相关信息了。 学习曲线不要以为把库功能做的强大,就算难用点也会有用户跪舔,这是幻觉。 Vue 之所以那么火爆,是因为原生 HTML 的门槛比 JSX 低,而使用 React 的用户往往都觉得 JSX 比 HTML 门槛低。我也不知道该怎么描述,从 JS 可以产生一切的角度,学习 HTML 反而被认为是高门槛的体现。 所以认清现实,JSX Star 多并不是其理论有多先进(理论确实先进),而是很多人觉得整体学习维护成本比 HTML 低。 评分:A - 一天就能成为这个库的熟练搬砖工。B - 浪费了一周时间才能投入使用。C - 学了一周才发现之前的理解是错的,而且认识到这只是个开始。 文档写文档的人一般都是库的作者,这种人一般经验会比较丰富,写起文档一般不会考虑初学者的感受,所以找到一份对初学者友好的文档还是挺不容易的。 对于库的维护者,要站在初学者角度去写文档,站在使用者角度,如果文档开头就看不懂的话,最好尽早换个文档或者换个库。 评分:A - 专门维护文档站点、视频、图片、示例项目,再好一点的话可以有专门基金会组织编程比赛,通过某三岁孩子可以一天入门强力影射技术生态的完备性。B - 有最基本的 Readme 和 API 文档。C - Readme 写的是 Create react app,其他的只能查源码了。 工具工具可以从多个维度体现出这个库的优势,首先是确实带来了使用方便,其次展示了团队维护实力的雄厚(精力溢出到可以做周边工具了)。 Redux 之所以这么火,Redux dev tools 功不可没,笔者读过一些心理学书籍,也经历过一些技术选型,看到 Redux dev tools 的图形化界面后,大脑因为受到视觉冲击比理性的逻辑思考大太多,潜意识里给 Redux 加了不少分,导致讨论结果都变得不太理性了。 如果你的库能图形化表达,或者做一个 PPT 或者辅助工具,那一定会大大加分。(React chrome 插件在打开 react 做的网页时亮起来真的很酷,这个勋章很有仪式感,以至于我不想换一个框架) 评分:A - 两个以上的工具,包括浏览器拓展、代码编辑器拓展、CLI 工具或者 SaaS 服务,实力碾压的话,会有许多花哨的辅助工具出现。B - 一个工具。C - 没有工具。 发展历史一个 Star 10K 的库,如果最早提交是十天前,就算不是刷的也最好也不要用,因为不知道哪天作者就不再维护了。 历史越悠久的库使用风险越小,除非它所在的面被淘汰(技术栈、生态、编程语言等等)。 评分:A - 4 年以上历史,有权威认证。B - 1-4 年历史,已经有不少人使用过了。C - 作者自己都没用过就安利你用到线上去。 团队看谁是这个库背后的男人。大公司广泛使用的开源库,并且有一定国际影响力,而且大厂也有成功开源历史经验的话,就会增加说服力。 但 Vue 就是个例外,几乎凭尤大一人之力打造,对这种情况,笔者想说的是,一个真心热爱技术并践行全职维护的人,也许比一个背着 KPI 的团队维护副产品更靠谱。 评分:A - 一线大厂,品质权威认证。B - 中型团队维护,并且有清晰的分工记录。C - 工作之余顺便开源出去,就没打算对这个库负责。 兼容性除了浏览器兼容性,库 API 的兼容性也非常重要。当你很容易联系到作者,并且改动 API 的建议被很快采纳时,你就要小心了。 React Router 3 -> 4 升级带来的阵痛大家都有体会过,babel7 放弃 stage 0-4 也带来不少吐槽,Angular1 和 Angular2 的区分直接让很多人粉转黑了。虽然许多时候频繁的更新是为了增添新功能,但如果带来 API 兼容问题,反而会招来反感。 假如你们团队维护的 10 年间,因为某个库作者非常勤奋的更新导致以时间为维度,均匀分布了数十种不同的版本,你会发誓下一个项目不再使用这个库了。 评分:A - 总是能兼容升级,实在不行就提前警告并告知在某个版本会废弃,并提供迁移工具,比如 React。B - 有 Break Change 但是文档把升级改动写的很清楚。C - 突然到来的小版本升级让你不得不重构之前的调用代码。 趋势炒作也好,讨论也好,保持大家对这个库的新鲜关注非常重要,因为这能连带的让这个库做好上面说的很多点。 但注意过分的炒作,可能会降低这个库的稳定性,毕竟在用户爆发式增长之前,最好有一部分当小白鼠。 评分:A - 是 HackNews 的明星话题,Star 成千上万,各种会议以此为名(Vue conf,React conf)。B - 几百 Star,有一些讨论。C - 别看现在 Star 少,迟早有一天我会超过那啥那啥。 搬家成本这个是作者补充的比较重要的一天:如果哪天不用这个库了,换成别的成本有多大? 这方面测试库做的很好,很多主流测试库比如 Jest、Ava、Mocha、Jasmine 等之间都有互转的脚本,业界基本达成了一些共识和规范。 比较坑的是 React、Vue、Angluar,使用之后你基本就被绑定了,至今没有谁可以无缝做各大框架的迁移。当然 JS 的年龄还很短,而且说不好未来还会被新语言、技术、容器颠覆而成为历史,标准化不是做不到而是需要时间,也许就在十几年之后,但是今天就是做不到。 3 总结下次技术选型讨论时,可以拿出规则一条一条比对了! 然后技术选型只是基础库,利用这些基础可以维护好自己的开源库,把更多时间用在创造业务价值上。 仔细思考就会发现,程序员开发的工具库也适合点线面体的概念。一个库 react-button 就是一个点,而它所在的线 react 如果被人抛弃了,无数个 react-xxx 也会翻船。而 react、vue、angluar 这些线都在 js 引擎这个面上,当可以用 C## 写 WebAssembly 时,Reason、Blazor、Dart 就会逐渐成为浏览器的主角,react 之类的库统统要回炉打造。而当未来人机互联不需要浏览器作为媒介时,js 引擎这个面依附的体 - 人机交互场景也被打翻了,这一浪又会引起多大的变化。 所以技术选型是为了解决当下业务问题,仔细考虑好几个因素,适合解决业务场景就足够了。 4 更多讨论 讨论地址是:精读《12 个评估 JS 库你需要关心的事》 · Issue ##104 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《15 大 LOD 表达式 - 上》","path":"/wiki/WebWeekly/前沿技术/《15 大 LOD 表达式 - 上》.html","content":"当前期刊数: 216 通过上一篇 精读《什么是 LOD 表达式》 的学习,你已经理解了什么是 LOD 表达式。为了巩固理解,结合场景复习是最有效的手段,所以这次我们结合 Top 15 LOD Expressions 这篇文章学习 LOD 表达式的 15 大应用场景,因篇幅限制,本文介绍 1~8 场景。 1. 客户下单频次各下单次数的顾客数量是多少? 柱状图的 Y 轴显然是 count([customerID]),因为要统计 当前维度下的客户总数。 这里插一句,对于柱状图的 Y 轴,在 sql 里就是对 X 轴 group by 后的聚合,因此 Y 轴就是对 X 轴各项的汇总。 柱状图的 X 轴要表达的是以何种粒度拆解,比如我们是看各城市数据,还是看各省数据。在这个场景下也不例外,我们要看 各下单次数下的数据,那么如何把下单次数转化为维度呢? 我们需要用 FIX 表达式制作一个维度字段,表示各顾客下单次数。很显然数据库是没有这个维度的,而且这个维度需要按照客户 ID group by 后,按照订单 ID count 聚合才能得到,因此可以利用 FIX 表达式:{ fixed [customerID] : count([orderId]) } 描述。 2. 阵列分析当我们看年客户销售量时,即便是逐年增长的,我们也会有一个疑问:每年销量中,首单在各年份的顾客分别贡献了多少? 因为关系到老客忠诚度和新客拓展速度,新客与老客差距过大都不好,那我们如何让 2021 年的柱状图按照 2019、2020、2021 年首单的顾客分层呢?这就是阵列分析。 我们要画一个柱状图,X、Y 轴分别是 [Year]、sum([Sales])。 为了让柱状图分层,我们需要一个表示颜色图例的维度字段,比如我们拖入已有的性别维度,每根柱子就会被划分为男、女两块。但问题是,我们制作并不存在的 “首单年份维度”? 答案是利用 FIX 表达式:{ fixed [customerID] : min([orderDate]) }。 3. 日利润指标分析 每年各月份的盈利、亏损天数分布。如下图: 列是年到月的下钻,比较好实现,只要拖入字段 [year] 并下钻到月粒度,移除季度粒度即可。 行是 “高收益”、“正收益”、“亏损” 的透视图,值是在当前月份中天数。 那么如何计算高收益、亏损状态呢?因为最终粒度是天,所以我们要按天计,首先就要得到每天的利润总和,这些中间过程可以利用 LOD 的字段来完成,即创建一个 日利润字段(profitPerDay):{ fixed [orderDate] : sum([profit]) }。 由于我们对利润总量不敏感,只希望拆分为三个阶段,所以利用 IF THEN 生成一个新字段 日利润指标(dailyProfitKPI):IF [profitPerDay] > 2000 THEN "Highly Profitable" ELSEIF [profitPerDay] <= 0 THEN "unprofitable" ELSE "profitable" END。 所以创建的 [dailyProfitKPI] 指标是个维度,即如果当前行所在的天利润汇总如果大于 2000,值就是 “Highly Profitable”。所以在行上拖入 count(distinct [orderDate]),把 [dailyProfitKPI] 拖入行的颜色透视即可。 4. 占总体百分比LOD 表达式的一大特色就是计算跨详细级别的占比,比如我们要看 欧洲各国的销量在全世界占比: 显然这个图里所有国家之和不是 100%,因为欧洲加起来也才不到百分之二十,然而在当前详细级别下,是拿不到全球总销售量的,所以我们可以利用 FIX 表达式来实现:sum([sales]) / max({ sum([sales]) })。 这里解释两点: 之所以用 max 是因为 LOD 表达式只是一个字段,并没有聚合方式,运算必须在相同详细级别下进行,由于总销量只有一条数据,所以我们用 max 或者 min 甚至 sum 都行,结果都是一样的。 如果不加维度限制,就可以省略 “fix” 申明,所以 { sum([sales]) } 实际上就是 FIX 表达式,它表示 { fixed : sum([sales]) }。 5. 新客增长趋势看着年客户增长趋势图,你有没有想过,这个趋势图肯定永远是向上的?也就是说,看着趋势图朝上走,不一定说明业务做得好。 如果公司每年都比去年发展的好,每年的新增新客数应该要比去年多,所以 每年新客增长趋势图 才比较有意义,如果你看到这个趋势图的趋势朝上,说明每年的新客都比去年多,说明公司摆脱了惯性,每年都获得了新的增长。 所以我们要加一个筛选条件。新增一个维度字段,当这一单客户是今年新客时为 true,否则为 false,这样我们筛选时,只看这个字段为 true 的结果就行了。 那么这个字段怎么来呢?思路是,获取客户首单年份,如果首单年份与当前下单年份相同,值为 true,否则为 false。 我们利用 LOD 创建首单年份字段 [firstOrderDate]:{ fixed [customerId] : min([orderDate]) },然后创建筛选字段 [newOrExist]: IFF([firstOrderDate] = [orderDate], 'true', 'false')。 6. 销量对比分析入下图条形图所示,右侧是每项根据选择的分类的对比数据: 对比值计算方式是,用 当前的销量减去当前选中分类的销量。相信你可以猜到,但前分类的销量与当前视图详细级别无关,只与用户选择的 Category 有关。 如果我们已经有一个度量字段 - 选中分类销量 selectedSales,应该再排除当前 category 维度的干扰,所以可用 EXCLUDE 表达式描述 selectedCategorySales: { exclude [category] : sum([selectedSales]) }。 接下来是创建 selectedSales 字段。背景知识是 [parameters].[category] 可以获得当前选中的维度值,那我们可以写个 IF 表达式,在维度等于选中维度时聚合销量,不就是选中销量吗?所以公式是:IF [category] = [parameters].[category] THEN sales ELSE 0 END。 最后对比差异,只要创建一个 [diff] 字段,表达式为 sum(sales) - sum(selectedCategorySales) 即可。 7. 平均最高交易额如下图所示,当前的详细级别是国家,但我们却要展示每个国家平均最高交易额: 显然,要求平均最高交易额,首先要计算每个销售代表的最高交易额,由于这个详细级别比国家低,我们可以利用 INCLUDE 表达式计算销售代表最高交易额 largestSalesByRep: { include [salesRep] : max([sales]) },并对这个度量字段求平均即可。 从这个例子可以看出,如果我们在一个较高的详细级别,比如国家,此时的 sum([sales]) 是根据国家详细级别汇总的,而忽略了销售代表这个详细级别。但如果要展示每个国家的平均最高交易额,就必须在销售代表这个详细级别求 max([sales]),由于是各国家的,所以我们不用 { fixed [salesRep] },而是 { include [salesRep] },这样最终计算的详细级别是:[country],[salesRep],这样才能算出销售在每个国家的最高交易额(因为也许某些销售同时在不同国家销售)。 8. 实际与目标在第六个例子 - 销量对比分析中,我们可以看到销量绝对值的对比,这次,我们需要计算实际销售额与目标的差距百分比: 如上图所示,左上角展示了实际与目标的差值;右上角展示了每个地区产品目标完成率;下半部分展示了每个产品实际销量柱状图,并用黑色横线标记出目标值。 左上角非常简单,[diffActualTraget]: [profit] - [targetProfit],只要将当前利润与目标利润相减即可。 右上角需要分为几步拆解。我们的最终目标是计算每个地区产品目标完成率,显然公式是 当前完成产品数/总产品数。总产品数比较简单,在已有地区维度拆解下,计算下产品总数就行了,即 count(distinct [product]);难点是当前完成产品数,这里我们又要用到 INCLUDE,为什么呢?因为地区粒度比产品粒度高,我们看地区汇总的时候,就不知道各产品的完成情况了,所以必须 INCLUDE product 维度计算利润目标差,公式是 [diffProductActualTraget] :{ include [product] : sum(diffActualTraget) },然后当这个值大于 0 就认为完成了目标,我们可以再创建一个字段,即完成目标数,如果达成目标就是 1,否则是 0,这样便于求 “当前完成产品数”:aboveTargetProductCount: IFF([diffProductActualTraget] > 0, 1, 0),那么当前完成产品数就是 sum([diffProductActualTraget]),所以产品目标完成率就是 sum([diffProductActualTraget]) / count(distinct [product]),将这个字段拖入指标,按照百分比格式化,就得到结果了。 总结通过上面的例子,我们可以总结出实际业务场景中几条使用心法: 首先对计算公式进行拆解,判断拆解后的字段是否数据集里都有,如果都有的话就结束了,说明是个简单需求。 如果数据集里没有,而且发现数据详细级别与当前不符(比如要得到每个国家销量,但当前维度是城市),就要用 FIXED 表达式固定详细级别。 如果不是明确的按照某个详细级别计算,就不要使用 FIXED,因为不太灵活。 当计算时要跳过某个指定详细级别,但又要保留视图里的详细级别时,使用 EXCLUDE 表达式。 如果计算涉及到比视图低的详细级别,比如计算平均或者最大最小时,使用 INCLUDE 表达式。 使用 FIXED 表达式创建的字段也可以进行二次计算,合理拆解多个计算字段并组合,会让逻辑更加清晰,易于理解。 讨论地址是:精读《15 大 LOD 表达式 - 上》· Issue ##369 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《15 大 LOD 表达式 - 下》","path":"/wiki/WebWeekly/前沿技术/《15 大 LOD 表达式 - 下》.html","content":"当前期刊数: 217 接着上一篇 精读《15 大 LOD 表达式 - 上》 ,这次继续总结 Top 15 LOD Expressions 这篇文章的 9~15 场景。 9. 某时间段内最后一天的值如何实现股票平均每日收盘价与当月最后一天收盘价的对比趋势图? 如图所示,要对比的并非是某个时间段,而是当月最后一天的收盘价,因此必须要借助 LOD 表达式。 设想原表如下: Date Ticker Adj Close 29/08/2013 SYMC $1 28/08/2013 SYMC $2 27/08/2013 SYMC $3 我们按照月进行聚合作为横轴,求 avg([Adj Close]) 作为纵轴即可。但计算对比我们需要一个 Max Date 字段如下: Date Ticker Adj Close Max, Date 29/08/2013 SYMC $1 29/08/2013 28/08/2013 SYMC $2 29/08/2013 27/08/2013 SYMC $3 29/08/2013 如果我们使用 max(Date) 表达式,在聚合后结果是可以看到 Max Date 的: Month of Date Ticker Avg, Adj Close Max, Date 08/2013 SYMC $2 29/08/2013 原因是,max(Date) 是一个聚合表达式,只能在 group by 聚合 sql 下生效。但如果我们要计算最后一天的收盘价,就要执行 sum([Close value on last day],表达式如下: [Close value on last day] = if [Max Date] = [Date] then [Adj Close] else 0 end。 但问题是,这个表达式计算的明细级别是以天为粒度的,我们 max(Date) 在天粒度下是算不出来的: Date Ticker Adj Close Max, Date 29/08/2013 SYMC $1 28/08/2013 SYMC $2 27/08/2013 SYMC $3 原因就是上面说过的,聚合表达式不能在非聚合的明细级别中出现。因此我们利用 { include : max([Date]) } 表达式就能轻松实现下面的效果了: Date Ticker Adj Close { include : max([Date]) } 29/08/2013 SYMC $1 29/08/2013 28/08/2013 SYMC $2 29/08/2013 27/08/2013 SYMC $3 29/08/2013 { include : max([Date]) } 表达式没有给定 include 参数,意味着永远以当前视图的明细级别计算,因此这个字段下推到明细表做计算时,也可以出现在明细表的每一行。接着按照上面的思路组装表达式即可。 拓展一下,如果横轴我们按年进行聚合,那么对比值就是每年最后一天的收盘价。原因是 { include : max([Date]) } 会以当前年这个粒度计算 max([Date]),自然是当年的最后一天,然后下推到明细表,整整一年 365 行数据中,[Close value on last day] 大概是这样: Date Ticker Adj Close [Close value on last day] 31/12/2013 SYMC $1 $1 30/12/2013 SYMC $2 $1 … … … … 03/01/2013 SYMC $7 $1 02/01/2013 SYMC $8 $1 01/01/2013 SYMC $9 $1 接着对比值按照 sum([Close value on last day]) 聚合即可。 10. 复购阵列如下图所示,希望查看客户第一次购买到第二次购买间隔季度的复购阵列: 关键在于如何求第一次与第二次购买的季度时间差。首先可以通过 [1st purchase] = { fixed [customer id] : min([order date]) } 计算每位客户首次购买时间。 如何计算第二次购买时间?这里有个小技巧。首先利用 [repeat purchase] = iif([order date] > [1st purchase], [order date], null) 得到一个新列,首次购买的那一行值为 null,我们可以利用 min 函数计算时忽略 null 的特性,得到第二次购买时间:[2nd purchase] = { fixed [customer id] : min([repeat purchase]) }。 最后利用 datediff 函数得到间隔的季度数:[quarters repeat to purchase] = datediff('quarter', [1st prechase], [2nd purchase])。 11. 范围平均值差异百分比如下图所示,我们希望将趋势图的每个点,与选定区域(图中两个虚线范围内)的均值做一个差异百分比,并生成一个新的折线图放在上方。 重点是上面折线图 y 轴字段,差异百分比如何表示。首先我们要生成一个只包含指定区间的收盘值: [Close value in reference period] = IF [Date] >= [Start reference date] AND [Date] <= [End reference date] THEN [Adj close] END,这段表达式只在日期在制定区间内时,才返回 [Adj close],也就是只包含这个区间内的值。 第二步,计算制定区间的平均值,这个用 FIX 表达式即可:[Average daily close value between ref date] = { fixed [Ticker] : AVG([Close value in reference period]) }。 第三步,计算百分比差异:[percent different from ref period] = ([Adj close] - [Average daily close value between ref date]) / [Average daily close value between ref date]。 最后就是用 [percent different from ref period] 这个字段绘制上面的图形了。 12. 相对周期过滤如果我们想对比两个周期数据差异,可能会遇到数据不全导致的错误。比如今年 3 月份数据只产出到 6 号,但却和去年 3 月整月的数据进行对比,显然是不合理的。我们可以利用 LOD 表达式解决这个问题: 相对周期过滤的重点是,不能直接用日期进行对比,因为今年数据总是比去年大。比如因为今年最新数据到 11.11 号,那么去年 11.11 号之后的数据都要被过滤掉。 首先找到最新数据是哪一天,利用不包含条件的 FIX 表达式即可:[max date] = { max([date]) }。 然后利用 datepart 函数计算当前日期是今年的第几天: [day of year of max date] = datepart('dayofyear', [max date]),[day of year of order date] = datepart('dayofyear', [order date])。 所以 [day of year of max date] 就是一个卡点,任何超过今年这么多天的数据都要过滤掉。因此我们创建一个过滤条件:[period filter] = [day of year of order date] <= [day of year of max date]。 把 [period filter] 字段作为筛选条件即可。 13. 用户登陆频率如何绘制一个用户每个月登陆频率? 要计算这个指标,得用用户总活跃时间除以总登陆次数。 首先计算总活跃时间:利用 FIX 表达式计算用户最早、最晚的登陆时间: [first login] = { fixed [user id] : min([log in date]) } [last login] = { fixed [user id] : max([log in date]) } 计算其中月份 diff,就是用户活跃月数: [total months user is active] = datediff("month", [first login], [last login]) 总登录次数比较简单,也是固定用户 ID 后,对登陆日期计数即可: [numbers of logins per user] = { fixed [user id] : count([login date]) } 最后,我们用两者相除,得到用户登陆频率: [login frequency] = [total months user is active] / [numbers of logins per user] 制作图表就很简单了,把 [login frequency] 移到横轴,count distinct 用户 ID 作为纵轴即可。 14. 比例笔刷这个是 LOD 最常见的场景,比如求各品类销量占此品类总销量的贡献占比? sum(sales) / sum({ fixed [category] : sum(sales) }) 即可。 当前详细级别是 category + country,我们固定品类,就可以得到各品类在所有国家的累积销量。 15. 按客户群划分的年度购买频率如何证明老客户忠诚度更高? 我们可以如下图,按照客户群(2011 年、2012 年客户)作为图例,观察他们每年购买频次分布。 如上图所示,我们发现顾客注册时间越早,各购买频次的比例都更高,所以证明了老顾客忠诚度更高这一结论。注意这里看的是至少购买 N 次,所以每条线相比才具有说服力。如果是购买 N 次,则可能老顾客购买 1 次较少,购买 10 次较多,难以直接对比。 首先我们生成图例字段,即按最早照购买年份划分顾客群:[Cohort] = { fixed [customer id] : min(Year([order date])) } 然后就和我们第一个例子类似,计算每个订单数量下,有多少顾客。唯一的区别是,我们不仅按照顾客 ID group,还要进一步对最早购买日期做拆分,即:{ fixed [customer id], [Cohort] : count([order id]) }。 上面的字段作为 X 轴,Y 轴和第一个例子类似:count(customer id),但我们想查看的是至少购买 N 次,也就是这个购买次数是累计值,即至少购买 9 次 = 购买 9 次 + 购买 10 次 + … 购买 MAX 次。所以是一种 DESC 的 windowsum,整体表达式应该类似 [Running Total] = WINDOW_SUM(count(customer id)), 0, LAST())。 最后,因为实际 Y 轴计算的是占比,所以用刚才计算的至少购买 N 次指标除以各 Cohort 下总购买次数,即 [Running Total] / sum({ fixed [Cohort] : count([customer id]) })。 总结上面的几个例子,都是基于 fixed、include、exclude 这几个基本 LOD 用法的叠加。但从实际例子来看,我们会发现真正的难点不在与 LOD 表达式的语法,而在于我们如何精确理解需求,拆解成合理的计算步骤,并在需要运行 LOD 的计算步骤正确的使用。 LOD 表达式看上去很神奇,似乎可以和数据 “神奇” 的贴合在一起,我们要理解到 LOD 背后就是表之间的 join,而不同明细级别就表示不同的 group by 规则这一背后原理,就能比较好的理解为什么 LOD 表达式能这么运作了。 讨论地址是:精读《15 大 LOD 表达式 - 下》· Issue ##370 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《2017 前端性能优化备忘录》","path":"/wiki/WebWeekly/前沿技术/《2017 前端性能优化备忘录》.html","content":"当前期刊数: 28 本期精读的文章是:Front End Performance Checklist 2017 现在随着 web 应用的复杂性日益增加,其性能优化就会显得尤为必要,同时会给性能指标分析带来新的挑战,因为性能指标之间的差异性非常大,这取决于使用的设备、浏览器、协议、网络类型以及其它能够对性能产生影响的潜在因素(如:CDN、ISP、cache、proxy、firewall、load balancer、server 等)。 1 引言 本文提供了解决如何让网站响应更加迅速、访问更加流畅等前端性能优化问题的方法,读者们可以提供一些在实际场景中的性能优化问题以及解决方案,可泛谈优化策略,亦可针对性深入讨论某个优化方法。 2 内容概要文中列举了很多不同的性能优化策略、模型或方法,如下: 制定目标网站速度快于他人 20%根据 psychological research 指出,网站最少在速度上比别人快 20%,才能让用户感觉到比别人的更快。这个速度说的并不是整个页面的加载时间,而是启动渲染时间,首次有效渲染时间,交互时间。 控制响应时间在 100ms,控制帧速在 60 帧/秒RAIL performance model 提出的性能优化指标:务必在用户初始操作后的 100ms 内提供反馈。考虑到存在响应时间不足 100ms 的情况,页面最迟要在 50ms 的时候,把控制权交给主线程。 针对动画,其每一帧都需要在 16ms 内完成,这样才能保证每秒 60 帧(一秒/60=16.6ms),如果可以的话最好能在 10ms 内完成。 控制首次有效渲染时间在 1.25s,控制 SpeedIndex 在 1000控制启动渲染时间在 1s 以内,且速度指数在 1000 以内,对于首次有效渲染时间,最好可以优化到 1.25s 以内。 环境搭建做好构建工具的选型不要过度使用那些酷炫的技术栈,坚持选择适合开发环境的工具,如 Grunt、Gulp、Webpack、PostCSS,或者组合起来的工具。只要这个工具运行的速度够快,而且没有给项目维护带来太大问题,就够了。 渐进增强在构建前端结构的时,应始终将渐进增强作为指导原则。首先设计并且构建核心体验,再完善为高性能浏览器设计的高级特性的相关体验。 前端框架最好使用那些支持服务器端渲染的框架,如 Angular,React,Ember 等。所选的框架要保证是被广泛使用并且经过考验的。不同框架对性能有着不同程度的影响,同时对应着不同的优化策略,所以要清楚的了解所选择框架的每个方面。 AMP 或 Instant Articles Google 的 AMP 技术会提供一套可靠的性能优化框架(基于免费的 CDN 网络) Facebook 的 Instant Articles 技术可以在 Facebook 上提升网站的性能。 合理利用 CDN根据网站的动态数据量,可以将部分内容给静态网站生成工具生成一个静态版本,将其置于 CDN 上,从而避免数据库的请求,亦可选择基于 CDN 的静态主机平台,通过交互组件丰富页面。 优化构建确定优先级将网站的所有文件(js,图片,字体,第三方 script 文件,多媒体内容等)进行分门别类。根据优先级区分基础核心内容,高性能浏览器设计的升级体验,附加内容等。具体细节可参考 Improving Smashing Magazine’s Performance。 使用 cutting-the-mustard 技术使用 cutting-the-mustard 技术能够实现不同类型的浏览器载入不同类型的资源(传统浏览器载入核心型资源,现代浏览器载入增强型资源)。在载入资源时要严格遵守相应的规则:页面加载时应首先载入 Core 资源,然后在 DomContentLoaded 事件触发时载入 Enhancement 资源,最后在 Load 事件触发时载入 Extras 资源。 micro-optimization 和 progressive booting 使用 skeleton screens 代替 loading indicator 展示 使用能够加速 App 初始化渲染的技术,如 tree-shaking、code-splitting 针对服务端渲染增加预编译环节 使用 Optimize.js 来加快初始加载速度,其原理是包装优先级高的调用函数 渐进启动,先通过使用服务器端渲染快速完成首次有效渲染,浏览器再通过少量的 JS 代码就可以让交互时间接近于首次有效渲染时间。 正确设置 HTTP cache header需要正确设置 expires、cache-control、max-age 以及其它 HTTP 缓存响应头。请使用 Cache-control: immutable,可以参考 Heroku’s primer on HTTP caching headers、HTTP caching primer以及缓存之最佳实践。 减少使用第三方库,异步加载 JS想要在不等 js 执行完就开始渲染页面,可以通过在 HTML 的 script 标签上添加 defer 以及 async 属性来实现。减少第三方库和脚本的使用,尤其是社交网站的分享按键和 iframe 嵌入等。 合理优化图片 要实现图片的响应式,应尽可能地使用带有 srcset、sizes 属性的 HTML 标签,如 <picture> 使用 WebP 格式的图片 图片优化进阶 可以使用渐进式 JPEG 图片 可以使用压缩工具对不同格式的图片进行压缩,如 JPEG 图片用 mozJPEG 压缩、PNG 图片用 Pingo 压缩、GIF 图片用 Lossy GIF 压缩、SVG 图片用 SVGOMG 压缩 可以通过过滤掉不必要的图片细节(通过给图片添加高斯模糊滤镜实现)来减小文件的大小 可以使用 PhotoShop 导出(质量在 0-10%)的图片用于做背景图 可以使用多张背景图的技巧来提高对图片性能感知的能力 优化 web 字体 如果使用开源字体,可以使用字体库中的子集或自己归类的子集来压缩文件大小 浏览器对 WOFF2 的支持度较高,当浏览器不支持 WOFF2 时,可以将 WOFF、OTF 作为备用 可以从 Comprehensive Guide to Font-Loading Strategies 中选择一些针对字体优化的策略 可以使用 service worker 来达到字体缓存持久化 关于如何快速入门字体优化的教程 快速推送 critical CSS 文件为了保证能够让浏览器快速渲染,会将所有用于首屏渲染的 CSS 文件整合成一个文件(即 critical CSS),以 <style> 的行内形式内嵌到 <head>,这样可以减少 critical 渲染路径。由于 HTTP 数据包大小的限制,因此 critical CSS 文件大小不能超过 14KB。 HTTP/2 协议可以让 critical CSS 用单个 CSS 文件存储,通过服务器推送 CSS 文件的传输方式来减少 HTML 文件数据量,由于存在高速缓存问题,因此需要建立带有缓存的 HTTP/2 服务器传输机制。 tree-shaking 和 code-splitting 机制减轻负载 Tree-shaking 机制能够帮助清理生产环境中的冗余代码。可以通过 Webpack2 Tree-Shaking 机制来清理冗余的 exports 代码或者使用 UnCSS、Helium 工具来清理冗余的 CSS 代码 code splitting 机制是 Webpack 的另一个特性,它能够将构建的代码分成多个 chunk,并且对 chunk 按需载入。只要在代码中定义了分离点(split point),Webpack 便会处理好相关的输出文件,不仅能够较少文件数据量,而且还能对代码做到按需载入。 用 Rollup 来 export 代码也能够取得不错的效果 提升渲染性能可以通过使用 css containment 属性的方式来达到隔离性能开销大的组件,限制浏览器样式的范围,限制作用在 canvas 以外的布局和绘制工作中,限制用在第三方工具上,以确保页面滚动和出现动画效果时没有延迟。推荐使用 CSS 属性 will-change,该属性能够在元素的属性改变之前通知浏览器。 需要衡量浏览器在处于运行时渲染模式下的性能,可以参考浏览器渲染优化、如何正确的使用 GPU。 优化网络环境,加快网络传输 使用 skeleton screen 或者使用懒加载的方式载入字体或者开销大的组件,如视频、iframe、图片等 dns-prefetch,能够让浏览器在后台进程执行一次 DNS 查询 preconnect,能够让浏览器在后台进程发起一次握手(DNS,TCP,TLS) prefetch,能够让浏览器发起对资源的请求 prerender,能够让浏览器在后台进程渲染出特定的页面 preload,在不执行资源的前提下,预先拿到该资源 HTTP/2为 HTTP/2 环境的搭建做好准备从目前来看,浏览器对 HTTP/2 支持度还不错,使用 HTTP/2 后,就可以利用 service worker 以及 HTTP/2 的服务器推送功能来获取更显著的性能提升。 在项目进行 HTTPS 改造时,需要评估 HTTP/1.1 项目的用户基数,需要针对这类用户构建并发送符合 HTTP2 规范的报头。 正确部署 HTTP/2需要在载入大模块以及并行载入小模块之间找到一个平衡点。 将所有视图都分散到小模块中,然后在项目构建的过程中完成对小模块的压缩,最后通过 scount approach 以及异步的方式来分别实现对模块的引用及载入,对一个文件将不再需要重新下载整个样式清单或 js 文件 HTTP/2 环境下打包 js 文件时存在问题,由于向浏览器发送很多 js 小文件的过程中会存在很多问题。 首先,文件压缩的优势被破坏。在压缩大文件的过程中,借助 dictionary reuse 可以达到优化性能的目的,然而单个小文件就不能。其次,浏览器不能针对一些工作流进行优化 确保服务器的安全性需要检查是否正确设置 HTTP 请求头部,如 strict-transport-security,使用 Snyk 工具排除已知的漏洞以及使用 SSL Server Test 网站来检查证书是否失效。 尽量保证从外部引入的插件以及 js 脚本的载入是通过 HTTPS 协议的,发起 HTTP 请求同时设置 strict-transport-security 以及 content-security-policy HTTP 请求头。 服务器和 CDN 是否支持 HTTP/2通过 Is TLS Fast Yet 来查看不同服务器和 CDN 对 HTTP/2 的兼容情况。 Brotli 或 Zopfli 压缩算法 Brotli,是 Google 开源的无损数据格式,其压缩效率要远高于 Gzip 和 Deflate Zopfli 压缩算法,能够将数据编码成 Deflate、Gzip、Zlib 数据格式。用 Zopfli 算法压缩过后的文件能够比同样用 Zlib 算法压缩的文件小 3%-8% 激活 OCSP stapling激活服务器的 OCSP stapling,可以减少 TLS 握手所需的时间,加速 TLS 握手过程。 使用 IPv6因为 IPv6 自带 NDP 以及路由优化,能够让网站的载入速度提升 10%-15%。 HPACK 压缩算法如果网站使用了 HTTP/2,需要检查服务器有没有执行 HPACK 对 HTTP 的响应头进行压缩,来减少不必要的消耗。 使用 service worker如果网站切换到 HTTPS,可以使用 pragmatist-service-worker 通过 service worker cache 来缓存静态资源、离线页面等,也可以从缓存中拿数据。参考当前浏览器对 service worker 的支持程度。 测试与监控监控警告 通过 Report-URI.io 工具监控混合内容中出现的警告 通过 Mixed Content Scan 工具扫描支持 HTTPS 的网站是否存在混合内容 使用 Devtools在 DevTool 中选一个调试工具来对每一个功能进行检查,确保知道如何分析渲染性能和控制台输出、明白如何调试 JS 以及编辑 CSS 样式。参考开发者工具的调试技巧。 使用代理浏览器或过时浏览器测试完成 Chrome 和 Firefox 的测试是不够的,还需要关注部分区域占比较高的浏览器,如 UC 浏览器、Opera Min 等, 也需要了解一下受关注国家的平均网速。 持续监控在进行快速、无限制的测试时,最好使用一个个人的 WebPageTest 实例。建立一个能自动预警的性能预算监听。建立自己的用户时间标记从而测量并监测具体商用的数据。使用 SpeedCurve 对性能的变化进行监控,同时利用 New Relic 获取 WebPageTest 没法提供的数据。SpeedTracker,Lighthouse 和 Calibre 都是不错的选择。 部署私密的 WebPageTest 测试环境,有助于快速构建测试用例。针对性能开销大的环节建立自动报警机制,可以使用 SpeedCurve 对性能的变化进行监控,利用 New Relic 获取 WebPageTest 无法提供的数据。 3 精读这一部分会介绍一些上述没有提到的方法,主要是利用 Devtools 工具对性能优化策略或方法进行深入的解读和分析。 通过 Devtools 排查渲染性能问题页面代码被转换成屏幕上显示的像素,这个转换过程可以简单归纳为以下流程,包含五个关键步骤: Javascript Style Layout Paint Composite Timeline通过 Chrome Timeline 对页面进行 Record,其中绿色波浪线就是页面的帧率。波浪线越高表示帧率越高,反之亦然,帧率区域上边标红一行区域,表示有问题的帧,凡是标红的帧都是存在问题的,排查问题时,需要着重关注帧率低和标红的区域。 需要逐一排查带红色角标的帧,即是有问题的帧: 点击选中该帧,可以看到详细的耗时和简单的问题描述: Javascript Profiler如果发现运行时间很长的 JavaScript 代码,则可以开启 DevTools 中 JavaScript profiler 选项,可以看到页面中的函数调用链路,就能分析出 JavaScript 代码对于页面渲染性能的影响,从而发现并修复 JavaScript 代码中性能低下的部分。那么如何修复 JavaScript 代码中性能问题呢? 使用 requestAnimationFrame假设页面上有一个动画效果,想在动画刚刚发生的那一刻运行一段 JavaScript 代码。那么唯一能保证这个运行时机的,就是 requestAnimationFrame。而大部分代码都是用 setTimeout 或 setInterval 来实现页面中的动画效果。这种实现方式的问题是,setTimeout 或 setInterval 中指定的回调函数的执行时机是无法保证的,如果是在帧结束的时候被执行,就意味着可能失去这一帧的信息,也就是发生 jank。 降低代码复杂度或者使用 Web WorkersJavaScript 代码是运行在浏览器的主线程上的。与此同时,浏览器的主线程还负责样式计算、布局,甚至绘制等的工作。可以想象,如果 JavaScript 代码运行时间过长,就会阻塞主线程上其他的渲染工作,很可能就会导致帧丢失。 因此,需要规划 JavaScript 代码的运行时机和运行耗时,或在浏览器空闲的时候来来运行更多的 JavaScript 代码。 也可以把纯计算工作放到 Web Workers 中做,前提是这些计算工作不会涉及 DOM 元素的存取。一般来说,JavaScript 中的数据处理工作,如排序或搜索比较适合这种处理方式。 如果 JavaScript 代码需要存取 DOM 元素,即必须在主线程上运行,那么可以考虑批处理的方式,把任务细分为若干个小任务,每个小任务耗时很少,各自放在一个 requestAnimationFrame 中回调运行。 Render(Style & Layout)render 部分包括 Recalculate Style 和 Layout,如果发现 render 部分耗时较长,需要分别从这两部分进行分析。如果这一帧,触发了强制 layout,Timeline 会用红色角标标出,这是需要进行优化的地方。 如果需要具体分析 Recalculate Style,可以选中 Recalculate Style 部分,查看受影响的元素个数、触发 Recalculate Style 函数以及警告提示。 如果需要分析 Layout,可以选中 Layout 部分,同 Recalculate Style 一样。 那么如何提升 Render 部分的性能问题呢? 降低样式计算和复杂度添加或移除一个 DOM 元素、修改元素属性和样式类、应用动画效果等操作,都会引起 DOM 结构的改变,从而导致浏览器需要重新计算每个元素的样式、对页面或其一部分重新布局(多数情况下),这就是所谓的样式计算。 因此需要减少执行样式计算的元素的个数,降低样式选择器的复杂度,使用基于 class 的方式,如以 BEM (Block, Element, Modifier)的方式编写 CSS 代码,能达到最好的样式计算的性能,因为这种方式建议对每个 DOM 元素都只使用一个样式 class。 避免大规模、复杂的布局布局,就是浏览器计算 DOM 元素的几何信息的过程:元素大小和在页面中的位置。 尽可能避免触发布局,当修改了元素的样式属性之后,浏览器会将会检查为了使这个修改生效是否需要重新计算布局以及更新渲染树。对于 DOM 元素的几何属性的修改,比如 width/height/left/top 等,都需要重新计算布局。通过 DevTools Timeline 可以查看页面性能的分解图,从而判断布局过程是否是页面性能的瓶颈,参考能触发布局、绘制或渲染层合并的 CSS 属性清单 使用 flexbox 替代老的布局模型,在相同数量的元素下 Flexbox 布局,不仅达到了同样的显示效果,而且时间消耗也大大降低,因此需要在对页面布局模型的性能分析的基础之上,来选择一种性能最优的布局方式,而且应该努力避免同时触发所有布局 避免强制同步布局事件的发生,将一帧画面渲染到屏幕上的处理顺序是执行 JavaScript 脚本、样式计算、布局。但还可以强制浏览器在执行 JavaScript 脚本之前先执行布局过程,这就是所谓的强制同步布局。为了避免触发不必要的布局过程,应该首先批量读取元素样式属性,然后再对样式属性进行写操作,过早地同步执行样式计算和布局是潜在的页面性能的瓶颈之一 避免快速连续的布局,如果想确保编写的读写操作是安全的,你可以使用 FastDOM,它能帮你自动完成读写操作的批处理,还能避免意外地触发强制同步布局或快速连续的布局 PaintPaint(绘制)其实是生成元素呈现的像素的过程。在页面的整个被解析、执行、渲染的过程中,Paint 通常来说是代价最高的一步,因此尽量减少 Paint 时间,甚至避免 Paint 的发生,对页面性能的提升有着很重要的作用。 如何触发 Paint 触发了 Layout,那么一定会触发 Paint 改变元素的一些非几何属性,如背景、颜色、阴影等,不会触发 Layout,但是依然会触发 Paint 如何定位 PaintTimeline 中绿色部分就是 Paint 部分,Summary 会展示绘制的总体情况,包括绘制的元素、元素本身绘制耗时、元素子元素绘制耗时。如果发现绘制的区域超过了本来期望的区域,那么就是需要优化的。更加详细的信息,可以切换至 Paint Profiler,包括了每个具体 Paint 的调用和 Paint 区域截图。当页面发生 Paint 时,如果发现不期望的区域进行了 Paint,那么这里就是可以优化的。 如何优化 Paint 提升元素渲染层为合成层,页面的绘制并非是在单层画面里完成的,浏览器的渲染原理,是浏览器将 DOM tree 映射成 GraphicsLayer tree,中间是经过了 RenderObject、RenderLayer 的一系列映射。元素所在的层提升为合成层后可以减少 Repaint 使用 transform 或 opacity 实现动画,对于独立的合成层应用 transform 和 opacity 是不会触发 Repaint 的,因此尽量对 transform 或 opactiy 应用动画来实现效果 减少绘制区域,对于不需要重新绘制的区域应尽量避免绘制,已减少绘制区域,比如一个 fix 在页面顶部的固定不变的导航 header,在页面底部某个区域 Repaint 时,整个屏幕包括 fix 的 header 也会被重绘,而对于固定不变的区域,期望其并不会被重绘,因此可以通过之前的方法,将其提升为独立的合成层 降低绘制复杂度,对于无法避免的 Paint,需要尽可能的减少 Paint 的消耗,有些效果的 Paint 代价十分昂贵,比如绘制一个阴影可能就比绘制一个边框更加耗时,因此开发过程中,需要研究能够实现相同的效果,同时却能达到更小的 Paint 消耗的方法 Composite渲染层合并,对页面中 DOM 元素的绘制是在多个层上进行的。在每个层上完成绘制过程之后,浏览器会将所有层按照合理的顺序合并成一个图层,然后显示在屏幕上。 提升为合成层简单说来有以下优点: 合成层的位图,会交由 GPU 合成,比 CPU 处理更快 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层 对于 transform 和 opacity 效果,不会触发 layout 和 paint 对于诸如 fixed 的合成层,移动时不会触发 repaint 提升动画效果的元素合成层的好处是不会影响到其他元素的绘制,因此,为了减少动画元素对其他元素的影响,从而减少 paint,可以把动画效果中的元素提升为合成层。提升合成层的最好方式是使用 CSS 的 will-change 属性。 合理管理合成层创建一个新的合成层并不是无消耗的,它得消耗额外的内存和管理资源。实际上,在内存资源有限的设备上,合成层带来的性能改善,可能远远赶不上过多合成层开销给页面性能带来的负面影响。同时,由于每个渲染层的纹理都需要上传到 GPU 处理,因此还需要考虑 CPU 和 GPU 之间的带宽问题、以及有多大内存供 GPU 处理这些纹理的问题。 防止层爆炸同合成层重叠也会使元素提升为合成层,虽然有浏览器的层压缩机制,但是也有很多无法进行压缩的情况。因此显式声明的合成层,还可能由于重叠原因不经意间产生一些不在预期的合成层,极端一点可能会产生大量的额外合成层,出现层爆炸的现象。 3 总结现在随着 web 应用的复杂性日益增加,其性能优化的重要性越来越突出,且性能优化的方法、技巧、工具也越来越丰富和复杂,本文所展示的内容仅仅只是管中窥豹,希望读者们可以在此讨论一些在实际场景中的性能优化问题以及解决方案。 讨论地址是:精读《2017 前端性能优化备忘录》 · Issue ##39 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《2021 前端新秀回顾》","path":"/wiki/WebWeekly/前沿技术/《2021 前端新秀回顾》.html","content":"当前期刊数: 226 2021 JavaScript Rising Stars 每年都会对前端开源项目进行点评,其依据是去年 Star 的增幅。Star 虽然只是一个维度,但至少反应了流行度,根据这个排行榜可以大体分析出前端社区的趋势。 精读该榜单包含整体榜单、前端框架、Node 框架、构建工具、Vue 生态、React 生态、CSS-In-JS、测试、移动端、桌面、静态建站、状态管理、GraphQL 共 13 个子榜单,都是前端开源最活跃的几个领域,下面分别介绍。 整体榜单第一名 zx 是一个命令行工具,它基于 Node 语法拓展了 Bash 支持,可以非常方便的进行 Node 与 Bash 之间的输入输出,就像 Node 原生就支持 Bash 一样。它解决了离不开 Bash,但 Bash 写起大段逻辑不如 Node 自然的痛点。 第二名 vite 是去年最闪耀的星,它是一个 bundless 概念的前端构建工具,最初服务于 vue,后来进行框架无关升级后,在 react、angular 生态都大受欢迎。它解决了 webpack 编译太慢,其他 bundless 方案不够开箱即用且存在大量兼容问题的痛点。 第三名 next.js 2016 年开始的项目,是一个大而全的 React 全家桶,定位就是各大厂都会自己做一套的前端一体化框架,但它更时髦,不断加入许多流行功能比如 Server Component。这和 next.js 所在的明星公司 Vercel 有关,这家公司挖了大量开源知名人物,包括 Svelte 作者与 React 团队核心成员,所以也许未来社区的新玩具会先用在 next.js 再独立开源。它给出了前端最佳实践,并解决了没有精力持续给项目进行全方位优化,或追逐不上潮流的问题,因为 next.js 本身正在成为前端潮流的发源地。 第四名 react 不用多说了,数据驱动、响应式编程、函数式的领军框架,它改变了前端开发效率。 第五名 tauri 比 electron 更轻量的桌面应用开发框架,基于任何前端框架。它解决了前端开发者遇到桌面应用开发场景时各平台巨大的原生开发学习成本的痛点。 第六名 Tailwind CSS 是 css 框架,它提供了大量语义化 className,提供了许多最佳实践,让你有机会把 css 打理的井然有序。它解决了前端项目 css 杂乱无章又没有人真的在意的痛点。 第七名 vscode 宇宙级 IDE,它解决了程序员没有真正趁手软件写代码的痛点。 第八名 Slidev 是一个把 markdown 渲染成 PPT 的框架,基于 vite + vue 等技术栈开发。用它开发的 PPT 非常简洁美观,非常适合在公开场合分享时使用,不仅看起来赏心悦目,还可以不经意间切换到 Markdown 源码 hotfix 一下小错误,展示出你的极客精神。它解决了你真的只想展示几句话,但又要以 PPT 方式 show 出来的痛点。 第九名 NocoDB 是一个支持多种数据源的数据库 UI 管理工具。但其实它有更大的格局,即对标 airtable,即用 NocoDB 连接数据库后,一切数据可视化的操作与功能都成为了可能,且提供了大量工作常用的甘特图、电子表格等视图,并可互相转换,最终其实数据存储到连接的数据库,但你无需关心细节。它解决了基于二维表格数据开发各类生产工具需投入大量研发资源的痛点。 第十名 Vue 和 React 一样不多说了。 前端框架第一名 react 在整体榜单里了。 第二名 Vue 也在整体榜单里了。 第三名 svelte 是一个类似 vue 的框架,但特色是极度重视编译时,而忽略运行时,即运行时除了必要逻辑外是完全不引入任何 runtime 框架的。说实话我觉得和 vue、react 相比在正儿八经项目中并没有核心优势,因为它并没有那种魔法能力,可以极大的减少大型项目体积与提升性能,反而会受制于其语法与编译时的特性产生副作用。但唯一一个好处是框架无关,即利用 svelte 编译的组件几乎没有额外运行时框架代码,可以最低成本,最大隔离性的与其他项目结合。 第四名 angular 笔者已经很久没有关注 angular 框架了,无法给出什么点评。但从 svelte 新增热度超过 angular 来看,可能大部分开发者对 angular 的态度和我一样。 第五名 solid 类似 svelte,提前编译,按需打包,重要的是,其类似 React useEffect 的 API createEffect 在依赖变化后,仅该函数会重新执行,而不会导致整个组件重新执行,在点对点更新上做得更极致。 前端框架的亮点是 svelte 与 solid 的概念,即重编译时,轻运行时,更加原子化的更新粒度,与更直接的调用原生浏览器方法带来性能提升。很难不让人觉得这是一个前端框架新趋势,但我翻了不少资料发现,这种创新带来的收益在正常项目里微乎其微,所以实际上 2021 年前端框架还是没能跳出三巨头创造新的概念,而以 svelte 与 solid 为代表的 “静态化” 框架只能算微创新。 Node 框架第一名 next.js 在整体榜单里了,在 Node 框架一骑绝尘。 第二名 nest 是一个 node 版 server 框架,支持传统的 Controller、Module、Service,支持用装饰器申明路由、控制器等,语法上比较时髦。 第三名 Strapi 专门为 API 场景服务,提供了一个 API 管理后台,解决了只需要一个便捷 API 管理,而不希望了解一个大而全的后端框架的痛点。 第四名 remix 其实和 next.js 定位差不多,由 react-router 作者开发,才开源不久,需要进一步观察。 第五名 nuxt.js 是 vue 领域的 next.js。 值得一提的是,svelte 也有自己的专属框架 sveltekit,所以 Node 后端框架之争大部分其实在打全栈的牌,毕竟 Node 的优势就是支持 js 语言,而当前端应用基于某个框架编写时,如果有一个 Node 框架可以无缝集成这个前端框架,它就比非 Node 框架更优。 不过大厂几乎都是前后端分离的,所以这种全栈优势框架在国内没有太多出场机会,如果你是一个个人博主,还是首推使用全栈框架建站。 构建工具第一名 vite 在整体榜单里了,在构建工具里也是一骑绝尘。 第二名 esbuild 是用 go 编写的构建工具,适用使用范围更广,其压缩模块在 bundless 还未成熟时就被各大构建全家桶提前集成了,而 vite 也是基于 esbuild 进行编译的,但 vite 的火热度更高,说明了整体 bundless 方案已在 2021 年成熟了。 第三名 swc 因采用 rust 编写而知名,类似 esbuild,但因为依托 rust 编译到 wasm 的特性,支持了在线编译器,非常方便。swc 还被大量新生代构建工具作为基建,这在 精读《Rust 是 JS 基建的未来》 时提到过。 第四名 turborepo 是用 go 写的 monorepo 项目管理工具,是 lerna 的替代品。 第五名 nx 也是一个 monorepo 管理工具。 与框架不同,构建工具往往呈现套娃结构,不是你中有我,就是我中有你,每个热门库都重点解决某一块关键问题,不断套娃套娃,最后套成一个很棒的全家桶。 Vue 生态第一名 Slidev 在整体榜单里了。 第二名 Vue Element Admin 基于 vue 的管理后台,在权限验证有一些最佳实践,使用 vuex 管理状态。 第三名 Headless UI 是一个完全无样式的基础组件库,支持 React 与 Vue,官网的例子都是利用 Tailwind CSS 内置样式组合而成的。它解决了 UI 组件库绑定样式后,自定义样式 “实际上非常恶心” 的痛点。 第四名 Naive UI 是一个 Vue 组件库,没有太多特别之处,但竟然上了排行榜。看了一下 star 趋势,在 2021.6 月份 star 涨幅是之后的十倍,估计刚开源推广了一波,后续涨幅很慢了,不出意外明年会跌出这个榜单。 第五名 vue-next 即 vue3,star 数量只有 vue2 的 13%,但今年 star 增幅有 vue2 的一半。 vue3 还自带了状态管理库 pinia,其生态已经非常完备。 React 生态第一名 next.js 在整体榜单里了。 第二名 Ant Design 虽然立志成为西湖区最好的 React 组件库,但事实上已经成为了全球最好的 React 组件库。 第三名 MUI 就是大名鼎鼎的 material design UI 组件库,我对它影响最深的是按钮点击后出现的水波纹,这是 material design 的一大特色。早在 2014 年就创建了,在 Ant Design 没火的时候,是开源组件库首选。 第四名 remix 在 Node 框架榜单里了,和 next.js 一样,是绑定了 React 生态的 Node 框架,所以也出现在 React 生态中。 第五名 react-use 是很小巧的 React Hook 库,提供了如 usePrevious、useDebounce 等常用的 Hook。 看完整个 React 生态榜单,无论是优质生态库数量,还是去年增长的 Star 数,都比 Vue 生态更胜一筹。这背后是无副作用的纯函数与自动依赖收集的响应式视图之争,甚至在 React 生态里也有比如 mobx-react 等优质 MVVM 库,这两种编程范式都会长期并存。 CSS-In-JS第一名 vanilla-extract 作为 2021 年的黑马,主打零运行时与 TS 支持。零运行时是通过 @vanilla-extract/webpack-plugin 插件在编译时就完成内容输出。 第二名 styled-components 是推出最早,也最成熟的一个 CSS-In-JS 框架,虽然版本间出现过运行时不兼容让我放弃过,但不得不说是这个方向的鼻祖。 第三名 stitches 和第一名很像,也主打零运行时,不过没有提对 TS 是否友好。 第四名 Twin 基于 Tailwind CSS 实现了 CSS-In-JS 版的语法,可以认为是内置了一套最佳实践的 CSS-In-JS 库,也没解决太大的痛点,只是如果你同时喜欢 Tailwind CSS 与 CSS-In-JS,可能会爱屋及乌的选择 Twin。 第五名 Emotion 也是一个相对完备的库,基本上 CSS-In-JS 各类语法都能支持。 相比传统 CSS-In-JS 库,第一名 vanilla-extract 的零运行时是一大亮点,是这个方向的新趋势。 测试第一名 Playwright 是一个跨浏览器跨平台的测试框架,可以利用 js 代码打开任意 url 地址截图或者对比,解决了搭建自动化测试平台需要从零开始编写底层框架的痛点。 第二名 Storybook 是非常有名的文档工具,很多开源组件、项目的文档都基于 Storybook 创建。神奇的是它还支持单元测试,在你访问 UI 组件时进行测试并打印出测试结果。Storybook 已经变成了一个 all-in-one 的组件开发工具。 第三名 Cypress 与 Playwright 且诞生比较早,但由于不支持多 tab 页面,且仅支持 js,所以仅在前端流行,在测试工程师角度却不如支持多语言的 Playwright 好用。 第四名 Puppeteer 是 2017 年谷歌推出基于 Chrome 无头浏览器的测试工具,但 2020 年微软的 Playwright 具有跨浏览器特性还是更胜一筹。 第五名 Jest 是代码级别单测工具的佼佼者,覆盖了全框架,只要你想对代码进行单元测试,选 Jest 是不会错的。 测试框架围绕单测与浏览器测试这两个子领域,2021 年在浏览器测试领域出现了跨浏览器这个特色方向,在单测领域没有太大变化,顶多出了一个 Vitest 让单测跑得更快,这个库在 2022 年稳定后可能会大放异彩,甚至可能因为 Vite 流行的原因取代 Jest。 移动端第一名 ReactNative 是基于 React 的 Mobile Native 开发框架,笔者用过一段时间,只能说不能抱有太大期待,因为极大的局限了 web 语法,如果你觉得仅掌握前端知识就可以轻松使用,那么一定会让你失望,不要一开始就抱着这种期待。另外跨端真是非常痛,比如 SwitchAndroid、SwitchIOS 让你感受不到 Write Once, Run everywhere(虽然官方也没这么说)。 第二名 Ionic 是一个跨前端框架的跨平台构建工具,解决了 ReactNative 无法 Run everywhere 的痛点,但也带来了不够灵活的问题,即无法使用平台特定特性。 第三名 Expo 是基于 ReactNative 的一站式跨端开发工具,它的 App 使用非常傻瓜化,并且内置了调试能力,可以说是把 ReactNative 要踩的坑帮你踩完了。 第四名 Quasar 可以认为是 Vue 版的 ReactNative。 第五名 Flipper 是一个 Native 应用调试工具,可以认为是手机应用版本的 Chrome DevTools,支持连接远程终端,解决了手机应用难以用电脑调试的痛点。 其实还少了 Flutter 这个优秀框架,虽然不属于前端方向,但就像前端脚手架越来越多用 Rust、Go 写一样,Native 用 Dart 也是可以接受的。 从前端角度看移动端,唯一需求就是 Write Once,Run Anywhere,然后再把调试体验做好一些,Native 的兼容性、拓展性做强一些,就是一个完美方案了。 说到跨端,基于 Flutter 的 kraken 也绝对值得一提,它利用 Flutter 高一执行渲染层能力,并解决了 Dart 生态对前端不友好的问题,做了一个 html+css+js 到 dart 的桥接层,如果明年可以在手淘稳定覆盖大量场景,那一定是个值得考虑的方案。 总结还有更多榜单就不一一总结了,如果觉得不过瘾,可以去 2021 JavaScript Rising Stars 翻翻这些 top star 项目的介绍和源码深入了解一下。 最后总结一下 2021 前端领域的几个关键特征: 编程语言全面开花。以后 JS 开发者不等于前端开发者了,因为 Go、Rust、Dart、C++ 语言都可以为前端服务,并且 2021 年是真的有不少场景做到了生产环境可用,不论我们接不接受,前端不止有 JS 一种语言了。 前端开发全家桶逐渐产生技术壁垒。在前几年,抄一个前端全家桶很容易,在过程中还可以学到很多底层知识,但现在前端全家桶的积累越来越多,涉及的领域越来越广,甚至 next.js 引入的特性会超越你自己调制的全家桶,这说明全家桶的知识量已经逐渐达到个人知识广度的极限,如果你没有足够精力持续学习,跟进时代步伐的最好方式是使用一个成熟的全家桶。 讨论地址是:精读《2021 前端新秀回顾》· Issue ##390 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《30 行 js 代码创建神经网络》","path":"/wiki/WebWeekly/前沿技术/《30 行 js 代码创建神经网络》.html","content":"当前期刊数: 33 本期精读的文章是:30 行 js 代码创建神经网络。 懒得看文章?没关系,稍后会附上文章内容概述,同时,更希望能通过阅读这一期的精读,穿插着深入阅读原文。 1 引言 自从 Alpha Go 打败了李世石,大家对深度学习的体感更加的强烈,人工智能也越来越多的出现在大家的生活之中。很多人也会谈论,程序员什么时候会被人工智能给替代?与其慌张,在人工智能的潮流下,不断学习新的人工智能相关技术,武装自己,才是硬道理。 本文介绍了如何使用Synaptic.js 创建简单的神经网络,解决异或运算的问题。 2 内容概要神经网络中的神经元和突触对神经网络有所了解的人都知道,神经网络就是构建类似人脑的神经系统,在人脑的神经系统中,存在一种非常重要的细胞,叫神经元。在神经网络中,你可以把神经元理解为一个函数,它接受一些输入返回一些输出结果,其中Sigmoid 神经元是一种非常常用的神经元,这种神经元以 Sigmoid 函数 作为激活函数。Sigmoid 函数接受任意的数值,输出 0 到 1 之间的值,大家可以看看常见的几种 Sigmoid 函数的函数曲线。 知道了 Sigmoid 函数了,我们可以看一个具体的 Sigmoid 神经元 例子。 在这个例子中 7 和 3 是权重参数,-2 是偏差,最左边的 1 和 0 是 输入层 中的两个节点,通过如下的计算,得到了一个 隐藏层 节点 5。 然后将节点 5 输入到一个 Sigmoid 函数,得到一个 输出层 节点 1。 如何构建神经网络有了神经元,将所有的神经元连接起来,就构建了一个 神经网络。如下图,神经元间的箭头,可以理解为是一种 “突触”。 完成神经网络的构建了,你可以用来识别手写数字、垃圾邮件判断等众多领域。当然就像上面的例子,好的模型依赖于正确的权重 和 偏差的选择。在实际工作中,每次完成神经网络的训练,我们都会拿训练的结果来对测试样式进行预测,得到算法的准确率,然后尝试选择更好的权重和偏差,期望达到更好的准确度,这个学习的过程称为反向传播。通过大量的学习后,最终才会得到更好的预测准确率。 代码实现下面附上代码的实现。 const { Layer, Network } = window.synaptic;var inputLayer = new Layer(2);var hiddenLayer = new Layer(3);var outputLayer = new Layer(1);inputLayer.project(hiddenLayer);hiddenLayer.project(outputLayer);var myNetwork = new Network({ input: inputLayer, hidden: [hiddenLayer], output: outputLayer});// train the network - learn XORvar learningRate = .3;for (var i = 0; i < 20000; i++) { // 0,0 => 0 myNetwork.activate([0,0]); myNetwork.propagate(learningRate, [0]); // 0,1 => 1 myNetwork.activate([0,1]); myNetwork.propagate(learningRate, [1]); // 1,0 => 1 myNetwork.activate([1,0]); myNetwork.propagate(learningRate, [1]); // 1,1 => 0 myNetwork.activate([1,1]); myNetwork.propagate(learningRate, [0]);} 简单而言,运行 myNetwork.activate([0,0]) 时,[0, 0]是输入值,它对应的异或运算的结果是 false, 也就是 0。 这个是前向的传播,所以称为 激活 网络,每次前向传播之后,我们需要做一次反向传播来更新权重和偏差。 反向传播就是通过这行代码来做的: myNetwork.propagate(learningRate, [0]),其中 learningRate 是告诉神经网络如何调整权重的常量,第二个参数 [0]是异或运算的结果。 经过 20000 次学习,我们得到如下的结果: console.log(myNetwork.activate([0,0])); -> [0.015020775950893527]console.log(myNetwork.activate([0,1]));->[0.9815816381088985]console.log(myNetwork.activate([1,0]));-> [0.9871822457132193]console.log(myNetwork.activate([1,1]));-> [0.012950087641929467] 对运算结果取最近的整数,我们就可以得到正确异或运算的结果。 3 精读读原文的时候,大家可能主要对反向传播如何修正权重和偏差会有所疑问,作者给出的引文A Step by Step Backpropagation Example — by Matt Mazur 很详细的解释了整个过程。 方便大家理解,我以上面的异或运算的例子,简单的分析一下整个过程。其中我们选取的激活函数是:Logistic 函数。 当我们输[0, 0]时,我们选取一些任意的权重和偏差,计算过程如下: 为了方便后续的推到,我们可以通过一些符号来简单的描述一下h1和最终记过output计算的过程: 计算得到结果 o 后,我们可以通过平方误差函数 来计算误差。我们在上面的运算中得到的 output = 0.73673, 目标值是对 [0,0] 取异或运算的值,也就是: target = 0,带入上面的公式得到的误差值为: 0.271385。 到这里我们进行反向传播的过程,也就是说我们需要确认新的参数: w1, w2, w3, w4, w5, w6, b1, b2. 我们先计算新的 w5,这里是通过计算误差函数相对于w5的偏导来得到新的参数的,我们可以通过下面的链式求导来计算偏导。 得到 w5 对应的偏导后,我们通过下面的公式来计算新的w5参数: 其中, 0.3 就是我们在代码中设置的 learningRate 的值。 重复上面相似的过程,我们可以计算其他参数的值,这里就不再累述。 4. 总结本文介绍了使用Synaptic.js 创建简单的神经网络,解决异或运算的问题过程,也对反向传播的过程进行了简单的解释。文中实现神经网络的代码非常简单,包行注释也不超过 30 行,但是只会代码会有点囫囵吞枣的感觉,大家可以参考文章中给的引文,了解更多的算法原理。 相关资料1. A Step by Step Backpropagation Example — by Matt Mazur 2. Hackers Guide to Neural Nets — by Andrej Karpathy 3. NeuralNetworksAndDeepLarning — by Michael Nielsen 4. Synaptic.js 更多讨论 讨论地址是:精读《30 行 js 代码创建神经网络》 · Issue ##45 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《@types react 值得注意的 TS 技巧》","path":"/wiki/WebWeekly/前沿技术/《@types react 值得注意的 TS 技巧》.html","content":"当前期刊数: 147 1 引言从 @types/react 源码中挖掘一些 Typescript 使用技巧吧。 2 精读泛型 extends泛型可以指代可能的参数类型,但指代任意类型范围太模糊,当我们需要对参数类型加以限制,或者确定只处理某种类型参数时,就可以对泛型进行 extends 修饰。 问题:React.lazy 需要限制返回值是一个 Promise<T> 类型,且 T 必须是 React 组件类型。 方案: 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,第二个参数是初始化参数,其实第二个参数的类型是第一个参数中回调函数第一个参数的类型,那我们怎么将这两个参数的关系联系到一起呢? 方案: 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,而且传入参数不同,返回值的类型也不同。 方案: 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 元素,我们希望这个函数具备类型收窄的功能。 方案: 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 等固定属性。 方案: 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 表示这种类型的变量可以作为函数执行: const App: FunctionComponent = () => <div />;App.displayName = "App"; 3 总结看完文章内容,相信你已经可以独立读懂 @types/react 这个包的所有类型定义! 更多基础内容可以阅读 精读《Typescript2.0 - 2.9》 与 精读《Typescript 3.2 新特性》,由于 TS 更新频繁,后续 TS 技巧可能继续以阅读源码方式进行,希望这次选用的 React 类型源码可以让你印象深刻。 讨论地址是:精读《@types/react 值得注意的 TS 技巧》 · Issue ##245 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《API 设计原则》","path":"/wiki/WebWeekly/前沿技术/《API 设计原则》.html","content":"当前期刊数: 23 本期精读的文章是:API 设计原则 1 引言 优秀的 API 之于代码,就如良好内涵对于每个人。好的 API 不但利于使用者理解,开发时也会事半功倍,后期维护更是顺风顺水。 一个骨灰级资深的同事跟我说过,任何在成长的代码库,至少半年到一年就要重构一次,否则失去的不仅是活力,更失去了可维护性与可用性。 2 内容概要由于本文已经是翻译后的文章,概要只列出不涉及 c++ 概念的思路框架,细节请移步译文。 好 API 的 6 个特质极简且完备、语义清晰简单、符合直觉、易于记忆和引导 API 使用者写出可读代码。 静态多态尽量减少继承,让相似的类具备相似的 API,而不是统一继承一个父类。因为统一继承会带来 API public 数量过多,父级无意义的方法对用户产生误导。 基于属性的 API属性指的是对象状态,通过属性为粒度的 API,有利于使用者理解 API 的含义,但需注意关联属性的顺序性。 API 语义和文档比如传值 -1 的含义是什么?如果 API 文档不像 http status codes 一样健全,建议通过枚举的方式增加可读性。 命名的艺术不要使用缩写,保持一致性。类命名以功能分组作为后缀,比零散命名更易懂。 函数命名要体现出是否包含副作用,参数过多时以对象作为传参,布尔参数改为枚举类型,或者分解为两个语义化 API。 3 精读以下精读是对原文观点的补充。 Const 入参eslint 有一条规则,不要直接改变入参的值。这个规则的初衷是解决函数副作用问题,禁止可能产生副作用代码的产生。但却可以通过如下方式避免: function (num) { let scopeNum = num scopeNum = 5} 这是从包含指针类型编程语言学习过来的,因为当 *num 表示指针时,代表代码可能产生副作用(修改入参的风险)。而 js 并不总是这样的,不但没有指针申明,基本类型也总是通过拷贝进入传参,非基本类型通过引用传递,也就是会发生通过如上代码绕过检测,却依然产生副作用(改变函数入参)的情况。 为了避免副作用,建议引入 flow 或 typescript,通过 const 关键字与约定约束入参行为: function (const num) { ...} 将没有副作用函数的所有入参定义为 const 类型,静态检查阶段就禁止了对值的直接修改,同时因为有这个关键字的约束,在函数体内也约定不要通过引用浅拷贝修改它的值。 但这也无法彻底避免,仍然可以通过如下写法绕过检测,修改入参: function (const num) { const scopeNum = { ...num } scopeNum.a.b = 'c'} 在 js 中没有完美的方式避免对入参的修改,但通过对入参修饰 const 关键字,可以对使用者明确这是纯函数,对开发者提示不要写有副作用的代码。 c++ 的 const 定义从编译开始就完全杜绝了修改的可能性,虽然有 const_cast “去” const 行为,但仍然不会改变入参的值(虽然可以后续对值修改,指针指向保持不变,但用 const 修饰的入参值永远不会改变)。 统一关键字库所有 api 定义之前,先抽离业务和功能语义的关键字,统一关键字库; 可以更好的让多人协作看起来如出一辙, 而且关键字库 更能够让调用者感觉到 符合直觉、语义清晰; 关键字库也是项目组新同学 PREDO 的内容之一, 很有带入感; 单一职责接口设计尽量要做到 单一职责,最细粒度化; 可以使用组合的方式把多个解耦的单个接口组合在一起作为一个大的功能项接口; 接口设计的单一职责,也更方便多人协作时候的扩展和组合; 面向未来的多态对于接口参数的扩展,我们要做到面向扩展开放,面向修改关闭; 升级做到要兼容,否则会导致大批量的下游不可用。 同时也要避免过度设计,当抽象功能只有一处使用时,尽量不要过早抽象。 不要重复局部命名class User { // good setName() {} // bad setUserName() {}} 在有上下文环境的调用中,减少不必要的描述可以提高 API 的精简和清晰度。 同时要避免过度使用解构,因为解构会丢失上下文,让我们对变量来源一无所知: const { setName } = this.props.store.userconst { setVisible } = this.props.store.article 上述 setName setVisible 脱离了 user article 作用域,当隔着几百行调用时,早已不知所云。 4 总结参考优秀类库是设计 API 很好的方法之一,比如本文 c++ 参考的 Qt、js 可以参考 jQuery。 当 API 稳定后,需要花时间整理文档,因为写文档的思考过程可能推动着你重构和优化代码。 最后,如果有精力,最好每半年重构一次(然后完整跑一遍测试)! 讨论地址是:精读《API 设计原则》 · Issue ##34 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Ant Design 3","path":"/wiki/WebWeekly/前沿技术/《Ant Design 3.html","content":"当前期刊数: 41 精读《Ant Design 3.0 背后的故事》引言2018 年初,蚂蚁金服 See Conf 上第一个分享《Ant Design 3.0 背后的故事》给很多人带来了启发。主题精彩又深刻,值得反复咀嚼。 内容概要设计体系Atomic Design 书中提到模块化思路以及原子级的模块抽象的方法启发了很多设计师。而解读者中较为说法较作者认同。设计体系是一个具包容性且充满生命力的东西。包容性指的是从组件库到设计语言到设计方法等所有和产品设计相关的方面。而生命力指的是它并非静态的内容,而是可以应对不断变化的环境,是一个不断进化的过程。 Ant Design System蚂蚁的设计体系中设计语言、设计资产以及体验策略是设计体系最核心的三块内容,分别对应的解决设计体系中的 Consistency、Efficiency 以及 Better UX 的目标抽象成三个关键词。接着对这三部分有详细的解说。 设计语言设计语言的核心是设计价值观,最初的版本是微小、确定以及幸福感,分别指代我们的细节微创新、模块化思路以及在研发体验上的追求。而 3.0 版在保留 『确定』的基础上,又发掘出了『自然』。Ant Design 认为,一切看似自然的事物在背后都是有数学/物理规律可循的。 主字号、字阶和行高提出两个问题:多大的主字号是自然的?多大的行高是自然的? 第一个问题来源于肉眼到物体之间的距离,物体的高度以及这个三角形的角度,构成了一个三角函数的关系。我们把实际的数值传入得出 14px 这个标准字号。字阶的生长规律来源于经典的字阶和古典音阶具备韵律上的相似性,因此用幂函数的字体计算公式来表达之间的关系。由 14px 这个基础再乘上一个系数。第二个问题来源于字阶函数的反增长。得到了一组原始的行高数组。 基于此再进行一定的调整。最终得出每一个字号之间的间隔都为 16px,行高和字体之间都相差 8。 布局与色彩布局 3.0 也是基于和字体相似的思路完成的,来源于斐波那契双数组的启发。帮助设计师在布局设计决策中更好的实现动态的秩序感。 色彩相对于字体以及布局来说更加会偏向与感性,但依然可以通过搭建三维模拟空间的方式,通过大量观察和调试,掌握了不同颜色在自然光照下对应的 HSB 的变化规律,最终形成了我们 3.0 的色板。 设计资产设计资产是以 『模块化』为核心思路展开的。 在过去的一年 AntD 在 Atomic Design 以及 GE predix design 的启发上,形成了符合自己特点的 E (examples)、T (template)、C(components)、G(global styles) 的抽象思路。 体验策略体验策略的核心思路是以任务为导向的。主要通过四个方面去构建体验策略:流程与方法、度量体系、运营活动和最佳实践。 精读整个分享感受颇深,但就『自然』这个关键词才生了我自己的疑问:主字号、字阶和行高是否存在关系? 在梳理的这层关系上,缺少了对字体的讨论,而字体又是非常关键的因素。我在之后,断断续续查阅了不少资料。写下关系背后还要考虑的问题。 字体 我在查阅资料的时候,发现 x-height 在西文字体中的概念。在英文字体的设计中,字体的高度体包含三部份,以基线 (baseline) 为中央,以上称之上行区域 (ascender area),基准线内称之为 x-height,以下称为下行区域 (descender area)。小写西文字母中的核心部件都位于 x-height 位置中,这一位置也被称为排版的核心位置,是引导视线流动的关键。放一张在 wikimedia 上的图: 每一种西文字体的 x-height 是不一样的。非常幸运,Jukka Korpela 做了一网站专门可以测量 web 上字体的 x-height。其中,Arial 的 X-HEIGHT RATIO 是 0.519,而 Tahoma 是 0.545,Times New Roman 是 0.448。Arial 和 Times New Roman 之间的比例差距大概 17%。 西文字体在正文中很少用全大写字体排版也是因为小写字体有一种错落的美感,但问题是每一种字体在同一字号下的字体大小有一定差距的。这里就带出了一些问题,我们常常在英文排版中遇到多种字体混排的情况,为了体现不同的信息,比如 code,比如特殊信息等等。 这是第一个问题,第二个问题是中文字体没有 x-height,也就是说中文字体就等同于西文字体的全大写,错落的美感都没有。而且中文有一个问题是因为字形之前的差异,每个字之间的留白都不尽相同,看上去又会差一些。一般情况下,靠行间距来弥补视觉差,但总体上要排版达到西文字体的效果要花一些功夫。 屏幕 我们的字体大小使用的是 points(pt),points 是一个物理衡量,它的标准是 72 points per inch(PPI)。但我们不同设备的 PPI 都是不一样的,那么造成了同样的设定在不同屏幕下看到的字体也会有差异。 Macbook Pro 的 PPI 是 220,Dell XPS 的 PPI 是 165,iPhone 7 有 326,但 iPhone 7p 的 PPI 有 401,而一般 HDTV 的 PPI 是 30。其中,iPhone,Macbook 都是 retina 屏。 在目前这个时代,越来越多的设备在 web 化,那么我们所接触到的设备会越来越多。PPI 的不同造成了我们所需要基准字体的不同,一套设计语言是否考虑更普适的情况,还是考虑不同设备之间的情况,这是一个问题。 目前来看,我们的确可以根据不同 PPI 去设定,但这个设定自然带来极高的成本。 视觉角度 我们虽然定义了一个 14px,但在不同的视觉角度下看到的 14px 其实也是不一样的。这个 14px 的原始值是基于人与设备一定的角度下算出来的。我们知道,人对于不同设备,甚至在不同环境下看到屏幕的距离和角度都是不同的,因为字体物理大小不等视觉大小。 基于此,我认为普适的字体基准是不存在的,只能在很多条件的约束下给定的一个值,对于公式也是一样的。我们在一定的范围内,公式表达出一种自然的特征,但这种自然是有范围的。 总结曾经有国外的设计师有写文用黄金比例来构建字号与行高的关系,在一片喝彩中看到了资深设计师的反对,主要也是从以上和一些其它因素来说关系是比较难设定。 今天看到我们的设计与理性之间建立的关系,我还是比较坚信建立这种关系背后带来的是更大的价值。"},{"title":"《AsyncAwait 优越之处》","path":"/wiki/WebWeekly/前沿技术/《AsyncAwait 优越之处》.html","content":"当前期刊数: 4 本期精读的文章是:6 Reasons Why JavaScript’s Async/Await Blows Promises Away 1 引言 我为什么要选这篇文章呢? 前端异步问题处理一直是一个老大难的问题,前有 Callback Hell 的绝望,后有 Promise/Deferred 的规范混战,从 Generator 配合 co 所向披靡,到如今 Async/Await 改变世界。为什么异步问题如此难处理,Async/Await 又能在多大程度上解决我们开发和调试过程中遇到的难点呢?希望这篇文章能给我们带来一些启发。 当然,本文不是一篇针对前端异步问题综合概要性的文章,更多的是从 Async/Await 的优越性谈起。但这并不妨碍我们从 Async/Await 的特点出发,结合自己在工作、开发过程中的经验教训,认真的思考和总结如何更优雅、更高效的处理异步问题。 2 内容概要Async/Await 的优点: 语法简洁清晰,节省了很多不必要的匿名函数 直接使用 try…catch… 进行异常处理 添加条件判断更符合直觉 减少不必要的中间变量 更清晰明确的错误堆栈 调试时可以轻松给每个异步调用加断点 Async/Await 的局限: 降低了我们阅读理解代码的速度,此前看到 .then() 就知道是异步,现在需要识别 async 和 await 关键字 目前支持 Async/Await 的 Node.js 版本(Node 7)并非 LTS 版本,但是下一个 LTS 版本很快将会发布 可以看出,文中提到 Async/Await 的优势大部分都是从开发调试效率提升层面来讲的,提到的问题或者说局限也只有不痛不痒的两点。 让我们来看看参与精读的同学都提出了哪些深度观点: 3 精读本次提出独到观点的同学有:@javie007 @流形 @camsong @Turbe Xue @淡苍 @留影 @黄子毅 精读由此归纳。 Async/Await 并不是什么新鲜概念参与精读的很多同学都提出来,Async/Await 并不是什么新鲜的概念,事实的确如此。 早在 2012 年微软的 C## 语言发布 5.0 版本时,就正式推出了 Async/Await 的概念,随后在 Python 和 Scala 中也相继出现了 Async/Await 的身影。再之后,才是我们今天讨论的主角,ES 2016 中正式提出了 Async/Await 规范。 以下是一个在 C## 中使用 Async/Await 的示例代码: public async Task<int> SumPageSizesAsync(IList<Uri> uris) { int total = 0; foreach (var uri in uris) { statusText.Text = string.Format("Found {0} bytes ...", total); var data = await new WebClient().DownloadDataTaskAsync(uri); total += data.Length; } statusText.Text = string.Format("Found {0} bytes total", total); return total;} 再看看在 JavaScript 中的使用方法: async function createNewDoc() { let response = await db.post({}); // post a new doc return await db.get(response.id); // find by id} 不难看出两者单纯在异步语法上,并没有太多的差异。这也是为什么 Async/Await 推出后,获得不少赞许和亲切感的原因之一吧。 其实在前端领域,也有不少类 Async/Await 的实现,其中不得不提到的就是知名网红之一的老赵写的 wind.js,站在今天的角度看,windjs 的设计和实现不可谓不超前。 Async/Await 是如何实现的根据 Async/Await 的规范 中的描述 —— 一个 Async 函数总是会返回一个 Promise —— 不难看出 Async/Await 和 Promise 存在千丝万缕的联系。这也是为什么不少参与精读的同学都说,Async/Await 不过是一个语法糖。 单谈规范太枯燥,我们还是看看实际的代码。下面是一个最基础的 Async/Await 例子: async function test() { const img = await fetch('tiger.jpg');} 使用 Babel 转换后: 'use strict';var test = function() { var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee() { var img; return regeneratorRuntime.wrap(function _callee$(_context) { while (1) { switch (_context.prev = _context.next) { case 0: _context.next = 2; return fetch('tiger.jpg'); case 2: img = _context.sent; case 3: case 'end': return _context.stop(); } } }, _callee, this); })); return function test() { return _ref.apply(this, arguments); };}();function _asyncToGenerator(fn) { return function() { var gen = fn.apply(this, arguments); return new Promise(function(resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function(value) { step("next", value); }, function(err) { step("throw", err); }); } } return step("next"); }); };} 不难看出,Async/Await 的实现被转换成了基于 Promise 的调用。值得注意的是,原来只需 3 行代码即可解决的问题,居然被转换成了 52 行代码,这还是基于执行环境中已经存在 regenerator 的前提之一。如果要在兼容性尚不是非常理想的 Web 环境下使用,代码 overhead 的成本不得不纳入考虑。 Async/Await 真的是更优秀的替代方案吗不知道是个人观察偏差,还是大家普遍都有这样的看法。在国内前端圈子里,并没有对 Async/Await 的出现表现出多么大的兴趣,几种常见的观点是:「还不是基于 Promise 的语法糖,没什么意思」、「现在使用 co 已经能完美解决异步问题,不需要再引入什么新的概念」、「浏览器兼容性这么差,用 Babel 编译又需要引入不少依赖,使用成本太高」等等。 在本次精读中,也有不少同学指出了使用 Async/Await 的局限性。 比如,使用 Async/Await 并不能很好的支持异步并发。考虑下面这种情况,一个模块需要发送 3 个请求并在获得结果后才能进行渲染,3 个请求之间没有依赖关系。如果使用 Async/Await,写法如下: async function mount() { const result1 = await fetch('a.json'); const result2 = await fetch('b.json'); const result3 = await fetch('c.json'); render(result1, result2, result3);} 这样的写法在异步上确实简洁不少,但是 3 个异步请求是顺序执行的,并没有充分利用到异步的优势。要想实现真正的异步,还是需要依赖 Promise.all 封装一层: async function mount() { const result = await Promise.all([ fetch('a.json'), fetch('b.json'), fetch('c.json') ]); render(...result);} 此外,正如在上文中提到的,async 函数默认会返回一个 Promise,这也意味着 Promise 中存在的问题 async 函数也会遇到,那就是 —— 默认会静默的吞掉异常。 所以,虽然 Async/Await 能够使用 try…catch… 这种符合同步习惯的方式进行异常捕获,你依然不得不手动给每个 await 调用添加 try…catch… 语句,否则,async 函数返回的只是一个 reject 掉的 Promise 而已。 异步还有哪些问题需要解决虽然处理异步问题的技术一直在进步,但是在实际工程实践中,我们对异步操作的需求也在不断扩展加深,这也是为什么各种 flow control 的库一直兴盛不衰的原因之一。 在本次精读中,大家肯定了 Async/Await 在处理异步问题的优越性,但也提到了其在异步问题处理上的一些不足: 缺少复杂的控制流程,如 always、progress、pause、resume 等 缺少中断的方法,无法 abort 当然,站在 EMCA 规范的角度来看,有些需求可能比较少见,但是如果纳入规范中,也可以减少前端程序员在挑选异步流程控制库时的纠结了。 3 总结Async/Await 的确是更优越的异步处理方案,但我们相信这一定不是终极处理方案。随着前端工程化的深入,一定有更多、更复杂、更精细的异步问题出现,同时也会有迎合这些问题的解决方案出现,比如精读中很多同学提到的 RxJS 和 js-csp。 讨论地址是:那些年我们处理过的异步问题 · Issue ##6 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《BI 搭建 - 筛选条件》","path":"/wiki/WebWeekly/前沿技术/《BI 搭建 - 筛选条件》.html","content":"当前期刊数: 166 筛选条件是 BI 搭建的核心概念,我们大部分所说的探索式分析、图表联动也都属于筛选条件的范畴,其本质就是一个组件对另一个组件的数据查询起到筛选作用。 筛选组件是如何作用的我们最常见的筛选条件就是表单场景的查询控件,如下图所示: 若干 “具有输出能力” 的组件作为筛选组件,点击查询按钮时触发其作用组件重新取数。 注意这里 “具有输出能力” 的组件不仅是输入框等具有输入性质的组件,其实所有具备交互能力的组件都可以,甚至可以由普通组件承担筛选触发的能力: 一个表格的表头点击也可以触发筛选行为,或者柱状图的一个柱子被点击都可以,只要进行到这层抽象,组件间联动本质也属于筛选行为。 同样重要的,筛选作用的组件也可以是具备输入能力的组件: 当目标组件是具备筛选能力组件时,这就是筛选联动场景了,所以 筛选联动也属于普通筛选行为。至于目标组件触发取数后,是否立即修改其筛选值,进而触发后续的筛选联动,就完全由业务特性决定了。 一个组件也可以自己联动自己筛选,比如折线图点击下钻的场景,就是自己触发了筛选,作用到自己的例子。 什么是筛选组件任何组件都可以是筛选组件。 可能最容易理解的是输入框、下拉框、日期选择器等具备输入特征的组件,这些组件只能说天然适合作为筛选组件,但不代表系统设计要为这些组件特殊处理。 扩大想一想,其实普通的按钮、表格、折线图等等 具有展示属性的组件也具有输入特性的一面,比如按钮被点击时触发查询、单元格被点击时想查询当前城市的数据趋势、折线图某条线被点击时希望自身从年下钻到月等等。 所以 不存在筛选组件这概念,而是任何组件都具有筛选的能力,因此筛选是一种任何组件都具有的能力,而不局限在某几个组件上,一旦这么设计,可以做到以下几点: 实现输入类组件到展示类组件的筛选,符合基本筛选诉求。 实现展示类组件到展示类组件的筛选,属于图表联动图表的高级功能。 实现输入类组件到输入类组件的筛选,属于筛选联动功能。 实现组件自身到自身的筛选,实现下钻功能。 下面介绍 bi-designer 的筛选条件设计。 筛选条件设计基于上述分析,bi-designer 在组件元信息中没有增加所谓的筛选组件类型,而是将其设定为一种筛选能力,任何组件都能触发。 如何触发筛选组件调用 onFilterChange 即可完成筛选动作: import { useDesigner } from "@alife/bi-designer";const InputFilter = () => { const { onFilterChange } = useDesigner(); return ( <input onChange={(event) => () => onFilterChange(event.target.value)} /> );}; 但这种开发方式违背了 低侵入 的设计理念,我们可以采用组件与引擎解构的方式,让输入框变更的时候直接调用 props.onChange ,这个组件保持了最大的独立性: const InputFilter = ({ onChange }) => { return <input onChange={(event) => () => onChange(event.target.value)} />;}; 那渲染引擎怎么将 onFilterChange 映射到 props.onChange 呢?如下配置 DSL 即可: { "props": { "onChange": { "type": "JSExpression", "value": "this.onFilterChange" } }} 筛选影响哪些组件一般筛选组件会选择作用于的目标组件,类似下图: 这些信息会存储在筛选组件的组件配置中,即 componentInstance.props,筛选目标组件在 componentMeta.eventConfigs 组件元信息的事件中配置: 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 推导出最终取数参数: 最终,组件元信息只要写一个 getFetchParam 回调函数即可,可以自动拿到作用于它的筛选组件,而不用关心是哪些配置导致了关联,只要响应式的去处理筛选作用即可。 import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { // 组装取数参数 getFetchParam: ({ componentInstance, filters }) => { // 结合 componentInstance 与 filters.map... 返回取数参数 },}; 筛选组件间联动带来的频繁取数问题对于筛选联动的复杂场景,会遇到频繁取数的问题。 假设国家、省、市三级联动筛选条件同时 filterFetch 作用于一个表格,这个表格取数的筛选条件需要同时包含国家、省、市三个参数,但我们又设置了 国家、省、市 这三个筛选组件之间的 filterFetch 作为筛选联动,那么国家切换后、省改变、联动市改变,这个过程筛选值会变化三次,但我们只想表格组件取数函数仅执行最后的一次,怎么办呢? 如上图所示,其实每个筛选条件在渲染引擎数据流中还存储了一个 ready 状态,表示筛选条件是否就绪,一个组件关联的筛选条件只要有一个 ready 不为 true,组件就不会触发取数。 因此我们需要在筛选变化的过程中,总是保证一个筛选组件的 ready 为 false,等筛选间联动完毕了,所有筛选器的 ready 为 true,组件才会取数,我们可以使用 filterReady 筛选依赖配置: 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,其他流程没有任何感知。 若干筛选组件聚合成一个查询控件除了联动外,也会存在防止频繁查询的诉求,希望将多个筛选条件绑定成一个大筛选组件,在点击 “查询” 按钮时再取数: 可以利用 筛选作用域 轻松实现此功能,只需要两步: 筛选组件设置独立筛选作用域import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { // 通过 componentInstance 判断,如果是全局筛选器内部,则设置 filterScope filterScope: ({ componentInstance }) => ["my-custom-scope-name"],}; 这样,这批筛选组件就与其作用的组件属于不同的 筛选作用域 了,所以筛选不会对其立即生效,功能实现了一半。 确认按钮点击时调用 submitFilterScopeimport { useDesigner } from '@alife/bi-designer'const componentMeta: Interfaces.ComponentMeta = { const { submitFilterScope } = useDesigner() // 点击确认按钮时,调用 submitFilterScope('my-custom-scope-name')}; 你可以在点击查询按钮后调用 submitFilterScope 并传入对应作用域名称,这样作用域内筛选组件就会立即对其 target 组件生效了。 至于确认按钮、UI 上的聚合,这些你可以写一个自定义组件去做,利用 ComponentLoader 把筛选组件聚合到一起加载,总之功能与 UI 是解耦的。 如果你对原理感兴趣,可以再多看一下这张图: 突破筛选作用域然而实际场景中,可能存在更复杂的组合,见下面的例子: 筛选器 1 同时对 筛选器 2、表格 产生筛选作用 filterFetch,但对 表格 的作用希望通过查询按钮拦截住,而对 筛选器 2 的作用希望能立即生效,对于这个例子有两种方式解决: 最简单的方式就是将 筛选器 1、筛选器 2 设置为相同作用域 group1,这样就通过作用域分割自然实现了效果,而且这本质上是两个筛选器 UI 不在一起,但筛选作用域相同的例子: 但是再变化一下,如果筛选器 2 也对表格产生筛选作用,那我们将 筛选器 1、筛选器 2 放入同一个 group1 等于对表格的查询都会受到 “查询” 按钮的控制,但 我们又希望筛选器 2 可以立即作用于表格: 如图所示,我们只能将 筛选器 1 的筛选作用域设置为 group1,这样 筛选器 2 与 表格 属于同一个筛选作用域,他们之间筛选会立即生效,我们只要解决 筛选器 1 不能立即作用于 筛选器 2 的问题即可,可以通过 ignoreFilterScope 方式突破筛选作用域: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《CSS Animations vs Web Animations API》","path":"/wiki/WebWeekly/前沿技术/《CSS Animations vs Web Animations API》.html","content":"当前期刊数: 16 本期精读文章 CSS Animations vs Web Animations API | CSS-Tricks 译文地址 CSS Animation 与 Web Animation API 之争 1. 引言 前端是一个很神奇的工种,一个合格的前端至少要熟练的使用 3 个技能,html、css 和 javascript。在传统的前端开发领域它们三个大多时候是各司其职,分别负责布局、样式以及交互。而在当代的前端开发中,由于多种原因 javascript 做的事情愈来愈多,大有一统全栈之势。服务端的 nodejs,让前端同学可以用自己的语言来开发 server。即便是在前端,我们现在好像也很少写 html 了,在 React 中出来了 JSX,在其他的开发体系中也有与之类似的前端模板代替了 html。我们好像也很少写 css 了,sass、less、stylus 等预处理器以及 css in js 出现。此外,很多 css 领域的的工作也可以通过 javascript 以更加优雅和高效的方式实现。今天我们来一起聊聊 CSS 动画与 WEB Animation API 的优劣。 2. 内容概要JavaScript 规范确实借鉴了很多社区内的优秀类库,通过原生实现的方式提供更好的性能。WAAPI 提供了与 jQuery 类似的语法,同时也做了很多补充,使得其更加的强大。同时 W3C 官方也为开发者提供了 web-animations/web-animations-js polyfill。下面简单回顾下文章内容: WAAPI 提供了很简洁明了的,我们可以直接在 dom 元素上直接调用 animate 函数: var element = document.querySelector('.animate-me');var animation = element.animate(keyframes, 1000); 第一个参数是一个对象数组,每个对象表示动画中的一帧: var keyframes = [ { opacity: 0 }, { opacity: 1 }]; 这与 css 中的 keyframe 定义类似: 0% { opacity: 0;}100% { opacity: 1;} 第二个参数是 duration,表示动画的时间。同时也支持在第二个参数中传入配置项来指定缓动方式、循环次数等。 var options = { iterations: Infinity, // 动画的重复次数,默认是 1 iterationStart: 0, // 用于指定动画开始的节点,默认是 0 delay: 0, // 动画延迟开始的毫秒数,默认 0 endDelay: 0, // 动画结束后延迟的毫秒数,默认 0 direction: 'alternate', // 动画的方向 默认是按照一个方向的动画,alternate 则表示交替 duration: 700, // 动画持续时间,默认 0 fill: 'forwards', // 是否在动画结束时回到元素开始动画前的状态 easing: 'ease-out', // 缓动方式,默认 "linear"}; 有了这些配置项,基本可以满足开发者的动画需求。同时,文中也提到了在 WAAPI 中很多专业术语与 CSS 变量有所不同,不过这些变化也更显简洁。 在 dom 元素上调用 animate 函数之后返回 animation 对象,或者通过 ele.getAnimation 方法获取 dom 上的 animation 对象。借此开发者可以通过 promise 和 event 两种方式对动画进行操作: 1. event 方式myAnimation.onfinish = function() { element.remove();} 2. promise 方式myAnimation.finished.then(() => element.remove()) 通过这种方式相对 dom 事件获取更加的简洁优雅。 3. 精读参与本次精度的同学主要来自 前端外刊评论 - 知乎专栏 的留言,该部分主要由文章评论总结而出。 WAAPI 优雅简洁web animation 的 api 设计优雅而又全面。文中比对了常见的 WAAPI 与 CSS Animation 对照关系,我们可以看到 WAAPI 更加简洁,而且语法上也更加容易为开发者接受。确实,在写一些复杂的动画逻辑时,需要灵活控制性强的接口。我们可以看到,在处理串连多个动画、截取完整动画的一部分时更加方便。如果非要说有什么劣势,个人在开发中感觉 keyframe 的很多只都只能使用字符串,不过这也是将 css 写在 js 中最常见的一种方式了。 低耦合CSS 动画中,如果需要控制动画或者过渡的开始或结束只能通过相应的 dom 事件来监听,并且在回调函数中操作,这也是受 CSS 本身语言特性约束所致。也就是说很多情况下,想要完成一个动画需要结合 CSS 和 JS 来共同完成。使用 WAAPI 则有 promise 和 event 两种方式与监听 dom 事件相对应。从代码可维护性和完整性上看 WAAPI 有自身语言上的优势。 兼容性和流畅度兼容性上 WAAPI 常用方法已经兼容了大部分现代的浏览器。如果想现在就玩玩 WAAPI,可以使用官方提供的 polyfill。而 CSS 动画我们也用了很久,基本作为一种在现代浏览器中提升体验的方式,对于老旧的浏览器只能用一些优雅的降级方案。至于流畅度的问题,文中也提到性能与 CSS 动画一般,而且提供了性能优化的方案。 4. 总结目前看来,CSS 动画可以做到的,使用 WAAPI 同样可以实现。至于浏览器支持问题,WAAPI 尚需要 polyfill 支持,不过 CSS 动画也同样存在兼容性问题。可能现在新的 API 的接受度还不够,但正如文章结尾处所说:『现有的规范和实现看起来更像是一项伟大事业的起点。』 讨论地址是:精读《CSS Animations vs Web Animations API》 · Issue ##22 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Caches API》","path":"/wiki/WebWeekly/前沿技术/《Caches API》.html","content":"当前期刊数: 88 1 引言caches 这个 API 是针对 Request Response 的。caches 一般结合 Service Worker 使用,因为请求级别的缓存与具有页面拦截功能的 Service Worker 最配。 本周精读的文章是 cache-api,介绍了浏览器缓存接口的基本语法。 2 概述浏览器拥有全局变量 caches 操作缓存。 caches 包含任意命名空间,可以通过 caches.open 创建或访问。 const myCache = await caches.open("myCache"); 添加缓存通过 add 添加缓存。由于 caches 缓存是基于请求的,因此参数可以是一个 URL 地址,或一个完整的 Request 对象: // URL onlymyCache.add("/subscribe");// Full request objectmyCache.add(new Request('/subscribe', { method: "GET", headers: new Headers({ 'Content-Type': 'text/html' }), /* more request options */}); 每执行 add 时,浏览器都会主动请求并缓存返回的 Response。 可以通过 addAll 批量添加缓存: myCache.addAll(["/subscribe", "/assets/images/profile.png"]); 读取缓存通过 match 读取缓存。与 add 类似,参数可以是 URL 地址或完整 Request 对象,同时支持 matchAll: const res = await myCache.match("/subscribe"); 更新缓存通过 add 或 put 更新缓存。 当某个请求缓存需要更新时,你可以重新执行 add 操作。 同时 put 也可以更新缓存,你可以手动构造返回值,这样浏览器就不需要发请求了: const request = new Request("/subscribe");const fetchResponse = await fetch(request);myCache.put(request, fetchResponse); 销毁缓存通过 delete 销毁缓存。 你可以销毁某个路径的缓存: myCache.delete("/subscribe"); 也可以销毁某个缓存命名空间: caches.delete("myCache"); 结合 service Worker可以利用 addEventListener('fetch') 监听浏览器请求时机,并在匹配到缓存时,直接替换为返回结果,当缓存不存在时才继续发请求。 self.addEventListener("fetch", (e) => { e.respondWith( // Check if item exists in cache caches.match(e.request).then((cachedResponse) => { // If found in cache, return cached response if (cachedResponse) return cachedResponse; // If not found, fetch over network return fetch(e.request); }); );}); 3 精读笔者利用 caches API + service worker 实现了纯浏览器端的后端渲染。 首先基于下面三个基本事实: 利用 service worker 可以拦截请求。 caches 可以主动 put 修改缓存。 react-dom/server 可以在浏览器端执行。 这三个能力组合一下,我们真的可以实现前端 SSR: 打开页面时,利用 web worker 调用 react-dom/server 构造一个 SSR 字符串。 利用 caches.put 添加当前页面缓存,将 react-root 部分塞入构造好的 SSR 字符串。 下次打开页面时,优先命中缓存,仿佛是后端提供了 SSR 服务,但其实服务是由上一次浏览器提供的。 前端渲染有几个好处: 不消耗服务器计算资源,如果页面有百万 UV,可能一天就能节省几十万元服务器电费。 不消耗服务器存储资源,如果页面是千人千面的,后端 SSR 存储成本巨大,但分摊到个人电脑就不成问题。 不需要写两套代码。虽然服务端渲染重复利用前端资源,但 DOM 环境等都是模拟出来的,且前端代码还存在内存泄露风险,许多 SSR 的前端代码必须判断前后端环境,给维护造成了巨大负担。在前端渲染下这不成问题,我们的口号是:前端代码请交给浏览器执行。 笔者将这套前端渲染能力封装在 前端工程化工具 Pri 中,开启配置项 useServiceWorker=true clientServerRender=true 尝试。 后面有机会单独选一篇精读介绍 前端渲染,你也可以直接参考笔者 简陋的实现:由于 service worker 必须存在一个实体文件,因此脚手架会自动生成它,所以你看到的运行代码是一堆字符串。 4 总结前端渲染是一个较为极端的例子,caches 更多用来缓存简单的静态页面,静态博文,或者不经常变动的后端接口。 留下一个思考题:你还能想到 caches 的其他用法吗?欢迎留言。 讨论地址是:精读《Caches API》 · Issue ##124 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《Compilers are the New Frameworks》","path":"/wiki/WebWeekly/前沿技术/《Compilers are the New Frameworks》.html","content":"当前期刊数: 49 本期精读文章 《Compilers are the New Frameworks》 1 引言本期文章篇幅短小却言简意骇,文中开头作者就抛出自己的观点 Web 框架正在从运行库转变为优化编译器。 作者主要从编译性能方面入手,也提到 WebAssembly 可能将会是下一代 Web 应用的落脚点,因此他也建议 Web 开发者们深入了解学习编译器的工作原理。 2 概述目前业界流行使用一整套工具来搭建前端项目,如 webpack、webpack-dev-server、babel、scss、react、redux、react-router …,在项目开发期间需要花费大量时间去进行工程性能优化、编写大量的构建配置项等,从现在前端工程的复杂度以及前端开发的工作量来看,前端框架已经不能再仅仅只是一个单独的视图层或数据处理层,而应该是一套相对完整的框架,它不仅提供如何编写前端页面的方法,同时也应该考虑代码构建编译的性能、页面间路由的跳转、新语法的兼容等一系列问题。 这也正是本期精读文章抛出的观点,Web 框架正在从运行库转变为优化编译器,或者说 Web 框架需要将优化编译性能考虑进去。 PriJs & UmiJsPriJs & UmiJs 二者正是以上述观点为基础的,基于 react 并包含了工具 & 路由 & 性能优化 & 数据流等强约定弱配置的前端一站式框架,通过约定、自动生成和解析代码等方式来辅助开发,减少开发者在性能&配置&路由&构建上耗费的时间,可以更专注于业务逻辑。 构建工具webpack 是目前主流的前端代码构建工具,但其复杂的配置一直是前端开发者头疼之处,PriJs & UmiJs 框架内部解决了这一难题,它们将 webpack 复杂的性能优化配置全部内置化,使项目在 0 配置的基础上直接支持 PWA、Automatic code splitting、Tree Shaking、Auto dll、Import on demand、Auto pick shared modules、Scope Hoist、Dynamic import、Service Worker、Sass Loader 等。 页面&路由PriJs & UmiJs 提供页面生成模版,并自动根据项目页面生成路由,通过单页面或多页面特性决定路由跳转的类型,默认提供 404 页面。 数据流PriJs & UmiJs 虽然是基于 react 的前端一站式框架,暂不支持 vue、angular 等,但并不局限数据流的使用的方式,可以根据项目需求使用任意数据流方式,如 redux、mobx 等。 插件机制PriJs & UmiJs 提供了灵活的插件机制,使项目能够拥有强大的定制能力,通过插件机制可以变更 webpack 配置、修改路由规则、修改页面模版、新增命令、使用任意数据流、定制项目规范和约定等。 其它此外,PriJs 还支持 markdown 格式、支持 Deploy to github pages、支持 Typescript 等。 PriJs & UmiJs 前端一站式框架实际上是提供了一整套的前端开发解决方案,它不仅仅只是单纯的一个运行库,而是将构建性能&工具&路由等一系列问题全部解决,这种做法在一定程度上不正是在说明 Compilers are the New Frameworks。 读者们对此肯定有很多不同的观点和看法,不妨各抒己见。 3 精读精读文章作者建议 Web 开发者学习编译器工作原理,对于前端开发者来说可以从与前端现在和未来息息相关的 JIT 和 WebAssembly 入手学习编译器相关原理。 JITJIT(Just-in-Time)主要是针对 javascript 这一解释型语言所做的性能优化,即浏览器引入编译器来解决解释器性能低效的问题,形成混合的模式。 监视器浏览器在 js 引擎中增加一个监视器,用于监控通过解释器的代码的运行情况,并将同一行代码运行若干次标记为 warm,将同一行代码运行很多次标记为 hot。 基线器JIT 会将 warm 代码段放到基线编译器中,并将编译结果存储起来。该代码段的每一行都会被编译成一个 stub,并以 行号 + 变量类型 为索引。如果监视器监视到了执行同样的代码和变量类型,就直接将对应的已编译版本提交给浏览器执行,而不用重新通过解释器来翻译,通过这样的做法可以加快执行速度。 优化器JIT 会将 hot 代码段放到优化编译器中进行代码优化,不过需要遵循优化规则:即如果代码循环中每次迭代的对象都有相同的形状,那么就认为它以后迭代的对象的形状也是相同的。但 javascript 是没有类型定义的,就无法确保每次代码迭代的对象都会具有相同类型,因此在代码运行前会检查其规则是否合理,如果合理则执行优化代码,如果不合理则丢弃优化代码,重新回到解释器或基线器。大多数浏览器为了防止引起 优化 - 丢弃优化 的无限循环,一般会对优化次数做限制,比如 JIT 做了超过 10 次 优化 - 丢弃优化 的操作,那么就不再执行优化编译。 JIT 在优化提升 javascript 性能的同时也会增加多余的其它开销,主要是对代码的监视和编译时间的开销,具体包括: 优化和丢弃优化的开销 监视器存储的内存开销 丢弃优化时恢复存储的内存开销 基线版本和优化后版本的内存开销 而 WebAssembly 从更底层去解决这部分多余开销,进一步提升 Web 应用的性能。 WebAssembly为什么说 WebAssembly 更为高效,性能更好? 在 JS 引擎中性能消耗的分布大致为:将源码转为解释器可运行代码 -> 基线&优化编译器的运行 -> 优化-丢弃优化的过程 -> 执行代码 -> 垃圾回收&内存清理,这个过程是交叉进行的。 而 WebAssembly 却只要简单的三个步骤即可完成 JS 引擎的整个交叉执行过程。 Parse当到达浏览器时,JS 源码需要被解析成 AST(抽象语法树)变成字节码提供给引擎编译,而 WebAssembly 却不需要这种转换,因为其本身就是字节码,因此它只需对代码进行 decode 并检查其正确性即可。 Compile + Optimize这是执行代码编译和优化的阶段,在这个阶段 WebAssembly 的性能优于 JS 的主要原因为: WebAssembly 是有类型定义的代码,不需要在编译前运行代码来获取变量类型 WebAssembly 不需要像 JS 那样当变量类型改变时需要将代码编译成不同版本 WebAssembly 不需要在编译阶段做太多的优化工作 Re-optimize当 JIT 在执行 JS 阶段发现变量类型不合理,就会丢弃优化代码重新进行 优化 - 丢弃优化 的循环,而 WebAssembly 中的变量类型都是确定的,JIT 不需要检查变量类型的合理性,因此并没有重优化阶段。 Execute如果开发者了解 JIT 的内部实现机制,当然是可以针对性的写出符合 JIT 标准的代码,使之具有更高的执行效率,但通常开发者为了代码可读性更好而使用的编码模式往往却不适合编译器对代码的优化,而且不同浏览器的优化规则也不尽相同,导致 JS 的执行效率并不高。 WebAssembly 正是为了编译器而设计的,很多 JIT 为 JS 所做的优化 WebAssembly 并不需要,使得 WebAssembly 专注于提供执行效率更高的指令。 Garbage collectionJS 不支持开发者手动清理内存,而是由 JS 引擎自动做垃圾回收,因此垃圾回收的时机并不可控,有可能会在一个不合适的时机执行,而且也会增加代码执行的开销。而对于 WebAssembly 而言,其内存操作是由开发者手动控制的,虽然会增加一些开发成本,不过这也使的代码执行效率更高。 4 总结本文从 Web 框架正在从运行库转变为优化编译器 这一观点切入,讨论了 PriJs & UmiJs 前端框架的思路转变,简洁的描述了 JIT 的工作原理以及 WebAssembly 相比于 JS 的性能优势。 本文主要希望读者可以积极参与讨论此观点,因此并没有长篇剖析 JIT & WebAssembly 的深刻原理,但我相信深入学习编译器的工作原理对 Web 开发者来说绝对是受益匪浅的事情,后续文章将对 WebAssembly 进行深入探讨和剖析。 5 更多讨论 讨论地址是:精读《Compilers are the New Frameworks》 · Issue ##69 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周末发布。"},{"title":"《DOM diff 最长上升子序列》","path":"/wiki/WebWeekly/前沿技术/《DOM diff 最长上升子序列》.html","content":"当前期刊数: 192 在 精读《DOM diff 原理》 一文中,我们提到了 Vue 使用了一种贪心 + 二分的算法求出最长上升子序列,但并没有深究这个算法的原理,因此特别开辟一章详细说明。 另外,最长上升子序列作为一道算法题,是非常经典的,同时在工业界具有实用性,且有一定难度的,因此希望大家务必掌握。 精读什么是最长上升子序列?就是求一个数组中,最长连续上升的部分,如下图所示: 如果序列本身就是上升的,那就直接返回其本身;如果序列没有任何一段是上升的,则返回任何一个数字都可以。图中可以看到,虽然 3, 7, 22 也是上升的,但因为 22 之后接不下去了,所以其长度是有 3,与 3, 7, 8, 9, 11, 12 比起来,肯定不是最长的,因此找起来并不太容易。 在具体 DOM diff 场景中,为了保证尽可能移动较少的 DOM,我们需要 保持最长上升子序 不动,只移动其他元素。为什么呢?因为最长上升子序列本身就相对有序,只要其他元素移动完了,答案也就出来了。还是这个例子,假设原本的 DOM 就是这样一个递增顺序(当然应该是 1 2 3 4 连续的下标,不过对算法来说是否连续间隔不影响,只要递增即可): 如果保持最长上升子序不变,只需要移动三次即可还原: 其他任何移动方式都不会小于三步,因为我们已经最大程度保持已经有序的部分不动了。 那么问题是,如何将这个最长上升子序列找出来?比较容易想到的解法分别有:暴力、动态规划。 暴力解法 时间复杂度: O(2ⁿ) 我们最终要生成一个最长子序列长度,那么就来模拟生成这个子序列的过程吧,只不过这个过程是暴力的。 暴力模拟生成一个子序列怎么做呢?就是从 [0,n] 范围内每次都尝试选或不选当前数,前提是后选的数字要比前面的大。由于数组长度为 n,每个数字都可以选或不选,也就是每个数字有两种选择,所以最多会生成 2ⁿ 个结果,从里面找到最长的长度,即为答案: 这么傻试下去,必然能试出最长的那一段,在遍历过程中记录最长的那一段即可。 由于这个方法效率太低了,所以并不推荐,但这种暴力思维还是要掌握的。 动态规划 时间复杂度: O(n²) 如果用动态规划思路考虑此问题,那么 DP(i) 的定义按照经验为:以第 i 个字符串结尾时,最长子序列长度。 这里有个经验,就是动规一般 DP 返回值就是答案,字符串问题常常是以第 i 个字符串结尾,这样扫描一遍即可。而且最长子序列是有重复子问题的,即第 i 个的答案运算中,包括了前面一些的计算,为了不重复计算,才使用动态规划。 那么就看第 i 项的结果和前面哪些结果有关系了,为了方便理解如图所示: 假设我们看 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 项组合起来更长哦?我们可以举个反例: 很显然,1, 2, 3, 4 组合起来是最长的上升子序列,如果你只看 5, 4,那么得出的答案只能是 4。 正是由于不连续这个特点,我们对于第 i 项,需要和第 j 项依次对比,其中 j=[0,i-1],只有和所有前项都比一遍,我们才放心,第 i 项找到的结果确实是最长的: 那么时间复杂度怎么算呢?动态规划解法中,我们首先从 0 循环到 n,然后对于其中每个 i,都做了一遍 [0,i-1] 的额外循环,所以计算次数是 1 + 2 + ... + n = n * (n + 1) / 2,剔除常数后,数量级是 O(n²)。 贪心 + 二分 时间复杂度: O(nlogn) 说实话,一般能想到动态规划解法就很不错了,再进一步优化时间复杂度就非常难想了。如果你没做过这道题,并且想挑战一下,读到这里就可以停止了。 好,公布答案了,说实话这个方法不像正常人类思维想出来的,具有很大的思维跳跃性,因此我也无法给出思维推导过程,直接说结论吧:贪心 + 二分法。 如果非要说是怎么想的,我们可以从时间复杂度上事后诸葛亮一下,一般 n² 时间复杂度再优化就会变成 nlogn,而一次二分查找的时间复杂度是 logn,所以就拼命想办法结合吧。 具体方案就一句话:用栈结构,如果值比栈内所有值都大则入栈,否则替换比它大的最小数,最后栈的长度就是答案: 先解释下时间复杂度,因为操作原因,栈内存储的数字都是升序的,因此可以采用二分法比较与插入,复杂度为 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,由于每个数字都比之前的大,所以没什么好思考的,直接塞到栈里: 遇到 9 的时候精彩了,此时 9 不是最大的,我们为了抓住稳稳的幸福,干脆把比 9 稍大一点的 11 替换了,这样会产生什么结果? 首先数组长度没变,因为替换操作不会改变数组长度,此时如果 9 后面没有值了,我们也不亏,此时输出的长度 4 依然是最优的答案。我们继续,下一步遇到 11,我们还是把比它稍大的 15 替换掉: 此时我们替换了最后一个数字,发现 3, 7, 9, 11 终于是个合理的顺序了,而且长度和 3, 7, 11, 15 一样,但是更有潜力,接下来 12 就理所应当的放到最后,拿到了最终答案:5。 到这里其实并没有说清楚这个算法的精髓,我们还是回到 3, 7, 9, 15 这一步,搞清楚 9 为什么可以替换掉 11。 假设 9 后面是一个很大的 99,那么下一步 99 会直接追加到后面: 此时我们拿到的是 3, 7, 9, 15, 99,但是你仔细看会发现,原序列里 9 在 15 后面的,因为我们的插入导致 9 放到 15 前面了,所以这显然不是正确答案,但长度却是正确的,因为这个答案就相当于我们选择了 3, 7, 11, 15, 99!为什么可以这么理解呢?因为 只要没有替换到最后一个数,我们心里的那个队列其实还是原始队列。 **即,只要栈没有被替换完,新插入的值永远只起到一个占位作用,目的是为了让新来的值好插入,但如果真的没有新来的值可插入了,那虽然栈内容不对,但至少长度是对的,因为 9 在没替换完的时候其实不是 9,它只是一个占位,背后的值还是 11**。所以不管怎么换,只要没替换掉最后一个,这个替换操作都是无效的,我们再拿一个例子来看: 可见,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 呢?我们看看能替换完是什么情况: 可见,当替换到 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 翻脸了,因为它的实力已经超出原来老大实力了。 那我们前面看似无关紧要的替换,其实就为了不断寻找未来可能的最优解,直到有出头之日那一天,如果没有出头之日,做一个小弟也挺好,长度还是对的;如果有出头之日,那最大长度就更新了,所以这种贪心可以同时兼顾正确性与效率。 最后我们看看,如何在找到答案的同时,还能找到正确的序列呢? 找出正确的序列找出正确的序列并不容易,让我们看下面这个情况: 贪心算法结束后,总长度是对的,但很明显顺序还是错的。为了方便计算,我们存储时转化为下标: 并且使用二维数组存储,这样被替换的数字可以被保留下来。当计算完毕后,我们从最后一位开始向前查找,一旦发现一个值不是单调递减的,就向数组上方继续查找,直到首节点。 因此上面的例子,最终顺序下标是 [0, 1, 2, 3, 4, 5, 9],对应数字为 [10, 20, 30, 40, 50, 60, 61],而且这个数字是潜力最大的最长子序列。 总结那么 Vue 最终采用贪心计算最长上升子序列,付出了多少代价呢?其实就是 O(n) 与 O(nlogn) 的关系,我们看图: 可以看到,O(nlogn) 时间复杂度增长趋势勉强可以接受,特别是在工程场景中,一个父节点的子节点个数不可能太多的情况下,不会占用太多分析的时间,带来的好处就是最少的 DOM 移动次数。是比较完美的算法与工程结合的实践。 讨论地址是:精读《DOM diff 最长上升子序列》· Issue ##310 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《DOM diff 原理详解》","path":"/wiki/WebWeekly/前沿技术/《DOM diff 原理详解》.html","content":"当前期刊数: 190 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 如图所示,理想的 Dom diff 自然是滴水不漏的复用所有能复用的,实在遇到新增或删除时,才执行插入或删除。这样的操作最贴近 Jquery 时代我们手写的 Dom diff 性能。 可惜程序无法猜到你的想法,想要精确复用就必须付出高昂的代价:时间复杂度 O(n³) 的 diff 算法,这显然是无法接受的,因此理想的 Dom diff 算法无法被使用。 关于 O(n³) 的由来。由于左树中任意节点都可能出现在右树,所以必须在对左树深度遍历的同时,对右树进行深度遍历,找到每个节点的对应关系,这里的时间复杂度是 O(n²),之后需要对树的各节点进行增删移的操作,这个过程简单可以理解为加了一层遍历循环,因此再乘一个 n。 简化的 Dom diff 如图所示,只按层比较,就可以将时间复杂度降低为 O(n)。按层比较也不是广度遍历,其实就是判断某个节点的子元素间 diff,跨父节点的兄弟节点也不必比较。 这样做确实非常高效,但代价就是,判断的有点傻,比如 ac 明明是一个移动操作,却被误识别为删除 + 新增。 好在跨 DOM 复用在实际业务场景中很少出现,因此这种笨拙出现的频率实际上非常低,这时候我们就不要太追求学术思维上的严谨了,毕竟框架是给实际项目用的,实际项目中很少出现的场景,算法是可以不考虑的。 下面是同层 diff 可能出现的三种情况,非常简单,看图即可: 那么同层比较是怎么达到 O(n) 时间复杂度的呢?我们来看具体框架的思路。 Vue 的 Dom diffVue 的 Dom diff 一共 5 步,我们结合下图先看前三步: 如图所示,第一和第二步分别从首尾两头向中间逼近,尽可能跳过首位相同的元素,因为我们的目的是 尽量保证不要发生 dom 位移。 这种算法一般采用双指针。如果前两步做完后,发现旧树指针重合了,新树还未重合,说明什么?说明新树剩下来的都是要新增的节点,批量插入即可。很简单吧?那如果反过来呢?如下图所示: 第一和第二步完成后,发现新树指针重合了,但旧树还未重合,说明什么?说明旧树剩下来的在新树都不存在了,批量删除即可。 当然,如果 1、2、3、4 步走完之后,指针还未处理完,那么就进入一个小小算法时间了,我们需要在 O(n) 时间复杂度内把剩下节点处理完。熟悉算法的同学应该很快能反映出,一个数组做一些检测操作,还得把时间复杂度控制在 O(n),得用一个 Map 空间换一下时间,实际上也是如此,我们看下图具体做法: 如图所示,1、2、3、4 步走完后,Old 和 New 都有剩余,因此走到第五步,第五步分为三小步: 遍历 Old 创建一个 Map,这个就是那个换时间的空间消耗,它记录了每个旧节点的 index 下标,一会好在 New 里查出来。 遍历 New,顺便利用上面的 Map 记录下下标,同时 Old 在 New 中不存在的说明被删除了,直接删除。 不存在的位置补 0,我们拿到 e:4 d:3 c:2 h:0 这样一个数组,下标 0 是新增,非 0 就是移过来的,批量转化为插入操作即可。 最后一步的优化也很关键,我们不要看见不同就随便移动,为了性能最优,要保证移动次数尽可能的少,那么怎么才能尽可能的少移动呢?假设我们随意移动,如下图所示: 但其实最优的移动方式是下面这样: 为什么呢?因为移动的时候,其他元素的位置也在相对变化,可能做了 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 更长,所以选择后者。 换成程序去做,可以采用贪心 + 二分法进行查找,详细可以看这道题 最长递增子序列,时间复杂度 O(nlogn)。由于该算法得出的结果顺序是乱的,Vue 采用提前复制数组的方式辅助找到了正确序列。 React 的 Dom diff 假设这么一种情况,我们将 a 移到了 c 后,那么框架从最终状态倒推,如何最快的找到这个动机呢?React 采用了 仅右移策略,即对元素发生的位置变化,只会将其移动到右边,那么右边移完了,其他位置也就有序了。 我们看图说明: 遍历 Old 存储 Map 和 Vue 是一样的,然后就到了第二步遍历 New,b 下标从原来的 1 变成了 0,需要左移才行,但我们不左移,我们只右移,因为所有右移做完后,左移就等于自动做掉了(前面的元素右移后,自己自然被顶到前面去了,实现了左移的效果)。 同理,c 下标从 2 变成了 1,需要左移才行,但我们继续不动。 a 的下标从 0 变成 2,终于可以右移了! 后面的 d、e 下标没变,就不用动。我们纵观整体可以发现,b 和 c 因为前面的 a 被抽走了,自然发生了左移。这就是用一个右移代替两个左移的高效操作。 同时我们发现,这也确实找到了我们开始提到的最佳位移策略。 那这个算法真的有这么聪明吗?显然不是,这个算法只是歪打误撞碰对了而已,有用右移替代左移的算法,就有用左移替代右移的算法,既然选择了右移替代左移,那么一定丢失了左移代替右移的效率。 什么时候用左移代替右移效率最高?就是把数组最后一位移到第一位的场景: 显然左移只要一步,那么右移就是 n-1 步,在这个例子就是 4 步,我们看右移算法图解: 首先找到 e,位置从 4 变成了 0,但我们不能左移!所以只能保持不动,悲剧从此开始。 虽然算法已经不是最优了,但该做的还是要做,其实之前有一个 lastIndex 概念没有说,因为 e 已经在 4 的位置了,所以再把 a 从 0 挪到 1 已经不够了,此时 a 应该从 0 挪到 5。 方法就是记录 lastIndex = max(oldIndex, newIndex) => lastIndex = max(4, 0),下一次移动到 lastIndex + 1 也就是 5: 发现 a 从 0 变成了 5(注意,此时考虑到 lastIndex 因素),所以右移。 同理,b、c、d 也一样。我们最后发现,发生了 4 次右移,e 也因为自然左移了 4 次到达了首位,符合预期。 所以这是一个有利有弊的算法。新增和删除比较简单,和 Vue 差不多。 PS:最新版 React Dom diff 算法如有更新,欢迎在评论区指出,因为这种算法看来不如 Vue 的高效。 总结Dom diff 总结有这么几点考虑: 完全对比 O(n³) 无法接受,故降级为同层对比的 O(n) 方案。 为什么降级可行?因为跨层级很少发生,可以忽略。 同层级也不简单,难点是如何高效位移,即最小步数完成位移。 Vue 为了尽量不移动,先左右夹击跳过不变的,再找到最长连续子串保持不动,移动其他元素。 React 采用仅右移方案,在大部分从左往右移的业务场景中,得到了较好的性能。 讨论地址是:精读《DOM diff 原理详解》· Issue ##308 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Elements of Web Dev》","path":"/wiki/WebWeekly/前沿技术/《Elements of Web Dev》.html","content":"当前期刊数: 51 1 引言本周精读, 来一起总结 web 开发的环节, 知识块和技能点. 是不是像 xx 速成班宣传的一样, 培训三个月, 经验顶三年, 入职 BAT, 年薪三十万? 本文虽然是罗列知识点, 但我想很有意义. 对于学习的人来说, 提供一个路线图. 对从业者来说, 对全局有更好的把控, 利于看到自己的强项和不足. 对组建团队, 更能起到一个点将谱的作用. 在网上我没有搜到任何深入全面的总结, 提供的那几篇已经算稍微好一些的了. 其他的要么太过笼统(前端-后端-数据-运维, 完毕)要么太细太窄(并不是不好, 只是和本文性质不一样). Generalist 和 Specialist 之间永远是一对辩证矛盾, 持续思考. 本文提供了有层级的列表形式, 如果有兴趣的读者可以把它做成概念图形式, 相互关联与距离相关, 可能会有意料之外的效果. 2 列表列表形式, 方便搜索浏览, 加上一些解释和列举 Backend authentication, oauth API design, RESTful, GraphQL payment integration social integration session/cookie management user management Server, e.g. nginx, connection model, conf, rewrite CRM Deployment, Env management, Container rollback/rollforward no downtime, non-disruptive deployment deploy to downstreams, e.g. npm, chrome extension store artefact management, e.g. gzips, OS specific builds container technology e.g. Docker, AWS ami DB schema design ORM language/env specific driver test/seed data backup batching, DB perfgomance query syntax, e.g. SQL, mongo query syntax connection pool/concurrent connection management connection restriction e.g. localhost only MessagingQueue/MiddlewareTesting parallel execution UI automated testing browser/OS compatibility, e.g. headless browser, cloud solutions screenshot diff regression unit test, isolation mocking integration test techniques coverage (line coverage, path coverage etc.), permutations Security CSRF, XSS, SQL injection, DDoS, brute force etc. automated tools OS OS differences, e.g. filesystem, path separator run daemon, startup job, process manager ssh everything bash, zsh, powershell, e.g. wildcard, expansion, syntax everything *nix, du, filesystem, ps, process model, netstat, pipes networking HTTP, and everything it entails e.g. CORS, MIME types, chunked websocket webworker, service worker proxy Web visualization technologies and principles webgl 2D/3D coord system and calculations Web standards, e.g. webassemblyperformance tuning frontend: lighthouse backend: load balancing, perf monitoring and profiling Source control work flow tagging, release, branching PR, collaboration commit message conventions Project management, Product management main success scenario PRD doc, sketch milestone, timeline, estimate daily report, weekly report Software Monitoring performance monitor exception monitor alert and alarm rules logging Engineering lint, prettier, custom rules, autofix editor, IDE, plugins, e.g. intellisense debugging, remote debug, debug mobile Analytics heatmap conversion bounce rate Frontend data flow state management componentization transpile, packing tool, e.g. webpack gulp coffeescript Typescript templating, e.g. handlebar ajax, jsonp etc. Design / styling cascading rules preprocessor, e.g. scss less box model z-index flexbox design principles, layout, color, theme 3 参考阅读https://medium.com/coderbyte/a-guide-to-becoming-a-full-stack-developer-in-2017-5c3c08a1600c https://medium.com/codingthesmartway-com-blog/the-2018-roadmap-to-fullstack-web-development-8884ff02557a https://www.lynda.com/learning-paths/Web/become-a-full-stack-web-developer 4 更多讨论 讨论地址是:精读《Elements of Web Development》 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Deno 1","path":"/wiki/WebWeekly/前沿技术/《Deno 1.html","content":"当前期刊数: 150 1 引言Deno 是什么?Deno 和 Node 有什么关系?Deno 和我有什么关系? Deno 将于 2020-05-13 发布 1.0,如果你还有上面的疑惑,可以和我一起通过 Deno 1.0: What you need to know 这篇文章一起了解 Deno 基础知识。 希望你带着疑问思考,未来 10 年看今天,会不会出现 Deno 官方生态壮大,完全替代 Node 进而影响到 Web 生态的局面呢?这个思考结果会影响到你未来职业发展,你需要学会自己思考,并对这个思考结果负责。 2 介绍 & 精读Deno 的作者是 Ryan Dahl,他是 Nodejs 背后的策划者,曾经说过 我对 Nodejs 感到遗憾的 10 件事。这也是为什么新开一个坑的原因,但 Deno 并不定位为 Nodejs 的替代品,从整体功能来看,Deno 有更大的野心,据我的推测是想要取代现在陈旧的前后端开发模式,让 Deno 一统前后端开发全流程。 Nodejs 是由 C++ 写的,而 Deno 则是由 Rust 写的,并选择了 Tokio 这个异步编程框架,并使用 V8 引擎解析 Javascript,并内置了对 Ts 的解析。 安装Deno 支持如下安装方式: Shell: curl -fsSL https://deno.land/x/install/install.sh | sh PowerShell: iwr https://deno.land/x/install/install.ps1 -useb | iex Homebrew: brew install deno Chocolatey: choco install deno 脚本执行方式为 deno run,可以类比为 node,但功能不同且支持远程文件,实际上远程依赖是 Deno 的一大特色,也是有争议的地方: deno run https://deno.land/std/examples/welcome.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 " });} 安全性Deno 是默认安全的,这体现在默认没有环境、网络访问权限、文件读写权限、运行子进程的能力。所以如果直接运行一个依赖权限的文件会报错: 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 等: deno --allow-read=/etc 上面表示 /etc 文件夹下的文件拥有文件读权限。 除了直接加参数调用、Bash 脚本调用外,还可以用 Make 运行,或者使用类似的 drake 启动。 或者使用 deno install 命令,将脚本转化为一个快捷指令: 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 生态官方库,都保证会持续维护,你更相信谁呢?官方是否有优势要取决于官方自身的实力。 内置 TypescriptDeno 内置支持了 TS,因此不需要 ts-node 我们就可以用 deno run test.ts 运行 Typescript 文件。值得注意的是,Deno 内部也是利用 Typescript 引擎解析为 Js 后交由 V8 引擎解析,因此本质上没太大的变化,只是这样 Deno 的生态会更规范。 由于内置了 TS 支持,自然也不需要写 tsconfig.json 配置了,但你依然可以定制它: deno run -c tsconfig.json [file-to-run.ts] Deno 默认还开启了 TS 严格模式,所以看到这里,可以认为 Deno 是为了构建高质量理想库而诞生的运行环境,基于已有的生态来做,但做了更多内置技术选型,这和 Facebook 的 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 规范,但引用路径必须加上后缀: 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 去中心化” 这件事,虽然引用了网络源,但会引发下面几个问题: 实际上还存在一个 “node_modules”,只是用户看不到。 网络下载速度放到运行时,第一次启动还是很慢。 普通模式下无 lock,必须配合 deps.ts 使用,这个后面会提到。 即使被打上 “中心化恶人” 的 npm 也有去中心化的一面,因为 npm 支持私有化部署,无论是速度还是稳定性都可以由公司自己掌控,从稳定性来说还是 npm 拥有压倒性优势。 三方库Deno 还有第三方库生态,截止目前共有 221 个三方库。 由于 Deno 走网络资源,我们可以借助 Pika 提供的 CDN 服务直接引用网络资源包: import * as pkg from "https://cdn.pika.dev/preact@^10.3.0"; 虽然这样看上去很轻量,但对公司来说还是需要自建一个 “Pika” 保障稳定性,以及做全球 CDN 缓存等的工作。 告别 package.jsonnpm 生态下包信息存放在 package.json,包含但不限于下面的内容: 项目元信息。 项目依赖和版本号。 依赖还进行分类,比如 dependencies、devDependencies 甚至 peerDependencies。 标记入口,main 和 module,还有 TS 用的 types 与 typings,脚手架的 bin 等等。 npm scripts。 随着标准的不断更新,package.json 信息已经非常臃肿了。 对于 Deno 来说,则使用 deps.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 时,就可以这么写了: // import { assert } from "https://deno.land/std@v0.39.0/testing/asserts.ts";import { assert } from "./deps.ts"; 如果需要锁定依赖,可以通过 deno --lock=lock.json 方式申明。 deno docdeno doc <filename> 命令可以根据文件按照 JS Doc 规则生成文档,同时也支持 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> { // ...} 生成文档如下: function send(_: Context, path: string, options: SendOptions): Promise<string | undefined>Asynchronously fulfill a response with a file from the local file system. deno 本身文档就是用这个命令生成的,可以 访问官方文档 查看使用效果。 内置工具链前端 Javascript 工具链相当混乱,虽然业界已有 Umi 等框架做了开箱即用的封装,但回到 Javascript 设计的初衷就是可以在浏览器直接使用的,包括浏览器对不依赖构建工具的模块化支持,注定了未来 Webpack 一定会被消灭。 Deno 通过内置一套工具链的方式解决这个问题,包括: 测试:提供 deno test 命令与 Deno.test() 测试函数。 格式化:提供 vscode 插件。 编译:提供 deno bundle 命令。 不过值得注意的是,在最重要的编译环节,deno bundle 目前提供的能力是相对欠缺的,比如还不支持 Tree Shaking。 用 Rust 等语言提升构建效率是业界一直在尝试的事,比如 @陈成 就基于 esbuild 做了 @umijs/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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Excel JS API》","path":"/wiki/WebWeekly/前沿技术/《Excel JS API》.html","content":"当前期刊数: 225 Excel 现在可利用 js 根据单元格数据生成图表、表格,或通过 js 拓展自定义函数拓展内置 Excel 表达式。 我们来学习一下 Excel js API 开放是如何设计的,从中学习到一些开放 API 设计经验。 API 文档:Excel JavaScript API overview 精读Excel 将利用 JS API 开放了大量能力,包括用户能通过界面轻松做到的,也包括无法通过界面操作做到的。 为什么需要开放 JS APIExcel 已经具备了良好的易用性,以及 formula 这个强大的公式。在之前 精读《Microsoft Power Fx》 提到过,formula 就是 Excel 里的 Power FX,属于画布低代码语言,不过在 Excel 里叫做 “公式” 更合适。 已经具备这么多能力,为何还需要 JS API 呢?一句话概括就是,在 JS API 内可以使用 formula,即 JS API 是公式能力的超集,它包含了对 Excel 工作簿的增删改查、数据的限制、RangeAreas 操作、图表、透视表,甚至可以自定义 formula 函数。 也就是说,JS API 让 Excel “可编程化”,即以开发者视角对 Excel 进行二次拓展,包括对公式进行二次拓展,使 Excel 覆盖更多场景。 JS API 可以用在哪些地方从 Excel 流程中最开始的工作薄、工作表环节,到最细节的单元格数据校验都可通过 JS API 支持,目前看来 Excel JS API 并没有设置能力边界,而且还会不断完善,将 Excel 全生命周期中一切可编程的地方开放出来。 首先是对工作薄、工作表的操作,以及对工作表用户操作的监听,或者对工作表进行只读设置。这一类 API 的目的是对 Excel 这个整体进行编程操作。 第二步就是对单元格级别进行操作,比如对单元格进行区域选中,获取选中区域,或者设置单元格属性、颜色,或者对单元格数据进行校验。自定义公式也在这个环节,因为单元格的值可以是公式,而公式可以利用 JS API 拓展。 最后一步是拓展行为,即在单元格基础上引入图表、透视表拓展。虽然这些功能在 UI 按钮上也可以操作出来,但 JS API 可以实现 UI 界面配置不出来的逻辑,对于非常复杂的逻辑行为,即便 UI 可以配置出来,可读性也远没有代码高。除了表格透视表外、还可以创建一些自定义形状,基本的几何图形、图片和 SVG 都支持。 JS API 设计比较有趣的是,Excel 并没有抽象 “单元格” 对象,即便我们所有人都认为单元格就是 Excel 的代表。 这么做是出于 API 设计的合理性,因为 Excel 使用 Range 概念表示连续单元格。比如: Excel.run(function (context) { var sheet = context.workbook.worksheets.getActiveWorksheet(); var headers = [ ["Product", "Quantity", "Unit Price", "Totals"] ]; var headerRange = sheet.getRange("B2:E2"); headerRange.values = headers; headerRange.format.fill.color = "##4472C4"; headerRange.format.font.color = "white"; return context.sync();}); 可以发现,Range 让 Excel 聚焦在批量单元格 API,即把单元格看做一个范围,整体 API 都可以围绕一个范围去设计。这种设计理念的好处是,把范围局限在单格单元格,就可以覆盖 Cell 概念,而聚焦在多个单元格时,可以很方便的基于二维数据结构创建表格、折线图等分析图形,因为二维结构的数据才是结构化数据。 或者可以说,结构化数据是 Excel 最核心的概念,而单元格无法体现结构化。结构化数据的好处是,一张工作表就是一个可以用来分析的数据集,在其之上无论是基于单元格的条件格式,还是创建分析图表,都是一种数据二次分析行为,这都得益于结构化数据,所以 Excel JS API 必然围绕结构化数据进行抽象。 再从 API 语法来看,除了工作薄这个级别的 API 采用了 Excel.createWorkbook(); 之外,其他大部分 API 都是以下形式: Excel.run(function (context) { // var sheet = context.workbook.worksheets.getItem("Sample"); // 对 sheet 操作 .. return context.sync();}); 最外层的函数 Excel.run 是注入 context 用的,而且也可以保证执行的时候 Excel context 已经准备好了。而 context.sync() 是同步操作,即使当前对 context 的操作生效。所以 Excel JS API 是命令式的,也不会做类似 MVVM 的双向绑定,所以在操作过程中数据和 Excel 状态不会发生变化,直到执行 context.sync()。 注意到这点后,就可以理解为什么要把某些代码写在 context.sync().then 里了,比如: Excel.run(function (ctx) { var pivotTable = context.workbook.worksheets.getActiveWorksheet().pivotTables.getItem("Farm Sales"); // Get the totals for each data hierarchy from the layout. var range = pivotTable.layout.getDataBodyRange(); var grandTotalRange = range.getLastRow(); grandTotalRange.load("address"); return context.sync().then(function () { // Sum the totals from the PivotTable data hierarchies and place them in a new range, outside of the PivotTable. var masterTotalRange = context.workbook.worksheets.getActiveWorksheet().getRange("E30"); masterTotalRange.formulas = [["=SUM(" + grandTotalRange.address + ")"]]; });}).catch(errorHandlerFunction); 这个从透视表获取数据的例子,只有执行 context.sync() 后才能拿到 grandTotalRange.address。 总结微软还在 Office 套件 Excel、Outlook、Word 中推出了 ScriptLab 功能,就可以在 Excel 的 ScriptLab 里编写 Excel JS API。 在 Excel JS API 之上,还有一个 通用 API,定义为跨应用的通用 API,这样 Excel JS API 就可以把精力聚焦在 Excel 产品本身能力上。 讨论地址是:精读《Excel JS API》· Issue ##387 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Function Component 入门》","path":"/wiki/WebWeekly/前沿技术/《Function Component 入门》.html","content":"当前期刊数: 104 1. 引言如果你在使用 React 16,可以尝试 Function Component 风格,享受更大的灵活性。但在尝试之前,最好先阅读本文,对 Function Component 的思维模式有一个初步认识,防止因思维模式不同步造成的困扰。 2. 精读什么是 Function Component?Function Component 就是以 Function 的形式创建的 React 组件: function App() { return ( <div> <p>App</p> </div> );} 也就是,一个返回了 JSX 或 createElement 的 Function 就可以当作 React 组件,这种形式的组件就是 Function Component。 所以我已经学会 Function Component 了吗? 别急,故事才刚刚开始。 什么是 Hooks?Hooks 是辅助 Function Component 的工具。比如 useState 就是一种 Hook,它可以用来管理状态: function Counter() { const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </div> );} useState 返回的结果是数组,数组的第一项是 值,第二项是 赋值函数,useState 函数的第一个参数就是 默认值,也支持回调函数。更详细的介绍可以参考 Hooks 规则解读。 先赋值再 setTimeout 打印我们再将 useState 与 setTimeout 结合使用,看看有什么发现。 创建一个按钮,点击后让计数器自增,但是延时 3 秒后再打印出来: function Counter() { const [count, setCount] = useState(0); const log = () => { setCount(count + 1); setTimeout(() => { console.log(count); }, 3000); }; return ( <div> <p>You clicked {count} times</p> <button onClick={log}>Click me</button> </div> );} 如果我们 在三秒内连续点击三次,那么 count 的值最终会变成 3,而随之而来的输出结果是。。? 012 嗯,好像对,但总觉得有点怪? 使用 Class Component 方式实现一遍呢?敲黑板了,回到我们熟悉的 Class Component 模式,实现一遍上面的功能: class Counter extends Component { state = { count: 0 }; log = () => { this.setState({ count: this.state.count + 1 }); setTimeout(() => { console.log(this.state.count); }, 3000); }; render() { return ( <div> <p>You clicked {this.state.count} times</p> <button onClick={this.log}>Click me</button> </div> ); }} 嗯,结果应该等价吧?3 秒内快速点击三次按钮,这次的结果是: 333 怎么和 Function Component 结果不一样? 这是用好 Function Component 必须迈过的第一道坎,请确认完全理解下面这段话: 首先对 Class Component 进行解释: 首先 state 是 Immutable 的,setState 后一定会生成一个全新的 state 引用。 但 Class Component 通过 this.state 方式读取 state,这导致了每次代码执行都会拿到最新的 state 引用,所以快速点击三次的结果是 3 3 3。 那么对 Function Component 而言: useState 产生的数据也是 Immutable 的,通过数组第二个参数 Set 一个新值后,原来的值会形成一个新的引用在下次渲染时。 但由于对 state 的读取没有通过 this. 的方式,使得 每次 setTimeout 都读取了当时渲染闭包环境的数据,虽然最新的值跟着最新的渲染变了,但旧的渲染里,状态依然是旧值。 为了更容易理解,我们来模拟三次 Function Component 模式下点击按钮时的状态: 第一次点击,共渲染了 2 次,setTimeout 生效在第 1 次渲染,此时状态为: function Counter() { const [0, setCount] = useState(0); const log = () => { setCount(0 + 1); setTimeout(() => { console.log(0); }, 3000); }; return ...} 第二次点击,共渲染了 3 次,setTimeout 生效在第 2 次渲染,此时状态为: function Counter() { const [1, setCount] = useState(0); const log = () => { setCount(1 + 1); setTimeout(() => { console.log(1); }, 3000); }; return ...} 第三次点击,共渲染了 4 次,setTimeout 生效在第 3 次渲染,此时状态为: function Counter() { const [2, setCount] = useState(0); const log = () => { setCount(2 + 1); setTimeout(() => { console.log(2); }, 3000); }; return ...} 可以看到,每一个渲染都是一个独立的闭包,在独立的三次渲染中,count 在每次渲染中的值分别是 0 1 2,所以无论 setTimeout 延时多久,打印出来的结果永远是 0 1 2。 理解了这一点,我们就能继续了。 如何让 Function Component 也打印 3 3 3?所以这是不是代表 Function Component 无法覆盖 Class Component 的功能呢?完全不是,我希望你读完本文后,不仅能解决这个问题,更能理解为什么用 Function Component 实现的代码更佳合理、优雅。 第一种方案是借助一个新 Hook - useRef 的能力: function Counter() { const count = useRef(0); const log = () => { count.current++; setTimeout(() => { console.log(count.current); }, 3000); }; return ( <div> <p>You clicked {count.current} times</p> <button onClick={log}>Click me</button> </div> );} 这种方案的打印结果就是 3 3 3。 想要理解为什么,首先要理解 useRef 的功能:通过 useRef 创建的对象,其值只有一份,而且在所有 Rerender 之间共享。 所以我们对 count.current 赋值或读取,读到的永远是其最新值,而与渲染闭包无关,因此如果快速点击三下,必定会返回 3 3 3 的结果。 但这种方案有个问题,就是使用 useRef 替代了 useState 创建值,那么很自然的问题就是,如何不改变原始值的写法,达到同样的效果呢? 如何不改造原始值也打印 3 3 3?一种最简单的做法,就是新建一个 useRef 的值给 setTimeout 使用,而程序其余部分还是用原始的 count: function Counter() { const [count, setCount] = useState(0); const currentCount = useRef(count); useEffect(() => { currentCount.current = count; }); const log = () => { setCount(count + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ( <div> <p>You clicked {count} times</p> <button onClick={log}>Click me</button> </div> );} 通过这个例子,我们引出了一个新的,也是 **最重要的 Hook - useEffect**,请务必深入理解这个函数。 useEffect 是处理副作用的,其执行时机在 每次 Render 渲染完毕后,换句话说就是每次渲染都会执行,只是实际在真实 DOM 操作完毕后。 我们可以利用这个特性,在每次渲染完毕后,将 count 此时最新的值赋给 currentCount.current,这样就使 currentCount 的值自动同步了 count 的最新值。 为了确保大家准确理解 useEffect,笔者再啰嗦一下,将其执行周期拆解到每次渲染中。假设你在三秒内快速点击了三次按钮,那么你需要在大脑中模拟出下面这三次渲染都发生了什么: 第一次点击,共渲染了 2 次,useEffect 生效在第 2 次渲染: function Counter() { const [1, setCount] = useState(0); const currentCount = useRef(0); useEffect(() => { currentCount.current = 1; // 第二次渲染完毕后执行一次 }); const log = () => { setCount(1 + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ...} 第二次点击,共渲染了 3 次,useEffect 生效在第 3 次渲染: function Counter() { const [2, setCount] = useState(0); const currentCount = useRef(0); useEffect(() => { currentCount.current = 2; // 第三次渲染完毕后执行一次 }); const log = () => { setCount(2 + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ...} 第三次点击,共渲染了 4 次,useEffect 生效在第 4 次渲染: function Counter() { const [3, setCount] = useState(0); const currentCount = useRef(0); useEffect(() => { currentCount.current = 3; // 第四次渲染完毕后执行一次 }); const log = () => { setCount(3 + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ...} 注意对比与上面章节展开的 setTimeout 渲染时有什么不同。 要注意的是,useEffect 也随着每次渲染而不同的,同一个组件不同渲染之间,useEffect 内闭包环境完全独立。对于本次的例子,useEffect 共执行了 四次,经历了如下四次赋值最终变成 3: currentCount.current = 0; // 第 1 次渲染currentCount.current = 1; // 第 2 次渲染currentCount.current = 2; // 第 3 次渲染currentCount.current = 3; // 第 4 次渲染 请确保理解了这句话再继续往下阅读: **setTimeout 的例子,三次点击触发了四次渲染,但 setTimeout 分别生效在第 1、2、3 次渲染中,因此值是 0 1 2**。 **useEffect 的例子中,三次点击也触发了四次渲染,但 useEffect 分别生效在第 1、2、3、4 次渲染中,最终使 currentCount 的值变成 3**。 用自定义 Hook 包装 useRef是不是觉得每次都写一堆 useEffect 同步数据到 useRef 很烦?是的,想要简化,就需要引出一个新的概念:自定义 Hooks。 首先介绍一下,自定义 Hooks 允许创建自定义 Hook,只要函数名遵循以 use 开头,且返回非 JSX 元素,就是 Hooks 啦!自定义 Hooks 内还可以调用包括内置 Hooks 在内的所有自定义 Hooks。 也就是我们可以将 useEffect 写到自定义 Hook 里: function useCurrentValue(value) { const ref = useRef(0); useEffect(() => { ref.current = value; }, [value]); return ref;} 这里又引出一个新的概念,就是 useEffect 的第二个参数,dependences。dependences 这个参数定义了 useEffect 的依赖,在新的渲染中,只要所有依赖项的引用都不发生变化,useEffect 就不会被执行,且当依赖项为 [] 时,useEffect 仅在初始化执行一次,后续的 Rerender 永远也不会被执行。 这个例子中,我们告诉 React:仅当 value 的值变化了,再将其最新值同步给 ref.current。 那么这个自定义 Hook 就可以在任何 Function Component 调用了: function Counter() { const [count, setCount] = useState(0); const currentCount = useCurrentValue(count); const log = () => { setCount(count + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ( <div> <p>You clicked {count} times</p> <button onClick={log}>Click me</button> </div> );} 封装以后代码清爽了很多,而且最重要的是将逻辑封装起来,我们只要理解 useCurrentValue 这个 Hook 可以产生一个值,其最新值永远与入参同步。 看到这里,也许有的小伙伴已经按捺不住迸发的灵感了:将 useEffect 第二个参数设置为空数组,这个自定义 Hook 就代表了 didMount 生命周期! 是的,但笔者建议大家 不要再想生命周期的事情,这样会阻碍你更好的理解 Function Component。因为下一个话题,就是要告诉你:永远要对 useEffect 的依赖诚实,被依赖的参数一定要填上去,否则会产生非常难以察觉与修复的 BUG。 将 setTimeout 换成 setInterval 会怎样我们回到起点,将第一个 setTimeout Demo 中换成 setInterval,看看会如何: function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>;} 这个例子将引发学习 Function Component 的第二个拦路虎,理解了它,才深入理解了 Function Component 的渲染原理。 首先介绍一下引入的新概念,**useEffect 函数的返回值**。它的返回值是一个函数,这个函数在 useEffect 即将重新执行时,会先执行上一次 Rerender useEffect 第一个回调的返回函数,再执行下一次渲染的 useEffect 第一个回调。 以两次连续渲染为例介绍,展开后的效果是这样的: 第一次渲染: function Counter() { useEffect(() => { // 第一次渲染完毕后执行 // 最终执行顺序:1 return () => { // 由于没有填写依赖项,所以第二次渲染 useEffect 会再次执行,在执行前,第一次渲染中这个地方的回调函数会首先被调用 // 最终执行顺序:2 } }); return ...} 第二次渲染: function Counter() { useEffect(() => { // 第二次渲染完毕后执行 // 最终执行顺序:3 return () => { // 依此类推 } }); return ...} 然而本 Demo 将 useEffect 的第二个参数设置为了 [],那么其返回函数只会在这个组件被销毁时执行。 读懂了前面的例子,应该能想到,这个 Demo 希望利用 [] 依赖,将 useEffect 当作 didMount 使用,再结合 setInterval 每次时 count 自增,这样期望将 count 的值每秒自增 1。 然而结果是: 111... 理解了 setTimeout 例子的读者应该可以自行推导出原因:setInterval 永远在第一次 Render 的闭包中,count 的值永远是 0,也就是等价于: function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(0 + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>;} 然而罪魁祸首就是 没有对依赖诚实 导致的。例子中 useEffect 明明依赖了 count,依赖项却非要写 [],所以产生了很难理解的错误。 所以改正的办法就是 对依赖诚实。 永远对依赖项诚实一旦我们对依赖诚实了,就可以得到正确的效果: function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, [count]); return <h1>{count}</h1>;} 我们将 count 作为了 useEffect 的依赖项,就得到了正确的结果: 123... 既然漏写依赖的风险这么大,自然也有保护措施,那就是 eslint-plugin-react-hooks 这个插件,会自动订正你的代码中的依赖,想不对依赖诚实都不行! 然而对这个例子而言,代码依然存在 BUG:每次计数器都会重新实例化,如果换成其他费事操作,性能成本将不可接受。 如何不在每次渲染时重新实例化 setInterval?最简单的办法,就是利用 useState 的第二种赋值用法,不直接依赖 count,而是以函数回调方式进行赋值: function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>;} 这这写法真正做到了: 不依赖 count,所以对依赖诚实。 依赖项为 [],只有初始化会对 setInterval 进行实例化。 而之所以输出还是正确的 1 2 3 ...,原因是 setCount 的回调函数中,c 值永远指向最新的 count 值,因此没有逻辑漏洞。 但是聪明的同学仔细一想,就会发现一个新问题:如果存在两个以上变量需要使用时,这招就没有用武之地了。 同时使用两个以上变量时?如果同时需要对 count 与 step 两个变量做累加,那 useEffect 的依赖必然要写上一种某一个值,频繁实例化的问题就又出现了: function Counter() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + step); }, 1000); return () => clearInterval(id); }, [step]); return <h1>{count}</h1>;} 这个例子中,由于 setCount 只能拿到最新的 count 值,而为了每次都拿到最新的 step 值,就必须将 step 申明到 useEffect 依赖中,导致 setInterval 被频繁实例化。 这个问题自然也困扰了 React 团队,所以他们拿出了一个新的 Hook 解决问题:useReducer。 什么是 useReducer先别联想到 Redux。只考虑上面的场景,看看为什么 React 团队要将 useReducer 列为内置 Hooks 之一。 先介绍一下 useReducer 的用法: const [state, dispatch] = useReducer(reducer, initialState); useReducer 返回的结构与 useState 很像,只是数组第二项是 dispatch,而接收的参数也有两个,初始值放在第二位,第一位就是 reducer。 reducer 定义了如何对数据进行变换,比如一个简单的 reducer 如下: function reducer(state, action) { switch (action.type) { case "increment": return { ...state, count: state.count + 1 }; default: return state; }} 这样就可以通过调用 dispatch({ type: 'increment' }) 的方式实现 count 自增了。 那么回到这个例子,我们只需要稍微改写一下用法即可: function Counter() { const [state, dispatch] = useReducer(reducer, initialState); const { count, step } = state; useEffect(() => { const id = setInterval(() => { dispatch({ type: "tick" }); }, 1000); return () => clearInterval(id); }, [dispatch]); return <h1>{count}</h1>;}function reducer(state, action) { switch (action.type) { case "tick": return { ...state, count: state.count + state.step }; }} 可以看到,我们通过 reducer 的 tick 类型完成了对 count 的累加,而在 useEffect 的函数中,竟然完全绕过了 count、step 这两个变量。所以 useReducer 也被称为解决此类问题的 “黑魔法”。 其实不管被怎么称呼也好,其本质是让函数与数据解耦,函数只管发出指令,而不需要关心使用的数据被更新时,需要重新初始化自身。 仔细的读者会发现这个例子还是有一个依赖的,那就是 dispatch,然而 dispatch 引用永远也不会变,因此可以忽略它的影响。这也体现了无论如何都要对依赖保持诚实。 这也引发了另一个注意项:尽量将函数写在 useEffect 内部。 将函数写在 useEffect 内部为了避免遗漏依赖,必须将函数写在 useEffect 内部,这样 eslint-plugin-react-hooks 才能通过静态分析补齐依赖项: function Counter() { const [count, setCount] = useState(0); useEffect(() => { function getFetchUrl() { return "https://v?query=" + count; } getFetchUrl(); }, [count]); return <h1>{count}</h1>;} getFetchUrl 这个函数依赖了 count,而如果将这个函数定义在 useEffect 外部,无论是机器还是人眼都难以看出 useEffect 的依赖项包含 count。 然而这就引发了一个新问题:将所有函数都写在 useEffect 内部岂不是非常难以维护? 如何将函数抽到 useEffect 外部?为了解决这个问题,我们要引入一个新的 Hook:useCallback,它就是解决将函数抽到 useEffect 外部的问题。 我们先看 useCallback 的用法: function Counter() { const [count, setCount] = useState(0); const getFetchUrl = useCallback(() => { return "https://v?query=" + count; }, [count]); useEffect(() => { getFetchUrl(); }, [getFetchUrl]); return <h1>{count}</h1>;} 可以看到,useCallback 也有第二个参数 - 依赖项,我们将 getFetchUrl 函数的依赖项通过 useCallback 打包到新的 getFetchUrl 函数中,那么 useEffect 就只需要依赖 getFetchUrl 这个函数,就实现了对 count 的间接依赖。 换句话说,我们利用了 useCallback 将 getFetchUrl 函数抽到了 useEffect 外部。 为什么 useCallback 比 componentDidUpdate 更好用回忆一下 Class Component 的模式,我们是如何在函数参数变化时进行重新取数的: class Parent extends Component { state = { count: 0, step: 0 }; fetchData = () => { const url = "https://v?query=" + this.state.count + "&step=" + this.state.step; }; render() { return <Child fetchData={this.fetchData} count={count} step={step} />; }}class Child extends Component { state = { data: null }; componentDidMount() { this.props.fetchData(); } componentDidUpdate(prevProps) { if ( this.props.count !== prevProps.count && this.props.step !== prevProps.step // 别漏了! ) { this.props.fetchData(); } } render() { // ... }} 上面的代码经常用 Class Component 的人应该很熟悉,然而暴露的问题可不小。 我们需要理解 props.count props.step 被 props.fetchData 函数使用了,因此在 componentDidUpdate 时,判断这两个参数发生了变化就触发重新取数。 然而问题是,这种理解成本是不是过高了?如果父级函数 fetchData 不是我写的,在不读源码的情况下,我怎么知道它依赖了 props.count 与 props.step 呢?更严重的是,如果某一天 fetchData 多依赖了 params 这个参数,下游函数将需要全部在 componentDidUpdate 覆盖到这个逻辑,否则 params 变化时将不会重新取数。可以想象,这种方式维护成本巨大,甚至可以说几乎无法维护。 换成 Function Component 的思维吧!试着用上刚才提到的 useCallback 解决问题: function Parent() { const [ count, setCount ] = useState(0); const [ step, setStep ] = useState(0); const fetchData = useCallback(() => { const url = 'https://v/search?query=' + count + "&step=" + step; }, [count, step]) return ( <Child fetchData={fetchData} /> )}function Child(props) { useEffect(() => { props.fetchData() }, [props.fetchData]) return ( // ... )} 可以看出来,当 fetchData 的依赖变化后,按下保存键,eslint-plugin-react-hooks 会自动补上更新后的依赖,而下游的代码不需要做任何改变,下游只需要关心依赖了 fetchData 这个函数即可,至于这个函数依赖了什么,已经封装在 useCallback 后打包透传下来了。 不仅解决了维护性问题,而且对于 只要参数变化,就重新执行某逻辑,是特别适合用 useEffect 做的,使用这种思维思考问题会让你的代码更 “智能”,而使用分裂的生命周期进行思考,会让你的代码四分五裂,而且容易漏掉各种时机。 useEffect 对业务的抽象非常方便,笔者举几个例子: 依赖项是查询参数,那么 useEffect 内可以进行取数请求,那么只要查询参数变化了,列表就会自动取数刷新。注意我们将取数时机从触发端改成了接收端。 当列表更新后,重新注册一遍拖拽响应事件。也是同理,依赖参数是列表,只要列表变化,拖拽响应就会重新初始化,这样我们可以放心的修改列表,而不用担心拖拽事件失效。 只要数据流某个数据变化,页面标题就同步修改。同理,也不需要在每次数据变化时修改标题,而是通过 useEffect “监听” 数据的变化,这是一种 “控制反转” 的思维。 说了这么多,其本质还是利用了 useCallback 将函数独立抽离到 useEffect 外部。 那么进一步思考,可以将函数抽离到整个组件的外部吗? 这也是可以的,需要灵活运用自定义 Hooks 实现。 将函数抽到组件外部以上面的 fetchData 函数为例,如果要抽到整个组件的外部,就不是利用 useCallback 做到了,而是利用自定义 Hooks 来做: function useFetch(count, step) { return useCallback(() => { const url = "https://v/search?query=" + count + "&step=" + step; }, [count, step]);} 可以看到,我们将 useCallback 打包搬到了自定义 Hook useFetch 中,那么函数中只需要一行代码就能实现一样的效果了: function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const [other, setOther] = useState(0); const fetch = useFetch(count, step); // 封装了 useFetch useEffect(() => { fetch(); }, [fetch]); return ( <div> <button onClick={() => setCount(c => c + 1)}>setCount {count}</button> <button onClick={() => setStep(c => c + 1)}>setStep {step}</button> <button onClick={() => setOther(c => c + 1)}>setOther {other}</button> </div> );} 随着使用越来越方便,我们可以将精力放到性能上。观察可以发现,count 与 step 都会频繁变化,每次变化就会导致 useFetch 中 useCallback 依赖的变化,进而导致重新生成函数。然而实际上这种函数是没必要每次都重新生成的,反复生成函数会造成大量性能损耗。 换一个例子就可以看得更清楚: function Parent(props) { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const [other, setOther] = useState(0); const drag = useDraggable(props.dom, count, step); // 封装了拖拽函数 useEffect(() => { // dom 变化时重新实例化 drag() }, [drag])} 假设我们使用 Sortablejs 对某个区域进行拖拽监听,这个函数每次都重复执行的性能损耗非常大,然而这个函数内部可能因为仅仅要上报一些日志,所以依赖了没有实际被使用的 count step 变量: function useDraggable(dom, count, step) { return useCallback(() => { // 上报日志 report(count, step); // 对区域进行初始化,非常耗时 // ... 省略耗时代码 }, [dom, count, step]);} 这种情况,函数的依赖就特别不合理。虽然依赖变化应该触发函数重新执行,但如果函数重新执行的成本非常高,而依赖只是可有可无的点缀,得不偿失。 利用 Ref 保证耗时函数依赖不变一种办法是通过将依赖转化为 Ref: function useFetch(count, step) { const countRef = useRef(count); const stepRef = useRef(step); useEffect(() => { countRef.current = count; stepRef.current = step; }); return useCallback(() => { const url = "https://v/search?query=" + countRef.current + "&step=" + stepRef.current; }, [countRef, stepRef]); // 依赖不会变,却能每次拿到最新的值} 这种方式比较取巧,将需要更新的区域与耗时区域分离,再将需更新的内容通过 Ref 提供给耗时的区域,实现性能优化。 然而这样做对函数的改动成本比较高,有一种更通用的做法解决此类问题。 通用的自定义 Hooks 解决函数重新实例化问题我们可以利用 useRef 创造一个自定义 Hook 代替 useCallback,使其依赖的值变化时,回调不会重新执行,却能拿到最新的值! 这个神奇的 Hook 写法如下: function useEventCallback(fn, dependencies) { const ref = useRef(null); useEffect(() => { ref.current = fn; }, [fn, ...dependencies]); return useCallback(() => { const fn = ref.current; return fn(); }, [ref]);} 再次体会到自定义 Hook 的无所不能。 首先看这一段: useEffect(() => { ref.current = fn;}, [fn, ...dependencies]); 当 fn 回调函数变化时, ref.current 重新指向最新的 fn 这个逻辑中规中矩。重点是,当依赖 dependencies 变化时,也重新为 ref.current 赋值,此时 fn 内部的 dependencies 值是最新的,而下一段代码: return useCallback(() => { const fn = ref.current; return fn();}, [ref]); 又仅执行一次(ref 引用不会改变),所以每次都可以返回 dependencies 是最新的 fn,并且 fn 还不会重新执行。 假设我们对 useEventCallback 传入的回调函数称为 X,则这段代码的含义,就是使每次渲染的闭包中,回调函数 X 总是拿到的总是最新 Rerender 闭包中的那个,所以依赖的值永远是最新的,而且函数不会重新初始化。 React 官方不推荐使用此范式,因此对于这种场景,利用 useReducer,将函数通过 dispatch 中调用。 还记得吗?dispatch 是一种可以绕过依赖的黑魔法,我们在 “什么是 useReducer” 小节提到过。 随着对 Function Component 的使用,你也渐渐关心到函数的性能了,这很棒。那么下一个重点自然是关注 Render 的性能。 用 memo 做 PureRender在 Fucntion Component 中,Class Component 的 PureComponent 等价的概念是 React.memo,我们介绍一下 memo 的用法: const Child = memo((props) => { useEffect(() => { props.fetchData() }, [props.fetchData]) return ( // ... )}) 使用 memo 包裹的组件,会在自身重渲染时,对每一个 props 项进行浅对比,如果引用没有变化,就不会触发重渲染。所以 memo 是一种很棒的性能优化工具。 下面就介绍一个看似比 memo 难用,但真正理解后会发现,其实比 memo 更好用的渲染优化函数:useMemo。 用 useMemo 做局部 PureRender相比 React.memo 这个异类,React.useMemo 可是正经的官方 Hook: const Child = (props) => { useEffect(() => { props.fetchData() }, [props.fetchData]) return useMemo(() => ( // ... ), [props.fetchData])} 可以看到,我们利用 useMemo 包裹渲染代码,这样即便函数 Child 因为 props 的变化重新执行了,只要渲染函数用到的 props.fetchData 没有变,就不会重新渲染。 这里发现了 useMemo 的第一个好处:更细粒度的优化渲染。 所谓更细粒度的优化渲染,是指函数 Child 整体可能用到了 A、B 两个 props,而渲染仅用到了 B,那么使用 memo 方案时,A 的变化会导致重渲染,而使用 useMemo 的方案则不会。 而 useMemo 的好处还不止这些,这里先留下伏笔。我们先看一个新问题:当参数越来越多时,使用 props 将函数、值在组件间传递非常冗长: function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const fetchData = useFetch(count, step); return <Child fetchData={fetchData} setCount={setCount} setStep={setStep} />;} 虽然 Child 可以通过 memo 或 useMemo 进行优化,但当程序复杂时,可能存在多个函数在所有 Function Component 间共享的情况,此时就需要新 Hook: useContext 来拯救了。 使用 Context 做批量透传在 Function Component 中,可以使用 React.createContext 创建一个 Context: const Store = createContext(null); 其中 null 是初始值,一般置为 null 也没关系。接下来还有两步,分别是在根节点使用 Store.Provider 注入,与在子节点使用官方 Hook useContext 拿到注入的数据: 在根节点使用 Store.Provider 注入: function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const fetchData = useFetch(count, step); return ( <Store.Provider value={{ setCount, setStep, fetchData }}> <Child /> </Store.Provider> );} 在子节点使用 useContext 拿到注入的数据(也就是拿到 Store.Provider 的 value): const Child = memo((props) => { const { setCount } = useContext(Store) function onClick() { setCount(count => count + 1) } return ( // ... )}) 这样就不需要在每个函数间进行参数透传了,公共函数可以都放在 Context 里。 但是当函数多了,Provider 的 value 会变得很臃肿,我们可以结合之前讲到的 useReducer 解决这个问题。 使用 useReducer 为 Context 传递内容瘦身使用 useReducer,所有回调函数都通过调用 dispatch 完成,那么 Context 只要传递 dispatch 一个函数就好了: const Store = createContext(null);function Parent() { const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 }); return ( <Store.Provider value={dispatch}> <Child /> </Store.Provider> );} 这下无论是根节点的 Provider,还是子元素调用都清爽很多: const Child = useMemo((props) => { const dispatch = useContext(Store) function onClick() { dispatch({ type: 'countInc' }) } return ( // ... )}) 你也许很快就想到,将 state 也通过 Provider 注入进去岂不更妙?是的,但此处请务必注意潜在性能问题。 将 state 也放到 Context 中稍稍改造下,将 state 也放到 Context 中,这下赋值与取值都非常方便了! const Store = createContext(null);function Parent() { const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 }); return ( <Store.Provider value={{ state, dispatch }}> <Count /> <Step /> </Store.Provider> );} 对 Count Step 这两个子元素而言,可需要谨慎一些,假如我们这么实现这两个子元素: const Count = memo(() => { const { state, dispatch } = useContext(Store); return ( <button onClick={() => dispatch("incCount")}>incCount {state.count}</button> );});const Step = memo(() => { const { state, dispatch } = useContext(Store); return ( <button onClick={() => dispatch("incStep")}>incStep {state.step}</button> );}); 其结果是:无论点击 incCount 还是 incStep,都会同时触发这两个组件的 Rerender。 其问题在于:memo 只能挡在最外层的,而通过 useContext 的数据注入发生在函数内部,会 绕过 memo。 当触发 dispatch 导致 state 变化时,所有使用了 state 的组件内部都会强制重新刷新,此时想要对渲染次数做优化,只有拿出 useMemo 了! useMemo 配合 useContext使用 useContext 的组件,如果自身不使用 props,就可以完全使用 useMemo 代替 memo 做性能优化: const Count = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( <button onClick={() => dispatch("incCount")}> incCount {state.count} </button> ), [state.count, dispatch] );};const Step = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( <button onClick={() => dispatch("incStep")}>incStep {state.step}</button> ), [state.step, dispatch] );}; 对这个例子来说,点击对应的按钮,只有使用到的组件才会重渲染,效果符合预期。 结合 eslint-plugin-react-hooks 插件使用,连 useMemo 的第二个参数依赖都是自动补全的。 读到这里,不知道你是否联想到了 Redux 的 Connect? 我们来对比一下 Connect 与 useMemo,会发现惊人的相似之处。 一个普通的 Redux 组件: const mapStateToProps = state => ({count: state.count});const mapDispatchToProps = dispatch => dispatch;@Connect(mapStateToProps, mapDispatchToProps)class Count extends React.PureComponent { render() { return ( <button onClick={() => this.props.dispatch("incCount")}> incCount {this.props.count} </button> ); }} 一个普通的 Function Component 组件: const Count = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( <button onClick={() => dispatch("incCount")}> incCount {state.count} </button> ), [state.count, dispatch] );}; 这两段代码的效果完全一样,Function Component 除了更简洁之外,还有一个更大的优势:全自动的依赖推导。 Hooks 诞生的一个原因,就是为了便于静态分析依赖,简化 Immutable 数据流的使用成本。 我们看 Connect 的场景: 由于不知道子组件使用了哪些数据,因此需要在 mapStateToProps 提前写好,而当需要使用数据流内新变量时,组件里是无法访问的,我们要回到 mapStateToProps 加上这个依赖,再回到组件中使用它。 而 useContext + useMemo 的场景: 由于注入的 state 是全量的,Render 函数中想用什么都可直接用,在按保存键时,eslint-plugin-react-hooks 会通过静态分析,在 useMemo 第二个参数自动补上代码里使用到的外部变量,比如 state.count、dispatch。 另外可以发现,Context 很像 Redux,那么 Class Component 模式下的异步中间件实现的异步取数怎么利用 useReducer 做呢?答案是:做不到。 当然不是说 Function Component 无法实现异步取数,而是用的工具错了。 使用自定义 Hook 处理副作用比如上面抛出的异步取数场景,在 Function Component 的最佳做法是封装成一个自定义 Hook: const useDataApi = (initialUrl, initialData) => { const [url, setUrl] = useState(initialUrl); const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, data: initialData }); useEffect(() => { let didCancel = false; const fetchData = async () => { dispatch({ type: "FETCH_INIT" }); try { const result = await axios(url); if (!didCancel) { dispatch({ type: "FETCH_SUCCESS", payload: result.data }); } } catch (error) { if (!didCancel) { dispatch({ type: "FETCH_FAILURE" }); } } }; fetchData(); return () => { didCancel = true; }; }, [url]); const doFetch = url => setUrl(url); return { ...state, doFetch };}; 可以看到,自定义 Hook 拥有完整生命周期,我们可以将取数过程封装起来,只暴露状态 - 是否在加载中:isLoading 是否取数失败:isError 数据:data。 在组件中使用起来非常方便: function App() { const { data, isLoading, isError } = useDataApi("https://v", { showLog: true });} 如果这个值需要存储到数据流,在所有组件之间共享,我们可以结合 useEffect 与 useReducer: function App(props) { const { dispatch } = useContext(Store); const { data, isLoading, isError } = useDataApi("https://v", { showLog: true }); useEffect(() => { dispatch({ type: "updateLoading", data, isLoading, isError }); }, [dispatch, data, isLoading, isError]);} 到此,Function Component 的入门概念就讲完了,最后附带一个彩蛋:Function Component 的 DefaultProps 怎么处理? Function Component 的 DefaultProps 怎么处理?这个问题看似简单,实则不然。我们至少有两种方式对 Function Component 的 DefaultProps 进行赋值,下面一一说明。 首先对于 Class Component,DefaultProps 基本上只有一种大家都认可的写法: class Button extends React.PureComponent { defaultProps = { type: "primary", onChange: () => {} };} 然而在 Function Component 就五花八门了。 利用 ES6 特性在参数定义阶段赋值function Button({ type = "primary", onChange = () => {} }) {} 这种方法看似很优雅,其实有一个重大隐患:没有命中的 props 在每次渲染引用都不同。 看这种场景: const Child = memo(({ type = { a: 1 } }) => { useEffect(() => { console.log("type", type); }, [type]); return <div>Child</div>;}); 只要 type 的引用不变,useEffect 就不会频繁的执行。现在通过父元素刷新导致 Child 跟着刷新,我们发现,每次渲染都会打印出日志,也就意味着每次渲染时,type 的引用是不同的。 有一种不太优雅的方式可以解决: const defaultType = { a: 1 };const Child = ({ type = defaultType }) => { useEffect(() => { console.log("type", type); }, [type]); return <div>Child</div>;}; 此时不断刷新父元素,只会打印出一次日志,因为 type 的引用是相同的。 我们使用 DefaultProps 的本意必然是希望默认值的引用相同, 如果不想单独维护变量的引用,还可以借用 React 内置的 defaultProps 方法解决。 利用 React 内置方案React 内置方案能较好的解决引用频繁变动的问题: const Child = ({ type }) => { useEffect(() => { console.log("type", type); }, [type]); return <div>Child</div>;};Child.defaultProps = { type: { a: 1 }}; 上面的例子中,不断刷新父元素,只会打印出一次日志。 因此建议对于 Function Component 的参数默认值,建议使用 React 内置方案解决,因为纯函数的方案不利于保持引用不变。 最后补充一个父组件 “坑” 子组件的经典案例。 不要坑了子组件我们做一个点击累加的按钮作为父组件,那么父组件每次点击后都会刷新: function App() { const [count, forceUpdate] = useState(0); const schema = { b: 1 }; return ( <div> <Child schema={schema} /> <div onClick={() => forceUpdate(count + 1)}>Count {count}</div> </div> );} 另外我们将 schema = { b: 1 } 传递给子组件,这个就是埋的一个大坑。 子组件的代码如下: const Child = memo(props => { useEffect(() => { console.log("schema", props.schema); }, [props.schema]); return <div>Child</div>;}); 只要父级 props.schema 变化就会打印日志。结果自然是,父组件每次刷新,子组件都会打印日志,也就是 子组件 [props.schema] 完全失效了,因为引用一直在变化。 其实 子组件关心的是值,而不是引用,所以一种解法是改写子组件的依赖: const Child = memo(props => { useEffect(() => { console.log("schema", props.schema); }, [JSON.stringify(props.schema)]); return <div>Child</div>;}); 这样可以保证子组件只渲染一次。 可是真正罪魁祸首是父组件,我们需要利用 Ref 优化一下父组件: function App() { const [count, forceUpdate] = useState(0); const schema = useRef({ b: 1 }); return ( <div> <Child schema={schema.current} /> <div onClick={() => forceUpdate(count + 1)}>Count {count}</div> </div> );} 这样 schema 的引用能一直保持不变。如果你完整读完了本文,应该可以充分理解第一个例子的 schema 在每个渲染快照中都是一个新的引用,而 Ref 的例子中,schema 在每个渲染快照中都只有一个唯一的引用。 3. 总结所以使用 Function Component 你入门了吗? 本次精读留下的思考题是:Function Component 开发过程中还有哪些容易犯错误的细节? 讨论地址是:精读《Function Component 入门》 · Issue ##157 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Headless 组件用法与原理》","path":"/wiki/WebWeekly/前沿技术/《Headless 组件用法与原理》.html","content":"当前期刊数: 259 Headless 组件即无 UI 组件,框架仅提供逻辑,UI 交给业务实现。这样带来的好处是业务有极大的 UI 自定义空间,而对框架来说,只考虑逻辑可以让自己更轻松的覆盖更多场景,满足更多开发者不同的诉求。 我们以 headlessui-tabs 为例看看它的用法,并读一读 源码。 概述headless tabs 最简单的用法如下: import { Tab } from "@headlessui/react";function MyTabs() { return ( <Tab.Group> <Tab.List> <Tab>Tab 1</Tab> <Tab>Tab 2</Tab> <Tab>Tab 3</Tab> </Tab.List> <Tab.Panels> <Tab.Panel>Content 1</Tab.Panel> <Tab.Panel>Content 2</Tab.Panel> <Tab.Panel>Content 3</Tab.Panel> </Tab.Panels> </Tab.Group> );} 以上代码没有做任何逻辑定制,只用 Tab 及其提供的标签把 tabs 的结构描述出来,此时框架能提供最基础的 tabs 切换特性,即按照顺序,点击 Tab 时切换内容到对应的 Tab.Panel。 此时没有任何额外的 UI 样式,甚至连 Tab 选中态都没有,如果需要进一步定制,需要用框架提供的 RenderProps 能力拿到状态后做业务层的定制,比如选中态: <Tab as={Fragment}> {({ selected }) => ( <button className={selected ? "bg-blue-500 text-white" : "bg-white text-black"} > Tab 1 </button> )}</Tab> 要实现选中态就要自定义 UI,如果使用 RenderProps 拓展,那么 Tab 就不应该提供任何 UI,所以 as={Fragment} 就表示该节点作为一个逻辑节点而非 UI 节点(不产生 dom 节点)。 类似的,框架将 tabs 组件拆分为 Tab 标题区域 Tab 与 Tab 内容区域 Tab.Panel,每个部分都可以用 RenderProps 定制,而框架早已根据业务逻辑规定好了每个部分可以做哪些逻辑拓展,比如 Tab 就提供了 selected 参数告知当前 Tab 是否处于选中态,业务就可以根据它对 UI 进行高亮处理,而框架并不包含如何做高亮的处理,因此才体现出该 tabs 组件的拓展性,但响应的业务开发成本也较高。 Headless 的拓展性可以拿一个场景举例:如果业务侧要定制 Tab 标题,我们可以将 Tab.List 包裹在一个更大的标题容器内,在任意位置添加标题 jsx,而不会破坏原本的 tabs 逻辑,然后将这个组件作为业务通用组件即可。 再看更多的配置参数: 控制某个 Tab 是否可编辑: <Tab disabled>Tab 2</Tab> Tab 切换是否为手动按 Enter 或 Space 键: <Tab.Group manual> 默认激活 Tab: <Tab.Group defaultIndex={1}> 监听激活 Tab 变化: <Tab.Group onChange={(index) => { console.log('Changed selected tab to:', index) }}> 受控模式: <Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}> 用法就介绍到这里。 精读由此可见,Headless 组件在 React 场景更多使用 RenderProps 的方式提供 UI 拓展能力,因为 RenderProps 既可以自定义 UI 元素,又可以拿到当前上下文的状态,天然适合对 UI 的自定义。 还有一些 Headless 框架如 TanStack table 还提供了 Hooks 模式,如: const table = useReactTable(options)return <table {table.getTableProps()}></table> Hooks 模式的好处是没有 RenderProps 那么多层回调,代码层级看起来舒服很多,而且 Hooks 模式在其他框架也逐渐被支持,使组件库跨框架适配的成本比较低。但 Hooks 模式在 React 场景下会引发不必要的全局 ReRender,相比之下,RenderProps 只会将重渲染限定在回调函数内部,在性能上 RenderProps 更优。 分析的差不多,我们看看 headlessui-tabs 的 源码。 首先组件要封装的好,一定要把内部组件通信问题给解决了,即为什么包裹了 Tab.Group 后,Tab 与 Tab.Panel 就可以产生联动?它们一定要访问共同的上下文数据。答案就是 Context: 首先在 Tab.Group 利用 ContextProvider 包裹一层上下文容器,并封装一个 Hook 从该容器提取数据: // 导出的别名就叫 Tab.Groupconst Tabs = () => { return ( <TabsDataContext.Provider value={tabsData}> {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_TABS_TAG, name: "Tabs", })} </TabsDataContext.Provider> );};// 提取数据方法function useData(component: string) { let context = useContext(TabsDataContext); if (context === null) { let err = new Error( `<${component} /> is missing a parent <Tab.Group /> component.` ); if (Error.captureStackTrace) Error.captureStackTrace(err, useData); throw err; } return context;} 所有子组件如 Tab、Tab.Panel、Tab.List 都从 useData 获取数据,而这些数据都可以从当前最近的 Tab.Group 上下文获取,所以多个 tabs 之间数据可以相互隔离。 另一个重点就是 RenderProps 的实现。其实早在 75.精读《Epitath 源码 - renderProps 新用法》 我们就讲过 RenderProps 的实现方式,今天我们来看一下 headlessui 的封装吧。 核心代码精简后如下: function _render<TTag extends ElementType, TSlot>( props: Props<TTag, TSlot> & { ref?: unknown }, slot: TSlot = {} as TSlot, tag: ElementType, name: string) { let { as: Component = tag, children, refName = 'ref', ...rest } = omit(props, ['unmount', 'static']) let resolvedChildren = (typeof children === 'function' ? children(slot) : children) as | ReactElement | ReactElement[] if (Component === Fragment) { return cloneElement( resolvedChildren, Object.assign( {}, // Filter out undefined values so that they don't override the existing values mergeProps(resolvedChildren.props, compact(omit(rest, ['ref']))), dataAttributes, refRelatedProps, mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref) ) ) } return createElement( Component, Object.assign( {}, omit(rest, ['ref']), Component !== Fragment && refRelatedProps, Component !== Fragment && dataAttributes ), resolvedChildren )} 首先为了支持 Fragment 模式,所以当制定 as={Fragment} 时,就直接把 resolvedChildren 作为子元素,否则自己就作为 dom 载体 createElement(Component, ..., resolvedChildren) 来渲染。 而体现 RenderProps 的点就在于 resolvedChildren 处理的这段: let resolvedChildren = typeof children === "function" ? children(slot) : children; 如果 children 是函数类型,就把它当做函数执行并传入上下文(此处为 slot),返回值是 JSX 元素,这就是 RenderProps 的本质。 再看上面 Tab.Group 的用法: render({ ourProps, theirProps, slot, defaultTag: DEFAULT_TABS_TAG, name: "Tabs",}); 其中 slot 就是当前 RenderProps 能拿到的上下文,比如在 Tab.Group 中就提供 selectedIndex,在 Tab 就提供 selected 等等,在不同的 RenderProps 位置提供便捷的上下文,对用户使用比较友好是比较关键的。 比如 Tab 内已知该 Tab 的 index 与 selectedIndex,那么给用户提供一个组合变量 selected 就可能比分别提供这两个变量更方便。 总结我们总结一下 Headless 的设计与使用思路。 作为框架作者,首先要分析这个组件的业务功能,并抽象出应该拆分为哪些 UI 模块,并利用 RenderProps 将这些 UI 模块以 UI 无关方式提供,并精心设计每个 UI 模块提供的状态。 作为使用者,了解这些组件分别支持哪些模块,各模块提供了哪些状态,并根据这些状态实现对应的 UI 组件,响应这些状态的变化。由于最复杂的状态逻辑已经被框架内置,所以对于 UI 状态多样的业务甚至可以每个组件重写一遍 UI 样式,对于样式稳定的场景,业务也可以按照 Headless + UI 作为整体封装出包含 UI 的组件,提供给各业务场景调用。 讨论地址是:精读《Headless 组件用法与原理》· Issue ##444 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《JS 中的内存管理》","path":"/wiki/WebWeekly/前沿技术/《JS 中的内存管理》.html","content":"当前期刊数: 29 本期精读的文章是: How JavaScript works: memory management + how to handle 4 common memory leaks 1 引言我为什么要选这篇文章呢? sessionstack 最近接连发了好几篇文章, 深入探讨 JS, 以及 JS 中一些内部原理. 文中也讲到了, 伴随深入了解 JS 中的一些工作原理, 才有可能写出更好的代码和程序. 而 JS 中的内存管理, 我的感觉就像 JS 中的一门副科, 我们平时不会太重视, 但是一旦出问题又很棘手. 所以可以通过平时多了解一些 JS 中内存管理问题, 在写代码中通过一些习惯, 避免内存泄露的问题. 2 内容概要内存生命周期 不管什么程序语言,内存生命周期基本是一致的: 分配你所需要的内存 使用分配到的内存(读, 写) 不需要时将其释放/归还 在 C 语言中, 有专门的内存管理接口, 像malloc() 和 free(). 而在 JS 中, 没有专门的内存管理接口, 所有的内存管理都是”自动”的. JS 在创建变量时, 自动分配内存, 并在不使用的时候, 自动释放. 这种”自动”的内存回收, 造成了很多 JS 开发并不关心内存回收, 实际上, 这是错误的. JS 中的内存回收引用垃圾回收算法主要依赖于引用的概念. 在内存管理的环境中, 一个对象如果有访问另一个对象的权限(隐式或者显式), 叫做一个对象引用另一个对象. 例如: 一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用). 引用计数垃圾收集这是最简单的垃圾收集算法.此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”. 如果没有引用指向该对象(零引用, 对象将被垃圾回收机制回收.示例: let arr = [1, 2, 3, 4];arr = null; // [1,2,3,4]这时没有被引用, 会被自动回收 限制: 循环引用在下面的例子中, 两个对象对象被创建并互相引用, 就造成了循环引用. 它们被调用之后不会离开函数作用域, 所以它们已经没有用了, 可以被回收了. 然而, 引用计数算法考虑到它们互相都有至少一次引用, 所以它们不会被回收. function f() { var o1 = {}; var o2 = {}; o1.p = o2; // o1 引用 o2 o2.p = o1; // o2 引用 o1. 这里会形成一个循环引用}f(); 实际例子: var div;window.onload = function(){ div = document.getElementById("myDivElement"); div.circularReference = div; div.lotsOfData = new Array(10000).join("*");}; 在上面的例子里, myDivElement 这个 DOM 元素里的 circularReference 属性引用了 myDivElement, 造成了循环引用. IE 6, 7 使用引用计数方式对 DOM 对象进行垃圾回收. 该方式常常造成对象被循环引用时内存发生泄漏. 现代浏览器通过使用标记-清除内存回收算法, 来解决这一问题. 标记-清除算法这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”. 这个算法假定设置一个叫做根root的对象(在 Javascript 里,根是全局对象). 定期的, 垃圾回收器将从根开始, 找所有从根开始引用的对象, 然后找这些对象引用的对象, 从根开始,垃圾回收器将找到所有可以获得的对象和所有不能获得的对象. 从 2012 年起, 所有现代浏览器都使用了标记-清除内存回收算法。所有对 JavaScript 垃圾回收算法的改进都是基于标记-清除算法的改进. 自动 GC 的问题尽管自动 GC 很方便, 但是我们不知道 GC 什么时候会进行. 这意味着如果我们在使用过程中使用了大量的内存, 而 GC 没有运行的情况下, 或者 GC 无法回收这些内存的情况下, 程序就有可能假死, 这个就需要我们在程序中手动做一些操作来触发内存回收. 什么是内存泄露?本质上讲, 内存泄露就是不再被需要的内存, 由于某种原因, 无法被释放. 常见的内存泄露案例1. 全局变量function foo(arg) { bar = "some text";} 在 JS 中处理未被声明的变量, 上述范例中的 bar时, 会把bar, 定义到全局对象中, 在浏览器中就是 window 上. 在页面中的全局变量, 只有当页面被关闭后才会被销毁. 所以这种写法就会造成内存泄露, 当然在这个例子中泄露的只是一个简单的字符串, 但是在实际的代码中, 往往情况会更加糟糕. 另外一种意外创建全局变量的情况. function foo() { this.var1 = "potential accidental global";}// Foo 被调用时, this 指向全局变量(window)foo(); 在这种情况下调用foo, this 被指向了全局变量window, 意外的创建了全局变量. 我们谈到了一些意外情况下定义的全局变量, 代码中也有一些我们明确定义的全局变量. 如果使用这些全局变量用来暂存大量的数据, 记得在使用后, 对其重新赋值为 null. 2. 未销毁的定时器和回调函数在很多库中, 如果使用了观察者模式, 都会提供回调方法, 来调用一些回调函数. 要记得回收这些回调函数. 举一个 setInterval 的例子. var serverData = loadData();setInterval(function() { var renderer = document.getElementById('renderer'); if(renderer) { renderer.innerHTML = JSON.stringify(serverData); }}, 5000); // 每 5 秒调用一次 如果后续 renderer 元素被移除, 整个定时器实际上没有任何作用. 但如果你没有回收定时器, 整个定时器依然有效, 不但定时器无法被内存回收, 定时器函数中的依赖也无法回收. 在这个案例中的 serverData 也无法被回收. 3. 闭包在 JS 开发中, 我们会经常用到闭包, 一个内部函数, 有权访问包含其的外部函数中的变量. 下面这种情况下, 闭包也会造成内存泄露. var theThing = null;var replaceThing = function () { var originalThing = theThing; var unused = function () { if (originalThing) // 对于 'originalThing'的引用 console.log("hi"); }; theThing = { longStr: new Array(1000000).join('*'), someMethod: function () { console.log("message"); } };};setInterval(replaceThing, 1000); 这段代码, 每次调用replaceThing时, theThing 获得了包含一个巨大的数组和一个对于新闭包someMethod的对象. 同时 unused 是一个引用了originalThing的闭包. 这个范例的关键在于, 闭包之间是共享作用域的, 尽管unused可能一直没有被调用, 但是someMethod 可能会被调用, 就会导致内存无法对其进行回收. 当这段代码被反复执行时, 内存会持续增长. 该问题的更多描述可见Meteor 团队的这篇文章. 4. DOM 引用很多时候, 我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 Map 中. var elements = { image: document.getElementById('image')};function doStuff() { elements.image.src = 'http://example.com/image_name.png';}function removeImage() { document.body.removeChild(document.getElementById('image')); // 这个时候我们对于 ##image 仍然有一个引用, Image 元素, 仍然无法被内存回收.} 上述案例中, 即使我们对于 image 元素进行了移除, 但是仍然有对 image 元素的引用, 依然无法对齐进行内存回收. 另外需要注意的一个点是, 对于一个 Dom 树的叶子节点的引用. 举个例子: 如果我们引用了一个表格中的td元素, 一旦在 Dom 中删除了整个表格, 我们直观的觉得内存回收应该回收除了被引用的 td外的其他元素. 但是事实上, 这个td 元素是整个表格的一个子元素, 并保留对于其父元素的引用. 这就会导致对于整个表格, 都无法进行内存回收. 所以我们要小心处理对于 Dom 元素的引用. 3 精读ES6 中引入WeakSet 和 WeakMap两个新的概念, 来解决引用造成的内存回收问题. WeakSet 和 WeakMap对于值的引用可以忽略不计, 他们对于值的引用是弱引用,内存回收机制, 不会考虑这种引用. 当其他引用被消除后, 引用就会从内存中被释放. JS 这类高级语言,隐藏了内存管理功能。但无论开发人员是否注意,内存管理都在那,所有编程语言最终要与操作系统打交道,在内存大小固定的硬件上工作。不幸的是,即使不考虑垃圾回收对性能的影响,2017 年最新的垃圾回收算法,也无法智能回收所有极端的情况。 唯有程序员自己才知道何时进行垃圾回收,而 JS 由于没有暴露显示内存管理接口,导致触发垃圾回收的代码看起来像“垃圾”,或者优化垃圾回收的代码段看起来不优雅、甚至不可读。 所以在 JS 这类高级语言中,有必要掌握基础内存分配原理,在对内存敏感的场景,比如 nodejs 代码做严格检查与优化。谨慎使用 dom 操作、主动删除没有业务意义的变量、避免提前优化、过度优化,在保证代码可读性的前提下,利用性能监控工具,通过调用栈定位问题代码。 同时对于如何利用 chrome 调试工具, 分析内存泄露的方法和技巧. 可以参考上期精读精读《2017 前端性能优化备忘录》 4 总结即便在 JS 中, 我们很少去直接去做内存管理. 但是我们在写代码的时候, 也要有内存管理的意识, 谨慎的处理可能会造成内存泄露的场景. 讨论地址是:精读《JS 中的内存管理》 · Issue ##40 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。 参考文章: MDN 的内存管理介绍"},{"title":"《JS with 语法》","path":"/wiki/WebWeekly/前沿技术/《JS with 语法》.html","content":"当前期刊数: 205 with 是一个不推荐使用的语法,因为它的作用是改变上下文,而上下文环境对开发者影响很大。 本周通过 JavaScript’s Forgotten Keyword (with) 这篇文章介绍一下 with 的功能。 概述下面是一种使用 with 的例子: with (console) { log('I dont need the "console." part anymore!');} 我们往上下文注入了 console 对象,而 console.log 这个属性就被注册到了这个 Scope 里。 再比如: with (console) { with (['a', 'b', 'c']) { log(join('')); // writes "abc" to the console. }} 通过嵌套,我们可以追加注入上下文。其中 with (['a', 'b', 'c']) 其实是把 ['a', 'b', 'c'] 的返回值对象注入到了上下文,而数组对象具有 .join 成员函数,所以可以直接调用 join('') 输出 "abc"。 为了不让结果这么 Magic,建议以枚举方式申明要注入的 key: with ({ myProperty: 'Hello world!' }) { console.log(myProperty); // Logs "Hello world!"} 那为什么不推荐使用 with 呢?比如下面的情况: function getAverage(min, max) { with (Math) { return round((min + max) / 2); }}getAverage(1, 5); 注入的上下文可能与已有上下文产生冲突,导致输出结果为 NaN。 所以业务代码中不推荐使用 with,而且实际上在 严格模式 下 with 也是被禁用的。 精读由于 with 定义的上下文会优先查找,因此在前端沙盒领域是一种解决方案,具体做法是: const sandboxCode = `with(scope) { ${code} }`new Function('scope', sandboxCode) 这样就把所有 scope 定义的对象限定住了。但如果访问 scope 外的对象还是会向上冒泡查找,我们可以结合 Proxy 来限制查找范围,这样就能完成一个可用性尚可的沙盒。 第二种 with 的用法是前端模版引擎。 我们经常看到模版引擎里会有一些 forEach、map 等特殊用法,这些语法完全可以通过 with 注入。当然并不是所有模版引擎都是这么实现的,还有另一种方案是,现将模版引擎解析为 AST,再根据 AST 构造并执行,如果把这个过程放到编译时,那么 JSX 就是一个例子。 最后关于 with 注入上下文,还有一个误区,那就是认为下面的代码仅仅注入了 run 属性: with ({ run: () => {} }) { run()} 其实不然,因为 with 会在整个原型链上查找,而 {} 的原型链是 Object.prototype,这就导致挂在了许多非预期的属性。 如果想要挂载一个纯净的对象,可以使用 Object.create() 创建对象挂载到 with 上。 总结with 的使用场景很少,一般情况下不推荐使用。 如果你还有其他正经的 with 使用场景,可以告知我,或者给出评论。 讨论地址是:精读《JS with 语法》· Issue ##343 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Immutable 结构共享》","path":"/wiki/WebWeekly/前沿技术/《Immutable 结构共享》.html","content":"当前期刊数: 9 本期精读的文章是:Immutable 结构共享是如何实现的 鉴于 mobx-state-tree 的发布,实现了 mutable 到 immutable 数据的自由转换,将 mobx 写法的数据流,无缝接入 redux 生态,或继续使用 mobx 生态。 这是将事务性,可追溯性与依赖追踪特性的结合,同时解决开发体验与数据流可维护性。万一这种思路火了呢?我们先来预热下其重要特征,结构共享。 1 引言 结构共享不仅仅是 “结构共享” 那么简单,背后包含了 Hash maps tries 与 vector tries 结构的支持,如果让我们设计一个结构共享功能,需要考虑哪些点呢?本期精读的文章给了答案。 2 内容概要使用 Object.assign 作用于大对象时,速度会成为瓶颈,比如拥有 100,000 个属性的对象,这个操作耗费了 134ms。性能损失主要原因是 “结构共享” 操作需要遍历近 10 万个属性,而这些引用操作耗费了 100ms 以上的时间。 解决办法就是减少引用指向的操作数量,而且由于引用指向到任何对象的损耗都几乎一致(无论目标对象极限小或者无穷大,引用消耗时间都几乎没有区别),我们需要一种精心设计的树状结构将打平的引用建立深度,以减少引用操作次数,vector tries 就是一种解决思路: 上图的 key: t0143c274,通过 hash 后得到的值为 621051904(与 md5 不同,比如 hash(“a”) == 0,hash(“c”) == 2),转化为二进制后,值是 10010 10000 01001 00000 00000 00000,这个路径是唯一的,同时,为了减少树的深度,按照 5bit 切分,切分后的路径也是唯一的。因此寻址路径就如上图所示。 因此结构共享的核心思路是以空间换时间。 3 精读本精读由 rccoder ascoders cisen BlackGanglion jasonslyvia TingGe twobin camsong 讨论而出,以及我个人的吐血阅读论文原文总结而成。 Immutable 树结构的特性以 camsong 的动态图形象介绍一下共享的操作流程: 但是,当树越宽(子节点越多)时,相应树的高度会下降,随之查询效率会提高,但更新效率则会下降(试想一下极限情况,就相当于线性结构)。为寻求更新与查询的平衡,我们便选择了 5bit 一分割。 因此最终每个节点拥有 2^5=32 个子节点,同时通过 Vector trie 和 Hash maps trie 压缩空间结构,使其深度最小,性能最优。 Vector trie通过这篇文章查看详细介绍。 其原理是,使用二叉树,将所有值按照顺序,从左到右存放于叶子节点,当需要更新数据时,只将其更新路径上的节点生成新的对象,没有改变的节点继续共用。 Hash maps trieImmutablejs 对于 Map,使用了这种方式优化,并且通过树宽与树高的压缩,形成了文中例图中的效果(10010 10000 聚合成了一个节点,并且移除了同级的空节点)。 树宽压缩: 树高压缩: 再结合 Vector trie,实现结构共享,保证其更新性能最优,同时查询路径相对较优。 Object.assign 是否可替代 Immutable? 结构共享指的是,根节点的引用改变,但对没修改的节点,引用依然指向旧节点。所以Object.assign 也能实现结构共享 见如下代码: const objA = { a: 1, b: 2, c: 3 }const objB = Object.assign({}, objA, { c: 4 })objA === objB // falseobjA.a === objB.a // trueobjA.b === objB.b // true 证明 Object.assign 完全可以胜任 Immutable 的场景。但正如文章所述,当对象属性庞大时, Object.assign 的效率较低,因此在特殊场景,不适合使用 Object.assign 生成 immutable 数据。但是大部分场景还是完全可以使用 Object.assign 的,因为性能不是瓶颈,唯一繁琐点在于深层次对象的赋值书写起来很麻烦。 Map 性能比 Object.assign 更好,是否可以替代 Immutable? 当一层节点达到 1000000 时,immutable.get 查询性能是 object.key 的 10 倍以上。 就性能而言可以替代 Immutable,但就结合 redux 使用而言,无法替代 Immutable。 redux 判断数据更新的条件是,对象引用是否变化,而且要满足,当修改对象子属性时,父级对象的引用也要一并修改。Map 跪在这个特性上,它无法使 set 后的 map 对象产生一份新的引用。 这样会导致,Connect 了 style 对象,其 backgroundColor 属性变化时,不会触发 reRender。因此虽然 Map 性能不错,但无法胜任 Object.assign 或 immutablejs 库对 redux 的支持。 3 总结数据结构共享要达到真正可用,需要借助 Hash maps tries 和 vector tries 数据结构的帮助,在上文中已经详细阐述。既然清楚了结构共享怎么做,就更加想知道 mobx-state-tree 是如何做到 mutable 数据到 immutable 数据转换了,敬请期待下次的源码分析(不一定在下一期)。 如何你对原理不是很关心,那拿走这个结论也不错:在大部分情况可以使用 Object.assign 代替 Immutablejs,只要你不怕深度赋值的麻烦语法;其效果与 Immutablejs 一模一样,唯一,在数据量巨大的字段上,可以使用 Immutablejs 代替以提高性能。 讨论地址是:Immutable 结构共享是如何实现的? · Issue ##14 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《JS 数组的内部实现》","path":"/wiki/WebWeekly/前沿技术/《JS 数组的内部实现》.html","content":"当前期刊数: 239 每个 JS 执行引擎都有自己的实现,我们这次关注 V8 引擎是如何实现数组的。 本周主要精读的文章是 How JavaScript Array Works Internally?,比较简略的介绍了 V8 引擎的数组实现机制,笔者也会参考部分其他文章与源码结合进行讲解。 概述JS 数组的内部类型有很多模式,如: PACKED_SMI_ELEMENTS PACKED_DOUBLE_ELEMENTS PACKED_ELEMENTS HOLEY_SMI_ELEMENTS HOLEY_DOUBLE_ELEMENTS HOLEY_ELEMENTS PACKED 翻译为打包,实际意思是 “连续有值的数组”;HOLEY 翻译为孔洞,表示这个数组有很多孔洞一样的无效项,实际意思是 “中间有孔洞的数组”,这两个名词是互斥的。 SMI 表示数据类型为 32 位整型,DOUBLE 表示浮点类型,而什么类型都不写,表示数组的类型还杂糅了字符串、函数等,这个位置上的描述也是互斥的。 所以可以这么去看数组的内部类型:[PACKED, HOLEY]_[SMI, DOUBLE, '']_ELEMENTS。 最高效的类型 PACKED_SMI_ELEMENTS一个最简单的空数组类型默认为 PACKED_SMI_ELEMENTS: const arr = [] // PACKED_SMI_ELEMENTS PACKED_SMI_ELEMENTS 类型是性能最好的模式,存储的类型默认是连续的整型。当我们插入整型时,V8 会给数组自动扩容,此时类型还是 PACKED_SMI_ELEMENTS: const arr = [] // PACKED_SMI_ELEMENTSarr.push(1) // PACKED_SMI_ELEMENTS 或者直接创建有内容的数组,也是这个类型: const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS 自动降级当我们对数组使用骚操作时,V8 会默默的进行类型降级。比如突然访问到第 100 项: const arr = [1, 2, 3] // PACKED_SMI_ELEMENTSarr[100] = 4 // HOLEY_SMI_ELEMENTS 如果突然插入一个浮点类型,会降级到 DOUBLE: const arr = [1, 2, 3] // PACKED_SMI_ELEMENTSarr.push(4.1) // PACKED_DOUBLE_ELEMENTS 当然如果两个骚操作一结合,HOLEY_DOUBLE_ELEMENTS 就成功被你造出来了: const arr = [1, 2, 3] // PACKED_SMI_ELEMENTSarr[100] = 4.1 // HOLEY_DOUBLE_ELEMENTS 再狠一点,插入个字符串或者函数,那就到了最最兜底类型,HOLEY_ELEMENTS: const arr = [1, 2, 3] // PACKED_SMI_ELEMENTSarr[100] = '4' // HOLEY_ELEMENTS 从是否有 Empty 情况来看,PACKED > HOLEY 的性能,Benchmark 测试结果大概快 23%。 从类型来看,SMI > DOUBLE > 空类型。原因是类型决定了数组每项的长度,DOUBLE 类型是指每一项可能为 SMI 也可能为 DOUBLE,而空类型的每一项类型完全不可确认,在长度确认上会花费额外开销。 因此,HOLEY_ELEMENTS 是性能最差的兜底类型。 降级的不可逆性文中提到一个重点,表示降级是不可逆的,具体可以看下图: 其实要表达的规律很简单,即 PACKED 只会变成更糟的 HOLEY,SMI 只会往更糟的 DOUBLE 和空类型变,且这两种变化都不可逆。 精读为了验证文章的猜想,笔者使用 v8-debug 调试了一番。 使用 v8-debug 调试先介绍一下 v8-debug,它是一个 v8 引擎调试工具,首先执行下面的命令行安装 jsvu: npm i -g jsvu 然后执行 jsvu,根据引导选择自己的系统类型,第二步选择要安装的 js 引擎,选择 v8 和 v8-debug: jsvu// 选择 macos// 选择 v8,v8-debug 然后随便创建一个 js 文件,比如 test.js,再通过 ~/.jsvu/v8-debug ./test.js 就可以执行调试了。默认是不输出任何调试内容的,我们根据需求添加参数来输出要调试的信息,比如: ~/.jsvu/v8-debug ./test.js --print-ast 这样就会把 test.js 文件的语法树打印出来。 使用 v8-debug 调试数组的内部实现为了观察数组的内部实现,使用 console.log(arr) 显然不行,我们需要用 %DebugPrint(arr) 以 debug 模式打印数组,而这个 %DebugPrint 函数式 V8 提供的 Native API,在普通 js 脚本是不识别的,因此我们要在执行时添加参数 --allow-natives-syntax: ~/.jsvu/v8-debug ./test.js --allow-natives-syntax 同时,在 test.js 里使用 %DebugPrint 打印我们要调试的数组,如: const arr = []%DebugPrint(arr) 输出结果为: DebugPrint: 0x120d000ca0b9: [JSArray] - map: 0x120d00283a71 <Map(PACKED_SMI_ELEMENTS)> [FastProperties] 也就是说,arr = [] 创建的数组的内部类型为 PACKED_SMI_ELEMENTS,符合预期。 验证不可逆转换不看源码的话,姑且相信原文说的类型转换不可逆,那么我们做一个测试: const arr = [1, 2, 3]arr.push(4.1)console.log(arr);%DebugPrint(arr)arr.pop()console.log(arr);%DebugPrint(arr) 打印核心结果为: 1,2,3,4.1DebugPrint: 0xf91000ca195: [JSArray] - map: 0x0f9100283b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]1,2,3DebugPrint: 0xf91000ca195: [JSArray] - map: 0x0f9100283b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties] 可以看到,即便 pop 后将原数组回退到完全整型的情况,DOUBLE 也不会优化为 SMI。 再看下长度的测试: const arr = [1, 2, 3]arr[4] = 4console.log(arr);%DebugPrint(arr)arr.pop()arr.pop()console.log(arr);%DebugPrint(arr) 打印核心结果为: 1,2,3,,4DebugPrint: 0x338b000ca175: [JSArray] - map: 0x338b00283ae9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]1,2,3DebugPrint: 0x338b000ca175: [JSArray] - map: 0x338b00283ae9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties] 也证明了 PACKED 到 HOLEY 的不可逆。 字典模式数组还有一种内部实现是 Dictionary Elements,它用 HashTable 作为底层结构模拟数组的操作。 这种模式用于数组长度非常大的时候,不需要连续开辟内存空间,而是用一个个零散的内存空间通过一个 HashTable 寻址来处理数据的存储,这种模式在数据量大时节省了存储空间,但带来了额外的查询开销。 当对数组的赋值远大于当前数组大小时,V8 会考虑将数组转化为 Dictionary Elements 存储以节省存储空间。 做一个测试: const arr = [1, 2, 3];%DebugPrint(arr);arr[3000] = 4;%DebugPrint(arr); 主要输出结果为: DebugPrint: 0x209d000ca115: [JSArray] - map: 0x209d00283a71 <Map(PACKED_SMI_ELEMENTS)> [FastProperties]DebugPrint: 0x209d000ca115: [JSArray] - map: 0x209d00287d29 <Map(DICTIONARY_ELEMENTS)> [FastProperties] 可以看到,占用了太多空间会导致数组的内部实现切换为 DICTIONARY_ELEMENTS 模式。 实际上这两种模式是根据固定规则相互转化的,具体查了下 V8 源码: 字典模式在 V8 代码里叫 SlowElements,反之则叫 FastElements,所以要看转化规则,主要就看两个函数:ShouldConvertToSlowElements 和 ShouldConvertToFastElements。 下面是 ShouldConvertToSlowElements 代码,即什么时候转化为字典模式: static inline bool ShouldConvertToSlowElements( uint32_t used_elements, uint32_t new_capacity) { uint32_t size_threshold = NumberDictionary::kPreferFastElementsSizeFactor * NumberDictionary::ComputeCapacity(used_elements) * NumberDictionary::kEntrySize; return size_threshold <= new_capacity;}static inline bool ShouldConvertToSlowElements( JSObject object, uint32_t capacity, uint32_t index, uint32_t* new_capacity) { STATIC_ASSERT(JSObject::kMaxUncheckedOldFastElementsLength <= JSObject::kMaxUncheckedFastElementsLength); if (index < capacity) { *new_capacity = capacity; return false; } if (index - capacity >= JSObject::kMaxGap) return true; *new_capacity = JSObject::NewElementsCapacity(index + 1); DCHECK_LT(index, *new_capacity); if (*new_capacity <= JSObject::kMaxUncheckedOldFastElementsLength || (*new_capacity <= JSObject::kMaxUncheckedFastElementsLength && ObjectInYoungGeneration(object))) { return false; } return ShouldConvertToSlowElements(object.GetFastElementsUsage(), *new_capacity);} ShouldConvertToSlowElements 函数被重载了两次,所以有两个判断逻辑。第一处 new_capacity > size_threshold 则变成字典模式,new_capacity 表示新尺寸,而 size_threshold 是根据 3 * 已有尺寸 * 2 计算出来的。 第二处 index - capacity >= JSObject::kMaxGap 时变成字典模式,其中 kMaxGap 是常量 1024,也就是新加入的 HOLEY(孔洞) 大于 1024,则转化为字典模式。 而由字典模式转化为普通模式的函数是 ShouldConvertToFastElements: static bool ShouldConvertToFastElements( JSObject object, NumberDictionary dictionary, uint32_t index, uint32_t* new_capacity) { // If properties with non-standard attributes or accessors were added, we // cannot go back to fast elements. if (dictionary.requires_slow_elements()) return false; // Adding a property with this index will require slow elements. if (index >= static_cast<uint32_t>(Smi::kMaxValue)) return false; if (object.IsJSArray()) { Object length = JSArray::cast(object).length(); if (!length.IsSmi()) return false; *new_capacity = static_cast<uint32_t>(Smi::ToInt(length)); } else if (object.IsJSArgumentsObject()) { return false; } else { *new_capacity = dictionary.max_number_key() + 1; } *new_capacity = std::max(index + 1, *new_capacity); uint32_t dictionary_size = static_cast<uint32_t>(dictionary.Capacity()) * NumberDictionary::kEntrySize; // Turn fast if the dictionary only saves 50% space. return 2 * dictionary_size >= *new_capacity;} 重点是最后一行 return 2 * dictionary_size >= *new_capacity 表示字典模式仅节省了 50% 空间时,不如切换为普通模式(fast mode)。 具体就不测试了,感兴趣同学可以用上面介绍的方法使用 v8-debug 测试一下。 总结JS 数组使用方法非常灵活,但 V8 使用 C++ 实现时,必须转化为更底层的类型,所以为了兼顾性能,就做了快慢模式,而快模式又分了 SMI、DOUBLE;PACKED、HOLEY 模式分别处理来尽可能提升速度。 也就是说,我们在随意创建数组的时候,V8 会分析数组的元素构成与长度变化,自动分发到各种不同的子模式处理,以最大化提升性能。 这种模式使 JS 开发者获得了更好的开发者体验,而实际上执行性能也和 C++ 原生优化相差无几,所以从这个角度来看,JS 是一种更高封装层次的语言,极大降低了开发者学习门槛。 当然 JS 还提供了一些相对原生的语法比如 ArrayBuffer,或者 WASM 让开发者直接操作更底层的特性,这可以使性能控制更精确,但带来了更大的学习和维护成本,需要开发者根据实际情况权衡。 讨论地址是:精读《JS 数组的内部实现》· Issue ##414 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《JS 引擎基础之 Shapes and Inline Caches》","path":"/wiki/WebWeekly/前沿技术/《JS 引擎基础之 Shapes and Inline Caches》.html","content":"当前期刊数: 62 1 引言本期精读的文章是:JS 引擎基础之 Shapes and Inline Caches 一起了解下 JS 引擎是如何运作的吧! JS 的运作机制可以分为 AST 分析、引擎执行两个步骤: JS 源码通过 parser(分析器)转化为 AST(抽象语法树),再经过 interpreter(解释器)解析为 bytecode(字节码)。 为了提高运行效率,optimizing compiler(优化编辑器)负责生成 optimized code(优化后的机器码)。 本文主要从 AST 之后说起。 2 概述JS 的解释器、优化器JS 代码可能在字节码或者优化后的机器码状态下执行,而生成字节码速度很快,而生成机器码就要慢一些了。 V8 也类似,V8 将 interpreter 称为 Ignition(点火器),将 optimizing compiler 称为 TurboFan(涡轮风扇发动机)。 可以理解为将代码先点火启动后,逐渐进入涡轮发动机提速。 代码先快速解析成可执行的字节码,在执行过程中,利用执行中获取的数据(比如执行频率),将一些频率高的方法,通过优化编译器生成机器码以提速。 火狐使用的 Mozilla 引擎有一点点不同,使用了两个优化编译器,先将字节码优化为部分机器码,再根据这个部分优化后的代码运行时拿到的数据进行最终优化,生成高度优化的机器码,如果优化失败将会回退到部分优化的机器码。 笔者:不同前端引擎对 JS 优化方式大同小异,后面会继续列举不同前端引擎在解析器、编译器部分优化的方式。 微软的 Edge 浏览器,使用的 Chakra 引擎,优化方式与 Mozilla 很像,区别是第二个最终优化的编译器同时接收字节码和部分优化的机器码产生的数据,并且在优化失败后回退到第一步字节码而不是第二步。 Safari、React Native 使用的 JSC 引擎则更为极端,使用了三个优化编译器,其优化是一步步渐进的,优化失败后都会回退到第一步部分优化的机器码。 为什么不同前端引擎会使用不同的优化策略呢?这是由于 JS 要么使用解释器快速执行(生成字节码),或者优化成机器码后再执行,但优化消耗时间的并不总是小于字节码低效运行损耗的时间,所以有些引擎选择了多个优化编译器,逐层优化,尽可能在解析时间与执行效率中找到一个平衡点。 JS 的对象模型JS 是基于面向对象的,那么 JS 引擎是如何实现 JS 对象模型的呢?他们用了哪些技巧加速访问 JS 对象的属性? 和解析器、优化器一样,大部分主流 JS 引擎在对象模型实现上也很类似。 ECMAScript 规范确定了对象模型就是一个以字符串为 key 的字典,除了其值以外,还定义了 Writeable Enumerable Configurable 这些配置,表示这个 key 能否被重写、遍历访问、配置。 虽然规范定义了 [[]] 双括号的写法,那这不会暴露给用户,暴露给用户的是 Object.getOwnPropertyDescriptor 这个 API,可以拿到某个属性的配置。 在 JS 中,数组是对象的特殊场景,相比对象,数组拥有特定的下标,根据 ECMAScript 规范规定,数组下标的长度最大为 2³²−1。同时数组拥有 length 属性: length 只是一个不可枚举、不可配置的属性,并且在数组赋值时,会自动更新数值: 所以数组是特殊的对象,结构完全一致。 属性访问效率优化属性访问是最常见的,所以 JS 引擎必须对属性访问做优化。 ShapesJS 编程中,给不同对象相同的 key 名很常见,访问不同对象的同一个 propertyKey 也很常见: const object1 = { x: 1, y: 2 };const object2 = { x: 3, y: 4 };function logX(object) { console.log(object.x); // ^^^^^^^^}logX(object1);logX(object2); 这时 object1 与 object2 拥有一个相同的 shape。拿拥有 x、y 属性的对象来看: 如果访问 object.y,JS 引擎会先找到 key y,再查找 [[value]]。 如果将属性值也存储在 JSObject 中,像 object1 object2 就会出现许多冗余数据,因此引擎单独存储 Shape,与真实对象隔离: 这样具有相同结构的对象可以共享 Shape。所有 JS 引擎都是用这种方式优化对象,但并不都称为 Shape,这里就不详细罗列了,可以去原文查看在各引擎中 Shape 的别名。 Transition chains 和 Transition trees如果给一个对象增加了 key,JS 引擎如何生成新的 Shape 呢? 这种 Shape 链式创建的过程,称为 Transition chains: 开始创建空对象时,JSObject 和 Shape 都是空,当为 x 赋值 5 时,在 JSObject 下标 0 的位置添加了 5,并且 Shape 指向了拥有字段 x 的 Shape(x),当赋值 y 为 6 时,在 JSObject 下标 1 的位置添加了 6,并将 Shape 指向了拥有字段 x 和 y 的 Shape(x, y)。 而且可以再优化,Shape(x, y) 由于被 Shape(x) 指向,所以可以省略 x 这个属性: 笔者:当然这里说的主要是优化技巧,我们可以看出来,JS 引擎在做架构设计时没有考虑优化问题,而在架构设计完后,再回过头对时间和空间进行优化,这是架构设计的通用思路。 如果没有连续的父 Shape,比如分别创建两个对象: const object1 = {};object1.x = 5;const object2 = {};object2.y = 6; 这时要通过 Transition trees 来优化: 可以看到,两个 Shape(x) Shape(y) 分别继承 Shape(empty)。当然也不是任何时候都会创建空 Shape,比如下面的情况: const object1 = {};object1.x = 5;const object2 = { x: 6 }; 生成的 Shape 如下图所示: 可以看到,由于 object2 并不是从空对象开始的,所以并不会从 Shape(empty) 开始继承。 Inline Caches大概可以翻译为“局部缓存”,JS 引擎为了提高对象查找效率,需要在局部做高效缓存。 比如有一个函数 getX,从 o.x 获取值: function getX(o) { return o.x;} JSC 引擎 生成的字节码结构是这样的: get_by_id 指令是获取 arg1 参数指向的对象 x,并存储在 loc0,第二步则返回 loc0。 当执行函数 getX({ x: 'a' }) 时,引擎会在 get_by_id 指令中缓存这个对象的 Shape: 这个对象的 Shape 记录了自己拥有的字段 x 以及其对应的下标 offset: 执行 get_by_id 时,引擎从 Shape 查找下标,找到 x,这就是 o.x 的查找过程。但一旦找到,引擎就会将 Shape 保存的 offset 缓存起来,下次开始直接跳过 Shape 这一步: 以后访问 o.x 时,只要 Shape 相同,引擎直接从 get_by_id 指令中缓存的下标中可以直接命中要查找的值,而这个缓存在指令中的下标就是 Inline Cache. 数组存储优化和对象一样,数组的存储也可以被优化,而由于数组的特殊性,不需要为每一项数据做完整的配置。 比如这个数组: const array = ["##jsconfeu"]; JS 引擎同样通过 Shape 与数据分离的方式存储: JS 引擎将数组的值单独存储在 Elements 结构中,而且它们通常都是可读可配置可枚举的,所以并不会像对象一样,为每个元素做配置。 但如果是这种例子: // 永远不要这么做const array = Object.defineProperty([], "0", { value: "Oh noes!!1", writable: false, enumerable: false, configurable: false}); JS 引擎会存储一个 Dictionary Elements 类型,为每个数组元素做配置: 这样数组的优化就没有用了,后续的赋值都会基于这种比较浪费空间的 Dictionary Elements 结构。所以永远不要用 Object.defineProperty 操作数组。 通过对 JS 引擎原理的认识,作者总结了下面两点代码中的注意事项: 尽量以相同方式初始化对象,因为这样会生成较少的 Shapes。 不要混淆对象的 propertyKey 与数组的下标,虽然都是用类似的结构存储,但 JS 引擎对数组下标做了额外优化。 3 精读这次原理系列解读是针对 JS 引擎执行优化这个点的,而网页渲染流程大致如下: 可以看到 Script 在整个网页解析链路中位置是比较靠前的,JS 解析效率会直接影响网页的渲染,所以 JS 引擎通过解释器(parser)和优化器(optimizing compiler)尽可能对 JS 代码提效。 Shapes需要特别说明的是,Shapes 并不是原型链,原型链是面向开发者的概念,而 Shapes 是面向 JS 引擎的概念。 比如如下代码: const a = {};const b = {};const c = {}; 显然对象 a b c 之间是没有关联的,但共享一个 Shapes。 另外理解引擎的概念有助于我们站在语法层面对立面的角度思考问题:在 JS 学习阶段,我们会执着于思考如下几种创建对象方式的异同: const a = {};const b = new Object();const c = new f1();const d = Object.create(null); 比如上面四种情况,我们要理解在什么情况下,用何种方式创建对象性能最优。 但站在 JS 引擎优化角度去考虑,JS 引擎更希望我们都通过 const a = {} 这种看似最没有难度的方式创建对象,因为可以共享 Shape。而与其他方式混合使用,可能在逻辑上做到了优化,但阻碍了 JS 引擎做自动优化,可能会得不偿失。 Inline Caches对象级别的优化已经很极致了,工程代码中也没有机会帮助 JS 引擎做得更好,值得注意的是不要对数组使用 Object 对象下的方法,尤其是 defineProperty,因为这会让 JS 引擎在存储数组元素时,使用 Dictionary Elements 结构替代 Elements,而 Elements 结构是共享 PropertyDescriptor 的。 但也有难以避免的情况,比如使用 Object.defineProperty 监听数组变化时,就不得不破坏 JS 引擎渲染了。 笔者写 dob 的时候,使用 proxy 监听数组变化,这并不会改变 Elements 的结构,所以这也从另一个侧面证明了使用 proxy 监听对象变化比 Object.defineProperty 更优,因为 Object.defineProperty 会破坏 JS 引擎对数组做的优化。 4 总结本文主要介绍了 JS 引擎两个概念: Shapes 与 Inline Caches,通过认识 JS 引擎的优化方式,在编程中需要注意以下两件事: 尽量以相同方式初始化对象,因为这样会生成较少的 Shapes。 不要混淆对象的 propertyKey 与数组的下标,虽然都是用类似的结构存储,但 JS 引擎对数组下标做了额外优化。 5 更多讨论 讨论地址是:精读《JS 引擎基础之 Shapes and Inline Caches》 · Issue ##91 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《Function VS Class 组件》","path":"/wiki/WebWeekly/前沿技术/《Function VS Class 组件》.html","content":"当前期刊数: 95 1. 引言为什么要了解 Function 写法的组件呢?因为它正在变得越来越重要。 那么 React 中 Function Component 与 Class Component 有何不同? how-are-function-components-different-from-classes 这篇文章带来了一个独特的视角。 顺带一提,以后会用 Function Component 代替 Stateless Component 的说法,原因是:自从 Hooks 出现,函数式组件功能在不断丰富,函数式组件不再需要强调其无状态特性,因此叫 Function Component 更为恰当。 2. 概述原文事先申明:并没有对 Function 与 Classes 进行优劣对比,而仅仅进行特性对比,所以不接受任何吐槽。 这两种写法没有好坏之分,性能差距也几乎可以忽略,而且 React 会长期支持这两种写法。 Capture props对比下面两段代码。 Class Component: class ProfilePage extends React.Component { showMessage = () => { alert("Followed " + this.props.user); }; handleClick = () => { setTimeout(this.showMessage, 3000); }; render() { return <button onClick={this.handleClick}>Follow</button>; }} Function Component: function ProfilePage(props) { const showMessage = () => { alert("Followed " + props.user); }; const handleClick = () => { setTimeout(showMessage, 3000); }; return <button onClick={handleClick}>Follow</button>;} (在线 Demo) 这两个组件都描述了同一个逻辑:点击按钮 3 秒后 alert 父级传入的用户名。 如下父级组件的调用方式: <ProfilePageFunction user={this.state.user} /><ProfilePageClass user={this.state.user} /> 那么当点击按钮后的 3 秒内,父级修改了 this.state.user,弹出的用户名是修改前的还是修改后的呢? Class Component 展示的是修改后的值: Function Component 展示的是修改前的值: 那么 React 文档中描述的 props 不是不可变(Immutable) 数据吗?为啥在运行时还会发生变化呢? 原因在于,虽然 props 不可变,是 this 在 Class Component 中是可变的,因此 this.props 的调用会导致每次都访问最新的 props。 而 Function Component 不存在 this.props 的语法,因此 props 总是不可变的。 为了便于理解,笔者补充一些代码注解: Function Component: function ProfilePage(props) { setTimeout(() => { // 就算父组件 reRender,这里拿到的 props 也是初始的 console.log(props); }, 3000);} Class Component: class ProfilePage extends React.Component { render() { setTimeout(() => { // 如果父组件 reRender,this.props 拿到的永远是最新的。 // 并不是 props 变了,而是 this.props 指向了新的 props,旧的 props 找不到了 console.log(this.props); }, 3000); }} 如果希望在 Class Component 捕获瞬时 Props,可以: const props = this.props;,但这样的代码很蹩脚,所以如果希望拿到稳定的 props,使用 Function Component 是更好的选择。 Hooks 也具有 capture value 特性看下面的代码: function MessageThread() { const [message, setMessage] = useState(""); const showMessage = () => { alert("You said: " + message); }; const handleSendClick = () => { setTimeout(showMessage, 3000); }; const handleMessageChange = e => { setMessage(e.target.value); }; return ( <> <input value={message} onChange={handleMessageChange} /> <button onClick={handleSendClick}>Send</button> </> );} (在线 Demo) 在点击 Send 按钮后,再次修改输入框的值,3 秒后的输出依然是 点击前输入框的值。这说明 Hooks 同样具有 capture value 的特性。 利用 useRef 可以规避 capture value 特性: function MessageThread() { const latestMessage = useRef(""); const showMessage = () => { alert("You said: " + latestMessage.current); }; const handleSendClick = () => { setTimeout(showMessage, 3000); }; const handleMessageChange = e => { latestMessage.current = e.target.value; };} 只要将赋值与取值的对象变成 useRef,而不是 useState,就可以躲过 capture value 特性,在 3 秒后得到最新的值。 这说明了利用 Function Component + Hooks 可以实现 Class Component 做不到的 capture props、capture value,而且 React 官方也推荐 新的代码使用 Hooks 编写。 3. 精读原文 how-are-function-components-different-from-classes 从一个侧面讲述了 Function Component 与 Class Component 的不同点,之所以将 Function Component 与 Class Component 相提并论,几乎都要归功于 Hooks API 的出现,有了 Hooks,Function Component 的能力才得以向 Class Component 看齐。 关于 React Hooks,之前的两篇精读分别有过介绍: 精读《React Hooks》 精读《怎么用 React Hooks 造轮子》 但是,虽然 Hook 已经发布了稳定版本,但周边生态跟进还需要时间(比如 useRouter)、最佳实践整理还需要时间,因此不建议重构老代码。 为了更好的使用 Function Component,建议时常与 Class Component 的功能做对比,方便理解和记忆。 下面整理一些常见的 Function Component 问题: 非常建议完整阅读 React Hooks FAQ。 怎么替代 shouldComponentUpdate说实话,Function Component 替代 shouldComponentUpdate 的方案并没有 Class Component 优雅,代码是这样的: const Button = React.memo(props => { // your component}); 或者在父级就直接生成一个自带 memo 的子元素: function Parent({ a, b }) { // Only re-rendered if `a` changes: const child1 = useMemo(() => <Child1 a={a} />, [a]); // Only re-rendered if `b` changes: const child2 = useMemo(() => <Child2 b={b} />, [b]); return ( <> {child1} {child2} </> );} 相比之下,Class Component 的写法通常是: class Button extends React.PureComponent {} 这样就自带了 shallowEqual 的 shouldComponentUpdate。 怎么替代 componentDidUpdate由于 useEffect 每次 Render 都会执行,因此需要模拟一个 useUpdate 函数: const mounting = useRef(true);useEffect(() => { if (mounting.current) { mounting.current = false; } else { fn(); }}); 更多可以查看 精读《怎么用 React Hooks 造轮子》 怎么替代 forceUpdateReact 官方文档提供了一种方案: const [ignored, forceUpdate] = useReducer(x => x + 1, 0);function handleClick() { forceUpdate();} 每次执行 dispatch 时,只要 state 变化就会触发组件更新。当然 useState 也同样可以模拟: const useUpdate = () => useState(0)[1]; 我们知道 useState 下标为 1 的项是用来更新数据的,而且就算数据没有变化,调用了也会刷新组件,所以我们可以把返回一个没有修改数值的 setValue,这样它的功能就仅剩下刷新组件了。 更多可以查看 精读《怎么用 React Hooks 造轮子》 state 拆分过多useState 目前的一种实践,是将变量名打平,而非像 Class Component 一样写在一个 State 对象里: class ClassComponent extends React.PureComponent { state = { left: 0, top: 0, width: 100, height: 100 };}// VSfunction FunctionComponent { const [left,setLeft] = useState(0) const [top,setTop] = useState(0) const [width,setWidth] = useState(100) const [height,setHeight] = useState(100)} 实际上在 Function Component 中也可以聚合管理 State: function FunctionComponent() { const [state, setState] = useState({ left: 0, top: 0, width: 100, height: 100 });} 只是更新的时候,不再会自动 merge,而需要使用 ...state 语法: setState(state => ({ ...state, left: e.pageX, top: e.pageY })); 可以看到,更少的黑魔法,更可预期的结果。 获取上一个 props虽然不怎么常用,但是毕竟 Class Component 可以通过 componentWillReceiveProps 拿到 previousProps 与 nextProps,对于 Function Component,最好通过自定义 Hooks 方式拿到上一个状态: function Counter() { const [count, setCount] = useState(0); const prevCount = usePrevious(count); return ( <h1> Now: {count}, before: {prevCount} </h1> );}function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }); return ref.current;} 通过 useEffect 在组件渲染完毕后再执行的特性,再利用 useRef 的可变特性,让 usePrevious 的返回值是 “上一次” Render 时的。 可见,合理运用 useEffect useRef,可以做许多事情,而且封装成 CustomHook 后使用起来仍然很方便。 未来 usePrevious 可能成为官方 Hooks 之一。 性能注意事项useState 函数的参数虽然是初始值,但由于整个函数都是 Render,因此每次初始化都会被调用,如果初始值计算非常消耗时间,建议使用函数传入,这样只会执行一次: function FunctionComponent(props) { const [rows, setRows] = useState(() => createRows(props.count));} useRef 不支持这种特性,需要写一些冗余的函判定是否进行过初始化。 掌握了这些,Function Component 使用起来与 Class Component 就几乎没有差别了! 4. 总结Function Component 功能已经可以与 Class Component 媲美了,但目前最佳实践比较零散,官方文档推荐的一些解决思路甚至不比社区第三方库的更好,可以预料到,Class Component 的功能会被五花八门的实现出来,那些没有被收纳进官方的 Hooks 乍看上去可能会眼花缭乱。 总之选择了 Function Component 就同时选择了函数式的好与坏。好处是功能强大,几乎可以模拟出任何想要的功能,坏处是由于可以灵活组合,如果自定义 Hooks 命名和实现不够标准,函数与函数之间对接的沟通成本会更大。 讨论地址是:精读《Stateless VS Class 组件》 · Issue ##137 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Javascript 事件循环与异步》","path":"/wiki/WebWeekly/前沿技术/《Javascript 事件循环与异步》.html","content":"当前期刊数: 30 本期精读的文章是: How JavaScript works: Event loop and the rise of Async programming + 5 ways to better coding with async/await 1 引言我为什么要选这篇文章呢? sessionstack 最近接连发了好几篇文章, 深入探讨 JS, 以及 JS 中一些内部原理. 文中也讲到了, 伴随深入了解 JS 中的一些工作原理, 才有可能写出更好的代码和程序. 而 JS 中 Event Loop, 我的感觉就像 JS 中的一门内科, 我们平时只注意外科创伤,却忽视了内科问题往往容易莫名其妙的生病。了解 JS Event Loop 的原理,对 setTimeout Promise 这种基础概念不再浮在表层,可以写出更可靠的代码,如果你是前端新人,不要总是因为这个问题挂在一面 :p。 2 内容概要从前我对 Event Loop 的理解也并不透彻,通过仔细阅读此文后, Event Loop 、宿主环境、js 线程三者之间关系更加透明了,希望读者读完后也能有所体会。 文中 Promise、async/await 部分就忽略了,本篇重点介绍 Event Loop 。 Event Loop 与 Call Stack、Web APIs 之间的关系 原文通过 16 个图表达了 5 行代码的执行过程,太长就只贴第一张图了。 Call Stack 是调用栈,Event Loop 就是本期的主角 - 事件循环,Web APIs 泛指宿主环境,比如 nodejs 中的 c++,前端中的浏览器。 任何同步的代码都只存在于 Call Stack 中,遵循先进后出,后进先出的规则,也就是只有异步的代码(不一定是回调)才会进入 Event Loop 中,哪些是异步代码呢?比如: setTimeout()setInterval()Promise.resolve().then()fetch().then() 所有这些异步代码在执行时,都不会进入 Call Stack,而是进入 Event Loop 队列,此时 JS 主线程执行完毕后,且异步时机到了,就会将异步回调中的代码推入 Call Stack 执行。 而控制异步什么时机开始执行,是由宿主环境决定的,因为此时 js 主线程已经调用完毕,除非 Event Loop 队列有内容,推送到 Call Stack 中,否则 js 引擎也不会再执行任何代码。比如通过 fetch 发送请求,当 js 调用浏览器发送请求后,直到浏览器主动告诉 js 请求完成了,期间 js 是无法干预任何的。 最终效果如下 gif 图所示: Microtask 与 MacrotaskEvent Loop 处理异步的方式也分两种,分别是 setTimeout 之流的 Macrotask,与 Promise 之流的 Microtask。 异步队列是周而复始循环执行的,可以看作是二维数组:横排是一个队列中的每一个函数,纵排是每一个队列。 Macrotask 的方式是将执行函数添加到新的纵排,而 Microtask 将执行函数添加到当前执行到队列的横排,因此 Microtask 方式的插入是轻量的,最快被执行到的。 3 精读Event Loop 内容不多,内容概要部分已经讲的比较彻底了,原文最后扯到了 Promise, async/await 的用法和注意点,不然是不会这么长的。 我最近写了一些 dob-react tests 测试文件,发现 componentWillMount 函数在 Microtask 时机 setState 不会触发 rerender: class Hello extends React.Component {\tasync componentWillMount() { await immediate(()=>{ this.setState({a:1}) }) } render() { /**/ }} 这种 immediate 函数的写法只会 render 一次: function immediate(fn) { return new Promise(resolve => { fn() resolve() });} 在线 Demo:http://jsfiddle.net/69z2wepo/90440/ 如果再套一层 setTimeout,哪怕是一层 Promise, 就会 render 两次: function immediate(fn) { return new Promise(resolve => Promise.resolve().then(() => { fn() resolve() }));} 在线 Demo:http://jsfiddle.net/69z2wepo/90441/ ps: 感谢读者们的回复,其实第一个 immediate 函数根本就写错了,执行 fn 的时机是同步的,还是老老实实的使用 Promise().resolve().then() 吧。 4 总结理解了事件循环之后,才是第一步,比如我就对 React 的生命周期中异步 setState 合并机制时而生效,时而不生效抱有疑问,所以想要写好稳健的业务代码还是挺难的,首先要理解这种 “内科” 知识,其次要读懂 react 源码,最后你还要保证不会忘。 讨论地址是:精读《Javascript 事件循环与异步》 · Issue ##41 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Nestjs》文档","path":"/wiki/WebWeekly/前沿技术/《Nestjs》文档.html","content":"当前期刊数: 20 精读 《Nestjs 文档》本期精读的文章是:Nestjs 文档 体验一下 nodejs mvc 框架的优雅设计。 1 引言 Nestjs 是我见过的,将 Typescript 与 Nodejs Framework 结合的最好的例子。 2 内容概要Nestjs 不是一个新轮子,它是基于 Express、socket.io 封装的 nodejs 后端开发框架,对 Typescript 开发者提供类型支持,也能优雅降级供 Js 使用,拥有诸多特性,像中间件等就不展开了,本文重点列举其亮点特性。 2.1 Modules, Controllers, ProvidersNestjs 开发围绕着这三个单词,Modules 是最大粒度的拆分,表示应用或者模块。Controllers 是传统意义的控制器,一个 Module 拥有多个 Controller。Providers 一般用于做 Services,比如将数据库 CRUD 封装在 Services 中,每个 Service 就是一个 Provider。 2.2 装饰器路由装饰器路由是个好东西,路由直接标志在函数头上,做到了路由去中心化: @Controller()export class UsersController { @Get('users') getAllUsers() {} @Get('users/:id') getUser() {} @Post('users') addUser() {}} 以前用过 Go 语言框架 Beego,就是采用了中心化路由管理方式,虽然引入了 namespace 概念,但当协作者多、模块体量巨大时,路由管理成本直线上升。Nestjs 类似 namespace 的概念通过装饰器实现: @Controller('users')export class UsersController { @Get() getAllUsers(req: Request, res: Response, next: NextFunction) {}} 访问 /users 时会进入 getAllUsers 函数。可以看到其 namespace 也是去中心化的。 2.3 模块间依赖注入Modules, Controllers, Providers 之间通过依赖注入相互关联,它们通过同名的 @Module @Controller @Injectable 装饰器申明,如: @Controller()export class UsersController { @Get('users') getAllUsers() {}} @Injectable()export class UsersService { getAllUsers() { return [] }} @Module({ controllers: [ UsersController ], providers: [ UsersService ],})export class ApplicationModule {} 在 ApplicationModule 申明其内部 Controllers 与 Providers 后,就可以在 Controllers 中注入 Providers 了: @Controller()export class UsersController {\tconstructor(private usersService: UsersService) {} @Get('users') getAllUsers() { return this.usersService.getAllUsers() }} 2.4 装饰器参数与大部分框架从 this.req 或 this.context 等取请求参数不同,Nestjs 通过装饰器获取请求参数: @Get('/:id')public async getUser(\t@Response() res,\t@Param('id') id,) { const user = await this.usersService.getUser(id); res.status(HttpStatus.OK).json(user);} @Response 获取 res,@Param 获取路由参数,@Query 获取 url query 参数,@Body 获取 Http body 参数。 3 精读由于临近双十一,项目工期很紧张,本期精读由我独自完成 :p。 3.1 Typeorm有了如此强大的后端框架,必须搭配上同等强大的 orm 才能发挥最大功力,Typeorm 就是最好的选择之一。它也完全使用 Typescript 编写,使用方式具有同样的艺术气息。 3.1.1 定义实体每个实体对应数据库的一张表,Typeorm 在每次启动都会同步表结构到数据库,我们完全不用使用数据库查看表结构,所有结构信息都定义在代码中: @Entity()export class Card { @PrimaryGeneratedColumn({ comment: '主键', }) id: number; @Column({ comment: '名称', length: 30, unique: true, }) name: string = 'nick';} 通过 @Entity 将类定义为实体,每个成员变量对应表中的每一列,如上定义了 id name 两个列,同时列 id 通过 @PrimaryGeneratedColumn 定义为了主键列,列 name 通过参数定义了其最大长度、唯一的信息。 至于类型,Typeorm 通过反射,拿到了类型定义,自动识别 id 为数字类型、name 为字符串类型,当然也可以手动设置 type 参数。 对于初始值,使用 js 语法就好,比如将 name 初始值设置为 nick,在 new Card() 时已经带上了初始值。 3.1.2 自动校验光判断参数类型是不够的,我们可以使用 class-validator 做任何形式的校验: @Column({\tcomment: '配置 JSON',\tlength: 5000,})@Validator.IsString({ message: '必须为字符串' })@Validator.Length(0, 5000, { message: '长度在 0~5000' })content: string; 这里遇到一个问题:新增实体时,需要校验所有字段,但更新实体时,由于性能需要,我们一般不会一次查询所有字段,就需要指定更新时,不校验没有赋值的字段,我们通过 Typeorm 的 EventSubscriber 完成数据库操作前的代码校验,并控制新增时全字段校验,更新时只校验赋值的字段,删除时不做校验: @EventSubscriber()export class EverythingSubscriber implements EntitySubscriberInterface<any> { // 插入前校验 async beforeInsert(event: InsertEvent<any>) { const validateErrors = await validate(event.entity); if (validateErrors.length > 0) { throw new HttpException(getErrorMessage(validateErrors), 404); } } // 更新前校验 async beforeUpdate(event: UpdateEvent<any>) { const validateErrors = await validate(event.entity, { // 更新操作不会验证没有涉及的字段 skipMissingProperties: true, }); if (validateErrors.length > 0) { throw new HttpException(getErrorMessage(validateErrors), 404); } }} HttpException 会在校验失败后,终止执行,并立即返回错误给客户端,这一步体现了 Nestjs 与 Typeorm 完美结合。这带来的好处就是,我们放心执行任何 CRUD 语句,完全不需要做错误处理,当校验失败或者数据库操作失败时,会自动终止执行后续代码,并返回给客户端友好的提示: @Post()async add( @Res() res: Response, @Body('name') name: string, @Body('description') description: string,) { const card = await this.cardService.add(name, description); // 如果传入参数实体校验失败,会立刻返回失败,并提示 `@Validator.IsString({ message: '必须为字符串' })` 注册时的提示信息 // 如果插入失败,也会立刻返回失败 // 所以只需要处理正确情况 res.status(HttpStatus.OK).json(card);} 3.1.3 外键外键也是 Typeorm 的特色之一,通过装饰器语义化解释实体之间的关系,常用的有 @OneToOne @OneToMany @ManyToOne @ManyToMany 四种,比如用户表到评论表,是一对多的关系,可以这样设置实体: @Entity()export class User { @PrimaryGeneratedColumn({ comment: '主键', }) id: number; @OneToMany(type => Comment, comment => comment.user) comments?: Comment[];} @Entity()export class Comment { @PrimaryGeneratedColumn({ comment: '主键', }) id: number; @ManyToOne(type => User, user => user.Comments) @JoinColumn() user: User;} 对 User 来说,一个 User 对应多个 Comment,就使用 OneToMany 装饰器装饰 Comments 字段;对 Comment 来说,多个 Comment 对应一个 User,所以使用 ManyToOne 装饰 User 字段。 在使用 Typeorm 查询 User 时,会自动外键查询到其关联的评论,保存在 user.comments 中。查询 Comment 时,会自动查询到其关联的 User,保存在 comment.user 中。 3.2 部署可以使用 Docker 部署 Mysql + Nodejs,通过 docker-compose 将数据库与服务都跑在 docker 中,内部通信。 有一个问题,就是 nodejs 服务运行时,要等待数据库服务启动完毕,也就是有一个启动等待的需求。可以通过 environment 来拓展等待功能,以下是 docker-compose.yml: version: "2"services: app: build: ./ restart: always ports: - "5000:8000" links: - db - redis depends_on: - db - redis environment: WAIT_HOSTS: db:3306 redis:6379 通过 WAIT_HOSTS 指定要等待哪些服务的端口服务 ready。在 nodejs Dockerfile 启动的 CMD 加上一个 wait-for.sh 脚本,它会读取 WAIT_HOSTS 环境变量,等待端口 ready 后,再执行后面的启动脚本。 CMD ./scripts/docker/wait-for.sh && npm run deploy 以下是 wait.sh 脚本内容: ##!/bin/bashset -etimeout=${WAIT_HOSTS_TIMEOUT:-30}waitAfterHosts=${WAIT_AFTER_HOSTS:-0}waitBeforeHosts=${WAIT_BEFORE_HOSTS:-0}echo "Waiting for ${waitBeforeHosts} seconds."sleep $waitBeforeHosts## our target format is a comma separated list where each item is "host:ip"if [ -n "$WAIT_HOSTS" ]; then uris=$(echo $WAIT_HOSTS | sed -e 's/,/ /g' -e 's/\\s+/ /g' | uniq)fi## wait for each targetif [ -z "$uris" ]; then echo "No wait targets found." >&2; else for uri in $uris do host=$(echo $uri | cut -d: -f1) port=$(echo $uri | cut -d: -f2) [ -n "${host}" ] [ -n "${port}" ] echo "Waiting for ${uri}." seconds=0 while [ "$seconds" -lt "$timeout" ] && ! nc -z -w1 $host $port do echo -n . seconds=$((seconds+1)) sleep 1 done if [ "$seconds" -lt "$timeout" ]; then echo "${uri} is up!" else echo " ERROR: unable to connect to ${uri}" >&2 exit 1 fi doneecho "All hosts are up"fiecho "Waiting for ${waitAfterHosts} seconds."sleep $waitAfterHostsexit 0 4 总结Nestjs 中间件实现也很精妙,与 Modules 完美结合起来,由于篇幅限制就不展开了。 后端框架已经很成熟了,相反前端发展的就眼花缭乱了,如果前端可以舍弃 ie11 浏览器,我推荐纯 proxy 实现的 dob,配合 react 效率非常高。 讨论地址是:精读 《Nestjs 文档》 · Issue ##30 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Monorepo 的优势》","path":"/wiki/WebWeekly/前沿技术/《Monorepo 的优势》.html","content":"当前期刊数: 102 1. 引言本周精读的文章是 The many Benefits of Using a Monorepo。 现在介绍 Monorepo 的文章很多,可以分为如下几类:直接介绍 Lerna API 的;介绍如何从独立仓库迁移到 Lerna 的;通过举例子说明 Monorepo 重要性的。 本文属于第三种,从 Android 与 IOS 的开发故事说明了 Monorepo 的重要性。 笔者之所以选择这篇文章,不是因为其故事写的好,而是认可这种具有普适性的解决思路。毕竟 Lerna 作为 Monorepo 的实现之一也并不尽善尽美,而不同场景对 Monorepo 依赖的原因、功能也有所不同,所以希望借这篇文章,从理论上解释清楚为什么会产生 Monorepo,以及 Monorepo 可以解决哪些问题,这样在工作遇到问题时,才能想清楚自己要的是什么。 2. 概述作者的一个项目是 PDF 服务,简称 PSPDFKit,需要同时兼顾 Android 与 IOS 平台,项目的发展经历了如下几个阶段。 初始阶段在 2011 到 2013 年间,PSPDFKit 仅支持 IOS 平台,但最终项目需要支持 Android,因此开了一个新仓库放置 Android 代码。Android 仓库的代码不仅在 UI 上不同,同时解析 PDF 文档的核心代码也不同,这是因为 IOS 平台上使用内置 PDF 渲染引擎同时做了一些业务拓展,但使用的 OC 代码无法在 Android 使用。 最终新建了两个仓库 PSPDFKit-Android 与 Core 。 仓库 Core 中代码依赖 Android 平台 JNI 的支持,所以并不能实现 Core 一处修改,两处都生效的愿望,而我们又希望两边功能始终兼容,且减少分支过多带来的潜在的冲突,因此花了很久才意识到应该将这两个仓库合并起来。 考虑使用 Monorepo由于 Android 的整套流程自己控制的,因此总是可以快速修复用户提出的 BUG,然而 IOS 提供的 CGPDF 总会遇上各种问题。所以在 2014 年,我们开启了一个庞大的项目,重写 IOS 的 Core 库。有三中方式可供选择: 在 IOS 代码中引用 PSPDFKit-Android。 将 PSPDFKit-Android 提取到 Core 仓库中并分别维护。 将 IOS 与 Android 代码合并到一个仓库中。 经过讨论,最终作者的团队选择了第三种方案,因此目录结构类似如下: - ios-platform- android-platform- core 特例Web 与后台服务代码一直是一个特例,我们认为这些内容相对独立,所以没有将其代码放置到 Monorepo 中。 直到一年后,开始探索 WebAssembly 时,PSPDFKit-web 模块就出现了,因为可以利用 WebAssembly 将 Core 的代码编译并在 Web 平台使用,因此 Core 仓库与 Web 仓库的关系变得非常紧密,最终,我们将 Web、Server 也都迁移到 Monorepo 中了。 问题Monorepo 瑕不掩瑜,但作者还是列举了一些缺陷。 由于源码在一起,仓库变更非常常见,存储空间也变得很大,甚至几 GB,CI 测试运行时间也会变长。即便如此,团队中任何人都不想回到 git submodules 多仓库的方式。 3. 精读总的来说,虽然拆分子仓库、拆分子 NPM 包(For web)是进行项目隔离的天然方案,但当仓库内容出现关联时,没有任何一种调试方式比源码放在一起更高效。 工程化的最终目的是让业务开发可以 100% 聚焦在业务逻辑上,那么这不仅仅是脚手架、框架需要从自动化、设计上解决的问题,这涉及到仓库管理的设计。 一个理想的开发环境可以抽象成这样: “只关心业务代码,可以直接跨业务复用而不关心复用方式,调试时所有代码都在源码中。” 在前端开发环境中,多 Git Repo,多 Npm 则是这个理想的阻力,它们导致复用要关心版本号,调试需要 Npm Link。 另外对于多仓库的缺点,文中还有一些没有提到的因素,这里一并列举出来: 管理、调试困难 多个 git 仓库管理起来天然是麻烦的。对于功能类似的模块,如果拆成了多个仓库,无论对于多人协作还是独立开发,都需要打开多个仓库页面。 虽然 vscode 通过 Workspaces 解决多仓库管理的问题,但在多人协作的场景下,无法保证每个人的环境配置一致。 对于共用的包通过 Npm 安装,如果不能接受调试编译后的代码,或每次 npm link 一下,就没有办法调试依赖的子包。 分支管理混乱 假如一个仓库提供给 A、B 两个项目用,而 B 项目优先开发了功能 b,无法与 A 项目兼容,此时就要在这个仓库开一个 feature/b 的分支支持这个功能,并且在未来合并到主干同步到项目 A。 一旦需要开分支的组件变多了,且之间出来依赖关联,分支管理复杂度就会呈指数上升。 依赖关系复杂 独立仓库间组件版本号的维护需要手动操作,因为源代码不在一起,所以没有办法整体分析依赖,自动化管理版本号的依赖。 三方依赖版本可能不一致 一个独立的包拥有一套独立的开发环境,难以保证子模块的版本和主项目完全一直,就存在运行结果不一致的风险。 占用总空间大 正常情况下,一个公司的业务项目只有一个主干,多 git repo 的方式浪费了大量存储空间重复安装比如 React 等大型模块,时间久了可能会占用几十 GB 的额外空间,对于没有外接硬盘的同学来说,定期清理不用的项目下 node_modules 也是一件麻烦事。 不利于团队协作 一个大项目可能会用到数百个二方包,不同二方包的维护频率不同,权限不同,仓库位置也不同,主仓库对它们的依赖方式也不同。 一旦其中一个包进行了非正常改动,就会影响到整个项目,而我们精力有限,只盯着主仓库,往往会栽在不起眼的二方包发布上。 所以对于一个非常复杂,又具有技术挑战的大型系统在协作人员多的情况下出现问题的概率非常大,需要通过 Review 制度避免错误的发生,那么将所有相关的源码聚合在一个仓库下,是更好管理的。 理想 monorepo 的设计参考 Lerna 的规范,以 packages 作为子模块根文件夹,笔者设计一个理想的 monorepo 结构: .├── packages│ ├─ module-a│ │ ├─ src ## 模块 a 的源码│ │ └─ package.json ## 自动生成的,仅模块 a 的依赖│ └─ module-b│ ├─ src ## 模块 b 的源码│ └─ package.json ## 自动生成的,仅模块 b 的依赖├── tsconfig.json ## 配置文件,对整个项目生效├── .eslintrc ## 配置文件,对整个项目生效├── node_modules ## 整个项目只有一个外层 node_modules└── package.json ## 包含整个项目所有依赖 所有全局配置文件只有一个,这样不会导致 IDE 遇到子文件夹中的配置文件,导致全局配置失效或异常。node_modules 也只有一个,既保证了项目依赖的一致性,又避免了依赖被重复安装,节省空间的同时还提高了安装速度。 兄弟模块之间通过模块 package.json 定义的 name 相互引用,保证模块之间的独立性,但又不需要真正发布或安装这个模块,通过 tsconfig.json 的 paths 与 webpack 的 alias 共同实现虚拟模块路径的效果。 再结合 Lerna 根据联动发布功能,使每个子模块都可以独立发布。 4. 总结Lerna 是业界知名度最高的 Monorepo 管理工具,功能完整。但由于通用性要求非常高,需要支持任意项目间 Monorepo 的组合,因此在 packages 文件夹下的配置文件还是与独立仓库保持一致,这样在 TS 环境下会造成配置截断的问题。同时包之间的引用也通过更通用的 symlink 完成,这导致了还是要在子模块目录存在 node_modules 文件夹,而且效果依赖项目初始化命令。 如果加一些限定条件,比如基于 Webpack + Typescript 环境的 Monorepo,可以换一套思路,利用这些工具自身运行时功能,减少更多模版代码或配置文件,进一步提升 Monorepo 的效果。 对于别名映射,对 symlink 与 alias 进行对比: symlink: 更通用,适合任何构建器。但需要初始化,且在每个关联模块下新增 node_modules 文件夹。 alias: 限定构建器。但不需要初始化,不新增文件夹,甚至可以运行时动态修改别名配置。 可见如果限定了构建器,别名映射可以做得更轻量,且无需初始化。 今天的问题是,你的项目需要使用 Monorepo 吗?你对 Monorepo 有其他要求吗? 讨论地址是:精读《Monorepo 的优势》 · Issue ##151 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《JavaScript 错误堆栈处理》","path":"/wiki/WebWeekly/前沿技术/《JavaScript 错误堆栈处理》.html","content":"当前期刊数: 6 本期精读文章:JavaScript-Errors-and-Stack-Traces 中文版译文 1. 引言 错误处理无论对那种语言来说,都至关重要。在 JavaScript 中主要是通过 Error 对象和 Stack Traces 提供有价值的错误堆栈,帮助开发者调试。在服务端开发中,开发者可以将有价值错误信息打印到服务器日志中,而对于客户端而言就很难重现用户环境下的报错,我们团队一直在做一个错误监控的应用,在这里也和大家一起讨论下 js 异常监控的常规方式。 2. 内容概要了解 StackStack 部分主要在阐明 js 中函数调用栈的概念,它符合栈的基本特性『当调用时,压入栈顶。当它执行完毕时,被弹出栈』,简单看下面的代码: function c() {\ttry { var bar = baz; throw new Error()\t} catch (e) { console.log(e.stack);\t}}function b() {\tc();}function a() {\tb();}a(); 上述代码中会在执行到 c 函数的时候跑错,调用栈为 a -> b -> c,如下图所示: 很明显,错误堆栈可以帮助我们定位到报错的位置,在大型项目或者类库开发时,这很有意义。 认知 Error 对象紧接着,原作者讲到了 Error 对象,主要有两个重要属性 message 和 name 分别表示错误信息和错误名称。实际上,除了这两个属性还有一个未被标准化的 stack 属性,我们上面的代码也用到了 e.stack,这个属性包含了错误信息、错误名称以及错误栈信息。在 chrome 中测试打印出 e.stack 于 e 类似。感兴趣的可以了解下 Sentry 的 stack traces,它集成了 TraceKit,会对 Error 对象进行规范化处理。 如何使用堆栈追踪该部分以 NodeJS 环境为例,讲解了 Error.captureStackTrace,将 stack 信息作为属性存储在一个对象当中,同时可以过滤掉一些无用的堆栈信息。这样可以隐藏掉用户不需要了解的内部细节。作者也以 Chai 为例,内部使用该方法对代码的调用者屏蔽了不相关的实现细节。通过以 Assertion 对象为例,讲述了具体的内部实现,简单来说通过一个 addChainableMethod 链式调用工具方法,在运行一个 Assertion 时,将它设为标记,其后面的堆栈会被移除;如果 assertion 失败移除起后面所有内部堆栈;如果有内嵌 assertion,将当前 assertion 的方法放到 ssfi 中作为标记,移除后面堆栈帧; 3. 精读参与本次精读的同学有:范洪春、黄子毅、杨森、camsong,该部分由他们的观点总结而出。 captureStackTrace 方法优劣captureStackTrace 方法通过截取有意义报错堆栈,并统计上报,有助于排查问题。常用的断言库 chai 就是通过此方式屏蔽了库自身的调用栈,仅保留了用户代码的调用栈,这样用户会清晰的看到自己代码的调用栈。不过 Chai 的断言方式过分语义化,代码不易读。而实际上,现在有另外一款更黑科技的断言库正在崛起,那就是 power-assert。 直观的看一下 Chai.js 和 power-assert 的用法及反馈效果(以下代码及截图来自[小菜荔枝](http://www.jianshu.com/p/41ced3207a0c): const assert = require('power-assert');const should = require('should'); // 别忘记 npm install shouldconst obj = { arr: [1,2,3], number: 10};describe('should.js和power-assert的区别', () => { it('使用should.js的情况', () => { should(obj.arr[0]).be.equal(obj.number); // should api }); it('使用power-assert的情况', () => { assert(obj.arr[0] === obj.number); // 用assert就可以 });}); 抛 Error 对象的正确姿势在我们日常开发中一定要抛出标准的 Error 对象。否则,无法知道抛出的类型,很难对错误进行统一处理。正确的做法应该是使用 throw new Error(“error message here”),这里还引用了 Node.js 中推荐的异常处理方式: 区分操作异常和程序员的失误。操作异常指可预测的不可避免的异常,如无法连接服务器 操作异常应该被处理。程序员的失误不需要处理,如果处理了反而会影响错误排查 操作异常有两种处理方式:同步 (try……catch) 和异步(callback, event - emitter)两种处理方式,但只能选择其中一种。 函数定义时应该用文档写清楚参数类型,及可能会发生的合理的失败。以及错误是同步还是异步传给调用者的 缺少参数或参数无效是程序员的错误,一旦发生就应该 throw。传递错误时,使用标准的 Error 对象,并附件尽可能多的错误信息,可以使用标准的属性名 异步(Promise)环境下错误处理方式在 Promise 内部使用 reject 方法来处理错误,而不要直接调用 throw Error,这样你不会捕捉到任何的报错信息。 reject 如果使用 Error 对象,会导致捕获不到错误的情况,在我的博客中有讨论过这种情况:Callback Promise Generator Async-Await 和异常处理的演进,我们看以下代码: function thirdFunction() { return new Promise((resolve, reject) => { setTimeout(() => { reject('我可以被捕获') // throw Error('永远无法被捕获') }) })}Promise.resolve(true).then((resolve, reject) => { return thirdFunction()}).catch(error => { console.log('捕获异常', error) // 捕获异常 我可以被捕获}); 我们发现,在 macrotask 队列中,reject 行为是可以被 catch 到的,而此时 throw Error 就无法捕获异常,大家可以贴到浏览器运行试一试,第二次把 reject('我可以被捕获') 注释起来,取消 throw Error('永远无法被捕获') 的注释,会发现异常无法 catch 住。 这是因为 setTimeout 中 throw Error 无论如何都无法捕获到,而 reject 是 Promise 提供的关键字,自己当然可以 catch 住。 监控客户端 Error 报错文中提到的 try...catch 可以拿到出错的信息,堆栈,出错的文件、行号、列号等,但无法捕捉到语法错误,也没法去捕捉全局的异常事件。此外,在一些古老的浏览器下 try...catch 对 js 的性能也有一定的影响。 这里,想提一下另一个捕捉异常的方法,即 window.onerror,这也是我们在做错误监控中用到比较多的方案。它可以捕捉语法错误和运行时错误,并且拿到出错的信息,堆栈,出错的文件、行号、列号等。不过,由于是全局监测,就会统计到浏览器插件中的 js 异常。当然,还有一个问题就是浏览器跨域,页面和 js 代码在不同域上时,浏览器出于安全性的考虑,将异常内容隐藏,我们只能获取到一个简单的 Script Error 信息。不过这个解决方案也很成熟: 给应用内所需的 标签添加 crossorigin 属性; 在 js 所在的 cdn 服务器上添加 Access-Control-Allow-Origin: * HTTP 头; 4. 总结Error 和 Stack 信息对于日常开发来说,尤为重要。如果可以将 Error 统计并上报,更有助于我们排查信息,发现在用户环境下到底触发了什么错误,帮助我们提升产品的稳定性。 讨论地址是:JavaScript 中错误堆栈处理 · Issue ##9 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Nodejs V12》","path":"/wiki/WebWeekly/前沿技术/《Nodejs V12》.html","content":"当前期刊数: 113 1. 引言Node12 发布有几个月了,让我们跟随 Nodejs 12 一起看看 Node12 带来了哪些改变。 2. 概述Node12 与以往的版本不同,带来了许多重大升级,包括更多 V8 特性,Http 解析速度的提升,启动速度的提升,更好的诊断报告、内置堆分析工具,ESM 模块的更新等。 V8 引擎升级V8 升级带来了如下几个特性: zero-cost async 堆栈信息 原生支持了 async 堆栈信息,不会添加额外运行时内容。 参数数量不匹配时性能优化 即便参数传递多了或少了,现在都几乎不会影响 Node 的执行速度。 更快的 async async /await 已经比 promises 快了两个 microticks。 更快的 Js 解析速度 网页中的 V8 引擎一般花费 9.5% 时间在 JS 解析上,经过解析加速后,现在花费在 JS 解析上的时间降低到平均 7.5%。 可见 V8 引擎的升级不仅给 Node12 带来了福音,也会一定程度上提升网页的运行效率。 TLS 1.3 更好的安全性随着 Node12 的发布,TLS 从 1.2 升级到了 1.3,更安全且更易配置。通过使用 TLS 1.3,Node 程序可以减少 Https 握手所需时间来提升请求性能。 默认堆被正确配置了以前默认堆大小需要通过 -max-old-space-size 设置,而且默认值是一个固定值,现在这个默认值可以根据可用内存动态分配,这样当内存较小时,Node 不会让内存移除而报错,而是主动终止自己的进程。 默认的 http 解析器变为 llhttpnodejs 的 http-parser 已经非常难以维护和优化了,因此 llhttp 这个库,比 http-parser 快 156%,更重要的是,在 Node12 中,将默认解析器切换到了 llhttp。 提供诊断报告Node12 有一项实验功能,根据用户需求提供诊断报告,包括崩溃、性能下降、内存泄露、CPU 使用高等等。 堆内存 dump在以前,如果要将堆内存生成 dump 文件,需要在生产环境安装额外的模块,而 Node12 集成了这个功能。 更好的原生模块支持C++ 拓展 N-API 升级到版本 4,同时一个原生模块可以被 C++ 编写并发布到 npm,就像一个普通 JS 模块一样被引用。不过要注意一些区别: JS 模块 原生拓展 1. … 需要编译 否 如果预编译了则不用 2. … 是否可以运行在所有平台 是 如果预编译了则可以 3. … 是否兼容所有 Node 版本 是 否 4. … 会被加载多次 是 否 5. … 如果没有明确使用多线程,则线程安全 是 否 6. … 可以被销毁 是 否 Worker 被正式启用了--experimental-worker 实验开关已取消,默认支持 worker_threads。 要注意的是,执行 CPU 密集型任务时适合用 worker(大量计算),而执行 I/O 密集型任务时,Worker 反而没有 Node 内置的 I/O 操作性能好(读写文件)。 启动速度优化通过在构建时提前为内置库生成代码缓存,最终使启动时间加快 30%。 支持 ES6 moduleNode12 对 ES6 module 的支持依然处于实验阶段,需要通过 --experimental-modules 开启。 简单来说,就是支持了 Import Export 语法,不需要再转成 require 了!如果在 package.json 增加 "type": "module" 的配置,Node 将按照 ES6 module 方式处理。 新的编译器和平台要求由于升级到新的 V8 引擎以及内部改造,因此 Node12 在 Mac 与 Windows 之外的平台上,需要至少 GCC6 和 glibc 2.17。 3. 精读对于 V8 引擎升级、TLS 升级、堆配置自动化、http-parser 升级到 llhttp、启动速度优化都属于被动优化,代码无需改动,只要升级 Node 版本就可以享受。 支持 ES6 module 这个特性其实比较鸡肋,毕竟源码用 Ts 写的话,这些升级并不会对源码产生影响。 worker_threads 可以被默认启用,就像以前支持 async/await 一样,会带来 Nodejs 多线程更广泛的使用。 Node12 更新了 V8 引擎,随着 V8 的更新,很多 ES 新规范也落地了,比如 Class 成员函数、私有成员变量等等。 4. 总结Nodejs 仅有 10 年历史,但现在越来越被开发者欢迎,因为它可以让 JS 运行在服务端,是扩大 JS 生态的重要一环。从 Node 更新历史中可以看到,性能和语法能力稳步提升,一些服务端环境需要的诊断报告、堆栈分析能力都在逐渐完善,社区上也有 Alinode 与 egg、express、koa 等好用的服务框架,相对于前端翻天覆地的变化,对 Node 的评价只有一个字:稳。 讨论地址是:精读《Nodejs V12》 · Issue ##184 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Nuxtjs》","path":"/wiki/WebWeekly/前沿技术/《Nuxtjs》.html","content":"当前期刊数: 126 1 引言Nuxt 是基于 Vue 的前端开发框架,这次我们通过 Introduction toNuxtJS 视频了解框架特色以及前端开发框架的基本要素。 nuxt 与 next 结构很像,可以结合在一起看 视频介绍了 NuxtJs 的安装、目录结构、页面路由、导航模版、asyncData、meta、vueX。 这是一个入门级视频,所以上面所列举的特征都是一个前端开发框架的最核心的基本要素。一个前端开发框架,安装、目录结构、页面路由、导航模版一定是最要下功夫认真设计的。 asyncData 和 Vuex 都在解决数据问题,meta 则是通过约定语法控制网页 meta 属性,这部分值得与 React 体系做对比,在精读部分再展开。 Nuxtjs 前端开发框架不仅提供了脚手架的基本功能,还对项目结构、代码做了约定,以减少代码量。从这点可以看出,脚手架永远围绕两个核心目标:让每一行源码都在描述业务逻辑;让每个项目结构都相同且易读。 20 年前,几百行 HTML、Css、Js 代码就能完成一个完整的项目,只需要遵守 W3C 的基本规范就足够了,每一个项目代码都简单清晰,而且由于没有复杂的业务逻辑,导致代码结构也非常简单。但现在前端项目复杂度逐渐升高,一个大型项目源码数量可能达到几十万行、几百万行,这是 W3C 规范没有设想到的,因此出现了各种工程化与模块化方案解决这个复杂度问题,也引发了各个框架间约定的割裂,且设计合理程度各不相同。 Nuxtjs 等框架要做的就是定义支持现代大型项目的前端研发标准,这个规范具有网络效应,即用的人越多,价值越大。 接下来我们进入正题,看看 Nuxt 脚手架定义了怎样的开发规范。 2 概述安装使用 npx create-nuxt-app app-name 创建新项目。这个命令与 create-react-app 一样,区别主要是模版以及配置不同。 这个命令本质上是拉取一个模版到本地,并安装 nuxt 系列脚本作为项目依赖,并自动生成一系列 npmScripts: { "scripts": { "dev": "nuxt", "build": "nuxt build", "start": "nuxt start", "generate": "nuxt generate", "lint": "eslint --ext .js,.vue --ignore-path .gitignore .", "test": "jest" }, "dependencies": { "nuxt": "^2.0.0" }} 之后即可通过 npm start 等命令开发项目,对大部分项目来说,npmScripts 启动是最能达成共识的。 这种安装方式另一个好处是,依赖都被安装在了本地,即开发环境 100% 内置在项目中。Nuxt 没有采用全局 cli 命令方式执行,第一是 npmScripts 更符合大家通用习惯,不需要记住不同脚手架繁琐的名称与不同约定的启动命令,第二是全局脚手架一旦进行不兼容升级,老项目就面临维护难题。 目录结构├── .nuxt├── layouts├── pages├── store├── assets├── static├── middleware├── plugins├── nuxt.config.js pages 页面文件存放的目录,路径 + 文件名即路由名,关于更多约定路由的信息,在下一节页面路由详细说明。 layouts 模版文件存放的目录,文件名即模版名,页面可以通过定义模版在选择使用的模版。 store 全局数据流目录,在 vueX 章节介绍。 assets、static 分别存放不需被编译的资源文件与非 .vue 的静态文件,比如 scss 文件。 由于 .vue 文件集成了 html、js、css,因此一般不会再额外定义样式文件在 static 文件夹中。 当然,这是 Vue 生态的特别之处,在 React 生态中会存在大量 .scss 文件混杂在各个目录中,比较影响阅读。 middleware、plugins 中间件与插件,这两个目录是可选的,作为一种定制化拓展能力。 .nuxt 为实现约定路由等便捷功能,启动项目时需要自动生成一些文件作为真正项目入口,这些文件就存储在 .nuxt 目录下,gitingore 且无需手动修改。 nuxt.config.js nuxt 使用 js 文件作为配置文件,比 json 配置文件拓展性更好一些,这个文件也是整个项目唯一的配置文件。 基本上 pages、layouts、store、assets、以及唯一的配置文件基本成为现代前端开发框架的标配。 页面路由nuxt 支持约定路由: ├── pages│ ├── home.vue│ └── index.vue 上述目录结构描述了两个路由:/ 与 /home。 也支持参数路由,只要以下划线作为前缀命名文件,就定义了一个动态参数路由: ├── pages│ ├── videos│ │ └── _id.vue /videos/* 都会指向这个文件,且可以通过 $route.params.id 拿到这个 url 参数。 另一个特性是嵌套路由: ├── pages│ ├── videos│ │ └── index.vue│ └── videos.vue videos.vue 与 videos/index.vue 都指向 /videos 这个路由,如果这两个文件同时存在,那么外层的 videos 就会作为外层拦截所有 /videos 文件夹下的路由,可以通过 nuxt-child 透出子元素: ## pages/videos.vue<template> <div> videos <nuxt-child /> </div></template> 导航模版页面公共逻辑,比如导航条可以放在模版里,模版的目录在 layouts 文件夹下。 默认 layouts/default.vue 对所有页面生效,但也可以创建例如 layouts/videos.vue 特殊导航文件,在 pages/ 页面文件通过如下申明指定使用这个模版: <script> export default { layout: "videos" };</script> asyncDataasyncData 是 nuxt 支持的异步取数函数,可以替代 data。 data 函数: <script> export default { data() { return {}; } };</script> 对于异步场景,可以用 asyncData 替代: <script> export default { async asyncData() { return await fetch("/"); } };</script> metanuxt 允许在 .vue 页面文件自定义 head 标签信息: <script> export default { headr() { return { title: "", meta: { charset: "utf-8" } }; } };</script> 这是开发框架提供的特性,不过在 React 体系下可以通过 useTitle 等自定义 Hooks 解决此问题,将框架功能降维到代码功能,会更容易理解些。 vueXnuxt 集成了 vuex,在 store/ 文件夹下创建数据模型: export const state = () => ({ videos: [], currentVideo: {}})export const mutations = { SET_VIDEOS (state, videos) { state.videos = videos } SET_CURRENT_VIDEO (state, video) { state.currentVideo = video }} 接下来就能在 pages 文件夹下的页面组件使用了: <script> import { mapState } from "vuex"; export default { async fetch({ $axios, params, store }) { const reponse = await $axios.get(`/videos/${params.id}`); const video = response.data.data.arrtibutes; store.commit("SET_CURRENT_VIDEO", video); } };</script> 将 return 替换为 store.commit 即可,更多语法可以参考 vuex 文档。 3 精读Nuxtjs 框架做了几件事情: 统一执行命令。 统一开发框架。 统一目录与代码规范。 内置公共 utils 函数。 统一执行命令命令行是所有开发者每天都要用上十几次甚至几十次的场景,试想一下团队中项目分别有如下这么多不同的启动命令会怎么样? npm start. monkey dev. npm run ng. npm run bootstrap & banana start. … 我永远不知道下一个项目该如何启动,这大大降低了开发效率。更严重的是,有的项目可以通过 npm run docs 查看文档,有的项目不能;有的项目 npm run build 可以触发编译,有的项目却无需编译,等等,所谓的环境不一致或者说迁移成本,学习成本,都是由最开始负责搭建项目脚手架的同学对架构设计不一致导致的,然而没有必须用 monkey dev 才能运行起来的项目,但项目却可能因为被设计为 monkey dev 启动而显得与其他项目格格不入,甚至难以统一维护。 Nuxtjs 等前端开发框架统一执行命令就是为了解决这个问题,统一开发者习惯需要很长的时间周期,但这个趋势不可挡。 统一开发框架虽然现在 React、Vue、Angular 框架各有利弊,但如果一个团队的项目同时使用了两个以上的框架,没有人会觉得这是一件好事。 诚然每个框架都有自己的特点,在不同维度都一些优势,但三大框架能并存,说明各自都没有绝对的杀手锏来消灭对方。 对开源来说,多元化是活力的源动力,但对一家公司来说,多元化就是一场灾难,至今没有一个框架敢说自己的优势是 “与其他框架混合使用可以提升整体开发效率”。 前端开发框架要解决的最重要问题也是这一点,无论如何只能选择一种开发框架,Nuxtjs 选择了 Vue,Nextjs 选择了 React。 统一目录与代码规范目录和代码规范不会从根本上影响项目的通用性,因为不同的目录结构可以通过映射来兼容,不同的代码规范不会影响代码执行。所以目录与代码规范真正影响的是一个程序员对项目的 “解码成本”。 所谓解码成本,就是程序员理解项目逻辑所需要的成本。如果你是一个销售主管,让团队周报统一用一种格式汇总绝对比 “用自己喜欢的方式汇总” 效率高,而对编程也一样,一个完全不同的目录结构和代码规范对程序员来说是巨大的阅读阻碍,甚至可能引发恶心反应。 所以不同的目录结构和代码规范是没有必要的壁垒,除非你的团队已经对某种规范产生达成了牢固的共识,否则最好和其他团队共享相同的目录结构与代码规范。改变代码规范是一件很难得事情,但只要不同规范的团队间产生了长期合作关系,规范统一就势必会被提上议程,那么为何不能在公司层面早一点达成共识,提前消除这种痛苦呢? 所以统一目录与代码规范是前端开发框架需要优先确定的,很多时候不要去质疑为什么目录叫 layouts 而不叫 layout,因为这个规范背后形成的协同网络规模越大,叫什么名字就越不重要。 内置公共 utils 函数让业务开发更聚焦,还可以通过抽取通用的逻辑的方式解决,但需要解决两个问题: 虽然将公共函数抽成 npm 包可以解决代码复用问题,但关键是怎么保证你的代码能被别人复用? 如何让业务通用的 utils 代码有效沉淀并从项目中移除? 脚手架内置公共 utils 函数就为了解决这个问题。上面几个小节解决了通用命令、框架、规范,但实际代码中,router history fetch store 等等概念也都是可以统一的,没有一个项目必须用定制的 fetch 函数才能取数,但一开始就定制了 fetch 会导致耦合了不可预期的、没有必要的业务逻辑,成为理解与提效的阻碍。 所以统一这些能统一的包,是进一步提效的关键。也许有人会觉得断了自己造轮子的路,但就像我们如今都不会重写浏览器内核逻辑一样,稳定的逻辑不仅带来了全行业的提效,还催生了前端岗位带来大量的就业,同样的,统一底层通用函数,其实是断了无意义产出这条路,每个人都有追求更高价值事情的权利,不要把自己困在反复造 fetch 函数这个低水平的活里。 4 总结如果一个项目没有使用类似 Nuxtjs 开发框架,它面临的不仅仅是技术选型不统一的问题,久而久之这种项目势必成为 代码孤岛,当尘封在代码仓库几年后,一系列文档工具链接都失效后,就成为谁也不想碰,不敢碰的高危代码。 所以我们今天不仅要看到 Nuxtjs 提供的能力对项目开发有多么便捷,更要看到这类框架带来的协同效应有多么巨大,如果它不能成为整个前端的标准,至少要成为你们公司,或者你们团队的标准。 讨论地址是:精读《Nuxtjs》 · Issue ##213 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Prisma 的使用》","path":"/wiki/WebWeekly/前沿技术/《Prisma 的使用》.html","content":"当前期刊数: 213 ORM(Object relational mappers) 的含义是,将数据模型与 Object 建立强力的映射关系,这样我们对数据的增删改查可以转换为操作 Object(对象)。 Prisma 是一个现代 Nodejs ORM 库,根据 Prisma 官方文档 可以了解这个库是如何设计与使用的。 概述Prisma 提供了大量工具,包括 Prisma Schema、Prisma Client、Prisma Migrate、Prisma CLI、Prisma Studio 等,其中最核心的两个是 Prisma Schema 与 Prisma Client,分别是描述应用数据模型与 Node 操作 API。 与一般 ORM 完全由 Class 描述数据模型不同,Primsa 采用了一个全新语法 Primsa Schema 描述数据模型,再执行 prisma generate 产生一个配置文件存储在 node_modules/.prisma/client 中,Node 代码里就可以使用 Prisma Client 对数据增删改查了。 Prisma SchemaPrimsa Schema 是在最大程度贴近数据库结构描述的基础上,对关联关系进行了进一步抽象,并且背后维护了与数据模型的对应关系,下图很好的说明了这一点: 可以看到,几乎与数据库的定义一模一样,唯一多出来的 posts 与 author 其实是弥补了数据库表关联外键中不直观的部分,将这些外键转化为实体对象,让操作时感受不到外键或者多表的存在,在具体操作时再转化为 join 操作。下面是对应的 Prisma Schema: datasource db { provider = "postgresql" url = env("DATABASE_URL")}generator client { provider = "prisma-client-js"}model Post { id Int @id @default(autoincrement()) title String content String? @map("post_content") published Boolean @default(false) author User? @relation(fields: [authorId], references: [id]) authorId Int?}model User { id Int @id @default(autoincrement()) email String @unique name String? posts Post[]} datasource db 申明了链接数据库信息;generator client 申明了使用 Prisma Client 进行客户端操作,也就是说 Prisma Client 其实是可以替换实现的;model 是最核心的模型定义。 在模型定义中,可以通过 @map 修改字段名映射、@@map 修改表名映射,默认情况下,字段名与 key 名相同: model Comment { title @map("comment_title") @@map("comments")} 字段由下面四种描述组成: 字段名。 字段类型。 可选的类型修饰。 可选的属性描述。 model Tag { name String? @id} 在这个描述里,包含字段名 name、字段类型 String、类型修饰 ?、属性描述 @id。 字段类型字段类型可以是 model,比如关联类型字段场景: model Post { id Int @id @default(autoincrement()) // Other fields comments Comment[] // A post can have many comments}model Comment { id Int // Other fields Post Post? @relation(fields: [postId], references: [id]) // A comment can have one post postId Int?} 关联场景有 1v1, nv1, 1vn, nvn 四种情况,字段类型可以为定义的 model 名称,并使用属性描述 @relation 定义关联关系,比如上面的例子,描述了 Commenct 与 Post 存在 nv1 关系,并且 Comment.postId 与 Post.id 关联。 字段类型还可以是底层数据类型,通过 @db. 描述,比如: model Post { id @db.TinyInt(1)} 对于 Prisma 不支持的类型,还可以使用 Unsupported 修饰: model Post { someField Unsupported("polygon")?} 这种类型的字段无法通过 ORM API 查询,但可以通过 queryRaw 方式查询。queryRaw 是一种 ORM 对原始 SQL 模式的支持,在 Prisma Client 会提到。 类型修饰类型修饰有 ? [] 两种语法,比如: model User { name String? posts Post[]} 分别表示可选与数组。 属性描述属性描述有如下几种语法: model User { id Int @id @default(autoincrement()) isAdmin Boolean @default(false) email String @unique @@unique([firstName, lastName])} @id 对应数据库的 PRIMARY KEY。 @default 设置字段默认值,可以联合函数使用,比如 @default(autoincrement()),可用函数包括 autoincrement()、dbgenerated()、cuid()、uuid()、now(),还可以通过 dbgenerated 直接调用数据库底层的函数,比如 dbgenerated("gen_random_uuid()")。 @unique 设置字段值唯一。 @relation 设置关联,上面已经提到过了。 @map 设置映射,上面也提到过了。 @updatedAt 修饰字段用来存储上次更新时间,一般是数据库自带的能力。 @ignore 对 Prisma 标记无效的字段。 所有属性描述都可以组合使用,并且还存在需对 model 级别的描述,一般用两个 @ 描述,包括 @@id、@@unique、@@index、@@map、@@ignore。 ManyToManyPrisma 在多对多关联关系的描述上也下了功夫,支持隐式关联描述: model Post { id Int @id @default(autoincrement()) categories Category[]}model Category { id Int @id @default(autoincrement()) posts Post[]} 看上去很自然,但其实背后隐藏了不少实现。数据库多对多关系一般通过第三张表实现,第三张表会存储两张表之间外键对应关系,所以如果要显式定义其实是这样的: model Post { id Int @id @default(autoincrement()) categories CategoriesOnPosts[]}model Category { id Int @id @default(autoincrement()) posts CategoriesOnPosts[]}model CategoriesOnPosts { post Post @relation(fields: [postId], references: [id]) postId Int // relation scalar field (used in the `@relation` attribute above) category Category @relation(fields: [categoryId], references: [id]) categoryId Int // relation scalar field (used in the `@relation` attribute above) assignedAt DateTime @default(now()) assignedBy String @@id([postId, categoryId])} 背后生成如下 SQL: CREATE TABLE "Category" ( id SERIAL PRIMARY KEY);CREATE TABLE "Post" ( id SERIAL PRIMARY KEY);-- Relation table + indexes -------------------------------------------------------CREATE TABLE "CategoryToPost" ( "categoryId" integer NOT NULL, "postId" integer NOT NULL, "assignedBy" text NOT NULL "assignedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("categoryId") REFERENCES "Category"(id), FOREIGN KEY ("postId") REFERENCES "Post"(id));CREATE UNIQUE INDEX "CategoryToPost_category_post_unique" ON "CategoryToPost"("categoryId" int4_ops,"postId" int4_ops); Prisma Client描述好 Prisma Model 后,执行 prisma generate,再利用 npm install @prisma/client 安装好 Node 包后,就可以在代码里操作 ORM 了: import { PrismaClient } from '@prisma/client'const prisma = new PrismaClient() CRUD使用 create 创建一条记录: const user = await prisma.user.create({ data: { email: 'elsa@prisma.io', name: 'Elsa Prisma', },}) 使用 createMany 创建多条记录: const createMany = await prisma.user.createMany({ data: [ { name: 'Bob', email: 'bob@prisma.io' }, { name: 'Bobo', email: 'bob@prisma.io' }, // Duplicate unique key! { name: 'Yewande', email: 'yewande@prisma.io' }, { name: 'Angelique', email: 'angelique@prisma.io' }, ], skipDuplicates: true, // Skip 'Bobo'}) 使用 findUnique 查找单条记录: const user = await prisma.user.findUnique({ where: { email: 'elsa@prisma.io', },}) 对于联合索引的情况: model TimePeriod { year Int quarter Int total Decimal @@id([year, quarter])} 需要再嵌套一层由 _ 拼接的 key: const timePeriod = await prisma.timePeriod.findUnique({ where: { year_quarter: { quarter: 4, year: 2020, }, },}) 使用 findMany 查询多条记录: const users = await prisma.user.findMany() 可以使用 SQL 中各种条件语句,语法如下: const users = await prisma.user.findMany({ where: { role: 'ADMIN', }, include: { posts: true, },}) 使用 update 更新记录: const updateUser = await prisma.user.update({ where: { email: 'viola@prisma.io', }, data: { name: 'Viola the Magnificent', },}) 使用 updateMany 更新多条记录: const updateUsers = await prisma.user.updateMany({ where: { email: { contains: 'prisma.io', }, }, data: { role: 'ADMIN', },}) 使用 delete 删除记录: const deleteUser = await prisma.user.delete({ where: { email: 'bert@prisma.io', },}) 使用 deleteMany 删除多条记录: const deleteUsers = await prisma.user.deleteMany({ where: { email: { contains: 'prisma.io', }, },}) 使用 include 表示关联查询是否生效,比如: const getUser = await prisma.user.findUnique({ where: { id: 19, }, include: { posts: true, },}) 这样就会在查询 user 表时,顺带查询所有关联的 post 表。关联查询也支持嵌套: const user = await prisma.user.findMany({ include: { posts: { include: { categories: true, }, }, },}) 筛选条件支持 equals、not、in、notIn、lt、lte、gt、gte、contains、search、mode、startsWith、endsWith、AND、OR、NOT,一般用法如下: const result = await prisma.user.findMany({ where: { name: { equals: 'Eleanor', }, },}) 这个语句代替 sql 的 where name="Eleanor",即通过对象嵌套的方式表达语义。 Prisma 也可以直接写原生 SQL: const email = 'emelie@prisma.io'const result = await prisma.$queryRaw( Prisma.sql`SELECT * FROM User WHERE email = ${email}`) 中间件Prisma 支持中间件的方式在执行过程中进行拓展,看下面的例子: const prisma = new PrismaClient()// Middleware 1prisma.$use(async (params, next) => { console.log(params.args.data.title) console.log('1') const result = await next(params) console.log('6') return result})// Middleware 2prisma.$use(async (params, next) => { console.log('2') const result = await next(params) console.log('5') return result})// Middleware 3prisma.$use(async (params, next) => { console.log('3') const result = await next(params) console.log('4') return result})const create = await prisma.post.create({ data: { title: 'Welcome to Prisma Day 2020', },})const create2 = await prisma.post.create({ data: { title: 'How to Prisma!', },}) 输出如下: Welcome to Prisma Day 2020 1 2 3 4 5 6 How to Prisma! 1 2 3 4 5 6 可以看到,中间件执行顺序是洋葱模型,并且每个操作都会触发。我们可以利用中间件拓展业务逻辑或者进行操作时间的打点记录。 精读ORM 的两种设计模式ORM 有 Active Record 与 Data Mapper 两种设计模式,其中 Active Record 使对象背后完全对应 sql 查询,现在已经不怎么流行了,而 Data Mapper 模式中的对象并不知道数据库的存在,即中间多了一层映射,甚至背后不需要对应数据库,所以可以做一些很轻量的调试功能。 Prisma 采用了 Data Mapper 模式。 ORM 容易引发性能问题当数据量大,或者性能、资源敏感的情况下,我们需要对 SQL 进行优化,甚至我们需要对特定的 Mysql 的特定版本的某些内核错误,对 SQL 进行某些看似无意义的申明调优(比如在 where 之前再进行相同条件的 IN 范围限定),有的时候能取得惊人的性能提升。 而 ORM 是建立在一个较为理想化理论基础上的,即数据模型可以很好的转化为对象操作,然而对象操作由于屏蔽了细节,我们无法对 SQL 进行针对性调优。 另外,得益于对象操作的便利性,我们很容易通过 obj.obj. 的方式访问某些属性,但这背后生成的却是一系列未经优化(或者部分自动优化)的复杂 join sql,我们在写这些 sql 时会提前考虑性能因素,但通过对象调用时却因为成本低,或觉得 ORM 有 magic 优化等想法,写出很多实际上不合理的 sql。 Prisma Schema 的好处其实从语法上,Prisma Schema 与 Typeorm 基于 Class + 装饰器的拓展几乎可以等价转换,但 Prisma Schema 在实际使用中有一个很不错的优势,即减少样板代码以及稳定数据库模型。 减少样板代码比较好理解,因为 Prisma Schema 并不会出现在代码中,而稳定模型是指,只要不执行 prisma generate,数据模型就不会变化,而且 Prisma Schema 也独立于 Node 存在,甚至可以不放在项目源码中,相比之下,修改起来会更加慎重,而完全用 Node 定义的模型因为本身是代码的一部分,可能会突然被修改,而且也没有执行数据库结构同步的操作。 如果项目采用 Prisma,则模型变更后,可以执行 prisma db pull 更新数据库结构,再执行 prisma generate 更新客户端 API,这个流程比较清晰。 总结Prisma Schema 是 Prisma 的一大特色,因为这部分描述独立于代码,带来了如下几个好处: 定义比 Node Class 更简洁。 不生成冗余的代码结构。 Prisma Client 更加轻量,且查询返回的都是 Pure Object。 至于 Prisma Client 的 API 设计其实并没有特别突出之处,无论与 sequelize 还是 typeorm 的 API 设计相比,都没有太大的优化,只是风格不同。 不过对于记录的创建,我更喜欢 Prisma 的 API: // typeorm - save APIconst userRepository = getManager().getRepository(User)const newUser = new User()newUser.name = 'Alice'userRepository.save(newUser)// typeorm - insert APIconst userRepository = getManager().getRepository(User)userRepository.insert({ name: 'Alice',})// sequelizeconst user = User.build({ name: 'Alice',})await user.save()// Mongooseconst user = await User.create({ name: 'Alice', email: 'alice@prisma.io',})// prismaconst newUser = await prisma.user.create({ data: { name: 'Alice', },}) 首先存在 prisma 这个顶层变量,使用起来会非常方便,另外从 API 拓展上来说,虽然 Mongoose 设计得更简洁,但添加一些条件时拓展性会不足,导致结构不太稳定,不利于统一记忆。 Prisma Client 的 API 统一采用下面这种结构: await prisma.modelName.operateName({ // 数据,比如 create、update 时会用到 data: /** ... */, // 条件,大部分情况都可以用到 where: /** ... */, // 其它特殊参数,或者 operater 特有的参数}) 所以总的来说,Prisma 虽然没有对 ORM 做出革命性改变,但在微创新与 API 优化上都做得足够棒,github 更新也比较活跃,如果你决定使用 ORM 开发项目,还是比较推荐 Prisma 的。 在实际使用中,为了规避 ORM 产生笨拙 sql 导致的性能问题,可以利用 Prisma Middleware 监控查询性能,并对性能较差的地方采用 prisma.$queryRaw 原生 sql 查询。 讨论地址是:精读《Prisma 的使用》· Issue ##362 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《REST, GraphQL, Webhooks, & gRPC 如何选型》","path":"/wiki/WebWeekly/前沿技术/《REST, GraphQL, Webhooks, & gRPC 如何选型》.html","content":"当前期刊数: 72 1 引言每当项目进入联调阶段,或者提前约定接口时,前后端就会聚在一起热火朝天的讨论起来。可能 99% 的场景都在约定 Http 接口,讨论 URL 是什么,入参是什么,出参是什么。 有的团队前后端接口约定更加高效,后端会拿出接口定义代码,前端会转换成(或自动转成)Typescript 定义文件。 但这些工作都针对于 Http 接口,今天通过 when-to-use-what-rest-graphql-webhooks-grpc 一文,抛开联调时千遍一律的 Http 接口,一起看看接口还可以怎么约定,分别适用于哪些场景,你现在处于哪个场景。 2 概述本文主要讲了四种接口设计方案,分别是:REST、gRPC、GraphQL、Webhooks,下面分别介绍一下。 RESTREST 也许是最通用,也是最常用的接口设计方案,它是 无状态的,以资源为核心,针对如何操作资源定义了一系列 URL 约定,而操作类型通过 GET POST PUT DELETE 等 HTTP Methods 表示。 REST 基于原生 HTTP 接口,因此改造成本很小,而且其无状态的特性,降低了前后端耦合程度,利于快速迭代。 随着未来发展,REST 可能更适合提供微服务 API。 使用举例: curl -v -X GET https://api.sandbox.paypal.com/v1/activities/activities?start_time=2012-01-01T00:00:01.000Z&amp;end_time=2014-10-01T23:59:59.999Z&amp;page_size=10 \\-H "Content-Type: application/json" \\-H "Authorization: Bearer Access-Token" gRPCgRPC 是对 RPC 的一个新尝试,最大特点是使用 protobufs 语言格式化数据。 RPC 主要用来做服务器之间的方法调用,影响其性能最重要因素就是 序列化/反序列化 效率。RPC 的目的是打造一个高效率、低消耗的服务调用方式,因此比较适合 IOT 等对资源、带宽、性能敏感的场景。而 gRPC 利用 protobufs 进一步提高了序列化速度,降低了数据包大小。 使用举例: gRPC 主要用于服务之间传输,这里拿 Nodejs 举例: 定义接口。由于 gRPC 使用 protobufs,所以接口定义文件就是 helloworld.proto: // The greeting service definition.service Greeter { // Sends a greeting rpc SayHello (HelloRequest) returns (HelloReply) {} // Sends another greeting rpc SayHelloAgain (HelloRequest) returns (HelloReply) {}}// The request message containing the user's name.message HelloRequest { string name = 1;}// The response message containing the greetingsmessage HelloReply { string message = 1;} 这里定义了服务 Greeter,拥有两个方法:SayHello 与 SayHelloAgain,通过 message 关键字定义了入参与出参的结构。 事实上利用 protobufs,传输数据时仅传送很少的内容,作为代价,双方都要知道接口定义规则才能序列化/反序列化。 定义服务器: function sayHello(call, callback) { callback(null, { message: "Hello " + call.request.name });}function sayHelloAgain(call, callback) { callback(null, { message: "Hello again, " + call.request.name });}function main() { var server = new grpc.Server(); server.addProtoService(hello_proto.Greeter.service, { sayHello: sayHello, sayHelloAgain: sayHelloAgain }); server.bind("0.0.0.0:50051", grpc.ServerCredentials.createInsecure()); server.start();} 我们在 50051 端口支持了 gRPC 服务,并注册了服务 Greeter,并对 sayHello sayHelloAgain 方法做了一些业务处理,并返回给调用方一些数据。 定义客户端: function main() { var client = new hello_proto.Greeter( "localhost:50051", grpc.credentials.createInsecure() ); client.sayHello({ name: "you" }, function(err, response) { console.log("Greeting:", response.message); }); client.sayHelloAgain({ name: "you" }, function(err, response) { console.log("Greeting:", response.message); });} 可以看到,客户端和服务端同时需要拿到 proto 结构,客户端数据发送也要依赖 proto 包提供的方法,框架会内置做掉序列化/反序列化的工作。 也有一些额外手段将 gRPC 转换为 http 服务,让网页端也享受到其高效、低耗的好处。但是不要忘了,RPC 最常用的场景是 IOT 等硬件领域,网页场景也许不会在乎节省几 KB 的流量。 GraphQLGraphQL 不是 REST 的替代品,而是另一种交互形式:前端决定后端的返回结果。 GraphQL 带来的最大好处是精简请求响应内容,不会出现冗余字段,前端可以决定后端返回什么数据。但要注意的是,前端的决定权取决于后端支持什么数据,因此 GraphQL 更像是精简了返回值的 REST,而后端接口也可以一次性定义完所有功能,而不需要逐个开发。 再次强调,相比 REST 和 gRPC,GraphQL 是由前端决定返回结果的反模式。 使用举例: 原文推荐参考 GitHub GraphQL API 比如查询某个组织下的成员,REST 风格接口可能是: curl -v https://api.github.com/orgs/:org/members 含义很明确,但问题是返回结果不明确,必须实际调试才知道。换成等价的 GraphQL 是这样的 query { organization(login: "github") { members(first: 100) { edges { node { name avatarUrl } } } }} 返回的结果和约定的格式结构一致,且不会有多余的字段: { "data": { "organization": { "members": { "edges": [ { "node": { "name": "Chris Wanstrath", "avatarUrl": "https://avatars0.githubusercontent.com/u/2?v=4" } }, { "node": { "name": "Justin Palmer", "avatarUrl": "https://avatars3.githubusercontent.com/u/25?v=4" } } ] } } }} 但是能看出来,这样做需要一个系统帮助你写 query,很多框架都提供这个功能,比如 apollo-client。 Webhooks如果说 GraphQL 颠覆了前后端交互模式,那 Webhooks 可以说是彻头彻尾的反模式了,因为其定义就是,前端不主动发送请求,完全由后端推送。 它最适合解决轮询问题。或者说轮询就是一种妥协的行为,当后端不支持 Webhooks 模式时。 使用举例: Webhooks 本身也可以由 REST 或者 gRPC 实现,所以就不贴代码了。举个常用例子,比如你的好友发了一条朋友圈,后端将这条消息推送给所有其他好友的客户端,就是 Webhooks 的典型场景。 最后作者给出的结论是,这四个场景各有不同使用场景,无法相互替代: REST:无状态的数据传输结构,适用于通用、快速迭代和标准化语义的场景。 gRPC:轻量的传输方式,特殊适合对性能高要求或者环境苛刻的场景,比如 IOT。 GraphQL: 请求者可以自定义返回格式,某些程度上可以减少前后端联调成本。 Webhooks: 推送服务,主要用于服务器主动更新客户端资源的场景。 3 精读REST 并非适用所有场景本文给了我们一个更大的视角看待日常开发中的接口问题,对于奋战在一线的前端同学,接触到 90% 的接口都是非 REST 规则的 Http 接口,能真正落实 REST 的团队其实非常少。这其实暴露了一个重要问题,就是 REST 所带来的好处,在整套业务流程中到底占多大的比重? 不仅接口设计方案的使用要分场景,针对某个接口方案的重要性也要再继续细分:在做一个开放接口的项目,提供 Http 接口给第三方使用,这时必须好好规划接口的语义,所以更容易让大家达成一致使用 REST 约定;而开发一个产品时,其实前后端不关心接口格式是否规范,甚至在开发内网产品时,性能和冗余都不会考虑,效率放在了第一位。所以第一点启示是,不要埋冤当前团队业务为什么没有使用某个更好的接口约定,因为接口约定很可能是业务形态决定的,而不是凭空做技术对比从而决定的。 gRPC 是服务端交互的首选前端同学转 node 开发时,很喜欢用 Http 方式进行服务器间通讯,但可能会疑惑,为什么公司内部 Java 或者 C++ 写的服务都不提供 Http 方式调用,而是另外一个名字。了解 gRPC 后,可以认识到这些平台都是对 RPC 方式的封装,服务器间通信对性能和延时要求非常高,所以比较适合专门为性能优化的 gRPC 等服务。 GraphQL 需要配套GraphQL 不是 REST 的替代品,所以不要想着团队从 Http 接口迁移到 GraphQL 就能提升 X% 的开发效率。GraphQL 方案是一种新的前后端交互约定,所以上手成本会比较高,同时,为了方便前端同学拼 query,等于把一部分后端工作量转移给了前端,如果此时没有一个足够好用的平台快速查阅、生成、维护这些定义,开发效率可能不升反降。 总的来说,对外开放 API 或者拥有完整配套的场景,使用 GraphQL 是比较理想的,但对于快速迭代,平台又不够成熟的团队,继续使用标准 Http 接口可以更快完成项目。 Webhooks 解决特殊场景问题对于第三方平台验权、登陆等 没有前端界面做中转的场景,或者强安全要求的支付场景等,适合用 Webhooks 做数据主动推送。说白了就是在前端无从参与,或者因为前端安全问题不适合参与时,就是 Webhooks 的场景。很显然 Webhooks 也不是 Http 的替代品,不过的确是一种新的前后端交互方式。 对于慢查询等场景,前端普遍使用轮询完成,这和 Socket 相比体验更弱,但无状态的特性反而会降低服务器负担,所以慢查询和即时通讯要区分对待,用户对消息及时性的敏感程度决定了使用哪种方案。 4 总结最后,上面总结的内容一定还有许多疏漏,欢迎补充。 5 更多讨论 讨论地址是:精读《REST, GraphQL, Webhooks, & gRPC 如何选型》 · Issue ##102 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《Microsoft Power Fx》","path":"/wiki/WebWeekly/前沿技术/《Microsoft Power Fx》.html","content":"当前期刊数: 211 Power Fx 是一门语言,虽然它被推荐的场景是低代码,但我们必须以一门语言角度看待它,才能更好的理解。 Power Fx 的创建是为了更好的辅助非专业开发人员,因此这门语言被设计的足够简单,希望这门语言可以同时服务于专业与非专业开发者,这是个非常崇高的理想。 本周我们就随着 Microsoft Power Fx 概述 这篇文章,详细了解一下这门语言是怎么做的。 概述Notify("this is a problem", Error) 这就是 Power Fx 语言的一个例子,乍一看没什么特别的。 Power Fx 描述的是画布应用公式语言,也就是说,这个编程语言是专门为画布引用设计的。 那什么是画布应用呢?低代码、网站搭建、BI、Web Excel 这些统统都是画布应用,所以 Power Fx 其实是一门适应画布场景的语言,直接面向用户。 那这种画布语言应该具备什么特性呢?Power Fx 团队已经有了一些思考: 简单:该语言设计本着简介简单的原则,这样才方便非开发人员上手。 Excel 一致性:可以帮助 Excel 开发者做知识迁移,一部分是和微软 Excel 太成功了有关,另一方面 Excel 表达式在画布语言领域探索确实深入,有可取性。对不能满足的尝试借鉴 SQL 这种声明性语言。 声明性:这个最重要,即描述做什么,而不是如何或何时做。这个有点像 Jquery 转到 React 模式时,过程式代码与数据驱动代码的区别。 函数式:函数式在灵活性和易用性上有天然优势,且无副作用的特性也利于理解逻辑与编译优化。 组合:即利用函数式这个特性,推荐利用已有函数组合成新功能,而不是将比如 Sort、Filter 等功能在每个组件上重复实现或者重复配置一遍。 强类型:类型对可维护性至关重要,再强大的低代码语言,如果没有类型支持,都不能称为易上手。 类型推理:可以自动推断类型。这个和强类型一样,有点 TS 的感觉,主要方便书写简洁代码。 不推荐面向对象:既然推荐了函数式,当然不推荐面向对象了。 可推展:开发者要拥有拓展函数与组件的能力,还要支持通过 Javascript 来拓展。 对开发人员友好:这门语言还要在与前面原则不冲突的情况下,尽量对开发人员友好。 语言的迭代:即当语法变更时,要帮助用户平滑迁移,毕竟这门语言直接面向普通用户而非专业开发者。Power Fx 提供了这个能力,对每个文档进行版本标记,并在升级后,通过 “兼容转换器” 自动将老语法升级为新语法。 无 undefined 值:为了简化语言带来的理解成本,移除了 undefined 值这个特定。 所以,基于这些考虑的 Power Fx 设计出来是这样的: 实时性 即无论任何 UI 或语法错误,都不会阻塞其它正常节点的工作,同时代码效果与错误信息实时反馈。这保证了在画布应用编写逻辑的良好体验,因为本身画布应用就是实时的,低代码能力本身也要与画布实时性浑然一体。 低代码特征 即任何 UI 组件都不需要描述类似 onChange 之类的回调,它们只要申明使用的变量,当这些变量变化时,程序会自动、异步、按需的更新使用到的组件。 与无代码结合 所谓无代码,就是通过 UI 表单可视化的对画布应用进行配置。 与无代码的结合方式是,任意属性都可以用低代码,即表达式编写,但也提供了 UI 表单供编辑,其中 UI 表单编辑后,可以用低代码二次加工,而用低代码编辑的属性,表单就无法编辑了,此时点击表单编辑会跳转到低代码编辑框。 精读创建一门不用学习就能上手的编程语言,需要足够简单,即从用户角度来理解事物:比如用户不知道回调函数等概念,那就屏蔽所谓的回调函数概念,让一切都是表达式。 这些表达式看起来很简单,也符合直觉,并且会自动驱动 UI 重绘,即声明式编程。 下面我们来讨论几个有意思的点: 为什么不用 Js大部分画布应用都是指 Web 应用了,即便是 Excel,现在也早已转型到 Web Excel,就微软来说,早早转型到 Office Online 就能看出来。 然而 Js 是浏览器内置支持的脚本语言,且上手成本也比较低,其实很多低代码平台内置的编程语言就是 Js,其好处是实现成本低(沙箱甚至 new function),而 Power Fx 在浏览器平台最终也要转换为 Js 执行,费这么大劲创造一门新语言,无非是觉得 Js 不够 “零门槛”。 首先第一点是不符合 Excel 表达式规范,我们不要忘了 Power Fx 也是有小心机的,它想利用 Excel 生态扩大用户群,所以第一目的是兼容 Excel 语法。比如 Excel 使用 & 链接字符串,而 Js 使用 + 连接,虽然我觉得显然 + 号更自然,但微软觉得还是要符合 Excel 用户习惯。说实话在这一点上,撇开 Excel 的语法,我很难看出为什么 & 连接字符串就 “更易上手”,而 + 连接字符串 “更适合程序员使用”。 但有些是认可的,比如移除了 undefined 值,确实让语言更好理解。 也许未来 Power Fx 会更进一步,引入类 SQL 描述性的语法,像写自然语言一样编程,在这种程度上,配合强类型提示,在特定场景会比 Js 更好用。 提供内置函数Js 提供了大量内置函数,这似乎不是 Power Fx 的专利,但 Power Fx 提供了许多 UI 级别的函数,这可比 Js 点到为止的 alert 强多了。 Power Fx 提供了 Confirm、Notify 用于弹出提示窗供用户输入,并且就算要形成逻辑,也只需要几乎一行代码: If( Confirm( "Are you sure?", {Title: "Delete Confirmation"} ), Remove( ThisItem ) ) 可以看到,这里充斥着异步操作: 等待用户输入。 删除元素。 但这些内置函数间的组合将异步效果转换为同步写法,这大大降低开发成本。 另一类内置函数则封装了业务属性,比如 User 可以获取当前用户信息。本来获取用户信息就需要代码开发,但低代码平台本身就实现了全套账号体系,因此低代码平台可以直接提供如 User().Email 函数访问当前用户的邮箱地址。 还有诸如 Reset 函数,可以重制控件为默认值,比如 Reset( TextInput1 ),这其实是把平台提供的所有上层能力抽象成低代码函数供用户调用,这样用户只要付出一点点学习成本,就可以获得比简单 UI 强大的多的应用编辑能力,这非常值得我们学习。 更多公式函数可以参考 文档。 提供对表的操作对表的操作 让应用数据管理可以和 Excel 同一概念来看待了,这个统一方式就是,把数据抽象成表。Power Fx 提供了系列函数用于表处理: AddColumns( Filter( Products, 'Quantity Requested' > 'Quantity Available' ), "Quantity To Order", 'Quantity Requested' - 'Quantity Available') 这些函数可以跨语言操作 Excel、Sql Server 等数据源的数据,学习成本与 SQL 类似,其实到这一步,对低代码用户的要求也不低,至少和熟练使用计算公式的 Excel 使用者相当。 总结UI 编辑能力局限但易上手,代码能力最强但难上手,Power Fx 给我们提供了一种折中方案,即提供一种 “高度封装的简化代码” 供用户使用。 纵观其它低代码平台,也有一类采用了另一种折中方案,即超强的复杂编辑 UI,登峰造极的产物便是逻辑编排,这个方向在特定领域也是不错的选择,参考: 精读《低代码逻辑编排》。 讨论地址是:精读《Microsoft Power Fx》· Issue ##355 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React 18》","path":"/wiki/WebWeekly/前沿技术/《React 18》.html","content":"当前期刊数: 202 React 18 带来了几个非常实用的新特性,同时也没有额外的升级成本,值得仔细看一看。 下面是几个关键信息: React 18 工作小组。利用社区讨论 React 18 发布节奏与新特性。 发布计划。目前还没有正式发布,不过 @alpha 版已经可用了,安装 alpha 版。 React 18 新特性介绍。虽然还未正式发布,但特性介绍可以先行,本周精读主要就是解读这篇文档。 精读总的来说,React 18 带来了 3 大新特性: Automatic batching。 Concurrent APIS。 SSR for Suspense。 同时为了开启新的特性,需要进行简单的 render 函数升级。 Automatic batchingbatching 是指,React 可以将回调函数中多个 setState 事件合并为一次渲染。 也就是说,setState 并不是实时修改 State 的,而将多次 setState 调用合并起来仅触发一次渲染,既可以减少程序数据状态存在中间值导致的不稳定性,也可以提升渲染性能。可以理解为如下代码所示: function handleClick() { setCount((c) => c + 1); setFlag((f) => !f); // 仅触发一次渲染} 但可惜的是,React 18 以前,如果在回调函数的异步调用中执行 setState,由于丢失了上下文,无法做合并处理,所以每次 setState 调用都会立即触发一次重渲染: function handleClick() { // React 18 以前的版本 fetch(/*...*/).then(() => { setCount((c) => c + 1); // 立刻重渲染 setFlag((f) => !f); // 立刻重渲染 });} 而 React 18 带来的优化便是,任何情况都可以合并渲染了!即使在 promise、timeout 或者 event 回调中调用多次 setState,也都会合并为一次渲染: function handleClick() { // React 18+ fetch(/*...*/).then(() => { setCount((c) => c + 1); setFlag((f) => !f); // 仅触发一次渲染 });} 当然如果你非要 setState 调用后立即重渲染也行,只需要用 flushSync 包裹: function handleClick() { // React 18+ fetch(/*...*/).then(() => { ReactDOM.flushSync(() => { setCount((c) => c + 1); // 立刻重渲染 setFlag((f) => !f); // 立刻重渲染 }); });} 开启这个特性的前提是,将 ReactDOM.render 替换为 ReactDOM.createRoot 调用方式。 新的 ReactDOM Render API升级方式很简单: const container = document.getElementById("app");// 旧 render APIReactDOM.render(<App tab="home" />, container);// 新 createRoot APIconst root = ReactDOM.createRoot(container);root.render(<App tab="home" />); API 修改的主要原因还是语义化,即当我们多次调用 render 时,不再需要重复传入 container 参数,因为在新的 API 中,container 已经提前绑定到 root 了。 ReactDOM.hydrate 也被 ReactDOM.hydrateRoot 代替: const root = ReactDOM.hydrateRoot(container, <App tab="home" />);// 注意这里不用调用 root.render() 这样的好处是,后续如果再调用 root.render(<Appx />) 进行重渲染,我们不用关心这个 root 来自 createRoot 或者 hydrateRoot,因为后续 API 行为表现都一样,减少了理解成本。 Concurrent APIS首先要了解 Concurrent Mode 是什么。 简单来说,Concurrent Mode 就是一种可中断渲染的设计架构。什么时候中断渲染呢?当一个更高优先级渲染到来时,通过放弃当前的渲染,立即执行更高优先级的渲染,换来视觉上更快的响应速度。 有人可能会说,不对啊,中断渲染后,之前渲染的 CPU 执行不就浪费了吗,换句话说,整体执行时长增加了。这句话是对的,但实际上用户对页面交互及时性的感知是分为两种的,第一种是即时输入反馈,第二种是这个输入带来的副作用反馈,比如更新列表。其中,即使输入反馈只要能优先满足,即便副作用反馈更慢一些,也会带来更好的体验,更不用说副作用反馈大部分情况会因为即使输入反馈的变化而作废。 由于 React 将渲染 DOM 树机制改为两个双向链表,并且渲染树指针只有一个,指向其中一个链表,因此可以在更新完全发生后再切换指针指向,而在指针切换之前,随时可以放弃对另一颗树的修改。 以上是背景输入。React 18 提供了三个新的 API 支持这一模式,分别是: startTransition。 useDeferredValue。 <SuspenseList>。 后两个文档还未放出,所以本文只介绍第一个 API:startTransition。首先看一下用法: import { startTransition } from "react";// 紧急更新:setInputValue(input);// 标记回调函数内的更新为非紧急更新:startTransition(() => { setSearchQuery(input);}); 简单来说,就是被 startTransition 回调包裹的 setState 触发的渲染 被标记为不紧急的渲染,这些渲染可能被其他紧急渲染所抢占。 比如这个例子,当 setSearchQuery 更新的列表内容很多,导致渲染时 CPU 占用 100% 时,此时用户又进行了一个输入,即触发了由 setInputValue 引起的渲染,此时由 setSearchQuery 引发的渲染会立刻停止,转而对 setInputValue 渲染进行支持,这样用户的输入就能快速反映在 UI 上,代价是搜索列表响应稍慢了一些。而一个 transition 被打断的状态可以通过 isPending 访问到: import { useTransition } from "react";const [isPending, startTransition] = useTransition(); 其实这比较符合操作系统的设计理念,我们知道在操作系统是通过中断响应底层硬件事件的,中断都非常紧急(因为硬件能存储的消息队列非常有限,操作系统不能即使响应,硬件的输入可能就丢失了),因此要支持抢占式内核,并在中断到来时立刻执行中断(可能把不太紧急的操作放到下半部执行)。 对前端交互来说,用户角度发出的 “中断” 一般来自键盘或鼠标的操作,但不幸的是,前端框架甚至是 JS 都过于上层,它们无法自动识别: 哪些代码是紧急中断产生的。比如 onClick 就一定是用户鼠标点击产生的吗?不一定,可能是 xxx.onClick 主动触发的,而非用户触发。 用户触发的就一定是紧急中断吗?不一定,比如键盘输入后,setInputValue 是紧急的,而更新查询列表的 setSearchQuery 就是非紧急的。 我们要理解到前端场景对用户操作感知的局限性,才能理解为什么必须手动指定更新的紧急程度,而不能像操作系统一样,上层程序无需感知中断的存在。 SSR for Suspense完整名称是:Streaming SSR with selective hydration。 即像水流一样,打造一个从服务端到客户端持续不断的渲染管线,而不是 renderToString 那样一次性渲染机制。selective hydration 表示选择性水合,水合指的是后端内容打到前端后,JS 需要将事件绑定其上,才能响应用户交互或者 DOM 更新行为,而在 React 18 之前,这个操作必须是整体性的,而水合过程可能比较慢,会引起全局的卡顿,所以选择性水合可以按需优先进行水合。 所以这个特性其实是转为 SSR 准备的,而功能启用载体就是 Suspense(所以以后不要再认为 Suspense 只是一个 loading 作用)。其实在 Suspense 设计之初,就是为了解决服务端渲染问题,只是一开始只实装了客户端测的按需加载功能,后面你会逐渐发现 React 团地逐渐赋予了 Suspense 更多强大能力。 SSR for Suspense 解决三个主要问题: SSR 模式下,如果不同模块取数效率不同,会因为最慢的一个模块拖慢整体 HTML 吞吐时间,这可能导致体验还不如非 SSR 来的好。举一个极端情况,假设报表中一个组件依赖了慢查询,需要五分钟数据才能出来,那么 SSR 的后果就是白屏时间拉长到 5 分钟。 即便 SSR 内容打到了页面上,由于 JS 没有加载完毕,所以根本无法进行 hydration,整个页面处于无法交互状态。 即便 JS 加载完了,由于 React 18 之前只能进行整体 hydration,可能导致卡顿,导致首次交互响应不及时。 在 React 18 的 server render 中,只要使用 pipeToNodeWritable 代替 renderToString 并配合 Suspense 就能解决上面三个问题。 使用 pipeToNodeWriteable 可以看 这个例子。 最大的区别在于,服务端渲染由简单的 res.send 改成了 res.socket,这样渲染就从单次行为变成了持续性的行为。 那么 React 18 的 SSR 到底有怎样的效果呢?这篇介绍文档 的图建议看一看,非常直观,这里我简要描述一下: 被 <Suspense> 包裹的区块,在服务端渲染时不会阻塞首次吞吐,而且在这个区块准备完毕后(包括异步取数)再实时打到页面中(以 HTML 模式,此时还没有 hydration),在此之前返回的是 fallback 的内容。 hydration 的过程也是逐步的,这样不会导致一下执行所有完整的 js 导致页面卡顿(hydration 其实就是 React 里写的回调注册、各类 Hooks,整个应用的量非常庞大)。 hydration 因为被拆成多部,React 还会提前监听鼠标点击,并提前对点击区域优先级进行 hydration,甚至能抢占已经在其他区域正在进行中的 hydration。 那么总结一下,新版 SSR 性能提高的秘诀在于两个字:按需。 而这个难点在于,SSR 需要后端到前端的配合,在 React 18 之前,后端到前端的过程完全没有优化,而现在将 SSR HTML 的吞吐改成多次,按需,并且水合过程中还支持抢占,因此性能得到进一步提升。 总结结合起来看,React 18 关注点在于更快的性能以及用户交互响应效率,其设计理念处处包含了中断与抢占概念。 以后提起前端性能优化,我们就多了一些应用侧的视角(而不仅仅是工程化视角),从以下两个应用优化视角有效提升交互反馈速度: 随时中断的框架设计,第一优先级渲染用户最关注的 UI 交互模块。 从后端到前端 “顺滑” 的管道式 SSR,并将 hydration 过程按需化,且支持被更高优先级用户交互行为打断,第一优先水合用户正在交互的部分。 讨论地址是:精读《React 18》· Issue ##336 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Conf 2019 - Day1》","path":"/wiki/WebWeekly/前沿技术/《React Conf 2019 - Day1》.html","content":"当前期刊数: 127 1 引言React Conf 2019 在今年 10 月份举办,内容质量还是一如既往的高,如果想进一步学习前端或者 React,这个大会一定不能错过。 希望前端精读成为你学习成长路上的布道者,所以本期精读就介绍 React Conf 2019 - Day1 的相关内容。 总的来看,React Conf 今年的内容视野更广了,不仅仅有技术内容,还有宣扬公益、拓展到移动端、后端,最后还有对 web 发展的总结与展望。 前端世界正变得越来越复杂,可以看到大家对未来都充满了希望,永不停歇的探索精神是这场大会的主旋律。 2 概述 & 精读本期大会思想、设计上的内容较多,具体实现层内容较少,因为行业领导者需要引领规范,而真正技术价值在于思维模型与算法,理解了解题思路,实现它其实并不难。 开发者体验与用户体验 开发者体验:DX(develop experience) 用户体验:UX(user experience) 技术人解决的问题总是围绕 DX 与 UX,而一般来说,优化了 DX 往往会带来 UX 的提升,这是因为一个解决开发者体验的技术创新往往也会带来用户体验的升级,至少也能让开发者有更好的心情、更充足的时间做出好产品。 如何优化开发者体验呢? 易上手 React 确实致力于解决这个问题,因为 React 实际上是一个开发者桥梁,无论你开发 web、ios 还是单片机,都可以通过一套统一的语法去实现。React 是一个协议标准(读到 reactReconciler 章节会更有体感),React 像 HTML,但 React 不止能构建 HTML 应用,React 希望构建一切。 高效开发 React 解决调试、工具问题,让开发者更高效的完成工作,这也是开发者体验重要组成部分。 弹性 React 编写的程序拥有良好可维护性,包括数据驱动、模块化等等特征都是为了更好服务于不同规模的团队。 对于 UX 问题,React 也有 Concurrent mode、Suspense 等方案。 虽然 React 还不完美,但 React 致力于解决 DX 与 UX 的目标和效果都是我们有目共睹的,更好的 DX、UX 一定是前端技术未来发展的大趋势。 样式方案Facebook 使用 css-in-js,而今年的 React conf 给出了一种技术方案,将 413 kb 的样式文件体积降低到 74kb! 一步步了解这个方案,从用法开始: const styles = stylex.create({ blue: { color: "blue" }, red: { color: "red" }});function MyComponent(props) { return <span className={styles("blue", "red")}>I'm red now!</span>;} 如上是这个方案的写法,通过 stylex.create 创建样式,通过 styles() 使用样式。 主题方案 如果使用 CSS 变量定义主题,那么换肤就可以由最外层 class 轻松决定了: .old-school-theme { --link-text: blue;}.text-link { color: var(--link-text);} 字体颜色具体的值由外层 class 决定,因此外层的 class 就可以控制所有子元素的样式: <div class="old-school-theme"> <a class="text-link" href="..."> I'm blue! </a></div> 将其封装成 React 组件,也不需要用 context 等 JS 能力,而是包裹一层 class 即可。 function ThemeProvider({ children, theme }) { return <div className={themes[theme]}>{children}</div>;} 图标方案 下面是设计师给出的 svg 代码: <svg viewBox="0 0 100 100"> <path d="M9 25C8 25 8..." /></svg> 将其包装为 React 组件: function SettingsIcon(props) { return ( <SVGIcon viewBox="0 0 100 100" {...props}> <path d="M9 25C8 25 8..." /> </SVGIcon> );} 结合上面提到的主题方案,就可以控制 svg 的主题颜色。 const styles = stylex.create({ primary: { fill: "var(--primary-icon)" }, gighlight: { fill: "var(--highlight-icon)" }});function SVGIcon(color, ...props) { return ( <svg> {...props} className={styles({ primary: color === "primary", highlight: color === "highlight" })} {children} </svg> );} 减少样式大小的秘密 const styles = stylex.create({ blue: { color: "blue" }, default: { color: "red", fontSize: 16 }});function MyComponent(props) { return <span className={styles("default", props.isBlue && "blue")} />;} 对于上述样式文件代码,最终会编译成 c1、c2、c3 三个 class: .c1 { color: blue;}.c2 { color: red;}.c3 { font-size: 16px;} 出乎意料的是,并没有根据 blue 和 default 生成对应的 class,而是根据实际样式值生成 class,这样做有什么好处呢? 首先是加载顺序,class 生效的顺序与加载顺序有关,而按照样式值生成的 class 可以精确控制样式加载顺序,使其与书写顺序对应: // 效果可能是 blue 而不是 red<div className="blue red" />// 效果一定是 red,因为 css-in-js 在最终编排 class 时,虽然两种样式都存在,但书写顺序导致最后一个优先级最高,// 合并的时候就会舍弃失效的那个 class<div className={styles('blue', 'red')} /> 这么做永远不会出现头疼的样式覆盖问题。 更重要的是,随着样式文件的增多,class 总量会减少。这是因为新增的 class 涵盖的属性可能已经被其他 class 写到并生成了,此时会直接复用对应属性生成的 class 而不会生成新的: <Component1 className=".class1"/><Component2 className=".class2"/> .class1 { background-color: mediumseagreen; cursor: default; margin-left: 0px;}.class2 { background-color: thistle; cursor: default; justify-self: flex-start; margin-left: 0px;} 正如这个 Demo 所示,正常情况的 class1 与 class2 存在许多重复定义的属性,但换成 css-in-js 的方案,编译后的效果等价于将 class 复用并拆解了: <Component1 classNames=".classA .classB .classD"><Component2 classNames=".classA .classC .classD .classE"> .classA { cursor: default;}.classB { background-color: mediumseagreen;}.classC { background-color: thistle;}.classD { margin-left: 0px;}.classE { justify-self: flex-start;} 这种方式不仅节省空间、还能自动计算样式优先级避免冲突,并将 413 kb 的样式文件体积降低到 74kb。 字体大小方案rem 的好处是相对的字体大小,使用 rem 作为单位可以很方便实现网页字体大小的切换。 但问题是现在工业设计都习惯了以 px 作为单位,所以一种全新的编译方案产生了:在编译阶段将 px 自动转换成 rem。 这等于让以 px 为单位的字体大小可以跟随根节点字体大小随意缩放。 代码检测静态检测类型错误、拼写错误、浏览器兼容问题。 在线检测 dom 节点元素问题,比如是否有可访问性,比如替代文案 aria-label。 提升加载速度普通网页的加载流程是这样的: 先加载代码,然后会渲染页面,在渲染的同时发取数请求,等取数完成后才能渲染出真实数据。 那么如何改善这个情况呢?首先是预取数,提前解析出请求并在脚本加载的同时取数,可以节省大量时间: 那么下载的代码可以再拆分吗?注意到并不是所有代码都作用于 UI 渲染,我们可以将模块分为 ImportForDisplay 与 importForAfterDisplay : 这样就可以优先加载与 UI 相关的代码,其余逻辑代码在页面展示出之后再加载: 这样可以实现源码分段加载,并分段渲染: 对取数来说也是如此,并不是所有取数都是初始化渲染阶段必须用上的。可以通过 relay 的特性 @defer 标记出可以延迟加载的数据: fragment ProfileData on User { classNameprofile_picture { ... } ...AdditionalData @defer} 这下取数也可以分段了,首屏的数据会优先加载: 利用 relay 还可以以数据驱动方式结合代码拆分: ... on Post { ... on PhotoPost { @module('PhotoComponent.js') photo_data } ... on VideoPost { @module('VideoComponent.js') video_data } ... on SongPost { @module('SongComponent.js') song_data }} 这样首屏数据中也只会按需加载用到的部分,请求时间可以再次缩短: 可以看到,与 relay 结合可以进一步优化加载性能。 加载体验可以 React.Suspense 与 React.lazy 动态加载组件。通过 fallback 指定元素的占位图可以提升加载体验: <React.Suspense fallback={<MyPlaceholder />}> <Post> <Header /> <Body /> <Reactions /> <Comments /> </Post></React.Suspense> Suspense 可以被嵌套,资源会按嵌套顺序加载,保证一个自然的视觉连贯性。 智能文档通过解析 Markdown 自动生成文档大家已经很熟悉了,也有很多现成的工具可以用,但这次分享的文档系统有意思之处在于,可以动态修改源码并实时生效。 不仅如此,还利用了 Typescript + MonacoEditor 在网页上做语法检测与 API 自动提示,这种文档体验上升了一个档次。 虽然没有透露技术实现细节,但从热更新的操作来看像是把编译工作放在了浏览器 web worker 中,如果是这种实现方式,原理与 CodeSandbox 实现原理 类似。 GraphQL and Stuff这一段在安利利用接口自动生成 Typescript 代码提升前后端联调效率的工具,比如 go2dts。 我们团队也开源了基于 swagger 的 Typescript 接口自动生成工具 pont,欢迎使用。 React Reconciler这是知识密度最大的一节,介绍了如何使用 React Reconclier。 React Reconclier 可以创建基于任何平台的 React 渲染器,也可以理解为通过 React Reconclier 可以创建自定义的 ReactDOM。 比如下面的例子,我们尝试用自定义函数 ReactDOMMini 渲染 React 组件: import React from "react";import logo from "./logo.svg";import ReactDOMMini from "./react-dom-mini";import "./App.css";function App() { const [showLogo, setShowLogo] = React.useState(true); let [color, setColor] = React.useState("red"); React.useEffect(() => { let colors = ["red", "green", "blue"]; let i = 0; let interval = setInterval(() => { i++; setColor(colors[i % 3]); }, 1000); return () => clearInterval(interval); }); return ( <div className="App" onClick={() => { setShowLogo(show => !show); }} > <header className="App-header"> {showLogo && <img src={logo} className="App-logo" alt="logo /" />} // 自创语法 <p bgColor={color}> Edit <code>src/App.js</code> and save to reload. </p> <a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer" > Learn React{" "} </a> </header> </div> );}ReactDOMMini.render(<App />, codument.getElementById("root")); ReactDOMMini 是利用 ReactReconciler 生成的自定义组件渲染函数,下面是完整的代码: import ReactReconciler from "react-reconciler";const reconciler = ReactReconciler({ createInstance( type, props, rootContainerInstance, hostContext, internalInstanceHandle ) { const el = document.createElement(type); ["alt", "className", "href", "rel", "src", "target"].forEach(key => { if (props[key]) { el[key] = props[key]; } }); // React 事件代理 if (props.onClick) { el.addEventListener("click", props.onClick); } // 自创 api bgColor if (props.bgColor) { el.style.backgroundColor = props.bgColor; } return el; }, createTextInstance( text, rootContainerInstance, hostContext, internalInstanceHandle ) { return document.createTextNode(text); }, appendChildToContainer(container, child) { container.appendChild(child); }, appendChild(parent, child) { parent.appendChild(child); }, appendInitialChild(parent, child) { parent.appendChild(child); }, removeChildFromContainer(container, child) { container.removeChild(child); }, removeChild(parent, child) { parent.removeChild(child); }, insertInContainerBefore(container, child, before) { container.insertBefore(child, before); }, insertBefore(parent, child, before) { parent.insertBefore(child, before); }, prepareUpdate( instance, type, oldProps, newProps, rootContainerInstance, currentHostContext ) { let payload; if (oldProps.bgColor !== newProps.bgColor) { payload = { newBgCOlor: newProps.bgColor }; } return payload; }, commitUpdate( instance, updatePayload, type, oldProps, newProps, finishedWork ) { if (updatePayload.newBgColor) { instance.style.backgroundColor = updatePayload.newBgColor; } }});const ReactDOMMini = { render(wahtToRender, div) { const container = reconciler.createContainer(div, false, false); reconciler.updateContainer(whatToRender, container, null, null); }};export default ReactDOMMini; 笔者拆解一下说明: React 之所以具备跨平台特性,是因为其渲染函数 ReactReconciler 只关心如何组织组件与组件间关系,而不关心具体实现,所以会暴露出一系列回调函数。 创建实例 由于 React 组件本质是一个描述,即 tag + 属性,所以 Reconciler 不关心元素是如何创建的,需要通过 createInstance 拿到组件基本属性,在 Web 平台利用 DOM API 实现: createInstance( type, props, rootContainerInstance, hostContext, internalInstanceHandle ) { const el = document.createElement(type); ["alt", "className", "href", "rel", "src", "target"].forEach(key => { if (props[key]) { el[key] = props[key]; } }); // React 事件代理 if (props.onClick) { el.addEventListener("click", props.onClick); } // 自创 api bgColor if (props.bgColor) { el.style.backgroundColor = props.bgColor; } return el; } 之所以说 React 对 DOM 事件都做了一层代理,是因为 JSX 的所有函数都没有真正透传给 DOM,而是通过类似 el.addEventListener("click", props.onClick) 的方式代理实现的。 而自定义这个函数,我们甚至能创建例如 bgColor 这种特殊语法,只要解析引擎实现了这个语法的 Handler。 除此之外,还有 创建、删除实例 的回调函数,我们都要利用 DOM 平台的 API 重新实现一遍,这样不仅可以实现对浏览器 API 的兼容,还可以对接到比如 react-native 等非 WEB 平台。 更新组件 实现了 prepareUpdate 与 commitUpdate 才能完成组件更新。 prepareUpdate 返回的 payload 被 commitUpdate 函数接收到,并根据接收到的信息决定如何更新实例节点。这个实例节点就是 createInstance 回调函数返回的对象,所以如果在 WEB 环境返回的 instance 就是 DOMInstance,后续所有操作都使用 DOMAPI。 总结一下:react 主要用平台无关的语法生成具有业务含义的 AST,而利用 react-reconciler 生成的渲染函数可以解析这个 AST,并提供了一系列回调函数实现完整的 UI 渲染功能,react-dom 现在也是基于 react-reconciler 写的。 图标体积优化Facebook 团队通过优化,将图标大小从 4046.05KB 降低到了 132.95kb,体积减少了惊人的 96.7%,减少体积占总包体积的 19.6%! 实现方式很简单,下面是原始图标使用的代码: <FontAwesomeIcon icon="coffee" /><Icon icon={["fab", "twitter"]} /><Button leftIcon="user" /><FeatureGroup.Item icon="info" /><FeatureGroup.Item icon={["fail", "info"]} /> 在编译期间通过 AST 分析,将所有字符串引用换成了图标实例的引用,利用 webpack 的 tree-shaking 功能实现按需加载,从而删除了没有使用到的图标。 import {faCoffee,faInfo,faUser} from "@fontawesome/free-solid-svg-icons"import {faTwitter} from '@fontawesome/free-brands-svg-icons'import {faInfo as faInfoFal} from '@fontawesome/pro-light-svg-icons'<FontAwesomeIcon icon={faCoffee} /><Icon icon={faTwitter} /><Button leftIcon={faUser} /><FeatureGroup.Item icon={faInfo} /><FeatureGroup.Item icon={faInfoFal} /> 替换工具 的链接放出来了,感兴趣的同学可以点进去了解更多。 这也从某种意义上说明了 iconFont 注定被淘汰,因为字体文件目前无法按需加载,只有全部使用 SVG 图标的项目才能使用这种优化。 Git & Github这一节介绍了基本 Git 知识以及 Github 用法,笔者略过比较水的部分,直接列出两个可能你不知道的点: 干预 Github 项目主要语言检测 如果你提交的代码包含许多自动生成的文件,可能你实际使用的语言不会被 Github 解析为主要语言,这时候可以通过 .gitattributes 文件忽略指定文件夹的检测: static/* linguist-vendored 这样语言文件占比统计就会忽略 static/ 文件夹。 Git hooks 的技巧 以下是几个比较具有启发的点,我们可以利用 Git hooks 做点什么: 阻止提交到 master。 在 commit 之前执行 prettier/eslint/jest 检测。 检测代码规范、合并冲突、检测是否有大文件。 commit 成功后给出提示或记录到日志。 但 Git hooks 仍然有局限性: 容易被绕过:–no-verifuy –no-merge –no-checkout —force。 本地 hooks 无法提交,导致项目开发规则可能不尽相同。 无法替代 CI、服务端分支保护、Code Review。 可以畅想一下,在 WebIDE 环境可以通过自定义 git 命令禁止检测绕过,自然解决第二条环境不一致的问题。 GraphQL + TypescriptGraphQL 是没有类型支持的,如果要手动创建一遍类型文件是非常痛苦的: interface GetArticleData { getArticle: { id: number; title: string; };}const query = graphql(gql` query getArticle { article { id title } }`);apolloClient.query<GetArticleData>(query); 同样的代码分散在两处维护一定会带来问题,我们可以利用比如 typed-graphqlify 这种库解决类型问题: import { params, types, query } from "typed-graphqlify";const getArticleQuery = { article: params({ id: types.number, title: types.string })};const gqlString = query("getUser", getUserQuery); 只要一遍定义就可以自动生成 GQLString,并且拿到 Typescript 类型。 React 文档国际化即便是谷歌翻译也不是很靠谱,国际化文档还是要靠人肉,Nat Alison 利用 Github 充分发动各国人民的力量,共同打造了一个个 reactjs group 下的国际化仓库。 国际化仓库命名规则是 reactjs/xx.reactjs.org,比如简体中文的国际化仓库是:https://github.com/reactjs/zh-hans.reactjs.org 从仓库的 readme 可以看到维护规则是这样的: 请 fork 这个仓库。 基于 fork 后的仓库中 master 分支拉取一个新的分支(名字自取)。 翻译(校对)你所选择的文章,提交到新的分支。 此时提交 Pull Request 到该仓库。 会有专人 Review 该 Pull Request,当两人以上通过该 Pull Request 时,你的翻译将被合并到仓库中。 删除你所创建的分支(如继续参与,参考同步流程)。 之后定期从 React 官方文档项目拉取最新代码即可保持文档的同步更新。 你需要 redux 吗?关于数据流的话题目前没有什么新意,但这次 React Conf 关于数据流总结的算是比较真诚的,总结了以下几个点: 全局数据流现在不是必须的,比如 Redux,但也不能说完全不能用,至少在全局状态较为复杂时有必要使用。 不要只使用一种数据流方案,根据状态的作用域确定方案比较好。 工程技术与科学不同,工程世界没有最好的方案,只有更好的方案。 就算有了完美方案也不要停止学习的步伐,总会有新知识产生。 web 历史很精彩的演讲,不过新鲜内容并不多,比较有感触一点是:以前的网页地址对应到的是服务器磁盘的某个具体文件,比如早期 php 应用,现在后端不再是文件化而是服务化了,这层抽象让服务端摆脱了对文件结构的依赖,可以构建更多复杂动态逻辑,也支持了前后端分离的技术方案。 3 总结这届 React Conf 让我们看到前端更多的可能性,我们不仅要关注技术实现细节,更要关注行业标准以及团队愿景。 React 团队的愿景是让 React 包罗万象,提升全球开发者的开发体验、提升全球产品的用户体验,基于这个目标,React Conf 自然不能只包含 DOM Diff、Reconciler 等等技术细节,更需要展示 React 如何帮助全球开发者,如何让这些开发者帮助到用户,如何推动行业标准的演进,如何让 React 打破国界、语言的壁垒。 相比其他前端大会非常多的干货来说,React Conf 虽然显得主题比较杂,但这正是人文情怀的体现,我相信只有带着更高的使命愿景,真诚帮助他人的技术团队才可以走得更远。 讨论地址是:精读《React Conf 2019 - Day1》 · Issue ##214 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Optional chaining》","path":"/wiki/WebWeekly/前沿技术/《Optional chaining》.html","content":"当前期刊数: 107 1. 引言备受开发者喜爱的特性 Optional chaining 在 2019.6.5 进入了 stage2,让我们详细读一下草案,了解一下这个特性的用法以及讨论要点。 借着这次精读草案,让我们了解一下一个完整草案的标准文档结构是怎样的。 一个新特性的文档,首先要描述 起因 是什么,也就是为什么要增加这个特性,大家不会没有理由的就增加一个特性。其次是其他语言是否有现成的实现版本,参考他们并进行归纳总结,可以增加思考角度的全面性。 第三点就是 语法介绍,也就进入了新特性的正题,这里要详细介绍所有可能的使用情况。第四点是 语义,也就是诠释语法的含义。 然后是可选的 是否有不支持的情况,对于不支持的点是否有意而为之,为什么?此处一般会留下讨论的 ISSUE。然后是 暂不考虑的点,是由于性价比低、使用场景少,或者实现成本高的原因,为什么某些已经想到的点暂不考虑,这里也会留下讨论的 ISSUE。 后面一般还有 “正在讨论的点”、“FAQ”、“草案进度”、“参考文献”、“相关问题”、“预先讨论资料” 等内容。 2. 概述&精读首先让我们回顾一下什么是 “Optional chaining”。 起因介绍当访问一个深层树形结构的对象时,我们总需要判断中间节点属性是否存在: var street = user.address && user.address.street; 而且很多 API 返回的属性都可能为 Null,而我们往往只想获取非 Null 时的结果: var fooInput = myForm.querySelector('input[name=foo]')var fooValue = fooInput ? fooInput.value : undefined 笔者这里补充,在人机交互的领域,可能为 Null 的情况很多。首先是交互行为模块很多,行为复杂,很容易导致数据分散且难以预测(可能为空),仅是 DOM 元素就需要太多兼容,因为 DOM 被修改的实际太多了,大家都在共享一个可变的结构;其次是交互过程中间状态很多,出现状态残缺的可能性也很大,就拿 SQL 解析为例:后端只要检测 Query 是否正确就可以了,但前端的 SQL 编辑器需要在输入不完整的情况下给出提示,也就是在语法树错误的情况下给出提示,因此需要进行容错。 而 Optional chaining 可以解决为了容错而写过多重复代码的问题: var street = user.address?.streetvar fooValue = myForm.querySelector('input[name=foo]')?.value 正如上面的例子:如果 user.address 为 undefined,那 street 拿到的就是 undefined,而不是报错。 配合另一个在 stage2 的新特性 Nullish Coalescing 做默认值处理非常方便: // falls back to a default value when response.setting is missing or nullish// (response.settings == null) or when respsonse.setting.animationDuration is missing// or nullish (response.settings.animationDuration == null)const animationDuration = response.settings?.animationDuration ?? 300; ?? 号可以理解为 “默认值场景下的 ||”: const response = { settings: { nullValue: null, height: 400, animationDuration: 0, headerText: '', showSplashScreen: false }};const undefinedValue = response.settings?.undefinedValue ?? 'some other default'; // result: 'some other default'const nullValue = response.settings?.nullValue ?? 'some other default'; // result: 'some other default'const headerText = response.settings?.headerText ?? 'Hello, world!'; // result: ''const animationDuration = response.settings?.animationDuration ?? 300; // result: 0const showSplashScreen = response.settings?.showSplashScreen ?? true; // result: false 0 || 1 的结果是 1,因为 0 判定为 false,而 || 在前面的变量为 false 型才继续执行,而我们想要的是 “前面的对象不存在时才使用后面的值”。?? 则代表了 “前面的对象不存在” 这个含义,即便值为 0 也会认为这个值是存在的。 Optional chaining 也可以用在方法上: iterator.return?.() 或者试图调用某些未被实现的方法: if (myForm.checkValidity?.() === false) { // skip the test in older web browsers // form validation fails return;} 比如某个旧版本浏览器不支持 myForm.checkValidity 方法,则不会报错,而是返回 false。 已有实现调研Optional chaining 在 C##、Swift、CoffeeScript、Kotlin、Dart、Ruby、Groovy 已经实现了,且实现方式均有差异,可以看到每个语言在实现语法时都是有取舍的,但是大方向基本是相同的。 想了解其他语言是如何实现 Optional chaining 的读者可以 点击阅读原文。 这些语言实现 Optional chaining 的差异基本在 语法、支持范围、边界情况处理 等不同,所以如果你每天要在不同语言之间切换工作,看似相同的语法,但不同的细节可能把你绕晕(所以会的语言多,只会让你变成一个速记字典,满脑子都是哪些语言在哪些语法讨论倾向哪一边,选择了哪些特性这些毫无意义的结论,如果不想记这些,基础语法都没有掌握怎么好意思说会这门语言呢?所以学 JS 就够了)。 语法Optional Chaining 的语法有三种使用场景: obj?.prop // optional static property accessobj?.[expr] // optional dynamic property accessfunc?.(...args) // optional function or method call 也就是将 . 替换为 ?.,但要注意第二行与第三行稍稍有点反直觉,比如在函数调用时,需要将 func(...args) 写为 func?.(...args)。至于为什么语法不是 func?(...args) 这种简洁一点的表达方式,在 FAQ 中有提到这个例子: obj?[expr].filter(fun):0 引擎难以判断 obj?[expr] 是 Optional Chaning,亦或这是一个普通的三元运算语句。 可见,要支持 ?. 这个看似简单的语法,在整个 JS 语法体系中要考虑的边界情况很多。 即便是 ?. 这样完整的用法,也需要注意 foo?.3:0 这种情况,不能将 foo?. 解析为 Optional chanining,而要将其解析为 foo? .3 : 0,这需要解析引擎支持 lookahead 特性。 语义**当 ?. 前面的变量值为 null 或 undefined 时,?. 返回的结果为 undefined**。 a?.b // undefined if `a` is null/undefined, `a.b` otherwise.a == null ? undefined : a.ba?.[x] // undefined if `a` is null/undefined, `a[x]` otherwise.a == null ? undefined : a[x]a?.b() // undefined if `a` is null/undefineda == null ? undefined : a.b() // throws a TypeError if `a.b` is not a function // otherwise, evaluates to `a.b()`a?.() // undefined if `a` is null/undefineda == null ? undefined : a() // throws a TypeError if `a` is neither null/undefined, nor a function // invokes the function `a` otherwise 短路所谓短路,就是指引入了 Optional chaining 后,某些看似一定会执行的语句在特定情况下会短路(终止执行),比如: a?.[++x] // `x` is incremented if and only if `a` is not null/undefineda == null ? undefined : a[++x] 第一个例子,如果 a 时 null/undefined,就不会执行 ++x。 原因是这段代码部分等价于 a == null ? undefined : a[++x],如果 a == null 为真,自然不会执行 a[++x] 这个语句。但由于 Optional chaining 使这个语句变得 “简洁了”,虽然带来了便利,但也可能导致看不清完整的执行逻辑,引发误判。 所以看到 ?. 语句时,一定要反射性的思考一下,这个语句会触发 “短路”。 长“短路”Optional chaining 在 JS 的规范中,作用域仅限于调用处。看下面的例子: a?.b.c(++x).d // if `a` is null/undefined, evaluates to undefined. Variable `x` is not incremented. // otherwise, evaluates to `a.b.c(++x).d`.a == null ? undefined : a.b.c(++x).d 可以看到 ?. 仅在 a?. 这一层生效,而不是对后续的 b.c、c(++x).d 继续生效。而对于 C+ 与 CoffeeScript,这个语法是对后续所有 get 生效的(这里再次提醒,不要用 CoffeeScript 了,因为对于相同语法,语义都发生了变化,对你与你的同事都是巨大的理解负担,或者说没有人愿意注意,为什么代码在 CoffeeScript 里不报错,而转移到 JS 就报错了,是因为 Optional chaining 语义不一致造成的。)。 正因为 Optional chaining 在 JS 语法中仅对当前位置起保护作用,因此一个调用语句中允许出现多个 ?. 调用: a?.b[3].c?.(x).da == null ? undefined : a.b[3].c == null ? undefined : a.b[3].c(x).d // (as always, except that `a` and `a.b[3].c` are evaluated only once) 上面这段代码,对 a?.b、c?.(x) 的访问与调用是安全的,而对于 b[3]、 b[3].c、c?.(x).d 的调用是不安全的。 在 FAQ 环节也提到了,为什么不学习 C## 与 CoffeeScript 的语义,将安全保护从 a?. 之后就一路 “贯穿” 下去? 原因是 JS 对 Optional chaining 的理解不同导致的。Optional chaining 仅仅是安全访问保护,不代表 try catch,也就是它不会捕获异常,举一个例子: a?.b() 这个调用,在 a.b 不是一个函数时依然会报错,原因就是 Optional chaining 仅提供了对属性访问的安全保护,不代表对整个执行过程进行安全保护,该抛出异常还是会抛出异常,因此 Optional chaining 没有必要对后面的属性访问安全性负责。 笔者认为 TC39 对这个属性的理解是合理的,否则用 try catch 就能代替 Optional chaining 了。让一个特性仅实现分内的功能,是每个前端从业者都要具备的思维能力。 PS:笔者再多提一句,在任何技术设计领域,这个概念都适用。想想你设计的功能,写过的函数,如果为了图方便,扩大了其功能,终究会带来整体设计的混乱,适得其反。 边界情况 - 分组我们知道,JS 代码可以通过括号的方式进行分组,分组内的代码拥有更高的执行优先级。那么在 Optional chaining 场景下考虑这个情况: (a?.b).c(a == null ? undefined : a.b).c 与不带括号的进行对比: a?.b.ca == null ? undefined : a.b.c 我们会发现,由于括号提高了优先级,导致在 a 为 null/undefined 时,解析出了 undefined.c 这个必定报错的荒谬语法。因此我们不要试图为 Optional chaining 进行括号分组,这样会打破逻辑顺序,使安全保护不但不生效,反而导致报错。 Optional delete中文大概可以翻译为 “安全删除” 吧,也就是 JS 的 Optional chaining 支持下面的使用方式: delete a?.ba == null ? true : delete a.b 这样不论 b 是否存在,得到的都是 b 删除成功的信号(返回值 true)。 至于为什么要支持 Optional delete,草案里也有提到,笔者认为非常有意思: 讨论重点应该是 “我们为什么不支持 Optional delete”,而不是 “我们为什么要支持 Optional delete”,有点像反证法的思路。由于 Optional delete 具备一定的使用场景,而且支持方式零成本(改写为 a == null ? true : delete a.b 即可),所以就支持它吧! 不支持的特性下面三个特性不支持,原因是没什么使用场景: 安全的 construction:new a?.() 安全的 template literal:a?.`string` 上面两者的结合:new a?.b(), a?.b`string` 首先看 new 一个对象,如果 new 出来的结果是 undefined,那这个返回值使用起来也没有意义。 对于第二个安全的 template literal 来说,比如下面的语法: a?.b`c` 会被解析为 a == null ? undefined : a.b`c` 那么对于下面这种翻译结果: a == null ? undefined : a.b `c` 目前不会有人这么写代码,因为这种语法的使用场景一般都是 “前面的属性必定存在时的简化语法”,比如 styled-components 的: div` width: 300px;` 而如果解析为: (a == null ? undefined : a?.b) `c` 则更不会有人愿意尝试这种写法,所以安全的 template literal 这种需求是不存在的,自然第三种需求也是不存在的。 下面一个不支持的特性,虽然有一定使用场景,但依然被否定的: 安全的赋值:a?.b = c 讨论 ISSUE 笔者总结一下,一共有这几种令人烦恼的地方,导致大家不想支持 安全赋值 特性: 短路特性导致的理解成本: 比如 a?.b = c(),如果 a 为 null/undefined,那么函数 c() 就不会被执行,这种语法太违背开发者的常识,如果支持这个特性带来的理解负担会很大。 连带考虑场景很多: 如果支持了这种看似简单的赋值场景,那么至少还有下面五种赋值场景需要考虑到: 简单赋值: a?.b = c 聚合赋值: a?.b += c, a?.b >>= c 自增,自减: a?.b++, --a?.b 解构赋值: { x: a?.b } = c, [ a?.b ] = c for 循环中的临时赋值: for (a?.b in c), for (a?.b of c) 总和这几种考虑,支持安全赋值会带来更多灵活的用法,导致代码复杂度陡增(想想你的同事大量使用上面的后四种例子,你绝对想要找他决斗,因为这种写法和乱用 window 变量一样,在 JS 允许的框架内写出难以维护的逻辑,像是钻了法律的孔子),因此 TC39 决定不支持这种用法,从源头上杜绝被滥用。 以上不支持的功能点会在静态编译时被禁止,但以后也许会重新讨论。 另外对于 Class 的私有变量是否支持 a?.##b a?.##b() 还在讨论中,这取决于私有成员变量草案是否能最终落地。 暂不讨论的点目前有两个 Optional chaining 功能点暂不讨论,分别是 Optional spread 与 Optional destructuring 对于 Optional spread,建议是: const arr = [...?listOne, ...?listTwo];foo(...?args); 但由于可以结合 Nullish Coalescing 达到同样的效果: foo(...args ?? []) 所以暂时不深入讨论,因为存在意义不大。 对于 Optional destructuring,建议是: // const baz = obj?.foo?.bar?.baz; const { baz } = obj?.foo?.bar?; 也就是对于解构用法,在最后一个位置添加 ?,使其能安全的解构。 但由于基于这个特性会演变出太多的使用变体: ‪const {foo ?: {bar ?: {baz}}} = obj? 或者 const { foo?: { bar?: { baz } }} = obj; 对开发者的理解成本压力较大,毕竟 Optional chaining 的出发点只是 ?. 这么简单。而且对于默认值,我们又有 ?? 语法可以快速满足,因此这个特性的讨论也被搁置了。 余下的 Q&A大部分 Q&A 在上面的解读都有提及,下面列出剩余的两个 Q&A: 为什么语法是 ?. 而不是 .? ?原因是与三元运算符冲突了,思考下面的用法: 1.?foo : bar 在 js 中,1. 等价于 1,那么这就是一个标准的三元运算表达式,因此 .? 语法会产生歧义,只能选择 ?.。 为什么 null?.b 的结果不是 null 呢?由于 . 表达式不关心 . 前面对象的类型,因为它的目的是访问 . 后面的属性,因此不会因为 null?.b 就返回 null,而是统一返回 undefined。 最后,需要 TC39 最终审核后,Optional chaining 才能进入 Stage3,我们拭目以待吧! 3. 总结写一篇 JS 特性草案的完整解读真的很累,以后也许很少有机会这么完整的解读草案了,但希望借着这次解读 Optional chaining 的机会,让大家理解 TC39 是如何制定草案的,草案都在讨论什么,怎么讨论的,流程有哪些。 同时,还希望让大家意识到,为一个语言添加一个看似简单的新特性有多么的不容易,一个简单的 ?. 语法就牵涉到与三元运算符、分组、解构等等已存在语法的交织与冲突,所以想要安全又妥当的添加一个新特性,参与讨论的人必须对 JS 语言有完整全面的理解,同时也要对边界情况考虑的很周全,懂得对语法融会贯通。 最后,希望大家可以意识到,JS 这么重量级的语言,一个新的语法特性其实也是这么三言两语讨论下来的,其中不乏有一些拍脑袋的地方、对于“即可也可”的情况,稍稍结合一些具体案例就定下来其中一种的现象也是存在的,甚至对于某些规范点根本不存在一个完美的 “真理”,比如为什么语法是 ?. 而不是 a&.b(Ruby 使用的就是 &.),认清了这种情况存在,就不会执着于 “语法的学习”,而转向更底层,更有用的 “语义的学习”,并能通过阅读 TC39 的草案了解其他语言的实现差异,从而快速掌握其他语言的语法。 讨论地址是:精读《Optional chaining》 · Issue ##165 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Error Boundaries》","path":"/wiki/WebWeekly/前沿技术/《React Error Boundaries》.html","content":"当前期刊数: 148 1 引言Error Boundaries 是 React16 提出来用来捕获渲染时错误的概念,今天我们一起读一读 A Simple Guide to Error Boundaries in React 这篇文章,了解一下这个重要机制。 2 概述Error Boundaries 可以用来捕获渲染时错误,API 如下: 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 官方文档 里提到了四种无法 Catch 的错误场景: 回调事件。由于回调事件执行时机不在渲染周期内,因此无法被 Error Boundary Catch 住,如有必要得自行 try/catch。 异步。比如 setTimeout 或 requestAnimationFrame,和第一条同理。 服务端渲染。 Error Boundary 组件自身触发的错误。因为只能捕获其子组件的错误。 这也是使用 Error Boundaries 最容易有疑问的地方。除了上面的情况,笔者结合自身经验再列举几种异常边界场景。 无法捕获编译时错误很明显,即便是 React 官方 API Error Boundary 也只能捕获运行时错误,而对编译时错误无能为力。 编译时错误包括不限于编译环境错误、运行前的框架错误检查提示、TS/Flow 类型错误等,这些都是 Error Boundary 无法捕获的,而且没有更好的办法 Catch 住,遇到编译错误就在编译时解决吧,仅关注运行时错误就好了。 可以作用于 Function Component虽然函数式组件无法定义 Error Boundary,但 Error Boundary 可以捕获函数式组件的错误,因此可以曲线救国: // ErrorBoundary 组件class ErrorBoundary extends React.Component { // ...}// 可以捕获所有组件异常,包括 Function Component 的子组件const App = () => { return ( <ErrorBoundary> <Child /> </ErrorBoundary> );}; 对 Hooks 也可生效对于 Hooks 中异常也可以生效,比如下面的代码: 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): 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 有很多宝藏,建议抽时间逐条阅读。 4 总结Error Boundary 可以捕获所有子元素渲染时异常,包括 render、各生命周期函数,但也有很多使用限制,希望你可以正确使用它。 错误捕获也不是万能的,更多时候我们要避免并及时修复错误,通过错误捕获降低出错时对用户体验的影响,并在第一时间内监控起来并快速修复。 最后,你有明明正确使用了 Error Boundary 却依然无法 Catch 住的错误 Case 吗? 讨论地址是:精读《React Error Boundaries》 · Issue ##246 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Hooks 数据流》","path":"/wiki/WebWeekly/前沿技术/《React Hooks 数据流》.html","content":"当前期刊数: 146 1 引言React Hooks 渐渐被国内前端团队所接受,但基于 Hooks 的数据流方案却还未固定,我们有 “100 种” 类似的选择,却各有利弊,让人难以取舍。 本周笔者就深入谈一谈对 Hooks 数据流的理解,相信读完文章后,可以从百花齐放的 Hooks 数据流方案中看到本质。 2 精读基于 React Hooks 谈数据流,我们先从最不容易产生分歧的基础方案说起。 单组件数据流单组件最简单的数据流一定是 useState: function App() { const [count, setCount] = useState();} useState 在组件内用是毫无争议的,那么下个话题就一定是跨组件共享数据流了。 组件间共享数据流跨组件最简单的方案就是 useContext: 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 已经为你想好解决方案了。 数据流与组件解耦unstated-next 可以帮你把上面例子中,定义在 App 中的数据单独出来,形成一个自定义数据管理 Hook: 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,毫无争议: 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 的方式合并更新。 然而还有性能问题: function ChildCount() { const { count } = Counter.useContainer();}function ChildFoo() { const { foo } = Counter.useContainer();} 更新 foo 时,ChildCount 和 ChildFoo 同时会执行,但 ChildCount 没用到 foo 呀?这个原因是 Counter.useContainer 提供的数据流是一个引用整体,其子节点 foo 引用变化后会导致整个 Hook 重新执行,继而所有引用它的组件也会重新渲染。 此时我们发现可以利用 Redux useSelector 实现按需更新。 按需更新首先我们利用 Redux 对数据流做一次改造: 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 的引用是不变的,但对于其他场景就不一定了。 举个例子: function Child() { const user = useSelector((state) => ({ user: state.user }), shallowEqual); return <UserPage user={user} />;} 假设 user 对象在每次数据流更新引用都会发生变化,那么 shallowEqual 自然是不起作用,那我们换成 deepEqual深对比呢?结果是引用依然会变,只是重渲染不那么频繁了: 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 引用不会频繁改变: function Child() { const user = useSelector( (state) => ({ user: state.user }), // 当 user 值变化时才重渲染 deepEqual ); const userDeep = useDeepMemo(() => user, [user]); return <UserPage user={userDeep} />;} 当然这是比较极端的情况,只要看到 deepEqual 与 useSelector 同时作用了,就要问问自己其返回的值的引用会不会发生意外变化。 缓存查询函数对于极限场景,即便控制了重渲染次数与返回结果的引用最大程度不变,还是可能存在性能问题,这最后一块性能问题就处在查询函数上。 上面的例子中,查询函数比较简单,但如果查询函数非常复杂就不一样了: 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 根据参数引用进行缓存。 想象一下,如果 state.user 的引用不频繁变化,但 verySlowFunction 非常慢,理想情况是 state.user 引用变化后才重新执行 verySlowFunction,但上面的例子中,useSelector 并不知道还能这么优化,只能傻傻的每次渲染重复执行 verySlowFunction,哪怕 state.user 没有变。 此时我们要告诉引用,state.user 是否变化才是重新执行的关键: 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 函数: 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 实例,因此这个函数还需要再包装一层高阶形态: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Conf 2019 - Day2》","path":"/wiki/WebWeekly/前沿技术/《React Conf 2019 - Day2》.html","content":"当前期刊数: 129 1 引言这是继 精读《React Conf 2019 - Day1》 之后的第二篇,补充了 React Conf 2019 第二天的内容。 2 概述 & 精读第二天的内容更为精彩,笔者会重点介绍比较干货的部分。 Fast refreshFast refresh 是更好的 react-hot-loader 替代方案,目前仅支持 react-native 平台,很快就会支持 react-dom 平台。 相比不支持 Function component、无法错误恢复、更新经常失灵的 hot reloading 来说,fast refresh 还拥有以下几个优点: 状态保持。 支持 Function Component Hooks。 更快的更新速度。 Fast refresh 更新速度更快,是基于 Function Component 生成了 “签名”,从而最大成都避免销毁重渲染,尽可能保持对组件的 rerender 刷新。下面介绍签名机制的工作原理。 Fast refresh 对每个 Function component 都生成了一份专属签名,用以描述这个组件核心状态,当这个核心状态改变时,就只能销毁重渲染了,但对于不触及核心的修改就能进行代价非常小的 rerender。 这个签名包含了 hooks 和参数名: // signature: "useState{isLoggedIn}"function ExampleComponent() { const [isLoggedIn, setIsLoggedIn] = useState(true);} 比如当参数名变更时,这个组件的逻辑已发生改动,此时只能销毁并重渲染了。因此实际上通过对签名的对比来判断是否要销毁并重刷新组件: // signature: "useState{isLoggedOut}"function ExampleComponent() { const [isLoggedOut, setIsLoggedOut] = useState(true);} 同理,当 hooks 从 useState 改成了 useReducer,签名也会发生变化从而导致彻底的重渲染。 但除此之外,比如对样式的修改、Dom 结构的修改都不会触发签名的变化,从而保证了 “对不触及逻辑的改动进行高效的轻量 renreder”。 然而 Fast refresh 也有如下局限性: 还不能友好支持 Class component。 混合导出 React 和非 React 组件时无法精确的 hot reload。 更高的内存要求。 可以看到,Fast Refresh 随着功能推广与内置,现在已经覆盖了 Facebook 95% 以上 hot reload 场景了: 这部分内容不仅揭开了 hot reload 技术内幕,还对其功能进行了进一步优化,2019 年的 React 开发体系已经进入精细化阶段。 重写 React devtoolsReact devtools 的更新终于被正式介绍了,本来笔者以为新的 devtools 只是支持了 hooks,但听完分享后发现还有更多有用的改进,包括: 更高的性能。 更多特性支持。 更好用户体验。 找到节点渲染链路 并不是每个 React 节点都参与渲染,新版 React devtools 可以展示出 rendered by: 调试 Suspense 在 Day1 中讲到的 Suspense 特性可以在 React devtools 调试了: 通过点击时钟 icon,可以模拟 Suspense 处于 pendding 或 ready 状态。 增强调试能力 可以通过点击直接跳转到组件源码: 最新版已增强至点击按钮后直接通过 Source 打开源码位置,这样可以快速通过 UI 寻找到代码。同时还可以看到,通过点击 debugger 按钮将当前组件信息打到控制台调试。 除此之外还可以动态修改组件的 props 与 hook state,大大增强了调试能力。 profiler 分析工具也得到了增强,现在可以看到每个组件被渲染了几次以及重新渲染的原因: 比如上图组件被渲染了 4 次,主要有两个原因:Hooks 改变与 Props 改变。 除此之外,还优化了更多细节体验,比如高亮搜索、HOC 的展示优化、嵌套层级过多时不会占用过多的横向宽度等等。 react codemodcodemod 是一个代码重构的方式,通过 AST 方式精准触达代码,我们可以认为 codemod 是一个更聪明的“查找/替换”。 codemod 主要有以下三种使用方式: 重命名。 代码排序。 一定程度的代码替换。 接下来就讲到 react codemod 了,它是 react 场景的 codemod 解决方案,facebook 是这么使用 react codemod 的: 迁移 facebook 代码。 涉及几万个组件。 修复了 3500 个文件的 React.PropTypes。 修复了 8500 个文件的生命周期 unsafe。 修复了 20000 个文件的 createClass 转 JSX。 使用方式: npx react-codemod React-PropTypes-to-prop-types 可以看到,通过 cli 对文件进行一次性重构处理。除此之外,再列举几种使用场景: create-element-to-jsx 将 React.createElement 转换为 JSX。 error-boundaries 将 unstable_handleError 改为 componentDidCatch。 findDOMNode 将 React.createClass 中 this.getDOMNode() 改为 React.findDOMNode。 sort-comp 将 Class Component 生命周期按照规范排序,eslint-plugin-react 插件也有相同能力。 理论上来讲,所有 codemode 做的事情都可以替换为 eslint 的 autofix 来完成,比如 sort-comp 就同时被 codemode 和 eslint 支持。 Suspense要理解 Suspense,就要理解 Suspense 与普通 loading 有什么区别。 从代码角度来说,Suspense 可以类比为 try/catch 的体验。为了简化代码复杂度,我们可以用 try/catch 包裹代码,从而简化 try 区块代码复杂度,并将兜底代码放在 catch 区块: try { // 只要考虑正确情况} catch { // 错误时 fallback} Suspense 也一样,它在渲染 React 组件时如果遇到了 Promise 抛出的 Error,就会进入 fallback,所以 fallback 含义是 Loading 中状态: <Suspense fallback={<Spinner />}> <ProfilePage /></Suspense> 与此同时,实际业务组件中的取数也不需要担心取数是否正在进行中,只要直接处理拿到数据的情况就好了: function ProfileDetails() { // 直接使用 user,不用担心失败。 const user = resource.user.read(); return <h1>{user.name}</h1>;} 进一步的,如果要处理组件渲染的异常,再使用 ErrorBoundary 包裹即可,此时的 fallback 含义是组件加载异常的错误状态: function Home(props) { return ( <ErrorBoundary fallback={<ErrorMessage />}> <Suspense fallback={<Placeholder />}> <Composer /> </Suspense> </ErrorBoundary> );} Suspense 模式的取数好处是 “fetch on render”,即渲染与取数同时进行,而普通模式的取数是 “fetch after render”,即渲染完成后再通过 useEffect 取数,此时取数时机已晚。 队列加载 假设 Composer 与 NewsFeed 组件内部都通过 useQuery 取数,那么并行取数时加载机制如下: 这可能有两个问题:组件内部加载顺序不统一与组件间加载顺序不统一。 如果组件内部有图片,可能图片与组件渲染实际不一致,此时可以利用 Suspense 统一 hold 所有子组件的特性,将图片加载改为 Suspense 模式: <div> <YourImage src={uri} alt={...} /> <MoreComposer /></div> 同一个 Suspense 可以等待所有子元素都 Ready 后才会一把渲染出 UI,因此可以看到网页被一次性刷新而不是分部刷新。 第二个问题是组件间加载顺序不统一,可能导致先渲染了文章内容,再渲染出文章头部,此时如果区块高度不固定,文章头部可能会撑开,导致文章内容下移,用户的阅读体验会遭到打断。可以通过 suspense ordering 解决这个问题: function Home(props) { return ( <SuspenseList revealOrder="forwards"> <Suspense fallback={<ComposerFallback />}> <Composer /> </Suspense> <Suspense fallback={<FeedFallback />}> <NewsFeed /> </Suspense> </SuspenseList> );} 比如 forwards 表示从上到下,那么一定会先渲染头部再渲染文章内容,这样文章内容就不会都抖动了。 Render as you fetch相比 “fetch on render”,更高级别的优化是 “Render as you fetch”,即取数在渲染时机之前。 比如页面路由的跳转、Hover 到一个区块,此时如果取数由这个动作触发,就可以再次将取数时机提前,Facebook 为此创造了一个新的 Hook:usePreloadedQuery。 用法是,在某个事件中取数,比如点击页面跳转按钮时,通过 preloadQuery 预取数,得到的结果并不是取数结果,而是一个标识,在渲染组件中,把这个标识传给 usePreloadedQuery 可以拿到真实取数结果: // 组件 A 的 onClickconst reference = preloadQuery(query, variables);// 组件 B 的 renderconst data = usePreloadedQuery(query, reference); 可以看到,取数真正触发的时机在渲染函数执行之前,所以在 usePreloadedQuery 调用时取数肯定已经在路上,甚至已经完成。相比之下,普通的 useQuery 函数存在下面几个问题: 由于取数过程存在状态变化,可能导致组件在 “取数无意义” 状态下重新渲染多次。 可能取数还未完成就触发重渲染。 没有取消的机制,没有清除结果的机制。 没有办法唯一标识组件。 preloadQuery 的好处就是将取数时机与 UI 分离,这样可以更细粒度的控制逻辑: 调用 preloadQuery 时: 在组件销毁时取消取数。 有新取数触发时取消取数。 销毁一些轮询机制。 渲染组件调用 usePreloadedQuery 时: 不会再触发取数,不会触发意外的 re-render。 不需要清空,因为取数不在这里发起。 不需要清理轮询。 可见 preloadQuery 相比 useQuery 的确有了一些体验提升,然而这个优化比较追求极致,对大部分国内项目来说可能还走不到 facebook 这么极致的性能优化,所以投入产出比显得不是那么高,而且这个开发方式对开发者不是太友好,因为它让请求的时机割裂到两个模块中。 但毕竟用户体验是大于开发者体验的,React 尽量通过提高开发者体验来间接提高用户体验,使双方都满意,但像 preloadQuery 就无法两者兼顾了,为了用户体验可以适当的降低一些开发者体验。 如何维护代码这个分享讲述了如何提升代码维护效率,毕竟一个月后可能连自己写的代码都看不懂了。hydrosquall 通过类比地图的方式解释了程序员是如何维护代码的。 首先看我们是如何认路的。认路分为三个层次: 随意走走。 通过一些地标判断方向。 有方向的寻路。 通过跟随同伴或者了解更多本地信息找到目的地。 地图。 通过 GPS 定位。 通过模拟地图方式指出路线。 可以看到这三种方式是逐层递进的,那么类比到代码就有意思了: 随意走走(滚动查看源代码 + ctrl/f 查找代码 + grep 搜索)。 入口(找到入口节点,查看数据结构)。 标记(查看代码注释、查看 README)。 发信号弹(断点、console.log 等调试行为) 找到方向。 git blame 查看 owner,或直接根据文档找到 codeowners。 地图。 幸运的话你可以找到一份架构流程图。 可以看到,地图有几种抽象层次,比如忽略了细节的纽约地铁线路图: 或者是包含丰富地面信息的地铁线路图: 抽象到什么层次取决于用户使用的场景,那么代码抽象也是如此。hydrosquall 做了一个工具自动分析出代码调用关系:js-callgraph 这就像路牌一样,可以更高效的看出代码结构,也包括了数据流结构,由于篇幅限制,感兴趣的同学可以看 原视频 了解更多。 写作与写代码本章讲了写作(小说)与写代码的关联,总结出如下几个重点: 写小说和写代码都是创造行为。 写代码需要抽象思维,写小说也要有抽象思维构造人物和情节。 Show, don’t tell,写作天然就是申明式的,和数据驱动很相似。 更多可以去看 原视频。 移动端动画最佳实践首先要使用一个真实的手机设备调试,否则可能出现 PC Chrome 一切正常,而手机上实际效果性能很差的情况! 手势下拉退出 利用 react-spring 和 react-use-gesture 做一个下滑消失的 Demo: import { animated, useSpring } from "react-spring";import { useDrag } from "react-use-gesture";const [{ y }, set] = useSpring(() => { y: 0;}); 首先定义一个 y 纵向位置,通过 useDrag 将拖拽操作与 UI 绑定,通过回调将其与 y 数据绑定: const bind = useDrag(({ last, movement: [, movementY], memo = y.value }) => { if (last) { // 拖拽结束时,如果偏移量超过 50 则效果和结束一样,直接将 y 设置为 100 const notificationClosed = movementY > 50; return set({ y: notificationClosed ? 100 : 0, onReset: notificationClosed && removeNotification }); } // y 的位置区间在 0~100 set([{ y: clamp(0, 100, memo + movementY) }]); return memo;}); 将 useDrag 与 y 绑定后,就可以用在 UI 组件上了: <StyledNotification as={animated.div} onTouchStart={bind().onTouchStart} style={{ opacity: y.interpolate([0, 100], [1, 0]), transform: y.interpolate(y => `translateY(${y}px)`) }}/> 将 opacity 与 transform 与位置 y 绑定就可以做出下拉消失的效果。 滑动的洞见 接着讲到了滑动的三个洞见: 要立刻响应,任何延迟都会造成用户额外精神负担。 滚动速度衰减可以提升用户体验: 接着我们需要预测用户的意图,比如在一个类似微信消息列表页左右滑动时: 是否想取消手势交互? 是否想展示出更多交互按钮? 是否想删除所有内容? 这需要更多设计思考。 橡皮筋滚动,即列表页可以一直向下拉,上面部分像橡皮筋一样可以被拉出空白页的效果。 在设计手势动画时要考虑三个要点: 使用移动增量作为手势动画的基准点。 动画和手势应该随时可以被中断,通过 springs 即可实现。 完成手势后的动画速度应该与手势速度相当,这样视觉体验更自然。 最后提到了动画兼容性与性能,比如尽量只使用 transform 与 opacity 可以保证移动端的流畅度,不同移动设备的默认手势效果不同,最好通过 touch-action 禁用默认行为以达到更好的兼容性与效果。 唱片与 ReactJ.Dash 拥有十年软件开发经验,同时也卖过很多唱片,他介绍了唱片行业与软件开发的共同点。 唱片行业需要音乐编排能力,这与编码能力类似,都存在良好的设计模式,并且需要团队合作,开发过程中会遇到一些痛苦的经历,但最终完成音乐和项目时都会获得满足的喜悦。 函数式编程 Declaratives UIs are the future, and the future is Comonadic. - Phil Freeman 申明式 UI 是未来,未来则是 Comonadic。 所谓申明式 UI 可以用下面的公式表达: type render = (state: State) => View; 然后用一段公式介绍了 Comonadic: class Functor w => Comonad w where extract :: w a -> a duplicate :: w a -> w (w a) extend :: (w a -> a) -> w a -> w b 用 JS 版本做一个解释: const Store = ({ state, render }) => ({ extend: f => Store({ state, render: state => f(Store({ state, render })) }), extract: () => render(state)}); extract 调用后会进行申明式渲染 UI,即 render(state)。 extend 表示拓展,接收一个拓展函数作为参数,返回一个新的 Store 对象。这个拓展函数可以拿到 state、render 并返回新的 state 作为 extract 时 render 的输入。使用例子是这样的: const App = Store({ state: { msg: "World" }, render: ({ msg }) => <p>Hello {msg}</p>});App.extend(({ state }) => state.msg === "World" ? { msg: "ReactConf" } : state).extract(); // <p> Hello ReactConf </p> 然而尴尬的是,笔者看了很久也没看懂 Store 函数,最后运行了一下发现这个 Demo 抛出了异常 😂。 下面是笔者稍微修改后的例子,至少能跑起来: const Store = ({ state, render }) => ({ extend: f => Store({ state, render: state => render(f({ state, render })) }), extract: () => render(state)});const app = Store({ state: { msg: "Hello World" }, render: ({ msg }) => console.log("render " + msg)});app .extend(({ state }) => { return { msg: state.msg + " extend1" }; }) .extend(({ state }) => { return { msg: state.msg + " extend2" }; }) .extract(); // render Hello World extend2 extend1 然而作者的意思仍是未解之谜,希望对函数式了解的同学可以在评论区指点一下。 wick editorwick editor 是一个开源的动画、游戏制作软件。 wick editor 是一个动画制作工具,但拓展了一些 js 编程能力,因此可以很好的将动画与游戏结合在一起: 演讲介绍了 wick editor 的演化过程: 从很简陋的 MVP 版本开始(1 周) 到 Pre-Alpha(4 月) Alpha(5 月) Beta(1.5 年) 重点是 1.0 版本采用 React 重写了!继 Beta 之后又经历了 1 年: 这个团队最棒的地方是,将游戏与教育结合,针对不同场景做了很多用户调研并根据反馈持续改进。 React Selectreact-select 的作者 Jed Watson 被请来啦。作为一个看上去很简单组件(select)的开发者,却拥有如此大的关注量(1.8w star),那作者有着怎样的心路历程呢? react-select 看似简单的名字背后其实有挺多的功能,比如作者列举了一些功能层面的内容: autocomplete - 输入时搜索。 单、多选。 focus 管理。 下拉框层级与位置,比如可以放在根 DOM 节点,也可以作为当前节点的子元素。 异步下拉框内容。 键盘、触控。 Createble,即在搜索时如果没有内容可以动态创建。 等等。 在设计层面: 申明式。 可以被定制。 性能要求。 等等。 随着 Star 逐渐上涨,越来越多的需求被提出,核心库代码量越来越大,甚至许多需求之间都是相互冲突的,而且作者每天都会被上百个 Issue 与 PR 吵醒。做一个业务 Select 可能只要 5 分钟,但做一个开源 Select 却要 5 年,原因是一个简单的 Select 如何满足所有不同业务场景?这绝对是个巨大的挑战。 比如用户即需要受控也要非受控的组件,如何满足好这个需求同时又让代码更可维护呢? 假设我们拥有一个受控的组件 SelectComponent,那么它的主要 props 是 value 与 onChange,如果要拓展成一个既支持 defaultValue(非受控)又支持 value(受控)的组件,我们可以创建一个 manageState 组件对 SelectComponent 进行封装: const manageState = SelectComponent => ({ value: valueProps, onChange: onChangeProp, defaultValue, ...props}) => { const [valueState, setValue] = useState(defaultValue); const value = valueProps !== undefined ? valueProps : valueState; const onChange = (newValue, actionMeta) => { if (typeof onChangeProp === "function") { onChangeProp(newValue, actionMeta); } setValue(newValue); }; return <SelectComponent {...props} value={value} onChange={onChange}>}; 这样就可以组合为一个受控/非受控的综合 Select 组件: import BaseSelect from "./Select";import manageState from "./manageState";export default manageState(Select); 同理对异步的封装也可以放在 makeAsync 函数中: const makeAsync = SelectComponent => ({ getOptions, defaultOptions, ...props}) => { const [options, setOptions] = useState(defaultOptions); const [isLoading, setIsLoading] = useState(false); const onInputChange = async newValue => { setIsLoading(true); const newOptions = await getOptions(newValue); setIsLoading(false); setOptions(newOptions); }; return ( <SelectComponent {...props} options={options} isLoading={isLoading} onInputChange={onInputChange} /> );}; 可以看到,SelectComponent 是一个完全受控的数据驱动的 UI,无论是 manageState 还是 makeAsync 都是对数据处理的拓展,所以这三者之间才可以融洽的组合: import BaseSelect from "./Select";import manageState from "./manageState";import makeAsync from "./async";export default manageState(Select);export const AsyncSelect = manageState(makeAsync(Select)); 后面还有一些风格化、开源协作的思考,这里就不展开了,对这部分感兴趣的同学可以查看原视频了解更多。 React + 政府财政透明项目usaspending.gov 这个网站使用 React 建设,可以查看美国政府支持财政的明细,通过流畅的体验让更多用户可以了解国家财政支出,进一步推动财政支出的透明化。由于并不涉及前端技术的介绍,主要是产品介绍,因此精读就不详细展开了。 顺便说一句,智能分析数据就用 QuickBI,QuickBI 是我们团队研发的一款智能 BI 服务平台,如果你将美国政府的财政支持作为数据集输入,你会分析得更透彻。 React + 星舰模拟器最后介绍的是使用 React 制作的星舰模拟器,看上去像一个游戏: 有星系图、船体、驾驶员信息、武器装备、燃料、通信等等内容。甚至可以模拟太空驾驶,进行任务,可以实时多人协同。对太空迷们的吸引力很大,感兴趣的同学建议直接观看 视频。 3 总结第二天的内容非常全面,涉及了 React API、开发者周边、codemod 工具、代码维护、写作/音乐与代码、动画、函数式编程、看似简单的 React 组件、使用 React 制作的各种脑洞大开的项目,等等。 React Conf 要展示的是一个完整的 React 世界,第一天提到了 React 是一个桥梁,正因为这个桥梁,连接了各行各业不同的人群以及不同的项目,大家都有一个共同的语言:React。 “We not only react code, but react the world”。 讨论地址是:精读《React Conf 2019 - Day2》 · Issue ##217 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Hooks 最佳实践》","path":"/wiki/WebWeekly/前沿技术/《React Hooks 最佳实践》.html","content":"当前期刊数: 120 简介React 16.8 于 2019.2 正式发布,这是一个能提升代码质量和开发效率的特性,笔者就抛砖引玉先列出一些实践点,希望得到大家进一步讨论。 然而需要理解的是,没有一个完美的最佳实践规范,对一个高效团队来说,稳定的规范比合理的规范更重要,因此这套方案只是最佳实践之一。 精读环境要求 拥有较为稳定且理解函数式编程的前端团队。 开启 ESLint 插件:eslint-plugin-react-hooks。 组件定义Function Component 采用 const + 箭头函数方式定义: const App: React.FC<{ title: string }> = ({ title }) => { return React.useMemo(() => <div>{title}</div>, [title]);};App.defaultProps = { title: 'Function Component'} 上面的例子包含了: 用 React.FC 申明 Function Component 组件类型与定义 Props 参数类型。 用 React.useMemo 优化渲染性能。 用 App.defaultProps 定义 Props 的默认值。 FAQ 为什么不用 React.memo? 推荐使用 React.useMemo 而不是 React.memo,因为在组件通信时存在 React.useContext 的用法,这种用法会使所有用到的组件重渲染,只有 React.useMemo 能处理这种场景的按需渲染。 没有性能问题的组件也要使用 useMemo 吗? 要,考虑未来维护这个组件的时候,随时可能会通过 useContext 等注入一些数据,这时候谁会想起来添加 useMemo 呢? 为什么不用解构方式代替 defaultProps? 虽然解构方式书写 defaultProps 更优雅,但存在一个硬伤:对于对象类型每次 Rerender 时引用都会变化,这会带来性能问题,因此不要这么做。 局部状态局部状态有三种,根据常用程度依次排列: useState useRef useReducer 。 useStateconst [hide, setHide] = React.useState(false);const [name, setName] = React.useState('BI'); 状态函数名要表意,尽量聚集在一起申明,方便查阅。 useRefconst dom = React.useRef(null); useRef 尽量少用,大量 Mutable 的数据会影响代码的可维护性。 但对于不需重复初始化的对象推荐使用 useRef 存储,比如 new G2() 。 useReducer局部状态不推荐使用 useReducer ,会导致函数内部状态过于复杂,难以阅读。 useReducer 建议在多组件间通信时,结合 useContext 一起使用。 FAQ 可以在函数内直接申明普通常量或普通函数吗? 不可以,Function Component 每次渲染都会重新执行,常量推荐放到函数外层避免性能问题,函数推荐使用 useCallback 申明。 函数所有 Function Component 内函数必须用 React.useCallback 包裹,以保证准确性与性能。 const [hide, setHide] = React.useState(false); const handleClick = React.useCallback(() => { setHide(isHide => !isHide)}, []) useCallback 第二个参数必须写,eslint-plugin-react-hooks 插件会自动填写依赖项。 发请求发请求分为操作型发请求与渲染型发请求。 操作型发请求操作型发请求,作为回调函数: return React.useMemo(() => { return ( <div onClick={requestService.addList} /> )}, [requestService.addList]) 渲染型发请求渲染型发请求在 useAsync 中进行,比如刷新列表页,获取基础信息,或者进行搜索, 都可以抽象为依赖了某些变量,当这些变量变化时要重新取数 : const { loading, error, value } = useAsync(async () => { return requestService.freshList(id);}, [requestService.freshList, id]); 组件间通信简单的组件间通信使用透传 Props 变量的方式,而频繁组件间通信使用 React.useContext 。 以一个复杂大组件为例,如果组件内部拆分了很多模块, 但需要共享很多内部状态 ,最佳实践如下: 定义组件内共享状态 - store.tsexport const StoreContext = React.createContext<{ state: State; dispatch: React.Dispatch<Action>;}>(null)export interface State {};export interface Action { type: 'xxx' } | { type: 'yyy' };export const initState: State = {};export const reducer: React.Reducer<State, Action> = (state, action) => { switch (action.type) { default: return state; }}; 根组件注入共享状态 - main.tsimport { StoreContext, reducer, initState } from './store'const AppProvider: React.FC = props => { const [state, dispatch] = React.useReducer(reducer, initState); return React.useMemo(() => ( <StoreContext.Provider value={{ state, dispatch }}> <App /> </StoreContext.Provider> ), [state, dispatch])}; 任意子组件访问/修改共享状态 - child.tsimport { StoreContext } from './store'const app: React.FC = () => { const { state, dispatch } = React.useContext(StoreContext); return React.useMemo(() => ( <div>{state.name}</div> ), [state.name])}; 如上解决了 多个联系紧密组件模块间便捷共享状态的问题 ,但有时也会遇到需要共享根组件 Props 的问题,这种不可修改的状态不适合一并塞到 StoreContext 里,我们新建一个 PropsContext 注入根组件的 Props: const PropsContext = React.createContext<Props>(null)const AppProvider: React.FC<Props> = props => { return React.useMemo(() => ( <PropsContext.Provider value={props}> <App /> </PropsContext.Provider> ), [props])}; 结合项目数据流参考 react-redux hooks。 debounce 优化比如当输入框频繁输入时,为了保证页面流畅,我们会选择在 onChange 时进行 debounce 。然而在 Function Component 领域中,我们有更优雅的方式实现。 其实在 Input 组件 onChange 使用 debounce 有一个问题,就是当 Input 组件 受控 时, debounce 的值不能及时回填,导致甚至无法输入的问题。 我们站在 Function Component 思维模式下思考这个问题: React scheduling 通过智能调度系统优化渲染优先级,我们其实不用担心频繁变更状态会导致性能问题。 如果联动一个文本还觉得慢吗? onChange 本不慢,大部分使用值的组件也不慢,没有必要从 onChange 源头开始就 debounce 。 找到渲染性能最慢的组件(比如 iframe 组件),对一些频繁导致其渲染的入参进行 useDebounce 。 下面是一个性能很差的组件,引用了变化频繁的 text (这个 text 可能是 onChange 触发改变的),我们利用 useDebounce 将其变更的频率慢下来即可: const App: React.FC = ({ text }) => { // 无论 text 变化多快,textDebounce 最多 1 秒修改一次 const textDebounce = useDebounce(text, 1000) return useMemo(() => { // 使用 textDebounce,但渲染速度很慢的一堆代码 }, [textDebounce])}; 使用 textDebounce 替代 text 可以将渲染频率控制在我们指定的范围内。 useEffect 注意事项事实上,useEffect 是最为怪异的 Hook,也是最难使用的 Hook。比如下面这段代码: useEffect(() => { props.onChange(props.id)}, [props.onChange, props.id]) 如果 id 变化,则调用 onChange。但如果上层代码并没有对 onChange 进行合理的封装,导致每次刷新引用都会变动,则会产生严重后果。我们假设父级代码是这么写的: class App { render() { return <Child id={this.state.id} onChange={id => this.setState({ id })} /> }} 这样会导致死循环。虽然看上去 <App> 只是将更新 id 的时机交给了子元素 <Child>,但由于 onChange 函数在每次渲染时都会重新生成,因此引用总是在变化,就会出现一个无限死循环: 新 onChange -> useEffect 依赖更新 -> props.onChange -> 父级重渲染 -> 新 onChange… 想要阻止这个循环的发生,只要改为 onChange={this.handleChange} 即可,**useEffect 对外部依赖苛刻的要求,只有在整体项目都注意保持正确的引用时才能优雅生效。** 然而被调用处代码怎么写并不受我们控制,这就导致了不规范的父元素可能导致 React Hooks 产生死循环。 因此在使用 useEffect 时要注意调试上下文,注意父级传递的参数引用是否正确,如果引用传递不正确,有两种做法: 使用 useDeepCompareEffect 对依赖进行深比较。 使用 useCurrentValue 对引用总是变化的 props 进行包装: function useCurrentValue<T>(value: T): React.RefObject<T> { const ref = React.useRef(null); ref.current = value; return ref;}const App: React.FC = ({ onChange }) => { const onChangeCurrent = useCurrentValue(onChange)}; onChangeCurrent 的引用保持不变,但每次都会指向最新的 props.onChange,从而可以规避这个问题。 总结如果还有补充,欢迎在文末讨论。 如需了解 Function Component 或 Hooks 基础用法,可以参考往期精读: 精读《React Hooks》 精读《怎么用 React Hooks 造轮子》 精读《useEffect 完全指南》 精读《Function Component 入门》 讨论地址是:精读《React Hooks 最佳实践》 · Issue ##202 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Hooks》","path":"/wiki/WebWeekly/前沿技术/《React Hooks》.html","content":"当前期刊数: 79 1 引言React Hooks 是 React 16.7.0-alpha 版本推出的新特性,想尝试的同学安装此版本即可。 React Hooks 要解决的问题是状态共享,是继 render-props 和 higher-order components 之后的第三种状态共享方案,不会产生 JSX 嵌套地狱问题。 状态共享可能描述的不恰当,称为状态逻辑复用会更恰当,因为只共享数据处理逻辑,不会共享数据本身。 不久前精读分享过的一篇 Epitath 源码 - renderProps 新用法 就是解决 JSX 嵌套问题,有了 React Hooks 之后,这个问题就被官方正式解决了。 为了更快理解 React Hooks 是什么,先看笔者引用的下面一段 renderProps 代码: function App() { return ( <Toggle initial={false}> {({ on, toggle }) => ( <Button type="primary" onClick={toggle}> Open Modal </Button> <Modal visible={on} onOk={toggle} onCancel={toggle} /> )} </Toggle> )} 恰巧,React Hooks 解决的也是这个问题: function App() { const [open, setOpen] = useState(false); return ( <> <Button type="primary" onClick={() => setOpen(true)}> Open Modal </Button> <Modal visible={open} onOk={() => setOpen(false)} onCancel={() => setOpen(false)} /> </> );} 可以看到,React Hooks 就像一个内置的打平 renderProps 库,我们可以随时创建一个值,与修改这个值的方法。看上去像 function 形式的 setState,其实这等价于依赖注入,与使用 setState 相比,这个组件是没有状态的。 2 概述React Hooks 带来的好处不仅是 “更 FP,更新粒度更细,代码更清晰”,还有如下三个特性: 多个状态不会产生嵌套,写法还是平铺的(renderProps 可以通过 compose 解决,可不但使用略为繁琐,而且因为强制封装一个新对象而增加了实体数量)。 Hooks 可以引用其他 Hooks。 更容易将组件的 UI 与状态分离。 第二点展开说一下:Hooks 可以引用其他 Hooks,我们可以这么做: import { useState, useEffect } from "react";// 底层 Hooks, 返回布尔值:是否在线function useFriendStatusBoolean(friendID) { const [isOnline, setIsOnline] = useState(null); function handleStatusChange(status) { setIsOnline(status.isOnline); } useEffect(() => { ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange); }; }); return isOnline;}// 上层 Hooks,根据在线状态返回字符串:Loading... or Online or Offlinefunction useFriendStatusString(props) { const isOnline = useFriendStatusBoolean(props.friend.id); if (isOnline === null) { return "Loading..."; } return isOnline ? "Online" : "Offline";}// 使用了底层 Hooks 的 UIfunction FriendListItem(props) { const isOnline = useFriendStatusBoolean(props.friend.id); return ( <li style={{ color: isOnline ? "green" : "black" }}>{props.friend.name}</li> );}// 使用了上层 Hooks 的 UIfunction FriendListStatus(props) { const status = useFriendStatusString(props); return <li>{status}</li>;} 这个例子中,有两个 Hooks:useFriendStatusBoolean 与 useFriendStatusString, useFriendStatusString 是利用 useFriendStatusBoolean 生成的新 Hook,这两个 Hook 可以给不同的 UI:FriendListItem、FriendListStatus 使用,而因为两个 Hooks 数据是联动的,因此两个 UI 的状态也是联动的。 顺带一提,这个例子也可以用来理解 对 React Hooks 的一些思考 一文的那句话:“有状态的组件没有渲染,有渲染的组件没有状态”: useFriendStatusBoolean 与 useFriendStatusString 是有状态的组件(使用 useState),没有渲染(返回非 UI 的值),这样就可以作为 Custom Hooks 被任何 UI 组件调用。 FriendListItem 与 FriendListStatus 是有渲染的组件(返回了 JSX),没有状态(没有使用 useState),这就是一个纯函数 UI 组件, 利用 useState 创建 ReduxRedux 的精髓就是 Reducer,而利用 React Hooks 可以轻松创建一个 Redux 机制: // 这就是 Reduxfunction useReducer(reducer, initialState) { const [state, setState] = useState(initialState); function dispatch(action) { const nextState = reducer(state, action); setState(nextState); } return [state, dispatch];} 这个自定义 Hook 的 value 部分当作 redux 的 state,setValue 部分当作 redux 的 dispatch,合起来就是一个 redux。而 react-redux 的 connect 部分做的事情与 Hook 调用一样: // 一个 Actionfunction useTodos() { const [todos, dispatch] = useReducer(todosReducer, []); function handleAddClick(text) { dispatch({ type: "add", text }); } return [todos, { handleAddClick }];}// 绑定 Todos 的 UIfunction TodosUI() { const [todos, actions] = useTodos(); return ( <> {todos.map((todo, index) => ( <div>{todo.text}</div> ))} <button onClick={actions.handleAddClick}>Add Todo</button> </> );} useReducer 已经作为一个内置 Hooks 了,在这里可以查阅所有 内置 Hooks。 不过这里需要注意的是,每次 useReducer 或者自己的 Custom Hooks 都不会持久化数据,所以比如我们创建两个 App,App1 与 App2: function App1() { const [todos, actions] = useTodos(); return <span>todo count: {todos.length}</span>;}function App2() { const [todos, actions] = useTodos(); return <span>todo count: {todos.length}</span>;}function All() { return ( <> <App1 /> <App2 /> </> );} 这两个实例同时渲染时,并不是共享一个 todos 列表,而是分别存在两个独立 todos 列表。也就是 React Hooks 只提供状态处理方法,不会持久化状态。 如果要真正实现一个 Redux 功能,也就是全局维持一个状态,任何组件 useReducer 都会访问到同一份数据,可以和 useContext 一起使用。 大体思路是利用 useContext 共享一份数据,作为 Custom Hooks 的数据源。具体实现可以参考 redux-react-hook。 利用 useEffect 代替一些生命周期在 useState 位置附近,可以使用 useEffect 处理副作用: useEffect(() => { const subscription = props.source.subscribe(); return () => { // Clean up the subscription subscription.unsubscribe(); };}); useEffect 的代码既会在初始化时候执行,也会在后续每次 rerender 时执行,而返回值在析构时执行。这个更多带来的是便利,对比一下 React 版 G2 调用流程: class Component extends React.PureComponent<Props, State> { private chart: G2.Chart = null; private rootDomRef: React.ReactInstance = null; componentDidMount() { this.rootDom = ReactDOM.findDOMNode(this.rootDomRef) as HTMLDivElement; this.chart = new G2.Chart({ container: this.rootDom, forceFit: true, height: 300 }); this.freshChart(this.props); } componentWillReceiveProps(nextProps: Props) { this.freshChart(nextProps); } componentWillUnmount() { this.chart.destroy(); } freshChart(props: Props) { // do something this.chart.render(); } render() { return <div ref={ref => (this.rootDomRef = ref)} />; }} 用 React Hooks 可以这么做: function App() { const ref = React.useRef(null); let chart: G2.Chart = null; React.useEffect(() => { chart = new G2.Chart({ container: ReactDOM.findDOMNode(ref.current) as HTMLDivElement, width: 500, height: 500 }); // do something chart.render(); return () => chart.destroy(); }, []); return <div ref={ref} />;} 可以看到将细碎的代码片段结合成了一个完整的代码块,更易维护。 现在介绍了 useState useContext useEffect useRef 等常用 hooks,更多可以查阅:内置 Hooks,相信不久的未来,这些 API 又会成为一套新的前端规范。 3 精读Hooks 带来的约定Hook 函数必须以 “use” 命名开头,因为这样才方便 eslint 做检查,防止用 condition 判断包裹 useHook 语句。 为什么不能用 condition 包裹 useHook 语句,详情可以见 官方文档,这里简单介绍一下。 React Hooks 并不是通过 Proxy 或者 getters 实现的(具体可以看这篇文章 React hooks: not magic, just arrays),而是通过链表实现的,每次 useState 都会改变下标,如果 useState 被包裹在 condition 中,那每次执行的下标就可能对不上,导致 useState 导出的 setter 更新错数据。 虽然有 eslint-plugin-react-hooks 插件保驾护航,但这第一次将 “约定优先” 理念引入了 React 框架中,带来了前所未有的代码命名和顺序限制(函数命名遭到官方限制,JS 自由主义者也许会暴跳如雷),但带来的便利也是前所未有的(没有比 React Hooks 更好的状态共享方案了,约定带来提效,自由的代价就是回到 renderProps or HOC,各团队可以自行评估)。 笔者认为,React Hooks 的诞生,也许来自于这个灵感:“不如通过增加一些约定,彻底解决状态共享问题吧!” React 约定大于配置脚手架 nextjs umi 以及笔者的 pri 都通过有 “约定路由” 的功能,大大降低了路由配置复杂度,那么 React Hooks 就像代码级别的约定,大大降低了代码复杂度。 状态与 UI 的界限会越来越清晰因为 React Hooks 的特性,如果一个 Hook 不产生 UI,那么它可以永远被其他 Hook 封装,虽然允许有副作用,但是被包裹在 useEffect 里,总体来说还是挺函数式的。而 Hooks 要集中在 UI 函数顶部写,也很容易养成书写无状态 UI 组件的好习惯,践行 “状态与 UI 分开” 这个理念会更容易。 不过这个理念稍微有点蹩脚的地方,那就是 “状态” 到底是什么。 function App() { const [count, setCount] = useCount(); return <span>{count}</span>;} 我们知道 useCount 算是无状态的,因为 React Hooks 本质就是 renderProps 或者 HOC 的另一种写法,换成 renderProps 就好理解了: <Count>{(count, setCount) => <App count={count} setCount={setCount} />}</Count>;function App(props) { return <span>{props.count}</span>;} 可以看到 App 组件是无状态的,输出完全由输入(Props)决定。 那么有状态无 UI 的组件就是 useCount 了: function useCount() { const [count, setCount] = useState(0); return [count, setCount];} 有状态的地方应该指 useState(0) 这句,不过这句和无状态 UI 组件 App 的 useCount() 很像,既然 React 把 useCount 成为自定义 Hook,那么 useState 就是官方 Hook,具有一样的定义,因此可以认为 useCount 是无状态的,useState 也是一层 renderProps,最终的状态其实是 useState 这个 React 内置的组件。 我们看 renderProps 嵌套的表达: <UseState> {(count, setCount) => ( <UseCount> {" "} {/**虽然是透传,但给 count 做了去重,不可谓没有作用 */} {(count, setCount) => <App count={count} setCount={setCount} />} </UseCount> )}</UseState> 能确定的是,App 一定有 UI,而上面两层父级组件一定没有 UI。为了最佳实践,我们尽量避免 App 自己维护状态,而其父级的 RenderProps 组件可以维护状态(也可以不维护状态,做个二传手)。因此可以考虑在 “有状态的组件没有渲染,有渲染的组件没有状态” 这句话后面加一句:没渲染的组件也可以没状态。 4 总结把 React Hooks 当作更便捷的 RenderProps 去用吧,虽然写法看上去是内部维护了一个状态,但其实等价于注入、Connect、HOC、或者 renderProps,那么如此一来,使用 renderProps 的门槛会大大降低,因为 Hooks 用起来实在是太方便了,我们可以抽象大量 Custom Hooks,让代码更加 FP,同时也不会增加嵌套层级。 5 更多讨论 讨论地址是:精读《React Hooks》 · Issue ##111 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《React Router v6》","path":"/wiki/WebWeekly/前沿技术/《React Router v6》.html","content":"当前期刊数: 145 1 引言React Router v6 alpha 版本发布了,本周通过 A Sneak Peek at React Router v6 这篇文章分析一下带来的改变。 2 概述 更名为 一个不痛不痒的改动,使 API 命名更加规范。 // v5import { 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: // v6import { BrowserRouter, Routes, Route } from "react-router-dom";function App() { return ( <BrowserRouter> <Routes> <Route path="/" element={<Home />} /> <Route path="profile/*" element={<Profile />} /> </Routes> </BrowserRouter> );} 升级在 v5 版本里,想要给组件传参数是不太直观的,需要利用 RenderProps 的方式透传 routeProps: 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 的拼接实现子路由: // v5import { 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 表示相对路径: // v6import { BrowserRouter, Routes, Route, Link, Outlet } from "react-router-dom";// Approach ##1function 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 placefunction 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 等操作: // v5import { useHistory } from "react-router-dom";function MyButton() { let history = useHistory(); function handleClick() { history.push("/home"); } return <button onClick={handleClick}>Submit</button>;} 而在 v6 版本中,可以通过 useNavigate 直接实现这个常用操作: // v6import { 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 } 参数指定: // v5history.push("/home");history.replace("/home");// v6navigate("/home");navigate("/home", { replace: true }); 更小的体积 8kb由于代码几乎重构,v6 版本的代码压缩后体积从 20kb 缩小到 8kb。 3 精读react-router v6 源码中有一段比较核心的理念,笔者拿出来与大家分享,对一些框架开发是大有裨益的。我们看 useRoutes 这段代码节选: 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 代码节选: 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 上: const Input = () => { // Input 组件在画布中会自动生成一个 id,但这个 id 组件无法通过 props 拿到}; 此时如果我们允许 Input 组件内部再创建一个子元素,又希望这个子元素的 id 是由 Input 推导出来的,我们可能需要用户这么做: const Input = ({ id }) => { return <ComponentLoader id={id + "1"} />;}; 这样做有两个问题: 将 id 暴露给 Input 组件,违背了之前设计的简洁性。 组件需要对 id 进行拼装,很麻烦。 这里遇到的问题和 React Router 遇到的一样,我们可以将代码简化成下面这样,但功能不变吗? const Input = () => { return <ComponentLoader id="1" />;}; 答案是可以做到,我们可以利用 Context 实现这种方案。关键点就在于,渲染 Input 但组件容器需要包裹一个 Provider: const ComponentLoader = ({ id, element }) => { <Context.Provider value={{ id }}>{element}</Context.Provider>;}; 那么对于内部的组件来说,在不同层级下调用 useContext 拿到的 id 是不同的,这正是我们想要的效果: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Router4","path":"/wiki/WebWeekly/前沿技术/《React Router4.html","content":"当前期刊数: 32 本期精读的文章是:React Router 进阶:嵌套路由,代码分割,转场动画等等。 懒得看文章?没关系,稍后会附上文章内容概述,同时,更希望能通过阅读这一期的精读,穿插着深入阅读原文。 1 引言 React Router4.0 出来之前,许多人都对其夸张的变化感到不适,但其实 4.0 说不定真的是一个非常正确的改动。 也许,说 4.0 不好的人,正是另一个消极版的小红点,希望这篇文章可以让大家意识到,React Router4.0 对大多数人真的很棒! 2 内容概要React Router4.0 正式版发布了,生态也逐渐完善了起来,是时候推一波与其完美结合的实用工具了! 代码分割通过 react-loadable,可以做到路由级别动态加载,或者更细粒度的模块级别动态加载: const AsyncHome = Loadable({ loader: () => import('../components/Home/Home'), loading: LoadingPage})<Route exact path="/" component={AsyncHome} /> 当然上面展示的是 ReactRouter 中的用法,AsyncHome 可以在任何 JSX 中引用,这样就提升到了模块级别的动态加载。 注意,无论是 webpack 的 Tree Shaking,还是动态加载,都只能以 Commonjs 的源码为分析目标,对 node_modules 中代码不起作用,所以 npm 包请先做好拆包。或者类似 antd 按照约定书写组件,并提供一种 webpack-loader 自动完成按需加载。 转场动画通过 React Router Transition (Ant Motion 也很好用) 可以实现路由级别的动画: <Router> <AnimatedSwitch atEnter={{ opacity: 0 }} atLeave={{ opacity: 0 }} atActive={{ opacity: 1 }} className="switch-wrapper" > <Route exact path="/" component={Home} /> <Route path="/about/" component={About}/> <Route path="/etc/" component={Etc}/> </AnimatedSwitch></Router> 并提供了一些生命周期的回调,具体可以参考文档。现在动画的思路比较靠谱的也大致是这种:通过添加/移除 class 的方式,利用 css3 做动效。 滚动条复位当页面回退时,将滚动条恢复到页面最顶部,可以让单页路由看起来更加正常。由于 React Router4.0 中,路由是一种组件,我们可以利用 componentDidUpdate 简单完成滚动条复位的功能: <Router history={history}> <ScrollToTop> <div> <Switch> <Route exact path="/" component={Home} /> <Route path="/about" component={About} /> <Route path="*" component={NotFound} /> </Switch> </div> </ScrollToTop></Router> @withRouterclass ScrollToTop extends Component { componentDidUpdate(prevProps) { if (this.props.location !== prevProps.location) { window.scrollTo(0, 0) } } render() { return this.props.children }} 非通过 Route 渲染的组件,可以通过 withRouter 拿到路由信息,仅当其为 Router 的子元素时有效。 嵌套路由React Router4.0 嵌套路由与 3.0 不同,是通过组件 Route 的嵌套实现的。 在任何组件,都可以使用如下代码实现嵌套路由: <Route path={`${this.props.match.url}/:id`} component={NestComponent} /> 这样将路由功能切分到各个组件中,我现在的项目甚至已经没有 route.js 文件了,路由由 layout 与各个组件自身承担。这种设计思路与 Nestjs 的描述性路由具有相同的思想 - 在 nodejs 中,我们可以通过装饰器,在任意一个 Action 上描述其访问的 URL: @POST("/api/service")async someAction() {} 服务端渲染浏览器端,需要一个专属的入口文件,使用 BrowserRouter 与 location 对接: <BrowserRouter> <App /></BrowserRouter> 服务器端,BrowserRouter 变成了 StaticRouter: renderToString( <StaticRouter location={req.url} context={context} > <html> <body> <App /> </body> </html> </StaticRouter>) 与浏览器不同的是,React Router 无法根据 location 自动判断当前所在页面,而需要你把 req.url 传给 StaticRouter,后续的路由渲染逻辑双端都是通用的。 如果存在跳转 Redirect,会通过 context.url 告诉你,所以后面会跟上跳转处理逻辑: if (context.url) { res.writeHead(301, { Location: context.url }) res.end()} else { res.write(markup) res.end()} 3 精读React Router 从 3.0 到 4.0 的改动,想来想去,认为是对于 URL 这个资源理解的变化。 URL 即浏览器地址,在前端数据化统一的浪潮下,其实 URL 也可以被看作是一种参数,在 React 中即一个 props 属性。 单页应用,如果从传统多页应用角度来思考,可能认为不过是一种体验的优化,或者是一种 “伪单页”,毕竟本质上单页应用只是一个页面而已。但换个角度想想,网站何尝不是一个整体,而网址的变化只是一种状态呢? 当我们做一个 Tabs 组件时,会发觉做得越来越像浏览器原生 tab,当用户给你提需求,在刷新浏览器时,能自动打开上一次打开的 Tab,我们的做法就是将当前打开的 Tab 信息保存在 URL 中,刷新时读取再切换过去。这证明了 URL 表示的就是一种状态。 而页面路由的状态化,是将模拟 Tab 的思路应用到了浏览器级别的 Tab。URL 是一种状态,在前端,可以通过浏览器地址自动获取,在后端,可以通过 req.url 获取,甚至可以手动传入来覆盖。 传统的开发思路:我们为每个 URL 编写独立的页面或者模块。 新的开发思路:URL 是一个状态,代码读取这个状态作出不同展现,展现得完全不同时,可以看作传统模式的页面切换;但还可以做到只有某一块区域展现得不同。 4. 总结也许 React Router4.0 带给我们的思考是,放下对网页“页面”的刻板印象,其实网站本没有页面,有的只是状态。 讨论地址是:精读《React Router4.0 进阶概念》》 · Issue ##43 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《React Server Component》","path":"/wiki/WebWeekly/前沿技术/《React Server Component》.html","content":"当前期刊数: 193 截止目前,React Server Component 还在开发与研究中,因此不适合投入生产环境使用。但其概念非常有趣,值得技术人学习。 目前除了国内各种博客、知乎解读外,最一手的学习资料有下面两处: Dan 的 Server Component 介绍视频 Server Component RFC 草案 我会结合这些一手资料,与一些业界大牛的解读,系统的讲清楚 React Server Component 的概念,以及我对它的一些理解。 首先我们来看,为什么需要提出 Server Component 这个概念: Server Component 概念的提出,是为了解决 “用户体验、可维护性、性能” 这个不可能三角,所谓不可能三角就是,最多同时满足两条,而无法三条都同时满足。 简单解释一下,用户体验体现在页面更快的响应、可维护性体现在代码应该高内聚低耦合、性能体现在请求速度。 保障 用户体验、可维护性,用一个请求拉取全部数据,所有组件一次性渲染。但当模块不断增多,无用模块信息不敢随意删除,请求会越来越大,越来越冗余,导致瓶颈卡在取数这块,也就是 性能不好。 保障 用户体验、性能,考虑并行取数,之后流程不变,那么以后业务逻辑新增或减少一个模块,我们就要同时修改并行取数公共逻辑与对应业务模块,可维护性不好。 保障 可维护性、性能,可以每个模块独立取数,但在父级渲染完才渲染子元素的情况下,父子取数就变成了串行,页面加载被阻塞,用户体验不好。 一言蔽之,在前后端解耦的模式下,唯一连接的桥梁就是取数请求。要把用户体验做好,取数就要提前并行发起,而前端模块是独立维护的,所以在前端做取数聚合这件事,必然会破坏前端可维护性,而这并行这件事放在后端的话,会因为后端不能解析前端模块,导致给出的聚合信息滞后,久而久之变得冗余。 要解决这个问题,就必须加深前端与后端的联系,所以像 GraphQL 这种前后端约定方案是可行的,但因为其部署成本高,收益又仅在前端,所以难以在后端推广。 Server Component 是另一种方案,通过启动一个 Node 服务辅助前端,但做的不是 API 对接,而是运行前端同构 js 代码,直接解析前端渲染模块,从中自动提取请求并在 Node 端直接与服务器通信,因为服务端间通信成本极低、前端代码又不需要做调整,请求数据也是动态按需聚合的,因此同时解决了 “用户体验、可维护性、性能” 这三个问题。 其核心改进点如下图所示: 如上图所示,这是前后端正常交互模式,可以看到,Root 与 Child 串行发了两个请求,因为网络耗时与串行都是严重阻塞部分,因此用红线标记。 Server Component 可以理解为下图,不仅减少了一次网络损耗,请求也变成了并行,请求返回结果也从纯数据变成了一个同时描述 UI DSL 与数据的特殊结构: 到此,恭喜你已经理解了 Server Component 核心概念,如果你只想泛泛了解一下,读到这里就可以结束了。如果你还想深入了解其实现细节,请继续阅读。 概述概括的说,Server Component 就是让组件拥有在服务端渲染的能力,从而解决不可能三角问题。也正因为这个特性,使得 Server Component 拥有几种让人眼前一亮的特性,都是纯客户端组件所不具备的: 运行在服务端的组件只会返回 DSL 信息,而不包含其他任何依赖,因此 Server Component 的所有依赖 npm 包都不会被打包到客户端。 可以访问服务端任何 API,也就是让组件拥有了 Nodejs 能拥有的能力,你理论上可以在前端组件里干任何服务端才能干的事情。 Server Component 与 Client Component 无缝集成,可以通过 Server Component 无缝调用 Client Component。 Server Component 会按需返回信息,在当前逻辑下,走不到的分支逻辑的所有引用都不会被客户端引入。比如 Server Component 虽然引用了一个巨大的 npm 包,但某个分支下没有用到这个包提供的函数,那客户端也不会下载这个巨大的 npm 包到本地。 由于返回的不是 HTML,而是一个 DSL,所以服务端组件即便重新拉取,已经产生的 State 也会被维持住。比如说 A 是 ServerComponent,其子元素 B 是 Client Component,此时对 B 组件做了状态修改比如输入一些文字,此时触发 A 重新拉取 DSL 后,B 已经输入的文字还会保留。 可以无缝与 Suspense 结合,并不会因为网络原因导致连 Suspense 的 loading 都不能及时展示。 共享组件可以同时在服务端与客户端运行。 三种组件Server Component 将组件分为三种:Server Component、Client Component、Shared Component,分别以 .server.js、.client.js、.js 后缀结尾。 其中 .client.js 与普通组件一样,但 .server.js 与 .js 都可能在服务端运行,其中: .server.js 必然在服务端执行。 .js 在哪执行要看谁调用它,如果是 .server.js 调用则在服务端执行,如果是 .client.js 调用则在客户端执行,因此其本质还要接收服务端组件的约束。 下面是 RFC 中展示的 Server Component 例子: // Note.server.js - Server Componentimport db from 'db.server'; // (A1) We import from NoteEditor.client.js - a Client Component.import NoteEditor from 'NoteEditor.client';function Note(props) { const {id, isEditing} = props; // (B) Can directly access server data sources during render, e.g. databases const note = db.posts.get(id); return ( <div> <h1>{note.title}</h1> <section>{note.body}</section> {/* (A2) Dynamically render the editor only if necessary */} {isEditing ? <NoteEditor note={note} /> : null } </div> );} 可以看到,这就是 Node 与 React 混合语法。服务端组件有着苛刻的限制条件:不能有状态,且 props 必须能被序列化。 很容易理解,因为服务端组件要被传输到客户端,就必须经过序列化、反序列化的过程,JSX 是可以被序列化的,props 也必须遵循这个规则。另外服务端不能帮客户端存储状态,因此服务端组件不能用任何 useState 等状态相关 API。 但这两个问题都可以绕过去,即将状态转化为组件的 props 入参,由 .client.js 存储,见下图: 或者利用 Server Component 与 Client Component 无缝集成的能力,将状态与无法序列化的 props 参数都放在 Client Component,由 Server Component 调用。 优点零客户端体积这句话听起来有点夸张,但其实在 Server Component 限定条件下还真的是。看下面代码: // NoteWithMarkdown.jsimport marked from 'marked'; // 35.9K (11.2K gzipped)import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)function NoteWithMarkdown({text}) { const html = sanitizeHtml(marked(text)); return (/* render */);} marked 与 sanitize-html 都不会被下载到本地,所以如果只有这一个文件传输,客户端的理论增加体积就是 render 函数序列化后字符串大小,可能不到 1KB。 当然这背后也是限制换来的,首先这个组件没有状态,无法在客户端实时执行,而且在服务端运行也可能消耗额外计算资源,如果某些 npm 包计算复杂度较高的话。 这个好处可以理解为,marked 这个包仅在服务端读取到内存一次,以后只要后客户端想用,只需要在服务端执行 marked API 并把输出结果返回给客户端,而不需要客户端下载 marked 这个包了。 拥有完整服务端能力由于 Server Component 在服务端执行,因此可以执行 Nodejs 的任何代码。 // Note.server.js - Server Componentimport fs from 'react-fs';function Note({id}) { const note = JSON.parse(fs.readFile(`${id}.json`)); return <NoteWithMarkdown note={note} />;} 我们可以把对请求的理解拔高一个层次,即 request 只是客户端发起的一个 Http 请求,其本质是访问一个资源,在服务端就是个 IO 行为。对于 IO,我们还可以通过 file 文件系统写入删除资源、db 通过 sql 语法直接访问数据库,或者 request 直接在服务器本地发出请求。 运行时 Code Split我们都知道 webpack 可以通过静态分析,将没有使用到的 import 移出打包,而 Server Component 可以在运行时动态分析,将当前分支逻辑下没有用到的 import 移出打包: // PhotoRenderer.jsimport React from 'react';// one of these will start loading *once rendered and streamed to the client*:import OldPhotoRenderer from './OldPhotoRenderer.client.js';import NewPhotoRenderer from './NewPhotoRenderer.client.js';function Photo(props) { // Switch on feature flags, logged in/out, type of content, etc: if (props.useNewPhotoRenderer) { return <NewPhotoRenderer {...props} />; } else { return <OldPhotoRenderer {...props} />; }} 这是因为 Server Component 构建时会进行预打包,运行时就是一个动态的包分发器,完全可以通过当前运行状态比如 props.xxx 来区分当前运行到哪些分支逻辑,而没有运行到哪些分支逻辑,并且仅告诉客户端拉取当前运行到的分支逻辑的缺失包。 纯前端模式与之类似的写法是: const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js'));const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js')); 只是这种写法不够原生,且实际场景往往只有前端框架把路由自动包一层 Lazy Load,而普通代码里很少出现这种写法。 无客户端往返的数据端取数一般考虑到取数网络消耗,我们往往会将其处理成异步,然后在数据返回前展示 Loading: // Note.jsfunction Note(props) { const [note, setNote] = useState(null); useEffect(() => { // NOTE: loads *after* rendering, triggering waterfalls in children fetchNote(props.id).then(noteData => { setNote(noteData); }); }, [props.id]); if (note == null) { return "Loading"; } else { return (/* render note here... */); }} 这是因为单页模式下,我们可以快速从 CDN 拿到这个 DOM 结构,但如果再等待取数,整体渲染就变慢了。而 Server Component 因为本身就在服务端执行,因此可以将拿 DOM 结构与取数同时进行: // Note.server.js - Server Componentfunction Note(props) { // NOTE: loads *during* render, w low-latency data access on the server const note = db.notes.get(props.id); if (note == null) { // handle missing note } return (/* render note here... */);} 当然这个前提是网络消耗敏感的情况,如果本身就是一个慢 SQL 查询,耗时几秒的情况下,这样做反而适得其反。 减少 Component 层次看下面的例子: // Note.server.js// ...imports...function Note({id}) { const note = db.notes.get(id); return <NoteWithMarkdown note={note} />;}// NoteWithMarkdown.server.js// ...imports...function NoteWithMarkdown({note}) { const html = sanitizeHtml(marked(note.text)); return <div ... />;}// client sees:<div> <!-- markdown output here --></div> 虽然在组件层面抽象了 Note 与 NoteWithMarkdown 两个组件,但由于真正 DOM 内容实体只有一个简单的 div,所以在 Server Component 模式下,返回内容就会简化为这个 div,而无需包含那两个抽象的组件。 限制Server Component 模式下有三种组件,分别是 Server Component、Client Component、Shared Component,其各自都有一些使用限制,如下: Server Component: ❌ 不能用 useState、useReducer 等状态存储 API。 ❌ 不能用 useEffect 等生命周期 API。 ❌ 不能用 window 等仅浏览器支持的 API。 ❌ 不能用包含了上面情况的自定义 Hooks。 ✅ 可无缝访问服务端数据、API。 ✅ 可渲染其他 Server/Client Component Client Component: ❌ 不能引用 Server Component。 ✅ 但可以在 Server Component 中出现 Client Component 调用 Server Component 的情况,比如 <ClientTabBar><ServerTabContent /></ClientTabBar>。 ❌ 不能调用服务端 API 获取数据。 ✅ 可以用一切 React 与浏览器完整能力。 Shared Component: ❌ 不能用 useState、useReducer 等状态存储 API。 ❌ 不能用 useEffect 等生命周期 API。 ❌ 不能用 window 等仅浏览器支持的 API。 ❌ 不能用包含了上面情况的自定义 Hooks。 ❌ 不能引用 Server Component。 ❌ 不能调用服务端 API 获取数据。 ✅ 可以同时在服务器与客户端使用。 其实不难理解,因为 Shared Component 同时在服务器与客户端使用,因此兼具它们的劣势,带来的好处就是更强的复用性。 精读要快速理解 Server Component,我觉得最好也是最快的方式,就是找到其与十年前 PHP + HTML 的区别。看下面代码: $link = mysqli_connect('localhost', 'root', 'root');mysql_select_db('test', $link);$result = mysql_query('select * from table');while($row=mysql_fetch_assoc($result)){ echo "<span>".$row["id"]."</span>";} 其实 PHP 早就是一套 “Server Component” 方案了,在服务端直接访问 DB、并返回给客户端 DOM 片段。 React Server Component 在折腾了这么久后,可以发现,最大的区别是将返回的 HTML 片段改为了 DSL 结构,这其实是浏览器端有一个强大的 React 框架在背后撑腰的结果。而这个带来的好处除了可以让我们在服务端能继续写 React 语法,而不用退化到 “PHP 语法” 以外,更重要的是组件状态得以维持。 另一个重要不同是,PHP 无法解析现在前端生态下任何 npm 包,所以无从解析模块化的前端代码,所以虽然直觉上感觉 PHP 效率与 Server Component 并无区别,但背后的成本是得写另一套不依赖任何 npm 包、JSX 的语法来返回 HTML 片段,Server Component 大部分特性都无法享受到,而且代码也无法复用。 所以,本质上还是 HTML 太简单了,无法适应如今前端的复杂度,而普通后端框架虽然后端能力强大,但在前端能力上还停留在 20 年前(直接返回 DOM),唯有 Node 中间层方案作为桥梁,才能较好的衔接现代后端代码与现代前端代码。 PHP VS Server Component其实在 PHP 时代,前后端都可以做模块化。后端模块化显而易见,因为可以将后端代码模块化的开发,最后打包至服务器运行。前端也可以在服务端模块化开发,只要我们将前后端代码剥离出来即可,下图青色是后端部分,红色是前端部分: 但这有个问题,因为后端服务对浏览器来说是无状态的,所以后端模块化本身就符合其功能特征,但前端页面显示在用户浏览器,每次都通过路由跳转到新页面,显然不能最大程度发挥客户端持续运行的优势,我们希望在保持前端模块化的基础上,在浏览器端有一个持续运行的框架优化用户体验,因此 Server Component 其实做了下面的事情: 这样做有两大好处: 兼顾了 PHP 模式下优势,即前后端代码无缝混合,带来一系列体验和能力增强。 前后端还是各自模块化编写,图中红色部分是随前端项目整体打包的,因此开发还是保留了模块化特点,且在浏览器上还保持了 React 现代框架运行,无论是单页还是数据驱动等特性都能继续使用。 总结Server Component 还没有成熟,但其理念还是很靠谱的。 想要同时实现 “用户体验、可维护性、性能”,重后端,或者重前端的方案都不可行,只有在前后端取得一种平衡才能达到。Server Component 表达了一种职业发展理念,即未来前后端还是会走向全栈,这种全栈是前后端同时做深,从而让程序开发达到纯前端或纯后端无法达到的高度。 2021 年国内开发环境依然比较落后,所谓全栈,往往指的是 “前后端都懂一点”,各端都做不深,难以孵化出 Server Component 这种概念。当然,这也是我们继续向世界学习的动力。 也许 PHP 与 Server Component 的区别,就是检验一个人是真全栈还是伪全栈的试金石,快去问问你的同事吧! 讨论地址是:精读《React Server Component》· Issue ##311 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React useEvent RFC》","path":"/wiki/WebWeekly/前沿技术/《React useEvent RFC》.html","content":"当前期刊数: 240 useEvent 要解决一个问题:如何同时保持函数引用不变与访问到最新状态。 本周我们结合 RFC 原文与解读文章 What the useEvent React hook is (and isn’t) 一起了解下这个提案。 借用提案里的代码,一下就能说清楚 useEvent 是个什么东西: function Chat() { const [text, setText] = useState(''); // ✅ Always the same function (even if `text` changes) const onClick = useEvent(() => { sendMessage(text); }); return <SendButton onClick={onClick} />;} onClick 既保持引用不变,又能在每次触发时访问到最新的 text 值。 为什么要提供这个函数,它解决了什么问题,在概述里慢慢道来。 概述定义一个访问到最新 state 的函数不是什么难事: function App() { const [count, setCount] = useState(0) const sayCount = () => { console.log(count) } return <Child onClick={sayCount} />} 但 sayCount 函数引用每次都会变化,这会直接破坏 Child 组件 memo 效果,甚至会引发其更严重的连锁反应(Child 组件将 onClick 回调用在 useEffect 里时)。 想要保证 sayCount 引用不变,我们就需要用 useCallback 包裹: function App() { const [count, setCount] = useState(0) const sayCount = useCallback(() => { console.log(count) }, [count]) return <Child onClick={sayCount} />} 但即便如此,我们仅能保证在 count 不变时,sayCount 引用不变。如果想保持 sayCount 引用稳定,就要把依赖 [count] 移除,这会导致访问到的 count 总是初始值,逻辑上引发了更大问题。 一种无奈的办法是,维护一个 countRef,使其值与 count 保持同步,在 sayCount 中访问 countRef: function App() { const [count, setCount] = useState(0) const countRef = React.useRef() countRef.current = count const sayCount = useCallback(() => { console.log(countRef.current) }, []) return <Child onClick={sayCount} />} 这种代码能解决问题,但绝对不推荐,原因有二: 每个值都要加一个配套 Ref,非常冗余。 在函数内直接同步更新 ref 不是一个好主意,但写在 useEffect 里又太麻烦。 另一种办法就是自创 hook,如 useStableCallback,这本质上就是这次提案的主角 - useEvent: function App() { const [count, setCount] = useState(0) const sayCount = useEvent(() => { console.log(count) }) return <Child onClick={sayCount} />} 所以 useEvent 的内部实现很可能类似于自定义 hook useStableCallback。在提案内也给出了可能的实现思路: // (!) Approximate behaviorfunction useEvent(handler) { const handlerRef = useRef(null); // In a real implementation, this would run before layout effects useLayoutEffect(() => { handlerRef.current = handler; }); return useCallback((...args) => { // In a real implementation, this would throw if called during render const fn = handlerRef.current; return fn(...args); }, []);} 其实很好理解,我们将需求一分为二看: 既然要返回一个稳定引用,那最后返回的函数一定使用 useCallback 并将依赖数组置为 []。 又要在函数执行时访问到最新值,那么每次都要拿最新函数来执行,所以在 Hook 里使用 Ref 存储每次接收到的最新函数引用,在执行函数时,实际上执行的是最新的函数引用。 注意两段注释,第一个是 useLayoutEffect 部分实际上要比 layoutEffect 执行时机更提前,这是为了保证函数在一个事件循环中被直接消费时,不可能访问到旧的 Ref 值;第二个是在渲染时被调用时要抛出异常,这是为了避免 useEvent 函数被渲染时使用,因为这样就无法数据驱动了。 精读其实 useEvent 概念和实现都很简单,下面我们聊聊提案里一些有意思的细节吧。 为什么命名为 useEvent提案里提到,如果不考虑名称长短,完全用功能来命名的话,useStableCallback 或 useCommittedCallback 会更加合适,都表示拿到一个稳定的回调函数。但 useEvent 是从使用者角度来命名的,即其生成的函数一般都被用于组件的回调函数,而这些回调函数一般都有 “事件特性”,比如 onClick、onScroll,所以当开发者看到 useEvent 时,可以下意识提醒自己在写一个事件回调,还算比较直观。(当然我觉得主要原因还是为了缩短名称,好记) 值并不是真正意义上的实时虽然 useEvent 可以拿到最新值,但和 useCallback 拿 ref 还是有区别的,这个差异体现在: function App() { const [count, setCount] = useState(0) const sayCount = useEvent(async () => { console.log(count) await wait(1000) console.log(count) }) return <Child onClick={sayCount} />} await 前后输出值一定是一样的,在实现上,count 值仅是调用时的快照,所以函数内异步等待时,即便外部又把 count 改了,当前这次函数调用还是拿不到最新的 count,而 ref 方法是可以的。在理解上,为了避免夜长梦多,回调函数尽量不要写成异步的。 useEvent 也救不了手残如果你坚持写出 onSomething={cond ? handler1 : handler2} 这样的代码,那么 cond 变化后,传下去的函数引用也一定会变化,这是 useEvent 无论如何也避免不了的,也许解救方案是 Lint and throw error。 其实将 cond ? handler1 : handler2 作为一个整体包裹在 useEvent 就能解决引用变化的问题,但除了 Lint,没有人能防止你绕过它。 可以用自定义 hook 代替 useEvent 实现吗?不能。虽然提案里给了一个近似解决方案,但实际上存在两个问题: 在赋值 ref 时,useLayoutEffect 时机依然不够提前,如果值变化后立即访问函数,拿到的会是旧值。 子组件 layout effect 在父组件之前执行,拿到的也是旧值。 生成的函数被用在渲染并不会给出错误提示。 总结useEvent 显然又给 React 增加了一个官方概念,在结结实实增加了理解成本的同时,也补齐了 React Hooks 在实践中缺失的重要一环,无论你喜不喜欢,问题就在那,解法也给了,挺好。 讨论地址是:精读《React useEvent RFC》· Issue ##415 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React 代码整洁之道》","path":"/wiki/WebWeekly/前沿技术/《React 代码整洁之道》.html","content":"当前期刊数: 34 本期精读的文章是:React 代码整洁之道。 1 引言编程也是艺术行为,当我们思考代码复用、变量命名时,就是在进行艺术思考。 可能这篇文章没法提高面试能力、开发效率,因为涉及的内容都是 “软能力”。但如果与我一样,时常害怕自己代码不够优雅,那就在茶余饭后看看这篇文章,也许,可以解决一部分你心中的困惑。 2 内容概要作者整理了几个好的思维习惯,尝试认同它,再看看如何实践。 不冗余避免重复代码段,对 JSX 同理: // Dirtyconst MyComponent = () => ( <div> <OtherComponent type="a" className="colorful" foo={123} bar={456} /> <OtherComponent type="b" className="colorful" foo={123} bar={456} /> </div>);// Cleanconst MyOtherComponent = ({ type }) => ( <OtherComponent type={type} className="colorful" foo={123} bar={456} />);const MyComponent = () => ( <div> <MyOtherComponent type="a" /> <MyOtherComponent type="b" /> </div>); 但也不要过度优化,过度优化和搞破坏没什么区别。 可预测、可测试如果使用 Jest 测试,可以考虑截图测试插件:Jest Image Snapshot 自我解释尽可能减少代码中的注释。可以通过让变量名更语义化、只注释复杂、潜在逻辑,来减少注释量,同时也提高了可维护性,毕竟不用总在代码与注释之间同步了。 // Dirtyconst fetchUser = (id) => ( fetch(buildUri`/users/${id}`) // Get User DTO record from REST API .then(convertFormat) // Convert to snakeCase .then(validateUser) // Make sure the the user is valid);// Cleanconst fetchUser = (id) => ( fetch(buildUri`/users/${id}`) .then(snakeToCamelCase) .then(validateUser)); 上面的例子,方法 convertFormat 含义是 “转换格式”,太过于笼统,以至于不得不添加注释。如果换成 snakeToCamelCase (转换为驼峰风格),这个名字就解释了自己的功能。 斟酌变量名 布尔值或者返回值是布尔类型的函数,命名以 is has should 开头: // Dirtyconst done = current >= goal;// Cleanconst isComplete = current >= goal; 函数以其效果命名,而不是怎么做的来命名 // Dirtyconst loadConfigFromServer = () => { ...};// Cleanconst loadConfig = () => { ...}; 很多时候我也经常犯这种错误,毕竟写代码的时候总要考虑实现,一不小心就将实现的方式带入了函数名中。 遵循设计模式这里的设计模式,并不是指工程上的,而是更广泛的开发中的设计模式,比如 “你应该使用 tslint 校验代码格式” “typescript 开启 stricts 模式” “编写一个 React 函数,应当将 React 作为 peerDependency” 等等(当然,不要随意设置 peerDependency 也是一种江湖约定)。 对于 React,遵循以下几个最佳实践: 单一责任原则, 确保每个功能都完整完成一项功能,比如更细粒度的组件拆分,同时也更利于测试。 不要把组件的内部依赖强加给使用方。 lint 规则尽量严格。 根据我的体验,尤为痛恨违背第二条的组件,比如当 React 组件使用了数据流,但必须依赖项目初始化该数据流才能执行,如果不是被生活所迫,我才不会使用这种组件。 第三条也一样,如果你是一个知名轮子的作者,请毫不留情的使用最严格的 lint 规则。如果使用者的 lint 规则比你还严格,你的组件将无法使用。 考虑到以上几点并不会降低编码速度编写整洁的代码在开始一定会放慢开发速度,因为你需要转变自己的思维模式,但随着不断迭代,它的带来的效率提升会逐渐弥补前面的损失,并不断带来开发效率的提升。 写组件库也是同理,用脚写固然能快速完成,但后续往往要重构掉。我很羡慕函数式工作环境的开发者,他们几乎只要为每个功能写一遍,剩下的就是记住并调用它。 在 React 中的实践略过几个没意思的例子。。 在 React 使用 defaultProps 代替在代码中动态判断 显然,利用 React 组件的规则,将入参的默认值预先定义好是最高效的。但顺带一句,如果在 ts 最严格的 stricts 模式里,依然会报错:变量可能未定义。这是因为 defaultProps 依然是个约定,而不能通过强类型推导出,目前还没有更优雅的解决思路。 渲染与判断逻辑分开 // Dirtyclass User extends Component { state = { loading: true }; render() { const { loading, user } = this.state; return loading ? <div>Loading...</div> : <div> <div> First name: {user.firstName} </div> <div> First name: {user.lastName} </div> ... </div>; } componentDidMount() { fetchUser(this.props.id) .then((user) => { this.setState({ loading: false, user })}) }}// Cleanimport RenderUser from './RenderUser';class User extends Component { state = { loading: true }; render() { const { loading, user } = this.state; return loading ? <Loading /> : <RenderUser user={user} />; } componentDidMount() { fetchUser(this.props.id) .then(user => { this.setState({ loading: false, user })}) }} 逻辑与渲染分离,便于维护,其次便于测试。 当然有人可能会问 “就算逻辑与渲染分离了,但组成的大组件不还是逻辑耦合的吗”,对,这就像函数单一指责一样,render 是过程代码,但过程中涉及到的逻辑,分配给单一指责的渲染组件渲染,如果把逻辑与渲染写在一起,就类似一个函数把功能全做完,这样做显然诸事不利。 提倡无状态组件// Dirtyclass TableRowWrapper extends Component { render() { return ( <tr> {this.props.children} </tr> ); }}// Cleanconst TableRowWrapper = ({ children }) => ( <tr> {children} </tr>); 性能是一个原因,原文比较强调性能与代码量。我认为 stateless 重点在于阻碍了内部状态的使用,移除了生命周期,所以提高了组件的可控性,也就拓宽了组件的使用场景。 受控与非受控组件都有其适用场景,像非常基础的底层组件库,往往倾向提供两套机制,通过 value 与 defaultValue 决定是否受控。拥有这样能力的组件源码就没法通过 stateless 写,所以无状态组件的面向对象并不是基础底层组件,而且这些基础组件也没必要完全无状态,两者都提供是最好的选择。 说到这,也就是考虑到成本问题,那么无状态组件也就更适合上层具有业务含义的组件。页面级别组件状态太多,不适合,所以我认为无状态组件比较适合 Wrapper 层,也就是对基础组件包裹并增强业务能力这一层。 解构// Dirtyconst splitLocale = locale.split('-');const language = splitLocale[0];const country = splitLocale[1];// Cleanconst [language, country] = locale.split('-'); ES6 新增的语法可以提升不少代码可读性,需要刻意训练去培养这个习惯。 3 精读本周精读已经融于内容概要中 ^_^。最后推荐在 typescript 中开启 strict 模式,强制使用良好的开发习惯。 // BadonChange(value => console.log(value.name))// DirtyonChange((value) => { if (!value) { value = {} } console.log(value.name)})// CleanonChange((value = {}) => console.log(value.name))// CleanonChange(value => console.log(value?.name)) 不要信任任何回调函数给你的变量,它们随时可能是 undefined,使用初始值是个不错的选择,但有的时候初始值没什么意义,使用 ?. 语法可以安全的访问属性,是时候抛弃 _.get 了。 4. 总结我要回去重构代码了,你呢? 更多讨论 讨论地址是:精读《React 代码整洁之道》 · Issue ##46 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《React 八种条件渲染》","path":"/wiki/WebWeekly/前沿技术/《React 八种条件渲染》.html","content":"当前期刊数: 61 1 引言本期精读的文章是:8 React conditional rendering methods 介绍了八种 React 条件渲染方式。 模版条件渲染非常常见,遇到的时候往往会随机选择一种方式使用,那么怎么写会有较好的维护性呢?先一起了解下有哪八种条件渲染方式吧! 2 概述IF/ELSE既然 JSX 支持 js 与 html 混写,那么交替使用就能解决条件渲染的问题: function render() { if (renderComponent1) { return <Component1 />; } else { return <div />; }} return null如果不想渲染空元素,最好使用 null 代替空的 div: function render() { if (renderComponent1) { return <Component1 />; } else { return null; }} 这样对 React 渲染效率有提升。 组件变量将组件赋值到变量,就可以在 return 前任意修改它了。 function render() { let component = null; if (renderComponent1) { component = <Component1 />; } return component;} 三元运算符三元运算符的语法如下: condition ? expr_if_true : expr_if_false 用在 JSX 上也很方便: function render() { return renderComponent1 ? <Component1 /> : null;} 但三元运算符产生嵌套时,理解成本会变得很高。 &&这个是最常用了,因为代码量最少。 function render() { return renderComponent1 && <Component1 />;} IIFEIIFE 含义是立即执行函数,也就是如下代码: (function myFunction(/* arguments */) { // ...})(/* arguments */); 当深陷 JSX 代码中,又想写一大块逻辑时,除了回到上方,还可以使用 IIFE: function render() { return ( <div> {(() => { if (renderComponent1) { return <Component1 />; } else { return <div />; } })()} </div> );} 子组件这是 IIFE 的变种,也就是把这段立即执行函数替换成一个普通函数: function render() { return ( <div> <SubRender /> </div> );}function SubRender() { if (renderComponent1) { return <Component1 />; } else { return <div />; }} IF 组件做一个条件渲染组件 IF 代替 js 函数的 if: <If condition={true}> <span>Hi!</span></If> 这个组件实现也很简单 const If = props => { const condition = props.condition || false; const positive = props.then || null; const negative = props.else || null; return condition ? positive : negative;}; 高阶组件高阶组件,就是返回一个新组件的函数,并且接收一个组件作为参数。 那么我们就能在高阶组件里写条件语句,返回不同的组件即可: function higherOrderComponent(Component) { return function EnhancedComponent(props) { if (condition) { return <AnotherComponent {...props} />; } return <Component {...props} />; };} 3 精读这么多方法都能实现条件渲染,那么重点在于可读性与可维护性。 比如通过调用函数实现组件渲染: <div>{renderButton()}</div> 看上去还是比较冗余,如果使用 renderButton getter 定义,我们就可以这么写它: <div>{button}</div> 其实我们想要的就是 button,而不是 renderButton。那么还可以进一步,干脆封装成 JSX 组件: <div> <Button /></div> 是否要付出这些努力,取决于应用的复杂度。如果应用复杂度非常高,那你应当尽量使用最后一种封装,让每个文件的逻辑尽量独立、简单。 如果应用复杂度比较低,那么注意不要过度封装,以免把自己绕进去。 所以看来这又是一个没有固定答案的问题,选择何种方式封装,取决于应用复杂度。 应用复杂度对任何代码封装,都会增加这段 连接逻辑 的复杂度。 假定无论如何代码的复杂度都是恒定不变的,下面这段代码,连接复杂度为 0,而对于 render 函数而言,逻辑复杂度是 100: function render() { if (renderComponent) { return isOk ? <Component1 /> : <Component2 />; } else { return <div />; }} 下面这段代码拆成了两个函数,逻辑复杂度对 render SubComponent 来说都是 50,但连接复杂度是 50: function render() { if (renderComponent) { return <SubComponent>; } else { return <div />; }}function SubComponent() { return isOk ? <Component1 /> : <Component2 />} 可以看到,我们通过函数拆分,降低了每个函数的逻辑复杂度,但却提高了连接复杂度。 下面来做一个比较,我们假设一个正常的程序员,可以一次性轻松记忆 10 个函数。如果再多,函数之间的调用关系就会让人摸不着头脑。 应用较小时在应用代码量比较小时,假设一共有 10 个函数,如果做了逻辑抽象,拆分出了 10 个子函数,那么总逻辑复杂度不变,函数变成了 20 个。 此时小王要修改此项目,他需要找到关键代码的位置。 如果没有做逻辑抽象,小王一下子就记住了 10 个函数,并且很快完成了需求。 如果应用做了逻辑抽象,他需要理解的逻辑复杂度是不变的,但是要读的函数变成了 20 个。小王需要像侦探一样在线索中不断跳转,他还是只找了 10 个关键函数,但一共也就 20 个函数,逻辑并不复杂,这值得吗? 小王心里可能会嘀咕:简单的逻辑瞎抽象,害我文件找了半天! 应用较大时此时应用代码量比较大,假设一共有 500 个函数,我们不考虑抽象后带来的复用好处,假设都无法复用,那么做了逻辑抽象后,那么总逻辑复杂度不变,函数变成了 1000 个。 此时小王接到了需求,终于维护了一个大项目。 小王知道这个项目很复杂,从一开始就没觉得能理解项目全貌,所以把自己当作一名侦探,准备一步步探索。 现在有两种选择,一种是在未做逻辑抽象时探索,一种是在做过逻辑抽象后探索。 如果没做逻辑抽象,小王需要面对 500 个这种函数: function render() { if (renderComponent) { return isOk ? <Component1 /> : <Component2 />; } else { return isReady ? <Component3 /> : <Component4 />; }} 如果做了逻辑抽象,小王需要面对 1000 个这种函数: function render() { if (renderComponent) { return <Component1And2 />; } else { return <Component3And4 />; }} 在项目庞大后,总函数数量并不会影响对线索的查找,而总线索深度也几乎总是固定的,一般在 5 层左右。 小王理解 5 个或 10 个函数成本都差不多,但没有做逻辑抽象时,这 5 个函数各自参杂了其他逻辑,反而影响对函数的理解。 这时做逻辑抽象是合适的。 4 总结所以总的来说,笔者更倾向使用子函数、子组件、IF 组件、高阶组件做条件渲染,因为这四种方式都能提高程序的抽象能力。 往往抽象后的代码会更具有复用性,单个函数逻辑更清晰,在切面编程时更利于理解。 当项目很简单时,整个项目的理解成本都很低,抽象带来的复杂度反而让项目变成了需要切面编程的时候,就得不偿失了。 总结一下: 当项目很简单,或者条件渲染的逻辑确认无法复用时,推荐在代码中用 && 或者三元运算符、IIFE 等直接实现条件渲染。 当项目很复杂时,尽量都使用 子函数、子组件、IF 组件、高阶组件 等方式做更有抽象度的条件渲染。 在做逻辑抽象时,考虑下项目的复杂度,避免因为抽象带来的成本增加,让本可以整体理解的项目变得支离破碎。 5 更多讨论 讨论地址是:精读《React 八种条件渲染》 · Issue ##90 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《React 性能调试》","path":"/wiki/WebWeekly/前沿技术/《React 性能调试》.html","content":"当前期刊数: 149 1 引言在数据中台做 BI 工具经常面对海量数据的渲染处理,除了组件本身性能优化之外,经常要排查整体页面性能瓶颈点,尤其是维护一些性能做得并不好的旧代码时。 React 性能调试是面对这种问题的必修课,借助 Profiling React.js Performance 这篇文章一起学习一下这个技能吧。 2 精读本文介绍了众多性能检测工具与方法。 React ProfilerProfiler 这个 API 是一种运行时 Debug 的补充,可以通过其 callback 拿到组件渲染信息,用法如下: 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 查看更直观更简洁的渲染耗时: 其中 Ranked 可以展示按照渲染耗时排序后的结果,Interations 需要配合 Tracing API 使用,在后面会提到。 Tracing API利用 scheduler/tracing 提供的 trace API,我们可以记录某个动作的耗时,比如 “点击添加按钮收藏一个电影” 耗时多久: 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 中可以看到动作触发的耗时: 这个动作还可以是渲染,比如可以记录 ReactDOM 渲染的耗时: import { unstable_trace as trace } from "scheduler/tracing";trace("initial render", performance.now(), () => { ReactDom.render(<App />, document.getElementById("app"));}); 甚至还可以追踪异步的耗时: import { unstable_trace as trace, unstable_wrap as wrap,} from "scheduler/tracing";trace("Some event", performance.now(), () => { setTimeout( wrap(() => { // 异步操作 }) );}); 有了 Profiler 与 trace 这两件武器,我们可以监控任意元素的渲染耗时与交互耗时,几乎可以涵盖所有性能监控需要。 Puppeteer我们还可以利用 Puppeteer 实现自动化操作并打印报告: 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 面板,就会得到一份自动的性能检测报告: 这张图相当重要,是浏览器综合运行开销分析的利器,最上面分为 4 个部分: FPS:每秒帧数,绿色竖线越高表示 FPS 越高,出现红线则表示出现了卡顿。 CPU:CPU 资源,用面积图展示消耗 CPU 资源的事件。 NET:网络消耗,每条横杠表示一种资源的加载。 HEAP:内存水位,由于短时间内看不出来是否会内存溢出,一般只用来简单看看内存消耗是否符合预期,对于内存溢出的检测需要用持续监控上报的方式。 下面会有一张 Network 详细图解,比如这张图: 细线表示等待的时间,粗线表示实际加载的情况,其中浅色部分表示服务器等待时间,即从发送下载请求到服务器响应第一个字节的时间。这部分可以看出资源并行加载阻塞情况以及资源服务器响应时间是否存在问题。 Timings 展示了几个重要时间节点,这里列举一部分: FP:First Paint,第一次绘制。 FCP:First Contentful Paint,第一次内容绘制。 LCP:Largest Contentful Paint,最大内容绘制。 DCL:Document Content Loaded,DOM 内容加载完毕。 再下面是 JS 计算消耗,用了一张火焰图,火焰图是性能分析的常用可视化工具。以下面这张图为例: 看火焰图首先看跨度最长的函数,也就是最长的那条线,这是最耗时的部分,从左到右是浏览器脚本的调用顺序,从上到下是函数嵌套的顺序。 我们可以看到鼠标位置的 34 这个函数虽然长,但并不是性能瓶颈,因为下面执行的 n 函数长度和它一样,表示 34 函数的性能几乎无损耗,其性能由其调用的 n 函数决定。 我们可以利用这种方式一步步排查到叶子结点,找到对性能影响最大的元子函数。 User Timing API我们还可以利用 performance.mark 自定义性能检测节点: // Record the time before running a taskperformance.mark("Movies:updateStart");// Do some work// Record the time after running a taskperformance.mark("Movies:updateEnd");// Measure the difference between the start and end of the taskperformance.measure("moviesRender", "Movies:updateStart", "Movies:updateEnd"); 这些节点可以在上面介绍的 Performance 面板中展示出来用于自定义分析。 3 总结利用 Performance 进行通用性能分析,利用 React Profiler 进行 React 定制性能分析,这两个结合在一起几乎可以完成任何性能检测。 一般来说,首先应该用 React Profiler 进行 React 层面的问题筛查,这样更直观,更容易定位问题。如果某些问题跳出了 React 框架范围,或者不再能以组件粒度进行度量,我们可以回到 Performance 面板进行通用性能分析。 讨论地址是:精读《React 性能调试》 · Issue ##247 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React 的多态性》","path":"/wiki/WebWeekly/前沿技术/《React 的多态性》.html","content":"当前期刊数: 63 1 引言本周精读的文章是:surprising-polymorphism-in-react-applications,看看作者是如何解释这个多态性含义的。 读完文章才发现,文章标题改为 Redux 的多态性更妥当,因为整篇文章都在说 Redux,而 Redux 使用场景不局限于 React。 2 概述Redux immutable 特性可能产生浏览器无法优化的性能问题,也就是浏览器无法做 shapes 优化,也就是上一篇精读《JS 引擎基础之 Shapes and Inline Caches》 里提到的。 先看看普通的 redux 的 reducer: const todo = (state = {}, action) => { switch (action.type) { case "ADD_TODO": return { id: action.id, text: action.text, completed: false }; case "TOGGLE_TODO": if (state.id !== action.id) { return state; } return Object.assign({}, state, { completed: !state.completed }); default: return state; }}; 我们简化一下使用场景,假设基于这个 reducer todo,生成了两个新 store s1 s2: const s1 = todo( {}, { type: "ADD_TODO", id: 1, text: "Finish blog post" });const s2 = todo(s1, { type: "TOGGLE_TODO", id: 1}); 看上去很常见,也的确如此,我们每次 dispatch 都会根据 reducer 生成新的 store 树,而且是一个新的对象。然而对 js 引擎而言,这样的代码可能做不了 Shapes 优化(关于 Shapes 优化建议阅读上一期精读 Shapes 优化),也就是最需要做优化的全局 store,在生成新 store 时无法被浏览器优化,这个问题很容易被忽视,但的确影响不小。 至于为什么会阻止 js 引擎的 shapes 优化,看下面的代码: // transition-trees.jslet a = {x:1, y:2, z:3};let b = {};b.x = 1;b.y = 2;b.z = 3;console.log("a is", a);console.log("b is", b);console.log("a and b have same map:", %HaveSameMap(a, b)); 通过 node --allow-natives-syntax test.js 执行,通过调用 node 原生函数 %HaveSameMap 判断这种情况下 a 与 b 是否共享一个 shape(v8 引擎的 Shape 实现称为 Map)。 结果是 false,也就是 js 引擎无法对 a b 做 Shapes 优化,这是因为 a 与 b 对象初始化的方式不同。 同样,在 Redux 代码中常用的 Object.assign 也有这个问题: 因为新的对象以 {} 空对象作为最初状态,js 引擎会为新对象创建 Empty Shape,这与原对象的 Shape 一定不同。 顺带一提 es6 的解构语法也存在同样的问题,因为 babel 将解构最终解析为 Object.assign: 对这种尴尬的情况,作者的建议是对所有对象赋值时都是用 Object.assign 以保证 js 引擎可以做 Shapes 优化: let a = Object.assign({}, {x:1, y:2, z:3});let b = Object.assign({}, a);console.log("a is", a);console.log("b is", b);console.log("a and b have same map:", %HaveSameMap(a, b)); // true 3 精读这篇文章需要与上一篇 精读《JS 引擎基础之 Shapes and Inline Caches》 连起来看容易理解。 作者描述的性能问题是引擎级别的 Shapes 优化问题,读过上篇精读就很容易知道,只有相同初始化方式的对象才被 js 引擎做优化,而 Redux 频繁生成的 immutable 全局 store 是否能被优化呢?答案是“往往不能”,因为 immutable 赋值问题,我们往往采用 Object.assign 或者解构方式赋值,这种方式产生的新对象与原对象的 Shape 不同,导致 Shape 无法复用。 这里解释一下疑惑,为什么说 immutable 对象之间也要优化呢?这不是两个不同的引用吗?这是因为 js 引擎级别的 Shapes 优化就是针对不同引用的对象,将对象的结构:Shape 与数据分离开,这样可以大幅优化存储效率,对数组也一样,上一篇精读有详细介绍。 所以笔者更推荐使用比如 immutable-js 这种库操作 immutable 对象,而不是 Object.assign,因为封装库内部是可能通过统一对象初始化方式利用 js 引擎进行优化的。 4 总结原文提到的多态是指多个相同结构对象,被拆分成了多个 Shape;而单态是指这些对象可以被一个 Shape 复用。 笔者以前也经历过从 Object.assign 到 Immutablejs 库,最后又回到解构新语法的经历,觉得在层级不深情况下解构语法可以代替 Immutablejs 库。 通过最近两篇精读的分析,我们需要重新思考这样做带来的优缺点,因为在 js 环境中,Object.assign 的优化效率比 Immutablejs 库更低。 最后,也完全没必要现在就开始重构,因为这只是 js 运行环境中很小一部分影响因素,比如为了引入 Immutablejs 让你的网络延时增加了 100%?所以仅在有必要的时候优化它。 5 更多讨论 讨论地址是:精读《React 的多态性》 · Issue ##92 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《React 高阶组件》","path":"/wiki/WebWeekly/前沿技术/《React 高阶组件》.html","content":"当前期刊数: 12 本期精读文章是:React Higher Order Components in depth 1 引言高阶组件( higher-order component ,HOC )是 React 中复用组件逻辑的一种进阶技巧。它本身并不是 React 的 API,而是一种 React 组件的设计理念,众多的 React 库已经证明了它的价值,例如耳熟能详的 react-redux。 高阶组件的概念其实并不难,我们能通过类比高阶函数迅速掌握。高阶函数是把函数作为参数传入到函数中并返回一个新的函数。这里我们把函数替换为组件,就是高阶组件了。 const EnhancedComponent = higherOrderComponent(WrappedComponent); 当然了解高阶组件的概念只是万里长征第一步,精读文章在阐述其概念与实现外,也强调了其重要性与局限性,以及与其他方案的比较,让我们一起来领略吧。 2 内容概要高阶组件常见有两种实现方式,一种是 Props Proxy,它能够对 WrappedComponent 的 props 进行操作,提取 WrappedComponent state 以及使用其他元素来包裹 WrappedComponent。Props Proxy 作为一层代理,具有隔离的作用,因此传入 WrappedComponent 的 ref 将无法访问到其本身,需要在 Props Proxy 内完成中转,具体可参考以下代码,react-redux 也是这样实现的。 此外各个 Props Proxy 的默认名称是相同的,需要根据 WrappedComponent 来进行不同命名。 function ppHOC(WrappedComponent) { return class PP extends React.Component { // 实现 HOC 不同的命名 static displayName = `HOC(${WrappedComponent.displayName})`; getWrappedInstance() { return this.wrappedInstance; } // 实现 ref 的访问 setWrappedInstance(ref) { this.wrappedInstance = ref; } render() { return <WrappedComponent { ...this.props, ref: this.setWrappedInstance.bind(this), } /> } }}@ppHOCclass Example extends React.Component { static displayName = 'Example'; handleClick() { ... } ...}class App extends React.Component { handleClick() { this.refs.example.getWrappedInstance().handleClick(); } render() { return ( <div> <button onClick={this.handleClick.bind(this)}>按钮</button> <Example ref="example" /> </div> ); }} 另一种是 Inheritance Inversion,HOC 类继承了 WrappedComponent,意味着可以访问到 WrappedComponent 的 state、props、生命周期和 render 等方法。如果在 HOC 中定义了与 WrappedComponent 同名方法,将会发生覆盖,就必须手动通过 super 进行调用了。通过完全操作 WrappedComponent 的 render 方法返回的元素树,可以真正实现渲染劫持。这种方案依然是继承的思想,对于 WrappedComponent 也有较强的侵入性,因此并不常见。 function ppHOC(WrappedComponent) { return class ExampleEnhance extends WrappedComponent { ... componentDidMount() { super.componentDidMount(); } componentWillUnmount() { super.componentWillUnmount(); } render() { ... return super.render(); } }} 3 精读本次提出独到观点的同学有:@monkingxue @alcat2008 @淡苍 @camsong,精读由此归纳。 HOC 的适用范围对比 HOC 范式 compose(render)(state) 与父组件(Parent Component)的范式 render(render(state)),如果完全利用 HOC 来实现 React 的 implement,将操作与 view 分离,也未尝不可,但却不优雅。HOC 本质上是统一功能抽象,强调逻辑与 UI 分离。但在实际开发中,前端无法逃离 DOM ,而逻辑与 DOM 的相关性主要呈现 3 种关联形式: 与 DOM 相关,建议使用父组件,类似于原生 HTML 编写 与 DOM 不相关,如校验、权限、请求发送、数据转换这类,通过数据变化间接控制 DOM,可以使用 HOC 抽象 交叉的部分,DOM 相关,但可以做到完全内聚,即这些 DOM 不会和外部有关联,均可 DOM 的渲染适合使用父组件,这是 React JSX 原生支持的方式,清晰易懂。最好是能封装成木偶组件(Dumb Component)。HOC 适合做 DOM 不相关又是多个组件共性的操作。如 Form 中,validator 校验操作就是纯数据操作的,放到了 HOC 中。但 validator 信息没有放到 HOC 中。但如果能把 Error 信息展示这些逻辑能够完全隔离,也可以放到 HOC 中(可结合下一小节 Form 具体实践详细了解)。数据请求是另一类 DOM 不相关的场景,react-refetch 的实现就是使用了 HOC,做到了高效和优雅: connect(props => ({ usersFetch: `/users?status=${props.status}&page=${props.page}`, userStatsFetch: { url: `/users/stats`, force: true }}))(UsersList) HOC 的具体实践HOC 在真实场景下的运行非常多,之前笔者在 基于 Decorator 的组件扩展实践 一文中也提过使用高阶组件将更细粒度的组件组合成 Selector 与 Search。结合精读文章,这次让我们通过 Form 组件的抽象来表现 HOC 具有的良好扩展机制。 Form 中会包含各种不同的组件,常见的有 Input、Selector、Checkbox 等等,也会有根据业务需求加入的自定义组件。Form 灵活多变,从功能上看,表单校验可能为单组件值校验,也可能为全表单值校验,可能为常规检验,比如:非空、输入限制,也可能需要与服务端配合,甚至需要根据业务特点进行定制。从 UI 上看,检验结果显示的位置,可能在组件下方,也可能是在组件右侧。 直接裸写 Form,无疑是机械而又重复的。将 Form 中组件的 value 经过 validator,把 value,validator 产生的 error 信息储存到 state 或 redux store 中,然后在 view 层完成显示。这条路大家都是相同的,可以进行复用,只是我们面对的是不同的组件,不同的 validator,不同的 view 而已。对于 Form 而言,既要满足通用,又要满足部分个性化的需求,以往单纯的配置化只会让使用愈加繁琐,我们所需要抽象的是 Form 功能而非 UI,因此通过 HOC 针对 Form 的功能进行提取就成为了必然。 至于 HOC 在 Form 上的具体实现,首先将表单中的组件(Input、Selector…)与相应 validator 与组件值回调函数名(trigger)传入 Decorator,将 validator 与 trigger 相绑定。Decorator 完成了各种不同组件与 Form 内置 Store 间 value 的传递、校验功能的抽象,即精读文章中提到 Props Proxy 方式的其中两种作用:提取 state 与 操作 props function formFactoryFactory({ validator, trigger = 'onChange', ...}) { return FormFactory(WrappedComponent) { return class Decorator extends React.Component { getBind(trigger, validator) { ... } render() { const newProps = { ...this.props, [trigger]: this.getBind(trigger, validator), ... } return <WrappedComponent {...newProps} /> } } }}// 调用formFactoryFactory({ validator: (value) => { return value !== ''; }})(<Input placeholder="请输入..." />) 当然为了考虑个性化需求,Form Store 也向外暴露很多 API,可以直接获取和修改 value、error 的值。现在我们需要对一个表单的所有值提交到后端进行校验,根据后端返回,分别列出各项的校验错误信息,就需要借助相应项的 setError 去完成了。 这里主要参考了 rc-form 的实现方式,有兴趣的读者可以阅读其源码。 import { createForm } from 'rc-form';class Form extends React.Component { submit = () => { this.props.form.validateFields((error, value) => { console.log(error, value); }); } render() { const { getFieldError, getFieldDecorator } = this.props.form; const errors = getFieldError('required'); return ( <div> {getFieldDecorator('required', { rules: [{ required: true }], })(<Input />)} {errors ? errors.join(',') : null} <button onClick={this.submit}>submit</button> </div> ); }}export createForm()(Form); 4 总结React 始终强调组合优于继承的理念,期望通过复用小组件来构建大组件使得开发变得简单而又高效,与传统面向对象思想是截然不同的。高阶函数(HOC)的出现替代了原有 Mixin 侵入式的方案,对比隐式的 Mixin 或是继承,HOC 能够在 Devtools 中显示出来,满足抽象之余,也方便了开发与测试。当然,不可过度抽象是我们始终要秉持的原则。希望读者通过本次阅读与讨论,能结合自己具体的业务开发场景,获得一些启发。 讨论地址是:精读《深入理解 React 高阶组件》 · Issue ##18 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《React\"s new Context API》","path":"/wiki/WebWeekly/前沿技术/《React's new Context API》.html","content":"当前期刊数: 45 本周精读的文章是 React’s new Context API。 1 引言React 即将推出全新的 Context api,让我们一起看看。 2 概述像 react-redux、mobx-react、react-router 都使用了旧 Context api,可谓 context 无处不在。 新版 Context 语法是这样的: const ThemeContext = React.createContext('light')class ThemeProvider extends React.Component { state = {theme: 'light'} render() { return ThemeContext.provide(this.state.theme, this.props.children) }}const ThemeConsumer = ({children}) => ThemeContext.consume(children)class App extends React.Component { render() { <ThemeProvider> <ThemeConsumer>{val => <div>{val}</div>}</ThemeConsumer> </ThemeProvider> }} React.createContext 创建新的 Context 并赋初始值。返回的对象包含 provider 与 consumer。 provide 是一个容器,它所有的子元素都能通过 consumer 访问到这个 Context 的值。 其实这种思想在 react-broadcast 已经被实现,现在变成了官方 API。 当然如果多个 Context 同时存在,可能会出现 jsx 的嵌套地狱,不过这个情况可以通过拆分模块,或者以如下方式定义多重 Consumer 来解决: function ThemeAndLanguageConsumer({children}) { return ( <LanguageConsumer> {language => ( <ThemeConsumer> {theme => children({language, theme})} </ThemeConsumer> )} </LanguageConsumer> )}class App extends React.Component { render() { <AppProviders> <ThemeAndLanguageConsumer> {({theme, language}) => <div>{theme} and {language}</div>} </ThemeAndLanguageConsumer> </AppProviders> }} 3 精读最大的问题是,React17 会废除旧 Context 这个 api,许许多多的库需要升级,不过到时候也应该会出现 codemod 自动更新吧。 从 15.0 升级到 16.0 时因为项目中大量使用 React.PropTypes 的地方需要重构,从 16.0 升级到 17.0 时,就不是项目要升级了,而是比如 react-redux 这类库要偷偷升级 context 的用法,可见 React 大版本间生态完全兼容是不可能了。 Context 多层嵌套问题一种方式是通过构造原文中描述的 ThemeAndLanguageConsumer 聚合 Consumer 解决,也可以使用比如 react-context-composer 这种库优雅的解决。摘自 如何解读 react 16.3 引入的新 context api@淡苍 绕过 shouldComponentUpdate像 redux、mobx - react 这些库,都使用了 forceUpdate 绕过 shouldComponentUpdate 机制。原因是这些全局状态管理工具接管了自己的组件更新时机,纵使保留组件原本的更新机制,但当数据流发生变化时,需要绕过一切阻碍,直接触发目标组件的一整套渲染生命周期。 好在新的 Context api 也拥有如此特性,可以在 context 改变时,直接更新即使 shouldComponentUpdate: false 的组件。 是否还需要 redux正如很多人说的,这要看我们是怎么使用 redux 了。 在之前一篇精读 前端数据流哲学 中,我提到了 redux、mobx、rxjs 这三大流派的竞争力。 其中 redux 其实是最没有竞争力的数据流框架,我们暂且抛开函数式和优雅性不说,从功能上说,看看 redux 到底做了啥?利用 react 特性,利用全局数据流解决组件间数据通信问题。抛开 react-redux,只看 redux,剩下不能再简单的 Action 与 Reducer。 再看 mobx,稍微好一点,其主打能力是自动追踪变量引用,当变量被修改时自动刷新视图,可见它的竞争力不仅仅在组件数据的打通,自动绑定带来的效率提升是一大亮点。 最后是 rxjs,其主打能力压根不在 react,核心竞争力在数据处理能力,与数据源的抽象,做到了副作用隔离在数据处理流程之外。 可见技术框架也是如此,核心竞争力在哪,未来就在哪。 是否 flux 多 store 思想再度崛起?我觉得几乎不可能。 新的 Context API 给了开发者创造多个 context 的能力,可不是在项目中创建多个 store,制造混乱的呀。我们之前说过,除了数据流框架,像 react-router,或者一些国际化组件也会使用到 context 传递数据,本质上是需要 context 解决对数据透传的控制能力。 举个例子,国际化参数可以让组件一层一层透传,但调用到 node_modules 组件时,我们无法修改其 dom 结构,怎么让这个参数强制透传呢?所以必须使用 context 对所有需要国际化的组件注入 props,而这个注入变量由顶层 Provider 控制。比如 antd local-provider。 然而共享一个 context 可能会冲突啊,现在你创建你的,我创建我的,咱们都互不影响,未来数据流框架大家会用的更爽,甚至一个项目可以同时并存多套数据流框架,因为互不影响嘛。 4 总结然而新的 Context api 并不是银弹,无法解决所有问题,更不能解决业务组件与项目数据流绑定,导致的耦合问题。 因为不论怎么组织数据流,官方提供了怎样的 api,只要我们想给组件注入数据,那么注入的那个节点就一定依赖一个特性的项目环境,或者变量,比如某个 consumer。 数据流框架也无法被取代,因为数据流框架的核心竞争力不在数据的依赖注入上,而是对数据的处理。 当然这次变化带来最乐观的改变是,react 拥有了一个稳定好用的依赖注入官方 api,在处理国际化这种需要拿 Context 小用一下的场景,可以不依赖第三方库了!代码如下: const Locale = React.createContext({ text: 'menu'})class MyComponent extends React.Component { render() { <Locale.consume> {text => ( <span>This is the {text}</span> )} </Locale.consume> }}class App extends React.Component { render() { <Locale.provide> <MyComponent /> </Locale.provide> }} 5 更多讨论 讨论地址是:精读《React’s new Context API》 · Issue ##64 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《React16 新特性》","path":"/wiki/WebWeekly/前沿技术/《React16 新特性》.html","content":"当前期刊数: 83 React16 新特性1 引言于 2017.09.26 Facebook 发布 React v16.0 版本,时至今日已更新到 React v16.6,且引入了大量的令人振奋的新特性,本文章将带领大家根据 React 更新的时间脉络了解 React16 的新特性。 2 概述按照 React16 的更新时间,从 React v16.0 ~ React v16.6 进行概述。 React v16.0 render 支持返回数组和字符串、Error Boundaries、createPortal、支持自定义 DOM 属性、减少文件体积、fiber; React v16.1 react-call-return; React v16.2 Fragment; React v16.3 createContext、createRef、forwardRef、生命周期函数的更新、Strict Mode; React v16.4 Pointer Events、update getDerivedStateFromProps; React v16.5 Profiler; React v16.6 memo、lazy、Suspense、static contextType、static getDerivedStateFromError(); React v16.7(~Q1 2019) Hooks; React v16.8(~Q2 2019) Concurrent Rendering; React v16.9(~mid 2019) Suspense for Data Fetching; 下面将按照上述的 React16 更新路径对每个新特性进行详细或简短的解析。 3 精读React v16.0render 支持返回数组和字符串// 不需要再将元素作为子元素装载到根元素下面render() { return [ <li/>1</li>, <li/>2</li>, <li/>3</li>, ];} Error BoundariesReact15 在渲染过程中遇到运行时的错误,会导致整个 React 组件的崩溃,而且错误信息不明确可读性差。React16 支持了更优雅的错误处理策略,如果一个错误是在组件的渲染或者生命周期方法中被抛出,整个组件结构就会从根节点中卸载,而不影响其他组件的渲染,可以利用 error boundaries 进行错误的优化处理。 class ErrorBoundary extends React.Component { state = { hasError: false }; componentDidCatch(error, info) { this.setState({ hasError: true }); logErrorToMyService(error, info); } render() { if (this.state.hasError) { return <h1>数据错误</h1>; } return this.props.children; }} createPortalcreatePortal 的出现为 弹窗、对话框 等脱离文档流的组件开发提供了便利,替换了之前不稳定的 API unstable_renderSubtreeIntoContainer,在代码使用上可以做兼容,如: const isReact16 = ReactDOM.createPortal !== undefined;const getCreatePortal = () => isReact16 ? ReactDOM.createPortal : ReactDOM.unstable_renderSubtreeIntoContainer; 使用 createPortal 可以快速创建 Dialog 组件,且不需要牵扯到 componentDidMount、componentDidUpdate 等生命周期函数。 并且通过 createPortal 渲染的 DOM,事件可以从 portal 的入口端冒泡上来,如果入口端存在 onDialogClick 等事件,createPortal 中的 DOM 也能够被调用到。 import React from "react";import { createPortal } from "react-dom";class Dialog extends React.Component { constructor() { super(props); this.node = document.createElement("div"); document.body.appendChild(this.node); } render() { return createPortal(<div>{this.props.children}</div>, this.node); }} 支持自定义 DOM 属性以前的 React 版本 DOM 不识别除了 HTML 和 SVG 支持的以外属性,在 React16 版本中将会把全部的属性传递给 DOM 元素。这个新特性可以让我们摆脱可用的 React DOM 属性白名单。笔者之前写过一个方法,用于过滤非 DOM 属性 filter-react-dom-props,16 之后即可不再需要这样的方法。 减少文件体积React16 使用 Rollup 针对不同的目标格式进行代码打包,由于打包工具的改变使得库文件大小得到缩减。 React 库大小从 20.7kb(压缩后 6.9kb)降低到 5.3kb(压缩后 2.2kb) ReactDOM 库大小从 141kb(压缩后 42.9kb)降低到 103.7kb(压缩后 32.6kb) React + ReactDOM 库大小从 161.7kb(压缩后 49.8kb)降低到 109kb(压缩后 43.8kb) FiberFiber 是对 React 核心算法的一次重新实现,将原本的同步更新过程碎片化,避免主线程的长时间阻塞,使应用的渲染更加流畅。 在 React16 之前,更新组件时会调用各个组件的生命周期函数,计算和比对 Virtual DOM,更新 DOM 树等,这整个过程是同步进行的,中途无法中断。当组件比较庞大,更新操作耗时较长时,就会导致浏览器唯一的主线程都是执行组件更新操作,而无法响应用户的输入或动画的渲染,很影响用户体验。 Fiber 利用分片的思想,把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,在每个小片执行完之后,就把控制权交还给 React 负责任务协调的模块,如果有紧急任务就去优先处理,如果没有就继续更新,这样就给其他任务一个执行的机会,唯一的线程就不会一直被独占。 因此,在组件更新时有可能一个更新任务还没有完成,就被另一个更高优先级的更新过程打断,优先级高的更新任务会优先处理完,而低优先级更新任务所做的工作则会完全作废,然后等待机会重头再来。所以 React Fiber 把一个更新过程分为两个阶段: 第一个阶段 Reconciliation Phase,Fiber 会找出需要更新的 DOM,这个阶段是可以被打断的; 第二个阶段 Commit Phase,是无法被打断的,完成 DOM 的更新并展示; 在使用 Fiber 后,需要检查与第一阶段相关的生命周期函数,避免逻辑的多次或重复调用: componentWillMount componentWillReceiveProps shouldComponentUpdate componentWillUpdate 与第二阶段相关的生命周期函数: componentDidMount componentDidUpdate componentWillUnmount React v16.1Call Return(react-call-return npm)react-call-return 目前还是一个独立的 npm 包,主要是针对 父组件需要根据子组件的回调信息去渲染子组件场景 提供的解决方案。 在 React16 之前,针对上述场景一般有两个解决方案: 首先让子组件初始化渲染,通过回调函数把信息传给父组件,父组件完成处理后更新子组件 props,触发子组件的第二次渲染才可以解决,子组件需要经过两次渲染周期,可能会造成渲染的抖动或闪烁等问题; 首先在父组件通过 children 获得子组件并读取其信息,利用 React.cloneElement 克隆产生新元素,并将新的属性传递进去,父组件 render 返回的是克隆产生的子元素。虽然这种方法只需要使用一个生命周期,但是父组件的代码编写会比较麻烦; React16 支持的 react-call-return,提供了两个函数 unstable_createCall 和 unstable_createReturn,其中 unstable_createCall 是 父组件使用,unstable_createReturn 是 子组件使用,父组件发出 Call,子组件响应这个 Call,即 Return。 在父组件 render 函数中返回对 unstable_createCall 的调用,第一个参数是 props.children,第二个参数是一个回调函数,用于接受子组件响应 Call 所返回的信息,第三个参数是 props; 在子组件 render 函数返回对 unstable_createReturn 的调用,参数是一个对象,这个对象会在 unstable_createCall 第二个回调函数参数中访问到; 当父组件下的所有子组件都完成渲染周期后,由于子组件返回的是对 unstable_createReturn 的调用所以并没有渲染元素,unstable_createCall 的第二个回调函数参数会被调用,这个回调函数返回的是真正渲染子组件的元素; 针对普通场景来说,react-call-return 有点过度设计的感觉,但是如果针对一些特定场景的话,它的作用还是非常明显,比如,在渲染瀑布流布局时,利用 react-call-return 可以先缓存子组件的 ReactElement,等必要的信息足够之后父组件再触发 render,完成渲染。 import React from "react";import { unstable_createReturn, unstable_createCall } from "react-call-return";const Child = props => { return unstable_createReturn({ size: props.children.length, renderItem: (partSize, totalSize) => { return ( <div> {props.children} {partSize} / {totalSize} </div> ); } });};const Parent = props => { return ( <div> {unstable_createCall( props.children, (props, returnValues) => { const totalSize = returnValues .map(v => v.size) .reduce((a, b) => a + b, 0); return returnValues.map(({ size, renderItem }) => { return renderItem(size, totalSize); }); }, props )} </div> );}; React v16.2FragmentFragment 组件其作用是可以将一些子元素添加到 DOM tree 上且不需要为这些元素提供额外的父节点,相当于 render 返回数组元素。 render() { return ( <Fragment> Some text. <h2>A heading</h2> More text. <h2>Another heading</h2> Even more text. </Fragment> );} React v16.3createContext全新的 Context API 可以很容易穿透组件而无副作用,其包含三部分:React.createContext,Provider,Consumer。 React.createContext 是一个函数,它接收初始值并返回带有 Provider 和 Consumer 组件的对象; Provider 组件是数据的发布方,一般在组件树的上层并接收一个数据的初始值; Consumer 组件是数据的订阅方,它的 props.children 是一个函数,接收被发布的数据,并且返回 React Element; const ThemeContext = React.createContext("light");class ThemeProvider extends React.Component { state = { theme: "light" }; render() { return ( <ThemeContext.Provider value={this.state.theme}> {this.props.children} </ThemeContext.Provider> ); }}class ThemedButton extends React.Component { render() { return ( <ThemeContext.Consumer> {theme => <Button theme={theme} />} </ThemeContext.Consumer> ); }} createRef / forwardRefReact16 规范了 Ref 的获取方式,通过 React.createRef 取得 Ref 对象。 // before React 16··· componentDidMount() { const el = this.refs.myRef } render() { return <div ref="myRef" /> }···// React 16+ constructor(props) { super(props) this.myRef = React.createRef() } render() { return <div ref={this.myRef} /> }··· React.forwardRef 是 Ref 的转发, 它能够让父组件访问到子组件的 Ref,从而操作子组件的 DOM。 React.forwardRef 接收一个函数,函数参数有 props 和 ref。 const TextInput = React.forwardRef((props, ref) => ( <input type="text" placeholder="Hello forwardRef" ref={ref} />));const inputRef = React.createRef();class App extends Component { constructor(props) { super(props); this.myRef = React.createRef(); } handleSubmit = event => { event.preventDefault(); alert("input value is:" + inputRef.current.value); }; render() { return ( <form onSubmit={this.handleSubmit}> <TextInput ref={inputRef} /> <button type="submit">Submit</button> </form> ); }} 生命周期函数的更新React16 采用了新的内核架构 Fiber,Fiber 将组件更新分为两个阶段:Render Parse 和 Commit Parse,因此 React 也引入了 getDerivedStateFromProps 、 getSnapshotBeforeUpdate 及 componentDidCatch 等三个全新的生命周期函数。同时也将 componentWillMount、componentWillReceiveProps 和 componentWillUpdate 标记为不安全的方法。 static getDerivedStateFromProps(nextProps, prevState)getDerivedStateFromProps(nextProps, prevState) 其作用是根据传递的 props 来更新 state。它的一大特点是无副作用,由于处在 Render Phase 阶段,所以在每次的更新都会触发该函数, 在 API 设计上采用了静态方法,使其无法访问实例、无法通过 ref 访问到 DOM 对象等,保证了该函数的纯粹高效。 为了配合未来的 React 异步渲染机制,React v16.4 对 getDerivedStateFromProps 做了一些改变, 使其不仅在 props 更新时会被调用,setState 时也会被触发。 如果改变 props 的同时,有副作用的产生,这时应该使用 componentDidUpdate; 如果想要根据 props 计算属性,应该考虑将结果 memoization 化; 如果想要根据 props 变化来重置某些状态,应该考虑使用受控组件; static getDerivedStateFromProps(props, state) { if (props.value !== state.controlledValue) { return { controlledValue: props.value, }; } return null;} getSnapshotBeforeUpdate(prevProps, prevState)getSnapshotBeforeUpdate(prevProps, prevState) 会在组件更新之前获取一个 snapshot,并可以将计算得的值或从 DOM 得到的信息传递到 componentDidUpdate(prevProps, prevState, snapshot) 函数的第三个参数,常常用于 scroll 位置定位等场景。 componentDidCatch(error, info)componentDidCatch 函数让开发者可以自主处理错误信息,诸如错误展示,上报错误等,用户可以创建自己的 Error Boundary 来捕获错误。 componentWillMount(nextProps, nextState)componentWillMount 被标记为不安全,因为在 componentWillMount 中获取异步数据或进行事件订阅等操作会产生一些问题,比如无法保证在 componentWillUnmount 中取消掉相应的事件订阅,或者导致多次重复获取异步数据等问题。 componentWillReceiveProps(nextProps) / componentWillUpdate(nextProps, nextState)componentWillReceiveProps / componentWillUpdate 被标记为不安全,主要是因为操作 props 引起的 re-render 问题,并且对 DOM 的更新操作也可能导致重新渲染。 Strict ModeStrictMode 可以在开发阶段开启严格模式,发现应用存在的潜在问题,提升应用的健壮性,其主要能检测下列问题: 识别被标志位不安全的生命周期函数 对弃用的 API 进行警告 探测某些产生副作用的方法 检测是否使用 findDOMNode 检测是否采用了老的 Context API class App extends React.Component { render() { return ( <div> <React.StrictMode> <ComponentA /> </React.StrictMode> </div> ); }} React v16.4Pointer Events指针事件是为指针设备触发的 DOM 事件。它们旨在创建单个 DOM 事件模型来处理指向输入设备,例如鼠标,笔 / 触控笔或触摸(例如一个或多个手指)。指针是一个与硬件无关的设备,可以定位一组特定的屏幕坐标。拥有指针的单个事件模型可以简化创建 Web 站点和应用程序,并提供良好的用户体验,无论用户的硬件如何。但是,对于需要特定于设备的处理的场景,指针事件定义了一个 pointerType 属性,用于检查产生事件的设备类型。 React 新增 onPointerDown / onPointerMove / onPointerUp / onPointerCancel / onGotPointerCapture / onLostPointerCapture / onPointerEnter / onPointerLeave / onPointerOver / onPointerOut 等指针事件。 这些事件只能在支持 指针事件 规范的浏览器中工作。如果应用程序依赖于指针事件,建议使用第三方指针事件 polyfill。 React v16.5ProfilerReact 16.5 添加了对新的 profiler DevTools 插件的支持。这个插件使用 React 的 Profiler 实验性 API 去收集所有 component 的渲染时间,目的是为了找出 React App 的性能瓶颈,它将会和 React 即将发布的 时间片 特性完全兼容。 React v16.6memoReact.memo() 只能作用在简单的函数组件上,本质是一个高阶函数,可以自动帮助组件执行 shouldComponentUpdate(),但只是执行浅比较,其意义和价值有限。 const MemoizedComponent = React.memo(props => { /* 只在 props 更改的时候才会重新渲染 */}); lazy / SuspenseReact.lazy() 提供了动态 import 组件的能力,实现代码分割。 Suspense 作用是在等待组件时 suspend(暂停)渲染,并显示加载标识。 目前 React v16.6 中 Suspense 只支持一个场景,即使用 React.lazy() 和 <React.Suspense> 实现的动态加载组件。 import React, { lazy, Suspense } from "react";const OtherComponent = lazy(() => import("./OtherComponent"));function MyComponent() { return ( <Suspense fallback={<div>Loading...</div>}> <OtherComponent /> </Suspense> );} static contextTypestatic contextType 为 Context API 提供了更加便捷的使用体验,可以通过 this.context 来访问 Context。 const MyContext = React.createContext();class MyClass extends React.Component { static contextType = MyContext; componentDidMount() { const value = this.context; } componentDidUpdate() { const value = this.context; } componentWillUnmount() { const value = this.context; } render() { const value = this.context; }} getDerivedStateFromErrorstatic getDerivedStateFromError(error) 允许开发者在 render 完成之前渲染 Fallback UI,该生命周期函数触发的条件是子组件抛出错误,getDerivedStateFromError 接收到这个错误参数后更新 state。 class ErrorBoundary extends React.Component { state = { hasError: false }; static getDerivedStateFromError(error) { // Update state so the next render will show the fallback UI. return { hasError: true }; } componentDidCatch(error, info) { // You can also log the error to an error reporting service logErrorToMyService(error, info); } render() { if (this.state.hasError) { // You can render any custom fallback UI return <h1>Something went wrong.</h1>; } return this.props.children; }} React v16.7(~Q1 2019)HooksHooks 要解决的是状态逻辑复用问题,且不会产生 JSX 嵌套地狱,其特性如下: 多个状态不会产生嵌套,依然是平铺写法; Hooks 可以引用其他 Hooks; 更容易将组件的 UI 与状态分离; Hooks 并不是通过 Proxy 或者 getters 实现,而是通过数组实现,每次 useState 都会改变下标,如果 useState 被包裹在 condition 中,那每次执行的下标就可能对不上,导致 useState 导出的 setter 更新错数据。 更多 Hooks 使用场景可以阅读下列文章: 精读《怎么用 React Hooks 造轮子》 function App() { const [open, setOpen] = useState(false); return ( <> <Button type="primary" onClick={() => setOpen(true)}> Open Modal </Button> <Modal visible={open} onOk={() => setOpen(false)} onCancel={() => setOpen(false)} /> </> );} React v16.8(~Q2 2019)Concurrent RenderingConcurrent Rendering 并发渲染模式是在不阻塞主线程的情况下渲染组件树,使 React 应用响应性更流畅,它允许 React 中断耗时的渲染,去处理高优先级的事件,如用户输入等,还能在高速连接时跳过不必要的加载状态,用以改善 Suspense 的用户体验。 目前 Concurrent Rendering 尚未正式发布,也没有详细相关文档,需要等待 React 团队的正式发布。 React v16.9(~mid 2019)Suspense for Data FetchingSuspense 通过 ComponentDidCatch 实现用同步的方式编写异步数据的请求,并且没有使用 yield / async / await,其流程:调用 render 函数 -> 发现有异步请求 -> 暂停渲染,等待异步请求结果 -> 渲染展示数据。 无论是什么异常,JavaScript 都能捕获,React 就是利用了这个语言特性,通过 ComponentDidCatch 捕获了所有生命周期函数、render 函数等,以及事件回调中的错误。如果有缓存则读取缓存数据,如果没有缓存,则会抛出一个异常 promise,利用异常做逻辑流控制是一种拥有较深的调用堆栈时的手段,它是在虚拟 DOM 渲染层做的暂停拦截,代码可在服务端复用。 import { fetchMovieDetails } from "../api";import { createFetch } from "../future";const movieDetailsFetch = createFetch(fetchMovieDetails);function MovieDetails(props) { const movie = movieDetailsFetch.read(props.id); return ( <div> <MoviePoster src={movie.poster} /> <MovieMetrics {...movie} /> </div> );} 4 总结从 React16 的一系列更新和新特性中我们可以窥见,React 已经不仅仅只在做一个 View 的展示库,而是想要发展成为一个包含 View / 数据管理 / 数据获取 等场景的前端框架,以 React 团队的技术实力以及想法,笔者还是很期待和看好 React 的未来,不过它渐渐地已经对开发新手们不太友好了。 5 更多讨论 讨论地址是:精读《React16 新特性》 · Issue ##115 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《Rekit Studio》","path":"/wiki/WebWeekly/前沿技术/《Rekit Studio》.html","content":"当前期刊数: 44 前端精读专栏,给大家拜年了! 趁着过年,先聊几句咱们前端精读: 前端精读不仅仅是知识的搬运工!前端精读不仅仅是知识的搬运工!重要的话重复两遍。 论搬运知识量,我们比不上前端周刊;论翻译水平,我们比不上专业翻译计划。各位读者也一定不是冲着这两点来的,恰好,我们也不是为这两点做的。 前端精读能带给读者的,是对如今互联网海量信息的思考能力。 想这么一个问题,当人人都能订阅鱼龙混的前端消息,真的是每天扫一眼就天下大势尽在手中了吗?说的不好听一点,可能啥都看个皮毛,最后什么都跟不上。 更可怕的是,多如牛毛的消息早已钝化了我们的神经,让我们养成了浮躁的性格,似乎提到一个单词,点一个头就心领神会了,其实什么也不会。而当真正好的思想出现时,可能已经被淹没在质量层次不齐的海洋中,就算有眼力识别出来,也早已对其麻木,更何况大部分人还做不到辨别消息的质量。 以前,前端精读想把有误导性的,不正确的文章挑出来加以指正。现在发现行不通,其一是具有误导性文章太多,实在是挑不完,其二是现在更多的文章如同白开水一般,味如嚼蜡,你挑不出毛病,但也挑不出亮点。 所以前端精读把我们认为有价值,值得大家关注的东西挑出来,深入的写一篇读后感。也许不是每一期选题都值得深入了解,但我们只求在广袤的知识沙漠中,画一个小圈,不断扎根。 好了,废话不多说,本周精读的文章是:introducing-rekit-studio-a-real-ide-for-react-and-redux-development。 1 引言以前,我们不断完善前端基础设施建设,现在前端不缺工具和库了,下一步怎么发展? 发展方向有很多,比如继续完善框架和库、争论数据流的取舍、推动 ECMA 规范打造未来蓝图、投入新语言的怀抱、可视化规范与平台的建设等等。 有一个没啥技术含量的领域正在成长,就是前端工具链整合。 我这里说的没技术含量可不是贬义,所谓有技术含量的 “造轮子” 才是贬义呢。当我们陶醉于前端技术能力时,产品和后端往往就认为我们是写网页的,根本没啥深奥技术,如果改个文案都喊着成本高,更会被人看不起。 前端的职责就是提升人机交互体验,这不是大话空话,蚂蚁的体验技术才代表了前端技术的精髓,代表了互联网大行业对前端的期望。 前端工具链的整合,拿的都是已有的技术,目标也是把复杂的项目维护变简单,最终要推动的是解放前端开发人员的精力,让我们不再陶醉于自己的一亩三分地,转而将精力投入到业务与人机交互体验的提升中。 正如之前所说,现在不缺前端基础设施了,我们对项目管理的思路也要有所转变。JS 无所不能,但做项目不能无法无天,约定产生效率,工具链保证约定。 当我们用工具链保证了项目结构的约定,就可以抽象出项目的逻辑结构。 有人走在更前面,Rekit Studio,就是根据文件结构解析出逻辑结构的工具,让开发以逻辑结构管理项目,真的可能带来项目维护、开发成本的大幅优化。 2 概述 一图胜千言,仔细看完图,不然文章就漏掉了一半。 Rekit Studio 以逻辑视图重新组织了项目,文件目录不见了,取而代之的是路由、Action、组件等,原本若干文件拼凑成的 Action 被聚合成一个按钮,统一管理。 同时支持在线管理本地文件、集成了 https://microsoft.github.io/monaco-editor/ 在线编辑文件,以及在线构建、测试等功能。 同时利用和弦图分析了路由与数据流之间绑定关系,路由与文件绑定关系,可以很轻松找到被遗弃的孤立节点。项目维护时,以看图代替看代码,效率至少提升 2 3 倍。 Cli 与可视化等效Rekit Studio 还提供了 Rekit CLI 可以完全用命令行达到可视化的效果,在比如插件化、二次开发,或者特定场景下保留了通用拓展的能力。 只是辅助工具,而非必须Rekit Studio 虽然拥有强大项目管理能力,但它不参与项目具体开发流程,项目可以脱离它独自开发,并且 Rekit 也不会内置任何 npm 包在项目中。 也就是说,Rekit Studio 的设计初衷,是为了增强项目管理能力,而不是参与到项目的开发流程中。 3 精读传统的云端开发应该不会大规模普及,毕竟网页的体验和本地 IDE 差距还是非常大的。 但网页优势在与图形化表达,以及脚本化,如果一个按钮可以帮你管理许多本地文件,那这种混合式云端开发的价值就非常大。 Rekit Studio 的尝试,给我们打开了一个网页管理本地文件的脑洞,再结合 next.js 看看,可以碰撞出什么火花呢? next.jsnext.js 支持许多约定,比如自动路由: 在 pages 下创建的文件会自动识别为路由,url 路径就是以 pages 开头的文件路径。 正因为如此,所以 next.js 对项目拥有强力的约束能力,支持了更多特性: code splitting因为路由和构建脚本都有 next.js 控制,因此支持将页面级别模块按需加载。 静态文件处理由于 next.js 包含对 node 端控制,自然可以处理静态文件:将 static 文件夹下的文件路由到 /static 路径。 页面请求数据每个页面级组件都支持静态函数 getInitialProps,这个方法的返回值不仅会注入到 props,更可以在 ssr 时预加载这部分数据。 页面预加载成为 Prefetching Pages,只要在 Link 标签添加 prefetch 属性,就会在空闲阶段预加载这个页面的按需 js 文件,几乎同时保证了整包的用户体验,与按需加载带来的 js 文件体积优化,只要用户别点击的太快。 Dynamic Import支持动态 import,这个是 webpack 刚支持不久的 TC39 新特性,可以按需加载任何文件与 npm 包。详情见 react-loadable. 自定义配置next.js 支持自定义错误处理、自定义 webpack、babel 和 next.js 导出配置等。比较有用的是 publishPath,因为大公司开发的 js 文件肯定会存储在专门的 CDN 节点。 静态 html 文件导出主要目的是做 GitHub Pages,这个功能与去年火起来的 gatsby 比较像。天下技术一大抄,之前还有 hexo、jekyll 几乎功能与 gatsby 一摸一样,起码应该在 readme 里写个 Inspired by hexo 吧。 到这里,next.js 核心功能差不多介绍完了,大家可以发现,next.js 对项目自动化管理能力很强,唯独缺少了可视化管理功能。 尝试结合 Rekit Studio 与 next.js实在对不起大家,这里要做一个硬广。 因为我同时看好 next.js 对项目约定化管理能力与 Rekit Studio 的可视化辅助能力,同时又很欣赏 parcel 的零配置理念,因此基于 parcel 做了一个三合一项目工具链:pri。 我真不是为了赚 star,这个项目在写文章时是 0 star,而且是过年这几天刚写的,很多功能还没开发完,就又赶着写精读了,所以不指望通过这篇文章赚粉,而是希望抛砖引玉,看看能不能吸引志同道合的朋友。 此刻想吐槽的同学别着急,等过段时间我写一篇彻彻底底的硬广软文时,再吐槽也不急。 硬广时间结束。下面重点说说为什么做 pri,以及制作过程中的一些思考。 各取所长提取这三个框架的精华: 融化在项目中的脚手架 - next.js。 网页也能管理代码 - Rekit Studio。 坚持零配置到底 - parcel。 我为什么觉得这三点叠加起来一起提高项目的开发效率和可维护性? 融化在项目中的脚手架都说一个项目中一百个文件可能有一百种写法,这就是为什么要约法三章。不仅约束目录结构,我们还约束 npm 包,固定 react/vue/ag 的版本号,提交时不仅强制 lint,还强制检测文件结构是否符合约定。 项目开发时,遵守约定可能带来一点的不自由的感觉,但是对项目进度影响微乎其微,不稳定的项目结构对后期维护成本的影响,可能导致 “这个文案改不了”。 构建脚本也固化下来,云构建时使用的就是项目依赖的脚手架做编译,脚手架不再游离于项目之外。 最后不用说的,满足条件后,就可以前面罗列的 next.js 强大的功能。 网页也能管理代码我看中的可不是 Rekit Studio 在线写代码的功能,那个是鸡肋!而是按照规范自动生成文件的功能,恰恰可以解决约定带来的不适感。 任何上手的人,不需要了解约定,就可以通过可视化界面看到项目拥有几个路由、数据流、组件等等,然后在网页上一键创建新页面,新数据流、新组件,不仅省去了机械化劳动,省去了查看约定规范文档的时间,还带来可模版市场的可能性。 可视化带来的不仅是项目理解成本大大降低,由于项目约定的存在,网页管理可以更加智能。甚至是自动检测项目是否有文件存在异常、通过语法树,比如 typescript 包分析各文件中语法层面的关联,让可视化界面显示更加详细的项目关系图。 当新版本脚手架发布时,如果对项目约定产生了修改,也可以按照规则写出固定的升级方案,并通过可视化界面提示用户如何一步步升级到新版约定结构,甚至一键升级。 正因为对项目拥有强力约束,所以脚手架才知道老项目该如何升级。唯一的缺点是,不要有任何项目开发细节游离于规则之外,这需要对业务方案进行完整设计,当产生新需求时,将其固化到规则中,而不是任其自由发展。 坚持零配置到底parcel 坚持认为,如果提供了配置文件,那和 webpack 有什么区别呢?pri 的理念也一样,如果提供了配置文件,那抛开可视化功能,和 next.js 以及其他脚手架又有什么区别呢? 配置是为了扩大通用范围而产生的,设想 webpack 如果只解决简单的 commonjs 脚本引用,那也不需要复杂的配置。parcel 内置了对 sass less typescript png json html 等等文件的处理,所以也不需要配置。 但是,没有配置的 webpack(且不说 4.0)无法解决基本项目开发需要,而无配置的 parcel 几乎可以胜任任何项目开发,而它唯一的缺点就是,可能无法胜任未来某个新语法的支持。但只要 parcel 继续维护,这个语法需求足够大,都可以继续内置进去。 可以看到,parcel 与 webpack 的竞争,是开源界一场配置战争的缩影,大到对所有类型项目的支持,parcel 都敢坚持无配置,那么小到某条业务线的管理,真的还需要配置吗? 对于 publicUrl 这种参数,或者页面 title 之类的本身就无法确定的项目,还是需要提供配置的,当然这种配置是可控的。 项目开发中,遇到新需求,就将这个特殊处理逻辑内置到管理脚本中,恰恰解决了程序员最头疼的 “历史包袱” 问题。 同时对特殊逻辑内置的取舍、讨论,可以促进项目积极的发展,而不是配置能力过强,导致开发者时不时偷偷给项目增加一些新逻辑,以满足业务临时需求,累积到最后导致项目无人能接手。 4 总结谈来谈去,并没有涉及到复杂的算法或者新技术,讨论的只是一种项目管理思想的不断自我挑战,整合与创新。 我并没有打算留下一个中庸的结局,我现在正在积极投入文中提及的三条思路的整合开发,并相信这是未来的趋势之一,并且确实能解决项目开发与维护遇到的难题。 我列出我认为应当拥有的所有功能与特性,欢迎大家在评论区补充,或者给 pri 提 issue: 功能 页面即路由。 支持 layouts 布局。 404 处理。 自定义配置。 - 主要解决 publicUrl 等无法给出标准值的配置。 内置数据流并自动绑定到页面。 前端环境变量。 - 可以由自定义配置拓展,内置基本变量,比如是本地环境还是生产打包。 Serve static files。 项目单测。 生成静态 HTML,支持 github pages。 特征 项目可视化管理仪表盘。 - 可视化管理代码,根据约定规范。 typescript 支持(个人偏好的)。 tslint(jslint)在执行任何脚本时强制校验。 Dynamic import。 热更新 | HMR(parcel 内置)。 code splitting(parcel 内置)。 公共依赖提取(parcel 内置)。 自动补全项目配置文件。 - 比如 .gitignore tslint.json 等等,可以以 merge 的方式保证基础配置存在。 PWA 支持。 Tree Shaking(parcel 暂时不支持,webpack 支持)。 Scope Hoist(parcel 暂时不支持,webpack 支持)。 Prefetching. 5 更多讨论 讨论地址是:精读《Rekit Studio》 · Issue ##63 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Records & Tuples 提案》","path":"/wiki/WebWeekly/前沿技术/《Records & Tuples 提案》.html","content":"当前期刊数: 223 immutablejs、immer 等库已经让 js 具备了 immutable 编程的可能性,但还存在一些无解的问题,即 “怎么保证一个对象真的不可变”。 如果不是拍胸脯担保,现在还真没别的办法。或许你觉得 frozen 是个 good idea,但它内部仍然可以增加非 frozen 的 key。 另一个问题是,当我们 debug 调试应用数据的时候,看到状态发生 [] -> [] 变化时,无论在控制台、断点、redux devtools 还是 .toString() 都看不出来引用有没有变化,除非把变量值分别拿到进行 === 运行时判断。但引用变与没变可是一个大问题,它甚至能决定业务逻辑的正确与否。 但现阶段我们没有任何处理办法,如果不能接受完全使用 Immutablejs 定义对象,就只能摆胸脯保证自己的变更一定是 immutable 的,这就是 js 不可变编程被许多聪明人吐槽的原因,觉得在不支持 immutable 的编程语言下强行应用不可变思维是一种很别扭的事。 proposal-record-tuple 解决的就是这个问题,它让 js 原生支持了 不可变数据类型(高亮、加粗)。 概述 & 精读JS 有 7 种原始类型:string, number, bigint, boolean, undefined, symbol, null. 而 Records & Tuples 提案一下就增加了三种原始类型!这三种原始类型完全是为 immutable 编程环境服务的,也就是说,可以让 js 开出一条原生 immutable 赛道。 这三种原始类型分别是 Record, Tuple, Box: Record: 类对象结构的深度不可变基础类型,如 ##{ x: 1, y: 2 }。 Tuple: 类数组结构的深度不可变基础类型,如 ##[1, 2, 3, 4]。 Box: 可以定义在上面两个类型中,存储对象,如 ##{ prop: Box(object) }。 核心思想可以总结为一句话:因为这三个类型为基础类型,所以在比较时采用值对比(而非引用对比),因此 ##{ x: 1, y: 2} === ##{ x: 1, y: 2 }。这真的解决了大问题!如果你还不了解 js 不支持 immutable 之痛,请不要跳过下一节。 js 不支持 immutable 之痛虽然很多人都喜欢 mvvm 的 reactive 特征(包括我也写了不少 mvvm 轮子和框架),但不可变数据永远是开发大型应用最好的思想,它可以非常可靠的保障应用数据的可预测性,同时不需要牺牲性能与内存,它使用起来没有 mutable 模式方便,但它永远不会出现预料外的情况,这对打造稳定的复杂应用至关重要,甚至比便捷性更加重要。当然可测试也是个非常重要的点,这里不详细展开。 然而 js 并不原生支持 immutable,这非常令人头痛,也造成了许多困扰,下面我试图解释一下这个困扰。 如果你觉得非原始类型按照引用对比很棒,那你一定一眼能看出下面的结果是正确的: assert({ a: 1 } !== { a: 1 }) 但如果是下面的情况呢? console.log(window.a) // { a: 1 }console.log(window.b) // { a: 1 }assert(window.a === window.b) // ??? 结果是不确定,虽然这两个对象长得一样,但我们拿到的 scope 无法推断其是否来自同一个引用,如果来自于相同的引用,则断言通过,否则即便看上去值一样,也会 throw error。 更大的麻烦是,即便这两个对象长得完全不一样,我们也不敢轻易下结论: console.log(window.a) // { a: 1 }// do some change..console.log(window.b) // { b: 1 }assert(window.a === window.b) // ??? 因为 b 的值可能在中途被修改,但确实与 a 来自同一个引用,我们无法断定结果到底是什么。 另一个问题则是应用状态变更的扑朔迷离。试想我们开发了一个树形菜单,结构如下: { "id": "1", "label": "root", "children": [{ "id": "2", "label": "apple", }, { "id": "3", "label": "orange", }]} 如果我们调用 updateTreeNode('3', { id: '3', title: 'banana' }),在 immutable 场景下我们仅更新 id 为 “1”, “3” 组件的引用,而 id 为 “2” 的引用不变,那么这棵树节点 “2” 就不会重渲染,这是血统纯正的 immutable 思维逻辑。 但当我们保存下这个新状态后,要进行 “状态回放”,会发现其实应用状态进行了一次变更,整个描述 json 变成了: { "id": "1", "label": "root", "children": [{ "id": "2", "label": "apple", }, { "id": "3", "label": "banana", }]} 但如果我们拷贝上面的文本,把应用状态直接设置为这个结果,会发现与 “应用回放按钮” 的效果不同,这时 id “2” 也重渲染了,因为它的引用变化了。 问题就是我们无法根据肉眼观察出引用是否变化了,即便两个结构一模一样,也无法保证引用是否相同,进而导致无法推断应用的行为是否一致。如果没有人为的代码质量管控,出现非预期的引用更新几乎是难以避免的。 这就是 Records & Tuples 提案要解决问题的背景,我们带着这个理解去看它的定义,就更好学习了。 Records & Tuples 在用法上与对象、数组保持一致Records & Tuples 提案说明,不可变数据结构除了定义时需要用 ## 符号申明外,使用时与普通对象、数组无异。 Record 用法与普通 object 几乎一样: const proposal = ##{ id: 1234, title: "Record & Tuple proposal", contents: `...`, // tuples are primitive types so you can put them in records: keywords: ##["ecma", "tc39", "proposal", "record", "tuple"],};// Accessing keys like you would with objects!console.log(proposal.title); // Record & Tuple proposalconsole.log(proposal.keywords[1]); // tc39// Spread like objects!const proposal2 = ##{ ...proposal, title: "Stage 2: Record & Tuple",};console.log(proposal2.title); // Stage 2: Record & Tupleconsole.log(proposal2.keywords[1]); // tc39// Object functions work on Records:console.log(Object.keys(proposal)); // ["contents", "id", "keywords", "title"] 下面的例子说明,Records 与 object 在函数内处理时并没有什么不同,这个在 FAQ 里提到是一个非常重要的特性,可以让 immutable 完全融入现在的 js 生态: const ship1 = ##{ x: 1, y: 2 };// ship2 is an ordinary object:const ship2 = { x: -1, y: 3 };function move(start, deltaX, deltaY) { // we always return a record after moving return ##{ x: start.x + deltaX, y: start.y + deltaY, };}const ship1Moved = move(ship1, 1, 0);// passing an ordinary object to move() still works:const ship2Moved = move(ship2, 3, -1);console.log(ship1Moved === ship2Moved); // true// ship1 and ship2 have the same coordinates after moving Tuple 用法与普通数组几乎一样: const measures = ##[42, 12, 67, "measure error: foo happened"];// Accessing indices like you would with arrays!console.log(measures[0]); // 42console.log(measures[3]); // measure error: foo happened// Slice and spread like arrays!const correctedMeasures = ##[ ...measures.slice(0, measures.length - 1), -1];console.log(correctedMeasures[0]); // 42console.log(correctedMeasures[3]); // -1// or use the .with() shorthand for the same result:const correctedMeasures2 = measures.with(3, -1);console.log(correctedMeasures2[0]); // 42console.log(correctedMeasures2[3]); // -1// Tuples support methods similar to Arraysconsole.log(correctedMeasures2.map(x => x + 1)); // ##[43, 13, 68, 0] 在函数内处理时,拿到一个数组或 Tuple 并没有什么需要特别注意的区别: const ship1 = ##[1, 2];// ship2 is an array:const ship2 = [-1, 3];function move(start, deltaX, deltaY) { // we always return a tuple after moving return ##[ start[0] + deltaX, start[1] + deltaY, ];}const ship1Moved = move(ship1, 1, 0);// passing an array to move() still works:const ship2Moved = move(ship2, 3, -1);console.log(ship1Moved === ship2Moved); // true// ship1 and ship2 have the same coordinates after moving 由于 Record 内不能定义普通对象(比如定义为 ## 标记的不可变对象),如果非要使用普通对象,只能包裹在 Box 里,并且在获取值时需要调用 .unbox() 拆箱,并且就算修改了对象值,在 Record 或 Tuple 层面也不会认为发生了变化: const myObject = { x: 2 };const record = ##{ name: "rec", data: Box(myObject)};console.log(record.data.unbox().x); // 2// The box contents are classic mutable objects:record.data.unbox().x = 3;console.log(myObject.x); // 3console.log(record === ##{ name: "rec", data: Box(myObject) }); // true 另外不能在 Records & Tuples 内使用任何普通对象或 new 对象实例,除非已经用转化为了普通对象: const instance = new MyClass();const constContainer = ##{ instance: instance};// TypeError: Record literals may only contain primitives, Records and Tuplesconst tuple = ##[1, 2, 3];tuple.map(x => new MyClass(x));// TypeError: Callback to Tuple.prototype.map may only return primitives, Records or Tuples// The following should work:Array.from(tuple).map(x => new MyClass(x)) 语法Records & Tuples 内只能使用 Record、Tuple、Box: ##{}##{ a: 1, b: 2 }##{ a: 1, b: ##[2, 3, ##{ c: 4 }] }##[]##[1, 2]##[1, 2, ##{ a: 3 }] 不支持空数组项: const x = ##[,]; // SyntaxError, holes are disallowed by syntax 为了防止引用追溯到上层,破坏不可变性质,不支持定义原型链: const x = ##{ __proto__: foo }; // SyntaxError, __proto__ identifier prevented by syntaxconst y = ##{ ["__proto__"]: foo }; // valid, creates a record with a "__proto__" property. 也不能在里面定义方法: ##{ method() { } } // SyntaxError 同时,一些破坏不可变稳定结构的特性也是非法的,比如 key 不可以是 Symbol: const record = ##{ [Symbol()]: ##{} };// TypeError: Record may only have string as keys 不能直接使用对象作为 value,除非用 Box 包裹: const obj = {};const record = ##{ prop: obj }; // TypeError: Record may only contain primitive valuesconst record2 = ##{ prop: Box(obj) }; // ok 判等判等是最核心的地方,Records & Tuples 提案要求 == 与 === 原生支持 immutable 判等,是 js 原生支持 immutable 的一个重要表现,所以其判等逻辑与普通的对象判等大相径庭: 首先看上去值相等,就真的相等,因为基础类型仅做值对比: assert(##{ a: 1 } === ##{ a: 1 });assert(##[1, 2] === ##[1, 2]); 这与对象判等完全不同,而且把 Record 转换为对象后,判等就遵循对象的规则了: assert({ a: 1 } !== { a: 1 });assert(Object(##{ a: 1 }) !== Object(##{ a: 1 }));assert(Object(##[1, 2]) !== Object(##[1, 2])); 另外 Records 的判等与 key 的顺序无关,因为有个隐式 key 排序规则: assert(##{ a: 1, b: 2 } === ##{ b: 2, a: 1 });Object.keys(##{ a: 1, b: 2 }) // ["a", "b"]Object.keys(##{ b: 2, a: 1 }) // ["a", "b"] Box 是否相等取决于内部对象引用是否相等: const obj = {};assert(Box(obj) === Box(obj));assert(Box({}) !== Box({})); 对于 +0 -0 之间,NaN 与 NaN 对比,都可以安全判定为相等,但 Object.is 因为是对普通对象的判断逻辑,所以会认为 ##{ a: -0 } 不等于 ##{ a: +0 },因为认为 -0 不等于 +0,这里需要特别注意。另外 Records & Tulpes 也可以作为 Map、Set 的 key,并且按照值相等来查找: assert(##{ a: 1 } === ##{ a: 1 });assert(##[1] === ##[1]);assert(##{ a: -0 } === ##{ a: +0 });assert(##[-0] === ##[+0]);assert(##{ a: NaN } === ##{ a: NaN });assert(##[NaN] === ##[NaN]);assert(##{ a: -0 } == ##{ a: +0 });assert(##[-0] == ##[+0]);assert(##{ a: NaN } == ##{ a: NaN });assert(##[NaN] == ##[NaN]);assert(##[1] != ##["1"]);assert(!Object.is(##{ a: -0 }, ##{ a: +0 }));assert(!Object.is(##[-0], ##[+0]));assert(Object.is(##{ a: NaN }, ##{ a: NaN }));assert(Object.is(##[NaN], ##[NaN]));// Map keys are compared with the SameValueZero algorithmassert(new Map().set(##{ a: 1 }, true).get(##{ a: 1 }));assert(new Map().set(##[1], true).get(##[1]));assert(new Map().set(##[-0], true).get(##[0])); 对象模型如何处理 Records & Tuples对象模型是指 Object 模型,大部分情况下,所有能应用于普通对象的方法都可无缝应用于 Record,比如 Object.key 或 in 都可与处理普通对象无异: const keysArr = Object.keys(##{ a: 1, b: 2 }); // returns the array ["a", "b"]assert(keysArr[0] === "a");assert(keysArr[1] === "b");assert(keysArr !== ##["a", "b"]);assert("a" in ##{ a: 1, b: 2 }); 值得一提的是如果 wrapper 了 Object 在 Record 或 Tuple,提案还准备了一套完备的实现方案,即 Object(record) 或 Object(tuple) 会冻结所有属性,并将原型链最高指向 Tuple.prototype,对于数组跨界访问也只能返回 undefined 而不是沿着原型链追溯。 Records & Tuples 的标准库支持对 Record 与 Tuple 进行原生数组或对象操作后,返回值也是 immutable 类型的: assert(Object.keys(##{ a: 1, b: 2 }) !== ##["a", "b"]);assert(##[1, 2, 3].map(x => x * 2), ##[2, 4, 6]); 还可通过 Record.fromEntries 和 Tuple.from 方法把普通对象或数组转成 Record, Tuple: const record = Record({ a: 1, b: 2, c: 3 });const record2 = Record.fromEntries([##["a", 1], ##["b", 2], ##["c", 3]]); // note that an iterable will also workconst tuple = Tuple(...[1, 2, 3]);const tuple2 = Tuple.from([1, 2, 3]); // note that an iterable will also workassert(record === ##{ a: 1, b: 2, c: 3 });assert(tuple === ##[1, 2, 3]);Record.from({ a: {} }); // TypeError: Can't convert Object with a non-const value to RecordTuple.from([{}, {} , {}]); // TypeError: Can't convert Iterable with a non-const value to Tuple 此方法不支持嵌套,因为标准 API 仅考虑一层,递归一般交给业务或库函数实现,就像 Object.assign 一样。 Record 与 Tuple 也都是可迭代的: const tuple = ##[1, 2];// output is:// 1// 2for (const o of tuple) { console.log(o); }const record = ##{ a: 1, b: 2 };// TypeError: record is not iterablefor (const o of record) { console.log(o); }// Object.entries can be used to iterate over Records, just like for Objects// output is:// a// bfor (const [key, value] of Object.entries(record)) { console.log(key) } JSON.stringify 会把 Record & Tuple 转化为普通对象: JSON.stringify(##{ a: ##[1, 2, 3] }); // '{"a":[1,2,3]}'JSON.stringify(##[true, ##{ a: ##[1, 2, 3] }]); // '[true,{"a":[1,2,3]}]' 但同时建议实现 JSON.parseImmutable 将一个 JSON 直接转化为 Record & Tuple 类型,其 API 与 JSON.parse 无异。 Tuple.prototype 方法与 Array 很像,但也有些不同之处,主要区别是不会修改引用值,而是创建新的引用,具体可看 appendix。 由于新增了三种原始类型,所以 typeof 也会新增三种返回结果: assert(typeof ##{ a: 1 } === "record");assert(typeof ##[1, 2] === "tuple");assert(typeof Box({}) === "box"); Record, Tuple, Box 都支持作为 Map、Set 的 key,并按照其自身规则进行判等,即 const record1 = ##{ a: 1, b: 2 };const record2 = ##{ a: 1, b: 2 };const map = new Map();map.set(record1, true);assert(map.get(record2)); const record1 = ##{ a: 1, b: 2 };const record2 = ##{ a: 1, b: 2 };const set = new Set();set.add(record1);set.add(record2);assert(set.size === 1); 但不支持 WeakMap、WeakSet: const record = ##{ a: 1, b: 2 };const weakMap = new WeakMap();// TypeError: Can't use a Record as the key in a WeakMapweakMap.set(record, true); const record = ##{ a: 1, b: 2 };const weakSet = new WeakSet();// TypeError: Can't add a Record to a WeakSetweakSet.add(record); 原因是不可变数据没有一个可预测的垃圾回收时机,这样如果用在 Weak 系列反而会导致无法及时释放,所以 API 不匹配。 最后提案还附赠了理论基础与 FAQ 章节,下面也简单介绍一下。 理论基础为什么要创建新的原始类型,而不是像其他库一样在上层处理?一句话说就是让 js 原生支持 immutable 就必须作为原始类型。假如不作为原始类型,就不可能让 ==, === 操作符原生支持这个类型的特定判等,也就会导致 immutable 语法与其他 js 代码仿佛处于两套逻辑体系下,妨碍生态的统一。 开发者会熟悉这套语法吗?由于最大程度保证了与普通对象与数组处理、API 的一致性,所以开发者上手应该会比较容易。 为什么不像 Immutablejs 一样使用 .get .set 方法操作?这会导致生态割裂,代码需要关注对象到底是不是 immutable 的。一个最形象的例子就是,当 Immutablejs 与普通 js 操作库配合时,需要写出类似如下代码: state.jobResult = Immutable.fromJS( ExternalLib.processJob( state.jobDescription.toJS() )); 这有非常强的割裂感。 为什么不使用全局 Record, Tuple 方法代替 ## 申明?下面给了两个对比: // with the proposed syntaxconst record = ##{ a: ##{ foo: "string", }, b: ##{ bar: 123, }, c: ##{ baz: ##{ hello: ##[ 1, 2, 3, ], }, },};// with only the Record/Tuple globalsconst record = Record({ a: Record({ foo: "string", }), b: Record({ bar: 123, }), c: Record({ baz: Record({ hello: Tuple( 1, 2, 3, ), }), }),}); 很明显后者没有前者简洁,而且也打破了开发者对对象、数组 Like 的认知。 为什么采用 ##[]/##{} 语法?采用已有关键字可能导致歧义或者兼容性问题,另外其实还有 {| |} [| |] 的 提案,但目前 ## 的赢面比较大。 为什么是深度不可变?这个提案喷了一下 Object.freeze: const object = { a: { foo: "bar", },};Object.freeze(object);func(object); 由于只保障了一层,所以 object.a 依然是可变的,既然要 js 原生支持 immutable,希望的肯定是深度不可变,而不是只有一层。 另外由于这个语法会在语言层面支持不可变校验,而深度不可变校验是非常重要的。 FAQ如何基于已有不可变对象创建一个新不可变对象?大部分语法都是可以使用的,比如解构: // Add a Record fieldlet rec = ##{ a: 1, x: 5 }##{ ...rec, b: 2 } // ##{ a: 1, b: 2, x: 5 }// Change a Record field##{ ...rec, x: 6 } // ##{ a: 1, x: 6 }// Append to a Tuplelet tup = ##[1, 2, 3];##[...tup, 4] // ##[1, 2, 3, 4]// Prepend to a Tuple##[0, ...tup] // ##[0, 1, 2, 3]// Prepend and append to a Tuple##[0, ...tup, 4] // ##[0, 1, 2, 3, 4] 对于类数组的 Tuple,可以使用 with 语法替换新建一个对象: // Change a Tuple indexlet tup = ##[1, 2, 3];tup.with(1, 500) // ##[1, 500, 3] 但在深度修改时也遇到了绕不过去的问题,目前有一个 提案 在讨论这件事,这里提到一个有意思的语法: const state1 = ##{ counters: ##[ ##{ name: "Counter 1", value: 1 }, ##{ name: "Counter 2", value: 0 }, ##{ name: "Counter 3", value: 123 }, ], metadata: ##{ lastUpdate: 1584382969000, },};const state2 = ##{ ...state1, counters[0].value: 2, counters[1].value: 1, metadata.lastUpdate: 1584383011300,};assert(state2.counters[0].value === 2);assert(state2.counters[1].value === 1);assert(state2.metadata.lastUpdate === 1584383011300);// As expected, the unmodified values from "spreading" state1 remain in state2.assert(state2.counters[2].value === 123); counters[0].value: 2 看上去还是蛮新颖的。 与 Readonly Collections 的关系?互补。 可以基于 Class 创建 Record 实例吗?目前不考虑。 TS 也有 Record 与 Tuple 关键字,之间的关系是?熟悉 TS 的同学都知道只是名字一样而已。 性能预期是?这个问题挺关键的,如果这个提案性能不好,那也无法用于实际生产。 当前阶段没有对性能提出要求,但在 Stage4 之前会给出厂商优化的最佳实践。 总结如果这个提案与嵌套更新提案一起通过,在 js 使用 immutable 就得到了语言层面的保障,包括 Immutablejs、immerjs 在内的库是真的可以下岗啦。 讨论地址是:精读《Records & Tuples 提案》· Issue ##384 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Rust 是 JS 基建的未来》","path":"/wiki/WebWeekly/前沿技术/《Rust 是 JS 基建的未来》.html","content":"当前期刊数: 218 Rust Is The Future of JavaScript Infrastructure 这篇文章讲述了 Rust 正在 JS 基建圈流行的事实:Webpack、Babel、Terser、Prettier、ESLint 这些前些年才流行起来的工具都已有了 Rust 替代方案,且性能有着 10~100 倍的提升。 前端基建的迭代浪潮从未停歇,当上面这些工具给 Gulp、js-beautify、tslint 等工具盖上棺材盖时,基于 Rust 的新一代构建工具已经悄悄将棺材盖悬挂在 webpack、babel、prettier、terser、eslint 它们头上,不知道哪天就会盖上。 原文已经有了不错的 中文翻译,值得一提的是,原文一些英文名词对应着特定中文解释,记录如下: low-level programming:低级编程 底层编程。 ergonomics:人体工程学 人机工程学。 opinionated:自以为是,固执的 开箱即用的。 critical adoption:批判性采用 技术选型临界点。 精读本文不会介绍 Rust 如何使用,而会重点介绍原文提到的 Rust 工具链的一些基本用法,如果你感兴趣,可以立刻替换现有的工具库! swcswc 是基于 Rust 开发的一系列编译、打包、压缩等工具,并且被广泛应用于更多更上层的 JS 基建,大大推动了 Rust 在 JS 基建的影响力,所以要第一个介绍。 swc 提供了一系列原子能力,涵盖构建与运行时: @swc/cli@swc/cli 可以同时构建 js 与 ts 文件: const a = 1 npm i -D @swc/clinpx swc ./main.ts## output:## Successfully compiled 1 file with swc.## var a = 1; 具体功能与 babel 类似,都可以让浏览器支持先进语法或者 ts,只是 @swc/cli 比 babel 快了至少 20 倍。可以通过 .swcrc 文件做 自定义配置。 @swc/core你可以利用 @swc/core 制作更上层的构建工具,所以它是 @swc/cli 的开发者调用版本。基本 API 来自官网开发者文档: const swc = require("@swc/core");swc .transform("source code", { // Some options cannot be specified in .swcrc filename: "input.js", sourceMaps: true, // Input files are treated as module by default. isModule: false, // All options below can be configured via .swcrc jsc: { parser: { syntax: "ecmascript", }, transform: {}, }, }) .then((output) => { output.code; // transformed code output.map; // source map (in string) }); 其实就是把 cli 调用改成了 node 调用。 @swc/wasm-web@swc/wasm-web 可以在浏览器运行时调用 wasm 版的 swc,以得到更好的性能。下面是官方的例子: import { useEffect, useState } from "react";import initSwc, { transformSync } from "@swc/wasm-web";export default function App() { const [initialized, setInitialized] = useState(false); useEffect(() => { async function importAndRunSwcOnMount() { await initSwc(); setInitialized(true); } importAndRunSwcOnMount(); }, []); function compile() { if (!initialized) { return; } const result = transformSync(`console.log('hello')`, {}); console.log(result); } return ( <div className="App"> <button onClick={compile}>Compile</button> </div> );} 这个例子可以在浏览器运行时做类似 babel 的事情,无论是低代码平台还是在线 coding 平台都可以用它做运行时编译。 @swc/jest@swc/jest 提供了 Rust 版本的 jest 实现,让 jest 跑得更快。使用方式也很简单,首先安装: npm i @swc/jest 然后在 jest.config.js 配置文件中,将 ts 文件 compile 指向 @swc/jest 即可: module.exports = { transform: { "^.+\\\\.(t|j)sx?$": ["@swc/jest"], },}; swc-loaderswc-loader 是针对 webpack 的 loader 插件,代替 babel-loader: module: { rules: [ { test: /\\.m?js$/, exclude: /(node_modules)/, use: { // `.swcrc` can be used to configure swc loader: "swc-loader" } } ];} swcpack增强了多文件 bundle 成一个文件的功能,基本可以认为是 swc 版本的 webpack,当然性能也会比 swc-loader 方案有进一步提升。 截至目前,该功能还在测试阶段,只要安装了 @swc/cli 就可使用,通过创建 spack.config.js 后执行 npx spack 即可运行,和 webpack 的使用方式一样。 DenoDeno 的 linter、code formatter、文档生成器采用 swc 构建,因此也算属于 Rust 阵营。 Deno 是一种新的 js/ts 运行时,所以我们总喜欢与 node 进行类比。quickjs 也一样,这三个都是一种对 js 语言的运行器,作为开发者,需求永远是更好的性能、兼容性与生态,三者几乎缺一不可,所以当下虽然不能完全代替 Nodejs,但作为高性能替代方案是很香的,可以基于他们做一些跨端跨平台的解析器,比如 kraken 就是基于 quickjs + flutter 实现的一种高性能 web 渲染引擎,是 web 浏览器的替代方案,作为一种跨端方案。 esbuildesbuild 是较早被广泛使用的新一代 JS 基建,是 JS 打包与压缩工具。虽然采用 Go 编写,但性能与 Rust 不相上下,可以与 Rust 风潮放在一起看。 esbuild 目前有两个功能:编译和压缩,理论上分别可代替 babel 与 terser。 编译功能的基本用法: require('esbuild').transformSync('let x: number = 1', { loader: 'ts',})// 'let x = 1; ' 压缩功能的基本用法: require('esbuild').transformSync('fn = obj => { return obj.x }', { minify: true,})// 'fn=n=>n.x; ' 压缩功能比较稳定,适合用在生产环境,而编译功能要考虑兼容 webpack 的地方太多,在成熟稳定后才考虑能在生产环境使用,目前其实已经有不少新项目已经在生产环境使用 esbuild 的编译功能了。 编译功能与 @swc 类似,但因为 Rust 支持编译到 wasm,所以 @swc 提供了 web 运行时编译能力,而 esbuild 目前还没有看到这种特性。 RomeRome 是 Babel 作者做的基于 Nodejs 的前端基建全家桶,包含但不限于 Babel, ESLint, webpack, Prettier, Jest。目前 计划使用 Rust 重构,虽然还没有实现,但我们姑且可以把 Rome 当作 Rust 的一员。 rome 是个全家桶 API,所以你只需要 yarn add rome 就完成了所有环境准备工作。 rome bundle 打包项目。 rome compile 编译单个文件。 rome develop 调试项目。 rome parse 解析文件抽象语法树。 rome analyzeDependencies 分析依赖。 Rome 还将文件格式化与 Lint 合并为了 rome check 命令,并提供了友好 UI 终端提示。 其实我并不太看好 Rome,因为它负担太重了,测试、编译、Lint、格式化、压缩、打包的琐碎事情太多,把每一块交给社区可能会做得更好,这不现在还在重构中,牵一发而动全身。 NAPI-RSNAPI-RS 提供了高性能的 Rust 到 Node 的衔接层,可以将 Rust 代码编译后成为 Node 可调用文件。下面是官网的例子: ##[js_function(1)]fn fibonacci(ctx: CallContext) -> Result<JsNumber> { let n = ctx.get::<JsNumber>(0)?.try_into()?; ctx.env.create_int64(fibonacci_native(n))} 上面写了一个斐波那契数列函数,直接调用了 fibonacci_native 函数实现。为了让这个方法被 Node 调用,首先安装 CLI:npm i @napi-rs/cli。 由于环境比较麻烦,因此需要利用这个脚手架初始化一个工作台,我们在里面写 Rust,然后再利用固定的脚本发布 npm 包。执行 napi new 创建一个项目,我们发现入口文件肯定是个 js,毕竟要被 node 引用,大概长这样(我创建了一个 myLib 包): const { loadBinding } = require('@node-rs/helper')/** * __dirname means load native addon from current dir * 'myLib' is the name of native addon * the second arguments was decided by `napi.name` field in `package.json` * the third arguments was decided by `name` field in `package.json` * `loadBinding` helper will load `myLib.[PLATFORM].node` from `__dirname` first * If failed to load addon, it will fallback to load from `myLib-[PLATFORM]` */module.exports = loadBinding(__dirname, 'myLib', 'myLib') 所以 loadBinding 才是入口,同时项目文件夹下存在三个系统环境包,分别供不同系统环境调用: @cool/core-darwin-x64 macOS x64 平台。 @cool/core-win32-x64 Windows x64 平台。 @cool/core-linux-arm64-gnu Linux aarch64 平台。 @node-rs/helper 这个包的作用是引导 node 执行预编译的二进制文件,loadBinding 函数会尝试加载当前平台识别的二进制包。 将 src/lib.rs 的代码改成上面斐波那契数列的代码后,执行 npm run build 编译。注意在编译前需要安装 rust 开发环境,只要一行脚本即可安装,具体看 rustup.rs。然后把当前项目整体当作 node 包发布即可。 发布后,就可以在 node 代码中引用啦: import { fibonacci } from 'myLib'function hello() { let result = fibonacci(10000) console.log(result) return result} NAPI-RS 作为 Rust 与 Node 的桥梁,很好的解决了 Rust 渐进式替换现有 JS 工具链的问题。 Rust + WebAssemblyRust + WebAssembly 说明 Rust 具备编译到 wasm 的能力,虽然编译后代码性能会变得稍慢,但还是比 js 快很多,同时由于 wasm 的可移植性,让 Rust 也变得可移植了。 其实 Rust 支持编译到 WebAssembly 也不奇怪,因为本来 WebAssembly 的定位之一就是作为其他语言的目标编译产物,然后它本身支持跨平台,这样它就很好的完成了传播的使命。 WebAssembly 是一个基于栈的虚拟机 (stack machine),所以跨平台能力一流。 想要将 Rust 编译为 wasm,除了安装 Rust 开发环境外,还要安装 wasm-pack。 安装后编译只需执行 wasm-pack build 即可。更多用法可以查看 API 文档。 dprintdprint 是用 rust 编写的 js/ts 格式化工具,并提供了 dprint-node 版本,可以直接作为 node 包,通过 npm 安装使用,从 源码 可以看到,使用 NAPI-RS 实现。 dprint-node 可以直接在 Node 中使用: const dprint = require('dprint-node');dprint.format(filePath, code, options); 参数文档。 ParcelParcel 严格来说算是上一代 JS 基建,它出现在 Webpack 之后,Rust 风潮之前。不过由于它已经采用 SWC 重写,所以姑且算是跟上了时髦。 总结前端全家桶已经有了一整套 Rust 实现,只是对于存量项目的编译准确性需要大量验证,我们还需要时间等待这些库的成熟度。 但毫无疑问的是,Rust 语言对 JS 基建支持已经较为完备了,剩下的只是工具层逻辑覆盖率的问题,都可以随时间而解决。而用 Rust 语言重写后的逻辑带来的巨幅性能提升将为社区注入巨大活力,就像原文说的,前端社区可以为了巨大性能提升而引入 Rust 语言,即便这可能导致为社区贡献门槛的提高。 讨论地址是:精读《Rust 是 JS 基建的未来》· Issue ##371 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Records & Tuples for React》","path":"/wiki/WebWeekly/前沿技术/《Records & Tuples for React》.html","content":"当前期刊数: 224 继前一篇 精读《Records & Tuples 提案》,已经有人在思考这个提案可以帮助 React 解决哪些问题了,比如这篇 Records & Tuples for React,就提到了许多 React 痛点可以被解决。 其实我比较担忧浏览器是否能将 Records & Tuples 性能优化得足够好,这将是它能否大规模应用,或者说我们是否放心把问题交给它解决的最关键因素。本文基于浏览器可以完美优化其性能的前提,一切看起来都挺美好,我们不妨基于这个假设,看看 Records & Tuples 提案能解决哪些问题吧! 概述Records & Tuples Proposal 提案在上一篇精读已经介绍过了,不熟悉可以先去看一下提案语法。 保证不可变性虽然现在 React 也能用 Immutable 思想开发,但大部分情况无法保证安全性,比如: const Hello = ({ profile }) => { // prop mutation: throws TypeError profile.name = 'Sebastien updated'; return <p>Hello {profile.name}</p>;};function App() { const [profile, setProfile] = React.useState(##{ name: 'Sebastien', }); // state mutation: throws TypeError profile.name = 'Sebastien updated'; return <Hello profile={profile} />;} 归根结底,我们不会总使用 freeze 来冻结对象,大部分情况下需要人为保证引用不被修改,其中的潜在风险依然存在。但使用 Record 表示状态,无论 TS 还是 JS 都会报错,立刻阻止问题扩散。 部分代替 useMemo比如下面的例子,为了保障 apiFilters 引用不变,需要对其 useMemo: const apiFilters = useMemo( () => ({ userFilter, companyFilter }), [userFilter, companyFilter],);const { apiData, loading } = useApiData(apiFilters); 但 Record 模式不需要 memo,因为 js 引擎会帮你做类似的事情: const {apiData,loading} = useApiData(##{ userFilter, companyFilter }) 用在 useEffect这段写的很啰嗦,其实和代替 useMemo 差不多,即: const apiFilters = ##{ userFilter, companyFilter };useEffect(() => { fetchApiData(apiFilters).then(setApiDataInState);}, [apiFilters]); 你可以把 apiFilters 当做一个引用稳定的原始对象看待,如果它确实变化了,那一定是值改变了,所以才会引发取数。如果把上面的 ## 号去掉,每次组件刷新都会取数,而实际上都是多余的。 用在 props 属性可以更方便定义不可变 props 了,而不需要提前 useMemo: <ExpensiveChild someData={##{ attr1: 'abc', attr2: 'def' }} />; 将取数结果转化为 Record这个目前还真做不到,除非用性能非常差的 JSON.stringify 或 deepEqual,用法如下: const fetchUserAndCompany = async () => { const response = await fetch( `https://myBackend.com/userAndCompany`, ); return JSON.parseImmutable(await response.text());}; 即利用 Record 提案的 JSON.parseImmutable 将后端返回值也转化为 Record,这样即便重新查询,但如果返回结果完全不变,也不会导致重渲染,或者局部变化也只会导致局部重渲染,而目前我们只能放任这种情况下全量重渲染。 然而这对浏览器实现 Record 的新能优化提出了非常严苛的要求,因为假设后端返回的数据有几十 MB,我们不知道这种内置 API 会导致多少的额外开销。 假设浏览器使用非常 Magic 的办法做到了几乎零开销,那么我们应该在任何时候都用 JSON.parseImmutable 解析而不是 JSON.parse。 生成查询参数也是利用了 parseImmutable 方法,让前端可以精确发送请求,而不是每次 qs.parse 生成一个新引用就发一次请求: // This is a non-performant, but working solution.// Lib authors should provide a method such as qs.parseRecord(search)const parseQueryStringAsRecord = (search) => { const queryStringObject = qs.parse(search); // Note: the Record(obj) conversion function is not recursive // There's a recursive conversion method here: // https://tc39.es/proposal-record-tuple/cookbook/index.html return JSON.parseImmutable( JSON.stringify(queryStringObject), );};const useQueryStringRecord = () => { const { search } = useLocation(); return useMemo(() => parseQueryStringAsRecord(search), [ search, ]);}; 还提到一个有趣的点,即到时候配套工具库可能提供类似 qs.parseRecord(search) 的方法把 JSON.parseImmutable 包装掉,也就是这些生态库想要 “无缝” 接入 Record 提案其实需要做一些 API 改造。 避免循环产生的新引用即便原始对象引用不变,但我们写几行代码随便 .filter 一下引用就变了,而且无论返回结果是否变化,引用都一定会改变: const AllUsers = [ { id: 1, name: 'Sebastien' }, { id: 2, name: 'John' },];const Parent = () => { const userIdsToHide = useUserIdsToHide(); const users = AllUsers.filter( (user) => !userIdsToHide.includes(user.id), ); return <UserList users={users} />;};const UserList = React.memo(({ users }) => ( <ul> {users.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul>)); 要避免这个问题就必须 useMemo,但在 Record 提案下不需要: const AllUsers = ##[ ##{ id: 1, name: 'Sebastien' }, ##{ id: 2, name: 'John' },];const filteredUsers = AllUsers.filter(() => true);AllUsers === filteredUsers;// true 作为 React key这个想法更有趣,如果 Record 提案保证了引用严格不可变,那我们完全可以拿 item 本身作为 key,而不需要任何其他手段,这样维护成本会大大降低。 const list = ##[ ##{ country: 'FR', localPhoneNumber: '111111' }, ##{ country: 'FR', localPhoneNumber: '222222' }, ##{ country: 'US', localPhoneNumber: '111111' },];<> {list.map((item) => ( <Item key={item} item={item} /> ))}</> 当然这依然建立在浏览器非常高效实现 Record 的前提,假设浏览器采用 deepEqual 作为初稿实现这个规范,那么上面这坨代码可能导致本来不卡的页面直接崩溃退出。 TS 支持也许到时候 ts 会支持如下方式定义不可变变量: const UsersPageContent = ({ usersFilters,}: { usersFilters: ##{nameFilter: string, ageFilter: string}}) => { const [users, setUsers] = useState([]); // poor-man's fetch useEffect(() => { fetchUsers(usersFilters).then(setUsers); }, [usersFilters]); return <Users users={users} />;}; 那我们就可以真的保证 usersFilters 是不可变的了。因为在目前阶段,编译时 ts 是完全无法保障变量引用是否会变化。 优化 css-in-js采用 Record 与普通 object 作为 css 属性,对 css-in-js 的区别是什么? const Component = () => ( <div css={##{ backgroundColor: 'hotpink', }} > This has a hotpink background. </div>); 由于 css-in-js 框架对新的引用会生成新 className,所以如果不主动保障引用不可变,会导致渲染时 className 一直变化,不仅影响调试也影响性能,而 Record 可以避免这个担忧。 精读总结下来,其实 Record 提案并不是解决之前无法解决的问题,而是用更简洁的原生语法解决了复杂逻辑才能解决的问题。这带来的优势主要在于 “不容易写出问题代码了”,或者让 Immutable 在 js 语言的上手成本更低了。 现在看下来这个规范有个严重担忧点就是性能,而 stage2 并没有对浏览器实现性能提出要求,而是给了一些建议,并在 stage4 之前给出具体性能优化建议方案。 其中还是提到了一些具体做法,包括快速判断真假,即对数据结构操作时的优化。 快速判真可以采用类似 hash-cons 快速判断结构相等,可能是将一些关键判断信息存在 hash 表中,进而不需要真的对结构进行递归判断。 快速判假可以通过维护散列表快速判断,或者我觉得也可以用上数据结构一些经典算法,比如布隆过滤器,就是用在高效快速判否场景的。 Record 降低了哪些心智负担其实如果应用开发都是 hello world 复杂度,那其实 React 也可以很好的契合 immutable,比如我们给 React 组件传递的 props 都是 boolean、string 或 number: <ExpensiveChild userName="nick" age={18} isAdmin />; 比如上面的例子,完全不用关心引用会变化,因为我们用的原始类型本身引用就不可能变化,比如 18 不可能突变成 19,如果子组件真的想要 19,那一定只能创建一个新的,总之就是没办法改变我们传递的原始类型。 如果我们永远在这种环境下开发,那 React 结合 immutable 会非常美妙。但好景不长,我们总是要面对对象、数组的场景,然而这些类型在 js 语法里不属于原始类型,我们了解到还有 “引用” 这样一种说法,两个值不一样对象可能是 === 全等的。 可以认为,Record 就是把这个顾虑从语法层面消除了,即 ##{ a: 1 } 也可以看作像 18,19 一样的数字,不可能有人改变它,所以从语法层面你就会像对 19 这个数字一样放心 ##{ a: 1 } 不会被改变。 当然这个提案面临的最大问题就是 “如何将拥有子结构的类型看作原始类型”,也许 JS 引擎将它看作一种特别的字符串更贴合其原理,但难点是这又违背了整个语言体系对子结构的默认认知,Box 装箱语法尤其别扭。 总结看了这篇文章的畅想,React 与 Records & Tulpes 结合的一定会很好,但前提是浏览器对其性能优化必须与 “引用对比” 大致相同才可以,这也是较为少见,对性能要求如此苛刻的特性,因为如果没有性能的加持,其便捷性将毫无意义。 讨论地址是:精读《Records & Tuples for React》· Issue ##385 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Rest vs Spread 语法》","path":"/wiki/WebWeekly/前沿技术/《Rest vs Spread 语法》.html","content":"当前期刊数: 261 符号 ... 在 JS 语言里同时被用作 Rest 与 Spread 两个场景,本周我们就结合 Rest vs Spread syntax in JavaScript 聊聊这两者的差异以及一些坑。 概述Spread... 作为 Spread 含义时,效果为扩散对象的属性: const obj = { a: 1, b: 2, c: 3,};const newObj = { ...obj,};console.log(newObj);// { a: 1, b: 2, c: 3 } ... 符号很形象的表示了把对象中所有属性拿出来平铺的含义。说到平铺,Spread 放在函数参数时,也表示将对象中每个 properties 拿出来作为平铺参数: const arr = [1, 2, 3];const sum = (a, b, c) => a + b + c;console.log(sum(...arr)); // Outputs: 6// ^// sum(1, 2, 3) Rest... 作 Rest 含义时,表示将多个值收集为一个数组,如用在函数定义的位置: const sum = (...args) => { return args.reduce((acc, curr) => acc + curr, 0); // ^ // [1, 2, 3]};console.log(sum(1, 2, 3));// 6 当然也可以在 ... 前面放置其他变量,这样 ... 仅聚合剩余的变量。... 之后不能再定义变量或者 ...: const sum = (a, b, ...restOfArguments) => { return a + b + restOfArguments.reduce((acc, curr) => acc + curr, 0); // ^ ^ ^ // 1 2 [3, 4, 5]};console.log(sum(1, 2, 3, 4, 5));// 15 精读Rest 处理 Set 与 MapSet 与 Map 都可以通过数组模式赋初值: const mySet = new Set(["a", "b", "c"]);const myMap = new Map([ ["a", 1], ["b", 2], ["c", 3],]); 在 ... 符号作 Rest 用途时,可以将其解构为数组: [...mySet] // ['a', 'b', 'c'][...myMap] // [ ['a', 1], ['b', 2], ['c', 3] ] 特别的,Map 与 Set 仅支持数组方式解构,不支持对象模式解构: {...mySet} // {}{...myMap} // {} 但对于一个普通数组,是同时支持数组与对象模式解构的: const arr = ['a', 'b', 'c'][...arr] // ['a', 'b', 'c']{...arr} // {0: 'a', 1: 'b', 2: 'c'} 这是因为数组变量有潜在的下标,这些下标可以转换为对象的 Key,而 Map Set 不存在下标,所以转换为对象找不到 Key,因此就不支持对象模式的解构。 更具体的原因与对象的可迭代性有关,虽然 Map 与 Set 都支持迭代,但如果用 for key of 来测试,会发现它们的 key 是 undefined。 Spread 会丢失 get() 与 set()Spread 并不代表完整复制整个对象,它能拷贝这个对象属性定义中的瞬时值,比如: const obj = { a: 1, get b() { return 2; },};const newObj = { ...obj }; newObj.b 属性不再是 get() 方法,而是固定值 2,这在 get() 函数内返回非固定值,或希望懒加载代码时会产生问题。 究其原因,Spread 毕竟不是在定义对象,更恰当的理解应该是 “访问对象”,所以访问的结果就是执行 get()。 Rest 会跳过不可枚举属性const err = new Error('error'){...error} // {} Error 拥有两个不可枚举属性 message 与 stack,所以不会被 Rest 收集到,遇到这种场景可以使用其他方式,如直接访问 error.message。 总结... 用在赋值位置含义为 Spread,用在参数收集位置含义为 Rest,同时因为该语法写起来很简单,因此有一些默认逻辑小心不要掉坑里,比如默认会执行对象属性的 getter,会跳过不可枚举属性等。 讨论地址是:精读《Rest vs Spread 语法》· Issue ##447 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《SQL vs Flux》","path":"/wiki/WebWeekly/前沿技术/《SQL vs Flux》.html","content":"当前期刊数: 69 1 引言 对时序数据的处理有两种方式,如图所示,右边是 SQL,左边是自定义查询语言,也称为 NoSQL,处于中间地带的称为 SQL-LIKE 语言。 本文通过对比 SQL 阵营的 TimescaleDB 与 NoSQL 阵营的 InfluxDB,试图给出一些对比。 2 概述TimescaleDBTimescaleDB 完全接受了 SQL 语法,因此几乎没有什么学习门槛,更通过可视化操作优化了使用方式。 InfluxDBInfluxDB 创造了一种新的查询语言,这里是 Flux 文法.(了解更多文法相关知识,可以移步 精读《手写 SQL 编译器 - 文法介绍》) InfluxDB 为什么创造 Flux 语法InfluxDB 之所以创造 Flux 语法,而不使用 SQL,主要有两个原因: 更强的查询功能:SQL 无法轻松完成时序查询。 时间序列的查询需要基于流的函数模型,而不是 SQL 的代数模型。 所谓流模型,就类似 JS 函数式编程中类似概念: source.pipe( map(x => x + x), mergeMap(...), filter(...)) 更强的查询功能?InfluxDB 拿下面例子举例: Flux: from(db:"telegraf") |> range(start:-1h) |> filter(fn: (r) => r._measurement == "foo") |> exponentialMovingAverage(size:-10s) SQL: select id, temp, avg(temp) over (partition by group_nr order by time_read) as rolling_avgfrom ( select id, temp, time_read, interval_group, id - row_number() over (partition by interval_group order by time_read) as group_nr from ( select id, time_read, 'epoch'::timestamp + '900 seconds'::interval * (extract(epoch from time_read)::int4 / 900) as interval_group, temp from readings ) t1) t2order by time_read; 虽然看上去 SQL 写法比 Flux 长了不少,但其实 Flux 代码的核心在于实现了自定义函数 exponentialMovingAverage,而 PostgreSQL 也有 创建函数 的能力。 通过 SQL 定义一个自定义函数: CREATE OR REPLACE FUNCTION exponential_moving_average_sfunc(state numeric, next_value numeric, alpha numeric)RETURNS numeric LANGUAGE SQL AS$$SELECT CASE WHEN state IS NULL THEN next_value ELSE alpha * next_value + (1-alpha) * state END$$;CREATE AGGREGATE exponential_moving_average(numeric, numeric)(sfunc = exponential_moving_average_sfunc, stype = numeric); 之后可以像 Flux 函数一样的调用: SELECT time, exponential_moving_average(value, 0.5) OVER (ORDER BY time)FROM telegraphWHERE measurement = 'foo' and time > now() - '1 hour'; 可见从函数定义上也和 Flux 打成平手,作者认为既然功能相同,而基于 SQL 的语言学习成本更低,所以不需要创造一个新的语言。 关于语法糖与 SQL 标准作者认为,虽然有观点认为,Flux 的语法糖比 SQL 更简洁,但代码的可维护性并不是行数越少越好,而是是否容易被人类理解。 对于创造一个函数标准可能破坏 SQL 的可移植性,作者认为那也比完全创造一个新语法要强。 基于流的函数模型强于 SQL 代数模型?诚然,从功能角度来看,当然函数模型强于代数模型,因为代数模型只是在描述事物,而不能精准控制执行的每一步。 但我们要弄清楚 SQL 的场景,是通过描述一个无顺序的查询问题,让数据库给出结果。而在查询过程中,数据库可以对 SQL 语句作出一些优化。 反观函数模型,是在用业务代码描述查询请求,这种代码是无法被自动优化的,虽然为用户提供了更底层的控制,但其代价是无法被数据库执行引擎所优化。 如果你更看中查询语言,而不是具体执行逻辑,SQLl 依然是最好的选择。 3 总结之所以制作这一期精读,是为了探索 SQL 与其他查询语言的关系,去理解为什么 SQL 沿用至今。 SQL 与其他函数类查询语言不在一个层面上,如果用语法糖、可操纵性抨击 SQL,只能得出看似正确,实则荒谬的结论。 SQL 是一个查询语言,与普通编程语言相比,它还在上层,最终会转化为关系代数执行,但关系代数会遵循一些等价的转换规律,比如交换律、结合律、过滤条件拆分等等,通过预估每一步的时间开销,将 SQL 执行顺序重新组合,可以提高执行效率。 如果有多个 SQL 同时执行,还可以整合成一个或多个新的 SQL,合并重复的查询请求。 在数据驱动商业的今天,SQL 依然是数据查询最通用的解决方案。 4 更多讨论 讨论地址是:精读《SQL vs Flux》 · Issue ##96 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《Scheduling in React》","path":"/wiki/WebWeekly/前沿技术/《Scheduling in React》.html","content":"当前期刊数: 99 1. 引言这次介绍的文章是 scheduling-in-react,简单来说就是 React 的调度系统,为了得到更顺滑的用户体验。 毕竟前端做到最后,都是体验优化,前端带给用户的价值核心就在于此。 2. 概述文章从 Dan 在 JSConf 提到的 Demo 说起: 这是一个测试性能的 Demo,随着输入框字符的增加,下方图表展示的数据量会急速提升。在 Synchronous 与 Debounced 模式下的效果都不尽如人意,只有 Concurrent 模式下看起来是顺畅的。 那么为什么普通的 Demo 会很卡呢? 这就涉及到浏览器 Event Loop 规则了。 JS 是单线程的,浏览器同一时间只能做一件事情,而肉眼能识别的刷新频率在 60FPS 左右,这意味着我们需要在 16ms 之内完成 Demo 中的三件事:响应用户输入,做动画,Dom 渲染。 然而目前几乎所有框架都使用同步渲染模式,这意味着如果一个渲染函数执行时间超过了 16ms,则不可避免的发生卡顿。 总结一下有两个主要问题: 长时间运行的任务造成页面卡顿,我们需要保证所有任务能在几毫秒内完成,这样才能保证页面的流畅。 不同任务优先级不同,比如响应用户输入的任务优先级就高于动画。这个很好理解。 React 调度机制为了解决这个问题,React16 通过 Concurrent(并行渲染) 与 Scheduler(调度)两个角度解决问题: Concurrent: 将同步的渲染变成可拆解为多步的异步渲染,这样可以将超过 16ms 的渲染代码分几次执行。 Scheduler: 调度系统,支持不同渲染优先级,对 Concurrent 进行调度。当然,调度系统对低优先级任务会不断提高优先级,所以不会出现低优先级任务总得不到执行的情况。 为了保证不产生阻塞的感觉,调度系统会将所有待执行的回调函数存在一份清单中,在每次浏览器渲染时间分片间尽可能的执行,并将没有执行完的内容 Hold 住留到下个分片处理。 Concurrent 的正式 API 会在 2019 Q2 发布,现在可以通过 <React.unstable_ConcurrentMode> API 方式调用: ReactDOM.render( <React.unstable_ConcurrentMode> <App /> </React.unstable_ConcurrentMode>, rootElement); 只申明这个是不够的,因为我们还没有申明各函数执行的优先级。我们可以通过 npm i scheduler 包来申明函数的优先级: import { unstable_next } from "scheduler";function SearchBox(props) { const [inputValue, setInputValue] = React.useState(); function handleChange(event) { const value = event.target.value; setInputValue(value); unstable_next(function() { props.onChange(value); sendAnalyticsNotification(value); }); } return <input type="text" value={inputValue} onChange={handleChange} />;} 在 unstable_next() 作用域下的代码优先级是 Normal,那么产生的效果是: 如果 props.onChange(value) 可以在 16ms 内执行完,则与不使用 unstable_next 没有区别。 如果 props.onChange(value) 的执行时间过长,可能这个函数会在下次几次的 Render 中陆续执行,不会阻塞后续的高优先级任务。 调度带来的限制调度系统也存在两个问题。 调度系统只能有一个,如果同时存在两个调度系统,就无法保证调度正确性。 调度系统能力有限,只能在浏览器提供的能力范围内进行调度,而无法影响比如 Html 的渲染、回收周期。 为了解决这个问题,Chrome 正在与 React、Polymer、Ember、Google Maps、Web Standars Community 共同创建一个 浏览器调度规范,提供浏览器级别 API,可以让调度控制更底层的渲染时机,也保证调度器的唯一性。 3. 精读关于 React 调度系统的剖析,可以读 深入剖析 React Concurrent 这篇文章,感谢我们团队的 淡苍 提供。 简单来说,一次 Render 一般涉及到许多子节点,而 Fiber 架构在 Render 阶段可以暂停,一个一个节点的执行,从而实现了调度的能力。 React 调度能力的限制 这意味着,如果你的 React 应用目前是流畅的,开启 Concurrent 并不会对你的应用带来性能体验上的提升,如果你的 React 应用目前是卡顿的,或者在某些场景下是卡顿的,那么 Concurrent 或许可以挽救你一下,带来一些改变。 正如《深入剖析 React Concurrent》一文提到的,如果你的应用没有性能问题,就不要指望 React 调度能力有所帮助了。 这也是在说,如果一段代码逻辑不存在性能问题,就不需要使用 Concurrent 优化,因为这种优化是无效的。我们需要能分辨哪些逻辑需要优化,哪些逻辑不要。 从现在开始尝试 Function Component为了配合 React Schedule 的实现,学会使用 Function Component 模式编写组件是很重要的,因为: Class Component 的生命周期概念阻碍了 React 调度系统对任务的拆分。 调度系统可能对 componentWillMount 重复调用,使得 Class Component 模式下很容易写出错误的代码。 Function Component 遵循了更严格的副作用分离,这使得 Concurrent 执行过程不会引发意外效果。 React.lazy与 Concurrent 一起发布的,还有 React 组件动态 import 与载入方案。正常的组件载入是这样的: import OtherComponent from "./OtherComponent";function MyComponent() { return ( <div> <OtherComponent /> </div> );} 但如果使用了 import() 动态载入,可以使用 React.lazy 让动态引入的组件像普通组件一样被使用: const OtherComponent = React.lazy(() => import("./OtherComponent"));function MyComponent() { return ( <div> <OtherComponent /> </div> );} 如果要加入 Loading,就可以配合 Suspense 一起使用: import React, { lazy, Suspense } from "react";const OtherComponent = lazy(() => import("./OtherComponent"));function MyComponent() { return ( <Suspense fallback={<div>Loading...</div>}> <OtherComponent /> </Suspense> );} 和 Concurrent 类似,React.lazy 方案也是一种对性能有益的组件加载方案。 调度分类调度分 4 个等级: Immediate:立即执行,最高优先级。 render-blocking:会阻塞渲染的优先级,优先级类似 requestAnimationFrame。如果这种优先级任务不能被执行,就可能导致 UI 渲染被 block。 default:默认优先级,普通的优先级。优先级可以理解为 setTimeout(0) 的优先级。 idle:比如通知等任务,用户看不到或者不在意的。 目前建议的 API 类似如下: function mytask() { ...}myQueue = TaskQueue.default("render-blocking") 先创建一个执行队列,并设置队列的优先级。 taskId = myQueue.postTask(myTask, <list of args>); 再提交队列,拿到当前队列的执行 id,通过这个 id 可以判断队列何时执行完毕。 myQueue.cancelTask(taskId); 必要的时候可以取消某个函数的执行。 4. 总结随着 Hooks 的发布,即将到来的 Concurrent 与 Suspense 你是否准备好了呢? 笔者希望大家一起思考,这三种 API 会给前端开发带来什么样的改变?欢迎留言! 讨论地址是:精读《Scheduling in React》 · Issue ##146 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Serverless 给前端带来了什么》","path":"/wiki/WebWeekly/前沿技术/《Serverless 给前端带来了什么》.html","content":"当前期刊数: 94 1. 引言Serverless 是一种 “无服务器架构”,让用户无需关心程序运行环境、资源及数量,只要将精力 Focus 到业务逻辑上的技术。 现在公司已经实现 DevOps 化,正在向 Serverless 迈进,而为什么前端要关注 Serverless? 对业务前端同学: 会改变前后端接口定义规范。 一定会改变前后端联调方式,让前端参与服务器逻辑开发,甚至 Node Java 混部。 大大降低 Nodejs 服务器维护门槛,只要会写 JS 代码就可以维护 Node 服务,而无需学习 DevOps 相关知识。 对一个自由开发者: 未来服务器部署更弹性,更省钱。 部署速度更快,更不易出错。 前端框架总是带入后端思维,而 Serverless 则是把前端思维带入了后端运维。 前端开发者其实是最早享受到 “Serverless” 好处的群体。他们不需要拥有自己的服务,甚至不需要自己的浏览器,就可以让自己的 JS 代码均匀、负载均衡的运行在每一个用户的电脑中。 而每个用户的浏览器,就像现在最时髦,最成熟的 Serverless 集群,从远程加载 JS 代码开始冷启动,甚至在冷启动上也是卓越领先的:利用 JIT 加速让代码实现毫秒级别的冷启动。不仅如此,浏览器还是实现了 BAAS 服务的完美环境,我们可以调用任何函数获取用户的 Cookie、环境信息、本地数据库服务,而无需关心用户用的是什么电脑,连接了怎样的网络,甚至硬盘的大小。 这就是 Serverless 理念。通过 FAAS(函数即服务)与 BAAS(后台即服务)企图在服务端制造前端开发者习以为常的开发环境,所以前端开发者应该更能理解 Serverless 带来的好处。 2. 精读FAAS(函数即服务) + BAAS(后台即服务) 可以称为一个完整的 Serverless 的实现,除此之外,还有 PASS(平台即服务)的概念。而通常平台环境都通过容器技术实现,最终都为了达到 NoOps(无人运维),或者至少 DevOps(开发&运维)。 简单介绍一下这几个名词,防止大家被绕晕: FAAS - Function as a service 函数即服务,每一个函数都是一个服务,函数可以由任何语言编写,除此之外不需要关心任何运维细节,比如:计算资源、弹性扩容,而且可以按量计费,且支持事件驱动。业界大云厂商都支持 FAAS,各自都有一套工作台、或者可视化工作流来管理这些函数。 BAAS - Backend as a service 后端及服务,就是集成了许多中间件技术,可以无视环境调用服务,比如数据即服务(数据库服务),缓存服务等。虽然下面还有很多 XAAS,但组成 Serverless 概念的只有 FAAS + BAAS。 PAAS - Platform as a service 平台即服务,用户只要上传源代码就可以自动持续集成并享受高可用服务,如果速度足够快,可以认为是类似 Serverless。但随着以 Docker 为代表的容器技术兴起,以容器为粒度的 PAAS 部署逐渐成为主流,是最常用的应用部署方式。比如中间件、数据库、操作系统等。 DAAS - Data as a service 数据即服务,将数据采集、治理、聚合、服务打包起来提供出去。DAAS 服务可以应用 Serverless 的架构。 IAAS - Infrastructure as a Service 基础设施即服务,比如计算机存储、网络、服务器等基建设施以服务的方式提供。 SAAS - Software as a Service 软件即服务,比如 ERP、CRM、邮箱服务等,以软件为粒度提供服务。 容器 容器就是隔离了物理环境的虚拟程序执行环境,而且环境可被描述、迁移。比较热门的容器技术是 Docker。 随着容器数量增多,就出现了管理容器集群的技术,比较有名的容器编排平台是 Kubernetes。容器技术是 Serverless 架构实现的一种选择,也是实现的基础。 NoOps 就是无人运维,比较理想主义,也许要借助 AI 的能力才能实现完全无人运维。 无人运维不代表 Serverless,Serverless 可能也需要人运维(至少现在),只是开发者不再需要关心环境。 DevOps 笔者觉得可以理解为 “开发即运维”,毕竟出了事情,开发要被问责,而一个成熟的 DevOps 体系可以让更多的开发者承担 OP 的职责,或者与 OP 更密切的合作。 回到 Serverless,未来后端开发的体验可能与前端相似:不需要关心代码运行在哪台服务器(浏览器),无需关心服务器环境(浏览器版本)、不用担心负载均衡(前端从未担心过)、中间件服务随时调用(LocalStorage、Service Worker)。 前端同学对 Serverless 应该尤为激动。就拿笔者亲身经历举例吧。 从做一款游戏说起笔者非常迷恋养成类游戏,养成游戏最常见的就是资源建造、收集,或者挂机时计算资源的 读秒规则。笔者在开发游戏的时候,最初是将客户端代码与服务端代码完全分成两套实现的: // ... UI 部分,画出一个倒计时伐木场建造进度条const currentTime = await requestBuildingProcess();const leftTime = new Date().getTime() - currentTime;// ... 继续倒计时读条// 读条完毕后,每小时木头产量 + 100,更新到客户端计时器store.woodIncrement += 100; 为了游戏体验,用户可以在不刷新浏览器的情况下,看到伐木场建造进度的读条,以及 嘭 一下建造完毕,并且发现每秒钟多获得了 100 点木材!但是当伐木场 建造完成前、完成时、完成后的任意时间点刷新浏览器,都要保持逻辑的统一,而且数据需要在后端离线计算。 此时就要写后端代码了: // 每次登陆时,校验当前登陆const currentTime = new Date().getTime()// 获取伐木场当前状态if ( /* 建造中 */) { // 返回给客户端当前时间 const leftTime = building.startTime - currentTime res.body = leftTime} else { // 建造完毕 store.woodIncrement += 100} 很快,建筑的种类多了起来,不同的状态、等级产量都不同,前后端分开维护成本会越来越大,我们需要做配置同步。 配置同步为了做前后端配置同步,可以将配置单独托管起来前后端共用,比如新建一个配置文件,专门存储游戏信息: export const buildings = { wood: { name: "..", maxLevel: 100, increamentPerLevel: 50, initIncreament: 100 } /* .. and so on .. */}; 这虽然复用了配置,但前后端都有一些共同的逻辑可以复用,比如 根据建筑建造时间判断建筑状态,判断 N 秒后建筑的产量等等。 而 Serverless 带来了进一步优化的空间。 在 Serverless 环境下做游戏试想一下,可以在服务器以函数粒度执行代码,我们可以这样抽象游戏逻辑: // 根据建筑建造时间判断建筑状态export const getBuildingStatusByTime = (instanceId: number, time: number) => { /**/};// 判断建筑生产量export const getBuildingProduction = (instanceId: number, lastTime: number) => { const status = getBuildingStatusByTime(instanceId, new Date().getTime()); switch (status) { case "building": return 0; case "finished": // 根据 (当前时间 - 上次打开时间)* 每秒产量得到总产量 return; /**/ }};// 前端 UI 层,每隔一秒调用一次 getBuildingProduction 函数,及时更新生产数据// 前端入口函数export const frontendMain = () => { /**/};// 后端 根据每次打开时间,调用一次 getBuildingProduction 函数并入库// 后端入口函数export const backendMain = () => { /**/}; 利用 PASS 服务,将前后端逻辑写在一起,将 getBuildingProduction 函数片段上传至 FAAS 服务,这样就可以同时共享前后端逻辑了! 在文件夹视图下,可以做如下结构规划: .├── client ## 前端入口├── server ## 后端入口├── common ## 共享工具函数,可以包含 80% 的通用游戏逻辑 也许有人会问:前后端共享代码不止有 Serverless 才能做到。 的确如此,如果代码抽象足够好,有成熟的工程方案支持,是可以将一份代码分别导出到浏览器与服务器的。但 Serverless 基于函数粒度功能更契合前后端复用代码的理念,它的出现可能会推动更广泛的前后端代码复用,这虽然不是新发明,但足够称为一个伟大的改变。 前后端的视角对于前端开发者,会发现后台服务变简单了。对于后端开发者,发现服务做厚了,面临的挑战更多了。 更简单的后台服务传统 ECS 服务器在租赁时,CentOS 与 AliyunOS 的环境选择就足够让人烦恼。对个人开发者而言,我们要搭建一个完整的持续集成服务是很困难的,而且面临的选择很多,让人眼花缭乱: 可以在服务器安装数据库等服务,本地直联服务器的数据库进行开发。 可以本地安装 Docker 连接本地数据库服务,将环境打包成镜像整体部署到服务器。 将前后端代码分离,前端代码在本地开发,服务端代码在服务器开发。 甚至服务器的稳定性,需要 PM2 等工具进行管理。当服务器面临攻击、重启、磁盘故障时,打开复杂的工作台或登陆 Shell 后一通操作才能恢复。这怎么让人专心把精力放在要做的事情上呢? Serverless 解决了这个问题,因为我们要上传的只是一个代码片段,不再需要面对服务器、系统环境、资源等环境问题,外部服务也有封装好的 BAAS 体系支持。 实际上在 Serverless 出来之前,就有许多后端团队利用 FAAS 理念简化开发流程。 为了减少写后端业务逻辑时,环境、部署问题的干扰,许多团队会将业务逻辑抽象成一个个区块(Block),对应到代码片段或 Blockly,这些区块可以独立维护、发布,最后将这些代码片段注入到主程序中,或动态加载。如果习惯了这种开发方式,那也更容易接受 Serverless。 更厚的后台服务站在后台角度,事情就变得比较复杂了。相对于提供简单的服务器和容器,现在要对用户屏蔽执行环境,将服务做得更厚。 笔者通过一些文章了解到,Serverless 的推行还面临着如下一些挑战: Serverless 各厂商实现种类很多,想让业务做到多云部署,需要抹平差异。 成熟的 PASS 服务其实是伪 Serverless,后续该如何标准化。 FAAS 冷启动需要重新加载代码、动态分配资源,导致冷启动速度很慢,除了预热,还需要经济的优化方式。 对于高并发(比如双 11 秒杀)场景的应用,无需容量评估是很危险的事情,但如果真能做到完全弹性化,就省去了烦恼的容量评估。 存量应用如何迁移。业界的 Serverless 服务厂商大部分都没有解决存量应用迁移的问题。 Serverless 的特性导致了无状态,而复杂的互联网应用都是有状态的,因此挑战在不改变开发习惯的情况下支持状态。 所幸的是,这些问题都已经在积极处理中,而且不少有了已经落地的解决方案。 Serverless 给后台带来的好处远比面临的挑战多: 推进前后端一体化。进一步降低 Node 写服务端代码的门槛,免除应用运营的学习成本。笔者曾经遇到过自己申请的数据库服务被迁移到其他机房而导致的应用服务中断,以后再也不需要担心了,因为数据库作为 BAAS 服务,是不需要关心在哪部署,是否跨机房,以及如何做迁移的。 提高资源利用效率。杜绝应用独占资源,换成按需加载必然能减少不必要的资源消耗,而且将服务均摊到集群的每一台机器,拉平集群的 CPU 水位。 降低云平台使用门槛。无需运维,灵活拓展,按价值服务,高可用,这些能力在吸引更多客户的同时,完全按需计费的特性也减少了用户开销,达到双赢。 利用 Serverless 尝试服务开放笔者在公司负责一个大型 BI 分析平台建设,BI 分析平台的底层能力之一就是可视化搭建。 那么可视化搭建能力该如何开放呢?现在比较容易做到的是组件开放,毕竟前端可以与后端设计相对解耦,利用 AMD 加载体系也比较成熟。 现在遇到的一个挑战就是后端能力开放,因为当对取数能力有定制要求时,可能需要定制后端数据处理的逻辑。目前能做到的是利用 maven3、jdk7 搭建本地开发环境测试,如果想上线,还需要后端同学的协助。 如果后端搭建一个特有的 Serverless BAAS 服务,那么就可以像前端组件一样进行线上 Coding,调试,甚至灰度发布进行预发测试。现在前端云端开发已经有了不少成熟的探索,Serverless 可以统一前后端代码在云端开发的体验,而不需要关心环境。 Serverless 应用架构设计看了一些 Serverless 应用架构图,发现大部分业务都可以套用这样一张架构图: 将业务函数抽象成一个个 FAAS 函数,将数据库、缓存、加速等服务抽象成 BAAS 服务。 上层提供 Restful 或事件触发机制调用,对应到不同的端(PC、移动端)。 想要拓展平台能力,只要在端上做开放(组件接入)与 FAAS 服务做开放(后端接入)即可。 收益与挑战Serverless 带来了的收益与挑战并存,本文站在前端角度聊一聊。 收益一:前端更 Focus 在前端体验技术,而不需要具备太多应用管理知识。 最近看了很多前端前辈写的总结文,最大的体会就是回忆 “前端在这几年到底起到了什么作用”。我们往往会夸大自己的存在感,其实前端存在的意义就是解决人机交互问题,大部分场景下,都是一种锦上添花的作用,而不是必须。 回忆你最自豪的工作经历,可能是掌握了 Node 应用的运维知识、前端工程体系建设、研发效能优化、标准规范制定等,但真正对业务起效的部分,恰恰是你觉得写得最不值得一提的业务代码。前端花了太多的时间在周边技术上,而减少了很多对业务、交互的思考。 即便是大公司,也难以招到既熟练使用 Nodejs,又具备丰富运维知识的人,同时还要求他前端技术精湛,对业务理解深刻,鱼和熊掌几乎不可兼得。 Serverless 可以有效解决这个问题,前端同学只需要会写 JS 代码而无需掌握任何运维知识,就可以快速实现自己的整套想法。 诚然,了解服务端知识是有必要的,但站在合理分工的角度,前端就应该 focus 在前端技术上。前端的核心竞争力或者带来的业务价值,并不会随着了解多一些运维知识而得到补充,相反,这会吞噬掉我们本可以带来更多业务价值的时间。 语言的进化、浏览器的进化、服务器的进化,都是从复杂到简单,底层到封装的过程,而 Serverless 是后端 + 运维作为一个整体的进一步封装的过程。 收益二:逻辑编排带来的代码高度复用、可维护,拓展 云+端 的能力。 云+端 是前端开发的下个形态,提供强大的云编码能力,或者通过插件将端打造为类似云的开发环境。其最大好处就是屏蔽前端开发环境细节,理念与 Serverless 类似。 之前有不少团队尝试过利用 GraphQL 让接口 “更有弹性”,而 Serverless 则是更彻底的方案。 我自己的团队就尝试过 GraphQL 方案,但由于业务非常复杂,难以用标准的模型描述所有场景的需求,因此不适合使用 GraphQL。恰恰是一套基于 Blockly 的可视化后端开发平台坚持了下来,而且取得了惊人的开发提效。这套 Blockly 通用化抽象后几乎可以由 Serverless 来代替。所以 Serverless 可以解决复杂场景下后端研发提效的问题。 Serverless 在融合了云端开发后,就可以通过逻辑编排进一步可视化调整函数执行顺序、依赖关系。 笔者之前在百度广告数据处理团队使用过这种平台计算离线日志,每个 MapReduce 计算节点经过可视化后,就可以轻松看出故障时哪个节点在阻塞,还可以看到最长执行链路,并为每个节点重新分配执行权重。即便逻辑编排不能解决开发的所有痛点,但在某个具体业务场景下一定可以大有作为。 挑战一:Serverless 可以完全取消前端转后端的门槛? 前端同学写 Node 代码最容易犯的毛病就是内存溢出。 浏览器 + Tab 天然是一个用完即关的场景,UI 组件与逻辑创建与销毁也非常频繁,因此前端同学很少,也不太需要关心 GC 问题。而 GC 在后端开发场景中是一个早已养成的习惯,因此 Nodejs 程序缓存溢出是大家最关注的问题。 Serverless 应用是动态加载,长时间不用就会释放的,因此一般来说不需要太担心 GC 的问题,就算内存溢出,在内存被占满前可能已经进程被释放,或者被监测到异常强制 Kill 掉。 但毕竟 FAAS 函数的加载与释放完全是由云端控制的,一个常用的函数长时间不卸载也是有可能的,因此 FAAS 函数还是要注意控制副作用。 所以 Serverless 虽然抹平了运维环境,但服务端基本知识还需要了解,必须意识到代码跑在前端还是后端。 挑战二:性能问题 Serverless 的冷启动会导致性能问题,而让业务方主动关心程序的执行频率或者性能要求,再开启预热服务又重新将研发拖入了运维的深渊中。 即便是业界最成熟的亚马逊 Serverless 云服务,也无法做到业务完全不关心调用频率,就可以轻松应付秒杀场景。 因此目前 Serverless 可能更适合结合合适的场景使用,而不是任何应用都强行套用 Serverless。 虽然可以通过定期运行 FAAS 服务来保证程序一直 Online,但笔者认为这还是违背了 Serverless 的理念。 挑战三:如何保证代码可迁移性 有一张很经典的 Serverless 定位描述图: 网络、存储、服务、虚拟家、操作系统、中间件、运行时、数据都不需要关心了,甚至连应用层都只需要关心其中函数部分,而不需要关心其他比如启动、销毁部分。 前面总拿这点当优势,但也可以反过来认为是个劣势。 当你的代码完全依赖某个公有云环境后,你就失去了整体环境的掌控力,甚至代码都只能在特定的云平台才能运行。 不同云平台提供的 BAAS 服务规范可能不同,FAAS 的入口、执行方式也可能不同,想要采用多云部署就必须克服这个问题。 现在许多 Serverless 平台都在考虑做标准化,但同时也有一些自下而上的工具库抹平一些差异,比如 Serverless Framework 等。 而我们写 FAAS 函数时,也尽量将与平台绑定的入口函数写得轻一些,将真正的入口放在通用的比如 main 函数中。 3. 总结Serverless 的价值远比挑战大,其理念可以切实解决许多研发效能问题。 但目前 Serverless 发展阶段仍处于早期,国内的 Serverless 也处于尝试阶段,而且执行环境存在诸多限制,也就是并没有完全实现 Serverless 的美好理念,因此如果什么都往上套一定会踩坑。 可能在 3-5 年后,这些坑会被填平,那么你是选择加入填坑大军,还是选一个合适的场景使用 Serverless 呢? 讨论地址是:精读《Serverless 给前端带来了什么》 · Issue ##135 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《SolidJS》","path":"/wiki/WebWeekly/前沿技术/《SolidJS》.html","content":"当前期刊数: 255 SolidJS 是一个语法像 React Function Component,内核像 Vue 的前端框架,本周我们通过阅读 Introduction to SolidJS 这篇文章来理解理解其核心概念。 为什么要介绍 SolidJS 而不是其他前端框架?因为 SolidJS 在教 React 团队正确的实现 Hooks,这在唯 React 概念与虚拟 DOM 概念马首是瞻的年代非常难得,这也是开源技术的魅力:任何观点都可以被自由挑战,只要你是对,你就可能脱颖而出。 概述整篇文章以一个新人视角交代了 SolidJS 的用法,但本文假设读者已有 React 基础,那么只要交代核心差异就行了。 渲染函数仅执行一次SolidJS 仅支持 FunctionComponent 写法,无论内容是否拥有状态管理,也无论该组件是否接受来自父组件的 Props 透传,都仅触发一次渲染函数。 所以其状态更新机制与 React 存在根本的不同: React 状态变化后,通过重新执行 Render 函数体响应状态的变化。 Solid 状态变化后,通过重新执行用到该状态代码块响应状态的变化。 与 React 整个渲染函数重新执行相对比,Solid 状态响应粒度非常细,甚至一段 JSX 内调用多个变量,都不会重新执行整段 JSX 逻辑,而是仅更新变量部分: const App = ({ var1, var2 }) => ( <> var1: {console.log("var1", var1)} var2: {console.log("var2", var2)} </>); 上面这段代码在 var1 单独变化时,仅打印 var1,而不会打印 var2,在 React 里是不可能做到的。 这一切都源于了 SolidJS 叫板 React 的核心理念:面向状态驱动而不是面向视图驱动。正因为这个差异,导致了渲染函数仅执行一次,也顺便衍生出变量更新粒度如此之细的结果,同时也是其高性能的基础,同时也解决了 React Hooks 不够直观的顽疾,一箭 N 雕。 更完善的 Hooks 实现SolidJS 用 createSignal 实现类似 React useState 的能力,虽然看上去长得差不多,但实现原理与使用时的心智却完全不一样: const App = () => { const [count, setCount] = createSignal(0); return <button onClick={() => setCount(count() + 1)}>{count()}</button>;}; 我们要完全以 SolidJS 心智理解这段代码,而不是 React 心智理解它,虽然它长得太像 Hooks 了。一个显著的不同是,将状态代码提到外层也完全能 Work: const [count, setCount] = createSignal(0);const App = () => { return <button onClick={() => setCount(count() + 1)}>{count()}</button>;}; 这是最快理解 SolidJS 理念的方式,即 SolidJS 根本没有理 React 那套概念,SolidJS 理解的数据驱动是纯粹的数据驱动视图,无论数据在哪定义,视图在哪,都可以建立绑定。 这个设计自然也不依赖渲染函数执行多次,同时因为使用了依赖收集,也不需要手动申明 deps 数组,也完全可以将 createSignal 写在条件分支之后,因为不存在执行顺序的概念。 派生状态用回调函数方式申明派生状态即可: const App = () => { const [count, setCount] = createSignal(0); const doubleCount = () => count() * 2; return <button onClick={() => setCount(count() + 1)}>{doubleCount()}</button>;}; 这是一个不如 React 方便的点,因为 React 付出了巨大的代价(在数据变更后重新执行整个函数体),所以可以用更简单的方式定义派生状态: // Reactconst App = () => { const [count, setCount] = useState(0); const doubleCount = count * 2; // 这块反而比 SolidJS 定义的简单 return ( <button onClick={() => setCount((count) => count + 1)}> {doubleCount} </button> );}; 当然笔者并不推崇 React 的衍生写法,因为其代价太大了。我们继续分析为什么 SolidJS 这样看似简单的衍生状态写法可以生效。原因在于,SolidJS 收集所有用到了 count() 的依赖,而 doubleCount() 用到了它,而渲染函数用到了 doubleCount(),仅此而已,所以自然挂上了依赖关系,这个实现过程简单而稳定,没有 Magic。 SolidJS 还支持衍生字段计算缓存,使用 createMemo: const App = () => { const [count, setCount] = createSignal(0); const doubleCount = () => createMemo(() => count() * 2); return <button onClick={() => setCount(count() + 1)}>{doubleCount()}</button>;}; 同样无需写 deps 依赖数组,SolidJS 通过依赖收集来驱动 count 变化影响到 doubleCount 这一步,这样访问 doubleCount() 时就不用总执行其回调的函数体,产生额外性能开销了。 状态监听对标 React 的 useEffect,SolidJS 提供的是 createEffect,但相比之下,不用写 deps,是真的监听数据,而非组件生命周期的一环: const App = () => { const [count, setCount] = createSignal(0); createEffect(() => { console.log(count()); // 在 count 变化时重新执行 });}; 这再一次体现了为什么 SolidJS 有资格 “教” React 团队实现 Hooks: 无 deps 申明。 将监听与生命周期分开,React 经常容易将其混为一谈。 在 SolidJS,生命周期函数有 onMount、onCleanUp,状态监听函数有 createEffect;而 React 的所有生命周期和状态监听函数都是 useEffect,虽然看上去更简洁,但即便是精通 React Hooks 的老手也不容易判断哪些是监听,哪些是生命周期。 模板编译为什么 SolidJS 可以这么神奇的把 React 那么多历史顽疾解决掉,而 React 却不可以呢?核心原因还是在 SolidJS 增加的模板编译过程上。 以官方 Playground 提供的 Demo 为例: function Counter() { const [count, setCount] = createSignal(0); const increment = () => setCount(count() + 1); return ( <button type="button" onClick={increment}> {count()} </button> );} 被编译为: const _tmpl$ = /*##__PURE__*/ template(`<button type="button"></button>`, 2);function Counter() { const [count, setCount] = createSignal(0); const increment = () => setCount(count() + 1); return (() => { const _el$ = _tmpl$.cloneNode(true); _el$.$$click = increment; insert(_el$, count); return _el$; })();} 首先把组件 JSX 部分提取到了全局模板。初始化逻辑:将变量插入模板;更新状态逻辑:由于 insert(_el$, count) 时已经将 count 与 _el$ 绑定了,下次调用 setCount() 时,只需要把绑定的 _el$ 更新一下就行了,而不用关心它在哪个位置。 为了更完整的实现该功能,必须将用到模板的 Node 彻底分离出来。我们可以测试一下稍微复杂些的场景,如: <button> count: {count()}, count+1: {count() + 1}</button> 这段代码编译后的模板结果是: const _el$ = _tmpl$.cloneNode(true), _el$2 = _el$.firstChild, _el$4 = _el$2.nextSibling;_el$4.nextSibling;_el$.$$click = increment;insert(_el$, count, _el$4);insert(_el$, () => count() + 1, null); 将模板分成了一个整体和三个子块,分别是字面量、变量、字面量。为什么最后一个变量没有加进去呢?因为最后一个变量插入直接放在 _el$ 末尾就行了,而中间插入位置需要 insert(_el$, count, _el$4) 给出父节点与子节点实例。 精读SolidJS 的神秘面纱已经解开了,下面笔者自问自答一些问题。 为什么 createSignal 没有类似 hooks 的顺序限制?React Hooks 使用 deps 收集依赖,在下次执行渲染函数体时,因为没有任何办法标识 “deps 是为哪个 Hook 申明的”,只能依靠顺序作为标识依据,所以需要稳定的顺序,因此不能出现条件分支在前面。 而 SolidJS 本身渲染函数仅执行一次,所以不存在 React 重新执行函数体的场景,而 createSignal 本身又只是创建一个变量,createEffect 也只是创建一个监听,逻辑都在回调函数内部处理,而与视图的绑定通过依赖收集完成,所以也不受条件分支的影响。 为什么 createEffect 没有 useEffect 闭包问题?因为 SolidJS 函数体仅执行一次,不会存在组件实例存在 N 个闭包的情况,所以不存在闭包问题。 为什么说 React 是假的响应式?React 响应的是组件树的变化,通过组件树自上而下的渲染来响应式更新。而 SolidJS 响应的只有数据,甚至数据定义申明在渲染函数外部也可以。 所以 React 虽然说自己是响应式,但开发者真正响应的是 UI 树的一层层更新,在这个过程中会产生闭包问题,手动维护 deps,hooks 不能写在条件分支之后,以及有时候分不清当前更新是父组件 rerender 还是因为状态变化导致的。 这一切都在说明,React 并没有让开发者真正只关心数据的变化,如果只要关心数据变化,那为什么组件重渲染的原因可能因为 “父组件 rerender” 呢? 为什么 SolidJS 移除了虚拟 dom 依然很快?虚拟 dom 虽然规避了 dom 整体刷新的性能损耗,但也带来了 diff 开销。对 SolidJS 来说,它问了一个问题:为什么要规避 dom 整体刷新,局部更新不行吗? 对啊,局部更新并不是做不到,通过模板渲染后,将 jsx 动态部分单独提取出来,配合依赖收集,就可以做到变量变化时点对点的更新,所以无需进行 dom diff。 为什么 signal 变量使用 count() 不能写成 count?笔者也没找到答案,理论上来说,Proxy 应该可以完成这种显式函数调用动作,除非是不想引入 Mutable 的开发习惯,让开发习惯变得更加 Immutable 一些。 props 的绑定不支持解构由于响应式特性,解构会丢失代理的特性: // ✅const App = (props) => <div>{props.userName}</div>;// ❎const App = ({ userName }) => <div>{userName}</div>; 虽然也提供了 splitProps 解决该问题,但此函数还是不自然。该问题比较好的解法是通过 babel 插件来规避。 createEffect 不支持异步没有 deps 虽然非常便捷,但在异步场景下还是无解: const App = () => { const [count, setCount] = createSignal(0); createEffect(() => { async function run() { await wait(1000); console.log(count()); // 不会触发 } run(); });}; 总结SolidJS 的核心设计只有一个,即让数据驱动真的回归到数据上,而非与 UI 树绑定,在这一点上,React 误入歧途了。 虽然 SolidJS 很棒,但相关组件生态还没有起来,巨大的迁移成本是它难以快速替换到生产环境的最大问题。前端生态想要无缝升级,看来第一步是想好 “代码范式”,以及代码范式间如何转换,确定了范式后再由社区竞争完成实现,就不会遇到生态难以迁移的问题了。 但以上假设是不成立的,技术迭代永远都以 BreakChange 为代价,而很多时候只能抛弃旧项目,在新项目实践新技术,就像 Jquery 时代一样。 讨论地址是:精读《SolidJS》· Issue ##438 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Spring 概念》","path":"/wiki/WebWeekly/前沿技术/《Spring 概念》.html","content":"当前期刊数: 163 spring 是 Java 非常重要的框架,且蕴含了一系列设计模式,非常值得研究,本期就通过 Spring 学习 这篇文章了解一下 spring。 spring 为何长寿spring 作为一个后端框架,拥有 17 年历史,这在前端看来是不可思议的。前端几乎没有一个框架可以流行超过 5 年,就最近来看,react、angular、vue 三大框架可能会活的久一点,他们都是前端相对成熟阶段的产物,我们或多或少可以看出一些设计模式。然而这些前端框架与 spring 比起来还是差距很大,我们来看看 spring 到底强大在哪。 设计模式设计模式是一种思想,不依附于任何编程语言与开发框架。比如你学会了工厂设计模式,可以在后端用,也可以转到前端用,可以在 Go 语言用,也可以在 Typescript 用,可以在 React 框架用,也可以在 Vue 里用,所以设计模式是一种具有迁移能力的知识,学会后可以受益整个职业生涯,而语言、框架则不具备迁移性,前端许多同学都把精力花在学习框架特性上,遇到前端技术迭代时期就尴尬了,这就是为什么大公司面试要问框架原理,就是看看你能否抓住一些不变的东西,所以洋洋洒洒的说上下文相关的细节也不是面试官想要的,真正想听到的是你抽象后对框架原理共性的总结。 spring 框架就用到了许多设计模式,包括: 工厂模式:用工厂生产对象实例来代替原始的 new。所谓工厂就是屏蔽实例话的细节,调用处无需关心实例化对象需要的环境参数,提升可维护性。spring 的 BeanFactory 创建 bean 对象就是工厂模式的体现。代理模式:允许通过代理对象访问目标对象。Spring 实现 AOP 就是通过动态代理模式。单例模式:单实例。spring 的 bean 默认都是单例。包装器模式:将几个不同方法通用部分抽象出来,调用时通过包装器内部引导到不同的实现。比如 spring 连接多种数据库就使用了包装器模式简化。观察者模式:这个前端同学很熟悉,就是事件机制,spring 中可以通过 ApplicationEvent 实践观察者模式。适配器模式:通过适配器将接口转换为另一个格式的接口。spring AOP 的增强和通知就使用了适配器模式。模板方法模式:父类先定义一些函数,这些函数之间存在调用关联,将某些设定为抽象函数等待子类继承时去重写。spring 的 jdbcTemplate、hibernateTemplate 等数据库操作类使用了模版方法模式。 全家桶spring 作为一个全面的 java 框架,提供了系列全家桶满足各种场景需求:spring mvc、spring security、spring data、spring boot、spring cloud。 spring boot:简化了 spring 应用配置,约定大于配置的思维。 spring data:是一个数据操作与访问工具集,比如支持 jdbc、redis 等数据源操作。 spring cloud:是一个微服务解决方案,基于 spring boot,集成了服务发现、配置管理、消息总线、负载均衡、断路器、数据监控等各种服务治理能力。 spring security:支持一些安全模型比如单点登录、令牌中继、令牌交换等。 spring mvc:MVC 思想的 web 框架。 IOCIOC(Inverse of Control)控制反转。IOC 是 Spring 最核心部分,因为所有对象调用都离不开 IOC 模式。 假设我们有三个类:Country、Province、City,最大的类别是国家,其次是省、城市,国家类需要调用省类,省类需要调用城市类: 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)参数,我们就要改动所有类代码: 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 的代码,而不用改动其他类的代码: 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 类。然而这样做也是有成本的,就是类实例化步骤会稍微繁琐一些: City city = new City(1000);Province province = new Province(city);Country country = new Country(province); 这就是控制反转,由 Country 依赖 Province 变成了类依赖框架(上面的实例化代码)注入。 然而手动维护这种初始化依赖是繁琐的,spring 提供了 bean 容器自动做这件事,我们只需要利用装饰器 Autowired 就可以自动注入依赖: @Componentpublic class Country { @Autowired private Province province;}@Componentpublic class Province { @Autowired public City city;}@Componentpublic class City { } 实际上这种自动分析并实例化的手段,不仅比手写方便,还能解决循环依赖的问题。在实际场景中,两个类相互调用是很常见的,假设现在有 A、B 类相互依赖: @Componentpublic class A { @Autowired private B b;}@Componentpublic class B { @Autowired public A a;} 那么假设我们想获取 A 实例,会经历这样一个过程: 获取 A 实例 -> 实例化不完整 A -> 检测到注入 B -> 实例化不完整 B -> 检测到注入 A -> 注入不完整 A -> 得到完整 B -> 得到完整 A -> 返回 A 实例 其实 spring 仅支持单例模式下非构造器的循环依赖,这是因为其内部有一套机制,让 bean 在初始化阶段先提前持有对方引用地址,这样就可以同时实例化两个对象了。 除了方便之外,IOC 配合 spring 容器概念还可以使获取实例时不用关心一个类实例化需要哪些参数,只需要直接申明获取即可,这样在类的数量特别多,尤其是大量代码不是你写的情况下,不需要阅读类源码也可以轻松获取实例,实在是大大提升了可维护性。 说到这就提到了 Bean 容器,在 spring 概念中,Bean 容器是对 class 的加强,如果说 Class 定义了类的基本含义,那 Bean 就是对类进行使用拓展,告诉我们应该如何实例化与使用这个类。 举个例子,比如利用注解描述的这段 Bean 类: @Configurationpublic class CityConfig { @Scope("prototype") @Lazy @Bean(initMethod = "init", destroyMethod = "destroy") public City city() { return new City() }} 可以看到,额外描述了是否延迟加载,是否单例,初始化与析构函数分别是什么等等。 下面给出一个从 Bean 获取实例的例子,采用比较古老的 xml 配置方式: public interface City { Int getPeople();} public class CityImpl implements City { public Int getPeople() { return 1000; }} 接下来用 xml 描述这个 bean: <?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 的实例。 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 构造函数要传递什么参数,不需要关心它依赖哪些其他的类,只要这一句话就可以拿到实例,是不是在维护项目时省心了很多。 AOPAOP(Aspect Oriented Program)面向切面编程。 AOP 是为了解决主要业务逻辑与次要业务逻辑之间耦合问题的。主要业务逻辑比如登陆、数据获取、查询等,次要业务逻辑比如性能监控、异常处理等等,次要业务逻辑往往有:不重要、和业务关联度低、贯穿多处业务逻辑的特性,如果没有好的设计模式,只能在业务代码里将主要逻辑与次要逻辑混合起来,但 AOP 可以做到主要、次要业务逻辑隔离。 使用 AOP 就是在定义在哪些地方(类、方法)切入,在什么地方切入(方法前、后、前后)以及做什么。 比如说,我们想在某个方法前后分别执行两个函数计算执行时间,下面是主要业务逻辑: @Component("work")public class Work { public void do() { System.out.println("执行业务逻辑"); }} 再定义切面方法: @Component@Aspectclass 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() 可以执行原函数: @Component@Aspectclass 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《State of CSS 2022》","path":"/wiki/WebWeekly/前沿技术/《State of CSS 2022》.html","content":"当前期刊数: 257 本周读一读 State of CSS 2022 介绍的 CSS 特性。 概述2022 已经支持的特性@layer解决业务代码的 !important 问题。为什么业务代码需要用 !important 解决问题?因为 css 优先级由文件申明顺序有关,而现在大量业务使用动态插入 css 的方案,插入的时机与 js 文件加载与执行时间有关,这就导致了样式优先级不固定。 @layer 允许业务定义样式优先级,层越靠后优先级越高,比如下面的例子,override 定义的样式优先级比 framework 高: @layer framework, override;@layer override { .title { color: white; }}@layer framework { .title { color: red; }} subgridsubgrid 解决 grid 嵌套 grid 时,子网格的位置、轨迹线不能完全对齐到父网格的问题。只要在子网格样式做如下定义: .sub-grid { display: grid; grid-template-columns: subgrid; grid-template-rows: subgrid;} @container@container 使元素可以响应某个特定容器大小。在 @container 出来之前,我们只能用 @media 响应设备整体的大小,而 @container 可以将相应局限在某个 DOM 容器内: // 将 .container 容器的 container-name 设置为 abc.container { container-name: abc;} // 根据 abc 容器大小做出响应@container abc (max-width: 200px) { .text { font-size: 14px; }} 一个使用场景是:元素在不同的 .container 元素内的样式可以是不同的,取决于当前所在 .container 的样式。 hwb支持 hwb(hue, whiteness, blackness) 定义颜色: .text { color: hwb(30deg 0% 20%);} 三个参数分别表示:角度 [0-360],混入白色比例、混入黑色比例。角度对应在色盘不同角度的位置,每个角度都有属于自己的颜色值,这个函数非常适合直观的从色盘某个位置取色。 lch, oklch, lab, oklab, display-p3 等lch(lightness, chroma, hue),即亮度、饱和度和色相,语法如: .text { color: lch(50%, 100, 100deg);} chroma(饱和度) 指颜色的鲜艳程度,越高色彩越鲜艳,越低色彩越暗淡。hue(色相) 指色板对应角度的颜色值。 oklch(lightness, chroma, hue, alpha),即亮度、饱和度、色相和透明度。 .text { color: oklch(59.69% 0.156 49.77 / 0.5);} 更多的就不一一枚举说明了,总之就是颜色表达方式多种多样,他们之间也可以互相转换。 color-mix()css 语法支持的 mix,类似 sass 的 mix() 函数功能: .text { color: color-mix(in srgb, ##34c9eb 10%, white);} 第一个参数是颜色类型,比如 hwb、lch、lab、srgb 等,第二个参数就是要基准颜色与百分比,第三个参数是要混入的颜色。 color-contrast()让浏览器自动在不同背景下调整可访问颜色。换句话说,就是背景过深时自动用白色文字,背景过浅时自动用黑色文字: .text { color: color-contrast(black);} 还支持更多参数,详情见 Manage Accessible Design System Themes With CSS Color-Contrast()。 相对颜色语法可以根据语法类型,基于某个语法将某个值进行一定程度的变化: .text { color: hsl(from var(--color) h s calc(l - 20%));} 如上面的例子,我们将 --color 这个变量在 hsl 颜色模式下,将其 l(lightness) 亮度降低 20%。 渐变色 namespace现在渐变色也支持申明 namespace 了,比如: .text { background-image: linear-gradient(to right in hsl, black, white);} 这是为了解决一种叫 灰色死区 的问题,即渐变色如果在色盘穿过了饱和度为 0 的区域,中间就会出现一段灰色,而指定命名空间比如 hsl 后就可以绕过灰色死区。 因为 hsl 对应色盘,渐变的逻辑是在色盘上沿圆弧方向绕行,而非直接穿过圆心(圆心饱和度低,会呈现灰色)。 accent-coloraccent-color 主要对单选、多选、进度条这些原生输入组件生效,控制的是它们的主题色: body { accent-color: red;} 比如这样设置之后,单选与多选的背景色会随之变化,进度条表示进度的横向条与圆心颜色也会随之变化。 inertinert 是一个 attribute,可以让拥有该属性的 dom 与其子元素无法被访问,即无法被点击、选中、也无法通过快捷键选中: <div inert>...</div> COLRv1 FontsCOLRv1 Fonts 是一种自定义颜色与样式的矢量字体方案,浏览器支持了这个功能,用法如下: @import url(https://fonts.googleapis.com/css2?family=Bungee+Spice);@font-palette-values --colorized { font-family: "Bungee Spice"; base-palette: 0; override-colors: 0 hotpink, 1 cyan, 2 white;}.spicy { font-family: "Bungee Spice"; font-palette: --colorized;} 上面的例子我们引入了矢量图字体文件,并通过 @font-palette-values --colorized 自定义了一个叫做 colorized 的调色板,这个调色板通过 base-palette: 0 定义了其继承第一个内置的调色板。 使用上除了 font-family 外,还需要定义 font-palette 指定使用哪个调色板,比如上面定义的 --colorized。 视口单位除了 vh、vw 等,还提供了 dvh、lvh、svh,主要是在移动设备下的区别: dvh: dynamic vh, 表示内容高度,会自动忽略浏览器导航栏高度。 lvh: large vh, 最大高度,包含浏览器导航栏高度。 svh: small vh, 最小高度,不包含浏览器导航栏高度。 :has()可以用来表示具有某些子元素的父元素: .parent:has(.child) {} 表示选中那些有用 .child 子节点的 .parent 节点。 即将支持的特性@scope可以让某个作用域内样式与外界隔绝,不会继承外部样式: @scope (.card) { header { color: var(--text); }} 如上定义后,.card 内 header 元素样式就被确定了,不会受到外界影响。如果我们用 .card { header {} } 来定义样式,全局的 header {} 样式定义依然可能覆盖它。 样式嵌套@nest 提案时 css 内置支持了嵌套,就像 postcss 做的一样: .parent { &:hover { // ... }} prefers-reduced-data@media 新增了 prefers-reduced-data,描述用户对资源占用的期望,比如 prefers-reduced-data: reduce 表示期望仅占用很少的网络带宽,那我们可以在这个情况下隐藏所有图片和视频: @media (prefers-reduced-data: reduce) { picture, video { display: none; }} 也可以针对 reduce 情况降低图片质量,至于要压缩多少效果取决于业务。 自定义 media 名称允许给 @media 自定义名称了,如下定义了很多自定义 @media: @custom-media --OSdark (prefers-color-scheme: dark);@custom-media --OSlight (prefers-color-scheme: light);@custom-media --pointer (hover) and (pointer: coarse);@custom-media --mouse (hover) and (pointer: fine);@custom-media --xxs-and-above (width >= 240px);@custom-media --xxs-and-below (width <= 240px); 我们就可以按照自定义名称使用了: @media (--OSdark) { :root { … }} media 范围描述支持表达式以前只能用 @media (min-width: 320px) 描述宽度不小于某个值,现在可以用 @media (width >= 320px) 代替了。 @property@property 允许拓展 css 变量,描述一些修饰符: @property --x { syntax: "<length>"; initial-value: 0px; inherits: false;} 上面的例子把变量 x 定义为长度类型,所以如果错误的赋值了字符串类型,将会沿用其 initial-value。 scroll-startscroll-start 允许定义某个容器从某个位置开始滚动: .snap-scroll-y { scroll-start-y: var(--nav-height);} :snapped:snapped 这个伪类可以选中当前滚动容器中正在被响应的元素: .card:snapped { --shadow-distance: 30px;} 这个特性有点像 IOS select 控件,上下滑动后就像个左轮枪一样转动元素,最后停留在某个元素上,这个元素就处于 :snapped 状态。同时 JS 也支持了 snapchanging 与 snapchanged 两种事件类型。 :toggle()只有一些内置 html 元素拥有 :checked 状态,:toggle 提案是用它把这个状态拓展到每一个自定义元素: button { toggle-trigger: lightswitch;}button::before { content: "🌚 ";}html:toggle(lightswitch) button::before { content: "🌝 ";} 上面的例子把 button 定义为 lightswitch 的触发器,且定义当 lightswitch 触发或不触发时的 css 样式,这样就可以实现点击按钮后,黑脸与黄脸的切换。 anchor()anchor() 可以将没有父子级关系的元素建立相对位置关系,更详细的用法可以看 CSS Anchored Positioning。 selectmenuselectmenu 允许将任何元素添加为 select 的 option: <selectmenu> <option>Option 1</option> <option>Option 2</option> <option>Option 3</option></selectmenu> 还支持更复杂的语法,比如对下拉内容分组: <selectmenu class="my-custom-select"> <div slot="button"> <span class="label">Choose a plant</span> <span behavior="selected-value" slot="selected-value"></span> <button behavior="button"></button> </div> <div slot="listbox"> <div popup behavior="listbox"> <div class="section"> <h3>Flowers</h3> <option>Rose</option> <option>Lily</option> <option>Orchid</option> <option>Tulip</option> </div> <div class="section"> <h3>Trees</h3> <option>Weeping willow</option> <option>Dragon tree</option> <option>Giant sequoia</option> </div> </div> </div></selectmenu> 总结CSS 2022 新特性很大一部分是将 HTML 原生能力暴露出来,赋能给业务自定义,不过如果这些状态完全由业务来实现,比如 Antd <Select> 组件早已实现自定义下拉选项与样式,既然 HTML 没有提供自定义能力,就按照其交互用 DIV + JS 模拟一套实现出来,定制空间更大。 但也有很多能力依赖浏览器实现,或者本身更适合实现在 CSS 侧,比如 @scope、subgrid、对颜色的处理等。 讨论地址是:精读《State of CSS 2022》· Issue ##442 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Suspense 改变开发方式》","path":"/wiki/WebWeekly/前沿技术/《Suspense 改变开发方式》.html","content":"当前期刊数: 143 1 引言很多人都用过 React Suspense,但如果你认为它只是配合 React.lazy 实现异步加载的蒙层,就理解的太浅了。实际上,React Suspense 改变了开发规则,要理解这一点,需要作出思想上的改变。 我们结合 Why React Suspense Will Be a Game Changer 这篇文章,带你重新认识 React Suspense。 2 概述异步加载是前端开发的重要环节,也是一直以来样板代码最严重的场景之一,原文通过三种取数方案的对比,逐渐找到一种最佳的异步取数方式。 在讲解这三种取数方案之前,首先通过下面这张图说明了 Suspense 的功能: 从上图可以看出,子元素在异步取数时会阻塞父组件渲染,并一直冒泡到最外层第一个 Suspense,此时 Suspense 不会渲染子组件,而是渲染 fallback,当所有子组件异步阻塞取消后才会正常渲染。 下面介绍文中给出的三种取数方式,首先是最原始的本地状态管理方案。 本地异步状态管理,直白但不利于维护在 Suspense 方案出来之前,我们一般都在代码中利用本地状态管理异步数据。 即便代码做了一定抽象,那也只是把逻辑从一个文件移到了另一个问题,可维护性与可拓展性都没有本质的改变,因此基本可以用下面的结构说明: 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 做状态共享,我们将取数的数据管理与逻辑代码写在父组件,子组件专心用于展示,效果会好一些,代码如下: 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 进行异步处理,代码处理大概是这样的: 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 的魔力,笔者特意把这段代码单独拿出来说明: 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 源码》 有介绍过,即 Suspense 要求代码 suspended,即抛出一个可以被捕获的 Promise 异常,在这个 Promise 结束后再渲染组件,因此取数函数需要在 Pending 状态时抛出一个 Promise,使其可以被 Suspense 捕获到。 另外,关于文中提到的 fallback 最小出现时间的保护间隔,目前还是一个 Open Issue,也许有一天 React 官方会提供支持。 不过即便官方不支持,我们也有方式实现,即让这个逻辑由 fallback 组件实现: <Suspense fallback={MyFallback} />;const MyFallback = () => { // 计时器,200 ms 以内 return null,200 ms 后 return <Spin />}; 4 总结之所以说 Suspense 开发方式改变了开发规则,是因为它做到了将异步的状态管理与 UI 组件分离,所有 UI 组件都无需关心 Pending 状态,而是当作同步去执行,这本身就是一个巨大的改变。 另外由于状态的分离,我们可以利用纯 UI 组件拼装任意粒度的 Pending 行为,以整个 App 作为一个大的 Suspense 作为兜底,这样 UI 彻底与异步解耦,哪里 Loading,什么范围内 Loading,完全由 Suspense 组合方式决定,这样的代码显然具备了更强的可拓展性。 讨论地址是:精读《Suspense 改变开发方式》 · Issue ##238 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《TC39 与 ECMAScript 提案》","path":"/wiki/WebWeekly/前沿技术/《TC39 与 ECMAScript 提案》.html","content":"当前期刊数: 15 本期精读文章是:TC39, ECMAScript, and the Future of JavaScript 1 引言 觉得 es6 es7 动不动就加新特性很烦?提案的讨论已经放开了,每个人都可以做 js 的主人,赶快与我一起了解下有哪些特性在日程中! 2 内容概要TC39 是什么?包括哪些人?一个推动 JavaScript 发展的委员会,由各个主流浏览器厂商的代表构成。 为什么会出现这样一个组织?从标准到落地是一个漫长的过程,相信大家上次阅读 web components 就能体会到标准到浏览器支持是一个漫长的过程。 TC39 这群人主要的工作是什么?制定 ECMAScript 标准,标准生成的流程,并实现。 标准的流程是什么样的?包括五个步骤: stage0 strawman 任何讨论、想法、改变或者还没加到提案的特性都在这个阶段。只有 TC39 成员可以提交。 stage1 proposal(1)产出一个正式的提案。(2)发现潜在的问题,例如与其他特性的关系,实现难题。(3)提案包括详细的 API 描述,使用例子,以及关于相关的语义和算法。 stage2 draft(1)提供一个初始的草案规范,与最终标准中包含的特性不会有太大差别。草案之后,原则上只接受增量修改。(2)开始实验如何实现,实现形式包括 polyfill, 实现引擎(提供草案执行本地支持),或者编译转换(例如 babel) stage3 candidate(1)候选阶段,获得具体实现和用户的反馈。此后,只有在实现和使用过程中出现了重大问题才会修改。(1)规范文档必须是完整的,评审人和 ECMAScript 的编辑要在规范上签字。(2)至少要在一个浏览器中实现,提供 polyfill 或者 babel 插件。 stage4 finished(1)已经准备就绪,该特性会出现在下个版本的 ECMAScript 规范之中。。(2)需要通过有 2 个独立的实现并通过验收测试,以获取使用过程中的重要实践经验。 一般可以去哪里查看 TC39 标准的进程呢?stage0 的提案 https://github.com/tc39/proposals/blob/master/stage-0-proposals.mdstage1 - 4 的提案 https://github.com/tc39/proposals 我们怎么在程序中应用这些新特性呢?babel 的插件:babel-presets-stage-0 babel-presets-stage-1 babel-presets-stage-2 babel-presets-stage-3 babel-presets-stage-4 3 精读本次提出独到观点的同学有:@huxiaoyun @monkingxue @jasonslyvia @ascoders,精读由此归纳。 3.1 Stage 4 大家庭Array.prototype.includesassert([1, 2, 3].includes(2) === true);assert([1, 2, 3].includes(4) === false);assert([1, 2, NaN].includes(NaN) === true);assert([1, 2, -0].includes(+0) === true);assert([1, 2, +0].includes(-0) === true);assert(["a", "b", "c"].includes("a") === true);assert(["a", "b", "c"].includes("a", 1) === false); 这个 api 很方便,没有悬念的进入了草案中。 曾争议过是否使用 Array.prototype.contains,但由于 不兼容因素 而换成了 includes。 Exponentiation operator// x ** ylet squared = 2 ** 2;// same as: 2 * 2let cubed = 2 ** 3;// same as: 2 * 2 * 2 列表中进入了 stage4,但其 git 仓库 readme 还停留在 stage3。。 虽然已经有 Math.pow 了,但由于其他语言都支持此方式,js 也就支持了。 Object.values/Object.entriesObject.values({\ta: 1,\tb: 2,\tc: Symbol(),}) // [1, 2, Symbol()]Object.entries({\ta: 1,\tb: 2,\tc: Symbol(),}) // [["a", 1], ["b", 2], ["c", Symbol()]] 也没有什么争议,Object.keys 都有了,获取 values、entries 也是合理的。 TC39 会议中有争辩过为何不返回迭代器,原因挺有意思,因为 Object.keys 返回的是数组,所以这两个 api 还是与老大哥统一吧。 String.prototype.padStart / String.prototype.padEnd"foo".padStart(5, "bar") // bafoo"foo".padEnd(5, "bar") // fooba 解决了字符串补齐需求,很棒! Object.getOwnPropertyDescriptorsObject.getOwnPropertyDescriptors({ a: 1})// { a: {// configurable: true,// enumberable: true,// value: 1,// writable: true// } } 特别是 babel 与 typescript 处理 class property decorator 方式不同的时候(typescript 处理得更成熟一些),会导致 babel 处理装饰器时,成员变量不设置默认值时,configurable 默认为 false,通过这个函数检查变量的配置很方便。 Trailing commas in function parameter lists and callsfunction clownPuppiesEverywhere( param1, param2, // Next parameter that's added only has to add a new line, not modify this line ) { /* ... */ } js 终于原生支持了,以前不支持的时候多加逗号还会报错,需要预编译工具删除最后一个逗号,现在终于名正言顺了。 Async functions这个不用多说了,都说好用。 Shared memory and atomics这是 ECMAScript 共享内存与 Atomics 的规范,涉及内容非常多,主要涉及到 asm.js。 asm.js 是一种性能解决方案,比如可以定义一个精确的 64k 堆: var heap = new ArrayBuffer( 0x10000 ) Lifting template literal restrictionstyled.div` background-color: red;` styled.div = text => {} 就可以处理了,目前使用最多在 styled-components 库里,这种场景还是蛮方便的。 3.2 Stage 3 大家庭Function.prototype.toString revision对函数的 toString 规则进行了修改:http://tc39.github.io/Function-prototype-toString-revision/##sec-function.prototype.tostring 当调用内置函数或 .bind 后函数,toString 方法会返回 NativeFunction。 global为 ECMAScript 规范添加 global 变量,同构代码再也不用这么写了: var getGlobal = function () {\t// the only reliable means to get the global object is\t// `Function('return this')()`\t// However, this causes CSP violations in Chrome apps.\tif (typeof self !== 'undefined') { return self; }\tif (typeof window !== 'undefined') { return window; }\tif (typeof global !== 'undefined') { return global; }\tthrow new Error('unable to locate global object');}; 虽然前端环境与 nodejs 区别很大,但既然提案进入了 stage3,说明大家非常关注 js 整体的生态,只要整体方向良性发展,相信不久将会进入 stage4。 Rest/Spread Propertieslet { x, y, ...z } = { x: 1, y: 2, a: 3, b: 4 };x; // 1y; // 2z; // { a: 3, b: 4 } 不得不说,非常常用,而且 babel,jsTransform,typescript 均支持,感觉很快会进入 stage4. Asynchronous Iterationconst { value, done } = syncIterator.next();asyncIterator.next().then(({ value, done }) => /* ... */); for await (const line of readLines(filePath)) { console.log(line);} async function* readLines(path) { let file = await fileOpen(path); try { while (!file.EOF) { yield await file.readLine(); } } finally { await file.close(); }} 异步迭代器实现了 async await 与 generator 的结合。然而 async await 是使用 generator 的语法糖,generator 也可以通过 switch 等流程控制函数模拟。更重要的是异步在 generator 中本身就可以实现,我在《Callback Promise Generator Async-Await 和异常处理的演进》 文章中提过。 语法的修改一定不能为了方便(在 ECMAScript 中可能出现),但这种混杂的方式容易让人混淆 await 与 generator 之间的关系,是否进入 stage4 还需仔细斟酌。 import()import(`./section-modules/${link.dataset.entryModule}.js`) .then(module => { module.loadPageInto(main); }) .catch(err => { main.textContent = err.message; }); 这个提案主要增加了函数调用版的 import,而 webpack 等构建工具也在积极实现此规范,并作为动态加载的最佳范例。希望这种“官方 Amd”可以早日加入草案。 RegExp Lookbehind Assertionsjavascript 正则表达式一直不支持后行断言,不过现在已经进入 stage3,相信不久会进入 stage4. 前向断言: /\\d+(?=%)/.exec("100% of US presidents have been male") // ["100"]/\\d+(?!%)/.exec("that’s all 44 of them") // ["44"] 后向断言: /(?<=\\$)\\d+/.exec("Benjamin Franklin is on the $100 bill") // ["100"]/(?<!\\$)\\d+/.exec("it’s is worth about €90") // ["90"] 后向断言会获取某个字符后面跟的内容,在获取美刀等货币单位上有很大用途。chrome 可以使用 chrome.exe --js-flags="--harmony-regexp-lookbehind" 命令开启。 RegExp Unicode Property Escapesconst regexGreekSymbol = /\\p{Script=Greek}/u;regexGreekSymbol.test('π');// → true 以上 π 字符是一个希腊字符,通过指定 \\p{Script=Greek} 就可以匹配这个字符了! 虽然可以通过引用希腊字符(或者其他编码)表做正则处理,当每当更新表时,更新起来会非常麻烦,不如让浏览器原生支持 \\p{UnicodePropertyName=UnicodePropertyValue} 的正则语法,帮助开发人员解决这个烦恼。 RegExp named capture groupslet re = /(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})/u;let result = re.exec('2015-01-02');// result.groups.year === '2015';// result.groups.month === '01';// result.groups.day === '02';// result[0] === '2015-01-02';// result[1] === '2015';// result[2] === '01';// result[3] === '02'; let {groups: {one, two}} = /^(?<one>.*):(?<two>.*)$/u.exec('foo:bar');console.log(`one: ${one}, two: ${two}`); // prints one: foo, two: bar 同时,还支持 反向引用能力,可以通过 \\k<name> 的语法,在正则中表示同一种匹配类型,这个和 ts 范型很像: let duplicate = /^(?<half>.*).\\k<half>$/u;duplicate.test('a*b'); // falseduplicate.test('a*a'); // true 总体来看非常给力,毫无意义的下标也是正则反人类的原因之一,这个提案通过的话,正则会变得更加可读。 s (dotAll) flag for regular expressions/foo.bar/s.test('foo bar');// → true 通过添加了新的标识符 /s,表示 . 这个标志可以匹配任何值。原因是觉得现在正则的做法比较反人类: /foo[^]bar/.test('foo bar');// → true/foo[\\s\\S]bar/.test('foo bar');// → true 从保守派角度来看,可能因为掌握了 [^] [\\s\\S] 这种奇技淫巧而沾沾自喜,借此提高正则的门槛,让初学者“看不懂”,而高级语言的第一要义是可读性,RegExp Unicode Property Escapes 与 RegExp named capture groups 进入草案就是表明了对正则语义化改进的决心,相信这个提案也会被采纳。 Legacy RegExp features in JavaScript该提案主要针对 RegExp 遗留的静态属性进行梳理。平时很少接触,希望了解的人解读一下。 3.3 Stage2 大家庭function.sent metapropertygenerator 的第一个 .next 参数会被抛弃,因为第一次 next 没有对应上任何 yield,如下代码就会产生疑惑: function *adder(total=0) { let increment=1; while (true) { switch (request = yield total += increment) { case undefined: break; case "done": return total; default: increment = Number(request); } }}let tally = adder();tally.next(0.1); // argument will be ignoredtally.next(0.1);tally.next(0.1);let last=tally.next("done");console.log(last.value); //1.2 instead of 0.3 当引入 function.sent 后,可以接收来自 next 的传值,包括初始传值: function *adder(total=0) { let increment=1; do { switch (request = function.sent){ case undefined: break; case "done": return total; default: increment = Number(request); } yield total += increment; } while (true)}let tally = adder();tally.next(0.1); // argument no longer ignoredtally.next(0.1);tally.next(0.1);let last=tally.next("done");console.log(last.value); //0.3 这是个很棒的特性,也不存在语意兼容问题,但 api 还是比较怪,而且自此 yield 接收参数也变得没有意义,况且如今 async await 逐渐成为主流,这种修正没有强烈刚需。而且 yield 的语意本身没有错误,这个提案比较危险。 String.prototype.{trimStart,trimEnd}既然 padStart 与 padEnd 都进入了 stage4,trimStart trimEnd 这两个 api 也非常常用,而且从 ES5 将 String.prototype.trim 引入了标准来看,这两个非常有望晋升到 stage3。 Class Fieldsclass Counter extends HTMLElement { x = 0; ##y = 1;} 类成员变量,有了它 js 就完整了。虽然觉得似有变量符号很难看,但成员变量绝对是非常有用的语法,在 react 中已经很常用了: class Todo extends React.Component { state = { //.. }} Promise.prototype.finally就像 try/catch/finally 一样,try return 了都能执行 finally,是非常方便的,对 promise 来说也是如此,bluebird Q 等库已经实现了此功能。 但是库实现不足以使其纳入标准,只有当这些需求足够常用和通用时才会考虑。第三方库可能从竞争力角度考虑,多支持一种功能、少些一行代码就是多一份筹码,但语言规范是不能在乎这些的。 Class and Property Decorators类级别的装饰器已经进入 stage2 了,但现代前端开发中已经非常常用,很可能会进一步进入 stage3. 如果这个提案被废弃,那么大部分现代 js 代码将面临大量使用不存在语法的窘境。不过乐观的是,目前还找不到更好的装饰器替代方案,而在 python 中也存在装饰器模式可以参考。 Intl.Segmenter// Create a segmenter in your localelet segmenter = new Intl.Segmenter("fr", {granularity: "word"});// Get an iterator over a stringlet iterator = segmenter.segment("Ceci n'est pas une pipe");// Iterate over it!for (let {segment, breakType} of iterator) { console.log(`segment: ${segment} breakType: ${breakType}`); break;}// logs the following to the console:// segment: Ceci breakType: letter Intl.Segmenter 可以帮助分析单词断句分析,可能在 nlp 领域比较有用,在文本编辑器自动选中功能中也很有用。 虽然不是刚需,但 js 作为网页交互的语言,确实需要解决分析用户输入的问题。 Arbitrary-precision Integers新增了基本类型:整数类型,以及 Integer api 与字面语法 1234n。 目前 js 使用 64 位浮点数处理所有计算,直接导致了运算效率低下,这个提案弥补了 js 的计算缺点,希望可以早日进入草案。 提案名称由 Integer 改为 BigInt。 import.meta提出了使用 import.meta 获取当前模块的域信息。类比 nodejs 存在 __dirname 等信息标志当前脚本信息,通过浏览器加载的模块也应当拥有这种能力。 目前 js 可以通过如下方式获取脚本信息: const theOption = document.currentScript.dataset.option; 这样污染了全局变量,脚本信息应当存储在脚本作用域中,因此提案希望将脚本信息存储在脚本的 import.meta 变量中,因此可以这么使用: (async () => { const response = await fetch(new URL("../hamsters.jpg", import.meta.url)); const blob = await response.blob(); const size = import.meta.scriptElement.dataset.size || 300; const image = new Image(); image.src = URL.createObjectURL(blob); image.width = image.height = size; document.body.appendChild(image);})(); 3.4 Stage1 大家庭Date.parse fallback semantics通过字符串格式化日期一直是跨浏览器的痛点,本提案希望通过新增 Date.parse 标准完成这个功能。 “The function first attempts to parse the format of the String according to the rules(including extended years) called out in Date Time String Format (20.3.1.16). If theString does not conform to that format the function may fall back to anyimplementation-specific heuristics or implementation-specific date formats.” 正如提案所说,“如果字符串不满足 ISO 8601 格式,可以返回你想返回的任何值” 这样迷惑开发者是没有任何意义的,这样只会让开发者越来越不相信 js 是跨平台的语言。 这么重要的规范居然才 stage1,必须要顶上去。 export * as ns from “mod”; statementsexport * as someIdentifier from "someModule"; 很方便的 api,很多时候希望导出某个模块的全部接口,又不希望命名冲突,可以少写一行 import。 export v from “mod”; statements这个提案与 export * as ns from “mod”; statements 冲突了,感觉 export * as ns from “mod”; statements 提案更清晰一些。 Observable可观察类型可以从 dom 事件、轮询等触发事件中创建监听并订阅: function listen(element, eventName) { return new Observable(observer => { // Create an event handler which sends data to the sink let handler = event => observer.next(event); // Attach the event handler element.addEventListener(eventName, handler, true); // Return a cleanup function which will cancel the event stream return () => { // Detach the event handler from the element element.removeEventListener(eventName, handler, true); }; });}// Return an observable of special key down commandsfunction commandKeys(element) { let keyCommands = { "38": "up", "40": "down" }; return listen(element, "keydown") .filter(event => event.keyCode in keyCommands) .map(event => keyCommands[event.keyCode])}let subscription = commandKeys(inputElement).subscribe({ next(val) { console.log("Received key command: " + val) }, error(err) { console.log("Received an error: " + err) }, complete() { console.log("Stream complete") },}); 这个名字和 Object.observe 很像,不过没什么关系。该功能已经被 RxJS、XStream 等库实现。 String##matchAll目前正则表达式想要匹配全部的语法不够语义化,提案希望通过 matchAll 返回迭代器来遍历匹配结果,很赞! 现在匹配全部只能使用 while ((result = patt.exec(str)) != null) 这种方式遍历,不优雅。 WeakRefs弱引用,提案地址文档:https://github.com/tc39/proposal-weakrefs/blob/master/specs/Weak%20References%20for%20EcmaScript.pdf 有点像 OC 的弱引用,当对象被释放时,当前持有弱引用的对象也会被 GC 回收,但似乎还没有开始讨论,js 越来越底层了? Frozen Realms增强了 Realms 提案,利用不可变结构,实现结构共享。 Math ExtensionsMath 函数的拓展包含的函数:https://rwaldron.github.io/proposal-math-extensions/ 这个函数拓展很给力,特别是设计游戏,计算角度的时候: Math.DEG_PER_RAD // Math.PI / 180 Math.DEG_PER_RAD 是一种单位,让角度可以用 0~360 为周期的数字表示,比如射击子弹时的角度、或者做可视化时都非常有用,类比 css 中的:transform: rotate(180deg);。 of and from on collection constructors该提案设计了 Set、Map 类型的 of from 方法,具体见此:https://tc39.github.io/proposal-setmap-offrom/ 问题由于: Reflect.construct(Array, [1,2,3]) // [1,2,3]Reflect.construct(Set, [1,2,3]) // Uncaught TypeError: undefined is not a function 因为 Set 接收的参数是数组,而 construct 会调用 CreateListFromArrayLike 将参数打平,变成了 new Set(1, 2, 3) 传入,实际上是语法错误的,因此作者提议新增下 Set、Map 的 of from 方法。 Set、Map 在国内环境用的比较少,也很少有人计较这个问题,不过从技术角度来看,确实需要修复。。 Generator arrow functions (=>*)还是挺有必要的,毕竟都出箭头函数了,也要支持一下箭头函数的 generator 语法。 Promise.try同理,各大库都有实现,好处是所有错误都可以通过 .catch 捕获,而不用担心同步、异步错误的抛出。 Null Propagation超级有用,看代码就知道了: const firstName = message.body?.user?.firstName || 'default' 该功能完全等同: const firstName = (message && message.body && message.body.user &&\tmessage.body.user.firstName) || 'default' 希望立刻进入 stage4. Math.signbit: IEEE-754 sign bit当值为 负数 或 -0 时返回 true。由于 Math.sign 不区分 +0 与 -0,因此提案建议增加此函数,而且此函数在 c、c++、go 语言都有实现。 Error stacks提案建议将 Error.prototype.stack 作为标准,这对错误上报与分析特别有用,强烈支持。 do expressionsreturn ( <nav> <Home /> { do { if (loggedIn) { <LogoutButton /> } else { <LoginButton /> } } } </nav>) jsx 再也不用写得超长了,styled-components 中被诟病的分支判断难以阅读的问题也会烟消云散,因为我们有 do! Realmslet realm = new Realm();let outerGlobal = window;let innerGlobal = realm.global;let f = realm.evalScript("(function() { return 17 })");f() === 17 // trueReflect.getPrototypeOf(f) === outerGlobal.Function.prototype // falseReflect.getPrototypeOf(f) === innerGlobal.Function.prototype // true Realms 提供了 global 环境的隔离,eval 执行代码时不再会污染全局,简直是测试的福利,脑洞很大。 Temporal与 Date 类似,但功能更强: var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, options);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, options);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, options);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, 456789);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, 456789, options);// add/subtract time (Dec 31 2017 23:00 + 2h = Jan 1 2018 01:00)var addHours = new temporal.LocalDateTime(2017, 12, 31, 23, 00).add(2, 'hours');// add/subtract months (Mar 31 - 1M = Feb 28)var addMonths = new temporal.LocalDateTime(2017,03,31).subtract(1, 'months'); // add/subtract years (Feb 29 2020 - 1Y = Feb 28 2019)var subtractYears = new temporal.LocalDateTime(2020, 02, 29).subtract(1, 'years'); 还自带时区转换 api 等等,如果进入草案,可以放弃 moment 这个重量级库了。 Float16 on TypedArrays, DataView, Math.hfround由于大多数 WebGL 纹理需要半精度以上的浮点数计算,推荐了 4 个 api: Float16Array DataView.prototype.getFloat16 DataView.prototype.setFloat16 Math.hfround(x) Atomics.waitNonblockingvar sab = new SharedArrayBuffer(4096);var ia = new Int32Array(sab);ia[37] = 0x1337;test1();function test1() { Atomics.waitNonblocking(ia, 37, 0x1337, 1000).then(function (r) { console.log("Resolved: " + r); test2(); });}var code = `var ia = null;onmessage = function (ev) { if (!ia) { console.log("Aux worker is running"); ia = new Int32Array(ev.data); } console.log("Aux worker is sleeping for a little bit"); setTimeout(function () { console.log("Aux worker is waking"); Atomics.wake(ia, 37); }, 1000);}`;function test2() { var w = new Worker("data:application/javascript," + encodeURIComponent(code)); w.postMessage(sab); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved: " + r); test3(w); });}function test3(w) { w.postMessage(false); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 1: " + r); }); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 2: " + r); }); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 3: " + r); }); } 该 api 可以在多线程操作中,有顺序的操作同一个内存地址,如上代码变量 ia 虽然在多线程中执行,但每个线程都会等资源释放后再继续执行。 Numeric separators1_000_000_000 // Ah, so a billion101_475_938.38 // And this is hundreds of millionslet fee = 123_00; // $123 (12300 cents, apparently)let fee = 12_300; // $12,300 (woah, that fee!)let amount = 12345_00; // 12,345 (1234500 cents, apparently)let amount = 123_4500; // 123.45 (4-fixed financial)let amount = 1_234_500; // 1,234,500 提案希望 js 支持分隔符使大数字阅读性更好(不影响计算),很多语言都有实现,很人性化。 4 总结每个草案都觉得很靠谱,涉及语义化、无障碍、性能、拓展语法、连接 nodejs 等方面,虽然部分提案从语言设计角度是错误的,但 js 运行在网页端,涉及到人机交互、网络加载等问题,遇到的问题自然比任何语言都要复杂,每个提案都是从实践中出发,相信这种道路是正确的。 由于篇幅与时间限制,stage0 的提案等下次再讨论。特别提一点,stage0 的 Cancellation API 很值得大家关注,取消异步操作是人心所向,大势所趋啊。 感谢所有参与讨论的同学,你们的支持会转化为我们的动力,每周更新,风雨无阻。 讨论地址是:精读《TC39, ECMAScript, and the Future of JavaScript》 · Issue ##21 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。 访问 原始文章地址 , 获得更好阅读效果。"},{"title":"《Tableau 探索式模型》","path":"/wiki/WebWeekly/前沿技术/《Tableau 探索式模型》.html","content":"当前期刊数: 117 1. 引言Tableau 探索式分析功能非常强大,各种功能组合似乎有着无限的可能性。 今天笔者会分析这种探索式模型解题思路,一起看看这种探索式分析功能是如何做到的。 2. 精读要掌握探索式分析,先要掌握探索式分析背后的思维模型。 理解数据有分析意义的数据一般是表结构,即分为行与列,列定义了数据含义,行则构成了数据明细。 当我们将数据作为 “原材料” 使用时,需要将这些明细数据封装为 “数据集” 的概念来理解,数据集概念中,数据就是一个个字段,对于字段,要理解 “维度” 与 “度量” 这两个概念。 维度维度是不能被计数的字段,一般为字符串或离散的值,用来描述数据的维度。 度量度量是可以被计数的字段,一般为数字、日期等连续的值,用来描述数据的量。 我们首先要将数据集字段归类到维度与度量,才能提高数据分析的效率。数据分析就是从不同维度下看度量值,先想清楚要看的是什么数据,比如销量还是利润?这些字段都属于度量,然后想一想要怎么看这些度量,是看总数、拆解到年看、还是按地区看呢?这些字段都属于维度。 维度和度量是可以单独看的,如果单看维度,那只能看这个维度的明细,比如看 订单日期 这个字段: 需要注意的时,维度与度量字段还可以分为 连续 与 离散 。 连续值是连续关系,即任意两个值之间可以计算差值。 离散值是离散关系,即任意两个值之间无法计算差值,无法以连续的方式去理解。 一般来说,维度字段都是离散的,度量字段都是连续的。从字段类型意义上也能得出相同的结论:维度字段一般为字符串或日期类型,字符串类型都是离散的,度量字段一般为数字类型,数字天生就可以连续。 值得注意的是,连续与离散其实与字段类型、维度度量并无关系,比如维度的日期字段就是可连续的,而就算是字符串类型,也可以以字符串长度等方式 “定义” 一种连续的计算方式。对数字类型的度量字段来说,我们也可以忽略数字之间的联系,将数字看待为字符串,这样数字之间就是离散的。 上图的 “离散方式看日期” 就是看维度的直观方式,但仍可以用 “连续方式看日期”: 离散方式下单看维度只有一条条数据,数据间并无排序规则,而以连续方式看维度,维度就会以某种方式排序:比如上图以时间类型进行排序。此时展示方式也从表格切换为了柱状图,因为表格适合展示离散数据,柱状图的一根柱子就可以展示连续数据。 单看度量时,由于 度量要依附于维度展示,因此仅有度量时,只能看这个度量的 聚合 概念: 如上图所示,单看销量这个度量字段时,我们只能将数据集中所有销量字段聚合在一起来看,但这种聚合方式也可以分成若干种计算类型 - 求和、平均值、中位数、计数、计数去重、最小值、最大值、方差等等: 这些能力之间都是 “正交” 的,即单看度量这一个字段,可以以这么多种类型进行计算,那么按维度拆分后,度量依然可以享受如上不同的计算方式。 也可以用连续方式看度量: 与连续-维度不同,连续-度量图形中除了最后一个值,其他过渡数值都是无效的,因为连续-度量只有一个值。连续-维度也要注意,由于以连续的方式画出图形,中间不存在的点也被 “无缝连接” 了。 数据之间也可以存在父子级关系,有父子级关系就可以进行上卷下钻了,这种父子级关系被称为 “层系字段”: 上图的 Orders 就是一个层系字段。层系字段是几个字段的排序组合,由上到下依次构成下钻关系,从下到上则是上卷的关系。 层系只有维度字段才能有层系,因为度量是不能被拆分的,只有维度才可以被拆分。 维度的拆分可以是有逻辑含义的,也可以是任意的。 有逻辑含义的层系 最典型有逻辑含义的层系字段就是时间了。一个好的 BI 系统识别到日期字段后,应该将拿到的日期字段进行归类,比如判断日期字段粒度到天,则自动生成一个日期层系字段,自动聚合到年,并允许用户随意切换: 如果数据集字段值精确到月,则层系只能最多展开到月。 日期层系的逻辑含义在于,年、季度、月、天这种下钻关系是天然从大到小的关系,符合自然理解。 任意层系 如果层系字段不代表日期,就只能以业务含义组合层系字段了。比如可以将层系按照 订单日期 -> 商品 ID -> 运货日期的方式组合: 这种下钻方式,可以看到每个订单日期下有哪些商品,每个商品分别运货日期是什么。 也可以按照商品 ID 拆分出不同的订单日期与运货日期,这种层系组合方式就是以商品 ID 为主要视角: 可以看到,不同思维角度会按照不同的方式组合层系。比如一家大公司要查看财务问题,维度有:BU、日期,度量有:销量。 那么有两种下钻方式:BU -> 日期、日期 -> BU。无论哪种下钻方式,都能看到每个 BU 按日期销量的明细,但 BU -> 日期 能看到每个 BU 按日期聚合的总销量,而 日期 -> BU 能看到不同日期按 BU 聚合的总销量,前者更易对比出 BU 之间差异,后者更易对比出日期之间的差异。 理解配置配置是探索式分析的入口,要理解分析模型首先得理解配置模型。 Table 主要配置分为行、列、标记与筛选。通过这四个配置区域可以组合成千变万化的数据洞察模型。既然如此,让我们看看这种配置思路是什么,以及为何这四种配置相互组合就能覆盖整个探索式分析场景? 我们不需要考虑三维数据分析场景,因为三维透视的关系,图形丢失了精确大小关系,没有精度的数据是没有分析价值的。由于在二位平面中分析数据,大部分图表都可以用 “行、列” 方式进行配置。 也许有人会问,为什么不用维度与度量替代行列呢?这是一个很好的问题,有数据分析经验的人会站在维度与度量角度思考问题,因此对于任意图表,只要配置维度、度量即可呀?笔者从三个方面说说自己的理解: 探索式分析思路中,不关心图表是什么,也不关心图表如何展示,因此图表是千变万化的,比如折线图可以横过来,条形图也可以变成柱状图,因此 你将维度放到列,就是一个柱状图,你将维度放到行,就是一个条形图 。 将精力真正放到你要拖拽的字段上。由于字段已经有维度、度量的区别,配置区域就不要再限定维度与度量了,减少理解成本。 维度与度量可以同时放在行或列上,这是探索式分析的另一个精髓能力,看下图: 做探索式分析功能时,要跳出思维定式:为什么条形图的纵轴不能放维度呢?如上图所示,如果行拖拽了两个不同的度量,那么可以出现两条线或者双轴图,但当拖拽一个维度一个度量时,可以对图表进行 分面 ,比如观察 2013 ~ 2016 年不同顾客对销量的贡献。 行表格类的行、图表类的纵轴。一般建议放置度量字段。 列表格类的列、图表类的横轴。一般建议放置维度字段。 如上所示,无论行还是列,都可以进行任意维度度量组合,且字段数量不限,而且可以在任何层级进行下钻。对图表来说,多个维度时需要进行分面处理: 如上图所示,将列放置两个维度字段成为柱状图,那么横轴就要同时表示两个维度,如上图所示。如果横轴还有更多的维度,可以再不断对横轴进行拆分。 横轴(列)多维度字段的顺序也会影响图表的展现。上图最后一个字段是 Category 默认是离散的,所以这个离值就决定了图表使用柱状图,图表类型由维度周最后一个字段连续或离散决定。 比如我们对调 Order Date 与 Category 会怎样? 我们得到了三个不同类目近 12 个月的趋势,之所以是折线图,因为图表的维度轴(列)是连续的。如果我们对 Order Date 进行天级别的下钻: 可以看到,下钻功能本质上就是维度轴支持对多个维度字段拆分处理。只要图表支持了维度轴任意维度字段的分面展示,那么配置端就可以将下钻按照拖了多个字段的方式去理解了。 如果我们将折线图切换为表格,会发生什么? 我们会发现,原本存在于列的 Category 被自动挪到了行,原本存在于行的 Sales 被挪到了 “标记” 区域。在正式介绍 “标记” 区域前,先理解一下为何会发生这种转变: 表格类组件是双维度组件,折线图是单维度组件。 也就是表格的行与列都是维度,而折线图横轴作为维度后,纵轴就要作为度量。上面的例子中,折线图维度有两个字段,虽然通过分面方式渲染出来了,但当切换为支持双维度的表格后, 可以将多余的一个维度挪到表格组件另一个维度区域中。 而表格行与列都是维度的情况下,单元格的值就需要用 “标记” 中文本来表示,因此原折线图的度量字段自动转移到了 “标记” 区域。 标记标记区域也采取字段拖拽的方式,即对字段进行标记。 标记区域分为 颜色、大小、标签、详细信息、工具提示、路径。标记正如其名,是作用于图表上的标记,即不会对图表框架有实质性影响的辅助标记信息。 对不同图表来说,影响最大的是行与列,它能决定用什么图表,如何拆分数据。而标记往往是改变图表中辅助性元素,比如文字或者颜色等等。 工具提示不影响任何图像显示,仅仅在提示信息中新增字段信息。 对图表来说,指的是 Tooltip 提示信息增加对应的字段: 从上图可以看到,利润字段放在工具提示区域,则图表的 Tooltip 会新增利润这个字段的信息。值得关注的是,Tableau 所有图表都支持 Tooltip 包括表格: 这保证了配置统一,行为统一。 大小控制图表大小。 对于线图,控制线的粗细;对于气泡图控制气泡大小;对于柱状图控制柱子粗细;但是对面积图与表格没有明显作用。这得益于 Tableau 将每个图表大小属性尽可能抽象出来。 文本即直接展示在图表上的文本。 对普通图表来说,文本体现为 Label,即直接展示在图表上的文字。比如柱状图默认是没有 Label 文字的,要将对应字段拖拽到文本标记上才会出现。 这体现出与普通报表构思的不同。对普通报表来说,Label 是通过一个勾选项开启的,Label 对应的值就是图表度量这个字段的值。而 Tableau 将标签值以字段方式开放拖拽,就有了展示与值分开的可能性,可适用范围更广。 有人觉得长度和数字一定要对应上,这也是对数据理解不同导致的。Tableau 将文本(标签)列在标记里,说明文本和颜色、大小一样,都是一种附加的信息展示维度,很多时候不需要两种方式展示同一种信息,反而需要图形以更多方式以不同维度展示信息。 颜色控制图表的颜色。 比如在度量为销量时,可以将利润作为颜色,甚至再将折扣作为文本,通过一个折线图同时看多种度量信息: 与之对比,我们可以将利润放在右 Y 轴作为双轴图达到相同的效果: 标记就是为了在不增加行、列字段数量基础上,通过颜色、大小、标签、工具提示等维度展示出额外信息。 详细信息如果将度量拖拽到详细信息,会发现完全没有作用。因为 “详细信息” 只有拖拽维度字段才生效。“详细信息” 其实是用作下钻的,拖拽一个维度字段后,可以按照这个维度进行下钻。 如上图所示,将销售按照产品线拆解成三条线。但这三条线无法分辨,因此可以使用颜色来拆分维度: 这样就能将拆解的内容按不同颜色展示。因此, 对标记作用的字段如果是维度字段,且作用于颜色、大小、标签、详细信息时,会额外进行维度进行拆解,并对拆解后的内容进行颜色或大小区分。 相信读到这里会有个疑问:按照维度进行拆解与维度拖拽多个字段进行字段有什么区别?我们试一下看看效果,将产品类目维度拖拽到销量所在的行,对销量进行销量维度的拆分: 可以看到,在行、列进行的多维度拆分使用的是分面策略,而在标记中对维度进行拆分使用的是单图表多轴方式来实现。 除此之外的区别在于,在标记进行的维度拆分默认作用于度量,而行列上的多维度拆分可以任意作用于维度或度量。 同时配置端要限制 能拆分的只有维度或离散状态的度量 ,也就是只有离散状态的字段可以被拆分。如上图所示,我们不能将 Category 拖拽到 Sales 右侧,除非将 Sales 设置为离散类型。Tips:Tables 对维度与度量分别分配了蓝色、绿色,当我们将绿色度量字段设置为离散类型时,这个度量字段会变成蓝色,也就是当作了维度字段进行处理。 最后,标记区域不仅能拖拽字段,还可以单击后修改详细配置,比如修改颜色详细配置: 或者对工具提示的 Tooltip 内容进行定制: 筛选器Tableau 将所有筛选条件都收敛到筛选器中,我们可以通过拖拽字段的方式对某个字段进行筛选: 如上图所示,比如只看办公用品与科技产品。但其实除了这个通用功能之外,Tableau 还支持更强大的图表交互功能,即点击或圈选图表后,可以对选中的点(字段值)进行保留或排除: 当我们选择排除这几个点时,会自动生成一份对维度字段的筛选条件排除掉选中日期,所以图表是完全数据驱动的: 一般来说 如果属性存在下钻关系会如何呢?无论是行列中对维度的下钻,还是通过标记对维度进行了拆解,筛选都是对 字段层系 生效的: 如上图所示,对下钻后的字段进行筛选,那么筛选条件也会自动构造出临时的字段层系,并对这个临时层系进行筛选。 可以看到,我们不仅能在字段配置区动态组成层系字段,在筛选器中也可以生成临时层系进行筛选,我们需要支持任意层系组合的字段,并作用于筛选器、行列,甚至是标记上。 顺带一提,我们还可以对设置了筛选的字段层系组合拖拽到任意地方使用: 要处理这种场景,我们需要让所有字段都拥有筛选能力,普通字段等于没有筛选条件,我们也可以对一个包含了筛选条件的字段拖拽到任何位置作用。 刚才是对维度进行的筛选,有没有对度量进行筛选的场景呢?有,但我们只能手动将度量字段拖拽到筛选器位置进行手动筛选: 如果我们进行图表内的圈选操作,增加的筛选条件一定是按维度来的: 这么理解这一行为:维度是离散的,勾选操作能表达的含义有限,比如勾选折线图的某些点,如何知道我们要勾选的是维度的那几个月,还是度量的利润范围呢? 由于最终勾选操作落地在点上,而不是区间上(连续值也不适合进行圈选),所以默认按对维度进行筛选是最准确的理解。如果上图的操作意图中,你想勾选的不是 6~12 月的区间,而是销量在 13k ~ 45.5k,则需要手动拖拽利润字段,并精确输入筛选范围: 值得注意的是,对连续型度量进行筛选前,还可选择聚合方式:比如对求和的值进行范围筛选,或者对最大值进行范围筛选,功能十分强大。 理解图表图表是数据可视化的载体,只有数据与配置,没有各式各样的图表,很难产生直观的数据洞察。 可以说, 按照探索式分析的思路,当配置好数据与配置后,可以有多种可视化载体去展示这种配置信息。 比如行、列分别拖拽了日期与销量,那么折线图、表格、散点图、柱状图都可以满足需求,但如果行所在的字段是离散的,那么折线图、散点图就不适合了,这就需要图表推荐功能根据配置推荐合适的图形展示。 Tableau 内置的图表分为 N 大类 - 表格、地图、柱折面饼、散点/象限图 、以及直方图、盒须图、甘特图、靶心图等。可见分析数据,不需要太多种类可视化展现方式,但对于每个图表组件来说,都需要修炼深厚的内功,做好一个表格、折线图并不简单。 行与列表格、地图、柱折面饼、散点/象限图等都可以用行与列描述基本架构: 表格天然拥有行与列,对调后则代表转置。表格的行与列必须是维度字段,如果拖拽度量字段上去会自动切换为其他图表,再切回来则会把度量字段挪动到 “文本” 标记区域中。 地图行与列就是经纬度,当维度字段放到 “详细信息” 时,根据地理映射表转化为经纬度自动生成经纬度放在行与列。 柱折面饼、散点/象限图都是直角坐标系的图形,以维度字段作为维度轴,以度量字段作为度量轴。 行列的下钻在行或列存在多个维度字段时,图表要进行相应下钻。表格对于行下钻如下图所示: 上图也可以理解为展示出 Order Date 与 Order ID 的明细数据,按照 Order Date 分组且列合并。 下钻就是一步步接近明细数据的过程,但目的不是为了看明细表,而是看某些维度下按其他维度拆分的详细信息。 图表下钻和表格思路是一致的: 对于维度轴多维度下钻,将每个维度轴下钻到更细粒度。图表在行与列同时下钻时,与表格的表现稍有不同。仅从轴来看拆解方式是相同的,内部展示了多套轴: 可以认为,当行或列上最后一个字段为度量时,就会切换为图表展示,因为图表适合展示连续状态。 如果排除上图蓝色区域,剩下的区域就是个交叉表,交叉表只是行与列同时存在维度字段的场景,仅有行或列时就变成了普通表格;而图形的下钻和表格下钻机理相同,只是把 “单元格” 的文本换成了柱子或线。 所以对任何图表的下钻,都是对轴的下钻, 相同的是单元格属性永远不会改变,表格的单元格是文本,图形单元格是图形,一个简单折线图可以理解为对整体行与列单元格进行 “连续打通”: 如果继续对行列添加维度进行下钻,其实是对轴进行下钻。排除度量字段不看,就是一个交叉表的下钻过程,如下图所示蓝色框圈住的部分就是一组大的单元格: 由于最后一个字段是度量,因此在叶子结点的展开就不是表格模式的单元格,而是连续的线条了。 经过上面的总结,我们要意识到,在探索式分析场景对行列的下钻,表格与图表的逻辑是通用的,实现时也要整体考虑。将轴功能抽离成通用部分来做,表格与图表的区别只是对最后一个字段单元格是离散处理还是连续处理。 层系的下钻 层系字段下钻与拖多个字段表现一致,但由于存在父子关系,因此在图表上可以展现出 “展开” “收起” 按钮,点击后并不是对图表本身进行操作,而是发送一个事件对 “行” 进行操作,最后通过数据驱动完成展开或收起动作。 不适合行列的图表饼图就不适合行列,因为饼图是根据离散维度进行拆分,扇叶大小可以由一个度量字段决定,因此对饼图来说,行就对应到 “颜色”、列就对应到新增的 “角度” 这个标记: 没有维度轴的图表只有行配置的图形推荐用表格,但柱状图、折线图也可以支持这种情况,只要把横轴忽略即可: 从样式上来看没有横轴,其实这种情况是把所有维度的横轴都聚合后的表现。 连续与离散值我们分别看看连续与离散作用于维度和度量时的区别。 作用于度量图表要能适配对连续或离散值的处理。比如对销量来说,如果切换为离散值,则当成字符串展示: 如果将销量切换为连续值,则单元格就要使用线条长度代表值的大小,即连续性的值要能够产生 “对比感”: 上图组件是表格,本身适合展示离散值,但可以看到对连续值展示做了适配。对于适合展示连续值的图形,则无法做离散适配: 比如这个柱状图,如果将销量切换为离散,则会自动切换到表格,因为对于双离散值用柱折面饼展示是无意义的。 作用于维度如上图所示,就是维度使用了离散字段的例子,由于维度是离散的,因此使用柱状图展示,因为柱子间也是隔离的。 对于连续型字段作用于维度,默认适合散点图,因为散点图的行与列都是度量,适合作为默认推荐: 但能用散点图的就也能用线图, 当维度是连续日期字段时,适合用折线图而不是散点图。因为日期虽然连续,但 本身不适合做比较 ,因此作为一种连续型维度展示比较合适;而散点图两个轴都适合连续型度量,因此不适合方日期这种连续型维度字段。 当然也具备将折线图随时切换为散点图的能力,但这种图形没有什么业务价值: 因此我们对折线图进行标记:行适合连续型维度字段,对散点图进行标记:行列都适合连续型度量字段,就可以根据配置 实现推荐图表的功能。 标记除了饼图支持 “角度”、线图支持 “路径” 这些特殊标记外,所有图表都支持下面五种通用标记:“工具提示”、“大小”、“文本”、“颜色”、“详细信息”。 工具提示 比较简单,所有图表都支持鼠标 Hover 后弹出 Tooltip 即可,并且这个 Tooltip 允许自定义和拓展工具提示字段。 大小 则只有折、柱、散三种图支持,因为这三种图分别有可以描述的大小的线条粗细、柱子宽度、圆圈半径。 文本 对应柱折面饼的 Label、对应表格,矩形树状图,地图的 单元格内容。 颜色、详细信息 则比较特殊,下面详细说明: 拖拽已有字段到详细信息 - 没有任何效果: 因为本身就在看这个字段的详细信息,因此没有效果。 但如果拖拽已有字段到颜色,则可以根据数值大小或分类进行按颜色区分: 等于开启了图表筛选功能,当颜色筛选条件字段是连续型时,出现筛选滑块,是离散型时,出现图例: 如果拖拽字段不存在于行和列上,对于度量字段,会根据值进行颜色排序(度量拖拽到详细信息依然没有效果): 如上图所示,我们可以从长度看利润,从颜色深度看销量。 如果拖拽字段不存在于行和列上,且是维度字段,则会先进行维度拆分,之后如果选择的是 “颜色” 标记区域,还会对同一组的拆分标记颜色区分。 由于标记区域对维度的拆分是不分行于列的,因此每个图表会根据自身情况进行合适的拆分。 比如条形图如果按某个新维度拆分,则会采取 “堆积柱状图” 的策略: 如果是折线图,则会采取 “多条线” 的策略: 如果是散点图,只要将拆分后多出来的点打散出来即可。由于散点图的维度拆分不像折线图和柱状图可以分段,因此如果不采用按颜色打散,是无法分辨分组的: 之所以说探索式分析的复杂度很高,是因为其可能性公式为: 字段 x 离散连续 x 行列 x 行列下钻 x 标记种类 x 筛选 x 图表 这种组合的笛卡尔积几乎是无穷无尽的。 轴交互图表一些特定功能是隐藏在轴交互里的。拿折线图来说,一共有 5 个拖拽交互位置,如下图所示: 一般这些区域是用来拖拽度量字段的,所以如果拖拽了维度字段过来,最终会被归类到行列或标记上。 拖拽维度维度拖拽到底部 1 区域等于替换列字段 : 维度拖拽到图表中 4 区域等于拖到了颜色标记 : 维度拖拽到左侧 3 区域等于对行进行下钻: 同理拖拽到最上面区域等于对列进行下钻。 拖拽度量让我们看看拖拽度量时的情况。度量能拖拽的范围更多。比如拖拽到右轴 5 区域,则形成了双轴图: 拖拽到左侧 2 区域则表示在图中额外增加一个轴: 要注意的是,上图的行显示 “度量值”,这是个特殊的字段,并通过筛选器筛选出拖拽的两个字段 Profit 和 Sales。除了拖拽以外,还可以通过将左侧 “度量值” 字段直接拖入行实现: 如上图所示,将度量值放到行,并按度量名称进行颜色标记,就得到了拖拽度量到左侧 2 区域的效果。 这也说明了所有图表交互最终都是通过映射到配置完成,所有能拖拽的操作都可以通过配置配出来 。 对表格来说,能拖拽的区域是行、列、单元格: 拖拽到行或列于拖拽到字段配置区域的行或列没有区别,拖拽到单元格等于拖拽到文本标记区域。通过图表于配置区域结合的方式,即便不完全理解配置的人也可以通过将字段拖拽到图表上得到直观的操作感。 点击、圈选交互所有图表都支持点击、圈选的方式选中 “点”。对表格来说,点就是单元格: 对柱状图来说,点就是柱子: 对折线图来说,点就是节点: 对饼图来说,点就是扇叶: 所有的点被选中后都有基本高亮功能,最重要的是能对选中的点进行保留、排除、局部排序等等。 比如我们可以对上图饼图选中的几个扇形区域进行从小到大排序: 我们也可以排除某些点,这个在配置章节有提到过,这个操作最终将转化为新增筛选条件: 最后,选中状态在单图表中看似只有高亮效果,但是在多图表联动时,高亮的选中区域会组成一个临时的筛选条件,作用于所有相同数据集的图表,并对这些图表的筛选结果做高亮处理。 3. 总结理解了探索模型对数据、配置、图表的理解,就能学会探索式思维分析数据,对制作探索式 BI 也有借鉴意义。 讨论地址是:精读《Tableau 探索式模型》 · Issue ##199 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Tasks, microtasks, queues and schedules》","path":"/wiki/WebWeekly/前沿技术/《Tasks, microtasks, queues and schedules》.html","content":"当前期刊数: 162 1 引言本周跟着 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看下面代码的输出顺序: console.log("script start");setTimeout(function () { console.log("setTimeout");}, 0);Promise.resolve() .then(function () { console.log("promise1"); }) .then(function () { console.log("promise2"); });console.log("script end"); 正确答案是 script start, script end, promise1, promise2, setTimeout,在线程中,同步脚本执行优先级最高,然后 promise 任务会存放到 Microtasks,setTimeout 任务会存放到 Tasks,Microtasks 会优先于 Tasks 执行。 Microtasks 中文可以翻译为微任务,只要有 Microtasks 插入,就会不断执行 Microtasks 队列直到结束,在结束前都不会执行到 Tasks。 点击冒泡 + 任务下面给出了更复杂的例子,提前说明后面的例子 Chrome、Firefox、Safari、Edge 浏览器的结果完全不一样,但只有 Chrome 的运行结果是对的!为什么 Chrome 是对的呢,请看下面的分析: <div class="outer"> <div class="inner"></div></div> // Let's get hold of those elementsvar outer = document.querySelector(".outer");var inner = document.querySelector(".inner");// Let's listen for attribute changes on the// outer elementnew 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 elementsinner.addEventListener("click", onClick);outer.addEventListener("click", onClick); 点击 inner 区块后,正确输出顺序应该是: clickpromisemutateclickpromisemutatetimeouttimeout 逻辑如下: 点击触发 onClick 函数入栈。 立即执行 console.log('click') 打印 click。 console.log('timeout') 入栈 Tasks。 console.log('promise') 入栈 microtasks。 outer.setAttribute('data-random') 的触发导致监听者 MutationObserver 入栈 microtasks。 onClick 函数执行完毕,此时线程调用栈为空,开始执行 microtasks 队列。 打印 promise,打印 mutate,此时 microtasks 已空。 执行冒泡机制,outer div 也触发 onClick 函数,同理,打印 promise,打印 mutate。 都执行完后,执行 Tasks,打印 timeout,打印 timeout。 模拟点击冒泡 + 任务如果将触发 onClick 行为由点击改为: inner.click(); 结果会不同吗?答案是会(单元测试与用户行为不符合,单测也有无解的时候)。然而四大浏览器的执行结果也是完全不一样,但从逻辑上讲仍然 Chrome 是对的,让我们看下 Chrome 的结果: clickclickpromisemutatepromisetimeouttimeout 逻辑如下: inner.click() 触发 onClick 函数入栈。 立即执行 console.log('click') 打印 click。 console.log('timeout') 入栈 Tasks。 console.log('promise') 入栈 microtasks。 outer.setAttribute('data-random') 的触发导致监听者 MutationObserver 入栈 microtasks。 由于冒泡改为 js 调用栈执行,所以此时 js 调用栈未结束,不会执行 microtasks,反而是继续执行冒泡,outer 的 onClick 函数入栈。 立即执行 console.log('click') 打印 click。 console.log('timeout') 入栈 Tasks。 console.log('promise') 入栈 microtasks。 MutationObserver 由于还没调用,因此这次 outer.setAttribute('data-random') 的改动实际上没有作用。 js 调用栈执行完毕,开始执行 microtasks,按照入栈顺序,打印 promise,mutate,promise。 microtasks 执行完毕,开始执行 Tasks,打印 timeout,timeout。 3 精读基于任务调度这么复杂,且浏览器实现方式很不同,下面两件事是我很不推荐的: 业务逻辑 “巧妙” 依赖了 microtasks 与 Tasks 执行逻辑的微妙差异。 死记硬背调用顺序。 且不说依赖了调用顺序的业务逻辑本身就很难维护,不同浏览器之间对任务调用顺序还是不同的,这可能源于对 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《This 带来的困惑》","path":"/wiki/WebWeekly/前沿技术/《This 带来的困惑》.html","content":"当前期刊数: 13 1 引言 javascript 的 this 是个头痛的话题,本期精读的文章更是引出了一个观点,避免使用 this。我们来看看是否有道理。 本期精读的文章是:classes-complexity-and-functional-programming 2 内容概要javascript 语言的 this 是个复杂的设计,相比纯对象与纯函数,this 带来了如下问题: const person = new Person('Jane Doe')const getGreeting = person.getGreeting// later...getGreeting() // Uncaught TypeError: Cannot read property 'greeting' of undefined at getGreeting 初学者可能突然将 this 弄丢导致程序出错,甚至在 react 中也要使用 bind 的方式,使回调可以访问到 setState 等函数。 this 也不利于测试,如果使用纯函数,可以通过入参出参做测试,而不需要预先初始化环境。 所以我们可以避免使用 this,看如下的例子: function setName(person, strName) { return Object.assign({}, person, {name: strName})}// bonus function!function setGreeting(person, newGreeting) { return Object.assign({}, person, {greeting: newGreeting})}function getName(person) { return getPrefixedName('Name', person.name)}function getPrefixedName(prefix, name) { return `${prefix}: ${name}`}function getGreetingCallback(person) { const {greeting, name} = person return (subject) => `${greeting} ${subject}, I'm ${name}`}const person = {greeting: 'Hey there!', name: 'Jane Doe'}const person2 = setName(person, 'Sarah Doe')const person3 = setGreeting(person2, 'Hello')getName(person3) // Name: Sarah DoegetGreetingCallback(person3)('Jeff') // Hello Jeff, I'm Sarah Doe 这样 person 实例是个纯对象,没有将方法挂载到原型链上,简单易懂。 或者可以将属性放在上级作用域,避免使用 this,就避免了 this 丢失带来的隐患: function getPerson(initialName) { let name = initialName const person = { setName(strName) { name = strName }, greeting: 'Hey there!', getName() { return getPrefixedName('Name') }, getGreetingCallback() { const {greeting} = person return (subject) => `${greeting} ${subject}, I'm ${name}` }, } function getPrefixedName(prefix) { return `${prefix}: ${name}` } return person} 以上代码没有用到 this,也不会因为 this 产生的问题所困扰。 3 精读本文作者认为,class 带来的困惑主要在于 this,这主要因为成员函数会挂到 prototype 下,虽然多个实例共享了引用,但因此带来的隐患就是 this 的不确定性。js 有许多种 this 丢失情况,比如 隐式绑定 别名丢失隐式绑定 回调丢失隐式绑定 显式绑定 new绑定 箭头函数改变this作用范围 等等。 由于在 prototype 中的对象依赖 this,如果 this 丢了,就访问不到原型链,不但会引发报错,在写代码时还需要注意 this 的作用范围是很头疼的事。因此作者有如下解决方案: function getPerson(initialName) { let name = initialName const person = { setName(strName) { name = strName } } return person} 由此生成的 person 对象不但是个简单 object,由于没有调用 this,也不存在 this 丢失的情况。 这个观点我是不认可的。当然做法没有问题,代码逻辑也正确,也解决了 this 存在的原型链访问丢失问题,但这并不妨碍使用 this。我们看以下代码: class Person { setName = (name) => { this.name = name }}const person = new Person()const setName = person.setNamesetName("Jane Doe")console.log(person) 这里用到了 this,也产生了别名丢失隐式绑定,但 this 还能正确访问的原因在于,没有将 setName 的方法放在原型链上,而是放在了每个实例中,因此无论怎么丢失 this,也仅仅丢失了原型链上的方法,但 this 无论如何会首先查找其所在对象的方法,只要方法不放在原型链上,就不用担心丢失的问题。 至于放在原型链上会节约多个实例内存开销问题,函数式也无法避免,如果希望摆脱 this 带来的困扰,class 的方式也可以解决问题。 3.1 this 丢失的情况3.1.1 默认绑定在严格模式与非严格模式下,默认绑定有所区别,非严格模式 this 会绑定到上级作用域,而 use strict 时,不会绑定到 window。 function foo(){ console.log(this.count) // 1 console.log(foo.count) // 2}var count = 1foo.count = 2foo() function foo(){ "use strict" console.log(this.count) // TypeError: count undefined}var count = 1foo() 3.1.2 隐式绑定当函数被对象引用起来调用时,this 会绑定到其依附的对象上。 function foo(){ console.log(this.count) // 2}var obj = { count: 2, foo: foo}obj.foo() 3.1.3 别名丢失隐式绑定调用函数引用时,this 会根据调用者环境而定。 function foo(){ console.log(this.count) // 1}var count = 1var obj = { count: 2, foo: foo}var bar = obj.foo // 函数别名bar() 3.1.4 回调丢失隐式绑定这种情况类似 react 默认的情况,将函数传递给子组件,其调用时,this 会丢失。 function foo(){ console.log(this.count) // 1}var count = 1var obj = { count: 2, foo: foo}setTimeout(obj.foo) 3.2 this 绑定修复3.2.1 bind 显式绑定使用 bind 属于显示绑定。 function foo(){ console.log(this.count) // 1}var obj = { count: 1}foo.call(obj)var bar = foo.bind(obj)bar() 3.2.2 es6 绑定这种情况类似使用箭头函数创建成员变量,以下方式等于创建了没有挂载到原型链的匿名函数,因此 this 不会丢失。 function foo(){ setTimeout(() => { console.log(this.count) // 2 })}var obj = { count: 2}foo.call(obj) 3.2.3 函数 bind除此之外,我们还可以指定回调函数的作用域,达到 this 指向正确原型链的效果。 function foo(){ setTimeout(function() { console.log(this.count) // 2 }.bind(this))}var obj = { count: 2}foo.call(obj) 关于块级作用域也是 this 相关的知识点,由于现在大量使用 let const 语法,甚至在 if 块下也存在块级作用域: if (true) { var a = 1 let b = 2 const c = 3}console.log(a) // 1console.log(b) // ReferenceErrorconsole.log(c) // ReferenceError 4 总结要正视 this 带来的问题,不能因为绑定丢失,引发非预期的报错而避免使用,其根本原因在于 javascript 的原型链机制。这种机制是非常好的,将对象保存在原型链上,可以方便多个实例之间共享,但因此不可避免带来了原型链查找过程,如果对象运行环境发生了变化,其原型链也会发生变化,此时无法享受到共享内存的好处,我们有两种选择:一种是使用 bind 将原型链找到,一种是比较偷懒的将函数放在对象上,而不是原型链上。 自动 bind 的方式 react 之前在框架层面做过,后来由于过于黑盒而取消了。如果为开发者隐藏 this 细节,框架层面自动绑定,看似方便了开发者,但过分提高开发者对 this 的期望,一旦去掉黑魔法,就会有许多开发者不适应 this 带来的困惑,所以不如一开始就将 this 问题透传给开发者,使用自动绑定的装饰器,或者回调处手动 bind(this),或将函数直接放在对象中都可以解决问题。"},{"title":"《Typescript 4","path":"/wiki/WebWeekly/前沿技术/《Typescript 4.html","content":"当前期刊数: 237 新增 Awaited 类型Awaited 可以将 Promise 实际返回类型抽出来,按照名字可以理解为:等待 Promise resolve 了拿到的类型。下面是官方文档提供的 Demo: // A = stringtype A = Awaited<Promise<string>>;// B = numbertype B = Awaited<Promise<Promise<number>>>;// C = boolean | numbertype C = Awaited<boolean | Promise<number>>; 捆绑的 dom lib 类型可以被替换TS 因开箱即用的特性,捆绑了所有 dom 内置类型,比如我们可以直接使用 Document 类型,而这个类型就是 TS 内置提供的。 也许有时不想随着 TS 版本升级而升级连带的 dom 内置类型,所以 TS 提供了一种指定 dom lib 类型的方案,在 package.json 申明 @typescript/lib-dom 即可: { "dependencies": { "@typescript/lib-dom": "npm:@types/web" }} 这个特性提升了 TS 的环境兼容性,但一般情况还是建议开箱即用,省去繁琐的配置,项目更好维护。 模版字符串类型也支持类型收窄export interface Success { type: `${string}Success`; body: string;}export interface Error { type: `${string}Error`; message: string;}export function handler(r: Success | Error) { if (r.type === "HttpSuccess") { // 'r' has type 'Success' let token = r.body; }} 模版字符串类型早就支持了,但现在才支持按照模版字符串在分支条件时,做类型收窄。 增加新的 –module es2022虽然可以使用 –module esnext 保持最新特性,但如果你想使用稳定的版本号,又要支持顶级 await 特性的话,可以使用 es2022。 尾递归优化TS 类型系统支持尾递归优化了,拿下面这个例子就好理解: type TrimLeft<T extends string> = T extends ` ${infer Rest}` ? TrimLeft<Rest> : T;// error: Type instantiation is excessively deep and possibly infinite.type Test = TrimLeft<" oops">; 在没有做尾递归优化前,TS 会因为堆栈过深而报错,但现在可以正确返回执行结果了,因为尾递归优化后,不会形成逐渐加深的调用,而是执行完后立即退出当前函数,堆栈数量始终保持不变。 JS 目前还没有做到自动尾递归优化,但可以通过自定义函数 TCO 模拟实现,下面放出这个函数的实现: function tco(f) { var value; var active = false; var accumulated = []; return function accumulator(...rest) { accumulated.push(rest); if (!active) { active = true; while (accumulated.length) { value = f.apply(this, accumulated.shift()); } active = false; return value; } };} 核心是把递归变成 while 循环,这样就不会产生堆栈。 强制保留 importTS 编译时会把没用到的 import 干掉,但这次提供了 --preserveValueImports 参数禁用这一特性,原因是以下情况会导致误移除 import: import { Animal } from "./animal.js";eval("console.log(new Animal().isDangerous())"); 因为 TS 无法分辨 eval 里的引用,类似的还有 vue 的 setup 语法: <!-- A .vue File --><script setup>import { someFunc } from "./some-module.js";</script><button @click="someFunc">Click me!</button> 支持变量 import type 声明之前支持了如下语法标记引用的变量是类型: import type { BaseType } from "./some-module.js"; 现在支持了变量级别的 type 声明: import { someFunc, type BaseType } from "./some-module.js"; 这样方便在独立模块构建时,安全的抹去 BaseType,因为单模块构建时,无法感知 some-module.js 文件内容,所以如果不特别指定 type BaseType,TS 编译器将无法识别其为类型变量。 类私有变量检查包含两个特性,第一是 TS 支持了类私有变量的检查: class Person { ##name: string;} 第二是支持了 ##name in obj 的判断,如: class Person { ##name: string; constructor(name: string) { this.##name = name; } equals(other: unknown) { return other && typeof other === "object" && ##name in other && // <- this is new! this.##name === other.##name; }} 该判断隐式要求了 ##name in other 的 other 是 Person 实例化的对象,因为该语法仅可能存在于类中,而且还能进一步类型缩窄为 Person 类。 Import 断言支持了导入断言提案: import obj from "./something.json" assert { type: "json" }; 以及动态 import 的断言: const obj = await import("./something.json", { assert: { type: "json" }}) TS 该特性支持了任意类型的断言,而不关心浏览器是否识别。所以该断言如果要生效,需要以下两种支持的任意一种: 浏览器支持。 构建脚本支持。 不过目前来看,构建脚本支持的语法并不统一,比如 Vite 对导入类型的断言有如下两种方式: import obj from "./something?raw"// 或者自创的语法 blob 加载模式const modules = import.meta.glob( './**/index.tsx', { assert: { type: 'raw' }, },); 所以该导入断言至少在未来可以统一构建工具的语法,甚至让浏览器原生支持后,就不需要构建工具处理 import 断言了。 其实完全靠浏览器解析要走的路还有很远,因为一个复杂的前端工程至少有 3000~5000 个资源文件,目前生产环境不可能使用 bundless 一个个加载这些资源,因为速度太慢了。 const 只读断言const obj = { a: 1} as constobj.a = 2 // error 通过该语法指定对象所有属性为 readonly。 利用 realpathSync.native 实现更快加载速度对开发者没什么感知,就是利用 realpathSync.native 提升了 TS 加载速度。 片段自动补全增强在 Class 成员函数与 JSX 属性的自动补全功能做了增强,在使用了最新版 TS 之后应该早已有了体感,比如 JSX 书写标签输入回车后,会自动根据类型补全内容,如: <App cla />// ↑回车↓// <App className="|" />// ↑光标自动移到这里 代码可以写在 super() 前了JS 对 super() 的限制是此前不可以调用 this,但 TS 限制的更严格,在 super() 前写任何代码都会报错,这显然过于严格了。 现在 TS 放宽了校验策略,仅在 super() 前调用 this 会报错,而执行其他代码是被允许的。 这点其实早就该改了,这么严格的校验策略让我一度以为 JS 就是不允许 super() 前调用任何函数,但想想也觉得不合理,因为 super() 表示调用父类的 constructor 函数,之所以不自动调用,而需要手动调用 super() 就是为了开发者可以灵活决定哪些逻辑在父类构造函数前执行,所以 TS 之前一刀切的行为实际上导致 super() 失去了存在的意义,成为一个没有意义的模版代码。 类型收窄对解构也生效了这个特性真的很厉害,即解构后类型收窄依然生效。 此前,TS 的类型收窄已经很强大了,可以做到如下判断: function foo(bar: Bar) { if (bar.a === '1') { bar.b // string 类型 } else { bar.b // number 类型 }} 但如果提前把 a、b 从 bar 中解构出来就无法自动收窄了。现在该问题也得到了解决,以下代码也可以正常生效了: function foo(bar: Bar) { const { a, b } = bar if (a === '1') { b // string 类型 } else { b // number 类型 }} 深度递归类型检查优化下面的赋值语句会产生异常,原因是属性 prop 的类型不匹配: interface Source { prop: string;}interface Target { prop: number;}function check(source: Source, target: Target) { target = source; // error! // Type 'Source' is not assignable to type 'Target'. // Types of property 'prop' are incompatible. // Type 'string' is not assignable to type 'number'.} 这很好理解,从报错来看,TS 也会根据递归检测的方式查找到 prop 类型不匹配。但由于 TS 支持泛型,如下写法就是一种无限递归的例子: interface Source<T> { prop: Source<Source<T>>;}interface Target<T> { prop: Target<Target<T>>;}function check(source: Source<string>, target: Target<number>) { target = source;} 实际上不需要像官方说明写的这么复杂,哪怕是 props: Source<T> 也足以让该例子无限递归下去。TS 为了确保该情况不会出错,做了递归深度判断,过深的递归会终止判断,但这会带来一个问题,即无法识别下面的错误: interface Foo<T> { prop: T;}declare let x: Foo<Foo<Foo<Foo<Foo<Foo<string>>>>>>;declare let y: Foo<Foo<Foo<Foo<Foo<string>>>>>;x = y; 为了解决这一问题,TS 做了一个判断:递归保护仅对递归写法的场景生效,而上面这个例子,虽然也是很深层次的递归,但因为是一个个人肉写出来的,TS 也会不厌其烦的一个个递归下去,所以该场景可以正确 Work。 这个优化的核心在于,TS 可以根据代码结构解析哪些是 “非常抽象/启发式” 写法导致的递归,哪些是一个个枚举产生的递归,并对后者的递归深度检查进行豁免。 增强的索引推导下面的官方文档给出的例子,一眼看上去比较复杂,我们来拆解分析一下: interface TypeMap { "number": number; "string": string; "boolean": boolean;}type UnionRecord<P extends keyof TypeMap> = { [K in P]: { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void; }}[P];function processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) { record.f(record.v);}// This call used to have issues - now works!processRecord({ kind: "string", v: "hello!", // 'val' used to implicitly have the type 'string | number | boolean', // but now is correctly inferred to just 'string'. f: val => { console.log(val.toUpperCase()); }}) 该例子的目的是实现 processRecord 函数,该函数通过识别传入参数 kind 来自动推导回调函数 f 中 value 的类型。 比如 kind: "string",那么 val 就是字符串类型,kind: "number",那么 val 就是数字类型。 因为 TS 这次更新解决了之前无法识别 val 类型的问题,我们不需要关心 TS 是怎么解决的,只要记住 TS 可以正确识别该场景(有点像围棋的定式,对于经典例子最好逐一学习),并且理解该场景是如何构造的。 如何做到呢?首先定义一个类型映射: interface TypeMap { "number": number; "string": string; "boolean": boolean;} 之后定义最终要的函数 processRecord: function processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) { record.f(record.v);} 这里定义了一个泛型 K,K extends keyof TypeMap 等价于 K extends 'number' | 'string' | 'boolean',所以这里是限定了以下泛型 K 的取值范围,值为这三个字符串之一。 重点来了,参数 record 需要根据传入的 kind 决定 f 回调函数参数类型。我们先想象以下 UnionRecord 类型怎么写: type UnionRecord<K extends keyof TypeMap> = { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void;} 如上,自然的想法是定义一个泛型 K,这样 kind 与 f, p 类型都可以表示出来,这样 processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) 的 UnionRecord<K> 就表示了将当前接收到的实际类型 K 传入 UnionRecord,这样 UnionRecord 就知道实际处理什么类型了。 本来到这里该功能就已经结束了,但官方给的 UnionRecord 定义稍有些不同: type UnionRecord<P extends keyof TypeMap> = { [K in P]: { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void; }}[P]; 这个例子特意提升了一个复杂度,用索引的方式绕了一下,可能之前 TS 就无法解析这种形式吧,总之现在这个写法也被支持了。我们看一下为什么这个写法与上面是等价的,上面的写法简化一下如下: type UnionRecord<P extends keyof TypeMap> = { [K in P]: X}[P]; 可以解读为,UnionRecord 定义了一个泛型 P,该函数从对象 { [K in P]: X } 中按照索引(或理解为下标) [P] 取得类型。而 [K in P] 这种描述对象 Key 值的类型定义,等价于定义了复数个类型,由于正好 P extends keyof TypeMap,你可以理解为类型展开后是这样的: type UnionRecord<P extends keyof TypeMap> = { 'number': X, 'string': X, 'boolean': X}[P]; 而 P 是泛型,由于 [K in P] 的定义,所以必定能命中上面其中的一项,所以实际上等价于下面这个简单的写法: type UnionRecord<K extends keyof TypeMap> = { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void;} 参数控制流分析这个特性字面意思翻译挺奇怪的,还是从代码来理解吧: type Func = (...args: ["a", number] | ["b", string]) => void;const f1: Func = (kind, payload) => { if (kind === "a") { payload.toFixed(); // 'payload' narrowed to 'number' } if (kind === "b") { payload.toUpperCase(); // 'payload' narrowed to 'string' }};f1("a", 42);f1("b", "hello"); 如果把参数定义为元组且使用或并列枚举时,其实就潜在包含了一个运行时的类型收窄。比如当第一个参数值为 a 时,第二个参数类型就确定为 number,第一个参数值为 b 时,第二个参数类型就确定为 string。 值得注意的是,这种类型推导是从前到后的,因为参数是自左向右传递的,所以是前面推导出后面,而不能是后面推导出前面(比如不能理解为,第二个参数为 number 类型,那第一个参数的值就必须为 a)。 移除 JSX 编译时产生的非必要代码JSX 编译时干掉了最后一个没有意义的 void 0,减少了代码体积: - export const el = _jsx("div", { children: "foo" }, void 0);+ export const el = _jsx("div", { children: "foo" }); 由于改动很小,所以可以借机学习一下 TS 源码是怎么修改的,这是 PR DIFF 地址。 可以看到,修改位置是 src/compiler/transformers/jsx.ts 文件,改动逻辑为移除了 factory.createVoidZero() 函数,该函数正如其名,会创建末尾的 void 0,除此之外就是大量的 tests 文件修改,其实理解了源码上下文,这种修改并不难。 JSDoc 校验提示JSDoc 注释由于与代码是分离的,随着不断迭代很容易与实际代码产生分叉: /** * @param x {number} The first operand * @param y {number} The second operand */function add(a, b) { return a + b;} 现在 TS 可以对命名、类型等不一致给出提示了。顺便说一句,用了 TS 就尽量不要用 JSDoc,毕竟代码和类型分离随时有不一致的风险产生。 总结从这两个更新来看,TS 已经进入成熟期,但 TS 在泛型类的问题上依然还处于早期阶段,有大量复杂的场景无法支持,或者没有优雅的兼容方案,希望未来可以不断完善复杂场景的类型支持。 讨论地址是:精读《Typescript 4.5-4.6 新特性》· Issue ##408 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Typescript 3","path":"/wiki/WebWeekly/前沿技术/《Typescript 3.html","content":"当前期刊数: 84 1 引言Typescript 3.2 发布了几个新特性,主要变化是类型检查更严格,对 ES6、ES7 一些时髦功能拓展了类型支持。 2 概要下面挑一些相对重要配置介绍。 strictBindCallApply对 bind call apply 更严格的类型检测。 比如如下可以检测出 apply 函数参数数量和类型的错误: function foo(a: number, b: string): string { return a + b;}let a = foo.apply(undefined, [10]); // error: too few argumnts 特别对一些 react 老代码,函数需要自己 bind(this),在没有用箭头函数时,可能经常使用 this.foo = this.foo.bind(this),这时类型可能会不准,但升级到 TS3.2 后,可以准确捕获到错误了。 Object spread 类型自动合并现在 Object spread 类型可以自动合并了: // Returns 'T & U'function merge<T, U>(x: T, y: U) { return { ...x, ...y };} Object rest 类型自动剔除const { x, y, z, ...rest } = obj; 当我们使用了 Object rest 语法时,rest 的类型其实是 obj 类型剔除了 x y z 这三个 key 的类型,现在 ts 已经能自动做到了! 下面是实现方式: interface XYZ { x: any; y: any; z: any;}type DropXYZ<T> = Pick<T, Exclude<keyof T, keyof XYZ>>;function dropXYZ<T extends XYZ>(obj: T): DropXYZ<T> { let { x, y, z, ...rest } = obj; return rest;} 通过 Pick & Exclude 达到剔除 obj 属性的效果,具体可以参考之前的精读:精读《Typescript2.0 - 2.9》。 tsconfig 配置集成支持 node_modules这是一个福音,以往在 tsconfig.json 为了继承一个配置,我们需要这么写: { "extends": "../node_modules/@my-team/tsconfig-base/tsconfig.json"} TS3.2 内置了 node_modules 解析,因此就可以更清晰的描述了: { "extends": "@my-team/tsconfig-base"} 内置 BigInt 类型新增了 bigint 类型,再也不会把 bigint 和 number 混淆了。 declare let foo: number;declare let bar: bigint;foo = bar; // error: Type 'bigint' is not assignable to type 'number'.bar = foo; // error: Type 'number' is not assignable to type 'bigint'. 3 精读这次改动意图非常明显,是为了跟上 JS 的新语法。随着 JS 规范发展,TS 类型必然要得到补充,像 Object spread 与 Object rest 在项目中使用已经非常普遍了,及时完善类型支持,有助于对项目类型的约束。 strictBindCallApply 基本可以算是对 React 社区的回馈。在 React 很早期的版本是支持函数自动 bind 的,后来觉得过于 magic 就移除了,由于当时没有箭头函数,大家只好在调用处 .bind(this) 一下。 后来有人发现 .bind(this) 会导致函数引用变化,对 Mutable 性能优化不友好,所以许多代码都在 constructor 位置进行类似 this.fooBind = this.foo.bind(this) 这样的赋值,如今 TS3.2 对这种 bind 过后的函数也具备了严格的类型推测,将会有一大批代码从中受益。 顺带一提,最近 Babel 7.2.0 发布,也带来了一些新特性支持,比如: 提前支持私有属性: class Person { ##age = 19; ##increaseAge() { this.##age++; } birthday() { this.##increaseAge(); alert("Happy Birthday!"); }} 提前支持 pipleline Operator: const result = 2 |> double |> 3 + ## |> toStringBase(2, ##); // "111" 整个 JS 生态一篇欣欣向荣的景象。不过 TS 对 ES 规范支持还是比较保守的,比如 Babel 6.x 就支持的 optional chain,现在也没有得到支持,原因是 “等待进入 Stage3”。追踪 ISSUE 可以参考:https://github.com/Microsoft/TypeScript/issues/16 如果不清楚 Stage3 的含义,可以阅读前端精读之前的一篇文章:精读 TC39 与 ECMAScript 提案。 4 总结这次的版本升级几乎没带来什么新语法,只是纯粹的类型检测能力增强,所以如果希望进一步提高代码质量,就尽快升级把。 讨论地址是:精读《Typescript 3.2 新特性》 · Issue ##117 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《Typescript 4》","path":"/wiki/WebWeekly/前沿技术/《Typescript 4》.html","content":"当前期刊数: 158 1 引言随着 Typescript 4 Beta 的发布,又带来了许多新功能,其中 Variadic Tuple Types 解决了大量重载模版代码的顽疾,使得这次更新非常有意义。 2 简介可变元组类型考虑 concat 场景,接收两个数组或者元组类型,组成一个新数组: function concat(arr1, arr2) { return [...arr1, ...arr2];} 如果要定义 concat 的类型,以往我们会通过枚举的方式,先枚举第一个参数数组中的每一项: 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 次重载,代码几乎不可维护: 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]; 如果我们采用批量定义的方式,问题也不会得到解决,因为参数类型的顺序得不到保证: function concat<T, U>(arr1: T[], arr2, U[]): Array<T | U>; 在 Typescript 4,可以在定义中对数组进行解构,通过几行代码优雅的解决可能要重载几百次的场景: 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,返回除第一项外剩下元素: function tail(arg) { const [_, ...result] = arg; return result;} 同样告诉 TS T 是数组类型,且 arr: readonly [any, ...T] 申明了 T 类型表示除第一项其余项的类型,TS 可自动将 T 类型关联到对象 rest: 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 只能将类型解构放在最后一个位置: type Strings = [string, string];type Numbers = [number, number];// [string, string, number, number]type StrStrNumNum = [...Strings, ...Numbers]; 如果你尝试将 [...Strings, ...Numbers] 这种写法,将会得到一个错误提示: A rest element must be last in a tuple type. 但在 Typescript 4 版本支持了这种语法: type Strings = [string, string];type Numbers = number[];// [string, string, ...Array<number | boolean>]type Unbounded = [...Strings, ...Numbers, boolean]; 对于再复杂一些的场景,例如高阶函数 partialCall,支持一定程度的柯里化: function partialCall(f, ...headArgs) { return (...tailArgs) => f(...headArgs, ...tailArgs);} 我们可以通过上面的特性对其进行类型定义,将函数 f 第一个参数类型定义为有顺序的 [...T, ...U]: 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);} 测试效果如下: 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() 时才会提示参数数量不正确。 元组标记下面两个函数定义在功能上是一样的: function foo(...args: [string, number]): void { // ...}function foo(arg0: string, arg1: number): void { // ...} 但还是有微妙的区别,下面的函数对每个参数都有名称标记,但上面通过解构定义的类型则没有,针对这种情况,Typescript 4 支持了元组标记: type Range = [start: number, end: number]; 同时也支持与解构一起使用: type Foo = [first: number, second?: string, ...rest: any[]]; Class 从构造函数推断成员变量类型构造函数在类实例化时负责一些初始化工作,比如为成员变量赋值,在 Typescript 4,在构造函数里对成员变量的赋值可以直接为成员变量推导类型: class Square { // Previously: implicit any! // Now: inferred to `number`! area; sideLength; constructor(sideLength: number) { this.sideLength = sideLength; this.area = sideLength ** 2; }} 如果对成员变量赋值包含在条件语句中,还能识别出存在 undefined 的风险: class Square { sideLength; constructor(sideLength: number) { if (Math.random()) { this.sideLength = sideLength; } } get area() { return this.sideLength ** 2; // ~~~~~~~~~~~~~~~ // error! Object is possibly 'undefined'. }} 如果在其他函数中初始化,则 TS 不能自动识别,需要用 !: 显式申明类型: 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; }} 短路赋值语法针对以下三种短路语法提供了快捷赋值语法: a &&= b; // a && (a = b)a ||= b; // a || (a = b)a ??= b; // a ?? (a = b) catch error unknown 类型Typescript 4.0 之后,我们可以将 catch error 定义为 unknown 类型,以保证后面的代码以健壮的类型判断方式书写: 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 工厂函数: { "compilerOptions": { "target": "esnext", "module": "commonjs", "jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment" }} 还可以通过注释方式覆盖单文件的配置: // 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> </>); 以上代码编译后解析结果如下: // 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 数据流的值,其调用方式是这样的: 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,但类型定义就麻烦了,通过重载可以这么做: 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 的例子,我们可以避免类型重载,而通过枚举的方式支持: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Typescript infer 关键字》","path":"/wiki/WebWeekly/前沿技术/《Typescript infer 关键字》.html","content":"当前期刊数: 207 Infer 关键字用于条件中的类型推导。 Typescript 官网也拿 ReturnType 这一经典例子说明它的作用: type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any; 理解为:如果 T 继承了 (...args: any[]) => any 类型,则返回类型 R,否则返回 any。其中 R 是什么呢?R 被定义在 extends (...args: any[]) => infer R 中,即 R 是从传入参数类型中推导出来的。 精读我们可以从两个视角来理解 infer,分别是需求角度与设计角度。 需求角度理解 infer实现 infer 这个关键字一定是背后存在需求,这个需求是普通 Typescript 能力无法满足的。 设想这样一个场景:实现一个函数,接收一个数组,返回第一项。 我们无法用泛型来描述这种类型推导,因为泛型类型是一个整体,而我们想要返回的是入参其中某一项,我们并不能通过类似 T[0] 的写法拿到第一项类型: function xxx<T>(...args: T[]): T[0] 而实际上不支持这种写法也是合理的,因为这次是获取第一项类型,如果 T 是一个对象,我们想返回其中 onChange 这个 Key 的返回值类型,就不知道如何书写了。所以此时必须用一种新的语法实现,就是 infer。 设计角度理解 infer从类型推导功能来看,泛型功能非常强大,我们可以用泛型描述调用时才传入的类型,并提前将它描述在类型表达式中: function xxx<T>(value: T): { result: T } 但我们发现 T 这个泛型太整体化了,我们还不具备从中 Pick 子类型的能力。也就是对于 xxx<{label: string}> 这个场景,T = {label: string},但我们无法将 R 定义为 {label: R} 这个位置,因为泛型是一个不可拆分的整体。 而且实际上为了类型安全,我们也不能允许用户描述任意的类型位置,万一传入的类型结构不是 {label: xxx} 而是一个回调 () => void,那子类型推导岂不是建立在了错误的环境中。 所以考虑到想要拿到 {label: infer R},首先参数必须具备 {label: xxx} 的结构,所以正好可以将 infer 与条件判断 T extends xxx ? A : B 结合起来用,即: type GetLabelTypeFromObject<T> = T extends { label: infer R } ? R : nevertype Result = GetLabelTypeFromObject<{ label: string }>;// type Result = string 即如果 T 遵循 { label: any } 这样一个结构,那么我可以将这个结构中任何变量位置替换为 infer xxx,如果传入类型满足这个结构(TS 静态解析环节判断),则可以基于这个结构体继续推导,所以在推导过程中我们就可以使用 infer xxx 推断的变量类型。 回过头来看第一个需求,拿到第一个参数类型就可以用 infer 实现了: type GetFirstParamType<T> = T extends (...args: infer R) => any ? R[0] : never 可以理解为,如果此时 T 满足 (...args: any) => any 这个结构,同时我们用 infer R 表示 R 这个临时变量指代第一个 any 运行时类型,那么整个函数返回的类型就是 R。如果 T 都不满足 (...args: any) => any 这个结构,比如 GetFirstParamType<number>,那这种推导根本无从谈起,直接返回 never 类型兜底,当然也可以自定义比如 any 之类的任何类型。 概述我们理解了 infer 含义后,再结合 conditional infer 这篇文章理解里面的例子,有助于加深记忆。 type ArrayElementType<T> = T extends (infer E)[] ? E : T;// type of item1 is `number`type item1 = ArrayElementType<number[]>;// type of item1 is `{name: string}`type item2 = ArrayElementType<{ name: string }>; 可以看到,ArrayElementType 利用了条件推断与 infer,表示了这样一个逻辑:如果 T 类型是一个数组,且我们将数组的每一项定义为 E 类型,那么返回类型就为 E,否则为 T 整体类型本身。 所以对于 item1 是满足结构的,所以返回 number,而 item2 不满足结构,所以返回其类型本身。 特别补充一点,对于下面的例子返回什么呢? type item3 = ArrayElementType<[number, string]>; 答案是 number | string,原因是我们用多个 infer E((infer E)[] 相当于 [infer E, infer E]... 不就是多个变量指向同一个类型代词 E 嘛)同时接收到了 number 和 string,所以可以理解为 E 时而为 number 时而为 string,所以是或关系,这就是协变。 那如果是函数参数呢? type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : nevertype T21 = Bar<{ a: (x: string) => void; b: (x: number) => void }>; // string & number 发现结果是 string & number,也就是逆变。但这个例子也是同一个 U 时而为 string 时而为 number 呀,为什么是且的关系,而不是或呢? 其实协变或逆变与 infer 参数位置有关。在 TypeScript 中,对象、类、数组和函数的返回值类型都是协变关系,而函数的参数类型是逆变关系,所以 infer 位置如果在函数参数上,就会遵循逆变原则。 逆变与协变: 协变(co-variant):类型收敛。 逆变(contra-variant):类型发散。 关于逆变与协变更深入的话题可以再开一篇文章了,这里就不细讲了,对于 infer 理解到这里就够啦。 总结infer 关键字让我们拥有深入展开泛型的结构,并 Pick 出其中任何位置的类型,并作为临时变量用于最终返回类型的能力。 对于 Typescript 类型编程,最大的问题莫过于希望实现一个效果却不知道用什么语法,infer 作为一个强大的类型推导关键字,势必会在大部分复杂类型推导场景下派上用场,所以在遇到困难时,可以想想是不是能用 infer 解决问题。 讨论地址是:精读《Typescript infer 关键字》· Issue ##346 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《V8 引擎 Lazy Parsing》","path":"/wiki/WebWeekly/前沿技术/《V8 引擎 Lazy Parsing》.html","content":"当前期刊数: 100 1. 引言本周精读的文章是 V8 引擎 Lazy Parsing,看看 V8 引擎为了优化性能,做了怎样的尝试吧! 这篇文章介绍的优化技术叫 preparser,是通过跳过不必要函数编译的方式优化性能。 2. 概述 & 精读解析 Js 发生在网页运行的关键路径上,因此加速对 JS 的解析,就可以加速网页运行效率。 然而并不是所有 Js 都需要在初始化时就被执行,因此也不需要在初始化时就解析所有的 Js!因为编译 Js 会带来三个成本问题: 编译不必要的代码会占用 CPU 资源。 在 GC 前会占用不必要的内存空间。 编译后的代码会缓存在磁盘,占用磁盘空间。 因此所有主流浏览器都实现了 Lazy Parsing(延迟解析),它会将不必要的函数进行预解析,也就是只解析出外部函数需要的内容,而全量解析在调用这个函数时才发生。 预解析的挑战本来预解析也不难,因为只要判断一个函数是否会立即执行就可以了,只有立即执行的函数才需要被完全解析。 使得预解析变复杂的是变量分配问题。原文通过了堆栈调用的例子说明原因: Js 代码的执行在堆栈上完成,比如下面这个函数: function f(a, b) { const c = a + b; return c;}function g() { return f(1, 2); // The return instruction pointer of `f` now points here // (because when `f` `return`s, it returns here).} 这段函数的调用堆栈如下: 首先是全局 This globalThis,然后执行到函数 f,再对 a b 进行赋值。在执行 f 函数时,通过 <rip g>(return instruction pointer) 保存 g 堆栈状态,再保存堆栈跳出后返回位置的指针 <save fp>(frame pointer),最后对变量 c 赋值。 这看上去没有问题,只要将值存在堆栈就搞定了。但是将变量定义到函数内部就不一样了: function make_f(d) { // ← declaration of `d` return function inner(a, b) { const c = a + b + d; // ← reference to `d` return c; };}const f = make_f(10);function g() { return f(1, 2);} 将变量 d 申明在函数 make_f 中,且在返回函数 inner 中用到了 d。那么函数的调用栈就变成了这样: 需要创建一个 context 存储函数 f 中变量 d 的值。 也就是说,如果一个在函数内部定义的变量被子 Scope 使用时,Js 引擎需要识别这种情况,并将这个变量值存储在 context 中。 所以对于函数定义的每一个入参,我们需要知道其是否会被子函数引用。也就是说,在 preparser 阶段,我们只要少能分析出哪些变量被内部函数引用了。 难以分辨的引用预处理器中跟踪变量的申明与引用很复杂,因为 Js 的语法导致了无法从部分表达式推断含义,比如下面的函数: function f(d) { function g() { const a = ({ d } 我们不清楚第三行的 d 到底是不是指代第一行的 d。它可能是: function f(d) { function g() { const a = ({ d } = { d: 42 }); return a; } return g;} 也可能只是一个自定义函数参数,与上面的 d 无关: function f(d) { function g() { const a = ({ d }) => d; return a; } return [d, g];} 惰性 parse在执行函数时,只会将最外层执行的函数完全编译并生成 AST,而对内部模块只进行 preparser。 // This is the top-level scope.function outer() { // preparsed function inner() { // preparsed }}outer(); // Fully parses and compiles `outer`, but not `inner`. 为了允许惰性编译函数,上下文指针指向了 ScopeInfo 的对象(从代码中可以看到,ScopeInfo 包含上下文信息,比如当前上下文是否有函数名,是否在一个函数内等等),当编译内部函数时,可以利用 ScopeInfo 继续编译子函数。 但是为了判断惰性编译函数自身是否需要一个上下文,我们需要再次解析内部的函数:比如我们需要知道某个子函数是否对外层函数定义的变量有所引用。 这样就会产生递归遍历: 由于代码总会包含一些嵌套,而编译工具更会产生 IIFE(立即调用函数) 这种多层嵌套的表达式,使得递归性能比较差。 而下面有一种办法可以将时间复杂度简化为线性:将变量分配的位置序列化为一个密集的数组,当惰性解析函数时,变量会按照原先的顺序重新创建,这样就不需要因为子函数可能引用外层定义变量的原因,对所有子函数进行递归惰性解析了。 按照这种方式优化后的时间复杂度是线性的: 针对模块化打包的优化由于现代代码几乎都是模块化编写的,构建起在打包时会将模块化代码封装在 IIFE(立即调用的闭包)中,以保证模拟模块化环境运行。比如 (function(){....})()。 这些代码看似在函数中应该惰性编译,但其实这些模块化代码从一开始就要被编译,否则反而会影响性能,因此 V8 有两种机制识别这些可能被立即调用的函数: 如果函数是带括号的,比如 (function(){...}),就假设它会被立即调用。 从 V8 v5.7 / Chrome 57 开始,还会识别 uglifyJS 的 !function(){...}(), function(){...}(), function(){...}() 这种模式。 然而在浏览器引擎解析环境比较复杂,很难对函数进行完整字符串匹配,因此只能对函数头进行简单判断。所以对于下面这种匿名函数的行为,浏览器是不识别的: // pre-parserfunction run(func) { func()}run(function(){}) // 在这执行它,进行 full parser 上面的代码看上去没毛病,但由于浏览器只检测被括号括住的函数,因此这个函数不被认为是立即执行函数,因此在后续执行时会被重复 full-parse。 也有一些代码辅助转换工具帮助 V8 正确识别,比如 optimize-js,会将代码做如下转换。 转换前: !function (){}()function runIt(fun){ fun() }runIt(function (){}) 转换后: !(function (){})()function runIt(fun){ fun() }runIt((function (){})) 然而在 V8 v7.5+ 已经很大程度解决了这个问题,因此现在其实不需要使用 optimize-js 这种库了~ 4. 总结JS 解析引擎在性能优化做了不少工作,但同时也要应对代码编译器产生的特殊 IIFE 闭包,防止对这种立即执行闭包进行重复 parser。 最后,不要试图总是将函数用括号括起来,因为这样会导致惰性编译的特性无法启用。 讨论地址是:精读《V8 引擎 Lazy Parsing》 · Issue ##148 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Typescript2","path":"/wiki/WebWeekly/前沿技术/《Typescript2.html","content":"当前期刊数: 58 1 引言精读原文是 typescript 2.0-2.9 的文档: 2.0-2.8,2.9 草案. 我发现,许多写了一年以上 Typescript 开发者,对 Typescript 对理解和使用水平都停留在入门阶段。造成这个现象的原因是,Typescript 知识的积累需要 刻意练习,使用 Typescript 的时间与对它的了解程度几乎没有关系。 这篇文章精选了 TS 在 2.0-2.9 版本中最重要的功能,并配合实际案例解读,帮助你快速跟上 TS 的更新节奏。 对于 TS 内部优化的用户无感部分并不会罗列出来,因为这些优化都可在日常使用过程中感受到。 2 精读由于 Typescript 在严格模式下的许多表现都与非严格模式不同,为了避免不必要的记忆,建议只记严格模式就好了! 严格模式导致的大量边界检测代码,已经有解了直接访问一个变量的属性时,如果这个变量是 undefined,不但属性访问不到,js 还会抛出异常,这几乎是业务开发中最高频的报错了(往往是后端数据异常导致的),而 typescript 的 strict 模式会检查这种情况,不允许不安全的代码出现。 在 2.0 版本,提供了 “非空断言标志符” !. 解决明确不会报错的情况,比如配置文件是静态的,那肯定不会抛出异常,但在 2.0 之前的版本,我们可能要这么调用对象: const config = { port: 8000};if (config) { console.log(config.port);} 有了 2.0 提供的 “非空断言标志符”,我们可以这么写了: console.log(config!.port); 在 2.8 版本,ts 支持了条件类型语法: type TypeName<T> = T extends string ? "string" 当 T 的类型是 string 时,TypeName 的表达式类型为 “string”。 这这时可以构造一个自动 “非空断言” 的类型,把代码简化为: console.log(config.port); 前提是框架先把 config 指定为这个特殊类型,这个特殊类型的定义如下: export type PowerPartial<T> = { [U in keyof T]?: T[U] extends object ? PowerPartial<T[U]> : T[U]}; 也就是 2.8 的条件类型允许我们在类型判断进行递归,把所有对象的 key 都包一层 “非空断言”! 此处灵感来自 egg-ts 总结 增加了 never object 类型当一个函数无法执行完,或者理解为中途中断时,TS 2.0 认为它是 never 类型。 比如 throw Error 或者 while(true) 都会导致函数返回值类型时 never。 和 null undefined 特性一样,never 等于是函数返回值中的 null 或 undefined。它们都是子类型,比如类型 number 自带了 null 与 undefined 这两个子类型,是因为任何有类型的值都有可能是空(也就是执行期间可能没有值)。 这里涉及到很重要的概念,就是预定义了类型不代表类型一定如预期,就好比函数运行时可能因为 throw Error 而中断。所以 ts 为了处理这种情况,将 null undefined 设定为了所有类型的子类型,而从 2.0 开始,函数的返回值类型又多了一种子类型 never。 TS 2.2 支持了 object 类型, 但许多时候我们总把 object 与 any 类型弄混淆,比如下面的代码: const persion: object = { age: 5};console.log(persion.age); // Error: Property 'age' does not exist on type 'object'. 这时候报错会出现,有时候闭个眼改成 any 就完事了。其实这时候只要把 object 删掉,换成 TS 的自动推导就搞定了。那么问题出在哪里? 首先 object 不是这么用的,它是 TS 2.3 版本中加入的,用来描述一种非基础类型,所以一般用在类型校验上,比如作为参数类型。如果参数类型是 object,那么允许任何对象数据传入,但不允许 3 "abc" 这种非对象类型: declare function create(o: object | null): void;create({ prop: 0 }); // 正确create(null); // 正确create(42); // 错误create("string"); // 错误create(false); // 错误create(undefined); // 错误 而一开始 const persion: object 这种用法,是将能精确推导的对象类型,扩大到了整体的,模糊的对象类型,TS 自然无法推断这个对象拥有哪些 key,因为对象类型仅表示它是一个对象类型,在将对象作为整体观察时是成立的,但是 object 类型是不承认任何具体的 key 的。 增加了修饰类型TS 在 2.0 版本支持了 readonly 修饰符,被它修饰的变量无法被修改。 在 TS 2.8 版本,又增加了 - 与 + 修饰修饰符,有点像副词作用于形容词。举个例子,readonly 就是 +readonly,我们也可以使用 -readonly 移除只读的特性;也可以通过 -?: 的方式移除可选类型,因此可以延伸出一种新类型:Required<T>,将对象所有可选修饰移除,自然就成为了必选类型: type Required<T> = { [P in keyof T]-?: T[P] }; 可以定义函数的 this 类型也是 TS 2.0 版本中,我们可以定制 this 的类型,这个在 vue 框架中尤为有用: function f(this: void) { // make sure `this` is unusable in this standalone function} this 类型是一种假参数,所以并不会影响函数真正参数数量与位置,只不过它定义在参数位置上,而且永远会插队在第一个。 引用、寻址支持通配符了简单来说,就是模块名可以用 * 表示任何单词了: declare module "*!text" { const content: string; export default content;} 它的类型可以辐射到: import fileContent from "./xyz.txt!text"; 这个特性很强大的一个点是用在拓展模块上,因为包括 tsconfig.json 的模块查找也支持通配符了!举个例子一下就懂: 最近比较火的 umi 框架,它有一个 locale 插件,只要安装了这个插件,就可以从 umi/locale 获取国际化内容: import { locale } from "umi/locale"; 其实它的实现是创建了一个文件,通过 webpack.alias 将引用指了过去。这个做法非常棒,那么如何为它加上类型支持呢?只要这么配置 tsconfig.json: { "compilerOptions": { "paths": { "umi/*": ["umi", "<somePath>"] } }} 将所有 umi/* 的类型都指向 <somePath>,那么 umi/locale 就会指向 <somePath>/locale.ts 这个文件,如果插件自动创建的文件名也恰好叫 locale.ts,那么类型就自动对应上了。 跳过仓库类型报错TS 在 2.x 支持了许多新 compileOptions,但 skipLibCheck 实在是太耀眼了,笔者必须单独提出来说。 skipLibCheck 这个属性不但可以忽略 npm 不规范带来的报错,还能最大限度的支持类型系统,可谓一举两得。 拿某 UI 库举例,某天发布的小版本 d.ts 文件出现一个漏洞,导致整个项目构建失败,你不再需要提 PR 催促作者修复了!skipLibCheck 可以忽略这种报错,同时还能保持类型的自动推导,也就是说这比 declare module "ui-lib" 将类型设置为 any 更强大。 对类型修饰的增强TS 2.1 版本可谓是针对类型操作革命性的版本,我们可以通过 keyof 拿到对象 key 的类型: interface Person { name: string; age: number;}type K1 = keyof Person; // "name" | "age" 基于 keyof,我们可以增强对象的类型: type NewObjType<T> = { [P in keyof T]: T[P] }; Tips:在 TS 2.8 版本,我们可以以表达式作为 keyof 的参数,比如 keyof (A & B)。Tips:在 TS 2.9 版本,keyof 可能返回非 string 类型的值,因此从一开始就不要认为 keyof 的返回类型一定是 string。 NewObjType 原封不动的将对象类型重新描述了一遍,这看上去没什么意义。但实际上我们有三处拓展的地方: 左边:比如可以通过 readonly 修饰,将对象的属性变成只读。 中间:比如将 : 改成 ?:,将对象所有属性变成可选。 右边:比如套一层 Promise<T[P]>,将对象每个 key 的 value 类型覆盖。 基于这些能力,我们拓展出一系列上层很有用的 interface: Readonly。把对象 key 全部设置为只读,或者利用 2.8 的条件类型语法,实现递归设置只读。 Partial。把对象的 key 都设置为可选。 Pick<T, K>。从对象类型 T 挑选一些属性 K,比如对象拥有 10 个 key,只需要将 K 设置为 "name" | "age" 就可以生成仅支持这两个 key 的新对象类型。 Extract<T, U>。是 Pick 的底层 API,直到 2.8 版本才内置进来,可以认为 Pick 是挑选对象的某些 key,Extract 是挑选 key 中的 key。 Record<K, U>。将对象某些属性转换成另一个类型。比较常见用在回调场景,回调函数返回的类型会覆盖对象每一个 key 的类型,此时类型系统需要 Record 接口才能完成推导。 Exclude<T, U>。将 T 中的 U 类型排除,和 Extract 功能相反。 Omit<T, K>(未内置)。从对象 T 中排除 key 是 K 的属性。可以利用内置类型方便推导出来:type Omit<T, K> = Pick<T, Exclude<keyof T, K>> NonNullable。排除 T 的 null 与 undefined 的可能性。 ReturnType。获取函数 T 返回值的类型,这个类型意义很大。 InstanceType。获取一个构造函数类型的实例类型。 以上类型都内置在 lib.d.ts 中,不需要定义就可直接使用,可以认为是 Typescript 的 utils 工具库。 单独拿 ReturnType 举个例子,体现出其重要性: Redux 的 Connect 第一个参数是 mapStateToProps,这些 Props 会自动与 React Props 聚合,我们可以利用 ReturnType<typeof currentMapStateToProps> 拿到当前 Connect 注入给 Props 的类型,就可以打通 Connect 与 React 组件的类型系统了。 对 Generators 和 async/await 的类型定义TS 2.3 版本做了许多对 Generators 的增强,但实际上我们早已用 async/await 替代了它,所以 TS 对 Generators 的增强可以忽略。需要注意的一块是对 for..of 语法的异步迭代支持: async function f() { for await (const x of fn1()) { console.log(x); }} 这可以对每一步进行异步迭代。注意对比下面的写法: async function f() { for (const x of await fn2()) { console.log(x); }} 对于 fn1,它的返回值是可迭代的对象,并且每个 item 类型都是 Promise 或者 Generator。对于 fn2,它自身是个异步函数,返回值是可迭代的,而且每个 item 都不是异步的。举个例子: function fn1() { return [Promise.resolve(1), Promise.resolve(2)];}function fn2() { return [1, 2];} 在这里顺带一提,对 Array.map 的每一项进行异步等待的方法: await Promise.all( arr.map(async item => { return await item.run(); })); 如果为了执行顺序,可以换成 for..of 的语法,因为数组类型是一种可迭代类型。 泛型默认参数了解这个之前,先介绍一下 TS 2.0 之前就支持的函数类型重载。 首先 JS 是不支持方法重载的,Java 是支持的,而 TS 类型系统一定程度在对标 Java,当然要支持这个功能。好在 JS 有一些偏方实现伪方法重载,典型的是 redux 的 createStore: export default function createStore(reducer, preloadedState, enhancer) { if (typeof preloadedState === "function" && typeof enhancer === "undefined") { enhancer = preloadedState; preloadedState = undefined; }} 既然 JS 有办法支持方法重载,那 TS 补充了函数类型重载,两者结合就等于 Java 方法重载: declare function createStore( reducer: Reducer, preloadedState: PreloadedState, enhancer: Enhancer);declare function createStore(reducer: Reducer, enhancer: Enhancer); 可以清晰的看到,createStore 想表现的是对参数个数的重载,如果定义了函数类型重载,TS 会根据函数类型自动判断对应的是哪个定义。 而在 TS 2.3 版本支持了泛型默认参数,可以减少某些场景函数类型重载的代码量,比如对于下面的代码: declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;declare function create<T extends HTMLElement, U extends HTMLElement>( element: T, children: U[]): Container<T, U[]>; 通过枚举表达了泛型默认值,以及 U 与 T 之间可能存在的关系,这些都可以用泛型默认参数解决: declare function create<T extends HTMLElement = HTMLDivElement, U = T[]>( element?: T, children?: U): Container<T, U>; 尤其在 React 使用过程中,如果用泛型默认值定义了 Component: .. Component<Props = {}, State = {}> .. 就可以实现以下等价的效果: class Component extends React.PureComponent<any, any> { //...}// 等价于class Component extends React.PureComponent { //...} 动态 ImportTS 从 2.4 版本开始支持了动态 Import,同时 Webpack4.0 也支持了这个语法(在 精读《webpack4.0%20 升级指南》 有详细介绍),这个语法就正式可以用于生产环境了: const zipUtil = await import("./utils/create-zip-file"); 准确的说,动态 Import 实现于 webpack 2.1.0-beta.28,最终在 TS 2.4 版本获得了语法支持。 在 TS 2.9 版本开始,支持了 import() 类型定义: const zipUtil: typeof import('./utils/create-zip-file') = await import('./utils/create-zip-file') 也就是 typeof 可以作用于 import() 语法,而不真正引入 js 内容。不过要注意的是,这个 import('./utils/create-zip-file') 路径需要可被推导,比如要存在这个 npm 模块、相对路径、或者在 tsconfig.json 定义了 paths。 好在 import 语法本身限制了路径必须是字面量,使得自动推导的成功率非常高,只要是正确的代码几乎一定可以推导出来。好吧,所以这也从另一个角度推荐大家放弃 require。 Enum 类型支持字符串从 Typescript 2.4 开始,支持了枚举类型使用字符串做为 value: enum Colors { Red = "RED", Green = "GREEN", Blue = "BLUE"} 笔者在这提醒一句,这个功能在纯前端代码内可能没有用。因为在 TS 中所有 enum 的地方都建议使用 enum 接收,下面给出例子: // 正确{ type: monaco.languages.types.Folder;}// 错误{ type: 75;} 不仅是可读性,enum 对应的数字可能会改变,直接写 75 的做法存在风险。 但如果前后端存在交互,前端是不可能发送 enum 对象的,必须要转化成数字,这时使用字符串作为 value 会更安全: enum types { Folder = "FOLDER"}fetch(`/api?type=${monaco.languages.types.Folder}`); 数组类型可以明确长度最典型的是 chart 图,经常是这样的二维数组数据类型: [[1, 5.5], [2, 3.7], [3, 2.0], [4, 5.9], [5, 3.9]] 一般我们会这么描述其数据结构: const data: number[][] = [[1, 5.5], [2, 3.7], [3, 2.0], [4, 5.9], [5, 3.9]]; 在 TS 2.7 版本中,我们可以更精确的描述每一项的类型与数组总长度: interface ChartData extends Array<number> { 0: number; 1: number; length: 2;} 自动类型推导自动类型推导有两种,分别是 typeof: function foo(x: string | number) { if (typeof x === "string") { return x; // string } return x; // number} 和 instanceof: function f1(x: B | C | D) { if (x instanceof B) { x; // B } else if (x instanceof C) { x; // C } else { x; // D }} 在 TS 2.7 版本中,新增了 in 的推导: interface A { a: number;}interface B { b: string;}function foo(x: A | B) { if ("a" in x) { return x.a; } return x.b;} 这个解决了 object 类型的自动推导问题,因为 object 既无法用 keyof 也无法用 instanceof 判定类型,因此找到对象的特征吧,再也不要用 as 了: // Badfunction foo(x: A | B) { // I know it's A, but i can't describe it. (x as A).keyofA;}// Goodfunction foo(x: A | B) { // I know it's A, because it has property `keyofA` if ("keyofA" in x) { x.keyofA; }} 4 总结Typescript 2.0-2.9 文档整体读下来,可以看出还是有较强连贯性的。但我们可能并不习惯一步步学习新语法,因为新语法需要时间消化、同时要连接到以往语法的上下文才能更好理解,所以本文从功能角度,而非版本角度梳理了 TS 的新特性,比较符合学习习惯。 另一个感悟是,我们也许要用追月刊漫画的思维去学习新语言,特别是 TS 这种正在发展中,并且迭代速度很快的语言。 5 更多讨论 讨论地址是:精读《Typescript2.0 - 2.9》 · Issue ##85 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《V8 引擎特性带来的的 JS 性能变化》","path":"/wiki/WebWeekly/前沿技术/《V8 引擎特性带来的的 JS 性能变化》.html","content":"当前期刊数: 22 本期精读的文章是:V8 引擎特性带来的的 JS 性能变化 1 引言 定时刷新一下对 js 的三观,防止经验变成坑。(对 IE 等浏览器的三观需保持不变) 2 内容概要try catch 对性能的影响忽略不计try catch 非常有用,特别在 node 端更是一个常规操作。前端框架也越来越多采用了异常捕获的方式,结合 async await 让代码组织得更加优雅,详细可以看我的这篇博文 统一异常捕获。react mixins 也喜欢 try 住 render 方法,包括 16 版本自动 try 住了所有 render,try catch 可谓无处不在。 node 8 版本之后 try 内部函数性能损耗可以忽略不计。 但是当前版本仍然存在安全隐患,将 这里的代码 拷贝到 chrome 控制台,当前页面会进入无限死循环。 此例子对 try catch 块做了大量循环,官方说法是在某些代码组合情况下陷入无限优化循环。 解决 delete 性能问题js 正在变得越来越简单,该 delete 的地方也不会犹豫是否写成 undefined,以提升性能为代价降低代码可读性了。 arguments 转数组性能已不是问题在 node8.3 版本及以上,该使用拓展运算符获取参数,不但没有性能问题,可读性也大大提高,结合 ts 时也能得到类型支持。 bind 对性能影响可以忽略但是在 react 中副作用仍需警惕。由于 ui 组件复用次数在大部分场景及其有限,强烈推荐使用箭头函数书写成员函数(在我的另一篇精读 This 带来的困惑 有详细介绍),而且在 node8 中,箭头函数的性能是最好的。 函数调用对性能影响越来越小对函数调用优化的越来越好,不需要过于担心注释与空白、函数间调用对性能的影响. 32 64 位数字计算性能node8 对超长数字计算性能还是较低,大概是 32 位数字性能的 2/3,所以尽量用字符串处理大数。 遍历 object基本用法有 for in Object.keys Object.values. 在 node8 中,for in 将变得更慢,但任然比其他两种方法快,所以,尽早取消不必要的优化。 创建对象创建对象速度在 node8 得到极大提升,似乎是面向对象编程的福音。 多态函数的性能问题当函数或者对象存在多种类型参数时,在 node8 中性能没什么优化,但单态函数性能大幅提升。所以尽量让对象内部属性单态是比较有用的,比如尽量不要对字符串数组 push 一个数字。 3 精读try catch 的问题在 v8 优化之前,前端 try catch 存在挺大的性能问题,导致许多老旧的项目很少有使用异常的场景,而经验丰富的程序员也会极力避免使用 try catch,在必须使用 try catch 的地方,将代码逻辑封装在函数中,try 住函数而不是代码块,以降低性能损失。 现在是推翻这些经验的时候了,合理的异常处理还能够优化用户体验。 前端代码最容易出错的逻辑在于对后端数据的处理,一旦后端数据出错,前端整条数据处理链路难免报错或者抛出异常。这种场景最适合将异常 try 住,显示提示文案,同时也避免代码内部对数据格式过多的兼容处理。 语句数量对性能的影响由于语句数量对性能影响已经忽略不计了,以前推崇的写法可以说再见了: // 提倡var i = 1;var j = "hello";var arr = [1,2,3];var now = new Date(); // 避免var i = 1, j = "hello", arr = [1,2,3], now = new Date(); 4 总结这波 v8 优化带来了一些 js 性能上的改变,但在 js 性能优化中只解决了很小一块问题,而 js 在前端性能优化又只是冰山一角,dom 与 样式 的优化对性能影响也非常重大,我们仍然应该重视代码质量,提高代码性能。 讨论地址是:精读《V8 引擎特性带来的的 JS 性能变化》 · Issue ##33 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Web Components 的困境》","path":"/wiki/WebWeekly/前沿技术/《Web Components 的困境》.html","content":"当前期刊数: 10 本期精读的文章是:The broken promise of Web Components 以及对这篇文章的回应: Regarding the broken promise of Web Components 1 引言我为什么要选这篇文章呢? 就在前几天的 Google I/O 2017 上, Polymer 正式发布了 Polymer 2.0 版本. 来看一下 Polymer 2.0 的一些变化: 使用 Shadow DOM v1 代替 Polymer.dom. Shady DOM 从 Polymer 中分离出来。 使用 标准的 ES6 类和 Custom Elements v1 来自定义元素. 还有数据系统的改进和生命周期的变更. 可以看到, Polymer 的这次升级主要是将 Shadow Dom 和 Custom Elements 升级到 v1 版本, 以获得更多浏览器的原生支持. 下一 代 Web Components - v1 规范,Chrome 已经支持了,Web Components 规范中的 2 个主要部分 - Shadow Dom 和 Custom Elements. Safari 在 10 版本中, 支持了 Shadow DOM v1 规范并且完成了在 Webkit 内核中对 Custom Elements v1 规范的实现;Firefox 对 Shadow DOM 和 Custom Elements v1 规范 支持正在开发中;Edge 也将对 Shadow DOM 和 Custom Elements 支持规划到他们的开发 roadmap 中。 这段时间, 大家都在讨论 react, vue, angular, 这些框架. 或者 该使用 redux 还 是 mobx 做数据管理. 在这个契机下, 我想我们可以不单单去思考这些框架, 也可以更多地去思考和了解 Web Components 标准. 对于 Web Components 标准有一些思考. 所以我选了一篇关于 Web Components 的文章, 想让大家对于 Web Components 的发展, 和 Web Componets 与现在的主流框架如何协作有更多的思考和讨论. 2 内容概要The broken promise of Web Components原文作者 dmitriid 主要是在喷 Web Components 从 2011 年到 2017 年这 6 年间毫无进展, 一共产出了 6 份标准, 其中两份已经被弃用. 几乎只有一个主流浏览器(chrome) 支持. Web Components 这些规范强依赖 JS 的实现 Custom Elements 是 JS 脚本的一部分 HTML Templates 的出现就是为了被 JS 脚本使用 Shadow Dom 也需要配合 JS 脚本使用 只有 HTML imports 可以脱离 JS 脚本使用 Web Components 操作 DOM 属性都是字符串 元素的内容模型(Content Model)比较奇怪 为了突破限制使用不同的方法来传递数据 CSS 作用域, 可以见上次精读《请停止 css-in-js 的行为》 来看一下 Polymer 的 核心成员 Rob Dodson 对于本文的回应: Regarding the broken promise of Web Components Web Components 特性需要被浏览器支持,必须有平缓的过渡,良好的兼容,以及成熟的方案,因此推进速度会比较慢一些。 React 很棒, 但是也不要忽略其他基于 Web Components 的优秀库比如 Amp 对于 DOM 更新的抽象比如 React/JSX 很赞, 但是也带来了一些损耗. 在旧的移动设备上, 加载一个大的 js 包性能依旧不理想, 最佳的做法是拆分你的 JS 包, 按需加载. 使用 JSX 和 虚拟 DOM 是很酷, 也可以直接把 JSX 用在 Web Components 内, 像SkateJS库, 已经在做这个事情了. 没有标准的数据绑定, Polymer 的数据绑定, 现在是基于MDV, 很多开发者更倾向于基于 Observables 或者 ES6 Proxies 的数据绑定方案. 处理组件的字符串属性是很烦人, 但是由于每一个组件都是一个类的实例, 可以利用 ES6 的 getters/setters 来改变属性. Rob Dodson 对于 Web Components 依然充满信心, 但是也承认推进标准总会有各种阻碍, 不可能像推荐框架一样快速把事情解决. 3 精读本次提出独到观点的同学有:@camsong @黄子毅 @杨森 @rccoder @alcat2008精读由此归纳。 标准与框架Web Components 作为一个标准,骨子里的进度就会落后于当前可行的技术体系。正如文中所说,浏览器厂商 ship 一个新功能是很严肃的,很可能会影响到一票的线上业务,甚至会影响到一个产业(遥想当年 Chrome Extension 禁用 NPAPI时的一片哀鸿遍野,许多返利插件都使用了这种技术)。那么 Web Components 的缓慢推进也在情理之中了.即使真的有一天这个标准建立起来,Web Components 作为浏览器底层特性不应该拿出来和 React 这类应用层框架相比较. 未来 Web Components 会做为浏览器非常重要的特性存在。API 偏低层操作,会易用性不够. 在很长时间内开发者依旧会使用 React/Vue/Angular/Polymer 这样的框架,Web Components 可能会做为这些框架的底层做一些 浏览器层面上的支持. 不需要 vendor 的自定义组件间调用在 Webpack 大行其道的时代,想在运行时做到组件即引即用变得很困难,因为这些组件大多是通过 React/Vue/Angular 开发的。不得不考虑引入一大堆 Vendor 包,这些 Vendor 里可能还必须包含 React 这类两个版本不能同时使用的库。目前我们团队在做组件化方案时就遇到这个问题,只能想办法避免两个版本的出现。你可以说这是 React 或 Webpack 引入的问题,但并没有看到 Web Compnents 标准化的解决方案。我想未来 Web Components 可能会作为浏览器的底层, 出现基于底层的标准方案来做组件间的相互应用的方法. 为什么对 Web components 讨论不断俗话说,成也萧何,败也萧何。正如原文提及的,现在网页规模越来越大,需求也越来越灵活,html 与 css 的能力已经严重不足,我们才孤注一掷的上了 js 的贼船:JSX 和 Css module,因为 Web components 依托在 html 模版语言上,当然没办法与 js 的灵活性媲美。 但使用前端框架的问题也日益暴露,随着前端框架种类的增多,同一个框架不同版本之间无法共存,导致组件无法跨框架复用,甚至只能固定在框架的某个版本,这与前端未来的模块化发展是相违背的,我们越是与之抗衡,就越希望 Web components 能站出来解决这个问题,因为浏览器原生支持模块化,相当于将 react angular vue 的能力内置在浏览器中,而且一定会向前兼容(这也是 Web components 推进缓慢的原因)。 4 总结我觉得 Web Components 作为浏览器底层特性不应该拿出来和 React, vue 这类应用层框架相比较. Web Components 的方向以及提供的价值都不会跟 应用框架一致. 而 Web Components 作为未来的 Web 组件标准 , 它在任何生态中都可以运行良好. 我倒是更加期待应用层去基于 Web Components 去做更多的实现, 让组件超越框架存在, 可以在不同技术栈中使用. 讨论地址是:精读《Web Components 的困境》 · Issue ##15 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Web fonts_ when you need them, when you don’t》","path":"/wiki/WebWeekly/前沿技术/《Web fonts_ when you need them, when you don’t》.html","content":"当前期刊数: 21 精读《Web fonts: when you need them, when you don’t》本期精读让我们来聊一聊 Web Fonts,文章地址:https://hackernoon.com/web-fonts-when-you-need-them-when-you-dont-a3b4b39fe0ae 文章简介文章分析了 Web Fonts 的优劣具体使用场景。 主要观点 作者用一张流程图非常言简意赅地概括了文章的上半部分。 当然上半部分作者也讲了很多案例,其中一个很明显的案例就是维基百科利用字体来提升阅读体验,通过文章内的对比,能直观感受到这一点 文章后半部分着力介绍了怎么解决 Web Font 的带来的弊端:认识 FOUT 带来的问题,如何使用现有的前端解决方案来尽可能避免这个问题,以及样式上优雅降级的几个方案。 把文章带入自己的开发环境作为一个中文开发者,在我们的开发技术栈中,Web Fonts 绝对是属于使用频率比较低的那一类的。本次精读选择这篇文章,也正是一探这一个不常见的领域。 对于英文字母,26 个字母可以解决大部分的问题,算上大小写和基本符号,一张 ASCII 码标就可以包含住。让我再扩展一下,到大部分的西文书写系统,几百个字符就能解决多语言显示的问题了。但是对于汉语而言,Web Fonts 真的是,想说爱你不容易,因为常用的汉字就有几千个(你想象中国还有《千字文》这种儿童读物……)。字体这东西跟字符数量直接挂钩,是很难通过压缩来获得性能提升的。 通常的想法就是用多少,取多少,但是这个方法也就只能适用于标题美化等场景。对于一个系统性的前端工程,我们不可能去实现一个动态字符的字体文件(就是统计这个页面上会产生多少个字符,为这个字符集去生成一个字符子集) 虽然汉字书写系统和西文书写系统天生存在差异,但是把作者在文章中提出来的几个问题再站在中文的角度上再来看一下,也可以得到一个比较客观的答案。以下是我作为一个普通开发者的自问自答: 字体对你的品牌很关键吗?(需要特性字体的中文 LOGO 基本都用 PNG 和 SVG 解决了,Web Font 不实用。) 字体让你的文字阅读起来更容易了吗?(我平时开发产品没有成片聚集的文字,用无衬线字体就能满足需求。Web Font 很好,我选择“微软雅黑”。)(注:泛指那些好用的支持全字集的系统自带字体;成片的文字适用衬线字体,个人认为中文的衬线字体,不同的字体带来的阅读体验还是有明显差别的。) 你需要在不同设备上显示一样的字体吗?(好像还没这么苛求吧……微软雅黑好看,安卓上的 Roboto 也很不错啊,Roboto 这种字体还针对移动设备有优化,何乐而不为。) 用了 Web Font 你会更开心吗?(在 icon 中使用 iconfont 让我们告别了 PNG Sprite 图,嗯这很开心。至于文字上用 Web Font,有好用的系统字体你不用,你这是何苦呢)(作者也说了,可能折腾半天还没系统字体看着舒服,那就是一行 font-family 的事情) 不同产品有着不同的场景,多像文章里问问自己会有最合适的答案。 关于 FOUT 和 FOIT文章中大篇幅地在安利你使用 Web Font,但是也很直白地指出了 Web Font 最大的问题,就是这个 FOUT——Flash Of Unstyled Text。连作者毫不避讳地说了句:“噢我的老天,这太丑了!” 具体表现是采用了 Web Font 的文案会存在闪动,这个的根本原因在于相比于系统字体,Web Font 最大的弊端在于它是异步加载的,你没有办法避免下载它所用的时间。文章中举了一个例子,在一个图文为主的页面中,一个 542KB 的字体文件,在第 9 秒才加载完成。在那之前只能以系统字体来展示,而在第 9 秒加载完成的时候,还会出现替换字体的情况,文字会突然跳动。 比 FOUT 更为极端的情况的是 FOIT——Flash Of Invisible Text。很多浏览器的行为,并不是默认展示系统字体,而是直接隐藏。那么即使在极快的网速下也很难避免存在一个几百毫秒的时间滞后。 不过好在,有一个 font-display 的属性,可以在声明@font-face 的时候配合使用。对于未加载 Web Fonts 的时候,auto 属性可以选择隐藏也就是会产生 FOIT,swap 会产生替换也就是会产生 FOUT,还有 fallback 和 optional 可以控制先 FOIT 后 FOUT 来达到折中方案。 还有一个思路,那就是预加载,对于字体,浏览器还是能够有效缓存的,如果能够做好预加载,还是不会太影响用户体验的。文章中就提到了一个方案,调用 link 的 rel=preload 来做预加载。因为通常加载字体是在 CSS 中的@font-face 被读到的时候才去加载的,那么就会出现先加载 CSS,后加载字体的情况。如果利用 link 预加载,那么在 CSS 中的@font-face 被读到前就已经开始加载了,那么字体加载和 CSS 加载就可以同时加载,提升速度。 当然 JS 是万能的,也有一些库在支持这方面功能,例如 bramstein/fontfaceobserver 这样的。 愚以为,FO*T 这种情况既然无法避免还是要具体情况具体分析的。如果你的用户网速够快,那么隐藏文字会更好,用户无感知;如果网速不确定,而且是文章为主的内容,那么内容至上就应该先用替代字体显示;如果你正在将 Web Font 应用在图标等东西上,那么我们自然不愿意看到满屏的方框方框,这种时候就选择隐藏吧。 文章也提了一点,如果你的字体授权很贵,但用户端深受 FO*T 折磨,那你还费这钱干嘛。 结论如果能解决 FO*T 的副作用,Web Font 怎么舒服怎么用。但是中文字体大,常用西文字体诸如 Google 字体库又时常被墙,对于中国开发者,Web Font 想说爱你不容易。(还是乖乖用微软雅黑吧,逃…… 彩蛋文章里有一段很精彩的话,摘抄出来翻译一下: 如果这个世界上有这样一个 Sketch 或者 Photoshop 的插件,可以在你每次打开一个文件的时候,延迟十秒才显示出字体;那么世界上就没有那么多多余的字体了。 (译者注:删光设计师的电脑里的奇怪字体也能达到相同目的,哈哈哈~)"},{"title":"《Vue3","path":"/wiki/WebWeekly/前沿技术/《Vue3.html","content":"当前期刊数: 109 1. 引言Vue 3.0 的发布引起了轩然大波,让我们解读下它的 function api RFC 详细了解一下 Vue 团队是怎么想的吧! 首先官方回答了几个最受关注的问题: Vue 3.0 是否有 break change,就像 Python 3 / Angular 2 一样? 不,100% 兼容 Vue 2.0,且暂未打算废弃任何 API(未来也不)。之前有草案试图这么做,但由于用户反馈太猛,被撤回了。 Vue 3.0 的设计盖棺定论了吗? 没有呀,这次精读的稿子就是 RFC(Request For Comments),翻译成中文就是 “意见征求稿”,还在征求大家意见中哦。 这 RFC 咋这么复杂? RFC 是写给贡献者/维护者的,要考虑许多边界情况与细节,所以当然会复杂很多喽!当然 Vue 本身使用起来还是很简单的。 Vue 本身 Mutable + Template 就注定了是个用起来简单(约定 + 自然),实现起来复杂(解析 + 双绑)的框架。 这次改动很像在模仿 React,为啥不直接用 React? 首先 Template 机制还是没变,其次模仿的是 Hooks 而不是 React 全部,如果你不喜欢这个改动,那你更不会喜欢用 React。 PS: 问这个问题的人,一定没有同时理解 React 与 Vue,其实这两个框架到现在差别蛮大的,后面精读会详细说明。 下面正式进入 Vue 3.0 Function API 的介绍。 2. 概述Vue 函数式基本 Demo: <template> <div> <span>count is {{ count }}</span> <span>plusOne is {{ plusOne }}</span> <button @click="increment">count++</button> </div></template><script>import { value, computed, watch, onMounted } from 'vue'export default { setup() { // reactive state const count = value(0) // computed state const plusOne = computed(() => count.value + 1) // method const increment = () => { count.value++ } // watch watch(() => count.value * 2, val => { console.log(`count * 2 is ${val}`) }) // lifecycle onMounted(() => { console.log(`mounted`) }) // expose bindings on render context return { count, plusOne, increment } }}</script> 函数式风格的入口是 setup 函数,采用了函数式风格后可以享受如下好处:类型自动推导、减少打包体积。 setup 函数返回值就是注入到页面模版的变量。我们也可以返回一个函数,通过使用 value 这个 API 产生属性并修改: import { value } from 'vue'const MyComponent = { setup(props) { const msg = value('hello') const appendName = () => { msg.value = `hello ${props.name}` } return { msg, appendName } }, template: `<div @click="appendName">{{ msg }}</div>`} 要注意的是,value() 返回的是一个对象,通过 .value 才能访问到其真实值。 为何 value() 返回的是 Wrappers 而非具体值呢?原因是 Vue 采用双向绑定,只有对象形式访问值才能保证访问到的是最终值,这一点类似 React 的 useRef() API 的 .current 规则。 那既然所有 value() 返回的值都是 Wrapper,那直接给模版使用时要不要调用 .value 呢?答案是否定的,直接使用即可,模版会自动 Unwrapping: const MyComponent = { setup() { return { count: value(0) } }, template: `<button @click="count++">{{ count }}</button>`} 接下来是 Hooks,下面是一个使用 Hooks 实现获得鼠标实时位置的例子: function useMouse() { const x = value(0) const y = value(0) const update = e => { x.value = e.pageX y.value = e.pageY } onMounted(() => { window.addEventListener('mousemove', update) }) onUnmounted(() => { window.removeEventListener('mousemove', update) }) return { x, y }}// in consuming componentconst Component = { setup() { const { x, y } = useMouse() const { z } = useOtherLogic() return { x, y, z } }, template: `<div>{{ x }} {{ y }} {{ z }}</div>`} 可以看到,useMouse 将所有与 “处理鼠标位置” 相关的逻辑都封装了进去,乍一看与 React Hooks 很像,但是有两个区别: useMouse 函数内改变 x、y 后,不会重新触发 setup 执行。 x y 拿到的都是 Wrapper 而不是原始值,且这个值会动态变化。 另一个重要 API 就是 **watch**,它的作用类似 React Hooks 的 useEffect,但实现原理和调用时机其实完全不一样。 watch 的目的是监听某些变量变化后执行逻辑,比如当 id 变化后重新取数: const MyComponent = { props: { id: Number }, setup(props) { const data = value(null) watch(() => props.id, async (id) => { data.value = await fetchData(id) }) }} 之所以要 watch,因为在 Vue 中,setup 函数仅执行一次,所以不像 React Function Component,每次组件 props 变化都会重新执行,因此无论是在变量、props 变化时如果想做一些事情,都需要包裹在 watch 中。 后面还有 unwatching、生命周期函数、依赖注入,都是一些语法定义,感兴趣可以继续阅读原文,笔者就不赘述了。 3. 精读对于 Vue 3.0 的 Function API + Hooks 与 React Function Component + Hooks,笔者做一些对比。 Vue 与 React 逻辑结构React Function Component 与 Hooks,虽然在实现原理上,与 Vue3.0 存在 Immutable 与 Mutable、JSX 与 Template 的区别,但逻辑理解上有着相通之处。 const MyComponent = { setup(props) { const x = value(0) const setXRandom = () => { x.value = Math.random() } return { x, setXRandom } }, template: ` <button @onClick="setXRandom"/>{{x}}</button> `} 虽然在 Vue 中,setup 函数仅执行一次,看上去与 React 函数完全不一样(React 函数每次都执行),但其实 Vue 将渲染层(Template)与数据层(setup)分开了,而 React 合在了一起。 我们可以利用 React Hooks 将数据层与渲染层完全隔离: // 类似 vue 的 setup 函数function useMyComponentSetup(props) { const [x, setX] = useState(0) const setXRandom = useCallback(() => { setX(Math.random()) }, [setX]) return { x, setXRandom }}// 类似 vue 的 template 函数function MyComponent(props: { name: String }) { const { x, setXRandom } = useMyComponentSetup(props) return ( <button onClick={setXRandom}>{x}</button> )} 这源于 JSX 与 Template 的根本区别。JSX 使模版与 JS 可以写在一起,因此数据层与渲染层可以耦合在一起写(也可以拆分),但 Vue 采取的 Template 思路使数据层强制分离了,这也使代码分层更清晰了。 而实际上 Vue3.0 的 setup 函数也是可选的,再配合其支持的 TSX 功能,与 React 真的只有 Mutable 的区别了: // 这是个 Vue 组件const MyComponent = createComponent((props: { msg: string }) => { return () => h('div', props.msg)}) 我们很难评价 Template 与 JSX 的好坏,但为了更透彻的理解 Vue 与 React,需要抛开 JSX&Template,Mutable&Immutable 去看,其实去掉这两个框架无关的技术选型,React@16 与 Vue@3 已经非常像了。 Vue3.0 的精髓是学习了 React Hooks 概念,因此正好可以用 Hooks 在 React 中模拟 Vue 的 setup 函数。 关于这两套技术选型,已经是相对完美的组合,不建议在 JSX 中再实现类似 Mutable + JSX 的花样来(因为喜欢 Mutable 可以用 Vue 呀): Vue:Mutable + Template React:Immutable + JSX 真正影响编码习惯的就是 Mutable 与 Immutable,使用 Vue 就坚定使用 Mutable,使用 React 就坚定使用 Immutable,这样能最大程度发挥两套框架的价值。 Vue Hooks 与 React Hooks 的差异先看 React Hooks 的简单语法: const [ count, setCount ] = useState(0)const setToOne = () => setCount(1) Vue Hooks 的简单语法: const count = value(0)const setToOne = () => count.value = 1 之所以 React 返回的 count 是一个数字,是因为 Immutable 规则,而 Vue 返回的 count 是个对象,拥有 count.value 属性,也是因为 Vue Mutable 规则导致,这使得 Vue 定义的所有变量都类似 React 中 useRef 定义变量,因此不存 React capture value 的特性。 关于 capture value 更多信息,可以阅读 精读《Function VS Class 组件》 Capute Value 介绍 另外,对于 Hooks 的值变更机制也不同,我们看 Vue 的代码: const Component = { setup() { const { x, y } = useMouse() const { z } = useOtherLogic() return { x, y, z } }, template: `<div>{{ x }} {{ y }} {{ z }}</div>`} 由于 setup 函数仅执行一次,怎么做到当 useMouse 导致 x、y 值变化时,可以在 setup 中拿到最新的值? 在 React 中,useMouse 如果修改了 x 的值,那么使用 useMouse 的函数就会被重新执行,以此拿到最新的 x,而在 Vue 中,将 Hooks 与 Mutable 深度结合,通过包装 x.value,使得当 x 变更时,引用保持不变,仅值发生了变化。所以 Vue 利用 Proxy 监听机制,可以做到 setup 函数不重新执行,但 Template 重新渲染的效果。 这就是 Mutable 的好处,Vue Hooks 中,不需要 useMemo useCallback useRef 等机制,仅需一个 value 函数,直观的 Mutable 修改,就可以实现 React 中一套 Immutable 性能优化后的效果,这个是 Mutable 的魅力所在。 Vue Hooks 的优势笔者对 RFC 中对 Vue、React Hooks 的对比做一个延展解释: 首先最大的不同:setup 仅执行一遍,而 React Function Component 每次渲染都会执行。 Vue 的代码使用更符合 JS 直觉。 这句话直截了当戳中了 JS 软肋,JS 并非是针对 Immutable 设计的语言,所以 Mutable 写法非常自然,而 Immutable 的写法就比较别扭。 当 Hooks 要更新值时,Vue 只要用等于号赋值即可,而 React Hooks 需要调用赋值函数,当对象类型复杂时,还需借助第三方库才能保证进行了正确的 Immutable 更新。 对 Hooks 使用顺序无要求,而且可以放在条件语句里。 对 React Hooks 而言,调用必须放在最前面,而且不能被包含在条件语句里,这是因为 React Hooks 采用下标方式寻找状态,一旦位置不对或者 Hooks 放在了条件中,就无法正确找到对应位置的值。 而 Vue Function API 中的 Hooks 可以放在任意位置、任意命名、被条件语句任意包裹的,因为其并不会触发 setup 的更新,只在需要的时候更新自己的引用值即可,而 Template 的重渲染则完全继承 Vue 2.0 的依赖收集机制,它不管值来自哪里,只要用到的值变了,就可以重新渲染了。 不会再每次渲染重复调用,减少 GC 压力。 这确实是 React Hooks 的一个问题,所有 Hooks 都在渲染闭包中执行,每次重渲染都有一定性能压力,而且频繁的渲染会带来许多闭包,虽然可以依赖 GC 机制回收,但会给 GC 带来不小的压力。 而 Vue Hooks 只有一个引用,所以存储的内容就非常精简,也就是占用内存小,而且当值变化时,也不会重新触发 setup 的执行,所以确实不会造成 GC 压力。 必须要总包裹 useCallback 函数确保不让子元素频繁重渲染。 React Hooks 有一个问题,就是完全依赖 Immutable 属性。而在 Function Component 内部创建函数时,每次都会创建一个全新的对象,这个对象如果传给子组件,必然导致子组件无法做性能优化。 因此 React 采取了 useCallback 作为优化方案: const fn = useCallback(() => /* .. */, []) 只有当第二个依赖参数变化时才返回新引用。但第二个依赖参数需要 lint 工具确保依赖总是正确的(关于为何要对依赖诚实,感兴趣可以移步 精读《Function Component 入门》 - 永远对依赖诚实)。 回到 Vue 3.0,由于 setup 仅执行一次,因此函数本身只会创建一次,不存在多实例问题,不需要 useCallback 的概念,更不需要使用 lint 插件 保证依赖书写正确,这对开发者是实实在在的友好。 不需要使用 useEffect useMemo 等进行性能优化,所有性能优化都是自动的。 这也是实在话,毕竟 Mutable + 依赖自动收集就可以做到最小粒度的精确更新,根本不会触发不必要的 Rerender,因此 useMemo 这个概念也不需要了。 而 useEffect 也需要传递第二个参数 “依赖项”,在 Vue 中根本不需要传递 “依赖项”,所以也不会存在用户不小心传错的问题,更不需要像 React 写一个 lint 插件保证依赖的正确性。(这也是笔者想对 React Hooks 吐槽的点,React 团队如何保障每个人都安装了 lint?就算装了 lint,如果 IDE 有 BUG,导致没有生效,随时可能写出依赖不正确的 “危险代码”,造成比如死循环等严重后果) 4. 总结通过对比 Vue Hooks 与 React Hooks 可以发现,Vue 3.0 将 Mutable 特性完美与 Hooks 结合,规避了一些 React Hooks 的硬伤。所以我们可以说 Vue 借鉴了 React Hooks 的思想,但创造出来的确实一个更精美的艺术品。 但 React Hooks 遵循的 Immutable 也有好的一面,就是每次渲染中状态被稳定的固化下来了,不用担心状态突然变更带来的影响(其实反而要注意状态用不变更带来的影响),对于数据记录、程序运行的稳定性都有较高的可预期性。 最后,对于喜欢 Mutable 的开发者,Vue 3.0 是你的最佳选择,基于 React + Mutable 搞的一些小轮子做到顶级可能还不如 Vue 3.0。对于 React 开发者来说,坚持你们的 Immutable 信仰吧,Vue 3.0 已经将 Mutable 发挥到极致,只有将 React Immutable 特性发挥到极致才能发挥 React 的最大价值。 讨论地址是:精读《Vue3.0 Function API》 · Issue ##173 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Webpack5 新特性 - 模块联邦》","path":"/wiki/WebWeekly/前沿技术/《Webpack5 新特性 - 模块联邦》.html","content":"当前期刊数: 144 1 引言先说结论:Webpack5 模块联邦让 Webpack 达到了线上 Runtime 的效果,让代码直接在项目间利用 CDN 直接共享,不再需要本地安装 Npm 包、构建再发布了! 我们知道 Webpack 可以通过 DLL 或者 Externals 做代码共享时 Common Chunk,但不同应用和项目间这个任务就变得困难了,我们几乎无法在项目之间做到按需热插拔。 模块联邦是 Webpack5 新内置的一个重要功能,可以让跨应用间真正做到模块共享,所以这周让我们通过 webpack-5-module-federation-a-game-changer-in-javascript-architecture 这篇文章了解什么是 “模块联邦” 功能。 2 概述 & 精读NPM 方式共享模块想象一下正常的共享模块方式,对,就是 NPM。 如下图所示,正常的代码共享需要将依赖作为 Lib 安装到项目,进行 Webpack 打包构建再上线,如下图: 对于项目 Home 与 Search,需要共享一个模块时,最常见的办法就是将其抽成通用依赖并分别安装在各自项目中。 虽然 Monorepo 可以一定程度解决重复安装和修改困难的问题,但依然需要走本地编译。 UMD 方式共享模块真正 Runtime 的方式可能是 UMD 方式共享代码模块,即将模块用 Webpack UMD 模式打包,并输出到其他项目中。这是非常普遍的模块共享方式: 对于项目 Home 与 Search,直接利用 UMD 包复用一个模块。但这种技术方案问题也很明显,就是包体积无法达到本地编译时的优化效果,且库之间容易冲突。 微前端方式共享模块微前端:micro-frontends (MFE) 也是最近比较火的模块共享管理方式,微前端就是要解决多项目并存问题,多项目并存的最大问题就是模块共享,不能有冲突。 由于微前端还要考虑样式冲突、生命周期管理,所以本文只聚焦在资源加载方式上。微前端一般有两种打包方式: 子应用独立打包,模块更解耦,但无法抽取公共依赖等。 整体应用一起打包,很好解决上面的问题,但打包速度实在是太慢了,不具备水平扩展能力。 模块联邦方式终于提到本文的主角了,作为 Webpack5 内置核心特性之一的 Federated Module: 从图中可以看到,这个方案是直接将一个应用的包应用于另一个应用,同时具备整体应用一起打包的公共依赖抽取能力。 让应用具备模块化输出能力,其实开辟了一种新的应用形态,即 “中心应用”,这个中心应用用于在线动态分发 Runtime 子模块,并不直接提供给用户使用: 对微前端而言,这张图就是一个完美的主应用,因为所有子应用都可以利用 Runtime 方式复用主应用的 Npm 包和模块,更好的集成到主应用中。 模块联邦的使用方式如下: 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,插件有几个重要参数: name 当前应用名称,需要全局唯一。 remotes 可以将其他项目的 name 映射到当前项目中。 exposes 表示导出的模块,只有在此申明的模块才可以作为远程依赖被使用。 shared 是非常重要的参数,指定了这个参数,可以让远程加载的模块对应依赖改为使用本地项目的 React 或 ReactDOM。 比如设置了 remotes: { app_two: "app_two_remote" },在代码中就可以直接利用以下方式直接从对方应用调用模块: import { Search } from "app_two/Search"; 这个 app_two/Search 来自于 app_two 的配置: // 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《When You “Git” in Trouble- a Version Control Story》","path":"/wiki/WebWeekly/前沿技术/《When You “Git” in Trouble- a Version Control Story》.html","content":"当前期刊数: 36 本期精读的文章是:When You “Git” in Trouble - a Version Control Story 1 引言git 作为目前最流行的版本控制系统,它拥有众多的用户并管理着数量庞大的实际软件项目。 本文主要通过一个实际的例子来描述,当项目(代码)仓库出现问题时如何使用 git 进行有效的维护,并分享一些 git 使用经验以及分析 git 的内部实现机制。 2 内容概要我们在管理项目代码仓库时,经常会碰到一些棘手的问题,比如:在使用 git 的过程中,有时会不小心丢失 commit 信息。如果实际场景中发生了类似的问题,该如何使用 git 找回丢失的 commit 呢? 首先,在 git 中想要找回丢失的 commit,就需要找出那些 commit 的 SHA,然后添加一个指向它的分支。 由于 git 会记录下每次修改 HEAD 的操作,当执行提交或修改分支的命令时 reflog 就会更新(执行 git update-ref 命令也可以更新 reflog),因此可以执行 git reflog 命令来查看当前的状态。 $ git reflog1a410ef HEAD@{0}: 1a410efbd13591db07496601ebc7a059dd55cfe9: updating HEADab1afef HEAD@{1}: ab1afef80fac8e34258ff41fc1b867c702daa24b: updating HEAD 执行 git log -g 命令可以查看更加详细 reflog 的日志。 $ git log -gcommit 1a410efbd13591db07496601ebc7a059dd55cfe9Reflog: HEAD@{0}Reflog message: updating HEAD third commitcommit ab1afef80fac8e34258ff41fc1b867c702daa24bReflog: HEAD@{1}Reflog message: updating HEAD modified repo a bit 确定丢失的 commit 后,就可以在这个 commit 上创建一个新分支将其恢复过来。比如,在 commit (ab1afef) 上创建 new-branch 分支,即可找回丢失的 commit 数据。 $ git branch new-branch ab1afef$ git log --pretty=oneline new-branchab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit484a59275031909e19aadb7c92262719cfcdf19a added repo.rb1a410efbd13591db07496601ebc7a059dd55cfe9 third commitcac0cab538b970a37ea1e769cbbde608743bc96d second commitfdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit 如果引起 commit 丢失的原因并没有记录在 reflog 中,即没有在 .git/logs/ 中(因为 reflog 数据是保存在 .git/logs/ 目录下的),这样就会导致丢失的 commit 不会被任何东西引用。这种情况应该如何恢复 commit 数据呢? 这里可以执行 git fsck –full 命令,该命令会检查仓库的数据完整性,会显示所有未被其他对象引用的所有对象。 $ git fsck --fulldangling blob d670460b4b4aece5915caf5c68d12f560a9fe3e4dangling commit ab1afef80fac8e34258ff41fc1b867c702daa24bdangling tree aea790b9a58f6cf6f2804eeac9f0abbe9631e4c9dangling blob 7108f7ecb345ee9d0084193f147cdad4d2998293 这样就可以从 dangling commit 中找到丢失的 commit 信息,确定丢失的 commit 后,就可以在这个 commit 上创建一个新分支将其恢复过来。 至此,文章分享了一个在实际工作中维护项目仓库时经常会遇到的难题的解决思路和方法。 3 精读上述文章中执行 git fsck 命令时出现了 blob、tree、commit 等关键词,虽然我们经常会看到此类的关键词,但可能并不清楚其真正含义,因此,下面内容将从 blob、tree、commit 这三个内部对象去深入分析 git 内部数据结构管理的机制。 git 的版本控制实际就是对文件进行管理和控制,其管理方法就是为每个文件生成(key, object)的结构,并利用 sha-1 加密算法,对每一个文件生成唯一的字符序列作为 hash_key,且文件改变就会生成新的 (key, object)。 执行 git init 初始化一个本地仓库,查看隐藏目录 .git 中的目录结构。其中,objects 目录下只有 info 和 pack 两个空文件夹,没有任何其他文件被记录下来。 blob 对象在当前项目仓库中添加文件 file1.js,执行 git hash-object file1.js,生成一个 40 字符长度的 hash-key 序列:08219db9b0969fa29cf16fd04df4a63964da0b69。 执行 git add file_1.txt,objects 中多了一个 08 对象。 其实是 40 位 hash-key 的前两位作为目录名,后 38 位 作为文件名,这个对象里面的内容就是 file1.js 的内容,可以查看该对象的内容和类型。 执行 git cat-file -p [hash-key] 可以查看已经存在的 object 对象内容; 执行 git cat-file -t [hash-key] 可以查看已经存在的 object 对象类型; git object 有四种类型,第一种类型 blob,用来储存文件内容。 tree 对象blob 对象用于存储对应文件的内容,tree 对象可以理解为存储目录,其树节点信息包含:文件名,hash-key,文件类型、权限等,这样就可以组织整个需要控制文件的结构。 在当前项目仓库中添加文件夹 dir1,在 dir1 中添加文件 file2.js。执行 git add 将内容加入到暂存区,执行 git hash-object dir1/file2.js 查看生成的 hash-key:30d67d4672d5c05833b7192cc77a79eaafb5c7ad。 查看 objects 目录,只新增了一个 30 目录,即 30d67d4672d5c05833b7192cc77a79eaafb5c7ad 对应的 file2.js 文件。 说明 file2.js 文件生成了 hash-key,但 dir1 目录并没有生成 tree 对象,tree 对象是在 commit 的过程中生成的,其生成会根据 .git 目录下的 index 文件的内容来创建。git add 的操作就是将文件的信息保存到 index 文件中,在 commit 时,根据 index 的内容来生成 tree 对象。 执行 git ls-files –stage 命令,可以看到 index 中包含了创建 tree 对象的信息:文件类型(100644)、hash-key、目录结构以及文件名。 进行一次 commit,生成 commit 对象和 tree 对象。其中,master^{tree} 表示 master 分支所指向的 tree 对象。 该 tree 对象是当前对应的目录,目录下有一个名为 dir1 的 tree 对象和 file1.js 的 blob 对象。 查看 dir1 对应的 tree 对象的内容,该 tree 对象只包含 file2.js 的信息。 当前项目的 git 仓库内部结构图如下: commit 对象只有在执行 git commit 时,才会根据 index 记录的内容生成 tree 对象,则 commit 对象中会有两个内容: 代表项目目录的 tree 对象的 key 上一个 commit 的 key 项目中 objects 目录的内容: 目前每个目录里有一个对象,共 5 个对象,之前的总体 tree 图只包含了 4 个对象,执行 git log 查看 commit 记录。 objects 中 65 对应的文件夹里面的文件就是 commit 对象,它指向项目目录 tree 以及上一次的 commit,由于是第一个 commit,因此不存在上一个 commit。 commit 对象内容指向项目目录 tree,所以能获取到一个 commit,可以得到当前完整的文件状况,objects 结构图如下: 再新增一个目录 dir2,该目录下新增文件 file3.js,执行 git add 和 git commit 后查看新的 commit 信息。 新的 commit 指向了上一个 commit,还指向了一个新生成的 tree,该 tree 表示了新的项目目录情况,查看该 tree 的内容。 这个 tree 包含了当前的文件目录和内容,目前对象完整的图如下: 可以看到 commit 对象指向了工作目录 tree,因此只要切换 commit,就可以随意切换对应的版本内容,当前 .git/objects 目录内容如下: git 版本控制住要就是围绕这三类内部对象展开,分别为 blob(记录文件内容),tree(目录结构),commit(工作目录 tree 以及提交历史)。 4 总结本文从一个实际问题即如何使用 git 维护丢失的 commit 入手,并给出相应的解决思路和方案,以及通过 git 内部三种对象来分析其内部工作机制,希望能过解决读者们对 git 存在困惑的地方,同时也希望读者们积极参与每周精度的讨论,各抒己见,分享自身在实际工作中遇到的问题及其解决思路。 讨论地址是:精读《When You “Git” in Trouble - a Version Control Story》 · Issue ##49 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《async await 是把双刃剑》","path":"/wiki/WebWeekly/前沿技术/《async await 是把双刃剑》.html","content":"当前期刊数: 55 本周精读内容是 《async/await 是把双刃剑》。 1 引言终于,async/await 也被吐槽了。Aditya Agarwal 认为 async/await 语法让我们陷入了新的麻烦之中。 其实,笔者也早就觉得哪儿不对劲了,终于有个人把实话说了出来,async/await 可能会带来麻烦。 2 概述下面是随处可见的现代化前端代码: (async () => { const pizzaData = await getPizzaData(); // async call const drinkData = await getDrinkData(); // async call const chosenPizza = choosePizza(); // sync call const chosenDrink = chooseDrink(); // sync call await addPizzaToCart(chosenPizza); // async call await addDrinkToCart(chosenDrink); // async call orderItems(); // async call})(); await 语法本身没有问题,有时候可能是使用者用错了。当 pizzaData 与 drinkData 之间没有依赖时,顺序的 await 会最多让执行时间增加一倍的 getPizzaData 函数时间,因为 getPizzaData 与 getDrinkData 应该并行执行。 回到我们吐槽的回调地狱,虽然代码比较丑,带起码两行回调代码并不会带来阻塞。 看来语法的简化,带来了性能问题,而且直接影响到用户体验,是不是值得我们反思一下? 正确的做法应该是先同时执行函数,再 await 返回值,这样可以并行执行异步函数: (async () => { const pizzaPromise = selectPizza(); const drinkPromise = selectDrink(); await pizzaPromise; await drinkPromise; orderItems(); // async call})(); 或者使用 Promise.all 可以让代码更可读: (async () => { Promise.all([selectPizza(), selectDrink()]).then(orderItems); // async call})(); 看来不要随意的 await,它很可能让你代码性能降低。 3 精读仔细思考为什么 async/await 会被滥用,笔者认为是它的功能比较反直觉导致的。 首先 async/await 真的是语法糖,功能也仅是让代码写的舒服一些。先不看它的语法或者特性,仅从语法糖三个字,就能看出它一定是局限了某些能力。 举个例子,我们利用 html 标签封装了一个组件,带来了便利性的同时,其功能一定是 html 的子集。又比如,某个轮子哥觉得某个组件 api 太复杂,于是基于它封装了一个语法糖,我们多半可以认为这个便捷性是牺牲了部分功能换来的。 功能完整度与使用便利度一直是相互博弈的,很多框架思想的不同开源版本,几乎都是把功能完整度与便利度按照不同比例混合的结果。 那么回到 async/await 它的解决的问题是回调地狱带来的灾难: a(() => { b(() => { c(); });}); 为了减少嵌套结构太多对大脑造成的冲击,async/await 决定这么写: await a();await b();await c(); 虽然层级上一致了,但逻辑上还是嵌套关系,这不是另一个程度上增加了大脑负担吗?而且这个转换还是隐形的,所以许多时候,我们倾向于忽略它,所以造成了语法糖的滥用。 理解语法糖虽然要正确理解 async/await 的真实效果比较反人类,但为了清爽的代码结构,以及防止写出低性能的代码,还是挺有必要认真理解 async/await 带来的改变。 首先 async/await 只能实现一部分回调支持的功能,也就是仅能方便应对层层嵌套的场景。其他场景,就要动一些脑子了。 比如两对回调: a(() => { b();});c(() => { d();}); 如果写成下面的方式,虽然一定能保证功能一致,但变成了最低效的执行方式: await a();await b();await c();await d(); 因为翻译成回调,就变成了: a(() => { b(() => { c(() => { d(); }); });}); 然而我们发现,原始代码中,函数 c 可以与 a 同时执行,但 async/await 语法会让我们倾向于在 b 执行完后,再执行 c。 所以当我们意识到这一点,可以优化一下性能: const resA = a();const resC = c();await resA;b();await resC;d(); 但其实这个逻辑也无法达到回调的效果,虽然 a 与 c 同时执行了,但 d 原本只要等待 c 执行完,现在如果 a 执行时间比 c 长,就变成了: a(() => { d();}); 看来只有完全隔离成两个函数: (async () => { await a(); b();})();(async () => { await c(); d();})(); 或者利用 Promise.all: async function ab() { await a(); b();}async function cd() { await c(); d();}Promise.all([ab(), cd()]); 这就是我想表达的可怕之处。回调方式这么简单的过程式代码,换成 async/await 居然写完还要反思一下,再反推着去优化性能,这简直比回调地狱还要可怕。 而且大部分场景代码是非常复杂的,同步与 await 混杂在一起,想捋清楚其中的脉络,并正确优化性能往往是很困难的。但是我们为什么要自己挖坑再填坑呢?很多时候还会导致忘了填。 原文作者给出了 Promise.all 的方式简化逻辑,但笔者认为,不要一昧追求 async/await 语法,在必要情况下适当使用回调,是可以增加代码可读性的。 4 总结async/await 回调地狱提醒着我们,不要过度依赖新特性,否则可能带来的代码执行效率的下降,进而影响到用户体验。同时,笔者认为,也不要过度利用新特性修复新特性带来的问题,这样反而导致代码可读性下降。 当我翻开 redux 刚火起来那段时期的老代码,看到了许多过度抽象、为了用而用的代码,硬是把两行代码能写完的逻辑,拆到了 3 个文件,分散在 6 行不同位置,我只好用字符串搜索的方式查找线索,最后发现这个抽象代码整个项目仅用了一次。 写出这种代码的可能性只有一个,就是在精神麻木的情况下,一口气喝完了 redux 提供的全部鸡汤。 就像 async/await 地狱一样,看到这种 redux 代码,我觉得远不如所谓没跟上时代的老前端写出的 jquery 代码。 决定代码质量的是思维,而非框架或语法,async/await 虽好,但也要适度哦。 PS: 经过讨论,笔者把原文 async/await 地狱标题改成了 async/await 是把双刃剑。因为 async/await 并没有回调地狱那么可怕,称它为地狱有误导的可能性。 5 更多讨论 讨论地址是:精读《async/await 是把双刃剑》 · Issue ##82 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《What\"s new in javascript》","path":"/wiki/WebWeekly/前沿技术/《What's new in javascript》.html","content":"当前期刊数: 105 1. 引言本周精读的内容是:Google I/O 19。 2019 年 Google I/O 介绍了一些激动人心的 JS 新特性,这些特性有些已经被主流浏览器实现,并支持 polyfill,有些还在草案阶段。 我们可以看到 JS 语言正变得越来越严谨,不同规范间也逐渐完成了闭环,而且在不断吸纳其他语言的优秀特性,比如 WeakRef,让 JS 在成为使用范围最广编程语言的同时,也越成为编程语言的集大成者,让我们有信心继续跟随 JS 生态,不用被新生的小语种分散精力。 2. 精读本视频共介绍了 16 个新特性。 private class fields私有成员修饰符,用于 Class: class IncreasingCounter { ##count = 0; get value() { return this.##count; } increment() { this.##count++; }} 通过 ## 修饰的成员变量或成员函数就成为了私有变量,如果试图在 Class 外部访问,则会抛出异常: const counter = new IncreasingCounter()counter.##count// -> SyntaxErrorcounter.##count = 42// -> SyntaxError 虽然 ## 这个关键字被吐槽了很多次,但结论已经尘埃落定了,只是个语法形式而已,不用太纠结。 目前仅 Chrome、Nodejs 支持。 Regex matchAll正则匹配支持了 matchAll API,可以更方便进行正则递归了: const string = 'Magic hex number: DEADBEEF CAFE'const regex = /\\b\\p{ASCII_Hex_Digit}+\\b/gu/for (const match of string.matchAll(regex)) { console.log(match)}// Output:// ['DEADBEEF', index: 19, input: 'Magic hex number: DEADBEEF CAFE']// ['CAFE', index: 28, input: 'Magic hex number: DEADBEEF CAFE'] 相比以前在 while 语句里循环正则匹配,这个 API 真的是相当的便利。And more,还顺带提到了 Named Capture Groups,这个在之前的 精读《正则 ES2018》 中也有提到,具体可以点过去阅读,也可以配合 matchAll 一起使用。 Numeric literals大数字面量的支持,比如: 1234567890123456789 * 123;// -> 151851850485185200000 这样计算结果是丢失精度的,但只要在数字末尾加上 n,就可以正确计算大数了: 1234567890123456789n * 123n;// -> 151851850485185185047n 目前 BigInt 已经被 Chrome、Firefox、Nodejs 支持。 BigInt formatting为了方便阅读,大数还支持了国际化,可以适配成不同国家的语言表达形式: const nf = new Intl.NumberFormat("fr");nf.format(12345678901234567890n);// -> '12 345 678 901 234 567 890' 记住 Intl 这个内置变量,后面还有不少国际化用途。 同时,为了方便程序员阅读代码,大数还支持带分隔符的书写方式,可以使用 useGrouping 属性配置,默认为 true: const nf = new Intl.NumberFormat("fr", { useGrouping: true });nf.format(12345678901234567890n);// -> '12 345 678 901 234 567 890' 目前已经被 Chrome、Firefox、Nodejs 支持。 flat & flatmap等价于 lodash flatten 功能: const array = [1, [2, [3]]];array.flat();// -> [1, 2, [3]] 还支持自定义深度,如果支持 Infinity 无限层级: const array = [1, [2, [3]]];array.flat(Infinity);// -> [1, 2, 3] 这样我们就可以配合 .map 使用: [2, 3, 4].map(duplicate).flat(); 因为这个用法太常见,js 内置了 flatMap 函数代替 map,与上面的效果是等价的: [2, 3, 4].flatMap(duplicate); 目前已经被 Chrome、Firefox、Safari、Nodejs 支持。 fromEntriesfromEntries 是 Object.fromEntries 的语法,用来将对象转化为数组的描述: const object = { x: 42, y: 50, abc: 9001 };const entries = Object.entries(object);// -> [['x', 42], ['y', 50]] 这样就可以对对象的 key 与 value 进行加工处理,并通过 fromEntries API 重新转回对象: const object = { x: 42, y: 50, abc: 9001 }const result = Object.fromEntries( Object.entries(object) .filter(([ key, value]) => key.length === 1) .map(([ key, value ]) => [ key, value * 2]))// -> { x: 84, y: 100 } 不仅如此,还可以将 object 快速转化为 Map: const map = new Map(Object.entries(object)); 目前已经被 Chrome、Firefox、Safari、Nodejs 支持。 Map to Object conversionfromEntries 建立了 object 与 map 之间的桥梁,我们还可以将 Map 快速转化为 object: const objectCopy = Object.fromEntries(map); 目前已经被 Chrome、Firefox、Safari、Nodejs 支持。 globalThis 业务代码一般不需要访问全局的 window 变量,但是框架与库一般需要,比如 polyfill。 访问全局的 this 一般会做四个兼容,因为 js 在不同运行环境下,全局 this 的变量名都不一样: const getGlobalThis = () => { if (typeof self !== "undefined") return self; // web worker 环境 if (typeof window !== "undefined") return window; // web 环境 if (typeof global !== "undefined") return global; // node 环境 if (typeof this !== "undefined") return this; // 独立 js shells 脚本环境 throw new Error("Unable to locate global object");}; 因此整治一下规范也合情合理: globalThis; // 在任何环境,它就是全局的 this 目前已经被 Chrome、Firefox、Safari、Nodejs 支持。 Stable sort就是稳定排序结果的功能,比如下面的数组: const doggos = [ { name: "Abby", rating: 12 }, { name: "Bandit", rating: 13 }, { name: "Choco", rating: 14 }, { name: "Daisy", rating: 12 }, { name: "Elmo", rating: 12 }, { name: "Falco", rating: 13 }, { name: "Ghost", rating: 14 }];doggos.sort((a, b) => b.rating - a.rating); 最终排序结果可能如下: [ { name: "Choco", rating: 14 }, { name: "Ghost", rating: 14 }, { name: "Bandit", rating: 13 }, { name: "Falco", rating: 13 }, { name: "Abby", rating: 12 }, { name: "Daisy", rating: 12 }, { name: "Elmo", rating: 12 }]; 也可能如下: [ { name: "Ghost", rating: 14 }, { name: "Choco", rating: 14 }, { name: "Bandit", rating: 13 }, { name: "Falco", rating: 13 }, { name: "Abby", rating: 12 }, { name: "Daisy", rating: 12 }, { name: "Elmo", rating: 12 }]; 注意 choco 与 Ghost 的位置可能会颠倒,这是因为 JS 引擎可能只关注 sort 函数的排序,而在顺序相同时,不会保持原有的排序规则。现在通过 Stable sort 规范,可以确保这个排序结果是稳定的。 目前已经被 Chrome、Firefox、Safari、Nodejs 支持。 Intl.RelativeTimeFormatIntl.RelativeTimeFormat 可以对时间进行语义化翻译: const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });rtf.format(-1, "day");// -> 'yesterday'rtf.format(0, "day");// -> 'today'rtf.format(1, "day");// -> 'tomorrow'rtf.format(-1, "week");// -> 'last week'rtf.format(0, "week");// -> 'this week'rtf.format(1, "week");// -> 'next week' 不同语言体系下,format 会返回不同的结果,通过控制 RelativeTimeFormat 的第一个参数 en 决定,比如可以切换为 ta-in。 Intl.ListFormatListFormat 以列表的形式格式化数组: const lfEnglish = new Intl.ListFormat("en");lfEnglish.format(["Ada", "Grace"]);// -> 'Ada and Grace' 可以通过第二个参数指定连接类型: const lfEnglish = new Intl.ListFormat("en", { type: "disjunction" });lfEnglish.format(["Ada", "Grace"]);// -> 'Ada or Grace' 目前已经被 Chrome、Nodejs 支持。 Intl.DateTimeFormat -> formatRangeDateTimeFormat 可以定制日期格式化输出: const start = new Date(startTimestamp);// -> 'May 7, 2019'const end = new Date(endTimestamp);// -> 'May 9, 2019'const fmt = new Intl.DateTimeFormat("en", { year: "numeric", month: "long", day: "numeric"});const output = `${fmt.format(start)} - ${fmt.format(end)}`;// -> 'May 7, 2019 - May 9, 2019' 最后一句,也可以通过 formatRange 函数代替: const output = fmt.formatRange(start, end);// -> 'May 7 - 9, 2019' 目前已经被 Chrome 支持。 Intl.Locale定义国际化本地化的相关信息: const locale = new Intl.Locale("es-419-u-hc-h12", { calendar: "gregory"});locale.language;// -> 'es'locale.calendar;// -> 'gregory'locale.hourCycle;// -> 'h12'locale.region;// -> '419'locale.toString();// -> 'es-419-u-ca-gregory-hc-h12' 目前已经被 Chrome、Nodejs 支持。 Top-Level await支持在根节点生效 await,比如: const result = await doSomethingAsync();doSomethingElse(); 目前还没有支持。 Promise.allSettled/Promise.anyPromise.allSettled 类似 Promise.all、Promise.any 类似 Promise.race,区别是,在 Promise reject 时,allSettled 不会 reject,而是也当作 fulfilled 的信号。 举例来说: const promises = [ fetch("/api-call-1"), fetch("/api-call-2"), fetch("/api-call-3")];await Promise.allSettled(promises); 即便某个 fetch 失败了,也不会导致 reject 的发生,这样在不在乎是否有项目失败,只要拿到都结束的信号的场景很有用。 对于 Promise.any 则稍有不同: const promises = [ fetch("/api-call-1"), fetch("/api-call-2"), fetch("/api-call-3")];try { const first = await Promise.any(promises); // Any of ths promises was fulfilled. console.log(first);} catch (error) { // All of the promises were rejected.} 只要有子项 fulfilled,就会完成 Promise.any,哪怕第一个 Promise reject 了,而第二个 Promise fulfilled 了,Promise.any 也会 fulfilled,而对于 Promise.race,这种场景会直接 rejected。 如果所有子项都 rejected,那 Promise.any 也只好 rejected 啦。 目前已经被 Chrome、Firefox 支持。 WeakRefWeakRef 是从 OC 抄过来的弱引用概念。 为了解决这个问题:当对象被引用后,由于引用的存在,导致对象无法被 GC。 所以如果建立了弱引用,那么对象就不会因为存在的这段引用关系而影响 GC 了! 具体用法是: const obj = {};const weakObj = new WeakRef(obj); 使用 weakObj 与 obj 没有任何区别,唯一不同时,obj 可能随时被 GC,而一旦被 GC,弱引用拿到的对象可能就变成 undefined,所以要做好错误保护。 3. 总结JS 这几个特性提升了 JS 语言的成熟性、完整性,而且看到其访问控制能力、规范性、国际化等能力有着重加强,解决的都是 JS 最普遍遇到的痛点问题。 那么,这些 JS 特性中,你最喜欢哪一条呢?想吐槽哪一条呢?欢迎留言。 讨论地址是:精读《What’s new in javascript》 · Issue ##159 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《class static block》","path":"/wiki/WebWeekly/前沿技术/《class static block》.html","content":"当前期刊数: 210 class-static-block 提案于 2021.9.1 进入 stage4,是一个基于 Class 增强的提案。 本周我们结合 ES2022 feature: class static initialization blocks 这篇文章一起讨论一下这个特性。 概述为什么我们需要 class static block 这个语法呢?其中一个原因是对 Class 静态变量的灵活赋值需求。以下面为例,我们想在 Class 内部对静态变量做批量初始化,就不得不写一个无用的 _ 变量用来做初始化的逻辑: class Translator { static translations = { yes: 'ja', no: 'nein', maybe: 'vielleicht', }; static englishWords = []; static germanWords = []; static _ = initializeTranslator( // (A) this.translations, this.englishWords, this.germanWords);}function initializeTranslator(translations, englishWords, germanWords) { for (const [english, german] of Object.entries(translations)) { englishWords.push(english); germanWords.push(german); }} 而且我们为什么把 initializeTranslator 写在外面呢?就因为在 Class 内部不能写代码块,但这造成一个严重的问题,是外部函数无法访问 Class 内部属性,所以需要做一堆枯燥的传值。 从这个例子看出,我们为了自定义一段静态变量初始化逻辑,需要做出两个妥协: 在外部定义一个函数,并接受大量 Class 成员变量传参。 在 Class 内部定义一个无意义的变量 _ 用来启动这个函数逻辑。 这实在太没有代码追求了,我们在 Class 内部做掉这些逻辑不就简洁了吗?这就是 class static block 特性: class Translator { static translations = { yes: 'ja', no: 'nein', maybe: 'vielleicht', }; static englishWords = []; static germanWords = []; static { // (A) for (const [english, german] of Object.entries(this.translations)) { this.englishWords.push(english); this.germanWords.push(german); } }} 可以看到,static 关键字后面不跟变量,而是直接跟一个代码块,就是 class static block 语法的特征,在这个代码块内部,可以通过 this 访问 Class 所有成员变量,包括 ## 私有变量。 原文对这个特性使用介绍就结束了,最后还提到一个细节,就是执行顺序。即所有 static 变量或区块都按顺序执行,父类优先执行: class SuperClass { static superField1 = console.log('superField1'); static { assert.equal(this, SuperClass); console.log('static block 1 SuperClass'); } static superField2 = console.log('superField2'); static { console.log('static block 2 SuperClass'); }}class SubClass extends SuperClass { static subField1 = console.log('subField1'); static { assert.equal(this, SubClass); console.log('static block 1 SubClass'); } static subField2 = console.log('subField2'); static { console.log('static block 2 SubClass'); }}// Output:// 'superField1'// 'static block 1 SuperClass'// 'superField2'// 'static block 2 SuperClass'// 'subField1'// 'static block 1 SubClass'// 'subField2'// 'static block 2 SubClass' 所以 Class 内允许有多个 class static block,父类和子类也可以有,不同执行顺序结果肯定不同,这个选择权交给了使用者,因为执行顺序和书写顺序一致。 精读结合提案来看,class static block 还有一个动机,就是给了一个访问私有变量的机制: let getX;export class C { ##x constructor(x) { this.##x = { data: x }; } static { // getX has privileged access to ##x getX = (obj) => obj.##x; }}export function readXData(obj) { return getX(obj).data;} 理论上外部无论如何都无法访问 Class 私有变量,但上面例子的 readXData 就可以,而且不会运行时报错,原因就是其整个流程都是合法的,最重要的原因是,class static block 可以同时访问私有变量与全局变量,所以可以利用其做一个 “里应外合”。 不过我并不觉得这是一个好点子,反而像一个 “BUG”,因为任何对规定的突破都会为可维护性埋下隐患,除非这个特性用在稳定的工具、框架层,用来做一些便利性工作,最终提升了应用编码的体验,这种用法是可以接受的。 最后要意识到,class static block 本质上并没有增加新功能,我们完全可以用普通静态变量代替,只是写起来很不自然,所以这个特性可以理解为对缺陷的补充,或者是语法完善。 总结总的来说,class static block 在 Class 内创建了一个块状作用域,这个作用域内拥有访问 Class 内部私有变量的特权,且这个块状作用域仅在引擎调用时初始化执行一次,是一个比较方便的语法。 原文下方有一些反对声音,说这是对 JS 的复杂化,也有诸如 JS 越来越像 Java 的声音,不过我更赞同作者的观点,也就是 Js 中 Class 并不是全部,现在越来越多代码使用函数式语法,即便使用了 Class 的场景也会存在大量函数申明,所以 class static block 这个提案对开发者的感知实际上并不大。 讨论地址是:精读《class static block》· Issue ##351 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《css-in-js 杀鸡用牛刀》","path":"/wiki/WebWeekly/前沿技术/《css-in-js 杀鸡用牛刀》.html","content":"当前期刊数: 27 本期精读的文章是:css-in-js 杀鸡用牛刀 1 引言 继 精读《请停止 css-in-js 的行为》 这篇文章之后,我们又读了一篇抵制 css-in-js 的文章,虽然大部分观点都有道理,但部分存在可商榷之处,让我们分析一下这篇文章,了解 css 还做了哪些努力,以及 css-in-js 会如何发展。 2 内容概要2.1 结构/行为 vs 样式作者认为,模块化 jsx 让 html 结构与行为耦合在一起是很有价值的,然而样式却不应该与模块耦合起来,因为样式是一种全局行为。许多时候需要对网站进行全局的设计,将样式分散到模块中会导致更多的理解成本。 2.2 松耦合与紧耦合将样式与模块松耦合,系统会获得更大的自由度与拓展性。如果样式与结构松耦合,一套看似相似的的元素,可能拥有完全不同的底层结构。然而交互必须与结构紧耦合,因为交互依赖于结构。 2.3 视觉一致性问题局部样式会阻碍视觉一致性,只有全局化样式才能保证视觉一致性。 2.4 代码复用问题如果每个组件维护自己的样式,那么会存在许多样式代码复制粘贴的问题,复制粘贴的代码可维护性极低。 3 精读无论是 css-in-js 还是 css 预编译的尝试,各自都具有强大优点,本文对 css-in-js 提出的质疑我认为是欠妥当的,下面谈谈 css-in-js 如何解决作者提出的问题,以及简单介绍 OOCSS, SMACSS, BEM, ITCSS, 和 ECSS 的思路。 3.1 css-in-js 依然具备视觉一致性文中提出,网站样式要从全局考虑,模块化样式行为的优点是解决了样式冲突问题,但因此也削弱了对全局样式的把控。 开发单个组件的样式分为两种情况,分别是明确风格的组件与样式独立的组件,在样式独立组件中,由于不确定会被哪些主题的网站所引用,因此无论是全局 css 还是局部 css,都无法控制样式。在明确风格的情况下,可以先把此风格的基色确定下来,无论是抽成 sass 变量还是 js 变量,都具有可复用性。 全局 css 的开发,适合自上而下控制,组件通过定义 class 而不需要关心具体样式,通过全局 class 统一调控整体风格。而 css-in-js 是自下而上的,但需要预先抽出整体风格的样式模块,其效果与全局 css 是等价的。 全局 css 控制风格: <style>\t.container{}\t.list-item{}\t.submit-button{}</style><div className="container">\t<div className="list-item"></div>\t<div className="list-item"></div>\t<div className="submit-button"></div></div> css-in-js 风格: const CommonContainer = styled.div``const CommonListItem = styled.div``const CommonSubmitButton = css``export const Container = styled(CommonContainer)``export const ListItem = styled(CommonListItem)``export const CommonSubmitButton = styled.div`\t${CommonSubmitButton}` 而 css-in-js 运行时的样式解析,让我们更轻易的切换主题。比如我们抽出一个公共样式包,业务代码中的色值都从此样式包中引用,那么在不同的环境下,公共样式包可能通过所在宿主环境的判断,返回给业务代码不同的色值,甚至与宿主环境配合,从宿主环境拿到注入的颜色,实现一套代码在运行时轻松换肤。 3.2 css-in-js 仍具备代码复用性文中观点提出,css-in-js 这种局部样式行为,会导致公共样式、方法难以复用,导致各个模块参杂着大量重复代码。因为 sass 通过定义全局变量、mixins 方法让样式更具有复用性。 我觉得这是一种误解,在 css-in-js 模式中,通过全局合理的设计,使用 js 文件存放颜色变量、公共方法、可能会复用的 css 代码块,其复用能力远大于 sass。 3.3 OOCSSOOCSS 成为 css 的面向对象加强版,每个 class 只处理一件事: .size {width: 25%;}.bgBlue {background:blue}.g-bd2{margin:0 0 10px;} 网易 NEC 就大量使用了这种思想。 这样的好处在于避免了 class 之间的冗余,让我们更容易创建可复用的 class,也不会在命名上纠结。 然而,先不说 oocss 带来的巨大零散 class 导致的维护成本,以及修改 class 导致的巨大风险,class 的本意是语义化,如果让 class 使用一堆对象描述堆砌,我们将很难定位一个元素,也很难描述这个元素的含义。 3.4 SMACSS为 css 分类SMACSS 认为 css 有 5 个类别: Base 基础样式 Layout 布局样式 Module 模块样式 State 状态样式 Theme 主题样式 我们通过这 5 种类别来拼凑出完整的 class,我感觉就是对 OOCSS 的进一步规范和约束。 命名规则对这 5 种类别,在命名时要加上对应前缀,分别是: Base 属于基础元素,比如 div p,不需要命名 Layout 使用 .l- 或 .layout-前缀 Module 使用模块名命名,比如文章区块就叫 .article State 使用 .is- 前缀,比如 .is-show Theme 使用 .theme- 前缀 我觉得这样在语义化的基础上,拆分了状态、主题、布局,着实增强了 css 可读性。 最小化适配深度尽可能减少适配层级,虽然增加适配层级会减少冲突发生率,但是会增加额外的阅读负担,以及一些 bug(旧版 ie 层级超过 255 导致样式失效)。 像 css-modules 这种解决方案恰恰反其道而行之,通过层级避免冲突,通过预编译解决阅读负担,然而在没有预编译的情况下,最小化适配深度原则依然是最有效的。 3.5 BEMBEM 规范更像是 SMACSS 分类的加强版,通过 __element 表述后代,--modifier 表述状态,比如: .article {}.article__label {} /* label 元素 */.article__label--selected {} /* label 元素处于被选中状态 */ 3.6 ITCSS类似 SMACSS 对 css 元素进行了分层: Settings – 与预处理器一起使用,包含颜色、字体等定义 Tools – 工具与方法,比如 mixins,Settings 与 Tools 都不会产生任何 css 代码,仅仅是辅助函数与变量 Generic – 通用层,比如 reset html、body 的样式 Elements – 对通用元素的样式重置,比如 a p div 等元素的样式重置 Objects – 类似 OOCSS 中的对象,描述一些常用的基础状态 Components – 对组件样式的定义,一个 UI 元素基本由 Objects 与 Components 组成 Utilities – 工具类,比如 .hidden ITCSS 的分层是非常有借鉴意义的,即便在 css-in-js 设计中,也可以参考此模式定义结构。 3.7 ECSSECSS 的规范是这样的:.nsp-Component_ChildNode-variant nsp 一个尽量简短的命名空间 Component 文件名 ChildNode 子元素名 variant 额外内容 例子: <div class="tl-MediaObject"> <a href="##" class="tl-MediaObject_Link"> <img class="tl-MediaObject_Media" src="mini.jpg" alt="User"> </a> <div class="tl-MediaObject_Attribution">@BF 14 minutes ago</div></div> 更多细节可以看此 PPT 4 总结虽然我认为这篇文章提出的 css-in-js 缺点大部分存在漏洞,但它警示了我们,css 设计的初衷是全局化控制样式,即便产生了样式冲突、混乱的问题,但我们仍要记住,在模块化开发的今天,仍要保持网站风格的整体性,即便使用了 css-in-js 的开发方式。 虽然作者呼吁我们不要只顾着 css-in-js,要放眼看看 OOCSS, SMACSS, BEM, ITCSS, 和 ECSS 等基于原生 css 的解决方案,但我觉得把这些思想运用到 css-in-js 是个不错的选择 :p 讨论地址是:精读《css-in-js 杀鸡用牛刀》 · Issue ##38 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《dob - 框架实现》","path":"/wiki/WebWeekly/前沿技术/《dob - 框架实现》.html","content":"当前期刊数: 35 本系列分三部曲:《框架实现》 《框架使用》 与 《跳出框架看哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。 本篇是 《框架实现》。 本周精读的文章是 dob 文档,如果不熟悉 API,可以简单读一读,文中有些地方会提到一些函数。 1 引言我觉得数据流与框架的关系,有点像网络与人的关系。 在网络诞生前,人与人之间连接点较少,大部分消息都是通过人与人之间传递,虽然信息整体性不强,但信息在局部非常完备:当你想开一家门面,找到经验丰富的经理人,可以一手包办完。 网络诞生后,如果想通过纯网络的方式,学习如何开门面,如果不是对网络很熟悉,一时半会也难以学习到全套流程。 数据流对框架来说,就像网络对人一样,总是存在着模块功能的完备性与项目整体性的博弈。 全局性强了,对整体性强要求的项目(频繁交互数据)友好,顺便利于测试,因为不利于测试的 UI 与数据关系被抽离开了。 局部性强了,对弱关联的项目友好,这样任何模块都能不依赖全局数据,自己完成所有功能。 对数据流的研究,大多集中于 “优化在某些框架的用法” “基于场景改良” “优化全局与局部数据流间关系” “函数式与面向对象之争” “对输入抽象” “数据格式转换” 这几方面。这里面参杂着统一与分离,类比到网络与人,也许最终只有人脑搬到网络中,才可以达到最终状态。 虚的就说这么多,本篇讲的是 《框架实现》,我们先钻到细节里。 2 精读 dob 框架实现dob 是个类似 mobx 的框架,实现思路都很类似,如果难以读懂 mobx 的源码,可以先参考 dob 的实现原理。 抽丝剥茧,实现依赖追踪 MVVM 思路中,依赖追踪是核心。 dob 中 observe 类似 mobx 的 autorun,是使用频率最高的依赖监听工具。 写作时,已经有许多文章将 vue 源码翻来覆去研究过了,因此这里就不长篇大论 MVVM 原理了。 依赖追踪分为两部分,分别是 依赖收集 与 触发回调,如果把这两个功能合起来,就是 observe 函数,分开的话,就是较为底层的 Reaction: Reaction 双管齐下,一边监听用到了哪些变量,另一边在这些变量改变后,执行回调函数。Observe 利用 Reaction 实现(简化版): function observe(callback) { const reaction = new Reaction(() => { reaction.track(callback) }) reaction.run()} reaction.run() 在初始化就执行 new Reaction 的回调,而这个回调又恰好执行 reaction.track(callback)。所以 callback 函数中用到的变量被记录了下来,当变量更改时,会触发 new Reaction 的回调,又重新收集一轮依赖,同时执行了 callback。 这样就实现了回调函数用到的变量被改变后,重新执行这个回调函数,这就是 observe。 为什么依赖追踪只支持同步函数 依赖收集无法得到触发时的环境信息。 依赖收集由 getter、setter 完成,但触发时,却无法定位触发代码位于哪个函数中,所以为了依赖追踪(即变量与函数绑定),需要定义一个全局的变量标示当前执行函数,当各依赖收集函数执行没有交叉时,可以正常运作: 上图右侧白色方块是函数体,getter 表示其中访问到某个变量的 getter,经由依赖收集后,变量被修改时,左侧控制器会重新调用其所在的函数。 但是,当函数嵌套函数时,就会出现异常: 由于采用全局变量标记法,当回调函数嵌套起来时,当内层函数执行完后,实际作用域已回到了外层,但依赖收集无法获取这个堆栈改变事件,导致后续 getter 都会误绑定到内层函数。 异步(回调)也是同理,虽然写在一个函数体内,但执行的堆栈却不同,因此无法实现正确的依赖收集。 所以需要一些办法,将嵌套的函数放在外层函数执行完毕后,再执行: 换成代码描述如下: observe(()=>{ console.log(1) observe(()=>{ console.log(2) }) console.log(3)})// 需要输出 1,3,2 当然这不是简单 setTimeout 异步控制就可以,因为依赖收集是同步的,我们要在同步基础上,实现函数执行顺序的变换。 我们可以逐层分解,在每一层执行时,子元素如果是 observe,就会临时放到队列里并跳过,在父 observe 执行完毕后,检查并执行队列,两层嵌套时执行逻辑如下图所示: 这些努力,就是为了保证在同步执行时,所有 getter 都能绑定到正确的回调函数。 如何结合 React observe 如何到 render observe 可以类比到 React 的 render,它们都具有相同的特征:是同步函数,同时 observe 的运行机制也符合了 render 函数的需求,不是吗? 如果将 observe 用到 react render 函数,当任何 render 函数使用到的变量发生改动,对应的 render 函数就会重新执行,实现 UI 刷新。 要实现结合,用到两个小技巧:聚合生命周期、替换 render 函数,用图才能解释清楚: 以上是简化版,正式版本使用 reaction 实现,可以更清晰的区分依赖收集与 rerender 阶段。 如何避免在 view 中随意修改变量为了使用起来具有更好的可维护性,需要限制依赖追踪的功能,使值不能再随意的修改。可见,强大的功能,不代表在数据流场景的高可用性,恰当的约束反而会更好。 因此引入 Action 概念,在 Action 中执行的变量修改,不仅会将多次修改聚合成一次 render,而且不在 Action 中的变量修改会抛出异常。 Action 类似进栈出栈,当栈深度不为 0 时,进行的任何的变量修改,拦截到后就可以抛出异常了。 有层次的实现 Debug 一层一层功能逐渐冒泡。 调试功能,在依赖追踪、与 react 结合这一层都需要做,怎样分工配合才能保证功能不冗余,且具有良好的拓展性呢? 数据流框架的 Debug 分为数据层和 UI 层,顺序是 dob 核心记录 debug 信息 -> dob-devtools 读取再加工,强化 UI 信息。 在 UI 层不止可以简单的将对象友好展示出来,更可以通过额外信息采集,将 Action 与 UI 元素绑定,让用户找到任意一次 Action 触发时,rerender 了哪些 UI 元素,以及每个 UI 元素分别与哪些 Action 绑定。 由于数据流需要一个 Provider 提供数据源,与 Connect 注入数据,所以可以将所有与数据流绑定的 UI 元素一一映射到 Debug UI,就像一面镜子一样映射: 通过 Debug UI,将 debug 信息与 UI 一一对应,实现 dob-react-devtools 的效果。 Debug 功能如何解耦 解耦还能方便许多功能拓展,比如支持 redux。 我得答案是事件。通过精心定义的一系列事件,制造出一个具有生命周期的工具库! 在所有 getter setter 节点抛出相关信息,Debug 端订阅这些事件,找到对自己有用的,记录下来。例如: event.on("get", info => { // 不在调试模式 if (!globalState.useDebug) { return } // 记录调用堆栈..}) Dob 目前支持这几种事件钩子: get: 任何数据发生了 getter。 set: 任何数据发生了 setter。 deleteProperty: 任何数据的 key 被移除时。 runInAction: 调用了 Action。 startBatch: 任意 Action 入栈。 endBatch: 任意 Action 出栈。 并且在关键生命周期节点,还要遵守调用顺序,比如以下是 Action 触发后,到触发 observe 的顺序: startBatch -> debugInAction -> ...multiple nested startBatch and endBatch -> debugOutAction -> reaction -> observe 如果未开启 debug,执行顺序简化为: startBatch -> ...multiple nested startBatch and endBatch -> reaction -> observe 订阅了这些事件,可以完成类似 redux-dev-tools 的功能。 3 总结由于篇幅有限,本文介绍的《框架实现》均是一些上层设计,很少有代码讲解。因为我觉得一篇引发思考的文章不应该贴太多的代码,况且人脑处理图形的效率远远高于文字、略高于代码,所以通过一些图来展示。如果想看代码实现,可以读 dob 源码。 如希望详细了解依赖注入实现流程,请看 从零开始用 proxy 实现 mobx。 下一篇是 《框架使用》,会站在使用者的角度思考数据流。当然不是下一篇精读,因为要换换胃口,也给我一些缓冲时间去整理。 更多讨论 讨论地址是:精读《dob - 框架实现》 · Issue ##48 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《dob - 框架使用》","path":"/wiki/WebWeekly/前沿技术/《dob - 框架使用》.html","content":"当前期刊数: 38 本系列分三部曲:《框架实现》 《框架使用》 与 《跳出框架看哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。 本篇是 《框架使用》。 1 引言现在我们团队也在重新思考数据流的价值,在业务不断发展,业务场景增多时,一个固定的数据流方案可能难以覆盖所有场景,在所有业务里都用得爽。特别在前端数据层很薄的场景下,在数据流治理上花功夫反倒是本末倒置。 业务场景通常很复杂,但是对技术的探索往往只追求理想情况下的效果,所以很多人草草阅读完别人的经验,给自己业务操刀时,会听到一些反对的声音,而实际效果也差强人意。 所以在阅读文章之前,应该先认识到数据流只是项目中非常微小的一环,而且每个具体方案都很看场景,就算用对了路子,带来的提效也不一定很明显。 2017 年 Redux 依然是主流,可能到 18 年还是。大家吐槽归吐槽,最终活还是得干,Redux 还是得用,就算分析出 js 天生不适合函数式,也依然一条路走到黑,因为谁也不知道未来会如何发展,redux 生态虽然用得繁琐,但普适性强,忍一忍,生活也能继续过。 Dob 和 Mobx 类似,也只是数据流中响应式方案的一个分支,思考也是比较理想化的,因此可能也摆脱不了中看不中用的命运,谁叫业务场景那么多呢。 不过相对而言,应该算是接地气一些,它既没有要求纯函数式和分离副作用,也没有 cyclejs 那么抽象,只要入门的面向对象,就可以用好。 2 精读 dob 框架使用使用 redux 时,很多时候是傻傻分不清要不要将结构化数据拍平,再分别订阅,或者分不清订阅后数据处理应该放在组件上还是全局。这是因为 redux 破坏了 react 分形设计,在 最近的一次讨论记录 有说到。而许多基于 redux 的分形方案都是 “伪” 分形的,偷偷利用 replaceReducer 做一些动态 reducer 注册,再绑定到全局。 讨论理想数据流方案比较痛苦,而且引言里说到,很多业务场景下收益也不大,所以可以考虑结合工程化思维解决,将组件类型区分开,分为普通组件与业务组件,普通组件不使用数据流,业务组件绑定全局数据流,可以避免纠结。 Store 如何管理 使用 Mobx 时,文档告诉我们它具有依赖追踪、监听等许多能力,但没有好的实践例子做指导,看完了 todoMvc 觉得学完了 90%,在项目中实践后发现无从下手。 所谓最佳实践,是基于某种约定或约束,让代码可读性、可维护性更好的方案。约定是活的,不遵守也没事,约束是死的,不遵守就无法运行。约束大部分由框架提供,比如开启严格模式后,禁止在 Action 外修改变量。然而纠结最多的地方还是在约定上,我在写 dob 框架前后,总结出了一套使用约定,可能仅对这种响应式数据流管用。 使用数据流,第一要做的事情就是管理数据,要解决 Store 放在哪,怎么放的问题。其实还有个前置条件:要不要用 Store 的问题。 要不要用 store首先,最简单的组件肯定不需要用数据流。那么组件复杂时,如果数据流本身具有分形功能,那么可用可不用。所谓具有分形功能的数据流,是贴着 react 分形功能,将其包装成任具有分形能力的组件: import { combineStores, observable, inject, observe } from 'dob'import { Connect } from 'dob-react'@observableclass Store { name = 123 }class Action { @inject(Store) store: Store changeName = () => { this.store.name = 456 }}const stores = combineStores({ Store, Action })@Connect(stores)class App extends React.Component<typeof stores, any> { render() { return <div onClick={this.props.Action.changeName}>{this.props.Store.name}</div> }}ReactDOM.render(<App /> , document.getElementById('react-dom')) dob 就是这样的框架,上面例子中,点击文字可以触发刷新,**即便根 dom 节点没有 Provider**。这意味着这个组件不论放到任何环境,都可以独立运行,成为任何项目中的一部分。这种组件虽然用了数据流,但是和普通 React 组件完全无区别,可以放心使用。 如果是伪分形的数据流,可能在 ReactDOM.render 需要特定的 Provider 配合才可使用,那么这个组件就不具备可迁移能力。如果别人不幸安装了这种组件,就需要在项目根目录安装一个全家桶。 问:虽然数据流+组件具备完全分形能力,但若此组件对 props 有响应式要求,那还是有对该数据流框架的隐形依赖。 答:是的,如果组件要求接收的 props 是 observable 化的,以便在其变化时自动 rerender,那当某个环境传递了普通 props,这个组件的部分功能将失效。其实 props 属于 react 的通用连接桥梁,因此组件只应该依赖普通对象的 props,内部可以再对其 observable 化,以具备完备的可迁移能力。 怎么用 storeReact 虽然可以完全模块化,但实际项目中模块一定分为通用组件与业务组件,页面模块也可以当作业务组件。复杂的网站由数据驱动比较好,既然是数据驱动,那么可以将业务组件与数据的连接移到顶层管理,一般通过页面顶层包裹 Provider 实现: import { combineStores, observable, inject, observe } from 'dob'import { Connect } from 'dob-react'@observableclass Store { name = 123 }class Action { @inject(Store) store: Store changeName = () => { this.store.name = 456 }}const stores = combineStores({ Store, Action })ReactDOM.render( <Provider {...store}> <App /> </Provider> , document.getElementById('react-dom')) 本质上只是改变了 Store 定义的位置,而组件使用方式依然不变: @Connectclass App extends React.Component<typeof stores, any> { render() { return <div onClick={this.props.Action.changeName}>{this.props.Store.name}</div> }} 有一个区别是 @Connect 不需要带参数了,因为如果全局注册了 Provider,会默认透传到 Connect 中。与分形相反,这种设计会导致组件无法迁移到其他项目单独运行,但好处是可以在本项目中任意移动。 分形的组件对结构强依赖,只要给定需要的 props 就可以完成功能,而全局数据流的组件几乎可以完全不依赖结构,所有 props 都从全局 store 获取。 其实说到这里,可以发现这两点是难以合二为一的,我们可以预先将组件分为业务耦合与非业务耦合两种,让业务耦合的组件依赖全局数据流,让非业务耦合组件保持分形能力。 如果有更好的 Store 管理方式,可以在我的 github 和 知乎 深入聊聊。 每个组件都要 Connect 吗 对于 Mvvm 思想的库,Connect 概念不仅仅在于注入数据(与 redux 不同),还会监听数据的变化触发 rerender。那么每个组件需要 Connect 吗? 从数据流功能来说,没有用到数据流的组件当然不需要 Connect,但业务组件保持着未来不确定性(业务不确定),所以保持每个业务组件的 Connect 便于后期维护。 而且 Connect 可能还会做其他优化工作,比如 dob 的 Connect 不仅会注入数据,完成组件自动 render,还会保证组件的 PureRender,如果对 dob 原理感兴趣,可以阅读 精读《dob - 框架实现》。 其实个议题只是非常微小的点,不过现实就是讽刺的,很多时候多会纠结在这种小点子上,所以单独花费篇幅说几句。 数据流是否要扁平化Store 扁平化有很大原因是 js 对 immutable 支持力度不够,导致对深层数据修改非常麻烦导致的,虽然 immutable.js 这类库可以通过字符串快速操作,但这种使用方式必然会被不断发展的前端浪潮所淹没,我们不可能看到 js 标准推荐我们使用字符串访问对象属性。 通过字符串访问对象属性,和 lodash 的 _.get 类似,不过对于安全访问属性,也已经有 proposal-optional-chaining 的提案在语法层面解决,同样 immutable 的便捷操作也需要一种标准方式完成。实际上不用等待另一个提案,利用 js 现有能力就可以模拟原生 immutable 支持的效果。 dob-redux 可以通过类似 this.store.articles.push(article) 的 mutable 写法,实现与 react-redux 的对接,内部自然做掉了类似 immutable.set 的事情,感兴趣可以读读我的这篇文章:Redux 使用可变数据结构,介绍了这个黑魔法的实现原理。 有点扯远了,那么数据流扁平化本质解决的是数据格式规范问题。比如 normalizr 就是一种标准数据规范的推进,很多时候我们都将冗余、或者错误归类的数据存入 Store,那维护性自然比较差,Redux 推崇的应当是正确的数据格式化,而不是一昧追求扁平化。 对于前端数据流很薄的场景,也不是随便处理数据就完事了。还有许多事可做,比如使用 node 微服务对后端数据标准化、封装一些标准格式处理组件,把很薄的数据做成零厚度,业务代码可以对简单的数据流完全无感知等等。 异步与副作用 Redux 自然而然用 action 隔离了副作用与异步,那在只有 action 的 Mvvm 开发模式中,异步需要如何隔离?Mvvm 真的完美解决了 Redux 避而远之的异步问题吗? 在使用 dob 框架时,异步后赋值需要非常小心: @Action async getUserInfo() { const userInfo = await fetchUser() this.store.user.data = userInfo // 严格模式下将会报错,因为脱离了 Action 作用域。} 原因是 await 只是假装用同步写异步,当一个 await 开始时,当前函数的栈已经退出,因此后续代码都不在一个 Action 中,所以一般的解法是显示申明 Action 的显示申明大法: @Action async getUserInfo() { const userInfo = await fetchUser() Action(() => { this.store.user.data = userInfo })} 这说明了异步需要当心!Redux 将异步隔离到 Reducer 之外很正确,只要涉及到数据流变化的操作是同步的,外面 Action 怎么千奇百怪,Reducer 都可以高枕无忧。 其实 redux 的做法与下面代码类似: @Action async getUserInfo() { // 类 redux action const userInfo = await fetchUser() this.setUserInfo(userInfo)}@Action async setUserInfo(userInfo) { // 类 redux reduer this.store.user.data = userInfo} 所以这是 dob 中对异步的另一种处理方法,称作隔离大法吧。所以在响应式框架中,显示申明大法与隔离大法都可以解决异步问题,代码也显得更加灵活。 请求自动重发响应式框架的另一个好处在于可以自动触发,比如自动触发请求、自动触发操作等等。 比如我们希望当请求参数改变时,可以自动重发,一般的,在 react 中需要这么申明: componentWillMount() { this.fetch({ url: this.props.url, userName: this.props.userName })}componentWillReceiveProps(nextProps) { if ( nextProps.url !== this.props.url || nextProps.userName !== this.props.userName ) { this.fetch({ url: nextProps.url, userName: nextProps.userName }) }} 在 dob 这类框架中,以下代码的功能是等价的: import { observe } from 'dob'componentWillMount() { this.signal = observe(() => { this.fetch({ url: this.props.url, userName: this.props.userName }) })} 其神奇地方在于,observe 回调函数内用到的变量(observable 后的变量)改变时,会重新执行此回调函数。而 componentWillReceiveProps 内做的判断,其实是利用 react 的生命周期手工监听变量是否改变,如果改变了就触发请求函数,然而这一系列操作都可以让 observe 函数代劳。 observe 有点像更自动化的 addEventListener: document.addEventListener('someThingChanged', this.fetch) 所以组件销毁时不要忘了取消监听: this.signal.unobserve() 最近我们团队也在探索如何更方便的利用这一特性,正在考虑实现一个自动请求库,如果有好的建议,也非常欢迎一起交流。 类型推导如果你在使用 redux,可以参考 你所不知道的 Typescript 与 Redux 类型优化 优化 typescript 下 redux 类型的推导,如果使用 dob 或 mobx 之类的框架,类型推导就更简单了: import { combineStores, Connect } from 'dob'const stores = combineStores({ Store, Action })@Connectclass Component extends React.PureComponent<typeof stores, any> { render() { this.props.Store // 几行代码便获得了完整类型支持 }} 这都得益于响应式数据流是基于面向对象方式操作,可以自然的推导出类型。 Store 之间如何引用复杂的数据流必然存在 Store 与 Action 之间相互引用,比较推荐依赖注入的方式解决,这也是 dob 推崇的良好实践之一。 当然依赖注入不能滥用,比如不要存在循环依赖,虽然手握灵活的语法,但在下手写代码之前,需要对数据流有一套较为完整的规划,比如简单的用户、文章、评论场景,我们可以这么设计数据流: 分别建立 UserStore ArticleStore ReplyStore: import { inject } from 'dob'class UserStore { users}class ReplyStore { @inject(UserStore) userStore: UserStore replys // each.user}class ArticleStore { @inject(UserStore) userStore: UserStore @inject(ReplyStore) replyStore: ReplyStore articles // each.replys each.user} 每个评论都涉及到用户信息,所以 ReplyStore 注入了 UserStore,每个文章都包含作者与评论信息,所以 ArticleStore 注入了 UserStore 与 ReplyStore,可以看出 Store 之间依赖关系应当是树形,而不是环形。 最终 Action 对 Store 的操作也是通过注入来完成,而由于 Store 之间已经注入完了,Action 可以只操作对应的 Store,必要的时候再注入额外 Store,而且也不会存在循环依赖: class UserAction { @inject(UserStore) userStore: UserStore}class ReplyAction { @inject(ReplyStore) replyStore: ReplyStore}class ArticleAction { @inject(ArticleStore) articleStore: ArticleStore} 最后,不建议在局部 Store 注入全局 Store,或者局部 Action 注入全局 Store,因为这会破坏局部数据流的分形特点,切记保证非业务组件的独立性,把全局绑定交给业务组件处理。 Action 的错误处理比较优雅的方式,是编写类级别的装饰器,统一捕获 Action 的异常并抛出: const errorCatch = (errorHandler?: (error?: Error) => void) => (target: any) => { Object.getOwnPropertyNames(target.prototype).forEach(key => { const func = target.prototype[key] target.prototype[key] = async (...args: any[]) => { try { await func.apply(this, args) } catch (error) { errorHandler && errorHandler(error) } } }) return target}const myErrorCatch = errorCatch(error => { // 上报异常信息 error})@myErrorCatchclass ArticleAction { @inject(ArticleStore) articleStore: ArticleStore} 当任意步骤触发异常,await 之后的代码将停止执行,并将异常上报到前端监控平台,比如我们内部的 clue 系统。关于异常处理更多信息,可以访问我较早的一篇文章:Callback Promise Generator Async-Await 和异常处理的演进。 3 总结准确区分出业务与非业务组件、写代码前先设计数据流的依赖关系、异步时注意分离,就可以解决绝大部分业务场景的问题,实在遇到特殊情况可以使用 observe 监听数据变化,由此可以拓展出比如请求自动重发的功能,运用得当可以解决余下比较棘手的特殊需求。 虽然数据流只是项目中非常微小的一环,但如果想让整个项目保持良好的可维护性,需要把各个环节做精致。 这篇文章写于 2017 年最后一天,祝大家元旦快乐! 更多讨论 讨论地址是:精读《dob - 框架使用》 · Issue ##53 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《how we position and what we compare》","path":"/wiki/WebWeekly/前沿技术/《how we position and what we compare》.html","content":"当前期刊数: 37 摘要本期精读文章以一个简单的例子,抽丝剥茧细数讲述如何面向用户可视化设计,探索用户最终的目的,化繁为简,化多为少,揉和 N 张图至一张图,并传达更多的深意。本文原文:http://www.storytellingwithdata.com/blog/2017/12/14/how-we-position-and-what-we-compare 下面是案例优化的具体步骤: 庖丁解牛 - 可视化案例优化可视化的时候,一定要优先考虑用户能够比较什么,然后把这些数据放到一个基准上,并把要比较的东西放到最近的地方。这样用户就可以快速简便的处理比较数据。图中有一系列类似分面的柱状图,代表了 Q1 ~ Q3 三个季度的账户 targeted 筛选 -> engaged 订阅 -> pitched 投放 -> adopted 采用的四个状态的百分比,这四个状态是呈漏斗式的数据,分别是上一个数据的子集。这个案列中,用户希望能够在一张图中比较所有的数据。如下是基础原始图: 这张原始也不乏一些优点,每个数据都清晰的表明了含义,但还是需要花费一些的时间找出图中数据表达的最重含义。但是是不是有更好的办法重新排列数据呢?我们来整理下这张图需要对比的内容把。 左上角,我们可以对比 Q1 北美 的四条柱状图,因为这四个数据相邻,并且是在一个坐标系中。 最上面一排,我们可以对比 Q1 北美和 EMEA engaged 订阅(两条紫色柱子)的数据,这个对比需要我们横向用手指去比较,需要参照物,存在一定的门槛。 基于第 2 点,我们可以考虑转换下思维,转换下数据呈现的类型可以更快速的比较。文中作者从自己在银行的职业生涯中发现线性图形的角度可以更快速的比较。所以就有了下面一张图 再进一步去除无用的柱状图,并把三个纵向的图合并,进一步简化成下面的图 可以看到坐标轴也有一些小优化,增加了字母标注每个步骤,不只是单一的颜色标注,每条线也能够区分出来它的归属(Q1 ~ Q3)。除此之外,我们还有一些非常特殊的信息需要透传,用户虽然在对比以前的数据,但是对当前的现状也是非常关注的,所以增加一些额外的实时信息以及数据解读,可以辅助用户做出下一步决策。 最后进行色彩优化,就可以看到如下最终的方案: 从这个方案,可以清晰的解读出: 每个阶段,北美的数据都比其他地区低 相比其他区域,为什么 APAC 的传播度有了突然的提升? 最需要关注 Q3 的数据,以及最终阶段的数据 最后从这篇文章的可视化案例优化样板,可以总结出以下步骤: 明确用户看图目标,根据需要转换图形表现形式 合理合并不需要拆分的数据,尽量减少数据的平铺 猜测探索用户下一步,突出当前实时现状、异常点、决策意见 利用色彩给数据分类,避免用户混淆 虽然这篇文章给出了比较场景下的优化案例,但是数据是僵硬的,呈现形式是多样的,其他场景下也会有其他更适合的优化方法。可视化和语言一样,前者是把数据翻译成图形,后者是人的思维翻译成文字。不过这也带来与语言雷同的障碍,换种方式表达可能传达的含义就完全不同。如果数据的表达方式能和语言一样,有一些固定的语法和规则,就可以大大减少表现上的歧义。因为人脑处理图形效率高于文字,所以现在很多大数据还是属于沉睡状态。我们前端做的是提升人机交互效率,通过图形可以几倍甚至几百倍提高理解速度。现在我们做的还只是把结构化的数据做到让人读懂,未来可能数据直接原始化存储,基于深度学习+可视化,转化成图形化数据,就可以让人类读懂图,甚至让机器也读懂,实现自我大数据解析。 未来,一切不可预期~~~ 讨论地址是:精读《how we position and what we compare》 · Issue ##50 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《null _= 0_》","path":"/wiki/WebWeekly/前沿技术/《null _= 0_》.html","content":"当前期刊数: 25 本期精读的文章是:null >= 0? 1 引言 你是如何看待 null >= 0 为 true 这个结果的呢?要么选择勉强接受,要么跟着我一探究竟吧。 2 内容概要大于判断javascript 在判断 a > b 时,记住下面 21 步判断法: 调用 b 的 ToPrimitive(hit Number) 方法. 调用 a 的 ToPrimitive(hit Number) 方法. 如果此时 Result(1) 与 Result(2) 都是字符串,跳到步骤 16. 调用 ToNumber(Result(1)). 调用 ToNumber(Result(2)). 如果 Result(4) 为 NaN, return undefined. 如果 Result(5) 为 NaN, return undefined. 如果 Result(4) 和 Result(5) 是相同的数字,return false. 如果 Result(4) 为 +0, Result(5) 为 -0, return false. 如果 Result(4) 为 -0, Result(5) 为 +0, return false. 如果 Result(4) 为 +∞, return false. 如果 Result(5) 为 +∞, return true. 如果 Result(5) 为 -∞, return false. 如果 Result(4) 为 -∞, return true. 如果 Result(4) 的数值大小小于 Result(5),return true,否则 return false. 如果 Result(2) 是 Result(1) 的前缀 return false. (比如 “ab” 是 “abc” 的前缀) 如果 Result(1) 是 Result(2) 的前缀, return true. 找到一个位置 k,使得 a[k] 与 b[k] 不相等. 取 m 为 a[k] 字符的数值. 取 n 为 b[k] 字符的数值. 如果 m < n, return true,否则 return false. ToPrimitive 会按照顺序优先使用存在的值:valueOf()、toString(),如果都没有,会抛出异常。ToPrimitive(hit Number) 表示隐转数值类型 所以 null > 0 结果为 false。 等于判断现在看看 a == b 时的表现(三等号会严格判断类型,两等号反而是最复杂的情况)。 如果 a 与 b 的类型相同,则: 如果 Type(b) 为 undefined,return true. 如果 Type(b) 为 null,return true. 如果 Type(b) 为 number,则: 如果 b 为 NaN,return false. 如果 a 为 NaN,return false. 如果 a 与 b 数值相同,return true. 如果 a 为 +0,b 为 -0,return true. 如果 a 为 -0,b 为 +0,return true. 否则 return false. 如果 Type(b) 为 string,且 a 与 b 是完全相同的字符串,return true,否则 return false. 如果 Type(b) 是 boolean,如果都是 true 或 false,return true,否则 return false. 如果 a 与 b 是同一个对象引用,return true,否则 return false. 如果 a 为 null,b 为 undefined,return true. 如果 a 为 undefined,b 为 null,return true. 如果 Type(a) 为 number,Type(b) 为 string,返回 a == ToNumber(b) 的结果. 如果 Type(a) 为 string,Type(b) 为 number,返回 ToNumber(a) == b 的结果. 如果 Type(a) 为 boolean,返回 ToNumber(a) == b 的结果. 如果 Type(b) 为 boolean,返回 a == ToNumber(b) 的结果. 如果 Type(a) 是 string 或 number,且 Type(b) 是对象类型,返回 a == ToPrimitive(b) 的结果. 如果 Type(a) 是对象类型,且 Type(b) 是 string 或 number,返回 ToPrimitive(a) == b 的结果. 否则 return false. 所以 null == 0 走到了第 10 步,返回了默认的 false。 大于等于判断javascript 是这么定义大于等于判断的: 如果 a < b 为 false,则 a >= b 为 true 所以 null >= 0 为 true,因为 null < 0 是 false. 3 精读关于 toPrimitive拓展一下,我们可以通过 Symbol.toPrimitive 定义某个 class 的 ToPrimitive 行为,比如: class AnswerToLifeAndUniverseAndEverything { [Symbol.toPrimitive](hint) { if (hint === 'string') { return 'Like, 42, man'; } else if (hint === 'number') { return 42; } else { // when pushed, most classes (except Date) // default to returning a number primitive return 42; } }} 还有不按套路出牌的情况?按上面的道理,我们可以举一反三: {} >= {} // true 可是这是为何呢? null >= {} // false 仔细读过上文应该不难发现,如果 ToPrimitive(hit Number) 出现了 NaN,将直接 return undefined,也就是打印出 false,而下面是隐式转换表,{} 的结果是 NaN,因此结果是 false。 4 总结NaN 在 javascript 是个特殊存在,只有 isNaN 可以准确判断到它,而且使用它进行比较判断时,会直接 return false. javascript 隐式转换有一套优先级规则,而且不同值的隐式转换还需要对照表记忆,还存在 ToPrimitive(hint Number) ToPrimitive(hint String) ToPrimitive(hint Boolean) 三份表,记忆起来确实有点复杂。 因此推荐比较判断时,尽量使用 ===,通过 Typescript Flow 等强类型语言约束变量类型,尽量不要做不同类型变量间的比较。 讨论地址是:精读《null >= 0?》 · Issue ##36 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《js 模块化发展》","path":"/wiki/WebWeekly/前沿技术/《js 模块化发展》.html","content":"当前期刊数: 1 这次是前端精读期刊与大家第一次正式碰面,我们每周会精读并分析若干篇精品好文,试图讨论出结论性观点。没错,我们试图通过观点的碰撞,争做无主观精品好文的意见领袖。 我是这一期的主持人 —— 黄子毅 本期精读的文章是:evolutionOfJsModularity。 懒得看文章?没关系,稍后会附上文章内容概述,同时,更希望能通过阅读这一期的精读,穿插着深入阅读原文。 1 引言 如今,Javascript 模块化规范非常方便、自然,但这个新规范仅执行了 2 年,就在 4 年前,js 的模块化还停留在运行时支持,10 年前,通过后端模版定义、注释定义模块依赖。对经历过来的人来说,历史的模块化方式还停留在脑海中,反而新上手的同学会更快接受现代的模块化规范。 但为什么要了解 Javascript 模块化发展的历史呢?因为凡事都有两面性,了解 Javascript 模块化规范,有利于我们思考出更好的模块化方案,纵观历史,从 1999 年开始,模块化方案最多维持两年,就出现了新的替代方案,比原有的模块化更清晰、强壮,我们不能被现代模块化方式限制住思维,因为现在的 ES2015 模块化方案距离发布也仅仅过了两年。 2 内容概要直接定义依赖 (1999): 由于当时 js 文件非常简单,模块化方式非常简单粗暴 —— 通过全局方法定义、引用模块。这种定义方式与现在的 commonjs 非常神似,区别是 commonjs 以文件作为模块,而这种方法可以在任何文件中定义模块,模块不与文件关联。 闭包模块化模式 (2003): 用闭包方式解决了变量污染问题,闭包内返回模块对象,只需对外暴露一个全局变量。 模版依赖定义 (2006): 这时候开始流行后端模版语法,通过后端语法聚合 js 文件,从而实现依赖加载,说实话,现在 go 语言等模版语法也很流行这种方式,写后端代码的时候不觉得,回头看看,还是挂在可维护性上。 注释依赖定义 (2006): 几乎和模版依赖定义同时出现,与 1999 年方案不同的,不仅仅是模块定义方式,而是终于以文件为单位定义模块了,通过 lazyjs 加载文件,同时读取文件注释,继续递归加载剩下的文件。 外部依赖定义 (2007): 这种定义方式在 cocos2d-js 开发中普遍使用,其核心思想是将依赖抽出单独文件定义,这种方式不利于项目管理,毕竟依赖抽到代码之外,我是不是得两头找呢?所以才有通过 webpack 打包为一个文件的方式暴力替换为 commonjs 的方式出现。 Sandbox 模式 (2009): 这种模块化方式很简单,暴力,将所有模块塞到一个 sandbox 变量中,硬伤是无法解决命名冲突问题,毕竟都塞到一个 sandbox 对象里,而 Sandbox 对象也需要定义在全局,存在被覆盖的风险。模块化需要保证全局变量尽量干净,目前为止的模块化方案都没有很好的做到这一点。 依赖注入 (2009): 就是大家熟知的 angular1.0,依赖注入的思想现在已广泛运用在 react、vue 等流行框架中。但依赖注入和解决模块化问题还差得远。 CommonJS (2009): 真正解决模块化问题,从 node 端逐渐发力到前端,前端需要使用构建工具模拟。 Amd (2009): 都是同一时期的产物,这个方案主要解决前端动态加载依赖,相比 commonJs,体积更小,按需加载。 Umd (2011): 兼容了 CommonJS 与 Amd,其核心思想是,如果在 commonjs 环境(存在 module.exports,不存在 define),将函数执行结果交给 module.exports 实现 Commonjs,否则用 Amd 环境的 define,实现 Amd。 Labeled Modules (2012): 和 Commonjs 很像了,没什么硬伤,但生不逢时,碰上 Commonjs 与 Amd,那只有被人遗忘的份了。 YModules (2013): 既然都出了 Commonjs Amd,文章还列出了此方案,一定有其独到之处。其核心思想在于使用 provide 取代 return,可以控制模块结束时机,处理异步结果;拿到第二个参数 module,修改其他模块的定义(虽然很有拓展性,但用在项目里是个搅屎棍)。 ES2015 Modules (2015): 就是我们现在的模块化方案,还没有被浏览器实现,大部分项目已通过 babel 或 typescript 提前体验。 3 精读本次提出独到观点的同学有:流形,黄子毅,苏里约,camsong,杨森,淡苍,留影,精读由此归纳。 从语言层面到文件层面的模块化 从 1999 年开始,模块化探索都是基于语言层面的优化,真正的革命从 2009 年 CommonJS 的引入开始,前端开始大量使用预编译。 这篇文章所提供的模块化历史的方案都是逻辑模块化,从 CommonJS 方案开始前端把服务端的解决方案搬过来之后,算是看到标准物理与逻辑统一的模块化。但之后前端工程不得不引入模块化构建这一步。正是这一步给前端开发无疑带来了诸多的不便,尤其是现在我们开发过程中经常为了优化这个工具带了很多额外的成本。 从 CommonJS 之前其实都只是封装,并没有一套模块化规范,这个就有些像类与包的概念。我在 10 年左右用的最多的还是 YUI2,YUI2 是用 namespace 来做模块化的,但有很多问题没有解决,比如多版本共存,因此后来 YUI3 出来了。 YUI().use('node', 'event', function (Y) { // The Node and Event modules are loaded and ready to use. // Your code goes here!}); YUI3 的 sandbox 像极了差不多同时出现的 AMD 规范,但早期 yahoo 在前端圈的影响力还是很大的,而 requirejs 到 2011 年才诞生,因此圈子不是用着 YUI 要不就自己封装一套 sandbox,内部使用 jQuery。 为什么模块化方案这么晚才成型,可能早期应用的复杂度都在后端,前端都是非常简单逻辑。后来 Ajax 火了之后,web app 概念的开始流行,前端的复杂度也呈指数级上涨,到今天几乎和后端接近一个量级。工程发展到一定阶段,要出现的必然会出现。 前端三剑客的模块化展望 从 js 模块化发展史,我们还看到了 css html 模块化方面的严重落后,如今依赖编译工具的模块化增强在未来会被标准所替代。 原生支持的模块化,解决 html 与 css 模块化问题正是以后的方向。 再回到 JS 模块化这个主题,开头也说到是为了构建 scope,实则提供了业务规范标准的输入输出的方式。但文章中的 JS 的模块化还不等于前端工程的模块化,Web 界面是由 HTML、CSS 和 JS 三种语言实现,不论是 CommonJS 还是 AMD 包括之后的方案都无法解决 CSS 与 HTML 模块化的问题。 对于 CSS 本身它就是 global scope,因此开发样式可以说是喜忧参半。近几年也涌现把 HTML、CSS 和 JS 合并作模块化的方案,其中 react/css-modules 和 vue 都为人熟知。当然,这一点还是非常依赖于 webpack/rollup 等构建工具,让我们意识到在 browser 端还有很多本质的问题需要推进。 对于 css 模块化,目前不依赖预编译的方式是 styled-component,通过 js 动态创建 class。而目前 css 也引入了与 js 通信的机制 与 原生变量支持。未来 css 模块化也很可能是运行时的,所以目前比较看好 styled-component 的方向。 对于 html 模块化,小尤最近爆出与 chrome 小组调研 html Modules,如果 html 得到了浏览器,编辑器的模块化支持,未来可能会取代 jsx 成为最强大的模块化、模板语言。 对于 js 模块化,最近出现的 <script type="module"> 方式,虽然还没有得到浏览器原生支持,但也是我比较看好的未来趋势,这样就连 webpack 的拆包都不需要了,直接把源代码传到服务器,配合 http2.0 完美抛开预编译的枷锁。 上述三种方案都不依赖预编译,分别实现了 html、css、js 模块化,相信这就是未来。 模块化标准推进速度仍然缓慢 2015 年提出的标准,在 17 年依然没有得到实现,即便在 nodejs 端。 这几年 TC39 对语言终于重视起来了,慢慢有动作了,但针对模块标准制定的速度,与落实都非常缓慢,与 javascript 越来越流行的趋势逐渐脱节。nodejs 至今也没有实现 ES2015 模块化规范,所有 jser 都处在构建工具的阴影下。 Http 2.0 对 js 模块化的推动 js 模块化定义的再美好,浏览器端的支持粒度永远是瓶颈,http 2.0 正是考虑到了这个因素,大力支持了 ES 2015 模块化规范。 幸运的是,模块化构建将来可能不再需要。随着 HTTP/2 流行起来,请求和响应可以并行,一次连接允许多个请求,对于前端来说宣告不再需要在开发和上线时再做编译这个动作。 几年前,模块化几乎是每个流行库必造的轮子(YUI、Dojo、Angular),大牛们自己爽的同时其实造成了社区的分裂,很难积累。有了 ES2015 Modules 之后,JS 开发者终于可以像 Java 开始者十年前一样使用一致的方式愉快的互相引用模块。 不过 ES2015 Modules 也只是解决了开发的问题,由于浏览器的特殊性,还是要经过繁琐打包的过程,等 Import,Export 和 HTTP 2.0 被主流浏览器支持,那时候才是彻底的模块化。 Http 2.0 后就不需要构建工具了吗? 看到大家基本都提到了 HTTP/2,对这项技术解决前端模块化及资源打包等工程问题抱有非常大的期待。很多人也认为 HTTP/2 普及后,基本就没有 Webpack 什么事情了。 不过 Webpack 作者 @sokra 在他的文章 webpack & HTTP/2 里提到了一个新的 Webpack 插件 AggressiveSplittingPlugin。简单的说,这款插件就是为了充分利用 HTTP/2 的文件缓存能力,将你的业务代码自动拆分成若干个数十 KB 的小文件。后续若其中任意一个文件发生变化,可以保证其他的小 chunk 不需要重新下载。 可见,即使不断的有新技术出现,也依然需要配套的工具来将前端工程问题解决方案推向极致。 模块化是大型项目的银弹吗? 只要遵循了最新模块化规范,就可以使项目具有最好的可维护性吗? Js 模块化的目的是支持前端日益上升的复杂度,但绝不是唯一的解决方案。 分析下 JavaScript 为什么没有模块化,为什么又需要模块化:这个 95 年被设计出来的时候,语言的开发者根本没有想到它会如此的大放异彩,也没有将它设计成一种模块化语言。按照文中的说法,99 年也就是 4 年后开始出现了模块化的需求。如果只有几行代码用模块化是扯,初始的 web 开发业务逻辑都写在 server 端,js 的作用小之又小。而现在 spa 都出现了,几乎所有的渲染逻辑都在前端,如果还是没有模块化的组织,开发过程会越来越难,维护也是更痛苦。 文中已经详细说明了模块化的发展和优劣,这里不准备做过多的讨论。我想说的是,在模块化之后还有一个模块间耦合的问题,如果模块间耦合度大也会降低代码的可重用性或者说复用性。所以也出现了降低耦合的观察者模式或者发布/订阅模式。这对于提升代码重用,复用性和避免单点故障等都很重要。说到这里,还想顺便提一下最近流行起来的响应式编程(RxJS),响应式编程中有一个很核心的概念就是 observable,也就是 Rx 中的流(stream)。它可以被 subscribe,其实也就是观察者设计模式。 补充阅读 JavaScript 模块化七日谈 JavaScript 模块化编程简史(2009-2016) 总结未来前端复杂度不断增加已成定论,随着后端成熟,自然会将焦点转移到前端领域,而且服务化、用户体验越来越重要,前端体验早不是当初能看就行,任何网页的异常、视觉的差异,或文案的模糊,都会导致用户流失,支付中断。前端对公司营收的影响,渐渐与后端服务宕机同等严重,所以前端会越来越重,异常监控,性能检测,工具链,可视化等等都是这几年大家逐渐重视起来的。 我们早已不能将 javascript 早期玩具性质的模块化方案用于现代越来越重要的系统中,前端界必然出现同等重量级的模块化管理方案,感谢 TC39 制定的 ES2015 模块化规范,我们已经离不开它,哪怕所有人必须使用 babel。 话说回来,标准推进的太慢,我们还是把编译工具当作常态,抱着哪怕支持了 ES2015 所有特性,babel 依然还有用的心态,将预编译进行到底。一句话,模块化仍在路上。js 模块化的矛头已经对准了 css 与 html,这两位元老也该向前卫的 js 学习学习了。 未来 css、html 的模块化会自立门户,还是赋予 js 更强的能力,让两者的模块化依附于 js 的能力呢?目前 html 有自立门户的苗头(htmlModules),而 css 迟迟没有改变,社区出现的 styled-component 已经用 js 将 css 模块化得很好了,最新 css 规范也支持了与 js 的变量通信,难道希望依附于 js 吗?这里希望得到大家更广泛的讨论。 我也认同,毕竟压缩、混淆、md5、或者利用 nonce 属性对 script 标签加密,都离不开本地构建工具。 据说 http2 的优化中,有个最佳文件大小与数量的比例,那么还是脱离不了构建工具,前端未来会越来越复杂,同时也越来越美好。 至此,对于 javascript 模块化讨论已接近尾声,对其优缺点也基本达成了一致。前端复杂度不断提高,促使着模块化的改进,代理(浏览器、node) 的支持程度,与前端特殊性(流量、缓存)可能前端永远也离不开构建工具,新的标准会让这些工作做的更好,同时取代、增强部分特征,前端的未来是更加美好的,复杂度也更高。 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《pnpm》","path":"/wiki/WebWeekly/前沿技术/《pnpm》.html","content":"当前期刊数: 253 pnpm 全称是 “Performant NPM”,即高性能的 npm。它结合软硬链接与新的依赖组织方式,大大提升了包管理的效率,也同时解决了 “幻影依赖” 的问题,让包管理更加规范,减少潜在风险发生的可能性。 使用 pnpm 很容易,可以使用 npm 安装: npm i pnpm -g 之后便可用 pnpm 代替 npm 命令了,比如最重要的安装包步骤,可以使用 pnpm i 代替 npm i,这样就算把 pnpm 使用起来了。 pnpm 的优势用一个比较好记的词描述 pnpm 的优势那就是 “快、准、狠”: 快:安装速度快。 准:安装过的依赖会准确复用缓存,甚至包版本升级带来的变化都只 diff,绝不浪费一点空间,逻辑上也严丝合缝。 狠:直接废掉了幻影依赖,在逻辑合理性与含糊的便捷性上,毫不留情的选择了逻辑合理性。 而带来这些优势的点子,全在官网上的这张图上: 所有 npm 包都安装在全局目录 ~/.pnpm-store/v3/files 下,同一版本的包仅存储一份内容,甚至不同版本的包也仅存储 diff 内容。 每个项目的 node_modules 下有 .pnpm 目录以打平结构管理每个版本包的源码内容,以硬链接方式指向 pnpm-store 中的文件地址。 每个项目 node_modules 下安装的包结构为树状,符合 node 就近查找规则,以软链接方式将内容指向 node_modules/.pnpm 中的包。 所以每个包的寻找都要经过三层结构:node_modules/package-a > 软链接 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a > 硬链接 ~/.pnpm-store/v3/files/00/xxxxxx。 经过这三层寻址带来了什么好处呢?为什么是三层,而不是两层或者四层呢? 依赖文件三层寻址的目的第一层接着上面的例子思考,第一层寻找依赖是 nodejs 或 webpack 等运行环境/打包工具进行的,他们的在 node_modules 文件夹寻找依赖,并遵循就近原则,所以第一层依赖文件势必要写在 node_modules/package-a 下,一方面遵循依赖寻找路径,一方面没有将依赖都拎到上级目录,也没有将依赖打平,目的就是还原最语义化的 package.json 定义:即定义了什么包就能依赖什么包,反之则不行,同时每个包的子依赖也从该包内寻找,解决了多版本管理的问题,同时也使 node_modules 拥有一个稳定的结构,即该目录组织算法仅与 package.json 定义有关,而与包安装顺序无关。 如果止步于此,这就是 npm@2.x 的包管理方案,但正因为 npm@2.x 包管理方案最没有歧义,所以第一层沿用了该方案的设计。 第二层从第二层开始,就要解决 npm@2.x 设计带来的问题了,主要是包复用的问题。所以第二层的 node_modules/package-a > 软链接 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a 寻址利用软链接解决了代码重复引用的问题。相比 npm@3 将包打平的设计,软链接可以保持包结构的稳定,同时用文件指针解决重复占用硬盘空间的问题。 若止步于此,也已经解决了一个项目内的包管理问题,但项目不止一个,多个项目对于同一个包的多份拷贝还是太浪费,因此要进行第三步映射。 第三层第三层映射 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a > 硬链接 ~/.pnpm-store/v3/files/00/xxxxxx 已经脱离当前项目路径,指向一个全局统一管理路径了,这正是跨项目复用的必然选择,然而 pnpm 更进一步,没有将包的源码直接存储在 pnpm-store,而是将其拆分为一个个文件块,这在后面详细讲解。 幻影依赖幻影依赖是指,项目代码引用的某个包没有直接定义在 package.json 中,而是作为子依赖被某个包顺带安装了。代码里依赖幻影依赖的最大隐患是,对包的语义化控制不能穿透到其子包,也就是包 a@patch 的改动可能意味着其子依赖包 b@major 级别的 Break Change。 正因为这三层寻址的设计,使得第一层可以仅包含 package.json 定义的包,使 node_modules 不可能寻址到未定义在 package.json 中的包,自然就解决了幻影依赖的问题。 但还有一种更难以解决的幻影依赖问题,即用户在 Monorepo 项目根目录安装了某个包,这个包可能被某个子 Package 内的代码寻址到,要彻底解决这个问题,需要配合使用 Rush,在工程上通过依赖问题检测来彻底解决。 peer-dependences 安装规则pnpm 对 peer-dependences 有一套严格的安装规则。对于定义了 peer-dependences 的包来说,意味着为 peer-dependences 内容是敏感的,潜台词是说,对于不同的 peer-dependences,这个包可能拥有不同的表现,因此 pnpm 针对不同的 peer-dependences 环境,可能对同一个包创建多份拷贝。 比如包 bar peer-dependences 依赖了 baz^1.0.0 与 foo^1.0.0,那我们在 Monorepo 环境两个 Packages 下分别安装不同版本的包会如何呢? - foo-parent-1 - bar@1.0.0 - baz@1.0.0 - foo@1.0.0- foo-parent-2 - bar@1.0.0 - baz@1.1.0 - foo@1.0.0 结果是这样(引用官网文档例子): node_modules└── .pnpm ├── foo@1.0.0_bar@1.0.0+baz@1.0.0 │ └── node_modules │ ├── foo │ ├── bar -> ../../bar@1.0.0/node_modules/bar │ ├── baz -> ../../baz@1.0.0/node_modules/baz │ ├── qux -> ../../qux@1.0.0/node_modules/qux │ └── plugh -> ../../plugh@1.0.0/node_modules/plugh ├── foo@1.0.0_bar@1.0.0+baz@1.1.0 │ └── node_modules │ ├── foo │ ├── bar -> ../../bar@1.0.0/node_modules/bar │ ├── baz -> ../../baz@1.1.0/node_modules/baz │ ├── qux -> ../../qux@1.0.0/node_modules/qux │ └── plugh -> ../../plugh@1.0.0/node_modules/plugh ├── bar@1.0.0 ├── baz@1.0.0 ├── baz@1.1.0 ├── qux@1.0.0 ├── plugh@1.0.0 可以看到,安装了两个相同版本的 foo,虽然内容完全一样,但却分别拥有不同的名称:foo@1.0.0_bar@1.0.0+baz@1.0.0、foo@1.0.0_bar@1.0.0+baz@1.1.0。这也是 pnpm 规则严格的体现,任何包都不应该有全局副作用,或者考虑好单例实现,否则可能会被 pnpm 装多次。 硬连接与软链接的原理要理解 pnpm 软硬链接的设计,首先要复习一下操作系统文件子系统对软硬链接的实现。 硬链接通过 ln originFilePath newFilePath 创建,如 ln ./my.txt ./hard.txt,这样创建出来的 hard.txt 文件与 my.txt 都指向同一个文件存储地址,因此无论修改哪个文件,都因为直接修改了原始地址的内容,导致这两个文件内容同时变化。进一步说,通过硬链接创建的 N 个文件都是等效的,通过 ls -li ./ 查看文件属性时,可以看到通过硬链接创建的两个文件拥有相同的 inode 索引: ls -li ./84976912 -rw-r--r-- 2 author staff 489 Jun 9 15:41 my.txt84976912 -rw-r--r-- 2 author staff 489 Jun 9 15:41 hard.txt 其中第三个参数 2 表示该文件指向的存储地址有两个硬链接引用。硬链接如果要指向目录就麻烦多了,第一个问题是这样会导致文件的父目录有歧义,同时还要将所有子文件都创建硬链接,实现复杂度较高,因此 Linux 并没有提供这种能力。 软链接通过 ln -s originFilePath newFilePath 创建,可以认为是指向文件地址指针的指针,即它本身拥有一个新的 inode 索引,但文件内容仅包含指向的文件路径,如: 84976913 -rw-r--r-- 2 author staff 489 Jun 9 15:41 soft.txt -> my.txt 源文件被删除时,软链接也会失效,但硬链接不会,软链接可以对文件夹生效。因此 pnpm 虽然采用了软硬结合的方式实现代码复用,但软链接本身也几乎不会占用多少额外的存储空间,硬链接模式更是零额外内存空间占用,所以对于相同的包,pnpm 额外占用的存储空间可以约等于零。 全局安装目录 pnpm-store 的组织方式pnpm 在第三层寻址时采用了硬链接方式,但同时还留下了一个问题没有讲,即这个硬链接目标文件并不是普通的 NPM 包源码,而是一个哈希文件,这种文件组织方式叫做 content-addressable(基于内容的寻址)。 简单来说,基于内容的寻址比基于文件名寻址的好处是,即便包版本升级了,也仅需存储改动 Diff,而不需要存储新版本的完整文件内容,在版本管理上进一步节约了存储空间。 pnpm-store 的组织方式大概是这样的: ~/.pnpm-store- v3 - files - 00 - e4e13870602ad2922bfc7.. - e99f6ffa679b846dfcbb1.. .. - 01 .. - .. .. - ff .. 也就是采用文件内容寻址,而非文件位置寻址的存储方式。之所以能采用这种存储方式,是因为 NPM 包一经发布内容就不会再改变,因此适合内容寻址这种内容固定的场景,同时内容寻址也忽略了包的结构关系,当一个新包下载下来解压后,遇到相同文件 Hash 值时就可以抛弃,仅存储 Hash 值不存在的文件,这样就自然实现了开头说的,pnpm 对于同一个包不同的版本也仅存储其增量改动的能力。 总结pnpm 通过三层寻址,既贴合了 node_modules 默认寻址方式,又解决了重复文件安装的问题,顺便解决了幻影依赖问题,可以说是包管理的目前最好的创新,没有之一。 但其苛刻的包管理逻辑,使我们单独使用 pnpm 管理大型 Monorepo 时容易遇到一些符合逻辑但又觉得别扭的地方,比如如果每个 Package 对于同一个包的引用版本产生了分化,可能会导致 Peer Deps 了这些包的包产生多份实例,而这些包版本的分化可能是不小心导致的,我们可能需要使用 Rush 等 Monorepo 管理工具来保证版本的一致性。 讨论地址是:精读《pnpm》· Issue ##435 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《pipe operator for JavaScript》","path":"/wiki/WebWeekly/前沿技术/《pipe operator for JavaScript》.html","content":"当前期刊数: 228 Pipe Operator (|>) for JavaScript 提案给 js 增加了 Pipe 语法,这次结合 A pipe operator for JavaScript: introduction and use cases 文章一起深入了解这个提案。 概述Pipe 语法可以将函数调用按顺序打平。如下方函数,存在三层嵌套,但我们解读时需要由内而外阅读,因为调用顺序是由内而外的: const y = h(g(f(x))) Pipe 可以将其转化为正常顺序: const y = x |> f(%) |> g(%) |> h(%) Pipe 语法有两种风格,分别来自 Microsoft 的 F## 与 Facebook 的 Hack。 之所以介绍这两个,是因为 js 提案首先要决定 “借鉴” 哪种风格。js 提案最终采用了 Hack 风格,因此我们最好把 F## 与 Hack 的风格都了解一下,并对其优劣做一个对比,才能知其所以然。 Hack Pipe 语法Hack 语法相对冗余,在 Pipe 时使用 % 传递结果: '123.45' |> Number(%) 这个 % 可以用在任何地方,基本上原生 js 语法都支持: value |> someFunction(1, %, 3) // function callsvalue |> %.someMethod() // method callvalue |> % + 1 // operatorvalue |> [%, 'b', 'c'] // Array literalvalue |> {someProp: %} // object literalvalue |> await % // awaiting a Promisevalue |> (yield %) // yielding a generator value F## Pipe 语法F## 语法相对精简,默认不使用额外符号: '123.45' |> Number 但在需要显式声明参数时,为了解决上一个 Pipe 结果符号从哪来的问题,写起来反而更为复杂: 2 |> $ => add2(1, $) await 关键字 - Hack 优F## 在 await yield 时需要特殊语法支持,而 Hack 可以自然的使用 js 内置关键字。 // Hackvalue |> await %// F##value |> await F## 代码看上去很精简,但实际上付出了高昂的代价 - await 是一个仅在 Pipe 语法存在的关键字,而非普通 await 关键字。如果不作为关键字处理,执行逻辑就变成了 await(value) 而不是 await value。 解构 - F## 优正因为 F## 繁琐的变量声明,反而使得在应对解构场景时得心应手: // F##value |> ({ a, b }) => someFunction(a, b)// Hackvalue |> someFunction(%.a, %.b) Hack 也不是没有解构手段,只是比较繁琐。要么使用立即调用函数表达式 IIFE: value |> (({ a, b }) => someFunction(a, b))(%) 要么使用 do 关键字: value |> do { const { a, b } = %; someFunction(a, b) } 但 Hack 虽败犹荣,因为解决方法都使用了 js 原生提供的语法,所以反而体现出与 js 已有生态亲和性更强,而 F## 之所以能优雅解决,全都归功于自创的语法,这些语法虽然甜,但割裂了 js 生态,这是 F## like 提案被放弃的重要原因之一。 潜在改进方案虽然选择了 Hack 风格,但 F## 与 Hack 各有优劣,所以列了几点优化方案。 利用 Partial Application Syntax 提案降低 F## 传参复杂度F## 被诟病的一个原因是传参不如 Hack 简单: // Hack2 |> add2(1, %)// F##2 |> $ => add2(1, $) 但如果利用处于 stage1 的提案 Partial Application Syntax 可以很好的解决问题。 这里就要做一个小插曲了。js 对柯里化没有原生支持,但 Partial Application Syntax 提案解决了这个问题,语法如下: const add = (x, y) => x + y;const addOne = add~(1, ?);addOne(2); // 3 即利用 fn~(?, arg) 的语法,将任意函数柯里化。这个特性解决 F## 传参复杂问题简直绝配,因为 F## 的每一个 Pipe 都要求是一个函数,我们可以将要传参的地方记为 ?,这样返回值还是一个函数,完美符合 F## 的语法: // F##2 |> add~(1, ?) 上面的例子拆开看就是: const addOne = add~(1, ?)2 |> addOne 想法很美好,但 Partial Application Syntax 得先落地。 融合 F## 与 Hack 语法在简单情况下使用 F##,需要利用 % 传参时使用 Hack 语法,两者混合在一起写就是: const resultArray = inputArray |> filter(%, str => str.length >= 0) // Hack |> map(%, str => '['+str+']') // Hack |> console.log // F## 不过这个 提案 被废弃了。 创造一个新的操作符如果用 |> 表示 Hack 语法,用 |>> 表示 F## 语法呢? const resultArray = inputArray |> filter(%, str => str.length >= 0) // Hack |> map(%, str => '['+str+']') // Hack |>> console.log // F## 也是看上去很美好,但这个特性连提案都还没有。 如何用现有语法模拟 Pipe即便没有 Pipe Operator (|>) for JavaScript 提案,也可以利用 js 现有语法模拟 Pipe 效果,以下是几种方案。 Function.pipe()利用自定义函数构造 pipe 方法,该语法与 F## 比较像: const resultSet = Function.pipe( inputSet, $ => filter($, x => x >= 0) $ => map($, x => x * 2) $ => new Set($)) 缺点是不支持 await,且存在额外函数调用。 使用中间变量说白了就是把 Pipe 过程拆开,一步步来写: const filtered = filter(inputSet, x => x >= 0)const mapped = map(filtered, x => x * 2)const resultSet = new Set(mapped) 没什么大问题,就是比较冗余,本来可能一行能解决的问题变成了三行,而且还声明了三个中间变量。 复用变量改造一下,将中间变量变成复用的: let $ = inputSet$ = filter($, x => x >= 0)$ = map($, x => x * 2)const resultSet = new Set($) 这样做可能存在变量污染,可使用 IIFE 解决。 精读Pipe Operator 语义价值非常明显,甚至可以改变编程的思维方式,在串行处理数据时非常重要,因此命令行场景非常常见,如: cat "somefile.txt" | echo 因为命令行就是典型的输入输出场景,而且大部分都是单输入、单输出。 在普通代码场景,特别是处理数据时也需要这个特性,大部分具有抽象思维的代码都进行了各种类型的管道抽象,比如: const newValue = pipe( value, doSomething1, doSomething2, doSomething3) 如果 Pipe Operator (|>) for JavaScript 提案通过,我们就不需要任何库实现 pipe 动作,可以直接写成: const newValue = value |> doSomething1(%) |> doSomething2(%) |> doSomething3(%) 这等价于: const newValue = doSomething3(doSomething2(doSomething1(value))) 显然,利用 pipe 特性书写处理流程更为直观,执行逻辑与阅读逻辑是一致的。 实现 pipe 函数即便没有 Pipe Operator (|>) for JavaScript 提案,我们也可以一行实现 pipe 函数: const pipe = (...args) => args.reduce((acc, el) => el(acc)) 但要实现 Hack 参数风格是不可能的,顶多实现 F## 参数风格。 js 实现 pipe 语法的考虑从 提案 记录来看,F## 失败有三个原因: 内存性能问题。 await 特殊语法。 割裂 js 生态。 其中割裂 js 生态是指因 F## 语法的特殊性,如果有太多库按照其语法实现功能,可能导致无法被非 Pipe 语法场景所复用。 甚至还有部分成员反对 隐性编程(Tacit programming),以及柯里化提案 Partial Application Syntax,这些会使 js 支持的编程风格与现在差异过大。 看来处于鄙视链顶端的编程风格在 js 是否支持不是能不能的问题,而是想不想的问题。 pipe 语法的弊端下面是普通 setState 语法: setState(state => ({ ...state, value: 123})) 如果改为 immer 写法如下: setState(produce(draft => draft.value = 123)) 得益于 ts 类型自动推导,在内层 produce 里就已经知道 value 是数值类型,此时如果输入字符串会报错,而如果其在另一个上下文的 setState 内,类型也会随着上下文的变化而变化。 但如果写成 pipe 模式: produce(draft => draft.value = 123) |> setState 因为先考虑的是如何修改数据,此时还不知道后面的 pipe 流程是什么,所以 draft 的类型无法确定。所以 pipe 语法仅适用于固定类型的数据处理流程。 总结pipe 直译为管道,潜在含义是 “数据像流水线一样被处理”,也可以形象理解为每个函数就是一个不同的管道,显然下一个管道要处理上一个管道的数据,并将结果输出到下一个管道作为输入。 合适的管道数量与体积决定了一条生产线是否高效,过多的管道类型反而会使流水线零散而杂乱,过少的管道会让流水线笨重不易拓展,这是工作中最大的考验。 讨论地址是:精读《pipe operator for JavaScript》· Issue ##395 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《react-rxjs》","path":"/wiki/WebWeekly/前沿技术/《react-rxjs》.html","content":"当前期刊数: 46 本周精读的代码是 react-rxjs。 1 引言本周精读的是 git 仓库 - react-rxjs,它给出了一个思路,让 rxjs 更好的与 react 结合。 2 概述View 层View 层设计没商量,至少应该看不出 rxjs 的痕迹,它做到了: // view.tsxexport default (props) => ( <div> {props.number} <button onClick={props.inc}>+</button> <button onClick={props.dec}>-</button> </div>) Container 层链接 View 与 Store 的层,同样也看不出 rxjs 的痕迹: import { inject } from 'react-rxjs'import store$, { inc, dec } from './store'import MyComponent from './view'const props = (storeState: number): MyProps => ({ number: storeState, inc, dec})export default inject(store$, props)(MyComponent) 这里 storeState 就是 store 全部数据,注意 react-rxjs 是多 store 思想,所以 inject 第一个参数传入不同的 store,组件就会与对应的 store 绑定。 Store 层这里代码就很有意思了,必须将 rxjs 与 action 对接起来: import { createStore } from 'react-rxjs'const inc$ = new Subject<void>()const dec$ = new Subject<void>()const reducer$: Observable<(state: number) => number> = Observable.merge( inc$.map(() => (state: number) => state + 1), dec$.map(() => (state: number) => state - 1))const store$ = createStore("example", reducer$, 0)export inc = () => inc$.next()export dec = () => dec$.next()export default store$ 如果转换成 redux 思维,action 就是下面的 inc 函数: const inc$ = new Subject<void>()export inc = () => inc$.next() reducer 就是下面的 reducer$,整个 store 对应 Observable.merge,switch case 的地方被 inc$、dec$ 自动识别出来了。 const reducer$: Observable<(state: number) => number> = Observable.merge( inc$.map(() => (state: number) => state + 1), dec$.map(() => (state: number) => state - 1)) 笔者优化一下代码结构,让 action 与 reducer 看起来更内聚: const inc$ = new Subject<void>()export inc = () => inc$.next()const incReducer = inc$.map(() => (state: number) => state + 1)const dec$ = new Subject<void>()export dec = () => dec$.next()const decReducer = dec$.map(() => (state: number) => state - 1)const reducer$: Observable<(state: number) => number> = Observable.merge( incReducer, decReducer) 3 精读让我们聚焦到 Action 部分: const inc$ = new Subject<void>()export inc = () => inc$.next() 可以看出,Action 功能很弱,我们只能触发 reducer,却无法 mergeMap 等流汇总的处理。 上周和叔叔讨论了 Rxjs 的一种代码组织方式:将 Rxjs 切成两部分使用,第一部分是数据源的抽象、聚合;第二部分是,对已经聚合过的单一数据源订阅后进行处理,这里处理过程只能包含对这个数据源的操作,不能再 merge 其他数据源。 这恰恰也是 Rxjs 在数据流中发挥的两大作用。分别是抽象,或者说是对副作用的隔离;以及强大的流处理能力。 react-rxjs 虽然代码看上去很简单,但 Action 部分没有足够的抽象能力,举例子说就是无法进行流的 merge,因为 Subject 自己就是一个事件触发器,想要进行流合并,必须发生在 reducer 中: const incReducer = inc$.merge(requestUser$).map(() => (state: number) => state + 1) 但这样就丧失了 Action 与 Reducer 一一对应的关系,因为 reducer 可以擅自 merge 任意数据流,那就完全不受控制了。 所以回到第二个约定:对已经聚合过的单一数据源订阅后进行处理,此时不能包含任何 merge 操作。 可以总结一下,react-rxjs 的方式是解决了 rxjs 与 react 结合繁琐的问题,但如果遵守开发约定,Action 的功能就很弱,无法进行进一步抽象,如果不遵守开发约定,就可以解决 Action 能力弱的问题,但带来的是 Reducer 与 Action 脱离关系,这在项目维护中是不可接受的。 所以 react-rxjs 是一个看上去方便,但实践起来会发现怎么都不舒服的方案。 redux-observable我们再看 redux-observable 这个库,就很容易理解为什么这么做了。 const pingEpic = action$ => action$.filter(action => action.type === 'PING') .delay(1000) // Asynchronously wait 1000ms then continue .mapTo({ type: 'PONG' });// later...dispatch({ type: 'PING' }); redux-observable 只有一个数据源,在 dispatch 的过程触发事件,进入 action 逻辑。其实每个 action 都源自对同一个数据源的订阅,通过 action.type 的筛选来确保执行了正确的 action。 所以每次 dispatch,包括 mapTo 也是 dispatch,都会触发数据源的事件派发,然后所有 Action 因为订阅了这个数据源,所以都会执行,最后被 .filter 逻辑拦截后,执行到正确的 Action。整个 Action 间调用的链路打个比方,就像我们使用微信一样,当触发任何消息,都会将其送到后台服务器,服务器给所有客户端发消息(假设系统设计的有问题,没有在服务端做 filter。。),每个客户端根据用户名做一个筛选,如果不是发给自己的消息,就过滤掉。然后,任何人与人之间的消息发送,都会走一遍这个流程。 reducer 与 redux 的 reducer 一摸一样: const pingReducer = (state = { isPinging: false }, action) => { switch (action.type) { case 'PING': return { isPinging: true }; case 'PONG': return { isPinging: false }; default: return state; }} redux-observable 的设计比 react-rxjs 好在哪呢?我认为好在遵循了上面总结的两条经验: 第一部分是数据源的抽象、聚合;第二部分是,对已经聚合过的单一数据源订阅后进行处理,这里处理过程只能包含对这个数据源的操作,不能再 merge 其他数据源。 Action 之间的 dispatch 就是第一部分对数据源的整合,这里包括所有副作用。Reducer 只需要挑选合适的 ActionType 绑定,这样确保了 Reducer 中处理操作一定是对单一数据源的,不存在对其他数据源 merge,换句话说就是和 Action 一一对应。 所以整体来看,我认为 redux-observable 比 react-rxjs 要靠谱。 但是 react-rxjs 抛开了 redux 繁琐的样板代码,而 redux-observable 样板代码只会比 react-redux 要多。如果要投入项目使用,比较好的方式是按照 dva 的思路,减少 redux-observable 的样板代码。 4 总结最后稍稍聊一下 cyclejs,因为用这个库,基本就脱离了 react 生态,我们 react 系开发者只能干瞪眼看看。 cyclejs 就一个目的,解决 react + rxjs 中阴魂不散的循环依赖问题:视图的回调函数可以产生数据源(observable),但视图又可能依赖这个数据源。 就是解决 A 依赖 B,B 又依赖 A 的问题,而且它做到了: function main(sources) { const input$ = sources.DOM.select('.field').events('input') const name$ = input$.map(ev => ev.target.value).startWith('') const vdom$ = name$.map(name => div([ label('Name:'), input('.field', {attrs: {type: 'text'}}), hr(), h1('Hello ' + name), ]) ) return { DOM: vdom$ }} 可以看到,最让我们不舒服的部分,就是 sources.DOM.select('.field') 和 input('.field') 这个循环节,为什么呢?因为初始化函数还没有返回 DOM 节点,为啥就能选中 DOM 节点?而且还作为参数参与这个 DOM 的生成。 可惜 React 无法解决这个问题,我们只能通过预定义数据源来解决:首先定义一个数据源,DOM 订阅它,Action 触发时找到这个数据源,手动调用 .next()。或者 redux-observable 这样,全局只有一个数据源。 总的来说,笔者认为 rxjs 还是难以落地到 react 业务代码中,究其本质,就是没有 cyclejs 这种机制解决数据源引起的循环依赖问题。 5 更多讨论 讨论地址是:精读《react-rxjs》 · Issue ##65 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《proposal-extractors》","path":"/wiki/WebWeekly/前沿技术/《proposal-extractors》.html","content":"当前期刊数: 258 proposal-extractors 是一个关于解构能力增强的提案,支持在直接解构时执行自定义逻辑。 概述const [first, second] = arr;const { name, age } = obj; 以上就是解构带来的便利,如果没有解构语法,相同的实现我们需要这么做: const first = arr[0];const second = arr[1];const name = obj.name;const age = obj.age; 但上面较为原始的方法可以在对象赋值时进行一些加工,比如: const first = someLogic(arr[0]);const second = someLogic(arr[1]);const name = someLogic(obj.name);const age = someLogic(obj.age); 解构语法就没那么简单了,想要实现类似的效果,需要退化到多行代码实现,冗余度甚至超过非解构语法: const [first: firstTemp, second: secondTemp] = arrconst {name: nameTemp, age: ageTemp} = objconst first = someLogic(firstTemp)const second = someLogic(secondTemp)const name = someLogic(nameTemp)const age = someLogic(ageTemp) proposal-extractors 提案就是用来解决这个问题,希望保持解构语法优雅的同时,加一些额外逻辑: const SomeLogic(first, second) = arr // 解构数组const SomeLogic{name, age} = obj // 解构对象 稍稍有点别扭,使用 () 解构数组,使用 {} 解构对象。我们再看 SomeLogic 的定义: const SomeLogic = { [Symbol.matcher]: (value) => { return { matched: true, value: value.toString() + "特殊处理" }; },}; 这样我们拿到的 first、second、name、age 变量就都变成字符串了,且后缀增加了 '特殊处理' 这四个字符。 为什么用 () 表示数组解构呢?主要是防止出现赋值歧义: // 只有一项时,[] 到底是下标含义还是解构含义呢?const SomeLogic[first] = arr 精读proposal-extractors 提案提到了 BindingPattern 与 AssignmentPattern: // binding patternsconst Foo(y) = x; // instance-array destructuringconst Foo{y} = x; // instance-object destructuringconst [Foo(y)] = x; // nestingconst [Foo{y}] = x; // ..const { z: Foo(y) } = x; // ..const { z: Foo{y} } = x; // ..const Foo(Bar(y)) = x; // ..const X.Foo(y) = x; // qualified names (i.e., a.b.c)// assignment patternsFoo(y) = x; // instance-array destructuringFoo{y} = x; // instance-object destructuring[Foo(y)] = x; // nesting[Foo{y}] = x; // ..({ z: Foo(y) } = x); // ..({ z: Foo{y} } = x); // ..Foo(Bar(y)) = x; // ..X.Foo(y) = x; // qualified names (i.e., a.b.c) 从例子来看,BindingPattern 相比 AssignmentPattern 只是前面多了一个 const 标记。那么 BindingPattern 与 AssignmentPattern 分别表示什么含义呢? BindingPattern 与 AssignmentPattern 是解构模式下的特有概念。 BindingPattern 需要用 const let 等变量定义符描述。比如下面的例子,生成了 a、d 两个新对象,我们称这两个对象被绑定了(binding)。 const obj = { a: 1, b: { c: 2 } };const { a, b: { c: d },} = obj;// Two variables are bound: `a` and `d` AssignmentPattern 无需用变量定义符描述,只能用已经定义好的变量,所以可以理解为对这些已经存在的变量赋值。比如下面的例子,将对象的 a b 分别绑定到数组 numbers 的每一项。 const numbers = [];const obj = { a: 1, b: 2 };({ a: numbers[0], b: numbers[1] } = obj); proposal-extractors 是针对解构的增强提案,自然也要支持 BindingPattern 与 AssignmentPattern 这两种模式。 总结proposal-extractors 提案维持了解构的优雅(自定义解构仍仅需一行代码),但引入了新语法(自定义处理函数、对数组使用 () 号解构的奇怪记忆),在过程式代码中并没有太大的优势,但结合其他特性可能有意想不到的便利,比如结合 Declarations-in-Conditionals 后可以快速判断是否是某个类的实例并同时解构 if / while let bindings。 讨论地址是:精读《proposal-extractors》· Issue ##443 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《recoil》","path":"/wiki/WebWeekly/前沿技术/《recoil》.html","content":"当前期刊数: 152 1 引言Recoil 是 Facebook 公司出的数据流管理方案,有一定思考的价值。 Recoil 是基于 Immutable 的数据流管理方案,这也是它值得被拿出来看的最重要原因,如果要用 Mutable 方式管理 React 数据流,直接看 mobx-react 就足够了。 然而 React Immutable 特性带来的可预测性非常利于调试和维护: 断点调试时变量的值与当前执行位置无关,已创建过的值不会突然 Mutable 突变,非常可预测。 在 React 框架下组件更新机制单一,只有引用变化才触发重渲染,而没有 Mutable 模式下 ForceUpdate 的心智负担。 当然 Immutable 模式下存在一定编码心智负担,所以各有优劣。 但 Recoil 和 Redux 一样,并不代表 React 官方数据流管理方案,因此不用带着官方光环去看它。 2 简介Recoil 解决 React 全局数据流管理的问题,采用分散管理原子状态的设计模式,支持派生数据与异步查询,在基本功能上可以覆盖 Redux。 状态作用域和 Redux 一样,全局数据流管理需要存在作用域 RecoilRoot: import React from "react";import { RecoilRoot } from "recoil";function App() { return ( <RecoilRoot> <CharacterCounter /> </RecoilRoot> );} RecoilRoot 在被嵌套时,最内层的 RecoilRoot 会覆盖外层的配置及状态值。 定义数据与 Redux 集中定义 initState 不同,Recoil 采用 atom 以分散方式定义数据: const textState = atom({ key: "textState", default: "",}); 其中 key 必须在 RecoilRoot 作用域内唯一,也可以认为是 state 树打平时 key 必须唯一的要求。 default 定义默认值,既然数据定义分散了,默认值定义也是分散的。 读取数据与 Redux 的 Connect 或 useSelector 类似,Recoil 采用 Hooks 方式读取数据: import { useRecoilValue } from "recoil";function App() { const text = useRecoilValue(textState);} useRecoilValue 与 useSetRecoilState 都可以获取数据,区别是 useRecoilState 还可以获取写数据的函数: import { useRecoilState } from "recoil";function App() { const [text, setText] = useRecoilState(useRecoilState);} 修改数据与 Redux 集中定义纯函数 reducer 修改数据不同,Recoil 采用 Hooks 方式写数据。 除了上面提到的 useRecoilState 之外,还有一个 useSetRecoilState 可以仅获取写函数: 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 用于只读不订阅场景: 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 支持派生值,这是比较有特色的功能: 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 函数写成异步即可: 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> );} 异步状态可以被 Suspense 捕获。 异步过程报错可以被 ErrorBoundary 捕获。 如果不想用 Suspense 阻塞异步,可以换 useRecoilValueLoadable 这个 API 在当前组件内管理异步状态: 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 本身是原子化状态管理的,所以这个问题相对好解决: const myMultipliedState = selectorFamily({ key: "MyMultipliedNumber", get: (multiplier) => ({ get }) => { return get(myNumberState) * multiplier; },});function MyComponent() { const number = useRecoilValue(myMultipliedState(100));} 当外部传参 multiplier 与依赖值 myNumberState 不变时,就不会重新计算。 Recoil 在 get 与 set 函数定义 Atom 时,内部会自动生成依赖,这个部分做的比较好。 依赖外部变量使用了 Family 后缀,比如 selector -> selectorFamily;atom -> atomFamily。 3 精读Recoil 以原子化方式对状态进行分离管理,确实比较契合 Immutable 的编程模式,尤其在缓存处理时非常亮眼,但编程领域中,优势换一个角度看往往就变成了劣势,我们还是要客观评价一下 Recoil。 Immutable 心智负担API 较多,在简介中也提到了,这可能是 Immutable 自带的硬伤,而不仅仅是 Recoil 的问题。 Immutable 模式中,对数据流只有读与写两种诉求,而申明式编程讲究的是数据变化后 UI 自动 Rerender,那么对数据的读自然而然就被赋予了订阅其变化后触发 Rerender 的期待,但是写与读不同,为什么 setState 强调用回调方式写数据?因为回调方式的写不依赖读,有写诉求的组件没必要与读挂上钩,也就是写组件的地方不一定要订阅对应数据。 Recoil 提供了 useRecoilState 作为读写双重 API,仅在既读又写的场景使用,而 useRecoilValue 仅仅是为了简化 API,替换为 useRecoilState 不会有性能损失,而 useSetRecoilValue 则必须认真对待,在仅写不读的场景必须严格使用这个 API。 那 useState 为什么默认是读写的?因为 useState 是单组件状态管理的场景,一个定义在组件内的状态不可能只写不读,但 Recoil 是全局状态解决方案,读写分离的场景下,对于只写的组件很有必要脱离对数据的订阅实现性能最大化。 条件访问数据这也是 Hooks 的通病,由于 Hooks 不能写在条件语句中,因此要利用 Hooks 获取一个带有条件判断的数据时,必须回到 selector 模式: 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 状态管理的基本功: 对象的读与写分离,做到最优按需渲染。 派生的值必须严格缓存,并在命中缓存时引用保证严格相等。 原子存储的数据相互无关联,所有关联的数据都使用派生值方式推导。 讨论地址是:精读《recoil》· Issue ##251 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《setState 做了什么》","path":"/wiki/WebWeekly/前沿技术/《setState 做了什么》.html","content":"当前期刊数: 87 1 引言setState 是 React 框架最常用的命令,它是用来更新状态的,这也是 React 框架划时代的功能。 但是 setState 函数是 react 包导出的,他们又是如何与 react-dom react-native react-art 这些包结合的呢? 通过 how-does-setstate-know-what-to-do 这篇文章,可以解开这个秘密。 2 概述setState 函数是在 React.Component 组件中调用的,所以最自然的联想是,更新 DOM 的逻辑在 react 包中实现。 但是 react 却可以和 react-dom react-native react-art 这些包打配合,甚至与 react-dom/server 配合在服务端运行,那可以肯定 react 包中不含有 DOM 更新逻辑。 所以可以推断,平台相关的 UI 更新逻辑分布在平台相关的包里,react 包只做了代理。 React 引擎不在 react 包里从 react 0.14 版本之后,引擎代码就从 react 包中抽离了,react 包仅仅做通用接口抽象。 也就是说,react 包定义了标准的状态驱动模型的 API,而 react-dom react-native react-art 这些包是在各自平台的具体实现。 各平台具体的渲染引擎实现被称为 reconciler,通过这个链接可以看到 react-dom react-native react-art 这三个包的 reconciler 实现。 这说明了 react 包仅告诉你 React 拥有哪些语法,而并不关心如何实现他们,所以我们需要结合 react 包与 react-xxx 一起使用。 对于 context,react 包仅仅会做如下定义: // A bit simplifiedfunction createContext(defaultValue) { let context = { _currentValue: defaultValue, Provider: null, Consumer: null }; context.Provider = { $$typeof: Symbol.for("react.provider"), _context: context }; context.Consumer = { $$typeof: Symbol.for("react.context"), _context: context }; return context;} 具体用到时,由 react-dom 和 react-native 决定用何种方式实现 MyContext.Provider 这个 API。 这也说明了,如果你不同步升级 react 与 react-dom 版本的话,就可能碰到这样的报错:fail saying these types are invalid,原因是 API 定义与实现不匹配。 setState 怎么调用平台实现每个平台对 UI 更新逻辑的实现,会封装在 updater 函数里,所以不同平台代码会为组件添加各自的 updater 实现: // Inside React DOMconst inst = new YourComponent();inst.props = props;inst.updater = ReactDOMUpdater;// Inside React DOM Serverconst inst = new YourComponent();inst.props = props;inst.updater = ReactDOMServerUpdater;// Inside React Nativeconst inst = new YourComponent();inst.props = props;inst.updater = ReactNativeUpdater; 不同于 props, updater 无法被直接调用,因为这个 API 是由 react 引擎在 setState 时调用的: // A bit simplifiedsetState(partialState, callback) { // Use the `updater` field to talk back to the renderer! this.updater.enqueueSetState(this, partialState, callback);} 关系可以这么描述:react -> setState -> updater <- react-dom 等。 HooksHooks 的原理与 setState 类似,当调用 useState 或 useEffect 时,其内部调用如下: // In React (simplified a bit)const React = { // Real property is hidden a bit deeper, see if you can find it! __currentDispatcher: null, useState(initialState) { return React.__currentDispatcher.useState(initialState); }, useEffect(initialState) { return React.__currentDispatcher.useEffect(initialState); } // ...}; ReactDOM 提供了 __currentDispatcher(简化的说法): // In React DOMconst prevDispatcher = React.__currentDispatcher;React.__currentDispatcher = ReactDOMDispatcher;let result;try { result = YourComponent(props);} finally { // Restore it back React.__currentDispatcher = prevDispatcher;} 可以看到,Hooks 的原理与 setState 基本一致,但需要注意 react 与 react-dom 之间传递了 dispatch,虽然你看不到。但这个 dispatch 必须对应到唯一的 React 实例,这就是为什么 Hooks 不允许同时加载多个 React 实例的原因。 和 updater 一样,dispatch 也可以被各平台实现重写,比如 react-debug-hooks 就重写了 dispatcher。 由于需要同时实现 readContext, useCallback, useContext, useEffect, useImperativeMethods, useLayoutEffect, useMemo, useReducer, useRef, useState,工程量比较浩大,建议了解基本架构就足够了,除非你要深入参与 React 生态建设。 3 精读与其他 React 分析文章不同,本文并没有过于刨根问题的上来就剖析 reconciler 实现,而是问了一个最基本的疑问:为什么 setState 来自 react 包,但实现却在 react-dom 里?React 是如何实现这个 magic 的? 通过这个疑问,我们了解了 React 更上层的抽象能力,如何用一个包制定规范,用 N 包去实现它。 接口的力量在日常编程中,接口也拥有的强大力量,下面举几个例子。 UI 组件跨三端的接口由于 RN、Weex、Flutter 的某些不足,越来越多的人选择 “一个思想三端实现” 的方式做跨三端的 UI 组件,这样既兼顾了性能,又可以照顾到平台差异性,对不同平台组件细节做定制优化。 要实施这个方案,最大问题就是接口约定。一定要保证三套实现遵循同一套 API 接口,业务代码才可以实现 “针对任意一个平台编写,自动移植到其他平台”。 比较常用的做法是,通过一套统一的 API 文件约束,固定组件的输入输出,不同平台的组件做平台具体实现。这个思想和 React 如出一辙。 当然 RN 这些框架本身也是同一接口在不同平台实现的典型,只是做的不够彻底,JS 与 Native 的通信导致了性能不如原生。 通用数据查询服务通用数据查询服务也比较流行,通过磨平各数据库语法,让用户通过一套 SQL 查询各种类型数据库的数据。 这个方案中,一套通用的查询语法就类似 React 定义的 API,执行阶段会转化为各数据库平台的 SQL 方言。 小程序融合方案现在这种方案很火。通过基于 template 或者 jsx 的语法,一键发布到各平台小程序应用。 这种方案一定会抽象一套通用语法,甚至几乎等价与 react 与 react-dom 的关系:所有符合规范的语法,转化为各小程序平台的实现。 4 总结这种分平台实现方案与跨平台方案还是有很大区别的,像 JAVA 虚拟机本质还是一套实现方案。而分平台的实现可以带来最原生的性能与体验,同样收到的约束也最大,应该其 API 应该是所有平台支持的一个子集。 另外,这种方案不仅可以用于 一套规范,不同平台的实现,甚至可以用在 “同一平台的实现”。 无论是公司还是开源节界,都有许多重复的轮子或者平台,如果通过技术委员会约定一套平台的实现规范,大家都遵循这个规范开发平台,那未来就比较好做收敛,或者说收敛的第一步都是先统一 API 规范。 留下一个思考题:还有没有利用 setState 规范与实现分离的思想案例?欢迎留下你的答案。 讨论地址是:精读《setState 做了什么》 · Issue ##122 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《snowpack》","path":"/wiki/WebWeekly/前沿技术/《snowpack》.html","content":"当前期刊数: 153 1 引言基于 webpack 构建的大型项目开发速度已经非常慢了,前端开发者已经逐渐习惯忍受超过 100 秒的启动时间,超过 30 秒的 reload 时间。即便被寄予厚望的 webpack5 内置了缓存机制也不会得到质的提升。但放到十年前,等待时间是几百毫秒。 好在浏览器支持了 ESM import 模块化加载方案,终于原生支持了文件模块化,这使得本地构建不再需要处理模块化关系并聚合文件,这甚至可以将构建时间从 30 秒降低到 300 毫秒。 当然基于 ESM import 的构建框架不止 snowpack 一个,还有比如基于 vue 的 vite,因为浏览器支持模块化是一个标准,而不与任何框架绑定,未来任何构建工具都会基于此特性开发,这意味着在未来的五年,前端构建一定会回到十年前的速度,这个趋势是明显、确定的。 ESM import 带来的最直观的改变有下面三点: node_modules 完全不需要参与到构建过程,仅这一点就足以让构建效率提升至少 10 倍。 模块化交给浏览器管理,修改任何组件都只需做单文件编译,时间复杂度永远是 O(1),reload 时间与项目大小无关。 浏览器完全模块化加载文件,不存在资源重复加载问题,这种原生的 TreeShaking 还可以做到访问文件时再编译,做到单文件级别的按需构建。 所以可以说 ESM import 模式下的开发效率,能做到与十年前修改 HTML 单文件的零构建效率几乎相当。 2 简介 & 精读snowpack 核心特征: 开发模式启动仅需 50ms 甚至更少。 热更新速度非常快。 构建时可以结合任何 bundler,比如 webpack。 内置支持 TS、JSX、CSS Modules 等。 支持自定义构建脚本以及三方插件。 安装yarn add --dev snowpack 通过 snowpack.config.json 文件配置,并能自动读取 babel.config.json 生效 babel 插件。 开发调试调试 snowpack dev,编译 snowpack build,会自动以 src/index 作为应用入口进行编译。 snowpack dev 命令几乎是零耗时的,因为文件仅会在被浏览器访问时进行按需编译,因此构建速度是理想的最快速。 当浏览器访问文件时,snowpack 会将文件做如下转换: // 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 相同: 也可以指定以 webpack 作为构建器: // snowpack.config.json{ // Optimize your production builds with Webpack "plugins": [ [ "@snowpack/plugin-webpack", { /* ... */ } ] ]} 除了默认构建方式之外,还支持自定义文件处理,通过 snowpack.config.json 配置 scripts 指定: { "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 就不一一列举了,详细可以见 官方文档。 我们可以从构建命令体会到 snowpack 的理念,将源码以流式方式编译后,直接部署到本地 server 提供的 URL 地址,浏览器通过一个 main 入口以 ESM import 的方式加载这些文件。 所以所有加载与构建逻辑都是按需的,snowpack 要做的只是将本地文件逐个构建好并启动本地服务给浏览器调用。 前端开发离不开 node_modules,snowpack 通过 snowpack install 的方式支持了这一点。 snowpack install这个命令已经被 snowpack dev 内置了,所以 snowpack install 仅用来理解原理。 以下是 snowpack install 执行的结果: ✔ 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 这个静态资源地址的引用: import * as ReactDOM from "react-dom";// 转换import * as React from "/web_modules/react.js"; 但同时可以看到 snowpack 对前端生态的高要求,如果某些包通过 webpack 别名设置了一些 magic 映射,就无法通过文件路径直接映射,所以 snowpack 生态成熟需要一段时间,但模块标准化一定是趋势,不规范的包在未来几年内会逐步被淘汰。 2020 年适合使用 snowpack 吗答案是还不适合用在生产环境。 当然用在开发环境还是可以的,但需要承担三个风险: 开发与生产环境构建结果不一致的风险。 项目生态存在非 ESM import 模块化包而导致大量适配成本的风险。 项目存在大量 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《web reflow》","path":"/wiki/WebWeekly/前沿技术/《web reflow》.html","content":"当前期刊数: 242 网页重排(回流)是阻碍流畅性的重要原因之一,结合 What forces layout / reflow 这篇文章与引用,整理一下回流的起因与优化思考。 借用这张经典图: 网页渲染会经历 DOM -> CSSOM -> Layout(重排 or reflow) -> Paint(重绘) -> Composite(合成),其中 Composite 在 精读《深入了解现代浏览器四》 详细介绍过,是在 GPU 进行光栅化。 那么排除 JS、DOM、CSSOM、Composite 可能导致的性能问题外,剩下的就是我们这次关注的重点,reflow 了。从顺序上可以看出来,重排后一定重绘,而重绘不一定触发重排。 概述什么时候会触发 Layout(reflow) 呢?一般来说,当元素位置发生变化时就会。但也不尽然,因为浏览器会自动合并更改,在达到某个数量或时间后,会合并为一次 reflow,而 reflow 是渲染页面的重要一步,打开浏览器就一定会至少 reflow 一次,所以我们不可能避免 reflow。 那为什么要注意 reflow 导致的性能问题呢?这是因为某些代码可能导致浏览器优化失效,即明明能合并 reflow 时没有合并,这一般出现在我们用 js API 访问某个元素尺寸时,为了保证拿到的是精确值,不得不提前触发一次 reflow,即便写在 for 循环里。 当然也不是每次访问元素位置都会触发 reflow,在浏览器触发 reflow 后,所有已有元素位置都会记录快照,只要不再触发位置等变化,第二次开始访问位置就不会触发 reflow,关于这一点会在后面详细展开。现在要解释的是,这个 ”触发位置等变化“,到底有哪些? 根据 What forces layout / reflow 文档的总结,一共有这么几类: 获得盒子模型信息 elem.offsetLeft, elem.offsetTop, elem.offsetWidth, elem.offsetHeight, elem.offsetParent elem.clientLeft, elem.clientTop, elem.clientWidth, elem.clientHeight elem.getClientRects(), elem.getBoundingClientRect() 获取元素位置、宽高的一些手段都会导致 reflow,不存在绕过一说,因为只要获取这些信息,都必须 reflow 才能给出准确的值。 滚动 elem.scrollBy(), elem.scrollTo() elem.scrollIntoView(), elem.scrollIntoViewIfNeeded() elem.scrollWidth, elem.scrollHeight elem.scrollLeft, elem.scrollTop 访问及赋值 对 scrollLeft 赋值等价于触发 scrollTo,所有导致滚动产生的行为都会触发 reflow,笔者查了一些资料,目前主要推测是滚动条出现会导致可视区域变窄,所以需要 reflow。 focus() elem.focus() (源码) 可以根据源码看一下注释,主要是这一段: // Ensure we have clean style (including forced display locks).GetDocument().UpdateStyleAndLayoutTreeForNode(this) 即在聚焦元素时,虽然没有拿元素位置信息的诉求,但指不定要被聚焦的元素被隐藏或者移除了,此时必须调用 UpdateStyleAndLayoutTreeForNode 重排重绘函数,确保元素状态更新后才能继续操作。 还有一些其他 element API: elem.computedRole, elem.computedName elem.innerText (源码) innerText 也需要重排后才能拿到正确内容。 获取 window 信息 window.scrollX, window.scrollY window.innerHeight, window.innerWidth window.visualViewport.height / width / offsetTop / offsetLeft (源码) 和元素级别一样,为了拿到正确宽高和位置信息,必须重排。 document 相关 document.scrollingElement 仅重绘 document.elementFromPoint elementFromPoint 因为要拿到精确位置的元素,必须重排。 Form 相关 inputElem.focus() inputElem.select(), textareaElem.select() focus、select 触发重排的原因和 elem.focus 类似。 鼠标事件相关 mouseEvt.layerX, mouseEvt.layerY, mouseEvt.offsetX, mouseEvt.offsetY (源码) 鼠标相关位置计算,必须依赖一个正确的排布,所以必须触发 reflow。 getComputedStylegetComputedStyle 通常会导致重排和重绘,是否触发重排取决于是否访问了位置相关的 key 等因素。 Range 相关 range.getClientRects(), range.getBoundingClientRect() 获取选中区域的大小,必须 reflow 才能保障精确性。 SVG大量 SVG 方法会引发重排,就不一一枚举了,总之使用 SVG 操作时也要像操作 dom 一样谨慎。 contenteditable被设置为 contenteditable 的元素内,包括将图像复制到剪贴板在内,大量操作都会导致重排。(源码) 精读What forces layout / reflow 下面引用了几篇关于 reflow 的相关文章,笔者挑几个重要的总结一下。 repaint-reflow-restylerepaint-reflow-restyle 提到现代浏览器会将多次 dom 操作合并,但像 IE 等其他内核浏览器就不保证有这样的实现了,因此给出了一个安全写法: // badvar left = 10, top = 10;el.style.left = left + "px";el.style.top = top + "px"; // better el.className += " theclassname"; // or when top and left are calculated dynamically... // betterel.style.cssText += "; left: " + left + "px; top: " + top + "px;"; 比如用一次 className 的修改,或一次 cssText 的修改保证浏览器一定触发一次重排。但这样可维护性会降低很多,不太推荐。 avoid large complex layoutsavoid large complex layouts 重点强调了读写分离,首先看下面的 bad case: function resizeAllParagraphsToMatchBlockWidth() { // Puts the browser into a read-write-read-write cycle. for (var i = 0; i < paragraphs.length; i++) { paragraphs[i].style.width = box.offsetWidth + 'px'; }} 在 for 循环中不断访问元素宽度,并修改其宽度,会导致浏览器执行 N 次 reflow。 虽然当 JavaScript 运行时,前一帧中的所有旧布局值都是已知的,但当你对布局做了修改后,前一帧所有布局值缓存都会作废,因此当下次获取值时,不得不重新触发一次 reflow。 而读写分离的话,就代表了集中读,虽然读的次数还是那么多,但从第二次开始就可以从布局缓存中拿数据,不用触发 reflow 了。 另外还提到 flex 布局比传统 float 重排速度快很多(3ms vs 16ms),所以能用 flex 做的布局就尽量不要用 float 做。 really fixing layout thrashingreally fixing layout thrashing 提到了用 fastdom 实践读写分离: ids.forEach(id => { fastdom.measure(() => { const top = elements[id].offsetTop fastdom.mutate(() => { elements[id].setLeft(top) }) })}) fastdom 是一个可以在不分离代码的情况下,分离读写执行的库,尤其适合用在 reflow 性能优化场景。每一个 measure、mutate 都会推入执行队列,并在 window.requestAnimationFrame 时机执行。 总结回流无法避免,但需要控制在正常频率范围内。 我们需要学习访问哪些属性或方法会导致回流,能不使用就不要用,尽量做到读写分离。在定义要频繁触发回流的元素时,尽量使其脱离文档流,减少回流产生的影响。 讨论地址是:精读《web reflow》· Issue ##420 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《useEffect 完全指南》","path":"/wiki/WebWeekly/前沿技术/《useEffect 完全指南》.html","content":"当前期刊数: 96 1. 引言工具型文章要跳读,而文学经典就要反复研读。如果说 React 0.14 版本带来的各种生命周期可以类比到工具型文章,那么 16.7 带来的 Hooks 就要像文学经典一样反复研读。 Hooks API 无论从简洁程度,还是使用深度角度来看,都大大优于之前生命周期的 API,所以必须反复理解,反复实践,否则只能停留在表面原地踏步。 相比 useState 或者自定义 Hooks 而言,最有理解难度的是 useEffect 这个工具,希望借着 a-complete-guide-to-useeffect 一文,深入理解 useEffect。 原文非常长,所以概述是笔者精简后的。作者是 Dan Abramov,React 核心开发者。 2. 概述unLearning,也就是学会忘记。你之前的学习经验会阻碍你进一步学习。 想要理解好 useEffect 就必须先深入理解 Function Component 的渲染机制,Function Component 与 Class Component 功能上的不同在上一期精读 精读《Function VS Class 组件》 已经介绍,而他们还存在思维上的不同: Function Component 是更彻底的状态驱动抽象,甚至没有 Class Component 生命周期的概念,只有一个状态,而 React 负责同步到 DOM。 这是理解 Function Component 以及 useEffect 的关键,后面还会详细介绍。 由于原文非常非常的长,所以笔者精简下内容再重新整理一遍。原文非常长的另一个原因是采用了启发式思考与逐层递进的方式写作,笔者最大程度保留这个思维框架。 从几个疑问开始假设读者有比较丰富的前端 & React 开发经验,并且写过一些 Hooks。那么你也许觉得 Function Component 很好用,但美中不足的是,总有一些疑惑萦绕在心中,比如: 🤔 如何用 useEffect 代替 componentDidMount? 🤔 如何用 useEffect 取数?参数 [] 代表什么? 🤔useEffect 的依赖可以是函数吗?是哪些函数? 🤔 为何有时候取数会触发死循环? 🤔 为什么有时候在 useEffect 中拿到的 state 或 props 是旧的? 第一个问题可能已经自问自答过无数次了,但下次写代码的时候还是会忘。笔者也一样,而且在三期不同的精读中都分别介绍过这个问题: 精读《React Hooks》 精读《怎么用 React Hooks 造轮子》 精读《Function VS Class 组件》 但第二天就忘记了,因为 用 Hooks 实现生命周期确实别扭。 讲真,如果想彻底解决这个问题,就请你忘掉 React、忘掉生命周期,重新理解一下 Function Component 的思维方式吧! 上面 5 个问题的解答就不赘述了,读者如果有疑惑可以去 原文 TLDR 查看。 要说清楚 useEffect,最好先从 Render 概念开始理解。 每次 Render 都有自己的 Props 与 State可以认为每次 Render 的内容都会形成一个快照并保留下来,因此当状态变更而 Rerender 时,就形成了 N 个 Render 状态,而每个 Render 状态都拥有自己固定不变的 Props 与 State。 看下面的 count: function Counter() { const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </div> );} 在每次点击时,count 只是一个不会变的常量,而且也不存在利用 Proxy 的双向绑定,只是一个常量存在于每次 Render 中。 初始状态下 count 值为 0,而随着按钮被点击,在每次 Render 过程中,count 的值都会被固化为 1、2、3: // During first renderfunction Counter() { const count = 0; // Returned by useState() // ... <p>You clicked {count} times</p>; // ...}// After a click, our function is called againfunction Counter() { const count = 1; // Returned by useState() // ... <p>You clicked {count} times</p>; // ...}// After another click, our function is called againfunction Counter() { const count = 2; // Returned by useState() // ... <p>You clicked {count} times</p>; // ...} 其实不仅是对象,函数在每次渲染时也是独立的。这就是 Capture Value 特性,后面遇到这种情况就不会一一展开,只描述为 “此处拥有 Capture Value 特性”。 每次 Render 都有自己的事件处理解释了为什么下面的代码会输出 5 而不是 3: const App = () => { const [temp, setTemp] = React.useState(5); const log = () => { setTimeout(() => { console.log("3 秒前 temp = 5,现在 temp =", temp); }, 3000); }; return ( <div onClick={() => { log(); setTemp(3); // 3 秒前 temp = 5,现在 temp = 5 }} > xyz </div> );}; 在 log 函数执行的那个 Render 过程里,temp 的值可以看作常量 5,执行 setTemp(3) 时会交由一个全新的 Render 渲染,所以不会执行 log 函数。而 3 秒后执行的内容是由 temp 为 5 的那个 Render 发出的,所以结果自然为 5。 原因就是 temp、log 都拥有 Capture Value 特性。 每次 Render 都有自己的 EffectsuseEffect 也一样具有 Capture Value 的特性。 useEffect 在实际 DOM 渲染完毕后执行,那 useEffect 拿到的值也遵循 Capture Value 的特性: function Counter() { const [count, setCount] = useState(0); useEffect(() => { document.title = `You clicked ${count} times`; }); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </div> );} 上面的 useEffect 在每次 Render 过程中,拿到的 count 都是固化下来的常量。 如何绕过 Capture Value利用 useRef 就可以绕过 Capture Value 的特性。可以认为 ref 在所有 Render 过程中保持着唯一引用,因此所有对 ref 的赋值或取值,拿到的都只有一个最终状态,而不会在每个 Render 间存在隔离。 function Example() { const [count, setCount] = useState(0); const latestCount = useRef(count); useEffect(() => { // Set the mutable latest value latestCount.current = count; setTimeout(() => { // Read the mutable latest value console.log(`You clicked ${latestCount.current} times`); }, 3000); }); // ...} 也可以简洁的认为,ref 是 Mutable 的,而 state 是 Immutable 的。 回收机制在组件被销毁时,通过 useEffect 注册的监听需要被销毁,这一点可以通过 useEffect 的返回值做到: useEffect(() => { ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange); };}); 在组件被销毁时,会执行返回值函数内回调函数。同样,由于 Capture Value 特性,每次 “注册” “回收” 拿到的都是成对的固定值。 用同步取代 “生命周期”Function Component 不存在生命周期,所以不要把 Class Component 的生命周期概念搬过来试图对号入座。Function Component 仅描述 UI 状态,React 会将其同步到 DOM,仅此而已。 既然是状态同步,那么每次渲染的状态都会固化下来,这包括 state props useEffect 以及写在 Function Component 中的所有函数。 然而舍弃了生命周期的同步会带来一些性能问题,所以我们需要告诉 React 如何比对 Effect。 告诉 React 如何对比 Effects虽然 React 在 DOM 渲染时会 diff 内容,只对改变部分进行修改,而不是整体替换,但却做不到对 Effect 的增量修改识别。因此需要开发者通过 useEffect 的第二个参数告诉 React 用到了哪些外部变量: useEffect(() => { document.title = "Hello, " + name;}, [name]); // Our deps 直到 name 改变时的 Rerender,useEffect 才会再次执行。 然而手动维护比较麻烦而且可能遗漏,因此可以利用 eslint 插件自动提示 + FIX: 不要对 Dependencies 撒谎如果你明明使用了某个变量,却没有申明在依赖中,你等于向 React 撒了谎,后果就是,当依赖的变量改变时,useEffect 也不会再次执行: useEffect(() => { document.title = "Hello, " + name;}, []); // Wrong: name is missing in dep 这看上去很蠢,但看看另一个例子呢? function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>;} setInterval 我们只想执行一次,所以我们自以为聪明的向 React 撒了谎,将依赖写成 []。 “组件初始化执行一次 setInterval,销毁时执行一次 clearInterval,这样的代码符合预期。” 你心里可能这么想。 但是你错了,由于 useEffect 符合 Capture Value 的特性,拿到的 count 值永远是初始化的 0。相当于 setInterval 永远在 count 为 0 的 Scope 中执行,你后续的 setCount 操作并不会产生任何作用。 诚实的代价笔者稍稍修改了一下标题,因为诚实是要付出代价的: useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id);}, [count]); 你老实告诉 React “嘿,等 count 变化后再执行吧”,那么你会得到一个好消息和两个坏消息。 好消息是,代码可以正常运行了,拿到了最新的 count。 坏消息有: 计时器不准了,因为每次 count 变化时都会销毁并重新计时。 频繁 生成/销毁 定时器带来了一定性能负担。 怎么既诚实又高效呢?上述例子使用了 count,然而这样的代码很别扭,因为你在一个只想执行一次的 Effect 里依赖了外部变量。 既然要诚实,那只好 想办法不依赖外部变量: useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id);}, []); setCount 还有一种函数回调模式,你不需要关心当前值是什么,只要对 “旧的值” 进行修改即可。这样虽然代码永远运行在第一次 Render 中,但总是可以访问到最新的 state。 将更新与动作解耦你可能发现了,上面投机取巧的方式并没有彻底解决所有场景的问题,比如同时依赖了两个 state 的情况: useEffect(() => { const id = setInterval(() => { setCount(c => c + step); }, 1000); return () => clearInterval(id);}, [step]); 你会发现不得不依赖 step 这个变量,我们又回到了 “诚实的代价” 那一章。当然 Dan 一定会给我们解法的。 利用 useEffect 的兄弟 useReducer 函数,将更新与动作解耦就可以了: const [state, dispatch] = useReducer(reducer, initialState);const { count, step } = state;useEffect(() => { const id = setInterval(() => { dispatch({ type: "tick" }); // Instead of setCount(c => c + step); }, 1000); return () => clearInterval(id);}, [dispatch]); 这就是一个局部 “Redux”,由于更新变成了 dispatch({ type: "tick" }) 所以不管更新时需要依赖多少变量,在调用更新的动作里都不需要依赖任何变量。 具体更新操作在 reducer 函数里写就可以了。在线 Demo。 Dan 也将 useReducer 比作 Hooks 的的金手指模式,因为这充分绕过了 Diff 机制,不过确实能解决痛点! 将 Function 挪到 Effect 里在 “告诉 React 如何对比 Diff” 一章介绍了依赖的重要性,以及对 React 要诚实。那么如果函数定义不在 useEffect 函数体内,不仅可能会遗漏依赖,而且 eslint 插件也无法帮助你自动收集依赖。 你的直觉会告诉你这样做会带来更多麻烦,比如如何复用函数?是的,只要不依赖 Function Component 内变量的函数都可以安全的抽出去: // ✅ Not affected by the data flowfunction getFetchUrl(query) { return "https://hn.algolia.com/api/v1/search?query=" + query;} 但是依赖了变量的函数怎么办? 如果非要把 Function 写在 Effect 外面呢?如果非要这么做,就用 useCallback 吧! function Parent() { const [query, setQuery] = useState("react"); // ✅ Preserves identity until query changes const fetchData = useCallback(() => { const url = "https://hn.algolia.com/api/v1/search?query=" + query; // ... Fetch data and return it ... }, [query]); // ✅ Callback deps are OK return <Child fetchData={fetchData} />;}function Child({ fetchData }) { let [data, setData] = useState(null); useEffect(() => { fetchData().then(setData); }, [fetchData]); // ✅ Effect deps are OK // ...} 由于函数也具有 Capture Value 特性,经过 useCallback 包装过的函数可以当作普通变量作为 useEffect 的依赖。useCallback 做的事情,就是在其依赖变化时,返回一个新的函数引用,触发 useEffect 的依赖变化,并激活其重新执行。 useCallback 带来的好处在 Class Component 的代码里,如果希望参数变化就重新取数,你不能直接比对取数函数的 Diff: componentDidUpdate(prevProps) { // 🔴 This condition will never be true if (this.props.fetchData !== prevProps.fetchData) { this.props.fetchData(); }} 反之,要比对的是取数参数是否变化: componentDidUpdate(prevProps) { if (this.props.query !== prevProps.query) { this.props.fetchData(); }} 但这种代码不内聚,一旦取数参数发生变化,就会引发多处代码的维护危机。 反观 Function Component 中利用 useCallback 封装的取数函数,可以直接作为依赖传入 useEffect,**useEffect 只要关心取数函数是否变化,而取数参数的变化在 useCallback 时关心,再配合 eslint 插件的扫描,能做到 依赖不丢、逻辑内聚,从而容易维护。** 更更更内聚除了函数依赖逻辑内聚之外,我们再看看取数的全过程: 一个 Class Component 的普通取数要考虑这些点: 在 didMount 初始化发请求。 在 didUpdate 判断取数参数是否变化,变化就调用取数函数重新取数。 在 unmount 生命周期添加 flag,在 didMount didUpdate 两处做兼容,当组件销毁时取消取数。 你会觉得代码跳来跳去的,不仅同时关心取数函数与取数参数,还要在不同生命周期里维护多套逻辑。那么换成 Function Component 的思维是怎样的呢? 笔者利用 useCallback 对原 Demo 进行了改造。 function Article({ id }) { const [article, setArticle] = useState(null); // 副作用,只关心依赖了取数函数 useEffect(() => { // didCancel 赋值与变化的位置更内聚 let didCancel = false; async function fetchData() { const article = await API.fetchArticle(id); if (!didCancel) { setArticle(article); } } fetchData(); return () => { didCancel = true; }; }, [id]); // ...} 当你真的理解了 Function Component 理念后,就可以理解 Dan 的这句话:虽然 useEffect 前期学习成本更高,但一旦你正确使用了它,就能比 Class Component 更好的处理边缘情况。 useEffect 只是底层 API,未来业务接触到的是更多封装后的上层 API,比如 useFetch 或者 useTheme,它们会更好用。 3. 精读原文有 9000+ 单词,非常长。但同时也配合一些 GIF 动图生动解释了 Render 执行原理,如果你想用好 Function Component 或者 Hooks,这篇文章几乎是必读的,因为没有人能猜到什么是 Capture Value,然而不能理解这个概念,Function Component 也不能用的顺手。 重新捋一下这篇文章的思路: 从介绍 Render 引出 Capture Value 的特性。 拓展到 Function Component 一切均可 Capture,除了 Ref。 从 Capture Value 角度介绍 useEffect 的 API。 介绍了 Function Component 只关注渲染状态的事实。 引发了如何提高 useEffect 性能的思考。 介绍了不要对 Dependencies 撒谎的基本原则。 从不得不撒谎的特例中介绍了如何用 Function Component 思维解决这些问题。 当你学会用 Function Component 理念思考时,你逐渐发现它的一些优势。 最后点出了逻辑内聚,高阶封装这两大特点,让你同时领悟到 Hooks 的强大与优雅。 可以看到,比写框架更高的境界是发现代码的美感,比如 Hooks 本是为增强 Function Component 能力而创造,但在抛出问题-解决问题的过程中,可以不断看到规则限制,换一个角度打破它,最后体会到整体的逻辑之美。 从这篇文章中也可以读到如何增强学习能力。作者告诉我们,学会忘记可以更好的理解。我们不要拿生命周期的固化思维往 Hooks 上套,因为那会阻碍我们理解 Hooks 的理念。 另补充一些零碎的内容。 useEffect 还有什么优势useEffect 在渲染结束时执行,所以不会阻塞浏览器渲染进程,所以使用 Function Component 写的项目一般都拥有更好的性能。 自然符合 React Fiber 的理念,因为 Fiber 会根据情况暂停或插队执行不同组件的 Render,如果代码遵循了 Capture Value 的特性,在 Fiber 环境下会保证值的安全访问,同时弱化生命周期也能解决中断执行时带来的问题。 useEffect 不会在服务端渲染时执行。 由于在 DOM 执行完毕后才执行,所以能保证拿到状态生效后的 DOM 属性。 4. 总结最后,提两个最重要的点,来检验你有没有读懂这篇文章: Capture Value 特性。 一致性。将注意放在依赖上(useEffect 的第二个参数 []),而不是关注何时触发。 你对 “一致性” 有哪些更深的解读呢?欢迎留言回复。 讨论地址是:精读《useEffect 完全指南》 · Issue ##138 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《useRef 与 createRef 的区别》","path":"/wiki/WebWeekly/前沿技术/《useRef 与 createRef 的区别》.html","content":"当前期刊数: 141 1 引言useRef 是常用的 API,但还有一个 createRef 的 API,你知道他们的区别吗?通过 React.useRef and React.createRef: The Difference 这篇文章,你可以了解到何时该使用它们。 2 概述其实原文就阐述了这样一个事实:useRef 仅能用在 FunctionComponent,createRef 仅能用在 ClassComponent。 第一句话是显然的,因为 Hooks 不能用在 ClassComponent。 第二句话的原因是,createRef 并没有 Hooks 的效果,其值会随着 FunctionComponent 重复执行而不断被初始化: 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 所有渲染周期的实例,理论上在任何地方都可以做修改,比如: function App() { const valueRef = React.useRef(); valueRef.current += 1; return <div />;} 但其实上面的修改方式是不规范的,React 官方文档里要求我们避免在 Render 函数中直接修改 Ref,请先看下面的 FunctionComponent 生命周期图: 从图中可以发现,在 Render phase 阶段是不允许做 “side effects” 的,也就是写副作用代码,这是因为这个阶段可能会被 React 引擎随时取消或重做。 修改 Ref 属于副作用操作,因此不适合在这个阶段进行。我们可以看到,在 Commit phase 阶段可以做这件事,或者在回调函数中做(脱离了 React 生命周期)。 当然有一种情况是可以的,即 懒初始化: 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》 Ref 不仅可以拿到组件引用、创建一个 Mutable 副作用对象,还可以配合 useEffect 存储一个较老的值,最常用来拿到 previousProps,React 官方利用 Ref 封装了一个简单的 Hooks 拿到上一次的值: function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }); return ref.current;} 由于 useEffect 在 Render 完毕后才执行,因此 ref 的值在当前 Render 中永远是上一次 Render 时候的,我们可以利用它拿到上一次 Props: function App(props) { const preProps = usePrevious(props);} 要实现这个功能,还是要归功于 ref 可以将值 “在各个不同的 Render 闭包中传递的特性”。最后,不要滥用 Ref,Mutable 引用越多,对 React 来说可维护性一般会越差。 4 总结你还挖掘了 useRef 哪些有意思的使用方式?欢迎在评论区留言。 讨论地址是:精读《useRef 与 createRef 的区别》 · Issue ##236 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《webpack4","path":"/wiki/WebWeekly/前沿技术/《webpack4.html","content":"当前期刊数: 47 本周精读的是 webpack4.0 一些变化,以及 typescript 该怎么做才能最大化利用 webpack4.0 的所有特性。 1 引言前段时间尝试了 parcel 作为构建工具,就像农村人享受了都市的生活,就再也回不去了一样,发现无配置真是前端构建工具的大趋势,用起来非常方便快捷,再也不想碰 webpack 的配置了。 可是实践一段实践后,发现 parcel 还是不够成熟,主要体现在暂时不支持一些 rollup 优秀特性:Tree shaking、Scope Hoist,大型项目打包速度反而比 webpack3.0 慢。由于笔者完全零配置,当发现构建速度急速下降时,自然把矛头指向了 parcel :p. 就在前几周,webpack4.0 发布了,也拥抱了零配置,我想,是时候再回到 webpack 了。可是,文档好少,怎么迁移呢? 就在这几天,webpack 文档发布了 4.0 版本,虽然遗留了大量旧文档,不过也足够参考了。 2 精读笔者尝试了 webpack node api,尝试了很久,发现被坑了。文档里只字未提 mode 模式,4.0 环境下 compiler 总是提示没有 mode 的 warning。 读了一些文档,发现 webpack4.0 大力度宣传的是 cli 方式启动,里面提到了最重要的 webpack --mode 模式,可见 webpack4.0 更推崇的是让开发者使用高度封装的 cli,而不是使用 node 方式开发(那 node 文档也应该更新呀)。笔者又看了一圈,发现 webpack-dev-server 的 webpack 版本升到了 4.0,ts-loader 也升级到了 4.0,可能生态已经全部准备好了。 使用 webpack cli、webpack-dev-server cli安装 webpack^4.1.1 webpack-cli^2.0.10 webpack-dev-server^3.1.0,以及创建一个公共配置文件 webpack.config.ts: export default { entry, output, module: { rules }, resolve, resolveLoader, devServer: { https: true, open: true, overlay: { warnings: true, errors: true }, port }} 记得用 tsc 转换为 webpack.config.js 作为 cli 入口。 开发模式下使用 webpack-dev-server: webpack-dev-server --mode development --progress --hot --hotOnly --config ./webpack.config.js 生产环境 build 使用 webpack: webpack --mode production --progress --config ./webpack.config.js 开发/生产模式,都以 webpack.config.ts 作为配置,其中 devServer 项仅在开发模式下,对 webpack-dev-server 生效。 一旦开启了 --mode production,会自动开启代码压缩、scope hoist 等插件,以及自动传递环境变量给 lib 包,所以已经不需要 plugins 这个配置项了。同理,开启了 --mode development 会自动开启 sourceMap 等开发插件,我们只要关心更简单的配置,这就是 4.0 零配置的重要改变。 mode=production, mode=development 具体内置了哪些配置,可以参考这篇文章:webpack 4 终于知道「约定优于配置」了。恰恰有意思的是,webpack4 这么做,就是不想我们浪费时间了解这些机制,社区应该会慢慢习惯零配置的开发方式。 当然,虽然说零配置,但配置文件基本三板斧还是非常有必要配置:entry output module。 我们可能还要给配置文件传一些参数,比如定制多种开发模式的入口,通过 --env 传递: webpack-dev-server --mode development --env.entry ./src/main.tsx webpack.config.ts 接收: const entry = yargs.argv.env.entry 使用 typescript + webpack简单来说,只需要 ts-loader 就够了。在 webpack.config.ts 中增加新的 rules: { module: { rules: [{ test: /\\.(tsx|ts)?$/, use: ["ts-loader"] }] }} 注意 tsconfig.json 中模块解析策略使用: "module": "esnext"。 原因是 webpack 需要 es6 import 语句,才能进行 tree shaking 或者动态 import 优化,我们不再让 ts-loader 包办模块设置,换句话说,我们采用白名单方式看待 typescript 以及 babel,只让他做我们需要的工作,剩下的丢给 webpack 处理,可以获得最大程度性能优化。 如果仅使用 webpack + typescript,建议将 ts 编译输出模式调整为 es3,因为 webpack 自带的压缩工具对 es6 语法还存在报错,而且也不会做兼容处理。 使用 typescript + babel + webpcak注意处理顺序,ts -> babel -> webpack。 因为多出了 babel,我们将 ts 编译兼容模式关闭:"target": "esnext",模块也不要解析:"module": "esnext",ts-loader 仅仅将 typescript 代码转换成 js,其他一切优化都不要做,将 esnext 原生代码直接传给 babel 处理。 babel 这一层的职责是对代码进行兼容处理,不要压缩,也不要把 import 转成 require。笔者发现 babel 直接解析 import 代码会无法处理,因此需要 stage-2 preset: { presets: [ ["env", { modules: false, }], ["stage-2"] ], plugins: [ ["transform-runtime"] ], comments: true} 从上面配置可以看到,babel 这层对 esnext 的代码进行了浏览器兼容处理(env 插件),直接透传 import(stage-2 插件让 babel 识别 esModule),以及支持 async await(transform-runtime) 插件。 本来想用 env 替代 transform-runtime 的功能,笔者暂时没有查询到可行方式,欢迎读者补充。 另外要允许 babel 保留注释(comments: true),因为 webpack import 支持自定义 chunkName 是通过注释的方式: import(/* webpackChunkName: "src" */ "./src") 配合 react-loadable 使用更佳: Loadable({ loader: () => import(/* webpackChunkName: "src" */ "./src"), loading: (): any => null}) 因为 react-loadable 让页面按 chunk 方式打包,而 webpack 又会自动 picke shared chunks,配合给每个 page chunks 通过 webpackChunkName 定义名称,webpack 可以给每个共享 chunks 更加可读的名字,比如:vendor~src,about,login,你就知道这个是 src about login 三个页面间公共模块。 可能已经有人看出瑕疵了,给每个文件增加 webpackChunkName 注释既麻烦又不优雅,而且只要有一个开发者没有加这个注释,上面说的可读 chunks 可能就缺少了某个模块名。 这就要笔者之前一篇精读来看了:精读《Rekit Studio》,项目可以通过约定的方式定义页面,入口文件通过 cli 自动生成,不就既减少业务代量,又统一加上了 webpackChunkName 嘛? 这里小小安利下集成了这个思路的项目脚手架 pri,使用了 ts + babel + webpack4.0,上述的小优化也是内置的功能之一。 webpack4 带来的是适配成本的大幅优化社区似乎有部分声音在抱怨,webpack 又发新版本,我们又要适配一轮。其实 webpack 这么做恰恰没有带来适配成本,出问题的在于我们对 webpack 的使用方式与理念。 如果我们开始就将 webpack 当作一体化打包方案,开发调试使用 webpack-dev-server cli,开发环境编译使用 webpack cli,那么 webpack4 其实只是补充了开发环境这个最重要的配置变量而已。类比 parcel 的两个命令: parcel index.htmlparcel build index.html 对应: webpack-dev-server --mode developmentwebpack --mode production 所以 webpack4 几乎是有史以来最方便使用与迁移的版本,前提是使用思维得正确,舍得将编译环节全权交给两个官方的 Cli。 3 总结只要合理的使用 typescript、babel,让各自只发挥最小功能,将原生的模块化代码抛给 webpack,再配合 --mode production 配置,webpack 会自动开启一切可能的插件优化你的项目,而我们再不需要阅读形形色色的 webpack 插件了,更令人激动的是,随着 webpack 版本升级,优化会不断升级,而我们只要留着 --mode 参数,不需要改一行配置。 总结起来,就是不用关心优化相关的配置,我们只需要配置业务相关的 entry output module,这就是 webpack4.0. 我以前为了实现第一次编译完后立即打开浏览器的功能,写了一共 200 行的 customCompiler 以及 format-webpack-message,而且利用 koa 开了一个 server,利用 await 和 flags 等待第一次编译完的时机,并利用 opn 库打开网页。 其实用 cli 只需要 webpack-dev-server --open。 随着新的一波零配置浪潮,真的不应该在编译配置上花那么多时间了。 4 番外 - prefetch读者自习阅读就会发现,这不是一篇单纯 webpack4 升级指南,仔细阅读可以发现文中蕴藏的一些工程优化思路。文章末尾再给一波福利,分析一下 prefetch 优化是什么,以及怎么做。 现代浏览器支持了以下两种语法: <link rel="preload" /><link rel="prefetch" /> 兼容性自己查 Caniuse,笔者重点在功能上。preload 收集当前用到的资源,prefetch 收集未来用到的资源。 页面本质上也是未来一种资源,如果认为用户会点击另一个页面(如果对产品没自信,或者 pv 过低可以忽略这个功能),就可以用 prefetch 让浏览器在空闲时间下载下一个页面的 chunk 文件。 前端包体积优化效率一般和用户体验是违背的,既然下一个页面在另一个 chunk 中,用户点击后必然会产生 loading。可是如果结合了 prefetch,鱼和熊掌就兼得了(正常用户不可能页面还没加载完就立刻点按钮跳页,所以唯一的缺点几乎不会对正常用户产生影响)。 api 有了,那么最大的问题就是,当前页面怎么知道要加载哪些 chunks?一般两种做法: 全量模式 使用比如 preload-webpack-plugin 插件,将所有生成的 chunk 都作为 prefetch 资源,在所有页面中。几乎所有规模的项目都不会产生过多的 chunks,所以这个方案理论上不够优雅,但能解决实际问题。 按需模式,是理论和实践双重优雅的方案,是否要这么做取决于您是否有代码洁癖。方法是提供一个定制的 Link 标签,根据 URL 地址按需生成 prefetch 标签。这种方案最大缺陷是,如果用户不按照约定使用内置的 Link,prefetch 规则将会无效。 5 更多讨论 讨论地址是:精读《webpack4.0 升级指南》 · Issue ##66 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《web streams》","path":"/wiki/WebWeekly/前沿技术/《web streams》.html","content":"当前期刊数: 214 Node stream 比较难理解,也比较难用,但 “流” 是个很重要而且会越来越常见的概念(fetch 返回值就是流),所以我们有必要认真学习 stream。 好在继 node stream 之后,又推出了比较好用,好理解的 web streams API,我们结合 Web Streams Everywhere (and Fetch for Node.js)、2016 - the year of web streams、ReadableStream、WritableStream 这几篇文章学一下。 node stream 与 web stream 可以相互转换:.fromWeb() 将 web stream 转换为 node stream;.toWeb() 将 node stream 转换为 web stream。 精读stream(流)是什么? stream 是一种抽象 API。我们可以和 promise 做一下类比,如果说 promise 是异步标准 API,则 stream 希望成为 I/O 的标准 API。 什么是 I/O?就是输入输出,即信息的读取与写入,比如看视频、加载图片、浏览网页、编码解码器等等都属于 I/O 场景,所以并不一定非要大数据量才算 I/O,比如读取一个磁盘文件算 I/O,同样读取 "hello world" 字符串也可以算 I/O。 stream 就是当下对 I/O 的标准抽象。 为了更好理解 stream 的 API 设计,以及让你理解的更深刻,我们先自己想一想一个标准 I/O API 应该如何设计? I/O 场景应该如何抽象 API?read()、write() 是我们第一个想到的 API,继续补充的话还有 open()、close() 等等。 这些 API 确实可以称得上 I/O 场景标准 API,而且也足够简单。但这些 API 有一个不足,就是缺乏对大数据量下读写的优化考虑。什么是大数据量的读写?比如读一个几 GB 的视频文件,在 2G 慢网络环境下访问网页,这些情况下,如果我们只有 read、write API,那么可能一个读取命令需要 2 个小时才能返回,而一个写入命令需要 3 个小时执行时间,同时对用户来说,不论是看视频还是看网页,都无法接受这么长的白屏时间。 但为什么我们看视频和看网页的时候没有等待这么久?因为看网页时,并不是等待所有资源都加载完毕才能浏览与交互的,许多资源都是在首屏渲染后再异步加载的,视频更是如此,我们不会加载完 30GB 的电影后再开始播放,而是先下载 300kb 片头后就可以开始播放了。 无论是视频还是网页,为了快速响应内容,资源都是 在操作过程中持续加载的,如果我们设计一个支持这种模式的 API,无论资源大还是小都可以覆盖,自然比 read、wirte 设计更合理。 这种持续加载资源的行为就是 stream(流)。 什么是 streamstream 可以认为在形容资源持续流动的状态,我们需要把 I/O 场景看作一个持续的场景,就像把一条河的河水导流到另一条河。 做一个类比,我们在发送 http 请求、浏览网页、看视频时,可以看作一个南水北调的过程,把 A 河的水持续调到 B 河。 在发送 http 请求时,A 河就是后端服务器,B 河就是客户端;浏览网页时,A 河就是别人的网站,B 河就是你的手机;看视频时,A 河是网络上的视频资源(当然也可能是本地的),B 河是你的视频播放器。 所以流是一个持续的过程,而且可能有多个节点,不仅网络请求是流,资源加载到本地硬盘后,读取到内存,视频解码也是流,所以这个南水北调过程中还有许多中途蓄水池节点。 将这些事情都考虑到一起,最后形成了 web stream API。 一共有三种流,分别是:writable streams、readable streams、transform streams,它们的关系如下: readable streams 代表 A 河流,是数据的源头,因为是数据源头,所以只可读不可写。 writable streams 代表 B 河流,是数据的目的地,因为要持续蓄水,所以是只可写不可读。 transform streams 是中间对数据进行变换的节点,比如 A 与 B 河中间有一个大坝,这个大坝可以通过蓄水的方式控制水运输的速度,还可以安装滤网净化水源,所以它一头是 writable streams 输入 A 河流的水,另一头提供 readable streams 供 B 河流读取。 乍一看很复杂的概念,但映射到河水引流就非常自然了,stream 的设计非常贴近生活概念。 要理解 stream,需要思考下面三个问题: readable streams 从哪来? 是否要使用 transform streams 进行中间件加工? 消费的 writable streams 逻辑是什么? 还是再解释一下,为什么相比 read()、write(),stream 要多这三个思考:stream 既然将 I/O 抽象为流的概念,也就是具有持续性,那么读取的资源就必须是一个 readable 流,所以我们要构造一个 readable streams(未来可能越来越多函数返回值就是流,也就是在流的环境下工作,就不用考虑如何构造流了)。对流的读取是一个持续的过程,所以不是调用一个函数一次性读取那么简单,因此 writable streams 也有一定 API 语法。正是因为对资源进行了抽象,所以无论是读取还是消费,都被包装了一层 stream API,而普通的 read 函数读取的资源都是其本身,所以才没有这些额外思维负担。 好在 web streams API 设计都比较简单易用,而且作为一种标准规范,更加有掌握的必要,下面分别说明: readable streams读取流不可写,所以只有初始化时才能设置值: const readableStream = new ReadableStream({ start(controller) { controller.enqueue('h') controller.enqueue('e') controller.enqueue('l') controller.enqueue('l') controller.enqueue('o') controller.close() }}) controller.enqueue() 可以填入任意值,相当于是将值加入队列,controller.close() 关闭后,就无法继续 enqueue 了,并且这里的关闭时机,会在 writable streams 的 close 回调响应。 上面只是 mock 的例子,实际场景中,读取流往往是一些调用函数返回的对象,最常见的就是 fetch 函数: async function fetchStream() { const response = await fetch('https://example.com') const stream = response.body;} 可见,fetch 函数返回的 response.body 就是一个 readable stream。 我们可以通过以下方式直接消费读取流: readableStream.getReader().read().then({ value, done } => {}) 也可以 readableStream.pipeThrough(transformStream) 到一个转换流,也可以 readableStream.pipeTo(writableStream) 到一个写入流。 不管是手动 mock 还是函数返回,我们都能猜到,读取流不一定一开始就充满数据,比如 response.body 就可能因为读的比较早而需要等待,就像接入的水管水流较慢,而源头水池的水很多一样。我们也可以手动模拟读取较慢的情况: const readableStream = new ReadableStream({ start(controller) { controller.enqueue('h') controller.enqueue('e') setTimeout(() => { controller.enqueue('l') controller.enqueue('l') controller.enqueue('o') controller.close() }, 1000) }}) 上面例子中,如果我们一开始就用写入流对接,必然要等待 1s 才能得到完整的 'hello' 数据,但如果 1s 后再对接写入流,那么瞬间就能读取整个 'hello'。另外,写入流可能处理的速度也会慢,如果写入流处理每个单词的时间都是 1s,那么写入流无论何时执行,都比读取流更慢。 所以可以体会到,流的设计就是为了让整个数据处理过程最大程度的高效,无论读取流数据 ready 的多迟、开始对接写入流的时间有多晚、写入流处理的多慢,整个链路都是尽可能最高效的: 如果 readableStream ready 的迟,我们可以晚一点对接,让 readableStream 准备好再开始快速消费。 如果 writableStream 处理的慢,也只是这一处消费的慢,对接的 “水管” readableStream 可能早就 ready 了,此时换一个高效消费的 writableStream 就能提升整体效率。 writable streams写入流不可读,可以通过如下方式创建: const writableStream = new WritableStream({ write(chunk) { return new Promise(resolve => { // 消费的地方,可以执行插入 dom 等等操作 console.log(chunk) resolve() }); }, close() { // 可读流 controller.close() 时,这里被调用 },}) 写入流不用关心读取流是什么,所以只要关心数据写入就行了,实现写入回调 write。 write 回调需要返回一个 Promise,所以如果我们消费 chunk 的速度比较慢,写入流执行速度就会变慢,我们可以理解为 A 河流引水到 B 河流,就算 A 河流的河道很宽,一下就把河水全部灌入了,但 B 河流的河道很窄,无法处理那么大的水流量,所以受限于 B 河流河道宽度,整体水流速度还是比较慢的(当然这里不可能发生洪灾)。 那么 writableStream 如何触发写入呢?可以通过 write() 函数直接写入: writableStream.getWriter().write('h') 也可以通过 pipeTo() 直接对接 readableStream,就像本来是手动滴水,现在直接对接一个水管,这样我们只管处理写入就行了: readableStream.pipeTo(writableStream) 当然通过最原始的 API 也可以拼装出 pipeTo 的效果,为了理解的更深刻,我们用原始方法模拟一个 pipeTo: const reader = readableStream.getReader()const writer = writableStream.getWriter()function tryRead() { reader.read().then(({ done, value }) => { if (done) { return } writer.ready().then(() => writer.write(value)) tryRead() })}tryRead() transform streams转换流内部是一个写入流 + 读取流,创建转换流的方式如下: const decoder = new TextDecoder()const decodeStream = new TransformStream({ transform(chunk, controller) { controller.enqueue(decoder.decode(chunk, {stream: true})) }}) chunk 是 writableStream 拿到的包,controller.enqueue 是 readableStream 的入列方法,所以它其实底层实现就是两个流的叠加,API 上简化为 transform 了,可以一边写入读到的数据,一边转化为读取流,供后面的写入流消费。 当然有很多原生的转换流可以用,比如 TextDecoderStream: const textDecoderStream = TextDecoderStream() readable to writable streams下面是一个包含了编码转码的完整例子: // 创建读取流const readableStream = new ReadableStream({ start(controller) { const textEncoder = new TextEncoder() const chunks = textEncoder.encode('hello', { stream: true }) chunks.forEach(chunk => controller.enqueue(chunk)) controller.close() }})// 创建写入流const writableStream = new WritableStream({ write(chunk) { const textDecoder = new TextDecoder() return new Promise(resolve => { const buffer = new ArrayBuffer(2); const view = new Uint16Array(buffer); view[0] = chunk; const decoded = textDecoder.decode(view, { stream: true }); console.log('decoded', decoded) setTimeout(() => { resolve() }, 1000) }); }, close() { console.log('writable stream close') },})readableStream.pipeTo(writableStream) 首先 readableStream 利用 TextEncoder 以极快的速度瞬间将 hello 这 5 个字母加入队列,并执行 controller.close(),意味着这个 readableStream 瞬间就完成了初始化,并且后面无法修改,只能读取了。 我们在 writableStream 的 write 方法中,利用 TextDecoder 对 chunk 进行解码,一次解码一个字母,并打印到控制台,然后过了 1s 才 resolve,所以写入流会每隔 1s 打印一个字母: h## 1s latere## 1s laterl## 1s laterl## 1s laterowritable stream close 这个例子转码解码处理的还不够优雅,我们不需要将转码与解码写在流函数里,而是写在转换流中,比如: readableStream .pipeThrough(new TextEncoderStream()) .pipeThrough(customStream) .pipeThrough(new TextDecoderStream()) .pipeTo(writableStream) 这样 readableStream 与 writableStream 都不需要处理编码与解码,但流在中间被转化为了 Uint8Array,方便被其它转换流处理,最后经过解码转换流转换为文字后,再 pipeTo 给写入流,这样写入流拿到的就是文字了。 但也并不总是这样,比如我们要传输一个视频流,可能 readableStream 原始值就已经是 Uint8Array,所以具体要不要对接转换流看情况。 总结streams 是对 I/O 抽象的标准处理 API,其支持持续小片段数据处理的特性并不是偶然,而是对 I/O 场景进行抽象后的必然。 我们通过水流的例子类比了 streams 的概念,当 I/O 发生时,源头的流转换是有固定速度的 x M/s,目标客户端比如视频的转换也是有固定速度的 y M/s,网络请求也有速度并且是个持续的过程,所以 fetch 天然也是一个流,速度时 z M/s,我们最终看到视频的速度就是 min(x, y, z),当然如果服务器提前将 readableStream 提供好,那么 x 的速度就可以忽略,此时看到视频的速度是 min(y, z)。 不仅视频如此,打开文件、打开网页等等都是如此,浏览器处理 html 也是一个流的过程: new Response(stream, { headers: { 'Content-Type': 'text/html' },}) 如果这个 readableStream 的 controller.enqueue 过程被刻意处理的比较慢,网页甚至可以一个字一个字的逐步呈现:Serving a string, slowly Demo。 尽管流的场景如此普遍,但也没有必要将所有代码都改成流式处理,因为代码在内存中执行速度很快,变量的赋值是没必要使用流处理的,但如果这个变量的值来自于一个打开的文件,或者网络请求,那么使用流进行处理是最高效的。 讨论地址是:精读《web streams》· Issue ##363 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《一种 Hooks 数据流管理方案》","path":"/wiki/WebWeekly/前沿技术/《一种 Hooks 数据流管理方案》.html","content":"当前期刊数: 206 维护大型项目 OR UI 组件模块时,一定会遇到全局数据传递问题。 维护项目时,像全局用户信息、全局项目配置、全局功能配置等等,都是跨模块复用的全局数据。 维护 UI 组件时,调用组件的入口只有一个,但组件内部会继续拆模块,分文件,对于这些组件内模块而言,入口文件的参数也就是全局数据。 这时一般有三种方案: props 透传。 上下文。 全局数据流。 props 透传方案,因为任何一个节点掉链子都会导致参数传递失败,因此带来的维护成本与心智负担都特别大。 上下文即 useContext 利用上下文共享全局数据,带来的问题是更新粒度太粗,同上下文中任何值的改变都会导致重渲染。有一种较为 Hack 的解决方案 use-context-selector,不过这个和下面说到的全局数据流很像。 全局数据流即利用 react-redux 等工具,绕过 React 更新机制进行全局数据传递的方案,这种方案较好解决了项目问题,但很少有组件会使用。以前也有过不少利用 Redux 做局部数据流的方案,但本质上还是全局数据流。现在 react-redux 支持了局部作用域方案: import { shallowEqual, createSelectorHook, createStoreHook } from 'react-redux'const context = React.createContext(null)const useStore = createStoreHook(context)const useSelector = createSelectorHook(context)const useDispatch = createDispatchHook(context) 因此是机会好好梳理一下数据流管理方案,做一个项目、组件通用的数据流管理方案。 精读对项目、组件来说,数据流包含两种数据: 可变数据。 不可变数据。 对项目来说,可变数据的来源有: 全局外部参数。 全局项目自定义变量。 不可变数据来源有: 操作数据或行为的函数方法。 全局外部参数指不受项目代码控制的,比如登陆用户信息数据。全局项目自定义变量是由项目代码控制的,比如定义了一些模型数据、状态数据。 对组件来说,可变数据的来源有: 组件被调用时的传参。 全局组件自定义变量。 不可变数据来源有: 组件被调用时的传参。 操作数据或行为的函数方法。 对组件来说,被调用时的传参既可能是可变数据,也可能是不可变数据。比如传入的 props.color 可能就是可变数据,而 props.defaultValue、props.onChange 就是不可变数据。 当梳理清楚项目与组件到底有哪些全局数据后,我们就可以按照注册与调用这两步来设计数据流管理规范了。 数据流调用首先来看调用。为了同时保证使用的便捷与应用程序的性能,我们希望使用一个统一的 API useXXX 来访问所有全局数据与方法,并满足: {} = useXXX() 只能引用到不可变数据,包括变量与方法。 { value } = useXXX(state => ({ value: state.value })) 可以引用到可变数据,但必须通过选择器来调用。 比如一个应用叫 gaea,那么 useGaea 就是对这个应用全局数据的唯一调用入口,我可以在组件里这么调用数据与方法: const Panel = () => { // appId 是应用不可变数据,所以即使是变量也可以直接获取,因为它不会变化,也不会导致重渲染 // fetchData 是取数函数,内置发送了 appId,所以绑定了一定上下文,也属于不可变数据 const { appId, fetchData } = useGaea() // 主题色可能在运行时修改,只能通过选择器获取 // 此时这个组件会额外在 color 变化时重渲染 const { color } = useGaea(state => ({ color: state.theme?.color }))} 比如一个组件叫 Menu,那么 useMenu 就是这个组件的全局数据调用入口,可以这么使用: // SubMenu 是 Menu 组件的子组件,可以直接使用 useMenuconst SubMenu = () => { // defaultValue 是一次性值,所以处理时做了不可变处理,这里已经是不可变数据了 // onMenuClick 是回调函数,不管传参引用如何变化,这里都处理成不可变的引用 const { defaultValue, onMenuClick } = useMenu() // disabled 是 menu 的参数,需要在变化时立即响应,所以是可变数据 const { disabled } = useMenu(state => ({ disabled: state.disabled })) // selectedMenu 是 Menu 组件的内部状态,也作为可变数据调用 const { selectedMenu } = useMenu(state => ({ selectedMenu: state.selectedMenu }))} 可以发现,在整个应用或者组件的使用 Scope 中,已经做了一层抽象,即不关心数据是怎么来的,只关心数据是否可变。这样对于组件或应用,随时可以将内部状态开放到 API 层,而内部代码完全不用修改。 数据流注册数据流注册的时候,我们只要定义三种参数: dynamicValue: 动态参数,通过 useInput(state => state.xxx) 才能访问到。 staticValue: 静态参数,引用永远不会改变,可以直接通过 useInput().xxx 访问到。 自定义 hooks,入参是 staticValue getState setState,这里可以封装自定义方法,并且定义的方法都必须是静态的,可以直接通过 useInput().xxx 访问到。 const { useState: useInput, Provider } = createHookStore<{ dynamicValue: { fontSize: number } staticValue: { onChange: (value: number) => void }}>(({ staticValue }) => { const onCustomChange = React.useCallback((value: number) => { staticValue.onChange(value + 1) }, [staticValue]) return React.useMemo(() => ({ onCustomChange }), [onCustomChange])}) 上面的方法暴露了 Provider 与 useInput 两个对象,我们首先需要在组件里给它传输数据。比如我写的是组件 Input,就可以这么调用: function Input({ onChange, fontSize }) { return ( <Provider dynamicValue={{fontSize}} staticValue={{onChange}}> <InputComponent /> </Provider> )} 如果对于某些动态数据,我们只想赋初值,可以使用 defaultDynamicValue: function Input({ onChange, fontSize }) { return ( <Provider dynamicValue={{fontSize}} defaultDynamicValue={{count: 1}}> <InputComponent /> </Provider> )} 这样 count 就是一个动态值,必须通过 useInput(state => ({ count: state.count })) 才能取到,但又不会因为外层组件 Rerender 而被重新赋值为 1。所有动态值都可以通过 setState 来修改,这个后面再说。 这样所有 Input 下的子组件就可以通过 useInput 访问到全局数据流的数据啦,我们有三种访问数据的场景。 一:访问传给 Input 组件的 onChange。 因为 onChange 是不可变对象,因此可以通过如下方式访问: function InputComponent() { const { onChange } = useInput()} 二:访问我们自定义的全局 Hooks 函数 onCustomChange: function InputComponent() { const { onCustomChange } = useInput()} 三:访问可能变化的数据 fontSize。由于我们需要在 fontSize 变化时让组件重渲染,又不想让上面两种调用方式受到 fontSize 的影响,需要通过如下方式访问: function InputComponent() { const { fontSize } = useInput(state => ({ fontSize: state.fontSize }))} 最后在自定义方法中,如果我们想修改可变数据,都要通过 updateStore 封装好并暴露给外部,而不能直接调用。具体方式是这样的,举个例子,假设我们需要定义一个应用状态 status,其可选值为 edit 与 preview,那么可以这么去定义: const { useState: useInput, Provider } = createHookStore<{ dynamicValue: { isAdmin: boolean status: 'edit' | 'preview' }}>(({ getState, setState }) => { const toggleStatus = React.useCallback(() => { // 管理员才能切换应用状态 if (!getState().isAdmin) { return } setState(state => ({ ...state, status: state.status === 'edit' ? 'preview' : 'edit' })) }, [getState, setState]) return React.useMemo(() => ({ toggleStatus }), [toggleStatus])}) 下面是调用: function InputComponent() { const { toggleStatus } = useInput() return ( <button onClick={toggleStatus} /> )} 而且整个链路的类型定义也是完全自动推导的,这套数据流管理方案到这里就讲完了。 总结对全局数据的使用,最方便的就是收拢到一个 useXXX API,并且还能区分静态、动态值,并在访问静态值时完全不会导致重渲染。 而之所以动态值 dynamicValue 需要在 Provider 里定义,是因为当动态值变化时,会自动更新数据流中的数据,使整个应用数据与外部动态数据同步。而这个更新步骤就是通过 Redux Store 来完成的。 本文特意没有给出实现源码,感兴趣的同学可以自己实现一个试一试。 讨论地址是:精读《一种 Hooks 数据流管理方案》· Issue ##345 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《不再需要 JS 做的 5 件事》","path":"/wiki/WebWeekly/前沿技术/《不再需要 JS 做的 5 件事》.html","content":"当前期刊数: 238 关注 JS 太久,会养成任何功能都用 JS 实现的习惯,而忘记了 HTML 与 CSS 也具备一定的功能特征。其实有些功能用 JS 实现吃力不讨好,我们要综合使用技术工具,而不是只依赖 JS。 5 things you don’t need Javascript for 这篇文章就从 5 个例子出发,告诉我们哪些功能不一定非要用 JS 做。 概述使用 css 控制 svg 动画原文绘制了一个放烟花的 例子,本质上是用 css 控制 svg 产生动画效果,核心代码: .trail { stroke-width: 2; stroke-dasharray: 1 10 5 10 10 5 30 150; animation-name: trail; animation-timing-function: ease-out;}@keyframes trail { from, 20% { stroke-width: 3; stroke-dashoffset: 80; } 100%, to { stroke-width: 0.5; stroke-dashoffset: -150; }} 可以看到,主要使用 stroke-dasharray 控制线条实虚线的样式,再利用动画效果对 stroke-dashoffset 产生变化,从而实现对线条起始点进行位移,实现线条 “绘图” 的效果,且该 css 样式对 svg 绘制的路径是生效的。 sidebar可以完全使用 css 实现 hover 时才出现的侧边栏: nav { position: 'absolute'; right: 100%; transition: 0.2s transform;}nav:hover,nav:focus-within { transform: translateX(100%);} 核心在于 hover 时设置 transform 属性可以让元素偏移,且 translateX(100%) 可以位移当前元素宽度的身位。 另一个有意思的是,如果使用 TABS 按键聚焦到 sidebar 内元素也要让 sidebar 出来,可以直接用 :focus-within 实现。如果需要 hover 后延迟展示可以使用 transition-delay 属性。 sticky position使用 position: sticky 来黏住一个元素: .square { position: sticky; top: 2em;} 这样该元素会始终展示在其父容器内,但一旦其出现在视窗时,当 top 超过 2em 后就会变为 fixed 定位并保持原位。 使用 JS 判断还是挺复杂的,你得设法监听父元素滚动,并且在定位切换时可能产生一些抖动,因为 JS 的执行与 CSS 之间是异步关系。但当我们只用 CSS 描述这个行为时,浏览器就有办法解决转换时的抖动问题。 手风琴菜单使用 <details> 标签可以实现类似一个简易的折叠手风琴效果: <details> <summary>title</summary> <p>1</p> <p>2</p></details> 在 <details> 标签内的 <summary> 标签内容总是会展示,且点击后会切换 <details> 内其他元素的显隐藏。虽然这做不了特殊动画效果,但如果只为了做一个普通的展开折叠功能,用 HTML 标签就够了。 暗色主题虽然直觉上暗色主题好像是一种定制业务逻辑,但其实因为暗色主题太过于普遍,以至于操作系统和浏览器都内置实现了,而 CSS 也实现了对应的方法判断当前系统的主题到底是亮色还是暗色:prefers-color-scheme。 所以如果系统要实现暗色系主题,最好可以和操作系统设置保持一致,这样用户体验也会更好: @media (prefers-color-scheme: light) { /** ... */}@media (prefers-color-scheme: dark) { /** ... */}@media (prefers-color-scheme: no-preference) { /** ... */} 如果使用 Checkbox 勾选是否开启暗色主题,也可以仅用 CSS 变量判断,核心代码是: ##checkboxId:checked ~ .container { background-color: black;} ~ 这个符号表示,selector1 ~ selector2 时,为选择器 selector1 之后满足 selector2 条件的兄弟节点设置样式。 精读除了上面例子外,笔者再追加几个例子。 幻灯片滚动幻灯片滚动即每次滚动有固定的步长,把子元素完整的展示在可视区域,不可能出现上下或者左右两个子元素各出现一部分的 “割裂” 情况。 该场景除了用浏览器实现幻灯片外,在许多网站首页也被频繁使用,比如将首页切割为 5 个纵向滚动的区块,每个区块展示一个产品特性,此时滚动不再是连续的,而是从一个区块到另一个区块的完整切换。 其实这种效果无需 JS 实现: html { scroll-snap-type: y mandatory;}.child { scroll-snap-align: start;} 这样便将页面设置为精准捕捉子元素滚动位置,在滚轮触发、鼠标点击滚动条松手或者键盘上下按键时,scroll-snap-type: y mandatory 可以精准捕捉这一垂直滚动行为,并将子元素完全滚动到可视区域。 颜色选择器使用 HTML 原生就能实现颜色选择器: <input type="color" value="##000000"> 该选择器的好处是性能、可维护性都非常非常的好,甚至可以捕捉桌面的颜色,不好的地方是无法对拾色器进行定制。 总结关于 CSS 可以实现哪些原本需要 JS 做的事,有很多很好的文章,比如: youmightnotneedjs。 You-Dont-Need-JavaScript。 以及本文简介里介绍的 5 things you don’t need Javascript for。 但并不是读了这些文章,我们就要尽量用 CSS 实现所有能做的事,那样也没有必要。CSS 因为是描述性语言,它可以精确控制样式,但却难以精确控制交互过程,对于标准交互行为比如幻灯片滑动、动画可以使用 CSS,对于非标准交互行为,比如自定义位置弹出 Modal、用 svg 绘制完全自定义路径动画尽量还是用 JS。 另外对于交互过程中的状态,如果需要传递给其他元素响应,还是尽量使用 JS 实现。虽然 CSS 伪类可以帮我们实现大部分这种能力,但如果我们要监听状态变化发一个请求什么的,CSS 就无能为力了,或者我们需要非常 trick 的利用 CSS 实现,这也违背了 CSS 技术选型的初衷。 最后,能否在合适的场景选择 CSS 方案,也是技术选型能力的一种,不要忘了 CSS 适用的领域,不要什么功能都用 JS 实现。 讨论地址是:精读《不再需要 JS 做的 5 件事》· Issue ##413 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《什么是 LOD 表达式》","path":"/wiki/WebWeekly/前沿技术/《什么是 LOD 表达式》.html","content":"当前期刊数: 215 LOD 表达式在数据分析领域很常用,其全称为 Level Of Detail,即详细级别。 精读什么是详细级别,为什么需要 LOD?你一定会有这个问题,我们来一步步解答。 什么是详细级别可以尝试这么发问:你这个数据有多详细? 得到的回答可能是: 数据是汇总的,抱歉看不到细节,不过如果您正好要看总销量的话,这儿都给您汇总好了。。 详细?这直接就是原始表数据,30 亿条,这够详细了吧?如果觉得还不够详细,那只好把业务过程再拆分一下重新埋点了。 详细程度越高,数据量越大,详细程度越低,数据就越少,就越是汇总的数据。 人很难在详细程度很高的 30 亿条记录里看到有价值的信息,所以数据分析的过程也可以看作是 对数据汇总计算的过程,这背后数据详细程度在逐渐降低。 BI 工具的详细级别如果没有 LOD 表达式,一个 BI 查询的详细程度是完全固定的: 如果表格拖入度量,没有维度,那就是最高详细级别,因为最终只会汇总出一条记录。 如果折线图拖入维度,那结果就是根据这个维度内分别聚合度量,数据更详细了,详细粒度为当前维度,比如日期。 如果我们要更详细的数据,就需要在维度上拖入更多字段,直到达到最详细的明细表级别的粒度。然而同一个查询不可能包含不同详细粒度,因为详细粒度由维度组合决定,不可改变,比如下面表格的例子: 行:国家 省 城市列:GDP 这个例子中,详细级别限定在了城市这一级汇总,城市下更细粒度的数据就看不到了,每一条数据都是城市粒度的,我们不可能让查询结果里出现按照国家汇总的 GDP,或者看到更详细粒度的每月 GDP 信息,更不可能让城市粒度的 GDP 与国家粒度 GDP 在一起做计算,算出城市 GDP 在国家中占比。 但是,类似上面例子的需求是很多的,而且很常见,BI 工具必须想出一种解法,因此诞生了 LOD:LOD 就是一种表达式,允许我们在一个查询中描述不同的详细粒度。 从表达式计算来看详细级别表达式计算必须限定在同样的详细粒度,这是铁律,为什么呢? 试想一下下面两张不同详细粒度的表: 总销售额: 10000 各城市销售额: 北京 3000上海 7000 如果我们想在各城市销售额中,计算贡献占比,那么就要写出 [各城市销售额] / [总销售额] 的计算公式,但显然这是不可能的,因为前者有两条数据,后者只有一条数据,根本无法计算。 我们能做的一定是数据行数相同,那么无论是 IF ELSE、CASE WHEN,还是加减乘除都可以按照行粒度进行了。 LOD 给了我们跨详细粒度计算的能力,其本质还是将数据详细粒度统一,但我们可以让某列数据来自于一个完全不同详细级别的计算: 城市 销售额 总销售额北京 3000 10000上海 7000 10000 如图表,LOD 可以把数据加工成这样,即虽然总销售额与城市详细粒度不同,但还是添加到了每一行的末尾,这样就可以进行计算了。 因此 LOD 可以按照任意详细级别进行计算,将最终产出 “贴合” 到当前查询的详细级别中。 LOD 表达式分为三种能力,分别是 FIXED、INCLUDE、EXCLUDE。 FIXED{ fixed [省份] : sum([GDP]) } 按照城市这个固定详细粒度,计算每个省份的 DGP,最后合并到当前详细粒度里。 假如现在的查询粒度是省份、城市,那么 LOD 字段的添加逻辑如下图所示: 可见,本质是两个不同 sql 查询后 join 的结果,内部的 sum 表示在 FIXED 表达式内的聚合方式,外部的 sum 表示,如果 FIXED 详细级别比当前视图详细级别低,应该如何聚合。在这个例子中,FIXED 详细级别较高,所以 sum 不起作用,换成 avg 效果也相同,因为合并详细级别是,是一对多关系,只有合并时多对一关系才需要聚合。 最外层聚合方式一般在 INCLUDE 表达式中发挥作用。 EXCLUDE{ exclude [城市] : sum([GDP]) } 在当前查询粒度中,排除城市这个粒度后计算 GDP,最后合并到当前详细粒度中。 假如现在的查询粒度是省份、城市、季节,那么 LOD 字段的添加逻辑如下图所示: 如图所示,EXCLUDE 在当前视图详细级别的基础上,排除一些维度,所得到的详细级别一定会更高。 INCLUDE{ include [城乡] : avg([GDP]) } 在当前查询粒度中,额外加上城乡这个粒度后计算 GDP,最后合并到当前详细粒度中。 这类的例子比较难理解,且在 sum 情况下一般无实际意义,因为计算结果不会有差异,必须在类似 avg 场景下才有意义,我们还是结合下图来看: 这就是 avg 算不准的问题,即不同详细级别计算的平均值是不同的,但 sum、count 等不会随着详细级别变化而影响计算结果,所以当涉及到 avg 计算时,可以通过 INCLUDE 表达式指定计算的详细级别,以保证数据口径准确性。 LOD 字段怎么用除了上面的例子中,直接查出来展示给用户外,LOD 字段更常用的是作为中间计算过程,比如计算省份 GDP 占在国内占比。因为 LOD 已经将不同详细粒度计算结果合并到了当前的详细粒度里,所以如下的计算表达式: sum([GDP]) / sum({ fixed [国家] : sum([GDP]) }) 看似是跨详细粒度计算,其实没有,实际计算时还是一行一行来算的,后面的 LOD 表达式只是在逻辑上按照指定的详细粒度计算,但最终会保持与当前视图详细粒度一致,因此可以参与计算。 我们后面会继续解读 tableau 整理的 Top 15 LOD 表达式业务场景,更深入的理解 LOD 表达式。 总结LOD 表达式让你轻松创建 “脱离” 当前视图详细级别的计算字段。 或许你会疑惑,为什么不主动改变当前视图详细级别来实现同样的效果?比如新增或减少一个维度。 原因是,LOD 往往用于跨详细级别的计算,比如算部分相对总体的占比,计算当条记录是否为用户首单等等,更多的场景会在下次精读中解读。 讨论地址是:精读《什么是 LOD 表达式》· Issue ##365 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《低代码逻辑编排》","path":"/wiki/WebWeekly/前沿技术/《低代码逻辑编排》.html","content":"当前期刊数: 197 逻辑编排是用可视化方式描述逻辑,在一般搭建场景中用于代替逻辑描述部分。 更进一步的逻辑编排是前后端逻辑混排,一般出现在一站式 paas 平台,今天就介绍一个全面实现了逻辑编排的 paas 工具 node-red,本周精读的内容是其介绍视频:How To Create Your First Flow In Node-RED,介绍了如果利用纯逻辑编排实现一个天气查询应用,以及部署与应用迁移。 概述想要在本地运行 Node-RED 很简单,只要下面两条命令: npm install -g --unsafe-perm node-rednode-red 之后你就可以看到这个逻辑编排界面了: 我们可以利用这些逻辑节点构建前端网站、后端服务,以及大部分开发工作。光这么说还比较抽象,我们接下来会详细介绍每个逻辑节点的作用,让你了解这些逻辑节点是如何规划设计的,以及逻辑编排到底是怎么控制研发规范来提高研发效率的。 Node-RED 截止目前共有 42 个逻辑节点,按照通用、功能、网络、序列、解析、存储分为六大类。 所有节点都可能有左右连接点,左连接点是输入,右连接点是输出,特殊节点可能有多个输入或多个输出,其实对应代码也不难理解,就是入参和出参。 下面依次介绍每个节点的功能。 通用通用节点处理通用逻辑,比如手动输入数据、调试、错误捕获、注释等。 inject 手动输入节点。可以定期产生一些输入,由下一个节点消费。 举个例子,比如可以定期产生一些固定值,如这样一个这个对象: return { payload: new Date(), topic: "abc",}; 当然这里是用 UI 表单配置的: 之后就是消费,几乎后面任何节点都可以消费,比如利用 change 节点来设置一些环境变量时,或者利用 template 节点设置 html 模版时,都可以拿到这里输入的变量。如果在模版里,变量通过 {{msg.payload}} 访问,如果是其它表单,甚至可以通过下拉框直接枚举选择。 然而这个节点往往用来设置静态变量,更多的输入情况是来自其它程序或者用户的,比如 http in,这个后面会讲到。其实通过这种组合关系,我们可以把任意节点的输入从生产节点替换为 inject 节点,从而实现一些 mock 效果,而 inject 节点也支持配置定时自动触发: debug 用来调试的,当任何输出节点连接到 debug 的输入后,将会在控制台打印出输出信息,方便调试。 比如我们将 inject 的输入连上 debug 的输入,就可以在触发数据后在控制台看到打印结果: 当然如果你把输入连接到 debug,那么原有逻辑就中断了,然而任何输出节点都可以无限制的输出给其它节点,你只要同时把输出连接到 debug 与功能节点就行了: complete 监听某些节点触发完成动作。通过这个节点,我们可以捕获任意节点触发的动作,可以接入 debug 节点打印日志,或者 function 节点处理一下逻辑。 可以监听全部节点,也可以用可视化方式选择要监听哪些节点: catch 错误捕获节点,当任何或指定节点触发错误时输出,输出的格式为: error.message 字符串错误消息。error.source.id 字符串引发错误的节点的ID。error.source.type 字符串引发错误的节点的类型。error.source.name 字符串引发错误的节点的名称。(如果已设置) 其实每个节点都有固定输出格式,这些固定格式限制了开发灵活度,但熟练掌握后可以大大提升开发效率,因为所有同类型节点格式都是一样的,这是逻辑编排带来规则约束的好处。 status 监听节点状态变化。 link in 只能连接 link out。link in、link out 就像一个传送门,用来整理逻辑编排节点,使之看上去易于维护。 比如下面的例子,在一个天气 http in 服务后,穿插了许多逻辑处理节点,有处理响应 html 内容的 template 节点,也有处理请求查询城市天气的 http request 服务,整体逻辑虽然聚合,但比较杂乱: 较好的方式是分类,即类似代码开发中的模块化行为,将天气服务导出,其他任何用到的模块直接导入,这个导入动作就是通过 link in 实现的,link out -> link in 只是一个空间位置的变换,传输值是不会变的: 这样模块看起来清晰了许多,如果要知道各个 “传送门” 见连接关系,只要鼠标点击其中一个就可以给出提示,看起来十分方便: link out 和 link in 成对出现,用来导出输入值,后面对接 link out 可以像传送门一样将值传送过去,在视觉上不会形成连接线。 comment 注释,配合 link 系列使用,可以让逻辑编排 UI 更易于维护。 结合原视频的例子,对于天气服务,有创建环境变量逻辑,有查询逻辑,其中查询天气还分为查询当前天气、连续 5 天天气、查询国家信息,我们可以在 UI 上讲每块逻辑分组,并利用 comment 组件标记好注释,方便阅读: 功能功能型节点,一般用于处理业务逻辑,所以包含了基础的 if else、js 代码、模版处理等等功能模块。 function 最核心的 js 函数模块,你可以用它做任何事: 其输入会传导到 msg 对象,可以通过代码修改 msg 对象后再通过输出节点传导出去。 当然也可以访问和修改节点、流程、全局变量,这个在 change 节点里介绍。 switch 对应代码的 switch,只是用起来更加方便,因为我们可以根据不同 case 导出不同的节点: 注意看上图,因为有三条分支,所以节点的导出项也变成了三个,我们可以根据不同逻辑走不同的连接: change 用来改变环境变量。环境变量分为三种,分别是当前节点、流程(画布)、全局(跨应用)。也就是说,变量可以存储在某个节点上,也可以存储在整个画布上,也可以跨画布存储在全局。 访问参数分别为 msg.、flow.、global.,设置这些参数后,就像全局变量一样,任何节点都可以在任何地方使用,比较方便。 比如应用固定了一些 URL 地址,直接把一串字符串写死在某个 http in 节点里并不明智,因为后面的 html 或者其它节点里可能会访问它,一旦你进行修改,影响面会非常广,因此最好将其设置为全局变量,在节点中通过变量方式访问: 其实在控制台,可以看到这三种变量的值: 当我们利用 change 节点赋值后,可以通过调试面板查看不同作用域全局变量的值: range 区间映射,将一个范围的值映射到另一个范围。其实通过 function 模块也能完成,只是因为比较常用所以封装了一个特殊节点。其实用户也可以自己封装节点,具体方式可以参考 官方文档。 上图很容易理解,比如数据分析中归一化就可以用这个节点实现。 template 以模版方式生成字符串或 json。 其实本质上也可以被 function 代替,只是用来写模版的话有高亮,维护起来比较方便。 内置了 mustache 模版语法,通过 {{}} 方式使用变量。 比如我们通过 inject 注入一个变量给 template,并通过 debug 打印,流程是这样的: 其中 inject 是这么配置的: 可以看到,将 msg.name 设置为一个字符串,然后通过 template 访问 name: delay 延迟发消息,一个快捷的工具,可以放在任何输入与输出中间,比如让上面的例子中,inject 触发后 5s 再打印结果,可以这么配置: trigger 一个消息触发器,相比 inject,可以更灵活的设置何时重新触发。 从配置可以看出,首先和 inject 一样发送一条消息,然后可以等待,或者等待被重置,或者周期性触发(这样就和 inject 一样),其中 “发送第二条消息到单独的输出” 和 switch 一样会多一个输出口。 然后有重置条件,即 payload 为什么值时重置。 通过这个组件可以看出来,其实每个节点都可以用 function 节点实现,只不过通过定制一个节点,可以用 UI 而非代码的方式配置,使用起来更方便。 exec 执行系统命令,比如 ls 等,这个在系统后台执行而非前端,所以是一个相当危险的节点。 我们可以在配置中写入任何命令: rbe 异常报告节点(Report by Exception),比如说当输入变化时进行阻塞。 网络用于创建网络服务,比如 http、socket、tcp、udp 等等,因为其它都不常用,这次仅介绍 http 服务。 http in 创建一个 http 服务,可以是任何接口或者 web 服务。 当你把 Method 设置为 post,连接到 http response 就创建了后端接口;当设置为 get 请求,并连接 template 写上 html 模版,并连接到 http response 就创建了 web 服务。 虽然这种方式创建 web 服务难以使用 react 或 vue 框架,不过自定义节点还是为其创造了可能性,或许真的可以把前端模块化文件定义为节点相互串联。 http response http 返回,只能对接 http in 的输出,总是与 http in 成对使用。 如果只用了 http in 但没有用 http response,就相当于后端代码里处理了请求,但没有调用类似: res.send("hello word"); 来向客户端发送内容。 http request 与 http in 创建一个 http 服务不同,http request 直接发送一个网络请求并将返回值导入到输出节点。 视频中获取天气的例子,就用了 http request 发起请求获取天气信息: 不难看出,发送请求后,又使用了 function 节点处理返回结果。不过在逻辑编排中还是期望少使用 function 节点,因为除非有很好的命名,否则难以看出来节点含义,如果 function 处理内容过多或者 function 区块过多,就失去了逻辑编排的意义。 序列序列是对数组进行处理的节点。 split 对应代码的 split,将字符串变为数组。 join 对应代码的 join,一般与 split 配合使用,方便处理字符串。 sort 对应代码 sort,只能根据 key 做简单的升序降序处理,对于简单场景比较方便,但对于复杂场景可能还会使用 function 节点代替。 batch 批量接收输入流后,根据数量进行打包后统一输出,等于批量打包,可以按照数量或者时间间隔进行分组: 解析 很容易理解,专门处理上述格式的数据,并按照数据特征输出,比如 csv 数据,可以每行一条消息的方式输出,或者打包为一个大数组以一条消息输出。 当然也可以被 function 节点代替,那么解析方式与输出方式都可以自定义。 存储持久化存储,一般存储为文件。 file 输出为文件。 file in 以文件作为输入,并将文件结果作为输出。 watch 监听目录或文件的修改。 精读看了上面 node-red 功能后,相信你对逻辑编排已经有较为体系化的认识了。 逻辑编排的目的是为了让非研发人群也可以快速上手研发工作,因此注定是为 paas 工具服务的,而逻辑编排到底好不好用,取决于节点功能是否完备,以及各节点之间通信是否顺畅,像 node-red 逻辑编排方案,在完备性上做的较为成熟,可以说只要熟练掌握了几个核心节点规则,使用起来还是非常提效的。 逻辑编排也有天然缺点,就是当所有节点都退化为 function 节点后,会存在两个问题: 所有节点都是 function 节点,即便有说明,但内部实现逻辑非常自由,导致逻辑编排无法起到约束输入输出的作用。 退化到代码函数式调用,本质上与写代码无异。逻辑编排之所以提效,很大程度上是我要的业务逻辑刚好与节点功能匹配,以低成本 UI 配置的方式实现效率才高。 然而这也是有解决方法的,如果你的业务无法被现有的逻辑编排节点满足,你可以尝试抽象一下,自己梳理出业务常用的节点,并用合理的配置封装,只要常用业务逻辑可以被封装为逻辑节点,逻辑编排就还有为业务提效的空间。 总结逻辑编排是一种极端,即用 UI 方式描述通用业务逻辑,降低非专业开发人员的上手门槛。通过对 node-red 的分析可以发现,一个较为完备的逻辑编排系统还是能带来价值的。 然而针对非专业开发人员降本提效还有一种极端,就是完全代码化,但是把代码模块化、函数库、工具链甚至低代码平台建设的非常完备,以至于写代码的效率根本不低,这条路走到极致也不错,因为既然要深入开发系统,同样是投入时间学习,为什么学习写代码就一定比学习拖拽 UI 效率低呢?如果有高度封装的函数与工具辅助,效率不见得比 UI 拖拽来的低。 然而 node-red 在创建前端 UI 的模版上还可以再增强一下,把 template 从节点升级为 UI 搭建画布,逻辑编排仅用来处理逻辑,这样对大型全栈项目的前端开发体验会更好。 讨论地址是:精读《低代码逻辑编排》· Issue ##319 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《使用 CSS 属性选择器》","path":"/wiki/WebWeekly/前沿技术/《使用 CSS 属性选择器》.html","content":"当前期刊数: 81 1 引言虽然现在 Css Module 与 Css-in-js 更流行,但使用它们会导致过分依赖 滥用 class 做唯一定位,违背了 Css 选择器的初衷。 本期精读的文章是:attribute-selectors-splicing-html-dna-css,带你重新理解强大的 Css 选择器。 2 概要Css Module 与 Css-in-js 大部分场景使用 className 作为选择器,那么本文以选择器为重点,看看选择器有哪些实用的用法。 属性选择器如果你想选择包含 title 属性的 div: div[title] 选择包含 title 属性的子元素,只需要加个空格: div [title] 选择 title 内容是 dna 的元素: div[title="dna"] 选择 title 属性包含 dna 单词的元素: 注意 dna 需要是单词,也就是用空格分割,比如 “my beautiful dna” 或 “mutating dna is fun!” div[title~="dna"] 和正则类似,选择 title 属性中,以 dna 结尾的元素: div[title$="dna"] 以 dna 开头: div[title^="dna"] 如果希望选择 dna 或 dna-zh,但不希望匹配 dnaer,可以: 这种场景一般用在国际化,比如 en en-us 就可以用 |="en" div[title|="dna"] 只要包含 dna 这三个字符就选中: div[title*="dna"] 真的很像正则,你可以用 i 标识匹配时大小写不敏感: div[title*="dna" i] 如果你想找到一个 a 标签,拥有 title 属性并且 className 以 genes 结尾,可以这样: a[title][class$="genes"] 获取标签的值可以用 attr 标识符拿到当前选择器选中元素的属性,比如当 hover 状态时,在文字尾部显示其 title 属性: .joke:hover:after { content: "Answer:" attr(title); display: block;} 其它用法本文还介绍了一些实用技巧,比如 根据输入框类型设置样式 input[type="email"] { color: papayawhip;}input[type="tel"] { color: thistle;} 改变下载标签的 icon a[download][href$="pdf"]:after { content: url(pdf-icon.svg);} 当然也可以选中一些老代码进行样式重写,比如: <div bgcolor="##000000" color="##FFFFFF">Old, holey genes</div> div[bgcolor="##000000"] { /*override*/ background-color: ##222222 !important;} 不过这种用法要谨慎,写的越多越难以维护。 结合一些新标签功能 比如 details 标签是 html 原生的手风琴折叠组件: <details> <summary>List of Genes</summary> Roddenberry Hackman </details> 我们可以使用属性选择器,定义其打开时的样式: details[open] { background-color: hotpink;} 为没有 async 标记的 script 标签着色,算是友情提示哪儿有错误: script[src]:not([async]) { display: block; width: 100%; height: 1em; background-color: red;}script:after { content: attr(src);} 为 JS 事件着色,比如触发的鼠标事件可以作为选择器: [OnMouseOver] { color: burlywood;}[OnMouseOver]:after { content: "JS: " attr(OnMouseOver);} 选中隐藏元素: [hidden],[type="hidden"] { display: block;} 还有更多就不一一列举了,感兴趣的读者可以跳转到原文继续阅读。大部分内容其实都写在了 w3school 选择器参考手册,只是结合一篇文章来读,可以理解得更深刻,同时文章里确实有一些新鲜的选择器,比如 JS 事件选择器,HTML5 属性标签选择器等等。 3 精读这篇文章确实说明了 Css 选择器的强大性,但回到 css module 或者 css-in-js 的工程代码里,我们往往难以做太多的实践,有如下几个原因: 一直在担心的 DOM 结构变动业务开发中,大量需求涌入,也许过了一周,DOM 结构就已经面目全非了,而且就算是一个普通的圣杯布局,可能老版本用 Table 布局,后面进来一个年轻小伙子直接用 div + flex 重构了,你会担心之前写的 table 选择器在某一天全部失效。 也许今天的 div 选择器,明天因为语义化改造就换成了 article 标签。 最大原因是 一种视觉界面对应的实现方式太多,不仅标签可以各异,css 属性还有 table、block、flex、grid 可选,同时 grid 属性还会导致视觉结构与 DOM 结构不完全对应。 如果你今天用 css 选择器做了一套完全贴合现在 DOM 结构的 css 文件,这个 css 文件也许是后面 dom 结构改动的噩梦。 你敢做全局样式覆盖吗我们排除标签,仅对属性做全局覆盖,的确可以部分绕开 DOM 结构的限制,但是这样的全局样式覆盖,不同的人有不同看法。 小明的团队非常懂得 css 运用,他们每天都会花一个小时讨论项目的 css 架构,并对通用需求样式做了抽象,并且每个人都很认可这个方案,在他们的团队,一个非常酷炫的按钮与动画效果,通过 <button animate /> 就可以完成,页面间交互非常流畅,用户体验统一,前端代码也非常简洁和优雅。 小白的团队水平参差不齐,有人永远只使用 table 布局,有人却总想将一些试验阶段 css 属性用在生产环境,小白自己抽象了一个全局样式 css 文件,可团队没什么时间沟通,甚至有人私下也注入了不少全局 css 样式,总有人抱怨自己的样式被全局覆盖了,最后小白甚至不得不在自己页面入口处写上 *: unset 清空各种奇怪的全局样式干扰,他想清空那该死的全局 css 样式文件,但他知道这样做带来的是更大的灾难。 可以看到,并不是每个团队都适合做全局样式覆盖。 JS 模块化思维的影响为什么一个项目安装了几百个 npm 三方包,却依然可以正常运行?因为好的三方包都是遵守模块化的,同时也不产生副作用,这样被使用时的效果就可以被预期,试想一下几百个 npm 包里同时定义了不同规范的全局 css 覆盖,你的项目会成为什么样。 当然 js 与 css 是不适合放在一起比较的,css 大多是业务级别的,也就是能写 css 只有做业务的你,第三方包一般是不会提供 css 定义干扰你的项目的。 然而大部分 UI 组件库是自带样式的,他们有自己的设计哲学,但为什么现在你会反感,而当初使用 Bootstrap 不会? 使用 Bootstrap 的时代,Bootstrap 一般是作为项目第一个依赖安装的,我们明确知道它会注入全局样式。我们会泡在他的官方文档目录,一条条理解他做的全局样式规则,他提供的各种 class。 然而现在是一个 Css-in-js 的时代,或者至少是 css-in-npm 的时代,什么都用 npm 装,什么都是模块化的,很多时候我们用一个 UI 组件仅仅是为了在某一处地方使用,而不想接受他带来的全局样式污染,视觉设计哲学,更不想看他的 css 文档。所以好的组件库往往 css 使用的很收敛,尽量不要对用户项目环境造成影响。 如果你项目的样式已经被不得不安装的第三方包全局覆盖得面目全非,每一次对全局样式修改都如履薄冰,可能你会比较反感 css 选择器,你会推崇更安全的 css modules,或甚至是 css-in-js,让每个组件的 className 都唯一,做到标签粒度的隔离。 4 总结笔者认为,在一个确定的环境中,比如一个组件,一个独立负责的模块,是比较适合用 css 选择器的,这样可以让样式代码更易读,DOM 结构更清爽。但请一定注意作用域,如果不是大家一起达成的共识,最好不要放到全局样式中。 就算项目的风格非常明确,a 标签一定要用红色,在把这条规则放到全局样式之前,请思考一下,这样会不会破坏了某个用 a 标签模拟按钮的组件库的样式? css 属性选择器的强大功能,需要有良好的项目管理做支撑,或者通过技术手段比如 shadow dom 做支撑。不过 shadow dom 的支持程度 现在仍然很低,所以使用编译工具做的隔离,在某种程度上模拟了 Css 选择器,承担了 Css 选择器 + shadow dom 的功能。 一切样式都用 className 控制,也许是 shadow dom 出来前的一种妥协方案,这篇文章更多是在描述 Css 选择器设计之美,但需要我们理性去使用。 讨论地址是:精读《使用 CSS 属性选择器》 · Issue ##113 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《依赖注入简介》","path":"/wiki/WebWeekly/前沿技术/《依赖注入简介》.html","content":"当前期刊数: 256 精读文章:Dependency Injection in JS/TS – Part 1 概述依赖注入是将函数内部实现抽象为参数,使我们更方便控制这些它们。 原文按照 “如何解决无法做单测的问题、统一依赖注入的入口、如何自动保证依赖顺序正确、循环依赖怎么解决、自上而下 vs 自下而上编程思维” 的思路,将依赖注入从想法起点,到延伸出来的特性连贯的串了起来。 如何解决无法做单测的问题如果一个函数内容实现是随机函数,如何做测试? export const randomNumber = (max: number): number => { return Math.floor(Math.random() * (max + 1));}; 因为结果不受控制,显然无法做单测,那将 Math.random 函数抽象到参数里问题不就解决了! export type RandomGenerator = () => number;export const randomNumber = ( randomGenerator: RandomGenerator, max: number): number => { return Math.floor(randomGenerator() * (max + 1));}; 但带来了一个新问题:这样破坏了 randomNumber 函数本身接口,而且参数变得复杂,不那么易用了。 工厂函数 + 实例模式为了方便业务代码调用,同时导出工厂函数和方便业务用的实例不就行了! export type RandomGenerator = () => number;export const randomNumberImplementation = ({ randomGenerator }: Deps) => (max: number): number => { return Math.floor(randomGenerator() * (max + 1)); };export const randomNumber = (max: number) => randomNumberImplementation(Math.random, max); 这样乍一看是不错,单测代码引用 randomNumberImplementation 函数并将 randomGenerator mock 为固定返回值的函数;业务代码引用 randomNumber,因为内置了 Math.random 实现,用起来也是比较自然的。 只要每个文件都遵循这种双导出模式,且业务实现除了传递参数外不要有额外的逻辑,这种代码就能同时解决单测与业务问题。 但带来了一个新问题:代码中同时存在工厂函数与实例,即同时构建与使用,这样职责不清晰,而且因为每个文件都要提前引用依赖,依赖间容易形成循环引用,即便上从具体函数层面看,并没有发生函数间的循环引用。 统一依赖注入的入口用一个统一入口收集依赖就能解决该问题: import { secureRandomNumber } from "secureRandomNumber";import { makeFastRandomNumber } from "./fastRandomNumber";import { makeRandomNumberList } from "./randomNumberList";const randomGenerator = Math.random;const fastRandomNumber = makeFastRandomNumber(randomGenerator);const randomNumber = process.env.NODE_ENV === "production" ? secureRandomNumber : fastRandomNumber;const randomNumberList = makeRandomNumberList(randomNumber);export const container = { randomNumber, randomNumberList,};export type Container = typeof container; 上面的例子中,一个入口文件同时引用了所有构造函数文件,所以这些构造函数文件之间就不需要相互依赖了,这解决了循环引用的大问题。 然后我们依次实例化这些构造函数,传入它们需要的依赖,再用 container 统一导出即可使用,对使用者来说无需关心如何构建,开箱即用。 但带来了一个新问题:统一注入的入口代码要随着业务文件的变化而变化,同时,如果构造函数之间存在复杂的依赖链条,手动维护起顺序将是一件越来越复杂的事情:比如 A 依赖 B,B 依赖 C,那么想要初始化 C 的构造函数,就要先初始化 A 再初始化 B,最后初始化 C。 如何自动保证依赖顺序正确那有没有办法固定依赖注入的模板逻辑,让其被调用时自动根据依赖关系来初始化呢?答案是有的,而且非常的漂亮: // container.tsimport { makeFastRandomNumber } from "./fastRandomNumber";import { makeRandomNumberList } from "./randomNumberList";import { secureRandomNumber } from "secureRandomNumber";const dependenciesFactories = { randomNumber: process.env.NODE_ENV !== "production" ? makeFastRandomNumber : () => secureRandomNumber, randomNumberList: makeRandomNumberList, randomGenerator: () => Math.random,};type DependenciesFactories = typeof dependenciesFactories;export type Container = { [Key in DependenciesFactories]: ReturnValue<DependenciesFactories[Key]>;};export const container = {} as Container;Object.entries(dependenciesFactories).forEach(([dependencyName, factory]) => { return Object.defineProperty(container, dependencyName, { get: () => factory(container), });}); 最核心的代码在 Object.defineProperty(container) 这部分,所有从 container[name] 访问的函数,都会在调用时才被初始化,它们会经历这样的处理链条: 初始化 container 为空,不提供任何函数,也没有执行任何 factory。 当业务代码调用 container.randomNumber 时,触发 get(),此时会执行 randomNumber 的 factory 并将 container 传入。 如果 randomNumber 的 factory 没有用到任何依赖,那么 container 的子 key 并不会被访问,randomNumber 函数就成功创建了,流程结束。 关键步骤来了,如果 randomNumber 的 factory 用到了任何依赖,假设依赖是它自己,那么会陷入死循环,这是代码逻辑错误,报错是应该的;如果依赖是别人,假设调用了 container.abc,那么会触发 abc 所在的 get(),重复第 2 步,直到 abc 的 factory 被成功执行,这样就成功拿到了依赖 很神奇,固定的代码逻辑竟然会根据访问链路自动嗅探依赖树,并用正确的顺序,从没有依赖的那个模块开始执行 factory,一层层往上,直到顶部包的依赖全部构建完成。其中每一条子模块的构建链路和主模块都是分型的,非常优美。 循环依赖怎么解决这倒不是说如何解决函数循环依赖问题,因为: 如果函数 a 依赖了函数 b,而函数 b 又依赖了函数 a,这个相当于 a 依赖了自身,神仙都救不了,如果循环依赖能解决,就和声明发明了永动机一样夸张,所以该场景不用考虑解决。 依赖注入让模块之间不引用,所以不存在函数间循环依赖问题。 为什么说 a 依赖了自身连神仙都救不了呢? a 的实现依赖 a,要知道 a 的逻辑,得先了解依赖项 a 的逻辑。 依赖项 a 的逻辑无从寻找,因为我们正在实现 a,这样递归下去会死循环。 那依赖注入还需要解决循环依赖问题吗?需要,比如下面代码: const aFactory = ({ a }: Deps) => () => { return { value: 123, onClick: () => { console.log(a.value); }, }; }; 这是循环依赖最极限的场景,自己依赖自己。但从逻辑上来看,并没有死循环,如果 onClick 触发在 a 实例化之后,那么它打印 123 是合乎情理的。 但逻辑容不得模糊,如果不经过特殊处理,a.value 还真就解析不出来。 这个问题的解法可以参考 spring 三级缓存思路,放到精读部分聊。 自上而下 vs 自下而上编程思维原文做了一下总结和升华,相当有思考价值:依赖注入的思维习惯是自上而下的编程思维,即先思考包之间的逻辑关系,而不需要真的先去实现它。 相比之下,自下而上的编程思维需要先实现最后一个无任何依赖的模块,再按照顺序实现其他模块,但这种实现顺序不一定符合业务抽象的顺序,也限制了实现过程。 精读我们讨论对象 A 与对象 B 相互引用时,spring 框架如何用三级缓存解决该问题。 无论用 spring 还是其他框架实现了依赖注入,当代码遇到这样的形式时,就碰到了 A B 循环引用的场景: class A { @inject(B) b; value = "a"; hello() { console.log("a:", this.b.value); }}class B { @inject(A) a; value = "b"; hello() { console.log("b:", this.a.value); }} 从代码执行角度来看,应该都可以正常执行 a.hello() 与 b.hello() 才对,因为虽然 A B 各自循环引用了,但他们的 value 并没有构成循环依赖,只要能提前拿到他们的值,输出自然不该有问题。 但依赖注入框架遇到了一个难题,初始化 A 依赖 B,初始化 B 依赖 A,让我们看看 spring 三级缓存的实现思路: spring 三级缓存的含义分别为: 一级缓存 二级缓存 三级缓存 实例 半成品实例 工厂类 实例:实例化 + 完成依赖注入初始化的实例. 半成品实例:仅完成了实例化。 工厂类:生成半成品实例的工厂。 先说流程,当 A B 循环依赖时,框架会按照随机顺序初始化,假设先初始化 A 时: 一:寻找实例 A,但一二三级缓存都没有,因此初始化 A,此时只有一个地址,添加到三级缓存。堆栈:A。 一级缓存 二级缓存 三级缓存 模块 A ✓ 模块 B 二:发现实例 A 依赖实例 B,寻找实例 B,但一二三级缓存都没有,因此初始化 B,此时只有一个地址,添加到三级缓存。堆栈:A->B。 一级缓存 二级缓存 三级缓存 模块 A ✓ 模块 B ✓ 三:发现实例 B 依赖实例 A,寻找实例 A,因为三级缓存找到,因此执行三级缓存生成二级缓存。堆栈:A->B->A。 一级缓存 二级缓存 三级缓存 模块 A ✓ ✓ 模块 B ✓ 四:因为实例 A 的二级缓存已被找到,因此实例 B 完成了初始化(堆栈变为 A->B),压入一级缓存,并清空三级缓存。堆栈:A。 一级缓存 二级缓存 三级缓存 模块 A ✓ ✓ 模块 B ✓ 五:因为实例 A 依赖实例 B 的一级缓存被找到,因此实例 A 完成了初始化,压入一级缓存,并清空三级缓存。堆栈:空。 一级缓存 二级缓存 三级缓存 模块 A ✓ 模块 B ✓ 总结依赖注入本质是将函数的内部实现抽象为参数,带来更好的测试性与可维护性,其中可维护性是 “只要申明依赖,而不需要关心如何实例化带来的”,同时自动初始化容器也降低了心智负担。但最大的贡献还是带来了自上而下的编程思维方式。 依赖注入因为其神奇的特性,需要解决循环依赖问题,这也是面试常问的点,需要牢记。 讨论地址是:精读《依赖注入简介》· Issue ##440 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《使用 css 变量生成颜色主题》","path":"/wiki/WebWeekly/前沿技术/《使用 css 变量生成颜色主题》.html","content":"当前期刊数: 118 作者:五灵 本周工作中遇到类似颜色主题的问题,在查资料的时候,看到这个视频,觉得讲得很清楚,而且趣味性丰富,所以想拿出来讲讲这个很有意思的主题。 视频链接: CSSconf EU 2018 | Dag-Inge Aas & Ida Aalen: Generating Colors with JS and CSS Custom Properties 1. 精读CSS 变量CSS 变量及 CSS Variables(Custom Properties),目前几乎都已经被主流浏览器所支持,但是估计还有一部分读者不熟悉这个功能,简单列举一下使用方法: :root { --bg-color: brown; // 定义颜色变量}.btn { // 直接使用颜色预定义的颜色变量 background-color: var(--bg-color);} Web 内容无障碍指南的对比度Web 内容无障碍指南的对比度指的是 W3C 组织发布的 《Web Content Accessibility Guidelines (WCAG)》,这个指南中涵盖了让 Web 内容更易于访问的各种建议,其中针对网页的颜色对比度发布了规范。 在 Chrome 中对于颜色编辑的时候,打开颜色选择器也会看到当前颜色的对比度值(Contrast ratio)。 网页颜色的对比度值在 1:1 到 21:1 之间,文本和图像文本的的对比度最小值为 4.5:1,也就是说低于这个值得对比度都不符合标准。 我们看一下列举的几种颜色对比度,对比度越高,也越有利于阅读。对比度越低,对于一些存在视力障碍或色觉缺陷的用户,可能就无法阅读。 演讲中的颜色解决方案演讲在最开始首先讲了挪威的一个法律,不符合 Web 内容无障碍指南的站点在挪威是非法的,所以挪威的 Web 开发者非常注重站点的内容无障碍。 首先讲了使用 css 变量的方式,支持各种颜色主题的切换。 利用 js 去设置颜色变量,支持主题的颜色切换。 但是紧接着就提出了问题,如果用户可以随意切换颜色主题背景色,那一些按钮的文字可读性如何去保障呢?如果用户选择了与按钮颜色想接近的背景色,我们又该怎么处理了,紧接着这个演讲给出了根据明度决定按钮文字颜色是黑色还是白色的方案。 根据明度决定是黑色还是白色 具体代码如下,大致原理是把彩色转为灰度的颜色,有一个著名的心理学公式:Gray = R*0.299 + G*0.587 + B*0.114,然后在根据颜色灰度决定使用黑色的主题还是白色的主题。 if (red*0.299 + green*0.587 + blue*0.114) > 186 use ##000000 else use ##ffffff 可读性的问题解决了,但是紧接着又遇到了一个问题,如果用户选取的颜色很浅呢,与背景颜色的对比度小于 4.5,该怎么处理呢。 寻找对比度更强的颜色,增强可读性 演讲中给出的解决方法是不断的加深当前用户选择的颜色,循环获取到对比度最高的同色系颜色。代码如下: 获取了一个更深的颜色后,通过给按钮加一个外边框的方式,优化整体的可读性。 文章最后还介绍了,通过给定一个主题色,获取第二第三主题色的方式,通过将颜色放到 HSL 的颜色轮上,转动 hue 的值 60 度,得到一个新的第二主题色。不过演讲者也没有说清楚为什么要这么做,只是说了这么做是出于经验,觉得这样能够得到一个恰当的主题色盘。 衍生的纯 css 解决方案演讲中提供颜色变更的解决方案基本都是基于 JS 计算的,后来有人在 css-tricks 抛出一篇文章说,这个功能基于 css 就可以完全实现,其实关于颜色的原理都是一致的,只是觉得这个实现更加 magic,但是功能都能够完全满足。比如这篇文章中,关于根据明度决定按钮文字是黑色还是白色的代码如下: :root { --light: 80; /* 文字颜色变化的临界值 */ --threshold: 60;}.btn { /* 会被解析成黑色或者白色 */ --switch: calc((var(--light) - var(--threshold)) * -100%); color: hsl(0, 0%, var(--switch));} 可视化图表对于颜色的应用在可视化图表当中,对于颜色的应用要比 Web 要谨慎的多。我们在做 Web 开发的时候,也不妨来看一下可视化图表当中对于颜色应用的一些规范。在可视化图表中,选择的颜色不可以过于随意,每次颜色的变更都是图表信息的改变,都为图表增加了新的数据,图表的每一种颜色也是要表达的信息。列举一些图表中的颜色使用规范,比如: 不建议使用多种颜色表达同种数据 在多条行图表中,不要使用不同的颜色或颜色轮中对立面的颜色。颜色对比过强会使读者无法专心于数据。 一般而言,应避免颜色的主体性表现,避免使用具有特殊意义的颜色。比如使用红色和绿色表示销售额的变化。 当然对于可视化图表来说,并不是遵循了一些色彩使用的准则,就可以得到一个优雅呈现的可视化图表。注重图表呈现的最重要的视觉元素,在视觉信息角度减少用户,减少用户视觉疲劳也很重要。 3. 相关链接CSS 前景背景自动配色技术简介: https://www.zhangxinxu.com/wordpress/2018/11/css-background-color-font-auto-match/纯 css 解决方案:https://css-tricks.com/switch-font-color-for-different-backgrounds-with-css/获取颜色的 Demo: https://confrere.com/a11y/test/颜色色盘推荐的文章:https://blog.graphiq.com/finding-the-right-color-palettes-for-data-visualizations-fcd4e707a283 讨论地址是:精读《使用 css 变量生成颜色主题》 · Issue ##203 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《入坑 React 前没有人会告诉你的事》","path":"/wiki/WebWeekly/前沿技术/《入坑 React 前没有人会告诉你的事》.html","content":"当前期刊数: 8 本期精读的文章是一个组合: 一篇是 Gianluca Guarini 写的 《Things nobody will tell you about React.js》,我将它译作 《那些入坑 React 前没有人会提醒你的事》,因为作者行文中明显带着对 React 的批判和失望。 另一篇则是 Facebook 员工,也是 Redux 作者的 Dan Abramov 针对上文的回复 《Hey, thanks for feedback!》。 1 引言 我为什么要选这篇文章呢? 我们团队最早在 2014 年中就确定了 React 作为未来的发展方向,那个时候很多人都还在感叹 Angular(那时候还是 Angular 1)是一个多么超前的框架,很多人甚至听都没有听说过 React。 在不到三年的时间里,React 社区迅速的发展壮大,许多 Angular、Ember、Knockout 等框架的拥趸,或主动或被动的都逐渐开始向 React 看齐。 站在 React 已经繁荣昌盛、无需四处布道宣传的今天,我们不妨冷静下来问问自己,React 真的是一个完美的框架吗?社区里一直不缺少吐槽的声音,这周我们就来看看,React 到底有哪些槽点。 2 内容概要Gianluca Guarini 着重吐槽的点在于: React 项目文件组织规范不统一,社区中 Starter Kit 太多(100+),新手不知道该怎么组织文件 由于 React 只关心 View 层,开发者就要面临选择 mobx 还是 redux 的纠结,无论选择哪种都会带来一系列的问题(重新配置构建脚本,更新 eslint 规则等) 如果选了 mobx,会发现 mobx 无法保证自己的 store 不被外部更新(官方建议是加上特殊的前缀) 如果选了 redux,会发现要实现同样的功能需要写很多的重复代码(这也是为什么社区中有海量的 redux helper 存在) 路由用起来也很蛋疼,因为 React Router 几乎是社区中唯一的选择,但是这货版本更新太快,一不小心就用了废弃的 API 用 JSX 的时候总是要嵌很多没必要的 div 或 span 要上手一个 React 应用,要配置很多的构建工具和规则才能看到效果 … Dan Abramov 的回复: 「React 16.0 引入的 Fiber 架构会导致现有代码全部需要重构」的说法是不对的,因为新的架构做到了向后兼容,而且 Facebook 内部超过 3 万个组件都能无痛迁移到新架构上 缺少统一脚手架的问题,可以通过 create-react-app 解决 觉得 redux 和 mobx 繁琐的话,对于刚刚上手的小应用不建议使用 React Router 升级太频繁?2015 年发布的 1.0,2016 年 2 月发布的 2.0,2016 年 10 月发布的 3.0。虽然 4.0 紧接着 3.0 马上就发布了,但是 React Router 很早就已经公布了这样的升级计划。 … 3 精读本次提出独到观点的同学有:@rccoder @Turbe Xue @Pines-Cheng @An Yan @淡苍 @黄子毅 @宾彬 @cisen @Bobo 精读由此归纳。 很高兴能看到不少新同学积极参与到精读的讨论中来,每一个人的声音都是社区发展的一份力量。 React 上手困难很早之前我们去四处布道 React 的时候,都会强调 React 很简单,因为它的 public API 非常之少,React 完整的文档 1 个小时就能看完。 那么说「React 上手困难」又是从何谈起呢?参与精读的同学中有不少都有 Vue 的使用经验(包括本周吐槽文的作者),所以不免会把两个框架上手的难易程度放在心里做个对比。 都说没有对比就没有伤害,大家普遍的观点是 Vue 上手简单、文档清晰、构建工具完善、脚手架统一……再反观 React,虽然 Dan 在文章里做了不少解释,但引用 @An Yan 的原话,『他也只是在说「事情没有那么糟糕」』。 所以说,大家认为的 React 上手困难,很大程度上不是 React 本身,而是 React 附带的生态圈野蛮发展太快,导致新人再进入的时候普遍感觉无所适从。虽然官方的 create-react-app 缓解了这一问题,但还没有从根本程度上找到解法。 状态管理的迷思在今时今日的前端圈子里,说 React 不说 Redux 就像说 Ruby 却不说 Rails 一样,总感觉缺点儿什么。 因为 React 将自己定位成 View 层的解决方案,所以对于中大型业务来说一个合适的状态管理方案是不可或缺的。从最早的 Backbone Model,到 Flux,再到 reflux、Redux,再到 mobx 和 redux-observable,你不得不感叹 React 社区的活力是多么强大。 然而当你真正开始做新项目架构的时候,你到底是选 Redux 还是 Mobx,疑惑是封装解决方案如 dva 呢? @淡苍 认为,Redux 与 MobX,React 两大状态管理方案,各有千秋,Redux 崇尚自由,扩展性好,却也带来了繁琐,一个简单的异步请求都必须引入中间件才能解决,MobX 上手容易,Reactive 避免不必要的渲染,带来性能提升,但相对封闭,不利于业务抽象,缺少最佳实践。至于如何选择?根据具体场景与需求判断。 不难看出,想要做好基于 React 的前端架构,你不仅需要对自己的业务了如指掌,还需要对各种解决方案的特性以及适合怎样的业务形态了如指掌。在 React 社区,永远没有标准解决方案。 Redux 亦非万能解Redux 在刚刚推出的时候凭借酷炫的 devtool 和时间旅行功能,瞬间俘获了不少工程师的心。 但当你真正开始使用 Redux 的时候,你会发现你不仅需要学习很多新的概念,如 reducer、store、dispatch、action 等,还有很多基础的问题都没有标准解法,最典型的例子就是异步 action。虽然 Redux 的 middleware 机制提供了实现异步 action 的可能性,但是对于小白来说去 dispatch 一个非 Object 类型的 action 之前需要先了解 thunk 的概念,还要给 Redux 添加一个 redux-thunk 中间件实属难题。 不仅如此,在前端工程中常见的表单处理,Redux 社区也一直没有给出完美的解法。前有简单的 util 工具 redux-form-utils,后有庞大复杂的 redux-form,还有 rc-component 实现的一套基于 HOC 的解决方案。若没有充分的了解和调研,你将如何选择? 这还没有提到最近非常火热的 redux-saga 和 redux-observable,虽然 Dan 说如果你不需要的话完全可以不用了解,但是如果你不了解他们的话怎么知道自己需不需要呢? React 与 Vue 之争Vue 之所以觉得入门简单,因为一开始就提供了 umd 的引入方式,这与传统 js 开发的习惯一致,以及 Avalon 多年布道的铺垫,大家可以很快接受一个不依赖于构建的 Vue。 React 因为引入了 JSX 概念,本可以以 umd 方式推广,但为了更好的 DX 所以上来就推荐大家使用 JSX,导致新手觉得门槛高。 React + Mobx 约等于一个复杂的 Vue,但这不是抛弃 React 的理由。为什么大家觉得 Vuex 比 Redux 更适合 Vue 呢?因为 Vuex 简单,而 Redux 麻烦,这已经将两个用户群划分开了。 一个简单的小公司,就是需要这种数据流简单,不需要编译,没有太多技术选型要考虑的框架,他们看中的是开发效率,可维护性并不是第一位,这点根本性的导致了这两类人永远也撮合不到一块。 而 Vue 就是解决了这个问题,帮助了那么多开发者,仅凭这点就非常值得称赞,而我们不应该从 React 维护性的角度去抨击谁好谁坏,因为站在我们的角度,大部分中小公司的开发者是不 care 的。 React 用户圈汇集了一批高端用户,他们不断探索技术选型,为开源社区迸发活力,如果大家都转向 Vue,这块摊子就死了,函数式、响应式编程的演进也会从框架的大统一而暂时终止,起码这是不利于技术进步的,也是不可能发生的。Vue 在自己的领域做好,将 React 敏捷思想借鉴过来,帮助更多适合场景的开发者,应该才是作者的目的。 小贴士:如何在开源社区优雅的撕逼开源社区撕逼常有,各种嘴炮也吃充斥在社区里,甚至有人在 Github 上维护了一份开源社区撕逼历史。虽然说做技术的人有争论很正常,但是撕的有理有据令人信服的案例却不多。这次 Facebook 的员工 Dan Abramov 就做出了很好的表率。面对咄咄逼人的文章,逐条回复,不回避、不扯淡且态度保持克制,实属难能可贵。 3 总结React 开发者们也不要因为产生了 Mobx 这种亲 Vue 派而产生焦虑,这也是对特定业务场景的权衡,未来更多更好的数据流方案还会继续诞生,技术社区对技术的优化永无止尽。 比如 mobx-state-tree 就是一种 redux 与 mobx 结合的大胆尝试,作者在很早之前也申明了,Mobx 一样可以做时间旅行,只要遵守一定的开发规范。 最后打个比方:安卓手机在不断进步,体验越来越逼近苹果,作为一个逼格高的用户,果断换苹果吧。但作为 java 开发人员的你,是否要为此换到 oc 流派呢?换,或者不换,其实都一样,安卓和苹果已经越来越像了。 讨论地址是:那些入坑 React 前没有人会提醒你的事 · Issue ##13 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《全链路体验浏览器挖矿》","path":"/wiki/WebWeekly/前沿技术/《全链路体验浏览器挖矿》.html","content":"当前期刊数: 39 本期精读的文章是: coinhive 官方文档及Monero 官方文档 懒得看文章?没关系。 咦,怎么是官方文档? 本期精读有所不同,注重实操,先操作获取感性认识,然后再介绍相关的概念,由浅入深,力求不纠缠细节,但不遮盖环节。阅读本文需要对加密货币有一些基本常识 (如果你是一个开发者但是完全没了解过加密货币,可以参考这里)。希望同学们看完本文能对加密货币领域有一个更深更切实的感受。如果要了解更多细节,文末总结的延伸阅读链接列表是最好的开始。 要注意一点,文中很多说明是默认基于 XMR 和 BTC 的,他们两个又同源,机制非常相似。所以很多命题判断并不适用于所有的成千上万的加密货币. 正相反,新的币种层出不穷,几乎所有的惯例都被打破,所有可能性都被尝试。这一点以下不再做说明。 1 引言首先,干货。10 行代码,5 分钟,不需部署不需构建直接浏览器开挖。 本地创建文件 test.html,粘贴如下代码: <!doctype html><html><head> <title>Mining</title></head><body> <div class="coinhive-miner" style="width: 256px; height: 310px" data-key="MUtCJzIDhrs01ERrf3qlqdawo35N0CYD"> <em>Loading...</em> </div> <script src="https://authedmine.com/lib/simple-ui.min.js" async></script></body></html> 本地双击打开。等待 JS 加载,点击 widget “Start Mining”。开始挖矿! 如下图 不错,数字已经在跳动,风扇开始工作,永无尽头的挖矿已经开始了。那么重要的是,挖出来的加密货币在哪呢?原来上面的代码里用的还是我的 API key,所以还没挖到你自己那里,所以请继续下面的步骤: 在 coinhive 注册账号并登陆。它是做什么的?别急,后面会详细讲。在 coinhive/settings 找到自己的 API keypair,把 public key 复制出来,形如 MUtCJzIDhrs01ERrf3qlqdawo35N0CYD 替换上面代码中的 data-key 部分,重新开始挖矿。好了,现在挖出的 Monero (这是啥?详见下一节) 已经会到你自己的 coinhive 账户中。用下图来说明,你名下总计算过的 Hash 个数为 264K,当前难度换算为 0.00002 个 Monero(Symbol:XMR)。当前难度为 66G 一个 block,一个 block reward 5.87 XMR,得到一个 XMR 是 11.268G。264K/11.268G = 0.0000234。这就是你目前的收益。 查 bitfinex 可得现在 XMR 价格在 375 美元 (当你看到本文的时候,价格可能早就又波动到不知哪里去了),所以你 (以及你忠实勤劳的电脑) 获得的实际收益为 0.000234 * 375 USD = 0.008775 USD,快到一分钱了 :) 怎么样,有没有一种浏览器点开即玩一刀 999 级的感觉。以上操作的便捷直接,是建立在无数前人大量的开发和基础设施建设之上的。越是领域早期的工作,越步履维艰,收货也越丰厚。如今加密货币已经走到了一个成年期,逐渐稳定成熟起来。 接下来我们聚焦到上面过程的每个环节,了解下拼图的每一块是怎样被构成全图的。 2 聚焦让我们从最终端最接近用户的环节开始,逐一聚焦,最后走完整条链路。 2.1 从浏览器说起本文标题叫浏览器挖矿,也是和贴合前端的部分。那么为什么可以在浏览器里挖矿?为什么可以很多用户在多个终端浏览器同时为同一个人 (你) 挖矿? 我们知道,挖矿是对加密货币产生机制的俗称。主流大多采取 Proof of Work (PoW) 机制。最常见的 PoW 方式就是由网络中所有节点作为矿工,每个节点都基于 blockchain 前面 block 已有信息计算一个新信息。这个新信息的计算方式往往是某种 hash function,并且人为地被设置为需要巨大计算力和时间才能完成 (其具体难度一般也会实时调整)。当一个节点幸运地 (也依靠强大的算力) 第一个计算出结果后,会把这个结果广播到网络中。其他所有节点会验证这个结果 (我们知道非对称加密算法,验证便宜而计算昂贵),一旦证实就会停下手里的计算,承认这个计算结果。新的计算结果创造出新的块,区块链的高度增加一层,然后计算继续下去。每一个块的生成一般在 2-10 分钟。这个过程就被叫做挖矿. 既然是通用计算,既然是算一个 hash 值,那么民用级 CPU 和 GPU,浏览器或任何沙盒,虚拟机,移动设备当然就都可以。在我们的例子中,计算过程被做成分布式,每个用户可以各自计算,结果按 chunk 发回 master 汇总。这样就实现了终端用户 - 浏览器 - 共同贡献计算资源 - 换为 XMR 的过程。 这就是对整个链路的一个描述。从中我们会生出一些疑问,比如: 给我看看具体算什么 hash?为什么要算 XMR 而不是比特币或者其他?既然第一个算出的赢家通吃所有,为什么我的收益却是线性的?这种描述来看岂不是算力最大的一方永远都能算出结果而其他人颗粒无收吗? 要看具体算法,没有问题。bitcoin 在这里,XMR 则看CryptoNote Standard 008 读完两个算法我们就有了以上疑问的答案: 2.1.1 为什么要算 XMR 而不是比特币或者其他XMR 不是唯一选择,但是 BTC 是一个不可选的选择。因为 double SHA-265 在专业级 GPU 上会比 CPU 上快 10^4 倍 (更多信息)。这样一百万用户合力浏览器挖矿还不如一架子双路 Titan,就失去了分布到终端用户的意义。而 CryptoNight 在 GPU 上只比同价值 CPU 快 2 倍。另外 CryptoNight 算法也被设计为不适用ASIC。 怎么实现的?CryptoNight 算法开宗明义地写明,运算主体是 Memory-Hard Loop,而不是 Computation-Hard Loop。每个循环都要在内存中检索。实际运行 CryptoNight 时,CPU 都会用最快,最接近 ALU 也是容量最小的 L3 Cache。换到 GPU,显存虽然很大,却没有 L3 Cache 一样的极致读写速度优化,而且由于内存读写成了瓶颈,GPU 中的大量 ALU 也没了用武之地。下图简略地描述了 CryptoNight 循环体的结构: 2.1.2 算力最大的一方永远都能算出结果看了比特币具体算法,你应该明白了 hash 是靠不停改变 nonce 来生成的。随机取一个值,算了不对,再随机取另一个 nonce 值..。既然是随机取,就不会存在赢家恒赢. 2.1.3 为什么我的收益却是线性的这是一个隐蔽但是却非常重要的问题。答案是,本来确实是赢家通吃。如果你的算力足够大,挖矿时间足够长之后总会轮到你,但是收益会有大幅波动. 就是因为如此,矿工们逐渐建立了矿池组织。大家把算力都投入到一起,合力算,然后不管这次实际是谁算出来,都按照贡献的算力比例分配收益。矿池是一个加密货币建立之初,完全推崇去中心化时没有预料到的结构,也产生了深远的影响。现在来自中国矿池的算力早已超过网络 50%,他们会在挖出的块中打上矿池标记,而这些矿池在加密货币的分叉,路线图中也扮演举足轻重的角色. 所以你的算力并不是直接投入 XMR 网络中,而是投入一个矿池。在我们的例子里矿池就是 coinhive,只不过是一个比较特殊的矿池,特殊在矿池成员都运作在浏览器中。这就是为什么你会得到线性收益而不是 all or none. 自古以来各行业都会自发产生行业工会,建立类似保险和行业守则 / 规范这些人人为我我为人人的机制。在 crypto 行业也不例外。这是意料之外而情理之中. 2.2 在浏览器里发生了什么,或 coinhive 干了什么好,我们搞清了一些基本的 Monero 挖矿机制,下面来看看 coinhive。已经知道 coinhive 帮我们接入它的矿池,让再小的算力也能按比例得到产出。但是还有什么呢?最关键的一点,coinhive 是怎样把一个完整的 mining 过程拆分成小块,让一个或许并不强大的设备上的浏览器,也能快速接收 task,快速完成并且即时上传的呢? 废话不多说,打开源码。本项目没有开源,构建完成后的在https://coinhive.com/lib/coinhive.min.js。先做初步 format 处理,发现有些工作完成在后端,worker shard 一侧。以下用松散的伪码总结一下 client side 的main success scenario流程 (注意很多地方简化了): - start- loadWorkerResource- load worker-asmjs.min.js- CRYPTONIGHT_WORKER_BLOB = createObjectURL(Blob(response_of_worker-asmjs.min.js))- _startNow -> _connectAfterSelfTest- selfTest -> verify(testJob), testJob = { verify_id: "1", nonce: "204f150c", result: "6a9c7dea83b079ce0e012907dd6929bcb0aeec3c1f06c032ca7c3386432bca00", blob: "0606c6d8cfd005cad45b0306350a730b0354d52f1b6d671063824287ce4a82c971d109d56d1f1b00000000ee2d1d4fd7c18bdc1b24abb902ac8ecc3d201ffb5904de9e476a7bbb0f9ec1ab04" }; verify = if (!this._isReady) { this.verifyJob = job } else { this.worker.postMessage(job) } // 实例化若干个JobThread,每个对应一个worker,worker实际执行asmjs.min.js- _connect // verify成功,终于建立连接。根据public key固定hash到一个shard池然后随机选一个shard,建立websocket- websocket.onmessage: if (type==job) work()- work: do { hash(input,output) } while !(meetTarget(output)); websocket.postMessage({nonce,output}) // hash done successfully。submit 看一下这个过程,结合cns003 XMR blockchain specs。XMR 的整体 hash input 很小,是: - size of [block_header,Merkle root hash,and the number of transactions] in bytes (varint)- block_header,- Merkle root hash,- number of transactions (varint). 这样用 websocket 发过来毫无问题。之后就是完全独立的计算,调整 nonce 来算不同的 hash 结果。target 就是当前难度的一个指示. 这样整条链路就比较清晰了。再思考一下以下问题: 2.2.1 为什么 XMR 适宜分布式客户端计算因为能够利用每个用户的 CPU 和其中的高速 L3 Cache。这是中心化执行难以具备的条件. 任何时候当考虑要不要把某项操作推到客户端进行时,都要想明白可以利用客户端的哪个资源,这个资源在客户端是否有明显优势,是否比后端中心化执行更有利。很多时候答案是,优势并不明显。那么引入的网络通信成本,法规成本,额外开销可能就并不值得. 2.3 最后的步骤到了这里,最后剩余步骤就很标准模式化了。coinhive 作为矿场,代管着用户生产的加密货币。用户发请求提出 XMR,就需要提到一个自己的钱包地址保管,比如 MyMonero。也可以直接提到 Exchange 交易所,在其中交易成其他币种,包括法币,然后电汇等等形式提现. 如果对钱包或者交易所感兴趣 (这两个也是很大的话题,比如钱包分硬件钱包和软件钱包,离线冷钱包和线上热钱包,private key 和 recover seeds。交易所有多种多样的交易对,有杠杆,期货,空和多,多种挂单类型等等),可以看这里和这里. 3 更多讨论 讨论地址是:精读《全链路体验浏览器挖矿》 · Issue ##55 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。 4 延伸阅读http://piotrpasich.com/introduction-bitcoin-for-developers https://en.wikipedia.org/wiki/Monero_(cryptocurrency) http://www.righto.com/2014/02/bitcoin-mining-hard-way-algorithms.html https://cryptonote.org/cns/cns001.txtcns001-008 是 Specs 集合 https://en.bitcoin.it/wiki/Why_a_GPU_mines_faster_than_a_CPU https://en.wikipedia.org/wiki/Application-specific_integrated_circuit https://github.com/txbits/txbits"},{"title":"《函数缓存》","path":"/wiki/WebWeekly/前沿技术/《函数缓存》.html","content":"当前期刊数: 160 1 引言函数缓存是重要概念,本质上就是用空间(缓存存储)换时间(跳过计算过程)。 对于无副作用的纯函数,在合适的场景使用函数缓存是非常必要的,让我们跟着 https://whatthefork.is/memoization 这篇文章深入理解一下函数缓存吧! 2 概述假设又一个获取天气的函数 getChanceOfRain,每次调用都要花 100ms 计算: 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 calculationshowWeatherReport(); // (!) Triggers the calculationshowWeatherReport(); // (!) Triggers the calculation 很显然这样太浪费计算资源了,当已经计算过一次天气后,就没有必要再算一次了,我们期望的是后续调用可以直接拿上一次结果的缓存,这样可以节省大量计算。因此我们可以做一个 memoizedGetChanceOfRain 函数缓存计算结果: 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);} 在每次调用时判断优先用缓存,如果没有缓存则调用原始函数并记录缓存。这样当我们多次调用时,除了第一次之外都会立即从缓存中返回结果: showWeatherReport(); // (!) Triggers the calculationshowWeatherReport(); // Uses the calculated resultshowWeatherReport(); // Uses the calculated resultshowWeatherReport(); // Uses the calculated result 然而对于有参数的场景就不适用了,因为缓存并没有考虑参数: function showWeatherReport(city) { let result = getChanceOfRain(city); // Pass the city console.log("The chance of rain tomorrow is:", result);}showWeatherReport("Tokyo"); // (!) Triggers the calculationshowWeatherReport("London"); // Uses the calculated answer 由于参数可能性很多,所以有三种解决方案: 1. 仅缓存最后一次结果仅缓存最后一次结果是最节省存储空间的,而且不会有计算错误,但带来的问题就是当参数变化时缓存会立即失效: 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 calculationshowWeatherReport("Tokyo"); // Uses the calculated resultshowWeatherReport("Tokyo"); // Uses the calculated resultshowWeatherReport("London"); // (!) Triggers the calculationshowWeatherReport("London"); // Uses the calculated result 在极端情况下等同于没有缓存: showWeatherReport("Tokyo"); // (!) Triggers the calculationshowWeatherReport("London"); // (!) Triggers the calculationshowWeatherReport("Tokyo"); // (!) Triggers the calculationshowWeatherReport("London"); // (!) Triggers the calculationshowWeatherReport("Tokyo"); // (!) Triggers the calculation 2. 缓存所有结果第二种方案是缓存所有结果,使用 Map 存储缓存即可: // 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 calculationshowWeatherReport("London"); // (!) Triggers the calculationshowWeatherReport("Tokyo"); // Uses the calculated resultshowWeatherReport("London"); // Uses the calculated resultshowWeatherReport("Tokyo"); // Uses the calculated resultshowWeatherReport("Paris"); // (!) Triggers the calculation 这么做带来的弊端就是内存溢出,当可能参数过多时会导致内存无限制的上涨,最坏的情况就是触发浏览器限制或者页面崩溃。 3. 其他缓存策略介于只缓存最后一项与缓存所有项之间还有这其他选择,比如 LRU(least recently used)只保留最小化最近使用的缓存,或者为了方便浏览器回收,使用 WeakMap 替代 Map。 最后提到了函数缓存的一个坑,必须是纯函数。比如下面的 CASE: // Inside the magical npm packagefunction getChanceOfRain() { // Show the input box! let city = prompt("Where do you live?"); // ... calculation ...}// Our codefunction showWeatherReport() { let result = getChanceOfRain(); console.log("The chance of rain tomorrow is:", result);} getChanceOfRain 每次会由用户输入一些数据返回结果,导致缓存错误,原因是 “函数入参一部分由用户输入” 就是副作用,我们不能对有副作用的函数进行缓存。 这有时候也是拆分函数的意义,将一个有副作用函数的无副作用部分分解出来,这样就能局部做函数缓存了: // 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);} 最后,我们可以将缓存函数抽象为高阶函数: 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; };} 这样生成新的缓存函数就方便啦: let memoizedGetChanceOfRain = memoize(getChanceOfRain);let memoizedGetNextEarthquake = memoize(getNextEarthquake);let memoizedGetCosmicRaysProbability = memoize(getCosmicRaysProbability); isCalculated 与 lastResult 都存储在 memoize 函数生成的闭包内,外部无法访问。 3 精读通用高阶函数实现函数缓存原文的例子还是比较简单,没有考虑函数多个参数如何处理,下面我们分析一下 Lodash memoize 函数源码: 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 交给用户自己管理,看这段代码: key = resolver ? resolver.apply(this, args) : args[0]; 也就是缓存的 key 默认是执行函数时第一个参数,也可以通过 resolver 拿到参数处理成新的缓存 key。 在执行函数时也传入了参数 func.apply(this, args)。 最后 cache 也不再使用默认的 Map,而是允许用户自定义 lodash.memoize.Cache 自行设置,比如设置为 WeakMap: _.memoize.Cache = WeakMap; 什么时候不适合用缓存以下两种情况不适合用缓存: 不经常执行的函数。 本身执行速度较快的函数。 对于不经常执行的函数,本身就不需要利用缓存提升执行效率,而缓存反而会长期占用内存。对于本身执行速度较快的函数,其实大部分简单计算速度都很快,使用缓存后对速度没有明显的提升,同时如果计算结果比较大,反而会占用存储资源。 对于引用的变化尤其重要,比如如下例子: function addName(obj, name){ return { ...obj, name: }} 为 obj 添加一个 key,本身执行速度是非常快的,但添加缓存后会带来两个坏处: 如果 obj 非常大,会在闭包存储完整 obj 结构,内存占用加倍。 如果 obj 通过 mutable 方式修改了,则普通缓存函数还会返回原先结果(因为对象引用没有变),造成错误。 如果要强行进行对象深对比,虽然会避免出现边界问题,但性能反而会大幅下降。 4 总结函数缓存非常有用,但并不是所有场景都适用,因此千万不要极端的将所有函数都添加缓存,仅限于计算耗时、可能重复利用多次,且是纯函数的。 讨论地址是:精读《函数缓存》· Issue ##261 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《初探 Reason 与 GraphQL》","path":"/wiki/WebWeekly/前沿技术/《初探 Reason 与 GraphQL》.html","content":"当前期刊数: 40 本期精读的文章是: Exploring Reason and GraphQL 1 引言2018 年了,Reason 生态发展了不少,而且正好看到一篇文章的作者也抱着这种心态尝鲜 React + graphql,索性调研一下,看看这套前沿的方案是否有落地对可能性。 2 内容概要一切皆模块在 reason 中,一切皆模块,而且不需要手动申明导出与引用,这个是 js 的痛点。以下面的代码为例: open Data;let typeDef = {| type Author { id: Int! firstName: String lastName: String posts: [Post] ## the list of Posts by this author }|};type resolvers = {. "posts": Js.Array.t(post)};let resolvers = { "posts": (author: Data.author) => Js.Array.filter((post) => post###authorId === author###id, posts)}; 第一行的 open 类似 js 中的 import,不同的是,js 中需要通过 Data.post 访问对象,而 reason 可以直接访问 post。不过也可以补全引用,比如 Data.author。 在定义 graphQL 类型时,graphql-tools 允许通过 [Post] 的语法将文章对象关联到作者。 内置不可变数据类型检测reason 中,一切类型都是 immutable 的,如果使用如下代码直接修改 post.votes,则会报错: Mutation: { upvotePost: (_, { postId }) => { const post = find(posts, { id: postId }); if (!post) { throw new Error(`Couldn't find post with id ${postId}`); } post.votes += 1; return post; },}, 可以通过 ref 告诉编译器,votes 可能是 mutable 的: type post = {. "id": int, "authorId": int, "title": string, "votes": ref(int)}; 最后作者介绍了如何通过 apollo-server 搭建后端代码,与 reason 结合使用。 我试了下,真的非常方便,后端定义好接口,会自动生成一份在线文档供前端查询,完全屏蔽了接口这一层,只要搜索要查询的元素即可。 3 精读graphql前端后沟通成本一直是个问题,以至于很多团队想做一个 “接口查询平台” 之类的系统。 当然,无论是解析后端代码也好,平台录入也好,还是 mock 平台反推,都不太理想: 解析后端代码,工作量比较大,而且还需要约定一些格式,其实越做越像 graphql,投入的话还不如考虑使用 graphql。 一条条接口录入方案是可行的,技术成本也几乎为零,但问题是后续代码变动会导致平台与实际接口不一致,或者某些项目甚至绕过了接口录入,导致一些接口游离在平台之外,无法聚合管理。 先通过 mock 平台联调,再读取 mock 平台数据,生成接口列表同样存在后端代码变动导致 mock 结构过期的问题。 如果不考虑需求变动,后端采用 graphql 其实是成本最小的选择,其一是类似 apollo-server 这类框架做了一个 IDE 供查询实体,同时绕过了接口,直接暴露数据,效率更高。其二是可以做到代码变动后文档实时同步,只要后端代码更新,文档也会自动更新。 不过对于后端代码并不掌握在前端的团队来说,如果不推动后端改造成 graphql,是无法享受到这个好处的,这时如果搭建一个 node 版 graphql 桥梁,那又如何衔接这个桥梁与后端呢?所以使用 graphql 的若不是第一手后端代码,使用后也不会有多少效果。 更多细节可以访问 GraphQL and Relay 浅析,那篇是基于 relay 的,现在 apollo-server 看上去是更轻量级的方案。 reason最近的 3.0 版本使用 JavaScript 的 application/abstraction 语法代替了 OCaml 的语法,看上去稍微顺眼一些了: myFunction(arg1, arg2) // 3.0 语法myFunction arg1 arg2 // 2.0 语法 能看出来 reason 在往 js 开发社区靠,不过大部分语法对 js 开发者都比较陌生,相比于 typescript,跳跃性有点太大了。 reason react使用 reason 写一个 react 组件是这样的: let component = ReasonReact.reducerComponent("Greeting");let make = (~name, _children) => { ...component, initialState: () => 0, /* here, state is an `int` */ render: (self) => { let greeting = "Hello " ++ name ++ ". You've clicked the button " ++ string_of_int(self.state) ++ " time(s)!"; <div>{ReasonReact.stringToElement(greeting)}</div> }}; ~name 称为 Labeled Arguments,也就是,调用函数时,可以无视顺序,显示指定入参名:make(~name=5),initialState 对应 reactjs 中 state,其他与 reactjs 都很像。 reason react 更新 state相比 react 的 setState,reason react 提供了 reducer 支持,这里可以类比到 redux: let make = (_children) => { ...component, initialState: () => {count: 0, show: false}, reducer: (action, state) => switch (action) { | Click => ReasonReact.Update({...state, count: state.count + 1}) | Toggle => ReasonReact.Update({...state, show: ! state.show}) }, render: (self) => { let message = "Clicked " ++ string_of_int(self.state.count) ++ " times(s)"; <div> <MyDialog onClick={_event => self.send(Click)} onSubmit={_event => self.send(Toggle)} /> {ReasonReact.stringToElement(message)} </div> }}; 除了类型提示支持模式匹配(ts 也支持了)比较完美之外,其他和 redux 还真没啥区别。 至于 immutable 特性,reason 本身也只支持 immutable 检测而已,同时支持了结构语法,可以较为方便进行 immutable 计算(es 也支持了)。 如果想在复杂场景深入使用 immutable,可以看看这个 Reason + BuckleScript bindings to Immutable.js。 4 总结graphql 很惊艳,但如果不能应用到后端第一手代码就没什么用。 reason 整体看上去比初版 react + redux 生态强大了太多,但是与现在的前端生态链 typescript + react + redux* 最新特征比起来,唯一惊艳的地方,就是对 ocaml 用户较为友好,另外在各大支持编译到 js 语言,纷纷支持 Assembly 编译后,这些语言更加趋同了,相比之下 ts 更适合用在生产环境。 5 更多讨论 讨论地址是:精读《初探 Reason 与 GraphQL》 · Issue ##56 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《利用 GPT 解读 PDF》","path":"/wiki/WebWeekly/前沿技术/《利用 GPT 解读 PDF》.html","content":"当前期刊数: 277 ChatPDF 最近比较火,上传 PDF 文件后,即可通过问答的方式让他帮你总结内容,比如让它帮你概括核心观点、询问问题,或者做观点判断。 背后用到了几个比较时髦的技术,还好有 ChatGPT for YOUR OWN PDF files with LangChain 解释了背后的原理,我觉得非常精彩,因此记录下来并做一些思考,希望可以帮到大家。 技术思路概括由于 GPT 非常强大,只要你把 PDF 文章内容发给他,他就可以解答你对于该文章的任何问题了。– 全文完。 等等,那么为什么要提到 langChain 与 vector dataBase?因为 PDF 文章内容太长了,直接传给 GPT 很容易超出 Token 限制,就算他允许无限制的 Token 传输,可能一个问题可能需要花费 10~100 美元,这个 成本 也是不可接受的。 因此黑魔法来了,下图截取自视频 ChatGPT for YOUR OWN PDF files with LangChain: 我们一步步解读: 找一些库把 PDF 内容文本提取出来。 把这些文本拆分成 N 份更小的文本,用 openai 进行文本向量化。 当用户提问时,对用户提问进行向量化,并用数学函数计算与 PDF 已向量化内容的相似程度。 把最相似的文本发送给 openai,让他总结并回答你的问题。 利用 GPT 解读 PDF 的实现步骤我把视频里每一步操作重新介绍一遍,并补上自己的理解。 登录 colab你可以在本地电脑运行 python 一步步执行,也可以直接登录 colab 这个 python 运行平台,它提供了很方便的 python 环境,并且可以一步步执行代码并保存,非常适合做研究。 只要你有谷歌账号就可以使用 colab。 安装依赖要运行一堆 gpt 相关函数,需要安装一些包,虽然本质上都是不断给 gpt openapi 发 http 请求,但封装后确实会语义化很多: !pip install langchain!pip install openai!pip install PyPDF2!pip install faiss-cpu!pip install tiktoken 其中 tiktoken 包是教程里没有的,我执行某处代码时被提示缺少这个包,大家可以提前按上。接下来提前引入一些后面需要用到的函数: from PyPDF2 import PdfReaderfrom langchain.embeddings.openai import OpenAIEmbeddingsfrom langchain.text_splitter import CharacterTextSplitterfrom langchain.vectorstores import ElasticVectorSearch, pinecone, Weaviate, FAISS 定义 openapi token为了调用 openapi 服务,需要先申请 token,当你申请到 token 后,通过如下方式定义: import osos.environ["OPENAI_API_KEY"] = "***" 默认 langchain 与 openai 都会访问 python 环境的 os.environ 来寻找 token,所以这里定义后,接下来就可以直接调用服务了。 如果你还没有 GPT openapi 的账号,详见 保姆级注册教程。(可惜的是中国被墙了,为了学习第一手新鲜知识,你需要自己找 vpn,甚至花钱买国外手机号验证码接收服务,虽然过程比较坎坷,但亲测可行)。 读取 PDF 内容为了方便在 colab 平台读取 PDF,你可以先把 PDF 上传到自己的 Google Drive,它是谷歌推出的个人云服务,集成了包括 colab 与文件存储等所有云服务(PS:微软类似的服务叫 One Drive,好吧,理论上你用哪个巨头的服务都行)。 传上去之后,在 colab 运行如下代码,会弹开一个授权网页,授权后就可以访问你的 drive 路径下资源了: from google.colab import drivedrive.mount('/content/gdrive', force_remount=True)root_dir = "/content/gdrive/My Drive/"reader = PdfReader('/content/gdrive/My Drive/2023_GPT4All_Technical_Report.pdf') 我们读取了 2023_GPT4All_Technical_Report.pdf 报告,这是一个号称本地可跑对标 GPT4 的服务(测评)。 将 PDF 内容文本化并拆分为多个小 chunk首先执行如下代码读取 PDF 文本内容: raw_text = ''for i, page in enumerate(reader.pages): text = page.extract_text() if text: raw_text += text 接下来要为调用 openapi 服务对文本向量化做准备,因为一次调用的 token 数量有限制,因此我们需要将一大段文本拆分为若干小文本: text_splitter = CharacterTextSplitter( separator = " ", chunk_size = 1000, chunk_overlap = 200, length_function = len,)texts = text_splitter.split_text(raw_text) 其中 chunk_size=1000 表示一个 chunk 有 1000 个字符,而 chunk_overlap 表示下一个 chunk 会重复上一个 chunk 最后 200 字符的内容,方便给每个 chunk 做衔接,这样可以让找相似性的时候尽量多找几个 chunk,找到更多的上下文。 向量化来了!最重要的一步,利用 openapi 对之前拆分好的文本 chunk 做向量化: embeddings = OpenAIEmbeddings()docsearch = FAISS.from_texts(texts, embeddings) 就是这么简单,docsearch 是一个封装对象,在这一步已经循环调用了若干次 openapi 接口将文本转化为非常长的向量。 文本向量化又是一个深水区,可以看下这个 介绍视频,简单来说就是一把文本转化为一系列数字,表示 N 维的向量,利用数学计算相似度,可以把文字处理转化为连续的数字进行数学处理,甚至进行文字加减法(比如 北京-中国+美国=华盛顿)。 总之这一步之后,我们本地就拿到了各段文本与其向量的对应关系,比如 “这是一段文字” 对应的向量为 [-0.231, 0.423, -0.2347831, ...]。 利用 chain 生成问答服务接下来要串起完整流程了,初始化一个 QA chain 表示与 GPT 使用 chat 模型进行问答: from langchain.chains.question_answering import load_qa_chainfrom langchain.llms import OpenAIchain = load_qa_chain(OpenAI(), chain_type="stuff") 接下来就可以问他 PDF 相关问题了: query = "who are the main author of the article?"docs = docsearch.similarity_search(query)chain.run(input_documents=docs, question=query)## The main authors of the article are Yuvanesh Anand, Zach Nussbaum, Brandon Duderstadt, Benjamin Schmidt, and Andriy Mulyar. 当然也可以用中文提问,openapi 会调用内置模块翻译给你: query = "训练 GPT4ALL 的成本是多少?"docs = docsearch.similarity_search(query)chain.run(input_documents=docs, question=query)## 根据文章,大约四天的工作,800美元的GPU成本(包括几次失败的训练)和500美元的OpenAI API开销。我们发布的模型gpt4all-lora大约在Lambda Labs DGX A100 8x 80GB上需要八个小时的训练,总成本约为100美元。 QA 环节发生了什么?根据我的理解,当你问出 who are the main author of the article? 这个问题时,发生了如下几步。 第一步:调用 openapi 将问题进行向量化,得到一堆向量。 第二步:利用数学函数与本地向量数据库进行匹配,找到匹配度最高的几个文本 chunk(之前我们拆分的 PDF 文本内容)。 第三步:把这些相关度最高的文本发送给 openapi,让他帮我们归纳。 对于第三步是否结合了 langchain 进行多步骤对答还不得而知,下次我准备抓包看一下这个程序与 openapi 的通信内容,才能解开其中的秘密。 当然,如果问题需要结合 PDF 所有内容才能概括出来,这种向量匹配的方式就不太行了,因为他总是发送与问题最相关的文本片段。但是呢,因为第三步的秘密还没有解决,很有可能当内容片段不够时,gpt4 会询问寻找更多相似片段,这样不断重复知道 gpt4 觉得可以回答了,再给出答案(想想觉得后背一凉)。 总结解读 PDF 的技术思路还可以用在任意问题上,比如网页搜索: 网页搜索就是一个典型的从知识海洋里搜索关键信息并解读的场景,只要背后将所有网页信息向量化,存储在某个向量数据库,就可以做一个 GPT 搜索引擎了,步骤是:一、将用户输入关键字分词并向量化。二:在数据库进行向量匹配,把匹配度最高的几个网页内容提取出来。三:把这些内容喂给 GPT,让他总结里面的知识并回答用户问题。 向量化可以解决任意场景模糊化匹配,比如我自己的备忘录会存储许多平台账号与密码,但有一天搜索 ChatGPT 密码却没搜到,后来发现关键词写成了 OpenAPI。向量化就可以解决这个问题,他可以将无法匹配的关键词也在备忘录里搜索到。 配合向量化搜索,再加上 GPT 的思考与总结能力,一个超级 AI 助手可做的事将会远远超过我们的想象。 留给大家一个思考题:结合向量化与 GPT 这两个能力,你还能想到哪些使用场景? 讨论地址是:精读《利用 GPT 解读 PDF》· Issue ##479 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《前后端渲染之争》","path":"/wiki/WebWeekly/前沿技术/《前后端渲染之争》.html","content":"当前期刊数: 3 本期精读的文章是:Here’s why Client-side Rendering Won 1 引言 我为什么要选这篇文章呢? 十年前,几乎所有网站都使用 ASP、Java、PHP 这类做后端渲染,但后来随着 jQuery、Angular、React、Vue 等 JS 框架的崛起,开始转向了前端渲染。从 2014 年起又开始流行了同构渲染,号称是未来,集成了前后端渲染的优点,但转眼间三年过去了,很多当时壮心满满的框架(rendr、Lazo)从先驱变成了先烈。同构到底是不是未来?自己的项目该如何选型?我想不应该只停留在追求热门和拘泥于固定模式上,忽略了前后端渲染之“争”的“核心点”,关注如何提升“用户体验”。 原文分析了前端渲染的优势,并没有进行深入探讨。我想以它为切入口来深入探讨一下。 明确三个概念:「后端渲染」指传统的 ASP、Java 或 PHP 的渲染机制;「前端渲染」指使用 JS 来渲染页面大部分内容,代表是现在流行的 SPA 单页面应用;「同构渲染」指前后端共用 JS,首次渲染时使用 Node.js 来直出 HTML。一般来说同构渲染是介于前后端中的共有部分。 2 内容概要前端渲染的优势 局部刷新。无需每次都进行完整页面请求 懒加载。如在页面初始时只加载可视区域内的数据,滚动后 rp 加载其它数据,可以通过 react-lazyload 实现 富交互。使用 JS 实现各种酷炫效果 节约服务器成本。省电省钱,JS 支持 CDN 部署,且部署极其简单,只需要服务器支持静态文件即可 天生的关注分离设计。服务器来访问数据库提供接口,JS 只关注数据获取和展现 JS 一次学习,到处使用。可以用来开发 Web、Serve、Mobile、Desktop 类型的应用 后端渲染的优势 服务端渲染不需要先下载一堆 js 和 css 后才能看到页面(首屏性能) SEO 服务端渲染不用关心浏览器兼容性问题(随着浏览器发展,这个优点逐渐消失) 对于电量不给力的手机或平板,减少在客户端的电量消耗很重要 以上服务端优势其实只有首屏性能和 SEO 两点比较突出。但现在这两点也慢慢变得微不足道了。React 这类支持同构的框架已经能解决这个问题,尤其是 Next.js 让同构开发变得非常容易。还有静态站点的渲染,但这类应用本身复杂度低,很多前端框架已经能完全囊括。 3 精读本次提出独到观点的同学有:@javie007 @杨森 @流形 @camsong @Turbe Xue @淡苍 @留影 @FrankFang @alcat2008 @xile611 @twobin @黄子毅 精读由此归纳。 大家对前端和后端渲染的现状基本达成共识。即前端渲染是未来趋势,但前端渲染遇到了首屏性能和 SEO 的问题。对于同构争议最多,在此我归纳一下。 前端渲染遇到的问题前端渲染主要面临的问题有两个 SEO、首屏性能。 SEO 很好理解。由于传统的搜索引擎只会从 HTML 中抓取数据,导致前端渲染的页面无法被抓取。前端渲染常使用的 SPA 会把所有 JS 整体打包,无法忽视的问题就是文件太大,导致渲染前等待很长时间。特别是网速差的时候,让用户等待白屏结束并非一个很好的体验。 同构的优点同构恰恰就是为了解决前端渲染遇到的问题才产生的,至 2014 年底伴随着 React 的崛起而被认为是前端框架应具备的一大杀器,以至于当时很多人为了用此特性而放弃 Angular 1 而转向 React。然而近 3 年过去了,很多产品逐渐从全栈同构的理想化逐渐转到首屏或部分同构。让我们再一次思考同构的优点真是优点吗? 有助于 SEO 首先确定你的应用是否都要做 SEO,如果是一个后台应用,那么只要首页做一些静态内容宣导就可以了。如果是内容型的网站,那么可以考虑专门做一些页面给搜索引擎时到今日,谷歌已经能够可以在爬虫中执行 JS 像浏览器一样理解网页内容,只需要往常一样使用 JS 和 CSS 即可。并且尽量使用新规范,使用 pushstate 来替代以前的 hashstate。不同的搜索引擎的爬虫还不一样,要做一些配置的工作,而且可能要经常关注数据,有波动那么可能就需要更新。第二是该做 sitemap 的还得做。相信未来即使是纯前端渲染的页面,爬虫也能很好的解析。 共用前端代码,节省开发时间 其实同构并没有节省前端的开发量,只是把一部分前端代码拿到服务端执行。而且为了同构还要处处兼容 Node.js 不同的执行环境。有额外成本,这也是后面会具体谈到的。 提高首屏性能 由于 SPA 打包生成的 JS 往往都比较大,会导致页面加载后花费很长的时间来解析,也就造成了白屏问题。服务端渲染可以预先使到数据并渲染成最终 HTML 直接展示,理想情况下能避免白屏问题。在我参考过的一些产品中,很多页面需要获取十几个接口的数据,单是数据获取的时候都会花费数秒钟,这样全部使用同构反而会变慢。 同构并没有想像中那么美 性能 把原来放在几百万浏览器端的工作拿过来给你几台服务器做,这还是花挺多计算力的。尤其是涉及到图表类需要大量计算的场景。这方面调优,可以参考 walmart 的调优策略。 个性化的缓存是遇到的另外一个问题。可以把每个用户个性化信息缓存到浏览器,这是一个天生的分布式缓存系统。我们有个数据类应用通过在浏览器合理设置缓存,双十一当天节省了 70% 的请求量。试想如果这些缓存全部放到服务器存储,需要的存储空间和计算都是很非常大。 不容忽视的服务器端和浏览器环境差异 前端代码在编写时并没有过多的考虑后端渲染的情景,因此各种 BOM 对象和 DOM API 都是拿来即用。这从客观层面也增加了同构渲染的难度。我们主要遇到了以下几个问题: document 等对象找不到的问题 DOM 计算报错的问题 前端渲染和服务端渲染内容不一致的问题 由于前端代码使用的 window 在 node 环境是不存在的,所以要 mock window,其中最重要的是 cookie,userAgent,location。但是由于每个用户访问时是不一样的 window,那么就意味着你得每次都更新 window。而服务端由于 js require 的 cache 机制,造成前端代码除了具体渲染部分都只会加载一遍。这时候 window 就得不到更新了。所以要引入一个合适的更新机制,比如把读取改成每次用的时候再读取。 export const isSsr = () => ( !(typeof window !== 'undefined' && window.document && window.document.createElement && window.setTimeout)); 原因是很多 DOM 计算在 SSR 的时候是无法进行的,涉及到 DOM 计算的的内容不可能做到 SSR 和 CSR 完全一致,这种不一致可能会带来页面的闪动。 内存溢出 前端代码由于浏览器环境刷新一遍内存重置的天然优势,对内存溢出的风险并没有考虑充分。比如在 React 的 componentWillMount 里做绑定事件就会发生内存溢出,因为 React 的设计是后端渲染只会运行 componentDidMount 之前的操作,而不会运行 componentWillUnmount 方法(一般解绑事件在这里)。 异步操作 前端可以做非常复杂的请求合并和延迟处理,但为了同构,所有这些请求都在预先拿到结果才会渲染。而往往这些请求是有很多依赖条件的,很难调和。纯 React 的方式会把这些数据以埋点的方式打到页面上,前端不再发请求,但仍然再渲染一遍来比对数据。造成的结果是流程复杂,大规模使用成本高。幸运的是 Next.js 解决了这一些,后面会谈到。 simple store(redux) 这个 store 是必须以字符串形式塞到前端,所以复杂类型是无法转义成字符串的,比如 function。 总的来说,同构渲染实施难度大,不够优雅,无论在前端还是服务端,都需要额外改造。 首屏优化再回到前端渲染遇到首屏渲染问题,除了同构就没有其它解法了吗?总结以下可以通过以下三步解决 分拆打包 现在流行的路由库如 react-router 对分拆打包都有很好的支持。可以按照页面对包进行分拆,并在页面切换时加上一些 loading 和 transition 效果。 交互优化 首次渲染的问题可以用更好的交互来解决,先看下 linkedin 的渲染 有什么感受,非常自然,打开渲染并没有白屏,有两段加载动画,第一段像是加载资源,第二段是一个加载占位器,过去我们会用 loading 效果,但过渡性不好。近年流行 Skeleton Screen 效果。其实就是在白屏无法避免的时候,为了解决等待加载过程中白屏或者界面闪烁造成的割裂感带来的解决方案。 部分同构 部分同构可以降低成功同时利用同构的优点,如把核心的部分如菜单通过同构的方式优先渲染出来。我们现在的做法就是使用同构把菜单和页面骨架渲染出来。给用户提示信息,减少无端的等待时间。 相信有了以上三步之后,首屏问题已经能有很大改观。相对来说体验提升和同构不分伯仲,而且相对来说对原来架构破坏性小,入侵性小。是我比较推崇的方案。 3 总结我们赞成客户端渲染是未来的主要方向,服务端则会专注于在数据和业务处理上的优势。但由于日趋复杂的软硬件环境和用户体验更高的追求,也不能只拘泥于完全的客户端渲染。同构渲染看似美好,但以目前的发展程度来看,在大型项目中还不具有足够的应用价值,但不妨碍部分使用来优化首屏性能。做同构之前 ,一定要考虑到浏览器和服务器的环境差异,站在更高层面考虑。 附:Next.js 体验Next.js 是时下非常流行的基于 React 的同构开发框架。作者之一就是大名鼎鼎的 Socket.io 的作者 Guillermo Rauch。它有以下几个亮点特别吸引我: 巧妙地用标准化的解决了请求的问题。同构和页面开发类似,异步是个大难题,异步中难点又在接口请求。Next.js 给组件新增了 getInitialProps 方法来专门处理初始化请求,再也不用手动往页面上塞 DATA 和调用 ReactDOMServer.renderToString 使用 styled-jsx 解决了 css-in-js 的问题。这种方案虽然不像 styled-component 那样强大,但足够简单,可以说是最小的成本解决了问题 Fast by default。页面默认拆分文件方式打包,支持 Prefetch 页面预加载 全家桶式的的解决方案。简洁清晰的目录结构,这一点 Redux 等框架真应该学一学。不过全家桶的方案比较适合全新项目使用,旧项目使用要评估好成本 讨论地址是:前后端渲染之争 · Issue ##5 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《前端数据流哲学》","path":"/wiki/WebWeekly/前沿技术/《前端数据流哲学》.html","content":"当前期刊数: 42 本系列分三部曲:《框架实现》 《框架使用》 与 《数据流哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。 本篇是收官之作 《前端数据流哲学》。 1 引言写这篇文章时,很有压力,如有不妥之处,欢迎指正。 同时,由于这是一篇佛系文章,所以不会得出你应该用 某某 框架的结论,你应该当作消遣来阅读。 2 精读首先数据流管理模式,比较热门的分为三种。 函数式、不可变、模式化。典型实现:Redux - 简直是正义的化身。 响应式、依赖追踪。典型实现:Mobx。 响应式,和楼上区别是以流的形式实现。典型实现:Rxjs、xstream。 当然还有第四种模式,裸奔,其实有时候也挺健康的。 数据流使用通用的准则是:副作用隔离、全局与局部状态的合理划分,以上三种数据流管理模式都可以实现,唯有是否强制的区别。 2.1 从时间顺序说起一直在思考如何将这三个思维串起来,后来想通了,按照时间顺序串起来就非常自然。 暂时略过 Prototype、jquery 时代,为什么略过呢?因为当时前端还在野蛮人时代,生存问题都没有解决,哪还有功夫思考什么数据流,设计模式?前端也是那时候被觉得比后端水的。 好在前端发展越来越健康,大坑小坑被不断填上,加上硬件性能的提高,同时需求又越来越复杂,是时候想想该如何组织代码了。 最先映入眼帘的是 angular,搬来的 mvvm 思想真是为前端开辟了新的世界,发现代码还可以这么写!虽然 angluar 用起来很重,但 mvvm 带来的数据驱动思想已经越来越深入人心,随后 react 就突然火起来了。 其实在 react 火起来之前,有一个框架一步到位,进入了 react + mobx 时代,对,就是 avalon。avalon 也非常火,但是一个框架要成功,必须天时、地利、人和,当时时机不对,大家处于 angular 疲惫期,大多投入了 react 的怀抱。 可能有些主观,但我觉得 react 能火起来,主要因为大家认为它就是轻量 angular + 继承了数据驱动思想啊,非常符合时代背景,同时一大波概念被炒得火热,状态驱动、单向数据流等等,基本上用过 angular 的人都跟上了这波节奏。 虽然 react 内置了分形数据流管理体系,但总是强调自己只是 View 层,于是数据层增强的框架不断涌现,从 flux、reflux、到 redux。不得不说,react 真的推动了数据流管理的独立,让我们重新认识了数据流管理的重要性。 redux 概念太超前了,一步到位强制把副作用隔离掉了,但自己又没有深入解决带来的代码冗余问题,让我们又爱又恨,于是一部分人把目光转向了 mobx,这个响应式数据流框架,这个没有强制分离副作用,所以写起来很舒服的框架。 当然 mobx 如果仅仅是 mvvm 就不会火起来了,毕竟 angular 摆在那。主要是乘上了 react 这趟车,又有很多质疑 angular 脏检测效率的声音,mobx 也火了起来。当然,作为前端的使命是优化人机交互,所以我们都知道,用户习惯是最难改变的,直到现在,redux 依然是绝对主流。 mobx 还在小范围推广时,另一个更偏门的领域正刚处于萌芽期,就是 rxjs 为代表的框架,和 mobx 公用一个 observable 名词,大家 mobx 都没搞清楚,更是很少人会去了解 rxjs。 当 mobx 逐渐展露头角时,笔者做了一个类似的库:dob。主要动机是 mobx 手感还不够完美,对于新赋值变量需要用一些 extendObservable 等 api 修饰,正好发现浏览器对 proxy 支持已经成熟,因此笔者后来几乎所有个人项目几乎都用 dob 替代了 mobx。 这一时期三巨头之一的 vue 火了起来,成功利用:如果 ”react + mobx 很好用,那为什么不用 vue?“ 的 flag 打动了我。 一直到现在,前端已经发展到可谓五花八门的地步,typescript 打败 flow 几乎成为了新的 js,出现了 ember、clojurescript 之后,各大语言也纷纷出了到 js 的编译实现,陆陆续续的支持编译到 webassembly,react 作者都弃坑 js 创造了新语言 reason。 之前写过一篇初步认识 reason 的精读。 能接下来这一套精神洗礼的前端们,已经养出内心波澜不惊的功夫,小众已经不会成为跨越舒适区的门槛,再学个 rxjs 算啥呢?(开个玩笑,rxjs 社区不乏深耕多年的巨匠)所以最近 rxjs 又被炒的火热。 所以,从时间顺序来看,我们可以从 redux - mobx - rxjs 的顺序解读这三个框架。 2.2 redux 带来了什么redux 是强制使用全局 store 的框架,尽管无数人在尝试将其做到局部化。 当然,一方面是由于时代责任,那时需要一个全局状态管理工具,弥补 react 局部数据流的不足。最重要的原因,是 redux 拥有一套几乎洁癖般完美的定位,就是要清晰、可回溯。 几乎一切都是为了这两个词准备的。第一步就要从分离副作用下手,因为副作用是阻碍代码清晰、以及无法回溯的第一道障碍,所以 action + reducer 概念闪亮登场,完美解决了副作用问题。可能是参考了 koa 中间件的设计思路,redux middleware 将 action 对接到 reducer 的黑盒的控制权暴露给了开发者。 由 redux middleware 源码阅读引发的函数式热,可能又拉近了开发者对 rxjs 的好感。同时高阶函数概念也在中间件源码中体现,几乎是为 react 高阶组件做铺垫。 社区出现了很多方案对 redux 异步做支持,从 redux-thunk 到 redux-saga,redux 带来的异步隔离思想也逐渐深入人心。同时基于此的一套高阶封装框架也层出不穷,建议用一个就好,比如 dva。 第二步就是解决阻碍回溯的“对象引用”机制,将 immutable 这套庞大思想搬到了前端。这下所有状态都不会被修改,基于此的 redux-dev-tools “时光机” 功能让人印象深刻。 Immutable 具体实现可以参考笔者之前写的一篇精读:精读 Immutable 结构共享。 当然,由于很像事件机制的 dispatch 导致了 redux 对 ts 支持比较繁琐,所以对 redux 的项目,维护的时候需要频繁使用全文搜索,以及至少在两个文件间来回跳跃。 2.3 mobx 带来了什么mobx 是一个非常灵活的 TFRP 框架,是 FRP 的一个分支,将 FRP 做到了透明化,也可以说是自动化。 从函数式(FP),到 FRP,再到 TFRP,之间只是拓展关系,并不意味着单词越长越好。 之前说过了,由于大家对 redux 的疲劳,让 mobx 得以迅速壮大,不过现在要从另一个角度分析。 mobx 带来的概念从某种角度看,与 rxjs 很像,比如,都说自己的 observable 有多神奇。那么 observable 到底是啥呢? 可以把 observable 理解为信号源,每当信号变化时,函数流会自动执行,并输出结果,对前端而言,最终会使视图刷新。这就是数据驱动视图。然而 mobx 是 TFRP 框架,每当变量变化时,都会自动触发数据源的 dispatch,而且各视图也是自动订阅各数据源的,我们称为依赖追踪,或者叫自动依赖绑定。 笔者到现在还是认为,TFRP 是最高效的开发方式,自动订阅 + 自动发布,没什么比这个更高效了。 但是这种模式有一个隐患,它引发了副作用对纯函数的污染,就像 redux 把 action 与 reducer 合起来了一样。同时,对 props 的直接修改,也会导致与 react 对 props 的不可变定义冲突。因此 mobx 后来给出了 action 解决方案,解决了与 react props 的冲突,但是没有解决副作用未强制分离的问题。 笔者认为,副作用与 mutable 是两件事,关于 mutable 与副作用的关系,后文会有说明。也就是 mobx 没有解决副作用问题,不代表 TFRP 无法分离副作用,而且 mutable 也不一定与 可回溯 冲突,比如 mobx-state-tree,就通过 mutable 的方式,完成了与 redux 的对接。 前端对数据流的探索还在继续,mobx 先提供了一套独有机制,后又与 redux 找到结合点,前端探索的脚步从未停止。 2.4 rxjs 带来了什么rxjs 是 FRP 的另一个分支,是基于 Event Stream 的,所以从对 view 的辅助作用来说,相比 mobx,显得不是那么智能,但是对数据源的定义,和 TFRP 有着本质的区别,似的 rxjs 这类框架几乎可以将任何事件转成数据源。 同时,rxjs 其对数据流处理能力非常强大,当我们把前端的一切都转为数据源后,剩下的一切都由无所不能的 rxjs 做数据转换,你会发现,副作用已经在数据源转换这一层完全隔离了,接下来会进入一个美妙的纯函数世界,最后输出到 dom driver 渲染,如果再加上虚拟 dom 的点缀,那岂不是。。岂不就是 cyclejs 吗? 多提一句,rxjs 对数据流纯函数的抽象能力非常强大,因此前端主要工作在于抽一个工具,将诸如事件、请求、推送等等副作用都转化为数据源。cyclejs 就是这样一个框架:提供了一套上述的工具库,与 dom 对接增加了虚拟 dom 能力。 rxjs 给前端数据流管理方案带来了全新的视角,它的概念由 mobx 引发,但解题思路却与 redux 相似。 rxjs 带来了两种新的开发方式,第一种是类似 cyclejs,将一切前端副作用转化为数据源,直接对接到 dom。另一种是类似 redux-observable,将 rxjs 数据流处理能力融合到已有数据流框架中, redux-observable 将 action 与 reducer 改造为 stream 模式,对 action 中副作用行为,比如发请求,也提供了封装好的函数转化为数据源,因此,将 redux middleware 中的副作用,转移到了数据源转换做成中,让 action 保持纯函数,同时增强了原本就是纯函数的 reducer 的数据处理能力,非常棒。 如果说 redux-saga 解决了异步,那么 redux-observable 就是解决了副作用,同时赠送了 rxjs 数据处理能力。 回头看一下 mobx,发现 rxjs 与 mobx 都有对 redux 的增强方案,前端数据流的发展就是在不断交融。 我们不但在时间线上,将 redux、mobx、rxjs 串了起来,还发现了他们内在的关联,这三个思想像一张网,复杂的交织在一起。 2.5 可以串起来些什么了我们发现,redux 和 rxjs 完全隔离了副作用,是因为他们有一个共性,那就是对前端副作用的抽象。 redux 通过在 action 做副作用,将副作用隔离在 reducer 之外,使 reducer 成为了纯函数。 rxjs 将副作用先转化为数据源,将副作用隔离在管道流处理之外。 唯独 mobx,缺少了对副作用抽象这一层,所以导致了代码写的比 redux 和 rxjs 更爽,但副作用与纯函数混杂在一起,因此与函数式无缘。 有人会说,mobx 直接 mutable 改变对象也是导致副作用的原因,笔者认为是,也不是,看如下代码: obj.a = 1 这段代码在 js 中铁定是 mutable 的?不一定,同样在 c++ 这些可以重载运算符的语言中也不一定了,setter 语法不一定会修改原有对象,比如可以通过 Object.defineProperty 来重写 obj 对象的 setter 事件。 由此我们可以开一个脑洞,通过运算符重载,让 mutable 方式得到 immutable 的结果。在笔者博客 Redux 使用可变数据结构 有说明原理和用法,而且 mobx 作者 mweststrate 是这么反驳那些吐槽 mobx 缺少 redux 历史回溯能力的声音的: autorun(() => { snapshots.push(Object.assign({}, obj))}) 思路很简单,在对象有改动时,保存一张快照,虽然性能可能有问题。这种简单的想法开了个好头,其实只要在框架层稍作改造,便可以实现 mutable 到 immutable 的转换。 比如 mobx 作者的新作:immer 通过 proxy 元编程能力,将 setter 重写为 Object.assign() 实现 mutable 到 immutable 的转换。 笔者的 dob-redux 也通过 proxy,调用 Immutablejs.set() 实现 mutable 到 immutable 的转换。 组件需要数据流吗真的是太看场景了。首先,业务场景的组件适合绑定全局数据流,业务无关的通用组件不适合绑定全局数据流。同时,对于复杂的通用组件,为了更好的内部通信,可以绑定支持分形的数据流。 然而,如果数据流指的是 rxjs 对数据处理的过程,那么任何需要数据复杂处理的场合,都适合使用 rxjs 进行数据计算。同时,如果数据流指的是对副作用的归类,那任何副作用都可以利用 rxjs 转成一个数据源归一化。当然也可以把副作用封装成事件,或者 promise。 对于副作用归一化,笔者认为更适合使用 rxjs 来做,首先事件机制与 rxjs 很像,另外 promise 只能返回一次,而且之后 resolve reject 两种状态,而 Observable 可以返回多次,而且没有内置的状态,所以可以更加灵活的表示状态。 所以对于各类业务场景,可以先从人力、项目重要程度、后续维护成本等外部条件考虑,再根据具体组件在项目中使用场景,比如是否与业务绑定来确定是否使用,以及怎么使用数据流。 可能在不远的未来,布局和样式工作会被 AI 取代,但是数据驱动下数据流选型应该比较难以被 AI 取代。 再次理解 react + mobx 不如用 vue 这句话首先这句话很有道理,也很有分量,不过笔者今天将从一个全新的角度思考。 经过前面的探讨,可以发现,现在前端开发过程分为三个部分:副作用隔离 -> 数据流驱动 -> 视图渲染。 先看视图渲染,不论是 jsx、或 template,都是相同的,可以互相转化的。 再看副作用隔离,一般来说框架也不解决这个问题,所以不管是 react/ag/vue + redux/mobx/rxjs 任何一种组合,最终你都不是靠前面的框架解决的,而是利用后面的 redux/mobx/rxjs 来解决。 最后看数据流驱动,不同框架内置的方式不同。react 内置的是类 redux 的方式,vue/angular 内置的是类 mobx 的方式,cyclejs 内置了 rxjs。 这么来看,react + redux 是最自然的,react + mobx 就像 vue + redux 一样,看上去不是很自然。也就是 react + mobx 别扭的地方仅在于数据流驱动方式不同。对于视图渲染、副作用隔离,这两个因素不受任何组合的影响。 就数据流驱动问题来看,我们可以站在更高层面思考,比如将 react/vue/angular 的语法视为三种 DSL 规范,那其实可以用一种通用的 DSL 将其描述,并转换对应的 DSL 对接不同框架(阿里内部已经有这种实现了)。而这个 DSL 对框架内置数据流处理过程也可以屏蔽,举个例子: <button onClick={() => { setState(() => { data: { name: 'nick' } })}}> {data.name}</button> 如果我们将上面的通用 jsx 代码转换为通用 DSL 时,会使用通用的方式描述结构以及方法,而转化为具体 react/vue/angluar 代码时,就会转化为对应内置数据流方案的实现。 所以其实内置数据流是什么风格,在有了上层抽象后,是可以忽略的,我们甚至可以利用 proxy,将 mutable 的代码转换到 react 时,改成 immutable 模式,转到 vue 时,保持 mutable 形式。 对框架封装的抽象度越高,框架之间差异就越小,渐渐的,我们会从框架名称的讨论中解放,演变成对框架 + 数据流哪种组合更加合适的思考。 3 总结最近梳理了一下 gaea-editor - 笔者做的一个 web designer,重新思考了其中插件机制,拿出来讲一讲。 首先大体说明一下,这个编辑器使用 dob 作为数据流,通过 react context 共享数据,写法和 mobx 很像,不过这不是重点,重点是插件拓展机制也深度使用了数据流。 什么是插件拓展机制?比如像 VScode 这些编辑器,都拥有强大的拓展能力,开发者想要添加一个功能,可以不用学习其深奥的框架内容,而是读一下简单明了的插件文档,使用插件完成想要功能的开发。解耦的很美好,不过重点是插件的能力是否强大,插件可以触及内核哪些功能、拿到哪些信息、拥有哪些能力? 笔者的想法比较激进,为了让插件拥有最大能力,这个 web designer 所有内核代码都是用插件写的,除了调用插件的部分。所以插件可以随意访问和修改内核中任何数据,包括 UI。 让 UI 拥有通用能力比较容易,gaea-editor 使用了插槽方式渲染 UI,也就是任何插件只要提供一个名字,就能嵌入到申明了对应名字的 UI 插槽中,而插件自己也可以申明任意数量的插槽,内核中也有几个内置的插槽。这样插件的 UI 能力极强,任何 UI 都可以被新的插件替代掉,只要申明相同的名字即可。 剩下一半就是数据能力,笔者使用了依赖注入,将所有内核、插件的 store、action 全量注入到每一个插件中: @Connectclass CustomPlugin extends React.PureComponent { render() { // this.props.Actions, this.props.Stores }} 同时,每个插件可以申明自己的 store,程序初始化时会合并所有插件的 store 到内存中。因此插件几乎可以做任何事,重写一套内核也没有问题,那么做做拓展更是轻松。 其实这有点像 webpack 等插件的机制: export default (context) => {} 每次申明插件,都可以从函数中拿到传来的数据,那么通过数据流的 Connect 能力,将数据注入到组件,也是一种强大的插件开发方式。 更多思考通过上面插件机制的例子会发现,数据流不仅定义了数据处理方式、副作用隔离,同时依赖注入也在数据流功能列表之中,前端数据流是个很宽泛的概念,功能很多。 redux、mobx、rxjs 都拥有独特的数据处理、副作用隔离方式,同时对应的框架 redux-react、mobx-react、cyclejs 都补充了各种方式的依赖注入,完成了与前端框架的衔接。正是应为他们纷纷将内核能力抽象了出来,才让 redux+rxjs mobx+rxjs 这些组合成为了可能。 未来甚至会诞生一种完全无数据管理能力的框架,只做纯 view 层,内核原生对接 redux、mobx、rxjs 也不是没有可能,因为框架自带的数据流与这些数据流框架比起来,太弱了。 react stateless-component 就是一种尝试,不过现在这种纯 view 层组件配合数据流框架的方式还比较小众。 纯 view 层不代表没有数据流管理功能,比如 props 的透传,更新机制,都可以是内置的。 不过笔者认为,未来的框架可能会朝着 view 与数据流完全隔离的方式演化,这样不但根本上解决了框架 + 数据流选择之争,还可以让框架更专注于解决 view 层的问题。 从有到无HTML5 有两个有意思的标签:details, summary。通过组合,可以达到 details 默认隐藏,点击 summary 可以 toggle 控制 details 下内容的效果: <details> <summary>标题</summary> <p>内容</p> </details> 更是可以通过 css 覆盖,完全实现 collapse 组件的效果。 当然就 collapse 组件来说,因为其内部维持了状态,所以控制折叠面板的 打开/关闭 状态,而 HTML5 的 details 也通过浏览器自身内部状态,对开发者只暴露 css。 在未来,浏览器甚至可能提供更多的原生上层组件,而组件内部状态越来越不需要开发者关心,甚至,不需要开发者再引用任何一个第三方通用组件,HTML 提供足够多的基础组件,开发者只需要引用 css 就能实现组件库更换,似乎回到了 bootstrap 时代。 有人会说,具有业务含义的再上层组件怎么提供?别忘了 HTML components,这个规范配合浏览器实现了大量原生组件后,可能变得异常光彩夺目,DSL 再也不需要了,HTML 本身就是一套通用的 DSL,框架更不需要了,浏览器内置了一套框架。 插一句题外话,所有组件都通过 html components 开发,就真正意义上实现了抹平框架,未来不需要前端框架,不需要 react 到 vue 的相互转化,组件加载速度提高一个档次,动态组件 load 可能只需要动态加载 css,也不用担心不同环境/框架下开发的组件无法共存。前端发展总是在进两步退一步,不要形成思维定式,每隔一段时间,需要重新审视下旧的技术。 话题拉回来,从浏览器实现的 details 标签来看,内部一定有状态机制,假如这套状态机制可以提供给开发者,那数据流的 数据处理、副作用隔离、依赖注入 可能都是浏览器帮我们做了,redux 和 mobx 会立刻失去优势,未来潜力最大的可能是拥有强大纯函数数据流处理能力的 rxjs。 当然在 2018 年,redux 和 mobx 依然会保持强大的活力,就算在未来浏览器内置的数据流机制,rxjs 可能也不适合大规模团队合作,尤其在现在有许多非前端岗位兼职前端的情况下。 就像现在 facebook、google 的模式一样,在未来的更多年内,前后端,甚至 dba 与算法岗位职能融合,每个人都是全栈时,可能 rxjs 会在更大范围被使用。 纵观前端历史,数据流框架从无到有,但在未来极有可能从有变到无,前端数据流框架消失了,但前端数据流思想永远保留了下来,变得无处不在。 4 更多讨论 讨论地址是:精读《前端数据流哲学》 · Issue ##58 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《前端与 BI》","path":"/wiki/WebWeekly/前沿技术/《前端与 BI》.html","content":"当前期刊数: 121 简介商业智能(Business Intelligence)简称 BI,即通过数据挖掘与分析找到商业洞察,助力商业成功。 一个完整的 BI 链路包含数据采集、数据清洗、数据挖掘、数据展现,其本质是对数据进行多维分析。前端的主要工作在数据展现环节,由于展示方式繁多、分析模型复杂且数据量大,前端环节的复杂度很高。 在 BI 做前端非常有挑战,开发者需要充分理解数据概念,而本身复杂度较高的可视化建站也只是 BI 的基础能力,想要建设 BI 的上层能力,比如探索式分析和数据洞察,都需要在前后端引入更复杂的计算模型。 本文作为一个引子,简单介绍笔者做 BI 的经验,后面如果有机会再写一个系列文章对细节进行阐述。 精读国内目前处于 BI 1.0 阶段,也就是报表阶段,因此笔者将阐述这个阶段 BI 的核心开发概念。 BI 2.0 探索式分析阶段是国内数据分析最前沿领域,这部分等开发完成后再分享。 BI 1.0 阶段的核心概念包括 数据集、渲染引擎、数据模型、可视化 这四个技术模块。 数据集数据集即数据的集合,在 BI 领域更多指一种标准化的数据结构。 任何数据都可以封装成数据集,比如 txt 文本、excel、mysql 数据库等等。 数据集的基本形态是二维表格,列头表示字段,每一行就是一份数据,数据展示就是通过对这些数据字段进行多维度分析。 数据集导入一般来说数据集导入有两种方式,分别是本地文件上传与数据库链接。本地文件上传又分为多种文件类型处理,比如对 excel 的解析,可能还包括数据清洗;数据库链接分析可视化导入与 SQL 输入。 可视化导入需要提前对数据库进行结构分析,绘制出表结构与字段结构,不用理解 SQL 也可以进行可视化操作。 SQL 输入可以利用 monaco-editor 等 web 代码编辑器作为输入框,最好能结合智能提示提高 sql 编写效率。sql 智能提示可以参考往期精读 精读《手写 SQL 编译器 - 智能提示》。 数据集建模数据集建模一般包含 维度度量建模、字段配置、层系建模。 维度度量建模需要智能分析出字段属于维度还是度量,一般会结合字段实际的值或者字段名来智能判断字段类型,如果数据库信息中已存储了字段类型,就可以 100% 准确归类。 字段配置即对字段进行增删或修改,还可以新增聚合字段或对比字段。 聚合字段是指将一个字段表达式封装为一个新字段,这里也会用到一个简单的 sql 编辑器,只需要支持四则运算、字段提示、以及一些基本函数的组合即可。 对比字段是指新增的字段是基于已有字段在某个时间周期内的对比,比如对 UV 字段的年同比就可以封装为一个对比字段。对比字段在前端技术上没有什么难度,仅需理解概念即可。 渲染引擎渲染引擎包括了对报表进行编辑与渲染的引擎,理论上可以合二为一。 渲染引擎的重要模块包括:画布拖拽、组件编辑、事件中心。 画布拖拽其实包含了组件自定义开发流程,到 CDN 发布、CDN 加载、组件拖拽、画布排版等一系列技术点,每个点展开都有写不完的细节,但好在这套功能属于通用建站基础功能点,本文就不再赘述。 组件编辑中,基本属性的编辑与属于通用建站领域的表单模型范畴,一般通过 UISchema 来描述通用表单,这块也不再赘述。组件编辑的另一部分就是数据编辑,这部分在后面数据模型章节里详细讲。 事件中心是渲染引擎部分,此功能在编辑状态需要禁用。这个功能可以实现图表联动、上卷下钻等数据能力。一个通用事件中心一般包括 事件触发 与 事件响应 两部分,基本结构如下: interface Event { trigger: | { type: 'callback'; callbackName: string; } | { type: 'listener'; eventName: string; } | { type: 'system'; name: string; }; action: | { type: 'dispatch'; eventName: string; } | { type: 'jumpUrl'; url: string; }} trigger 即事件触发,包括基本的系统事件 system,比如定时器或者初始化自动触发;组件的回调 callback 比如当按钮被点击时;事件监听 listener 比如另一个事件被触发时,这个事件可能来自于 action。 action 即事件响应,包括基本的事件触发 dispatch,可以触发其他事件,可以构成一个事件链路;其他的 action 就是数据相关,可以用来做条件联动、字段联动、数据集联动等等,因为实现各异这里不做介绍。 事件机制还需要支持值传递,即事件触发源的值可以传递到事件响应方。值传递可以在触发源内部进行,比如当触发源是回调函数时,函数参数就自然作为值传递过去,触发源通过 ...args 方式接收。 数据钻取配置了层系的字段都可以进行数据钻取。层系可以在数据集配置,也可以在报表编辑页配置,可以理解为一个顺序有关的文件夹,将文件夹作为字段使用时,默认生效的是第一个子元素,之后可以按照顺序分别进行下钻。 比如 “地区” 层系包含了国家、省、市、区,那么就可以按照这个层级进行数据上卷下钻。 如果一个字段是层系字段,图表需要有对应的操作区域进行上卷下钻,数据编辑区域也可以进行同样操作。数据钻取的计算过程不在图表内部处理,而是触发一个状态后,由渲染引擎将这个层系字段实例状态改为下钻到第 N 层,并且每下钻一次就多拿到一列的数据,由图表组件进行下钻展示。 一般来说下钻后数据仍是全量的,有时候为了避免数据量过大,比如在柱状图点击某个柱子进行下钻,只想看这个柱子下钻后的数据:比如 2017、2018、2019 年三年的数据,下钻到月后数据量是 3 x 12 = 36 条,但如果仅在 2019 年进行下钻,只想看 2019 年的 12 条数据,可以转化为下钻 + 筛选条件的模式:全局下钻展开后 36 条,在 2019 年上点击下钻后,增加一个筛选条件(年 = 2019),这样就达到了效果,整个流程对图表组件是无感知的。 数据模型与通用表单模型 UISchema 相对应,数据模型笔者称之为 CubeSchema,因为 BI 领域对数据的多维处理模型成为 Cube 立方体,数据配置即表示如何对这个立方体进行查询,因此其配置表单成为 CubeSchema。 不管是探索式分析还是 BI 1.0 的报表阶段,数据模型的基本概念是通用的(探索式分析固定了行列,且增加了标记):将字段放置到不同的区域,这些区域的划分方式可以按照功能:横轴、纵轴;按照概念:维度、度量;按照探索分析思路:固化为行、列等等。 这块可能涉及到的技术点有:拖拽、批量选择+拖拽、双击后按照维度度量自动添加、图表切换后区域字段自动迁移、对字段拖拽的系列配置:限制数量、限制类型、限制数据集、是否重复等等。 拖拽可以用 react-beautiful-dnd 等库,与渲染引擎拖拽方案基本类似,遇到有层系的数据集还需支持嵌套层级的拖拽。 图表切换后字段迁移,可以将每个拖拽区域设置若干类型: { "dataType": ["dimension"]} 这样在切换后,维度类型的字段可以自动迁移到维度类型区域,如果对应区域字段数量达到了 limit 限制,就继续填充到下一个区域,直到字段用尽或区域填充完为止。 如果在探索式分析场景里,需要提前对字段进行维度度量建模,在切换时按照图表情况进行相应的处理。比如折线图切换到表格的情况:折线图是天然一个维度(主轴) + N 个度量的场景,表格是天然两个维度(行、列)+ 1 个度量的场景(也可以支持多个,对单元格进行再切分即可),那么从折线图切换到表格时,度量就会落到标记的文本区域;如果从拥有行和列的表格切换到柱状图(之所以无法切换到折线图,是因为表格的度量值一般是离散的,而折线图度量值一般是连续的),表格的行与列的字段会落到柱状图的维度轴,表现效果是对维度轴进行下钻。 精读《Tableau 探索式模型》 了解更多探索式分析。 数据模型还包括数据分析相关配置,比如设置对比字段,或者均值线等分析功能。这些数据计算工作放在后端,前端需要将配置项整理到取数接口中,并按照数据驱动的方式展现。 对于对比字段等 “拓展字段” 的分析功能,可以拓展通用取数接口,图表组件无感知,相当于多添加了几个隐藏字段;去特殊值等对标准数据进行操作的情况图表组件也无需感知。 聚类、均值线等需要图表组件额外展示的部分抽象为一套固定的数据格式透传给图表组件,由图表组件自行处理。 可以看出来,都是取数 + 展示,普通的前端业务与 BI 业务开发的区别: 普通前端业务是以业务逻辑为核心的,根据业务需要确定接口格式;BI 业务是以数据为核心的,围绕数据计算模型确定一套固定的接口格式,取数不依赖组件,所有组件对标准数据都有对应的展现。 可视化与普通可视化组件不同,BI 可视化组件需要对接 CubeSchema 模型,同时还要支持 大数据性能优化、边界数据展示优化、交互响应。 对接 CubeSchema 即统一对接二维表格的数据,大部分组件都是二维以上结构展示,因此对接起来并不困难,有一些一维数据结构的组件比如单指标块就要舍弃其中的某一维,需要确定一套规则。 二维以上部分是较为通用的,虽然计算模型是基于 Cube N 维的,但组件可以通过标准轴进行多维度展开,或者说下钻来实现类似效果。对于折线图来说,轴的含义有限,可以用分面的方式展示多维数据。当然也有一些组件只适合展示特定维度数量的数据。 大数据性能优化可视化组件特别需要关注性能优化,因为 BI 查询出的数据量可能非常大,特别是多层下钻或基于地理的数据。 技术手段包括 GPU 渲染、缓存 canvas、多线程运算等,业务手段包括数据抽样、按需渲染可视区域、限制数据条数等等。 边界数据展示优化永远不知道数据集会给出怎样的数据,因此 BI 边界情况特别多,可能点非常密集,也可能丢失一些数据导致渲染异常。图表组件需要利用避让算法将密集的数据打散或着色,目的是为了容易阅读,对于丢失的异常数据也要有保护性的补全机制。 交互响应包括上卷下钻、点选、圈选、高亮等交互操作,这些操作反馈到渲染引擎导致数据变化并将新的数据灌入图表组件。 业务逻辑上这些交互操作并不复杂,难点在使用的可视化库是否有这个能力,以及如何统一交互行为。 总结BI 领域的四大方向:数据集、渲染引擎、数据模型与可视化都有许多可以做深的技术点,每一块都需要深入沉淀几年技术经验才能做好,需要大量优秀人才通力协作才有可能做好。 目前我们在阿里数据中台正在打造一款面向未来的优秀 BI 工具,如果 BI 领域让你觉得有挑战,随时欢迎你的加入,联系邮箱:ziyi.hzy@alibaba-inc.com 讨论地址是:精读《前端与 BI》 · Issue ##208 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《前端深水区》","path":"/wiki/WebWeekly/前沿技术/《前端深水区》.html","content":"当前期刊数: 119 作者:五灵 简介其实关于前端深水区的讨论,已经有了很多,也有了很多相关的文章。我也想借这篇关于深水区的讨论文章,讲一下自己对于深水区的理解。原文链接:技术路线:前端开发已进入深水区 本期精读,@camsong、@arcthur、@ascoders 都有贡献观点。 概述原文对于深水区的想法,讲的很清楚,还是建议读者去读一下原文。对比 2010 年,整个前端生态已经翻新了好几遍,直到近几年的 Node BFF、IDE Cloud,抑或是客户端 AI,还是 Serverless 的建设,前端想要深度参与的话,单纯依靠原来的 HTML/CSS/JS 三件套技能也远远不够了。再抛开技术,整个互联网创业生态也重构了好几遍。无论是技术层面还是意识层面,如今的前端开发已经进入深水区。 深水区需要哪些技能深水区需要是四个核心能力,分别是:技术、产品、业务和管理能力。 面对深水压力不需紧张其实何止前端开发,整个技术行业都已步入深水区,只是前端工程师的感知来的晚一些而已。只要把眼光投向深水区,问题就会一个接一个的浮上来,当越来越多问题浮起来的时候,就是你慢慢沉向深水区的时候,这时候不需要太过紧张。 精读深水区的理解首先需要达成一致,并不只是一个维度的加深,而是全方位多方面的困难同时加击,压强升高、光线减少、温度剧变等等。 对应到文中总结的解法就是需要『技术创新、流程优化、团队合作、影响大盘、驱动业务、商业决策和团队管理』。但你展开想一下,把这个角色换成后端、无线端、甚至是 UED,是不是也能完美匹配。所以这些能力应该是技术人员发展到一定程度面临的普遍问题而不仅仅是前端。 但这些能力是否有个更好的概括?当然有,就是明确一个方向并带领一群人完成目标并实线商业价值。这其实就是商业或者说业务的整个运作过程。 这其实也在抛一个命题,前端发展到一定程度就一定要转业务吗?是也不是。当然要转,但并不是全转。全转业务你过去的积累有什么用?不转业务单纯前端能发挥的影响力就会受限。所以答案是利用前端技术优势同时补充业务能力推动商业流程。 所以此文并不是严格上讲前端技术的深水区,或者作者肯定认为他能接触的前端技术已经到瓶颈,且没有想到突破口。 怎么去定义深水区,@流形 认为是需要建立技术壁垒或学术壁垒。当我们看待一向技术,如果在投入一到两年就可以对齐,那么显然技术本身的深度是可观的,如果是十年才能对齐,这时候除了会影响经济或政治外,不会有人会去重做,只能使用。用另一个类似的概念反摩尔定律来对应深水区说,每隔两年,技术不能显著带来效能的成倍提升。 深水区值得关注的方向业务领导力也就是原文提到的 “技术创新、流程优化、团队合作、影响大盘、驱动业务、商业决策、团队管理” 等能力,一个拥有领导力的人发挥的价值远超自身孤立的价值。 业务价值发挥业务价值是技术人的最终目标,比如数据库技术想发挥业务价值,就要做到高效、稳定,价值越大往往技术难度就越大。 值得庆幸的是,前端的业务价值与技术难度往往不成正比,有时候将客户的业务场景固化成一套模版,整合起来赋能给更多客户,这等于将商业模型作为能力赋予了其他客户,但本身并没有用到一些高级技术。前端能做的不仅是内部提效和外部体验,因为前端是人机交互的入口,才有机会将业务思考打包到代码中,直接透出给客户。 端技术的发展 数字孪生。那么在端上的仿真能力需要大幅提高,那么结合模型自动生成,不同物体的建模能力等都是很大挑战 虚拟实现。这点上就不赘述,从 FB 重点发展 Oculus,微软发展 HoloLens 可以看到这个趋势,从互动的未来来看,这不是终局,但是最适合今天要突破的技术。 可视分析。数据在人类面前还是过分难懂,结合数据的分析系统在各行各业正在渗透,端上结合可视化的能力就显得非常重要。 更多的,像边缘计算,前端安全等领域都是非常深入的领域。这些问题,已经不是一年就能完全突破的,需要 3-5 年,甚至 10 年时间。 前端深入体系 但对于我所处的大数据环境来说,确实接触了前端技术深水区。来源于端计算能力 + 网络基建 + 大数据的爆炸式增长。编辑器:复杂的开发离不开代码,前端们一直孜孜不倦的把 IDE 引入 web,VS Code 做了很成功的尝试但还是需要一层壳套着。且对于大数据处理这样的领域,需要定制的能力远超过通用的 Manaco editor 等能提供。 表格类数据处理能力:比尔盖茨最引以为豪的微软软件是 Excel。你永远不知道 Excel 有多少种酷的用法来解决用户问题。能否把 Excel 引入到 web?同时对数百万条数据做交叉分析,这对性能和架构都有很大的挑战。 可视化数据展现:大数据的一个典型特征就是价值稀疏性,如何把蕴含的价值展现出来,需要了解图形学、统计学、交互色彩等各种能力。大学老师教的内容终于能派生用场了。 总结在局部领域前端已经有可能深入,当然前端技能上说这些也不能用 HTML, CSS, JS 来解决,需要开发者有深入学科的背景。但今天前端面向还是产品功能的需要,在端上更强调的还是产品功能为主。我们做一款复杂产品,更多还会在工程上纠结。如果没在功能的深入性上思考更多,以对应真正技术发展,那么深水区还远。 正如前面所说,深水区会压强升高、光线减少、温度剧变,需要自己发光发热和更多的坚持。 跨过深水区,让其他人处在浅水区就能做事,这或许就是你走出深水区的标志。就像 Alan Perlis 说的一句话『简单不先于复杂,而是在复杂之后』,也许未来看来你今天挣扎的深水区只是个小泥坑。 讨论地址是:精读《前端深水区》 · Issue ##193 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《前端未来展望》","path":"/wiki/WebWeekly/前沿技术/《前端未来展望》.html","content":"当前期刊数: 111 1. 引言前端展望的文章越来越不好写了,随着前端发展的深入,需要拥有非常宽广的视野与格局才能看清前端的未来。 笔者根据自身经验,结合下面几篇文章发表一些总结与感悟: A Look at JavaScript’s Future 前端开发 20 年变迁史 前端开发编程语言的过去、现在和未来 绕过技术纷争,哪些技术决定前端开发者的未来? 未来前端的机会在哪里? 读完这几篇文章可以发现,即便是最资深的前端从业者,每个人看前端未来也有不同的侧重点。这倒不是因为视野的局限,而是现在前端领域太多了,专精其中某几个领域就足够了,适量比全面更好。 同时前端底层也在逐渐封闭,虽然目睹了前端几十年变迁的开发者仍会对一些底层知识津津乐道,但通往底层的大门已经一扇扇逐渐关闭了,将更多的开发者挤到上层区域建设,所以仅学会近几年的前端知识依然能找到不错的工作。 然而上层建设是不封顶的,有人看到了山,有人看到了星球,不同业务环境,不同视野的人看到的东西都不同。 有意思的是国内和国外看到前端未来的视角也不同:国内看到的是追求更多的参与感、影响力,国外看到的是对新特性的持续跟进。 2. 精读前端可以从多个角度理解,比如规范、框架、语言、社区、场景以及整条研发链路。 看待前端未来的角度随着视野不同也会有变化,比如 Serverless 是未来,务实的思考是:前端在 Serverless 研发链路中仅处于使用方,并不会因为用了 Serverless 而提升了技术含量。更高格局的思考是:怎么推动 Serverless 的建设,不把自己局限在前端。 所以当我们读到不同的人对前端理解的时候,有人站在一线前端研发的角度,有人站在全栈的角度,也有人站在业务负责人的角度。其实国内前端发展也到了这个阶段,老一辈的前端开拓者们已经进入不同的业务领域,承担着更多不同的职能分工,甚至是整个大业务线的领导者,这说明两点: 前辈已经用行动指出了前端突破天花板的各种方向。 同是前端未来展望,不同的文章侧重的格局不同,两个标题相同的文章内容可能大相径庭。 笔者顺着这些文章分析角度,发表一些自己的看法。 框架在前端早期,也就是 1990 年浏览器诞生的时候,JS 没有良好的设计,浏览器也没有全面的实现,框架还没出来,浏览器之间就打起来了。 这也给前端发展定了一个基调:凭实力说话。 后面诞生的 Prototype、jquery 都是为了解决时代问题而诞生的,所以有种时代造就前端框架的感觉。 但到了最近几年,React、Angular、Vue 大有前端框架引领新时代的势头,前端要做的不再是填坑,而是模式创新。国内出现的小程序浪潮是个意料之外的现象,虽然群雄割据为开发者适配带来了一定成本,但本质上是中国在前端底层领域争取话语权的行为,而之所以各大公司不约而同的推出自己的小程序,则是商业、经济发展到了这个阶段的自然产物。 在原生开发领域,像 RN、Flutter 也是比较靠谱的移动端开发框架,RN 就长在 React 上,而 Flutter 的声明式 UI 也借鉴了前端框架的思路。每个框架都想往其他框架的领域渗透,所以标准总是很相近,各自的特色并没有宣传的那么明显,这个阶段只选用一种框架是明智的选择,未来这些框架之间会有更多使用场景争夺,但更多的是融合,推动新的开发方式提高生产力。 在数据驱动 UI 的方式上,具有代表性的是 React 的 Immutable 模式与 Vue 的 MVVM 观察者模式,前者模式虽然新颖,但是符合 JS 语言自然运行机制,Vue 的 MVVM 模式也相当好,特别是 Vue3.0 的 API 巧妙的解决了 React Hooks 无法解决的难题。如果 Vue 继续保持蓬勃的发展势头,未来前端 MVVM 模式甚至可能标准化,那么 Vue 是作为标准化的事实规范,还是和 JQuery 一样的命运,还需观察。 语言JS 语言本身有满多缺陷的,但通过 babel 前端工程师可以提前享受到大部分新特性,这在很大程度上抵消了早期语言设计带来的问题。 横向对比来看,我们还可以把编程语言分为:前端语言、后端语言、能编译到 JS 的语言。 之所以有 “能编译到 JS 的语言” 这一类,是因为 JS Runtime 几乎是前端跨平台的通用标准,能编译到 JS 就代表了可跨平台,然而现在 “能编译到 JS 的语言” 除了紧贴 JS 做类型增强的 TS 外,其他并没有火起来,有工具链生态不匹配的原因,也有各大公司之间利益争夺的原因。 后端语言越来越贴场景化,比如 Go 主打轻量级高并发方案,Python 以其易用性占领了大部分大数据、人工智能的运算场景。 与此对应的是前端语言的同质化,前端语言绑定在前端框架的趋势越来越明显,比如 IOS 平台只能用 OC 和 Swift,安卓只能用 JAVA 和 Kotlin,Flutter 只支持 Dart,与其说这些语言更适合这些平台特性,不如说背后是谷歌、苹果、微软等巨头对平台生态掌控权的争夺。Web 与移动端要解决的问题是类似的:如何高效管理 UI 状态,现在大部分都采用数据驱动的思路,通过 JSX 或 Template 的方式描述出 UI DSL(更多可参考 前端开发编程语言的过去、现在和未来 UI DSL 一节)、以及性能提升:渲染和计算分离(这里又分为并发与调度两种实现思路,目的和效果是类似的)。 所以编程语言的未来也没什么悬念,前端领域如果有的选就用 JS,没得选只能依附所在平台绑定的语言,而前端语言最近正在完成一轮升级大迁徙:JS -> TS,JAVA -> Kotlin,OC -> Swift,前端语言的特性、易用性正在逐步趋同。需要说明的是,如果仅了解这些语言的语法,对编程能力是毫无帮助的,了解平台特性,解决业务问题,提供更好的交互体验才是前端应该不断追求的目标,随着前端、Native 开发者之间的流动,前端领域语言层面差异会会来越小,大家越关注上层,越倾向抹平语言差异,甚至可能 All in JS,这不是因为 JS 有多大野心,而是因为在解决的问题趋同、业务优先的大背景下,大家都需要减少语言不通带来的障碍,最好的办法就是统一语言,从人类语言的演变就可以发现,要解决的问题趋同(人类交流)、与国家绑定的小众语言一直都有生存空间、语法大同小异,但不同语言都有一定自己的特色(比如法语表意更精确)、跨语言学习成本高,所以当国际化协作频繁时,一定会催生一套官方语言(英语),而使用基数大的语言可能会发展为通用国际语言(中文)。 将编程语言的割裂、统一比作人类语言来看,就能理解现状,和未来发展趋势了。 可视化前面也说过,前端的底层在逐渐封闭,而可视化就是前端的上层。 所以笔者很少提到工程化,原因就是未来前端开发者接触工程化的机会越来越少,工程化机制也越来越完善,前端会逐渐回归到自己的本质 - 人机交互,而交互的重要媒介就是图形,无论组件库还是智能化设计稿 To Code 都为了解放简单、模式化的交互工作,专业前端将更多聚集到图形化领域。 图形和数据是分不开的,所以图形化还要考虑性能问题与数据转换。 可视化是对性能要求最高的,因此像 web worker、GPU 加速都是常见处理手段,WASM 技术也会用到可视化中。具体到某个图表或大屏的性能优化,还会涉及数据抽样算法,分层渲染等,仅仅性能优化领域就有不少探索的空间。性能问题一般还伴随着数据量大,所以数据序列化方案也要一并考虑。 可视化图形学是非常学术的领域,从图形语法到交互语法,从一图一做的简单场景,到可视化分析场景的灵活拓展能力,再到探索式分析的图形语法完备性要求,可视化库想要一层层支持不同业务场景的需求,要有一个清晰的分层设计。 仅可视化的图形学领域,就足够将所有时间投入了,未来做可视化的前端会越来越专业,提供的工具库接口也越来越有一套最佳实践沉淀,对普通前端越来越友好。 BI 可视化分析就是前端深造的一个方向,跟随 BI 发展阶段,对前端的要求也在不断变化:工程化、组件化、搭建技术、渲染引擎、可视化、探索式、智能化,跟上产品对技术能力的要求,其实是相当有挑战性的。 编辑器编辑器方向主要有 IDE(Web IDE)、富文本编辑器。 IDE 方向 国产做的比较好的是 HBuilder,国际上做的比较好的是 VSCode,由于微软还同时推出了 Web 版 MonacoEditor,让 Web IDE 开发的门槛大大降低。 作为使用者,现在和未来的主流可能都是微软系,毕竟微软在操作系统、IDE 方面人才储备和经验积累很多。但随着云服务的变迁,引导着开发方式升级,IDE 游戏规则可能迎来重大改变 - 云化。云化使得作为开发者拥有更多竞争的机会,因为云上 IDE 市场现在还是蓝海,现在很多创业公司和大公司内部都在走这个方向,这标志着中国计算机技术往更底层的技术发展,未来会有更多的话语权。 从发展阶段来说,前端也发展到了 Web IDE 这个时代。对大公司来说,内部有许许多多割裂的工程化孤岛,不仅消耗大量优秀的前端同学去维护,也造成内部物料体系、工程体系难以打通,阻碍了内部技术流通,而云 IDE 天生的中心化环境管理可以解决这个问题,同时还能带来抹平计算机环境差异、统一编译环境、源码不落盘、甚至实现自动的多人协作也成为了可能,而云 IDE 因为在云上,也不止于 IDE,还可以很方便的集成流程,将研发全链路打通,因此在阿里内部也成为了今年四大方向之一。 所以今年可以明显看到的是,前端又在逐步替代低水平重复的 UI 设计,从设计稿生成代码,到研发链路上云,这种顶层设计正在进一步收窄前端底层建设,所以未来会有更多专业前端涌入可视化领域。 富文本编辑器方向 是一个重要且小众的领域,老牌做的较好的是 UEditor 系列,现在论体验和周边功能完善度,做得最好的是语雀编辑器。开源也有很多优秀的实现,比如 Quill、DraftJS、Slate 等等,但现在富文本编辑器核心能力是功能完备性(是否支持视频、脑图、嵌入)、性能、服务化功能打通了多少(是否支持在线解析 pdf、ppt 等文件)、交互自然程度(拷贝内容的智能识别)等等。如果将眼光放到全球,那国外有大量优秀富文本编辑器案例,比如 Google Docs、Word Online、iCloud Pages 等等。 最好用的富文本编辑器往往不开源,因为投入的技术研发成本是巨大的,本身这项技术就是一个产品,卖点就是源码。 富文本编辑器功能强度可以分为三个级别:L0~L2: L0:利用浏览器自带的输入框,主要指 contenteditable 实现。 L1:在 L0 的基础上通过 DOM API 自主实现增删改的功能,自定义能力非常强。 L2:从输入框、光标开始自主研发,完全不依赖浏览器特性,如果研发团队能力强,可以实现任何功能,典型产品比如 Google Docs。 无论国内外都鲜有进入 L2 强度的产品,除了超级大公司或者主打编辑器的创业公司。 所以编辑器方向中,无论 IDE 方向,还是富文本编辑器方向,都值得深入探索,其中 IDE 方向更偏工程化一些,考验体系化思维,编辑器方向更偏经验与技术,考验基本功和架构设计能力。 智能化笔者认为智能化离前端这个工种是比较远的,智能化最终服务前后端,给前后端开发效率带来一个质的提升,而在此之前,作为前端从业者无非有两种选择:加入智能化开拓者队伍,或者准备好放弃可能被智能化替代的工作内容,积极投身于智能化解放开发者双手后,更具有挑战性的工作。这种挑战性的工作恰好包括了上面分析过的四个点:语言、框架、可视化、编辑器。 类比商业智能化,商业智能化包括网络协同和数据智能,也就是大量的网络协同产生海量数据,通过数据智能算法促进更好的算法模型、更高效的网络协同,形成一个反馈闭环。前端智能化也是类似,不管是自动切图、生成图片、页面,或者自动生成代码,都需要算法和前端工程师之间形成协同关系,并完成一个高效的反馈闭环,算法将是前端工程师手中的开发利器,且越规模化的使用功效越大。 另一种智能化方向是探索 BI 与可视化结合的智能化,通过功能完备的底层图表库,与后端通用 Cube 计算模型,形成一种探索式分析型 BI 产品,Tableau 就是典型的案例,在这个智能化场景中,需要对数据、产品、可视化全面理解的综合性人才,是前端职业生涯另一个突破点。 3. 总结本文列举的五点显然不能代表前端的全貌,还遗漏了太多方面,比如工程化、组件化、Serverless 等,但 语言、框架、可视化、编辑器、智能化 这五个点是笔者认为前端,特别是国内前端值得持续发力,可以做深的点,成为任何一个领域的专家都足以突破前端工程师成长的天花板。 最后,前端是最贴近业务的技术之一,业务的未来决定了前端的未来,创造的业务价值决定了前端的价值,从现在开始锻炼自己的商业化思考能力与产品意识,看得懂业务,才能看到未来。 讨论地址是:精读《前端未来展望》 · Issue ##178 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《前端职业规划 - 2021 年》","path":"/wiki/WebWeekly/前沿技术/《前端职业规划 - 2021 年》.html","content":"当前期刊数: 196 不知道你上次思考前端职业规划是什么时候? 如果你是一位学生,你肯定对前端这个职业感到陌生,你虽然没有经验,但却对未来充满好奇,你有大把时间来思考,但可能摸不着方向,有种拳头打在棉花上的无力感。 如果你已经参加了工作,不论是刚开始实习,还是工作了 3 年、5 年甚至 10 年,一定觉得非常充实,但真正用于思考的时间足够吗?如果维持现状,再过 5 年自己的提升点在哪里?如果你对这些结论不清晰,很可能是缺乏了对职业规划的思考。 这种缺乏职业规划的焦虑已经发展成为了商机。当你没有清晰职业规划,正在迷茫的时候,培训机构站出来说,是不是对职业规划充满焦虑?如果是,可以订购我们的课程,名牌大厂 P10 带你跑赢职场。其实课程确实是干货,但一个具体课程并不能代替你自己的思考,你需要自己想明白自己想要的,而不是被别人灌输思想,因为职场没有标准路线,但培训机构的文案确实有标准写法。 所以这篇前端职业规划是站在我自己角度写的,你如果也在思考长线发展问题,可以作为参考。 我总结出三个主要思考方向,分别是 知识分类、领域深耕、经济视角。 知识分类 指的是你对知识的理解是否成体系。现在全球每天新增的知识,一个人穷尽一生也学不完,如果不建立一套你自己的知识筛选标准,长期发展就无从谈起。 领域深耕 是实践,天天学习也是没有用的,你必须要做出什么有价值的事情,才能为行业带来贡献,或者说将知识转化为财富。当然不同职业学习与实践的比例是不同的,比如理论物理可能模糊了学习与实践的边界,而在职场环境的工程师,更容易区分什么是学习,什么是实践。 经济视角 是说你要能够带着经济视角看问题。可以说没有经济活动,我们一切学习、生产、职业都没有任何意义,因为推动我们学习、推动社会生产的动力是交易,没有经济活动就没有需求,需求是推动一切活动的基础。稍微理解了经济和生产的关系,就能理解为什么技术要为商业服务,因为任何技术都要有转化为商业价值的潜力才值得被研究,大到社会价值,小到产品价值,都一样。 下面我分别讲讲自己对每个方向的理解。 知识分类作为前端,为了保持技术敏锐度,我们会订阅许多专栏了解新知识。仅我知道的周更专栏就有 30 个,其实根据一些专门整理好的专栏检索网站,每周甚至可以看到超过 100 种不同的前端专栏。大部分专栏都在做文章聚合,每篇专栏聚合的文章一般有 5 篇到 30 篇不等,这样即便去除重复,一周至少有几百篇新的前端技术文章等你去读,所以有些同学会觉得焦虑,甚至喊出学不动了。 我每周写前端精读恰好也要找一些文章阅读,但几年下来,我恰恰觉得每周根本找不到有用的素材。就以本周的 javascript weekly 为例,我摘了一些文章标题: DOM Events: A Way to Visualize and Experiment with the DOM Event System。 Introducing WebContainers: Run Node.js Natively in the Browser。 New & Updated Course: Complete Intro to React v6 with Brian Holt Parcel 2 Beta 3: A Wild Rust Appears! 2D Optics Demos in JavaScript A Complete Beginner’s Guide to Next.js How to Create Reusable Web Components with Lit and Vue 第一篇是通过可视化帮你理解 DOM 事件的文章,UI 很有意思,但 DOM 事件作为前端基础,精读实在不适合拿过来炒冷饭,这个知识点讲一遍就行了,没必要做成 UI 后再讲一遍。 第二篇是讲一项技术可以让 Node 运行在浏览器的,这确实是一个新技术,但现阶段我们没必要为这项技术找场景,只要知道有这个东西就行了,没必要仔细阅读。第三篇是对 React 的完整教程,非常体系化,但没有新东西,适合前端新人读,所以也不需要看。 再后面几篇分别是框架升级带来的特性介绍、一个有趣的可视化效果、Next.js 新手入门、如何用 Lit 框架开发组件。这些知识从直觉来看属于可读可不读的,读了吧觉得好像对自己没什么成长,不读又觉得错过了什么,真的像鸡肋。 如果你看到这些 Feed 流也有犹豫的感觉,我建议你建立一套前端知识分类体系。就像学习武功,如果你不了解什么是基本功,什么是花拳绣腿,那么每天面临几百本推送过来的 “武学新闻” 确实是无从学起,而且也学不过来。 在技术领域,知识分类体系是有规可循的,大致可以讲知识分为两种类型:通用、行业知识。 通用知识是指最为基础、适用面也最大的知识,比如数理化,这些知识我们上学时都学过,工作中用到的知识都是建立在这些通用知识基础之上的,比如没有一定数学基础就难以学习计算机可视化领域,因为其中会大量运用数学知识。 通用知识最有用,也最保值,所以学校时就安排给我们了,那么大学其实就在教通用行业知识,所以这个阶段如果没有打牢的基础,想要弥补也很简单,只要按照大学教材温习一遍就好了,对于计算机领域的通用知识一般有计算机原理、操作系统、设计模式、编译原理、数据结构、算法等。 领域通用知识看上去比较死板,而初入工作的同学一般都在做拧螺丝钉的事,往往会忽略行业通用知识的重要性,但当你不断深入接触公司核心技术时,会发现大量运用了大学里教的那些通用知识,等用到的时候再学就迟了。 如果说行业通用知识的保值时间是 30 年,那接下来提到的行业专用知识的保值时间只有 1 年。行业专用知识就是我们在 Weekly 上看到的大部分内容,也包括培训班帮我们速成的前端框架、API 等知识。这些知识非常有用,接地气,而且刚接触工作时第一时间就要用到,但这些知识最大的问题就是太过于上层,以至于同类产品过多,可替代性强,知识点可以随着新版本发布全变了样。 就像项目脚手架工具,现在每天都会出一个基于 webpack 或者 rollup 包装的新品牌,这种脚手架就不值得学习,你也不需要把新出的脚手架当作新知识,因为这些知识的生命周期大部分不到一年,大多没有人用,最重要的是除了名字以外,组成要素里没有任何新知识,所以读完源码也学不到新知识。更最重要的是,你无法根据这些知识生产同类产品,所以如果你真的想学脚手架相关知识,认真读好一个主流脚手架源码就行了,以后除了工作中用到,不需要看任何使用文档。 对于架构能力也一样,我们在工作中通过踩坑甚至把一个项目做失败得出的经验,可能只是设计模式这本书里提到的一个常见误区;我们在设计一个非常复杂的系统时,用到的模块通信设计,可能只是操作系统设计里的一种常见通信方法。一个能理解操作系统复杂度的人,基本上可以处理与其等价复杂度的软件工程问题,而软件工程的复杂度其实很难超越操作系统,所以与其在项目里试错,不如从这些基础知识里找答案。 所以如果你想在职业规划上更进一步,检查一下自己的基础是否牢固。如果你通用知识特别扎实,就可以快速学会行业基础知识,根据行业基础知识,你甚至可以独立创造任何一个新的框架,这些框架都会成为别人学习到的行业专用知识,如果另一位同学没有打基础,把时间都用在学习你做的框架上,那么他的职业发展一定程度会被你左右,而他如果只停留在用的阶段,而不了解实现原理,从长期来看,你的职业天花板一定会更高。 关于哪些是通用基础知识、行业基础知识、行业专用知识,这里不给出具体的建议,相信每个人都会有自己的判断。 领域深耕 这段思考 不适用于 刚参加工作的前端同学。 前端有一句有名的鸡汤 “前端不是因为做交互界面,而是因为站在业务的最前端”,其实这句话是有问题的,我觉得每一位工作经验超过三年的前端同学都有一种在业务领域的无力感。 其实最核心的业务模型天然在后端,这是因为前端只是一个用户与业务系统交互的窗口,没有前端,用户也可以和接口直接交互,只是这么做成本很大,所以为了降低用户上手难度,或者带来更好的用户体验,才需要不断升级 UI 界面,所以 UI 界面和后端往往是多对一的关系,移动端、小程序、网页对应的接口都是一套,目的就是为了方便任何场景用户都能轻松触达业务,所以作为前端,首先要对前端存在的原因有正确的认识。 注意这里说的是业务模型,没有提到体验深度,如果讲究体验深度,自然只有前端能做到。然而前端本质还是锦上添花的部分,因为在任何行业耕耘久了,如果仅仅只考虑前端,那么目标永远是体验度量、研发提效的事情,很少触及到业务层,以至于前端在业务价值的体现不直接,比较难解释体验度量、研发提效与最终业务增长之间的关系。 所以对于有一定工作经验的前端同学,想要更进一步,一定要在业务领域深耕。 那么如何在业务领域深耕呢?首先你要抛开前端视角,用业务眼光看问题,否则还是会陷入无尽的交互细节。首先要了解你所在的领域,比如笔者在的数据领域,要知道行业的历史、现状和未来,有哪些产品,每种产品的商业模式是什么,产品之间有什么关联,现在的产品距离头部产品还有哪些差距,今年产品目标主要解决什么问题,三年目标是什么等等。每个同学首先都应该理解产品,其次再产生研发、产品经理的分工。 然后审视一下自己的工作,在产品核心能力里扮演者什么角色?比如做 BI 工具,其核心是数据分析能力与报表可视化分析能力,如果你总在做类似报表列表页、个人中心这种通用中后台的工作,你就要想想,这些工作是不是可以外包出去,如果不行,那就想办法做一些领域搭建,往通用领域转吧。 当你审视了自己工作,发现核心产品能力与你工作内容不相符,而你又不想转到前端中后台通用领域一直做研发提效的事情,这时候你就要想办法和老板沟通改变一下工作内容了,你可以找一些前端也能接触强业务模型的领域,比如 BI 分析,数据可视化等等。其实通用领域也有不少深水区,比如语雀背后的富文本编辑器、流程图、研发工作台、业务组件库等等都是可以做深的通用领域,当你想再上一层楼时,就要像玉伯一样成为语雀整个产品的引领者,这样你其实又进入了知识协作、生产力工具这个专业领域。 如果你既不想往通用技术领域发展,又无法改变工作内容,就尝试承担更多职责吧,如果可能的话,尝试参与后端业务逻辑的开发,这样可以帮助你深入、全面理解业务逻辑。其实前端 + 产品的路线也可以很好在专业领域做深,前端 + 后端路线也可以,你需要根据自己团队实际情况做出调整。 任何产品的研发团队都要有产品全局观,这就是刚才说的在技术之外,你对你所在业务领域的理解程度,理解程度越高,技术方向就越明确,但如果你的职业规划是再继续攀爬,就要成为整个产品负责人了。现在的年轻人非常上进,许多公司都在尝试采取活水政策,让想更进一步的年轻人尝试新方向开疆拓土,而不是留在一个成熟的团队里内卷。 经济视角做职业规划的另一个目的当然是升职加薪了,但是你的薪资并不能无限膨胀,其增长大致还是符合市场规律的。另外任何工作都是一笔经济账,我们要带着技术、产品和经济视角看业务,才能做出合理的判断。 因为去年疫情原因,全球远程办公得到了积极实践,并且在未来依然有增长潜力,因此作为用人单位方,必定会逐渐放眼全球去看人力成本问题,因为在哪都能办公。从全球软件开发数据来看,美国的工资水平最高,中国软件工程师的工资也紧随其后,所以在软件领域中国已经不存在劳动力成本低廉的优势了,尤其当你工作经验丰富后,要竞争中高级岗位,中国软件公司开的薪资放眼全球都不低。 然而国家之间技术发展阶段、教育水平仍然存在差距,如果同样的资深技术专家岗位,国内与国外开的薪资持平,但中国的软件工程师架构水平完全不及美国的软件工程师,那么长期来看,这种错配会造成企业用人成本浪费,企业会在一定程度想办法优化一下人员构成的。因此作为前端,或者软件工程师,你必须清楚长期而言,你要和全球的软件工程师竞争,所以你还要充分了解你的领域在全球范围的发展阶段,人才水平如何。 以上是个人的经济账,接下来谈谈业务的经济账。 首先你要了解自己的技术是怎样转化为收入,覆盖自己工资的。我们首先看市场竞争,市场竞争通过价格调节供需关系,我们做的产品成本、售价很清楚,是否值得做一目了然。然而对于复杂产品需要多人协作,如果人与人之间再通过市场化机制合作,往往容易产生低效的结果,比如我做的按钮按照 3 元一个的价格卖给后端,那为了提升我的价值,我会提价到 5 元一个,然而倾向于给产品加更多的按钮,这样都在看短期利益,谁也不会为产品长期发展负责。 所以公司是一个相对大锅饭的组织,谁也不要给自己工作定价,大家都尽可能的打磨产品,月底按照合同约定给固定薪酬。这样做确实解决了产品长期发展的问题,但这套机制成熟后,尤其在大公司,刚毕业就去拧螺丝钉的同学很可能永远没有机会了解何为成本,没有成本概念,就难以想清楚为什么做事要考虑投入产出比,或者觉得 ROI 这个词很高级,其实这个词一点不高级,只是公司将它屏蔽了,但如果这导致你做技术完全不考虑成本,只追求让你激动的技术细节,或者只做你感兴趣的技术方向,那其实是不成熟的表现,你做的事情可能也难以被业务认可。 如果你想往更高层次发展,成本意识是一定要培养的,可以了解一下人力成本、机器成本、以及接入二方、三方服务的外部成本,了解这些成本后,再算算产品年营收是否能覆盖这些成本,如果想继续加人,那明年产品营收相应要翻多少,现在市场空间允许产品翻这么多吗?如果想提供更好的服务,要加机器,那么你的业务方是否会因为服务变好变得更多?衡量业务方增多带来的价值一般从订单价格,MAU 来看,如果服务外部,直接看价格是否覆盖成本就行了,如果服务内部,就看 MAU 是否值得投入这些机器成本。 然而也不能只看钱,市场份额也很重要。如果 Chrome 对研发投入只看年营收,那现在 IE 估计还是主流浏览器。其实 Chrome 在确立霸主地位后,对谷歌产品生态的打通、W3C 的话语权、开发者吸引力有很大提升,这些看不见的影响面难以直接转化为金钱来统计,所以如果你认为产品市场份额的提升可以带来长线价值,那么也可以把市场份额作为目标之一。 最后经济视角也不仅仅让我们停留在算业务帐上,经济学的边际收益理论可以指导我们优先做边际收益更大的事。当前业务产品矩阵中,拓展哪些产品可以快速弥补不足,如果做技术优化,优化哪些模块带来产品收益、可维护性收益最大,如果时刻能想清楚这些问题,那每年的产品、技术方向就不会跑偏。 总结总结一下文中提到的三个思考方向,其实是职业生涯发展中可能遇到的三种问题。 工作时间久了就会发现,哪怕依然有学习的激情,但保持刚毕业那会的学习方式已经难有突破了,你会发现:工作实践用到的知识不会很多,反复读或者写入门技术文章,只会让自己停留在校招生的技术水平;自己所处的职业也限制了进一步发展,你需要思考怎么打破职业天花板;甚至只钻研技术领域都是不够的,大家都在谈成本,你在谈技术,天然就不在一个频道上。 本文也给出了对应的三个解决方案,知识分类 帮助你解决反复学习无用的、入门知识的问题;领域深耕 帮助你解决职业天花板的问题;经济视角 帮助你解决技术单一视角的问题。 其实职业有天花板很正常,没有哪个职业上升通道是一路无阻的,但人是活的,你可以逐渐改变自己,在适当的时候多看看业务、经济问题,学习知识也不要仅停留在表面,虽然这些你工作中可能根本用不到,但这其实是悖论,因为你没掌握某些知识,所以也没机会接触那些工作,想打破悖论只能从痛苦的自我打破边界开始。 与一般前端职业规划不同,我并没有说很多前端领域专有名词,或者点名要学哪些框架,因为我觉得人之间智商差距并不大,必须掌握的知识工作几年都能学会,而真正能拉开人之间差距的,不是智商,而是学习方法,或者学习路线,如果你把时间用在错误的地方,或者错误的阶段,终将积累成巨大差距。 希望我的思考可以对你有帮助。 讨论地址是:精读《前端职业规划 - 2021 年》· Issue ##317 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《前端调试技巧》","path":"/wiki/WebWeekly/前沿技术/《前端调试技巧》.html","content":"当前期刊数: 11 本期精读的文章是:debugging-tips-tricks 编码只是开发过程中的一小部分,为了使我们工作更加高效,我们必须学会调试,并擅长调试。 1 引言 梵高这幅画远景漆黑一片,近景的咖啡店色彩却反差很大,他只是望着黑夜中温暖的咖啡馆,交织着矛盾与孤独。代码不可能没有 BUG,调试与开发也始终交织在一起,我们在这两种矛盾中不断成长。 2 内容概要文中列举了常用调试技巧,如下: Debugger在代码中插入 debugger 可以在其位置触发断点调试。 Console.dir使用 console.dir 命令,可以打印出对象的结构,而 console.log 仅能打印返回值,在打印 document 属性时尤为有用。 ps: 大部分时候,对象返回值就是其结构 使用辅助工具,语法高亮、linting它可以帮助我们快速定位问题,其实 flow 与 typescript 也起到了很好的调试作用。 浏览器拓展使用类似 ReactDTools VueDTools 调试对应框架。 借助 DevToolsChrome Dev Tools 非常强大,dev-tips 列出了 100 多条它可以做的事。 移动端调试工具最靠谱的应该是 eruda,可以内嵌在任何 h5 页面,充当 DevTools 控制台的作用。 实时调试不需要预先埋点,比如 document.activeElement 可以打印最近 focus 过的元素,因为打开控制台导致失去焦点,但我们可以通过此 api 获取它。 结构化打印对象瞬时状态JSON.stringify(obj, null, 2) 可以结构化打印出对象,因为是字符串,不用担心引用问题。 数组调试通过 Array.prototype.find 快速寻找某个元素。 3 精读本精读由 rccoder ascoders NE-SmallTown BlackGanglion jasonslyvia alcat2008 DanielWLam HsuanXyz huxiaoyun vagusX 讨论而出。 移动端真机测试由于 webview 不一定支持连接 chrome 控制台调试,只有真机测试才能复现真实场景。 browserstack dynatrace 都是真机测试平台,公司内部应该也会搭建这种平台。 移动端控制台 Chrome 远程调试 app 支持后,连接 usb 或者局域网,即可通过 Dev Tools 调试 webview 页面。 Weinre 通过页面加载脚本,与 pc 端调试器通信。 通过内嵌控制台解决,比如 eruda VConsole Rosin fiddler 的一个插件,协助移动页面调试。 jsconsole 在本地部署后,手机访问对应 ip,可以测试对应浏览器的控制台。 请求代理charles Fiddler 可以抓包,更重要是可以代理请求。假数据、边界值测试、开发环境代码加载,每一项都非常有用。 定制 Chrome 拓展对于特定业务场景也可以通过开发 chrome 插件来做,比如分析自己网站的结构、版本、代码开发责任人、一键切换开发环境。 在用户设备调试把控制台输出信息打到服务器,本地通过与服务器建立 socket 链接实时查看控制台信息。要知道实时根据用户 id 开启调试信息,并看用户真是环境的控制台打印信息是非常有用的,能解决很多难以复现问题。 代码中可以使用封装过的 console.log,当服务端开启调试状态后,对应用户网页会源源不断打出 log。 DOM 断点、事件断点 DOM 断点,在 dom 元素右键,选择 (Break on subtree modifications),可以在此 dom 被修改时触发断点,在不确定 dom 被哪段 js 脚本修改时可能有用。 Event Listener Breakpoints,神器之一,对于任何事件都能进入断点,比如 click,touch,script 事件统统能监听。 使用错误追踪平台对错误信息采集、分析、报警是很必要的,这里有一些对外服务:sentry trackjs 黑盒调试SourceMap 可以精准定位到代码,但有时候报错是由某处代码统一抛出的,比如 invariant 让人又爱又恨的库,所有定位全部跑到这个库里了(要你有何用),这时候,可以在 DevTools 源码中右键,选中 BlackBox Script,它就变成黑盒了,下次 log 的定位将会是准确的。 FireFox、Chrome。 删除无用的 cssCss 不像 Js 一样方便分析规则是否存在冗余,Chrome 帮我们做了这件事:CSS Tracker。 在 Chrome 快速查找元素Chrome 会记录最后插入的 5 个元素,分别以 $0 ~ $4 的方式在控制台直接输出。 Console.table以表格形式打印,对于对象数组尤为合适。 监听特定函数调用monitor 有点像 proxy,用 monitor 包裹住的 function,在其调用后,会在控制台输出其调用信息。 > function func(num){}> monitor(func)> func(3)// < function func called with arguments: 3 模拟发送请求利器 PostManPostMan, FireFox 控制台 Network 也支持此功能。 找到控制台最后一个对象有了 $_,我们就不需要定义新的对象来打印值了,比如: > [1, 2, 3, 4]< [1, 2, 3, 4]> $_.length// < 4 更多控制台相关技巧可以查看:command-line-reference。 3 总结虽然在抛砖引玉,但整理完之后发现仍然是块砖头,调试技巧繁多,里面包含了通用的、不通用的,精读不可能一一列举。希望大家能根据自己的业务场景,掌握相关的调试技巧,让工作更加高效。 讨论地址是:精读《前端调试技巧》 · Issue ##17 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《加密媒体扩展》","path":"/wiki/WebWeekly/前沿技术/《加密媒体扩展》.html","content":"当前期刊数: 26 首图来自 https://www.cablelabs.com/meet-connectivity-enabler-alberto-campos 精读加密媒体扩展(Encrypted Media Extensions,EME)本期精读的文章是: W3C 发布加密媒体扩展(Encrypted Media Extensions,EME)正式推荐标准 感谢 xekri 提供 hacker news 上的热门讨论帖:https://news.ycombinator.com/item?id=15278883 1 引言 制定 Web 标准的行业组织 W3C 发表了加密媒体扩展(Encrypted Media Extensions,EME)的推荐规格,使得受争议的 HTML5 DRM 成为 Web 的正式标准。W3C 的新闻稿称,“EME 是一个应用编程接口(API),允许无插件播放 Web 浏览器中受保护(加密的)内容,它可以无缝地作用于所有主要的平台。W3C 的媒体资源扩展标准(Media Source Extensions, MSE)提供传送媒体视频的 API,而 EME 提供了处理加密内容的 API。MSE 和 EME 的组合是当今最常见的做法,允许 Web 开发人员在不使用插件的情况下也可以通过 Web 提供商业品质的视频。”在 W3C 成员批准该规格的最终投票中,58.4% 支持,30.8% 反对,10.8% 弃权。电子前哨基金会(EFF)随后发表了致 W3C 的公开信,谴责 W3C 放弃了共识,宣布辞职抗议。 —— 摘自《HTML5 DRM 正式成为 Web 标准,EFF 辞职抗议》 以上,是我 17 年 9 月 19 日晚收到的一条推送消息。我当时在写《关于 React 系前端技术的思考》,可是它让我意识到,该关注下 背后的故事了。17 年下半年发生了两件有趣的撕 X 事件:Facebook 将部分开源项目的防专利流氓证书 “BSD + Pattern” 重新授权为 “MIT” 和 W3C 发布 HTML5 版权保护的 EME 推荐标准。一时,似乎著作权、版权和开源、分享,甚至普世、网络中立性,这些声音开始在不少人耳边盘绕。 “无论如何,在当前的现实中,法律是保护著作权的。” 那么,我以 EME 为切入点,和大家聊聊 HTML 5 中如何保护知识产权吧。 2 内容概要接下来,我将为大家分享一些基本概念、背景和 EME 对利益相关方的影响。 在精读部分,将重点汇总浏览器对 MSE 和 EME 的支持情况;分享现代播放器的技术原理, MSE 和 EME 组合的播放器示例,加深大家对现代播放器的相关技术的理解。最后,推荐一些较实用、成熟的开源技术。 基本概念 DRM:数字版权管理(Digital Rights Management)是以一定的计算方法,实现对数字内容的保护, 也可以解释为, 内容数字版权加密保护技术。 EME:加密媒体扩展(Encrypted Media Extensions)是 W3C 提出的一种规范,用于在 Web 浏览器和 DRM 代理软件之间提供通信通道。 MSE:媒体源扩展(Media Source Extensions)是一项 W3C 规范,它扩展了 HTMLMediaElement,允许 JavaScript 生成媒体流以支持回放。这可以用于自适应流(adaptive streaming)及随时间变化的视频直播流(live streaming)等应用场景。 CDM:内容解密模块(Content Decryption Module),客户端或者使用端软件或硬件提供的一个机制,可以播放加密内容。 背景长期以来,“多方利益”模式的 W3C ,以或标准化引领、或被各方优良实践推动再制定标准的方式,来影响着互联网的发展。 2011 年时 Silverlight 、HTML5 及 Flash 还是最受热捧的 RIA (富互联网应用) 技术。当时,Silverlight 的 PlayReady DRM、 Flash 的 Flash Media Rights Management(FMRM),在版权保护上已十分成熟。而 HTML5 还处于 未指明编码标准的萌芽状态、更谈不上版权保护。 随着移动互联网、视频直播、职能家电等等互联网快速发展,浏览器插件一度成为网络恶意攻击的重灾区,给网络用户安全性带来很大隐患。微软和许多企业都鼓励用户、开发者使用 HTML5 的通信协议,标准化通信可以极大增加网络安全性。其中包括 W3C 的 Media Source Extensions (MSE)、 Encrypted Media Extensions (EME),MPEG 的 MPEG-DASH 和 Common Encryption (CENC)。 终于,内容提供商(如 Netflix、Adobe、CableLabs 等)从 Flash、Silverlight 插件播放器过渡到统一的 HTML5 视频播放;各大浏览器公司(如 Google, Microsoft, Apple)也逐步抛弃了过时的媒体插件。 EME 作为 HTML 5 DRM 版权保护方案中的一员,虽然从 2012 年提案开始就颇多争议,但是事实上已被各浏览器以捆绑闭源的 CDM 的沙箱化方式“悄悄”分发。现在,W3C 只是给了它应有的名分罢了。 EME 对 Web 产生的影响W3C 理事长 Tim Berners-Lee 在《W3C Blog: 关于 HTML5 标准中的加密媒体扩展(EME)》中阐述了 EME 对内容分发商、媒体、用户、开发者、安全技术研究人员的影响。 对多数人的影响大概是,可以提供一个相对安全的在线环境使用户可以获取高品质商业级的 Web 音视频等内容,并便捷的就此进行在线互动。 下图是内容提供商分发他们电影的选择渠道和优缺点。 图 1. 取自《ON EME IN HTML5》 值得注意的是,安全技术研究人员还是有些影响的。中国虽然没有所谓的“数字千年著作权法案”,可是毕竟还是保护网络安全和著作权的。 精读浏览器支持情况以下是截取 caniuse 网站统计的 EME 和 ESM 的支持情况(点击图片可跳转到对应网址): 现代播放器的技术原理《视频直播技术详解——现代播放器原理》中,将典型的播放器分解为:UI、多媒体引擎和解码器。如下图: UI:含皮肤、自定义特性(如播放列表、分享等)和业务逻辑部分(广告、设备兼容性逻辑和认证管理等); 多媒体引擎:处理所有播放控制相关逻辑,如描述文件解析、视频片段拉取、自适应码率规则设定和切换等。它拥有非常多的不同组件和特性,从字幕到截图到广告插入等等。 解码器和 DEM 管理器:解码器解码并渲染视频内容;DRM 则通过解密过程来控制是否有权播放。解码器和 DRM 管理器与操作系统平台密切绑定。 图 :解码器、渲染器和 DRM 工作流程图 图 DRM 管理器 今天,在传输工作室生产的付费内容的时候,DRM 是必要的。这些内容必须防止被盗,因此 DRM 的代码和工作过程都向终端用户和开发者屏蔽了。解密过的内容不会离开解码层,因此也不会被拦截。 为了标准化 DRM 以及为各平台的实现提供一定的互通性,几个 Web 巨头一起创建了通用加密标准Common Encryption (CENC) 和通用的多媒体加密扩展Encrypted Media Extensions,以便为多个 DRM 提供商(例如,EME 可用于 Edge 平台上的 Playready 和 Chrome 平台上的 Widewine)构建一套通用的 API,这些 API 能够从 DRM 授权模块读取视频内容加密密钥用于解密。 CENC 声明了一套标准的加密和密钥映射方法,它可用于在多个 DRM 系统上解密相同的内容,只需要提供相同的密钥即可。 在浏览器内部,基于视频内容的元信息,EME 可以通过识别它使用了哪个 DRM 系统加密,并调用相应的解密模块(Content Decryption Module, CDM)解密 CENC 加密过的内容。解密模块 CDM 则会去处理内容授权相关的工作,获得密钥并解密视频内容。 CENC 没有规定授权的发放、授权的格式、授权的存储、以及使用规则和权限的映射关系等细节,这些细节的处理都由 DRM 提供商负责。 MSE 和 EME 组合的播放器示例结合 cpearce/mse-eme 做简要说明,代码可参见对应的 Github 仓库。 index.html:模拟内容服务商视频播放网页,获取 EME 设置(本例中 eme.js),通过调用 MSE 模块(本例中 mse.js) 逐块加载视频片段并控制播放。 resources.js:模拟 License(Key) server,与 CDM 模块交互并提供解密媒体资源所需的 key; media:模拟 Key System 和 Packaging service。主要功能是提供一种内容保护(DRM)机制,实际应用中常见的 Key System 有 Clear Key、Playready、Widevine 等;另外,作为 Packaging Service,提供编码并加密媒体资源以供发布和播放使用。 eme.js: 模拟 EME 通信模块。主要包括监听 MediaKeys 的 message 和 keystatuseschange 变化;发起证书请求;最后,通过 License(key) 解密 video/audio 流; mse.js:模拟媒体源扩展模块,通过调用浏览器提供的 MSE API,来控制视频流播放逻辑。 成熟的开源技术 开源的视频播放器 个人点评 video.js 和其插件。设备检测与配置逻辑的 videojs-contrib-hls 、广告 videojs-contrib-ads 免费开源的 HTML5 和 Flash 播放器,通过强大的插件应用于 400,000 网站。采用 Apache License, Version 2.0 授权 JW Player 号称世界上最流行的嵌入播放器,应用于 200 万网站、每月 13 亿播放次数。采用 Creative Commons license 授权 Shaka Player Google 开源的基于 MSE + EME 的 JavaScript 库,支持 DASH、HLS 等。采用 Apache License 2.0 授权 dash.js 一个支持 MPEG DASH 的参考实现,适合研究学习。采用 BSD 授权 总结目前来看,DRM 市场还是分散状态。只有考虑到各浏览器厂商的 DRM 系统,才能让所有浏览器来支持 DRM 播放。 期待随着标准的发布,注重著作权、版权的互联网能够很快地向有序方向发展。 讨论地址是:精读《W3C 发布加密媒体扩展(Encrypted Media Extensions,EME)正式推荐标准》 · Issue ##37 · dt-fe/weekly如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《可视化搭建思考 - 富文本搭建》","path":"/wiki/WebWeekly/前沿技术/《可视化搭建思考 - 富文本搭建》.html","content":"当前期刊数: 161 1 引言「可视化搭建系统」——从设计到架构,探索前端的领域和意义 这篇文章主要分析了现阶段可视化搭建的几种表现形式和实现原理,并重点介绍了基于富文本的可视化搭建思路,让人耳目一新。 基于富文本的可视化搭建看似很新颖,但其实早就被广泛使用了,任何一个富文本编辑器几乎都有插入表格功能,这就是一个典型插入自定义组件的场景。 使用过 语雀 的同学应该知道,这个产品的富文本编辑器可以插入各种各样自定义区块,是 “最像搭建” 的富文本编辑器。 那么积木式搭建和富文本搭建存在哪些差异,除了富文本更倾向于记录静态内容外,还有哪些差异,两者是否可以结合?本文将围绕这两点进行讨论。 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 的能力。 但富文本搭建有着显著的特征,就是光标。 积木式搭建和富文本搭建的区别富文本以文本为中心,因此编辑文字的光标会常驻,编辑的核心逻辑是排版文字,并考虑如何在文字周围添加一些自定义区块。 有了光标后,圈选也非常重要,因为大家编辑文字时有一种很自然的想法是,任何文字圈选后复制,可以粘贴到任何地方,那么所有插入到富文本中的自定义组件也要支持被圈选,被复制。 实际上富文本内插入自定义区块也可以转换为积木式搭建方案解决,比如下面的场景: 文本 A图表 B文本 C 我们在文本 A 与 文本 C 之间插入图表 B,也可以理解为拖拽了三个组件:文本组件 A + 图表组件 B + 文本组件 C,然后分别编辑这三个组件,微调样式后可以达到与富文本一样的编辑效果,甚至加上自由布局后,在布局能力上会超越富文本。 虽然功能层面上富文本略有输给积木式搭建,但富文本在编辑体验上是胜出的,对于文字较多的场景,我们还是会选择富文本方式编辑而不是积木式搭建拖拽 N 个文本组件。 所以微软 OneNote 也吸取了这个经验,毕竟笔记本主要还是记录文字,因此还是采用富文本的编辑模式,但创造性的加入了一个个独立区块,点击任何区域都会创造一个区块,整个文档可以由一个区块构成,也可以是多个区块组合而成,这样对于连贯性的文字场景可以采用一个富文本区块,对于自定义区块较多,比如大部分是图片和表格的,还可以回到积木式搭建的体验。由于 OneNote 采用绝对定位模拟流式布局的思路,当区块重叠时还可以自动挤压底部区块,因此多区块模式下编辑体验还是相对顺畅的。 可以看出来这是一种结合的尝试,从前端角度来看,富文本本质上是对一个 div 进行 contenteditable 申明,那么一个应用可以整体是 contenteditable 的,也可以局部几个区块是,这种代码层面的自由度体现在搭建上就是积木式搭建可以与富文本搭建自由结合。 积木式搭建与富文本搭建如何结合对于积木式搭建来说,富文本只是其中一个组件,在不考虑有富文本组件时是完全没有富文本能力的。比如一个搭建平台只提供了几个图表和基础控件,你是不可能在其基础上使用富文本能力的,甚至连写静态文本都做不到。 所以富文本只是搭建中一个组件,就像 contenteditable 也只能依附于一个标签,整个网页还是由标签组成的。但对于一个提供了富文本组件的积木式搭建系统来说,文字与控件混排又是一个痛点,毕竟要以一个个区块组件的方式去拖拽文本节点,成本比富文本模式大得多。 所以理想情况是富文本与整个搭建系统使用同一套 DSL 描述结构,富文本只是在布局上有所简化,简化为简单的平铺模式即可,但因为 DSL 描述打通,富文本也可以描述使用搭建提供的任意组件嵌套在内,所以只要用户愿意,可以将富文本组件拉到最大,整个页面都基于富文本模式去搭建,这就变成了富文本搭建,也可以将富文本缩小,将普通控件以积木方式拖拽到画布中,走积木式搭建路线。 用代码方式描述积木式搭建: <bar-chart /><div> <p>header</p> <line-chart /> <p>footer</p></div> 上述模式需要拖拽 bar-chart、div、p、line-chart、p 共 5 个组件。富文本模式则类似下面的结构: <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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《可维护性思考》","path":"/wiki/WebWeekly/前沿技术/《可维护性思考》.html","content":"当前期刊数: 212 PS: 所有没给原文链接的精读都是原创,本篇也是原创。 前端精读之前写了 23 篇设计模式总结文,再加上 6 种设计原则,开闭、单一职责、依赖倒置、接口分离、迪米特法则、里氏替换原则,基本上对代码的可维护性有了全面深刻的理解。 但你我在工作中都会不断遇到烂代码,快要无法维护的大型项目,想一想,仅凭设计模式就能解决这些问题吗?为什么不断膨胀的大型项目总是变得越来越难以维护,而复杂度更高的真实世界,但没有人觉得快要崩塌了呢? 设计模式考虑的是代码之间的关系,设计原则考虑的是模块以及项目间的关系,那是否存在更上层的思考,解决大型项目越来越难维护的问题? 精读先考虑一下,为什么真实世界没有可维护性问题? 真实世界为什么没有可维护问题这个问题看起来有点傻,因为从来没有人会发出这样的抱怨 “我们的产品、科技、概念太多了,多到我觉得无法在这个世界活下去了”。但是在代码世界,程序员经常会抱怨,项目的概念太多、设计过于复杂,以至于他无法继续再维护下去了,是时候寻找下一份工作了。 一种显而易见的解释是,生活中,我们都是小角色,活在自己的天空下并不需要触及那么多概念,而程序员在项目中基本扮演了上帝的角色,必须为每一个细节操心。 但这并不完全解释得通。我们以为自己接触的东西不多,但实际上日常生活的知识太多了,就拿家电来说,每个人都会同时接触几十种家电,大到空调冰箱洗衣机,小到手机牙刷充电器,即便这些产品被大量标准化,但每个产品用起来都有大量细节的区别,但没有一个人觉得学习使用一个新剃须刀是一种负担,也并不觉得一款设计得不好的牙刷,会对整个牙刷行业造成怎样负面的冲击。 这背后的原因是:拷贝。正因为我们用的每一件东西都是拷贝,所以即使用坏了也不会对其它相同物品产生任何影响。但代码世界则不同,因为代码调用关系的存在,复用的越优雅,破坏力也就越大。一栋大楼断了几块钢筋尚可支撑,但换在代码世界,只要断了一块钢筋,就意味着这栋大楼所有钢筋都断了。这就是程序员最痛恨的问题之一,就是为什么改了一处看似人畜无害的代码,却导致一场故障。 从这个角度来说,代码世界是无法吸取真实世界经验的。而且代码世界的这种副作用,在商业上是有巨大正向价值的,即软件的边际成本几乎为零,这是实体产品做不到的,因此软件需要付出可维护性代价,似乎是这种极低边际成本的代价。 虽然通过借鉴真实世界的经验,使自己维护成本变成零是不可能的,但真实世界对软件世界确实有可借鉴之处,下面我们就来探讨几个有意思的点。 真实世界不断屏蔽复杂度不知道你会不会有过这样的思考:面试官总是问原理,就是担心我只会用框架,而缺乏基础。但基础是什么呢?懂得 js,java 算是基础吗?也可以说不算,因为这些语言背后的编译原理好像才是基础,编译原理背后还有操作系统,操作系统运行在硬件上,而硬件的原理呢?从 CPU 设计到背后的硅是如何制作的,等等,这样下去,似乎永远也无法掌握原理。 但当我们从软件推导到硬件时,可以很自然的发现,没有人觉得掌握硅胶的制作过程是一件必须的事,我们可以一直使用硅胶制作的产品,但却可以不用了解硅胶制作的原理。 真实世界总是不断屏蔽复杂度,作为消费者时,我们面对的商品总是经过精心包装,简单易用的,只有我们工作时,才需要对某个专业领域的原理有所了解。 这个道理可以迁移到代码世界,即对于一个庞大而复杂的项目,不能指望每位开发者都了解全部原理后才能工作,我们需要在大多数时候把开发者当作消费者来看待,提供精美而稳定的接口。要做到这一点,需要一个类似下图的架构设计: 从图中可以看出,即便是业务层代码,我也不需要关心过于底层的实现,底层的代码就像脚下被压实了的土地,只需要在上面走就行了。 然而最让人崩溃的是下面的设计: 为了解决一个问题,需要面对无穷无尽的上下文,这就是维护成本高的最主要原因。 为什么觉得维护成本高作为开发者,已经习惯了评价代码维护成本高还是低,今天我们换个视角,想一想为什么你会觉得维护成本高? 对维护成本的感受不完全是客观的,我画了一个四象限图: 左边是和人相关部分,包括你对代码的理解能力,以及对项目的熟悉度。 理解能力越强,越不容易觉得维护成本高;对项目越熟悉,哪怕是屎山代码,也会觉得重构后可维护性并不会提高,因为自己对项目会变得不熟悉。 右边是和项目相关部分,包括业务本身的复杂度,以及这背后的技术抽象实现的质量。 业务本身越复杂,维护成本就会越高,因为信息量不可避免的增大了,我们永远不能只盯着 Hello World 的 Demo 研究框架;代码质量体现了技术对业务的抽象,抽象的好,复杂度曲线就会比较贴合业务真实复杂度,抽象的不好,Hello World Demo 也能够新人进来喝一壶。 在这四个关键词中,业务复杂度是几乎无法改变的,对项目熟悉也需要一个过程,所以重点应该放在理解能力与代码质量两部分。 无论是个人理解能力,还是代码质量,目标都是帮助我们快速理解项目,也就是说,只要能快速理解技术项目在做什么,我如何快速融入,就会觉得可维护性高,反之则觉得不好维护。 所以一个简单的项目,或者一个分层合理,文档清晰的大型项目都会让人觉得可维护性好。在这一点上,需要向真实世界学习的经验就是,即便在软件世界,也并不是了解所有原理,所有犄角旮旯的逻辑才表明技高一筹,带着这种思想工作只会让大家陷入无尽的内卷和理解焦虑。我们要给大家思想减负,不需要理解的模块、代码设计,就不要轻易展示出来,将每个模块开发所需了解的最小知识设定好,最大程度减少开发者的理解负担。 当然要补充一句,这并不意味着局限开发者的成长和学习空间,其它知识随时敞开大门,只是理解它们并不是日常开发所必要的,这些知识形成文档可以用完即弃,不用成为长期记忆。说到这,就引出了真实世界第二个有趣的地方,就是说明书。 真实世界的说明书我回头想想也挺不可思议的,无论快递买来任何需要组装的东西,按照说明书的指引最终都可以组装好,而且装好之后就可以把说明书扔了,完全没有认知负担。 与其说快递包裹的说明书太完善了,不如说说明不完善,不好用的商品根本卖不出去。我们早已习惯极度易用的商品,及其详尽的说明书了,这是商业社会持续发展,长期博弈后的结果,而且会稳定持续下去。试想一下,如果我们参与维护的项目也有精巧的设计,完善的文档,那维护就不是什么问题,按照文档说的一步步来就行了。 那为什么大部分情况,我们接手的项目就像一个没有说明书的乐高呢?这应该是商品与代码的本质区别了,即商品质量好不好,是由买家用钞票投票的,做得好用,说明书完善的商品才能存活下来,但这背后的技术实现是看不到的,也没有人可以投票,即便技术人员吐槽代码无法维护,但如果项目取得了商业上的成功,也只会越做越大,技术债越滚越多。 技术项目的买家是程序员,但程序员没有拒签的办法,导致无论项目质量如何都要接受,没有市场机制的作用,就导致了烂代码随处可见。 要解决这个问题,首先要意识到这个问题,即技术项目质量本质上是无人长期、持续关心的,你可能会说,技术 Leader 会关心呀?但这和业务驱动相比实在是太弱了。产品有用户侧钞票的投票,无论管理者换多少人,还是会从源头持续提供动力,但项目质量总是要反复强调,间歇性整治,并且不同的 Leader 关心程度也不同,因为这背后没有源动力,除非项目质量影响到用户那头的现金供给了,但这种情况发生时,说明项目早已烂透了。 正是因为技术质量缺乏源动力,或者说源动力传导链路太长,我们才要人为的不断加强重视,重视文档、重视使用体验、重视是否符合设计模式。只有长期主义者才能坚持做代码质量治理,因为坚信总有一天,代码质量会影响到业务发展。 总结这次从真实世界借鉴了一些经验到软件世界,我们从借鉴真实世界的屏蔽复杂度,谈到了为什么真实世界的说明书这么好用,但技术项目文档却总是缺胳膊少腿的问题。 我们总结出的经验是,设计原则与设计模式固然可以提升可维护性,但归根结底还是动力的问题,提升代码质量本身就是一件缺乏动力去做的事,或者长期被认为是重要不紧急的事,往往很难找出理由现在就去做,但没有人觉得不应该做。 所以想要提升可维护性,找到为什么现在,立刻,马上就要做技术优化的原因,并立即开始优化才是最重要的。 讨论地址是:精读《可维护性思考》· Issue ##359 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《国际化布局 - Logical Properties》","path":"/wiki/WebWeekly/前沿技术/《国际化布局 - Logical Properties》.html","content":"当前期刊数: 86 1 引言“一带一路” 正在积极推动中国的国际化进程,前端网站也面临着前所未有的国际化挑战。那么怎么才能积极响应 “一带一路” 战略,推动网站的国际化工作呢?可以先从国际化布局开始考虑。 本周精读的文章是:new-css-logical-properties,通过一种新的 CSS 技术,实现国际化布局。 CSS Logical Properties 是一种新的 CSS 布局方案,嗯对,和几年前的 Flex 布局、Grid 布局一样,CSS Logical Properties 方案不出意外的受到了微软的阻挠: 不过没关系,不论是 Flex、Grid 我们都挺过来了,Proxy 虽然还不被微软支持,不过已经在 Edge 被支持了。相信 CSS Logical Properties 也一样,现在可以率先使用在国外环境,国内等若干年后 Edge 支持或者被淘汰了,就可以用上了。 2 概述旧的盒子模型告诉我们左右上下这四个方向,但在新的模型中,请记住 inline-start inline-end block-start block-end: (LTR)对应关系如下: 左: inline-start 右: inline-end 上: block-start 下: block-end 这些适用于 margin padding border 修饰,比如 margin-left 中,left -> 左 -> inline-start -> margin-inline-start 这有点像把坐标系概念引入了布局,对于不同国家,inline 与 block 的方向是不同的: 在东亚绝大多数国家、英美系国家 padding-inline-start = padding-left 在阿拉伯国家 padding-inline-start = padding-right 在日本 padding-inline-start = padding-top 以中国和英美系国家的阅读顺序为基准的话,阿拉伯国家等于把左右颠倒了,而日本是把网页沿顺时针旋转 90 度。 为什么 inline 表示从左右,block 表示上下呢?还记得 display: inline 吗?此时排版是从左到右排布的,而 display: block 的排版是从上到下的。 宽高width height 也需要换成 inline-size 与 block-size,整理如下(LTR): width: inline-size min-width: min-inline-size max-width: max-inline-size height: block-size min-height: min-inline-size max-height: max-inline-size 下图是 Box Model 与 Logical 的对比: 绝对定位对于绝对定位属性 top/right/left/bottom top: inset-block-start bottom: inset-block-end left: inset-inline-start right: inset-inline-end 记得方式与 上下左右 表相同,在前面加上 inset 前缀。 尽管这样描述起来很复杂: .popup { position: fixed; inset-block-start: 0; /*top - in English*/ inset-block-end: 0; /*bottom - in English*/ inset-inline-start: 0; /*left - in English*/ inset-inline-end: 0; /*right - in English*/} 但是这种属性支持聚合写法: .popup { position: fixed; inset: 0 0 0 0; /*top, right, bottom, left - in English*/} Float对于 float 的两个值 left right,可以很容易推测出来,会被 inline-start 与 inline-end 取代(LTR): float: left = float: inline-start float: right = float: inline-end Text-aligntext-align 也有 left right 属性,分别取代为 start end(LTR): text-align :left = text-align: start text-align :right = text-align: end Css Grid 与 Flexbox使用 css grid 与 flexbox 布局方案的网页,将在支持的浏览器上自动享受国际化布局调整,不需要改变语法。 Writing-mode目前为止,看到的是 Css 对排版含义的规范化,Grid 与 Flexbox 由于 API 比较新,定义的较为规范,所以不用变,而旧的 display, position, width, height, float 等 API 需要进行语义化改造。 现在就要聊到最关键的布局国际化部分,我们至今为止遇到的网页都是从上到下的,但其他文化却不同。可以通过配置 writing-mode 让整个网页布局改变: writing-mode: horizontal-tb = 从上到下writing-mode: vertical-rl = 从右到左 比如日本文化writing-mode: vertical-lr = 从左到右 比如蒙古文化 至今还没有见过从下到上的网页,也许这证明了从下到上是最不合理的阅读方式。 Direction这是一个排版属性,writing-mode 是控制网页方向的,而 direction 是控制文字对齐方向的。 目前只有两个配置:rtl 与 ltr: html { direction: rtl;} 其实 writing-mode 与 direction 结合起来也没什么问题,比如网页布局变成 vertical-rl - 从右到左,那么 direction 的 ltr 就等于是从上到下了。 最后还有一些悬而未决的问题,比如如何开启智能布局?一种方式是: html { flow-mode: physical; /*or*/ flow-mode: logical;} 另外,像 @meta 配置中的 max-width 也要替换为 max-inline-size, line-height 需要被替换为 line-size,border-width 需要被替换为 border-size 等等。 3 精读整个 Logical Properties 规范看下来是个不可逆的趋势,也代表着 W3C 规范在排版方面的全球化工作。 为什么要改造语法第一个问题就是这个,我们习以为常的 left top right bottom 语法都需要改成 inline-start block-end 等略微晦涩的语法,而且你可以发现,新语法与旧语法是完全一对一对等的,也就是完全可以交给某个转换程序去做! 可以看出,这是一个习惯问题,W3C 希望重塑国际化布局的语义,而原有的 left top 等无法承担这些语义,所以只好换掉。 新版规范要求开发者做出一个抽象,把自己国家的习惯抽象成习惯无关的描述。但对于每个前端从业者来说,left top 等描述估计已经成为肌肉记忆了,想要改变规范还是挺难的,未来前端社区也许会出现三种解决方案: 保守派 - 利用 babel 将原有语法与新语法做一对一映射转换,比如 position: left -> position: inset-inline-start。这种方案 成本最小,且不改变开发者习惯,所以最有可能被国内公司率先采用。在商业环境推动一件事情,最大的阻力无非是 成本 与 共识,这次的布局规范同时触及了这两个点,可能让团队倾向于做保守派。 兼容派 - 其实就是两面派,利用 babel 工具做映射这一点与保守派相同,但是新代码推荐用新语法编写,如果团队中有人不遵循新规范,也会被工具自动转换为新规范。这种软要求会导致团队布局代码存在两套,但最终效果却没有问题的神奇效果,长远来说不利于维护,但不失为一种较为妥协的策略。 改革派 - 利用脚本,将项目里旧规范替换成新规范,并让团队未来的代码遵循新的布局规范编写。很显然,这派抓住了迁移成本小这个优势,但没有考虑到人这个因素的习惯迁移成本,如何说服其他人理解新规范,并做到让 “未来加入的同事” 也能认同并遵循这套新规范,也许是最大的不确定因素。 为什么 Flex Grid 语法不需改造?这次改造是冲着 left right width height 等明显带有文化色彩的语法来的。 然而 Flex 语法已经将方向定义转化为抽象的 start 与 end,而 center 是没有歧义的,所以 FlexBox 语法不用改。 而 Grid 是一种拆分单元格的语法,也不涉及具体上下左右的描述,所以也符合国际化语义。 4 总结那么为什么 W3C 到现在才改语法,难道以前没有想到吗?也许还真是,或者处于推广成本的考量,或者当时的文明发展阶段还没有意识到文化差异会导致布局方式有所不同。 当出现 Logical Properties 特性时,说明人类的全球化已经突破了翻译维度,开始向比如布局方式等其它维度蔓延了。 除了布局需要国际化,使用数字的习惯也需要国际化,可以阅读这篇拓展文章 和欧洲人打交道一定要知道他们数字写法,否则吃大亏!。 那么除了这些,还有哪些维度的国际化策略呢?除了语言的翻译,国际化还有哪些工作需要准备?欢迎在下面留言。 讨论地址是:精读《国际化布局 - Logical Properties》 · Issue ##121 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《图解 ES 模块》","path":"/wiki/WebWeekly/前沿技术/《图解 ES 模块》.html","content":"当前期刊数: 52 精读《图解 ES 模块》ES 模块为 JavaScript 开发者带来了官方并且标准化的模块系统。模块标准化来之不易,用了近 10 年的时间。漫长的等待就要宣告结束了。随着五月份(2018)即将发布的 Firefox 60,几乎所有的主流浏览器都将支持 ES 模块,并且 Node 模块工作组也正尝试将 ES 模块支持到 Node 环境。本期精读文章和大家一起了解 ES 模块,讨论它能够解决的问题以及与其他模块系统间的差别。 1. 引言精读文章主要讨论了下面几点: 模块旨在解决哪些问题; 模块为开发者带来哪些; ES 模块化的工作机制; ES 模块化的现状; 2. 内容概要模块旨在解决哪些问题JavaScript 开发可以简单地抽象成维护变量,赋值和计算操作。大量的代码在用于操作变量,开发者需要懂得如何去组织和维护这些变量。JavaScript 提供了一种方式,即函数作用域。在一个函数内只需要考虑这个函数的变量问题。不必去担心其他函数会操作这些变量。当然,随之带来的问题是,变量无法共享,无法在不同的函数之间相互共享变量。如果想要在作用域外共享变量,只能通过外层作用域,或者全局作用域。 jQuery 时代,只要 $ 变量在全局作用域下,就可以加载任何的插件,不过它本身存在问题的。 首先,要保障 script 标签的顺序。如果顺序错乱,应用将会抛错。比如函数用到了全局作用域的 $ 函数,但没有找到,就会抛错了。 这就使得维护代码变得很复杂。移除旧代码会像轮盘赌游戏一样,无法预料将会发生什么。不同部分代码之间存在隐形的依赖。所有函数都可以访问全局变量,根本无法知道哪个函数属于哪个脚本。 还有,存储在全局的变量可以被任何作用域中的代码修改。代码可能遭到恶意的修改。 模块为开发者带来哪些模块提供了更好的方式来组织变量和函数,把相关的变量和函数组织到一起。具体就是将这些函数和变量放到一个模块作用域内,实现在模块间共享变量。 与函数作用域不同的是,模块内部的变量实现了在其他模块内共享。而且可以指定哪些变量、类或者函数可以共享。 在其他模块中共享,被称为 export。这就出现了模块间的依赖,是一种很明确的关系,当移除一个模块时可以准确的知道哪些模块会出错。 一旦有了模块间导出和引用变量的能力,我们就可以将代码打成小包。然后就可以像乐高玩具那样组合,再组合。使用小模块就可以创建出各类应用。 模块非常有用,这也就出现了很多种类的 JavaScript 模块。目前存在两种主流的模块系统。CJS 是 Nodejs 遗留下来的。ESM 是一个 JavaScript 的新规范。浏览器已经支持了 ESM,并且 Node 也在添加支持。 ES 模块化的工作机制模块化开发会将依赖构建为树形结构。通过 import 语句通知浏览器或者 Node 去加载相关的代码。这些依赖树会有一个根节点作为入口文件,从入口可以找到依赖的其他代码。 在浏览器环境下这些文件需要被转化为一种叫做『模块记录』的数据结构。紧接着,模块记录需要被转化为模块实例。每个实例包含了两个东西:代码和状态。 代码就像是指令集。如果仅通过代码并不能做什么,还需要一些原始的材料来应用这些指令。状态就提供了原始的材料。状态其实就是这些变量的值。当然,这些变量仅仅是内存中存储值的别名。 模块将代码和状态结合到一起。 从入口文件到完整的模块树形实例,主要经过了下面三个步骤: 构建:查找,下载,然后将所有的文件转化为模块记录。 安装:将所有导出的变量放到内存中,此时的变量并没有被赋值。然后将导出和导入变量全部放到内存中。我们称之为链接。 赋值:执行代码,将变量值添加到内存中。 之所以说 ES 模块是异步的,正是因为 ES 模块将这三个步骤划分开。实际上在 CJS 中模块和相关的依赖都是一次完成加载,安装和赋值的。 ES 模块需要借助模块加载器来实现这三步。加载器在不同的平台下有不同的规范,浏览器端就是 HTML 规范。 1. 构建确认从哪里加载文件所包含的模块,查找加载文件加载器比较关心的是查找并且下载到文件。首先需要找到入口文件。在 HTML 中通过一个 script 标签。 但是接下来要如何找到模块直接依赖的文件树呢? 这就是 import 语句出场的时候了,它可以通知加载器去哪里找到其他的模块。 模块规范需要注意的一件事就是:它们有时候需要处理浏览器和 Node 两个不同的环境。每个宿主环境处理模块标识符的方式不同。为了能够实现这个,它使用了一个模块识别算法,用来区分不同的平台。目前,有些 Node 模块规范是无法在浏览器端工作的,不过也正在持续修复中。 在修复前,浏览器仅仅会接收 URL 模块标识符,通过 URL 来加载模块文件。不过,在转化之前你并不知道模块有哪些依赖项,并且你在加载文件前是没有办法转化文件的。 这就意味着我们必须一层一层的遍历文件树,转化文件并找出依赖,最后查找并且加载这些依赖。如果主线程正在等待去下载这些文件,那么很多的任务会堆积在队列中。这是因为浏览器环境下下载用了很长时间。 阻塞主线程会导致应用所需的模块变得很慢。将构建过程分片进行实现了在全部下载前进行获取和构建。这种差分构建的方式是 ES 模块和 CJS 模块最本质的不同。 CJS 的做法很不同,主要是由于相对于通过网络请求从文件系统加载文件耗时更少。这意味着 Node 可以在加载文件的时候阻塞主线程。文件加载完毕后,进行实例化和计算。这也就以为着在返回模块实例前完成遍历整个树,加载,实例化并且计算依赖。 在 Node 环境下,你可以在模块内部声明变量。在查找下一个模块前,都在执行这个模块里的代码。这意味着在执行模块前,变量会有一个值。但在 ES 模块中,需要事先构建整个模块树。 将文件转化为一个模块记录在我们加载文件后,我们需要将它转化为一个模块记录。这会让浏览器理解模块的不同部分。一旦模块记录被创建,就会被放在一个模块映射中。这意味着当它被请求时,加载器可以从映射中拉出来。 在浏览器中你只要将 type="module" 放在 script 标签上。这会通知浏览器这个文件应该被转化为一个模块。同样,只有模块才能够被导入,浏览器也就知道了模块中有哪些引用。 不过在 Node 中,并没有 HTML 标签,所以也没有地方声明 type 属性。社区内的一种方式就是使用 .mjs 扩展。使用这个扩展告诉 Node 这个文件是一个模块。 无论哪种方式,加载器将决定是否将文件转化为一个模块。如果是一个模块并且有导入的话,它就会开始处理直到所有的文件被获取和转化。 2. 安装我之前提到了,实例由代码和状态结合而成的。状态在内存中,所以安装这一步基本是关于如何在写入到内存。 首先,JS 引擎创建一个模块环境记录。这会为模块记录维护变量。然后在内存中开辟空间,让这些变量可以被导出。模块环境记录会基础追踪内存中的值导出的每个变量。内存空间并不会获取到变量的值,而是计算后得到值。 为了实例化模块树,引擎将会完成一个叫做深度优先的后序遍历。这意味从树的底部开始,底部的依赖不会再依赖其他的东西,并且创建它们的导出。 引擎会绘制出一个模块下的所有导出。然后绘制这个模块的所有导入。注意,导出和导入在内存中指向同一个地址。这里和 CJS 模块有区别,在 CJS 中所有导出对象的值都是一个拷贝。与之相反,ES 模块使用了类似绑定的东西。模块会指向内存这种的同一个地址。这意味着当导出模块修改了一个值,这个修改会在不在导入模块时表现出来。 有导出值的模块会在任何时候修改这些值,不过导入模块不会改变他们导入的值。也就是说,如果一个模块引入了一个对象,它可以改变对象的属性值。 像这样动态绑定的原因就是可以在不执行代码的情况下连接所有的模块。 在这一步的最后,我们我们会将实例和内存地址连接起来。 3. 赋值最后一步就是填充内存空间。JS 引擎通过执行顶层的代码来完成,也就是函数外的代码。如果遇到类似异步调用的情况,还可能会出现一些负面的影响。 由于这种负面影响,赋值得到的结果可能是不相同的。这也是模块映射机制出现的一个原因。模块映射会通过 URL 来缓存模块,所以每个模块仅会有一个模块记录。这会确保每个模块只执行一次。就像初始化一样,这也是一个深度优先的后序遍历。 再说一下循环依赖的情况,需要遍历树。通常是一个很长的循环。但是为了解释这个问题,我们做一个简短的例子。 我们先看一下 CJS 是如何工作的。首先,模块会执行 require 语句。然后加载 counter 模块。 ounter 模块接着会访问导出对象里的 message。但由于这个还没有在模块中计算,会返回 undefined。JS 引擎会为本地变量分配内存空间,并且将值赋为 undefined。 Evaluation continues down to the end of the counter module’s top level code. We want to see whether we’ll get the correct value for message eventually (after main.js is evaluated), so we set up a timeout. Then evaluation resumes on main.js. 继续向下计算会执行到 counter 模块的顶部代码。这里设置了一个延时看是否可以正确的获取到 message 的值。 message 变量会被初始化后添加到内存中。不过由于这两者间并没有关联,加载模块后还是 undefined。 如果导出时用了动态绑定处理的,counter 模块最终会拿到准确的值。在执行 setTimeout 后,main.js 会执行完成并且拿到值的。 3. 精读 & 总结模块化提供了更好的方式来组织变量和函数,把相关的变量和函数组织到一起。具体就是将这些函数和变量放到一个模块作用域内,实现在模块间共享变量。与函数作用域不同的是,模块内部的变量实现了在其他模块内共享。而且可以指定哪些变量、类或者函数可以共享。 由于 Nodejs 的缘故,目前看来 CJS 模块系统是使用数量更大。目前的 CJS 还无法兼容新的 ESM,不过 Node 工作组也正在这方面努力尝试中。而这两个模块系统最大的区别就是运行时。CJS 是一个动态的模块系统,而 ESM 只是静态模块系统。动态模块的导出只有在执行后才能得到,并且可以添加和删除,而静态模块则不可以,导入和导出是不可变化的。 而目前我们大都是通过 webpack 的构建工具之上使用 ESM,它可以在一定程度上模拟环境。期待 Node 工作组实现对 ESM 的早日支持。"},{"title":"《在浏览器运行 serverRender》","path":"/wiki/WebWeekly/前沿技术/《在浏览器运行 serverRender》.html","content":"当前期刊数: 54 本周精读内容是 《在浏览器运行 serverRender》。 这里是效果页,先睹为快:client-ssr。 1 引言在服务端 ssr 成为常识的今天,前端 ssr 是我最近的新尝试,效果可以点击上面链接查看。说说前端 ssr 有哪些好处: 不消耗服务器资源。对,不消耗服务器资源,完美的分布式运行!对于百万 UV 甚至更高的页面,服务器成本减少了几十万或者上百万。 前后端分离,首先 ssr 不需要部署服务器,其次前端代码也不需要担心质量问题导致的内存泄露了,同时可以不必时刻注意使用同构的三方库,只需要考虑前端可运行! 不需要后端缓存服务,对于千人千面的复杂页面,对后端 ssr 来说缓存规模庞大的无法计算。 相比后端 ssr,在前端可以绕过复杂的权限系统,同时 http 请求的权限问题也无需关心。 因为第一点,对于不支持后端服务的 github pages 也能做到 ssr。 相对的,缺点是: 需要客户端支持 serviceWorker。 第二次首屏才会生效。后端 ssr 可以做到访问前预缓存 ssr 结果。 可能破坏前端页面状态,因为在同一个环境偷偷执行了一些页面逻辑。不过这个缺点可以通过 web worker 执行 ssr 解决,还在调研中。 service worker 拦截入口 html 风险很高,一旦代码有故障可能导致严重后果,需要提前考虑完备的回滚方案。 像缓存清空时机等问题,前后端 ssr 都会遇到,所以不列在优缺点中。 2 精读本篇精读分享的是前端 ssr 方案具体实现步骤。 我们先了解整体流程: service worker 拦截首页service worker 可以在浏览器尝试请求首屏 html 之前的时机拦截,此时如果 caches 命中,直接将 response 扔给浏览器,那么服务端将完全不会收到请求,完成了最高效的缓存命中。 当然第一次没有缓存,所以在没有命中缓存时,会同步的做两件事: 发送请求,拿到后端返回的 response,扔给浏览器。这是最普通的请求逻辑。 当前端代码 ready 后,postMessage 给浏览器,索要 ssr 内容。 附上代码片段: self.addEventListener("fetch", event => { if ( event.request.mode === "navigate" && event.request.method === "GET" && event.request.headers.get("accept").includes("text/html") ) { event.respondWith( caches.open(SSR_BUNDLE_VERSION).then(cache => { return cache.match(event.request).then(response => { // 命中缓存,直接返回结果。 if (response) { return response; } return fetch(event.request).then(response => { const newResponse = response.clone(); return newResponse .text() .then(text => { // 通知浏览器,执行 ssr 并且返回内容。 self.clients.matchAll().then(clients => { if (!clients || !clients.length) { return; } clients.forEach(client => { client.postMessage({ type: "getServerRenderContent", pathname: new URL(event.request.url, location).pathname }); }); }); return response; }) .catch(err => response); }); }); }) ); }}); 当然还需要一个监听,用来拿浏览器的 ssr 内容,并缓存到 caches 中,比较简单就省略了。 浏览器执行 ssr监听就不说了,主要是如何利用 react-router 与 react-loadable 完成前端 ssr。 首先根据 service worker 告诉我们的 pathname,拿到对应 loadable 的实例,并通过 loadable.preload() 预先加载 chunk,当 chunk 加载完毕时,资源已经准备好了。 我们利用给 StaticRouter 传递当前的 pathname,让 react-router 模拟出需要 ssr 的页面内容,通过 renderToString 拿到 ssr 的结果。 附上代码片段: if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener("message", event => { if (event.data.type === "getServerRenderContent") { const baseHrefRegex = new RegExp( escapeRegExp("${projectConfig.baseHref}"), "g" ); const matchRouterPath = event.data.pathname.replace(baseHrefRegex, ""); const loadableMap = pageLoadableMap.get( matchRouterPath === "/" ? "/" : trimEnd(matchRouterPath, "/") ); if (loadableMap) { loadableMap.preload().then(() => { const ssrResult = renderToString( <StaticRouter location={event.data.pathname} context={{}}> <App /> </StaticRouter> ); if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage({ type: "serverRenderContent", pathname: event.data.pathname, content: ssrResult }); } }); } } });} 这里需要优化,利用 web worker 执行 ssr 才可以用于生产环境。 最后,等待用户的下一次刷新,service worker 会帮我们把 ssr 内容作为首屏给用户一个惊喜的。 3 总结同样这次只是抛砖引玉,希望大家能提出建议一起帮助我们完善这个方案。 此方案正式用在生产环境后,会再写一篇文章介绍实践过程。 4 更多讨论 讨论地址是:精读《在浏览器运行 serverRender》 · Issue ##80 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《增强现实与可视化》","path":"/wiki/WebWeekly/前沿技术/《增强现实与可视化》.html","content":"当前期刊数: 43 增强现实与可视化引言增强现实,Augmented Reality,简称 AR。在 VR 的热潮已经褪去,AI 当下正红的技术圈里,AR 似乎已经成为了过气网红。但似乎在现在,我们可以来冷静地看待一下增强现实这个概念。 做前端的最终还是要和用户打交道的,增强现实是不是为用户界面带来了新的可能?可视化作为前端的一个细分领域,这篇文章正是从这个角度出发来重新认识增强现实与可视化。 内容概要移动设备为我们带来了巨大的便利,但是在各种好处下,有一点不容忽视:屏幕太小,根本没有更多的空间来放置更多的内容。对于数据可视化而言,更大的空间意味着更好的数据分析能力。而在空间这件事情上,AR 正是很好地解决了这个问题。当下,AR 的主要形态还是将虚拟信息作为图层叠加在现实图像之上的形态,这样的 AR 其实更接近于混合现实。例如使用面部追踪给你脸上加上小动物的 Snapchat Lenses,可以在沙发茶几上玩 MineCraft 的微软 HoloLens,和苹果最近新出的 ARKit 都是这种。 所以要怎么通过增强现实来解决当下移动端屏幕空间不足的问题呢?在 AR 的概念下,一切皆屏幕,现实之上的任何地方都可以展示信息。对于增强现实的未来,文章给出了 3 个可能的方向。首先,需要提供更好的针对个人的视觉体验。其次,要更好地利用 3D 展示。最后,让屏幕悬浮在任何地方。 但是我们也不妨从反面重新理性地看到了增强现实。是不是有了足够空间展示东西我们就要把东西一股脑都展示出来呢?并不是的,增强现实是和真实世界的混合。所以增强现实下的可视化,也不是也不是简单地照搬网页。在增强现实下,我们所展示的每一个虚拟物体,是需要有现实依据的。但是也不必拘泥于现实,增强现实也可以改造所展示的世界。增强现实并不是简单的做加法,只能构建出各类虚拟物品叠加在世界上。同样也能改造世界,使事物变形和消失。 所以回到整体在增强现实下的可视化,增强现实是一个完全不同的世界。我们原先的可视化相关的经验并不一定都能应用到增强现实的世界中。文章中也针对我们常见的可视化元素进行了辨识分析,对增强现实下的可视化的基本元素进行了分析。 精读前端是什么?对于这样一个本源性的话题。每个人都有自己不同的理解。我也不会说书式得非得确定这个问题的答案。但是可以确定的是,如果一切的描述要带上设备端的前提,是短视的。 从 PC Web 到移动 App,前端并没有消失,各大网站也都纷纷开始将自己的网站搬到了手机上以移动 APP 的形式存在。同样的,如果有一天增强现实来到我们的生活。前端作为和用户界面直接接触的工作,毫无疑问已经会是很重要的一部分。 再看可视化。移动化的大潮让我们开始习惯移动设备,触屏之类的交互的确让我们的信息获取变得更加方便。但是这并不代表全部,特殊行业的人依旧离不开传统的 PC,尤其是数据分析人员,对于他们而言,更大的屏幕等于一切。尤其是金融分析师,你给他们四块屏幕都不够用。增强现实下的可视化的确是对这些人的一个重要利好。原因无非就是增强现实的可展现区域相当于整个人眼的视角,远远大于任何我们可以感知到的屏幕。 如果只是在我们的眼睛上盖上一层信息,那是远远不够的,这样的增强现实仅仅是对屏幕的扩展。增强现实最关键的一点是和现实世界的充分结合:例如配合地理信息和我们的地理定位所带来的信息可视化展示;通过图像识别对物体的感知后,在物体上进行信息可视化展示;等等。文章的几个建议中,最后一个特别有趣——让屏幕悬浮在任何地方。这有两层含义,一是要充分利用好上面所提到的增强现实下的空间,二是我们需要清楚地认识到,人类对于二维世界的感知能力还是远远超出三维世界的。过度利用现实世界所构建出的 3D 场景,可能并不能为我们的分析带来太多进步。所以,二维的图形信息展示可能依然是增强现实可视化中很重要的一部分。 这篇文章更加引起我兴趣的地方在于它对于可视化基本元素进行了分析——颜色,亮度,纹理,尺寸,方向、形状、位置……这些从我们平时的显示器屏幕或者手机屏幕上所展示的可视化元素会带来完全不一样的表现。增强现实的增强在于我们可以对现实的展示进行改造,纹理是很有趣的一块。一般来说我们平时做可视化分析,常用到阴影,虚线这些纹理,但是现实世界里的纹理就太丰富了,我们可以充分利用现实花纹了,让展现更直观更融合。 增强现实的另一个难点在于带着设备的人随着不断走动,所有的展示都在变化。所以我们传统意义上关于大小值的比对的思路可能都不适用了。 大的东西一定大吗?不一定,可能它只是比较近而已。 而反观我们传统的可视化理念,都免不了对于大的线、柱、饼来表示大的东西。这一套理念可能在增强现实里就不够用了。这也是增强现实对于可视化的矛盾之处。 增强现实下的可视化还有很多路要走。 总结增强现实对于可视化带来了很多新的可能,很重要的一点就在于,当下我们的屏幕空间太小,很多东西都为了展示做出了取舍。增强现实下“一切皆为屏幕”的理念,为数据分析的展现带来了巨大的屏幕空间。我们可以用更高级的手段来进行可视化分析。 文章也分析了增强现实对可视化的利弊。虽然增强现实的设备还不普及,但是现在增强现实设备开始增多。的确增强现实带来了更多的在可视化上的可能性,但是用的不好一样会出大问题。现在成熟的可视化设计经验在增强现实下可能并不会适用。 想起之前看到的一句话,”Zen for monks, not for merchants.”。增强现实在当先依旧是一个玩乐的东西,但是利用好增强现实来展示信息,作为解决可视化难的问题的一条重要出路,来发挥真正的价值,还是很值得一看的。 讨论 讨论地址: 精读《增强现实与可视化》 欢迎大家前来讨论"},{"title":"《如何为 TS 类型写单测》","path":"/wiki/WebWeekly/前沿技术/《如何为 TS 类型写单测》.html","content":"当前期刊数: 260 如何为 TS 类型写单测呢? 最简单的办法就是试探性访问属性,如果该属性访问不到自然会在异常时出现错误,如: import { myLib } from "code";myLib.update; // 正确 如上所示,如果 myLib 没有正确的开放 update 属性将会提示错误。但这种单测并不是我们要讲的类型。想一想,如果我们只开放 .update API 给用户,但框架内部可以使用全量的 .update、.add、.remove 方法,如何验证框架没有把不必要的属性也开放给了用户呢? 一种做法是直接访问类型提示,此时会出现错误下划线: myLib.add ~~~ // Property 'add' does not exist on type MyLib 此时说明代码逻辑正常,但却抛出了 ts 错误,这可能会阻塞 CI 流程,而且我们也无从判断这个报错是否 “实际山是逻辑正确的表现”,所以 “不能出现某个属性” 就不能以直接访问属性的方式实现了,我们要做一些曲线方案。 利用特殊类型方法我们可以利用 extends 构造三元类型表达式,逻辑是如果 myLib 拥有 .add 属性就返回 a 类型,否则返回 b 类型。因为 myLib 不该提供 .add 属性,所以下一步判断该新类型一定符合 b 即可: const check: typeof myLib extends { add: any } ? number : number[] = [];check.length; // 该行在没有 .add 属性时不会报错,反之则报错 因为我们给的默认值是字符串,而预期正确的结果也是进入 number[] 类型分支,所以 check.length 正常,如果某次改动误将 .add 提供了出来,check.length 就会报错,因为我们给值 [] 定义了 number 类型,访问 .length 属性肯定会出错。 利用赋值语句判断另一种简化的办法是利用 true or false 判断变量类型是否匹配,如: const check: typeof fn extends (a: any) => any ? true : false = true; 如果 fn 满足 (a: any) => any 类型,则 check 的类型限定为 true,否则为 false,所以当 fn 满足条件时该表达式正确,当 fn 不满足条件式,我们将变量 true 赋值给类型 false 的对象,会出现报错。 可以将 ts 转换为 js 吗?也许你会有疑问,可以将 ts 类型校验错误转换为 js 对象吗?这样就可以用 expect 等断言结合到测试框架流程中了。很可惜,至少现在是不行的,只能做到利用 js 变量推导类型,不能利用类型生成变量。 总结总结一下,如果想判断某些类型定义未暴露给用户,而实际上在 js 变量里是拥有这些属性的,就只能用类型方案判断正确性了。 比如变量 myLib 实际上拥有 .update 与 .add 方法,但提供给用户的类型定义刻意将 .add 隐藏了,此时校验方式是,利用一个跳板变量 check,使用 extends 判断其是否包含 add 属性,再利用特殊类型方法或者直接用赋值语句判断 extends 是否成立。 讨论地址是:精读《如何为 TS 类型写单测》· Issue ##446 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《如何做好 CodeReview》","path":"/wiki/WebWeekly/前沿技术/《如何做好 CodeReview》.html","content":"当前期刊数: 142 1 引言任何软件都是协同开发的,所以 CodeReview 非常重要,它可以帮助你减少代码质量问题,提高开发效率,提升稳定性,同时还能保证软件架构的稳定性,防止代码结构被恶意破坏导致难以维护。 所以 CodeReview 机制是否健全是一个工程团队能否长期健康发展的决定因素之一,这次我们读一篇关于 CodeReview 如何做得更好的文章: 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 尽可能反馈逻辑问题而不是格式问题。 针对新人的 CodeReviewGood CodeReview 对任何人都是用相同评判标准,可以遵循上面几点注意事项。 Better CodeReview 会对新人区分对待,对新人给予对多的耐心、解释和评论,甚至给出解决方法,并更积极的给出鼓励。 任何人到一家新公司都有适应过程,一视同仁是 base 要求,但如果能给予新人更多关怀就更好啦。 跨办公区、时区的 CodeReviewGood CodeReview 仅在工作时间有重叠的时间范围内进行 CodeReview,这样能保证对方可以积极响应,在必要时进行语音、视频沟通。 Better CodeReview 会注意到更本质的问题,留意跨团队协作的必要性,如果某个模块经常被另一个时区同时修改,也许可以将这个模块交给对方维护,或者将 CodeReview 交给对方团队内部进行会更加高效。 笔者所在公司也有跨时区协作情况,但绝大部分场景会避免跨时区的 CodeReview,因为 CodeReview 一般会在同一时区团队内部进行,这样效率更高,应对跨时区协作时,往往也是电话、视频会议优先。 公司支持Good CodeReview 会得到公司组织支持,公司能意识到这么做虽然看起来占用开发时间,但长远来看提升了开发效率,因此能任何 CodeReview 价值。 Better CodeReview 会得到公司进一步支持,公司甚至不断研发并完善 CodeReview 系统与流程,通过系统化方案保证上面几项 CodeReview 注意事项是否有在团队内落实,可以全员参与。 CodeReview 也是一种团队文化和公司文化,公司文化带来的是规章制度与系统工具,团队文化带来的是良好 CodeReview 氛围与更高 CodeReview 的效率。 3 总结总结一下,良好的 CodeReview 需要做到以下几点: 更全面,从正确性到系统影响评估。 注意语气,从给出建设性一觉到换位思考。 及时完成审阅,从充分讨论到随机应变。 加强交流,从面对面交流到灵活选择最高效的沟通方式。 区分重点,从添加标记到利用工程化工具自动解决。 对新人要更友好。 尽量避免跨时区协作,必要时选择视频会议。 最后,希望 CodeReview 能够得到公司的支持,如果你们公司还没有认可 CodeReview 的价值,可以将这篇文章分享给你的领导。 讨论地址是:精读《如何做好 CodeReview》 · Issue ##237 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《如何利用 Nodejs 监听文件夹》","path":"/wiki/WebWeekly/前沿技术/《如何利用 Nodejs 监听文件夹》.html","content":"当前期刊数: 59 1 引言本期精读的文章是:How to Watch for Files Changes in Node.js,探讨如何监听文件的变化。 如果想使用现成的库,推荐 chokidar 或 node-watch,如果想了解实现原理,请往下阅读。 2 概述使用 fs.watchfile使用 fs 内置函数 watchfile 似乎可以解决问题: fs.watchFile(dir, (curr, prev) => {}); 但你可能会发现这个回调执行有一定延迟,因为 watchfile 是通过轮询检测文件变化的,它并不能实时作出反馈,而且只能监听一个文件,存在效率问题。 使用 fs.watch使用 fs 的另一个内置函数 watch 是更好的选择: fs.watch(dir, (event, filename) => {}); watch 通过操作系统提供的文件更改通知机制,在 Linux 操作系统使用 inotify,在 macOS 系统使用 FSEvents,在 windows 系统使用 ReadDirectoryChangesW,而且可以用来监听目录的变化,在监听文件夹的场景中,比创建 N 个 fs.watchfile 效率高出很多。 $ node file-watcher.js[2018-05-21T00:55:52.588Z] Watching for file changes on ./button-presses.log[2018-05-21T00:56:00.773Z] button-presses.log file Changed[2018-05-21T00:56:00.793Z] button-presses.log file Changed[2018-05-21T00:56:00.802Z] button-presses.log file Changed[2018-05-21T00:56:00.813Z] button-presses.log file Changed 但当我们修改一个文件时,回调却执行了 4 次!原因是文件被写入时,可能触发多次写操作,即使只保存了一次。但我们不需要这么敏感的回调,因为通常认为一次保存就是一次修改,系统底层写了几次文件我们并不关心。 因而可以进一步判断是否触发状态是 change: fs.watch(dir, (event, filename) => { if (filename && event === "change") { console.log(`${filename} file Changed`); }}); 这样做可以一定程度解决问题,但作者发现 Raspbian 系统不支持 rename 事件,如果归类为 change,会导致这样的判断毫无意义。 作者要表达的意思是,在不同平台下,fs.watch 的规则可能会不同,原因是 fs.watch 分别使用了各平台提供的 api,所以无法保证这些 api 实现规则的统一性。 优化方案一:对比文件修改时间基于 fs.watch,增加了对修改时间的判断: let previousMTime = new Date(0);fs.watch(dir, (event, filename) => { if (filename) { const stats = fs.statSync(filename); if (stats.mtime.valueOf() === previousMTime.valueOf()) { return; } previousMTime = stats.mtime; console.log(`${filename} file Changed`); }}); log 由 4 个变成了 3 个,但依然存在问题。我们认为文件内容变化才算有修改,但操作系统考虑的因素更多,所以我们再尝试对比文件内容是否变化。 笔者补充:另外一些开源编辑器可能先清空文件再写入,也会影响到触发回调的次数。 优化方案二:校验文件 md5只有文件内容变化了,才认为触发了改动,这下总可以了吧: let md5Previous = null;fs.watch(dir, (event, filename) => { if (filename) { const md5Current = md5(fs.readFileSync(buttonPressesLogFile)); if (md5Current === md5Previous) { return; } md5Previous = md5Current; console.log(`${filename} file Changed`); }}); log 终于由 3 个变成了 2 个,为什么多出一个?可能的原因是,在文件保存过程中,系统可能会触发多个回调事件,也许存在中间态。 优化方案三:加入延迟机制我们尝试延迟 100 毫秒进行判断,也许能避开中间状态: let fsWait = false;fs.watch(dir, (event, filename) => { if (filename) { if (fsWait) return; fsWait = setTimeout(() => { fsWait = false; }, 100); console.log(`${filename} file Changed`); }}); 这下 log 变成一个了。很多 npm 包在这里使用了 debounce 函数控制触发频率,才将触发频率修正。 而且我们需要结合 md5 与延迟机制共同作用,才能得到相对精准的结果: let md5Previous = null;let fsWait = false;fs.watch(dir, (event, filename) => { if (filename) { if (fsWait) return; fsWait = setTimeout(() => { fsWait = false; }, 100); const md5Current = md5(fs.readFileSync(dir)); if (md5Current === md5Previous) { return; } md5Previous = md5Current; console.log(`${filename} file Changed`); }}); 3 精读作者讨论了一些实现文件夹监听的基本方式,可以看出,使用了各平台原生 API 的 fs.watch 并不那么靠谱,但这也我们监听文件的唯一手段,所以需要基于它进行一系列优化。 而实际场景中,还需要考虑区分文件夹与文件、软连接、读写权限等情况。 另外用在生产环境的库,也基本使用 50 到 100 毫秒解决重复触发的问题。 所以无论 chokidar 或 node-watch,都大量使用了文中提及的技巧,再加上对边界条件的处理,对软连接、权限等情况处理,将所有可能情况都考虑到,才能提供较为准确的回调。 比如判断文件写入操作是否完毕,也需要通过轮询的方式: function awaitWriteFinish() { // ...省略 fs.stat( fullPath, function(err, curStat) { // ...省略 if (prevStat && curStat.size != prevStat.size) { this._pendingWrites[path].lastChange = now; } if (now - this._pendingWrites[path].lastChange >= threshold) { delete this._pendingWrites[path]; awfEmit(null, curStat); } else { timeoutHandler = setTimeout( awaitWriteFinish.bind(this, curStat), this.options.awaitWriteFinish.pollInterval ); } }.bind(this) ); // ...省略} 可以看出,第三方 npm 库都采取不信任操作系统回调的方式,根据文件信息完全重写了判断逻辑。 可见,信任操作系统的回调,就无法抹平所有操作系统间的差异,唯有统一重写文件的 “写入”、“删除”、“修改” 等逻辑,才能保证在全平台的兼容性。 4 总结利用 nodejs 监听文件夹变化很容易,但提供准确的回调却很难,主要难在两点: 抹平操作系统间的差异,这需要在结合 fs.watch 的同时,增加一些额外校验机制与延时机制。 分清楚操作系统预期与用户预期,比如编辑器的额外操作、操作系统的多次读写都应该被忽略,用户的预期不会那么频繁,会忽略极小时间段内的连续触发。 另外还有兼容性、权限、软连接等其他因素要考虑,fs.watch 并不是一个开箱可用的工程级别 api。 5 更多讨论 讨论地址是:精读《如何利用 Nodejs 监听文件夹》 · Issue ##87 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《如何安全地使用 React context》","path":"/wiki/WebWeekly/前沿技术/《如何安全地使用 React context》.html","content":"当前期刊数: 17 精读《如何安全地使用 React context》本期精读文章是:How to safely use React context 1 引言在 React 源码中,context 始终存在,却在 React 0.14 的官方文档中才有所体现。在目前最新的官方文档中,仍不建议使用 context,也表明 context 是一个实验性的 API,在未来 React 版本中可能被更改。那么哪些场景下需要用到 context,而哪些情况下应该避免使用,context 又有什么坑呢?让我们一起来讨论一下。 2 内容概要React context 可以把数据直接传递给组件树的底层组件,而无需中间组件的参与。Redux 作者 Dan Abramov 为 contenxt 的使用总结了一些注意事项: 如果你是一个库的作者,需要将信息传递给深层次组件时,context 在一些情况下可能无法更新成功。 如果是界面主题、本地化信息,context 被应用于不易改变的全局变量,可以提供一个高阶组件,以便在 API 更新时只需修改一处。 如果库需要你使用 context,请它提供高阶组件给你。 正如 Dan 第一条所述,在 React issue 中,经常能找到 React.PureComponent、shouldComponentUpdate 与包含 Context 的库结合后引发的一些问题。原因在于 shouldComponentUpdate 会切断子树的 rerender,当 state 或 props 没有发生变化时,可能意外中断上层 context 传播。也就是当 shouldComponentUpdate 返回 false 时,context 的变化是无法被底层所感知的。 因此,我们认为 context 应该是不变的,在构造时只接受 context 一次,使用 context,应类似于依赖注入系统来进行。结合精读文章的示例总结一下思路,不变的 context 中包含可变的元素,元素的变化触发自身的监听器实现底层组件的更新,从而绕过 shouldComponentUpdate。 最后作者提出了 Mobx 下的两种解决方案。context 中的可变元素可用 observable 来实现,从而避免上述事件监听器编写,因为 observable 会帮你完成元素改变后的响应。当然 Provider + inject 也可以完成,具体可参考精读文章中的代码。 3 精读本次提出独到观点的同学有:@monkingxue @alcat2008 @ascoders,精读由此归纳。 context 的使用场景 In some cases, you want to pass data through the component tree without having to pass the props down manually at every level. context 的本质在于为组件树提供一种跨层级通信的能力,原本在 React 只能通过 props 逐层传递数据,而 context 打破了这一层束缚。 context 虽然不被建议使用,但在一些流行库中却非常常见,例如:react-redux、react-router。究其原因,我认为是单一顶层与多样底层间不是单纯父子关系的结果。例如:react-redux 中的 Provider,react-router 中的 Router,均在顶层控制 store 信息与路由信息。而对于 Connect 与 Route 而言,它们在 view 中的层级是多样化的,通过 context 获取顶层 Provider 与 Router 中的相关信息再合适不过。 context 的坑 context 相当于一个全局变量,难以追溯数据源,很难找到是在哪个地方中对 context 进行了更新。 组件中依赖 context,会使组件耦合度提高,既不利于组件复用,也不利于组件测试。 当 props 改变或是 setState 被调用,getChildContext 也会被调用,生成新的 context,但 shouldComponentUpdate 返回的 false 会 block 住 context,导致没有更新,这也是精读文章的重点内容。 4 总结正如精读文章开头所说,context 是一个非常强大的,具有很多免责声明的特性,就像伊甸园中的禁果。的确,引入全局变量似乎是应用混乱的开始,而 context 与 props/state 相比也实属异类。在业务代码中,我们应抵制使用 context,而在框架和库中可结合场景适当使用,相信 context 也并非洪水猛兽。 讨论地址是:精读《How to safely use React context》· Issue ##23 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布"},{"title":"《如何比较 Object 对象》","path":"/wiki/WebWeekly/前沿技术/《如何比较 Object 对象》.html","content":"当前期刊数: 157 1 引言Object 类型的比较是非常重要的基础知识,通过 How to Compare Objects in JavaScript 这篇文章,我们可以学到四种对比方法:引用对比、手动对比、浅对比、深对比。 2 简介引用对比下面三种对比方式用于 Object,皆在引用相同是才返回 true: === == Object.is() const hero1 = { name: "Batman",};const hero2 = { name: "Batman",};hero1 === hero1; // => truehero1 === hero2; // => falsehero1 == hero1; // => truehero1 == hero2; // => falseObject.is(hero1, hero1); // => trueObject.is(hero1, hero2); // => false 手动对比写一个自定义函数,按照对象内容做自定义对比也是一种方案: function isHeroEqual(object1, object2) { return object1.name === object2.name;}const hero1 = { name: "Batman",};const hero2 = { name: "Batman",};const hero3 = { name: "Joker",};isHeroEqual(hero1, hero2); // => trueisHeroEqual(hero1, hero3); // => false 如果要对比的对象 key 不多,或者在特殊业务场景需要时,这种手动对比方法其实还是蛮实用的。 但这种方案不够自动化,所以才有了浅对比。 浅对比浅对比函数写法有很多,不过其效果都是标准的,下面给出了一种写法: 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 下有特殊的意义。 下面给出了使用例子: const hero1 = { name: "Batman", realName: "Bruce Wayne",};const hero2 = { name: "Batman", realName: "Bruce Wayne",};const hero3 = { name: "Joker",};shallowEqual(hero1, hero2); // => trueshallowEqual(hero1, hero3); // => false 如果对象层级再多一层,浅对比就无效了,此时需要使用深对比。 深对比深对比就是递归对比对象所有简单对象值,遇到复杂对象就逐个 key 进行对比,以此类推。 下面是一种实现方式: 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] 也可作用于数组,因此数组和对象都可以采用相同方式处理。 有了深对比,再也不用担心复杂对象的比较了: const hero1 = { name: "Batman", address: { city: "Gotham", },};const hero2 = { name: "Batman", address: { city: "Gotham", },};deepEqual(hero1, hero2); // => true 但深对比会造成性能损耗,不要小看递归的作用,在对象树复杂时,深对比甚至会导致严重的性能问题。 3 精读常见的引用对比引用对比是最常用的,一般在做 props 比较时,只允许使用引用对比: this.props.style !== nextProps.style; 如果看到有深对比的地方,一般就要有所警觉,这里是真的需要深对比吗?是不是其他地方写法有问题导致的。 比如在某处看到这样的代码: deepEqual(this.props.style, nextProps.style); 可能是父组件一处随意拼写导致的: const Parent = () => { return <Child style={{ color: "red" }} />;}; 一个只解决局部问题的同学可能会采用 deepEqual,OK 这样也能解决问题,但一个有全局感的同学会这样解决问题: this.props.style === nextProps.style; const Parent = () => { const style = useMemo(() => ({ color: "red" }), []); return <Child style={style} />;}; 从性能上来看,Parent 定义的 style 只会执行一次且下次渲染几乎没有对比损耗(依赖为空数组),子组件引用对比性能最佳,这样的组合一定优于 deepEqual 的例子。 常见的浅对比浅对比也在判断组件是否重渲染时很常用: shouldComponentUpdate(nextProps) { return !shallowEqual(this.props, nextProps)} 原因是 this.props 这个对象引用的变化在逻辑上是无需关心的,因为应用只会使用到 this.props[key] 这一层级,再考虑到 React 组件生态下,Immutable 的上下文保证了任何对象子属性变化一定导致对象整体引用变化,可以放心的进行浅对比。 最少见的就是手动对比和深对比,如果你看到一段代码中使用了深对比,大概率这段代码可以被优化为浅对比。 4 总结虽然今天总结了 4 种比较 Object 对象的方式,但在实际项目中,应该尽可能使用引用对比,其次是浅对比和手动对比,最坏的情况是使用深对比。 讨论地址是:精读《如何比较 Object 对象》· Issue ##258 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《如何在 nodejs 使用环境变量》","path":"/wiki/WebWeekly/前沿技术/《如何在 nodejs 使用环境变量》.html","content":"当前期刊数: 60 1 引言本期精读的文章是:如何在 nodejs 使用环境变量。 介绍了开发与生产环境如何管理环境变量。 这里环境变量指的是数据库密码等重要数据,而不是指普通变量传参。 2 概述环境变量历史悠久,在运行第一行 JAVA 代码之前,你就得将环境变量设置好。 可问题是,系统变量并不易用,比如结尾是否要使用分号,JAVA_HOME 与 PATH 在哪些程序中功能相同?而且与操作系统绑定,在操作系统级别设置的变量,给 JAVA 级别的程序用还好,但用来存数据库密码就不合适了。 在 Node 中,我们怎样使用环境变量呢?作者给出了如下的建议: 通过命令行传递PORT=65534 node bin/www 这是最基本、最常用的方式,可是当变量数量过多,不免觉得很崩溃: PORT=65534 DB_CONN="mongodb://react-cosmos-db:swQOhAsVjfHx3Q9VXh29T9U8xQNVGQ78lEQaL6yMNq3rOSA1WhUXHTOcmDf38Q8rg14NHtQLcUuMA==@react-cosmos-db.documents.azure.com:19373/?ssl=true&replicaSet=globaldb" SECRET_KEY=b6264fca-8adf-457f-a94f-5a4b0d1ca2b9 node bin/www 作者提到,这种代码没有拓展性。作者认为,对工程师来说,可拓展性甚至比能正确运行更为重要。 使用 .env 文件很显然,命令行写不下了就写到文件里: PORT=65534DB_CONN="mongodb://react-cosmos-db:swQOhAsVjfHx3Q9VXh29T9U8xQNVGQ78lEQaL6yMNq3rOSA1WhUXHTOcmDf38Q8rg14NHtQLcUuMA==@react-cosmos-db.documents.azure.com:10255/?ssl=true&replicaSet=globaldb"SECRET_KEY="b6264fca-8adf-457f-a94f-5a4b0d1ca2b9" 通过 dotenv 这个 npm 包可以读取 .env 文件的配置到 Nodejs 程序中。 npm install dotenv --save 安装后,直接调用它解析,就可以从环境变量中拿到 .env 文件的配置信息了: require("dotenv").config();var MongoClient = require("mongodb").MongoClient;// Reference .env vars off of the process.env objectMongoClient.connect( process.env.DB_CONN, function(err, db) { if (!err) { console.log("We are connected"); } }); 这有个问题,不要将配置文件发送到 Git 仓库,可能会泄漏隐私数据。然而 VSCode 帮你解决了这个问题(什么,你不用 VSCode?) VSCode 启动配置 VSCode 可以配置 Node 启动配置,在这里可以设置环境变量: 为了和 .env 文件打通,我们可以在配置里设置 envFile 属性: { "envFile": "${workspaceFolder}/.env"} 程序中依然使用 dotenv 读取环境变量。这么做将配置保留在 VSCode 中,而不是代码中,不用再担心不小心上传了配置文件啦! 使用 Npm Scripts作者推荐了一个良好的习惯:使用 npm start 运行项目,而不是暴露出 Node 命令。那么首先在 VSCode launch.json 中配置 Npm 模式: 记住,需要给 Node 脚本添加 --inspect 参数,才能触发 VSCode debugger 的钩子: 这样一来,通过 npm start 就可以启动 Node,并读取配置在 VSCode 的环境变量。 生产环境的环境变量上面介绍了本地开发如何使用环境变量,但在生产环境,环境变量必须得换个方式管理。 不知道作者与微软是什么关系,这块推荐了微软的 Azure 管理环境变量。 主要思路是通过一个不赚差价的中间商提供环境变量管理服务。通过 Azure CLI 启动你的 Node 项目,就可以从云服务平台拿到环境变量信息。 3 精读环境变量管理是非常重要的问题,以前还看到将公司数据库密码提交到 Github 的例子,反面教材非常多。 本文介绍了许多本地开发使用环境变量的方式,笔者补充一下生产环境使用环境变量的经验。 私有部署如果你在一个高自动化运维水平的公司,这个问题已经被私有 Git + 私有云服务器天然解决了。 是的,部署私有 Git,把数据库密码提交到 Git 仓库才是最完美的方案! 持久化配置服务通过自建,或者开源的 Azure 持久化配置服务存储环境变量,在服务器利用 SDK 获取它。 一般云服务商都会打包这项服务,因为只有服务器和持久化配置服务都由一个供应商提供,供应商才能将持久化配置与服务器权限形成关联,让第三方服务器即便拿到 Token 也无法访问配置。 加密服务如果安全级别特别高,内部 Git 都不允许提交,又要防止第三方(比如某宽带运营商)拦截到信息,就要使用加密服务了。 流程一般是: 在加密平台注册,拿到密钥。 在加密平台设置环境变量,加密平台会对内容进行加密。 利用 Node SDK 获取到加密平台输出的密文。 利用 SDK 和密钥解密成明文。 4 总结对待在基础设施完备公司的同学,可能不需要关心环境变量安全性问题。对于自己搭建博客,或者使用第三方服务器的同学,这篇文章告诉我们三个注意点: 不要将重要环境变量提交到公开的 Git 仓库。 本地通过 VSCode 调试环境变量既方便又安全。 生产环境通过云服务商提供的环境变量配置服务拿到环境变量。 5 更多讨论 讨论地址是:精读《如何在 nodejs 使用环境变量》 · Issue ##89 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《如何编译前端项目与组件》","path":"/wiki/WebWeekly/前沿技术/《如何编译前端项目与组件》.html","content":"当前期刊数: 89 1 引言说到前端编译方案,也就是如何打包项目,如何编译组件,可选方案有很多,比如: 通过 webpack / parcel / gulp 构建项目。 通过 parcel / gulp / babel 构建组件。 如果你喜欢零配置的 parcel,那么项目和组件都可以拿它来编译。 如果你业务比较复杂,需要使用 webpack 做深度定制,那么常见组合是:项目 - webpack,组件 - gulp。 但项目与组件的编译存在异同点,不同构建工具支持的生态也存在异同点。 webpack parcel gulp 生态的区别 babel 一般不会解析模块,也就是一般仅做代码预处理,而不会改变文件结构,也对 require、import 语句不敏感。 webpack / parcel 主要就是解决模块化打包问题,因为浏览器还不支持(现在部分支持 type="module")。 gulp 理论上可以将 babel、webpack、parcel 作为插件,但这是后来的事。历史上由于 gulp 是作为 grunt 的替代品出现,当时要解决的问题是处理浏览器兼容问题,打包 scss 或 less,做一些公共资源替换,雪碧图等,最后可以顺带合并到一个文件,但模块化功能远远比 webpack 弱,基本上只能合并,但不能 “理解模块概念”。 项目构建与组件构建的区别项目构建的目的主要在于发布 CDN,所以大家一般不在乎构建脚本的通用性。换句话说,无论项目使用了怎样的构建方式,怎样理解 import 语句,甚至写出 require.context 等自定义语法,只要最终编译出符合浏览器规范的代码(考虑到兼容性)就足够。 组件构建的目的主要在于发布 NPM,除了 ESNext 规范会使用 Babel 编译成 ES3,大部分代码写的很收敛,甚至对 SASS 的使用都要与 Typescript 插件一起组合成复杂的 Gulp Task。 所以往往大家会对项目采取复杂的构建约束策略,而对组件的编译采取相对简单的办法,确保发布代码的通用性。 所以在大部分项目使用 webpack 支持 worker-loader 时,编写组件时发现这段代码不灵了。或者至少你得付出一些代价,因为组件的调试依然可以利用 webpack-dev-server,这时可以加上 worker-loader,但由于 gulp 没有靠谱的 worker 插件,你的组件可能需要将 Worker 引用部分原样输出,希望由引用它的项目做掉对 worker-loader 的支持。 其实这种心态是很危险的,不仅导致了组件不通用,甚至引发了各构建工具的 Tree Shaking 优化。原因就是构建组件的代码太原始,冗余的代码没有删除,甚至直接引用的 SASS 代码仍然保留,更危险的是带上了一些特殊 webpack loader 才支持的语法。 之所以说 Antd 是一个拥有优秀基因的前端组件库,是因为他遵循了前端组件最基本的代码素养: 编译后的代码全部符合基本 JS 规范,换个角度来说,使用 webpack 内置基本 js loader 就能完全解析。 将 css 代码抽离出来,这样不会强制项目对 node_modules 的代码应用 css-loader。 所以一个 靠谱的组件库 的产出文件,应该符合基本 ES 模块化规范,且不包括任何特殊语法。 但是这引发了一个新的问题:组件开发体验比项目差很多。 比如组件想使用雪碧图自动优化、想使用 worker-loader 方便快捷的调用多线程,想用自己的 css modules,甚至想把项目里一堆 PostCSS 快捷语法搬过来时怎么办?难道组件开发就不能获得与项目开发一样的体验吗? 要解决这个问题,笔者介绍一种基于 webpack 的通用构建方案,让本地调试、CDN 打包、ES6 -> ES3 转换 都使用统一套配置代码,同一套 loader。 2 精读核心思想只有一句话:利用 webpack-node-externals 忽略 Webpack 对指向 node_modules 的 require 或 import 语句: 进行项目/组件调试时,开启 development 模式。 进行项目编译时,开启 production 模式。 进行组件编译时,开启 production 模式,且利用 webpack-node-externals 插件忽略 node_modules。 可以想像,根据第三条,如果所有组件都按照这个模式输出代码,那么 webpack 对 node_modules 编译时,只需要将所有 require 代码进行合并,不需要执行任何 loader,也不需要压缩,不需要 TreeShaking,因为这些在组件代码编译时全部已经做好了,这种构建效率几乎达到最大。 实际案例我们拿支持 typescript、sass、css-modules、worker-loader 的场景作为案例。 我们创建三个文件 entry.tsx entry.worker.ts 与 entry.scss: entry.scss: .container { border: 1px solid ##ccc;}.primary { color: blue; &:hover { color: green; }} entry.worker.ts: import hello from "hello";const ctx: Worker = self as any;ctx.onmessage = event => { ctx.postMessage(hello());};export default null as any; entry.tsx: import * as React from "react";import styles from "./entry.scss";import * as MyWorker from "./parser.worker";const worker = new MyWorker();export default () => ( <div className={styles.container}> <button className={styles.primary}>Click Me.</button> </div>); 在上面三个文件中,我们分别利用了 Typescript 编译、SCSS 编译、css-modules 解析、worker-loader 解析(利用 webpack 自动生成字符串代码并利用 Blob URL 方式载入,这样就不需要创建新文件也可以用 worker 了,也不会存在跨域问题)。 为了支持这几个特性对如上代码做调试、项目发布、组件发布,我们分别看下这三个场景该如何配置编译脚本。 本地调试本地调试是不用区分组件与项目的。因为无论何种情况,都需要进行基本的项目编译,载入所有自定义 loader 并打成一个 bundle 包。 此时我们只要维护一份 webpack 配置即可: const webpackConfig = { mode: "development", module: { rules: [ { test: /\\.worker\\.tsx?$/, use: { loader: "worker-loader", options: { inline: true } }, include: path.join(projectRootPath, "src") }, { test: /\\.tsx?$/, use: [ [ "babel-loader", { plugins: [ [ "babel-plugin-react-css-modules", { filetypes: { ".scss": { syntax: "postcss-scss" } } } ] ] } ], "ts-loader" ], include: path.join(projectRootPath, "src") }, { test: /\\.scss$/, use: [ "style-loader", [ "css-loader", { importLoaders: 1, modules: true } ], "sass-loader" ], include: path.join(projectRootPath, "src") } ] }};export default webpackConfig; 利用这个配置加上 webpack-dev-server 即可完成组件与项目的本地调试。 项目发布项目发布时,需要将所有代码打入到一个 bundle 包,此时只需使用 webpack-cli 即可,对配置做如下修改: export default { ...webpackConfig, mode: "production"}; 组件发布组件发布时,依然使用 webpack-cli 构建,但利用 webpack-node-externals 忽略对 node_modules 的解析。 import * as nodeExternals from "webpack-node-externals";export default { ...webpackConfig, mode: "production", externals: [nodeExternals()]}; 此时编译的组件代码,包含了 Typescript 编译、SCSS 编译、css-modules 解析、worker-loader 解析,但所有 node_modules 代码都保持原样,比如下面的代码: 做了代码去重、按需加载、打包、压缩,但因为保持了 require 原样,因此大小只有源码体积。 同时上述三个场景都在复用 webpack 一套代码的基础上,利用了 webpack 的生态,因此维护性和拓展性都很强。后续再加入新功能,再也不需要到处找 babel 或 gulp 的插件了! 3 总结本文从 webpack 为切入点,但其实还可以从 parcel 或 gulp 为切入点,实现前端项目、组件构建体系的统一。 不过从可定制性来看,webpack 插件生态更完善,所以笔者选择了 webpack。 留下一个思考题:你的项目、组件是如何构建的呢?是用了一套代码,还是两套呢? 讨论地址是:精读《如何编译前端项目与组件》 · Issue ##125 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《对 Markdown 的思考》","path":"/wiki/WebWeekly/前沿技术/《对 Markdown 的思考》.html","content":"当前期刊数: 230 Markdown 即便在 2022 年也非常常用,比如这篇文章依然采用 Markdown 编写。 但 Markdown 是否应该成为文本编辑领域的默认技术选型呢?答案是否定的。我找到了一篇批判无脑使用 Markdown 作为技术选型的好文 Thoughts On Markdown,它提到 Markdown 在标准化、结构化、组件化都存在硬伤,如果你真的想做一个现代化的文本结构编辑器,不要采用 Markdown。 概述Markdown 流传甚广,甚至已成为我们的第二语言。Markdown 最早的解析器由 John Gruber 在 2004 年基于 Perl 编写发布,那时候 Markdown 只有一个目的,即为了方便网络写作。 网络写作必须基于 HTML 规范,而 HTML 规范对大部分人上手成本太高,因此 Markdown 就是基于文本创建的更易理解,或者说上手成本更低,甚至傻瓜化的一种语法,而要解析这个语法需要配套一个解析器,将这种语法文本最终转化为 HTML。 而数字化发展到今天,Markdown 已不再适合当下的写作场景了,主要原因有二: Markdown 不再适合当下富交互、内容形态的编写。 Markdown 纯文本的开发体验不再满足当代开发者日益提高的体验需求。 首先还是从 Markdown 思想开始介绍。 Markdown 的核心思想Markdown 最大优势就是好上手,不需要接触 HTML 这种复杂的嵌套语句(虽然对程序员来说 HTML 也简单到处于鄙视链底端)。原文抽象了三个优势: 基于文本的合适抽象。虽然 HTML 甚至代码都是文本,但 “合适” 这个词很重要,即任何文本都可以是 Markdown,只要加一点点小标记就能描述专业结构,学习成本极低。 有大量生态工具。比如语法解析器、高亮、格式转换、格式化、渲染等工具完备。 编辑内容便于维护。比如 Markdown 很方便作为源码存储,而其他格式的富文本可能并不方便在源码里维护。 如果把 Markdown 与数据库表结构做比较,那数据库的理解成本真是太高了。 但是在如今后端即服务的时代,数据库访问越来越轻松,甚至出现大量如 AirTable 等 SAAS 产品将结构化数据快速转化为应用,其实接触了这些后才真正发现,结构化数据对开发者有多重要。Markdown 用来写写文章还是不错的,但用来表达逻辑结构最后一定会引发灾难后果,原文作者的团队就深受 Markdown 技术选型的困扰,被迫解决大量远超预期的难题。 如果真的要在 Markdown 的坑越走越深,就必须使用语法拓展来满足自定义诉求。 Markdown 语法拓展最初 Markdown 语法是不支持表格的,如果想用 Markdown 绘制一张表格,只能使用原生 HTML 标签:<table></table>,当然,这也说明了 Markdown 本质就是给 HTML 加强了便捷的语法,我们完全可以将其当 HTML 使用。 然而并不是所有创作平台都支持 <table></table> 语法的,笔者自己就经常受到困扰,比如有些平台会屏蔽原生 HTML 语法,已保障所谓的 “安全性” 或者内容体验的 “一致性”,而这些平台为了弥补缺失的绘制表格能力,往往会支持一些自定义语法,更糟糕的是不支持,这就说到了 Markdown 的语法拓展。 Markdown 有哪些拓展呢?比如:multiMarkdown、commonMark、Github Flavored Markdown 等等。 这里随便举个例子,比如标准 MD 格式,其实第一行最后要加两个空格才能换行,但 GFM 取消了这个限制。这虽然更方便了,但暴露出平台间规范的不一致性,导致 Markdown 跨平台基本一定被坑。 而各平台拓展的语法,我们是否有足够的精力学习和记忆呢?先不说能不能记得下来,首先值不值得学习就是个问题,为什么一个网络写作平台需要占用写手学习与认知成本,而不是想办法去简化写作流程呢?所以语法拓展看似很美好,但放在写手角度,或者整个互联网各平台林立的角度来看,这种非标准的做法一定不靠谱,没有用户觉得你的平台有资格 “教他语法”,除非你是微信,钉钉或者飞书。 原文提到的观点是: 作为写手,你不知道 Markdown 哪些语法可用,哪些语法不可用。 标准规范存在一些 模糊地带 导致开发者实现时也会遇到各种纠结。 原文还提到一个语法拓展导致理解成本增高的例子:slack 平台自定义的 mrkdown 就不支持 [link](https://slack.com) 方式描述链接,而使用了 <link|https://slack.com> 语法。 总结来说,Markdown 语法拓展本应该是件好事,但实际无标准导致了标准的百花齐放,使 Markdown 成为了实际上没有标准的状态,整体来看弊端更多。 Markdown 面向的用户群Markdown 的对自己的定位其实很不清晰,这也导致了一直不想确定标准化语法。 最初 Markdown 是服务给熟悉 HTML 的人提供的标记语言,而后来面向用户群实质上转向了开发者,因为开发者才会想到拓展语法以满足更复杂的使用场景,Markdown 原生语法无法适应越来越复杂的视觉展示形态。 如今 Markdown 的主要用户已经是开发人员与对代码感兴趣的人了,这倒不是说开发者有多喜欢它,而是在说 Markdown 的受众变窄了。如今任何一款面向非开发者群体的文档编辑器都不会采用 Markdown 了,而是所见即所得的 WYSIWYG(what you see is what you want)模式。 这个转变的过程是痛苦的,但现在来看,富文本编辑器不应用用 Markdown 语法,而是 WYSIWYG 模式已经是共识了。 从段落到区块、从文章到应用简单来说,即 Markdown 已经不适应当前 HTML 丰富的生态了,能轻松描述段落的标记语言,遇到富有交互的组件区块时,不得不引入例如 MDX 等方案,但这样的方案根本只适合程序员群体,完全无法移植。 网络浏览形态也从简单的文章发展到具有整体性的应用,这些应用拥有复杂的布局、样式与交互,如果你尝试基于 Markdown 拓展语法来支持,最后可能发现还不如直接用原生 HTML。 对结构化内容的诉求从编程角度理解就是 “组件复用”。Markdown 原生语法无法实现内容的复用,如果必须要复用内容,只能将其重复写在每一处,势必造成巨大同步成本。 比如 Jekyll 就提出了 FrontMatter 概念用来创建复用的变量: ---food: Pizza---<h1>{{ page.food }}</h1> WYSIWYG 编辑器不应将 HTML 作为底层数据结构虽然浏览器真正将 HTML 作为底层数据结构,但这并不代表所见即所得的编辑器也可以如此,这也是为什么浏览器只能提供从源码到 UI 的输出,而不能提供从 UI 编辑到源码的反向输入。 因为用户的输入与 HTML 并不是一一对应关系,其中存在大量模糊地带,比如当前光标处在粗体与细体文字中间,那下一个输入到底算加粗还是不加粗呢?从 UI 上看不到加粗标签。再有,如果 HTML 存在冗余,其实当前光标所在位置已经被加粗标签包裹了好几层,但因为光标所在区域又被另一个样式标签覆盖成非加粗模式,当再次输入时可能就跳出了覆盖范围,重新变成了加粗,这个过程符合用户预期吗?从技术上,这种复杂标签结构也几乎无法被处理,因为组合花样实在太多。 现代大多数编辑器都以 JSON 格式存储数据结构,就因为其结构化且易于检索。 结构化最重要的体现是,其生成的 HTML 结构可以是稳定的,即对于一个既加粗又标红的文字,一定包裹在一个 <strong style="color: red"> 标签里,而不是 <strong><div style="color: red">,也就是这种模式根本没把 HTML 作为结构化数据去看待,自然就不会出现歧义。 Markdown 也是一样,其本身也会出现类似 HTML 标签的二义性,不适合作为底层数据结构存储。 精读批判 Markdown 的文章不多见,笔者也是看了之后才恍然发现 Markdown 竟然有这么多缺点。笔者结合自己的经验谈谈 Markdown 的缺点吧。 不支持富交互的无奈Markdown 仅能支持简单的 HTML 结构,而无法描述逻辑区块。Github 上大部分 Readme 都采用图片来实现这些功能,包括状态卡片、构建结果、个人信息名片等,可惜交互能力还是太弱,我觉得有朝一日 Github 应该会推出比如 Block 小区块的概念,让这些区块可以直接插入 Markdown 成为一个可交互的元素。 MDX 解决了 Markdown 的痛点吗?看似完美兼容 JSX 与 Markdown 的 MDX 曾经也是笔者写作的救命稻草,但该方案移植性是一大痛点,组件只能在自己部署的网站用,如果你想把文章发布到另一个平台,完全不可能。 这还仅是笔者的视角,如果从 Markdown 生态来看,MDX 面向用户仅是程序员群体,根本没有解决其使命 “方便网络写作”,而程序员最终也会抛弃 MDX 而转向开发所见即所得编辑器解决问题。 Markdown 到 HTML 的转换存在逻辑问题Markdown 本质上还是一种脱离 HTML 的文本表示结构,看上去解耦很优雅,实际上会遇到不少不一致的问题。 比如说连续敲击多个空格会出现什么情况呢?在 Markdown 会变成一个引用区块,那如何才能展示多个空格呢?谁也不知道,可能需要查阅具体平台提供的额外语法才可以做到。 这种大体上用起来方便,但细节无法定制,甚至用户无法控制的情况会大大伤害已经深度使用 Markdown 的用户,此时用户要么硬着头皮发明新语法解决这些漏洞,要么就完全放弃 Markdown 了。 结构化能力不足看上去 Markdown 的语法挺具有结构化的,但实际上 Markdown 的结构化不具有强约束力。 拿 JSON 作对比,比如我们可以用 JSON 拓展出 https://json-schema.org/ 结构,这个结构甚至可以反推出一个完整的表单应用,其原因是 JSON 可以针对每一个 Key、层级下定义,首先有结构,其次才有内容。 而 Markdown 正好反过来,是先有内容,再有结构。比如我们可以在 Markdown 任何地方写任何 HTML 标签,或者任意段落的问题,这些内容是无法被序列化的,即便我们按照浏览器解析 HTML 的规则解析成 JSON,也无法从中方便的提取信息。 背后的根本原因是,Markdown 本身定位就是 “近乎于 UI 渲染结果” 的,而实际上浏览器渲染 UI 背后是需要一套严谨的 HTML 语法,因为 UI 与背后语法并不能一一建立映射,一个稳定的渲染逻辑只能是从源码推导到渲染,而不能从渲染反推出源码。Markdown 本身定位就近乎于渲染结果,所以结构化能力不足是天然的问题。 总结记得语雀早期内部试用时,编辑态还是采用 Markdown 的,但后来很快就把 Markdown 的编辑入口下掉了,这件事还引发了不少开发者的不满,甚至还有一些 Markdown 编辑的插件被开发出来,一度很受欢迎。但渐渐的我们都习惯用所见即所得方式编辑了,Markdown 唯一留给我们的印象就是快捷键,比如 #### 后敲入空格可以生成 h3 标题段落,而语雀编辑器也在富交互组件区块上越走越远,要是当年被 Markdown 锁定住了技术,也不可能有今天这么高级的编辑体验。 所以技术前瞻性真的很重要,Markdown 所有程序员都爱,但提前看到它在当前互联网发展阶段的局限性,并设计一套结构化数据代替 Markdown 结构不是所有人都能想到的,我们需要以动态的眼光看待技术,也要放下技术人的偏见,把偏爱让位于产品定位。 讨论地址是:精读《对 Markdown 的思考》· Issue ##397 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《对低代码搭建的理解》","path":"/wiki/WebWeekly/前沿技术/《对低代码搭建的理解》.html","content":"当前期刊数: 159 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《对前端架构的理解 - 分层与抽象》","path":"/wiki/WebWeekly/前沿技术/《对前端架构的理解 - 分层与抽象》.html","content":"当前期刊数: 254 可能一些同学会认为前端比较简单而不需要架构,或者因为前端交互细节杂而乱难以统一抽象,所以没办法进行架构设计。这个理解是片面的,虽然一些前端项目是没有仔细考虑架构就堆起来的,但这不代表不需要架构设计。任何业务程序都可以通过代码堆砌的方式实现功能,但背后的可维护性、可拓展性自然也就千差万别了。 为什么前端项目也要考虑架构设计?有如下几点原因: 从必要性看,前后端应用都跑在计算机上,计算机从硬件到操作系统,再到上层库都是有清晰架构设计与分层的,应用程序作为最上层的一环也是嵌入在整个大架构图里的。 从可行性看,交互虽然多而杂,但这不构成不需要架构设计的理由。对计算机基础设计来说,也面临着多种多样的输入设备与输出设备,进而产生的标准输入输出的抽象,那么前端也应当如此。 从广义角度看,大部分通用的约定与模型早已沉淀下来了,如编程语言,前端框架本身就是业务架构的一部分,用 React 哪怕写个 “Hello World” 也使用了数据驱动的设计理念。 从必要性看,虽然操作系统和各类基础库屏蔽了底层实现,让业务可以仅关心业务逻辑,大大解放了生产力,但一款应用必然是底层操作系统与业务层代码协同才能运行的,从应用程序往下有一套逻辑井然的架构分层设计,如果到了业务层没有很好的架构设计,技术抽象是一团乱麻,很难想象这样形成的整体运行环境是健康的。 业务模块的架构设计应当类似计算机基础的架构设计,从需求分析出发,设计有哪些业务子模块,并定义这些子模块的职责与子模块之间的关系。子模块的设计取决于业务的特性,子模块间的分层取决于业务的拓展能力。 比如一个绘图软件设计时只要需要组件子系统与布局子系统,它们之间互相独立,也能无缝结合。对于 BI 软件来说,就增加了筛选联动与通用数据查询的概念,因此对应的也会增加筛选联动模型、数据模型、图形语法这几个子模块,并按照其作用关系上下分层: 如果分层清晰而准确,可以看出这两个业务上层具有相同的抽象,即最上层都是组件与布局的结合,而筛选联动与数据查询,以及从数据模型映射到图元关系的映射功能都属于附加项,这些项移除了也不影响系统的运行。如果不这么设计,可能就理不清系统之间的相似点与差异点,导致功能耦合,要维护一个大系统可能要时刻关系各模块之间的相互影响,这样的系统即不清晰,也不够可拓展,关键是要维护它的理解成本也高。 从可行性看,前端的特点在于用户输入的触点非常多,但这不妨碍我们抽象标准输入接口,比如用户点击按钮或者输入框是输入,那键盘快捷键也是一种输入方式,URL 参数也是一种输入方式,在业务前置的表单配置也是一种输入方式,如果输入方式很多,对标准输入的抽象就变得重要,使业务代码的实际复杂度不至于真的膨胀到用户使用的复杂度那么高。 不止输入触点多,前端系统的功能组合也非常多,比如图形绘制软件,画布可以放任意数量的组件,每个组件有任意多的配置,组件之间还可以相互影响。这种系统属于开放式系统,用户很容易试出开发者都未曾想到过的功能组合,有些时候开发者都惊叹这些新的组合竟然能一起工作!用户会感叹软件能力的强大,但开发者不能真的把这些功能组合一一尝试来解决冲突,必须通过合理的分层抽象来保证功能组合的稳定性。 其实这种挑战也是计算机面临的问题,如何设计一个通用架构的计算机,使上面可以运行任何开发者软件,且软件之间可以相互独立,也可以相互调用,系统还不容易产生 BUG。从这个角度来看,计算机的底层架构设计对前端架构设计是有参考意义的,大体上来说,计算机通过硬件、操作系统、软件这个三个分层解决了要计算一切的难题。 冯·诺依曼体系就解决了硬件层面的问题。为了保证软件层的可拓展性,通过 CPU、存储、输入输出设备的抽象解决了计算、存储、拓展的三个基本能力。再细分来看,CPU 也仅仅支持了三个基本能力:数学计算、条件控制、子函数。这使得计算机底层设计既是稳定的,设计因素也是可枚举的,同时拥有了强大的拓展能力。 操作系统也一样,它不需要知道软件具体是怎么执行的,只需要给软件提供一个安全的运行环境,使软件不会受到其他软件的干扰;提供一些基本范式统一软件的行为,比如多窗口系统,防止软件同时在一块区域绘图而相互影响;提供一些基础的系统调用封装给上层的语言进行二次封装,而考虑到这些系统调用封装可能会随着需求而拓展,进而采用动态链接库的方式实现,等等。操作系统为了让自身功能稳定与可枚举,对自己与软件定义了清晰的边界,无论软件怎么拓展,操作系统不需要拓展。 回到前端业务,想要保障一个复杂的绘图软件代码清晰与好的可维护性,一样需要从最底层稳定的模块开始网上,一步步构建模块间依赖关系,只有这样,模块内逻辑才能可枚举,模块与模块间才敢大胆的组合,各自设计各自的拓展点,使整个系统最终拥有强大的拓展能力,但细看每个子模块又都是简单清晰、可枚举可测试的代码逻辑。 以 BI 系统举例,划分为组件、筛选、布局、数据模型四个子系统的话: 对组件系统来说,任何组件实现都可接入,这就使这个 BI 系统不仅可以展示报表,也可以展示普通的按钮,甚至表单,可以搭建任意数据产品,或者可以搭建任意的网站,能力拓展到哪完全由业务决定。 对筛选系统来说,任何组件之间都能关联,不一定是筛选器到图表,也可以是图表到图表,这样就支持了图表联动。不仅是 BI 联动场景,即便是做一个表单联动都可以复用这个筛选能力,使整个系统实现统一而简单。 对布局系统来说,不关心布局内的组件是什么,有哪些关联能力,只要做好布局就行。这样画布系统容易拓展为任何场景,比如生产效率工具、仪表盘、ppt 或者大屏,而对其他系统无影响。 对数据模型系统来说,其承担了数据配置到 sql 查询,最后映射到图形通道展示的过程,它本身是对组件系统中,统计图表类型的抽象实现,因此虽然逻辑复杂,但也不影响其他子系统的设计。 从广义角度看,前端业务代码早就处于一系列架构分层中,也就是编程语言与前端框架。编程语言与前端框架会自带一些设计模式,以减少混用代码范式带来的沟通成本,其实架构设计本身也要解决代码一致性问题,所以这些内容都是架构设计的一环。 前端框架带来的数据驱动特性本身就很大程度上解决了前端代码在复杂应用下可维护问题,大大降低了过程代码带来的复杂度。React 或 Vue 框架本身也起到了类似操作系统的操作,即定义上层组件(软件规格)的规格,为组件渲染和事件响应抹平浏览器差异(硬件差异),并提供组件渲染调度功能(软件调度)。同时也提供了组件间变量传递(进程通信),让组件与组件间通信符合统一的接口。 但是没有必要把每个组件都类比到进程来设计,也就是说,组件与组件之间不用都通过通信方式工作。比较合适的类比粒度是模块,把一个大模块抽象为组件,模块与模块间互相不依赖,用数据通信来交流。小粒度组件就做成状态无关的元件,注意相似功能的组件接口尽量保持一致,这样就能体验到类似多态的好处。 所以话说回来,遵循前端框架的代码规范不是一件可有可无的事情,业务架构设计从编程语言和前端框架时就已经开始了,如果一个组件不遵循框架的最佳实践,就无法参与到更上层的业务架构规划里,最终可能导致项目混乱,或者无架构可言。所以重视架构设计从代码规范就要开始。 所以前端架构设计是必要的,那怎么做好前端架构设计呢?这个话题太过于庞大,本次就从操作系统借鉴一些灵感,先谈一谈对分层与抽象的理解。 没有绝对的分层分层是架构设计的重点,但一个模块在分层的位置可能会随着业务迭代而变化,类比到操作系统举两个例子: 语音输入现在由各个软件自行提供,背后的语音识别与 NLP 能力可能来自各大公司的 AI 中台,或者一些提供 AI 能力的云服务。但语音输入能力成熟后,很可能会成为操作系统内置能力,因为语音输入与键盘输入都属于标准输入,只是语音输入难度更大,操作系统短期难以内置,所以目前发展在各个上层应用里。 Go 语言的协程实现在编程语言层,但其对标的线程实现在操作系统层,协程运行在用户态,而线程运行在内核态。但如果哪天操作系统提供了更高效的线程,内存占用也采用动态递增的逻辑,说不定协程就不那么必要了。 按理说语音输入属于标准输入的一部分,应该实现在操作系统的通用输入层,协程也属于多任务处理的一部分,应该实现在操作系统多任务处理层,但它们都被是现在了更上层,有的在编程语言层,有的在业务服务层。之所以产生了这些意外,是因为通用输入输出层与多任务处理层的需求并没有想象中那么稳定,随着技术的迭代,需要对其拓展时,因为内置在底层不方便拓展,只能在更上层实现了。 当然我们也要注意到的是,即便这些拓展点实现在更上层,但对软件工程师来说并没有特别大的侵入性影响,比如 goroutine,程序员并不接触操作系统提供的 API,所以编程语言层对操作系统能力的拓展对程序员是透明的;语音输入就有一点影响了,如果由操作系统来实现,可能就变成与键盘输出保持一致的事件结构了,但由业务层实现就有无数种 API 格式了,业务流程可能也更加复杂,比如增加鉴权。 从计算机操作系统的例子我们可以学习到两点: 站在分层合理性视角对输入做进一步的抽象与整合。比如将语音识别封装到标准的输入事件,让其逻辑上成为标准输入层。 业务架构的设计必然也会遇到分层不满足业务拓展性的场景。 业务分层与硬件、操作系统不同的是,业务分层中,几乎所有层都方便修改与拓展,因此如果遇到分层不合理的设计,最好将其移动到应该归属的层。操作系统与硬件层不方便随意拓展的原因是版本更新的频率和软件更新的频率不匹配。 同时,也要意识到分层需要一个演进过程,等新模块稳定后再移动到其归属所在层可能更好,因为从上层挪到底层意味着更多被模块共享使用,就像我们不会轻易把软件层某个包提供的函数内置到编程语言一样,也不会随意把编程语言实现的函数内置到操作系统内置的系统调用。 在前端领域的一个例子是,如果一个搭建平台项目中已经有了一套组件元信息描述,最好先让其在业务代码里跑一段时间,观察一下元信息定义的属性哪些有缺失,哪些是不必要的,等业务稳定一段时间后,再把这套元信息运行时代码抽成一个通用包提供给本业务,甚至其他业务使用。但即便这个能力沉淀到了通用包,也不代表它就是永远不能被迭代的,操作系统的多任务管理都有协程来挑战,何况前端一个抽象包的能力呢?所以要慎重抽象,但抽象后也要敢于质疑挑战。 没有绝对的抽象抽象粒度永远是架构设计的难题。 计算机把一切都理解为数据。计算结果是数据,执行程序的代码也是数据,所以 CPU 只要专注于对数据的计算,再加上存储与输入输出,就可以完成一切工作。想一想这样抽象的伟大之处:所有程序最终对计算机来说都是这三个概念,CPU 在计算时无需关心任何业务含义,这也使得它可以计算任何业务。 另一个有争议的抽象是 Unix 一切皆文件的抽象,该抽象使文件、进程、线程、socket 等管理都抽象为文件的 API,且都拥有特定的 “文件路径”,比如你甚至可以通过 /proc 访问到进程文件夹,ls 可以看到所有运行的进程。当然进程不是文件,这只是说明了 Unix 的一种抽象哲学,即 “文件” 本身就是一种抽象,开发和可以用理解文件的方式理解一切事物,这带来了巨大的理解成本降低,也使许多代码模式可以不关心具体资源类型。但这样做的争议点在于,并不是一切资源都适合抽象成文件,比如输入输出中的显示器,它作为一个呈现五彩缤纷像素点的载体,实在难以用文件系统来统一描述。 计算机设计与操作系统设计已经给了我们很明显的启发,即一切能抽象的都要尽可能的抽象,如此才能提高系统各模块内的稳定性。但从如 Unix 一切皆文件的抽象来看,有时候的技术抽象难免被当时的业务需求所局限,当输入输出设备的种类增加后,这种极致的抽象未必能永远合适。但永远要相信抽象,因为假若所有资源都可以被文件抽象所描述,且使用起来没有不便捷的地方,为什么还要造其他的抽象概念呢?如无必要勿增实体。 比如 BI 场景的筛选、联动、下钻场景是否都能抽象为组件与组件间的联动关系呢?如果一套标准联动设计可以解决这三个场景,那自然不需要为某个具体场景单独引入概念。从原始场景来看,无论筛选、联动还是下钻场景都是修改组件的取数参数以改变查询条件,我们就可以抽象出一种组件间联动的规范,使其可以驱动取数参数的变化,但未来需求可能引入更多的可能性,如在筛选时触发一些额外的追加分析查询,此时之前的抽象就收到了挑战,我们需要权衡维持统一性的收益与通用接口不适用于特殊场景带来成本之间的平衡。 抽象的方式是无数的,哪种更好取决于业务如何变化,不用过于纠结完美的抽象,就连 Unix 一切皆文件的最基础抽象都备受争议,业务抽象的稳定性肯定会更差,也更需要随着需求变化而调整。 总结我们从计算机与操作系统的架构设计出发,探讨了前端架构设计的必要性,并从分层与抽象两个角度分析了架构设计时的考量,希望你在架构设计遇到拿捏不定的问题时,可以向下借助计算机的架构设计获得一些灵感或支持。 讨论地址是:精读《对前端架构的理解 - 分层与抽象》· Issue ##436 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《寻找框架设计的平衡点》","path":"/wiki/WebWeekly/前沿技术/《寻找框架设计的平衡点》.html","content":"当前期刊数: 133 1 引言尤雨溪 在 2019 JSConf 的分享 Seeking the Balance in Framework Design 十分精彩,道出了如何进行合理的前端框架设计与框架选型。 正如所说,框架对比不能只停留在 Star 数量、Npm 下载量、Stackoverflow 问题量这些简单的数据对比,而要深入到技术细节进行比较。比较框架有多种不同维度,这次分享就从服务范围、渲染机制、状态机制这三个维度进行对比。 2 概述这次分享的精彩之处在于不偏不倚的站在客观立场分析了框架各维度好的一面与坏的一面,从中我们不仅能学习到一些框架知识,还能培养思辨能力。 服务范围服务范围是个比较难翻译的单词,在原 PPT 中用了 “Scope” 这个单词表示,可以理解为 “作用域、框架的承诺功能范围、服务配套齐全程度”。比如提供的是一个工具库还是整体框架,插件管理是集中式还是依赖生态。 React 是典型的小服务范围框架,核心包只实现了基本功能,而其他生态基本靠社区拓展;Angular 是典型大服务范围框架,官方对所有业务场景都做了最佳实践能力覆盖;Vue 处在中间区域,通过功能分层,既拥有小服务范围的能力,又可以搭配官方插件实现更多场景化能力。 小服务范围优势概念少,易上手 小的服务范围代表了小的学习成本,因为暴露的基本能力较少,概念也会比较少,对新人上手比较友好。 生态繁荣,百花齐放 由于很多功能没有被官方实现,社区就有机会填补这些空白,因此会冒出许多第三方库,而且一旦做得好,就有机会成为 “事实标准”,因此开发者会更加积极参与到社区开发,自己做的框架 “上升空间” 也非常大。 同时,社区的力量会导致多元化,因此整体生态完整度与创新性都会非常亮眼,而且具有持续迭代的能力。 核心维护成本低 官方维护的核心代码较少,因此维护成本大大降低,而且官方可以将精力放在更多核心能力增强上,比如 Suspense 等,而不是将精力消耗在生态插件上。 小服务范围的劣势复杂场景要引入新概念 复杂场景无法支持时,就要引入新的概念解决,这导致后续技术选型可能产生分歧,并带来持续的新概念理解成本。 非官方的开发模式逐渐产生 随着时间的流逝,会逐渐涌出一些新的设计模式,成为当下几乎是必不可少的方案,但却不会出现在官方文档中,造成选型时的疑惑。Redux 就是一个例子。 生态变化快,碎片化且持续流失 非官方的生态也意味着不稳定,而且缺乏统一的管理,碎片化的模块之间可能经常出现不兼容的问题。 而且任何模块都可能被时代无情的淘汰,就像 Flux 到 Redux 再到 Hooks,带来额外的迁移成本和认知成本。谁也不希望自己的项目架构 “变得过时”,或者随时面临被新架构取代的风险,但第三方社区几乎一定代表未来会出现一种模式取代现有模式,只是时间早晚而已。 大服务范围的优势大部分业务场景都被内置解决 减少不必要的技术方案调研与纷争,大服务范围的框架内置的方案就能解决几乎 100% 业务问题,团队再也不会为通用架构问题烦恼了。 生态稳定、连贯 稳定是指,官方维护作为背书,几乎不会存在一些生态包突然不维护、与已有版本不兼容、被植入恶意程序等等意外情况。 连贯是指,官方会统一考虑一个改动在所有生态插件造成的影响,并以一个最合理的思路做整体改造,生态包无论是接口还是兼容性都不需要担心,设计思路也会一脉相承。 大服务范围的劣势前期上手成本高 全家桶的概念导致上手难度偏高,因为必须理解所有内置概念后才能开始项目。 如果内置模块无法满足业务,会觉得有些死板 一旦发生内置功能无法满足业务的场景,就很难拓展了,因为 all in one 的思路本质上就是排斥自定义拓展的,这点从 angular-cli 就能看出来。 之所以觉得死板,是因为这种情况没办法用优雅的方式解决,只能在现有约束的框架内通过某些 “Hack” 方式解决,自然会有种死板的感觉。 中等服务范围的优势分层设计,允许新特性渐进加入 Vue 通过分层设计做到了折中,即官方还是会维护生态,只不过生态不是必须的,可以按需使用。这样做的好处是兼顾了一些优势。 低学习门槛 与小服务范围框架一样,对于核心包来说学习成本都比较低。 依然有最佳实践解决所有业务问题 和大服务范围框架一样,拥有全套官方最佳实践,但不内置,不强求一定要使用,因此你可以按需使用。 中等服务范围的劣势维护成本高 和大服务范围框架一样,虽然生态不强求,但毕竟官方还是要持续维护的,因此维护成本高的问题依然存在。 生态多样性不高 虽然生态是按需的,但毕竟中等服务范围的框架官方会实现一套标准生态插件,这会极大影响社区生态的发展空间,导致 “非官方插件没人愿意做”,因此生态多样性会差一些。 渲染机制渲染机制区别主要在 JSX vs Template 之间,不同的表达方式之间还是存在一些很本质的区别,然而正如一开始所说,无法一言蔽之,必须从多个角度拆解的看。 JSX 的优势纯 JS 表达 UI 单这一点就非常重要了,满足了 All In Js 的幻想。毕竟 Html、Css 相比 Js 来说,模块化能力和灵活性都很弱,将其都收敛到 Js 不仅表达方式更统一,更重要的是都获得了与 Js 一样的模块化、灵活性、Typescript 支持等能力。 视图即数据 将视图看作一种数据,让针对视图的逻辑测试成为可能。 同时也将视图概念泛化了,因为数据是平台无关的,一份描述视图的 DSL 可以运行在任何平台。 JSX 的劣势开销大 页面节点越多,Diff 开销就越大。 动态渲染很难性能优化 由于所有 DOM 节点都是动态生成,因此无法根据初始状态结构进行安全的优化。相比之下,Template 模式可以确定哪部分属于变量,哪部分是固定的,对固定部分的 Diff 检测都可以跳过。 动态调度虽然改善了性能,但依赖更重的运行时 React ConcurrentMode 是一个调度优化器,但实现的逻辑也比较复杂,加重了运行时负担。 Template 的优势原生性能 由于 Template 对节点进行直接渲染,因此与原生性能一致。 Runtime 更小 由于不需要额外优化,运行时代码会小很多。 Template 的劣势被 Template 语法约束,且无法拓展 对于 Template 不支持的,只能选择接受,因为除了框架自己,没有人能拓展 Template 的特性。当遇到一些非常动态场景,但 Template 不支持的情况,只能选择接受,并用比较 Hack 的方式绕过解决,除此之外别无他法。 模版冗长 JSX 可以利用循环语句或者变量赋值进行模版区块的复用,但 Template 模式每次新模版都要一行一行的打出来,这种冗长的开发体验不太友好。 运行时解析开销或者依赖编译期逻辑 要么通过编译器预先生成 AST,要么运行时动态将 Template 解析成 AST,无论哪种方案都有额外的开销,一种是工程依赖的开销,一种是运行时动态解析的性能开销。 VDom + Template 的特色Vue 在 Template 基础上支持了虚拟 DOM,因此兼具两者特色。 性能上,在编译时就进行 AST 解析,减少了运行时解析开销。 功能上,支持模版与 JSX 两种语法。 状态机制状态机制 尤雨溪 在 JSConf 提到要单独拆出来讲,因为内容较多,时间可能不够,本次精读也限于篇幅原因略过: Mutable vs Immutable。 依赖追踪 vs 脏检测。 响应式 vs 模拟响应式。 显然,状态机制方案更是仁者见仁智者见智的事情,同样得从多个维度进行独立分析,并根据实际业务场景具体选择。 最后,意识到没有一个绝对均衡的框架设计方案,因为在工程领域,没有最好只有更好。 3 精读我们再延伸谈一谈为什么框架设计要寻找平衡点。 框架设计没有银弹 与数学公式不同,框架设计甚至整个工程技术设计都没有所谓的真理,所谓条条大路通罗马,实现同一个技术目标的众多方案之间也许就是平行关系,可以根据不同维度列出一二三的对比,但无法得出一个总的结论,孰优孰劣。 使用场景不同 不同使用场景决定了对框架诉求的不同。 比如开发非常定制、炫酷的可视化大屏,那么前端开发框架基本也用不上,因为关注点不会聚焦在项目路由、UI 描述、甚至是数据流,而是聚焦在性能、图形渲染等问题。解决这些领域的框架可能是 虚幻 4、Unity 等游戏引擎,但普通的前端开发框架绝不会涉足这种领域,框架一定要确定自己功能范围。 即便仅局限在 Web 领域,也需要考虑是否要支持非 Web 场景,那么将 HTML 抽象成一个通用 DSL 就可能是一种选择,但非 Web 领域毕竟不是主打业务领域,在这种业务场景周边生态维护可能就比较少,这也是需要取舍的地方。 使用的人不同 不同团队对框架的要求也不同。 刚起步的小团队可能更需要保姆式的框架,因为这样最节省人力成本。对于规模较大的团队,希望对框架拥有较大定制能力时,小服务范围的框架可能更受青睐。当然框架作者可以像 Vue 一样做出渐进式官方能力增强方案,以此满足不同需求的用户,但毕竟也不能将生态完全交给社区,还是要做取舍。 所以当遇到更新更酷的框架时,需要冷静思考的不只是这个框架带来的收益与花费的迁移成本哪个更高,以及团队能否接受这套框架的开发习惯,更需要思考的是这个框架自身做了哪些权衡,如果这些权衡与 React、Vue、Angular 类似,那么仅仅变化了语法或者语言的改动其实意义不大,此时需要慎重考虑。 4 总结这次没有提到的状态机制对比,你能分别列举出优缺点吗?欢迎留言。 讨论地址是:精读《寻找框架设计的平衡点》 · Issue ##223 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《快速上手构建 ARKit 应用》","path":"/wiki/WebWeekly/前沿技术/《快速上手构建 ARKit 应用》.html","content":"当前期刊数: 50 精读《快速上手构建 ARKit 应用》原文地址: how-to-make-your-own-arkit-app-in-5-minutes-using-react-native 引言ARKit 是苹果推出的增强现实套装,而 react-native-arkit 是基于此的上层封装。对于前端开发而言,这可能是最快上手 ARKit 的方式了,本周精读让我们来初窥 ARKit 和 React Native ARKit 这个库。 概要本次精读我们带来的是一篇《快速上手构建 ARKit 应用》,原文链接如上。原文标题更加直接,直译的话是“如何在 5 分钟里利用 react native 搭建出你自己的 ARKit 应用”。确实,这篇文章整体也非常明确,以跑起整个 ARKit Demo 为最直接最主要的目的。 跑起 ARKit,也很简单。硬件上,只要有一台 iPhone 6S 以上的手机;软件上,只要准备好最新版本的 XCode 和日常开发要用的 Node 环境了就好。按照react-native-arkit的里面的 README 就可以跑起来了。这个库不 3 精读在开始精读前,我先抛出我的问题三连:Why AR? Why ARKit? Why React Native ARKit? 3.1 Why AR?在之前的第 43 期精读评论中,我们探讨了 AR 对于和前端结合的可能性。总的来说,AR 把前端开发不再局限在有限的屏幕空间上,对于可视化等对前端展示空间有强烈需求的细分领域,AR 是一个很值得研究的内容。如果对于这一块内容有兴趣,欢迎回看第 43 期精读评论 《精读〈增强现实与可视化〉》。 3.2 Why ARKit?为什么选择 ARKit 入手进行实验?其因有二。第一,相比于 Microsoft HoloLens 的价格,售价只有它三分之一的 iPhone X 无论是体积重量,还是性价比,抑或是保有量都是大大占优的。噢对,说到保有量,iPhone 6S 及以上都支持 ARKit。所以说 iPhone 是我们身边最容易接触到的 AR 设备是不为过的。第二,ARKit 对于硬件的利用能力非一般的前端库可以做到的。大部分的 AR 前端库可以做到利用陀螺仪来构建一个三维立体空间。但是 ARKit 更进一步,他利用高频调用摄像头,通过对图像进行识别分析,可以进行空间感知,例如可以识别出一个平面。而这些都是 ARKit 所提供的,我们只需要调用它的能力就好了。对于开发者而言,ARKit 会比一般的 AR 库更近一步。 3.3 Why React Native ARKit?对于当下的前端开发,所有事情可以分为两种——0. 可以用 JavaScript 写的 1. 其他。至于为什么选择react-native-arkit这个库,原因自然也可以理解。相比于用原生的 Swift 来开发,React Native 的开发方式对于前端而言明显是更加容易上手了。对于尝试新东西,这也未尝不可。 3.4 About Demo相比于原文中从初始化开始的步骤,官方还提供了一个已经配置好的官方 Demo。使用这个,如果环境没有问题,的确只需要 5 分钟就可以跑起来一个 ARKit 应用了。 上面的图片来自原文,可以看到,在react-native-arkit这个库里面的所支持的 9 种基本图形和文字。使用如下已经封装好的 React Native 组件就可以直接使用了。 <ARKit.Box pos={{ x: 0, y: 0, z: 0 }} shape={{ width: 0.1, height: 0.1, length: 0.1, chamfer: 0.01 }}/> 几何构造上面的一个视频片段是我们在跑起来 Demo 后的立体效果。可以很清楚地看到,ARKit 感知到了房间这个立方体空间后所构建出来的 AR 的效果。 平面识别而最后的这段视频会更加有趣一些,中央的红圈的出现逻辑是停留在最近识别出的一个平面上。我们可以看到首先识别出了地面,红圈随地面而动;再移向桌面时,很快又识别出了桌面,重新生成了一个停留在桌面上的红圈。通过这一段可以看出无论是明暗划分明显的地面,还是堆满杂物的桌面,ARKit 都可以很轻松的识别出来。 4. 总结苹果的 ARKit 对空间平面的感知能力胜过了一般的 AR 渲染库。而 iPhone 6S 就能跑的特性又让我们觉得 AR 其实并没有那么遥远。在此基础之上的 React Native 封装react-native-arkit,让我们通过 JS 就拥有操作 ARKit 的能力。这的确是一个快速上手 ARKit 的方式。 5 更多讨论讨论地址是:精读《快速上手构建 ARKit 应用》 · Issue ##70 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周末发布。"},{"title":"《怎么用 React Hooks 造轮子》","path":"/wiki/WebWeekly/前沿技术/《怎么用 React Hooks 造轮子》.html","content":"当前期刊数: 80 1 引言上周的 精读《React Hooks》 已经实现了对 React Hooks 的基本认知,也许你也看了 React Hooks 基本实现剖析(单向链表),但理解实现原理就可以用好了吗?学的是知识,而用的是技能,看别人的用法就像刷抖音一样(哇,饭还可以这样吃?),你总会有新的收获。 这篇文章将这些知识实践起来,看看广大程序劳动人民是如何发掘 React Hooks 的潜力的(造什么轮子)。 首先,站在使用角度,要理解 React Hooks 的特点是 “非常方便的 Connect 一切”,所以无论是数据流、Network,或者是定时器都可以监听,有一点 RXJS 的意味,也就是你可以利用 React Hooks,将 React 组件打造成:任何事物的变化都是输入源,当这些源变化时会重新触发 React 组件的 render,你只需要挑选组件绑定哪些数据源(use 哪些 Hooks),然后只管写 render 函数就行了! 2 精读参考了部分 React Hooks 组件后,笔者按照功能进行了一些分类。 由于 React Hooks 并不是非常复杂,所以就不按照技术实现方式去分类了,毕竟技术总有一天会熟练,而且按照功能分类才有持久的参考价值。 DOM 副作用修改 / 监听做一个网页,总有一些看上去和组件关系不大的麻烦事,比如修改页面标题(切换页面记得改成默认标题)、监听页面大小变化(组件销毁记得取消监听)、断网时提示(一层层装饰器要堆成小山了)。而 React Hooks 特别擅长做这些事,造这种轮子,大小皆宜。 由于 React Hooks 降低了高阶组件使用成本,那么一套生命周期才能完成的 “杂耍” 将变得非常简单。 下面举几个例子: 修改页面 title效果:在组件里调用 useDocumentTitle 函数即可设置页面标题,且切换页面时,页面标题重置为默认标题 “前端精读”。 useDocumentTitle("个人中心"); 实现:直接用 document.title 赋值,不能再简单。在销毁时再次给一个默认标题即可,这个简单的函数可以抽象在项目工具函数里,每个页面组件都需要调用。 function useDocumentTitle(title) { useEffect( () => { document.title = title; return () => (document.title = "前端精读"); }, [title] );} 在线 Demo 监听页面大小变化,网络是否断开效果:在组件调用 useWindowSize 时,可以拿到页面大小,并且在浏览器缩放时自动触发组件更新。 const windowSize = useWindowSize();return <div>页面高度:{windowSize.innerWidth}</div>; 实现:和标题思路基本一致,这次从 window.innerHeight 等 API 直接拿到页面宽高即可,注意此时可以用 window.addEventListener('resize') 监听页面大小变化,此时调用 setValue 将会触发调用自身的 UI 组件 rerender,就是这么简单! 最后注意在销毁时,removeEventListener 注销监听。 function getSize() { return { innerHeight: window.innerHeight, innerWidth: window.innerWidth, outerHeight: window.outerHeight, outerWidth: window.outerWidth };}function useWindowSize() { let [windowSize, setWindowSize] = useState(getSize()); function handleResize() { setWindowSize(getSize()); } useEffect(() => { window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); }; }, []); return windowSize;} 在线 Demo 动态注入 css效果:在页面注入一段 class,并且当组件销毁时,移除这个 class。 const className = useCss({ color: "red"});return <div className={className}>Text.</div>; 实现:可以看到,Hooks 方便的地方是在组件销毁时移除副作用,所以我们可以安心的利用 Hooks 做一些副作用。注入 css 自然不必说了,而销毁 css 只要找到注入的那段引用进行销毁即可,具体可以看这个 代码片段。 DOM 副作用修改 / 监听场景有一些现成的库了,从名字上就能看出来用法:document-visibility、network-status、online-status、window-scroll-position、window-size、document-title。 组件辅助Hooks 还可以增强组件能力,比如拿到并监听组件运行时宽高等。 获取组件宽高效果:通过调用 useComponentSize 拿到某个组件 ref 实例的宽高,并且在宽高变化时,rerender 并拿到最新的宽高。 const ref = useRef(null);let componentSize = useComponentSize(ref);return ( <> {componentSize.width} <textArea ref={ref} /> </>); 实现:和 DOM 监听类似,这次换成了利用 ResizeObserver 对组件 ref 进行监听,同时在组件销毁时,销毁监听。 其本质还是监听一些副作用,但通过 ref 的传递,我们可以对组件粒度进行监听和操作了。 useLayoutEffect(() => { handleResize(); let resizeObserver = new ResizeObserver(() => handleResize()); resizeObserver.observe(ref.current); return () => { resizeObserver.disconnect(ref.current); resizeObserver = null; };}, []); 在线 Demo,对应组件 component-size。 拿到组件 onChange 抛出的值效果:通过 useInputValue() 拿到 Input 框当前用户输入的值,而不是手动监听 onChange 再腾一个 otherInputValue 和一个回调函数把这一堆逻辑写在无关的地方。 let name = useInputValue("Jamie");// name = { value: 'Jamie', onChange: [Function] }return <input {...name} />; 可以看到,这样不仅没有占用组件自己的 state,也不需要手写 onChange 回调函数进行处理,这些处理都压缩成了一行 use hook。 实现:读到这里应该大致可以猜到了,利用 useState 存储组件的值,并抛出 value 与 onChange,监听 onChange 并通过 setValue 修改 value, 就可以在每次 onChange 时触发调用组件的 rerender 了。 function useInputValue(initialValue) { let [value, setValue] = useState(initialValue); let onChange = useCallback(function(event) { setValue(event.currentTarget.value); }, []); return { value, onChange };} 这里要注意的是,我们对组件增强时,组件的回调一般不需要销毁监听,而且仅需监听一次,这与 DOM 监听不同,因此大部分场景,我们需要利用 useCallback 包裹,并传一个空数组,来保证永远只监听一次,而且不需要在组件销毁时注销这个 callback。 在线 Demo,对应组件 input-value。 做动画利用 React Hooks 做动画,一般是拿到一些具有弹性变化的值,我们可以将值赋给进度条之类的组件,这样其进度变化就符合某种动画曲线。 在某个时间段内获取 0-1 之间的值这个是动画最基本的概念,某个时间内拿到一个线性增长的值。 效果:通过 useRaf(t) 拿到 t 毫秒内不断刷新的 0-1 之间的数字,期间组件会不断刷新,但刷新频率由 requestAnimationFrame 控制(不会卡顿 UI)。 const value = useRaf(1000); 实现:写起来比较冗长,这里简单描述一下。利用 requestAnimationFrame 在给定时间内给出 0-1 之间的值,那每次刷新时,只要判断当前刷新的时间点占总时间的比例是多少,然后做分母,分子是 1 即可。 在线 Demo,对应组件 use-raf。 弹性动画效果:通过 useSpring 拿到动画值,组件以固定频率刷新,而这个动画值以弹性函数进行增减。 实际调用方式一般是,先通过 useState 拿到一个值,再通过动画函数包住这个值,这样组件就会从原本的刷新一次,变成刷新 N 次,拿到的值也随着动画函数的规则变化,最后这个值会稳定到最终的输入值(如例子中的 50)。 const [target, setTarget] = useState(50);const value = useSpring(target);return <div onClick={() => setTarget(100)}>{value}</div>; 实现:为了实现动画效果,需要依赖 rebound 库,它可以实现将一个目标值拆解为符合弹性动画函数过程的功能,那我们需要利用 React Hooks 做的就是在第一次接收到目标值是,调用 spring.setEndValue 来触发动画事件,并在 useEffect 里做一次性监听,再值变时重新 setValue 即可。 最神奇的 setTarget 联动 useSpring 重新计算弹性动画部分,是通过 useEffect 第二个参数实现的: useEffect( () => { if (spring) { spring.setEndValue(targetValue); } }, [targetValue]); 也就是当目标值变化后,才会进行新的一轮 rerender,所以 useSpring 并不需要监听调用处的 setTarget,它只需要监听 target 的变化即可,而巧妙利用 useEffect 的第二个参数可以事半功倍。 在线 Demo Tween 动画明白了弹性动画原理,Tween 动画就更简单了。 效果:通过 useTween 拿到一个从 0 变化到 1 的值,这个值的动画曲线是 tween。可以看到,由于取值范围是固定的,所以我们不需要给初始值了。 const value = useTween(); 实现:通过 useRaf 拿到一个线性增长的值(区间也是 0 ~ 1),再通过 easing 库将其映射到 0 ~ 1 到值即可。这里用到了 hook 调用 hook 的联动(通过 useRaf 驱动 useTween),还可以在其他地方举一反三。 const fn: Easing = easing[easingName];const t = useRaf(ms, delay);return fn(t); 发请求利用 Hooks,可以将任意请求 Promise 封装为带有标准状态的对象:loading、error、result。 通用 Http 封装效果:通过 useAsync 将一个 Promise 拆解为 loading、error、result 三个对象。 const { loading, error, result } = useAsync(fetchUser, [id]); 实现:在 Promise 的初期设置 loading,结束后设置 result,如果出错则设置 error,这里可以将请求对象包装成 useAsyncState 来处理,这里就不放出来了。 export function useAsync(asyncFunction: any, params: any[]) { const asyncState = useAsyncState(options); useEffect(() => { const promise = asyncFunction(); asyncState.setLoading(); promise.then( result => asyncState.setResult(result);, error => asyncState.setError(error); ); }, params);} 具体代码可以参考 react-async-hook,这个功能建议仅了解原理,具体实现因为有一些边界情况需要考虑,比如组件 isMounted 后才能相应请求结果。 Request Service业务层一般会抽象一个 request service 做统一取数的抽象(比如统一 url,或者可以统一换 socket 实现等等)。假如以前比较 low 的做法是: async componentDidMount() { // setState: 改 isLoading state try { const data = await fetchUser() // setState: 改 isLoading、error、data } catch (error) { // setState: 改 isLoading、error }} 后来把请求放在 redux 里,通过 connect 注入的方式会稍微有些改观: @Connect(...)class App extends React.PureComponent { public componentDidMount() { this.props.fetchUser() } public render() { // this.props.userData.isLoading | error | data }} 最后会发现还是 Hooks 简洁明了: function App() { const { isLoading, error, data } = useFetchUser();} 而 useFetchUser 利用上面封装的 useAsync 可以很容易编写: const fetchUser = id => fetch(`xxx`).then(result => { if (result.status !== 200) { throw new Error("bad status = " + result.status); } return result.json(); });function useFetchUser(id) { const asyncFetchUser = useAsync(fetchUser, [id]); return asyncFetchUser;} 填表单React Hooks 特别适合做表单,尤其是 antd form 如果支持 Hooks 版,那用起来会方便许多: function App() { const { getFieldDecorator } = useAntdForm(); return ( <Form onSubmit={this.handleSubmit} className="login-form"> <FormItem> {getFieldDecorator("userName", { rules: [{ required: true, message: "Please input your username!" }] })( <Input prefix={<Icon type="user" style={{ color: "rgba(0,0,0,.25)" }} />} placeholder="Username" /> )} </FormItem> <FormItem> <Button type="primary" htmlType="submit" className="login-form-button"> Log in </Button> Or <a href="">register now!</a> </FormItem> </Form> );} 不过虽然如此,getFieldDecorator 还是基于 RenderProps 思路的,彻底的 Hooks 思路是利用之前说的 组件辅助方式,提供一个组件方法集,用解构方式传给组件。 Hooks 思维的表单组件效果:通过 useFormState 拿到表单值,并且提供一系列 组件辅助 方法控制组件状态。 const [formState, { text, password }] = useFormState();return ( <form> <input {...text("username")} required /> <input {...password("password")} required minLength={8} /> </form>); 上面可以通过 formState 随时拿到表单值,和一些校验信息,通过 password("pwd") 传给 input 组件,让这个组件达到受控状态,且输入类型是 password 类型,表单 key 是 pwd。而且可以看到使用的 form 是原生标签,这种表单增强是相当解耦的。 实现:仔细观察一下结构,不难发现,我们只要结合 组件辅助 小节说的 “拿到组件 onChange 抛出的值” 一节的思路,就能轻松理解 text、password 是如何作用于 input 组件,并拿到其输入状态。 往简单的来说,只要把这些状态 Merge 起来,通过 useReducer 聚合到 formState 就可以实现了。 为了简化,我们只考虑对 input 的增强,源码仅需 30 几行: export function useFormState(initialState) { const [state, setState] = useReducer(stateReducer, initialState || {}); const createPropsGetter = type => (name, ownValue) => { const hasOwnValue = !!ownValue; const hasValueInState = state[name] !== undefined; function setInitialValue() { let value = ""; setState({ [name]: value }); } const inputProps = { name, // 给 input 添加 type: text or password get value() { if (!hasValueInState) { setInitialValue(); // 给初始化值 } return hasValueInState ? state[name] : ""; // 赋值 }, onChange(e) { let { value } = e.target; setState({ [name]: value }); // 修改对应 Key 的值 } }; return inputProps; }; const inputPropsCreators = ["text", "password"].reduce( (methods, type) => ({ ...methods, [type]: createPropsGetter(type) }), {} ); return [ { values: state }, // formState inputPropsCreators ];} 上面 30 行代码实现了对 input 标签类型的设置,监听 value onChange,最终聚合到大的 values 作为 formState 返回。读到这里应该发现对 React Hooks 的应用都是万变不离其宗的,特别是对组件信息的获取,通过解构方式来做,Hooks 内部再做一下聚合,就完成表单组件基本功能了。 实际上一个完整的轮子还需要考虑 checkbox radio 的兼容,以及校验问题,这些思路大同小异,具体源码可以看 react-use-form-state。 模拟生命周期有的时候 React15 的 API 还是挺有用的,利用 React Hooks 几乎可以模拟出全套。 componentDidMount效果:通过 useMount 拿到 mount 周期才执行的回调函数。 useMount(() => { // quite similar to `componentDidMount`}); 实现:componentDidMount 等价于 useEffect 的回调(仅执行一次时),因此直接把回调函数抛出来即可。 useEffect(() => void fn(), []); componentWillUnmount效果:通过 useUnmount 拿到 unmount 周期才执行的回调函数。 useUnmount(() => { // quite similar to `componentWillUnmount`}); 实现:componentWillUnmount 等价于 useEffect 的回调函数返回值(仅执行一次时),因此直接把回调函数返回值抛出来即可。 useEffect(() => fn, []); componentDidUpdate效果:通过 useUpdate 拿到 didUpdate 周期才执行的回调函数。 useUpdate(() => { // quite similar to `componentDidUpdate`}); 实现:componentDidUpdate 等价于 useMount 的逻辑每次执行,除了初始化第一次。因此采用 mouting flag(判断初始状态)+ 不加限制参数确保每次 rerender 都会执行即可。 const mounting = useRef(true);useEffect(() => { if (mounting.current) { mounting.current = false; } else { fn(); }}); Force Update效果:这个最有意思了,我希望拿到一个函数 update,每次调用就强制刷新当前组件。 const update = useUpdate(); 实现:我们知道 useState 下标为 1 的项是用来更新数据的,但数据必须有变化才会触发 render,因此我们可以这样设计: const useUpdate = () => { const [, setState] = useState(0); return () => setState(cnt => cnt + 1);}; 或者利用 useReducer 做一个简单的 Action 来支持: const [, forceRender] = useReducer(s => s + 1, 0); 感谢:感谢用户 cike8899 对此处的勘误,并提供示例代码。 对于 getSnapshotBeforeUpdate, getDerivedStateFromError, componentDidCatch 目前 Hooks 是无法模拟的。 isMounted很久以前 React 是提供过这个 API 的,后来移除了,原因是可以通过 componentWillMount 和 componentWillUnmount 推导。自从有了 React Hooks,支持 isMount 简直是分分钟的事。 效果:通过 useIsMounted 拿到 isMounted 状态。 const isMounted = useIsMounted(); 实现:看到这里的话,应该已经很熟悉这个套路了,useEffect 第一次调用时赋值为 true,组件销毁时返回 false,注意这里可以加第二个参数为空数组来优化性能。 const [isMount, setIsMount] = useState(false);useEffect(() => { if (!isMount) { setIsMount(true); } return () => setIsMount(false);}, []);return isMount; 在线 Demo 存数据上一篇提到过 React Hooks 内置的 useReducer 可以模拟 Redux 的 reducer 行为,那唯一需要补充的就是将数据持久化。我们考虑最小实现,也就是全局 Store + Provider 部分。 全局 Store效果:通过 createStore 创建一个全局 Store,再通过 StoreProvider 将 store 注入到子组件的 context 中,最终通过两个 Hooks 进行获取与操作:useStore 与 useAction: const store = createStore({ user: { name: "小明", setName: (state, payload) => { state.name = payload; } }});const App = () => ( <StoreProvider store={store}> <YourApp /> </StoreProvider>);function YourApp() { const userName = useStore(state => state.user.name); const setName = userAction(dispatch => dispatch.user.setName);} 实现:这个例子的实现可以单独拎出一篇文章了,所以笔者从存数据的角度剖析一下 StoreProvider 的实现。 对,Hooks 并不解决 Provider 的问题,所以全局状态必须有 Provider,但这个 Provider 可以利用 React 内置的 createContext 简单搞定: const StoreContext = createContext();const StoreProvider = ({ children, store }) => ( <StoreContext.Provider value={store}>{children}</StoreContext.Provider>); 剩下就是 useStore 怎么取到持久化 Store 的问题了,这里利用 useContext 和刚才创建的 Context 对象: const store = useContext(StoreContext);return store; 更多源码可以参考 easy-peasy,这个库基于 redux 编写,提供了一套 Hooks API。 封装原有库是不是 React Hooks 出现后,所有的库都要重写一次?当然不是,我们看看其他库如何做改造。 RenderProps to Hooks这里拿 react-powerplug 举例。 比如有一个 renderProps 库,希望改造成 Hooks 的用法: import { Toggle } from 'react-powerplug'function App() { return ( <Toggle initial={true}> {({ on, toggle }) => ( <Checkbox checked={on} onChange={toggle} /> )} </Toggle> )}↓ ↓ ↓ ↓ ↓ ↓import { useToggle } from 'react-powerhooks'function App() { const [on, toggle] = useToggle() return <Checkbox checked={on} onChange={toggle} />} 效果:假如我是 react-powerplug 的维护者,怎么样最小成本支持 React Hook? 说实话这个没办法一步做到,但可以通过两步实现。 export function Toggle() { // 这是 Toggle 的源码 // balabalabala..}const App = wrap(() => { // 第一步:包 wrap const [on, toggle] = useRenderProps(Toggle); // 第二步:包 useRenderProps}); 实现:首先解释一下为什么要包两层,首先 Hooks 必须遵循 React 的规范,我们必须写一个 useRenderProps 函数以符合 Hooks 的格式,那问题是如何拿到 Toggle 给 render 的 on 与 toggle?正常方式应该拿不到,所以退而求其次,将 useRenderProps 拿到的 Toggle 传给 wrap,让 wrap 构造 RenderProps 执行环境拿到 on 与 toggle 后,调用 useRenderProps 内部的 setArgs 函数,让 const [on, toggle] = useRenderProps(Toggle) 实现曲线救国。 const wrappers = []; // 全局存储 wrappersexport const useRenderProps = (WrapperComponent, wrapperProps) => { const [args, setArgs] = useState([]); const ref = useRef({}); if (!ref.current.initialized) { wrappers.push({ WrapperComponent, wrapperProps, setArgs }); } useEffect(() => { ref.current.initialized = true; }, []); return args; // 通过下面 wrap 调用 setArgs 获取值。}; 由于 useRenderProps 会先于 wrap 执行,所以 wrappers 会先拿到 Toggle,wrap 执行时直接调用 wrappers.pop() 即可拿到 Toggle 对象。然后构造出 RenderProps 的执行环境即可: export const wrap = FunctionComponent => props => { const element = FunctionComponent(props); const ref = useRef({ wrapper: wrappers.pop() }); // 拿到 useRenderProps 提供的 Toggle const { WrapperComponent, wrapperProps } = ref.current.wrapper; return createElement(WrapperComponent, wrapperProps, (...args) => { // WrapperComponent => Toggle,这一步是在构造 RenderProps 执行环境 if (!ref.current.processed) { ref.current.wrapper.setArgs(args); // 拿到 on、toggle 后,通过 setArgs 传给上面的 args。 ref.current.processed = true; } else { ref.current.processed = false; } return element; });}; 以上实现方案参考 react-hooks-render-props,有需求要可以拿过来直接用,不过实现思路可以参考,作者的脑洞挺大。 Hooks to RenderProps好吧,如果希望 Hooks 支持 RenderProps,那一定是希望同时支持这两套语法。 效果:一套代码同时支持 Hooks 和 RenderProps。 实现:其实 Hooks 封装为 RenderProps 最方便,因此我们使用 Hooks 写核心的代码,假设我们写一个最简单的 Toggle: const useToggle = initialValue => { const [on, setOn] = useState(initialValue); return { on, toggle: () => setOn(!on) };}; 在线 Demo 然后通过 render-props 这个库可以轻松封装出 RenderProps 组件: const Toggle = ({ initialValue, children, render = children }) => renderProps(render, useToggle(initialValue)); 在线 Demo 其实 renderProps 这个组件的第二个参数,在 Class 形式 React 组件时,接收的是 this.state,现在我们改成 useToggle 返回的对象,也可以理解为 state,利用 Hooks 机制驱动 Toggle 组件 rerender,从而让子组件 rerender。 封装原本对 setState 增强的库Hooks 也特别适合封装原本就作用于 setState 的库,比如 immer。 useState 虽然不是 setState,但却可以理解为控制高阶组件的 setState,我们完全可以封装一个自定义的 useState,然后内置对 setState 的优化。 比如 immer 的语法是通过 produce 包装,将 mutable 代码通过 Proxy 代理为 immutable: const nextState = produce(baseState, draftState => { draftState.push({ todo: "Tweet about it" }); draftState[1].done = true;}); 那这个 produce 就可以通过封装一个 useImmer 来隐藏掉: function useImmer(initialValue) { const [val, updateValue] = React.useState(initialValue); return [ val, updater => { updateValue(produce(updater)); } ];} 使用方式: const [value, setValue] = useImmer({ a: 1 });value(obj => (obj.a = 2)); // immutable 3 总结本文列出了 React Hooks 的以下几种使用方式以及实现思路: DOM 副作用修改 / 监听。 组件辅助。 做动画。 发请求。 填表单。 模拟生命周期。 存数据。 封装原有库。 欢迎大家的持续补充。 4 更多讨论 讨论地址是:精读《怎么用 React Hooks 造轮子》 · Issue ##112 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《我们为何弃用 css-in-js》","path":"/wiki/WebWeekly/前沿技术/《我们为何弃用 css-in-js》.html","content":"当前期刊数: 263 emotion 排名第二的维护者 Sam 所在公司弃用了 css-in-js 方案,引起了不小的讨论:Why We’re Breaking Up with CSS-in-JS 概述 & 精读原文很有有条理,先从 css-in-js 优点说起,再转而谈到缺点,说明了 css-in-js 这个新事物拥有明显的优点与缺点;然后从性能问题作为切入点,说明自己所在的公司为什么不得不抛弃 css-in-js;最后告诉读者目前自己的解决方案是 css-modules。 之后还有一点儿延展性思考,即目前还诞生了一批编译时 css-in-js 方案,但面对性能问题时依然徒劳。 让我们花点儿时间了解下作者的具体思路吧。 css-in-js 的优缺点css-in-js 作为一个理念较新的开发思路,拥有如下几个明显的优缺点。 优点: 无全局样式冲突。就像 js 文件天然支持模块化的好处一样,原生 css 因为没有模块化能力,天然容易导致全局样式污染,如果不是特意用 BEM 方式命名,想要避免冲突就只能借助 css-in-js 了。(css-modules 也一样能做到) 与 js 代码合在一起。天然融合进 js 代码方便模块化管理,使 css 可以与某个局部模块绑定。(css-modules 也一样能做到,只是必须单独拆一个样式文件) 能将 js 变量应用到样式上。虽然 css 变量也能解决这个问题,但不如 css-in-js 那么直观,inline-style 也能解决这个问题,但会产生大量重复的局部样式,且这个优势 css-modules 做不到。 缺点: css-in-js 运行时解析的实现版本增加了运行时性能压力,尤其在 React18 调度机制模式下,存在无法解决的性能问题(运行时插入样式会导致 React 渲染暂停,浏览器解析一遍样式,渲染再继续,然后浏览器又解析一遍样式)。 增加了包体积。相比原生或者 css-modules 方案来说,增加了运行时框架代码 8kb 左右。 让 ReactDevTools 结构变得复杂,因为 css-in-js 会包裹额外的 React 组件层用来实现样式插入。 除了上述缺点外,css-in-js 还有三点深度使用后才能察觉的坑: 多个不同(甚至是相同)版本的 css-in-js 库同时加载时可能导致错误。笔者用 styled-components 就遇到了类似问题,甚至语法会产生不兼容的情况,虽然这些问题都可以被解决,但花费的额外时间需要计算一样,相比 css-in-js 得到的收益是否值得。 样式插入优先级无法自定义,这就导致产生样式覆盖时,业务对样式覆盖的优先级无法产生稳定的预期。class 优先级由 header 定义顺序决定,而非 className 的字符顺序决定,而 header 定义顺序又由资源加载与 css-in-js 插入执行时机决定,导致业务几乎不可能有稳定的样式覆盖顺序。这里产生的问题就是业务代码不断增多的 !important 定义。 不同 React 版本的 SSR,css-in-js 需要适配不同的实现,这对框架作者不太友好。 除了性能问题以外,其他问题都可以忍,但偏偏在性能问题上,css-in-js 遇到了无解的场景。 无解的性能问题第一条缺点提到的运行时解析,是 css-in-js 方案永远跨不过去的困境,即便对于编译时 css-in-js 方案来说,也免不了在渲染时做额外的逻辑执行拖慢渲染速度: function App() { return <div css={{ color: "red" }} />; // 就是这种代码导致了性能问题} 原因是当 React 重渲染组件时,需要重新解析样式定义,并序列化 className,当渲染非常频繁时会导致明显的性能瓶颈,而解决方法是把样式定义抽出来,但这样就损失了第三个优点,即无法读取 js 变量了: const myCss = css({ backgroundColor: "blue", width: 100, height: 100,}); 不得不说 React 的渲染机制实在是太有问题了,如果换成 SolidJS 这个问题就好办了,因为运行时的样式代码仅会运行一次,组件重渲染也不会导致这段解析代码被重复执行,此时 css-in-js 在样式变化时再做一次精确样式更新,性能问题就可以被解决了。 换成 css modulescss-modules 同时支持优点一和二,而优点三可以通过一些特定语法糖绕过:通过 :import :export 伪类做 css 变量的导入导出,用 webpack-loader 实现 js 中引用 css 变量,用 css variable 实现 css 引用 js 变量。 所以当性能问题是绕不过去的话题,而 css-modules 在性能最优的情况下,有一些曲线方案可以同时支持 css-in-js 的优点,也就能理解为什么作者要弃用 css-in-js 了。 包体积真的变大了吗原文谈到的 css-in-js 增加了 8~16kb 其实是在强行堆缺点了,除非你的项目只有一行 css 定义。如果我们只考虑传输时的包体积与 HTML 中样式定义数量,而忽略运行时产生的性能负担,那么 css-in-js 在大型项目无疑是最优的。 原因就是 css-in-js 样式是按需插入的,没有渲染的组件就不会插入样式。甚至渲染了的组件也不一定会插入样式,因为 css-in-js 可以对包含相同样式定义的场景做 className 合并,类似于 webpack 打包时,可以把不同模块公共代码抽到一个 chunk 里。 编译时 css-in-js 方案是出路吗理论上是出路,但限制了 css-in-js 的灵活性。从 vanilla-extract 等编译时 css-in-js 框架来看,确实解决了运行时 css-in-js 性能问题,但带来了更多语法限制,比如必须预先定义样式再使用: import { style } from '@vanilla-extract/css'const myStyle = style({ display: 'flex', paddingTop: '3px'})const App = () => <div className={myStyle}/> 编译时 css-in-js 想要做到通用性,只能提供一个 className,这样就不受任何框架和环境的限制了,但这样也限制了声明语法的灵活性,显然不可以用内联方式定义样式。 而且这种编译时的方案本质上和 css-modules 是一样的,背后都是定义了一些静态样式名,只是说这些样式问题以 .sass 定义还是 .ts 定义,如果用 .ts 定义,配合编译工具可以使代码原生 import 的更加舒服。 所以使用了编译时 css-in-js 方案,本质上还是抛弃了运行时 css-in-js,投向了变种的 css-modules 阵营。 总结css-in-js 本身方向是对的,即把 css 与 js 融合,但太过灵活的运行时 css-in-js 方案遇到了几乎不可解的性能问题,编译时的 css-in-js 方案可能是更好的出路。 css-in-js 这个名字本身就表示它拥有 in js 的灵活性,而编译时 css-in-js 方案本质因为是 css-module,所以不可避免拥有一些比较奇怪的限制,如果 js 里的代码不能像真的 js 一样灵活,可能还不如回到 .scss 或者 .less 的后缀更好理解一些。 讨论地址是:精读《我们为何弃用 css-in-js》· Issue ##450 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《我不再使用高阶组件》","path":"/wiki/WebWeekly/前沿技术/《我不再使用高阶组件》.html","content":"当前期刊数: 31 本期精读的文章是:我不再使用高阶组件。 懒得看文章?没关系,稍后会附上文章内容概述,同时,更希望能通过阅读这一期的精读,穿插着深入阅读原文。 1 引言 React 与 Vue 相比,组件为一等公民是最大特色之一。 由于组件可以作为一个 props 向下传递,因此 React 具备了高度抽象化的能力,Vue 虽然更易上手,但因 template 特点,没有所谓 props 传递组件这种概念,但这样导致在抽象能力上落后于 React。可能这是 JSX 与 template 之间的差异吧,也是变量与字符串之间的差异,变量同名但含义不同,所以可抽象,而模版靠规则和名称确定含义。 当然 Vue 也有 babel-plugin-transform-vue-jsx 这里不做展开。 强大的组件能力,导致了实践的多样性,高阶组件就是其一。高阶组件的特点是,JSX 描述的子元素,会注入到父级组件的 this.props.children 中,因此可以无入侵增强组件能力,常用比如权限、跳转、埋点、异常、描述、注入等等。 高阶组件也带来了使用中的困扰,作者这篇文章阐述了高阶组件存在的问题,值得我们了解。 2 内容概要高阶组件由于可嵌套,如果有一环高阶组件没有将内部 wrappedComponent 暴露出来,会导致后续叠加的高阶组件都无法获取、注入到原始组件。 另外就算所有高阶组件都遵循了规范,组件也难以察觉被注入的数据是由哪些高阶组件提供的,而且高阶组件之间互相隔离,导致可能存在覆盖 props 的危险情况,这些问题高阶组件都束手无策。这体现出约定比约束更加效率,但约定的可维护性低于约束。 因此更好的解决思路可能是叫做 render props render callback function as child 这些名字的方法,组件定义如下: // Contrived example for simplicityimport React, { Component } from 'react';import PropTypes from 'prop-types';class Caffeinate extends Component { propTypes = { children: PropTypes.func.isRequired }; state = { coffee: "Americano" }; render() { return this.props.children(this.state.coffee); }} 直接将函数作为子元素,可以认为是一个匿名组件: render( <Caffeinate> {(beverage) => <div>Drinking an {beverage}.</div>} </Caffeinate>, document.querySelector("##root"));//=> Drinking an Americano. 这种用法在 React Motion React Router 里都有采用。 3 精读本质是将组件作为参数我们看另一种写法: import React, { Component } from 'react';import PropTypes from 'prop-types';class Caffeinate extends Component { propTypes = { children: PropTypes.func.isRequired }; state = { coffee: "Americano" }; render() { return this.props.child(this.state.coffee); }}// usagerender( <Caffeinate child={ (beverage) => <div>Drinking an {beverage}.</div> } />, document.querySelector("##root")); render props 本质上与上面这种很常规的写法没什么不同,差异在于利用了 props.children,将参数写在 JSX 的子元素中。相比高阶组件用法,这样嵌套下来,看得清楚数据流动,解决了高阶组件反复嵌套导致的各类问题: render( <RenderProps1> {(title) => <div> <h1>{title}</h1> <RenderProps2> {(name) => <RenderProps3> {(age) => { <div>{name}, {age}</div> }} </RenderProps3> } </RenderProps2> </div>} </RenderProps1>) 与高阶组件对比与 HOC 相比,render props 开放性提升明显,原本 HOC 所做的功能抽象可通过 render Props 获取,而 render 也可以访问到父级的一切: Render Props 存在的问题 this.props.children 不该作为函数调用。 渲染粒度变大,表格等需要性能优化的场景不适合。 renderProps 渲染的并不是 React 组件,无法为其单独使用 redux,mobx dob 等依赖收集粒度也放不下去。 renderProps 为了解耦,让控制权从上到下传递,而底层实现不需要了解上层实现,这是解决 JSX 修改组件模版问题的方法之一,作为优化点之一,可以考虑让传入的 props 自身作为一个组件: const View = ({title}) => <div>{title}</div>// ...render() { return ( <Component view={View} /> )} 简约即美与其绕那么大一圈,还不如回归到最普通的 props 传参,这说明 renderProps 作为其中一种特例,可能观赏价值大于其实用价值。其控制放权的思想也是为了解决组件 dom 结构定制化的问题。 但是这也涉及到限度的问题,以下就是两种极端: render() { return this.props.children} render() { return ( <div> <Header /> <Sidebar> <Toolbox> <ul> <li>..</li> <li>{this.props.secondLi}</li> <li>..</li> </ul> </Toolbox> </Sidebar> <Footer /> </div> )} 可以看出,写出这两种代码的目的,都为了从外部控制组件结构,以至于最大限度提高组件的复用能力。其实很难在不了解组件自身含义时,妄下一个通用的结论,说 “你只要这么写,就能保证任何组件都非常通用”。 比如 Card 组件可以将 title extra 设定为 ReactNode,加上 children,其实用性已经足够了: render() { return ( <Container> <Title> {this.props.title} {this.props.extra} </Title> <Body> {this.props.children} </Body> </Container> )} 再比如 Modal 也只需要对 Header Footer children 支持 ReactNode 就可以保证足够的通用性。 在业务场景,由于代码修改频率较高,复用性重要程度就没那么高。 4. 总结作者也提到了,高阶组件在某些场景很有用,所以不会完全拒绝使用。 在不为组件做注入的场景下是高阶组件的好场景,利用其生命周期实现权限、埋点,在层级少的时候用作依赖注入也非常方便。 其实程序员在思考这些最佳实践时,与艺术家的思考方式很类似,况且这些最佳实践在不同场景、不同团队,不同项目下都有所侧重,所以不用逮着所谓最完美的实践把代码全部重构,以后也全部用一种风格写代码。就像陶瓷艺术家也不会说:我再也不做彩瓷了,因为白瓷这种颜色非常简约,在我心中是完美的,因此我宁愿一辈子只做白瓷。 这一期也想表达一个积极含义,精读周刊是不会 give up 的! 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《我在阿里数据中台大前端》","path":"/wiki/WebWeekly/前沿技术/《我在阿里数据中台大前端》.html","content":"当前期刊数: 134 1 引言当下互联网行业里面最流行的就是 ABC: A: AI 人工智能 B: BIG DATA C: CLOUD 而阿里经济体中的 ABC,其中的 BIG DATA,即是我们 DT https://dt.alibaba.com/ ,我们用大数据赋能商业,创造价值。 而我们说数据中台,其实阿里提出的中台只有两个:业务中台与数据中台。业务中台的目的是让业务能够快速落地,数据中台的目的是完成数据的采集、建设、管理、使用这四个环节,让数据从生产到使用过程变得丝般顺滑,不仅不让数据资产成为累赘,还会最大限度发挥出数据潜藏的价值。 笔者所在的就是数据中台的大前端团队,既为阿里经济体提供数据服务,又着力为上云企业打造属于自己的数据中台,处在前端技术、商业模式、产品设计的最前沿,且听我慢慢道来。 2 精读全链路数据能力从能力上看,数据中台处理数据的方方面面,从数据产生开始就进行追踪,不仅打通了数据采集、存储、处理、查询、消费的全链路,还用以下几种方式赋能业务:研发数据管理平台并监控数据质量,研发生意参谋等数据分析产品直接服务大、中、小商家,提供统一数据服务标准化数据使用流程,将数据分析的算法能力服务化,将支撑内部的数据服务上云搭建客户自己的数据中台,研发 BI 平台完成数据决策的最后一环。 全链路数据技术从技术架构上看,从底层的数据采集技术开始,逐步向上建设了数据计算与管理能力、数据服务、数据平台、数据应用与数据安全。 从使用者角度来看,现在的公司对数据的诉求可以概括为以下几点: 数据从哪来,如何完全数字化:对应全链路数据采集服务。 如何得到想要的数据:数据计算、建模与管理服务。 如何使用数据:统一数据服务平台。 如何利用数据做商业决策:BI 平台。 如何保障数据安全:数据安全服务。 对阿里而言,还会额外考虑下面几点: 如何让数据服务横向支撑所有业务线:数据服务平台化,数据智能化服务平台与 BI 平台。 如何让数据服务普惠到每一个企业:数据服务全面上云。 如何让数据服务更有价值:打通阿里经济体的数据体系,让数据相互产生化学反应。 当然,挑战性也非常大,首先是数据壁垒的挑战,要说服其他团队将数据交给你管理绝非易事。其次是价值挑战,如何证明数据中台存在的价值,并做到肉眼可见的业务增值。最后是技术挑战,对前端来说,几十款数据产品的搭建、几十万张数据报表的搭建,需要一个足够好用的数据产品搭建平台来支持;数据分析产品的下一代探索式分析也对 BI 引擎提出了新的要求;数据可视化远比普通可视化复杂,不仅要考虑大数据下的性能与可读性,还要理解商业,做出能体现数据分析价值的图表。 不论是数据搭建还是数据可视化,都是前端垂直领域的另一条好赛道,不仅有沉甸甸的业务价值,还有全新数据领域的的前端技术挑战,而且随着数据中台影响力的持续扩大,我们的前端技术也会带来业界越来越大的影响力。 如何建设和管理数据想要数据用的好,首先要管的好,在大数据时代,企业必须建立一套自己的标准数仓系统对数据的采集、运维调度做全链路管理,让大数据变成好数据,让好数据可以发挥价值。 Dataphin 数仓建设平台。 数仓的建设需要从物理空间与逻辑空间,也就是底层的表开始整理,通过对数据的采集、清洗、结构化,产出一套规范的数据定义。 所谓规范的数据定义即口径、算法、命名均一致的数据规范,降低数据二义性,提升数据查找效率与准确性。之后对数据建模,建模即是对数据的进一步抽象,可能是抽象为一个 Cube 模型,这样在顶层认知上,所有数据都是不同维度的 Cube,方便统一理解。 最后通过对数据进行在线的、离线的调度计算,产出数据资产。 如何看数据或导出一个 Excel 文件仔细品味,或如双十一媒体大屏般夺目,或如股票操盘手般紧盯着屏幕,或随时随地的手机浏览。在哪看,怎么看,看什么,决定着同一份数据可带来不同的效果,产生不同的价值。 稳:双十一大屏,零点起得来,24 点收得住,每个彩蛋的出现,每个数字的跳动,如丝般顺滑,这不是播放 VCR,每一帧画面都是真实的数据展现。容:即是生意参谋用户的浏览器兼容,又是多端用户的兼容,也是 BI 分析结果的数据大容量。有容乃大,方显前端功底。 “如何看数据” 这恰是做为数据前端人的使命和责任。 不同的人,不同的端,不同的需求,这恰是给数据前端的挑战。而让用户透过数据创造价值,也正是数据前端人的价值。 如何分析数据大数据浪潮之下,必然会诞生各式各样的数据产品,产品化的方式可以降低数据应用的门槛。我们希望人人都能成为数据分析师,于是 BI (商业智能)产品应运而生,作为大数据行业中的一个重要领域,BI 产品用大数据的方式解决了企业的业务分析需求,支撑企业进行数字化转型,从经验驱动决策转变为数据驱动决策,进而给企业带来超额收益。 QuickBI 数据分析工具。 人人都是数据分析师的情况在不断增强。 根据 Gartner 对 2020 年 BI 产品发展趋势预测: 到 2020 年,为用户提供对内部和外部数据策划目录的访问权限的组织将从分析投资中获得两倍的业务价值。 到 2020 年,业务部门的数据和分析专家数量的增速将是 IT 部门专家的 3 倍,这会迫使企业重新考虑其组织模式和技能。 到 2021 年,自然语言处理和会话分析这两个功能,会在新用户、特别是一线工作人员中,将分析和商业智能产品的使用率从 35% 提升到 50% 以上。 快速增涨的市场规模。 根据中国电子信息产业发展研究院发布的《中国大数据产业发展水平评估报告》,预计 2019 年我国大数据核心产业规模突破 5700 亿元,未来 2-3 年的市场规模的增长率仍将保持 35% 左右。未来切入这部分应用环节,BI 商业智能的潜在市场规模将在数百亿的市场空间。 大数据与前端。 前端的职业发展除了提升自己的技能技术储备之外,选择合适行业方向和研究领域也尤为重要。如果用路和车的关系来比喻的话,把前端技能比作车的话,各个行业都是路,有的路是乡间小路,有的路是城乡公路,而大数据行业当之无愧是行业中的上高速公路,路况更好,路面更宽,如果你拥有一辆好车,为什么不来高速公路上飞驰呢? 大数据下的前端面临哪些挑战?以 BI 为例,BI 领域的四大方向:数据集、渲染引擎、数据模型与可视化都有许多可以做深的技术点,每一块都需要深入沉淀几年技术经验才能做好,需要大量优秀人才通力协作才有可能做好。你也可以阅读 精读《前端与 BI》 了解更多 BI 相关知识。 我们是数据中台大前端 “ 前端不是因为我们用 JavaScript,而是因为我们站在业务最前端,解决业务端的问题,所以我们是前端 ”。 BI 分析产品、做数据可视化、做产品搭建 .. 我们早已经跳出了“前端”的传统概念范畴。我们做大数据表格优化、 Web Excel、 SQL 编辑器、智能可视化。在数据中台,我们有着天然的复杂业务场景和海量数据优势,迫使你向自己提出更大的挑战来解决业务上的问题。如果你热爱挑战、热爱技术,请加入我们吧。 在这里,你可以愉快的使用 React、TypesScript 写业务代码,尝试最新、最炫酷的 React Hooks 新特性,我们团队一直走在前端技术路线的最前沿,渴求技术创新。 你也不需要担心伙伴的代码风格问题,因为我们有着严格的代码规;你不必担心每个人的代码都是一座孤岛,因为我们会对每一行代码做严格的 review;你不必担心你的成长空间,我们有定期的技术分享、团队内小竞赛,还有足够复杂的业务场景支撑;你也不必担心你会因工作日渐消瘦,下午茶和海量小零食等你来! 4 总结大数据前端人才缺口在 100 人以上,由于业务增长非常非常迅猛,春节前条件放宽、特批急召! 如果你对我们感兴趣,请立刻把简历发送到邮箱 ziyi.hzy@alibaba-inc.com 吧!绝无仅有的好机会,响应速度绝对超乎你的想象! 讨论地址是:精读《我在阿里数据中台大前端》 · Issue ##224 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《手写 JSON Parser》","path":"/wiki/WebWeekly/前沿技术/《手写 JSON Parser》.html","content":"当前期刊数: 139 1 引言JSON.parse 是浏览器内置的 API,但如果面试官让你实现一个怎么办?好在有人已经帮忙做了这件事,本周我们一起精读这篇 JSON Parser with Javascript 文章吧,再温习一遍大学时编译原理相关知识。 2 概述 & 精读要解析 JSON 首先要理解语法概念,之前的 精读《手写 SQL 编译器 - 语法分析》 系列也有介绍过,不过本文介绍的更形象,看下面这个语法图: 这是关于 Object 类型的语法描述图,从左向右看,根据箭头指向只要能走出这个迷宫就属于正确语法。 比如第一行 { → whitespace → } 表示 { } 属于合法的 JSON 语法。 再比如观察向下的一条最长路线:{ → whitespace → string → whitespace → : → value → } 表示 { string : value } 属于合法的 JSON 语法。 你可能会问,双引号去哪儿了?这就是语法树最核心的概念了,这张图是关于 Object 类型的 产生式,同理还有 string、value 的产生式,产生式中可以嵌套其他产生式,甚至形成环路,以此拥有描述纷繁多变语法的能力。 最后我们再看一个环路,即 { → whitespace → string … , → whitespace → string … , … },我们发现,只要不走回头路,这条路是可以一直 “绕圈” 下去的,因此 Object 类型拥有了任意数量子字段的能力,只是每形成一个子字段,必须经过 , 号分割。 实现 Parser首先实现一个基本结构: function fakeParseJSON(str) { let i = 0; // TODO} i 表示访问字符的下标,当 i 走到字符串结尾表示遍历结束。 然后是下一步,用几个函数描述解析语法的过程: 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 编译器 - 语法分析》 系列文章了解更多。 由于 Object 第一个元素前面不允许加逗号,因此可以利用 initial 做一个初始化判定,在初始时机不会吃掉逗号: 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: 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 即可: 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 的语法图: 我们只需要吃逗号和 parseValue 即可: 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 是许多种基础类型的 “或” 关系组成的: 我们只需要继续拆解分析即可: 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: function fakeParseJSON(str) { // ... function parseKeyword(name, value) { if (str.slice(i, i + name.length) === name) { i += name.length; return value; } }} 如上所示,只要在 name 与对应字符相等时,返回第二个传入参数即可。 处理异常输入一个完整的语法解析功能需要包含错误处理,错误的情况主要分两种: 非法字符。 非正常结尾。 原文提到的 JSON 错误提示优化非常棒,想想你在开发中突然看到下面的提示,是不是很蒙圈: Unexpected token "a" 既然我们是自己写的 JSON 解析器,就可以进行更友好的异常提示,比如: // 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 可以查看 原文。 3 总结这篇文章通过一个具体的例子解释如何做语法分析,对于词法解析入门非常直观,如果你想更深入理解语法解析,或者写一个通用语法解析器,可以阅读语法解析系列入门文章,笔者通过实际例子带你一步一步做一个完备的词法解析工具! 语法解析入门系列文章,建议阅读顺序: 精读《手写 SQL 编译器 - 词法分析》 精读《手写 SQL 编译器 - 文法介绍》 精读《手写 SQL 编译器 - 语法分析》 精读《手写 SQL 编译器 - 回溯》 精读《手写 SQL 编译器 - 语法树》 精读《手写 SQL 编译器 - 错误提示》 精读《手写 SQL 编译器 - 性能优化之缓存》 精读《手写 SQL 编译器 - 智能提示》 syntax-parser 这个零依赖的通用语法解析库就是根据上述文章一步一步完成的,看完了上面文章,就彻底理解了这个库的源码。 讨论地址是:精读《手写 JSON Parser》 · Issue ##233 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《持续集成 vs 持续交付 vs 持续部署》","path":"/wiki/WebWeekly/前沿技术/《持续集成 vs 持续交付 vs 持续部署》.html","content":"当前期刊数: 101 一、摘要相信大家以前应该接触过持续集成(Continuous integration)持续交付(continuous delivery)持续发布(continuous deployment)的概念,下面我们来说说三者的差异以及团队如何入手 CI/CD。 作者:猫神。 二、差异2.1 CI 持续集成开发者尽量时时刻刻合并开发分支至主干分支。避免直到发布日才开始合并,掉入集成地狱。无论何时新分支集成至项目,持续集成可以自动化测试持续验证应用是否正常。 2.2 CD 持续交付持续交付是持续集成的扩展,可以保证稳定的发布产品新特性。这意味着基于自动化测试,你可以也可以一键自动化发布。理论上,持续交付可以决定是按天,按周,按双周发布产品。如果确实希望能够享受持续交付的好处,那么应该尽快发布到新产品中。一旦出现问题时能尽早排除。 2.3 CD 持续部署持续部署是持续交付的下一步。通过这一步,每个新特性都自动的部署到产品中。但是如果出现未通过的测试用例将会终止自动部署。持续部署可以加速用户反馈新特性,避免发布日带来的压力。开发可以着力于开发系统,开发结束后几分钟就可以触达到用户。 三、协作CI/CD 具体是个什么样的流程呢,如下图所示,差异仅在于是否自动部署。 现在开发都讲究投入产出比,那么 CI/CD 具体需要做些什么呢? Continuous Intergretion 持续集成投入: 需要为每个新特性编写测试用例 需要搭建持续集成服务器,监控主干仓库,并自动运行测试用例 开发需要尽量频繁的合并分支,至少一天一次 产出: 更少的 bug,因为自动化测试可以回归测试产品 编译部署产品更简化,因为集成的问题都尽早的解决了 开发者可以尽量减少上下文切换,因为构建的时候就暴露问题,尽早解决了 测试成本降低,因为 CI 服务器可以一秒运行几百个测试用例 测试团队花更少的时间测试,可以重点关注测试上的改进。 Continuous delivery 持续交付投入: 需要有持续集成的基础,测试用例需要覆盖足够的代码 部署需要自动化,用户只需要手动触发,剩余的部署应该自动化 团队需要增加新特性标志,避免未完成的新特性进入待发布的产品 产出: 部署软件变得非常简单。团队不需要花费 n 天准备发布。 可以提高发布频率,加速新特性触达用户进程。 小的更改,对决策的压力要小得多,可以更快地迭代。 Continuous deployment 持续部署投入: 测试必须要做到足够。测试的质量将决定发布的质量。 文档建设需要和产品部署保持同步。 新特性的发布需要协调其他部门,包括售后支持&市场&推广等。 产出: 快速的发布节奏,因为每个新特性一旦完成都会自动的发布给用户。 发布风险降低,修复问题更容易,因为每次变更都是小步迭代发布。 用户可以看到持续性的优化和质量提升,而不是非要等到按月,按季度,甚至按年 如果开发的是一个新项目,暂时还没有任何用户,那么每次提交代码后发布将会特别简单,可以随时随地发布。一旦产品开始开发后,就需要提高测试文化,并确保在构建应用程序时增加代码覆盖率。当您准备好面向用户发布时,您将有一个非常好的连续部署过程,在该过程中,所有新的更改都将在自动发布到生产环境之前进行测试。 如果正在开发的是一个老系统,就需要放慢节奏,开始打造持续集成&持续交付。首先可以完成一些简单可自动化执行的单元测试,不需要考虑复杂的端到端的测试。另外,应该尽快尝试自动化部署,搭建可以自动化部署的临时环境。因为自动化部署,可以让开发者去优化测试用例,而不是停下来联调发布。一旦开始按日发布产品,我们可以考虑持续部署,但一定要保证团队已经准备好这种方式,文档 & 售后支持 & 市场。这些步骤都需要加入到新产品发布节奏中,因为和用户直接打交道的是他们。 四、如何开始持续集成4.1 了解测试类型为了获得 CI 的所有好处,每次代码变更后,我们需要自动运行测试用例。我们需要在每个分支运行测试用例,而不是仅仅在主干分支。这样可以最快速的找到问题,最小化问题影响面。在初始阶段并不需要实现所有的测试类型。一开始可以以单元测试入手,随着时间扩展覆盖面。 单元测试:范围非常小,验证每个独立方法级别的操作。 集成测试:保证模块间运行正常,包括多个模块、多个服务。 验收测试:与集成测试类似,但是仅关注业务 case,而不是模块内部本身。 UI 测试:从用户的角度保证呈现正确运行。 并不是所有的测试都是对等的,实际运行中可以做些取舍。 单元测试实现起来既快成本又低,因为它们主要是对小代码块进行检查。另一方面,UI 测试实施起来很复杂,运行起来很慢,因为它们通常需要启动一个完整的环境以及多个服务来模拟浏览器或移动行为。因此,实际情况可能希望限制复杂的 UI 测试的数量,并依赖基础上良好的单元测试来快速构建,并尽快获得开发人员的反馈。 4.2 自动运行测试要采用持续集成,您需要对推回到主分支的每个变更运行测试。要做到这一点,您需要有一个服务来监视您的存储库,并听取对代码库的新推送。您可以从企业预置型解决方案和云端解决方案中进行选择。您需要考虑以下因素来选择服务器: 代码托管在哪里?CI 服务可以访问您的代码库吗?您对代码的生存位置有特殊的限制吗? 应用程序需要哪些操作系统和资源?应用程序环境是否受支持?能安装正确的依赖项来构建和测试软件吗? 测试需要多少资源?一些云应用程序可能对您可以使用的资源有限制。如果软件消耗大量资源,可能希望将 CI 服务器宿主在防火墙后面。 团队中有多少开发人员?当团队实践 CI 时,每天都会将许多更改推回到主存储库中。对于开发人员来说,要获得快速的反馈,您需要减少构建的队列时间,并且您需要使用能够提供正确并发性的服务或服务器。在过去,通常需要安装一个独立的 CI 服务器,如 Bamboo 或 Jenkins,但现在您可以在云端找到更简单的解决方案。例如,如果您的代码托管在 BitBucket 云上,那么您可以使用存储库中的 Pipelines 功能在每次推送时运行测试,而无需配置单独的服务器或构建代理,也无需限制并发性。 使用代码覆盖率查找未测试的代码。一旦您采用了自动化测试,最好将它与一个测试覆盖工具结合起来,帮助了解测试套件覆盖了多少代码库。代码覆盖率定在 80%以上是很好的,但要注意不要将高覆盖率与良好的测试套件混淆。代码覆盖工具将帮助您找到未经测试的代码,但在一天结束的时候,测试的质量会产生影响。如果刚开始,不要急于获得代码库的 100%覆盖率,而是使用测试覆盖率工具来找出应用程序的关键部分,这些部分还没有测试并从那里开始。 重构是一个添加测试的机会。如果您将要对应用程序进行重大更改,那么应该首先围绕可能受到影响的特性编写验收测试。这将为您提供一个安全网,以确保在重构代码或添加新功能后,原始行为不会受到影响。 五、接受 CI 文化自动化测试是 CI 的关键,但同时也需要团队成员接受 CI 文化,并不是心血来潮晒两天鱼,并且需要保证编译畅通无阻。QA 可以帮助团队建设测试文化。他们不再需要手动测试应用程序的琐碎功能,现在他们可以投入更多的时间来提供支持开发人员的工具,并帮助他们采用正确的测试策略。一旦开始采用持续集成,QA 工程师将能够专注于使用更好的工具和数据集促进测试,并帮助开发人员提高编写更好代码的能力。 尽早集成。如果很长时间不合并代码,代码冲突的风险就越高,代码冲突的范围就越广。如果发现某些分支会影响已经存在的分支,需要增加发布关闭标签,避免发布时两个分支冲突。 保证编译时时刻刻畅通。一旦发现任何编译问题,立刻修复,否则可能会带来更多的错误。测试套件需要尽快反馈测试结果,或者优先返回短时间测试(单元测试)的结果,否则开发者可能就切换回开发了。一旦编译出错,需要通知给开发者,或者更进一步给出一个 dashboard,每个人都可以在这里查看编译结果。 把测试用例纳入流程的一部分。确保每个分支都有自动化测试用例。似乎编写测试用例拖慢了项目节奏,但是它可以减少回归时间,减少每次迭代带来的 bug。而且每次测试通过后,将会非常有信息合并到主干分支,因为新增的内容不影响以前的功能。 修 bug 的时候编写测试用例。把 bug 的每个场景都编写成测试用例,避免再次出现。 六、集成测试 5 个步骤 从最严格的代码部分入手测试 搭建一个自动构建的服务自动运行测试用例,在每次提交代码后。 确保团队成员每天合并变更 代码出现问题及时修复 为每个新实现的操作编写测试用例。可能看着很简单,但是要求团队能够真正落地。一开始你需要放慢发布的脚步,需要和 pd、用户沟通确保不上线没有测试用例的新功能。我们的建议是从小处入手,通过简单的测试来适应新的例程,然后再着手实现更复杂更难管理的测试套件。 七、说说笔者的团队以上文章主要是说明团队实现 CI/CD 的取舍和可行性步骤。下面来说说希望 CI/CD 给笔者团队带来什么样的变化。目前笔者团队已经实现前端项目发布编译工程化,采用的是基于 webpack 的自建工具云构建模式。但现在面临的问题是 1. 交互的系统比较多,交互系统提供的接入源变更后,需要人工通知其他系统手动触发编译,而且每次手动编译都需要在本地切换到指定分支,然后手动触发云构建,2. 多人协作,分支拆分较细,需要手动合并分支,触发编译。整个流程冗长,而且中间存在人力沟通成本,容易产生沟通误差。所以首先希望解决的是 CI 自动化,当依赖变更后或者分支合并后,自动集成,自动编译。当然生产环境暂时还不敢瞎搞,但大部分重复编译的工作量主要集中在预发环境,所以手动部署生产环境的成本还是可以接受的。CI 自动化之前,需要提供系统之间交互的单元测试用例,每次 CI 后自动运行单元测试用例,最好能打通 QA 的测试用例,进行回归测试。流程对比如下: 可以看出引入CI后,我们的成本是需要搭建CI服务器,新增单元测试、打通回归测试案例,但前者可以加快系统编译效率,后者可以进一步的提升代码质量,减少回归测试时间,这些成本都是可以接受的。市面上已有很多开源持续集成工具,例如我们熟悉的Jenkins,还有TeamCity、Travis CI、GO CD、Bamboo、Gitlab CI、CircleCI……等等等等。目前还在继续调研中,这片文章应该会有第二篇,说说后续的实践和CD。 讨论地址是:精读《持续集成 vs 持续交付 vs 持续部署》 · Issue ##147 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《插件化思维》","path":"/wiki/WebWeekly/前沿技术/《插件化思维》.html","content":"当前期刊数: 53 本周精读内容是 《插件化思维》。没有参考文章,资料源自 webpack、fis、egg 以及笔者自身开发经验。 1 引言用过构建工具的同学都知道,grunt, webpack, gulp 都支持插件开发。后端框架比如 egg koa 都支持插件机制拓展,前端页面也有许多可拓展性的要求。插件化无处不在,所有的框架都希望自身拥有最强大的可拓展能力,可维护性,而且都选择了插件化的方式达到目标。 我认为插件化思维是一种极客精神,而且大量可拓展、需要协同开发的程序都离不开插件机制支撑。 没有插件化,核心库的代码会变得冗余,功能耦合越来越严重,最后导致维护困难。插件化就是将不断扩张的功能分散在插件中,内部集中维护逻辑,这就有点像数据库横向扩容,结构不变,拆分数据。 2 精读理想情况下,我们都希望一个库,或者一个框架具有足够的可拓展性。这个可拓展性体现在这三个方面: 让社区可以贡献代码,而且即使代码存在问题,也不会影响核心代码的稳定性。 支持二次开发,满足不同业务场景的特定需求。 让代码以功能为纬度聚合起来,而不是某个片面的逻辑结构,在代码数量庞大的场景尤为重要。 我们都清楚插件化应该能解决问题,但从哪下手呢?这就是笔者希望分享给大家的经验。 做技术设计时,最好先从使用者角度出发,当设计出舒服的调用方式时,再去考虑实现。所以我们先从插件使用者角度出发,看看可以提供哪些插件使用方式给开发者。 2.1 插件化分类插件化许多都是从设计模式演化而来的,大概可以参考的有:命令模式,工厂模式,抽象工厂模式等等,笔者根据个人经验,总结出三种插件化形式: 约定/注入插件化。 事件插件化。 插槽插件化。 最后还有一个不算插件化实现方式,但效果比较优雅,姑且称为分形插件化吧。下面一一解释。 2.1.1 约定/注入插件化按照某个约定来设计插件,这个约定一般是:入口文件/指定文件名作为插件入口,文件形式.json/.ts 不等,只要返回的对象按照约定名称书写,就会被加载,并可以拿到一些上下文。 举例来说,比如只要项目的 package.json 的 apollo 存在 commands 属性,会自动注册新的命令行: { "apollo": { "commands": [{ "name": "publish", "action": "doPublish" }] }} 当然 json 能力很弱,定义函数部分需要单独在 ts 文件中完成,那么更广泛的方式是直接写 ts 文件,但按照文件路径决定作用,比如:项目的 ./controllers 存在 ts 文件,会自动作为控制器,响应前端的请求。 这种情况根据功能类型决定对 ts 文件代码结构的要求。比如 node 控制器这层,一个文件要响应多个请求,而且逻辑单一,那就很适合用 class 的方式作为约定,比如: export default class User { async login(ctx: Context) { ctx.json({ ok: true }); }} 如果功能相对杂乱,没有清晰的功能入口规划,比如 gulp 这种插件,那用对象会更简洁,而且更倾向于用一个入口,因为主要操作的是上下文,而且只需要一个入口,内部逻辑种类无法控制。所以可能会这样写: export default (context: Context) => { // context.sourceFiles.xx}; 举例:fis、gulp、webpack、egg。 2.1.2 事件插件化顾名思义,通过事件的方式提供插件开发的能力。 这种方式的框架之间跨界更大,比如 dom 事件: document.on("focus", callback); 虽然只是普通的业务代码,但这本质上就是插件机制: 可拓展:可以重复定义 N 个 focus 事件相互独立。 事件相互独立:每个 callback 之间互相不受影响。 也可以解释为,事件机制就是在一些阶段放出钩子,允许用户代码拓展整体框架的生命周期。 service worker 就更明显,业务代码几乎完全由一堆事件监听构成,比如 install 时机,随时可以新增一个监听,将 install 时机进行 delay,而不需要侵入其他代码。 在事件机制玩出花样的应该算 koa 了,它的中间件洋葱模型非常有名,换个角度理解,可以认为是能控制执行时机的事件插件化,也就是只要想把执行时机放在所有事件执行完毕时,把代码放在 next() 之后即可,如果想终止插件执行,可以不调用 next()。 举例:koa、service worker、dom events。 2.1.3 插槽插件化这种插件化一般用在对 UI 元素的拓展。react 的内置数据流是符合组件物理结构的,而 redux 数据流是符合用户定义的逻辑结构,那么对于 html 布局来说也是一样:html 默认布局是物理结构,那插槽布局方式就是 html 中的 redux。 正常 UI 组织逻辑是这样的: <div> <Layout> <Header> <Logo /> </Header> <Footer> <Help /> </Footer> </Layout></div> 插槽的组织方式是这样的: { position: "root", View: <Layout>{insertPosition("layout")}</Layout>} { position: "layout", View: [ <Header>{insertPosition("header")}</Header>, <Footer>{insertPosition("footer")}</Footer> ]} { position: "header", View: <Logo />} { position: "footer", View: <Help />} 这样插件中的代码可以不受物理结构的约束,直接插入到任何插入点。 更重要的是,实现了 UI 解耦,父元素就不需要知道子元素的具体实例。一般来说,决定一个组件状态的都是其父元素而不是子元素,比如一个按钮可能在 <ButtonGroup/> 中表现为一种组合态的样式。但不可能说 <ButtonGroup/> 因为有了 <Select/> 作为子元素,自身的逻辑而发生变化的。 这就意味着,父元素不需要知道子元素的实例,比如 Tabs: <Tabs>{insertPosition(`tabs-${this.state.selectedTab}`)}</Tabs> 当然有些情况看似是例外,比如 Tree 的查询功能,就依赖子元素 TreeNode 的配合。但它依赖的是基于某个约定的子元素,而不是具体子元素的实例,父级只需要与子元素约定接口即可。真正需要关心物理结构的恰恰是子元素,比如插入到 Tree 子元素节点的 TreeNode 必须实现某些方法,如果不满足这个功能,就不要把组件放在 Tree 下面;而 Tree 的实现就无需顾及啦,只需要默认子元素有哪些约定即可。 举例:gaea-editor。 2.1.4 分型插件化代表 egg,特点是插件结构与项目结构分型,也就是组成大项目的小插件,自身结构与项目结构相同。 因为对于 node server 的插件来说,要实现的功能应该是项目功能的子集,而本身 egg 对功能是按照目录结构划分的,所以插件的目录结构与项目一致,看起来也很美观。 举例:egg。 当然不是所有插件都能写成目录分形的,这也恰好解释了 egg 与 koa 之间的关系:koa 是 node 框架,与项目结构无关,egg 是基于 koa 上层的框架,将项目结构转化成 server 功能,而插件需要拓展的也是 server 功能,恰好可以用项目结构的方式写插件。 2.2 核心代码如何加载插件一个支持插件化的框架,核心功能是整合插件以及定义生命周期,与功能相关的代码反而可以通过插件实现,下一小节再展开说明。 2.2.1 确定插件加载形式根据 2.1 节的描述,我们根据项目的功能,找到一个合适的插件使用方式,这会决定我们如何执行插件。 2.2.2 确定插件注册方式插件注册方式非常多样,这里举几个例子: 通过 npm 注册:比如只要 npm 包符合某个前缀,就会自动注册为插件,这个很简单,不举例子了。 通过文件名注册:比如项目中存在 xx.plugin.ts 会自动做到插件引用,当然这一般作为辅助方案使用。 通过代码注册:这个很基础,就是通过代码 require 就行,比如 babel-polyfill,不过这个要求插件执行逻辑正好要在浏览器运行,场景比较受限。 通过描述注册:比如在 package.json 描述一个属性,表明了要加载的插件,比如 .babelrc: { "presets": ["es2015"]} 自动注册:比较暴力,通过遍历可能存在的位置,只要满足插件约定的,会自动注册为插件。这个行为比较像 require 行为,会自动递归寻找 node_modules,当然别忘了像 require 一样提供 paths 让用户手动配置寻址起始路径。 2.2.3 确定生命周期确定插件注册方式后,一般第一件事就是加载插件,后面就是根据框架业务逻辑不同而不同的生命周期了,插件在这些生命周期中扮演不同的功能,我们需要通过一些方式,让插件能够影响这些过程。 2.2.4 插件对生命周期的拦截一般通过事件、回调函数的方式,支持插件对生命周期的拦截,最简单的例子比如: document.on("click", callback); 就是让插件拦截了 click 这个事件,当然这个事件与 dom 的生命周期相比微乎其微,但也算是一个微小的生命周期,我们也可以 event.stopPropagation() 阻止冒泡,来影响这个生命周期的逻辑。 2.2.5 插件之间的依赖与通信插件之间难免有依赖关系,目前有两种方式处理,分为:依赖关系定义在业务项目中,与依赖关系定义在插件中。 稍微解释下,依赖关系定义在业务项目中,比如 webpack 的配置,我们在业务项目里是这么配的: { "use": ["babel-loader", "ts-loader"]} 在 webpack 中,执行逻辑是 ts-loader -> babel-loader,当然这个规则由框架说了算,但总之插件加载执行肯定有个顺序,而且与配置写法有关,而且配置需要写在项目中(至少不在插件中)。 另一种行为,将插件依赖写在插件中,比如 webpack-preload-plugin 就是依赖 html-webpack-plugin。 这两种场景各不同,一个是业务有关的顺序,也就是插件无法做主的业务逻辑问题,需要把顺序交给业务项目配置;一种是插件内部顺序,也就是业务无需关心的顺序问题,由插件自己定义就好啦。注意框架核心一般可能要同时支持这两种配置方式,最终决定插件的加载顺序。 插件之间通信也可以通过 hook 或者 context 方式支持,hook 主要传递的是时机信息,而 context 主要传递的是数据信息,但最终是否能生效,取决于上面说到的插件加载顺序。 context 可以拿 react 做个类比,一般都有作用域的,而且与执行顺序严格相关。 hook 等于插件内部的一个事件机制,由一个插件注册。业界有个比较好的实现,叫 tapable,这里简单介绍一下。 利用 tapable 在 A 插件注册新 hook: const SyncWaterfallHook = require("tapable").SyncWaterfallHook;compilation.hooks.htmlWebpackPluginAlterChunks = new SyncWaterfallHook([ "chunks", "objectWithPluginRef"]); 在 A 插件某个地方使用此 hook,实现某个特定业务逻辑。 const chunks = compilation.hooks.htmlWebpackPluginAlterChunks.call(chunks, { plugin: self}); B 插件可以拓展此 hook,来改变 A 的行为: compilation.hooks.htmlWebpackPluginAlterChunks.tap( "HtmlWebpackIncludeSiblingChunksPlugin", chunks => { const ids = [] .concat(...chunks.map(chunk => [...chunk.siblings, chunk.id])) .filter(onlyUnique); return ids.map(id => allChunks[id]); }); 这样,A 拿到的 chunks 就被 B 修改掉了。 2.3 核心功能的插件化2.2 开头说到,插件化框架的核心代码主要功能是对插件的加载、生命周期的梳理,以及实现 hook 让插件影响生命周期,最后补充上插件的加载顺序以及通信,就比较完备了。 那么写到这里,衡量代码质量的点就在于,是不是所有核心业务逻辑都可以由插件完成?因为只有用插件实现核心业务逻辑,才能检验插件的能力,进而推导出第三方插件是否拥有足够的拓展能力。 如果核心逻辑中有一部分代码没有通过插件机制编写,不仅让第三方插件也无法拓展此逻辑,而且还不利于框架的维护。 所以这主要是个思想,希望开发者首先明确哪些功能应该做成插件,以及将哪些插件固化为内置插件。 笔者认为应该提前思考清楚三点: 2.3.1 哪些插件需要内置这个是业务相关的问题,但总体来看,开源的,基础功能以及体现核心竞争力的可以内置,可以开源与核心竞争力都比较好理解,主要说下基础功能: 基础功能就是一个业务的架子。因为插件机制的代码并不解决任何业务问题,一个没有内置插件的框架肯定什么都不是,所以选择基础功能就尤为重要。 举个例子,比如做构建工具,至少要有一个基本的配置作为模版,其他插件通过拓展这个配置来修改构建效果。那么这个基本配置就决定了其他插件可以如何修改它,也决定了这个框架的配置基调。 比如:create-react-app 对 dev 开发时的模版配置。如果没有这个模版,本地就无法开发,所以这个插件必须内置,而且需要考虑如何让其他插件对其拓展,这个在 2.3.2 节详细说明。 另一种情况就是非常基本,而又不需要再拓展加工的可以做成内置插件,比如 babel 对 js 模块的 commonjs 分析逻辑就不需要暴露出来,因为这个标准已经确定,既不需要拓展,又是 babel 运行的基础,所以肯定要内置。 2.3.2 插件是依赖型还是完全正交的功能完全正交的插件是最完美的,因为它既不会影响其他插件,也不需要依赖任何插件,自身也不需要被任何插件拓展。 在写非正交功能的插件时就要担心了,我们还是分为三个点去看: 2.3.2.1 依赖其他插件的插件举个例子,比如插件 X 需要拓展命令行,在执行 npm start 时统计当前用户信息并打点。那么这个插件就要知道当前登陆用户是谁。这个功能恰好是另一个 “用户登陆” 插件完成的,那么插件 X 就要依赖 “用户登陆” 插件了。 这种情况,根据 2.2.5 插件依赖小节经验,需要明确这个插件是插件级别依赖,还是项目级别依赖。 当然,这种情况是插件级别依赖,我们把依赖关系定义在插件 X 中即可,比如 package.json: "plugin-dep": ["user-login"] 另一种情况,比如我们写的是 babel-loader 插件,它在 ts 项目中依赖 ts-loader,那只能在项目中定义依赖了,此时需要补充一些文档说明 ts 场景的使用顺序。 2.3.2.2 依赖并拓展其他插件的插件如果插件 X 在以来 “用户登陆” 插件的基础上,还要拓展登陆时获取的用户信息,比如要同时获取用户的手机号,而 “用户登陆” 插件默认并没有获取此信息,但可以通过扩展方式实现,插件 X 需要注意什么呢? 首先插件 X 最好不要减少另一个插件的功能(具体拓展方式,参考 2.2.5 节,这里假设插件都比较具有可拓展性),否则插件 X 可能破坏 “用户登录” 插件与其他插件之间的协作。 减少功能的情况非常普遍,为了加深理解,这里举一个例子:某个插件直接 pipeTemplate 拓展模版内容,但插件 X 直接返回了新内容,而没有 concat 原有内容,就是减少了功能。 但也不是所有情况都要保证不减少功能,比如当缺少必要的配置项时,可以直接抛出异常,提前终止程序。 其次,要确保增加的功能尽可能少的与其他插件产生可能的冲突。拿拓展 webpack 配置举例,现在要拓展对 node_modules js 文件的处理,让这些文件过一遍 babel。 不好的做法是直接修改原有对 js 的 rules,增加一项对 node_modules 的 include,以及 babel-loader。因为这样会破坏原先插件对项目内 js 文件的处理,可能项目的 js 文件不需要 babel 处理呢? 比较好的做法是,新增一个 rules,单独对 node_modules 的 js 文件处理,不要影响其他规则。 2.3.2.3 可能被其他插件拓展的插件这点是最难的,难在如何设计拓展的粒度。 由于所有场景都类似,我们拿对模版的拓展举例子,其他场景可以类比:插件 X 定义了入口文件的基础内容,但还要提供一些 hook 供其他插件修改入口文件。 假设入口文件一般是这样的: import * as React from "react";import * as ReactDOM from "react-dom";import { App } from "./app";ReactDOM.render(<App />, document.getELementById("root")); 这种最简单的模版,其实内部要考虑以下几点潜在拓展需求: 在某处需要插入其他代码,怎么支持? 如何保证插入代码的顺序? 用 react-lite 替换 react,怎么支持? dev 模式需要用 hot(App) 替换 App 作为入口,怎么支持? 模版入口 div 的 id 可能不是 root,怎么支持? 模版入口 div 是自动生成的,怎么支持? 用在 reactNative,没有 document,怎么支持? 后端渲染时,需要用 ReactDOM.hydrate 而不是 ReactDOM.render,怎么支持? 以上 8 种场景可能会不同组合,需要保证任意组合都能正确运行,所以无法全量模版替换,那怎么办? 笔者此处给出一种解决方案,供大家参考。另外要注意,这个方案随着考虑到的使用场景增多,是要不断调整变化的。 get( "entry", ` ${get("importBefore", "")} ${get("importReact", `import * as React from "react"`)} ${get("importReactDOM", `import * as ReactDOM from "react-dom"`)} import { App } from "./app" ${get("importAfter", "")} ${get("renderMethod", `ReactDOM.render`)}(${get( "renderApp", "<App/>" )}, ${get("rootElement", `document.getELementById("root")`)}) ${get("renderAfter", "")}`); 以上八种情况读者脑补一下,不详细说明了。 2.3.3 内置插件如何与第三方插件相处内置的插件与第三方插件的冲突点在于,内置插件如果拓展性很差,那还不如不要内置,内置了反而阻碍第三方插件的拓展。 所以参考 2.3.2.3 节,为内置插件考虑最大化的拓展机制,才能确保内置插件的功能不会变成拓展性瓶颈。 每新增一个内置的插件,都在消灭一部分拓展能力,因为由插件拓展后的区块拥有的拓展能力,应该是逐渐减弱的。这里比较拗口,可以比喻为,一条小溪流,插件就是层层的水处理站,每新增一个处理站就会改变下游水势变化,甚至可能将水拦住,下游一滴水也拿不到。 2.3.1 节说了哪些插件需要内置,而这一节想说明的是,谨慎增加内置插件数量,因为内置的越多,框架拓展能力就越弱。 2.4 哪些场景可以插件化最后梳理下插件化适用场景,笔者根据有限的经验列出一下一些场景。 2.4.1 前后端框架如果你要做一个前/后端开发框架,插件化是必然,比如 react 的生命周期,koa 的中间件,甚至业务代码用到的 request 处理,都是插件化的体现。 2.4.2 脚手架支持插件化的脚手架具有拓展性,社区方便提供插件,而且脚手架为了适配多种代码,功能可插拔是非常重要的。 2.4.3 工具库一些小的工具库,比如管理数据流的 redux 提供的中间件机制,就是让社区贡献插件,完善自身的功能。 2.4.4 需要多人协同的复杂业务项目如果业务项目很复杂,同时又有多人协作完成,最好按照功能划分来分工。但是分工如果只是简单的文件目录分配方式,必然导致功能的不均匀,也就是每个人开发的模块可能不能访问所有系统能力,或者涉及到与其他功能协同时,文件相互引用带来代码的耦合度提高,最终导致难以维护。 插件化给这种项目带来的最大优势就是,每一个人开发的插件都是一个拥有完整功能的个体,这样只需要关心功能的分配,不用担心局部代码功能不均衡;插件之间的调用框架层已经做掉了,所以协同不会发生耦合,只需要申明好依赖关系。 插件化机制良好的项目开发,和 git 功能分支开发的体验有相似之处,git 给每个功能或需求开一个分支,而插件化可以让每个功能作为一个插件,而 git 功能分支之间是无关联的,所以只有功能之间正交的需求才能开多个分支,而插件机制可以考虑到依赖情况,进行更复杂的功能协同。 3 总结现在还没有找到对插件化系统化思考的文章,所以这一篇算是抛砖引玉,大家一定有更多的框架开发心得值得分享。 同时也想借这篇文章提高大家对插件化必要性的重视,许多情况插件化并不是小题大做,因为它能带来更好的分工协作,而分工的重要性不言而喻。 4 更多讨论 讨论地址是:精读《插件化思维》 · Issue ##75 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《捕获所有异步 error》","path":"/wiki/WebWeekly/前沿技术/《捕获所有异步 error》.html","content":"当前期刊数: 209 成熟的产品都有较高的稳定性要求,仅前端就要做大量监控、错误上报,后端更是如此,一个未考虑的异常可能导致数据错误、服务雪崩、内存溢出等等问题,轻则每天焦头烂额的处理异常,重则引发线上故障。 假设代码逻辑没有错误,那么剩下的就是异常错误了。 由于任何服务、代码都可能存在外部调用,只要外部调用存在不确定性,代码就可能出现异常,所以捕获异常是一个非常重要的基本功。 所以本周就精读 How to avoid uncaught async errors in Javascript 这篇文章,看看 JS 如何捕获异步异常错误。 概述之所以要关注异步异常,是因为捕获同步异常非常简单: try { ;(() => { throw new Error('err') })()} catch (e) { console.log(e) // caught} 但异步错误却无法被直接捕获,这不太直观: try { ;(async () => { throw new Error('err') // uncaught })()} catch (e) { console.log(e)} 原因是异步代码并不在 try catch 上下文中执行,唯一的同步逻辑只有创建一个异步函数,所以异步函数内的错误无法被捕获。 要捕获 async 函数内的异常,可以调用 .catch,因为 async 函数返回一个 Promise: ;(async () => { throw new Error('err')})().catch((e) => { console.log(e) // caught}) 当然也可以在函数体内直接用 try catch: ;(async () => { try { throw new Error('err') } catch (e) { console.log(e) // caught }})() 类似的,如果在循环体里捕获异常,则要使用 Promise.all: try { await Promise.all( [1, 2, 3].map(async () => { throw new Error('err') }) )} catch (e) { console.log(e) // caught} 也就是说 await 修饰的 Promise 内抛出的异常,可以被 try catch 捕获。 但不是说写了 await 就一定能捕获到异常,一种情况是 Promise 内再包含一个异步: new Promise(() => { setTimeout(() => { throw new Error('err') // uncaught }, 0)}).catch((e) => { console.log(e)}) 这个情况要用 reject 方式抛出异常才能被捕获: new Promise((res, rej) => { setTimeout(() => { rej('err') // caught }, 0)}).catch((e) => { console.log(e)}) 另一种情况是,这个 await 没有被执行到: const wait = (ms) => new Promise((res) => setTimeout(res, ms));(async () => { try { const p1 = wait(3000).then(() => { throw new Error('err') }) // uncaught await wait(2000).then(() => { throw new Error('err2') }) // caught await p1 } catch (e) { console.log(e) }})() p1 等待 3s 后抛出异常,但因为 2s 后抛出了 err2 异常,中断了代码执行,所以 await p1 不会被执行到,导致这个异常不会被 catch 住。 而且有意思的是,如果换一个场景,提前执行了 p1,等 1s 后再 await p1,那异常就从无法捕获变成可以捕获了,这样浏览器会怎么处理? const wait = (ms) => new Promise((res) => setTimeout(res, ms));(async () => { try { const p1 = wait(1000).then(() => { throw new Error('err') }) await wait(2000) await p1 } catch (e) { console.log(e) }})() 结论是浏览器 1s 后会抛出一个未捕获异常,但再过 1s 这个未捕获异常就消失了,变成了捕获的异常。 这个行为很奇怪,当程序复杂时很难排查,因为并行的 Promise 建议用 Promise.all 处理: await Promise.all([ wait(1000).then(() => { throw new Error('err') }), // p1 wait(2000),]) 另外 Promise 的错误会随着 Promise 链传递,因此建议把 Promise 内多次异步行为改写为多条链的模式,在最后 catch 住错误。 还是之前的例子,Promise 无法捕获内部的异步错误: new Promise((res, rej) => { setTimeout(() => { throw Error('err') }, 1000) // 1}).catch((error) => { console.log(error)}) 但如果写成 Promise Chain,就可以捕获了: new Promise((res, rej) => { setTimeout(res, 1000) // 1}) .then((res, rej) => { throw Error('err') }) .catch((error) => { console.log(error) }) 原因是,用 Promise Chain 代替了内部多次异步嵌套,这样多个异步行为会被拆解为对应 Promise Chain 的同步行为,Promise 就可以捕获啦。 最后,DOM 事件监听内抛出的错误都无法被捕获: document.querySelector('button').addEventListener('click', async () => { throw new Error('err') // uncaught}) 同步也一样: document.querySelector('button').addEventListener('click', () => { throw new Error('err') // uncaught}) 只能通过函数体内 try catch 来捕获。 精读我们开篇提到了要监控所有异常,仅通过 try catch、then 捕获同步、异步错误还是不够的,因为这些是局部错误捕获手段,当我们无法保证所有代码都处理了异常时,需要进行全局异常监控,一般有两种方法: window.addEventListener('error') window.addEventListener('unhandledrejection') error 可以监听所有同步、异步的运行时错误,但无法监听语法、接口、资源加载错误。而 unhandledrejection 可以监听到 Promise 中抛出的,未被 .catch 捕获的错误。 在具体的前端框架中,也可以通过框架提供的错误监听方案解决部分问题,比如 React 的 Error Boundaries、Vue 的 error handler,一个是 UI 组件级别的,一个是全局的。 回过头来看,本身 js 提供的 try catch 错误捕获是非常有效的,之所以会遇到无法捕获错误的经常,大多是因为异步导致的。 然而大部分异步错误,都可以通过 await 的方式解决,我们唯一要注意的是,await 仅支持一层,或者说一条链的错误监听,比如这个例子是可以监听到错误的: try { await func1()} catch (err) { // caught}async function func1() { await func2()}async function func2() { throw Error('error')} 也就是说,只要这一条链内都被 await 住了,那么最外层的 try catch 就能捕获异步错误。但如果有一层异步又脱离了 await,那么就无法捕获了: async function func2() { setTimeout(() => { throw Error('error') // uncaught })} 针对这个问题,原文也提供了例如 Promise.all、链式 Promise、.catch 等方法解决,因此只要编写代码时注意对异步的处理,就可以用 try catch 捕获这些异步错误。 总结关于异步错误的处理,如果还有其它未考虑到的情况,欢迎留言补充。 讨论地址是:精读《捕获所有异步 error》· Issue ##350 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《数据搭建引擎 bi-designer API-设计器》","path":"/wiki/WebWeekly/前沿技术/《数据搭建引擎 bi-designer API-设计器》.html","content":"当前期刊数: 164 bi-designer 是阿里数据中台团队自研的前端搭建引擎,基于它开发了阿里内部最大的数据分析平台,以及阿里云上的 QuickBI。 bi-designer 目前没有开源,因此文中使用的私有 npm 源 @alife/bi-designer 是无法在公网访问的。 本文介绍 bi-designer 设计器的使用 API。 bi-designer 设计有如下几个特点: 心智统一:编辑模式与渲染模式统一。 通用搭建:支持接入任意通用 npm 组件。 低入侵:围绕数据分析能力做了增强,但对组件代码无入侵。 渲染画布做搭建,第一步是将画布渲染出来,需要用到 Designer 与 Canvas 组件: import { Designer, Canvas } from '@alife/bi-designer'export () => ( <Designer> <Canvas /> </Designer>) Designer:数据容器,用于管理渲染引擎数据流。 参数 defaultPageSchema:页面 DSL 默认值。 参数 defaultMode:控制编辑渲染状态,edit or render。 Canvas:渲染画布的所有组件,会根据 DSL 结构将组件一一渲染出来。 编辑模式编辑模式 = 渲染画布(编辑模式)+ 拓展一些自定义面板。 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 的结构大致如下: { "componentInstances": { "1": { "id": "1", "componentName": "root", }, "2": { "id": "2", "parentId": "1", "componentName": "button", } }} 根据 id parentId 关系描述了组件父子关系,对于同一个父节点在流式布局下的顺序,还会增加 index 标记顺序。 注册组件DSL 描述信息中最重要的是 componentName,为了告诉渲染引擎这个组件是什么,我们需要将组件元信息(componentMetas)传递给 Designer: 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 属性定义: 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 中某个组件信息: 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 作为组件配置面板,这个配置面板中,最重要的是这块代码: 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 设置主题风格: import { Designer } from '@alife/bi-designer'const App = () => ( <Designer defaultPageSchema={{ theme: { primaryColor: '##333' } }} />) 我们也可以在运行时使用 setTheme 动态修改主题风格,做到动态切换主题: const { setTheme, theme } = useDesigner();return <Button onClick={() => { setTheme({ ...theme, primaryColor: '##ffffff' })}} /> 这些主题颜色,组件可以通过 css 变量拿到: .ok-button { color: var(--primaryColor);} 获取组件数据数据分析引擎中,组件是由数据驱动展示的,这些数据可能来自 OLAP 数据集,或者普通 URL 接口,但无论如何数据都是一个组件重要组成部分,因此对组件的取数与数据操作是 bi-designer 的一个重点。 可以利用 fetchStateSelector 获取任意组件的数据信息,包括取数状态、数据、是否有查询错误等: 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 加载一个画布中已有组件,与其共享同一套数据: import { ComponentLoader } from '@alife/bi-designer'const App = () => { return <ComponentLoader id="some-id-already-exist" />} 加载一个额外的新组件如果这个组件不需要响应事件,只是做简单的渲染,那就不需要记录到数据流中,此时仅申明 componentName 即可: import { ComponentLoader } from '@alife/bi-designer'const App = () => { return <ComponentLoader componentName="button" />} 但这种方式加载的组件存在如下问题: 其组件 id 不会存储到 pageSchema ,后端可能无法做一些校验。 无法响应事件,因为事件响应前提是组件信息存在于 pageSchema 中。 加载一个有事件功能的额外新组件通过申明 id 与 componentName 加载一个全新组件,为了在其销毁时做有效清理,请将其 id 记录到 useKeepComponentLoaders 中。 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 随时渲染一个组件副本: import { ComponentLoader } from '@alife/bi-designer'// ... 任意可拿到 componentInstance 处return ( <ComponentLoader id={componentInstance.id} />) 那么全屏就是将组件渲染到一个新容器内,非常 easy。 局部配置覆盖可以通过 DesignerProvider 实现干涉其子元素 useDesigner 获取信息的能力: 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 定义: 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 访问: { "componentInstances": { "1": { "id": "1", "componentName": "button", "props": { "text": { "type": "JSExpression", "value": "this.i18n['你好']" } } } }} 容器拓展组件 propscomponentMeta.container 可以定义组件外层容器,但有的时候我们想在容器做一点事情,比如获取宽高,以 props 的方式传递给子组件。 因为子组件以 children 的方式书写不易拓展,因此提供了 PropsProvider 来拓展子组件拿到的 props: 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 的使用方式是这样: import { useDesigner } from '@alife/bi-designer'export default () => { const { undo, redo } = useDesigner() // 撤销调用 undo() // 重做调用 redo()} 是不是觉得很简单?是的,因为所有值得撤销重做的操作在引擎内部使用了 HistoryManager 管理,因此引擎知道每一个可以被撤销或者重做的操作,直接调用函数即可。 组件复制执行 copyComponent 命令即可复制组件,比如: const App() { const { copyComponent } = useDesigner() // 复制组件 copyComponent(componentInstance) } copyComponent 的参数分别为: function copyComponent( componentInstance?: ComponentInstance, parentId?: string, index?: number) 如不指定 parentId ,默认复制到自己父元素下。 如不指定 index ,默认复制到当前元素下方。 组件模版如果觉得某些组件配置可能被复用,可以在画布组件右上角增加一个 “添加到组件模版” 按钮,bi-designer 也提供了生成、添加组件模版的方法。 创建组件模版利用 createCombine 函数从画布中已有组件创建出组件模版,也可以将其生成结果持久化,作为一个固定的组件模版: const ComponentContainer: Interfaces.InnerComponentElement = ({ componentInstance }) => { const { createCombine } = useDesigner(); const setToCombine = React.useCallback(() => { // 创建组件模版 const combine = createCombine(componentInstance.id) }, [createCombine]);} createCombine 的参数就是画布中组件的 id。 添加组件模版到画布利用 addCombine 函数将组件模版添加到画布,第一个参数就是上面生成的 combine 对象: const App = () => { const { addCombine } = useDesigner(); const addComponent = React.useCallback(() => { // 创建组件模版 const combine = addCombine(combine, parentId) }, [addCombine]);} 渲染完成标识当画布中所有组件都完成渲染了,可能要做一些监控上报,或者告诉截图软件可以截图了,bi-designer 提供了这种回调时机 onRendered: 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 字段描述当前画布中有哪些 “边节点”: import { Designer } from '@alife/bi-designer';const App = ({ defaultPageSchema }) => ( <Designer defaultPageSchema={{ ...defaultPageSchema, edges: [] }} />) 可以看到,只要任意拓展 pageSchema 即可。 2. 通过 useDesigner 拿到拓展字段首先定义一个 edgesSelector : import { DesignerState } from '@alife/bi-designer';export const edgesSelector = () => (state: DesignerState) => { return { // 从 pageSchema.edges 读取 edges edges: state.pageSchema?.edges as Edge[], };}; 在需要读取的地方结合 useDesigner : import { useDesigner } from '@alife/bi-designer';import { edgesSelector } from './selector'const Panel = () => { // 自带类型 const { edges } = useDesigner(edgesSelector())} 3. 通过 useDesigner 修改拓展字段通过 setPageSchema 更新拓展字段: 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 进行临时数据存取,要注意非结构化数据是无法监听变化的,引用永远保持不变: 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 可以阻止画布添加组件: import { Designer } from '@alife/bi-designer'const App = () => ( <Designer shouldAddComponents={({addedComponentInstancesArray, pageSchema}) => { // 阻止添加 return false }} />) addedComponentInstancesArray :添加的组件, ComponentInstance[] 类型。 shouldMoveComponents() 返回 false 可以阻止画布移动组件: import { Designer } from '@alife/bi-designer'const App = () => ( <Designer shouldmoveComponents={({movedComponentInstancesArray, targetComponentInstance, pageSchema}) => { // 阻止移动 return false }} />) movedComponentInstancesArray :移动的组件,ComponentInstance[] 类型。 taragetComponentInstance :要移动到的父组件实例信息, ComponentInstance 类型。 shouldDeleteComponents() 返回 false 可以阻止画布删除组件: import { Designer } from '@alife/bi-designer'const App = () => ( <Designer shouldDeleteComponents={({deletedComponentInstancesArray, pageSchema}) => { // 阻止删除 return false }} />) deletedComponentInstancesArray :删除的组件, ComponentInstance[] 类型。 仅刷新可视区域组件默认组件都会以按需加载的方式渲染,即对于不在可视区域的组件,不会触发任何重渲染,以此提升交互操作的效率,以及首屏速度。 对于筛选条件等可能影响到其他组件的组件,可以通过 ComponentMeta.keepActive 强制保持激活状态: import { Interfaces } from '@alife/bi-designer'const componentMeta: Interfaces.ComponentMeta = { keepActive: true} keepActive:组件始终保持激活状态,即不出现在可视区域也会被渲染与响应刷新,默认关闭。 对于特殊场景比如截图,可能要求所有组件强制为 active 状态,可以通过 forceActive 函数实现: import { Interfaces, useDesigner } from '@alife/bi-designer'const Test: Interfaces.ComponentElement = () => { const { forceActive, cancelForceActive } = useDesigner() // forceActive() 强制所有组件 active // cancelForceActive() 取消强制 active,组件根据实际情况 active}; 可以通过 getSnapshot().actives 获取任意组件当前瞬时 active 状态: 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 修改上下文。 举例子: // 初始化 pageSchemaconst 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 就可以持有状态: import { Interfaces } from '@alife/bi-designer'const statefulComponentMeta: Interfaces.ComponentMeta = { stateful: true} 被有状态的容器包裹的组件 this.state 与 this.setState 都局限在当前状态容器内,也就是当前状态容器内组件的 state 是互通的,且一个有状态容器与外部环境是隔离的,可以独立运行。 工具类拓展工具类拓展可以通过上下文访问,如下是拓展方式: 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 :内容。 用法: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《数据搭建引擎 bi-designer API-组件》","path":"/wiki/WebWeekly/前沿技术/《数据搭建引擎 bi-designer API-组件》.html","content":"当前期刊数: 165 bi-designer 是阿里数据中台团队自研的前端搭建引擎,基于它开发了阿里内部最大的数据分析平台,以及阿里云上的 QuickBI。 bi-designer 目前没有开源,因此文中使用的私有 npm 源 @alife/bi-designer 是无法在公网访问的。 本文介绍 bi-designer 组件的使用 API。 组件加载组件实例定义在元信息 - element 中: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { element: () => <div />,}; 异步加载使用 React.lazy 即可实现异步加载组件: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { // 懒加载 element: React.lazy(async () => import("./real-component")),}; 懒加载的组件会自动完成加载,如需自定义加载 Loading 效果,可以阅读 组件异步、错误处理 文档。 组件异步、错误处理 组件源码异步加载或者进行 Suspense 取数时,会调用 ComponentMeta.suspenseFallback 渲染。 组件渲染出错时,会调用 ComponentMeta.errorFallback 渲染。 异步加载import { Interfaces } from "@alife/bi-designer";const SuspenseFallback: Interfaces.InnerComponentElement = ({ componentInstance, componentmeta,}) => { return <span>Loading</span>;};const componentMeta = { componentName: "suspense-custom-fallback", element: React.lazy(async () => { await sleep(2000); return Promise.resolve({ default: () => null }); }), suspenseFallback,}; 上面例子中,对异步加载的组件定义了 suspenseFallback 来处理异步中的状态。 错误处理import { Interfaces } from "@alife/bi-designer";const errorFallback: Interfaces.ErrorFallbackElement = ({ componentInstance, componentmeta, error,}) => { return <span>错误:{error.toString()}</span>;};const componentMeta = { componentName: "error-custom-fallback", element: () => { throw new Error("error!"); }, errorFallback,}; 上面例子中, errorFallback 处理了组件抛出的任何错误。 error :当前组件报错信息。 容器组件容器元素可以被拖入子元素,只要将 isContainer 设置为 true 即可: export const yourComponentMeta: Interfaces.ComponentMeta = { componentName: "yourComponent", element: YourComponent, isContainer: true,}; 之后可以从 props.children 访问到子元素: const YourComponent = ({ children }) => { return <div>{children}</div>;}; 多插槽容器组件多插槽容器即一个容器内部有多个位置可响应拖拽。 实现多插槽容器组件注意两点即可: 这个大容器组件本身不为容器类型,因为我们要拖入到子元素,不需要拖入到它自己本身。 内部通过 ComponentLoader 添加容器类组件作为子元素。 比如我们要利用 Antd Card 实现一个多插槽容器,首先把 Card 申明为普通组件: export const cardComponentMeta: Interfaces.ComponentMeta = { componentName: "card", element: CardComponent,}; 在实现 Card 功能时,我们在两处内部可拖拽区域调用 ComponentLoader 加载一个事先定义好的容器组件 div : import { ComponentLoader, useDesigner } from '@alife/bi-designer'const CardComponent: Interfaces.ComponentElement = () => { const { useKeepComponentLoaders } = useDesigner() useKeepComponentLoaders(['1']) return ( <Card actions={[...]} > <ComponentLoader id="1" componentName="div" props={{style: { minHeight: 30 }}} /> </Card> );}; 总结一下,我们可以利用 ComponentLoader 在组件内部加载任意组件,如果加载的是容器组件,就相当于增加了一块内部插槽。这种插槽可以插入理论上无数种容器组件,根据业务需求而定,比如上面这种最简单的 div 容器,可以是这么实现的: const Div: Interfaces.ComponentElement = ({ children, style }) => { return ( <div style={{ width: "100%", height: "100%", ...style }}>{children}</div> );}; Tabs 容器组件Tabs 容器可以看作动态数量的多插槽容器: import { ComponentLoader, useDesigner } from "@alife/bi-designer";const TabsComponent: Interfaces.ComponentElement = ({ tabs }) => { const { useKeepComponentLoaders } = useDesigner(); useKeepComponentLoaders(tabs?.map((each) => each.key)); return ( <div> <Tabs> {tabs?.map((each) => ( <Tabs.TabPane tab={`Tab${each.title}`} key={each.key}> /* 举个例子,拿 div 这个组件作为 TabPane 的容器 */ <ComponentLoader id={each.key} componentName="div" /> </Tabs.TabPane> ))} </Tabs> </div> );}; Tabs 根据配置动态渲染 TabPane ,为每个 TabPane 塞入一个容器即可。 注意, useKeepComponentLoaders 函数可以让数据变化后某个子 Tab 消失时,及时做画布脏数据清除。另外即便数据不是动态的,也要及时更新这个函数,比如某次更新, ComponentLoader id 为 3 的值从代码移除了,也要把 3 这个 id 从 useKeepComponentLoaders 中移除。 组件宽高对于能自适应高度的组件,最佳方案是设置 100% 的宽高: import { Interfaces } from "@alife/bi-designer";const CustomComponent: Interfaces.ComponentElement = () => { return <div style={{ width: "100%", height: "100%", minHeight: 50 }} />;}; 流式布局下 height: ‘100%’ 高度会坍塌,因此可以设置个最小高度固定值兜底,或者通过 props 让用户配置。 如果组件不支持自适应宽高,比如渲染 canvas、svg 等图表时,需要自己监听宽高,或者利用 容器拓展组件 props 功能,在容器算好宽高具体值,再传入组件。 当然也可以直接设置一个默认高度,或者根据内容动态撑开组件,在流式布局、磁贴布局下可以自动撑开容器(磁贴布局编辑模式下拖拽的高度允许被运行时自动撑大),在自由布局下无法撑开,会出现内滚动条。 组件配置默认值组件配置表单的默认值在 ComponentMeta.props 中定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { props: [ { name: "title", defaultValue: "标题", }, ],}; Props 描述了组件入参信息,包括: interface MetaProps { /** * 属性名 */ name: string; /** * 属性类型 */ type?: string; /** * 属性描述 */ description?: string; /** * 默认值 */ defaultValue?: any;} 如果只设置默认值,只需要关心 name 和 defaultValue 。 组件配置表单组件配置表单在 ComponentMeta.propsSchema 中定义: import { Interfaces } from '@alife/bi-designer'const componentMeta: Interfaces.ComponentMeta = { platform: 'fbi', // 平台名称 propsSchema: { style: { color: { title: 'Color', type: 'color', redirect: 'color', }, }, },} platform :项目类型。不同项目类型的 propsSchema 结构可能不同,其他取数逻辑可能也不同。 propsSchema :表单配置结构,符合 UISchema 规范。对于特殊表单可能使用自己的规范。 组件配置修改回调组件配置修改回调在每次组件实例信息被修改时触发,在 ComponentMeta.onPropsChange 中定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { onPropsChange: ({ prevProps, currentProps, componentMeta }) => { return { ...currentProps, color: "red", }; },}; prevProps :上一次组件配置。 currentProps :当前组件配置。 componentMeta :组件元信息。 Return :新的组件配置。 跨组件关联配置更新当画布任何组件变化时,组件都可以在 ComponentMeta.onPageChange 监听到,并修改自己的组件配置: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { onPageChange: ({ props, pageSchema }) => { // 当关联的组件找不到时清空 if ( !pageSchema?.componentInstances?.some((each) => each.id === props.value) ) { return { ...props, // 清空 props.value value: "", }; } // 返回值会更新当前组件配置 return props; },}; props :当前组件配置。 pageSchema :页面信息。 Return :新的组件配置。 假设组件配置中用到了其他组件 id 等数据,可以在 onPageChange 回调时做判断,如果目标组件不存在,对当前组件的部分配置内容做更新。 组件隐藏组件隐藏可以通过 hide 设置: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { hide: ({ componentInstance, mode }) => true,}; componentInstance :组件实例信息。 mode :当前模式,比如组件仅编辑模式隐藏,可以判断 ({ mode }) => mode === ‘edit’ 。 属性值类型 - JSSlotJSSlot 是一种配置类型,可以将组件某个 props 参数设置为另一个组件实例,运行时作为 React Node 传参。 import { Interfaces } from "@alife/bi-designer";// 组件直接使用 props.header 作为 JSXconst ComponentWithJSSlot: Interfaces.ComponentElement = ({ header }) => { return ( <div> header 元素: {header} </div> );};// DSL 中增加 Slot 描述const defaultPageSchema: Interfaces.PageSchema = { componentInstances: { tg43g42f: { id: "tg43g42f", componentName: "js-slot-component", index: 0, props: { header: { type: "JSSlot", value: ["child1", "child2"], }, }, }, child1: { id: "child1", componentName: "input", parentId: "tg43g42f", index: 0, isSlot: true, }, child2: { id: "child2", componentName: "input", parentId: "tg43g42f", index: 1, isSlot: true, }, },}; isSlot :标识节点是 JSSlot 类型。 type: ‘JSSlot’ :标记属性为 JSSlot 类型, value 数组存储 Slot 组件 id。 属性值类型 - JSFunctionJSFunction 是一种配置类型,可以将组件某个 props 参数设置为自定义函数。 import { Interfaces } from "@alife/bi-designer";// 组件直接使用 props.onClick 作为函数调用const FunctionComponent: Interfaces.ComponentElement = ({ onClick }) => { return <div onClick={onClick} />;};// DSL 中增加 Function 描述const defaultPageSchema: Interfaces.PageSchema = { componentInstances: { test: { id: "tg43g42f", componentName: "functionComponent", index: 0, props: { onClick: { type: "JSFunction", value: 'function onClick() { console.log("123") }', }, }, }, },}; type: ‘JSFunction’ :标记属性为 JSFunction 类型, value 用字符串存储函数体。函数中可以使用 上下文数据对象 与 工具类拓展。 属性值类型 - JSExpressionJSExpression 是一种配置类型,可以将组件某个 props 参数设置为自定义表达式。 import { Interfaces } from "@alife/bi-designer";// 组件直接使用 props.variable 作为变量直接渲染const ExpressionComponent: Interfaces.ComponentElement = ({ variable }) => { return <div>JSExpression:{variable}</div>;};// DSL 中增加 Expression 描述const defaultPageSchema: Interfaces.PageSchema = { componentInstances: { test: { id: "tg43g42f", componentName: "expressionComponent", props: { variable: { type: "JSExpression", value: '"1" + "2"', }, }, }, },}; type: ‘JSExpression’ :标记属性为 JSExpression 类型, value 用字符串存储表达式。表达式可以使用 上下文数据对象、与 工具类拓展。 组件状态持久化组件自身在运行时可以通过 updateComponentById 函数将状态持久化到配置中: import { Interfaces, useDesigner } from "@alife/bi-designer";import * as fp from "lodash/fp";const componentMeta: Interfaces.ComponentMeta = { element: Component,};const Component: Interfaces.ComponentElement = ({ id, count }) => { const { updateComponentById } = useDesigner(); const handleIncCount = React.useCallback(() => { updateComponentById(id, (each) => fp.set("props.count", each?.props?.count + 1, each) ); }, [id, updateComponentById]); return <div onClick={handleIncCount}>{count}</div>;}; 注意:由于 updateComponentById 修改的是画布 DSL,因此在非编辑模式下,此 DSL 无法持久化。对于此模式下产生的脏数据清理问题,同 组件配置订正。 动态创建组件组件内可以动态创建任何其他组件,通过 props.ComponentLoader 实现: import { Interfaces, useDesigner, ComponentLoader } from "@alife/bi-designer";const Card: Interfaces.ComponentElement = () => { const { useKeepComponentLoaders } = useDesigner(); useKeepComponentLoaders(["1"]); return ( <ComponentLoader id="1" componentName="button" props={{ color: "red" }} /> );}; useKeepComponentLoaders :与下面动态创建的组件 id 保持同步,以便引擎管理动态组件。ComponentLoader 参数说明: id :动态组件的唯一 id,在同一个组件内,动态组件的 id 需要保持唯一。 componentName :组件名。 props :组件 Props,可选。 动态组件嵌套动态组件可以任意嵌套: import { Interfaces, useDesigner, ComponentLoader } from "@alife/bi-designer";const Card: Interfaces.ComponentElement = ({ ComponentLoader, useKeepComponentLoaders,}) => { const { useKeepComponentLoaders } = useDesigner(); useKeepComponentLoaders(["1", "2"]); return ( <ComponentLoader id="1" componentName="div"> 这是子元素: <ComponentLoader id="2" componentName="button" /> </ComponentLoader> );}; 组件配置未 Ready 时不渲染可以在组件容器或通用容器层对组件渲染做拦截,比如判断某些配置不满足,展示一个兜底图而不是直接渲染组件: import { Interfaces, useDesigner } from "@alife/bi-designer";import * as fp from "lodash/fp";const componentMeta: Interfaces.ComponentMeta = { element: Component, container: Container,};const Container: Interfaces.InnerComponentElement = ({ componentInstance, children,}) => { if (!componentInstance?.props?.count) { // 不满足渲染条件 return <div>count 配置未 ready,不渲染组件</div>; } // 渲染 children,children 即组件本身 return children;}; 配置未 Ready 时不取数只要 getFetchParam 抛出异常即可暂停取数: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { getFetchParam: ({ componentInstance, componentMeta, filters, context }) => { if (componentInstance.props?.count !== "5") { // count 不为 '5' 则不取数 throw Error("Not Ready"); } return "123"; },}; 这个错误可以通过 props.fetchError 访问到,组件和容器层都可以拦截: import { Interfaces } from "@alife/bi-designer";class PropsNotReadyError extends Error {}const componentMeta: Interfaces.ComponentMeta = { getFetchParam: ({ componentInstance, componentMeta, filters, context }) => { if (componentInstance.props?.count !== "5") { throw PropsNotReadyError("Not Ready"); } return "123"; }, container: Wrapper,};const Wrapper: Interfaces.InnerComponentElement = ({ componentInstance }) => { if (componentInstance.props.fetchError instanceof PropsNotReadyError) { return <div>不满足取数条件</div>; }}; 组件取数组件是否初始化取数在 ComponentMeta.initFetch 中定义;生成取数参数在 ComponentMeta.getFetchParam 中定义;组件取数函数在 ComponentMeta.fetcher 中定义 import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { // 组件是否开启自动取数,当取数参数变化时(getFetchParam 控制)会触发自动取数 autoFetch: ({ componentInstance, componentMeta }) => true, // 组件是否默认取数,仅 autoFetch 为 true 时生效 initFetch: ({ componentInstance, componentMeta }) => true, // 组装取数参数 getFetchParam: ({ componentInstance, componentMeta, filters, context }) => { return { name: componentInstance?.props?.name }; }, // 取数函数 fetcher: async ({ componentMeta, param, context }) => { // 根据当前组件信息 componentInstance 与筛选条件组件&值 filters 进行取数 return await customFetcher(param.name); },}; componentInstance :当前组件实例信息。 getFetchParam :取数开始的回调,用来组装取数参数。返回 null 或 undefined 不会触发取数。 filters :作用于此组件的筛选信息,在 组件筛选 文档有进一步阐述。包含的 key 有: componentInstance :筛选条件组件实例信息。 filterValue :筛选条件的当前筛选值。 payload :自定义传参,由组件筛选的 eventConfigs 定义,具体见文档 组件筛选 - 传递额外筛选信息 。 context :上下文,可以访问 useDesigner 一切内容。 做了取数配置后,组件就可以通过 props 拿到数据了: import { useDesigner } from "@alife/bi-designer";const NameList: Interfaces.ComponentElement = () => { const { data, isFetching, isFilterReady } = useDesigner(); if (!isFilterReady) { return <Spin>筛选条件未 Ready</Spin>; } if (isFetching) { return <Spin>取数中</Spin>; } return ( <div> {data.map((each: any, index: number) => ( <div key={index}>{each}</div> ))} </div> );}; data 取数结果。 isFetching 是否在取数中。 isFilterReady 筛选条件是否 Ready,在组件筛选一节会详细说明,此处理解为一种特殊取数 Hold 状态。 fetchError 取数错误。 还可以 在引擎层配置全局组件取数配置,组件级配置的优先级高于引擎层的。 组件主动取数通过 fetchData 可以主动取数: const NameList: Interfaces.ComponentElement = ({ fetchData }) => { const { fetchData } = useDesigner(); return <button onClick={fetchData}>主动取数</button>;}; fetchData :主动取数函数,调用后可以立即重新取数。 主动取数调用后,取数结果依然通过 props.data 返回。 自定义取数参数fetchData 可以传入参数 getFetchParam 自定义取数参数: const NameList: Interfaces.ComponentElement = ({ fetchData }) => { const { fetchData } = useDesigner(); const handleFetchData = React.useCallback(() => { fetchData({ getFetchParam: ({ param, context }) => ({ ...param, top: 1, }), }); }, [fetchData]); return <button onClick={handleFetchData}>主动取数</button>;}; 要注意,非独立取数模式下即便修改了取数参数,下一次由外部触发的取数会重置取数参数。 独立取数独立取数可以通过 standalone 参数申明,此时触发取数不会导致组件 Rerender 并拿到新 data ,而是返回一个 Promise 由组件自行处理。 const NameList: Interfaces.ComponentElement = ({ fetchData }) => { const { fetchData } = useDesigner(); const handleFetchData = React.useCallback(async () => { const data = await fetchData({ standalone: true, }); // 组件自己处理取数结果 data }, [fetchData]); return <button onClick={handleFetchData}>主动取数</button>;}; 这种独立取数场景可以适应下钻等组件自由取数的场景。 独立取数模式下当然也可以结合 getFetchParam 一起使用。 主动取消取数通过 cancelFetch 可以主动取消取数: const NameList: Interfaces.ComponentElement = ({ cancelFetch }) => { const { cancelFetch } = useDesigner(); return <button onClick={cancelFetch}>取消取数</button>;}; cancelFetch :取消取数函数,调用后立即生效。取数完成后再调用则无作用。 优化取数性能是否重新取数由 getFetchParam 返回值是否有变化决定,默认写法会进行 deepEqual 判断: import { Interfaces } from "@alife/bi-design";const componentMeta: Interfaces.ComponentMeta = { getFetchParam: ({ componentInstance }) => { // 引擎会对返回值进行深对比 return { name: componentInstance?.props?.name }; },}; 但是下面两种情况可能会产生性能问题: 返回值数据结构非常大,导致频繁 deepEqual 开销明显增大。 生成取数参数的逻辑本身就耗时,导致频繁执行 getFetchParam 函数本身的开销明显增大。 我们对这种情况提供了一种优化方案,利用 shouldFetch 主动阻止不必要的取数,具体参考 组件阻止自动取数。 组件取数事件钩子如果想在取数后做一些更新,但不想触发额外的重渲染,可以在“组件取数事件钩子”里做。 取数完成后afterFetch 钩子在取数完成后执行: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { afterFetch: ({ data, context, componentInstance }) => { context.updateComponentById(componentInstance.id, (each) => fp.set("props.value", "newValue", each) ); },}; data :取数结果,即 fetcher 的返回值。 context :上下文。 componentInstance :组件实例信息。 componentMeta :组件元信息。 在取数钩子触发的数据流变更事件(比如 updateComponentById )不会触发额外重渲染,其渲染时机与取数结束后时机合并。 组件定时取数对于需要定时刷新重新取数的实时数据,可以配置 autoFetchInterval 实现定时自动取数功能: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { autoFetchInterval: () => 1000,}; autoFetchInterval :自动重新取数间隔,单位 ms,不设置则无此功能。 组件强制取数正常情况取数参数变化才会重新取数,但如有强制取数的诉求,可执行 forceFetch : import { useDesigner } from "@alife/bi-designer";export default () => { const { forceFetch } = useDesigner(); // 指定某个组件重新取数 // forceFetch('jtw4x8ns')}; forceFetch :强制取数函数,传参为组件 ID。 组件筛选触发筛选行为任何组件都可以作为筛选条件,只要实现 onFilterChange 接口就具备了筛选能力,通过 filterValue 可以拿到当前组件筛选值,下面创建一个具有筛选功能的组件: import { useDesigner } from "@alife/bi-designer";const SelectFilter = () => { const { filterValue, onFilterChange } = useDesigner(); return ( <span> <Select value={filterValue} onChange={onFilterChange}> // ... </Select> </span> );}; 当组件触发 onFilterChange 时则视为触发筛选,其作用的组件会触发 组件取数。 通过表达式设置任意 key注意, onFilterChange 与 filterValue 可以映射到组件任意 key,只需要如下定义: { props: { onChange: { type: "JSExpression", value: "this.onFilterChange" }, value: { type: "JSExpression", value: "this.filterValue" } }} 组件的 props.onChange 与 props.value 就拥有了 onFilterChange 与 filterValue 的能力。 设置筛选作用的组件那么如何定义被作用的组件呢?由于筛选关联属于运行时能力,我们需要用到 组件运行时配置 功能。 运行时能力中,筛选关联功能属于 ComponentMeta.eventConfigs 中 filterFetch 部分能力 ,即筛选条件的作用范围,在列表中的组件会在当前组件触发 onFilterChange 时触发取数: import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => createComponentInstancesArray(pageSchema?.componentInstances) // 找到画布中所有 name-list 组件 ?.filter((each) => each.componentName === "name-list") ?.map((each) => ({ // 事件类型是筛选触发取数 type: "filterFetch", // 条件由当前组件触发 source: componentInstance.id, // 作用于找到的 name-list 组件 target: each.id, })),}; 上面的例子,我们通过 eventConfigs 将所有组件名为 name-list 都做了绑定,当然你也可以根据 componentInstance.props 根据组件当前配置来绑定,自由使用。 同理,还可以实现条件反向绑定,只要设置 source 和 target 即可,source 是触发 onFilterChange 的组件,target 是被作用取数的组件。 注意: componentInstances 包含所有组件,包括自身及 root 根节点,如果要绑定所有组件,一般情况下需要排除掉自身和 root 节点: { eventConfigs: componentInstances?.filter( // 不选中 root 节点 (each) => each.componentName !== "root" && // 不选中自己 each.componentId === componentInstance.id ); // ...} 传递额外筛选信息考虑到筛选条件正向、反向绑定,或者同一个筛选条件组件针对同一个组件有多个不同筛选功能,bi-designer 支持 source 与 target 重复的多对多,比如: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "filterFetch", source: componentInstance.id, target: 1, payload: "作用于取数参数", }, { type: "filterFetch", source: componentInstance.id, target: 1, payload: "作用于字段筛选", }, ],}; 在上面的例子中,我们可以将当前组件连续绑定多个同一个目标( target ),为了区分作用,我们可以申明 payload ,这个 payload 最终会传递到 target 组件的 getFetchParam.filters 参数中,可以通过 eachFilter.payload 访问,具体见文档 组件取数 。 对于同一个组件连续绑定多个相同目标组件场景较少,但对于 A 组件配置绑定 B,B 组件配置被 A 绑定的场景还是很多的。 筛选依赖筛选条件间存在的依赖关系称为筛选依赖。 筛选 Ready 依赖筛选 Ready 依赖由 filterReady 定义: import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => createComponentInstancesArray(pageSchema?.componentInstances) // 找到画布中所有 input 组件 ?.filter((each) => each.componentName === "input") ?.map((each) => ({ type: "filterReady", source: each.id, target: componentInstance.id, })),}; target 依赖 source ,当筛选条件 source 变化时, target 组件的筛选就会失效并且被置空。 source :一旦触发 onFilterChange 。 target :组件筛选 Ready 就置为 false,且 filterValue 置为 null。 筛选 Value 依赖筛选 Value 依赖由 filterValue 定义: import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => createComponentInstancesArray(pageSchema?.componentInstances) // 找到画布中所有 input 组件 ?.filter((each) => each.componentName === "input") ?.map((each) => ({ type: "filterValue", source: each.id, target: componentInstance.id, })),}; target 依赖 source ,当筛选条件 source 变化时, target 组件的 filterValue 将被赋值为 from 的 filterValue 。 source :一旦触发 onFilterChange 。 target :组件 filterValue 就会被置为 source 组件 filterValue 的值。 组件筛选默认值默认情况下,组件筛选器的默认值为 undefined ,并且后续筛选条件变更由组件 onFilterChange 行为控制(具体可以看 组件筛选 文档)。 但如果配置了筛选默认值,或者默认从 URL 参数等,让组件筛选拥有默认值,这个需求也是非常合理的,可以通过 defaultFilterValue 定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { // 组件筛选默认值 defaultFilterValue: ({ componentInstance }) => componentInstance.props.defaultFilterValue,}; 注意此为筛选条件默认值,后续筛选条件变化不会再受此参数控制。 组件主题风格组件可以通过两种方式读取主题风格配置: JS:通过例如 props.theme.primaryColor 读取。 CSS:通过例如 var(–primaryColor) 读取。 JS 模式import { themeSelector, useDesigner } from "@alife/bi-designer";const Component: Interfaces.ComponentElement = () => { const { theme } = useDesigner(themeSelector()); return <div style={{ color: theme?.primaryColor }}>文本</div>;}; CSS 模式import "./index.scss";const Component: Interfaces.ComponentElement = () => { return <div className="custom-text">文本</div>;}; .custom-text { color: var(--primaryColor);} CSS 模式的 Key 与 JS 变量的 Key 完全相同。 组件国际化组件配置通过 JSExpression 方式使用国际化: const defaultPageSchema: Interfaces.PageSchema = { componentInstances: { test: { id: "tg43g42f", componentName: "expressionComponent", props: { variable: { type: "JSExpression", value: 'this.i18n["中国"]', }, }, }, },}; 通过 this.i18n 即可根据 key 访问国际化内容。 国际化内容配置 - 配置国际化。 JSExpression 说明 - JSExpression。 组件配置订正当组件实例版本低于最新版本号时,说明产生了回滚,也会按照顺序依次订正。 注:需要考虑数据回滚的组件,在发布前要把 undo 逻辑写好并测试后提前上线,之后再进行项目正式上线,以保证回滚后可以正确执行 undo 。 组件配置订正在 ComponentMeta.revises 中定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { revises: [ { version: 1, redo: async (prevProps) => { return prevProps; }, undo: async (prevProps) => { return prevProps; }, }, ],}; version :订正的版本号。 redo :升级到这个版本订正逻辑。 undo :回退到这个版本订正逻辑。 Return :新的组件 props 。 组件吸顶全局吸顶组件吸顶通过 ComponentMeta.fixTop 定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { fixTop: ({ componentInstance }) => true,}; 配置 fixTop 后即可吸顶,不需要组件做额外支持。 如果置顶的组件具有筛选功能,吸顶后仍具有筛选功能。 组件内吸顶通过 ComponentMeta.fixTopInsideParent 来设置组件在父容器内吸顶。 平滑取消滚动: 设置 ComponentMeta.smoothlyFadeOut 可以实现该效果。 直接让组件回到原位置: 不需要任何配置。 import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { fixTop: () => true, fixTopInsideParent: () => true, smoothlyFadeOut: () => true,}; 设置吸顶组件自定义样式设置 ComponentMeta.getFixTopStyle 来自定义组件吸顶后的样式,一般拿来设置 zIndex 。 type getFixTopStyle = (componentInfo: { componentInstance: ComponentInstance; componentMeta: ComponentMeta; dom: HTMLElement; context: any;}) => React.CSSProperties;import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { getFixTopStyle: () => ({ zIndex: 1000000, }),}; 组件渲染完成标识默认组件渲染完毕不需要主动上报,下面是自动上报机制: 组件 initFetch 为 false 时,组件 DOM Ready 作为渲染完成时机。 组件 initFetch 为 true 时,组件取数完毕后且 DOM Ready 作为渲染完成时机。 主动上报渲染完成标识对于特殊组件,比如 DOM 渲染完毕不是时机加载完毕时机时,可以选择主动上报: import { Interfaces, useDesigner } from "@alife/bi-designer";const customOnRendered: Interfaces.ComponentElement = () => { const { onRendered } = useDesigner(); return <div onClick={onRendered}>点我后这个组件才算渲染完成</div>;};const customOnRenderedMeta: Interfaces.ComponentMeta = { manualOnRendered: true,}; manualOnRendered :设置为 true 时禁用自动上报。 onRendered :主动上报组件渲染完毕,仅第一次生效。 组件阻止自动取数对于需要精细化控制取数时机的场景,可以使用 shouldFetch 控制组件取数时机: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { shouldFetch: ({ prevComponentInstance, nextComponentInstance, prevFilters, nextFilters, componentMeta, context, }) => true,}; shouldFetch 返回 false 则阻止自动取数逻辑,不会执行到 getFetchParam 与 fetcher 。 prevComponentInstance :上一次组件实例信息。 nextComponentInstance :下一次组件实例信息。 prevFilters :上一次筛选条件信息。 nextFilters :下一次筛选条件信息。 componentMeta :组件元信息。 context :上下文。 对于取数参数没变化时仍要重新取数,参考 组件强制取数。 shouldFetch 不会阻塞 组件强制取数、组件定时自动取数、组件主动取数。 shouldFetch 会阻塞 initFetch=true 初始化取数。 组件按需取数默认 bi-designer 取数是全量并发的,也就是无论组件是否出现在可视区域内,都会第一时间取数,但取数结果不会造成非可视区域组件的刷新。 如果考虑到浏览器请求并发限制,需要优先发起可视区域内组件的取数,可以将 fetchOnlyActive 设置为 true : const componentMeta = { componentName: "line-chart", fetchonlyActive: () => true,}; 当组件开启此功能后: 在可视区域内组件才会发起自动取数。 当组件从非可视区域出现在可视区域时,如果需要则会自动发起取数。 组件回调事件组件回调可以触发事件,通过运行时配置 ComponentMeta.eventConfigs 中 callback 定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "callback", callbackName: "onClick", }, ],}; callbackName :回调函数名。 定义了回调时机后,我们可以触发一些 action 实现自定义效果,在后面的 更新组件 Props、更新组件配置、更新取数参数 了解详细内容。 事件 - 更新组件 Props更新组件配置属于 Action 之 setProps : import { Interfaces } from '@alife/bi-designer'const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [{ type: 'callback', callbackName: 'onClick', source: componentInstance.id, target: componentInstance.id action: { type: 'setProps', setProps: (props, eventArgs) => { return { ...props, color: 'red' } } } }]} 如上配置,效果是将 props.color 设置为 red 。 eventArgs 是事件参数,比如 onClick 如下调用: props.onClick("jack", 19); setProps: (props, eventArgs) => { return { ...props, name: eventArgs[0], age: eventArgs[1], };}; 如果有多个事件同时作用于同一个组件的 setProps ,则 setProps 函数会依次触发多次。 事件 - 更新取数参数更新组件取数参数属于 Action 之 setFetchParam : import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "callback", callbackName: "onClick", action: { type: "setFetchParam", setFetchParam: (param, eventArgs) => { return { ...param, count: true, }; }, }, }, ],}; 如上配置,效果是在取数参数中增加一项 count:true 。 事件 - 更新筛选条件更新筛选条件属于 Action 之 setFilterValue : import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "callback", callbackName: "onClick", action: { type: "setFilterValue", setFilterValue: (filterValue, eventArgs) => { return "uv"; }, }, }, ],}; 如上配置,效果是将目标组件的筛选条件值改为 uv 。 总结以上就是结合了通用搭建与 BI 特色功能的搭建引擎对组件功能的支持,如果你对功能、或者 API 有任何问题或建议,欢迎联系我。 讨论地址是:精读《数据搭建引擎 bi-designer API-组件》· Issue ##269 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《新一代前端构建工具对比》","path":"/wiki/WebWeekly/前沿技术/《新一代前端构建工具对比》.html","content":"当前期刊数: 195 本周精读的文章是 Comparing the New Generation of Build Tools。 前端工程领域近期出了不少新工具,这些新工具都运用了一些新技术或者跨领域技术,实现了一些突破,因此有必要了解一下这些工具都有什么特性,以及是否可以投入生产环境。 由于原文比较啰嗦,所以具体用法和支持细节不在这里展开,如果想进一步了解细节,可以直接阅读 原文。 精读按照从底层到上层的封装粒度,以 esbuild、snowpack、vite、wmr 的顺序介绍。 esbuildesbuild 使用 go 语言编写,由于相对 node 更为底层,且不提供 AST 操作能力,所以代码执行效率更高,根据其官方 benchmark 介绍提速有 10~100 倍: esbuild 有两大功能,分别是 bundler 与 minifier,其中 bundler 用于代码编译,类似 babel-loader、ts-loader;minifier 用于代码压缩,类似 terser。 使用 esbuild 编译代码方法如下: esbuild.build({ entryPoints: ["src/app.jsx"], outdir: "dist", define: { "process.env.NODE_ENV": '"production"' }, watch: true,}); 但由于 esbuild 无法操作 AST,所以一些需要操作 AST 的 babel 插件无法与之兼容,导致生产环境很少直接使用 esbuild 的 bundler 模块。 幸运的是 minifier 模块可以直接替换 terser 使用,可以用于生产环境: esbuild.transform(code, { minify: true,}); 由于 esbuild 牺牲了一些包大小换取了更高的执行效率,因此压缩后包体积会稍微大一些,不过也就是 177KB 与 165KB 的区别,几乎可以忽略。 esbuild 比较底层,所以可以与后续介绍的上层构建工具结合使用,当然根据工具设计理念,是否内置,内置到什么程度,以及是否允许通过插件替换就是另一回事了。 snowpacksnowpack 是一个相对轻量的 bundless 方案,之前也写过一篇 精读 snowpack,其实 bundless 就是利用浏览器支持的 ESM import 特性,利用浏览器进行模块间依赖加载,而不需要在编译时进行。 跳过编译时依赖加载可以省很多事,比如不用考虑 tree shaking 问题,也不用为了最终产物加速而使用缓存,相当于这些工作交给最终执行的浏览器了,而浏览器作为最终运行时容器,比编译时工具更了解应该如何按需加载。 仅从编译时来看,修改单个文件的编译速度与项目整体大小有关,而若不考虑整体项目,仅编译单个文件(最多递归一下有限的依赖模块,解决比如 TS 类型变量判断问题)时间复杂度一定是 O(1) 的。 实际上我们很少单独使用 snowpack,因为其编译使用的 esbuild 还未达到 1.0 稳定版本,在生态兼容与产物稳定性上存在风险,所以编译打包时往往采用 rollup 或 webpack,但这种割裂也导致了开发与生产环境不一致,这往往代表着更大的风险,因此在 vite 框架可以看到这块的取舍。 snowpack 是开箱即用的: // package.json"scripts": { "start": "snowpack dev", "build": "snowpack build"}, 我们还可以增加 snowpack.config.js 配置文件开启 remote 模式: // snowpack.config.jsmodule.exports = { packageOptions: { "source": "remote", }}; remote 模式是 Streaming Imports,即不用安装对应的 npm 包到本地,snowpack 自动从 skypack 读取文件并缓存起来。 snowpack 看起来更多是对 bundless 纯粹的尝试,而不是一个适合满足日常开发的工具,因为日常开发需要一个一站式工具,这就是后面说的 vite 与 wmr。 vite可以理解为结合了 snowpack 特色的一站式构建工具,从开发到发布全套流程都帮你搞定。 涉及的用法非常多,具体内容可以看 官方文档。 与 snowpack 不同的是,snowpack 生产打包的产物是独立的文件,而 vite 没有采用 esbuild 而是 rollup 打包,目的是为了打包为一个整体,并规避 esbuild 不稳定的风险。 另外由于 vite 集成化更高,比 snowpack 多了许多功能,比如 css 拆分、多页、使用 esbuild 进行依赖预构建、monorepo 支持、对多框架支持、SSR 等等。具体可以看 文档介绍。然而原文说这有利有弊,好处是开箱即用,弊端是缺乏定制的灵活性。 其实革命性突破主要是 bundless,在这基础上发展出一系列便捷的功能,这值得每一个工程化团队学习。其实就算决定再造一个轮子,也是维持 90% 功能不变的基础上,在默认的偏好设置做一些微调,而这些大多可以用 插件 解决。 总结下来,Vite 是一个既积极拥抱新特性,又为生产环境考虑的工程化全家桶,相比之下,技术栈过于前沿的工具只能称为玩具,而 Vite 是真的可以用一用的。 wmr由 preact 作者开发,可以理解为 preact 版的 vite。所以对于 preact 技术栈的开发者更加友好,集成度更高。 原文提到的另一个特色是,wmr 使用了 htm 转换 JSX,使其获得了更加精确的报错体验,即可以精确到源码行的同时指定到具体列。 综合功能和 vite 差不多,单页 + ssr 都支持,如果你平时使用 preact,或者想开发一个体积极小的项目,可以考虑用 wmr 全家桶。 总结新一代前端构建工具最大特色有两个:更底层的语言编写、bundless,如果用一个词描述就是高性能。积极拥抱浏览器新特性或者知识跨界都可以帮助前端领域取得新的突破。 另外构建工具已经变得越来越集成化,从仅用于编译的 esbuild,到支持开发的 snowpack,再到内置了最佳实践、甚至支持比如 ssr 等后端能力、最后到垂直场景的 vitePress,每抽象一次,都更开箱即用,但带来的灵活性降低也成为各团队自己造轮子的理由,越上层越是有自己造轮子的冲动。 这和可视化领域很像,可视化从最底层的 svg、canvas、webgl 到基于其封装的命令式框架,再到数据驱动开发框架、完全 JSON 配置化的图表库、甚至到零配置,根据数据猜配置的智能化项目,也是配置越来越少,但灵活度越来越低,使用什么层次的完全看项目对细节的要求。 不过工程化相对还是标准化的,因为可视化面向的是用户,而工程化面向的是程序员,我们不能控制用户需求,但可以控制程序员的开发习惯 :P。 最后,除了升级你的构建工具外,换一台 M1 芯片电脑也可以极大提升开发效率,笔者亲测 webpack 构建速度提升 3 倍! 讨论地址是:精读《新一代前端构建工具对比》· Issue ##316 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《最佳前端面试题》及面试官技巧","path":"/wiki/WebWeekly/前沿技术/《最佳前端面试题》及面试官技巧.html","content":"当前期刊数: 19 本期精读的文章是:The-Best-Frontend-JavaScript-Interview-Questions 讨论前端面试哪些问题,以及如何面试。 1 引言 又到了招聘的季节,如何为自己的团队找到真正优秀的人才?问哪些问题更合适?我们简单总结一把。 2 内容概要The-Best-Frontend-JavaScript-Interview-Questions 从 概念 - 算法 coding - 调试 - 设计 这 4 步全面了解候选人的基本功。 3 精读本精读由 ascoders camsong jasonslyvia 讨论而出。 网络技术发展非常迅速,前端变化尤为快,对优秀人才的面试方式在不同时期会有少许不同。 整体套路在面试之前,第一步要询问自己,是否对当前岗位的职责、要求有清晰的认识?不知道自己岗位要招什么样的人,也无法组织好面试题。 认真阅读简历,这是对候选人起码的尊重,同时也是对自己的负责。阅读简历是为了计划面试流程,不应该对所有候选人都准备相同的问题。 具体流程我们一般会通过: 开场白 候选人自我介绍 面试 附加信息 结束 开场白是最重要的,毕竟候选人如果拒绝了本次面试,后面的流程都不会存在。其次,通过候选人自我介绍,了解简历中你所疑惑的地方。简历是为了突出重点,快速判断是否基本匹配岗位要求,一旦确认了面试,全面了解候选人经验是对双方的负责。接下来重点讨论面试过程。 开放性问题面试的目的是挖掘对方的优点,而不是拿面试官自己的知识点与对方知识点做交集,看看能否匹配上 80%。但受主观因素影响,又不宜询问太多开放性问题,因此开放问题很讲究技巧。 正如上面所说,我推荐以开放性问题开场,这样便于了解候选人的经历、熟悉哪些技术点,便于后面的技术提问。如果开场就以准备好的题目展开车轮战,容易引起候选人心里紧张,同时我们问的问题不一定是候选人所在行的,技术问题不是每一个都那么重要,很多时候我们只看到了候选人的冰山一角,但此时气氛已经尴尬,很多时候会遗漏优秀人才。 开放性问题最好基于行为面试法询问(Star 法则): Situation: 场景 - 当时是怎样的场景 Task: 任务 - 当时的任务是什么 Action: 我采取了怎样的行动 Result: 达到了什么样的结果 行为面试法的好处在于还原当时场景,不但让面试官了解更多细节,也开拓了面试者的思维,让面试过程更加高效、全面。 举一个例子,比如考察候选人是否聪明,star 法则会这样询问: 在刚才的项目中,你提到了公司业务发展很快,人手不够,你是如何应对的呢? 相比不推荐的 “假设性问题” 会如此提问: 假如让你学习一个新技术,你会如何做? 更不推荐的是 “引导性问题”: 你觉得自己聪明吗? 相比于 star 法则,其他方式的提问,不但让候选人觉得突兀,不好回答,而且容易被主观想法带歪,助长了面试中投机的气氛。至于对 star 法则都精心编排的候选人,我还没有遇到过,如果遇到了肯定会劝他转行做演员 —— 开玩笑的,会通过后续技术问题甄别候选人是否有真本领。 技术问题亘古不变的问题就是考察基本功了,然而基本功随着技术的演进会有所调整,Html Css Js 这三个维度永远是不变的,但旧的 api 是否考察,取决于是否有最新 api 代替了它,如果有,在浏览器兼容性达标的基础上,可以只考察替代的 api,当然了解历史会更好。 比如 proxy 与 defineProperty 需要结合考察,因为 proxy 不兼容任何 IE 浏览器,候选人需要全面了解这两种用法。 变的地方在于对候选人使用技术框架的提问。在开放性问题中已经做好了铺垫,那无论候选人时以什么框架开发的,或者不使用框架开发,最好按照候选人的使用习惯提问。比如候选人使用 Angular 框架的开发经验较多,就重点考察对 Angular 框架设计、实现原理是否了解,实际使用中是否遇到过问题,以及对问题的解决方法,这也回到了 star 法则。 如果候选人能总结出比如当前流行的 Vue React Angular 这三个框架核心实现思想的异同,就是加分项。 对与老旧的问题,比如 jquery 的问题,也会问与设计思想相关的问题,比如候选人不知道 $.delegate,也不知道其已被 $on 在 Jq3.0 取代,这不代表候选人能力不行,最多说明候选人比较年轻。此时应该通过引导的方式,让其思考如何优化 $.bind 方法的性能,通过逐步引导,判断候选人的思维活跃度有多强。 如何防止被套路把面试官经验抛出来,怕不怕让候选人有所准备呢? —— 说实在的,几乎所有候选人都是有准备的,也不差这一篇文章。 以上是开玩笑。 面试主要是看候选人基础有多扎实,和思维能力。基础主要指的是,候选人提前了解了多少前端相关知识,比如对闭包的理解,对原生 api 的理解?如果候选人没接触过这两个知识点,会有两种情况: 这些知识点看完需要多久?如果是闭包和原生 api 的定义与用法,候选人这方面的缺陷可以通过 5 分钟来弥补,那么这种问题到底想考什么?我们真的在乎这 5 分钟看文档的时间吗?此时应该了解候选人对知识点的感悟,或者学习方式,因为这两点的差距可能几年都无法弥补 如果候选人学习能力非常强,但几乎所有前端知识点都不了解,弥补完大概一共要花 1000*5 分钟,这时候量变引发质变了,是不是说明候选人本身对技术的热情存在问题? 通过了基础问题还远远不够。甚至当问一个复杂的问题的时候,如果候选人瞬间把答案完美流畅表达出来,说明这个问题基本上白问了。 技术面更应该考察候选人的思考过程和基于此来表达出的技术能力和项目经验。如果候选人基础没有落下太多,思维足够灵活,在过往项目中主动学习,并主导解决过项目问题,说明已经比较优秀了,我们招的每一人都应当拥有激情与学习能力。 所以,当问到候选人不了解的知识点时,通过引导并挖掘出候选人拥有多少问题解决能力,才是最大的权重项,如果这个问题候选人也提前准备了,那说明准备对了。 非技术相关最后考察候选人的发展潜力与工作态度,我们一般通过询问简单的算法问题,进一步了解候选人是否对技术真正感兴趣,而不只是对前端工程感兴趣。同时,算法问题也考察候选人解决抽象问题的能力,或者让候选人设计一个组件,通过对组件需求的不断升级,考察候选人是否能及时给出解决方案。 最后是工作态度,首先会考察人品,对不懂的知识点装懂是违背诚信的行为,任何团队都不会要的。同时,不正视自己技术存在的盲点,将是技术发展的最大阻碍。不过这里也不怕被候选人套路,如果全部都回答不懂那也不用考虑了。 3 总结由于经验不多,只能编出这些体会,希望求职者多一些真诚,少一些套路,就一定会找到满意的工作。 讨论地址是:精读《最佳前端面试题》及前端面试官技巧 · Issue ##27 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《模态框的最佳实践》","path":"/wiki/WebWeekly/前沿技术/《模态框的最佳实践》.html","content":"当前期刊数: 2 本期精读的文章是:best practices for modals overlays dialog windows。 1 引言 我为什么要选这篇文章呢? 前端工程师今天在外界是怎么定位的。很多人以为前端都应该讨论架构层面的问题,其实不仅仅在此,我们不应该忽视交互体验这件事。 对于用户体验的追求前端工程师从来没有停止过,而模态框在产品中的出现出现过很多争议,我想知道我们是怎么思考这件事的。 2 内容概要来自 Wikipedia 的定义:模态框是一个定位于应用视窗顶层的元素。它创造了一种模式让自身保持在一个最外层的子视察下显示,并让主视窗失效。用户必须在回到主视窗前在它上面做交互动作。 模态框用处 抓住用户的吸引力 需要用户输入 在上下文下显示额外的信息 不在上下文下显示额外的信息 不要用模态框显示错误、成功或警告的信息。保持它们在页面上。 模态框的组成 退出的方式。可以是模态框上的一个按钮,可以是键盘上的一个按键,也可以是模态框外的区域。 描述性的标题。标题其实给了用户一个上下文信息。让用户知道他现在在哪个位置作操作。 按钮的内容。它一定要是可行动的,可以理解的。不要试图让按钮的内容让用户迷惑,如果你尝试做一个取消动作,但框内有一个取消的按钮,那么我是要取消一个取消呢,还是继续我的取消。 大小与位置。模态框的大小不要太大或太小,不应该。模态框的位置建议在视窗中间偏上的位置,因为在移动端如果太低的话会失去很多信息。 焦点。模态框的出现一定要吸引你的注意力,建议键盘的焦点也切换到框内。 用户发起。不要对用户造成惊吓。用用户的动作,比如一个按钮的点击来触发模态框的出现。 模态框在移动端 模态框在移动端总是不是玩转得很好。其中一个原因是一般来说模态框都太大了,占用了太多空间。建议增加设备的按键或内置的滚动条来操作,用户可以左移或放大缩小来抓住模态框。 无障碍访问 快捷键。我们应该考虑在打开,移动,管理焦点和关闭时增加对模态框的快捷键。 ARIA。在前端代码层面加上 aria 的标识,如 Role = “dialog” , aria-hidden, aria-label 3 精读模态框定位首先,Modal 与 Toast、Notification、Message 以及 Popover 都会在某个时间点被触发弹出一个浮层,但与 Modal(模态框)还是有所不同的。定义上看,上述组件都不属于模态框,因为模态框有一个重要的特性,即阻塞原来主视窗下的操作,只能在框内作后续动作。也就是说模态框从界面上彻底打断了用户心流。 当然,这也是我们需要讨论的问题,如果只是一般的消息提醒,可以用信息条、小红点等交互形式,至少是不阻塞用户操作的。在原文末引用的 10 Guidelines to Consider when using Overlays 一文中,第 8 条强调了模态框不到万不得以不应该使用。这时我们应该思考什么情况下你非常希望他不要离开页面,来读框内的信息或作操作呢? 反过来说,模态框有什么优点呢?要知道比起页面跳转来说,模态框的体验还是要轻量的多。例如,用户在淘宝上看中了一款商品,想登陆购买,此时弹出登陆模态框的体验就要远远好于跳转到登陆页面,因为用户在模态框中登陆后,就可以直接购买了。其次,模态框的内容对于当前页面来说是一种衍生或补充,可以让用户更为专注去阅读或者填写一些内容。 也就是说,当我们设计好模态框出现的时机,流畅的弹出体验,必要的上下文信息,以及友好的退出反馈,还是完全可以提升体验的。模态框的目的在于吸引注意,但一定需要提供额外的信息,或是一个重要的用户操作,或是一份重要的协议确认。在本页面即可完成流程或信息告知。 合理的使用模态框我们也总结了一些经验,更好地使用模态框。 内容是否相关。模态框是作为当前页面的一种衍生或补充,如果其内容与当前内容毫不相干,那么可以使用其他操作(如新页面跳转)来替代模态框; 模态框内部应该避免有过多的操作。模态框应该给用户一种看完即走,而且走的流畅潇洒的感觉,而不是利用繁杂的交互留住或牵制住用户; 避免出现一个以上的模态框。出现多个模态框会加深了产品的垂直深度,提高了视觉复杂度,而且会让用户烦躁起来; 不要突然打开或自动打开模态框,这个操作应该是用户主动触发的; 还有两种根据实际情况来定义: 大小。对于模态框的大小应该要有相对严格的限制,如果内容过多导致模态框或页面出现滚动条,一般来说这种体验很糟糕,但如果用于展示一些明细内容,我们可能还是会考虑使用滚动条来做; 开启或关闭动画。现在有非常多的设计倾向于用动画完成流畅的过渡,让 Modal 变得不再突兀,dribble 上有很多相关例子。但在一些围绕数据来做复杂处理的应用中,如 ERP、CRM 产品中用户通常关注点都在一个表单和围绕表单做的一系列操作,页面来回切换或复杂的看似酷炫的动画可能都会影响效率。用户需要的是直截了当的完成操作,这时候可能就不需要动画,用户想要的就是快捷的响应。 举两个例子,Facebook 在这方面给我们很好的 demo,它的分享模态框与主视窗是在同一个位置,给人非常流畅的体验。还看到一个细节,从主视窗到模态框焦点上的字体会变大。对比微博,它就把照片等分享形式直接展示出来,焦点在输入框上时也没有变化。 第二个例子是 Quora,Quora 主页呈现的是 Feed 流,点击标题就会打开一个模态框展示它回答的具体内容,内容里面是带有滚动条的,按 ESC 键就可以关闭。非常流畅的体验。相比较之下知乎首页想要快速看内容得来回切换。 可访问性的反思Accessibility 翻译过来是『无障碍访问』,是对不同终端用户的体验完善。每一个模态框,都要有通过键盘关闭的功能,通常使用 ESC 键。似乎我们程序员多少总会把我们自我的惯性思维带进实现的产品,尤其是当我们敲着外置的键盘,用着 PC 的时候。 下面的这些问题都是对可访问性的反思: 用户可能没有鼠标,或者没有键盘,甚至可能既没有鼠标也没有键盘,只使用的是语音控制?你让这些用户如何退出 很多的 Windows PC 都已经获得了很好的触屏支持,而你的网页依旧只支持了键盘跟鼠标? 在没有苹果触摸板的地方,横向滚动条是不是一个逆天的设计? 在网页里,使用 Command(Ctrl) and +/- 和使用触摸板的缩放事件是两个不同的表现? 如果你的终端用户没有好用的触摸板,但是他的确看不清你的网页上的内容。如果他用了前者,你能不能保证你的网页依然能够正常展示内容? 可访问性一直都是产品极其忽视的,在文章的最佳实践最后特别强调了它是怎么做的,对我们这些开发者是很好的督促。 模态框代码实现层面前端开发还是少不了代码层面的实现,业务代码对于有状态或无状态模态框的使用方式存在普遍问题。 对有状态模态框来说,很多库会支持 .show 直接调用的方式,那么模态框内部渲染逻辑,会在此方法执行时执行,没有什么问题。不过现在流行无状态模态框(Stateless Modal),模态框的显示与否交由父级组件控制,我们只要将模态框代码预先写好,由外部控制是否显示。 这种无状态模态框的方式,在模态框需要显示复杂逻辑的场景中,会自然将初始化逻辑写在父级,当模态框出现在循环列表中,往往会引发首屏触发 2-30 次模态框初始化运算,而这些运算最佳状态是模态框显示时执行一次,由于模态框同一时间只会出现一个,最次也是首屏初始化一次,但下面看似没问题的代码往往会引发性能危机: const TdElement = data.map(item => { return ( <Td> <Button>详情</Button> <Modal show={item.show} /> </Td> )}); 上面代码初始化执行了 N 个模态框初始化代码,显然不合适。对于 table 操作列中触发的模态框,所有行都复用同一个模态框,通过父级中一个状态变量来控制展示的内容: class Table extends Component { static state = { activeItem: null, }; render() { const { activeItem } = this.state; return ( <div> <Modal show={!!activeItem} data={activeItem} /> </div> ); }} 这种方案减少了节点数,但是可能会带来的问题是,每次模态框被展示的时候,触发是会是模态框的更新 (componentDidUpdate) 而不是新增。当然结合 table 中操作的特点,我们可以这样优化: {activeItem ? <Modal show={true} data={activeItem} /> : null} 补充阅读总结这篇讲的是最佳实践,而且是 UX 层面的。但我们还是看到一些同学提出了相反的意见,我总结下就是不同的产品或不同的用户带给我们不同的认识。这时候是不是要死守着『最佳实践』呢?这时候,对于产品而言,我们可以采集用户研究的方法去判断,用数据结论代替感官上的结论。 另外,可访问性在这两年时不时会在一些文章中看到,但非常少。这是典型的长尾需求,很多研发在做产品只考虑 90% 的用户,不清楚我们放弃的一部分用户的需求。这是从产品到研发整体的思考的缺失。 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《架构设计之 DCI》","path":"/wiki/WebWeekly/前沿技术/《架构设计之 DCI》.html","content":"当前期刊数: 14 本期精读文章是:The DCI Architecture 1 引言随着前端 ES6 ES7 的一路前行, 我们大前端借鉴和引进了各种其他编程语言中的概念、特性、模式;我们可以使用函数式 Functional 编程设计,可以使用面向对象 OOP 的设计,可以使用面向接口的思想,也可以使用 AOP,可以使用注解,代理、反射,各种设计模式; 在大前端辉煌发展、在数据时代的当下 我们一起阅读了一篇设计相关的老文:《The DCI Architecture》一起来再探索和复习一下 相关的设计和思想 2 内容摘要DCI 是数据 Data 场景 Context 交互 Interactions 简称, 重点是关注 数据的不同场景的交互行为, 是面向对象系统 状态和行为的一种范式设计;DCI 在许多方面是许多过去范式的统一,多年来这些模式已经成为面向对象编程的辅助工具。 尽管面向切面的编程(AOP)也有其他用途,但 DCI 满足了许多 AOP 的应用以及 Aspects 在解决问题方面的许多目标。根据 AOP 的基本原理,DCI 基于深层次的反射或元编程。与 Aspects 不同,角色聚合并组合得很好。Context 提供角色集之间的关联的范围关闭,而 Aspect 仅与应用它们的对象配对。在许多时候,虽然混合本身缺乏我们在 Context 语义中发现的动力 ,但 DCI 反映了混合风格策略。DCI 实现了多范式设计的许多简单目标,能够将过程逻辑与对象逻辑分开。然而,DCI 具有比多范式设计提供的更强大的技术更好的耦合和内聚效果 结合 ATM 汇款场景案例,讲解了一下 DCI角色提供了和用户相关 自然的边界,以转账为例,我们实际谈论的是钱的转移,以及源账户和目标账户的角色,算法(用例 角色行为集合)应该是这样:1.账户拥有人选择从一个账户到另外一个账户的钞票转移。2.系统显示有效账户3.用户选择源账户4.系统显示存在的有效账户5.账户拥有人选择目标账户。6.系统需要数额7.账户拥有人输入数额8.钞票转移 账户进行中(确认金额 修改账户等操作) 设计者的工作就是把这个用例转化为类似交易的算法,如下:1.源账户开始交易事务2.源账户确认余额可用3.源账户减少其帐目4.源账户请求目标账户增加其帐目5.源账户请求目标账户更新其日志 log6.源账户结束交易事务7.源账户显示给账户拥有人转账成功。 template <class ConcreteAccountType>class TransferMoneySourceAccount: public MoneySource{private: ConcreteDerived *const self() { return static_cast<ConcreteDerived*>(this); } void transferTo(Currency amount) { // This code is reviewable and // meaningfully testable with stubs! beginTransaction(); if (self()->availableBalance() < amount) { endTransaction(); throw InsufficientFunds(); } else { self()->decreaseBalance(amount); recipient()->increaseBalance (amount); self()->updateLog("Transfer Out", DateTime(), amount); recipient()->updateLog("Transfer In", DateTime(), amount); } gui->displayScreen(SUCCESS_DEPOSIT_SCREEN); endTransaction(); } 3 精读本次提出独到观点的同学有:@ascoders、@TingGe、@zy,精读由此归纳。 尝试从人类思维角度出发 理解DCI 即 数据(data) 场景(context) 交互(interactive)。 DCI 之所以被提出,是因为传统 mvc 代码,在越来越丰富的交互需求中变得越来越难读。有人会觉得,复杂的需求 mvc 也可以 cover 住,诚然如此,但很少有人能只读一遍源码就能理解程序处理了哪些事情,这是因为人类思维与 mvc 的传统程序设计思想存在鸿沟,我们需要脑补内容很多,才会觉得难度。 现在仍有大量程序使用面向对象的思想表达交互行为,当我们把所有对象之间的关联记录在脑海中时,可能对象之间交互行为会比较清楚,但任无法轻松理解,因为对象的封装会导致内聚性不断增加,交互逻辑会在不同对象之间跳转,对象之间的嵌套关系在复杂系统中无疑是一个理解负担。 DCI 尝试从人类思维角度出发,举一个例子:为什么在看电影时会轻轻松松的理解故事主线呢?回想一下我们看电影的过程,看到一个画面时,我们会思考三件事: 画面里有什么人或物? 人或物发生了什么行为、交互? 现在在哪?厨房?太空舱?或者原始森林? 很快把这三件事弄清楚,我们就能快速理解当前场景的逻辑,并且轻松理解该场景继续发生的状况,即便是盗梦空间这种烧脑的电影,当我们搞清楚这三个问题后,就算街道发生了 180 度扭曲,也不会存在理解障碍,反而可以吃着爆米花享受,直到切换到下一个场景为止。 当我们把街道扭曲 180 度的能力放在街道对象上时,理解就变的复杂了:这个函数什么时候被调用?为什么不好好承载车辆而自己发生扭曲?这就像电影开始时,把电影里播放的所有关于街道的状态都走马灯过一遍:我们看到街道通过了车辆、又卷曲、又发生了爆炸,实在觉得莫名其妙。 理解代码也是如此,当交互行为复杂时,把交互和场景分别抽象出来,以场景为切入点交互数据。 举个例子,传统的 mvc 可能会这么组织代码: UserModel: class My { private name = "ascoders" // 名字 private skills = ["javascript", "nodejs", "切图"] // 技能 private hp = 100 // 生命值?? private account = new Account() // 账户相关} UserController: class Controller { private my = new My() private account = new Account() private accountController = new AccountController() public cook() { // 做饭 } public coding() { // 写代码 } public fireball() { // 搓火球术。。? } public underAttack() { // 受到攻击?? } public pay() { // 支付,用到了 account 与 accountController }} 这只是我自己的行为,当我这个对象,与文章对象、付款行为发生联动时,就发生了各种各样的跳转。到目前为止我还不是非常排斥这种做法,毕竟这样是非常主流的,前端数据管理中,不论是 redux,还是 mobx,都类似 MVC。 不论如何,尝试一下 DCI 的思路吧,看看是否会像看电影一样轻松的理解代码: 以上面向对象思想主要表达了 4 个场景,家庭、工作、梦境、购物: home.scene.scala work.scene.scala dream.scene.scala buy.scene.scala 以程序员工作为例,在工作场景下,写代码可以填充我们的钱包,那么我们看到一个程序员的钱包: codingWallet.scala: case class CodingWallet(name: String, var balance: Int) { def coding(line: Int) { balance += line * 1 }} 写一行代码可以赚 1 块钱,它不需要知道在哪个场景被使用,程序员的钱包只要关注把代码变成钱。 交互是基于场景的,所以交互属于场景,写代码赚钱的交互,放在工作场景中: work.scene.scala: object MoneyTransferApp extends App { @context class MoneyTransfer(wallet: CodingWallet, time: int) { // 在这个场景中,工作 1 小时,可以写 100 行代码 // 开始工作! wallet.working role wallet { def working() { wallet.coding(time) } } } // 钱包默认有 3000 元 val wallet = CodingWallet("wallet", 3000) // 初始化工作场景,工作了 1 小时 new MoneyTransfer(wallet, 1) // 此时钱包一共拥有 3100 元 println(wallet.balance)} 小结:,就是把数据与交互分开,额外增加了场景,交互属于场景,获取数据进行交互。原文的这张图描述了 DCI 与 MVC 之间的关系: 发现并梳理现代前端模式和概念的蛛丝马迹现代前端受益于低门槛和开放,伴随 OO 和各种 MV* 盛行,也出现了越来越多的概念、模式和实践。而 DCI 作为 MVC 的补充,试图通过引入函数式编程的一些概念,来平衡 OO 、数据结构和算法模型。值得我们津津乐道的如 Mixins、Multiple dispatch、 依赖注入(DI)、Multi-paradigm design、面向切面编程(AOP)都是不错的。如果对这些感兴趣,深挖下 AngularJS 在这方面的实践会有不少收获。当然,也有另辟途径的,如 Flux 则采用了 DDD/CQRS 架构。 软件架构设计,是一个很大的话题,也是值得每位工程师长期实践和思考的内容。个人的几点体会: 一个架构,往往强调职责分离,通过分层和依赖原则,来解决程序内、程序间的相互通讯问题; 知道最好的几种可能的架构,可以轻松地创建一个适合的优化方案; 最后,必须要记住,程序必须遵循的架构。 分享些架构相关的文章: Comparison of Architecture presentation patterns MVP(SC),MVP(PV),PM,MVVM and MVC The DCI Architecture: A New Vision of Object-Oriented Programming 干净的架构 The Clean Architecture MVC 的替代方案 展示模式架构比较 MVP(SC),MVP(PV),PM,MVVM 和 MVC Software Architecture Design 【译】什么是 Flux 架构?(兼谈 DDD 和 CQRS) 结合 DCI 设想开发的过程中使用到一些设计方法和原则我们在开发的过程中多多少少都会使用到一些设计方法和原则DCI 重点是关注 数据的不同场景的交互行为, 是面向对象系统 状态和行为的一种范式设计; 它能够将过程逻辑与对象逻辑分开,是一种典型的行为模式设计;很好的点是 它根据 AOP 的基本原理,DCI 提出基于 AOP 深层次的元编程(可以理解成面向接口编程), 去促使系统的内聚效果和降低耦合度; 举个例子:在一个 BI 系统中, 在业务的发展中, 这个系统使用到了多套的 底层图表库,比如: Echarts, G2,Recharts, FusionChart; 等等; 那么问题来了, 如何去同时支持 这些底层库, 并且达到很容易切换的一个效果? 如何去面向未来的考虑 将来接入更多类型的图表? 如何去考虑扩展业务 对图表的日益增强的业务功能(如: 行列转换、智能格式化 等等) 带着这些问题, 我们再来看下 DCI 给我们的启示, 我们来试试看相应的解法: 图表的模型数据就是 数据 Data , 我们可以把[日益增强的业务功能] 认为是各个场景交互 Interactions; 接入更多类型的图表咋么搞?不同类型的图表其实是图表数据模型的转换,我们也可以把这些转换的行为过程作为一个个的切片(Aspect),每个切片都是独立的, 松耦合的 ; 接入多套底层库怎么搞? 每个图形库的 build 方法,render 方法 , resize 方法,repaint 方法 都不一样 ,怎么搞 ? 我们可以使用 DCI 提到的元编程- 我们在这里理解为面向接口编程, 我们分装一层 统一的接口;利用面向接口的父类引用指向子类对象 我们就可以很方便的 接入更多的 implement 接入更多的图形库(当然,一个系统统一一套是最好的); 4 总结DCI 是数据 Data 场景 Context 交互 Interactions 的简称,DCI 是一种特别关注行为的设计模式(行为模式),DCI 关注数据不同场景的交互行为, 是面向对象 状态和行为的一种范式设计;DCI 尝试从人类思维,过程化设计一些行为;DCI 也会使用一些面向切面和接口编程的设计思想去达到高内聚低耦合的目标。 讨论地址是:精读《架构设计 之 DCI》 · Issue ##20 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题, 欢迎来一起学习 共同探索。"},{"title":"《正交的 React 组件》","path":"/wiki/WebWeekly/前沿技术/《正交的 React 组件》.html","content":"当前期刊数: 132 1 引言搭配了合适的设计模式的代码,才可拥有良好的可维护性,The Benefits of Orthogonal React Components 这篇文章就重点介绍了正交性原理。 所谓正交,即模块之间不会相互影响。想象一个音响的音量与换台按钮间如果不是正交关系,控制音量同时可能影响换台,这样的设备很难维护: 前端代码也一样,UI 与数据处理逻辑分离就是一种符合正交原则的设计,这样有利于长期代码质量维护。 2 概述一个拥有良好正交性的 React App 会按照如下模块分离设计: UI 元素(展示型组件)。 取数逻辑(fetch library, REST or GraphQL)。 全局状态管理(redux)。 持久化(local storage, cookies)。 文中通过两个例子说明。 让组件与取数逻辑正交比如一个展示雇员列表组件 <EmployeesPage>: import React, { useState } from "react";import axios from "axios";import EmployeesList from "./EmployeesList";function EmployeesPage() { const [isFetching, setFetching] = useState(false); const [employees, setEmployees] = useState([]); useEffect(function fetch() { (async function() { setFetching(true); const response = await axios.get("/employees"); setEmployees(response.data); setFetching(false); })(); }, []); if (isFetching) { return <div>Fetching employees....</div>; } return <EmployeesList employees={employees} />;} 这样设计看上去没问题,但其实违背了正交原则,因为 EmployeesPage 既负责渲染 UI 又关心取数逻辑。正交的写法如下: import React, { Suspense } from "react";import EmployeesList from "./EmployeesList";function EmployeesPage({ resource }) { return ( <Suspense fallback={<h1>Fetching employees....</h1>}> <EmployeesFetch resource={resource} /> </Suspense> );}function EmployeesFetch({ resource }) { const employees = resource.employees.read(); return <EmployeesList employees={employees} />;} Suspense 将 loading 状态剥离到父级组件,因此子组件只需要关心如何用数据,不需关心如何取数据(以及 loading 态)。 让组件与滚动监听正交比如一个滚动到一定距离就出现 “jump to top” 的组件 <ScrollToTop>,可能会这么实现: import React, { useState, useEffect } from "react";const DISTANCE = 500;function ScrollToTop() { const [crossed, setCrossed] = useState(false); useEffect(function() { const handler = () => setCrossed(window.scrollY > DISTANCE); handler(); window.addEventListener("scroll", handler); return () => window.removeEventListener("scroll", handler); }, []); function onClick() { window.scrollTo({ top: 0, behavior: "smooth" }); } if (!crossed) { return null; } return <button onClick={onClick}>Jump to top</button>;} 可以看到,在这个组件中,按钮与滚动状态判断逻辑混合在了一起。如果我们将 “滚动到一定距离就渲染 UI” 抽象成通用组件 IfScrollCrossed 呢? import { useState, useEffect } from "react";function useScrollDistance(distance) { const [crossed, setCrossed] = useState(false); useEffect( function() { const handler = () => setCrossed(window.scrollY > distance); handler(); window.addEventListener("scroll", handler); return () => window.removeEventListener("scroll", handler); }, [distance] ); return crossed;}function IfScrollCrossed({ children, distance }) { const isBottom = useScrollDistance(distance); return isBottom ? children : null;} 有了 IfScrollCrossed,我们就能专注写 “点击按钮跳转到顶部” 这个 UI 组件了: function onClick() { window.scrollTo({ top: 0, behavior: "smooth" });}function JumpToTop() { return <button onClick={onClick}>Jump to top</button>;} 最后将他们拼装在一起: import React from "react";// ...const DISTANCE = 500;function MyComponent() { // ... return ( <IfScrollCrossed distance={DISTANCE}> <JumpToTop /> </IfScrollCrossed> );} 这么做,我们的 <JumpToTop> 与 <IfScrollCrossed> 组件就是正交关系,而且逻辑更清晰。不仅如此,这样的抽象使 <IfScrollCrossed> 可以被其他场景复用: import React from "react";// ...const DISTANCE_NEWSLETTER = 300;function OtherComponent() { // ... return ( <IfScrollCrossed distance={DISTANCE_NEWSLETTER}> <SubscribeToNewsletterForm /> </IfScrollCrossed> );} Main 组件上面例子中,<MyComponent> 就是一个 Main 组件,Main 组件封装一些脏逻辑,即它要负责不同模块的组装,而这些模块之间不需要知道彼此的存在。 一个应用会存在多个 Main 组件,它们负责拼装各种作用域下的脏逻辑。 正交设计的好处 容易维护: 正交组件逻辑相互隔离,不用担心连带影响,因此可以放心大胆的维护单个组件。 易读: 由于逻辑分离导致了抽象,因此每个模块做的事情都相对单一,很容易猜测一个组件做的事情。 可测试: 由于逻辑分离,可以采取逐个击破的思路进行单测。 权衡如果不采用正交设计,因为模块之间的关联导致应用最终变得难以维护。但如果将正交设计应用到极致,可能会多处许多不必要的抽象,这些抽象的复用仅此一次,造成过度设计。 3 精读正交设计一定程度可以理解为合理抽象,完全不抽象与过度抽象都是不可取的,因此列举了四块需要抽象的要点:UI 元素、取数逻辑、全局状态管理、持久化。 全局状态管理注入到组件,就是一种正交的抽象模式,即组件不用关心数据从哪来,而直接使用数据,而数据管理完全交由数据流层管理。 取数逻辑往往是可能被忽略的一环,无论是像原文中直接关心到 fetch 方法的 UI 组件,还是利用取数工具库关心了 loading 状态: import useSWR from "swr";function Profile() { const { data, error } = useSWR("/api/user", fetcher); if (error) return <div>failed to load</div>; if (!data) return <div>loading...</div>; return <div>hello {data.name}!</div>;} 虽然将取数生命周期封装到自定义 hook useSWR 中,但 error 信息对 UI 组件来说就是一个脏数据:这让这个 UI 组件不仅要渲染数据,还要担心取数是否会失败,或者是否在 loading 中。 好在 Suspense 模式解决了这个问题: import { Suspense } from "react";import useSWR from "swr";function Profile() { const { data } = useSWR("/api/user", fetcher, { suspense: true }); return <div>hello, {data.name}</div>;}function App() { return ( <Suspense fallback={<div>loading...</div>}> <Profile /> </Suspense> );} 这样 <Profile> 只要专注于做数据渲染,而不用担心 useSWR('/api/user', fetcher, { suspense: true }) 这个取数过程发生了什么、是否取数失败、是否在 loading 中。因为取数状态由 Suspense 管理,而取数是否意外失败由 ErrorBoundary 管理。 合理的抽象使组件逻辑变得更简单,从而组件嵌套使用使不用担心额外影响。尤其在大型项目中,不要担心正交抽象会使本来就很多的模块数量再次膨胀,因为相比于维护 100 个相互影响,内部逻辑复杂的模块,维护 200 个职责清晰,相互隔离的模块也许会更轻松。 4 总结从正交设计角度来看,Hooks 解决了状态管理与 UI 分离的问题,Suspense 解决了取数状态与 UI 分离的问题,ErrorBoundary 解决了异常与 UI 分离的问题。 在你看来,React 还有哪些逻辑需要与 UI 分离?分别使用哪些方法呢?欢迎留言。 讨论地址是:精读《正交的 React 组件》 · Issue ##221 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《正则 ES2018》","path":"/wiki/WebWeekly/前沿技术/《正则 ES2018》.html","content":"当前期刊数: 91 1. 引言本周精读的文章是 regexp-features-regular-expressions。 这篇文章介绍了 ES2018 正则支持的几个重要特性: Lookbehind assertions - 后行断言 Named capture groups - 命名捕获组 s (dotAll) Flag - . 匹配任意字符 Unicode property escapes - Unicode 属性转义 2. 概述还在用下标匹配内容吗?匹配任意字符只有 [\\w\\W] 吗?现在正则有更简化的写法了,事实上正则正在变得更加易用,是时候更新对正则的认知了。 2.1. Lookbehind assertions完整的断言定义分为:正/负向断言 与 先/后行断言 的笛卡尔积组合,在 ES2018 之前仅支持先行断言,现在终于支持了后行断言。 解释一下这四种断言: 正向先行断言 (?=...) 表示之后的字符串能匹配 pattern。 const re = /Item(?= 10)/;console.log(re.exec("Item"));// → nullconsole.log(re.exec("Item5"));// → nullconsole.log(re.exec("Item 5"));// → nullconsole.log(re.exec("Item 10"));// → ["Item", index: 0, input: "Item 10", groups: undefined] 负向先行断言 (?!...) 表示之后的字符串不能匹配 pattern。 const re = /Red(?!head)/;console.log(re.exec("Redhead"));// → nullconsole.log(re.exec("Redberry"));// → ["Red", index: 0, input: "Redberry", groups: undefined]console.log(re.exec("Redjay"));// → ["Red", index: 0, input: "Redjay", groups: undefined]console.log(re.exec("Red"));// → ["Red", index: 0, input: "Red", groups: undefined] 在 ES2018 后,又支持了两种新的断言方式: 正向后行断言 (?<=...) 表示之前的字符串能匹配 pattern。 先行时字符串放前面,pattern 放后面;后行时字符串放后端,pattern 放前面。先行匹配以什么结尾,后行匹配以什么开头。 const re = /(?<=€)\\d+(\\.\\d*)?/;console.log(re.exec("199"));// → nullconsole.log(re.exec("$199"));// → nullconsole.log(re.exec("€199"));// → ["199", undefined, index: 1, input: "€199", groups: undefined] 负向后行断言 (?<!...) 表示之前的字符串不能匹配 pattern。 注:下面的例子表示 meters 之前 不能匹配 三个数字。 const re = /(?<!\\d{3}) meters/;console.log(re.exec("10 meters"));// → [" meters", index: 2, input: "10 meters", groups: undefined]console.log(re.exec("100 meters"));// → null 文中给了一个稍复杂的例子,结合了 正向后行断言 与 负向后行断言: 注:下面的例子表示 meters 之前 能匹配 两个数字,且 之前 不能匹配 数字 35. const re = /(?<=\\d{2})(?<!35) meters/;console.log(re.exec("35 meters"));// → nullconsole.log(re.exec("meters"));// → nullconsole.log(re.exec("4 meters"));// → nullconsole.log(re.exec("14 meters"));// → ["meters", index: 2, input: "14 meters", groups: undefined] 2.2. Named Capture Groups命名捕获组可以给正则捕获的内容命名,比起下标来说更可读。 其语法是 ?<name>: const re = /(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})/;const [match, year, month, day] = re.exec("2020-03-04");console.log(match); // → 2020-03-04console.log(year); // → 2020console.log(month); // → 03console.log(day); // → 04 也可以在正则表达式中,通过下标 \\1 直接使用之前的捕获组,比如: 解释一下,\\1 代表 (\\w\\w) 匹配的内容而非 (\\w\\w) 本身,所以当 (\\w\\w) 匹配了 'ab' 后,\\1 表示的就是对 'ab' 的匹配了。 console.log(/(\\w\\w)\\1/.test("abab")); // → true// if the last two letters are not the same// as the first two, the match will failconsole.log(/(\\w\\w)\\1/.test("abcd")); // → false 对于命名捕获组,可以通过 \\k<name> 的语法访问,而不需要通过 \\1 这种下标: 下标和命名可以同时使用。 const re = /\\b(?<dup>\\w+)\\s+\\k<dup>\\b/;const match = re.exec("I'm not lazy, I'm on on energy saving mode");console.log(match.index); // → 18console.log(match[0]); // → on on 2.3. s (dotAll) Flag虽然正则中 . 可以匹配任何字符,但却无法匹配换行符。因此聪明的开发者们用 [\\w\\W] 巧妙的解决了这个问题。 然而这终究是个设计缺陷,在 ES2018 支持了 /s 模式,这个模式下,. 等价于 [\\w\\W]: console.log(/./s.test(" ")); // → trueconsole.log(/./s.test("\\r")); // → true 2.4. Unicode Property Escapes正则支持了更强大的 Unicode 匹配方式。在 /u 模式下,可以用 \\p{Number} 匹配所有数字: u 修饰符可以识别所有大于 0xFFFF 的 Unicode 字符。 const regex = /^\\p{Number}+$/u;regex.test("²³¹¼½¾"); // trueregex.test("㉛㉜㉝"); // trueregex.test("ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ"); // true \\p{Alphabetic} 可以匹配所有 Alphabetic 元素,包括汉字、字母等: const str = "漢";console.log(/\\p{Alphabetic}/u.test(str)); // → true// the \\w shorthand cannot match 漢console.log(/\\w/u.test(str)); // → false 终于有简便的方式匹配汉字了。 2.5. 兼容表可以到 原文 查看兼容表,总体上只有 Chrome 与 Safari 支持,Firefox 与 Edge 都不支持。所以大型项目使用要再等几年。 3. 精读文中列举的四个新特性是 ES2018 加入到正则中的。但正如兼容表所示,这些特性基本还都不能用,所以不如我们再温习一下 ES6 对正则的改进,找一找与 ES2018 正则变化的结合点。 3.1. RegExp 构造函数优化当 RegExp 构造函数第一个参数是正则表达式时,允许指定第二个参数 - 修饰符(ES5 会报错): new RegExp(/book(?=s)/giu, "iu"); 不痛不痒的优化,,毕竟大部分时间构造函数不会这么用。 3.2. 字符串的正则方法将字符串的 match()、replace()、search、split 方法内部调用时都指向到 RegExp 的实例方法上,比如 String.prototype.match 指向 RegExp.prototype[Symbol.match]。 也就是正则表达式原本应该由正则实例触发,但现在却支持字符串直接调用(方便)。但执行时其实指向了正则实例对象,让逻辑更为统一。 举个例子: "abc".match(/abc/g) / // 内部执行时,等价于 abc / g[Symbol.match]("abc"); 3.3. u 修饰符概述中,Unicode Property Escapes 就是对 u 修饰符的增强,而 u 修饰符是在 ES6 中添加的。 u 修饰符的含义为 “Unicode 模式”,用来正确处理大于 \\uFFFF 的 Unicode 字符。 同时 u 修饰符还会改变以下正则表达式的行为: 点字符原本支持单字符,但在 u 模式下,可以匹配大于 0xFFFF 的 Unicode 字符。 将 \\u{61} 含义由匹配 61 个 u 改编为匹配 Unicode 编码为 61 号的字母 a。 可以正确识别非单字符 Unicode 字符的量词匹配。 \\S 可以正确识别 Unicode 字符。 u 模式下,[a-z] 还能识别 Unicode 编码不同,但是字型很近的字母,比如 \\u212A 表示的另一个 K。 基本上,在 u 修饰符模式下,所有 Unicode 字符都可以被正确解读,而在 ES2018,又新增了一些 u 模式的匹配集合来匹配一些常见的字符,比如 \\p{Number} 来匹配 ¼。 3.4. y 修饰符y 修饰符是 “粘连”(sticky)修饰符。 y 类似 g 修饰符,都是全局匹配,也就是从上次成功匹配位置开始,继续匹配。y 的区别是,必须是上一次匹配成功后的下一个位置就立即匹配才算成功。 比如: /a+/g.exec("aaa_aa_a"); // ["aaa"] 3.5. flags通过 flags 属性拿到修饰符: const regex = /[a-z]*/gu;regex.flags; // 'gu' 4. 总结本周精读借着 regexp-features-regular-expressions 这篇文章,一起理解了 ES2018 添加的正则新特性,又顺藤摸瓜的整理了 ES6 对正则做的增强。 如果你擅长这种扩散式学习方式,不妨再进一步温习一下整个 ES6 引入的新特性,笔者强烈推荐阮一峰老师的 ECMAScript 6 入门 一书。 ES2018 引入的特性还太新,单在对 ES6 特性的使用应该和对 ES3 一样熟练。 如果你身边的小伙伴还对 ES6 特性感到惊讶,请把这篇文章分享给他,防止退化为 “只剩项目经验的 JS 入门者”。 讨论地址是:精读《正则 ES2018》 · Issue ##127 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《民工叔单页数据流方案》","path":"/wiki/WebWeekly/前沿技术/《民工叔单页数据流方案》.html","content":"当前期刊数: 5 本周精读文章:单页应用的数据流方案探索 1 引言 前几期精读了前端模块化、语法相关的文章,这次讨论另一个举足轻重的话题:数据流。数据流在前端的地位与工程化、可视化、组件化是一样重要的,没有好的数据流框架与思想的指导,业务代码长期肯定倾向于不可维护的状态,当项目不断增加功能后,管理数据变得更加重要。 早期前端是没有数据流概念的,因为前端非常薄,每个页面只要展示请求数据,不需要数据流管理。 随着前端越来越复杂,框架越来越内聚,数据流方案由分到合,由合又到了分,如今数据流逐渐从框架中解绑,形成了一套通用体系,供各个框架使用。 虽然数据流框架很多,但基本上可以分为 双向数据流党、单向数据流党、响应式数据流党,分别以 Mobx、Redux、Rxjs 为代表呈现三国鼎立之状,顺带一提,对 css 而言也有 css in js 和纯 css党 势均力敌,前端真是不让人省心啊。这次我们来看看民工叔徐飞在 QConf 分享的主题:单页应用的数据流方案探索。 2 内容概要文中主要介绍了响应式编程理念,提到的观点,主要有: Reactive 数据封装 数据源,数据变更的归一 局部与全局状态的归一 分形思想 action 分散执行 app 级别数据处理,推荐前端 Orm 整体来看,核心思路是推荐组件内部完成数据流的处理,不用关心使用了 Redux Mobx 或者 Rxjs,也不用关心这些库是否有全局管理的野心,如果全局管理那就挂载到全局,但组件内部还是局部管理。 最后谈到了 Rxjs、xstream 响应式数据流的优势,但并未放出框架,仅仅指点了思想,让一些读者心里痒痒。但现在太多”技术大牛“把”业界会议“当成了打广告,或者工作汇报的机会,所谓授人以鱼不如授人以渔,这篇文章卓尔不群。 3 精读一切技术都要看业务场景,民工叔的 单页应用数据流方案 解决的是重前端的复杂业务场景,虽然现在前端几乎全部单页化,但单页也不能代表业务数据流是复杂的,比如偏数据展示型的中台单页应用就不适合使用这套方案。 此文讨论的是纯数据流方案,与 Dom 结合的方案可以参考 cyclejs,但这个库主要搭建了 Reactive -> Dom 的桥梁,使用起来还要参考此文的思路。 3.1 响应式数据流是最好的方案吗?我认为前端数据流方案迭代至今,并不存在比如:面向对象 -> 函数式 -> 响应式,这种进化链路,不同业务场景下都有各自优势。 面向对象以 Mobx 为代表,轻前端用的较多,因为复杂度集中在后端,前端做好数据展示即可,那么直接拥抱 js 这种基于对象的语言,结合原生 Map Proxy Reflect 将副作用进行到底,开发速度快得飞起。 数据存储方式按照视图形态来,因为视图之间几乎毫无关联,而且特别是数据产品,后端数据量巨大,把数据处理过程搬到前端是不可能的(为了推导出一个视图形态数据,需要动辄几 GB 的原始数据运算,存储和性能都不适合在前端做)。 函数式以 Haskell 为代表,金融行业用的比较多,可能原因是金融对数据正确性非常敏感,不仅函数式适合分布式计算,更重要的是无副作用让数据计算更安全可靠。 个人认为最重要的原因是,金融行业本来很少有副作用,像前端天天与 Dom 打交道的,副作用完全逃不了。 响应式以 Rxjs 为代表,重前端更适合使用。对于 React native 等 App 级别的开发,考虑到数据一致性(比如修改昵称后回退到文章详情,需同步作者修改后的昵称),优先考虑原始类型存储,更适合抽象出前端 Orm 作为数据源。 其实 Orm 作为数据源,面向对象也很适合,但响应式编程的高层次抽象,使其对数据源、数据变动的依赖可插拔,中等规模使用大对象作为数据源,App 级别使用 Orm 作为数据源,因地制宜。 3.2 分形思想分形思想即充血组件的升级版,特点是同时支持贫血组件的被外部控制能力。 分形的优点分形保证了两点: 组件和数据流融为整体,与外部数据流隔离,甚至将数据处理也融合在数据管道中,便于调试。 便于组件复用,因为数据流作为组件的一部分。 如果结合文中的 本地状态 概念,局部数据也放在全局,就出现了第三点好处: 创建局部数据等于创建了全局数据,这样代码调试可局部,可整体,更加灵活。 本地状态 可以参考 dva 框架的设计,如果没有全局 Redux 就创建一个,否则就挂载到全局 Redux 上。 分形的缺点对于聊天室或者在线 IDE 等,全局数据居多,很多交叉绑定的情况,就不适合分形思想,反而纯 Redux 思想更合适。 3.3 数据形态,是原始数据还是视图数据?我认为这也是分业务场景,文章提到不应该太偏向视图结构数据,是有道理的,意思是说,在适合原始结构数据时,就不要倾向于视图结构数据了。但有必要补充一下,在后端做了大量工作的中台场景,前端数据层非常薄,同时拿到的数据也是后端服务集群计算后的离线数据,显然原始数据结构不可能放在前端,这时候就不要使用原始数据存储了。 3.4 从原始数据到视图数据的处理过程放在哪文中推荐放在 View 中处理,因为考虑到不想增加额外的 Store,但不知道这个 Store 是否包含组件局部的 Store。业务组件推荐使用内部数据流操作,但最终还是会将视图数据存在全局 Store 中,只是对组件而言,是局部的,对项目而言是全局的,而且这样对特定的情况,比如其他组件复用数据变更的监听可以支持到。 总结我们到头来还是没有提供一个完美的解决方案,但提供了一个完整的思路,即在不同场景下,如何选择最合适的数据流方案。 最后,不要盲目选型,就像上面提到的,这套方案对复杂场景非常棒,但也许你的业务完全不适合。不要纠结于文中为何没有给出系统化解决方案的 Coding 库,我们需要了解响应式数据流的优势,同时要看清自己的业务场景,打造一套合适的数据流方案。 最后的最后,如有不错的数据流方案,解决了特定场景的痛点,欢迎留言。 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《深入了解现代浏览器一》","path":"/wiki/WebWeekly/前沿技术/《深入了解现代浏览器一》.html","content":"当前期刊数: 219 Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第一篇。 虽然本文写于 2018 年,但如今依然值得学习,因为浏览器实现非常复杂,从细节开始学习很容易迷失方向,缺乏整体感,而这篇文章从宏观层面开始介绍,几乎没有涉及代码实现,全都是思路性的描述,非常适合培养对浏览器整体框架性思维。 原文有非常多形象的插图与动图,便于加深对知识的理解,所以也推荐直接阅读原文。 概述文章先从 CPU、GPU、操作系统开始介绍,因为这些是浏览器运行的基座。 CPU、GPU、操作系统、应用的关系CPU 即中央处理器,可以处理几乎所有计算。以前的 CPU 是单核的,现在大部分笔记电脑都是多核的,专业服务器甚至有高达 100 多核的。CPU 计算能力很强,但只能一件件事处理, GPU 一开始是为图像处理设计的,即主要处理像素点,所以拥有大量并行的处理简单事物的能力,非常适合用来做矩阵运算,而矩阵运算又是计算机图形学的基础,所以大量用在可视化领域。 CPU、GPU 都是计算机硬件,这些硬件各自都提供了一些接口供汇编语言调用;而操作系统则基于它们之上用 C 语言(如 linux)将硬件管理了起来,包括进程调度、内存分配、用户内核态切换等等;运行在操作系统之上的则是应用程序了,所以应用程序不直接和硬件打交道,而是通过操作系统间接操作硬件。 为什么应用程序不能直接操作硬件呢?这样做有巨大的安全隐患,因为硬件是没有任何抽象与安全措施的,这意味着理论上一个网页可以通过 js 程序,在你打开网页时直接访问你的任意内存地址,读取你的聊天记录,甚至读取历史输入的银行卡密码进行转账操作。 显然,浏览器作为一个应用程序,运行在操作系统之上。 进程与线程为了让程序运行的更安全,操作系统创造了进程与线程的概念(linux 对进程与线程的实现是同一套),进程可以分配独立的内存空间,进程内可以创建多个线程进行工作,这些线程共享内存空间。 因为线程间共享内存空间,因此不需通信就能交流,但内存地址相互隔离的进程间也有通信需求,需通过 IPC(Inter Process Communication)进行通信。 进程之间相互独立,即一个进程挂了不会影响到其它进程,而在一个进程中可以创建一个新进程,并与之通信,所以浏览器就采用了这种策略,将 UI、网络、渲染、插件、存储等模块进程独立,并且任意挂掉后都可以被重新唤起。 浏览器架构浏览器可以拆分为许多独立的模块,比如: 浏览器模块(Browser):负责整个浏览器内行为协调,调用各个模块。 网络模块(Network):负责网络 I/O。 存储模块(Storage):负责本地 I/O。 用户界面模块(UI):负责浏览器提供给用户的界面模块。 GPU 模块:负责绘图。 渲染模块(Renderer):负责渲染网页。 设备模块(Device):负责与各种本地设备交互。 插件模块(Plugin):负责处理各类浏览器插件。 基于这些模块,浏览器有两种可用的架构设计,一种是少进程,一种是多进程。 少进程是指将这些模块放在一个或有限的几个进程里,也就是每个模块一个线程,这样做的好处是最大程度共享了内存空间,对设备要求较低,但问题是只要一个线程挂了都会导致整个浏览器挂掉,因此稳定性较差。 多进程是指为每个模块(尽量)开辟一个进程,模块间通过 IPC 通信,因此任何模块挂掉都不会影响其它模块,但坏处是内存占用较大,比如浏览器 js 解析与执行引擎 V8 就要在这套架构下拷贝多份实例运行在每个进程中。 Chrome 多进程架构的优势Chrome 尽量为每个 tab 单独创建一个进程,所以我们才能在某个 tab 未响应时,从容的关闭它,而其它 tab 不会受到影响。不仅是 tab 间,一个 tab 内的 iframe 间也会创建独立的进程,这样做是为了保护网站的安全性。 服务化 - 单/多进程弹性架构Chrome 并不满足于采用一种架构,而是在不同环境下切换不同的架构。Chrome 将各功能模块化后,就可以自由决定当前将哪些模块放在一个进程中,将哪些模块启动独立进程,即可以在运行时决定采用哪套进程架构。 这样做的好处是,可以在资源受限的机器上开启单进程模式,以尽量节约内存开销,实际上在手机应用上就是这么做的;而在资源丰富、内核数量充足的机器上采用独立进程模式,虽然消耗了更多资源,但获得了更好的稳定性。 Iframe 独占进程site-isolation 将同一个 tab 内不同 iframe 包裹在不同的进程内运行,以确保 iframe 间资源的独占性,以及安全性。该功能直到 2018.7 才更新,是因为背后有许多复杂的工作要处理,比如开发者工具的调试、网页的全局搜索功能,都不能因为进程的隔离而受到影响,Chrome 必须让每个进程单独响应这些操作,并最终聚合在一起,让用户感受不到进程间的阻隔。 精读本文从浏览器如何基于操作系统提供的进程、线程概念构建自己的应用程序开始,从硬件、操作系统、软件的分层开始,介绍到浏览器是如何划分模块的,并且分配进程或线程给这些模块运行,这背后的思考非常有价值。 从宏观角度看,要设计一个安全稳定、高性能、具有拓展性的浏览器,首先要把各功能模块划分清楚,并定义好各模块的通信关系,在各业务场景下制定一套模块协作的流程。 浏览器的主从架构类似应用程序的主从模式,浏览器的 Browser 模块可以看作主模块,它本身用于协调其它模块的运行,并维持其它各模块的正常工作,在其它模块失去响应时等待或重新唤起,或者在模块销毁时进行内存回收。 各从模块也分工明确,比如在浏览器敲击 URL 地址时,会先通过 UI 模块响应用户的输入,并判断输入是否为 URL 地址,因为输入的可能是其它非法参数,或一些查询或设置命令。若输入的确实是 URL 地址,则校验通过后,会通知 Network 网络模块发送请求,UI 模块就不再关心请求是如何处理了。Network 模块也是相对独立的,仅处理请求的发送与接收,如果接收到的是 HTML 网页,则交给 Renderer 模块进行渲染。 有了这些相对独立且分工明确的模块划分后,将这些模块作为线程或进程管理就都不会影响它们的业务逻辑了,唯一影响的就是内存是否共享,以及某个模块 crash 后是否会影响到其它模块了,所以基于这个架构,判断设备类型,以采用单进程或多进程模式就变得简单了很多,且这个进程弹性架构本身也不需要入侵各模块业务逻辑,本身就是一套独立的机制。 浏览器作为非常复杂的应用程序,想要持续维护,就必须对每个功能点都进行合理的设计,让模块间高内聚、低耦合,这样才不至于让任何修改牵一发而动全身。 tab、iframe 进程隔离微前端的沙箱隔离方案也比较火,这里可以和浏览器 tab/iframe 隔离做个对比。 基于 js 运行时的沙箱方案大多都因为吐槽 iframe 慢而诞生的,一般会基于 with 改变沙箱代码的上下文,修改访问的全局对象引用,但基于 js 原型链特征,为了阻断向原型链追溯到主应用代码,一般会采用 proxy 对 with mock 的变量进行访问阻断。 还有一些方案利用创建空 iframe 获取到 document 变量传递给沙箱,一定程度做到了访问隔离,且对 document 添加的监听会随 iframe 销毁而销毁,便于控制。 还有一些更加彻底的尝试,将 js 代码扔到 web worker 运行,并通过 mock 模拟了 worker 运行时缺失的 dom API。 对比这些方案可以发现,只有最后 worker 的方案是最彻底的,因为浏览器创建的 worker 进程是完全资源隔离的,想要和浏览器主线程通信只能利用 postMessage,虽然有一些基于 ArrayBuffer 的内存共享方案,但因为支持的数据类型具有针对性,也不会存在安全问题。 回到浏览器开发者的视角,为什么 iframe 隔离要花费九牛二虎之力拆分多进程,最后再费很大功夫拼接回来,还原出一个相对无缝的体验?浏览器厂商其实完全可以利用上面提到的 js 运行时能力,对 API 语法进行改造,创建一个逻辑上的沙盒环境。 我认为本质原因是浏览器要实现的沙盒必须是进程层面的,也就是对内存访问权限的绝对隔离,因为逻辑层面的隔离可能随着各浏览器厂商实现差异,或 API 本身存在的逻辑漏洞而导致越权情况的出现,所以如果需要构造一个完全安全的沙盒,最好利用浏览器提供的 API 创建新的进程处理沙盒代码。 总结本文介绍了浏览器是如何基于操作系统做宏观架构设计的,主要就说了一件事,即对进程,线程模型的弹性使用。同时在 tab、iframe 的设计中也要考虑到安全性要求,在必要的时候采用进程,在浏览器自身模块间因为没有安全性问题,所以可对进程模型进行灵活切换。 讨论地址是:精读《深入了解现代浏览器一》· Issue ##374 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《深入了解现代浏览器三》","path":"/wiki/WebWeekly/前沿技术/《深入了解现代浏览器三》.html","content":"当前期刊数: 221 Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第三篇。 概述本篇宏观的介绍 renderer process 做了哪些事情。 浏览器 tab 内 html、css、javascript 内容基本上都由 renderer process 的主线程处理,除了一些 js 代码会放在 web worker 或 service worker 内,所以浏览器主线程核心工作就是解析 web 三剑客并生成可交互的用户界面。 解析阶段首先 renderer process 主线程会解析 HTML 文本为 DOM(Document Object Model),直译为中文就是文档对象模型,所以首先要把文本结构化才能继续处理。不仅是浏览器,代码的解析也得首先经历 Parse 阶段。 对于 HTML 的 link、img、script 标签需要加载远程资源的,浏览器会调用 network thread 优先并行处理,但遇到 script 标签就必须停下来优先执行,因为 js 代码可能会改变任何 dom 对象,这可能导致浏览器要重新解析。所以如果你的代码没有修改 dom 的副作用,可以添加 async、defer 标签,或 JS 模块的方式使浏览器不必等待 js 的执行。 样式计算只有 DOM 是不够的,style 标签申明的样式需要作用在 DOM 上,所以基于 DOM,浏览器要生成 CSSOM,这个 CSSOM 主要是基于 css 选择器(selector)确定作用节点的。 布局有了 DOM、CSSOM 仍然不足以绘制网页,因为我们仅知道结构和样式,但不知道元素的位置,这就需要生成 LayoutTree 以描述布局的结构。 LayoutTree 和 DOM 结构很像了,但比如 display: none 的元素不会出现在 LayoutTree 上,所以 LayoutTree 仅考虑渲染结构,而 DOM 是一个综合描述结构,它不适合直接用来渲染。 原文特别提到,LayoutTree 有个很大的技术难点,即排版,Chrome 专门有一整个团队在攻克这个技术难题。为什么排版这么难?可以从这几个例子中体会冰山一角:盒模型间碰撞、字体撑开内容导致换行,引发更大区域的重新排版、一个盒模型撑开挤压另一个盒模型,但另一个盒模型大小变化后内容排版也随之变化,导致盒模型再次变化,这个变化又导致了外部其它盒模型的布局变化。 布局最难的地方在于,需要对所有奇奇怪怪的布局定式做一个尽量合理的处理,而很多时候布局定式间规则是相互冲突的。而且这还不考虑布局引擎的修改在数亿网页上引发未知 BUG 的风险。 绘图有了 DOM、CSSOM、LayoutTree 就够了吗?还不行,还缺少最后一环 PaintRecord,这个指绘图记录,它会记录元素的层级关系,以决定元素绘制的顺序。因为 LayoutTree 仅决定了物理结构,但不决定元素的上下空间结构。 有了 DOM、CSSOM、LayoutTree、PaintRecord 之后,终于可以绘图了。然而当 HTML 变化时,重绘的代价是巨大的,因为上面任何一步的计算结果都依赖前面一步,HTML 改变时,需要对 DOM、CSSOM、LayoutTree、PaintRecord 进行重新计算。 大部分时候浏览器都可以在 16ms 内完成,使 FPS 保持在 60 左右,但当页面结构过于复杂,这些计算本身超过了 16ms,或其中遇到 js 代码的阻塞,都会导致用户感觉到卡顿。当然对于 js 卡顿问题可以通过 requestAnimationFrame 把逻辑运算分散在各帧空闲时进行,也可以独立到 web worker 里。 合成绘图的步骤称为 rasterizing(光栅化)。在 Chrome 最早发布时,采用了一种较为简单的光栅化方案,即仅渲染可视区域内的像素点,当滚动后,再补充渲染当前滚动位置的像素点。这样做会导致渲染永远滞后于滚动。 现在一般采用较为成熟的合成技术(compositing),即将渲染内容分层绘制与渲染,这可以大大提升性能,并可通过 CSS 属性 will-change 手动申明为一个新层(不要滥用)。 浏览器会根据 LayoutTree 分析后得到 LayerTree(层树),并根据它逐层渲染。 合成层会将绘图内容切分为多个栅格并交由 GPU 渲染,因此性能会非常好。 精读从渲染分层看性能优化本篇提到了浏览器渲染的 5 个重要环节:解析、样式、布局、绘图、合成,是前端开发者日常工作中对浏览器体感最深的部分,也是优化最常发生在的部分。 其实从性能优化角度来看,解析环节可以被替代为 JS 环节,因为现代 JS 框架往往没有什么 HTML 模版内容要解析,几乎全是 JS 操作 DOM,所以可以看作 5 个新环节:JS、样式、布局、绘图、合成。 值得注意的是,几乎每层的计算都依赖上层的结果,但并不是每层都一定会重复计算,我们需要尤其注意以下几种情况: 修改元素几何属性(位置、宽高等)会触发所有层的重新计算,因为这是一个非常重量级的修改。 修改某个元素绘图属性(比如颜色和背景色),并不影响位置,则会跳过布局层。 修改比如 transform 属性会跳过布局与绘图层,这看上去很不可思议。 对于第三点,由于 transform 的内容会提升到合成层并交由 GPU 渲染,因此并不会与浏览器主线程的布局、绘图放在一起处理,所以视觉上这个元素的确产生了位移,但它和修改 left、top 的位移在实现上却有本质的不同。 所以站在浏览器开发者的角度,可以轻松理解为什么这种优化不是奇技淫巧了,因为本身浏览器的实现就把布局、绘图与合成层的行为分离开了,不同的代码底层方案不同,性能肯定会不同。你可以通过 csstriggers 查看不同 css 属性会引发哪些层的重计算。 当然作为开发者还是可以吐槽,为什么浏览器不能 “自动把 left top 与 transform 的实现细节屏蔽,并自动进行合理的分层”,然而如果浏览器厂商做不到这一点,开发者还是主动去了解实现原理吧。 隐式合成层、层爆炸、层自动合并除了 transform、will-change 属性外,还有很多种情况元素会提升到合成层,比如 video、canvas、iframe,或 fixed 元素,但这些都有明确的规则,所以属于显示合成。 而隐式合成是指元素没有被特别标记,但也被提升到合成层的情况,这种情况常见发生在 z-index 元素产生重叠时,下方的元素显示申明提升到合成层,则浏览器为了保证 z-index 覆盖关系,就要隐式把上方的元素提升到合成层。 层爆炸是指隐式合成的原因,当 css 出现一些复杂行为时(比如轨迹动画),浏览器无法实时捕捉哪些元素位于当前元素上方,所以只好把所有元素都提升到合成层,当合成层数量过多,主线程与 GPU 的通信可能会成为瓶颈,反而影响性能。 浏览器也会支持层自动合并,比如隐式提升到合成层时,多个元素会自动合并在一个合成层里。但这种方式也并不总是靠谱,自动处理毕竟猜不到开发者的意图,所以最好的优化方式是开发者主动干预。 我们只要注意将所有显示提升到合成层的元素放在 z-index 的上方,这样浏览器就有了判断依据,不用再担惊受怕会不会这个元素突然移动到某个元素的位置,导致压住了那个元素,于是又不得不把这个元素给隐式提升到合成层以保证它们之间顺序的正确性,因为这个元素本来就位于其它元素的最上方。 总结读完这篇文章,希望你能根据浏览器在渲染进程的实现原理,总结出更多代码级别的性能优化经验。 最后想要吐槽的是,浏览器规范由于是逐步迭代的,因此看似都在描述位置的 css 属性其实背后实现原理是不同的,虽然这个规则体现在 W3C 规范上,但如果仅从属性名是很难看出来端倪的,因此想要做极致性能优化就必须了解浏览器实现原理。 讨论地址是:精读《深入了解现代浏览器三》· Issue ##379 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《深入了解现代浏览器二》","path":"/wiki/WebWeekly/前沿技术/《深入了解现代浏览器二》.html","content":"当前期刊数: 220 Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第二篇。 概述本篇重点介绍了 浏览器路由跳转后发生了什么,下一篇会介绍浏览器的渲染进程是如何渲染网页的,环环相扣。 在上一篇介绍了,browser process 包含 UI thread、network thread 和 storage thread,当我们在浏览器菜单栏输入网址并敲击回车时,这套动作均由 browser process 的 UI thread 响应。 接下来,按照几种不同的路由跳转场景,分别介绍了内部流程。 普通的跳转第一步,UI thread 响应输入,并判断是否为一个合法的网址,当然输入的也可能是个搜索协议,这就会导致分发到另外的服务处理。 第二步,如果第一步输入的是合法网址,则 UI thread 会通知 network thread 获取网页内容,network thread 会寻找合适的协议处理网络请求,一般会通过 DNS 协议 寻址,通过 TLS 协议 建立安全链接。如果服务器返回了比如 301 重定向信息,network thread 会通知 UI thread 这个信息,再启动一遍第二步。 第三步,读取响应内容,在这一步 network thread 会首先读取首部一些字节,即我们常说的响应头,其中包含 Content-Type 告知返回内容是什么。如果返回内容是 HTML,则 network thread 会将数据传送给 renderer process。这一步还会校验安全性,比如 CORB 或 cross-site 问题。 第四步,寻找 renderer process。一旦所有检查都完成,network thread 会通知 UI thread 已经准备好跳转了(注意此时并没有加载完所有数据,第三步只是检查了首字节),UI thread 会通知 renderer process 进行渲染。为了提升性能,UI thread 在通知 network thread 的同时就会实例化一个 renderer process 等着,一旦 network thread 完毕后就可以立即进入渲染阶段,如果检查失败则丢弃提前实例化的 renderer process。 第五步,确认导航。第四步后,browser process 通过 IPC 向 renderer process 传送 stream(精读《web streams》)数据。此时导航会被确认,浏览器的各个状态(比如导航状态、前进后退历史)将会被修改,同时为了方便 tab 关闭后快速恢复,会话记录会被存储在硬盘。 额外步骤,加载完成。当 renderer process 加载完成后(具体做了什么下一篇会说明),会通知 browser process onLoad 事件,此时浏览器完成最终加载完毕状态,loading 圆圈也会消失,各类 onLoad 的回调触发。注意此时 js 可能会继续加载远程资源,但这都是加载状态完成后的事了。 跳转到别的网站当你准备跳转到别的网站时,在执行普通跳转流程前,还会响应 beforeunload 事件,这个事件注册在 renderer process,所以 browser process 需要检查 renderer process 是否注册了这个响应。注册 beforeunload 无论如何都会拖慢关闭 tab 的速度,所以如无必要请勿注册。 如果跳转是 js 发出的,那么执行跳转就由 renderer process 触发,browser process 来执行,后续流程就是普通的跳转流程。要注意的是,当执行跳转时,会触发原网站 unload 等事件(网页生命周期),所以这个由旧的 renderer process 响应,而新网站会创建一个新的 renderer process 处理,当旧网页全部关闭时,才会销毁旧的 renderer process。 也就是说,即便只有一个 tab,在跳转时,也可能会在短时间内存在多个 renderer process。 Service WorkerService Worker 可以在页面加载前执行一些逻辑,甚至改变网页内容,但浏览器仍然把 Service Worker 实现在了 renderer process 中。 当 Service Worker 被注册后,会被丢到一个作用域中,当 UI thread 执行时会检查这个作用域是否注册了 Service Worker,如果有,则 network thread 会创建一个 renderer process 执行 Service Worker(因为是 js 代码)。然后网络响应会被 Service Worker 接管。 但这样会慢一步,所以 UI thread 往往会在注册 Service Worker 的同时告诉 network thread 发送请求,这就是 Navigation Preload 机制。 本文介绍了网页跳转时发生的步骤,涉及 browser process、UI thread、network thread、renderer process 的协同。 精读也许你会有疑问,为什么是 renderer process 而不是 renderer thread?因为相比 process(进程)相比 thread(线程),之间数据是被操作系统隔离的,为了网页间无法相互读取数据(mysite.com 读取你 baidu.com 正在输入的账号密码),浏览器必须为每个 tab 创建一个独立的进程,甚至每个 iframe 都必须是独立进程。 读完第二篇,应该能更深切的感受到模块间合理分工的重要性。 UI thread 处理浏览器 UI 的展现与用户交互,比如当前加载的状态变化,历史前进后退,浏览器地址栏的输入、校验与监听按下 Enter 等事件,但不会涉及诸如发送请求、解析网页内容、渲染等内容。 network thread 也仅处理网络相关的事情,它主要关心通信协议、安全协议,目标就是快速准确的找到网站服务器,并读取其内容。network thread 会读取内容头做一些前置判断,读取内容和 renderer process 做的事情是有一定重合的,但 network thread 读取内容头仅为了判断内容类型,以便交给渲染引擎还是下载管理器(比如一个 zip 文件),所以为了不让渲染引擎知道下载管理器的存在,读取内容头必须由 network thread 来做。 与 renderer process 的通信也是由 browser process 来做的,也就是 UI thread、network thread 一旦要创建或与 renderer process 通信,都会交由它们所在的 browser process 处理。 renderer process 仅处理渲染逻辑,它不关心是从哪来的,比如是网络请求过来的,还是 Service Worker 拦截后修改的,也不关心当前浏览器状态是什么,它只管按照约定的接口规范,在指定的节点抛出回调,而修改应用状态由其它关心的模块负责,比如 onLoad 回调触发后,browser process 处理浏览器的状态就是一个例子。 再比如 renderer process 里点击了一个新的跳转链接,这个事情发生在 renderer process,但会交给 browser process 处理,因为每个模块解耦的非常彻底,所以任何复杂工作都能找到一个能响应它的模块,而这个模块也只要处理这个复杂工作的一部分,其余部分交给其它模块就好了,这就是大型应用维护的秘诀。 所以在浏览器运行周期里,有着非常清晰的逻辑链路,这些模块必须事先规划设计好,很难想象这些模块分工是在开发中逐渐形成的。 最后提到加速优化,Chrome 惯用技巧就是,用资源换时间。即宁可浪费潜在资源,也要让事物尽可能的并发,这些从提前创建 renderer process、提前发起 network process 都能看出来。 总结深入了解现代浏览器二介绍了网页跳转时发生的,browser process 与 renderer process 是如何协同的。 也许这篇文章可以帮助你回答 “聊聊在浏览器地址栏输入 www.baidu.com 并回车后发生了什么事儿吧!” 讨论地址是:精读《深入了解现代浏览器二》· Issue ##375 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《深入了解现代浏览器四》","path":"/wiki/WebWeekly/前沿技术/《深入了解现代浏览器四》.html","content":"当前期刊数: 222 Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第四篇。 概述前几章介绍了浏览器的基础进程、线程以及它们之间协同的关系,并重点说到了渲染进程是如何处理页面绘制的,那么最后一章也就深入到了浏览器是如何处理页面中事件的。 全篇站在浏览器实现的视角思考问题,非常有趣。 输入进入合成器这是第一小节的标题。乍一看可能不明白在说什么,但这句话就是本文的核心知识点。为了更好的理解这句话,先要解释输入与合成器是什么: 输入:不仅包括输入框的输入,其实所有用户操作在浏览器眼中都是输入,比如滚动、点击、鼠标移动等等。 合成器:第三节说过的,渲染的最后一步,这一步在 GPU 进行光栅化绘图,如果与浏览器主线程解耦的化效率会非常高。 所以输入进入合成器的意思是指,在浏览器实际运行的环境中,合成器不得不响应输入,这可能会导致合成器本身渲染被阻塞,导致页面卡顿。 “non-fast” 滚动区域由于 js 代码可以绑定事件监听,而且事件监听中存在一种 preventDefault() 的 API 可以阻止事件的原生效果比如滚动,所以在一个页面中,浏览器会对所有创建了此监听的区块标记为 “non-fast” 滚动区域。 注意,只要创建了 onwheel 事件监听就会标记,而不是说调用了 preventDefault() 才会标记,因为浏览器不可能知道业务什么时候调用,所以只能一刀切。 为什么这种区域被称为 “non-fast”?因为在这个区域触发事件时,合成器必须与渲染进程通信,让渲染进程执行 js 事件监听代码并获得用户指令,比如是否调用了 preventDefault() 来阻止滚动?如果阻止了就终止滚动,如果没有阻止才会继续滚动,如果最终结果是不阻止,但这个等待时间消耗是巨大的,在低性能设备比如手机上,滚动延迟甚至有 10~100ms。 然而这并不是设备性能差导致的,因为滚动是在合成器发生的,如果它可以不与渲染进程通信,那么即便是 500 元的安卓机也可以流畅的滚动。 注意事件委托更有意思的是,浏览器支持一种事件委托的 API,它可以将事件委托到其父节点一并监听。 这本是一个非常方便的 API,但对浏览器实现可能是一个灾难: document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault(); }}); 如果浏览器解析到上面的代码,只能用无语来形容。因为这意味着必须对全页面都进行 “non-fast” 标记,因为代码委托的是整个 document!这会导致滚动非常慢,因为在页面任何地方滚动都要发生一次合成器与渲染进程的通信。 所以最好的办法就是不要写这种监听。但还有一种方案是,告诉浏览器你不会 preventDefault(),这是因为 chrome 通过对应用源码统计后发现,大约 80% 的事件监听没有 preventDefault(),而仅仅是做别的事情,所以合成器应该可以与渲染进程的事件处理并行进行,这样既不卡顿,逻辑也不会丢失。所以添加了一种 passive: true 的标记,标识当前事件可以并行处理: document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault() } }, {passive: true}); 这样就不会卡顿了,但 preventDefault() 也会失效。 检查事件是否可取消对于 passive: true 的情况,事件就实际上变得不可取消了,所以我们最好在代码里做一层判断: document.body.addEventListener('touchstart', event => { if (event.cancelable && event.target === area) { event.preventDefault() } }, {passive: true}); 然而这仅仅是阻止执行没有意义的 preventDefault(),并不能阻止滚动。这种情况下,最好的办法是通过 css 申明来阻止横向移动,因为这个判断不会发生在渲染进程,所以不会导致合成器与渲染进程的通信: ##area { touch-action: none;} 事件合并由于事件触发频率可能比浏览器帧率还要高(1 秒 120 次),如果浏览器坚持对每个事件都进行响应,而一次事件都必须在 js 里响应一次的话,会导致大量事件阻塞,因为当 FPS 为 60 时,一秒也仅能执行 60 次事件响应,所以事件积压是无法避免的。 为了解决这个问题,浏览器在针对可能导致积压的事件,比如滚动事件时,将多个事件合并到一次 js 中,仅保留最终状态。 如果不希望丢掉事件中间过程,可以使用 getCoalescedEvents 从合并事件中找回每一步事件的状态: window.addEventListener('pointermove', event => { const events = event.getCoalescedEvents(); for (let event of events) { const x = event.pageX; const y = event.pageY; // draw a line using x and y coordinates. }}); 精读只要我们认识到事件监听必须运行在渲染进程,而现代浏览器许多高性能 “渲染” 其实都在合成层采用 GPU 做,所以看上去方便的事件监听肯定会拖慢页面流畅度。 但就这件事在 React 17 中有过一次讨论 Touch/Wheel Event Passiveness in React 17(实际上在即将到来的 18 该问题还在讨论中 React 18 not passive wheel / touch event listeners support),因为 React 可以直接在元素上监听 Touch、Wheel 事件,但其实框架采用了委托的方式在 document(后在 app 根节点)统一监听,这就导致了用户根本无从决定事件是否为 passive,如果框架默认 passive,会导致 preventDefault() 失效,否则性能得不到优化。 就结论而言,React 目前还是对几个受影响的事件 touchstart touchmove wheel 采用 passive 模式,即: const Test = () => ( <div // 没有用的,无法阻止滚动,因为委托处默认 passive onWheel={event => event.preventDefault()} > ... </div>) 虽然结论如此而且对性能友好,但并不是一个让所有人都能满意的方案,我们看看当时 Dan 是如何思考,并给了哪些解决方案的。 首先背景是,React 16 事件委托绑定在 document 上,React 17 事件委托绑定在 App 根节点上,而根据 chrome 的优化,绑定在 document 的事件委托默认是 passive 的,而其它节点的不会,因此对 React 17 来说,如果什么都不做,仅改变绑定节点位置,就会存在一个 Break Change。 第一种方案是坚持 Chrome 性能优化的精神,委托时依然 pasive 处理。这样处理至少和 React 16 一样,preventDefault() 都是失效的,虽然不正确,但至少不是 BreakChange。 第二种方案即什么都不做,这导致原本默认 passive 的因为绑定到非 document 节点上而 non-passive 了,这样做不仅有性能问题,而且 API 会存在 BreackChange,虽然这种做法更 “原生”。 touch/wheel 不再采用委托,意味着浏览器可以有更少的 “non-fast” 区域,而 preventDefault() 也可以生效了。 最终选择了第一个方案,因为暂时不希望在 React API 层面出现行为不一致的 BreakChange。 然而 React 18 是一次 BreakChange 的时机,目前还没有进一步定论。 总结从浏览器角度看待问题会让你具备上帝视角而不是开发者视角,你不会再觉得一些奇奇怪怪的优化逻辑是 Hack 了,因为你了解浏览器背后是如何理解与实现的。 不过我们也会看到一些和实现强绑定的无奈,在前端开发框架实现时造成了不可避免的困扰。毕竟作为一个不了解浏览器实现的开发者,自然会认为 preventDefault() 绑定在滚动事件时,一定可以阻止默认滚动行为呀,但为什么因为: 浏览器分为合成层和渲染进程,通信成本较高导致滚动事件监听会引发滚动卡顿。 为了避免通信,浏览器默认为 document 绑定开启 passive 策略减少 “non-fast” 区域。 开启了 passive 的事件监听 preventDefault() 会失效,因为这层实现在 js 里而不是 GPU。 React16 采用事件代理,把元素 onWheel 代理到 document 节点而非当前节点。 React17 将 document 节点绑定下移到了 App 根节点,因此浏览器优化后的 passive 失效了。 React 为了保持 API 不发生 BreakChange,因此将 App 根节点绑定的事件委托默认补上了 passive,使其表现与绑定在 document 一样。 总之就是 React 与浏览器实现背后的纠纷,导致滚动行为阻止失效,而这个结果链条传导到了开发者身上,而且有明显感知。但了解背后原因后,你应该能理解一下 React 团队的痛苦吧,因为已有 API 确实没有办法描述是否 passive 这个行为,所以这是个暂时无法解决的问题。 讨论地址是:精读《深入了解现代浏览器四》· Issue ##381 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《深度学习 - 函数式之美》","path":"/wiki/WebWeekly/前沿技术/《深度学习 - 函数式之美》.html","content":"当前期刊数: 125 1 引言函数式语言在深度学习领域应用很广泛,因为函数式与深度学习模型的契合度很高,The Beauty of Functional Languages in Deep Learning — Clojure and Haskell 就很好的诠释了这个道理。 通过这篇文章可以加深我们对深度学习与函数式编程的理解。 2 概述与精读深度学习是机器学习中基于人工神经网络模型的一个分支,通过模拟多层神经元的自编码神经网络,将特征逐步抽象化,这需要多维度、大数据量的输入。TensorFlow 和 PyTorch 是比较著名的 Python 深度学习框架,同样 Keras 在 R 语言中也很著名。然而在生产环境中,基于 性能和安全性 的考虑,一般会使用函数式语言 Clojure 或 Haskell。 在生产环境中,可能要并发出里几百万个参数,因此面临的挑战是:如何高效、安全的执行这些运算。 所以为什么函数式编程语言可以胜任深度学习的计算要求呢? 深度学习的计算模型本质上是数学模型,而数学模型本质上和函数式编程思路是一致的:数据不可变且函数间可以任意组合。这意味着使用函数式编程语言可以更好的表达深度学习的计算过程,因此更容易理解与维护,同时函数式语言内置的 Immutable 数据结构也保障了并发的安全性。 另外函数式语言的函数之间都是相互隔离的,即便在多线程环境下也不会发生竞争和死锁的情况,函数式编程语言会自动处理这些情况。 比如说 Clojure,它甚至可在两个同时修改同一引用的程序并发运行时,自动重试其中之一,而不需要手动加锁: (import ‘(java.util.concurrent Executors))(defn test-stm [nitems nthreads niters] (let [refs (map ref (repeat nitems 0)) pool (Executors/newFixedThreadPool nthreads) tasks (map (fn [t] (fn [] (dotimes [n niters] (dosync (doseq [r refs] (alter r + 1 t)))))) (range nthreads))] (doseq [future (.invokeAll pool tasks)] (.get future)) (.shutdown pool) (map deref refs)))(test-stm 10 10 10000) -> (550000 550000 550000 550000 550000 550000 550000 550000 550000 550000) 上面的代码创建了引用(refs),同时创建了多个线程自增这个引用对象,按理说每个线程都修改这个引用会导致竞争状态出现,但从结果来看是正常的,说明 Clojure 引擎在执行时会自动解决这个问题。实际上当两个线程出现竞争而失败时,Clojure 会自动重试其中之一。 原文介绍 Clojure 的另一个优势是并行效率高: (defn calculate-pixels-2 [] (let [n (* *width* *height*) work (partition (/ n 16) (range 0 n)) result (pmap (fn [x] (doall (map (fn [p] (let [row (rem p *width*) col (int (/ p *height*))] (get-color (process-pixel (/ row (double *width*)) (/ col (double *height*)))))) x))) work)] (doall (apply concat result)))) 使用 partition 结合 pmap 可以使并发效率达到最大化,也就是 CPU 几乎都消耗在实际计算上,而不是并行的任务管理与上下文切换。Clojure 凭借 partition 对计算进行分区,采取分而治之并对分区计算结果进行合并的思路优化了并发性能。 原文介绍 Clojure 另一个特性是函数链式调用: ;; pipe arg to function(-> "x" f1) ; "x1";; pipe. function chaining(-> "x" f1 f2) ; "x12" 其中 (-> "x" f1 f2) 等价于 f2(f1("x")),这种描述不仅更简洁清晰,也更接近于实际数学模型。 原文介绍 最后,Clojure 还具备计算安全性,计算过程不会修改已有的数据,因此在神经网络的任何一层的原始值都会保留,每层计算都可以独立运行且函数永远幂等。 Haskell 也有独特的优势,它具有类型推断、惰性求值等特性,被认为更适合用于机器学习。 类型推断即 Haskell 类型都是静态的,如果试图赋予错误的类型会报错。 Haskell 的另一个优势是可以非常清晰的描述数学模型。 想想一般数学模型是怎么描述函数的: fn => f1 = 1 f2 = 9 f3 = 16 n > 2, fn = 3fn-3 + 2fn-2 + fn-1 一般语言用 if-else 描述等价关系,但 Haskell 可以几乎原汁原味的还原函数定义过程: solve :: Int -> Intergersolve 1 = 1solve 2 = 9solve 3 = 16solve n = 3 * solve (n - 3) + 2 * solve (n - 2) + solve (n - 1) 这使得阅读 Haskell 代码和阅读数学公式一样轻松。 原文 Haskell 另一个优势是惰性求值,即计算会在真正用到时才进行,而不会在计算前提前消费掉,比如: let x = [1..]let y = [2,4 ..]head (tail tail( (zip x y))) 可以看到,x 与 y 分别是 1,2,3,4,5,6... 与 2,4,6,8... 的无限数组,而 zip 函数将其整合为一个新数组 (1,2),(2,4),(3,6),(4,8)... 这也是无限数组,如果将 zip 函数执行完那么程序就会永远执行下去。但 Haskell 却不会陷入死循环,而是直接输出第一位数字 1。这就是惰性计算的特性,无论数组有多长,只有真正用到某项时才对其进行计算,所以哪怕初始数据量或计算量很大,实际消耗的运算资源只取决于这次计算实际用到的部分。 由于深度学习数据量巨大,惰性求值可以忽略海量数据输入,大大提升计算性能。 3 总结本文介绍了为什么深度学习更适合使用函数式语言,以及介绍了 Clojure 与 Haskell 语言的共性:安全性、高性能,以及各自独有的特性,证明了为何这两种语言更适合用在深度学习中。 在前端领域说到函数式或函数之美,大部分时候想到的是 Class Component 与 Function Component 的关系,这个理解是较为片面的。通过本文我们可以了解到,函数式的思想与数学表达式思想如出一辙,以写数学公式的思维方式写代码,就是一种较好的函数式编程思路。 函数式应该只有表达式,没有语句,这是因为函数式是为了处理运算而诞生的,因此很适合用在深度学习领域。 讨论地址是:精读《深度学习 - 函数式之美》 · Issue ##212 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《源码学习》","path":"/wiki/WebWeekly/前沿技术/《源码学习》.html","content":"当前期刊数: 112 1. 引言javascript-knowledge-reading-source-code 这篇文章介绍了阅读源码的重要性,精读系列也已有八期源码系列文章,分别是: 精读《Immer.js》源码 精读《sqorn 源码》 精读《Epitath 源码 - renderProps 新用法》 精读《Htm - Hyperscript 源码》 精读《React PowerPlug 源码》 精读《syntax-parser 源码》 精读《react-easy-state 源码》 精读《Inject Instance 源码》 笔者自己的感悟是,读过大量源码的程序员有以下几个特质: 思考具有系统性,主要体现在改一处代码模块时,会将项目所有文件串联起来整体考虑,提前评估影响面。 思考具有前瞻性,对已实现的方案可以快速评价所处阶段(临时 or 标准 or 可拓展),将边界情况提前解决,将框架 BUG 降低到最小程度。 代码实现更优雅,有大量源码经验做支撑,解决同样问题时,这些程序员可以用更短的行数、更合适的三方库解决问题,代码可读性更好,模块拆分更合理,更利于维护。 既然阅读源码这么重要,那么怎么才能读好源码呢?本周精读的文章就是一篇方法论文章,告诉你如何更好的阅读源码。 2. 概述原文分三个部分:阅读源码的好处、阅读源码的技巧、以及 Redux Connect 的案例研究。 阅读源码的好处阅读源码有助于理解抽象的概念,比如虚拟 DOM;有助于做方案调研,而不仅仅只看 Github star 数量;了解优秀框架目录结构的设计;看到一些陌生的工具函数,还可能激发你对 JS 规范的查阅,这种问题驱动的方式也是笔者推荐的 JS 规范学习方式。 阅读源码的技巧最好的阅读源码方式是看文章,如果源码的作者有写源码解读文章,这就是最省力的方式。虽然直接看代码可以了解到所有细节,但当你不清楚设计思路时,仅看源码可能会找不到方向,而读源码的最终目的是找到核心的设计理念,如果一个框架没有自己核心设计理念,这个框架也不值得诞生,更不值得被阅读。如果框架的作者已经将框架核心理念写成了文章,那读文章就是最佳方案。 还有一种方式是断点,写一个最小程序,在框架执行入口出打下断点,然后按照执行路径一步步理解。虽然执行路径中会存在大量无关的函数干扰精力,但如果你足够有耐心,当断点走完时一定会有所收获。 原文还提到了一种看源码方式,即没有目的的寻宝。在寻找框架主要思路的过程中,遇到一些有意思的函数,可以停下来仔细阅读,可能会发现一些对你有启发的代码片段。 Redux Connect 案例研究原文以 Redux Connect 作为案例介绍研究思路。 首先看到 Connect 的功能 “包装组件” 后,就要问自己两个问题: Connect 是如何实现包装组件后原样返回组件,但却增强组件功能的?(高阶组件知识) 了解这个设计模式后,如何利用已有的文档实现它? 通过创建一个使用 Connect 的基本程序: class MarketContainer extends Component {}const mapDispatchToProps = dispatch => { return { updateSummary: (summary, start, today) => dispatch(updateSummary(summary, start, today)) }}export default connect(null, mapDispatchToProps)(MarketContainer); 比如从生成 connect 函数的 createConnect 我们就可以学习到 Facade Pattern - 门面模式。 从 createConnect 函数调用处: export function createConnect({ connectHOC = connectAdvanced, mapStateToPropsFactories = defaultMapStateToPropsFactories, mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories, mergePropsFactories = defaultMergePropsFactories, selectorFactory = defaultSelectorFactory} = {}) 我们可以学习到解构默认函数参数的知识点。 总之,在学习源码的过程中,可以了解到一些新的 JS 特性,一些设计模式,这些都是额外的宝藏,不断理解并学会运用到自己写的框架里,就实现了源码学习的目的。 3. 精读原文介绍了学习源码的两个技巧,并利用 Redux Connect 实例说明了源码学习过程中可以学到许多周边知识,都让我们受益匪浅。 笔者结合之前写过的八篇源码分析文章,把最重要的设计思路提取出来,以实际的例子展示阅读源码能给我们思维带来哪些帮助。 Immerjs 源码的精华Immer 可以让我们以 Mutable 的方式更新对象,最终得到一个 Immutable 对象: this.setState(produce(state => (state.isShow = true))) 详细源码解读可以阅读 这里。 核心思路是利用 Proxy 把脏活累活做掉。上面的例子中,state 已经是一个代理(Proxy)对象,通过自定义 setting 不断递归进行浅拷贝,最后返回一个新引用的顶层对象作为 produce 的返回值。 从 Immerjs 中,我们学到了 Proxy 可以化腐朽为神奇的用法,比看任何 Proxy 介绍文章都直观。 sqorn 源码的精华sqorn 是一个 sql orm,举例来看: const sq = require("sqorn-pg")();const Person = sq`person`, Book = sq`book`;// SELECTconst children = await Person`age < ${13}`;// "select * from person where age < 13" 详细源码解读可以阅读 这里 核心思路是在链式调用过程中创建 context 存储结构,并在链式调用的时候不断填充 context 信息,最终拿到的是一个结构化 context 对象,生成 sql 语句也就简单了。 从 sqorn 中,我们学到了如何实现链式调用 init().a().b().c().print() 最后拿到一个综合的结果,原理是内部维护了一个不断修改的对象。不论前端 React Vue 还是后端框架 Koa 等,一般都有内置的 context,一般实现这种优雅语法的框架内部都会维护 context。 Epitath 源码的精华Epitath 在 React Hooks 之前出来,解决了高阶函数地狱的问题: const App = epitath(function*() { const { count } = yield <Counter /> const { on } = yield <Toggle /> return ( <MyComponent counter={count} toggle={on} /> )})<App /> 详细源码解读可以阅读 这里 其核心是利用 generator 的迭代,将 React 组件的平级结构还原成嵌套结构,将嵌套写法打平了: yield <A>yield <B>yield <C>// 等价于<A> <B> <C /> </B></A> 从 epitath 中,我们了解到 generator 原来可以这么用,正因为其执行是多次迭代的,因此我们可以利用这个特性,改变代码运行结构。 Htm - Hyperscript 源码的精华Htm 将模版语法很自然的融入到了 html 中: html` <div class="app"> <${Header} name="ToDo's (${page})" /> <ul> ${todos.map( todo => html` <li>${todo}</li> ` )} </ul> <button onClick=${() => this.addTodo()}>Add Todo</button> <${Footer}>footer content here<//> </div>`; 详细源码解读可以阅读 这里 其核心是怎么根据模版拿到 dom 元素的 AST?拿到 AST 后就方便生成后续内容了。 作者的办法是: const TEMPLATE = document.createElement("template");TEMPLATE.innerHTML = str; 这样 TEMPLATE 就自带了 AST 解析,这是利用浏览器自带的 AST 解析拿到了 AST。从 Htm 中,我们学到了 innerHTML 可以生成标准 AST,所以只要有浏览器运行环境,需要拿 AST 的时候,不需要其他库,innerHTML 就是最好的方案。 React PowerPlug 源码的精华React PowerPlug 是一个利用 render props 进行状态管理的工具库。 它可以在 JSX 中对任意粒度插入状态管理: <Value initial="React"> {({ value, set, reset }) => ( <> <Select label="Choose one" options={["React", "Preact", "Vue"]} value={value} onChange={set} /> <Button onClick={reset}>Reset to initial</Button> </> )}</Value> 详细源码解读可以阅读 这里 这个库的核心就是利用 render props 解决 JSX 局部状态管理的痛点,通过读源码了解 render props 的使用方式是这个源码带给你的最大价值。 syntax-parser 源码的精华syntax-parser 是一个 JS 版语法解器生成器,笔者也是作者,使用方式: import { createParser, chain, matchTokenType, many } from "syntax-parser";const root = () => chain(addExpr)(ast => ast[0]);const addExpr = () => chain(matchTokenType("word"), many(addPlus))(ast => ({ left: ast[0].value, operator: ast[1] && ast[1][0].operator, right: ast[1] && ast[1][0].term }));const addPlus = () => chain("+"), root)(ast => ({ operator: ast[0].value, term: ast[1] }));const myParser = createParser( root, // Root grammar. myLexer // Created in lexer example.); 详细源码解读可以阅读 这里 syntax-parser 的核心是利用双向链表实现了可回溯的语法解析器,了解了这个库,你可以自己实现 JS 调用堆栈,并在任意时候返回某个之前的执行状态重新执行。同时这个库的源码也会加强你对链表的理解,以及拓展你对链表使用场景的想象。 react-easy-state 源码的精华react-easy-state 利用 Proxy 创建了一个简易的全局数据流管理方式: import React from "react";import { store, view } from "react-easy-state";const counter = store({ num: 0 });const increment = () => counter.num++;export default view(() => <button onClick={increment}>{counter.num}</button>); 详细源码解读可以阅读 这里 react-easy-state 利用了 observer-util 实现主要功能,从中我们能学到最有价值的就是 Proxy 与 React 结合的设计理念,即利用 getter setter 实现数据与视图的双向绑定,或者叫依赖追踪,更多细节就不在这里展开,感兴趣可以阅读笔者之前写的 抽丝剥茧,实现依赖追踪 一节。 Inject Instance 源码的精华inject-instance 是一个 Class 实现依赖注入的库: import {inject} from 'inject-instance'import B from './B'class A { @inject('B') private b: B public name = 'aaa' say() { console.log('A inject B instance', this.b.name) }} 详细源码解读可以阅读 这里 主要对我们有两个启发,第一可以利用装饰器为对象存储一些额外信息,这些信息在必要的时候我们可以用到;第二是依赖注入并不复杂,通过提前实例化后,可以解决循环依赖的问题,即所有循环依赖问题都可以通过加一个父级解决。 4. 总结阅读代码不是目的,读懂源码背后要表达的核心设计思路才是目的。比如写脚手架,阅读了大量脚手架源码的人写出的代码,与一个没有经验的人写出的代码会有天壤之别,这之间的差距就是对一些设计模式、三方库、结构设计的经验差距。 只学习理论太空洞,只看代码又太局限,学会从代码中看出理论才是最佳学习方式。 讨论地址是:精读《源码学习》 · Issue ##179 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《现代 JavaScript 概览》","path":"/wiki/WebWeekly/前沿技术/《现代 JavaScript 概览》.html","content":"当前期刊数: 24 本期精读的文章是: Glossary of Modern JavaScript Concepts: Part 1 Glossary of Modern JavaScript Concepts: Part 2 1 引言我为什么要选这篇文章呢? 之所以选这篇文章, 是因为非常认同作者写这两篇文章的原因. 作者在文中说, 现代 JavaScript 的很多概念和思想在快速被传播和扩展, 很多新概念出现在前端相关的博客和文档中, 这些概念对于很多前端开发人员来说, 仍然很陌生. 因此我们有必要来学习一下现代的这些 JavaScript 的概念, 看这些概念在现在 JavaScript 的库或应用中是怎么被使用的. 2 内容概要文章讲了很多现代 JavaScript 中的概念, 罗列如下: 纯函数和副作用在了解纯函数之前, 首先要了解副作用. 副作用是指改变了其作用域外的状态. 副作用的举例有调用了一个 API, 操作了一个 DOM 节点, 弹出了一个弹窗, 或者改变了一条数据等. 而纯函数则是指 函数的返回值仅仅由参数决定, 当给同样的参数时, 返回值是固定的. Stateful 和 Stateless (有状态和无状态)Stateless 无状态, 有点像纯函数, 不管理自己的数据或状态, 结果取决于参数. 而 Stateful, 有状态, 指的是函数自己有自己的运行状态, 可以修改自己的状态. 在现代 JavaScript 开发中, 处理状态, 显得很重要. 可变对象与不可变对象可变对象与不可变对象概念很清楚, 可变对象指的是在创建后值仍可以被改变, 不可变对象指的是创建后值无法被改变. 相比于其他语言, 可变对象与不可变对象在 JavaScript 中更加模糊, 当你了解函数式编程时, 你会听到很多不可变对象的好处. 在 JavaScript 中, 你可以通过 Object.freeze(obj), 让一个对象变得不可变, 但是注意这是浅层的冻结对象, 如果有一个属性的值是个对象, 那这个对象中的属性是可以被修改的. 现在 JavaScript 也出现了 npm deep-freeze , Immutable.js 这些库来帮助你在 JavaScript 中实现不可变对象. Imperative and Declarative Programming(命令式和声明式编程)命令式编程, 描述一段代码的逻辑怎么被显式调用去改变程序的状态. 声明式编程, 描述一段代码的逻辑, 而不需要描述如何完成这段逻辑. JavaScript 可以同时被写为命令式和声明式编程方式, 但是随着函数式编程的兴起, 声明式编程将变得更加普遍. 高阶函数函数作为 JavaScript 的一等公民, 可以跟普通数据类型一样, 被存储, 或者被作为值传参. 而高阶函数就是一种函数 可以接收另外一个函数作为入参, 或者返回一个函数作为结果. 函数式编程 FP上面我们了解的 纯函数, 无状态, 不可变对象, 命令式编程, 和高阶函数, 都是很重要的函数式编程组成. 函数式编程通过以下方式包含上述概念: 关键函数实现使用纯函数, 没有副作用. 数据不可变 函数 无状态 声明式代码去管理副作用和执行命令式编程 Hot and Cold ObservablesObservables 和数组类似, 只不过数组是被保存在内存中, 而 Observables 的每一个元素则是异步加入进来. 我们可以订阅这些 observables. Hot Observables 容易会被执行, 即使我们没有订阅它们. 比如说 用户的操作界面的 按钮点击事件, 鼠标移动, 窗口大小改变, 这些都是 Hot Observables. 而 cold observable 则是需要我们去订阅, 并且会在我们订阅的时候开始执行. 响应式编程 RP响应式编程, 可以看作是面向异步事件流的编程, 声明式的, 表述去做什么, 而不是怎么做. 函数式响应型编程 FRP函数式响应型编程简而言之,就是对事件或者行为给予声明式的反馈. FRP 具有两个很明显的特点: 函数或者类型有明确的定义 操作的是连续变化的值 作用域和闭包闭包作为最常见的面试题经常被提及, 但是很多资深的前端开发都解释不清楚闭包, 即使他们理解闭包. 作者首先介绍了全局作用域和局部作用域, 作用域作为许多 JS 开发人员最开始学习的知识, 理解作用域对于编写优秀的代码至关重要. 闭包的形成在于, 当一个在函数内声明的函数可以引用外部函数的局部变量. 就形成了闭包. 单向数据流和双向数据流随着现在各种 SPA 框架的兴起, 理解数据流概念, 对于现在 JS 开发者越来越重要, React 被认为是单向数据流的典范, 使用 Model 作为唯一的数据来源, 控制 View 的渲染. 在 View 层用事件的方式通知 Model 更新, 在反应到 View 层的变化上. 数据沿着一个方向流动, UI 永远不会更新 Model, 而是通过事件或者 setState 方法. 在双向数据绑定中, 数据是在两个方向上流动的, JS 可以更新 Model 数据, View 层 也可以更新 Model 数据. AngularJs 的 1.x 版本是双向数据流的典型实现. 早在 2009 年, 双向绑定是 Angualr 最受欢迎的特性之一, 但是 Angular 把这一特性抛弃了. 现在很多流行的框架和库都使用了单向数据流(React,Angular,Inferno,Redux 等). 单向数据流倡导的是清晰的架构, 数据流动更加清晰和易管理. 对于单向数据流来说说了点 View 自动更新数据的便利, 但也得到了清晰的数据流. JS 框架中的变化侦测: 脏检查, getter 和 setter, 虚拟 DOM变化侦测对于现代 SPA 应用来说很重要. 当用户更新一些内容时, 应用必须以一种方法知道这种变化, 并做出反应更新. AngularJS 1.x 使用的是脏检查的方式, 具体做法是对 View 中涉及到的 Model 进行深度比较. 脏检查的优点在于它的简单和可预测, 不涉及到 API 和对象的变更. 但是正因为涉及到大量比较, 也很低效. Ember 和 Backbone 是使用 getters 和 setters 来做变化侦测, 这样涉及到数据修改时, 都会触发变更事件. 而 React 是使用了虚拟 Dom 来做变化侦测, React 通过 setState 方法来通知变更, 使用虚拟 Dom 来比较是否发生了数据变化. Web Components 组件Web 组件是 Web 平台上可复用的基础组件, 而 Web Components 则定义了一些规范来实现这些可复用组件.规范包括: 自定义元素 HTML Template Shadow Dom HTML imports 引入 Web Components 本身并不能代替 SPA 框架的功能, 但是它的想法和核心概念, 在很多 SPA 框架中都有体现. Smart 和 Dumb 组件现在 Web 的开发严重依赖组件, 而很多时候我们把组件分成 Smart 组件和 Dumb 组件. Smart 组件, 又叫容器组件, 在组件内处理各种业务逻辑, 通常也管理 Dumb 组件,响应 Dumb 组件的事件. Dumb 组件, 又叫展示组件, 通常被写成纯函数, 依赖于外部的数据和方法, 专注于展现数据. JIT 编译Just-In-time(JIT)编译指的是代码的运行时, 被编译成机器代码的过程. 在 JavaScript 运行时, JIT 能够找到代码的特定模式, 而这些模式可以让 JavaScript 更快的被执行. AOT 编译Ahead-Of-Time(AOT), 指的是编写的代码在运行之前, 被翻译成机器代码的过程. AOT 给 tree shaking 带来了可能, 使用 AOT 预编译, 对于生产环境下的代码有以下好处: 更少的异步请求, 模板和样式内联在 JS 内 更小的体积 更早的检查到模板错误 更好的安全性 Tree ShakingTree Shaking 是指打包 JS 模块时, 通过对代码的静态分析, 排除掉不用的代码的机制. Tree Shaking 技术建立在 ES2015 模块的, import 和 export 上, 支持我们导入特定的内容,而不是整个库. import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 这样我们只导入了 BehaviorSubject, 而没有导入整个 Rxjs 库. 3 精读文中讲到的现代 JavaScript 已经很多了, 再对理解的现代 JavaScript 补充几条: Dependent injection(依赖注入)通过控制反转,父级不需要关心子实现细节,将子类可能用到的实例都初始化好,由子类决定引入哪些依赖。还有一个好处是维持了单实例,这一点在数据流中尤为重要,如果 store 不是单例的,那数据流必然乱了套,既希望传给子类使用,又要维持单例,依赖注入是很好的解决方案。 Symbol Reflect ProxySymbol 是 ES6 中加入的一种新的数据类型, 每一个 Symbol 都是独一无二的, 不与其它 Symbol 重复. ES6 中的 Proxy , 则是通过 Proxy 方法, 实现对于对象的一层拦截. 提供一种机制, 代理对象的操作. 而 Reflect 是一个内置的对象,它提供可拦截 JavaScript 操作的方法。方法与代理处理程序的方法相同。 这三篇文章非常详细介绍了这三位 API:symbol reflect proxy Server rendering前端对后端渲染的热度降了很多,主要是盲目跟风的氛围消停了,真正需要的团队已经稳定的用起来了。后端渲染的理念很新颖,一定程度帮助了 html 认识到自己的不足,就像 Angular, React, Vue 对 webComponents 的冲击一样,或许未来十年可以用上 ECMAScript 标准提供的功能,但业务不能等待技术,现在唯有不断折腾,直到被消灭或者招安。 4 总结伴随着各种框架的热度, 理解这些现代 JavaScript 概念变得越来越重要, 大家可以以这个作为概览, 详细去学习和了解现代 JavaScript 的概念. 讨论地址是:精读《现代 JavaScript 概览》 · Issue ##35 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《现代 js 框架存在的根本原因》","path":"/wiki/WebWeekly/前沿技术/《现代 js 框架存在的根本原因》.html","content":"当前期刊数: 57 1 引言深入思考为何前端需要框架,以及 web components 是否可以代替前端框架? 原文地址,建议先阅读原文,或者阅读概述。 2 概述现在前端框架非常多了,如果让我们回答 “为什么要用前端框架” 这个问题,你觉得是下面这些原因吗? 组件化。 拥有强大的开源社区。 拥有大量第三方库解决大部分问题。 拥有大量现成的第三方组件。 拥有浏览器拓展/工具帮助快速 debug。 友好的支持单页应用。 不,这些都不是根本原因,最多算前端框架的营销手段。作者给出的最根本原因是: 解决 UI 与状态同步的难题。 作者假设了一个没有前端框架的项目,就像 Jquery 时代,我们需要手动同步状态与 UI。就像下面的代码: addAddress(address) { // state logic const id = String(Dat.now()) this.state = this.state.concat({ address, id }) // UI logic this.updateHelp() const li = document.createElement('li') const span = document.createElement('span') const del = document.createElement('a') span.innerText = address del.innerText = 'delete' del.setAttribute('data-delete-id', id) this.ul.appendChild(li) li.appendChild(del) li.appendChild(span) this.items[id] = li} 首先更新效率是个问题,最大问题还是同步问题。试想多次与服务器交互,在同步过程中漏执行了一步,会导致之后的 UI 与状态逐渐脱节。 因为我们只能一步步同步状态与 UI,却无法保证每个瞬间 UI 与状态是完全同步的,任何一个疏忽都会导致 UI 与状态脱节,而我们除了不断检查 UI 与数据是否对应,毫无办法。 所以现代框架最重要的帮助是保持 UI 与状态的同步。 如何做到有两种思路: 组件级重渲染:比如 React,当状态改版后,映射出改变后的虚拟 DOM,最终改变当前组件映射的真实 DOM,这个过程被称为 reconciliation。 监听修改:比如 Angluar 和 Vue.js,状态改变直接触发对应 DOM 节点中 value 值的变化。 这里稍微说明下,React 虽然是整体渲染,但在虚拟 DOM 作用下,效率不比 observable 低。observable 在值不能完整映射 UI 时,也需要做更大范围的 rerender。另外,Vue.js 与 Angluar 也早已采用了虚拟 DOM。 这三个框架已经融会贯通,作者提到的两种思路现在已经是一种混合技术了。 那 web components 呢?大家经常会拿 React, Angluar, Vue.js 与 web components 做比较,可 web components 最大的问题就是,没有解决 UI 与状态同步。 web components 只提供了模版语法,自定义标签解决 html 的问题,并没有给出一套状态与 UI 同步的方法。 所以就算使用 web components,我们可能还需要一个框架做 UI 同步,比如 Vue.js 或者 stenciljs。 作者还提供了一段简短的 UI 状态同步实例,这里略过。 最后给出了四点总结: 现代 js 框架主要在解决 UI 与状态同步的问题。 仅使用原生 js 难以写出复杂、高效、又容易维护的 UI 代码。 Web components 没有解决这个主要问题。 虽然使用虚拟 DOM 库很容易造一个解决问题的框架,但不建议你真的这么做! 3 精读作者的核心观点是,现代前端框架主要解决 UI 与状态同步的问题,这是毫无疑问的,也提到了包括 web components 也依然没有解决这个问题。 这可能是 web 开发最核心的问题了。 最初开发者的精力都在前端标准化上,诞生了一系列解决标准化问题的库,最有知名度的是 jquery。当前端进入 react 时代后,可以看到精力从解决标准化到解决 web 规范与实践的冲突,这个冲突正是作者说的问题。 前端三剑客问题就出现在 html、js、css 三者分离上。 html、css、js 各是一套独立的体系,但 js 又能同时控制 html 与 css,那为了解决同步问题,最好将控制权全部交给 js。 这样 web components 的问题也就好理解了,web components 解决的是 html 问题,注定与 js 无关。 html 官方规范估计很难出现现代框架的设计了,因为官方设计中前端三剑客是相互分离的方案,为了解决现阶段前端框架的问题,html 必须由 js 完全接管,这几乎就是 jsx,或者支持 template 语法的 html,可这与最初网页设计思路是违背的。 html 是独立的,甚至可以不依赖 js 运行,这天然导致了 UI 与状态同步这个难题。 为什么一定要用 jshtml 不依赖 js 的设计可能已经跟不上前端发展步伐了,也许 jsx 或者 template 才是真正的未来。 诚然,html 现在的设计可以在不支持 js 的浏览器执行,但就在最近,所有现代浏览器都支持了 service worker,它是凌驾于 html 执行时机之上的 js 脚本,甚至可以拦截 html 请求。一个不支持 js 的浏览器,可能也无法支持 service worker,禁用 js 的坚持可能只剩下安全性保护。 而实际上现代 web 页面都使用了 js 完全主导网页渲染,所以这已经从技术问题上升到了社会问题,如今禁用 js 的浏览器还有多少网页可以正常访问?除了某些超大型网站对禁用 js 状态做了特殊优化以外,现在几乎没有前端项目会考虑禁用 js 的情况了,因为我们不会假设 React、Angluar、Vue.js 框架代码无法运行。 所以为什么不融合 html 与 js 呢?既然事实上 UI 已经与 js 绑定了,那 w3c 为何不将 jsx 或者 template 列为标准呢?也许为了向前兼容,规范永远也迈不出这一步吧。 幸运的是,这并不妨碍现代前端框架的大量普及,而且势不可挡。 4 总结也许 UI 与状态同步的问题是前端发展的最大阻力,虽然现代化框架已经解决了这个问题,但 w3c 标准却一直无法往这个方向发力,导致 web 的下一个发展方向难以依靠标准规范来推动。前端日新月异的发展,很大一部分是规范的发展带来的,而现在我们进入了一个由工业化领导的时代,规范很可能永远也跟不上来,随之而来的是工业化社区也难以做进一步突破。 前端不仅是 web,或者也许下一个突破并不在 web,而是 ar/vr 或者下一个人机交互场景。同样,web 也不仅是前端三剑客,如果认为 React、Angluar、Vue.js 带来的工业化规范就是新的规范,前端才有动力向后发展,比如基于虚拟 DOM 的新框架、新语言。 所以笔者推导出现代前端开发的本质,是将 js、html 的平行关系变成了 js 包含 html 的关系,正如上面所说,这可能背离了 w3c 的初衷,但这就是现在的潮流。 最后总结一下观点: 也是原作者的,现代 js 框架主要在解决 UI 与状态同步的问题。 传统的前端三剑客正面临着进一步发展乏力的危机。 现代前端框架正在告诉我们新的三剑客:js(虚拟 dom、虚拟 css)。 5 更多讨论 讨论地址是:精读《现代 js 框架存在的根本原因》 · Issue ##84 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《用 Babel 创造自定义 JS 语法》","path":"/wiki/WebWeekly/前沿技术/《用 Babel 创造自定义 JS 语法》.html","content":"当前期刊数: 123 1 引言在写这次精读之前,我想谈谈前端精读可以为读者带来哪些价值,以及如何评判这些价值。 前端精读已经写到第 123 篇了,大家已经不必担心它突然停止更新,因为我已养成每周写一篇文章的习惯,而读者也养成了每周看一篇的习惯。所以我想说的其实是一种更有生命力的自媒体运作方式,定期更新。一个定期更新的专栏比一个不不定期更新的专栏更有活力,也更受读者喜爱,因为读者能看到文章之间的联系,跟随作者一起成长。个人学习也是如此,养成定期学习的习惯,比在培训班突击几个月更有用,学会在生活中规律的学习,甚至好过读几年名牌大学。 前端精读想带给读者的不仅是一篇篇具体的内容和知识,知识是无穷无尽的,几万篇文章也说不完,但前端精读一直沿用了“引言-概述-精读-总结”这套学习模式,无论是前端任何领域的问题,还是对人生和世界的思考都可以套用,希望能为读者提供一套学习思维框架,让你能学习到如何找到好的文章,以及如何解读它。 至今已经选择了许多源码解读的题材,与培训思维的源码解读不同,我希望你不要带着面试的目的学习源码,因为这样会让你只局限在 react、vue 这种热门的框架上。前端精读选取的框架类型之所以广泛,是希望你能静下心来,吸取不同框架风格与作者的优势,培养一种优雅编码的气质。 进入正题,这次选择的文章 《用 Babel 创造自定义 JS 语法》 也是培养编码气质的一类文章,虽然对你实际工作用处不大,但这篇文章可以培养几个程序员梦寐以求的能力:深入理解 Babel、深入理解框架拓展机制。理解一个复杂系统或培养框架思维不是一朝一夕的,但持续阅读这种文章可以让你越来越接近掌握它。 之所以选择 Babel,是因为 Babel 处理的一直是语法树相关的底层逻辑,编译原理是程序世界的基座之一,拥有很大的学习价值。所以我们的目的并不是像文章标题说的 - 创造一个自定义 JS 语法,因为你创造的语法只会让 JS 复杂体系更加混乱,但可以让你理解 Babel 解析标准 JS 语法的原理,以及看待新语法提案时,拥有从实现层面思考的能力。 最后,不必多说,能重温 Babel 经典的插件机制,你可以发现 Babel 的插件拓展机制和 Antrl4 很像,在设计业务模块拓展方案时也可以作为参考。 2 概述我们要利用 Babel 实现 function @@ 的新语法,用 @@ 装饰的函数会自动柯里化: // '@@' makes the function `foo` curriedfunction @@ foo(a, b, c) { return a + b + c;}console.log(foo(1, 2)(3)); // 6 可以看到,function @@ foo 描述的函数 foo 支持 foo(1, 2)(3) 这种柯里化调用。 实现方式分为两步: Fork babel 源码。 创建一个 babel 转换器插件。 不要畏惧这些步骤,“如果你读完了这篇文章,你将成为同事眼中的 Babel 大神” - 原文。 首先 Fork babel 源码到本地,执行下面的命令可以初始化并编译 babel: $ make bootstrap$ make build babel 使用 Makefile 执行编译命令,并且采用 monorepo 管理,我们这次要关心的是 package/babel-parser 这个模块。 词法首先要了解词法知识,更详细的可以阅读原文或精读之前的一篇系列文章:精读《词法分析》。 要解析语法,首先要进行词法分析。任何语法输入都是一个字符串,比如 function @@ foo(a, b, c),词法分析就是要将这个长度为 24 的字符拆分为一个个有语义的单词片段:function @@ foo ( a .. 由于 @@ 是我们创造的语法,所以我们第一个任务就是让 babel 词法分析可以识别它。 下面是 package/babel-parser 的文件结构: - src/ - tokenizer/ - parser/ - plugins/ - jsx/ - typescript/ - flow/ - ...- test/ 可以看到,分为词法分析 tokenizer,语法分析 parser,以及支持一些特殊语法的插件,以及测试用例 test。 推荐使用 Test-driven development (TDD) - 测试驱动开发的方式,就是先写测试用例,再根据测试用例开发。这种开发方式在后端或者 babel 这种底层框架很常见,因为 TDD 方式开发的逻辑能保证测试用例 100% 覆盖,同时先看测试用例也是个很好的切面编程思维。 // packages/babel-parser/test/curry-function.jsimport { parse } from '../lib';function getParser(code) { return () => parse(code, { sourceType: 'module' });}describe('curry function syntax', function() { it('should parse', function() { expect(getParser(`function @@ foo() {}`)()).toMatchSnapshot(); });}); 可以利用 jest 直接测试这段代码: BABEL_ENV=test node_modules/.bin/jest -u packages/babel-parser/test/c 结果会出现如下报错: SyntaxError: Unexpected token (1:9)at Parser.raise (packages/babel-parser/src/parser/location.js:39:63)at Parser.raise [as unexpected] (packages/babel-parser/src/parser/util.js:133:16)at Parser.unexpected [as parseIdentifierName] (packages/babel-parser/src/parser/expression.js:2090:18)at Parser.parseIdentifierName [as parseIdentifier] (packages/babel-parser/src/parser/expression.js:2052:23)at Parser.parseIdentifier (packages/babel-pars 第 9 个字符就是 @,说明程序现在还不支持函数前面的 @ 解析。我们还可以在错误堆栈中找到报错位置,并把当前 Token 与下一个 Token 打印出来: // packages/babel-parser/src/parser/expression.jsparseIdentifierName(pos: number, liberal?: boolean): string { if (this.match(tt.name)) { // ... } else { console.log(this.state.type); // current token console.log(this.lookahead().type); // next token throw this.unexpected(); }} this.state.type 代表当前 Token,this.lookahead().type 表示下一个 Token。lookahead 是词法分析的专有词,表示向后查看。打印之后,我们会发现输出了两个 @ Token: TokenType { label: '@', // ...} 下一步,我们需要让 babel 词法分析识别 @@ 这个 Token。首先需要注册这个 Token: // packages/babel-parser/src/tokenizer/types.jsexport const types: { [name: string]: TokenType } = { // ... at: new TokenType('@'), atat: new TokenType('@@'),}; 注册了之后,我们要在遍历 Token 时增加判断 “如果当前字符是 @ 且下一个字符也是 @,则整体构成了 @@ Token 并且光标向后移动两格”: // packages/babel-parser/src/tokenizer/index.jsgetTokenFromCode(code: number): void { switch (code) { // ... case charCodes.atSign: // if the next character is a `@` if (this.input.charCodeAt(this.state.pos + 1) === charCodes.atSign) { // create `tt.atat` instead this.finishOp(tt.atat, 2); } else { this.finishOp(tt.at, 1); } return; // ... }} 再次运行测试文件,输出变成了: // current tokenTokenType { label: '@@', // ...}// next tokenTokenType { label: 'name', // ...} 到这一步,已经能正确解析 @@ Token 了。 语法词法已经可以将 @@ 解析为 atat Token,下一步我们就要利用这个 Token,让生成的 AST 结构中包含柯里化函数的信息,并利用 babel 插件在解析时实现柯里化功能。 首先我们可以在 Babel AST explorer 看到 AST 解析的结构,我们拿 generator 函数测试,因为这个函数结构与柯里化函数类似: 可以看到,babel 通过 generator async 属性来标识函数是否为 generator 或者 async 函数。同理,增加一个 curry 属性就可以实现第一步了: 要实现如上效果,只需在词法分析 parser/statement 文件的 parseFunction 处新增 atat 解析即可: // packages/babel-parser/src/parser/statement.jsexport default class StatementParser extends ExpressionParser { // ... parseFunction<T: N.NormalFunction>( node: T, statement?: number = FUNC_NO_FLAGS, isAsync?: boolean = false ): T { // ... node.generator = this.eat(tt.star); node.curry = this.eat(tt.atat); }} eat 是吃掉的意思,实际上可以理解为吞掉这个 Token,这样做有两个效果:1. 为函数添加了 curry 属性 2. 吞掉了 @@ 标识,保证所有 Token 都被识别是 AST 解析正确的必要条件。 关于递归下降语法分析的更多知识,可以参考 精读《手写 SQL 编译器 - 语法分析》,或者阅读原文。 我们再次执行测试函数,发现测试通过了,一切都在预料中。 babel 插件现在我们得到了标记了 curry 的 AST,那么最后需要一个 babel 解析插件,实现柯里化。 首先我们通过修改 babel 源码的方式实现的效果,是可以转化为自定义 babel parser 插件的: // babel-plugin-transformation-curry-function.jsimport customParser from './custom-parser';export default function ourBabelPlugin() { return { parserOverride(code, opts) { return customParser.parse(code, opts); }, };} 这样就可以实现修改 babel 源码一样的效果,这也是做框架常用的插件机制。 其次我们要理解如何实现柯里化。柯里化可以通过柯里函数包装后实现: function currying(fn) { const numParamsRequired = fn.length; function curryFactory(params) { return function (...args) { const newParams = params.concat(args); if (newParams.length >= numParamsRequired) { return fn(...newParams); } return curryFactory(newParams); } } return curryFactory([]);}// fromfunction @@ foo(a, b, c) { return a + b + c;}// toconst foo = currying(function foo(a, b, c) { return a + b + c;}) 柯里化函数通过构造参数数量相关的递归,当参数传入不足时返回一个新函数,并持久化之前传入的参数,最后当参数齐全后一次性调用函数。 我们需要做的是,将 @@ foo 解析为 currying() 函数包裹后的新函数。 下面就是我们熟悉的 babel 插件部分了: // babel-plugin-transformation-curry-function.jsexport default function ourBabelPlugin() { return { // ... visitor: { FunctionDeclaration(path) { if (path.get('curry').node) { // const foo = curry(function () { ... }); path.node.curry = false; path.replaceWith( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(path.get('id.name').node), t.callExpression(t.identifier('currying'), [ t.toExpression(path.node), ]) ), ]) ); } }, }, };} FunctionDeclaration 就是 AST 的 visit 钩子,这个钩子在执行到函数时被触发,我们通过 path.get('curry') 拿到 柯里化函数,并利用 replaceWith 将这个函数构造为一个被 currying 函数包裹的新函数。 剩下最后一个问题:currying 函数源码放在哪里。 第一种方式,创建类似 babel-plugin-transformation-curry-function 这样的插件,在 babel 解析时将 currying 函数注册到全局,这是全局思维的方案。 第二种是模块化解决方案,创建一个自定义的 @babel/helpers,注册一个 currying 标识: // packages/babel-helpers/src/helpers.jshelpers.currying = helper("7.6.0")` export default function currying(fn) { const numParamsRequired = fn.length; function curryFactory(params) { return function (...args) { const newParams = params.concat(args); if (newParams.length >= numParamsRequired) { return fn(...newParams); } return curryFactory(newParams); } } return curryFactory([]); }`; 在 visit 函数使用 addHelper 方式拿到 currying: path.replaceWith( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(path.get('id.name').node), t.callExpression(this.addHelper("currying"), [ t.toExpression(path.node), ]) ), ])); 这样在 babel 转换后,就会自动 import helper,并引用 helper 中导出的 currying。 最后原文末尾留下了一些延伸阅读内容,感兴趣的同学可以 点击到原文。 3 精读读完这篇文章,相信你不仅对 babel 插件有了更深刻的认识,而且还掌握了如何为 js 添加新语法这种黑魔法。 我来帮你从 babel 这篇文章总结一些编程模型和知识点,借助 babel 创造自定义语法的实例,加深对它们的理解。 TDDTest-driven development 即测试驱动的开发模式。 从文章的例子可以看出,创造一个新语法,可以先在测试用例先写上这个语法,通过执行测试命令通过报错堆栈一步步解决问题。这种方式开发可以让测试覆盖率更高,目的更专注,更容易保障代码质量。 联想编程联想编程不属于任何编程模型,但从简介的思路来看,作者把 “为 babel 创建一个新 js 语法” 看作一种探案式探索过程,通过错误堆栈和代码阅读,一步一步通过合理联想实现最终目的。 在 AST 那一节,还借助了 Babel AST explorer 工具查看 AST 结构,通过联想到 generator 函数找到类似的 AST 结构,并找到拓展 AST 的突破口。 随着解决问题的不同,联想方式也不同,如果能够举一反三,对不同场景都能合理的联想,才算是具备了技术专家的软素质。 词法、语法分析词法、语法分析属于编译原理的知识,理解词法拆分、递归下降,可以帮助你技术走的更深。 不论是 Babel 插件的使用、还是 Babel 增加自定义 JS 语法,都要具备基本编译原理知识。编译原理知识还能帮助你开发在线编辑器,做智能语法提示等等。 插件机制如下是 babel 自定义 parser 的插件拓展方式: export default function ourBabelPlugin() { return { parserOverride(code, opts) { return customParser.parse(code, opts); }, };} 这只是插件拓展的一种,有申明式,也有命令式;有用 JS 书写的,也有用 JSON 书写的。babel 选择了通过对象方式拓展,是比较适合对 AST 结构统一处理的。 做框架首先要确定接口规范,比如 parser,先按照接口规范实现一套官方解析,对接时按照接口进行对接,就可以自然而然被用户自定义插件替代了。 可以参考的文章: 精读《插件化思维》 柯里化柯里化是面试经常考察的一个知识点,我们能学到的有两点:理解递归、理解如何将函数变成柯里化。 这里再拓展一下,我们还可以想到 JS 尾递归优化。如何快速写一个支持尾递归的函数? const fn = tailCallOptimize(() => { if ( /* xxx */ ) { fn() }}) 通过封装 tailCallOptimize 函数,可以很方便的构造一个支持尾递归的函数,这个函数可以这么写: export function tailCallOptimize<T>(f: T): T { let value: any; let active = false; const accumulated: any[] = []; return function accumulator(this: any) { accumulated.push(arguments); if (!active) { active = true; while (accumulated.length) { value = (f as any).apply(this, accumulated.shift()); } active = false; return value; } };} 感兴趣的读者可以在评论里解释一下这个函数的原理。 AST visit遍历 AST 树常采用的方案是做一个遍历器 visitor,所以在遍历过程中进行拓展常采用 babel 这种方式: return { // ... visitor: { FunctionDeclaration(path) { if (path.get('curry').node) { // const foo = curry(function () { ... }); path.node.curry = false; path.replaceWith( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(path.get('id.name').node), t.callExpression(t.identifier('currying'), [ t.toExpression(path.node), ]) ), ]) ); } }, },}; visitor 下每一个 key 名都是遍历过程中的拓展点,比如上面的例子,我们可以对函数定义位置进行拓展和改写。 内置函数注册babel 提供了两种内置函数注册方式,一种类似 polyfill,在全局注册 window 级的变量,另一种是模块化的方式。 除此之外,可以学习的是 babel 通过 this.addHelper("currying") 这种插件拓展方式,在编译后会自动从 helper 引入对应的模块,前提是 @babel/helper 需要注册 currying 这个 helper。 babel 将编译过程隐藏了起来,通过一些高度封装的函数调用,以较为语义化方式书写插件,这样写出来的代码也容易理解。 4 总结《用 Babel 创造自定义 JS 语法》这篇文章虽然说的是 babel 相关知识,但可以从中提取到许多通用知识,这就是现在还去理解 babel 的原因。 从某个功能点为切面,走一遍框架的完整流程是一种高效的进阶学习方式,如果你也有看到类似这样的文章,欢迎推荐出来。 讨论地址是:精读《用 Babel 创造自定义 JS 语法》 · Issue ##210 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《用 React 做按需渲染》","path":"/wiki/WebWeekly/前沿技术/《用 React 做按需渲染》.html","content":"当前期刊数: 154 1 引言BI 平台是阿里数据中台团队非常重要的平台级产品,要保证报表编辑与浏览的良好体验,性能优化是必不可少的。 当前 BI 工具普遍是报表形态,要知道报表形态可不仅仅是一张张图表组件,与这些组件关联的筛选条件和联动关系错综复杂,任何一个筛选条件变化就会导致其关联项重新取数并重渲染组件,而报表数据量非常大,一个表格组件加载百万量级的数据稀松平常,为了维持这么大量级数据量下的正常展示,按需渲染是必须要做的功课。 这里说的按需渲染不是指 ListView 无限滚动,因为报表的布局模式有流式布局、磁贴布局和自由布局三套,每种布局风格差异很大,无法用固定的公式计算组件是否可见,因此我们选择初始化组件全量渲染,阻止非首屏内组件的重渲染。因为初始条件下还没有获取数据,全量渲染不会造成性能问题,这是这套方案成立的前提。 所以我今天就专门介绍如何利用 DOM 判断组件在画布中是否可见这个技术方案,从架构设计与代码抽象的角度一步步分解,不仅希望你能轻松理解这个技术方案如何实现,也希望你能掌握这其中的诀窍,学会举一反三。 2 精读我们以 React 框架为例,做按需渲染的思维路径是这样的: 得到组件 active 状态 -> 阻塞非 active 组件的重渲染。 这里我选择从结果入手,先考虑如何阻塞组件渲染,再一步步推导出判断组件是否可见这个函数怎么写。 阻塞组件重渲染我们需要一个 RenderWhenActive 组件,支持一个 active 参数,当 active 为 true 时这一层是透明的,当 active 为 false 时阻塞所有渲染。 再具体描述一下,其效果是这样的: inActive 时,任何 props 变化都不会导致组件渲染。 从 inActive 切换到 active 时,之前作用于组件的 props 要立即生效。 如果切换到 active 后 props 没有变化,也不应该触发重渲染。 从 active 切换到 inActive 后不应触发渲染,且立即阻塞后续重渲染。 我们可以写一个 RenderWhenActive 组件轻松实现此功能: const RenderWhenActive = React.memo(({ children }) => children, (prevProps, nextProps) => ( !nextProps.active)) 获取组件 active 状态在进一步思考之前,我们先不要掉到 “如何判断组件是否显示” 这个细节中,可以先假设 “已经有了这样一个函数”,我们应该如何调用。 很显然我们需要一个自定义 Hook:useActive 判断组件是否是激活态,并拿到 active 返回值传递给 RenderWhenActive 组件: const ComponentLoader = ({ children }) => { const active = useActive(); return <RenderWhenActive active={active}>{children}</RenderWhenActive>;}; 这样,渲染引擎利用 ComponentLoader 渲染的任何组件就具备了按需渲染的功能。 实现 useActive到现在,组件与 Hook 侧的流程已经完整串起来了,我们可以聚焦于如何实现 useActive 这个 Hook。 利用 Hooks 的 API,可以在组件渲染完毕后利用 useEffect 判断组件是否 Active,并利用 useState 存储这个状态: 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: /** * 监听元素是否可见的抽象类 */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 实现的新潮方法,由于后者有兼容性要求,前者就作为兜底方案实现。 因此我们可以定义两套对应方法: 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.. }} 最后再做一个总类作为调用入口: /** * 监听元素是否可见总类 */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 函数中,核心思想是判断两个矩形(容器与要判断的组件)是否存在包含关系,如果包含成立则代表可见,如果包含不成立则不可见。 下面是完整实现函数: 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。 计算 root 与 component 长度之和 sumOfWidth 与宽度之和 sumOfHeight。 计算 root 与 component 长度之和 + 两倍间距 sumOfWidthWithGap 与 宽度之和 + 两倍间距 sumOfHeightWithGap。 sumOfWidthWithGap - sumOfWidth 的差值就是横向 gap 距离,sumOfHeightWithGap - sumOfHeight 的差值就是横向 gap 距离,两个值都为负数表示在内部。 其中的关键是,从横向角度来看,下面的公式可以理解为宽度之和 + 两倍的宽度间距: // 长度之和 + 两倍间距(交叉则间距为负)const sumOfWidthWithGap = Math.abs( rootComponentRect.left + rootComponentRect.right - componentRect.left - componentRect.right); 而 sumOfWidth 是宽度之和,这之间的差值就是两倍间距值,正数表示横向没有交集。当横纵两个交集都是负数时,代表存在交叉或者包含在内部。 监听组件是否可见 - 原生版本如果浏览器支持 IntersectionObserver 这个 API 就好办多了,以下是完整代码: 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 元素被销毁后,导致后续监听失效,因此需要在元素隐藏时加入下面的代码: // 因为虚拟 dom 更新导致实际 dom 更新,也会在此触发,判断 dom 丢失则重新监听if (!document.body.contains(changes[0].target)) { this.intersectionObserver.unobserve(changes[0].target); this.intersectionObserver.observe(document.getElementById(this.targetDomId));} 当元素判断不在可视区域时,也包含了元素被销毁。 因此通过 body.contains 判断元素是否被销毁,如果被销毁则重新监听新的 DOM 实例。 3 总结总结一下,按需渲染的逻辑的适用面不仅仅在渲染引擎,但对于 ProCode 场景直接编写的代码中,要加入这段逻辑就显得侵入性较强。 或许可视区域内按需渲染可以做到前端开发框架内部,虽然不属于标准框架功能,但也不完全属于业务功能。 这次留下一个思考题,如果让手写的 React 代码具备按需渲染功能,怎么设计更好呢? 讨论地址是:精读《用 React 做按需渲染》· Issue ##254 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《用 Reduce 实现 Promise 串行执行》","path":"/wiki/WebWeekly/前沿技术/《用 Reduce 实现 Promise 串行执行》.html","content":"当前期刊数: 77 1 引言本周精读的文章是 why-using-reduce-to-sequentially-resolve-promises-works,讲了如何利用 reduce 实现 Promise 串行执行。 在 async/await 以前 Promise 串行执行还是比较麻烦的,希望根据这篇文章可以理清楚串行 Promise 的思维脉络。 2 概述除了依赖 async promise-fun 等工具库,最常用的队列操作就是 Array.prototype.reduce() 了: let result = [1, 2, 5].reduce((accumulator, item) => { return accumulator + item;}, 0); // <-- Our initial value.console.log(result); // 8 最后一个值 0 是起始值,每次 reduce 返回的值都会作为下次 reduce 回调函数的第一个参数,直到队列循环完毕,因此可以进行累加计算。 那么将 reduce 的特性用在 Promise 试试: function runPromiseByQueue(myPromises) { myPromises.reduce( (previousPromise, nextPromise) => previousPromise.then(() => nextPromise()), Promise.resolve() );} 当上一个 Promise 开始执行(previousPromise.then),当其执行完毕后再调用下一个 Promise,并作为一个新 Promise 返回,下次迭代就会继续这个循环。 const createPromise = (time, id) => () => new Promise(solve => setTimeout(() => { console.log("promise", id); solve(); }, time) );runPromiseByQueue([ createPromise(3000, 1), createPromise(2000, 2), createPromise(1000, 3)]); 得到的输出是: promise 1promise 2promise 3 3 精读Reduce 是同步执行的,在一个事件循环就会完成(更多请参考 精读《Javascript 事件循环与异步》),但这仅仅是在内存快速构造了 Promise 执行队列,展开如下: new Promise((resolve, reject) => { // Promise ##1 resolve();}) .then(result => { // Promise ##2 return result; }) .then(result => { // Promise ##3 return result; }); // ... and so on! Reduce 的作用就是在内存中生成这个队列,而不需要把这个冗余的队列写在代码里! 更简单的方法感谢 eos3tion 同学补充,在 async/await 的支持下,runPromiseByQueue 函数可以更为简化: async function runPromiseByQueue(myPromises) { for (let value of myPromises) { await value(); }} 多亏了 async/await,代码看起来如此简洁明了。 不过要注意,这个思路与 reduce 思路不同之处在于,利用 reduce 的函数整体是个同步函数,自己先执行完毕构造 Promise 队列,然后在内存异步执行;而利用 async/await 的函数是利用将自己改造为一个异步函数,等待每一个 Promise 执行完毕。 更多 Promise 拓展天猪 同学分享的 promise-fun 除了串行 Promise 解决方案,还提供了一系列 Promise 功能拓展(有一些逐渐被 ES 标准采纳,比如 finally 已经进入 Stage 4),如果你的项目还无法使用 async/await,是不需要自己重新写一遍的,当然本文的原理还是需要好好理解。 Stage 相关可以进行拓展阅读 精读《TC39 与 ECMAScript 提案》。 4 总结Promise 串行队列一般情况下用的不多,因为串行会阻塞,而用户交互往往是并行的。那么对于并行发请求,前端按串行顺序接收 Response 也是一个有意思的话题,留给大家思考。 5 更多讨论 讨论地址是:精读《用 Reduce 实现 Promise 串行执行》 · Issue ##109 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《磁贴布局 - 功能分析》","path":"/wiki/WebWeekly/前沿技术/《磁贴布局 - 功能分析》.html","content":"当前期刊数: 265 磁贴布局三部曲:功能分析、实现分析、性能优化的第一部 - 功能分析。 因为需要做自由布局与磁贴布局混排,以及磁贴布局嵌套,所以要实现一套磁贴分析功能,所以本系列不是简单的介绍使用 react-grid-layout 这个库就行了,而是深入分析磁贴布局的特性,以及重头实现一遍。 对磁贴布局不熟悉的话,react-grid-layout 也是个很好的 Demo 体验页,大家可以先体验一下再阅读文章。 精读简单碰撞磁贴布局最重要的就是碰撞了,用过 Demo 就会发现,磁贴左右不会碰撞,只有上下会产生碰撞,这是因为网页天然是从上而下阅读的,因此垂直碰撞比水平碰撞更自然。 那么垂直的碰撞方向是什么样的呢?实际上只有自上而下的碰撞,没有自下而上的碰撞 为了讲清楚这个原理,先看下面的例子: [-----] [-----]| A | → | B |[-----] [-----] 如上所示,将方块 A 移动到方块 B 的位置,如果此时 A 的 Y 轴位置小于等于 B,则会将 B 挤下去。结果如下所示: [-----]| A |[-----][-----]| B |[-----] 如果 A 的 Y 轴位置比 B 大,则碰撞结果是 A 跑到了 B 的下面: [-----][-----] | B || A | → [-----][-----] 结果如下所示: [-----]| B |[-----][-----]| A |[-----] 如果 A 挤到 B 和 C 中间会如何呢?见下图: [-----][-----] | B || A | → [-----][-----] [-----] [ C ] [-----] 很容易想到,A 会落到 B 与 C 的中间位置。那问题来了,实现的时候,当时 A 放到 B 的下方,还是认为 A 放到 C 的上方? 乍一看会觉得,这不一样吗?对这个例子来说是的,但对其他例子就不同了。实际上应该始终认为是 A 放到了 B 的下方。原因的话,我们举一个反例就行,假设认为 A 放到了 C 的上方,那么看下面的例子: [-----][-----] | B || A | → [-----][-----] [ X ] [-----] [ C ] [-----] 如上图所示,B 和 C 中间夹了一个狭长的 X,此时 A 拖入 B 和 C 的中间,并未与 X 产生碰撞,那结果一定是 A 落在了 B 的下方。如果落在 C 的上方,A 就悬空了。 所以磁贴布局模式下,组件始终只能落在另一个组件下面,除了 Y 轴为 0 的情况下,可以定到组件上方。 连续碰撞连续碰撞是指当磁贴布局产生碰撞而导致位置变化后,需要重新调整整体位置,或者继续与其他组件位置产生碰撞的情况,首先看下面这个简单例子: [-----]| A |[-----] ↓[-----]| B |[-----][-----]| C |[-----] 如果把 A 拖动到 B 位置,遵循简单碰撞原理,必须 Y 轴高于 B 的 Y 轴才会置于 B 下方,此时会把 C 顶上去。但仅做到这一步,A 原来的位置会产生空缺,需要重新吸附到顶部,这就是连续碰撞: [ ] [-----]|Empty| | B |[ ] [-----][-----] [-----]| B | [ A ][-----] → Remove Empty [-----][-----] [-----]| A | [ C ][-----] [-----][-----]| C |[-----] 这时候你可能会想,结果不就是 B 和 A 交换了位置嘛,实际上用 react-grid-layout 看起来效果也是如此,那么代码实现时是不是不用这么麻烦?直接判断 A 与 B 是否产生位置交换,如果交换了,按照交换的方式处理不就行了吗? 听上去很美好,因为按照 A 与 B 交换的思路处理效果一致,而且性能更优,因为不用重新计算 C 组件被挤走,然后 A、B、C 再重新挤上去。但实际上交换方案是不可行的,我们看下面的例子: [-----] | A | [-----] ↓[-------------]| B |[-------------][-----]| C |[-----] 如果把 A 和 B 位置交换,会发现 C 悬空了。之所以上面的例子可以用交换思路,是因为 A 与 B 交换后,A 还可以 “挡住” C 的上移。但这个例子因为 B 很长,但 A 很短,A、B 交换后,A 就挡不住 C 的上移了: [-------------]| B |[-------------][-----] [-----]| C | | A |[-----] [-----] 所以为了保证任何时候位移都不会产生 BUG,需要老老实实的分两步来判断:1. 判断 A 移到 B 的底部。2. 新的 A 把下面组件挤走,同时如果上面还有空位置,需要整体向上位移。 看起来还是比较消耗性能的,但通过一些优化手段是可以极大减少计算量的,我们到系列的 “性能优化” 部分再说。 碰撞边界 case我们再考虑两个极端情况,第一种是要碰撞的组件过于矮的时候,第二种是要碰撞的组件过高的时候。 首先是过矮的情况,我们看下面 5 种情况: [-----]| | ← [ A ]| B || |[-----][-----]| || C || |[-----] 上面的情况插入到 B 的上方(假设 B 上方没有元素了,如果有的话,假设 B 上方为 X,那么应该认为 A 插入到 X 的底部)。 [-----]| || B || | ← [ A ][-----][-----]| || C || |[-----] 上面的情况插入到 B 的下方。 [-----]| || B || |[-----][-----]| | ← [ A ]| C || |[-----] 上面的情况插入到 B 的下方。 [-----]| || B || |[-----][-----]| || C || | ← [ A ][-----] 上面的情况插入到 C 的下方。 [-----]| || B || | [---][-----] ← [ A ][-----] [---]| || C || |[-----] 上面的情况和简单碰撞里提到的例子一样,碰撞位置在 B 与 C 之间,还是会认为插入到 B 的下方。 总结一下,过矮的情况下很多时候拖动组件只会与一个组件产生碰撞,当拖拽中心点在碰撞组件中心点上方时,插入到碰撞组件上方的组件下面(如果上方没有组件则插入到顶部)。当然插入到上方组件下面也不是真的找到上方组件是什么,具体如何做我们等到【实现分析】篇再讲。反之,如果中心点相对在下方,就插入到碰撞组件的下方。如果同时碰撞了多个组件,则忽略中心点偏移量靠上的碰撞,仅考虑中心点偏移量靠下的碰撞。 关于中心点上方其实也可以进一步优化,比如当目标碰撞组件太长的时候,可能比较难移到下方,此时在还没有拖拽到中心点下方时就要做下方碰撞判定了,此时判断依据可以优化为:碰撞时,拖拽组件的 Y 只要比目标组件的 Y 大(或者再加一个常数阈值,该阈值由拖拽组件高度决定,比如是高度的 1/3),那么就认为拖入到目标组件底部,比如: [-----]| | [---]| | ← [ A ]| B | [---]| || |[-----] 如上图所示,虽然 A 的中心点在 B 中心点上方,但因为 A.y - B.y > A.height / 3,所以判定插入到 B 的下方。当然这也会导致拖入超高组件上方很困难,所以要不要这么设定看用户喜好。 再看组件过高的情况: [---][-----] [ ]| B | ← [ A ][-----] [ ][-----] [---]| C |[-----] 上面的情况插入 B 的上方(如果 B 上面还有组件 X,则判定为插入该 X 下方)。 [-----] [---]| B | [ ][-----] ← [ A ][-----] [ ]| C | [---][-----] 上面的情况插入 B 的下方。 [-----]| B |[-----][-----] [---]| C | [ ][-----] ← [ A ] [ ] [---] 上面的情况插入 C 的下方。 总结一下,当拖拽组件过高时,还是维持中心点判断规则,但更可能同时碰撞到多个组件,此时沿用 “中心点偏移量靠上的碰撞” 的原则就行了。但这里有一个较大的区别,拖拽组件矮的时候最多同时和两个组件碰撞,但拖拽组件高的时候,可能同时和 N 个组件碰撞,如下图所示: [-----] [---]| B | [ ][-----] [ ][-----] [ ]| C | [ ][-----] ← [ A ][-----] [ ]| D | [ ][-----] [ ][-----] [ ]| E | [---][-----] 此时就要看和哪个组件碰撞的优先级最高了。我们单从 B、C、D、E 的角度看,A 分别应该放在 B 下方、C 下方、D 上方、E 上方,其中 B 下方与 C 上方是同一个位置,但与 D 上方、E 上方都不是同一个位置,此时就要看拖拽到哪个位置产生的位移最小了,因为最小的位移是最不突兀的,最符合用户的预期。 另一个边界情况就是拖拽组件过高时,如果中心点还未移动到下方,但高度却超出了下面组件下方,也要视为拖拽到下方: [-----]| || || || A || || || |[-----] ↓[-----]| B |[-----] 如上图所示,A 非常高,B 很矮,当 A 往下移动时,可能 A 的底部都超出 B 底部了(可以优化为 B 的中间),但 A 的中心点仍然在 B 中心点上方,此时在用户已经认为可以交换位置了,所以判断是否移动到下方多了一个优先判断条件:拖拽组件底部超出目标组件底部。同理拖拽到上方也类似。 要注意的是,这个例子与下面的例子表现并不一致,下面的例子 A 向左移时,应该放置 B 的上方,而上面的例子却放置 B 的下方: [-----] | | | | | | ← | A |[-----] | || B | | |[-----] | | [-----] 发现了吗?单从垂直位置来看,都是 A 的底部超过了 B 底部,但有时候和 B 互换,有时候却不互换。区分方法就是该碰撞发生时,这两个区块是否已经发生过碰撞。如果未发生过碰撞则严格根据中心点偏移量判断,偏移量靠上则放在上方,反之下方;已经处于碰撞状态则根据顶部或底部判断,顶部超出目标中心点则放上方,底部超出目标中心点则放下方。 碰撞边界与静态区块如果没有静态组件,碰撞边界就只有容器顶部。加上静态组件后,产生位移时要判断加上一段位移是否会把静态组件挤走,如果会挤走,则该拖拽位置无效。 固定步长磁贴布局为了方便对齐,往往会把父容器切割为 12 或者 6 等分,此时拖拽位置就不会完全跟手,当拖拽没有超过临界点的时候,实际拖拽位置不会跟随移动。 总结磁贴布局的功能主要聚焦在组件间碰撞逻辑上,目标是让用户能够自然的布局,所以组件间碰撞逻辑也要尽可能自然,符合直觉。 讨论地址是:精读《磁贴布局 - 功能分析》· Issue ##458 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《磁贴布局 - 性能优化》","path":"/wiki/WebWeekly/前沿技术/《磁贴布局 - 性能优化》.html","content":"当前期刊数: 267 经过上一篇 精读《磁贴布局 - 功能实现》 的介绍,这次我们进入性能优化环节。 精读磁贴布局性能优化方式有很多,比如通过空间换时间,存储父子关系的索引,方便快速查找到目标组件。但有一个最核心的性能优化点,即碰撞性能优化。 试想,最朴素的判断组件碰撞方法是什么?一般会遍历画布所有的组件,根据当前组件位置与目标组件位置的相对位置判断是否产生碰撞,所以仅判断单个组件碰撞时,时间复杂度是 O(n)。 但磁贴布局的碰撞判断涉及整个画布,因为一个组件的移动可能引发另一个组件的移动,形成一系列连环布局变化,比如下面这个情况: [---] [ ] [ A ] [ ] ↑ [---][---------][ B ][---------] [---] [ C ] [---] [-------] [ D ] [-------] 比如将 B 向上移动,每个组件落下来时都要做独立的碰撞判定。因为最终碰撞结果是很难预测的,只能一个组件一个组件的判断。比如上面的例子,结果如下: [---------][ B ][---------] [---] [---] [ C ] [ ] [---] [ A ] [ ] [---] [-------] [ D ] [-------] 可以看到,D 本来是紧紧靠着 C 的,但因为 A 组件移下来了,且 A 比 C 高,所以 D 紧靠的组件就从 C 变成 A 了,这个在 C 做独立碰撞判断之前,是难以通过画布的结构分析出来的,更不用说结合上画布的整体大小缩放、栅格数量的变化后产生的影响,组件最终落点必须每个组件通过正确顺序依次判定碰撞后才能确定。 因此磁贴碰撞的时间复杂度是 O(n²),比如页面中有 100 个组件,就至少要遍历 10000 次才能完成一次布局计算,这样在比较极限的情况下,比如页面有 1000 个组件时,布局计算肯定非常耗时。 栅格碰撞判定法再思考一个问题,正是由于磁贴布局的碰撞判定,导致 磁贴布局不可能存在组件重叠的情况,因此即便画布存在 1000 个组件,只要组件宽高不是特别小(比如每个组件 1px 宽高,挤满 1000px 区域),都不可能聚集在某个小区域内,而是分散在很大的范围,那么与当前组件过远的组件就根本不需要做碰撞判定,因为他们不可能相交。 再类比到人判断碰撞的视角,当画布有 1000 个组件时,我们也能一眼看出来某个组件与哪些组件相交,但这个判断来自于肉眼在可视区域一扫而过,而不是把 1000 个组件全部看一遍。这说明人眼判定碰撞是经过优化的:以这个组件为圆心,上下左右扩大一定的范围扫一眼是否有碰撞就够了。 因此我们模拟人眼找碰撞的思路,把画布分为若干的栅格,记录每个组件所在的栅格,这样碰撞判定时,只要在组件所在栅格内进行判定就行了。 如下将画布分为若干栅格: [---] │ │ │ │ [ A ] │ │ │ │ [---] │ │ │ │────────┼────────┼────────┼────────┼──────── [-----] │ │ │ [ B ] │ [---]│ │ [-----][C] │ [ G ]│ │────────┼────────┼───[---]┼────────┼──────── │ │ [E] │ [F] │ │ │ [-----------] │ │ │ [ ] │────────┼────────┼───[ D ]─┼──────── │ │ [ ] │ │ │ [-----------] │ │ │ │ │ 这样当判定如下组件碰撞时,要对比的组件如下: A:对比组件无。 B:对比组件 C。 D:对比组件 E、F、G 由于一个区域承载组件数量是固定的,所以 O(n²) 时间复杂度就优化为了 O(n x P) 其中 P 对每个组件来说都是常数,因此时间复杂度最终为 O(n)。 当然这里存在几个注意事项: 需要空间换时间,即存储每个组件属于哪些区域,以及每个区域有哪些组件,这样拖拽判定时无需遍历所有组件。 栅格大小不宜过大,栅格过大则划分栅格的意义就不大了,因为一个栅格内组件数还是很多。 栅格大小不宜过小,这样每个组件可能横跨很多栅格,导致栅格数量本身的循环次数甚至会超越组件树,就变成了负优化。 关于栅格大小,一般磁贴布局会设置 cols rowHeight 两个选项,以这两个选项的正整数倍为跨度设置栅格是比较合适的,这样会尽可能减少栅格的无效面积。 不同场景下的栅格计算上面说了 组件碰撞 如何使用栅格计算,我们再总结一下:判定组件碰撞,只要找到当前组件所在的栅格 areas,遍历每一个栅格区域内的组件即可。 除了碰撞判断外,磁贴拖拽过程中还有两个场景需要计算组件间碰撞关系,主要包括 落点位置 与 落点后组件排序 两个场景。 比如下面的例子: 蓝色框为鼠标拖动组件时,鼠标的实时位置,而红色背景正方形表示 落点位置,红色正方形下方的组件属于 落点后组件,这些组件因为红色正方形的位置插入,需要重新计算位置。 为了最大程度利用栅格优化性能,这两种情况需要分别判断。 落点位置由于磁贴布局的重力是垂直向上的,因此落点只会落在当前组件的上方,也就是落点只会与上方组件碰撞,因此考虑垂直向上的栅格区域即可。而且过程中还是可以优化的,即一格一格向上查找,只要在某个格内查到碰撞组件,就可以终止查找了: [---] │ │ [ A ] │ │ [---] │ │────────┼────────┼───────── [-----] │ [ B ] │ [-----] │────────┼────────┼─────────[-----] │ │[ C ] │ │[-----] │ │────────┼────────┼───────── [-----] │ [ D ] │ [-----] │ 如上面的例子,移动 D 时: 先考虑 D 所在区域是否有组件垂直区域可碰撞,因为 D 所在区域只有自己,所以跳过。 在考虑 D 区域上方一格区域,发现组件 C,且与 D 在垂直位置可碰撞,因此 D 的落点位置放在 C 的下方。 查找结束,再向上的区域直接跳过。 因此落点位置的查找时间复杂度是 O(1)。 落点后组件排序落点位置决定后,由于落点位置毕竟发生了变化,落点之后的组件都要重新按照磁贴向上的重力作用排序,所以此时组件查找范围是包含落点所在区域内,垂直向下的所有区域: [---] │ │ [ A ] │ │ [---] │ │────────┼────────┼───────── [-----] │ [ B ] │ [-----] │────────┼────────┼─────────[-----] │ │[ C ] │ │[-----] │ │────────┼────────┼───────── [-----] │ [ D ] │ [-----] │ 如上面的例子,移动 A 时,A 所在区域下方所有区域都要重新判断落点,也就是 B、C、D 组件所在区域。其他区域不受影响。我们假设所有组件均匀的平铺在所有区域,那么最坏的情况下(移动的组件在最顶部,那么一整条高度的区域都要搜索)纵向区域的组件数是 logn,所以时间复杂度理论上是 O(logn)。但一般情况磁贴布局高度远大于宽度,所以可能往较坏的 O(n) 复杂度发展,但不论如何,这个线性性能是可接受的。 总结经过优化,磁贴布局在拖拽前、中、后各个阶段的计算复杂度均为 O(n),即一个拥有 500 个组件实例的复杂画布,也只要在每次拖动时循环 500 次计算位置,而配合空间换时间的一些 Map 映射关系配合,500 次计算加起来最多消耗 23 ms,而 1000 个组件实例也最多 46 ms 的消耗,但超过 1000 个组件实例的画布几乎是不可能存在的,况且这里 log(n) 的 n 指的是每个容器内的组件,因此只要单个容器内组件数量几乎不会超过特别多,所以性能是没有问题的。 讨论地址是:精读《磁贴布局 - 性能优化》· Issue ##461 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《用 css grid 重新思考布局》","path":"/wiki/WebWeekly/前沿技术/《用 css grid 重新思考布局》.html","content":"当前期刊数: 124 1 引言Flex 与 Grid 相比就像功能键盘和触摸屏。触摸屏的控制力相比功能键盘来说就像是降维打击,因为功能键盘只能上下左右控制(x、y 轴),而触摸屏打破了布局障碍,直接从(z 轴)触达,这样 无论 UI 内部布局再复杂,都可以通过 touch 直接定位。 Flex 是一维布局方式,我们需要不断嵌套 Div 才能形成复杂结构,而一旦布局产生了变化,原有嵌套结构如果不能 “兼容变化” 到新结构,代码就需要重构。而 Grid 就像触摸屏一样,可以二维布局,即便布局方式做了翻天覆地的调整,也仅需少量修改就能适配。 这就是这次精读 用 css grid 重新思考布局 的原因,理解这个革命性布局技术给布局,甚至代码逻辑组织带来的变化。 2 概述作者首先抛出了 Flex 的问题,其实是 block float flex 这三种布局模式的通病: 布局结构由 Div 层级结构描述,导致 Div 层级复杂且遇到结构变更时难以维护。 定制能力弱。Flex 布局有一些不受控制的智能设定,比如宽度 50% 的子元素会被同级元素挤到 50% 以下,这种智能化在某些场景是需要的,但由于没有提供像 Grid 的 minmax 之类的 API,所以定制型不足。 举个例子,上图的结构用 Flex 描述可能是这样的: <div class="card"> <div class="profile-sidebar"> <img src="https://i.pravatar.cc/125?image=3" alt="" class="profile-img" /> <ul class="social-list"> <li> <a href="##" class="social-link" ><i class="fab fa-dribbble-square"></i ></a> </li> <li> <a href="##" class="social-link" ><i class="fab fa-facebook-square"></i ></a> </li> <li> <a href="##" class="social-link" ><i class="fab fa-twitter-square"></i ></a> </li> </ul> </div> <div class="profile-body"> <h2 class="profile-name">Ramsey Harper</h2> <p class="profile-position">Graphic Designer</p> <p class="profile-info"> Lorem ipsum dolor sit amet consectetur adipisicing elit. Facere a tempore, dignissimos odit accusantium repellat quidem, sit molestias dolorum placeat quas debitis ipsum esse rerum? </p> </div></div> 利用 HTML 嵌套结构,我们将图形纵向分成两大块,然后在每块内部继续嵌套划分布局,这是最经典的布局行为了。 样式文件里,我们需要对每层布局进行描述,同时支持多分辨率弹性布局,包括顶层 card 容器在内的一些样式需要做一定调整: .card { width: 80%; margin: 0 auto; display: flex; flex-direction: column; max-width: 600px; background: ##005e9b; flex-basis: 250px; color: white; padding: 2em; text-align: center;}.profile-info { font-weight: 300; opacity: 0.7;}.profile-sidebar { margin-right: 2em; text-align: center;}.profile-name { letter-spacing: 1px; font-size: 2rem; margin: 0.75em 0 0; line-height: 1;}.profile-name::after { content: ""; display: block; width: 2em; height: 1px; background: ##5bcbf0; margin: 0.5em auto 0.65em; opacity: 0.25;}.profile-position { text-transform: uppercase; font-size: 0.875rem; letter-spacing: 3px; margin: 0 0 2em; line-height: 1; color: ##5bcbf0;}.profile-img { max-width: 100%; border-radius: 50%; border: 2px solid white;}.social-list { list-style: none; justify-content: space-evenly; display: flex; min-width: 125px; max-width: 175px; margin: 0 auto; padding: 0;}.social-link { color: ##5bcbf0; opacity: 0.5;}.social-link:hover,.social-link:focus { opacity: 1;}.bio { padding: 2em; display: flex; flex-direction: column; justify-content: center;}@media (min-width: 450px) { .bio { text-align: left; max-width: 350px; }}.bio-title { color: ##0090d1; font-size: 1.25rem; letter-spacing: 1px; text-transform: uppercase; line-height: 1; margin: 0;}.bio-body { color: ##555;}.profile { display: flex; align-items: flex-start;}@media (min-width: 450px) { .card { flex-direction: row; text-align: left; } .profile-name::after { margin-left: 0; }} 让我们看看 Grid 是怎么做的吧!Grid 有许多 API,我们重点看 grid-template-areas 这个属性,利用它,我们可以不关心模块的 HTML 结构,直接平铺方式描述: <div class="card"> <img src="https://i.pravatar.cc/125?image=3" alt="" class="profile-img" /> <ul class="social-list"> <li> <a href="##" class="social-link"><i class="fab fa-dribbble-square"></i></a> </li> <li> <a href="##" class="social-link"><i class="fab fa-facebook-square"></i></a> </li> <li> <a href="##" class="social-link"><i class="fab fa-twitter-square"></i></a> </li> </ul> <h2 class="profile-name">Ramsey Harper</h2> <p class="profile-position">Graphic Designer</p> <p class="profile-info"> Lorem ipsum dolor sit amet consectetur adipisicing elit. Facere a tempore, dignissimos odit accusantium repellat quidem, sit molestias dolorum placeat quas debitis ipsum esse rerum? </p></div> 可以看到,使用 Grid 可以将 UI 结构与 HTML 结构分离,HTML 结构仅描述包含关系,我们只需在样式文件中描述具体 UI 结构。 样式文件只截取 Grid 相关部分: .card { width: 80%; margin: 0 auto; display: flex; flex-direction: column; max-width: 600px; background: ##005e9b; flex-basis: 250px; color: white; padding: 2em; text-align: left; display: grid; grid-template-columns: 1fr 3fr; grid-column-gap: 2em; grid-template-areas: "image name" "image position" "social description";}.profile-name { grid-area: name;}.profile-position { grid-area: position;}.profile-info { grid-area: description;}.profile-img { grid-area: image;}.social-list { grid-area: social;} 可以看到,grid-template-areas 是进一步抽象的语法,将页面结构通过直观的文本描述,无论是理解还是修改都更为轻松。 这种描述方式适配不同分辨率下也具有优势,只要重组 grid-template-areas 即可: @media (min-width: 600px) { .card { text-align: left; grid-template-columns: 1fr 3fr; grid-template-areas: "image name" "image position" "social description"; }} 归根结底,Grid 通过二维结构描述,将子元素布局控制收到了父级,使布局描述更加直观。 最后作者也提到,Flex 依然有使用场景,即简单的一维结构,或者 space-between 等 Flex 独有语法的情况。因此推荐整体、复杂的二维布局采用 Grid,一维的简单布局采用 Flex。 3 精读Grid 的布局思路给了我很多启发,HTML 结构与 UI 结构的分离有助于减少 DIV 的层级结构,使代码看上去更清晰。 也许有人会疑惑,Grid 无非将 HTML 布局部分功能挪到了 CSS,整体复杂度应该不变。其实,从 grid-template-areas 这个 API 可以看到,Grid 不仅仅将布局功能抽到 CSS 中,更是将布局描述进行了一层抽象,使代码更易维护。 抽象,再抽象为什么 Grid 可以对布局进行抽象?因为 Grid 将二维结构都掌握在手中,得到了更大的布局能力,才能进一步将结构化语法抽象为字符串的描述。 抽象的好处是不言而喻的,你觉得一堆嵌套的 DIV 与下面的代码,哪个更易读呢? .card { grid-template-areas: "image name" "image position" "social description";} 这就是抽象的好处,一般来说,代码抽象程度越高就越易读,越易维护。 再看一个 Chrome Grid 插件,将 Grid 可视化显示出来,并可以以 UI 方式进行调整: UI 是对文本的再抽象,同时可以规避一些不可能存在的语法,比如: .card { grid-template-areas: "image name" "image position" "social image";} 布局只能以凸多边形方式拓展,不可能分离,也不可能突然插入一个其他模块而变成凹多边形。因此 UI 可以将这个错误规避,并简化为横竖多条线的方式对 UI 进行划分,显然这种描述方式效率更高。 不得不说,Grid 以及图形化插件的探索,是布局领域的一大进步,是不断抽象的尝试,要解决的问题只有一个:如何提供一种更直观的描述 UI 的方式。 布局对模块化的影响Grid 将布局方式提高了一个维度,会直接影响到 JS 模块化方式。 尤其是以 JSX 组织代码的情况下,一个模块等于 UI + JS,通过嵌套方式的布局会让我们更倾向于站在 UI 视角划分模块。 比如对于上图模块,如果用 Flex 方式布局,我们可能会首先创建模块 X 作为左侧容器,子元素是 A 和 B,创建模块 Y 作为右侧容器,子元素是 C 以及新容器 Z,Z 容器的子元素是 D 和 E。 如果你的第一印象是这么组织代码,不得不承认模块化会受到布局方式的影响。虽然许多时候这样划分是正确的,但当这 5 个模块各自没有关联时,我们创建的容器 X、Y、Z 就失去了复用性,在新的组合场景我们又要重新组合一遍。 但是在 Grid 语法中,我们不需要 X、Y、Z,只需要用 css grid generator 按照上图的方式拖拖拽拽即可自动生成如下布局代码: .parent { display: grid; grid-template-columns: 3fr repeat(2, 1fr); grid-template-rows: repeat(5, 1fr); grid-column-gap: 0px; grid-row-gap: 0px;}.div1 { grid-area: 1 / 1 / 3 / 2;}.div2 { grid-area: 3 / 1 / 6 / 2;}.div3 { grid-area: 1 / 2 / 2 / 4;}.div4 { grid-area: 2 / 2 / 6 / 3;}.div5 { grid-area: 2 / 3 / 6 / 4;} 其实 grid-template-columns grid-template-rows 组合起来使用比 grid-template-areas 更强大,但是纯代码方式描述没有 grid-template-areas 直观,可是配合一些可视化系统就非常直观了: 将 A ~ E 这 5 个模块布局抽出来后,它们之间的关系就打平了,我们可以完全从逻辑视角审视如何做模块化了。 4 总结CSS Grid 本质上是一种二维布局的语法,相比 Block、Flex 等一维布局方案,多了一个维度可以同时从行与列角度定义布局,因此派生出 grid-template-areas 等语法,整体上更内聚更直观,抽象度也更高了。 理解了这些也就理解了布局未来的发展方向,让布局与 Dom 分离 一直是前端的一个梦想,开发 UI 部分时,只需关心页面由哪些模块组成,去实现这些模块就行了,而不需要关心模块之间应该如何组合。在描述组合时,可以通过可视化或比较抽象的字符串描述布局的结构,并对应到写好的模块上,这样的代码维护性远高于用 DIV 描述结构的方案。 讨论地址是:精读《用 css grid 重新思考布局》 · Issue ##211 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《磁贴布局 - 功能实现》","path":"/wiki/WebWeekly/前沿技术/《磁贴布局 - 功能实现》.html","content":"当前期刊数: 266 经过上一篇 精读《磁贴布局 - 功能分析》 的分析,这次我们进入实现环节。 精读实现磁贴布局前,先要实现最基础的组件拖拽流程,然后我们才好在拖拽的基础上增加磁贴效果。 基础拖拽能力对布局抽象来说,它关心的就是 可拖拽的组件 与 容器 的 DOM,至于这些 DOM 是如何创建的都可以不用关心,在这个基础上,甚至可以再做一套搭建或者布局框架层,专门实现对 DOM 的管理,但这篇文章还是聚焦在布局的实现层。 布局组件首先要收集到有哪些可拖拽组件与容器,假设业务层将这些 DOM 生成好传给了布局: const elementMap: Record< string, { dom: HTMLElement; x: number; y: number; width: number; height: number; }> = {};const containerMap: Record< string, { dom: HTMLElement; rectX: number; rectY: number; width: number; height: number; }> = {}; elementMap 表示可拖拽的组件信息,包括其 DOM 实例,以及相对于父容器的 x、y、width、height。 containerMap 表示容器组件信息,之所以存储 rectX 与 rectY 这两个相对浏览器绝对定位,是因为容器的直接父组件可能是 element,比如 Card 组件可以同时渲染 Header 与 Footer,这两个位置都可以拖入 element,所以这两个位置都是 container,它们是相对父 element Card 定位的,所以存储绝对定位方便计算。 接下来给 elementMap 的每一个组件绑定鼠标按下事件作为 onDragStart 时机: Object.keys(elementMap).forEach((componentId) => { elementMap[componentId].dom.onmousedown = () => { // 记录拖拽开始 };}); 然后在 document 监听 onMouseMove 与 onMouseUp,分别作为 onDrag 与 onDragEnd 时机,这样我们就抽象了拖拽的前、中、后三个阶段: function onDragStart(context, componentId) { context.dragComponent = componentId;}function onDrag(context, event) { // 根据 context.dragComponent 响应组件的拖动 // 将 element x、y 改为 event.clientX、event.clientY 即可}function onDragEnd(context) { context.dragComponent = undefined;} 这样最基础的拖拽能力就做好了,在实际代码中,可能包含进一步的抽象这里为了简化先忽略,比如可能对所有事件的监听进行 Action 化,以便单测在任何时候模拟用户输入。 磁贴布局影响因子磁贴布局入场后,仅影响 onDrag 阶段。在之前的逻辑中,拖拽是完全自由的,那么磁贴布局就会约束两点: 对当前拖拽组件位置做约束。 可能把其他组件挤走。 对拖拽组件位置的约束是由背后的 “松手 DOM” 决定的,也就是拖拽时 element 是实时跟手的,但如果拖拽位置无法放置,就会在松手时修改落地位置,这个落地位置我们叫做 safePosition,即当前组件的安全位置。 所以 onDrag 就要计算一个新的 safePosition,它应该如何计算,由磁贴的碰撞方式决定,我们可以在 onDrag 函数里做如下抽象: function onDrag(context, event) { // 根据 context.dragComponent 响应组件的拖动 const { safeX, safeY } = collision(context, event.clientX, event.clientY); // 实时的把组件位置改为 event.clientX、event.clientY // 把背后实际落点 DOM 位置改为 safeX、safeY // onDragEnd 时,再把组件位置改为 safeX、safeY,让组件落在安全的位置上} 接下来就到了重点函数 collision 的实现部分,它需要囊括磁贴布局的所有核心逻辑。 collision 函数包括两大模块,分别是拖入拖出模块与碰撞模块。拖入拖出判断当前拖拽位置是否进入了一个新容器,或者离开了当前容器;碰撞模块判断当前拖拽位置是否与其他 element 产生了碰撞,并做出相应的碰撞效果。 除此之外,磁贴布局还允许组件按照重力影响向上吸附,因此我们需要做一个 runGravity 函数,把所有组件按照重力作用排列。 function collision(context, x, y) { // 先做拖入拖出判断 if (judgeDragInOrOut(context, event)) { // 如果判定为拖入或拖出,则不会产生碰撞,提前 return // 但是拖出时需要对原来的父节点做一次 runGravity // 拖入时不用对原来父节点做 runGravity return { safeX: x, safeY: y }; } // 碰撞模块 return gridCollsion(context, x, y);} 为什么拖入时不用对原来父节点做 runGravity: 假设一个 element 从上向下移动入一个 container,那么一旦拖入 container 就会在其上方产生 Empty 区域,如果此时 container 立即受重力作用挤了上去,但鼠标还没松手,可能鼠标位置又立即落在了 container 之外,导致组件触发了拖出。因此拖入时,先不要立刻对原先所在的父容器作用重力,这样可以维持拖入时结构的稳定。 拖入拖出模块拖入拖出判断很简单,即一个 element 如果有 x% 进入了 container 就判定为拖入,有 y% 离开了 container 就判定为离开。 碰撞模块碰撞模块 gridCollsion 比较复杂,这里展开来讲。首先需要写一个矩形相交函数判断两个组件是否产生了碰撞: function gridCollsion(context, x, y) { Object.keys(context.elementMap).forEach((componentId) => { // 判断 context.dragComponent 与 context.elementMap[componentId] 是否相交,相交则认为产生了碰撞 });} 如果没有产生碰撞,那我们要根据重力影响计算落点 safeY(横向不受重力作用且一定跟手,所以不用算 safeX)。此时直接调用 runGravity 函数,传一个 extraBox,这个 extraBox 就是当前鼠标位置产生的 box,这个 box 因为没有与任何组件产生碰撞,直接判断一下在重力的作用下,该 extraBox 会落在哪个位置即可,这个位置就是 safeY: function gridCollision(context, x, y) { // 在某个父容器内计算重力,同时塞入一个 extraBox,返回这个 extraBox 生效重力后的 Y:extraBoxY const { extraBoxY } = runGravity(context, parentId, extraBox); return { safeY: extraBoxY };} 没有产生碰撞的逻辑相对简单,如果产生了碰撞的逻辑是这样的: // 是否为初始化碰撞。初始化碰撞优先级最低,所以只要发生过非初始碰撞,与其他组件的初始碰撞也视为非初始碰撞let isInitCollision = true;Object.keys(context.elementMap).forEach((componentId) => { // 判断 context.dragComponent 与 context.elementMap[componentId] 是否相交 const intersect = areRectanglesOverlap(); // 相交 if (intersect.isIntersect) { // 1. 在 context 存储一个全局变量,判断当前组件之前是否相交过,以此来判断是否要修改 isInitCollision // 2. 判断产生碰撞后,该碰撞会导致鼠标位置的 box,也就是 extraBox 放到该组件之上还是之下 }}); 首先要确定当前碰撞是否为初始化碰撞,且一旦有一个组件不是初始化碰撞,就认为没有发生初始化碰撞。原因是初始化碰撞的位置判断比较简单,直接根据 source 与 target element 的水平中心点的高低来判断落地位置。如果 source 水平中心点位置比 target 的高,则放到 target 上方,否则放在 target 下方。 如果是非初始化碰撞逻辑会复杂一些,比如下面的例子: // [---] [ C ]// [ B ]// [---]// ↑// [-------]// [ A ]// [-------] 当 A 组件向上移动时,因为已经与 B 产生了碰撞,所以就会尝试判断合适置于 B 之上,否则永远会把自己锁在 B 的下方。实际上,我们希望 A 的上边缘超过 B 的水平中心点就产生交换,此时 A 的水平中心点还在 B 的水平中心点之下,所以此时按照两种不同的判断规则会产生不同的位置判定,区分的手段就是 A 与 B 是否已经处于相交状态。 现在终于把插入位置算好了(根据是否初始化碰撞,判断 extraBox 落在哪个 element 的上方或者下方),那么就进入 runGravity 函数: function runGravity(context, parentId, extraBox) {} 这个函数针对某个父容器节点生效重力,因此在不考虑 extraBox 的情况下逻辑是这样的: 先拿到该容器下所有子 element,对这些 element 按照 y 从小到大排序,然后依次计算落点,已经计算过的组件会计算到碰撞影响范围内,也就是新的组件 y 要尽可能小,但如果水平方向与已经算过的组件存在重叠,那么只能顶到这些组件的下方。 如果有 extraBox 的话,问题就复杂了一些,看下面的图: // [---] [ C ]// [ B ]// [---]// ↑// [-------]// [ A ]// [-------]// A 这个 extraBox before B// 这个例子应该按照 C -> A -> B 的顺序计算重力// 规则:如果有 before ids(ids y,bottom 都一样),则把排序结果中 y >= ids.y & bottom < ids[0].bottom 的组件抽出来放到 ids 第一个组件之前// [-------]// [ A ]// [-------]// ↓// [---] [ C ]// [ B ]// [---]// A 这个 extraBox after B// 这个例子应该按照 C -> A -> B 的顺序计算重力// 规则:如果有 after ids(ids y,bottom 都一样),则把排序结果中 y <= ids.y & bottom > ids[0].bottom 的组件抽出来放到 ids 最后一个组件之后 因为 extraBox 是一个插入性质的位置,所以计算方式肯定有所不同。以第一个例子为例:当 A 向上移动并可以与 B 产生交换时,最后希望的结果自上至下是 C -> A -> B,但因为 C 和 B 的 y 都是 0,如果我们把 A 与 B 交换理解为 A 的 y 变成 0 从而把 B 挤下去,那么 A 也会把 C 挤下去,导致结果不对。 因此重要的是计算重力的优先级,上面的例子重力计算顺序应该是先算 C,再算 A,再算 B,这个逻辑的判断依据如上面注释所说。 上面说的都是 isInitCollision=false 的算法,如果 isInitCollision=true,则 extraBox 按照 y 顺序普通插入即可。原因看下图: // [-------] [-]// [ ] [ ]// [ ] [D]// [ A ] → [ ]// [ ] [-]// [ ] [-----------------]// [-------] [ ]// [-----] [ C ]// [ B ] [ ]// [-----] [-----------------] 当将 A 向右移动直到与 C 碰撞时,按照 y 来计算重力优先级时结果是正确的。如果按照 extraBox 已产生过碰撞的算法,则会认为 A 放到 C 的上方,但因为 B 相对于 C 满足 y >= ids.y & bottom < ids[0].bottom,所以会被提取到 C 的前面计算,导致 B 放在了 A 前面,产生了错误结果。因为这种碰撞被误判为 “A 从 C 的下方向上移动,直到与 C 交换,此时 B 依然要置于 A 的上方”,但实际上并没有产生这样的移动,而是 A 与 C 的一次初始化碰撞,因此不能适用这个算法。 总结因为篇幅有限,本文仅介绍磁贴布局实现最关键的部分,其他比如步长功能,如果后续有机会再单独整理成一篇文章发出来。 从上面的讨论可以发现,在每次移动时都要重新计算 safe 位置的落点,而这个落点又依赖 runGravity 函数,如果每次都要把容器下所有组件排序,并一一计算落点位置的话,时间复杂度达到了 O(n²),如果画布有 100 个组件,就会至少循环一万次,对性能压力是比较大的。因此磁贴布局也要做性能优化,这个我们放到下篇文章介绍。 讨论地址是:精读《磁贴布局 - 功能实现》· Issue ##459 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法基础数据结构》","path":"/wiki/WebWeekly/前沿技术/《算法基础数据结构》.html","content":"当前期刊数: 194 掌握了不同数据结构的特点,可以让你在面对不同问题时,采用合适的数据结构处理,达到事半功倍的效果。 所以这次我们详细介绍各类数据结构的特点,希望你可以融会贯通。 精读数组 数组非常常用,它是一块连续的内存空间,因此可以根据下标直接访问,其查找效率为 O(1)。 但数组的插入、删除效率较低,只有 O(n),原因是为了保持数组的连续性,必须在插入或删除后对数组进行一些操作:比如插入第 K 个元素,需要将后面元素后移;而删除第 K 个元素,需要将后面元素前移。 链表 链表是为了解决数组问题而发明出来的,它提升了插入、删除效率,而牺牲了查找效率。 链表的插入、删除效率是 O(1),因为只要将对应位置元素断链、重连就可以完成插入、删除,而无需关心其他节点。 相应的查找效率就低了,因为存储空间不是连续的,所以无法像数组一样通过下标直接查找,而需要通过指针不断搜索,所以查找效率为 O(n)。 顺带一提,链表可以通过增加 .prev 属性改造为双向链表,也可以通过定义两个 .next 形成二叉树(.left .right)或者多叉树(N 个 .next)。 栈 栈是一种先入后出的结构,可以用数组模拟。 const stack: number[] = []// 入栈stack.push(1)// 出栈stack.pop() 堆 堆是一种特殊的完全二叉树,分为大顶堆与小顶堆。 大顶堆指二叉树根节点是最大的数,小顶堆指二叉树根节点是最小的数。为了方便说明,以下以大顶堆举例,小顶堆的逻辑与之相反即可。 大顶堆中,任意节点都比其叶子结点大,所以根节点是最大的节点。这种数据结构的优势是可以以 O(1) 效率找到最大值(小顶堆找最小值),因为直接取 stack[0] 就是根节点。 这里稍微提一下二叉树与数组结构的映射,因为采用数组方式操作二叉数,无论操作还是空间都有优势:第一项存储的是节点总数,对于下标为 K 的节点,其父节点下标是 floor(K / 2),其子节点下标分别是 K * 2、K * 2 + 1,所以可以快速定位父子位置。 而利用这个特性,可以将插入、删除的效率达到 O(logn),因为可以通过上下移动的方式调整其他节点顺序,而对于一个拥有 n 个节点的完全二叉树,树的深度为 logn。 哈希表 哈希表就是所谓的 Map,不同 Map 实现方式不同,常见的有 HashMap、TreeMap、HashSet、TreeSet。 其中 Map 和 Set 实现类似,所以以 Map 为例讲解。 首先将要存储的字符求出其 ASCII 码值,再根据比如余数等方法,定位到一个数组的下标,同一个下标可能对应多个值,因此这个下标可能对应一个链表,根据链表进一步查找,这种方法称为拉链法。 如果存储的值超过一定数量,链表的查询效率就会降低,可能会升级为红黑树存储,总之这样的增、删、查效率为 O(1),但缺点是其内容是无序的。 为了保证内容有序,可以使用树状结构存储,这种数据结构称为 HashTree,这样时间复杂度退化为 O(logn),但好处是内容可以是有序的。 树 & 二叉搜索树 二叉搜索树是一种特殊二叉树,更复杂的还有红黑树,但这里就不深入了,只介绍二叉搜索树。 二叉搜索树满足对于任意节点,left 的所有节点 < 根节点 < right 的所有节点,注意这里是所有节点,因此在判断时需要递归考虑所有情况。 二叉搜索树的好处在于,访问、查找、插入、删除的时间复杂度均为 O(logn),因为无论何种操作都可以通过二分方式进行。但在最坏的情况会降级为 O(n),原因是多次操作后,二叉搜索树可能不再平衡,最后退化为一个链表,就变成了链表的时间复杂度。 更好的方案有 AVL 树、红黑树等,像 JAVA、C++ 标准库实现的二叉搜索树都是红黑树。 字典树 字典树多用于单词搜索场景,只要给定一个单独开头,就可以快速查找到后面有几种推荐词。 比如上面的例子,输入 “o”,就可以快速查找到后面有 “ok” 与 “ol” 两个单词。要注意的是,每个节点都要有一个属性 isEndOfWord 表示到当前为止是否为一个完整的单词:比如 go 与 good 两个都是完整的单词,但 goo 不是,因此第二个 o 与第四个 d 都有 isEndOfWord 标记,表示读到这里就查到一个完整的单词了,叶子结点的标记也可以省略。 并查集 并查集用来解决团伙问题,或者岛屿问题,即判断多个元素之间是属于某个集合。并查集的英文是 Union and Find,即归并与查找,因此并查集数据结构可以写成一个类,提供两个最基础的方法 union 与 find。 其中 union 可以将任意两个元素放在一个集合,而 find 可以查找任意元素属于哪个根集合。 并查集使用数组的数据结构,只是有以下特殊含义,设下标为 k: nums[k] 表示其所属的集合,如果 nums[k] === k 表示它是这个集合的根节点。 如果要数一共有几个集合,只要数有多少满足 nums[k] === k 条件的数目即可,就像数有几个团伙,只要数有几个老大即可。 并查集的实现不同,数据也会有微妙的不同,高效的并查集在插入时,会递归将元素的值尽量指向根老大,这样查找判断时计算的快一些,但即便指向的不是根老大,也可以通过递归的方式找到根老大。 布隆过滤器 Bloom Filter 只是一个过滤器,可以用远远超过其他算法的速度把未命中的数据排除掉,但未排除的也可能实际不存在,所以需要进一步查询。 布隆过滤器是如何做到这一点的呢?就是通过二进制判断。 如上图所示,我们先存储了 a、b 两个数据,将其转化为二进制,将对应位置改为 1,那么当我们再查询 a 或 b 时,因为映射关系相同,所以查到的结果肯定存在。 但查询 c 时,发现有一项是 0,说明 c 一定不存在;但查询 d 时,恰好两个都查到是 1,但实际 d 是不存在的,这就是其产生误差的原因。 布隆过滤器在比特币与分布式系统中使用广泛,比如比特币查询交易是否在某个节点上,就先利用布隆过滤器挡一下,以快速跳过不必要的搜索,而分布式系统计算比如 Map Reduce,也通过布隆过滤器快速过滤掉不在某个节点的计算。 总结最后给出各数据结构 “访问、查询、插入、删除” 的平均、最差时间复杂度图: 这个图来自 bigocheatsheet,你也可以点开链接直接访问。 学习了这些基础数据结构之后,希望你可以融会贯通,善于组合这些数据结构解决实际的问题,同时还要意识到没有任何一个数据结构是万能的,否则就不会有这么多数据结构需要学习了,只用一个万能的数据结构就行了。 对于数据结构的组合,我举两个例子: 第一个例子是如何以 O(1) 平均时间复杂度查询一个栈的最大或最小值。此时一个栈是不够的,需要另一个栈 B 辅助,遇到更大或更小值的时候才入栈 B,这样栈 B 的第一个数就是当前栈内最大或最小的值,查询效率是 O(1),而且只有在出栈时才需要更新,所以平均时间复杂度整体是 O(1)。 第二个例子是如何提升链表查找效率,可以通过哈希表与链表结合的思路,通过空间换时间的方式,用哈希表快速定位任意值在链表中的位置,就可以通过空间翻倍的牺牲换来插入、删除、查询时间复杂度均为 O(1)。虽然哈希表就能达到这个时间复杂度,但哈希表是无序的;虽然 HashTree 是有序的,但时间复杂度是 O(logn),所以只有通过组合 HashMap 与链表才能达到有序且时间复杂度更优,但牺牲了空间复杂度。 包括最后说的布隆过滤器也不是单独使用的,它只是一个防火墙,用极高的效率阻挡一些非法数据,但没有阻挡住的不一定就是合法的,需要进一步查询。 所以希望你能了解到各个数据结构的特征、局限以及组合的用法,相信你可以在实际场景中灵活使用不同的数据结构,以实现当前业务场景的最优解。 讨论地址是:精读《算法基础数据结构》· Issue ##312 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《结合 React 使用原生 Drag Drop API》","path":"/wiki/WebWeekly/前沿技术/《结合 React 使用原生 Drag Drop API》.html","content":"当前期刊数: 140 1 引言拖拽是前端非常常见的交互操作,但显然拖拽是强 DOM 交互的,而 React 绕过了 DOM 这一层,那么基于 React 的拖拽方案就必定值得聊一聊。 结合 How To Use The HTML Drag-And-Drop API In React 这篇文章,让我们谈谈 React 拖拽这些事。 2 概述原文说的比较简单,笔者先快速介绍其中重点部分。 首先拖拽主要的 API 有 4 个:dragEnter dragLeave dragOver drop,分别对应拖入、拖出、正在当前元素范围内拖拽、完成拖入动作。 基于这些 API,我们可以利用 React 实现一个拖入区域: 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,顺便复习一下用法: ...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 增加拖入文件、设置拖入状态即可: 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: 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 当作函数执行以达到效果: 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 可以这么写: const useDnd = ({ componentId }) => { const dragProps = {} const dropProps = {} return { dragProps, dropProps }} 接下来,我们就要分别实现 drag 与 drop 了。 对 drag 来说,只要实现 onDragStart 与 onDragEnd 即可: const dragProps = { onDragStart: ev => { ev.stopPropagation() ev.dataTransfer.setData('componentId', componentId) }, onDragEnd: ev => { // 做一些拖拽结束的清理工作 }} stopPropagation 的作用在原文简介中已经介绍过了,setData 则是通知拖拽方,当前拖拽的组件 id 是什么,这是由于拖拽由 drag 发起而由 drop 响应,因此必须有个数据传输过程,而 dataTransfer 就最适合做这件事。 对于 drop 来说,只要实现 onDragOver 与 onDrop 即可: const dropProps = { onDragOver: ev => { // 做一些样式处理,提示用户此时松手会将元素放置在何处 }, onDrop: ev => { ev.stopPropagation() const componentId = ev.dataTransfer.getData('componentId') // 通过 componentId 修改数据,通过 React Rerender 刷新 UI }} 重点在 onDrop,它是实现拖拽效果的 “真正执行处”,最终通过修改 UI 的方式更新数据。 存在一种场景,一个容器既可以被拖动,也可以被拖入,这种情况一般这个组件是个容器,但这个容器可以被拖入到其他容器中,可以自由嵌套。 实现这种场景的方式就是将 DragContainer 与 DropContainer 作用到一个组件上: const Box = ( <DragContainer> {({ dragProps }) => ( <DropContainer> {({ dropProps }) => { <div {...dragProps} {...dropProps} /> }} </DropContainer> )} </DragContainer>) 之所以能嵌套,在于 HTML5 的 API 允许一个元素同时拥有 onDragStart、onDrop 这两种属性,而上面的语法不过是同时将这两种属性传给组件 DOM。 所以,动手实现一个拖拽库就是这么简单,只要活用 HTML5 的拖拽 API,结合 React 一些特殊语法便够了。 4 总结最后留下一个思考题,许多具有拖拽功能的系统都具备 “拖拽 placeholder” 的功能,即拖拽元素的过程中,在其 “落点” 位置展示一条横线或竖线,引导出松手后元素位置落点,如图所示: 那么这条辅助线是通过什么方式实现的呢?欢迎在评论区留言!如果你有辅助线实现方案解析的文章,欢迎分享,也可以期待笔者未来专门写一篇 “拖拽 placeholder” 实现剖析的精读。 讨论地址是:精读《手写 JSON Parser》 · Issue ##233 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《编写有弹性的组件》","path":"/wiki/WebWeekly/前沿技术/《编写有弹性的组件》.html","content":"当前期刊数: 97 1. 引言读了 精读《useEffect 完全指南》 之后,是不是对 Function Component 的理解又加深了一些呢? 这次通过 Writing Resilient Components 一文,了解一下什么是有弹性的组件,以及为什么 Function Component 可以做到这一点。 2. 概述相比代码的 Lint 或者 Prettier,或许我们更应该关注代码是否具有弹性。 Dan 总结了弹性组件具有的四个特征: 不要阻塞数据流。 时刻准备好渲染。 不要有单例组件。 隔离本地状态。 以上规则不仅适用于 React,它适用于所有 UI 组件。 不要阻塞渲染的数据流不阻塞数据流的意思,就是 不要将接收到的参数本地化, 或者 使组件完全受控。 在 Class Component 语法下,由于有生命周期的概念,在某个生命周期将 props 存储到 state 的方式屡见不鲜。 然而一旦将 props 固化到 state,组件就不受控了: class Button extends React.Component { state = { color: this.props.color }; render() { const { color } = this.state; // 🔴 `color` is stale! return <button className={"Button-" + color}>{this.props.children}</button>; }} 当组件再次刷新时,props.color 变化了,但 state.color 不会变,这种情况就阻塞了数据流,小伙伴们可能会吐槽组件有 BUG。这时候如果你尝试通过其他生命周期(componentWillReceiveProps 或 componentDidUpdate)去修复,代码会变得难以管理。 然而 Function Component 没有生命周期的概念,**所以没有必须要将 props 存储到 state**,直接渲染即可: function Button({ color, children }) { return ( // ✅ `color` is always fresh! <button className={"Button-" + color}>{children}</button> );} 如果需要对 props 进行加工,可以利用 useMemo 对加工过程进行缓存,仅当依赖变化时才重新执行: const textColor = useMemo( () => slowlyCalculateTextColor(color), [color] // ✅ Don’t recalculate until `color` changes); 不要阻塞副作用的数据流发请求就是一种副作用,如果在一个组件内发请求,那么在取数参数变化时,最好能重新取数。 class SearchResults extends React.Component { state = { data: null }; componentDidMount() { this.fetchResults(); } componentDidUpdate(prevProps) { if (prevProps.query !== this.props.query) { // ✅ Refetch on change this.fetchResults(); } } fetchResults() { const url = this.getFetchUrl(); // Do the fetching... } getFetchUrl() { return "http://myapi/results?query" + this.props.query; // ✅ Updates are handled } render() { // ... }} 如果用 Class Component 的方式实现,我们需要将请求函数 getFetchUrl 抽出来,并且在 componentDidMount 与 componentDidUpdate 时同时调用它,还要注意 componentDidUpdate 时如果取数参数 state.query 没有变化则不执行 getFetchUrl。 这样的维护体验很糟糕,如果取数参数增加了 state.currentPage,你很可能在 componentDidUpdate 中漏掉对 state.currentPage 的判断。 如果使用 Function Component,可以通过 useCallback 将整个取数过程作为一个整体: 原文没有使用 useCallback,笔者进行了加工。 function SearchResults({ query }) { const [data, setData] = useState(null); const [currentPage, setCurrentPage] = useState(0); const getFetchUrl = useCallback(() => { return "http://myapi/results?query=" + query + "&page=" + currentPage; }, [currentPage, query]); useEffect(() => { const url = getFetchUrl(); // Do the fetching... }, [getFetchUrl]); // ✅ Refetch on change // ...} Function Component 对 props 与 state 的数据都一视同仁,且可以将取数逻辑与 “更新判断” 通过 useCallback 完全封装在一个函数内,再将这个函数作为整体依赖项添加到 useEffect,如果未来再新增一个参数,只要修改 getFetchUrl 这个函数即可,而且还可以通过 eslint-plugin-react-hooks 插件静态分析是否遗漏了依赖项。 Function Component 不但将依赖项聚合起来,还解决了 Class Component 分散在多处生命周期的函数判断,引发的无法静态分析依赖的问题。 不要因为性能优化而阻塞数据流相比 PureComponent 与 React.memo,手动进行比较优化是不太安全的,比如你可能会忘记对函数进行对比: class Button extends React.Component { shouldComponentUpdate(prevProps) { // 🔴 Doesn't compare this.props.onClick return this.props.color !== prevProps.color; } render() { const onClick = this.props.onClick; // 🔴 Doesn't reflect updates const textColor = slowlyCalculateTextColor(this.props.color); return ( <button onClick={onClick} className={"Button-" + this.props.color + " Button-text-" + textColor} > {this.props.children} </button> ); }} 上面的代码手动进行了 shouldComponentUpdate 对比优化,但是忽略了对函数参数 onClick 的对比,因此虽然大部分时间 onClick 确实没有变化,因此代码也不会有什么 bug: class MyForm extends React.Component { handleClick = () => { // ✅ Always the same function // Do something }; render() { return ( <> <h1>Hello!</h1> <Button color="green" onClick={this.handleClick}> Press me </Button> </> ); }} 但是一旦换一种方式实现 onClick,情况就不一样了,比如下面两种情况: class MyForm extends React.Component { state = { isEnabled: true }; handleClick = () => { this.setState({ isEnabled: false }); // Do something }; render() { return ( <> <h1>Hello!</h1> <Button color="green" onClick={ // 🔴 Button ignores updates to the onClick prop this.state.isEnabled ? this.handleClick : null } > Press me </Button> </> ); }} onClick 随机在 null 与 this.handleClick 之间切换。 drafts.map(draft => ( <Button color="blue" key={draft.id} onClick={ // 🔴 Button ignores updates to the onClick prop this.handlePublish.bind(this, draft.content) } > Publish </Button>)); 如果 draft.content 变化了,则 onClick 函数变化。 也就是如果子组件进行手动优化时,如果漏了对函数的对比,很有可能执行到旧的函数导致错误的逻辑。 所以尽量不要自己进行优化,同时在 Function Component 环境下,在内部申明的函数每次都有不同的引用,因此便于发现逻辑 BUG,同时利用 useCallback 与 useContext 有助于解决这个问题。 时刻准备渲染确保你的组件可以随时重渲染,且不会导致内部状态管理出现 BUG。 要做到这一点其实挺难的,比如一个复杂组件,如果接收了一个状态作为起点,之后的代码基于这个起点派生了许多内部状态,某个时刻改变了这个起始值,组件还能正常运行吗? 比如下面的代码: // 🤔 Should prevent unnecessary re-renders... right?class TextInput extends React.PureComponent { state = { value: "" }; // 🔴 Resets local state on every parent render componentWillReceiveProps(nextProps) { this.setState({ value: nextProps.value }); } handleChange = e => { this.setState({ value: e.target.value }); }; render() { return <input value={this.state.value} onChange={this.handleChange} />; }} componentWillReceiveProps 标识了每次组件接收到新的 props,都会将 props.value 同步到 state.value。这就是一种派生 state,虽然看上去可以做到优雅承接 props 的变化,但 父元素因为其他原因的 rerender 就会导致 state.value 非正常重置,比如父元素的 forceUpdate。 当然可以通过 不要阻塞渲染的数据流 一节所说的方式,比如 PureComponent, shouldComponentUpdate, React.memo 来做性能优化(当 props.value 没有变化时就不会重置 state.value),但这样的代码依然是脆弱的。 健壮的代码不会因为删除了某项优化就出现 BUG,不要使用派生 state 就能避免此问题。 笔者补充:解决这个问题的方式是,1. 如果组件依赖了 props.value,就不需要使用 state.value,完全做成 受控组件。2. 如果必须有 state.value,那就做成内部状态,也就是不要从外部接收 props.value。总之避免写 “介于受控与非受控之间的组件”。 补充一下,如果做成了非受控组件,却想重置初始值,那么在父级调用处加上 key 来解决: <EmailInput defaultEmail={this.props.user.email} key={this.props.user.id} /> 另外也可以通过 ref 解决,让子元素提供一个 reset 函数,不过不推荐使用 ref。 不要有单例组件一个有弹性的应用,应该能通过下面考验: ReactDOM.render( <> <MyApp /> <MyApp /> </>, document.getElementById("root")); 将整个应用渲染两遍,看看是否能各自正确运作? 除了组件本地状态由本地维护外,具有弹性的组件不应该因为其他实例调用了某些函数,而 “永远错过了某些状态或功能”。 笔者补充:一个危险的组件一般是这么思考的:没有人会随意破坏数据流,因此只要在 didMount 与 unMount 时做好数据初始化和销毁就行了。 那么当另一个实例进行销毁操作时,可能会破坏这个实例的中间状态。一个具有弹性的组件应该能 随时响应 状态的变化,没有生命周期概念的 Function Component 处理起来显然更得心应手。 隔离本地状态很多时候难以判断数据属于组件的本地状态还是全局状态。 文章提供了一个判断方法:“想象这个组件同时渲染了两个实例,这个数据会同时影响这两个实例吗?如果答案是 不会,那这个数据就适合作为本地状态”。 尤其在写业务组件时,容易将业务数据与组件本身状态数据混淆。 根据笔者的经验,从上层业务到底层通用组件之间,本地状态数量是递增的: 业务 -> 全局数据流 -> 页面(完全依赖全局数据流,几乎没有自己的状态) -> 业务组件(从页面或全局数据流继承数据,很少有自己状态) -> 通用组件(完全受控,比如 input;或大量内聚状态的复杂通用逻辑,比如 monaco-editor) 3. 精读再次强调,一个有弹性的组件需要同时满足下面 4 个原则: 不要阻塞数据流。 时刻准备好渲染。 不要有单例组件。 隔离本地状态。 想要遵循这些规则看上去也不难,但实践过程中会遇到不少问题,笔者举几个例子。 频繁传递回调函数Function Component 会导致组件粒度拆分的比较细,在提高可维护性同时,也会导致全局 state 成为过去,下面的代码可能让你觉得别扭: const App = memo(function App() { const [count, setCount] = useState(0); const [name, setName] = useState("nick"); return ( <> <Count count={count} setCount={setCount}/> <Name name={name} setName={setName}/> </> );});const Count = memo(function Count(props) { return ( <input value={props.count} onChange={pipeEvent(props.setCount)}> );});const Name = memo(function Name(props) { return ( <input value={props.name} onChange={pipeEvent(props.setName)}> );}); 虽然将子组件 Count 与 Name 拆分出来,逻辑更加解耦,但子组件需要更新父组件的状态就变得麻烦,我们不希望将函数作为参数透传给子组件。 一种办法是将函数通过 Context 传给子组件: const SetCount = createContext(null)const SetName = createContext(null)const App = memo(function App() { const [count, setCount] = useState(0); const [name, setName] = useState("nick"); return ( <SetCount.Provider value={setCount}> <SetName.Provider value={setName}> <Count count={count}/> <Name name={name}/> </SetName.Provider> </SetCount.Provider> );});const Count = memo(function Count(props) { const setCount = useContext(SetCount) return ( <input value={props.count} onChange={pipeEvent(setCount)}> );});const Name = memo(function Name(props) { const setName = useContext(SetName) return ( <input value={props.name} onChange={pipeEvent(setName)}> );}); 但这样会导致 Provider 过于臃肿,因此建议部分组件使用 useReducer 替代 useState,将函数合并到 dispatch: const AppDispatch = createContext(null)class State = { count = 0 name = 'nick'}function appReducer(state, action) { switch(action.type) { case 'setCount': return { ...state, count: action.value } case 'setName': return { ...state, name: action.value } default: return state }}const App = memo(function App() { const [state, dispatch] = useReducer(appReducer, new State()) return ( <AppDispatch.Provider value={dispatch}> <Count count={count}/> <Name name={name}/> </AppDispatch.Provider> );});const Count = memo(function Count(props) { const dispatch = useContext(AppDispatch) return ( <input value={props.count} onChange={pipeEvent(value => dispatch({type: 'setCount', value}))}> );});const Name = memo(function Name(props) { const dispatch = useContext(AppDispatch) return ( <input value={props.name} onChange={pipeEvent(pipeEvent(value => dispatch({type: 'setName', value})))}> );}); 将状态聚合到 reducer 中,这样一个 ContextProvider 就能解决所有数据处理问题了。 memo 包裹的组件类似 PureComponent 效果。 useCallback 参数变化频繁在 精读《useEffect 完全指南》 我们介绍了利用 useCallback 创建一个 Immutable 的函数: function Form() { const [text, updateText] = useState(""); const handleSubmit = useCallback(() => { const currentText = text; alert(currentText); }, [text]); return ( <> <input value={text} onChange={e => updateText(e.target.value)} /> <ExpensiveTree onSubmit={handleSubmit} /> </> );} 但这个函数的依赖 [text] 变化过于频繁,以至于在每个 render 都会重新生成 handleSubmit 函数,对性能有一定影响。一种解决办法是利用 Ref 规避这个问题: function Form() { const [text, updateText] = useState(""); const textRef = useRef(); useEffect(() => { textRef.current = text; // Write it to the ref }); const handleSubmit = useCallback(() => { const currentText = textRef.current; // Read it from the ref alert(currentText); }, [textRef]); // Don't recreate handleSubmit like [text] would do return ( <> <input value={text} onChange={e => updateText(e.target.value)} /> <ExpensiveTree onSubmit={handleSubmit} /> </> );} 当然,也可以将这个过程封装为一个自定义 Hooks,让代码稍微好看些: function Form() { const [text, updateText] = useState(""); // Will be memoized even if `text` changes: const handleSubmit = useEventCallback(() => { alert(text); }, [text]); return ( <> <input value={text} onChange={e => updateText(e.target.value)} /> <ExpensiveTree onSubmit={handleSubmit} /> </> );}function useEventCallback(fn, dependencies) { const ref = useRef(() => { throw new Error("Cannot call an event handler while rendering."); }); useEffect(() => { ref.current = fn; }, [fn, ...dependencies]); return useCallback(() => { const fn = ref.current; return fn(); }, [ref]);} 不过这种方案并不优雅,React 考虑提供一个更优雅的方案。 有可能被滥用的 useReducer在 精读《useEffect 完全指南》 “将更新与动作解耦” 一节里提到了,利用 useReducer 解决 “函数同时依赖多个外部变量的问题”。 一般情况下,我们会这么使用 useReducer: const reducer = (state, action) => { switch (action.type) { case "increment": return { value: state.value + 1 }; case "decrement": return { value: state.value - 1 }; case "incrementAmount": return { value: state.value + action.amount }; default: throw new Error(); }};const [state, dispatch] = useReducer(reducer, { value: 0 }); 但其实 useReducer 对 state 与 action 的定义可以很随意,因此我们可以利用 useReducer 打造一个 useState。 比如我们创建一个拥有复数 key 的 useState: const [state, setState] = useState({ count: 0, name: "nick" });// 修改 countsetState(state => ({ ...state, count: 1 }));// 修改 namesetState(state => ({ ...state, name: "jack" })); 利用 useReducer 实现相似的功能: function reducer(state, action) { return action(state);}const [state, dispatch] = useReducer(reducer, { count: 0, name: "nick" });// 修改 countdispatch(state => ({ ...state, count: 1 }));// 修改 namedispatch(state => ({ ...state, name: "jack" })); 因此针对如上情况,我们可能滥用了 useReducer,建议直接用 useState 代替。 4. 总结本文总结了具有弹性的组件的四个特性:不要阻塞数据流、时刻准备好渲染、不要有单例组件、隔离本地状态。 这个约定对代码质量很重要,而且难以通过 lint 规则或简单肉眼观察加以识别,因此推广起来还是有不小难度。 总的来说,Function Component 带来了更优雅的代码体验,但是对团队协作的要求也更高了。 讨论地址是:精读《编写有弹性的组件》 · Issue ##139 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《自由 + 磁贴混合布局》","path":"/wiki/WebWeekly/前沿技术/《自由 + 磁贴混合布局》.html","content":"当前期刊数: 281 本篇精读来自笔者代码实践,没有原文出处请谅解。 早些我们介绍过了 磁贴布局 - 功能分析 与实现,现在我们来做一个更进一步的思考,如何让磁贴布局与自由布局混合实现? 让磁贴布局与自由布局混合实现,从效果来看就是让画布同时存在磁贴与自由布局两种布局状态的组件,并且可以随时切换。接下来我们分析实现该方案的技术要点。 磁贴与自由布局的差异磁贴布局与自由布局在交互上有很多差异,比如: 磁贴布局不能重叠,自由布局可以重叠。 磁贴布局可以向上方吸引,自由布局不会被吸引。 磁贴布局不存在自动吸附概念,但自由布局可以支持对齐,吸附等功能。 这些交互时差异都容易在运行时分开处理弥补,真正需要从顶层设计的是 单位的差异。 自由布局因为位置固定,所以一般以像素描述位置;磁贴布局因为宽高是按照比例来的,往往以不带单位的 {w:1, h:2} 等相对数字描述位置,在渲染时再根据当前视窗大小缩放。 但在磁贴与自由混合的情况下,一个组件的布局选择磁贴还是自由可以由父容器来决定,或者自身来决定,这就引发了一个挑战: 一个组件的状态可能随时被切换到磁贴或自由,同时混用两种单位论上也可以实现,但计算成本比较高,所以最好采用一种单位来存储与计算,那么 同时适配磁贴与自由的单位就是像素。 用像素实现磁贴布局因为自由布局使用像素计算非常容易,所以我们只讲磁贴布局下如何用像素计算。 像素模式下所有磁贴组件的位置、大小都是像素: { "layoutMode": "grid", "x": 100, "y": 100, "width": 150, "height": 150} 如上所示,磁贴模式的组件与自由布局组件的差异仅在 layoutMode 值的区别,位置描述是完全一样的。 为了让磁贴布局组件可以适配屏幕大小缩放,需要存储画布根节点宽度 rootWidth,比如宽度为 150 的组件是在画布 rootWidth 为 1000 时保存下来的,那么在画布宽度为 2000 的屏幕尺寸打开时,组件宽度就要放大到 300. 自由布局对齐磁贴布局自由布局在大部分情况下是无法对齐磁贴布局的,因为即便我们将这两种布局的位置统一使用像素描述,但磁贴布局还是免不了会在不同尺寸的屏幕间缩放,也就是磁贴布局组件的位置是不固定的,而自由布局组件的位置是固定的,所以自由布局组件某条边对齐了磁贴布局的组件,也只在当前画布宽度下生效,一旦换一个尺寸屏幕就会产生偏移。 一种维持自由与磁贴组件相对位置的办法是 “整体随访”,即画布中所有组件位置都按照画布大小缩放,实现该方案有两种技术路线: scale 画布整体缩放。 仅位置、宽高的缩放。 第一种缩放方式会同时缩放组件内字体、图表等元素的大小,而第二种方案不会,我们可以根据实际场景灵活选择来实现,但两种方式都可以达到自由布局与磁贴布局稳定对齐的效果。 总结自由与磁贴混合布局模式下,还有更多值得我们思考的地方,比如: 是否允许磁贴布局与自由布局的组件产生碰撞。 怎么设计才能在同时多选了磁贴与自由布局组件时,批量拖动。 磁贴布局组件在拖入更小的容器时,宽度按照画布尺寸缩放,还是按照该容器尺寸缩放。 自由布局成组模式下,组内组件如何支持磁贴布局。 甚至,能否将浏览器最早支持的流式布局模式一起加入混合?混合布局模式还有很多值得深入思考的地方。 讨论地址是:精读《自由 + 磁贴混合布局》· Issue ##488 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《精通 console","path":"/wiki/WebWeekly/前沿技术/《精通 console.html","content":"当前期刊数: 138 1 引言本周精读的文章是 Mastering JS console.log like a Pro,一起来更全面的认识 console 吧! 2 概述 & 精读console 的功能主要在于控制台打印,它可以打印任何字符、对象、甚至 DOM 元素和系统信息,下面一一介绍。 console.log( ) | info( ) | debug( ) | warn( ) | error( )直接打印字符,区别在于展示形态的不同: 新版 chrome 控制台可以将打印信息分类: log() 与 info() 都对应 info,warn() 对应 warnings,error() 对应 errors,而 debug() 对应 verbose,因此建议在合适的场景使用合适的打印习惯,这样排查问题时也可以有针对性的筛选。 比如调试信息可以用 console.debug 仅在调试环境下输出,调试者即便开启了调试参数也不会影响正常 info 的查看,因为调试信息都输出在 verbose 中。 使用占位符 %o — 对象 %s — 字符串 %d — 数字 如下所示,可通过占位符在一行中插入不同类型的值: 添加 CSS 样式 %c - 样式 可以总结出,console 支持输出复杂的内容,其输出能力堪比 HTML,但输入能力太弱,仅为字符串,因此采用了占位符 + 多入参修饰的设计模式解决这个问题。 console.dir( )按 JSON 模式输出。笔者在这里也补充一句:console.log() 会自动判断类型,如果内容是 DOM 属性,则输出 DOM 树,但 console.dir 会强制以 JSON 模式输出,用在 DOM 对象时可强制转换为 JSON 输出。 输出 HTML 元素按照 HTML ELements 结构输出: 这种输出结构和 Elements 打印形式是一致的,如果要看详细属性,可以使用 console.dir()。 console.table在控制台打印一个表格,属于功能增强。虽然仅文本也可以在控制台打印出漂亮的表格,但浏览器调试控制台的功能更强大,console.table 只是其富文本能力的一个体现。 console.group( ) & console.groupEnd( )接下来是另一个富文本能力,按分组输出: 这种带有副作用的 API 显然是为方便阅读而设计的,然而在需要输出大量动态结构化数据的场景下,还需要进行结构转换,是比较麻烦的地方。 console.count( )count() 用来打印调用次数,一般用在循环或递归函数中。接收一个 label 参数以定制输出,默认直接输出 1 2 3 数字。 console.assert( )console 版断言工具,当且仅当第一个参数值为 false 时才打印第二个参数作为输出。 这种输出结果为 error,所以也可被 console.error + 代码级别断言所取代。 console.trace( )打印此时的调用栈,在打印辅助调试信息时非常有用。 console.time( )打印代码执行时间,性能优化和监控场景比较常见。 console.memory打印内存使用情况。 console.clear( )清空控制台输出。 3 总结console 提供了如此多的输出规范,其实也是在变相制定开发规范,毕竟离开发者最近的就是调试控制台,如果你的项目打印规范与标准规范有差异,那么调试时信息看起来就会很别扭。 可以看到,大部分开源库都良好的遵循了这套规范,比如三方库绝不会输出 log(),而且将错误、警告与调试信息正确分开,并尽量少的用 CSS 样式、分组、table 等功能,因为这些功能干扰性较强,不能保证所有用户都可接受。 相对的,项目源码就比较适合使用一些醒目的自定义规范,只要这套规则能被很好的执行起来。 最后留下一个讨论点:console 可以作为调试、招聘信息、隐藏菜单的投放点,你还看到过哪些有意思的 console 使用方式呢?欢迎留言。 讨论地址是:精读《精通 console.log》 · Issue ##228 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《自由布局吸附线的实现》","path":"/wiki/WebWeekly/前沿技术/《自由布局吸附线的实现》.html","content":"当前期刊数: 282 本篇精读来自笔者代码实践,没有原文出处请谅解。 自由布局吸附线的效果如下图所示: 那么如何实现吸附线呢?我们先归纳一下吸附线的特征: 正在拖动的 box 与其他 box 在水平或垂直位置距离接近时,会显示对齐线。 当吸附作用产生时,鼠标在一定范围内移动都不会改变组件位置,这样鼠标对齐就产生了一定的容错性,用户不需要一像素一像素的调整位置。 当鼠标拖动的足够远时,吸附作用消失,此时 box 跟手移动。 根据这些规则,我们首先要实现的就是判断当前拖动 box 与哪些组件的边足够接近。 判断 box 离哪条边最近距离最近的边可能不止一条,水平与垂直位置要分别判断。我们以水平位置为例,垂直同理。 拖动 box 在水平位置可能有 上、中、下 三条边可以产生吸附,而其他 box 同样也有 上、中、下 三条边可以与之产生交互,因此对于每一个目标 box,我们需要计算 9 个距离: source 上 vs target 上 source 上 vs target 中 source 上 vs target 下 source 中 vs target 上 source 中 vs target 中 source 中 vs target 下 source 下 vs target 上 source 下 vs target 中 source 下 vs target 下 因为 source 的每条边最多只能出现一条吸附线,所以按照 source 聚合一下每条边的最近 target 边: source 上 vs min(target 上、中、下) = min 上 source 中 vs min(target 上、中、下) = min 中 source 下 vs min(target 上、中、下) = min 下 可以想象,当 source 与 target box 完全一样大时,最多产生三条吸附线(上 vs 上,中 vs 中,下 vs 下)。但一旦 box 高度不同,结果就不一样了,所以我们还需要计算 source 上、中、下 最接近的距离是多少: source 所有位置最小距离 = min(min 上、min 中、min 下) 然后按照 source 所有位置最小距离筛选 min 上、min 中、min 下,留下来的就是要 source 距离 target 水平位置最近的吸附线。 我们还需要设置吸附阈值,否则所有鼠标位置都会产生吸附。所以当 source 所有位置最小距离大于吸附阈值时,就不产生吸附效果了。 产生吸附效果吸附的实现方式与拖拽的实现方式有关。 假设拖拽的实现方式是:dragStart 时记录鼠标的起始位置 mouseStartX(Y 同理),在 drag 时产生了位移 movementX,那么组件当前位置就是 mouseStartX + movementX。 如果我们可以拿到吸附产生的反向位移 snapX,那么组件位置就可以实现为: mouseStartX + movementX + snapX 可以想象当鼠标从上往下移动时,当产生吸附时,snapX 会产生反向作用抵消 box 的向下位移,从而保证 box 在吸附时在垂直方向没有产生移动,这样吸附效果就实现了。 snapX 的值如何计算呢?其实就是上一步的 “source 所有位置最小距离” 取反。 resize 时中间对齐线需要放大双倍吸附力resize 与 drag 不同,设想鼠标拖动 box 的下方边缘向下做 resize,此时除了组件移动外,还产生了组件高度变高的效果,那么从上、中、下三段观察 box,其位置与鼠标位移的变化关系是: 上:位置不变。 中:位置向下位移为鼠标位移 * 0.5 下:位置向下位移为鼠标位移 * 1 因此如果中间位置产生了吸附线,为了抵消鼠标向下移动,需要产生两倍的 snap 反向位移: mouseStartX + movementX + snapX * 2 总结我们梳理了吸附的判断条件与吸附作用如何生效,以及 resize 时中间线吸附的特殊处理逻辑。 自由布局除了吸附之外,还有哪些边界的交互,如何实现呢?希望大家思考与留言。 讨论地址是:精读《自由布局吸附线的实现》· Issue ##490 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《衡量用户体验》","path":"/wiki/WebWeekly/前沿技术/《衡量用户体验》.html","content":"当前期刊数: 68 衡量用户体验已经不是一个新话题。最近也关注了一些话题来写写这方面的感受。 前言从某种意义上说,定性反馈是 UX 设计师最常用的武器。今天从诸多交互出版物或文章中看到,Metrics-Driven Design 概念的发展和越来越受重视的趋势。但两者取一都是片面的,倾向任何一者都会出现问题,追求定性总是在小样本下。定量分析总是忽视用户的反馈,以为数据就可以说明问题。充其量只是达到局部高点。一位 UX 设计师称之为 tunnel vision。 像 Booking 网站给出了『洞察数据,情感驱动』作为他们的设计准则。这里总是听上去像是在讲交互视觉范畴,而用户体验是不是指交互视觉呢。我觉得体验度量是不是就是度量交互视觉呢。 用户体验 产品用户体验不仅是指交互视觉,我的理解用户体验反映用户与产品从认知,使用到传播整个情感的连接和反馈。能力上包括了产品设计与功能实现,用户交互界面,以及系统承载能力。 上图是 CUBI Mobel:CUBI UX - User Experience Model。完整地说明了用户体验从内容、商业目标、交互、用户目标四个方面组合。 以初创产品为例,以流量增长为主要目的,这时用户交互与系统能力并不一定是重点。对于上面这张图而言,背后隐含着动态的权重,这个权重来自于产品的发展阶段目标。 以成熟产品为例,流量增长的红利已经减弱或消失,这时精细化运营就非常重要,这时用户交互能力和系统能力就与产品目标一样重要。这时的权重就有所改变。 体验度量现代数据分析建立的背景是海量,全量数据。然而产品根据受众的不同,有 To C,To B,To D,在 To C 中不同产品的消费者群体又有细分,但总体来说面向消费者的产品一定需要流量。现代 To C 分析思路是在上述也讲到,是从流量分析 -> 细分人群的消费者洞察 -> 经营链路的全域分析过程,已经有非常多的商业分析思路在这几年沉淀,如 AARRR。在体验度量研究上,有 Google 的 HEART 模型。 但 To B 或 To D,使用的人群和频率远远比 To C 低。从一开始就是从专业领域沉淀和积累的过程而成就,往往依靠产品经理的经验与行业习惯就满足了,我们经常看到对这类产品我们不会以『美观』来强调,而以『清晰』来强调。反馈这样产品的体验,定性分析起到决定性的作用。可以说,好的设计工作由定量与定性的信息一起来解答问题。 比如,我们通过对设计一组反馈问题来统计用户对产品的满意度,其中 Bayes’ Probability 在小样本中可以起到很好的作用,又回归到定量分析中。 再比如,对 To B 或 To D 研究用抽样用户行为作单体分析也是一种行之有效的方法。推荐一定志愿者,在特定的数据采集中,持续跟踪志愿者的行为,细化收集几个关键指标:如平均完成一次关键路径的时长、关键路径完成效率等数据来研究产品可用性等作为体验 KPI 来衡量。 此外,AB 测试在这种场景下,也会是一种较为常用的方法,通过研究关键路径的转化来看更优方案,并能够智能化的切换不同分层样本,得到更精准的分析结果。 综上,不同定位的产品,面向的市场和人员所定义的度量方法更是不同的。 我试着列了几类 产品商业阶段性目标和最终目标。营收提高,优化结构,工程效率提高,质量提高,能力覆盖。 工程研发过程及能力。投入产出比,系统成本,系统稳定性,创新性。 用户可用性。满意度,过程效率,过程质量。 总结产品数据分析对我们来说很多关注点主要在数据上,有明确行动指引或明确的智能化方案。而用户体验分析是一个更广泛的概念,从刚开始说的这张图开始,它包括了商业,也包含产品其它方面。 我们做互联网产品,往往以商业目标为导向。但一个产品发展长远,是立体来看的,必然包含了技术的创新,交互视觉的创新。技术能力对于用户目标,交互与内容的帮助是非常大的,我们对于技术的期待并不只在性能上,稳定性上。也应该在专业,沉浸,丰富这样的关键词上来衡量可预见的未来。 这是一个很大的话题,我只是管中窥豹,希望有更多人跳出思维圈,利用数据,不局限于数据。"},{"title":"《请停止 css-in-js 的行为》","path":"/wiki/WebWeekly/前沿技术/《请停止 css-in-js 的行为》.html","content":"当前期刊数: 7 本周精读文章:请停止 css-in-js 的行为 1 引言 这篇文章表面是在讲 CSS in JS,实际上是 CSS Modules 支持者与 styled-components 拥趸之间的唇枪舌剑、你来我往。从 2014 年 Vjeux 的演讲开始,css-in-js 的轮子层出不穷。终于过了三年,鸡血时期已经慢慢过去,大家开始冷静思考了。 2 内容概要styled-componentsstyled-components 利用 ES6 的 tagged template 语法创建 react 纯样式组件。消除了人肉在 dom 和 css 之间做映射和切换的痛苦,并且有大部分编辑器插件的大力支持(语法高亮等)。此外,styled-components 在 ReactNaive 中尤其适用。 styled-components 简单易学,引用官方源码: import React from 'react';import styled from 'styled-components';const Title = styled.h1` font-size: 1.5em; text-align: center; color: palevioletred;`;<Title> Hello World, this is my first styled component!</Title> css-modules顾名思义,css-modules 将 css 代码模块化,可以很方便的避免本模块样式被污染。并且可以很方便的复用 css 代码。 // 全局变量:global(.className) { background-color: blue;}// 本地变量,其它模块无法污染.className { background-color: blue;}.title { // 复用 className 类的样式 composes: className; color: red;} react-css-modules值得一提的是,文章的作者也是 react-css-modules 的作者。 react-css-modules 代码示例: import React from 'react';import CSSModules from 'react-css-modules';import styles from './table.css';class Table extends React.Component { render () { return <div styleName='table'> <div styleName='row'> <div styleName='cell'>A0</div> <div styleName='cell'>B0</div> </div> </div>; }}export default CSSModules(Table, styles); react-css-modules 引入了 styleName,将本地变量和全局变量很清晰的分开。并且也避免了每次对 styles 对象的引用,本地 className 名也不用总是写成 camelCase。 另外,使用 react-css-modules,可以方便的覆盖本地变量的样式: import customStyles from './table-custom-styles.css';<Table styles={customStyles} />; 文章内容3 精读参与本次精读的同学有 黄子毅,杨森 和 camsong。该部分由他们的观点总结而出。 CSS 本身有不少缺陷,如书写繁琐(不支持嵌套)、样式易冲突(没有作用域概念)、缺少变量(不便于一键换主题)等不一而足。为了解决这些问题,社区里的解决方案也是出了一茬又一茬,从最早的 CSS prepocessor(SASS、LESS、Stylus)到后来的后起之秀 PostCSS,再到 CSS Modules、Styled-Components 等。更有甚者,有人维护了一份完整的 CSS in JS 技术方案的对比。截至目前,已有 49 种之多。 Styled-components 优缺点优点使用成本低如果是要做一个组件库,让使用方拿着 npm 就能直接用,样式全部自己搞定,不需要依赖其它组件,如 react-dnd 这种,比较适合。 更适合跨平台适用于 react-native 这类本身就没有 css 的运行环境。 缺陷缺乏扩展性样式就像小孩的脸,说变就变。比如是最简单的 button,可能在用的时候由于场景不同,就需要设置不同的 font-size,height,width,border 等等,如果全部使用 css-in-js 那将需要把每个样式都变成 props,如果这个组件的 dom 还有多层级呢?你是无法把所有样式都添加到 props 中。同时也不能全部设置成变量,那就丧失了单独定制某个组件的能力。css-in-js 生成的 className 通常是不稳定的随机串,这就给外部想灵活覆盖样式增加了困难。 css-modules 优缺点优点1、CSS Modules 可以有效避免全局污染和样式冲突,能最大化地结合现有 CSS 生态和 JS 模块化能力 2、与 SCSS 对比,可以避免 className 的层级嵌套,只使用一个 className 就能把所有样式定义好。 缺点:1、与组件库难以配合 2、会带来一些使用成本,本地样式覆盖困难,写到最后可能一直在用 :global。 关于 scss/less无论是 sass 还是 less 都有一套自己的语法,postcss 更支持了自定义语法,自创的语法最大特点就是雷同,格式又不一致,增加了无意义的学习成本。我们更希望去学习和使用万变不离其宗的东西,而不愿意使用各种定制的“语法糖”来“提高效率”。 就 css 变量与 js 通信而言,虽然草案已经考虑到了这一点,通过表达式与 attribute 通信,使用 js 与 attribute 同步。不难想象,这种情况维护的变量值最终是存储在 js 中更加妥当,然而 scss 给大家带来的 css first 思想根深蒂固,导致许多基础库的变量完全存储在 _variable.scss 文件中,现在无论是想适应 css 的新特性,还使用 css-in-js 都有巨大的成本,导致项目几乎无法迁移。反过来,如果变量存储在 js 中,就像草案中说的一样轻巧,你只要换一种方式实现 css 就行了。 总结在众多解决方案中,没有绝对的优劣。还是要结合自己的场景来决定。 我们团队在使用过 scss 和 css modules 后,仍然又重新选择了使用 scss。css modules 虽然有效解决了样式冲突的问题,但是带来的使用成本也很大。尤其是在写动画(keyframe)的时候,语法尤其奇怪,总是出错,难以调试。并且我们团队在开发时,因为大家书写规范,也从来没有碰到过样式冲突的问题。 Styled-components 笔者未曾使用过,但它消除人肉在 dom 和 css 之间做映射的优点,非常吸引我。而对于样式扩展的问题,其实也有比较优雅的方式。 const CustomedButton = styled(Button)` color: customedColor;`; 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《维护好一个复杂项目》","path":"/wiki/WebWeekly/前沿技术/《维护好一个复杂项目》.html","content":"当前期刊数: 264 现在许多国内互联网公司的项目都持续了五年左右,美国老牌公司如 IBM 的项目甚至持续维护了十五年,然而这些项目却有着截然不同的维护成本,有的公司项目运作几年后维护成本依然与初创期不大,可以保持较为高效的迭代速度,但有的项目甚至改几个文案都会导致线上事故,研发效率变得越来越慢。 根据笔者的经验,尝试总结一些持续维护项目变得难以维护的原因,以及如何设计才能保持良好的可维护性。 精读心态如果不真心对待自己的项目,其实是很难做到良好可维护性的,所以第一点就是需要一个良好的心态。 作为项目管理者,一个项目一旦交给一位同学开发,那么就要完全信任这位同学的能力,因为实际上你已经不可能实质性的影响到开发细节了。有人可能觉得好的流程或者事后 CodeReview 能发现一些问题,但这永远是杯水车薪,比如下面这个例子: 小张接到任务研发透视表,要求这个透视表具有良好的开发体验并做好单测。 那怎么样做单测才算是有效的,如何同时保证开发体验呢?不同人会有不同的想法,也会有不同的结果。 有主人翁心态的小张对于一个有一定经验,又对项目真正上心的小张来说,开发过程可能是这样的。 首先上来先写主要功能,比如考虑数据模型、绘图技术方案后,决定采用图形语法方式定义数据结构,在做了一系列高性能前置考虑后,快速做出来了一个原型,包含表格的渲染、操作、翻页、冻结等等功能。 但随着需求的深入,小张发现做到下钻、排序时,不知道为何影响到了列冻结的功能,而代码架构其实没什么大问题,抽象的也很好,主要就是一些细节的代码调用漏掉了,只要补上就立马打通了任督二脉,整套功能再度行云流水了起来。但不知道下次做树状展示结构时会不会又把之前的功能影响了,这始终是个隐患,于是小张开始思考先把单测加上再继续开发功能。 由于出问题的场景有很小部分是大量操作后偶然引发的,普通的函数式单测也无法保证覆盖的全面,因此小张决定做一个单测录制功能,他首先把对表格的所有操作 Action 化,让一套 json 可以描述所有用户操作,然后又在本地开发界面做了一个单测录制功能,即在页面上对表格功能拖拖拽拽时,就会实时生成这套用户操作 json,再把当时页面结构与内部状态记录下来作为对比依据,单测就还原这套 json 并与基准状态做对比就行了。 小张很快录制了很多原子操作的单测,比如表格的各种空数据状态、单行单列渲染、列冻结行冻结;然后又把一些功能混合的场景结合起来,比如列冻结时排序,翻页后进行下钻;最后又把一些随机复杂的功能组合在一起,形成一些日常容易出问题的特殊单测 case,比如表格单页后突然清空数据,再强制冻结第二列,再灌入3列数据并对第2行做排序,再取消列冻结并翻到第4页。以后每当遇到一个边界 case 时,小张都会把这个问题 case 记录到单测,验证确实运行失败,再进行修复,直到包含这个单测在内的所有单测都验证通过后,才算开发完成。 打工人小张对于为了混口饭吃的小张来说,开发过程可能是这样的。 首先上来写主要功能,把各种表格功能做完后,也遇到了一样的边界 case 难题,此时小张本来想 case by case 修复,但又想到 leader 要求他写单测,觉得倒也不坏,就创建了单测目录。 怎么写单测呢?首先小张把遇到的问题修了,毕竟谁也不希望自己手里的 bug 太多,但至于录到单测就太麻烦了,反正大家也不知道这个 case,修掉了就再也不会出来了吧,那就只把 leader 要求的几个基本功能单测加上去,看下覆盖率也达到硬性指标就行了。 大团队代码总是容易走向混乱假设你是 leader,你不知道自己团队的小张到底是主人翁小张还是打工人小张,企图通过 code review 来统一提升团队的代码质量,实际上可行吗? 如果不幸遇上了打工人小张,他在 code review 时展示的代码结构就不是能做整体单测的抽象,你只能看着单测文件硬提一些比如 “多加一些单测,多考虑一些情况” 的建议,实际上完全达不到主人翁小张做的效果。 这背后的原因是影响代码质量的因素太多,比如 Action 化,比如各种极端 case 的录入,比如全流程的单测形式,这些对代码来说都是质变,但 code review 时看到的代码就是不够抽象,不够 Action 化的,不可能把代码推翻重写一遍,只能在已有代码基础上提优化建议,而到这个时候,神仙也没法让打工人小张的代码优化为主人翁小张的,除非推翻重写。 这就是心态的影响力,能把项目做好的细节很多,而且细节之间还是环环相扣的,比如不把代码 Action 化就不方便做整体单测,但如果开发者打一开始就没想好好设计,code review 时又有多少人能想到这一点呢?想到了此时再提可能也为时太晚,一切都已成定局。 这些年笔者看过不少久经历史的代码,因为大公司有大量的开发者维护同一个项目,每个人开发时的心态都各有不同,会发现总能看到那些模块是用打工人心态做出来的,而你想彻底优化就只能彻底重写,但碍于项目体量太大时间上不允许,只能沿着打工人思路洋洋洒洒的继续写下去。 所以拥有一个良好,正面或者说积极的主人翁心态来写代码,一般来说都可以维护好复杂项目。 解耦复杂项目的复杂指的是什么呢?是指功能多吗?其实不然。 如果仅从功能多就判定这个项目复杂,那我们身处的社会才是最复杂的系统,但社会中的每个玩家都没有觉得吃穿住行很难,核心原因就在于了解我们用到的场景只需要少量的知识,而做出一个行动要得到正确的结果,也不会造成太大的影响。比如出门买菜,只要做个公交车到菜市场,扫一下码就完成了交易,而不需要对背后的城市公交体系与菜市场背后的金融体系有任何深入的了解,你不需要理解公交车是哪儿来的,菜农手里的菜是从哪儿收购的。 但代码世界就很有趣了,在代码世界买个菜可能会导致世界毁灭。这就导致每一个项目开发人员,哪怕是去买个菜,也要受过总统级训练,对各种国家级大事做出正确的预案,为什么会这样呢? 因为代码世界的逻辑是不同开发者码出来的,在实现世界底层逻辑时可能就埋下了耦合的种子,导致你不知道为什么买菜会触发那么严重的事情。举个例子,改一个文案导致系统崩溃,原因可能是某处错误兜底逻辑用字面量判断了这个文案,而你把文案改了,这个判断就失效了。有的程序员挺难的,在这种项目环境下生存,每一步修改都要小心翼翼。 这个问题的解决办法就是解耦,在这里我们不细说具体怎么解耦,因为每个场景的解耦方式都不同。我们只需要理解几乎所有的业务逻辑都可以用解耦的方式做,就行了。只要你按照这样的大思路去设计系统,不论路径是怎样的,最终都能设计出一个漂亮的系统级方案。 比如做一个 BI 系统,看上去里面有各种复杂的模块可能会产生相互影响,比如数据处理、仪表盘搭建、大屏搭建、图表、GIS 地图等,在设计之初就要假装其他模块不存在,来考虑每个模块必要的输入是哪些。 比如布局,它仅仅用于对画布进行布局,为了保证布局系统是完全解耦的,必须让项目支持在无布局的环境下运行。为了做到这一点,就必须让布局真的 “只做布局”,而不存储当前画布结构,这样才不会因为布局系统被移除时,影响组件的联动,因为组件联动需要利用画布结构 API。 图层列表也可以和布局解耦,因为图层列表只关心画布的组件树结构,而不关心布局是如何实现的,所以画布的组件树结构就像生活中的金钱,大家都可以用它交易,而无需关心它流向了何方,被谁使用。 数据逻辑与画布结构无关,只需要关心表达式以及用户对维度度量的配置、聚合方式以及图表本身的特性进行查询 sql 拼接即可,唯一用到的通用资源是当前组件实例信息修改后,需要更新到画布的组件树上。 社会也是建立在这种底层认同上,才能这么解耦的,所以在复杂项目中一定要有一个大家都认可的底层概念,这个概念应该尽可能通用化(想想金钱什么都能买,如果只能买蔬菜就麻烦了)、贯穿整个业务逻辑(金钱是现代社会任何交易都必须的媒介)。 许多项目被诟病难改,往往是没有遵循这条逻辑,硬生生把可以不相关的概念耦合了。比如某个筛选器条件变化时,对某个组件做特殊操作,这个场景可以控制反转为,这个组件在接收到某些筛选条件时,自己做特定的操作。因为对 BI 系统来说,筛选器的输出要作为图表绘图的输入,在这个底层框架下,就不要再开辟一条筛选器关心到具体图表的逻辑了。 总结维护好一个复杂项目很难,这次分享了两个实践中有用的方案,第一个抱有主人翁心态设计代码,要在设计之初就做好考量,不要寄希望于对没有好好设计的系统做缝缝补补。第二是深入理解为什么现代社会的运作巧妙之处,尽可能把代码架构组织一定程度映射到社会的运作机制上,目前来看,社会最适合代码借鉴的思路就是解耦,再利用庞大的分工协作网络完成单人无法完成的工作。 讨论地址是:精读《维护好一个复杂项目》· Issue ##454 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计完美的日期选择器》","path":"/wiki/WebWeekly/前沿技术/《设计完美的日期选择器》.html","content":"当前期刊数: 18 1. 摘要日期选择器作为基础组件重要不可或缺的一员,大家已经快习惯它一成不变的样子,输入框+日期选择弹出层。但到业务中,这种墨守成规的样子真的能百分百契合业务需求吗。这篇文章从多个网站的日期选择场景出发,企图归纳出日期选择器的最佳实践。这篇文章对移动端的日期选择暂无涉猎,都是 PC 端,列举出通用场景,每个类型日期选择器需要考虑的设计。文章链接:Designing The Perfect Date And Time Picker感谢本期评论官 @黄子毅 @流形 @王亮 @赵阳 @不知名的花瓣工程师 2. 设计原则2.1 通用设计1)明确需求,是实现日期选择、日期区间选择、时间选择 2)用户选中日期后是否需要自动触发下一步?尤其是在某些固定业务流程中 3)日期选择器是否是最佳的日期选择方法?如果提供预定义的日期选择按钮是不是更快呢? 4)如何避免展示不可用日期? 5)是否需要根据上下文自动定位? 适用于生日选择场景。 2.2 输入框设计1)用户是否可以自定义输入日期,还是只能通过点击选择程序给出的日期?有时候直接输入的效率明显高于点击选择,在很多银行流水查询的场景中就提供自定义输入。 2)用户自定义输入如何保证日期格式正确性? 3)是否需要提供预设场景输入? 比如昨天,三天前,七天前,30 天前?像很多数据分析场景,分析师会关注数据周期,比如流量的周环比,月环比,年环比。 4)是否需要包含默认值?如果有默认,应该是什么?像 google flight 根据用户历史数据提供默认值,临近节假日默认填充节假日。同时像有些数据场景,数据存在延迟,需要默认提供 T-1/T-2 ,避免用户选择当天。 5)当用户激活输入框时,是否保留默认值? 6)是否提供重置按钮? 7)是否提供『前一项』『现在』『后一项』导航?这个设计点我第一次看到,专门附图说明。 2.3 日期弹出层设计1)理想状态下,任何日期选择都应该在三步之内完成 2)日期选择弹出层的触发方式? 是点输入框就还是点日期小图标? 3)默认情况下,展示多少周、月、天? 4)周的定义是周一到周日 还是 周日到周六? 5)如何提示当前时间和当前时间? 6)是否需要提供『前一项』『现在』『后一项』导航?如果提供,选择天、月、年的场景下如何展示? 7)提示用户最关心的信息,比如 价格、公共假期,可采用背景色、点标记 8)是否用户点击非弹出层自动关闭弹出层?是否需要提供关闭按钮? 9)是否可以不和输入框联动? 10)用户可以重置选中的日期吗? 2.4 日期区间设计1)理想状态下,任何日期区间选择需要在六步之内完成 2)用户选中后是否立刻做背景色提示? 3)当用户选择时,区间是否需要随着用户动作改变?比如用户 hover 时,动态改变选中区间。 4)是否提供快捷键切换 日、月、年选择? 5)是分成两个日期选择器还是采用区间形式? 6)如何去除某些特殊时间点? 比如春节、节假日。 2.5 时间选择设计1)最简单的方法是竖直的日期,水平的时间选择 2)更有用的是先提供日期还是时间选择? 时间选择可以作为一个过滤项,移除某些不可用的日期,这个也很有用。 3)提供最常使用的时间片段,并提供快捷键选择。 3. 文章中亮点设计3.1 google flight 这个案例在最小的范围内提供用户找出最优选择。虽然第一眼看到这个方法,我懵了一秒,但仔细一看发现这种展现方法完美的给出了各种组合。 3.2 春夏秋冬 这个案例另辟蹊径增加了季节的概念,在某些旅游、机票类业务场景季节是非常必要的概念,提供超出月更粗粒度的日期范围选择。 3.3 枚举选择时间 使用一系列的按钮代替时间选择器,比如像我们的作息时间表,大部分是把时间划分成有规律的时间段供用户选择,固化用户选择。 3.4 对话式交互 采用与用户交互的方式选择日期,如果今后应用上 AI,单纯的日期选择器是不是会消失不见呢?.. 3.5 特殊标识周末在机票、旅行场景中,周末是大家最有可能出行的时间点,采用竖线划分的方式着重标注提醒。 4. 总结 总得来说,日期选择器是一个业务组件,虽然现有很多组件库把它纳入 UI 基础组件。但在每个不通的业务场景和需求下的展现形式、交互都会有所有不同。首先一定一定要明确确定需要日期选择器的场景,尤其是与日期强关联的业务,比如机票定价、日程安排,结合到日期选择器中更直观,提高用户对信息的检索效率。满足用户需求场景的同时,尽量减少用户操作链路。 看到最后点个赞呗,给你比小心心 ❤ ~~"},{"title":"《谈谈 Web Workers》","path":"/wiki/WebWeekly/前沿技术/《谈谈 Web Workers》.html","content":"当前期刊数: 76 1 引言本周精读的文章是 speedy-introduction-to-web-workers,是一篇 Web Workers 快速入门的文章,借精读这篇文章的机会,谈谈对 Web Workers 的理解与运用。 2 概述 就像分工,你只负责编码,而你的朋友负责设计,那你就可以专心把自己的事情做好,而且更快速的完成任务。 本文通过一个比方,描述了 Web Workers 的两大特征: 高效。 并行。 因为浏览器是单线程的,任何大量耗时的 JS 任务都会卡住界面,使浏览器无法响应任何操作,这样的用户体验非常糟糕。Web Workers 可以将耗时任务拆解出去,降低主线程的压力,避免主线程无响应。 但 CPU 资源是有限的,Web Workers 并不能增加总体运行效率,算上通信的损耗,整体计算效率会有一定的下降。 创建 Web Workersconst worker = new Worker("../src/worker.js"); 上述代码中,worker 就是一个 Web Workers 实例,执行的代码是 ../src/worker.js 路径下的文件。 收发消息Web Workers 用来执行异步脚本,只要掌握了它与主线程通信的方式,就可以在指定时机运行异步脚本,并在运行完时将结果传递给主线程。 主线程接收发 Web Workers 消息const worker = new Worker("../src/worker.js");worker.onmessage = e => {};worker.postMessage("Marco!"); 每个 worker 实例通过 onmessage 接收消息,通过 postMessage 发送消息。 Web Workers 收发主线程消息self.onmessage = e => {};self.postMessage("Marco!"); 和主线程代码类似,在 Web Workers 代码中,也是 onmessage 接收消息,这个消息来自主线程或者其它 Workers。也可以通过 postMessage 发送消息。 销毁 Web Workersworker.terminate(); 文章内容就这么多,是不是有写太简单了呢!笔者结合自己的使用经验,再补充一些知识。 3 精读对象转移(Transferable Objects)对象转移就是将对象引用零成本转交给 Web Workers 的上下文,而不需要进行结构拷贝。 这里要解释的是,主线程与 Web Workers 之间的通信,并不是对象引用的传递,而是序列化/反序列化的过程,当对象非常庞大时,序列化和反序列化都会消耗大量计算资源,降低运行速度。 上面的图充分证明了,大对象传递,使用对象转移各项指标都优于结构拷贝。 对象转移使用方式很简单,给 postMessage 增加一个参数,把对象引用传过去即可: var ab = new ArrayBuffer(1);worker.postMessage(ab, [ab]); 浏览器兼容性也不错:Currently Chrome 17+, Firefox, Opera, Safari, IE10+。更具体内容,可以看 Transferable Objects: Lightning Fast!。 需要注意的是,对象引用转移后,原先上下文就无法访问此对象了,需要在 Web Workers 再次将对象还原到主线程上下文后,主线程才能正常访问被转交的对象。 如何不用 JS 文件创建 Web WorkersWeb Workers 优势这么大,但用起来需要在同域下创建一个 JS 文件实在不方便,尤其在前后端分离做的比较彻底的团队,前端团队能控制的仅仅是一个 JS 文件。那么下面给出几个不用 JS 文件,就创建 Web Workers 的方法: webpack 插件 - worker-loaderworker-loader 是一个 webpack 插件,可以将一个普通 JS 文件的全部依赖提取后打包并替换调用处,以 Blob 形式内联在源码中。 import Worker from "worker-loader!./file.worker.js";const worker = new Worker(); 上述代码的魔术在于,转化成下面的方式执行: const blob = new Blob([codeFromFileWorker], { type: "application/javascript" });const worker = new Worker(URL.createObjectURL(blob)); Blob URL第二种方式由第一种方式自然带出:如果不想用 webpack 插件,那自己通过 Blob 的方式创建也可以: const code = ` importScripts('https://xxx.com/xxx.js'); self.onmessage = e => {};`;const blob = new Blob([code], { type: "application/javascript" });const worker = new Worker(URL.createObjectURL(blob)); 看上去代码更轻量一些,不过问题是当遇到复杂依赖时,如果不能把依赖都转化为脚本通过 importScripts 方式引用,就无法访问到主线程环境中的包。如果真的遇到了这个问题,可以用第一种 webpack 插件的方式解决,这个插件会自动把文件所有依赖都打包进源码。 管理 postMessage 队列为什么 postMessage 会形成队列,为什么要管理它? 首先在 Web Workers 架构设计上就必须做成队列,因为调用 postMessage 时,对应的 Web Workers 不一定完成了初始化,所以浏览器底层必须管理一个队列,在 Web Workers 初始化完毕时,依次消费,这样才能确保任何时候发出的 postMessage 都能被 Web Workers 接收到。 其次,为什么要手动维护这个队列,原因可能取决于如下几点: 业务原因,前面的 postMessage 还没来得及消费,就不要发送新的消息,或者丢弃新的消息,这时候需要通过双向通信拿到 Web Workers 的执行结果回执,手动控制队列。 性能原因,一般 Web Workers 都会被用来执行耗时的同步运算,如果运算时间比较长,那短期塞入多个消息队列是没有意义的。 如上图所示,对于每次用户输入都要进行的 SQL Parser 很耗时,及时放在 Web Workers 也可能导致将 Workers 撑爆到无响应,这是不仅要使用多 Workers 缓冲池,还要对待执行队列进行过滤,因为用户永远只关心最后一次输入的 Parser 结果。 由于 Web Workers 运算被卡住时,除了销毁 Worker 没有别的办法,而销毁 Worker 的成本比较高,不能对每一个用户输入都销毁并新建 Web Workers,所以利用 Workers 缓冲池,当缓冲池满了,新的消费队列又进来的时候,可以销毁全部 Workers 缓冲池,换一批新缓冲池重新消费用户输入。 4 总结Web Workers 是拆解异步计算的好帮手,vscode 网页版也通过 Web Workers 异步完成代码提示和高亮,笔者有对比过,发现 Web Workers 性能提升非常明显。 管理好你的 Web Workers 消息队列,谨防同步计算让 Web Workers 失去响应,建立一个智能的消息队列,根据业务需求设计一个最好的队列消费模型吧! 5 更多讨论 讨论地址是:精读《谈谈 Web Workers》 · Issue ##108 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《迭代器 Iterable》","path":"/wiki/WebWeekly/前沿技术/《迭代器 Iterable》.html","content":"当前期刊数: 262 本周精读的文章是 Iterables 与 Iteration protocols,按照为什么需要迭代器、迭代器是如何设计的,我们还能怎么利用迭代器展开来讲。 概述为什么需要迭代器因为用 for ... of 循环数组非常方便,但如果仅数组才支持这个语法就太过于麻烦了,比如我们自然会希望 for ... of 可以遍历字符串的每个字符,希望 new Set([1, 2, 3]) 可以快速初始化一个新的 Set。 以上提到的能力 JS 都支持,那么为什么 JS 引擎知道字符串该如何遍历?如何知道数组 [1, 2, 3] 与 Set 类型每一个 Key 之间的对应关系?实现这些功能背后的原理就是迭代器(Iterables)。 因为 Array、Set 都是可迭代的,所以他们都可以被 for ... of 遍历,JS 引擎也自然知道他们之间相互转换的关系。 迭代器是如何设计的有两种定义迭代器的方法,分别是独立定义与合并在对象里定义。 独立定义为对象拓展 [Symbol.iterator] 属性即可。之所以规范采用 [Symbol.iterator] 是为了防止普通的字面量 Key 与对象自身的 OwnProperties 冲突: const obj = {}obj[Symbol.iterator] = function() { return { someValue: 1, next() { // 可通过 this.someValue 访问与修改该值,可定义任意数量的变量作为迭代过程中的辅助变量 if (...) { return { done: false, value: this.current++ } // 表示迭代还没完,当前值为 value } return { done: true } // 表示迭代完毕 } };}; 在 for ... of 时,只要没有读到 done: true 就会一直循环。 合并在对象里定义简化一点可以将迭代定义在对象里: let range = { from: 1, to: 5, [Symbol.iterator]() { this.current = this.from; return this; }, next() { if (this.current <= this.to) { return { done: false, value: this.current++ }; } else { return { done: true }; } },}; 这么定义的缺点是并行迭代对象时可能触发 BUG,因为每个迭代间共享了同一份状态变量。 手动控制迭代迭代器也可以自定义触发,方法如下: const myObj = iterable[Symbol.iterator]();myObj.next(); // { value: 1, done: false }myObj.next(); // { value: 2, done: false }myObj.next(); // { value: 3, done: false }myObj.next(); // { done: true } 当 done 为 true 时你就知道迭代停止了。手动控制迭代的好处是,你可以自由控制 next() 触发的时机与频率,甚至提前终止,带来了更大的自由度。 可迭代与 ArrayLike 的区别如果不了解迭代器,可能会以为 for of 是通过下标访问的,也就会把一个对象能否用 obj[index] 访问与是否可迭代弄混。 读过上面的介绍,你应该理解到可迭代的原因是实现了 [Symbol.iterator],而与对象是否是数组,或者 ArrayLike 没有关系。 // 该对象可迭代,不是 ArrayLikeconst range = { from: 1, to: 5,};range[Symbol.iterator] = function () { // ...}; // 该对象不可迭代,是 ArrayLikeconst range = { "0": "a", "1": "b", length: 2,}; // 该对象可迭代,是 ArrayLikeconst range = { "0": "a", "1": "b", length: 2,};range[Symbol.iterator] = function () { // ...}; 顺带一提,js 的数组类型就是典型既可迭代,又属于 ArrayLike 的类型。 精读可迭代的内置类型String、Array、TypedArray、Map、Set 都支持迭代,其表现为: const myString = "abc";for (let val of myString) { console.log(val);} // 'a', 'b', 'c'const myArr = ["a", "b", "c"];for (let val of myArr) { console.log(val);} // 'a', 'b', 'c'const myMap = [ ["1", "a"], ["2", "b"], ["3", "c"],];for (let val of myMap) { console.log(val);} // ['1', 'a'], ['2', 'b'], ['3', 'c']const mySet = new Set(["a", "b", "c"]);for (let val of mySet) { console.log(val);} // 'a', 'b', 'c' 可迭代对象可以适用哪些 API可迭代对象首先支持上文提到的 for ... of 与 for ... in 语法。 另外就是许多内置函数的入参支持传入可迭代对象:Map() WeakMap() Set() WeakSet() Promise.all() Promise.allSettled() Promise.race() Promise.any() Array.from()。 如 Array.from 语法,可以将可迭代对象变成真正的数组,该数组的下标就是执行 next() 的次数,值就是 next().value: Array.from(new Set(["1", "2", "3"])); // ['1', '2', '3'] generator 也是迭代器的一种,属于异步迭代器,所以你甚至可以将 yield 一个 generator 函数作为上面这些内置函数的参数: new Set( (function* () { yield 1; yield 2; yield 3; })()); 最后一种就是上周精读提到的 精读《Rest vs Spread 语法》,解构本质也是用迭代器进行运算的: const range = { from: 1, to: 5, [Symbol.iterator]() { this.current = this.from; return this; }, next() { if (this.current <= this.to) { return { done: false, value: this.current++ }; } else { return { done: true }; } },};[...range]; // [1, 2, 3, 4, 5] 总结生活中,我们可以数苹果的数量,数大楼的窗户,数杂乱的衣物有多少个,其实不同的场景这些对象的排列形式都不同,甚至老师在黑板写的 0~10,我们按照这 4 个字符也能从 1 数到 10,这背后的原理抽象到程序里就是迭代器。 一个对象黑盒,不论内部怎么实现,如果我们能按照顺序数出内部结构,那么这个对象就是可迭代的,这就是 [Symbol.iterator] 定义要解决的问题。 生活中与程序中都有一些默认的迭代器,可以仔细领悟一下它们之间的关系。 讨论地址是:精读《迭代器 Iterable》· Issue ##448 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《默认、命名导出的区别》","path":"/wiki/WebWeekly/前沿技术/《默认、命名导出的区别》.html","content":"当前期刊数: 204 从代码可维护性角度出发,命名导出比默认导出更好,因为它减少了因引用产生重命名情况的发生。 但命名导出与默认导出的区别不止如此,在逻辑上也有很大差异,为了减少开发时在这方面栽跟头,有必要提前了解它们的区别。 本周找来了这方面很好的的文章:export-default-thing-vs-thing-as-default,先描述梗概,再谈谈我的理解。 概述一般我们认为,import 导入的是引用而不是值,也就是说,当导入对象在模块内值发生变化后,import 导入的对象值也应当同步变化。 // module.jsexport let thing = 'initial';setTimeout(() => { thing = 'changed';}, 500); 上面的例子,500ms 后修改导出对象的值。 // main.jsimport { thing as importedThing } from './module.js';const module = await import('./module.js');let { thing } = await import('./module.js');setTimeout(() => { console.log(importedThing); // "changed" console.log(module.thing); // "changed" console.log(thing); // "initial"}, 1000); 1s 后输出发现,前两种输出结果变了,第三种没有变。也就是对命名导出来说,前两种是引用,第三种是值。 但默认导出又不一样: // module.jslet thing = 'initial';export { thing };export default thing;setTimeout(() => { thing = 'changed';}, 500); // main.jsimport { thing, default as defaultThing } from './module.js';import anotherDefaultThing from './module.js';setTimeout(() => { console.log(thing); // "changed" console.log(defaultThing); // "initial" console.log(anotherDefaultThing); // "initial"}, 1000); 为什么对默认导出的导入结果是值而不是引用? 原因是默认导出可以看作一种对 “default 赋值” 的特例,就像 export default = thing 这种旧语法表达的一样,本质上是一种赋值,所以拿到的是值而不是引用。 那么默认导出的另一种写法 export { thing as default } 也是如此吗?并不是: // module.jslet thing = 'initial';export { thing, thing as default };setTimeout(() => { thing = 'changed';}, 500); // main.jsimport { thing, default as defaultThing } from './module.js';import anotherDefaultThing from './module.js';setTimeout(() => { console.log(thing); // "changed" console.log(defaultThing); // "changed" console.log(anotherDefaultThing); // "changed"}, 1000); 可见,这种默认导出,导出的都是引用。所以导出是否是引用,不取决于是否是命名导出,而是取决于写法。不同的写法效果不同,哪怕相同含义的不同写法,效果也不同。 难道是写法的问题吗?是的,只要是 export default 导出的都是值而不是引用。但不幸的是,存在一个特例: // module.jsexport default function thing() {}setTimeout(() => { thing = 'changed';}, 500); // main.jsimport thing from './module.js';setTimeout(() => { console.log(thing); // "changed"}, 1000); 为什么 export default function 是引用呢?原因是 export default function 是一种特例,这种写法就会导致导出的是引用而不是值。如果我们用正常方式导出 Function,那依然遵循前面的规则: // module.jsfunction thing() {}export default thing;setTimeout(() => { thing = 'changed';}, 500); 只要没有写成 export default function 语法,哪怕导出的对象是个 Function,引用也不会变化。所以取决效果的是写法,而与导出对象类型无关。 对于循环引用也有时而生效,时而不生效的问题,其实也取决于写法。下面的循环引用是可以正常工作的: // main.jsimport { foo } from './module.js';foo();export function hello() { console.log('hello');} // module.jsimport { hello } from './main.js';hello();export function foo() { console.log('foo');} 为什么呢?因为 export function 是一种特例,JS 引擎对其做了全局引用提升,所以两个模块都能各自访问到。下面方式就不行了,原因是不会做全局提升: // main.jsimport { foo } from './module.js';foo();export const hello = () => console.log('hello'); // module.jsimport { hello } from './main.js';hello();export const foo = () => console.log('foo'); 所以是否生效取决于是否提升,而是否提升取决于写法。当然下面的写法也会循环引用失败,因为这种写法会被解析为导出值: // main.jsimport foo from './module.js';foo();function hello() { console.log('hello');}export default hello; 作者的探索到这里就结束了,我们来整理一下思路,尝试理解其中的规律。 精读可以这么理解: 导出与导入均为引用时,最终才是引用。 导入时,除 {} = await import() 外均为引用。 导出时,除 export default thing 与 export default 123 外均为引用。 对导入来说,{} = await import() 相当于重新赋值,所以具体对象的引用会丢失,也就是说异步的导入会重新赋值,而 const module = await import() 引用不变的原因是 module 本身是一个对象,module.thing 的引用还是不变的,即便 module 是被重新赋值的。 对导出来说,默认导出可以理解为 export default = thing 的语法糖,所以 default 本身就是一个新的变量被赋值,所以基础类型的引用无法被导出也很合理。甚至 export default '123' 是合法的,而 export { '123' as thing } 是非法的也证明了这一点,因为命名导出本质是赋值到 default 变量,你可以用已有变量赋值,也可以直接用一个值,但命名导出不存在赋值,所以你不能用一个字面量作命名导出。 而导出存在一个特例,export default function,这个我们尽量少写就行了,写了也无所谓,因为函数保持引用不变一般不会引发什么问题。 为了保证导入的总是引用,一方面尽量用命名导入,另一方面要注意命名导出。如果这两点都做不到,可以尽量把需要维持引用的变量使用 Object 封装,而不要使用简单变量。 最后对循环依赖而言,只有 export default function 存在声明提升的 Magic,可以保证循环依赖正常 Work,但其他情况都不支持。要避免这种问题,最好的办法是不要写出循环依赖,遇到循环依赖时使用第三个模块作中间人。 总结一般我们都希望 import 到的是引用而不是瞬时值,但因为语义与特殊语法糖的原因,导致并不是所有写法效果都是一致的。 我也认为不需要背下来这些导入导出细枝末节的差异,只要写模块时都用规范的命名导入导出,少用默认导出,就可以在语义与实际表现上规避掉这些问题啦。 讨论地址是:精读《export 默认/命名导出的区别》· Issue ##342 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《高性能表格》","path":"/wiki/WebWeekly/前沿技术/《高性能表格》.html","content":"当前期刊数: 191 每个前端都想做一个完美的表格,业界也在持续探索不同的思路,比如钉钉表格、语雀表格。 笔者所在数据中台团队也对表格有着极高的要求,尤其是自助分析表格,需要兼顾性能与交互功能,本文便是记录自助分析表格高性能的研发思路。 精读要做表格首先要选择基于 DOM 还是 Canvas,这是技术选型的第一步。比如钉钉表格就是 基于 Canvas 实现的,当然这不代表 Canvas 实现就比 DOM 实现要好,从技术上各有利弊: Canvas 渲染效率比 DOM 高,这是浏览器实现导致的。 DOM 可拓展性比 Canvas 好,渲染自定义内容首选 DOM 而非 Canvas。 技术选型要看具体的业务场景,钉钉表格其实就是在线 Excel,Excel 这种形态决定了单元格内一定是简单文本加一些简单图标,因此不用考虑渲染自定义内容的场景,所以选择 Canvas 渲染在未来也不会遇到不好拓展的麻烦。 而自助分析表格天然可能拓展图形、图片、操作按钮到单元格中,对轴的拖拽响应交互也非常复杂,为了不让 Canvas 成为以后拓展的瓶颈,还是选择 DOM 实现比较妥当。 那问题来了,既然 DOM 渲染效率天然比 Canvas 低,我们应该如何用 DOM 实现一个高性能表格呢? 其实业界已经有许多 DOM 表格优化方案了,主要以按需渲染、虚拟滚动为主,即预留一些 Buffer 区域用于滑动时填充,表格仅渲染可视区域与 Buffer 区域部分。但这些方案都不可避免的存在快速滑动时白屏问题,笔者通过不断尝试终于发现了一种完美解决的方案,我们一起往下看吧! 单元格使用 DIV 绝对定位即每个单元格都是用绝对定位的 DIV 实现,整个表格都是有独立计算位置的 DIV 拼接而成的: 这样做的前提是: 所有单元格位置都要提前计算,这里可以利用 web worker 做并行计算。 单元格合并仅是产生一个更大的单元格,它的定位方式与小单元格并无差异。 带来的好处是: 滚动时,单元格可以最大程度实现复用。 对于合并的单元格,只会让可视区域渲染的总单元格数更小,更利于性能提升,而不是带来性能负担。 如图所示有 16 个单元格,当我们向右下滑动一格时,中间 3x3 即 9 个格子的区域是完全不会重新渲染的,这样零散的绝对定位分布可以最大程度维持单元格本来的位置。我们可以认为,任何一格单元格只要自身不超出屏幕范围,就不会随着滚动而重渲染。 如果你采用 React 框架来实现,只要将每个格子的 key 设置为唯一的即可,比如当前行列号。 模拟滚动而非原生滚动一般来说,轴因为逻辑特殊,其渲染逻辑和单元格会分开维护,因此我们将表格分为三个区域:横轴、纵轴、单元格。 显然,常识是横轴只能纵向滚动,纵轴只能横向滚动,单元格可以横纵向滚动,那么横向和纵向滚动条就只能出现在单元格区域: 这样会存在三个问题: 单元格使用原生滚动,横纵轴只能在单元格区域监听滚动后,通过 .scroll 模拟滚动,这必然会导致单元格与轴滚动有一定错位,即轴的滚动有几毫秒的滞后感。 鼠标放在轴上时无法滚动,因为只有单元格是 overflow: auto 的,而轴区域 overflow: hidden 无法触发滚动。 快速滚动出现白屏,即便留了 Buffer 区域,在快速滚动时也无能为力,这是因为渲染速度跟不上滚动导致的。 经过一番思考,我们只要将方案稍作调整,就能同时解决上面三个问题:即不要使用原生的滚动条,而是使用 .scroll 代替滚动,用 mousewheel 监听滚动的触发: 这样做带来什么变化呢? 轴、单元格区域都使用 .scroll 触发滚动,使得轴和单元格不会出现错位,因为轴和单元格都是用 .scroll 触发的滚动。 任何位置都能监听滚动,使得轴上也能滚动了,我们不再依赖 overflow 属性。 快速滚动时惊喜的发现不会白屏了,原因是用 js 控制触发的滚动发生在渲染完成之后,所以浏览器会在滚动发生前现完成渲染,这相当有趣。 模拟滚动时,实际上整个表格都是 overflow: hidden 的,浏览器就不会给出自带滚动条了,我们需要用 DIV 做出虚拟滚动条代替,这个相对容易。 零 buffer 区域当我们采用模拟滚动方案时,相当于采用了在滚动时 “高频渲染” 的方案,因此不需要使用截留,更不要使用 Buffer 区域,因为更大的 Buffer 区域意味着更大的渲染开销。 当我们把 Buffer 区域移除时,发现整个屏幕内渲染单元格在 1000 个以内时,现代浏览器甚至配合 Windows 都能快速完成滚动前刷新,并不会影响滚动的流畅性。 当然,滚动过快依然不是一件好事,既然滚动是由我们控制的,可以稍许控制下滚动速度,控制在每次触发 mousewheel 位移不超过 200 左右最佳。 预计算像单元格合并、行列隐藏、单元格格式化等计算逻辑,最好在滚动前提前算掉,否则在快速滚动时实时计算必然会带来额外的计算成本损耗。 但是这种预计算也有弊端,当单元格数量超过 10w 时,计算耗时一般会超过 1 秒,单元格数量超过 100w 时,计算耗时一般会超过 10 秒,用预计算的牺牲换来滚动的流畅,还是有些遗憾,我们可以再思考以下,能否降低预计算的损耗? 局部预计算局部预计算就是一种解决方案,即便单元格数量有一千万个,但我们如果仅计算前 1w 个单元格呢?那无论数据量有多大,都不会出现丝毫卡顿。 但局部预计算有着明显缺点,即表格渲染过程中,局部计算结果并不总等价于全局计算结果,典型的有列宽、行高、跨行跨列的计算字段。 我们需要针对性解决,对于单元格宽高计算,必须采用局部计算,因为全量计算的损耗非常大。但局部计算肯定是不准确的,如下图所示: 但出于性能考虑,我们初始化可能仅能计算前三行的高度,此时,我们需要在滚动时做两件事情: 在快速滚动的时候,向 web worker 发送预计要滚动到的位置,增量计算这些位置文字宽度,并实时修正列总宽。(因为列总宽算完只要存储最大值,所以已计算的数量级会被压缩为 O(1))。 宽度计算完毕后,快速刷新当前屏幕单元格宽度,但在宽度校准的同时,维持可视区域内左对齐不变,如下图所示: 这样滚动过程中虽然单元格会被突然撑开,但位置并不会产生相对移动,与提前全量撑开后视觉内容相同,因此用户体验并不会有实际影响,但计算时间却由 O(row * column) 下降到 O(1),只要计算一个常数量级的单元格数目。 计算字段也是同理,可以在滚动时按片预计算,但要注意仅能在计算涉及局部单元格的情况下进行,如果这个计算是全局性质的,比如排名,那么局部排序的排名肯定是错误的,我们必须进行全量计算。 好在,即便是全量计算,我们也只需要考虑一部分数据,假设行列数量都是 n,可以将计算复杂度由 O(n²) 降低为 O(n): 这种计算字段的处理无法保证支持无限数量级的数据,但可以大大降低计算时间,假设 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《重新思考 Redux》","path":"/wiki/WebWeekly/前沿技术/《重新思考 Redux》.html","content":"当前期刊数: 56 本周精读内容是 《重新思考 Redux》。 1 引言《重新思考 Redux》是 rematch 作者 Shawn McKay 写的一篇干货软文。 dva 之后,有许多基于 redux 的状态管理框架,但大部分都很局限,甚至是倒退。但直到看到了 rematch,总算觉得 redux 社区又进了一步。 这篇文章的宝贵之处在于,抛开 Mobx、RXjs 概念,仅针对 redux 做深入的重新思考,对大部分还在使用 redux 的工程场景非常有帮助。 2 概述比较新颖的是,作者给出一个公式,评价一个框架或工具的质量: 工具质量 = 工具节省的时间/使用工具消耗的时间 如果这样评估原生的 redux,我们会发现,使用 redux 需要额外花费的时间可能超过了其节省下来的时间,从这个角度看,redux 是会降低工作效率的。 但 redux 的数据管理思想是正确的,复杂的前端项目也确实需要这种理念,为了更有效率的使用 redux,我们需要使用基于 redux 的框架。作者从 6 个角度阐述了基于 redux 的框架需要解决什么问题。 简化初始化redux 初始化代码涉及的概念比较多,比如 compose thunk 等等,同时将 reducer、initialState、middlewares 这三个重要概念拆分成了函数方式调用,而不是更容易接受的配置方式: const store = preloadedState => { return createStore( rootReducer, preloadedState, compose(applyMiddleware(thunk, api), DevTools.instrument()) );}; 如果换成配置方式,理解成本会降低不少: const store = new Redux.Store({ instialState: {}, reducers: { count }, middlewares: [api, devTools]}); 笔者注:redux 的初始化方式非常函数式,而下面的配置方式就更面向对象一些。相比之下,还是面向对象的方式更好理解,毕竟 store 是一个对象。instialState 也存在同样问题,相比显示申明,将 preloadedState 作为函数入参就比较抽象了,同时 redux 对初始 state 的赋值也比较隐蔽,createStore 时统一赋值比较别扭,因为 reducers 是分散的,如果在 reducers 中赋值,要利用 es 的默认参数特性,看起来更像业务思考,而不是 redux 提供的能力。 简化 Reducersredux 的 reducer 粒度太大,不但导致函数内手动匹配 type,还带来了 type、payload 等理解成本: const countReducer = (state, action) => { switch (action.type) { case INCREMENT: return state + action.payload; case DECREMENT: return state - action.payload; default: return state; }}; 如果用配置的方式设置 reducers,就像定义一个对象一样,会更清晰: const countReducer = { INCREMENT: (state, action) => state + action.payload, DECREMENT: (state, action) => state - action.payload}; 支持 async/awaitredux 支持动态数据还是挺费劲的,需要理解高阶函数,理解中间件的使用方式,否则你不会知道为什么这样写是对的: const incrementAsync = count => async dispatch => { await delay(); dispatch(increment(count));}; 为什么不抹掉理解成本,直接允许 async 类型的 action 呢? const incrementAsync = async count => { await delay(); dispatch(increment(count));}; 笔者注:我们发现 rematch 的方式,dispatch 是 import 进来的(全局变量),而 redux 的 dispatch 是注入进来的,乍一看似乎 redux 更合理,但其实我更推崇 rematch 的方案。经过长期实践,组件最好不要使用数据流,项目的数据流只用一个实例完全够用了,全局 dispatch 的设计其实更合理,而注入 dispatch 的设计看似追求技术极致,但忽略了业务使用场景,导致画蛇添足,增加了不必要的麻烦。 将 action + reducer 改为两种 actionredux 抽象的 action 与 reducer 的职责很清晰,action 负责改 store 以外所有事,而 reducer 负责改 store,偶尔用来做数据处理。这种概念其实比较模糊,因为往往不清楚数据处理放在 action 还是 reducer 里,同时过于简单的 reducer 又要写 action 与之匹配,感觉过于形式化,而且繁琐。 重新考虑这个问题,我们只有两类 action:reducer action 与 effect action。 reducer action:改变 store。 effect action:处理异步场景,能调用其他 action,不能修改 store。 同步的场景,一个 reducer 函数就能处理,只有异步场景需要 effect action 处理掉异步部分,同步部分依然交给 reducer 函数,这两种 action 职责更清晰。 不再显示申明 action type不要在用一个文件存储 Action 类型了,const ACTION_ONE = 'ACTION_ONE' 其实重复写了一遍字符串,直接用对象的 key 表示 action 的值,再加上 store 的 name 为前缀保证唯一性即可。 同时 redux 建议使用 payload key 来传值,那为什么不强制使用 payload 作为入参,而要通过 action.payload 取值呢?直接使用 payload 不但视觉上减少代码数量,容易理解,同时也强制约束了代码风格,让建议真正落地。 Reducer 直接作为 ActionCreatorredux 调用 action 比较繁琐,使用 dispatch 或者将 reducer 经过 ActionCreator 函数包装。为什么不直接给 reducer 自动包装 ActionCreator 呢?减少样板代码,让每一行代码都有业务含义。 最后作者给出了一个 rematch 完整的例子: import { init, dispatch } from "@rematch/core";import delay from "./makeMeWait";const count = { state: 0, reducers: { increment: (state, payload) => state + payload, decrement: (state, payload) => state - payload }, effects: { async incrementAsync(payload) { await delay(); this.increment(payload); } }};const store = init({ models: { count }});dispatch.count.incrementAsync(1); 3 精读我觉得本文基本上把 redux 存在的工程问题分析透彻了,同时还给出了一套非常好的实现。 细节的极致优化首先是直接使用 payload 而不是整个 action 作为入参,加强了约束同时简化代码复杂度: increment: (state, payload) => state + payload; 其次使用 async 在 effects 函数中,使用 this.increment 函数调用方式,取代 put({type: "increment"})(dva),在 typescript 中拥有了类型支持,不但可以用自动跳转代替字符串搜索,还能校验参数类型,在 redux 框架中非常难得。 最后在 dispatch 函数,也提供了两种调用方式: dispatch({ type: "count/increment", payload: 1 });dispatch.count.increment(1); 如果为了更好的类型支持,或者屏蔽 payload 概念,可以使用第二种方案,再一次简化 redux 概念。 内置了比较多的插件rematch 将常用的 reselect、persist、immer 等都集成为了插件,相对比较强化插件生态的概念。数据流对数据缓存,性能优化,开发体验优化都有进一步施展的空间,拥抱插件生态是一个良好的发展方向。 比如 rematch-immer 插件,可以用 mutable 的方式修改 store: const count = { state: 0, reducers: { add(state) { state += 1; return state; } }}; 但是当 state 为非对象时,immer 将不起作用,所以最好能养成 return state 的习惯。 最后说一点瑕疵的地方,reducers 申明与调用参数不一致。 Reducers 申明与调用参数不一致比如下面的 reducers: const count = { state: 0, reducers: { increment: (state, payload) => state + payload, decrement: (state, payload) => state - payload }, effects: { async incrementAsync(payload) { await delay(); this.increment(payload); } }}; 定义时 increment 是两个参数,而 incrementAsync 调用它时,只有一个参数,这样可能造成一些误导,笔者建议保持参数对应关系,将 state 放在 this 中: const count = { state: 0, reducers: { increment: payload => this.state + payload, decrement: payload => this.state - payload }, effects: { async incrementAsync(payload) { await delay(); this.increment(payload); } }}; 当然 rematch 的方式保持了函数的无副作性质,可以看出是做了一些取舍。 4 总结重复一下作者提出工具质量的公式: 工具质量 = 工具节省的时间/使用工具消耗的时间 如果一个工具能节省开发时间,但本身带来了很大使用成本,在想清楚如何减少使用成本之前,不要急着用在项目中,这是我得到的最大启发。 最后感谢 rematch 作者精益求精的精神,给 redux 带来进一步的极致优化。 5 更多讨论 讨论地址是:精读《重新思考 Redux》 · Issue ##83 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《手写 SQL 编译器 - 回溯》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 回溯》.html","content":"当前期刊数: 67 1 引言上回 精读《手写 SQL 编译器 - 语法分析》 说到了如何利用 Js 函数实现语法分析时,留下了一个回溯问题,也就是存档、读档问题。 我们把语法分析树当作一个迷宫,有直线有岔路,而想要走出迷宫,在遇到岔路时需要提前进行存档,在后面走错时读档换下一个岔路进行尝试,这个功能就叫回溯。 上一篇我们实现了 分支函数,在分支执行失败后回滚 TokenIndex 位置并重试,但在函数调用栈中,如果其子函数执行完毕,堆栈跳出,我们便无法找到原来的函数栈重新执行。 为了更加详细的描述这个问题,举一个例子,存在以下岔路: a -> tree() -> c -> b1 -> b1' -> b2 -> b2' 上面描述了两条判断分支,分别是 a -> b1 -> b1' -> c 与 a -> b2 -> b2' -> c,当岔路 b1 执行失败后,分支函数 tree 可以复原到 b2 位置尝试重新执行。 但设想 b1 -> b1' 通过,但 b1 -> b1' -> c 不通过的场景,由于 b1' 执行完后,分支函数 tree 的调用栈已经退出,无法再尝试路线 b2 -> b2' 了。 要解决这个问题,我们要 通过链表手动构造函数执行过程,这样不仅可以实现任意位置回溯,还可以解决左递归问题,因为函数并不是立即执行的,在执行前我们可以加一些 Magic 动作,比如调换执行顺序!这文章主要介绍如何通过链表构造函数调用栈,并实现回溯。 2 精读假设我们拥有了这样一个函数 chain,可以用更简单的方式表示连续匹配: const root = (tokens: IToken[], tokenIndex: number) => match('a', tokens, tokenIndex) && match('b', tokens, tokenIndex) && match('c', tokens, tokenIndex)↓ ↓ ↓ ↓ ↓ ↓const root = (chain: IChain) => chain('a', 'b', 'c') 遇到分支条件时,通过数组表示取代 tree 函数: const root = (tokens: IToken[], tokenIndex: number) => tree( line(match('a', tokens, tokenIndex) && match('b', tokens, tokenIndex)), line(match('c', tokens, tokenIndex) && match('d', tokens, tokenIndex)))↓ ↓ ↓ ↓ ↓ ↓const root = (chain: IChain) => chain([ chain('a', 'b'), chain('c', 'd')]) 这个 chain 函数有两个特质: 非立即执行,我们就可以 预先生成执行链条 ,并对链条结构进行优化、甚至控制执行顺序,实现回溯功能。 无需显示传递 Token,减少每一步匹配写的代码量。 封装 scanner、matchToken我们可以制作 scanner 函数封装对 token 的操作: const query = "select * from table;";const tokens = new Lexer(query);const scanner = new Scanner(tokens); scanner 拥有两个主要功能,分别是 read 读取当前 token 内容,和 next 将 token 向下移动一位,我们可以根据这个功能封装新的 matchToken 函数: function matchToken( scanner: Scanner, compare: (token: IToken) => boolean): IMatch { const token = scanner.read(); if (!token) { return false; } if (compare(token)) { scanner.next(); return true; } else { return false; }} 如果 token 消耗完,或者与比对不匹配时,返回 false 且不消耗 token,当匹配时,消耗一个 token 并返回 true。 现在我们就可以用 matchToken 函数写一段匹配代码了: const query = "select * from table;";const tokens = new Lexer(query);const scanner = new Scanner(tokens);const root = matchToken(scanner, token => token.value === "select") && matchToken(scanner, token => token.value === "*") && matchToken(scanner, token => token.value === "from") && matchToken(scanner, token => token.value === "table") && matchToken(scanner, token => token.value === ";"); 我们最终希望表达成这样的结构: const root = (chain: IChain) => chain("select", "*", "from", "table", ";"); 既然 chain 函数作为线索贯穿整个流程,那 scanner 函数需要被包含在 chain 函数的闭包里内部传递,所以我们需要构造出第一个 chain。 封装 createChainNodeFactory我们需要 createChainNodeFactory 函数将 scanner 传进去,在内部偷偷存起来,不要在外部代码显示传递,而且 chain 函数是一个高阶函数,不会立即执行,由此可以封装二阶函数: const createChainNodeFactory = (scanner: Scanner, parentNode?: ChainNode) => ( ...elements: any[]): ChainNode => { // 生成第一个节点 return firstNode;}; 需要说明两点: chain 函数返回第一个链表节点,就可以通过 visiter 函数访问整条链表了。 (...elements: any[]): ChainNode 就是 chain 函数本身,它接收一系列参数,根据类型进行功能分类。 有了 createChainNodeFactory,我们就可以生成执行入口了: const chainNodeFactory = createChainNodeFactory(scanner);const firstNode = chainNodeFactory(root); // const root = (chain: IChain) => chain('select', '*', 'from', 'table', ';') 为了支持 chain('select', '*', 'from', 'table', ';') 语法,我们需要在参数类型是文本类型时,自动生成一个 matchToken 函数作为链表节点,同时通过 reduce 函数将链表节点关联上: const createChainNodeFactory = (scanner: Scanner, parentNode?: ChainNode) => ( ...elements: any[]): ChainNode => { let firstNode: ChainNode = null; elements.reduce((prevNode: ChainNode, element) => { const node = new ChainNode(); // ... Link node node.addChild(createChainChildByElement(node, scanner, element)); return node; }, parentNode); return firstNode;}; 使用 reduce 函数对链表上下节点进行关联,这一步比较常规所以忽略掉,通过 createChainChildByElement 函数对传入函数进行分类,如果 传入函数是字符串,就构造一个 matchToken 函数塞入当前链表的子元素,当执行链表时,再执行 matchToken 函数。 重点是我们对链表节点的处理,先介绍一下链表结构。 链表结构class ChainNode { public prev: ChainNode; public next: ChainNode; public childs: ChainChild[] = [];}class ChainChild { // If type is function, when run it, will expend. public type: "match" | "chainNode" | "function"; public node?: IMatchFn | ChainNode | ChainFunctionNode;} ChainNode 是对链表节点的定义,这里给出了和当前文章内容相关的部分定义。这里用到了双向链表,因此每个 node 节点都拥有 prev 与 next 属性,分别指向上一个与下一个节点,而 childs 是这个链表下挂载的节点,可以是 matchToken 函数、链表节点、或者是函数。 整个链表结构可能是这样的: node1 <-> node2 <-> node3 <-> node4 |- function2-1 |- matchToken2-1 |- node2-1 <-> node2-2 <-> node2-3 |- matchToken2-2-1 对每一个节点,都至少存在一个 child 元素,如果存在多个子元素,则表示这个节点是 tree 节点,存在分支情况。 而节点类型 ChainChild 也可以从定义中看到,有三种类型,我们分别说明: matchToken 类型这种类型是最基本类型,由如下代码生成: chain("word"); 链表执行时,match 是最基本的执行单元,决定了语句是否能匹配,也是唯一会消耗 Token 的单元。 node 类型链表节点的子节点也可能是一个节点,类比嵌套函数,由如下代码生成: chain(chain("word")); 也就是 chain 的一个元素就是 chain 本身,那这个 chain 子链表会作为父级节点的子元素,当执行到链表节点时,会进行深度优先遍历,如果执行通过,会跳到父级继续寻找下一个节点,其执行机制类比函数调用栈的进出关系。 函数类型函数类型非常特别,我们不需要递归展开所有函数类型,因为文法可能存在无限递归的情况。 好比一个迷宫,很多区域都是相同并重复的,如果将迷宫完全展开,那迷宫的大小将达到无穷大,所以在计算机执行时,我们要一步步展开这些函数,让迷宫结束取决于 Token 消耗完、走出迷宫、或者 match 不上 Token,而不是在生成迷宫时就将资源消耗完毕。函数类型节点由如下代码生成: chain(root); 所有函数类型节点都会在执行到的时候展开,在展开时如果再次遇到函数节点仍会保留,等待下次执行到时再展开。 分支普通的链路只是分支的特殊情况,如下代码是等价的: chain("a");chain(["a"]); 再对比如下代码: chain(["a"]);chain(["a", "b"]); 无论是直线还是分支,都可以看作是分支路线,而直线(无分支)的情况可以看作只有一条分叉的分支,对比到链表节点,对应 childs 只有一个元素的链表节点。 回溯现在 chain 函数已经支持了三种子元素,一种分支表达方式: chain("a"); // MatchNodechain(chain("a")); // ChainNodechain(foo); // FunctionNodechain(["a"]); // 分支 -> [MatchNode] 而上文提到了 chain 函数并不是立即执行的,所以我们在执行这些代码时,只是生成链表结构,而没有真正执行内容,内容包含在 childs 中。 我们需要构造 execChain 函数,拿到链表的第一个节点并通过 visiter 函数遍历链表节点来真正执行。 function visiter( chainNode: ChainNode, scanner: Scanner, treeChances: ITreeChance[]): boolean { const currentTokenIndex = scanner.getIndex(); if (!chainNode) { return false; } const nodeResult = chainNode.run(); let nestedMatch = nodeResult.match; if (nodeResult.match && nodeResult.nextNode) { nestedMatch = visiter(nodeResult.nextNode, scanner, treeChances); } if (nestedMatch) { if (!chainNode.isFinished) { // It's a new chance, because child match is true, so we can visit next node, but current node is not finished, so if finally falsely, we can go back here. treeChances.push({ chainNode, tokenIndex: currentTokenIndex }); } if (chainNode.next) { return visiter(chainNode.next, scanner, treeChances); } else { return true; } } else { if (chainNode.isFinished) { // Game over, back to root chain. return false; } else { // Try again scanner.setIndex(currentTokenIndex); return visiter(chainNode, scanner, treeChances); } }} 上述代码中,nestedMatch 类比嵌套函数,而 treeChances 就是实现回溯的关键。 当前节点执行失败时由于每个节点都包含 N 个 child,所以任何时候执行失败,都给这个节点的 child 打标,并判断当前节点是否还有子节点可以尝试,并尝试到所有节点都失败才返回 false。 当前节点执行成功时,进行位置存档当节点成功时,为了防止后续链路执行失败,需要记录下当前执行位置,也就是利用 treeChances 保存一个存盘点。 然而我们不知道何时整个链表会遭遇失败,所以必须等待整个 visiter 执行完才知道是否执行失败,所以我们需要在每次执行结束时,判断是否还有存盘点(treeChances): while (!result && treeChances.length > 0) { const newChance = treeChances.pop(); scanner.setIndex(newChance.tokenIndex); result = judgeChainResult( visiter(newChance.chainNode, scanner, treeChances), scanner );} 同时,我们需要对链表结构新增一个字段 tokenIndex,以备回溯还原使用,同时调用 scanner 函数的 setIndex 方法,将 token 位置还原。 最后如果机会用尽,则匹配失败,只要有任意一次机会,或者能一命通关,则匹配成功。 3 总结本篇文章,我们利用链表重写了函数执行机制,不仅使匹配函数拥有了回溯能力,还让其表达更为直观: chain("a"); 这种构造方式,本质上与根据文法结构编译成代码的方式是一样的,只是许多词法解析器利用文本解析成代码,而我们利用代码表达出了文法结构,同时自身执行后的结果就是 “编译后的代码”。 下次我们将探讨如何自动解决左递归问题,让我们能够写出这样的表达式: const foo = (chain: IChain) => chain(foo, bar); 好在 chain 函数并不是立即执行的,我们不会立即掉进堆栈溢出的漩涡,但在执行节点的过程中,会导致函数无限展开从而堆栈溢出。 解决左递归并不容易,除了手动或自动重写文法,还会有其他方案吗?欢迎留言讨论。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 回溯》 · Issue ##96 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《手写 SQL 编译器 - 性能优化之缓存》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 性能优化之缓存》.html","content":"当前期刊数: 78 1 引言重回 “手写 SQL 编辑器” 系列。这次介绍如何利用缓存优化编译器执行性能。 可以利用 First 集 与 Match 节点缓存 这两种方式优化。 本文会用到一些图做解释,下面介绍图形规则: First 集优化,是指在初始化时,将整体文法的 First 集找到,因此在节点匹配时,如果 Token 不存在于 First 集中,可以快速跳过这个文法,在文法调用链很长,或者 “或” 的情况比较多时,可以少走一些弯路: 如图所示,只要构建好了 First 集,不论这个节点的路径有多长,都可以以最快速度判断节点是否不匹配。如果节点匹配,则继续深度遍历方式访问节点。 现在节点不匹配时性能已经最优,那下一步就是如何优化匹配时的性能,这时就用到 Match 节点缓存。 Match 节点缓存,指在运行时,缓存节点到其第一个终结符的过程。与 First 集相反,First 集可以快速跳过,而 Match 节点缓存可以快速找到终结符进行匹配,在非终结符很多时,效果比较好: 如图所示,当匹配到节点时,如果已经构建好了缓存,可以直接调到真正匹配 Token 的 Match 节点,从而节省了大量节点遍历时间。 这里需要注意的是,由于 Tree 节点存在分支可能性,因此缓存也包含将 “沿途” Chances 推入 Chances 池的职责。 2 精读那么如何构建 First 集与 Match 节点缓存呢?通过两张图解释。 构建 First 集 如图所示,构建 First 集是个自下而上的过程,当访问到 MatchNode 节点时,就可以收集作为父节点的 First 集了!父集判断 First 集收集完毕的话,就会触发它的父节点 First 集收集判断,如此递归,最后完成 First 集收集的是最顶级节点。 构建 Match 节点缓存 如图所示,访问节点时,如果没有缓存,则会将这个节点添加到 Match 缓存查找队列,同时路途遇到 TreeNode,也会将下一个 Chance 添加到缓存查找队列。直到遇到了第一个 MatchNode 节点,则这个节点是 “Match 缓存查找队列” 所有节点的 Match 节点缓存,此时这些节点的缓存就可以生效了,指向这个 MatchNode,同时清空缓存查找队列,等待下一次查找。 3 总结拿 select a, b, c, d from e 这个语句做测试: node 节点访问次数 First 集优化 First 集 + Match 节点缓存优化 784 669 652 从这个简单 Demo 来看,提效了 16% 左右。不过考虑到文法结构会影响到提效,对于层级更深的文法、能激活深层级文法的输入可以达到更好的效率提升。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 性能优化之缓存》 · Issue ##110 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《手写 SQL 编译器 - 智能提示》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 智能提示》.html","content":"当前期刊数: 85 1 引言词法、语法、语义分析概念都属于编译原理的前端领域,而这次的目的是做 具备完善语法提示的 SQL 编辑器,只需用到编译原理的前端部分。 经过连续几期的介绍,《手写 SQL 编译器》系列进入了 “智能提示” 模块,前几期从 词法到文法、语法,再到构造语法树,错误提示等等,都是为 “智能提示” 做准备。 由于智能提示需要对词法分析、语法分析做深度定制,所以我们没有使用 antlr4 等语法分析器生成工具,而是创造了一个 JS 版语法分析生成器 syntax-parser。 这次一口气讲完如何从 syntax-parser 到做一个具有智能提示功能的 SQL 编辑器。 2 精读从语法解析、智能提示和 SQL 编辑器封装三个层次来介绍,这三个层次就像俄罗斯套娃一样具有层层递进的关系。 为了更清晰展现逻辑层次,同时满足解耦的要求,笔者先从智能提示整体设计架构讲起。 智能提示的架构syntax-parser 是一个 JS 版的语法分析器生成器,除了类似 antlr4 基本语法分析功能外,还支持专门为智能提示优化的功能,后面会详细介绍。整体架构设计如下图所示: 首先需要实现 SQL 语法,我们利用语法分析器生成器 syntax-parser,生成一个 SQL 语法分析器,这一步其实是利用 syntax-parser 能力完成了 sql lexer 与 sql parser。 为了解析语法树含义,我们需要在 sql parser 基础之上编写一套 sql reader,包含了一些分析函数解析语法树的语义。 利用 monaco-editor 生态,利用 sql reader 封装 monaco-editor 插件,同时实现 用户 <=> 编辑器 间的交互,与 编辑器 <=> 语义分析器 间的交互。 语法解析器syntax-parser 分为词法分析、语法分析两步。词法分析主要利用正则构造一个有穷自动机,大家都学过的 “编译原理” 里有更完整的解读,或者移步 精读《手写 SQL 编译器 - 词法分析》,这里主要介绍语法分析。 词法分析的输入是语法分析输出的 Tokens。Tokens 就是一个个单词,Token 结构存储了单词的值、位置、类型。 我们需要构造一个执行链条消费这些 Token,也就是可以执行文法扫描的程序。我们用四种类型节点描述文法,如下图所示: 如果不了解文法概念,可以阅读 精读《手写 SQL 编译器 - 文法介绍》 能消耗 Token 的只有 MatchNode 节点,ChainNode 节点描述先后关系(比如 expr -> name id),TreeNode 节点描述并列关系(比如 factor -> num | id),FunctionNode 是函数节点,表示还未展开的节点(如果把文法匹配比做迷宫探险,那这是个无限迷宫,无法穷尽展开)。 如何用 syntax-parser 描述一个文法,可以访问文档,现在我们已经描述了一个文法树,应该如何解析呢? 我们先找到一个非终结符作为根节点,深度遍历所有非终结符节点,遇到 MatchNode 时如果匹配,就消耗一个 Token 并继续前进,否则文法匹配失败。 遇到 ChainNode 会按照顺序执行其子节点;遇到 FunctionNode(非终结符节点)会执行这个函数,转换为一个非 FunctionNode 节点,如下图所示: 遇到 TreeNode 节点时保存这个节点运行状态并继续执行,在 MatchNode 匹配失败时可以还原到此节点继续尝试下个节点,如下图所示: 这样就具备了最基本的语法分析功能,如需更详细阅读,可以移步 精读《手写 SQL 编译器 - 语法分析》。 我们还做了一些优化,比如 First 集优化与路径缓存优化。限于篇幅,分布在以下几篇文章: 精读《手写 SQL 编译器 - 回溯》 精读《手写 SQL 编译器 - 语法树》 精读《手写 SQL 编译器 - 错误提示》 精读《手写 SQL 编译器 - 性能优化之缓存》 SQL 编辑器重点在于如何做输入提示,也就是如何在用户光标位置给出恰当的提示。这就是我们定制 SQL 编辑器的原因,输入提示与语法检测需要分开来做,而语法树并不能很好解决输入提示的问题。 智能提示为了找到一个较为完美的语法提示方案,通过查阅大量资料,我决定将光标作为一个 Token 考虑来实现智能提示。 思考我们用 | 表示光标所在位置,那么下面的 SQL 应该如何处理? select | from b; 从语法角度来看,它是错的,因为实际上是一个不完整语句 “select from b;” 从提示角度来看,它是对的,因为这是一个正确的输入过程,光标位置再输入一个单词就正确了。 你会发现,从语法和提示角度来看同一个输入,结果往往是矛盾的,所以我们需要分两条线程分别处理语法与提示。 但输入错误时,我们是无法构造语法树的,而智能提示的时机往往都是语句语法错误的时机,用过 AST 工具的人都知道。可是没有语法树,我们怎么做到智能的提示呢?试想如下语句: select c.| from ( select * from dt;) c; 面对上面这个语句,很显然 c. 没有写完,一般的语法树解析器提示你语法错误。你可能想到这几种方案: 字符串匹配方式强行提示。但很显然这样提示不准确,没有完整语法树,是无法做精确解析的。而且当语法复杂时,字符串解析方案几乎无从下手。 把光标位置用一个特殊的字符串补上,先构造一个临时正确的语句,生成 AST 后再找到光标位置。 一般我们会采取第二种方案,看上去相对靠谱。处理过程是这样的: select c.$my_custom_symbol$ from ... 之后在 AST 中找到 $my_custom_symbol$ 字符串,对应的节点就是光标位置。实际上这可以解决大部分问题,除了关键字。 这种方案唯有关键字场景不兼容,试想一下: select a |from b;## select a $my_custom_symbol$ from b; 你会发现,“补全光标文字” 法,在关键字位置时,会把原本正确的语句变成错误的语句,根本解析不出语法树。 我们在 syntax-parser 解析引擎层就解决了这个问题,解决方案是 连同光标位置一起解析。 两个假设我们做两个基本假设: 需要自动补全的位置分为 “关键字” 与 “非关键字”。 “非关键字” 位置基本都是由字符串构成的。 关键字: 因此针对第一种假设,syntax-parser 内置了 “关键字提示” 功能。因为 syntax-parser 可以拿到你配置的文法,因此当给定光标位置时,可以拿到当前位置前一个 Token,通过回溯和平行尝试,将后面所有可能性提示出来,如下图: 输入是 select a |,灰色部分是已经匹配成功的部分,而我们发现光标位置前一个 Token 正是红色标识的 word,通过尝试运行推导,我们发现,桔红色标记的 ',' 和 'from' 都是 word 可能的下一个确定单词,这种单词就是 SQL 语法中的 “关键字”,syntax-parser 会自动告诉你,光标位置可能的输入是 [',', 'from']。 所以关键字的提示已经在 syntax-parser 层内置解决了!而且无论语法正确与否,都不影响提示结果,因为算法是 “寻找光标位置前一个 Token 所有可能的下一个 Token”,这可以完全由词法分析器内置支持。 非关键字: 针对非关键字,我们解决方案和用特殊字符串补充类似,但也有不同: 在光标位置插入一个新 Token,这个 Token 类型是特殊的 “光标类型”。 在 word 解析函数加一个特殊判断,如果读到 “光标类型” Token,也算成功解析,且消耗 Token。 因此 syntax-parser 总是返回两个 AST 信息: { "ast": {}, "cursorPath": []} 分别是语法树详细信息,与光标位置在语法树中的访问路径。 对于 select a | 的情况,会生成三个 Tokens:['select', 'a', 'cursor'],对于 select a| 的情况,会生成两个 Tokens:['select', 'a'],也就是光标与字符相连时,不会覆盖这个字符。 cursorPath 的生成也比 “字符串补充” 方案更健壮,syntax-parser 生成的 AST 会记录每一个 Token 的位置,最终会根据光标位置进行比对,进而找到光标对应语法树上哪个节点。 对 .| 的处理: 可能你已经想到了,.| 情况是很通用的输入场景,比如 user. 希望提示出 user 对象的成员函数,或者 SQL 语句表名存在项目空间的情况,可能 tableName 会存在 .| 的语法。 .| 状况时,语法是错误的,此时智能提示会遇到挑战。根据查阅的资料,这块也有两种常见处理手法: 在 . 位置加上特殊标识,让语法解析器可以正确解析出语法树。 抹去 .,先让语法正确解析,再分析语法树拿到 . 前面 Token 的属性,推导出后面的属性。 然而这两种方式都不太优雅,syntax-parser 选择了第三种方式:隔空打牛。 通过抽象,我们发现,无论是 user.name 还是 udf:count() 这种语法,都要求在某个制定字符打出时(比如 . 或 :),提示到这个字符后面跟着的 Token。 此时光标焦点在 . 而非之后的字符上,那我们何不将光标偷偷移到 . 之后,进行空光标 Token 补位呢!这样不但能完全复用之前的处理思想,还可以拿到我们真正想拿到的位置: select a(.|) from b;## select a. (|) from b 对比后发现,第一行拥有 4 个 Token,语法错误,而经过修改的第二行拥有 5 个 Token(一个光标补位),语法正确,且光标所在位置等价于第一行我们希望提示的位置,此问题得以解决。 SQL 编辑器封装我们拥有了内置 “智能提示” 功能的语法解析器,定制了一套自定义的 SQL 词法、文法描述,便完成了 sql-lexer 与 sql-parser 这一层。由于 SQL 文法完善工作非常庞大,且需要持续推进,这里举流计算中,申明动态维表的例子: CREATE TABLE dwd_log_pv_wl_ri( PRIMARY KEY(rowkey), PERIOD FOR SYSTEM_TIME) WITH () 要支持这种语法,我们在非终结符 tableOption 下增加两个分支即可: const tableOption = () => chain([ chain(stringOrWord, dataType)(), chain("primary", "key", "(", primaryKeyList, ")")(), chain("period", "for", "system_time")() ])(); sql-reader: 为了方便解析 SQL 语法树,我们在 sql-reader 内置了几个常用方法,比如: 找到距离光标位置最近的父节点。比如 select a, b, | from d 会找到这个 selectStatement。 根据表源找到所有提供的字段。表源是指 from 之后跟的语法,不但要考虑嵌套场景,别名,分组,方言,还要追溯每个字段来源于哪张表(针对 join 或 union 的情况)。 有了 sql-reader,我们可以保证在这种层层嵌套 + 别名混淆 + select * 这种复杂的场景下,仍然能追溯到字段的最原始名称,最原始的表名: 这样上层业务拓展时,可以拿到足够准、足够多的信息,具有足够好的拓展型。 monaco-editor plugin: 我们也支持了更上层的封装,Monaco Editor 插件级别的,只需要填一些参数:获取表名、获取字段的回调函数就能 Work,统一了内部业务的调用方式: import { monacoSqlAutocomplete } from '@alife/monaco-sql-plugin';// Get monaco and editor.monacoSqlAutocomplete(monaco, editor, { onInputTableField: async tableName => { // ...}, onInputTableName: async () => { // ... }, onInputFunctionName: async () => { // ... }, onHoverTableName: async cursorInfo => { // ... }, onHoverTableField: (fieldName, extra) => { // ... }, onHoverFunctionName: functionName => { // ... }}); 比如实现了 onInputTableField 接口,我们可以拿到当前表名信息,轻松实现字段提示: 你也许会看到,上图中鼠标位置有错误提示(红色波浪线),但依然给出了正确的推荐提示。这得益于我们对 syntax-parser 内部机制的优化,将语法检查与智能提示分为两个模块独立处理,经过语法解析,虽然抛出了语法错误,但因为有了光标的加入,最终生成了语法树。 再比如实现了 onHoverFunctionName,可以自定义鼠标 hover 在函数时的提示信息: 得益于 sql-reader,我们对 sql 语句做了层层解析,所以才能把自动提示做到极致。比如在做字段自动提示时,经历了如下判断步骤: 而你只需要实现 onInputTableField,告诉程序每个表可以提供哪些字段,整个流程就会严格的层层检查表名提供对原始字段与 selectList 描述的输出字段,找到映射关系并逐级传递、校验,最终 Merge 后一直冒泡到当前光标位置所在语句,形成输入建议。 4 总结整个智能提示的封装链条如下: syntax-parser -> sql-parser -> monaco-editor-plugin 对应关系是: 语法解析器生成器 -> SQL 语法解析器 -> 编辑器插件 这样逻辑层次清晰,解耦,而且可以从任意节点切入,进行自定义,比如: 从 syntax-parser 开始使用 从最底层开始使用,也许有两个目的: 上层封装的 sql-parser 不够好用,我重写一个 sql-parser’ 以及 monaco-editor-plugin’。 我的场景不是 SQL,而是流程图语法、或 Markdown 语法的自动提示。 针对这种情况,首先将目标文法找到,转化成 syntax-parser 的语法,比如: chain(word, "=>", word); 再仿照 sql-parser -> monaco-editor-plugin 的结构把上层封装依次实现。 从 sql-parser 开始使用 也许你需要的仅仅是一颗 SQL 语法树?或者你的输出目标不是 SQL 编辑器而是一个 UI 界面?那可以试试直接使用 sql-parser。 sql-parser 不仅可以生成语法树,还能找到当前光标位置所在语法树的节点,找到 SQL 某个语法返回的所有字段列表等功能,基于它,甚至可以做 UI 与 SQL 文本互转的应用。 从 monaco-editor-plugin 开始使用 也许你需要支持自动提示的 SQL 编辑器,那太棒了,直接用 monaco-editor-plugin 吧,根据你的业务场景或个人喜好,实现一个定制的 monaco-editor 交互插件。 目前我们只开源最底层的 syntax-parser,这也是业务无关的语法解析引擎生成器,期待您的使用与建议! 讨论地址是:精读《手写 SQL 编译器 - 智能提示》 · Issue ##118 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《手写 SQL 编译器 - 文法介绍》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 文法介绍》.html","content":"当前期刊数: 65 1 引言文法用来描述语言的语法规则,所以不仅可以用在编程语言上,也可用在汉语、英语上。 2 精读我们将一块语法规则称为 产生式,使用 “Left → Right” 表示任意产生式,用 “Left => Right” 表示产生式的推导过程,比如对于产生式: E → iE → E + E 我们进行推导时,可以这样表示:E => E + E => i + E => i + i + E => i + i + i 也有使用 Left : Right 表示产生式的例子,比如 ANTLR。BNF 范式通过 Left ::= Right 表示产生式。 举个例子,比如 SELECT * FROM table 可以被表达为: S → SELECT * FROM table 当然这是最固定的语法,真实场景中,* 可能被替换为其他单词,而 table 不但可能有其他名字,还可能是个子表达式。 一般用大写的 S 表示文法的开头,称为开始符号。 终结符与非终结符 下面为了方便书写,使用 BNF 范式表示文法。 终结符就是语句的终结,读到它表示产生式分析结束,相反,非终结符就是一个新产生式的开始,比如: <selectStatement> ::= SELECT <selectList> FROM <tableName><selectList> ::= <selectField> [ , <selectList> ]<tableName> ::= <tableName> [ , <tableList> ] 所有 ::= 号左边的都是非终结符,所以 selectList 是非终结符,解析 selectStatement 时遇到了 selectList 将会进入 selectList 产生式,而解析到普通 SELECT 单词就不会继续解析。 对于有二义性的文法,可以通过 上下文相关文法 方式描述,也就是在产生式左侧补全条件,解决二义性: aBc -> a1c | a2cdBe -> d3e 一般产生式左侧都是非终结符,大写字母是非终结符,小写字母是终结符。 上面表示,非终结符 B 在 ac 之间时,可以解析为 1 或 2,而在 de 之间时,解析为 3。但我们可以增加一个非终结符让产生式可读性更好: B -> 1 | 2C -> 3 这样就将上下文相关文法转换为了上下文无关文法。 上下文无关文法根据是否依赖上下文,文法分为 上下文相关文法 与 上下文无关文法,一般来说 上下文相关文法 都可以转换为一堆 上下文无关文法 来处理,而用程序处理 上下文无关文法 相对轻松。 SQL 的文法就是上下文相关文法,在正式介绍 SQL 文法之前,举一个简单的例子,比如我们描述等号(=)的文法: SELECT CASE WHEN bee = 'red' THEN 'ANGRY' ELSE 'NEUTRAL' END AS BeeStateFROM bees;SELECT * from bees WHERE bee = 'red'; 上面两个 SQL 中,等号前后的关键字取决于当前是在 CASE WHEN 语句里,还是在 WHERE 语句里,所以我们认为等号所在位置的文法是上下文相关的。 但是当我们将文法粒度变细,将 CASE WHEN 与 WHERE 区块分别交由两块文法解决,将等号这个通用的表达式抽离出来,就可以不关心上下文了,这种方式称为 上下文无关文法。 附上一个 mysql 上下文无关文法集合。 左推导与右推导上面提到的推导符号 => 在实际运行过程中,显然有两种方向左和右: E + E => ? 从最左边的 E 开始分析,称为左推导,对语法解析来说是自顶向下的方式,常用方法是递归下降。 从最右边的 E 开始分析,称为右推导,对语法解析来说是自底向上的方式,常用方法是移进、规约。 右推导过程比左推导过程复杂,所以如果考虑手写,最好使用左推导的方式。 左推导的分支预测比如 select <selectList> 的 selectList 产生式,它可以表示为: <SelectList> ::= <SelectList> , <SelectField> | <SelectField> 由于它可以展开:SelectList => SelectList , a => SelectList , b, a => c, b, a。 但程序执行时,读到这里会进入死循环,因为 SelectList 可以被无限展开,这就是左递归问题。 消除左递归消除左递归一般通过转化为右递归的方式,因为左递归完全不消耗 Token,而右递归可以通过消耗 Token 的方式跳出死循环。 Token 见上一期精读 精读《手写 SQL 编译器 - 词法分析》 <SelectList> ::= <SelectField> <G><G> ::= , <SelectList> | null 这其实是一个通用处理,可以抽象出来: E → E + FE → F E → FGG → + FGG → null 不过我们也不难发现,通过通用方式消除左递归后的文法更难以阅读,这是因为用死循环的方式解释问题更容易让人理解,但会导致机器崩溃。 笔者建议此处不要生硬的套公式,在套了公式后,再对产生式做一些修饰,让其更具有语义: <SelectList> ::= <SelectField> | , <SelectList> 提取左公因式即便是上下文无关的文法,通过递归下降方式,许多时候也必须从左向右超前查看 K 个字符才能确定使用哪个产生式,这种文法称为 LL(k)。 但如果每次超前查看的内容都有许多字符相同,会导致第二次开始的超前查看重复解析字符串,影响性能。最理想的情况是,每次超前查看都不会对已确定的字符重复查看,解决方法是提取左公因式。 设想如下的 sql 文法: <Field> ::= <Text> as <Text> | <Text> as<String> | <Text> <Text> | <Text> 其实 Text 本身也是比较复杂的产生式,最坏的情况需要对 Text 连续匹配六遍。我们将 Text 公因式提取出来就可以仅匹配一遍,因为无论是何种 Field 产生式,都必定先遇到 Text: <Field> ::= <Text> <F><F> ::= <G> | <Text><G> ::= as <H><H> ::= <space> <Text> | <String> 和消除左递归一样,提取左公因式也会降低文法的可读性,需要进行人为修复。不过提取左公因式的修复没办法在文法中处理,在后面的 “函数式” 处理环节是有办法处理的,敬请期待。 结合优先级对 SQL 的文法来说不存在优先级的概念,所以从某种程度来说,SQL 的语法复杂度还不如基本的加减乘除。 3 总结在实现语法解析前,需要使用文法描述 SQL 的语法,文法描述就是语法分析的主干业务代码。 下一篇将介绍语法分析相关知识,帮助你一步步打造自己的 SQL 编译器。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 文法介绍》 · Issue ##94 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《手写 SQL 编译器 - 词法分析》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 词法分析》.html","content":"当前期刊数: 64 1 引言因为工作关系,需要开发支持众多方言的 SQL 编辑器,所以复习了一下编译原理相关知识。 相比编译原理专家,我们只需要了解部分编译原理即可实现 SQL 编辑器,所以这是一篇写给前端的编译原理文章。 解析 SQL 可以分为如下四步: 词法分析,将 SQL 字符串拆分成包含关键词识别的字符段(Tokens)。 语法分析,利用自顶向下或自底向上的算法,将 Tokens 解析为 AST,可以手动,也可以自动。 错误检测、恢复、提示推断,都需要利用语法分析产生的 AST。 语义分析,做完这一步就可以执行 SQL 语句了,不过对前端而言,不需要深入到这一步,可以跳过。 2 精读词法分析就像刀削面的过程,拿着一段字符串(面条)一端不断下刀,当面条被切完也就完成了词法分析,所以词法分析是 字符串 -> 一堆字符段 的过程。 流程很简单,难点就在下刀的分寸了,每次砍几厘米呢? 回到词法分析,为了准备切分,我们需要定义 SQL 的 Token 有哪些类型,即 Token 分类。 Token 分类SQL 的 Token 可以分为如下几类: 注释。 关键字(SELECT、CREATE)。 操作符(+、-、>=)。 开闭合标志((、CASE)。 占位符(?)。 空格。 引号包裹的文本、数字、字段。 方言语法(${variable})。 可以看到,在词法分析阶段,我们的 Tokens 不需要关心关键词是什么,只要识别是不是关键词即可,因为关键词的辨认会留到语法分析时处理。涉及到语意处理就要考虑上下文,而这都不是词法分析阶段要考虑的。 同样,操作符、空格、文本、占位符等构成了 SQL 语句的其他部分,最后通过开闭合标志比如左括号和右括号,让 SQL 支持子语句。 再强调一次,虽然 SQL 支持子语句,但并不是放在任何位置都是合理的,其他类型 Token 同理,但是词法分析不需要考虑 Token 是否合理,只要切分即可。 用正则逐段分词像大多数语言一样,SQL 为了方便人类阅读,采用从左到右的书写方式,因此分词方向也从左到右。 我们为每个 Token 类型写一个函数,比如匹配空格的匹配函数: function getTokenWhitespace(restStr: string) { const matches = restStr.match(/^(\\s+)/); if (matches) { return { type, value: matches[1] }; }} restStr 表示掐去头部剩下的 SQL 字符串,所有匹配函数都拿 restStr 进行匹配,已经匹配的不需要再处理。 通过正则 /^(\\s+)/ 匹配到第一个以空格开头的空格(读起来有点别扭),匹配时必须保证以你要匹配的内容开头,而且只匹配一次,这样才不会在切词时发生遗漏。 同理匹配 /**/ 类型注释时,也能通过正则轻而易举的实现: function getTokenBlockComment(restStr: string) { const matches = restStr.match(/^(\\/\\*[^]*?(?:\\*\\/|$))/); if (matches) { return { type, value: matches[1] }; }} 其中 (?:\\*/\\) 表示匹配到以 */ 结尾处,而 (?:\\*\\/|$) 后面的 |$ 表示或者直接匹配到结尾(如果一直没有遇到 */ 那后面全部当作注释)。 所以只要 Token 分类得当,并且能为每一个分类写一个头匹配正则,分词功能就实现了 90%。 方言拓展为了支持某些方言,需要从分词时就开始做考虑。比如 ${variable} 作为一种变量用法时,我们需要在普通字段的正则匹配中,加入一项 \\$\\{[a-zA-Z0-9]+\\} 匹配。 如果要支持纯中文作为字段,可以再补充 |\\u4e00-\\u9fa5。 分词主流程有了一个个分词函数,再补充一个不断匹配、切割字符串、再匹配的主函数即可,这一步更简单: while (sqlStr) { token = getTokenWhitespace(sqlStr, token) || getTokenBlockComment(sqlStr, token); sqlStr = sqlStr.substring(token.value.length); tokens.push(token);} 上面的函数每取一次 Token,都将取到的 Token 长度丢掉,继续匹配剩下的字符串,直到字符串被切分完为止。 有些特殊情况需要拿到上次的 Token 才能判断下一个 Token 该如何切割,所以将 Token 传给每一个下一步 Match 函数。 最后,执行这个主函数,分词就完成了! 3 总结分词比较简单,到这里就全部结束了。后面即将进入深水区语法分析,敬请期待。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 词法分析》 · Issue ##93 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《手写 SQL 编译器 - 语法树》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 语法树》.html","content":"当前期刊数: 70 1 引言重回 “手写 SQL 编辑器” 系列。之前几期介绍了 词法、文法、语法的解析,以及回溯功能的实现,这次介绍如何生成语法树。 基于 《回溯》 一文介绍的思路,我们利用 JS 实现一个微型 SQL 解析器,并介绍如何生成语法树,如何在 JS SQL 引擎实现语法树生成功能! 解析目标是: select name, version from my_table; 文法: const root = () => chain(selectStatement, many(";", selectStatement));const selectStatement = () => chain("select", selectList, fromClause);const selectList = () => chain(matchWord, many(",", matchWord));const fromClause = () => chain("from", matchWord);const statement = () => chain( "select", selectList, "from", chain(tableName, [whereStatement, limitStatement]) ); 这是本文为了方便说明,实现的一个精简版本。完整版见我们的开源仓库 cparser。 root 是入口函数,many() 包裹的文法可以执行任意次,所以 chain(selectStatement, many(";", selectStatement)); 表示允许任意长度的 selectStatement 由 ; 号连接,selectList 的写法也同理。 matchWord 表示匹配任意单词。 语法树是人为对语法结构的抽象,本质上,如果我们到此为止,是可以生成一个 基本语法树 的,这个语法树是多维数组,比如: const fromClause = () => chain("from", matchWord); 这个文法生成的默认语法树是:['from', 'my_table'],只不过 from my_table 具体是何含义,只有当前文法知道(第一个标志无含义,第二个标志表示表名)。 fromClause 返回的语法树作为结果被传递到文法 selectStatement 中,其结果可能是:['select', [['name', 'version']], ['from', 'my_table']]。 大家不难看出问题:当默认语法树聚集在一起,就无法脱离文法结构单独理解语法含义了,为了脱离文法结构理解语法树,我们需要将其抽象为一个有规可循的结构。 2 精读通过上面的分析,我们需要对 chain 函数提供修改局部 AST 结构的能力: const selectStatement = () => chain("select", selectList, fromClause)(ast => ({ type: "statement", variant: "select", result: ast[1], from: ast[2] })); 我们可以通过额外参数对默认语法树进行改造,将多维数组结构改变为对象结构,并增加 type variant 属性标示当前对象的类型、子类型。比如上面的例子,返回的对象告诉使用者:“我是一个表达式,一个 select 表达式,我的结果是 result,我的来源表是 from”。 那么,chain 函数如何实现语法树功能呢? 对于每个文法(每个 chain 函数),其语法树必须等待所有子元素执行完,才能生成。所以这是个深度优先的运行过程。 下图描述了 chain 函数执行机制: 生成结构中有四个基本结构,分别是 Chain、Tree、Function、Match,足以表达语法解析需要的所有逻辑。(不包含 可选、多选 逻辑)。 每个元素的子节点全部执行完毕,才会生成当前节点的语法树。实际上,每个节点执行完,都会调用 callParentNode 访问父节点,执行到了这个函数,说明子元素已成功执行完毕,补全对应节点的 AST 信息即可。 对于修改局部 AST 结构函数,需等待整个 ChainNode 执行完毕才调用,并将返回的新 AST 信息存储下来,作为这个节点的最终 AST 信息并传递给父级(或者没有父级,这就是根结点的 AST 结果)。 3 总结本文介绍了如何生成语法树,并说明了 默认语法树 的存在,以及我们之所以要一个定制的语法树,是为了更方便的理解含义。 同时介绍了如何通过 JS 运行一套完整的语法解析器,以及如何提供自定义 AST 结构的能力。 本文介绍的模型,只是为了便于理解而定制的简化版,了解全部细节,请访问 cparser。 最后说一下为何要做这个语法解析器。如今有许多开源的 AST 解析工具,但笔者要解决的场景是语法自动提示,需要在语句不完整,甚至错误的情况,给出当前光标位置的所有可能输入。所以通过完整重写语法解析器内核,在解析的同时,生成语法树的同时,也给出光标位置下一个可能输入提示,在通用错误场景自动从错误中恢复。 目前在做性能优化,通用 SQL 文法还在陆续完善中,目前仅可当学习参考,不要用于生产环境。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 语法树》 · Issue ##99 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《手写 SQL 编译器 - 错误提示》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 错误提示》.html","content":"当前期刊数: 71 1 引言 编译器除了生成语法树之外,还要在输入出现错误时给出恰当的提示。 比如当用户输入 select (name,这是个未完成的 SQL 语句,我们的目标是提示出这个语句未完成,并给出后续的建议: ) - + % / * . ( 。 2 精读分析一个 SQL 语句,现将 query 字符串转成 Token 数组,再构造文法树解析,那么可能出现错误的情况有两种: 语句错误。 文法未完成。 给出错误提示的第一步是判断错误发生。 通过这张 Token 匹配过程图可以发现,当深度优先遍历文法节点时,匹配成功后才会返回父元素继续往下走。而当走到父元素没有根节点了才算匹配成功;当尝试 Chance 时没有机会了,就是错误发生的时机。 所以我们只要找到最后一个匹配成功的节点,再根据最后成功与否,以及搜索出下一个可能节点,就能知道错误类型以及给出建议了。 function onMatchNode(matchNode, store) { const matchResult = matchNode.run(store.scanner); if (!matchResult.match) { tryChances(matchNode, store); } else { const restTokenCount = store.scanner.getRestTokenCount(); if (matchNode.matching.type !== "loose") { if (!lastMatch) { lastMatch = { matchNode, token: matchResult.token, restTokenCount }; } } callParentNode(matchNode, store, matchResult.token); }} 所以在运行语法分析器时,在遇到匹配节点(MatchNode)时,如果匹配成功,就记录下这个节点,这样我们最终会找到最后一个匹配成功的节点:lastMatch。 之后通过 findNextMatchNodes 函数找到下一个可能的推荐节点列表,作为错误恢复的建议。 findNextMatchNodes 函数会根据某个节点,找出下一节点所有可能 Tokens 列表,这个函数后面文章再专门介绍,或者你也可以先阅读 源码. 语句错误也就是任何一个 Token 匹配失败。比如: select * from table_name as table1 error_string; 这里 error_string 就是冗余的语句。 通过语法解析器分析,可以得到执行失败的结果,然后通过 findNextMatchNodes 函数,我们可以得到下面分析结果: 可以看到,程序判断出了 error_string 这个 Token 属于错误类型,同时给出建议,可以将 error_string 替换成这 14 个建议字符串中任意一个,都能使语句正确。 之所以失败类型判断为错误类型,是因为查找了这个正确 Token table1 后面还有一个没有被使用的 error_string,所以错误归类是 wrong。 注意,这里给出的是下一个 Token 建议,而不是全部 Token 建议,因此推荐了 where 表示 “或者后面跟一个完整的 where 语句”。 文法未完成和语句错误不同,这种错误所有输入的单词都是正确的,但却没有写完。比如: select * 通过语法解析器分析,可以得到执行失败的结果,然后通过 findNextMatchNodes 函数,我们可以得到下面分析结果: 可以看到,程序判断出了 * 这个 Token 属于未完成的错误类型,建议在后面补全这 14 个建议字符串中任意一个。比较容易联想到的是 where,但也可以是任意子文法的未完成状态,比如后面补充 , 继续填写字段,或者直接跟一个单词表示别名,或者先输入 as 再跟别名。 之所以失败类型判断为未完成,是因为最后一个正确 Token * 之后没有 Token 了,但语句解析失败,那只有一个原因,就是语句为写完,因此错误归类是 inComplete。 找到最易读的错误类型在一开始有提到,我们只要找到最后一个匹配成功的节点,就可以顺藤摸瓜找到错误原因以及提示,但最后一个成功的节点可能和我们人类直觉相违背。举下面这个例子: select a from b where a = '1' ~ -- 这里手滑了 正常情况,我们都认为错误点在 ~,而最后一个正确输入是 '1'。但词法解析器可不这么想,在我初版代码里,判断出错误是这样的: 提示是 where 错了,而且提示是 .,有点摸不着头脑。 读者可能已经想到了,这个问题与文法结构有关,我们看 fromClause 的文法描述: const fromClause = () => chain( "from", tableSources, optional(whereStatement), optional(groupByStatement), optional(havingStatement) )(); 虽然实际传入的 where 语句多了一个 ~ 符号,但由于文法认为整个 whereStatement 是可选的,因此出错后会跳出,跳到 b 的位置继续匹配,而 显然 groupByStatement 与 havingStatement 都不能匹配到 where,因此编译器认为 “不会从 b where a = '1' ~” 开始就有问题吧?因此继续往回追溯,从 tableName 开始匹配: const tableName = () => chain([matchWord, chain(matchWord, ".", matchWord)()])(); 此时第一次走的 b where a = '1' ~ 路线对应 matchWord,因此尝试第二条路线,所以认为 where 应该换成 .。 要解决这个问题,首先要 承认这个判断是对的,因为这是一种 错误提前的情况,只是人类理解时往往只能看到最后几步,所以我们默认用户想要的错误信息,是 正确匹配链路最长的那条,并对 onMatchNode 作出下面优化: 将 lastMatch 对象改为 lastMatchUnderShortestRestToken: if ( !lastMatchUnderShortestRestToken || (lastMatchUnderShortestRestToken && lastMatchUnderShortestRestToken.restTokenCount > restTokenCount)) { lastMatchUnderShortestRestToken = { matchNode, token: matchResult.token, restTokenCount };} 也就是每次匹配到正确字符,都获取剩余 Token 数量,只保留最后一匹配正确 且剩余 Token 最少的那个。 3 总结做语法解析器错误提示功能时,再次刷新了笔者三观,原来我们以为的必然,在编译器里对应着那么多 “可能”。 当我们遇到一个错误 SQL 时,错误原因往往不止一个,你可以随便截取一段,说是从这一步开始就错了。语法解析器为了让报错符合人们的第一直觉,对错误信息做了 过滤,只保留剩余 Token 数最短的那条错误信息。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 错误提示》 · Issue ##101 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《手写 SQL 编译器 - 语法分析》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 语法分析》.html","content":"当前期刊数: 66 1 引言接着上周的文法介绍,本周介绍的是语法分析。 以解析顺序为角度,语法分析分为两种,自顶而下与自底而上。 自顶而下一般采用递归下降方式处理,称为 LL(k),第一个 L 是指从左到右分析,第二个 L 指从左开始推导,k 是指超前查看的数量,如果实现了回溯功能,k 就是无限大的,所以带有回溯功能的 LL(k) 几乎是最强大的。LL 系列一般分为 LL(0)、LL(1)、LL(k)、LL(∞)。 自底而上一般采用移进(shift)规约(reduce)方式处理,称为 LR,第一个 L 也是从左到右分析,第二个 R 指从右开始推导,而规约时可能产生冲突,所以通过超前查看一个符号解决冲突,就有了 SLR,后面还有功能更强的 LALR(1) LR(1) LR(k)。 通过这张图可以看到 LL 家族与 LR 家族的能力范围: 如图所示,无论 LL 还是 LR 都解决不了二义性文法,还好所有计算机语言都属于无二义性文法。 值得一提的是,如果实现了回溯功能的 LL(k) -> LL(∞),那么能力就可以与 LR(k) 所比肩,而 LL 系列手写起来更易读,所以笔者采用了 LL 方式书写,今天介绍如何手写无回溯功能的 LL。 另外也有一些根据文法自动生成 parser 的库,比如兼容多语言的 antlr4 或者对 js 支持比较友好的 pegjs。 2 精读递归下降可以理解为走多出口的迷宫: 我们先根据 SQL 语法构造一个迷宫,进迷宫的不是探险家,而是 SQL 语句,这个 SQL 语句会拿上一堆令牌(切分好的 Tokens,详情见 精读:词法分析),迷宫每前进一步都会要求按顺序给出令牌(交上去就没收),如果走到出口令牌刚好交完,就成功走出了迷宫;如果出迷宫时手上还有令牌,会被迷宫工作人员带走。这个迷宫会有一些分叉,在分岔路上会要求你亮出几个令牌中任意一个即可通过(LL1),有的迷宫允许你失败了存档,只要没有走出迷宫,都可以读档重来(LLk),理论上可以构造一个最宽容的迷宫,只要还没走出迷宫,可以在分叉处任意读档(LL∞),这个留到下一篇文章介绍。 词法分析首先对 SQL 进行词法分析,拿到 Tokens 列表,这些就是探险家 SQL 带上的令牌。 根据上次讲的内容,我们对 select a from b 进行词法分析,可以拿到四个 Token(忽略空格与注释)。 Match 函数递归下降最重要的就是 Match 函数,它就是迷宫中索取令牌的关卡。每个 Match 函数只要匹配上当前 Token 便将 Token index 下移一位,如果没有匹配上,则不消耗 Token: function match(word: string) { const currentToken = tokens[tokenIndex] // 拿到当前所在的 Token if (currentToken.value === word) { // 如果 Token 匹配上了,则下移一位,同时返回 true tokenIndex++ return true } // 没有匹配上,不消耗 Token,但是返回 false return false} Match 函数就是精简版的 if else,试想下面一段代码: if (token[tokenIndex].value === 'select') {\ttokenIndex++} else {\treturn false}if (token[tokenIndex].value === 'a') {\ttokenIndex++} else {\treturn false} 通过不断对比与移动 Token 进行判断,等价于下面的 Match 实现: match('select') && match('a') 这样写出来的语法分析代码可读性会更强,我们能专注精神在对文法的解读上,而忽略其他环境因素。 顺便一提,下篇文章笔者会带来更精简的描述方法: chain('select', 'a') 让函数式语法更接近文法形式。 最后这种语法不但描述更为精简,而且拥有 LL(∞) 的查找能力,拥有几乎最强大的语法分析能力。 语法分析主体函数既然关卡(Match)已经有了,下面开始构造主函数了,可以开始画迷宫了。 举个最简单的例子,我们想匹配 select a from b,只需要这么构造主函数: let tokenIndex = 0function match() { /* .. */ }const root = () => match("select") && match("a") && match("from") && match("b")tokens = lexer("select a from b")if (root() && tokenIndex === tokens.length) { // sql 解析成功} 为了简化流程,我们把 tokens、tokenIndex 作为全局变量。首先通过 lexer 拿到 select a from b 语句的 Tokens:['select', ' ', 'a', ' ', 'from', ' ', 'b'],注意在语法解析过程中,注释和空格可以消除,这样可以省去对空格和注释的判断,大大简化代码量。所以最终拿到的 Tokens 是 ['select', 'a', 'from', 'b']。 很显然这样与我们构造的 Match 队列相吻合,所以这段语句顺利的走出了迷宫,而且走出迷宫时,Token 正好被消费完(tokenIndex === tokens.length)。 这样就完成了最简单的语法分析,一共十几行代码。 函数调用函数调用是 JS 最最基础的知识,但用在语法解析里可就不那么一样了。 考虑上面最简单的语句 select a from b,显然无法胜任真正的 SQL 环境,比如 select [位置] from b 这个位置可以放置任意用逗号相连的字符串,我们如果将这种 SQL 展开描述,将非常复杂,难以阅读。恰好函数调用可以帮我们完美解决这个问题,我们将这个位置抽象为 selectList 函数,所以主语句改造如下: const root = () => match("select") && selectList() && match("from") && match("b") 这下能否解析 select a, b, c from table 就看 selectList 这个函数了: const selectList = match("a") && match(",") && match("b") && match(",") && match("c") 显然这样做不具备通用性,因为我们将参数名与数量固定了。考虑到上期精读学到的文法,我们可以这样描述 selectList: selectList ::= word (',' selectList)?word ::= [a-zA-Z] 故意绕过了左递归,采用右递归的写法,因而避开了语法分析的核心难点。 ? 号是可选的意思,与正则的 ? 类似。 这是一个右递归文法,不难看出,这个文法可以如此展开: selectList => word (‘,’ selectList)? => a (‘,’ selectList)? => a, word (‘,’ selectList)? => a, b, word (‘,’ selectList)? => a, b, word => a, b, c 我们一下遇到了两个问题: 补充 word 函数。 如何描述可选参数。 同理,利用函数调用,我们假定拥有了可选函数 optional,与函数 word,这样可以先把 selectList 函数描述出来: const selectList = () => word() && optional(match(",") && selectList()) 这样就通过可选函数 optional 描述了文法符号 ?。 我们来看 word 函数如何实现。需要简单改造下 match 使其支持正则,那么 word 函数可以这样描述: const word = () => match(/[a-zA-Z]*/) 而 optional 不是普通的 match 函数,从调用方式就能看出来,我们提到下一节详细介绍。 注意 selectList 函数的尾部,通过右递归的方式调用 selectList,因此可以解析任意长度以 , 分割的字段列表。 Antlr4 支持左递归,因此文法可以写成 selectList ::= selectList (, word)? | word,用在我们这个简化的代码中会导致堆栈溢出。 在介绍 optional 函数之前,我们先引出分支函数,因为可选函数是分支函数的一种特殊形式(猜猜为什么?)。 分支函数我们先看看函数 word,其实没有考虑到函数作为字段的情况,比如 select a, SUM(b) from table。所以我们需要升级下 selectList 的描述: const selectList = () => field() && optional(match(",") && selectList())const field = () => word() 这时注意 field 作为一个字段,也可能是文本或函数,我们假设拥有函数处理函数 functional,那么用文法描述 field 就是: field ::= text | functional | 表示分支,我们用 tree 函数表示分支函数,那么可以如此改写 field: const field = () => tree(word(), functional()) 那么改如何表示 tree 呢?按照分支函数的特性,tree 的职责是超前查看,也就是超前查看 word 是否符合当前 Token 的特征,如何符合,则此分支可以走通,如果不符合,同理继续尝试 functional。 若存在 A、B 分支,由于是函数式调用,若 A 分支为真,则函数堆栈退出到上层,若后续尝试失败,则无法再回到分支 B 继续尝试,因为函数栈已经退出了。这就是本文开头提到的 回溯 机制,对应迷宫的 存档、读档 机制。要实现回溯机制,要模拟函数执行机制,拿到函数调用的控制权,这个下篇文章再详细介绍。 根据这个特性,我们可以写出 tree 函数: function tree(...args: any[]) { return args.some(arg => arg())} 按照顺序执行 tree 的入参,如果有一个函数执行为真,则跳出函数,如果所有函数都返回 false,则这个分支结果为 false。 考虑到每个分支都会消耗 Token,所以我们需要在执行分支时,先把当前 TokenIndex 保存下来,如果执行成功则消耗,执行失败则还原 Token 位置: function tree(...args: any[]) { const startTokenIndex = tokenIndex return args.some(arg => { const result = arg() if (!result) { tokenIndex = startTokenIndex // 执行失败则还原 TokenIndex } return result });} 可选函数可选函数就是分支函数的一个特例,可以描述为: func? => func | ε ε 表示空,也就是这个产生式解析到这里永远可以解析成功,而且不消耗 Token。借助分支函数 tree 执行失败后还原 TokenIndex 的特性,我们先尝试执行它,执行失败的话,下一个 ε 函数一定返回 true,而且会重置 TokenIndex 且不消耗 Token,这与可选的含义是等价的。 所以可以这样描述 optional 函数: const optional = fn => tree(fn, () => true) 基本的运算连接上面通过对 SQL 语句的实践,发现了 match 匹配单个单词、 && 连接、tree 分支、ε 空字符串的产生式这四种基本用法,这是符合下面四个基本文法组合思想的: G ::= ε 空字符串产生式,对应 () => true,不消耗 Token,总是返回 true。 G ::= t 单词匹配,对应 match(t)。 G ::= x y 连接运算,对应 match(x) && match(y)。 G ::= xG ::= y 并运算,对应 tree(x, y)。 有了这四种基本用法,几乎可以描述所有 SQL 语法。 比如简单描述一下 select 语法: const root = () => match("select") && select() && match("from") && table()const selectList = () => field() && optional(match(",") && selectList())const field = () => tree(word, functional)const word = () => match(/[a-zA-Z]+/) 3 总结递归下降的 SQL 语法解析就是一个走迷宫的过程,将 Token 从左到右逐个匹配,最终能找到一条路线完全贴合 Token,则 SQL 解析圆满结束,这个迷宫采用空字符串产生式、单词匹配、连接运算、并运算这四个基本文法组合就足以构成。 掌握了这四大法宝,基本的 SQL 解析已经难不倒你了,下一步需要做这些优化: 回溯功能,实现它才可能实现 LL(∞) 的匹配能力。 左递归自动消除,因为通过文法转换,会改变文法的结合律与语义,最好能实现左递归自动消除(左递归在上一篇精读 文法 有说明)。 生成语法树,仅匹配语句的正确性是不够的,我们还要根据语义生成语法树。 错误检查,在错误的地方给出建议,甚至对某些错误做自动修复,这个在左 SQL 智能提示时需要用到。 错误恢复。 下篇文章会介绍如何实现回溯,让递归下降达到 LL(∞) 的效果。 从本文不难看出,通过函数调用方式我们无法做到 迷宫存档和读档机制,也就是遇到岔路 A B 时,如果 A 成功了,函数调用栈就会退出,而后面迷宫探索失败的话,我们无法回到岔路 B 继续探索。而 回溯功能就赋予了这个探险者返回岔路 B 的能力。 为了实现这个功能,几乎要完全推翻这篇文章的代码组织结构,不过别担心,这四个基本组合思想还会保留。 下篇文章也会放出一个真正能运行的,实现了 LL(∞) 的代码库,函数描述更精简,功能(比这篇文章的方法)更强大,敬请期待。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 语法分析》 · Issue ##95 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《设计模式 - Abstract Factory 抽象工厂》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Abstract Factory 抽象工厂》.html","content":"当前期刊数: 167 Abstract Factory(抽象工厂)Abstract Factory(抽象工厂)属于创建型模式,工厂类模式抽象程度从低到高分为:简单工厂模式 -> 工厂模式 -> 抽象工厂模式。 意图:提供一个接口以创建一系列相关或相互依赖的对象,而无须指定它们具体的类。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 汽车工厂我们都知道汽车有很多零部件,随着工业革命带来的分工,很多零件都可以被轻松替换。但实际生活中我们消费者不愿意这样,我们希望买来的宝马车所包含的零部件都是同一系列的,以保证最大的匹配度,从而带来更好的性能与舒适度。 所以消费者不愿意到轮胎工厂、方向盘工厂、车窗工厂去一个个采购,而是将需求提给了宝马工厂这家抽象工厂,由这家工厂负责组装。那你是这家工厂的老板,已知汽车的组成部件是固定的,只是不同配件有不同的型号,分别来自不同的制造厂商,你需要推出几款不同组合的车型来满足不同价位的消费者,你会怎么设计? 迷宫游戏你做一款迷宫游戏,已知元素有房间、门、墙,他们之间的组合关系是固定的,你通过一套算法生成随机迷宫,这套算法调用房间、门、墙的工厂生成对应的实例。但随着新资料片的放出,你需要生成具有新功能的房间(可以回复体力)、新功能的门(需要魔法钥匙才能打开)、新功能的墙(可以被炸弹破坏),但修改已有的迷宫生成算法违背了开闭原则(需要在已有对象进行修改),如果你希望生成迷宫的算法完全不感知新材料的存在,你会怎么设计? 事件联动假设我们做一个前端搭建引擎,现在希望做一套关联机制,以实现点击表格组件单元格,可以弹出一个模态框,内部展示一个折线图。已知业务方存在定制表格组件、模态框组件、折线图组件的需求,但组件之间联动关系是确定的,你会怎么设计? 意图解释在汽车工厂的例子中,我们已知车子的构成部件,为了组装成一辆车子,需要以一定方式拼装部件,而具体用什么部件是需要可拓展的。 在迷宫游戏的例子中,我们已知迷宫的组成部分是房间、门、墙,为了生成一个迷宫,需要以某种算法生成许多房间、门、墙的实例,而具体用哪种房间、哪种门、哪种墙是这个算法不关心的,是需要可被拓展的。 在事件联动的例子中,我们已知这个表格弹出趋势图的交互场景基本组成元素是表格组件、模态框组件、折线图组件,需要以某种联动机制让这三者间产生联动关系,而具体是什么表格、什么模态框组件、什么折线图组件是这个事件联动所不关心的,是需要可以被拓展的,表格可以被替换为任意业务方注册的表格,只要满足点击 onClick 机制就可以。 意图:提供一个接口以创建一系列相关或相互依赖的对象,而无须指定它们具体的类。 这三个例子不正是符合上面的意图吗?我们要设计的抽象工厂就是要 创建一系列相关或相互依赖的对象,在上面的例子中分别是汽车的组成配件、迷宫游戏的素材、事件联动的组件。而无须指定它们具体的类,也就说明了我们不关心车子方向盘用的是什么牌子,迷宫的房间是不是普通房间,联动机制的折线图是不是用 Echarts 画的,我们只要描述好他们之间的关系即可,这带来的好处是,未来我们拓展新的方向盘、新的房间、新的折线图时,不需要修改抽象工厂。 结构图 AbstractFactory 就是我们要的抽象工厂,描述了创建产品的抽象关系,比如描述迷宫如何生成,表格和趋势图怎么联动。 至于具体用什么方向盘、用什么房间,是由 ConcreteFactory 实现的,所以我们可能有多个 ConcreteFactory,比如 ConcreteFactory1 实例化的墙壁是普通墙壁,ConcreteFactory2 实例化的墙壁是魔法墙壁,但其对 AbstractFactory 的接口是一致的,所以 AbstractFactory 不需要关心具体调用的是哪一个工厂。 AbstractProduct 是产品抽象类,描述了比如方向盘、墙壁、折线图的创建方法,而 ConcreteProduct 是具体实现产品的方法,比如 ConcreteProduct1 创建的表格是用 canvas 画的,折线图是用 G2 画的,而 ConcreteProduct2 创建的表格是用 div 画的,折线图是用 Echarts 画的。 这样,当我们要拓展一个用 Echarts 画的折线图,用 svg 画的表格,用 div 画的模态框组成的事件机制时,只需要再创建一个 ConcreteFactory3 做相应的实现即可,再将这个 ConcreteFactory3 传递给 AbstractFactory,并不需要修改 AbstractFactory 方法本身。 代码例子下面例子使用 javascript 编写。 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 新增每一个元素,还要修改抽象工厂,以将新元素与旧元素间建立联系,违背了开闭原则。 因此,对于已有元素固定的系统,适合使用抽象工厂,反之不然。 总结抽象工厂对新增已有产品的实现适用,对新增一个产品种类不适用,可以参考结合了例子的下图加深理解: 拓展一个熔岩素材包是 增加一种产品风格,适合使用抽象工厂设计模式;拓展一个陷阱是 增加一个产品种类,不适合使用抽象工厂设计模式。为什么呢?看下图: 创建迷宫这个抽象工厂做的事情,是把已有的房间、门、墙壁建立关联,因为操作的是抽象类,所以拓展一套具体实现(熔岩素材包)对这个抽象工厂没有感知,这样做很容易。 但如果新增一个产品种类 - 陷阱,可以看到,抽象工厂必须将陷阱与前三者重新建立关联,这就要修改抽象工厂,不符合开闭原则。同时,如果我们已有素材包 1 ~素材包 999,就需要同时增加 999 个对应的陷阱实现(普通陷阱、魔法陷阱、熔岩陷阱),其工作量会非常大。 因此,只有产品种类稳定时,需要频繁拓展产品风格时才适合用抽象工厂设计模式。 讨论地址是:精读《设计模式 - Abstract Factory 抽象工厂》· Issue ##271 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Adapter 适配器模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Adapter 适配器模式》.html","content":"当前期刊数: 172 Adapter(适配器模式)Adapter(适配器模式)属于结构型模式,别名 wrapper,结构性模式关注的是如何组合类与对象,以获得更大的结构,我们平常工作大部分时间都在与这种设计模式打交道。 意图:将一个类的接口转换成客户希望的另一个接口。Adapter 模式使得原本由于接口不兼容而不能在一起工作的那些类可以一起工作。 这个设计模式的意图很好懂,就是把接口不兼容问题抹平。注意,也仅仅能解决接口不一致的问题,而不能解决功能不一致的问题。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 接口转换器插座的种类很多,我们都用过许多适配器,将不同的插头进行转换,可以在不替换插座的情况下正常使用。 USB 接口转换也同样精彩,有将 TypeC 接口转换为 TypeA 的,也有将 TypeA 接口转换为 TypeC 的,支持双向转换。 接口转换器就是我们在生活中使用到的适配器模式,因为厂商并没有生产一个新的插座,我们也没有因为接口不适配而换一个手机,一切只需要一个接口转换器即可,这就是运用设计模式的收益。 数据库 ORMORM 屏蔽了 SQL 这一层,带来的好处是不需要理解不同 SQL 语法之间的区别,对于通用功能,ORM 会根据不同的平台,比如 Postgresql、Mysql 进行 SQL 的转换。 对 ORM 来说,屏蔽不同平台的差异,就是利用适配器模式做到的。 API Deprecated当一个广泛使用的库进行了含有 break change 的升级时,往往要留给开发者足够的时间去升级,而不能升级后就直接挂掉,因此被废弃的 API 要标记为 deprecated,而这种被废弃标记的 API 的实际实现,往往是使用新的 API 替代,这种场景正是使用了适配器模式,将新的 API 适配到旧的 API,实现 API Deprecated。 意图解释上面三个例子都满足下面两个条件: API 不兼容:因为接口的不同;数据库 SQL 语法的不同;框架 API 的不同。 但能力已支持:插座都拥有充电或读取能力;不同的 SQL 都拥有查询数据库能力;新 API 覆盖了旧 API 的能力。 这样就可以通过适配器满足 Adapter 的意图: 意图:将一个类的接口转换成客户希望的另一个接口。Adapter 模式使得原本由于接口不兼容而不能在一起工作的那些类可以一起工作。 结构图适配器的实现分为继承与组合模式。 下面是名词解释: Adapter 适配器,把 Adeptee 适配成 Target。 Adaptee 被适配的内容,比如不兼容的接口。 Target 适配为的内容,比如需要用的接口。 继承: 适配器继承 Adaptee 并实现 Target,适用场景是 Adaptee 与 Target 结构类似的情况,因为这样只需要实现部分差异化即可。 组合: 组合的拓展性更强,但工作量更大,如果 Target 与 Adaptee 结构差异较大,适合用组合模式。 代码例子下面例子使用 typescript 编写。 继承: interface ITarget { // 标准方式是 hello hello: () => void}class Adaptee { // 要被适配的类方法叫 sayHello sayHello() { console.log('hello') }}// 适配器继承 Adaptee 并实现 ITargetclass Adapter extends Adaptee implements ITarget { hello() { // 用 sayHello 对接到 hello super.sayHello() }} 组合: interface ITarget { // 标准方式是 hello hello: () => void}class Adaptee { // 要被适配的类方法叫 sayHello sayHello() { console.log('hello') }}// 适配器继承 Adaptee 并实现 ITargetclass Adapter implements ITarget { private adaptee: Adaptee constructor(adaptee: Adaptee) { this.adaptee = adaptee } hello() { // 用 adaptee.sayHello 对接到 hello this.adaptee.sayHello() }} 弊端使用适配器模式本身就可能是个问题,因为一个好的系统内部不应该做任何侨界,模型应该保持一致性。只有在如下情况才考虑使用适配器模式: 新老系统接替,改造成本非常高。 三方包适配。 新旧 API 兼容。 统一多个类的接口。一般可以结合工厂方法使用。 总结适配器模式也符合开闭原则,在不对原有对象改造的前提下,构造一个适配器就能完成模块衔接。 适配器模式的实现分为类与对象模式,类模式用继承,对象模式用组合,分别适用于 Adaptee 与 Target 结构相似与结构差异较大的场景,在任何情况下,组合模式都是灵活性最高的。 最后用一张图概括一下适配器模式的思维: 讨论地址是:精读《设计模式 - Adapter 适配器模式》· Issue ##279 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Builder 生成器》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Builder 生成器》.html","content":"当前期刊数: 168 Builder(生成器)Builder(生成器)属于创建型模式,针对的是单个复杂对象的创建。 意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 搭乐高积木乐高积木是很典型的随机拼装场景,你有很多乐高积木,要搭一个小房子都太复杂了,可能不得不看着说明书一步步操作,这就像创建一个复杂的对象,要传入非常多的参数,而且顺序还不能错。 如果不考虑拼装乐高过程中的乐趣,你只是想快速得到一个标准的房子,怎么样才可以最快最省事? 工厂流水线制作一个罐头要经历许多步骤,而其中一些步骤比如制作罐头是通用的,可以用这个罐头装很多东西,比如红枣罐头、黄桃罐头,那工厂流水线是怎么做到灵活可拓展的呢? 创建数据库连接池建立一个数据库连接池,我们需要传入数据库的地址、用户名与密码、还有要创建多少大小的连接池,缓存的位置等等。 考虑到数据库必须正确连接后才有效,创建时必须校验传入的数据库地址与密码的正确性,甚至存储方式与数据库类型还有关系,这是一个简单的 new 实例化可以解决的吗? 意图解释在乐高积木的例子中,我们为了得到一个房子其实不需要关心每一个积木应该如何摆放,我们只要交给组装工厂(一个人或者一个程序)产出标准房子就行了,这其中参数可能是 .setHouseType().build() 设置房屋类型,而不需要 new House(block1, block2, ... block999) 传递这些没必要的参数。其中组装工厂就是生成器。 在工厂流水线的例子中,流水线就是生成器,一个流水线可以不通过不同组合生成不同作用的工厂,黄桃罐头的流水线可以理解为 new Builder().组装罐头().放入黄桃().build(),红枣罐头的流水线可以理解为 new Builder().组装罐头().放入红枣().build(),我们可以复用生成器最基础的函数 组装罐头() 将其用于创建不同的产品中,复用了组装基础能力。 在创建数据库例子中,我们可以先设置一些必要的参数再创建,比如 new Builder().setUrl().setPassword().setType().build(),这样在最终执行 build 函数的时候,可以对参数中存在关联的进行校验,而得到的对象也无法再被修改,这样比直接暴露数据库连接池对象,再一个值一个值 Set 多了如下好处: 对象无法被修改,保护了程序稳定性,减少了维护复杂度。 可以对参数关联进行一次性校验。 在创建对象之前不会存在中间态,即创建了对象实例,但缺少部分参数,这可能导致对象无法正确 work。 意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。 我们再理解一次意图,所谓构建与表示分离,就是指一个对象 Person 并不是简单的 new Person() 就可以实例化出来的,如果可以,那就是构建与表示一体。所谓构建与表示分离,就是指 Person 只能描述,而不能通过 new Person() 实例化,将实例化工作通过 Builder 实现,这样同样一个构建过程可以创建不同的 Person 实例。 在乐高积木的例子中,通过乐高创建的房子并不是 new House() 出来,而是将构建与表示分离了,工厂流水线中我们创建一个黄桃罐头,不是通过 new 黄桃罐头(),而是通过流水线不同拼装方式来完成,在数据库例子中,我们没有通过 new DB() 的方式创建数据库,而是通过 Builder 来创建,这都体现了构建与表示的分离。 结构图 Director 指导器,用来指导构建过程。 Builder 生成器接口,用来提供一系列构建对象的方法,以及最终的 build 生成对象函数,这个函数里可以做一些参数校验。 ConcreteBuilder 是 Builder 的具体实现。 实际上,Builder 模式抽象层次可高可低,我们上面三个例子都没有用到指导器与生成器接口,这是因为在代码不太复杂的情况下,可以使用简化模型。 代码例子下面例子使用 javascript 编写。 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,只要两者都严格按照接口实现,我们可以: 替换任意 Director,使创建的过程做任意修改。 替换任意 Builder,使创建的实现做任意修改。 做了任意的改动,都可以得到不同的房子实现,这就是创建与表示分离的好处,我们可以通过同样的构建过程创建不同的表示。 这个 director.create(): 在搭乐高积木的例子,表示用乐高搭建房屋的过程。 在工程流水线的例子,表示罐头的组装构成。 在创建数据库连接池的例子,表示数据库连接池的创建过程。 而 Builder 以及其函数 buildA buildB 等方法表示具体制造方法,比如: 在搭乐高积木的例子,表示如何盖房子,如何刷油漆。 在工程流水线的例子,表示如何做一个罐头,如何添加黄桃。 在创建数据库连接池的例子,表示如何设置数据库地址,如何设置用户名密码等。 对于数据库的例子中,我们不仅可以保证创建对象的便捷性,因为不需要传入过多参数,也保证了对象的正确校验,同时生成的实例也是不可变的。 更重要的是,如果使用完整模式,我们可以替换 Director 来修改创建数据库的方式,替换 Builder 来修改具体方法,比如 .setUserName 这个函数不做具体实现,而是统计性能,build() 函数创建的不是一个数据库连接实例,而是一个测试实例。 再比如前端同一个方法在 JS 和 Node 环境下运行效果不一样,我们可以实现 BrowserBuild 与 NodeBuild,实现相同的接口,这样可以共享相同的创建过程,创建不同环境可以运行的实例。 可以看到,使用 Builder 模式可以保证创建对象的便捷与稳定性,还留了足够的拓展空间改变对象的创建过程与创建方法,具有极强的拓展性。 弊端任何设计模式都有其适用场景,反过来也说明了在某些场景下不适用。 实例化对象非常繁琐,重复定义了许多对象成员变量的 set 方法,而且也不如 new 看的直观,也就是场景足够简单时,不需要任何地方都用 Builder 实例化对象。 一个对象只有一种表示时,没必要做如此地步的抽象。 上面的例子都是相对复杂的,假设我们的搭房子的例子中,我们不是用乐高积木搭建,而是用两块半成品模板拼起来就得到一个房子,那就没有必要使用 Builder 模式,直接 new House() 即可。 再者,如果我们只需要生产各种罐头,而不需要生产汽车,那么就没必要过度抽象 Builder,把创建汽车的方法也囊括进去,最后,如果我们的对象只有一种表示时,没有必要抽象 Builder,也就是流水线如果只生产黄桃罐头,就没必要把各个生产环节变成可拆卸的,因为也没有重新组合的需要。 总结Builder 模式对于创建一个复杂对象特别有用,可以看下图加深理解: 最后总结一下何时适合用 Builder 模式:只有当创建过程允许被构造对象有不同表示,或者对象复杂到对象描述与创建对象过程值得分离时,才使用 Builder 设计模式。 讨论地址是:精读《设计模式 - Builder 生成器》· Issue ##273 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Bridge 桥接模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Bridge 桥接模式》.html","content":"当前期刊数: 173 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 能力隔离、将组件功能与平台功能隔离,只有做到了抽象部分与实现部分的隔离,才可以通过组合满足更多场景。 结构图 Abstraction:定义抽象类的接口。 RefinedAbstraction:扩充 Abstraction。 Implementor:定义实现类的接口,该接口可以与 Abstraction 接口不一致。 ConcreteImplementor:实现 Implementor 接口并定义它的具体实现。 抽象部分就是 Abstraction,实现部分就是 Implementor,在这个结构图中,它们是分离的,可以各自独立变化的,桥接模式,就是指 imp 这个桥,通过 Implementor 实现 Abstraction 接口,就算是桥接上了,这种组合的桥接相比普通的类实现更灵活,更具有拓展性。 代码例子对于完全版桥接模式,Implementor 可以有多套实现,Abstraction 不需关心具体用的是哪一种实现,而是通过抽象工厂方式封装。下面举一个简单版的例子。 下面例子使用 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 也可以利用其基础能力画线了。 弊端不要过度抽象,桥接模式是为了让类的职责更单一,维护更便捷,但如果只是个小型项目,桥接模式会增加架构设计的复杂度,而且不正确的模块拆分,把本来关联的逻辑强制解耦,在未来会导致更大的问题。 另外桥接模式也有简单与复杂模式之分,只有一种实现的场景就不要用抽象工厂做过度封装了。 总结桥接模式让我们重新审视类的设计是否合理,把类中不相关,或者说相互独立的维度抽出去,由桥接模式做桥接的方式使用,这样会使每个类功能更内聚,代码量更少更清晰,组合能力更强大,更容易做拓展。 下图做了一个简单的解释: 讨论地址是:精读《设计模式 - Bridge 桥接模式》· Issue ##280 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Chain of Responsibility 职责链模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Chain of Responsibility 职责链模式》.html","content":"当前期刊数: 179 Chain of Responsibility(职责链模式)Chain of Responsibility(职责链模式)属于行为型模式。行为型模式不仅描述对象或类的模式,还描述它们之间的通信模式,比如对操作的处理应该如何传递等等。 意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。 几乎所有设计模式,在了解到它之前,笔者就已经在实战中遇到过了,因此设计模式的确是从实践中得出的真知。但另一方面,如果没有实战的理解,单看设计模式是枯燥的,而且难以理解的,因此大家学习设计模式时,要结合实际问题思考。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 中间件机制设想我们要为一个后端框架实现中间件(知道 Koa 的同学可以理解为 Koa 的洋葱模型),在代码中可以插入任意多个中间件,每个中间件都可以对请求与响应进行处理。 由于每个中间件只响应自己感兴趣的请求,因此只有运行时才知道这个中间件是否会处理请求,那么中间件机制应该如何设计,才能保证其功能和灵活性呢? 通用帮助文案如果一个大型系统中,任何一个模块点击都会弹出帮助文案,但并不是每个模块都有帮助文案的,如果一个模块没有帮助文案,则显示其父级的帮助文案,如果再没有,就继续冒泡到整个应用,展示应用级别的兜底帮助文案。这种系统应该如何设计? JS 事件冒泡机制其实 JS 事件冒泡机制就是个典型的职责链模式,因为任何 DOM 元素都可以监听比如 onClick,不仅可以自己响应事件,还可以使用 event.stopPropagation() 阻止继续冒泡。 意图解释JS 事件冒泡机制对前端来说太常见了,但我们换个角度,站在点击事件的角度理解,就能重新发现其设计的精妙之处: 点击事件是叠加在每层 dom 上的,由于 dom 对事件的处理和绑定是动态的,浏览器本身不知道哪些地方会处理点击事件,但又要让每层 dom 拥有对点击事件的 “平等处理权”,所以就产生了冒泡机制,与事件阻止冒泡功能。 通用帮助文案和 JS 事件冒泡很类似,只是把点击事件换成了弹出帮助文案罢了,其场景机理是一样的。 说到这,我们可以再重新理解一下职责链模式的意图: 意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。 请求指的是某个触发机制产生的请求,是一个通用概念。“避免请求的发送者和接收者之间的耦合关系”,指的是如果我们只有一个对象有处理请求的机会,那接收者就与发送者之间耦合了,其他接收者必须通过这个接收者才能继续处理,这种模式不够灵活。 后半句描述的是如何设计,可以实现这个灵活的模式,即将对象连成一条链,沿着链条传递该请求,直到有一个对象处理它为止。还要理解到,任何一个对象都拥有阻断请求继续传递的能力。 在中间件机制的例子中,后端 Web 框架对 Http 请求的处理就是个运用职责链模式的典型案例,因为后端框架要处理的请求是平行关系,任何请求都可能要求被响应,但对请求的处理是通过插件机制拓展的,且对每个请求的处理都是一个链条,存在处理、加工、再处理的逻辑关系。 结构图 Handler 就是对请求的处理,可以看到这里是一条环路,只要处理完之后就可以交给下一个 Handler 进行处理,可以在中途拦截后中断,也可以穿透整条链路。 ConcreteHandler 是具体 Handler 的实现,他们都需要继承 Handler 以具备相同的 HandleRequest 方法,这样每一个处理中间件就都拥有了处理能力,使得这些对象连成的链条可以对请求进行传递。 代码例子职责链实现方式非常多,比如 Koa 的洋葱模型实现原理就值得再写一篇文章,感兴趣的同学可以阅读 co 源码。这里仅介绍最简单场景的实现方案。 职责链的简单实现模式也分为两种,一种是每个对象本身维护到下一个对象的引用,另一种是由 Handler 维护后继者。 下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Command 命令模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Command 命令模式》.html","content":"当前期刊数: 180 Command(命令模式)Command(命令模式)属于行为型模式。 意图:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 点菜是命令模式为什么顾客会找服务员点菜,而不是直接冲到后厨盯着厨师做菜?因为做菜比较慢,肯定会出现排队的现象,而且有些菜可能是一起做效率更高,所以将点菜和做菜分离比较容易控制整体效率。 其实这个社会现象就对应编程领域的命令模式:点菜就是一个个请求,点菜员记录的菜单就是将请求生成的对象,点菜员不需要关心怎么做菜、谁来做,他只要把菜单传到后厨即可,由后厨统一调度。 大型软件系统的操作菜单大型软件操作系统都有一个特点,即软件非常复杂,菜单按钮非常多。但由于菜单按钮本身并没有业务逻辑,所以通过菜单按钮点击后触发的业务行为不适合由菜单按钮完成,此时可利用命令模式生成一个或一系列指令,由软件系统的实现部分来真正执行。 浏览器请求排队浏览器的请求不仅会排队,还会取消、重试,因此是个典型的命令模式场景。如果不能将 window.fetch 序列化为一个个指令放入到队列中,是无法实现请求排队、取消、重试的。 意图解释意图:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。 一个请求指的是来自客户端的一个操作,比如菜单按钮点击。重点在点击后并不直接实现,而是将请求封装为一个对象,可以理解为从直接实现: function onClick() { // ... balabala 实现逻辑} 改为生成一个对象,序列化这个请求: function onClick() { concreteCommand.push({ // ... 描述这个请求 }) // 执行所有命令队列 concreteCommand.executeAll()} 看上去繁琐了一些,但得到了后面所说的好处:“从而使你可用不同的请求对客户进行参数化”,也就是可以对任何请求进行参数化存储,我们可以在任意时刻调用。 这相当于掌握了执行时机,可以在任意时刻调用,以实现排队或记录日志,如果再记录下反向操作信息,就可以实现撤销重做了。 结构图 Command 是命令的接口,一般固定有一个 execute 方法。 ConcreteCommand 是命令接口的实现,它会注入具体执行者 Receiver,它实现的 execute 方法会调用 receiver.execute 来具体执行。 Invoker 是执行请求的命令,其实上面都在推入命令,并没有真正执行,如果排队结束或点击撤销重做时,就触发了 Invoker 实际,就该调用对应的 Command 执行啦。 代码例子下面例子使用 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(): class Invoker { push(command) { // 队列里推入命令 this.commands.push(command) } execute() { this.commands.forEach(command => command.execute()) // 别忘了清空 this.commands }} 弊端命令模式需要注意序列化大小,一般分为: 仅记录操作。 记录全量快照。 全量快照共享内存。 记录操作是较为精细的管理方式,并且可以延伸出协同编辑功能。记录快照要注意尽量共享内存,防止快照过大,而且协同编辑场景因为快照无法做冲突处理,所以快照模式在协同编辑场景无法应用。 另外要识别没必要使用命令模式的场景,对于没有撤销重做的前端大部分场景来说,都无需改为命令模式。 总结命令模式本质上就是将操作抽象为可序列化的命令,使操作可以在合适的时间执行,这种设计带来了许多额外好处。 利用命令模式可以达到高内聚低耦合的效果,提升代码可维护性,也可以实现撤销重做、协同编辑等功能性需求。 讨论地址是:精读《设计模式 - Command(命令模式)》· Issue ##295 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Composite 组合模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Composite 组合模式》.html","content":"当前期刊数: 174 Composite(组合模式)Composite(组合模式)属于结构型模式,是一种统一管理树形结构的抽象方式。 意图:将对象组合成树形结构以表示 “部分 - 整体” 的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 公司组织关系树公司组织关系可能分为部门与人,其中人属于部门,有的人有下属,有的人没有下属。如果我们统一将部门、人抽象为组织节点,就可以方便的统计某个部门下有多少人、财务数据等等,而不用关心当前节点是部门还是人。 操作系统的文件夹与文件操作系统的文件夹与文件也是典型的树状结构,为了方便递归出文件夹内文件数量或者文件总大小,我们最好设计的时候就将文件夹与文件抽象为文件,这样每个节点都拥有相同的方法添加、删除、查找子元素,而不需要关心当前节点是文件夹或是文件。 搭建平台的组件与容器容器与组件的关系很小,用户常常认为容器也是一种组件,但搭建平台实现时,容器与组件稍有不同,不同之处在于容器可以嵌套子元素,而组件不可以。如果因此搭建平台就将组件分为容器与组件,会导致 API 割裂为两套,不利于组件开发者维护与用户理解,比较好的设计思路是将组件与容器统一看成组件,组件只是一种没有子元素的特殊容器,这样组件与容器就可以拥有相同的 API,统一理解与操作了。 意图解释意图:将对象组合成树形结构以表示 “部分 - 整体” 的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。 比较好理解,组合是指多个对象虽然有一定差异,但共同组合成了一个树形结构,那么对象之间就一定存在 “部分 - 整体” 的关系,组合模式要求我们抽象一个对象 Component 作为统一操作模型,叶子结点与非叶子结点都实现了所有功能,即便是没有子元素的叶子结点,为了强调透明性,还是具备比如 getChildren 方法,只不过永远都返回 null。 结构图 其中 Component 是组合中对象声明接口,一般会实现所有公共类的所有接口,还要提供一个接口管理其子组件。 Leaf 表示叶子结点,没有子结点,相应的 Composite 就是有子结点的节点。 可以看到,组合模式就是将树状结构中所有节点统一抽象了,我们不需要关心叶子结点与非叶子结点的差异,而可以通过组合模式的抽象屏蔽掉这些差异,统一处理。 代码例子下面例子使用 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 可能会失败,其本质上还是将节点的区别暴露给了业务层。 总结组合模式是针对树状结构这个特定场景的统一抽象方案,对降低系统复杂度有很重要的意义,同时也不要忘了过度抽象是有害的,我们要拿捏其中的度。 下图做了一个简单的解释: 程序中始终关注 Component 就行了,树状结构的差异已经被抹平。 讨论地址是:精读《设计模式 - Composite 组合模式》· Issue ##284 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Decorator 装饰器模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Decorator 装饰器模式》.html","content":"当前期刊数: 175 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 模式相比生成子类更为灵活” 这句话的含义是,组合比继承更灵活,当可拓展的功能很多时,继承方案会产生大量的子类,而组合可以提前写好处理函数,在需要时动态构造,显然是更灵活的。 结构图 ConcreteComponent 指的是需要被装饰的组件,可以看到,装饰器 Decorator 与他都继承同一个类,这样能保证 API 的一致,才保证无论装饰多少层,始终符合 Component 类型。 装饰器如果有多种,就要将 Decorator 申明为抽象类,ConcreteDecoratorA、ConcreteDecoratorB 分别实现它们,如果只有一种装饰器,可以退化到 Decorator 自身就是一种实现。 代码例子下面例子使用 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 是一个透明的包装,只要保证包装的透明性,就可以最大限度发挥装饰器模式的优势。 最后总结一个装饰器应用图: 讨论地址是:精读《设计模式 - Decorator 装饰器模式》· Issue ##286 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Facade 外观模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Facade 外观模式》.html","content":"当前期刊数: 176 Facade(外观模式)Facade(外观模式)属于结构型模式,是一种日常开发中经常被使用到的设计模式。 意图:为子系统中的一组接口提供一个一致的界面,Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 意图解释图书管理员图书馆是一个非常复杂的系统,虽然图书按照一定规则摆放,但也只有内部人员比较清楚,作为一位初次来的访客,想要快速找到一本书,最好的办法是直接问图书管理员,而不是先了解这个图书馆的设计,因为你可能要来回在各个楼宇间奔走,借书的流程可能也比较长。 图书管理员就起到了简化图书馆子系统复杂度的作用,我们只要凡事询问图书管理员即可,而不需要关心他是如何与图书馆内部系统打交道的。 最多跑一次便民服务浙江省推出的最多跑一次服务非常方便,很多办事流程都简化了,无论是证件办理还是业务受理,几乎只要跑一次,而必须要持续几天的流程也会通过手机短信或者 App 操作完成后续流程。 这就相当于外观模式,因为政府系统内部的办事流程可能没有太大变化,但通过抽象出 Facade(外观),让普通市民可以直接与便民办事处连接,而不需要在车管所与驾校之间来回奔波,背后的事情没有少,只是便民办事处帮你做了。 Iphone 快捷指令功能手机的 App 非常多,而我们需要了解每个功能在哪个 App 上才能运用自如,而快捷指令功能可以将 App 的某些功能单独提取出来,形成一套新的功能组,我们可以只接触到 “拍照” “付款” “计算”,而不用管背后是调用了支付宝还是微信、系统内置摄像机还是其他摄像 App,也不用关心这个 App 内部功能的入口在哪里,这些对接都在快接指令中自动完成。 快捷指令也是一种外观模式。 意图解释意图:为子系统中的一组接口提供一个一致的界面,Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。 为降低一个拥有多个接口的子系统内部复杂性,我们需要一个外观来屏蔽内部的复杂性,因此外观模式就是定义一个高层接口,这个接口直连子系统的内部实现,但调用这个高层接口的人不需要关心子系统内部的实现,这样,对于不想了解子系统内部实现的人来说,提高了易用度。 当然如果想要深度定制,就可以绕过外观模式,直接使用子系统提供的类,所以说并不是有了外观模式就必须通过外观调用,而是根据实际需要判断使用哪种调用方式。 结构图 可以看到,Facade 直接指向子系统中的类,而子系统的类不会反向指向 Facade。 代码例子下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Factory Method 工厂方法》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Factory Method 工厂方法》.html","content":"当前期刊数: 169 Factory Method(工厂方法)Factory Method(工厂方法)属于创建型模式,利用工厂方法创建对象实例而不是直接用 New 关键字实例化。 理解如何写出工厂方法很简单,但理解为什么要用工厂方法就需要动动脑子了。工厂方法看似简单的将 New 替换为一个函数,其实是体现了面向接口编程的思路,它创建的对象其实是一个符合通用接口的通用对象,这个对象的具体实现可以随意替换,以达到通用性目的。 意图:定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method 使一个类的实例化延迟到其子类。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 换灯泡我自己在家换过灯泡,以前我家里灯坏掉的时候,我看着这个奇形怪状的灯管,心里想,这种灯泡和这个灯座应该是一体的,市场上估计很难买到适配我这个灯座的灯泡了。结果等我把灯泡拧下来,跑到门口的五金店去换的时候,店员随便给了我一个灯泡,我回去随便拧了一下居然就能用了。 我买这个灯泡的过程就用到了工厂模式,而正是得益于这种模式,让我可以方便在家门口就买到可以用的灯泡。 卡牌对战游戏卡牌对战中,卡牌有一些基本属性,比如攻防、生命值,也符合一些通用约定,比如一回合出击一起等等,那么对于战斗系统来说,应该怎样实例化卡牌呢?如何批量操作卡牌,而不是通用功能也要拿到每个卡牌的实例才能调用?另外每个卡牌有特殊能力,这些特殊能力又应该如何拓展呢? 实现任意图形拖拽系统一个可以被交互操作的图形,它可以用鼠标进行拉伸、旋转或者移动,不同图形实现这些操作可能并不相同,要存储的数据也不一样,这些数据应该独立于图形存储,我们的系统如果要对接任意多的图形,具备强大拓展能力,对象关系应该如何设计呢? 意图解释在使用工厂方法之前,我们就要创建一个 用于创建对象的接口,这个接口具备通用性,所以我们可以忽略不同的实现来做一些通用的事情。 换灯泡的例子来说,我去门口五金店买灯泡,而不是拿到灯泡材料自己 New 一个出来,就是因为五金店这个 “工厂” 提供给我的灯泡符合国家接口标准,而我家里的灯座也符合这个标准,所以灯座不需要知道对接的灯泡是具体哪个实例,什么颜色,什么形状,这些都无所谓,只要灯泡符合国家标准接口,就可以对接上。 对卡牌对战的系统来说,所有卡牌都应该实现同一种接口,所以卡牌对战系统拿到的卡牌应该就是简单的 Card 类型,这种类型具备基本的卡片操作交互能力,系统就调用这些能力完成基本流程就好了,如果系统直接实例化具体的卡片,那不同的卡片类型会导致系统难以维护,卡片间操作也无法抽象化。 正是这种模式,使得我们可以在卡牌的具体实现上做一些特殊功能,比如修改卡片攻击时效果,修改卡牌销毁时效果。 对图形拖拽系统来说,用到了 “连接平行的类层次” 这个特性,所谓连接平行的类层次,就是指一个图形,与其对应的操作类是一个平行抽象类,而一个具体的图形与具体的操作类则是另一个平行关系,系统只要关注最抽象的 “通用图形类” 与 “通用操作类” 即可,操作时,底层可能是某个具体的 “圆类” 与 “圆操作类” 结合使用,具体的类有不同的实现,但都符合同一种接口,因此操作系统才可以把它们一视同仁,统一操作。 意图:定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method 使一个类的实例化延迟到其子类。 所以接口是非常重要的,工厂方法第一句话就是 “定义一个用于创建对象的接口”,这个接口就是 Creator,让子类,也就是具体的创建类(ConcreteCreator)决定要实例化哪个类(ConcreteProduct)。 所谓使一个类的实例化延迟到其子类,是因为抽象类不知道要实例化哪个具体类,所以实例化动作只能由具体的子类去做,这样绕一圈的好处是,我们可以将任意多对象看作是同一类事物,做统一的处理,比如 无论何种灯泡实例都满足通用的灯座接口,所有工厂实例化的卡牌都具备玩一局卡牌游戏的基本功能,任何图形与交互类都满足特定功能关系,这种思想让生活和设计得到了大幅简化。 结构图 Creator 就是工厂方法,ConcreteCreator 是实现了 Creator 的具体工厂方法,每一个具体工厂方法生产一个具体的产品 ConcreteProduct,每个具体的产品都实现通用产品的特性 Product。 代码例子下面例子使用 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: function main(anyCreator: Creator) { const product = anyCreator.createProduct()} 在外面调用 main 函数时,实际传进去的是一个具体工厂,比如 myCreator,但关键是 main 函数不用关心到底是哪一个具体工厂,只要知道是个工厂就行了,具体对象创建过程交给了其子类。 你也许也发现了,这就是抽象工厂中其中的一步,所以抽象工厂使用了工厂方法。 弊端工厂方法中,每创建一种具体的子类,就要写一个对应的 ConcreteCreate,这相对比较笨重,但有意思的是,如果将创建多个对象放到一个 ConcreteCreate 中,就变成了 简单工厂模式,新增产品要修改已有类不符合开闭模式,反而推荐写成本文说的这种模式。 彼之毒药吾之蜜糖,要知道没有一种设计模式解决所有问题,没有一种设计模式没有弊端,而这个弊端不代表这个设计模式不好,一个弊端的出现可能是为了解决另一个痛点。 要接受不完美的存在,这么多种设计模式就是对应了不同的业务场景,为合适的场景选择一种能将优势发扬光大,以至于能掩盖弊端,就算进行了合理的架构设计。 总结工厂方法并不是简单把 New 的过程换成了函数,而是抽象出一套面向接口的设计模式: 你看,我要做灯泡,可以直接做具体的灯泡,也可以定一个灯泡接口,通过灯泡工厂拿到具体灯泡,灯泡工厂对待所有灯泡的只做流程都是一样的,不管是中世纪风灯泡,还是复古灯泡,还是普通白织灯,都是一模一样的制作流程,具体怎么做由具体的子类去实现,这样我们可以统一管理 “灯泡” 这一个通用概念,而忽略不同灯泡之间不太重要的差别,程序的可维护性得到了大幅提升。 讨论地址是:精读《设计模式 - Factory Method 工厂方法》· Issue ##274 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Interpreter 解释器模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Interpreter 解释器模式》.html","content":"当前期刊数: 181 Interpreter(解释器模式)Interpreter(解释器模式)属于行为型模式。 意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器。这个解释器使用该表示来解释语言中的句子。 任何一门语言,无论是日常语言还是编程语言都有明确的语法,只要有语法就可以用文法描述,并通过语法解释器将字符串的语言结构化。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 SQL 解释器SQL 是一种描述语言,所以也适用于解释器模式。不同的 SQL 方言有不同的语法,我们可以根据某种特定的 SQL 方言定制一套适配它的文法表达式,再利用 antlr 解析为一颗语法书。在这个例子中,antlr 就是解释器。 代码编译器程序语言也因为其天然是字符串的原因,和 SQL、日常语言都类似,需要一种模式解析后才能工作。不同的语言有不同的文法表示,我们只需要一个类似 antlr 的通用解释器,通过传入不同的文法表示,返回不同的对象结构。 自然语言处理自然语言处理也是解释器的一种,首先自然语言处理一般只能处理日常语言的子集,因此先定义好支持的范围,再定义一套分词系统与文法表达式,并将分词后的结果传入灌入了此文法表达式的解释器,这样解释器可以返回结构化数据,根据结构化数据再进行分析与加工。 意图解释意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器。这个解释器使用该表示来解释语言中的句子。 对于给定的语言,可以是 SQL、代码或自然语言,“定义它的文法的一种表示” 即文法可以有多种表示,只需定义一种。要注意的是,不同文法执行效率会有差异。 “并定义一个解释器”,这个解释器就是类似 antlr 的东西,传给它一个文法表达式,就可以解析句子了。即:解释器(语言, 文法) = 抽象语法树。 我们可以直接把文法定义耦合到解释器里,但这样做会导致语法复杂时,解释器难以维护。比较好的方式是定义一套与解释器解耦的文法表达式,通过预处理器最终生成解释器。 结构图 Context 是其他上下文变量,AbstractExpression 是抽象语法表达式。 可以看到,TerminalExpression(终结符)与 NonterminalExpression(非终结符) 都继承于 AbstractExpression,终结符指的是没有后续展开的符号,非终结符相反,所以非终结符又指向了 AbstractExpression,如此递归。 代码例子下面例子使用 typescript 编写。 假设我们要实现以下文法: sum ::= number + numbernumber ::= 1 | 2 表达一个最简单的加法文法,其中加法表达式 sum 和 number 都是非终结符,而 +、1、2 是终结符。这个例子只能做到 1 与 2 的加法,通过这个简单例子,了解一下解释器模式的精髓吧: // 抽象表达式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") // trueadd.interpreter("1 + 2") // trueadd.interpreter("1 + 3") // falseadd.interpreter("2 - 1") // false 遇到非终结符则继续调用,只有终结符才能直接判断,原理很简单。 弊端上面的例子是比较低效场景,因为当语法复杂后,类的数目会明显增多,难以维护,此时需要用一个通用语法解析器,了解更多可以看笔者之前的文章:精读《手写 SQL 编译器 - 语法分析》 系列。 总结解释器是一种思维,将复杂语法解析抽象为一个个独立的终结符与非终结符各自判断,只要每个文法自己的判断做好了,剩下的工作就是组装文法。 这种将单个逻辑判断与文法组装解耦的做法,可以使逻辑判断与文法组装独立变换,使复杂语法解析转化为一个个具体的简单问题。 讨论地址是:精读《设计模式 - Interpreter 解释器模式》· Issue ##296 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Flyweight 享元模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Flyweight 享元模式》.html","content":"当前期刊数: 177 Flyweight(享元模式)Flyweight(享元模式)属于结构型模式,是一种共享对象的设计模式。 意图:运用共享技术有效地支持大量细粒度的对象。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 富文本编辑器的字母对象富文本编辑器在英文环境下,其中的文本由大量字母组成,为了便于做统一的格式化、计算等处理,需要将每个字母都存储为对象,但这样存储的代价太大了。 已知英文字母一共 26 个,所以文档中存在大量重复使用的字母,而每个字母除了位置信息外,其它信息都是相同且只读的,那么有办法降低富文本场景巨大的字母对象数量吗? 网盘存储当我们上传一部电影时,有时候几十 GB 的内容不到一秒就上传完了,这是网盘提示你,“已采用极速技术秒传”,你会不会心生疑惑,这么厉害的技术为什么不能每次都生效? 另外,网盘存储时,同一部电影可能都会存放在不同用户的不同文件夹中,而且电影文件又特别巨大,和富文本类似,电影文件也只有存放位置是不同的,而其余内容都特别巨大且只读,有什么办法能优化存储呢? 大型多人游戏玩多人游戏时,为了防止外挂,一般对象的创建与计算是在服务器完成的,那如何保证一个玩家拾取物品后,另一个玩家看到的物品会消失? 其实道理已经不言而喻了,虽然在不同客户端之间,游戏对象是相互独立的,但在一局游戏中,所有玩家的对象在服务器是共享的。 意图解释“共享” 就是享元模式的精髓,将那些大量的,具有很多内部状态而外部状态很少的对象进行共享,就是享元模式的使用方式。 意图:运用共享技术有效地支持大量细粒度的对象。 共享技术可以理解为缓存,当一个对象创建后,再次访问相同对象时,就不再创建新的对象了,而只有在访问没有被缓存过的对象时,才创建新对象,并立即缓存起来。 这样做可以有效支持大量细粒度的对象,在富文本例子中,无数的字母就是大量细粒度对象,在网盘存储中,电影文件就是大量细粒度对象,在大型多人游戏中,每局游戏内存在大量细粒度对象。 这些细粒度对象都拥有相同的特征: 量特别大,这个很容易理解。 具有大量内部状态,且不随着客户端的不同而改变。 富文本的字母,不因为展示到不同语句中而发生变化,变化的只有状态;电影文件,不因为放在不同用户的文件夹中而对电影内容产生变化,变化的只有属于哪些用户,放在哪些文件夹里;多人游戏中,同一把武器对象,不因为有多个人的电脑独立运行而拥有更多的弹药,变化的只有在哪些客户端被访问。 具有少量外部状态,甚至没有外部状态。在上面已经解释了,字母的位置、电影的位置、游戏对象的客户端都是外部状态,这些外部状态相比于其内部状态来说,大小微乎其微,且方便分离存储。 遇到这种情况,我们就可以将对象内部状态共享,外部状态独立存储,从而节省大量空间。 尤其是对于网盘的场景,承诺给用户 2 TB 的存储空间,这个用户看到其他人分享了 100 个电影,就点击 “下载到我的网盘”,此时虽然占用了自己 1 TB 的网盘空间,但实际上网盘运营商并没有增加 1 TB 的存储空间,实际可能增加了 1kb 的存储空间,记录了存储位置,这就是网盘鸡贼的地方,并不占用空间的内容,却占用了用户真金白银购买的存储空间。 当然,这就是享元模式的价值,对网盘公司来说,价值巨大,对用户来说,没有价值。所以享元模式的价值体现在全局,比如对整个富文本编辑器来说,减少了巨量字母对象数量,但对于每一个字母对象而言,并没有任何优化。 结构图 对于 Client 而言,下图描述了如何共享 Flyweight: Flyweight: 共享接口,通过这个接口可以操作对象的外部状态。 ConcreteFlyweight: 实现 Flyweight 接口的对象,这个对象是可被共享的。 UnsharedConcreteFlyweight: 不被共享的对象,因为在享元模式中,实际上并不是所有对象都可以被共享。 FlyweightFactory: 创建并管理 Flyweight 对象,通过其返回的 Flyweight 对象,如果已创建,则会返回之前创建的那个,没有的话才会创建一个新的。 Client: 使用 Flyweight 的客户端。 通过第二个图可以明显看到,两个不同的 Client 持有了相同 aConcreteFlyweight 引用。 代码例子下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Iterator 迭代器模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Iterator 迭代器模式》.html","content":"当前期刊数: 182 Iterator(迭代器模式)Iterator(迭代器模式)属于行为型模式。 意图:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。 这种设计模式要解决的根本问题是,聚合的种类有很多,比如对象、链表、数组、甚至自定义结构,但遍历这些结构时,不同结构的遍历方式又不同,所以我们必须了解每种结构的内部定义才能遍历。 比如数组我们可以利用 length + for 循环,对象我们可以 Object.keys,链表比较麻烦,需要内部暴露出元素的 next 以操作指向下一个元素。 迭代器模式可以做到用同一种 API 遍历任意类型聚合对象,且不用关心聚合对象的内部结构。 这种模式和 Array.from 有点像,但其实真正的迭代器在 JS 里是 obj[Symbol.iterator](),也就是一个对象实现了 [Symbol.interator],就认为是可遍历的。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 迭代器的例子非常简单,我们平时工作中有大量使用到。 generatorgenerator 天生为迭代器的 API: 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 的场景中,迭代器不仅用来遍历聚合,还用于执行代码。 数组迭代器我们可以用迭代器的方式遍历数组: 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 对象也可以用迭代器方式遍历: 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 内部的存储结构也没有意义,如果我们不用迭代器进行遍历,那么对于复杂结构的遍历成本是非常高的。 结构图 Aggregate: 聚合,需要定义创建迭代器的接口。比如前端规范的 [Symbol.iterator](),或者这里定义的 CreateIterator()。 Iterator: 迭代器,定义了访问与遍历的 API。 迭代器的定义很简单,实现时要考虑的因素可不少,包括: 健壮性。即迭代过程中增加、删除元素后,还能正常遍历。或者遍历空聚合时也要能正常工作。 外部控制迭代还是内部。即类似 KOA 由插件调用 next() 控制迭代,还是由外层统一控制迭代。 如何定义遍历算法。即便对于对象这种简单场景,也存在深度优先和广度优先、冒泡与捕获这几种遍历顺序,迭代器可以提供选择或者拓展的方式,自定义遍历算法。 代码例子下面例子使用 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 循环会比迭代器方便很多,没必要为了用设计模式而用设计模式。迭代器仅在以下情况可以考虑用于数组: 这个数组比较特殊,是 N 维数组,需要一次性遍历完,那么可以用迭代器。 同时遍历数组和其他类型的聚合,则不论数组还是其他聚合,都用相同的迭代器模式遍历最好。 总结迭代器模式比较好理解,这里补充几个相关设计模式: 迭代器可以和组合模式配合,在组合结构内进行递归,这样一个迭代器就可以遍历完所有组合。 可以用工厂模式 + 多态模式,实例化不同的迭代器的实例。 迭代器模式还可以与备忘录模式配合使用,当我们要还原迭代器状态时,适合在迭代器内部使用备忘录模式进行状态存储。 讨论地址是:精读《设计模式 - Iterator 迭代器模式》· Issue ##298 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Mediator 中介者模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Mediator 中介者模式》.html","content":"当前期刊数: 183 Mediator(中介者模式)Mediator(中介者模式)属于行为型模式。 意图:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。 前端开发中,最常用的 “数据驱动” 其实就最好的诠释了中介者模式。 想一个这样的场景: 按钮点击后,表单提交。按钮需要调用所有表单项获取表单值。 表单关联,当勾选了城市后,才出现满意度 Input 框,此时城市勾选按钮需要引用满意度 Input 框。 甚至会出现循环引用,两个输入框是互斥的,输入了一个,另一个输入框就要 Disable。 当新增加一个表单项时,需要重新建立所有引用关系。 以上过程式编程方式,维护大型项目几乎是不可能的。然而数据驱动可以很好的解决这个问题,所有表单项都依赖数据,并修改数据,这样当 Input 框联动 Check 时,Input 并不需要感知 Checkbox 的存在,他只要关联数据、修改数据就行了,Checkbox 也只要关联数据和修改数据,这样不但逻辑可以独立完成,甚至可以解决循环引用的问题。 在数据驱动的例子中,数据就是中介。 所有 UI 之间都不会相互引用,而是通过数据这个中介来协同工作,这样做带来的明显好处是可以处理复杂项目,且易于维护。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 数据驱动正如开篇说的,数据驱动是中介者非常经典的例子,正是因为引入了 “数据中介者”,才让前端项目的复杂度可以呈几何倍数递增,而代码的逻辑复杂度仅线性递增。因为 UI 是杂乱的且动态的,UI 间依赖会导致关系网非常复杂,且关系网一旦形成,增加一个新元素或修改就变得异常困难。 中介者模式则避开了 UI 间依赖的关系网,通过数据层统一调度,UI 受控响应,可以大大减少逻辑复杂度。 解决循环依赖循环依赖几乎只能利用中介者模式解决: import { b } from './b'export const a = 'a' import { a } from './a'export const b = 'b' 当双方相互引用时,构成循环依赖,不仅对于模块化来说是有问题的,从逻辑上也是讲不通的,因为一定存在递归调用的问题。这是,引入第三方中介者就不仅仅是一种设计模式思维了,而是 a、b 模块中原本就有一些内容是两边公用的,一定需要提出来,而统一提出来的地方就是中介者模式的中介者部分。 企业组织架构一个树状企业组织架构中,每个非叶子结点都是中介者,需要给他的子节点分配任务,并协调他们的工作,这样一来,叶子结点不需要有全局观即可工作,因为他们只需负责 “去做自己的事情”,而不需要关心 “是如何协同的”。 如图所示,环境部不需要关心人事部做了什么,只要专注做好环境事物即可,他们之间的协调由总经理处理,这是一种分工协作的体现。 而只存在于理论中的网状企业管理模型,则是没有中介者的例子,所有节点都是非叶子结点,并相互引用,这样一来每个人既要做自己的工作,又要处理自己与公司里其他几万人的协同,几乎是一件不可能完成的事情,所以从设计模式角度来看,也更倾向于使用树状而不是网状模式管理企业。 意图解释意图:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。 中介者模式非常好理解,直接看字面意思即可。所谓的对象交互,指的是对象之间是如何协同的,中介者做的是处理对象间协同的工作,而不是 “替每个对象干活”。 最后一句 “可以独立地改变他们之间的交互”,指的是对象之间协同方式不是一成不变的,比如一个输入框组件,只要实现自己的输入功能就行了,而不需要关心是如何与外界交互的。外界可以通过将其嵌入到表单中,成为表单项的一部分,也可以将其包裹一层符号后缀,成为一个专门输入金额的金额输入框。 结构图 Mediator:中介者接口,定义一些通信 API。 ConcreteMediator:具体的中介者,继承 Mediator,协调各个对象。 Colleague:同事类,比如之前提到的输入框、文本框,每个同事之间只要知道中介者即可,他们之间不需要知道对方的存在。 代码例子下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Memoto 备忘录模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Memoto 备忘录模式》.html","content":"当前期刊数: 184 Memento(备忘录模式)Memento(备忘录模式)属于行为型模式,是针对如何捕获与恢复对象内部状态的设计模式。 意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。 其实备忘录模式思想非常简单,其核心是定义了一个 Memoto(备忘录) 封装对象,由这个对象处理原始对象的状态捕获与还原,其他地方不需要感知其内部数据结构和实现原理,而且 Memoto 对象本身结构也非常简单,只有 getState 与 setState 一存一取两个方法,后面会详细讲解。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 撤销重做如果撤销重做涉及到大量复杂对象,每个对象内部状态的存储结构都不同,如果一个一个处理,很容易写出 case by case 的冗余代码,而且在拓展一种新对象结构时(如嵌入 ppt),还需要在撤销重做时对相应结构做处理。备忘录思维相当于一种统一封装思维,不管这个对象结构如何,都可以保存在一个 Memoto 对象中,通过 setState 设置对象状态与 getState 获取对象状态,这样对于任何类型的对象,画布都可以通过统一的 API 操作进行存取了。 游戏保存玩过游戏的同学都知道,许多游戏支持设置与读取多种存档,如果转换为代码模式,我们可能希望有这样一种 API 进行多存档管理: // 创建一盘游戏。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 这个抽象的备忘录类。 结构图 Originator:创建、读取备忘录的发起者。 Memento:备忘录,专门存储原始对象状态,并且防止 Originator 之外的对象读取。 Caretaker:备忘录管理者,一般用数组或链表管理一堆备忘录,在撤销重做或者版本管理时会用到。 代码例子下面例子使用 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(); }} 下面是一个简化版客户端使用的例子: // 实例化发起者,比如画布、文章管理器、游戏管理器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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Observer 观察者模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Observer 观察者模式》.html","content":"当前期刊数: 185 Observer(观察者模式)Observer(观察者模式)属于行为型模式。 意图:定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。 拿项目的 npm 依赖举例子:npm 包与项目是一对多的关系(一个 npm 包被多个项目使用),当 npm 包发布新版本时,如果所有依赖于它的项目都能得到通知,并自动更新这个包的版本号,那么就解决了包版本更新的问题,这就是观察者模式要解决的基本问题。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 对象与视图双向绑定在 精读《设计模式 - Proxy 代理模式》 中我们也提到了双向绑定概念,只不过代理是实现双向绑定的一个具体方案,而观察者模式才是在描述双向绑定这个概念。 观察者模式在最初提出的时候,就举了数据与 UI 相互绑定的例子。即同一份数据可以同时渲染为表格与柱状图,那么当操作表格更新数据时,如何让柱状图的数据也刷新?从这个场景引出了对观察者模式的定义,即 “数据” 与 “UI” 是一对多的关系,我们需要一种设计模式实现当 “数据” 变化时,所有依赖于它的 “UI” 都得到通知并自动更新。 拍卖拍卖由一个拍卖员与多为拍卖者组成。拍卖时,由 A 同学喊出的竞价(我出 100)就是观察者向目标发出的 setState 同时,此时拍卖员喊出(有人出价 100,还有更高的吗?)就是一个 notify 通知行为,拍卖员通知了现场竞价全员,刷新了他们对当前最高价的信息。 聊天室聊天室由一个中央服务器与多个客户端组成。客户端发送消息后,就是向中央服务器发送了 setState 更新请求,此时中央服务器通知所有处于同一聊天室的客户端,更新他们的信息,从而完成一次消息的发送。 意图解释数据与 UI 的例子已经详细说明了其意图含义,这里就不赘述了。 结构图 Subject: 目标,即例子中的 “数据”。 Observer: 观察者,即例子中的 “表格”、“柱状图”。 还是以数据与 UI 同步为例,当表格发生操作修改数据时,表格这个 TableObserver 会调用 Subject(数据)的 setState,此时数据被更新了。然后数据这个 Subject 维护了所有监听(包括表格 TableObserver 与柱状图 ColumnChartObserver),此时 setState 内会调用 notify 遍历所有监听,并依次调用 Update 方法,每个监听的 Update 方法都会调用 getState 获取最新数据,从而实现表格更新后 -> 更新数据 -> 表格、柱状图同时刷新。 为了更好的理解,以这张协作图为例: aConcreteSubject: 对应例子中的数据。 aConcreteObserver: 对应例子中的表格。 anotherConcreteObserver: 对应例子中的柱状图。 代码例子下面例子使用 typescript 编写。 PS: 为了简化处理,就不定义 Subject 接口与 ConcreteSubject 了,而是直接用 Subject 类代替。Observer 也同理。 // 目标,管理所有观察者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 很轻松的实现: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Prototype 原型模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Prototype 原型模式》.html","content":"当前期刊数: 170 Prototype(原型模式)Prototype(原型模式)属于创建型模式,既不是工厂也不是直接 New,而是以拷贝的方式创建对象。 意图:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 做钥匙很显然,为了房屋安全,要尽量做到一把钥匙只能开一扇门,每把钥匙结构都多多少少不一样,却又很相似,做钥匙的人按照你给的钥匙一模一样做一个新的,这属于什么模式呢? 两种状态表当网站做不停机维护时,假设维护内容是给每个高级会员账户多打 100 元现金,现在需要改数据库表。已知: 数据库表有几千万条数据,其中高级会员有几千位,为了方便调用已经缓存在中间层了,且数据库对应 ID 更新后对应缓存也会更新。 几千条数据修改语句执行完需要几分钟,这几分钟内无法接受用户数据不同步的问题。 一种常见的做法是,我们生成一份高级会员列表的拷贝,代替数据库缓存的结果,数据库只要读到对应会员 ID 就从拷贝列表中获取,数据表新增一列状态标志,操作完后这个拷贝移除,更新高级会员缓存。 但是如何生成高级会员列表拷贝呢?如果直接从几千万条用户数据中重新查询,会有较高的数据库查询成本。 模版组件通用搭建系统中,我们可以将某个拖拽到页面的区块设置为 “模版”,这个模版可以作为一个新组件被重新拖拽到任意位置,实例化任意次。实际上,这是一种分段式复制粘贴,你会如何实现这个功能呢? 意图解释解决上面问题的办法都很简单,就是基于已有对象进行复制即可,效率比 New 一个,或者工厂模式都要高。 意图:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。 所谓原型实例,就是被选为拷贝模版的那个对象,比如做钥匙例子中,你给老板的样板钥匙;两种状态表中的已有缓存高级会员列表;模版组件中选中的那个组件。然后,通过拷贝这些原型创建你想要的对象即可。 我们抽象思考一下,如果每把钥匙都遵循 Prototype 接口,提供了 clone() 方法以复制自己,那就可以快速复制任意一把钥匙。钥匙工厂可无法解决每把钥匙不一样的问题,我们要的就是和某个钥匙一模一样的副本,复制一份钥匙最简单。 高级会员状态表例子中,查询数据库的成本是高昂的,但如果仅仅复制已经查询好的列表,时间可以忽略不计,因此最经济的方案是直接复制,而不是通过工厂模式重新连接数据库并执行查询。 模版组件更是如此,我们根本没有定义那么多组件实例的基类,只要每个组件提供一个 clone() 函数,就可以立即复制任意组件实例,这无疑是最经济实惠的方案。 看到这里,你应该知道了,原型模式的精髓是对象要提供 clone() 方法,而这个 clone() 方法实现难度有高有低。 一般来说,原型模式的拷贝建议用深拷贝,毕竟新对象最好不要影响到旧对象,但是在深拷贝性能问题较大的情况下,可以考虑深浅拷贝结合,也就是将在新对象中,不会修改的数据使用浅拷贝,可能被修改的数据使用深拷贝。 结构图 Client 是发出指令的客户端,Prototype 是一个接口,描述了一个对象如何克隆自身,比如必须拥有 clone() 方法,而 ConcretePrototype 就是克隆具体的实现,不同对象有不同的实现来拷贝自身。 代码例子下面例子使用 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 模式还是有类似之处的,在隐藏创建对象细节这一点上。 使用的时候,我们就可以这样创建一个新对象: const newComponent = oldComponent.clone() 这里有两个注意点:一般来说,如果要二次修改生成的对象,不建议给 clone 函数加参数,因为这样会导致接口的不一致。 我们可以为对象实例提供一些 set 函数进行二次修改。另外,clone 函数要考虑性能,就像前面说过的,可以考虑深浅拷贝结合的方式,同时要注意当对象存在引用关系甚至循环引用时,甚至不一定能实现拷贝函数。 弊端每个设计模式必有弊端,但就像每一期都说的,有弊端不代表设计模式不好用,而是指在某种场景喜爱存在问题,我们只要规避这些场景,在合理的场景使用对应设计模式即可。 原型模式的弊端: 每个类都要实现 clone 方法,对类的实现是有一定入侵的,要修改已有类时,违背了开闭原则。 当类又调用了其他对象时,如果要实现深拷贝,需要对应对象也实现 clone 方法,整体链路可能会特别长,实现起来比较麻烦。 总结原型模式一般与工厂模式搭配使用,一般工厂方法接收一个符合原型模式的实例,就可以调用它的 clone 函数创建返回新对象啦。 代码大概是这样: // buildComponentFactory 内部通过 targetComponent.clone() 创建对象,而不是 New 或者调用其他工厂函数。const newComponent = buildComponentFactory(new Component()) 最后来一张图快速理解原型模式: 讨论地址是:精读《设计模式 - Prototype 原型模式》· Issue ##277 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Proxy 代理模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Proxy 代理模式》.html","content":"当前期刊数: 178 Proxy(代理模式)Proxy(代理模式)属于结构型模式,通过访问代理对象代替访问原始对象,以获得一些设计上的便捷。 意图:为其他对象提供一种代理以控制这个对象的访问。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 获得文本对象长度获得一个文本对象长度,必须要真正渲染出来,而渲染是比较耗时的,我们可能只在某些场景下需要访问文本对象长度,而更多时候只需要读取文本内容,这两种操作耗时是完全不同的,如何做到业务层调用无感知,来优化执行耗时呢? 代理模式可以解决这个问题,我们将业务层使用的文本对象替换为代理对象,这个代理对象初始化并不渲染文本,而是在调用文本长度时才渲染。 对象访问保护某个大型系统开发完了,突然要求增加代码访问权限体系,不同模块对相同的底层对象拥有不同访问权限,此时这个权限控制逻辑如果写入底层对象,就违背了开闭原则,而对象本身的实现也不再纯粹,增加了维护成本,如何做到不修改对象本身,实现权限控制呢? 代理模式也能解决,将底层对象导出替换为代理对象,由代理对象控制访问权限即可。 对象与视图双向绑定Angular 或 Vue 这类前端框架采用双向绑定视图更新技术,即对象修改后,使用到的视图会自动刷新,这就需要做到以下两点: 在对象被访问时,记录调用的视图绑定。 在对象被修改时,刷新调用它的视图。 问题是,在业务代码使用对象与修改对象的地方插入这段逻辑,显然会增加巨大的维护成本,如何做到业务层无感知呢? 代理模式可以很好的解决这个问题,其实业务层拿到的对象已经是代理对象了,它在被访问与被修改时,都会执行固定的钩子做视图绑定与视图刷新。 意图解释意图:为其他对象提供一种代理以控制这个对象的访问。 代理模式的意图很容易理解,就是通过代理对象代替原始对象的访问。 这只是代理模式的实现方式,代理模式真正的难点不在于理解它是如何工作的,而是理解哪些场景适合用代理,或者说创建了代理对象,怎么用才能发挥它的价值。 在上面例子中,已经举出了几种常见代理使用场景: 对开销大的对象使用代理,以按需使用。 对需要保护的对象进行代理,在代理层做权限控制。 在对象访问与修改时要执行一些其他逻辑,适合在代理层做。 结构图 使用时关系如下: Subject 定义的是 RealSubject 与 Proxy 共用的接口,这样任何使用 RealSubject 的地方都可以使用 Proxy。 RealSubject 指的是原始对象,Proxy 是一个代理实体。 关系图中可以看出,当客户端要访问 subject 时,第一层访问的是 Proxy 代理,由这个代理将 realSubject 转发给客户端。 代码例子下面例子使用 typescript 编写。 // 对象 objconst 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Singleton 单例模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Singleton 单例模式》.html","content":"当前期刊数: 171 Singleton(单例模式)Singleton(单例模式)属于创建型模式,提供一种对象获取方式,保证在一定范围内是唯一的。 意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。 其实单例模式在前端体会的不明显,原因有: 前端代码本身在单机运行,创建的任何变量都是天然分布式的,不需要担心影响另一个用户。 后端代码是一对多的,分辨出哪些资源是请求间共享的,哪些是请求内独有的很重要。 另外我们说到单例,是隐含了一个范围的,指的是在某个范围内单例,比如在一个上下文中,还是一个房间中,还是一个进程,一个线程中单例,不同场景范围会不同。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 多人游戏的共享物品玩过游戏的同学都知道,我们在每局游戏中使用的公共物品在当前房间中是唯一的,但在游戏房间间却不是唯一的,所以这些公共物品肯定有不同的类去描述,那每局游戏中怎么拿公共物品,可以保证拿到的是当前局内唯一的? Redux 数据流其实前端的 Redux 数据流本身就是单例模式,在一个应用中,数据是唯一的,但可以有不同的 UI 使用这份唯一的数据,甚至把一个表格组件展示在两个不同地方,比如全屏模式,但数据依然是一份,我们没有必要为了全屏展示表格,就让它再发一次取数请求,完全可以和原来的表格共享一份数据。 数据库连接池每个 SQL 查询都依赖数据库连接池,如果每次查询都建立一次数据库连接池,则建立连接的速度会远远慢于 SQL 查询速度,因此你会怎么设计数据库连接池的获取方法? 意图解释单例模式的意图很简单,几乎就是其字面含义: 意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。 对于多人游戏的共享物品,比如一口锅,要保证在一局游戏内唯一,就要提供一种方法访问到唯一实例。 Redux 数据流的 connect 装饰器就是全局访问点的一种设计。 数据库连接池可以提前初始化好,并通过固定 API 提供这个唯一实例。 结构图 Singleton 是单例模式的接口,客户只能通过其定义的 instance() 访问实例,以保证单例。 代码例子下面例子使用 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 改成多实例,都是有可能的,在系统设计之初就要考虑到未来是否还会保持单例。 可测试性不好,因为单例是全局共享的,无法保证测试用例间的隔离。 无法使用构造函数传参。 另外单例模式还可以被工厂方法所替代,所以不用特别纠结一种设计模式,可以结合使用,工厂函数也可以内嵌单例模式。 总结单例模式概念、用法都简单,是架构设计常用方案,但要充分理解到单例模式的弊端,防止不恰当的使用。 讨论地址是:精读《设计模式 - Singleton 单例模式》· Issue ##278 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Strategy 策略模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Strategy 策略模式》.html","content":"当前期刊数: 187 Strategy(策略模式)Strategy(策略模式)属于行为型模式。 意图:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。 策略是个形象的表述,所谓策略就是方案,我们都知道任何事情都有多种方案,而且不同方案都能解决问题,所以这些方案可以相互替换。我们将方案从问题中抽象出来,这样就可以抛开问题,单独优化方案了,这就是策略模式的核心思想。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 地图导航我们去任何地方都可以选择步行、骑车、开车、公交,不同的方案都可以帮助我们到达目的地,那么很明显应该将这些方案变成策略封装起来,接收的都是出发点和目的地,输出的都是路线。 布局方式比如我们做一个报表系统,在 PC 使用珊格布局,在移动端使用流式布局,其实内容还是那些,只是布局方式会随着不同终端大小做不同的适配,那么布局的适配就是一种策略,它可以与报表内容无关。 我们可以将布局策略单独抽取出来,以后甚至可以适配电视机、投影仪等等不同尺寸的场景,而不需要对其他代码做任何改动,这就是将布局策略从代码中解耦出来的好处。 排序算法当我们调用 .sort 时,使用的是什么排序算法?可能是冒泡、快速、插入排序?其实无论何种排序算法,本质上做的事情都是一样的,我们可以事先将排序算法封装起来,针对不同特性的数组调用不同的排序算法。 意图解释意图:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。 算法可以理解为策略,我们制定许多解决某个场景的策略,这些策略都可以独立的解决这个场景的问题,这样下次遇到这个场景时,我们就可以选择任何策略来解决,而且我们还可以脱离场景,单独优化策略,只要接口不变即可。 这个意图本质上就是解耦,解耦之后才可以分工。想想一个复杂的系统,如果所有策略都耦合在业务逻辑里,那么只有懂业务的人才能小心翼翼的维护,但如果将策略与业务解耦,我们就可以独立维护这些策略,为业务带来更灵活的变化。 结构图 Strategy: 策略公共接口。 ConcreteStrategy: 具体策略,实现了上面这个接口。 只要你的策略符合接口,就满足策略模式的条件。 代码例子下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - State 状态模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - State 状态模式》.html","content":"当前期刊数: 186 State(状态模式)State(状态模式)属于行为型模式。 意图:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。 简单来说,就是将 “一个大 class + 一堆 if else” 替换为 “一堆小 class”。一堆小 class 就是一堆状态,用一堆状态代替 if else 会更好拓展与维护。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 团队接口人团队是由很多同学组成的,但有一位接口人 TL,这位 TL 可能一会儿和产品经理谈需求,一会儿和其他 TL 谈规划,一会儿和 HR 谈人事,总之要做很多事情,很显然一个人是忙不过来的。TL 通过将任务分发给团队中每个同学,而不让他们直接和产品经理、其他 TL、HR 接触,那么这位 TL 的办事效率就会相当高,因为每个同学只负责一块具体的业务,而 TL 在不同时刻叫上不同的同学,让他们出面解决他们负责的专业领域问题,那么在外面看,这位 TL 团队能力很广,在内看,每个人负责的事情也比较单一。 台灯按钮我们经常会看到只有一个按钮的台灯,但是可以通过按钮调节亮度,大概是如下一个循环 “关 -> 弱光 -> 亮 -> 强光 -> 关”,那么每次按按钮后,要跳转到什么状态,其实和当前状态有关。我们可以用 if else 解决这个问题,也可以用状态模式解决。 用状态模式解决,就是将这四个状态封装为四个类,每个类都执行按下按钮后要跳转到的状态,这样未来新增一种模式,只要改变部分类即可。 数据库连接器在数据库连接前后,这个连接器的状态显然非常不同,我们如果仅用一个类描述数据库连接器,则内部免不了写大量分支语句进行状态判断。那么此时有更好的方案吗?状态模式告诉我们,可以创建多个不同状态类,比如连接前、连接中、连接后三种状态类,在不同时刻内部会替换为不同的子类,它们都继承同样的父类,所以外面看上去不需要感知内部的状态变化,内部又可以进行状态拆分,进行更好的维护。 意图解释意图:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。 重点在 “内部状态” 的理解,也就是状态改变是由对象内部触发的,而不是外部,所以 外部根本无需关心对象是否用了状态模式,拿数据库连接器的例子来说,不管这个类是用 if else 堆砌的,还是用状态模式做的,都完全不妨碍它对外提供的稳定 API(接口问题),所以状态模式实质上是一种内聚的设计模式。 结构图 State: 状态接口,类比为台灯状态。 ConcreteState: 具体状态,都继承于 State,类比为台灯的强光、弱光状态。 代码例子下面例子使用 typescript 编写。 abstract class Context { abstract setState(state: State): void;}// 定义状态接口interface State { // 模拟台灯点亮 show: () => string}interface Light { click: () => void}type LightState = State & Lightclass TurnOff implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '关灯' } // 按下按钮 public click() { this.context.setState(new WeakLight(this.context)) }}class WeakLight implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '弱光' } // 按下按钮 public click() { this.context.setState(new StandardLight(this.context)) }}class StandardLight implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '亮' } // 按下按钮 public click() { this.context.setState(new StrongLight(this.context)) }}class StrongLight implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '强光' } // 按下按钮 public click() { this.context.setState(new TurnOff(this.context)) }}// 台灯class Lamp extends Context { // 当前状态 ##currentState: LightState = new TurnOff(this) setState(state: LightState) { this.##currentState = state } getState() { return this.##currentState } // 按下按钮 click() { this.getState().click() }}const lamp = new Lamp() // 关闭console.log(lamp.getState().show()) // 关灯lamp.click() // 弱光console.log(lamp.getState().show()) // 弱光lamp.click() // 亮console.log(lamp.getState().show()) // 亮lamp.click() // 强光console.log(lamp.getState().show()) // 强光lamp.click() // 关闭console.log(lamp.getState().show()) // 关闭 其实有很多种方式来实现,不必拘泥于形式,大体上只要保证由多个类实现不同状态,每个类实现到下一个状态切换就好了。 弊端该用 if else 的时候还是要用,不要但凡遇到 if else 就使用状态模式,那样就是书读傻了。一定要判断,是否各状态间差异很大,且使用状态模式后维护性比 if else 更好,才应该用状态模式。 总结在合适场景下,状态模式可以使代码更符合开闭原则,每个类独立维护时,逻辑也更精简、聚焦,更易维护。 讨论地址是:精读《设计模式 - State 状态模式》· Issue ##303 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Template Method 模版模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Template Method 模版模式》.html","content":"当前期刊数: 188 Template Method(模版模式)Template Method(模版模式)属于行为型模式。 意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。TemplateMethod 使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 模版文件我们办事打印的文件就是模版文件,只需要写上个人基本信息再签字就可以了,我们不需要做太多的重复劳动,因为某些场景下大部分内容是可以固化下来的。比如买卖房屋,那大部分甲方乙方的条款是固定的,最大的变化是甲方与乙方的不同,我们在模版上签字时,就是利用了模版模式减少了大量写条款的时间。 实例化实例化也可以认为是模版模式的某种表现形式,因为对于工厂方法,我们传入不同的初始值可能给出不同结果,那么实际上就是用很少的代码撬动了很大一块功能,起到了抽象作用。 Vue 模版Vue 模版更符合我们对模版直觉的理解。这个场景中,模版指的是 HTML 模版,我们只需要在模版中以 {} 形式描述一些变量,就可以生成一块只有局部变量变化的模版 DOM,非常方便。 意图解释意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。TemplateMethod 使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。 这个设计模式初衷是用于面向对象的,所以考虑的是如何在类中运用模版模式。首先定义一个父类,实现了一些算法,再将需要被子类重载的方法提出来,子类重载这些部分方法后,即可利用父类实现好的算法做一些功能。 比如说父类方法 function a() { b() + c() },此时子类只需要重定义 b 与 c 方法,即可复用 a 的算法(b 与 c 相加)。当然这个例子比较简单,当算法较为复杂时,模版模式的好处将凸显出来。 结构图 ConcreteClass: 具体的父类。可以看到父类中实现了 TemplateMethod,其调用了 primitiveOperation1 与 primitiveOperation2, 所以子类只需要重载这两个方法,即可享用 TemplateMethod 提供的算法。 假设 TemplateMethod 是 OpenDocument 打开文档的作用,那么 primitiveOperation1 可能是 CanOpen 校验,primitiveOperation2 可能是 ReadDocument 读取文档方法。 我们只要专心实现具体的细节方法,而不需要关心他们之间是如何相互作用的,父级会帮我们实现它。之后我们就可以调用子类的 OpenDocument 实现打开文档了。 代码例子下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"公众号图文","path":"/wiki/YuDaoBoot/公众号手册/公众号图文/公众号图文.html","content":"开发指南公众号手册 芋道源码 2023-01-30 目录 公众号图文 本章节,讲解公众号图文的相关内容,包括两部分: ① 在 [公众号管理 -> 图文草稿箱] 菜单中,创建一个图文草稿。如下图所示: ② 点击【发布】按钮,将图文草稿发布到公众号,成为一个图文记录,展示在 [公众号管理 -> 图文发表记录] 菜单中。如下图所示: # 1. 表结构 暂无,全部基于微信公众号提供的 API 接口。 图文草稿箱:《微信公众号官方文档 —— 草稿箱》 (opens new window) 图文发表记录:《微信公众号官方文档 —— 发布能力》 (opens new window) # 2. 图文草稿箱界面 前端:/@views/mp/draft (opens new window) 后端:MpDraftController (opens new window) # 3. 图文发表记录界面 前端:/@views/mp/freePublish (opens new window) 后端:MpFreePublishController (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号素材 公众号统计 ← 公众号素材 公众号统计→"},{"title":"《设计模式 - Visitor 访问者模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Visitor 访问者模式》.html","content":"当前期刊数: 189 Visitor(访问者模式)Visitor(访问者模式)属于行为型模式。 意图:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。 访问者,顾名思义,就是对象访问的一种设计模式,我们可以在不改变要访问对象的前提下,对访问对象的操作做拓展。 举例子由于能应用访问者模式的场景很少,所以这里只举一个例子。 建造游戏中的资源设计假设你制作一款城市建造游戏,游戏的基础资源只有毛皮、木材、铜矿、铁矿。你需要用这些资源建造各种,比如造楼房、做衣服、制作家具、门、空调、甚至锅、健身房、游泳馆等。记住一个前提,就是你想把游戏设计的非常逼真,所以每种资源的不同使用方法都非常定制,不是简单的消耗 N 个数量就能完成,比如制作家具时,需要用到毛皮和木材,此时毛皮和木材对环境、制作人、资金都有不同的要求。 常见的想法是,我们将资源的所有使用方法都枚举在资源类中,这样资源就在用到不同场景时,调用不同方法即可。但问题是资源本身其实较为固定,我们每增加一种用途就修改一次木材、铁矿的类会显得非常麻烦。 能不能在增加新用途时,不修改原始资源类呢?答案是可以用访问者模式。 意图解释意图:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。 第一句话指明了 Visitor 的作用,即 “作用于某对象结构中的各元素的操作”,也就是 Visitor 是用于操作对象元素的。“它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作” 也就是说,你可以只修改 Visitor 本身完成新操作的定义,而不需要修改原本对象。 这看上去比较奇怪,给对象定义新的操作,竟然不用修改对象本身,而通过改另外一个对象就可以?这就是 Visitor 设计的奇妙之处,它将对象的操作权移交给了 Visitor。 结构图 Visitor:访问者接口。 ConcreteVisitor:具体的访问者。 Element 可以被访问者使用的元素,它必须定义一个 Accept 属性,接收 visitor 对象。这是实现访问者模式的关键。 ObjectStructure:对象结构,存储了多个 Element,利用 Visitor 进行批量操作。 可以看到,要实现操作权转让到 Visitor,核心是元素必须实现一个 Accept 函数,将这个对象抛给 Visitor: class ConcreteElement implements Element { public accept(visitor: Visitor) { visitor.visit(this) }} 从上面代码可以看出这样一条链路:Element 通过 accept 函数接收到 Visitor 对象,并将自己的实例抛给 Visitor 的 visit 函数,这样我们就可以在 Visitor 的 visit 方法中拿到对象实例,完成对对象的操作。 代码例子下面例子使用 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,可以看到,经历了如下过程: // 先创建元素const element = new ConcreteElement()// 访问者 Xconst visitorX = new ConcreteVisitorX()// 访问者 Yconst 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 方法: public accept(visitor: Visitor) { visitor.visit(this)} 所以又调用了 Visitor 的 visit 函数,不同的是,此时的参数是具体 Element 类型,所以可能调用到的是具体对某个元素处理的 visit 方法,比如: public visit(concreteElementA: ConcreteElementA) { console.log('X 操作 A')} 最终就输出了 “X 操作 A” 这段话。 我们可以看到这样的程序拓展性有这么些: Element 元素的所有子类都不用频繁修改,只要修改 Visitor 即可。 一个 Visitor 可以选择性的操作任何类型的 Element 子类,只要申明了处理函数即可处理,不申明就不会命中,比较方便。在城市建造的例子中,可以提现为锅需要用铁制作,但不需要消耗木材,所以不需要定义木材的 visit 方法。 可以定义多种 Visitor,对同一种 Element 子类也可以有不同的操作,这在我们城市建造的例子中,可以体现为门和窗户,对铁矿的使用是不同的。 由此一来,我们就能在城市建造的例子中拓展出任意多种使用资源的场景,而无需让资源感知到这些场景的存在。 弊端访问者模式使用场景非常有限,请确定你的场景满足以上情况再使用。如果资源并不需要频繁修改和拓展,那么就没必要使用访问者模式。 总结访问者模式的精髓,就是在不断拓展的业务场景中,防止基础元素代码不断膨胀。 假设我们这款城市建造游戏有 20 人团队开发,每周发布 2 个版本,每个版本新增了几种资源的组合使用方式,由于资源一共就木材、铁矿、铜矿那么几种,如果你作为团队负责人,任大家随意修改这些资源基础类,过不了半年就会发现,木材类的成员方法突破了 100 种,而且以每天新增 2 种的速度不断增加,你会明显发现自己精心打造的程序即将变成一堆屎山。 更要命的是,你还搞不清楚哪些场景的用法是打包的,当一种使用场景下线时,已存在的成员方法还不敢删除。 假设你用了访问者模式,会发现,每天因为迭代而新增的那几个方法,都会放到一个新 Visitor 文件下,比如一种纳米材料的门板在游戏 V1.5 版本被引进,它对材料的使用会体现在新增一个 Visitor 文件,资源本身的类不会被修改,这既不会引发协同问题,也使功能代码按照场景聚合,不论维护还是删除的心智负担都非常小。 访问者模式背后的思考本质还是,基础的元素数量一般不会随着程序迭代产生太大变化,而对这些基础元素的使用方式或组合使用会随着程序迭代不断更新,我们将变化更快的通过 Visitor 打包提取出来,自然会更利于维护。 讨论地址是:精读《设计模式 - Visitor 访问者模式》· Issue ##306 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"公众号接入","path":"/wiki/YuDaoBoot/公众号手册/公众号接入/公众号接入.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号接入 本章节,讲解如果将你的公众号,接入到系统中。步骤如下: 第一步,申请公众号(可选) 第二步,在系统中,添加公众号账号 第三步,在公众号中,配置接入信息 # 1. 配置步骤 本小节,手把手教你如何将公众号接入到系统中。 # 第一步,申请公众号(可选) 友情提示:如果你已经有公众号,可以忽略这一步。 ① 如果你还没有公众号,可以申请一个测试帐号。 申请地址:微信公众平台接口测试帐号申请 (opens new window) ② 申请完成后,获得一个测试号。如下图所示: # 第二步,添加公众号账号 点击 [公众号管理 -> 账号管理] 菜单,添加一个公众号账号。如下图所示: # 第三步,配置接入信息 ① 找一个内网穿透工具,转发到本地的 48080 端口。例如说,ngrok (opens new window)、frp (opens new window)、natapa (opens new window) 等等。 这里,我们使用 natapp 作为内网传统工具。访问 https://natapp.cn/tunnel/buy/free (opens new window) 地址,免费购买一个隧道。如下图所示: ② 购买完成后,参考 《NATAPP 1 分钟快速新手图文教程》 (opens new window) 文档,将 natapp 进行启动。如下图所示: ③ 打开微信公众号界面,填写 URL 和 Token 信息。如下图所示: 点击提交后,看到“配置成功”提示,说明配置成功。 # 2. 实现代码 本小节,将介绍如何实现公众号接入的代码。 # 2.1 表结构 公众号账号对应 mp_account 表,结构如下图所示: # 2.2 账号管理界面 前端:/@views/mp/account (opens new window) 后端:MpAccountController (opens new window) # 2.3 配置接入回调 在 第三步,配置接入信息 时,微信公众号会回调系统的 GET /admin-api/mp/open/{appID} 接口,进行接入配置的验证。对应 MpOpenController (opens new window) 类的 checkSignature 方法,如下图所示: 对应 《微信公众号官方文档 —— 接入指南》 (opens new window) 文档。 友情提示: 项目使用的微信工具开发包是 weixin-java-mp (opens new window),超级好用! # 2.4 消息处理 配置接入完成后,用户发给公众号的消息,公众号都会回调到 POST /admin-api/mp/open/{appID} 接口,进行消息的处理。对应 MpOpenController (opens new window) 类的 handleMessage 方法,如下图所示: 核心逻辑是第二步,再解析到消息后,交给 WxMpMessageRouter 进行消息的处理。WxMpMessageRouter 在 DefaultMpServiceFactory (opens new window) 初始化,设置每种消息对应的 handler (opens new window) 处理器。如下图所示: 具体每个处理器的实现,后续每个章节单独详细讲解。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 功能开启 公众号粉丝 ← 功能开启 公众号粉丝→"},{"title":"公众号消息","path":"/wiki/YuDaoBoot/公众号手册/公众号消息/公众号消息.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号消息 本章节,讲解公众号消息的相关内容,对应 [公众号管理 -> 消息管理] 菜单。如下图所示: # 1. 表结构 公众号消息对应 mp_message 表,结构如下图所示: ① type 字段:消息类型,包括文本、图片、语音、视频、小视频、图文、音乐、地理位置、链接、事件等类型,对应 mp_message_type 字典。 ② send_from 字段:消息发送方,分成两类: 【接收】用户发送给公众号:接收普通消息 (opens new window)、接收事件推送 (opens new window) 【发送】公众号发给用户:被动回复用户消息 (opens new window)、客服消息 (opens new window) # 2. 消息管理界面 前端:/@views/mp/message (opens new window) 后端:MpMessageController (opens new window) # 3.【接收】 # 3.1 接收普通消息 对应 《微信公众号官方文档 —— 接收普通消息》 (opens new window) 文档。 当用户向公众账号发消息时,会被 MessageReceiveHandler (opens new window) 处理,记录到 mp_message 表,消息类型为文本、图片、语音、视频、小视频、地理位置、链接。如下图所示: # 3.2 接收事件消息 对应 《微信公众号官方文档 —— 接收事件推送》 (opens new window) 文档。 在用户和公众号产交互的过程中,会被 MessageReceiveHandler (opens new window) 处理,记录到 mp_message 表,消息类型仅为事件。 # 4.【发送】 # 4.1 被动回复用户消息 对应 《微信公众号官方文档 —— 被动回复用户消息》 (opens new window) 文档。 在被动回复用户消息时,统一由 MpMessageServiceImpl (opens new window) 的 sendOutMessage 方法来构建回复消息,也会记录到 mp_message 表,消息类型为文本、图片、语音、视频、音乐、图文。如下图所示: # 4.2 主动发送客服消息 对应 《微信公众号官方文档 —— 客服消息》 (opens new window) 文档。 点击消息管理界面的【消息】按钮,可以主动发送客服消息给用户。如下图所示: 主动发送客服消息,统一由 MpMessageServiceImpl (opens new window) 的 sendKefuMessage 方法来构建客服消息,也会记录到 mp_message 表,消息类型为文本、图片、语音、视频、音乐、图文。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号标签 自动回复 ← 公众号标签 自动回复→"},{"title":"公众号粉丝","path":"/wiki/YuDaoBoot/公众号手册/公众号粉丝/公众号粉丝.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号粉丝 本章节,讲解公众号粉丝的相关内容,包括关注、取消关注等等,对应 《微信公众号官方文档 —— 获取用户列表》 (opens new window) 文档。 # 1. 表结构 公众号粉丝对应 mp_user 表,结构如下图所示: 注意,自 2021-12-27 开始,公众号接口不再返回头像和昵称,只能通过微信公众号的网页登录获取。因此,表中的 avatar 和 nickname 字段,往往是空的。 # 2. 粉丝管理界面 前端:/@views/mp/user (opens new window) 后端:MpUserController (opens new window) # 3. 同步粉丝 点击粉丝管理界面的【同步】按钮,可以 异步 从公众号同步所有的粉丝信息,存储到 mp_user 表中。如果你的粉丝较多,可能需要等待一段时间。 对应后端的 MpUserServiceImpl (opens new window) 的 syncUser 方法。 # 4. 关注 SubscribeHandler 用户关注公众号时,会触发 SubscribeHandler (opens new window) 处理器,新增或修改 mp_user 粉丝信息。 # 5. 取关 UnsubscribeHandler 用户取消关注公众号时,会触发 UnsubscribeHandler (opens new window) 处理器,标记 mp_user 粉丝信息为取消关注,设置 subscribe_status 字段为 0。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号接入 公众号标签 ← 公众号接入 公众号标签→"},{"title":"公众号标签","path":"/wiki/YuDaoBoot/公众号手册/公众号标签/公众号标签.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号标签 本章节,讲解公众号标签的相关内容,支持对标签进行创建、查询、修改、删除等操作,也可以对用户进行打标签、取消标签等操作,对应 《微信公众号官方文档 —— 用户标签管理》 (opens new window) 文档。 # 1. 表结构 公众号粉丝对应 mp_tag 表,结构如下图所示: 而给用户打上标签后,存储在 mp_user 表的 tag_ids 字段中(多个标签之间用 , 分隔),不单独存储关联表。 # 2. 标签管理界面 前端:/@views/mp/tag (opens new window) 后端:MpTagController (opens new window) # 3. 同步标签 点击标签管理界面的【同步】按钮,可以从公众号同步所有的标签信息,存储到 mp_tag 表中。 对应后端的 MpTagServiceImpl (opens new window) 的 syncTag 方法。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号粉丝 公众号消息 ← 公众号粉丝 公众号消息→"},{"title":"公众号统计","path":"/wiki/YuDaoBoot/公众号手册/公众号统计/公众号统计.html","content":"开发指南公众号手册 芋道源码 2023-01-30 目录 公众号统计 本章节,讲解公众号统计的相关内容,包括用户、消息、接口分析。对应 [公众号管理 -> 数据统计] 菜单,如下图所示: # 1. 表结构 暂无,全部基于微信公众号提供的 API 接口。 用户增减数据 + 累计用户数据:《微信公众号官方文档 —— 用户分析》 (opens new window) 消息概况数据:《微信公众号官方文档 —— 消息分析》 (opens new window) 接口分析数据:《微信公众号官方文档 —— 接口分析》 (opens new window) # 2. 数据统计界面 前端:/@views/mp/statistics (opens new window) 后端:MpStatisticsController (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号图文 功能开启 ← 公众号图文 功能开启→"},{"title":"公众号素材","path":"/wiki/YuDaoBoot/公众号手册/公众号素材/公众号素材.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号素材 本章节,讲解公众号素材的相关内容,包括图片、语音、视频素材,不包括图文素材。对应 [公众号管理 -> 素材管理] 菜单,如下图所示: 在配置公众号的自动回复、菜单的自动回复、主动给用户发送消息时,都可以使用素材。 # 1. 表结构 公众号素材对应 mp_material 表,结构如下图所示: ① type 字段:素材类型。对应微信的素材类型,包括 image 图片、voice 语音、video 视频。 ② media_id 字段:素材的媒体编号,对应微信公众号的 media_id。 ③ permanent 字段:是否永久。true 代表 永久素材 (opens new window),false 代表 临时素材 (opens new window)。 ④ mp_url 字段:公众号存储素材的 URL 地址,有且仅有永久素材才有。 ⑤ url 字段:存储在自己文件服务器上的 URL 地址,解决临时素材只在微信服务器上保存 3 天的问题,也解决图片素材的 mp_url 无法在自己管理后台显示的问题。 # 2. 素材管理界面 前端:/@views/mp/material (opens new window) 后端:MpMaterialController (opens new window) # 3. 永久素材 对应 《微信公众号官方文档 —— 永久素材》 (opens new window) 文档。 MpMaterialController (opens new window) 的 uploadPermanentMaterial 方法对应的接口,实现了上传【永久】素材到公众号。如下图所示: # 4. 临时素材 对应 《微信公众号官方文档 —— 临时素材》 (opens new window) 文档。 ① 来源一:主动发送客服消息给用户时,如果是图片、语音、视频素材,需要先上传到微信服务器,获得到 media_id 后,才能发送给用户。 此时,可调用 MpMaterialController (opens new window) 的 uploadTemporaryMaterial 方法对应的接口,实现了上传【临时】素材到公众号。如下图所示: ② 来源二:在接收到用户消息时,如果是图片、语音、视频素材,需要先下载到自己的文件服务器上,避免超过 3 天后无法访问的问题。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号菜单 公众号图文 ← 公众号菜单 公众号图文→"},{"title":"公众号菜单","path":"/wiki/YuDaoBoot/公众号手册/公众号菜单/公众号菜单.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号菜单 本章节,讲解公众号菜单的相关内容,对应 [公众号管理 -> 菜单管理] 菜单,对应 《微信公众号官方文档 —— 自定义菜单》 (opens new window) 文档。如下图所示: # 1. 表结构 公众号菜单对应 mp_menu 表,结构如下图所示: type 字段:按钮类型。如果类型为 CLICK 点击回复时,可进行文本、图片、语音、视频、图文、音乐消息。 # 2. 菜单管理界面 前端:/@views/mp/menu (opens new window) 后端:MpMenuController (opens new window) # 3. 点击回复 用户点击菜单按钮时,会接收事件消息,进而被 MenuHandler (opens new window) 处理。如果类型为 CLICK 点击回复时,自动回复对应的消息。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 自动回复 公众号素材 ← 自动回复 公众号素材→"},{"title":"功能开启","path":"/wiki/YuDaoBoot/公众号手册/功能开启/功能开启.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 功能开启 微信公众号 (opens new window)的功能,由 yudao-module-mp (opens new window) 模块实现,对应前端代码为 @/views/mp (opens new window) 目录。 主要包括如下 10 个功能(菜单): 功能 描述 账号管理 配置接入的微信公众号,可支持多个公众号 数据统计 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 粉丝管理 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 消息管理 查看粉丝发送的消息列表,可主动回复粉丝消息 自动回复 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 标签管理 对公众号的标签进行创建、查询、修改、删除等操作 菜单管理 自定义公众号的菜单,也可以从公众号同步菜单 素材管理 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 图文草稿箱 新增常用的图文素材到草稿箱,可发布到公众号 图文发表记录 查看已发布成功的图文素材,支持删除操作 考虑到编译速度,默认 yudao-module-mp 模块是关闭的,需要手动开启。步骤如下: 第一步,开启 yudao-module-mp 模块 第二步,导入公众号的 SQL 数据库脚本 第三步,重启后端项目,确认功能是否生效 # 1. 第一步,开启模块 ① 修改根目录的 pom.xml (opens new window) 文件,取消 yudao-module-mp 模块的注释。如下图所示: ② 修改 yudao-server 目录的 pom.xml (opens new window) 文件,引入 yudao-module-mp 模块。如下图所示: ③ 点击 IDEA 右上角的【Reload All Maven Projects】,刷新 Maven 依赖。如下图所示: # 2. 第二步,导入 SQL 将 mp.sql (opens new window) 文件导入到数据库中。如下图所示: 以 mp_ 作为前缀的表,就是公众号模块的表。 # 3. 第三步,重新项目 重启后端项目,然后访问前端的公众号菜单,确认功能是否生效。如下图所示: 至此,我们就成功开启了公众号的功能 🙂 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 大屏设计器 公众号接入 ← 大屏设计器 公众号接入→"},{"title":"自动回复","path":"/wiki/YuDaoBoot/公众号手册/自动回复/自动回复.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 自动回复 本章节,讲解自动回复的相关内容,对应 [公众号管理 -> 自动回复] 菜单。如下图所示: 在用户关注、发送消息时,公众号可以自动回复消息给用户。 # 1. 表结构 自动回复对应 mp_auto_reply 表,结构如下图所示: type 字段:回复类型, 1 - 关注回复:用户关注公众号时 3 - 关键字回复:消息类型为文本时,匹配到关键字 2 - 消息回复:没有匹配到关键字时,根据消息类型 # 2. 自动回复界面 前端:/@views/mp/autoReply (opens new window) 后端:MpAutoReplyController (opens new window) # 3. 关注回复 用户关注公众号时,被动回复用户消息,由 MpAutoReplyServiceImpl (opens new window) 的 replyForSubscribe 方法来生成回复内容。如下图所示: # 4. 消息回复 & 关键字回复 用户发送消息给公众号时,自动回复消息给用户,分为两种情况: 关键字回复:消息类型为文本时,匹配到关键字,自动回复消息 消息回复:没有匹配到关键字时,根据消息类型,自动回复消息 这两种情况,由 MessageAutoReplyHandler (opens new window) 调用 MpAutoReplyServiceImpl (opens new window) 的 replyForMessage 方法来生成回复内容。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号消息 公众号菜单 ← 公众号消息 公众号菜单→"},{"title":"字典数据","path":"/wiki/YuDaoBoot/前端手册 Vue 2/字典数据/字典数据.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 字典数据 本小节,讲解前端如何使用 [系统管理 -> 字典管理] 菜单的字典数据,例如说字典数据的下拉框、单选 / 多选按钮、高亮展示等等。 # 1. 全局缓存 用户登录成功后,前端会从后端获取到全量的字典数据,缓存在 store 中。如下图所示: 这样,前端在使用到字典数据时,无需重复请求后端,提升用户体验。 不过,缓存暂时未提供刷新,所以在字典数据发生变化时,需要用户刷新浏览器,进行重新加载。 # 2. DICT_TYPE 在 dict.js (opens new window) 文件中,使用 DICT_TYPE 枚举了字典的 KEY。如下图所示: 后续如果有新的字典 KEY,需要你自己进行添加。 # 3. DictTag 字典标签 <dict-tag /> (opens new window) 组件,翻译字段对应的字典展示文本,并根据 colorType、cssClass 进行高亮。使用示例如下: <!-- type: 字典 KEY value: 字典值--><dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_TYPE" :value="row.logType" /> # 4. 字典工具类 在 dict.js (opens new window) 文件中,提供了字典工具类,方法如下: // 获取 dictType 对应的数据字典数组export function getDictDatas(dictType) { /** 省略代码 */ }// 获得 dictType + value 对应的字典展示文本export function getDictDataLabel(dictType, value) { /** 省略代码 */ } 结合 Element UI 的表单组件,使用示例如下: <!-- radio 单选框 --><el-radio v-for="dict in this.getDictDatas(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="parseInt(dict.value)">{{dict.label}}</el-radio><!-- select 下拉框 --><el-select v-model="form.code" placeholder="请选择渠道编码" clearable> <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE)" :key="dict.value" :label="dict.label" :value="dict.value"/></el-select> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 Icon 图标 系统组件 ← Icon 图标 系统组件→"},{"title":"Icon 图标","path":"/wiki/YuDaoBoot/前端手册 Vue 2/Icon 图标/Icon 图标.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 Icon 图标 Element UI 内置多种 Icon 图标,可参考 Element Icon 图标 (opens new window) 的文档。 在项目的 /src/assets/icons/svg (opens new window) 目录下,自定义了 Icon 图标,默认注册到全局中,可以在项目中任意地方使用。如下图所示: # 1. 使用方式 <!-- 示例一: icon-class 为 icon 的名字 class-name 为 icon 的自定义 class--><svg-icon icon-class="password" class-name='custom-class' /><!-- 示例二: icon 为 Element UI 的图标--><el-button icon="el-icon-plus">新增</el-button><!-- 示例三:结合上述两示例 --><el-button> <svg-icon icon-class="password" class-name='custom-class' /> 新增</el-button> # 2. 自定义图标 ① 访问 https://www.iconfont.cn/ ( opens new window) 地址,搜索你想要的图标,下载 SVG 格式。如下图所示: 友情提示:其它 SVG 图标网站也可以。 ② 将 SVG 图标添加到 @/icons/svg ( opens new window) 目录下,然后进行使用。 <svg-icon icon-class="helpless" /> # 3. 改变颜色 <svg-icon /> 默认会读取其父级的 color fill: currentColor; 。 你可以改变父级的 color ,或者直接改变 fill 的颜色即可。 疑问: 如果你遇到图标颜色不对,可以参照本 issue ( opens new window) 进行修改 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 菜单路由 字典数据 ← 菜单路由 字典数据→"},{"title":"开发规范","path":"/wiki/YuDaoBoot/前端手册 Vue 2/开发规范/开发规范.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 开发规范 # 1. view 页面 在 @views (opens new window) 目录下,每个模块对应一个目录,它的所有功能的 .vue 都放在该目录里。 一般来说,一个路由对应一个 .vue 文件。 # 2. api 请求 在 @/api (opens new window) 目录下,每个模块对应一个 .api 文件。 每个 API 方法,会调用 request 方法,发起对后端 RESTful API 的调用。 # 2.1 请求封装 @/utils/request (opens new window) 基于 axios (opens new window) 封装,统一处理 GET、POST 方法的请求参数、请求头,以及错误提示信息等。 # 2.1.1 创建 axios 实例 baseURL 基础路径 timeout 超时时间 实现代码 import axios from 'axios'// 创建 axios 实例const service = axios.create({ // axios 中请求配置有 baseURL 选项,表示请求 URL 公共部分 baseURL: process.env.VUE_APP_BASE_API + '/admin-api/', // 此处的 /admin-api/ 地址,原因是后端的基础路径为 /admin-api/ // 超时 timeout: 10000}) # 2.1.2 Request 拦截器 Authorization、tenant-id 请求头 GET 请求参数的拼接 实现代码 import { getToken } from '@/utils/auth'import { getTenantEnable } from "@/utils/ruoyi";import Cookies from "js-cookie";service.interceptors.request.use(config => { // 是否需要设置 token const isToken = (config.headers || {}).isToken === false if (getToken() && !isToken) { config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改 } // 设置租户 if (getTenantEnable()) { const tenantId = Cookies.get('tenantId'); if (tenantId) { config.headers['tenant-id'] = tenantId; } } // get 请求映射 params 参数 if (config.method === 'get' && config.params) { let url = config.url + '?'; for (const propName of Object.keys(config.params)) { const value = config.params[propName]; var part = encodeURIComponent(propName) + "="; if (value !== null && typeof(value) !== "undefined") { if (typeof value === 'object') { for (const key of Object.keys(value)) { let params = propName + '[' + key + ']'; var subPart = encodeURIComponent(params) + "="; url += subPart + encodeURIComponent(value[key]) + "&"; } } else { url += part + encodeURIComponent(value) + "&"; } } } url = url.slice(0, -1); config.params = {}; config.url = url; } return config}, error => { console.log(error) Promise.reject(error)}) # 2.1.3 Response 拦截器 Token 失效、登录过期时,跳回首页 请求失败,Message 错误提示 实现代码 import { Notification, MessageBox, Message } from 'element-ui'import store from '@/store'import errorCode from '@/utils/errorCode'import Cookies from "js-cookie";export let isRelogin = { show: false };service.interceptors.response.use(res => { // 未设置状态码则默认成功状态 const code = res.data.code || 200; // 获取错误信息 const msg = errorCode[code] || res.data.msg || errorCode['default'] if (code === 401) { if (!isRelogin.show) { isRelogin.show = true; MessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' } ).then(() => { isRelogin.show = false; store.dispatch('LogOut').then(() => { location.href = '/index'; }) }).catch(() => { isRelogin.show = false; }); } return Promise.reject('无效的会话,或者会话已过期,请重新登录。') } else if (code === 500) { Message({ message: msg, type: 'error' }) return Promise.reject(new Error(msg)) } else if (code !== 200) { Notification.error({ title: msg }) return Promise.reject('error') } else { // 请求成功! return res.data } }, error => { console.log('err' + error) let { message } = error; if (message === "Network Error") { message = "后端接口连接异常"; } else if (message.includes("timeout")) { message = "系统接口请求超时"; } else if (message.includes("Request failed with status code")) { message = "系统接口" + message.substr(message.length - 3) + "异常"; } Message({ message: message, type: 'error', duration: 5 * 1000 }) return Promise.reject(error) }) # 2.2 交互流程 一个完整的前端 UI 交互到服务端处理流程,如下图所示: 以 [系统管理 -> 用户管理] 菜单为例,查看它是如何读取用户列表的。代码如下: // ① api/system/user.jsimport request from '@/utils/request'// 查询用户列表export function listUser(query) { return request({ url: '/system/user/page', method: 'get', params: query })}// ② views/system/user/index.vueimport { listUser } from "@/api/system/user";export default { data() { userList: null, loading: true }, methods: { getList() { this.loading = true listUser().then(response => { this.userList = response.rows this.loading = false }) } }} # 2.3 自定义 baseURL 基础路径 如果想要自定义的 baseURL 基础路径,可以通过 baseURL 进行直接覆盖。示例如下: export function listUser(query) { return request({ url: '/system/user/page', method: 'get', params: query, baseURL: 'https://www.iocoder.cn' // 自定义 })} # 3. component 组件 ① 在 @/components ( opens new window) 目录下,实现全局 组件,被所有模块所公用。例如说,富文本编辑器、各种各搜索组件、封装的分页组件等等。 ② 每个模块的业务组件,可实现在 views 目录下,自己模块的目录的 components 目录下,避免单个 .vue 文件过大,降低维护成功。例如说, @/views/pay/app/components/xxx.vue。 # 4. style 样式 ① 在 @/styles ( opens new window) 目录下,实现全局 样式,被所有页面所公用。 ② 每个 .vue 页面,可在 <style /> 标签中添加样式,注意需要添加 scoped 表示只作用在当前页面里,避免造成全局的样式污染。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 服务监控 菜单路由 ← 服务监控 菜单路由→"},{"title":"菜单路由","path":"/wiki/YuDaoBoot/前端手册 Vue 2/菜单路由/菜单路由.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 菜单路由 前端项目基于 element-ui-admin 实现,它的 路由和侧边栏 (opens new window) 是组织起一个后台应用的关键骨架。 侧边栏和路由是绑定在一起的,所以你只有在 @/router/index.js (opens new window) 下面配置对应的路由,侧边栏就能动态的生成了,大大减轻了手动重复编辑侧边栏的工作量。 当然,这样就需要在配置路由的时候,遵循一些约定的规则。 # 1. 路由配置 首先,我们了解一下本项目配置路由时,提供了哪些配置项: // 当设置 true 的时候该路由不会在侧边栏出现 如 401,login 等页面,或者如一些编辑页面 /edit/1hidden: true // (默认 false)// 当设置 noRedirect 的时候该路由在面包屑导航中不可被点击redirect: 'noRedirect'// 1. 当你一个路由下面的 children 声明的路由大于 1 个时,自动会变成嵌套的模式。例如说,组件页面// 2. 只有一个时,会将那个子路由当做根路由显示在侧边栏。例如说,如引导页面// 若你想不管路由下面的 children 声明的个数都显示你的根路由,// 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由alwaysShow: truename: 'router-name' // 设定路由的名字,一定要填写不然使用 <keep-alive> 时会出现各种问题meta: { roles: ['admin', 'editor'] // 设置该路由进入的权限,支持多个权限叠加 title: 'title' // 设置该路由在侧边栏和面包屑中展示的名字 icon: 'svg-name' // 设置该路由的图标,支持 svg-class,也支持 el-icon-x element-ui 的 icon noCache: true // 如果设置为 true,则不会被 <keep-alive> 缓存(默认 false) breadcrumb: false // 如果设置为 false,则不会在breadcrumb面包屑中显示(默认 true) affix: true // 如果设置为 true,它则会固定在 tags-view 中(默认 false) // 当路由设置了该属性,则会高亮相对应的侧边栏。 // 这在某些场景非常有用,比如:一个文章的列表页路由为:/article/list // 点击文章进入文章详情页,这时候路由为 /article/1,但你想在侧边栏高亮文章列表的路由,就可以进行如下设置 activeMenu: '/article/list'} 普通示例 { path: '/system/test', component: Layout, redirect: 'noRedirect', hidden: false, alwaysShow: true, meta: { title: '系统管理', icon : "system" }, children: [{ path: 'index', component: (resolve) => require(['@/views/index'], resolve), name: 'Test', meta: { title: '测试管理', icon: 'user' } }]} 外链示例 { path: 'https://www.iocoder.cn', meta: { title: '芋道源码', icon : "guide" }} # 2. 路由 项目的路由分为两种:静态路由、动态路由。 # 2.1 静态路由 静态路由,代表那些不需要动态判断权限的路由,如登录页、404、个人中心等通用页面。 在 @/router/index.js ( opens new window) 的 constantRoutes ,就是配置对应的公共路由。如下图所示: # 2.2 动态路由 动态路由,代表那些需要根据用户动态判断权限,并通过 addRoutes ( opens new window) 动态添加的页面,如用户管理、角色管理等功能页面。 在用户登录成功后,会触发 @/store/modules/permission.js ( opens new window) 请求后端的菜单 RESTful API 接口,获取用户有权限 的菜单列表,并转化添加到路由中。如下图所示: 友情提示: 动态路由可以在 [系统管理 -> 菜单管理] 进行新增和修改操作,请求的后端 RESTful API 接口是 /admin-api/system/list-menus ( opens new window) 动态路由在生产环境下会默认使用路由懒加载,实现方式参考 loadView ( opens new window) 方法的判断 # 2.3 路由跳转 使用 router.push 方法,可以实现跳转到不同的页面。 // 简单跳转this.$router.push({ path: "/system/user" });// 跳转页面并设置请求参数,使用 `query` 属性this.$router.push({ path: "/system/user", query: {id: "1", name: "芋道"} }); # 3. 菜单管理 项目的菜单在 [系统管理 -> 菜单管理] 进行管理,支持无限 层级,提供目录、菜单、按钮三种类型。如下图所示: 菜单可在 [系统管理 -> 角色管理] 被分配给角色。如下图所示: # 3.1 新增目录 ① 大多数情况下,目录是作为菜单的【分类】: ② 目录也提供实现【外链】的能力: # 3.2 新增菜单 # 3.3 新增按钮 # 4. 权限控制 前端通过权限控制,隐藏用户没有权限的按钮等,实现功能级别的权限。 友情提示:前端的权限控制,主要是提升用户体验,避免操作后发现没有权限。 最终在请求到后端时,还是会进行一次权限的校验。 # 4.1 v-hasPermi 指令 v-hasPermi ( opens new window) 指令,基于权限字符,进行权限的控制。 <!-- 单个 --><el-button v-hasPermi="['system:user:create']">存在权限字符串才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasPermi="['system:user:create', 'system:user:update']">包含权限字符串才能看到</el-button> # 4.2 v-hasRole 指令 v-hasRole ( opens new window) 指令,基于角色标识,机进行的控制。 <!-- 单个 --><el-button v-hasRole="['admin']">管理员才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasRole="['role1', 'role2']">包含角色才能看到</el-button> # 4.3 结合 v-if 指令 在某些情况下,它是不适合使用 v-hasPermi 或 v-hasRole 指令,如元素标签组件。此时,只能通过手动设置 v-if,通过使用全局权限判断函数,用法是基本一致的。 <template> <el-tabs> <el-tab-pane v-if="checkPermi(['system:user:create'])" label="用户管理" name="user">用户管理</el-tab-pane> <el-tab-pane v-if="checkPermi(['system:user:create', 'system:user:update'])" label="参数管理" name="menu">参数管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin'])" label="角色管理" name="role">角色管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin','common'])" label="定时任务" name="job">定时任务</el-tab-pane> </el-tabs></template><script>import { checkPermi, checkRole } from "@/utils/permission"; // 权限判断函数export default{ methods: { checkPermi, checkRole }}</script> # 5. 页面缓存 由于目前 keep-alive 和 router-view 是强耦合的,而且查看 Vue 的文档和源码不难发现 keep-alive 的 include 默认是优先匹配组件的 name ,所以在编写路由 router 和路由对应的 view component 的时候一定要确保两者的 name 是完全一致的。 注意,切记 view component 的 name 命名时候尽量保证唯一性,切记不要和某些组件的命名重复了,不然会递归引用最后内存溢出等问题。 友情提示:页面缓存是什么? 简单来说,Tab 切换时,开启页面缓存的 Tab 保持原本的状态,不进行刷新(不请求数据)。 详细可见 Vue 文档 —— KeepAlive ( opens new window) # 5.1 静态路由的示例 ① router 路由的 name 声明如下: { path: 'create-form', component: ()=>import('@/views/form/create'), name: 'createForm', meta: { title: 'createForm', icon: 'table' }} ② view component 的 name 声明如下: export default { name: 'createForm'} 一定要保证两者的名字相同,切记写重或者写错。默认如果不写 name 就不会被缓存,详情见 issue (opens new window)。 # 5.2 动态路由的示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 开发规范 Icon 图标 ← 开发规范 Icon 图标→"},{"title":"通用方法","path":"/wiki/YuDaoBoot/前端手册 Vue 2/通用方法/通用方法.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 通用方法 本小节,分享前端项目的常用方法。 # 1. $tab 对象 @tab 对象,由 plugins/tab.js (opens new window) 实现,用于 Tab 标签相关的操作。它有如下方法: ① 打开页签 this.$tab.openPage("用户管理", "/system/user");this.$tab.openPage("用户管理", "/system/user").then(() => { // 执行结束的逻辑}) ② 修改页签 const obj = Object.assign({}, this.$route, { title: "自定义标题" })this.$tab.updatePage(obj);this.$tab.updatePage(obj).then(() => { // 执行结束的逻辑}) ③ 关闭页签 // 关闭当前 tab 页签,打开新页签const obj = { path: "/system/user" };this.$tab.closeOpenPage(obj);// 关闭当前页签,回到首页this.$tab.closePage();// 关闭指定页签const obj = { path: "/system/user", name: "User" };this.$tab.closePage(obj);this.$tab.closePage(obj).then(() => { // 执行结束的逻辑}) ④ 刷新页签 // 刷新当前页签this.$tab.refreshPage();// 刷新指定页签const obj = { path: "/system/user", name: "User" };this.$tab.refreshPage(obj);this.$tab.refreshPage(obj).then(() => { // 执行结束的逻辑}) ⑤ 关闭所有页签 this.$tab.closeAllPage();this.$tab.closeAllPage().then(() => { // 执行结束的逻辑}) ⑥ 关闭左侧页签 this.$tab.closeLeftPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeLeftPage(obj);this.$tab.closeLeftPage(obj).then(() => { // 执行结束的逻辑}) ⑦ 关闭右侧页签 this.$tab.closeRightPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeRightPage(obj);this.$tab.closeRightPage(obj).then(() => { // 执行结束的逻辑}) ⑧ 关闭其它页签 this.$tab.closeOtherPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeOtherPage(obj);this.$tab.closeOtherPage(obj).then(() => { // 执行结束的逻辑}) # 2. $modal 对象 @modal 对象,由 plugins/modal.js (opens new window) 实现,用于做消息提示、通知提示、对话框提醒、二次确认、遮罩等。它有如下方法: ① 提供成功、警告和错误等反馈信息 this.$modal.msg("默认反馈");this.$modal.msgError("错误反馈");this.$modal.msgSuccess("成功反馈");this.$modal.msgWarning("警告反馈"); ② 提供成功、警告和错误等提示信息 this.$modal.alert("默认提示");this.$modal.alertError("错误提示");this.$modal.alertSuccess("成功提示");this.$modal.alertWarning("警告提示"); ③ 提供成功、警告和错误等通知信息 this.$modal.notify("默认通知");this.$modal.notifyError("错误通知");this.$modal.notifySuccess("成功通知");this.$modal.notifyWarning("警告通知"); ④ 提供确认窗体信息 this.$modal.confirm('确认信息').then(function() { // ...}).then(() => { // ...}).catch(() => {}); ⑤ 提供遮罩层信息 // 打开遮罩层this.$modal.loading("正在导出数据,请稍后...");// 关闭遮罩层this.$modal.closeLoading(); # 3. $auth 对象 @auth 对象,由 plugins/auth.js (opens new window) 实现,用于验证用户是否拥有某(些)权限或角色。它有如下方法: ① 验证用户权限 // 验证用户是否具备某权限this.$auth.hasPermi("system:user:add");// 验证用户是否含有指定权限,只需包含其中一个this.$auth.hasPermiOr(["system:user:add", "system:user:update"]);// 验证用户是否含有指定权限,必须全部拥有this.$auth.hasPermiAnd(["system:user:add", "system:user:update"]); ② 验证用户角色 // 验证用户是否具备某角色this.$auth.hasRole("admin");// 验证用户是否含有指定角色,只需包含其中一个this.$auth.hasRoleOr(["admin", "common"]);// 验证用户是否含有指定角色,必须全部拥有this.$auth.hasRoleAnd(["admin", "common"]); # 4. $cache 对象 @auth 对象,由 plugins/cache.js (opens new window) 实现,基于 session 或 local 实现不同级别的缓存。它有如下方法: 对象名称 缓存类型 session 会话级缓存,通过 sessionStorage (opens new window) 实现 local 本地级缓存,通过 localStorage (opens new window) 实现 ① 读写 String 缓存 // local 普通值this.$cache.local.set('key', 'local value')console.log(this.$cache.local.get('key')) // 输出 'local value'// session 普通值this.$cache.session.set('key', 'session value')console.log(this.$cache.session.get('key')) // 输出 'session value' ② 读写 JSON 缓存 // local JSON值 this.$cache.local.setJSON('jsonKey', { localProp: 1 })console.log(this.$cache.local.getJSON('jsonKey')) // 输出 '{localProp: 1}'// session JSON值this.$cache.session.setJSON('jsonKey', { sessionProp: 1 })console.log(this.$cache.session.getJSON('jsonKey')) // 输出 '{sessionProp: 1}' ③ 删除缓存 this.$cache.local.remove('key')this.$cache.session.remove('key') # 5. $download 对象 $download 对象,由 plugins/download.js (opens new window) 实现,用于各种类型的文件下载。它有如下方法: 方法列表 this.$download.excel(data, fileName);this.$download.word(data, fileName);this.$download.zip(data, fileName);this.$download.html(data, fileName);this.$download.markdown(data, fileName); 在 user/index.vue (opens new window) 页面中,导出 Excel 文件的代码如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 系统组件 配置读取 ← 系统组件 配置读取→"},{"title":"配置读取","path":"/wiki/YuDaoBoot/前端手册 Vue 2/配置读取/配置读取.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 配置读取 在 [基础设施 -> 配置管理] 菜单,可以动态修改配置,无需重启服务器即可生效。 提示 对应 《后端手册 —— 配置中心》 文档。 # 1. 读取配置 前端调用 /@api/infra/config (opens new window) 的 #getConfigKey(configKey) 方法,获取指定 key 对应的配置的值。代码如下: export function getConfigKey(configKey) { return request({ url: '/infra/config/get-value-by-key?key=' + configKey, method: 'get' })} # 2. 实战案例 在 src/views/infra/server/index.vue ( opens new window) 页面中,获取 key 为 \"url.skywalking\" 的配置的值。代码如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:01 通用方法 开发规范 ← 通用方法 开发规范→"},{"title":"系统组件","path":"/wiki/YuDaoBoot/前端手册 Vue 2/系统组件/系统组件.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 系统组件 # 1. 引入三方组件 除了 Element UI 组件以及项目内置的系统组件,有时还需要引入其它三方组件 (opens new window)。 # 1.1 如何安装 这里,以引入 vue-count-to (opens new window) 为例。在终端输入下面的命令完成安装: ## 加上 --save 参数,会自动添加依赖到 package.json 中去。npm install vue-count-to --save # 1.2 如何注册 Vue 注册组件有两种方式:全局注册、局部注册。 # 1.2.1 局部注册 在对应的 Vue 页面中,使用 components 属性来注册组件。代码如下: <template> <countTo :startVal='startVal' :endVal='endVal' :duration='3000'></countTo></template><script>import countTo from 'vue-count-to';export default { components: { countTo }, // components 属性 data () { return { startVal: 0, endVal: 2017 } }}</script> # 1.2.2 全局注册 ① 在 main.js ( opens new window) 中,全局注册组件。代码如下: import countTo from 'vue-count-to'Vue.component('countTo', countTo) ② 在对应的 Vue 页面中,直接使用组件,无需注册。代码如下: <template> <countTo :startVal='startVal' :endVal='endVal' :duration='3000'></countTo></template> # 2. 系统组件 项目使用到的相关组件。 # 2.1 基础框架组件 element-ui ( opens new window) vue-element-admin ( opens new window) # 2.2 树形选择组件 vue-treeselect ( opens new window) 在 menu/index.vue ( opens new window) 的使用案例: <el-form-item label="上级菜单"> <treeselect v-model="form.parentId" :options="menuOptions" :normalizer="normalizer" :show-count="true" placeholder="选择上级菜单"/></el-form-item> # 2.3 表格分页组件 el-pagination (opens new window),二次封装成 pagination (opens new window) 组件。 在 notice/index.vue (opens new window) 的使用案例: <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize" @pagination="getList"/> # 2.4 工具栏右侧组件 right-toolbar (opens new window) 在 notice/index.vue (opens new window) 的使用案例: <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> # 2.5 文件上传组件 file-upload (opens new window) # 2.6 图片上传组件 图片上传组件 image-upload (opens new window) 图片预览组件 image-preview (opens new window) # 2.7 富文本编辑器 quill (opens new window),二次封装成 Editor (opens new window) 组件。 在 notice/index.vue (opens new window) 的使用案例: <el-form-item label="内容"> <editor v-model="form.content" :min-height="192"/></el-form-item> # 2.8 表单设计组件 ① 表单设计组件 form-generator (opens new window) 在 build/index.vue (opens new window) 中使用,效果如下图: ② 表单展示组件 parser (opens new window),基于 form-generator (opens new window) 封装。 在 processInstance/create.vue (opens new window) 的使用案例: <parser :key="new Date().getTime()" :form-conf="detailForm" @submit="submitForm" /> # 2.9 工作流组件 bpmn-process-designer (opens new window),二次封装成 bpmnProcessDesigner (opens new window) 工作流设计组件 ① 工作流设计组件 my-process-designer (opens new window),在 bpm/model/modelEditor.vue (opens new window) 中使用案例: <!-- 流程设计器,负责绘制流程等 --><my-process-designer :key="`designer-${reloadIndex}`" v-model="xmlString" v-bind="controlForm" keyboard ref="processDesigner" @init-finished="initModeler" @save="save"/><!-- 流程属性器,负责编辑每个流程节点的属性 --><my-properties-panel :key="`penal-${reloadIndex}`" :bpmn-modeler="modeler" :prefix="controlForm.prefix" class="process-panel" :model="model" /> ② 工作流展示组件 my-process-viewer (opens new window),在 bpm/model/modelEditor.vue (opens new window) 中使用案例: <my-process-viewer key="designer" v-model="bpmnXML" v-bind="bpmnControlForm" :activityData="activityList" :processInstanceData="processInstance" :taskData="tasks" /> # 2.10 Cron 表达式组件 vue-crontab (opens new window),二次封装成 crontab (opens new window) 组件。 在 job/index.vue (opens new window) 的使用案例: <crontab @hide="openCron=false" @fill="crontabFill" :expression="expression"></crontab> # 2.11 内容复制组件 clipboard (opens new window),使用可见 文档 (opens new window)。 在 codegen/index.vue (opens new window) 的使用案例: <el-link :underline="false" icon="el-icon-document-copy" style="float:right" v-clipboard:copy="item.code" v-clipboard:success="clipboardSuccess"> 复制</el-link> # 3. 其它推荐组件 推荐一些其它组件,可自己引入后使用。 Tree Table 树形表格:使用文档 (opens new window) Excel 前端直接导出:使用文档 (opens new window) CodeMirror 代码编辑器:使用文档 (opens new window) wangEditor 文本编辑器:使用文档 (opens new window) mavonEditor Markdown 编辑器:使用文档 (opens new window) # 4. 自定义组件 在 @/components (opens new window) 目录下,创建 .vue 文件,在通过 components 进行注册即可。 # 4.1 创建使用 新建一个简单的 a 组件来举例子。 ① 在 @/components/ 目录下,创建 test 文件,再创建 a.vue 文件。代码如下: <!-- 子组件 --><template> <div>这是a组件</div></template> ② 在其它 Vue 页面,导入并注册后使用。代码如下: <!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa></testa> <!-- 3. 使用 --> </div></template><script>import a from "@/components/a"; // 1. 引入export default { components: { testa: a } // 2. 注册};</script> # 4.2 组件通信 基于上述的 a 示例组件,讲解父子组件如何通信。 ① 子组件通过 props 属性,来接收父组件传递的值。代码如下: <!-- 子组件 --><template> <div>这是a组件 name:{{ name }}</div></template><script> export default { props: { // 1. props 的 name 进行接收 name: { type: String, default: "" }, } };</script><!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa :name="name"></testa> <!-- 2. :name 传入 --> </div></template><script>import a from "@/components/a";export default { components: { testa: a }, data() { return { name: "芋道" }; },};</script> ② 子组件通过 $emit 方法,让父组件监听到自定义事件。代码如下: <!-- 子组件 --><template> <div> 这是a组件 name:{{ name }} <button @click="click">发送</button> </div></template><script>export default { props: { name: { type: String, default: "" }, }, data() { return { message: "我是来自子组件的消息" }; }, methods: { click() { this.$emit("ok", this.message); // 1. $emit 方法,通知 ok 事件,message 是参数 }, },};</script><!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa :name="name" @ok="ok"></testa> 子组件传来的值 : {{ message }} </div></template><script>import a from "@/components/a";export default { components: { testa: a }, data() { return { name: "芋道", message: "" }; }, methods: { ok(message) { // 2. 声明 ok 方法,监听 ok 自定义事件 this.message = message; }, },};</script> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 字典数据 通用方法 ← 字典数据 通用方法→"},{"title":"CRUD 组件","path":"/wiki/YuDaoBoot/前端手册 Vue 3/CRUD 组件/CRUD 组件.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-05 目录 CRUD 组件 管理后台的功能,一般就是 CRUD 增删改查,可以拆分 3 个部分:“列表”、“新增/修改”、“详情”,如下图所示: 部分 组件 示例 列表 Search + Table 新增 / 修改 Form 详情 Descriptions # 1. 基础组件 涉及到 4 个前端基础组件,如下所示: 组件 文档 Search (opens new window) 查询组件 (opens new window) Table (opens new window) 表格组件 (opens new window) Form (opens new window) 表单组件 (opens new window) Descriptions (opens new window) 描述组件 (opens new window) # 2. CRUD 组件 由于以上 4 个组件都需要 Schema 或者 columns 的字段,如果每个组件都写一遍的话,会造成大量重复代码,所以提供 useCrudSchemas 来进行统一的数据生成。 ① useCrudSchemas:位于 src/hooks/web/useCrudSchemas.ts (opens new window) 内 ② useCrudSchemas 可以理解成一个 JSON 配置,示例如下: useCrudSchemas 示例 <script setup lang="ts">import { CrudSchema, useCrudSchemas } from '@/hooks/web/useCrudSchemas'const crudSchemas = reactive<CrudSchema[]>([ { field: 'index', label: t('tableDemo.index'), type: 'index', form: { show: false }, detail: { show: false } }, { field: 'title', label: t('tableDemo.title'), search: { show: true }, form: { colProps: { span: 24 } }, detail: { span: 24 } }, { field: 'author', label: t('tableDemo.author') }, { field: 'display_time', label: t('tableDemo.displayTime'), form: { component: 'DatePicker', componentProps: { type: 'datetime', valueFormat: 'YYYY-MM-DD HH:mm:ss' } } }, { field: 'importance', label: t('tableDemo.importance'), formatter: (_: Recordable, __: TableColumn, cellValue: number) => { return h( ElTag, { type: cellValue === 1 ? 'success' : cellValue === 2 ? 'warning' : 'danger' }, () => cellValue === 1 ? t('tableDemo.important') : cellValue === 2 ? t('tableDemo.good') : t('tableDemo.commonly') ) }, form: { component: 'Select', componentProps: { options: [ { label: '重要', value: 3 }, { label: '良好', value: 2 }, { label: '一般', value: 1 } ] } } }, { field: 'pageviews', label: t('tableDemo.pageviews'), form: { component: 'InputNumber', value: 0 } }, { field: 'content', label: t('exampleDemo.content'), table: { show: false }, form: { component: 'Editor', colProps: { span: 24 } }, detail: { span: 24 } }, { field: 'action', width: '260px', label: t('tableDemo.action'), form: { show: false }, detail: { show: false } }])const { allSchemas } = useCrudSchemas(crudSchemas)</script> ③ 字段的详细说明,可见 useCrudSchemas 文档 (opens new window)。 # 3. 实战案例 项目的 [系统管理 -> 邮箱管理] 相关的功能,都使用 CRUD 实现,你可以自己去学习。 功能 代码 邮箱账号 src/views/system/mail/account (opens new window) 邮箱模版 src/views/system/mail/template (opens new window) 邮箱记录 src/views/system/mail/log (opens new window) # 4. 常见问题 # 4.1 如何隐藏某个字段? 如 formSchema 不需要 field 为 createTime 的字段,可以使用 form: { show: false } 或 isForm: false 进行过滤,其他组件同理。 # 4.2 如何使用数据字典? 设置 dictType 字典的类型,和 dictClass 字典的数据类型。 # 4.3 如何使用 API 获取数据? 使用 api 来获取接口数据,需要主动 return 数据。 # 4.4 如何结合 Slot 自定义? 如果想要自定义,可以结合 Slot 来实现。具体有哪些 Slot,阅读对应基础组件的文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/05, 22:46:09 配置读取 国际化 ← 配置读取 国际化→"},{"title":"Icon 图标","path":"/wiki/YuDaoBoot/前端手册 Vue 3/Icon 图标/Icon 图标.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 Icon 图标 Element Plus 内置多种 Icon 图标,可参考 Element Plus —— Icon 图标 (opens new window) 的文档。 在项目的 /src/assets/svgs (opens new window) 目录下,自定义了 Icon 图标,默认注册到全局中,可以在项目中任意地方使用。如下图所示: # 1. Icon 图标组件 友情提示: 该小节,基于 《vue element plus admin —— Icon 图标组件 》 (opens new window) 的内容修改。 Icon 组件位于 src/components/Icon (opens new window) 内,用于项目内组件的展示,基本支持所有图标库(支持按需加载,只打包所用到的图标),支持使用本地 svg 和 Iconify (opens new window) 图标。 提示 在 Iconify (opens new window) 上,你可以查询到你想要的所有图标并使用,不管是不是 element-plus 的图标库。 # 1.1 基本用法 如果以 svg-icon: 开头,则会在本地中找到该 svg 图标,否则,会加载 Iconify 图标。代码如下: <template> <!-- 加载本地 svg --> <Icon icon="svg-icon:peoples" /> <!-- 加载 Iconify --> <Icon icon="ep:aim" /></template> # 1.2 useIcon 如果需要在其他组件中如 ElButton 传入 icon 属性,可以使用 useIcon。代码如下: <script setup lang="ts">import { useIcon } from '@/hooks/web/useIcon'import { ElButton } from 'element-plus'const icon = useIcon({ icon: 'svg-icon:save' })</script><template> <ElButton :icon="icon"> button </ElButton></template> useIcon 的 props 属性如下: 属性 说明 类型 可选值 默认值 icon 图标名 string - - color 图标颜色 string - - size 图标大小 number - 16 # 2. 自定义图标 ① 访问 https://www.iconfont.cn/ (opens new window) 地址,搜索你想要的图标,下载 SVG 格式。如下图所示: 友情提示:其它 SVG 图标网站也可以。 ② 将 SVG 图标添加到 /src/assets/svgs (opens new window) 目录下,然后进行使用。 <Icon icon="svg-icon:helpless" /> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 菜单路由 字典数据 ← 菜单路由 字典数据→"},{"title":"IDE 调试","path":"/wiki/YuDaoBoot/前端手册 Vue 3/IDE 调试/IDE 调试.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-13 目录 IDE 调试 除了使用 Chrome 调试 JS 代码外,我们也可以使用 IDEA / WebStorm 或 VS Code 进行代码的调试。 # 1. IDEA 调试 友情提示:WebStorm 也支持。 ① 使用 npm 命令将前端项目运行起来,例如说 npm run dev。耐心等待项目启动成功~ ② 点击链接,Windows 需按住 Ctrl + Shift + 鼠标左键,MacOS 需要按住 Shift + Command + 鼠标左键。如下图所示: ③ 点击后,会跳出一个独立的 Chrome 窗口。如下图所示: ④ 打个断点,例如说 /src/api/login/index.ts 的登录接口。如下图所示: ⑤ 使用管理后台进行登录,可以看到成功进入断点。如下图所示: # 2. VS Code 调试 ① 使用 npm 命令将前端项目运行起来,例如说 npm run dev。耐心等待项目启动成功~ ② 点击 VS Code 左侧的运行和调试,然后启动 Launch,之后会跳出一个独立的 Edge 窗口。如下图所示: ③ 打个断点,例如说 /src/api/login/index.ts 的登录接口。如下图所示: ④ 使用管理后台进行登录,可以看到成功进入断点。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/13, 23:26:46 国际化 【v1.7.3】开发中 ← 国际化 【v1.7.3】开发中→"},{"title":"字典数据","path":"/wiki/YuDaoBoot/前端手册 Vue 3/字典数据/字典数据.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-04-17 目录 字典数据 本小节,讲解前端如何使用 [系统管理 -> 字典管理] 菜单的字典数据,例如说字典数据的下拉框、单选 / 多选按钮、高亮展示等等。 # 1. 全局缓存 用户登录成功后,前端会从后端获取到全量的字典数据,缓存在 store 中。如下图所示: 这样,前端在使用到字典数据时,无需重复请求后端,提升用户体验。 不过,缓存暂时未提供刷新,所以在字典数据发生变化时,需要用户刷新浏览器,进行重新加载。 # 2. DICT_TYPE 在 dict.ts (opens new window) 文件中,使用 DICT_TYPE 枚举了字典的 KEY。如下图所示: 后续如果有新的字典 KEY,需要你自己进行添加。 # 3. DictTag 字典标签 <dict-tag /> (opens new window) 组件,翻译字段对应的字典展示文本,并根据 colorType、cssClass 进行高亮。使用示例如下: <!-- type: 字典 KEY value: 字典值--><dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_TYPE" :value="row.logType" /> 【推荐】注意,一般情况下使用 CRUD schemas 方式,不需要直接使用 <dict-tag />,而是通过 columns 的 dictType 和 dictClass 属性即可。如下图所示: # 4. 字典工具类 在 dict.ts (opens new window) 文件中,提供了字典工具类,方法如下: // 获取 dictType 对应的数据字典数组【object】export const getDictOptions = (dictType: string) => {{ /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【int】export const getIntDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【string】export const getStrDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【boolean】export const getBoolDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【object】export const getDictObj = (dictType: string, value: any) => { /** 省略代码 */ } 结合 Element Plus 的表单组件,使用示例如下: <template> <!-- radio 单选框 --> <el-radio v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="parseInt(dict.value)" > {{dict.label}} </el-radio> <!-- select 下拉框 --> <el-select v-model="form.code" placeholder="请选择渠道编码" clearable> <el-option v-for="dict in getStrDictOptions(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE)" :key="dict.value" :label="dict.label" :value="dict.value" /> </el-select></template><script setup lang="tsx">import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'</script> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 Icon 图标 系统组件 ← Icon 图标 系统组件→"},{"title":"开发规范","path":"/wiki/YuDaoBoot/前端手册 Vue 3/开发规范/开发规范.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-04-17 目录 开发规范 # 0. 实战案例 本小节,提供大家开发管理后台的功能时,最常用的普通列表、树形列表、新增与修改的表单弹窗、详情表单弹窗的实战案例。 # 0.1 普通列表 可参考 [系统管理 -> 岗位管理] 菜单: API 接口:/src/api/system/post/index.ts (opens new window) 列表界面:/src/views/system/post/index.vue (opens new window) 表单界面:/src/views/system/post/PostForm.vue (opens new window) 为什么界面拆成列表和表单两个 Vue 文件? 每个 Vue 文件,只实现一个功能,更简洁,维护性更好,Git 代码冲突概率低。 # 0.2 树形列表 可参考 [系统管理 -> 部门管理] 菜单: API 接口:/src/api/system/dept/index.ts (opens new window) 列表界面:/src/views/system/dept/index.vue (opens new window) 表单界面:/src/views/system/dept/DeptForm.vue (opens new window) # 0.3 高性能列表 可参考 [系统管理 -> 地区管理] 菜单,对应 /src/views/system/area/index.vue (opens new window) 列表界面 基于 Virtualized Table 虚拟化表格 (opens new window) 实现,解决一屏里超过 1000 条数据记录时,就会出现卡顿等性能问题。 # 0.4 详情弹窗 可参考 [基础设施 -> API 日志 -> 访问日志] 菜单,对应 /src/views/infra/apiAccessLog/ApiAccessLogDetail.vue (opens new window) 详情弹窗 # 1. view 页面 在 @views (opens new window) 目录下,每个模块对应一个目录,它的所有功能的 .vue 都放在该目录里。 一般来说,一个路由对应一个 index.vue 文件。 # 2. api 请求 在 @/api (opens new window) 目录下,每个模块对应一个 index.ts API 文件。 API 方法:会调用 request 方法,发起对后端 RESTful API 的调用。 interface 类型:定义了 API 的请求参数和返回结果的类型,对应后端的 VO 类型。 # 2.1 请求封装 /src/config/axios/index.ts (opens new window) 基于 axios (opens new window) 封装,统一处理 GET、POST 方法的请求参数、请求头,以及错误提示信息等。 # 2.1.1 创建 axios 实例 baseURL 基础路径 timeout 超时时间,默认为 30000 毫秒 实现代码 /src/config/axios/service.ts import axios from 'axios'const { result_code, base_url, request_timeout } = config// 创建 axios 实例const service: AxiosInstance = axios.create({ baseURL: base_url, // api 的 base_url timeout: request_timeout, // 请求超时时间 withCredentials: false // 禁用 Cookie 等信息}) # 2.1.2 Request 拦截器 【重点】Authorization、tenant-id 请求头 GET 请求参数的拼接 实现代码 /src/config/axios/service.ts import axios, { AxiosInstance, AxiosRequestHeaders, AxiosResponse, AxiosError, InternalAxiosRequestConfig} from 'axios'import { getAccessToken, getTenantId } from '@/utils/auth'const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLEservice.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // 是否需要设置 token let isToken = (config!.headers || {}).isToken === false whiteList.some((v) => { if (config.url) { config.url.indexOf(v) > -1 return (isToken = false) } }) if (getAccessToken() && !isToken) { (config as Recordable).headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token } // 设置租户 if (tenantEnable && tenantEnable === 'true') { const tenantId = getTenantId() if (tenantId) (config as Recordable).headers['tenant-id'] = tenantId } const params = config.params || {} const data = config.data || false if ( config.method?.toUpperCase() === 'POST' && (config.headers as AxiosRequestHeaders)['Content-Type'] === 'application/x-www-form-urlencoded' ) { config.data = qs.stringify(data) } // get参数编码 if (config.method?.toUpperCase() === 'GET' && params) { let url = config.url + '?' for (const propName of Object.keys(params)) { const value = params[propName] if (value !== void 0 && value !== null && typeof value !== 'undefined') { if (typeof value === 'object') { for (const val of Object.keys(value)) { const params = propName + '[' + val + ']' const subPart = encodeURIComponent(params) + '=' url += subPart + encodeURIComponent(value[val]) + '&' } } else { url += `${propName}=${encodeURIComponent(value)}&` } } } // 给 get 请求加上时间戳参数,避免从缓存中拿数据 // const now = new Date().getTime() // params = params.substring(0, url.length - 1) + `?_t=${now}` url = url.slice(0, -1) config.params = {} config.url = url } return config }, (error: AxiosError) => { // Do something with request error console.log(error) // for debug Promise.reject(error) }) # 2.1.3 Response 拦截器 访问令牌 AccessToken 过期时,使用刷新令牌 RefreshToken 刷新,获得新的访问令牌 刷新令牌失败(过期)时,跳回首页进行登录 请求失败,Message 错误提示 实现代码 /src/config/axios/service.ts import axios, { AxiosInstance, AxiosRequestHeaders, AxiosResponse, AxiosError, InternalAxiosRequestConfig} from 'axios'import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'import { getAccessToken, getRefreshToken, removeToken, setToken } from '@/utils/auth'// 需要忽略的提示。忽略后,自动 Promise.reject('error')const ignoreMsgs = [ '无效的刷新令牌', // 刷新令牌被删除时,不用提示 '刷新令牌已过期' // 使用刷新令牌,刷新获取新的访问令牌时,结果因为过期失败,此时需要忽略。否则,会导致继续 401,无法跳转到登出界面]// 是否显示重新登录export const isRelogin = { show: false }import errorCode from './errorCode'import { resetRouter } from '@/router'import { useCache } from '@/hooks/web/useCache'service.interceptors.response.use( async (response: AxiosResponse<any>) => { const { data } = response const config = response.config if (!data) { // 返回“[HTTP]请求没有返回值”; throw new Error() } const { t } = useI18n() // 未设置状态码则默认成功状态 const code = data.code || result_code // 二进制数据则直接返回 if ( response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer' ) { return response.data } // 获取错误信息 const msg = data.msg || errorCode[code] || errorCode['default'] if (ignoreMsgs.indexOf(msg) !== -1) { // 如果是忽略的错误码,直接返回 msg 异常 return Promise.reject(msg) } else if (code === 401) { // 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了 if (!isRefreshToken) { isRefreshToken = true // 1. 如果获取不到刷新令牌,则只能执行登出操作 if (!getRefreshToken()) { return handleAuthorized() } // 2. 进行刷新访问令牌 try { const refreshTokenRes = await refreshToken() // 2.1 刷新成功,则回放队列的请求 + 当前请求 setToken((await refreshTokenRes).data.data) config.headers!.Authorization = 'Bearer ' + getAccessToken() requestList.forEach((cb: any) => { cb() }) requestList = [] return service(config) } catch (e) { // 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。 // 2.2 刷新失败,只回放队列的请求 requestList.forEach((cb: any) => { cb() }) // 提示是否要登出。即不回放当前请求!不然会形成递归 return handleAuthorized() } finally { requestList = [] isRefreshToken = false } } else { // 添加到队列,等待刷新获取到新的令牌 return new Promise((resolve) => { requestList.push(() => { config.headers!.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token 请根据实际情况自行修改 resolve(service(config)) }) }) } } else if (code === 500) { ElMessage.error(t('sys.api.errMsg500')) return Promise.reject(new Error(msg)) } else if (code === 901) { ElMessage.error({ offset: 300, dangerouslyUseHTMLString: true, message: '<div>' + t('sys.api.errMsg901') + '</div>' + '<div> &nbsp; </div>' + '<div>参考 https://doc.iocoder.cn/ 教程</div>' + '<div> &nbsp; </div>' + '<div>5 分钟搭建本地环境</div>' }) return Promise.reject(new Error(msg)) } else if (code !== 200) { if (msg === '无效的刷新令牌') { // hard coding:忽略这个提示,直接登出 console.log(msg) } else { ElNotification.error({ title: msg }) } return Promise.reject('error') } else { return data } }, (error: AxiosError) => { console.log('err' + error) // for debug let { message } = error const { t } = useI18n() if (message === 'Network Error') { message = t('sys.api.errorMessage') } else if (message.includes('timeout')) { message = t('sys.api.apiTimeoutMessage') } else if (message.includes('Request failed with status code')) { message = t('sys.api.apiRequestFailed') + message.substr(message.length - 3) } ElMessage.error(message) return Promise.reject(error) })const refreshToken = async () => { axios.defaults.headers.common['tenant-id'] = getTenantId() return await axios.post(base_url + '/system/auth/refresh-token?refreshToken=' + getRefreshToken())}const handleAuthorized = () => { const { t } = useI18n() if (!isRelogin.show) { isRelogin.show = true ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), { confirmButtonText: t('login.relogin'), cancelButtonText: t('common.cancel'), type: 'warning' }) .then(() => { const { wsCache } = useCache() resetRouter() // 重置静态路由表 wsCache.clear() removeToken() isRelogin.show = false window.location.href = '/' }) .catch(() => { isRelogin.show = false }) } return Promise.reject(t('sys.api.timeoutMessage'))} # 2.2 交互流程 一个完整的前端 UI 交互到服务端处理流程,如下图所示: 继续以 [系统管理 -> 岗位管理] 菜单为例,查看它是如何读取岗位列表的。代码如下: // ① api/system/post/index.tsimport request from '@/config/axios'// 查询岗位列表export const getPostPage = async (params: PageParam) => { return await request.get({ url: '/system/post/page', params })}// ② views/system/post/index.vue<script setup lang="tsx">const loading = ref(true) // 列表的加载中const total = ref(0) // 列表的总页数const list = ref([]) // 列表的数据const queryParams = reactive({ pageNo: 1, pageSize: 10, code: '', name: '', status: undefined})/** 查询岗位列表 */const getList = async () => { loading.value = true try { const data = await PostApi.getPostPage(queryParams) list.value = data.list total.value = data.total } finally { loading.value = false }}</script> # 3. component 组件 # 3.1 全局组件 在 @/components ( opens new window) 目录下,实现全局组件,被所有模块所公用。 例如说,富文本编辑器、各种各搜索组件、封装的分页组件等等。 # 3.2 模块内组件 每个模块的业务组件,可实现在 views 目录下,自己模块的目录的 components 目录下,避免单个 .vue 文件过大,降低维护成功。 例如说, @/views/pay/app/components/xxx.vue: # 4. style 样式 ① 在 @/styles ( opens new window) 目录下,实现全局 样式,被所有页面所公用。 ② 每个 .vue 页面,可在 <style /> 标签中添加样式,注意需要添加 scoped 表示只作用在当前页面里,避免造成全局的样式污染。 更多也可以看看如下两篇文档: 《vue-element-plus-admin —— 项目配置「样式配置」》 ( opens new window) 《vue-element-plus-admin —— 样式》 ( opens new window) # 5. 项目规范 可参考 《vue-element-plus-admin —— 项目规范》 ( opens new window) 文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 配置读取 菜单路由 ← 配置读取 菜单路由→"},{"title":"国际化","path":"/wiki/YuDaoBoot/前端手册 Vue 3/国际化/国际化.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 国际化 友情提示: 该章节,基于 《vue element plus admin —— 国际化》 (opens new window) 的内容修改。 如果你使用的 vscode 开发工具,则推荐安装 I18n-ally (opens new window) 这个插件 # 1. I18n-ally 插件 安装了该插件后,你的代码内可以实时看到对应的语言内容 # 2. 配置默认语言 在 src/store/modules/locale.ts (opens new window) 内配置 currentLocale 为其他语言。 查看代码 import { defineStore } from 'pinia'import { store } from '../index'import zhCn from 'element-plus/es/locale/lang/zh-cn'import en from 'element-plus/es/locale/lang/en'import { CACHE_KEY, useCache } from '@/hooks/web/useCache'import { LocaleDropdownType } from '@/types/localeDropdown'const { wsCache } = useCache()const elLocaleMap = { 'zh-CN': zhCn, en: en}interface LocaleState { currentLocale: LocaleDropdownType localeMap: LocaleDropdownType[]}export const useLocaleStore = defineStore('locales', { state: (): LocaleState => { return { currentLocale: { lang: wsCache.get(CACHE_KEY.LANG) || 'zh-CN', elLocale: elLocaleMap[wsCache.get(CACHE_KEY.LANG) || 'zh-CN'] }, // 多语言 localeMap: [ { lang: 'zh-CN', name: '简体中文' }, { lang: 'en', name: 'English' } ] } }, getters: { getCurrentLocale(): LocaleDropdownType { return this.currentLocale }, getLocaleMap(): LocaleDropdownType[] { return this.localeMap } }, actions: { setCurrentLocale(localeMap: LocaleDropdownType) { // this.locale = Object.assign(this.locale, localeMap) this.currentLocale.lang = localeMap?.lang this.currentLocale.elLocale = elLocaleMap[localeMap?.lang] wsCache.set(CACHE_KEY.LANG, localeMap?.lang) } }})export const useLocaleStoreWithOut = () => { return useLocaleStore(store)} # 3. 语言文件 在 src/locales (opens new window) 可以配置具体的语言。 目前项目中的语言都是没有拆分的,全部放一起,后续会考虑拆分出来,比较好维护。 # 4. 语言导入逻辑说明 在 src/plugins/vueI18n/index.ts (opens new window) 内可以看到 const defaultLocal = await import(`../../locales/${locale.lang}.ts`) 这会导入 src/locales 文件语言包。 # 5. 使用 引入项目自带的 useI18n 注意不要引入 vue-i18n 的 useI18n import { useI18n } from '/@/hooks/web/useI18n'const { t } = useI18n()const title = t('common.menu') # 6. 切换语言 切换语言需要使用 src/hooks/web/useLocale.ts ( opens new window) import { useLocale } from '@/hooks/web/useLocale'const { changeLocale } = useLocale()changeLocale('en') # 7. 新增新语言 # 7.1 语言文件 在 src/locales ( opens new window) 增加对应语言的文件即可 # 7.2 新增语言 目前项目自带的语言只有 zh_CN 和 en 两种 如果需要新增,按以下操作即可 在 src/locales ( opens new window) 下语言文件 在 types/global.d.ts ( opens new window) 给 LocaleType 添加对应的类型 在 src/store/modules/locale.ts localeMap 中添加对应语言 # 8. 远程读取语言数据 目前项目会在 src/main.ts 内等待 setupI18n 这个函数执行完之后才会渲染界面,所以只需在 setupI18n 内的 createI18nOptions 发送 ajax 请求,将对应的数据设置到 i18n 实例上即可。 const createI18nOptions = async (): Promise<I18nOptions> => { const localeStore = useLocaleStoreWithOut() const locale = localeStore.getCurrentLocale const localeMap = localeStore.getLocaleMap // 这里改为远程请求即可。 const defaultLocal = await import(`../../locales/${locale.lang}.ts`) const message = defaultLocal.default ?? {} setHtmlPageLang(locale.lang) localeStore.setCurrentLocale({ lang: locale.lang // elLocale: elLocal }) return { legacy: false, locale: locale.lang, fallbackLocale: locale.lang, messages: { [locale.lang]: message }, availableLocales: localeMap.map((v) => v.lang), sync: true, silentTranslationWarn: true, missingWarn: false, silentFallbackWarn: true }} # 8.1 useLocale 代码: src/hooks/web/useLocale.ts ( opens new window) 当手动切换语言的时候会触发 useLocale 函数,useLocale 也是异步函数,只需等待接口返回响应的数据后,再进行设置即可 export const useLocale = () => { // Switching the language will change the locale of useI18n // And submit to configuration modification const changeLocale = async (locale: LocaleType) => { const globalI18n = i18n.global // 改为远程获取 const langModule = await import(`../../locales/${locale}.ts`) globalI18n.setLocaleMessage(locale, langModule.default) setI18nLanguage(locale) } return { changeLocale }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/05, 22:46:09 CRUD 组件 IDE 调试 ← CRUD 组件 IDE 调试→"},{"title":"系统组件","path":"/wiki/YuDaoBoot/前端手册 Vue 3/系统组件/系统组件.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-12-31 目录 系统组件 # 1. 常用组件 # 1.1 Editor 富文本组件 基于 wangEditor (opens new window) 封装 Editor 组件:位于 src/components/Editor (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/editor.html (opens new window) 实战案例:src/views/system/notice/form.vue (opens new window) TODO # 1.2 Dialog 弹窗组件 对 Element Plus 的 Dialog 组件进行封装,支持最大化、最大高度等特性 Dialog 组件:位于 src/components/Dialog (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/dialog.html (opens new window) 实战案例:src/views/system/dept/DeptForm.vue (opens new window) # 1.3 ContentWrap 包裹组件 对 Element Plus 的 ElCard 组件进行封装,自带标题、边距 ContentWrap 组件:位于 src/components/ContentWrap (opens new window) 内 实战案例:src/views/system/post/index.vue (opens new window) # 1.4 Pagination 分页组件 对 Element Plus 的 Pagination (opens new window) 组件进行封装 Pagination 组件:位于 src/components/Pagination (opens new window) 内 实战案例:src/views/system/post/index.vue (opens new window) # 1.5 UploadFile 上传文件组件 对 Element Plus 的 Upload (opens new window) 组件进行封装,上传文件到文件服务 UploadFile 组件:位于 src/components/UploadFile/src/UploadFile.vue (opens new window) 内 实战案例:暂无 # 1.6 UploadImg 上传图片组件 对 Element Plus 的 Upload (opens new window) 组件进行封装,上传图片到文件服务 UploadImg 组件:位于 src/components/UploadFile/src/UploadImg.vue (opens new window) 内 实战案例:src/views/system/oauth2/client/ClientForm.vue (opens new window) # 2. 不常用组件 # 2.1 EChart 图表组件 基于 Apache ECharts (opens new window) 封装,自适应窗口大小 EChart 组件:位于 src/components/EChart (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/echart.html (opens new window) 实战案例:src/views/mp/statistics/index.vue (opens new window) # 2.2 InputPassword 密码输入框 对 Element Plus 的 Input 组件进行封装 InputPassword 组件:位于 src/components/InputPassword (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/input-password.html (opens new window) 实战案例:src/views/Profile/components/ResetPwd.vue (opens new window) # 2.3 ContentDetailWrap 详情包裹组件 用于展示详情,自带返回按钮。 ContentDetailWrap 组件:位于 src/components/ContentDetailWrap (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/content-detail-wrap.html (opens new window) 实战案例:暂无 # 2.4 ImageViewer 图片预览组件 将 Element Plus 的 ImageViewer (opens new window) 组件函数化,通过函数方便创建组件 ImageViewer 组件:位于 src/components/ImageViewer (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/image-viewer.html (opens new window) 实战案例:暂无 # 2.5 Qrcode 二维码组件 基于 qrcode (opens new window) 封装 Qrcode 组件:位于 src/components/Qrcode (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/qrcode.html (opens new window) 实战案例:暂无 # 2.6 Highlight 高亮组件 Highlight 组件:位于 src/components/Highlight (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/highlight.html (opens new window) 实战案例:暂无 # 2.6.1 Infotip 信息提示组件 基于 Highlight 组件封装 Infotip 组件:位于 src/components/Infotip (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/infotip.html (opens new window) 实战案例:暂无 # 2.7 Error 缺省组件 用于各种占位图组件,如 404、403、500 等错误页面。 Error 组件:位于 src/components/Error (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/error.html (opens new window) 实战案例:403.vue (opens new window)、404.vue (opens new window)、500.vue (opens new window) # 2.8 Sticky 黏性组件 Sticky 组件:位于 src/components/Sticky (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/sticky.html (opens new window) 实战案例:暂无 # 2.9 CountTo 数字动画组件 CountTo 组件:位于 src/components/CountTo (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/count-to.html (opens new window) 实战案例:暂无 # 2.10 useWatermark 水印组件 为元素设置水印 useWatermark 组件:位于 src/hooks/web/useWatermark.ts (opens new window) 内 详细文档:vue-element-plus-admin-doc/hooks/useWatermark.html (opens new window) 实战案例:暂无 # 2.11 form-create 动态表单生成器 详细文档:http://www.form-create.com/ (opens new window) ① 实战案例 - 表单设计:src/views/infra/build/index.vue (opens new window) ② 实战案例 - 表单展示:src/views/bpm/processInstance/detail/index.vue (opens new window) # 2.12 bpmn-js 工作流组件 核心是基于 bpmn-js (opens new window) 封装 # 2.12.1 MyProcessDesigner 流程设计组件 MyProcessDesigner 组件:位于 src/components/bpmnProcessDesigner/package/designer/index.ts (opens new window) 内,基于 https://gitee.com/MiyueSC/bpmn-process-designer (opens new window) 项目适配 实战案例:src/views/bpm/model/editor/index.vue (opens new window) # 2.12.2 MyProcessViewer 流程展示组件 MyProcessViewer 组件:位于 src/components/bpmnProcessDesigner/package/designer/index2.ts (opens new window) 内 实战案例:src/views/bpm/processInstance/detail/ProcessInstanceBpmnViewer.vue (opens new window) # 3. 组件注册 友情提示: 该小节,基于 《vue element plus admin —— 组件注册 》 (opens new window) 的内容修改。 组件注册可以分成两种类型:按需引入、全局注册。 # 3.1 按需引入 项目目前的组件注册机制是按需注册,是在需要用到的页面才引入。 <script setup lang="ts">import { ElBacktop } from 'element-plus'import { useDesign } from '@/hooks/web/useDesign'const { getPrefixCls, variables } = useDesign()const prefixCls = getPrefixCls('backtop')</script><template> <ElBacktop :class="`${prefixCls}-backtop`" :target="`.${variables.namespace}-layout-content-scrollbar .${variables.elNamespace}-scrollbar__wrap`" /></template> 注意:tsx 文件内不能使用全局注册组件,需要手动引入组件使用。 # 3.2 全局注册 如果觉得按需引入太麻烦,可以进行全局注册,在 src/components/index.ts (opens new window),添加需要注册的组件。 以 Icon 组件进行了全局注册,举个例子: import type { App } from 'vue'import { Icon } from './Icon'export const setupGlobCom = (app: App<Element>): void => { app.component('Icon', Icon)} 如果 Element Plus 的组件需要全局注册,在 src/plugins/elementPlus/index.ts (opens new window) 添加需要注册的组件。 以 Element Plus 中只有 ElLoading 与 ElScrollbar 进行全局注册,举个例子: import type { App } from 'vue'// 需要全局引入一些组件,如 ElScrollbar,不然一些下拉项样式有问题import { ElLoading, ElScrollbar } from 'element-plus'const plugins = [ElLoading]const components = [ElScrollbar]export const setupElementPlus = (app: App) => { plugins.forEach((plugin) => { app.use(plugin) }) components.forEach((component) => { app.component(component.name, component) })} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 字典数据 通用方法 ← 字典数据 通用方法→"},{"title":"通用方法","path":"/wiki/YuDaoBoot/前端手册 Vue 3/通用方法/通用方法.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 通用方法 本小节,分享前端项目的常用方法。 # 1. 缓存配置 友情提示: 该小节,基于 《vue element plus admin —— 项目配置「缓存配置 」》 (opens new window) 的内容修改。 # 1.1 说明 在项目中,你可以看到很多地方都使用了 wsCache.set 或者 wsCache.get,这是基于 web-storage-cache (opens new window) 进行封装,采用 hook 的形式。 该插件对HTML5 localStorage 和 sessionStorage 进行了扩展,添加了超时时间,序列化方法。可以直接存储 json 对象,同时可以非常简单的进行超时时间的设置。 本项目默认是采用 sessionStorage 的存储方式,如果更改,可以直接在 useCache.ts (opens new window) 中把 type: CacheType = 'sessionStorage' 改为 type: CacheType = 'localStorage',这样项目中的所有用到的地方,都会变成该方式进行数据存储。 如果只想单个更改,可以传入存储类型 const { wsCache } = useCache('localStorage'),既可只适用当前存储对象。 注意: 更改完默认存储方式后,需要清除浏览器缓存并重新登录,以免造成不可描述的问题。 # 1.2 示例 # 2. message 对象 # 2.1 说明 message 对象,由 src/hooks/web/useMessage.ts (opens new window) 实现,基于 ElMessage、ElMessageBox、ElNotification 封装,用于做消息提示、通知提示、对话框提醒、二次确认等。 # 2.2 示例 # 3. download 对象 # 3.1 说明 $download 对象,由 util/download.ts (opens new window) 实现,用于 Excel、Word、Zip、HTML 等类型的文件下载。 # 3.2 示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 21:23:40 系统组件 配置读取 ← 系统组件 配置读取→"},{"title":"配置读取","path":"/wiki/YuDaoBoot/前端手册 Vue 3/配置读取/配置读取.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-07 目录 配置读取 在 [基础设施 -> 配置管理] 菜单,可以动态修改配置,无需重启服务器即可生效。 提示 对应 《后端手册 —— 配置中心》 文档。 # 1. 读取配置 前端调用 /@api/infra/config/index.ts (opens new window) 的 #getConfigKey(configKey) 方法,获取指定 key 对应的配置的值。代码如下: // 根据参数键名查询参数值export const getConfigKey = (configKey: string) => { return request.get({ url: '/infra/config/get-value-by-key?key=' + configKey })} # 2. 实战案例 在 src/views/infra/server/index.vue ( opens new window) 页面中,获取 key 为 \"url.skywalking\" 的配置的值。代码如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:01 通用方法 CRUD 组件 ← 通用方法 CRUD 组件→"},{"title":"菜单路由","path":"/wiki/YuDaoBoot/前端手册 Vue 3/菜单路由/菜单路由.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-12-31 目录 菜单路由 前端项目基于 vue-element-plus-admin 实现,它的 路由和侧边栏 (opens new window) 是组织起一个后台应用的关键骨架。 侧边栏和路由是绑定在一起的,所以你只有在 @/router/index.js (opens new window) 下面配置对应的路由,侧边栏就能动态的生成了,大大减轻了手动重复编辑侧边栏的工作量。 当然,这样就需要在配置路由的时候,遵循一些约定的规则。 # 1. 路由配置 首先,我们了解一下本项目配置路由时,提供了哪些配置项: /*** redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击* name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题* meta : { hidden: true 当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false) alwaysShow: true 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式, 只有一个时,会将那个子路由当做根路由显示在侧边栏, 若你想不管路由下面的 children 声明的个数都显示你的根路由, 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则, 一直显示根路由(默认 false) title: 'title' 设置该路由在侧边栏和面包屑中展示的名字 icon: 'svg-name' 设置该路由的图标 noCache: true 如果设置为true,则不会被 <keep-alive> 缓存(默认 false) breadcrumb: false 如果设置为false,则不会在breadcrumb面包屑中显示(默认 true) affix: true 如果设置为true,则会一直固定在tag项中(默认 false) noTagsView: true 如果设置为true,则不会出现在tag中(默认 false) activeMenu: '/dashboard' 显示高亮的路由路径 followAuth: '/dashboard' 跟随哪个路由进行权限过滤 canTo: true 设置为true即使hidden为true,也依然可以进行路由跳转(默认 false) }**/ # 1.1 普通示例 注意事项: 整个项目所有路由 name 不能重复 所有的多级路由最终都会转成二级路由,所以不能内嵌子路由 除了 layout 对应的 path 前面需要加 /,其余子路由都不要以 / 开头 { path: '/level', component: Layout, redirect: '/level/menu1/menu1-1/menu1-1-1', name: 'Level', meta: { title: t('router.level'), icon: 'carbon:skill-level-advanced' }, children: [ { path: 'menu1', name: 'Menu1', component: getParentLayout(), redirect: '/level/menu1/menu1-1/menu1-1-1', meta: { title: t('router.menu1') }, children: [ { path: 'menu1-1', name: 'Menu11', component: getParentLayout(), redirect: '/level/menu1/menu1-1/menu1-1-1', meta: { title: t('router.menu11'), alwaysShow: true }, children: [ { path: 'menu1-1-1', name: 'Menu111', component: () => import('@/views/Level/Menu111.vue'), meta: { title: t('router.menu111') } } ] }, { path: 'menu1-2', name: 'Menu12', component: () => import('@/views/Level/Menu12.vue'), meta: { title: t('router.menu12') } } ] }, { path: 'menu2', name: 'Menu2Demo', component: () => import('@/views/Level/Menu2.vue'), meta: { title: t('router.menu2') } } ]} # 1.2 外链示例 只需要将 path 设置为需要跳转的 HTTP 地址即可。 { path: '/external-link', component: Layout, meta: { name: 'ExternalLink' }, children: [ { path: 'https://www.iocoder.cn', meta: { name: 'Link', title: '芋道源码' } } ]} # 2. 路由 项目的路由分为两种:静态路由、动态路由。 # 2.1 静态路由 静态路由,代表那些不需要动态判断权限的路由,如登录页、404、个人中心等通用页面。 在 @/router/modules/remaining.ts ( opens new window) 的 remainingRouter ,就是配置对应的公共路由。如下图所示: # 2.2 动态路由 动态路由,代表那些需要根据用户动态判断权限,并通过 addRoutes ( opens new window) 动态添加的页面,如用户管理、角色管理等功能页面。 在用户登录成功后,会触发 @/store/modules/permission.ts ( opens new window) 请求后端的菜单 RESTful API 接口,获取用户有权限 的菜单列表,并转化添加到路由中。如下图所示: 友情提示: 动态路由可以在 [系统管理 -> 菜单管理] 进行新增和修改操作,请求的后端 RESTful API 接口是 /admin-api/system/list-menus ( opens new window) 动态路由在生产环境下会默认使用路由懒加载,实现方式参考 import.meta.glob('../views/**/* .{vue,tsx}') ( opens new window) 方法的判断 补充说明: 最新的代码,部分逻辑重构到 @/permission.ts ( opens new window) # 2.3 路由跳转 使用 router.push 方法,可以实现跳转到不同的页面。 const { push } = useRouter()// 简单跳转push('/job/job-log');// 跳转页面并设置请求参数,使用 `query` 属性push('/bpm/process-instance/detail?id=' + row.processInstance.id) # 3. 菜单管理 项目的菜单在 [系统管理 -> 菜单管理] 进行管理,支持无限 层级,提供目录、菜单、按钮三种类型。如下图所示: 菜单可在 [系统管理 -> 角色管理] 被分配给角色。如下图所示: # 3.1 新增目录 ① 大多数情况下,目录是作为菜单的【分类】: ② 目录也提供实现【外链】的能力: # 3.2 新增菜单 # 3.3 新增按钮 # 4. 权限控制 前端通过权限控制,隐藏用户没有权限的按钮等,实现功能级别的权限。 友情提示:前端的权限控制,主要是提升用户体验,避免操作后发现没有权限。 最终在请求到后端时,还是会进行一次权限的校验。 # 4.1 v-hasPermi 指令 v-hasPermi ( opens new window) 指令,基于权限字符,进行权限的控制。 <!-- 单个 --><el-button v-hasPermi="['system:user:create']">存在权限字符串才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasPermi="['system:user:create', 'system:user:update']">包含权限字符串才能看到</el-button> # 4.2 v-hasRole 指令 v-hasRole ( opens new window) 指令,基于角色标识,机进行的控制。 <!-- 单个 --><el-button v-hasRole="['admin']">管理员才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasRole="['role1', 'role2']">包含角色才能看到</el-button> # 4.3 结合 v-if 指令 在某些情况下,它是不适合使用 v-hasPermi 或 v-hasRole 指令,如元素标签组件。此时,只能通过手动设置 v-if,通过使用全局权限判断函数,用法是基本一致的。 <template> <el-tabs> <el-tab-pane v-if="checkPermi(['system:user:create'])" label="用户管理" name="user">用户管理</el-tab-pane> <el-tab-pane v-if="checkPermi(['system:user:create', 'system:user:update'])" label="参数管理" name="menu">参数管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin'])" label="角色管理" name="role">角色管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin','common'])" label="定时任务" name="job">定时任务</el-tab-pane> </el-tabs></template><script>import { checkPermi, checkRole } from "@/utils/permission"; // 权限判断函数export default{ methods: { checkPermi, checkRole }}</script> # 5. 页面缓存 开启缓存有 2 个条件 路由设置 name,且不能重复 路由对应的组件加上 name ,与路由设置的 name 保持一致 友情提示:页面缓存是什么? 简单来说,Tab 切换时,开启页面缓存的 Tab 保持原本的状态,不进行刷新。 详细可见 Vue 文档 —— KeepAlive ( opens new window) # 5.1 静态路由的示例 ① router 路由的 name 声明如下: { path: 'menu2', name: 'Menu2', component: () => import('@/views/Level/Menu2.vue'), meta: { title: t('router.menu2') }} ② view component 的 name 声明如下: <script setup lang="ts"> defineOptions({ name: 'Menu2'})</script> 注意: keep-alive 生效的前提是:需要将路由的 name 属性及对应的页面的 name 设置成一样。 因为:include - 字符串或正则表达式,只有名称匹配的组件会被缓存 # 5.2 动态路由的示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 开发规范 Icon 图标 ← 开发规范 Icon 图标→"},{"title":"功能开启","path":"/wiki/YuDaoBoot/商城手册/功能开启/功能开启.html","content":"开发指南商城手册 芋道源码 2023-02-04 目录 功能开启 商城目前处于【开发】阶段,功能还在不断完善中,敬请期待! 完成时间不确定,主要前端的工作量比较大。如果你有兴趣一起开发,可以联系微信 wangwenbin-server 商城的功能,由 yudao-module-mall (opens new window) 模块实现,对应管理后台的前端代码为 @/views/mall (opens new window) 目录,用户前台的前端代码为 yudao-ui-admin (opens new window) 项目。 # 1. 功能介绍 主要拆分四大模块:商品中心、交易中心、营销中心、会员中心(待建设)。如下图所示: # 2. 功能开启 考虑到编译速度,默认 yudao-module-mall 模块是关闭的,需要手动开启。步骤如下: 第一步,开启 yudao-module-mall 模块 第二步,导入商城的 SQL 数据库脚本 第三步,重启后端项目,确认功能是否生效 # 2.1 开启模块 ① 修改根目录的 pom.xml (opens new window) 文件,取消 yudao-module-mall 模块的注释。如下图所示: ② 修改 yudao-server 目录的 pom.xml (opens new window) 文件,引入 yudao-module-mp 模块。如下图所示: ③ 点击 IDEA 右上角的【Reload All Maven Projects】,刷新 Maven 依赖。如下图所示: # 2.2 第二步,导入 SQL 点击 mall.sql 下载,然后导入到数据库中。 以 product_ 作为前缀的表,对应商品模块(中心)。 以 trade_ 作为前缀的表,对应交易模块(中心)。 以 promotion_ 作为前缀的表,对应营销模块(中心)。 【待建设】以 member_ 作为前缀的表,对应会员模块(中心)。 # 2.3 第三步,重新项目 重启后端项目,然后访问前端的商城菜单,确认功能是否生效。如下图所示: 至此,我们就成功开启了商城的功能 🙂 # 3. 项目进展 功能 用户 App 管理后台 商品列表 60% 90% 商品分类 已完成 已完成 商品品牌 已完成 已完成 商品属性 60% 已完成 订单列表 50% 80% 售后退款 50% 80% 价格计算 50% 已完成 购物车 50% 已完成 优惠劵 0% 已完成 秒杀活动 0% 已完成 限时折扣活动 0% 已完成 满减送活动 0% 已完成 收货地址 30% 100% 物流发货 0% 20% 支付退款 20% 100% .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号统计 开发环境 ← 公众号统计 开发环境→"},{"title":"Excel 导入导出","path":"/wiki/YuDaoBoot/后端手册/Excel 导入导出/Excel 导入导出.html","content":"开发指南后端手册 芋道源码 2022-03-27 目录 Excel 导入导出 项目的 yudao-spring-boot-starter-excel (opens new window) 技术组件,基于 EasyExcel 实现 Excel 的读写操作,可用于实现最常见的 Excel 导入导出等功能。 EasyExcel 的介绍? EasyExcel 是阿里开源的 Excel 工具库,具有简单易用、低内存、高性能的特点。 在尽可用节约内存的情况下,支持百万行的 Excel 读写操作。例如说,仅使用 64M 内存,20 秒完成 75M(46 万行 25 列)Excel 的读取。并且,还有极速模式能更快,但是内存占用会在100M 多一点。 # 1. Excel 导出 以 [系统管理 -> 岗位管理] 菜单为例子,讲解它 Excel 导出的实现。 # 1.1 后端导入实现 在 PostController (opens new window) 类中,定义 /admin-api/system/post/export 导出接口。代码如下: ① 将从数据库中查询出来的列表,转换成对应的 PostExcelVO 列表。 ② 将 PostExcelVO 列表,转换成 Excel 文件,返回给前端。 # 1.1.1 PostExcelVO 类 创建 PostExcelVO (opens new window) 类,岗位 Excel 导出的 VO 类。它有两个作用,代码如下: ① 每个字段上的 @ExcelProperty (opens new window) 注解,声明 Excel Head 头部的名字。 ② 每个字段的值,就是它对应的 Excel Row 行的数据值。 因此,最终 Excel 导出的效果如下: 另外,在上述代码的红线部分,@ExcelProperty 注解的 converter 属性是 DictConvert 转换器,通过它将 status = 1 转换成“开启”列,status = 0 转换成”禁用”列。稍后,我们会在 「3. 字段转换器」 小节来详细讲讲。 # 1.1.2 ExcelUtils 写入 ExcelUtils 的 #write(...) (opens new window) 方法,将列表以 Excel 响应给前端。代码如下图: # 1.2 前端导入实现 在 post/index.vue (opens new window) 界面,定义 #handleExport() 操作,代码如下图: # 2. Excel 导入 以 [系统管理 -> 用户管理] 菜单为例子,讲解它 Excel 导出的实现。 # 2.1 后端导入实现 在 UserController (opens new window) 类中,定义 /admin-api/system/user/import 导入接口。代码如下: 将前端上传的 Excel 文件,读取成 UserImportExcelVO 列表。 # 2.1.1 UserImportExcelVO 类 创建 UserImportExcelVO (opens new window) 类,用户 Excel 导入的 VO 类。它的作用和 Excel 导入是一样的,代码如下: 对应使用的 Excel 导入文件如下: # 2.1.2 ExcelUtils 读取 ExcelUtils 的 #read(...) (opens new window) 方法,读取 Excel 文件成列表。代码如下图: # 2.2 前端导入实现 在 user/index.vue (opens new window) 界面,定义 Excel 导入的功能,代码如下图: # 3. 字段转换器 EasyExcel 定义了 Converter (opens new window) 接口,用于实现字段的转换。它有两个核心方法: ① #convertToJavaData(...) 方法:将 Excel Row 对应表格的值,转换成 Java 内存中的值。例如说,Excel 的“状态”列,将“状态”列转换成 status = 1,”禁用”列转换成 status = 0。 ② #convertToExcelData(...) 方法:恰好相反,将 Java 内存中的值,转换成 Excel Row 对应表格的值。例如说,Excel 的“状态”列,将 status = 1 转换成“开启”列,status = 0 转换成”禁用”列。 # 3.1 DictConvert 实现 以项目中提供的 DictConvert (opens new window) 举例子,它实现 Converter 接口,提供字典数据的转换。代码如下: 实现的代码比较简单,自己看看就可以明白。 # 3.1 DictConvert 使用示例 在需要转换的字段上,声明注解 @ExcelProperty 的 converter 属性是 DictConvert 转换器,注解 @DictFormat (opens new window) 为对应的字典数据的类型。示例如下: # 4. 更多 EasyExcel 注解 基于 《EasyExcel 中的注解 》 (opens new window) 文章,整理相关注解。 # 4.1 @ExcelProperty 这是最常用的一个注解,注解中有三个参数 value、index、converter 分别代表列明、列序号、数据转换方式。value 和 index 只能二选一,通常不用设置 converter。 最佳实践 public class ImeiEncrypt { @ExcelProperty(value = "imei") private String imei;} # 4.2 @ColumnWith 用于设置列宽度的注解,注解中只有一个参数 value。value 的单位是字符长度,最大可以设置 255 个字符,因为一个 Excel 单元格最大可以写入的字符个数,就是 255 个字符。 最佳实践 public class ImeiEncrypt { @ColumnWidth(value = 18) private String imei;} # 4.3 @ContentFontStyle 用于设置单元格内容字体格式的注解。参数如下: 参数 含义 fontName 字体名称 fontHeightInPoints 字体高度 italic 是否斜体 strikeout 是否设置删除水平线 color 字体颜色 typeOffset 偏移量 underline 下划线 bold 是否加粗 charset 编码格式 # 4.4 @ContentLoopMerge 用于设置合并单元格的注解。参数如下: 参数 含义 eachRow columnExtend # 4.5 @ContentRowHeight 用于设置行高。参数如下: 参数 含义 value 行高,-1 代表自动行高 # 4.6 @ContentStyle 设置内容格式注解。参数如下: 参数 含义 dataFormat 日期格式 hidden 设置单元格使用此样式隐藏 locked 设置单元格使用此样式锁定 quotePrefix 在单元格前面增加`符号,数字或公式将以字符串形式展示 horizontalAlignment 设置是否水平居中 wrapped 设置文本是否应换行。将此标志设置为true通过在多行上显示使单元格中的所有内容可见 verticalAlignment 设置是否垂直居中 rotation 设置单元格中文本旋转角度。03版本的Excel旋转角度区间为-90°~90°,07版本的Excel旋转角度区间为0°~180° indent 设置单元格中缩进文本的空格数 borderLeft 设置左边框的样式 borderRight 设置右边框样式 borderTop 设置上边框样式 borderBottom 设置下边框样式 leftBorderColor 设置左边框颜色 rightBorderColor 设置右边框颜色 topBorderColor 设置上边框颜色 bottomBorderColor 设置下边框颜色 fillPatternType 设置填充类型 fillBackgroundColor 设置背景色 fillForegroundColor 设置前景色 shrinkToFit 设置自动单元格自动大小 # 4.7 @HeadFontStyle 用于定制标题字体格式。参数如下: 参数 含义 fontName 设置字体名称 fontHeightInPoints 设置字体高度 italic 设置字体是否斜体 strikeout 是否设置删除线 color 设置字体颜色 typeOffset 设置偏移量 underline 设置下划线 charset 设置字体编码 bold 设置字体是否家畜 # 4.8 @HeadRowHeight 设置标题行行高。参数如下: 参数 含义 value 设置行高,-1代表自动行高 # 4.9 @HeadStyle 设置标题样式。参数如下: 参数 含义 dataFormat 日期格式 hidden 设置单元格使用此样式隐藏 locked 设置单元格使用此样式锁定 quotePrefix 在单元格前面增加` 符号,数字或公式将以字符串形式展示 horizontalAlignment 设置是否水平居中 wrapped 设置文本是否应换行。将此标志设置为true 通过在多行上显示使单元格中的所有内容可见 verticalAlignment 设置是否垂直居中 rotation 设置单元格中文本旋转角度。03版本的Excel旋转角度区间为-90°~90°,07版本的Excel旋转角度区间为0°~180° indent 设置单元格中缩进文本的空格数 borderLeft 设置左边框的样式 borderRight 设置右边框样式 borderTop 设置上边框样式 borderBottom 设置下边框样式 leftBorderColor 设置左边框颜色 rightBorderColor 设置右边框颜色 topBorderColor 设置上边框颜色 bottomBorderColor 设置下边框颜色 fillPatternType 设置填充类型 fillBackgroundColor 设置背景色 fillForegroundColor 设置前景色 shrinkToFit 设置自动单元格自动大小 # 4.10 @ExcelIgnore 不将该字段转换成 Excel。 # 4.11 @ExcelIgnoreUnannotated 没有注解的字段都不转换 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 文件存储(上传下载) 系统日志 ← 文件存储(上传下载) 系统日志→"},{"title":"Redis 缓存","path":"/wiki/YuDaoBoot/后端手册/Redis 缓存/Redis 缓存.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 Redis 缓存 yudao-spring-boot-starter-redis (opens new window) 技术组件,使用 Redis 实现缓存的功能,它有 2 种使用方式: 编程式缓存:基于 Spring Data Redis 框架的 RedisTemplate 操作模板 声明式缓存:基于 Spring Cache 框架的 @Cacheable 等等注解 # 1. 编程式缓存 友情提示: 如果你未学习过 Spring Data Redis 框架,可以后续阅读 《芋道 Spring Boot Redis 入门》 (opens new window) 文章。 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId></dependency> 由于 Redisson 提供了分布式锁、队列、限流等特性,所以使用它作为 Spring Data Redis 的客户端。 # 1.1 Spring Data Redis 配置 ① 在 application-local.yaml (opens new window) 配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。如下图所示: ② 在 YudaoRedisAutoConfiguration (opens new window) 配置类,设置使用 JSON 序列化 value 值。如下图所示: # 1.2 实战案例 以访问令牌 Access Token 的缓存来举例子,讲解项目中是如何使用 Spring Data Redis 框架的。 # 1.2.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-redis 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-redis</artifactId></dependency> # 1.2.2 OAuth2AccessTokenDO 新建 OAuth2AccessTokenDO ( opens new window) 类,访问令牌 Access Token 类。代码如下: 友情提示: ① 如果值是【简单】的 String 或者 Integer 等类型,无需创建数据实体。 ② 如果值是【复杂对象】时,建议在 dal/dataobject 包下,创建对应的数据实体。 # 1.2.3 RedisKeyConstants 为什么要定义 Redis Key 常量? 每个 yudao-module-xxx 模块,都有一个 RedisKeyConstants 类,定义该模块的 Redis Key 的信息。目的是,避免 Redis Key 散落在 Service 业务代码中,像对待数据库的表一样,对待每个 Redis Key。通过这样的方式,如果我们想要了解一个模块的 Redis 的使用情况,只需要查看 RedisKeyConstants 类即可。 在 yudao-module-system 模块的 RedisKeyConstants ( opens new window) 类中,新建 OAuth2AccessTokenDO 对应的 Redis Key 定义 OAUTH2_ACCESS_TOKEN 。如下图所示: # 1.2.4 OAuth2AccessTokenRedisDAO 新建 OAuth2AccessTokenRedisDAO ( opens new window) 类,是 OAuth2AccessTokenDO 的 RedisDAO 实现。代码如下: # 1.2.5 OAuth2TokenServiceImpl 在 OAuth2TokenServiceImpl ( opens new window) 中,只要注入 OAuth2AccessTokenRedisDAO Bean,非常简洁干净的进行 OAuth2AccessTokenDO 的缓存操作,无需关心具体的实现。代码如下: # 2. 声明式缓存 友情提示: 如果你未学习过 Spring Cache 框架,可以后续阅读 《芋道 Spring Boot Cache 入门》 ( opens new window) 文章。 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId></dependency> 相比来说 Spring Data Redis 编程式缓存,Spring Cache 声明式缓存的使用更加便利,一个 @Cacheable 注解即可实现缓存的功能。示例如下: @Cacheable(value = "users", key = "#id")UserDO getUserById(Integer id); # 2.1 Spring Cache 配置 ① 在 application.yaml ( opens new window) 配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。如下图所示: ② 在 YudaoCacheAutoConfiguration ( opens new window) 配置类,设置使用 JSON 序列化 value 值。如下图所示: # 2.2 常见注解 # 2.2.1 @Cacheable 注解 @Cacheable ( opens new window) 注解:添加在方法上,缓存方法的执行结果。执行过程如下: 1)首先,判断方法执行结果的缓存。如果有,则直接返回该缓存结果。 2)然后,执行方法,获得方法结果。 3)之后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。 4)最后,返回方法结果。 # 2.2.2 @CachePut 注解 @CachePut ( opens new window) 注解,添加在方法上,缓存方法的执行结果。不同于 @Cacheable 注解,它的执行过程如下: 1)首先,执行方法,获得方法结果。也就是说,无论是否有缓存,都会执行方法。 2)然后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。 3)最后,返回方法结果。 # 2.2.3 @CacheEvict 注解 @CacheEvict ( opens new window) 注解,添加在方法上,删除缓存。 # 2.3 实战案例 在 RoleServiceImpl ( opens new window) 中,使用 Spring Cache 实现了 Role 角色缓存,采用【被动读】的方案。原因是: 【被动读】相对能够保证 Redis 与 MySQL 的一致性 绝大数数据不需要放到 Redis 缓存中,采用【主动写】会将非必要的数据进行缓存 友情提示: 如果你未学习过 MySQL 与 Redis 一致性的问题,可以后续阅读 《Redis 与 MySQL 双写一致性如何保证? 》 ( opens new window) 文章。 ① 执行 #getRoleFromCache(...) 方法,从 MySQL 读取数据后,向 Redis 写入缓存。如下图所示: ② 执行 #updateRole(...) 或 #deleteRole(...) 方法,在更新或者删除 MySQL 数据后,从 Redis 删除缓存。如下图所示: # 2.4 过期时间 Spring Cache 默认使用 spring.cache.redis.time-to-live 配置项,设置缓存的过期时间,项目默认为 1 小时。 如果你想自定义过期时间,可以在 @Cacheable 注解中的 cacheNames 属性中,添加 #{过期时间} 后缀,单位是秒。如下图所示: 实现的原来,参考 《Spring @Cacheable 扩展支持自定义过期时间 》 ( opens new window) 文章。 # 3. Redis 监控 yudao-module-infra 的 redis ( opens new window) 模块,提供了 Redis 监控的功能。 点击 [基础设施 -> Redis 监控] 菜单,可以查看到 Redis 的基础信息、命令统计、内存信息。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:30:07 多数据源(读写分离) 本地缓存 ← 多数据源(读写分离) 本地缓存→"},{"title":"OAuth 2.0(SSO 单点登录)","path":"/wiki/YuDaoBoot/后端手册/OAuth 2.0(SSO 单点登录)/OAuth 2.0(SSO 单点登录).html","content":"开发指南后端手册 芋道源码 2022-09-27 目录 OAuth 2.0(SSO 单点登录) # OAuth 2.0 是什么? OAuth 2.0 的概念讲解,可以阅读如下三篇文章: 《理解 OAuth 2.0》 (opens new window) 《OAuth 2.0 的一个简单解释》 (opens new window) 《OAuth 2.0 的四种方式》 (opens new window) 重点是理解 授权码模式 和 密码模式,它们是最常用的两种授权模式。 本文,我们也会基于它们,分别实现 SSO 单点登录。 # OAuth 2.0 授权模式的选择? 授权模式的选择,其实非常简单,总结起来就是一张图: 问题一:什么场景下,使用客户端模式(Client Credentials)? 如果令牌拥有者是机器的情况下,那就使用客户端模式。 例如说: 开发了一个开放平台,提供给其它外部服务调用 开发了一个 RPC 服务,提供给其它内部服务调用 实际的案例,我们接入微信公众号时,会使用 appid 和 secret 参数,获取 Access token (opens new window) 访问令牌。 问题二:什么场景下,使用密码模式(Resource Owner Password Credentials)? 接入的 Client 客户端,是属于自己的情况下,可以使用密码模式。 例如说: 客户端是你自己公司的 App 或网页,然后授权服务也是你公司的 不过,如果客户端是第三方的情况下,使用密码模式的话,该客户端是可以拿到用户的账号、密码,存在安全的风险,此时可以考虑使用授权码或简化模式。 问题三:什么场景下,使用授权码模式(Authorization Code)? 接入的 Client 客户端,是属于第三方的情况下,可以使用授权码模式。例如说: 客户端是你自己公司的 App 或网页,作为第三方,接入 微信 (opens new window)、QQ (opens new window)、钉钉 (opens new window) 等等进行 OAuth 2.0 登录 当然,如果客户端是自己的情况下,也可以采用授权码模式。例如说: 客户端是腾讯旗下的各种游戏,可使用微信、QQ,接入 微信 (opens new window)、QQ (opens new window) 等等进行 OAuth 2.0 登录 客户端是公司内的各种管理后台(ERP、OA、CRM 等),跳转到统一的 SSO 单点登录,使用授权码模式进行授权 问题四:什么场景下,使用简化模式(Implicit)? 简化模式,简化 的是授权码模式的流程的 第二步,差异在于: 授权码模式:授权完成后,获得的是 code 授权码,需要 Server Side 服务端使用该授权码,再向授权服务器获取 Access Token 访问令牌 简化模式:授权完成后,Client Side 客户端直接获得 Access Token 访问令牌 暂时没有特别好的案例,感兴趣可以看看如下文档,也可以不看: 《QQ OAuth 2.0 开发指定 —— 开发攻略_Client-side》 (opens new window) 《百度 OAuth —— Implicit Grant 授权》 (opens new window) 问题五:该项目中,使用了哪些授权模式? 如上图所示,分成 外部授权 和 内部登录 两种方式。 ① 红色的“外部授权”:基于【授权码模式】,实现 SSO 单点登录,将用户授权给接入的客户端。客户端可以是内部的其它管理系统,也可以是外部的第三方。 ② 绿色的“内部登录”:管理后台的登录接口,还是采用传统的 /admin-api/system/auth/login (opens new window) 账号密码登录,并没有使用【密码模式】,主要考虑降低大家的学习成本,如果没有将用户授权给其它系统的情况下,这样做已经可以很好的满足业务的需要。当然,这里也可以将管理后台作为一个客户端,使用【密码模式】进行授权。 另外,考虑到 OAuth 2.0 使用的访问令牌 + 刷新令牌可以提供更好的安全性,所以即使是传统的账号密码登录,也复用了它作为令牌的实现。 # OAuth 2.0 技术选型? 实现 OAuth 2.0 的功能,一般采用 Spring Security OAuth (opens new window) 或 Spring Authorization Server (opens new window)(SAS) 框架,前者已废弃,被后者所替代。但是使用它们,会面临三大问题: 学习成本大:SAS 是新出的框架,入门容易精通难,引入项目中需要花费 1-2 周深入学习 排查问题难:使用碰到问题时,往往需要调试到源码层面,团队只有个别人具备这种能力 定制成本高:根据业务需要,想要在 SAS 上定制功能,对源码要有不错的掌控力,难度可能过大 ⚔ 因此,项目参考多个 OAuth 2.0 框架,自研实现 OAuth 2.0 的功能,具备学习成本小、排查问题容易、定制成本低的优点,支持多种授权模式,并内置 SSO 单点登录的功能。 友情提示:具备一定规模的互联网公司,基本不会直接采用 Spring Security OAuth 或 Spring Authorization Server 框架,也是采用自研的方式,更好的满足自身的业务需求与技术拓展。 🙂 另外,通过学习项目的 OAuth 2.0 实现,可以进一步加深对 OAuth 2.0 的理解,知其然而不知其所以然! 最终实现的整体架构,如下图所示: 详细的代码实现,我们在视频中进行讲解。 # 如何实现 SSO 单点登录? # 实战一:基于授权码模式,实现 SSO 单点登录 示例代码见 https://github.com/YunaiV/ruoyi-vue-pro/tree/master/yudao-example/yudao-sso-demo-by-code (opens new window) 地址,整体流程如下图所示: 具体的使用流程如下: ① 第一步,分别启动 ruoyi-vue-pro 项目的前端和后端。注意,前端需要使用 Vue2 版本,因为 Vue3 版本暂时没有实现 SSO 页面。 ② 第二步,访问 系统管理 -> OAuth 2.0 -> 应用管理 (opens new window) 菜单,新增一个应用(客户端),信息如下图: 客户端编号:yudao-sso-demo-by-code 客户端密钥:test 应用名:基于授权码模式,如何实现 SSO 单点登录? 授权类型:authorization_code、refresh_token 授权范围:user.read、user.write 可重定向的 URI 地址:http://127.0.0.1:18080 ps:如果已经有这个客户端,可以不用新增。 ③ 第三步,运行 SSODemoApplication (opens new window) 类,启动接入方的项目,它已经包含前端和后端部分。启动成功的日志如下: 2022-10-01 21:24:35.572 INFO 60265 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 18080 (http) with context path '' ④ 第四步,浏览器访问 http://127.0.0.1:18080/index.html (opens new window) 地址,进入接入方的 index.html 首页。因为暂未登录,可以点击「跳转」按钮,跳转到 ruoyi-vue-pro 项目的 SSO 单点登录页。 疑问:为什么没有跳转到 SSO 单点登录页,而是跳转到 ruoyi-vue-pro 项目的登录页? 因为在 ruoyi-vue-pro 项目也未登录,所以先跳转到该项目的登录页,使用账号密码进行登录。登录完成后,会跳转回 SSO 单点登录页,继续完成 OAuth 2.0 的授权流程。 ⑤ 第五步,勾选 \"访问你的个人信息\" 和 \"修改你的个人信息\",点击「同意授权」按钮,完成 code 授权码的申请。 ⑥ 第六步,完成授权后,会跳转到接入方的 callback.html 回调页,并在 URL 上可以看到 code 授权码。 ⑦ 第七步,点击「确认」按钮,接入方的前端会使用 code 授权码,向接入方的后端获取 accessToken 访问令牌。 而接入方的后端,使用接收到的 code 授权码,通过调用 ruoyi-vue-pro 项目的后端,获取到 accessToken 访问令牌,并最终返回给接入方的前端。 ⑧ 第八步,在接入方的前端拿到 accessToken 访问令牌后,跳转回自己的 index.html 首页,并进一步从 ruoyi-vue-pro 项目获取到该用户的昵称等个人信息。后续,你可以执行「修改昵称」、「刷新令牌」、「退出登录」等操作。 示例代码的具体实现,与详细的解析,可以观看如下视频: 02、基于授权码模式,如何实现 SSO 单点登录? (opens new window) 03、请求时,如何校验 accessToken 访问令牌? (opens new window) 04、访问令牌过期时,如何刷新 Token 令牌? (opens new window) 05、登录成功后,如何获得用户信息? (opens new window) 06、退出时,如何删除 Token 令牌? (opens new window) # 实战二:基于密码模式,实现 SSO 登录 示例代码见 https://github.com/YunaiV/ruoyi-vue-pro/tree/master/yudao-example/yudao-sso-demo-by-password (opens new window) 地址,整体流程如下图所示: 具体的使用流程如下: ① 第一步,分别启动 ruoyi-vue-pro 项目的前端和后端。注意,前端需要使用 Vue2 版本,因为 Vue3 版本暂时没有实现 SSO 页面。 ② 第二步,访问 系统管理 -> OAuth 2.0 -> 应用管理 (opens new window) 菜单,新增一个应用(客户端),信息如下图: 客户端编号:yudao-sso-demo-by-password 客户端密钥:test 应用名:基于密码模式,如何实现 SSO 单点登录? 授权类型:password、refresh_token 授权范围:user.read、user.write 可重定向的 URI 地址:http://127.0.0.1:18080 ps:如果已经有这个客户端,可以不用新增。 ③ 第三步,运行 SSODemoApplication (opens new window) 类,启动接入方的项目,它已经包含前端和后端部分。启动成功的日志如下: 2022-10-04 21:24:35.572 INFO 60265 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 18080 (http) with context path '' ④ 第四步,浏览器访问 http://127.0.0.1:18080/index.html (opens new window) 地址,进入接入方的 index.html 首页。因为暂未登录,可以点击「跳转」按钮,跳转到 login.html 登录页。 ⑤ 第五步,点击「登录」按钮,调用 ruoyi-vue-pro 项目的后端,获取到 accessToken 访问令牌,完成登录操作。 ⑥ 第六步,登录完成后,跳转回自己的 index.html 首页,并进一步从 ruoyi-vue-pro 项目获取到该用户的昵称等个人信息。后续,你可以执行「修改昵称」、「刷新令牌」、「退出登录」等操作。 示例代码的具体实现,与详细的解析,可以观看如下视频: 07、基于密码模式,如何实现 SSO 单点登录? (opens new window) # OAuth 2.0 表结构 每个表的具体设计,与详细的解析,可以观看如下视频: 08、如何实现客户端的管理? (opens new window) 09、单点登录界面,如何进行初始化? (opens new window) 10、单点登录界面,如何进行【手动】授权? (opens new window) 11、单点登录界面,如何进行【自动】授权? (opens new window) 12、基于【授权码】模式,如何获得 Token 令牌? (opens new window) 13、基于【密码】模式,如何获得 Token 令牌? (opens new window) 14、如何校验、刷新、删除访问令牌? (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/10/06, 20:13:06 三方登录 SaaS 多租户【字段隔离】 ← 三方登录 SaaS 多租户【字段隔离】→"},{"title":"SaaS 多租户【数据库隔离】","path":"/wiki/YuDaoBoot/后端手册/SaaS 多租户【数据库隔离】/SaaS 多租户【数据库隔离】.html","content":"开发指南后端手册 芋道源码 2023-03-01 目录 SaaS 多租户【数据库隔离】 本章节,讲解 SaaS 租户的 DATASOURCE 模式,实现数据库级别的隔离。 注意,需要前置阅读 《SaaS 多租户【字段隔离】》 文档。 # 0. 极速体验 ① 克隆 https://gitee.com/zhijiantianya/ruoyi-vue-pro (opens new window) 仓库,并切换到 feature/dev-yunai 分支。 ② 创建 ruoyi-vue-pro-master、ruoyi-vue-pro-tenant-a、ruoyi-vue-pro-tenant-b 三个数据库。 ③ 下载 多租户多db.zip 并解压,将 SQL 导入到对应的数据库中。 友情提示: 随着版本的迭代,SQL 脚本可能过期。如果碰到问题,可以在星球给我反馈下。 ④ 启动前端和后端项目,即可愉快的体验了。 # 1. 实现原理 DATASOURCE 模式,基于 dynamic-datasource (opens new window) 进行拓展实现。 核心:每次对数据库操作时,动态切换到该租户所在的数据源,然后执行 SQL 语句。 # 2. 功能演示 我们来新增一个租户,使用 DATASOURCE 模式。 ① 点击 [基础设施 -> 数据源配置] 菜单,点击 [新增] 按钮,新增一个名字为 tenant-a 数据源。 然后,手动将如下表拷贝到 ruoyi-vue-pro 主库中的如下表,拷贝到 ruoyi-vue-pro-tenant-a 库中。如下图所示: system_deptsystem_login_logsystem_noticesystem_notify_messagesystem_operate_logsystem_postsystem_rolesystem_role_menusystem_social_usersystem_social_user_bindsystem_user_postsystem_user_rolesystem_users 友情提示: 随着版本的迭代,可能需要拷贝更多的表。如果碰到问题,可以在星球给我反馈下。 ② 点击 [基础设施 -> 租户管理] 菜单,点击 [新增] 按钮,新增一个名字为 土豆租户 的租户,并使用 tenant-a 数据源。如下图所示: 此时,在 ruoyi-vue-pro-tenant-a 库中,可以查询到对应的租户管理员、角色等信息。如下图所示: ③ 退出系统,登录刚创建的租户。 至此,我们已经完成了租户的创建。 补充说明: 后续在使用时,建议把拷贝到其它租户数据库的表,从 ruoyi-vue-pro 主库中进行删除。 目的是,主库只保留所有租户共享的全局表。例如说,菜单表、定时任表等等。 # 3. 创建表 在使用 DATASOURCE 模式时,数据库可以分为两种:主库、租户库。 # 3.1 主库 ① 存放所有租户共享的表。例如说:菜单表、定时任务表等等。如下图所示: ② 对应 master 数据源,配置在 application-{env}.yaml 配置文件。如下图所示: ③ 每个主库对应的 Mapper,必须添加 @Master (opens new window) 注解。例如说: # 3.2 租户库 ① 存放每个租户的表。例如说:用户表、角色表等等。 ② 在 [基础设施 -> 数据源配置] 菜单中,配置数据源。 ③ 每个主库对应的 Mapper,必须添加 @TenantDS 注解。例如说: # 3.3 租户字段 ① 考虑到拓展性,在使用 DATASOURCE 模式时,默认会叠加 COLUMN 模式,即还有 tenant_id 租户字段: 在 INSERT 操作时,会自动记录租户编号到 tenant_id 字段。 在 SELECT 操作时,会自动添加 WHERE tenant_id = ? 查询条件。 如果你不需要,可以直接删除 TenantDatabaseInterceptor (opens new window) 类,以及它的 Bean 自动配置。 拓展性,指的是部分【大】租户独立数据库,部分【小】租户共享数据。 ② 也因为叠加了 COLUMN 模式,主库的表需要根据情况添加 tenant_id 字段。 情况一:不需要添加 tenant_id 字段。例如说:菜单表、定时任务表等等。注意,需要把表名添加到 yudao.tenant.ignore-tables 配置项中。 情况二:需要 tenant_id 字段。例如说:访问日志表、异常日志表等等。目的,排查是哪个租户的系统级别的日志。 # 4. 多数据源事务 使用 DATASOURCE 模式后,可能一个操作涉及到多个数据源。例如说:创建租户时,即需要操作主库,也需要操作租户库。 考虑到多数据的数据一致性,我们会采用事务的方式,而使用 Spring 事务时,会存在多数据库无法切换的问题。不了解的胖友,可以阅读 《MyBatis Plus 的多数据源 @DS 切换不起作用了,谁的锅 》 (opens new window) 文章。 多数据源的事务方案,是一个老生常谈的问题。比较主流的,有如下两种,都是相对重量级的方案: 使用 Atomikos (opens new window) 实现 JTA 分布式事务,配置复杂,性能较差。 使用 Seata (opens new window) 实现分布式事务,使用简单,性能不错,但是需要额外引入 Seata Server 服务。 # 4.1 本地事务 考虑到项目是单体架构,不适合采用重量级的事务,因此采用 dynamic-datasource (opens new window) 提供的 “本地事务” 轻量级方案。 它的实现原理是:自定义 @DSTransactional (opens new window) 事务注解,替代 Spring @Transactional 事务注解。 在逻辑执行成功时,循环提交每个数据源的事务。 在逻辑执行失败时,循环回滚每个数据源的事务。 但是它存在一个风险点,如果数据库发生异常(例如说宕机),那么本地事务就可能会存在数据不一致的问题。例如说: ① 主库的事务提交 ② 租户库发生异常,租户的事务提交失败 结果:主库的数据已经提交,而租户库的数据没有提交,就会导致数据不一致。 因此,如果你的系统对数据一致性要求很高,那么请使用 Seata 方案。 # 4.2 使用示例 在最外层的 Service 方法上,添加 @DSTransactional 注解。例如说,创建租户的 Service 方法: 注意,里面不能嵌套有 Spring 自带的事务,就是上图中【黄圈】的 Service 方法不能使用 Spring @Transactional 注解,否则会导致数据源无法切换。 如果【黄圈】的 Service 自身还需要事务,那么可以使用 @DSTransactional 注解。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/02, 00:37:14 SaaS 多租户【字段隔离】 异常处理(错误码) ← SaaS 多租户【字段隔离】 异常处理(错误码)→"},{"title":"代码生成(新增功能)","path":"/wiki/YuDaoBoot/后端手册/代码生成(新增功能)/代码生成(新增功能).html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 代码生成(新增功能) 大部分项目里,其实有很多代码是重复的,几乎每个模块都有 CRUD 增删改查的功能,而这些功能的实现代码往往是大同小异的。如果这些功能都要自己去手写,非常无聊枯燥,浪费时间且效率很低,还可能会写错。 所以这种重复性的代码,项目提供了 codegen (opens new window) 代码生成器,只需要在数据库中设计好表结构,就可以一键生成前后端代码 + 单元测试 + Swagger 接口文档 + Validator 参数校验。 下面,我们使用代码生成器,在 yudao-module-system 模块中,开发一个【用户组】的功能。 # 👍 相关视频教程 从零开始 05:如何 5 分钟,开发一个新功能? (opens new window) # 1. 数据库表结构设计 设计用户组的数据库表名为 system_group,其建表语句如下: CREATE TABLE `system_group` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '名字', `description` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '描述', `status` tinyint NOT NULL COMMENT '状态', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户组'; ① 表名的前缀,要和 Maven Module 的模块名保持一致。例如说,用户组在 yudao-module-system 模块,所以表名的前缀是 system_。 疑问:为什么要保持一致? 代码生成器会自动解析表名的前缀,获得其所属的 Maven Module 模块,简化配置过程。 ② 设置 ID 主键,一般推荐使用 bigint 长整形,并设置自增长。 ③ 正确设置每个字段是否允许空,代码生成器会根据它生成参数是否允许空的校验规则。 ④ 正确设置注释,代码生成器会根据它生成字段名与提示等信息。 ⑤ 添加 creator、create_time、updater、update_time、deleted 是必须设置的系统字段;如果开启多租户的功能,并且该表需要多租户的隔离,则需要添加 tenant_id 字段。 # 2. 代码生成 ① 点击 [基础设施 -> 代码生成] 菜单,点击 [基于 DB 导入] 按钮,选择 system_group 表,后点击 [确认] 按钮。 代码实现? 可见 CodegenBuilder (opens new window) 类,自动解析数据库的表结构,生成默认的配置。 ② 点击 system_group 所在行的 [编辑] 按钮,修改生成配置。后操作如下: 将 status 字段的显示类型为【下拉框】,字典类型为【系统状态】。 将 description 字段的【查询】取消。 将 id、name、description、status 字段的【示例】填写上。 字段信息 插入:新增时,是否传递该字段。 编辑:修改时,是否传递该字段。 列表:Table 表格,是否展示该字段。 查询:搜索框,是否支持该字段查询,查询的条件是什么。 允许空:新增或修改时,是否必须传递该字段,用于 Validator 参数校验。 字典类型:在显示类型是下拉框、单选框、复选框时,选择使用的字典。 示例:参数示例,用于 Swagger 接口文档的 example 示例。 将【上级菜单】设置为【系统管理】。 将【前端类型】设置为【Vue2 Element UI 标准模版】或【Vue3 Element Plus 标准模版】,具体根据你使用哪种管理后台。 生成信息 生成场景:分成管理后台、用户 App 两种,用于生成 Controller 放在 admin 还是 app 包。 上级菜单:生成场景是管理后台时,需要设置其所属的上级菜单。 前端类型: 提供多种 UI 模版。 【Vue3 Element Plus Schema 模版】,对应 《前端手册 Vue 3.X —— CRUD 组件》 说明。 后端的 application.yaml 配置文件中的 yudao.codegen.front-type 配置项,设置默认的 UI 模版,避免每次都需要设置。 完成后,点击 [提交] 按钮,保存生成配置。 ③ 点击 system_group 所在行的 [预览] 按钮,在线预览生成的代码,检查是否符合预期。 ④ 点击 system_group 所在行的 [生成代码] 按钮,下载生成代码的压缩包,双击进行解压。 代码实现? 可见 CodegenEngine (opens new window) 类,基于 Velocity 模板引擎,生成具体代码。模板文件,可见 resources/codegen (opens new window) 目录。 # 3. 代码运行 本小节,我们将生成的代码,复制到项目中,并进行运行。 # 3.1 后端运行 ① 将生成的后端代码,复制到项目中。操作如下图所示: ② 将 ErrorCodeConstants.java_手动操作 文件的错误码,复制到该模块 ErrorCodeConstants 类中,并设置对应的错误码编号,之后进行删除。操作如下图所示: ③ 将 h2.sql 的 CREATE 语句复制到该模块的 create_tables.sql 文件,DELETE 语句复制到该模块的 clean.sql。操作如下图: 疑问:`create_tables.sql` 和 `clean.sql` 文件的作用是什么? 项目的单元测试,需要使用到 H2 内存数据库,create_tables.sql 用于创建所有的表结构,clean.sql 用于每个单元测试的方法跑完后清理数据。 然后,运行 GroupServiceImplTest 单元测试,执行通过。 ④ 打开数据库工具,运行代码生成的 sql/sql.sql 文件,用于菜单的初始化。 ⑤ Debug 运行 YudaoServerApplication 类,启动后端项目。通过 IDEA 的 [Actuator -> Mappings] 菜单,可以看到代码生成的 GroupController 的 RESTful API 接口已经生效。 # 3.2 前端运行 ① 将生成的前端代码,复制到项目中。操作如下图所示: ② 重新执行 npm run dev 命令,启动前端项目。点击 [系统管理 -> 用户组管理] 菜单,就可以看到用户组的 UI 界面。 至此,我们已经完成了【用户组】功能的代码生成,基本节省了你 80% 左右的开发任务,后续可以根据自己的需求,进行剩余的 20% 的开发! # 4. 后续变更 随着业务的发展,已经生成代码的功能需要变更。继续以【用户组】举例子,它的 system_group 表需要新增一个分类 category 字段,此时不建议使用代码生成器,而是直接修改已经生成的代码: ① 后端:修改 GroupDO 数据实体类、GroupBaseVO 基础 VO 类、GroupExcelVO 导出结果 VO 类,新增 category 字段。 ② 前端:修改 index.vue 界面的列表和表单组件,新增 category 字段。 ③ 重新编译后后端,并进行启动。 over!非常简单方便,即保证了代码的整洁规范,又不增加过多的开发量。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/13, 08:15:12 新建模块 功能权限 ← 新建模块 功能权限→"},{"title":"SaaS 多租户【字段隔离】","path":"/wiki/YuDaoBoot/后端手册/SaaS 多租户【字段隔离】/SaaS 多租户【字段隔离】.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 SaaS 多租户【字段隔离】 本章节,将介绍多租户的基础知识、以及怎样使用多租户的功能。 相关的视频教程: 01、如何实现多租户的 DB 封装? (opens new window) 02、如何实现多租户的 Redis 封装? (opens new window) 03、如何实现多租户的 Web 与 Security 封装? (opens new window) 04、如何实现多租户的 Job 封装? (opens new window) 05、如何实现多租户的 MQ 与 Async 封装? (opens new window) 06、如何实现多租户的 AOP 与 Util 封装? (opens new window) 07、如何实现多租户的管理? (opens new window) 08、如何实现多租户的套餐? (opens new window) # 1. 多租户是什么? 多租户,简单来说是指一个业务系统,可以为多个组织服务,并且组织之间的数据是隔离的。 例如说,在服务上部署了一个 ruoyi-vue-pro (opens new window) 系统,可以支持多个不同的公司使用。这里的一个公司就是一个租户,每个用户必然属于某个租户。因此,用户也只能看见自己租户下面的内容,其它租户的内容对他是不可见的。 # 2. 数据隔离方案 多租户的数据隔离方案,可以分成分成三种: DATASOURCE 模式:独立数据库 SCHEMA 模式:共享数据库,独立 Schema COLUMN 模式:共享数据库,共享 Schema,共享数据表 # 2.1 DATASOURCE 模式 一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本也高。 优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。 缺点:增大了数据库的安装数量,随之带来维护成本和购置成本的增加。 # 2.2 SCHEMA 模式 多个或所有租户共享数据库,但一个租户一个表。 优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可以支持更多的租户数量。 缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵扯到其他租户的数据; 如果需要跨租户统计数据,存在一定困难。 # 2.3 COLUMN 模式 共享数据库,共享数据架构。租户共享同一个数据库、同一个表,但在表中通过 tenant_id 字段区分租户的数据。这是共享程度最高、隔离级别最低的模式。 优点:维护和购置成本最低,允许每个数据库支持的租户数量最多。 缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。 # 2.4 方案选择 一般情况下,可以考虑采用 COLUMN 模式,开发、运维简单,以最少的服务器为最多的租户提供服务。 租户规模比较大,或者一些租户对安全性要求较高,可以考虑采用 DATASOURCE 模式,当然它也相对复杂的多。 不推荐采用 SCHEMA 模式,因为它的优点并不明显,而且它的缺点也很明显,同时对复杂 SQL 支持一般。 提问:项目支持哪些模式? 目前支持最主流的 DATASOURCE 和 COLUMN 两种模式。而 SCHEMA 模式不推荐使用,所以暂时不考虑实现。 考虑到让大家更好的理解 DATASOURCE 和 COLUMN 模式,拆成了两篇文章: 《SaaS 多租户【字段隔离】》:讲解 COLUMN 模式 《SaaS 多租户【数据库隔离】》:讲解 DATASOURCE 模式 # 3. 多租户的开关 系统有两个配置项,设置为 true 时开启多租户,设置为 false 时关闭多租户。 注意,两者需要保持一致,否则会报错! 配置项 说明 配置文件 yudao.server.tenant 后端开关 VUE_APP_TENANT_ENABLE 前端开关 疑问:为什么要设置两个配置项? 前端登录界面需要使用到多租户的配置项,从后端加载配置项的话,体验会比较差。 # 4. 多租户的业务功能 多租户主要有两个业务功能: 业务功能 说明 界面 代码 租户管理 配置系统租户,创建对应的租户管理员 后端 (opens new window) 前端 (opens new window) 租户套餐 配置租户套餐,自定每个租户的菜单、操作、按钮的权限 后端 (opens new window) 前端 (opens new window) 下面,我们来新增一个租户,它使用 COLUMN 模式。 ① 点击 [租户套餐] 菜单,点击 [新增] 按钮,填写租户的信息。 ② 点击 [确认] 按钮,完成租户的创建,它会自动创建对应的租户管理员、角色等信息。 ③ 退出系统,登录刚创建的租户。 至此,我们已经完成了租户的创建。 # 5. 多租户的技术组件 技术组件 yudao-spring-boot-starter-biz-tenant (opens new window),实现透明化的多租户能力,针对 Web、Security、DB、Redis、AOP、Job、MQ、Async 等多个层面进行封装。 # 5.1 租户上下文 TenantContextHolder (opens new window) 是租户上下文,通过 ThreadLocal 实现租户编号的共享与传递。 通过调用 TenantContextHolder 的 #getTenantId() 静态方法,获得当前的租户编号。绝绝绝大多数情况下,并不需要。 # 5.2 Web 层【重要】 实现可见 web (opens new window) 包。 默认情况下,前端的每个请求 Header 必须带上 tenant-id,值为租户编号,即 system_tenant 表的主键编号。 如果不带该请求头,会报“租户的请求未传递,请进行排查”错误提示。 😜 通过 yudao.tenant.ignore-urls 配置项,可以设置哪些 URL 无需带该请求头。例如说: # 5.3 Security 层 实现可见 security (opens new window) 包。 主要是校验登录的用户,校验是否有权限访问该租户,避免越权问题。 # 5.4 DB 层【重要】 实现可见 db (opens new window) 包。 COLUMN 模式,基于 MyBatis Plus 自带的多租户 (opens new window)功能实现。 核心:每次对数据库操作时,它会自动拼接 WHERE tenant_id = ? 条件来进行租户的过滤,并且基本支持所有的 SQL 场景。 如下是具体方式: ① 需要开启多租户的表,必须添加 tenant_id 字段。例如说 system_users、system_role 等表。 CREATE TABLE `system_role` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID', `name` varchar(30) CHARACTER NOT NULL COMMENT '角色名称', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='角色信息表'; 并且该表对应的 DO 需要使用到 tenantId 属性时,建议继承 TenantBaseDO (opens new window) 类。 ② 无需开启多租户的表,需要添加表名到 yudao.tenant.ignore-tables 配置项目。例如说: 如果不配置的话,MyBatis Plus 会自动拼接 WHERE tenant_id = ? 条件,导致报 tenant_id 字段不存在的错误。 # 5.5 Redis 层【重要】 实现可见 redis (opens new window) 包。 由于 Redis 不同于 DB 有 tenant_id 字段,无法通过类似 WHERE tenant_id = ? 的方式过滤,所以需要通过在 Redis Key 上增加 :t{tenantId} 后缀的方式,进行租户之间的隔离。 例如说,假设 Redis Key 是 user:%d,示例是 user:1024;对应到多租户 1 的 Redis Key 是 user:t1:1024。 为什么 Redis Key 要多租户隔离呢? ① 在使用 DATASOURCE 模式时,不同库的相同表的 id 可能相同,例如说 A 库的用户,和 B 库的用户都是 1024,直接缓存会存在 Redis Key 的冲突。 ② 在所有模式下,跨租户可能存在相同的需要唯一的数据,例如说用户的手机号,直接缓存会存在 Redis Key 的冲突。 # 使用方式一:基于 Spring Cache + Redis【推荐】 只需要一步,在方法上添加 Spring Cache 注解,例如说 @Cachable、@CachePut、@CacheEvict。 具体的实现原理,可见 TenantRedisCacheManager (opens new window) 的源码。 注意!!!默认配置下,Spring Cache 都开启 Redis Key 的多租户隔离。如果不需要,可以将 Key 添加到 yudao.tenant.ignore-cache 配置项中。如下图所示: # 使用方式二:基于 RedisTemplate + TenantRedisKeyDefine 暂时没有合适的封装,需要在自己 format Redis Key 的时候,手动将 :t{tenantId} 后缀拼接上。 这也是为什么,我推荐你使用 Spring Cache + Redis 的原因! # 5.6 AOP【重要】 实现可见 aop (opens new window) 包。 ① 声明 @TenantIgnore (opens new window) 注解在方法上,标记指定方法不进行租户的自动过滤,避免自动拼接 WHERE tenant_id = ? 条件等等。 例如说:RoleServiceImpl (opens new window) 的 #initLocalCache() (opens new window) 方法,加载所有租户的角色到内存进行缓存,如果不声明 @TenantIgnore 注解,会导致租户的自动过滤,只加载了某个租户的角色。 // RoleServiceImpl.javapublic class RoleServiceImpl implements RoleService { @Resource @Lazy // 注入自己,所以延迟加载 private RoleService self; @Override @PostConstruct @TenantIgnore // 忽略自动多租户,全局初始化缓存 public void initLocalCache() { // ... 从数据库中,加载角色 } @Scheduled(fixedDelay = SCHEDULER_PERIOD, initialDelay = SCHEDULER_PERIOD) public void schedulePeriodicRefresh() { self.initLocalCache(); // <x> 通过 self 引用到 Spring 代理对象 }} 有一点要格外注意,由于 @TenantIgnore 注解是基于 Spring AOP 实现,如果是方法内部的调用,避免使用 this 导致不生效,可以采用上述示例的 <x> 处的 self 方式。 ② 使用 TenantUtils (opens new window) 的 #execute(Long tenantId, Runnable runnable) 方法,模拟指定租户( tenantId ),执行某段业务逻辑( runnable )。 例如说:在 TenantServiceImpl (opens new window) 的 #createTenant(...) 方法,在创建完租户时,需要模拟该租户,进行用户和角色的创建。如下图所示: # 5.7 Job【重要】 实现可见 job (opens new window) 包。 声明 @TenantJob (opens new window) 注解在 Job 类上,实现并行遍历每个租户,执行定时任务的逻辑。 # 5.8 MQ 实现可见 mq (opens new window) 包。 通过租户对 MQ 层面的封装,实现租户上下文,可以继续传递到 MQ 消费的逻辑中,避免丢失的问题。实现原理是: 发送消息时,MQ 会将租户上下文的租户编号,记录到 Message 消息头 tenant-id 上。 消费消息时,MQ 会将 Message 消息头 tenant-id,设置到租户上下文的租户编号。 # 5.9 Async 实现可见 YudaoAsyncAutoConfiguration (opens new window) 类。 通过使用阿里开源的 TransmittableThreadLocal (opens new window) 组件,实现 Spring Async 执行异步逻辑时,租户上下文可以继续传递,避免丢失的问题。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/03, 23:36:37 OAuth 2.0(SSO 单点登录) SaaS 多租户【数据库隔离】 ← OAuth 2.0(SSO 单点登录) SaaS 多租户【数据库隔离】→"},{"title":"三方登录","path":"/wiki/YuDaoBoot/后端手册/三方登录/三方登录.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 三方登录 系统对接国内多个第三方平台,实现三方登录的功能。例如说: 管理后台:企业微信、阿里钉钉 用户 App:微信公众号、微信小程序 友情提示:为了表述方便,本文主要使用管理后台的三方登录作为示例。 用户 App 也是支持该功能,你可以自己去体验一下。 # 1. 表结构 ① 三方登录完成时,系统会将三方用户存储到 system_social_user (opens new window) 表中,通过 type (opens new window) 标记对应的第三方平台。 ② 【未】关联本系统 User 的三方用户,需要在三方登录完成后,使用账号密码进行「绑定登录」,成功后记录到 system_social_user_bind (opens new window) 表中。 【已】关联本系统 User 的三方用户,在三方登录完成后,直接进入系统,即「快捷登录」。 # 2. 绑定登录 ① 使用浏览器访问 http://127.0.0.1:1024/login (opens new window) 地址,点击 [钉钉] 或者 [企业微信] 进行三方登录。此时,会调用 /admin-api/system/auth/social-auth-redirect (opens new window) 接口,获得第三方平台的登录地址,并进行跳转。 然后,使用 [钉钉] 或者 [企业微信] 进行扫码,完成三方登录。 ② 三方登录成功后,跳转回 http://127.0.0.1:1024/social-login (opens new window) 地址。此时,会调用 /admin-api/system/auth/social-login (opens new window) 接口,尝试「快捷登录」。由于该三方用户【未】关联管理后台的 AdminUser 用户,所以会看到 “未绑定账号,需要进行绑定” 报错。 ③ 输入账号密码,点击 [提交] 按钮,进行「绑定登录」。此时,会调用 /admin-api/system/auth/login (opens new window) 接口(在账号密码登录的基础上,额外带上 socialType + socialCode + socialState 参数)。成功后,即可进入系统的首页。 # 3. 快捷登录 退出系统,再进行一次三方登录的流程。 【相同】① 使用浏览器访问 http://127.0.0.1:1024/login (opens new window) 地址,点击 [钉钉] 或者 [企业微信] 进行三方登录。此时,会调用 /admin-api/system/auth/social-auth-redirect (opens new window) 接口,获得第三方平台的登录地址,并进行跳转。 【不同】② 三方登录成功后,跳转回 http://127.0.0.1:1024/social-login (opens new window) 地址。此时,会调用 /admin-api/system/auth/social-login (opens new window) 接口,尝试「快捷登录」。由于该三方用户【已】关联管理后台的 AdminUser 用户,所以直接进入系统的首页。 # 4. 绑定与解绑 访问 http://127.0.0.1:1024/user/profile (opens new window) 地址,选择 [社交信息] 选项,可以三方用户的绑定与解绑。 # 5. 配置文件 在 application-{env}.yaml (opens new window) 配置文件中,对应 justauth 配置项,填写你的第三方平台的配置信息。 系统使用 justauth-spring-boot-starter (opens new window) JustAuth (opens new window) 组件,想要对接其它第三方平台,只需要新增对应的配置信息即可。 疑问:yudao-spring-boot-starter-biz-social 技术组件的作用是什么? yudao-spring-boot-starter-biz-social (opens new window) 对 JustAuth 进行二次封装,提供微信小程序的集成。 # 6. 第三方平台的申请 阿里钉钉:https://justauth.wiki/guide/oauth/dingtalk/ (opens new window) 企业微信:https://justauth.wiki/guide/oauth/wechat_enterprise_qrcode/ (opens new window) 微信开放平台:https://justauth.wiki/guide/oauth/wechat_open/ (opens new window) 注意,如果第三方平台如果需要配置具体的授信地址,需要添加 /social-login 用于三方登录回调页、/user/profile 用于三方用户的绑定与解绑。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/07/06, 00:54:39 用户体系 OAuth 2.0(SSO 单点登录) ← 用户体系 OAuth 2.0(SSO 单点登录)→"},{"title":"分页实现","path":"/wiki/YuDaoBoot/后端手册/分页实现/分页实现.html","content":"开发指南后端手册 芋道源码 2022-03-26 目录 分页实现 前端:基于 Element UI 分页组件 Pagination (opens new window) 后端:基于 MyBatis Plus 分页功能,二次封装 以 [系统管理 -> 租户管理 -> 租户列表] 菜单为例子,讲解它的分页 + 搜索的实现。 # 1. 前端分页实现 # 1.1 Vue 界面 界面 tenant/index.vue (opens new window) 相关的代码如下: <template> <!-- 搜索工作栏 --> <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px"> <el-form-item label="租户名" prop="name"> <el-input v-model="queryParams.name" placeholder="请输入租户名" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="联系人" prop="contactName"> <el-input v-model="queryParams.contactName" placeholder="请输入联系人" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="联系手机" prop="contactMobile"> <el-input v-model="queryParams.contactMobile" placeholder="请输入联系手机" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="租户状态" prop="status"> <el-select v-model="queryParams.status" placeholder="请选择租户状态" clearable> <el-option v-for="dict in this.getDictDatas(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="dict.label" :value="dict.value"/> </el-select> </el-form-item> <el-form-item> <el-button type="primary" icon="el-icon-search" @click="handleQuery">搜索</el-button> <el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button> </el-form-item> </el-form> <!-- 列表 --> <el-table v-loading="loading" :data="list"> <!-- 省略每一列... --> </el-table> <!-- 分页组件 --> <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize" @pagination="getList"/></template><script>import { getTenantPage } from "@/api/system/tenant";export default { name: "Tenant", components: {}, data() { // 遮罩层 return { // 遮罩层 loading: true, // 显示搜索条件 showSearch: true, // 总条数 total: 0, // 租户列表 list: [], // 查询参数 queryParams: { pageNo: 1, pageSize: 10, // 搜索条件 name: null, contactName: null, contactMobile: null, status: undefined, }, } }, created() { this.getList(); }, methods: { /** 查询列表 */ getList() { this.loading = true; // 处理查询参数 let params = {...this.queryParams}; // 执行查询 getTenantPage(params).then(response => { this.list = response.data.list; this.total = response.data.total; this.loading = false; }); }, /** 搜索按钮操作 */ handleQuery() { this.queryParams.pageNo = 1; this.getList(); }, /** 重置按钮操作 */ resetQuery() { this.resetForm("queryForm"); this.handleQuery(); } }}</script> # 1.2 API 请求 请求 system/tenant.js ( opens new window) 相关的代码如下: import request from '@/utils/request'// 获得租户分页export function getTenantPage(query) { return request({ url: '/system/tenant/page', method: 'get', params: query })} # 2. 后端分页实现 # 2.1 Controller 接口 在 TenantController ( opens new window) 类中,定义 /admin-api/system/tenant/page 接口。代码如下: @Tag(name = "管理后台 - 租户")@RestController@RequestMapping("/system/tenant")public class TenantController { @Resource private TenantService tenantService; @GetMapping("/page") @Operation(summary = "获得租户分页") @PreAuthorize("@ss.hasPermission('system:tenant:query')") public CommonResult<PageResult<TenantRespVO>> getTenantPage(@Valid TenantPageReqVO pageVO) { PageResult<TenantDO> pageResult = tenantService.getTenantPage(pageVO); return success(TenantConvert.INSTANCE.convertPage(pageResult)); }} Request 分页请求,使用 TenantPageReqVO (opens new window) 类,它继承 PageParam 类 Response 分页结果,使用 PageResult 类,每一项是 TenantRespVO (opens new window) 类 # 2.1.1 分页参数 PageParam 分页请求,需要继承 PageParam (opens new window) 类。代码如下: @Schema(description="分页参数")@Datapublic class PageParam implements Serializable { private static final Integer PAGE_NO = 1; private static final Integer PAGE_SIZE = 10; @Schema(description = "页码,从 1 开始", required = true,example = "1") @NotNull(message = "页码不能为空") @Min(value = 1, message = "页码最小值为 1") private Integer pageNo = PAGE_NO; @Schema(description = "每页条数,最大值为 100", required = true, example = "10") @NotNull(message = "每页条数不能为空") @Min(value = 1, message = "每页条数最小值为 1") @Max(value = 100, message = "每页条数最大值为 100") private Integer pageSize = PAGE_SIZE;} 分页条件,在子类中进行定义。以 TenantPageReqVO 举例子,代码如下: @Schema(description = "管理后台 - 租户分页 Request VO")@Data@EqualsAndHashCode(callSuper = true)@ToString(callSuper = true)public class TenantPageReqVO extends PageParam { @Schema(description = "租户名", example = "芋道") private String name; @Schema(description = "联系人", example = "芋艿") private String contactName; @Schema(description = "联系手机", example = "15601691300") private String contactMobile; @Schema(description = "租户状态(0正常 1停用)", example = "1") private Integer status; @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @Schema(description = "创建时间") private LocalDateTime[] createTime;} # 2.1.2 分页结果 PageResult 分页结果 PageResult ( opens new window) 类,代码如下: @Schema(description = "分页结果")@Datapublic final class PageResult<T> implements Serializable { @Schema(description = "数据", required = true) private List<T> list; @Schema(description = "总量", required = true) private Long total;} 分页结果的数据 list 的每一项,通过自定义 VO 类,例如说 TenantRespVO (opens new window) 类。 # 2.2 Mapper 查询 在 TenantMapper (opens new window) 类中,定义 selectPage 查询方法。代码如下: @Mapperpublic interface TenantMapper extends BaseMapperX<TenantDO> { default PageResult<TenantDO> selectPage(TenantPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX<TenantDO>() .likeIfPresent(TenantDO::getName, reqVO.getName()) // 如果 name 不为空,则进行 like 查询 .likeIfPresent(TenantDO::getContactName, reqVO.getContactName()) .likeIfPresent(TenantDO::getContactMobile, reqVO.getContactMobile()) .eqIfPresent(TenantDO::getStatus, reqVO.getStatus()) // 如果 status 不为空,则进行 = 查询 .betweenIfPresent(TenantDO::getCreateTime, reqVO.getBeginCreateTime(), reqVO.getEndCreateTime()) // 如果 create 不为空,则进行 between 查询 .orderByDesc(TenantDO::getId)); // 按照 id 倒序 }} 针对 MyBatis Plus 分页查询的二次分装,在 BaseMapperX (opens new window) 中实现,主要是将 MyBatis 的分页结果 IPage,转换成项目的分页结果 PageResult。代码如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:58:24 参数校验 文件存储(上传下载) ← 参数校验 文件存储(上传下载)→"},{"title":"分布式锁","path":"/wiki/YuDaoBoot/后端手册/分布式锁/分布式锁.html","content":"开发指南后端手册 芋道源码 2022-04-05 目录 分布式锁 yudao-spring-boot-starter-protection (opens new window) 技术组件,使用 Redis 实现分布式锁的功能,它有 2 种使用方式: 编程式锁:基于 Redisson (opens new window) 框架提供的各种 (opens new window)分布式锁 声明式锁:基于 Lock4j (opens new window) 框架的 @Lock4j 注解 Redis 分布式锁的实现原理? 参见 《Redis 实现原理与源码解析系列》 (opens new window) 文章。 # 1. 编程式锁 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId></dependency> # 1.1 Redisson 配置 无需配置。因为在 Redis 缓存 中,进行了 Spring Data Redis + Redisson 的配置。 # 1.2 实战案例 yudao-module-pay 模块的 notify ( opens new window) 功能,使用到分布式锁,确保每个 支付通知任务有且仅有一个在执行。下面,来看看这个案例是如何实现的。 友情提示: 建议你已经阅读过 《开发指南 —— Redis 缓存》 文档。 ① 在 RedisKeyConstants ( opens new window) 类中,定义通知任务使用的分布式锁的 Redis Key。如下图所示: ② 创建 PayNotifyLockRedisDAO ( opens new window) 类,使用 RedisClient 实现分布式锁的加锁与解锁。如下图所示: ③ 在 PayNotifyServiceImpl ( opens new window) 执行指定的支付通知任务时,通过 PayNotifyLockRedisDAO 获得分布式锁。如下图所示: 技术选型:为什么不使用 Lock4j 提供的 LockTemplate 实现编程式锁? 两者各有优势,选择 Redisson 主要考虑它支持的 Redis 分布式锁的类型较多:可靠性较高的红锁、性能较好的读写锁等等。 Lock4j 的 LockTemplate 也是不错的选择,一方面不强依赖 Redisson 框架,一方面支持 ZooKeeper 等等。 # 2. 声明式锁 <dependency> <groupId>com.baomidou</groupId> <artifactId>lock4j-redisson-spring-boot-starter</artifactId></dependency> # 2.1 Lock4j 配置 在 application-local.yaml ( opens new window) 配置文件中,通过 lock4j 配置项,添加 Lock4j 全局默认的分布式锁配置。如下图所示: # 2.2 使用案例 在需要使用到分布式锁的方法上,添加 @Lock4j 注解,非常方便。示例代码如下: @Servicepublic class DemoService { // 默认使用 lock4j 配置项 @Lock4j public void simple() { //do something } // 完全配置,支持 Spring EL 表达式 @Lock4j(keys = {"#user.id", "#user.name"}, expire = 60000, acquireTimeout = 1000) public User customMethod(User user) { return user; }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 22:48:53 单元测试 幂等性(防重复提交) ← 单元测试 幂等性(防重复提交)→"},{"title":"功能权限","path":"/wiki/YuDaoBoot/后端手册/功能权限/功能权限.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 功能权限 # 👍 相关视频教程 功能权限 01:如何设计一套权限系统? (opens new window) 功能权限 02:如何实现菜单的创建? (opens new window) 功能权限 03:如何实现角色的创建? (opens new window) 功能权限 04:如何给用户分配权限 —— 将菜单赋予角色? (opens new window) 功能权限 05:如何给用户分配权限 —— 将角色赋予用户? (opens new window) 功能权限 06:后端如何实现 URL 权限的校验? (opens new window) 功能权限 07:前端如何实现菜单的动态加载? (opens new window) 功能权限 08:前端如何实现按钮的权限校验? (opens new window) # 1. RBAC 权限模型 系统采用 RBAC 权限模型,全称是 Role-Based Access Control 基于角色的访问控制。 简单来说,每个用户拥有若干角色,每个角色拥有若干个菜单,菜单中存在菜单权限、按钮权限。这样,就形成了 “用户<->角色<->菜单” 的授权模型。 在这种模型中,用户与角色、角色与菜单之间构成了多对多的关系,如下图: # 2. Token 认证机制 安全框架使用的是 Spring Security (opens new window) + Token 方案,整体流程如下图所示: ① 前端调用登录接口,使用账号密码获得到认证 Token。响应示例如下: { "code":0, "msg":"", "data":{ "token":"d2a3cdbc6c53470db67a582bd115103f" }} 管理后台的登录实现,可见 代码 (opens new window) 用户 App 的登录实现,可见 代码 (opens new window) 疑问:为什么不使用 Spring Security 内置的表单登录? Spring Security 的登录拓展起来不方便,例如说验证码、三方登录等等。 Token 存储在数据库中,对应 system_oauth2_access_token 访问令牌表的 id 字段。考虑到访问的性能,缓存在 Redis 的 oauth2_access_token:%s (opens new window) 键中。 疑问:为什么不使用 JWT(JSON Web Token)? JWT 是无状态的,无法实现 Token 的作废,例如说用户登出系统、修改密码等等场景。 推荐阅读 《还分不清 Cookie、Session、Token、JWT?》 (opens new window) 文章。 默认配置下,Token 有效期为 30 天,可通过 system_oauth2_client 表中 client_id = default 的记录进行自定义: 修改 access_token_validity_seconds 字段,设置访问令牌的过期时间,默认 1800 秒 = 30 分钟 修改 refresh_token_validity_seconds 字段,设置刷新令牌的过期时间,默认 43200 秒 = 30 天 ② 前端调用其它接口,需要在请求头带上 Token 进行访问。请求头格式如下: ### Authorization: Bearer 登录时返回的 TokenAuthorization: Bearer d2a3cdbc6c53470db67a582bd115103f 具体的代码实现,可见 TokenAuthenticationFilter (opens new window) 过滤器 考虑到使用 Postman、Swagger 调试接口方便,提供了 Token 的模拟机制。请求头格式如下: ### Authorization: Bearer test用户编号Authorization: Bearer test1 其中 \"test\" 可自定义,配置项如下: ### application-local.yamlyudao: security: mock-enable: true # 是否开启 Token 的模拟机制 mock-secret: test # Token 模拟机制的 Token 前缀 # 3. 权限注解 # 3.1 @PreAuthorize 注解 @PreAuthorize ( opens new window) 是 Spring Security 内置的前置权限注解,添加在 接口方法上,声明需要的权限,实现访问权限的控制。 ① 基于【权限标识】的权限控制 权限标识,对应 system_menu 表的 permission 字段,推荐格式为 ${系统}:${模块}:${操作},例如说 system:admin:add 标识 system 服务的添加管理员。 使用示例如下: // 符合 system:user:list 权限要求@PreAuthorize("@ss.hasPermission('system:user:list')")// 符合 system:user:add 或 system:user:edit 权限要求即可@PreAuthorize("@ss.hasAnyPermissions('system:user:add,system:user:edit')") ② 基于【角色标识】的权限控制 权限标识,对应 system_role 表的 code 字段, 例如说 super_admin 超级管理员、tenant_admin 租户管理员。 使用示例如下: // 属于 user 角色@PreAuthorize("@ss.hasRole('user')")// 属于 user 或者 admin 之一@PreAuthorize("@ss.hasAnyRoles('user,admin')") 实现原理是什么? 当 @PreAuthorize 注解里的 Spring EL 表达式返回 false 时,表示没有权限。 而 @PreAuthorize(\"@ss.hasPermission('system:user:list')\") 表示调用 Bean 名字为 ss 的 #hasPermission(...) 方法,方法参数为 \"system:user:list\" 字符串。ss 对应的 Bean 是 PermissionServiceImpl (opens new window) 类,所以你只需要去看该方法的实现代码 (opens new window)。 # 3.2 @PreAuthenticated 注解 @PreAuthenticated (opens new window) 是项目自定义的认证注解,添加在接口方法上,声明登录的用户才允许访问。 主要使用场景是,针对用户 App 的 /app-app/** 的 RESTful API 接口,默认是无需登录的,通过 @PreAuthenticated 声明它需要进行登录。使用示例如下: // AppAuthController.java@PostMapping("/update-password")@Operation(summary = "修改用户密码", description = "用户修改密码时使用")@PreAuthenticatedpublic CommonResult<Boolean> updatePassword(@RequestBody @Valid AppAuthUpdatePasswordReqVO reqVO) { // ... 省略代码} 具体的代码实现,可见 PreAuthenticatedAspect (opens new window) 类。 # 4. 自定义权限配置 默认配置下,管理后台的 /admin-api/** 所有 API 接口都必须登录后才允许访问,用户 App 的 /app-api/** 所有 API 接口无需登录就可以访问。 如下想要自定义权限配置,设置定义 API 接口可以匿名(不登录)进行访问,可以通过下面三种方式: # 4.1 方式一:自定义 AuthorizeRequestsCustomizer 实现 每个 Maven Module 可以实现自定义的 AuthorizeRequestsCustomizer (opens new window) Bean,额外定义每个 Module 的 API 接口的访问规则。例如说 yudao-module-infra 模块的 SecurityConfiguration (opens new window) 类,代码如下: @Configuration("infraSecurityConfiguration")public class SecurityConfiguration { @Value("${spring.boot.admin.context-path:''}") private String adminSeverContextPath; @Bean("infraAuthorizeRequestsCustomizer") public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() { return new AuthorizeRequestsCustomizer() { @Override public void customize(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) { // Swagger 接口文档 registry.antMatchers("/swagger-ui.html").anonymous() .antMatchers("/swagger-resources/**").anonymous() .antMatchers("/webjars/**").anonymous() .antMatchers("/*/api-docs").anonymous(); // Spring Boot Actuator 的安全配置 registry.antMatchers("/actuator").anonymous() .antMatchers("/actuator/**").anonymous(); // Druid 监控 registry.antMatchers("/druid/**").anonymous(); // Spring Boot Admin Server 的安全配置 registry.antMatchers(adminSeverContextPath).anonymous() .antMatchers(adminSeverContextPath + "/**").anonymous(); } }; }} 友情提示 permitAll() 方法:所有用户可以任意访问,包括带上 Token 访问 anonymous() 方法:匿名用户可以任意访问,带上 Token 访问会报错 如果你对 Spring Security 了解不多,可以阅读艿艿写的 《芋道 Spring Boot 安全框架 Spring Security 入门 》 (opens new window) 文章。 # 4.2 方式二:@PermitAll 注解 在 API 接口上添加 @PermitAll (opens new window) 注解,示例如下: // FileController.java@GetMapping("/{configId}/get/{path}")@PermitAllpublic void getFileContent(HttpServletResponse response, @PathVariable("configId") Long configId, @PathVariable("path") String path) throws Exception { // ...} # 4.3 方式三:yudao.security.permit-all-urls 配置项 在 application.yaml 配置文件,通过 yudao.security.permit-all-urls 配置项设置,示例如下: yudao: security: permit-all-urls: - /admin-ui/** # /resources/admin-ui 目录下的静态资源 - /admin-api/xxx/yyy .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:40 代码生成(新增功能) 数据权限 ← 代码生成(新增功能) 数据权限→"},{"title":"单元测试","path":"/wiki/YuDaoBoot/后端手册/单元测试/单元测试.html","content":"开发指南后端手册 芋道源码 2022-04-04 目录 单元测试 项目使用 Junit5 + Mockito 实现单元测试,提升代码质量、重复测试效率、部署可靠性等。 截止目前,项目已经有 500+ 测试用例。 内容推荐 如果你想系统学习单元测试,可以阅读《有效的单元测试》 (opens new window)这本书,非常适合 Java 工程师。 如果只是想学习 Spring Boot Test 的话,可以阅读 《芋道 Spring Boot 单元测试 Test 入门 》 (opens new window) 文章。 # 1.测试组件 yudao-spring-boot-starter-test (opens new window) 是项目提供的测试组件,用于单元测试、集成测试等等。 # 1.1 快速测试的基类 测试组件提供了 4 种单元测试的基类,通过继承它们,可以快速的构建单元测试的环境。 基类 作用 BaseMockitoUnitTest (opens new window) 纯 Mockito 的单元测试 BaseDbUnitTest (opens new window) 使用内嵌的 H2 数据库的单元测试 BaseRedisUnitTest (opens new window) 使用内嵌的 Redis 缓存的单元测试 BaseDbAndRedisUnitTest (opens new window) 使用内嵌的 H2 数据库 + Redis 缓存的单元测试 疑问:什么是内嵌的 Redis 缓存? 基于 jedis-mock (opens new window) 开源项目,通过 RedisTestConfiguration (opens new window) 配置类,启动一个 Redis 进程。一般情况下,会使用 16379 端口。 # 1.2 测试工具类 ① RandomUtils (opens new window) 基于 podam (opens new window) 开源项目,实现 Bean 对象的随机生成。 ② AssertUtils (opens new window) 封装 Junit 的 Assert 断言,实现 Bean 对象的断言,支持忽略部分属性。 # 2. BaseDbUnitTest 实战案例 以字典类型模块的 DictTypeServiceImpl (opens new window) 为例子,讲解它的 DictTypeServiceTest (opens new window) 单元测试的编写实现。 # 2.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-test 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-test</artifactId> <scope>test</scope></dependency> # 2.2 新建 ut 配置文件 在 test/resources ( opens new window) 目录,新建单元测试的 application-unit-test.yaml ( opens new window) 配置文件,内容如下: # 2.3 添加 H2 SQL 脚本 修改 test/resources/sql ( opens new window) 目录的两个 H2 SQL 脚本: ① 在 create_tables.sql ( opens new window) 文件中,添加 system_dict_type 的 H2 建表语句。SQL 如下: CREATE TABLE IF NOT EXISTS "system_dict_type" ( "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "name" varchar(100) NOT NULL DEFAULT '', "type" varchar(100) NOT NULL DEFAULT '', "status" tinyint NOT NULL DEFAULT '0', "remark" varchar(500) DEFAULT NULL, "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted" bit NOT NULL DEFAULT FALSE, PRIMARY KEY ("id")) COMMENT '字典类型表'; 注意,H2 和 MySQL 的建表语句有区别,需要手动进行转换。如果你不想进行转换,可以使用 [基础设置 -> 代码生成] 菜单的代码生成器功能,如下图所示: ② 在 clean.sql (opens new window) 文件中,添加 system_dict_type 的清空数据的语句。SQL 如下: DELETE FROM "system_dict_type"; 每次单元测试的方法执行完后,会执行 clean.sql 脚本,进行数据的清理,保证每个单元测试的方法的数据隔离性。 # 2.3 新建 DictTypeServiceTest 类 新建 DictTypeServiceTest 测试类,继承 BaseMockitoUnitTest 基类,并完成它的配置。代码如下图所示: 属于自己模块的,使用 Spring 初始化成真实的 Bean,然后通过 @Resource 注入。例如说:dictTypeService、dictTypeMapper 属于别人模块的,使用 Spring @MockBean 注解,模拟 Mock 成一个 Bean 后注入。例如说:dictDataService 疑问:为什么有的进行 Mock,有的不进行 Mock 呢? 单元测试需要避免对外部的依赖,而 dictDataService 是外部依赖,所以需要 Mock 掉。 dictTypeMapper 某种程度来说,也是一种外部依赖,但是通过内嵌的 H2 内存数据库,进行“真实”的数据库操作,反而单元测试的编写效率更高,效果更好,所以不需要 Mock 掉。 另外,[基础设置 -> 代码生成] 菜单的代码生成器功能,已经生成了绝大多数的单元测试的逻辑,这里主要是希望让你了解单元测试的具体使用,所以并没有使用它。如下图所示: # 2.4 新增方法的单测 # 2.5 修改方法的单测 # 2.6 删除方法的单测 # 2.7 单条查询方法的单测 # 2.8 分页查询方法的单测 # 3. BaseMockitoUnitTest 实战案例 一些类由于不依赖 MySQL 和 Redis,可以通过继承 BaseMockitoUnitTest 基类,实现纯 Mockito 的单元测试。例如说 SmsSendServiceTest (opens new window) 单元测试类,代码如下: 具体 SmsSendServiceTest 的每个测试方法,和 DictTypeServiceTest 并没有什么差别,还是 Mock 模拟 + Assert 断言 + Verify 调用,你可以自己花点时间瞅瞅。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 工具类 Util 分布式锁 ← 工具类 Util 分布式锁→"},{"title":"参数校验","path":"/wiki/YuDaoBoot/后端手册/参数校验/参数校验.html","content":"开发指南后端手册 芋道源码 2022-03-26 目录 参数校验 项目使用 Hibernate Validator (opens new window) 框架,对 RESTful API 接口进行参数的校验,以保证最终数据入库的正确性。例如说,用户注册时,会校验手机格式的正确性,密码非弱密码。 如果参数校验不通过,会抛出 ConstraintViolationException 异常,被全局的异常处理捕获,返回“请求参数不正确”的响应。示例如下: { "code": 400, "data": null, "msg": "请求参数不正确:密码不能为空"} # 1. 参数校验注解 Validator 内置了 20+ 个参数校验注解,整理成常用与不常用的注解。 # 1.1 常用注解 注解 功能 @NotBlank 只能用于字符串不为 null ,并且字符串 #trim() 以后 length 要大于 0 @NotEmpty 集合对象的元素不为 0 ,即集合不为空,也可以用于字符串不为 null @NotNull 不能为 null @Pattern( value) 被注释的元素必须符合指定的正则表达式 @Max(value) 该字段的值只能小于或等于该值 @Min(value) 该字段的值只能大于或等于该值 @Range(min=, max=) 检被注释的元素必须在合适的范围内 @Size(max, min) 检查该字段的 size 是否在 min 和 max 之间,可以是字符串、数组、集合、Map 等 @Length(max, min) 被注释的字符串的大小必须在指定的范围内。 @AssertFalse 被注释的元素必须为 true @AssertTrue 被注释的元素必须为 false @Email 被注释的元素必须是电子邮箱地址 @URL( protocol=,host=,port=,regexp=,flags=) 被注释的字符串必须是一个有效的 URL # 1.2 不常用注解 注解 功能 @Null 必须为 null @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @DecimalMin(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @Digits(integer, fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内 @Positive 判断正数 @PositiveOrZero 判断正数或 0 @Negative 判断负数 @NegativeOrZero 判断负数或 0 @Future 被注释的元素必须是一个将来的日期 @FutureOrPresent 判断日期是否是将来或现在日期 @Past 检查该字段的日期是在过去 @PastOrPresent 判断日期是否是过去或现在日期 @SafeHtml 判断提交的 HTML 是否安全。例如说,不能包含 JavaScript 脚本等等 # 2. 参数校验使用 只需要三步,即可开启参数校验的功能。 〇 第零步,引入参数校验的 spring-boot-starter-validation ( opens new window) 依赖。一般不需要做,项目默认已经引入。 ① 第一步,在需要参数校验的类上,添加 @Validated ( opens new window) 注解,例如说 Controller、Service 类。代码如下: // Controller 示例@Validatedpublic class AuthController {}// Service 示例,一般放在实现类上@Service@Validatedpublic class AdminAuthServiceImpl implements AdminAuthService {} ② 第二步(情况一)如果方法的参数是 Bean 类型,则在方法参数上添加 @Valid (opens new window) 注解,并在 Bean 类上添加参数校验的注解。代码如下: // Controller 示例@Validatedpublic class AuthController { @PostMapping("/login") public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {}}// Service 示例,一般放在接口上public interface AdminAuthService { String login(@Valid AuthLoginReqVO reqVO, String userIp, String userAgent);}// Bean 类的示例。一般建议添加参数注解到属性上。原因:采用 Lombok 后,很少使用 getter 方法public class AuthLoginReqVO { @NotEmpty(message = "登录账号不能为空") @Length(min = 4, max = 16, message = "账号长度为 4-16 位") @Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母") private String username; @NotEmpty(message = "密码不能为空") @Length(min = 4, max = 16, message = "密码长度为 4-16 位") private String password;} ② 第二步(情况二)如果方法的参数是普通类型,则在方法参数上直接添加参数校验的注解。代码如下: // Controller 示例@Validatedpublic class DictDataController { @GetMapping(value = "/get") public CommonResult<DictDataRespVO> getDictData(@RequestParam("id") @NotNull(message = "编号不能为空") Long id) {}}// Service 示例,一般放在接口上public interface DictDataService { DictDataDO getDictData(@NotNull(message = "编号不能为空") Long id);} ③ 启动项目,模拟调用 RESTful API 接口,少填写几个参数,看看参数校验是否生效。 疑问:Controller 做了参数校验后,Service 是否需要做参数校验? 是需要的。Service 可能会被别的 Service 进行调用,也会存在参数不正确的情况,所以必须进行参数校验。 # 3. 自定义注解 如果 Validator 内置的参数校验注解不满足需求时,我们也可以自定义参数校验的注解。 在项目的 yudao-common (opens new window) 的 validation (opens new window) 包下,就自定义了多个参数校验的注解,以 @Mobile (opens new window) 注解来举例,它提供了手机格式的校验。 ① 第一步,新建 @Mobile 注解,并设置自定义校验器为 MobileValidator (opens new window) 类。代码如下: @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})@Retention(RetentionPolicy.RUNTIME)@Documented@Constraint( validatedBy = MobileValidator.class // 设置校验器)public @interface Mobile { String message() default "手机号格式不正确"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {};} ② 第二步,新建 MobileValidator (opens new window) 校验器。代码如下: public class MobileValidator implements ConstraintValidator<Mobile, String> { @Override public void initialize(Mobile annotation) { } @Override public boolean isValid(String value, ConstraintValidatorContext context) { // 如果手机号为空,默认不校验,即校验通过 if (StrUtil.isEmpty(value)) { return true; } // 校验手机 return ValidationUtils.isMobile(value); }} ③ 第三步,在需要手机格式校验的参数上添加 @Mobile 注解。示例代码如下: public class AppAuthLoginReqVO { @NotEmpty(message = "手机号不能为空") @Mobile // <=== here private String mobile;} # 4. 更多使用文档 更多关于 Validator 的使用,可以系统阅读 《芋道 Spring Boot 参数校验 Validation 入门 》 ( opens new window) 文章。 例如说,手动参数校验、分组校验、国际化 i18n 等等。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/07/06, 00:34:56 异常处理(错误码) 分页实现 ← 异常处理(错误码) 分页实现→"},{"title":"多数据源(读写分离)","path":"/wiki/YuDaoBoot/后端手册/多数据源(读写分离)/多数据源(读写分离).html","content":"开发指南后端手册 芋道源码 2022-04-02 目录 多数据源(读写分离) yudao-spring-boot-starter-mybatis (opens new window) 技术组件,除了提供 MyBatis 数据库操作,还提供了如下 2 种功能: 数据连接池:基于 Alibaba Druid (opens new window) 实现,额外提供监控的能力。 多数据源(读写分离):基于 Dynamic Datasource (opens new window) 实现,支持 Druid 连接池,可集成 Seata (opens new window) 实现分布式事务。 # 1. 数据连接池 友情提示: 如果你未学习过 Druid 数据库连接池,可以后续阅读 《芋道 Spring Boot 数据库连接池入门》 (opens new window) 文章。 <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId></dependency> # 1.1 Druid 监控配置 在 application-local.yaml ( opens new window) 配置文件中,通过 spring.datasource.druid 配置项,仅仅设置了 Druid 监控相关的配置项目,具体数据库的设置需要使用 Dynamic Datasource 的配置项。如下图所示: # 1.2 Druid 监控界面 ① 访问后端的 /druid/index.html 路径,例如说本地的 http://127.0.0.1:48080/druid/index.html 地址,可以查看到 Druid 监控界面。如下图所示: ② 访问前端的 [基础设施 -> MySQL 监控] 菜单,也可以查看到 Druid 监控界面。如下图所示: 补充说明: 前端 [基础设施 -> MySQL 监控] 菜单,通过 iframe 内嵌后端的 /druid/index.html 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.druid 配置项。 # 2. 多数据源 友情提示: 如果你未学习过多数据源,可以后续阅读 《芋道 Spring Boot 多数据源(读写分离)入门》 ( opens new window) 文章。 <dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId></dependency> # 2.1 多数据源配置 在 application-local.yaml ( opens new window) 配置文件中,通过 spring.datasource.dynamic 配置项,配置了 Master-Slave 主从两个数据源。如下图所示: # 2.2 数据源切换 # 2.2.1 @Master 注解 在方法上添加 @Master ( opens new window) 注解,使用名字为 master 的数据源,即使用【主】库,一般适合【写】场景。示例如下图: 由于项目的 spring.datasource.dynamic.primary 为 master,默认使用【主】库,所以无需手动添加 @Master 注解。 # 2.2.2 @Slave 注解 在方法上添加 @Slave ( opens new window) 注解,使用名字为 slave 的数据源,即使用【从】库,一般适合【读】场景。示例如下图: # 2.2.3 @DS 注解 在方法上添加 @DS ( opens new window) 注解,使用指定名字的数据源,适合多数据源的情况。示例如下图: # 2.3 分布式事务 在使用 Spring @Transactional 声明的事务中,无法进行数据源的切换,此时有 3 种解决方案: ① 拆分成多个 Spring 事务,每个事务对应一个数据源。如果是【写】场景,可能会存在多数据源的事务不一致的问题。 ② 引入 Seata 框架,提供完整的分布式事务的解决方案,可学习 《芋道 Seata 极简入门 》 ( opens new window) 文章。 ③ 使用 Dynamic Datasource 提供的 @DSTransactional ( opens new window) 注解,支持多数据源的切换,不提供绝对可靠的多数据源的事务一致性(强于 ① 弱于 ②),可学习 《DSTransactional 实现源码分析 》 ( opens new window) 文章。 # 3. 分库分表 建议采用 ShardingSphere 的子项目 Sharding-JDBC 完成分库分表的功能,可阅读 《芋道 Spring Boot 分库分表入门 》 ( opens new window) 文章,学习如何整合进项目。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:25:01 数据库 MyBatis Redis 缓存 ← 数据库 MyBatis Redis 缓存→"},{"title":"地区 & IP 库","path":"/wiki/YuDaoBoot/后端手册/地区 & IP 库/地区 & IP 库.html","content":"开发指南后端手册 芋道源码 2022-12-29 目录 地区 & IP 库 yudao-spring-boot-starter-biz-ip (opens new window) 业务组件,提供地区 & IP 库的封装。 # 1. 地区 AreaUtils (opens new window) 是地区工具类,可以查询中国的省、市、区县,也可以查询国外的国家。 它的数据来自 Administrative-divisions-of-China (opens new window) 项目,最终整理到项目的 area.csv (opens new window) 文件。每一行的数据,对应 Area (opens new window) 对象。代码所示: public class Area { /** * 编号 */ private Integer id; /** * 名字 */ private String name; /** * 类型 * * 枚举 {@link AreaTypeEnum} * 1 - 国家 * 2 - 省份 * 3 - 城市 * 4 - 地区, 例如说县、镇、区等 */ private Integer type; /** * 父节点 */ private Area parent; /** * 子节点 */ private List<Area> children;} AreaUtils 主要有如下两个方法: // AreaUtils.java/** * 获得指定编号对应的区域 * * @param id 区域编号 * @return 区域 */public static Area getArea(Integer id) { // ... 省略具体实现}/** * 格式化区域 * * 例如说: * 1. id = “静安区”时:上海 上海市 静安区 * 2. id = “上海市”时:上海 上海市 * 3. id = “上海”时:上海 * 4. id = “美国”时:美国 * 当区域在中国时,默认不显示中国 * * @param id 区域编号 * @param separator 分隔符 * @return 格式化后的区域 */public static String format(Integer id, String separator) { // ... 省略具体实现} 具体的使用,可见 AreaUtilsTest (opens new window) 测试类。 另外,管理后台提供了 [系统管理 -> 地区管理] 菜单,可以按照树形结构查看地区列表。如下图所示: 后端代码,对应 AreaController (opens new window) 的 /admin-api/system/area/tree 接口 前端代码,对应 system/area/index.vue (opens new window) 界面 # 2. IP IPUtils (opens new window) 是 IP 工具类,可以查询 IP 对应的城市信息。 它的数据来自 ip2region (opens new window) 项目,最终整理到项目的 ip2region.xdb (opens new window) 文件。 IPUtils 主要有如下两个方法: // IPUtils.java/** * 查询 IP 对应的地区编号 * * @param ip IP 地址,格式为 127.0.0.1 * @return 地区id */public static Integer getAreaId(String ip) { // ... 省略具体实现}/** * 查询 IP 对应的地区 * * @param ip IP 地址,格式为 127.0.0.1 * @return 地区 */public static Area getArea(String ip) { // ... 省略具体实现} 具体的使用,可见 IPUtilsTest (opens new window) 测试类。 另外,管理后台提供了 [系统管理 -> 地区管理] 菜单,也提供了 IP 查询城市的示例。如下图所示: 后端代码,对应 AreaController (opens new window) 的 /admin-api/system/area/get-by-ip 接口 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/27, 21:55:46 验证码 工作流(Flowable)会签、或签 ← 验证码 工作流(Flowable)会签、或签→"},{"title":"定时任务","path":"/wiki/YuDaoBoot/后端手册/定时任务/定时任务.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 定时任务 定时任务的使用场景主要如下: 时间驱动处理场景:每分钟扫描超时支付的订单,活动状态刷新,整点发送优惠券。 批量处理数据:按月批量统计报表数据,批量更新短信状态,实时性要求不高。 年度最佳定时任务:每个月初的工资单的推送!!! 如果你对定时任务了解不多,可以后续阅读 《芋道 Spring Boot 定时任务入门》 (opens new window) 文章。 项目基于 Quartz + MySQL 实现分布式定时任务,并提供 [基础设施 -> 定时任务] 菜单,进行定时任务的统一管理,支持动态控制任务的添加、修改、开启、暂停、删除、执行一次等操作。 yudao-spring-boot-starter-job (opens new window) 技术组件:基于 Quartz 框架的封装,提供简便的 JobHandler (opens new window) 接入,任务的执行、重试,执行日志的记录。 yudao-module-infra 的 job (opens new window) 业务模块,提供任务的动态管理,执行日志的存储。 # 1. Quartz 配置 在 application-local.yaml (opens new window) 配置文件中,通过 spring.quartz 配置项,设置 Quartz 使用 MySQL 实现集群。如下图所示: 考虑到 local 本地和 dev 测试环境使用相同的数据库,所以【本地】配置 spring.quartz.auto-startup 为 false,禁用本地执行定时任务的功能,影响测试环境。 # 2. 实战案例 以用户 Session 超时的定时任务举例子,讲解在项目中使用定时任务。 注意,需要修改 application-local.yaml 配置文件,将 spring.quartz.auto-startup 为 true,开启本地执行定任务的功能。 # 2.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-job 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-job</artifactId></dependency> # 2.2 UserSessionTimeoutJob 每个 yudao-module-xxx-biz 模块的 job 包,用于定义定时任务的 Job 类。 因此,在 yudao-module-system-biz 模块的 job 包下,创建 UserSessionTimeoutJob ( opens new window) 类,实现 JobHandler ( opens new window) 接口,执行用户 Session 超时 Job。如下图所示: 疑问:为什么添加 @TenantJob 注解? 声明 @TenantJob ( opens new window) 注解在 Job 类上,实现并行遍历每个租户,执行定时任务的逻辑。 更多多租户的内容,可见 《开发指南 —— SaaS 多租户》 ( opens new window) 文档。 # 2.3 配置任务 ① 点击 [新增] 按钮,填写定时任务 UserSessionTimeoutJob 的信息。如下图所示: 处理器的名字:对应的 Spring Bean 名字。例如说 UserSessionTimeoutJob 对应 userSessionTimeoutJob Cron 表达式:执行周期,可通过 [生成表达式] 功能,进行生成 重试次数、重试间隔:执行失败后,立即重试的次数以及重试的间隔时间 超时时间监控:执行超过该时间后,发送告警邮件给开发【暂不支持,未来实现】 常用的 Cron 表达式如下: 0 0 10,14,16 * * ? 每天上午 10 点,下午 2 点、4 点 0 0/30 9-17 * * ? 朝九晚五工作时间内,每半小时 0 0 12 ? * WED 表示每个星期三中午 12 点 0 0 12 * * ? 每天中午 12 点触发 0 15 10 ? * * 每天上午 10:15 触发 0 15 10 * * ? 每天上午 10:15 触发 0 15 10 * * ? * 每天上午 10:15 触发 0 15 10 * * ? 2005 2005 年的每天上午 10:15 触发 0 * 14 * * ? 在每天下午 2 点到下午 2:59 期间,每 1 分钟触发 0 0/5 14 * * ? 在每天下午 2 点到下午 2:55 期间,每 5 分钟触发 0 0/5 14,18 * * ? 在每天下午 2 点到 2:55 期间和下午 6 点到 6:55 期间,每 5 分钟触发 0 0-5 14 * * ? 在每天下午 2 点到下午 2:05 期间,每 1 分钟触发 0 10,44 14 ? 3 WED 每年三月的星期三的下午 2:10 和 2:44 触发 0 15 10 ? * MON-FRI 周一至周五的上午 10:15 触发 0 15 10 15 * ? 每月15日上午 10:15 触发 0 15 10 L * ? 每月最后一日的上午 10:15 触发 0 15 10 ? * 6L 每月的最后一个星期五上午 10:15 触发 0 15 10 ? * 6L 2002-2005 2002 年至 2005 年,每月的最后一个星期五上午 10:15 触发 0 15 10 ? * 6#3 每月的第三个星期五上午 10:15 触发 ② 点击 [更多 -> 任务详情] 按钮,可以查看任务的基础信息、后续的执行时间。如下图所示: # 2.4 测试任务 ① 点击 [更多 -> 执行一次] 按钮,立即执行一次 UserSessionTimeoutJob 定时任务。可以在 IDEA 控制台看到输出,如下图所示: ② 点击 [更多 -> 调度日志] 按钮,可以查看到 UserSessionTimeoutJob 的执行日志。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 本地缓存 异步任务 ← 本地缓存 异步任务→"},{"title":"工具类 Util","path":"/wiki/YuDaoBoot/后端手册/工具类 Util/工具类 Util.html","content":"开发指南后端手册 芋道源码 2022-04-04 目录 工具类 Util 本小节,介绍项目中使用到的工具类,避免大家重复造轮子。 # 1. Hutool 项目使用 Hutool (opens new window) 作为主工具库。Hutool 是国产的一个 Java 工具包,它可以帮助我们简化每一行代码,减少每一个方法,让 Java 语言也可以“甜甜的”。 yudao-common (opens new window) 模块的 util (opens new window) 包作为辅工具库,以 Utils 结尾,补充 Hutool 缺少的工具能力。 友情提示:常用的工具类,使用 ⭐ 标记,需要的时候可以找找有没对应的工具方法。 作用 Hutool 芋道 Utils 数组工具 ArrayUtil (opens new window) ArrayUtils (opens new window) ⭐ 集合工具 CollUtil (opens new window) CollectionUtils (opens new window) ⭐ Map 工具 MapUtil (opens new window) MapUtils (opens new window) Set 工具 SetUtils (opens new window) List 工具 ListUtil (opens new window) 文件工具 FileUtil (opens new window) FileTypeUtil (opens new window) FileUtils (opens new window) 压缩工具 ZipUtil (opens new window) IoUtils (opens new window) IO 工具 ZipUtil (opens new window) Resource 工具 ResourceUtil (opens new window) JSON 工具 JsonUtils (opens new window) 数字工具 NumberUtil (opens new window) NumberUtils (opens new window) 对象工具 ObjectUtil (opens new window) ObjectUtils (opens new window) 唯一 ID 工具 IdUtil (opens new window) ⭐ 字符串工具 StrUtil (opens new window) StrUtils (opens new window) 时间工具 DateUtil (opens new window) DateUtils (opens new window) 反射工具 ReflectUtil (opens new window) 异常工具 ExceptionUtil (opens new window) 随机工具 RandomUtil (opens new window) RandomUtils (opens new window) URL 工具 URLUtil (opens new window) HttpUtils (opens new window) Servlet 工具 ServletUtils (opens new window) Spring 工具 SpringUtil (opens new window) SpringAopUtils (opens new window) SpringExpressionUtils (opens new window) 分页工具 PageUtils (opens new window) 校验工具 ValidationUtil (opens new window) ValidationUtils (opens new window) 断言工具 Assert (opens new window) AssertUtils (opens new window) 强烈推荐: Guava 是 Google 开源的 Java 常用类库,如果你感兴趣,可以阅读 《Guava 学习笔记》 (opens new window) 文章。 # 2. Lombok Lombok (opens new window) 是一个 Java 工具,通过使用其定义的注解,自动生成常见的冗余代码,提升开发效率。 如果你没有学习过 Lombok,需要阅读下 《芋道 Spring Boot 消除冗余代码 Lombok 入门》 (opens new window) 文章。 在项目的根目录有 lombok.config (opens new window) 全局配置文件,开启链式调用、生成的 toString/hashcode/equals 方法需要调用父方法。如下图所示: # 3. MapStruct 项目使用 MapStruct (opens new window) 实现 VO、DO、DTO 等对象之间的转换。 如果你没有学习过 MapStruct,需要阅读下 《芋道 Spring Boot 对象转换 MapStruct 入门》 (opens new window) 文章。 在每个 yudao-module-xxx-biz 模块的 convert 包下,可以看到各个业务的 Convert 接口,如下图所示: # 4. HTTP 调用 ① 使用 Feign 实现声明式的调用,可参考《芋道 Spring Boot 声明式调用 Feign 入门 》 (opens new window)文章。 ② 使用 Hutool 自带的 HttpUtil (opens new window) 工具类。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/05/03, 18:13:25 配置管理 单元测试 ← 配置管理 单元测试→"},{"title":"幂等性(防重复提交)","path":"/wiki/YuDaoBoot/后端手册/幂等性(防重复提交)/幂等性(防重复提交).html","content":"开发指南后端手册 芋道源码 2022-04-09 目录 幂等性(防重复提交) yudao-spring-boot-starter-protection (opens new window) 技术组件,由它的 idempotent (opens new window) 包,提供声明式的幂等特性,可防止重复请求。例如说,用户快速的双击了某个按钮,前端没有禁用该按钮,导致发送了两次重复的请求。 // UserController.java@Idempotent(timeout = 10, timeUnit = TimeUnit.SECONDS, message = "正在添加用户中,请勿重复提交")@PostMapping("/user/create")public String createUser(User user){ userService.createUser(user); return "添加成功";} # 1. 实现原理 它的实现原理非常简单,针对相同参数的方法,一段时间内,有且仅能执行一次。执行流程如下: ① 在方法执行前,根据参数对应的 Key 查询是否存在。 如果存在,说明正在执行中,则进行报错。 如果不在 ,则计算参数对应的 Key,存储到 Redis 中,并设置过期时间,即标记正在执行中。 默认参数的 Redis Key 的计算规则由 DefaultIdempotentKeyResolver ( opens new window) 实现,使用 MD5(方法名 + 方法参数),避免 Redis Key 过长。 ② 方法执行完成, 不会主动删除参数对应的 Key。 如果希望会主动删除 Key,可以使用 《开发指南 —— 分布式锁》 提供的 @Lock 来实现幂等性。 🙂 从本质上来说,idempotent 包提供的幂等特性,本质上也是基于 Redis 实现的分布式锁。 ③ 如果方法执行时间较长,超过 Key 的过期时间,则 Redis 会自动删除对应的 Key。因此,需要大概评估下,避免方法的执行时间超过过期时间。 # 2. @Idempotent 注解 @Idempotent ( opens new window) 注解,声明在方法上,表示该方法需要开启幂等性。代码如下: ① 对应的 AOP 切面是 IdempotentAspect ( opens new window) 类,核心就 10 行左右的代码,如下图所示: ② 对应的 Redis Key 的前缀是 idempotent:%s ,可见 IdempotentRedisDAO ( opens new window) 类,如下图所示: # 3. 使用示例 本小节,我们实现 /admin-api/infra/test-demo/get RESTful API 接口的幂等性。 ① 在 pom.xml 文件中,引入 yudao-spring-boot-starter-protection 依赖。 <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-protection</artifactId></dependency> ② 在 /admin-api/infra/test-demo/get RESTful API 接口的对应方法上,添加 @Idempotent 注解。代码如下: // TestDemoController.java@GetMapping("/get")@Idempotent(timeout = 10, message = "重复请求,请稍后重试")public CommonResult<TestDemoRespVO> getTestDemo(@RequestParam("id") Long id) { // ... 省略代码} ③ 调用 /admin-api/infra/test-demo/get RESTful API 接口,执行成功。 ④ 再次调用 /admin-api/infra/test-demo/get RESTful API 接口,被幂等性拦截,执行失败。 { "code": 900, "data": null, "msg": "重复请求,请稍后重试"} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/16, 01:42:17 分布式锁 限流熔断 ← 分布式锁 限流熔断→"},{"title":"异步任务","path":"/wiki/YuDaoBoot/后端手册/异步任务/异步任务.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 异步任务 yudao-spring-boot-starter-job (opens new window) 技术组件,除了提供定时任务的功能,还提供了 Async 异步任务的能力。系统使用异步任务,提升执行效率。例如说: 操作日志模块 (opens new window),异步记录【操作日志】 访问日志模块 (opens new window),异步记录【访问日志】 友情提示: 如果你未学习过 Spring 异步任务,可以后续阅读 《芋道 Spring Boot 异步任务入门 》 (opens new window) 文章。 # 1. Async 配置 在 YudaoAsyncAutoConfiguration (opens new window) 配置类,设置使用 TransmittableThreadLocal (opens new window),解决异步执行时上下文传递的问题。如下图所示: 友情提示: 项目使用到 ThreadLocal 的地方,建议都使用 TransmittableThreadLocal 进行替换。 # 2. 引入依赖 以访问日志模块为例,讲解它如何使用异步任务,实现异步记录【访问日志】的功能。 # 2.1 引入依赖 在 yudao-module-system-infra 模块中,引入 yudao-spring-boot-starter-job 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-job</artifactId></dependency> # 2.2 添加 @Async 注解 在 ApiAccessLogServiceImpl ( opens new window) 的 #createApiAccessLogAsync(...) 方法上,添加 @Async 注解,声明它要异步执行。如下图所示: # 2.3 测试调用 随便请求一个 RESTful API 接口,可以看到在异步任务的线程池中,进行了访问日志的记录。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 定时任务 消息队列 ← 定时任务 消息队列→"},{"title":"异常处理(错误码)","path":"/wiki/YuDaoBoot/后端手册/异常处理(错误码)/异常处理(错误码).html","content":"开发指南后端手册 芋道源码 2022-03-25 目录 异常处理(错误码) 本章节,将讲解异常相关的统一响应、异常处理、业务异常、错误码这 4 块的内容。 # 1. 统一响应 后端提供 RESTful API 给前端时,需要响应前端 API 调用是否成功: 如果成功,成功的数据是什么。后续,前端会将数据渲染到页面上 如果失败,失败的原因是什么。一般,前端会将原因弹出提示给用户 因此,需要有统一响应,而不能是每个接口定义自己的风格。一般来说,统一响应返回信息如下: 成功时,返回成功的状态码 + 数据 失败时,返回失败的状态码 + 错误提示 在标准的 RESTful API 的定义,是推荐使用 HTTP 响应状态码 (opens new window) 作为状态码。一般来说,我们实践很少这么去做,主要原因如下: 业务返回的错误状态码很多,HTTP 响应状态码无法很好的映射。例如说,活动还未开始、订单已取消等等 学习成本高,开发者对 HTTP 响应状态码不是很了解。例如说,可能只知道 200、403、404、500 几种常见的 # 1.1 CommonResult ruoyi-vue-pro (opens new window) 项目在实践时,将状态码放在 Response Body 响应内容中返回。一共有 3 个字段,通过 CommonResult (opens new window) 定义如下: // 成功响应{ code: 0, data: { id: 1, username: "yudaoyuanma" }}// 失败响应{ code: 233666, message: "徐妈太丑了"} 可以增加 success 字段吗? 有些团队在实践时,会增加了 success 字段,通过 true 和 false 表示成功还是失败。 这个看每个团队的习惯吧。艿艿的话,还是偏好基于约定,返回 0 时表示成功。 失败时的 code 字段,使用全局的错误码,稍后在 「4. 错误码」 小节来讲解。 ① 在 RESTful API 成功时,定义 Controller 对应方法的返回类型为 CommonResult,并调用 #success(T data) (opens new window) 方法来返回。代码如下图: CommonResult 的 data 字段是泛型,建议定义对应的 VO 类,而不是使用 Map 类。 ② 在 RESTful API 失败时,通过抛出 Exception 异常,具体在 「2. 异常处理」 小节。 # 1.2 使用 @ControllerAdvice ? 在 Spring MVC 中,可以使用 @ControllerAdvice 注解,通过 Spring AOP 拦截修改 Controller 方法的返回结果,从而实现全局的统一返回。 使用 @ControllerAdvice 注解的实战案例? 如果你感兴趣的话,可以阅读 《芋道 Spring Boot SpringMVC 入门 》 (opens new window) 文章的「4. 全局统一返回 」小节。 为什么项目不采用这种方式呢?主要原因是,这样的方式“破坏”了方法的定义,导致一些隐性的问题。例如说,Swagger 接口定义错误,展示的响应结果不是 CommonResult。 还有个原因,部分 RESTful API 不需要自动包装 CommonResult 结果。例如说,第三方支付回调只需要返回 \"success\" 字符串。 # 2. 异常处理 RESTful API 发生异常时,需要拦截 Exception 异常,转换成统一响应的格式,否则前端无法处理。 # 2.1 Spring MVC 的异常 在 Spring MVC 中,通过 @ControllerAdvice + @ExceptionHandler 注解,声明将指定类型的异常,转换成对应的 CommonResult 响应。实现的代码,可见 GlobalExceptionHandler (opens new window) 类,代码如下: # 2.2 Filter 的异常 在请求被 Spring MVC 处理之前,是先经过 Filter 处理的,此时发生异常时,是无法通过 @ExceptionHandler 注解来处理的。只能通过 try catch 的方式来实现,代码如下: # 3. 业务异常 在 Service 发生业务异常时,如果进行返回呢?例如说,用户名已经存在,商品库存不足等。常用的方案选择,主要有两种: 方案一,使用 CommonResult 统一响应结果,里面有错误码和错误提示,然后进行 return 返回 方案二,使用 ServiceException 统一业务异常,里面有错误码和错误提示,然后进行 throw 抛出 选择方案一 CommonResult 会存在两个问题: 因为 Spring @Transactional 声明式事务,是基于异常进行回滚的,如果使用 CommonResult 返回,则事务回滚会非常麻烦 当调用别的方法时,如果别人返回的是 CommonResult 对象,还需要不断的进行判断,写起来挺麻烦的 因此,项目采用方案二 ServiceException 异常。 # 3.1 ServiceException 定义 ServiceException (opens new window) 异常类,继承 RuntimeException 异常类(非受检),用于定义业务异常。代码如下: 为什么继承 RuntimeException 异常? 大多数业务场景下,我们无需处理 ServiceException 业务异常,而是通过 GlobalExceptionHandler 统一处理,转换成对应的 CommonResult 对象,进而提示给前端即可。 如果真的需要处理 ServiceException 时,通过 try catch 的方式进行主动捕获。 # 3.2 ServiceExceptionUtil 在 Service 需抛出业务异常时,通过调用 ServiceExceptionUtil (opens new window) 的 #exception(ErrorCode errorCode, Object... params) 方法来构建 ServiceException 异常,然后使用 throw 进行抛出。代码如下: // ServiceExceptionUtil.javapublic static ServiceException exception(ErrorCode errorCode) { /** 省略参数 */ }public static ServiceException exception(ErrorCode errorCode, Object... params) { /** 省略参数 */ } 为什么使用 ServiceExceptionUtil 来构建 ServiceException 异常? 错误提示的内容,支持使用管理后台进行动态配置,所以通过 ServiceExceptionUtil 获取内容的配置与格式化。 # 4. 错误码 错误码,对应 ErrorCode (opens new window) 类,枚举项目中的错误,全局唯一,方便定位是谁的错、错在哪。 # 4.1 错误码分类 错误码分成两类:全局的系统错误码、模块的业务错误码。 # 4.1.1 系统错误码 全局的系统错误码,使用 0-999 错误码段,和 HTTP 响应状态码 (opens new window) 对应。虽然说,HTTP 响应状态码作为业务使用表达能力偏弱,但是使用在系统层面还是非常不错的。 系统错误码定义在 GlobalErrorCodeConstants (opens new window) 类,代码如下: # 4.1.2 业务错误码 模块的业务错误码,按照模块分配错误码的区间,避免模块之间的错误码冲突。 ① 业务错误码一共 10 位,分成 4 段,在 ServiceErrorCodeRange (opens new window) 分配,规则与代码如下图: ② 每个业务模块,定义自己的 ErrorCodeConstants 错误码枚举类。以 yudao-module-system 模块举例子,代码如下: # 4.2 错误码管理 在管理后台的 [系统管理 -> 错误码管理] 菜单,可以进行错误码的管理。 启动中的项目会每 60 秒,加载最新的错误码配置。所以,我们在修改完错误码的提示后,无需重启项目。 # 4.2.1 手动添加 点击 [新增] 按钮,进行错误码的手动添加。如下图所示: # 4.2.2 自动添加 通过 yudao.error-code.constants-class-list 配置项,设置需要自动添加的 ErrorCodeConstants 错误码枚举类。如下图所示: 项目启动时,会自动扫描对应的 ErrorCodeConstants 中的错误码,自动添加或修改错误码的配置。 注意,自动添加的错误码的类型为【自动生成】,一旦在管理后台手动 [编辑] 后,该错误码就不再支持自动修改。 自动添加是如何实现的? 参见 system/framework/errorcode (opens new window) 包的代码。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 SaaS 多租户【数据库隔离】 参数校验 ← SaaS 多租户【数据库隔离】 参数校验→"},{"title":"敏感词","path":"/wiki/YuDaoBoot/后端手册/敏感词/敏感词.html","content":"开发指南后端手册 芋道源码 2022-12-31 目录 敏感词 本章节,介绍项目的敏感词功能,可用于文本检测,高效过滤色情、广告、敏感、暴恐等违规内容。例如说,用户昵称、评论、私信等文本内容,都可以使用敏感词功能进行过滤。 # 1. 实现原理 敏感词采用 前缀树 (opens new window) 算法,,核心代码见 SimpleTrie (opens new window) 类。 # 2. 使用教程 对应的管理后台,可以在 [系统管理 -> 敏感词] 菜单,进行敏感词的管理。如下图所示: 前端实现:sensitiveWord/index.vue (opens new window) 后端实现:SensitiveWordController (opens new window) # 2.1 添加敏感词 标签:用于敏感词分组,不同的场景会需要使用不同的敏感词,通过标签进行分组。 添加完敏感词后,刷新下界面。 # 2.2 测试敏感词 ① 输入检测文本为“你是白痴么?”,选择标签为“测试”,检测到有敏感词: ② 选择标签为“蔬菜”,检测到米有敏感词: # 3. 敏感词的使用 SensitiveWordApi (opens new window) 提供了敏感词的 API 接口,可以在任意地方使用。方法如下: public interface SensitiveWordApi { /** * 获得文本所包含的不合法的敏感词数组 * * @param text 文本 * @param tags 标签数组 * @return 不合法的敏感词数组 */ List<String> validateText(String text, List<String> tags); /** * 判断文本是否包含敏感词 * * @param text 文本 * @param tags 表述数组 * @return 是否包含 */ boolean isTextValid(String text, List<String> tags);} 使用步骤如下: ① 在需要使用的 yudao-module-*-biz 模块的 pom.xml 中,引入 yudao-module-system-api 依赖。代码如下: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 注入 SensitiveWordApi Bean,调用对应的方法即可。例如说: @Servicepublic class DemoService { @Resource private SensitiveWordApi sensitiveWordApi; public void demo() { sensitiveWordApi.validateText("你是白痴吗", Collections.singletonList("测试")); sensitiveWordApi.isTextValid("你是白痴吗", Collections.singletonList("蔬菜")); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/26, 21:09:52 数据脱敏 验证码 ← 数据脱敏 验证码→"},{"title":"数据库文档","path":"/wiki/YuDaoBoot/后端手册/数据库文档/数据库文档.html","content":"None"},{"title":"数据库 MyBatis","path":"/wiki/YuDaoBoot/后端手册/数据库 MyBatis/数据库 MyBatis.html","content":"开发指南后端手册 芋道源码 2022-04-01 目录 数据库 MyBatis yudao-spring-boot-starter-mybatis (opens new window) 技术组件,基于 MyBatis Plus 实现数据库的操作。如果你没有学习过 MyBatis Plus,建议先阅读 《芋道 Spring Boot MyBatis 入门 》 (opens new window) 文章。 友情提示 MyBatis 是最容易读懂的 Java 框架之一,感兴趣的话,可以看看艿艿写的 《芋道 MyBatis 源码解析》 (opens new window) 系列,已经有 18000 人学习过! # 1. 实体类 BaseDO (opens new window) 是所有数据库实体的父类,代码如下: @Datapublic abstract class BaseDO implements Serializable { /** * 创建时间 */ @TableField(fill = FieldFill.INSERT) private Date createTime; /** * 最后更新时间 */ @TableField(fill = FieldFill.INSERT_UPDATE) private Date updateTime; /** * 创建者,目前使用 AdminUserDO / MemberUserDO 的 id 编号 * * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 */ @TableField(fill = FieldFill.INSERT) private String creator; /** * 更新者,目前使用 AdminUserDO / MemberUserDO 的 id 编号 * * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 */ @TableField(fill = FieldFill.INSERT_UPDATE) private String updater; /** * 是否删除 */ @TableLogic private Boolean deleted;} createTime + creator 字段,创建人相关信息。 updater + updateTime 字段,创建人相关信息。 deleted 字段,逻辑删除。 对应的 SQL 字段如下: `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', # 1.1 主键编号 id 主键编号,推荐使用 Long 型自增,原因是: 自增,保证数据库是按顺序写入,性能更加优秀。 Long 型,避免未来业务增长,超过 Int 范围。 对应的 SQL 字段如下: `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', 项目的 id 默认采用数据库自增的策略,如果希望使用 Snowflake 雪花算法,可以修改 application.yaml 配置文件,将配置项 mybatis-plus.global-config.db-config.id-type 修改为 ASSIGN_ID。如下图所示: # 1.2 逻辑删除 所有表通过 deleted 字段来实现逻辑删除,值为 0 表示未删除,值为 1 表示已删除,可见 application.yaml 配置文件的 logic-delete-value 和 logic-not-delete-value 配置项。如下图所示: ① 所有 SELECT 查询,都会自动拼接 WHERE deleted = 0 查询条件,过滤已经删除的记录。如果被删除的记录,只能通过在 XML 或者 @SELECT 来手写 SQL 语句。例如说: ② 建立唯一索引时,需要额外增加 delete_time 字段,添加到唯一索引字段中,避免唯一索引冲突。例如说,system_users 使用 username 作为唯一索引: 未添加前:先逻辑删除了一条 username = yudao 的记录,然后又插入了一条 username = yudao 的记录时,会报索引冲突的异常。 已添加后:先逻辑删除了一条 username = yudao 的记录并更新 delete_time 为当前时间,然后又插入一条 username = yudao 并且 delete_time 为 0 的记录,不会导致唯一索引冲突。 # 1.3 自动填充 DefaultDBFieldHandler (opens new window) 基于 MyBatis 自动填充机制,实现 BaseDO 通用字段的自动设置。代码如下如: # 1.4 “复杂”字段类型 MyBatis Plus 提供 TypeHandler 字段类型处理器,用于 JavaType 与 JdbcType 之间的转换。示例如下: 常用的字段类型处理器有: JacksonTypeHandler (opens new window):通用的 Jackson 实现 JSON 字段类型处理器。 JsonLongSetTypeHandler (opens new window):针对 Set<Long> 的 Jackson 实现 JSON 字段类型处理器。 另外,如果你后续要拓展自定义的 TypeHandler 实现,可以添加到 cn.iocoder.yudao.framework.mybatis.core.type (opens new window) 包下。 注意事项: 使用 TypeHandler 时,需要设置实体的 @TableName 注解的 @autoResultMap = true。 # 2. 编码规范 ① 数据库实体类放在 dal.dataobject 包下,以 DO 结尾;数据库访问类放在 dal.mysql 包下,以 Mapper 结尾。如下图所示: ② 数据库实体类的注释要完整,特别是哪些字段是关联(外键)、枚举、冗余等等。例如说: ③ 禁止在 Controller、Service 中,直接进行 MyBatis Plus 操作。原因是:大量 MyBatis 操作散落在 Service 中,会导致 Service 的代码越来乱,无法聚焦业务逻辑。 示例 错误 正确 并且,通过只允许将 MyBatis Plus 操作编写 Mapper 层,更好的实现 SELECT 查询的复用,而不是 Service 会存在很多相同且重复的 SELECT 查询的逻辑。 ④ Mapper 的 SELECT 查询方法的命名,采用 Spring Data 的 \"Query methods\" (opens new window) 策略,方法名使用 selectBy查询条件 规则。例如说: ⑤ 优先使用 LambdaQueryWrapper 条件构造器,使用方法获得字段名,避免手写 \"字段\" 可能写错的情况。例如说: ⑥ 简单的单表查询,优先在 Mapper 中通过 default 方法实现。例如说: # 3. CRUD 接口 BaseMapperX (opens new window) 接口,继承 MyBatis Plus 的 BaseMapper 接口,提供更强的 CRUD 操作能力。 # 3.1 selectOne #selectOne(...) (opens new window) 方法,使用指定条件,查询单条记录。示例如下: # 3.2 selectCount #selectCount(...) (opens new window) 方法,使用指定条件,查询记录的数量。示例如下: # 3.3 selectList #selectList(...) (opens new window) 方法,使用指定条件,查询多条记录。示例如下: # 3.4 selectPage 针对 MyBatis Plus 分页查询的二次分装,在 BaseMapperX (opens new window) 中实现,目的是使用项目自己的分页封装: 【入参】查询前,将项目的分页参数 PageParam (opens new window),转换成 MyBatis Plus 的 IPage 对象。 【出参】查询后,将 MyBatis Plus 的分页结果 IPage,转换成项目的分页结果 PageResult (opens new window)。代码如下图: 具体的使用示例,可见 TenantMapper (opens new window) 类中,定义 selectPage 查询方法。代码如下: @Mapperpublic interface TenantMapper extends BaseMapperX<TenantDO> { default PageResult<TenantDO> selectPage(TenantPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX<TenantDO>() .likeIfPresent(TenantDO::getName, reqVO.getName()) // 如果 name 不为空,则进行 like 查询 .likeIfPresent(TenantDO::getContactName, reqVO.getContactName()) .likeIfPresent(TenantDO::getContactMobile, reqVO.getContactMobile()) .eqIfPresent(TenantDO::getStatus, reqVO.getStatus()) // 如果 status 不为空,则进行 = 查询 .betweenIfPresent(TenantDO::getCreateTime, reqVO.getBeginCreateTime(), reqVO.getEndCreateTime()) // 如果 create 不为空,则进行 between 查询 .orderByDesc(TenantDO::getId)); // 按照 id 倒序 }} 完整实战,可见 《开发指南 —— 分页实现》 文档。 # 3.5 insertBatch #insertBatch(...) (opens new window) 方法,遍历数组,逐条插入数据库中,适合少量数据插入,或者对性能要求不高的场景。 示例如下: 为什么不使用 insertBatchSomeColumn 批量插入? 只支持 MySQL 数据库。其它 Oracle 等数据库使用会报错,可见 InsertBatchSomeColumn (opens new window) 说明。 未支持多租户。插入数据库时,多租户字段不会进行自动赋值。 # 4. 批量插入 绝大多数场景下,推荐使用 MyBatis Plus 提供的 IService 的 #saveBatch() (opens new window) 方法。示例 PermissionServiceImpl (opens new window) 如下: # 5. 条件构造器 继承 MyBatis Plus 的条件构造器,拓展了 LambdaQueryWrapperX (opens new window) 和 QueryWrapperX (opens new window) 类,主要是增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。例如说: 具体的使用示例如下: # 6. Mapper XML 默认配置下,MyBatis Mapper XML 需要写在各 yudao-module-xxx-biz 模块的 resources/mapper 目录下。示例 TestDemoMapper.xml (opens new window) 如下: 尽量避免数据库的连表(多表)查询,而是采用多次查询,Java 内存拼接的方式替代。例如说: # 7. 字段加密 EncryptTypeHandler (opens new window),基于 Hutool AES (opens new window) 实现字段的解密与解密。 例如说,数据源配置 (opens new window)的 password 密码需要实现加密存储,则只需要在该字段上添加 EncryptTypeHandler 处理器。示例代码如下: @TableName(value = "infra_data_source_config", autoResultMap = true) // ① 添加 autoResultMap = truepublic class DataSourceConfigDO extends BaseDO { // ... 省略其它字段 /** * 密码 */ @TableField(typeHandler = EncryptTypeHandler.class) // ② 添加 EncryptTypeHandler 处理器 private String password;} 另外,在 application.yaml 配置文件中,可使用 mybatis-plus.encryptor.password 设置加密密钥。 字段加密后,只允许使用精准匹配,无法使用模糊匹配。示例代码如下: @Test // 测试使用 password 查询,可以查询到数据public void testSelectPassword() { // mock 数据 DataSourceConfigDO dbDataSourceConfig = randomPojo(DataSourceConfigDO.class); dataSourceConfigMapper.insert(dbDataSourceConfig);// @Sql: 先插入出一条存在的数据 // 调用 DataSourceConfigDO result = dataSourceConfigMapper.selectOne(DataSourceConfigDO::getPassword, EncryptTypeHandler.encrypt(dbDataSourceConfig.getPassword())); // 重点:需要使用 EncryptTypeHandler 去加密查询字段!!!} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/11/12, 09:29:04 系统日志 多数据源(读写分离) ← 系统日志 多数据源(读写分离)→"},{"title":"数据权限","path":"/wiki/YuDaoBoot/后端手册/数据权限/数据权限.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 数据权限 数据权限,实现指定用户可以操作指定范围的数据。例如说,针对员工信息的数据权限: 用户 数据范围 普通员工 自己 部门领导 所属部门的所有员工 HR 小姐姐 整个公司的所有员工 上述的这个示例,使用硬编码是可以实现的,并且也非常简单。但是,在业务快速迭代的过程中,类似这种数据需求会越来越多,如果全部采用硬编码的方式,无疑会给我们带来非常大的开发与维护成本。 因此,项目提供 yudao-spring-boot-starter-biz-data-permission (opens new window) 技术组件,只需要少量的编码,无需入侵到业务代码,即可实现数据权限。 友情提示:数据权限是否支持指定用户只能查看数据的某些字段? 不支持。权限可以分成三类:功能权限、数据权限、字段权限。 字段权限的控制,不属于数据权限,而是属于字段权限,会在未来提供,敬请期待。 # 1. 实现原理 yudao-spring-boot-starter-biz-data-permission 技术组件的实现原理非常简单,每次对数据库操作时,他会自动拼接 WHERE data_column = ? 条件来进行数据的过滤。 例如说,查看员工信息的功能,对应 SQL 是 SELECT * FROM system_users,那么拼接后的 SQL 结果会是: 用户 数据范围 SQL 普通员工 自己 SELECT * FROM system_users WHERE id = 自己 部门领导 所属部门的所有员工 SELECT * FROM system_users WHERE dept_id = 自己的部门 HR 小姐姐 整个公司的所有员工 SELECT * FROM system_users 无需拼接 明白了实现原理之后,想要进一步加入理解,后续可以找时间 Debug 调试下 DataPermissionDatabaseInterceptor (opens new window) 类的这三个方法: #processSelect(...) 方法:处理 SELECT 语句的 WHERE 条件。 #processUpdate(...) 方法:处理 UPDATE 语句的 WHERE 条件。 #processDelete(...) 方法:处理 DELETE 语句的 WHERE 条件。 # 2. 基于部门的数据权限 项目内置了基于部门的数据权限,支持 5 种数据范围: 全部数据权限:无数据权限的限制。 指定部门数据权限:根据实际需要,设置可操作的部门。 本部门数据权限:只能操作用户所在的部门。 本部门及以下数据权限:在【本部门数据权限】的基础上,额外可操作子部门。 仅本人数据权限:相对特殊,只能操作自己的数据。 # 2.1 后台配置 可通过管理后台的 [系统管理 -> 角色管理] 菜单,设置用户角色的数据权限。 实现代码? 可见 DeptDataPermissionRule (opens new window) 数据权限规则。 # 2.2 字段配置 每个 Maven Module, 通过自定义 DeptDataPermissionRuleCustomizer (opens new window) Bean,配置哪些表的哪些字段,进行数据权限的过滤。以 yudao-module-system 模块来举例子,代码如下: @Configuration(proxyBeanMethods = false)public class DataPermissionConfiguration { @Bean public DeptDataPermissionRuleCustomizer sysDeptDataPermissionRuleCustomizer() { return rule -> { // dept 基于部门的数据权限 rule.addDeptColumn(AdminUserDO.class); // WHERE dept_id = ? rule.addDeptColumn(DeptDO.class, "id"); // WHERE id = ? // user 基于用户的数据权限 rule.addUserColumn(AdminUserDO.class, "id"); // WHERE id = ?// rule.addUserColumn(OrderDO.class); // WHERE user_id = ? }; }} 注意,数据库的表字段必须添加: 基于【部门】过滤数据权限的表,需要添加部门编号字段,例如说 dept_id 字段。 基于【用户】过滤数据权限的表,需要添加部门用户字段,例如说 user_id 字段。 # 3. @DataPermission 注解 @DataPermission (opens new window) 数据权限注解,可声明在类或者方法上,配置使用的数据权限规则。 ① enable 属性:当前类或方法是否开启数据权限,默认是 true 开启状态,可设置 false 禁用状态。 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 使用示例如下,可见 UserProfileController (opens new window) 类: // UserProfileController.java@GetMapping("/get")@Operation(summary = "获得登录用户信息")@DataPermission(enable = false) // 关闭数据权限,避免只查看自己时,查询不到部门。public CommonResult<UserProfileRespVO> profile() { // .. 省略代码 if (user.getDeptId() != null) { DeptDO dept = deptService.getDept(user.getDeptId()); resp.setDept(UserConvert.INSTANCE.convert02(dept)); } // .. 省略代码} ② includeRules 属性,配置生效的 DataPermissionRule (opens new window) 数据权限规则。例如说,项目里有 10 种 DataPermissionRule 规则,某个方法只想其中的 1 种生效,则可以使用该属性。 ③ excludeRules 属性,配置排除的 DataPermissionRule (opens new window) 数据权限规则。例如说,项目里有 10 种 DataPermissionRule 规则,某个方法不想其中的 1 种生效,则可以使用该属性。 # 4. 自定义的数据权限规则 如果想要自定义数据权限规则,只需要实现 DataPermissionRule (opens new window) 数据权限规则接口,并声明成 Spring Bean 即可。需要实现的只有两个方法: public interface DataPermissionRule { /** * 返回需要生效的表名数组 * 为什么需要该方法?Data Permission 数组基于 SQL 重写,通过 Where 返回只有权限的数据 * * 如果需要基于实体名获得表名,可调用 {@link TableInfoHelper#getTableInfo(Class)} 获得 * * @return 表名数组 */ Set<String> getTableNames(); /** * 根据表名和别名,生成对应的 WHERE / OR 过滤条件 * * @param tableName 表名 * @param tableAlias 别名,可能为空 * @return 过滤条件 Expression 表达式 */ Expression getExpression(String tableName, Alias tableAlias);} #getTableNames() 方法:哪些数据库表,需要使用该数据权限规则。 #getExpression(...) 方法:当操作这些数据库表,需要额外拼接怎么样的 WHERE 条件。 下面,艿艿带你写个自定义数据权限规则的示例,它的数据权限规则是: 针对 system_dict_type 表,它的创建人 creator 要是当前用户。 针对 system_post 表,它的更新人 updater 要是当前用户。 具体实现代码如下: package cn.iocoder.yudao.module.system.framework.datapermission;import cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRule;import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;import com.google.common.collect.Sets;import net.sf.jsqlparser.expression.Alias;import net.sf.jsqlparser.expression.Expression;import net.sf.jsqlparser.expression.LongValue;import net.sf.jsqlparser.expression.operators.relational.EqualsTo;import org.springframework.stereotype.Component;import java.util.Set;@Component // 声明为 Spring Bean,保证被 yudao-spring-boot-starter-biz-data-permission 组件扫描到public class DemoDataPermissionRule implements DataPermissionRule { @Override public Set<String> getTableNames() { return Sets.newHashSet("system_dict_type", "system_post"); } @Override public Expression getExpression(String tableName, Alias tableAlias) { Long userId = SecurityFrameworkUtils.getLoginUserId(); assert userId != null; switch (tableName) { case "system_dict_type": return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, "creator"), new LongValue(userId)); case "system_post": return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, "updater"), new LongValue(userId)); default: return null; } }} ① 启动前端 + 后端项目。 ② 访问 [系统管理 -> 字典管理] 菜单,查看 IDEA 控制台,可以看到 system_dict_type 表的查询自动拼接了 AND creator = 1 的查询条件。 ② 访问 [系统管理 -> 岗位管理] 菜单,查看 IDEA 控制台,可以看到 system_post 表的查询自动拼接了 AND updater = 1 的查询条件。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:40 功能权限 用户体系 ← 功能权限 用户体系→"},{"title":"数据脱敏","path":"/wiki/YuDaoBoot/后端手册/数据脱敏/数据脱敏.html","content":"开发指南后端手册 芋道源码 2023-01-21 目录 数据脱敏 接口在返回一些敏感或隐私数据时,是需要进行脱敏处理,通常的手段是使用 * 隐藏一部分数据。例如说: 类型 原始数据 脱敏数据 手机 13248765917 132****5917 身份证 530321199204074611 530321**********11 银行卡 9988002866797031 998800********31 # 1. 脱敏组件 yudao-spring-boot-starter-desensitize (opens new window) 基于 Jackson 拓展,只需要在字段上添加脱敏注解,即可实现对该字段进行脱敏。 使用步骤如下: ① 在 pom.xml 引入该依赖,如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-desensitize</artifactId></dependency> ② 在字段上添加脱敏注解。如下所示: @Datapublic static class DesensitizeDemo { @MobileDesensitize // 手机号的脱敏注解 private String phoneNumber;} # 2. 内置脱敏注解 根据不同的脱敏处理方式,项目内置了两类脱敏注解:正则脱敏、滑块脱敏。 # 2.1 regex 正则脱敏 # 2.1.1 @RegexDesensitize 注解 正则脱敏注解 @RegexDesensitize ( opens new window):根据正则表达式,将原始数据进行替换处理。 public @interface RegexDesensitize { /** * 匹配的正则表达式(默认匹配所有) */ String regex() default "^[\\\\s\\\\S]*$"; /** * 替换规则,会将匹配到的字符串全部替换成 replacer */ String replacer() default "******";} 例如说:regex=123; replacer=****** 表示将 123 替换为 ****** 原始字符串 123456789 脱敏后字符串 ******456789 # 2.1.2 其它正则脱敏注解 项目内置了其它基于正则脱敏的常用注解,无需手动填写 regex、replacer 属性,更加方便。例如说: @Datapublic static class DesensitizeDemo { @EmailDesensitize private String email;} 所有注解如下: 注解 原始数据 脱敏数据 @EmailDesensitize (opens new window) example@gmail.com e****@gmail.com # 2.2 slider 滑块脱敏 # 2.2.1 @SliderDesensitize 注解 滑块脱敏注解 @SliderDesensitize (opens new window):根据设置的左右明文字符长度,中间部分全部替换为 *。 例如说:prefixKeep=3; suffixKeep=4; replacer=* 表示前 3 后 4 保持明文,中间都替换成 * 原始字符串 13248765917 脱敏后字符串 132****5917 # 2.2.2 其它滑块脱敏注解 项目内置了其它基于滑块脱敏的常用注解,无需手动填写 prefixKeep、suffixKeep、replacer 属性,更加方便。例如说: @Datapublic static class DesensitizeDemo { @MobileDesensitize private String mobile;} 所有注解如下: 注解 原始数据 脱敏数据 @MobileDesensitize (opens new window) 13248765917 132****5917 @FixedPhoneDesensitize (opens new window) 01086551122 0108*****22 @BankCardDesensitize (opens new window) 9988002866797031 998800********31 @PasswordDesensitize (opens new window) 123456 ****** @CarLicenseDesensitize (opens new window) 粤A66666 粤A6***6 @ChineseNameDesensitize (opens new window) 刘子豪 刘** @IdCardDesensitize (opens new window) 530321199204074611 530321**********11 # 3. 自定义脱敏注解 如果内置的注解无法满足你的需求,只需要自定义一个脱敏注解,并实现它的脱敏处理器即可。 例如说,我们要实现一个新的脱敏处理方法,将编号使用 MD5 或 SHA256 计算后返回。步骤如下: ① 创建 @DigestDesensitize 注解,使用 @DesensitizeBy (opens new window) 标记它使用的处理器。代码如下: import cn.iocoder.yudao.framework.desensitize.core.base.annotation.DesensitizeBy;import cn.iocoder.yudao.framework.desensitize.core.handler.DigestHandler;import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;import java.lang.annotation.*;@Documented@Target({ElementType.FIELD})@Retention(RetentionPolicy.RUNTIME)@JacksonAnnotationsInside@DesensitizeBy(handler = DigestHandler.class) // 使用 @DesensitizeBy 设置它的处理器public @interface DigestDesensitize { /** * 摘要算法,例如说:MD5、SHA256 */ String algorithm() default "md5";} ② 创建 DigestHandler 类,实现 DigestHandler (opens new window) 接口,将编号使用 MD5 或 SHA256 处理。代码如下: import cn.hutool.crypto.digest.DigestUtil;import cn.iocoder.yudao.framework.desensitize.core.annotation.DigestDesensitize;import cn.iocoder.yudao.framework.desensitize.core.base.handler.DesensitizationHandler;public class DigestHandler implements DesensitizationHandler<DigestDesensitize> { @Override public String desensitize(String origin, DigestDesensitize annotation) { String algorithm = annotation.algorithm(); return DigestUtil.digester(algorithm).digestHex(origin); }} 友情提示: ① 如果自定义的是基于正则脱敏的注解,可选择继承 AbstractRegexDesensitizationHandler (opens new window) 处理器。 ① 如果自定义的是基于滑块脱敏的注解,可选择继承 AbstractSliderDesensitizationHandler (opens new window) 处理器。 ③ 在需要使用的字段上,添加 @DigestDesensitize 注解。示例代码如下: @Datapublic static class DesensitizeDemo { @DigestDesensitize private String email;} 完事~ # 4. 脱敏工具类 Hutool 提供了 DesensitizedUtil (opens new window) 脱敏工具类,支持用户 ID、 中文名、身份证、座机号、手机号、 地址、电子邮件、 密码、车牌、银行卡号的脱敏处理。 使用方式,代码如下: DesensitizedUtil.desensitized("100", DesensitizedUtils.DesensitizedType.USER_ID)) = "0"DesensitizedUtil.desensitized("段正淳", DesensitizedUtils.DesensitizedType.CHINESE_NAME)) = "段**"DesensitizedUtil.desensitized("51343620000320711X", DesensitizedUtils.DesensitizedType.ID_CARD)) = "5***************1X"DesensitizedUtil.desensitized("09157518479", DesensitizedUtils.DesensitizedType.FIXED_PHONE)) = "0915*****79"DesensitizedUtil.desensitized("18049531999", DesensitizedUtils.DesensitizedType.MOBILE_PHONE)) = "180****1999"DesensitizedUtil.desensitized("北京市海淀区马连洼街道289号", DesensitizedUtils.DesensitizedType.ADDRESS)) = "北京市海淀区马********"DesensitizedUtil.desensitized("duandazhi-jack@gmail.com.cn", DesensitizedUtils.DesensitizedType.EMAIL)) = "d*************@gmail.com.cn"DesensitizedUtil.desensitized("1234567890", DesensitizedUtils.DesensitizedType.PASSWORD)) = "**********"DesensitizedUtil.desensitized("苏D40000", DesensitizedUtils.DesensitizedType.CAR_LICENSE)) = "苏D4***0"DesensitizedUtil.desensitized("11011111222233333256", DesensitizedUtils.DesensitizedType.BANK_CARD)) = "1101 **** **** **** 3256" 适合场景,逻辑里需要直接对某个变量进行脱敏处理,然后打印 logger 日志,或者存储到数据库中。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/26, 21:09:52 站内信配置 敏感词 ← 站内信配置 敏感词→"},{"title":"新建模块","path":"/wiki/YuDaoBoot/后端手册/新建模块/新建模块.html","content":"开发指南后端手册 芋道源码 2022-03-02 目录 新建模块 本章节,将介绍如何新建名字为 yudao-module-demo 的示例模块,并添加 RESTful API 接口。 虽然内容看起来比较长,是因为艿艿写的比较详细,大量截图,保姆级教程!其实只有五个步骤,保持耐心,跟着艿艿一点点来。🙂 完成之后,你会对整个 项目结构 有更充分的了解。 # 👍 相关视频教程 从零开始 06:如何 5 分钟,创建一个新模块? (opens new window) # 1. 新建 demo 模块 ① 选择 File -> New -> Module 菜单,如下图所示: ② 选择 Maven 类型,并点击 Next 按钮,如下图所示: ③ 选择父模块为 yudao,输入名字为 yudao-module-demo,并点击 Finish 按钮,如下图所示: ④ 打开 yudao-module-demo 模块,删除 src 文件,如下图所示: ⑤ 打开 yudao-module-demo 模块的 pom.xml 文件,修改内容如下: 提示 <!-- --> 部分,只是注释,不需要写到 XML 中。 <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao</artifactId> <groupId>cn.iocoder.boot</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>yudao-module-demo</artifactId> <packaging>pom</packaging> <!-- 2. 新增 packaging 为 pom --> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块,主要实现 XXX、YYY、ZZZ 等功能。 </description></project> # 2. 新建 demo-api 子模块 ① 新建 yudao-module-demo-api 子模块,整个过程和“新建 demo 模块”是一致的,如下图所示: ② 打开 yudao-module-demo-api 模块的 pom.xml 文件,修改内容如下: <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao-module-demo</artifactId> <groupId>cn.iocoder.boot</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>yudao-module-demo-api</artifactId> <packaging>jar</packaging> <!-- 2. 新增 packaging 为 jar --> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块 API,暴露给其它模块调用 </description> <dependencies> <!-- 5. 新增 yudao-common 依赖 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-common</artifactId> </dependency> </dependencies></project> ③ 【可选】新建 cn.iocoder.yudao.module.demo 基础包,其中 demo 为模块名。之后,新建 api 和 enums 包。如下图所示: # 3. 新建 demo-biz 子模块 ① 新建 yudao-module-demo-biz 子模块,整个过程和“新建 demo 模块”也是一致的,如下图所示: ② 打开 yudao-module-demo-biz 模块的 pom.xml 文件,修改成内容如下: <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao-module-demo</artifactId> <groupId>cn.iocoder.boot</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <packaging>jar</packaging> <!-- 2. 新增 packaging 为 jar --> <artifactId>yudao-module-demo-biz</artifactId> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块,主要实现 XXX、YYY、ZZZ 等功能。 </description> <dependencies> <!-- 5. 新增依赖,这里引入的都是比较常用的业务组件、技术组件 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-demo-api</artifactId> <version>${revision}</version> </dependency> <!-- 业务组件 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-biz-operatelog</artifactId> </dependency> <!-- Web 相关 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-security</artifactId> </dependency> <!-- DB 相关 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-mybatis</artifactId> </dependency> <!-- Test 测试相关 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-test</artifactId> </dependency> </dependencies></project> ③ 【必选】新建 cn.iocoder.yudao.module.demo 基础包,其中 demo 为模块名。之后,新建 controller.admin 和 controller.user 等包。如下图所示: ④ 打开 Maven 菜单,点击刷新按钮,让引入的 Maven 依赖生效。如下图所示: # 4. 新建 RESTful API 接口 ① 在 controller.admin 包,新建一个 DemoTestController 类,并新建一个 /demo/test/get 接口。代码如下: package cn.iocoder.yudao.module.demo.controller.admin;import cn.iocoder.yudao.framework.common.pojo.CommonResult;import io.swagger.annotations.Api;import io.swagger.annotations.ApiOperation;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;@Tag(name = "管理后台 - Test")@RestController@RequestMapping("/demo/test")@Validatedpublic class DemoTestController { @GetMapping("/get") @Operation(summary = "获取 test 信息") public CommonResult<String> get() { return success("true"); }} 注意,/demo 是该模块所有 RESTful API 的基础路径,/test 是 Test 功能的基础路径。 ① 在 controller.app 包,新建一个 AppDemoTestController 类,并新建一个 /demo/test/get 接口。代码如下: package cn.iocoder.yudao.module.demo.controller.app;import cn.iocoder.yudao.framework.common.pojo.CommonResult;import io.swagger.annotations.Api;import io.swagger.annotations.ApiOperation;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;@Tag(name = "用户 App - Test")@RestController@RequestMapping("/demo/test")@Validatedpublic class AppDemoTestController { @GetMapping("/get") @Operation(summary = "获取 test 信息") public CommonResult<String> get() { return success("true"); }} 在 Controller 的命名上,额外增加 App 作为前缀,一方面区分是管理后台还是用户 App 的 Controller,另一方面避免 Spring Bean 的名字冲突。 可能你会奇怪,这里我们定义了两个 /demo/test/get 接口,会不会存在重复导致冲突呢?答案,当然是并不会。原因是: controller.admin 包下的接口,默认会增加 /admin-api,即最终的访问地址是 /admin-api/demo/test/get controller.app 包下的接口,默认会增加 /app-api,即最终的访问地址是 /app-api/demo/test/get # 5. 引入 demo 模块 ① 在 yudao-server 模块的 pom.xml 文件,引入 yudao-module-demo-biz 子模块,并点击 Maven 刷新。如下图所示: ② 运行 YudaoServerApplication 类,将后端项目进行启动。启动完成后,使用浏览器打开 http://127.0.0.1:48080/doc.html (opens new window) 地址,进入 Swagger 接口文档。 ③ 打开“管理后台 - Test”接口,进行 /admin-api/demo/test/get 接口的调试,如下图所示: ④ 打开“用户 App - Test”接口,进行 /app-api/demo/test/get 接口的调试,如下图所示: # 6. 访问接口返回 404? 请检查,你新建的模块的 package 包名是不是在 cn.iocoder.yudao.module 下! 如果不是,修改 YudaoServerApplication (opens new window) 类,增加新建的模块的 package 包名。例如说: @SpringBootApplication(scanBasePackages = {"${yudao.info.base-package}.server", "${yudao.info.base-package}.module", "xxx.yyy.zzz"}) // xxx.yyy.zzz 是你新建的模块的 `package` 包名 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:40 删除功能 代码生成(新增功能) ← 删除功能 代码生成(新增功能)→"},{"title":"文件存储(上传下载)","path":"/wiki/YuDaoBoot/后端手册/文件存储(上传下载)/文件存储(上传下载).html","content":"开发指南后端手册 芋道源码 2022-03-17 目录 文件存储(上传下载) 项目支持将文件上传到三类存储器: 兼容 S3 协议的对象存储:支持 MinIO、腾讯云 COS、七牛云 Kodo、华为云 OBS、亚马逊 S3 等等。 磁盘存储:本地、FTP 服务器、SFTP 服务器。 数据库存储:MySQL、Oracle、PostgreSQL、SQL Server 等等。 技术选型? 优先,✔ 推荐方案 1。如果无法使用云服务,可以自己搭建一个 MinIO 服务。参见 《芋道 Spring Boot 对象存储 MinIO 入门 》 (opens new window) 文章。 其次,推荐方案 3。数据库的主从机制可以实现高可用,备份也方便,少量小文件问题不大。 最后,× 不推荐方案 2。主要是实现高可用比较困难,无法实现故障转移。 # 1. 快速入门 本小节,我们来添加个文件配置,并使用它上传下载文件。 # 1.1 新增配置 ① 打开 [基础设施 -> 文件管理 -> 文件配置] 菜单,进入文件配置的界面。 ② 点击 [新增] 按钮,选择存储器为【S3 对象存储器】,并填写七牛云的配置。如下图: 节点地址:s3-cn-south-1.qiniucs.com 存储 bucket:ruoyi-vue-pro accessKey:b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8 accessSecret:kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP 自定义域名:http://test.yudao.iocoder.cn 友善的眼神! 上述七牛云的配置,是艿艿为了大家方便体验,请勿在测试或生产环境体验。 ③ 添加完后,点击该配置所在行的 [测试] 按钮,测试配置是否正确。 ④ 测试通过后,点击该配置所在行的 [主配置] 按钮,设置它为默认的配置,后续使用它进行文件的上传。 # 1.2 上传文件 ① 点击 [基础设施 -> 文件管理 -> 文件列表] 菜单,进入文件列表的界面。 ② 点击 [上传文件] 按钮,选择要上传的文件。 ③ 上传完成后,如果想要删除,可点击该文件所在行的 [删除] 按钮。 # 2. 文件上传 项目提供了 2 种文件上传的方式,分别适合前端、后端使用。 # 2.1 方式一:前端上传 FileController (opens new window) 提供了 /admin-api/infra/file/upload RESTful API,用于前端直接上传文件。 // FileController.java@PostMapping("/upload")@Operation(summary = "上传文件")@OperateLog(logArgs = false) // 上传文件,没有记录操作日志的必要public CommonResult<String> uploadFile(FileUploadReqVO uploadReqVO) throws Exception { MultipartFile file = uploadReqVO.getFile(); String path = uploadReqVO.getPath(); return success(fileService.createFile(file.getOriginalFilename(), path, IoUtil.readBytes(file.getInputStream())));} 前端上传文件的代码如何实现,可见: 文件列表,文件上传 index.vue (opens new window) 个人中心,头像修改 userAvatar.vue (opens new window) # 2.2 方式二:后端上传 yudao-module-infra 的 FileApi (opens new window) 提供了 #createFile(...) 方法,用于后端需要上传文件的逻辑。 // FileApi.java/** * 保存文件,并返回文件的访问路径 * * @param path 文件路径 * @param content 文件内容 * @return 文件路径 */String createFile(String path, byte[] content); 例如说,个人中心修改头像时,需要进行头像的上传。如下图所示: 注意,需要使用到后端上传的 Maven 模块,需要引入 yudao-module-infra-api 依赖。例如说 yudao-module-system-biz 模块的 pom.xml 文件,引用如下: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-infra-api</artifactId> <version>${revision}</version></dependency> # 3. 文件下载 文件上传成功后,返回的是完整的 URL 访问路径 ,例如说 http://test.yudao.iocoder.cn/822aebded6e6414e912534c6091771a4.jpg ( opens new window) 。 不同的文件存储器,返回的 URL 路径的规则是不同的: ① 当存储器是【S3 对象存储】时,支持 HTTP 访问,所以直接使用 S3 对象存储返回的 URL 路径即可。 ② 当存储器是【数据库】【本地磁盘】等时,它们只支持存储,所以需要 FileController ( opens new window) 提供的 /admin-api/infra/file/{configId}/get/{path} RESTful API,读取文件内容后返回。 // FileController.java@GetMapping("/{configId}/get/**")@PermitAll@Operation(summary = "下载文件")@Parameter(name = "configId", description = "配置编号", required = true)public void getFileContent(HttpServletRequest request, HttpServletResponse response, @PathVariable("configId") Long configId) throws Exception { // 获取请求的路径 String path = StrUtil.subAfter(request.getRequestURI(), "/get/", false); if (StrUtil.isEmpty(path)) { throw new IllegalArgumentException("结尾的 path 路径必须传递"); } // 读取内容 byte[] content = fileService.getFileContent(configId, path); if (content == null) { log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path); response.setStatus(HttpStatus.NOT_FOUND.value()); return; } ServletUtils.writeAttachment(response, path, content);} # 4. 文件客户端 技术组件 yudao-spring-boot-starter-file ( opens new window) ,定义了 FileClient ( opens new window) 接口,抽象了文件客户端的方法。 public interface FileClient { /** * 获得客户端编号 * * @return 客户端编号 */ Long getId(); /** * 上传文件 * * @param content 文件流 * @param path 相对路径 * @return 完整路径,即 HTTP 访问地址 */ String upload(byte[] content, String path); /** * 删除文件 * * @param path 相对路径 */ void delete(String path); /** * 获得文件的内容 * * @param path 相对路径 * @return 文件的内容 */ byte[] getContent(String path);} FileClient 有 5 个实现类,使用不同存储器进行文件的上传与下载。UML 类图如所示: 文件上传的调用的 UML 时序图如下所示: # 5. S3 对象存储的配置 做的不错的云存储服务,都是兼容 S3 协议的。如何获取对应的 S3 配置,艿艿整理到了 S3FileClientConfig (opens new window) 配置类。 有一点要注意,云存储服务的 Bucket 需要设置为公共读,不然 URL 无法访问到文件。 并且,最好使用自定义域名,方便迁移到不同的云存储服务。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:51:54 分页实现 Excel 导入导出 ← 分页实现 Excel 导入导出→"},{"title":"本地缓存","path":"/wiki/YuDaoBoot/后端手册/本地缓存/本地缓存.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 本地缓存 重要说明: ① 由于大家普遍反馈,“本地缓存”学习成本太高,一般 Redis 缓存足够满足大多数场景的性能要求,所以基本使用 Spring Cache + Redis 所替代。 也因此,本章节更多的,是讲解如何在项目中使用本地缓存。如果你不需要本地缓存,可以忽略本章节。 ② 项目中还保留了部分地方使用本地缓存,例如说:短信客户端、文件客户端、敏感词等。主要原因是,它们是“有状态”的 Java 对象,无法缓存到 Redis 中。 系统使用本地缓存,提升公用逻辑的执行性能。 例如说: 租户模块 (opens new window) 缓存租户信息,每次 RESTful API 校验租户是否禁用、过期时,无需读库。 部门模块 (opens new window) 缓存部门信息,每次数据权限校验时,无需读库。 权限模块 (opens new window) 缓存权限信息,每次功能权限校验时,无需读库。 # 1. 实现原理 本地缓存的实现,一共有两步,如下图所示: 项目启动时,初始化缓存:从数据库中读取数据,写入到本地缓存(例如说一个 Map 对象) 数据变化时,实时刷新缓存:(例如说通过管理后台修改数据)重新从数据库中读取数据,重新写入到本地缓存 # 2. 实战案例 以 角色模块 (opens new window) 为例,讲解如何实现角色信息的本地缓存。 # 2.1 初始化缓存 ① 在 RoleService (opens new window) 接口中,定义 #initLocalCache() 方法。代码如下: // RoleService.java/** * 初始化角色的本地缓存 */void initLocalCache(); 为什么要定义接口方法? 稍后实时刷新缓存时,会调用 RoleService 接口的该方法。 ② 在 RoleServiceImpl (opens new window) 类中,实现 #initLocalCache() 方法,通过 @PostConstruct 注解,在项目启动时进行本地缓存的初始化。代码如下: // RoleServiceImpl.java/** * 角色缓存 * key:角色编号 {@link RoleDO#getId()} * * 这里声明 volatile 修饰的原因是,每次刷新时,直接修改指向 */@Getterprivate volatile Map<Long, RoleDO> roleCache;/** * 初始化 {@link #roleCache} 缓存 */@Override@PostConstructpublic void initLocalCache() { // 注意:忽略自动多租户,因为要全局初始化缓存 TenantUtils.executeIgnore(() -> { // 第一步:查询数据 List<RoleDO> roleList = roleMapper.selectList(); log.info("[initLocalCache][缓存角色,数量为:{}]", roleList.size()); // 第二步:构建缓存 roleCache = CollectionUtils.convertMap(roleList, RoleDO::getId); });} 疑问:为什么使用 TenantUtils 的 executeIgnore 方法来执行逻辑? 由于 RoleDO 是多租户隔离,如果使用 TenantUtils 方法,会导致缓存刷新时,只加载某个租户的角色数据,导致本地缓存的错误。 所以,如果缓存的数据不存在多租户隔离的情况,可以不使用 TenantUtils 方法!!!! # 2.2 实时刷新缓存 为什么需要使用 Redis Pub/Sub 来实时刷新缓存?考虑到高可用,线上会部署多个 JVM 实例,需要通过 Redis 广播到所有实例,实现本地缓存的刷新。 友情提示: Redis Pub/Sub 的使用与讲解,可见 《开发指南 —— 消息队列》 文档。 # 2.2.1 RoleRefreshMessage 新建 RoleRefreshMessage (opens new window) 类,角色数据刷新 Message。代码如下: @Data@EqualsAndHashCode(callSuper = true)public class RoleRefreshMessage extends AbstractChannelMessage { @Override public String getChannel() { return "system.role.refresh"; }} # 2.2.2 RoleProducer ① 新建 RoleProducer ( opens new window) 类,RoleRefreshMessage 的 Producer 生产者。代码如下: @Componentpublic class RoleProducer { @Resource private RedisMQTemplate redisMQTemplate; /** * 发送 {@link RoleRefreshMessage} 消息 */ public void sendRoleRefreshMessage() { RoleRefreshMessage message = new RoleRefreshMessage(); redisMQTemplate.send(message); }} ② 在数据的新增 / 修改 / 删除等写入操作时,需要使用 RoleProducer 发送消息。如下图所示: # 2.2.3 RoleRefreshConsumer 新建 RoleRefreshConsumer (opens new window) 类,RoleRefreshMessage 的 Consumer 消费者,刷新本地缓存。代码如下: @Component@Slf4jpublic class RoleRefreshConsumer extends AbstractChannelMessageListener<RoleRefreshMessage> { @Resource private RoleService roleService; @Override public void onMessage(RoleRefreshMessage message) { log.info("[onMessage][收到 Role 刷新消息]"); roleService.initLocalCache(); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/03, 22:14:22 Redis 缓存 定时任务 ← Redis 缓存 定时任务→"},{"title":"消息队列","path":"/wiki/YuDaoBoot/后端手册/消息队列/消息队列.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 消息队列 yudao-spring-boot-starter-mq (opens new window) 技术组件,基于 Redis 实现分布式消息队列: 使用 Stream (opens new window) 特性,提供【集群】消费的能力。 使用 Pub/Sub (opens new window) 特性,提供【广播】消费的能力。 友情提示: 考虑到有部分同学对分布式消息队列了解的不多,所以在下文的广播消费、集群消费的描述,去除【消费者分组】的概念。如果你对这块感兴趣,可以看看艿艿写的系列文章: 《芋道 Spring Boot 消息队列 RocketMQ 入门》 (opens new window) 对应 lab-31 (opens new window) 《芋道 Spring Boot 消息队列 Kafka 入门》 (opens new window) 对应 lab-03-kafka (opens new window) 《芋道 Spring Boot 消息队列 RabbitMQ 入门》 (opens new window) 对应 lab-04-rabbitmq (opens new window) 《芋道 Spring Boot 消息队列 ActiveMQ 入门》 (opens new window) 对应 lab-32 (opens new window) # 1. 集群消费 集群消费,是指消息发送到 Redis 时,有且只会被一个消费者(应用 JVM 实例)收到,然后消费成功。如下图所示: # 1.1 使用场景 集群消费在项目中的使用场景,主要是提供可靠的、可堆积的异步任务的能力。例如说: 短信模块,使用它异步 (opens new window)发送短信。 邮件模块,使用它异步 (opens new window)发送邮件。 相比 《开发指南 —— 异步任务》 来说,Spring Async 在 JVM 实例重启时,会导致未执行完的任务丢失。而集群消费,因为消息是存储在 Redis 中,所以不会存在该问题。 # 1.2 实现源码 集群消费基于 Redis Stream 实现: 实现 AbstractStreamMessage (opens new window) 抽象类,定义【集群】消息。 使用 RedisMQTemplate (opens new window) 的 #send(message) (opens new window) 方法,发送消息。 实现 AbstractStreamMessageListener (opens new window) 接口,消费消息。 最终使用 YudaoMQAutoConfiguration (opens new window) 配置类,扫描所有的 AbstractStreamMessageListener 监听器,初始化对应的消费者。如下图所示: # 1.3 实战案例 以短信模块异步发送短息为例子,讲解集群消费的使用。 # 1.3.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-mq 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-mq</artifactId></dependency> # 1.3.2 SmsSendMessage 在 yudao-module-system-biz 的 mq/message/sms ( opens new window) 包下,创建 SmsSendMessage ( opens new window) 类,继承 AbstractStreamMessage 抽象类,短信发送消息。代码如下图: # 1.3.3 SmsProducer ① 在 yudao-module-system-biz 的 mq/producer/sms ( opens new window) 包下,创建 SmsProducer ( opens new window) 类,SmsSendMessage 的 Producer 生产者,核心是使用 RedisMQTemplate 发送 SmsSendMessage 消息。代码如下图: ② 发送短信时,需要使用 SmsProducer 发送消息。如下图所示: # 1.3.4 SmsSendConsumer 在 yudao-module-system-biz 的 mq/consumer/sms ( opens new window) 包下,创建 SmsSendConsumer ( opens new window) 类,SmsSendMessage 的 Consumer 消费者。代码如下图: # 2. 广播消费 广播消费,是指消息发送到 Redis 时,所有消费者(应用 JVM 实例)收到,然后消费成功。如下图所示: # 2.1 使用场景 例如说,在应用中,缓存了数据字典等配置表在内存中,可以通过 Redis 广播消费,实现每个应用节点都消费消息,刷新本地内存的缓存。 又例如说,我们基于 WebSocket 实现了 IM 聊天,在我们给用户主动发送消息时,因为我们不知道用户连接的是哪个提供 WebSocket 的应用,所以可以通过 Redis 广播消费。每个应用判断当前用户是否是和自己提供的 WebSocket 服务连接,如果是,则推送消息给用户。 # 2.2 实现源码 广播消费基于 Redis Pub/Sub 实现: 实现 AbstractChannelMessage ( opens new window) 抽象类,定义【广播】消息。 使用 RedisMQTemplate ( opens new window) 的 #send( message) ( opens new window) 方法,发送消息。 实现 AbstractChannelMessageListener ( opens new window) 接口,消费消息。 最终使用 YudaoMQAutoConfiguration ( opens new window) 配置类,扫描所有的 AbstractChannelMessageListener 监听器,初始化对应的消费者。如下图所示: # 2.3 实战案例 参见 《开发指南 —— 本地缓存》 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 异步任务 配置管理 ← 异步任务 配置管理→"},{"title":"短信配置","path":"/wiki/YuDaoBoot/后端手册/短信配置/短信配置.html","content":"开发指南后端手册 芋道源码 2022-04-10 目录 短信配置 本章节,介绍项目的短信功能。该功能提供统一的短信 API 给其它模块,使它们可以快速接入短信功能,无需关心不同短信平台的具体对接。 短信采用异步发送,基于 Redis 消息队列,如下图所示: yudao-spring-boot-starter-biz-sms (opens new window) 业务组件:封装不同短信平台的客户端。 yudao-module-system 的 sms (opens new window) 业务模块,提供短信渠道、模板的配置,短信日志的查看,短信的发送等功能。 # 1. 表结构 # 2. 短信配置 本小节,讲解如何配置短信功能,整个过程如下: 新建一个短信【渠道】,配置对应短信平台的账号 新建一个短信【模版】,配置对应短信平台的模板 测试该短信模板,查看对应的短信【日志】,确认是否发送成功 # 2.1 新建短信渠道 ① 点击 [系统管理 -> 短信管理 -> 短信渠道] 菜单,查看短信渠道的列表。如下图所示: ② 点击 [新增] 按钮,选择渠道编码为【调试(钉钉)】,并填写信息如下图: 短信 API 的账号: 696b5d8ead48071237e4aa5861ff08dbadb2b4ded1c688a7b7c9afc615579859短信 API 的密钥: SEC5c4e5ff888bc8a9923ae47f59e7ccd30af1f14d93c55b4e2c9cb094e35aeed67 疑问 1:为什么选择渠道编码为【调试(钉钉)】? 该类型使用钉钉机器人来模拟短信发送,用于日常调试。 短信 API 的账号,对应机器人的 Webhook 的 access_token 参数 短信 API 的密钥,对应机器人的安全设置的加签 上图使用的配置,是艿艿自己的钉钉机器人。正式使用时,必须参考 《钉钉开放平台 —— 自定义机器人接入 》 (opens new window) 文档,申请自己的专属机器人。 疑问 2:可以选择其它渠道编码吗? 当然可以,这里主要考虑部分同学暂时没有申请短信平台,所以使用【调试(钉钉)】渠道编码。 不同短信平台的配置,可见 「6. 短信平台附录」 小节。 # 2.2 新建短信模板 ① 点击 [系统管理 -> 短信管理 -> 短信模板] 菜单,查看短信模板的列表。如下图所示: ② 点击 [新增] 按钮,选择刚创建的短信渠道,并填写信息如下图: 短信渠道编号:发送该短信模板时,使用的短信渠道,即使用哪个短信平台进行发送 模板编号:短信模板的唯一标识,使用短信 API 时,通过它标识使用的短信模板 模板内容:短信模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 短信 API 模板编号:短信平台的短信模板的编号,需要保证该模板在短信平台已经审核通过 开启状态:短信模板被禁用时,该短信模板将不发送短信,只记录短信日志 疑问:为什么设计短信模板的功能? 在一些场景下,需要修改短信模板所使用的短信平台。例如说:短信平台出现故障,或者切换短信平台等等。 此时,只需要修改短信模板的两个属性:短信渠道编号、短信 API 模板编号,无需重启应用。 # 2.3 查看短信日志 ① 使用钉钉,扫码 图片 加入机器人所在的【ruoyi-vue-pro 短信测试群】,查看测试短信的模拟发送。 ② 点击 [测试] 按钮,输入任一手机号,进行该短信模板的模拟发送。如下图所示: 友情提示:如果使用的短信渠道是阿里云、腾讯云等正式的短信平台,则会发送到填写的手机号中。例如说: ③ 点击 [系统管理 -> 短信管理 -> 短信日志] 采单,可以查看到每条短信的发送状态、接收状态。如下图所示: # 3. 短信发送 # 3.1 SmsSendApi 使用 SmsSendApi (opens new window) 进行短信的发送,支持多种用户类型。它的方法如下: # 3.2 实战案例 以工作流申请通过时,发送短信为例子,讲解 SmsSendApi 的使用。 ① 引入 yudao-module-system-api 依赖,如下图所示: ② 新建对应的短信模板,如下图所示: ③ 使用 Spring 注入 SmsSendApi Bean,调用对应的短信发送方法。如下图所示: # 4. 验证码发送 # 4.1 SmsCodeApi 使用 SmsCodeApi (opens new window) 进行【验证码】短信的发送,例如说:用户手机验证码登录、用户忘记密码等等。它的方法如下: 验证码使用 system_sms_code (opens new window) 表进行存储,默认每天最多发送 10 条,每分钟发送 1 条,有效期为 10 分钟,可通过 yudao.sms-code 配置项进行自定义: # 4.2 实战案例 以会员用户手机验证码登录为例子,讲解 SmsCodeApi 的使用。 ① 引入 yudao-module-system-api 依赖,如下图所示: ② 新建对应的短信模板,如下图所示: ③ 在 SmsSceneEnum (opens new window) 中,枚举会员用户的手机号登录的场景,如下图所示: ④ 使用 Spring 注入 SmsCodeApi Bean,调用对应的短信验证码的发送与使用方法。如下图所示: # 5. 短信客户端 yudao-spring-boot-starter-biz-sms (opens new window) 业务组件,对接阿里云、腾讯云等短信平台,提供统一的短信客户端,提供给 yudao-module-system 的 sms (opens new window) 业务模块来调用。 # 5.1 SmsClient SmsClient (opens new window) 接口,定义短信客户端的方法。代码如下: 每个短信平台,都对应一个 SmsClient 实现类。 # 5.2 SmsCodeMapping SmsCodeMapping (opens new window) 接口,定义短信平台错误码转换成 标准错误码 (opens new window) 的方法。代码如下: 每个短信平台,都对应一个 SmsCodeMapping 实现类。 # 5.3 对接其它短信平台 如果你想要对接其它短信平台,自定义一个 SmsClient + SmsCodeMapping 实现类,并使用 SmsClientFactoryImpl (opens new window) 进行创建。代码如下: # 6. 短信平台附录 一般情况下,建议接入 2-3 个短信平台,避免某个短信平台故障时,影响业务的正常运行。 例如说,手机验证码的短信平台 A 故障时,赶紧将短信验证码切换到短信平台 B 上,否则用户将无法正常登录或是注册。 # 6.1 阿里云 短信 API 的账号、密钥,可通过 阿里云 —— AccessKey (opens new window) 获取。 短信发送回调 URL,可通过 阿里云 —— 短信服务 —— 通用设置 (opens new window) 配置。 # 6.2 腾讯云 短信 API 的账号、密钥,可通过 腾讯云 —— API 密钥管理 (opens new window) 获取。 注意!!! 腾讯云需要额外使用 SDKAppID (opens new window) 参数,它的账号需要采用 SDKAppID secretId 格式,具体可见 TencentSmsChannelProperties (opens new window) 类。 短信发送回调 URL,可通过 腾讯云 —— 短信 —— 基础配置 (opens new window) 配置。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/28, 22:55:26 数据库文档 邮件配置 ← 数据库文档 邮件配置→"},{"title":"用户体系","path":"/wiki/YuDaoBoot/后端手册/用户体系/用户体系.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 用户体系 系统提供了 2 种类型的用户,分别满足对应的管理后台、用户 App 场景。 AdminUser 管理员用户,前端访问 yudao-ui-admin (opens new window) 管理后台,后端访问 /admin-api/** RESTful API 接口。 MemberUser 会员用户,前端访问 yudao-ui-user (opens new window) 用户 App,后端访问 /app-api/** RESTful API 接口。 虽然是不同类型的用户,他们访问 RESTful API 接口时,都通过 Token 认证机制,具体可见 《开发指南 —— 功能权限》。 # 1. 表结构 2 种类型的时候,采用不同数据库的表进行存储,管理员用户对应 system_users (opens new window) 表,会员用户对应 member_user (opens new window) 表。如下图所示: 为什么不使用统一的用户表? 确实可以采用这样的方案,新增 type 字段区分用户类型。不同用户类型的信息字段,例如说上图的 dept_id、post_ids 等等,可以增加拓展表,或者就干脆“冗余”在用户表中。 不过实际项目中,不同类型的用户往往是不同的团队维护,并且这也是绝大多团队的实践,所以我们采用了多个用户表的方案。 如果表需要关联多种类型的用户,例如说上述的 system_oauth2_access_token 访问令牌表,可以通过 user_type 字段进行区分。并且 user_type 对应 UserTypeEnum (opens new window) 全局枚举,代码如下: # 2. 如何获取当前登录的用户? 使用 SecurityFrameworkUtils (opens new window) 提供的如下方法,可以获得当前登录用户的信息: /** * 【最常用】获得当前用户的编号,从上下文中 * * @return 用户编号 */@Nullablepublic static Long getLoginUserId() { /** 省略实现 */ }/** * 获取当前用户 * * @return 当前用户 */@Nullablepublic static LoginUser getLoginUser() { /** 省略实现 */ }/** * 获得当前用户的角色编号数组 * * @return 角色编号数组 */@Nullablepublic static Set<Long> getLoginUserRoleIds() { /** 省略实现 */ } # 3. 账号密码登录 # 3.1 管理后台的实现 使用 username 账号 + password 密码进行登录,由 AuthController ( opens new window) 提供 /admin-api/system/auth/login 接口。代码如下: @PostMapping("/login")@Operation(summary = "使用账号密码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) { String token = authService.login(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} 如何关闭验证码? 参见 《后端手册 —— 验证码》 文档。 # 3.2 用户 App 的实现 使用 mobile 手机 + password 密码进行登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/login 接口。代码如下: @PostMapping("/login")@Operation(summary = "使用手机 + 密码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> login(@RequestBody @Valid AppAuthLoginReqVO reqVO) { String token = authService.login(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AppAuthLoginRespVO.builder().token(token).build());} # 4. 手机验证码登录 # 4.1 管理后台的实现 ① 使用 mobile 手机号获得验证码,由 AuthController ( opens new window) 提供 /admin-api/system/auth/send-sms-code 接口。代码如下: @PostMapping("/send-sms-code")@Operation(summary = "发送手机验证码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<Boolean> sendSmsCode(@RequestBody @Valid AuthSendSmsReqVO reqVO) { authService.sendSmsCode(getLoginUserId(), reqVO); return success(true);} ② 使用 mobile 手机 + code 验证码进行登录,由 AppAuthController (opens new window) 提供 /admin-api/system/auth/sms-login 接口。代码如下: @PostMapping("/sms-login")@Operation(summary = "使用短信验证码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) { String token = authService.smsLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} # 4.2 用户 App 的实现 ① 使用 mobile 手机号获得验证码,由 AppAuthController ( opens new window) 提供 /app-api/member/auth/send-sms-code 接口。代码如下: @PostMapping("/send-sms-code")@Operation(summary = "发送手机验证码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<Boolean> sendSmsCode(@RequestBody @Valid AppAuthSendSmsReqVO reqVO) { authService.sendSmsCode(getLoginUserId(), reqVO); return success(true);} ② 使用 mobile 手机 + code 验证码进行登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/sms-login 接口。代码如下: @PostMapping("/sms-login")@Operation(summary = "使用手机 + 验证码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> smsLogin(@RequestBody @Valid AppAuthSmsLoginReqVO reqVO) { String token = authService.smsLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AppAuthLoginRespVO.builder().token(token).build());} 如果用户未注册,会自动使用手机号进行注册会员用户。所以,/app-api/member/user/sms-login 接口也提供了用户注册的功能。 # 5. 三方登录 详细参见 《开发指南 —— 三方登录》 文章。 # 5.1 管理后台的实现 ① 跳转第三方平台,来获得三方授权码,由 AuthController (opens new window) 提供 /admin-api/system/auth/social-auth-redirect 接口。代码如下: @GetMapping("/social-auth-redirect")@Operation(summary = "社交授权的跳转")@Parameters({ @Parameter(name = "type", description = "社交类型", required = true), @Parameter(name = "redirectUri", description = "回调路径")})public CommonResult<String> socialAuthRedirect(@RequestParam("type") Integer type, @RequestParam("redirectUri") String redirectUri) { return CommonResult.success(socialUserService.getAuthorizeUrl(type, redirectUri));} ② 使用 code 三方授权码进行快登录,由 AuthController (opens new window) 提供 /admin-api/system/auth/social-login 接口。代码如下: @PostMapping("/social-login")@Operation(summary = "社交快捷登录,使用 code 授权码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> socialQuickLogin(@RequestBody @Valid AuthSocialQuickLoginReqVO reqVO) { String token = authService.socialLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} ③ 使用 socialCode 三方授权码 + username + password 进行绑定登录,直接使用 /admin-api/system/auth/login 账号密码登录的接口,区别在于额外带上 socialType + socialCode + socialState 参数。 # 5.2 用户 App 的实现 ① 跳转第三方平台,来获得三方授权码,由 AppAuthController (opens new window) 提供 /app-api/member/auth/social-auth-redirect 接口。代码如下: @GetMapping("/social-auth-redirect")@Operation(summary = "社交授权的跳转")@Parameters({ @Parameter(name = "type", description = "社交类型", required = true), @Parameter(name = "redirectUri", description = "回调路径")})public CommonResult<String> socialAuthRedirect(@RequestParam("type") Integer type, @RequestParam("redirectUri") String redirectUri) { return CommonResult.success(socialUserService.getAuthorizeUrl(type, redirectUri));} ② 使用 code 三方授权码进行快登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/social-login 接口。代码如下: @PostMapping("/social-login")@Operation(summary = "社交快捷登录,使用 code 授权码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> socialQuickLogin(@RequestBody @Valid AuthSocialQuickLoginReqVO reqVO) { String token = authService.socialLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} ③ 使用 socialCode 三方授权码 + username + password 进行绑定登录,直接使用 /app-api/system/auth/login 手机验证码登录的接口,区别在于额外带上 socialType + socialCode + socialState 参数。 ④ 【微信小程序特有】使用 phoneCode + loginCode 实现获取手机号并一键登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/weixin-mini-app-login 接口。代码如下: @PostMapping("/weixin-mini-app-login")@Operation(summary = "微信小程序的一键登录")public CommonResult<AppAuthLoginRespVO> weixinMiniAppLogin(@RequestBody @Valid AppAuthWeixinMiniAppLoginReqVO reqVO) { return success(authService.weixinMiniAppLogin(reqVO));} # 6. 注册 # 6.1 管理后台的实现 管理后台暂不支持用户注册,而是通过在 [系统管理 -> 用户管理] 菜单,进行添加用户,由 UserController ( opens new window) 提供 /admin-api/system/user/create 接口。代码如下: @PostMapping("/create")@Operation(summary = "新增用户")@PreAuthorize("@ss.hasPermission('system:user:create')")public CommonResult<Long> createUser(@Valid @RequestBody UserCreateReqVO reqVO) { Long id = userService.createUser(reqVO); return success(id);} # 6.2 用户 App 的实现 手机验证码登录时,如果用户未注册,会自动使用手机号进行注册会员用户。所以, /app-api/system/user/sms-login 接口也提供了用户注册的功能。 # 7. 用户登出 用户登出的功能,统一使用 Spring Security 框架,通过删除用户 Token 的方式来实现。代码如下: 差别在于使用的 API 接口不同,管理员用户使用 /admin-api/system/logout,会员用户使用 /app-api/member/logout。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:51:54 数据权限 三方登录 ← 数据权限 三方登录→"},{"title":"系统日志","path":"/wiki/YuDaoBoot/后端手册/系统日志/系统日志.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 系统日志 项目提供 2 类 4 种系统日志: 审计日志:用户的操作日志、登录日志 API 日志:RESTful API 的访问日志、错误日志 # 1. 操作日志 操作日志,记录「谁」在「什么时间」对「什么对象」做了「什么事情」。 打开 [系统管理 -> 审计日志 -> 操作日志] 菜单,可以看到对应的列表,如下图所示: 操作日志的记录,由 yudao-spring-boot-starter-biz-operatelog (opens new window) 技术组件实现,OperateLogAspect (opens new window) 通过 Spring AOP 拦声明了 @OperateLog (opens new window) 注解的方法,异步记录日志。使用示例如下: 操作日志的存储,由 yudao-module-system 的 OperateLog (opens new window) 模块实现,记录到数据库的 system_operate_log (opens new window) 表。 # 1.1 @OperateLog 注解 @OperateLog 注解,一共有 6 个属性,如下图所示: module 属性:操作模块,例如说:用户、岗位、部门等等。为空时,默认会读取类上的 Swagger @Tag 注解的 name 属性。 name 属性:操作名,例如说:新增用户、修改用户等等。为空时,默认会读取方法的 Swagger @Operation 注解的 summary 属性。 type 属性:操作类型,在 OperateTypeEnum (opens new window) 枚举。目前有 GET 查询、CREATE 新增、UPDATE 修改、DELETE 删除、EXPORT 导出、IMPORT 导入、OTHER 其它,可进行自定义。 # 1.2 自动记录 操作日志往往记录的是针对某个对象的写操作,所以针对 POST、PUT、DELETE 等写请求,yudao-spring-boot-starter-biz-operatelog 组件会自动记录操作日志。 基于请求方法,转换出对应的 type 操作方法:POST 对应 CREATE 类型,PUT 对应 UPDATE 类型,DELETE 对应 DELETE 类型,其它对应 OTHER 类型。 基于 Swagger 注解,转换出对应的 module 操作模块、name 操作名。 因此,绝大多数 RESTful API 对应的方法,无需添加 @OperateLog 注解。例如说: 一般来说,只有两种场景需要添加 @OperateLog 注解。 ① 场景一:需要自定义 @OperateLog 注解的属性。例如说: ② 场景二:不想自动记录操作日志。例如说: # 1.3 后续优化 yudao-spring-boot-starter-biz-operatelog 组件目前提供的是轻量级的操作日志的解决方案,暂时未提供很好的记录操作对应、操作明细、拓展字段的能力。例如说: 【新增】2021-09-16 10:00 订单创建,订单号:NO.11089999,其中涉及变量订单号 “NO.11089999” 【修改】2021-09-16 10:00 用户小明修改了订单的配送地址:从 “金灿灿小区” 修改到 “银盏盏小区” 未来,艿艿会引入老友开源的 https://github.com/mouzt/mzt-biz-log (opens new window) 操作日志组件,优化项目的操作日志功能。大家记得给个 Star 哟! 目前,如果要记录具体的操作明细、拓展字段,可以调用 OperateLogUtils (opens new window) 的静态方法,代码如下: # 2. 登录日志 登录日志,记录用户的登录、登出行为,包括成功的、失败的。 打开 [系统管理 -> 审计日志 -> 登录日志] 菜单,可以看对应的列表,如下图所示: 登录日志的存储,由 yudao-module-system 的 LoginLog (opens new window) 模块实现,记录到数据库的 system_login_log (opens new window) 表。 登录类型通过 LoginLogTypeEnum (opens new window) 枚举,登录结果通过 LoginResultEnum (opens new window) 枚举,都可以自定义。代码如下: # 3. API 访问日志 API 访问日志,记录 API 的每次调用,包括 HTTP 请求、用户、开始时间、时长等等信息。 打开 [基础设施 -> API 日志 -> 访问日志] 菜单,可以看对应的列表,如下图所示: 访问日志的记录,由 yudao-spring-boot-starter-web (opens new window) 技术组件实现,通过 ApiAccessLogFilter (opens new window) 过滤 RESTful API 请求,异步记录日志。 访问日志的存储,由 yudao-module-infra 的 AccessLog (opens new window) 模块实现,记录到数据库的 infra_api_access_log (opens new window) 表。 # 4. API 错误日志 API 错误日志,记录每次 API 的异常调用,包括 HTTP 请求、用户、异常的堆栈等等信息。 打开 [基础设施 -> API 日志 -> 错误日志] 菜单,可以看对应的列表,如下图所示: 错误日志的记录,由 yudao-spring-boot-starter-web (opens new window) 技术组件实现,通过 GlobalExceptionHandler (opens new window) 拦截每次 RESTful API 的系统异常,异步记录日志。 错误日志的存储,由 yudao-module-infra 的 ErrorLog (opens new window) 模块实现,记录到数据库的 infra_api_error_log (opens new window) 表。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:58:24 Excel 导入导出 数据库 MyBatis ← Excel 导入导出 数据库 MyBatis→"},{"title":"站内信配置","path":"/wiki/YuDaoBoot/后端手册/站内信配置/站内信配置.html","content":"开发指南后端手册 芋道源码 2023-01-28 目录 站内信配置 本章节,介绍项目的站内信功能。它在管理后台有三个菜单,分别是: ① 站内信模版:管理站内信的内容模版 ② 站内信管理:查看站内信的发送记录 ③ 我的站内信:查看发送给我的站内信 # 1. 表结构 # 2. 实现代码 前端代码:views/system/notify (opens new window) 后端代码:controller/admin/notify (opens new window) # 3. 站内信配置 本小节,讲解如何配置站内信功能,整个过程如下: 新建一个站内信【模版】,配置站内信的内容模版 测试该站内信模板,查看对应的站内信【记录】,确认是否发送成功 # 3.1 新建站内信模版 ① 点击 [系统管理 -> 站内信管理 -> 模板管理] 菜单,查看站内信模板的列表。如下图所示: ② 点击 [新增] 按钮,填写信息如下图: 模版编号:站内信模板的唯一标识,使用站内信 API 时,通过它标识使用的站内信模板 发件人名称:发送站内信显示的发件人名字 模板内容:站内信模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 模版类型:站内信的分类,可使用 system_notify_template_type 字典进行自定义 开启状态:站内信模板被禁用时,该站内信模板将不发送站内信,只打印 logger 日志 疑问:为什么设计站内信模板的功能? 在一些场景下,产品会希望修改发送站内信的内容、发送人昵称,此时只需要修改站内信模版的对应属性,无需重启应用。 # 3.2 测试站内信模版 ① 点击 [测试] 按钮,选择接收人为「芋道源码」,进行该站内信模板的模拟发送。如下图所示: ② 点击 [系统管理 -> 站内信管理 -> 消息记录] 菜单,可以查看到刚发送的站内信。如下图所示: ③ 点击右上角的 [消息] 图标,也可以查看到刚发送的站内信。如下图所示: # 4. 站内信发送 # 4.1 NotifyMessageSendApi 站内信配置完成后,可使用 NotifyMessageSendApi (opens new window) 进行站内信的发送,支持多种用户类型。它的方法如下: # 4.2 接入示例 以 yudao-module-infra 模块,需要发站内信为例子,讲解 SmsCodeApi 的使用。 ① 在 yudao-module-infra-biz 模块的 pom.xml (opens new window) 引入 yudao-module-system-api 依赖,如所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 在代码中注入 NotifyMessageSendApi Bean,并调用发送站内信的方法。代码如下: public class TestDemoServiceImpl implements TestDemoService { // 0. 注入 NotifyMessageSendApi Bean @Resource private NotifyMessageSendApi notifySendApi; public void sendDemo() { // 1. 准备参数 Long userId = 1L; // 示例中写死,你可以改成你业务中的 userId 噢 String templateCode = "test_01"; // 站内信模版,记得在【站内信管理】中配置噢 Map<String, Object> templateParams = new HashMap<>(); templateParams.put("key1", "奥特曼"); templateParams.put("key2", "变身"); // 2. 发送站内信 notifySendApi.sendSingleNotifylToAdmin(new NotifySendSingleToUserReqDTO() .setUserId(userId).setTemplateCode(templateCode).setTemplateParams(templateParams)); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/29, 19:16:45 邮件配置 数据脱敏 ← 邮件配置 数据脱敏→"},{"title":"邮件配置","path":"/wiki/YuDaoBoot/后端手册/邮件配置/邮件配置.html","content":"开发指南后端手册 芋道源码 2023-01-26 目录 邮件配置 本章节,介绍项目的邮件功能。它在管理后台有三个菜单,分别是: ① 邮箱账号:配置邮件的发送账号 ② 邮件模版:管理邮件的内容模版 ③ 邮件记录:查看邮件的发送记录 # 1. 表结构 # 2. 实现原理 邮件功能提供统一的 API 给其它模块,使它们可以快速实现发送邮件的功能,无需关心不同邮件平台的具体对接。 邮件采用异步发送,基于 Redis 消息队列,如下图所示: 前端代码:views/system/mail (opens new window) 后端代码:controller/admin/mail (opens new window) 最终使用 Hutool 的 MailUtil (opens new window) 发送邮件。 # 3. 邮箱配置 本小节,讲解如何配置邮件功能,整个过程如下: 新建一个邮箱【账号】,配置邮件的发送账号 新建一个邮件【模版】,配置邮件的内容模版 测试该邮件模板,查看对应的邮件【日志】,确认是否发送成功 # 3.1 新建邮箱账号 ① 点击 [系统管理 -> 邮件管理 -> 邮箱账号] 菜单,查看邮箱账号的列表。如下图所示: ② 点击 [新增] 按钮,添加一个邮箱账号,并填写信息如下图: 友情提示: 邮件发送基于 SMTP (opens new window) 协议实现,需要开通账号的 STMP 服务。例如说: 不同邮件平台的 SMTP 配置,可见 「5. 邮箱平台附录」 小节。 ③ 新增完成后,确认你的邮箱账号是否可以发送邮件,可通过如下代码: import cn.hutool.extra.mail.MailAccount;import cn.hutool.extra.mail.MailUtil;@Testpublic void testDemo() { MailAccount mailAccount = new MailAccount()// .setFrom("奥特曼 <ydym_test@163.com>") .setFrom("ydym_test@163.com") // 邮箱地址 .setHost("smtp.163.com").setPort(465).setSslEnable(true) // SMTP 服务器 .setAuth(true).setUser("ydym_test@163.com").setPass("WBZTEINMIFVRYSOE"); // 登录账号密码 String messageId = MailUtil.send(mailAccount, "7685413@qq.com", "主题", "内容", false); System.out.println("发送结果:" + messageId);} # 3.2 新建邮箱模版 ① 点击 [系统管理 -> 邮箱管理 -> 邮件模板] 菜单,查看邮件模板的列表。如下图所示: ② 点击 [新增] 按钮,选择刚创建的邮箱账号,并填写信息如下图: 邮箱账号:发送该邮件模板时,使用的邮件账号,即使用哪个邮箱进行发送邮件 模版编号:邮件模板的唯一标识,使用邮件 API 时,通过它标识使用的邮件模板 发件人名称:发送邮件显示的发件人名字 模板内容:邮件模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 开启状态:邮件模板被禁用时,该邮件模板将不发送邮件,只记录邮件日志 疑问:为什么设计邮件模板的功能? 在一些场景下,产品会希望修改发送邮件的标题、内容,甚至邮箱账号,此时只需要修改邮件模版的对应属性,无需重启应用。 # 3.3 查看邮件日志 ① 点击 [测试] 按钮,输入测试的收件邮箱地址,进行该邮件模板的模拟发送。如下图所示: ② 打开收件邮箱,查看邮件是否发送成功。如下图所示: ③ 点击 [系统管理 -> 邮箱管理 -> 邮件日志] 采单,可以查看到每条邮件的发送状态。如下图所示: # 4. 邮件发送 # 4.1 MailSendApi 邮箱配置 完成后,可使用 MailSendApi ( opens new window) 进行邮件的发送,支持多种用户类型。它的方法如下: # 4.2 接入示例 以 yudao-module-infra 模块,需要发邮件为例子,讲解 SmsCodeApi 的使用。 ① 在 yudao-module-infra-biz 模块的 pom.xml ( opens new window) 引入 yudao-module-system-api 依赖,如所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 在代码中注入 SmsCodeApi Bean,并调用发送邮件的方法。代码如下: public class TestDemoServiceImpl implements TestDemoService { // 0. 注入 MailSendApi Bean @Resource private MailSendApi mailSendApi; public void sendDemo() { // 1. 准备参数 Long userId = 1L; // 示例中写死,你可以改成你业务中的 userId 噢 String templateCode = "test_01"; // 邮件模版,记得在【邮箱管理】中配置噢 Map<String, Object> templateParams = new HashMap<>(); templateParams.put("key1", "奥特曼"); templateParams.put("key2", "变身"); // 2. 发送邮件 mailSendApi.sendSingleMailToAdmin(new MailSendSingleToUserReqDTO() .setUserId(userId).setTemplateCode(templateCode).setTemplateParams(templateParams)); }} # 5. 邮箱平台附录 《QQ 邮箱的 SMTP 设置》 ( opens new window) 《网易 163 邮箱的 SMTP 设置》 ( opens new window) 《QQ 邮箱、网易邮箱、腾讯企业邮箱、网易企业邮箱的 SMTP 设置》 ( opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/28, 22:55:26 短信配置 站内信配置 ← 短信配置 站内信配置→"},{"title":"配置管理","path":"/wiki/YuDaoBoot/后端手册/配置管理/配置管理.html","content":"开发指南后端手册 芋道源码 2022-04-04 目录 配置管理 在 [基础设施 -> 配置管理] 菜单,可以查看和管理配置,适合业务上需要动态的管理某个配置。 例如说:创建用户时,需要配置用户的默认密码,这个密码是不会变的,但是有时候需要修改这个默认密码,这个时候就可以通过配置管理来修改。 对应的后端代码是 yudao-module-infra 的 config (opens new window) 业务模块。 # 1. 配置的表结构 infra_config 的表结构如下: CREATE TABLE `infra_config` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '参数主键', `group` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '参数分组', `type` tinyint NOT NULL COMMENT '参数类型', `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数名称', `key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数键名', `value` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数键值', `sensitive` bit(1) NOT NULL COMMENT '是否敏感', `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='参数配置表'; key 字段,对应到 Spring Boot 配置文件的配置项,例如说 yudao.captcha.enable、sys.user.init-password 等等。 # 3. 后端案例 TODO 芋艿:待补充 # 4. 前端案例 后端提供了 /admin-api/infra/config/get-value-by-key (opens new window) RESTful API 接口,返回指定配置项的值。前端的使用示例如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:01 消息队列 工具类 Util ← 消息队列 工具类 Util→"},{"title":"限流熔断","path":"/wiki/YuDaoBoot/后端手册/限流熔断/限流熔断.html","content":"None"},{"title":"验证码","path":"/wiki/YuDaoBoot/后端手册/验证码/验证码.html","content":"开发指南后端手册 芋道源码 2023-01-20 目录 验证码 项目基于 AJ-Captcha (opens new window) 实现行为验证码,包含滑动拼图、文字点选两种方式,UI 支持弹出和嵌入两种方式。如下图所示: 滑动拼图 文字点选 疑问:为什么采用行为验证码? 相比传统的「传统字符型验证码」的“展示验证码-填写字符-比对答案”的流程来说,「行为验证码」的“展示验证码-操作-比对答案”的流程,用户只需要使用鼠标产生指定的行为轨迹,不需要键盘手动输入,用户体验更好,更加难以被机器识别,更加安全可靠。 # 1. 交互流程 ① 用户访问应用页面,请求显示行为验证码 ② 用户按照提示要求完成验证码拼图/点击 ③ 用户提交表单,前端将第二步的输出一同提交到后台 ④ 验证数据随表单提交到后台后,后台需要调用 captchaService.verification (opens new window) 做二次校验 ⑤ 第 4 步返回校验通过/失败到产品应用后端,再返回到前端 # 2. 如何关闭验证码 管理后台的登录界面,默认开启验证码。如果需要关闭验证码,操作如下: ① 后端的 application-local.yaml 配置文件中,将 yudao.captcha.enabled (opens new window) 设置为 false。 ② 如果前端使用 yudao-ui-admin 项目,将 .env.local 配置文件中,将 VUE_APP_DOC_ENABLE (opens new window) 设置为 false。 如果前端使用 yudao-ui-admin-vue3 项目,将 .env 配置文件中,将 VITE_APP_CAPTCHA_ENABLE (opens new window) 设置为 false。 # 3. 接入场景 # 3.1 后端接入 ① yudao-spring-boot-starter-captcha (opens new window) 对 AJ-Captcha 进行封装,使用 Redis 存储验证码数据,保证分布式环境下的可用性。 由于 AJ-Captcha 对 Spring Boot 3.X 版本的支持还不完善,所以使用 captcha-plus (opens new window) 替代,它是基于 AJ-Captcha 进行增强。 使用时,需要在 pom.xml (opens new window) 引入该依赖,如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-captcha</artifactId></dependency> ② 验证码的配置,在 application.yaml (opens new window) 配置文件中,配置项如下: aj: captcha: jigsaw: classpath:images/jigsaw # 滑动验证,底图路径,不配置将使用默认图片;以 classpath: 开头,取 resource 目录下路径 pic-click: classpath:images/pic-click # 滑动验证,底图路径,不配置将使用默认图片;以 classpath: 开头,取 resource 目录下路径 cache-type: redis # 缓存 local/redis... cache-number: 1000 # local 缓存的阈值,达到这个值,清除缓存 timing-clear: 180 # local定时清除过期缓存(单位秒),设置为0代表不执行 type: blockPuzzle # 验证码类型 default两种都实例化。 blockPuzzle 滑块拼图 clickWord 文字点选 water-mark: 芋道源码 # 右下角水印文字(我的水印),可使用 https://tool.chinaz.com/tools/unicode.aspx 中文转 Unicode,Linux 可能需要转 unicode interference-options: 0 # 滑动干扰项(0/1/2) req-frequency-limit-enable: false # 接口请求次数一分钟限制是否开启 true|false req-get-lock-limit: 5 # 验证失败 5 次,get接口锁定 req-get-lock-seconds: 10 # 验证失败后,锁定时间间隔 req-get-minute-limit: 30 # get 接口一分钟内请求数限制 req-check-minute-limit: 60 # check 接口一分钟内请求数限制 req-verify-minute-limit: 60 # verify 接口一分钟内请求数限制 如果你想修改验证码的 图片,修改 resources/images (opens new window) 目录即可。 ③ 验证码的使用,可以参考 CaptchaController (opens new window) 和 AuthController (opens new window) 两个类的实现代码。 # 3.2 Vue2.X 管理后台 ① 验证码组件:Verifition (opens new window) ② 登录界面的接入:login.vue (opens new window) <!-- 图形验证码 --><Verify ref="verify" :captcha-type="'blockPuzzle'" :img-size="{width:'400px',height:'200px'}" @success="handleLogin" /> # 3.3 Vue3.X 管理后台 ① 验证码组件: Verifition ( opens new window) ② 登录界面的接入: LoginForm.vue ( opens new window) <Verify ref="verify" mode="pop" :captchaType="captchaType" :imgSize="{ width: '400px', height: '200px' }" @success="handleLogin"/> # 3.4 uni-app 用户 App ① 验证码组件: verifition ( opens new window) ② 登录界面的接入: login.vue ( opens new window) <Verify @success="pwdLogin" :mode="'pop'" :captchaType="'blockPuzzle'" :imgSize="{ width: '330px', height: '155px' }" ref="verify"></Verify> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/02/11, 02:22:37 敏感词 地区 & IP 库 ← 敏感词 地区 & IP 库→"},{"title":"工作流(Flowable)会签、或签","path":"/wiki/YuDaoBoot/工作流手册/工作流(Flowable)会签、或签/工作流(Flowable)会签、或签.html","content":"开发指南工作流手册 芋道源码 2022-03-07 目录 工作流(Flowable)会签、或签 项目基于 Flowable 实现了工作流的功能。本章节,我们将介绍工作流的相关功能。 以请假流程为例,讲解系统支持的两种表单方式的工作流: 流程表单:在线配置动态表单,无需建表与开发 业务表单:业务需建立独立的数据库表,并开发对应的表单、详情界面 整个过程包括: 定义流程:【管理员】新建流程、设计流程模型、并设置用户任务的审批人,最终发布流程 发起流程:【员工】选择流程,并发起流程实例 审批流程:【审批人】接收到流程任务,审批结果为通过或不通过 微信扫描下方二维码,加入后可观看视频! 01、如何集成 Flowable 框架? (opens new window) 02、如何实现动态的流程表单? (opens new window) 03、如何实现流程表单的保存? (opens new window) 04、如何实现流程表单的展示? (opens new window) 05、如何实现流程模型的新建? (opens new window) 06、如何实现流程模型的流程图的设计? (opens new window) 07、如何实现流程模型的流程图的预览? (opens new window) 08、如何实现流程模型的分配规则? (opens new window) 09、如何实现流程模型的发布? (opens new window) 10、如何实现流程定义的查询? (opens new window) 11、如何实现流程的发起? (opens new window) 12、如何实现我的流程列表? (opens new window) 13、如何实现流程的取消? (opens new window) 14、如何实现流程的任务分配? (opens new window) 15、如何实现会签、或签任务? (opens new window) 16、如何实现我的待办任务列表? (opens new window) 17、如何实现我的已办任务列表? (opens new window) 18、如何实现任务的审批通过? (opens new window) 19、如何实现任务的审批不通过? (opens new window) 20、如何实现流程的审批记录? (opens new window) 21、如何实现流程的流程图的高亮? (opens new window) 22、如何实现工作流的短信通知? (opens new window) 23、如何实现 OA 请假的发起? (opens new window) 24、如何实现 OA 请假的审批? (opens new window) # 0. 如何开启 bpm 模块? yudao-module-bpm 模块默认未启用,需要手动开启。步骤如下: ① 第一步,修改根目录的 pom.xml (opens new window) 文件,取消 yudao-module-bpm 模块的注释。 ① 第二步,修改 yudao-server 的 pom.xml (opens new window) 文件,取消 yudao-module-bpm-biz 依赖的注释,并进行 IDEA 的 Maven 刷新。 ③ 第三步,重启项目,看到 Property Source flowable-liquibase-override refreshed 说明开启成功。 另外,启动过程中,Flowable 会自动创建 ACT_ 和 FLW_ 开头的表。 如果启动中报 MySQL “Specified key was too long; max key length is 1000 bytes” (opens new window) 错误,可以将 MySQL 的缺省存储引擎设置为 innodb,即 default-storage-engine=innodb 配置项。 # 1. 请假流程【流程表单】 # 1.1 第一步:定义流程 登录账号 admin、密码 admin123 的用户,扮演【管理员】的角色,进行流程的定义。 ① 访问 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [新建流程] 按钮,填写流程标识、流程名称。如下图所示: 流程标识:对应 BPMN 流程文件 XML 的 id 属性,不能重复,新建后不可修改。 流程名称:对应 BPMN 流程文件 XML 的 name 属性。 <!-- 这是一个 BPMN XML 的示例,主要看 id 和 name 属性 --><?xml version="1.0" encoding="UTF-8"?><bpmn2:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn2="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" id="diagram_Process_1647305370393" targetNamespace="http://activiti.org/bpmn"> <bpmn2:process id="common-form" name="通用表单流程" isExecutable="true" /> <bpmndi:BPMNDiagram id="BPMNDiagram_1"> <bpmndi:BPMNPlane id="common-form_di" bpmnElement="common-form" /> </bpmndi:BPMNDiagram></bpmn2:definitions> ② 访问 [工作流程 -> 流程管理 -> 流程表单] 菜单,点击 [新增] 按钮,新增一个名字为 leave-form 的表单。如下图所示: 流程表单的实现? 基于 https://github.com/JakHuang/form-generator (opens new window) 项目实现的动态表单。 回到 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [修改流程] 按钮,配置表单类型为流程表单,选择名字为 leave-form 的流程表单。如下图所示: ③ 点击 [设计流程] 按钮,在线设计请假流程模型,包含两个用户任务:领导审批、HR 审批。如下图所示: 设计流程的实现? 基于 https://github.com/miyuesc/bpmn-process-designer (opens new window) 项目实现,它的底层是 bpmn-js (opens new window)。 ④ 点击 [分配规则] 按钮,设置用户任务的审批人。其中,规则类型用于分配用户任务的审批人,目前有 7 种规则:角色、部门成员、部门负责人、岗位、用户、用户组、自定义脚本,基本可以满足绝大多数场景,是不是非常良心。 设置【领导审批】的规则类型为自定义脚本 + 流程发起人的一级领导,如下图所示: 设置【HR 审批】的规则类型为岗位 + 人力资源,如下图所示: 规则类型的实现? 可见 BpmUserTaskActivityBehavior (opens new window) 代码,目前暂时支持分配一个审批人。 ⑤ 点击 [发布流程] 按钮,把定义的流程模型部署出去。部署成功后,就可以发起该流程了。如下图所示: 修改流程后,需要重新发布流程吗? 需要,必须重新发布才能生效。每次流程发布后,会生成一个新的流程定义,版本号从 v1 开始递增。 发布成功后,会部署新版本的流程定义,旧版本的流程定义将被挂起。当然,已经发起的流程不会受到影响,还是走老的流程定义。 # 1.2 第二步:发起流程 登录账号 admin、密码 admin123 的用户,扮演【员工】的角色,进行流程的发起。 ① 访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,点击 [发起流程] 按钮,可以看到可以选择的流程定义的列表。 ② 选择名字为通用表单的流程定义,发起请假流程。填写请假表单信息如下: ③ 点击提交成功后,可在我的流程中,可看到该流程的状态、结果。 ④ 点击 [详情] 按钮,可以查看申请的表单信息、审批记录、流程跟踪图。 # 1.2 第三步:审批流程(领导审批) 登录账号 test、密码 test123 的用户,扮演【审批人】的角色,进行请假流程的【领导审批】任务。 ① 访问 [工作流程 -> 任务管理 -> 待办任务] 菜单,可以查询到需要审批的任务。 ② 点击 [审批] 按钮,填写审批建议,并点击 [通过] 按钮,这样任务的审批就完成了。 ③ 访问 [工作流程 -> 任务管理 -> 已办任务] 菜单,可以查询到已经审批的任务。 此时,使用【员工】的角色,访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,可以看到流程流转到了【HR 审批】任务。 # 1.3 第三步:审批流程(HR 审批) 登录账号 hrmgr、密码 hr123 的用户,扮演【审批人】的角色,进行请假流程的【HR 审批】任务。 ① 访问 [工作流程 -> 任务管理 -> 待办任务] 菜单,点击 [审批] 按钮,填写审批建议,并点击 [通过] 按钮。 此时,使用【员工】的角色,访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,可以看到流程处理结束,最终审批通过。 # 2. 请假流程【业务表单】 根据业务需要,业务通过建立独立的数据库表(业务表)记录申请信息,而流程引擎只负责推动流程的前进或者结束。两者需要进行双向的关联: 每一条业务表记录,通过它的流程实例的编号( process_instance_id )指向对应的流程实例 每一个流程实例,通过它的业务键( BUSINESS_KEY_ ) 指向对应的业务表记录。 以项目中提供的 OALeave (opens new window) 请假举例子,它的业务表 bpm_oa_leave 和流程引擎的流程实例的关系如下图: 也因为业务建立了独立的业务表,所以必须开发业务表对应的列表、表单、详情页面。不过,审核相关的功能是无需重新开发的,原因是业务表已经关联对应的流程实例,流程引擎审批流程实例即可。 下面,我们以项目中的 OALeave (opens new window) 为例子,详细讲解下业务表单的开发与使用的过程。 # 2.0 第零步:业务开发 ① 新建业务表 bpm_oa_leave,建表语句如下: CREATE TABLE `bpm_oa_leave` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '请假表单主键', `user_id` bigint NOT NULL COMMENT '申请人的用户编号', `type` tinyint NOT NULL COMMENT '请假类型', `reason` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请假原因', `start_time` datetime NOT NULL COMMENT '开始时间', `end_time` datetime NOT NULL COMMENT '结束时间', `day` tinyint NOT NULL COMMENT '请假天数', `result` tinyint NOT NULL COMMENT '请假结果', `process_instance_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '流程实例的编号', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA 请假申请表'; process_instance_id 字段,关联流程引擎的流程实例对应的 ACT_HI_PROCINST 表的 PROC_INST_ID_ 字段 result 字段,请假结果,需要通过 Listener 监听回调结果,稍后来看看 ② 实现业务表的【后端】业务逻辑,具体代码可以看看如下两个类: BpmOALeaveController (opens new window) BpmOALeaveServiceImpl (opens new window) 重点是看流程发起的逻辑,它定义了 /bpm/oa/leave/create 给业务的表单界面调用,UML 时序图如下: 具体的实现代码比较简单,如下图所示: PROCESS_KEY 静态变量:是业务对应的流程模型的编号,稍后会进行创建编号为 oa_leave 的流程模型。 BpmProcessInstanceApi (opens new window) 定义了 #createProcessInstance(...) 方法,用于创建流程实例,业务无需关心底层是 Activiti 还是 Flowable 引擎,甚至未来可能的 Camunda 引擎。 ③ 实现业务表的【前端】业务逻辑,具体代码可以看看如下三个页面: leave/create.vue (opens new window) leave/detail.vue (opens new window) leave/index.vue (opens new window) 另外,在 router/index.js (opens new window) 中定义 create.vue 和 detail.vue 的路由,配置如下: { path: '/bpm', component: Layout, hidden: true, redirect: 'noredirect', children: [{ path: 'oa/leave/create', component: (resolve) => require(['@/views/bpm/oa/leave/create'], resolve), name: '发起 OA 请假', meta: {title: '发起 OA 请假', icon: 'form', activeMenu: '/bpm/oa/leave'} }, { path: 'oa/leave/detail', component: (resolve) => require(['@/views/bpm/oa/leave/detail'], resolve), name: '查看 OA 请假', meta: {title: '查看 OA 请假', icon: 'view', activeMenu: '/bpm/oa/leave'} } ]} 为什么要做独立的 `create.vue` 和 `index.vue` 页面? 创建流程时,需要跳转到 create.vue 页面,填写业务表的信息,才能提交流程。 审批流程时,需要跳转到 detail.vue 页面,查看业务表的信息。 ④ 实现业务表的【后端】监听逻辑,具体可见 BpmOALeaveResultListener (opens new window) 监听器。它实现流程引擎定义的 BpmProcessInstanceResultEventListener (opens new window) 抽象类,在流程实例结束时,回调通知它最终的结果是通过还是不通过。代码如下图: 至此,我们了解了 OALeave 使用业务表单所涉及到的开发,下面我们来定义对应的流程、发起该流程、并审批该流程。 # 2.1 第一步:定义流程 登录账号 admin、密码 admin123 的用户,扮演【管理员】的角色,进行流程的定义。 ① 访问 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [新建流程] 按钮,填写流程标识、流程名称。如下图所示: 注意,流程标识需要填 oa_leave。因为在 BpmOALeaveServiceImpl 类中,规定了对应的流程标识为 oa_leave。 ② 点击 [修改流程] 按钮,配置表单类型为业务表单,填写表单提交路由为 /bpm/oa/leave/create(用于发起流程时,跳转的业务表单的路由)、表单查看路由为 /bpm/oa/leave/detail(用于在流程详情中,点击查看表单的路由)。如下图所示: ③ 点击 [设计流程] 按钮,在线设计请假流程模型,包含两个用户任务:领导审批、HR 审批。如下图所示: 可以点击 oa_leave_bpmn.XML 进行下载,然后点击 [打开文件] 按钮,进行导入。 ④ 点击 [分配规则] 按钮,设置用户任务的审批人。 设置【领导审批】的规则类型为自定义脚本 + 流程发起人的一级领导,如下图所示: 设置【HR 审批】的规则类型为岗位 + 人力资源,如下图所示: ⑤ 点击 [发布流程] 按钮,把定义的流程模型部署出去。部署成功后,就可以发起该流程了。 # 2.1 第二步:发起流程 登录账号 admin、密码 admin123 的用户,扮演【员工】的角色,进行流程的发起。 ① 发起业务表单请假流程,两种路径: 访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,点击 [发起流程] 按钮,会跳转到流程模型 oa_leave 配置的表单提交路由。 访问 [工作流程 -> 请假查询] 菜单,点击 [发起请假] 按钮。 ② 填写一个小于等于 3 天的请假,只会走【领导审批】任务;填写一个大于 3 天的请假,在走完【领导审批】任务后,会额外走【HR 审批】任务。 后续的流程,和「1. 请假流程【流程表单】」是基本一致的,这里就不重复赘述,当然你还是要试着跑一跑,了解整个的过程。 # 2.3 第三步:审批流程(领导审批) 略~自己跑 # 2.4 第三步:审批流程(HR 审批) 略~自己跑 # 2. 流程通知 流程在发生变化时,会发送通知给相关的人。目前有三个场景会有通知,通过短信的方式。 # 3. 流程图示例 # 3.1 会签 定义:指同一个审批节点设置多个人,如 ABC 三人,三人会同时收到审批,需全部同意之后,审批才可到下一审批节点。 配置方式如下图所示: 重点是【完成条件】为 ${ nrOfCompletedInstances== nrOfInstances }。 # 3.2 或签 定义:指同一个审批节点设置多个人,如ABC三人,三人会同时收到审批,只要其中任意一人审批即可到下一审批节点。 配置方式如下图所示: 重点是【完成条件】为 ${ nrOfCompletedInstances== 1 }。 # 4. 如何使用 Activiti? Activiti 和 Flowable 提供的 Java API 是基本一致的,例如说 Flowable 的 org.flowable.engine.RepositoryService 对应 Activiti 的 org.activiti.engine .RepositoryService。所以,我们可以修改 import 的包路径来替换。 另外,在项目的老版本,我们也提供了 Activiti 实现,你可以具体参考下: yudao-spring-boot-starter-activiti (opens new window) yudao-module-bpm-biz-activiti (opens new window) # 4. 迭代计划 工作流的基本功能已经开发完成,当然还是有很多功能需要进行建设。已经整理在 https://gitee.com/zhijiantianya/ruoyi-vue-pro/issues/I4UPEU (opens new window) 链接中,你也可以提一些功能的想法。 如果您有参与工作流开发的想法,可以添加我的微信 wangwenbin10 ! 艿艿会带着你做技术方案,Code Review 你的每一行代码的实现。相信在这个过程中,你会收获不错的技术成长! .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 地区 & IP 库 报表设计器 ← 地区 & IP 库 报表设计器→"},{"title":"报表设计器","path":"/wiki/YuDaoBoot/大屏手册/报表设计器/报表设计器.html","content":"开发指南大屏手册 芋道源码 2022-07-29 目录 报表设计器 数据可视化,一般可以通过报表设计器、或者大屏设计器来实现。本小节,我们来讲解报表设计器的功能开启。 报表设计器,指的是使用 Web 版设计器,通过类似于 Excel 操作风格,通过拖拽完成报表设计。如下图所示: 在项目中,通过集成市面上的报表引擎,实现了报表设计器的能力。目前使用如下: 是否集成 是否开源 JimuReport (opens new window) 已集成 不开源 AJ-Report (opens new window) 集成中 开源 UReport2 (opens new window) 不集成 开源 为什么不使用 UReport2 报表引擎呢? UReport2 基本处于不维护的状态,最后发版时间是 2018 年! # 1. 功能开启 yudao-module-report 实现了报表设计器的能力,考虑到编译速度,默认是关闭的。开启步骤如下: 第一步,开启 yudao-report-report 模块 第二步,导入报表的 SQL 数据库脚本 第三步,启动后端项目,确认功能是否生效 第四步,启动报表设计器的前端项目 # 1.1 第一步,开启模块 ① 修改根目录的 pom.xml (opens new window) 文件,取消 yudao-module-report 模块的注释。 ② 修改 yudao-server 目录的 pom.xml (opens new window) 文件,引入 yudao-module-report 模块。如下图所示: ③ 点击 IDEA 右上角的【Reload All Maven Projects】,刷新 Maven 依赖。如下图所示: # 1.2 第二步,导入 SQL 导入 jimureport.mysql5.7.create.sql (opens new window) 脚本,初始化 JimuReport 相关的表结构和数据。如果你是 Oracle、PostgreSQL 等其它数据库,需要自己使用 Navicat 进行转换。 # 1.3 第三步,启动后端项目 启动后端项目,看到 \"Init JimuReport Config [ 线程池 ] \" 说明开启成功。 # 1.4 第四步,启动前端项目(AJ-Report) TODO 开发中,预计 4 月份左右。 # 1.4 第四步,启动前端项目(JimuReport) ① JimuReport 前端项目内置在后端项目中,无需启动。 ② 访问 [报表管理 -> 报表设计器] 菜单,可以查看对应的功能。如下图所示: 可以看到,JimuReport 支持数据报表、图形报表、打印设计等能力。 # 2. 如何使用? # 2.1 AJ-Report 报表设计器 TODO 开发中,预计 4 月份左右。 # 2.2 JimuReport 报表设计器 可以查看 JimuReport 的官方文档,主要是: 快速入门 (opens new window) 操作手册(报表设计器) (opens new window) 注意,JimuReport 是商业化的产品,报表设计器的功能应该是免费的,大屏设计器的功能是收费的。 集成 JimuReport 的代码实现? ① 后端:在 jmreport (opens new window) 包下,进行 JimuReport 的集成。 ② 前端:在 @/views/report/jmreport (opens new window) 文件,通过 IFrame 嵌入 JimuReport 界面。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 工作流(Flowable)会签、或签 大屏设计器 ← 工作流(Flowable)会签、或签 大屏设计器→"},{"title":"大屏设计器","path":"/wiki/YuDaoBoot/大屏手册/大屏设计器/大屏设计器.html","content":"开发指南大屏手册 芋道源码 2023-02-07 目录 大屏设计器 数据可视化,一般可以通过报表设计器、或者大屏设计器来实现。本小节,我们来讲解大屏设计器的功能开启。 大屏设计器,指的是通过拖拽图表或页面元素,无需编写代码即可制作数据大屏。如下图所示: 在项目中,通过集成市面上的报表引擎,实现了大屏设计器的能力。目前使用如下: 是否集成 是否开源 AJ-Report (opens new window) 集成中 开源 Go-View (opens new window) 集成中 开源 JimuReport (opens new window) 不集成 不开源 为什么不使用 JimuReport 报表引擎呢? 因为 JimuReport 的大屏设计器是商业化的,需要购买授权。我手头暂时没有授权,所以没办法集成~ # 1. 功能开启 yudao-module-report 也实现了大屏设计器的能力,考虑到编译速度,默认是关闭的。开启步骤如下: 第一步,开启 yudao-report-report 模块 第二步,导入报表的 SQL 数据库脚本 第三步,启动后端项目,确认功能是否生效 第四步,启动大屏设计器的前端项目 # 1.1 第一步,开启模块 ① 修改根目录的 pom.xml (opens new window) 文件,取消 yudao-module-report 模块的注释。 ② 修改 yudao-server 目录的 pom.xml (opens new window) 文件,引入 yudao-module-report 模块。如下图所示: ③ 点击 IDEA 右上角的【Reload All Maven Projects】,刷新 Maven 依赖。如下图所示: # 1.2 第二步,导入 SQL 导入 go-view.sql (opens new window) 脚本,初始化 Go-View 相关的表结构和数据。 # 1.3 第三步,启动后端项目 启动后端项目,看到 \"Init JimuReport Config [ 线程池 ] \" 说明开启成功。 # 1.4 第四步,启动前端项目(AJ-Report) TODO 开发中,预计 4 月份左右。 # 1.4 第四步,启动前端项目(Go-View) ① 克隆 yudao-ui-go-view (opens new window) 项目,执行如下命令进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ② 启动完成后,浏览器会自动打开 http://127.0.0.1:3000 (opens new window) 地址,可以看到前端界面。 ③ 访问 [报表管理 -> 大屏设计器] 菜单,可以查看对应的功能。如下图所示: # 2. 如何使用? # 2.1 AJ-Report 大屏设计器 TODO 开发中,预计 4 月份左右。 # 2.2 Go-View 大屏设计器 可以查看 Go-View 的官方文档,主要是: GoView 说明文档 —— 页面引导 (opens new window) GoView 说明文档 —— 常见问题 (opens new window) 如果你想了解在 Go-View 中,如何使用 SQL 或 HTTP 查询数据,可以查看内置的两个示例: 集成 Go-View 的代码实现? ① 后端:Go-View 的后端代码,主要在 go-view (opens new window) 包下实现。 ② 前端:在 @/views/report/go-view (opens new window) 文件,通过 IFrame 嵌入 Go-View 界面。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 报表设计器 功能开启 ← 报表设计器 功能开启→"},{"title":"【v1.0.0】2021.05.03","path":"/wiki/YuDaoBoot/更新日志/【v1.0.0】2021.05.03/【v1.0.0】2021.05.03.html","content":"开发指南更新日志 芋道源码 2022-03-07 目录 【v1.0.0】2021.05.03 # 初始版本 第一个版本,基于 RuoYi-Vue (opens new window) 重构,主要是三个方面: 代码的重构 技术选型的调整 后台功能的新增 因此,v1.0.0 的更新日志,分成这三方面来写。 # 代码的重构 调整整体代码结构,将多个 Maven Module 合并为单个,使用 Java package 进行拆分隔离,如 图 (opens new window) 所示。原因是:随着业务逻辑的逐步复杂,多个 Maven Module 的依赖关系的管理,会是一个很大的问题。 拆分 framework (opens new window) 为多个 Maven Module,按照 Web (opens new window)、Security (opens new window)、MyBatis (opens new window)、Redis (opens new window) 等不同组件,进行封装与拓展。 基于 JUnit5 (opens new window) 与 Mockito (opens new window),实现单元测试,保证功能的正确性,与代码的可维护性。一直自动化,一直爽! 增加 SpringBoot 多环境的配置文件,提供完善的 deploy.sh (opens new window) 部署脚本,以及 Jenkins 部署教程 (opens new window)。 优化 Spring Security (opens new window) 实现权限的代码,提升可读性和维护性。 增加本地缓存(菜单、角色、数据字典等等),提升性能。通过 Redis 订阅发布,实现缓存的实时刷新。 增加 VO (opens new window) 类,作为 API 接口的响应对象,避免数据库实体与前端的直接耦合。 优化 操作日志 (opens new window),支持读取 Swagger 作为日志的内容。 优化 定时任务 (opens new window),支持执行失败的重试,更完善的执行日志。 优化 codegen (opens new window) 代码生成器,在原先生成 Controller、Service、Mapper、数据库实体、Vue 代码的基础上,额外生成 VO、单元测试的代码。 调整文件改用 数据库 (opens new window) 存储,而不是文件系统。原因是,项目在部署多个服务节点时,文件需要做同步。未来,会增加阿里云、七牛云等存储云服务。 去除原有数据库的连表查询、递归查询,改为单表操作的方式,多次读取 + 内存拼接。 优化 Java 代码的格式,解决 IDEA 代码告警的问题。 # 后台功能的新增 增加 API 访问 (opens new window)与异常 (opens new window)日志,方便排查线上 API 的问题。 增加 全局错误码 (opens new window),统一业务异常的管理。管理后台会支持错误码的管理,支持提示文案的可配置化。 增加 短信模块 (opens new window),提供短信渠道、短息模板、短信日志的管理,对接阿里云、云片等主流短信平台。 增加 Redis Key (opens new window) 的管理,知道项目中使用到的 Redis Key 的格式、数据类型、过期时间、描述等等信息。 # 技术选型的调整 将 Spring Boot 版本,从 2.1.3 升级到 2.4.5 最新。 增加 bom (opens new window) 文件,统一 Maven 的依赖管理。 引入 MyBatis Plus (opens new window) 组件,简化 MyBatis 使用,提升开发效率。 引入 Redisson (opens new window) 组件,作为 Redis 的客户端,提供更强大的 Redis 操作。 基于 Redis 实现分布式消息队列的功能。接入 Redis Pub/Sub (opens new window) 实现广播消费,接入 Redis Stream (opens new window) 实现集群消费。 去除 fastjson (opens new window),统一使用 Jackson (opens new window) 作为 JSON 库,老爆安全漏洞的悲伤。 引入 MapStruct (opens new window) 组件,实现数据库实体与 VO 类之间的转换。 引入 Lombok (opens new window) 组件,生成 setter、getter 等常用方法,去除冗余代码。 引入 Spring Async (opens new window) 功能,实现异步任务。例如说,异步记录 API 访问日志、管理员操作日志等等。 魔改 Apollo (opens new window) 组件,接入本地数据库,实现内嵌的配置中心。通俗的说,我们可以将原本添加到 application.yaml 的配置项,改为添加到数据库中,项目启动会进行读取。 引入 Hutool (opens new window) 组件,去除大量重复的工具类,也避免原本 Util 存在一些 bug 的问题。 引入 Screw (opens new window) 组件,实现数据库文档的生成,虽然好像现在用途较少。 引入 EasyExcel (opens new window),提供 Excel 的导入与导出的功能。 实现 Idempotent (opens new window) 组件,实现幂等的功能,可以用来解决 HTTP 重复请求的问题。 引入 Lock4J (opens new window),实现声明式的分布式锁的功能。虽然 Redisson 内置了分布式锁的功能,但是通过注解声明一个 @Lock4j 注解的使用方式,更加便利,且满足绝大多数场景。 去除原有的服务监控,使用 SpringBoot Admin (opens new window) 替代,提供更完整的监控能力。 引入 SkyWalking (opens new window) 组件,实现链路追踪和日志服务的功能。通过链路追踪,我们可以看到一个 API 请求涉及到的 MySQL、Redis 等操作;通过日志服务,我们可以方便的看到每个服务实例的日志。 引入 Resilience4j (opens new window) 组件,实现限流、熔断等功能,保证服务的稳定性。 引入 Knife4j (opens new window),美化接口文档。原本所有 API 接口文档是缺失的,已经全部补全,可见 http://api-dashboard.yudao.iocoder.cn/doc.html (opens new window) 地址。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.1.0】2021.10.25 ← 【v1.1.0】2021.10.25"},{"title":"【v1.1.0】2021.10.25","path":"/wiki/YuDaoBoot/更新日志/【v1.1.0】2021.10.25/【v1.1.0】2021.10.25.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.1.0】2021.10.25 # 增加管理后台的企业微信、钉钉等社交登录 新增管理后台的企业微信、钉钉等社交登录 新增用户前台(例如说,用户使用的小程序)的后端项目 yudao-user-server 新增公共服务 yudao-core-service 项目,通过 Jar 包的方式,提供 yudao-user-server 和 yudao-admin-server 的共享逻辑的复用 新增用户前台的手机登录、验证码登录 修复管理后台的用户头像上传 404 的问题,原因是请求路径不对 修复用户导入失败的问题,原因是 Lombok 链式与 cglib 读取属性有冲突 修复阿里云短信发送失败的问题,原因是 Opentracing 依赖的版本太低,调整成 0.31.0 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.2.0】2021.12.15 【v1.0.0】2021.05.03 ← 【v1.2.0】2021.12.15 【v1.0.0】2021.05.03→"},{"title":"【v1.2.0】2021.12.15","path":"/wiki/YuDaoBoot/更新日志/【v1.2.0】2021.12.15/【v1.2.0】2021.12.15.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.2.0】2021.12.15 # 新增多租户、数据权限的功能 这个版本新增了多租户与数据权限两个重量级的功能,建议花点时间进行了解与学习。 # ⭐ New Features 【新增】多租户,支持 Web、Security、Job、MQ、Async、DB、Redis 组件 【新增】数据权限,内置基于部门过滤的规则 【新增】用户前台的昵称、头像的修改 【新增】用户前台的微信公众号、微信小程序的社交登录的 API 接口 完整功能,需要等基于 Uniapp 实现的用户前台一起~ 努力 coding 中,胖友可以 star 持续关注一波! 【优化】管理后台的登录成功后,LoginUser 使用统一方法补全信息 # 🐞 Bug Fixes 【修复】通知和字典查询接口的 @PreAuthorize 权限标识错误 【修复】代码生成的 Java 类路径缺少 modules 目录 【修复】代码生成的 Test 单元测试类的引入 Util 工具类的包路径不正确 # 🔨 Dependency Upgrades 【引入】mockito-inline 3.6.28:Mockito 提供对 final、static 的支持 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.3.0】2022.01.24 【v1.1.0】2021.10.25 ← 【v1.3.0】2022.01.24 【v1.1.0】2021.10.25→"},{"title":"【v1.4.0】2022-02-04","path":"/wiki/YuDaoBoot/更新日志/【v1.4.0】2022-02-04/【v1.4.0】2022-02-04.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.4.0】2022-02-04 # 重构成多 Maven Module 的代码结构 大版本重构,基于 Maven Module 的方式拆分多模块,希望大家多多提点建议! # 📈 Statistic 总代码行数:69118 源码代码行数:42571 注释行数:15847 单元测试用例数:278 # ⭐ New Features 【重构】大模块按照多 Maven Module 的方式拆分,提升可维护性,为后续重构 yudao-cloud 提供基础 【移除】将 yudao-core-service 模块移除,替换成每个 Maven Module 暴露对应的 yudao-module-***-api 模块 【新增】Spring Security 支持读取多种用户类型,从不同的数据库表,从而实现单项目提供管理后台、用户 APP 的不同 RESTful API 接口 【新增】Spring Security 新增 AuthorizeRequestsCustomizer 抽象类, 自定义每个 Maven Module 的 URL 的安全配置 【新增】代码生成器支持多 Maven Module 的方式生成代码,支持管理后台、用户 APP 两种场景的 RESTful API 的生成,支持 H2 SQL 脚本的生成 【新增】每次发布大版本时,将 yudao-ui-admin 编译后,放到 yudao-server 项目中,可以快速体验,无需搭建前端开发环境 【重构】将数据库文档调整到 tool 模块,更加明确 【优化】代码生成器的前端展示效果,例如说 Java 包路径合并 # 🐞 Bug Fixes 【修复】用户无权限访问 指定 API 时,未返回 FORBIDDEN 结果码 【修复】定时任务刷新本地缓存时,无租户上线文,导致查询报错 【修复】配置中心只加载了删除的配置 【修复】管理后台 UI 超时登录后,返回登录界面时,由于未登录加载不到信息,导致报错的问题 # 🔨 Dependency Upgrades 【升级】spring-boot from 2.4.12 to 2.5.9,最新的 Spring Boot 2.6.X 在等更流行一些,稳定第一 【升级】Spring Boot Admin from 2.3.2 to 2.6.2,提供更好的监控能力 【移除】Apache FreeMarker 依赖,修改 Screw 使用 Velocity 作为模板引擎 【升级】redisson from 3.16.6 to 3.16.8 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.5.0】2022-02-17 【v1.3.0】2022.01.24 ← 【v1.5.0】2022-02-17 【v1.3.0】2022.01.24→"},{"title":"【v1.3.0】2022.01.24","path":"/wiki/YuDaoBoot/更新日志/【v1.3.0】2022.01.24/【v1.3.0】2022.01.24.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.3.0】2022.01.24 # 新增工作流的功能 基于 Activiti 7.X 版本实现工作流功能,支持可配置的动态表单、自定义的业务表单。 下个版本会提供基于 Flowable 6.X 版本实现的工作流! # 📈 Statistic 总代码行数:61594 源码代码行数:37931 注释行数:14225 单元测试用例数:278 # ⭐ New Features 【优化】引入 form generator 0.2.0 版本,并重构相关代码 【修改】修改部门负责人,从 String 字符串,调整成和后台用户的用户编号绑定 【新增】流程表单,支持动态进行表单的配置 【新增】工作组,用于支持指定工作组进行任务的审批 【新增】流程模型的管理,支持新增、导入、编辑、删除、发布流程模型 【新增】我的流程的管理,支持发起流程 【新增】待办任务的管理,支持任务的审批通过与不通过 【新增】已办任务的管理,支持详情的查看 【新增】任务分配规则,可指定角色、部门成员、部门负责人、用户、用户组、自定义脚本等维度,进行任务的审批 【新增】引入 bpmn-process-designer 0.0.1 版本,提供流程设计器的能力 【优化】新增 LambdaQueryWrapperX 类,改成使用 Lambda 的方式选择字段,避免手写导致字段不正确 # 🐞 Bug Fixes 【修复】biz-data-permission 组件的缓存机制,导致部分 SQL 未进行数据过滤 【修复】codegen 生成代码时,delete 接口补充 dataTypeClass 属性,避免 Swagger 打印 WARN 日志 【修复】Swagger 文档由于写错 @ApiImplicitParam 注解的 name 和 dataTypeClass 属性,导致文档生成失败 # 🔨 Dependency Upgrades 【升级】redisson from 3.16.3 to 3.16.6,解决 Stream 在调试场景下会存在 NPE 的问题 【升级】spring-boot from 2.4.5 to 2.4.12,最新的 Spring Boot 2.6.X 在等更流行一些,稳定第一 【升级】druid from 1.2.4 to 1.2.8,提升数据库连接池的稳定性 【升级】dynamic-datasource from 3.3.2 to 3.5.0,修复动态数据源切换的问题 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.4.0】2022-02-04 【v1.2.0】2021.12.15 ← 【v1.4.0】2022-02-04 【v1.2.0】2021.12.15→"},{"title":"【v1.5.1】2022-02-28","path":"/wiki/YuDaoBoot/更新日志/【v1.5.1】2022-02-28/【v1.5.1】2022-02-28.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.5.1】2022-02-28 # 优化多租户功能,新增租户套餐,增强多租户封装 创建租户时,自动创建用户、角色等信息 支持租户套餐,自定义每个租户的菜单、操作、按钮等权限信息 # 📈 Statistic 总代码行数:71249 源码代码行数:43921 注释行数:16341 单元测试用例数:341 # ⭐ New Features 【新增】后端 yudao.tenant.enable 配置项,前端 VUE_APP_TENANT_ENABLE 配置项,用于开关租户功能。 commit (opens new window) 【优化】调整默认所有表开启多租户的特性,可通过 yudao.tenant.ignore-tables 配置项进行忽略,替代原本默认不开启的策略 commit (opens new window) 【新增】通过 yudao.tenant.ignore-urls 配置忽略多租户的请求,例如说 ,例如说短信回调、支付回调等 Open API commit (opens new window) 【新增】新增 @TenantIgnore 注解,标记指定方法,忽略多租户的自动过滤,适合实现跨租户的逻辑 commit (opens new window) 【新增】租户套餐的管理,可配置每个租户的可使用的功能权限 commit (opens new window) 【优化】新建租户时,自动创建对应的管理员账号、角色等基础信息 commit (opens new window) 【优化】Redis 最低版本 5.0.0 检测,解决搭建环境过程中无法理解 XREADGROUP 指令的报错 commit (opens new window) # 🐞 Bug Fixes 【修复】修复不支持根部门的问题 commit (opens new window) 【修复】错误码存在重复的问题 commit (opens new window) 【修复】角色的数据范围为仅本人时,登录后获取权限列表报错的问题 commit (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.5.9 to 2.5.10 【升级】mybatis-plus from 3.4.3.4 to 3.5.1 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.0】2022-03-10 【v1.5.0】2022-02-17 ← 【v1.6.0】2022-03-10 【v1.5.0】2022-02-17→"},{"title":"【v1.5.0】2022-02-17","path":"/wiki/YuDaoBoot/更新日志/【v1.5.0】2022-02-17/【v1.5.0】2022-02-17.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.5.0】2022-02-17 # 重构成多 Maven Module 的代码结构 修复各种多 Maven Module 重构带来的 Bug,感谢大量群友的 PR 支持! 跟进 ruoyi-vue 3.4.0 ~ 3.8.1 版本,感谢这么优秀的开源项目! # 📈 Statistic 总代码行数:69299 源码代码行数:42687 注释行数:15888 单元测试用例数:278 # ⭐ New Features 【优化】使用 Lombok 简化 JsonUtils 工具类 #73 (opens new window) 【新增】兼容 Node 16 版本,通过升级 BPMN-JS 相关库 commit (opens new window) 【新增】前端的表格右侧工具栏组件支持显隐列,具体可见【用户管理】功能 commit (opens new window) 【新增】前端的菜单导航显示风格 TopNav(false 为 左侧导航菜单,true 为顶部导航菜单),支持布局的保存与重置 commit1 (opens new window) commit2 (opens new window) 【新增】前端的网页标题支持根据选择的菜单,动态展示标题 commit (opens new window) 【新增】字典标签样式回显,例如说开启的状态展示为 primary 蓝色,禁用的状态为 info 灰色 commit (opens new window) 【新增】前端的 iframe 组件,方便内嵌网页 commit (opens new window) 【新增】在基础设施-配置管理菜单,可通过修改 yudao.captcha.enable 配置项,动态修改登录是否需要验证码 commit (opens new window) 【新增】在代码生成的预览界面,支持一键复制代码 commit (opens new window) # 🐞 Bug Fixes 【修复】数据权限的 DEPT_AND_CHILD 范围时,未设置自己所在的部门 #72 (opens new window) 【修复】Knife4j 接口文档 404 的问题,原因是 spring.mvc.static-path-pattern 配置项,影响了基础路径 commit (opens new window) 【修复】修复文件访问地址错误 #68 (opens new window) 【修复】工作流程发起以及审批异常,由 @NotEmpty 校验、和 Long 类型异常导致 #73 (opens new window) 【修复】自定义 DefaultStreamMessageListenerContainerX 实现,解决 Redisson Stream 读取不到数据返回 null 导致 NPE 问题 commit (opens new window) 【修复】部门更新后,本地缓存不刷新的问题 #77 (opens new window) 【修复】获取拥有指定的角色用户时,返回错误的 id 编号 #79 (opens new window) # 🔨 Dependency Upgrades *【修复】Maven 构建的一些错误提示 #78 (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.5.1】2022-02-28 【v1.4.0】2022-02-04 ← 【v1.5.1】2022-02-28 【v1.4.0】2022-02-04→"},{"title":"【v1.6.1】2022-03-21","path":"/wiki/YuDaoBoot/更新日志/【v1.6.1】2022-03-21/【v1.6.1】2022-03-21.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.6.1】2022-03-21 # 支持 OSS 云存储,优化代码生成 对应 版本 1.6.1 功能列表 (opens new window) # 📈 Statistic 总代码行数:77279 源码代码行数:47812 注释行数:17676 单元测试用例数:537 # ⭐ New Features 【优化】文件存储的功能,支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、SFTP、数据库等 #98 (opens new window) 【新增】《开发文档》的代码生成(新增功能)、功能权限、上传下载等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 【新增】开发环境下,管理后台每个菜单展示对应的《开发文档》的说明 code (opens new window) 【新增】《开发文档》的工作流、代码生成(新增功能)、功能权限、数据权限等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 【优化】将 yudao-module-tool 合并到 yudao-module-infra 模块,统一基础设施 #94 (opens new window) 【优化】代码生成时,额外生成 MyBatis Mapper XML 文件 #96 (opens new window) 【新增】开启 TopNav 时,没有子菜单的情况下,隐藏侧边栏 code (opens new window) # 🐞 Bug Fixes 【修复】仅本人数据权限时,个人中心会报错的问题 #97 (opens new window) 【修复】修改租户套餐的权限时,本地缓存刷新错误的问题 #99 (opens new window) 【修复】删除菜单、角色时,本地缓存未刷新的问题 code (opens new window) 【修复】登录界面输入不存在的租户时,导致后续请求报错的问题 code (opens new window) 【修复】登录超时刷新页面时,跳转登录页面还提示重新登录问题 code (opens new window) # 🔨 Dependency Upgrades 【升级】apollo-client from 1.7.0 to 1.9.2 【升级】guide from 4.1.0 to 5.1.0 :解决 Apollo 在 JDK 17 无法启动的问题 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.2】2022-06-05 【v1.6.0】2022-03-10 ← 【v1.6.2】2022-06-05 【v1.6.0】2022-03-10→"},{"title":"【v1.6.0】2022-03-10","path":"/wiki/YuDaoBoot/更新日志/【v1.6.0】2022-03-10/【v1.6.0】2022-03-10.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.6.0】2022-03-10 # 支持 Flowable 工作流,发布开发文档 基于 Flowable 实现工作流,可见 yudao-module-bpm-impl-flowable (opens new window) 模块。 友情提示:原本 Activiti 实现的工作流,在 yudao-module-bpm-impl-activiti (opens new window) 模块,保持同步更新。 # 📈 Statistic 总代码行数:75008 源码代码行数:46416 注释行数:17132 单元测试用例数:341 # ⭐ New Features 【新增】 yudao-module-bpm-impl-flowable (opens new window) 模块,实现 Flowable 工作流 #88 (opens new window) 【新增】《开发文档》的简介、功能列表、快速启动、技术选型、项目结构、新建模块、SaaS 多租户等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 # 🐞 Bug Fixes 【修复】正常租户登录后退出,切换到过期租户时造成的 tenant.ignore-urls 配置失效的问题,比如无法获取验证码图片造成无法登录 #91 (opens new window) # 🔨 Dependency Upgrades 暂无,计划升级 Spring Boot 2.6.X .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.1】2022-03-21 【v1.5.1】2022-02-28 ← 【v1.6.1】2022-03-21 【v1.5.1】2022-02-28→"},{"title":"【v1.6.2】2022-06-05","path":"/wiki/YuDaoBoot/更新日志/【v1.6.2】2022-06-05/【v1.6.2】2022-06-05.html","content":"开发指南更新日志 芋道源码 2022-03-26 目录 【v1.6.2】2022-06-05 # 新增 OAuth 2.0、SSO 单点登录、多种数据库支持等功能 对应 版本 1.6.2 功能列表 (opens new window) # 📈 Statistic 总代码行数:84846 源码代码行数:52792 注释行数:19234 单元测试用例数:671 # ⭐ New Features 【新增】对 PostgreSQL 数据库的支持 #151 (opens new window) 感谢这个过程中怪物的帮助! 【新增】对 Oracle 数据库的支持 #152 (opens new window) 感谢这个过程中 安贞 (opens new window)、品霖的帮助! 【新增】对 SQL Server 数据库的支持 #153 (opens new window) 感谢这个过程中 Simon、蜉蝣无垠、牛希尧的帮助! 【新增】《开发指南 —— 后端手册》的接口文档、三方登录、异常处理(错误码)、参数校验、分页实现、系统日志、数据库 MyBatis、多数据源、缓存 Redis、本地缓存、定时任务、消息队列、配置中心、单元测试、分布式锁、幂等性、限流熔断、数据库文档、短信配置、开发环境... 【新增】《开发指南 —— 运维手册》的开发环境、Linux 部署、Docker 部署、Jenkins 部署、HTTPS 证书、服务监控... 【新增】《开发指南 —— 前端手册》的开发规范、菜单路由、Icon 图标、字典数据、系统组件、通用方法、配置读取... 【新增】手机验证码登录,美化登录界面,由 #155 (opens new window) 贡献 【新增】一键改包的程序,快速将项目的 Maven、包名等信息替换成你的 #110 (opens new window) 【新增】菜单新增是否缓存、是否隐藏的字段 #133 (opens new window) #172 (opens new window) 【新增】Spring Cache 声明式缓存,使用 Redis 存储 code (opens new window) 【新增】腾讯云短信,由 swpthebest (opens new window) 贡献 #118 (opens new window) 【新增】敏感词,由 dachuan 贡献 #121 (opens new window) 【新增】数据源配置,为多租户、代码生成支持动态数据源做准备 #138 (opens new window) 【新增】用户 Token 采用 OAuth2.0 的 Access Token + Refresh Token,提升安全性 #166 (opens new window) 【新增】基于 OAuth2.0 实现 SSO 单点登录 #176 (opens new window) 【新增】用户与岗位的关联表,由 anzhen-tech (opens new window) 贡献 #113 (opens new window) 【新增】MyBatis 字段的加解密功能 code (opens new window) 【新增】集成微信 Native、小程序的支付能力,支持 v2 和 v3 的回调数据处理 #142 (opens new window) 【优化】yudao-module-xx-impl 调整成 yudao-module-xx-biz,更加符合定位 code (opens new window) 【优化】简化三方登录的实现,降低理解成本 #137 (opens new window) 【优化】去除 yudao-module-system、yudao-module-infra 对 yudao-module-member 的依赖 #122 (opens new window) 【优化】yudao-framework-test 测试组件的封装,内置 Redis、DB 等多种快速测试的基类 code (opens new window) 【优化】配置指定默认的 npm 镜像源 #170 (opens new window) 【优化】字典管理、通知管理、岗位管理、角色管理、错误码管理的排序显示 #174 (opens new window) 【优化】前端 Token、账号、密码等信息,统一使用 LocalStorage 替代 Cookie 存储 code (opens new window) 【优化】上传文件的类型识别,增加基于 filename 的读取 code (opens new window) # 🐞 Bug Fixes 【修复】角色菜单集合复选框回显不正确 #107 (opens new window) 【修复】工作流 BPMN 图的 canvas 自适应,解决展示补全的问题 #104 (opens new window) 【修复】API 访问日志不记录的问题 code (opens new window) 【修复】修复忽略租户的 URL,未带租户会报错的问题 code (opens new window) 【修复】菜单无法使用外链的问题 code (opens new window) 【修复】代码生成器的 vue 模板中,导出 Excel 文件时,文件名未格式化的问题 #133 (opens new window) 【修复】代码生成时,对话框的日期选择器,在编辑情况下不能回显 #135 (opens new window) 【修复】在 Windows 下 ftp 上传和下载存在报错的问题 #156 (opens new window) 【修复】图片上传组件 ImageUpload 上传报错的问题 code (opens new window) 【修复】文件上传组件 FileUpload 上传报错的问题 code (opens new window) 【修复】form generator 组件上传文件、图片报错的问题 code (opens new window) 【修复】富文本编辑器的 Editor 的图片上传报错的问题 code (opens new window) 【修复】DO 生成模板,当主键是 String 类型,模板有误 #167 (opens new window) 【修复】创建用户不分配角色的情况会存在空指针 #171 (opens new window) 【修复】yudao-ui-admin 启动告警 #173 (opens new window) 【修复】新建的用户未分配角色时,操作自己信息回报错的问题 code (opens new window) 【修复】工作流的编辑无法撤回、crtl 选中的问题 code (opens new window) 【修复】支付宝通知回调 BUG 修复 #142 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.5.10 to 2.6.8 :修复 RCE 漏洞,并且 2.5.X 结束声明周期 【升级】redisson from 3.16.6 to 3.17.3 :提升 Redisson 客户端的稳定性 【升级】mysql-connector-java from 5.1.46 to 8.0.28 :提升 MySQL 客户端的性能 【升级】Knife4j from from 3.0.2 to 3.0.3 【升级】swagger-annotations from 1.5.22 to 1.6.6 【升级】spring-boot-admin from 2.6.2 to 2.6.7 【升级】fastjson from 1.2.73 to 2.0.5 【升级】resilience4j from 1.7.0 to 1.7.1 【升级】jackson from 2.12.6 to 2.13.3 【升级】spring-mvc from 5.3.16 to 5.3.20 【升级】spring-security from 5.5.5 to 5.6.5 【升级】hibernate-validator from 6.2.2 to 6.2.3 【升级】junit from 5.7.2 to 5.8.2 【升级】mockito from 3.9.0 to 4.0.0 【升级】mybatis-plus from 3.4.3.4 to 3.5.2 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.3】2022-07-29 【v1.6.1】2022-03-21 ← 【v1.6.3】2022-07-29 【v1.6.1】2022-03-21→"},{"title":"【v1.6.4】2022-08-22","path":"/wiki/YuDaoBoot/更新日志/【v1.6.4】2022-08-22/【v1.6.4】2022-08-22.html","content":"开发指南更新日志 芋道源码 2022-08-22 目录 【v1.6.4】2022-08-22 # 新增 uniapp 管理后台、报表设计器 # 📈 Statistic 总代码行数:87565 源码代码行数:54279 注释行数:19868 单元测试用例数:671 # ⭐ New Features 【新增】完善 Vue3 管理后台的工作流实现,由 @xingyu4j (opens new window) 贡献 #238 【新增】管理后台的移动端 yudao-ui-admin-uniapp 项目,采用 uni-app (opens new window) 方案,一份代码多终端适配,同时支持 APP、小程序、H5!#247 (opens new window) 【新增】集成积木报表,提供低代码报表设计器,由 @jiangqiang1996 (opens new window) 贡献 #237 (opens new window) 【新增】接入支付宝 PC 网站支付,由 @jiangqiang1996 (opens new window) 贡献 #240 (opens new window) 【优化】项目的启动速度,控制在 30 秒左右,默认不启动 bpm、visualization 模块 【优化】管理后台的弹窗支持滚动、拖拽,并点击背景布关闭,避免误操作,由 @颗粒 (opens new window) 贡献 #253 (opens new window) 【优化】一键改包,如果目标目录已存在,则不进行生成,由 @C (opens new window) 贡献 #229 (opens new window) # 🐞 Bug Fixes 【修复】Redis 7.0 监控查询 calls 数值超过 Integer 范围的异常,由 @lanyue52011 (opens new window) 贡献 #239 (opens new window) 【修复】前端表单设计器中动态数据,不能正常获取和更深层级的赋值错误的情况,由 @CorrectRoadH (opens new window) 贡献 #256 (opens new window) 【修复】代码生成功能中,点击同步,会清除已添加并存在的字段,由 @xrcoder (opens new window) 贡献 #249 (opens new window) 【修复】工作流与积木报表的依赖冲突,将 xercesImpl 升级到 2.12.0 版本,由 @shihy (opens new window) 贡献 #254 (opens new window) # 🔨 Dependency Upgrades 暂无 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.5】2022-12-01 【v1.6.3】2022-07-29 ← 【v1.6.5】2022-12-01 【v1.6.3】2022-07-29→"},{"title":"【v1.7.0】2023-01-30","path":"/wiki/YuDaoBoot/更新日志/【v1.7.0】2023-01-30/【v1.7.0】2023-01-30.html","content":"开发指南更新日志 芋道源码 2023-01-07 目录 【v1.7.0】2023-01-30 # 增加微信公众号的接入、邮箱、站内信、数据脱敏 # 📈 Statistic 总代码行数:119925 源码代码行数:73678 注释行数:27769 单元测试用例数:674 # ⭐ New Features 【新增】微信公众号功能,包括账号管理、数据统计、粉丝管理、消息管理、自动回复、标签管理、菜单管理、素材管理、图文草稿箱、图文发表记录,由 @芋道源码 (opens new window) 贡献 #382 (opens new window) 【新增】RESTful API 返回数据时,支持数据脱敏,由 @与或非 (opens new window) 贡献 #372 (opens new window) 【新增】邮箱功能:邮箱账号、邮件模版、邮件发送记录,由 @芋道源码 (opens new window) 贡献 #385 (opens new window) 【新增】站内信功能:站内信模版、站内信消息,由 @圆梦巨人 (opens new window)、@xrcoder (opens new window) 贡献 #385 (opens new window) 【新增】Vue3 管理后台新增 WebSocket 连接测试,由 @xingyu4j (opens new window) 贡献 #379 (opens new window) 【新增】配置 yaml 文件中自定义属性的提示,由 @与或非 (opens new window) 贡献 #373 (opens new window) 【优化】重构 Vue3 管理后台的路由代码生成逻辑,优化性能,由 @xingyu4j (opens new window) 贡献 #375 (opens new window) 【优化】Vue3 管理后台的第一次进入加载速度,由 @xingyu4j (opens new window) 贡献 #381 (opens new window) 【新增】Vue3 管理后台基于 unplugin-auto-import 实现自动导入,由 @xingyu4j (opens new window) 贡献 #376 (opens new window) 【优化】重构滑块验证码 captcha 的实现,由 @xingyu4j (opens new window) 贡献 #374 (opens new window) #376 (opens new window) 【优化】简化本地缓存的实现,优化 《后端手册 —— 本地缓存》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 #382 (opens new window) 【优化】代码生成列表的加载速度,由 @与或非 (opens new window) 贡献 #378 (opens new window) 【新增】《后端手册 —— 验证码》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 【新增】《后端手册 —— 数据脱敏》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 【新增】《公众号手册》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 # 🐞 Bug Fixes 【修复】积木报表:部分请求会报错:JmReportTokenServices 实现类 getUsername 方法返回值不允许为空,由 @与或非 (opens new window) 贡献 #358 (opens new window) 【修复】积木报表:分享报错,由 @与或非 (opens new window) 贡献 #357 (opens new window) 【修复】积木报表:API数据集解析时,提示数据为空,报表字段明细会被清空,由 @与或非 (opens new window) 贡献 #359 (opens new window) 【修复】yudao-ui-appi 的 refreshToken is not a function 问题修复,由 @chaining (opens new window) 贡献 #356 (opens new window) 【修复】Vue2 管理后台 Redis 监控 echarts 图表不显示,由 @zy_2021 (opens new window) 贡献 #354 (opens new window) 【修复】MyBatis Plus 升级导致 generatorTest 用例找不到对象爆红,由 @miozus (opens new window) 贡献 #365 (opens new window) 【修复】代码生成器读取不到 dataType 属性,导致无法正确生成代码,由 @与或非 (opens new window) 贡献 #370 (opens new window) 【修复】Xss 启用后,编辑器上传图片错误,由 @与或非 (opens new window) 贡献 #361 (opens new window) #383 (opens new window) 【修复】管理后台 uniapp 的令牌过期时,无法刷新令牌的 bug,由 @chaining (opens new window) 贡献 #360 (opens new window) 【修复】获取菜单返回了不可修改集合,导致无法排序的报错,由 @ambi (opens new window) 贡献 #371 (opens new window) 【修复】Vue2 管理后台的 tags 页签超过屏幕后,无法滚动导致无法选择后面的页签,由 @zhang.xionghui (opens new window) 贡献 #366 (opens new window) # 🔨 Dependency Upgrades 【升级】mybatis-plus from 3.5.3 to 3.5.3.1 【升级】spring-security from 3.7.5 to 3.7.6 【升级】spring-boot-admin from 2.7.9 to 2.7.10 【升级】minio from 8.4.6 to 8.5.1 【升级】knife4j from 3.0.3 to 4.0.0 【升级】vxe-table from 4.3.7 to 4.3.9 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.7.1】2023-03-05 【v1.6.6】2023-01-05 ← 【v1.7.1】2023-03-05 【v1.6.6】2023-01-05→"},{"title":"【v1.6.3】2022-07-29","path":"/wiki/YuDaoBoot/更新日志/【v1.6.3】2022-07-29/【v1.6.3】2022-07-29.html","content":"开发指南更新日志 芋道源码 2022-07-29 目录 【v1.6.3】2022-07-29 # 工作流支持会签或签、新增 Vue3 管理后台 # 📈 Statistic 总代码行数:81410 源码代码行数:50413 注释行数:30977 单元测试用例数:671 # ⭐ New Features 【新增】基于 Vue3 + ElementUI Plus 实现 yudao-ui-admin-vue3 (opens new window) 管理后台项目,已完成系统管理 + 基础设施等功能,工作流正在实现中,主要由 @xingyu4j (opens new window) 贡献 【新增】工作流支持会签、或签,可自定义任务分配方式 #212 (opens new window) 【新增】接口支持通过 @PermitAll 注解,允许匿名(未登录)进行访问 d9c2da7 (opens new window) 【新增】yudao.security.permit-all-urls 配置项,允许匿名(未登录)进行访问 d9c2da7 (opens new window) 【新增】Redis 缓存的查询与删除 由 @lwf_org (opens new window) 贡献 #211 (opens new window) 【优化】文件表增加 name 字段,记录上传的文件名,由 @manning233 (opens new window) 贡献 #186 (opens new window) 【优化】基于 Guava 实现 dict 字典数据的本地缓存 d320091 (opens new window) 【优化】基于 Guava 实现 tenant 租户数据的本地缓存 992e205 (opens new window) 【重构】新增 yudao-spring-boot-starter-biz-error-code 错误码组件,用于错误码的自动创建与加载 7a86a61 (opens new window) 【重构】新增 yudao-spring-boot-starter-banner 组件,用于项目启动时打印开发文档、接口文档等 69a3a83 (opens new window) 【新增】yudao.access-log.enable 访问日志的开关,默认在 local 环境关闭记录访问日志 9040b17 (opens new window) 【新增】yudao.error-code.enable 错误码的开关,默认在 local 环境关闭自动生成错误码 cca8375 (opens new window) 【新增】集成 Prometheus 监控点 4dfa816 (opens new window) 【移除】去除 Activiti 工作流的支持,专注提供基于 Flowable 提供更强大的工作流能力 【重构】时间区间的过滤条件,从开始和结束时间两个变量,修改为数组,由 @xingyu4j (opens new window) 贡献 dad10d8 (opens new window) # 🐞 Bug Fixes 【修复】流程审批不通过会报错的问题,由 @wzy_lc (opens new window) 贡献 #215 (opens new window) 【修复】Spring Boot Admin 的 prefer-ip 过期,由 @xingyu4j (opens new window) 贡献 63877cf (opens new window) 【修复】环境 test、stage、stage、prod 不打印日志的问题 8a6c48f (opens new window) 【修复】短信验证码的每日发送条数不正确 e5a7b84 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.6.8 to 2.6.10 【升级】hutool from 5.6.1 to 5.7.22 【升级】druid from 1.2.8 to 1.2.11 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.4】2022-08-22 【v1.6.2】2022-06-05 ← 【v1.6.4】2022-08-22 【v1.6.2】2022-06-05→"},{"title":"【v1.6.6】2023-01-05","path":"/wiki/YuDaoBoot/更新日志/【v1.6.6】2023-01-05/【v1.6.6】2023-01-05.html","content":"开发指南更新日志 芋道源码 2023-01-01 目录 【v1.6.6】2023-01-05 # 完善 Vue3 管理后台,新增 IP & 地区库 # 📈 Statistic 总代码行数:104298 源码代码行数:63656 注释行数:24708 单元测试用例数:602 # ⭐ New Features 【新增】yudao-spring-boot-starter-biz-ip (opens new window) 业务组件,提供地区 & IP 库的封装,由 @WangLH (opens new window) 贡献 0b5aa56 (opens new window) 【新增】《后端手册 —— 地区 & IP 库》 (opens new window) 文档 【新增】《后端手册 —— 敏感词》 (opens new window) 文档 【新增】《前端手册 Vue 3.x》 (opens new window) 文档 【优化】本地缓存的刷新实现,数据变更时,强制刷新,贡献 #3443aa6 (opens new window) 【新增】Vue3 XTable 组件,由 @xingyu4j (opens new window) 贡献 #349 (opens new window) 【优化】优化 Vue3 管理后台实现,由 @xingyu4j (opens new window) 贡献 #317 (opens new window) #322 (opens new window) #331 (opens new window) #335 (opens new window) #339 (opens new window) #343 (opens new window) 【优化】完善 Vue3 上传组件 && 提升打包速度,由 @xingyu4j (opens new window) 贡献 #337 (opens new window) 【重构】Vue3 头像上传,由 @xingyu4j (opens new window) 贡献 #338 (opens new window) 【新增】WebSocket 连接测试,由 @咱哥丶 (opens new window) 贡献 #348 (opens new window) # 🐞 Bug Fixes 【修复】字典类型逻辑删除时,唯一索引冲突的问题,由 @tangkc123 (opens new window) 贡献 #323 (opens new window) 【修复】pay 模块提交退款申请时,重复设置属性,由 @qshome (opens new window) 贡献 #325 (opens new window) 【修复】修改pay 模块创建支付单时,错误返回订单编号,由 @qshome (opens new window) 贡献 #324 (opens new window) 【修复】修改 pay 模块在微信支付时,支付过期时间格式化异常 (yyyy-MM-ddTHH:mm:ssXXX),由 @qshome (opens new window) 贡献 #329 (opens new window) 【修复】数据权限 SQL 存在多个表达式时,缺少括号问题,由 @与或非 (opens new window) 贡献 #328 (opens new window) 【修复】yudao-ui-admin-vue3 面包屑导航图标和文字不在同一水平线,由 @supine-win (opens new window) 贡献 #333 (opens new window) 【修复】yudao-module-system-api 的 ErrorCodeConstants 中错误码重复的问题,由 @王添翼 (opens new window) 贡献 #340 (opens new window) 【修复】DeptService 的 getDeptsByParentIdFromCache 在获取部门列表时,未处理多租户场景,贡献 #75b3a29 (opens new window) 【修复】前端 FileUpload 文件上传时,code 未使用 0 判断成功,由 @plimlips (opens new window) 贡献 #344 (opens new window) 【修复】Redis Stream 消息队列在重启 Java 进程时,由于 Consumer 未释放消息,导致消息丢失的问题,由 @与或非 (opens new window) 贡献 #332 (opens new window) 【修复】腾讯 COS 异常,Region 必传,由 @与或非 (opens new window) 贡献 #347 (opens new window) 【修复】DB 存储文件时,读取可能报错的问题,由 @与或非 (opens new window) 贡献 #346 (opens new window) 【修复】没有数据权限时,添加/修改用户的唯一手机、账号等字段的校验不正确,贡献 7912a54 (opens new window) 【修复】配置管理,配置是否可见判断写反了,由 @kinlon92 (opens new window) 贡献 #350 (opens new window) 【修复】上传视频无法预览,由 @与或非 (opens new window) 贡献 #352 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.7.6 to 2.7.7 【升级】mybatis-plus from 3.5.2 to 3.5.3 【升级】dynamic-datasource from 3.6.0 to 3.6.1 【升级】flowable from 6.7.2 to 6.8.0 【升级】lock4j from 2.2.2 to 2.2.3 【升级】podam from 7.2.9 to 7.2.11 【升级】jedis-mock from 1.0.4 to 1.0.5 【升级】transmittable-thread-local from 2.14.0 to 2.14.2 【升级】netty-all from 4.1.82 to 4.1.86 【升级】aliyun-java-sdk-core from 4.6.2 to 4.6.3 【升级】tencentcloud-sdk-java from 3.1.635 to 3.1.660 【升级】spring-boot-admin from 2.7.7 to 2.7.9 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.7.0】2023-01-30 【v1.6.5】2022-12-01 ← 【v1.7.0】2023-01-30 【v1.6.5】2022-12-01→"},{"title":"【v1.7.3】开发中","path":"/wiki/YuDaoBoot/更新日志/【v1.7.3】开发中/【v1.7.3】开发中.html","content":"开发指南更新日志 芋道源码 2023-04-22 目录 【v1.7.3】开发中 # # 📈 Statistic 总代码行数: 源码代码行数: 注释行数: 单元测试用例数: # ⭐ New Features 【重构】Vue3 管理后台:公众号 MP 模块重构,功能增强,由 @dhb52 (opens new window) 贡献 #135 (opens new window) 【新增】Vue3 管理后台:菜单管理:添加刷新菜单缓存按钮,由 @puhui999 (opens new window) 贡献 #134 (opens new window) 【优化】Vue3 管理后台:升级 Vite 4.3.1,升级其它依赖,由 @xingyu4j (opens new window) 贡献 #53b6f0b (opens new window) # 🐞 Bug Fixes 【修复】代码生成:Vue3 标准模板缺少 baseURL 的格式化,由 @baayso (opens new window) 贡献 #462 (opens new window) 【修复】新建商品时商品分类状态判断错误,由 @LiZhongShi (opens new window) 贡献 #459 (opens new window) 【修复】缺少 ServletUtils 引用,由 @inypeacock (opens new window) 贡献 #461 (opens new window) 【修复】一键改包的”占位“文件影响改包工具运行,由 @anzhen-tech (opens new window) 贡献 #458 (opens new window) 【修复】尝试修复项目第一次打包失败报 Failed to execute goal org.apache.maven.plugins:maven-jar-plugin:3.3.0:jar,由 @芋道源码 (opens new window) 贡献 #91f63ff (opens new window) # 🔨 Dependency Upgrades .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:33 IDE 调试 【v1.7.2】2023-04-19 ← IDE 调试 【v1.7.2】2023-04-19→"},{"title":"【v1.7.1】2023-03-05","path":"/wiki/YuDaoBoot/更新日志/【v1.7.1】2023-03-05/【v1.7.1】2023-03-05.html","content":"开发指南更新日志 芋道源码 2023-01-30 目录 【v1.7.1】2023-03-05 # 新增 Vue3 管理后台支持工作流、大屏设计器,升级 OpenAPI 3.0 接口文档 # 📈 Statistic 总代码行数:126673 源码代码行数:78532 注释行数:28594 单元测试用例数:782 # ⭐ New Features 【重构】Vue3 管理后台调整到 GitHub (opens new window)、Gitee (opens new window) 地址,逐步分离前端和后端仓库,保证 Git commit 日志的整洁! 【新增】Vue3 工作流的,由 @周建 (opens new window)、@xingyu4j (opens new window) 贡献 #397 (opens new window)、#401 (opens new window)、#407 (opens new window)、#6 (opens new window)、#7 (opens new window)、#12 (opens new window)、#14 (opens new window) 【新增】基于 Go-View 共建大屏设计器,支持 Vue2 和 Vue3 管理后台,由 @芋道源码 (opens new window) 贡献 #403 (opens new window) 【新增】支付收银台,接入支付宝的 PC、Wap、二维码、条码、App 等支付方式,由 @芋道源码 (opens new window) 贡献 #403 (opens new window) 【新增】接口文档使用 OpenAPI 3.0 实现,@xingyu4j (opens new window) 贡献 #380 (opens new window) 【优化】菜单新增 alwaysShow 总是展示、componentName 组件名,由 @芋道源码 (opens new window) 贡献 #408 (opens new window) 【优化】system 模块的 Service 逻辑单元测试,单测数量 423,方法行覆盖率 95%,行覆盖率 93%,由 @芋道源码 (opens new window) 贡献 #392 (opens new window) 【优化】infra 模块的 Service 逻辑单元测试,单测数量 81,方法行覆盖率 63%,行覆盖率 47%,由 @芋道源码 (opens new window) 贡献 #393 (opens new window) 【优化】清理单元测试多余的 SQL 脚本,由 @niu_dehua (opens new window) 贡献 #345 (opens new window) 【优化】《后端手册 —— 快速启动》 (opens new window)文档,由 @芋道源码 (opens new window) 贡献 【优化】解决 Vue2 管理后台,只有一个菜单时,不展父菜单/目录的情况,由 @zhang.xionghui (opens new window) 贡献 #394 (opens new window) 【优化】缓存部门的变量命名,由 @重楼 (opens new window) 贡献 #421 (opens new window) 【新增】《萌新必读 —— 快速启动(我是前端)》 (opens new window) 文档,适合前端同学启动前端项目 # 🐞 Bug Fixes 【修复】Vue3 管理后台的tagViews 左右两侧按钮不能垂直居中的问题,由 @AKING (opens new window) 贡献 #406 (opens new window) 【修复】项目启动,链接数据查询时控制台报错 SQLNonTransientConnectionException 异常,由 @zhang (opens new window) 贡献 #406 (opens new window) 【修复】Redis Pub/Sub 广播消费的容器,默认未启动的问题,由 @筱龙缘 (opens new window) 贡献 #415 (opens new window) 【修复】MySQL 连接为 Asia/Shanghai 本地时区,由 @小桂子 (opens new window) 贡献 #409 (opens new window) #410 (opens new window) 【修复】代码生成器的同步报错问题,由 @Rex (opens new window) 贡献 #413 (opens new window) 【修复】登录选择钉钉等第三方弹窗后,点击取消弹窗后恢复登录按钮 loading 状态,由 @thisliuyang (opens new window) 贡献 #217 (opens new window) 【修复】去掉 Swagger 自动配置类中的冗余配置,由 @zhangxingjia (opens new window) 贡献 #424 (opens new window) 【修复】用户详情不显示所属部门部门,由 @babylazsss (opens new window) 贡献 #424 (opens new window) 【修复】GitHub Action 自动 build 前端报错的问题,由 @六楼的雨 (opens new window) 贡献 #424 (opens new window) 【修复】Vue3 管理后台:新增”字典类型“的时候,字典类型的必填校验不通过,由 @六楼的雨 (opens new window) 贡献 #1 (opens new window) 【修复】Vue3 管理后台:字典点击表格红色报错修改;keepalive 缓存 toCamelCase 设置中去掉 ‘-’,保留驼峰命名;新增 Search 组件新增插槽传递;topActionSlots: false 报错修改;tagsView.ts 删除页面缓存优化;,由 @毕梅 (opens new window) 贡献 #2 (opens new window) 【修复】Vue3 管理后台:部分逻辑的规范代码(eslint),由 @孔思宇 (opens new window) 贡献 #4 (opens new window) 【修复】Vue3 管理后台:build script 增加内存配置(解决 nodejs 默认配置内存溢出),由 @孔思宇 (opens new window) 贡献 #5 (opens new window) 【修复】Vue3 管理后台:分配角色的权限 el-tree 组件 setCheckedKeys 设置一旦选中父级子级也被选中,由 @当时明月在 (opens new window) 贡献 #8 (opens new window) 【修复】Vue3 管理后台:XTable 中主题颜色不跟随项目主体一起切换,由 @毕梅 (opens new window) 贡献 #12 (opens new window) 【修复】Vue3 管理后台:角色提交问题修改;XTable var 修改,由 @毕梅 (opens new window) 贡献 #16 (opens new window) 【修复】Vue3 管理后台:Vite 由于 optimize.ts 缺少部门文件,导致二次 reload 的问题,由 @毕梅 (opens new window) 贡献 #19 (opens new window) 【修复】Vue3 管理后台:系统管理中 id 显示序号bug,由 @周建 (opens new window) 贡献 #18 (opens new window) 【修复】Vue3 管理后台:字典标签渲染问题不正确,由 @puhui999 (opens new window) 贡献 #15 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.7.7 to 2.7.8 【升级】easy-excel from 3.1.5 to 3.2.0 【升级】captcha-plus from 1.0.1 to 1.0.2 【升级】jedis-mock from 1.0.5 to 1.0.6 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/07, 20:14:22 【v1.7.2】2023-04-19 【v1.7.0】2023-01-30 ← 【v1.7.2】2023-04-19 【v1.7.0】2023-01-30→"},{"title":"一键改包","path":"/wiki/YuDaoBoot/萌新必读/一键改包/一键改包.html","content":"开发指南萌新必读 芋道源码 2022-03-27 目录 一键改包 项目提供了 ProjectReactor (opens new window) 程序,支持一键改包,包括 Maven 的 groupId、artifactId、Java 的根 package、前端的 title、数据库的 SQL 配置、应用的 application.yaml 配置文件等等。效果如下图所示: 友情提示:修改包名后,未来合并最新的代码可能会有一定的成本。 # 👍 相关视频教程 08、如何实现一键改包? (opens new window) # 操作步骤 ① 第一步,使用 IDEA (opens new window) 克隆 https://github.com/YunaiV/ruoyi-vue-pro (opens new window) 仓库的最新代码,并给该仓库一个 Star (opens new window)。 ② 第二步,打开 ProjectReactor 类,填写 groupIdNew、artifactIdNew、packageNameNew、titleNew 属性。如下图所示: ③ 第三步,执行 ProjectReactor 的 #main(String[] args) 方法,它会基于当前项目,复制一个新项目到 projectBaseDirNew 目录,并进行相关的改名逻辑。 13:02:36.765 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][开始获得需要重写的文件]13:02:41.530 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][需要重写的文件数量:2825,预计需要 5-10 秒]13:02:45.799 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][重写完成] ④ 第四步,使用 IDEA 打开 projectBaseDirNew 目录,参考 《开发指南 —— 快速启动》 文档,进行项目的启动。注意,一定要重新执行 SQL 的导入!!! 整个过程非常简单,如果碰到问题,请添加项目的技术交流群。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 19:00:28 代码热加载 删除功能 ← 代码热加载 删除功能→"},{"title":"代码热加载","path":"/wiki/YuDaoBoot/萌新必读/代码热加载/代码热加载.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 代码热加载 在日常开发中,我们需要经常修改 Java 代码,手动重启项目,查看修改后的效果。如果在项目小时,重启速度比较快,等待的时间是较短的。但是随着项目逐渐变大,重启的速度变慢,等待时间 1-2 min 是比较常见的。 这样就导致我们开发效率降低,影响我们的下班时间,哈哈哈~ 那么是否有方式能够实现,在我们修改完 Java 代码之后,能够不重启项目呢?答案是有的,通过 代码热加载 的方式。实现方案有三种: spring-boot-devtools【不推荐】 IDEA 自带 HowSwap 功能【推荐】 JRebel 插件【最推荐】 # 1. spring-boot-devtools spring-boot-devtools (opens new window) 是 Spring Boot 提供的开发者工具,它会监控当前应用所在的 classpath 下的文件发生变化,进行自动重启。 devtools 存在重启速度较慢的问题,所以不推荐! # 2. IDEA 自带 HowSwap 功能 该功能是 IDEA Ultimate 旗舰版的专属功能,不支持 IDEA Community 社区版。 # 2.1 如何使用 ① 设置 Spring Boot 启动类,开启 HotSwap 功能。如下图所示: ② Debug 运行该启动类,等待项目启动完成。 ③ 每次修改 Java 代码后,点击左下角的「热加载」按钮,即可实现代码热加载。如下图所示: # 2.2 存在问题 IDEA 自带 HowSwap 功能,支持比较有限,很多修改都不支持。例如说: 只能增加方法或字段但不可以减少方法或字段 只能增加可见性不能减少 只能维持已有方法的签名而不能修改等等。 你可以认为,只支持方法内的代码修改热加载。 如果想要相对完美的方案,建议使用 JRebel 插件。 # 3. JRebel 插件【最推荐】 JRebel 插件是目前最好用的热加载插件,它支持 IDEA Ultimate 旗舰版、Community 社区版。 # 3.1 如何安装 ① 点击 https://plugins.jetbrains.com/plugin/4441-jrebel-and-xrebel/versions (opens new window) 地址,必须下载 2022.4.1 版本。如下图所示: ② 打开 [Preference -> Plugins] 菜单,点击「Install Plugin from Disk...」按钮,选择刚下载的 JRebel 插件的压缩包。如下图所示: 安装完成后,需要重启 IDEA 生效。 ③ 打开 [Preference -> JRebel & XRebel] 菜单,输入 GUID address 为 https://jrebel.qekang.com/1e67ec1b-122f-4708-87d0-c1995dc0cdaa ,邮件随便写,完成 JRebel 的激活。如下图所示: 之后,点击「Work Offline」按钮,设置 JRebel 为离线,避免因为网络问题导致激活失效。如下图所示: # 3.2 如何使用 ① 点击「Debug With JRebel」按钮,使用 JRebel 启动项目。如下图所示: ② 每次修改 Java 代码后,点击左下角的「热加载」按钮,即可实现代码热加载。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/06, 01:37:36 项目结构 一键改包 ← 项目结构 一键改包→"},{"title":"交流群","path":"/wiki/YuDaoBoot/萌新必读/交流群/交流群.html","content":"开发指南萌新必读 芋道源码 2022-03-11 目录 交流群 # 🐱 反馈交流 如果有问题,可以通过 Gitee Issue (opens new window) 或者 Github Issue (opens new window) 进行反馈。 欢迎加入用户交流群,一起苦练技术基本功,每日精进 30 公里。 如果微信提示“提示对方被加好友过于频繁,请稍后再试?”,可以过一会再尝试下!🙂 项目关注和使用的人太多了~ .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 20:54:45 简介 视频教程 ← 简介 视频教程→"},{"title":"【v1.7.2】2023-04-19","path":"/wiki/YuDaoBoot/更新日志/【v1.7.2】2023-04-19/【v1.7.2】2023-04-19.html","content":"开发指南更新日志 芋道源码 2023-03-06 目录 【v1.7.2】2023-04-19 # 重构 Vue3 管理后台,提升易用性、稳定性 # 📈 Statistic 总代码行数:125001 源码代码行数:77128 注释行数:28642 单元测试用例数:789 # ⭐ New Features 【新增】《代码热加载》 (opens new window) 文档,提升开发效率。 【新增】Vue 管理后台:优化 VSCode 代码 Debugger 调试,使用 VSCode 自带的功能,由 @puhui999 (opens new window) 贡献 #117 (opens new window) 【新增】代码生成时,增加 UI 类型的选择,可生成 Vue2、Vue3 多种管理后台的代码,支持 CRUD Schema 模式,由 @芋道源码 (opens new window) 贡献 #453 (opens new window) 【新增】代码生成器,支持 VBEN 管理后台,由 @xingyu (opens new window) 贡献 #454 (opens new window) 【优化】Vue3 管理后台:去除 BPMNJS、FormCreate、Highlight 的全局引入,降低打包后的大小(6.6M -> 1.3M),由 @芋道源码 (opens new window) 贡献 #128 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 配置管理] 由 @芋道源码 (opens new window) 贡献 #24 (opens new window) 【重构】Vue3 管理后台:[SSO 登录] 由 @puhui999 (opens new window) 贡献 #107 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 数据源配置] 由 @xiaowuye (opens new window) 贡献 #25 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 通知公告] 由 @babylazsss (opens new window) 贡献 #26 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 文件管理] 由 @xiaowuye (opens new window) 贡献 #29 (opens new window)、#28 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 字典管理] 由 @Theo (opens new window) 贡献 #38 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 错误码管理] 由 @kinlon92 (opens new window) 贡献 #39 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 岗位管理] 由 @Chika (opens new window) 贡献 #44 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 登录日志] 由 @lour6498 (opens new window) 贡献 #41 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 客户端管理] 由 @yj441106 (opens new window) 贡献 #60 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 错误日志] 由 @oldBaby (opens new window) 贡献 #43 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 访问日志] 由 @oldBaby (opens new window) 贡献 #48 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 代码生成] 由 @xiaowuye (opens new window) 贡献 #68 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 定时任务] 由 @孔思宇 (opens new window) 贡献 #65 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 租户管理] 由 @东方白 (opens new window) 贡献 #40 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 租户套餐] 由 @puhui999 (opens new window) 贡献 #77 (opens new window)、#75 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 短信管理] 由 @puhui999 (opens new window) 贡献 #45 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 部门管理] 由 @凌太虚 (opens new window) 贡献 #36 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 敏感词管理] 由 @syd (opens new window) 贡献 #55 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 菜单管理] 由 @Theo (opens new window) 贡献 #54 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 用户管理] 由 @fessor (opens new window) 贡献 #67 (opens new window)、#76 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 角色管理] 由 @Chika (opens new window) 贡献 #63 (opens new window)、#85 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 站内信消息] 由 @咱哥丶 (opens new window) 贡献 #53 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 站内信消息] 由 @咱哥丶 (opens new window) 贡献 #53 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 账号管理] 由 @kinlon92 (opens new window) 贡献 #49 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 标签管理] 由 @矿泉水 (opens new window) 贡献 #50 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 数据统计] 由 @kinlon92 (opens new window) 贡献 #69 (opens new window)、#72 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 粉丝管理] 由 @dhb52 (opens new window) 贡献 #103 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 消息管理] 由 @&wxr (opens new window) 贡献 #58 (opens new window)、#70 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 图文草稿箱] 由 @dhb52 (opens new window) 贡献 #102 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 素材管理] 由 @dhb52 (opens new window) 贡献 #105 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 自动回复] 由 @dhb52 (opens new window) 贡献 #110 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品分类] 由 @孔思宇 (opens new window) 贡献 #82 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品属性] 由 @孔思宇 (opens new window) 贡献 #83 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品品牌] 由 @Aix (opens new window) 贡献 #104 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 商户信息] 由 @凌太虚 (opens new window) 贡献 #81 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 应用信息] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 支付订单] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 退款订单] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 我的流程] 由 @Chika (opens new window) 贡献 #93 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 已办任务] 由 @Chika (opens new window) 贡献 #90 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 待办任务] 由 @Chika (opens new window) 贡献 #93 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 请假查询] 由 @ZanGe丶 (opens new window) 贡献 #108 (opens new window) 【新增】Vue3 管理后台:增加全局权限判断函数 checkPermi 和 checkRole,由 @LinkLi (opens new window) 贡献 #22 (opens new window) 【新增】字典数据 starter 模块单元测试,由 @与或非 (opens new window) 贡献 #440 (opens new window) 【新增】多租住 Job 部分的单元测试,由 @与或非 (opens new window) 贡献 #27 (opens new window) 【优化】校验手机号码是否正确的正则,由 @冰是睡着的水 (opens new window) 贡献 #447 (opens new window) 【新增】PasswordEncoder 加密复杂度自定义,由 @Fanjc (opens new window) 贡献 #24 (opens new window) 【新增】Vue3 增加 @element-plus/icons-vue 依赖,由 @dhb52 (opens new window) 贡献 #101 (opens new window) 【优化】Vue3 管理后台:增加 Mp 账号 Select 下拉框组件,由 @dhb52 (opens new window) 贡献 #113 (opens new window)、#118 (opens new window) 【优化】Vue3 管理后台:使用 Editor 替代 WxEditor,移除 @vueup/vue-quill 依赖,由 @dhb52 (opens new window) 贡献 #121 (opens new window) 【优化】Vue3 管理后台:公众号消息独立 MessageTable 等组件,解决消息弹窗不重置的问题,由 @dhb52 (opens new window) 贡献 #121 (opens new window) 【优化】Vue3 管理后台:公众号的素材管理,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #126 (opens new window) 【优化】Vue3 管理后台:公众号的自动回复,拆分 ReplyTable 列表组件,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的消息回复组件,不同消息拆分不同表单,提升可维护性,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的草稿管理件,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的菜单管理,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue2 管理后台:将工作流的业务表单做为动态组件,直接显示到审批页面,不再需要点击查看,由 @疯狂的世界 (opens new window) 贡献 #432 (opens new window) 【优化】Vue3 管理后台:将工作流的业务表单做为动态组件,直接显示到审批页面,不再需要点击查看,由 @puhui999 (opens new window) 贡献 #130 (opens new window) 【重构】Vue3 管理后台:给所有组件添加 name 属性预防未知 bug!!! 由 @puhui999 (opens new window) 贡献 #125 (opens new window) # 🐞 Bug Fixes 【修复】Flowable 无法自动建表问题,由 @LinkLi (opens new window) 贡献 #427 (opens new window) 【修复】Vue3 管理后台:包含字典表的页面加载时报错,由 @毕梅 (opens new window) 贡献 #21 (opens new window) 【修复】Vue3 管理后台:ProcessDesigner.vue 编译错误(eslint),由 @孔思宇 (opens new window) 贡献 #23 (opens new window) 【修复】积木报告建表语句错误,由 @疯狂的世界 (opens new window) 贡献 #430 (opens new window) 【修复】基于 Spring Cloud Bus 实现的 Producer 抽象类,获取自己服务实例时获取不到,由 @Lee.J.Eric (opens new window) 贡献 #26 (opens new window) 【修复】修复某些情况下 ContextHolder 的 NPE 异常,由 @xuing (opens new window) 贡献 #225 (opens new window) 【修复】生成代码测试里面的时间问题(buildBetweenTime 方法),由 @xiaohe4966 (opens new window) 贡献 #228 (opens new window) 【修复】Vue3 管你后台的各种验收 bug,由 @周建 (opens new window) 贡献 #32 (opens new window)、#51 (opens new window)、#56 (opens new window)、#71 (opens new window)、#84 (opens new window) 【修复】PostgreSQLSQL 的 system_menu 表缺少 component_name、always_show 字段、缺少 system_mail_account、system_mail_log、system_mail_template、system_notify_message、system_notify_template 表,由 @libran (opens new window) 贡献 #435 (opens new window)、#435 (opens new window)、#436 (opens new window)、#437 (opens new window) 【修复】订单的创建时间差 8 小时的问题,由 @chop (opens new window) 贡献 #442 (opens new window) 【修复】Vue2 短信验证码登录问题,由 @打听幸福的下落 (opens new window) 贡献 #438 (opens new window) 【修复】工作流的审批任务列表的时间不正确的问题,由 @SuperHao (opens new window) 贡献 #426 (opens new window) 【修复】IP 查询时,因为空格导致异常问题,由 @chasel-jc (opens new window) 贡献 #31 (opens new window) 【修复】Spring Cloud 打包后,无法使用 java -jar 的问题,由 @lovezhike (opens new window) 贡献 #28 (opens new window) 【修复】点击遮罩层弹窗关闭后,页面就操作不了了会一直转圈的问题,由 @puhui999 (opens new window) 贡献 #78 (opens new window) 【修复】设置 vite basePath 后,重新登录跳转路由错误,由 @mgzu (opens new window) 贡献 #89 (opens new window) 【修复】在 Vue3 + Vite4 模块中,使用顶层 await打 包的时候报错,由 @puhui999 (opens new window) 贡献 #78 (opens new window) 【修复】Vue3 公众号素材选择时,获取 FreePublic 出错,以及分页溢出,由 @dhb52 (opens new window) 贡献 #96 (opens new window) 【修复】Vue3 公众号图文显示有误,articles 为数组,由 @dhb52 (opens new window) 贡献 #100 (opens new window) 【修复】xss 请求 Wrapper getAttribute 方法返回错误,由 @zhangxingjia (opens new window) 贡献 #451 (opens new window) 【修复】支付通知的通知 Transaction 不生效的问题,由 @kokoko (opens new window) 贡献 #450 (opens new window) 【修复】修复工作流创建流程时,流程名可能不存在的问题,由 @xushu (opens new window) 贡献 #439 (opens new window) 【修复】修复租户名的重复问题,由 @clockdotnet (opens new window) 贡献 #446 (opens new window) 【修复】Vue3 debugger 位置异常,由 @黄爱武 (opens new window) 贡献 #114 (opens new window) 【修复】Vue3 新增或修改菜单时,无法选择菜单图标的 Bug,由 @chongyul (opens new window) 贡献 #2 (opens new window) 【修复】Vue2 管理后台新增租户时,未校验账号、密码是否为空,由 @LiZhongShi (opens new window) 贡献 #456 (opens new window) 【修复】敏感词导出和字典数据编辑保存的两个 BUG,由 @clockdotnet (opens new window) 贡献 #457 (opens new window) 【修复】Vue3 管理后台:用户管理查询入参错误、站内信模板删除 API 调用错误,由 @AhJindeg (opens new window) 贡献 #132 (opens new window) # 🔨 Dependency Upgrades 【升级】knife4j from 4.0.0 to 4.1.0 【升级】spring-boot from 2.7.8 to 2.7.10 【升级】spring-doc 1.6.14 to 1.6.15 【升级】lombok from 1.18.24 to 1.18.26 【升级】druid from 1.2.15 to 1.2.16 【升级】jedis-mock from 1.0.6 to 1.0.7 【升级】hutool from 1.15.3 to 1.15.4 【升级】tika-core from 2.6.0 to 2.7.0 【升级】netty-all from 4.1.86.Final to 4.1.90.Final 【升级】minio from 8.5.1 to 8.5.2 【升级】tencentcloud-sdk-java from 3.1.676 to 3.1.715 【升级】alipay-sdk-java from 4.35.32.ALL to 4.35.79.ALL 【升级】ip-region from 2.6.6 to 2.7.0 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:33 【v1.7.3】开发中 【v1.7.1】2023-03-05 ← 【v1.7.3】开发中 【v1.7.1】2023-03-05→"},{"title":"功能列表","path":"/wiki/YuDaoBoot/萌新必读/功能列表/功能列表.html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 功能列表 芋道,以开发者为中心,打造中国第一流的快速开发平台,全部开源,个人与企业可 100% 免费使用。 管理后台的电脑端:Vue3 提供 element-plus (opens new window)、vben(ant-design-vue) (opens new window) 两个版本,Vue2 提供 element-ui (opens new window) 版本 管理后台的移动端:采用 uni-app (opens new window) 方案,一份代码多终端适配,同时支持 APP、小程序、H5! 后端采用 Spring Boot、MySQL + MyBatis Plus、Redis + Redisson 数据库可使用 MySQL、Oracle、PostgreSQL、SQL Server、MariaDB、国产达梦 DM、TiDB 等 权限认证使用 Spring Security & Token & Redis,支持多终端、多种用户的认证系统,支持 SSO 单点登录 支持加载动态权限菜单,按钮级别权限控制,本地缓存提升性能 支持 SaaS 多租户系统,可自定义每个租户的权限,提供透明化的多租户底层封装 工作流使用 Flowable,支持动态表单、在线设计流程、会签 / 或签、多种任务分配方式 高效率开发,使用代码生成器可以一键生成前后端代码 + 单元测试 + Swagger 接口文档 + Validator 参数校验 集成微信小程序、微信公众号、企业微信、钉钉等三方登陆,集成支付宝、微信等支付与退款 集成阿里云、腾讯云等短信渠道,集成 MinIO、阿里云、腾讯云、七牛云等云存储服务 集成报表设计器、大屏设计器,通过拖拽即可生成酷炫的报表与大屏 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 🐼 内置功能 系统内置多种多种业务功能,可以用于快速你的业务系统: 系统功能 基础设施 工作流程 支付系统 会员中心 数据报表 商城系统 微信公众号 友情提示:本项目基于 RuoYi-Vue 修改,重构优化后端的代码,美化前端的界面。 额外新增的功能,我们使用 🚀 标记。 重新实现的功能,我们使用 ⭐️ 标记。 🙂 所有功能,都通过 单元测试 保证高质量。 # 系统功能 功能 描述 用户管理 用户是系统操作者,该功能主要完成系统用户配置 ⭐️ 在线用户 当前系统中活跃用户状态监控,支持手动踢下线 角色管理 角色菜单权限分配、设置角色按机构进行数据范围权限划分 菜单管理 配置系统菜单、操作权限、按钮权限标识等,本地缓存提供性能 部门管理 配置系统组织机构(公司、部门、小组),树结构展现支持数据权限 岗位管理 配置系统用户所属担任职务 🚀 租户管理 配置系统租户,支持 SaaS 场景下的多租户功能 🚀 租户套餐 配置租户套餐,自定每个租户的菜单、操作、按钮的权限 字典管理 对系统中经常使用的一些较为固定的数据进行维护 🚀 短信管理 短信渠道、短息模板、短信日志,对接阿里云等主流短信平台 🚀 邮件管理 邮箱账号、邮件模版、邮件发送日志,支持所有邮件平台 🚀 站内信 系统内的消息通知,支持站内信模版、站内信消息 🚀 操作日志 系统正常操作日志记录和查询,集成 Swagger 生成日志内容 ⭐️ 登录日志 系统登录日志记录查询,包含登录异常 🚀 错误码管理 系统所有错误码的管理,可在线修改错误提示,无需重启服务 通知公告 系统通知公告信息发布维护 🚀 敏感词 配置系统敏感词,支持标签分组 🚀 应用管理 管理 SSO 单点登录的应用,支持多种 OAuth2 授权方式 🚀 地区管理 展示省份、城市、区镇等城市信息,支持 IP 对应城市 # 基础设施 功能 描述 🚀 代码生成 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 🚀 系统接口 基于 Swagger 自动生成相关的 RESTful API 接口文档 🚀 数据库文档 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 表单构建 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 🚀 配置管理 对系统动态配置常用参数,支持 SpringBoot 加载 🚀 文件服务 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 🚀 文件服务 支持本地文件存储,同时支持兼容 Amazon S3 协议的云服务、开源组件 🚀 API 日志 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 MySQL 监控 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 Redis 监控 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 🚀 消息队列 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 🚀 Java 监控 基于 Spring Boot Admin 实现 Java 应用的监控 🚀 链路追踪 接入 SkyWalking 组件,实现链路追踪 🚀 日志中心 接入 SkyWalking 组件,实现日志中心 🚀 分布式锁 基于 Redis 实现分布式锁,满足并发场景 🚀 幂等组件 基于 Redis 实现幂等组件,解决重复请求问题 🚀 服务保障 基于 Resilience4j 实现服务的稳定性,包括限流、熔断等功能 🚀 日志服务 轻量级日志中心,查看远程服务器的日志 🚀 单元测试 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 # 工作流程 功能 描述 🚀 流程模型 配置工作流的流程模型,支持文件导入与在线设计流程图,提供 7 种任务分配规则 🚀 流程表单 拖动表单元素生成相应的工作流表单,覆盖 Element UI 所有的表单组件 🚀 用户分组 自定义用户分组,可用于工作流的审批分组 🚀 我的流程 查看我发起的工作流程,支持新建、取消流程等操作,高亮流程图、审批时间线 🚀 待办任务 查看自己【未】审批的工作任务,支持通过、不通过、转发、委派、退回等操作 🚀 已办任务 查看自己【已】审批的工作任务,未来会支持回退操作 🚀 OA 请假 作为业务自定义接入工作流的使用示例,只需创建请求对应的工作流程,即可进行审批 # 支付系统 功能 描述 🚀 商户信息 管理商户信息,支持 Saas 场景下的多商户功能 🚀 应用信息 配置商户的应用信息,对接支付宝、微信等多个支付渠道 🚀 支付订单 查看用户发起的支付宝、微信等的【支付】订单 🚀 退款订单 查看用户发起的支付宝、微信等的【退款】订单 ps:核心功能已经实现,正在对接微信小程序中... # 数据报表 功能 描述 🚀 报表设计器 支持数据报表、图形报表、打印设计等 🚀 大屏设计器 拖拽生成数据大屏,内置几十种图表组件 # 微信公众号 功能 描述 🚀 账号管理 配置接入的微信公众号,可支持多个公众号 🚀 数据统计 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 🚀 粉丝管理 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 🚀 消息管理 查看粉丝发送的消息列表,可主动回复粉丝消息 🚀 自动回复 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 🚀 标签管理 对公众号的标签进行创建、查询、修改、删除等操作 🚀 菜单管理 自定义公众号的菜单,也可以从公众号同步菜单 🚀 素材管理 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 🚀 图文草稿箱 新增常用的图文素材到草稿箱,可发布到公众号 🚀 图文发表记录 查看已发布成功的图文素材,支持删除操作 # 商城系统 建设中... # 会员中心 和「商城系统」一起开发 # 🐷 演示图 # 系统功能 模块 biu biu biu 登录 & 首页 用户 & 应用 租户 & 套餐 - 部门 & 岗位 - 菜单 & 角色 - 审计日志 - 短信 字典 & 敏感词 ) 错误码 & 通知 - # 工作流程 模块 biu biu biu 流程模型 表单 & 分组 - 我的流程 待办 & 已办 OA 请假 # 基础设施 模块 biu biu biu 代码生成 - 文档 - 文件 & 配置 定时任务 - API 日志 - MySQL & Redis - 监控平台 # 支付系统 模块 biu biu biu 商家 & 应用 支付 & 退款 --- # 数据报表 模块 biu biu biu 报表设计器 大屏设计器 # 移动端(管理后台) biu biu biu 目前已经实现登录、我的、工作台、编辑资料、头像修改、密码修改、常见问题、关于我们等基础功能。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:33 视频教程 快速启动(适合“后端”工程师) ← 视频教程 快速启动(适合“后端”工程师)→"},{"title":"删除功能","path":"/wiki/YuDaoBoot/萌新必读/删除功能/删除功能.html","content":"开发指南萌新必读 芋道源码 2022-10-17 目录 删除功能 项目内置功能较多,会存在一些你可能用不到的功能。一般的情况下,建议通过设置该功能对应的菜单为【禁用】,实现功能的“删除”。如下图所示: 后续,如果你又需要使用到该功能,只需要设置该功能对应的菜单为【开启】即可。 🙂 当然,如果你希望彻底删除功能,那么就需要采用删除代码的方式。整个过程如下: ① 【菜单】第一步,使用管理后台的菜单管理,删除对应的菜单、按钮。 ② 【数据库表】第二步,删除对应的数据库表。 ③ 【后端代码】第三步,删除对应的 Controller、Service、数据库实体等后端代码;然后启动后端项目,若存在代码报错,则继续删除相关联的代码,之后如此反复,直到成功。 ④ 【前端代码】第四步,删除对应的 View 和 API 等前端代码;然后启动前端项目,若存在代码报错,则继续删除相关联的代码,之后如此反复,直到成功。 下面,我们来举一些例子。 # 👍 相关视频教程 从零开始 07:如何有效的删除不用的功能? (opens new window) # 删除「多租户」功能 对应功能的文档:多租户 对应的关键字是 tenant # 第一步,删除菜单 删除“租户管理“下的所有菜单,从最里层的按钮开始。如下图所示: # 第二步,删除数据库表 删除 system_tenant 和 system_tenant_package 表。如下图所示: # 第三步,删除后端代码 ① 删除 yudao-module-system-api 模块的 api/tenant (opens new window) 包。 ② 删除 yudao-module-system-api 模块的 ErrorCodeConstants (opens new window) 类中,和租户、租户套餐相关的错误码。如下图所示: 如果想删除的更干净,可以把 system_error_code 表中,对应编号的错误码也都删除一下。 ③ 删除 yudao-module-system-biz 模块的如下包: api/tenant (opens new window) controller/admin/tenant (opens new window) service/tenant (opens new window) test/service/tenant (opens new window) dal/dataobject/tenant (opens new window) dal/mysql/tenant (opens new window) convert/tenant (opens new window) ④ 删除 yudao-spring-boot-starter-biz-tenant (opens new window) 模块。 然后,使用 IDEA 搜索 yudao-spring-boot-starter-biz-tenant 关键字,删除 Maven 中所有对它的定义与引用。如下图所示: 之后,使用 IDEA 刷新下 Maven 依赖。如下图所示: ⑤ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.db 不存在的错误,需要将继承 TenantBaseDO 的数据库实体,都改成继承 BaseDO 基类。 ⑥ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.aop 不存在的错误,需要去除对 @TenantIgnore 注解的使用。如下图所示: ⑦ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.module.system.service.tenant 不存在的错误,需要去除对 TenantService 的使用。如下图所示: ⑧ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.context 不存在的错误,需要去除对 TenantContextHolder 的使用。如下图所示: ⑨ 运行 YudaoServerApplication 启动类,终于成功了!!! ps:可以将 application.yaml 配置文件中,对应的 yudao.tenant 配置项给进一步删除。 # 第四步,删除前端代码 以 yudao-admin-ui 为示例~ ① 删除 View 和 API 的前端代码: views/system/tenant (opens new window) views/system/tenantPackage (opens new window) api/system/tenant.js (opens new window) api/system/tenantPackage.js (opens new window) ② 在 yudao-admin-ui 目录下,执行 npm run local 成功。访问登录页,结果访问白屏。需要清理 login.vue 页,涉及 tenant 关键字的代码。例如说: 刷新,成功访问登录界面。 ③ 在 yudao-admin-ui 目录下,搜索 tenant 或 Tenant 关键字,可进一步清理多租户的代码。例如说: # 第五步,测试验收 至此,我们已经完成了多租户的代码删除,还是蛮艰辛的~ 后续,你可以简单测试一下,看看是不是删除代码,导致一些小问题。 # 更多... 如果你有其它功能想要删除,可以在 Issue (opens new window) 留言,可以不断补充到该文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/06, 00:33:28 一键改包 新建模块 ← 一键改包 新建模块→"},{"title":"快速启动(适合“前端”工程师)","path":"/wiki/YuDaoBoot/萌新必读/快速启动(适合“前端”工程师)/快速启动(适合“前端”工程师).html","content":"开发指南萌新必读 芋道源码 2023-03-05 目录 快速启动(适合“前端”工程师) 目标:在 本地 将前端项目运行起来,使用 远程 演示环境的后端服务。 整个过程非常简单,预计 5 分钟就可以完成,取决于大家的网速。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ 友情提示: 远程 演示环境的后端服务,只允许 GET 请求,不允许 POST、PUT、DELETE 等请求。 如果你要完整的后端服务,建议后续参考 《快速启动(我是后端)》 文档,将后端服务运行起来。 # 👍 相关视频教程 从零开始 02:在 Windows 环境下,如何运行前后端项目? (opens new window) 从零开始 03:在 MacOS 环境下,如何运行前后端项目? (opens new window) # 1. Apifox 接口工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 接口文档? 阅读 《开发指南 —— 接口文档》 呀~~ # 2. 启动 Vue3 + element-plus 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vue3.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run front ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 友情提示:Vue3 使用 Vite 构建,所以它存在如下的情况,都是正常的: 项目启动很快,浏览器打开需要等待 1 分钟左右,请保持耐心。 点击菜单,感觉会有一点卡顿,因为 Vite 采用懒加载机制。不用担心,最终部署到生产环境,就不存在这个问题了。 详细说明,可见 《为什么有人说 Vite 快,有人却说 Vite 慢?》 (opens new window) 文章。 # 3. 启动 Vue3 + vben(ant-design-vue) 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 + vben(ant-design-vue) 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vben.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run front ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 # 4. 启动 Vue2 管理后台 yudao-ui-admin (opens new window) 是前端 Vue2 管理后台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 在 yudao-ui-admin 目录下,执行如下命令,进行启动: # 进入项目目录cd yudao-ui-admin# 安装 Yarn,提升依赖的安装速度npm install --global yarn# 安装依赖yarn install# 启动服务npm run front ② 启动完成后,浏览器会自动打开 http://localhost:1024 (opens new window) 地址,可以看到前端界面。 # 5. 启动 uni-app 管理后台 yudao-ui-admin-uniapp (opens new window) 是前端 uni-app 管理后台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-admin-uniapp 目录。 然后,修改 config.js 配置文件的 baseUrl 后端服务的地址为 'http://api-dashboard.yudao.iocoder.cn。如下图所示: ③ 执行如下命令,安装 npm 依赖: # 进入项目目录cd yudao-ui-admin-uniapp# 安装 npm 依赖npm i ④ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: 友情提示:登录时,滑块验证码,在内存浏览器可能存在兼容性的问题,此时使用 Chrome 浏览器,并使用“开发者工具”,设置为 iPhone 12 Pro 模式! # 6. 启动 uni-app 用户前台 yudao-ui-app (opens new window) 是前端 uni-app 用户前台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-app 目录 然后,修改 config.js 配置文件的 baseUrl 后端服务的地址为 'http://api-dashboard.yudao.iocoder.cn/app-api。如下图所示: ③ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: # 7. 参与项目 如果你想参与到前端项目的开发,可以微信 wangwenbin-server 噢。 近期,重点开发 Vue3 管理后台、uniapp 商城,欢迎大家参与进来。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/15, 00:03:57 快速启动(适合“后端”工程师) 接口文档 ← 快速启动(适合“后端”工程师) 接口文档→"},{"title":"快速启动(适合“后端”工程师)","path":"/wiki/YuDaoBoot/萌新必读/快速启动(适合“后端”工程师)/快速启动(适合“后端”工程师).html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 快速启动(适合“后端”工程师) 目标:使用 IDEA 工具,将后端项目 ruoyi-vue-pro (opens new window) 运行起来,并按需启动前端项目。 整个过程非常简单,预计 10 分钟就可以完成,取决于大家的网速。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ # 👍 相关视频教程 从零开始 02:在 Windows 环境下,如何运行前后端项目? (opens new window) 从零开始 03:在 MacOS 环境下,如何运行前后端项目? (opens new window) # 1. 克隆代码 使用 IDEA (opens new window) 克隆 https://github.com/YunaiV/ruoyi-vue-pro (opens new window) 仓库的最新代码,并给该仓库一个 Star (opens new window)。 友情提示:IDEA 请使用至少 2020 版本,不知道怎么激活的可以看看 《IDEA 破解新招 - 无限重置30天试用期(适用于 2018、2019、2020、2021 所有版本) 》 (opens new window) 文章! 注意:不支持使用 Eclipse 启动项目,因为它没有支持 Lombok 和 Mapstruct 的插件。 克隆完成后,耐心等待 Maven 下载完相关的依赖。 友情提示:项目的每个模块的作用,可见 《开发指南 —— 项目结构》 文档。 使用的 SpringBoot 版本较新,所以需要下载一段时间。趁着这个时间,胖友可以给项目添加一个 Star (opens new window),支持下艿艿。 # 2. Apifox 接口工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 接口文档? 阅读 《开发指南 —— 接口文档》 呀~~ # 3. 初始化 MySQL 友情提示? 如果你是 PostgreSQL、Oracle、SQL Server 等其它数据库,也是可以的。 因为我主要使用 MySQL数据库为主,所以其它数据库的 SQL 文件可能存在滞后,可以加入 用户群 反馈。 项目使用 MySQL 存储数据,所以需要启动一个 MySQL 服务,建议使用 5.7 版本。 ① 创建一个名字为 ruoyi-vue-pro 数据库,执行对应数据库类型的 sql (opens new window) 目录下的 SQL 文件,进行初始化。 ② 默认配置下,MySQL 需要启动在 3306 端口,并且账号是 root,密码是 123456。如果不一致,需要修改 application-local.yaml 配置文件。 # 4. 初始化 Redis 项目使用 Redis 缓存数据,所以需要启动一个 Redis 服务。 一定要使用 5.0 以上的版本,项目使用 Redis Stream 作为消息队列。 不会安装的胖友,可以选择阅读下文,良心的艿艿。 Windows 安装 Redis 指南:http://www.iocoder.cn/Redis/windows-install (opens new window) Mac 安装 Redis 指南:http://www.iocoder.cn/Redis/mac-install (opens new window) 默认配置下,Redis 启动在 6379 端口,不设置账号密码。如果不一致,需要修改 application-local.yaml 配置文件。 # 5. 启动后端项目 yudao-server (opens new window) 是后端项目,提供管理后台、用户 APP 的 RESTful API 接口。 # 5.1 编译项目 第一步,使用 IDEA 打开 Terminal 终端,在 根目录 下直接执行 mvn clean install package '-Dmaven.test.skip=true' 命令,将项目进行初始化的打包,预计需要 1 分钟左右。成功后,控制台日志如下: [INFO] BUILD SUCCESS[INFO] ------------------------------------------------------------------------[INFO] Total time: 01:12 min[INFO] Finished at: 2022-02-12T09:52:38+08:00[INFO] Final Memory: 250M/2256M[INFO] ------------------------------------------------------------------------ JDK 版本的选择? 如下的 JDK 版本,是艿艿在本地测试通过的 JDK 8 版本:尽量保证 >= 1.8.0_144 JDK 11 版本:尽量保证 >= 11.0.14 JDK 17 版本:尽量保证 >= 17.0.2 如果 JDK 版本过低,包括 JDK 的小版本过低,也会 mvn 编译报错。例如说: “编译器(1.8.0_40)中出现编译错误“。此处,升级下 JDK 版本即可。 Maven 补充说明: ① 只有首次需要执行 Maven 命令,解决基础 pom.xml 文件不存在,导致报 BaseDbUnitTest 类不存在的问题。 ② 如果执行报 Unknown lifecycle phase “.test.skip=true” 错误,使用 mvn clean install package -Dmaven.test.skip=true 即可。 # 5.2 启动项目 第二步,执行 YudaoServerApplication (opens new window) 类,进行启动。 启动还是报类不存在? 可能是 IDEA 的 bug,点击 [File -> Invalidate Caches] 菜单,清空下缓存,重启后在试试看。 启动完成后,使用浏览器访问 http://127.0.0.1:48080 (opens new window) 地址,返回如下 JSON 字符串,说明成功。 友情提示:注意,默认配置下,后端项目启动在 48080 端口。 { "code": 401, "data": null, "msg": "账号未登录"} 如果报 “Command line is too long” 错误,参考 《Intellij IDEA 运行时报 Command line is too long 解决方法 》 (opens new window) 文章解决,或者直接点击 YudaoServerApplication 蓝字部分! # 5.3 启动其它模块 考虑到启动速度,默认值启动 system 系统服务,infra 基础设施两个模块。如果你需要启动其它模块,可以参考下面的文档: 《工作流手册 —— 工作流》 《公众号手册 —— 功能开启》 《大屏手册 —— 报表设计器》 《商城手册 —— 功能开启》 # 6. 启动前端项目【简易】 在 yudao-ui-static (opens new window) 项目中,提前编译好了前端项目的静态资源,可以直接体验和使用。操作步骤如下: ① 克隆 https://gitee.com/yudaocode/yudao-ui-static (opens new window) 项目,运行 UiConfiguration 类,进行启动。 ② 访问 http://127.0.0.1:2048/admin-ui-vue2/ (opens new window) 地址,可以看到 Vue2 管理后台。 ② 访问 http://127.0.0.1:2048/admin-ui-vue3/ (opens new window) 地址,可以看到 Vue3 + element-plus 管理后台。 ③ 访问 http://127.0.0.1:2048/admin-ui-vben/ (opens new window) 地址,可以看到 Vue3 + vben(ant-design-vue) 管理后台。 补充说明: 前端项目是不定期编译,可能不是最新版本。 如果需要最新版本,请继续往下看。 # 7. 启动前端项目【完整】 项目提供了多套前端项目,可以按需启动哈。 友情提示:可能胖友本地没有安装 Node.js 的环境,导致报错。可以参考如下文档安装: Windows 安装 Node.js 指南:http://www.iocoder.cn/NodeJS/windows-install (opens new window) Mac 安装 Node.js 指南:http://www.iocoder.cn/NodeJS/mac-install (opens new window) # 7.1 启动 Vue3 + element-plus 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 + element-plus 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vue3.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 友情提示:Vue3 使用 Vite 构建,所以它存在如下的情况,都是正常的: 项目启动很快,浏览器打开需要等待 1 分钟左右,请保持耐心。 点击菜单,感觉会有一点卡顿,因为 Vite 采用懒加载机制。不用担心,最终部署到生产环境,就不存在这个问题了。 详细说明,可见 《为什么有人说 Vite 快,有人却说 Vite 慢?》 (opens new window) 文章。 # 7.2 启动 Vue3 + vben(ant-design-vue) 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 + vben(ant-design-vue) 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vben.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 # 7.3 启动 Vue2 管理后台 yudao-ui-admin (opens new window) 是前端 Vue2 管理后台项目。 ① 在 yudao-ui-admin 目录下,执行如下命令,进行启动: # 进入项目目录cd yudao-ui-admin# 安装 Yarn,提升依赖的安装速度npm install --global yarn# 安装依赖yarn install# 启动服务npm run local ② 启动完成后,浏览器会自动打开 http://localhost:1024 (opens new window) 地址,可以看到前端界面。 # 7.4 启动 uni-app 管理后台 yudao-ui-admin-uniapp (opens new window) 是前端 uni-app 管理后台项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-admin-uniapp 目录 ③ 执行如下命令,安装 npm 依赖: # 进入项目目录cd yudao-ui-admin-uniapp# 安装 npm 依赖npm i ④ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: 友情提示:登录时,滑块验证码,在内存浏览器可能存在兼容性的问题,此时使用 Chrome 浏览器,并使用“开发者工具”,设置为 iPhone 12 Pro 模式! # 7.5 启动 uni-app 用户前台 yudao-ui-app (opens new window) 是前端 uni-app 用户前台项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-app 目录 ③ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: # 666. 彩蛋 至此,我们已经完成了项目 ruoyi-vue-pro (opens new window) 的启动。 胖友可以根据自己的兴趣,阅读相关源码。如果你想更快速的学习,可以看看 《视频教程 》 教程哟。 后面,艿艿会花大量的时间,继续优化这个项目。同时,输出与项目匹配的技术博客,方便胖友更好的学习与理解。 还是那句话,😆 为开源继绝学,我辈义不容辞! 嘿嘿嘿,记得一定要给 https://github.com/YunaiV/ruoyi-vue-pro (opens new window) 一个 star,这对艿艿真的很重要。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/15, 00:03:57 功能列表 快速启动(适合“前端”工程师) ← 功能列表 快速启动(适合“前端”工程师)→"},{"title":"接口文档","path":"/wiki/YuDaoBoot/萌新必读/接口文档/接口文档.html","content":"开发指南萌新必读 芋道源码 2022-03-26 目录 接口文档 项目使用 Swagger 实现 RESTful API 的接口文档,提供两种解决方案: *【推荐】 Apifox (opens new window):强大的 API 工具,支持 API 文档、API 调试、API Mock、API 自动化测试 Knife4j:简易的 API 工具,仅支持 API 文档、API 调试 为什么选择 Swagger 呢? Swagger 通过 Java 注解实现 API 接口文档的编写。相比使用 Java 注释的方式,注解提供更加规范的接口定义方式,开发体验更好。 如果你没有学习 Swagger,可以阅读 《芋道 Spring Boot API 接口文档 Swagger 入门 》 (opens new window) 文章。 # 1. Apifox 使用 本小节,我们来将项目中的 API 接口,一键导入到 Apifox 中,并使用它发起一次 API 的调用。 # 1.1 下载工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 # 1.2 API 导入 ① 先点击「示例项目」,再点击「+」按钮,选择「导入」选项。 ② 先选择「URL 导入」按钮,填写 Swagger 数据 URL 为 http://127.0.0.1:48080/v3/api-docs。 ③ 先点击「提交」按钮,再点击「确认导入」按钮,完成 API 接口的导入。 ④ 导入完成后,点击「接口管理」按钮,可以查看到 API 列表。 # 1.3 API 调试 ① 先点击右上角「请选择环境」,再点击「管理环境」选项,填写测试环境的地址为 http://127.0.0.1:48080,并进行保存。 ② 点击「管理后台 —— 认证」的「使用账号密码登录」接口,查看该 API 接口的定义。 ③ 点击「运行」按钮,填写 Headers 的 tenant-id 为 1,再点击 Body 的「自动生成」按钮,最后点击「发送」按钮。 # 2. Knife4j 使用 浏览器访问 http://127.0.0.1:48080/doc.html (opens new window) 地址,使用 Knife4j 查看 API 接口文档。 ① 点击任意一个接口,进行接口的调用测试。这里,使用「管理后台 - 用户个中心」的“获得登录用户信息”举例子。 ② 点击左侧「调试」按钮,并将请求头部的 header-id 和 Authorization 勾选上。 其中,header-id 为租户编号,Authorization 的 \"Bearer test\" 后面为用户编号(模拟哪个用户操作)。 ③ 点击「发送」按钮,即可发起一次 API 的调用。 # 3. Swagger 技术组件 ① 在 yudao-spring-boot-starter-web (opens new window) 技术组件的 swagger (opens new window) 包,实现了对 Swagger 的封装。 ② 如果想要禁用 Swagger 功能,可通过 springdoc.api-docs.enable 配置项为 false。一般情况下,建议 prod 生产环境进行禁用,避免发生安全问题。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/05, 11:33:32 快速启动(适合“前端”工程师) 技术选型 ← 快速启动(适合“前端”工程师) 技术选型→"},{"title":"简介","path":"/wiki/YuDaoBoot/萌新必读/简介/简介.html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 简介 yudao-vue-pro (opens new window),RuoYi-Vue 全新 Pro 版本,优化重构所有功能。 基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + UniApp 微信小程序,支持 RBAC 动态权限、数据权限、SaaS 多租户、Flowable 工作流、三方登录、支付、短信、商城等功能。 (opens new window) (opens new window) 😆 为开源继绝学,我辈义不容辞! 2017 年,艿艿创建「芋道源码」公众号,帮助了 20w+ 工程师学习优秀框架的源码。 2019 年,看了 Gitee 和 Github 非常多的业务开源项目,无法到达代码整洁、架构整洁。 于是,艿艿利用休息时间,每天肝到晚上 1 点多,如此便有了芋道管理后台 + 微信小程序。 # 🐴 严肃声明 现在、未来都不会有商业版本,所有代码全部开源! 「我喜欢写代码,乐此不疲」 「我喜欢做开源,以此为乐」 我 🐶 在上海艰苦奋斗,早中晚在 top3 大厂认真搬砖,夜里为开源做贡献。 如果这个项目让你有所收获,记得 Star 关注哦,这对我是非常不错的鼓励与支持。 # 🐳 项目关系 三个项目的功能对比,可见社区共同整理的 国产开源项目对比 (opens new window) 表格。 # 后端项目 项目 Star 简介 ruoyi-vue-pro (opens new window) (opens new window) (opens new window) 基于 Spring Boot 多模块架构 yudao-cloud (opens new window) (opens new window) (opens new window) 基于 Spring Cloud 微服务架构 Spring-Boot-Labs (opens new window) (opens new window) (opens new window) 系统学习 Spring Boot & Cloud 专栏 # 前端项目 项目 Star 简介 yudao-ui-admin-vue3 (opens new window) (opens new window) (opens new window) 基于 Vue3 + element-plus 实现的管理后台 yudao-ui-admin (opens new window) (opens new window) (opens new window) 基于 Vue2 + element-ui 实现的管理后台 yudao-ui-admin-uniapp (opens new window) (opens new window) (opens new window) 基于 uni-app + uni-ui 实现的管理后台的小程序 yudao-ui-go-view (opens new window) (opens new window) (opens new window) 基于 Vue3 + naive-ui 实现的大屏报表 yudao-ui-app (opens new window) (opens new window) (opens new window) 基于 uni-app + uview 实现的用户 App # 🐶 在线体验 演示地址【Vue3 + element-plus】:http://dashboard-vue3.yudao.iocoder.cn (opens new window) 演示地址【Vue3 + vben(ant-design-vue)】:http://dashboard-vben.yudao.iocoder.cn (opens new window) 演示地址【Vue2 + element-ui】:http://dashboard.yudao.iocoder.cn (opens new window) 如果你要搭建本地环境,可参考如下文档: 《开发指南 —— 快速启动(适合“后端”工程师)》 《开发指南 —— 快速启动(适合“前端”工程师)》 # 📚 国内顶级开源项目对比 社区整理,欢迎补充!传送门 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:33 交流群 交流群→"},{"title":"技术选型","path":"/wiki/YuDaoBoot/萌新必读/技术选型/技术选型.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 技术选型 # 技术架构图 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 👻 后端 # 系统环境 框架 说明 版本 学习指南 JDK Java 开发工具包 >= 1.8.0 书单 (opens new window) Maven Java 管理与构建工具 >= 3.5.0 书单 (opens new window) Nginx 高性能 Web 服务器 - 文档 (opens new window) # 主框架 框架 说明 版本 学习指南 Spring Boot (opens new window) 应用开发框架 2.7.10 文档 (opens new window) Spring MVC (opens new window) MVC 框架 5.3.24 文档 (opens new window) Spring Security (opens new window) Spring 安全框架 5.7.6 文档 (opens new window) Hibernate Validator (opens new window) 参数校验组件 6.2.5 文档 (opens new window) # 存储层 框架 说明 版本 学习指南 MySQL (opens new window) 数据库服务器 >= 5.7 书单 (opens new window) Druid (opens new window) JDBC 连接池、监控组件 1.2.14 文档 (opens new window) MyBatis Plus (opens new window) MyBatis 增强工具包 3.5.3.1 文档 (opens new window) Dynamic Datasource (opens new window) 动态数据源 3.6.1 文档 (opens new window) Redis (opens new window) key-value 数据库 >= 5.0 书单 (opens new window) Redisson (opens new window) Redis 客户端 3.17.7 文档 (opens new window) # 中间件 框架 说明 版本 学习指南 Flowable (opens new window) 工作流引擎 6.8.0 文档 (opens new window) Quartz (opens new window) 任务调度组件 2.3.2 文档 (opens new window) Resilience4j (opens new window) 服务保障组件 1.7.1 文档 (opens new window) # 系统监控 框架 说明 版本 学习指南 Spring Boot Admin (opens new window) Spring Boot 监控平台 2.7.10 文档 (opens new window) SkyWalking (opens new window) 分布式应用追踪系统 8.5.0 文档 (opens new window) # 单元测试 框架 说明 版本 学习指南 JUnit (opens new window) Java 单元测试框架 5.8.2 - Mockito (opens new window) Java Mock 框架 4.8.0 - # 其它工具 框架 说明 版本 学习指南 Springdoc (opens new window) Swagger 文档 1.6.15 文档 (opens new window) Jackson (opens new window) JSON 工具库 2.13.3 MapStruct (opens new window) Java Bean 转换 1.5.3.Final 文档 (opens new window) Lombok (opens new window) 消除冗长的 Java 代码 1.18.26 文档 (opens new window) # 👾 前端 # 管理后台(Vue3 + ElementPlus) 框架 说明 版本 Vue (opens new window) vue 框架 3.2.45 Vite (opens new window) 开发与构建工具 4.0.1 Element Plus (opens new window) Element Plus 2.2.26 TypeScript (opens new window) JavaScript 的超集 4.9.4 pinia (opens new window) Vue 存储库 替代 vuex5 2.0.28 vueuse (opens new window) 常用工具集 9.6.0 vxe-table (opens new window) vue 最强表单 4.3.7 vue-i18n (opens new window) 国际化 9.2.2 vue-router (opens new window) vue 路由 4.1.6 windicss (opens new window) 下一代工具优先的 CSS 框架 3.5.6 iconify (opens new window) 在线图标库 3.0.0 wangeditor (opens new window) 富文本编辑器 5.1.23 # 管理后台(Vue3 + Vben + Ant-Design-Vue) 框架 说明 版本 Vue (opens new window) Vue 框架 3.2.47 Vite (opens new window) 开发与构建工具 4.3.0 ant-design-vue (opens new window) ant-design-vue 3.2.17 TypeScript (opens new window) JavaScript 的超集 5.0.4 pinia (opens new window) Vue 存储库 替代 vuex5 2.0.34 vueuse (opens new window) 常用工具集 9.13.0 vue-i18n (opens new window) 国际化 9.2.2 vue-router (opens new window) Vue 路由 4.1.6 windicss (opens new window) 下一代工具优先的 CSS 框架 3.5.6 iconify (opens new window) 在线图标库 3.1.0 # 管理后台(Vue2) 框架 说明 版本 学习指南 Node (opens new window) JavaScript 运行时环境 >= 12 - Vue (opens new window) JavaScript 框架 2.7.14 书单 (opens new window) Vue Element Admin (opens new window) 后台前端解决方案 2.5.10 # 管理后台(uni-app) 框架 说明 版本 uni-app 跨平台框架 2.0.0 uni-ui (opens new window) 基于 uni-app 的 UI 框架 1.4.20 # 用户 App 框架 说明 版本 学习指南 Vue (opens new window) JavaScript 框架 2.6.12 书单 (opens new window) UniApp (opens new window) 小程序、H5、App 的统一框架 - - .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 23:29:13 接口文档 项目结构 ← 接口文档 项目结构→"},{"title":"视频教程","path":"/wiki/YuDaoBoot/萌新必读/视频教程/视频教程.html","content":"开发指南萌新必读 芋道源码 2022-07-02 目录 视频教程 # 大纲 每个点都是大章节,包含 10-20 小节的视频。 每个视频,控制在 10 分钟左右,问题驱动,全程无废话,保证高质量的学习。 视频的内容,会带你理解整个系统的设计思想,每一个组件和模块的代码实现。 知其然,知其所以然!让你走出只会 CRUD 的困局~ 支持手机、平板、电脑设备,随时随地在线观看,无需下载! # 技术架构图 # 为什么学习该视频? 学习的过程中,往往会碰到如下的问题: 一个人瞎摸索,走弯路,效率低 一脸懵逼,不知道如何学习 遇到问题,无人解答,信心备受打击 遇到一些难题,自己无法透彻理解 知识面狭窄,不知道的太多 而通过这套视频,可以实现 “系统全面,效率高” 的效果。 # 获取方式 使用微信扫描下方二维码,即可获取~ # 从零开始 01、视频课程导读:项目简介、功能列表、技术选型 (opens new window) 02、在 Windows 环境下,如何运行前后端项目? (opens new window) 03、在 MacOS 环境下,如何运行前后端项目? (opens new window) 04、自顶向下,讲解项目的整体结构(上) (opens new window) 04、自顶向下,讲解项目的整体结构(下) (opens new window) 05、如何 5 分钟,开发一个新功能? (opens new window) 06、如何 5 分钟,创建一个新模块? (opens new window) 07、如何有效的删除不用的功能? (opens new window) 08、如何实现一键改包? (opens new window) # 用户认证 01、如何实现管理后台和微信小程序的用户? (opens new window) 02、如何实现用户的创建? (opens new window) 03、如何实现用户的账号密码登录? (opens new window) 04、如何实现用户的手机验证码登录? (opens new window) 05、如何实现用户的退出? (opens new window) 06、如何生成用户认证 Token 令牌? (opens new window) 07、如何校验用户认证 Token 令牌? (opens new window) 08、如何刷新用户认证 Token 令牌? (opens new window) 09、如何模拟用户认证 Token 令牌? (opens new window) 10、如何实现 URL 是否需要登录? (opens new window) 11、如何实现微信、钉钉等第三方登录? (opens new window) 12、如何实现微信小程序的一键登录? (opens new window) # 功能权限 01、如何设计一套权限系统? (opens new window) 02、如何实现菜单的创建? (opens new window) 03、如何实现角色的创建? (opens new window) 04、如何给用户分配权限 —— 将菜单赋予角色? (opens new window) 05、如何给用户分配权限 —— 将角色赋予用户? (opens new window) 06、后端如何实现 URL 权限的校验? (opens new window) 07、前端如何实现菜单的动态加载? (opens new window) 08、前端如何实现按钮的权限校验? (opens new window) # 数据权限 01、如何实现数据权限(内核)—— 原理剖析? (opens new window) 02、如何实现数据权限(内核)—— 源码实现:MyBatis 如何重写 SQL? (opens new window) 03、如何实现数据权限(内核)—— 源码实现:如何基于(数据规则)生成 WHERE 条件? (opens new window) 04、如何实现【部门级别】的数据权限 —— 入门使用? (opens new window) 05、如何实现【部门级别】的数据权限 —— 源码实现? (opens new window) 06、如何实现【自定义】的数据权限 —— 案例实战? (opens new window) # OAuth2 模块 01、快速入门 OAuth 2.0 授权? (opens new window) 02、基于授权码模式,如何实现 SSO 单点登录? (opens new window) 03、请求时,如何校验 accessToken 访问令牌? (opens new window) 04、访问令牌过期时,如何刷新 Token 令牌? (opens new window) 05、登录成功后,如何获得用户信息? (opens new window) 06、退出时,如何删除 Token 令牌? (opens new window) 07、基于密码模式,如何实现 SSO 单点登录? (opens new window) 08、如何实现客户端的管理? (opens new window) 09、单点登录界面,如何进行初始化? (opens new window) 10、单点登录界面,如何进行【手动】授权? (opens new window) 11、单点登录界面,如何进行【自动】授权? (opens new window) 12、基于【授权码】模式,如何获得 Token 令牌? (opens new window) 13、基于【密码】模式,如何获得 Token 令牌? (opens new window) 14、如何校验、刷新、删除访问令牌? (opens new window) # 工作流 01、如何集成 Flowable 框架? (opens new window) 02、如何实现动态的流程表单? (opens new window) 03、如何实现流程表单的保存? (opens new window) 04、如何实现流程表单的展示? (opens new window) 05、如何实现流程模型的新建? (opens new window) 06、如何实现流程模型的流程图的设计? (opens new window) 07、如何实现流程模型的流程图的预览? (opens new window) 08、如何实现流程模型的分配规则? (opens new window) 09、如何实现流程模型的发布? (opens new window) 10、如何实现流程定义的查询? (opens new window) 11、如何实现流程的发起? (opens new window) 12、如何实现我的流程列表? (opens new window) 13、如何实现流程的取消? (opens new window) 14、如何实现流程的任务分配? (opens new window) 15、如何实现会签、或签任务? (opens new window) 16、如何实现我的待办任务列表? (opens new window) 17、如何实现我的已办任务列表? (opens new window) 18、如何实现任务的审批通过? (opens new window) 19、如何实现任务的审批不通过? (opens new window) 20、如何实现流程的审批记录? (opens new window) 21、如何实现流程的流程图的高亮? (opens new window) 22、如何实现工作流的短信通知? (opens new window) 23、如何实现 OA 请假的发起? (opens new window) 24、如何实现 OA 请假的审批? (opens new window) # SaaS 多租户 01、如何实现多租户的 DB 封装? (opens new window) 02、如何实现多租户的 Redis 封装? (opens new window) 03、如何实现多租户的 Web 与 Security 封装? (opens new window) 04、如何实现多租户的 Job 封装? (opens new window) 05、如何实现多租户的 MQ 与 Async 封装? (opens new window) 06、如何实现多租户的 AOP 与 Util 封装? (opens new window) 07、如何实现多租户的管理? (opens new window) 08、如何实现多租户的套餐? (opens new window) # Web 组件 01、如何实现统一 API 前缀? (opens new window) 02、如何实现统一 API 响应? (opens new window) 03、如何实现 API 全局异常处理? (opens new window) 04、如何实现全局错误码? (opens new window) 05、如何实现 API 接口文档? (opens new window) 06、如何记录 API 访问日志? (opens new window) 07、如何校验 API 请求参数? (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/05, 16:00:35 交流群 功能列表 ← 交流群 功能列表→"},{"title":"Docker 部署","path":"/wiki/YuDaoBoot/运维手册/Docker 部署/Docker 部署.html","content":"开发指南运维手册 芋道源码 2022-04-13 目录 Docker 部署 本小节,讲解如何将前端 + 后端项目,使用 Docker 容器,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: 注意:服务器的 IP 地址。 外网 IP:139.9.196.247 内网 IP:192.168.0.213 下属所有涉及到 IP 的配置,需要替换成你自己的。 # 1. 安装 Docker 执行如下命令,进行 Docker 的安装。 ## ① 使用 DaoCloud 的 Docker 高速安装脚本。参考 https://get.daocloud.io/#install-dockercurl -sSL https://get.daocloud.io/docker | sh## ② 设置 DaoCloud 的 Docker 镜像中心,加速镜像的下载速度。参考 https://www.daocloud.io/mirrorcurl -sSL https://get.daocloud.io/daotools/set_mirror.sh | sh -s http://f1361db2.m.daocloud.io## ③ 启动 Docker 服务systemctl start docker # 2. 配置 MySQL # 2.1 安装 MySQL(可选) 友情提示:使用 Docker 安装 MySQL 是可选步骤,也可以直接安装 MySQL,或者购买 MySQL 云服务。 ① 执行如下命令,使用 Docker 启动 MySQL 容器。 docker run -v /work/mysql/:/var/lib/mysql \\-p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 \\--restart=always --name mysql -d mysql 数据库文件,挂载到服务器的的 /work/mysql/ 目录下 端口是 3306,密码是 123456 ② 执行 ls /work/mysql 命令,查看 /work/mysql/ 目录的数据库文件。 # 2.2 导入 SQL 脚本 创建一个名字为 ruoyi-vue-pro 数据库,执行数据库对应的 sql (opens new window) 目录下的 SQL 文件,进行初始化。 # 3. 配置 Redis 友情提示:使用 Docker 安装 Redis 是可选步骤,也可以直接安装 Redis,或者购买 Redis 云服务。 执行如下命令,使用 Docker 启动 Redis 容器。 docker run -d --name redis --restart=always -p 6379:6379 redis:5.0.14-alpine 端口是 6379,密码未设置 # 4. 部署后端 # 4.1 修改配置 后端 dev 开发环境对应的是 application-dev.yaml (opens new window) 配置文件,主要是修改 MySQL 和 Redis 为你的地址。如下图所示: # 4.2 编译后端 在项目的根目录下,执行 mvn clean package -Dmaven.test.skip=true 命令,编译后端项目,构建出它的 Jar 包。如下图所示: 疑问:-Dmaven.test.skip=true 是什么意思? 跳过单元测试的执行。如果你项目的单元测试写的不错,建议使用 mvn clean package 命令,执行单元测试,保证交付的质量。 # 4.3 上传 Jar 包 在 Linux 服务器上创建 /work/projects/yudao-server 目录,使用 scp 命令或者 FTP 工具,将 yudao-server.jar 上传到该目录下。如下图所示: # 4.4 构建镜像 ① 在 /work/projects/yudao-server 目录下,新建 Dockerfile (opens new window) 文件,用于制作后端项目的 Docker 镜像。编写内容如下: ## AdoptOpenJDK 停止发布 OpenJDK 二进制,而 Eclipse Temurin 是它的延伸,提供更好的稳定性## 感谢复旦核博士的建议!灰子哥,牛皮!FROM eclipse-temurin:8-jre## 创建目录,并使用它作为工作目录RUN mkdir -p /yudao-serverWORKDIR /yudao-server## 将后端项目的 Jar 文件,复制到镜像中COPY yudao-server.jar app.jar## 设置 TZ 时区## 设置 JAVA_OPTS 环境变量,可通过 docker run -e "JAVA_OPTS=" 进行覆盖ENV TZ=Asia/Shanghai JAVA_OPTS="-Xms512m -Xmx512m"## 暴露后端项目的 48080 端口EXPOSE 48080## 启动后端项目ENTRYPOINT java ${JAVA_OPTS} -Djava.security.egd=file:/dev/./urandom -jar app.jar ② 执行如下命令,构建名字为 yudao-server 的 Docker 镜像。 cd /work/projects/yudao-serverdocker build -t yudao-server . ③ 在 /work/projects/yudao-server 目录下,新建 Shell 脚本 deploy.sh,使用 Docker 启动后端项目。编写内容如下: #!/bin/bashset -e## 第一步:删除可能启动的老 yudao-server 容器echo "开始删除 yudao-server 容器"docker stop yudao-server || truedocker rm yudao-server || trueecho "完成删除 yudao-server 容器"## 第二步:启动新的 yudao-server 容器 \\echo "开始启动 yudao-server 容器"docker run -d \\--name yudao-server \\-p 48080:48080 \\-e "SPRING_PROFILES_ACTIVE=dev" \\-v /work/projects/yudao-server:/root/logs/ \\yudao-serverecho "正在启动 yudao-server 容器中,需要等待 60 秒左右" 应用日志文件,挂载到服务器的的 /work/projects/yudao-server 目录下 通过 SPRING_PROFILES_ACTIVE 设置为 dev 开发环境 # 4.5 启动后端 ① 执行 sh deploy.sh 命令,使用 Docker 启动后端项目。日志如下: 开始删除 yudao-server 容器yudao-serveryudao-server完成删除 yudao-server 容器开始启动 yudao-server 容器0dfd3dc409a53ae6b5e7c5662602cf5dcb52fd4d7f673bd74af7d21da8ead9d5正在启动 yudao-server 容器中,需要等待 60 秒左右 ② 执行 docker logs yudao-server 命令,查看启动日志。看到如下内容,说明启动完成: 友情提示:如果日志比较多,可以使用 grep 进行过滤。 例如说:使用 docker logs yudao-server | grep 48080 2022-04-15 00:34:19.647 INFO 8 --- [main] [TID: N/A] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 48080 (http) # 5. 部署前端 友情提示: 本小节的内容,和 《开发指南 —— Linux 部署》 的「部署前端」是基本一致的。 # 5.1 修改配置 前端 dev 开发环境对应的是 .env.dev ( opens new window) 配置文件,主要是修改 VUE_APP_BASE_API 为你的后端项目的访问地址。如下图所示: # 5.2 编译前端 在 yudao-ui-admin 目录下,执行 npm run build:dev 命令,编译前端项目,构建出它的 dist 文件,里面是 HTML、CSS、JavaScript 等静态文件。如下图所示: 如下想要打包其它环境,可使用如下命令: npm run build:prod ## 打包 prod 生产环境npm run build:stage ## 打包 stage 预发布环境 其它高级参数说明【可暂时不看】: ① PUBLIC_PATH:静态资源地址,可用于七牛等 CDN 服务回源读取前端的静态文件,提升访问速度,建议 prod 生产环境使用。示例如下: ② VUE_APP_APP_NAME:二级部署路径,默认为 / 根目录,一般不用修改。 ③ mode:前端路由的模式,默认采用 history 路由,一般不用修改。可以通过修改 router/index.js (opens new window) 来设置为 hash 路由,示例如下: # 5.3 上传 dist 文件 在 Linux 服务器上创建 /work/projects/yudao-ui-admin 目录,使用 scp 命令或者 FTP 工具,将 dist 上传到 /work/nginx/html 目录下。如下图所示: # 5.4 启动前端? 前端无法直接启动,而是通过 Nginx 转发读取 /work/projects/yudao-ui-admin 目录的静态文件。 # 6. 配置 Nginx # 6.1 安装 Nginx Nginx 挂载到服务器的目录: /work/nginx/conf.d 用于存放配置文件 /work/nginx/html 用于存放网页文件 /work/nginx/logs 用于存放日志 /work/nginx/cert 用于存放 HTTPS 证书 ① 创建 /work/nginx 目录,并在该目录下新建 nginx.conf 文件,避免稍后安装 Nginx 报错。内容如下: user nginx;worker_processes 1;events { worker_connections 1024;}error_log /var/log/nginx/error.log warn;pid /var/run/nginx.pid;http { include /etc/nginx/mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"';# access_log /var/log/nginx/access.log main; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types text/plain application/x-javascript text/css application/xml application/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 include /etc/nginx/conf.d/*.conf; ## 加载该目录下的其它 Nginx 配置文件} ② 执行如下命令,使用 Docker 启动 Nginx 容器。 docker run -d \\--name nginx --restart always \\-p 80:80 -p 443:443 \\-e "TZ=Asia/Shanghai" \\-v /work/nginx/nginx.conf:/etc/nginx/nginx.conf \\-v /work/nginx/conf.d:/etc/nginx/conf.d \\-v /work/nginx/logs:/var/log/nginx \\-v /work/nginx/cert:/etc/nginx/cert \\-v /work/nginx/html:/usr/share/nginx/html ginx:alpine ③ 执行 docker ps 命令,查看到 Nginx 容器的状态是 UP 的。 下面,来看两种 Nginx 的配置,分别满足服务器 IP、独立域名的不同场景。 # 6.2 方式一:服务器 IP 访问 ① 在 /work/nginx/conf.d 目录下,创建 ruoyi-vue-pro.conf,内容如下: server { listen 80; server_name 139.9.196.247; ## 重要!!!修改成你的外网 IP/域名 location / { ## 前端项目 root /usr/share/nginx/html/yudao-admin-ui; index index.html index.htm; try_files $uri $uri/ /index.html; } location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://192.168.0.213:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://192.168.0.213:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }} 友情提示: [root] 指令在本地文件时,要使用 Nginx Docker 容器内的路径,即 /usr/share/nginx/html/yudao-admin-ui,否则会报 404 的错误。 ② 执行 docker exec nginx nginx -s reload 命令,重新加载 Nginx 配置。 友情提示:如果你担心 Nginx 配置不正确,可以执行 docker exec nginx nginx -t 命令。 ③ 执行 curl http://192.168.0.213/admin-api/ 命令,成功访问后端项目的内网地址,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} 执行 curl http://139.9.196.247:48080/admin-api/ 命令,成功访问后端项目的外网地址,返回结果一致。 ④ 请求 http://139.9.196.247:48080 (opens new window) 地址,成功访问前端项目的外网地址,,返回前端界面如下: # 6.3 方式二:独立域名访问 友情提示:在前端项目的编译时,需要把 `VUE_APP_BASE_API` 修改为后端项目对应的域名。 例如说,这里使用的是 http://api.iocoder.cn ① 在 /work/nginx/conf.d 目录下,创建 ruoyi-vue-pro2.conf,内容如下: server { ## 前端项目 listen 80; server_name admin.iocoder.cn; ## 重要!!!修改成你的前端域名 location / { ## 前端项目 root /usr/share/nginx/html/yudao-admin-ui; index index.html index.htm; try_files $uri $uri/ /index.html; }}server { ## 后端项目 listen 80; server_name api.iocoder.cn; ## 重要!!!修改成你的外网 IP/域名 ## 不要使用 location / 转发到后端项目,因为 druid、admin 等监控,不需要外网可访问。或者增加 Nginx IP 白名单限制也可以。 location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://192.168.0.213:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://192.168.0.213:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }} ② 执行 docker exec nginx nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://api.iocoder.cn/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://admin.iocoder.cn (opens new window) 地址,成功访问前端项目,返回前端界面如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 Linux 部署 Jenkins 部署 ← Linux 部署 Jenkins 部署→"},{"title":"HTTPS 证书","path":"/wiki/YuDaoBoot/运维手册/HTTPS 证书/HTTPS 证书.html","content":"开发指南运维手册 芋道源码 2022-04-16 目录 HTTPS 证书 本小节,讲解如何在 Nginx 配置 SSL 证书,实现前端和后端使用 HTTPS 安全访问的功能。 考虑到各大云服务厂商的文档写的比较齐全,这里更多做汇总与整理。 😜 如果想要免费的 SSL 证书,请申请 DV 单域名证书。如果要配置多个域名,可以申请多个 DV 单域名证书。 友情提示:HTTPS 的学习资料? 《HTTPS 的工作原理》 (opens new window) 《面试官:你连 HTTPS 原理没搞懂,还给我讲“中间人攻击”?》 (opens new window) # 1. 阿里云 SSL【最常用】 阿里云 SSL 证书 (opens new window) 第一步,免费证书申购流程 (opens new window) 第二步,在 Nginx 或 Tengine 服务器上安装证书 (opens new window) ↑ 点击观看 ↑ (opens new window)# 2. FreeSSL【最便宜】 FreeSSL.cn (opens new window),一个提供免费 HTTPS 证书申请的网站。 《如何在 Nginx/Apache/Tomcat/IIS 自动部署证书?》 (opens new window) 疑问:有没其它类似的平台? OHTTPS (opens new window):免费提供 HTTPS 证书,支持一键申请、自动更新、自动部署的功能。 # 3. 腾讯云 SSL 腾讯云 SSL 证书 (opens new window) 第一步,免费 SSL 证书申请流程 (opens new window) 第二步,Nginx 服务器 SSL 证书安装部署 (opens new window) ↑ 点击观看 ↑ (opens new window)# 4. 华为云 SSL 云证书管理服务 CCM (opens new window) 第一步,SSL 证书申购流程 (opens new window) 第二步,下载与安装 SSL 证书 (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 Jenkins 部署 服务监控 ← Jenkins 部署 服务监控→"},{"title":"项目结构","path":"/wiki/YuDaoBoot/萌新必读/项目结构/项目结构.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 项目结构 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 👻 后端结构 后端采用模块化的架构,按照功能拆分成多个 Maven Module,提升开发与研发的效率,带来更好的可维护性。 一共有四类 Maven Module: Maven Module 作用 yudao-dependencies Maven 依赖版本管理 yudao-framework Java 框架拓展 yudao-module-xxx XXX 功能的 Module 模块 yudao-server 管理后台 + 用户 App 的服务端 下面,我们来逐个看看。 # 1. yudao-dependencies 该模块是一个 Maven Bom,只有一个 pom.xml (opens new window) 文件,定义项目中所有 Maven 依赖的版本号,解决依赖冲突问题。 详细的解释,可见 《微服务中使用 Maven BOM 来管理你的版本依赖 》 (opens new window) 文章。 从定位上来说,它和 Spring Boot 的 spring-boot-starter-parent (opens new window) 和 Spring Cloud 的 spring-cloud-dependencies (opens new window) 是一致的。 实际上,ruoyi-vue-pro 本质上还是个单体项目,直接在根目录 pom.xml (opens new window) 管理依赖版本会更加方便,也符合绝大多数程序员的认知。但是要额外考虑一个场景,如果每个 yudao-module-xxx 模块都维护在一个独立的 Git 仓库,那么 yudao-dependencies 就可以在多个 yudao-module-xxx 模块下复用。 # 2. yudao-framework 该模块是 ruoyi-vue-pro 项目的框架封装,其下的每个 Maven Module 都是一个组件,分成两种类型: ① 技术组件:技术相关的组件封装,例如说 MyBatis、Redis 等等。 Maven Module 作用 yudao-common 定义基础 pojo 类、枚举、工具类等 yudao-spring-boot-starter-web Web 封装,提供全局异常、访问日志等 yudao-spring-boot-starter-security 认证授权,基于 Spring Security 实现 yudao-spring-boot-starter-mybatis 数据库操作,基于 MyBatis Plus 实现 yudao-spring-boot-starter-redis 缓存操作,基于 Spring Data Redis + Redisson 实现 yudao-spring-boot-starter-mq 消息队列,基于 Redis 实现,支持集群消费和广播消费 yudao-spring-boot-starter-job 定时任务,基于 Quartz 实现,支持集群模式 yudao-spring-boot-starter-flowable 工作流,基于 Flowable 实现 yudao-spring-boot-starter-protection 服务保障,提供幂等、分布式锁、限流、熔断等功能 yudao-spring-boot-starter-file 文件客户端,支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、SFTP、数据库等 yudao-spring-boot-starter-excel Excel 导入导出,基于 EasyExcel 实现 yudao-spring-boot-starter-monitor 服务监控,提供链路追踪、日志服务、指标收集等功能 yudao-spring-boot-starter-captcha 验证码 Captcha,提供滑块验证码 yudao-spring-boot-starter-test 单元测试,基于 Junit + Mockito 实现 yudao-spring-boot-starter-banner 控制台 Banner,启动打印各种提示 yudao-spring-boot-starter-desensitize 脱敏组件:支持 JSON 返回数据时,将邮箱、手机等字段进行脱敏 ② 业务组件:业务相关的组件封装,例如说数据字典、操作日志等等。如果是业务组件,名字会包含 biz 关键字。 Maven Module 作用 yudao-spring-boot-starter-biz-tenant SaaS 多租户 yudao-spring-boot-starter-biz-data-permissionn 数据权限 yudao-spring-boot-starter-biz-dict 数据字典 yudao-spring-boot-starter-biz-operatelog 操作日志 yudao-spring-boot-starter-biz-pay 支付客户端,对接微信支付、支付宝等支付平台 yudao-spring-boot-starter-biz-sms 短信客户端,对接阿里云、腾讯云等短信服务 yudao-spring-boot-starter-biz-social 社交客户端,对接微信公众号、小程序、企业微信、钉钉等三方授权平台 yudao-spring-boot-starter-biz-weixin 微信客户端,对接微信的公众号、开放平台等 yudao-spring-boot-starter-biz-error-code 全局错误码 yudao-spring-boot-starter-biz-ip 地区 & IP 库 每个组件,包含两部分: core 包:组件的核心封装,拓展相关的功能。 config 包:组件的 Spring Boot 自动配置。 # 3. yudao-module-xxx 该模块是 XXX 功能的 Module 模块,目前内置了 8 个模块。 项目 说明 是否必须 yudao-module-system 系统功能 √ yudao-module-infra 基础设施 √ yudao-module-member 会员中心 x yudao-module-bpm 工作流程 x yudao-module-pay 支付系统 x yudao-module-report 大屏报表 x yudao-module-mall 商城系统 x yudao-module-mp 微信公众号 x 每个模块包含两个 Maven Module,分别是: Maven Module 作用 yudao-module-xxx-api 提供给其它模块的 API 定义 yudao-module-xxx-biz 模块的功能的具体实现 例如说,yudao-module-infra 想要访问 yudao-module-system 的用户、部门等数据,需要引入 yudao-module-system-api 子模块。示例如下: 疑问:为什么设计 `yudao-module-xxx-api` 模块呢? 明确需要提供给其它模块的 API 定义,方便未来迁移微服务架构。 模块之间可能会存在相互引用的情况,虽然说从系统设计上要尽量避免,但是有时在快速迭代的情况下,可能会出现。此时,通过只引用对方模块的 API 子模块,解决相互引用导致 Maven 无法打包的问题。 yudao-module-xxx-api 子模块的项目结构如下: 所在包 类 作用 示例 api Api 接口 提供给其它模块的 API 接口 AdminUserApi (opens new window) api DTO 类 Api 接口的入参 ReqDTO、出参 RespDTO LoginLogCreateReqDTO (opens new window) DeptRespDTO (opens new window) enums Enum 类 字段的枚举 LoginLogTypeEnum (opens new window) enums DictTypeConstants 类 数据字典的枚举 DictTypeConstants (opens new window) enums ErrorCodeConstants 类 错误码的枚举 ErrorCodeConstants (opens new window) yudao-module-xxx-biz 子模块的项目结构如下: 所在包 类 作用 示例 api ApiImpl 类 提供给其它模块的 API 实现类 AdminUserApiImpl (opens new window) controler.admin Controller 类 提供给管理后台的 RESTful API,默认以 admin-api/ 作为前缀。 例如 admin-api/system/auth/login 登录接口 AuthController (opens new window) controler.admin VO 类 Admin Controller 接口的入参 ReqVO、出参 RespVO AuthLoginReqVO (opens new window) AuthLoginRespVO (opens new window) controler.app Controller 类,以 App 为前缀 提供给用户 App 的 RESTful API,默认以 app-api/ 作为前缀。 例如 app-api/member/auth/login 登录接口 AppAuthController (opens new window) controler.app VO 类,以 App 为前缀 App Controller 接口的入参 ReqVO、出参 RespVO AppAuthLoginReqVO (opens new window) AppAuthLoginRespVO (opens new window) controler .http 文件 IDEA Http Client 插件 (opens new window),模拟请求 RESTful 接口 AuthController.http (opens new window) service Service 接口 业务逻辑的接口定义 AdminUserService (opens new window) service ServiceImpl 类 业务逻辑的实现类 AdminUserServiceImpl (opens new window) dal - Data Access Layer,数据访问层 dal.dataobject DO 类 Data Object,映射数据库表、或者 Redis 对象 AdminUserDO (opens new window) dal.mysql Mapper 接口 数据库的操作 AdminUserMapper (opens new window) dal.redis RedisDAO 类 Redis 的操作 LoginUserRedisDAO (opens new window) convert Convert 接口 DTO / VO / DO 等对象之间的转换器 UserConvert (opens new window) job Job 类 定时任务 UserSessionTimeoutJob (opens new window) mq - Message Queue,消息队列 mq.message Message 类 发送和消费的消息 DeptRefreshMessage (opens new window) mq.producer Producer 类 消息的生产者 DeptProducer (opens new window) mq.consumer Producer 类 消息的消费者 DeptRefreshConsumer (opens new window) framework - 模块自身的框架封装 framework (opens new window) 疑问:为什么 Controller 分成 Admin 和 App 两种? 提供给 Admin 和 App 的 RESTful API 接口是不同的,拆分后更加清晰。 疑问:为什么 VO 分成 Admin 和 App 两种? 相同功能的 RESTful API 接口,对于 Admin 和 App 传入的参数、返回的结果都可能是不同的。例如说,Admin 查询某个用户的基本信息时,可以返回全部字段;而 App 查询时,不会返回 mobile 手机等敏感字段。 疑问:为什么 DO 不作为 Controller 的出入参? 明确每个 RESTful API 接口的出入参。例如说,创建部门时,只需要传入 name、parentId 字段,使用 DO 接参就会导致 type、createTime、creator 等字段可以被传入,导致前端同学一脸懵逼。 每个 RESTful API 有自己独立的 VO,可以更好的设置 Swagger 注解、Validator 校验规则,而让 DO 保持整洁,专注映射好数据库表。 疑问:为什么操作 Redis 需要通过 RedisDAO? Service 直接使用 RedisTemplate 操作 Redis,导致大量 Redis 的操作细节和业务逻辑杂糅在一起,导致代码不够整洁。通过 RedisDAO 类,将每个 Redis Key 像一个数据表一样对待,清晰易维护。 总结来说,每个模块采用三层架构 + 非严格分层,如下图所示: # 4. yudao-server 该模块是后端 Server 的主项目,通过引入需要 yudao-module-xxx 业务模块,从而实现提供 RESTful API 给 yudao-ui-admin、yudao-ui-user 等前端项目。 本质上来说,它就是个空壳(容器)!如下图所示: # 👾 前端结构 前端一共有六个项目,分别是: 项目 说明 yudao-ui-admin-vue3 (opens new window) 基于 Vue3 + element-plus 实现的管理后台 yudao-ui-admin-vben (opens new window) 基于 Vue3 + vben(ant-design-vue) 实现的管理后台 yudao-ui-admin 基于 Vue2 + element-ui 实现的管理后台 yudao-ui-go-view (opens new window) 基于 Vue3 + naive-ui 实现的大屏报表 yudao-ui-admin-uniapp 基于 uni-app + uni-ui 实现的管理后台的小程序 yudao-ui-app 基于 uni-app + uview 实现的用户 App # 1. yudao-admin-ui-vue3 .├── .github # github workflows 相关├── .husky # husky 配置├── .vscode # vscode 配置├── mock # 自定义 mock 数据及配置├── public # 静态资源├── src # 项目代码│ ├── api # api接口管理│ ├── assets # 静态资源│ ├── components # 公用组件│ ├── hooks # 常用hooks│ ├── layout # 布局组件│ ├── locales # 语言文件│ ├── plugins # 外部插件│ ├── router # 路由配置│ ├── store # 状态管理│ ├── styles # 全局样式│ ├── utils # 全局工具类│ ├── views # 路由页面│ ├── App.vue # 入口vue文件│ ├── main.ts # 主入口文件│ └── permission.ts # 路由拦截├── types # 全局类型├── .env.base # 本地开发环境 环境变量配置├── .env.dev # 打包到开发环境 环境变量配置├── .env.gitee # 针对 gitee 的环境变量 可忽略├── .env.pro # 打包到生产环境 环境变量配置├── .env.test # 打包到测试环境 环境变量配置├── .eslintignore # eslint 跳过检测配置├── .eslintrc.js # eslint 配置├── .gitignore # git 跳过配置├── .prettierignore # prettier 跳过检测配置├── .stylelintignore # stylelint 跳过检测配置├── .versionrc 自动生成版本号及更新记录配置├── CHANGELOG.md # 更新记录├── commitlint.config.js # git commit 提交规范配置├── index.html # 入口页面├── package.json├── .postcssrc.js # postcss 配置├── prettier.config.js # prettier 配置├── README.md # 英文 README├── README.zh-CN.md # 中文 README├── stylelint.config.js # stylelint 配置├── tsconfig.json # typescript 配置├── vite.config.ts # vite 配置└── windi.config.ts # windicss 配置 # 2. yudao-ui-admin-vben .├── build # 打包脚本相关│ ├── config # 配置文件│ ├── generate # 生成器│ ├── script # 脚本│ └── vite # vite配置├── mock # mock文件夹├── public # 公共静态资源目录├── src # 主目录│ ├── api # 接口文件│ ├── assets # 资源文件│ │ ├── icons # icon sprite 图标文件夹│ │ ├── images # 项目存放图片的文件夹│ │ └── svg # 项目存放svg图片的文件夹│ ├── components # 公共组件│ ├── design # 样式文件│ ├── directives # 指令│ ├── enums # 枚举/常量│ ├── hooks # hook│ │ ├── component # 组件相关hook│ │ ├── core # 基础hook│ │ ├── event # 事件相关hook│ │ ├── setting # 配置相关hook│ │ └── web # web相关hook│ ├── layouts # 布局文件│ │ ├── default # 默认布局│ │ ├── iframe # iframe布局│ │ └── page # 页面布局│ ├── locales # 多语言│ ├── logics # 逻辑│ ├── main.ts # 主入口│ ├── router # 路由配置│ ├── settings # 项目配置│ │ ├── componentSetting.ts # 组件配置│ │ ├── designSetting.ts # 样式配置│ │ ├── encryptionSetting.ts # 加密配置│ │ ├── localeSetting.ts # 多语言配置│ │ ├── projectSetting.ts # 项目配置│ │ └── siteSetting.ts # 站点配置│ ├── store # 数据仓库│ ├── utils # 工具类│ └── views # 页面├── test # 测试│ └── server # 测试用到的服务│ ├── api # 测试服务器│ ├── upload # 测试上传服务器│ └── websocket # 测试ws服务器├── types # 类型文件├── vite.config.ts # vite配置文件└── windi.config.ts # windcss配置文件 # 3. yudao-admin-ui ├── bin // 执行脚本├── build // 构建相关 ├── public // 公共文件│ ├── favicon.ico // favicon 图标│ └── index.html // html 模板│ └── robots.txt // 反爬虫├── src // 源代码│ ├── api // 所有请求【重要】│ ├── assets // 主题、字体等静态资源│ ├── components // 全局公用组件│ ├── directive // 全局指令│ ├── icons // 图标│ ├── layout // 布局│ ├── plugins // 插件│ ├── router // 路由│ ├── store // 全局 store 管理│ ├── utils // 全局公用方法│ ├── views // 视图【重要】│ ├── App.vue // 入口页面│ ├── main.js // 入口 JS,加载组件、初始化等│ ├── permission.js // 权限管理│ └── settings.js // 系统配置├── .editorconfig // 编码格式├── .env.development // 开发环境配置├── .env.production // 生产环境配置├── .env.staging // 测试环境配置├── .eslintignore // 忽略语法检查├── .eslintrc.js // eslint 配置项├── .gitignore // git 忽略项├── babel.config.js // babel.config.js├── package.json // package.json└── vue.config.js // vue.config.js # 4. yudao-admin-ui-uniapp TODO 待补充 # 5. yudao-ui-app 建设中,基于 uniapp 实现... # 6. yudao-ui-go-view TODO 待补充 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 23:29:13 技术选型 代码热加载 ← 技术选型 代码热加载→"},{"title":"Jenkins 部署","path":"/wiki/YuDaoBoot/运维手册/Jenkins 部署/Jenkins 部署.html","content":"开发指南运维手册 芋道源码 2022-04-15 目录 Jenkins 部署 本小节,讲解如何将前端 + 后端项目,使用 Jenkins 工具,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: 友情提示: 本文是 《开发指南 —— Linux 部署》 的加强版,差别在于使用 Jenkins 部署。 # 1. 安装 Jenkins 阅读 《芋道 Jenkins 极简入门 》 (opens new window) 文章,进行 Jenkins 的安装。 # 2. 部署后端 阅读 《芋道 Spring Boot 持续交付 Jenkins 入门 》 (opens new window) 文章,进行后端的部署。 可参考 Jenkins 配置如下: # 3. 部署前端 可参考 Jenkins 配置如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 Docker 部署 HTTPS 证书 ← Docker 部署 HTTPS 证书→"},{"title":"【v1.6.5】2022-12-01","path":"/wiki/YuDaoBoot/更新日志/【v1.6.5】2022-12-01/【v1.6.5】2022-12-01.html","content":"开发指南更新日志 芋道源码 2022-08-22 目录 【v1.6.5】2022-12-01 # 重构 Vue3 管理后台,优化稳定性 # 📈 Statistic 总代码行数:98088 源码代码行数:55926 注释行数:23265 单元测试用例数:671 # ⭐ New Features 【新增】管理后台登录时,使用滑块验证码,由 @xingyu4j (opens new window) 贡献 #238 (opens new window) 【新增】SSO 单点登录的示例,包括基于授权码模式、密码模式两种实现 #272 (opens new window) 【优化】提升 Vue3 实现管理后台的稳定性、兼容性,基于 vxe-table 解决 el-table 卡顿的问题,由 @xingyu4j (opens new window) 贡献 #271 (opens new window) #282 (opens new window) #283 (opens new window) #288 (opens new window) #291 (opens new window) #293 (opens new window) #299 (opens new window) #300 (opens new window) #314 (opens new window) #316 (opens new window) 【优化】使用 LocalDateTime 替换 Date,由 @xingyu4j (opens new window) 贡献 #292 (opens new window) 【新增】Spring Cache 在多租户下的支持,由 @whitedolphin (opens new window) 贡献 #257 (opens new window) 【新增】流程图 ServiceTask 的完成和 todo 高亮,增加 ServiceTask 节点的 hover 显示内容,由 @FinalFinancialFreedom (opens new window) 贡献 #260 (opens new window) 【移除】云片短信渠道,解决云片的安全风险 ea95115 (opens new window) 【移除】jasypt-spring-boot-starter 加密库使用 hutool AES 替代 ce3aefa (opens new window) 【移除】Apollo 配置中心,简化学习成本 a8cdf74 (opens new window) # 🐞 Bug Fixes 【修复】WxMaService 的 null key in entry 报错,由 @rayyer (opens new window) 贡献 #259 (opens new window) 【修复】导入用户后编辑报错,由 @wangjun (opens new window) 贡献 #258 (opens new window) 【修复】编辑流程模型时,不退出模拟直接保存,导致后续分配规则报错,由 @wangjun (opens new window) 贡献 #258 (opens new window) 【修复】数据权限,不支持隐式内连接的问题 【修复】\"定时任务 -> 调度日志 -> 详细\"里面,”执行时长“字段显示不正确的问题,由 @idevmo (opens new window) 贡献 #265 (opens new window) 【修复】Vue3 代码生成选择父菜单无效,生成的前端代码缺少字段以及格式错误,由 @jueyinghua (opens new window) 贡献 #286 (opens new window) 【修复】前端配置管理中参数分类显示错误,由 @guyuezb (opens new window) 贡献 #278 (opens new window) 【修复】短信接收报告回调时,设置 errorMsg 不正确,由 @Macro (opens new window) 贡献 #280 (opens new window) 【修复】当只修改模型并保存,再发布时,提示\"流程定义部署失败,原因:信息未发生变化\",由 @SuperHao (opens new window) 贡献 #284 (opens new window) 【修复】WXLitePayClient.java 中 copy 应忽略的字段,由 @chenlei65368 (opens new window) 贡献 #284 (opens new window) 【修复】阿里云 OSS 解析 region 时兼容带 https的 配置,由 @huangyemin (opens new window) 贡献 #276 (opens new window) 【修复】三级及以上菜单路由缓存失效问题,由 @咱哥丶 (opens new window) 贡献 #290 (opens new window) 【修复】钉钉登录时,重定向后 type 丢失导致报错的问题 7093ed3 (opens new window) 【修复】无法自定义 Icon 图标的问题 e403684 (opens new window) 【修复】访问数据库存储的文件,path 多层级时,无法访问的问题 92ace03 (opens new window) 【修复】S3 上传七牛云无 mime type 的问题,由 @石溪 (opens new window) 贡献 #313 (opens new window) 【修复】流程代办,日期时区转换错误,由 @zy_2021 (opens new window) 贡献 #309 (opens new window) # 🔨 Dependency Upgrades 【升级】spring boot from 2.6.10 to 2.7.6 【升级】flowable from 6.7.0 to 6.7.2 【升级】hutool from 5.7.22 to 5.8.9 【升级】velocity from 2.2 to 2.3 【升级】druid from 1.2.11 to 1.2.14 【升级】spring boot admin from 2.6.7 to 2.6.9 【升级】mapstruct from 1.4.1 to 1.5.3.Final 【升级】lombok from 1.16.14 to 1.18.24 【升级】mockito from 4.0.0 to 4.8.0 【升级】dynamic-datasource from 3.5.0 to 3.5.2 【升级】redisson from 3.17.4 to 3.17.7 【升级】easyexcel from 3.1.1 to 3.1.2 【升级】vue from 2.7.0 to 2.7.14 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.6】2023-01-05 【v1.6.4】2022-08-22 ← 【v1.6.6】2023-01-05 【v1.6.4】2022-08-22→"},{"title":"开发环境","path":"/wiki/YuDaoBoot/运维手册/开发环境/开发环境.html","content":"开发指南运维手册 芋道源码 2022-04-11 目录 开发环境 在系统开发的经典模型,一般会分成 2 类 5 种环境: 【线下】本地环境(local)、开发环境(dev)、测试环境(test) 【线上】预发布环境(stage)、生产环境(prod) 每个环境、每个项目使用独立的二级域名 线下、线上各一套 MySQL 数据库,多个环境共享使用 每个环境对应一个配置文件,后端使用 application-{env}.yaml (opens new window) 文件,前端使用 .env.{env} (opens new window) 文件 友情提示:项目中暂时没有 test、stage、production 等环境的配置,需要自己创建。 另外,本文的 MySQL 数据库是基础设施的“泛指”,包括 Redis 缓存、MQ 消息队列,都需要线上线下独立。 # 1. 本地环境 后端工程师使用 application-local.yaml 配置文件,在本地电脑启动后端服务,连接线下 MySQL 数据库。考虑到不影响 dev、test 环境,会配置禁用定时任务、MQ 集群消费的执行。 前端工程师也会在本地电脑启动前端服务,一般不使用 .env.local 配置文件,而是使用 .env.dev 配置文件,访问 dev 环境的后端服务。如果需要和后端进行本地联调,可以使用 .env.local 配置文件。 # 2. 开发环境 dev 环境的用户是前端工程师、后端工程师,主要用于前后端的联调、又或者功能开发完后的自测。 一些公司可能不提供 dev 环境,直接使用 test 环境,适合团队规模较小的团队,可以降低服务器的成本。 不过,测试工程师可能比较反感 dev 和 test 环境不隔离,因为他们是按照测试用例,一轮一轮的进行验收。这个时候,如果前端或者后端工程师部署了 test 环境,“破坏”了他当前轮次的验收。 疑问:开发环境可以使用独立的 MySQL 数据库吗? 当然是可以的,提供更好的环境隔离性,避免开发阶段产生过多的脏数据,影响 test 环境的验收。 不过呢,这也带来额外的成本,部署程序到 test 环境时,需要做一次数据库的同步。 # 3. 测试环境 test 环境的用户是产品经理、测试工程师,主要用于他们的功能验收。 考虑到 test 环境的稳定性,一般建议由测试工程师使用 Jenkins 等工具,完成该环境的部署。具体的原因,上面 dev 环境已经解释了。 疑问:如果需要并行验收多个功能,怎么办? 并行验收多个功能时候,对应不同的 Git 分支,需要搭建多套测试环境。 # 4. 预发布环境 stage 环境的用户是产品经理、测试工程师,连接线上 MySQL 数据库,基于真实的数据,进行功能的全回归测试。 因为数据更加真实,且更具多样性,所以往往也会测试出较多的 Bug。比较好的解决方案,是将线上数据库定期脱敏,导入线下数据库。 考虑到 stage 环境的安全性,一般由技术经理、运维工程师进行部署。 一些公司可能不提供 stage 环境,直接上线到 production 环境,风险非常高,容易产生较多报错。 # 5. 生产环境 production 环境的用户是真实用户,即线上环境。一般发布上线时,会进行核心功能的快速测试,避免主流程存在问题。 考虑到 production 环境的问题排查效率,会给技术核心开放 MySQL 数据库的读权限。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 功能开启 Linux 部署 ← 功能开启 Linux 部署→"},{"title":"Linux 部署","path":"/wiki/YuDaoBoot/运维手册/Linux 部署/Linux 部署.html","content":"开发指南运维手册 芋道源码 2022-04-12 目录 Linux 部署 本小节,讲解如何将前端 + 后端项目,使用 Shell 脚本,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: # 1. 配置 MySQL # 1.1 安装 MySQL(可选) 友情提示:安装 MySQL 是可选步骤,也可以购买 MySQL 云服务。 ① 执行如下命令,进行 MySQL 的安装。 ## ① 安装 MySQL 5.7 版本的软件源 https://dev.mysql.com/downloads/repo/yum/rpm -Uvh https://dev.mysql.com/get/mysql57-community-release-el7-11.noarch.rpm## ② 安装 MySQL Server 5.7 版本yum install mysql-server --nogpgcheck## ③ 查看 MySQL 的安装版本。结果是 mysqld Ver 5.7.37 for Linux on x86_64 (MySQL Community Server (GPL))mysqld --version ② 修改 /etc/my.cnf 文件,在文末加上 lower_case_table_names=1 和 validate_password=off 配置,执行 systemctl restart mysqld 命令重启。 ③ 执行 grep password /var/log/mysqld.log 命令,获得 MySQL 临时密码。 2022-04-16T09:39:57.365086Z 1 [Note] A temporary password is generated for root@localhost: ZOKUaehW2e.e ④ 执行如下命令,修改 MySQL 的密码,设置允许远程连接。 ## ① 连接 MySQL Server 服务,并输入临时密码mysql -uroot -p## ② 修改密码,123456 可改成你想要的密码alter user 'root'@'localhost' identified by '123456';## ③ 设置允许远程连接use mysql;update user set host = '%' where user = 'root';FLUSH PRIVILEGES; # 1.2 导入 SQL 脚本 创建一个名字为 ruoyi-vue-pro 数据库,执行数据库对应的 sql ( opens new window) 目录下的 SQL 文件,进行初始化。 # 2. 配置 Redis 友情提示:安装 Redis 是可选步骤,也可以购买 Redis 云服务。 执行如下命令,进行 Redis 的安装。 ## ① 安装 remi 软件源yum install http://rpms.famillecollet.com/enterprise/remi-release-7.rpm## ② 安装最新 Redis 版本。如果想要安装指定版本,可使用 yum --enablerepo=remi install redis-6.0.6 -y 命令yum --enablerepo=remi install redis ## ③ 查看 Redis 的安装版本。结果是 Redis server v=6.2.6 sha=00000000:0 malloc=jemalloc-5.1.0 bits=64 build=4ab9a06393930489redis-server --version## ④ 启动 Redis 服务systemctl restart redis 端口是 6379,密码未设置 # 3. 部署后端 # 3.1 修改配置 后端 dev 开发环境对应的是 application-dev.yaml (opens new window) 配置文件,主要是修改 MySQL 和 Redis 为你的地址。如下图所示: # 3.2 编译后端 在项目的根目录下,执行 mvn clean package -Dmaven.test.skip=true 命令,编译后端项目,构建出它的 Jar 包。如下图所示: 疑问:-Dmaven.test.skip=true 是什么意思? 跳过单元测试的执行。如果你项目的单元测试写的不错,建议使用 mvn clean package 命令,执行单元测试,保证交付的质量。 # 3.3 上传 Jar 包 在 Linux 服务器上创建 /work/projects/yudao-server 目录,使用 scp 命令或者 FTP 工具,将 yudao-server.jar 上传到该目录下。如下图所示: 疑问:如果构建 War 包,部署到 Tomcat 下? 并不推荐采用 War 包部署到 Tomcat 下。如果真的需要,可以参考 《Deploy a Spring Boot WAR into a Tomcat Server》 (opens new window) 文章。 # 3.4 编写脚本 在 /work/projects/yudao-server 目录下,新建 Shell 脚本 deploy.sh,用于启动后端项目。编写内容如下: #!/bin/bashset -eDATE=$(date +%Y%m%d%H%M)# 基础路径BASE_PATH=/work/projects/yudao-server# 服务名称。同时约定部署服务的 jar 包名字也为它。SERVER_NAME=yudao-server# 环境PROFILES_ACTIVE=dev# heapError 存放路径HEAP_ERROR_PATH=$BASE_PATH/heapError# JVM 参数JAVA_OPS="-Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=$HEAP_ERROR_PATH"# SkyWalking Agent 配置#export SW_AGENT_NAME=$SERVER_NAME#export SW_AGENT_COLLECTOR_BACKEND_SERVICES=192.168.0.84:11800#export SW_GRPC_LOG_SERVER_HOST=192.168.0.84#export SW_AGENT_TRACE_IGNORE_PATH="Redisson/PING,/actuator/**,/admin/**"#export JAVA_AGENT=-javaagent:/work/skywalking/apache-skywalking-apm-bin/agent/skywalking-agent.jar# 停止:优雅关闭之前已经启动的服务function stop() { echo "[stop] 开始停止 $BASE_PATH/$SERVER_NAME" PID=$(ps -ef | grep $BASE_PATH/$SERVER_NAME | grep -v "grep" | awk '{print $2}') # 如果 Java 服务启动中,则进行关闭 if [ -n "$PID" ]; then # 正常关闭 echo "[stop] $BASE_PATH/$SERVER_NAME 运行中,开始 kill [$PID]" kill -15 $PID # 等待最大 120 秒,直到关闭完成。 for ((i = 0; i < 120; i++)) do sleep 1 PID=$(ps -ef | grep $BASE_PATH/$SERVER_NAME | grep -v "grep" | awk '{print $2}') if [ -n "$PID" ]; then echo -e ".\\c" else echo '[stop] 停止 $BASE_PATH/$SERVER_NAME 成功' break fi done # 如果正常关闭失败,那么进行强制 kill -9 进行关闭 if [ -n "$PID" ]; then echo "[stop] $BASE_PATH/$SERVER_NAME 失败,强制 kill -9 $PID" kill -9 $PID fi # 如果 Java 服务未启动,则无需关闭 else echo "[stop] $BASE_PATH/$SERVER_NAME 未启动,无需停止" fi}# 启动:启动后端项目function start() { # 开启启动前,打印启动参数 echo "[start] 开始启动 $BASE_PATH/$SERVER_NAME" echo "[start] JAVA_OPS: $JAVA_OPS" echo "[start] JAVA_AGENT: $JAVA_AGENT" echo "[start] PROFILES: $PROFILES_ACTIVE" # 开始启动 nohup java -server $JAVA_OPS $JAVA_AGENT -jar $BASE_PATH/$SERVER_NAME.jar --spring.profiles.active=$PROFILES_ACTIVE > nohup.out 2>&1 & echo "[start] 启动 $BASE_PATH/$SERVER_NAME 完成"}# 部署function deploy() { cd $BASE_PATH # 第一步:停止 Java 服务 stop # 第二步:启动 Java 服务 start}deploy 友情提示: 脚本的详细讲解,可见 《芋道 Jenkins 极简入门 》 (opens new window) 的「2.3 远程服务器配置 」小节。 如果你想要修改脚本,主要关注 BASE_PATH、PROFILES_ACTIVE、JAVA_OPS 三个参数。如下图所示: # 3.5 启动后端 ① 【可选】执行 yum install -y java-1.8.0-openjdk 命令,安装 OpenJDK 8。 友情提示:如果已经安装 JDK,可不安装。建议使用的 JDK 版本为 8、11、17 这三个。 ② 执行 sh deploy.sh 命令,启动后端项目。日志如下: [stop] 开始停止 /work/projects/yudao-server/yudao-server[stop] /work/projects/yudao-server/yudao-server 未启动,无需停止[start] 开始启动 /work/projects/yudao-server/yudao-server[start] JAVA_OPS: -Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/work/projects/yudao-server/heapError[start] JAVA_AGENT:[start] PROFILES: dev[start] 启动 /work/projects/yudao-server/yudao-server 完成 ③ 执行 tail -f nohup.out 命令,查看启动日志。看到如下内容,说明启动完成: 2022-04-13 00:06:20.049 INFO 1395 --- [main] [TID: N/A] c.i.yudao.server.YudaoServerApplication : Started YudaoServerApplication in 35.315 seconds (JVM running for 36.282) # 4. 部署前端 # 4.1 修改配置 前端 dev 开发环境对应的是 .env.dev ( opens new window) 配置文件,主要是修改 VUE_APP_BASE_API 为你的后端项目的访问地址。如下图所示: # 4.2 编译前端 在 yudao-ui-admin 目录下,执行 npm run build:dev 命令,编译前端项目,构建出它的 dist 文件,里面是 HTML、CSS、JavaScript 等静态文件。如下图所示: 如下想要打包其它环境,可使用如下命令: npm run build:prod ## 打包 prod 生产环境npm run build:stage ## 打包 stage 预发布环境 其它高级参数说明【可暂时不看】: ① PUBLIC_PATH:静态资源地址,可用于七牛等 CDN 服务回源读取前端的静态文件,提升访问速度,建议 prod 生产环境使用。示例如下: ② VUE_APP_APP_NAME:二级部署路径,默认为 / 根目录,一般不用修改。 ③ mode:前端路由的模式,默认采用 history 路由,一般不用修改。可以通过修改 router/index.js (opens new window) 来设置为 hash 路由,示例如下: # 4.3 上传 dist 文件 在 Linux 服务器上创建 /work/projects/yudao-ui-admin 目录,使用 scp 命令或者 FTP 工具,将 dist 上传到该目录下。如下图所示: # 4.4 启动前端? 前端无法直接启动,而是通过 Nginx 转发读取 /work/projects/yudao-ui-admin 目录的静态文件。 # 5. 配置 Nginx # 5.1 安装 Nginx 参考 Nginx 官方文档 (opens new window),安装 Nginx 服务。命令如下: ## 添加 yum 源yum install epel-releaseyum update## 安装 nginxyum install nginx## 启动 nginx nginx Nginx 默认配置文件是 /etc/nginx/nginx.conf。 下面,来看两种 Nginx 的配置,分别满足服务器 IP、独立域名的不同场景。 # 5.2 方式一:服务器 IP 访问 ① 修改 Nginx 配置,内容如下: worker_processes 1;events { worker_connections 1024;}http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 server { listen 80; server_name 192.168.225.2; ## 重要!!!修改成你的外网 IP/域名 location / { ## 前端项目 root /work/projects/yudao-ui-admin; index index.html index.htm; try_files $uri $uri/ /index.html; } location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://localhost:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://localhost:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }} ② 执行 nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://192.168.225.2/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://192.168.225.2 (opens new window) 地址,成功访问前端项目,返回前端界面如下: # 5.3 方式二:独立域名访问 友情提示:在前端项目的编译时,需要把 `VUE_APP_BASE_API` 修改为后端项目对应的域名。 例如说,这里使用的是 http://api.iocoder.cn ① 修改 Nginx 配置,内容如下: worker_processes 1;events { worker_connections 1024;}http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types text/plain application/x-javascript text/css application/xml application/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 server { ## 前端项目 listen 80; server_name admin.iocoder.cn; ## 重要!!!修改成你的前端域名 location / { ## 前端项目 root /work/projects/yudao-ui-admin; index index.html index.htm; try_files $uri $uri/ /index.html; } } server { ## 后端项目 listen 80; server_name api.iocoder.cn; ## 重要!!!修改成你的外网 IP/域名 ## 不要使用 location / 转发到后端项目,因为 druid、admin 等监控,不需要外网可访问。或者增加 Nginx IP 白名单限制也可以。 location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://localhost:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://localhost:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }} ② 执行 nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://api.iocoder.cn/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://admin.iocoder.cn (opens new window) 地址,成功访问前端项目,返回前端界面如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 开发环境 Docker 部署 ← 开发环境 Docker 部署→"},{"title":"服务监控","path":"/wiki/YuDaoBoot/运维手册/服务监控/服务监控.html","content":"开发指南运维手册 芋道源码 2022-04-16 目录 服务监控 系统使用 Spring Boot Admin 和 SkyWalking 实现后端服务的监控。 # 1. Spring Boot Admin 阅读 《芋道 Spring Boot 监控工具 Admin 入门》 (opens new window) 文章,入门 Spring Boot Admin。 注意,Spring Boot Admin 是内嵌在 yudao-server 后端项目中,无需单独启动。 # 1.1 配置 在 application-local.yaml (opens new window) 配置文件中,通过 spring.boot.admin 配置项,设置 Spring Boot Admin 的配置。如下图所示: 疑问:prod 生产环境下,后端部署多个 JVM 进程时,spring.boot.admin.client.url 填写哪个 IP? 第一步,在 Nginx 中配置 /admin 路径,转发到多个 JVM 的 IP 上,使用 backup (opens new window) 参数实现主备。注意,该转发只允许内网访问,避免安全问题!!! 第二步,设置 spring.boot.admin.client.url 配置项,为 Nginx 的 内置 IP/admin 地址。 # 1.2 使用 ① 访问 http://127.0.0.1:48080/admin/applications (opens new window) 地址,可以在 Spring Boot Admin 中,查看到应用与实例的列表。如下图所示: ② 点击 yudao-server 应用,再点击实例,可以查看到该实例的细节信息。如下图所示: ③ 点击 [日志 -> 日志文件] 菜单,查看该示例的日志内容。如下图所示: 点击 [日志 -> 日志文件] 菜单,可动态修改 Logger 的日志级别,方便排查线上的某些 BUG。如下图所示: 补充说明:也可以通过前端的 [基础设施 -> Java 监控] 菜单。 前端 [基础设施 -> Java 监控] 菜单,通过 iframe 内嵌后端 /admin/applications 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.spring-boot-admin 配置项。 # 2. SkyWalking 阅读 《芋道 SkyWalking 极简入门》 (opens new window) 文章,入门 SkyWalking。 注意,SkyWalking 需要单独启动,预计需要 4 核 8G 的硬件资源。 # 2.1 配置 ① 在 logback-spring.xml (opens new window) 配置文件中,添加 SkyWalking 收集日志的 appender 配置。如下图所示: ② 修改 SkyWalking 在前端项目的 [基础设施 -> 监控平台] 对应的 skywaling/index.vue (opens new window) 文件,调整为你 SkyWalking 的访问地址。如下图所示: # 2.2 使用 ① 点击 [基础设施 -> 监控平台] 菜单,可以看到 SkyWalking 提供的监控平台。如下图所示: ② 点击 yudao-server 服务,查看该服务的监控信息。如下图所示: 补充说明: 前端 [基础设施 -> 监控平台] 菜单,通过 iframe 内嵌 http://skywalking.iocoder.cn 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.skywalking 配置项。 # 3. 更多监控系统 # 3.1 Prometheus 参见 《芋道 Prometheus + Grafana + Alertmanager 极简入门 》 (opens new window) 文章。 # 3.2 ELK 参见 芋道 ELK(Elasticsearch + Logstash + Kibana) 极简入门 (opens new window) 文章。 # 3.3 Sentry 参见 《Sentry 极简入门 》 (opens new window) 文章。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:25:01 HTTPS 证书 开发规范 ← HTTPS 证书 开发规范→"},{"title":"Icon 图标","path":"/wiki/YuDaoCloud/前端手册 Vue 2/Icon 图标/Icon 图标.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 Icon 图标 Element UI 内置多种 Icon 图标,可参考 Element Icon 图标 (opens new window) 的文档。 在项目的 /src/assets/icons/svg (opens new window) 目录下,自定义了 Icon 图标,默认注册到全局中,可以在项目中任意地方使用。如下图所示: # 1. 使用方式 <!-- 示例一: icon-class 为 icon 的名字 class-name 为 icon 的自定义 class--><svg-icon icon-class="password" class-name='custom-class' /><!-- 示例二: icon 为 Element UI 的图标--><el-button icon="el-icon-plus">新增</el-button><!-- 示例三:结合上述两示例 --><el-button> <svg-icon icon-class="password" class-name='custom-class' /> 新增</el-button> # 2. 自定义图标 ① 访问 https://www.iconfont.cn/ ( opens new window) 地址,搜索你想要的图标,下载 SVG 格式。如下图所示: 友情提示:其它 SVG 图标网站也可以。 ② 将 SVG 图标添加到 @/icons/svg ( opens new window) 目录下,然后进行使用。 <svg-icon icon-class="helpless" /> # 3. 改变颜色 <svg-icon /> 默认会读取其父级的 color fill: currentColor; 。 你可以改变父级的 color ,或者直接改变 fill 的颜色即可。 疑问: 如果你遇到图标颜色不对,可以参照本 issue ( opens new window) 进行修改 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 菜单路由 字典数据 ← 菜单路由 字典数据→"},{"title":"字典数据","path":"/wiki/YuDaoCloud/前端手册 Vue 2/字典数据/字典数据.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 字典数据 本小节,讲解前端如何使用 [系统管理 -> 字典管理] 菜单的字典数据,例如说字典数据的下拉框、单选 / 多选按钮、高亮展示等等。 # 1. 全局缓存 用户登录成功后,前端会从后端获取到全量的字典数据,缓存在 store 中。如下图所示: 这样,前端在使用到字典数据时,无需重复请求后端,提升用户体验。 不过,缓存暂时未提供刷新,所以在字典数据发生变化时,需要用户刷新浏览器,进行重新加载。 # 2. DICT_TYPE 在 dict.js (opens new window) 文件中,使用 DICT_TYPE 枚举了字典的 KEY。如下图所示: 后续如果有新的字典 KEY,需要你自己进行添加。 # 3. DictTag 字典标签 <dict-tag /> (opens new window) 组件,翻译字段对应的字典展示文本,并根据 colorType、cssClass 进行高亮。使用示例如下: <!-- type: 字典 KEY value: 字典值--><dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_TYPE" :value="row.logType" /> # 4. 字典工具类 在 dict.js (opens new window) 文件中,提供了字典工具类,方法如下: // 获取 dictType 对应的数据字典数组export function getDictDatas(dictType) { /** 省略代码 */ }// 获得 dictType + value 对应的字典展示文本export function getDictDataLabel(dictType, value) { /** 省略代码 */ } 结合 Element UI 的表单组件,使用示例如下: <!-- radio 单选框 --><el-radio v-for="dict in this.getDictDatas(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="parseInt(dict.value)">{{dict.label}}</el-radio><!-- select 下拉框 --><el-select v-model="form.code" placeholder="请选择渠道编码" clearable> <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE)" :key="dict.value" :label="dict.label" :value="dict.value"/></el-select> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 Icon 图标 系统组件 ← Icon 图标 系统组件→"},{"title":"开发规范","path":"/wiki/YuDaoCloud/前端手册 Vue 2/开发规范/开发规范.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 开发规范 # 1. view 页面 在 @views (opens new window) 目录下,每个模块对应一个目录,它的所有功能的 .vue 都放在该目录里。 一般来说,一个路由对应一个 .vue 文件。 # 2. api 请求 在 @/api (opens new window) 目录下,每个模块对应一个 .api 文件。 每个 API 方法,会调用 request 方法,发起对后端 RESTful API 的调用。 # 2.1 请求封装 @/utils/request (opens new window) 基于 axios (opens new window) 封装,统一处理 GET、POST 方法的请求参数、请求头,以及错误提示信息等。 # 2.1.1 创建 axios 实例 baseURL 基础路径 timeout 超时时间 实现代码 import axios from 'axios'// 创建 axios 实例const service = axios.create({ // axios 中请求配置有 baseURL 选项,表示请求 URL 公共部分 baseURL: process.env.VUE_APP_BASE_API + '/admin-api/', // 此处的 /admin-api/ 地址,原因是后端的基础路径为 /admin-api/ // 超时 timeout: 10000}) # 2.1.2 Request 拦截器 Authorization、tenant-id 请求头 GET 请求参数的拼接 实现代码 import { getToken } from '@/utils/auth'import { getTenantEnable } from "@/utils/ruoyi";import Cookies from "js-cookie";service.interceptors.request.use(config => { // 是否需要设置 token const isToken = (config.headers || {}).isToken === false if (getToken() && !isToken) { config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改 } // 设置租户 if (getTenantEnable()) { const tenantId = Cookies.get('tenantId'); if (tenantId) { config.headers['tenant-id'] = tenantId; } } // get 请求映射 params 参数 if (config.method === 'get' && config.params) { let url = config.url + '?'; for (const propName of Object.keys(config.params)) { const value = config.params[propName]; var part = encodeURIComponent(propName) + "="; if (value !== null && typeof(value) !== "undefined") { if (typeof value === 'object') { for (const key of Object.keys(value)) { let params = propName + '[' + key + ']'; var subPart = encodeURIComponent(params) + "="; url += subPart + encodeURIComponent(value[key]) + "&"; } } else { url += part + encodeURIComponent(value) + "&"; } } } url = url.slice(0, -1); config.params = {}; config.url = url; } return config}, error => { console.log(error) Promise.reject(error)}) # 2.1.3 Response 拦截器 Token 失效、登录过期时,跳回首页 请求失败,Message 错误提示 实现代码 import { Notification, MessageBox, Message } from 'element-ui'import store from '@/store'import errorCode from '@/utils/errorCode'import Cookies from "js-cookie";export let isRelogin = { show: false };service.interceptors.response.use(res => { // 未设置状态码则默认成功状态 const code = res.data.code || 200; // 获取错误信息 const msg = errorCode[code] || res.data.msg || errorCode['default'] if (code === 401) { if (!isRelogin.show) { isRelogin.show = true; MessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' } ).then(() => { isRelogin.show = false; store.dispatch('LogOut').then(() => { location.href = '/index'; }) }).catch(() => { isRelogin.show = false; }); } return Promise.reject('无效的会话,或者会话已过期,请重新登录。') } else if (code === 500) { Message({ message: msg, type: 'error' }) return Promise.reject(new Error(msg)) } else if (code !== 200) { Notification.error({ title: msg }) return Promise.reject('error') } else { // 请求成功! return res.data } }, error => { console.log('err' + error) let { message } = error; if (message === "Network Error") { message = "后端接口连接异常"; } else if (message.includes("timeout")) { message = "系统接口请求超时"; } else if (message.includes("Request failed with status code")) { message = "系统接口" + message.substr(message.length - 3) + "异常"; } Message({ message: message, type: 'error', duration: 5 * 1000 }) return Promise.reject(error) }) # 2.2 交互流程 一个完整的前端 UI 交互到服务端处理流程,如下图所示: 以 [系统管理 -> 用户管理] 菜单为例,查看它是如何读取用户列表的。代码如下: // ① api/system/user.jsimport request from '@/utils/request'// 查询用户列表export function listUser(query) { return request({ url: '/system/user/page', method: 'get', params: query })}// ② views/system/user/index.vueimport { listUser } from "@/api/system/user";export default { data() { userList: null, loading: true }, methods: { getList() { this.loading = true listUser().then(response => { this.userList = response.rows this.loading = false }) } }} # 2.3 自定义 baseURL 基础路径 如果想要自定义的 baseURL 基础路径,可以通过 baseURL 进行直接覆盖。示例如下: export function listUser(query) { return request({ url: '/system/user/page', method: 'get', params: query, baseURL: 'https://www.iocoder.cn' // 自定义 })} # 3. component 组件 ① 在 @/components ( opens new window) 目录下,实现全局 组件,被所有模块所公用。例如说,富文本编辑器、各种各搜索组件、封装的分页组件等等。 ② 每个模块的业务组件,可实现在 views 目录下,自己模块的目录的 components 目录下,避免单个 .vue 文件过大,降低维护成功。例如说, @/views/pay/app/components/xxx.vue。 # 4. style 样式 ① 在 @/styles ( opens new window) 目录下,实现全局 样式,被所有页面所公用。 ② 每个 .vue 页面,可在 <style /> 标签中添加样式,注意需要添加 scoped 表示只作用在当前页面里,避免造成全局的样式污染。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 服务监控 菜单路由 ← 服务监控 菜单路由→"},{"title":"系统组件","path":"/wiki/YuDaoCloud/前端手册 Vue 2/系统组件/系统组件.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 系统组件 # 1. 引入三方组件 除了 Element UI 组件以及项目内置的系统组件,有时还需要引入其它三方组件 (opens new window)。 # 1.1 如何安装 这里,以引入 vue-count-to (opens new window) 为例。在终端输入下面的命令完成安装: ## 加上 --save 参数,会自动添加依赖到 package.json 中去。npm install vue-count-to --save # 1.2 如何注册 Vue 注册组件有两种方式:全局注册、局部注册。 # 1.2.1 局部注册 在对应的 Vue 页面中,使用 components 属性来注册组件。代码如下: <template> <countTo :startVal='startVal' :endVal='endVal' :duration='3000'></countTo></template><script>import countTo from 'vue-count-to';export default { components: { countTo }, // components 属性 data () { return { startVal: 0, endVal: 2017 } }}</script> # 1.2.2 全局注册 ① 在 main.js ( opens new window) 中,全局注册组件。代码如下: import countTo from 'vue-count-to'Vue.component('countTo', countTo) ② 在对应的 Vue 页面中,直接使用组件,无需注册。代码如下: <template> <countTo :startVal='startVal' :endVal='endVal' :duration='3000'></countTo></template> # 2. 系统组件 项目使用到的相关组件。 # 2.1 基础框架组件 element-ui ( opens new window) vue-element-admin ( opens new window) # 2.2 树形选择组件 vue-treeselect ( opens new window) 在 menu/index.vue ( opens new window) 的使用案例: <el-form-item label="上级菜单"> <treeselect v-model="form.parentId" :options="menuOptions" :normalizer="normalizer" :show-count="true" placeholder="选择上级菜单"/></el-form-item> # 2.3 表格分页组件 el-pagination (opens new window),二次封装成 pagination (opens new window) 组件。 在 notice/index.vue (opens new window) 的使用案例: <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize" @pagination="getList"/> # 2.4 工具栏右侧组件 right-toolbar (opens new window) 在 notice/index.vue (opens new window) 的使用案例: <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> # 2.5 文件上传组件 file-upload (opens new window) # 2.6 图片上传组件 图片上传组件 image-upload (opens new window) 图片预览组件 image-preview (opens new window) # 2.7 富文本编辑器 quill (opens new window),二次封装成 Editor (opens new window) 组件。 在 notice/index.vue (opens new window) 的使用案例: <el-form-item label="内容"> <editor v-model="form.content" :min-height="192"/></el-form-item> # 2.8 表单设计组件 ① 表单设计组件 form-generator (opens new window) 在 build/index.vue (opens new window) 中使用,效果如下图: ② 表单展示组件 parser (opens new window),基于 form-generator (opens new window) 封装。 在 processInstance/create.vue (opens new window) 的使用案例: <parser :key="new Date().getTime()" :form-conf="detailForm" @submit="submitForm" /> # 2.9 工作流组件 bpmn-process-designer (opens new window),二次封装成 bpmnProcessDesigner (opens new window) 工作流设计组件 ① 工作流设计组件 my-process-designer (opens new window),在 bpm/model/modelEditor.vue (opens new window) 中使用案例: <!-- 流程设计器,负责绘制流程等 --><my-process-designer :key="`designer-${reloadIndex}`" v-model="xmlString" v-bind="controlForm" keyboard ref="processDesigner" @init-finished="initModeler" @save="save"/><!-- 流程属性器,负责编辑每个流程节点的属性 --><my-properties-panel :key="`penal-${reloadIndex}`" :bpmn-modeler="modeler" :prefix="controlForm.prefix" class="process-panel" :model="model" /> ② 工作流展示组件 my-process-viewer (opens new window),在 bpm/model/modelEditor.vue (opens new window) 中使用案例: <my-process-viewer key="designer" v-model="bpmnXML" v-bind="bpmnControlForm" :activityData="activityList" :processInstanceData="processInstance" :taskData="tasks" /> # 2.10 Cron 表达式组件 vue-crontab (opens new window),二次封装成 crontab (opens new window) 组件。 在 job/index.vue (opens new window) 的使用案例: <crontab @hide="openCron=false" @fill="crontabFill" :expression="expression"></crontab> # 2.11 内容复制组件 clipboard (opens new window),使用可见 文档 (opens new window)。 在 codegen/index.vue (opens new window) 的使用案例: <el-link :underline="false" icon="el-icon-document-copy" style="float:right" v-clipboard:copy="item.code" v-clipboard:success="clipboardSuccess"> 复制</el-link> # 3. 其它推荐组件 推荐一些其它组件,可自己引入后使用。 Tree Table 树形表格:使用文档 (opens new window) Excel 前端直接导出:使用文档 (opens new window) CodeMirror 代码编辑器:使用文档 (opens new window) wangEditor 文本编辑器:使用文档 (opens new window) mavonEditor Markdown 编辑器:使用文档 (opens new window) # 4. 自定义组件 在 @/components (opens new window) 目录下,创建 .vue 文件,在通过 components 进行注册即可。 # 4.1 创建使用 新建一个简单的 a 组件来举例子。 ① 在 @/components/ 目录下,创建 test 文件,再创建 a.vue 文件。代码如下: <!-- 子组件 --><template> <div>这是a组件</div></template> ② 在其它 Vue 页面,导入并注册后使用。代码如下: <!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa></testa> <!-- 3. 使用 --> </div></template><script>import a from "@/components/a"; // 1. 引入export default { components: { testa: a } // 2. 注册};</script> # 4.2 组件通信 基于上述的 a 示例组件,讲解父子组件如何通信。 ① 子组件通过 props 属性,来接收父组件传递的值。代码如下: <!-- 子组件 --><template> <div>这是a组件 name:{{ name }}</div></template><script> export default { props: { // 1. props 的 name 进行接收 name: { type: String, default: "" }, } };</script><!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa :name="name"></testa> <!-- 2. :name 传入 --> </div></template><script>import a from "@/components/a";export default { components: { testa: a }, data() { return { name: "芋道" }; },};</script> ② 子组件通过 $emit 方法,让父组件监听到自定义事件。代码如下: <!-- 子组件 --><template> <div> 这是a组件 name:{{ name }} <button @click="click">发送</button> </div></template><script>export default { props: { name: { type: String, default: "" }, }, data() { return { message: "我是来自子组件的消息" }; }, methods: { click() { this.$emit("ok", this.message); // 1. $emit 方法,通知 ok 事件,message 是参数 }, },};</script><!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa :name="name" @ok="ok"></testa> 子组件传来的值 : {{ message }} </div></template><script>import a from "@/components/a";export default { components: { testa: a }, data() { return { name: "芋道", message: "" }; }, methods: { ok(message) { // 2. 声明 ok 方法,监听 ok 自定义事件 this.message = message; }, },};</script> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 字典数据 通用方法 ← 字典数据 通用方法→"},{"title":"通用方法","path":"/wiki/YuDaoCloud/前端手册 Vue 2/通用方法/通用方法.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 通用方法 本小节,分享前端项目的常用方法。 # 1. $tab 对象 @tab 对象,由 plugins/tab.js (opens new window) 实现,用于 Tab 标签相关的操作。它有如下方法: ① 打开页签 this.$tab.openPage("用户管理", "/system/user");this.$tab.openPage("用户管理", "/system/user").then(() => { // 执行结束的逻辑}) ② 修改页签 const obj = Object.assign({}, this.$route, { title: "自定义标题" })this.$tab.updatePage(obj);this.$tab.updatePage(obj).then(() => { // 执行结束的逻辑}) ③ 关闭页签 // 关闭当前 tab 页签,打开新页签const obj = { path: "/system/user" };this.$tab.closeOpenPage(obj);// 关闭当前页签,回到首页this.$tab.closePage();// 关闭指定页签const obj = { path: "/system/user", name: "User" };this.$tab.closePage(obj);this.$tab.closePage(obj).then(() => { // 执行结束的逻辑}) ④ 刷新页签 // 刷新当前页签this.$tab.refreshPage();// 刷新指定页签const obj = { path: "/system/user", name: "User" };this.$tab.refreshPage(obj);this.$tab.refreshPage(obj).then(() => { // 执行结束的逻辑}) ⑤ 关闭所有页签 this.$tab.closeAllPage();this.$tab.closeAllPage().then(() => { // 执行结束的逻辑}) ⑥ 关闭左侧页签 this.$tab.closeLeftPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeLeftPage(obj);this.$tab.closeLeftPage(obj).then(() => { // 执行结束的逻辑}) ⑦ 关闭右侧页签 this.$tab.closeRightPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeRightPage(obj);this.$tab.closeRightPage(obj).then(() => { // 执行结束的逻辑}) ⑧ 关闭其它页签 this.$tab.closeOtherPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeOtherPage(obj);this.$tab.closeOtherPage(obj).then(() => { // 执行结束的逻辑}) # 2. $modal 对象 @modal 对象,由 plugins/modal.js (opens new window) 实现,用于做消息提示、通知提示、对话框提醒、二次确认、遮罩等。它有如下方法: ① 提供成功、警告和错误等反馈信息 this.$modal.msg("默认反馈");this.$modal.msgError("错误反馈");this.$modal.msgSuccess("成功反馈");this.$modal.msgWarning("警告反馈"); ② 提供成功、警告和错误等提示信息 this.$modal.alert("默认提示");this.$modal.alertError("错误提示");this.$modal.alertSuccess("成功提示");this.$modal.alertWarning("警告提示"); ③ 提供成功、警告和错误等通知信息 this.$modal.notify("默认通知");this.$modal.notifyError("错误通知");this.$modal.notifySuccess("成功通知");this.$modal.notifyWarning("警告通知"); ④ 提供确认窗体信息 this.$modal.confirm('确认信息').then(function() { // ...}).then(() => { // ...}).catch(() => {}); ⑤ 提供遮罩层信息 // 打开遮罩层this.$modal.loading("正在导出数据,请稍后...");// 关闭遮罩层this.$modal.closeLoading(); # 3. $auth 对象 @auth 对象,由 plugins/auth.js (opens new window) 实现,用于验证用户是否拥有某(些)权限或角色。它有如下方法: ① 验证用户权限 // 验证用户是否具备某权限this.$auth.hasPermi("system:user:add");// 验证用户是否含有指定权限,只需包含其中一个this.$auth.hasPermiOr(["system:user:add", "system:user:update"]);// 验证用户是否含有指定权限,必须全部拥有this.$auth.hasPermiAnd(["system:user:add", "system:user:update"]); ② 验证用户角色 // 验证用户是否具备某角色this.$auth.hasRole("admin");// 验证用户是否含有指定角色,只需包含其中一个this.$auth.hasRoleOr(["admin", "common"]);// 验证用户是否含有指定角色,必须全部拥有this.$auth.hasRoleAnd(["admin", "common"]); # 4. $cache 对象 @auth 对象,由 plugins/cache.js (opens new window) 实现,基于 session 或 local 实现不同级别的缓存。它有如下方法: 对象名称 缓存类型 session 会话级缓存,通过 sessionStorage (opens new window) 实现 local 本地级缓存,通过 localStorage (opens new window) 实现 ① 读写 String 缓存 // local 普通值this.$cache.local.set('key', 'local value')console.log(this.$cache.local.get('key')) // 输出 'local value'// session 普通值this.$cache.session.set('key', 'session value')console.log(this.$cache.session.get('key')) // 输出 'session value' ② 读写 JSON 缓存 // local JSON值 this.$cache.local.setJSON('jsonKey', { localProp: 1 })console.log(this.$cache.local.getJSON('jsonKey')) // 输出 '{localProp: 1}'// session JSON值this.$cache.session.setJSON('jsonKey', { sessionProp: 1 })console.log(this.$cache.session.getJSON('jsonKey')) // 输出 '{sessionProp: 1}' ③ 删除缓存 this.$cache.local.remove('key')this.$cache.session.remove('key') # 5. $download 对象 $download 对象,由 plugins/download.js (opens new window) 实现,用于各种类型的文件下载。它有如下方法: 方法列表 this.$download.excel(data, fileName);this.$download.word(data, fileName);this.$download.zip(data, fileName);this.$download.html(data, fileName);this.$download.markdown(data, fileName); 在 user/index.vue (opens new window) 页面中,导出 Excel 文件的代码如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 系统组件 配置读取 ← 系统组件 配置读取→"},{"title":"配置读取","path":"/wiki/YuDaoCloud/前端手册 Vue 2/配置读取/配置读取.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 配置读取 在 [基础设施 -> 配置管理] 菜单,可以动态修改配置,无需重启服务器即可生效。 提示 对应 《后端手册 —— 配置中心》 文档。 # 1. 读取配置 前端调用 /@api/infra/config (opens new window) 的 #getConfigKey(configKey) 方法,获取指定 key 对应的配置的值。代码如下: export function getConfigKey(configKey) { return request({ url: '/infra/config/get-value-by-key?key=' + configKey, method: 'get' })} # 2. 实战案例 在 src/views/infra/server/index.vue ( opens new window) 页面中,获取 key 为 \"url.skywalking\" 的配置的值。代码如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:10 通用方法 开发规范 ← 通用方法 开发规范→"},{"title":"大屏设计器","path":"/wiki/YuDaoCloud/大屏手册/大屏设计器/大屏设计器.html","content":"开发指南大屏手册 芋道源码 2023-02-07 目录 大屏设计器 数据可视化,一般可以通过报表设计器、或者大屏设计器来实现。本小节,我们来讲解大屏设计器的功能开启。 大屏设计器,指的是通过拖拽图表或页面元素,无需编写代码即可制作数据大屏。如下图所示: 在项目中,通过集成市面上的报表引擎,实现了大屏设计器的能力。目前使用如下: 是否集成 是否开源 AJ-Report (opens new window) 集成中 开源 Go-View (opens new window) 集成中 开源 JimuReport (opens new window) 不集成 不开源 为什么不使用 JimuReport 报表引擎呢? 因为 JimuReport 的大屏设计器是商业化的,需要购买授权。我手头暂时没有授权,所以没办法集成~ # 1. 功能开启 yudao-module-report 实现了报表设计器的能力,开启步骤如下: 第一步,导入报表的 SQL 数据库脚本 第二步,启动 yudao-report-report 服务 第三步,启动大屏设计器的前端项目 # 1.1 第一步,导入 SQL 导入 go-view.sql (opens new window) 脚本,初始化 Go-View 相关的表结构和数据。 # 1.2 第二步,启动 report 服务 运行该服务的 ReportServerApplication (opens new window) 启动类,看到 \"Init JimuReport Config [ 线程池 ] \" 说明开启成功。 # 1.3 第三步,启动前端项目(AJ-Report) TODO 开发中,预计 4 月份左右。 # 1.3 第三步,启动前端项目(Go-View) ① 克隆 yudao-ui-go-view (opens new window) 项目,执行如下命令进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ② 启动完成后,浏览器会自动打开 http://127.0.0.1:3000 (opens new window) 地址,可以看到前端界面。 ③ 访问 [报表管理 -> 大屏设计器] 菜单,可以查看对应的功能。如下图所示: # 2. 如何使用? # 2.1 AJ-Report 大屏设计器 TODO 开发中,预计 4 月份左右。 # 2.2 Go-View 大屏设计器 可以查看 Go-View 的官方文档,主要是: GoView 说明文档 —— 页面引导 (opens new window) GoView 说明文档 —— 常见问题 (opens new window) 如果你想了解在 Go-View 中,如何使用 SQL 或 HTTP 查询数据,可以查看内置的两个示例: 集成 Go-View 的代码实现? ① 后端:Go-View 的后端代码,主要在 go-view (opens new window) 包下实现。 ② 前端:在 @/views/report/go-view (opens new window) 文件,通过 IFrame 嵌入 Go-View 界面。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 报表设计器 开发环境 ← 报表设计器 开发环境→"},{"title":"菜单路由","path":"/wiki/YuDaoCloud/前端手册 Vue 2/菜单路由/菜单路由.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 菜单路由 前端项目基于 element-ui-admin 实现,它的 路由和侧边栏 (opens new window) 是组织起一个后台应用的关键骨架。 侧边栏和路由是绑定在一起的,所以你只有在 @/router/index.js (opens new window) 下面配置对应的路由,侧边栏就能动态的生成了,大大减轻了手动重复编辑侧边栏的工作量。 当然,这样就需要在配置路由的时候,遵循一些约定的规则。 # 1. 路由配置 首先,我们了解一下本项目配置路由时,提供了哪些配置项: // 当设置 true 的时候该路由不会在侧边栏出现 如 401,login 等页面,或者如一些编辑页面 /edit/1hidden: true // (默认 false)// 当设置 noRedirect 的时候该路由在面包屑导航中不可被点击redirect: 'noRedirect'// 1. 当你一个路由下面的 children 声明的路由大于 1 个时,自动会变成嵌套的模式。例如说,组件页面// 2. 只有一个时,会将那个子路由当做根路由显示在侧边栏。例如说,如引导页面// 若你想不管路由下面的 children 声明的个数都显示你的根路由,// 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由alwaysShow: truename: 'router-name' // 设定路由的名字,一定要填写不然使用 <keep-alive> 时会出现各种问题meta: { roles: ['admin', 'editor'] // 设置该路由进入的权限,支持多个权限叠加 title: 'title' // 设置该路由在侧边栏和面包屑中展示的名字 icon: 'svg-name' // 设置该路由的图标,支持 svg-class,也支持 el-icon-x element-ui 的 icon noCache: true // 如果设置为 true,则不会被 <keep-alive> 缓存(默认 false) breadcrumb: false // 如果设置为 false,则不会在breadcrumb面包屑中显示(默认 true) affix: true // 如果设置为 true,它则会固定在 tags-view 中(默认 false) // 当路由设置了该属性,则会高亮相对应的侧边栏。 // 这在某些场景非常有用,比如:一个文章的列表页路由为:/article/list // 点击文章进入文章详情页,这时候路由为 /article/1,但你想在侧边栏高亮文章列表的路由,就可以进行如下设置 activeMenu: '/article/list'} 普通示例 { path: '/system/test', component: Layout, redirect: 'noRedirect', hidden: false, alwaysShow: true, meta: { title: '系统管理', icon : "system" }, children: [{ path: 'index', component: (resolve) => require(['@/views/index'], resolve), name: 'Test', meta: { title: '测试管理', icon: 'user' } }]} 外链示例 { path: 'https://www.iocoder.cn', meta: { title: '芋道源码', icon : "guide" }} # 2. 路由 项目的路由分为两种:静态路由、动态路由。 # 2.1 静态路由 静态路由,代表那些不需要动态判断权限的路由,如登录页、404、个人中心等通用页面。 在 @/router/index.js ( opens new window) 的 constantRoutes ,就是配置对应的公共路由。如下图所示: # 2.2 动态路由 动态路由,代表那些需要根据用户动态判断权限,并通过 addRoutes ( opens new window) 动态添加的页面,如用户管理、角色管理等功能页面。 在用户登录成功后,会触发 @/store/modules/permission.js ( opens new window) 请求后端的菜单 RESTful API 接口,获取用户有权限 的菜单列表,并转化添加到路由中。如下图所示: 友情提示: 动态路由可以在 [系统管理 -> 菜单管理] 进行新增和修改操作,请求的后端 RESTful API 接口是 /admin-api/system/list-menus ( opens new window) 动态路由在生产环境下会默认使用路由懒加载,实现方式参考 loadView ( opens new window) 方法的判断 # 2.3 路由跳转 使用 router.push 方法,可以实现跳转到不同的页面。 // 简单跳转this.$router.push({ path: "/system/user" });// 跳转页面并设置请求参数,使用 `query` 属性this.$router.push({ path: "/system/user", query: {id: "1", name: "芋道"} }); # 3. 菜单管理 项目的菜单在 [系统管理 -> 菜单管理] 进行管理,支持无限 层级,提供目录、菜单、按钮三种类型。如下图所示: 菜单可在 [系统管理 -> 角色管理] 被分配给角色。如下图所示: # 3.1 新增目录 ① 大多数情况下,目录是作为菜单的【分类】: ② 目录也提供实现【外链】的能力: # 3.2 新增菜单 # 3.3 新增按钮 # 4. 权限控制 前端通过权限控制,隐藏用户没有权限的按钮等,实现功能级别的权限。 友情提示:前端的权限控制,主要是提升用户体验,避免操作后发现没有权限。 最终在请求到后端时,还是会进行一次权限的校验。 # 4.1 v-hasPermi 指令 v-hasPermi ( opens new window) 指令,基于权限字符,进行权限的控制。 <!-- 单个 --><el-button v-hasPermi="['system:user:create']">存在权限字符串才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasPermi="['system:user:create', 'system:user:update']">包含权限字符串才能看到</el-button> # 4.2 v-hasRole 指令 v-hasRole ( opens new window) 指令,基于角色标识,机进行的控制。 <!-- 单个 --><el-button v-hasRole="['admin']">管理员才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasRole="['role1', 'role2']">包含角色才能看到</el-button> # 4.3 结合 v-if 指令 在某些情况下,它是不适合使用 v-hasPermi 或 v-hasRole 指令,如元素标签组件。此时,只能通过手动设置 v-if,通过使用全局权限判断函数,用法是基本一致的。 <template> <el-tabs> <el-tab-pane v-if="checkPermi(['system:user:create'])" label="用户管理" name="user">用户管理</el-tab-pane> <el-tab-pane v-if="checkPermi(['system:user:create', 'system:user:update'])" label="参数管理" name="menu">参数管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin'])" label="角色管理" name="role">角色管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin','common'])" label="定时任务" name="job">定时任务</el-tab-pane> </el-tabs></template><script>import { checkPermi, checkRole } from "@/utils/permission"; // 权限判断函数export default{ methods: { checkPermi, checkRole }}</script> # 5. 页面缓存 由于目前 keep-alive 和 router-view 是强耦合的,而且查看 Vue 的文档和源码不难发现 keep-alive 的 include 默认是优先匹配组件的 name ,所以在编写路由 router 和路由对应的 view component 的时候一定要确保 两者的 name 是完全一致的。 注意,切记 view component 的 name 命名时候尽量保证唯一性,切记不要和某些组件的命名重复了,不然会递归引用最后内存溢出等问题。 友情提示:页面缓存是什么? 简单来说,Tab 切换时,开启页面缓存的 Tab 保持原本的状态,不进行刷新。 详细可见 Vue 文档 —— KeepAlive ( opens new window) # 5.1 静态路由的示例 ① router 路由的 name 声明如下: { path: 'create-form', component: ()=>import('@/views/form/create'), name: 'createForm', meta: { title: 'createForm', icon: 'table' }} ② view component 的 name 声明如下: export default { name: 'createForm'} 一定要保证两者的名字相同,切记写重或者写错。默认如果不写 name 就不会被缓存,详情见 issue (opens new window)。 # 5.2 动态路由的示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 开发规范 Icon 图标 ← 开发规范 Icon 图标→"},{"title":"报表设计器","path":"/wiki/YuDaoCloud/大屏手册/报表设计器/报表设计器.html","content":"开发指南大屏手册 芋道源码 2022-07-29 目录 报表设计器 数据可视化,一般可以通过报表设计器、或者大屏设计器来实现。本小节,我们来讲解报表设计器的功能开启。 报表设计器,指的是使用 Web 版设计器,通过类似于 Excel 操作风格,通过拖拽完成报表设计。如下图所示: 在项目中,通过集成市面上的报表引擎,实现了报表设计器的能力。目前使用如下: 是否集成 是否开源 JimuReport (opens new window) 已集成 不开源 AJ-Report (opens new window) 集成中 开源 UReport2 (opens new window) 不集成 开源 为什么不使用 UReport2 报表引擎呢? UReport2 基本处于不维护的状态,最后发版时间是 2018 年! # 1. 功能开启 yudao-module-report 实现了报表设计器的能力,开启步骤如下: 第一步,导入报表的 SQL 数据库脚本 第二步,启动 yudao-report-report 服务 第三步,启动报表设计器的前端项目 # 1.1 第一步,导入 SQL 导入 jimureport.mysql5.7.create.sql (opens new window) 脚本,初始化 JimuReport 相关的表结构和数据。如果你是 Oracle、PostgreSQL 等其它数据库,需要自己使用 Navicat 进行转换。 # 1.2 第二步,启动 report 服务 运行该服务的 ReportServerApplication (opens new window) 启动类,看到 \"Init JimuReport Config [ 线程池 ] \" 说明开启成功。 # 1.3 第三步,启动前端项目(AJ-Report) TODO 开发中,预计 4 月份左右。 # 1.3 第三步,启动前端项目(JimuReport) ① JimuReport 前端项目内置在后端项目中,无需启动。 ② 访问 [报表管理 -> 报表设计器] 菜单,可以查看对应的功能。如下图所示: 可以看到,JimuReport 支持数据报表、图形报表、打印设计等能力。 # 2. 如何使用? # 2.1 AJ-Report 报表设计器 TODO 开发中,预计 4 月份左右。 # 2.2 JimuReport 报表设计器 可以查看 JimuReport 的官方文档,主要是: 快速入门 (opens new window) 操作手册(报表设计器) (opens new window) 注意,JimuReport 是商业化的产品,报表设计器的功能应该是免费的,大屏设计器的功能是收费的。 集成 JimuReport 的代码实现? ① 后端:在 jmreport (opens new window) 包下,进行 JimuReport 的集成。 ② 前端:在 @/views/report/jmreport (opens new window) 文件,通过 IFrame 嵌入 JimuReport 界面。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 工作流(Flowable)会签、或签 大屏设计器 ← 工作流(Flowable)会签、或签 大屏设计器→"},{"title":"Excel 导入导出","path":"/wiki/YuDaoCloud/后端手册/Excel 导入导出/Excel 导入导出.html","content":"开发指南后端手册 芋道源码 2022-03-27 目录 Excel 导入导出 项目的 yudao-spring-boot-starter-excel (opens new window) 技术组件,基于 EasyExcel 实现 Excel 的读写操作,可用于实现最常见的 Excel 导入导出等功能。 EasyExcel 的介绍? EasyExcel 是阿里开源的 Excel 工具库,具有简单易用、低内存、高性能的特点。 在尽可用节约内存的情况下,支持百万行的 Excel 读写操作。例如说,仅使用 64M 内存,20 秒完成 75M(46 万行 25 列)Excel 的读取。并且,还有极速模式能更快,但是内存占用会在100M 多一点。 # 1. Excel 导出 以 [系统管理 -> 岗位管理] 菜单为例子,讲解它 Excel 导出的实现。 # 1.1 后端导入实现 在 PostController (opens new window) 类中,定义 /admin-api/system/post/export 导出接口。代码如下: ① 将从数据库中查询出来的列表,转换成对应的 PostExcelVO 列表。 ② 将 PostExcelVO 列表,转换成 Excel 文件,返回给前端。 # 1.1.1 PostExcelVO 类 创建 PostExcelVO (opens new window) 类,岗位 Excel 导出的 VO 类。它有两个作用,代码如下: ① 每个字段上的 @ExcelProperty (opens new window) 注解,声明 Excel Head 头部的名字。 ② 每个字段的值,就是它对应的 Excel Row 行的数据值。 因此,最终 Excel 导出的效果如下: 另外,在上述代码的红线部分,@ExcelProperty 注解的 converter 属性是 DictConvert 转换器,通过它将 status = 1 转换成“开启”列,status = 0 转换成”禁用”列。稍后,我们会在 「3. 字段转换器」 小节来详细讲讲。 # 1.1.2 ExcelUtils 写入 ExcelUtils 的 #write(...) (opens new window) 方法,将列表以 Excel 响应给前端。代码如下图: # 1.2 前端导入实现 在 post/index.vue (opens new window) 界面,定义 #handleExport() 操作,代码如下图: # 2. Excel 导入 以 [系统管理 -> 用户管理] 菜单为例子,讲解它 Excel 导出的实现。 # 2.1 后端导入实现 在 UserController (opens new window) 类中,定义 /admin-api/system/user/import 导入接口。代码如下: 将前端上传的 Excel 文件,读取成 UserImportExcelVO 列表。 # 2.1.1 UserImportExcelVO 类 创建 UserImportExcelVO (opens new window) 类,用户 Excel 导入的 VO 类。它的作用和 Excel 导入是一样的,代码如下: 对应使用的 Excel 导入文件如下: # 2.1.2 ExcelUtils 读取 ExcelUtils 的 #read(...) (opens new window) 方法,读取 Excel 文件成列表。代码如下图: # 2.2 前端导入实现 在 user/index.vue (opens new window) 界面,定义 Excel 导入的功能,代码如下图: # 3. 字段转换器 EasyExcel 定义了 Converter (opens new window) 接口,用于实现字段的转换。它有两个核心方法: ① #convertToJavaData(...) 方法:将 Excel Row 对应表格的值,转换成 Java 内存中的值。例如说,Excel 的“状态”列,将“状态”列转换成 status = 1,”禁用”列转换成 status = 0。 ② #convertToExcelData(...) 方法:恰好相反,将 Java 内存中的值,转换成 Excel Row 对应表格的值。例如说,Excel 的“状态”列,将 status = 1 转换成“开启”列,status = 0 转换成”禁用”列。 # 3.1 DictConvert 实现 以项目中提供的 DictConvert (opens new window) 举例子,它实现 Converter 接口,提供字典数据的转换。代码如下: 实现的代码比较简单,自己看看就可以明白。 # 3.1 DictConvert 使用示例 在需要转换的字段上,声明注解 @ExcelProperty 的 converter 属性是 DictConvert 转换器,注解 @DictFormat (opens new window) 为对应的字典数据的类型。示例如下: # 4. 更多 EasyExcel 注解 基于 《EasyExcel 中的注解 》 (opens new window) 文章,整理相关注解。 # 4.1 @ExcelProperty 这是最常用的一个注解,注解中有三个参数 value、index、converter 分别代表列明、列序号、数据转换方式。value 和 index 只能二选一,通常不用设置 converter。 最佳实践 public class ImeiEncrypt { @ExcelProperty(value = "imei") private String imei;} # 4.2 @ColumnWith 用于设置列宽度的注解,注解中只有一个参数 value。value 的单位是字符长度,最大可以设置 255 个字符,因为一个 Excel 单元格最大可以写入的字符个数,就是 255 个字符。 最佳实践 public class ImeiEncrypt { @ColumnWidth(value = 18) private String imei;} # 4.3 @ContentFontStyle 用于设置单元格内容字体格式的注解。参数如下: 参数 含义 fontName 字体名称 fontHeightInPoints 字体高度 italic 是否斜体 strikeout 是否设置删除水平线 color 字体颜色 typeOffset 偏移量 underline 下划线 bold 是否加粗 charset 编码格式 # 4.4 @ContentLoopMerge 用于设置合并单元格的注解。参数如下: 参数 含义 eachRow columnExtend # 4.5 @ContentRowHeight 用于设置行高。参数如下: 参数 含义 value 行高,-1 代表自动行高 # 4.6 @ContentStyle 设置内容格式注解。参数如下: 参数 含义 dataFormat 日期格式 hidden 设置单元格使用此样式隐藏 locked 设置单元格使用此样式锁定 quotePrefix 在单元格前面增加`符号,数字或公式将以字符串形式展示 horizontalAlignment 设置是否水平居中 wrapped 设置文本是否应换行。将此标志设置为true通过在多行上显示使单元格中的所有内容可见 verticalAlignment 设置是否垂直居中 rotation 设置单元格中文本旋转角度。03版本的Excel旋转角度区间为-90°~90°,07版本的Excel旋转角度区间为0°~180° indent 设置单元格中缩进文本的空格数 borderLeft 设置左边框的样式 borderRight 设置右边框样式 borderTop 设置上边框样式 borderBottom 设置下边框样式 leftBorderColor 设置左边框颜色 rightBorderColor 设置右边框颜色 topBorderColor 设置上边框颜色 bottomBorderColor 设置下边框颜色 fillPatternType 设置填充类型 fillBackgroundColor 设置背景色 fillForegroundColor 设置前景色 shrinkToFit 设置自动单元格自动大小 # 4.7 @HeadFontStyle 用于定制标题字体格式。参数如下: 参数 含义 fontName 设置字体名称 fontHeightInPoints 设置字体高度 italic 设置字体是否斜体 strikeout 是否设置删除线 color 设置字体颜色 typeOffset 设置偏移量 underline 设置下划线 charset 设置字体编码 bold 设置字体是否家畜 # 4.8 @HeadRowHeight 设置标题行行高。参数如下: 参数 含义 value 设置行高,-1代表自动行高 # 4.9 @HeadStyle 设置标题样式。参数如下: 参数 含义 dataFormat 日期格式 hidden 设置单元格使用此样式隐藏 locked 设置单元格使用此样式锁定 quotePrefix 在单元格前面增加` 符号,数字或公式将以字符串形式展示 horizontalAlignment 设置是否水平居中 wrapped 设置文本是否应换行。将此标志设置为true 通过在多行上显示使单元格中的所有内容可见 verticalAlignment 设置是否垂直居中 rotation 设置单元格中文本旋转角度。03版本的Excel旋转角度区间为-90°~90°,07版本的Excel旋转角度区间为0°~180° indent 设置单元格中缩进文本的空格数 borderLeft 设置左边框的样式 borderRight 设置右边框样式 borderTop 设置上边框样式 borderBottom 设置下边框样式 leftBorderColor 设置左边框颜色 rightBorderColor 设置右边框颜色 topBorderColor 设置上边框颜色 bottomBorderColor 设置下边框颜色 fillPatternType 设置填充类型 fillBackgroundColor 设置背景色 fillForegroundColor 设置前景色 shrinkToFit 设置自动单元格自动大小 # 4.10 @ExcelIgnore 不将该字段转换成 Excel。 # 4.11 @ExcelIgnoreUnannotated 没有注解的字段都不转换 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/30, 21:15:08 文件存储(上传下载) 系统日志 ← 文件存储(上传下载) 系统日志→"},{"title":"OAuth 2.0(SSO 单点登录)","path":"/wiki/YuDaoCloud/后端手册/OAuth 2.0(SSO 单点登录)/OAuth 2.0(SSO 单点登录).html","content":"开发指南后端手册 芋道源码 2022-09-27 目录 OAuth 2.0(SSO 单点登录) # OAuth 2.0 是什么? OAuth 2.0 的概念讲解,可以阅读如下三篇文章: 《理解 OAuth 2.0》 (opens new window) 《OAuth 2.0 的一个简单解释》 (opens new window) 《OAuth 2.0 的四种方式》 (opens new window) 重点是理解 授权码模式 和 密码模式,它们是最常用的两种授权模式。 本文,我们也会基于它们,分别实现 SSO 单点登录。 # OAuth 2.0 授权模式的选择? 授权模式的选择,其实非常简单,总结起来就是一张图: 问题一:什么场景下,使用客户端模式(Client Credentials)? 如果令牌拥有者是机器的情况下,那就使用客户端模式。 例如说: 开发了一个开放平台,提供给其它外部服务调用 开发了一个 RPC 服务,提供给其它内部服务调用 实际的案例,我们接入微信公众号时,会使用 appid 和 secret 参数,获取 Access token (opens new window) 访问令牌。 问题二:什么场景下,使用密码模式(Resource Owner Password Credentials)? 接入的 Client 客户端,是属于自己的情况下,可以使用密码模式。 例如说: 客户端是你自己公司的 App 或网页,然后授权服务也是你公司的 不过,如果客户端是第三方的情况下,使用密码模式的话,该客户端是可以拿到用户的账号、密码,存在安全的风险,此时可以考虑使用授权码或简化模式。 问题三:什么场景下,使用授权码模式(Authorization Code)? 接入的 Client 客户端,是属于第三方的情况下,可以使用授权码模式。例如说: 客户端是你自己公司的 App 或网页,作为第三方,接入 微信 (opens new window)、QQ (opens new window)、钉钉 (opens new window) 等等进行 OAuth 2.0 登录 当然,如果客户端是自己的情况下,也可以采用授权码模式。例如说: 客户端是腾讯旗下的各种游戏,可使用微信、QQ,接入 微信 (opens new window)、QQ (opens new window) 等等进行 OAuth 2.0 登录 客户端是公司内的各种管理后台(ERP、OA、CRM 等),跳转到统一的 SSO 单点登录,使用授权码模式进行授权 问题四:什么场景下,使用简化模式(Implicit)? 简化模式,简化 的是授权码模式的流程的 第二步,差异在于: 授权码模式:授权完成后,获得的是 code 授权码,需要 Server Side 服务端使用该授权码,再向授权服务器获取 Access Token 访问令牌 简化模式:授权完成后,Client Side 客户端直接获得 Access Token 访问令牌 暂时没有特别好的案例,感兴趣可以看看如下文档,也可以不看: 《QQ OAuth 2.0 开发指定 —— 开发攻略_Client-side》 (opens new window) 《百度 OAuth —— Implicit Grant 授权》 (opens new window) 问题五:该项目中,使用了哪些授权模式? 如上图所示,分成 外部授权 和 内部登录 两种方式。 ① 红色的“外部授权”:基于【授权码模式】,实现 SSO 单点登录,将用户授权给接入的客户端。客户端可以是内部的其它管理系统,也可以是外部的第三方。 ② 绿色的“内部登录”:管理后台的登录接口,还是采用传统的 /admin-api/system/auth/login (opens new window) 账号密码登录,并没有使用【密码模式】,主要考虑降低大家的学习成本,如果没有将用户授权给其它系统的情况下,这样做已经可以很好的满足业务的需要。当然,这里也可以将管理后台作为一个客户端,使用【密码模式】进行授权。 另外,考虑到 OAuth 2.0 使用的访问令牌 + 刷新令牌可以提供更好的安全性,所以即使是传统的账号密码登录,也复用了它作为令牌的实现。 # OAuth 2.0 技术选型? 实现 OAuth 2.0 的功能,一般采用 Spring Security OAuth (opens new window) 或 Spring Authorization Server (opens new window)(SAS) 框架,前者已废弃,被后者所替代。但是使用它们,会面临三大问题: 学习成本大:SAS 是新出的框架,入门容易精通难,引入项目中需要花费 1-2 周深入学习 排查问题难:使用碰到问题时,往往需要调试到源码层面,团队只有个别人具备这种能力 定制成本高:根据业务需要,想要在 SAS 上定制功能,对源码要有不错的掌控力,难度可能过大 ⚔ 因此,项目参考多个 OAuth 2.0 框架,自研实现 OAuth 2.0 的功能,具备学习成本小、排查问题容易、定制成本低的优点,支持多种授权模式,并内置 SSO 单点登录的功能。 友情提示:具备一定规模的互联网公司,基本不会直接采用 Spring Security OAuth 或 Spring Authorization Server 框架,也是采用自研的方式,更好的满足自身的业务需求与技术拓展。 🙂 另外,通过学习项目的 OAuth 2.0 实现,可以进一步加深对 OAuth 2.0 的理解,知其然而不知其所以然! 最终实现的整体架构,如下图所示: 详细的代码实现,我们在视频中进行讲解。 # 如何实现 SSO 单点登录? # 实战一:基于授权码模式,实现 SSO 单点登录 示例代码见 https://github.com/YunaiV/ruoyi-vue-pro/tree/master/yudao-example/yudao-sso-demo-by-code (opens new window) 地址,整体流程如下图所示: 具体的使用流程如下: ① 第一步,分别启动 ruoyi-vue-pro 项目的前端和后端。注意,前端需要使用 Vue2 版本,因为 Vue3 版本暂时没有实现 SSO 页面。 ② 第二步,访问 系统管理 -> OAuth 2.0 -> 应用管理 (opens new window) 菜单,新增一个应用(客户端),信息如下图: 客户端编号:yudao-sso-demo-by-code 客户端密钥:test 应用名:基于授权码模式,如何实现 SSO 单点登录? 授权类型:authorization_code、refresh_token 授权范围:user.read、user.write 可重定向的 URI 地址:http://127.0.0.1:18080 ps:如果已经有这个客户端,可以不用新增。 ③ 第三步,运行 SSODemoApplication (opens new window) 类,启动接入方的项目,它已经包含前端和后端部分。启动成功的日志如下: 2022-10-01 21:24:35.572 INFO 60265 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 18080 (http) with context path '' ④ 第四步,浏览器访问 http://127.0.0.1:18080/index.html (opens new window) 地址,进入接入方的 index.html 首页。因为暂未登录,可以点击「跳转」按钮,跳转到 ruoyi-vue-pro 项目的 SSO 单点登录页。 疑问:为什么没有跳转到 SSO 单点登录页,而是跳转到 ruoyi-vue-pro 项目的登录页? 因为在 ruoyi-vue-pro 项目也未登录,所以先跳转到该项目的登录页,使用账号密码进行登录。登录完成后,会跳转回 SSO 单点登录页,继续完成 OAuth 2.0 的授权流程。 ⑤ 第五步,勾选 \"访问你的个人信息\" 和 \"修改你的个人信息\",点击「同意授权」按钮,完成 code 授权码的申请。 ⑥ 第六步,完成授权后,会跳转到接入方的 callback.html 回调页,并在 URL 上可以看到 code 授权码。 ⑦ 第七步,点击「确认」按钮,接入方的前端会使用 code 授权码,向接入方的后端获取 accessToken 访问令牌。 而接入方的后端,使用接收到的 code 授权码,通过调用 ruoyi-vue-pro 项目的后端,获取到 accessToken 访问令牌,并最终返回给接入方的前端。 ⑧ 第八步,在接入方的前端拿到 accessToken 访问令牌后,跳转回自己的 index.html 首页,并进一步从 ruoyi-vue-pro 项目获取到该用户的昵称等个人信息。后续,你可以执行「修改昵称」、「刷新令牌」、「退出登录」等操作。 示例代码的具体实现,与详细的解析,可以观看如下视频: 02、基于授权码模式,如何实现 SSO 单点登录? (opens new window) 03、请求时,如何校验 accessToken 访问令牌? (opens new window) 04、访问令牌过期时,如何刷新 Token 令牌? (opens new window) 05、登录成功后,如何获得用户信息? (opens new window) 06、退出时,如何删除 Token 令牌? (opens new window) # 实战二:基于密码模式,实现 SSO 登录 示例代码见 https://github.com/YunaiV/ruoyi-vue-pro/tree/master/yudao-example/yudao-sso-demo-by-password (opens new window) 地址,整体流程如下图所示: 具体的使用流程如下: ① 第一步,分别启动 ruoyi-vue-pro 项目的前端和后端。注意,前端需要使用 Vue2 版本,因为 Vue3 版本暂时没有实现 SSO 页面。 ② 第二步,访问 系统管理 -> OAuth 2.0 -> 应用管理 (opens new window) 菜单,新增一个应用(客户端),信息如下图: 客户端编号:yudao-sso-demo-by-password 客户端密钥:test 应用名:基于密码模式,如何实现 SSO 单点登录? 授权类型:password、refresh_token 授权范围:user.read、user.write 可重定向的 URI 地址:http://127.0.0.1:18080 ps:如果已经有这个客户端,可以不用新增。 ③ 第三步,运行 SSODemoApplication (opens new window) 类,启动接入方的项目,它已经包含前端和后端部分。启动成功的日志如下: 2022-10-04 21:24:35.572 INFO 60265 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 18080 (http) with context path '' ④ 第四步,浏览器访问 http://127.0.0.1:18080/index.html (opens new window) 地址,进入接入方的 index.html 首页。因为暂未登录,可以点击「跳转」按钮,跳转到 login.html 登录页。 ⑤ 第五步,点击「登录」按钮,调用 ruoyi-vue-pro 项目的后端,获取到 accessToken 访问令牌,完成登录操作。 ⑥ 第六步,登录完成后,跳转回自己的 index.html 首页,并进一步从 ruoyi-vue-pro 项目获取到该用户的昵称等个人信息。后续,你可以执行「修改昵称」、「刷新令牌」、「退出登录」等操作。 示例代码的具体实现,与详细的解析,可以观看如下视频: 07、基于密码模式,如何实现 SSO 单点登录? (opens new window) # OAuth 2.0 表结构 每个表的具体设计,与详细的解析,可以观看如下视频: 08、如何实现客户端的管理? (opens new window) 09、单点登录界面,如何进行初始化? (opens new window) 10、单点登录界面,如何进行【手动】授权? (opens new window) 11、单点登录界面,如何进行【自动】授权? (opens new window) 12、基于【授权码】模式,如何获得 Token 令牌? (opens new window) 13、基于【密码】模式,如何获得 Token 令牌? (opens new window) 14、如何校验、刷新、删除访问令牌? (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/10/06, 20:13:18 三方登录 SaaS 多租户【字段隔离】 ← 三方登录 SaaS 多租户【字段隔离】→"},{"title":"SaaS 多租户【数据库隔离】","path":"/wiki/YuDaoCloud/后端手册/SaaS 多租户【数据库隔离】/SaaS 多租户【数据库隔离】.html","content":"None"},{"title":"代码生成(新增功能)","path":"/wiki/YuDaoCloud/后端手册/代码生成(新增功能)/代码生成(新增功能).html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 代码生成(新增功能) 大部分项目里,其实有很多代码是重复的,几乎每个模块都有 CRUD 增删改查的功能,而这些功能的实现代码往往是大同小异的。如果这些功能都要自己去手写,非常无聊枯燥,浪费时间且效率很低,还可能会写错。 所以这种重复性的代码,项目提供了 codegen (opens new window) 代码生成器,只需要在数据库中设计好表结构,就可以一键生成前后端代码 + 单元测试 + Swagger 接口文档 + Validator 参数校验。 下面,我们使用代码生成器,在 yudao-module-system 模块中,开发一个【用户组】的功能。 # 👍 相关视频教程 友情提示:虽然是基于 Boot 项目录制,但是 Cloud 一样可以学习。 从零开始 05:如何 5 分钟,开发一个新功能? (opens new window) # 1. 数据库表结构设计 设计用户组的数据库表名为 system_group,其建表语句如下: CREATE TABLE `system_group` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '名字', `description` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '描述', `status` tinyint NOT NULL COMMENT '状态', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户组'; ① 表名的前缀,要和 Maven Module 的模块名保持一致。例如说,用户组在 yudao-module-system 模块,所以表名的前缀是 system_。 疑问:为什么要保持一致? 代码生成器会自动解析表名的前缀,获得其所属的 Maven Module 模块,简化配置过程。 ② 设置 ID 主键,一般推荐使用 bigint 长整形,并设置自增长。 ③ 正确设置每个字段是否允许空,代码生成器会根据它生成参数是否允许空的校验规则。 ④ 正确设置注释,代码生成器会根据它生成字段名与提示等信息。 ⑤ 添加 creator、create_time、updater、update_time、deleted 是必须设置的系统字段;如果开启多租户的功能,并且该表需要多租户的隔离,则需要添加 tenant_id 字段。 # 2. 代码生成 ① 点击 [基础设施 -> 代码生成] 菜单,点击 [基于 DB 导入] 按钮,选择 system_group 表,后点击 [确认] 按钮。 代码实现? 可见 CodegenBuilder (opens new window) 类,自动解析数据库的表结构,生成默认的配置。 ② 点击 system_group 所在行的 [编辑] 按钮,修改生成配置。后操作如下: 将 status 字段的显示类型为【下拉框】,字典类型为【系统状态】。 将 description 字段的【查询】取消。 将 id、name、description、status 字段的【示例】填写上。 字段信息 插入:新增时,是否传递该字段。 编辑:修改时,是否传递该字段。 列表:Table 表格,是否展示该字段。 查询:搜索框,是否支持该字段查询,查询的条件是什么。 允许空:新增或修改时,是否必须传递该字段,用于 Validator 参数校验。 字典类型:在显示类型是下拉框、单选框、复选框时,选择使用的字典。 示例:参数示例,用于 Swagger 接口文档的 example 示例。 将【前端类型】设置为【Vue2 Element UI 标准模版】或【Vue3 Element Plus 标准模版】,具体根据你使用哪种管理后台。 生成信息 生成场景:分成管理后台、用户 App 两种,用于生成 Controller 放在 admin 还是 app 包。 上级菜单:生成场景是管理后台时,需要设置其所属的上级菜单。 前端类型: 提供多种 UI 模版。 【Vue3 Element Plus Schema 模版】,对应 《前端手册 Vue 3.X —— CRUD 组件》 说明。 后端的 application.yaml 配置文件中的 yudao.codegen.front-type 配置项,设置默认的 UI 模版,避免每次都需要设置。 完成后,点击 [提交] 按钮,保存生成配置。 ③ 点击 system_group 所在行的 [预览] 按钮,在线预览生成的代码,检查是否符合预期。 ④ 点击 system_group 所在行的 [生成代码] 按钮,下载生成代码的压缩包,双击进行解压。 代码实现? 可见 CodegenEngine (opens new window) 类,基于 Velocity 模板引擎,生成具体代码。模板文件,可见 resources/codegen (opens new window) 目录。 # 3. 代码运行 本小节,我们将生成的代码,复制到项目中,并进行运行。 # 3.1 后端运行 ① 将生成的后端代码,复制到项目中。操作如下图所示: ② 将 ErrorCodeConstants.java_手动操作 文件的错误码,复制到该模块 ErrorCodeConstants 类中,并设置对应的错误码编号,之后进行删除。操作如下图所示: ③ 将 h2.sql 的 CREATE 语句复制到该模块的 create_tables.sql 文件,DELETE 语句复制到该模块的 clean.sql。操作如下图: 疑问:`create_tables.sql` 和 `clean.sql` 文件的作用是什么? 项目的单元测试,需要使用到 H2 内存数据库,create_tables.sql 用于创建所有的表结构,clean.sql 用于每个单元测试的方法跑完后清理数据。 然后,运行 GroupServiceImplTest 单元测试,执行通过。 ④ 打开数据库工具,运行代码生成的 sql/sql.sql 文件,用于菜单的初始化。 ⑤ Debug 运行 YudaoServerApplication 类,启动后端项目。通过 IDEA 的 [Actuator -> Mappings] 菜单,可以看到代码生成的 GroupController 的 RESTful API 接口已经生效。 # 3.2 前端运行 ① 将生成的前端代码,复制到项目中。操作如下图所示: ② 重新执行 npm run dev 命令,启动前端项目。点击 [系统管理 -> 用户组管理] 菜单,就可以看到用户组的 UI 界面。 至此,我们已经完成了【用户组】功能的代码生成,基本节省了你 80% 左右的开发任务,后续可以根据自己的需求,进行剩余的 20% 的开发! # 4. 后续变更 随着业务的发展,已经生成代码的功能需要变更。继续以【用户组】举例子,它的 system_group 表需要新增一个分类 category 字段,此时不建议使用代码生成器,而是直接修改已经生成的代码: ① 后端:修改 GroupDO 数据实体类、GroupBaseVO 基础 VO 类、GroupExcelVO 导出结果 VO 类,新增 category 字段。 ② 前端:修改 index.vue 界面的列表和表单组件,新增 category 字段。 ③ 重新编译后后端,并进行启动。 over!非常简单方便,即保证了代码的整洁规范,又不增加过多的开发量。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/13, 08:14:58 新建服务 功能权限 ← 新建服务 功能权限→"},{"title":"SaaS 多租户【字段隔离】","path":"/wiki/YuDaoCloud/后端手册/SaaS 多租户【字段隔离】/SaaS 多租户【字段隔离】.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 SaaS 多租户【字段隔离】 本章节,将介绍多租户的基础知识、以及怎样使用多租户的功能。 相关的视频教程: 01、如何实现多租户的 DB 封装? (opens new window) 02、如何实现多租户的 Redis 封装? (opens new window) 03、如何实现多租户的 Web 与 Security 封装? (opens new window) 04、如何实现多租户的 Job 封装? (opens new window) 05、如何实现多租户的 MQ 与 Async 封装? (opens new window) 06、如何实现多租户的 AOP 与 Util 封装? (opens new window) 07、如何实现多租户的管理? (opens new window) 08、如何实现多租户的套餐? (opens new window) # 1. 多租户是什么? 多租户,简单来说是指一个业务系统,可以为多个组织服务,并且组织之间的数据是隔离的。 例如说,在服务上部署了一个 yudao-cloud (opens new window) 系统,可以支持多个不同的公司使用。这里的一个公司就是一个租户,每个用户必然属于某个租户。因此,用户也只能看见自己租户下面的内容,其它租户的内容对他是不可见的。 # 2. 数据隔离方案 多租户的数据隔离方案,可以分成分成三种: DATASOURCE 模式:独立数据库 SCHEMA 模式:共享数据库,独立 Schema COLUMN 模式:共享数据库,共享 Schema,共享数据表 # 2.1 DATASOURCE 模式 一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本也高。 优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。 缺点:增大了数据库的安装数量,随之带来维护成本和购置成本的增加。 # 2.2 SCHEMA 模式 多个或所有租户共享数据库,但一个租户一个表。 优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可以支持更多的租户数量。 缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵扯到其他租户的数据; 如果需要跨租户统计数据,存在一定困难。 # 2.3 COLUMN 模式 共享数据库,共享数据架构。租户共享同一个数据库、同一个表,但在表中通过 tenant_id 字段区分租户的数据。这是共享程度最高、隔离级别最低的模式。 优点:维护和购置成本最低,允许每个数据库支持的租户数量最多。 缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。 # 2.4 方案选择 一般情况下,可以考虑采用 COLUMN 模式,开发、运维简单,以最少的服务器为最多的租户提供服务。 租户规模比较大,或者一些租户对安全性要求较高,可以考虑采用 DATASOURCE 模式,当然它也相对复杂的多。 不推荐采用 SCHEMA 模式,因为它的优点并不明显,而且它的缺点也很明显,同时对复杂 SQL 支持一般。 提问:项目支持哪些模式? 目前支持最主流的 DATASOURCE 和 COLUMN 两种模式。而 SCHEMA 模式不推荐使用,所以暂时不考虑实现。 考虑到让大家更好的理解 DATASOURCE 和 COLUMN 模式,拆成了两篇文章: 《SaaS 多租户【字段隔离】》:讲解 COLUMN 模式 《SaaS 多租户【数据库隔离】》:讲解 DATASOURCE 模式 # 3. 多租户的开关 系统有两个配置项,设置为 true 时开启多租户,设置为 false 时关闭多租户。 注意,两者需要保持一致,否则会报错! 配置项 说明 配置文件 yudao.server.tenant 后端开关 VUE_APP_TENANT_ENABLE 前端开关 疑问:为什么要设置两个配置项? 前端登录界面需要使用到多租户的配置项,从后端加载配置项的话,体验会比较差。 # 3. 多租户的业务功能 多租户主要有两个业务功能: 业务功能 说明 界面 代码 租户管理 配置系统租户,创建对应的租户管理员 后端 (opens new window) 前端 (opens new window) 租户套餐 配置租户套餐,自定每个租户的菜单、操作、按钮的权限 后端 (opens new window) 前端 (opens new window) 下面,我们来新增一个租户,它使用 COLUMN 模式。 ① 点击 [租户套餐] 菜单,点击 [新增] 按钮,填写租户的信息。 ② 点击 [确认] 按钮,完成租户的创建,它会自动创建对应的租户管理员、角色等信息。 ③ 退出系统,登录刚创建的租户。 至此,我们已经完成了租户的创建。 # 4. 多租户的技术组件 技术组件 yudao-spring-boot-starter-biz-tenant (opens new window),实现透明化的多租户能力,针对 Web、Security、DB、Redis、AOP、Job、MQ、Async 等多个层面进行封装。 # 4.1 租户上下文 TenantContextHolder (opens new window) 是租户上下文,通过 ThreadLocal 实现租户编号的共享与传递。 通过调用 TenantContextHolder 的 #getTenantId() 静态方法,获得当前的租户编号。绝绝绝大多数情况下,并不需要。 # 4.2 Web 层【重要】 实现可见 web (opens new window) 包。 默认情况下,前端的每个请求 Header 必须带上 tenant-id,值为租户编号,即 system_tenant 表的主键编号。 如果不带该请求头,会报“租户的请求未传递,请进行排查”错误提示。 😜 通过 yudao.tenant.ignore-urls 配置项,可以设置哪些 URL 无需带该请求头。例如说: # 4.3 Security 层 实现可见 security (opens new window) 包。 主要是校验登录的用户,校验是否有权限访问该租户,避免越权问题。 # 4.4 DB 层【重要】 实现可见 db (opens new window) 包。 COLUMN 模式,基于 MyBatis Plus 自带的多租户 (opens new window)功能实现。 核心:每次对数据库操作时,它会自动拼接 WHERE tenant_id = ? 条件来进行租户的过滤,并且基本支持所有的 SQL 场景。 如下是具体方式: ① 需要开启多租户的表,必须添加 tenant_id 字段。例如说 system_users、system_role 等表。 CREATE TABLE `system_role` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID', `name` varchar(30) CHARACTER NOT NULL COMMENT '角色名称', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='角色信息表'; 并且该表对应的 DO 需要使用到 tenantId 属性时,建议继承 TenantBaseDO (opens new window) 类。 ② 无需开启多租户的表,需要添加表名到 yudao.tenant.ignore-tables 配置项目。例如说: 如果不配置的话,MyBatis Plus 会自动拼接 WHERE tenant_id = ? 条件,导致报 tenant_id 字段不存在的错误。 # 4.5 Redis 层 实现可见 redis (opens new window) 包。 # 使用方式一:基于 Spring Cache + Redis【推荐】 只需要一步,在方法上添加 Spring Cache 注解,例如说 @Cachable、@CachePut、@CacheEvict。 具体的实现原理,可见 TenantRedisCacheManager (opens new window) 的源码。 注意!!!默认配置下,Spring Cache 都开启 Redis Key 的多租户隔离。如果不需要,可以将 Key 添加到 yudao.tenant.ignore-cache 配置项中。如下图所示: # 使用方式二:基于 RedisTemplate + TenantRedisKeyDefine 暂时没有合适的封装,需要在自己 format Redis Key 的时候,手动将 :t{tenantId} 后缀拼接上。 这也是为什么,我推荐你使用 Spring Cache + Redis 的原因! # 4.6 AOP【重要】 实现可见 aop (opens new window) 包。 ① 声明 @TenantIgnore (opens new window) 注解在方法上,标记指定方法不进行租户的自动过滤,避免自动拼接 WHERE tenant_id = ? 条件等等。 例如说:RoleServiceImpl (opens new window) 的 #initLocalCache() (opens new window) 方法,加载所有租户的角色到内存进行缓存,如果不声明 @TenantIgnore 注解,会导致租户的自动过滤,只加载了某个租户的角色。 // RoleServiceImpl.javapublic class RoleServiceImpl implements RoleService { @Resource @Lazy // 注入自己,所以延迟加载 private RoleService self; @Override @PostConstruct @TenantIgnore // 忽略自动多租户,全局初始化缓存 public void initLocalCache() { // ... 从数据库中,加载角色 } @Scheduled(fixedDelay = SCHEDULER_PERIOD, initialDelay = SCHEDULER_PERIOD) public void schedulePeriodicRefresh() { self.initLocalCache(); // <x> 通过 self 引用到 Spring 代理对象 }} 有一点要格外注意,由于 @TenantIgnore 注解是基于 Spring AOP 实现,如果是方法内部的调用,避免使用 this 导致不生效,可以采用上述示例的 <x> 处的 self 方式。 ② 使用 TenantUtils (opens new window) 的 #execute(Long tenantId, Runnable runnable) 方法,模拟指定租户( tenantId ),执行某段业务逻辑( runnable )。 例如说:在 TenantServiceImpl (opens new window) 的 #createTenant(...) 方法,在创建完租户时,需要模拟该租户,进行用户和角色的创建。如下图所示: # 4.7 Job【重要】 实现可见 job (opens new window) 包。 声明 @TenantJob (opens new window) 注解在 Job 类上,实现并行遍历每个租户,执行定时任务的逻辑。 # 4.8 MQ 实现可见 mq (opens new window) 包。 通过租户对 MQ 层面的封装,实现租户上下文,可以继续传递到 MQ 消费的逻辑中,避免丢失的问题。实现原理是: 发送消息时,MQ 会将租户上下文的租户编号,记录到 Message 消息头 tenant-id 上。 消费消息时,MQ 会将 Message 消息头 tenant-id,设置到租户上下文的租户编号。 # 4.9 Async 实现可见 YudaoAsyncAutoConfiguration (opens new window) 类。 通过使用阿里开源的 TransmittableThreadLocal (opens new window) 组件,实现 Spring Async 执行异步逻辑时,租户上下文可以继续传递,避免丢失的问题。 # 4.10 RPC 实现可见 mq (opens new window) 包。 RPC 使用 Feign 调用时,会自动将租户上下文的租户编号,设置到 HTTP 请求头 tenant-id 上。 在 Provider 服务端,会自动将 HTTP 请求头 tenant-id,设置到租户上下文的租户编号。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/03, 23:42:51 OAuth 2.0(SSO 单点登录) SaaS 多租户【数据库隔离】 ← OAuth 2.0(SSO 单点登录) SaaS 多租户【数据库隔离】→"},{"title":"三方登录","path":"/wiki/YuDaoCloud/后端手册/三方登录/三方登录.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 三方登录 系统对接国内多个第三方平台,实现三方登录的功能。例如说: 管理后台:企业微信、阿里钉钉 用户 App:微信公众号、微信小程序 友情提示:为了表述方便,本文主要使用管理后台的三方登录作为示例。 用户 App 也是支持该功能,你可以自己去体验一下。 # 1. 表结构 ① 三方登录完成时,系统会将三方用户存储到 system_social_user (opens new window) 表中,通过 type (opens new window) 标记对应的第三方平台。 ② 【未】关联本系统 User 的三方用户,需要在三方登录完成后,使用账号密码进行「绑定登录」,成功后记录到 system_social_user_bind (opens new window) 表中。 【已】关联本系统 User 的三方用户,在三方登录完成后,直接进入系统,即「快捷登录」。 # 2. 绑定登录 ① 使用浏览器访问 http://127.0.0.1:1024/login (opens new window) 地址,点击 [钉钉] 或者 [企业微信] 进行三方登录。此时,会调用 /admin-api/system/auth/social-auth-redirect (opens new window) 接口,获得第三方平台的登录地址,并进行跳转。 然后,使用 [钉钉] 或者 [企业微信] 进行扫码,完成三方登录。 ② 三方登录成功后,跳转回 http://127.0.0.1:1024/social-login (opens new window) 地址。此时,会调用 /admin-api/system/auth/social-login (opens new window) 接口,尝试「快捷登录」。由于该三方用户【未】关联管理后台的 AdminUser 用户,所以会看到 “未绑定账号,需要进行绑定” 报错。 ③ 输入账号密码,点击 [提交] 按钮,进行「绑定登录」。此时,会调用 /admin-api/system/auth/login (opens new window) 接口(在账号密码登录的基础上,额外带上 socialType + socialCode + socialState 参数)。成功后,即可进入系统的首页。 # 3. 快捷登录 退出系统,再进行一次三方登录的流程。 【相同】① 使用浏览器访问 http://127.0.0.1:1024/login (opens new window) 地址,点击 [钉钉] 或者 [企业微信] 进行三方登录。此时,会调用 /admin-api/system/auth/social-auth-redirect (opens new window) 接口,获得第三方平台的登录地址,并进行跳转。 【不同】② 三方登录成功后,跳转回 http://127.0.0.1:1024/social-login (opens new window) 地址。此时,会调用 /admin-api/system/auth/social-login (opens new window) 接口,尝试「快捷登录」。由于该三方用户【已】关联管理后台的 AdminUser 用户,所以直接进入系统的首页。 # 4. 绑定与解绑 访问 http://127.0.0.1:1024/user/profile (opens new window) 地址,选择 [社交信息] 选项,可以三方用户的绑定与解绑。 # 5. 配置文件 在 application-{env}.yaml (opens new window) 配置文件中,对应 justauth 配置项,填写你的第三方平台的配置信息。 系统使用 justauth-spring-boot-starter (opens new window) JustAuth (opens new window) 组件,想要对接其它第三方平台,只需要新增对应的配置信息即可。 疑问:yudao-spring-boot-starter-biz-social 技术组件的作用是什么? yudao-spring-boot-starter-biz-social (opens new window) 对 JustAuth 进行二次封装,提供微信小程序的集成。 # 6. 第三方平台的申请 阿里钉钉:https://justauth.wiki/guide/oauth/dingtalk/ (opens new window) 企业微信:https://justauth.wiki/guide/oauth/wechat_enterprise_qrcode/ (opens new window) 微信开放平台:https://justauth.wiki/guide/oauth/wechat_open/ (opens new window) 注意,如果第三方平台如果需要配置具体的授信地址,需要添加 /social-login 用于三方登录回调页、/user/profile 用于三方用户的绑定与解绑。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/30, 21:37:18 用户体系 OAuth 2.0(SSO 单点登录) ← 用户体系 OAuth 2.0(SSO 单点登录)→"},{"title":"分布式锁","path":"/wiki/YuDaoCloud/后端手册/分布式锁/分布式锁.html","content":"开发指南后端手册 芋道源码 2022-04-05 目录 分布式锁 yudao-spring-boot-starter-protection (opens new window) 技术组件,使用 Redis 实现分布式锁的功能,它有 2 种使用方式: 编程式锁:基于 Redisson (opens new window) 框架提供的各种 (opens new window)分布式锁 声明式锁:基于 Lock4j (opens new window) 框架的 @Lock4j 注解 Redis 分布式锁的实现原理? 参见 《Redis 实现原理与源码解析系列》 (opens new window) 文章。 # 1. 编程式锁 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId></dependency> # 1.1 Redisson 配置 无需配置。因为在 Redis 缓存 中,进行了 Spring Data Redis + Redisson 的配置。 # 1.2 实战案例 yudao-module-pay 模块的 notify ( opens new window) 功能,使用到分布式锁,确保每个 支付通知任务有且仅有一个在执行。下面,来看看这个案例是如何实现的。 友情提示: 建议你已经阅读过 《开发指南 —— Redis 缓存》 文档。 ① 在 RedisKeyConstants ( opens new window) 类中,定义通知任务使用的分布式锁的 Redis Key。如下图所示: ② 创建 PayNotifyLockRedisDAO ( opens new window) 类,使用 RedisClient 实现分布式锁的加锁与解锁。如下图所示: ③ 在 PayNotifyServiceImpl ( opens new window) 执行指定的支付通知任务时,通过 PayNotifyLockRedisDAO 获得分布式锁。如下图所示: 技术选型:为什么不使用 Lock4j 提供的 LockTemplate 实现编程式锁? 两者各有优势,选择 Redisson 主要考虑它支持的 Redis 分布式锁的类型较多:可靠性较高的红锁、性能较好的读写锁等等。 Lock4j 的 LockTemplate 也是不错的选择,一方面不强依赖 Redisson 框架,一方面支持 ZooKeeper 等等。 # 2. 声明式锁 <dependency> <groupId>com.baomidou</groupId> <artifactId>lock4j-redisson-spring-boot-starter</artifactId></dependency> # 2.1 Lock4j 配置 友情提示:以 yudao-module-system 服务为例子。 在 application-local.yaml ( opens new window) 配置文件中,通过 lock4j 配置项,添加 Lock4j 全局默认的分布式锁配置。如下图所示: # 2.2 使用案例 在需要使用到分布式锁的方法上,添加 @Lock4j 注解,非常方便。示例代码如下: @Servicepublic class DemoService { // 默认使用 lock4j 配置项 @Lock4j public void simple() { //do something } // 完全配置,支持 Spring EL 表达式 @Lock4j(keys = {"#user.id", "#user.name"}, expire = 60000, acquireTimeout = 1000) public User customMethod(User user) { return user; }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 11:00:42 单元测试 幂等性(防重复提交) ← 单元测试 幂等性(防重复提交)→"},{"title":"分页实现","path":"/wiki/YuDaoCloud/后端手册/分页实现/分页实现.html","content":"开发指南后端手册 芋道源码 2022-03-26 目录 分页实现 前端:基于 Element UI 分页组件 Pagination (opens new window) 后端:基于 MyBatis Plus 分页功能,二次封装 以 [系统管理 -> 租户管理 -> 租户列表] 菜单为例子,讲解它的分页 + 搜索的实现。 # 1. 前端分页实现 # 1.1 Vue 界面 界面 tenant/index.vue (opens new window) 相关的代码如下: <template> <!-- 搜索工作栏 --> <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px"> <el-form-item label="租户名" prop="name"> <el-input v-model="queryParams.name" placeholder="请输入租户名" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="联系人" prop="contactName"> <el-input v-model="queryParams.contactName" placeholder="请输入联系人" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="联系手机" prop="contactMobile"> <el-input v-model="queryParams.contactMobile" placeholder="请输入联系手机" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="租户状态" prop="status"> <el-select v-model="queryParams.status" placeholder="请选择租户状态" clearable> <el-option v-for="dict in this.getDictDatas(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="dict.label" :value="dict.value"/> </el-select> </el-form-item> <el-form-item> <el-button type="primary" icon="el-icon-search" @click="handleQuery">搜索</el-button> <el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button> </el-form-item> </el-form> <!-- 列表 --> <el-table v-loading="loading" :data="list"> <!-- 省略每一列... --> </el-table> <!-- 分页组件 --> <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize" @pagination="getList"/></template><script>import { getTenantPage } from "@/api/system/tenant";export default { name: "Tenant", components: {}, data() { // 遮罩层 return { // 遮罩层 loading: true, // 显示搜索条件 showSearch: true, // 总条数 total: 0, // 租户列表 list: [], // 查询参数 queryParams: { pageNo: 1, pageSize: 10, // 搜索条件 name: null, contactName: null, contactMobile: null, status: undefined, }, } }, created() { this.getList(); }, methods: { /** 查询列表 */ getList() { this.loading = true; // 处理查询参数 let params = {...this.queryParams}; // 执行查询 getTenantPage(params).then(response => { this.list = response.data.list; this.total = response.data.total; this.loading = false; }); }, /** 搜索按钮操作 */ handleQuery() { this.queryParams.pageNo = 1; this.getList(); }, /** 重置按钮操作 */ resetQuery() { this.resetForm("queryForm"); this.handleQuery(); } }}</script> # 1.2 API 请求 请求 system/tenant.js ( opens new window) 相关的代码如下: import request from '@/utils/request'// 获得租户分页export function getTenantPage(query) { return request({ url: '/system/tenant/page', method: 'get', params: query })} # 2. 后端分页实现 # 2.1 Controller 接口 在 TenantController ( opens new window) 类中,定义 /admin-api/system/tenant/page 接口。代码如下: @Tag(name = "管理后台 - 租户")@RestController@RequestMapping("/system/tenant")public class TenantController { @Resource private TenantService tenantService; @GetMapping("/page") @Operation(summary = "获得租户分页") @PreAuthorize("@ss.hasPermission('system:tenant:query')") public CommonResult<PageResult<TenantRespVO>> getTenantPage(@Valid TenantPageReqVO pageVO) { PageResult<TenantDO> pageResult = tenantService.getTenantPage(pageVO); return success(TenantConvert.INSTANCE.convertPage(pageResult)); }} Request 分页请求,使用 TenantPageReqVO (opens new window) 类,它继承 PageParam 类 Response 分页结果,使用 PageResult 类,每一项是 TenantRespVO (opens new window) 类 # 2.1.1 分页参数 PageParam 分页请求,需要继承 PageParam (opens new window) 类。代码如下: @Schema(description="分页参数")@Datapublic class PageParam implements Serializable { private static final Integer PAGE_NO = 1; private static final Integer PAGE_SIZE = 10; @Schema(description = "页码,从 1 开始", required = true,example = "1") @NotNull(message = "页码不能为空") @Min(value = 1, message = "页码最小值为 1") private Integer pageNo = PAGE_NO; @Schema(description = "每页条数,最大值为 100", required = true, example = "10") @NotNull(message = "每页条数不能为空") @Min(value = 1, message = "每页条数最小值为 1") @Max(value = 100, message = "每页条数最大值为 100") private Integer pageSize = PAGE_SIZE;} 分页条件,在子类中进行定义。以 TenantPageReqVO 举例子,代码如下: @Schema(description = "管理后台 - 租户分页 Request VO")@Data@EqualsAndHashCode(callSuper = true)@ToString(callSuper = true)public class TenantPageReqVO extends PageParam { @Schema(description = "租户名", example = "芋道") private String name; @Schema(description = "联系人", example = "芋艿") private String contactName; @Schema(description = "联系手机", example = "15601691300") private String contactMobile; @Schema(description = "租户状态(0正常 1停用)", example = "1") private Integer status; @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @Schema(description = "创建时间") private LocalDateTime[] createTime;} # 2.1.2 分页结果 PageResult 分页结果 PageResult ( opens new window) 类,代码如下: @Schema(description = "分页结果")@Datapublic final class PageResult<T> implements Serializable { @Schema(description = "数据", required = true) private List<T> list; @Schema(description = "总量", required = true) private Long total;} 分页结果的数据 list 的每一项,通过自定义 VO 类,例如说 TenantRespVO (opens new window) 类。 # 2.2 Mapper 查询 在 TenantMapper (opens new window) 类中,定义 selectPage 查询方法。代码如下: @Mapperpublic interface TenantMapper extends BaseMapperX<TenantDO> { default PageResult<TenantDO> selectPage(TenantPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX<TenantDO>() .likeIfPresent(TenantDO::getName, reqVO.getName()) // 如果 name 不为空,则进行 like 查询 .likeIfPresent(TenantDO::getContactName, reqVO.getContactName()) .likeIfPresent(TenantDO::getContactMobile, reqVO.getContactMobile()) .eqIfPresent(TenantDO::getStatus, reqVO.getStatus()) // 如果 status 不为空,则进行 = 查询 .betweenIfPresent(TenantDO::getCreateTime, reqVO.getBeginCreateTime(), reqVO.getEndCreateTime()) // 如果 create 不为空,则进行 between 查询 .orderByDesc(TenantDO::getId)); // 按照 id 倒序 }} 针对 MyBatis Plus 分页查询的二次分装,在 BaseMapperX (opens new window) 中实现,主要是将 MyBatis 的分页结果 IPage,转换成项目的分页结果 PageResult。代码如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:58:32 参数校验 文件存储(上传下载) ← 参数校验 文件存储(上传下载)→"},{"title":"单元测试","path":"/wiki/YuDaoCloud/后端手册/单元测试/单元测试.html","content":"开发指南后端手册 芋道源码 2022-04-04 目录 单元测试 项目使用 Junit5 + Mockito 实现单元测试,提升代码质量、重复测试效率、部署可靠性等。 截止目前,项目已经有 500+ 测试用例。 内容推荐 如果你想系统学习单元测试,可以阅读《有效的单元测试》 (opens new window)这本书,非常适合 Java 工程师。 如果只是想学习 Spring Boot Test 的话,可以阅读 《芋道 Spring Boot 单元测试 Test 入门 》 (opens new window) 文章。 # 1.测试组件 yudao-spring-boot-starter-test (opens new window) 是项目提供的测试组件,用于单元测试、集成测试等等。 # 1.1 快速测试的基类 测试组件提供了 4 种单元测试的基类,通过继承它们,可以快速的构建单元测试的环境。 基类 作用 BaseMockitoUnitTest (opens new window) 纯 Mockito 的单元测试 BaseDbUnitTest (opens new window) 使用内嵌的 H2 数据库的单元测试 BaseRedisUnitTest (opens new window) 使用内嵌的 Redis 缓存的单元测试 BaseDbAndRedisUnitTest (opens new window) 使用内嵌的 H2 数据库 + Redis 缓存的单元测试 疑问:什么是内嵌的 Redis 缓存? 基于 jedis-mock (opens new window) 开源项目,通过 RedisTestConfiguration (opens new window) 配置类,启动一个 Redis 进程。一般情况下,会使用 16379 端口。 # 1.2 测试工具类 ① RandomUtils (opens new window) 基于 podam (opens new window) 开源项目,实现 Bean 对象的随机生成。 ② AssertUtils (opens new window) 封装 Junit 的 Assert 断言,实现 Bean 对象的断言,支持忽略部分属性。 # 2. BaseDbUnitTest 实战案例 以字典类型模块的 DictTypeServiceImpl (opens new window) 为例子,讲解它的 DictTypeServiceTest (opens new window) 单元测试的编写实现。 # 2.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-test 技术组件。如下所示: <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-test</artifactId> <scope>test</scope></dependency> # 2.2 新建 ut 配置文件 在 test/resources ( opens new window) 目录,新建单元测试的 application-unit-test.yaml ( opens new window) 配置文件,内容如下: # 2.3 添加 H2 SQL 脚本 修改 test/resources/sql ( opens new window) 目录的两个 H2 SQL 脚本: ① 在 create_tables.sql ( opens new window) 文件中,添加 system_dict_type 的 H2 建表语句。SQL 如下: CREATE TABLE IF NOT EXISTS "system_dict_type" ( "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "name" varchar(100) NOT NULL DEFAULT '', "type" varchar(100) NOT NULL DEFAULT '', "status" tinyint NOT NULL DEFAULT '0', "remark" varchar(500) DEFAULT NULL, "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted" bit NOT NULL DEFAULT FALSE, PRIMARY KEY ("id")) COMMENT '字典类型表'; 注意,H2 和 MySQL 的建表语句有区别,需要手动进行转换。如果你不想进行转换,可以使用 [基础设置 -> 代码生成] 菜单的代码生成器功能,如下图所示: ② 在 clean.sql (opens new window) 文件中,添加 system_dict_type 的清空数据的语句。SQL 如下: DELETE FROM "system_dict_type"; 每次单元测试的方法执行完后,会执行 clean.sql 脚本,进行数据的清理,保证每个单元测试的方法的数据隔离性。 # 2.3 新建 DictTypeServiceTest 类 新建 DictTypeServiceTest 测试类,继承 BaseMockitoUnitTest 基类,并完成它的配置。代码如下图所示: 属于自己模块的,使用 Spring 初始化成真实的 Bean,然后通过 @Resource 注入。例如说:dictTypeService、dictTypeMapper 属于别人模块的,使用 Spring @MockBean 注解,模拟 Mock 成一个 Bean 后注入。例如说:dictDataService 疑问:为什么有的进行 Mock,有的不进行 Mock 呢? 单元测试需要避免对外部的依赖,而 dictDataService 是外部依赖,所以需要 Mock 掉。 dictTypeMapper 某种程度来说,也是一种外部依赖,但是通过内嵌的 H2 内存数据库,进行“真实”的数据库操作,反而单元测试的编写效率更高,效果更好,所以不需要 Mock 掉。 另外,[基础设置 -> 代码生成] 菜单的代码生成器功能,已经生成了绝大多数的单元测试的逻辑,这里主要是希望让你了解单元测试的具体使用,所以并没有使用它。如下图所示: # 2.4 新增方法的单测 # 2.5 修改方法的单测 # 2.6 删除方法的单测 # 2.7 单条查询方法的单测 # 2.8 分页查询方法的单测 # 3. BaseMockitoUnitTest 实战案例 一些类由于不依赖 MySQL 和 Redis,可以通过继承 BaseMockitoUnitTest 基类,实现纯 Mockito 的单元测试。例如说 SmsSendServiceTest (opens new window) 单元测试类,代码如下: 具体 SmsSendServiceTest 的每个测试方法,和 DictTypeServiceTest 并没有什么差别,还是 Mock 模拟 + Assert 断言 + Verify 调用,你可以自己花点时间瞅瞅。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 12:11:24 工具类 Util 分布式锁 ← 工具类 Util 分布式锁→"},{"title":"功能权限","path":"/wiki/YuDaoCloud/后端手册/功能权限/功能权限.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 功能权限 # 👍 相关视频教程 友情提示:虽然是基于 Boot 项目录制,但是 Cloud 一样可以学习。 功能权限 01:如何设计一套权限系统? (opens new window) 功能权限 02:如何实现菜单的创建? (opens new window) 功能权限 03:如何实现角色的创建? (opens new window) 功能权限 04:如何给用户分配权限 —— 将菜单赋予角色? (opens new window) 功能权限 05:如何给用户分配权限 —— 将角色赋予用户? (opens new window) 功能权限 06:后端如何实现 URL 权限的校验? (opens new window) 功能权限 07:前端如何实现菜单的动态加载? (opens new window) 功能权限 08:前端如何实现按钮的权限校验? (opens new window) # 1. RBAC 权限模型 系统采用 RBAC 权限模型,全称是 Role-Based Access Control 基于角色的访问控制。 简单来说,每个用户拥有若干角色,每个角色拥有若干个菜单,菜单中存在菜单权限、按钮权限。这样,就形成了 “用户<->角色<->菜单” 的授权模型。 在这种模型中,用户与角色、角色与菜单之间构成了多对多的关系,如下图: # 2. Token 认证机制 安全框架使用的是 Spring Security (opens new window) + Token 方案,整体流程如下图所示: ① 前端调用登录接口,使用账号密码获得到认证 Token。响应示例如下: { "code":0, "msg":"", "data":{ "token":"d2a3cdbc6c53470db67a582bd115103f" }} 管理后台的登录实现,可见 代码 (opens new window) 用户 App 的登录实现,可见 代码 (opens new window) 疑问:为什么不使用 Spring Security 内置的表单登录? Spring Security 的登录拓展起来不方便,例如说验证码、三方登录等等。 Token 存储在数据库中,对应 system_oauth2_access_token 访问令牌表的 id 字段。考虑到访问的性能,缓存在 Redis 的 oauth2_access_token:%s (opens new window) 键中。 疑问:为什么不使用 JWT(JSON Web Token)? JWT 是无状态的,无法实现 Token 的作废,例如说用户登出系统、修改密码等等场景。 推荐阅读 《还分不清 Cookie、Session、Token、JWT?》 (opens new window) 文章。 默认配置下,Token 有效期为 30 天,可通过 system_oauth2_client 表中 client_id = default 的记录进行自定义: 修改 access_token_validity_seconds 字段,设置访问令牌的过期时间,默认 1800 秒 = 30 分钟 修改 refresh_token_validity_seconds 字段,设置刷新令牌的过期时间,默认 43200 秒 = 30 天 ② 前端调用其它接口,需要在请求头带上 Token 进行访问。请求头格式如下: ### Authorization: Bearer 登录时返回的 TokenAuthorization: Bearer d2a3cdbc6c53470db67a582bd115103f 具体的代码实现,可见 TokenAuthenticationFilter (opens new window) 过滤器 考虑到使用 Postman、Swagger 调试接口方便,提供了 Token 的模拟机制。请求头格式如下: ### Authorization: Bearer test用户编号Authorization: Bearer test1 其中 \"test\" 可自定义,配置项如下: ### application-local.yamlyudao: security: mock-enable: true # 是否开启 Token 的模拟机制 mock-secret: test # Token 模拟机制的 Token 前缀 # 3. 权限注解 # 3.1 @PreAuthorize 注解 @PreAuthorize ( opens new window) 是 Spring Security 内置的前置权限注解,添加在 接口方法上,声明需要的权限,实现访问权限的控制。 ① 基于【权限标识】的权限控制 权限标识,对应 system_menu 表的 permission 字段,推荐格式为 ${系统}:${模块}:${操作},例如说 system:admin:add 标识 system 服务的添加管理员。 使用示例如下: // 符合 system:user:list 权限要求@PreAuthorize("@ss.hasPermission('system:user:list')")// 符合 system:user:add 或 system:user:edit 权限要求即可@PreAuthorize("@ss.hasAnyPermissions('system:user:add,system:user:edit')") ② 基于【角色标识】的权限控制 权限标识,对应 system_role 表的 code 字段, 例如说 super_admin 超级管理员、tenant_admin 租户管理员。 使用示例如下: // 属于 user 角色@PreAuthorize("@ss.hasRole('user')")// 属于 user 或者 admin 之一@PreAuthorize("@ss.hasAnyRoles('user,admin')") 实现原理是什么? 当 @PreAuthorize 注解里的 Spring EL 表达式返回 false 时,表示没有权限。 而 @PreAuthorize(\"@ss.hasPermission('system:user:list')\") 表示调用 Bean 名字为 ss 的 #hasPermission(...) 方法,方法参数为 \"system:user:list\" 字符串。ss 对应的 Bean 是 PermissionServiceImpl (opens new window) 类,所以你只需要去看该方法的实现代码 (opens new window)。 # 3.2 @PreAuthenticated 注解 @PreAuthenticated (opens new window) 是项目自定义的认证注解,添加在接口方法上,声明登录的用户才允许访问。 主要使用场景是,针对用户 App 的 /app-app/** 的 RESTful API 接口,默认是无需登录的,通过 @PreAuthenticated 声明它需要进行登录。使用示例如下: // AppAuthController.java@PostMapping("/update-password")@Operation(summary = "修改用户密码", description = "用户修改密码时使用")@PreAuthenticatedpublic CommonResult<Boolean> updatePassword(@RequestBody @Valid AppAuthUpdatePasswordReqVO reqVO) { // ... 省略代码} 具体的代码实现,可见 PreAuthenticatedAspect (opens new window) 类。 # 4. 自定义权限配置 默认配置下,管理后台的 /admin-api/** 所有 API 接口都必须登录后才允许访问,用户 App 的 /app-api/** 所有 API 接口无需登录就可以访问。 如下想要自定义权限配置,设置定义 API 接口可以匿名(不登录)进行访问,可以通过下面三种方式: # 4.1 方式一:自定义 AuthorizeRequestsCustomizer 实现 每个 Maven Module 可以实现自定义的 AuthorizeRequestsCustomizer (opens new window) Bean,额外定义每个 Module 的 API 接口的访问规则。例如说 yudao-module-infra 模块的 SecurityConfiguration (opens new window) 类,代码如下: @Configuration("infraSecurityConfiguration")public class SecurityConfiguration { @Value("${spring.boot.admin.context-path:''}") private String adminSeverContextPath; @Bean("infraAuthorizeRequestsCustomizer") public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() { return new AuthorizeRequestsCustomizer() { @Override public void customize(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) { // Swagger 接口文档 registry.antMatchers("/swagger-ui.html").anonymous() .antMatchers("/swagger-resources/**").anonymous() .antMatchers("/webjars/**").anonymous() .antMatchers("/*/api-docs").anonymous(); // Spring Boot Actuator 的安全配置 registry.antMatchers("/actuator").anonymous() .antMatchers("/actuator/**").anonymous(); // Druid 监控 registry.antMatchers("/druid/**").anonymous(); // Spring Boot Admin Server 的安全配置 registry.antMatchers(adminSeverContextPath).anonymous() .antMatchers(adminSeverContextPath + "/**").anonymous(); } }; }} 友情提示 permitAll() 方法:所有用户可以任意访问,包括带上 Token 访问 anonymous() 方法:匿名用户可以任意访问,带上 Token 访问会报错 如果你对 Spring Security 了解不多,可以阅读艿艿写的 《芋道 Spring Boot 安全框架 Spring Security 入门 》 (opens new window) 文章。 # 4.2 方式二:@PermitAll 注解 在 API 接口上添加 @PermitAll (opens new window) 注解,示例如下: // FileController.java@GetMapping("/{configId}/get/{path}")@PermitAllpublic void getFileContent(HttpServletResponse response, @PathVariable("configId") Long configId, @PathVariable("path") String path) throws Exception { // ...} # 4.3 方式三:yudao.security.permit-all-urls 配置项 在 application.yaml 配置文件,通过 yudao.security.permit-all-urls 配置项设置,示例如下: yudao: security: permit-all-urls: - /admin-ui/** # /resources/admin-ui 目录下的静态资源 - /admin-api/xxx/yyy .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:33 代码生成(新增功能) 数据权限 ← 代码生成(新增功能) 数据权限→"},{"title":"Redis 缓存","path":"/wiki/YuDaoCloud/后端手册/Redis 缓存/Redis 缓存.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 Redis 缓存 yudao-spring-boot-starter-redis (opens new window) 技术组件,使用 Redis 实现缓存的功能,它有 2 种使用方式: 编程式缓存:基于 Spring Data Redis 框架的 RedisTemplate 操作模板 声明式缓存:基于 Spring Cache 框架的 @Cacheable 等等注解 # 1. 编程式缓存 友情提示: 如果你未学习过 Spring Data Redis 框架,可以后续阅读 《芋道 Spring Boot Redis 入门》 (opens new window) 文章。 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId></dependency> 由于 Redisson 提供了分布式锁、队列、限流等特性,所以使用它作为 Spring Data Redis 的客户端。 # 1.1 Spring Data Redis 配置 ① 在 application-local.yaml (opens new window) 配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。如下图所示: ② 在 YudaoRedisAutoConfiguration (opens new window) 配置类,设置使用 JSON 序列化 value 值。如下图所示: # 1.2 实战案例 以访问令牌 Access Token 的缓存来举例子,讲解项目中是如何使用 Spring Data Redis 框架的。 # 1.2.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-redis 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-redis</artifactId></dependency> # 1.2.2 OAuth2AccessTokenDO 新建 OAuth2AccessTokenDO ( opens new window) 类,访问令牌 Access Token 类。代码如下: 友情提示: ① 如果值是【简单】的 String 或者 Integer 等类型,无需创建数据实体。 ② 如果值是【复杂对象】时,建议在 dal/dataobject 包下,创建对应的数据实体。 # 1.2.3 RedisKeyConstants 为什么要定义 Redis Key 常量? 每个 yudao-module-xxx 模块,都有一个 RedisKeyConstants 类,定义该模块的 Redis Key 的信息。目的是,避免 Redis Key 散落在 Service 业务代码中,像对待数据库的表一样,对待每个 Redis Key。通过这样的方式,如果我们想要了解一个模块的 Redis 的使用情况,只需要查看 RedisKeyConstants 类即可。 在 yudao-module-system 模块的 RedisKeyConstants ( opens new window) 类中,新建 OAuth2AccessTokenDO 对应的 Redis Key 定义 OAUTH2_ACCESS_TOKEN 。如下图所示: # 1.2.4 OAuth2AccessTokenRedisDAO 新建 OAuth2AccessTokenRedisDAO ( opens new window) 类,是 OAuth2AccessTokenDO 的 RedisDAO 实现。代码如下: # 1.2.5 OAuth2TokenServiceImpl 在 OAuth2TokenServiceImpl ( opens new window) 中,只要注入 OAuth2AccessTokenRedisDAO Bean,非常简洁干净的进行 OAuth2AccessTokenDO 的缓存操作,无需关心具体的实现。代码如下: # 2. 声明式缓存 友情提示: 如果你未学习过 Spring Cache 框架,可以后续阅读 《芋道 Spring Boot Cache 入门》 ( opens new window) 文章。 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId></dependency> 相比来说 Spring Data Redis 编程式缓存,Spring Cache 声明式缓存的使用更加便利,一个 @Cacheable 注解即可实现缓存的功能。示例如下: @Cacheable(value = "users", key = "#id")UserDO getUserById(Integer id); # 2.1 Spring Cache 配置 ① 在 application.yaml ( opens new window) 配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。如下图所示: ② 在 YudaoCacheAutoConfiguration ( opens new window) 配置类,设置使用 JSON 序列化 value 值。如下图所示: # 2.2 常见注解 # 2.2.1 @Cacheable 注解 @Cacheable ( opens new window) 注解:添加在方法上,缓存方法的执行结果。执行过程如下: 1)首先,判断方法执行结果的缓存。如果有,则直接返回该缓存结果。 2)然后,执行方法,获得方法结果。 3)之后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。 4)最后,返回方法结果。 # 2.2.2 @CachePut 注解 @CachePut ( opens new window) 注解,添加在方法上,缓存方法的执行结果。不同于 @Cacheable 注解,它的执行过程如下: 1)首先,执行方法,获得方法结果。也就是说,无论是否有缓存,都会执行方法。 2)然后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。 3)最后,返回方法结果。 # 2.2.3 @CacheEvict 注解 @CacheEvict ( opens new window) 注解,添加在方法上,删除缓存。 # 2.3 实战案例 在 RoleServiceImpl ( opens new window) 中,使用 Spring Cache 实现了 Role 角色缓存,采用【被动读】的方案。原因是: 【被动读】相对能够保证 Redis 与 MySQL 的一致性 绝大数数据不需要放到 Redis 缓存中,采用【主动写】会将非必要的数据进行缓存 友情提示: 如果你未学习过 MySQL 与 Redis 一致性的问题,可以后续阅读 《Redis 与 MySQL 双写一致性如何保证? 》 ( opens new window) 文章。 ① 执行 #getRoleFromCache(...) 方法,从 MySQL 读取数据后,向 Redis 写入缓存。如下图所示: ② 执行 #updateRole(...) 或 #deleteRole(...) 方法,在更新或者删除 MySQL 数据后,从 Redis 删除缓存。如下图所示: # 2.4 过期时间 Spring Cache 默认使用 spring.cache.redis.time-to-live 配置项,设置缓存的过期时间,项目默认为 1 小时。 如果你想自定义过期时间,可以在 @Cacheable 注解中的 cacheNames 属性中,添加 #{过期时间} 后缀,单位是秒。如下图所示: 实现的原来,参考 《Spring @Cacheable 扩展支持自定义过期时间 》 ( opens new window) 文章。 # 3. Redis 监控 yudao-module-infra 的 redis ( opens new window) 模块,提供了 Redis 监控的功能。 点击 [基础设施 -> Redis 监控] 菜单,可以查看到 Redis 的基础信息、命令统计、内存信息。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:30:21 多数据源(读写分离) 本地缓存 ← 多数据源(读写分离) 本地缓存→"},{"title":"地区 & IP 库","path":"/wiki/YuDaoCloud/后端手册/地区 & IP 库/地区 & IP 库.html","content":"开发指南后端手册 芋道源码 2022-12-29 目录 地区 & IP 库 yudao-spring-boot-starter-biz-ip (opens new window) 业务组件,提供地区 & IP 库的封装。 # 1. 地区 AreaUtils (opens new window) 是地区工具类,可以查询中国的省、市、区县,也可以查询国外的国家。 它的数据来自 Administrative-divisions-of-China (opens new window) 项目,最终整理到项目的 area.csv (opens new window) 文件。每一行的数据,对应 Area (opens new window) 对象。代码所示: public class Area { /** * 编号 */ private Integer id; /** * 名字 */ private String name; /** * 类型 * * 枚举 {@link AreaTypeEnum} * 1 - 国家 * 2 - 省份 * 3 - 城市 * 4 - 地区, 例如说县、镇、区等 */ private Integer type; /** * 父节点 */ private Area parent; /** * 子节点 */ private List<Area> children;} AreaUtils 主要有如下两个方法: // AreaUtils.java/** * 获得指定编号对应的区域 * * @param id 区域编号 * @return 区域 */public static Area getArea(Integer id) { // ... 省略具体实现}/** * 格式化区域 * * 例如说: * 1. id = “静安区”时:上海 上海市 静安区 * 2. id = “上海市”时:上海 上海市 * 3. id = “上海”时:上海 * 4. id = “美国”时:美国 * 当区域在中国时,默认不显示中国 * * @param id 区域编号 * @param separator 分隔符 * @return 格式化后的区域 */public static String format(Integer id, String separator) { // ... 省略具体实现} 具体的使用,可见 AreaUtilsTest (opens new window) 测试类。 另外,管理后台提供了 [系统管理 -> 地区管理] 菜单,可以按照树形结构查看地区列表。如下图所示: 后端代码,对应 AreaController (opens new window) 的 /admin-api/system/area/tree 接口 前端代码,对应 system/area/index.vue (opens new window) 界面 # 2. IP IPUtils (opens new window) 是 IP 工具类,可以查询 IP 对应的城市信息。 它的数据来自 ip2region (opens new window) 项目,最终整理到项目的 ip2region.xdb (opens new window) 文件。 IPUtils 主要有如下两个方法: // IPUtils.java/** * 查询 IP 对应的地区编号 * * @param ip IP 地址,格式为 127.0.0.1 * @return 地区id */public static Integer getAreaId(String ip) { // ... 省略具体实现}/** * 查询 IP 对应的地区 * * @param ip IP 地址,格式为 127.0.0.1 * @return 地区 */public static Area getArea(String ip) { // ... 省略具体实现} 具体的使用,可见 IPUtilsTest (opens new window) 测试类。 另外,管理后台提供了 [系统管理 -> 地区管理] 菜单,也提供了 IP 查询城市的示例。如下图所示: 后端代码,对应 AreaController (opens new window) 的 /admin-api/system/area/get-by-ip 接口 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/27, 21:56:08 验证码 注册中心 Nacos ← 验证码 注册中心 Nacos→"},{"title":"参数校验","path":"/wiki/YuDaoCloud/后端手册/参数校验/参数校验.html","content":"开发指南后端手册 芋道源码 2022-03-26 目录 参数校验 项目使用 Hibernate Validator (opens new window) 框架,对 RESTful API 接口进行参数的校验,以保证最终数据入库的正确性。例如说,用户注册时,会校验手机格式的正确性,密码非弱密码。 如果参数校验不通过,会抛出 ConstraintViolationException 异常,被全局的异常处理捕获,返回“请求参数不正确”的响应。示例如下: { "code": 400, "data": null, "msg": "请求参数不正确:密码不能为空"} # 1. 参数校验注解 Validator 内置了 20+ 个参数校验注解,整理成常用与不常用的注解。 # 1.1 常用注解 注解 功能 @NotBlank 只能用于字符串不为 null ,并且字符串 #trim() 以后 length 要大于 0 @NotEmpty 集合对象的元素不为 0 ,即集合不为空,也可以用于字符串不为 null @NotNull 不能为 null @Pattern( value) 被注释的元素必须符合指定的正则表达式 @Max(value) 该字段的值只能小于或等于该值 @Min(value) 该字段的值只能大于或等于该值 @Range(min=, max=) 检被注释的元素必须在合适的范围内 @Size(max, min) 检查该字段的 size 是否在 min 和 max 之间,可以是字符串、数组、集合、Map 等 @Length(max, min) 被注释的字符串的大小必须在指定的范围内。 @AssertFalse 被注释的元素必须为 true @AssertTrue 被注释的元素必须为 false @Email 被注释的元素必须是电子邮箱地址 @URL( protocol=,host=,port=,regexp=,flags=) 被注释的字符串必须是一个有效的 URL # 1.2 不常用注解 注解 功能 @Null 必须为 null @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @DecimalMin(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @Digits(integer, fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内 @Positive 判断正数 @PositiveOrZero 判断正数或 0 @Negative 判断负数 @NegativeOrZero 判断负数或 0 @Future 被注释的元素必须是一个将来的日期 @FutureOrPresent 判断日期是否是将来或现在日期 @Past 检查该字段的日期是在过去 @PastOrPresent 判断日期是否是过去或现在日期 @SafeHtml 判断提交的 HTML 是否安全。例如说,不能包含 JavaScript 脚本等等 # 2. 参数校验使用 只需要三步,即可开启参数校验的功能。 〇 第零步,引入参数校验的 spring-boot-starter-validation ( opens new window) 依赖。一般不需要做,项目默认已经引入。 ① 第一步,在需要参数校验的类上,添加 @Validated ( opens new window) 注解,例如说 Controller、Service 类。代码如下: // Controller 示例@Validatedpublic class AuthController {}// Service 示例,一般放在实现类上@Service@Validatedpublic class AdminAuthServiceImpl implements AdminAuthService {} ② 第二步(情况一)如果方法的参数是 Bean 类型,则在方法参数上添加 @Valid (opens new window) 注解,并在 Bean 类上添加参数校验的注解。代码如下: // Controller 示例@Validatedpublic class AuthController { @PostMapping("/login") public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {}}// Service 示例,一般放在接口上public interface AdminAuthService { String login(@Valid AuthLoginReqVO reqVO, String userIp, String userAgent);}// Bean 类的示例。一般建议添加参数注解到属性上。原因:采用 Lombok 后,很少使用 getter 方法public class AuthLoginReqVO { @NotEmpty(message = "登录账号不能为空") @Length(min = 4, max = 16, message = "账号长度为 4-16 位") @Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母") private String username; @NotEmpty(message = "密码不能为空") @Length(min = 4, max = 16, message = "密码长度为 4-16 位") private String password;} ② 第二步(情况二)如果方法的参数是普通类型,则在方法参数上直接添加参数校验的注解。代码如下: // Controller 示例@Validatedpublic class DictDataController { @GetMapping(value = "/get") public CommonResult<DictDataRespVO> getDictData(@RequestParam("id") @NotNull(message = "编号不能为空") Long id) {}}// Service 示例,一般放在接口上public interface DictDataService { DictDataDO getDictData(@NotNull(message = "编号不能为空") Long id);} ③ 启动项目,模拟调用 RESTful API 接口,少填写几个参数,看看参数校验是否生效。 疑问:Controller 做了参数校验后,Service 是否需要做参数校验? 是需要的。Service 可能会被别的 Service 进行调用,也会存在参数不正确的情况,所以必须进行参数校验。 # 3. 自定义注解 如果 Validator 内置的参数校验注解不满足需求时,我们也可以自定义参数校验的注解。 在项目的 yudao-common (opens new window) 的 validation (opens new window) 包下,就自定义了多个参数校验的注解,以 @Mobile (opens new window) 注解来举例,它提供了手机格式的校验。 ① 第一步,新建 @Mobile 注解,并设置自定义校验器为 MobileValidator (opens new window) 类。代码如下: @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})@Retention(RetentionPolicy.RUNTIME)@Documented@Constraint( validatedBy = MobileValidator.class // 设置校验器)public @interface Mobile { String message() default "手机号格式不正确"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {};} ② 第二步,新建 MobileValidator (opens new window) 校验器。代码如下: public class MobileValidator implements ConstraintValidator<Mobile, String> { @Override public void initialize(Mobile annotation) { } @Override public boolean isValid(String value, ConstraintValidatorContext context) { // 如果手机号为空,默认不校验,即校验通过 if (StrUtil.isEmpty(value)) { return true; } // 校验手机 return ValidationUtils.isMobile(value); }} ③ 第三步,在需要手机格式校验的参数上添加 @Mobile 注解。示例代码如下: public class AppAuthLoginReqVO { @NotEmpty(message = "手机号不能为空") @Mobile // <=== here private String mobile;} # 4. 更多使用文档 更多关于 Validator 的使用,可以系统阅读 《芋道 Spring Boot 参数校验 Validation 入门 》 ( opens new window) 文章。 例如说,手动参数校验、分组校验、国际化 i18n 等等。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/30, 21:15:08 异常处理(错误码) 分页实现 ← 异常处理(错误码) 分页实现→"},{"title":"多数据源(读写分离)","path":"/wiki/YuDaoCloud/后端手册/多数据源(读写分离)/多数据源(读写分离).html","content":"开发指南后端手册 芋道源码 2022-04-02 目录 多数据源(读写分离) yudao-spring-boot-starter-mybatis (opens new window) 技术组件,除了提供 MyBatis 数据库操作,还提供了如下 2 种功能: 数据连接池:基于 Alibaba Druid (opens new window) 实现,额外提供监控的能力。 多数据源(读写分离):基于 Dynamic Datasource (opens new window) 实现,支持 Druid 连接池,可集成 Seata (opens new window) 实现分布式事务。 # 1. 数据连接池 友情提示: 如果你未学习过 Druid 数据库连接池,可以后续阅读 《芋道 Spring Boot 数据库连接池入门》 (opens new window) 文章。 <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId></dependency> # 1.1 Druid 监控配置 友情提示:以 yudao-module-system 服务为例子。 在 application-local.yaml ( opens new window) 配置文件中,通过 spring.datasource.druid 配置项,仅仅设置了 Druid 监控相关的配置项目,具体数据库的设置需要使用 Dynamic Datasource 的配置项。如下图所示: # 1.2 Druid 监控界面 ① 访问后端的 /druid/index.html 路径,例如说本地的 http://127.0.0.1:48080/druid/index.html 地址,可以查看到 Druid 监控界面。如下图所示: ② 访问前端的 [基础设施 -> MySQL 监控] 菜单,也可以查看到 Druid 监控界面。如下图所示: 补充说明: 前端 [基础设施 -> MySQL 监控] 菜单,通过 iframe 内嵌后端的 /druid/index.html 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.druid 配置项。 # 2. 多数据源 友情提示: 如果你未学习过多数据源,可以后续阅读 《芋道 Spring Boot 多数据源(读写分离)入门》 ( opens new window) 文章。 <dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId></dependency> # 2.1 多数据源配置 友情提示:以 yudao-module-system 服务为例子。 在 application-local.yaml ( opens new window) 配置文件中,通过 spring.datasource.dynamic 配置项,配置了 Master-Slave 主从两个数据源。如下图所示: # 2.2 数据源切换 # 2.2.1 @Master 注解 在方法上添加 @Master ( opens new window) 注解,使用名字为 master 的数据源,即使用【主】库,一般适合【写】场景。示例如下图: 由于项目的 spring.datasource.dynamic.primary 为 master,默认使用【主】库,所以无需手动添加 @Master 注解。 # 2.2.2 @Slave 注解 在方法上添加 @Slave ( opens new window) 注解,使用名字为 slave 的数据源,即使用【从】库,一般适合【读】场景。示例如下图: # 2.2.3 @DS 注解 在方法上添加 @DS ( opens new window) 注解,使用指定名字的数据源,适合多数据源的情况。示例如下图: # 2.3 分布式事务 在使用 Spring @Transactional 声明的事务中,无法进行数据源的切换,此时有 3 种解决方案: ① 拆分成多个 Spring 事务,每个事务对应一个数据源。如果是【写】场景,可能会存在多数据源的事务不一致的问题。 ② 引入 Seata 框架,提供完整的分布式事务的解决方案,可学习 《芋道 Seata 极简入门 》 ( opens new window) 文章。 ③ 使用 Dynamic Datasource 提供的 @DSTransactional ( opens new window) 注解,支持多数据源的切换,不提供绝对可靠的多数据源的事务一致性(强于 ① 弱于 ②),可学习 《DSTransactional 实现源码分析 》 ( opens new window) 文章。 # 3. 分库分表 建议采用 ShardingSphere 的子项目 Sharding-JDBC 完成分库分表的功能,可阅读 《芋道 Spring Boot 分库分表入门 》 ( opens new window) 文章,学习如何整合进项目。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:24:52 数据库 MyBatis Redis 缓存 ← 数据库 MyBatis Redis 缓存→"},{"title":"幂等性(防重复提交)","path":"/wiki/YuDaoCloud/后端手册/幂等性(防重复提交)/幂等性(防重复提交).html","content":"开发指南后端手册 芋道源码 2022-04-09 目录 幂等性(防重复提交) yudao-spring-boot-starter-protection (opens new window) 技术组件,由它的 idempotent (opens new window) 包,提供声明式的幂等特性,可防止重复请求。例如说,用户快速的双击了某个按钮,前端没有禁用该按钮,导致发送了两次重复的请求。 // UserController.java@Idempotent(timeout = 10, timeUnit = TimeUnit.SECONDS, message = "正在添加用户中,请勿重复提交")@PostMapping("/user/create")public String createUser(User user){ userService.createUser(user); return "添加成功";} # 1. 实现原理 它的实现原理非常简单,针对相同参数的方法,一段时间内,有且仅能执行一次。执行流程如下: ① 在方法执行前,根据参数对应的 Key 查询是否存在。 如果存在,说明正在执行中,则进行报错。 如果不在 ,则计算参数对应的 Key,存储到 Redis 中,并设置过期时间,即标记正在执行中。 默认参数的 Redis Key 的计算规则由 DefaultIdempotentKeyResolver ( opens new window) 实现,使用 MD5(方法名 + 方法参数),避免 Redis Key 过长。 ② 方法执行完成, 不会主动删除参数对应的 Key。 如果希望会主动删除 Key,可以使用 《开发指南 —— 分布式锁》 提供的 @Lock 来实现幂等性。 🙂 从本质上来说,idempotent 包提供的幂等特性,本质上也是基于 Redis 实现的分布式锁。 ③ 如果方法执行时间较长,超过 Key 的过期时间,则 Redis 会自动删除对应的 Key。因此,需要大概评估下,避免方法的执行时间超过过期时间。 # 2. @Idempotent 注解 @Idempotent ( opens new window) 注解,声明在方法上,表示该方法需要开启幂等性。代码如下: ① 对应的 AOP 切面是 IdempotentAspect ( opens new window) 类,核心就 10 行左右的代码,如下图所示: ② 对应的 Redis Key 的前缀是 idempotent:%s ,可见 IdempotentRedisDAO ( opens new window) 类,如下图所示: # 3. 使用示例 本小节,我们实现 /admin-api/infra/test-demo/get RESTful API 接口的幂等性。 ① 在 pom.xml 文件中,引入 yudao-spring-boot-starter-protection 依赖。 <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-protection</artifactId></dependency> ② 在 /admin-api/infra/test-demo/get RESTful API 接口的对应方法上,添加 @Idempotent 注解。代码如下: // TestDemoController.java@GetMapping("/get")@Idempotent(timeout = 10, message = "重复请求,请稍后重试")public CommonResult<TestDemoRespVO> getTestDemo(@RequestParam("id") Long id) { // ... 省略代码} ③ 调用 /admin-api/infra/test-demo/get RESTful API 接口,执行成功。 ④ 再次调用 /admin-api/infra/test-demo/get RESTful API 接口,被幂等性拦截,执行失败。 { "code": 900, "data": null, "msg": "重复请求,请稍后重试"} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 12:11:24 分布式锁 数据库文档 ← 分布式锁 数据库文档→"},{"title":"工具类 Util","path":"/wiki/YuDaoCloud/后端手册/工具类 Util/工具类 Util.html","content":"开发指南后端手册 芋道源码 2022-04-04 目录 工具类 Util 本小节,介绍项目中使用到的工具类,避免大家重复造轮子。 # 1. Hutool 项目使用 Hutool (opens new window) 作为主工具库。Hutool 是国产的一个 Java 工具包,它可以帮助我们简化每一行代码,减少每一个方法,让 Java 语言也可以“甜甜的”。 yudao-common (opens new window) 模块的 util (opens new window) 包作为辅工具库,以 Utils 结尾,补充 Hutool 缺少的工具能力。 友情提示:常用的工具类,使用 ⭐ 标记,需要的时候可以找找有没对应的工具方法。 作用 Hutool 芋道 Utils 数组工具 ArrayUtil (opens new window) ArrayUtils (opens new window) ⭐ 集合工具 CollUtil (opens new window) CollectionUtils (opens new window) ⭐ Map 工具 MapUtil (opens new window) MapUtils (opens new window) Set 工具 SetUtils (opens new window) List 工具 ListUtil (opens new window) 文件工具 FileUtil (opens new window) FileTypeUtil (opens new window) FileUtils (opens new window) 压缩工具 ZipUtil (opens new window) IoUtils (opens new window) IO 工具 ZipUtil (opens new window) Resource 工具 ResourceUtil (opens new window) JSON 工具 JsonUtils (opens new window) 数字工具 NumberUtil (opens new window) NumberUtils (opens new window) 对象工具 ObjectUtil (opens new window) ObjectUtils (opens new window) 唯一 ID 工具 IdUtil (opens new window) ⭐ 字符串工具 StrUtil (opens new window) StrUtils (opens new window) 时间工具 DateUtil (opens new window) DateUtils (opens new window) 反射工具 ReflectUtil (opens new window) 异常工具 ExceptionUtil (opens new window) 随机工具 RandomUtil (opens new window) RandomUtils (opens new window) URL 工具 URLUtil (opens new window) HttpUtils (opens new window) Servlet 工具 ServletUtils (opens new window) Spring 工具 SpringUtil (opens new window) SpringAopUtils (opens new window) SpringExpressionUtils (opens new window) 分页工具 PageUtils (opens new window) 校验工具 ValidationUtil (opens new window) ValidationUtils (opens new window) 断言工具 Assert (opens new window) AssertUtils (opens new window) 强烈推荐: Guava 是 Google 开源的 Java 常用类库,如果你感兴趣,可以阅读 《Guava 学习笔记》 (opens new window) 文章。 # 2. Lombok Lombok (opens new window) 是一个 Java 工具,通过使用其定义的注解,自动生成常见的冗余代码,提升开发效率。 如果你没有学习过 Lombok,需要阅读下 《芋道 Spring Boot 消除冗余代码 Lombok 入门》 (opens new window) 文章。 在项目的根目录有 lombok.config (opens new window) 全局配置文件,开启链式调用、生成的 toString/hashcode/equals 方法需要调用父方法。如下图所示: # 3. MapStruct 项目使用 MapStruct (opens new window) 实现 VO、DO、DTO 等对象之间的转换。 如果你没有学习过 MapStruct,需要阅读下 《芋道 Spring Boot 对象转换 MapStruct 入门》 (opens new window) 文章。 在每个 yudao-module-xxx-biz 模块的 convert 包下,可以看到各个业务的 Convert 接口,如下图所示: # 4. HTTP 调用 ① 使用 Feign 实现声明式的调用,可参考《芋道 Spring Boot 声明式调用 Feign 入门 》 (opens new window)文章。 ② 使用 Hutool 自带的 HttpUtil (opens new window) 工具类。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 11:00:42 异步任务 单元测试 ← 异步任务 单元测试→"},{"title":"异常处理(错误码)","path":"/wiki/YuDaoCloud/后端手册/异常处理(错误码)/异常处理(错误码).html","content":"开发指南后端手册 芋道源码 2022-03-25 目录 异常处理(错误码) 本章节,将讲解异常相关的统一响应、异常处理、业务异常、错误码这 4 块的内容。 # 1. 统一响应 后端提供 RESTful API 给前端时,需要响应前端 API 调用是否成功: 如果成功,成功的数据是什么。后续,前端会将数据渲染到页面上 如果失败,失败的原因是什么。一般,前端会将原因弹出提示给用户 因此,需要有统一响应,而不能是每个接口定义自己的风格。一般来说,统一响应返回信息如下: 成功时,返回成功的状态码 + 数据 失败时,返回失败的状态码 + 错误提示 在标准的 RESTful API 的定义,是推荐使用 HTTP 响应状态码 (opens new window) 作为状态码。一般来说,我们实践很少这么去做,主要原因如下: 业务返回的错误状态码很多,HTTP 响应状态码无法很好的映射。例如说,活动还未开始、订单已取消等等 学习成本高,开发者对 HTTP 响应状态码不是很了解。例如说,可能只知道 200、403、404、500 几种常见的 # 1.1 CommonResult yudao-cloud (opens new window) 项目在实践时,将状态码放在 Response Body 响应内容中返回。一共有 3 个字段,通过 CommonResult (opens new window) 定义如下: // 成功响应{ code: 0, data: { id: 1, username: "yudaoyuanma" }}// 失败响应{ code: 233666, message: "徐妈太丑了"} 可以增加 success 字段吗? 有些团队在实践时,会增加了 success 字段,通过 true 和 false 表示成功还是失败。 这个看每个团队的习惯吧。艿艿的话,还是偏好基于约定,返回 0 时表示成功。 失败时的 code 字段,使用全局的错误码,稍后在 「4. 错误码」 小节来讲解。 ① 在 RESTful API 成功时,定义 Controller 对应方法的返回类型为 CommonResult,并调用 #success(T data) (opens new window) 方法来返回。代码如下图: CommonResult 的 data 字段是泛型,建议定义对应的 VO 类,而不是使用 Map 类。 ② 在 RESTful API 失败时,通过抛出 Exception 异常,具体在 「2. 异常处理」 小节。 # 1.2 使用 @ControllerAdvice ? 在 Spring MVC 中,可以使用 @ControllerAdvice 注解,通过 Spring AOP 拦截修改 Controller 方法的返回结果,从而实现全局的统一返回。 使用 @ControllerAdvice 注解的实战案例? 如果你感兴趣的话,可以阅读 《芋道 Spring Boot SpringMVC 入门 》 (opens new window) 文章的「4. 全局统一返回 」小节。 为什么项目不采用这种方式呢?主要原因是,这样的方式“破坏”了方法的定义,导致一些隐性的问题。例如说,Swagger 接口定义错误,展示的响应结果不是 CommonResult。 还有个原因,部分 RESTful API 不需要自动包装 CommonResult 结果。例如说,第三方支付回调只需要返回 \"success\" 字符串。 # 2. 异常处理 RESTful API 发生异常时,需要拦截 Exception 异常,转换成统一响应的格式,否则前端无法处理。 # 2.1 Spring MVC 的异常 在 Spring MVC 中,通过 @ControllerAdvice + @ExceptionHandler 注解,声明将指定类型的异常,转换成对应的 CommonResult 响应。实现的代码,可见 GlobalExceptionHandler (opens new window) 类,代码如下: # 2.2 Filter 的异常 在请求被 Spring MVC 处理之前,是先经过 Filter 处理的,此时发生异常时,是无法通过 @ExceptionHandler 注解来处理的。只能通过 try catch 的方式来实现,代码如下: # 3. 业务异常 在 Service 发生业务异常时,如果进行返回呢?例如说,用户名已经存在,商品库存不足等。常用的方案选择,主要有两种: 方案一,使用 CommonResult 统一响应结果,里面有错误码和错误提示,然后进行 return 返回 方案二,使用 ServiceException 统一业务异常,里面有错误码和错误提示,然后进行 throw 抛出 选择方案一 CommonResult 会存在两个问题: 因为 Spring @Transactional 声明式事务,是基于异常进行回滚的,如果使用 CommonResult 返回,则事务回滚会非常麻烦 当调用别的方法时,如果别人返回的是 CommonResult 对象,还需要不断的进行判断,写起来挺麻烦的 因此,项目采用方案二 ServiceException 异常。 # 3.1 ServiceException 定义 ServiceException (opens new window) 异常类,继承 RuntimeException 异常类(非受检),用于定义业务异常。代码如下: 为什么继承 RuntimeException 异常? 大多数业务场景下,我们无需处理 ServiceException 业务异常,而是通过 GlobalExceptionHandler 统一处理,转换成对应的 CommonResult 对象,进而提示给前端即可。 如果真的需要处理 ServiceException 时,通过 try catch 的方式进行主动捕获。 # 3.2 ServiceExceptionUtil 在 Service 需抛出业务异常时,通过调用 ServiceExceptionUtil (opens new window) 的 #exception(ErrorCode errorCode, Object... params) 方法来构建 ServiceException 异常,然后使用 throw 进行抛出。代码如下: // ServiceExceptionUtil.javapublic static ServiceException exception(ErrorCode errorCode) { /** 省略参数 */ }public static ServiceException exception(ErrorCode errorCode, Object... params) { /** 省略参数 */ } 为什么使用 ServiceExceptionUtil 来构建 ServiceException 异常? 错误提示的内容,支持使用管理后台进行动态配置,所以通过 ServiceExceptionUtil 获取内容的配置与格式化。 # 4. 错误码 错误码,对应 ErrorCode (opens new window) 类,枚举项目中的错误,全局唯一,方便定位是谁的错、错在哪。 # 4.1 错误码分类 错误码分成两类:全局的系统错误码、模块的业务错误码。 # 4.1.1 系统错误码 全局的系统错误码,使用 0-999 错误码段,和 HTTP 响应状态码 (opens new window) 对应。虽然说,HTTP 响应状态码作为业务使用表达能力偏弱,但是使用在系统层面还是非常不错的。 系统错误码定义在 GlobalErrorCodeConstants (opens new window) 类,代码如下: # 4.1.2 业务错误码 模块的业务错误码,按照模块分配错误码的区间,避免模块之间的错误码冲突。 ① 业务错误码一共 10 位,分成 4 段,在 ServiceErrorCodeRange (opens new window) 分配,规则与代码如下图: ② 每个业务模块,定义自己的 ErrorCodeConstants 错误码枚举类。以 yudao-module-system 模块举例子,代码如下: # 4.2 错误码管理 在管理后台的 [系统管理 -> 错误码管理] 菜单,可以进行错误码的管理。 启动中的项目会每 60 秒,加载最新的错误码配置。所以,我们在修改完错误码的提示后,无需重启项目。 # 4.2.1 手动添加 点击 [新增] 按钮,进行错误码的手动添加。如下图所示: # 4.2.2 自动添加 通过 yudao.error-code.constants-class-list 配置项,设置需要自动添加的 ErrorCodeConstants 错误码枚举类。如下图所示: 项目启动时,会自动扫描对应的 ErrorCodeConstants 中的错误码,自动添加或修改错误码的配置。 注意,自动添加的错误码的类型为【自动生成】,一旦在管理后台手动 [编辑] 后,该错误码就不再支持自动修改。 自动添加是如何实现的? 参见 system/framework/errorcode (opens new window) 包的代码。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/30, 21:15:08 SaaS 多租户【数据库隔离】 参数校验 ← SaaS 多租户【数据库隔离】 参数校验→"},{"title":"异步任务","path":"/wiki/YuDaoCloud/后端手册/异步任务/异步任务.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 异步任务 yudao-spring-boot-starter-job (opens new window) 技术组件,除了提供定时任务的功能,还提供了 Async 异步任务的能力。系统使用异步任务,提升执行效率。例如说: 操作日志模块 (opens new window),异步记录【操作日志】 访问日志模块 (opens new window),异步记录【访问日志】 友情提示: 如果你未学习过 Spring 异步任务,可以后续阅读 《芋道 Spring Boot 异步任务入门 》 (opens new window) 文章。 # 1. Async 配置 在 YudaoAsyncAutoConfiguration (opens new window) 配置类,设置使用 TransmittableThreadLocal (opens new window),解决异步执行时上下文传递的问题。如下图所示: 友情提示: 项目使用到 ThreadLocal 的地方,建议都使用 TransmittableThreadLocal 进行替换。 # 2. 引入依赖 以访问日志模块为例,讲解它如何使用异步任务,实现异步记录【访问日志】的功能。 # 2.1 引入依赖 在 yudao-module-system-infra 模块中,引入 yudao-spring-boot-starter-job 技术组件。如下所示: <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-job</artifactId></dependency> # 2.2 添加 @Async 注解 在 ApiAccessLogServiceImpl ( opens new window) 的 #createApiAccessLogAsync(...) 方法上,添加 @Async 注解,声明它要异步执行。如下图所示: # 2.3 测试调用 随便请求一个 RESTful API 接口,可以看到在异步任务的线程池中,进行了访问日志的记录。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 12:11:24 本地缓存 工具类 Util ← 本地缓存 工具类 Util→"},{"title":"敏感词","path":"/wiki/YuDaoCloud/后端手册/敏感词/敏感词.html","content":"开发指南后端手册 芋道源码 2022-12-31 目录 敏感词 本章节,介绍项目的敏感词功能,可用于文本检测,高效过滤色情、广告、敏感、暴恐等违规内容。例如说,用户昵称、评论、私信等文本内容,都可以使用敏感词功能进行过滤。 # 1. 实现原理 敏感词采用 前缀树 (opens new window) 算法,,核心代码见 SimpleTrie (opens new window) 类。 # 2. 使用教程 对应的管理后台,可以在 [系统管理 -> 敏感词] 菜单,进行敏感词的管理。如下图所示: 前端实现:sensitiveWord/index.vue (opens new window) 后端实现:SensitiveWordController (opens new window) # 2.1 添加敏感词 标签:用于敏感词分组,不同的场景会需要使用不同的敏感词,通过标签进行分组。 添加完敏感词后,刷新下界面。 # 2.2 测试敏感词 ① 输入检测文本为“你是白痴么?”,选择标签为“测试”,检测到有敏感词: ② 选择标签为“蔬菜”,检测到米有敏感词: # 3. 敏感词的使用 SensitiveWordApi (opens new window) 提供了敏感词的 API 接口,可以在任意地方使用。方法如下: public interface SensitiveWordApi { /** * 获得文本所包含的不合法的敏感词数组 * * @param text 文本 * @param tags 标签数组 * @return 不合法的敏感词数组 */ List<String> validateText(String text, List<String> tags); /** * 判断文本是否包含敏感词 * * @param text 文本 * @param tags 表述数组 * @return 是否包含 */ boolean isTextValid(String text, List<String> tags);} 使用步骤如下: ① 在需要使用的 yudao-module-*-biz 模块的 pom.xml 中,引入 yudao-module-system-api 依赖。代码如下: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 在该 yudao-module-*-biz 模块的 RpcConfiguration (opens new window) 配置类,注入 SensitiveWordApi 接口。代码如下: @Configuration(proxyBeanMethods = false)@EnableFeignClients(clients = {SensitiveWordApi.class.class})public class RpcConfiguration {} ③ 注入 SensitiveWordApi Bean,调用对应的方法即可。例如说: @Servicepublic class DemoService { @Resource private SensitiveWordApi sensitiveWordApi; public void demo() { sensitiveWordApi.validateText("你是白痴吗", Collections.singletonList("测试")); sensitiveWordApi.isTextValid("你是白痴吗", Collections.singletonList("蔬菜")); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/27, 21:56:08 数据脱敏 验证码 ← 数据脱敏 验证码→"},{"title":"数据库文档","path":"/wiki/YuDaoCloud/后端手册/数据库文档/数据库文档.html","content":"None"},{"title":"数据库 MyBatis","path":"/wiki/YuDaoCloud/后端手册/数据库 MyBatis/数据库 MyBatis.html","content":"开发指南后端手册 芋道源码 2022-04-01 目录 数据库 MyBatis yudao-spring-boot-starter-mybatis (opens new window) 技术组件,基于 MyBatis Plus 实现数据库的操作。如果你没有学习过 MyBatis Plus,建议先阅读 《芋道 Spring Boot MyBatis 入门 》 (opens new window) 文章。 友情提示 MyBatis 是最容易读懂的 Java 框架之一,感兴趣的话,可以看看艿艿写的 《芋道 MyBatis 源码解析》 (opens new window) 系列,已经有 18000 人学习过! # 1. 实体类 BaseDO (opens new window) 是所有数据库实体的父类,代码如下: @Datapublic abstract class BaseDO implements Serializable { /** * 创建时间 */ @TableField(fill = FieldFill.INSERT) private Date createTime; /** * 最后更新时间 */ @TableField(fill = FieldFill.INSERT_UPDATE) private Date updateTime; /** * 创建者,目前使用 AdminUserDO / MemberUserDO 的 id 编号 * * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 */ @TableField(fill = FieldFill.INSERT) private String creator; /** * 更新者,目前使用 AdminUserDO / MemberUserDO 的 id 编号 * * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 */ @TableField(fill = FieldFill.INSERT_UPDATE) private String updater; /** * 是否删除 */ @TableLogic private Boolean deleted;} createTime + creator 字段,创建人相关信息。 updater + updateTime 字段,创建人相关信息。 deleted 字段,逻辑删除。 对应的 SQL 字段如下: `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', # 1.1 主键编号 id 主键编号,推荐使用 Long 型自增,原因是: 自增,保证数据库是按顺序写入,性能更加优秀。 Long 型,避免未来业务增长,超过 Int 范围。 对应的 SQL 字段如下: `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', 项目的 id 默认采用数据库自增的策略,如果希望使用 Snowflake 雪花算法,可以修改 application.yaml 配置文件,将配置项 mybatis-plus.global-config.db-config.id-type 修改为 ASSIGN_ID。如下图所示: # 1.2 逻辑删除 所有表通过 deleted 字段来实现逻辑删除,值为 0 表示未删除,值为 1 表示已删除,可见 application.yaml 配置文件的 logic-delete-value 和 logic-not-delete-value 配置项。如下图所示: ① 所有 SELECT 查询,都会自动拼接 WHERE deleted = 0 查询条件,过滤已经删除的记录。如果被删除的记录,只能通过在 XML 或者 @SELECT 来手写 SQL 语句。例如说: ② 建立唯一索引时,需要额外增加 delete_time 字段,添加到唯一索引字段中,避免唯一索引冲突。例如说,system_users 使用 username 作为唯一索引: 未添加前:先逻辑删除了一条 username = yudao 的记录,然后又插入了一条 username = yudao 的记录时,会报索引冲突的异常。 已添加后:先逻辑删除了一条 username = yudao 的记录并更新 delete_time 为当前时间,然后又插入一条 username = yudao 并且 delete_time 为 0 的记录,不会导致唯一索引冲突。 # 1.3 自动填充 DefaultDBFieldHandler (opens new window) 基于 MyBatis 自动填充机制,实现 BaseDO 通用字段的自动设置。代码如下如: # 1.4 “复杂”字段类型 MyBatis Plus 提供 TypeHandler 字段类型处理器,用于 JavaType 与 JdbcType 之间的转换。示例如下: 常用的字段类型处理器有: JacksonTypeHandler (opens new window):通用的 Jackson 实现 JSON 字段类型处理器。 JsonLongSetTypeHandler (opens new window):针对 Set<Long> 的 Jackson 实现 JSON 字段类型处理器。 另外,如果你后续要拓展自定义的 TypeHandler 实现,可以添加到 cn.iocoder.yudao.framework.mybatis.core.type (opens new window) 包下。 注意事项: 使用 TypeHandler 时,需要设置实体的 @TableName 注解的 @autoResultMap = true。 # 2. 编码规范 ① 数据库实体类放在 dal.dataobject 包下,以 DO 结尾;数据库访问类放在 dal.mysql 包下,以 Mapper 结尾。如下图所示: ② 数据库实体类的注释要完整,特别是哪些字段是关联(外键)、枚举、冗余等等。例如说: ③ 禁止在 Controller、Service 中,直接进行 MyBatis Plus 操作。原因是:大量 MyBatis 操作散落在 Service 中,会导致 Service 的代码越来乱,无法聚焦业务逻辑。 示例 错误 正确 并且,通过只允许将 MyBatis Plus 操作编写 Mapper 层,更好的实现 SELECT 查询的复用,而不是 Service 会存在很多相同且重复的 SELECT 查询的逻辑。 ④ Mapper 的 SELECT 查询方法的命名,采用 Spring Data 的 \"Query methods\" (opens new window) 策略,方法名使用 selectBy查询条件 规则。例如说: ⑤ 优先使用 LambdaQueryWrapper 条件构造器,使用方法获得字段名,避免手写 \"字段\" 可能写错的情况。例如说: ⑥ 简单的单表查询,优先在 Mapper 中通过 default 方法实现。例如说: # 3. CRUD 接口 BaseMapperX (opens new window) 接口,继承 MyBatis Plus 的 BaseMapper 接口,提供更强的 CRUD 操作能力。 # 3.1 selectOne #selectOne(...) (opens new window) 方法,使用指定条件,查询单条记录。示例如下: # 3.2 selectCount #selectCount(...) (opens new window) 方法,使用指定条件,查询记录的数量。示例如下: # 3.3 selectList #selectList(...) (opens new window) 方法,使用指定条件,查询多条记录。示例如下: # 3.4 selectPage 针对 MyBatis Plus 分页查询的二次分装,在 BaseMapperX (opens new window) 中实现,目的是使用项目自己的分页封装: 【入参】查询前,将项目的分页参数 PageParam (opens new window),转换成 MyBatis Plus 的 IPage 对象。 【出参】查询后,将 MyBatis Plus 的分页结果 IPage,转换成项目的分页结果 PageResult (opens new window)。代码如下图: 具体的使用示例,可见 TenantMapper (opens new window) 类中,定义 selectPage 查询方法。代码如下: @Mapperpublic interface TenantMapper extends BaseMapperX<TenantDO> { default PageResult<TenantDO> selectPage(TenantPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX<TenantDO>() .likeIfPresent(TenantDO::getName, reqVO.getName()) // 如果 name 不为空,则进行 like 查询 .likeIfPresent(TenantDO::getContactName, reqVO.getContactName()) .likeIfPresent(TenantDO::getContactMobile, reqVO.getContactMobile()) .eqIfPresent(TenantDO::getStatus, reqVO.getStatus()) // 如果 status 不为空,则进行 = 查询 .betweenIfPresent(TenantDO::getCreateTime, reqVO.getBeginCreateTime(), reqVO.getEndCreateTime()) // 如果 create 不为空,则进行 between 查询 .orderByDesc(TenantDO::getId)); // 按照 id 倒序 }} 完整实战,可见 《开发指南 —— 分页实现》 文档。 # 3.5 insertBatch #insertBatch(...) (opens new window) 方法,遍历数组,逐条插入数据库中,适合少量数据插入,或者对性能要求不高的场景。 示例如下: 为什么不使用 insertBatchSomeColumn 批量插入? 只支持 MySQL 数据库。其它 Oracle 等数据库使用会报错,可见 InsertBatchSomeColumn (opens new window) 说明。 未支持多租户。插入数据库时,多租户字段不会进行自动赋值。 # 4. 批量插入 绝大多数场景下,推荐使用 MyBatis Plus 提供的 IService 的 #saveBatch() (opens new window) 方法。示例 PermissionServiceImpl (opens new window) 如下: # 5. 条件构造器 继承 MyBatis Plus 的条件构造器,拓展了 LambdaQueryWrapperX (opens new window) 和 QueryWrapperX (opens new window) 类,主要是增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。例如说: 具体的使用示例如下: # 6. Mapper XML 默认配置下,MyBatis Mapper XML 需要写在各 yudao-module-xxx-biz 模块的 resources/mapper 目录下。示例 TestDemoMapper.xml (opens new window) 如下: 尽量避免数据库的连表(多表)查询,而是采用多次查询,Java 内存拼接的方式替代。例如说: # 7. 字段加密 EncryptTypeHandler (opens new window),基于 Hutool AES (opens new window) 实现字段的解密与解密。 例如说,数据源配置 (opens new window)的 password 密码需要实现加密存储,则只需要在该字段上添加 EncryptTypeHandler 处理器。示例代码如下: @TableName(value = "infra_data_source_config", autoResultMap = true) // ① 添加 autoResultMap = truepublic class DataSourceConfigDO extends BaseDO { // ... 省略其它字段 /** * 密码 */ @TableField(typeHandler = EncryptTypeHandler.class) // ② 添加 EncryptTypeHandler 处理器 private String password;} 另外,在 application.yaml 配置文件中,可使用 mybatis-plus.encryptor.password 设置加密密钥。 字段加密后,只允许使用精准匹配,无法使用模糊匹配。示例代码如下: @Test // 测试使用 password 查询,可以查询到数据public void testSelectPassword() { // mock 数据 DataSourceConfigDO dbDataSourceConfig = randomPojo(DataSourceConfigDO.class); dataSourceConfigMapper.insert(dbDataSourceConfig);// @Sql: 先插入出一条存在的数据 // 调用 DataSourceConfigDO result = dataSourceConfigMapper.selectOne(DataSourceConfigDO::getPassword, EncryptTypeHandler.encrypt(dbDataSourceConfig.getPassword())); // 重点:需要使用 EncryptTypeHandler 去加密查询字段!!!} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/30, 21:15:08 系统日志 多数据源(读写分离) ← 系统日志 多数据源(读写分离)→"},{"title":"数据权限","path":"/wiki/YuDaoCloud/后端手册/数据权限/数据权限.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 数据权限 数据权限,实现指定用户可以操作指定范围的数据。例如说,针对员工信息的数据权限: 用户 数据范围 普通员工 自己 部门领导 所属部门的所有员工 HR 小姐姐 整个公司的所有员工 上述的这个示例,使用硬编码是可以实现的,并且也非常简单。但是,在业务快速迭代的过程中,类似这种数据需求会越来越多,如果全部采用硬编码的方式,无疑会给我们带来非常大的开发与维护成本。 因此,项目提供 yudao-spring-boot-starter-biz-data-permission (opens new window) 技术组件,只需要少量的编码,无需入侵到业务代码,即可实现数据权限。 友情提示:数据权限是否支持指定用户只能查看数据的某些字段? 不支持。权限可以分成三类:功能权限、数据权限、字段权限。 字段权限的控制,不属于数据权限,而是属于字段权限,会在未来提供,敬请期待。 # 1. 实现原理 yudao-spring-boot-starter-biz-data-permission 技术组件的实现原理非常简单,每次对数据库操作时,他会自动拼接 WHERE data_column = ? 条件来进行数据的过滤。 例如说,查看员工信息的功能,对应 SQL 是 SELECT * FROM system_users,那么拼接后的 SQL 结果会是: 用户 数据范围 SQL 普通员工 自己 SELECT * FROM system_users WHERE id = 自己 部门领导 所属部门的所有员工 SELECT * FROM system_users WHERE dept_id = 自己的部门 HR 小姐姐 整个公司的所有员工 SELECT * FROM system_users 无需拼接 明白了实现原理之后,想要进一步加入理解,后续可以找时间 Debug 调试下 DataPermissionDatabaseInterceptor (opens new window) 类的这三个方法: #processSelect(...) 方法:处理 SELECT 语句的 WHERE 条件。 #processUpdate(...) 方法:处理 UPDATE 语句的 WHERE 条件。 #processDelete(...) 方法:处理 DELETE 语句的 WHERE 条件。 # 2. 基于部门的数据权限 项目内置了基于部门的数据权限,支持 5 种数据范围: 全部数据权限:无数据权限的限制。 指定部门数据权限:根据实际需要,设置可操作的部门。 本部门数据权限:只能操作用户所在的部门。 本部门及以下数据权限:在【本部门数据权限】的基础上,额外可操作子部门。 仅本人数据权限:相对特殊,只能操作自己的数据。 # 2.1 后台配置 可通过管理后台的 [系统管理 -> 角色管理] 菜单,设置用户角色的数据权限。 实现代码? 可见 DeptDataPermissionRule (opens new window) 数据权限规则。 # 2.2 字段配置 每个 Maven Module, 通过自定义 DeptDataPermissionRuleCustomizer (opens new window) Bean,配置哪些表的哪些字段,进行数据权限的过滤。以 yudao-module-system 模块来举例子,代码如下: @Configuration(proxyBeanMethods = false)public class DataPermissionConfiguration { @Bean public DeptDataPermissionRuleCustomizer sysDeptDataPermissionRuleCustomizer() { return rule -> { // dept 基于部门的数据权限 rule.addDeptColumn(AdminUserDO.class); // WHERE dept_id = ? rule.addDeptColumn(DeptDO.class, "id"); // WHERE id = ? // user 基于用户的数据权限 rule.addUserColumn(AdminUserDO.class, "id"); // WHERE id = ?// rule.addUserColumn(OrderDO.class); // WHERE user_id = ? }; }} 注意,数据库的表字段必须添加: 基于【部门】过滤数据权限的表,需要添加部门编号字段,例如说 dept_id 字段。 基于【用户】过滤数据权限的表,需要添加部门用户字段,例如说 user_id 字段。 # 3. @DataPermission 注解 @DataPermission (opens new window) 数据权限注解,可声明在类或者方法上,配置使用的数据权限规则。 ① enable 属性:当前类或方法是否开启数据权限,默认是 true 开启状态,可设置 false 禁用状态。 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 使用示例如下,可见 UserProfileController (opens new window) 类: // UserProfileController.java@GetMapping("/get")@Operation(summary = "获得登录用户信息")@DataPermission(enable = false) // 关闭数据权限,避免只查看自己时,查询不到部门。public CommonResult<UserProfileRespVO> profile() { // .. 省略代码 if (user.getDeptId() != null) { DeptDO dept = deptService.getDept(user.getDeptId()); resp.setDept(UserConvert.INSTANCE.convert02(dept)); } // .. 省略代码} ② includeRules 属性,配置生效的 DataPermissionRule (opens new window) 数据权限规则。例如说,项目里有 10 种 DataPermissionRule 规则,某个方法只想其中的 1 种生效,则可以使用该属性。 ③ excludeRules 属性,配置排除的 DataPermissionRule (opens new window) 数据权限规则。例如说,项目里有 10 种 DataPermissionRule 规则,某个方法不想其中的 1 种生效,则可以使用该属性。 # 4. 自定义的数据权限规则 如果想要自定义数据权限规则,只需要实现 DataPermissionRule (opens new window) 数据权限规则接口,并声明成 Spring Bean 即可。需要实现的只有两个方法: public interface DataPermissionRule { /** * 返回需要生效的表名数组 * 为什么需要该方法?Data Permission 数组基于 SQL 重写,通过 Where 返回只有权限的数据 * * 如果需要基于实体名获得表名,可调用 {@link TableInfoHelper#getTableInfo(Class)} 获得 * * @return 表名数组 */ Set<String> getTableNames(); /** * 根据表名和别名,生成对应的 WHERE / OR 过滤条件 * * @param tableName 表名 * @param tableAlias 别名,可能为空 * @return 过滤条件 Expression 表达式 */ Expression getExpression(String tableName, Alias tableAlias);} #getTableNames() 方法:哪些数据库表,需要使用该数据权限规则。 #getExpression(...) 方法:当操作这些数据库表,需要额外拼接怎么样的 WHERE 条件。 下面,艿艿带你写个自定义数据权限规则的示例,它的数据权限规则是: 针对 system_dict_type 表,它的创建人 creator 要是当前用户。 针对 system_post 表,它的更新人 updater 要是当前用户。 具体实现代码如下: package cn.iocoder.yudao.module.system.framework.datapermission;import cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRule;import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;import com.google.common.collect.Sets;import net.sf.jsqlparser.expression.Alias;import net.sf.jsqlparser.expression.Expression;import net.sf.jsqlparser.expression.LongValue;import net.sf.jsqlparser.expression.operators.relational.EqualsTo;import org.springframework.stereotype.Component;import java.util.Set;@Component // 声明为 Spring Bean,保证被 yudao-spring-boot-starter-biz-data-permission 组件扫描到public class DemoDataPermissionRule implements DataPermissionRule { @Override public Set<String> getTableNames() { return Sets.newHashSet("system_dict_type", "system_post"); } @Override public Expression getExpression(String tableName, Alias tableAlias) { Long userId = SecurityFrameworkUtils.getLoginUserId(); assert userId != null; switch (tableName) { case "system_dict_type": return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, "creator"), new LongValue(userId)); case "system_post": return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, "updater"), new LongValue(userId)); default: return null; } }} ① 启动前端 + 后端项目。 ② 访问 [系统管理 -> 字典管理] 菜单,查看 IDEA 控制台,可以看到 system_dict_type 表的查询自动拼接了 AND creator = 1 的查询条件。 ② 访问 [系统管理 -> 岗位管理] 菜单,查看 IDEA 控制台,可以看到 system_post 表的查询自动拼接了 AND updater = 1 的查询条件。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:33 功能权限 用户体系 ← 功能权限 用户体系→"},{"title":"文件存储(上传下载)","path":"/wiki/YuDaoCloud/后端手册/文件存储(上传下载)/文件存储(上传下载).html","content":"开发指南后端手册 芋道源码 2022-03-17 目录 文件存储(上传下载) 项目支持将文件上传到三类存储器: 兼容 S3 协议的对象存储:支持 MinIO、腾讯云 COS、七牛云 Kodo、华为云 OBS、亚马逊 S3 等等。 磁盘存储:本地、FTP 服务器、SFTP 服务器。 数据库存储:MySQL、Oracle、PostgreSQL、SQL Server 等等。 技术选型? 优先,✔ 推荐方案 1。如果无法使用云服务,可以自己搭建一个 MinIO 服务。参见 《芋道 Spring Boot 对象存储 MinIO 入门 》 (opens new window) 文章。 其次,推荐方案 3。数据库的主从机制可以实现高可用,备份也方便,少量小文件问题不大。 最后,× 不推荐方案 2。主要是实现高可用比较困难,无法实现故障转移。 # 1. 快速入门 本小节,我们来添加个文件配置,并使用它上传下载文件。 # 1.1 新增配置 ① 打开 [基础设施 -> 文件管理 -> 文件配置] 菜单,进入文件配置的界面。 ② 点击 [新增] 按钮,选择存储器为【S3 对象存储器】,并填写七牛云的配置。如下图: 节点地址:s3-cn-south-1.qiniucs.com 存储 bucket:ruoyi-vue-pro accessKey:b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8 accessSecret:kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP 自定义域名:http://test.yudao.iocoder.cn 友善的眼神! 上述七牛云的配置,是艿艿为了大家方便体验,请勿在测试或生产环境体验。 ③ 添加完后,点击该配置所在行的 [测试] 按钮,测试配置是否正确。 ④ 测试通过后,点击该配置所在行的 [主配置] 按钮,设置它为默认的配置,后续使用它进行文件的上传。 # 1.2 上传文件 ① 点击 [基础设施 -> 文件管理 -> 文件列表] 菜单,进入文件列表的界面。 ② 点击 [上传文件] 按钮,选择要上传的文件。 ③ 上传完成后,如果想要删除,可点击该文件所在行的 [删除] 按钮。 # 2. 文件上传 项目提供了 2 种文件上传的方式,分别适合前端、后端使用。 # 2.1 方式一:前端上传 FileController (opens new window) 提供了 /admin-api/infra/file/upload RESTful API,用于前端直接上传文件。 // FileController.java@PostMapping("/upload")@Operation(summary = "上传文件")@OperateLog(logArgs = false) // 上传文件,没有记录操作日志的必要public CommonResult<String> uploadFile(FileUploadReqVO uploadReqVO) throws Exception { MultipartFile file = uploadReqVO.getFile(); String path = uploadReqVO.getPath(); return success(fileService.createFile(file.getOriginalFilename(), path, IoUtil.readBytes(file.getInputStream())));} 前端上传文件的代码如何实现,可见: 文件列表,文件上传 index.vue (opens new window) 个人中心,头像修改 userAvatar.vue (opens new window) # 2.2 方式二:后端上传 yudao-module-infra 的 FileApi (opens new window) 提供了 #createFile(...) 方法,用于后端需要上传文件的逻辑。 // FileApi.java/** * 保存文件,并返回文件的访问路径 * * @param path 文件路径 * @param content 文件内容 * @return 文件路径 */String createFile(String path, byte[] content); 例如说,个人中心修改头像时,需要进行头像的上传。如下图所示: 注意,需要使用到后端上传的 Maven 模块,需要引入 yudao-module-infra-api 依赖。例如说 yudao-module-system-biz 模块的 pom.xml 文件,引用如下: <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-module-infra-api</artifactId> <version>${revision}</version></dependency> # 3. 文件下载 文件上传成功后,返回的是完整的 URL 访问路径 ,例如说 http://test.yudao.iocoder.cn/822aebded6e6414e912534c6091771a4.jpg ( opens new window) 。 不同的文件存储器,返回的 URL 路径的规则是不同的: ① 当存储器是【S3 对象存储】时,支持 HTTP 访问,所以直接使用 S3 对象存储返回的 URL 路径即可。 ② 当存储器是【数据库】【本地磁盘】等时,它们只支持存储,所以需要 FileController ( opens new window) 提供的 /admin-api/infra/file/{configId}/get/{path} RESTful API,读取文件内容后返回。 // FileController.java@GetMapping("/{configId}/get/**")@PermitAll@Operation(summary = "下载文件")@Parameter(name = "configId", description = "配置编号", required = true)public void getFileContent(HttpServletRequest request, HttpServletResponse response, @PathVariable("configId") Long configId) throws Exception { // 获取请求的路径 String path = StrUtil.subAfter(request.getRequestURI(), "/get/", false); if (StrUtil.isEmpty(path)) { throw new IllegalArgumentException("结尾的 path 路径必须传递"); } // 读取内容 byte[] content = fileService.getFileContent(configId, path); if (content == null) { log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path); response.setStatus(HttpStatus.NOT_FOUND.value()); return; } ServletUtils.writeAttachment(response, path, content);} # 4. 文件客户端 技术组件 yudao-spring-boot-starter-file ( opens new window) ,定义了 FileClient ( opens new window) 接口,抽象了文件客户端的方法。 public interface FileClient { /** * 获得客户端编号 * * @return 客户端编号 */ Long getId(); /** * 上传文件 * * @param content 文件流 * @param path 相对路径 * @return 完整路径,即 HTTP 访问地址 */ String upload(byte[] content, String path); /** * 删除文件 * * @param path 相对路径 */ void delete(String path); /** * 获得文件的内容 * * @param path 相对路径 * @return 文件的内容 */ byte[] getContent(String path);} FileClient 有 5 个实现类,使用不同存储器进行文件的上传与下载。UML 类图如所示: 文件上传的调用的 UML 时序图如下所示: # 5. S3 对象存储的配置 做的不错的云存储服务,都是兼容 S3 协议的。如何获取对应的 S3 配置,艿艿整理到了 S3FileClientConfig (opens new window) 配置类。 有一点要注意,云存储服务的 Bucket 需要设置为公共读,不然 URL 无法访问到文件。 并且,最好使用自定义域名,方便迁移到不同的云存储服务。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:52:02 分页实现 Excel 导入导出 ← 分页实现 Excel 导入导出→"},{"title":"数据脱敏","path":"/wiki/YuDaoCloud/后端手册/数据脱敏/数据脱敏.html","content":"开发指南后端手册 芋道源码 2023-01-21 目录 数据脱敏 接口在返回一些敏感或隐私数据时,是需要进行脱敏处理,通常的手段是使用 * 隐藏一部分数据。例如说: 类型 原始数据 脱敏数据 手机 13248765917 132****5917 身份证 530321199204074611 530321**********11 银行卡 9988002866797031 998800********31 # 1. 脱敏组件 yudao-spring-boot-starter-desensitize (opens new window) 基于 Jackson 拓展,只需要在字段上添加脱敏注解,即可实现对该字段进行脱敏。 使用步骤如下: ① 在 pom.xml 引入该依赖,如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-desensitize</artifactId></dependency> ② 在字段上添加脱敏注解。如下所示: @Datapublic static class DesensitizeDemo { @MobileDesensitize // 手机号的脱敏注解 private String phoneNumber;} # 2. 内置脱敏注解 根据不同的脱敏处理方式,项目内置了两类脱敏注解:正则脱敏、滑块脱敏。 # 2.1 regex 正则脱敏 # 2.1.1 @RegexDesensitize 注解 正则脱敏注解 @RegexDesensitize ( opens new window):根据正则表达式,将原始数据进行替换处理。 public @interface RegexDesensitize { /** * 匹配的正则表达式(默认匹配所有) */ String regex() default "^[\\\\s\\\\S]*$"; /** * 替换规则,会将匹配到的字符串全部替换成 replacer */ String replacer() default "******";} 例如说:regex=123; replacer=****** 表示将 123 替换为 ****** 原始字符串 123456789 脱敏后字符串 ******456789 # 2.1.2 其它正则脱敏注解 项目内置了其它基于正则脱敏的常用注解,无需手动填写 regex、replacer 属性,更加方便。例如说: @Datapublic static class DesensitizeDemo { @EmailDesensitize private String email;} 所有注解如下: 注解 原始数据 脱敏数据 @EmailDesensitize (opens new window) example@gmail.com e****@gmail.com # 2.2 slider 滑块脱敏 # 2.2.1 @SliderDesensitize 注解 滑块脱敏注解 @SliderDesensitize (opens new window):根据设置的左右明文字符长度,中间部分全部替换为 *。 例如说:prefixKeep=3; suffixKeep=4; replacer=* 表示前 3 后 4 保持明文,中间都替换成 * 原始字符串 13248765917 脱敏后字符串 132****5917 # 2.2.2 其它滑块脱敏注解 项目内置了其它基于滑块脱敏的常用注解,无需手动填写 prefixKeep、suffixKeep、replacer 属性,更加方便。例如说: @Datapublic static class DesensitizeDemo { @MobileDesensitize private String mobile;} 所有注解如下: 注解 原始数据 脱敏数据 @MobileDesensitize (opens new window) 13248765917 132****5917 @FixedPhoneDesensitize (opens new window) 01086551122 0108*****22 @BankCardDesensitize (opens new window) 9988002866797031 998800********31 @PasswordDesensitize (opens new window) 123456 ****** @CarLicenseDesensitize (opens new window) 粤A66666 粤A6***6 @ChineseNameDesensitize (opens new window) 刘子豪 刘** @IdCardDesensitize (opens new window) 530321199204074611 530321**********11 # 3. 自定义脱敏注解 如果内置的注解无法满足你的需求,只需要自定义一个脱敏注解,并实现它的脱敏处理器即可。 例如说,我们要实现一个新的脱敏处理方法,将编号使用 MD5 或 SHA256 计算后返回。步骤如下: ① 创建 @DigestDesensitize 注解,使用 @DesensitizeBy (opens new window) 标记它使用的处理器。代码如下: import cn.iocoder.yudao.framework.desensitize.core.base.annotation.DesensitizeBy;import cn.iocoder.yudao.framework.desensitize.core.handler.DigestHandler;import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;import java.lang.annotation.*;@Documented@Target({ElementType.FIELD})@Retention(RetentionPolicy.RUNTIME)@JacksonAnnotationsInside@DesensitizeBy(handler = DigestHandler.class) // 使用 @DesensitizeBy 设置它的处理器public @interface DigestDesensitize { /** * 摘要算法,例如说:MD5、SHA256 */ String algorithm() default "md5";} ② 创建 DigestHandler 类,实现 DigestHandler (opens new window) 接口,将编号使用 MD5 或 SHA256 处理。代码如下: import cn.hutool.crypto.digest.DigestUtil;import cn.iocoder.yudao.framework.desensitize.core.annotation.DigestDesensitize;import cn.iocoder.yudao.framework.desensitize.core.base.handler.DesensitizationHandler;public class DigestHandler implements DesensitizationHandler<DigestDesensitize> { @Override public String desensitize(String origin, DigestDesensitize annotation) { String algorithm = annotation.algorithm(); return DigestUtil.digester(algorithm).digestHex(origin); }} 友情提示: ① 如果自定义的是基于正则脱敏的注解,可选择继承 AbstractRegexDesensitizationHandler (opens new window) 处理器。 ① 如果自定义的是基于滑块脱敏的注解,可选择继承 AbstractSliderDesensitizationHandler (opens new window) 处理器。 ③ 在需要使用的字段上,添加 @DigestDesensitize 注解。示例代码如下: @Datapublic static class DesensitizeDemo { @DigestDesensitize private String email;} 完事~ # 4. 脱敏工具类 Hutool 提供了 DesensitizedUtil (opens new window) 脱敏工具类,支持用户 ID、 中文名、身份证、座机号、手机号、 地址、电子邮件、 密码、车牌、银行卡号的脱敏处理。 使用方式,代码如下: DesensitizedUtil.desensitized("100", DesensitizedUtils.DesensitizedType.USER_ID)) = "0"DesensitizedUtil.desensitized("段正淳", DesensitizedUtils.DesensitizedType.CHINESE_NAME)) = "段**"DesensitizedUtil.desensitized("51343620000320711X", DesensitizedUtils.DesensitizedType.ID_CARD)) = "5***************1X"DesensitizedUtil.desensitized("09157518479", DesensitizedUtils.DesensitizedType.FIXED_PHONE)) = "0915*****79"DesensitizedUtil.desensitized("18049531999", DesensitizedUtils.DesensitizedType.MOBILE_PHONE)) = "180****1999"DesensitizedUtil.desensitized("北京市海淀区马连洼街道289号", DesensitizedUtils.DesensitizedType.ADDRESS)) = "北京市海淀区马********"DesensitizedUtil.desensitized("duandazhi-jack@gmail.com.cn", DesensitizedUtils.DesensitizedType.EMAIL)) = "d*************@gmail.com.cn"DesensitizedUtil.desensitized("1234567890", DesensitizedUtils.DesensitizedType.PASSWORD)) = "**********"DesensitizedUtil.desensitized("苏D40000", DesensitizedUtils.DesensitizedType.CAR_LICENSE)) = "苏D4***0"DesensitizedUtil.desensitized("11011111222233333256", DesensitizedUtils.DesensitizedType.BANK_CARD)) = "1101 **** **** **** 3256" 适合场景,逻辑里需要直接对某个变量进行脱敏处理,然后打印 logger 日志,或者存储到数据库中。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/27, 21:56:08 站内信配置 敏感词 ← 站内信配置 敏感词→"},{"title":"新建服务","path":"/wiki/YuDaoCloud/后端手册/新建服务/新建服务.html","content":"开发指南后端手册 芋道源码 2022-03-02 目录 新建服务 本章节,将介绍如何新建名字为 yudao-module-demo 的示例服务,并添加 RESTful API 接口。 虽然内容看起来比较长,是因为艿艿写的比较详细,大量截图,保姆级教程!其实只有 6 个步骤,保持耐心,跟着艿艿一点点来。🙂 完成之后,你会对整个 项目结构 有更充分的了解。 # 👍 相关视频教程 从零开始 06:如何 5 分钟,创建一个新模块? (opens new window) 【该视频是 Boot 单体版,Cloud 待录制】 # 1. 新建 demo 模块 ① 选择 File -> New -> Module 菜单,如下图所示: ② 选择 Maven 类型,选择父模块为 yudao,输入名字为 yudao-module-demo,并点击 Create 按钮,如下图所示: ③ 打开 yudao-module-demo 模块,删除 src 文件,如下图所示: ④ 打开 yudao-module-demo 模块的 pom.xml 文件,修改内容如下: 提示 <!-- --> 部分,只是注释,不需要写到 XML 中。 <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao</artifactId> <groupId>cn.iocoder.cloud</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>yudao-module-demo</artifactId> <packaging>pom</packaging> <!-- 2. 新增 packaging 为 pom --> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块,主要实现 XXX、YYY、ZZZ 等功能。 </description></project> # 2. 新建 demo-api 子模块 ① 新建 yudao-module-demo-api 子模块,整个过程和“新建 demo 模块”是一致的,如下图所示: ② 打开 yudao-module-demo-api 模块的 pom.xml 文件,修改内容如下: <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao-module-demo</artifactId> <groupId>cn.iocoder.cloud</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>yudao-module-demo-api</artifactId> <packaging>jar</packaging> <!-- 2. 新增 packaging 为 jar --> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块 API,暴露给其它模块调用 </description> <dependencies> <!-- 5. 新增 yudao-common 依赖 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-common</artifactId> </dependency> </dependencies></project> ③ 【可选】新建 cn.iocoder.yudao.module.demo 基础包,其中 demo 为模块名。之后,新建 api 和 enums 包。如下图所示: # 3. 新建 demo-biz 子模块 ① 新建 yudao-module-demo-biz 子模块,整个过程和“新建 demo 模块”也是一致的,如下图所示: ② 打开 yudao-module-demo-biz 模块的 pom.xml 文件,修改成内容如下: <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao-module-demo</artifactId> <groupId>cn.iocoder.cloud</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <packaging>jar</packaging> <!-- 2. 新增 packaging 为 jar --> <artifactId>yudao-module-demo-biz</artifactId> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块,主要实现 XXX、YYY、ZZZ 等功能。 </description> <dependencies> <!-- 5. 新增依赖,这里引入的都是比较常用的业务组件、技术组件 --> <!-- Spring Cloud 基础 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-env</artifactId> </dependency> <!-- 依赖服务 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-module-infra-api</artifactId> <version>${revision}</version> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-module-demo-api</artifactId> <version>${revision}</version> </dependency> <!-- 业务组件 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-banner</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-biz-operatelog</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-biz-dict</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-biz-data-permission</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-biz-tenant</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-biz-error-code</artifactId> </dependency> <!-- Web 相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-security</artifactId> </dependency> <!-- DB 相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-mybatis</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-redis</artifactId> </dependency> <!-- RPC 远程调用相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-rpc</artifactId> </dependency> <!-- Registry 注册中心相关 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <!-- Config 配置中心相关 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> </dependency> <!-- Job 定时任务相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-job</artifactId> </dependency> <!-- 消息队列相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-mq</artifactId> </dependency> <!-- Test 测试相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-test</artifactId> </dependency> <!-- 工具类相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-excel</artifactId> </dependency> <!-- 监控相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-monitor</artifactId> </dependency> </dependencies> <build> <!-- 设置构建的 jar 包名 --> <finalName>${project.artifactId}</finalName> <plugins> <!-- 打包 --> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring.boot.version}</version> <configuration> <fork>true</fork> </configuration> <executions> <execution> <goals> <goal>repackage</goal> <!-- 将引入的 jar 打入其中 --> </goals> </execution> </executions> </plugin> </plugins> </build></project> ③ 【必选】新建 cn.iocoder.yudao.module.demo 基础包,其中 demo 为模块名。之后,新建 controller.admin 和 controller.user 等包。如下图所示: 其中 SecurityConfiguration 的 Java 代码如下: package cn.iocoder.yudao.module.demo.framework.security.config;import cn.iocoder.yudao.framework.security.config.AuthorizeRequestsCustomizer;import cn.iocoder.yudao.module.system.enums.ApiConstants;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;/** * Demo 模块的 Security 配置 */@Configuration(proxyBeanMethods = false)public class SecurityConfiguration { @Bean public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() { return new AuthorizeRequestsCustomizer() { @Override public void customize(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) { // Swagger 接口文档 registry.antMatchers("/v3/api-docs/**").permitAll() // 元数据 .antMatchers("/swagger-ui.html").permitAll(); // Swagger UI // Druid 监控 registry.antMatchers("/druid/**").anonymous(); // Spring Boot Actuator 的安全配置 registry.antMatchers("/actuator").anonymous() .antMatchers("/actuator/**").anonymous(); // RPC 服务的安全配置 registry.antMatchers(ApiConstants.PREFIX + "/**").permitAll(); } }; }} 其中 DemoServerApplication 的 Java 代码如下: package cn.iocoder.yudao.module.demo;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;/** * 项目的启动类 * * @author 芋道源码 */@SpringBootApplicationpublic class DemoServerApplication { public static void main(String[] args) { SpringApplication.run(DemoServerApplication.class, args); }} ④ 打开 Maven 菜单,点击刷新按钮,让引入的 Maven 依赖生效。如下图所示: ⑤ 在 resources 目录下,新建配置文件。如下图所示: 其中 application.yml 的配置如下: spring: main: allow-circular-references: true # 允许循环依赖,因为项目是三层架构,无法避免这个情况。 allow-bean-definition-overriding: true # 允许 Bean 覆盖,例如说 Dubbo 或者 Feign 等会存在重复定义的服务 # Servlet 配置 servlet: # 文件上传相关配置项 multipart: max-file-size: 16MB # 单个文件大小 max-request-size: 32MB # 设置总上传的文件大小 mvc: pathmatch: matching-strategy: ANT_PATH_MATCHER # 解决 SpringFox 与 SpringBoot 2.6.x 不兼容的问题,参见 SpringFoxHandlerProviderBeanPostProcessor 类 # Jackson 配置项 jackson: serialization: write-dates-as-timestamps: true # 设置 LocalDateTime 的格式,使用时间戳 write-date-timestamps-as-nanoseconds: false # 设置不使用 nanoseconds 的格式。例如说 1611460870.401,而是直接 1611460870401 write-durations-as-timestamps: true # 设置 Duration 的格式,使用时间戳 fail-on-empty-beans: false # 允许序列化无属性的 Bean # Cache 配置项 cache: type: REDIS redis: time-to-live: 1h # 设置过期时间为 1 小时--- #################### 接口文档配置 ####################springdoc: api-docs: enabled: true # 1. 是否开启 Swagger 接文档的元数据 path: /v3/api-docs swagger-ui: enabled: true # 2.1 是否开启 Swagger 文档的官方 UI 界面 path: /swagger-ui.htmlknife4j: enable: true # 2.2 是否开启 Swagger 文档的 Knife4j UI 界面 setting: language: zh_cn# MyBatis Plus 的配置项mybatis-plus: configuration: map-underscore-to-camel-case: true # 虽然默认为 true ,但是还是显示去指定下。 global-config: db-config: # 重要说明:如果将配置放到 Nacos 时,请注意将 id-type 设置为对应 DB 的类型,否则会报错;详细见 https://gitee.com/zhijiantianya/yudao-cloud/issues/I5W2N0 讨论 id-type: NONE # “智能”模式,基于 IdTypeEnvironmentPostProcessor + 数据源的类型,自动适配成 AUTO、INPUT 模式。# id-type: AUTO # 自增 ID,适合 MySQL 等直接自增的数据库# id-type: INPUT # 用户输入 ID,适合 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库# id-type: ASSIGN_ID # 分配 ID,默认使用雪花算法。注意,Oracle、PostgreSQL、Kingbase、DB2、H2 数据库时,需要去除实体类上的 @KeySequence 注解 logic-delete-value: 1 # 逻辑已删除值(默认为 1) logic-not-delete-value: 0 # 逻辑未删除值(默认为 0) type-aliases-package: ${yudao.info.base-package}.dal.dataobject--- #################### RPC 远程调用相关配置 ####################dubbo: scan: base-packages: ${yudao.info.base-package}.api # 指定 Dubbo 服务实现类的扫描基准包 protocol: name: dubbo # 协议名称 port: -1 # 协议端口,-1 表示自增端口,从 20880 开始 registry: address: spring-cloud://localhost # 设置使用 Spring Cloud 注册中心--- #################### MQ 消息队列相关配置 ####################--- #################### 定时任务相关配置 ####################xxl: job: executor: appname: ${spring.application.name} # 执行器 AppName logpath: ${user.home}/logs/xxl-job/${spring.application.name} # 执行器运行日志文件存储磁盘路径 accessToken: default_token # 执行器通讯TOKEN--- #################### 芋道相关配置 ####################yudao: info: version: 1.0.0 base-package: cn.iocoder.yudao.module.demo web: admin-ui: url: http://dashboard.yudao.iocoder.cn # Admin 管理后台 UI 的地址 swagger: title: 管理后台 description: 提供管理员管理的所有功能 version: ${yudao.info.version} base-package: ${yudao.info.base-package} tenant: # 多租户相关配置项 enable: truedebug: false yudao.info.version.base-package 配置项:可以改成你的项目的基准包名。 其中 application-local.yml 的配置如下: --- #################### 数据库相关配置 ####################spring: # 数据源配置项 autoconfigure: exclude: - com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure # 排除 Druid 的自动配置,使用 dynamic-datasource-spring-boot-starter 配置多数据源 datasource: druid: # Druid 【监控】相关的全局配置 web-stat-filter: enabled: true stat-view-servlet: enabled: true allow: # 设置白名单,不填则允许所有访问 url-pattern: /druid/* login-username: # 控制台管理用户名和密码 login-password: filter: stat: enabled: true log-slow-sql: true # 慢 SQL 记录 slow-sql-millis: 100 merge-sql: true wall: config: multi-statement-allow: true dynamic: # 多数据源配置 druid: # Druid 【连接池】相关的全局配置 initial-size: 5 # 初始连接数 min-idle: 10 # 最小连接池数量 max-active: 20 # 最大连接池数量 max-wait: 600000 # 配置获取连接等待超时的时间,单位:毫秒 time-between-eviction-runs-millis: 60000 # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位:毫秒 min-evictable-idle-time-millis: 300000 # 配置一个连接在池中最小生存的时间,单位:毫秒 max-evictable-idle-time-millis: 900000 # 配置一个连接在池中最大生存的时间,单位:毫秒 validation-query: SELECT 1 FROM DUAL # 配置检测连接是否有效 test-while-idle: true test-on-borrow: false test-on-return: false primary: master datasource: master: name: ruoyi-vue-pro url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.master.name}?allowMultiQueries=true&useUnicode=true&useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X 连接的示例# url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.master.name}?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=CTT # MySQL Connector/J 5.X 连接的示例# url: jdbc:postgresql://127.0.0.1:5432/${spring.datasource.dynamic.datasource.slave.name} # PostgreSQL 连接的示例# url: jdbc:oracle:thin:@127.0.0.1:1521:xe # Oracle 连接的示例# url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=${spring.datasource.dynamic.datasource.master.name} # SQLServer 连接的示例 username: root password: 123456# username: sa# password: JSm:g(*%lU4ZAkz06cd52KqT3)i1?H7W slave: # 模拟从库,可根据自己需要修改 name: ruoyi-vue-pro url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.slave.name}?allowMultiQueries=true&useUnicode=true&useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X 连接的示例# url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.slave.name}?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=CTT # MySQL Connector/J 5.X 连接的示例# url: jdbc:postgresql://127.0.0.1:5432/${spring.datasource.dynamic.datasource.slave.name} # PostgreSQL 连接的示例# url: jdbc:oracle:thin:@127.0.0.1:1521:xe # Oracle 连接的示例# url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=${spring.datasource.dynamic.datasource.slave.name} # SQLServer 连接的示例 username: root password: 123456# username: sa# password: JSm:g(*%lU4ZAkz06cd52KqT3)i1?H7W # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 redis: host: 127.0.0.1 # 地址 port: 6379 # 端口 database: 0 # 数据库索引# password: 123456 # 密码,建议生产环境开启--- #################### MQ 消息队列相关配置 ####################spring: cloud: stream: rocketmq: # RocketMQ Binder 配置项,对应 RocketMQBinderConfigurationProperties 类 binder: name-server: 127.0.0.1:9876 # RocketMQ Namesrv 地址--- #################### 定时任务相关配置 ####################xxl: job: admin: addresses: http://127.0.0.1:9090/xxl-job-admin # 调度中心部署跟地址--- #################### 服务保障相关配置 ##################### Lock4j 配置项lock4j: acquire-timeout: 3000 # 获取分布式锁超时时间,默认为 3000 毫秒 expire: 30000 # 分布式锁的超时时间,默认为 30 毫秒--- #################### 监控相关配置 ##################### Actuator 监控端点的配置项management: endpoints: web: base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator exposure: include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 * ,可以开放所有端点。# Spring Boot Admin 配置项spring: boot: admin: # Spring Boot Admin Client 客户端的相关配置 client: instance: service-host-type: IP # 注册实例时,优先使用 IP [IP, HOST_NAME, CANONICAL_HOST_NAME]# 日志文件配置logging: level: # 配置自己写的 MyBatis Mapper 打印日志 cn.iocoder.yudao.module.demo.dal.mysql: debug--- #################### 芋道相关配置 ##################### 芋道配置项,设置当前项目所有自定义的配置yudao: env: # 多环境的配置项 tag: ${HOSTNAME} security: mock-enable: true xss: enable: false exclude-urls: # 如下两个 url,仅仅是为了演示,去掉配置也没关系 - ${spring.boot.admin.context-path}/** # 不处理 Spring Boot Admin 的请求 - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 access-log: # 访问日志的配置项 enable: false error-code: # 错误码相关配置项 enable: false demo: false # 关闭演示模式 logging.level.cn.iocoder.yudao.module.demo.dal.mysql 配置项:可以改成你的项目的基准包名。 其中 bootstrap.yml 的配置如下: spring: application: name: demo-server profiles: active: localserver: port: 48099# 日志文件配置。注意,如果 logging.file.name 不放在 bootstrap.yaml 配置文件,而是放在 application.yaml 中,会导致出现 LOG_FILE_IS_UNDEFINED 文件logging: file: name: ${user.home}/logs/${spring.application.name}.log # 日志文件名,全路径 spring.application.name 配置项:可以改成你想要的服务名。 server.port 配置项:可以改成你想要的端口号。 其中 bootstrap-local.yml 的配置如下: --- #################### 注册中心相关配置 ####################spring: cloud: nacos: server-addr: 127.0.0.1:8848 discovery: namespace: dev # 命名空间。这里使用 dev 开发环境 metadata: version: 1.0.0 # 服务实例的版本号,可用于灰度发布--- #################### 配置中心相关配置 ####################spring: cloud: nacos: # Nacos Config 配置项,对应 NacosConfigProperties 配置属性类 config: server-addr: 127.0.0.1:8848 # Nacos 服务器地址 namespace: dev # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP name: # 使用的 Nacos 配置集的 dataId,默认为 spring.application.name file-extension: yaml # 使用的 Nacos 配置集的 dataId 的文件拓展名,同时也是 Nacos 配置集的配置格式,默认为 properties 其中 logback-spring.xml 的配置如下: <configuration> <!-- 引用 Spring Boot 的 logback 基础配置 --> <include resource="org/springframework/boot/logging/logback/defaults.xml" /> <!-- 变量 yudao.info.base-package,基础业务包 --> <springProperty scope="context" name="yudao.info.base-package" source="yudao.info.base-package"/> <!-- 格式化输出:%d 表示日期,%X{tid} SkWalking 链路追踪编号,%thread 表示线程名,%-5level:级别从左显示 5 个字符宽度,%msg:日志消息,%n是换行符 --> <property name="PATTERN_DEFAULT" value="%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- [%thread] [%tid] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/> <!-- 控制台 Appender --> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder"> <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout"> <pattern>${PATTERN_DEFAULT}</pattern> </layout> </encoder> </appender> <!-- 文件 Appender --> <!-- 参考 Spring Boot 的 file-appender.xml 编写 --> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder"> <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout"> <pattern>${PATTERN_DEFAULT}</pattern> </layout> </encoder> <!-- 日志文件名 --> <file>${LOG_FILE}</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <!-- 滚动后的日志文件名 --> <fileNamePattern>${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN:-${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz}</fileNamePattern> <!-- 启动服务时,是否清理历史日志,一般不建议清理 --> <cleanHistoryOnStart>${LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START:-false}</cleanHistoryOnStart> <!-- 日志文件,到达多少容量,进行滚动 --> <maxFileSize>${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE:-10MB}</maxFileSize> <!-- 日志文件的总大小,0 表示不限制 --> <totalSizeCap>${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP:-0}</totalSizeCap> <!-- 日志文件的保留天数 --> <maxHistory>${LOGBACK_ROLLINGPOLICY_MAX_HISTORY:-30}</maxHistory> </rollingPolicy> </appender> <!-- 异步写入日志,提升性能 --> <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"> <!-- 不丢失日志。默认的,如果队列的 80% 已满,则会丢弃 TRACT、DEBUG、INFO 级别的日志 --> <discardingThreshold>0</discardingThreshold> <!-- 更改默认的队列的深度,该值会影响性能。默认值为 256 --> <queueSize>256</queueSize> <appender-ref ref="FILE"/> </appender> <!-- SkyWalking GRPC 日志收集,实现日志中心。注意:SkyWalking 8.4.0 版本开始支持 --> <appender name="GRPC" class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.log.GRPCLogClientAppender"> <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder"> <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout"> <pattern>${PATTERN_DEFAULT}</pattern> </layout> </encoder> </appender> <!-- 本地环境 --> <springProfile name="local"> <root level="INFO"> <appender-ref ref="STDOUT"/> <appender-ref ref="GRPC"/> <!-- 本地环境下,如果不想接入 SkyWalking 日志服务,可以注释掉本行 --> <appender-ref ref="ASYNC"/> <!-- 本地环境下,如果不想打印日志,可以注释掉本行 --> </root> </springProfile> <!-- 其它环境 --> <springProfile name="dev,test,stage,prod,default"> <root level="INFO"> <appender-ref ref="STDOUT"/> <appender-ref ref="ASYNC"/> <appender-ref ref="GRPC"/> </root> </springProfile></configuration> # 4. 新建 RESTful API 接口 ① 在 controller.admin 包,新建一个 DemoTestController 类,并新建一个 /demo/test/get 接口。代码如下: package cn.iocoder.yudao.module.demo.controller.admin;import cn.iocoder.yudao.framework.common.pojo.CommonResult;import io.swagger.annotations.Api;import io.swagger.annotations.ApiOperation;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;@Tag(name = "管理后台 - Test")@RestController@RequestMapping("/demo/test")@Validatedpublic class DemoTestController { @GetMapping("/get") @Operation(summary = "获取 test 信息") public CommonResult<String> get() { return success("true"); }} 注意,/demo 是该模块所有 RESTful API 的基础路径,/test 是 Test 功能的基础路径。 ① 在 controller.app 包,新建一个 AppDemoTestController 类,并新建一个 /demo/test/get 接口。代码如下: package cn.iocoder.yudao.module.demo.controller.app;import cn.iocoder.yudao.framework.common.pojo.CommonResult;import io.swagger.annotations.Api;import io.swagger.annotations.ApiOperation;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;@Tag(name = "用户 App - Test")@RestController@RequestMapping("/demo/test")@Validatedpublic class AppDemoTestController { @GetMapping("/get") @Operation(summary = "获取 test 信息") public CommonResult<String> get() { return success("true"); }} 在 Controller 的命名上,额外增加 App 作为前缀,一方面区分是管理后台还是用户 App 的 Controller,另一方面避免 Spring Bean 的名字冲突。 可能你会奇怪,这里我们定义了两个 /demo/test/get 接口,会不会存在重复导致冲突呢?答案,当然是并不会。原因是: controller.admin 包下的接口,默认会增加 /admin-api,即最终的访问地址是 /admin-api/demo/test/get controller.app 包下的接口,默认会增加 /app-api,即最终的访问地址是 /app-api/demo/test/get # 5. 启动 demo 服务 ① 运行 SystemServerApplication 类,将 system 服务启动。运行 InfraServerApplication 类,将 infra 服务启动。 ② 运行 DemoServerApplication 类,将新建的 demo 服务进行启动。启动完成后,使用浏览器打开 http://127.0.0.1:48099/doc.html (opens new window) 地址,进入该服务的 Swagger 接口文档。 ③ 打开“管理后台 - Test”接口,进行 /admin-api/demo/test/get 接口的调试,如下图所示: ④ 打开“用户 App - Test”接口,进行 /app-api/demo/test/get 接口的调试,如下图所示: # 6. 网关配置 ① 打开 yudao-gateway 网关项目的 application.yml 配置文件,增加 demo 服务的路由配置。代码如下: - id: demo-admin-api # 路由的编号 uri: grayLb://demo-server predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 - Path=/admin-api/demo/** filters: - RewritePath=/admin-api/demo/v2/api-docs, /v2/api-docs # 配置,保证转发到 /v2/api-docs- id: demo-app-api # 路由的编号 uri: grayLb://demo-server predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 - Path=/app-api/demo/** filters: - RewritePath=/app-api/demo/v2/api-docs, /v2/api-docs - name: demo-server service-name: demo-server url: /admin-api/demo/v3/api-docs ② 运行 GatewayServerApplication 类,将 gateway 网关服务启动。 ③ 使用浏览器打开 http://127.0.0.1:48080/doc.html (opens new window) 地址,进入网关的 Swagger 接口文档。然后,选择 demo-server 服务,即可进行 /admin-api/demo/test/get 和 /app-api/demo/test/get 接口的调试,如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 09:21:21 删除功能 代码生成(新增功能) ← 删除功能 代码生成(新增功能)→"},{"title":"本地缓存","path":"/wiki/YuDaoCloud/后端手册/本地缓存/本地缓存.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 本地缓存 重要说明: ① 由于大家普遍反馈,“本地缓存”学习成本太高,一般 Redis 缓存足够满足大多数场景的性能要求,所以基本使用 Spring Cache + Redis 所替代。 也因此,本章节更多的,是讲解如何在项目中使用本地缓存。如果你不需要本地缓存,可以忽略本章节。 ② 项目中还保留了部分地方使用本地缓存,例如说:短信客户端、文件客户端、敏感词等。主要原因是,它们是“有状态”的 Java 对象,无法缓存到 Redis 中。 系统使用本地缓存,提升公用逻辑的执行性能。 例如说: * 租户模块 (opens new window) 缓存租户信息,每次 RESTful API 校验租户是否禁用、过期时,无需读库。 部门模块 (opens new window) 缓存部门信息,每次数据权限校验时,无需读库。 权限模块 (opens new window) 缓存权限信息,每次功能权限校验时,无需读库。 # 1. 实现原理 本地缓存的实现,一共有两步,如下图所示: 项目启动时,初始化缓存:从数据库中读取数据,写入到本地缓存(例如说一个 Map 对象) 数据变化时,实时刷新缓存:(例如说通过管理后台修改数据)重新从数据库中读取数据,重新写入到本地缓存 # 2. 实战案例 以 角色模块 (opens new window) 为例,讲解如何实现角色信息的本地缓存。 # 2.1 初始化缓存 ① 在 RoleService (opens new window) 接口中,定义 #initLocalCache() 方法。代码如下: // RoleService.java/** * 初始化角色的本地缓存 */void initLocalCache(); 为什么要定义接口方法? 稍后实时刷新缓存时,会调用 RoleService 接口的该方法。 ② 在 RoleServiceImpl (opens new window) 类中,实现 #initLocalCache() 方法,通过 @PostConstruct 注解,在项目启动时进行本地缓存的初始化。代码如下: // RoleServiceImpl.java/** * 角色缓存 * key:角色编号 {@link RoleDO#getId()} * * 这里声明 volatile 修饰的原因是,每次刷新时,直接修改指向 */@Getterprivate volatile Map<Long, RoleDO> roleCache;/** * 初始化 {@link #roleCache} 缓存 */@Override@PostConstructpublic void initLocalCache() { // 注意:忽略自动多租户,因为要全局初始化缓存 TenantUtils.executeIgnore(() -> { // 第一步:查询数据 List<RoleDO> roleList = roleMapper.selectList(); log.info("[initLocalCache][缓存角色,数量为:{}]", roleList.size()); // 第二步:构建缓存 roleCache = CollectionUtils.convertMap(roleList, RoleDO::getId); });} 疑问:为什么使用 TenantUtils 的 executeIgnore 方法来执行逻辑? 由于 RoleDO 是多租户隔离,如果使用 TenantUtils 方法,会导致缓存刷新时,只加载某个租户的角色数据,导致本地缓存的错误。 所以,如果缓存的数据不存在多租户隔离的情况,可以不使用 TenantUtils 方法!!!! # 2.2 实时刷新缓存 为什么需要使用 Spring Cloud Bus (opens new window) 来实时刷新缓存?考虑到高可用,线上会部署多个 JVM 实例,需要通过 RocketMQ 广播到所有实例,实现本地缓存的刷新。 友情提示: 对 Spring Cloud Bus 不熟悉的同学,可以后续阅读 《芋道 Spring Cloud Alibaba 事件总线 Bus RocketMQ 入门 》 (opens new window) 文档。 # 2.2.1 RoleRefreshMessage 新建 RoleRefreshMessage (opens new window) 类,角色数据刷新 Message。代码如下: @Datapublic class RoleRefreshMessage extends RemoteApplicationEvent { public RoleRefreshMessage() { } public RoleRefreshMessage(Object source, String originService, String destinationService) { super(source, originService, DEFAULT_DESTINATION_FACTORY.getDestination(destinationService)); }} # 2.2.2 RoleProducer ① 新建 RoleProducer ( opens new window) 类,RoleRefreshMessage 的 Producer 生产者。代码如下: @Componentpublic class RoleProducer extends AbstractBusProducer { /** * 发送 {@link RoleRefreshMessage} 消息 */ public void sendRoleRefreshMessage() { publishEvent(new RoleRefreshMessage(this, getBusId(), selfDestinationService())); }} ② 在数据的新增 / 修改 / 删除等写入操作时,需要使用 RoleProducer 发送消息。如下图所示: # 2.2.3 RoleRefreshConsumer 新建 RoleRefreshConsumer (opens new window) 类,RoleRefreshMessage 的 Consumer 消费者,刷新本地缓存。代码如下: @Component@Slf4jpublic class RoleRefreshConsumer { @Resource private RoleService roleService; @EventListener public void execute(RoleRefreshMessage message) { log.info("[execute][收到 Role 刷新消息]"); roleService.initLocalCache(); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/03, 22:14:31 Redis 缓存 异步任务 ← Redis 缓存 异步任务→"},{"title":"短信配置","path":"/wiki/YuDaoCloud/后端手册/短信配置/短信配置.html","content":"开发指南后端手册 芋道源码 2022-04-10 目录 短信配置 本章节,介绍项目的短信功能。该功能提供统一的短信 API 给其它模块,使它们可以快速接入短信功能,无需关心不同短信平台的具体对接。 短信采用异步发送,基于 Redis 消息队列,如下图所示: yudao-spring-boot-starter-biz-sms (opens new window) 业务组件:封装不同短信平台的客户端。 yudao-module-system 的 sms (opens new window) 业务模块,提供短信渠道、模板的配置,短信日志的查看,短信的发送等功能。 # 1. 表结构 # 2. 短信配置 本小节,讲解如何配置短信功能,整个过程如下: 新建一个短信【渠道】,配置对应短信平台的账号 新建一个短信【模版】,配置对应短信平台的模板 测试该短信模板,查看对应的短信【日志】,确认是否发送成功 # 2.1 新建短信渠道 ① 点击 [系统管理 -> 短信管理 -> 短信渠道] 菜单,查看短信渠道的列表。如下图所示: ② 点击 [新增] 按钮,选择渠道编码为【调试(钉钉)】,并填写信息如下图: 短信 API 的账号: 696b5d8ead48071237e4aa5861ff08dbadb2b4ded1c688a7b7c9afc615579859短信 API 的密钥: SEC5c4e5ff888bc8a9923ae47f59e7ccd30af1f14d93c55b4e2c9cb094e35aeed67 疑问 1:为什么选择渠道编码为【调试(钉钉)】? 该类型使用钉钉机器人来模拟短信发送,用于日常调试。 短信 API 的账号,对应机器人的 Webhook 的 access_token 参数 短信 API 的密钥,对应机器人的安全设置的加签 上图使用的配置,是艿艿自己的钉钉机器人。正式使用时,必须参考 《钉钉开放平台 —— 自定义机器人接入 》 (opens new window) 文档,申请自己的专属机器人。 疑问 2:可以选择其它渠道编码吗? 当然可以,这里主要考虑部分同学暂时没有申请短信平台,所以使用【调试(钉钉)】渠道编码。 不同短信平台的配置,可见 「6. 短信平台附录」 小节。 # 2.2 新建短信模板 ① 点击 [系统管理 -> 短信管理 -> 短信模板] 菜单,查看短信模板的列表。如下图所示: ② 点击 [新增] 按钮,选择刚创建的短信渠道,并填写信息如下图: 短信渠道编号:发送该短信模板时,使用的短信渠道,即使用哪个短信平台进行发送 模板编号:短信模板的唯一标识,使用短信 API 时,通过它标识使用的短信模板 模板内容:短信模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 短信 API 模板编号:短信平台的短信模板的编号,需要保证该模板在短信平台已经审核通过 开启状态:短信模板被禁用时,该短信模板将不发送短信,只记录短信日志 疑问:为什么设计短信模板的功能? 在一些场景下,需要修改短信模板所使用的短信平台。例如说:短信平台出现故障,或者切换短信平台等等。 此时,只需要修改短信模板的两个属性:短信渠道编号、短信 API 模板编号,无需重启应用。 # 2.3 查看短信日志 ① 使用钉钉,扫码 图片 加入机器人所在的【ruoyi-vue-pro 短信测试群】,查看测试短信的模拟发送。 ② 点击 [测试] 按钮,输入任一手机号,进行该短信模板的模拟发送。如下图所示: 友情提示:如果使用的短信渠道是阿里云、腾讯云等正式的短信平台,则会发送到填写的手机号中。例如说: ③ 点击 [系统管理 -> 短信管理 -> 短信日志] 采单,可以查看到每条短信的发送状态、接收状态。如下图所示: # 3. 短信发送 # 3.1 SmsSendApi 使用 SmsSendApi (opens new window) 进行短信的发送,支持多种用户类型。它的方法如下: # 3.2 实战案例 以工作流申请通过时,发送短信为例子,讲解 SmsSendApi 的使用。 ① 引入 yudao-module-system-api 依赖,如下图所示: ② 新建对应的短信模板,如下图所示: ③ 使用 Spring 注入 SmsSendApi Bean,调用对应的短信发送方法。如下图所示: # 4. 验证码发送 # 4.1 SmsCodeApi 使用 SmsCodeApi (opens new window) 进行【验证码】短信的发送,例如说:用户手机验证码登录、用户忘记密码等等。它的方法如下: 验证码使用 system_sms_code (opens new window) 表进行存储,默认每天最多发送 10 条,每分钟发送 1 条,有效期为 10 分钟,可通过 yudao.sms-code 配置项进行自定义: # 4.2 实战案例 以会员用户手机验证码登录为例子,讲解 SmsCodeApi 的使用。 ① 引入 yudao-module-system-api 依赖,如下图所示: ② 新建对应的短信模板,如下图所示: ③ 在 SmsSceneEnum (opens new window) 中,枚举会员用户的手机号登录的场景,如下图所示: ④ 使用 Spring 注入 SmsCodeApi Bean,调用对应的短信验证码的发送与使用方法。如下图所示: # 5. 短信客户端 yudao-spring-boot-starter-biz-sms (opens new window) 业务组件,对接阿里云、腾讯云等短信平台,提供统一的短信客户端,提供给 yudao-module-system 的 sms (opens new window) 业务模块来调用。 # 5.1 SmsClient SmsClient (opens new window) 接口,定义短信客户端的方法。代码如下: 每个短信平台,都对应一个 SmsClient 实现类。 # 5.2 SmsCodeMapping SmsCodeMapping (opens new window) 接口,定义短信平台错误码转换成 标准错误码 (opens new window) 的方法。代码如下: 每个短信平台,都对应一个 SmsCodeMapping 实现类。 # 5.3 对接其它短信平台 如果你想要对接其它短信平台,自定义一个 SmsClient + SmsCodeMapping 实现类,并使用 SmsClientFactoryImpl (opens new window) 进行创建。代码如下: # 6. 短信平台附录 一般情况下,建议接入 2-3 个短信平台,避免某个短信平台故障时,影响业务的正常运行。 例如说,手机验证码的短信平台 A 故障时,赶紧将短信验证码切换到短信平台 B 上,否则用户将无法正常登录或是注册。 # 6.1 阿里云 短信 API 的账号、密钥,可通过 阿里云 —— AccessKey (opens new window) 获取。 短信发送回调 URL,可通过 阿里云 —— 短信服务 —— 通用设置 (opens new window) 配置。 # 6.2 腾讯云 短信 API 的账号、密钥,可通过 腾讯云 —— API 密钥管理 (opens new window) 获取。 注意!!! 腾讯云需要额外使用 SDKAppID (opens new window) 参数,它的账号需要采用 SDKAppID secretId 格式,具体可见 TencentSmsChannelProperties (opens new window) 类。 短信发送回调 URL,可通过 腾讯云 —— 短信 —— 基础配置 (opens new window) 配置。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/28, 22:54:42 数据库文档 邮件配置 ← 数据库文档 邮件配置→"},{"title":"系统日志","path":"/wiki/YuDaoCloud/后端手册/系统日志/系统日志.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 系统日志 项目提供 2 类 4 种系统日志: 审计日志:用户的操作日志、登录日志 API 日志:RESTful API 的访问日志、错误日志 # 1. 操作日志 操作日志,记录「谁」在「什么时间」对「什么对象」做了「什么事情」。 打开 [系统管理 -> 审计日志 -> 操作日志] 菜单,可以看到对应的列表,如下图所示: 操作日志的记录,由 yudao-spring-boot-starter-biz-operatelog (opens new window) 技术组件实现,OperateLogAspect (opens new window) 通过 Spring AOP 拦声明了 @OperateLog (opens new window) 注解的方法,异步记录日志。使用示例如下: 操作日志的存储,由 yudao-module-system 的 OperateLog (opens new window) 模块实现,记录到数据库的 system_operate_log (opens new window) 表。 # 1.1 @OperateLog 注解 @OperateLog 注解,一共有 6 个属性,如下图所示: module 属性:操作模块,例如说:用户、岗位、部门等等。为空时,默认会读取类上的 Swagger @Tag 注解的 name 属性。 name 属性:操作名,例如说:新增用户、修改用户等等。为空时,默认会读取方法的 Swagger @Operation 注解的 summary 属性。 type 属性:操作类型,在 OperateTypeEnum (opens new window) 枚举。目前有 GET 查询、CREATE 新增、UPDATE 修改、DELETE 删除、EXPORT 导出、IMPORT 导入、OTHER 其它,可进行自定义。 # 1.2 自动记录 操作日志往往记录的是针对某个对象的写操作,所以针对 POST、PUT、DELETE 等写请求,yudao-spring-boot-starter-biz-operatelog 组件会自动记录操作日志。 基于请求方法,转换出对应的 type 操作方法:POST 对应 CREATE 类型,PUT 对应 UPDATE 类型,DELETE 对应 DELETE 类型,其它对应 OTHER 类型。 基于 Swagger 注解,转换出对应的 module 操作模块、name 操作名。 因此,绝大多数 RESTful API 对应的方法,无需添加 @OperateLog 注解。例如说: 一般来说,只有两种场景需要添加 @OperateLog 注解。 ① 场景一:需要自定义 @OperateLog 注解的属性。例如说: ② 场景二:不想自动记录操作日志。例如说: # 1.3 后续优化 yudao-spring-boot-starter-biz-operatelog 组件目前提供的是轻量级的操作日志的解决方案,暂时未提供很好的记录操作对应、操作明细、拓展字段的能力。例如说: 【新增】2021-09-16 10:00 订单创建,订单号:NO.11089999,其中涉及变量订单号 “NO.11089999” 【修改】2021-09-16 10:00 用户小明修改了订单的配送地址:从 “金灿灿小区” 修改到 “银盏盏小区” 未来,艿艿会引入老友开源的 https://github.com/mouzt/mzt-biz-log (opens new window) 操作日志组件,优化项目的操作日志功能。大家记得给个 Star 哟! 目前,如果要记录具体的操作明细、拓展字段,可以调用 OperateLogUtils (opens new window) 的静态方法,代码如下: # 2. 登录日志 登录日志,记录用户的登录、登出行为,包括成功的、失败的。 打开 [系统管理 -> 审计日志 -> 登录日志] 菜单,可以看对应的列表,如下图所示: 登录日志的存储,由 yudao-module-system 的 LoginLog (opens new window) 模块实现,记录到数据库的 system_login_log (opens new window) 表。 登录类型通过 LoginLogTypeEnum (opens new window) 枚举,登录结果通过 LoginResultEnum (opens new window) 枚举,都可以自定义。代码如下: # 3. API 访问日志 API 访问日志,记录 API 的每次调用,包括 HTTP 请求、用户、开始时间、时长等等信息。 打开 [基础设施 -> API 日志 -> 访问日志] 菜单,可以看对应的列表,如下图所示: 访问日志的记录,由 yudao-spring-boot-starter-web (opens new window) 技术组件实现,通过 ApiAccessLogFilter (opens new window) 过滤 RESTful API 请求,异步记录日志。 访问日志的存储,由 yudao-module-infra 的 AccessLog (opens new window) 模块实现,记录到数据库的 infra_api_access_log (opens new window) 表。 # 4. API 错误日志 API 错误日志,记录每次 API 的异常调用,包括 HTTP 请求、用户、异常的堆栈等等信息。 打开 [基础设施 -> API 日志 -> 错误日志] 菜单,可以看对应的列表,如下图所示: 错误日志的记录,由 yudao-spring-boot-starter-web (opens new window) 技术组件实现,通过 GlobalExceptionHandler (opens new window) 拦截每次 RESTful API 的系统异常,异步记录日志。 错误日志的存储,由 yudao-module-infra 的 ErrorLog (opens new window) 模块实现,记录到数据库的 infra_api_error_log (opens new window) 表。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:58:32 Excel 导入导出 数据库 MyBatis ← Excel 导入导出 数据库 MyBatis→"},{"title":"站内信配置","path":"/wiki/YuDaoCloud/后端手册/站内信配置/站内信配置.html","content":"开发指南后端手册 芋道源码 2023-01-28 目录 站内信配置 本章节,介绍项目的站内信功能。它在管理后台有三个菜单,分别是: ① 站内信模版:管理站内信的内容模版 ② 站内信管理:查看站内信的发送记录 ③ 我的站内信:查看发送给我的站内信 # 1. 表结构 # 2. 实现代码 前端代码:views/system/notify (opens new window) 后端代码:controller/admin/notify (opens new window) # 3. 站内信配置 本小节,讲解如何配置站内信功能,整个过程如下: 新建一个站内信【模版】,配置站内信的内容模版 测试该站内信模板,查看对应的站内信【记录】,确认是否发送成功 # 3.1 新建站内信模版 ① 点击 [系统管理 -> 站内信管理 -> 模板管理] 菜单,查看站内信模板的列表。如下图所示: ② 点击 [新增] 按钮,填写信息如下图: 模版编号:站内信模板的唯一标识,使用站内信 API 时,通过它标识使用的站内信模板 发件人名称:发送站内信显示的发件人名字 模板内容:站内信模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 模版类型:站内信的分类,可使用 system_notify_template_type 字典进行自定义 开启状态:站内信模板被禁用时,该站内信模板将不发送站内信,只打印 logger 日志 疑问:为什么设计站内信模板的功能? 在一些场景下,产品会希望修改发送站内信的内容、发送人昵称,此时只需要修改站内信模版的对应属性,无需重启应用。 # 3.2 测试站内信模版 ① 点击 [测试] 按钮,选择接收人为「芋道源码」,进行该站内信模板的模拟发送。如下图所示: ② 点击 [系统管理 -> 站内信管理 -> 消息记录] 菜单,可以查看到刚发送的站内信。如下图所示: ③ 点击右上角的 [消息] 图标,也可以查看到刚发送的站内信。如下图所示: # 4. 站内信发送 # 4.1 NotifyMessageSendApi 站内信配置完成后,可使用 NotifyMessageSendApi (opens new window) 进行站内信的发送,支持多种用户类型。它的方法如下: # 4.2 接入示例 以 yudao-module-infra 模块,需要发站内信为例子,讲解 SmsCodeApi 的使用。 ① 在 yudao-module-infra-biz 模块的 pom.xml (opens new window) 引入 yudao-module-system-api 依赖,如所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 在代码中注入 NotifyMessageSendApi Bean,并调用发送站内信的方法。代码如下: public class TestDemoServiceImpl implements TestDemoService { // 0. 注入 NotifyMessageSendApi Bean @Resource private NotifyMessageSendApi notifySendApi; public void sendDemo() { // 1. 准备参数 Long userId = 1L; // 示例中写死,你可以改成你业务中的 userId 噢 String templateCode = "test_01"; // 站内信模版,记得在【站内信管理】中配置噢 Map<String, Object> templateParams = new HashMap<>(); templateParams.put("key1", "奥特曼"); templateParams.put("key2", "变身"); // 2. 发送站内信 notifySendApi.sendSingleNotifylToAdmin(new NotifySendSingleToUserReqDTO() .setUserId(userId).setTemplateCode(templateCode).setTemplateParams(templateParams)); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/29, 19:06:42 邮件配置 数据脱敏 ← 邮件配置 数据脱敏→"},{"title":"邮件配置","path":"/wiki/YuDaoCloud/后端手册/邮件配置/邮件配置.html","content":"开发指南后端手册 芋道源码 2023-01-26 目录 邮件配置 本章节,介绍项目的邮件功能。它在管理后台有三个菜单,分别是: ① 邮箱账号:配置邮件的发送账号 ② 邮件模版:管理邮件的内容模版 ③ 邮件记录:查看邮件的发送记录 # 1. 表结构 # 2. 实现原理 邮件功能提供统一的 API 给其它模块,使它们可以快速实现发送邮件的功能,无需关心不同邮件平台的具体对接。 邮件采用异步发送,基于 Redis 消息队列,如下图所示: 前端代码:views/system/mail (opens new window) 后端代码:controller/admin/mail (opens new window) 最终使用 Hutool 的 MailUtil (opens new window) 发送邮件。 # 3. 邮箱配置 本小节,讲解如何配置邮件功能,整个过程如下: 新建一个邮箱【账号】,配置邮件的发送账号 新建一个邮件【模版】,配置邮件的内容模版 测试该邮件模板,查看对应的邮件【日志】,确认是否发送成功 # 3.1 新建邮箱账号 ① 点击 [系统管理 -> 邮件管理 -> 邮箱账号] 菜单,查看邮箱账号的列表。如下图所示: ② 点击 [新增] 按钮,添加一个邮箱账号,并填写信息如下图: 友情提示: 邮件发送基于 SMTP (opens new window) 协议实现,需要开通账号的 STMP 服务。例如说: 不同邮件平台的 SMTP 配置,可见 「5. 邮箱平台附录」 小节。 ③ 新增完成后,确认你的邮箱账号是否可以发送邮件,可通过如下代码: import cn.hutool.extra.mail.MailAccount;import cn.hutool.extra.mail.MailUtil;@Testpublic void testDemo() { MailAccount mailAccount = new MailAccount()// .setFrom("奥特曼 <ydym_test@163.com>") .setFrom("ydym_test@163.com") // 邮箱地址 .setHost("smtp.163.com").setPort(465).setSslEnable(true) // SMTP 服务器 .setAuth(true).setUser("ydym_test@163.com").setPass("WBZTEINMIFVRYSOE"); // 登录账号密码 String messageId = MailUtil.send(mailAccount, "7685413@qq.com", "主题", "内容", false); System.out.println("发送结果:" + messageId);} # 3.2 新建邮箱模版 ① 点击 [系统管理 -> 邮箱管理 -> 邮件模板] 菜单,查看邮件模板的列表。如下图所示: ② 点击 [新增] 按钮,选择刚创建的邮箱账号,并填写信息如下图: 邮箱账号:发送该邮件模板时,使用的邮件账号,即使用哪个邮箱进行发送邮件 模版编号:邮件模板的唯一标识,使用邮件 API 时,通过它标识使用的邮件模板 发件人名称:发送邮件显示的发件人名字 模板内容:邮件模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 开启状态:邮件模板被禁用时,该邮件模板将不发送邮件,只记录邮件日志 疑问:为什么设计邮件模板的功能? 在一些场景下,产品会希望修改发送邮件的标题、内容,甚至邮箱账号,此时只需要修改邮件模版的对应属性,无需重启应用。 # 3.3 查看邮件日志 ① 点击 [测试] 按钮,输入测试的收件邮箱地址,进行该邮件模板的模拟发送。如下图所示: ② 打开收件邮箱,查看邮件是否发送成功。如下图所示: ③ 点击 [系统管理 -> 邮箱管理 -> 邮件日志] 采单,可以查看到每条邮件的发送状态。如下图所示: # 4. 邮件发送 # 4.1 MailSendApi 邮箱配置 完成后,可使用 MailSendApi ( opens new window) 进行邮件的发送,支持多种用户类型。它的方法如下: # 4.2 接入示例 以 yudao-module-infra 模块,需要发邮件为例子,讲解 SmsCodeApi 的使用。 ① 在 yudao-module-infra-biz 模块的 pom.xml ( opens new window) 引入 yudao-module-system-api 依赖,如所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 在代码中注入 SmsCodeApi Bean,并调用发送邮件的方法。代码如下: public class TestDemoServiceImpl implements TestDemoService { // 0. 注入 MailSendApi Bean @Resource private MailSendApi mailSendApi; public void sendDemo() { // 1. 准备参数 Long userId = 1L; // 示例中写死,你可以改成你业务中的 userId 噢 String templateCode = "test_01"; // 邮件模版,记得在【邮箱管理】中配置噢 Map<String, Object> templateParams = new HashMap<>(); templateParams.put("key1", "奥特曼"); templateParams.put("key2", "变身"); // 2. 发送邮件 mailSendApi.sendSingleMailToAdmin(new MailSendSingleToUserReqDTO() .setUserId(userId).setTemplateCode(templateCode).setTemplateParams(templateParams)); }} # 5. 邮箱平台附录 《QQ 邮箱的 SMTP 设置》 ( opens new window) 《网易 163 邮箱的 SMTP 设置》 ( opens new window) 《QQ 邮箱、网易邮箱、腾讯企业邮箱、网易企业邮箱的 SMTP 设置》 ( opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/28, 22:54:42 短信配置 站内信配置 ← 短信配置 站内信配置→"},{"title":"分布式事务 Seata","path":"/wiki/YuDaoCloud/微服务手册/分布式事务 Seata/分布式事务 Seata.html","content":"None"},{"title":"验证码","path":"/wiki/YuDaoCloud/后端手册/验证码/验证码.html","content":"开发指南后端手册 芋道源码 2023-01-20 目录 验证码 项目基于 AJ-Captcha (opens new window) 实现行为验证码,包含滑动拼图、文字点选两种方式,UI 支持弹出和嵌入两种方式。如下图所示: 滑动拼图 文字点选 疑问:为什么采用行为验证码? 相比传统的「传统字符型验证码」的“展示验证码-填写字符-比对答案”的流程来说,「行为验证码」的“展示验证码-操作-比对答案”的流程,用户只需要使用鼠标产生指定的行为轨迹,不需要键盘手动输入,用户体验更好,更加难以被机器识别,更加安全可靠。 # 1. 交互流程 ① 用户访问应用页面,请求显示行为验证码 ② 用户按照提示要求完成验证码拼图/点击 ③ 用户提交表单,前端将第二步的输出一同提交到后台 ④ 验证数据随表单提交到后台后,后台需要调用 captchaService.verification (opens new window) 做二次校验 ⑤ 第 4 步返回校验通过/失败到产品应用后端,再返回到前端 # 2. 如何关闭验证码 管理后台的登录界面,默认开启验证码。如果需要关闭验证码,操作如下: ① 后端的 application-local.yaml 配置文件中,将 yudao.captcha.enabled (opens new window) 设置为 false。 ② 如果前端使用 yudao-ui-admin 项目,将 .env.local 配置文件中,将 VUE_APP_DOC_ENABLE (opens new window) 设置为 false。 如果前端使用 yudao-ui-admin-vue3 项目,将 .env 配置文件中,将 VITE_APP_CAPTCHA_ENABLE (opens new window) 设置为 false。 # 3. 接入场景 # 3.1 后端接入 ① yudao-spring-boot-starter-captcha (opens new window) 对 AJ-Captcha 进行封装,使用 Redis 存储验证码数据,保证分布式环境下的可用性。 由于 AJ-Captcha 对 Spring Boot 3.X 版本的支持还不完善,所以使用 captcha-plus (opens new window) 替代,它是基于 AJ-Captcha 进行增强。 使用时,需要在 pom.xml (opens new window) 引入该依赖,如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-captcha</artifactId></dependency> ② 验证码的配置,在 application.yaml (opens new window) 配置文件中,配置项如下: aj: captcha: jigsaw: classpath:images/jigsaw # 滑动验证,底图路径,不配置将使用默认图片;以 classpath: 开头,取 resource 目录下路径 pic-click: classpath:images/pic-click # 滑动验证,底图路径,不配置将使用默认图片;以 classpath: 开头,取 resource 目录下路径 cache-type: redis # 缓存 local/redis... cache-number: 1000 # local 缓存的阈值,达到这个值,清除缓存 timing-clear: 180 # local定时清除过期缓存(单位秒),设置为0代表不执行 type: blockPuzzle # 验证码类型 default两种都实例化。 blockPuzzle 滑块拼图 clickWord 文字点选 water-mark: 芋道源码 # 右下角水印文字(我的水印),可使用 https://tool.chinaz.com/tools/unicode.aspx 中文转 Unicode,Linux 可能需要转 unicode interference-options: 0 # 滑动干扰项(0/1/2) req-frequency-limit-enable: false # 接口请求次数一分钟限制是否开启 true|false req-get-lock-limit: 5 # 验证失败 5 次,get接口锁定 req-get-lock-seconds: 10 # 验证失败后,锁定时间间隔 req-get-minute-limit: 30 # get 接口一分钟内请求数限制 req-check-minute-limit: 60 # check 接口一分钟内请求数限制 req-verify-minute-limit: 60 # verify 接口一分钟内请求数限制 如果你想修改验证码的 图片,修改 resources/images (opens new window) 目录即可。 ③ 验证码的使用,可以参考 CaptchaController (opens new window) 和 AuthController (opens new window) 两个类的实现代码。 # 3.2 Vue2.X 管理后台 ① 验证码组件:Verifition (opens new window) ② 登录界面的接入:login.vue (opens new window) <!-- 图形验证码 --><Verify ref="verify" :captcha-type="'blockPuzzle'" :img-size="{width:'400px',height:'200px'}" @success="handleLogin" /> # 3.3 Vue3.X 管理后台 ① 验证码组件: Verifition ( opens new window) ② 登录界面的接入: LoginForm.vue ( opens new window) <Verify ref="verify" mode="pop" :captchaType="captchaType" :imgSize="{ width: '400px', height: '200px' }" @success="handleLogin"/> # 3.4 uni-app 用户 App ① 验证码组件: verifition ( opens new window) ② 登录界面的接入: login.vue ( opens new window) <Verify @success="pwdLogin" :mode="'pop'" :captchaType="'blockPuzzle'" :imgSize="{ width: '330px', height: '155px' }" ref="verify"></Verify> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/02/11, 02:22:19 敏感词 地区 & IP 库 ← 敏感词 地区 & IP 库→"},{"title":"服务保障 Sentinel","path":"/wiki/YuDaoCloud/微服务手册/服务保障 Sentinel/服务保障 Sentinel.html","content":"None"},{"title":"服务网关 Spring Cloud Gateway","path":"/wiki/YuDaoCloud/微服务手册/服务网关 Spring Cloud Gateway/服务网关 Spring Cloud Gateway.html","content":"开发指南微服务手册 芋道源码 2022-12-31 目录 服务网关 Spring Cloud Gateway yudao-gateway (opens new window) 模块,基于 Spring Cloud Gateway 构建 API 服务网关,提供用户认证、服务路由、灰度发布、访问日志、异常处理等功能。 友情提示:如何学习 Spring Cloud Gateway? 阅读 《芋道 Spring Cloud 网关 Spring Cloud Gateway 入门 》 (opens new window) 文章。 # 1. 服务路由 新建服务后,在 application.yaml (opens new window) 配置文件中,需要添加该服务的路由配置。示例如下图: # 2. 用户认证 由 filter/security (opens new window) 包实现,无需配置。 TokenAuthenticationFilter 会获得请求头中的 Authorization 字段,调用 system-server 服务,进行用户认证。 如果认证成功,会将用户信息放到 login-user 请求头,转发到后续服务。后续服务可以从 login-user 请求头,解析 (opens new window)到用户信息。 如果认证失败,依然会转发到后续服务,由该服务决定是否需要登录,是否需要校验权限。 考虑到性能,API 网关会本地缓存 (opens new window) Token 与用户信息,每次收到 HTTP 请求时,异步从 system-server 刷新本地缓存。 # 3. 灰度发布 由 filter/grey (opens new window) 包实现,实现原理如下: 所以在使用灰度时,如要如下配置: ① 第一步,【网关】配置服务的路由配置使用 grebLb:// 协议,指向灰度服务。例如说: ② 第二步,【服务】配置服务的版本 version 配置。例如说: ③ 第三步,请求 API 网关时,请求头带上想要 version 版本。 可能想让用户的请求带上 version 请求头比较难,可以通过 Spring Cloud Gateway 修改请求头,通过 User Agent、Cookie、登录用户等信息,来判断用户想要的版本。详细的解析,可见 《Spring Cloud Gateway 实现灰度发布功能 》 (opens new window) 文章。 # 4. 访问日志 由 filter/logging (opens new window) 包实现,无需配置。 每次收到 HTTP 请求时,会打印访问日志,包括 Request、Response、用户等信息。如下图所示: # 5. 异常处理 由 GlobalExceptionHandler (opens new window) 累实现,无需配置。 请求发生异常时,会翻译异常信息,返回给用户。例如说: { "code": 500, "data": null, "msg": "系统异常"} # 6. 动态路由 在 Nacos 配置发生变化时,Spring Cloud Alibaba Nacos Config 内置的监听器,会监听到配置刷新,最终触发 Gateway 的路由信息刷新。 参见 《芋道 Spring Cloud 网关 Spring Cloud Gateway 入门 》 ( opens new window) 博客的「6. 基于配置中心 Nacos 实现动态路由」小节。 使用方式:在 Nacos 新增 DataId 为 gateway-server.yaml 的配置,修改 spring.cloud.gateway.routes 配置项。 # 7. Swagger 接口文档 基于 Knife4j 实现 Swagger 接口文档的 网关聚合 ( opens new window) 。需要路由配置如下: 管理后台的接口:- RewritePath=/admin-api/{服务的基础路由}/v2/api-docs, /v2/api-docs 用户 App 的接口:- RewritePath=/app-api/{服务的基础路由}/v2/api-docs, /v2/api-docs Knife4j 配置: knife4j.gateway.routes 添加 浏览器访问 http://127.0.0.1:48080/doc.html ( opens new window) 地址,可以看到所有接口的信息。如下图所示: # 7.1 如何调用 〇 点击左边「文档管理 - 全局参数设置」菜单,设置 header-id 和 Authorization 请求头。如下图所示: tenant-id:1Authorization: Bearer test1 添加完后,需要 F5 刷新下网页,否则全局参数不生效。 ① 点击任意一个接口,进行接口的调用测试。这里,使用「管理后台 - 用户个中心」的“获得登录用户信息”举例子。 ② 点击左侧「调试」按钮,并将请求头部的 header-id 和 Authorization 勾选上。 其中,header-id 为租户编号,Authorization 的 \"Bearer test\" 后面为用户编号(模拟哪个用户操作)。 ③ 点击「发送」按钮,即可发起一次 API 的调用。 # 7.2 如何关闭 如果想要禁用 Swagger 功能,可通过 knife4j.gateway.enabled 配置项为 false。一般情况下,建议 prod 生产环境进行禁用,避免发生安全问题。 # 8. Cors 跨域处理 由 filter/cors (opens new window) 包实现,无需配置。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 09:21:21 服务调用 Feign 分布式事务 Seata ← 服务调用 Feign 分布式事务 Seata→"},{"title":"服务调用 Feign","path":"/wiki/YuDaoCloud/微服务手册/服务调用 Feign/服务调用 Feign.html","content":"开发指南微服务手册 芋道源码 2022-12-31 目录 服务调用 Feign yudao-spring-boot-starter-rpc (opens new window) 技术组件,基于 Feign 实现服务之间的调用。 为什么不使用 Dubbo 呢? Feign 通用性更强,学习成本更低,对于绝大多数场景,都能够很好的满足需求。虽然 Dubbo 提供的性能更强,特性更全,但都是非必须的。 目前国内 95% 左右都是采用 Feign,而 Dubbo 的使用率只有 5% 左右。所以,我们也选择了 Feign。 如果你对 Feign 了解较少,可以阅读 《芋道 Spring Cloud 声明式调用 Feign 入门》 (opens new window) 系统学习。 # 1. RPC 使用规约 本小节,我们来讲解下项目中 RPC 使用的规约。 # 1.1 API 前缀 API 使用 HTTP 协议,所有的 API 前缀,都以 /rpc-api (opens new window) 开头,方便做统一的全局处理。 # 1.2 API 权限 服务之间的调用,不需要进行权限校验,所以需要在每个服务的 SecurityConfiguration 权限配置类中,添加如下配置: // RPC 服务的安全配置registry.antMatchers(ApiConstants.PREFIX + "/**").permitAll(); # 1.3 API 全局返回 所有 API 接口返回使用 CommonResult ( opens new window) 返回,和前端 RESTful API 保持统一。例如说: public interface DeptApi { @GetMapping(PREFIX + "/get") @Operation(summary = "获得部门信息") @Parameter(name = "id", description = "部门编号", required = true, example = "1024") CommonResult<DeptRespDTO> getDept(@RequestParam("id") Long id);} # 1.4 用户传递 服务调用时,已经封装 Feign 将用户信息通过 HTTP 请求头 login-user 传递,通过 LoginUserRequestInterceptor ( opens new window) 类实现。 这样,被调用服务,可以通过 SecurityFrameworkUtils 获取到用户信息,例如说: #getLoginUser() 方法,获取当前用户。 #getLoginUserId() 方法,获取当前用户编号。 # 2. 如何定义一个 API 接口 本小节,我们来讲解下如何定义一个 API 接口。以 AdminUserApi 提供的 getUser 接口来举例子。 # 2.1 服务提供者 AdminUserApi 由 system-server 服务所提供。 # 2.1.1 ApiConstants 在 yudao-module-system-api 模块,创建 ApiConstants ( opens new window) 类,定义 API 相关的枚举。代码如下: public class ApiConstants { /** * 服务名 * * 注意,需要保证和 spring.application.name 保持一致 */ public static final String NAME = "system-server"; public static final String PREFIX = RpcConstants.RPC_API_PREFIX + "/system"; public static final String VERSION = "1.0.0";} # 2.1.2 AdminUserApi 在 yudao-module-system-api 模块,创建 AdminUserApi ( opens new window) 类,定义 API 接口。代码如下: @FeignClient(name = ApiConstants.NAME) // ① @FeignClient 注解@Tag(name = "RPC 服务 - 管理员用户") // ② Swagger 接口文档public interface AdminUserApi { String PREFIX = ApiConstants.PREFIX + "/user"; @GetMapping(PREFIX + "/get") // ③ Spring MVC 接口注解 @Operation(summary = "通过用户 ID 查询用户") // ② Swagger 接口文档 @Parameter(name = "id", description = "部门编号", required = true, example = "1024") // ② Swagger 接口文档 CommonResult<AdminUserRespDTO> getUser(@RequestParam("id") Long id);} 另外,需要创建 AdminUserRespDTO (opens new window) 类,定义用户 Response DTO。代码如下: @Datapublic class AdminUserRespDTO { /** * 用户ID */ private Long id; /** * 用户昵称 */ private String nickname; /** * 帐号状态 * * 枚举 {@link CommonStatusEnum} */ private Integer status; /** * 部门ID */ private Long deptId; /** * 岗位编号数组 */ private Set<Long> postIds; /** * 手机号码 */ private String mobile;} # 2.1.3 AdminUserRpcImpl 在 yudao-module-system-biz 模块,创建 AdminUserRpcImpl ( opens new window) 类,实现 API 接口。代码如下: @RestController // 提供 RESTful API 接口,给 Feign 调用@Validatedpublic class AdminUserApiImpl implements AdminUserApi { @Resource private AdminUserService userService; @Override public CommonResult<AdminUserRespDTO> getUser(Long id) { AdminUserDO user = userService.getUser(id); return success(UserConvert.INSTANCE.convert4(user)); }} # 2.2 服务消费者 bpm-server 服务,调用了 AdminUserApi 接口。 # 2.2.1 引入依赖 在 yudao-module-bpm-biz 模块的 pom.xml ( opens new window),引入 yudao-module-system-api 模块的依赖。代码如下: <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> # 2.2.2 引用 API 在 yudao-module-bpm-biz 模块,创建 RpcConfiguration ( opens new window) 配置类,注入 AdminUserApi 接口。代码如下: @Configuration(proxyBeanMethods = false)@EnableFeignClients(clients = {AdminUserApi.class.class})public class RpcConfiguration {} # 2.2.3 调用 API 例如说, BpmTaskServiceImpl ( opens new window) 调用了 AdminUserApi 接口,代码如下: @Servicepublic class BpmTaskServiceImpl implements BpmTaskService { @Resource private AdminUserApi adminUserApi; @Override public void updateTaskExtAssign(Task task) { // ... 省略非关键代码 AdminUserRespDTO startUser = adminUserApi.getUser(id).getCheckedData(); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:33 定时任务 XXL Job 服务网关 Spring Cloud Gateway ← 定时任务 XXL Job 服务网关 Spring Cloud Gateway→"},{"title":"定时任务 XXL Job","path":"/wiki/YuDaoCloud/微服务手册/定时任务 XXL Job/定时任务 XXL Job.html","content":"开发指南微服务手册 芋道源码 2022-04-03 目录 定时任务 XXL Job 定时任务的使用场景主要如下: 时间驱动处理场景:每分钟扫描超时支付的订单,活动状态刷新,整点发送优惠券。 批量处理数据:按月批量统计报表数据,批量更新短信状态,实时性要求不高。 年度最佳定时任务:每个月初的工资单的推送!!! 项目基于 XXL Job 实现分布式定时任务,支持动态控制任务的添加、修改、开启、暂停、删除、执行一次等操作。 # 1. 如何搭建 XXL Job 调度中心 ① 参见 《芋道 XXL-Job 极简入门》 (opens new window) 文档的「4. 搭建调度中心 」部分。 ② 搭建完成后,需要修改管理后台的 [基础设施 -> 定时任务] 菜单,指向你的 XXL-Job 地址。如下图所示: # 2. 如何编写 XXL Job 定时任务 友情提示:以 yudao-module-system 服务为例子。 # 2.1 引入依赖 在 yudao-module-system-biz 模块的 pom.xml (opens new window) 中,引入 yudao-spring-boot-starter-job 技术组件。如下所示: <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-job</artifactId></dependency> 该组件基于 XXL Job 框架的封装,实现它的 Spring Boot Starter 配置。 # 2.2 添加配置 ① 在 application.yaml (opens new window) 中,添加 xxl.job 配置。如下所示: --- #################### 定时任务相关配置 ####################xxl: job: executor: appname: ${spring.application.name} # 执行器 AppName logpath: ${user.home}/logs/xxl-job/${spring.application.name} # 执行器运行日志文件存储磁盘路径 accessToken: default_token # 执行器通讯TOKEN 注意,xxl.job.accessToken 配置,需要改成你的 XXL Job 调度中心的访问令牌。 ② 在 application-local.yaml (opens new window) 中,添加 xxl.job 配置。如下所示: --- #################### 定时任务相关配置 ####################xxl: job: admin: addresses: http://127.0.0.1:9090/xxl-job-admin # 调度中心部署跟地址 # 2.3 创建 Job 定时任务 参见 《芋道 Spring Boot 定时任务入门》 ( opens new window) 文章的「5. 快速入门 XXL-JOB」部分。 常用的 Cron 表达式如下: 0 0 10,14,16 * * ? 每天上午 10 点,下午 2 点、4 点 0 0/30 9-17 * * ? 朝九晚五工作时间内,每半小时 0 0 12 ? * WED 表示每个星期三中午 12 点 0 0 12 * * ? 每天中午 12 点触发 0 15 10 ? * * 每天上午 10:15 触发 0 15 10 * * ? 每天上午 10:15 触发 0 15 10 * * ? * 每天上午 10:15 触发 0 15 10 * * ? 2005 2005 年的每天上午 10:15 触发 0 * 14 * * ? 在每天下午 2 点到下午 2:59 期间,每 1 分钟触发 0 0/5 14 * * ? 在每天下午 2 点到下午 2:55 期间,每 5 分钟触发 0 0/5 14,18 * * ? 在每天下午 2 点到 2:55 期间和下午 6 点到 6:55 期间,每 5 分钟触发 0 0-5 14 * * ? 在每天下午 2 点到下午 2:05 期间,每 1 分钟触发 0 10,44 14 ? 3 WED 每年三月的星期三的下午 2:10 和 2:44 触发 0 15 10 ? * MON-FRI 周一至周五的上午 10:15 触发 0 15 10 15 * ? 每月15日上午 10:15 触发 0 15 10 L * ? 每月最后一日的上午 10:15 触发 0 15 10 ? * 6L 每月的最后一个星期五上午 10:15 触发 0 15 10 ? * 6L 2002-2005 2002 年至 2005 年,每月的最后一个星期五上午 10:15 触发 0 15 10 ? * 6#3 每月的第三个星期五上午 10:15 触发 疑问:为什么 Job 查询数据库时,报多租户的错误? 需要声明 @TenantJob (opens new window) 注解在 Job 类上,实现并行遍历每个租户,执行定时任务的逻辑。 更多多租户的内容,可见 《开发指南 —— SaaS 多租户》 文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 17:01:04 消息队列 RocketMQ 服务调用 Feign ← 消息队列 RocketMQ 服务调用 Feign→"},{"title":"注册中心 Nacos","path":"/wiki/YuDaoCloud/微服务手册/注册中心 Nacos/注册中心 Nacos.html","content":"开发指南微服务手册 芋道源码 2022-12-31 目录 注册中心 Nacos 项目使用 Nacos 作为配置中心,实现服务的注册发现。 # 1. 搭建 Nacos Server ① 参考《芋道 Nacos 极简入门》 (opens new window)文章的「2. 单机部署(最简模式)」或「3. 单机部署(基于 MySQL 数据库)」小节。 ② 点击 Nacos 控制台的 [命名空间] 菜单,创建一个 ID 和名字都为 dev 的命名空间,稍后会使用到。如下图所示: # 2. 项目接入 Nacos 友情提示:以 yudao-module-system 服务为例子。 # 2.1 引入依赖 在 yudao-module-system-biz 模块的 pom.xml (opens new window) 中,引入 Nacos 对应的依赖。如下所示: <!-- Spring Cloud 基础 --><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId></dependency><!-- Registry 注册中心相关 --><dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency> # 2.2 添加配置 在 bootstrap-local.yaml ( opens new window) 中,添加 nacos.config 配置。如下所示: --- #################### 注册中心相关配置 ####################spring: cloud: nacos: server-addr: 127.0.0.1:8848 discovery: namespace: dev # 命名空间。这里使用 dev 开发环境 metadata: version: 1.0.0 # 服务实例的版本号,可用于灰度发布 spring.cloud.nacos.discovery.namespace 配置项:设置为 dev,就是刚创建的命名空间 # 2.3 启动项目 运行 SystemServerApplication 类,将 system-server 服务启动。 然后,在 Nacos 控制台的 [服务管理 -> 服务列表] 菜单,就可以看到该服务实例。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 11:00:42 地区 & IP 库 配置中心 Nacos ← 地区 & IP 库 配置中心 Nacos→"},{"title":"配置中心 Nacos","path":"/wiki/YuDaoCloud/微服务手册/配置中心 Nacos/配置中心 Nacos.html","content":"开发指南微服务手册 芋道源码 2022-04-04 目录 配置中心 Nacos # 1. 配置中心 Nacos 项目使用 Nacos 作为配置中心,实现配置的动态管理。 # 1.1 搭建 Nacos Server ① 参考《芋道 Nacos 极简入门》 (opens new window)文章的「2. 单机部署(最简模式)」或「3. 单机部署(基于 MySQL 数据库)」小节。 ② 点击 Nacos 控制台的 [命名空间] 菜单,创建一个 ID 和名字都为 dev 的命名空间,稍后会使用到。如下图所示: # 1.2 项目接入 Nacos 友情提示:以 yudao-module-system 服务为例子。 # 1.2.1 引入依赖 在 yudao-module-system-biz 模块的 pom.xml (opens new window) 中,引入 Nacos 对应的依赖。如下所示: <!-- Spring Cloud 基础 --><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId></dependency><!-- Config 配置中心相关 --><dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency> # 1.2.2 添加配置 在 bootstrap-local.yaml ( opens new window) 中,添加 nacos.config 配置。如下所示: --- #################### 配置中心相关配置 ####################spring: cloud: nacos: # Nacos Config 配置项,对应 NacosConfigProperties 配置属性类 config: server-addr: 127.0.0.1:8848 # Nacos 服务器地址 namespace: dev # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP name: # 使用的 Nacos 配置集的 dataId,默认为 spring.application.name file-extension: yaml # 使用的 Nacos 配置集的 dataId 的文件拓展名,同时也是 Nacos 配置集的配置格式,默认为 properties spring.cloud.nacos.config.namespace 配置项:设置为 dev,就是刚创建的命名空间 # 1.2.3 配置管理 ① 参考《芋道 Spring Cloud Alibaba 配置中心 Nacos 入门 》 (opens new window)文档,学习 Nacos 配置中心的使用。 ② 按照需要,将不同环境存在差异的 application-local.yaml (opens new window) 和 application-dev.yaml (opens new window) 中的配置,迁移到 Nacos 配置中心。 一般情况下,不建议将 application.yaml 中的配置,迁移到 Nacos 配置中心。因为 application.yaml 中的配置,是通用的配置,无需动态管理。 疑问:为什么项目中的 `application-{env}.yaml` 中的配置,没有放到 Nacos 配置中心中? 主要考虑大家 《快速启动》 可以更简单。 实际项目中,是建议放到 Nacos 配置中心,进行配置的动态管理的。 操作过程中,可能会碰到的问题: IdTypeEnvironmentPostProcessor 与 Nacos 配置中心加载顺序问题 (opens new window) # 2. 配置管理 友情提示:该功能是从 Boot 项目延用到 Cloud 项目,一般情况下不会使用到,使用 Nacos 管理配置即可。 在 [基础设施 -> 配置管理] 菜单,可以查看和管理配置,适合业务上需要动态的管理某个配置。 例如说:创建用户时,需要配置用户的默认密码,这个密码是不会变的,但是有时候需要修改这个默认密码,这个时候就可以通过配置管理来修改。 对应的后端代码是 yudao-module-infra 的 config (opens new window) 业务模块。 # 2.1 配置的表结构 infra_config 的表结构如下: CREATE TABLE `infra_config` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '参数主键', `group` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '参数分组', `type` tinyint NOT NULL COMMENT '参数类型', `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数名称', `key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数键名', `value` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数键值', `sensitive` bit(1) NOT NULL COMMENT '是否敏感', `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='参数配置表'; key 字段,对应到 Spring Boot 配置文件的配置项,例如说 yudao.captcha.enable、sys.user.init-password 等等。 # 2.2 后端案例 TODO 芋艿:待补充 # 2.3 前端案例 后端提供了 /admin-api/infra/config/get-value-by-key (opens new window) RESTful API 接口,返回指定配置项的值。前端的使用示例如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:10 注册中心 Nacos 消息队列 RocketMQ ← 注册中心 Nacos 消息队列 RocketMQ→"},{"title":"消息队列 RocketMQ","path":"/wiki/YuDaoCloud/微服务手册/消息队列 RocketMQ/消息队列 RocketMQ.html","content":"开发指南微服务手册 芋道源码 2022-04-03 目录 消息队列 RocketMQ yudao-spring-boot-starter-mq (opens new window) 技术组件,基于 RocketMQ 实现分布式消息队列,支持集群消费、广播消费。 友情提示:我对消息队列不了解,怎么办? ① 项目主要使用 RocketMQ 作为消息队列,所以可以学习下文章: 《芋道 Spring Cloud Alibaba 消息队列 RocketMQ 入门》 (opens new window) 《芋道 Spring Cloud Alibaba 事件总线 Bus RocketMQ 入门》 (opens new window) ② 如果你想替换使用 Kafka 或者 RabbitMQ,可以参考下文章: 《芋道 Spring Cloud 消息队列 Kafka 入门 》 (opens new window) 《芋道 Spring Cloud 事件总线 Bus Kafka 入门》 (opens new window) 《芋道 Spring Cloud 消息队列 RabbitMQ 入门 》 (opens new window) 《芋道 Spring Cloud 事件总线 Bus RabbitMQ 入门》 (opens new window) # 1. 集群消费 集群消费,是指消息发送到 RocketMQ 时,有且只会被一个消费者(应用 JVM 实例)收到,然后消费成功。如下图所示: # 1.1 使用场景 集群消费在项目中的使用场景,主要是提供可靠的、可堆积的异步任务的能力。例如说: 短信模块,使用它异步 (opens new window)发送短信。 邮件模块,使用它异步 (opens new window)发送邮件。 相比 《开发指南 —— 异步任务》 来说,Spring Async 在 JVM 实例重启时,会导致未执行完的任务丢失。而集群消费,因为消息是存储在 RocketMQ 中,所以不会存在该问题。 # 1.2 实战案例 以短信模块异步发送短息为例子,讲解集群消费的使用。 # 1.3.1 引入依赖 在 yudao-module-system-biz 模块的 pom.xml (opens new window) 中,引入 yudao-spring-boot-starter-mq 技术组件。如下所示: <!-- 消息队列相关 --><dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-mq</artifactId></dependency> # 1.3.2 添加配置 ① 在 application.yaml ( opens new window) 中,添加 spring.cloud.stream 配置。如下所示: --- #################### MQ 消息队列相关配置 ####################spring: cloud: # Spring Cloud Stream 配置项,对应 BindingServiceProperties 类 stream: function: definition: smsSendConsumer; # Binding 配置项,对应 BindingProperties Map bindings: smsSend-out-0: destination: system_sms_send smsSendConsumer-in-0: destination: system_sms_send group: system_sms_send_consumer_group # Spring Cloud Stream RocketMQ 配置项 rocketmq: default: # 默认 bindings 全局配置 producer: # RocketMQ Producer 配置项,对应 RocketMQProducerProperties 类 group: system_producer_group # 生产者分组 send-type: SYNC # 发送模式,SYNC 同步 注意,带有 sms 关键字的,都是和短信发送相关的配置。 ② 在 application-local.yaml (opens new window) 中,添加 spring.cloud.stream 配置。如下所示: --- #################### MQ 消息队列相关配置 ####################spring: cloud: stream: rocketmq: # RocketMQ Binder 配置项,对应 RocketMQBinderConfigurationProperties 类 binder: name-server: 127.0.0.1:9876 # RocketMQ Namesrv 地址 # 1.3.3 SmsSendMessage 在 yudao-module-system-biz 的 mq/message/sms ( opens new window) 包下,创建 SmsSendMessage ( opens new window) 类,短信发送消息。代码如下: @Datapublic class SmsSendMessage { /** * 短信日志编号 */ @NotNull(message = "短信日志编号不能为空") private Long logId; /** * 手机号 */ @NotNull(message = "手机号不能为空") private String mobile; /** * 短信渠道编号 */ @NotNull(message = "短信渠道编号不能为空") private Long channelId; /** * 短信 API 的模板编号 */ @NotNull(message = "短信 API 的模板编号不能为空") private String apiTemplateId; /** * 短信模板参数 */ private List<KeyValue<String, Object>> templateParams;} # 1.3.4 SmsProducer ① 在 yudao-module-system-biz 的 mq/producer/sms ( opens new window) 包下,创建 SmsProducer ( opens new window) 类,SmsSendMessage 的 Producer 生产者,核心是使用 StreamBridge 发送 SmsSendMessage 消息。代码如下图: @Componentpublic class SmsProducer { @Resource private StreamBridge streamBridge; /** * 发送 {@link SmsSendMessage} 消息 * * @param logId 短信日志编号 * @param mobile 手机号 * @param channelId 渠道编号 * @param apiTemplateId 短信模板编号 * @param templateParams 短信模板参数 */ public void sendSmsSendMessage(Long logId, String mobile, Long channelId, String apiTemplateId, List<KeyValue<String, Object>> templateParams) { SmsSendMessage message = new SmsSendMessage().setLogId(logId).setMobile(mobile); message.setChannelId(channelId).setApiTemplateId(apiTemplateId).setTemplateParams(templateParams); streamBridge.send("smsSend-out-0", message); }} 注意,这里的 smsSend-out-0 和上述的配置文件是对应的噢。 ② 发送短信时,需要使用 SmsProducer 发送消息。如下图所示: # 1.3.4 SmsSendConsumer 在 yudao-module-system-biz 的 mq/consumer/sms (opens new window) 包下,创建 SmsSendConsumer (opens new window) 类,SmsSendMessage 的 Consumer 消费者。代码如下图: @Component@Slf4jpublic class SmsSendConsumer implements Consumer<SmsSendMessage> { @Resource private SmsSendService smsSendService; @Override public void accept(SmsSendMessage message) { log.info("[accept][消息内容({})]", message); smsSendService.doSendSms(message); }} # 2. 广播消费 广播消费,是指消息发送到 RocketMQ 时,所有消费者(应用 JVM 实例)收到,然后消费成功。如下图所示: # 2.1 使用场景 例如说,在应用中,缓存了数据字典等配置表在内存中,可以通过 RocketMQ 广播消费,实现每个应用节点都消费消息,刷新本地内存的缓存。 又例如说,我们基于 WebSocket 实现了 IM 聊天,在我们给用户主动发送消息时,因为我们不知道用户连接的是哪个提供 WebSocket 的应用,所以可以通过 RocketMQ 广播消费。每个应用判断当前用户是否是和自己提供的 WebSocket 服务连接,如果是,则推送消息给用户。 # 2.2 使用方式一:Bus 基于 RocketMQ 的广播消费,可以使用 Spring Cloud Bus 实现。 Spring Cloud Bus 是什么? Spring Cloud Bus 是 Spring Cloud 的一个子项目,它的作用是将分布式系统的节点与轻量级消息系统链接起来,用于广播状态变化,事件推送等。 它的实现原理是,通过 Spring Cloud Stream 将消息发送到消息代理(如 RabbitMQ、Kafka、RocketMQ),然后通过 Spring Cloud Bus 的事件监听,监听到消息后,进行处理。 以角色的本地缓存刷新为例子,讲解下 Spring Cloud Bus 如何使用 RocketMQ 广播消费。 # 2.2.1 引入依赖 在 yudao-module-system-biz 模块的 pom.xml ( opens new window) 中,引入 yudao-spring-boot-starter-mq 技术组件。如下所示: <!-- 消息队列相关 --><dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-mq</artifactId></dependency> # 2.2.2 添加配置 在 application.yaml ( opens new window) 中,添加 spring.cloud.bus 配置。如下所示: spring: cloud: # Spring Cloud Bus 配置项,对应 BusProperties 类 bus: enabled: true # 是否开启,默认为 true id: ${spring.application.name}:${server.port} # 编号,Spring Cloud Alibaba 建议使用“应用:端口”的格式 destination: springCloudBus # 目标消息队列,默认为 springCloudBus # 2.2.3 编写代码 参见 《开发指南 —— 本地缓存》 文章的「3. 实时刷新缓存」小节。 # 2.2 使用方式二:Stream 基于 RocketMQ 的广播消费,也可以使用 Spring Cloud Stream 实现。 Spring Cloud Stream 是什么? Spring Cloud Stream 是 Spring Cloud 的一个子项目,它的作用是为微服务应用构建消息驱动能力。 使用方式,和「1.2 实战案例」小节是一样的,只是需要在 application.yaml 配置文件中,添加 spring.cloud.stream.rocketmq.bindings.<channelName>.consumer.broadcasting ( opens new window) 配置项为 true。 由于项目中暂时使用该方式,文档后续补充。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 12:11:24 配置中心 Nacos 定时任务 XXL Job ← 配置中心 Nacos 定时任务 XXL Job→"},{"title":"【v1.0.0】2021.05.03","path":"/wiki/YuDaoCloud/更新日志/【v1.0.0】2021.05.03/【v1.0.0】2021.05.03.html","content":"开发指南更新日志 芋道源码 2022-03-07 目录 【v1.0.0】2021.05.03 # 初始版本 第一个版本,基于 RuoYi-Vue (opens new window) 重构,主要是三个方面: 代码的重构 技术选型的调整 后台功能的新增 因此,v1.0.0 的更新日志,分成这三方面来写。 # 代码的重构 调整整体代码结构,将多个 Maven Module 合并为单个,使用 Java package 进行拆分隔离,如 图 (opens new window) 所示。原因是:随着业务逻辑的逐步复杂,多个 Maven Module 的依赖关系的管理,会是一个很大的问题。 拆分 framework (opens new window) 为多个 Maven Module,按照 Web (opens new window)、Security (opens new window)、MyBatis (opens new window)、Redis (opens new window) 等不同组件,进行封装与拓展。 基于 JUnit5 (opens new window) 与 Mockito (opens new window),实现单元测试,保证功能的正确性,与代码的可维护性。一直自动化,一直爽! 增加 SpringBoot 多环境的配置文件,提供完善的 deploy.sh (opens new window) 部署脚本,以及 Jenkins 部署教程 (opens new window)。 优化 Spring Security (opens new window) 实现权限的代码,提升可读性和维护性。 增加本地缓存(菜单、角色、数据字典等等),提升性能。通过 Redis 订阅发布,实现缓存的实时刷新。 增加 VO (opens new window) 类,作为 API 接口的响应对象,避免数据库实体与前端的直接耦合。 优化 操作日志 (opens new window),支持读取 Swagger 作为日志的内容。 优化 定时任务 (opens new window),支持执行失败的重试,更完善的执行日志。 优化 codegen (opens new window) 代码生成器,在原先生成 Controller、Service、Mapper、数据库实体、Vue 代码的基础上,额外生成 VO、单元测试的代码。 调整文件改用 数据库 (opens new window) 存储,而不是文件系统。原因是,项目在部署多个服务节点时,文件需要做同步。未来,会增加阿里云、七牛云等存储云服务。 去除原有数据库的连表查询、递归查询,改为单表操作的方式,多次读取 + 内存拼接。 优化 Java 代码的格式,解决 IDEA 代码告警的问题。 # 后台功能的新增 增加 API 访问 (opens new window)与异常 (opens new window)日志,方便排查线上 API 的问题。 增加 全局错误码 (opens new window),统一业务异常的管理。管理后台会支持错误码的管理,支持提示文案的可配置化。 增加 短信模块 (opens new window),提供短信渠道、短息模板、短信日志的管理,对接阿里云、云片等主流短信平台。 增加 Redis Key (opens new window) 的管理,知道项目中使用到的 Redis Key 的格式、数据类型、过期时间、描述等等信息。 # 技术选型的调整 将 Spring Boot 版本,从 2.1.3 升级到 2.4.5 最新。 增加 bom (opens new window) 文件,统一 Maven 的依赖管理。 引入 MyBatis Plus (opens new window) 组件,简化 MyBatis 使用,提升开发效率。 引入 Redisson (opens new window) 组件,作为 Redis 的客户端,提供更强大的 Redis 操作。 基于 Redis 实现分布式消息队列的功能。接入 Redis Pub/Sub (opens new window) 实现广播消费,接入 Redis Stream (opens new window) 实现集群消费。 去除 fastjson (opens new window),统一使用 Jackson (opens new window) 作为 JSON 库,老爆安全漏洞的悲伤。 引入 MapStruct (opens new window) 组件,实现数据库实体与 VO 类之间的转换。 引入 Lombok (opens new window) 组件,生成 setter、getter 等常用方法,去除冗余代码。 引入 Spring Async (opens new window) 功能,实现异步任务。例如说,异步记录 API 访问日志、管理员操作日志等等。 魔改 Apollo (opens new window) 组件,接入本地数据库,实现内嵌的配置中心。通俗的说,我们可以将原本添加到 application.yaml 的配置项,改为添加到数据库中,项目启动会进行读取。 引入 Hutool (opens new window) 组件,去除大量重复的工具类,也避免原本 Util 存在一些 bug 的问题。 引入 Screw (opens new window) 组件,实现数据库文档的生成,虽然好像现在用途较少。 引入 EasyExcel (opens new window),提供 Excel 的导入与导出的功能。 实现 Idempotent (opens new window) 组件,实现幂等的功能,可以用来解决 HTTP 重复请求的问题。 引入 Lock4J (opens new window),实现声明式的分布式锁的功能。虽然 Redisson 内置了分布式锁的功能,但是通过注解声明一个 @Lock4j 注解的使用方式,更加便利,且满足绝大多数场景。 去除原有的服务监控,使用 SpringBoot Admin (opens new window) 替代,提供更完整的监控能力。 引入 SkyWalking (opens new window) 组件,实现链路追踪和日志服务的功能。通过链路追踪,我们可以看到一个 API 请求涉及到的 MySQL、Redis 等操作;通过日志服务,我们可以方便的看到每个服务实例的日志。 引入 Resilience4j (opens new window) 组件,实现限流、熔断等功能,保证服务的稳定性。 引入 Knife4j (opens new window),美化接口文档。原本所有 API 接口文档是缺失的,已经全部补全,可见 http://api-dashboard.yudao.iocoder.cn/doc.html (opens new window) 地址。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.1.0】2021.10.25 ← 【v1.1.0】2021.10.25"},{"title":"工作流(Flowable)会签、或签","path":"/wiki/YuDaoCloud/工作流手册/工作流(Flowable)会签、或签/工作流(Flowable)会签、或签.html","content":"开发指南工作流手册 芋道源码 2022-03-07 目录 工作流(Flowable)会签、或签 项目基于 Flowable 实现了工作流的功能。本章节,我们将介绍工作流的相关功能。 以请假流程为例,讲解系统支持的两种表单方式的工作流: 流程表单:在线配置动态表单,无需建表与开发 业务表单:业务需建立独立的数据库表,并开发对应的表单、详情界面 整个过程包括: 定义流程:【管理员】新建流程、设计流程模型、并设置用户任务的审批人,最终发布流程 发起流程:【员工】选择流程,并发起流程实例 审批流程:【审批人】接收到流程任务,审批结果为通过或不通过 微信扫描下方二维码,加入后可观看视频! 01、如何集成 Flowable 框架? (opens new window) 02、如何实现动态的流程表单? (opens new window) 03、如何实现流程表单的保存? (opens new window) 04、如何实现流程表单的展示? (opens new window) 05、如何实现流程模型的新建? (opens new window) 06、如何实现流程模型的流程图的设计? (opens new window) 07、如何实现流程模型的流程图的预览? (opens new window) 08、如何实现流程模型的分配规则? (opens new window) 09、如何实现流程模型的发布? (opens new window) 10、如何实现流程定义的查询? (opens new window) 11、如何实现流程的发起? (opens new window) 12、如何实现我的流程列表? (opens new window) 13、如何实现流程的取消? (opens new window) 14、如何实现流程的任务分配? (opens new window) 15、如何实现会签、或签任务? (opens new window) 16、如何实现我的待办任务列表? (opens new window) 17、如何实现我的已办任务列表? (opens new window) 18、如何实现任务的审批通过? (opens new window) 19、如何实现任务的审批不通过? (opens new window) 20、如何实现流程的审批记录? (opens new window) 21、如何实现流程的流程图的高亮? (opens new window) 22、如何实现工作流的短信通知? (opens new window) 23、如何实现 OA 请假的发起? (opens new window) 24、如何实现 OA 请假的审批? (opens new window) 友情提示:虽然是基于 Boot 项目录制,但是 Cloud 一样可以学习。 # 0. 如何开启 bpm 模块? yudao-module-bpm 模块是工作流服务。启动步骤如下: ① 第一步,运行 BpmServerApplication 类,启动工作流服务。 ② 第二步,查看数据库。启动过程中,Flowable 会自动创建 ACT_ 和 FLW_ 开头的表。 如果启动中报 MySQL “Specified key was too long; max key length is 1000 bytes” (opens new window) 错误,可以将 MySQL 的缺省存储引擎设置为 innodb,即 default-storage-engine=innodb 配置项。 # 1. 请假流程【流程表单】 # 1.1 第一步:定义流程 登录账号 admin、密码 admin123 的用户,扮演【管理员】的角色,进行流程的定义。 ① 访问 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [新建流程] 按钮,填写流程标识、流程名称。如下图所示: 流程标识:对应 BPMN 流程文件 XML 的 id 属性,不能重复,新建后不可修改。 流程名称:对应 BPMN 流程文件 XML 的 name 属性。 <!-- 这是一个 BPMN XML 的示例,主要看 id 和 name 属性 --><?xml version="1.0" encoding="UTF-8"?><bpmn2:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn2="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" id="diagram_Process_1647305370393" targetNamespace="http://activiti.org/bpmn"> <bpmn2:process id="common-form" name="通用表单流程" isExecutable="true" /> <bpmndi:BPMNDiagram id="BPMNDiagram_1"> <bpmndi:BPMNPlane id="common-form_di" bpmnElement="common-form" /> </bpmndi:BPMNDiagram></bpmn2:definitions> ② 访问 [工作流程 -> 流程管理 -> 流程表单] 菜单,点击 [新增] 按钮,新增一个名字为 leave-form 的表单。如下图所示: 流程表单的实现? 基于 https://github.com/JakHuang/form-generator (opens new window) 项目实现的动态表单。 回到 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [修改流程] 按钮,配置表单类型为流程表单,选择名字为 leave-form 的流程表单。如下图所示: ③ 点击 [设计流程] 按钮,在线设计请假流程模型,包含两个用户任务:领导审批、HR 审批。如下图所示: 设计流程的实现? 基于 https://github.com/miyuesc/bpmn-process-designer (opens new window) 项目实现,它的底层是 bpmn-js (opens new window)。 ④ 点击 [分配规则] 按钮,设置用户任务的审批人。其中,规则类型用于分配用户任务的审批人,目前有 7 种规则:角色、部门成员、部门负责人、岗位、用户、用户组、自定义脚本,基本可以满足绝大多数场景,是不是非常良心。 设置【领导审批】的规则类型为自定义脚本 + 流程发起人的一级领导,如下图所示: 设置【HR 审批】的规则类型为岗位 + 人力资源,如下图所示: 规则类型的实现? 可见 BpmUserTaskActivityBehavior (opens new window) 代码,目前暂时支持分配一个审批人。 ⑤ 点击 [发布流程] 按钮,把定义的流程模型部署出去。部署成功后,就可以发起该流程了。如下图所示: 修改流程后,需要重新发布流程吗? 需要,必须重新发布才能生效。每次流程发布后,会生成一个新的流程定义,版本号从 v1 开始递增。 发布成功后,会部署新版本的流程定义,旧版本的流程定义将被挂起。当然,已经发起的流程不会受到影响,还是走老的流程定义。 # 1.2 第二步:发起流程 登录账号 admin、密码 admin123 的用户,扮演【员工】的角色,进行流程的发起。 ① 访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,点击 [发起流程] 按钮,可以看到可以选择的流程定义的列表。 ② 选择名字为通用表单的流程定义,发起请假流程。填写请假表单信息如下: ③ 点击提交成功后,可在我的流程中,可看到该流程的状态、结果。 ④ 点击 [详情] 按钮,可以查看申请的表单信息、审批记录、流程跟踪图。 # 1.2 第三步:审批流程(领导审批) 登录账号 test、密码 test123 的用户,扮演【审批人】的角色,进行请假流程的【领导审批】任务。 ① 访问 [工作流程 -> 任务管理 -> 待办任务] 菜单,可以查询到需要审批的任务。 ② 点击 [审批] 按钮,填写审批建议,并点击 [通过] 按钮,这样任务的审批就完成了。 ③ 访问 [工作流程 -> 任务管理 -> 已办任务] 菜单,可以查询到已经审批的任务。 此时,使用【员工】的角色,访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,可以看到流程流转到了【HR 审批】任务。 # 1.3 第三步:审批流程(HR 审批) 登录账号 hrmgr、密码 hr123 的用户,扮演【审批人】的角色,进行请假流程的【HR 审批】任务。 ① 访问 [工作流程 -> 任务管理 -> 待办任务] 菜单,点击 [审批] 按钮,填写审批建议,并点击 [通过] 按钮。 此时,使用【员工】的角色,访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,可以看到流程处理结束,最终审批通过。 # 2. 请假流程【业务表单】 根据业务需要,业务通过建立独立的数据库表(业务表)记录申请信息,而流程引擎只负责推动流程的前进或者结束。两者需要进行双向的关联: 每一条业务表记录,通过它的流程实例的编号( process_instance_id )指向对应的流程实例 每一个流程实例,通过它的业务键( BUSINESS_KEY_ ) 指向对应的业务表记录。 以项目中提供的 OALeave (opens new window) 请假举例子,它的业务表 bpm_oa_leave 和流程引擎的流程实例的关系如下图: 也因为业务建立了独立的业务表,所以必须开发业务表对应的列表、表单、详情页面。不过,审核相关的功能是无需重新开发的,原因是业务表已经关联对应的流程实例,流程引擎审批流程实例即可。 下面,我们以项目中的 OALeave (opens new window) 为例子,详细讲解下业务表单的开发与使用的过程。 # 2.0 第零步:业务开发 ① 新建业务表 bpm_oa_leave,建表语句如下: CREATE TABLE `bpm_oa_leave` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '请假表单主键', `user_id` bigint NOT NULL COMMENT '申请人的用户编号', `type` tinyint NOT NULL COMMENT '请假类型', `reason` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请假原因', `start_time` datetime NOT NULL COMMENT '开始时间', `end_time` datetime NOT NULL COMMENT '结束时间', `day` tinyint NOT NULL COMMENT '请假天数', `result` tinyint NOT NULL COMMENT '请假结果', `process_instance_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '流程实例的编号', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA 请假申请表'; process_instance_id 字段,关联流程引擎的流程实例对应的 ACT_HI_PROCINST 表的 PROC_INST_ID_ 字段 result 字段,请假结果,需要通过 Listener 监听回调结果,稍后来看看 ② 实现业务表的【后端】业务逻辑,具体代码可以看看如下两个类: BpmOALeaveController (opens new window) BpmOALeaveServiceImpl (opens new window) 重点是看流程发起的逻辑,它定义了 /bpm/oa/leave/create 给业务的表单界面调用,UML 时序图如下: 具体的实现代码比较简单,如下图所示: PROCESS_KEY 静态变量:是业务对应的流程模型的编号,稍后会进行创建编号为 oa_leave 的流程模型。 BpmProcessInstanceApi (opens new window) 定义了 #createProcessInstance(...) 方法,用于创建流程实例,业务无需关心底层是 Activiti 还是 Flowable 引擎,甚至未来可能的 Camunda 引擎。 ③ 实现业务表的【前端】业务逻辑,具体代码可以看看如下三个页面: leave/create.vue (opens new window) leave/detail.vue (opens new window) leave/index.vue (opens new window) 另外,在 router/index.js (opens new window) 中定义 create.vue 和 detail.vue 的路由,配置如下: { path: '/bpm', component: Layout, hidden: true, redirect: 'noredirect', children: [{ path: 'oa/leave/create', component: (resolve) => require(['@/views/bpm/oa/leave/create'], resolve), name: '发起 OA 请假', meta: {title: '发起 OA 请假', icon: 'form', activeMenu: '/bpm/oa/leave'} }, { path: 'oa/leave/detail', component: (resolve) => require(['@/views/bpm/oa/leave/detail'], resolve), name: '查看 OA 请假', meta: {title: '查看 OA 请假', icon: 'view', activeMenu: '/bpm/oa/leave'} } ]} 为什么要做独立的 `create.vue` 和 `index.vue` 页面? 创建流程时,需要跳转到 create.vue 页面,填写业务表的信息,才能提交流程。 审批流程时,需要跳转到 detail.vue 页面,查看业务表的信息。 ④ 实现业务表的【后端】监听逻辑,具体可见 BpmOALeaveResultListener (opens new window) 监听器。它实现流程引擎定义的 BpmProcessInstanceResultEventListener (opens new window) 抽象类,在流程实例结束时,回调通知它最终的结果是通过还是不通过。代码如下图: 至此,我们了解了 OALeave 使用业务表单所涉及到的开发,下面我们来定义对应的流程、发起该流程、并审批该流程。 # 2.1 第一步:定义流程 登录账号 admin、密码 admin123 的用户,扮演【管理员】的角色,进行流程的定义。 ① 访问 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [新建流程] 按钮,填写流程标识、流程名称。如下图所示: 注意,流程标识需要填 oa_leave。因为在 BpmOALeaveServiceImpl 类中,规定了对应的流程标识为 oa_leave。 ② 点击 [修改流程] 按钮,配置表单类型为业务表单,填写表单提交路由为 /bpm/oa/leave/create(用于发起流程时,跳转的业务表单的路由)、表单查看路由为 /bpm/oa/leave/detail(用于在流程详情中,点击查看表单的路由)。如下图所示: ③ 点击 [设计流程] 按钮,在线设计请假流程模型,包含两个用户任务:领导审批、HR 审批。如下图所示: 可以点击 oa_leave_bpmn.XML 进行下载,然后点击 [打开文件] 按钮,进行导入。 ④ 点击 [分配规则] 按钮,设置用户任务的审批人。 设置【领导审批】的规则类型为自定义脚本 + 流程发起人的一级领导,如下图所示: 设置【HR 审批】的规则类型为岗位 + 人力资源,如下图所示: ⑤ 点击 [发布流程] 按钮,把定义的流程模型部署出去。部署成功后,就可以发起该流程了。 # 2.1 第二步:发起流程 登录账号 admin、密码 admin123 的用户,扮演【员工】的角色,进行流程的发起。 ① 发起业务表单请假流程,两种路径: 访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,点击 [发起流程] 按钮,会跳转到流程模型 oa_leave 配置的表单提交路由。 访问 [工作流程 -> 请假查询] 菜单,点击 [发起请假] 按钮。 ② 填写一个小于等于 3 天的请假,只会走【领导审批】任务;填写一个大于 3 天的请假,在走完【领导审批】任务后,会额外走【HR 审批】任务。 后续的流程,和「1. 请假流程【流程表单】」是基本一致的,这里就不重复赘述,当然你还是要试着跑一跑,了解整个的过程。 # 2.3 第三步:审批流程(领导审批) 略~自己跑 # 2.4 第三步:审批流程(HR 审批) 略~自己跑 # 2. 流程通知 流程在发生变化时,会发送通知给相关的人。目前有三个场景会有通知,通过短信的方式。 # 3. 流程图示例 # 3.1 会签 定义:指同一个审批节点设置多个人,如 ABC 三人,三人会同时收到审批,需全部同意之后,审批才可到下一审批节点。 配置方式如下图所示: 重点是【完成条件】为 ${ nrOfCompletedInstances== nrOfInstances }。 # 3.2 或签 定义:指同一个审批节点设置多个人,如ABC三人,三人会同时收到审批,只要其中任意一人审批即可到下一审批节点。 配置方式如下图所示: 重点是【完成条件】为 ${ nrOfCompletedInstances== 1 }。 # 4. 如何使用 Activiti? Activiti 和 Flowable 提供的 Java API 是基本一致的,例如说 Flowable 的 org.flowable.engine.RepositoryService 对应 Activiti 的 org.activiti.engine .RepositoryService。所以,我们可以修改 import 的包路径来替换。 另外,在项目的老版本,我们也提供了 Activiti 实现,你可以具体参考下: yudao-spring-boot-starter-activiti (opens new window) yudao-module-bpm-biz-activiti (opens new window) # 4. 迭代计划 工作流的基本功能已经开发完成,当然还是有很多功能需要进行建设。已经整理在 https://gitee.com/zhijiantianya/ruoyi-vue-pro/issues/I4UPEU (opens new window) 链接中,你也可以提一些功能的想法。 如果您有参与工作流开发的想法,可以添加我的微信 wangwenbin10 ! 艿艿会带着你做技术方案,Code Review 你的每一行代码的实现。相信在这个过程中,你会收获不错的技术成长! .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 服务保障 Sentinel 报表设计器 ← 服务保障 Sentinel 报表设计器→"},{"title":"【v1.1.0】2021.10.25","path":"/wiki/YuDaoCloud/更新日志/【v1.1.0】2021.10.25/【v1.1.0】2021.10.25.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.1.0】2021.10.25 # 增加管理后台的企业微信、钉钉等社交登录 新增管理后台的企业微信、钉钉等社交登录 新增用户前台(例如说,用户使用的小程序)的后端项目 yudao-user-server 新增公共服务 yudao-core-service 项目,通过 Jar 包的方式,提供 yudao-user-server 和 yudao-admin-server 的共享逻辑的复用 新增用户前台的手机登录、验证码登录 修复管理后台的用户头像上传 404 的问题,原因是请求路径不对 修复用户导入失败的问题,原因是 Lombok 链式与 cglib 读取属性有冲突 修复阿里云短信发送失败的问题,原因是 Opentracing 依赖的版本太低,调整成 0.31.0 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.2.0】2021.12.15 【v1.0.0】2021.05.03 ← 【v1.2.0】2021.12.15 【v1.0.0】2021.05.03→"},{"title":"【v1.3.0】2022.01.24","path":"/wiki/YuDaoCloud/更新日志/【v1.3.0】2022.01.24/【v1.3.0】2022.01.24.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.3.0】2022.01.24 # 新增工作流的功能 基于 Activiti 7.X 版本实现工作流功能,支持可配置的动态表单、自定义的业务表单。 下个版本会提供基于 Flowable 6.X 版本实现的工作流! # 📈 Statistic 总代码行数:61594 源码代码行数:37931 注释行数:14225 单元测试用例数:278 # ⭐ New Features 【优化】引入 form generator 0.2.0 版本,并重构相关代码 【修改】修改部门负责人,从 String 字符串,调整成和后台用户的用户编号绑定 【新增】流程表单,支持动态进行表单的配置 【新增】工作组,用于支持指定工作组进行任务的审批 【新增】流程模型的管理,支持新增、导入、编辑、删除、发布流程模型 【新增】我的流程的管理,支持发起流程 【新增】待办任务的管理,支持任务的审批通过与不通过 【新增】已办任务的管理,支持详情的查看 【新增】任务分配规则,可指定角色、部门成员、部门负责人、用户、用户组、自定义脚本等维度,进行任务的审批 【新增】引入 bpmn-process-designer 0.0.1 版本,提供流程设计器的能力 【优化】新增 LambdaQueryWrapperX 类,改成使用 Lambda 的方式选择字段,避免手写导致字段不正确 # 🐞 Bug Fixes 【修复】biz-data-permission 组件的缓存机制,导致部分 SQL 未进行数据过滤 【修复】codegen 生成代码时,delete 接口补充 dataTypeClass 属性,避免 Swagger 打印 WARN 日志 【修复】Swagger 文档由于写错 @ApiImplicitParam 注解的 name 和 dataTypeClass 属性,导致文档生成失败 # 🔨 Dependency Upgrades 【升级】redisson from 3.16.3 to 3.16.6,解决 Stream 在调试场景下会存在 NPE 的问题 【升级】spring-boot from 2.4.5 to 2.4.12,最新的 Spring Boot 2.6.X 在等更流行一些,稳定第一 【升级】druid from 1.2.4 to 1.2.8,提升数据库连接池的稳定性 【升级】dynamic-datasource from 3.3.2 to 3.5.0,修复动态数据源切换的问题 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.4.0】2022-02-04 【v1.2.0】2021.12.15 ← 【v1.4.0】2022-02-04 【v1.2.0】2021.12.15→"},{"title":"【v1.2.0】2021.12.15","path":"/wiki/YuDaoCloud/更新日志/【v1.2.0】2021.12.15/【v1.2.0】2021.12.15.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.2.0】2021.12.15 # 新增多租户、数据权限的功能 这个版本新增了多租户与数据权限两个重量级的功能,建议花点时间进行了解与学习。 # ⭐ New Features 【新增】多租户,支持 Web、Security、Job、MQ、Async、DB、Redis 组件 【新增】数据权限,内置基于部门过滤的规则 【新增】用户前台的昵称、头像的修改 【新增】用户前台的微信公众号、微信小程序的社交登录的 API 接口 完整功能,需要等基于 Uniapp 实现的用户前台一起~ 努力 coding 中,胖友可以 star 持续关注一波! 【优化】管理后台的登录成功后,LoginUser 使用统一方法补全信息 # 🐞 Bug Fixes 【修复】通知和字典查询接口的 @PreAuthorize 权限标识错误 【修复】代码生成的 Java 类路径缺少 modules 目录 【修复】代码生成的 Test 单元测试类的引入 Util 工具类的包路径不正确 # 🔨 Dependency Upgrades 【引入】mockito-inline 3.6.28:Mockito 提供对 final、static 的支持 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.3.0】2022.01.24 【v1.1.0】2021.10.25 ← 【v1.3.0】2022.01.24 【v1.1.0】2021.10.25→"},{"title":"【v1.4.0】2022-02-04","path":"/wiki/YuDaoCloud/更新日志/【v1.4.0】2022-02-04/【v1.4.0】2022-02-04.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.4.0】2022-02-04 # 重构成多 Maven Module 的代码结构 大版本重构,基于 Maven Module 的方式拆分多模块,希望大家多多提点建议! # 📈 Statistic 总代码行数:69118 源码代码行数:42571 注释行数:15847 单元测试用例数:278 # ⭐ New Features 【重构】大模块按照多 Maven Module 的方式拆分,提升可维护性,为后续重构 yudao-cloud 提供基础 【移除】将 yudao-core-service 模块移除,替换成每个 Maven Module 暴露对应的 yudao-module-***-api 模块 【新增】Spring Security 支持读取多种用户类型,从不同的数据库表,从而实现单项目提供管理后台、用户 APP 的不同 RESTful API 接口 【新增】Spring Security 新增 AuthorizeRequestsCustomizer 抽象类, 自定义每个 Maven Module 的 URL 的安全配置 【新增】代码生成器支持多 Maven Module 的方式生成代码,支持管理后台、用户 APP 两种场景的 RESTful API 的生成,支持 H2 SQL 脚本的生成 【新增】每次发布大版本时,将 yudao-ui-admin 编译后,放到 yudao-server 项目中,可以快速体验,无需搭建前端开发环境 【重构】将数据库文档调整到 tool 模块,更加明确 【优化】代码生成器的前端展示效果,例如说 Java 包路径合并 # 🐞 Bug Fixes 【修复】用户无权限访问 指定 API 时,未返回 FORBIDDEN 结果码 【修复】定时任务刷新本地缓存时,无租户上线文,导致查询报错 【修复】配置中心只加载了删除的配置 【修复】管理后台 UI 超时登录后,返回登录界面时,由于未登录加载不到信息,导致报错的问题 # 🔨 Dependency Upgrades 【升级】spring-boot from 2.4.12 to 2.5.9,最新的 Spring Boot 2.6.X 在等更流行一些,稳定第一 【升级】Spring Boot Admin from 2.3.2 to 2.6.2,提供更好的监控能力 【移除】Apache FreeMarker 依赖,修改 Screw 使用 Velocity 作为模板引擎 【升级】redisson from 3.16.6 to 3.16.8 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.5.0】2022-02-17 【v1.3.0】2022.01.24 ← 【v1.5.0】2022-02-17 【v1.3.0】2022.01.24→"},{"title":"【v1.5.0】2022-02-17","path":"/wiki/YuDaoCloud/更新日志/【v1.5.0】2022-02-17/【v1.5.0】2022-02-17.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.5.0】2022-02-17 # 重构成多 Maven Module 的代码结构 修复各种多 Maven Module 重构带来的 Bug,感谢大量群友的 PR 支持! 跟进 ruoyi-vue 3.4.0 ~ 3.8.1 版本,感谢这么优秀的开源项目! # 📈 Statistic 总代码行数:69299 源码代码行数:42687 注释行数:15888 单元测试用例数:278 # ⭐ New Features 【优化】使用 Lombok 简化 JsonUtils 工具类 #73 (opens new window) 【新增】兼容 Node 16 版本,通过升级 BPMN-JS 相关库 commit (opens new window) 【新增】前端的表格右侧工具栏组件支持显隐列,具体可见【用户管理】功能 commit (opens new window) 【新增】前端的菜单导航显示风格 TopNav(false 为 左侧导航菜单,true 为顶部导航菜单),支持布局的保存与重置 commit1 (opens new window) commit2 (opens new window) 【新增】前端的网页标题支持根据选择的菜单,动态展示标题 commit (opens new window) 【新增】字典标签样式回显,例如说开启的状态展示为 primary 蓝色,禁用的状态为 info 灰色 commit (opens new window) 【新增】前端的 iframe 组件,方便内嵌网页 commit (opens new window) 【新增】在基础设施-配置管理菜单,可通过修改 yudao.captcha.enable 配置项,动态修改登录是否需要验证码 commit (opens new window) 【新增】在代码生成的预览界面,支持一键复制代码 commit (opens new window) # 🐞 Bug Fixes 【修复】数据权限的 DEPT_AND_CHILD 范围时,未设置自己所在的部门 #72 (opens new window) 【修复】Knife4j 接口文档 404 的问题,原因是 spring.mvc.static-path-pattern 配置项,影响了基础路径 commit (opens new window) 【修复】修复文件访问地址错误 #68 (opens new window) 【修复】工作流程发起以及审批异常,由 @NotEmpty 校验、和 Long 类型异常导致 #73 (opens new window) 【修复】自定义 DefaultStreamMessageListenerContainerX 实现,解决 Redisson Stream 读取不到数据返回 null 导致 NPE 问题 commit (opens new window) 【修复】部门更新后,本地缓存不刷新的问题 #77 (opens new window) 【修复】获取拥有指定的角色用户时,返回错误的 id 编号 #79 (opens new window) # 🔨 Dependency Upgrades *【修复】Maven 构建的一些错误提示 #78 (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.5.1】2022-02-28 【v1.4.0】2022-02-04 ← 【v1.5.1】2022-02-28 【v1.4.0】2022-02-04→"},{"title":"【v1.5.1】2022-02-28","path":"/wiki/YuDaoCloud/更新日志/【v1.5.1】2022-02-28/【v1.5.1】2022-02-28.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.5.1】2022-02-28 # 优化多租户功能,新增租户套餐,增强多租户封装 创建租户时,自动创建用户、角色等信息 支持租户套餐,自定义每个租户的菜单、操作、按钮等权限信息 # 📈 Statistic 总代码行数:71249 源码代码行数:43921 注释行数:16341 单元测试用例数:341 # ⭐ New Features 【新增】后端 yudao.tenant.enable 配置项,前端 VUE_APP_TENANT_ENABLE 配置项,用于开关租户功能。 commit (opens new window) 【优化】调整默认所有表开启多租户的特性,可通过 yudao.tenant.ignore-tables 配置项进行忽略,替代原本默认不开启的策略 commit (opens new window) 【新增】通过 yudao.tenant.ignore-urls 配置忽略多租户的请求,例如说 ,例如说短信回调、支付回调等 Open API commit (opens new window) 【新增】新增 @TenantIgnore 注解,标记指定方法,忽略多租户的自动过滤,适合实现跨租户的逻辑 commit (opens new window) 【新增】租户套餐的管理,可配置每个租户的可使用的功能权限 commit (opens new window) 【优化】新建租户时,自动创建对应的管理员账号、角色等基础信息 commit (opens new window) 【优化】Redis 最低版本 5.0.0 检测,解决搭建环境过程中无法理解 XREADGROUP 指令的报错 commit (opens new window) # 🐞 Bug Fixes 【修复】修复不支持根部门的问题 commit (opens new window) 【修复】错误码存在重复的问题 commit (opens new window) 【修复】角色的数据范围为仅本人时,登录后获取权限列表报错的问题 commit (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.5.9 to 2.5.10 【升级】mybatis-plus from 3.4.3.4 to 3.5.1 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.0】2022-03-10 【v1.5.0】2022-02-17 ← 【v1.6.0】2022-03-10 【v1.5.0】2022-02-17→"},{"title":"【v1.6.0】2022-03-10","path":"/wiki/YuDaoCloud/更新日志/【v1.6.0】2022-03-10/【v1.6.0】2022-03-10.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.6.0】2022-03-10 # 支持 Flowable 工作流,发布开发文档 基于 Flowable 实现工作流,可见 yudao-module-bpm-impl-flowable (opens new window) 模块。 友情提示:原本 Activiti 实现的工作流,在 yudao-module-bpm-impl-activiti (opens new window) 模块,保持同步更新。 # 📈 Statistic 总代码行数:75008 源码代码行数:46416 注释行数:17132 单元测试用例数:341 # ⭐ New Features 【新增】 yudao-module-bpm-impl-flowable (opens new window) 模块,实现 Flowable 工作流 #88 (opens new window) 【新增】《开发文档》的简介、功能列表、快速启动、技术选型、项目结构、新建模块、SaaS 多租户等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 # 🐞 Bug Fixes 【修复】正常租户登录后退出,切换到过期租户时造成的 tenant.ignore-urls 配置失效的问题,比如无法获取验证码图片造成无法登录 #91 (opens new window) # 🔨 Dependency Upgrades 暂无,计划升级 Spring Boot 2.6.X .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.1】2022-03-21 【v1.5.1】2022-02-28 ← 【v1.6.1】2022-03-21 【v1.5.1】2022-02-28→"},{"title":"【v1.6.1】2022-03-21","path":"/wiki/YuDaoCloud/更新日志/【v1.6.1】2022-03-21/【v1.6.1】2022-03-21.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.6.1】2022-03-21 # 支持 OSS 云存储,优化代码生成 对应 版本 1.6.1 功能列表 (opens new window) # 📈 Statistic 总代码行数:77279 源码代码行数:47812 注释行数:17676 单元测试用例数:537 # ⭐ New Features 【优化】文件存储的功能,支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、SFTP、数据库等 #98 (opens new window) 【新增】《开发文档》的代码生成(新增功能)、功能权限、上传下载等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 【新增】开发环境下,管理后台每个菜单展示对应的《开发文档》的说明 code (opens new window) 【新增】《开发文档》的工作流、代码生成(新增功能)、功能权限、数据权限等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 【优化】将 yudao-module-tool 合并到 yudao-module-infra 模块,统一基础设施 #94 (opens new window) 【优化】代码生成时,额外生成 MyBatis Mapper XML 文件 #96 (opens new window) 【新增】开启 TopNav 时,没有子菜单的情况下,隐藏侧边栏 code (opens new window) # 🐞 Bug Fixes 【修复】仅本人数据权限时,个人中心会报错的问题 #97 (opens new window) 【修复】修改租户套餐的权限时,本地缓存刷新错误的问题 #99 (opens new window) 【修复】删除菜单、角色时,本地缓存未刷新的问题 code (opens new window) 【修复】登录界面输入不存在的租户时,导致后续请求报错的问题 code (opens new window) 【修复】登录超时刷新页面时,跳转登录页面还提示重新登录问题 code (opens new window) # 🔨 Dependency Upgrades 【升级】apollo-client from 1.7.0 to 1.9.2 【升级】guide from 4.1.0 to 5.1.0 :解决 Apollo 在 JDK 17 无法启动的问题 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.2】2022-06-05 【v1.6.0】2022-03-10 ← 【v1.6.2】2022-06-05 【v1.6.0】2022-03-10→"},{"title":"【v1.6.2】2022-06-05","path":"/wiki/YuDaoCloud/更新日志/【v1.6.2】2022-06-05/【v1.6.2】2022-06-05.html","content":"开发指南更新日志 芋道源码 2022-03-26 目录 【v1.6.2】2022-06-05 # 新增 OAuth 2.0、SSO 单点登录、多种数据库支持等功能 对应 版本 1.6.2 功能列表 (opens new window) # 📈 Statistic 总代码行数:84846 源码代码行数:52792 注释行数:19234 单元测试用例数:671 # ⭐ New Features 【新增】对 PostgreSQL 数据库的支持 #151 (opens new window) 感谢这个过程中怪物的帮助! 【新增】对 Oracle 数据库的支持 #152 (opens new window) 感谢这个过程中 安贞 (opens new window)、品霖的帮助! 【新增】对 SQL Server 数据库的支持 #153 (opens new window) 感谢这个过程中 Simon、蜉蝣无垠、牛希尧的帮助! 【新增】《开发指南 —— 后端手册》的接口文档、三方登录、异常处理(错误码)、参数校验、分页实现、系统日志、数据库 MyBatis、多数据源、缓存 Redis、本地缓存、定时任务、消息队列、配置中心、单元测试、分布式锁、幂等性、限流熔断、数据库文档、短信配置、开发环境... 【新增】《开发指南 —— 运维手册》的开发环境、Linux 部署、Docker 部署、Jenkins 部署、HTTPS 证书、服务监控... 【新增】《开发指南 —— 前端手册》的开发规范、菜单路由、Icon 图标、字典数据、系统组件、通用方法、配置读取... 【新增】手机验证码登录,美化登录界面,由 #155 (opens new window) 贡献 【新增】一键改包的程序,快速将项目的 Maven、包名等信息替换成你的 #110 (opens new window) 【新增】菜单新增是否缓存、是否隐藏的字段 #133 (opens new window) #172 (opens new window) 【新增】Spring Cache 声明式缓存,使用 Redis 存储 code (opens new window) 【新增】腾讯云短信,由 swpthebest (opens new window) 贡献 #118 (opens new window) 【新增】敏感词,由 dachuan 贡献 #121 (opens new window) 【新增】数据源配置,为多租户、代码生成支持动态数据源做准备 #138 (opens new window) 【新增】用户 Token 采用 OAuth2.0 的 Access Token + Refresh Token,提升安全性 #166 (opens new window) 【新增】基于 OAuth2.0 实现 SSO 单点登录 #176 (opens new window) 【新增】用户与岗位的关联表,由 anzhen-tech (opens new window) 贡献 #113 (opens new window) 【新增】MyBatis 字段的加解密功能 code (opens new window) 【新增】集成微信 Native、小程序的支付能力,支持 v2 和 v3 的回调数据处理 #142 (opens new window) 【优化】yudao-module-xx-impl 调整成 yudao-module-xx-biz,更加符合定位 code (opens new window) 【优化】简化三方登录的实现,降低理解成本 #137 (opens new window) 【优化】去除 yudao-module-system、yudao-module-infra 对 yudao-module-member 的依赖 #122 (opens new window) 【优化】yudao-framework-test 测试组件的封装,内置 Redis、DB 等多种快速测试的基类 code (opens new window) 【优化】配置指定默认的 npm 镜像源 #170 (opens new window) 【优化】字典管理、通知管理、岗位管理、角色管理、错误码管理的排序显示 #174 (opens new window) 【优化】前端 Token、账号、密码等信息,统一使用 LocalStorage 替代 Cookie 存储 code (opens new window) 【优化】上传文件的类型识别,增加基于 filename 的读取 code (opens new window) # 🐞 Bug Fixes 【修复】角色菜单集合复选框回显不正确 #107 (opens new window) 【修复】工作流 BPMN 图的 canvas 自适应,解决展示补全的问题 #104 (opens new window) 【修复】API 访问日志不记录的问题 code (opens new window) 【修复】修复忽略租户的 URL,未带租户会报错的问题 code (opens new window) 【修复】菜单无法使用外链的问题 code (opens new window) 【修复】代码生成器的 vue 模板中,导出 Excel 文件时,文件名未格式化的问题 #133 (opens new window) 【修复】代码生成时,对话框的日期选择器,在编辑情况下不能回显 #135 (opens new window) 【修复】在 Windows 下 ftp 上传和下载存在报错的问题 #156 (opens new window) 【修复】图片上传组件 ImageUpload 上传报错的问题 code (opens new window) 【修复】文件上传组件 FileUpload 上传报错的问题 code (opens new window) 【修复】form generator 组件上传文件、图片报错的问题 code (opens new window) 【修复】富文本编辑器的 Editor 的图片上传报错的问题 code (opens new window) 【修复】DO 生成模板,当主键是 String 类型,模板有误 #167 (opens new window) 【修复】创建用户不分配角色的情况会存在空指针 #171 (opens new window) 【修复】yudao-ui-admin 启动告警 #173 (opens new window) 【修复】新建的用户未分配角色时,操作自己信息回报错的问题 code (opens new window) 【修复】工作流的编辑无法撤回、crtl 选中的问题 code (opens new window) 【修复】支付宝通知回调 BUG 修复 #142 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.5.10 to 2.6.8 :修复 RCE 漏洞,并且 2.5.X 结束声明周期 【升级】redisson from 3.16.6 to 3.17.3 :提升 Redisson 客户端的稳定性 【升级】mysql-connector-java from 5.1.46 to 8.0.28 :提升 MySQL 客户端的性能 【升级】Knife4j from from 3.0.2 to 3.0.3 【升级】swagger-annotations from 1.5.22 to 1.6.6 【升级】spring-boot-admin from 2.6.2 to 2.6.7 【升级】fastjson from 1.2.73 to 2.0.5 【升级】resilience4j from 1.7.0 to 1.7.1 【升级】jackson from 2.12.6 to 2.13.3 【升级】spring-mvc from 5.3.16 to 5.3.20 【升级】spring-security from 5.5.5 to 5.6.5 【升级】hibernate-validator from 6.2.2 to 6.2.3 【升级】junit from 5.7.2 to 5.8.2 【升级】mockito from 3.9.0 to 4.0.0 【升级】mybatis-plus from 3.4.3.4 to 3.5.2 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.3】2022-07-29 【v1.6.1】2022-03-21 ← 【v1.6.3】2022-07-29 【v1.6.1】2022-03-21→"},{"title":"【v1.6.3】2022-07-29","path":"/wiki/YuDaoCloud/更新日志/【v1.6.3】2022-07-29/【v1.6.3】2022-07-29.html","content":"开发指南更新日志 芋道源码 2022-07-29 目录 【v1.6.3】2022-07-29 # 工作流支持会签或签、新增 Vue3 管理后台 # 📈 Statistic 总代码行数:81410 源码代码行数:50413 注释行数:30977 单元测试用例数:671 # ⭐ New Features 【新增】基于 Vue3 + ElementUI Plus 实现 yudao-ui-admin-vue3 (opens new window) 管理后台项目,已完成系统管理 + 基础设施等功能,工作流正在实现中,主要由 @xingyu4j (opens new window) 贡献 【新增】工作流支持会签、或签,可自定义任务分配方式 #212 (opens new window) 【新增】接口支持通过 @PermitAll 注解,允许匿名(未登录)进行访问 d9c2da7 (opens new window) 【新增】yudao.security.permit-all-urls 配置项,允许匿名(未登录)进行访问 d9c2da7 (opens new window) 【新增】Redis 缓存的查询与删除 由 @lwf_org (opens new window) 贡献 #211 (opens new window) 【优化】文件表增加 name 字段,记录上传的文件名,由 @manning233 (opens new window) 贡献 #186 (opens new window) 【优化】基于 Guava 实现 dict 字典数据的本地缓存 d320091 (opens new window) 【优化】基于 Guava 实现 tenant 租户数据的本地缓存 992e205 (opens new window) 【重构】新增 yudao-spring-boot-starter-biz-error-code 错误码组件,用于错误码的自动创建与加载 7a86a61 (opens new window) 【重构】新增 yudao-spring-boot-starter-banner 组件,用于项目启动时打印开发文档、接口文档等 69a3a83 (opens new window) 【新增】yudao.access-log.enable 访问日志的开关,默认在 local 环境关闭记录访问日志 9040b17 (opens new window) 【新增】yudao.error-code.enable 错误码的开关,默认在 local 环境关闭自动生成错误码 cca8375 (opens new window) 【新增】集成 Prometheus 监控点 4dfa816 (opens new window) 【移除】去除 Activiti 工作流的支持,专注提供基于 Flowable 提供更强大的工作流能力 【重构】时间区间的过滤条件,从开始和结束时间两个变量,修改为数组,由 @xingyu4j (opens new window) 贡献 dad10d8 (opens new window) # 🐞 Bug Fixes 【修复】流程审批不通过会报错的问题,由 @wzy_lc (opens new window) 贡献 #215 (opens new window) 【修复】Spring Boot Admin 的 prefer-ip 过期,由 @xingyu4j (opens new window) 贡献 63877cf (opens new window) 【修复】环境 test、stage、stage、prod 不打印日志的问题 8a6c48f (opens new window) 【修复】短信验证码的每日发送条数不正确 e5a7b84 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.6.8 to 2.6.10 【升级】hutool from 5.6.1 to 5.7.22 【升级】druid from 1.2.8 to 1.2.11 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.4】2022-08-22 【v1.6.2】2022-06-05 ← 【v1.6.4】2022-08-22 【v1.6.2】2022-06-05→"},{"title":"【v1.6.4】2022-08-22","path":"/wiki/YuDaoCloud/更新日志/【v1.6.4】2022-08-22/【v1.6.4】2022-08-22.html","content":"开发指南更新日志 芋道源码 2022-08-22 目录 【v1.6.4】2022-08-22 # 新增 uniapp 管理后台、报表设计器 # 📈 Statistic 总代码行数:87565 源码代码行数:54279 注释行数:19868 单元测试用例数:671 # ⭐ New Features 【新增】完善 Vue3 管理后台的工作流实现,由 @xingyu4j (opens new window) 贡献 #238 【新增】管理后台的移动端 yudao-ui-admin-uniapp 项目,采用 uni-app (opens new window) 方案,一份代码多终端适配,同时支持 APP、小程序、H5!#247 (opens new window) 【新增】集成积木报表,提供低代码报表设计器,由 @jiangqiang1996 (opens new window) 贡献 #237 (opens new window) 【新增】接入支付宝 PC 网站支付,由 @jiangqiang1996 (opens new window) 贡献 #240 (opens new window) 【优化】项目的启动速度,控制在 30 秒左右,默认不启动 bpm、visualization 模块 【优化】管理后台的弹窗支持滚动、拖拽,并点击背景布关闭,避免误操作,由 @颗粒 (opens new window) 贡献 #253 (opens new window) 【优化】一键改包,如果目标目录已存在,则不进行生成,由 @C (opens new window) 贡献 #229 (opens new window) # 🐞 Bug Fixes 【修复】Redis 7.0 监控查询 calls 数值超过 Integer 范围的异常,由 @lanyue52011 (opens new window) 贡献 #239 (opens new window) 【修复】前端表单设计器中动态数据,不能正常获取和更深层级的赋值错误的情况,由 @CorrectRoadH (opens new window) 贡献 #256 (opens new window) 【修复】代码生成功能中,点击同步,会清除已添加并存在的字段,由 @xrcoder (opens new window) 贡献 #249 (opens new window) 【修复】工作流与积木报表的依赖冲突,将 xercesImpl 升级到 2.12.0 版本,由 @shihy (opens new window) 贡献 #254 (opens new window) # 🔨 Dependency Upgrades 暂无 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.5】2022-12-01 【v1.6.3】2022-07-29 ← 【v1.6.5】2022-12-01 【v1.6.3】2022-07-29→"},{"title":"【v1.6.5】2022-12-01","path":"/wiki/YuDaoCloud/更新日志/【v1.6.5】2022-12-01/【v1.6.5】2022-12-01.html","content":"开发指南更新日志 芋道源码 2022-08-22 目录 【v1.6.5】2022-12-01 # 重构 Vue3 管理后台,优化稳定性 # 📈 Statistic 总代码行数:98088 源码代码行数:55926 注释行数:23265 单元测试用例数:671 # ⭐ New Features 【新增】管理后台登录时,使用滑块验证码,由 @xingyu4j (opens new window) 贡献 #238 (opens new window) 【新增】SSO 单点登录的示例,包括基于授权码模式、密码模式两种实现 #272 (opens new window) 【优化】提升 Vue3 实现管理后台的稳定性、兼容性,基于 vxe-table 解决 el-table 卡顿的问题,由 @xingyu4j (opens new window) 贡献 #271 (opens new window) #282 (opens new window) #283 (opens new window) #288 (opens new window) #291 (opens new window) #293 (opens new window) #299 (opens new window) #300 (opens new window) #314 (opens new window) #316 (opens new window) 【优化】使用 LocalDateTime 替换 Date,由 @xingyu4j (opens new window) 贡献 #292 (opens new window) 【新增】Spring Cache 在多租户下的支持,由 @whitedolphin (opens new window) 贡献 #257 (opens new window) 【新增】流程图 ServiceTask 的完成和 todo 高亮,增加 ServiceTask 节点的 hover 显示内容,由 @FinalFinancialFreedom (opens new window) 贡献 #260 (opens new window) 【移除】云片短信渠道,解决云片的安全风险 ea95115 (opens new window) 【移除】jasypt-spring-boot-starter 加密库使用 hutool AES 替代 ce3aefa (opens new window) 【移除】Apollo 配置中心,简化学习成本 a8cdf74 (opens new window) # 🐞 Bug Fixes 【修复】WxMaService 的 null key in entry 报错,由 @rayyer (opens new window) 贡献 #259 (opens new window) 【修复】导入用户后编辑报错,由 @wangjun (opens new window) 贡献 #258 (opens new window) 【修复】编辑流程模型时,不退出模拟直接保存,导致后续分配规则报错,由 @wangjun (opens new window) 贡献 #258 (opens new window) 【修复】数据权限,不支持隐式内连接的问题 【修复】\"定时任务 -> 调度日志 -> 详细\"里面,”执行时长“字段显示不正确的问题,由 @idevmo (opens new window) 贡献 #265 (opens new window) 【修复】Vue3 代码生成选择父菜单无效,生成的前端代码缺少字段以及格式错误,由 @jueyinghua (opens new window) 贡献 #286 (opens new window) 【修复】前端配置管理中参数分类显示错误,由 @guyuezb (opens new window) 贡献 #278 (opens new window) 【修复】短信接收报告回调时,设置 errorMsg 不正确,由 @Macro (opens new window) 贡献 #280 (opens new window) 【修复】当只修改模型并保存,再发布时,提示\"流程定义部署失败,原因:信息未发生变化\",由 @SuperHao (opens new window) 贡献 #284 (opens new window) 【修复】WXLitePayClient.java 中 copy 应忽略的字段,由 @chenlei65368 (opens new window) 贡献 #284 (opens new window) 【修复】阿里云 OSS 解析 region 时兼容带 https的 配置,由 @huangyemin (opens new window) 贡献 #276 (opens new window) 【修复】三级及以上菜单路由缓存失效问题,由 @咱哥丶 (opens new window) 贡献 #290 (opens new window) 【修复】钉钉登录时,重定向后 type 丢失导致报错的问题 7093ed3 (opens new window) 【修复】无法自定义 Icon 图标的问题 e403684 (opens new window) 【修复】访问数据库存储的文件,path 多层级时,无法访问的问题 92ace03 (opens new window) 【修复】S3 上传七牛云无 mime type 的问题,由 @石溪 (opens new window) 贡献 #313 (opens new window) 【修复】流程代办,日期时区转换错误,由 @zy_2021 (opens new window) 贡献 #309 (opens new window) # 🔨 Dependency Upgrades 【升级】spring boot from 2.6.10 to 2.7.6 【升级】flowable from 6.7.0 to 6.7.2 【升级】hutool from 5.7.22 to 5.8.9 【升级】velocity from 2.2 to 2.3 【升级】druid from 1.2.11 to 1.2.14 【升级】spring boot admin from 2.6.7 to 2.6.9 【升级】mapstruct from 1.4.1 to 1.5.3.Final 【升级】lombok from 1.16.14 to 1.18.24 【升级】mockito from 4.0.0 to 4.8.0 【升级】dynamic-datasource from 3.5.0 to 3.5.2 【升级】redisson from 3.17.4 to 3.17.7 【升级】easyexcel from 3.1.1 to 3.1.2 【升级】vue from 2.7.0 to 2.7.14 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.6】2023-01-05 【v1.6.4】2022-08-22 ← 【v1.6.6】2023-01-05 【v1.6.4】2022-08-22→"},{"title":"【v1.7.1】2023-03-05","path":"/wiki/YuDaoCloud/更新日志/【v1.7.1】2023-03-05/【v1.7.1】2023-03-05.html","content":"开发指南更新日志 芋道源码 2023-01-30 目录 【v1.7.1】2023-03-05 # 新增 Vue3 管理后台支持工作流、大屏设计器,升级 OpenAPI 3.0 接口文档 # 📈 Statistic 总代码行数:126673 源码代码行数:78532 注释行数:28594 单元测试用例数:782 # ⭐ New Features 【重构】Vue3 管理后台调整到 GitHub (opens new window)、Gitee (opens new window) 地址,逐步分离前端和后端仓库,保证 Git commit 日志的整洁! 【新增】Vue3 工作流的,由 @周建 (opens new window)、@xingyu4j (opens new window) 贡献 #397 (opens new window)、#401 (opens new window)、#407 (opens new window)、#6 (opens new window)、#7 (opens new window)、#12 (opens new window) 【新增】基于 Go-View 共建大屏设计器,支持 Vue2 和 Vue3 管理后台,由 @芋道源码 (opens new window) 贡献 #403 (opens new window) 【新增】支付收银台,接入支付宝的 PC、Wap、二维码、条码、App 等支付方式,由 @芋道源码 (opens new window) 贡献 #403 (opens new window) 【新增】接口文档使用 OpenAPI 3.0 实现,@xingyu4j (opens new window) 贡献 #380 (opens new window) 【优化】菜单新增 alwaysShow 总是展示、componentName 组件名,由 @芋道源码 (opens new window) 贡献 #408 (opens new window) 【优化】system 模块的 Service 逻辑单元测试,单测数量 423,方法行覆盖率 95%,行覆盖率 93%,由 @芋道源码 (opens new window) 贡献 #392 (opens new window) 【优化】infra 模块的 Service 逻辑单元测试,单测数量 81,方法行覆盖率 63%,行覆盖率 47%,由 @芋道源码 (opens new window) 贡献 #393 (opens new window) 【优化】清理单元测试多余的 SQL 脚本,由 @niu_dehua (opens new window) 贡献 #345 (opens new window) 【优化】《后端手册 —— 快速启动》 (opens new window)文档,由 @芋道源码 (opens new window) 贡献 【优化】解决 Vue2 管理后台,只有一个菜单时,不展父菜单/目录的情况,由 @zhang.xionghui (opens new window) 贡献 #394 (opens new window) 【优化】缓存部门的变量命名,由 @重楼 (opens new window) 贡献 #421 (opens new window) 【新增】《萌新必读 —— 快速启动(我是前端)》 (opens new window) 文档,适合前端同学启动前端项目 # 🐞 Bug Fixes 【修复】Vue3 管理后台的tagViews 左右两侧按钮不能垂直居中的问题,由 @AKING (opens new window) 贡献 #406 (opens new window) 【修复】项目启动,链接数据查询时控制台报错 SQLNonTransientConnectionException 异常,由 @zhang (opens new window) 贡献 #406 (opens new window) 【修复】Redis Pub/Sub 广播消费的容器,默认未启动的问题,由 @筱龙缘 (opens new window) 贡献 #415 (opens new window) 【修复】MySQL 连接为 Asia/Shanghai 本地时区,由 @小桂子 (opens new window) 贡献 #409 (opens new window) #410 (opens new window) 【修复】代码生成器的同步报错问题,由 @Rex (opens new window) 贡献 #413 (opens new window) 【修复】登录选择钉钉等第三方弹窗后,点击取消弹窗后恢复登录按钮 loading 状态,由 @thisliuyang (opens new window) 贡献 #217 (opens new window) 【修复】去掉 Swagger 自动配置类中的冗余配置,由 @zhangxingjia (opens new window) 贡献 #424 (opens new window) 【修复】用户详情不显示所属部门部门,由 @babylazsss (opens new window) 贡献 #424 (opens new window) 【修复】GitHub Action 自动 build 前端报错的问题,由 @六楼的雨 (opens new window) 贡献 #424 (opens new window) 【修复】Vue3 管理后台:新增”字典类型“的时候,字典类型的必填校验不通过,由 @六楼的雨 (opens new window) 贡献 #1 (opens new window) 【修复】Vue3 管理后台:字典点击表格红色报错修改;keepalive 缓存 toCamelCase 设置中去掉 ‘-’,保留驼峰命名;新增 Search 组件新增插槽传递;topActionSlots: false 报错修改;tagsView.ts 删除页面缓存优化;,由 @毕梅 (opens new window) 贡献 #2 (opens new window) 【修复】Vue3 管理后台:部分逻辑的规范代码(eslint),由 @孔思宇 (opens new window) 贡献 #4 (opens new window) 【修复】Vue3 管理后台:build script 增加内存配置(解决 nodejs 默认配置内存溢出),由 @孔思宇 (opens new window) 贡献 #5 (opens new window) 【修复】Vue3 管理后台:分配角色的权限 el-tree 组件 setCheckedKeys 设置一旦选中父级子级也被选中,由 @当时明月在 (opens new window) 贡献 #8 (opens new window) 【修复】Vue3 管理后台:XTable 中主题颜色不跟随项目主体一起切换,由 由 @毕梅 (opens new window) 贡献 #12 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.7.7 to 2.7.8 【升级】easy-excel from 3.1.5 to 3.2.0 【升级】captcha-plus from 1.0.1 to 1.0.2 【升级】jedis-mock from 1.0.5 to 1.0.6 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/05, 20:06:33 【v1.7.2】2023-04-19 【v1.7.0】2023-01-30 ← 【v1.7.2】2023-04-19 【v1.7.0】2023-01-30→"},{"title":"用户体系","path":"/wiki/YuDaoCloud/后端手册/用户体系/用户体系.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 用户体系 系统提供了 2 种类型的用户,分别满足对应的管理后台、用户 App 场景。 AdminUser 管理员用户,前端访问 yudao-ui-admin (opens new window) 管理后台,后端访问 /admin-api/** RESTful API 接口。 MemberUser 会员用户,前端访问 yudao-ui-user (opens new window) 用户 App,后端访问 /app-api/** RESTful API 接口。 虽然是不同类型的用户,他们访问 RESTful API 接口时,都通过 Token 认证机制,具体可见 《开发指南 —— 功能权限》。 # 1. 表结构 2 种类型的时候,采用不同数据库的表进行存储,管理员用户对应 system_users (opens new window) 表,会员用户对应 member_user (opens new window) 表。如下图所示: 为什么不使用统一的用户表? 确实可以采用这样的方案,新增 type 字段区分用户类型。不同用户类型的信息字段,例如说上图的 dept_id、post_ids 等等,可以增加拓展表,或者就干脆“冗余”在用户表中。 不过实际项目中,不同类型的用户往往是不同的团队维护,并且这也是绝大多团队的实践,所以我们采用了多个用户表的方案。 如果表需要关联多种类型的用户,例如说上述的 system_oauth2_access_token 访问令牌表,可以通过 user_type 字段进行区分。并且 user_type 对应 UserTypeEnum (opens new window) 全局枚举,代码如下: # 2. 如何获取当前登录的用户? 使用 SecurityFrameworkUtils (opens new window) 提供的如下方法,可以获得当前登录用户的信息: /** * 【最常用】获得当前用户的编号,从上下文中 * * @return 用户编号 */@Nullablepublic static Long getLoginUserId() { /** 省略实现 */ }/** * 获取当前用户 * * @return 当前用户 */@Nullablepublic static LoginUser getLoginUser() { /** 省略实现 */ }/** * 获得当前用户的角色编号数组 * * @return 角色编号数组 */@Nullablepublic static Set<Long> getLoginUserRoleIds() { /** 省略实现 */ } # 3. 账号密码登录 # 3.1 管理后台的实现 使用 username 账号 + password 密码进行登录,由 AuthController ( opens new window) 提供 /admin-api/system/auth/login 接口。代码如下: @PostMapping("/login")@Operation(summary = "使用账号密码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) { String token = authService.login(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} 如何关闭验证码? 参见 《后端手册 —— 验证码》 文档。 # 3.2 用户 App 的实现 使用 mobile 手机 + password 密码进行登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/login 接口。代码如下: @PostMapping("/login")@Operation(summary = "使用手机 + 密码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> login(@RequestBody @Valid AppAuthLoginReqVO reqVO) { String token = authService.login(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AppAuthLoginRespVO.builder().token(token).build());} # 4. 手机验证码登录 # 4.1 管理后台的实现 ① 使用 mobile 手机号获得验证码,由 AuthController ( opens new window) 提供 /admin-api/system/auth/send-sms-code 接口。代码如下: @PostMapping("/send-sms-code")@Operation(summary = "发送手机验证码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<Boolean> sendSmsCode(@RequestBody @Valid AuthSendSmsReqVO reqVO) { authService.sendSmsCode(getLoginUserId(), reqVO); return success(true);} ② 使用 mobile 手机 + code 验证码进行登录,由 AppAuthController (opens new window) 提供 /admin-api/system/auth/sms-login 接口。代码如下: @PostMapping("/sms-login")@Operation(summary = "使用短信验证码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) { String token = authService.smsLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} # 4.2 用户 App 的实现 ① 使用 mobile 手机号获得验证码,由 AppAuthController ( opens new window) 提供 /app-api/member/auth/send-sms-code 接口。代码如下: @PostMapping("/send-sms-code")@Operation(summary = "发送手机验证码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<Boolean> sendSmsCode(@RequestBody @Valid AppAuthSendSmsReqVO reqVO) { authService.sendSmsCode(getLoginUserId(), reqVO); return success(true);} ② 使用 mobile 手机 + code 验证码进行登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/sms-login 接口。代码如下: @PostMapping("/sms-login")@Operation(summary = "使用手机 + 验证码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> smsLogin(@RequestBody @Valid AppAuthSmsLoginReqVO reqVO) { String token = authService.smsLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AppAuthLoginRespVO.builder().token(token).build());} 如果用户未注册,会自动使用手机号进行注册会员用户。所以,/app-api/member/user/sms-login 接口也提供了用户注册的功能。 # 5. 三方登录 详细参见 《开发指南 —— 三方登录》 文章。 # 5.1 管理后台的实现 ① 跳转第三方平台,来获得三方授权码,由 AuthController (opens new window) 提供 /admin-api/system/auth/social-auth-redirect 接口。代码如下: @GetMapping("/social-auth-redirect")@Operation(summary = "社交授权的跳转")@Parameters({ @Parameter(name = "type", description = "社交类型", required = true), @Parameter(name = "redirectUri", description = "回调路径")})public CommonResult<String> socialAuthRedirect(@RequestParam("type") Integer type, @RequestParam("redirectUri") String redirectUri) { return CommonResult.success(socialUserService.getAuthorizeUrl(type, redirectUri));} ② 使用 code 三方授权码进行快登录,由 AuthController (opens new window) 提供 /admin-api/system/auth/social-login 接口。代码如下: @PostMapping("/social-login")@Operation(summary = "社交快捷登录,使用 code 授权码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> socialQuickLogin(@RequestBody @Valid AuthSocialQuickLoginReqVO reqVO) { String token = authService.socialLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} ③ 使用 socialCode 三方授权码 + username + password 进行绑定登录,直接使用 /admin-api/system/auth/login 账号密码登录的接口,区别在于额外带上 socialType + socialCode + socialState 参数。 # 5.2 用户 App 的实现 ① 跳转第三方平台,来获得三方授权码,由 AppAuthController (opens new window) 提供 /app-api/member/auth/social-auth-redirect 接口。代码如下: @GetMapping("/social-auth-redirect")@Operation(summary = "社交授权的跳转")@Parameters({ @Parameter(name = "type", description = "社交类型", required = true), @Parameter(name = "redirectUri", description = "回调路径")})public CommonResult<String> socialAuthRedirect(@RequestParam("type") Integer type, @RequestParam("redirectUri") String redirectUri) { return CommonResult.success(socialUserService.getAuthorizeUrl(type, redirectUri));} ② 使用 code 三方授权码进行快登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/social-login 接口。代码如下: @PostMapping("/social-login")@Operation(summary = "社交快捷登录,使用 code 授权码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> socialQuickLogin(@RequestBody @Valid AuthSocialQuickLoginReqVO reqVO) { String token = authService.socialLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} ③ 使用 socialCode 三方授权码 + username + password 进行绑定登录,直接使用 /app-api/system/auth/login 手机验证码登录的接口,区别在于额外带上 socialType + socialCode + socialState 参数。 ④ 【微信小程序特有】使用 phoneCode + loginCode 实现获取手机号并一键登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/weixin-mini-app-login 接口。代码如下: @PostMapping("/weixin-mini-app-login")@Operation(summary = "微信小程序的一键登录")public CommonResult<AppAuthLoginRespVO> weixinMiniAppLogin(@RequestBody @Valid AppAuthWeixinMiniAppLoginReqVO reqVO) { return success(authService.weixinMiniAppLogin(reqVO));} # 6. 注册 # 6.1 管理后台的实现 管理后台暂不支持用户注册,而是通过在 [系统管理 -> 用户管理] 菜单,进行添加用户,由 UserController ( opens new window) 提供 /admin-api/system/user/create 接口。代码如下: @PostMapping("/create")@Operation(summary = "新增用户")@PreAuthorize("@ss.hasPermission('system:user:create')")public CommonResult<Long> createUser(@Valid @RequestBody UserCreateReqVO reqVO) { Long id = userService.createUser(reqVO); return success(id);} # 6.2 用户 App 的实现 手机验证码登录时,如果用户未注册,会自动使用手机号进行注册会员用户。所以, /app-api/system/user/sms-login 接口也提供了用户注册的功能。 # 7. 用户登出 用户登出的功能,统一使用 Spring Security 框架,通过删除用户 Token 的方式来实现。代码如下: 差别在于使用的 API 接口不同,管理员用户使用 /admin-api/system/logout,会员用户使用 /app-api/member/logout。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:52:02 数据权限 三方登录 ← 数据权限 三方登录→"},{"title":"【v1.7.0】2023-01-30","path":"/wiki/YuDaoCloud/更新日志/【v1.7.0】2023-01-30/【v1.7.0】2023-01-30.html","content":"开发指南更新日志 芋道源码 2023-01-07 目录 【v1.7.0】2023-01-30 # 增加微信公众号的接入、邮箱、站内信、数据脱敏 # 📈 Statistic 总代码行数:119925 源码代码行数:73678 注释行数:27769 单元测试用例数:674 # ⭐ New Features 【新增】微信公众号功能,包括账号管理、数据统计、粉丝管理、消息管理、自动回复、标签管理、菜单管理、素材管理、图文草稿箱、图文发表记录,由 @芋道源码 (opens new window) 贡献 #382 (opens new window) 【新增】RESTful API 返回数据时,支持数据脱敏,由 @与或非 (opens new window) 贡献 #372 (opens new window) 【新增】邮箱功能:邮箱账号、邮件模版、邮件发送记录,由 @芋道源码 (opens new window) 贡献 #385 (opens new window) 【新增】站内信功能:站内信模版、站内信消息,由 @圆梦巨人 (opens new window)、@xrcoder (opens new window) 贡献 #385 (opens new window) 【新增】Vue3 管理后台新增 WebSocket 连接测试,由 @xingyu4j (opens new window) 贡献 #379 (opens new window) 【新增】配置 yaml 文件中自定义属性的提示,由 @与或非 (opens new window) 贡献 #373 (opens new window) 【优化】重构 Vue3 管理后台的路由代码生成逻辑,优化性能,由 @xingyu4j (opens new window) 贡献 #375 (opens new window) 【优化】Vue3 管理后台的第一次进入加载速度,由 @xingyu4j (opens new window) 贡献 #381 (opens new window) 【新增】Vue3 管理后台基于 unplugin-auto-import 实现自动导入,由 @xingyu4j (opens new window) 贡献 #376 (opens new window) 【优化】重构滑块验证码 captcha 的实现,由 @xingyu4j (opens new window) 贡献 #374 (opens new window) #376 (opens new window) 【优化】简化本地缓存的实现,优化 《后端手册 —— 本地缓存》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 #382 (opens new window) 【优化】代码生成列表的加载速度,由 @与或非 (opens new window) 贡献 #378 (opens new window) 【新增】《后端手册 —— 验证码》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 【新增】《后端手册 —— 数据脱敏》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 【新增】《公众号手册》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 # 🐞 Bug Fixes 【修复】积木报表:部分请求会报错:JmReportTokenServices 实现类 getUsername 方法返回值不允许为空,由 @与或非 (opens new window) 贡献 #358 (opens new window) 【修复】积木报表:分享报错,由 @与或非 (opens new window) 贡献 #357 (opens new window) 【修复】积木报表:API数据集解析时,提示数据为空,报表字段明细会被清空,由 @与或非 (opens new window) 贡献 #359 (opens new window) 【修复】yudao-ui-appi 的 refreshToken is not a function 问题修复,由 @chaining (opens new window) 贡献 #356 (opens new window) 【修复】Vue2 管理后台 Redis 监控 echarts 图表不显示,由 @zy_2021 (opens new window) 贡献 #354 (opens new window) 【修复】MyBatis Plus 升级导致 generatorTest 用例找不到对象爆红,由 @miozus (opens new window) 贡献 #365 (opens new window) 【修复】代码生成器读取不到 dataType 属性,导致无法正确生成代码,由 @与或非 (opens new window) 贡献 #370 (opens new window) 【修复】Xss 启用后,编辑器上传图片错误,由 @与或非 (opens new window) 贡献 #361 (opens new window) #383 (opens new window) 【修复】管理后台 uniapp 的令牌过期时,无法刷新令牌的 bug,由 @chaining (opens new window) 贡献 #360 (opens new window) 【修复】获取菜单返回了不可修改集合,导致无法排序的报错,由 @ambi (opens new window) 贡献 #371 (opens new window) 【修复】Vue2 管理后台的 tags 页签超过屏幕后,无法滚动导致无法选择后面的页签,由 @zhang.xionghui (opens new window) 贡献 #366 (opens new window) # 🔨 Dependency Upgrades 【升级】mybatis-plus from 3.5.3 to 3.5.3.1 【升级】spring-security from 3.7.5 to 3.7.6 【升级】spring-boot-admin from 2.7.9 to 2.7.10 【升级】minio from 8.4.6 to 8.5.1 【升级】knife4j from 3.0.3 to 4.0.0 【升级】vxe-table from 4.3.7 to 4.3.9 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.7.1】2023-03-05 【v1.6.6】2023-01-05 ← 【v1.7.1】2023-03-05 【v1.6.6】2023-01-05→"},{"title":"【v1.6.6】2023-01-05","path":"/wiki/YuDaoCloud/更新日志/【v1.6.6】2023-01-05/【v1.6.6】2023-01-05.html","content":"开发指南更新日志 芋道源码 2023-01-01 目录 【v1.6.6】2023-01-05 # 完善 Vue3 管理后台,新增 IP & 地区库 # 📈 Statistic 总代码行数:104298 源码代码行数:63656 注释行数:24708 单元测试用例数:602 # ⭐ New Features 【新增】yudao-spring-boot-starter-biz-ip (opens new window) 业务组件,提供地区 & IP 库的封装,由 @WangLH (opens new window) 贡献 0b5aa56 (opens new window) 【新增】《后端手册 —— 地区 & IP 库》 (opens new window) 文档 【新增】《后端手册 —— 敏感词》 (opens new window) 文档 【新增】《前端手册 Vue 3.x》 (opens new window) 文档 【优化】本地缓存的刷新实现,数据变更时,强制刷新,贡献 #3443aa6 (opens new window) 【新增】Vue3 XTable 组件,由 @xingyu4j (opens new window) 贡献 #349 (opens new window) 【优化】优化 Vue3 管理后台实现,由 @xingyu4j (opens new window) 贡献 #317 (opens new window) #322 (opens new window) #331 (opens new window) #335 (opens new window) #339 (opens new window) #343 (opens new window) 【优化】完善 Vue3 上传组件 && 提升打包速度,由 @xingyu4j (opens new window) 贡献 #337 (opens new window) 【重构】Vue3 头像上传,由 @xingyu4j (opens new window) 贡献 #338 (opens new window) 【新增】WebSocket 连接测试,由 @咱哥丶 (opens new window) 贡献 #348 (opens new window) # 🐞 Bug Fixes 【修复】字典类型逻辑删除时,唯一索引冲突的问题,由 @tangkc123 (opens new window) 贡献 #323 (opens new window) 【修复】pay 模块提交退款申请时,重复设置属性,由 @qshome (opens new window) 贡献 #325 (opens new window) 【修复】修改pay 模块创建支付单时,错误返回订单编号,由 @qshome (opens new window) 贡献 #324 (opens new window) 【修复】修改 pay 模块在微信支付时,支付过期时间格式化异常 (yyyy-MM-ddTHH:mm:ssXXX),由 @qshome (opens new window) 贡献 #329 (opens new window) 【修复】数据权限 SQL 存在多个表达式时,缺少括号问题,由 @与或非 (opens new window) 贡献 #328 (opens new window) 【修复】yudao-ui-admin-vue3 面包屑导航图标和文字不在同一水平线,由 @supine-win (opens new window) 贡献 #333 (opens new window) 【修复】yudao-module-system-api 的 ErrorCodeConstants 中错误码重复的问题,由 @王添翼 (opens new window) 贡献 #340 (opens new window) 【修复】DeptService 的 getDeptsByParentIdFromCache 在获取部门列表时,未处理多租户场景,贡献 #75b3a29 (opens new window) 【修复】前端 FileUpload 文件上传时,code 未使用 0 判断成功,由 @plimlips (opens new window) 贡献 #344 (opens new window) 【修复】Redis Stream 消息队列在重启 Java 进程时,由于 Consumer 未释放消息,导致消息丢失的问题,由 @与或非 (opens new window) 贡献 #332 (opens new window) 【修复】腾讯 COS 异常,Region 必传,由 @与或非 (opens new window) 贡献 #347 (opens new window) 【修复】DB 存储文件时,读取可能报错的问题,由 @与或非 (opens new window) 贡献 #346 (opens new window) 【修复】没有数据权限时,添加/修改用户的唯一手机、账号等字段的校验不正确,贡献 7912a54 (opens new window) 【修复】配置管理,配置是否可见判断写反了,由 @kinlon92 (opens new window) 贡献 #350 (opens new window) 【修复】上传视频无法预览,由 @与或非 (opens new window) 贡献 #352 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.7.6 to 2.7.7 【升级】mybatis-plus from 3.5.2 to 3.5.3 【升级】dynamic-datasource from 3.6.0 to 3.6.1 【升级】flowable from 6.7.2 to 6.8.0 【升级】lock4j from 2.2.2 to 2.2.3 【升级】podam from 7.2.9 to 7.2.11 【升级】jedis-mock from 1.0.4 to 1.0.5 【升级】transmittable-thread-local from 2.14.0 to 2.14.2 【升级】netty-all from 4.1.82 to 4.1.86 【升级】aliyun-java-sdk-core from 4.6.2 to 4.6.3 【升级】tencentcloud-sdk-java from 3.1.635 to 3.1.660 【升级】spring-boot-admin from 2.7.7 to 2.7.9 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.7.0】2023-01-30 【v1.6.5】2022-12-01 ← 【v1.7.0】2023-01-30 【v1.6.5】2022-12-01→"},{"title":"【v1.7.3】开发中","path":"/wiki/YuDaoCloud/更新日志/【v1.7.3】开发中/【v1.7.3】开发中.html","content":"开发指南更新日志 芋道源码 2023-04-22 目录 【v1.7.3】开发中 # # 📈 Statistic 总代码行数: 源码代码行数: 注释行数: 单元测试用例数: # ⭐ New Features 【重构】Vue3 管理后台:公众号 MP 模块重构,功能增强,由 @dhb52 (opens new window) 贡献 #135 (opens new window) 【新增】Vue3 管理后台:菜单管理:添加刷新菜单缓存按钮,由 @puhui999 (opens new window) 贡献 #134 (opens new window) 【优化】Vue3 管理后台:升级 Vite 4.3.1,升级其它依赖,由 @xingyu4j (opens new window) 贡献 #53b6f0b (opens new window) # 🐞 Bug Fixes 【修复】代码生成:Vue3 标准模板缺少 baseURL 的格式化,由 @baayso (opens new window) 贡献 #462 (opens new window) 【修复】新建商品时商品分类状态判断错误,由 @LiZhongShi (opens new window) 贡献 #459 (opens new window) 【修复】缺少 ServletUtils 引用,由 @inypeacock (opens new window) 贡献 #461 (opens new window) 【修复】一键改包的”占位“文件影响改包工具运行,由 @anzhen-tech (opens new window) 贡献 #458 (opens new window) 【修复】尝试修复项目第一次打包失败报 Failed to execute goal org.apache.maven.plugins:maven-jar-plugin:3.3.0:jar,由 @芋道源码 (opens new window) 贡献 #91f63ff (opens new window) # 🔨 Dependency Upgrades .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:23 IDE 调试 【v1.7.2】2023-04-19 ← IDE 调试 【v1.7.2】2023-04-19→"},{"title":"一键改包","path":"/wiki/YuDaoCloud/萌新必读/一键改包/一键改包.html","content":"开发指南萌新必读 芋道源码 2022-03-27 目录 一键改包 项目提供了 ProjectReactor (opens new window) 程序,支持一键改包,包括 Maven 的 groupId、artifactId、Java 的根 package、前端的 title、数据库的 SQL 配置、应用的 application.yaml 配置文件等等。效果如下图所示: 友情提示:修改包名后,未来合并最新的代码可能会有一定的成本。 # 👍 相关视频教程 08、如何实现一键改包? (opens new window) # 操作步骤 ① 第一步,使用 IDEA (opens new window) 克隆 https://github.com/YunaiV/yudao-cloud (opens new window) 仓库的最新代码,并给该仓库一个 Star (opens new window)。 ② 第二步,打开 ProjectReactor 类,填写 groupIdNew、artifactIdNew、packageNameNew、titleNew 属性。如下图所示: 另外,如下两个属性也必须修改: projectBaseDir 属性:修改为你 yudao-cloud 所在目录的绝对地址 projectBaseDirNew 属性:修改为你想要的新项目的绝对地址。注意,不要有 yudao 关键字。 ③ 第三步,执行 ProjectReactor 的 #main(String[] args) 方法,它会基于当前项目,复制一个新项目到 projectBaseDirNew 目录,并进行相关的改名逻辑。 11:19:11.180 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][原项目路劲改地址 (/Users/yunai/Java/yudao-cloud-2023)]11:19:11.184 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][检测新项目目录 (/Users/yunai/Java/xx-new)是否存在]11:19:11.298 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][完成新项目目录检测,新项目路径地址 (/Users/yunai/Java/xx-new)]11:19:11.298 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][开始获得需要重写的文件,预计需要 10-20 秒]11:19:12.169 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][需要重写的文件数量:1573,预计需要 15-30 秒]11:19:14.607 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][重写完成]共耗时:3 秒 ④ 第四步,使用 IDEA 打开 projectBaseDirNew 目录,参考 《开发指南 —— 快速启动》 文档,进行项目的启动。注意,一定要重新执行 SQL 的导入!!! 整个过程非常简单,如果碰到问题,请添加项目的技术交流群。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 12:50:10 代码热加载 删除功能 ← 代码热加载 删除功能→"},{"title":"交流群","path":"/wiki/YuDaoCloud/萌新必读/交流群/交流群.html","content":"开发指南萌新必读 芋道源码 2022-03-11 目录 交流群 # 🐱 反馈交流 如果有问题,可以通过 Gitee Issue (opens new window) 或者 Github Issue (opens new window) 进行反馈。 欢迎加入用户交流群,一起苦练技术基本功,每日精进 30 公里。 如果微信提示“提示对方被加好友过于频繁,请稍后再试?”,可以过一会再尝试下!🙂 项目关注和使用的人太多了~ .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 20:53:54 简介 视频教程 ← 简介 视频教程→"},{"title":"代码热加载","path":"/wiki/YuDaoCloud/萌新必读/代码热加载/代码热加载.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 代码热加载 在日常开发中,我们需要经常修改 Java 代码,手动重启项目,查看修改后的效果。如果在项目小时,重启速度比较快,等待的时间是较短的。但是随着项目逐渐变大,重启的速度变慢,等待时间 1-2 min 是比较常见的。 这样就导致我们开发效率降低,影响我们的下班时间,哈哈哈~ 那么是否有方式能够实现,在我们修改完 Java 代码之后,能够不重启项目呢?答案是有的,通过 代码热加载 的方式。实现方案有三种: spring-boot-devtools【不推荐】 IDEA 自带 HowSwap 功能【推荐】 JRebel 插件【最推荐】 友情提示:本文图中看到的 YudaoServerApplication 启动类,可以换成每个服务的 XXXApplication 启动类。 # 1. spring-boot-devtools spring-boot-devtools (opens new window) 是 Spring Boot 提供的开发者工具,它会监控当前应用所在的 classpath 下的文件发生变化,进行自动重启。 devtools 存在重启速度较慢的问题,所以不推荐! # 2. IDEA 自带 HowSwap 功能 该功能是 IDEA Ultimate 旗舰版的专属功能,不支持 IDEA Community 社区版。 # 2.1 如何使用 ① 设置 Spring Boot 启动类,开启 HotSwap 功能。如下图所示: ② Debug 运行该启动类,等待项目启动完成。 ③ 每次修改 Java 代码后,点击左下角的「热加载」按钮,即可实现代码热加载。如下图所示: # 2.2 存在问题 IDEA 自带 HowSwap 功能,支持比较有限,很多修改都不支持。例如说: 只能增加方法或字段但不可以减少方法或字段 只能增加可见性不能减少 只能维持已有方法的签名而不能修改等等。 你可以认为,只支持方法内的代码修改热加载。 如果想要相对完美的方案,建议使用 JRebel 插件。 # 3. JRebel 插件 JRebel 插件是目前最好用的热加载插件,它支持 IDEA Ultimate 旗舰版、Community 社区版。 # 3.1 如何安装 ① 点击 https://plugins.jetbrains.com/plugin/4441-jrebel-and-xrebel/versions (opens new window) 地址,必须下载 2022.4.1 版本。如下图所示: ② 打开 [Preference -> Plugins] 菜单,点击「Install Plugin from Disk...」按钮,选择刚下载的 JRebel 插件的压缩包。如下图所示: 安装完成后,需要重启 IDEA 生效。 ③ 打开 [Preference -> JRebel & XRebel] 菜单,输入 GUID address 为 https://jrebel.qekang.com/1e67ec1b-122f-4708-87d0-c1995dc0cdaa ,邮件随便写,完成 JRebel 的激活。如下图所示: 之后,点击「Work Offline」按钮,设置 JRebel 为离线,避免因为网络问题导致激活失效。如下图所示: # 3.2 如何使用 ① 点击「Debug With JRebel」按钮,使用 JRebel 启动项目。如下图所示: ② 每次修改 Java 代码后,点击左下角的「热加载」按钮,即可实现代码热加载。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/06, 01:38:02 项目结构 一键改包 ← 项目结构 一键改包→"},{"title":"功能列表","path":"/wiki/YuDaoCloud/萌新必读/功能列表/功能列表.html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 功能列表 芋道,以开发者为中心,打造中国第一流的快速开发平台,全部开源,个人与企业可 100% 免费使用。 管理后台的电脑端:Vue3 提供 element-plus (opens new window)、vben(ant-design-vue) (opens new window) 两个版本,Vue2 提供 element-ui (opens new window) 版本 管理后台的移动端:采用 uni-app (opens new window) 方案,一份代码多终端适配,同时支持 APP、小程序、H5! 后端采用 Spring Cloud Alibaba 微服务架构,注册中心 + 配置中心 Nacos,消息队列 RocketMQ,定时任务 XXL-Job,服务保障 Sentinel,服务网关 Gateway,分布式事务 Seata 数据库可使用 MySQL、Oracle、PostgreSQL、SQL Server、MariaDB、国产达梦 DM、TiDB 等,基于 MyBatis Plus、Redis + Redisson 操作 权限认证使用 Spring Security & Token & Redis,支持多终端、多种用户的认证系统,支持 SSO 单点登录 支持加载动态权限菜单,按钮级别权限控制,本地缓存提升性能 支持 SaaS 多租户系统,可自定义每个租户的权限,提供透明化的多租户底层封装 工作流使用 Flowable,支持动态表单、在线设计流程、会签 / 或签、多种任务分配方式 高效率开发,使用代码生成器可以一键生成前后端代码 + 单元测试 + Swagger 接口文档 + Validator 参数校验 集成微信小程序、微信公众号、企业微信、钉钉等三方登陆,集成支付宝、微信等支付与退款 集成阿里云、腾讯云等短信渠道,集成 MinIO、阿里云、腾讯云、七牛云等云存储服务 集成报表设计器、大屏设计器,通过拖拽即可生成酷炫的报表与大屏 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 🐼 内置功能 系统内置多种多种业务功能,可以用于快速你的业务系统: 系统功能 基础设施 工作流程 支付系统 会员中心 数据报表 商城系统 公众号系统 友情提示:本项目基于 RuoYi-Vue 修改,重构优化后端的代码,美化前端的界面。 额外新增的功能,我们使用 🚀 标记。 重新实现的功能,我们使用 ⭐️ 标记。 🙂 所有功能,都通过 单元测试 保证高质量。 # 系统功能 功能 描述 用户管理 用户是系统操作者,该功能主要完成系统用户配置 ⭐️ 在线用户 当前系统中活跃用户状态监控,支持手动踢下线 角色管理 角色菜单权限分配、设置角色按机构进行数据范围权限划分 菜单管理 配置系统菜单、操作权限、按钮权限标识等,本地缓存提供性能 部门管理 配置系统组织机构(公司、部门、小组),树结构展现支持数据权限 岗位管理 配置系统用户所属担任职务 🚀 租户管理 配置系统租户,支持 SaaS 场景下的多租户功能 🚀 租户套餐 配置租户套餐,自定每个租户的菜单、操作、按钮的权限 字典管理 对系统中经常使用的一些较为固定的数据进行维护 🚀 短信管理 短信渠道、短息模板、短信日志,对接阿里云、腾讯云等主流短信平台 🚀 邮件管理 邮箱账号、邮件模版、邮件发送日志,支持所有邮件平台 🚀 操作日志 系统正常操作日志记录和查询,集成 Swagger 生成日志内容 ⭐️ 登录日志 系统登录日志记录查询,包含登录异常 🚀 错误码管理 系统所有错误码的管理,可在线修改错误提示,无需重启服务 通知公告 系统通知公告信息发布维护 🚀 敏感词 配置系统敏感词,支持标签分组 🚀 应用管理 管理 SSO 单点登录的应用,支持多种 OAuth2 授权方式 🚀 地区管理 展示省份、城市、区镇等城市信息,支持 IP 对应城市 # 基础设施 功能 描述 🚀 代码生成 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 🚀 系统接口 基于 Swagger 自动生成相关的 RESTful API 接口文档 🚀 数据库文档 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 表单构建 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 🚀 配置管理 对系统动态配置常用参数,支持 SpringBoot 加载 🚀 文件服务 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 🚀 文件服务 支持本地文件存储,同时支持兼容 Amazon S3 协议的云服务、开源组件 🚀 API 日志 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 MySQL 监控 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 Redis 监控 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 🚀 消息队列 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 🚀 Java 监控 基于 Spring Boot Admin 实现 Java 应用的监控 🚀 链路追踪 接入 SkyWalking 组件,实现链路追踪 🚀 日志中心 接入 SkyWalking 组件,实现日志中心 🚀 分布式锁 基于 Redis 实现分布式锁,满足并发场景 🚀 幂等组件 基于 Redis 实现幂等组件,解决重复请求问题 🚀 服务保障 基于 Resilience4j 实现服务的稳定性,包括限流、熔断等功能 🚀 日志服务 轻量级日志中心,查看远程服务器的日志 🚀 单元测试 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 # 工作流程 功能 描述 🚀 流程模型 配置工作流的流程模型,支持文件导入与在线设计流程图,提供 7 种任务分配规则 🚀 流程表单 拖动表单元素生成相应的工作流表单,覆盖 Element UI 所有的表单组件 🚀 用户分组 自定义用户分组,可用于工作流的审批分组 🚀 我的流程 查看我发起的工作流程,支持新建、取消流程等操作,高亮流程图、审批时间线 🚀 待办任务 查看自己【未】审批的工作任务,支持通过、不通过、转发、委派、退回等操作 🚀 已办任务 查看自己【已】审批的工作任务,未来会支持回退操作 🚀 OA 请假 作为业务自定义接入工作流的使用示例,只需创建请求对应的工作流程,即可进行审批 # 支付系统 功能 描述 🚀 商户信息 管理商户信息,支持 Saas 场景下的多商户功能 🚀 应用信息 配置商户的应用信息,对接支付宝、微信等多个支付渠道 🚀 支付订单 查看用户发起的支付宝、微信等的【支付】订单 🚀 退款订单 查看用户发起的支付宝、微信等的【退款】订单 ps:核心功能已经实现,正在对接微信小程序中... # 数据报表 功能 描述 🚀 报表设计器 支持数据报表、图形报表、打印设计等 🚀 大屏设计器 拖拽生成数据大屏,内置几十种图表组件 # 微信公众号 功能 描述 🚀 账号管理 配置接入的微信公众号,可支持多个公众号 🚀 数据统计 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 🚀 粉丝管理 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 🚀 消息管理 查看粉丝发送的消息列表,可主动回复粉丝消息 🚀 自动回复 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 🚀 标签管理 对公众号的标签进行创建、查询、修改、删除等操作 🚀 菜单管理 自定义公众号的菜单,也可以从公众号同步菜单 🚀 素材管理 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 🚀 图文草稿箱 新增常用的图文素材到草稿箱,可发布到公众号 🚀 图文发表记录 查看已发布成功的图文素材,支持删除操作 # 商城系统 建设中... # 会员中心 和「商城系统」一起开发 # 🐷 演示图 # 系统功能 模块 biu biu biu 登录 & 首页 用户 & 应用 租户 & 套餐 - 部门 & 岗位 - 菜单 & 角色 - 审计日志 - 短信 字典 & 敏感词 ) 错误码 & 通知 - # 工作流程 模块 biu biu biu 流程模型 表单 & 分组 - 我的流程 待办 & 已办 OA 请假 # 基础设施 模块 biu biu biu 代码生成 - 文档 - 文件 & 配置 定时任务 - API 日志 - MySQL & Redis - 监控平台 # 支付系统 模块 biu biu biu 商家 & 应用 支付 & 退款 --- # 数据报表 模块 biu biu biu 报表设计器 大屏设计器 # 移动端(管理后台) biu biu biu 目前已经实现登录、我的、工作台、编辑资料、头像修改、密码修改、常见问题、关于我们等基础功能。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:23 视频教程 快速启动(适合“后端”工程师) ← 视频教程 快速启动(适合“后端”工程师)→"},{"title":"删除功能","path":"/wiki/YuDaoCloud/萌新必读/删除功能/删除功能.html","content":"开发指南萌新必读 芋道源码 2022-10-17 目录 删除功能 项目内置功能较多,会存在一些你可能用不到的功能。一般的情况下,建议通过设置该功能对应的菜单为【禁用】,实现功能的“删除”。如下图所示: 后续,如果你又需要使用到该功能,只需要设置该功能对应的菜单为【开启】即可。 🙂 当然,如果你希望彻底删除功能,那么就需要采用删除代码的方式。整个过程如下: ① 【菜单】第一步,使用管理后台的菜单管理,删除对应的菜单、按钮。 ② 【数据库表】第二步,删除对应的数据库表。 ③ 【后端代码】第三步,删除对应的 Controller、Service、数据库实体等后端代码;然后启动后端项目,若存在代码报错,则继续删除相关联的代码,之后如此反复,直到成功。 ④ 【前端代码】第四步,删除对应的 View 和 API 等前端代码;然后启动前端项目,若存在代码报错,则继续删除相关联的代码,之后如此反复,直到成功。 下面,我们来举一些例子。 # 👍 相关视频教程 从零开始 07:如何有效的删除不用的功能? (opens new window) # 删除「多租户」功能 对应功能的文档:多租户 对应的关键字是 tenant # 第一步,删除菜单 删除“租户管理“下的所有菜单,从最里层的按钮开始。如下图所示: # 第二步,删除数据库表 删除 system_tenant 和 system_tenant_package 表。如下图所示: # 第三步,删除后端代码 ① 删除 yudao-module-system-api 模块的 api/tenant (opens new window) 包。 ② 删除 yudao-module-system-api 模块的 ErrorCodeConstants (opens new window) 类中,和租户、租户套餐相关的错误码。如下图所示: 如果想删除的更干净,可以把 system_error_code 表中,对应编号的错误码也都删除一下。 ③ 删除 yudao-module-system-biz 模块的如下包: api/tenant (opens new window) controller/admin/tenant (opens new window) service/tenant (opens new window) test/service/tenant (opens new window) dal/dataobject/tenant (opens new window) dal/mysql/tenant (opens new window) convert/tenant (opens new window) ④ 删除 yudao-spring-boot-starter-biz-tenant (opens new window) 模块。 然后,使用 IDEA 搜索 yudao-spring-boot-starter-biz-tenant 关键字,删除 Maven 中所有对它的定义与引用。如下图所示: 之后,使用 IDEA 刷新下 Maven 依赖。如下图所示: ⑤ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.db 不存在的错误,需要将继承 TenantBaseDO 的数据库实体,都改成继承 BaseDO 基类。 ⑥ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.aop 不存在的错误,需要去除对 @TenantIgnore 注解的使用。如下图所示: ⑦ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.module.system.service.tenant 不存在的错误,需要去除对 TenantService 的使用。如下图所示: ⑧ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.context 不存在的错误,需要去除对 TenantContextHolder 的使用。如下图所示: ⑨ 运行 YudaoServerApplication 启动类,终于成功了!!! ps:可以将 application.yaml 配置文件中,对应的 yudao.tenant 配置项给进一步删除。 # 第四步,删除前端代码 以 yudao-admin-ui 为示例~ ① 删除 View 和 API 的前端代码: views/system/tenant (opens new window) views/system/tenantPackage (opens new window) api/system/tenant.js (opens new window) api/system/tenantPackage.js (opens new window) ② 在 yudao-admin-ui 目录下,执行 npm run local 成功。访问登录页,结果访问白屏。需要清理 login.vue 页,涉及 tenant 关键字的代码。例如说: 刷新,成功访问登录界面。 ③ 在 yudao-admin-ui 目录下,搜索 tenant 或 Tenant 关键字,可进一步清理多租户的代码。例如说: # 第五步,测试验收 至此,我们已经完成了多租户的代码删除,还是蛮艰辛的~ 后续,你可以简单测试一下,看看是不是删除代码,导致一些小问题。 # 更多... 如果你有其它功能想要删除,可以在 Issue (opens new window) 留言,可以不断补充到该文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/06, 01:38:02 一键改包 新建服务 ← 一键改包 新建服务→"},{"title":"快速启动(适合“前端”工程师)","path":"/wiki/YuDaoCloud/萌新必读/快速启动(适合“前端”工程师)/快速启动(适合“前端”工程师).html","content":"开发指南萌新必读 芋道源码 2023-03-05 目录 快速启动(适合“前端”工程师) 目标:在 本地 将前端项目运行起来,使用 远程 演示环境的后端服务。 整个过程非常简单,预计 5 分钟就可以完成,取决于大家的网速。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ 友情提示: 远程 演示环境的后端服务,只允许 GET 请求,不允许 POST、PUT、DELETE 等请求。 如果你要完整的后端服务,建议后续参考 《快速启动(我是后端)》 文档,将后端服务运行起来。 # 👍 相关视频教程 从零开始 02:在 Windows 环境下,如何运行前后端项目? (opens new window) 从零开始 03:在 MacOS 环境下,如何运行前后端项目? (opens new window) # 1. Apifox 接口工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 接口文档? 阅读 《开发指南 —— 接口文档》 呀~~ # 2. 启动 Vue3 + element-plus 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vue3.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run front ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 友情提示:Vue3 使用 Vite 构建,所以它存在如下的情况,都是正常的: 项目启动很快,浏览器打开需要等待 1 分钟左右,请保持耐心。 点击菜单,感觉会有一点卡顿,因为 Vite 采用懒加载机制。不用担心,最终部署到生产环境,就不存在这个问题了。 详细说明,可见 《为什么有人说 Vite 快,有人却说 Vite 慢?》 (opens new window) 文章。 # 3. 启动 Vue3 + vben(ant-design-vue) 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 + vben(ant-design-vue) 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vben.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run front ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 # 4. 启动 Vue2 管理后台 yudao-ui-admin (opens new window) 是前端 Vue2 管理后台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 在 yudao-ui-admin 目录下,执行如下命令,进行启动: # 进入项目目录cd yudao-ui-admin# 安装 Yarn,提升依赖的安装速度npm install --global yarn# 安装依赖yarn install# 启动服务npm run front ② 启动完成后,浏览器会自动打开 http://localhost:1024 (opens new window) 地址,可以看到前端界面。 # 5. 启动 uni-app 管理后台 yudao-ui-admin-uniapp (opens new window) 是前端 uni-app 管理后台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-admin-uniapp 目录。 然后,修改 config.js 配置文件的 baseUrl 后端服务的地址为 'http://api-dashboard.yudao.iocoder.cn。如下图所示: ③ 执行如下命令,安装 npm 依赖: # 进入项目目录cd yudao-ui-admin-uniapp# 安装 npm 依赖npm i ④ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: 友情提示:登录时,滑块验证码,在内存浏览器可能存在兼容性的问题,此时使用 Chrome 浏览器,并使用“开发者工具”,设置为 iPhone 12 Pro 模式! # 6. 启动 uni-app 用户前台 yudao-ui-app (opens new window) 是前端 uni-app 用户前台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-app 目录 然后,修改 config.js 配置文件的 baseUrl 后端服务的地址为 'http://api-dashboard.yudao.iocoder.cn/app-api。如下图所示: ③ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: # 7. 参与项目 如果你想参与到前端项目的开发,可以微信 wangwenbin-server 噢。 近期,重点开发 Vue3 管理后台、uniapp 商城,欢迎大家参与进来。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/15, 00:05:05 快速启动(适合“后端”工程师) 接口文档 ← 快速启动(适合“后端”工程师) 接口文档→"},{"title":"【v1.7.2】2023-04-19","path":"/wiki/YuDaoCloud/更新日志/【v1.7.2】2023-04-19/【v1.7.2】2023-04-19.html","content":"开发指南更新日志 芋道源码 2023-03-06 目录 【v1.7.2】2023-04-19 # 重构 Vue3 管理后台,提升易用性、稳定性 # 📈 Statistic 总代码行数:125001 源码代码行数:77128 注释行数:28642 单元测试用例数:789 # ⭐ New Features 【新增】《代码热加载》 (opens new window) 文档,提升开发效率。 【新增】Vue 管理后台:优化 VSCode 代码 Debugger 调试,使用 VSCode 自带的功能,由 @puhui999 (opens new window) 贡献 #117 (opens new window) 【新增】代码生成时,增加 UI 类型的选择,可生成 Vue2、Vue3 多种管理后台的代码,支持 CRUD Schema 模式,由 @芋道源码 (opens new window) 贡献 #453 (opens new window) 【新增】代码生成器,支持 VBEN 管理后台,由 @xingyu (opens new window) 贡献 #454 (opens new window) 【优化】Vue3 管理后台:去除 BPMNJS、FormCreate、Highlight 的全局引入,降低打包后的大小(6.6M -> 1.3M),由 @芋道源码 (opens new window) 贡献 #128 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 配置管理] 由 @芋道源码 (opens new window) 贡献 #24 (opens new window) 【重构】Vue3 管理后台:[SSO 登录] 由 @puhui999 (opens new window) 贡献 #107 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 数据源配置] 由 @xiaowuye (opens new window) 贡献 #25 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 通知公告] 由 @babylazsss (opens new window) 贡献 #26 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 文件管理] 由 @xiaowuye (opens new window) 贡献 #29 (opens new window)、#28 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 字典管理] 由 @Theo (opens new window) 贡献 #38 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 错误码管理] 由 @kinlon92 (opens new window) 贡献 #39 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 岗位管理] 由 @Chika (opens new window) 贡献 #44 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 登录日志] 由 @lour6498 (opens new window) 贡献 #41 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 客户端管理] 由 @yj441106 (opens new window) 贡献 #60 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 错误日志] 由 @oldBaby (opens new window) 贡献 #43 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 访问日志] 由 @oldBaby (opens new window) 贡献 #48 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 代码生成] 由 @xiaowuye (opens new window) 贡献 #68 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 定时任务] 由 @孔思宇 (opens new window) 贡献 #65 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 租户管理] 由 @东方白 (opens new window) 贡献 #40 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 租户套餐] 由 @puhui999 (opens new window) 贡献 #77 (opens new window)、#75 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 短信管理] 由 @puhui999 (opens new window) 贡献 #45 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 部门管理] 由 @凌太虚 (opens new window) 贡献 #36 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 敏感词管理] 由 @syd (opens new window) 贡献 #55 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 菜单管理] 由 @Theo (opens new window) 贡献 #54 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 用户管理] 由 @fessor (opens new window) 贡献 #67 (opens new window)、#76 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 角色管理] 由 @Chika (opens new window) 贡献 #63 (opens new window)、#85 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 站内信消息] 由 @咱哥丶 (opens new window) 贡献 #53 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 站内信消息] 由 @咱哥丶 (opens new window) 贡献 #53 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 账号管理] 由 @kinlon92 (opens new window) 贡献 #49 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 标签管理] 由 @矿泉水 (opens new window) 贡献 #50 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 数据统计] 由 @kinlon92 (opens new window) 贡献 #69 (opens new window)、#72 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 粉丝管理] 由 @dhb52 (opens new window) 贡献 #103 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 消息管理] 由 @&wxr (opens new window) 贡献 #58 (opens new window)、#70 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 图文草稿箱] 由 @dhb52 (opens new window) 贡献 #102 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 素材管理] 由 @dhb52 (opens new window) 贡献 #105 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 自动回复] 由 @dhb52 (opens new window) 贡献 #110 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品分类] 由 @孔思宇 (opens new window) 贡献 #82 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品属性] 由 @孔思宇 (opens new window) 贡献 #83 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品品牌] 由 @Aix (opens new window) 贡献 #104 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 商户信息] 由 @凌太虚 (opens new window) 贡献 #81 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 应用信息] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 支付订单] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 退款订单] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 我的流程] 由 @Chika (opens new window) 贡献 #93 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 已办任务] 由 @Chika (opens new window) 贡献 #90 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 待办任务] 由 @Chika (opens new window) 贡献 #93 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 请假查询] 由 @ZanGe丶 (opens new window) 贡献 #108 (opens new window) 【新增】Vue3 管理后台:增加全局权限判断函数 checkPermi 和 checkRole,由 @LinkLi (opens new window) 贡献 #22 (opens new window) 【新增】字典数据 starter 模块单元测试,由 @与或非 (opens new window) 贡献 #440 (opens new window) 【新增】多租住 Job 部分的单元测试,由 @与或非 (opens new window) 贡献 #27 (opens new window) 【优化】校验手机号码是否正确的正则,由 @冰是睡着的水 (opens new window) 贡献 #447 (opens new window) 【新增】PasswordEncoder 加密复杂度自定义,由 @Fanjc (opens new window) 贡献 #24 (opens new window) 【新增】Vue3 增加 @element-plus/icons-vue 依赖,由 @dhb52 (opens new window) 贡献 #101 (opens new window) 【优化】Vue3 管理后台:增加 Mp 账号 Select 下拉框组件,由 @dhb52 (opens new window) 贡献 #113 (opens new window)、#118 (opens new window) 【优化】Vue3 管理后台:使用 Editor 替代 WxEditor,移除 @vueup/vue-quill 依赖,由 @dhb52 (opens new window) 贡献 #121 (opens new window) 【优化】Vue3 管理后台:公众号消息独立 MessageTable 等组件,解决消息弹窗不重置的问题,由 @dhb52 (opens new window) 贡献 #121 (opens new window) 【优化】Vue3 管理后台:公众号的素材管理,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #126 (opens new window) 【优化】Vue3 管理后台:公众号的自动回复,拆分 ReplyTable 列表组件,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的消息回复组件,不同消息拆分不同表单,提升可维护性,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的草稿管理件,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的菜单管理,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue2 管理后台:将工作流的业务表单做为动态组件,直接显示到审批页面,不再需要点击查看,由 @疯狂的世界 (opens new window) 贡献 #432 (opens new window) 【优化】Vue3 管理后台:将工作流的业务表单做为动态组件,直接显示到审批页面,不再需要点击查看,由 @puhui999 (opens new window) 贡献 #130 (opens new window) 【重构】Vue3 管理后台:给所有组件添加 name 属性预防未知 bug!!! 由 @puhui999 (opens new window) 贡献 #125 (opens new window) # 🐞 Bug Fixes 【修复】Flowable 无法自动建表问题,由 @LinkLi (opens new window) 贡献 #427 (opens new window) 【修复】Vue3 管理后台:包含字典表的页面加载时报错,由 @毕梅 (opens new window) 贡献 #21 (opens new window) 【修复】Vue3 管理后台:ProcessDesigner.vue 编译错误(eslint),由 @孔思宇 (opens new window) 贡献 #23 (opens new window) 【修复】积木报告建表语句错误,由 @疯狂的世界 (opens new window) 贡献 #430 (opens new window) 【修复】基于 Spring Cloud Bus 实现的 Producer 抽象类,获取自己服务实例时获取不到,由 @Lee.J.Eric (opens new window) 贡献 #26 (opens new window) 【修复】修复某些情况下 ContextHolder 的 NPE 异常,由 @xuing (opens new window) 贡献 #225 (opens new window) 【修复】生成代码测试里面的时间问题(buildBetweenTime 方法),由 @xiaohe4966 (opens new window) 贡献 #228 (opens new window) 【修复】Vue3 管你后台的各种验收 bug,由 @周建 (opens new window) 贡献 #32 (opens new window)、#51 (opens new window)、#56 (opens new window)、#71 (opens new window)、#84 (opens new window) 【修复】PostgreSQLSQL 的 system_menu 表缺少 component_name、always_show 字段、缺少 system_mail_account、system_mail_log、system_mail_template、system_notify_message、system_notify_template 表,由 @libran (opens new window) 贡献 #435 (opens new window)、#435 (opens new window)、#436 (opens new window)、#437 (opens new window) 【修复】订单的创建时间差 8 小时的问题,由 @chop (opens new window) 贡献 #442 (opens new window) 【修复】Vue2 短信验证码登录问题,由 @打听幸福的下落 (opens new window) 贡献 #438 (opens new window) 【修复】工作流的审批任务列表的时间不正确的问题,由 @SuperHao (opens new window) 贡献 #426 (opens new window) 【修复】IP 查询时,因为空格导致异常问题,由 @chasel-jc (opens new window) 贡献 #31 (opens new window) 【修复】Spring Cloud 打包后,无法使用 java -jar 的问题,由 @lovezhike (opens new window) 贡献 #28 (opens new window) 【修复】点击遮罩层弹窗关闭后,页面就操作不了了会一直转圈的问题,由 @puhui999 (opens new window) 贡献 #78 (opens new window) 【修复】设置 vite basePath 后,重新登录跳转路由错误,由 @mgzu (opens new window) 贡献 #89 (opens new window) 【修复】在 Vue3 + Vite4 模块中,使用顶层 await打 包的时候报错,由 @puhui999 (opens new window) 贡献 #78 (opens new window) 【修复】Vue3 公众号素材选择时,获取 FreePublic 出错,以及分页溢出,由 @dhb52 (opens new window) 贡献 #96 (opens new window) 【修复】Vue3 公众号图文显示有误,articles 为数组,由 @dhb52 (opens new window) 贡献 #100 (opens new window) 【修复】xss 请求 Wrapper getAttribute 方法返回错误,由 @zhangxingjia (opens new window) 贡献 #451 (opens new window) 【修复】支付通知的通知 Transaction 不生效的问题,由 @kokoko (opens new window) 贡献 #450 (opens new window) 【修复】修复工作流创建流程时,流程名可能不存在的问题,由 @xushu (opens new window) 贡献 #439 (opens new window) 【修复】修复租户名的重复问题,由 @clockdotnet (opens new window) 贡献 #446 (opens new window) 【修复】Vue3 debugger 位置异常,由 @黄爱武 (opens new window) 贡献 #114 (opens new window) 【修复】Vue3 新增或修改菜单时,无法选择菜单图标的 Bug,由 @chongyul (opens new window) 贡献 #2 (opens new window) 【修复】Vue2 管理后台新增租户时,未校验账号、密码是否为空,由 @LiZhongShi (opens new window) 贡献 #456 (opens new window) 【修复】敏感词导出和字典数据编辑保存的两个 BUG,由 @clockdotnet (opens new window) 贡献 #457 (opens new window) 【修复】Vue3 管理后台:用户管理查询入参错误、站内信模板删除 API 调用错误,由 @AhJindeg (opens new window) 贡献 #132 (opens new window) # 🔨 Dependency Upgrades 【升级】knife4j from 4.0.0 to 4.1.0 【升级】spring-boot from 2.7.8 to 2.7.10 【升级】spring-doc 1.6.14 to 1.6.15 【升级】lombok from 1.18.24 to 1.18.26 【升级】druid from 1.2.15 to 1.2.16 【升级】jedis-mock from 1.0.6 to 1.0.7 【升级】hutool from 1.15.3 to 1.15.4 【升级】tika-core from 2.6.0 to 2.7.0 【升级】netty-all from 4.1.86.Final to 4.1.90.Final 【升级】minio from 8.5.1 to 8.5.2 【升级】tencentcloud-sdk-java from 3.1.676 to 3.1.715 【升级】alipay-sdk-java from 4.35.32.ALL to 4.35.79.ALL 【升级】ip-region from 2.6.6 to 2.7.0 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:23 【v1.7.3】开发中 【v1.7.1】2023-03-05 ← 【v1.7.3】开发中 【v1.7.1】2023-03-05→"},{"title":"技术选型","path":"/wiki/YuDaoCloud/萌新必读/技术选型/技术选型.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 技术选型 # 技术架构图 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 👻 后端 # 系统环境 框架 说明 版本 学习指南 JDK Java 开发工具包 >= 1.8.0 书单 (opens new window) Maven Java 管理与构建工具 >= 3.5.0 书单 (opens new window) Nginx 高性能 Web 服务器 - 文档 (opens new window) # 主框架 框架 说明 版本 学习指南 Spring Cloud Alibaba (opens new window) 微服务框架 2021.0.4.0 文档 (opens new window) Spring MVC (opens new window) MVC 框架 5.3.24 文档 (opens new window) Spring Security (opens new window) Spring 安全框架 5.7.6 文档 (opens new window) Hibernate Validator (opens new window) 参数校验组件 6.2.5 文档 (opens new window) # 存储层 框架 说明 版本 学习指南 MySQL (opens new window) 数据库服务器 >= 5.7 书单 (opens new window) Druid (opens new window) JDBC 连接池、监控组件 1.2.14 文档 (opens new window) MyBatis Plus (opens new window) MyBatis 增强工具包 3.5.3.1 文档 (opens new window) Dynamic Datasource (opens new window) 动态数据源 3.6.1 文档 (opens new window) Redis (opens new window) key-value 数据库 >= 5.0 书单 (opens new window) Redisson (opens new window) Redis 客户端 3.17.7 文档 (opens new window) # 中间件 框架 说明 版本 学习指南 Nacos (opens new window) 配置中心 & 注册中心 2.0.4 文档 (opens new window) RocketMQ (opens new window) 消息队列 4.9.4 文档 (opens new window) Sentinel (opens new window) 服务保障 1.8.6 文档 (opens new window) XXL Job (opens new window) 定时任务 2.3.1 文档 (opens new window) Spring Cloud Gateway (opens new window) 服务网关 3.4.1 文档 (opens new window) Seata (opens new window) 分布式事务 1.6.1 文档 (opens new window) Flowable (opens new window) 工作流引擎 6.7.2 文档 (opens new window) # 系统监控 框架 说明 版本 学习指南 Spring Boot Admin (opens new window) Spring Boot 监控平台 2.6.10 文档 (opens new window) SkyWalking (opens new window) 分布式应用追踪系统 8.5.0 文档 (opens new window) # 单元测试 框架 说明 版本 学习指南 JUnit (opens new window) Java 单元测试框架 5.8.2 - Mockito (opens new window) Java Mock 框架 4.8.0 - # 其它工具 框架 说明 版本 学习指南 Springdoc (opens new window) Swagger 文档 1.6.15 文档 (opens new window) Jackson (opens new window) JSON 工具库 2.13.3 MapStruct (opens new window) Java Bean 转换 1.5.3.Final 文档 (opens new window) Lombok (opens new window) 消除冗长的 Java 代码 1.18.26 文档 (opens new window) # 👾 前端 # 管理后台(Vue3 + ElementPlus) 框架 说明 版本 Vue (opens new window) vue 框架 3.2.45 Vite (opens new window) 开发与构建工具 4.0.1 Element Plus (opens new window) Element Plus 2.2.26 TypeScript (opens new window) JavaScript 的超集 4.9.4 pinia (opens new window) Vue 存储库 替代 vuex5 2.0.28 vueuse (opens new window) 常用工具集 9.6.0 vxe-table (opens new window) vue 最强表单 4.3.7 vue-i18n (opens new window) 国际化 9.2.2 vue-router (opens new window) vue 路由 4.1.6 windicss (opens new window) 下一代工具优先的 CSS 框架 3.5.6 iconify (opens new window) 在线图标库 3.0.0 wangeditor (opens new window) 富文本编辑器 5.1.23 # 管理后台(Vue3 + Vben + Ant-Design-Vue) 框架 说明 版本 Vue (opens new window) Vue 框架 3.2.47 Vite (opens new window) 开发与构建工具 4.3.0 ant-design-vue (opens new window) ant-design-vue 3.2.17 TypeScript (opens new window) JavaScript 的超集 5.0.4 pinia (opens new window) Vue 存储库 替代 vuex5 2.0.34 vueuse (opens new window) 常用工具集 9.13.0 vue-i18n (opens new window) 国际化 9.2.2 vue-router (opens new window) Vue 路由 4.1.6 windicss (opens new window) 下一代工具优先的 CSS 框架 3.5.6 iconify (opens new window) 在线图标库 3.1.0 # 管理后台(Vue2) 框架 说明 版本 学习指南 Node (opens new window) JavaScript 运行时环境 >= 12 - Vue (opens new window) JavaScript 框架 2.7.14 书单 (opens new window) Vue Element Admin (opens new window) 后台前端解决方案 2.5.10 # 管理后台(uni-app) 框架 说明 版本 uni-app 跨平台框架 2.0.0 uni-ui (opens new window) 基于 uni-app 的 UI 框架 1.4.20 # 用户 App 框架 说明 版本 学习指南 Vue (opens new window) JavaScript 框架 2.6.12 书单 (opens new window) UniApp (opens new window) 小程序、H5、App 的统一框架 - - .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 23:29:24 接口文档 项目结构 ← 接口文档 项目结构→"},{"title":"接口文档","path":"/wiki/YuDaoCloud/萌新必读/接口文档/接口文档.html","content":"开发指南萌新必读 芋道源码 2022-03-26 目录 接口文档 项目使用 Swagger 实现 RESTful API 的接口文档,提供两种解决方案: *【推荐】 Apifox (opens new window):强大的 API 工具,支持 API 文档、API 调试、API Mock、API 自动化测试 Knife4j:简易的 API 工具,仅支持 API 文档、API 调试 为什么选择 Swagger 呢? Swagger 通过 Java 注解实现 API 接口文档的编写。相比使用 Java 注释的方式,注解提供更加规范的接口定义方式,开发体验更好。 如果你没有学习 Swagger,可以阅读 《芋道 Spring Boot API 接口文档 Swagger 入门 》 (opens new window) 文章。 每个服务都会启动 Swagger 的接口文档,方便开发者进行 API 调试。下述的内容,使用 system-server 系统服务举例子,它的端口是 48081。 注意!注意!注意!文章部分图中,看到的是 48080 端口,实际你都填写 48081。 # 1. Apifox 使用 本小节,我们来将项目中的 API 接口,一键导入到 Apifox 中,并使用它发起一次 API 的调用。 # 1.1 下载工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 # 1.2 API 导入 ① 先点击「示例项目」,再点击「+」按钮,选择「导入」选项。 ② 先选择「URL 导入」按钮,填写 Swagger 数据 URL 为 http://127.0.0.1:48081/v3/api-docs。 ③ 先点击「提交」按钮,再点击「确认导入」按钮,完成 API 接口的导入。 ④ 导入完成后,点击「接口管理」按钮,可以查看到 API 列表。 # 1.3 API 调试 ① 先点击右上角「请选择环境」,再点击「管理环境」选项,填写测试环境的地址为 http://127.0.0.1:48081,并进行保存。 ② 点击「管理后台 —— 认证」的「使用账号密码登录」接口,查看该 API 接口的定义。 ③ 点击「运行」按钮,填写 Headers 的 tenant-id 为 1,再点击 Body 的「自动生成」按钮,最后点击「发送」按钮。 # 2. Knife4j 使用 浏览器访问 http://127.0.0.1:48081/doc.html (opens new window) 地址,使用 Knife4j 查看 API 接口文档。 ① 点击任意一个接口,进行接口的调用测试。这里,使用「管理后台 - 用户个中心」的“获得登录用户信息”举例子。 ② 点击左侧「调试」按钮,并将请求头部的 header-id 和 Authorization 勾选上。 其中,header-id 为租户编号,Authorization 的 \"Bearer test\" 后面为用户编号(模拟哪个用户操作)。 ③ 点击「发送」按钮,即可发起一次 API 的调用。 如何使用 Gateway 网关,聚合各个服务的接口文档? 参见 《微服务手册 —— 服务网关》 文档 # 3. Swagger 技术组件 ① 在 yudao-spring-boot-starter-web (opens new window) 技术组件的 swagger (opens new window) 包,实现了对 Swagger 的封装。 ② 如果想要禁用 Swagger 功能,可通过 springdoc.api-docs.enable 配置项为 false。一般情况下,建议 prod 生产环境进行禁用,避免发生安全问题。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/05, 12:34:30 快速启动(适合“前端”工程师) 技术选型 ← 快速启动(适合“前端”工程师) 技术选型→"},{"title":"简介","path":"/wiki/YuDaoCloud/萌新必读/简介/简介.html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 简介 yudao-cloud (opens new window),RuoYi-Vue 全新 Cloud 版本,优化重构所有功能。 基于 Spring Cloud Alibaba + MyBatis Plus + Vue & Element 实现的后台管理系统 + UniApp 微信小程序,支持 RBAC 动态权限、数据权限、SaaS 多租户、Activiti + Flowable 工作流、三方登录、支付、短信、商城等功能。 (opens new window) (opens new window) 😆 为开源继绝学,我辈义不容辞! 2017 年,艿艿创建「芋道源码」公众号,帮助了 20w+ 工程师学习优秀框架的源码。 2019 年,看了 Gitee 和 Github 非常多的业务开源项目,无法到达代码整洁、架构整洁。 于是,艿艿利用休息时间,每天肝到晚上 1 点多,如此便有了芋道管理后台 + 微信小程序。 # 🐴 严肃声明 现在、未来都不会有商业版本,所有代码全部开源! 「我喜欢写代码,乐此不疲」 「我喜欢做开源,以此为乐」 我 🐶 在上海艰苦奋斗,早中晚在 top3 大厂认真搬砖,夜里为开源做贡献。 如果这个项目让你有所收获,记得 Star 关注哦,这对我是非常不错的鼓励与支持。 # 🐳 项目关系 三个项目的功能对比,可见社区共同整理的 国产开源项目对比 (opens new window) 表格。 # 后端项目 项目 Star 简介 ruoyi-vue-pro (opens new window) (opens new window) (opens new window) 基于 Spring Boot 多模块架构 yudao-cloud (opens new window) (opens new window) (opens new window) 基于 Spring Cloud 微服务架构 Spring-Boot-Labs (opens new window) (opens new window) (opens new window) 系统学习 Spring Boot & Cloud 专栏 # 前端项目 项目 Star 简介 yudao-ui-admin-vue3 (opens new window) (opens new window) (opens new window) 基于 Vue3 + element-plus 实现的管理后台 yudao-ui-admin (opens new window) (opens new window) (opens new window) 基于 Vue2 + element-ui 实现的管理后台 yudao-ui-admin-uniapp (opens new window) (opens new window) (opens new window) 基于 uni-app + uni-ui 实现的管理后台的小程序 yudao-ui-go-view (opens new window) (opens new window) (opens new window) 基于 Vue3 + naive-ui 实现的大屏报表 yudao-ui-app (opens new window) (opens new window) (opens new window) 基于 uni-app + uview 实现的用户 App # 🐶 在线体验 演示地址【Vue3 + element-plus】:http://dashboard-vue3.yudao.iocoder.cn (opens new window) 演示地址【Vue3 + vben(ant-design-vue)】:http://dashboard-vben.yudao.iocoder.cn (opens new window) 演示地址【Vue2 + element-ui】:http://dashboard.yudao.iocoder.cn (opens new window) 如果你要搭建本地环境,可参考如下文档: 《开发指南 —— 快速启动(适合“后端”工程师)》 《开发指南 —— 快速启动(适合“前端”工程师)》 # 📚 国内顶级开源项目对比 社区整理,欢迎补充!传送门 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:23 交流群 交流群→"},{"title":"项目结构","path":"/wiki/YuDaoCloud/萌新必读/项目结构/项目结构.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 项目结构 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 👻 后端结构 后端采用模块化的架构,按照功能拆分成多个 Maven Module,提升开发与研发的效率,带来更好的可维护性。 一共有四类 Maven Module: Maven Module 作用 yudao-dependencies Maven 依赖版本管理 yudao-framework Java 框架拓展 yudao-module-xxx XXX 功能的 Module 模块 yudao-server 管理后台 + 用户 App 的服务端 下面,我们来逐个看看。 # 1. yudao-dependencies 该模块是一个 Maven Bom,只有一个 pom.xml (opens new window) 文件,定义项目中所有 Maven 依赖的版本号,解决依赖冲突问题。 详细的解释,可见 《微服务中使用 Maven BOM 来管理你的版本依赖 》 (opens new window) 文章。 从定位上来说,它和 Spring Boot 的 spring-boot-starter-parent (opens new window) 和 Spring Cloud 的 spring-cloud-dependencies (opens new window) 是一致的。 实际上,ruoyi-vue-pro 本质上还是个单体项目,直接在根目录 pom.xml (opens new window) 管理依赖版本会更加方便,也符合绝大多数程序员的认知。但是要额外考虑一个场景,如果每个 yudao-module-xxx 模块都维护在一个独立的 Git 仓库,那么 yudao-dependencies 就可以在多个 yudao-module-xxx 模块下复用。 # 2. yudao-framework 该模块是 ruoyi-vue-pro 项目的框架封装,其下的每个 Maven Module 都是一个组件,分成两种类型: ① 技术组件:技术相关的组件封装,例如说 MyBatis、Redis 等等。 Maven Module 作用 yudao-common 定义基础 pojo 类、枚举、工具类等 yudao-spring-boot-starter-web Web 封装,提供全局异常、访问日志等 yudao-spring-boot-starter-security 认证授权,基于 Spring Security 实现 yudao-spring-boot-starter-mybatis 数据库操作,基于 MyBatis Plus 实现 yudao-spring-boot-starter-redis 缓存操作,基于 Spring Data Redis + Redisson 实现 yudao-spring-boot-starter-rpc 服务调用,基于 Feign 实现,也可以选择 Dubbo yudao-spring-boot-starter-mq 消息队列,基于 RocketMQ 实现,支持集群消费和广播消费 yudao-spring-boot-starter-job 定时任务,基于 XXL Job 实现,支持集群模式 yudao-spring-boot-starter-env 多环境,实现类似阿里的特性环境的能力 yudao-spring-boot-starter-flowable 工作流,基于 Flowable 实现 yudao-spring-boot-starter-protection 服务保障,基于 Sentinel 实现,提供幂等、分布式锁、限流、熔断等功能 yudao-spring-boot-starter-file 文件客户端,支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、SFTP、数据库等 yudao-spring-boot-starter-excel Excel 导入导出,基于 EasyExcel 实现 yudao-spring-boot-starter-monitor 服务监控,提供链路追踪、日志服务、指标收集等功能 yudao-spring-boot-starter-captcha 验证码 Captcha,提供滑块验证码 yudao-spring-boot-starter-test 单元测试,基于 Junit + Mockito 实现 yudao-spring-boot-starter-banner 控制台 Banner,启动打印各种提示 yudao-spring-boot-starter-desensitize 脱敏组件:支持 JSON 返回数据时,将邮箱、手机等字段进行脱敏 ② 业务组件:业务相关的组件封装,例如说数据字典、操作日志等等。如果是业务组件,名字会包含 biz 关键字。 Maven Module 作用 yudao-spring-boot-starter-biz-tenant SaaS 多租户 yudao-spring-boot-starter-biz-data-permissionn 数据权限 yudao-spring-boot-starter-biz-dict 数据字典 yudao-spring-boot-starter-biz-operatelog 操作日志 yudao-spring-boot-starter-biz-pay 支付客户端,对接微信支付、支付宝等支付平台 yudao-spring-boot-starter-biz-sms 短信客户端,对接阿里云、腾讯云等短信服务 yudao-spring-boot-starter-biz-social 社交客户端,对接微信公众号、小程序、企业微信、钉钉等三方授权平台 yudao-spring-boot-starter-biz-weixin 微信客户端,对接微信的公众号、开放平台等 yudao-spring-boot-starter-biz-error-code 全局错误码 yudao-spring-boot-starter-biz-ip 地区 & IP 库 每个组件,包含两部分: core 包:组件的核心封装,拓展相关的功能。 config 包:组件的 Spring Boot 自动配置。 # 3. yudao-module-xxx 该模块是 XXX 功能的 Module 模块,目前内置了 8 个模块。 项目 说明 是否必须 yudao-module-system 系统功能 √ yudao-module-infra 基础设施 √ yudao-module-member 会员中心 x yudao-module-bpm 工作流程 x yudao-module-pay 支付系统 x yudao-module-report 大屏报表 x yudao-module-mall 商城系统 x yudao-module-mp 微信公众号 x 每个模块包含两个 Maven Module,分别是: Maven Module 作用 yudao-module-xxx-api 提供给其它模块的 API 定义 yudao-module-xxx-biz 模块的功能的具体实现 例如说,yudao-module-infra 想要访问 yudao-module-system 的用户、部门等数据,需要引入 yudao-module-system-api 子模块。示例如下: yudao-module-xxx-api 子模块的项目结构如下: 所在包 类 作用 示例 api Api 接口 提供给其它模块的 API 接口 AdminUserApi (opens new window) api DTO 类 Api 接口的入参 ReqDTO、出参 RespDTO LoginLogCreateReqDTO (opens new window) DeptRespDTO (opens new window) enums Enum 类 字段的枚举 LoginLogTypeEnum (opens new window) enums DictTypeConstants 类 数据字典的枚举 DictTypeConstants (opens new window) enums ErrorCodeConstants 类 错误码的枚举 ErrorCodeConstants (opens new window) yudao-module-xxx-biz 子模块的项目结构如下: 所在包 类 作用 示例 api ApiImpl 类 提供给其它模块的 API 实现类 AdminUserApiImpl (opens new window) controler.admin Controller 类 提供给管理后台的 RESTful API,默认以 admin-api/ 作为前缀。 例如 admin-api/system/auth/login 登录接口 AuthController (opens new window) controler.admin VO 类 Admin Controller 接口的入参 ReqVO、出参 RespVO AuthLoginReqVO (opens new window) AuthLoginRespVO (opens new window) controler.app Controller 类,以 App 为前缀 提供给用户 App 的 RESTful API,默认以 app-api/ 作为前缀。 例如 app-api/member/auth/login 登录接口 AppAuthController (opens new window) controler.app VO 类,以 App 为前缀 App Controller 接口的入参 ReqVO、出参 RespVO AppAuthLoginReqVO (opens new window) AppAuthLoginRespVO (opens new window) controler .http 文件 IDEA Http Client 插件 (opens new window),模拟请求 RESTful 接口 AuthController.http (opens new window) service Service 接口 业务逻辑的接口定义 AdminUserService (opens new window) service ServiceImpl 类 业务逻辑的实现类 AdminUserServiceImpl (opens new window) dal - Data Access Layer,数据访问层 dal.dataobject DO 类 Data Object,映射数据库表、或者 Redis 对象 AdminUserDO (opens new window) dal.mysql Mapper 接口 数据库的操作 AdminUserMapper (opens new window) dal.redis RedisDAO 类 Redis 的操作 LoginUserRedisDAO (opens new window) convert Convert 接口 DTO / VO / DO 等对象之间的转换器 UserConvert (opens new window) job Job 类 定时任务 UserSessionTimeoutJob (opens new window) mq - Message Queue,消息队列 mq.message Message 类 发送和消费的消息 DeptRefreshMessage (opens new window) mq.producer Producer 类 消息的生产者 DeptProducer (opens new window) mq.consumer Producer 类 消息的消费者 DeptRefreshConsumer (opens new window) framework - 模块自身的框架封装 framework (opens new window) 疑问:为什么 Controller 分成 Admin 和 App 两种? 提供给 Admin 和 App 的 RESTful API 接口是不同的,拆分后更加清晰。 疑问:为什么 VO 分成 Admin 和 App 两种? 相同功能的 RESTful API 接口,对于 Admin 和 App 传入的参数、返回的结果都可能是不同的。例如说,Admin 查询某个用户的基本信息时,可以返回全部字段;而 App 查询时,不会返回 mobile 手机等敏感字段。 疑问:为什么 DO 不作为 Controller 的出入参? 明确每个 RESTful API 接口的出入参。例如说,创建部门时,只需要传入 name、parentId 字段,使用 DO 接参就会导致 type、createTime、creator 等字段可以被传入,导致前端同学一脸懵逼。 每个 RESTful API 有自己独立的 VO,可以更好的设置 Swagger 注解、Validator 校验规则,而让 DO 保持整洁,专注映射好数据库表。 疑问:为什么操作 Redis 需要通过 RedisDAO? Service 直接使用 RedisTemplate 操作 Redis,导致大量 Redis 的操作细节和业务逻辑杂糅在一起,导致代码不够整洁。通过 RedisDAO 类,将每个 Redis Key 像一个数据表一样对待,清晰易维护。 总结来说,每个模块采用三层架构 + 非严格分层,如下图所示: # 4. yudao-server 该模块是后端 Server 的主项目,通过引入需要 yudao-module-xxx 业务模块,从而实现提供 RESTful API 给 yudao-ui-admin、yudao-ui-user 等前端项目。 本质上来说,它就是个空壳(容器)!如下图所示: # 👾 前端结构 前端一共有六个项目,分别是: 项目 说明 yudao-ui-admin-vue3 (opens new window) 基于 Vue3 + element-plus 实现的管理后台 yudao-ui-admin-vben (opens new window) 基于 Vue3 + vben(ant-design-vue) 实现的管理后台 yudao-ui-admin 基于 Vue2 + element-ui 实现的管理后台 yudao-ui-go-view (opens new window) 基于 Vue3 + naive-ui 实现的大屏报表 yudao-ui-admin-uniapp 基于 uni-app + uni-ui 实现的管理后台的小程序 yudao-ui-app 基于 uni-app + uview 实现的用户 App # 1. yudao-admin-ui-vue3 .├── .github # github workflows 相关├── .husky # husky 配置├── .vscode # vscode 配置├── mock # 自定义 mock 数据及配置├── public # 静态资源├── src # 项目代码│ ├── api # api接口管理│ ├── assets # 静态资源│ ├── components # 公用组件│ ├── hooks # 常用hooks│ ├── layout # 布局组件│ ├── locales # 语言文件│ ├── plugins # 外部插件│ ├── router # 路由配置│ ├── store # 状态管理│ ├── styles # 全局样式│ ├── utils # 全局工具类│ ├── views # 路由页面│ ├── App.vue # 入口vue文件│ ├── main.ts # 主入口文件│ └── permission.ts # 路由拦截├── types # 全局类型├── .env.base # 本地开发环境 环境变量配置├── .env.dev # 打包到开发环境 环境变量配置├── .env.gitee # 针对 gitee 的环境变量 可忽略├── .env.pro # 打包到生产环境 环境变量配置├── .env.test # 打包到测试环境 环境变量配置├── .eslintignore # eslint 跳过检测配置├── .eslintrc.js # eslint 配置├── .gitignore # git 跳过配置├── .prettierignore # prettier 跳过检测配置├── .stylelintignore # stylelint 跳过检测配置├── .versionrc 自动生成版本号及更新记录配置├── CHANGELOG.md # 更新记录├── commitlint.config.js # git commit 提交规范配置├── index.html # 入口页面├── package.json├── .postcssrc.js # postcss 配置├── prettier.config.js # prettier 配置├── README.md # 英文 README├── README.zh-CN.md # 中文 README├── stylelint.config.js # stylelint 配置├── tsconfig.json # typescript 配置├── vite.config.ts # vite 配置└── windi.config.ts # windicss 配置 # 2. yudao-ui-admin-vben .├── build # 打包脚本相关│ ├── config # 配置文件│ ├── generate # 生成器│ ├── script # 脚本│ └── vite # vite配置├── mock # mock文件夹├── public # 公共静态资源目录├── src # 主目录│ ├── api # 接口文件│ ├── assets # 资源文件│ │ ├── icons # icon sprite 图标文件夹│ │ ├── images # 项目存放图片的文件夹│ │ └── svg # 项目存放svg图片的文件夹│ ├── components # 公共组件│ ├── design # 样式文件│ ├── directives # 指令│ ├── enums # 枚举/常量│ ├── hooks # hook│ │ ├── component # 组件相关hook│ │ ├── core # 基础hook│ │ ├── event # 事件相关hook│ │ ├── setting # 配置相关hook│ │ └── web # web相关hook│ ├── layouts # 布局文件│ │ ├── default # 默认布局│ │ ├── iframe # iframe布局│ │ └── page # 页面布局│ ├── locales # 多语言│ ├── logics # 逻辑│ ├── main.ts # 主入口│ ├── router # 路由配置│ ├── settings # 项目配置│ │ ├── componentSetting.ts # 组件配置│ │ ├── designSetting.ts # 样式配置│ │ ├── encryptionSetting.ts # 加密配置│ │ ├── localeSetting.ts # 多语言配置│ │ ├── projectSetting.ts # 项目配置│ │ └── siteSetting.ts # 站点配置│ ├── store # 数据仓库│ ├── utils # 工具类│ └── views # 页面├── test # 测试│ └── server # 测试用到的服务│ ├── api # 测试服务器│ ├── upload # 测试上传服务器│ └── websocket # 测试ws服务器├── types # 类型文件├── vite.config.ts # vite配置文件└── windi.config.ts # windcss配置文件 # 3. yudao-admin-ui ├── bin // 执行脚本├── build // 构建相关 ├── public // 公共文件│ ├── favicon.ico // favicon 图标│ └── index.html // html 模板│ └── robots.txt // 反爬虫├── src // 源代码│ ├── api // 所有请求【重要】│ ├── assets // 主题、字体等静态资源│ ├── components // 全局公用组件│ ├── directive // 全局指令│ ├── icons // 图标│ ├── layout // 布局│ ├── plugins // 插件│ ├── router // 路由│ ├── store // 全局 store 管理│ ├── utils // 全局公用方法│ ├── views // 视图【重要】│ ├── App.vue // 入口页面│ ├── main.js // 入口 JS,加载组件、初始化等│ ├── permission.js // 权限管理│ └── settings.js // 系统配置├── .editorconfig // 编码格式├── .env.development // 开发环境配置├── .env.production // 生产环境配置├── .env.staging // 测试环境配置├── .eslintignore // 忽略语法检查├── .eslintrc.js // eslint 配置项├── .gitignore // git 忽略项├── babel.config.js // babel.config.js├── package.json // package.json└── vue.config.js // vue.config.js # 4. yudao-admin-ui-uniapp TODO 待补充 # 5. yudao-ui-app 建设中,基于 uniapp 实现... # 6. yudao-ui-go-view TODO 待补充 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 23:29:24 技术选型 代码热加载 ← 技术选型 代码热加载→"},{"title":"CRUD 组件","path":"/wiki/YuDaoCloud/前端手册 Vue 3/CRUD 组件/CRUD 组件.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-05 目录 CRUD 组件 管理后台的功能,一般就是 CRUD 增删改查,可以拆分 3 个部分:“列表”、“新增/修改”、“详情”,如下图所示: 部分 组件 示例 列表 Search + Table 新增 / 修改 Form 详情 Descriptions # 1. 基础组件 涉及到 4 个前端基础组件,如下所示: 组件 文档 Search (opens new window) 查询组件 (opens new window) Table (opens new window) 表格组件 (opens new window) Form (opens new window) 表单组件 (opens new window) Descriptions (opens new window) 描述组件 (opens new window) # 2. CRUD 组件 由于以上 4 个组件都需要 Schema 或者 columns 的字段,如果每个组件都写一遍的话,会造成大量重复代码,所以提供 useCrudSchemas 来进行统一的数据生成。 ① useCrudSchemas:位于 src/hooks/web/useCrudSchemas.ts (opens new window) 内 ② useCrudSchemas 可以理解成一个 JSON 配置,示例如下: useCrudSchemas 示例 <script setup lang="ts">import { CrudSchema, useCrudSchemas } from '@/hooks/web/useCrudSchemas'const crudSchemas = reactive<CrudSchema[]>([ { field: 'index', label: t('tableDemo.index'), type: 'index', form: { show: false }, detail: { show: false } }, { field: 'title', label: t('tableDemo.title'), search: { show: true }, form: { colProps: { span: 24 } }, detail: { span: 24 } }, { field: 'author', label: t('tableDemo.author') }, { field: 'display_time', label: t('tableDemo.displayTime'), form: { component: 'DatePicker', componentProps: { type: 'datetime', valueFormat: 'YYYY-MM-DD HH:mm:ss' } } }, { field: 'importance', label: t('tableDemo.importance'), formatter: (_: Recordable, __: TableColumn, cellValue: number) => { return h( ElTag, { type: cellValue === 1 ? 'success' : cellValue === 2 ? 'warning' : 'danger' }, () => cellValue === 1 ? t('tableDemo.important') : cellValue === 2 ? t('tableDemo.good') : t('tableDemo.commonly') ) }, form: { component: 'Select', componentProps: { options: [ { label: '重要', value: 3 }, { label: '良好', value: 2 }, { label: '一般', value: 1 } ] } } }, { field: 'pageviews', label: t('tableDemo.pageviews'), form: { component: 'InputNumber', value: 0 } }, { field: 'content', label: t('exampleDemo.content'), table: { show: false }, form: { component: 'Editor', colProps: { span: 24 } }, detail: { span: 24 } }, { field: 'action', width: '260px', label: t('tableDemo.action'), form: { show: false }, detail: { show: false } }])const { allSchemas } = useCrudSchemas(crudSchemas)</script> ③ 字段的详细说明,可见 useCrudSchemas 文档 (opens new window)。 # 3. 实战案例 项目的 [系统管理 -> 邮箱管理] 相关的功能,都使用 CRUD 实现,你可以自己去学习。 功能 代码 邮箱账号 src/views/system/mail/account (opens new window) 邮箱模版 src/views/system/mail/template (opens new window) 邮箱记录 src/views/system/mail/log (opens new window) # 4. 常见问题 # 4.1 如何隐藏某个字段? 如 formSchema 不需要 field 为 createTime 的字段,可以使用 form: { show: false } 或 isForm: false 进行过滤,其他组件同理。 # 4.2 如何使用数据字典? 设置 dictType 字典的类型,和 dictClass 字典的数据类型。 # 4.3 如何使用 API 获取数据? 使用 api 来获取接口数据,需要主动 return 数据。 # 4.4 如何结合 Slot 自定义? 如果想要自定义,可以结合 Slot 来实现。具体有哪些 Slot,阅读对应基础组件的文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/05, 22:46:17 配置读取 国际化 ← 配置读取 国际化→"},{"title":"快速启动(适合“后端”工程师)","path":"/wiki/YuDaoCloud/萌新必读/快速启动(适合“后端”工程师)/快速启动(适合“后端”工程师).html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 快速启动(适合“后端”工程师) 目标:使用 IDEA 工具,将后端项目 yudao-cloud (opens new window) 运行起来,并按需启动前端项目。 整个过程非常简单,预计 30 分钟就可以完成,取决于大家的网速。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ # 👍 相关视频教程 从零开始 02:在 Windows 环境下,如何运行前后端项目? (opens new window) 从零开始 03:在 MacOS 环境下,如何运行前后端项目? (opens new window) # 1. 克隆代码 使用 IDEA (opens new window) 克隆 https://github.com/YunaiV/yudao-cloud (opens new window) 仓库的最新代码,并给该仓库一个 Star (opens new window)。 友情提示:IDEA 请使用至少 2020 版本,不知道怎么激活的可以看看 《IDEA 破解新招 - 无限重置30天试用期(适用于 2018、2019、2020、2021 所有版本) 》 (opens new window) 文章! 注意:不支持使用 Eclipse 启动项目,因为它没有支持 Lombok 和 Mapstruct 的插件。 克隆完成后,耐心等待 Maven 下载完相关的依赖。 友情提示:项目的每个模块的作用,可见 《开发指南 —— 项目结构》 文档。 使用的 Spring Cloud 版本较新,所以需要下载一段时间。趁着这个时间,胖友可以给项目添加一个 Star (opens new window),支持下艿艿。 # 2. Apifox 接口工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 接口文档? 阅读 《开发指南 —— 接口文档》 呀~~ # 3. 基础设施(必选) 本小节的基础设施【必须】安装,否则项目无法启动。 # 3.1 初始化 MySQL 友情提示? 如果你是 PostgreSQL、Oracle、SQL Server 等其它数据库,也是可以的。 因为我主要使用 MySQL数据库为主,所以其它数据库的 SQL 文件可能存在滞后,可以加入 用户群 反馈。 补充说明? 由于工作较忙,暂时未拆分到多个数据库,可以按照前缀自行处理: system_ 前缀,属于 yudao-module-system 服务 infra_ 前缀,属于 yudao-module-infra 服务 项目使用 MySQL 存储数据,所以需要启动一个 MySQL 服务,建议使用 5.7 版本。 ① 创建一个名字为 ruoyi-vue-pro 数据库,执行对应数据库类型的 sql (opens new window) 目录下的 SQL 文件,进行初始化。 ② 默认配置下,MySQL 需要启动在 3306 端口,并且账号是 root,密码是 123456。如果不一致,需要修改 application-local.yaml 配置文件。 # 3.2 初始化 Redis 项目使用 Redis 缓存数据,所以需要启动一个 Redis 服务。 一定要使用 5.0 以上的版本,项目使用 Redis Stream 作为消息队列。 不会安装的胖友,可以选择阅读下文,良心的艿艿。 Windows 安装 Redis 指南:http://www.iocoder.cn/Redis/windows-install (opens new window) Mac 安装 Redis 指南:http://www.iocoder.cn/Redis/mac-install (opens new window) 默认配置下,Redis 启动在 6379 端口,不设置账号密码。如果不一致,需要修改 application-local.yaml 配置文件。 # 3.3 初始化 Nacos 项目使用 Nacos 作为注册中心和配置中心,参考 《芋道 Nacos 极简入门》 (opens new window) 文章,进行安装,只需要看该文的 「2. 单机部署(最简模式)」 即可。 安装完成之后,需要创建 dev 命名空间,如下图所示: Nacos 拓展学习资料: 《芋道 Spring Cloud Alibaba 配置中心 Nacos 入门》 (opens new window) 对应 labx-05-spring-cloud-alibaba-nacos-config (opens new window) 《芋道 Spring Cloud Alibaba 注册中心 Nacos 入门》 (opens new window) 对应 labx-01-spring-cloud-alibaba-nacos-discovery (opens new window) # 4. 基础设施(可选) 本小节的基础设施【可选】安装,不影响项目的启动,可在项目启动后再安装。 # 4.1 RocketMQ 项目使用 RocketMQ 作为消息中心和事件总线,参考 《芋道 RocketMQ 极简入门》 (opens new window) 文章,进行安装,只需要看该文的 「2. 单机部署」 即可。 Seata 拓展学习资料: 《芋道 Spring Cloud Alibaba 消息队列 RocketMQ 入门》 (opens new window) 对应 labx-06-spring-cloud-stream-rocketmq (opens new window) 《芋道 Spring Cloud Alibaba 事件总线 Bus RocketMQ 入门》 (opens new window) 对应 labx-06-spring-cloud-stream-rocketmq (opens new window) 《性能测试 —— RocketMQ 基准测试》 (opens new window) # 4.2 XXL-Job ① 项目使用 XXL-Job 作为定时任务,参考 《芋道 XXL-Job 极简入门》 (opens new window) 文章,进行安装,只需要看该文的 「4. 搭建调度中心」 即可。 注意,需要修改 application.yaml 配置文件,修改 server.port 为 9090。 ② 默认配置下,本地 local 环境的定时任务是关闭的,避免控制台一直报错报错。如果要开启,请参考 《微服务手册 —— 定时任务》 文档。 # 4.3 Seata TODO 暂时忽略,后续版本引入 Seata 拓展学习资料: 《芋道 Spring Cloud Alibaba 分布式事务 Seata 入门 》 (opens new window) 对应 对应 labx-17 (opens new window) # 4.4 Sentinel TODO 暂时忽略,后续版本引入 Sentinel 拓展学习资料: 《芋道 Spring Cloud Alibaba 服务容错 Sentinel 入门 》 (opens new window) 对应 labx-04-spring-cloud-alibaba-sentinel (opens new window) # 4.5 Elasticsearch TODO 暂时忽略,后续版本引入 Elasticsearch 拓展学习资料: 《芋道 Spring Boot Elasticsearch 入门》 (opens new window) 《芋道 ELK(Elasticsearch + Logstash + Kibana) 极简入门》 (opens new window) # 5. 启动后端项目 # 5.1 编译项目 使用 IDEA 打开 Terminal 终端,在根目录下直接执行 mvn clean install package '-Dmaven.test.skip=true' 命令,将项目进行初始化的打包,预计需要 1 分钟左右。成功后,控制台日志如下: [INFO] BUILD SUCCESS[INFO] ------------------------------------------------------------------------[INFO] Total time: 01:12 min[INFO] Finished at: 2022-02-12T09:52:38+08:00[INFO] Final Memory: 250M/2256M[INFO] ------------------------------------------------------------------------ JDK 版本的选择? 如下的 JDK 版本,是艿艿在本地测试通过的 JDK 8 版本:尽量保证 >= 1.8.0_144 JDK 11 版本:尽量保证 >= 11.0.14 JDK 17 版本:尽量保证 >= 17.0.2 如果 JDK 版本过低,包括 JDK 的小版本过低,也会 mvn 编译报错。例如说: “编译器(1.8.0_40)中出现编译错误“。此处,升级下 JDK 版本即可。 Maven 补充说明: ① 只有首次需要执行 Maven 命令,解决基础 pom.xml 文件不存在,导致报 BaseDbUnitTest 类不存在的问题。 ② 如果执行报 Unknown lifecycle phase “.test.skip=true” 错误,使用 mvn clean install package -Dmaven.test.skip=true 即可。 # 5.2 启动 gateway 服务 执行 GatewayServerApplication (opens new window) 类,进行启动。 启动还是报类不存在? 可能是 IDEA 的 bug,点击 [File -> Invalidate Caches] 菜单,清空下缓存,重启后在试试看。 启动完成后,使用浏览器访问 http://127.0.0.1:48080 (opens new window) 地址,返回如下 JSON 字符串,说明成功。 友情提示:注意,默认配置下,网关启动在 48080 端口。 {"code":404,"data":null,"msg":null} 如果报 “Command line is too long” 错误,参考 《Intellij IDEA 运行时报 Command line is too long 解决方法 》 (opens new window) 文章解决,或者直接点击 YudaoServerApplication 蓝字部分! # 5.3 启动 system 服务 执行 SystemServerApplication (opens new window) 类,进行启动。 启动完成后,使用浏览器访问 http://127.0.0.1:48081/admin-api/system/ (opens new window) 和 http://127.0.0.1:48080/admin-api/system/ (opens new window) 地址,都返回如下 JSON 字符串,说明成功。 友情提示:注意,默认配置下,yudao-module-system 服务启动在 48081 端口。 {"code":401,"data":null,"msg":"账号未登录"} # 5.3 启动 infra 服务 执行 InfraServerApplication ( opens new window) 类,进行启动。 启动完成后,使用浏览器访问 http://127.0.0.1:48082/admin-api/infra/ ( opens new window) 和 http://127.0.0.1:48080/admin-api/infra/ ( opens new window) 地址,都返回如下 JSON 字符串,说明成功。 友情提示:注意,默认配置下,yudao-module-infra 服务启动在 48082 端口。 {"code":401,"data":null,"msg":"账号未登录"} # 5.4 启动 bpm 服务 参见 《工作流手册 —— 工作流》 文档。 # 5.5 启动 report 服务 参见 《大屏手册 —— 报表设计器》 文档。 # 5.6 启动 pay 服务 适配中,预计 3 - 4 月份完成。 # 5.7 启动 mall 服务 适配中,预计 6 月份完成。 # 6. 启动前端项目【简易】 在 yudao-ui-static ( opens new window) 项目中,提前编译好了前端项目的静态资源,可以直接体验和使用。操作步骤如下: ① 克隆 https://gitee.com/yudaocode/yudao-ui-static ( opens new window) 项目,运行 UiConfiguration 类,进行启动。 ② 访问 http://127.0.0.1:2048/admin-ui-vue2/ ( opens new window) 地址,可以看到 Vue2 管理后台。 ② 访问 http://127.0.0.1:2048/admin-ui-vue3/ ( opens new window) 地址,可以看到 Vue3 + element-plus 管理后台。 ③ 访问 http://127.0.0.1:2048/admin-ui-vben/ ( opens new window) 地址,可以看到 Vue3 + vben(ant-design-vue) 管理后台。 补充说明: 前端项目是不定期编译,可能不是最新版本。 如果需要最新版本,请继续往下看。 # 7. 启动前端项目【完整】 项目提供了多套前端项目,可以按需启动哈。 友情提示:可能胖友本地没有安装 Node.js 的环境,导致报错。可以参考如下文档安装: Windows 安装 Node.js 指南:http://www.iocoder.cn/NodeJS/windows-install ( opens new window) Mac 安装 Node.js 指南:http://www.iocoder.cn/NodeJS/mac-install ( opens new window) # 7.1 启动 Vue3 + element-plus 管理后台 yudao-ui-admin-vue3 ( opens new window) 是前端 Vue3 + element-plus 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vue3.git ( opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 友情提示:Vue3 使用 Vite 构建,所以它存在如下的情况,都是正常的: 项目启动很快,浏览器打开需要等待 1 分钟左右,请保持耐心。 点击菜单,感觉会有一点卡顿,因为 Vite 采用懒加载机制。不用担心,最终部署到生产环境,就不存在这个问题了。 详细说明,可见 《为什么有人说 Vite 快,有人却说 Vite 慢?》 (opens new window) 文章。 # 7.2 启动 Vue3 + vben(ant-design-vue) 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 + vben(ant-design-vue) 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vben.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 # 7.3 启动 Vue2 管理后台 yudao-ui-admin (opens new window) 是前端 Vue2 管理后台项目。 ① 在 yudao-ui-admin 目录下,执行如下命令,进行启动: # 进入项目目录cd yudao-ui-admin# 安装 Yarn,提升依赖的安装速度npm install --global yarn# 安装依赖yarn install# 启动服务npm run local ② 启动完成后,浏览器会自动打开 http://localhost:1024 (opens new window) 地址,可以看到前端界面。 # 7.4 启动 uni-app 管理后台 yudao-ui-admin-uniapp (opens new window) 是前端 uni-app 管理后台项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-admin-uniapp 目录 ③ 执行如下命令,安装 npm 依赖: # 进入项目目录cd yudao-ui-admin-uniapp# 安装 npm 依赖npm i ④ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: 友情提示:登录时,滑块验证码,在内存浏览器可能存在兼容性的问题,此时使用 Chrome 浏览器,并使用“开发者工具”,设置为 iPhone 12 Pro 模式! # 7.5 启动 uni-app 用户前台 yudao-ui-app (opens new window) 是前端 uni-app 用户前台项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-app 目录 ③ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: # 666. 彩蛋 至此,我们已经完成了项目 ruoyi-vue-pro (opens new window) 的启动。 胖友可以根据自己的兴趣,阅读相关源码。如果你想更快速的学习,可以看看 《视频教程 》 教程哟。 后面,艿艿会花大量的时间,继续优化这个项目。同时,输出与项目匹配的技术博客,方便胖友更好的学习与理解。 还是那句话,😆 为开源继绝学,我辈义不容辞! 嘿嘿嘿,记得一定要给 https://github.com/YunaiV/yudao-cloud (opens new window) 一个 star,这对艿艿真的很重要。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/15, 00:05:05 功能列表 快速启动(适合“前端”工程师) ← 功能列表 快速启动(适合“前端”工程师)→"},{"title":"视频教程","path":"/wiki/YuDaoCloud/萌新必读/视频教程/视频教程.html","content":"开发指南萌新必读 芋道源码 2022-07-02 目录 视频教程 # 大纲 每个点都是大章节,包含 10-20 小节的视频。 每个视频,控制在 10 分钟左右,问题驱动,全程无废话,保证高质量的学习。 视频的内容,会带你理解整个系统的设计思想,每一个组件和模块的代码实现。 知其然,知其所以然!让你走出只会 CRUD 的困局~ 支持手机、平板、电脑设备,随时随地在线观看,无需下载! # 技术架构图 # 为什么学习该视频? 学习的过程中,往往会碰到如下的问题: 一个人瞎摸索,走弯路,效率低 一脸懵逼,不知道如何学习 遇到问题,无人解答,信心备受打击 遇到一些难题,自己无法透彻理解 知识面狭窄,不知道的太多 而通过这套视频,可以实现 “系统全面,效率高” 的效果。 # 获取方式 使用微信扫描下方二维码,即可获取~ # 从零开始 01、视频课程导读:项目简介、功能列表、技术选型 (opens new window) 02、在 Windows 环境下,如何运行前后端项目? (opens new window) 03、在 MacOS 环境下,如何运行前后端项目? (opens new window) 04、自顶向下,讲解项目的整体结构(上) (opens new window) 04、自顶向下,讲解项目的整体结构(下) (opens new window) 05、如何 5 分钟,开发一个新功能? (opens new window) 06、如何 5 分钟,创建一个新模块? (opens new window) 07、如何有效的删除不用的功能? (opens new window) 08、如何实现一键改包? (opens new window) # 用户认证 01、如何实现管理后台和微信小程序的用户? (opens new window) 02、如何实现用户的创建? (opens new window) 03、如何实现用户的账号密码登录? (opens new window) 04、如何实现用户的手机验证码登录? (opens new window) 05、如何实现用户的退出? (opens new window) 06、如何生成用户认证 Token 令牌? (opens new window) 07、如何校验用户认证 Token 令牌? (opens new window) 08、如何刷新用户认证 Token 令牌? (opens new window) 09、如何模拟用户认证 Token 令牌? (opens new window) 10、如何实现 URL 是否需要登录? (opens new window) 11、如何实现微信、钉钉等第三方登录? (opens new window) 12、如何实现微信小程序的一键登录? (opens new window) # 功能权限 01、如何设计一套权限系统? (opens new window) 02、如何实现菜单的创建? (opens new window) 03、如何实现角色的创建? (opens new window) 04、如何给用户分配权限 —— 将菜单赋予角色? (opens new window) 05、如何给用户分配权限 —— 将角色赋予用户? (opens new window) 06、后端如何实现 URL 权限的校验? (opens new window) 07、前端如何实现菜单的动态加载? (opens new window) 08、前端如何实现按钮的权限校验? (opens new window) # 数据权限 01、如何实现数据权限(内核)—— 原理剖析? (opens new window) 02、如何实现数据权限(内核)—— 源码实现:MyBatis 如何重写 SQL? (opens new window) 03、如何实现数据权限(内核)—— 源码实现:如何基于(数据规则)生成 WHERE 条件? (opens new window) 04、如何实现【部门级别】的数据权限 —— 入门使用? (opens new window) 05、如何实现【部门级别】的数据权限 —— 源码实现? (opens new window) 06、如何实现【自定义】的数据权限 —— 案例实战? (opens new window) # OAuth2 模块 01、快速入门 OAuth 2.0 授权? (opens new window) 02、基于授权码模式,如何实现 SSO 单点登录? (opens new window) 03、请求时,如何校验 accessToken 访问令牌? (opens new window) 04、访问令牌过期时,如何刷新 Token 令牌? (opens new window) 05、登录成功后,如何获得用户信息? (opens new window) 06、退出时,如何删除 Token 令牌? (opens new window) 07、基于密码模式,如何实现 SSO 单点登录? (opens new window) 08、如何实现客户端的管理? (opens new window) 09、单点登录界面,如何进行初始化? (opens new window) 10、单点登录界面,如何进行【手动】授权? (opens new window) 11、单点登录界面,如何进行【自动】授权? (opens new window) 12、基于【授权码】模式,如何获得 Token 令牌? (opens new window) 13、基于【密码】模式,如何获得 Token 令牌? (opens new window) 14、如何校验、刷新、删除访问令牌? (opens new window) # 工作流 01、如何集成 Flowable 框架? (opens new window) 02、如何实现动态的流程表单? (opens new window) 03、如何实现流程表单的保存? (opens new window) 04、如何实现流程表单的展示? (opens new window) 05、如何实现流程模型的新建? (opens new window) 06、如何实现流程模型的流程图的设计? (opens new window) 07、如何实现流程模型的流程图的预览? (opens new window) 08、如何实现流程模型的分配规则? (opens new window) 09、如何实现流程模型的发布? (opens new window) 10、如何实现流程定义的查询? (opens new window) 11、如何实现流程的发起? (opens new window) 12、如何实现我的流程列表? (opens new window) 13、如何实现流程的取消? (opens new window) 14、如何实现流程的任务分配? (opens new window) 15、如何实现会签、或签任务? (opens new window) 16、如何实现我的待办任务列表? (opens new window) 17、如何实现我的已办任务列表? (opens new window) 18、如何实现任务的审批通过? (opens new window) 19、如何实现任务的审批不通过? (opens new window) 20、如何实现流程的审批记录? (opens new window) 21、如何实现流程的流程图的高亮? (opens new window) 22、如何实现工作流的短信通知? (opens new window) 23、如何实现 OA 请假的发起? (opens new window) 24、如何实现 OA 请假的审批? (opens new window) # SaaS 多租户 01、如何实现多租户的 DB 封装? (opens new window) 02、如何实现多租户的 Redis 封装? (opens new window) 03、如何实现多租户的 Web 与 Security 封装? (opens new window) 04、如何实现多租户的 Job 封装? (opens new window) 05、如何实现多租户的 MQ 与 Async 封装? (opens new window) 06、如何实现多租户的 AOP 与 Util 封装? (opens new window) 07、如何实现多租户的管理? (opens new window) 08、如何实现多租户的套餐? (opens new window) # Web 组件 01、如何实现统一 API 前缀? (opens new window) 02、如何实现统一 API 响应? (opens new window) 03、如何实现 API 全局异常处理? (opens new window) 04、如何实现全局错误码? (opens new window) 05、如何实现 API 接口文档? (opens new window) 06、如何记录 API 访问日志? (opens new window) 07、如何校验 API 请求参数? (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/05, 16:00:43 交流群 功能列表 ← 交流群 功能列表→"},{"title":"IDE 调试","path":"/wiki/YuDaoCloud/前端手册 Vue 3/IDE 调试/IDE 调试.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-13 目录 IDE 调试 除了使用 Chrome 调试 JS 代码外,我们也可以使用 IDEA / WebStorm 或 VS Code 进行代码的调试。 # 1. IDEA 调试 友情提示:WebStorm 也支持。 ① 使用 npm 命令将前端项目运行起来,例如说 npm run dev。耐心等待项目启动成功~ ② 点击链接,Windows 需按住 Ctrl + Shift + 鼠标左键,MacOS 需要按住 Shift + Command + 鼠标左键。如下图所示: ③ 点击后,会跳出一个独立的 Chrome 窗口。如下图所示: ④ 打个断点,例如说 /src/api/login/index.ts 的登录接口。如下图所示: ⑤ 使用管理后台进行登录,可以看到成功进入断点。如下图所示: # 2. VS Code 调试 ① 使用 npm 命令将前端项目运行起来,例如说 npm run dev。耐心等待项目启动成功~ ② 点击 VS Code 左侧的运行和调试,然后启动 Launch,之后会跳出一个独立的 Edge 窗口。如下图所示: ③ 打个断点,例如说 /src/api/login/index.ts 的登录接口。如下图所示: ④ 使用管理后台进行登录,可以看到成功进入断点。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/13, 23:26:36 国际化 【v1.7.3】开发中 ← 国际化 【v1.7.3】开发中→"},{"title":"国际化","path":"/wiki/YuDaoCloud/前端手册 Vue 3/国际化/国际化.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 国际化 友情提示: 该章节,基于 《vue element plus admin —— 国际化》 (opens new window) 的内容修改。 如果你使用的 vscode 开发工具,则推荐安装 I18n-ally (opens new window) 这个插件 # 1. I18n-ally 插件 安装了该插件后,你的代码内可以实时看到对应的语言内容 # 2. 配置默认语言 在 src/store/modules/locale.ts (opens new window) 内配置 currentLocale 为其他语言。 查看代码 import { defineStore } from 'pinia'import { store } from '../index'import zhCn from 'element-plus/es/locale/lang/zh-cn'import en from 'element-plus/es/locale/lang/en'import { CACHE_KEY, useCache } from '@/hooks/web/useCache'import { LocaleDropdownType } from '@/types/localeDropdown'const { wsCache } = useCache()const elLocaleMap = { 'zh-CN': zhCn, en: en}interface LocaleState { currentLocale: LocaleDropdownType localeMap: LocaleDropdownType[]}export const useLocaleStore = defineStore('locales', { state: (): LocaleState => { return { currentLocale: { lang: wsCache.get(CACHE_KEY.LANG) || 'zh-CN', elLocale: elLocaleMap[wsCache.get(CACHE_KEY.LANG) || 'zh-CN'] }, // 多语言 localeMap: [ { lang: 'zh-CN', name: '简体中文' }, { lang: 'en', name: 'English' } ] } }, getters: { getCurrentLocale(): LocaleDropdownType { return this.currentLocale }, getLocaleMap(): LocaleDropdownType[] { return this.localeMap } }, actions: { setCurrentLocale(localeMap: LocaleDropdownType) { // this.locale = Object.assign(this.locale, localeMap) this.currentLocale.lang = localeMap?.lang this.currentLocale.elLocale = elLocaleMap[localeMap?.lang] wsCache.set(CACHE_KEY.LANG, localeMap?.lang) } }})export const useLocaleStoreWithOut = () => { return useLocaleStore(store)} # 3. 语言文件 在 src/locales (opens new window) 可以配置具体的语言。 目前项目中的语言都是没有拆分的,全部放一起,后续会考虑拆分出来,比较好维护。 # 4. 语言导入逻辑说明 在 src/plugins/vueI18n/index.ts (opens new window) 内可以看到 const defaultLocal = await import(`../../locales/${locale.lang}.ts`) 这会导入 src/locales 文件语言包。 # 5. 使用 引入项目自带的 useI18n 注意不要引入 vue-i18n 的 useI18n import { useI18n } from '/@/hooks/web/useI18n'const { t } = useI18n()const title = t('common.menu') # 6. 切换语言 切换语言需要使用 src/hooks/web/useLocale.ts ( opens new window) import { useLocale } from '@/hooks/web/useLocale'const { changeLocale } = useLocale()changeLocale('en') # 7. 新增新语言 # 7.1 语言文件 在 src/locales ( opens new window) 增加对应语言的文件即可 # 7.2 新增语言 目前项目自带的语言只有 zh_CN 和 en 两种 如果需要新增,按以下操作即可 在 src/locales ( opens new window) 下语言文件 在 types/global.d.ts ( opens new window) 给 LocaleType 添加对应的类型 在 src/store/modules/locale.ts localeMap 中添加对应语言 # 8. 远程读取语言数据 目前项目会在 src/main.ts 内等待 setupI18n 这个函数执行完之后才会渲染界面,所以只需在 setupI18n 内的 createI18nOptions 发送 ajax 请求,将对应的数据设置到 i18n 实例上即可。 const createI18nOptions = async (): Promise<I18nOptions> => { const localeStore = useLocaleStoreWithOut() const locale = localeStore.getCurrentLocale const localeMap = localeStore.getLocaleMap // 这里改为远程请求即可。 const defaultLocal = await import(`../../locales/${locale.lang}.ts`) const message = defaultLocal.default ?? {} setHtmlPageLang(locale.lang) localeStore.setCurrentLocale({ lang: locale.lang // elLocale: elLocal }) return { legacy: false, locale: locale.lang, fallbackLocale: locale.lang, messages: { [locale.lang]: message }, availableLocales: localeMap.map((v) => v.lang), sync: true, silentTranslationWarn: true, missingWarn: false, silentFallbackWarn: true }} # 8.1 useLocale 代码: src/hooks/web/useLocale.ts ( opens new window) 当手动切换语言的时候会触发 useLocale 函数,useLocale 也是异步函数,只需等待接口返回响应的数据后,再进行设置即可 export const useLocale = () => { // Switching the language will change the locale of useI18n // And submit to configuration modification const changeLocale = async (locale: LocaleType) => { const globalI18n = i18n.global // 改为远程获取 const langModule = await import(`../../locales/${locale}.ts`) globalI18n.setLocaleMessage(locale, langModule.default) setI18nLanguage(locale) } return { changeLocale }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/05, 22:46:17 CRUD 组件 IDE 调试 ← CRUD 组件 IDE 调试→"},{"title":"字典数据","path":"/wiki/YuDaoCloud/前端手册 Vue 3/字典数据/字典数据.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-04-17 目录 字典数据 本小节,讲解前端如何使用 [系统管理 -> 字典管理] 菜单的字典数据,例如说字典数据的下拉框、单选 / 多选按钮、高亮展示等等。 # 1. 全局缓存 用户登录成功后,前端会从后端获取到全量的字典数据,缓存在 store 中。如下图所示: 这样,前端在使用到字典数据时,无需重复请求后端,提升用户体验。 不过,缓存暂时未提供刷新,所以在字典数据发生变化时,需要用户刷新浏览器,进行重新加载。 # 2. DICT_TYPE 在 dict.ts (opens new window) 文件中,使用 DICT_TYPE 枚举了字典的 KEY。如下图所示: 后续如果有新的字典 KEY,需要你自己进行添加。 # 3. DictTag 字典标签 <dict-tag /> (opens new window) 组件,翻译字段对应的字典展示文本,并根据 colorType、cssClass 进行高亮。使用示例如下: <!-- type: 字典 KEY value: 字典值--><dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_TYPE" :value="row.logType" /> 【推荐】注意,一般情况下使用 CRUD schemas 方式,不需要直接使用 <dict-tag />,而是通过 columns 的 dictType 和 dictClass 属性即可。如下图所示: # 4. 字典工具类 在 dict.ts (opens new window) 文件中,提供了字典工具类,方法如下: // 获取 dictType 对应的数据字典数组【object】export const getDictOptions = (dictType: string) => {{ /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【int】export const getIntDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【string】export const getStrDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【boolean】export const getBoolDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【object】export const getDictObj = (dictType: string, value: any) => { /** 省略代码 */ } 结合 Element Plus 的表单组件,使用示例如下: <template> <!-- radio 单选框 --> <el-radio v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="parseInt(dict.value)" > {{dict.label}} </el-radio> <!-- select 下拉框 --> <el-select v-model="form.code" placeholder="请选择渠道编码" clearable> <el-option v-for="dict in getStrDictOptions(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE)" :key="dict.value" :label="dict.label" :value="dict.value" /> </el-select></template><script setup lang="tsx">import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'</script> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 Icon 图标 系统组件 ← Icon 图标 系统组件→"},{"title":"Icon 图标","path":"/wiki/YuDaoCloud/前端手册 Vue 3/Icon 图标/Icon 图标.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 Icon 图标 Element Plus 内置多种 Icon 图标,可参考 Element Plus —— Icon 图标 (opens new window) 的文档。 在项目的 /src/assets/svgs (opens new window) 目录下,自定义了 Icon 图标,默认注册到全局中,可以在项目中任意地方使用。如下图所示: # 1. Icon 图标组件 友情提示: 该小节,基于 《vue element plus admin —— Icon 图标组件 》 (opens new window) 的内容修改。 Icon 组件位于 src/components/Icon (opens new window) 内,用于项目内组件的展示,基本支持所有图标库(支持按需加载,只打包所用到的图标),支持使用本地 svg 和 Iconify (opens new window) 图标。 提示 在 Iconify (opens new window) 上,你可以查询到你想要的所有图标并使用,不管是不是 element-plus 的图标库。 # 1.1 基本用法 如果以 svg-icon: 开头,则会在本地中找到该 svg 图标,否则,会加载 Iconify 图标。代码如下: <template> <!-- 加载本地 svg --> <Icon icon="svg-icon:peoples" /> <!-- 加载 Iconify --> <Icon icon="ep:aim" /></template> # 1.2 useIcon 如果需要在其他组件中如 ElButton 传入 icon 属性,可以使用 useIcon。代码如下: <script setup lang="ts">import { useIcon } from '@/hooks/web/useIcon'import { ElButton } from 'element-plus'const icon = useIcon({ icon: 'svg-icon:save' })</script><template> <ElButton :icon="icon"> button </ElButton></template> useIcon 的 props 属性如下: 属性 说明 类型 可选值 默认值 icon 图标名 string - - color 图标颜色 string - - size 图标大小 number - 16 # 2. 自定义图标 ① 访问 https://www.iconfont.cn/ (opens new window) 地址,搜索你想要的图标,下载 SVG 格式。如下图所示: 友情提示:其它 SVG 图标网站也可以。 ② 将 SVG 图标添加到 /src/assets/svgs (opens new window) 目录下,然后进行使用。 <Icon icon="svg-icon:helpless" /> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 菜单路由 字典数据 ← 菜单路由 字典数据→"},{"title":"开发规范","path":"/wiki/YuDaoCloud/前端手册 Vue 3/开发规范/开发规范.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-04-17 目录 开发规范 # 0. 实战案例 本小节,提供大家开发管理后台的功能时,最常用的普通列表、树形列表、新增与修改的表单弹窗、详情表单弹窗的实战案例。 # 0.1 普通列表 可参考 [系统管理 -> 岗位管理] 菜单: API 接口:/src/api/system/post/index.ts (opens new window) 列表界面:/src/views/system/post/index.vue (opens new window) 表单界面:/src/views/system/post/PostForm.vue (opens new window) 为什么界面拆成列表和表单两个 Vue 文件? 每个 Vue 文件,只实现一个功能,更简洁,维护性更好,Git 代码冲突概率低。 # 0.2 树形列表 可参考 [系统管理 -> 部门管理] 菜单: API 接口:/src/api/system/dept/index.ts (opens new window) 列表界面:/src/views/system/dept/index.vue (opens new window) 表单界面:/src/views/system/dept/DeptForm.vue (opens new window) # 0.3 高性能列表 可参考 [系统管理 -> 地区管理] 菜单,对应 /src/views/system/area/index.vue (opens new window) 列表界面 基于 Virtualized Table 虚拟化表格 (opens new window) 实现,解决一屏里超过 1000 条数据记录时,就会出现卡顿等性能问题。 # 0.4 详情弹窗 可参考 [基础设施 -> API 日志 -> 访问日志] 菜单,对应 /src/views/infra/apiAccessLog/ApiAccessLogDetail.vue (opens new window) 详情弹窗 # 1. view 页面 在 @views (opens new window) 目录下,每个模块对应一个目录,它的所有功能的 .vue 都放在该目录里。 一般来说,一个路由对应一个 index.vue 文件。 # 2. api 请求 在 @/api (opens new window) 目录下,每个模块对应一个 index.ts API 文件。 API 方法:会调用 request 方法,发起对后端 RESTful API 的调用。 interface 类型:定义了 API 的请求参数和返回结果的类型,对应后端的 VO 类型。 # 2.1 请求封装 /src/config/axios/index.ts (opens new window) 基于 axios (opens new window) 封装,统一处理 GET、POST 方法的请求参数、请求头,以及错误提示信息等。 # 2.1.1 创建 axios 实例 baseURL 基础路径 timeout 超时时间,默认为 30000 毫秒 实现代码 /src/config/axios/service.ts import axios from 'axios'const { result_code, base_url, request_timeout } = config// 创建 axios 实例const service: AxiosInstance = axios.create({ baseURL: base_url, // api 的 base_url timeout: request_timeout, // 请求超时时间 withCredentials: false // 禁用 Cookie 等信息}) # 2.1.2 Request 拦截器 【重点】Authorization、tenant-id 请求头 GET 请求参数的拼接 实现代码 /src/config/axios/service.ts import axios, { AxiosInstance, AxiosRequestHeaders, AxiosResponse, AxiosError, InternalAxiosRequestConfig} from 'axios'import { getAccessToken, getTenantId } from '@/utils/auth'const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLEservice.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // 是否需要设置 token let isToken = (config!.headers || {}).isToken === false whiteList.some((v) => { if (config.url) { config.url.indexOf(v) > -1 return (isToken = false) } }) if (getAccessToken() && !isToken) { (config as Recordable).headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token } // 设置租户 if (tenantEnable && tenantEnable === 'true') { const tenantId = getTenantId() if (tenantId) (config as Recordable).headers['tenant-id'] = tenantId } const params = config.params || {} const data = config.data || false if ( config.method?.toUpperCase() === 'POST' && (config.headers as AxiosRequestHeaders)['Content-Type'] === 'application/x-www-form-urlencoded' ) { config.data = qs.stringify(data) } // get参数编码 if (config.method?.toUpperCase() === 'GET' && params) { let url = config.url + '?' for (const propName of Object.keys(params)) { const value = params[propName] if (value !== void 0 && value !== null && typeof value !== 'undefined') { if (typeof value === 'object') { for (const val of Object.keys(value)) { const params = propName + '[' + val + ']' const subPart = encodeURIComponent(params) + '=' url += subPart + encodeURIComponent(value[val]) + '&' } } else { url += `${propName}=${encodeURIComponent(value)}&` } } } // 给 get 请求加上时间戳参数,避免从缓存中拿数据 // const now = new Date().getTime() // params = params.substring(0, url.length - 1) + `?_t=${now}` url = url.slice(0, -1) config.params = {} config.url = url } return config }, (error: AxiosError) => { // Do something with request error console.log(error) // for debug Promise.reject(error) }) # 2.1.3 Response 拦截器 访问令牌 AccessToken 过期时,使用刷新令牌 RefreshToken 刷新,获得新的访问令牌 刷新令牌失败(过期)时,跳回首页进行登录 请求失败,Message 错误提示 实现代码 /src/config/axios/service.ts import axios, { AxiosInstance, AxiosRequestHeaders, AxiosResponse, AxiosError, InternalAxiosRequestConfig} from 'axios'import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'import { getAccessToken, getRefreshToken, removeToken, setToken } from '@/utils/auth'// 需要忽略的提示。忽略后,自动 Promise.reject('error')const ignoreMsgs = [ '无效的刷新令牌', // 刷新令牌被删除时,不用提示 '刷新令牌已过期' // 使用刷新令牌,刷新获取新的访问令牌时,结果因为过期失败,此时需要忽略。否则,会导致继续 401,无法跳转到登出界面]// 是否显示重新登录export const isRelogin = { show: false }import errorCode from './errorCode'import { resetRouter } from '@/router'import { useCache } from '@/hooks/web/useCache'service.interceptors.response.use( async (response: AxiosResponse<any>) => { const { data } = response const config = response.config if (!data) { // 返回“[HTTP]请求没有返回值”; throw new Error() } const { t } = useI18n() // 未设置状态码则默认成功状态 const code = data.code || result_code // 二进制数据则直接返回 if ( response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer' ) { return response.data } // 获取错误信息 const msg = data.msg || errorCode[code] || errorCode['default'] if (ignoreMsgs.indexOf(msg) !== -1) { // 如果是忽略的错误码,直接返回 msg 异常 return Promise.reject(msg) } else if (code === 401) { // 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了 if (!isRefreshToken) { isRefreshToken = true // 1. 如果获取不到刷新令牌,则只能执行登出操作 if (!getRefreshToken()) { return handleAuthorized() } // 2. 进行刷新访问令牌 try { const refreshTokenRes = await refreshToken() // 2.1 刷新成功,则回放队列的请求 + 当前请求 setToken((await refreshTokenRes).data.data) config.headers!.Authorization = 'Bearer ' + getAccessToken() requestList.forEach((cb: any) => { cb() }) requestList = [] return service(config) } catch (e) { // 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。 // 2.2 刷新失败,只回放队列的请求 requestList.forEach((cb: any) => { cb() }) // 提示是否要登出。即不回放当前请求!不然会形成递归 return handleAuthorized() } finally { requestList = [] isRefreshToken = false } } else { // 添加到队列,等待刷新获取到新的令牌 return new Promise((resolve) => { requestList.push(() => { config.headers!.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token 请根据实际情况自行修改 resolve(service(config)) }) }) } } else if (code === 500) { ElMessage.error(t('sys.api.errMsg500')) return Promise.reject(new Error(msg)) } else if (code === 901) { ElMessage.error({ offset: 300, dangerouslyUseHTMLString: true, message: '<div>' + t('sys.api.errMsg901') + '</div>' + '<div> &nbsp; </div>' + '<div>参考 https://doc.iocoder.cn/ 教程</div>' + '<div> &nbsp; </div>' + '<div>5 分钟搭建本地环境</div>' }) return Promise.reject(new Error(msg)) } else if (code !== 200) { if (msg === '无效的刷新令牌') { // hard coding:忽略这个提示,直接登出 console.log(msg) } else { ElNotification.error({ title: msg }) } return Promise.reject('error') } else { return data } }, (error: AxiosError) => { console.log('err' + error) // for debug let { message } = error const { t } = useI18n() if (message === 'Network Error') { message = t('sys.api.errorMessage') } else if (message.includes('timeout')) { message = t('sys.api.apiTimeoutMessage') } else if (message.includes('Request failed with status code')) { message = t('sys.api.apiRequestFailed') + message.substr(message.length - 3) } ElMessage.error(message) return Promise.reject(error) })const refreshToken = async () => { axios.defaults.headers.common['tenant-id'] = getTenantId() return await axios.post(base_url + '/system/auth/refresh-token?refreshToken=' + getRefreshToken())}const handleAuthorized = () => { const { t } = useI18n() if (!isRelogin.show) { isRelogin.show = true ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), { confirmButtonText: t('login.relogin'), cancelButtonText: t('common.cancel'), type: 'warning' }) .then(() => { const { wsCache } = useCache() resetRouter() // 重置静态路由表 wsCache.clear() removeToken() isRelogin.show = false window.location.href = '/' }) .catch(() => { isRelogin.show = false }) } return Promise.reject(t('sys.api.timeoutMessage'))} # 2.2 交互流程 一个完整的前端 UI 交互到服务端处理流程,如下图所示: 继续以 [系统管理 -> 岗位管理] 菜单为例,查看它是如何读取岗位列表的。代码如下: // ① api/system/post/index.tsimport request from '@/config/axios'// 查询岗位列表export const getPostPage = async (params: PageParam) => { return await request.get({ url: '/system/post/page', params })}// ② views/system/post/index.vue<script setup lang="tsx">const loading = ref(true) // 列表的加载中const total = ref(0) // 列表的总页数const list = ref([]) // 列表的数据const queryParams = reactive({ pageNo: 1, pageSize: 10, code: '', name: '', status: undefined})/** 查询岗位列表 */const getList = async () => { loading.value = true try { const data = await PostApi.getPostPage(queryParams) list.value = data.list total.value = data.total } finally { loading.value = false }}</script> # 3. component 组件 # 3.1 全局组件 在 @/components ( opens new window) 目录下,实现全局组件,被所有模块所公用。 例如说,富文本编辑器、各种各搜索组件、封装的分页组件等等。 # 3.2 模块内组件 每个模块的业务组件,可实现在 views 目录下,自己模块的目录的 components 目录下,避免单个 .vue 文件过大,降低维护成功。 例如说, @/views/pay/app/components/xxx.vue: # 4. style 样式 ① 在 @/styles ( opens new window) 目录下,实现全局 样式,被所有页面所公用。 ② 每个 .vue 页面,可在 <style /> 标签中添加样式,注意需要添加 scoped 表示只作用在当前页面里,避免造成全局的样式污染。 更多也可以看看如下两篇文档: 《vue-element-plus-admin —— 项目配置「样式配置」》 ( opens new window) 《vue-element-plus-admin —— 样式》 ( opens new window) # 5. 项目规范 可参考 《vue-element-plus-admin —— 项目规范》 ( opens new window) 文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 配置读取 菜单路由 ← 配置读取 菜单路由→"},{"title":"通用方法","path":"/wiki/YuDaoCloud/前端手册 Vue 3/通用方法/通用方法.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 通用方法 本小节,分享前端项目的常用方法。 # 1. 缓存配置 友情提示: 该小节,基于 《vue element plus admin —— 项目配置「缓存配置 」》 (opens new window) 的内容修改。 # 1.1 说明 在项目中,你可以看到很多地方都使用了 wsCache.set 或者 wsCache.get,这是基于 web-storage-cache (opens new window) 进行封装,采用 hook 的形式。 该插件对HTML5 localStorage 和 sessionStorage 进行了扩展,添加了超时时间,序列化方法。可以直接存储 json 对象,同时可以非常简单的进行超时时间的设置。 本项目默认是采用 sessionStorage 的存储方式,如果更改,可以直接在 useCache.ts (opens new window) 中把 type: CacheType = 'sessionStorage' 改为 type: CacheType = 'localStorage',这样项目中的所有用到的地方,都会变成该方式进行数据存储。 如果只想单个更改,可以传入存储类型 const { wsCache } = useCache('localStorage'),既可只适用当前存储对象。 注意: 更改完默认存储方式后,需要清除浏览器缓存并重新登录,以免造成不可描述的问题。 # 1.2 示例 # 2. message 对象 # 2.1 说明 message 对象,由 src/hooks/web/useMessage.ts (opens new window) 实现,基于 ElMessage、ElMessageBox、ElNotification 封装,用于做消息提示、通知提示、对话框提醒、二次确认等。 # 2.2 示例 # 3. download 对象 # 3.1 说明 $download 对象,由 util/download.ts (opens new window) 实现,用于 Excel、Word、Zip、HTML 等类型的文件下载。 # 3.2 示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 21:23:33 系统组件 配置读取 ← 系统组件 配置读取→"},{"title":"菜单路由","path":"/wiki/YuDaoCloud/前端手册 Vue 3/菜单路由/菜单路由.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-12-31 目录 菜单路由 前端项目基于 vue-element-plus-admin 实现,它的 路由和侧边栏 (opens new window) 是组织起一个后台应用的关键骨架。 侧边栏和路由是绑定在一起的,所以你只有在 @/router/index.js (opens new window) 下面配置对应的路由,侧边栏就能动态的生成了,大大减轻了手动重复编辑侧边栏的工作量。 当然,这样就需要在配置路由的时候,遵循一些约定的规则。 # 1. 路由配置 首先,我们了解一下本项目配置路由时,提供了哪些配置项: /*** redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击* name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题* meta : { hidden: true 当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false) alwaysShow: true 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式, 只有一个时,会将那个子路由当做根路由显示在侧边栏, 若你想不管路由下面的 children 声明的个数都显示你的根路由, 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则, 一直显示根路由(默认 false) title: 'title' 设置该路由在侧边栏和面包屑中展示的名字 icon: 'svg-name' 设置该路由的图标 noCache: true 如果设置为true,则不会被 <keep-alive> 缓存(默认 false) breadcrumb: false 如果设置为false,则不会在breadcrumb面包屑中显示(默认 true) affix: true 如果设置为true,则会一直固定在tag项中(默认 false) noTagsView: true 如果设置为true,则不会出现在tag中(默认 false) activeMenu: '/dashboard' 显示高亮的路由路径 followAuth: '/dashboard' 跟随哪个路由进行权限过滤 canTo: true 设置为true即使hidden为true,也依然可以进行路由跳转(默认 false) }**/ # 1.1 普通示例 注意事项: 整个项目所有路由 name 不能重复 所有的多级路由最终都会转成二级路由,所以不能内嵌子路由 除了 layout 对应的 path 前面需要加 /,其余子路由都不要以 / 开头 { path: '/level', component: Layout, redirect: '/level/menu1/menu1-1/menu1-1-1', name: 'Level', meta: { title: t('router.level'), icon: 'carbon:skill-level-advanced' }, children: [ { path: 'menu1', name: 'Menu1', component: getParentLayout(), redirect: '/level/menu1/menu1-1/menu1-1-1', meta: { title: t('router.menu1') }, children: [ { path: 'menu1-1', name: 'Menu11', component: getParentLayout(), redirect: '/level/menu1/menu1-1/menu1-1-1', meta: { title: t('router.menu11'), alwaysShow: true }, children: [ { path: 'menu1-1-1', name: 'Menu111', component: () => import('@/views/Level/Menu111.vue'), meta: { title: t('router.menu111') } } ] }, { path: 'menu1-2', name: 'Menu12', component: () => import('@/views/Level/Menu12.vue'), meta: { title: t('router.menu12') } } ] }, { path: 'menu2', name: 'Menu2Demo', component: () => import('@/views/Level/Menu2.vue'), meta: { title: t('router.menu2') } } ]} # 1.2 外链示例 只需要将 path 设置为需要跳转的 HTTP 地址即可。 { path: '/external-link', component: Layout, meta: { name: 'ExternalLink' }, children: [ { path: 'https://www.iocoder.cn', meta: { name: 'Link', title: '芋道源码' } } ]} # 2. 路由 项目的路由分为两种:静态路由、动态路由。 # 2.1 静态路由 静态路由,代表那些不需要动态判断权限的路由,如登录页、404、个人中心等通用页面。 在 @/router/modules/remaining.ts ( opens new window) 的 remainingRouter ,就是配置对应的公共路由。如下图所示: # 2.2 动态路由 动态路由,代表那些需要根据用户动态判断权限,并通过 addRoutes ( opens new window) 动态添加的页面,如用户管理、角色管理等功能页面。 在用户登录成功后,会触发 @/store/modules/permission.ts ( opens new window) 请求后端的菜单 RESTful API 接口,获取用户有权限 的菜单列表,并转化添加到路由中。如下图所示: 友情提示: 动态路由可以在 [系统管理 -> 菜单管理] 进行新增和修改操作,请求的后端 RESTful API 接口是 /admin-api/system/list-menus ( opens new window) 动态路由在生产环境下会默认使用路由懒加载,实现方式参考 import.meta.glob('../views/**/* .{vue,tsx}') ( opens new window) 方法的判断 补充说明: 最新的代码,部分逻辑重构到 @/permission.ts ( opens new window) # 2.3 路由跳转 使用 router.push 方法,可以实现跳转到不同的页面。 const { push } = useRouter()// 简单跳转push('/job/job-log');// 跳转页面并设置请求参数,使用 `query` 属性push('/bpm/process-instance/detail?id=' + row.processInstance.id) # 3. 菜单管理 项目的菜单在 [系统管理 -> 菜单管理] 进行管理,支持无限 层级,提供目录、菜单、按钮三种类型。如下图所示: 菜单可在 [系统管理 -> 角色管理] 被分配给角色。如下图所示: # 3.1 新增目录 ① 大多数情况下,目录是作为菜单的【分类】: ② 目录也提供实现【外链】的能力: # 3.2 新增菜单 # 3.3 新增按钮 # 4. 权限控制 前端通过权限控制,隐藏用户没有权限的按钮等,实现功能级别的权限。 友情提示:前端的权限控制,主要是提升用户体验,避免操作后发现没有权限。 最终在请求到后端时,还是会进行一次权限的校验。 # 4.1 v-hasPermi 指令 v-hasPermi ( opens new window) 指令,基于权限字符,进行权限的控制。 <!-- 单个 --><el-button v-hasPermi="['system:user:create']">存在权限字符串才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasPermi="['system:user:create', 'system:user:update']">包含权限字符串才能看到</el-button> # 4.2 v-hasRole 指令 v-hasRole ( opens new window) 指令,基于角色标识,机进行的控制。 <!-- 单个 --><el-button v-hasRole="['admin']">管理员才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasRole="['role1', 'role2']">包含角色才能看到</el-button> # 4.3 结合 v-if 指令 在某些情况下,它是不适合使用 v-hasPermi 或 v-hasRole 指令,如元素标签组件。此时,只能通过手动设置 v-if,通过使用全局权限判断函数,用法是基本一致的。 <template> <el-tabs> <el-tab-pane v-if="checkPermi(['system:user:create'])" label="用户管理" name="user">用户管理</el-tab-pane> <el-tab-pane v-if="checkPermi(['system:user:create', 'system:user:update'])" label="参数管理" name="menu">参数管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin'])" label="角色管理" name="role">角色管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin','common'])" label="定时任务" name="job">定时任务</el-tab-pane> </el-tabs></template><script>import { checkPermi, checkRole } from "@/utils/permission"; // 权限判断函数export default{ methods: { checkPermi, checkRole }}</script> # 5. 页面缓存 开启缓存有 2 个条件 路由设置 name,且不能重复 路由对应的组件加上 name ,与路由设置的 name 保持一致 友情提示:页面缓存是什么? 简单来说,Tab 切换时,开启页面缓存的 Tab 保持原本的状态,不进行刷新。 详细可见 Vue 文档 —— KeepAlive ( opens new window) # 5.1 静态路由的示例 ① router 路由的 name 声明如下: { path: 'menu2', name: 'Menu2', component: () => import('@/views/Level/Menu2.vue'), meta: { title: t('router.menu2') }} ② view component 的 name 声明如下: <script setup lang="ts"> defineOptions({ name: 'Menu2'})</script> 注意: keep-alive 生效的前提是:需要将路由的 name 属性及对应的页面的 name 设置成一样。 因为:include - 字符串或正则表达式,只有名称匹配的组件会被缓存 # 5.2 动态路由的示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 开发规范 Icon 图标 ← 开发规范 Icon 图标→"},{"title":"配置读取","path":"/wiki/YuDaoCloud/前端手册 Vue 3/配置读取/配置读取.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-07 目录 配置读取 在 [基础设施 -> 配置管理] 菜单,可以动态修改配置,无需重启服务器即可生效。 提示 对应 《后端手册 —— 配置中心》 文档。 # 1. 读取配置 前端调用 /@api/infra/config/index.ts (opens new window) 的 #getConfigKey(configKey) 方法,获取指定 key 对应的配置的值。代码如下: // 根据参数键名查询参数值export const getConfigKey = (configKey: string) => { return request.get({ url: '/infra/config/get-value-by-key?key=' + configKey })} # 2. 实战案例 在 src/views/infra/server/index.vue ( opens new window) 页面中,获取 key 为 \"url.skywalking\" 的配置的值。代码如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:10 通用方法 CRUD 组件 ← 通用方法 CRUD 组件→"},{"title":"Jenkins 部署","path":"/wiki/YuDaoCloud/运维手册/Jenkins 部署/Jenkins 部署.html","content":"开发指南运维手册 芋道源码 2022-04-15 目录 Jenkins 部署 友情提示:目前是 Boot 项目的部署,后续会调整成 Cloud 项目的部署 本小节,讲解如何将前端 + 后端项目,使用 Jenkins 工具,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: 友情提示: 本文是 《开发指南 —— Linux 部署》 的加强版,差别在于使用 Jenkins 部署。 # 1. 安装 Jenkins 阅读 《芋道 Jenkins 极简入门 》 (opens new window) 文章,进行 Jenkins 的安装。 # 2. 部署后端 阅读 《芋道 Spring Boot 持续交付 Jenkins 入门 》 (opens new window) 文章,进行后端的部署。 可参考 Jenkins 配置如下: # 3. 部署前端 可参考 Jenkins 配置如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 Docker 部署 HTTPS 证书 ← Docker 部署 HTTPS 证书→"},{"title":"HTTPS 证书","path":"/wiki/YuDaoCloud/运维手册/HTTPS 证书/HTTPS 证书.html","content":"开发指南运维手册 芋道源码 2022-04-16 目录 HTTPS 证书 本小节,讲解如何在 Nginx 配置 SSL 证书,实现前端和后端使用 HTTPS 安全访问的功能。 考虑到各大云服务厂商的文档写的比较齐全,这里更多做汇总与整理。 😜 如果想要免费的 SSL 证书,请申请 DV 单域名证书。如果要配置多个域名,可以申请多个 DV 单域名证书。 友情提示:HTTPS 的学习资料? 《HTTPS 的工作原理》 (opens new window) 《面试官:你连 HTTPS 原理没搞懂,还给我讲“中间人攻击”?》 (opens new window) # 1. 阿里云 SSL【最常用】 阿里云 SSL 证书 (opens new window) 第一步,免费证书申购流程 (opens new window) 第二步,在 Nginx 或 Tengine 服务器上安装证书 (opens new window) ↑ 点击观看 ↑ (opens new window)# 2. FreeSSL【最便宜】 FreeSSL.cn (opens new window),一个提供免费 HTTPS 证书申请的网站。 《如何在 Nginx/Apache/Tomcat/IIS 自动部署证书?》 (opens new window) 疑问:有没其它类似的平台? OHTTPS (opens new window):免费提供 HTTPS 证书,支持一键申请、自动更新、自动部署的功能。 # 3. 腾讯云 SSL 腾讯云 SSL 证书 (opens new window) 第一步,免费 SSL 证书申请流程 (opens new window) 第二步,Nginx 服务器 SSL 证书安装部署 (opens new window) ↑ 点击观看 ↑ (opens new window)# 4. 华为云 SSL 云证书管理服务 CCM (opens new window) 第一步,SSL 证书申购流程 (opens new window) 第二步,下载与安装 SSL 证书 (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 Jenkins 部署 服务监控 ← Jenkins 部署 服务监控→"},{"title":"系统组件","path":"/wiki/YuDaoCloud/前端手册 Vue 3/系统组件/系统组件.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-12-31 目录 系统组件 # 1. 常用组件 # 1.1 Editor 富文本组件 基于 wangEditor (opens new window) 封装 Editor 组件:位于 src/components/Editor (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/editor.html (opens new window) 实战案例:src/views/system/notice/form.vue (opens new window) TODO # 1.2 Dialog 弹窗组件 对 Element Plus 的 Dialog 组件进行封装,支持最大化、最大高度等特性 Dialog 组件:位于 src/components/Dialog (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/dialog.html (opens new window) 实战案例:src/views/system/dept/DeptForm.vue (opens new window) # 1.3 ContentWrap 包裹组件 对 Element Plus 的 ElCard 组件进行封装,自带标题、边距 ContentWrap 组件:位于 src/components/ContentWrap (opens new window) 内 实战案例:src/views/system/post/index.vue (opens new window) # 1.4 Pagination 分页组件 对 Element Plus 的 Pagination (opens new window) 组件进行封装 Pagination 组件:位于 src/components/Pagination (opens new window) 内 实战案例:src/views/system/post/index.vue (opens new window) # 1.5 UploadFile 上传文件组件 对 Element Plus 的 Upload (opens new window) 组件进行封装,上传文件到文件服务 UploadFile 组件:位于 src/components/UploadFile/src/UploadFile.vue (opens new window) 内 实战案例:暂无 # 1.6 UploadImg 上传图片组件 对 Element Plus 的 Upload (opens new window) 组件进行封装,上传图片到文件服务 UploadImg 组件:位于 src/components/UploadFile/src/UploadImg.vue (opens new window) 内 实战案例:src/views/system/oauth2/client/ClientForm.vue (opens new window) # 2. 不常用组件 # 2.1 EChart 图表组件 基于 Apache ECharts (opens new window) 封装,自适应窗口大小 EChart 组件:位于 src/components/EChart (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/echart.html (opens new window) 实战案例:src/views/mp/statistics/index.vue (opens new window) # 2.2 InputPassword 密码输入框 对 Element Plus 的 Input 组件进行封装 InputPassword 组件:位于 src/components/InputPassword (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/input-password.html (opens new window) 实战案例:src/views/Profile/components/ResetPwd.vue (opens new window) # 2.3 ContentDetailWrap 详情包裹组件 用于展示详情,自带返回按钮。 ContentDetailWrap 组件:位于 src/components/ContentDetailWrap (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/content-detail-wrap.html (opens new window) 实战案例:暂无 # 2.4 ImageViewer 图片预览组件 将 Element Plus 的 ImageViewer (opens new window) 组件函数化,通过函数方便创建组件 ImageViewer 组件:位于 src/components/ImageViewer (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/image-viewer.html (opens new window) 实战案例:暂无 # 2.5 Qrcode 二维码组件 基于 qrcode (opens new window) 封装 Qrcode 组件:位于 src/components/Qrcode (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/qrcode.html (opens new window) 实战案例:暂无 # 2.6 Highlight 高亮组件 Highlight 组件:位于 src/components/Highlight (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/highlight.html (opens new window) 实战案例:暂无 # 2.6.1 Infotip 信息提示组件 基于 Highlight 组件封装 Infotip 组件:位于 src/components/Infotip (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/infotip.html (opens new window) 实战案例:暂无 # 2.7 Error 缺省组件 用于各种占位图组件,如 404、403、500 等错误页面。 Error 组件:位于 src/components/Error (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/error.html (opens new window) 实战案例:403.vue (opens new window)、404.vue (opens new window)、500.vue (opens new window) # 2.8 Sticky 黏性组件 Sticky 组件:位于 src/components/Sticky (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/sticky.html (opens new window) 实战案例:暂无 # 2.9 CountTo 数字动画组件 CountTo 组件:位于 src/components/CountTo (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/count-to.html (opens new window) 实战案例:暂无 # 2.10 useWatermark 水印组件 为元素设置水印 useWatermark 组件:位于 src/hooks/web/useWatermark.ts (opens new window) 内 详细文档:vue-element-plus-admin-doc/hooks/useWatermark.html (opens new window) 实战案例:暂无 # 2.11 form-create 动态表单生成器 详细文档:http://www.form-create.com/ (opens new window) ① 实战案例 - 表单设计:src/views/infra/build/index.vue (opens new window) ② 实战案例 - 表单展示:src/views/bpm/processInstance/detail/index.vue (opens new window) # 2.12 bpmn-js 工作流组件 核心是基于 bpmn-js (opens new window) 封装 # 2.12.1 MyProcessDesigner 流程设计组件 MyProcessDesigner 组件:位于 src/components/bpmnProcessDesigner/package/designer/index.ts (opens new window) 内,基于 https://gitee.com/MiyueSC/bpmn-process-designer (opens new window) 项目适配 实战案例:src/views/bpm/model/editor/index.vue (opens new window) # 2.12.2 MyProcessViewer 流程展示组件 MyProcessViewer 组件:位于 src/components/bpmnProcessDesigner/package/designer/index2.ts (opens new window) 内 实战案例:src/views/bpm/processInstance/detail/ProcessInstanceBpmnViewer.vue (opens new window) # 3. 组件注册 友情提示: 该小节,基于 《vue element plus admin —— 组件注册 》 (opens new window) 的内容修改。 组件注册可以分成两种类型:按需引入、全局注册。 # 3.1 按需引入 项目目前的组件注册机制是按需注册,是在需要用到的页面才引入。 <script setup lang="ts">import { ElBacktop } from 'element-plus'import { useDesign } from '@/hooks/web/useDesign'const { getPrefixCls, variables } = useDesign()const prefixCls = getPrefixCls('backtop')</script><template> <ElBacktop :class="`${prefixCls}-backtop`" :target="`.${variables.namespace}-layout-content-scrollbar .${variables.elNamespace}-scrollbar__wrap`" /></template> 注意:tsx 文件内不能使用全局注册组件,需要手动引入组件使用。 # 3.2 全局注册 如果觉得按需引入太麻烦,可以进行全局注册,在 src/components/index.ts (opens new window),添加需要注册的组件。 以 Icon 组件进行了全局注册,举个例子: import type { App } from 'vue'import { Icon } from './Icon'export const setupGlobCom = (app: App<Element>): void => { app.component('Icon', Icon)} 如果 Element Plus 的组件需要全局注册,在 src/plugins/elementPlus/index.ts (opens new window) 添加需要注册的组件。 以 Element Plus 中只有 ElLoading 与 ElScrollbar 进行全局注册,举个例子: import type { App } from 'vue'// 需要全局引入一些组件,如 ElScrollbar,不然一些下拉项样式有问题import { ElLoading, ElScrollbar } from 'element-plus'const plugins = [ElLoading]const components = [ElScrollbar]export const setupElementPlus = (app: App) => { plugins.forEach((plugin) => { app.use(plugin) }) components.forEach((component) => { app.component(component.name, component) })} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 字典数据 通用方法 ← 字典数据 通用方法→"},{"title":"Docker 部署","path":"/wiki/YuDaoCloud/运维手册/Docker 部署/Docker 部署.html","content":"开发指南运维手册 芋道源码 2022-04-13 目录 Docker 部署 友情提示:目前是 Boot 项目的部署,后续会调整成 Cloud 项目的部署 本小节,讲解如何将前端 + 后端项目,使用 Docker 容器,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: 注意:服务器的 IP 地址。 外网 IP:139.9.196.247 内网 IP:192.168.0.213 下属所有涉及到 IP 的配置,需要替换成你自己的。 # 1. 安装 Docker 执行如下命令,进行 Docker 的安装。 ## ① 使用 DaoCloud 的 Docker 高速安装脚本。参考 https://get.daocloud.io/#install-dockercurl -sSL https://get.daocloud.io/docker | sh## ② 设置 DaoCloud 的 Docker 镜像中心,加速镜像的下载速度。参考 https://www.daocloud.io/mirrorcurl -sSL https://get.daocloud.io/daotools/set_mirror.sh | sh -s http://f1361db2.m.daocloud.io## ③ 启动 Docker 服务systemctl start docker # 2. 配置 MySQL # 2.1 安装 MySQL(可选) 友情提示:使用 Docker 安装 MySQL 是可选步骤,也可以直接安装 MySQL,或者购买 MySQL 云服务。 ① 执行如下命令,使用 Docker 启动 MySQL 容器。 docker run -v /work/mysql/:/var/lib/mysql \\-p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 \\--restart=always --name mysql -d mysql 数据库文件,挂载到服务器的的 /work/mysql/ 目录下 端口是 3306,密码是 123456 ② 执行 ls /work/mysql 命令,查看 /work/mysql/ 目录的数据库文件。 # 2.2 导入 SQL 脚本 创建一个名字为 ruoyi-vue-pro 数据库,执行数据库对应的 sql (opens new window) 目录下的 SQL 文件,进行初始化。 # 3. 配置 Redis 友情提示:使用 Docker 安装 Redis 是可选步骤,也可以直接安装 Redis,或者购买 Redis 云服务。 执行如下命令,使用 Docker 启动 Redis 容器。 docker run -d --name redis --restart=always -p 6379:6379 redis:5.0.14-alpine 端口是 6379,密码未设置 # 4. 部署后端 # 4.1 修改配置 后端 dev 开发环境对应的是 application-dev.yaml (opens new window) 配置文件,主要是修改 MySQL 和 Redis 为你的地址。如下图所示: # 4.2 编译后端 在项目的根目录下,执行 mvn clean package -Dmaven.test.skip=true 命令,编译后端项目,构建出它的 Jar 包。如下图所示: 疑问:-Dmaven.test.skip=true 是什么意思? 跳过单元测试的执行。如果你项目的单元测试写的不错,建议使用 mvn clean package 命令,执行单元测试,保证交付的质量。 # 4.3 上传 Jar 包 在 Linux 服务器上创建 /work/projects/yudao-server 目录,使用 scp 命令或者 FTP 工具,将 yudao-server.jar 上传到该目录下。如下图所示: # 4.4 构建镜像 ① 在 /work/projects/yudao-server 目录下,新建 Dockerfile (opens new window) 文件,用于制作后端项目的 Docker 镜像。编写内容如下: ## AdoptOpenJDK 停止发布 OpenJDK 二进制,而 Eclipse Temurin 是它的延伸,提供更好的稳定性## 感谢复旦核博士的建议!灰子哥,牛皮!FROM eclipse-temurin:8-jre## 创建目录,并使用它作为工作目录RUN mkdir -p /yudao-serverWORKDIR /yudao-server## 将后端项目的 Jar 文件,复制到镜像中COPY yudao-server.jar app.jar## 设置 TZ 时区## 设置 JAVA_OPTS 环境变量,可通过 docker run -e "JAVA_OPTS=" 进行覆盖ENV TZ=Asia/Shanghai JAVA_OPTS="-Xms512m -Xmx512m"## 暴露后端项目的 48080 端口EXPOSE 48080## 启动后端项目ENTRYPOINT java ${JAVA_OPTS} -Djava.security.egd=file:/dev/./urandom -jar app.jar ② 执行如下命令,构建名字为 yudao-server 的 Docker 镜像。 cd /work/projects/yudao-serverdocker build -t yudao-server . ③ 在 /work/projects/yudao-server 目录下,新建 Shell 脚本 deploy.sh,使用 Docker 启动后端项目。编写内容如下: #!/bin/bashset -e## 第一步:删除可能启动的老 yudao-server 容器echo "开始删除 yudao-server 容器"docker stop yudao-server || truedocker rm yudao-server || trueecho "完成删除 yudao-server 容器"## 第二步:启动新的 yudao-server 容器 \\echo "开始启动 yudao-server 容器"docker run -d \\--name yudao-server \\-p 48080:48080 \\-e "SPRING_PROFILES_ACTIVE=dev" \\-v /work/projects/yudao-server:/root/logs/ \\yudao-serverecho "正在启动 yudao-server 容器中,需要等待 60 秒左右" 应用日志文件,挂载到服务器的的 /work/projects/yudao-server 目录下 通过 SPRING_PROFILES_ACTIVE 设置为 dev 开发环境 # 4.5 启动后端 ① 执行 sh deploy.sh 命令,使用 Docker 启动后端项目。日志如下: 开始删除 yudao-server 容器yudao-serveryudao-server完成删除 yudao-server 容器开始启动 yudao-server 容器0dfd3dc409a53ae6b5e7c5662602cf5dcb52fd4d7f673bd74af7d21da8ead9d5正在启动 yudao-server 容器中,需要等待 60 秒左右 ② 执行 docker logs yudao-server 命令,查看启动日志。看到如下内容,说明启动完成: 友情提示:如果日志比较多,可以使用 grep 进行过滤。 例如说:使用 docker logs yudao-server | grep 48080 2022-04-15 00:34:19.647 INFO 8 --- [main] [TID: N/A] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 48080 (http) # 5. 部署前端 友情提示: 本小节的内容,和 《开发指南 —— Linux 部署》 的「部署前端」是基本一致的。 # 5.1 修改配置 前端 dev 开发环境对应的是 .env.dev ( opens new window) 配置文件,主要是修改 VUE_APP_BASE_API 为你的后端项目的访问地址。如下图所示: # 5.2 编译前端 在 yudao-ui-admin 目录下,执行 npm run build:dev 命令,编译前端项目,构建出它的 dist 文件,里面是 HTML、CSS、JavaScript 等静态文件。如下图所示: 如下想要打包其它环境,可使用如下命令: npm run build:prod ## 打包 prod 生产环境npm run build:stage ## 打包 stage 预发布环境 其它高级参数说明【可暂时不看】: ① PUBLIC_PATH:静态资源地址,可用于七牛等 CDN 服务回源读取前端的静态文件,提升访问速度,建议 prod 生产环境使用。示例如下: ② VUE_APP_APP_NAME:二级部署路径,默认为 / 根目录,一般不用修改。 ③ mode:前端路由的模式,默认采用 history 路由,一般不用修改。可以通过修改 router/index.js (opens new window) 来设置为 hash 路由,示例如下: # 5.3 上传 dist 文件 在 Linux 服务器上创建 /work/projects/yudao-ui-admin 目录,使用 scp 命令或者 FTP 工具,将 dist 上传到 /work/nginx/html 目录下。如下图所示: # 5.4 启动前端? 前端无法直接启动,而是通过 Nginx 转发读取 /work/projects/yudao-ui-admin 目录的静态文件。 # 6. 配置 Nginx # 6.1 安装 Nginx Nginx 挂载到服务器的目录: /work/nginx/conf.d 用于存放配置文件 /work/nginx/html 用于存放网页文件 /work/nginx/logs 用于存放日志 /work/nginx/cert 用于存放 HTTPS 证书 ① 创建 /work/nginx 目录,并在该目录下新建 nginx.conf 文件,避免稍后安装 Nginx 报错。内容如下: user nginx;worker_processes 1;events { worker_connections 1024;}error_log /var/log/nginx/error.log warn;pid /var/run/nginx.pid;http { include /etc/nginx/mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"';# access_log /var/log/nginx/access.log main; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types text/plain application/x-javascript text/css application/xml application/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 include /etc/nginx/conf.d/*.conf; ## 加载该目录下的其它 Nginx 配置文件} ② 执行如下命令,使用 Docker 启动 Nginx 容器。 docker run -d \\--name nginx --restart always \\-p 80:80 -p 443:443 \\-e "TZ=Asia/Shanghai" \\-v /work/nginx/nginx.conf:/etc/nginx/nginx.conf \\-v /work/nginx/conf.d:/etc/nginx/conf.d \\-v /work/nginx/logs:/var/log/nginx \\-v /work/nginx/cert:/etc/nginx/cert \\-v /work/nginx/html:/usr/share/nginx/html ginx:alpine ③ 执行 docker ps 命令,查看到 Nginx 容器的状态是 UP 的。 下面,来看两种 Nginx 的配置,分别满足服务器 IP、独立域名的不同场景。 # 6.2 方式一:服务器 IP 访问 ① 在 /work/nginx/conf.d 目录下,创建 ruoyi-vue-pro.conf,内容如下: server { listen 80; server_name 139.9.196.247; ## 重要!!!修改成你的外网 IP/域名 location / { ## 前端项目 root /usr/share/nginx/html/yudao-admin-ui; index index.html index.htm; try_files $uri $uri/ /index.html; } location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://192.168.0.213:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://192.168.0.213:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }} 友情提示: [root] 指令在本地文件时,要使用 Nginx Docker 容器内的路径,即 /usr/share/nginx/html/yudao-admin-ui,否则会报 404 的错误。 ② 执行 docker exec nginx nginx -s reload 命令,重新加载 Nginx 配置。 友情提示:如果你担心 Nginx 配置不正确,可以执行 docker exec nginx nginx -t 命令。 ③ 执行 curl http://192.168.0.213/admin-api/ 命令,成功访问后端项目的内网地址,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} 执行 curl http://139.9.196.247:48080/admin-api/ 命令,成功访问后端项目的外网地址,返回结果一致。 ④ 请求 http://139.9.196.247:48080 (opens new window) 地址,成功访问前端项目的外网地址,,返回前端界面如下: # 6.3 方式二:独立域名访问 友情提示:在前端项目的编译时,需要把 `VUE_APP_BASE_API` 修改为后端项目对应的域名。 例如说,这里使用的是 http://api.iocoder.cn ① 在 /work/nginx/conf.d 目录下,创建 ruoyi-vue-pro2.conf,内容如下: server { ## 前端项目 listen 80; server_name admin.iocoder.cn; ## 重要!!!修改成你的前端域名 location / { ## 前端项目 root /usr/share/nginx/html/yudao-admin-ui; index index.html index.htm; try_files $uri $uri/ /index.html; }}server { ## 后端项目 listen 80; server_name api.iocoder.cn; ## 重要!!!修改成你的外网 IP/域名 ## 不要使用 location / 转发到后端项目,因为 druid、admin 等监控,不需要外网可访问。或者增加 Nginx IP 白名单限制也可以。 location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://192.168.0.213:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://192.168.0.213:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }} ② 执行 docker exec nginx nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://api.iocoder.cn/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://admin.iocoder.cn (opens new window) 地址,成功访问前端项目,返回前端界面如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 Linux 部署 Jenkins 部署 ← Linux 部署 Jenkins 部署→"},{"title":"开发环境","path":"/wiki/YuDaoCloud/运维手册/开发环境/开发环境.html","content":"开发指南运维手册 芋道源码 2022-04-11 目录 开发环境 在系统开发的经典模型,一般会分成 2 类 5 种环境: 【线下】本地环境(local)、开发环境(dev)、测试环境(test) 【线上】预发布环境(stage)、生产环境(prod) 每个环境、每个项目使用独立的二级域名 线下、线上各一套 MySQL 数据库,多个环境共享使用 每个环境对应一个配置文件,后端使用 application-{env}.yaml (opens new window) 文件,前端使用 .env.{env} (opens new window) 文件 友情提示:项目中暂时没有 test、stage、production 等环境的配置,需要自己创建。 另外,本文的 MySQL 数据库是基础设施的“泛指”,包括 Redis 缓存、MQ 消息队列,都需要线上线下独立。 # 1. 本地环境 后端工程师使用 application-local.yaml 配置文件,在本地电脑启动后端服务,连接线下 MySQL 数据库。考虑到不影响 dev、test 环境,会配置禁用定时任务、MQ 集群消费的执行。 前端工程师也会在本地电脑启动前端服务,一般不使用 .env.local 配置文件,而是使用 .env.dev 配置文件,访问 dev 环境的后端服务。如果需要和后端进行本地联调,可以使用 .env.local 配置文件。 # 2. 开发环境 dev 环境的用户是前端工程师、后端工程师,主要用于前后端的联调、又或者功能开发完后的自测。 一些公司可能不提供 dev 环境,直接使用 test 环境,适合团队规模较小的团队,可以降低服务器的成本。 不过,测试工程师可能比较反感 dev 和 test 环境不隔离,因为他们是按照测试用例,一轮一轮的进行验收。这个时候,如果前端或者后端工程师部署了 test 环境,“破坏”了他当前轮次的验收。 疑问:开发环境可以使用独立的 MySQL 数据库吗? 当然是可以的,提供更好的环境隔离性,避免开发阶段产生过多的脏数据,影响 test 环境的验收。 不过呢,这也带来额外的成本,部署程序到 test 环境时,需要做一次数据库的同步。 # 3. 测试环境 test 环境的用户是产品经理、测试工程师,主要用于他们的功能验收。 考虑到 test 环境的稳定性,一般建议由测试工程师使用 Jenkins 等工具,完成该环境的部署。具体的原因,上面 dev 环境已经解释了。 疑问:如果需要并行验收多个功能,怎么办? 并行验收多个功能时候,对应不同的 Git 分支,需要搭建多套测试环境。 # 4. 预发布环境 stage 环境的用户是产品经理、测试工程师,连接线上 MySQL 数据库,基于真实的数据,进行功能的全回归测试。 因为数据更加真实,且更具多样性,所以往往也会测试出较多的 Bug。比较好的解决方案,是将线上数据库定期脱敏,导入线下数据库。 考虑到 stage 环境的安全性,一般由技术经理、运维工程师进行部署。 一些公司可能不提供 stage 环境,直接上线到 production 环境,风险非常高,容易产生较多报错。 # 5. 生产环境 production 环境的用户是真实用户,即线上环境。一般发布上线时,会进行核心功能的快速测试,避免主流程存在问题。 考虑到 production 环境的问题排查效率,会给技术核心开放 MySQL 数据库的读权限。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 大屏设计器 Linux 部署 ← 大屏设计器 Linux 部署→"},{"title":"服务监控","path":"/wiki/YuDaoCloud/运维手册/服务监控/服务监控.html","content":"开发指南运维手册 芋道源码 2022-04-16 目录 服务监控 系统使用 Spring Boot Admin 和 SkyWalking 实现后端服务的监控。 # 1. Spring Boot Admin 阅读 《芋道 Spring Boot 监控工具 Admin 入门》 (opens new window) 文章,入门 Spring Boot Admin。 注意,Spring Boot Admin 是内嵌在 yudao-server 后端项目中,无需单独启动。 # 1.1 配置 在 application-local.yaml (opens new window) 配置文件中,通过 spring.boot.admin 配置项,设置 Spring Boot Admin 的配置。如下图所示: 疑问:prod 生产环境下,后端部署多个 JVM 进程时,spring.boot.admin.client.url 填写哪个 IP? 第一步,在 Nginx 中配置 /admin 路径,转发到多个 JVM 的 IP 上,使用 backup (opens new window) 参数实现主备。注意,该转发只允许内网访问,避免安全问题!!! 第二步,设置 spring.boot.admin.client.url 配置项,为 Nginx 的 内置 IP/admin 地址。 # 1.2 使用 ① 访问 http://127.0.0.1:48080//admin/applications (opens new window) 地址,可以在 Spring Boot Admin 中,查看到应用与实例的列表。如下图所示: ② 点击 yudao-server 应用,再点击实例,可以查看到该实例的细节信息。如下图所示: ③ 点击 [日志 -> 日志文件] 菜单,查看该示例的日志内容。如下图所示: 点击 [日志 -> 日志文件] 菜单,可动态修改 Logger 的日志级别,方便排查线上的某些 BUG。如下图所示: 补充说明:也可以通过前端的 [基础设施 -> Java 监控] 菜单。 前端 [基础设施 -> Java 监控] 菜单,通过 iframe 内嵌后端 /admin/applications 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.spring-boot-admin 配置项。 # 2. SkyWalking 阅读 《芋道 SkyWalking 极简入门》 (opens new window) 文章,入门 SkyWalking。 注意,SkyWalking 需要单独启动,预计需要 4 核 8G 的硬件资源。 # 2.1 配置 ① 在 logback-spring.xml (opens new window) 配置文件中,添加 SkyWalking 收集日志的 appender 配置。如下图所示: ② 修改 SkyWalking 在前端项目的 [基础设施 -> 监控平台] 对应的 skywaling/index.vue (opens new window) 文件,调整为你 SkyWalking 的访问地址。如下图所示: # 2.2 使用 ① 点击 [基础设施 -> 监控平台] 菜单,可以看到 SkyWalking 提供的监控平台。如下图所示: ② 点击 yudao-server 服务,查看该服务的监控信息。如下图所示: 补充说明: 前端 [基础设施 -> 监控平台] 菜单,通过 iframe 内嵌 http://skywalking.iocoder.cn 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.skywalking 配置项。 # 3. 更多监控系统 # 3.1 Prometheus 参见 《芋道 Prometheus + Grafana + Alertmanager 极简入门 》 (opens new window) 文章。 # 3.2 ELK 参见 芋道 ELK(Elasticsearch + Logstash + Kibana) 极简入门 (opens new window) 文章。 # 3.3 Sentry 参见 《Sentry 极简入门 》 (opens new window) 文章。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:24:52 HTTPS 证书 开发规范 ← HTTPS 证书 开发规范→"},{"title":"Linux 部署","path":"/wiki/YuDaoCloud/运维手册/Linux 部署/Linux 部署.html","content":"开发指南运维手册 芋道源码 2022-04-12 目录 Linux 部署 友情提示:目前是 Boot 项目的部署,后续会调整成 Cloud 项目的部署 本小节,讲解如何将前端 + 后端项目,使用 Shell 脚本,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: # 1. 配置 MySQL # 1.1 安装 MySQL(可选) 友情提示:安装 MySQL 是可选步骤,也可以购买 MySQL 云服务。 ① 执行如下命令,进行 MySQL 的安装。 ## ① 安装 MySQL 5.7 版本的软件源 https://dev.mysql.com/downloads/repo/yum/rpm -Uvh https://dev.mysql.com/get/mysql57-community-release-el7-11.noarch.rpm## ② 安装 MySQL Server 5.7 版本yum install mysql-server --nogpgcheck## ③ 查看 MySQL 的安装版本。结果是 mysqld Ver 5.7.37 for Linux on x86_64 (MySQL Community Server (GPL))mysqld --version ② 修改 /etc/my.cnf 文件,在文末加上 lower_case_table_names=1 和 validate_password=off 配置,执行 systemctl restart mysqld 命令重启。 ③ 执行 grep password /var/log/mysqld.log 命令,获得 MySQL 临时密码。 2022-04-16T09:39:57.365086Z 1 [Note] A temporary password is generated for root@localhost: ZOKUaehW2e.e ④ 执行如下命令,修改 MySQL 的密码,设置允许远程连接。 ## ① 连接 MySQL Server 服务,并输入临时密码mysql -uroot -p## ② 修改密码,123456 可改成你想要的密码alter user 'root'@'localhost' identified by '123456';## ③ 设置允许远程连接use mysql;update user set host = '%' where user = 'root';FLUSH PRIVILEGES; # 1.2 导入 SQL 脚本 创建一个名字为 ruoyi-vue-pro 数据库,执行数据库对应的 sql ( opens new window) 目录下的 SQL 文件,进行初始化。 # 2. 配置 Redis 友情提示:安装 Redis 是可选步骤,也可以购买 Redis 云服务。 执行如下命令,进行 Redis 的安装。 ## ① 安装 remi 软件源yum install http://rpms.famillecollet.com/enterprise/remi-release-7.rpm## ② 安装最新 Redis 版本。如果想要安装指定版本,可使用 yum --enablerepo=remi install redis-6.0.6 -y 命令yum --enablerepo=remi install redis ## ③ 查看 Redis 的安装版本。结果是 Redis server v=6.2.6 sha=00000000:0 malloc=jemalloc-5.1.0 bits=64 build=4ab9a06393930489redis-server --version## ④ 启动 Redis 服务systemctl restart redis 端口是 6379,密码未设置 # 3. 部署后端 # 3.1 修改配置 后端 dev 开发环境对应的是 application-dev.yaml (opens new window) 配置文件,主要是修改 MySQL 和 Redis 为你的地址。如下图所示: # 3.2 编译后端 在项目的根目录下,执行 mvn clean package -Dmaven.test.skip=true 命令,编译后端项目,构建出它的 Jar 包。如下图所示: 疑问:-Dmaven.test.skip=true 是什么意思? 跳过单元测试的执行。如果你项目的单元测试写的不错,建议使用 mvn clean package 命令,执行单元测试,保证交付的质量。 # 3.3 上传 Jar 包 在 Linux 服务器上创建 /work/projects/yudao-server 目录,使用 scp 命令或者 FTP 工具,将 yudao-server.jar 上传到该目录下。如下图所示: 疑问:如果构建 War 包,部署到 Tomcat 下? 并不推荐采用 War 包部署到 Tomcat 下。如果真的需要,可以参考 《Deploy a Spring Boot WAR into a Tomcat Server》 (opens new window) 文章。 # 3.4 编写脚本 在 /work/projects/yudao-server 目录下,新建 Shell 脚本 deploy.sh,用于启动后端项目。编写内容如下: #!/bin/bashset -eDATE=$(date +%Y%m%d%H%M)# 基础路径BASE_PATH=/work/projects/yudao-server# 服务名称。同时约定部署服务的 jar 包名字也为它。SERVER_NAME=yudao-server# 环境PROFILES_ACTIVE=dev# heapError 存放路径HEAP_ERROR_PATH=$BASE_PATH/heapError# JVM 参数JAVA_OPS="-Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=$HEAP_ERROR_PATH"# SkyWalking Agent 配置#export SW_AGENT_NAME=$SERVER_NAME#export SW_AGENT_COLLECTOR_BACKEND_SERVICES=192.168.0.84:11800#export SW_GRPC_LOG_SERVER_HOST=192.168.0.84#export SW_AGENT_TRACE_IGNORE_PATH="Redisson/PING,/actuator/**,/admin/**"#export JAVA_AGENT=-javaagent:/work/skywalking/apache-skywalking-apm-bin/agent/skywalking-agent.jar# 停止:优雅关闭之前已经启动的服务function stop() { echo "[stop] 开始停止 $BASE_PATH/$SERVER_NAME" PID=$(ps -ef | grep $BASE_PATH/$SERVER_NAME | grep -v "grep" | awk '{print $2}') # 如果 Java 服务启动中,则进行关闭 if [ -n "$PID" ]; then # 正常关闭 echo "[stop] $BASE_PATH/$SERVER_NAME 运行中,开始 kill [$PID]" kill -15 $PID # 等待最大 120 秒,直到关闭完成。 for ((i = 0; i < 120; i++)) do sleep 1 PID=$(ps -ef | grep $BASE_PATH/$SERVER_NAME | grep -v "grep" | awk '{print $2}') if [ -n "$PID" ]; then echo -e ".\\c" else echo '[stop] 停止 $BASE_PATH/$SERVER_NAME 成功' break fi done # 如果正常关闭失败,那么进行强制 kill -9 进行关闭 if [ -n "$PID" ]; then echo "[stop] $BASE_PATH/$SERVER_NAME 失败,强制 kill -9 $PID" kill -9 $PID fi # 如果 Java 服务未启动,则无需关闭 else echo "[stop] $BASE_PATH/$SERVER_NAME 未启动,无需停止" fi}# 启动:启动后端项目function start() { # 开启启动前,打印启动参数 echo "[start] 开始启动 $BASE_PATH/$SERVER_NAME" echo "[start] JAVA_OPS: $JAVA_OPS" echo "[start] JAVA_AGENT: $JAVA_AGENT" echo "[start] PROFILES: $PROFILES_ACTIVE" # 开始启动 nohup java -server $JAVA_OPS $JAVA_AGENT -jar $BASE_PATH/$SERVER_NAME.jar --spring.profiles.active=$PROFILES_ACTIVE > nohup.out 2>&1 & echo "[start] 启动 $BASE_PATH/$SERVER_NAME 完成"}# 部署function deploy() { cd $BASE_PATH # 第一步:停止 Java 服务 stop # 第二步:启动 Java 服务 start}deploy 友情提示: 脚本的详细讲解,可见 《芋道 Jenkins 极简入门 》 (opens new window) 的「2.3 远程服务器配置 」小节。 如果你想要修改脚本,主要关注 BASE_PATH、PROFILES_ACTIVE、JAVA_OPS 三个参数。如下图所示: # 3.5 启动后端 ① 【可选】执行 yum install -y java-1.8.0-openjdk 命令,安装 OpenJDK 8。 友情提示:如果已经安装 JDK,可不安装。建议使用的 JDK 版本为 8、11、17 这三个。 ② 执行 sh deploy.sh 命令,启动后端项目。日志如下: [stop] 开始停止 /work/projects/yudao-server/yudao-server[stop] /work/projects/yudao-server/yudao-server 未启动,无需停止[start] 开始启动 /work/projects/yudao-server/yudao-server[start] JAVA_OPS: -Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/work/projects/yudao-server/heapError[start] JAVA_AGENT:[start] PROFILES: dev[start] 启动 /work/projects/yudao-server/yudao-server 完成 ③ 执行 tail -f nohup.out 命令,查看启动日志。看到如下内容,说明启动完成: 2022-04-13 00:06:20.049 INFO 1395 --- [main] [TID: N/A] c.i.yudao.server.YudaoServerApplication : Started YudaoServerApplication in 35.315 seconds (JVM running for 36.282) # 4. 部署前端 # 4.1 修改配置 前端 dev 开发环境对应的是 .env.dev ( opens new window) 配置文件,主要是修改 VUE_APP_BASE_API 为你的后端项目的访问地址。如下图所示: # 4.2 编译前端 在 yudao-ui-admin 目录下,执行 npm run build:dev 命令,编译前端项目,构建出它的 dist 文件,里面是 HTML、CSS、JavaScript 等静态文件。如下图所示: 如下想要打包其它环境,可使用如下命令: npm run build:prod ## 打包 prod 生产环境npm run build:stage ## 打包 stage 预发布环境 其它高级参数说明【可暂时不看】: ① PUBLIC_PATH:静态资源地址,可用于七牛等 CDN 服务回源读取前端的静态文件,提升访问速度,建议 prod 生产环境使用。示例如下: ② VUE_APP_APP_NAME:二级部署路径,默认为 / 根目录,一般不用修改。 ③ mode:前端路由的模式,默认采用 history 路由,一般不用修改。可以通过修改 router/index.js (opens new window) 来设置为 hash 路由,示例如下: # 4.3 上传 dist 文件 在 Linux 服务器上创建 /work/projects/yudao-ui-admin 目录,使用 scp 命令或者 FTP 工具,将 dist 上传到该目录下。如下图所示: # 4.4 启动前端? 前端无法直接启动,而是通过 Nginx 转发读取 /work/projects/yudao-ui-admin 目录的静态文件。 # 5. 配置 Nginx # 5.1 安装 Nginx 参考 Nginx 官方文档 (opens new window),安装 Nginx 服务。命令如下: ## 添加 yum 源yum install epel-releaseyum update## 安装 nginxyum install nginx## 启动 nginx nginx Nginx 默认配置文件是 /etc/nginx/nginx.conf。 下面,来看两种 Nginx 的配置,分别满足服务器 IP、独立域名的不同场景。 # 5.2 方式一:服务器 IP 访问 ① 修改 Nginx 配置,内容如下: worker_processes 1;events { worker_connections 1024;}http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 server { listen 80; server_name 192.168.225.2; ## 重要!!!修改成你的外网 IP/域名 location / { ## 前端项目 root /work/projects/yudao-ui-admin; index index.html index.htm; try_files $uri $uri/ /index.html; } location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://localhost:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://localhost:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }} ② 执行 nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://192.168.225.2/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://192.168.225.2 (opens new window) 地址,成功访问前端项目,返回前端界面如下: # 5.3 方式二:独立域名访问 友情提示:在前端项目的编译时,需要把 `VUE_APP_BASE_API` 修改为后端项目对应的域名。 例如说,这里使用的是 http://api.iocoder.cn ① 修改 Nginx 配置,内容如下: worker_processes 1;events { worker_connections 1024;}http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types text/plain application/x-javascript text/css application/xml application/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 server { ## 前端项目 listen 80; server_name admin.iocoder.cn; ## 重要!!!修改成你的前端域名 location / { ## 前端项目 root /work/projects/yudao-ui-admin; index index.html index.htm; try_files $uri $uri/ /index.html; } } server { ## 后端项目 listen 80; server_name api.iocoder.cn; ## 重要!!!修改成你的外网 IP/域名 ## 不要使用 location / 转发到后端项目,因为 druid、admin 等监控,不需要外网可访问。或者增加 Nginx IP 白名单限制也可以。 location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://localhost:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://localhost:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }} ② 执行 nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://api.iocoder.cn/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://admin.iocoder.cn (opens new window) 地址,成功访问前端项目,返回前端界面如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 开发环境 Docker 部署 ← 开发环境 Docker 部署→"}] \ No newline at end of file +[{"title":"微信读书自动阅读","path":"//Tencent-WxRead-Daily/","content":"前言本文章实现需要服务器, 无可视化界面亦可。使用的Cookie获取上一篇文章有介绍, 顺手写了这篇。 每日一问: 我为什么要实现这个功能??? 微信读书Cookie续活https://blog.thatcoder.cn/Tencent-WxRead-Cookies/ 机制分析网页版状态下阅读, 每分钟左右会有一个read请求, 通过回执可以判断是否阅读成功。具体参数我不想耗费时间去逆向, 但是可以通过模拟浏览阅读页面来等待read响应进行read重播,进而轻易实现自动阅读。 稳定性服务器测试了24小时, 阅读时间也是相应增加24。 有趣的是, 经测试, 每次程序运行5min, 增加的时长可能是 5min、6min、8min、11min、13min 甚至是 21min。但是总时长是稳定的, 也就是说会回归一天能拉满的时间24h。 实现代码虽说是浏览器模拟事件, 到了python的表演时间, 但是我采用了JS去写, 辅佐包是 Playwright 。总体是一次有趣的尝试。 准备事项开始吧, 安装 Playwright # 先创建一个文件夹mkdir /server/auto/wxread && cd /server/auto/wxread# 安装 playwrightnpm install playwrightnpx playwright install# 下面这个可能需要点时间# 因为有浏览器的下载npx playwright install-deps# 当然少不了 axiosnpm install axios# 好的, 一切准备就绪, 创建代码吧 代码wxread.jsconst { firefox } = require('playwright');const axios = require('axios');// 获取命令行参数const args = process.argv.slice(2);const params = {};args.forEach((arg) => { const [key, value] = arg.split('='); if (key && value) { params[key] = value; }});const url1 = 'https://weread.qq.com/web/reader/8f5329e0813ab7d1eg012feake4d32d5015e4da3b7fbb1fa';const url2 = 'https://weread.qq.com/web/book/read';let capturedResponse = null;let browser = null;const scrollInterval = 10000; // 上下滑动间隔时间 单位毫秒const totalTime = 400000; // 单次阅读时间 单位毫秒const getXHR = async () => { console.log("Success: 启动 Playwright 浏览器"); browser = await firefox.launch({ headless: true, }); const page = await browser.newPage(); await page.setExtraHTTPHeaders({ cookie: (await axios.get("https://sijnzx.laf.thatcoder.cn/tencent-weread-refcookie?key="+params['key'])).data["data"]["cookies"] }); await page.goto(url1, { waitUntil: 'networkidle', }); console.log("Success: 打开内容页面"); page.on('response', async (response) => { if (response.url() === url2) { const data = await response.json(); if (data['succ'] === 1) { console.log("Success: 目标URL响应成功"); } else { console.log("Error: 目标URL响应失败"); } capturedResponse = data['succ'] === 1 ? response : null; await repeatXHR(100); // 不要关闭浏览器 } });// 定期上下滑动 let scrollCount = 0; // 计数器 let scrollDirection = 1; // 1表示向下滑动,-1表示向上滑动 setInterval(async () => { await page.evaluate((scrollDirection) => { const windowHeight = window.innerHeight; window.scrollBy(0, scrollDirection * windowHeight); // 向上或向下滑动一个屏幕高度 }, scrollDirection); scrollCount++; // 如果达到了五次滑动,切换方向并重置计数器 if (scrollCount === 5) { scrollDirection *= -1; // 切换方向 scrollCount = 0; // 重置计数器 } }, scrollInterval); // 设置浏览器关闭定时器 setTimeout(async () => { console.log("Success: 关闭浏览器"); await browser.close(); }, totalTime);};const repeatXHR = async (count) => { if (!capturedResponse) { console.log("Failed: 没有捕获到响应,无法重放"); return; } const request = capturedResponse.request(); for (let i = 0; i < count; i++) { try { const response = await axios({ method: request.method(), url: request.url(), headers: request.headers(), params: request.params, data: request.postData(), }); if (response.data.succ !== 1) { console.log(`Failed: 重放响应 ${i + 1}: 失败, succ!==1`); return; } } catch (error) { console.error(`Failed: 重放响应 ${i + 1}: 失败, ${error.message}`); } } console.log(`Success: 重放响应 ${count} 次完毕`)};(async () => { await getXHR();})(); 运行代码会启动一个无头浏览器, 所以没有可视化也不需要担心。个人测试24小时, 无任何问题, 使用的内存为300MB左右, CPU占用率为0.1%左右。对了, 带上key参数是我接口的鉴权, 也就是上一篇文章的参数(个人有所修改)。你实现了上一篇文章的获取可以使用你的接口。保证cookie是有效的即可。 node wxread.js key=xxxx# 成功运行大概输出如下# Success: 启动 Playwright 浏览器# Success: 打开内容页面# Success: 目标URL响应成功# Success: 重放响应 100 次完毕# Success: 目标URL响应成功# Success: 重放响应 100 次完毕# Success: 浏览器关闭 (400秒后)","tags":["Tencent"],"categories":["堆栈"]},{"title":"微信读书Cookies续活","path":"//Tencent-WxRead-Cookies/","content":"机制分析很多优秀的文章分析了延期机制, 这里列举两个 Hank's Blog 微信读书延期机制分析https://zhaohongxuan.github.io/2022/05/16/how-to-relong-cookies-in-weread/ 陈虚渊 微信读书数据内容接口逆向https://blog.csdn.net/paycho/article/details/132796745 稳定性目前跑了几天, Cookies都能自动刷新保活 每小时自动刷新回执 主要代码续活我没使用代理服务器, 直接请求了 refCookie// 刷新 Cookie 的函数,模拟发送请求获取新 Cookieconst refCookie = async (uid: string) => { try { const response = await axios.head('https://weread.qq.com', { headers: globalHeaders() }); if (response.status === 200 || response.headers['set-cookie']) { globalCookies = CookieUtil.WebArrayToString(response.headers['set-cookie'], globalCookies); return (await upUserCookie(uid)) ? true : false }else { return false } } catch (e) { return false }} 全部代码 运行在自己搭建的 Laf 云函数, 不能无脑抄。 代码虽烂 但已写注释。需要临时使用我的接口可以联系我 代码结构图这样也许清晰一点 Serverless Codeimport axios from 'axios';import cloud from "@/cloud-sdk";/** * API请求入口方法 */exports.main = async function (ctx: FunctionContext) { try { const { cookies, uid, refresh } = ctx.method === 'GET' ? ctx.query || ctx.params : ctx.body; if (verifyData(uid)) { // 用户获取cookie请求 if ((await userServer.verifyUser(uid))) { !(await CookiesApi.verifyRefresh(uid)) || (await CookiesApi.refCookie(refresh)) return msgServer.success("获取Cookie成功", { cookies: globalCookies }) } else { return msgServer.failed("搞咩! " + uid + " 不存在!"); } } else if (verifyData(cookies)) { // 新增用户请求 const uid = CookieUtil.StringToJson(cookies)['wr_vid'] globalCookies = cookies const userInfo = (await CookiesApi.getUserInfo(uid)) if (!userInfo['name']) { return msgServer.failed("搞咩! cookies 不能用!"); } const userData: WxReadUser = { 'userVid': uid, 'userInfo': userInfo, 'cookies': globalCookies, 'cookies_uptime': (new Date()).valueOf(), 'cookies_life': true } const add = (await userServer.addUser(userData)) if (add.answer) { return msgServer.success( `存入cookies成功, 未来取用cookies请通过以下方式${' '}[ https://sijnzx.laf.thatcoder.cn/tencent-weread-refcookie?uid=您的userVid ]`, { userVid: uid, userInfo } ) } else { return msgServer.error() } } else if (verifyData(refresh)) { // 刷新请求 let req: any if (!(await CookiesApi.verifyRefresh(refresh))) { return msgServer.success("Cookie不需要刷新") } else { return (await CookiesApi.refCookie(refresh)) ? msgServer.success("刷新Cookie成功") : msgServer.error() } } else { return msgServer.failed("搞咩! 传的什么狗屁参数!"); } } catch (error) { return msgServer.error() }};/** * 获取数据库访问器 */const db = cloud.database().collection('tc_tencent_wxread');/** * cookies格式工具 */const CookieUtil = { StringToJson: (cookiesString: string) => { const cookieGroup = cookiesString.split('; ') const cookieJson = {} for (let i = 0; i < cookieGroup.length; i++) { const cookieGroupJson = cookieGroup[i].split('=') cookieJson[cookieGroupJson[0]] = cookieGroupJson.length === 1 ? '' : cookieGroupJson[1] } return cookieJson }, JsonToString: (cookiesJson: object) => { const keyValuePairs = []; for (const key in cookiesJson) { if (cookiesJson.hasOwnProperty(key)) { const value = cookiesJson[key]; keyValuePairs.push(`${key}=${value}`); } } return keyValuePairs.join('; '); }, WebArrayToString: (cookiesArray: Array<string>, cookiesString: string) => { let cookieJson = CookieUtil.StringToJson(cookiesString) for (const cookie of cookiesArray) { const refresh: Array<string> = cookie.split('; ')[0].split('=') cookieJson[refresh[0]] = refresh[1] } return CookieUtil.JsonToString(cookieJson) }, StringToArray: (cookiesString: string) => { return cookiesString.split('; ') }}// 全局变量。不合理, 但是能减少云函数单文件代码量var globalCookies = ""var globalHeaders = () => { return { Cookie: CookieUtil.StringToArray(globalCookies), // 传入的 Cookie 数组 Referer: 'https://weread.qq.com/', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': '*', }}/** * 微信读书API方法 */const CookiesApi = { /** * 获取用户信息 * @uid: 微信Cookie['wr_vid'] */ getUserInfo: async (uid: string) => { let userInfo = { 'userVid': "" } await axios.get('https://weread.qq.com/web/user?userVid=' + uid, { headers: globalHeaders() }).then(e => { userInfo = e.data }) return userInfo }, /** * 验证Cookie是否存活 * @uid: 微信Cookie['wr_vid'] */ verifyAlive: async (uid: string) => { const cookie = (await userServer.getUserCookie(uid)) globalCookies = cookie let userInfo = await CookiesApi.getUserInfo(uid) return (String)(userInfo['userVid']).includes(uid) }, // 判断是否需要刷新 Cookie verifyRefresh: async (uid: string) => { const time = (await userServer.getUserCookieTime(uid)) if ( !(await CookiesApi.verifyAlive(uid)) || (new Date()).valueOf() - time >= 3600000) { return true } return false }, // 刷新 Cookie 的函数,模拟发送请求获取新 Cookie refCookie: async (uid: string) => { try { const response = await axios.head('https://weread.qq.com', { headers: globalHeaders() }); // if (response.status === 200) { // return true // } const newCookies = response.headers['set-cookie'] if (!newCookies) { return false } globalCookies = CookieUtil.WebArrayToString(newCookies, globalCookies); return (await userServer.upUserCookie(uid)) ? true : false } catch (e) { return false } }}/** * 数据库服务层。 呵, JS哪来的服务层 */const userServer = { /** * 验证数据库是否存在用户 * @uid: 微信Cookie['wr_vid'] * @return: {boolean} */ verifyUser: async (uid: string) => { return (await db.where({ 'userVid': uid }).count()).total > 0 }, /** * 获取用户Cookie * @uid: 微信Cookie['wr_vid'] */ getUserCookie: async (uid: string) => { const get = (await db.where({ 'userVid': uid }).limit(1).get()) return get.data[0]['cookies'] }, /** * 获取用户CookieTime * @uid: 微信Cookie['wr_vid'] */ getUserCookieTime: async (uid: string) => { const get = (await db.where({ 'userVid': uid }).limit(1).get()) return get.data[0]['cookies_uptime'] }, /** * 更新用户Cookie */ upUserCookie: async (uid: string) => { return (await db.where({'userVid': uid}).limit(1).update({ cookies: globalCookies, cookies_uptime: (new Date()).valueOf(), cookies_life: true })).ok }, /** * 新增数据库用户信息 */ addUser: async (userData: WxReadUser) => { let add: any if ((await userServer.verifyUser(userData.userVid))) { add = (await db.where({'userVid': userData.userVid}).limit(1).update(userData)) } else { add = (await db.add(userData)) } return { answer: add.ok, id: add.upsertId } }}/** * 回执服务层 */const msgServer = { success: (msg: string, data: any = {}) => { return JSON.stringify({ statusCode: 200, event: "操作成功", message: msg, data }) }, failed: (msg: string) => { return JSON.stringify({ statusCode: 400, event: "操作失败", message: msg }) }, error: () => { return JSON.stringify({ statusCode: 500, event: "程序错误", message: "请联系钟意, 必应搜索钟意博客。" }) }}/** * 微信用户对象接口 */interface WxReadUser { 'userVid': string, 'userInfo'?: any, 'cookies': string, 'cookies_uptime'?: number, 'cookies_life'?: boolean}const verifyData = (data: any) => { if (data === null || data === [] || data === {} || data === undefined || data === '') { return false } else if (data.length > 0) { return true } else { return false }} 实际应用 获取自己的微信读书信息 下载微信读书的书籍 导出书单信息 带出读书笔记 自动阅读( 这个功能有什么用? ) 自动阅读微信读书https://blog.thatcoder.cn/Tencent-WxRead-Daily/","tags":["Tencent"],"categories":["堆栈"]},{"title":"Linux 挂载磁盘","path":"//Linux-Add-Device/","content":"大致步骤 准备挂载目录 磁盘分区 格式化分区 挂载磁盘 创建目录没啥好说的, 看你喜欢啥名字 创建目录mkdir -p /extra 磁盘分区先查看磁盘是否需要分区 磁盘信息fdisk -l 查看需要分区的 `Device Boot`fdisk -l 打印信息 开始分区# 根据你的 Device Boot 更改 /dev/vdafdisk /dev/vda# 根据提示依次进行以下输入# n、p、1、回车、回车、wq 再次打印磁盘信息会有多一个区 格式化分区格式化分区# 这里填多出来的那个 Device Bootmkfs.ext4 /dev/vda1 挂载这样修改/etc/fstab下次重启就不会丢失挂载信息 挂载# /dev/vda1 和 /extra 还是根据你的来echo "/dev/vda1 /extra ext4 defaults 0 0" >> /etc/fstab Extra顺手记录几种查看磁盘UUID方法 查看UUID# 块设备信息 树形lsblk -o name,mountpoint,size,uuid# 查看/etc/fstab 文件cat /etc/fstab# 块设备信息blkidls -lh /dev/disk/by-uuid/","tags":["Linux"],"categories":["堆栈"]},{"title":"Stellar 提高时间线适配范围","path":"//Stellar-Timeline-More/","content":"前言某天想把其它app的动态放进时间线, 但每个app接口都返回不同的json数据格式, 即使同一个app不同提取项目也是不同的json数据格式,便不了了之。直到前几天萌生一个想法: 通过传入有效路径匹配提取对应的json数据。但是这样代码太长就不推送了, 也需要的人自己加进去(不影响主题升级) 目前成果 编写路径即可匹配数据 编写路径时赋予路径类型可生成对应类型组件 允许多个api聚合到一个时间线展示 有时间字段可按照时间排序 排除包含的内容、正则匹配替换内容 聚合的时间线 加入主题 经常使用git的coder直接看提交吧 [add] 添加timeline功能: api自适应注意最后一个custom.js非终版, 以下方的为准 路径以stellar主题为根 文件路径: _config.yml 一处 _config.ymlplugins: stellar: ......+ custom: /js/plugins/custom.js 文件路径: layout/_partial/widgets/timeline.ejs 15行一处 timeline.ejs- ['api', 'user', 'hide', 'limit'].forEach(key => {+ ['api', 'user', 'hide', 'limit', 'config'].forEach(key => { 文件路径: scripts/tags/lib/timeline.js 38,45行两处 timeline.js# 38行- args = ctx.args.map(args, ['api', 'user', 'type', 'limit', 'hide')+ args = ctx.args.map(args, ['api', 'user', 'type', 'limit', 'hide', 'config'])# 45行- el += ' ' + ctx.args.joinTags(args, ['api', 'user', 'limit', 'hide']).join(' ')+ el += ' ' + ctx.args.joinTags(args, ['api', 'user', 'limit', 'hide', 'config']).join(' ') 文件路径: source/js/plugins/custom.js 添加一整个JS文件custom.js-持续更新https://kedao.thatcoder.cn/#s/9kZW_6Eg 食用方法作为一个timeline插件形式, 所以使用和正常的timeline一样, 只是多了一个config。 有点抽象, 我尽能力表述清楚 示例以下是一个基本使用格式 简单使用示例代码数据代码xxx.md{% timeline api:https://blog.thatcoder.cn/custom/test/timetest1.json type:custom config:"[{ 'type': 'root', 'src': 'data' }, { 'type': 'msg', 'src': 'content|markdown:true' }, { 'type': 'tags', 'src': 'map:talkTags' },{ 'type': 'timestamp', 'src': 'time时间戳' }]" %}{% endtimeline %}timetest1.json{ "id": "timetest1", "data": [ { "talkTags": ["测试", "BUG制造者"], "content": "这是timetest1的**第一个数据**, 时间为2023-08-11", "time时间戳": "1691740257" }, { "talkTags": ["摆烂", "佛祖保佑", "永无BUG"], "content": "这是timetest1的第二个数据, 时间为2023-06-06", "time时间戳": "1686037857" }, { "talkTags": ["再看一眼","就会爆炸"], "content": "这是timetest1的第三个数据, 时间为2023-07-06 再看一眼就会爆炸, 应该排除", "time时间戳": "1688629857" } ]} 关于config 我们现在把config单独拿出来, 它就是一个数组, 里面有每个配置对象。 xxx.md[ {'type': '组件名', 'src':'指令:参数|指令:参数' }] 组件名和指令细分在下文现在需要注意的是以下几点: {% timeline ... %} 不能分行, 必须一行。 config整体用双引号包裹, 里面的内容用单引号包裹, 都是英文的! 暂时就这些 指令 指令其实就是调用什么方法去处理指令附属的内容指令之间是协同的 (比如使用1、2搭配拿到数据,再使用其余指令加以处理补充)default比较特殊, 一般用了default就不需要使用其余的主指令是1、2, 常用指令是3、4 filter (可省略, 默认指令) 用途: 匹配数据的方法之一, 匹配的内容为单个 参数: 填写对应的路径, 路径指向的地方是字符串、数值之类 提示: 字符串形式的json或数值也能匹配, 请大胆写路径 map 用途: 匹配数据的方法之一, 匹配的内容为复数 参数: 填写对应的路径, 路径指向的地方是数组之类的集合 default (编码) 用途: 放弃匹配, 使用默认值 参数: 填写组件显示的默认值 提示: 常用来补充作者名、作者头像、来源、来源icon等 base (编码) 用途: 给匹配到的内容追加前缀 参数: 填写需要追加的前缀 提示: 常用来根据ID拼凑源链接、给图片拼凑基础URL。 后缀的话…没写! markdown 用途: 简易的markdown转义 参数: 填写 true 提示: Memos的内容就是markdown exclude (编码) 用途: 若包含内容关键字, 则放弃这条数据 参数: 填写需要匹配的跳过循环的内容 提示: 比如我网易云动态有分享黑胶礼品卡, 我就填写的黑胶 regex (编码) 用途: 正则替换 参数: 第一个参数为正则规则, 第二个参数为替换内容(不写就是替换为空字符串) 提示: memos去标签的实现 ‘…|regex:#[\\d\\u4e00-\\u9fa5a-zA-Z]+[\\s ]‘ (方便展示, 记得编码) 注意事项: 我忘了要注意什么, 但开发时候依稀记得regex第一个正则参数需要注意点什么…私密马赛 组件 组件其实就是用对应的已经准备好的div和样式去装载内容 root (很重要, 要写在最前面) 组件内容: 接口数据真正的主体 参数类型: 基础路径 提示: 这不是组件, 是一个特殊的配置。指向数据真正的主体(一般指向的是array), 不然其它路径很长且重复 author 组件内容: 时间节点上显示的作者名称 参数类型: 字符串 avatar 组件内容: 时间节点上显示的作者头像 参数类型: 链接 avatar 组件内容: 时间节点上显示的时间 参数类型: 时间戳 提示: 没写多少解析,尽量是标准的时间戳或其字符串, 11位13位均可 tags 组件内容: 内容主体右上角的小标签 参数类型: 字符串或数组 提示: 类似话题之类的 title 组件内容: 内容主体上方居中的标题 参数类型: 字符串或数组 提示: 一般用不上啦 msg 组件内容: 内容主体内容 参数类型: 字符串 提示: 类似 之类的已经解析了, 更多解析记得开启markdown quote 组件内容: 内容主体msg下面的引用 参数类型: 字符串 提示: 类似于回复的原内容, 我是因为微信读书笔记有引用 pics 组件内容: 内容主体msg下面的图片 参数类型: 链接 (字符串或数组) 提示: 即使是数组也是显示数组的第一张图片, 不然很丑的! 预留了多张, 请设计一个方案给我. netease 组件内容: 内容主体msg下面的音乐 参数类型: 网易云音乐歌曲ID 提示: QQ音乐请先打钱, 私密马赛QAQ link 组件内容: 左下角的小火箭, 点击跳转动态源链接 参数类型: 链接 提示: 一般动态之类的只有ID, 记得加base补充完整 origin 组件内容: 右下角的文字 参数类型: 字符串 提示: 我一般用来写 ‘– Form XXX’, 已经赋予了斜体 icon 组件内容: 右下角的图标 参数类型: 链接 提示: 我一般用来放来源的icon, 至于你呢, 你喜欢便好 编码 因为涉及到正则、冒号、竖杠等特殊字符, 有编码标注的地方需使用下面的编码, 在浏览器控制台即可使用 编码window.btoa(window.encodeURIComponent(String.raw'输入编码内容')); (编码里面不是单引号, 是常用来包裹代码的符号) 解码window.decodeURIComponent(window.atob('输入解码内容')) 编码 解码 复制 function encodeText(inputId, resultId) { const inputText = document.getElementById(inputId).value; document.getElementById(resultId).value = window.btoa(window.encodeURIComponent(String.raw`${inputText}`)); util.copy(resultId, '复制成功!') } function decodeText(inputId, resultId) { const inputText = document.getElementById(inputId).value; document.getElementById(resultId).value = window.decodeURIComponent(window.atob(inputText)); util.copy(resultId, '复制成功!') } 指令教程匹配单个 匹配目标集合 没了不知道写什么, 有问题再问吧! 进阶 已经写成屎山了, 我还在乎多来几个for循环 ?这里虽然是进阶, 但毫无难度, 只是可能有bug, 排了bug记得告诉我! timelines一定要紧接在root组件后面, root没有就写在最前面。 sort 用途: 全节点排序 参数: timestamp 顺序 | pmatsemit 逆序 (目前只支持时间排序) identifier 用途: 集合标识符 参数: 随便一个单词 提示: 需要集合在一起的timeline的标识符是一样的 num 用途: 这个标识符集合的数量 参数: 数值 提示: 考虑到api请求耗时不一样, 还是加一个num为妥, 不满则等待{ 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' } 代码参考看着json数据和对应config代码, 相信你就能明白一切, 并且大喊一声: 狗屁设计!!! 网易接口: https://netease.thatapi.cn/user/event?uid=134968139&limit=10 Memos接口: https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 微信读书接口: https://blog.thatcoder.cn/custom/test/ThatRead.json (需要提取微信读书数据可留言) 代码参考渲染结果https://blog.thatcoder.cn/邮箱模板集/#组装时间线测试 参考代码### 网易云memos微信读书联合测试{% timeline api:https://netease.thatapi.cn/user/event?uid=134968139&limit=10 type:custom config:"[{ 'type': 'root', 'src': 'events' }, { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'user.nickname' }, { 'type': 'avatar', 'src': 'user.avatarUrl' }, { 'type': 'msg', 'src': 'json.msg' }, { 'type': 'netease', 'src': 'json.song.id' }, { 'type': 'tags', 'src': 'map:bottomActivityInfos|name|exclude:JUU5JUJCJTkxJUU4JTgzJUI2' }, { 'type': 'pics', 'src': 'map:pics|originUrl' }, { 'type': 'timestamp', 'src': 'showTime' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRiVFNyVCRCU5MSVFNiU5OCU5MyVFNCVCQSU5MSVFOSU5RiVCMyVFNCVCOSU5MC5zdmc='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwJUU3JUJEJTkxJUU2JTk4JTkzJUU0JUJBJTkxJUU5JTlGJUIzJUU0JUI5JTkw' } ]" %}{% endtimeline %}{% timeline api:https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 type:custom config:"[{ 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw==' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]" %}{% endtimeline %}{% timeline api:https://blog.thatcoder.cn/custom/test/ThatRead.json type:custom config:"[{ 'type': 'root', 'src': 'data' }, { 'type': 'timelines', 'identifier': 'life', 'num': '3', 'sort': 'timestamp' }, { 'type': 'author', 'src': 'ideaAuthor' }, { 'type': 'avatar', 'src': 'ideaAvtar' }, { 'type': 'msg', 'src': 'ideaContent' }, { 'type': 'quote', 'src': 'ideaQuote' }, { 'type': 'timestamp', 'src': 'ideaTime' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRiVFNSVCRSVBRSVFNCVCRiVBMSVFOCVBRiVCQiVFNCVCOSVBNi5zdmc='}, { 'type': 'origin', 'src': 'default:LS0lMjBGcm9tJTIwJUU1JUJFJUFFJUU0JUJGJUExJUU4JUFGJUJCJUU0JUI5JUE2' } ]" %}{% endtimeline %}### memos单个测试标识符不同应该不会混淆进去{% timeline api:https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 type:custom config:"[{ 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw==' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]" %}{% endtimeline %} 侧边栏使用 效果是主页侧边栏的 近期动态 参考代码memosLife: layout: timeline title: 近期动态 api: https://memos.thatcoder.cn/api/v1/memo/all?reatorId=1&limit=20 type: custom config: "[{ 'type': 'author', 'src': 'creatorName' }, { 'type': 'avatar', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmF1dGhvci5qcGc=' }, { 'type': 'msg', 'src': 'content|regex:JTIzJTVCJTVDZCU1Q3U0ZTAwLSU1Q3U5ZmE1YS16QS1aJTVEJTJCJTVCJTVDcyU1Q24lNUQ=|markdown:true' }, { 'type': 'pics', 'src': 'map:resourceList|externalLink' }, { 'type': 'timestamp', 'src': 'createdTs' }, {'type': 'icon', 'src': 'default:aHR0cHMlM0ElMkYlMkZibG9nLnRoYXRjb2Rlci5jbiUyRmN1c3RvbSUyRmltZyUyRmZsb21vLnN2Zw=='}, { 'type': 'origin', 'src': 'default:LS0lMjBGb3JtJTIwTWVtb3M=' } ]" 结语我再也不想写这种代码, 简直是屎山, 不 这就是屎山! 虽说是屎山, 但至少能让我随意对接接口了, 不是吗。 钟意你依然是个喜欢一劳永逸的人呢。 如果多一个人使用, 这屎山又发挥了作用。 毕竟它就好比, 用头起飞的鸽子。 如果你不知道我想表达什么, 不知道用头起飞的鸽子, 请一定往下看再往下 default 懂了叭🕊","tags":["Stellar"],"categories":["堆栈"]},{"title":"我数据价值 2082 元的 MongoDB 被攻击","path":"//MongoDB 被攻击/","content":"一个小故事2023.08.05 凌晨随手在闲置服务器安装了一个 MongoDB用于临时测试给QQ机器人添加的Key功能是否有效测试完便下机睡觉2023.08.05 上午十点在我前往另一个城市的时候, 发生了2023.08.05 晚上和朋友聚完回来准备完善测试再push发现携带key方法挂了, 开始排查代码…就刚写没一点的破代码有个屁BUG排查数据库的key, key没了…不!!! 是库没了!!! 生活的小插曲啦 这是攻击者留的唯一库(代码直观展示)‘您的所有数据都已备份。您必须支付0.01 比特币至—博主和谐—在48小时内,您的数据将被公开披露和删除。(更多信息:转到—博主和谐—)付款后发送邮件给我们:—博主和谐—我们将提供一个链接供您下载您的数据。您的DBCODE是:—博主和谐—‘db.getCollection("READ__ME_TO_RECOVER_YOUR_DATA").insert([ {_id: ObjectId("64cdcb2a0f2c98e1b7c19017"),content: "All your data is backed up. You must pay 0.01 BTC to ---博主和谐--- In 48 hours, your data will be publicly disclosed and deleted. (more information: go to ---博主和谐---)After paying send mail to us: ---博主和谐--- and we will provide a link for you to download your data. Your DBCODE is: ---博主和谐---"} ]); 这是日志留下的痕迹 算上东八区, 老贼, 在我上高铁时候下手, 怪不得车上没睡好 这是攻击者IP(当然是IP伪造欺骗) 这是去给定网址的回执(已翻译) 请注意以下几点:我们知道您已经访问了本指南。恢复您的数据的唯一方法是付款。我们不会免费或打折提供数据。如果您决定不检索数据,我们可能会在在线市场上出售您的数据库,向您的用户披露并要求他们付款,在在线违规论坛中披露,或删除它。如果适用,我们将联系您所在国家的欧盟数据保护法机构。如果您无法联系我们,请访问https://xxxxxxx/并下载会话信使。使用以下ID添加我们,以进行流畅的对话和更好的谈判,***不要忘记提及分配给您的DBCODE***:xxxxxxxxxxxxxxxx 结个尾代码虽然开源但config里面还是127.0.0.1, 应该是自动化主机端口扫描的结果(临时用数据库没给密码) 好在是临时用的服务器与数据库, 0.01比特币(2082元)算起步价了吧, 没有比我这更廉价的数据了哈哈哈 大家记得做好安全措施 有趣的是, 我没在日志看到任何那个时间段有关数据库的备份相关操作, 哪怕是查询","tags":["随笔"],"categories":["生活"]},{"title":"Ubuntu 安装使用 Clash","path":"//Clash For Linux/","content":"前言适用范围建立在使用过 window IOS 下的 Clash 为基础的半安装教程 步骤 取出之前设备配置 安装配置Clash 注册为系统服务 在线管理Clash 取出配置备好两个文件 Country.mmdb profiles/xxxxxxxx.yml 打开目录找到两个文件 安配 Clash安装 Clash下载解压并命名为 clash 解压 重命名 移动到 /usr/local/bin/ 目录下 (方便在任何位置调用Clash) 查看版本 下载地址 配置 Clash 首次启动命令行输入clash即可, 一般会提示失败(不重要), 目的是生成配置文件 找到配置目录 一般在 /用户/.config/clash/ 即 /root/.config/clash 放置全球IP库把之前准备的 Country.mmdb 放进去 写配置文件创建一个 config.yaml config.yaml# port of HTTP# port: 7890 ## 解释掉该行,使用mixed-port# port of SOCKS5# socks-port: 7891 ## 解释掉该行,使用mixed-portmixed-port: 52443 ## 提供统一的端口authentication: ## 增加配置,设置账号和密码 - "username:password"# web ui 配置external-controller: 0.0.0.0:52444 # web ui 监听地址secret: "xxxxxxxxxxxxx" # web ui 密钥# allow-lan: falseallow-lan: true ## 允许局域网连接# Rule / Global/ DIRECT (default is Rule)mode: rule# external-ui: dashboard ## 关闭external## 以下贴订阅的配置 接着把之前准备的 /profiles/xxxxxxxx.yml 的文件dns开始到结尾的配置贴到 config.yaml 后面.window与linux配置不同的是前者读取profiles下的列表, 后者直接读取 config.yaml. 注册为系统服务在/etc/systemd/system目录下创建clash.service文件 clash.service[Unit]Description=Clash ServiceAfter=network.target[Service]Type=simpleUser=rootExecStart=/usr/local/bin/clashRestart=on-failure[Install]WantedBy=multi-user.target 以后就能直接使用熟悉的服务命令 systemctl enable clash # 开机自启systemctl start clashsystemctl restart clashsystemctl status clashsystemctl stop clash 在线管理有两个选择, 根据config文件的 web ui 配置, 使用在线网站管理 yacdyacdhttp://yacd.haishan.me/ 如果你不用IP,已经反向代理使用域名并且使用Https, 也可以使用下面的https的yacd 博主的搭建的yacdhttps://clash.thatcoder.cn/ razordrazord提供的(也许要科学上网)http://clash.razord.top/ 结语注意端口自行定义与放行","tags":["Clash"],"categories":["堆栈"]},{"title":"《原神》私有服务器搭建","path":"//game/Genshin Impact/","content":"碎碎念退坑卖号两年, 最近网上冲浪的我看到宵宫传说任务二想来过剧情, 遂想起 grasscutter(开源的原神私服项目,简称 割草机). 不得不说grasscutter 相比之前已经进步很多. 大致步骤1.安装mongodb数据库 2. 配置cultivation3. 配置config (搭建在服务器或本地的区别就在这里)4. 下载游戏本体5. 启动cultivation grasscutter: 相当于游戏的服务器 cultivation: 相当于游戏的代理启动器 下载游戏本体下载游戏本体是最后一步, 放在第一步考虑的是下载太久, 但放在最后是前面都没耐心配置就没必要下载了不是吗之前官服也可以, 不用额外下载. 但现版本grasscutter对应的是3.7版本资源, 官服已经迈入3.8, 所以需要下载3.7版本的国际服. 国际服3.7https://d3ln624mszu7ty.cloudfront.net/client_app/download/pc_zip/20230513200104_2odHBzbUAP5IOIvE/GenshinImpact_3.7.0.zip 安装mongodb官网自行解决 https://www.mongodb.com/try/download/communityMongoDB社区 配置cultivation下载cultivation下载后缀msi的包 cultivation最新版https://github.com/Grasscutters/Cultivation/releases/latest 配置cultivation下载grasscutter(本地运行) 大约要下载400MB左右, 下载出错可以关掉重新来.下载一体化 点击设置 下载grasscutter(服务器运行)服务器跑通自行研究, 其实可以本地编译完上传到服务器运行, 缺少resource文件夹可以走上一步本地运行的方式下载到资源. 路径大概在C:\\Users\\Administrator\\AppData\\Roaming\\cultivation\\grasscutter\\resources.zip Grasscutter最新版https://github.com/Grasscutters/Grasscutter/releases/latest Windows Windowsgit clone --recurse-submodules https://github.com/Grasscutters/Grasscutter.gitcd Grasscutter.\\gradlew.bat # 设置开发环境.\\gradlew jar # 编译 Linux(GNU) Linuxgit clone --recurse-submodules https://github.com/Grasscutters/Grasscutter.gitcd Grasscutterchmod +x gradlew./gradlew jar # 编译 你可以在项目的根目录找到输出的jar。 还有, 尊贵的Coder, Grasscutter是一个Gradle的Java项目, 您可以自定义服务器内容(目前能运营的私服就是这么干的) 配置config如果是在自己电脑当服务器,自己一个人玩, 就不需要配置, 请跳过这步。 因为是一体化下载的grasscutter, 所以路径大概在 C:\\Users\\Administrator\\AppData\\Roaming\\cultivation\\grasscutter\\config.json需要修改几个参数 config.json"bindAddress": "127.0.0.1" //有两个, 都改成 0.0.0.0"accessAddress": "127.0.0.1" //有两个, 都改成服务器IP或者能解析到IP的域名"port": 443 //有两个, 不想撞443的话改成你想要的端口, 记得端口开放 启动cultivation启动游戏 免责声明开此博客纯属积累相关经验记录,而且我需要有记录实证。所有记录的内容均未经专业人士证实,请大家在查看时自行甄别,切勿随意传播。若有违背,本人不承担任何责任!有问题找grasscutters咩! 我只负责和万叶喝茶!最后祝原神越做越好, 米哈游生意兴隆! 问题归纳 Q: 启动grasscutter报错缺失resource资源?A: 下载放到grasscutter的文件夹 https://gitlab.com/YuukiPS/GC-Resources Q: 我能当原神服务器上帝咩?A: 你要的这里都有, 甚至自定义圣遗物 自定义技能. https://github.com/jie65535/GrasscutterCommandGenerator Q: 还有其它问题来频道交流A: 点击链接加入频道【钟意博客】:https://pd.qq.com/s/6h7wytr8a","tags":["Game"],"categories":["第九艺术"]},{"title":"《SKY·光遇》","path":"//game/sky/","content":"谨以此文记录光遇 开端 寒冬, 大一上学年的收尾。寒冬, 世界级瘟疫的开端。落叶捎来讯息, 美好的大学生活埋葬在疫情之下, 比覆雪更严实。 暖春, 好在阴霾里裂开的间隙, 一束光影悄然降临, 光遇。 初遇 第一次听说光遇是年前室友询问我, 怎样在国内玩光遇。我研究了一下当时只有国际服, 发现单纯游玩可以但涉及更新需要手机有谷歌套件,否则更新有丢失账号的风险, 室友嫌麻烦就此作罢。我也没多少兴趣便继续投入到 Rockstar Games 的游戏《Grand Theft Auto V》和《RedDead Redemption 2》。 第二次便是入坑。 疫情下游戏荒时期, 2020.03.05日无意间看到《纪念碑谷》, 便想起陈星汉,进而想起光遇。这便是快乐与遗憾的开始。很符合当天的节气: 惊蛰。 抛开游戏货币蜡烛, 回想起第一周目的游戏体验, 就像是《星际拓荒》般纯粹、干净且美好。但与太空探索的震撼与孤寂不同的是,一周目碰到了几个指引我的”大佬”, 或许是加拿大人, 亦或是日本人、国人, 谁知道呢。唯一能确定的是过客, 因为没解锁聊天, 甚至没加好友。 一周目通关 友人 这是个主打社交的游戏, 遇到数十位性情相投的固玩直接拉满游戏体验。在疫情下大家似乎一天25小时高强度在线, 只可惜一个房间只能8人。 第一个正式好友是”阳菜”, 一位同年级团支书。也许她刚看完《天气之子》。 第二位是”秋刀鱼”, 带我度过了新手时期。 第三位是现实高中室友LQ, 游戏里叫”朔风”, 陪我到一起淡游。 后来加了一位up的群, 认识了很多伙伴, 也在群里一起负责游戏攻略管理。有同年级吐槽光遇乐器不是88键的音乐生”病病”(测试服好搭档), 同年级爱画画和找游戏bug的”小昭”, 后来好像当兵去了的”小新”, 开内衣工厂的”Atlantis.峰”, 弹琴很厉害的”婷婷”,古灵精怪的”鱼鱼”(感谢给我占卜)…后面的再去回忆, 只剩下昵称…”乌拉”、”飞哥”、”小奕”、”姜妤”、”雾”、”yaa”、”陈君泽”(唯一一个游戏上真名的)、”久”、”诺诺”、一些全家一起玩光遇的家庭…还有一些昵称都回忆不起, 尤其是外国友人(笑死, 太长了根本记不住,甚至有些国家不是用英文) 有些印象深刻的记忆碎片: 对线台独分子(其实少部分是台独); 凌晨五点时日本妹子说”窗外的阳光有点刺眼, 我已经一个月没出门”(一小时时差); 很多祝我国战胜疫情的外国玩家(虽然后来成了我们祝福他们); 疫情只能在游戏见面的爸妈和孩子(亲子玩家);发黄黑脸表情就能互相确认身份的国人玩家…… 游戏中后期无趣且重复, 辛有他们带来欢乐与音乐, 以此可抵疫情漫长。 日常音乐会 日常跑图 所剩不多的截图 召唤神狗 这两张插图诠释了光遇内核 这两张插图诠释了光遇内核 羁绊 那段时间家庭情况不好, 经济亦如此。因光遇国际服充值不便, 遂干光遇代氪两个月的收入也帮助我度过这段岁月。 在咸鱼代氪 光遇也让我第一次尝试游戏二创, 不过是音乐方面, 很高兴通过二创能与一些玩家有所共鸣, 同时有些收入。当然现在无法满足催更的玩家了,毕竟离开光遇太久。 QQ音乐 国服 2020.07.09光遇国服开启, 安利给了两个堂妹和同学珞。 陪她们玩的差不多我也就撤了, 国服体验不太纯粹, 就不展开吐槽啦。当然也碰到些难忘的人。 国服记忆 国服记忆 好像我GTA5的游轮喷漆是 SKY-20200709 存档 删除上万张相册之前, 我居然备份了这个视频在网易云音乐。 尾声 频频落笔却一直不知如何写, 就像我想不起什么时候退游的。很遗憾没好好告别, 也许正是想写下此篇的原因。故事开头总是这样,适逢其会,猝不及防。 故事的结局总是这样,花开两朵,天各一方。 售出时间2020.08.13 关于光遇. 想起什么会再回来补充。感谢陈星汉团队与光遇友人。 ——幼稚鬼","tags":["Game"],"categories":["第九艺术"]},{"title":"Ubuntu UOS统信 双显卡外接屏显示问题","path":"//UOS-Nvidia/","content":"前言23年农历年初把电脑双系统的Ubuntu换成统信UOS。安装方法是官网的安装工具。安装过程异常顺利, 但完成后遇到显示屏只亮笔记本的, 原因显卡只使用了核显(知道原因还是能救的)。并尝试如下补救方法: 走原Ubuntu安装N卡驱动流程只亮了外接屏幕, 笔记本屏幕黑屏! 加入官方微信群交流, 工作人员建议尝试UOS软件商店的驱动工具, 还是只亮笔记本的! 最后在第一种方法的基础上手动添加xorg.conf文件得已解决。适用基于Ubuntu与其分支系统。 xorg.conf xorg.conf文件是Linux中用来配置X Window系统的配置文件,它通常存储在/etc/X11/目录下。它的主要目的是控制您的图形卡及其连接显示器的设置和选项。PS: Ubuntu系统中在目录/etc/X11下默认已经没有了文件xorg.conf,为了方便调整显示器的分辨率,可以通过重新生文件xorg.conf来达到目的 如下是我的xorg.conf配置, 仅作参考 xorg.conf# 定义了布局信息,包含一个名为“layout”的标识符# 并设置为使用"NVIDIA"作为屏幕0,同时“intel”处于非活动状态。Section "ServerLayout" Identifier "layout" Screen 0 "nvidia" Inactive "intel"EndSection# 定义了"NVIDIA"设备、标识符、驱动程序和总线ID等信息Section "Device" Identifier "nvidia" Driver "nvidia" BusID "PCI:1:0:0"EndSection# 将"NVIDIA"设备和标识符链接到一起Section "Screen" Identifier "nvidia" Device "nvidia" Option "AccelMethod" "sna" Option "TearFree" "True" Option "Tiling" "True" Option "SwapbuffersWait" "True"EndSection# 定义了"Intel"设备,并将驱动程序设置为modesettingSection "Device" Identifier "intel" Driver "modesetting" BusID "PCI:0:2:0" Option "AllowEmptyInitialConfiguration" "Yes"EndSection# 将"Intel"设备和标识符链接到一起Section "Screen" Identifier "intel" Device "intel"EndSection Section "Files"EndSection 其中BusID等信息可以通过命令获取, BusID就是开头的诸如 1:0,0:2 之类的 同时这个配置能直接操作输出信息等, 修改不当易黑屏, 慎用! 何算成功成功配置输出后会使用N卡, 打印N卡信息即可复查 N卡信息 设置面板有读取信息常为成功。命令: nvidia-settings GPU信息 GPU有使用率定为成功。命令: nvidia-smi 尾声这篇本打算当时写完, 但解决问题后愉快的使用系统去了, 昨天看了一眼UOS发送给Window的文件夹有上面两张图片才想起。","tags":["Linux"],"categories":["堆栈"]},{"title":"Flomo浮墨数据迁移至Memos","path":"//FlomoToMemos/","content":"碎碎念 以前喜欢捣腾笔记软件, 然在两年前遇到 Flomo (一款功能相当简约毫不起眼的APP)。一年后我发现我使用它的频率是所有笔记APP里最高的! (最长是Obsidian) 然后被 Cubox 取代, 诚然也有可能是 Flomo 过期我没续费。 今年初看到memos项目, 便萌生了继续使用Flomo(用memos代替)。因为 Cubox 更多的是琐碎时间浏览到需要的资料或者感兴趣的资料,就转发到 Cubox 里面, 抽空再整理 Cubox 即可。Cubox 不太适合记录突发奇想、文摘、待办事项、感悟等内容。 这篇便是实现年初的想法, 把flomo全部数据转到memos! 开工! 2023.8.18修改适配Memos的0.14版本 2023.8.18修改支持创建时间一致 迁移思路 实现挺简单的, 但在git没看到完整的轮子, 便自己完善 将flomo浮墨导出的数据转成json文件 (这步其实有一个轮子flomoParse,但让使用的人不用折腾两个不同语言项目就一起写成了python代码) 读取json文件将内容和附件图片等通过API上传到自己的memos 实现方法实现在这里就不赘述, 代码比较明了。中途倒是遇到一个 python 实现 multipart/form-data; boundary={boundary} 切片上传(直接上传整个图片文件会限制大小)的小问题 有空记录一下。 multipart切片def upFile(filePath): boundary = '----ThatCoder.cn' # 切片标识符 fileName = filePath.split('/')[-1] with open("flomo/" + filePath, "rb") as f: # 读取二进制文件内容 file_data = f.read() # payload的encode()一个也不能删!!! payload = f'--{boundary}\\r Content-Disposition: form-data; name="file";'.encode() payload += f'filename="{fileName}"\\r Content-Type: {getType(fileName)}\\r \\r '.encode() payload += file_data payload += f'\\r --{boundary}--'.encode() headers = Headers headers['Content-Length'] = str(os.path.getsize("flomo/" + filePath)) headers['Content-Type'] = f'multipart/form-data; boundary={boundary}' response = requests.post(ApiBlob, headers=headers, data=payload) # files参数上传方案 requests_toolbelt包 return response.json() 使用方法项目README有图文讲解, 本篇用来防止提问的人(大概率没有)找不到地方。项目地址: FlomoToMemos 浮墨浅谈 昔者时光溢畅,余悠然自得,好炼煉微型软件。遇上浮墨,其简洁明了,且颜值甚高,遂投身其共修群聊。见开发者努力谋取,且妙趣横生,群友问题皆一一回复,群谈也和蔼可亲。证明喜欢一项产品,一部分为赏识开发团队之风采与行事方式。惟后来,再无后续之因缘。( GPT)","tags":["Memos"],"categories":["堆栈"]},{"title":"《星际拓荒》","path":"//game/Outer Wilds/","content":"Little Nightmares 7+ 太空 解密 游戏封面与音乐 前言 我知道这是一款很神奇的游戏, 但玩后我还是想说: 真TMD牛逼! 这才是第九艺术! 游戏发展史 2012年一个硕士的学生项目南加州大学Alex通过游戏展示海森堡提出的不确定性原理2013年初次会面在demo day冈政伟一见钟情星际拓荒demo, 但Alex去了微软2014年Alex回归Alex加入Mobius2015年公开Alpha版本获麦克纳利大奖, 3万刀, fig众筹2019年05月30日游戏发行感谢马丁的美术 游戏介绍 任何剧情的介绍都是在浪费这款游戏, 如果您没有 晕3D、深海恐惧症, 并且有一颗探索宇宙的心。 答案在最危险的太空中等着您。 尾声 拨开很多游戏光鲜亮丽的美术外衣, 本质是从这个地图位置到另一个地图位置击杀一个单位完成一个问号的过程, 亦或是纯数值游戏。 而《星际拓荒》让我回到了游戏最开始的纯粹, 没有数值没有升级没有装备没有金币没有地图问号。 甚至问NPC我该干嘛 他会回答:”你是去月球还是碎空星,还是去木炉星的另一侧都无所谓。对我来说都一样。快去吧,好好玩儿!”, 我猜他还想补一句: “别打扰老子烤棉花!” 很想分享游戏途中的一些惊奇, 但非常影响初玩者体验, 作罢! 如果你要游玩, 我想转告你: “这里充满了恐惧与孤独,但是一点浪漫即可将其全部驱散。” 下载地址 密码栏下载栏云盘密码星际拓荒https://cloud.189.cn/web/share?code=VV3uAbNfUj2u 有能力一定入正喔! 星际拓荒-steam正版https://store.steampowered.com/app/753640/Outer_Wilds/","tags":["Game"],"categories":["第九艺术"]},{"title":"您名下已备案网站目前涉及违法信息","path":"//您名下已备案网站目前涉及违法信息/","content":"故事的开始 2号写单写的有点晚, 次日九点在睡梦中被电话惊醒, 一看是天翼就给他挂了继续睡 (已经忘记服务器就在天翼 ) 3号下午收到一封邮件说这件事 3号晚上发现这封邮件时我的服务器已经被封了端口 80 和 443 解决办法 按邮件查找相应内容并删除 删除完致电邮件里的联系方式并告诉客服原因与IP 等待客服审核对应内容与回电 (客服声音真好听 ) 原因懒得排查了, 那个服务器坐等过期.可能是wordpress主题的原因, 有些链接被攻击成了成人网站, 导致我站包含链接网站. 总之碰到类似情况不要慌, 按邮件做即可.","tags":["随笔"],"categories":["生活"]},{"title":"邮件样式模板集","path":"//邮箱模板集/","content":"薇尔莉特 动漫来源出自: 紫罗兰永恒花园邮件模板作者: 旧版作者未知, 我是在Akilar看到的改版. 薇尔莉特<head> <base target="_blank"/> <style id="scrollbar" type="text/css">::-webkit-scrollbar { width: 0 !important } pre { white-space: pre-wrap !important; word-wrap: break-word !important; *white-space: normal !important } pre { white-space: pre-wrap !important; word-wrap: break-word !important; *white-space: normal !important } #letter img { max-width: 300px }</style> <style id="from-wrapstyle" type="text/css">#form-wrap { overflow: hidden; height: 447px; position: relative; top: 0px; transition: all 1s ease-in-out .3s; z-index: 0 }</style> <style id="from-wraphoverstyle" type="text/css">#form-wrap:hover { height: 1300px; top: -200px }</style></head><body><div style="width: 530px;margin: 20px auto 0;height: 1000px;"> <div id="form-wrap"><img src="https://upyun.thatcdn.cn/public/web/email_template/head_before.png" alt="before" style="position: absolute;bottom: 126px;left: 0px;background-repeat: no-repeat;width: 530px;height: 317px;z-index:-100"> <div style="position: relative;overflow: visible;height: 1500px;width: 500px;margin: 0px auto;transition: all 1s ease-in-out .3s;padding-top:200px;" <form> <div style="background: white;width: 95%;max-width: 800px;margin: auto auto;border-radius: 5px;border: 1px solid;overflow: hidden;-webkit-box-shadow: 0px 0px 20px 0px rgba(0, 0, 0, 0.12);box-shadow: 0px 0px 20px 0px rgba(0, 0, 0, 0.18);"> <img style="width:100%;overflow: hidden;" src="https://upyun.thatcdn.cn/public/web/email_template/head_wely.jpg"/> <div style="padding: 5px 20px;"><br> <div><h3 style="text-decoration: none; color: rgb(246, 214, 175);">{{ parent.nick }},见信安:</h3> </div> <br> <div id="letter" style="overflow:auto;height:300px;width:100%;display:block;word-break: break-all;word-wrap: break-word;"> <p style="display: inline-block;">您在<a style="text-decoration: none;color: rgb(246, 214, 175)" target="_blank" href="{{site.url}}">⟬{{ site.name }}⟭</a>上发表的评论: </p> <div id="parentC" style="border-bottom: #ddd 1px solid;border-left: #ddd 1px solid;padding-bottom: 20px;background-color: #eee;margin: 15px 0px;padding-left: 20px;padding-right: 20px;border-top: #ddd 1px solid;border-right: #ddd 1px solid;padding-top: 20px;font-family: 'Arial', 'Microsoft YaHei' , '黑体' , '宋体' , sans-serif;"> {{ parent.comment }} </div> <p>收到了来自{{ self.nick }}的回复:</p> <div id="selfC" style="border-bottom: #ddd 1px solid;border-left: #ddd 1px solid;padding-bottom: 20px;background-color: #eee;margin: 15px 0px;padding-left: 20px;padding-right: 20px;border-top: #ddd 1px solid;border-right: #ddd 1px solid;padding-top: 20px;font-family: 'Arial', 'Microsoft YaHei' , '黑体' , '宋体' , sans-serif;"> {{ self.comment }} </div> </div> <br> <div style="text-align: center;margin-top: 40px;"><img src="https://upyun.thatcdn.cn/public/web/email_template/footer_bilibili.png" alt="hr" style="width:100%; margin:5px auto 5px auto; display: block;"/><a style="text-transform: uppercase;text-decoration: none;font-size: 14px;border: 2px solid #6c7575;color: #2f3333;padding: 10px;display: inline-block;margin: 10px auto 0;background-color: rgb(246, 214, 175);" target="_blank" href="{{site.postUrl}}">{{ parent.nick }}|请您点击签收~</a></div> <p style="font-size: 12px;text-align: center;color: #999;"><br>薇尔莉特·伊芙加登<br>自动书记人偶竭诚为您服务!<br>©2020-2023<a style="text-decoration:none; color:rgb(246, 214, 175)" href="{{site.url}}">{{ site.name }}</a></p></div> </div> </form> </div> <img src="https://upyun.thatcdn.cn/public/web/email_template/head_after.png" alt="after" style=" position: absolute;bottom: -2px;left: 0;background-repeat: no-repeat;width: 530px;height: 259px;z-index:100"></div></div></body> 简洁渐变 邮箱模板作者: 未知, 以前PHP站点扒的. 简洁渐变<div style="border-radius: 10px 10px 10px 10px;font-size:14px;color: #555555;width: 666px;font-family:'Century Gothic','Trebuchet MS','Hiragino Sans GB',微软雅黑,'Microsoft Yahei',Tahoma,Helvetica,Arial,'SimSun',sans-serif;margin:50px auto;border:1px solid #eee;max-width:100%;background: #ffffff repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);">\t<div style="width:100%;background:#49BDAD;color:#ffffff;border-radius: 10px 10px 0 0;background-image: -moz-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));background-image: -webkit-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));height: 66px;">\t<p style="font-size:15px;word-break:break-all;padding: 23px 32px;margin:0;background-color: hsla(0,0%,100%,.4);border-radius: 10px 10px 0 0;">您在<a style="text-decoration:none;color: #ffffff;" href="{{site.url}}" target="_blank">{{site.name}}</a>上的留言有新评论啦!</p>\t</div> <div style="margin:40px auto;width:90%"><p>{{self.nick}} 回复说:</p> <div style="background: #fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:14px;color:#555555;">{{self.comment | safe}}</div> <p>您可以点击<a style="text-decoration:none; color:#12addb" href="{{site.postUrl}}" target="_blank">查看回复的完整內容</a>。<hr /> </p><style type="text/css">a:link{text-decoration:none}a:visited{text-decoration:none}a:hover{text-decoration:none}a:active{text-decoration:none}</style> </div>\t</div>`;\tmailSubject: '{{parent.nick | safe}},『{{site.name | safe}}』上的评论收到了回复', mailTemplate: `<div style="border-radius: 10px 10px 10px 10px;font-size:14px;color: #555555;width: 666px;font-family:'Century Gothic','Trebuchet MS','Hiragino Sans GB',微软雅黑,'Microsoft Yahei',Tahoma,Helvetica,Arial,'SimSun',sans-serif;margin:50px auto;border:1px solid #eee;max-width:100%;background: #ffffff repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);">\t<div style="width:100%;background:#49BDAD;color:#ffffff;border-radius: 10px 10px 0 0;background-image: -moz-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));background-image: -webkit-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));height: 66px;">\t<p style="font-size:15px;word-break:break-all;padding: 23px 32px;margin:0;background-color: hsla(0,0%,100%,.4);border-radius: 10px 10px 0 0;">您在<a style="text-decoration:none;color: #ffffff;" href="{{site.url}}" target="_blank">{{site.name}}</a>上的留言有新回复啦!</p>\t</div> <div style="margin:40px auto;width:90%"><p>Hi, {{parent.nick}},您曾在文章上发表评论:</p> <div style="background: #fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:14px;color:#555555;">{{self.comment | safe}}</div> <p><strong>{{self.nick}}</strong> 给您的回复如下:</p> <div style="background: #fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:14px;color:#555555;">{{self.comment | safe}}</div> <p>您可以点击<a style="text-decoration:none; color:#12addb" href="{{site.postUrl}}" target="_blank">查看回复的完整內容</a>,欢迎再次光临<a style="text-decoration:none; color:#12addb" href="{{site.url}}" target="_blank">{{site.name}}</a>。<hr /> <p style="font-size:12px;color:#b7adad">本邮件为系统自动发送,请勿直接回复邮件哦,可到博文内容回复。</p> </p><style type="text/css">a:link{text-decoration:none}a:visited{text-decoration:none}a:hover{text-decoration:none}a:active{text-decoration:none}</style> </div>\t</div> 简洁头图 邮件模板作者: SaraKale根据上面改的. 简洁头图<div style="background-image: url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/bg.jpg);;padding:20px 0px 20px;margin:0px;background-color:#ded8ca;width:100%;">\t<div style="background: url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/leisi-714x62.png) repeat-y scroll top;"> <div style="border-radius: 10px 10px 10px 10px;font-size:14px;color: #555555;width: 666px;font-family:'Century Gothic','Trebuchet MS','Hiragino Sans GB',微软雅黑,'Microsoft Yahei',Tahoma,Helvetica,Arial,'SimSun',sans-serif;margin:50px auto;border:1px solid #eee;max-width:100%;background: #ffe8dd61;box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);margin:auto"> <img class="headerimg no-lightbox entered loaded" src="https://npm.elemecdn.com/sarakale-assets@v1/bg/bg3.jpg" style="width:100%;overflow:hidden;pointer-events:none" data-ll-status="loaded"> <div style="width:100%;color:#9d2850;border-radius: 10px 10px 0 0;background-image: -moz-linear-gradient(0deg, rgb(67, 198, 184), rgb(255, 209, 244));height: 66px;background: url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/line034_666x66.png) left top no-repeat;"> <p style="font-size:16px;font-weight: bold;text-align:center;word-break:break-all;padding: 23px 32px;margin:0;border-radius: 10px 10px 0 0;">您在<a style="text-decoration:none;color: #9d2850;" href="{{site.url}}"target="_blank">{{site.name}}</a>上的文章有了新的评论</p> </div> <div style="margin:40px auto;width:90%;"><p><strong>{{self.nick}}</strong> 回复说:</p> <div style="background: #fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:15px;color:#555555;">{{self.comment | safe}} </div> <p>您可以点击<a style="text-decoration:none; color:#cf5c83" href="{{site.postUrl}}" target="_blank">查看回复的完整內容</a></p> </div> </div>\t</div></div>`, mailSubject: '{{parent.nick}},您在『{{site.name}}』上发表的评论收到了来自 {{self.nick}} 的回复', mailTemplate: `<div style="background-image:url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/bg.jpg);;padding:20px 0px 20px;margin:0px;background-color:#ded8ca;width:100%;"><div style="background:url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/leisi-714x62.png) repeat-y scroll top;">\t<div style="border-radius:10px 10px 10px 10px;font-size:14px;color:#555555;width:666px;font-family:'Century Gothic','Trebuchet MS','Hiragino Sans GB',微软雅黑,'Microsoft Yahei',Tahoma,Helvetica,Arial,'SimSun',sans-serif;margin:50px auto;border:1px solid #eee;max-width:100%;background:#ffe8dd61;box-shadow:0 1px 5px rgba(0,0,0,0.15);margin:auto">\t<img class="headerimg no-lightbox entered loaded" src="https://npm.elemecdn.com/sarakale-assets@v1/bg/bg3.jpg" style="width:100%;overflow:hidden;pointer-events:none" data-ll-status="loaded"> <div style="width:100%;border-radius:10px 10px 0 0;background-image:-moz-linear-gradient(0deg,rgb(67,198,184),rgb(255,209,244));height:66px;background:url(https://npm.elemecdn.com/sarakale-assets@v1/Article/email/line034_666x66.png) left top no-repeat;color:#9d2850;"> <p style="font-size:16px;font-weight: bold;text-align:center;word-break:break-all;padding:23px 32px;margin:0;border-radius:10px 10px 0 0;">您在<a style="text-decoration:none;color:#9d2850;" href="{{site.url}}">『{{site.name | safe}}』</a>上的留言有新回复啦!</p> </div> <div style="margin:40px auto;width:90%;"><p>Hi,{{parent.nick}},您曾在文章上发表评论:</p> <div style="background:#fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow:0 2px 5px rgba(0,0,0,0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:15px;color:#555555;">{{parent.comment | safe}}</div> <p><strong>{{self.nick}}</strong> 给您的回复如下:</p> <div style="background:#fafafa repeating-linear-gradient(-45deg,#fff,#fff 1.125rem,transparent 1.125rem,transparent 2.25rem);box-shadow:0 2px 5px rgba(0,0,0,0.15);margin:20px 0px;padding:15px;border-radius:5px;font-size:15px;color:#555555;">{{self.comment | safe}}</div> <p>您可以点击<a style="text-decoration:none;color:#cf5c83" href="{{site.postUrl}}" target="_blank"> 查看回复的完整內容 </a>,欢迎再次光临<a style="text-decoration:none;color:#cf5c83" href="{{site.url}}" target="_blank"> {{site.name}} </a>。 <hr /><p style="font-size:14px;color:#b7adad">本邮件为系统自动发送,请勿直接回复邮件哦,可到博文内容回复。<br />https://sarakale.top/blog</p></p> </div>\t</div></div></div> 组装时间线测试网易云memos微信读书联合测试 memos单个测试标识符不同应该不会混淆进去","tags":["邮件"],"categories":["分享"]},{"title":"观《深海》的奇妙联想","path":"//daily/Deep Sea/","content":"观前 多位朋友安利 被影评博主吹爆 特效看着不赖 疫情管控放开后爷单纯想看电影 观感 首先,我有部分原因是奔着画面去的。《深海》也没令我失望, 呈现了一道国风视觉盛宴。颜狗党摊牌了。不过《深海》的特效也不是纯炫技, 极致的色彩对于参宿梦境的构建与情绪的表达都是锦上添花,极具表现力。 至于呈现的剧情, 整体而言没有把握好因果, 或者说是导演的大胆想法导致必须舍弃良好的因果回归,以至于将故事的真相与参宿的和解滞后到结尾。导演的放飞自我也会导致没对影片没共情的人带来更加不良好的观感。不过不得不说,这滞后的后劲真大。 联想 其实剧情没什么内容, 倒是让我产生了一些联想。 开头参宿的情况我想起《我的姐姐》,当然影片重点亦不是家庭情况。 整部剧情我想起游戏《古树旋律DEEMO》。海精灵和丧气鬼就像deemo里的神秘女孩,是自我意愿的具化, 不想让自己离开梦境; 影片中后暗示的光与声想起deemo右侧房间的病房心电图声;一道白光拉回现实的雷同; 牺牲的南河与deemo… 还想起数句话: 抑郁症患者自杀是想通了还是没想通。 抑郁症是病, 是缺神经质, 不是单纯的心理问题, 不是笑一笑就能解决的。 醒醒了,该散场了。 尾声 总的我不安利这部电影, 正如网评一般: 影片最大的缝合,是将虚构的动画世界与现实的离异家庭子女问题、抑郁症等“丧文化”情绪进行对接,试图以超越性的情感共鸣唤起观众的价值共振,实现动画干预现实的诉求。然而,来源复杂、牵涉广泛的要素堆积,并没有为影片带来蒸汽朋克式的别样审美,反而因为过于强烈和刻意的表现欲,将上述要素降格为机械拼盘,未能产生应有的艺术效果。 当然无聊可以看看, 也许你是小众狂欢里的一员。 美图 深海古树旋律 咳咳, 其实也想分享古树旋律歌曲的, 大部分要VIP淦 DEEMO歌单","tags":["影剧"],"categories":["生活"]},{"title":"浅谈跨域-就你小子不让我跨域","path":"//CrossOrigin/","content":"何为跨域全称: “跨来源资源共享” “Cross-origin resource sharing” 跨域范畴: 不同主域名 不同二级域名 不同端口 http和https协议不同 域名访问和直接访问其解析IP 造成影响: Cookie、LocalStorage和IndexDB无法获取 DOM无法获得 AJAX请求不能发送 … 为何制定跨域W3C的搞事佬制定的标准, 出发点当然是安全问题.不妨思考一下古老钓鱼网站的行为, 与我的抽象代码. 第一步通过个人通信方式把人骗到钓鱼网站第二步钓鱼网站已经嵌入了目标官方网站第三步(营销语气) 注意看, 这个男人叫王小帅, 他在钓鱼网站上输入了账号密码.第四步获取嵌入的官网的DOM节点获取账号密码. 抽象钓鱼代码...<iframe name="diaoyu" src="www.xxbank.com"></iframe>...<script> const iframe = window.frames['diaoyu'] const count = iframe.document.getElementById('count') const pwd = iframe.document.getElementById('password') console.log("账号:${count}, 密码:${pwd}")</script> 解决方案 CORS需要浏览器和服务器同时支持。所有浏览器都支持该功能,IE浏览器不能低于IE10。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求。因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。 了解请求与响应 简单请求: 请求method是get、head或者post 除了用户代理自动设置的一些头部,开发工程师手动设置的头部是如下头部之一: Accept, Accept-Language, Content-Language,Content-Type, Last-Event-ID, DPR, Save-Data, Viewport-Width, Width content-type是application/x-www-form-urlencoded、 multipart/form-data或者text/plain 没有事件注册到XMLHttpRequestUpload上 在请求时没有使用ReadableStream 简单请求主要是解决Access-Control-Allow-Origin是否包含在通行域 简单响应//指定允许其他域名访问'Access-Control-Allow-Origin:http://172.20.0.206'//一般用法(*,指定域,动态设置),3是因为*不允许携带认证头和cookies//是否允许后续请求携带认证信息(cookies),该值只能是true,否则不返回'Access-Control-Allow-Credentials:true' 复杂请求:没错,不满足上面的,都是我啦!浏览器会先发送option(预检)请求,option请求多了2个字段 Access-Control-Request-Method, Access-Control-Request-Headers 复杂响应//指定允许其他域名访问'Access-Control-Allow-Origin:http://172.20.0.206'//一般用法(*,指定域,动态设置),3是因为*不允许携带认证头和cookies//是否允许后续请求携带认证信息(cookies),该值只能是true,否则不返回'Access-Control-Allow-Credentials:true'//预检结果缓存时间,也就是上面说到的缓存啦'Access-Control-Max-Age: 1800'//允许的请求类型'Access-Control-Allow-Methods:GET,POST,PUT,POST'//允许的请求头字段'Access-Control-Allow-Headers:x-requested-with,content-type' 前端 祖传JSONP同源策略是根据脚本(js)的来源判断是否限制, jsonp是通过 <script> 标签冒充同源.缺点是只能发送get请求(聊胜于无?) 原生JS原生JS跨域问题示例代码 原生JSvar script = document.createElement('script'); script.type = 'text/javascript'; // 传参一个回调函数名给后端,方便后端返回时执行这个在前端定义的回调函数 script.src = 'http://www.domain2.com:8080/login?user=admin&callback=handleCallback'; document.head.appendChild(script); // 回调执行函数 function handleCallback(res) { alert(JSON.stringify(res)); } JQJQ跨域问题示例代码 Ajax$.ajax({ url: 'http://www.domain2.com:8080/login', type: 'get', dataType: 'jsonp', // 请求方式为jsonp jsonpCallback: "handleCallback", // 自定义回调函数名 data: {}}); VueVue Axios跨域问题示例代码 Axiosthis.$http = axios;this.$http.jsonp('http://www.domain2.com:8080/login', { params: {}, jsonp: 'handleCallback' }).then((res) => { console.log(res);}) Node.jsNodeJS跨域问题示例代码 Nodevar querystring = require('querystring');var http = require('http');var server = http.createServer();server.on('request', function(req, res) { var params = querystring.parse(req.url.split('?')[1]); var fn = params.callback; // jsonp返回设置 res.writeHead(200, { 'Content-Type': 'text/javascript' }); res.write(fn + '(' + JSON.stringify(params) + ')'); res.end();});server.listen('8080');console.log('Server is running at port 8080...'); 前端配置 原生Ajax原生Ajax跨域问题示例代码 Ajaxvar xhr = new XMLHttpRequest(); // IE8/9需用window.XDomainRequest兼容// 前端设置是否带cookiexhr.withCredentials = true;xhr.open('post', 'http://www.domain2.com:8080/login', true);xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');xhr.send('user=admin');xhr.onreadystatechange = function() { if (xhr.readyState == 4 && xhr.status == 200) { alert(xhr.responseText); }}; jQ AjaxJQ Ajax跨域问题配置示例代码 JQAjax$.ajax({...xhrFields: { withCredentials: true // 前端设置是否带cookie},crossDomain: true, // 会让请求头中包含跨域的额外信息,但不会含cookie...}); Vue配置Vue跨域跨域问题axios,vue-resource配置示例代码 axios设置:axios.defaults.withCredentials = true vue-resource设置:Vue.http.options.credentials = true 后端 java 方案一: WebCrosConfigjava跨域问题addCorsMappings方案示例代码 配置类@Configurationpublic class WebCrosConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET","HEAD","POST","PUT","DELETE","OPTIONS") .allowCredentials(true) .maxAge(3600) .allowedHeaders("*"); }} 方案二: CrosFilterjava跨域问题CrosFilter方案示例代码友人南山客补充方案 CrosFilter/* * @author 南山客 友情赞助代码 * @email nansker@163.com * @create 2022/10/10 17:31 * @description */package cn.nansk.takeout.config;import cn.nansk.takeout.common.JacksonObjectMapper;import lombok.extern.slf4j.Slf4j;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.http.converter.HttpMessageConverter;import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;import org.springframework.web.cors.CorsConfiguration;import org.springframework.web.cors.UrlBasedCorsConfigurationSource;import org.springframework.web.filter.CorsFilter;import org.springframework.web.servlet.config.annotation.CorsRegistry;import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;import java.util.List;@Slf4j@Configurationpublic class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { log.info("开始进行静态资源映射..."); } // FIXME: 2022/11/2 跨域问题没有得到解决 //解决跨域问题 //@Override //public void addCorsMappings(CorsRegistry registry){ // registry.addMapping("/**") // .allowedOriginPatterns("*") // .allowedMethods("GET","HEAD","POST","PUT","DELETE","OPTIONS") // .allowCredentials(true) // .maxAge(3600) // .allowedHeaders("*"); //} /*** * @author Nansker * @date 2023/2/17 23:17 * @return org.springframework.web.filter.CorsFilter * @description 允许跨域调用过滤器 * 这里不能使用Override addCorsMappings()方法解决跨域问题,具体原因未知 */ @Bean public CorsFilter corsFilter(){ CorsConfiguration config = new CorsConfiguration(); //允许白名单域名进行跨域调用 config.addAllowedOrigin("*"); //允许跨越发送cookie config.setAllowCredentials(true); //放行全部原始头信息 config.addAllowedHeader("*"); //允许所有请求方法跨域调用 config.addAllowedMethod("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); }} Node配置var http = require('http');var server = http.createServer();var qs = require('querystring');server.on('request', function(req, res) { var postData = ''; // 数据块接收中 req.addListener('data', function(chunk) { postData += chunk; }); // 数据接收完毕 req.addListener('end', function() { postData = qs.parse(postData); // 跨域后台设置 res.writeHead(200, { 'Access-Control-Allow-Credentials': 'true', // 后端允许发送Cookie 'Access-Control-Allow-Origin': 'http://www.domain1.com', // 允许访问的域(协议+域名+端口) /* * 此处设置的cookie还是domain2的而非domain1,因为后端也不能跨域写cookie(nginx反向代{过}{滤}理可以实现), * 但只要domain2中写入一次cookie认证,后面的跨域接口都能从domain2中获取cookie,从而实现所有的接口都能跨域访问 */ 'Set-Cookie': 'l=a123456;Path=/;Domain=www.domain2.com;HttpOnly' // HttpOnly的作用是让js无法读取cookie }); res.write(JSON.stringify(postData)); res.end(); });});server.listen('8080');console.log('Server is running at port 8080...'); 服务器 Nginx通过Nginx配置一个代理服务器域名与domain1相同,端口不同)做跳板机,反向代理访问domain2接口,并且可以顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域访问。 站点配置#proxy服务器server { listen 81; server_name www.domain1.com; location / { proxy_pass http://www.domain2.com:8080; #反向代理 proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名 index index.html index.htm; # 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用 add_header Access-Control-Allow-Origin http://www.domain1.com; #当前端只跨域不带cookie时,可为* add_header Access-Control-Allow-Credentials true; }} Nodenode中间件实现跨域代理,原理大致与nginx相同,都是通过启一个代理服务器,实现数据的转发,也可以通过设置cookieDomainRewrite参数修改响应头中cookie中域名,实现当前域的cookie写入,方便接口登录认证。 fetch跨域get请求FG前端代码前端getfetch('http://localhost:6888/test_get',{ method: 'GET', mode: 'cors',}).then(res => { return res.json();}).then(json => { console.log('获取的结果', json.data); return json;}).catch(err => { console.log('请求错误', err);}) 服务端服务端配置c.Header("Access-Control-Allow-Origin", "*")c.Header("Access-Control-Allow-Methods", "GET, POST") post请求FP前端代码前端postfetch('http://localhost:6888/test_post',{ method: 'POST', body: JSON.stringify({name: 'zaozuo'}), mode: 'cors',}).then(res => { return res.json();}).then(json => { console.log('获取的结果', json.data); return json;}).catch(err => { console.log('请求错误', err);}) 后端代码同get相同 put请求把post请求模式改成put即可, 其它一致.不同于get、post请求的地方是请求有个预检查(OPTIONS请求),然后再发put请求;上面的头部信息都是options请求相关的,put请求跟平时普通http请求一样。 头部补充 request跨域头部介绍 Access-Control-Allow-Origin:可以允许哪些客户端来访问,指可以是*,也可以是某个域名或者用逗号隔开的域名列表。 Access-Control-Expose-Headers: 浏览器可以访问的一些头部。 Access-Control-Max-Age:预检查结果可以缓存的问题 Access-Control-Allow-Methods:指定客户端发请求可以使用的方法 Access-Control-Allow-Headers:指定客户端发请求可以使用的头部。 Access-Control-Allow-Credentials: 指定客户端是否可以携带cookie等认证信息(前端fetch设置withCredentials:true进行发送cookie),如果是简单请求等跨域得确保此response头设置为true。 response头部 Access-Control-Allow-Origin:可以允许哪些客户端来访问,指可以是*,也可以是某个域名或者用逗号隔开的域名列表。 Access-Control-Expose-Headers: 浏览器可以访问的一些头部。 Access-Control-Max-Age:预检查结果可以缓存的问题 Access-Control-Allow-Methods:指定客户端发请求可以使用的方法 Access-Control-Allow-Headers:指定客户端发请求可以使用的头部。 Access-Control-Allow-Credentials: 指定客户端是否可以携带cookie等认证信息(前端fetch设置withCredentials:true进行发送cookie),如果是简单请求等跨域得确保此response头设置为true。 奇技淫巧方案同主域不同子域实现原理:两个页面都通过js强制设置document.domain为基础主域,就实现了同域。 父窗口 <iframe id="iframe" src="http://child.domain.com/b.html"></iframe><script> document.domain = 'domain.com'; var user = 'admin';</script> 子窗口 <script> document.domain = 'domain.com'; // 获取父窗口中变量 alert('get js data from parent ---> ' + window.parent.user);</script> 不同主域方案一实现原理:a欲与b跨域相互通信,通过中间页c来实现。三个页面,不同域之间利用iframe的location.hash传值,相同域之间直接js访问来通信。具体实现:A域:a.html -> B域:b.html -> A域:c.html,a与b不同域只能通过hash值单向通信,b与c也不同域也只能单向通信,但c与a同域,所以c可通过parent.parent访问a页面所有对象。 a.html<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe><script> var iframe = document.getElementById('iframe'); // 向b.html传hash值 setTimeout(function() { iframe.src = iframe.src + '#user=admin'; }, 1000); // 开放给同域c.html的回调方法 function onCallback(res) { alert('data from c.html ---> ' + res); }</script> b.html<iframe id="iframe" src="http://www.domain1.com/c.html" style="display:none;"></iframe><script> var iframe = document.getElementById('iframe'); // 监听a.html传来的hash值,再传给c.html window.onhashchange = function () { iframe.src = iframe.src + location.hash; };</script> c.html<script> // 监听b.html传来的hash值 window.onhashchange = function () { // 再通过操作同域a.html的js回调,将结果传回 window.parent.parent.onCallback('hello: ' + location.hash.replace('#user=', '')); };</script> 方案二window.name属性的独特之处:name值在不同页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的name值(2MB).代理b.html的数据通过iframe的src属性由外域转向本地域,跨域数据即由iframe的window.name从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。 doamin1/a.html doamin1/proxy.html domain2/b.html a.htmlvar proxy = function(url, callback) { var state = 0; var iframe = document.createElement('iframe'); // 加载跨域页面 iframe.src = url; // onload事件会触发2次,第1次加载跨域页,并留存数据于window.name iframe.onload = function() { if (state === 1) { // 第2次onload(同域proxy页)成功后,读取同域window.name中数据 callback(iframe.contentWindow.name); destoryFrame(); } else if (state === 0) { // 第1次onload(跨域页)成功后,切换到同域代{过}{滤}理页面 iframe.contentWindow.location = 'http://www.domain1.com/proxy.html'; state = 1; } }; document.body.appendChild(iframe); // 获取数据以后销毁这个iframe,释放内存;这也保证了安全(不被其他域frame js访问) function destoryFrame() { iframe.contentWindow.document.write(''); iframe.contentWindow.close(); document.body.removeChild(iframe); }};// 请求跨域b页面数据proxy('http://www.domain2.com/b.html', function(data){ alert(data);}); proxy.html中间代理页,与a.html同域,内容为空即可。 b.html<script> window.name = 'This is domain2 data!';</script> postMessagepostMessage是HTML5 XMLHttpRequest Level 2中的API,且是为数不多可以跨域操作的window属性之一,它可用于解决以下方面的问题: 页面和其打开的新窗口的数据传递 多窗口之间消息传递 页面与嵌套的 iframe 消息传递 上面三个场景的跨域数据传递 用法:postMessage(data,origin)方法接受两个参数: data:html5规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用JSON.stringify()序列化。 origin:协议+主机+端口号,也可以设置为”*“,表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为”/“。 a.html<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe><script> var iframe = document.getElementById('iframe'); iframe.onload = function() { var data = { name: 'aym' }; // 向domain2传送跨域数据 iframe.contentWindow.postMessage(JSON.stringify(data), 'http://www.domain2.com'); }; // 接受domain2返回数据 window.addEventListener('message', function(e) { alert('data from domain2 ---> ' + e.data); }, false);</script> b.html<script> // 接收domain1的数据 window.addEventListener('message', function(e) { alert('data from domain1 ---> ' + e.data); var data = JSON.parse(e.data); if (data) { data.number = 16; // 处理后再发回domain1 window.parent.postMessage(JSON.stringify(data), 'http://www.domain1.com'); } }, false);</script> WebSocketWebSocket protocol是HTML5一种新的协议。它实现了浏览器与服务器全双工通信,同时允许跨域通讯,是server push技术的一种很好的实现。原生WebSocket API使用起来不太方便,我们使用Socket.io,它很好地封装了webSocket接口,提供了更简单、灵活的接口,也对不支持webSocket的浏览器提供了向下兼容。 前端代码<div>user input:<input type="text"></div><script src="./socket.io.js"></script><script>var socket = io('http://www.domain2.com:8080');// 连接成功处理socket.on('connect', function() { // 监听服务端消息 socket.on('message', function(msg) { console.log('data from server: ---> ' + msg); }); // 监听服务端关闭 socket.on('disconnect', function() { console.log('Server socket has closed.'); });});document.getElementsByTagName('input')[0].onblur = function() { socket.send(this.value);};</script> Node-socket后台var http = require('http');var socket = require('socket.io');// 启http服务var server = http.createServer(function(req, res) { res.writeHead(200, { 'Content-type': 'text/html' }); res.end();});server.listen('8080');console.log('Server is running at port 8080...');// 监听socket连接socket.listen(server).on('connection', function(client) { // 接收信息 client.on('message', function(msg) { client.send('hello:' + msg); console.log('data from client: ---> ' + msg); }); // 断开处理 client.on('disconnect', function() { console.log('Client socket has closed.'); });});","tags":["跨域"],"categories":["堆栈"]},{"title":"Waline评论与Lsky兰空图床","path":"//Waline与Lsky兰空图床/","content":"前言giscus改为waline后不能上传图片, 配置好后还是会弹图片大于128KB, 可能我哪里搞错了吧.查阅waline源码有一个defaultUpdateImage限制图片文件大小128KB, 但自定义后还是会走到这里判断.遂自己改造了一下, 并记录一路碰到的许多问题. 2023.02.06 破案了, 感谢是非题提醒主题后续将主题参数 url 改成 api,详见2023.1.12的commit.好细节!这个故事告诉我们要及时拉取.所以我把Stellar的提交历史放到了便签关注 获取兰空Token搭建LskyPro兰空搭建https://blog.thatcoder.cn/Lsky%E5%85%B0%E7%A9%BA%E5%9B%BE%E5%BA%8A%E6%90%AD%E5%BB%BA/ 获取Token一个Post请求就可以获取Token, 但前端的一切都是公开的, 意味着你的Token一定暴露.所以建一个专门用来当博客评论图床的账号, 给少点鉴权限. Post请求https://你的部署地址/api/v1/tokens 你可以使用ApiPost网页版 ApiPost网页版请求方法成功响应填装body参数 `email` `password` 请求成功得到一个类似 3|xxxxxxxxxxxxxxxxxxxxxx 的响应参数, 前面的竖杠和数字不要漏了. 搭建waline这个不用服务器, vercel一步到位, 详情参考官方教程. waline之vercel部署https://waline.js.org/guide/get-started/#vercel-%E9%83%A8%E7%BD%B2-%E6%9C%8D%E5%8A%A1%E7%AB%AF hexo启用waline不同主题不一样, 如果主题没适配waline可以自己在生成文章的地方适当位置添加一个div给上唯一id, 等下会用到.这里给Stellar主题评论启用waline 根目录/_config.stellar.yml# 评论 twikoo服务comments: service: waline waline: serverURL: https://你部署的地址/ # waline 地址 locale: placeholder: "" # 输入框内提示文字 # Custom emoji emoji: - https://unpkg.com/@waline/emojis@1.1.0/bilibili - https://unpkg.com/@waline/emojis@1.1.0/qq - https://fastly.jsdelivr.net/gh/norevi/waline-blobcatemojis@1.0/blobs imageUploader: # 适配了兰空图床V1、V2版本 # 以兰空图床V1为例,下列填写内容为: fileName: file tokenName: Authorization api: https://你的兰空地址/api/v1/upload token: Bearer 1|xxxxxxx你的token resp: data.links.url Stellar主题用户配置完要是可以上传文件就不用往下看了. 自定义js路径: themes/stellar/layout/_partial/plugins/comments/waline/script.ejs我的这个文件肯定是加载了, 但到上传图片时会限制128KB, 按理用了imageUploader就不应该还是走的数据库存储base64策略, 唉自己动手吧.以下代码参考waline官网和xaoxuu, 所以不同主题也适用(不同的是看你代码放哪里, 要是主题不是js模板引擎即ejs结尾,就改成js代码) 路径在上面<script type="module">import { init } from '/custom/package/waline/dist/waline.mjs'; //md 我这里用CDN还是会提示大于128KB, 所以直接引入了const el = document.getElementById("waline_container"); //这里是你的评论div#idvar idPath = el.getAttribute('comment_id');if (!idPath) { idPath = decodeURI(window.location.pathname); // 给评论div加上唯一标识, 不如评论乱串文章.}const waline = init({ el: '#waline_container', //这里是你的评论div#id search: false, //关闭表情查找 不大好用 // 设置 emoji 为微博与哔哩哔哩 emoji: [ 'https://unpkg.com/@waline/emojis@1.1.0/bilibili', 'https://unpkg.com/@waline/emojis@1.1.0/qq', 'https://fastly.jsdelivr.net/gh/norevi/waline-blobcatemojis@1.0/blobs' ], reaction: true, // 开启反应 comment: true, // 评论数统计 // pageview: true, // 浏览量统计 serverURL: 'https://你的waline地址', // 记得改自己的waline地址 path: idPath, imageUploader: (file) => { let formData = new FormData(); let headers = new Headers(); formData.append('file', file); headers.append("Access-Control-Allow-Headers", "*"); headers.append("Access-Control-Allow-Origin", "*"); headers.set('Authorization', 'Bearer 1|xxxxxxx你的兰空tokens'); // 记得改自己的token headers.set('Accept', 'application/json'); // headers.set("Content-Type","multipart/form-data"); return fetch('https://你的兰空地址/api/v1/upload', { // 记得改自己的兰空 method: 'POST', headers: headers, body: formData, mode: 'cors', }) .then((resp) => resp.json()) .then((resp) => resp.data.links.url); },});</script> 结语至此结束了, 要是也碰到奇葩的fetch跨域问题, 不妨试试下面的文章.","tags":["Stellar"],"categories":["堆栈"]},{"title":"Lsky兰空图床搭建","path":"//Lsky兰空图床搭建/","content":"前言 感谢兰空图床开源作者 Wisp X及其它贡献者 环境需求 一台服务器 PHP 8.0.2+ 及系列拓展(万恶的PHP!) Mysql 5.7+ 最新版下载 建站环节新建站点有了Vercel好久没自己建站了 ,久违的感觉.直接新建一个PHP8的站点即可, 域名记得解析. 站点设置 把下载好的压缩包解压到站点根目录. 站点目录所有者改为 www,权限改为 0755 网站运行目录选择为 public, 宝塔用户如下图 宝塔用户不知道哪里修改看这里 伪静态策略我给的伪静态代码和官网不太一样, 我用官网的不能跨域, 响应会出现两个Access-Control-Allow-Origin, 所以只能自己改写一个伪静态规则.如果你的跨域有问题就试试我的.官网钟意伪静态location / { try_files $uri $uri/ /index.php?$query_string;}伪静态location /{ try_files $uri $uri/ /index.php?s=$1; add_header Access-Control-Allow-Origin "*";} 建数据库新建一个数据库叫什么名字都行, 其实配置默认即可. 安装Lsky浏览你的网站会自动进入域名/install.一路检查下来应该问题出在PHP拓展和禁用函数, 根据提示去安装拓展和开放函数. 宝塔用户开放函数软件商店->已安装->php8->设置 安装完成后根据自己需求配置用户组存储策略等. 结语储存策略记得加一个服务并设置为默认, 然后删除本地储存(服务器哪扛得住)","tags":["图床"],"categories":["堆栈"]},{"title":"Stellar代码块个人向美化","path":"//Stellar代码块个人向美化/","content":"前言增加主题控制后代码块样式有些唐突, 遂改之. 思路不改变主题代码情况下思路的主旋律按以下走: 代码块随主题颜色变更 增加复制代码功能 (来源whbbit) 增加代码过长折叠 代码下面直接成品, 有需求自定义修改 代码块样式ZYCode.css:root{ --code-autor: '© 钟意博客🌙'; --code-tip: "优雅借鉴";} /*语法高亮*/ .hljs { position: relative; display: block; overflow-x: hidden; /*背景跟随Stellar*/ background: var(--block); color: #9c67a1; padding: 30px 5px 2px 5px; box-shadow: 0 10px 30px 0px rgb(0 0 0 / 40%) } .hljs::before { content: var(--code-tip); position: absolute; left: 15px; top: 10px; overflow: visible; width: 12px; height: 12px; border-radius: 16px; box-shadow: 20px 0 #a9a6a1, 40px 0 #999; -webkit-box-shadow: 20px 0 #999, 40px 0 #999; background-color: #999; white-space: nowrap; text-indent: 75px; font-size: 16px; line-height: 12px; font-weight: 700; color: #999 } .highlight:hover .hljs::before { color: #35cd4b; box-shadow: 20px 0 #fdbc40, 40px 0 #35cd4b; -webkit-box-shadow: 20px 0 #fdbc40, 40px 0 #35cd4b; background-color: #fc625d; } .hljs-ln { display: inline-block; overflow-x: auto; padding-bottom: 5px } .hljs-ln td { padding: 0; background-color: var(--block) } .hljs-ln::-webkit-scrollbar { height: 10px; border-radius: 5px; background: #333; } .hljs-ln::-webkit-scrollbar-thumb { background-color: #bbb; border-radius: 5px; } .hljs-ln::-webkit-scrollbar-thumb:hover { background: #ddd; } .hljs table tbody tr { border: none } .hljs .hljs-ln-line { padding: 1px 10px; border: none } td.hljs-ln-line.hljs-ln-numbers { border-right: 1px solid #666; } .hljs-keyword, .hljs-literal, .hljs-symbol, .hljs-name { color: #c78300 } .hljs-link { color: #569cd6; text-decoration: underline } .hljs-built_in, .hljs-type { color: #4ec9b0 } .hljs-number, .hljs-class { color: #2094f3 } .hljs-string, .hljs-meta-string { color: #4caf50 } .hljs-regexp, .hljs-template-tag { color: #9a5334 } .hljs-subst, .hljs-function, .hljs-title, .hljs-params, .hljs-formula { color: #c78300 } .hljs-property { color: #9c67a1; } .hljs-comment, .hljs-quote { color: #57a64a; font-style: italic } .hljs-doctag { color: #608b4e } .hljs-meta, .hljs-meta-keyword, .hljs-tag { color: #9b9b9b } .hljs-variable, .hljs-template-variable { color: #bd63c5 } .hljs-attr, .hljs-attribute, .hljs-builtin-name { color: #d34141 } .hljs-section { color: gold } .hljs-emphasis { font-style: italic } .hljs-strong { font-weight: bold } .hljs-bullet, .hljs-selector-tag, .hljs-selector-id, .hljs-selector-class, .hljs-selector-attr, .hljs-selector-pseudo { color: #c78300 } .hljs-addition { background-color: #144212; display: inline-block; width: 100% } .hljs-deletion { background-color: #600; display: inline-block; width: 100% } .hljs.language-html::before, .hljs.language-xml::before { content: "HTML/XML" } .hljs.language-javascript::before { content: "JavaScript" } .hljs.language-c::before { content: "C" } .hljs.language-cpp::before { content: "C++" } .hljs.language-java::before { content: "Java" } .hljs.language-asp::before { content: "ASP" } .hljs.language-actionscript::before { content: "ActionScript/Flash/Flex" } .hljs.language-bash::before { content: "Bash" } .hljs.language-css::before { content: "CSS" } .hljs.language-asp::before { content: "ASP" } .hljs.language-cs::before, .hljs.language-csharp::before { content: "C#" } .hljs.language-d::before { content: "D" } .hljs.language-golang::before, .hljs.language-go::before { content: "Go" } .hljs.language-json::before { content: "JSON" } .hljs.language-lua::before { content: "Lua" } .hljs.language-less::before { content: "LESS" } .hljs.language-md::before, .hljs.language-markdown::before, .hljs.language-mkdown::before, .hljs.language-mkd::before { content: "Markdown" } .hljs.language-mm::before, .hljs.language-objc::before, .hljs.language-obj-c::before, .hljs.language-objective-c::before { content: "Objective-C" } .hljs.language-php::before { content: "PHP" } .hljs.language-perl::before, .hljs.language-pl::before, .hljs.language-pm::before { content: "Perl" } .hljs.language-python::before, .hljs.language-py::before, .hljs.language-gyp::before, .hljs.language-ipython::before { content: "Python" } .hljs.language-r::before { content: "R" } .hljs.language-ruby::before, .hljs.language-rb::before, .hljs.language-gemspec::before, .hljs.language-podspec::before, .hljs.language-thor::before, .hljs.language-irb::before { content: "Ruby" } .hljs.language-sql::before { content: "SQL" } .hljs.language-sh::before, .hljs.language-shell::before, .hljs.language-Session::before, .hljs.language-shellsession::before, .hljs.language-console::before { content: "Shell" } .hljs.language-swift::before { content: "Swift" } .hljs.language-vb::before { content: "VB/VBScript" } .hljs.language-yaml::before { content: "YAML" } /*stellar主题补偿*/ .md-text pre>.hljs { padding-top: 2rem !important; } .md-text pre { padding: 0 !important; } code { background-image: linear-gradient(90deg, rgba(60, 10, 30, .04) 3%, transparent 0), linear-gradient(1turn, rgba(60, 10, 30, .04) 3%, transparent 0) !important; background-size: 20px 20px !important; background-position: 50% !important; } figure::after { content: var(--code-autor); text-align: right; font-size: 10px; float: right; margin-top: 3px; padding-right: 15px; padding-bottom: 8px; color: #999 } figcaption span { border-radius: 0px 0px 12px 12px !important; } /* 复制代码按钮 */ .highlight { position: relative; } .highlight .code .copy-btn { position: absolute; top: 0; right: 0; padding: 4px 0.5rem; opacity: 0.25; font-weight: 700; color: var(--theme); cursor: pointer; transination: opacity 0.3s; } .highlight .code .copy-btn:hover { color: var(--text-code); opacity: 0.75; } .highlight .code .copy-btn.success { color: var(--swiper-theme-color); opacity: 0.75; } /* 描述 */ .md-text .highlight figcaption span { font-size: small; } /* 折叠 */ code.hljs { display: -webkit-box; overflow: hidden; text-overflow: ellipsis; -webkit-box-orient: vertical; /*-webkit-line-clamp: 6;*/ padding: 1rem 1rem 0 1rem; /* chino建议 */ } .hljsOpen { -webkit-line-clamp: 99999 !important; } .CodeCloseDiv { color: #999; background: var(--block); display: flex; justify-content: center; margin-top: inherit; margin-bottom: -18px; } .CodeClose { color: #999; margin-top: 3px; background: var(--block); } .highlight button:hover, .highlight table:hover+button { color: var(--swiper-theme-color); opacity: 0.75; } 执行函数 原作者复制代码会因为tabs这种标签的display:none而与代码语言重合, 已修复(也不算修复, 我把它写死了) ZYCode.js// 这四个常量是复制,复制成功,展开,收缩// 我使用的是 https://fontawesome.com/ 图标, 不用可以改为文字.const copyText = '<i class="fa-regular fa-copy" style="color: #aa69ec;"></i>';const copySuccess = '<i class="fa-regular fa-circle-check" style="color: limegreen;"></i>';const openText = '<i class="fa-solid fa-angles-down fa-beat-fade"></i>';const closeText = '<i class="fa-solid fa-angles-up fa-beat-fade"></i>';const codeElements = document.querySelectorAll('td.code');codeElements.forEach((code, index) => { const preCode = code.querySelector('pre'); // 设置id和样式 preCode.id = `ZYCode${index+1}`; preCode.style.webkitLineClamp = '6'; // 添加展开/收起按钮 if (preCode.innerHTML.split('<br>').length > 6) { const codeCopyDiv = document.createElement('div'); codeCopyDiv.classList.add('CodeCloseDiv'); code.parentNode.parentNode.parentNode.parentNode.appendChild(codeCopyDiv); const codeCopyOver = document.createElement('button'); codeCopyOver.classList.add('CodeClose'); codeCopyOver.innerHTML = openText; const parent = code.parentNode.parentNode.parentNode.parentNode; const description = parent.childNodes.length === 3 ? parent.children[2] : parent.children[1]; description.appendChild(codeCopyOver); codeCopyOver.addEventListener('click', () => { if (codeCopyOver.innerHTML === openText) { const scrollTop = document.documentElement.scrollTop; const codeHeight = code.clientHeight; if (scrollTop < codeHeight) { document.documentElement.scrollTop += codeHeight - scrollTop; } preCode.style.webkitLineClamp = '99999'; codeCopyOver.innerHTML = closeText; } else { preCode.style.webkitLineClamp = '6'; codeCopyOver.innerHTML = openText; } }); } // 添加复制按钮 const codeCopyBtn = document.createElement('div'); codeCopyBtn.classList.add('copy-btn'); codeCopyBtn.innerHTML = copyText; code.appendChild(codeCopyBtn); // 添加复制功能 codeCopyBtn.addEventListener('click', async () => { const currentCodeElement = code.querySelector('pre')?.innerText; await copyCode(currentCodeElement); codeCopyBtn.innerHTML = copySuccess; codeCopyBtn.classList.add('success'); setTimeout(() => { codeCopyBtn.innerHTML = copyText; codeCopyBtn.classList.remove('success'); }, 3000); });});async function copyCode(currentCode) { if (navigator.clipboard) { try { await navigator.clipboard.writeText(currentCode); } catch (error) { console.error(error); } } else { console.error('当前浏览器不支持此API'); }} 引入函数根目录/_config.yml# 自定义引入css,jsinject: script: - <script type="text/javascript" src="/custom/js/ZYCode.js"></script> 引入样式根目录/_config.stellar.ymlstyle: codeblock: highlightjs_theme: /custom/css/ZYCode.css 结语你备份了吗?","tags":["Stellar"],"categories":["堆栈"]},{"title":"Stellar文章目录个人向美化","path":"//Stellar文章目录个人向美化/","content":"前言用习惯之前的无银百两网站目录, 很想念.那就改成熟悉的样子! 思路好像没什么好说的, 关于目录一共就两个文件, 纯Stylus硬改了.就是不能变成css引入式覆盖有点可惜, 还是得改主题文件(有什么能覆盖的引入方法请务必告诉我).需要的看着修改即可. 代码 替换位置1 stellar/source/css/_layout/widgets/toc_common.styl.widget-wrapper.toc .widget-header margin-top: 1rem.widget-wrapper.toc .widget-header font-weight: 500 font-size: $fs-12 >span margin: 0.5rem 0.widget-wrapper.toc.single .widget-body margin-top: 0 border-left: 2.5px dashed var(--block-hover) ul ul, ul ol padding-left: 0 ol ul, ol ol padding-left: 0 .doc-tree margin: 4px 0 margin-left: 10.5px .toc padding: 0 margin: 0 //padding-left: 0.25rem .toc-item .toc-link //padding: 0.5rem font-weight: 500 font-size: $fs-13 color: var(--text-p2) .toc-child .toc-item .toc-link padding: 0.25rem 0.5rem 0.25rem 1.3rem font-weight: 400 color: var(--text-p2) .toc-child .toc-child .toc-item .toc-link padding-left: 2.1rem font-size: $fs-12 color: var(--text-p3) .toc-child .toc-child .toc-child .toc-item .toc-link padding-left: 2.9rem.widget-wrapper.toc.single .toc-item span display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;.widget-wrapper.toc .toc-item color: var(--text-p2) font-size: $fs-12 padding: 0 list-style: '' //ul样式 &:has(> a.toc-link) &::marker content: '🌸' color: #E9979C17 &:has(> a.toc-link:hover) &::marker content: '🌸' color: #E9979C5C &:has(> a.toc-link.active) &::marker content: '🌸' !important color: #f1404b !important .widget-wrapper.toc.single .toc-item &.active color: #fff; background: #f1404b; margin-top: 2px; margin-bottom: 2px; -webkit-box-shadow: 0 8px 15px rgb(240 65 76 / 30%); box-shadow: 0 8px 15px rgb(240 65 76 / 30%); .toc-child .toc-item padding: 0 &:after content: none// 二级目录颜色加深.toc-level-2 &::marker content: '🌸' color: #E9979C5C !important.widget-wrapper.toc.single a.toc-link position:relative; color:#738192; background:transparent; line-height:20px; border-radius:10px; display:inline-grid; padding:4px 20px 4px 10px; margin:-2px 0 -2px 12px; text-decoration:none; transition:.3s; margin-left :0 left: 10px; &:before content:""; position:absolute; transition:.3s; border-right:0px solid transparent; border-top:6px solid transparent; border-bottom:6px solid transparent; top: 8px; left:0px; &:hover color:#fff !important; background:#f1404bBF; margin-top:2px; margin-bottom:2px; -webkit-box-shadow:0 8px 15px rgba(240,65,76,0.3) !important; box-shadow:0 8px 15px rgba(240,65,76,0.3) !important; &::before border-right:6px solid #f1404bBF !important;left:-6px; &.active color:#fff !important; background:#f1404b !important; margin-top:2px; margin-bottom:2px; -webkit-box-shadow:0 8px 15px rgba(240,65,76,0.3) !important; box-shadow:0 8px 15px rgba(240,65,76,0.3) !important; &:before border-right:6px solid #f1404b !important;left:-6px;//激活上级目录时显示子目录.toc-item a.toc-link+ol display: none.toc a.toc-link.active+ol display: blockol:has(> .toc-item a.active) display: block.doc-tree:hover a.toc-link+ol display: block// wiki样式保持.widget-wrapper.toc.multi .widget-body margin-top: 0 ul ul, ul ol padding-left: 0 ol ul, ol ol padding-left: 0 .doc-tree margin: 4px 0 .toc padding: 0 margin: 0 padding-left: 0.25rem .toc-item .toc-link padding: 0.5rem font-weight: 500 font-size: $fs-13 color: var(--text-p2) .toc-child .toc-item .toc-link padding: 0.25rem 0.5rem 0.25rem 1.3rem font-weight: 400 color: var(--text-p2) .toc-child .toc-child .toc-item .toc-link padding-left: 2.1rem font-size: $fs-12 color: var(--text-p3) .toc-child .toc-child .toc-child .toc-item .toc-link padding-left: 2.9rem.widget-wrapper.toc.multi .toc-item color: var(--text-p2) font-size: $fs-12 padding: 0 list-style: none &.active color: $color-theme border-left-color: @color .toc-child .toc-item padding: 0.widget-wrapper.toc.multi a.toc-link color: inherit display: block line-height: 1.2 border-radius: 4px position: relative &:before content: '' position: absolute left: -6px top: 'calc(50% - %s)' % 6px bottom: 'calc(50% - %s)' % 6px width: 2px border-radius: 2px background: $color-theme visibility: hidden &:hover background: var(--block-hover) &.active color: $color-theme !important &:before visibility: visible 替换位置2 stellar/source/css/_layout/widgets/toc_blog.styltoc_blog.styl里面注释掉就行, 就两三行. 结语你备份了吗?","tags":["Stellar"],"categories":["堆栈"]},{"title":"Stellar可控夜间模式","path":"//Stellar可控夜间模式/","content":"前言可能习惯了主题能自己改变黑夜白昼, 所以打算做访客控制的配置.吃怕了换主题和更新主题的苦, 所以尽量抽离出来, 尽量不修改主题文件.这篇文章也是记录本次修改, 怕下次忘记改, 修改遵循原则: 尽量不修改主题文件 尽量与主题样式一致 尽量做到便携可移植 思路 抽离夜间样式 增加我们CSS文件优先级 网页添加主题按钮 评论主题跟随 后续优化 抽离夜间样式查阅主题配置文件可以看到博主控制昼夜是通过style.darkmode: false # auto / always / false来控制stylus生成整个网站main.css再查阅主题样式代码可以看到if hexo-config('style.darkmode') == 'always'包裹的就是夜间主题代码我们把它抽离出一个单独的ZYDark.css文件 增加我们CSS文件优先级我的想法是通过给html标签一个ID来取得优先级, 抽离的ZYDark.css都赋予这个ID.比如:root{--site-bg: #1c1e21;}变成#ZYDark:root{--site-bg: #1c1e21;} 网页添加主题按钮想了很多种方案都达不到主题样式一致原则.最后发现这里有7个位置!就拿他来当切换按钮吧! 储存与功能实现用户变量就扔到localStorage储存,反正不清空浏览器缓存就是永久储存.功能实现函数操作全都是一个JS执行, 包括给html标签一个ID. 黑夜闪白优化因为一些渲染顺序原因这个js只能放到网页靠末尾地方, 可能不是控制主题功能我还有其它功能方法, 所以结果是黑暗模式下刷新有点闪白色.解决办法是在head引入一个提前js,即判断localStorage是黑暗就马上给html加黑色ID, 后续渲染就没问题了!!! 评论主题跟随评论按这个思路去改吧, 加几句css的事情, 不会可以问博主.但我用的giscus就有点麻烦, 主题没有给giscus样式是引入的, 所以我的js里面有关于giscus的方法, 不用可以删除. 2023.2.4: 很棒, 我受够了引入式的giscus不太跟随主题变化(虽然它真的很棒). 投靠waline(香). 代码样式提取的stellar黑夜样式,一般无需修改, 你也可以自定义 ZYDark.css#ZYDark:root { --site-bg: #1c1e21; --card: #373d43; --block: #26292c; --block-border: #383d42; --block-hover: #2f3337; --text-p0: #fff; --text-p1: #ccc; --text-p2: #b3b3b3; --text-p3: #858585; --text-p4: #707070; --text-meta: #4d4d4d; --text-code: #ff6333;}@media screen and (max-width: 667px) { #ZYDark:root { --site-bg: #000; }}#ZYDark:root { --blur-bg: rgba(0,0,0,0.5);}#ZYDark .float-panel { --blur-bg: rgba(0,0,0,0.4);}#ZYDark .tag-plugin.tag { --theme: #ff6333; --theme-bg1: #3d1e14; --theme-bg2: #2f2522; --theme-border: #5c2d1f; --text-p0: #ffc4b3; --text-p1: #dfae9f; --text-p2: #f1997e;}#ZYDark .tag-plugin[color='red'] { --theme: #f44336; --theme-bg1: #3d1714; --theme-bg2: #2f2322; --theme-border: #5c231f; --text-p0: #ffb8b3; --text-p1: #dfa49f; --text-p2: #f1867e;}#ZYDark .tag-plugin[color='orange'] { --theme: #fa6400; --theme-bg1: #3d2514; --theme-bg2: #2f2722; --theme-border: #5c371f; --text-p0: #ffd1b3; --text-p1: #dfb99f; --text-p2: #f1ac7e;}#ZYDark .tag-plugin[color='yellow'] { --theme: #ffbd2b; --theme-bg1: #3d3014; --theme-bg2: #2f2b22; --theme-border: #5c491f; --text-p0: #ffe7b3; --text-p1: #dfcb9f; --text-p2: #f1cd7e;}#ZYDark .tag-plugin[color='green'] { --theme: #3dc550; --theme-bg1: #143d1a; --theme-bg2: #222f24; --theme-border: #1f5c27; --text-p0: #b3ffbd; --text-p1: #9fdfa8; --text-p2: #7ef18e;}#ZYDark .tag-plugin[color='cyan'] { --theme: #1bcdfc; --theme-bg1: #14353d; --theme-bg2: #222d2f; --theme-border: #1f4f5c; --text-p0: #b3efff; --text-p1: #9fd2df; --text-p2: #7ed9f1;}#ZYDark .tag-plugin[color='blue'] { --theme: #2196f3; --theme-bg1: #142b3d; --theme-bg2: #222a2f; --theme-border: #1f415c; --text-p0: #b3ddff; --text-p1: #9fc3df; --text-p2: #7ebef1;}#ZYDark .tag-plugin[color='purple'] { --theme: #9c27b0; --theme-bg1: #37143d; --theme-bg2: #2d222f; --theme-border: #531f5c; --text-p0: #f4b3ff; --text-p1: #d69fdf; --text-p2: #e07ef1;}#ZYDark .tag-plugin[color='light'] { --theme-border: #fff; --theme-bg1: #e0e0e0; --theme-bg2: #fff; --text-p0: #000; --text-p1: #111; --text-p2: #1f1f1f; --text-p3: #555; --text-code: #fff;}#ZYDark .tag-plugin[color='dark'] { --theme-border: #000; --theme-bg1: #1f1f1f; --theme-bg2: #111; --text-p0: #fff; --text-p1: #fff; --text-p2: #e0e0e0; --text-p3: #ddd; --text-code: #fff;}#ZYDark .tag-plugin[color='warning'],#ZYDark .tag-plugin[color='light'] { --text-p0: #000; --text-p1: #111; --text-p2: #1f1f1f; --text-p3: #555; --text-code: #fff;}#ZYDark .social-wrap a.social:hover { box-shadow: none;}/* waline评论样式 */#ZYDark .wl-count{ padding: .375em; font-weight: bold; font-size: 1.25em; color: #fff;}#ZYDark .cmt-body.waline{ --waline-white: #000; --waline-light-grey: #666; --waline-dark-grey: #999; /* 布局颜色 */ --waline-color: #fff; --waline-bgcolor: var(--block); --waline-bgcolor-light: #272727; --waline-border-color: #333; --waline-disable-bgcolor: #444; --waline-disable-color: #272727; /* 特殊颜色 */ --waline-bq-color: #272727; /* 其他颜色 */ --waline-info-bgcolor: #272727; --waline-info-color: #666;} 函数比如我的按钮在网页左下角第5开始是: dark, light, Moss(流浪地球AI的意思), 对应是下面这个代码的567.如果按钮按我的顺序而且是giscus评论模块则无需修改代码. 但giscus默认评论样式改成light. 2023.2.4: 离开giscus的我每晚睡的很好。如果你是giscus请使用这里面的代码。 Giscus版JShttps://cloud.thatcoder.cn/%E5%B7%B2%E5%85%AC%E5%BC%80%E6%96%87%E4%BB%B6/CoderSpace/Stellar/ZYDark.js ZYDark.js/** * 监听系统主题 * @type {MediaQueryList} */var OSTheme = window.matchMedia('(prefers-color-scheme: dark)');OSTheme.addListener(e => { if (window.localStorage.getItem('ZYI_Theme_Mode') === 'Moss') { ThemeChange('Moss'); }})/** * 修改博客主题 * @param theme 亮为light,暗为dark,自动为auto * @constructor */const ThemeChange = (theme) => { if (theme === 'light' || (theme === 'Moss' && !OSTheme.matches)) { document.querySelector("html").id = "ZYLight"; document.querySelector("#start > aside > footer > div > a:nth-child(6)").style.filter= 'grayscale(0%)'; document.querySelector("#start > aside > footer > div > a:nth-child(5)").style.filter= 'grayscale(100%)'; } else { document.querySelector("html").id = "ZYDark"; document.querySelector("#start > aside > footer > div > a:nth-child(5)").style.filter= 'grayscale(0%)'; document.querySelector("#start > aside > footer > div > a:nth-child(6)").style.filter= 'grayscale(100%)'; } if (theme==='Moss'){document.querySelector("#start > aside > footer > div > a:nth-child(7)").style.filter= 'grayscale(0%)';} else {document.querySelector("#start > aside > footer > div > a:nth-child(7)").style.filter= 'grayscale(100%)';} window.localStorage.setItem('ZYI_Theme_Mode', theme);}/** * 初始化博客主题 */switch (window.localStorage.getItem('ZYI_Theme_Mode')) { case 'light': ThemeChange('light'); break; case 'dark': ThemeChange('dark'); break; default: ThemeChange('Moss');}/** * 切换主题模式 */document.querySelector("#start > aside > footer > div > a:nth-child(5)").onclick = () => { ThemeChange('dark');}document.querySelector("#start > aside > footer > div > a:nth-child(6)").onclick = () => { ThemeChange('light');}document.querySelector("#start > aside > footer > div > a:nth-child(7)").onclick = () => { ThemeChange('Moss');} 提前量就一句js,你也可以打包成文件. 就这样写的话别漏了那个 | 竖 根目录/_config.yml# 自定义引入css,jsinject: head: - | <script> if (window.localStorage.getItem('ZYI_Theme_Mode')==='dark' || (window.localStorage.getItem('ZYI_Theme_Mode')==='Moss' && window.matchMedia('(prefers-color-scheme: dark)').matches)){ document.querySelector("html").id = "ZYDark"; } </script> 引入样式与函数看你自定义代码文件放哪咯, 我的在根目录/source/custom/里面if you like 你也可以挂成CDN链接引入 博客目录/_config.yml# 自定义引入css,jsinject: head: - <link rel="stylesheet" href="/custom/css/ZYDark.css"> # 黑夜样式 script: - <script type="text/javascript" src="/custom/js/ZYDark.js"></script> # 黑夜控制 自定义博主配置darkmode用false意味对主题而言保持永远白昼(才有了我们的操作空间)然后footer.social这东西我对应是567, 懒得改JS的可以前面也加四个社交按钮. 博客目录/_config.stellar.ymlstyle: darkmode: false # auto / always / false# 页尾footer: social: github: icon: '<img src="https://upyun.thatcdn.cn/public/img/icon/github-logo2.png"/>' url: https://github.com/ThatCoders music: icon: '<img src="https://upyun.thatcdn.cn/public/img/icon/neteasemusic-icon.png"/>' url: https://music.163.com/#/user/home?id=134968139 bili: icon: '<img src="https://upyun.thatcdn.cn/public/img/icon/bilibili-icon.png"/>' url: https://space.bilibili.com/1664687779 card: icon: '<img src="https://upyun.thatcdn.cn/public/img/icon/weChat.png"/>' url: https://muselink.cc/naive Moon: icon: '<img id="ThemeM" src="https://upyun.thatcdn.cn/public/img/icon/Moon.png"/>' url: javaScript:void('永夜'); Sun: icon: '<img id="ThemeL" src="https://upyun.thatcdn.cn/public/img/icon/Sun.png"/>' url: javaScript:void('永昼'); AI: icon: '<img id="ThemeAI" src="https://upyun.thatcdn.cn/public/img/icon/AI.png"/>' url: javaScript:void('跟随系统'); 修改主题文件waline评论才要这步, 其它评论自己看一下comments文件夹 文件路径: 根目录/themes/stellar/source/css/_plugins/comments/waline.styl 注释掉 41-64 行 结语至此结束了, 有什么问题可以评论留言.方案我会一直优化下去. 2023.01.29 文章发行钟意发表了此篇文章.2023.02.04午 修复001修复没有giscus评论页面导致切换主题代码停止运行.2023.02.04晚 修复002不能完美解决问题, 就把问题解决!评论插件更换为waline.","tags":["Stellar"],"categories":["堆栈"]},{"title":"Stellar自用魔改存档","path":"//Stellar自用魔改记录/","content":"前言 当前主题版本: 1.18.5 钟意博客提醒您, 魔改不备份, 亲人两行泪. 文章目录样式效果代码文章目录样式https://blog.thatcoder.cn/Stellar%E6%96%87%E7%AB%A0%E7%9B%AE%E5%BD%95%E4%B8%AA%E4%BA%BA%E5%90%91%E7%BE%8E%E5%8C%96/ 昼夜模式切换用户主题切换功能https://blog.thatcoder.cn/Stellar%E5%8F%AF%E6%8E%A7%E5%A4%9C%E9%97%B4%E6%A8%A1%E5%BC%8F/ 根据分类排版 这是大致思路, 根据自己需求修改 博客目录/_config.yml>inject.script引入// 判断是否为首页类。有很多种方法, 有空我找一下最优解const isIndex=()=>{ const href = window.location.href; const protocol = window.location.protocol+"//"+window.location.host+"/"; return href === protocol || href === protocol + "categories/" || href === protocol + "tags/" || href === protocol + "archives/" || href === protocol + "rss/" || href === protocol + "wiki/" || href === protocol + "links/" || href === protocol + "about/";}if (!isIndex()){ var ThisCategory = document.querySelector("#breadcrumb > a.cap.breadcrumb-link").text; if (ThisCategory == "第九艺术") { document.querySelector("#start > div > article > h1").style.display = 'none'; document.querySelector("#start > div > article > div:nth-child(3)").style.textAlign = 'center'; }} 全局复制提示引入JS博客目录/_config.ymlinject: head: - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/izitoast@1.4.0/dist/css/iziToast.min.css"> script: - <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/izitoast@1.4.0/dist/js/iziToast.min.js"></script>博客目录/_config.yml>inject.script引入// 复制提示document.body.oncopy = function () {iziToast.info({timeout: 4000, // 关闭弹窗的时间// icon: 'Fontawesome', // 图标类别closeOnEscape: 'true', // 允许使用Esc键关闭弹窗transitionIn: 'bounceInLeft', // 弹窗打开动画transitionOut: 'fadeOutRight', // 弹窗关闭动画displayMode: 'replace', // 替换已经打开的弹窗layout: '2', // Medium模式position: 'topRight', // 弹窗位置//icon: 'fad fa-copy', // 图标类名iconUrl: 'https://upyun.thatcdn.cn/hexo/stellar/image/favicon.webp',backgroundColor: '#fff', // 弹窗背景色title: '复制成功', // 通知标题message: '请遵守 CC BY-NC-SA 4.0 协议' // 通知消息内容});} 修改网页字体引入配置博客目录/_config.ymlinject: head: - <link rel="stylesheet" href="https://upyun.thatcdn.cn/hexo/stellar/css/font.css">博客目录/_config.stellar.ymlstyle: font-size: root: 19.3px body: .9999rem # 15px code: 85% # 14px codeblock: 0.8125rem # 13px font-family: logo: 'ZFonts, system-ui, "Microsoft Yahei", "Segoe UI", -apple-system, Roboto, Ubuntu, "Helvetica Neue", Arial, "WenQuanYi Micro Hei", sans-serif' body: 'ZFonts, system-ui, "Microsoft Yahei", "Segoe UI", -apple-system, Roboto, Ubuntu, "Helvetica Neue", Arial, "WenQuanYi Micro Hei", sans-serif' code: 'Menlo, Monaco, Consolas, system-ui, "Courier New", monospace, sans-serif' codeblock: 'Menlo, Monaco, Consolas, system-ui, "Courier New", monospace, sans-serif' 侧边栏欢迎图 API采用IP签名档, 主要是代码的img标签. 效果代码其实字有点小博客目录/source/_data/widgets.ymlwelcome: layout: markdown title: '🎉欢迎, 先生亦或是姑娘: ' content: | 这是一个成分复杂的小站,建于二十一世纪初,至今练习时长两年半,将会继续长期维护和更新。<br>🙏本站评论与动态在Github托管, 显示不全是不能裸连Github。 <img id="ZYTheme" style="border-radius: 10px;" src="https://api.szfx.top/info-card/?word=感谢来访钟意博客, 懈怠轻忽."/> 自定义Fancybox范围好在主题已经给出相关配置, 我们只需要找到自己要开启的地方.我开的地方除了自带image标签还有文章里所有img, 和waline评论里的img. _config.stellar.ymlplugins: fancybox: # 可以处理评论区的图片(不支持 iframe 类评论系统)例如: # 使用twikoo评论可以写: .tk-content img:not([class*="emo"]) # 使用waline评论可以写: #waline_container .vcontent img selector: .swiper-slide img, .md-text.content p>img, .md-text.content li img , .wl-content img # 多个选择器用英文逗号隔开 代码主题样式效果代码效果body{display:none;}ZYCode.csshttps://cloud.thatcoder.cn/%E5%B7%B2%E5%85%AC%E5%BC%80%E6%96%87%E4%BB%B6/CoderSpace/Stellar/ZYCode.css 博客目录/_config.stellar.ymlstyle: codeblock: highlightjs_theme: https://upyun.thatcdn.cn/public/web/stellar-1.18.5/ZYCode.css 字体切片cn-font-split 切片方法第一步安装npm包: 第二步新建一个xxx.mjs文件, 填入以下代码.切片函数import { fontSplit } from "@konghayao/cn-font-split";fontSplit({ FontPath: "./xxx.ttf", // 把字体放到本文件同级下 destFold: "./FontOrigin", // 生成的文件夹位置 targetType: "ttf", // ttf woff woff2;注意 eot 文件在浏览器中的支持度非常低,所以不进行支持 testHTML: true, // 输出一份 html 报告文件 reporter: true, // 输出 json 报告});第三步把字体放到平级目录, 根据字体名称与类型修改函数第四步运行这个函数. 运行命令很多选择 比如: 第五步等待片刻成功的话, 会有FontOrigin目录 使用方法引入FontOrigin目录里面的css即可, 并把网站字体选成css里面的font-family 测试TimeLIne自定义模板还在测试…","tags":["Stellar"],"categories":["堆栈"]},{"title":"Stellar自用排版片断","path":"//Stellar自用排版片断/","content":"视频分集视频分集<video id="postVideo" src="https://upyun.thatcdn.cn/blog/wp-public/wp-daily/blog/2022/08/20220815152828271.webm" width="100%" controls></video><div class="tag-plugin navbar"><nav class="cap"> <a onclick="myVid.src ='https://upyun.thatcdn.cn/blog/wp-public/wp-daily/blog/2022/08/20220815152828271.webm'">预告1</a> <a onclick="myVid.src ='https://upyun.thatcdn.cn/blog/wp-public/wp-daily/blog/2022/08/20220815152919397.webm'">预告2</a> <a onclick="myVid.src ='https://upyun.thatcdn.cn/blog/wp-public/wp-daily/blog/2022/08/20220815153006675.webm'">预告3</a></nav></div><script> let myVid=document.getElementById("postVideo");</script> 效果 预告1 预告2 预告3 let myVid=document.getElementById(\"postVideo\"); 隐藏开头标题隐藏开头艺术标题, 自定义h1, 并把本文类型tag居中 {% quot el:h1 PLASTIC MEMORIES %}<div class="MyTag">{% tag 15+ color:green %}&nbsp;{% tag 恋爱 color:pick %}&nbsp;{% tag 养成 color:pick %}</div><style>.article-title{display:none;}.MyTag{text-align: center}</style> 效果","tags":["Stellar"],"categories":["堆栈"]},{"title":"Vervel反向代理功能","path":"//Vercel Proxy/","content":"前言使用Vercel反向代理有以下优点 域名不需要备案 隐藏源主机地址 可以充当缓存机 还赠送免费的SSL 需要环境 npm Vercel账号 配置模块打开命令行执行以下 安装所需模块: npm i-g vercel 登入: vercel login 选择相应的登入方式登入即可. 实现反代 假设我有一个博客和一个未备案域名(bilibili.com), 博客运行在主机 123.123.123.123 里面, 运行端口是9000 .我需要这个未备案域名指向我的博客(123.123.123.123:9000).碰巧国外主机不需要备案, 碰巧vercel是国外服务器, 还碰巧未备案解析商(腾讯)是国内, 就碰巧能解决这个需求, 步骤如下. 新建一个JSON文件, 比如这个叫 blog.json, 编辑内容如下: { "version": 2, "routes": [ {"src": "/(.*)","dest": "http://123.123.123.123:9000/$1"} ]} 打开命令行 cd 到这个json文件的目录, 执行部署: vercel -A blog.json --prod 根据提示完成部署, 你会得到一个默认域名, 域名指向http://123.123.123.123:9000,此时打开 Vercel官网 就能看到这个项目.我们接下来把未备案域名解析到这个项目 域名解析 我需要这个未备案域名指向我的博客(123.123.123.123:9000). 打开 Vercel官网 点击刚才的项目 找到 setting->domains->add 根据提示去域名商那里完成解析即可, 你就会得到一个免费的SSL. 忠告不要手贱去反向代理github,google等知名网站, 会判定你为钓鱼网站, 最后发一封邮件告诉你你在钓鱼违反规定然后封号斗罗. (不要问我怎么知道的呜呜呜)","tags":["Vercel"],"categories":["堆栈"]},{"title":"匡庐游记","path":"//daily/匡庐游记/","content":"诗词鉴赏题西林壁宋·苏轼横看成岭侧成峰,远近高低各不同。不识庐山真面目,只缘身在此山中。诗词节选望庐山瀑布唐·李白朝日照香炉生紫烟,遥看瀑布挂前川。飞流直下三千尺,疑是银河落九天。诗词节选 游记首次2019.12.31 首次是大一那年, 邓小生还在浪迹天涯, 浪到九江找我跨年. 便于老王、老煌一同前往. 在庐山找了个音乐餐厅享受跨年氛围, 为时1晚.餐厅屏幕上轮播着历史, 关于餐厅游客的深情投稿历史. 有祝愿、 有期盼、 有告白…在驻唱演奏完进行了赛马项目(摇手机), 我和老王夺冠喜提红酒. 拍照留念就让他女朋友陪她.元旦早晨便下山, 老邓继续浪迹, 我去了老煌学校布置元旦晚会(我们学校没有!!!)首次去庐山没有怎么看它, 我知道我们来日方长. 二顾2021.01.12 第二次和蕾宝, 在寒假回家前, 为时3天.这次是游玩了大部分主要景点, 去时抱着看庐山雪景, 但雪正在消融.芦林湖、 五老峰、 如琴湖、含鄱口日出很美, 但三叠泉要被冻住了(三叠泉:别说了,说多了都是泪,别人现在说我水龙头没关)下山我们在九江甘棠公园、海韵沙滩等地游玩回家. 待完成","tags":["随笔"],"categories":["生活"]},{"title":"八一广场记","path":"//八一广场记/","content":"因为学校自选的德育实践活动地点我选择了南昌八一广场, 而且不想让每个我经手的事情过于形式化, 就有了此篇记录一下本次 “最忆当年·红色绵延” 的地点.毕竟在时间洪流里 “没有记录就等于没有发生“ 历史沿革明清时期明清时期,该地域是顺化门外的护城河和沼泽地。清朝光绪年间(1875年至1908年)清政府在沼泽地开辟出训练新兵的大校场。宣统三年(1911年)革命军于大校场整集,推翻了清政府在江西的统治,建立中华民国江西政府。民国元年(1912年)10月28日孙中山在大校场检阅了李烈钧部的江西革命军;民国十七年(1928年),南昌城市改造,拆去顺化门,填塞护城河,修建绕城公路(今八一大道),使之具备了广场的雏形。1956年人民政府在城建工程中,正式命名“人民广场”,并加扩充拓展,广场作为城市中心的地位得到确定。半个世纪以来,广场经过多次改造和扩建,一直是省会城市中政治、经济、文化活动的重要场所。1968年广场西侧新建“毛泽东思想胜利万岁馆”。20世纪50年代各个地方都在兴建万岁馆,其中八一广场“万岁馆”正是现在江西省美术馆大楼的前身,随着历史时代的变迁,万岁馆的功能也随之发生改变。1969年八一广场进行第一次改造。1977年8月1日广场上开始兴建“八一南昌起义纪念塔”。同时,广场进行大整修,人民广场改名为“八一广场”。1979年1月8日八一起义纪念塔建设落成,成为南昌英雄城的标志性建筑。1983年八一广场进行第二次改造。1993年八一广场进行了第三次改造。1995年八一广场新增东西两侧二块绿色游园,扩大广场绿地面积47700平方米。2001至2004年在中共南昌市委和市政府的主持下,实施大规模的扩建改造工程,使之成为突现八一南昌起义中心主题,包括纪念性、标志性、群众性和休闲性多项功能的大型现代化城市广场。广场核心区面积扩至七万八千平方米,周边面积扩至三十多万平方米。2005年1月1日南昌市人民政府实施《南昌市八一广场管理规定》。2017年03月10日为迎接南昌起义90周年庆,八一广场实施改造扩建。八一广场提升改造工程动工,涉及广场景观改造、广场周边建筑立面美化及广告,同时还有万达广场、百货大楼、省移动大厦等建筑外立面进行改造。以展览馆为核心,保留原建筑风貌,以淡黄色为主背景的基调就这么定下了。 这些年那些事在浏览整个互联网对八一广场的记忆中, 伴随着上面的历史沿革对比, 让人唏嘘不已. 以下可能是一段历史事件, 或者是一段那个时代的评语. 1961年,从庐山开完会议的各省领导在总理的带领下,来到省政府大楼的顶楼露台。 远眺南昌八一大道以及人民广场周边的建设,听着邵式平省长的介绍,大家称赞不已,总理说:“江西老俵,气魄不小”。 那时的人民广场及其周边已经初具雏形,南昌的中心从老城东移已成定局。 十九世纪七十年代, 曾经让南昌人无比骄傲,国内仅次于北京天安门广场,属全国第二大的广场,叠加了南昌几代人乃至全省人民的深刻记忆。 随便到老南昌的家中,翻箱倒柜都有几张广场主席台、展览馆的老照片,照相地点不是【服务大楼】就是【东方红】。那时的广场就是南昌城的中心地标,全市全省人民的网红打卡点,也承载了一个城市的集体记忆。 谈起万岁馆, 南昌上了年纪的老人都记忆犹新, 他们也有很多人当年都参加了建设万岁馆的义务劳动。按那个年代流行的话语就是“献忠”,有些人还因为没有被单位安排去劳动而“眼泪汪汪”,痛恨、懊恼自己的家庭出身。 老人们在回忆修建“万岁馆”时, 人民广场人山人海的场景,唏嘘不已! 九十年代,无论从地理位置还是商业服务设施的繁华集中度。八一广场成为南昌当仁不让,舍我其谁的中心。 三十年河东,三十年河西。财富广场于是在文化宫旧址上横空出世,成为广场地标。 过去人人喊打的“财富”二字, 成为时代无可替代的香饽饽。八一广场迎来了南昌商业史上最风光的时刻。 2000年,南昌第一家肯德基店在广场开业, 当日就创造了全球单店单日营业额的新纪录; 2003年,沃尔玛超市在八一广场盛大开业; 2012年,星巴克南昌首店落户南昌百货大楼,再度引发排队热潮。 上世纪的八、九十年代,一批共和国的同龄人也开始在南昌商贸服务业崭露头角,叱咤风云。 令人称奇的是: 三个南昌商业学校66届的同班同学在广场周边开始了属于他们自己的“时区”。 “涂世明”执掌服务大楼,“周勤生”一手打造了洪城大厦, “夏泰吉”掌管华侨友谊商店。 顺带一提的是,曾经的南昌商业学校. 简直就是本土商业系统的黄埔军校。 过去南昌诸多商场、餐饮、服务行业的老总几乎都是出自这个学校。 它现在仿佛是一幅画, 只能看不能就去. 多去广场逛逛,是对老南昌最起码的尊重. 如果说八一桥一带是老南昌近代百年开枝散叶的“根”,那八一广场就是建国后南昌人心中的“魂”。但这个魂走着走着就走丢了。 一些思考这些纪念塔、纪念广场之类的建筑, 说出来大家都知道, 但并不会过多探究这个建筑有什么寓意,为什么会建立起来?是什么时候建立起来的?大多数人也许更多是一个旁观者的角色。 我认为它存在的意义与本篇一样, 真的就怕没有记录就像没有发生一样. 它就像一个历史的书签🔖, 当你不经意间看到它, 当你想了解它, 就能拨开这枚历史书签去翻阅这段快要让人遗忘的、不再被人打扰的时光. 历史的重量真实可感, 多沉淀下来去感受. 多去广场逛逛,是对老南昌最起码的尊重.","tags":["随笔"],"categories":["生活"]},{"title":"基本运行环境","path":"//PartTimeREADME/","content":"IDEA破解版资源下载 官网: JetBrains 本站: 钟意云盘 使用教程 下载 IDEA.exe IDEA.7z.001 使用 运行 IDEA.exe, 选择解压地址. 解压完成去到解压地址的IDEA文件夹里面, 点击 绿化.bat 运行文件在 bin 文件夹的 idea64.exe Mysql免安装版资源下载 官网: MySQL 本站: 钟意云盘 使用教程 解压到随意一个你不敢乱删的文件夹 在文件夹新建 my.ini my.ini[mysqld]# 设置3306端口port=3306# 设置mysql的安装目录,一定要与上面的安装路径保持一致basedir=D:\\\\devSoft\\\\mysql# 设置mysql数据库的数据的存放目录datadir=D:\\devSoft\\mysql\\\\Data# 允许最大连接数max_connections=200# 允许连接失败的次数。max_connect_errors=10# 服务端使用的字符集默认为utf8mb4character-set-server=utf8mb4# 创建新表时将使用的默认存储引擎default-storage-engine=INNODB# 默认使用“mysql_native_password”插件认证#mysql_native_passworddefault_authentication_plugin=mysql_native_password[mysql]# 设置mysql客户端默认字符集default-character-set=utf8mb4[client]# 设置mysql客户端连接服务端时默认使用的端口 可以根据实际情况进行修改port=3306default-character-set=utf8mb4 在数据库文件夹bin目录下打开管理员命令符执行初始化, 获取密码 管理员窗口mysqld --initialize --console 开启mysql net start mysql # 启动mysql的服务net stop mysql # 关闭mysql服务 登入并修改密码 mysql -u root -p 初始化的密码 #登录mysqlALTER USER 'root'@'localhost' IDENTIFIED BY '新密码'; # 修改密码exit; # 退出当前命令行 添加环境变量(添加bin目录的绝对路径到环境变量的path即可) Android Studio 官网: 官方下载 PyCharm相关资源下载 官网: MySQL 本站: 钟意云盘 pip换源升级pippython -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple --upgrade pip # 临时使用清华源升级pip 修改默认源pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple # 修改默认源为清华源 下面一般用不上 配置多个镜像源平衡负载,可在已经替换 index-url 的情况下通过以下方式继续增加源站: 多源平衡负载pip config set global.extra-index-url "源1 源2..." # 请自行替换引号内的内容,源地址之间需要有空格","categories":["堆栈"]},{"title":"小小梦魇","path":"//小小梦魇/","content":"Little Nightmares 16+ 恐怖 解密 IP发展史 2015年04月27日 一作PC发行一作PC发行小小梦魇1本作的故事背景发生在一艘神秘的、名为“胃”邮轮上,玩家扮演身着黄色小雨衣,从睡梦中醒来的小女孩——小六。玩家将在巨大的邮轮中进行探索,逃脱恐怖怪物的追捕,寻找一线生机。可是小女孩在逃亡的过程中却陷入了迷茫。2015年5月18日 一作NS发行一作NS发行《小小梦魇 完全版》移植至NS平台,于5月18日发售海外版。另外,NS版本收录迄今为止所有DLC。啊, 和我没关系。2021年02月11日 二作PC发行二作PC发行小小梦魇2呜呜呜呜呜, 小六放手了, 成为了老六...2022年09月11日 手游安卓IOS发行手游安卓IOS发行我是没看到。 游戏简介 一作:让自己沉浸在噩梦,一个黑暗的异想天开的故事,面对你和你的童年的恐惧! 帮助六个逃跑的——一个巨大的,神秘的船居住着的灵魂在寻找他们的下一顿饭。你进展你的旅程,探索最令人不安的玩偶之家提供一间监狱,逃离和一个操场充满秘密的发现。伴着你的内在小孩释放你的想象力并寻找出路! 二作:重回那令人着迷的恐怖世界── 在《小小梦魇2》的悬疑冒险旅途中,您将化身为小男孩摩诺,身处在电波笼罩之下,因此变得扭曲的世界里。 摩诺将与穿着黄色雨衣的女孩──小六携手合作,揭发讯号塔暗藏的秘密。 然而这绝对不是一趟轻松的旅程:潜藏在这个世界中的未知威胁,正等着他们。 准备好面对另一场儿时梦魇了吗? 不错的小小梦魇解说 游戏画面 下载地址 密码栏下载栏云盘密码解压密码小小梦魇12https://cloud.thatcoder.cn/%E5%B7%B2%E5%85%AC%E5%BC%80%E6%96%87%E4%BB%B6/%E7%AC%AC%E4%B9%9D%E8%89%BA%E6%9C%AF/%E5%B0%8F%E4%BC%97/%E5%B0%8F%E5%B0%8F%E6%A2%A6%E9%AD%871-2/ 有能力一定入正喔! 小小梦魇1-steam正版https://store.steampowered.com/app/424840/Little_Nightmares/?curator_clanid=42309150/ 小小梦魇2-steam正版https://store.steampowered.com/app/860510/2/","tags":["Game"],"categories":["第九艺术"]},{"title":"切尔诺贝利特","path":"//切尔诺贝利/","content":"Chernobylite 18+ 恐怖 生化 .article-title{display:none;}.MyTag{text-align: center} IP发展史 2021年07月28日 一作PC发行一作PC发行《Chernobylite》是 The Farm 51 开发的科幻生存恐怖角色扮演游戏。故事设定在超现实的切尔诺贝利隔离区,在这片基于 3D扫描的荒弃土地上,探索非线性的故事,揭露你饱受煎熬的过去中隐藏的真相。 游戏介绍 食之无味,弃之可惜  《切尔诺贝利人》作为一款科幻生存恐怖角色扮演游戏,除了游戏名和切尔诺贝利有关系,其内核则是以切尔诺贝利核电站作为一个切入点,讲述了核灾害发生后禁区内一种名为“切尔诺贝利特”的物质所引发的种种超自然现象的故事。在禁区内生存、探索、收集物资,招募队友并建立营地,以及解开这一切谜团背后的真相。   作为一款实景扫描当做卖点的游戏,该作展现了切尔诺贝利浓郁的东欧风格和苏联美学的构造,艺术风格也是令人眼前一亮。但是本作的剧情虽是无伤大雅,却也缺乏亮点。开头和结尾处场景的首尾呼应和结尾处的反转都算是不错的亮点,但是并不能掩盖该作剧情疲软的缺点,以及玩家能猜测到后续剧情的发展。剧情的推进主要靠任务的发展和主人公的“回忆演练”,但是单调的任务甚至到了后期很多跑腿任务都为本作的剧情大打折扣。相对于《地铁》系列中的塑造非常成功的安娜,该作中的未婚妻塔蒂阿娜则更像是一个冰冷的任务引导器,互动感基本为零。虽说是剧情需要,但是可以在回忆部分添加一些和主人公的互动,很可惜这部分并没有。 建造系统也是该作的卖点之一。令人遗憾的是,除了在营地内布置一些可以提升队友“心情”的摆件,大部分时间还是更专注于对战斗力提升的设备建造。并不能像《辐射》等其他开放类型游戏一样建造房屋等建筑,只是在一个较为狭小的空间内放置各种工具台或者是家具,并且被完全限制在营地内,开放世界里无法建造任何东西。    本作的队友也都是非黑即白的二元思维,大多数时候都是“做”与“不做”的选项。当你面临选择时,听从某位队友的建议并选择提升其好感度的选项时,其它建议选项的队友对你的好感度必定会下降,如果好感度到达“糟糕”的话,这名队友就会离开你的队伍。这时你就需要重置时间线,改变剧情的关键选择点。包括营地内的居住环境也会影响队友的战斗力和心情,不达标的环境很可能会造成队友的不满意,降低他们委托任务的成功率,他们还会因任务失败而死在外面。    恐怖的气氛全靠低级的scare jump和怪物的传送机制,明明清理完了一片区域,当你再次返回来的时候,总有几个怪物在你背后准备给你一次转角遇到爱。多次的使用scare jump使得整个游戏的恐怖感很廉价,只是单纯的吓人而并没有一种恐怖的氛围。   《切尔诺贝利人》并没有达到一线游戏的水准,看的出制作组什么都想要的心情,与其“我全都要”,不如老老实实的做好其中的某一项。游戏的体量保证了它有足够的内容,但是其质量却不敢令人恭维。    最后说一句,这个游戏真是成就党的福音。 游戏画面 游戏预告 预告1 预告2 预告3 let myVid=document.getElementById(\"postVideo\"); 系统配置 系统配置 下载地址 密码栏下载栏云盘密码解压密码切尔诺贝利https://cloud.thatcoder.cn/%E5%B7%B2%E5%85%AC%E5%BC%80%E6%96%87%E4%BB%B6/%E7%AC%AC%E4%B9%9D%E8%89%BA%E6%9C%AF/3A/%E5%88%87%E5%B0%94%E8%AF%BA%E8%B4%9D%E5%88%A9%E4%BA%BA 有能力记得入正喔! 切尔诺贝利https://store.steampowered.com/app/1016800/Chernobylite_Enhanced_Edition/","tags":["Game"],"categories":["第九艺术"]},{"title":"可塑性记忆","path":"//可塑性记忆/","content":"PLASTIC MEMORIES 15+ 恋爱 养成 IP发展史 2014年8月 Twitter入住Twitter入住Twitter2015年3月27日 网络广播网络广播B站UP有留存《满和扎克的PLAMEMO广播》, 每周五在HiBiKi Radio Station播出. 听得懂你就来, 给你链接可塑性记忆广播https://space.bilibili.com/275246/search/video?keyword=%E5%8F%AF%E5%A1%91%E6%80%A72015年4月4日 电视动画电视动画《可塑性记忆》原创电视动画由ANIPLEX公司企划,由负责过5pb.公司开发的《命运石之门》等“科学ADV系列”游戏剧本的林直孝担当编剧。2015年4月24日 外传漫画外传漫画这部前日谈性质的外传漫画由祐佑作画,以绢岛满为主人公,主要讲述发生在动画剧情前的故事。与动画同步进行描写不同视角故事的本传漫画则于《电击G's Comic》2015年6月号开始连载。2016年9月10日 外传小说ISBN: 4048654098《プラスティック・メモリーズ -Heartfelt Thanks》由原作者林直孝亲自执笔的外传小说于2016年9月10日发售。电击文库欸嘿2016年10月13日 平台游戏PS Vita「プラスティック・メモリーズ」公式サイト由5pb.制作的PSV平台ADV游戏 游戏简介 故事发生在一个比现在的科学要进步的世界。18岁的水柿司高考失败,多亏父母找关系得以进入世界大企业SAI社工作。SAI社是制造管理拥有感情的人形智能机器人(通称:Giftia)的企业,司在其中被安排到终端服务部门工作。这个部门其实就是回收即将到期的Giftia,是所谓的“窗边部门(不被重视的部门)”。于是司和打杂的Giftia少女“艾拉”组成搭档,一起开始了工作…… 游戏画面 一段游戏PV 下载地址 密码栏下载栏云盘密码解压密码可塑性记忆汉化版https://cloud.thatcoder.cn/%E5%B7%B2%E5%85%AC%E5%BC%80%E6%96%87%E4%BB%B6/%E7%AC%AC%E4%B9%9D%E8%89%BA%E6%9C%AF/galgame/%E5%8F%AF%E5%A1%91%E6%80%A7%E8%AE%B0%E5%BF%86.7z/","tags":["GalGame"],"categories":["第九艺术"]},{"title":"QQ音乐找QQ号","path":"//QQ音乐查找QQ号/","content":"先决条件 不是音乐人 空间不是隐私仅自己可见 QQ号登入 切勿用来对线谢谢 大致步骤 登入自己的QQ号 进入目标的主页 打开开发者模式的网络模式 第一次查关键字确定链接 第二次查关键字确定账号 壹、登入官方的 QQ音乐, 进去先登入一个账号啥都行. 因为查看别人必须登入, 贰、目标主页 找到那个人点击头像就进去了, 大概长这样子. 点击[我喜欢] 叁、网络模式 在上面的目标页面按键盘上的 F12, 把开发者窗口拉大一点. 像我这样. 然后在打开的窗口上面一栏找到网络, 英文应该是叫Web什么的 点击网络后按下键盘 Ctrl+F, 会打开一个搜索栏 有搜索栏之后刷新目标主页(一定要刷新不然网络里面没内容) 肆、找链接 在搜索栏搜索 musicid, 一般会出现两个结果, 我们一般选第二个.点击后右边有内容. 需要再次查找. 伍、找账号 QAQ、失败答疑 Q: 网络选项卡搜索不到 musicid A: 没刷新 Q: 搜索出来的账号是10几位数字或者是null A: 此人不是QQ登入 Q: 能不能博主帮找 A: 方法已给出,不想动请付费, 勿白嫖劳动力, 联系方式Q: 2297813468 Q: 找到了QQ号, 但QQ搜索不到 A: 对方设置了不能QQ号查找, 可以使用我的API试试看, 把2297813468改成你找到的的QQ号: https://api.seclusion.work/api/qq/?qq=2297813468","tags":["Tencent"],"categories":["堆栈"]},{"title":"修复、格式化U盘不可读取状态","path":"//U-Fix/","content":"前言总有些奇怪的操作, 能把U盘干废, 导致都能不可读取。以下命令基于 Window10 Cmd 运行 代码 输入 diskpart 会新开一个窗口 New Diskpartdiskpart 大概这样diskpart窗口 查看问题的磁盘是第几个 查询磁盘list disk 修复 Fix# 根据第二步知道的编号进行选择select disk 2cleancreate partition primaryactive","tags":["Window"],"categories":["堆栈"]},{"title":"笔名钟意","path":"/about/index.html","content":"笔名钟意一位即将失业的某不知名本科生性格分析为: INFP-P感谢你的阅读, 让我们拥有一段对彼此都有意义的时光友人帐博主 网站动态拍下来行路难写下就要干! 没记录就会没发生 2023.01.25厦门鼓浪屿沙滩LQ想海了,和他来厦门看海(说走咱就走)2023.01.1广州番禺区番禺大道和ZFP自驾来广州找DZQ、LL游玩(说走咱就走)2022.12.31CZF老家跨年喝白酒加柠檬.(柠檬还是奶茶里面的)2022.10.05南昌东湖区佑民寺国庆与WJX、ZCY逛南昌2022.09.08CHQ毕业经常网易云听歌的好友毕业了,毕业后应该没什么时间听歌了.(她说我毕业送我花, 记下来.)2022.07.23赣州龙南安基山林洞ZFP非要去避暑,自驾来了安基山.山里藏着好多麦田.2022.07.04九江职业技术学院不出意外, 这是我最后一眼看这个学校. 不管是知识还是感情三年收获还是良多. 再见了相互嫌弃的课本与同学.2022.06.23房间窗口在家除了学习就是滚出去玩, 沉浸学习了一段时间才发现我房间窗口的景色还不错, 希望我忙碌之余多抬头望望天.2022.06.15家乡邻镇老家旁边被洪水淹没2022.03.13杭州西湖区云栖竹径闲暇散步云栖竹径2022.03.12杭州西湖区郭庄闲暇散步郭庄2022.03.07杭州西湖区宝石山闲暇散步宝石山2022.03.05杭州西湖区太子湾公园闲暇散步太子湾公园2022.03.12杭州西湖区某处闲暇散步无目的2022.02.27杭州上城区白塔公园闲暇散步白塔公园2022.02.22杭州上城区河坊下班和LQ逛街吃饭财物2022.01.27杭州上城区万象城回家过年前一天和LPS溜冰(果然摔倒第一眼先看有没有人发现)2022.01.15上班的日子今天工作不用审账!配小姐姐挖哈根达斯就行!2022.01.09下班的路灯提醒自己忙碌之余多留意生活中的美好2022.01.01杭州西湖区环城西路1号和LYX漫步西湖区2021.12.12杭州西湖区苏堤和LPS游玩西湖区2021.12.09温州洞头列岛半屏大桥上校招的第一份实习工作, 一周就和小伙伴们跑路, 吃住报销纯当旅游了。看到的小伙伴不要去垃圾学校的校招!2021.10.05庐山五老峰第四峰和WJJ彻底游完庐山2021.05.01九江游乐场劳动节和WJJ兼职游乐场(实现了一个童年小愿望)2021.03.29九江濂溪区天花井和WJJ登顶天花井(虽然并不高, 但每次没爬完.)2021.01.13九江浔阳区长江边和ZL在长江的日落2021.01.12庐山含鄱口观景台和ZL在庐山的日出2021.01.05九江濂溪区南山公园YJQ要去参军和我告别, 那天在南山顶聊了很久, 风也很大.2020.12.31学校宿舍元旦了,让PYY给我们整了一副对联,然后班导被领导骂了.(当然我也被骂)2020.08.29我家江边家乡的河边刚修建完过道2019.12.31庐山牯岭镇瓦的音乐餐厅与WJJ、DZQ、YXH去庐山跨年, 我和WJJ赛马冠军一路不过寥寥几笔 2022.08.28进入南昌交通学院.2022.07.03专升本惜败, 选择独立院校.2021.06.25很荣幸有了第二个生日.2019.08.28进入九江职业技术学院2019.06.7复读失败, 选择专科2018.06.7高考失败, 选择复读. div>article>div.tag-plugin.note{margin-top: 0}.bread-nav,.article-title{display:none;}h5{margin-top: 0;padding-top: 0;} document.title = '笔名钟意';"},{"path":"/chat/index.html","content":"友人帐博主动态"},{"title":"钟意的便签","path":"/notes/index.html","content":"便签目录书签开发助手写作Stellar渲染样式代码Ubuntu命令关注StellarCommits"},{"path":"/friends/index.html","content":"document.title = '友人帐'; 友人帐互联网的魅力在于不远千里总能遇到志同道合的你们友人帐博主Q版友人 互关 南山客十织のblog若歆Moeyy's Blog一缕阳光别抢我小鱼干云晓晨Sara一蓑烟雨星日语CAYZLH张洪Heo安知鱼`BlogxaoxuuCIRCUIT雾时之森TomyJan平头哥神邸-Zendee杜老师说carrot·鸿HeiYing’s Blog 擅自订阅 林木木"},{"title":"钟意的友人帐","path":"/links/index.html","content":"友人帐互联网的魅力在于不远千里总能遇到志同道合的你们友人帐博主 互关 南山客HELLO WORLD·BUG征服者十织のblog萌部图片API作者若歆她说:一个群的群友罢了Moeyy's Blog一个小小的博客一缕阳光活得像诗一样别抢我小鱼干鱼干虽香,切勿贪吃云晓晨未来路长 · 勿忘初心Sara生活倒影一蓑烟雨竹杖芒鞋轻胜马,谁怕?一蓑烟雨任平生。星日语星语日点灯CAYZLHCODE IS POETRY张洪Heo一名设计师、产品经理、独立开发者、博主安知鱼`Blog生活明朗,万物可爱xaoxuuStellar主题作者, 当然还有很多其它优秀项目CIRCUIT鸯飞漫冬山雾时之森一个爱折腾爱作死的人建立的无名小站TomyJan一只菜的要死还每天不努力只知道bbll娱乐至死的废柴平头哥平头哥分享社区神邸-Zendee加入神邸,精彩由你!杜老师说杜老师! 传道,授业,解惑!carrot·鸿实践是检验真理的唯一标准。HeiYing’s Blog游龙当归海,海不迎我自来也。 擅自订阅 林木木木木木木木 div>article>div.tag-plugin.note{margin-top: 0}.bread-nav,.article-title{display:none;}h5{margin-top: 0;padding-top: 0;} document.title = '友人帐';"},{"path":"/friends/rss/index.html","content":"document.title = '友人文章'; 天青色等烟雨而我在等你们更新"},{"title":"开发助手","path":"/notes/书签/开发.html","content":"在线IDEAliyunIDEGithubSpacecloudstudio"},{"title":"Ubuntu命令","path":"/notes/代码/Ubuntu.html","content":"常用 查询端口或进程: netstat -ap | grep 端口号/进程名 杀死进程: kill -9 进程号 进程守护: nohup 启动命令 启动进程: systemctl start 进程名 换源 清华大学开源软件镜像站 备份资源: sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak 换源 换源sudo sed -i "s@http://.*archive.ubuntu.com@https://mirrors.tuna.tsinghua.edu.cn@g" /etc/apt/sources.listsudo sed -i "s@http://.*security.ubuntu.com@https://mirrors.tuna.tsinghua.edu.cn@g" /etc/apt/sources.list 更新软件源: sudo apt-get update 升级软件源: sudo apt-get upgrade"},{"title":"Stellar渲染样式","path":"/notes/写作/Stellar样式.html","content":"FrontMatter文章配图文章配图---# 只选cover会在图下方显示title与descriptioncover: /assets/xaoxuu/blog/2020-0927a@1x.svgposter: # 海报模式 全图封面 headline: 大标题 topic: 标题上方的小字 caption: 标题下方的小字 color: 标题颜色 # 可选,默认为跟随主题的动态颜色 # white,red...# 文章页面顶部区域横幅banner: /assets/xaoxuu/blog/2020-0927a@1x.svg--- 内容摘要内容摘要---......---这里是描述,前后要空一行<!-- more -->"},{"title":"夏日花火","path":"/wiki/GalGames/CERO-A/ 夏日花火.html","content":"简介阿巴阿巴"},{"title":"全年龄段","path":"/wiki/GalGames/CERO-A/CERO-A.html","content":"介绍这是一个存档Gal的文档, 博主目前还没建设…"},{"title":"Laf 部署之 k8s","path":"/wiki/Laf/start/laf-k8s-start.html","content":"私密马赛博主是docker编排部署的, k8s改天吧"},{"title":"Laf 部署之 docker-compose","path":"/wiki/Laf/start/laf-docker-compose-start.html","content":"文章部署环境 Ubuntu: 22.04 LTS Docker: 24.0.5 Docker Compose: 2.20.2 Laf: laf-1.0.0-alpha.4 下载解压项目laf-1.0.0-alpha.4最新本版的部署目录已经没有docker-compose.yml, 所以给了我的1.0a测的备注备份版本, 还保留了编排。其实0.8之后就本该去除的。 laf-1.0.0-alpha.4https://kedao.thatcoder.cn/#s/9k9mH0vg laf-1.0.0-beta.0这是官方最后一个有docker-compose.yml的版本 laf-1.0.0-beta.0https://github.com/labring/laf/releases/tag/v1.0.0-beta.0 解压tar -zcvf test.tar.gz 文件名 lafcd /laf/laf-1.0.0-alpha.4/deploy/docker-compose 修改配置/deploy/docker-compose底下有.env配置文件, 按自己情况编辑就行, 我已经备注了参数含义 需要注意的是先不要改APP_SERVICE_DEPLOY_URL_SCHEMA, 因为涉及的域名比较多, 冒然开启https会进不去。 启动服务## 去docker-compose目录cd /laf/laf-1.0.0-alpha.4/deploy/docker-compose## 创建docker网络docker network create laf_shared_network --driver bridge || true## 拉取镜像docker pull lafyun/app-service:latest## 启动所有服务docker-compose up -d 启动所有服务会拉取镜像比较久, 可以先设置docker加速源启动后如果有可视化管理可以看到多了11个容器, 没有就docker ps -a | grep laf 测试服务打开你在.env填的SYS_CLIENT_HOS, 登入账号密码新建一个世界级函数测试一下,写完记得发布。 exports.main = function () { return "hello world!";};"},{"title":"Laf 简介","path":"/wiki/Laf/start/start.html","content":"👀 laf 是什么 laf 是云开发平台,可以快速的开发应用 laf 是一个开源的 BaaS 开发平台(Backend as a Service) laf 是一个开箱即用的 serverless 开发平台 laf 是一个集「函数计算」、「数据库」、「对象存储」等于一身的一站式开发平台 laf 可以是开源版的腾讯云开发、开源版的 Google Firebase、开源版的 UniCloud laf 让每个开发团队都可以随时拥有一个自己的云开发平台! 🎉 laf 有什么 多应用管理,新建、启停应用,无需折腾服务器,一分钟上线应用 云函数,laf 提供的函数计算服务,可以快速的实现后端业务 云数据库,为应用开发提供开箱即用的数据库服务 云存储,为应用开发提供专业的文件对象存储服务,兼容 S3 和其他存储服务接口 WebIDE,在线写代码,完善的类型提示、代码自动完成,像写博客一样写函数,随手发布上线! 静态托管,支持静态网站的托管,可以快速的上线静态网站,无需折腾 nginx Client Db,支持客户端使用 laf-client-sdk“直连”数据库,通过访问策略控制访问权限,极大程度提升应用开发效率 WebSocket,应用支持长连接,业务无死角 👨‍💻 谁适合使用 laf ? 前端开发者 + laf = 全栈开发者,前端秒变全栈,成为真正的大前端 laf 为前端提供了 laf-client-sdk,适用于任何 js运行环境 laf 云函数使用 js/ts 开发,前后端代码无隔裂,无门槛快速上手 laf 提供了静态网站托管,可将前端构建的网页直接同步部署上来,无需再配置服务器、nginx、域名等 laf 后续会提供多种客户端的 SDK(Flutter/Android/iOS 等),为所有客户端开发者提供后端开发服务和一致的开发体验 后端开发者,可以从琐事中解放出来,专注于业务本身,提升开发效率 laf 可以节约服务器运维、多环境部署和管理精力 laf 可以让你告别配置、调试 nginx laf 可以让你告别「为每个项目手动部署数据库、安全顾虑等重复性工作」 laf 可以让你告别「修改一次、发布半天」的重复繁琐的迭代体验 laf 可以让你随时随地在 Web 上查看函数的运行日志,不必再连接服务器,费神费眼翻找 laf 可以让你「像写博客一样写一个函数」,招之即来,挥之即去,随手发布! 云开发用户,若你是微信云开发用户,你不仅可以获得更强大、快速的开发体验,还不被微信云开发平台锁定 你可以为客户提供源码交付,为客户私有部署一套 laf + 你的云开发应用,而使用闭源的云开发服务,无法交付可独立运行的源码 你可以根据未来的需要,随时将自己的产品部署到自己的服务器上,laf 是开源免费的 你甚至可以修改、订制自己的云开发平台,laf 是开源的、高度可扩展的 Node.js 开发者,laf 是使用 Node.js 开发的,你可以把 laf 当成一个更方便的 Node.js 开发平台 or 框架 你可以在线编写、调试函数,不用重启服务,一键发布即可用 你可以在线查看、检索函数调用日志 你可以不必折腾数据库、对象存储、nginx,随时随地让你的应用上线 你可以随手将一段 Node.js 代码上云,比如一段爬虫,一段监控代码,像写博客一样写 Node! 独立开发者、初创创业团队,节约成本,快速开始,专注业务 减少启动项目开发的流程,快速启动,缩短产品验证周期 极大程度提高迭代速度,随时应对变化,随时发布 专注于产品业务本身,快速推出最小可用产品 (MVP),快速进行产品、市场验证 一个人 + laf = 团队 life is short, you need laf:) 💥 laf 能用来做什么 laf 是应用的后端开发平台,理论上可以做任何应用! 使用 laf 快速开发微信小程序/公众号:电商、社交、工具、教育、金融、游戏、短视频、社区、企业等应用! 微信小程序强要求 https 访问,可直接使用 laf.run 创建应用,为小程序提供 https 的接口服务 可将应用的 h5 页面和管理端 (admin) 直接部署到可由 laf 静态托管 将 h5 直接托管到 laf 上,将分配的专用域名配置到公众号即可在线访问 使用云函数实现微信授权、支付等业务 使用云存储存储视频、头像等用户数据 开发 Android or iOS 应用 使用云函数、云数据库、云存储进行业务处理 应用的后端管理 (admin) 直接部署到可由 laf 静态托管 可使用云函数实现微信授权、支付、热更新等业务 部署个人博客、企业官网 将 vuepress / hexo / hugo 等静态生成的博客,一键部署到 laf静态托管上,见 laf-cli 可使用云函数来处理用户留言、评论、访问统计等业务 可使用云函数扩展博客的其它能力,如课程、投票、提问等 可使用云存储存储视频、图片 可使用云函数做爬虫、推送等功能 企业信息化建设:企业私有部署一套 laf 云开发平台 快速开发企业内部信息化系统,可快速上线、修改、迭代,降成本 支持多应用、多账户,不同部门、不同系统,即可隔离,亦可连通 可借助 laf 社区生态,直接使用现存的 laf 应用,开箱即用,降成本 laf 开源免费,没有技术锁定的顾虑,可自由订制和使用 个人开发者的「手边云」 laf 让开发者随手写的一段代码,瞬间具备随手上云的能力 就像在你手机的备忘录随手敲下一段文字,自动同步到云端,且可被全网访问和执行 laf 是每个开发者的“烂笔头”,像记事一样写个函数 laf 是每个开发者的“私人助理”,比如随时可以写一个定时发送短信、邮件通知的函数 其它 有用户把 laf 云存储当网盘使用 有用户把 laf 应用当成一个日志服务器,收集客户端日志数据,使用云函数做分析统计 有用户用 laf 来跑爬虫,抓取三方新闻和咨讯等内容 有用户使用 laf 云函数做 webhook,监听 Git 仓库提交消息,推送到钉钉、企业微信群 有用户使用 laf 云函数做拨测,定时检查线上服务的健康状态 … 🏘️ 社群 论坛 微信群 QQ 群:603059673 官方公众号:laf-dev"},{"title":"Laf 部署之 开启HTTPS/SSL","path":"/wiki/Laf/start/laf-https-start.html","content":"只看解决方法: 点击定位 项目ISSUE方法 1.0之前: 通过修改容器网关openresty、nginx等实现 1.0之后: 新建文件夹 cert, gateway-controller会定时检测证书信息(俺的cert文件夹挂载不进去啊,检查了编排文件与laf/packages/gateway-controller/src/support/apisix-gateway-init.ts 文件输出成功后都没反应) 也参考了下面的ISSUE Laf issue 证书相关https://github.com/labring/laf/issues?q=is%3Aissue+%E8%AF%81%E4%B9%A6 既然方法都没用, 就自己折腾一个方法。 尝试引入代理解决实在折腾不了laf自身https, 我开始琢磨熟悉的nginx反向代理。中途碰了点坑记录一下。 尝试两个域名 A域名: nginx代理给用户https使用 B域名: 给Laf使用 问题: 一次请求后变成用户用B域名通讯 总结: 白给, 我不该这么想当然。 一次请求后变成用户用B域名通讯 尝试一个域名 A域名: nginx代理给用户https使用, 并且与Laf通讯(Laf是8000端口不冲突) 问题: Laf会携带.env的请求方式与端口号, 导致有些请求变成http被nginx拦截, 部分功能不可用 总结: 多巧妙的解决方式, 可惜有内鬼。 部分功能不可用 完善一个域名 总结: 纵观Laf模块功能, 把内鬼干掉, 还好内鬼只有一个文件 很奈斯 解决方法步骤如下: 配置nginx 修改server容器的返回值 配置nginx准备一个nginx, 服务器上的, docker上的都可以。 新建一个和Laf域名一致的网站, 注意Laf用到的域名有四种, 所以四种都要在你的域名列表里, 举例给你参考: 控制台域名: laf.thatcoder.cn 容器通配域名: *.laf.thatcoder.cn OSS域名: oss.laf.thatcoder.cn OSS通配域名: *.oss.laf.thatcoder.cn 其中 oss.laf.thatcoder.cn 与 *.laf.thatcoder.cn 重叠, 所以需要 1、2、4 三个域名的解析与证书所以上面的域名都代理在这个nginx的网站上, 并且需要1、2、4组合证书来开启SSL 开启SSL后配置反向代理, 配置文件如下: 反向代理location ^~ / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_http_version 1.1; proxy_cache_bypass $http_upgrade; # 处理OSS通配 https://*.oss.laf.thatcoder.cn if ($host ~* ^(.*?)\\.oss\\.laf\\.thatcoder\\.cn$) { proxy_pass http://$1.oss.laf.thatcoder.cn:9000/; } # 捕获容器通配 https://*.laf.thatcoder.cn if ($host ~* ^(.*?)\\.laf\\.thatcoder\\.cn$) { set $subdomain $1; } # 处理容器通配 if ($subdomain) { proxy_pass http://$subdomain.laf.thatcoder.cn:8000/; } # 默认发送给主控制台 proxy_pass http://laf.thatcoder.cn:8000/; add_header X-Cache $upstream_cache_status; # 设置Nginx缓存 set $static_filequKUAdsO 0; if ($uri ~* "\\.(gif|png|jpg|css|js|woff|woff2)$") { set $static_filequKUAdsO 1; expires 1m; } if ($static_filequKUAdsO = 0) { add_header Cache-Control no-cache; }} 修改server容器进入容器 lafyun/system-server 找到 /app/dist/handler/application/get.js修改文件最后的数据返回异步方法handleGetApplicationByAppid(), 将整个方法替换为 替换方法async function handleGetApplicationByAppid(req, res) { var _a; const uid = (_a = req['auth']) === null || _a === void 0 ? void 0 : _a.uid; if (!uid) return res.status(401).send(); const appid = req.params.appid; const app = await application_1.getApplicationByAppid(appid); if (!app) return res.status(422).send('invalid appid'); const roles = application_1.getUserGroupsOfApplication(uid, app); if (!roles.length) { return res.status(403).send(); } const permissions = permission_1.getActionsOfRoles(roles); const exp = Math.floor(Date.now() / 1000) + 60 * 60 * config_1.default.TOKEN_EXPIRED_TIME; let debug_token = undefined; if (permissions.includes(actions_1.FunctionActionDef.InvokeFunction)) { debug_token = token_1.getToken({ appid, type: 'debug', exp }, app.config.server_secret_salt); } let export_port = config_1.default.APP_SERVICE_DEPLOY_URL_SCHEMA === 'http' ? config_1.default.PUBLISH_PORT : config_1.default.PUBLISH_HTTPS_PORT; const app_deploy_host = config_1.default.APP_SERVICE_DEPLOY_HOST + ':' + export_port; const app_deploy_url_schema = config_1.default.APP_SERVICE_DEPLOY_URL_SCHEMA; const oss_external_endpoint = config_1.default.MINIO_CONFIG.endpoint.external; const oss_internal_endpoint = config_1.default.MINIO_CONFIG.endpoint.internal; const spec = await application_spec_1.ApplicationSpecSupport.getValidAppSpec(appid); app.config = undefined; return res.send({ data: { application: app, permissions, roles, debug_token, app_deploy_host: thatUrl(app_deploy_host), app_deploy_url_schema: thatUrl(app_deploy_url_schema), oss_external_endpoint: thatUrl(oss_external_endpoint), oss_internal_endpoint: thatUrl(oss_internal_endpoint), spec } });}function thatUrl(originUrl) { let modifiedString = originUrl.replace(/http/g, "https"); modifiedString = modifiedString.replace(/:8000/g, ""); return modifiedString;} 这样返回的值就是https, 也没用端口号, 是正常的https请求。记得重启 lafyun/system-server 容器。改天在编排地方加上挂载, 就能在外面修改了。"},{"title":"SQL CASE 表达式","path":"/wiki/WebWeekly/SQL/SQL CASE 表达式.html","content":"当前期刊数: 234 CASE 表达式分为简单表达式与搜索表达式,其中搜索表达式可以覆盖简单表达式的全部能力,我也建议只写搜索表达式,而不要写简单表达式。 简单表达式: SELECT CASE cityWHEN '北京' THEN 1WHEN '天津' THEN 2ELSE 0END AS abcFROM test 搜索表达式: SELECT CASEWHEN city = '北京' THEN 1WHEN city = '天津' THEN 2ELSE 0END AS abcFROM test 明显可以看出,简单表达式只是搜索表达式 a = b 的特例,因为无法书写任何符号,只要条件换成 a > b 就无法胜任了,而搜索表达式不但可以轻松胜任,甚至可以写聚合函数。 CASE 表达式里的聚合函数为什么 CASE 表达式里可以写聚合函数? 因为本身表达式就支持聚合函数,比如下面的语法,我们不会觉得奇怪: SELECT sum(pv), avg(uv) from test 本身 SQL 就支持多种不同的聚合方式同时计算,所以将其用在 CASE 表达式里,也是顺其自然的: SELECT CASEWHEN count(city) = 100 THEN 1WHEN sum(dau) > 200 THEN 2ELSE 0END AS abcFROM test 只要 SQL 表达式中存在聚合函数,那么整个表达式都聚合了,此时访问非聚合变量没有任何意义。所以上面的例子,即便在 CASE 表达式中使用了聚合,其实也不过是聚合了一次后,按照条件进行判断罢了。 这个特性可以解决很多实际问题,比如将一些复杂聚合判断条件的结果用 SQL 结构输出,那么很可能是下面这种写法: SELECT CASEWHEN 聚合函数(字段) 符合什么条件 THEN xxx... 可能有 N 个ELSE NULLEND AS abcFROM test 这也可以认为是一种行转列的过程,即 把行聚合后的结果通过一条条 CASE 表达式形成一个个新的列。 聚合与非聚合不能混用我们希望利用 CASE 表达式找出那些 pv 大于平均值的行,以下这种想当然的写法是错误的: SELECT CASEWHEN pv > avg(pv) THEN 'yes'ELSE 'no'END AS abcFROM test 原因是,只要 SQL 中存在聚合表达式,那么整条 SQL 就都是聚合的,所以返回的结果只有一条,而我们期望查询结果不聚合,只是判断条件用到了聚合结果,那么就要使用子查询。 为什么子查询可以解决问题?因为子查询的聚合发生在子查询,而不影响当前父查询,理解了这一点,就知道为什么下面的写法才是正确的了: SELECT CASEWHEN pv > ( SELECT avg(pv) from test ) THEN 'yes'ELSE 'no'END AS abcFROM test 这个例子也说明了 CASE 表达式里可以使用子查询,因为子查询是先计算的,所以查询结果在哪儿都能用,CASE 表达式也不例外。 WHERE 中的 CASEWHERE 后面也可以跟 CASE 表达式的,用来做一些需要特殊枚举处理的筛选。 比如下面的例子: SELECT * FROM demo WHERECASEWHEN city = '北京' THEN trueELSE ID > 5END 本来我们要查询 ID 大于 5 的数据,但我想对北京这个城市特别对待,那么就可以在判断条件中再进行 CASE 分支判断。 这个场景在 BI 工具里等价于,创建一个 CASE 表达式字段,可以拖入筛选条件生效。 GROUP BY 中的 CASE想不到吧,GROUP BY 里都可以写 CASE 表达式: SELECT isPower, sum(gdp) FROM test GROUP BY CASEWHEN isPower = 1 THEN city, areaELSE cityEND 上面例子表示,计算 GDP 时,对于非常发达的城市,按照每个区粒度查看聚合结果,也就是看的粒度更细一些,而对于欠发达地区,本身 gdp 也不高,直接按照城市粒度看聚合结果。 这样,就按照不同的条件对数据进行了分组聚合。由于返回行结果是混在一起的,像这个例子,可以根据 isPower 字段是否为 1 判断,是否按照城市、区域进行了聚合,如果没有其他更显著的标识,可能导致无法区分不同行的聚合粒度,因此谨慎使用。 ORDER BY 中的 CASE同样,ORDER BY 使用 CASE 表达式,会将排序结果按照 CASE 分类进行分组,每组按照自己的规则排序,比如: SELECT * FROM test ORDER BY CASEWHEN isPower = 1 THEN gdpELSE peopleEND 上面的例子,对发达地区采用 gdp 排序,否则采用人口数量排序。 总结CASE 表达式总结一下有如下特点: 支持简单与搜索两种写法,推荐搜索写法。 支持聚合与子查询,需要注意不同情况的特点。 可以写在 SQL 查询的几乎任何地方,只要是可以写字段的地方,基本上就可以替换为 CASE 表达式。 除了 SELECT 外,CASE 表达式还广泛应用在 INSERT 与 UPDATE,其中 UPDATE 的妙用是不用将 SQL 拆分为多条,所以不用担心数据变更后对判断条件的二次影响。 讨论地址是:精读《SQL CASE 表达式》· Issue ##404 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"SQL 窗口函数","path":"/wiki/WebWeekly/SQL/SQL 窗口函数.html","content":"当前期刊数: 235 窗口函数形如: 表达式 OVER (PARTITION BY 分组字段 ORDER BY 排序字段) 有两个能力: 当表达式为 rank() dense_rank() row_number() 时,拥有分组排序能力。 当表达式为 sum() 等聚合函数时,拥有累计聚合能力。 无论何种能力,窗口函数都不会影响数据行数,而是将计算平摊在每一行。 这两种能力需要区分理解。 底表 以上是示例底表,共有 8 条数据,城市1、城市2 两个城市,下面各有地区1~4,每条数据都有该数据的人口数。 分组排序如果按照人口排序,ORDER BY people 就行了,但如果我们想在城市内排序怎么办? 此时就要用到窗口函数的分组排序能力: SELECT *, rank() over (PARTITION BY city ORDER BY people) FROM test 该 SQL 表示在 city 组内按照 people 进行排序。 其实 PARTITION BY 也是可选的,如果我们忽略它: SELECT *, rank() over (ORDER BY people) FROM test 也是生效的,但该语句与普通 ORDER BY 等价,因此利用窗口函数进行分组排序时,一般都会使用 PARTITION BY。 各分组排序函数的差异我们将 rank() dense_rank() row_number() 的结果都打印出来: SELECT *, rank() over (PARTITION BY city ORDER BY people),dense_rank() over (PARTITION BY city ORDER BY people),row_number() over (PARTITION BY city ORDER BY people)FROM test 其实从结果就可以猜到,这三个函数在处理排序遇到相同值时,对排名统计逻辑有如下差异: rank(): 值相同时排名相同,但占用排名数字。 dense_rank(): 值相同时排名相同,但不占用排名数字,整体排名更加紧凑。 row_number(): 无论值是否相同,都强制按照行号展示排名。 上面的例子可以优化一下,因为所有窗口逻辑都是相同的,我们可以利用 WINDOW AS 提取为一个变量: SELECT *, rank() over wd, dense_rank() over wd, row_number() over wdFROM testWINDOW wd as (PARTITION BY city ORDER BY people) 累计聚合我们之前说过,凡事使用了聚合函数,都会让查询变成聚合模式。如果不用 GROUP BY,聚合后返回行数会压缩为一行,即使用了 GROUP BY,返回的行数一般也会大大减少,因为分组聚合了。 然而使用窗口函数的聚合却不会导致返回行数减少,那么这种聚合是怎么计算的呢?我们不如直接看下面的例子: SELECT *, sum(people) over (PARTITION BY city ORDER BY people)FROM test 可以看到,在每个 city 分组内,按照 people 排序后进行了 累加(相同的值会合并在一起),这就是 BI 工具一般说的 RUNNGIN_SUM 的实现思路,当然一般我们排序规则使用绝对不会重复的日期,所以不会遇到第一个红框中合并计算的问题。 累计函数还有 avg() min() 等等,这些都一样可以作用于窗口函数,其逻辑可以按照下图理解: 你可能有疑问,直接 sum(上一行结果,下一行) 不是更方便吗?为了验证猜想,我们试试 avg() 的结果: 可见,如果直接利用上一行结果的缓存,那么 avg 结果必然是不准确的,所以窗口累计聚合是每行重新计算的。当然也不排除对于 sum、max、min 做额外性能优化的可能性,但 avg 只能每行重头计算。 与 GROUP BY 组合使用窗口函数是可以与 GROUP BY 组合使用的,遵循的规则是,窗口范围对后面的查询结果生效,所以其实并不关心是否进行了 GROUP BY。我们看下面的例子: 按照地区分组后进行累加聚合,是对 GROUP BY 后的数据行粒度进行的,而不是之前的明细行。 总结窗口函数在计算组内排序或累计 GVM 等场景非常有用,我们只要牢记两个知识点就行了: 分组排序要结合 PARTITION BY 才有意义。 累计聚合作用于查询结果行粒度,支持所有聚合函数。 讨论地址是:精读《SQL 窗口函数》· Issue ##405 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"SQL 入门","path":"/wiki/WebWeekly/SQL/SQL 入门.html","content":"当前期刊数: 231 本系列是 SQL 系列的开篇,介绍一些宏观与基础的内容。 SQL 是什么?SQL 是一种结构化查询语言,用于管理关系型数据库,我们 90% 接触的都是查询语法,但其实它包含完整的增删改查和事物处理功能。 声明式特性SQL 属于声明式编程语言,而现代通用编程语言一般都是命令式的。但是不要盲目崇拜声明式语言,比如说它未来会代替低级的命令式语言,因为声明式本身也有它的缺点,它与命令式语言也有相通的地方。 为什么我们觉得声明式编程语言更高级?因为声明式语言抽象程度更高,比如 select * from table1 仅描述了要从 table1 查询数据,但查询的具体步骤的完全没提,这背后可能存在复杂的索引优化与锁机制,但我们都无需关心,这简直是编程的最高境界。 那为什么现在所有通用业务代码都是命令式呢?因为 命令式给了我们描述具体实现的机会 ,而通用领域的编程正需要建立在严谨的实现细节上。比如校验用户权限这件事,即便 AI 编程提供了将 “登陆用户仅能访问有权限的资源” 转化为代码的能力,我们也不清楚资源具体指哪些,以及在权限转移过程中的资源所有权属于谁。 SQL 之所以能保留声明式特性,完全因为锁定了关系型数据管理这个特定领域,而恰恰对这个领域的需求是标准化且可枚举的,才使声明式成为可能。 基于命令式语言也完全可拓展出声明式能力,比如许多 ORM 提供了类似 select({}).from({}).where({}) 之类的语法,甚至一个 login() 函数也是声明式编程的体现,因为调用者无需关心是如何登陆的,总之调用一下就完成了登陆,这不就是声明式的全部精髓吗? 语法分类作为关系型数据库管理工具,SQL 需要定义、操纵与控制数据。 数据定义即修改数据库与表级别结构,这些是数据结构,或者是数据元信息,它不代表具体数据,但描述数据的属性。 数据操纵即修改一行行具体数据,增删改查。 数据控制即对事务、用户权限的管理与控制。 数据定义DDL(Data Definition Language)数据定义,包括 CREATE DROP ALTER 方法。 数据操纵DML(Data Manipulation Language)数据操纵,包括 SELECT INSERT UPDATE DELETE 方法。 数据控制DCL(Data Control Language)数据控制,包括 COMMIT、ROLLBACK 等。 所有 SQL 操作都围绕这三种类型,其中数据操纵几乎占了 90% 的代码量,毕竟数据查询的诉求远大于写,数据写入对应数据采集,而数据查询对应数据分析,数据分析领域能玩出的花样远比数据采集要多。 PS:有些情况下,会把最重要的 SELECT 提到 DQL(Data Query Language)分类下,这样分类就变成了四个。 集合运算SQL 世界的第一公民是集合,就像 JAVA 世界第一公民是对象。我们只有以集合的视角看待 SQL,才能更好的理解它。 何为集合视角,即所有的查询、操作都是二维数据结构中进行的,而非小学算术里的单个数字间加减乘除关系。 集合的运算一般有 UNION 并集、EXCEPT 差集、INTERSECT 交集,这些都是以行为单位的操作,而各种 JOIN 语句则是以列为单位的集合运算,也是后面提到的连接查询。 只要站在二维数据结构中进行思考,运算无非是横向或纵向的操作。 数据范式数据范式分为五层,每层要求都比上一层更严苛,因此是一个可以逐步遵循的范式。数据范式要求数据越来越解耦,减少冗余。 比如第一范式要求每列都具有原子性,即都是不可分割的最小数据单元。如果数据采集时,某一列作为字符串存储,并且以 “|” 分割表示省市区,那么它就不具有原子性。 当然实际生产过程往往不都遵循这种标准,因为表不是孤立的,在数据处理流中,可能在某个环节再把列原子化,而原始数据为了压缩体积,进行列合并处理。 希望违反范式的还不仅是底层表,现在大数据处理场景下,越来越多的业务采用大宽表结构,甚至故意进行数据冗余以提升查询效率,列存储引擎就是针对这种场景设计的,所以数据范式在大数据场景下是可以变通的,但依然值得学习。 聚合当采用 GROUP BY 分组聚合数据时,如希望针对聚合值筛选,就不能用 WHERE 限定条件了,因为 WHERE 是基于行的筛选,而不是针对组合的。(GROUP BY 对数据进行分组,我们称这些组为 “组合”),所以需要使用针对组合的筛选语句 HAVING: SELECT SUM(pv) FROM tableGROUP BY cityHAVING AVG(uv) > 100 这个例子中,如果 HAVING 换成 WHERE 就没有意义,因为 WHERE 加聚合条件时,需要对所有数据进行合并,不符合当前视图的详细级别。(关于视图详细级别,在我之前写的 精读《什么是 LOD 表达式》 有详细说明)。 聚合如此重要,是因为我们分析数据必须在高 LEVEL 视角看,明细数据是看不出趋势的。而复杂的需求往往伴随着带有聚合的筛选条件,明白 SQL 是如何支持的非常重要。 CASE 表达式CASE 表达式分为简单与搜索 CASE 表达式,简单表达式: SELECT CASE pv WHEN 1 THEN 'low' ELSE 'high' END AS quality 上面的例子利用 CASE 简单表达式形成了一个新字段,这种模式等于生成了业务自定义临时字段,在对当前表进行数据加工时非常有用。搜索 CASE 表达式能力完全覆盖简单 CASE 表达式: SELECT CASE WHEN pv < 100 THEN 'low' ELSE 'high' END AS quality 可以看到,搜索 CASE 表达式可以用 “表达式” 描述条件,可以轻松完成更复杂的任务,甚至可以在表达式里使用子查询、聚合等手段,这些都是高手写 SQL 的惯用技巧,所以 CASE 表达式非常值得深入学习。 复杂查询SELECT 是 SQL 最复杂的部分,其中就包含三种复杂查询模式,分别是连接查询与子查询。 连接查询指 JOIN 查询,比如 LEFT JOIN、RIGHT JOIN、INNER JOIN。 在介绍聚合时我们提到了,连接查询本质上就是对列进行拓展,而两个表之间不会无缘无故合成一个,所以必须有一个外键作为关系纽带: SELECT A.pv, B.uvFROM table1 as t1 LEFT JOIN table2 AS P t2ON t1.productId = t2.productId 连接查询不仅拓展了列,还会随之拓展行,而拓展方式与连接的查询的类型有关。除了连接查询别的表,还可以连接查询自己,比如: SELECT t1.pv AS pv1, P2.pv AS pv2FROM tt t1, tt t2 这种子连接查询结果就是自己对自己的笛卡尔积,可通过 WHERE 筛选去重,后面会有文章专门介绍。 子查询与视图子查询就是 SELECT 里套 SELECT,一般来说 SELECT 会从内到外执行,只有在关联子查询模式下,才会从外到内执行。 而如果把子查询保存下来,就是一个视图,这个视图并不是实体表,所以很灵活,且数据会随着原始表数据而变化: CREATE VIEW countryGDP (country, gdp)ASSELECT country, SUM(gdp)FROM ttGROUP BY country 之后 countryGDP 这个视图就可以作为临时表来用了。 这种模式其实有点违背 SQL 声明式的特点,因为定义视图类似于定义变量,如果继续写下去,势必会形成一定命令式思维逻辑,但这是无法避免的。 事务当 SQL 执行一连串操作时,难免遇到不执行完就会出现脏数据的问题,所以事务可以保证操作的原子性。一般来说每个 DML 操作都是一个内置事务,而 SQL 提供的 START TRANSACTION 就是让我们可以自定义事务范围,使一连串业务操作都可以包装在一起,成为一个原子性操作。 对 SQL 来说,原子性操作是非常安全的,即失败了不会留下任何痕迹,成功了会全部成功,不会存在中间态。 OLAPOLAP(OnLine Analytical Processing)即实时数据分析,是 BI 工具背后计算引擎实现的基础。 现在越来越多的 SQL 数据库支持了窗口函数实现,用于实现业务上的 runningSum 或 runningAvg 等功能,这些都是数据分析中很常见的。 以 runningSum 为例,比如双十一实时表的数据是以分钟为单位的实时 GMV,而我们要做一张累计到当前时间的 GMV 汇总折线图,Y 轴就需要支持 running_sum(GMV) 这样的表达式,而这背后可能就是通过窗口函数实现的。 当然也不是所有业务函数都由 SQL 直接提供,业务层仍需实现大量内存函数,在 JAVA 层计算,这其中一部分是需要下推到 SQL 执行的,只有内存函数与下推函数结合在一起,才能形成我们在 BI 工具看到的复杂计算字段效果。 总结SQL 是一种声明式语言,一个看似简单的查询语句,在引擎层往往对应着复杂的实现,这就是 SQL 为何如此重要却又如此普及的原因。 虽然 SQL 容易上手,但要系统的理解它,还得从结构化数据与集合的概念开始进行思想转变。 不要小看 CASE 语法,它不仅与容易与编程语言的 CASE 语法产生混淆,本身结合表达式进行条件分支判断,是许多数据分析师在日常工作中最长用的套路。 现在使用简单 SQL 创建应用的场景越来越少了,但 BI 场景下,基于 SQL 的增强表达式场景越来越多了,本系列我就是以理解 BI 场景下查询表达式为目标创建的,希望能够学以致用。 讨论地址是:精读《SQL 入门》· Issue ##398 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"SQL 聚合查询","path":"/wiki/WebWeekly/SQL/SQL 聚合查询.html","content":"当前期刊数: 232 SQL 为什么要支持聚合查询呢? 这看上去是个幼稚的问题,但我们还是一步步思考一下。数据以行为粒度存储,最简单的 SQL 语句是 select * from test,拿到的是整个二维表明细,但仅做到这一点远远不够,出于以下两个目的,需要 SQL 提供聚合函数: 明细数据没有统计意义,比如我想知道今天的营业额一共有多少,而不太关心某桌客人消费了多少。 虽然可以先把数据查到内存中再聚合,但在数据量非常大的情况下很容易把内存撑爆,可能一张表一天的数据量就有 10TB,而 10TB 数据就算能读到内存里,聚合计算可能也会慢到难以接受。 另外聚合本身也有一定逻辑复杂度,而 SQL 提供了聚合函数与分组聚合能力,可以方便快速的统计出有业务价值的聚合数据,这奠定了 SQL 语言的分析价值,因此大部分分析软件直接采用 SQL 作为直接面向用户的表达式。 聚合函数常见的聚合函数有: COUNT:计数。 SUM:求和。 AVG:求平均值。 MAX:求最大值。 MIN:求最小值。 COUNTCOUNT 用来计算有多少条数据,比如我们看 id 这一列有多少条: SELECT COUNT(id) FROM test 但我们发现其实查任何一列的 COUNT 都是一样的,那传入 id 有什么意义呢?没必要特殊找一个具体列指代呀,所以也可以写成: SELECT COUNT(*) FROM test 但这两者存在微妙差异。SQL 存在一种很特殊的值类型 NULL,如果 COUNT 指定了具体列,则统计时会跳过此列值为 NULL 的行,而 COUNT(*) 由于未指定具体列,所以就算包含了 NULL,甚至某一行所有列都为 NULL,也都会包含进来。所以 COUNT(*) 查出的结果一定大于等于 COUNT(c1)。 当然任何聚合函数都可以跟随查询条件 WHERE,比如: SELECT COUNT(*) FROM testWHERE is_gray = 1 SUMSUM 求和所有项,因此必须作用于数值字段,而不能用于字符串。 SELECT SUM(cost) FROM test SUM 遇到 NULL 值时当 0 处理,因为这等价于忽略。 AVGAVG 求所有项均值,因此必须作用于数值字段,而不能用于字符串。 SELECT AVG(cost) FROM test AVG 遇到 NULL 值时采用了最彻底的忽略方式,即 NULL 完全不参与分子与分母的计算,就像这一行数据不存在一样。 MAX、MINMAX、MIN 分别求最大与最小值,与上面不同的是,也可以作用于字符串上,因此可以根据字母判断大小,从大到小依次对应 a-z,但即便能算,也没有实际意义且不好理解,因此不建议对字符串求极值。 SELECT MAX(cost) FROM test 多个聚合字段虽然都是聚合函数,但 MAX、MIN 严格意义上不算是聚合函数,因为它们只是寻找了满足条件的行。可以看看下面两段查询结果的对比: SELECT MAX(cost), id FROM test -- id: 100SELECT SUM(cost), id FROM test -- id: 1 第一条查询可以找到最大值那一行的 id,而第二条查询的 id 是无意义的,因为不知道归属在哪一行,所以只返回了第一条数据的 id。 当然,如果同时计算 MAX、MIN,那么此时 id 也只返回第一条数据的值,因为这个查询结果对应了复数行: SELECT MAX(cost), MIN(cost), id FROM test -- id: 1 基于这些特性,最好不要混用聚合与非聚合,也就是一条查询一旦有一个字段是聚合的,那么所有字段都要聚合。 现在很多 BI 引擎的自定义字段都有这条限制,因为混用聚合与非聚合在自定义内存计算时处理起来边界情况很多,虽然 SQL 能支持,但业务自定义的函数可能不支持。 分组聚合分组聚合就是 GROUP BY,其实可以把它当作一种高级的条件语句。 举个例子,查询每个国家的 GDP 总量: SELECT SUM(GDP) FROM amazing_tableGROUP BY country 返回的结果就会按照国家进行分组,这时,聚合函数就变成了在组内聚合。 其实如果我们只想看中、美的 GDP,用非分组也可以查,只是要分成两条 SQL: SELECT SUM(GDP) FROM amazing_tableWHERE country = '中国'SELECT SUM(GDP) FROM amazing_tableWHERE country = '美国' 所以 GROUP BY 也可理解为,将某个字段的所有可枚举的情况都查了出来,并整合成一张表,每一行代表了一种枚举情况,不需要分解为一个个 WHERE 查询了。 多字段分组聚合GROUP BY 可以对多个维度使用,含义等价于表格查询时行/列拖入多个维度。 上面是 BI 查询工具视角,如果没有上下文,可以看下面这个递进描述: 按照多个字段进行分组聚合。 多字段组合起来成为唯一 Key,即 GROUP BY a,b 表示 a,b 合在一起描述一个组。 GROUP BY a,b,c 查询结果第一列可能看到许多重复的 a 行,第二列看到重复 b 行,但在同一个 a 值内不会重复,c 在 b 行中同理。 下面是一个例子: SELECT SUM(GDP) FROM amazing_tableGROUP BY province, city, area 查询结果为: 浙江 杭州 余杭区浙江 杭州 西湖区浙江 宁波 海曙区浙江 宁波 江北区北京 ......... GROUP BY + WHEREWHERE 是根据行进行条件筛选的。因此 GROUP BY + WHERE 并不是在组内做筛选,而是对整体做筛选。 但由于按行筛选,其实组内或非组内结果都完全一样,所以我们几乎无法感知这种差异: SELECT SUM(GDP) FROM amazing_tableGROUP BY province, city, areaWHERE industry = 'internet' 然而,忽略这个差异会导致我们在聚合筛选时碰壁。 比如要筛选出平均分大于 60 学生的成绩总和,如果不使用子查询,是无法在普通查询中在 WHERE 加聚合函数实现的,比如下面就是一个语法错误的例子: SELECT SUM(score) FROM amazing_tableWHERE AVG(score) > 60 不要幻想上面的 SQL 可以执行成功,不要在 WHERE 里使用聚合函数。 GROUP BY + HAVINGHAVING 是根据组进行条件筛选的。因此可以在 HAVING 使用聚合函数: SELECT SUM(score) FROM amazing_tableGROUP BY class_nameHAVING AVG(score) > 60 上面的例子中可以正常查询,表示按照班级分组看总分,且仅筛选出平均分大于 60 的班级。 所以为什么 HAVING 可以使用聚合条件呢?因为 HAVING 筛选的是组,所以可以对组聚合后过滤掉不满足条件的组,这样是有意义的。而 WHERE 是针对行粒度的,聚合后全表就只有一条数据,无论过滤与否都没有意义。 但要注意的是,GROUP BY 生成派生表是无法利用索引筛选的,所以 WHERE 可以利用给字段建立索引优化性能,而 HAVING 针对索引字段不起作用。 总结聚合函数 + 分组可以实现大部分简单 SQL 需求,在写 SQL 表达式时,需要思考这样的表达式是如何计算的,比如 MAX(c1), c2 是合理的,而 SUM(c1), c2 这个 c2 就是无意义的。 最后记住 WHERE 是 GROUP BY 之前执行的,HAVING 针对组进行筛选。 讨论地址是:精读《SQL 聚合查询》· Issue ##401 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Diff, AnyOf, IsUnion","path":"/wiki/WebWeekly/TS 类型体操/《Diff, AnyOf, IsUnion.html","content":"当前期刊数: 247 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 25~32 题。 精读Diff实现 Diff<A, B>,返回一个新对象,类型为两个对象类型的 Diff: type Foo = { name: string age: string}type Bar = { name: string age: string gender: number}Equal<Diff<Foo, Bar> // { gender: number } 首先要思考 Diff 的计算方式,A 与 B 的 Diff 是找到 A 存在 B 不存在,与 B 存在 A 不存在的值,那么正好可以利用 Exclude<X, Y> 函数,它可以得到存在于 X 不存在于 Y 的值,我们只要用 keyof A、keyof B 代替 X 与 Y,并交替 A、B 位置就能得到 Diff: // 本题答案type Diff<A, B> = { [K in Exclude<keyof A, keyof B> | Exclude<keyof B, keyof A>]: K extends keyof A ? A[K] : ( K extends keyof B ? B[K]: never )} Value 部分的小技巧我们之前也提到过,即需要用两套三元运算符保证访问的下标在对象中存在,即 extends keyof 的语法技巧。 AnyOf实现 AnyOf 函数,任意项为真则返回 true,否则返回 false,空数组返回 false: type Sample1 = AnyOf<[1, '', false, [], {}]> // expected to be true.type Sample2 = AnyOf<[0, '', false, [], {}]> // expected to be false. 本题有几个问题要思考: 第一是用何种判定思路?像这种判断数组内任意元素是否满足某个条件的题目,都可以用递归的方式解决,具体是先判断数组第一项,如果满足则继续递归判断剩余项,否则终止判断。这样能做但比较麻烦,还有种取巧的办法是利用 extends Array<> 的方式,让 TS 自动帮你遍历。 第二个是如何判断任意项为真?为真的情况很多,我们尝试枚举为假的 Case:0 undefined '' undefined null never []。 结合上面两个思考,本题作如下解答不难想到: type Falsy = '' | never | undefined | null | 0 | false | []type AnyOf<T extends readonly any[]> = T extends Falsy[] ? false : true 但会遇到这个测试用例没通过: AnyOf<[0, '', false, [], {}]> 如果此时把 {} 补在 Falsy 里,会发现除了这个 case 外,其他判断都挂了,原因是 { a: 1 } extends {} 结果为真,因为 {} 并不表示空对象,而是表示所有对象类型,所以我们要把它换成 Record<PropertyKey, never>,以锁定空对象: // 本题答案type Falsy = '' | never | undefined | null | 0 | false | [] | Record<PropertyKey, never>type AnyOf<T extends readonly any[]> = T extends Falsy[] ? false : true IsNever实现 IsNever 判断值类型是否为 never: type A = IsNever<never> // expected to be truetype B = IsNever<undefined> // expected to be falsetype C = IsNever<null> // expected to be falsetype D = IsNever<[]> // expected to be falsetype E = IsNever<number> // expected to be false 首先我们可以毫不犹豫的写下一个错误答案: type IsNever<T> = T extends never ? true :false 这个错误答案离正确答案肯定是比较近的,但错在无法判断 never 上。在 Permutation 全排列题中我们就认识到了 never 在泛型中的特殊性,它不会触发 extends 判断,而是直接终结,致使判断无效。 而解法也很简单,只要绕过 never 这个特性即可,包一个数组: // 本题答案type IsNever<T> = [T] extends [never] ? true :false IsUnion实现 IsUnion 判断是否为联合类型: type case1 = IsUnion<string> // falsetype case2 = IsUnion<string|number> // truetype case3 = IsUnion<[string|number]> // false 这道题完全是脑筋急转弯了,因为 TS 肯定知道传入类型是否为联合类型,并且会对联合类型进行特殊处理,但并没有暴露联合类型的判断语法,所以我们只能对传入类型进行测试,推断是否为联合类型。 我们到现在能想到联合类型的特征只有两个: 在 TS 处理泛型为联合类型时进行分发处理,即将联合类型拆解为独立项一一进行判定,最后再用 | 连接。 用 [] 包裹联合类型可以规避分发的特性。 所以怎么判定传入泛型是联合类型呢?如果泛型进行了分发,就可以反推出它是联合类型。 难点就转移到了:如何判断泛型被分发了?首先分析一下,分发的效果是什么样: A extends A// 如果 A 是 1 | 2,分发结果是:(1 extends 1 | 2) | (2 extends 1 | 2) 也就是这个表达式会被执行两次,第一个 A 在两次值分别为 1 与 2,而第二个 A 在两次执行中每次都是 1 | 2,但这两个表达式都是 true,无法体现分发的特殊性。 此时要利用包裹 [] 不分发的特性,即在分发后,由于在每次执行过程中,第一个 A 都是联合类型的某一项,因此用 [] 包裹后必然与原始值不相等,所以我们在 extends 分发过程中,再用 [] 包裹 extends 一次,如果此时匹配不上,说明产生了分发: type IsUnion<A> = A extends A ? ( [A] extends [A] ? false : true) : false 但这段代码依然不正确,因为在第一个三元表达式括号内,A 已经被分发,所以 [A] extends [A] 即便对联合类型也是判定为真的,此时需要用原始值代替 extends 后面的 [A],骚操作出现了: type IsUnion<A, B = A> = A extends A ? ( [B] extends [A] ? false : true) : false 虽然我们申明了 B = A,但过程中因为 A 被分发了,所以运行时 B 是不等于 A 的,才使得我们达成目的。[B] 放 extends 前面是因为,B 是未被分发的,不可能被分发后的结果包含,所以分发时此条件必定为假。 最后因为测试用例有一个 never 情况,我们用刚才的 IsNever 函数提前判否即可: // 本题答案type IsUnion<A, B = A> = IsNever<A> extends true ? false : ( A extends A ? ( [B] extends [A] ? false : true ) : false) 从该题我们可以深刻体会到 TS 的怪异之处,即 type X<T> = T extends ... 中 extends 前面的 T 不一定是你看到传入的 T,如果是联合类型的话,会分发为单个类型分别处理。 ReplaceKeys实现 ReplaceKeys<Obj, Keys, Targets> 将 Obj 中每个对象的 Keys Key 类型转化为符合 Targets 对象对应 Key 描述的类型,如果无法匹配到 Targets 则类型置为 never: type NodeA = { type: 'A' name: string flag: number}type NodeB = { type: 'B' id: number flag: number}type NodeC = { type: 'C' name: string flag: number}type Nodes = NodeA | NodeB | NodeCtype ReplacedNodes = ReplaceKeys<Nodes, 'name' | 'flag', {name: number, flag: string}> // {type: 'A', name: number, flag: string} | {type: 'B', id: number, flag: string} | {type: 'C', name: number, flag: string} // would replace name from string to number, replace flag from number to string.type ReplacedNotExistKeys = ReplaceKeys<Nodes, 'name', {aa: number}> // {type: 'A', name: never, flag: number} | NodeB | {type: 'C', name: never, flag: number} // would replace name to never 本题别看描述很吓人,其实非常简单,思路:用 K in keyof Obj 遍历原始对象所有 Key,如果这个 Key 在描述的 Keys 中,且又在 Targets 中存在,则返回类型 Targets[K] 否则返回 never,如果不在描述的 Keys 中则用在对象里本来的类型: // 本题答案type ReplaceKeys<Obj, Keys, Targets> = { [K in keyof Obj] : K extends Keys ? ( K extends keyof Targets ? Targets[K] : never ) : Obj[K]} Remove Index Signature实现 RemoveIndexSignature<T> 把对象 <T> 中 Index 下标移除: type Foo = { [key: string]: any; foo(): void;}type A = RemoveIndexSignature<Foo> // expected { foo(): void } 该题思考的重点是如何将对象字符串 Key 识别出来,可以用 `${infer P}` 是否能识别到 P 来判断当前是否命中了字符串 Key: // 本题答案type RemoveIndexSignature<T> = { [K in keyof T as K extends `${infer P}` ? P : never]: T[K]} Percentage Parser实现 PercentageParser<T>,解析出百分比字符串的符号位与数字: type PString1 = ''type PString2 = '+85%'type PString3 = '-85%'type PString4 = '85%'type PString5 = '85'type R1 = PercentageParser<PString1> // expected ['', '', '']type R2 = PercentageParser<PString2> // expected ["+", "85", "%"]type R3 = PercentageParser<PString3> // expected ["-", "85", "%"]type R4 = PercentageParser<PString4> // expected ["", "85", "%"]type R5 = PercentageParser<PString5> // expected ["", "85", ""] 这道题充分说明了 TS 没有正则能力,尽量还是不要做正则的事情 ^_^。 回到正题,如果非要用 TS 实现,我们只能枚举各种场景: // 本题答案type PercentageParser<A extends string> = // +/-xxx% A extends `${infer X extends '+' | '-'}${infer Y}%`? [X, Y, '%'] : ( // +/-xxx A extends `${infer X extends '+' | '-'}${infer Y}` ? [X, Y, ''] : ( // xxx% A extends `${infer X}%` ? ['', X, '%'] : ( // xxx 包括 ['100', '%', ''] 这三种情况 A extends `${infer X}` ? ['', X, '']: never ) ) ) 这道题运用了 infer 可以无限进行分支判断的知识。 Drop Char实现 DropChar 从字符串中移除指定字符: type Butterfly = DropChar<' b u t t e r f l y ! ', ' '> // 'butterfly!' 这道题和 Replace 很像,只要用递归不断把 C 排除掉即可: // 本题答案type DropChar<S, C extends string> = S extends `${infer A}${C}${infer B}` ? `${A}${DropChar<B, C>}` : S 总结写到这,越发觉得 TS 虽然具备图灵完备性,但在逻辑处理上还是不如 JS 方便,很多设计计算逻辑的题目的解法都不是很优雅。 但是解决这类题目有助于强化对 TS 基础能力组合的理解与综合运用,在解决实际类型问题时又是必不可少的。 讨论地址是:精读《Diff, AnyOf, IsUnion…》· Issue ##429 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"SQL grouping","path":"/wiki/WebWeekly/SQL/SQL grouping.html","content":"当前期刊数: 236 SQL grouping 解决 OLAP 场景总计与小计问题,其语法分为几类,但要解决的是同一个问题: ROLLUP 与 CUBE 是封装了规则的 GROUPING SETS,而 GROUPING SETS 则是最原始的规则。 为了方便理解,让我们从一个问题入手,层层递进吧。 底表 以上是示例底表,共有 8 条数据,城市1、城市2 两个城市,下面各有地区1~4,每条数据都有该数据的人口数。 现在想计算人口总计,以及各城市人口小计。在没有掌握 grouping 语法前,我们只能通过两个 select 语句 union 后得到: SELECT city, sum(people) FROM test GROUP BY cityunionSELECT '合计' as city, sum(people) FROM test 但两条 select 语句聚合了两次,性能是一个不小的开销,因此 SQL 提供了 GROUPING SETS 语法解决这个问题。 GROUPING SETSGROUP BY GROUPING SETS 可以指定任意聚合项,比如我们要同时计算总计与分组合计,就要按照空内容进行 GROUP BY 进行一次 sum,再按照 city 进行 GROUP BY 再进行一次 sum,换成 GROUPING SETS 描述就是: SELECT city, area,sum(people)FROM testGROUP BY GROUPING SETS((), (city, area)) 其中 GROUPING SETS((), (city, area)) 表示分别按照 ()、(city, area) 聚合计算总计。返回结果是: 可以看到,值为 NULL 的行就是我们要的总计,其值是没有任何 GROUP BY 限制算出来的。 类似的,我们还可以写 GROUPING SETS((), (city), (city, area), (area)) 等任意数量、任意组合的 GROUP BY 条件。 通过这种规则计算的数据我们称为 “超级分组记录”。我们发现 “超级分组记录” 产生的 NULL 值很容易和真正的 NULL 值弄混,所以 SQL 提供了 GROUPING 函数解决这个问题。 函数 GROUPING对于超级分组记录产生的 NULL,是可以被 GROUPING() 函数识别为 1 的: SELECT GROUPING(city),GROUPING(area),sum(people)FROM testGROUP BY GROUPING SETS((), (city, area)) 具体效果见下图: 可以看到,但凡是超级分组计算出来的字段都会识别为 1,我们利用之前学习的 SQL CASE 表达式 将其转换为总计、小计字样,就可以得出一张数据分析表了: SELECT CASE WHEN GROUPING(city) = 1 THEN '总计' ELSE city END,CASE WHEN GROUPING(area) = 1 THEN '小计' ELSE area END,sum(people)FROM testGROUP BY GROUPING SETS((), (city, area)) 然后前端表格展示时,将第一行 “总计”、“小计” 单元格合并为 “总计”,就完成了总计这个 BI 可视化分析功能。 ROLLUPROLLUP 是卷起的意思,是一种特定规则的 GROUPING SETS,以下两种写法是等价的: SELECT sum(people) FROM testGROUP BY ROLLUP(city)-- 等价于SELECT sum(people) FROM testGROUP BY GROUPING SETS((), (city)) 再看一组等价描述: SELECT sum(people) FROM testGROUP BY ROLLUP(city, area)-- 等价于SELECT sum(people) FROM testGROUP BY GROUPING SETS((), (city), (city, area)) 发现规律了吗?ROLLUP 会按顺序把 GROUP BY 内容 “一个个卷起来”。用 GROUPING 函数判断超级分组记录对 ROLLUP 同样适用。 CUBECUBE 又有所不同,它对内容进行了所有可能性展开(所以叫 CUBE)。 类比上面的例子,我们再写两组等价的展开: SELECT sum(people) FROM testGROUP BY CUBE(city)-- 等价于SELECT sum(people) FROM testGROUP BY GROUPING SETS((), (city)) 上面的例子因为只有一项还看不出来,下面两项分组就能看出来了: SELECT sum(people) FROM testGROUP BY CUBE(city, area)-- 等价于SELECT sum(people) FROM testGROUP BY GROUPING SETS((), (city), (area), (city, area)) 所谓 CUBE,是一种多维形状的描述,二维时有 2^1 种展开,三维时有 2^2 种展开,四维、五维依此类推。可以想象,如果用 CUBE 描述了很多组合,复杂度会爆炸。 总结学习了 GROUPING 语法,以后前端同学的你不会再纠结这个问题了吧: 产品开启了总计、小计,我们是额外取一次数还是放到一起获取啊? 这个问题的标准答案和原理都在这篇文章里了。PS:对于不支持 GROUPING 语法数据库,要想办法屏蔽,就像前端 polyfill 一样,是一种降级方案。至于如何屏蔽,参考文章开头提到的两个 SELECT + UNION。 讨论地址是:精读《SQL grouping》· Issue ##406 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"SQL 复杂查询","path":"/wiki/WebWeekly/SQL/SQL 复杂查询.html","content":"当前期刊数: 233 SQL 复杂查询指的就是子查询。 为什么子查询叫做复杂查询呢?因为子查询相当于查询嵌套查询,因为嵌套导致复杂度几乎可以被无限放大(无限嵌套),因此叫复杂查询。下面是一个最简单的子查询例子: SELECT pv FROM ( SELECT pv FROM test) 上面的例子等价于 SELECT pv FROM test,但因为把表的位置替换成了一个新查询,所以摇身一变成为了复杂查询!所以复杂查询不一定真的复杂,甚至可能写出和普通查询等价的复杂查询,要避免这种无意义的行为。 我们也要借此机会了解为什么子查询可以这么做。 理解查询的本质当我们查一张表时,数据库认为我们在查什么? 这点很重要,因为下面两个语句都是合法的: SELECT pv FROM testSELECT pv FROM ( SELECT pv FROM test) 为什么数据库可以把子查询当作表呢?为了统一理解这些概念,我们有必要对查询内容进行抽象理解:任意查询位置都是一条或多条记录。 比如 test 这张表,显然是多条记录(当然只有一行就是一条记录),而 SELECT pv FROM test 也是多条记录,然而因为 FROM 后面可以查询任意条数的记录,所以这两种语法都支持。 不仅是 FROM 可以跟单条或多条记录,甚至 SELECT、GROUP BY、WHERE、HAVING 后都可以跟多条记录,这个后面再说。 说到这,也就很好理解子查询的变种了,比如我们可以在子查询内使用 WHERE 或 GROUP BY 等等,因为无论如何,只要查询结果是多条记录就行了: SELECT sum(people) as allPeople, sum(gdp), city FROM ( SELECT people, gdp, city FROM test GROUP BY city HAVING sum(gdp) > 10000) 这个例子就有点业务含义了。子查询是从内而外执行的,因此我们先看内部的逻辑:按照城市分组,筛选出总 GDP 超过一万的所有地区的人口数量明细。外层查询再把人口数加总,这样就能对比每个 GDP 超过一万的地区,总人口和总 GDP 分别是多少,方便对这些重点城市做对比。 不过这个例子看起来还是不太自然,因为我们没必要写成复杂查询,其实简单查询也是等价的: SELECT sum(people) as allPeople, sum(gdp), city FROM testGROUP BY cityHAVING sum(gdp) > 10000 那为什么要多此一举呢?因为复杂查询的真正用法并不在这里。 视图正因为子查询的存在,我们才可能以类似抽取变量的方式,抽取子查询,这个抽取出来的抽象就是视图: CREATE VIEW my_table(people, gdp, city)ASSELECT sum(people) as allPeople, sum(gdp), city FROM testGROUP BY cityHAVING sum(gdp) > 10000SELECT sum(people) as allPeople, sum(gdp), city FROM my_table 这样的好处是,这个视图可以被多条 SQL 语句复用,不仅可维护性变好了,执行时也仅需查询一次。 要注意的是,SELECT 可以使用任何视图,但 INSERT、DELETE、UPDATE 用于视图时,需要视图满足一下条件: 未使用 DISTINCT 去重。 FROM 单表。 未使用 GROUP BY 和 HAVING。 因为上面几种模式都会导致视图成为聚合后的数据,不方便做除了查以外的操作。 另外一个知识点就是物化视图,即使用 MATERIALIZED 描述视图: CREATE MATERIALIZED VIEW my_table(people, gdp, city)AS ... 这种视图会落盘,为什么要支持这个特性呢?因为普通视图作为临时表,无法利用索引等优化手段,查询性能较低,所以物化视图是较为常见的性能优化手段。 说到性能优化手段,还有一些比较常见的理念,即把读的复杂度分摊到写的时候,比如提前聚合新表落盘或者对 CASE 语句固化为字段等,这里先不展开。 标量子查询上面说了,WHERE 也可以跟子查询,比如: SELECT city FROM testWHERE gdp > ( SELECT avg(gdp) from test) 这样可以查询出 gdp 大于平均值的城市。 那为什么不能直接这么写呢? SELECT city FROM testWHERE gdp > avg(gdp) -- 报错,WHERE 无法使用聚合函数 看上去很美好,但其实第一篇我们就介绍了,WHERE 不能跟聚合查询,因为这样会把整个父查询都聚合起来。那为什么子查询可以?因为子查询聚合的是子查询啊,父查询并没有被聚合,所以这才符合我们的意图。 所以上面例子不合适的地方在于,直接在当前查询使用 avg(gdp) 会导致聚合,而我们并不想聚合当前查询,但又要通过聚合拿到平均 GDP,所以就要使用子查询了! 回过头来看,为什么这一节叫标量子查询?标量即单一值,因为 avg(gdp) 聚合出来的只有一个值,所以 WHERE 可以把它当做一个单一数值使用。反之,如果子查询没有使用聚合函数,或 GROUP BY 分组,那么就不能使用 WHERE > 这种语法,但可以使用 WHERE IN,这涉及到单条与多条记录的思考,我们接着看下一节。 单条和多条记录介绍标量子查询时说到了,WHERE > 的值必须时单一值。但其实 WHERE 也可以跟返回多条记录的子查询结果,只要使用合理的条件语句,比如 IN: SELECT area FROM testWHERE gdp IN ( SELECT max(gdp) from test GROUP BY city) 上面的例子,子查询按照城市分组,并找到每一组 GDP 最大的那条记录,所以如果数据粒度是区域,那么我们就查到了每个城市 GDP 最大的那些记录,然后父查询通过 WHERE IN 找到 gdp 符合的复数结果,所以最后就把每个城市最大 gdp 的区域列了出来。 但实际上 WHERE > 语句跟复数查询结果也不会报错,但没有任何意义,所以我们要理解查询结果是单条还是多条,在 WHERE 判断时选择合适的条件。WHERE 适合跟复数查询结果的语法有:WHERE IN、WHERE SOME、WHERE ANY。 关联子查询所谓关联子查询,即父子查询间存在关联,既然如此,子查询肯定不能单独优先执行,毕竟和父查询存在关联嘛,所以关联子查询是先执行外层查询,再执行内层查询的。要注意的是,对每一行父查询,子查询都会执行一次,因此性能不高(当然 SQL 会对相同参数的子查询结果做缓存)。 那这个关联是什么呢?关联的是每一行父查询时,对子查询执行的条件。这么说可能有点绕,举个例子: SELECT * FROM test where gdp > ( select avg(gdp) from test group by city) 对这个例子来说,想要查找 gdp 大于按城市分组的平均 gdp,比如北京地区按北京比较,上海地区按上海比较。但很可惜这样做是不行的,因为父子查询没有关联,SQL 并不知道要按照相同城市比较,因此只要加一个 WHERE 条件,就变成关联子查询了: SELECT * FROM test as t1 where gdp > ( select avg(gdp) from test as t2 where t1.city = t2.city group by city) 就是在每次判断 WHERE gdp > 条件时,重新计算子查询结果,将平均值限定在相同的城市,这样就符合需求了。 总结学会灵活运用父子查询,就掌握了复杂查询了。 SQL 第一公民是集合,所以所谓父子查询就是父子集合的灵活组合,这些集合可以出现在几乎任何位置,根据集合的数量、是否聚合、关联条件,就派生出了标量查询、关联子查询。 更深入的了解就需要大量实战案例了,但万变不离其宗,掌握了复杂查询后,就可以理解大部分 SQL 案例了。 讨论地址是:精读《SQL 复杂查询》· Issue ##403 · ascoders/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Flip, Fibonacci, AllCombinations","path":"/wiki/WebWeekly/TS 类型体操/《Flip, Fibonacci, AllCombinations.html","content":"当前期刊数: 250 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 49~56 题。 精读Flip实现 Flip<T>,将对象 T 中 Key 与 Value 对调: Flip<{ a: "x", b: "y", c: "z" }>; // {x: 'a', y: 'b', z: 'c'}Flip<{ a: 1, b: 2, c: 3 }>; // {1: 'a', 2: 'b', 3: 'c'}Flip<{ a: false, b: true }>; // {false: 'a', true: 'b'} 在 keyof 描述对象时可以通过 as 追加变形,所以这道题应该这样处理: type Flip<T> = { [K in keyof T as T[K]]: K} 由于 Key 位置只能是 String or Number,所以 T[K] 描述 Key 会显示错误,我们需要限定 Value 的类型: type Flip<T extends Record<string, string | number>> = { [K in keyof T as T[K]]: K} 但这个答案无法通过测试用例 Flip<{ pi: 3.14; bool: true }>,原因是 true 不能作为 Key。只能用字符串 'true' 作为 Key,所以我们得强行把 Key 位置转化为字符串: // 本题答案type Flip<T extends Record<string, string | number | boolean>> = { [K in keyof T as `${T[K]}`]: K} Fibonacci Sequence用 TS 实现斐波那契数列计算: type Result1 = Fibonacci<3> // 2type Result2 = Fibonacci<8> // 21 由于测试用例没有特别大的 Case,我们可以放心用递归实现。JS 版的斐波那契非常自然,但 TS 版我们只能用数组长度模拟计算,代码写起来自然会比较扭曲。 首先需要一个额外变量标记递归了多少次,递归到第 N 次结束: type Fibonacci<T extends number, N = [1]> = N['length'] extends T ? ( // xxx) : Fibonacci<T, [...N, 1]> 上面代码每次执行都判断是否递归完成,否则继续递归并把计数器加一。我们还需要一个数组存储答案,一个数组存储上一个数: // 本题答案type Fibonacci< T extends number, N extends number[] = [1], Prev extends number[] = [1], Cur extends number[] = [1]> = N['length'] extends T ? Prev['length'] : Fibonacci<T, [...N, 1], Cur, [...Prev, ...Cur]> 递归时拿 Cur 代替下次的 Prev,用 [...Prev, ...Cur] 代替下次的 Cur,也就是说,下次的 Cur 符合斐波那契定义。 AllCombinations实现 AllCombinations<S> 对字符串 S 全排列: type AllCombinations_ABC = AllCombinations<'ABC'>// should be '' | 'A' | 'B' | 'C' | 'AB' | 'AC' | 'BA' | 'BC' | 'CA' | 'CB' | 'ABC' | 'ACB' | 'BAC' | 'BCA' | 'CAB' | 'CBA' 首先要把 ABC 字符串拆成一个个独立的联合类型,进行二次组合才可能完成全排列: type StrToUnion<S> = S extends `${infer F}${infer R}` ? F | StrToUnion<R> : never infer 描述字符串时,第一个指向第一个字母,第二个指向剩余字母;对剩余字符串递归可以将其逐一拆解为单个字符并用 | 连接: StrToUnion<'ABC'> // 'A' | 'B' | 'C' 将 StrToUnion<'ABC'> 的结果记为 U,则利用对象转联合类型特征,可以制造出 ABC 在三个字母时的全排列: { [K in U]: `${K}${AllCombinations<never, Exclude<U, K>>}` }[U] // `ABC${any}` | `ACB${any}` | `BAC${any}` | `BCA${any}` | `CAB${any}` | `CBA${any}` 然而只要在每次递归时巧妙的加上 '' | 就可以直接得到答案了: type AllCombinations<S extends string, U extends string = StrToUnion<S>> = | '' | { [K in U]: `${K}${AllCombinations<never, Exclude<U, K>>}` }[U] // '' | 'A' | 'B' | 'C' | 'AB' | 'AC' | 'BA' | 'BC' | 'CA' | 'CB' | 'ABC' | 'ACB' | 'BAC' | 'BCA' | 'CAB' | 'CBA' 为什么这么神奇呢?这是因为每次递归时都会经历 ''、'A'、'AB'、'ABC' 这样逐渐累加字符的过程,而每次都会遇到 '' | 使其自然形成了联合类型,比如遇到 'A' 时,会自然形成 'A' 这项联合类型,同时继续用 'A' 与 Exclude<'A' | 'B' | 'C', 'A'> 进行组合。 更精妙的是,第一次执行时的 '' 填补了全排列的第一个 Case。 最后注意到上面的结果产生了一个 Error:”Type instantiation is excessively deep and possibly infinite”,即这样递归可能产生死循环,因为 Exclude<U, K> 的结果可能是 never,所以最后在开头修补一下对 never 的判否,利用之前学习的知识,never 不会进行联合类型展开,所以我们用 [never] 判断来规避: // 本题答案type AllCombinations<S extends string, U extends string = StrToUnion<S>> = [ U] extends [never] ? '' : '' | { [K in U]: `${K}${AllCombinations<never, Exclude<U, K>>}` }[U] Greater Than实现 GreaterThan<T, U> 判断 T > U: GreaterThan<2, 1> //should be trueGreaterThan<1, 1> //should be falseGreaterThan<10, 100> //should be falseGreaterThan<111, 11> //should be true 因为 TS 不支持加减法与大小判断,看到这道题时就应该想到有两种做法,一种是递归,但会受限于入参数量限制,可能堆栈溢出,一种是参考 MinusOne 的特殊方法,用巧妙的方式构造出长度符合预期的数组,用数组 ['length'] 进行比较。 先说第一种,递归肯定要有一个递增 Key,拿 T U 先后进行对比,谁先追上这个数,谁就是较小的那个: // 本题答案type GreaterThan<T, U, R extends number[] = []> = T extends R['length'] ? false : U extends R['length'] ? true : GreaterThan<T, U, [...R, 1]> 另一种做法是快速构造两个长度分别等于 T U 的数组,用数组快速判断谁更长。构造方式不再展开,参考 MinusOne 那篇的方法即可,重点说下如何快速判断 [1, 1] 与 [1, 1, 1] 谁更大。 因为 TS 没有大小判断能力,所以拿到了 ['length'] 也没有用,我们得考虑 arr1 extends arr2 这种方式。可惜的是,长度不相等的数组,extends 永远等于 false: [1,1,1,1] extends [1,1,1] ? true : false // false[1,1,1] extends [1,1,1,1] ? true : false // false[1,1,1] extends [1,1,1] ? true : false // true 但我们期望进行如下判断: ArrGreaterThan<[1,1,1,1],[1,1,1]> // trueArrGreaterThan<[1,1,1],[1,1,1,1]> // falseArrGreaterThan<[1,1,1],[1,1,1]> // false 解决方法非常体现 TS 思维:既然俩数组相等才返回 true,那我们用 [...T, ...any] 进行补充判定,如果能判定为 true,就说明前者长度更短(因为后者补充几项后可以判等): type ArrGreaterThan<T extends 1[], U extends 1[]> = U extends [...T, ...any] ? false : true 这样一来,第二种答案就是这样的: // 本题答案type GreaterThan<T extends number, U extends number> = ArrGreaterThan< NumberToArr<T>, NumberToArr<U>> Zip实现 TS 版 Zip 函数: type exp = Zip<[1, 2], [true, false]> // expected to be [[1, true], [2, false]] 此题同样配合辅助变量,进行计数递归,并额外用一个类型变量存储结果: // 本题答案type Zip< T extends any[], U extends any[], I extends number[] = [], R extends any[] = []> = I['length'] extends T['length'] ? R : U[I['length']] extends undefined ? Zip<T, U, [...I, 0], R> : Zip<T, U, [...I, 0], [...R, [T[I['length']], U[I['length']]]]> [...R, [T[I['length']], U[I['length']]]] 在每次递归时按照 Zip 规则添加一条结果,其中 I['length'] 起到的作用类似 for 循环的下标 i,只是在 TS 语法中,我们只能用数组的方式模拟这种计数。 IsTuple实现 IsTuple<T> 判断 T 是否为元组类型(Tuple): type case1 = IsTuple<[number]> // truetype case2 = IsTuple<readonly [number]> // truetype case3 = IsTuple<number[]> // false 不得不吐槽的是,无论是 TS 内部或者词法解析都是更有效的判断方式,但如果用 TS 来实现,就要换一种思路了。 Tuple 与 Array 在 TS 里的区别是前者长度有限,后者长度无限,从结果来看,如果访问其 ['length'] 属性,前者一定是一个固定数字,而后者返回 number,用这个特性判断即可: // 本题答案type IsTuple<T> = [T] extends [never] ? false : T extends readonly any[] ? number extends T['length'] ? false : true : false 其实这个答案是根据单测一点点试出来的,因为存在 IsTuple<{ length: 1 }> 单测用例,它可以通过 number extends T['length'] 的校验,但因为其本身不是数组类型,所以无法通过 T extends readonly any[] 的前置判断。 Chunk实现 TS 版 Chunk: type exp1 = Chunk<[1, 2, 3], 2> // expected to be [[1, 2], [3]]type exp2 = Chunk<[1, 2, 3], 4> // expected to be [[1, 2, 3]]type exp3 = Chunk<[1, 2, 3], 1> // expected to be [[1], [2], [3]] 老办法还是要递归,需要一个变量记录当前收集到 Chunk 里的内容,在 Chunk 达到上限时释放出来,同时也要注意未达到上限就结束时也要释放出来。 type Chunk< T extends any[], N extends number = 1, Chunked extends any[] = []> = T extends [infer First, ...infer Last] ? Chunked['length'] extends N ? [Chunked, ...Chunk<T, N>] : Chunk<Last, N, [...Chunked, First]> : [Chunked] Chunked['length'] extends N 判断 Chunked 数组长度达到 N 后就释放出来,否则把当前数组第一项 First 继续塞到 Chunked 数组,数组项从 Last 开始继续递归。 我们发现 Chunk<[], 1> 这个单测没过,因为当 Chunked 没有项目时,就无需成组了,所以完整的答案是: // 本题答案type Chunk< T extends any[], N extends number = 1, Chunked extends any[] = []> = T extends [infer Head, ...infer Tail] ? Chunked['length'] extends N ? [Chunked, ...Chunk<T, N>] : Chunk<Tail, N, [...Chunked, Head]> : Chunked extends [] ? Chunked : [Chunked] Fill实现 Fill<T, N, Start?, End?>,将数组 T 的每一项替换为 N: type exp = Fill<[1, 2, 3], 0> // expected to be [0, 0, 0] 这道题也需要用递归 + Flag 方式解决,即定义一个 I 表示当前递归的下标,一个 Flag 表示是否到了要替换的下标,只要到了这个下标,该 Flag 就永远为 true: type Fill< T extends unknown[], N, Start extends number = 0, End extends number = T['length'], I extends any[] = [], Flag extends boolean = I['length'] extends Start ? true : false> 由于递归会不断生成完整答案,我们将 T 定义为可变的,即每次仅处理第一条,如果当前 Flag 为 true 就采用替换值 N,否则就拿原本的第一个字符: type Fill< T extends unknown[], N, Start extends number = 0, End extends number = T['length'], I extends any[] = [], Flag extends boolean = I['length'] extends Start ? true : false> = I['length'] extends End ? T : T extends [infer F, ...infer R] ? Flag extends false ? [F, ...Fill<R, N, Start, End, [...I, 0]>] : [N, ...Fill<R, N, Start, End, [...I, 0]>] : T 但这个答案没有通过测试,仔细想想发现 Flag 在 I 长度超过 Start 后就判定失败了,为了让超过后维持 true,在 Flag 为 true 时将其传入覆盖后续值即可: // 本题答案type Fill< T extends unknown[], N, Start extends number = 0, End extends number = T['length'], I extends any[] = [], Flag extends boolean = I['length'] extends Start ? true : false> = I['length'] extends End ? T : T extends [infer F, ...infer R] ? Flag extends false ? [F, ...Fill<R, N, Start, End, [...I, 0]>] : [N, ...Fill<R, N, Start, End, [...I, 0], Flag>] : T 总结勤用递归、辅助变量可以解决大部分本周遇到的问题。 讨论地址是:精读《Flip, Fibonacci, AllCombinations…》· Issue ##432 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Get return type, Omit, ReadOnly","path":"/wiki/WebWeekly/TS 类型体操/《Get return type, Omit, ReadOnly.html","content":"当前期刊数: 244 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 1~8 题。 精读Get Return Type实现非常经典的 ReturnType<T>: const fn = (v: boolean) => { if (v) return 1 else return 2}type a = MyReturnType<typeof fn> // should be "1 | 2" 首先不要被例子吓到了,觉得必须执行完代码才知道返回类型,其实 TS 已经帮我们推导好了返回类型,所以上面的函数 fn 的类型已经是这样了: const fn = (v: boolean): 1 | 2 => { ... } 我们要做的就是把函数返回值从内部抽出来,这非常适合用 infer 实现: // 本题答案type MyReturnType<T> = T extends (...args: any[]) => infer P ? P : never infer 配合 extends 是解构复杂类型的神器,如果对上面代码不能一眼理解,说明对 infer 熟悉度还是不够,需要多看。 Omit实现 Omit<T, K>,作用恰好与 Pick<T, K> 相反,排除对象 T 中的 K key: interface Todo { title: string description: string completed: boolean}type TodoPreview = MyOmit<Todo, 'description' | 'title'>const todo: TodoPreview = { completed: false,} 这道题比较容易尝试的方案是: type MyOmit<T, K extends keyof T> = { [P in keyof T]: P extends K ? never : T[P]} 其实仍然包含了 description、title 这两个 Key,只是这两个 Key 类型为 never,不符合要求。 所以只要 P in keyof T 写出来了,后面怎么写都无法将这个 Key 抹去,我们应该从 Key 下手: type MyOmit<T, K extends keyof T> = { [P in (keyof T extends K ? never : keyof T)]: T[P]} 但这样写仍然不对,我们思路正确,即把 keyof T 中归属于 K 的排除,但因为前后 keyof T 并没有关联,所以需要借助 Exclude 告诉 TS,前后 keyof T 是同一个指代(上一讲实现过 Exclude): // 本题答案type MyOmit<T, K extends keyof T> = { [P in Exclude<keyof T, K>]: T[P]}type Exclude<T, U> = T extends U ? never : T 这样就正确了,掌握该题的核心是: 三元判断还可以写在 Key 位置。 JS 抽不抽函数效果都一样,但 TS 需要推断,很多时候抽一个函数出来就是为了告诉 TS “是同一指代”。 当然既然都用上了 Exclude,我们不如再结合 Pick,写出更优雅的 Omit 实现: // 本题优雅答案type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> Readonly 2实现 MyReadonly2<T, K>,让指定的 Key K 成为 ReadOnly: interface Todo { title: string description: string completed: boolean}const todo: MyReadonly2<Todo, 'title' | 'description'> = { title: "Hey", description: "foobar", completed: false,}todo.title = "Hello" // Error: cannot reassign a readonly propertytodo.description = "barFoo" // Error: cannot reassign a readonly propertytodo.completed = true // OK 该题乍一看蛮难的,因为 readonly 必须定义在 Key 位置,但我们又没法在这个位置做三元判断。其实利用之前我们自己做的 Pick、Omit 以及内置的 Readonly 组合一下就出来了: // 本题答案type MyReadonly2<T, K extends keyof T> = Readonly<Pick<T, K>> & Omit<T, K> 即我们可以将对象一分为二,先 Pick 出 K Key 部分设置为 Readonly,再用 & 合并上剩下的 Key,正好用到上一题的函数 Omit,完美。 Deep Readonly实现 DeepReadonly<T> 递归所有子元素: type X = { x: { a: 1 b: 'hi' } y: 'hey'}type Expected = { readonly x: { readonly a: 1 readonly b: 'hi' } readonly y: 'hey' }type Todo = DeepReadonly<X> // should be same as `Expected` 这肯定需要用类型递归实现了,既然要递归,肯定不能依赖内置 Readonly 函数,我们需要将函数展开手写: // 本题答案type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends Object> ? DeepReadonly<T[K]> : T[K]} 这里 Object 也可以用 Record<string, any> 代替。 Tuple to Union实现 TupleToUnion<T> 返回元组所有值的集合: type Arr = ['1', '2', '3']type Test = TupleToUnion<Arr> // expected to be '1' | '2' | '3' 该题将元组类型转换为其所有值的可能集合,也就是我们希望用所有下标访问这个数组,在 TS 里用 [number] 作为下标即可: // 本题答案type TupleToUnion<T extends any[]> = T[number] Chainable Options直接看例子比较好懂: declare const config: Chainableconst result = config .option('foo', 123) .option('name', 'type-challenges') .option('bar', { value: 'Hello World' }) .get()// expect the type of result to be:interface Result { foo: number name: string bar: { value: string }} 也就是我们实现一个相对复杂的 Chainable 类型,拥有该类型的对象可以 .option(key, value) 一直链式调用下去,直到使用 get() 后拿到聚合了所有 option 的对象。 如果我们用 JS 实现该函数,肯定需要在当前闭包存储 Object 的值,然后提供 get 直接返回,或 option 递归并传入新的值。我们不妨用 Class 来实现: class Chain { constructor(previous = {}) { this.obj = { ...previous } } obj: Object get () { return this.obj } option(key: string, value: any) { return new Chain({ ...this.obj, [key]: value }) }}const config = new Chain() 而本地要求用 TS 实现,这就比较有趣了,正好对比一下 JS 与 TS 的思维。先打个岔,该题用上面 JS 方式写出来后,其实类型也就出来了,但用 TS 完整实现类型也另有其用,特别在一些复杂函数场景,需要用 TS 系统描述类型,JS 真正实现时拿到 any 类型做纯运行时处理,将类型与运行时分离开。 好我们回到题目,我们先把 Chainable 的框架写出来: type Chainable = { option: (key: string, value: any) => any get: () => any} 问题来了,如何用类型描述 option 后还可以接 option 或 get 呢?还有更麻烦的,如何一步一步将类型传导下去,让 get 知道我此时拿的类型是什么呢? Chainable 必须接收一个泛型,这个泛型默认值是个空对象,所以 config.get() 返回一个空对象也是合理的: type Chainable<Result = {}> = { option: (key: string, value: any) => any get: () => Result} 上面的代码对于第一层是完全没问题的,直接调用 get 返回的就是空对象。 第二步解决递归问题: // 本题答案type Chainable<Result = {}> = { option: <K extends string, V>(key: K, value: V) => Chainable<Result & { [P in K]: V }> get: () => Result} 递归思维大家都懂就不赘述了。这里有个看似不值得一提,但确实容易坑人的地方,就是如何描述一个对象仅包含一个 Key 值,这个值为泛型 K 呢? // 这是错的,因为描述了一大堆类型{ [K] : V}// 这也是错的,这个 K 就是字面量 K,而非你希望的类型指代{ K: V} 所以必须使用 TS “习惯法” 的 [K in keyof T] 的套路描述,即便我们知道 T 只有一个固定的类型。可见 JS 与 TS 完全是两套思维方式,所以精通 JS 不必然精通 TS,TS 还是要大量刷题培养思维的。 Last of Array实现 Last<T> 获取元组最后一项的类型: type arr1 = ['a', 'b', 'c']type arr2 = [3, 2, 1]type tail1 = Last<arr1> // expected to be 'c'type tail2 = Last<arr2> // expected to be 1 我们之前实现过 First,类似的,这里无非是解构时把最后一个描述成 infer: // 本题答案type Last<T> = T extends [...infer Q, infer P] ? P : never 这里要注意,infer Q 有人第一次可能会写成: type Last<T> = T extends [...Others, infer P] ? P : never 发现报错,因为 TS 里不可能随便使用一个未定义的泛型,而如果把 Others 放在 Last<T, Others> 里,你又会面临一个 TS 大难题: type Last<T, Others extends any[]> = T extends [...Others, infer P] ? P : never// 必然报错Last<arr2> 因为 Last<arr2> 仅传入了一个参数,必然报错,但第一个参数是用户给的,第二个参数是我们推导出来的,这里既不能用默认值,又不能不写,无解了。 如果真的硬着头皮要这么写,必须借助 TS 还未通过的一项特性:部分类型参数推断,举个例子,很可能以后的语法是: type Last<T, Others extends any[] = infer> = T extends [...Others, infer P] ? P : never 这样首先传参只需要一个了,而且还申明了第二个参数是一个推断类型。不过该提案还未支持,而且本质上和把 infer 写到表达式里面含义和效果也都一样,所以对这道题来说就不用折腾了。 Pop实现 Pop<T>,返回去掉元组最后一项之后的类型: type arr1 = ['a', 'b', 'c', 'd']type arr2 = [3, 2, 1]type re1 = Pop<arr1> // expected to be ['a', 'b', 'c']type re2 = Pop<arr2> // expected to be [3, 2] 这道题和 Last 几乎完全一样,返回第一个解构值就行了: // 本题答案type Pop<T> = T extends [...infer Q, infer P] ? Q : never 总结从题目中很明显能看出 TS 思维与 JS 思维有很大差异,想要真正掌握 TS,大量刷题是必须的。 讨论地址是:精读《Get return type, Omit, ReadOnly…》· Issue ##422 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《MinusOne, PickByType, StartsWith","path":"/wiki/WebWeekly/TS 类型体操/《MinusOne, PickByType, StartsWith.html","content":"当前期刊数: 248 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 33~40 题。 精读MinusOne用 TS 实现 MinusOne 将一个数字减一: type Zero = MinusOne<1> // 0type FiftyFour = MinusOne<55> // 54 TS 没有 “普通” 的运算能力,但涉及数字却有一条生路,即 TS 可通过 ['length'] 访问数组长度,几乎所有数字计算都是通过它推导出来的。 这道题,我们只要构造一个长度为泛型长度 -1 的数组,获取其 ['length'] 属性即可,但该方案有一个硬伤,无法计算负值,因为数组长度不可能小于 0: // 本题答案type MinusOne<T extends number, arr extends any[] = []> = [ ...arr, '']['length'] extends T ? arr['length'] : MinusOne<T, [...arr, '']> 该方案的原理不是原数字 -1,而是从 0 开始不断加 1,一直加到目标数字减一。但该方案没有通过 MinusOne<1101> 测试,因为递归 1000 次就是上限了。 还有一种能打破递归的思路,即: type Count = ['1', '1', '1'] extends [...infer T, '1'] ? T['length'] : 0 // 2 也就是把减一转化为 extends [...infer T, '1'],这样数组 T 的长度刚好等于答案。那么难点就变成了如何根据传入的数字构造一个等长的数组?即问题变成了如何实现 CountTo<N> 生成一个长度为 N,每项均为 1 的数组,而且生成数组的递归效率也要高,否则还会遇到递归上限的问题。 网上有一个神仙解法,笔者自己想不到,但是可以拿出来给大家分析下: type CountTo< T extends string, Count extends 1[] = []> = T extends `${infer First}${infer Rest}` ? CountTo<Rest, N<Count>[keyof N & First]> : Counttype N<T extends 1[] = []> = { '0': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T] '1': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1] '2': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1] '3': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1] '4': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1] '5': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1 ] '6': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1 ] '7': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1, 1 ] '8': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1, 1, 1 ] '9': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1, 1, 1, 1 ]} 也就是该方法可以高效的实现 CountTo<'1000'> 产生长度为 1000,每项为 1 的数组,更具体一点,只需要遍历 <T> 字符串长度次数,比如 1000 只要递归 4 次,而 10000 也只需要递归 5 次。 CountTo 函数体的逻辑是,如果字符串 T 非空,就拆为第一个字符 First 与剩余字符 Rest,然后拿剩余字符递归,但是把 First 一次性生成到了正确的长度。最核心的逻辑就是函数 N<T> 了,它做的其实是把 T 的数组长度放大 10 倍再追加上当前数量的 1 在数组末尾。 而 keyof N & First 也是神来之笔,此处本意就是访问 First 下标,但 TS 不知道它是一个安全可访问的下标,而 keyof N & First 最终值还是 First,也可以被 TS 安全识别为下标。 拿 CountTo<'123'> 举例: 第一次执行 First='1'、Rest='23': CountTo<'23', N<[]>['1']>// 展开时,...[] 还是 [],所以最终结果为 ['1'] 第二次执行 First='2'、Rest='3' CountTo<'3', N<['1']>['2']>// 展开时,...[] 有 10 个,所以 ['1'] 变成了 10 个 1,追加上 N 映射表里的 2 个 1,现在一共有 12 个 1 第三次执行 First='3'、Rest='' CountTo<'', N<['1', ...共 12 个]>['3']>// 展开时,...[] 有 10 个,所以 12 个 1 变成 120 个,加上映射表中 3,一共有 123 个 1 总结一下,就是将数字 T 变成字符串,从最左侧开始获取,每次都把已经积累的数组数量乘以 10 再追加上当前值数量的 1,实现递归次数极大降低。 PickByType实现 PickByType<P, Q>,将对象 P 中类型为 Q 的 key 保留: type OnlyBoolean = PickByType< { name: string count: number isReadonly: boolean isEnable: boolean }, boolean> // { isReadonly: boolean; isEnable: boolean; } 本题很简单,因为之前碰到 Remove Index Signature 题目时,我们用了 K in keyof P as xxx 来对 Key 位置进行进一步判断,所以只要 P[K] extends Q 就保留,否则返回 never 即可: // 本题答案type PickByType<P, Q> = { [K in keyof P as P[K] extends Q ? K : never]: P[K]} StartsWith实现 StartsWith<T, U> 判断字符串 T 是否以 U 开头: type a = StartsWith<'abc', 'ac'> // expected to be falsetype b = StartsWith<'abc', 'ab'> // expected to be truetype c = StartsWith<'abc', 'abcd'> // expected to be false 本题也比较简单,用递归 + 首字符判等即可破解: // 本题答案type StartsWith< T extends string, U extends string> = U extends `${infer US}${infer UE}` ? T extends `${infer TS}${infer TE}` ? TS extends US ? StartsWith<TE, UE> : false : false : true 思路是: U 如果为空字符串则匹配一切场景,直接返回 true;否则 U 可以拆为以 US(U Start) 开头、UE(U End) 的字符串进行后续判定。 接着上面的判定,如果 T 为空字符串则不可能被 U 匹配,直接返回 false;否则 T 可以拆为以 TS(T Start) 开头、TE(T End) 的字符串进行后续判定。 接着上面的判定,如果 TS extends US 说明此次首字符匹配了,则递归匹配剩余字符 StartsWith<TE, UE>,如果首字符不匹配提前返回 false。 笔者看了一些答案后发现还有一种降维打击方案: // 本题答案type StartsWith<T extends string, U extends string> = T extends `${U}${string}` ? true : false 没想到还可以用 ${string} 匹配任意字符串进行 extends 判定,有点正则的意思了。当然 ${string} 也可以被 ${infer X} 代替,只是拿到的 X 不需要再用到了: // 本题答案type StartsWith<T extends string, U extends string> = T extends `${U}${infer X}` ? true : false 笔者还试了下面的答案在后缀 Diff 部分为 string like number 时也正确: // 本题答案type StartsWith<T extends string, U extends string> = T extends `${U}${number}` ? true : false 说明字符串模板最通用的指代是 ${infer X} 或 ${string},如果要匹配特定的数字类字符串也可以混用 ${number}。 EndsWith实现 EndsWith<T, U> 判断字符串 T 是否以 U 结尾: type a = EndsWith<'abc', 'bc'> // expected to be truetype b = EndsWith<'abc', 'abc'> // expected to be truetype c = EndsWith<'abc', 'd'> // expected to be false 有了上题的经验,这道题不要太简单: // 本题答案type EndsWith<T extends string, U extends string> = T extends `${string}${U}` ? true : false 这可以看出 TS 的技巧掌握了就非常简单,但不知道就几乎无解,或者用很笨的递归来解决。 PartialByKeys实现 PartialByKeys<T, K>,使 K 匹配的 Key 变成可选的定义,如果不传 K 效果与 Partial<T> 一样: interface User { name: string age: number address: string}type UserPartialName = PartialByKeys<User, 'name'> // { name?:string; age:number; address:string } 看到题目要求是不传参数时和 Partial<T> 行为一直,就应该能想到应该这么起头写个默认值: type PartialByKeys<T, K = keyof T> = {} 我们得用可选与不可选分别描述两个对象拼起来,因为 TS 不支持同一个对象下用两个 keyof 描述,所以只能写成两个对象: type PartialByKeys<T, K = keyof T> = { [Q in keyof T as Q extends K ? Q : never]?: T[Q]} & { [Q in keyof T as Q extends K ? never : Q]: T[Q]} 但不匹配测试用例,原因是最终类型正确,但因为分成了两个对象合并无法匹配成一个对象,所以需要用一点点 Magic 行为合并: // 本题答案type PartialByKeys<T, K = keyof T> = { [Q in keyof T as Q extends K ? Q : never]?: T[Q]} & { [Q in keyof T as Q extends K ? never : Q]: T[Q]} extends infer R ? { [Q in keyof R]: R[Q] } : never 将一个对象 extends infer R 再重新展开一遍看似无意义,但确实让类型上合并成了一个对象,很有意思。我们也可以将其抽成一个函数 Merge<T> 来使用。 本题还有一个函数组合的答案: // 本题答案type Merge<T> = { [K in keyof T]: T[K]}type PartialByKeys<T, K extends PropertyKey = keyof T> = Merge< Partial<T> & Omit<T, K>> 利用 Partial & Omit 来合并对象。 因为 Omit<T, K> 中 K 有来自于 keyof T 的限制,而测试用例又包含 unknown 这种不存在的 Key 值,此时可以用 extends PropertyKey 处理此场景。 RequiredByKeys实现 RequiredByKeys<T, K>,使 K 匹配的 Key 变成必选的定义,如果不传 K 效果与 Required<T> 一样: interface User { name?: string age?: number address?: string}type UserRequiredName = RequiredByKeys<User, 'name'> // { name: string; age?: number; address?: string } 和上题正好相反,答案也呼之欲出了: type Merge<T> = { [K in keyof T]: T[K]}type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge< Required<T> & Omit<T, K>> 等等,一个测试用例都没过,为啥呢?仔细想想发现确实暗藏玄机: Merge<{ a: number} & { a?: number}> // 结果是 { a: number } 也就是同一个 Key 可选与必选同时存在时,合并结果是必选。上一题因为将必选 Omit 掉了,所以可选不会被必选覆盖,但本题 Merge<Required<T> & Omit<T, K>>,前面的 Required<T> 必选优先级最高,后面的 Omit<T, K> 虽然本身逻辑没错,但无法把必选覆盖为可选,因此测试用例都挂了。 解法就是破解这一特征,用原始对象 & 仅包含 K 的必选对象,使必选覆盖前面的可选 Key。后者可以 Pick 出来: type Merge<T> = { [K in keyof T]: T[K]}type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge< T & Required<Pick<T, K>>> 这样就剩一个单测没通过了: Expect<Equal<RequiredByKeys<User, 'name' | 'unknown'>, UserRequiredName>> 我们还要兼容 Pick 访问不存在的 Key,用 extends 躲避一下即可: // 本题答案type Merge<T> = { [K in keyof T]: T[K]}type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge< T & Required<Pick<T, K extends keyof T ? K : never>>> Mutable实现 Mutable<T>,将对象 T 的所有 Key 变得可写: interface Todo { readonly title: string readonly description: string readonly completed: boolean}type MutableTodo = Mutable<Todo> // { title: string; description: string; completed: boolean; } 把对象从可写变成不可写: type Readonly<T> = { readonly [K in keyof T]: T[K]} 从不可写改成可写也简单,主要看你是否记住了这个语法:-readonly: // 本题答案type Mutable<T extends object> = { -readonly [K in keyof T]: T[K]} OmitByType实现 OmitByType<T, U> 根据类型 U 排除 T 中的 Key: type OmitBoolean = OmitByType< { name: string count: number isReadonly: boolean isEnable: boolean }, boolean> // { name: string; count: number } 本题和 PickByType 正好反过来,只要把 extends 后内容对调一下即可: // 本题答案type OmitByType<T, U> = { [K in keyof T as T[K] extends U ? never : K]: T[K]} 总结本周的题目除了 MinusOne 那道神仙解法比较难以外,其他的都比较常见,其中 Merge 函数的妙用需要领悟一下。 讨论地址是:精读《MinusOne, PickByType, StartsWith…》· Issue ##430 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《ObjectEntries, Shift, Reverse","path":"/wiki/WebWeekly/TS 类型体操/《ObjectEntries, Shift, Reverse.html","content":"当前期刊数: 249 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 41~48 题。 精读ObjectEntries实现 TS 版本的 Object.entries: interface Model { name: string; age: number; locations: string[] | null;}type modelEntries = ObjectEntries<Model> // ['name', string] | ['age', number] | ['locations', string[] | null]; 经过前面的铺垫,大家应该熟悉了 TS 思维思考问题,这道题看到后第一个念头应该是:如何先把对象转换为联合类型?这个问题不解决,就无从下手。 对象或数组转联合类型的思路都是类似的,一个数组转联合类型用 [number] 作为下标: ['1', '2', '3']['number'] // '1' | '2' | '3' 对象的方式则是 [keyof T] 作为下标: type ObjectToUnion<T> = T[keyof T] 再观察这道题,联合类型每一项都是数组,分别是 Key 与 Value,这样就比较好写了,我们只要构造一个 Value 是符合结构的对象即可: type ObjectEntries<T> = { [K in keyof T]: [K, T[K]]}[keyof T] 为了通过单测 ObjectEntries<{ key?: undefined }>,让 Key 位置不出现 undefined,需要强制把对象描述为非可选 Key: type ObjectEntries<T> = { [K in keyof T]-?: [K, T[K]]}[keyof T] 为了通过单测 ObjectEntries<Partial<Model>>,得将 Value 中 undefined 移除: // 本题答案type RemoveUndefined<T> = [T] extends [undefined] ? T : Exclude<T, undefined>type ObjectEntries<T> = { [K in keyof T]-?: [K, RemoveUndefined<T[K]>]}[keyof T] Shift实现 TS 版 Array.shift: type Result = Shift<[3, 2, 1]> // [2, 1] 这道题应该是简单难度的,只要把第一项抛弃即可,利用 infer 轻松实现: // 本题答案type Shift<T> = T extends [infer First, ...infer Rest] ? Rest : never Tuple to Nested Object实现 TupleToNestedObject<T, P>,其中 T 仅接收字符串数组,P 是任意类型,生成一个递归对象结构,满足如下结果: type a = TupleToNestedObject<['a'], string> // {a: string}type b = TupleToNestedObject<['a', 'b'], number> // {a: {b: number}}type c = TupleToNestedObject<[], boolean> // boolean. if the tuple is empty, just return the U type 这道题用到了 5 个知识点:递归、辅助类型、infer、如何指定对象 Key、PropertyKey,你得全部知道并组合起来才能解决该题。 首先因为返回值是个递归对象,递归过程中必定不断修改它,因此给泛型添加第三个参数 R 存储这个对象,并且在递归数组时从最后一个开始,这样从最内层对象开始一点点把它 “包起来”: type TupleToNestedObject<T, U, R = U> = /** 伪代码 T extends [...infer Rest, infer Last]*/ 下一步是如何描述一个对象 Key?之前 Chainable Options 例子我们学到的 K in Q,但需要注意直接这么写会报错,因为必须申明 Q extends PropertyKey。最后再处理一下递归结束条件,即 T 变成空数组时直接返回 R: // 本题答案type TupleToNestedObject<T, U, R = U> = T extends [] ? R : ( T extends [...infer Rest, infer Last extends PropertyKey] ? ( TupleToNestedObject<Rest, U, { [P in Last]: R }> ) : never) Reverse实现 TS 版 Array.reverse: type a = Reverse<['a', 'b']> // ['b', 'a']type b = Reverse<['a', 'b', 'c']> // ['c', 'b', 'a'] 这道题比上一题简单,只需要用一个递归即可: // 本题答案type Reverse<T extends any[]> = T extends [...infer Rest, infer End] ? [End, ...Reverse<Rest>] : T Flip Arguments实现 FlipArguments<T> 将函数 T 的参数反转: type Flipped = FlipArguments<(arg0: string, arg1: number, arg2: boolean) => void> // (arg0: boolean, arg1: number, arg2: string) => void 本题与上题类似,只是反转内容从数组变成了函数的参数,只要用 infer 定义出函数的参数,利用 Reverse 函数反转一下即可: // 本题答案type Reverse<T extends any[]> = T extends [...infer Rest, infer End] ? [End, ...Reverse<Rest>] : Ttype FlipArguments<T> = T extends (...args: infer Args) => infer Result ? (...args: Reverse<Args>) => Result : never FlattenDepth实现指定深度的 Flatten: type a = FlattenDepth<[1, 2, [3, 4], [[[5]]]], 2> // [1, 2, 3, 4, [5]]. flattern 2 timestype b = FlattenDepth<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, [[5]]]. Depth defaults to be 1 这道题比之前的 Flatten 更棘手一些,因为需要控制打平的次数。 基本想法就是,打平 Deep 次,所以需要实现打平一次的函数,再根据 Deep 值递归对应次: type FlattenOnce<T extends any[], U extends any[] = []> = T extends [infer X, ...infer Y] ? ( X extends any[] ? FlattenOnce<Y, [...U, ...X]> : FlattenOnce<Y, [...U, X]>) : U 然后再实现主函数 FlattenDepth,因为 TS 无法实现 +、- 号运算,我们必须用数组长度判断与操作数组来辅助实现: // FlattenOncetype FlattenDepth< T extends any[], U extends number = 1, P extends any[] = []> = P['length'] extends U ? T : ( FlattenDepth<FlattenOnce<T>, U, [...P, any]>) 当递归没有达到深度 U 时,就用 [...P, any] 的方式给数组塞一个元素,下次如果能匹配上 P['length'] extends U 说明递归深度已达到。 但考虑到测试用例 FlattenDepth<[1, [2, [3, [4, [5]]]]], 19260817> 会引发超长次数递归,需要提前终止,即如果打平后已经是平的,就不用再继续递归了,此时可以用 FlattenOnce<T> extends T 判断: // 本题答案// FlattenOncetype FlattenDepth< T extends any[], U extends number = 1, P extends any[] = []> = P['length'] extends U ? T : ( FlattenOnce<T> extends T ? T : ( FlattenDepth<FlattenOnce<T>, U, [...P, any]> )) BEM style string实现 BEM 函数完成其规则拼接: Expect<Equal<BEM<'btn', [], ['small', 'medium', 'large']>, 'btn--small' | 'btn--medium' | 'btn--large' >>, 之前我们了解了通过下标将数组或对象转成联合类型,这里还有一个特殊情况,即字符串中通过这种方式申明每一项,会自动笛卡尔积为新的联合类型: type BEM<B extends string, E extends string[], M extends string[]> = `${B}__${E[number]}--${M[number]}` 这是最简单的写法,但没有考虑项不存在的情况。不如创建一个 SafeUnion 函数,当传入值不存在时返回空字符串,保证安全的跳过: type IsNever<TValue> = TValue[] extends never[] ? true : false;type SafeUnion<TUnion> = IsNever<TUnion> extends true ? "" : TUnion; 最终代码: // 本题答案// IsNever, SafeUniontype BEM<B extends string, E extends string[], M extends string[]> = `${B}${SafeUnion<`__${E[number]}`>}${SafeUnion<`--${M[number]}`>}` InorderTraversal实现 TS 版二叉树中序遍历: const tree1 = { val: 1, left: null, right: { val: 2, left: { val: 3, left: null, right: null, }, right: null, },} as consttype A = InorderTraversal<typeof tree1> // [1, 3, 2] 首先回忆一下二叉树中序遍历 JS 版的实现: function inorderTraversal(tree) { if (!tree) return [] return [ ...inorderTraversal(tree.left), res.push(val), ...inorderTraversal(tree.right) ]} 对 TS 来说,实现递归的方式有一点点不同,即通过 extends TreeNode 来判定它不是 Null 从而递归: // 本题答案interface TreeNode { val: number left: TreeNode | null right: TreeNode | null}type InorderTraversal<T extends TreeNode | null> = [T] extends [TreeNode] ? ( [ ...InorderTraversal<T['left']>, T['val'], ...InorderTraversal<T['right']> ] ): [] 你可能会问,问什么不能像 JS 一样,用 null 做判断呢? type InorderTraversal<T extends TreeNode | null> = [T] extends [null] ? [] : ( [ // error ...InorderTraversal<T['left']>, T['val'], ...InorderTraversal<T['right']> ] ) 如果这么写会发现 TS 抛出了异常,因为 TS 不能确定 T 此时符合 TreeNode 类型,所以要执行操作时一般采用正向判断。 总结这些类型挑战题目需要灵活组合 TS 的基础知识点才能破解,常用的包括: 如何操作对象,增减 Key、只读、合并为一个对象等。 递归,以及辅助类型。 infer 知识点。 联合类型,如何从对象或数组生成联合类型,字符串模板与联合类型的关系。 讨论地址是:精读《ObjectEntries, Shift, Reverse…》· Issue ##431 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Permutation, Flatten, Absolute","path":"/wiki/WebWeekly/TS 类型体操/《Permutation, Flatten, Absolute.html","content":"当前期刊数: 246 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 17~24 题。 精读Permutation实现 Permutation 类型,将联合类型替换为可能的全排列: type perm = Permutation<'A' | 'B' | 'C'>; // ['A', 'B', 'C'] | ['A', 'C', 'B'] | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C', 'A', 'B'] | ['C', 'B', 'A'] 看到这题立马联想到 TS 对多个联合类型泛型处理是采用分配律的,在第一次做到 Exclude 题目时遇到过: Exclude<'a' | 'b', 'a' | 'c'>// 等价于Exclude<'a', 'a' | 'c'> | Exclude<'b', 'a' | 'c'> 所以这题如果能 “递归触发联合类型分配率”,就有戏解决啊。但触发的条件必须存在两个泛型,而题目传入的只有一个,我们只好创造第二个泛型,使其默认值等于第一个: type Permutation<T, U = T> 这样对本题来说,会做如下展开: Permutation<'A' | 'B' | 'C'>// 等价于Permutation<'A' | 'B' | 'C', 'A' | 'B' | 'C'>// 等价于Permutation<'A', 'A' | 'B' | 'C'> | Permutation<'B', 'A' | 'B' | 'C'> | Permutation<'C', 'A' | 'B' | 'C'> 对于 Permutation<'A', 'A' | 'B' | 'C'> 来说,排除掉对自身的组合,可形成 'A', 'B','A', 'C' 组合,之后只要再递归一次,再拼一次,把已有的排除掉,就形成了 A 的全排列,以此类推,形成所有字母的全排列。 这里要注意两点: 如何排除掉自身?Exclude<T, P> 正合适,该函数遇到 T 在联合类型 P 中时,会返回 never,否则返回 T。 递归何时结束?每次递归时用 Exclude<U, T> 留下没用过的组合,最后一次组合用完一定会剩下 never,此时终止递归。 // 本题答案type Permutation<T, U = T> = [T] extends [never] ? [] : T extends U ? [T, ...Permutation<Exclude<U, T>>] : [] 验证一下答案,首先展开 Permutation<'A', 'B', 'C'>: 'A' extends 'A' | 'B' | 'C' ? ['A', ...Permutation<'B' | 'C'>] : []'B' extends 'A' | 'B' | 'C' ? ['B', ...Permutation<'A' | 'C'>] : []'C' extends 'A' | 'B' | 'C' ? ['C', ...Permutation<'A' | 'B'>] : [] 我们再展开第一行 Permutation<'B' | 'C'>: 'B' extends 'B' | 'C' ? ['B', ...Permutation<'C'>] : []'C' extends 'B' | 'C' ? ['C', ...Permutation<'B'>] : [] 再展开第一行的 Permutation<'C'>: 'C' extends 'C' ? ['C', ...Permutation<never>] : [] 此时已经完成全排列,但我们还要处理一下 Permutation<never>,使其返回 [] 并终止递归。那为什么要用 [T] extends [never] 而不是 T extends never 呢? 如果我们用 T extends never 代替本题答案,输出结果是 never,原因如下: type X = never extends never ? 1 : 0 // 1type Custom<T> = T extends never ? 1 : 0type Y = Custom<never> // never 理论上相同的代码,为什么用泛型后输出就变成 never 了呢?原因是 TS 在做 T extends never ? 时,会对联合类型进行分配,此时有一个特例,即当 T = never 时,会跳过分配直接返回 T 本身,所以三元判断代码实际上没有执行。 [T] extends [never] 这种写法可以避免 TS 对联合类型进行分配,继而绕过上面的问题。 Length of String实现 LengthOfString<T> 返回字符串 T 的长度: LengthOfString<'abc'> // 3 破解此题你需要知道一个前提,即 TS 访问数组类型的 [length] 属性可以拿到长度值: ['a','b','c']['length'] // 3 也就是说,我们需要把 'abc' 转化为 ['a', 'b', 'c']。 第二个需要了解的前置知识是,用 infer 指代字符串时,第一个指代指向第一个字母,第二个指向其余所有字母: 'abc' extends `${infer S}${infer E}` ? S : never // 'a' 那转换后的数组存在哪呢?类似 js,我们弄第二个默认值泛型存储即可: // 本题答案type LengthOfString<S, N extends any[] = []> = S extends `${infer S}${infer E}` ? LengthOfString<E, [...N, S]> : N['length'] 思路就是,每次把字符串第一个字母拿出来放到数组 N 的第一项,直到字符串被取完,直接拿此时的数组长度。 Flatten实现类型 Flatten: type flatten = Flatten<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, 5] 此题一看就需要递归: // 本题答案type Flatten<T extends any[], Result extends any[] = []> = T extends [infer Start, ...infer Rest] ? ( Start extends any[] ? Flatten<Rest, [...Result, ...Flatten<Start>]> : Flatten<Rest, [...Result, Start]>) : Result 这道题看似答案复杂,其实还是用到了上一题的套路:递归时如果需要存储临时变量,用泛型默认值来存储。 本题我们就用 Result 这个泛型存储打平后的结果,每次拿到数组第一个值,如果第一个值不是数组,则直接存进去继续递归,此时 T 自然是剩余的 Rest;如果第一个值是数组,则将其打平,此时有个精彩的地方,即 ...Start 打平后依然可能是数组,比如 [[5]] 就套了两层,能不能想到 ...Flatten<Start> 继续复用递归是解题关键。 Append to object实现 AppendToObject: type Test = { id: '1' }type Result = AppendToObject<Test, 'value', 4> // expected to be { id: '1', value: 4 } 结合之前刷题的经验,该题解法很简单,注意 K in Key 可以给对象拓展某些指定 Key: // 本题答案type AppendToObject<Obj, Key extends string, Value> = Obj & { [K in Key]: Value} 当然也有不用 Obj & 的写法,即把原始对象和新 Key, Value 合在一起的描述方式: // 本题答案type AppendToObject<T, U extends number | string | symbol, V> = { [key in (keyof T) | U]: key extends U ? V : T[Exclude<key, U>]} Absolute实现 Absolute 将数字转成绝对值: type Test = -100;type Result = Absolute<Test>; // expected to be "100" 该题重点是把数字转成绝对值字符串,所以我们可以用字符串的方式进行匹配: // 本题答案type Absolute<T extends number> = `${T}` extends `-${infer R}` ? R : `${T}` 为什么不用 T extends 来判断呢?因为 T 是数字,这样写无法匹配符号的字符串描述。 String to Union实现 StringToUnion 将字符串转换为联合类型: type Test = '123';type Result = StringToUnion<Test>; // expected to be "1" | "2" | "3" 还是老套路,用一个新的泛型存储答案,递归即可: // 本题答案type StringToUnion<T, P = never> = T extends `${infer F}${infer R}` ? StringToUnion<R, P | F> : P 当然也可以不依托泛型存储答案,因为该题比较特殊,可以直接用 |: // 本题答案type StringToUnion<T> = T extends `${infer F}${infer R}` ? F | StringToUnion<R> : never Merge实现 Merge 合并两个对象,冲突时后者优先: type foo = { name: string; age: string;}type coo = { age: number; sex: string}type Result = Merge<foo,coo>; // expected to be {name: string, age: number, sex: string} 这道题答案甚至是之前题目的解题步骤,即用一个对象描述 + keyof 的思维: // 本题答案type Merge<A extends object, B extends object> = { [K in keyof A | keyof B] : K extends keyof B ? B[K] : ( K extends keyof A ? A[K] : never )} 只要知道 in keyof 支持元组,值部分用 extends 进行区分即可,很简单。 KebabCase实现驼峰转横线的函数 KebabCase: KebabCase<'FooBarBaz'> // 'foo-bar-baz' 还是老套路,用第二个参数存储结果,用递归的方式遍历字符串,遇到大写字母就转成小写并添加上 -,最后把开头的 - 干掉就行了: // 本题答案type KebabCase<S, U extends string = ''> = S extends `${infer F}${infer R}` ? ( Lowercase<F> extends F ? KebabCase<R, `${U}${F}`> : KebabCase<R, `${U}-${Lowercase<F>}`>) : RemoveFirstHyphen<U>type RemoveFirstHyphen<S> = S extends `-${infer Rest}` ? Rest : S 分开写就非常容易懂了,首先 KebabCase 每次递归取第一个字符,如何判断这个字符是大写呢?只要小写不等于原始值就是大写,所以判断条件就是 Lowercase<F> extends F 的 false 分支。然后再写个函数 RemoveFirstHyphen 把字符串第一个 - 干掉即可。 总结TS 是一门编程语言,而不是一门简单的描述或者修饰符,很多复杂类型问题要动用逻辑思维来实现,而不是查查语法就能简单实现。 讨论地址是:精读《Permutation, Flatten, Absolute…》· Issue ##426 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Unique, MapTypes, Construct Tuple","path":"/wiki/WebWeekly/TS 类型体操/《Unique, MapTypes, Construct Tuple.html","content":"当前期刊数: 252 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 63~68 题。 精读Unique实现 Unique<T>,对 T 去重: type Res = Unique<[1, 1, 2, 2, 3, 3]> // expected to be [1, 2, 3]type Res1 = Unique<[1, 2, 3, 4, 4, 5, 6, 7]> // expected to be [1, 2, 3, 4, 5, 6, 7]type Res2 = Unique<[1, 'a', 2, 'b', 2, 'a']> // expected to be [1, "a", 2, "b"]type Res3 = Unique<[string, number, 1, 'a', 1, string, 2, 'b', 2, number]> // expected to be [string, number, 1, "a", 2, "b"]type Res4 = Unique<[unknown, unknown, any, any, never, never]> // expected to be [unknown, any, never] 去重需要不断递归产生去重后结果,因此需要一个辅助变量 R 配合,并把 T 用 infer 逐一拆解,判断第一个字符是否在结果数组里,如果不在就塞进去: type Unique<T, R extends any[] = []> = T extends [infer F, ...infer Rest] ? Includes<R, F> extends true ? Unique<Rest, R> : Unique<Rest, [...R, F]> : R 那么剩下的问题就是,如何判断一个对象是否出现在数组中,使用递归可以轻松完成: type Includes<Arr, Value> = Arr extends [infer F, ...infer Rest] ? Equal<F, Value> extends true ? true : Includes<Rest, Value> : false 每次取首项,如果等于 Value 直接返回 true,否则继续递归,如果数组递归结束(不构成 Arr extends [xxx] 的形式)说明递归完了还没有找到相等值,直接返回 false。 把这两个函数组合一下就能轻松解决本题: // 本题答案type Unique<T, R extends any[] = []> = T extends [infer F, ...infer Rest] ? Includes<R, F> extends true ? Unique<Rest, R> : Unique<Rest, [...R, F]> : Rtype Includes<Arr, Value> = Arr extends [infer F, ...infer Rest] ? Equal<F, Value> extends true ? true : Includes<Rest, Value> : false MapTypes实现 MapTypes<T, R>,根据对象 R 的描述来替换类型: type StringToNumber = { mapFrom: string; // value of key which value is string mapTo: number; // will be transformed for number}MapTypes<{iWillBeANumberOneDay: string}, StringToNumber> // gives { iWillBeANumberOneDay: number; } 因为要返回一个新对象,所以我们使用 { [K in keyof T]: ... } 的形式描述结果对象。然后就要对 Value 类型进行判断了,为了防止 never 的作用,我们包一层数组进行判断: type MapTypes<T, R extends { mapFrom: any; mapTo: any }> = { [K in keyof T]: [T[K]] extends [R['mapFrom']] ? R['mapTo'] : T[K]} 但这个解答还有一个 case 无法通过: MapTypes<{iWillBeNumberOrDate: string}, StringToDate | StringToNumber> // gives { iWillBeNumberOrDate: number | Date; } 我们需要考虑到 Union 分发机制以及每次都要重新匹配一次是否命中 mapFrom,因此需要抽一个函数: type Transform<R extends { mapFrom: any; mapTo: any }, T> = R extends any ? T extends R['mapFrom'] ? R['mapTo'] : never : never 为什么要 R extends any 看似无意义的写法呢?原因是 R 是联合类型,这样可以触发分发机制,让每一个类型独立判断。所以最终答案就是: // 本题答案type MapTypes<T, R extends { mapFrom: any; mapTo: any }> = { [K in keyof T]: [T[K]] extends [R['mapFrom']] ? Transform<R, T[K]> : T[K]}type Transform<R extends { mapFrom: any; mapTo: any }, T> = R extends any ? T extends R['mapFrom'] ? R['mapTo'] : never : never Construct Tuple生成指定长度的 Tuple: type result = ConstructTuple<2> // expect to be [unknown, unkonwn] 比较容易想到的办法是利用下标递归: type ConstructTuple< L extends number, I extends number[] = []> = I['length'] extends L ? [] : [unknown, ...ConstructTuple<L, [1, ...I]>] 但在如下测试用例会遇到递归长度过深的问题: ConstructTuple<999> // Type instantiation is excessively deep and possibly infinite 一种解法是利用 minusOne 提到的 CountTo 方法快捷生成指定长度数组,把 1 替换为 unknown 即可: // 本题答案type ConstructTuple<L extends number> = CountTo<`${L}`>type CountTo< T extends string, Count extends unknown[] = []> = T extends `${infer First}${infer Rest}` ? CountTo<Rest, N<Count>[keyof N & First]> : Counttype N<T extends unknown[] = []> = { '0': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T] '1': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown] '2': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown ] '3': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown ] '4': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown ] '5': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown ] '6': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown ] '7': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown, unknown ] '8': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown ] '9': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown ]} Number Range实现 NumberRange<T, P>,生成数字为从 T 到 P 的联合类型: type result = NumberRange<2, 9> // | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 以 NumberRange<2, 9> 为例,我们需要实现 2 到 9 的递增递归,因此需要一个数组长度从 2 递增到 9 的辅助变量 U,以及一个存储结果的辅助变量 R: type NumberRange<T, P, U extends any[] = 长度为 T 的数组, R> 所以我们先实现 LengthTo 函数,传入长度 N,返回一个长度为 N 的数组: type LengthTo<N extends number, R extends any[] = []> = R['length'] extends N ? R : LengthTo<N, [0, ...R]> 然后就是递归了: // 本题答案type NumberRange<T extends number, P extends number, U extends any[] = LengthTo<T>, R extends number = never> = U['length'] extends P ? ( R | U['length'] ) : ( NumberRange<T, P, [0, ...U], R | U['length']> ) R 的默认值为 never 非常重要,否则默认值为 any,最终类型就会被放大为 any。 Combination实现 Combination<T>: // expected to be `"foo" | "bar" | "baz" | "foo bar" | "foo bar baz" | "foo baz" | "foo baz bar" | "bar foo" | "bar foo baz" | "bar baz" | "bar baz foo" | "baz foo" | "baz foo bar" | "baz bar" | "baz bar foo"`type Keys = Combination<['foo', 'bar', 'baz']> 本题和 AllCombination 类似: type AllCombinations_ABC = AllCombinations<'ABC'>// should be '' | 'A' | 'B' | 'C' | 'AB' | 'AC' | 'BA' | 'BC' | 'CA' | 'CB' | 'ABC' | 'ACB' | 'BAC' | 'BCA' | 'CAB' | 'CBA' 还记得这题吗?我们要将字符串变成联合类型: type StrToUnion<S> = S extends `${infer F}${infer R}` ? F | StrToUnion<R> : never 而本题 Combination 更简单,把数组转换为联合类型只需要 T[number]。所以本题第一种组合解法是,将 AllCombinations 稍微改造下,再利用 Exclude 和 TrimRight 删除多余的空格: // 本题答案type AllCombinations<T extends string[], U extends string = T[number]> = [ U] extends [never] ? '' : '' | { [K in U]: `${K} ${AllCombinations<never, Exclude<U, K>>}` }[U]type TrimRight<T extends string> = T extends `${infer R} ` ? TrimRight<R> : Ttype Combination<T extends string[]> = TrimRight<Exclude<AllCombinations<T>, ''>> 还有一种非常精彩的答案在此分析一下: // 本题答案type Combination<T extends string[], U = T[number], A = U> = U extends infer U extends string ? `${U} ${Combination<T, Exclude<A, U>>}` | U : never; 依然利用 T[number] 的特性将数组转成联合类型,再利用联合类型 extends 会分组的特性递归出结果。 之所以不会出现结尾出现多余的空格,是因为 U extends infer U extends string 这段判断已经杜绝了 U 消耗完的情况,如果消耗完会及时返回 never,所以无需用 TrimRight 处理右侧多余的空格。 至于为什么要定义 A = U,在前面章节已经介绍过了,因为联合类型 extends 过程中会进行分组,此时访问的 U 已经是具体类型了,但此时访问 A 还是原始的联合类型 U。 Subsequence实现 Subsequence<T> 输出所有可能的子序列: type A = Subsequence<[1, 2]> // [] | [1] | [2] | [1, 2] 因为是返回数组的全排列,只要每次取第一项,与剩余项的递归构造出结果,| 上剩余项本身递归的结果就可以了: // 本题答案type Subsequence<T extends number[]> = T extends [infer F, ...infer R extends number[]] ? ( Subsequence<R> | [F, ...Subsequence<R>]) : T 总结对全排列问题有两种经典解法: 利用辅助变量方式递归,注意联合类型与字符串、数组之间转换的技巧。 直接递归,不借助辅助变量,一般在题目返回类型容易构造时选择。 讨论地址是:精读《Unique, MapTypes, Construct Tuple…》· Issue ##434 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Pick, Awaited, If","path":"/wiki/WebWeekly/TS 类型体操/《Pick, Awaited, If.html","content":"当前期刊数: 243 TS 强类型非常好用,但在实际运用中,免不了遇到一些难以描述,反复看官方文档也解决不了的问题,至今为止也没有任何一篇文档,或者一套教材可以解决所有犄角旮旯的类型问题。为什么会这样呢?因为 TS 并不是简单的注释器,而是一门图灵完备的语言,所以很多问题的解决方法藏在基础能力里,但你学会了基础能力又不一定能想到这么用。 解决该问题的最好办法就是多练,通过实际案例不断刺激你的大脑,让你养成 TS 思维习惯。所以话不多说,我们今天从 type-challenges 的 Easy 难度题目开始吧。 精读Pick手动实现内置 Pick<T, K> 函数,返回一个新的类型,从对象 T 中抽取类型 K: interface Todo { title: string description: string completed: boolean}type TodoPreview = MyPick<Todo, 'title' | 'completed'>const todo: TodoPreview = { title: 'Clean room', completed: false,} 结合例子更容易看明白,也就是 K 是一个字符串,我们需要返回一个新类型,仅保留 K 定义的 Key。 第一个难点在如何限制 K 的取值,比如传入 T 中不存在的值就要报错。这个考察的是硬知识,只要你知道 A extends keyof B 这个语法就能联想到。 第二个难点在于如何生成一个仅包含 K 定义 Key 的类型,你首先要知道有 { [A in keyof B]: B[A] } 这个硬知识,这样可以重新组合一个对象: // 代码 1type Foo<T> = { [P in keyof T]: T[P]} 只懂这个语法不一定能想出思路,原因是你要打破对 TS 的刻板理解,[K in keyof T] 不是一个固定模板,其中 keyof T 只是一个指代变量,它可以被换掉,如果你换掉成另一个范围的变量,那么这个对象的 Key 值范围就变了,这正好契合本题的 K: // 代码 2(本题答案)type MyPick<T, K extends keyof T> = { [P in K]: T[P]} 这个题目别看知道答案后简单,回顾下还是有收获的。对比上面两个代码例子,你会发现,只不过是把代码 1 的 keyof T 从对象描述中提到了泛型定义里而已,所以功能上没有任何变化,但因为泛型可以由用户传入,所以代码 1 的 P in keyof T 因为没有泛型支撑,这里推导出来的就是 T 的所有 Keys,而代码 2 虽然把代码挪到了泛型,但因为用的是 extends 描述,所以表示 P 的类型被约束到了 T 的 Keys,至于具体是什么,得看用户代码怎么传。 所以其实放到泛型里的 K 是没有默认值的,而写到对象里作为推导值就有了默认值。泛型里给默认值的方式如下: // 代码 3type MyPick<T, K extends keyof T = keyof T> = { [P in K]: T[P]} 也就是说,这样 MyPick<Todo> 就也可以正确工作并原封不动返回 Todo 类型,也就是说,代码 3 在不传第二个参数时,与代码 1 的功能完全一样。仔细琢磨一下共同点与区别,为什么代码 3 可以做到和代码 1 功能一样,又有更强的拓展性,你对 TS 泛型的实战理解就上了一个台阶。 Readonly手动实现内置 Readonly<T> 函数,将对象所有属性设置为只读: interface Todo { title: string description: string}const todo: MyReadonly<Todo> = { title: "Hey", description: "foobar"}todo.title = "Hello" // Error: cannot reassign a readonly propertytodo.description = "barFoo" // Error: cannot reassign a readonly property 这道题反而比第一题简单,只要我们用 { [A in keyof B]: B[A] } 重新声明对象,并在每个 Key 前面加上 readonly 修饰即可: // 本题答案type MyReadonly<T> = { readonly [K in keyof T]: T[K]} 根据这个特性我们可以做很多延伸改造,比如将对象所有 Key 都设定为可选: type Optional<T> = { [K in keyof T]?: T[K]} { [A in keyof B]: B[A] } 给了我们描述每一个 Key 属性细节的机会,限制我们发挥的只有想象力。 First Of Array实现类型 First<T>,取到数组第一项的类型: type arr1 = ['a', 'b', 'c']type arr2 = [3, 2, 1]type head1 = First<arr1> // expected to be 'a'type head2 = First<arr2> // expected to be 3 这题比较简单,很容易想到的答案: // 本题答案type First<T extends any[]> = T[0] 但在写这个答案时,有 10% 脑细胞提醒我没有判断边界情况,果然看了下答案,有空数组的情况要考虑,空数组时返回类型 never 而不是 undefined 会更好,下面几种写法都是答案: type First<T extends any[]> = T extends [] ? never : T[0]type First<T extends any[]> = T['length'] extends 0 ? never : T[0]type First<T> = T extends [infer P, ...infer Rest] ? P : never 第一种写法通过 extends [] 判断 T 是否为空数组,是的话返回 never。 第二种写法通过长度为 0 判断空数组,此时需要理解两点:1. 可以通过 T['length'] 让 TS 访问到值长度(类型的),2. extends 0 表示是否匹配 0,即 extends 除了匹配类型,还能直接匹配值。 第三种写法是最省心的,但也使用了 infer 关键字,即使你充分知道 infer 怎么用(精读《Typescript infer 关键字》),也很难想到它。用 infer 的理由是:该场景存在边界情况,最便于理解的写法是 “如果 T 形如 <P, ...>” 那我就返回类型 P,否则返回 never”,这句话用 TS 描述就是:T extends [infer P, ...infer Rest] ? P : never。 Length of Tuple实现类型 Length<T> 获取元组长度: type tesla = ['tesla', 'model 3', 'model X', 'model Y']type spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT']type teslaLength = Length<tesla> // expected 4type spaceXLength = Length<spaceX> // expected 5 经过上一题的学习,很容易想到这个答案: type Length<T extends any[]> = T['length'] 对 TS 来说,元组和数组都是数组,但元组对 TS 来说可以观测其长度,T['length'] 对元组来说返回的是具体值,而对数组来说返回的是 number。 Exclude实现类型 Exclude<T, U>,返回 T 中不存在于 U 的部分。该功能主要用在联合类型场景,所以我们直接用 extends 判断就行了: // 本题答案type Exclude<T, U> = T extends U ? never : T 实际运行效果: type C = Exclude<'a' | 'b', 'a' | 'c'> // 'b' 看上去有点不那么好理解,这是因为 TS 对联合类型的执行是分配律的,即: Exclude<'a' | 'b', 'a' | 'c'>// 等价于Exclude<'a', 'a' | 'c'> | Exclude<'b', 'a' | 'c'> Awaited实现类型 Awaited,比如从 Promise<ExampleType> 拿到 ExampleType。 首先 TS 永远不会执行代码,所以脑子里不要有 “await 得等一下才知道结果” 的念头。该题关键就是从 Promise<T> 中抽取类型 T,很适合用 infer 做: type MyAwaited<T> = T extends Promise<infer U> ? U : never 然而这个答案还不够标准,标准答案考虑了嵌套 Promise 的场景: // 该题答案type MyAwaited<T extends Promise<unknown>> = T extends Promise<infer P> ? P extends Promise<unknown> ? MyAwaited<P> : P : never 如果 Promise<P> 取到的 P 还形如 Promise<unknown>,就递归调用自己 MyAwaited<P>。这里提到了递归,也就是 TS 类型处理可以是递归的,所以才有了后面版本做尾递归优化。 If实现类型 If<Condition, True, False>,当 C 为 true 时返回 T,否则返回 F: type A = If<true, 'a', 'b'> // expected to be 'a'type B = If<false, 'a', 'b'> // expected to be 'b' 之前有提过,extends 还可以用来判定值,所以果断用 extends true 判断是否命中了 true 即可: // 本题答案type If<C, T, F> = C extends true ? T : F Concat用类型系统实现 Concat<P, Q>,将两个数组类型连起来: type Result = Concat<[1], [2]> // expected to be [1, 2] 由于 TS 支持数组解构语法,所以可以大胆的尝试这么写: type Concat<P extends any[], Q extends any[]> = [...P, ...Q] 考虑到 Concat 函数应该也能接收非数组类型,所以做一个判断,为了方便书写,把 extends 从泛型定义位置挪到 TS 类型推断的运行时: // 本题答案type Concat<P, Q> = [ ...P extends any[] ? P : [P], ...Q extends any[] ? Q : [Q],] 解决这题需要信念,相信 TS 可以像 JS 一样写逻辑。这些能力都是版本升级时渐进式提供的,所以需要不断阅读最新 TS 特性,快速将其理解为固化知识,其实还是有一定难度的。 Includes用类型系统实现 Includes<T, K> 函数: type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'> // expected to be `false` 由于之前的经验,很容易做下面的联想: // 如果题目要求是这样type isPillarMen = Includes<'Kars' | 'Esidisi' | 'Wamuu' | 'Santana', 'Dio'>// 那我就能用 extends 轻松解决了type Includes<T, K> = K extends T ? true : false 可惜第一个输入是数组类型,extends 可不支持判定 “数组包含” 逻辑,此时要了解一个新知识点,即 TS 判断中的 [number] 下标。不仅这道题,以后很多困难题都需要它作为基础知识。 [number] 下标表示任意一项,而 extends T[number] 就可以实现数组包含的判定,因此下面的解法是有效的: type Includes<T extends any[], K> = K extends T[number] ? true : false 但翻答案后发现这并不是标准答案,还真找到一个反例: type Includes<T extends any[], K> = K extends T[number] ? true : falsetype isPillarMen = Includes<[boolean], false> // true 原因很简单,true、false 都继承自 boolean,所以 extends 判断的界限太宽了,题目要求的是精确值匹配,故上面的答案理论上是错的。 标准答案是每次判断数组第一项,并递归(讲真觉得这不是 easy 题),分别有两个难点。 第一如何写 Equal 函数?比较流行的方案是这个: type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false 关于如何写 Equal 函数还引发了一次 小讨论,上面的代码构造了两个函数,这两个函数内的 T 属于 deferred(延迟)判断的类型,该类型判断依赖于内部 isTypeIdenticalTo 函数完成判断。 有了 Equal 后就简单了,我们用解构 + infer + 递归的方式做就可以了: // 本题答案type Includes<T extends any[], K> = T extends [infer F, ...infer Rest] ? Equal<F, K> extends true ? true : Includes<Rest, K> : false 每次取数组第一个值判断 Equal,如果不匹配则拿剩余项递归判断。这个函数组合了不少 TS 知识,比如: 递归 解构 infer extends true 可以发现,就为了解决 true extends boolean 为 true 的问题,我们绕了一大圈使用了更复杂的方式来实现,这在 TS 体操中也算是常态,解决问题需要耐心。 Push实现 Push<T, K> 函数: type Result = Push<[1, 2], '3'> // [1, 2, '3'] 这道题真的很简单,用解构就行了: // 本题答案type Push<T extends any[], K> = [...T, K] 可见,想要轻松解决一个 TS 简单问题,首先你需要能解决一些困难问题 😁。 Unshift实现 Unshift<T, K> 函数: type Result = Unshift<[1, 2], 0> // [0, 1, 2,] 在 Push 基础上改下顺序就行了: // 本题答案type Unshift<T extends any[], K> = [K, ...T] Parameters实现内置函数 Parameters: Parameters 可以拿到函数的参数类型,直接用 infer 实现即可,也比较简单: type Parameters<T> = T extends (...args: infer P) => any ? P : [] infer 可以很方便从任何具体的位置取值,属于典型难懂易用的语法。 总结学会 TS 基础语法后,活用才是关键。 讨论地址是:精读《Pick, Awaited, If…》· Issue ##422 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"ComponentLoader 与动态组件","path":"/wiki/WebWeekly/可视化搭建/ComponentLoader 与动态组件.html","content":"当前期刊数: 278 组件通过 <Canvas /> 渲染在画布上,内容完全由组件树 componentTree 驱动,但也有一些情况我们需要把某个组件实例渲染到组件树之外,比如全屏、置顶等场景,甚至有些时候我们要渲染一个不在组件树中的临时组件,却要拥有一系列画布能力。 为了让组件渲染更灵活,我们暴露出 <ComponentLoader> API: import { createDesigner } from 'designer'const { Designer, Canvas, ComponentLoader } = createDesigner()const App = () => { return ( <Designer componentTree={/** ... */}> <Canvas /> {/** 任意位置,甚至 Canvas 的组件实例内使用 ComponentLoader 加载任意组件 */} <ComponentLoader /> </Designer> )} 组件加载器有三种用法:按组件 ID 加载、按组件树路径加载、动态组件,下面分别介绍。 按组件 ID 加载将组件树上的某个组件渲染到任何地方,即一个组件实例渲染到 N 个地方,实例级别信息共享,渲染为 N 份: <ComponentLoader componentId="input1" /> 如上例子,将组件 ID 为 input1 的组件渲染到目标位置。 甚至可以在组件内套组件,比如我们定义一个容器组件,内置渲染 ID 为 input1 的子组件: const container: ComponentMeta = { componentName: 'container', // 组件 props 会自动注入 ComponentLoader element: ({ ComponentLoader, children }) => { return ( <div> <ComponentLoader componentId="input1" /> {children} </div> ) }} 当该组件 ID 在组件树中被移除时,<ComponentLoader componentId="input1" /> 返回 null。 按组件树路径加载如果组件在组件树上没有 ID,或者你希望固定渲染某个位置的组件,而无论组件树如何变化,那么就可以采用按组件树路径的加载模式,将 componentId 替换为 treePath 即可: <ComponentLoader treePath="children.0" /> 如上例子,渲染的是 componentTree 根节点 children.0 位置的子组件,同样,但组件不存在时返回 null。 动态组件如果要渲染一个不存在于组件树的组件实例,还可以这么用 <ComponentLoader />: <ComponentLoader standalone componentName="card" /> 即添加 standalone 表示它为一个 “孤立” 组件,即不存在于组件树的组件,以及 componentName 指定组件名。 之所以不需要指定 componentId,是因为每个 ComponentLoader 此时都是一个唯一的实例,在 designer 内部会自动分配一个固定的组件 ID。 这么设计非常灵活,但实现起来难度是有一些,主要注意两点: 动态组件不存在于组件树,但我们之前设计在组件元信息的所有功能都要可以响应,这就要求框架代码不能依赖组件树产生作用,而是将所有组件独立存储计算,包括组件树上的,以及动态组件。 性能,独立组件加载器之间的执行并无关联,因为框架本身为响应式,为了防止频繁刷新或频繁计算需要设计一套自动批处理机制,类似 React 自动 batch 的实现。 对于动态组件,我们还可以传递更多参数: <ComponentLoader standalone componentName="chart" props={{ color: 'red' }}> <button>click</button></ComponentLoader> 如上例子,我们传了额外 props 属性,以及一个子元素给 chart 组件实例。 特别的,如果传递了 componentId,可以将该动态组件的 ID 固定下来,方便进行联动: <ComponentLoader standalone componentName="chart" componentId="abc" /> 但动态组件也有一些限制,如下: 该方式渲染的组件元信息定义的 defaultProps、props 不会生效,因为不存在于组件树中。 该组件无法通过 deleteComponent 删除,也无法通过 setProps、setComponent 等修改,因为渲染完全由父组件控制,而不由组件树控制。 不能用 setParent 改变这种组件的位置,因为其位置在代码中被固定了。 总结其实 <Canvas /> 根节点本质上等价于 <ComponentLoader treePath="" />,即从根节点开始渲染一个组件实例。 所以提供 ComponentLoader 势必会让业务能力更灵活,在任意位置渲染组件,甚至渲染一个不存在于组件树的动态组件。 讨论地址是:精读《ComponentLoader 与动态组件》· Issue ##482 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"keepAlive 模式","path":"/wiki/WebWeekly/可视化搭建/keepAlive 模式.html","content":"当前期刊数: 276 由于 React 的特点,组件改变所在父级后会产生 Remount,而在可视化搭建场景存在两个特点: 自由、磁贴、流式布局都可以通过拖拽轻松改变组件父元素。 大数据量下组件 Remount 的消耗不容忽视。 结合上面两个特点,拖拽过程中或者松手时不可避免会产生卡顿,这就是我们这篇文章要解决的问题。 利用 createPortal 解决 Remount 问题createPortal 可以将 React 实例渲染到任意指定 DOM 上,所以我们利用这个 API,将组件树的组件打平,但通过 createPortal 生成到嵌套的 DOM 树上,就同时实现了以下两点: 在 dom 结构上依然符合组件树的嵌套描述。 在 React 实例角度,没有嵌套关系。 实现分为三步: 遍历组件树,根据组件树嵌套结构生成 createPortal 的目标 dom,我们姑且称为 keepElement,对需要挂载 keepElement 的容器位置生成 dom,称为 keepContainer。对于没有渲染的容器,可以先不挂载 keepElement,而是等到父容器 mount 后再将 keepElement 移过去,后面再展开说明。 遍历组件树,一次性打平渲染所有树中 React 组件实例,并利用 createPortal 挂载到对应的 keepElement 上。 当数据流产生变化导致父级变化,或者布局插件拖动改变父级时,我们仅利用 dom api 将 keepElement 在不同的 keepContainer 之间移动,而在 React 实例视角没有发生任何变化。 协议做到用户无感知因为实现了 dom 结构与 React 实例结构分离,因此开启 keepAlive 模式不需要改变 componentTree 描述,也不会影响任何逻辑功能,我们只需要标记一下 keepAlive 参数即可开启: import { createDesigner } from 'designer'const { Designer, Canvas, useDesigner } = createDesigner()const App = () => { <Designer keepAlive={true} />} 渲染增加了额外 dom 嵌套keepAlive 模式唯一对功能产生的影响是增加了额外 dom 嵌套,分别是 keepContainer 与 keepElement,产生这两层 dom 的原因分别是: keepElement: 因为 React 实例 Remount 的作用范围是该组件自身 return 的所有虚拟 dom 最终映射的真实 dom,为了保证 React 映射 dom 与 React 树结构的对应,为了不产生 Remount 就必须要用额外的游离态 dom 作为 createPortal 的挂载节点。 keepContainer: 由于不仅要知道组件产生移动时,应该将 keepElement 移动到哪个 keepContainer 下,还需要在比如容器代码 return children 位置突然 return null 并恢复时,重新构建 keepElement,所以我们需要监听每一个 keepContainer 生命周期,所以需要额外生成一个 dom。 因此 keepAlive 模式势必会打乱原有应用的 dom 结构,新增的 dom 结构在比如流式布局时可能产生意外的定位错误,所以 keepAlive 模式尽量与绝对定位的布局方式结合。 总结keepAlive 模式可以在不改变任何协议、应用代码的情况下,解决跨父级移动导致的 Remount 问题,但这种设计也会引入新增 dom 结构的问题,只要尽量采用绝对定位的布局策略,就可以避免负面影响。 讨论地址是:精读《可视化搭建 - keepAlive 模式》· Issue ##475 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Promise","path":"/wiki/WebWeekly/TS 类型体操/《Promise.html","content":"当前期刊数: 245 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 9~16 题。 精读Promise.all实现函数 PromiseAll,输入 PromiseLike,输出 Promise<T>,其中 T 是输入的解析结果: const promiseAllTest1 = PromiseAll([1, 2, 3] as const)const promiseAllTest2 = PromiseAll([1, 2, Promise.resolve(3)] as const)const promiseAllTest3 = PromiseAll([1, 2, Promise.resolve(3)]) 该题难点不在 Promise 如何处理,而是在于 { [K in keyof T]: T[K] } 在 TS 同样适用于描述数组,这是 JS 选手无论如何也想不到的: // 本题答案declare function PromiseAll<T>(values: T): Promise<{ [K in keyof T]: T[K] extends Promise<infer U> ? U : T[K]}> 不知道是 bug 还是 feature,TS 的 { [K in keyof T]: T[K] } 能同时兼容元组、数组与对象类型。 Type Lookup实现 LookUp<T, P>,从联合类型 T 中查找 type 为 P 的项并返回: interface Cat { type: 'cat' breeds: 'Abyssinian' | 'Shorthair' | 'Curl' | 'Bengal'}interface Dog { type: 'dog' breeds: 'Hound' | 'Brittany' | 'Bulldog' | 'Boxer' color: 'brown' | 'white' | 'black'}type MyDog = LookUp<Cat | Dog, 'dog'> // expected to be `Dog` 该题比较简单,只要学会灵活使用 infer 与 extends 即可: // 本题答案type LookUp<T, P> = T extends { type: infer U} ? ( U extends P ? T : never) : never 联合类型的判断是一个个来的,所以我们只要针对每一个单独写判断就行了。上面的解法中,我们先利用 extend + infer 锁定 T 的类型是包含 type key 的对象,且将 infer U 指向了 type,所以在内部再利用三元运算符判断 U extends P ? 就能将 type 命中的类型挑出来。 笔者翻了下答案,发现还有一种更高级的解法: // 本题答案type LookUp<U extends { type: any }, T extends U['type']> = U extends { type: T } ? U : never 该解法更简洁,更完备: 在泛型处利用 extends { type: any }、extends U['type'] 直接锁定入参类型,让错误校验更早发生。 T extends U['type'] 精确缩小了参数 T 范围,可以学到的是,之前定义的泛型 U 可以直接被后面的新泛型使用。 U extends { type: T } 是一种新的思考角度。在第一个答案中,我们的思维方式是 “找到对象中 type 值进行判断”,而第二个答案直接用整个对象结构 { type: T } 判断,是更纯粹的 TS 思维。 Trim Left实现 TrimLeft<T>,将字符串左侧空格清空: type trimed = TrimLeft<' Hello World '> // expected to be 'Hello World ' 在 TS 处理这类问题只能用递归,不能用正则。比较容易想到的是下面的写法: // 本题答案type TrimLeft<T extends string> = T extends ` ${infer R}` ? TrimLeft<R> : T 即如果字符串前面包含空格,就把空格去了继续递归,否则返回字符串本身。掌握该题的关键是 infer 也能用在字符串内进行推导。 Trim实现 Trim<T>,将字符串左右两侧空格清空: type trimmed = Trim<' Hello World '> // expected to be 'Hello World' 这个问题简单的解法是,左右都 Trim 一下: // 本题答案type Trim<T extends string> = TrimLeft<TrimRight<T>>type TrimLeft<T extends string> = T extends ` ${infer R}` ? TrimLeft<R> : Ttype TrimRight<T extends string> = T extends `${infer R} ` ? TrimRight<R> : T 这个成本很低,性能也不差,因为单写 TrimLeft 与 TrimRight 都很简单。 如果不采用先 Left 后 Right 的做法,想要一次性完成,就要有一些 TS 思维了。比较笨的思路是 “如果左边有空格就切分左边,或者右边有空格就切分右边”,最后写出来一个复杂的三元表达式。比较优秀的思路是利用 TS 联合类型: // 本题答案type Trim<T extends string> = T extends ` ${infer R}` | `${infer R} ` ? Trim<R> : T extends 后面还可以跟联合类型,这样任意一个匹配都会走到 Trim<R> 递归里。这就是比较难说清楚的 TS 思维,如果没有它,你只能想到三元表达式,但一旦理解了联合类型还可以在 extends 里这么用,TS 帮你做了 N 元表达式的能力,那么写出来的代码就会非常清秀。 Capitalize实现 Capitalize<T> 将字符串第一个字母大写: type capitalized = Capitalize<'hello world'> // expected to be 'Hello world' 如果这是一道 JS 题那就简单到爆,可题目是 TS 的,我们需要再度切换为 TS 思维。 首先要知道利用基础函数 Uppercase 将单个字母转化为大写,然后配合 infer 就不用多说了: type MyCapitalize<T extends string> = T extends `${infer F}${infer U}` ? `${Uppercase<F>}${U}` : T Replace实现 TS 版函数 Replace<S, From, To>,将字符串 From 替换为 To: type replaced = Replace<'types are fun!', 'fun', 'awesome'> // expected to be 'types are awesome!' 把 From 夹在字符串中间,前后用两个 infer 推导,最后输出时前后不变,把 From 换成 To 就行了: // 本题答案type Replace<S extends string, From extends string, To extends string,> = S extends `${infer A}${From}${infer B}` ? `${A}${To}${B}` : S ReplaceAll实现 ReplaceAll<S, From, To>,将字符串 From 替换为 To: type replaced = ReplaceAll<'t y p e s', ' ', ''> // expected to be 'types' 该题与上题不同之处在于替换全部,解法肯定是递归,关键是何时递归的判断条件是什么。经过一番思考,如果 infer From 能匹配到不就说明还可以递归吗?所以加一层三元判断 From extends '' 即可: // 本题答案type ReplaceAll<S extends string, From extends string, To extends string> = From extends '' ? S : ( S extends `${infer A}${From}${infer B}` ? ( From extends '' ? `${A}${To}${B}` : `${A}${To}${ReplaceAll<B, From, To>}` ) : S ) 补充一些细节: 如果替换文本为空字符串需要跳过,否则会匹配第二个任意字符。 为了防止替换完后结果可以再度匹配,对递归形式做一下调整,下次递归直接从剩余部分开始。 Append Argument实现类型 AppendArgument<F, E>,将函数参数拓展一个: type Fn = (a: number, b: string) => numbertype Result = AppendArgument<Fn, boolean> // expected be (a: number, b: string, x: boolean) => number 该题很简单,用 infer 就行了: // 本题答案type AppendArgument<F, E> = F extends (...args: infer T) => infer R ? (...args: [...T, E]) => R : F 总结这几道题都比较简单,主要考察对 infer 和递归的熟练使用。 讨论地址是:精读《Promise.all, Replace, Type Lookup…》· Issue ##425 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Trim Right, Without, Trunc","path":"/wiki/WebWeekly/TS 类型体操/《Trim Right, Without, Trunc.html","content":"当前期刊数: 251 解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 57~62 题。 精读Trim Right实现 TrimRight 删除右侧空格: type Trimed = TrimRight<' Hello World '> // expected to be ' Hello World' 用 infer 找出空格前的字符串递归一下即可: type TrimRight<S extends string> = S extends `${infer R}${' '}` ? TrimRight<R> : S 再补上测试用例的边界情况, 与 \\t 后就是完整答案了: // 本题答案type TrimRight<S extends string> = S extends `${infer R}${' ' | ' ' | '\\t'}` ? TrimRight<R> : S Without实现 Without<T, U>,从数组 T 中移除 U 中元素: type Res = Without<[1, 2], 1> // expected to be [2]type Res1 = Without<[1, 2, 4, 1, 5], [1, 2]> // expected to be [4, 5]type Res2 = Without<[2, 3, 2, 3, 2, 3, 2, 3], [2, 3]> // expected to be [] 该题最难的点在于,参数 U 可能是字符串或字符串数组,我们要判断是否存在只能用 extends,这样就存在两个问题: 既是字符串又是数组如何判断,合在一起判断还是分开判断? [1] extends [1, 2] 为假,数组模式如何判断? 可以用数组转 Union 的方式解决该问题: type ToUnion<T> = T extends any[] ? T[number] : T 这样无论是数字还是数组,都会转成联合类型,而联合类型很方便判断 extends 包含关系: // 本题答案type Without<T, U> = T extends [infer H, ...infer R] ? H extends ToUnion<U> ? Without<R, U> : [H, ...Without<R, U>] : [] 每次取数组第一项,判断是否被 U 包含,是的话就丢弃(丢弃的动作是把 H 抛弃继续递归),否则包含(包含的动作是形成新的数组 [H, ...] 并把递归内容解构塞到后面)。 Trunc实现 Math.trunc 相同功能的函数 Trunc: type A = Trunc<12.34> // 12 如果入参是字符串就很简单了: type Trunc<T> = T extends `${infer H}.${infer R}` ? H : '' 如果不是字符串,将其转换为字符串即可: // 本题答案type Trunc<T extends string | number> = `${T}` extends `${infer H}.${infer R}` ? H : `${T}` IndexOf实现 IndexOf 寻找元素所在下标,找不到返回 -1: type Res = IndexOf<[1, 2, 3], 2>; // expected to be 1type Res1 = IndexOf<[2,6, 3,8,4,1,7, 3,9], 3>; // expected to be 2type Res2 = IndexOf<[0, 0, 0], 2>; // expected to be -1 需要用一个辅助变量存储命中下标,递归的方式一个个判断是否匹配: type IndexOf<T, U, Index extends any[] = []> = T extends [infer F, ...infer R] ? F extends U ? Index['length'] : IndexOf<R, U, [...Index, 0]> : -1 但没有通过测试用例 IndexOf<[string, 1, number, 'a'], number>,原因是 1 extends number 结果为真,所以我们要换成 Equal 函数判断相等: // 本题答案type IndexOf<T, U, Index extends any[] = []> = T extends [infer F, ...infer R] ? Equal<F, U> extends true ? Index['length'] : IndexOf<R, U, [...Index, 0]> : -1 Join实现 TS 版 Join<T, P>: type Res = Join<["a", "p", "p", "l", "e"], "-">; // expected to be 'a-p-p-l-e'type Res1 = Join<["Hello", "World"], " ">; // expected to be 'Hello World'type Res2 = Join<["2", "2", "2"], 1>; // expected to be '21212'type Res3 = Join<["o"], "u">; // expected to be 'o' 递归 T 每次拿第一个元素,再使用一个辅助字符串存储答案,拼接起来即可: // 本题答案type Join<T, U extends string | number> = T extends [infer F extends string, ...infer R extends string[]] ? R['length'] extends 0 ? F : `${F}${U}${Join<R, U>}` : '' 唯一要注意的是处理到最后一项时,不要再追加 U 了,可以通过 R['length'] extends 0 来判断。 LastIndexOf实现 LastIndexOf 寻找最后一个匹配的下标: type Res1 = LastIndexOf<[1, 2, 3, 2, 1], 2> // 3type Res2 = LastIndexOf<[0, 0, 0], 2> // -1 和 IndexOf 类似,从最后一个下标往前判断即可。需要注意的是,我们无法用常规办法把 Index 下标减一,但好在 R 数组长度可以代替当前下标: // 本题答案type LastIndexOf<T, U> = T extends [...infer R, infer L] ? Equal<L, U> extends true ? R['length'] : LastIndexOf<R, U> : -1 总结本周六道题都没有刷到新知识点,中等难题还剩 6 道,如果学到这里能有种索然无味的感觉,说明前面学习的很扎实。 讨论地址是:精读《Trim Right, Without, Trunc…》· Issue ##433 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"可视化搭建内置 API","path":"/wiki/WebWeekly/可视化搭建/可视化搭建内置 API.html","content":"当前期刊数: 271 在设计好画布与组件数据流体系后,理论上主体功能已经完成,但缺乏方便易用的 API,所以还需要内置一些状态与方法。 但是内置状态与方法必须寻求业务的最大公约数,极具抽象性,添加需慎重。 接下来我们从必须有与建议有的角度,看看一个可视化搭建需要内置哪些 API。 状态状态是可变的,引用方式有如下两种。 第一种在任意 React 组件内通过 useDesigner 访问,当状态变化时会触发所在组件重渲染: const { componentTree } = useDesigner((state) => ({ componentTree: state.componentTree,})); 第二种在任意组件元信息内通过 selector 访问,当状态变化时会触发不同行为,比如在 runtimeProps 会触发组件重渲染,在 fetcher 会触发重新查询: const tableMeta = { /** ... */ runtimeProps: ({ selector }) => { const { componentTree } = selector(({ state }) => ({ componentTree: state.componentTree, })); return { componentTree }; },}; componentTree 评价:必须有 类型:ComponentInstance 描述完整组件树 JSON 结构。在非受控模式下,组件树就存储在 <Designer /> 实例内部,而受控模式下,组件树存储在外部状态。 但我们允许这两种模式都可以访问此状态,这样在开发可视化搭建应用的过程中,就不用关心受控或非受控模式了,即一套代码同时兼容受控与非受控模式。 selectedComponentIds 评价:建议有 类型:string[] 定义当前选中组件实例 id 列表。 虽然这个状态业务也可以定义,但选中组件在可视化搭建是一种常见行为,以后定义插件、自定义组件也许都会读取当前选中的组件,如果框架定义了此通用 key,那么插件和自定义组件就可无缝结合到任意业务代码里。反之如果在业务层定义该状态,插件或者自定义组件也不知道如何标准的读取到当前选中的组件。 canUndo, canRedo 评价:建议有 类型:boolean 描述当前状态是否能撤销或重做。 该状态需要结合内置方法 undo() redo() 一起提供,属于 “有了更好” 的状态。但有时候也会产生困扰,比如你的应用分了多个 sheet,每个 sheet 内是一个画布实例,而你希望撤销重做可以跨 sheet,那就不适合用单实例提供的方法了。 方法状态引用不可变,引用方式有如下两种。 第一种在任意 React 组件内通过 useDesigner 访问,它不会变化,因此不会导致组件重渲染: const { addComponent } = useDesigner(); 第二种在任意组件元信息内通过回调访问: const tableMeta = { /** ... */ runtimeProps: ({ addComponent }) => {},}; getState() 评价:必须有 类型:() => State 获取应用全部状态,包括内置与业务自定义。 setState() 评价:必须有 类型:(state: State) => void 更新应用全部状态,包括内置与业务自定义。 getComponentTree() 评价:必须有 类型:() => ComponentInstance 返回当前组件树。 并不是有了 componentTree 状态就万事大吉了,很多回调函数并不依赖组件树重渲染,而仅仅在触发时获取其瞬时值必须调用此方法。 虽然该方法一定程度上可以用 getState().componentTree 代替,但组件树概念太重要了,以至于单独定义一个方法不会增加理解成本。另外在受控模式下,getState().componentTree 不一定等价于 getComponentTree(),因为前者是从 <Designer /> 拿组件树,而后者直接请求外部状态最新的组件树,当组件树受控模式没有及时触发渲染同步时,后者值会比前者更新。 setComponentTree() 评价:必须有 类型:(callback: (now: ComponentInstance) => ComponentInstance) => boolean 更新当前组件树。 在非受控模式下等价于 setState() 修改 componentTree,但在非受控模式下,会直接透传到外部状态,直接修改一手组件树,因此极端情况下表现更稳定。 addComponent() 评价:必须有 类型 (componentInstance, parentIdPath?, index?, position?) => void 添加组件实例。 基于 setComponentTree() 实现,但因为其太常见且意图较为复杂,抽成一个独立函数还是很有必要的。 componentInstance 必选,默认把组件实例添加到根节点的 children 位置。 parentIdPath 可选,描述要添加到的父节点 ID,当父节点没定义组件 ID 时,也可以用例如 children.0 这种组件树路径代替,所以名称不叫 parentId,而是 parentIdPath。 index 可选,描述要添加到父节点子元素下标,比如添加到 children 的第几项。 position 可选,描述要添加到父节点 children 还是 props.header 等位置,毕竟组件实例并不只有 children 一个地方。 deleteComponent() 评价:必须有 类型:(componentIdPath: string) => boolean 删除组件实例。 基于 setComponentTree() 实现,但同理太常用,所以单独提供。 这里还有个细节,就是 componentIdPath 指可传组件 ID,也可传组件树路径,而真正删除肯定要从树上删,框架内部为了快速从组件 ID 定位到 treePath,维护了一个映射表,因此使用该函数无论何时都是 O(1) 的时间复杂度。 getComponent() 评价:必须有 类型:(componentIdPath: string) => ComponentInstance 查询组件实例。 基于 getComponentTree() 实现。“增删” 都有了,“查” 还能没有吗? setComponent() 评价:必须有 类型:(componentIdPath, callback) => boolean 修改组件实例。 基于 setComponentTree() 实现,“增改查” 都有了,就差一个 “改” 了。 setProps() 评价:建议有 类型:(componentIdPath, callback) => boolean 修改组件实例的 props。 基于 setComponent() 实现,因为修改组件 props 属性比修改整个组件实例常见,建议实现。 getProps() 评价:建议有 类型:(componentIdPath) => any 获取组件实例的 props。 基于 getComponent() 实现,同理,调用可能比 getComponent() 更常见,因此建议实现。 getComponents() 评价:建议有 类型:() => ComponentInstance[] 获取全量组件实例数组。 因为组件树是树状结构,业务除了用递归方式遍历外,还可以提供这种获取打平形式的组件树以备不时之需。 getParentId() 评价:必须有 类型:(componentIdPath: string) => string 获取组件的父组件 ID。 以为 componentTree 为树状结构,所以直接从组件实例上找不到父节点,因此提供一个快速找父节点的函数是非常必要的。 当然框架内部实现寻找父节点肯定不会用遍历,而是提前解析组件树时就建立好关联映射表,所有内置方法时间复杂度都是 O(1) 的。 getParentBy() 评价:建议有 类型:(componentIdPath: string, finder: (parent: ComponentInstance) => boolean) => string 一直向上寻找父节点,直到找到为止。 基于 getParentId() 实现,方便业务向上寻找符合条件的父节点。 setParent() 评价:必须有 类型:(componentIdPath, parentIdPath, index, position) => boolean 调整某个组件的父节点。参数和 addComponent() 很像,只是把第一个从组件实例改为了组件 ID,参数含义相同。 当画布涉及组件跨父节点移动时,这个方法就显得很关键了,虽然底层也是基于 setComponentTree 实现的。一个比较复杂的场景是,当组件跨节点移动时,在组件树上操作还是比较复杂的,因为移除 + 添加无论先做哪个,都会导致组件树变化,从而导致后一个操作位置可能错误。如果每次都重新寻址性能会较差,如果想用聪明的方法绕过,逻辑还是比较复杂的,因此有必要内置该方法。 setComponentMeta() 评价:必须有 类型:(componentName: string, componentMeta: ComponentMeta) => void 更新组件元信息。 提供这个方法其实对框架的挑战比较大,在提供很多生命周期的情况下,随时可能发生组件实例的更新,要保证整体逻辑符合预期,需要仔细设计一下。 getComponentMeta() 评价:必须有 类型:(componentName: string) => ComponentMeta 获取组件元信息。 既然可以注册组件元信息,就可以获取它。注意通过 <Designer /> 受控或者非受控模式注册,或者直接调用 setComponentMeta 注册的组件元信息都应该可以正常获取到。 getComponentMetas() 评价:建议有 类型:() => ComponentMeta[] 批量获取所有已注册的组件元信息。 说不定业务会有什么特别的用途,建议提供。 clearComponentMetas() 评价:建议有 类型:() => void 清空所有组件元信息。 说不定业务会有什么特别的用途,建议提供。 setSelectedComponentIds() 评价:建议有 类型:(ids: string[]) => void 修改内置状态 selectedComponentIds。 如果你提供了 selectedComponentIds 这个内置状态,那提供对应的修改方法就是强烈建议了。虽然也可通过 setState() 更新 selectedComponentIds Key 来实现。 getTreePath() 评价:建议有 类型:(componentIdPath: string) => string 根据组件 ID 查找在组件树上的路径。 也许业务想要自己操作组件树,那么框架提供根据组件 ID 找到组件树路径的方法就挺合适。 undo(), redo() 评价:建议有 类型:() => void 撤销,重做。 如果提供了 canUndo、canRedo 内置状态,那么一定要提供 undo()、redo() 内置函数。 getMergedProps() 评价:建议有 类型:(componentIdPath: string) => any 返回组件最终混合后的 props。 由于组件 props 可能来自组件树,也可能来自 runtimeProps,为了防止傻傻分不清,因此规定 getProps() 仅获取组件树上序列化的 props,而 getMergedProps() 获取了包含 runtimeProps 处理后的最终 props。 getComponentDom() 评价:建议有 类型:(componentIdPath: string) => HTMLElement 根据组件 ID 获取 DOM 实例。 框架最好通过一些技巧,让组件即便不用 forwardRef 也能拿到 DOM,那么组件只要存在 DOM,就可以通过该方法拿到,非常方便。 afterDomRender() 评价:建议有 类型:(componentIdPath: string, callback: () => void) => Promise 当组件 ID 的 DOM 实例挂载后,执行 callback。 因为组件 DOM 依赖渲染,所以不能保证 getComponentDom 时 DOM 真的完成了渲染,因此可以将时机放在 afterDomRender() 后,保证一定可以拿到 DOM。 总结这一章我们设计了内置 API,设计思路总结如下: 从组件树这个核心概念散开,设置了必要的 API,以及一些逻辑复杂,或者使用很方便的推荐 API。 虽然组件树是树状结构,但内置 API 需要考虑易用性,所有操作都以组件 ID 作为参数,在内部实现时转化为操作组件树,并内置好 O(1) 时间复杂度的优化措施。 核心 API 只有寥寥几个,其余 API 都以便利性为目的提供,且都以核心 API 为基础实现,这样框架核心会更稳定,框架大部分 API 只是一种实现规则,业务利用核心 API 拥有更大的实现自由。 讨论地址是:精读《可视化搭建内置 API》· Issue ##467 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"场景实战","path":"/wiki/WebWeekly/可视化搭建/场景实战.html","content":"当前期刊数: 280 接下来用实战来说明该可视化搭建框架是否好用,以下几条原则需要始终贯穿在下面每个实战场景中: 复杂的业务场景,背后使用的框架 API 是简单的。 底层 API 并不为业务场景特殊编写,而是具有很强的抽象性,很容易挖掘出其他业务场景的用法。 所有场景都是基于有限的几条基础规则实现,即背后实现的复杂度不随着业务场景复杂度提升而提升。 上卷下钻上卷下钻其实是 组件作用于自身的筛选。 所以上卷下钻背后的实现原理应该与筛选、联动一样。利用 setValue 在点击下钻按钮时,修改组件自己的 value,然后通过 valueRelates 让该组件的联动作用于自身,剩下的逻辑就和普通筛选、联动没有太多区别了,区别仅仅是联动触发源是自己: import { ComponentMeta } from "designer";const chart: ComponentMeta = { componentName: "chart", element: Chart, // 利用 runtimeProps 将组件 value 映射到 props.value,将 props.onChange 映射为 setValue 修改自身 value runtimeProps: ({ selector, setValue, componentId }) => ({ value: selector(({ value }) => value), onChange: (value: string) => setValue(componentId, value), }), // 自己联动自己 valueRelates: ({ componentId }) => [ { sourceComponentId: componentId, targetComponentId: componentId, }, ], fetcher: ({ selector }) => { // relates 可能来自自己、其他筛选器组件实例,或者其他图表组件实例 const relates = selector(({ relates }) => relates); // 根据 relates 下钻 ... },}; 上卷下钻就是作用于自身的联动。 Tabs 组件利用组件树解析规则,我们任意找一个 Key 存放每个 TabPanel 的子元素就可以了。 我们利用 props.tabs 存放 tabs 配置,props.content 存放每项 TabPanel 的子组件,因为其顺序永远和 props.tabs 保持一致,我们可以简单的使用下标匹配。 const tabs = { componentName: "tabs", element: TabsComponent, defaultProps: { // 存放 tabPanel 配置 tabs: [ { title: "tab1", key: "1", }, ], // 存放每个 tabPanel 内子画布的组件实例 content: [ { componentName: "gridLayout", }, ], },}; 而 TabsComponent 组件实现就完全与平台解耦了,即使用 props.tabs 与 props.content 渲染即可: const TabsComponent = ({ content, handleAddTab, handleDeleteTab, tabs }) => ( <Tabs editable defaultActiveTab="1" onAddTab={handleAddTab} onDeleteTab={handleDeleteTab} > {tabs.map((tab, index) => ( <TabPane key={tab.key} title={tab.title}> {content[index]} </TabPane> ))} </Tabs>); tabs 使用 treeLike 结构,按照下标存储组件实例。 富文本内嵌组件实例与 tabs 很像,区别是富文本内嵌入的组件实例数量是不固定的,每一个组件实例都对应富文本某个 block id. 下面是富文本实现代码的一部分: const SomeRichTextLibrary = (props) => { // 自定义渲染 block 槽位 const RenderCustomBlock = useCallback( (blockId: string) => { // 渲染组件实例 return props.blockElements.find( (componentInstance) => componentInstance.componentId === blockId ); }, [props.blockElements] );}; 富文本一般拥有自定义 block 区块的能力,我们只要将 block id 与组件实例 id 绑定,然后将组件实例存储在 props.blockElements,就可以轻松匹配到对应组件实例了。 其中 props.blockElements 的结构如下: { "blockElements": [ { "componentId": "block1", "componentName": "chart" }, { "componentId": "block2", "componentName": "radar" } ]} 富文本的结构可能如下: { "type": "rich_text", "content": [ { "type": "paragraph", "text": "This is a paragraph of rich text." }, { "type": "heading", "level": 2, "text": "This is a heading" }, { "type": "block", "blockId": "block1" }, { "type": "block", "blockId": "block2" } ]} 最后两个 block 是自定义区块,通过自定义 RenderCustomBlock 来渲染,我们正好可以通过 blockId 对应到 componentId,在 props.blockElements 中找到。 富文本的实现思路和 tabs 基本一样,只是查找组件实例的逻辑不同。 实现任意协议我们也许为了进一步抽象,或对指定业务场景降低配置门槛,在组件树拓展一些额外的 json 结构协议做一些特定功能。 以拓展事件配置为例,假如我们需要实现如下协议:每个组件实例信息上拓展了 events 属性,通过配置这个属性可以实现一些内置动作,如打开 Modal。这个协议至少要定义触发源是什么 trigger、做什么事情 type 以及作用的目标组件 targetId: { "componentName": "button", "events": [ { "trigger": "onClick", "type": "openModal", "targetId": "123" } ]} 如上面的例子,只要定义好触发源、类型和目标组件,就可以在按钮组件 onClick 时将目标组件 visible 设为 true,实现弹出 Modal 的效果。 实现思路是,利用 onReadComponentMeta,在所有组件的元信息做拓展。比如要拓展这种事件,一般 Trigger 都要绑定在组件 Props 的回调上(如果是全局监听,可以绑定在全局并利用事件机制通信给组件),那就可以通过 runtimeProps 进行绑定: const App = () => ( <Designer onReadComponentMeta={(meta) => ({ ...meta, runtimeProps: (options) => { const result = meta.runtimeProps?.(options) ?? {}; const events = options.selector( ({ componentInstance }) => componentInstance.events ); events?.forEach((event) => { switch (event.type) { case "openModal": // 给组件添加新的 trigger 绑定 result[event.trigger] = options.setRuntimeProps( event.targetId, (props) => ({ ...props, visible: true, }) ); break; } }); return result; }, })} />); 除此之外,我们还可以想象有更多的协议可以通过这种方式处理响应,无论何种协议,背后都是基于组件元信息的实现,易懂且单测有保障。 总结本文我们总结了三个场景实战: 利用 treeLike 结构在组件内渲染任意数量的子组件实例,如 tabs 或富文本。 利用组件联动的 API,实现筛选、联动以及上卷下钻。 利用 onReadComponentMeta 为所有组件元信息统一增加逻辑,用来解读如 props 属性中定义的某些规则,进而实现任意协议。 讨论地址是:精读《可视化搭建 - 场景实战》· Issue ##485 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"如何抽象可视化搭建","path":"/wiki/WebWeekly/可视化搭建/如何抽象可视化搭建.html","content":"当前期刊数: 268 在做任何可视化搭建项目时,第一步都要思考如何抽象。 如果不抽象,当搭建项目做到后期可能会出现 API 杂乱,难以维护的问题;做到一半甚至会怀疑为什么需要一个搭建框架,怀疑把框架去掉会不会效率更高;在后期发现不能自然的水平拓展到仪表盘、大屏、表单搭建场景等。 所以如果在维护一套可视化搭建系统时,不管这个系统的上层是 BI、大屏、表单填报,还是脑图也好,无论是什么,都要先思考一下这些系统背后的底层是什么,需不需要抽象,抽象的意义和价值在哪。 以下结合笔者的经验,尝试给出一种思考角度。 精读什么是可视化搭建表单搭建、中后台应用搭建、BI 仪表盘搭建、大屏搭建都算可视化搭建,因为它们都是在一个画布上拖拖拽拽完成的。 那么组件配置表单算搭建吗?聚焦单组件分析的可视化探索呢?幻灯片呢? 比如组件配置表单,它基于 UI 组件树抽象的话,就是可视化搭建,但如果基于表单结构抽象,就是 JsonSchema,但真的所有业务场景都是数据完全映射 UI 吗?不一定,因为 UI 可以为了用户操作方便而加入更多辅助元素,甚至把一个属性拆成多个 UI 填写,所以基于可视化搭建,也就是 UI 组件树抽象的一定可以覆盖所有表单场景,但不一定是描述效率最高的方式。 如果每种可视化搭建场景都定义一套协议与实现,那按照搭建平台的复杂度,想同时维护两个类搭建平台的成本一定是两倍,而且不同维护人员很难交流。又或者某些可以按照搭建思路解决的场景,因为实现时经验不足,没有进行抽象,甚至进行了另一套定制抽象,回过头来看可能积重难返,团队不得不接受多套笨重实现的现状。 所以建议将这些场景都视为可视化搭建场景,用一套接口描述结构、API 方法,让看似百花齐放的编辑器之下拥有统一的上下文与实现。 可视化搭建的分层对于不同种类的可视化搭建平台,我们尝试寻找其分层设计的最大公约数。如果把可视化搭建底层设定为逻辑层,即这个层是 UI 无关的,仅关心组件树结构、逻辑功能,那么对于每种平台的分层应该是这样的: 表单搭建:逻辑层、表单联动协议层、表单控件、业务层。 中后台应用搭建:逻辑层、应用联动协议层、应用控件、业务层。 BI 仪表盘:逻辑层、筛选联动协议层、可视化控件、业务层。 大屏搭建:逻辑层、画布编辑控制器层、可视化控件和基础图形控件、业务层。 最底层的逻辑层应该可以统一所有类型搭建系统,并成为开发人员统一上下文的。它可以包含以下基础能力: 定义组件树结构。 定义组件元信息。 按照组件树结构递归渲染画布。 支持布局、取数、联动、筛选、校验等一系列拓展能力,业务可根据需要定制。 提供所有业务层都需要的能力,比如性能优化的组件冻结、状态管理、对组件树增删改查的 API。 在逻辑层完备后,再开发上层应用就会轻松很多,只要注册组件、根据业务需要在组件树初始化或组件初始化,或组件元信息注册时添加定制逻辑,与系统功能对接,并补充业务特色的如自定义布局能力,这样就可以用简单的三言两语说清楚整个系统是如何设计的。 逻辑层存在的必要性再回到问题的根源:对逻辑层做统一的抽象到底是不是多余的? 要回答这个问题,需要先了解我们手头里有哪些工具:基础开发工具 html、js、css,并且 html 也提供了一套标准化的 xml 结构;vue、react 等开发框架,基础组件、应用生命周期与事件定义。理论上基于这些,我们就可以直接上手写一个可视化搭建平台了,似乎也可以不抽象。但真正要上手时,一定会遇到以下几个通用问题需要处理: 定义组件树结构 无论做表单搭建、报表搭建、大屏搭建还是脑图画布,第一个想到的肯定是如何描述这个画布结构,而无论画布是横着排还是竖着排,横竖都是一棵树。HTML 树不能直接搬过来,一是 HTML 树的完整结构太大而我们需要的更精简的结构,二是业务层框架一般都先有一套虚拟树再转化为 dom 树,因果关系也没法反过来。而这棵树也完全可以做最大程度的抽象,即定义组件 ID、组件名、属性(Props)、子节点。 定义对组件树增删改查函数 有了组件树肯定需要对其进行增删改查操作,因为无法基于 document API,上层框架如 vue、react 也不提供对任何标准组件树的增删改查 API,这部分能力势必要手动实现。 生命周期 假设完全依赖 React 框架提供的组件生命周期,是可以完成大部分业务逻辑,但这意味着定义不够精细化。比方说,我们在组件 Mount 的实际监听了联动、实现取数、设置冻结等等效果,虽然也可以实现,但会遇到要不要抽象的问题: 如果不抽象,业务代码就会乱糟糟的,比较难读。 如果抽象,就要把联动、取数、冻结等等模块归类,封装成函数,甚至可以提供主动调用机制,UI 与逻辑解耦,但当业务层精细的去做这件事就会发现,这就是在做框架层的抽象工作,所以还不如一开始就把这些生命周期抽象到框架里。 逻辑层有两个核心结构,第一个是组件树结构,包含了对每个组件实例的定义;第二个是组件元信息结构,包含了对每个组件的元信息描述,大概如下图所示: 逻辑层的难点就是在元信息定义足够多、足够通用的生命周期回调函数,并且这些回调函数还能尽可能的功能正交。 组件渲染 通常一棵树按照 json 结构描述自顶向下自动渲染就可以了,但也有一些时候,比如内嵌一个富文本组件,而富文本内又嵌入一些画布组件,这些组件需要像普通画布组件一样可交互,此时就有 渲染一个不存在于组件树的组件实例 的需求,而这样的动态组件又要无感知的满足上面所说的各类生命周期,这也是不小的工作量。 功能的拓展抽象 等可视化搭建平台正式维护时,就至少会遇到组件版本升级、不同类型的布局方案对接、三方组件注册等需求,这些功能如何加入到现有的搭建平台,而不让其他功能感知,是需要精心设计的。如果逻辑层把这一点抽象好,在每个功能设计一个钩子,实现一个功能时无需感知其他功能,那平台的功能拓展就会保持一个恒定的速度,不随功能增加而变得难以维护。 可见,可视化搭建不断迭代的过程就是自身不断抽象的过程,逻辑层实现的好坏直接影响到后期的维护性与拓展性,所以好好设计逻辑层可以让开发事半功倍。 组件配置表单要不要用搭建方案做组件配置直接用表单方案而不是搭建,似乎是最容易想到的。但当每个组件都要自定义配置,我们就不得不选择基于 JsonSchema 描述的表单方案,但这与搭建应用本身的技术栈割裂了,随着联动功能的要求越来越多,会越来越发现小小的表单渲染引擎维护得越来越复杂,甚至复杂度与画布不分上下,此时再叹息两边技术栈不统一就已经晚了。 换个角度想一下,搭建应用不也要考虑组件间联动吗?从表单值能力来看,搭建场景并不要求每个组件都拥有一个值,反倒是可以将组件任意 props 属性看作表单值更具有 “弹性”,我们可以拓展任意 Key 作为表单值。 另外,从数据结构触发来描述表单看似很美好,但当表单变得越来越复杂,UI 越来越定制后,势必引入新的 UI 节点或者新的结构描述,与其后期拓展到一个不纯净的 JsonSchema 结构,不如一开始就放弃这个幻想,用 UI 组件树结构描述表单,这样事情就变得简单了:“先描述组件树,再定义每个节点分别用什么组件渲染,响应表单的哪部分 Key”。 总结总结一下,回到主题,抽象可视化搭建的方法是分层:以逻辑层打底,提供一套标准规范与 API 接口,上层注册组件、实现布局,一切围绕着标准化的逻辑层进行拓展。 而可视化搭建的每一层都可以分别写单元测试,保证最终变化的代码只有业务层的对接部分,应用的稳定性就提高了。 最后提一个思考题:你是觉得可视化搭建应该如何抽象?如果想要做到每一层独立正交,你会如何设计 API 呢? 讨论地址是:精读《如何抽象可视化搭建》· Issue ##463 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"定义联动协议","path":"/wiki/WebWeekly/可视化搭建/定义联动协议.html","content":"当前期刊数: 274 虽然底层框架提供了通用的组件值与联动配置,可以建立对组件任意 props 的映射,但这只是一个能力,还不是协议。 业务层是可以确定一个协议的,还要让这个协议具有拓展性。 我们先从使用者角度设计 API,再看看如何根据已有的组件值与联动能力去实现。 设计联动协议首先,不同的业务方会定义不同的联动协议,因此该联动协议需要通过拓展的方式注入: import { createDesigner } from 'designer'import { onReadComponentMeta } from 'linkage-protocol'return <Designer onReadComponentMeta={onReadComponentMeta} /> 首先可视化搭建框架支持 onReadComponentMeta 属性,用于拓展所有已注册的组件元信息,而联动协议的拓展就是基于组件值与组件联动能力的,因此这种是最合理的拓展方式。 之后我们就注册了一个固定的联动协议,它形如下: { "componentName": "input", "linkage": [{ "target": "input1", "do": { "value": "{{ $self.value + 'hello' }}" } }]} 只要在组件实例上定义 linkage 属性,就可以生效联动。比如上面的例子: target: 联动目标。 do: 联动效果,比如该例子为,组件 ID 为 input1 的组件,组件值同步为当前组件实例的组件值 + 'hello'。 $self: 描述自己实例,比如可以从 $self.value 拿到自己的组件值,从 $self.props 拿到自己的 props。 更近一步,target 还可以支持数组,就表示同时对多个组件生效相同规则。 我们还可以支持更复杂的语法,比如让该组件可以同步其他组件值: { "componentName": "input", "linkage": [{ "deps": ["input1", "input2"] "props": { "text": "{{ $deps[0].value + deps[1].value }}" } }]} 上面的例子表示,该组件实例的 props.text 同步为 input1 + input2 的组件值: deps: 描述依赖列表,每个依赖实例都可以在表达式里用 $deps[] 访问到,比如 $deps[0].props 可以访问组件 ID 为 input1 组件的 props。 props: 同步组件的 props。 如果定义了 target 则作用于目标组件,未定义 target 则作用于自身。但无论如何,表达式的 $self 都指向自己实例。 总结一下,该联动协议允许组件实例实现以下效果: 设定组件值、组件 props 的联动效果。 可以将自己的组件值同步给组件实例,也可以将其他组件值同步给自己。 基本上,可以满足任意组件联动到任意组件的诉求。而且甚至支持组件间传递,比如 A 组件的组件值同步组件 B, B 组件的组件值同步组件 C,那么 A 组件 setValue() 后,组件 B 和 组件 C 的组件值会同时更新。 实现联动协议以上联动协议只是一种实现,我们可以基于组件值与组件联动设定任意协议,因此实现联动协议的思维具备通用性,但为了方便,我们以上面说的这个协议为例子,说明如何用可视化搭建框架的基础功能实现协议。 首先解读组件实例的 linkage 属性,将联动定义转化为组件联动关系,因为联动协议本质上就是产生了组件联动。接下来代码片段比较长,因此会尽量使用代码注释来解释: const extendMeta = { // 定义 valueRelates 关系,就是我们上一节提到的定义组件联动关系的 key valueRelates: ({ componentId, selector }) => { // 利用 selector 读取组件实例 linkage 属性 // 由于 selector 的特性,会实时更新,因此联动协议变化后,联动状态也会实时更新 const linkage = selector(({ componentInstance }) => componentInstance.linkage) // 返回联动数组,结构: [{ sourceComponentId, targetComponentId, payload }] return linkage.map(relation => { const result = []; // 定义此类联动类型,就叫做 simpleRelation const payload = { type: 'simpleRelation', do: JSON.parse( JSON.stringify(relation.do) // 将 $deps[index] 替换为 $deps[componentId] .replace( /\\$deps\\[([0-9]+)\\]/g, (match: string, index: string) => `$deps['${relation.deps[Number(index)]}']`, ) // 将 $self 替换为 $deps[componentId] .replace(/\\$self/g, () => `$deps['${componentId}']`), ), }; // 经过上面的代码,表达式里无论是 $self. 还是 $deps[0]. 都转化为了 // $deps[componentId] 这个具体组件 ID,这样后面处理流程会简单而统一 // 读取 deps,并定义 dep 组件作为 source,target 作为目标组件 // 这是最关键的一步,将 dep -> target 关系绑定上 relation.target.forEach((targetComponentId) => { if (relation.deps) { relation.deps.forEach((depIdPath: string) => { result.push({ sourceComponentId: depIdPath, targetComponentId, }); }); } // 定义自己到 target 目标组件的联动关系 result.push({ sourceComponentId: componentId, targetComponentId, payload, }); }); return result; }).flat() }} 上述代码利用 valueRelates,将联动协议的关联关系提取出来,转化为值联动关系。 接着,我们要实现 props 同步功能,实现这个功能自然是利用 runtimeProps 以及 selector.relates,将关联到当前组件的组件值,按照联动协议的表达式执行,并更新到对应 key 上,下面是大致实现思路: const extendMeta = { runtimeProps: ({ componentId, selector, getProps, getMergedProps }) => { // 拿到作用于自己的值关联信息: relates const relates = selector(({ relates }) => relates); // 记录最终因为值联动而影响的 props let relationProps: any = {}; // 记录关联到自己的组件此时组件值 const $deps = relates?.reduce( (result, next) => ({ ...result, [next.componentId]: { value: next.value, }, }), {}, ); // 为了让每个依赖变化都能生效,多对一每一项 do 都带过来了,需要按照 relationIndex 先去重 relates .filter((relate) => relate.payload?.type === 'simpleRelation') .forEach((relate) => { const expressionArgs = { // $deps[].value 指向依赖的 value $deps, get, getProps: relate.componentId === componentId ? getProps : getMergedProps, }; // 处理 props 联动 if (isObject(relate.payload?.do?.props)) { Object.keys(relate.payload?.do?.props).forEach((propsKey) => { relationProps = set( propsKey, selector( () => // 这个函数是关键,传入组件 props 与表达式,返回新的 props 值 getExpressionResult( get(propsKey, relate.payload?.do?.props), expressionArgs, ), { compare: equals, // 根据表达式数量可能不同,所以不启用缓存 cache: false, }, ), relationProps, ); }); } }); return relationProps }} 其中比较复杂函数就是 getExpressionResult,它要解析表达式并执行,原理就是利用代码沙盒执行字符串函数,并利用正则替换变量名以匹配上下文中的变量,大致代码如下: // 代码执行沙盒,传入字符串 js 函数,利用 new Function 执行function sandBox(code: string) { // with 是关键,利用 with 定制代码执行的上下文 const withStr = `with(obj) { ${code} }`; const fun = new Function('obj', withStr); return function (obj: any) { return fun(obj); };}// 获取沙盒代码执行结果,可以传入参数覆盖沙盒内上下文function getSandBoxReturnValue(code: string, args = {}) { try { return sandBox(code)(args); } catch (error) { // eslint-disable-next-line no-console console.warn(error); }}// 如果对象是字符串则直接返回,是 {{}} 表达式则执行后返回function getExpressionResult(code: string, args = {}) { if (code.startsWith('{{') && code.endsWith('}}')) { // {{}} 内的表达式 let codeContent = code.slice(2, code.length - 2); // 将形如 $deps['id'].props.a.b.c // 转换为 get('a.b.c', getProps('id')) codeContent = codeContent.replace( /\\$deps\\[['"]([a-zA-Z0-9]*)['"]\\]\\.props\\.([a-zA-Z0-9.]*)/g, (str: string, componentId: string, propsKeyPath: string) => { return `get('${propsKeyPath}', getProps('${componentId}'))`; }, ); return getSandBoxReturnValue(`return ${codeContent}`, args); } return code;} 其中 with 是沙盒执行时替换代码上下文的关键。 总结componentMeta.valueRelates 与 componentMeta.runtimeProps 可以灵活的定义组件联动关系,与更新组件 props,利用这两个声明式 API,甚至可以实现组件联动协议。总结一下,包含以下几个关键点: 将 deps 和 target 利用 valueRelates 转化为组件值关联关系。 将联动协议定义的相对关系(比较容易写于容易记)转化为绝对关系(利用 componentId 定位),方便框架处理。 利用 with 执行表达式上下文。 利用 runtimeProps + selector 实现注入组件 props 与响应联动值 relates 变化,从而实现按需联动。 讨论地址是:精读《定义联动协议》· Issue ##471 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"容器组件设计","path":"/wiki/WebWeekly/可视化搭建/容器组件设计.html","content":"当前期刊数: 272 可视化搭建会遇到如下三类容器组件: 简单容器:以 children 容纳子组件的容器。 卡片容器:以 props.header 加上 props.header 等多个插槽容纳子组件的容器。 Tab 容器:以 props.tabPanel[x] 等动态数量插槽容纳子组件的容器。 画布本身也是一个容器组件,所以可视化搭建离不开容器。 另一方面,我们应该允许给组件 props 传入 React 组件实例,但组件树是可序列化的 JSON 结构,因此需要一种定义方式,将某些属性转化为 React 组件实例传给组件实例。 容器的定义任何组件都可能是容器组件,只要它将 props.children 或 props.footer 等任何属性作为 ReactNode 渲染。因此我们不需要特殊声明组件是否为容器,而仅需将某些组件 Key 声明为 ReactNode 节点。 Childrenchildren 因为太常用因此单独强调出来,可以只在组件实例定义 children 属性,它是一个数组: import { ComponentInstance } from "designer";const componentTree: ComponentInstance = { componentName: "div", children: [ { componentName: "input", }, ],}; 对于这个组件,Designer 会将 children 定义的属性理解为组件实例,并真正解析为 React 实例传递给 props.children,因此组件渲染代码可以直接使用 children 渲染: import { ComponentMeta } from "designer";const divMeta: ComponentMeta = { componentName: "div", element: ({ children }) => <div>{children}</div>,}; 这种约定的好处是直观自然,组件代码也没有关心到框架逻辑,自然而然实现了容器功能。 treeLike 结构只要将任意组件 props 定义为数组模式,并且包含 componentName,Designer 就认为应该解析为 ReactNode。 如下面的例子,我们定义的 div 组件初始化就会渲染一个 input 组件在 props.header 位置: import { ComponentMeta } from "designer";const divMeta: ComponentMeta = { componentName: "div", element: ({ header }) => <div>{header}</div>, defaultProps: { header: [ { componentName: "input", }, ], },}; 也可以在描述组件树时直接写在对应 props 位置: import { ComponentInstance } from "designer";const componentTree: ComponentInstance = { componentName: "div", props: { header: [ { componentName: "input", }, ], },}; 这种约定的好处是直观的支持了任意 props key 为组件实例,但依然存在限制,因此 Designer 还需要支持一种用户 100% 掌控的申明式定义:propTypes。 PropTypes在组件元信息 propTypes 属性定义更细致的容器插槽位置,比如: const tabMeta = { componentName: "tab", propTypes: { tabs: [ { panel: "element", }, ], },}; 那么当组件实例如下定义时: const componentInstance = { componentName: "tab", props: { tabs: [ { title: "tab1", panel: { componentName: "card", }, }, { title: "tab2", panel: { componentName: "text", }, }, ], },}; 组件拿到的 props.tabs[0].panel 就是一个可以直接渲染的 React 组件实例,因为在 propTypes 定义了 tabs[].panel 路径是一个组件实例。 这样设计需要考虑组件树遍历的问题,因为组件实例位置定义在组件元信息上,因此仅靠组件树无法做遍历(因为遍历父节点时,不结合 componentMeta 就无法确认哪些 props 位置是子组件实例),这样会带来两个问题: 遍历组件非常麻烦,极端情况下,如果大量组件是远程注册的三方组件,会导致需要一层层串行远程拉取组件实例,导致遍历过程变慢。 更极端的场景是,当组件版本升级导致 propTypes 变化,一些原本不是组件实例的位置成为了组件实例,或者反之,此时拉取最新组件元信息读取的 propTypes 可能就是错的。 因为以上两个原因,实现方案应该是将组件元信息定义的 propTypes 拷贝一份到组件实例,这样就可以仅凭组件树自身来遍历组件树了,而且定义在组件树上的 propTypes 一定对应当前组件树的结构。 总结我们通过 children 与 props 上 treeLike 这两个约定,实现了业务基本够用的容器定义能力,仅凭这两个约定就可以实现几乎所有容器需要的效果。 propTypes 定义补全了约定拓展性的不足,让 props 任何位置都可能成为组件实例,只需要付出额外定义 propTypes 的代价。 阅读到这,相信你已经理解到,可视化搭建其实不存在容器组件的概念,因为这个组件之所以是容器,仅仅因为它的某个 prop 属性是组件实例,而它恰好将该属性渲染到某个位置(甚至用 createPortal 挂载到其他 dom 节点),所以它仅仅是一种 prop 属性的体现,因此对容器组件,我们没有设计一种新 type,而是允许任意位置属性定义为实例。 下一节我们会介绍为组件元信息添加取数与筛选联动的钩子,让筛选器 + 查询场景可以轻松被实现。 讨论地址是:精读《容器组件设计》· Issue ##468 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"组件值校验","path":"/wiki/WebWeekly/可视化搭建/组件值校验.html","content":"当前期刊数: 275 组件值校验,即在组件值变化时判断是否满足校验逻辑,若不满足校验逻辑,可以拿到校验错误信息进行错误提示或其他逻辑处理。 声明 valueValidator 可开启值校验: import { ComponentMeta } from "designer";const input: ComponentMeta = { componentName: "input", element: Input, valueValidator: () => ({ required: true, maximum: 10, }),}; 如上面的例子,相当于对组件值做了 “不能为 undefined 且最大值为 10” 的限制。 可以内置 JSONSchema validate 的全部校验规则作为内置规则。 支持拓展自定义校验规则。 支持异步校验。 可以用 selector 绑定任意变量(如全局状态 state 或者当前组件实例的 props 来灵活定义组件值校验规则)。 当校验出错时,框架也不会做任何处理,而是将错误抛给业务,由业务来判断如何处理错误。 接下来我们来详细说说每一项特征。 错误处理定义了组件值校验后,当校验错误出现时,可以通过 selector 的 validateError 拿到错误信息: const input: ComponentMeta = { componentName: "input", element: Input, valueValidator: () => ({ required: true, maximum: 10, }), runtimeProps: ({ selector }) => ({ errorName: selector(({ validateError }) => validateError.ruleName), errorMessage: selector(({ validateError }) => validateError.payload), }),}; ruleName: 校验规则名称。 payload: 该规则未命中时的返回值,校验函数返回什么,这里拿到的就是什么。内置的校验函数返回的是错误信息文案。 拿到校验错误后,通过 runtimeProps 传给组件,我们可通过组件自身或 element 增加统一的组件 React 容器层处理并展示这些错误信息。 也可以使用 fetcher 接收这个错误,并调整取数参数。总之支持 selector 的地方都可以响应校验错误,如何使用完全由你决定。 自定义校验规则createDesigner 传递的中间件可以拓展自定义校验规则: import { createMiddleware } from "designer";const myMiddleware = createMiddleware({ validateRules: { // 自定义校验规则,判断是否为空字符串 isEmptyString: (value, options?: { errorMessage?: string }) => { if (value === "") { return true; } return options.errorMessage; }, },}); 通过 validateRules 定义自定义校验规则后,就可以在 valueValidator 中使用了: const input: ComponentMeta = { componentName: "input", element: Input, valueValidator: () => ({ isEmptyString: { errorMessage: "字符串必须为空", }, }),}; 用 selector 绑定校验规则利用 selector 将校验规则绑定到任意状态,比如: const input: ComponentMeta = { componentName: "input", element: Input, valueValidator: ({ selector }) => selector(({ props }) => props.validator),}; 上面的例子,将所有组件名为 input 组件的校验规则绑定到当前组件实例的 props.validator 上。 const input: ComponentMeta = { componentName: "input", element: Input, valueValidator: ({ selector }) => selector(({ state }) => state.validatorInfo),}; 上面的例子,将所有组件名为 input 组件的校验规则绑定绑定到全局状态 state.validatorInfo 上。 异步校验将自定义校验函数定义为异步函数,就可以定义异步校验。 const myMiddleware = createMiddleware({ validateRules: { isEmptyString: async (value, options?: { errorMessage?: string }) => { await wait(1000); if (value === "") { return true; } return options.errorMessage; }, },}); 如上所示,定义了 isEmptyString 的错误校验规则,那么当校验函数执行完后,在 1s 后将会出现校验信息。 总结组件值校验依然提供了强大的灵活拓展性,以下几种定制能力相互正交,将灵活性成倍放大: valueValidator 利用 selector 绑定任意值,这样既可以定义固定的校验规则,也可以定义跟随全局状态变化的校验规则,也可定义跟随当前组件实例 props 变化的校验规则。 在此基础上,还可以自定义校验规则,且支持异步校验。 更精彩的是,对值校验失败时,如何处理校验失败的表现交给了业务层。我们再次依托强大的 selector 设计,将校验错误传给 selector,就让校验错误的用法产生了无限可能。比如用在 runtimeProps 可以让渲染响应校验错误,用在 fetcher 可以让查询响应校验错误。 讨论地址是:精读《组件值校验》· Issue ##473 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"组件注册与画布渲染","path":"/wiki/WebWeekly/可视化搭建/组件注册与画布渲染.html","content":"当前期刊数: 269 接着可视化搭建的理论抽象,我们开始勾勒一个具体的 React 可视化搭建器。 精读假如我们将可视化搭建整体定义为 <Designer>,那么 API 可能是这样的: <Designer componentMetas={[]} componentTree={} /> componentMetas: 定义组件元信息的数组。 componentTree: 定义组件树结构。 只要注册了组件元信息与组件树,可视化搭建的画布就可以渲染出来了,这很好理解。 我们先看组件树如何定义: 组件树组件树里有各组件的实例,那么最好的设计是,组件树与组件实例结构是同构的,称为 ComponentInstance - 组件实例: { "componentName": "container", "children": [ { "componentName": "text", "props": { "name": "我是一个文本组件" } } ]} 上面的结构既可以当做单个组件的 组件实例信息,也可以认为是一个 组件树,也就是组件树的任何组件节点都可以拎出来成为一个新组件树,这就是同构的含义。 我们定义了最最基础的组件树结构,以后所有功能都基于这三个要素来拓展: componentName: 组件名,描述组件类型,比如是个文本、图片还是表格。 props: 该组件实例的所有配置信息,透传给组件 props。 children: 子组件,类型为 ComponentInstance[]。 每一个概念都不可或缺,让我们从概念必要性再分析一下这三个属性: componentName: 必须拥有的属性,否则怎么渲染该节点都无从谈起。所以相应的,我们需要组件元信息来定义每个组件名应该如何渲染。 props: 即便是相同组件名的不同实例,也可能拥有不同配置,这些配置放在 props 里足够了,没必要开额外的其他属性存储各种各样的业务配置。 children: 理论上可以合并到 props.children,但因为子组件概念太常见,建议 children 与 props.children 这两种位置同时支持,同时定义时,前者优先级更高。 除此之外,还有一个可选属性 componentId,即组件唯一 ID。我们从可选性与必要性两个角度分析一下这个属性: componentId 的可选性:组件实例在 组件树的路径 就是天然的组件唯一 ID,比如上面的文本组件的组件唯一 ID 可以认为是 children.0。 componentId 的必要性:用组件树路径代替组件唯一 ID 的坏处是,组件在组件树上移动后其唯一性就会消失,此时就要用上 componentId 了。 一个好的可视化搭建实现是支持 componentId 的可选性。 组件元信息接着上面说的,至少要定义一个组件名是如何渲染的,所以组件元信息(ComponentMeta)的必要结构如下: const textMeta = { componentName: "text", element: ({ name }) => <span>{name}</span>,}; componentName: 定义哪个组件名的元信息。 element: 该组件的渲染函数。 实现这些最基础功能后,虽然该可视化搭建器没有任何实质性的功能,但至少完成了一个核心基础工作:将组件树结构的描述与实现分开了。哪怕以后什么功能也不再增加,也永久的改变了开发模式,我们需要先定义组件元信息,再将其放置在组件树上。 对于画板工具软件,如果不考虑布局等复杂的画布功能,该结构描述足以完成大部分工作的技术抽象:配置面板修改组件实例的 props 属性,甚至布局位置也可以存储在 props 上。 对于 element 的命名,可能会产生分歧,比如还有其他命名风格如 render、renderer、reactNode 等等,但不管叫什么名字,只要是基于 React 响应式定义的,最终应该都殊途同归,最多对于各类 Key 的名称定义有所不同,这块可以保留自己的观点。 我们继续聚焦组件元信息的 element 属性,看以下 element 代码: const divMeta = { componentName: "div", element: ({ children, header }) => ( <div> {children} {header} </div> ),}; 上面的例子中,我们可以识别出 children 与 header 类型吗?可以识别一部分: children: 一定是 React 实例,可以是一个或多个组件实例。 header: 可能是数字、字符串,也可能是 React 实例。 props.children 对应了 componentInstance.children 描述,那么如何识别 header 是一个普通对象还是 React 实例呢? Props 上的 ComponentTreeLike 属性ComponentTreeLike 指的是:组件 props 属性上,识别出 “像组件实例的属性”,并将其转换为真正的组件实例传给组件。 假设一个正常的 props.header 值为 "some text",那么组件 props 实际拿到的 props.header 值也是字符串 "some text": { "componentName": "div", "props": { "header": "some text" }} const divMeta = { componentName: "div", element: ({ header }) => ( <div> {header} {/** 字符串 "some text" */} </div> ),}; 如果将 props.header 写成类 children 结构,可视化搭建框架就会识别为组件实例,将其转化为真正的 React 实例再传给组件: { "componentName": "div", "props": { "header": [ { "componentName": "text" } ] }} const divMeta = { componentName: "div", element: ({ header }) => ( <div> {header} {/** React 组件实例,此时会渲染出组件实例 */} </div> ),}; 这样设计是基于一个原则:组件树应该能描述出任何组件想要的 props 属性。我们反过来站在 element 角度来看,假设你注入了一个 Antd 等框架组件,如果在不改一行源码的情况下,就希望接入平台,那平台必须满足可配置出任何 props 的能力。除了基础变量外,更复杂的还有 React 组件实例与函数,现在我们解决了传组件实例的问题,至于如何传函数,我们下一小节再讲。 这样设计存在两个缺陷: 由于 ComponentTreeLike 会自动转成实例,所以没有办法让组件拿到 ComponentTreeLike 的原始值。 由于 ComponentTreeLike 位置不确定,为了避免深层解析产生的性能损耗,只解析 props 的第一级节点会导致嵌套层级较深的 ComponentTreeLike 无法被解析到。 如果要解决这两个缺陷,就需要在组件元信息上定义 Props 的类型,比如: const divMeta = { componentName: "div", propTypes: { header: "element", content: ["element"], tabs: [ { panel: "element", }, ], },}; 解释一下上面的例子代表的含义: header: 是单个 React Element。 content: 是 React Element 数组。 tabs: 是一个数组结构,每一项是对象,其中 panel 是 React Element。 这样配合以下组件树的描述,就可以精确的将对应 element 类型转化为组件实例了,而对于基本类型 primitive 保持原样传给组件: { "componentName": "div", "props": { "header": { "componentName": "text" }, "names": ["a", "b", "c"], "content": [ { "componentName": "text" }, { "componentName": "text" } ], "tabs": [ { "title": "tab1", "panel": { "componentName": "text" } } ] }} 如此一来,没有定义为 Element 的属性不会处理成 React 实例,第一个问题就自然解决了。通过配置更深层嵌套的结构,第二个问题也自然解决。 componentMeta.propTypes 之所以不采用 JSONSchema 结构,是因为框架没必要内置对 props 类型校验的能力,这个能力可以交给业务层来处理,所以这里就可以采用简化版结构,方便书写,也容易阅读。 注意:propTypes 中 {} 表示 value 是对象,而 [] 表示 value 是数组。为数组时,仅支持单个子元素,因为单项即是对数组每一项类型的定义。 给组件注入函数现在已经能给 componentMeta.element 传入任意基础类型、React 实例的 props 了,现在还缺函数类型或者 Set、Map 等复杂类型问题需要解决。 由于组件树结构需要序列化入库,所以必须为一个可以序列化的 JSON 结构,而这个结构又需要暴露给开发者,所以也不适合定义一些 hack 的序列化、反序列化规则。因此要给组件 props 注入函数,需要定义在组件元信息上,由于其定义了额外的 props 属性,且不在组件树中,所以我们将其命名为 runtimeProps: const divMeta = { componentName: "div", runtimeProps: () => ({ onClick: () => { console.log('click') } }) element: ({ onClick }) => ( <button onClick={onClick}> 点击我 </button> ),}; 点击按钮后,会打印出 click。这是因为 runtimeProps 定义了函数类型 onClick 在运行时传入了组件 props。 当组件树与 componentMeta.runtimeProps 同时定义了同一个 key 时,runtimeProps 优先级更高。 总结本节我们介绍了组件注册与画布渲染的基础内容,我们再重新梳理一下。 首先定义了 <Designer /> API,并支持传入 componentTree 与 componentMetas,有了组件树与组件元信息,就可以实现可视化搭建画布的渲染了。 我们还介绍了如何在组件元信息定义组件的渲染函数,如何给渲染函数 props 传入基本变量、React 实例以及函数,让渲染函数可以对接任何成熟的组件库,而不需要组件库做任何适配工作。 但这只是可视化搭建的第一步,在真正开始做项目后,你还会遇到越来越多的问题,比如除了渲染画布,还要在业务层定义属性配置面板、组件拖拽列表、图层列表、撤销重做等等功能,这些功能如何拿到画布属性?如何与画布交互?runtimeProps 如何基于项目数据流给组件注入不同的属性或函数?如何根据组件 props 的变化动态注入不同函数?如何保证注入的函数引用不变? 要解决这些问题,需要在本章的基础上实现一套系统的数据流规则以及配套 API,这也是下一讲的内容。 讨论地址是:精读《组件注册与画布渲染》· Issue ##464 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"画布与组件元信息数据流","path":"/wiki/WebWeekly/可视化搭建/画布与组件元信息数据流.html","content":"当前期刊数: 270 接下来需要解决两个问题: 可视化搭建的其他业务元素如何与画布交互。比如拓展属性配置面板、图层列表、拖拽添加组件、定位锚点、主题等等。 runtimeProps 如何访问到当前组件实例的 props。 这两个问题非常重要,而恰好又可以通过良好的数据流设计一次性解决,接下来让我们分别分析讨论一下。 问题一:可视化搭建的其他业务元素如何与画布交互。比如拓展属性配置面板、图层列表、拖拽添加组件、定位锚点、主题等等 需要设计一个 Hooks API,可以访问到画布提供的方法、数据。在 React 设计中,访问 Hooks API 需要在一定上下文内,所以可以将 <Designer> 拆为 <Designer> 与 <Canvas>,其中 <Designer> 提供 Hooks 上下文,<Canvas> 负责渲染画布。这样开发者的使用方式就变成了这样: import { createDesigner } from 'designer'const { Designer, Canvas, useDesigner } = createDesigner()const EditPanel = { const { addComponent } = useDesigner() return <button onClick={() => addComponent(/** ... */)}>创建组件</button>}const App = () => { <Designer> <Canvas /> <EditPanel /> </Designer>} 为了支持多个 Designer 实例间隔离,通过 createDesigner 创建一套上下文独立的 API,这样就可以让画布、配置面板同时用 Designer 实现,用一套技术方案同时实现画布与配置表单,这样学习上下文、组件规范都可以统一为一套,表单、画布能力也可以共享。 在 <Designer> 内的组件可以通过 useDesigner 直接访问数据与方法,比如上面例子在直接访问内置方法 addComponent 时,不需要附加任何参加,而 addComponent 方法也永远保持引用不变,此时 useDesigner 不会导致 EditPanel 重渲染。 如果需要访问当前组件树,并在组件树变化时重渲染,可以通过如下方式访问: const EditPanel = { const { componentTree } = useDesigner(state => ({ componentTree: state.componentTree }))} 该写法的效果是,当 state.componentTree 变化了,会触发 EditPanel 重新渲染,并拿到最新值。 同时也可以传入第二个参数 compare 自定义对比方法,默认为 shallowEqual: useDesigner( (state) => ({ componentTree: state.componentTree, }), isEqual); 如此一来,无论给画布拓展多少 UI 元素都没有问题,而且 UI 元素可以自由的访问画布方法与数据。 问题二:runtimeProps 如何访问到当前组件实例的 props 在 componentMeta.runtimeProps 中,我们构造一个 selector 函数用于访问当前组件 props: const divMeta = { componentName: "div", runtimeProps: ({ selector }) => { const name = selector(({ props }) => props.name) return { fullName: `full-${name}` } } element: /** ... */}; 首先支持从 runtimeProps 回调里拿到 selector,并且该 selector 支持传入一个回调函数,该回调函数的参数中 props 指向当前组件实例的 props,通过该方法就可以访问组件 props 了。 该 selector 仅在 props.name 改变时重新执行,并且也遵循 compare 对比规则,即当 props.name 变化时,selector 回调函数的返回值通过 compare 与上一次值进行对比,如果没有变化就返回上一次的旧值,变化了则返回新值。默认对比函数为 shallowEqual,与 useDesigner 类似,也可以在第二个参数位置覆写 compare 方法。 那组件元信息如何访问内置静态方法呢?由于静态方法引用不变,因此可以在 selector 同级直接传入: const divMeta = { componentName: "div", runtimeProps: ({ addComponent }) => { return { add: () => { /** addComponent(...) */ } } } element: /** ... */}; 如此一来,我们就将数据流与组件元信息打通了,即 UI 可以通过 useDesigner 访问与操作数据流,组件元信息也可以直接拿到方法,或通过 selector 拿到数据,相应的也可以访问与操作数据流。这样的设计在以后拓展更多组件元信息函数时,都可以继承下来,开发者只要学习一次语法,就可以获得非常强力的拓展性。 拓展应用状态与静态方法刚才介绍了一些内置的状态(componentTree)与方法(addComponent),在下一接会系统介绍笔者梳理了哪些内置状态与方法。首先抛开内置状态与方法不谈,应用肯定需要定义自己的状态与方法,我们可以提供两种模式给用户。 第一种是应用的状态与方法定义在外部,对应受控模式。 假设你的应用在对接 Designer 之前就已经用 Redux、Dva、Zustand 等状态管理库,那么就可以使用受控模式直接接入: const App = () => { // 伪代码,不管是 useState 还是其他数据流管理状态,假这里拿到了数据与方法 const { getAppInfo } = useSomeLib(); const { userName } = useSomeLib("userName"); return <Designer actions={{ getAppInfo }} state={{ userName }} />;}; 将方法传给 actions,状态传给 state。 第二种是应用的状态与方法通过 <Designer> 定义,对应非受控模式。 假设你的应用之前没有使用任何数据流,那么也可以直接将 Designer 的数据流作为项目数据流使用: import { createMiddleware, createDesigner } from "designer";const middleware1 = createMiddleware({ state: { userName: "bob " }, actions: { getAppInfo: () => {} },});const { Designer } = createDesigner(middleware1);const App = () => { return <Designer />;}; 通过 createMiddleware 创建一个中间件定义状态与函数,传入 createDesigner 即可生效。 也可以在 createMiddleware 里通过第二个参数定义自定义 hooks,或者拿到方法更改 State: const middleware1 = createMiddleware( { state: { userName: "bob " }, }, ({ setState }) => { const setUserName = React.useCallback((newName: string) => { setState((state) => ({ ...state, userName: newName, })); }); return { setUserName }; }); Designer 内部采用最朴素的 Redux 管理状态,提供了最基础的 getState 与 setState 获取与修改状态,基于它们封装业务函数即可。 无论是受控模式,还是非受控模式(亦或两种模式同时使用),定义的状态与方法都可以在以下两个位置访问,第一个位置是 useDesigner: const { /** 自定义函数 */, setUserName, /** 自定义函数 */ getAppInfo, /** 内置函数 */ addComponent, // 内置变量 componentTree, // 自定义变量 userNamee} = useDesigner(state => ({ componentTree: state.componentTree, userName: state.userName})) 第二个位置是组件元信息上的回调函数,比如 runtimeProps: const divMeta = { componentName: "div", runtimeProps: ({ selector, /** 自定义函数 */, setUserName, /** 自定义函数 */ getAppInfo, /** 内置函数 */ addComponent }) => { const { /** 内置变量 */ componentTree, /** 自定义变量 */ userName } = selector(({ state }) => ({ componentTree: state.componentTree, userName: state.userName })) return { componentTree, userName } } element: /** ... */}; 至此,我们实现了一套完整的数据流定义,包括: 不同 Designer 之间上下文隔离。 可无缝对接项目数据流,也可作为独立数据流方案提供。 内置变量与函数与自定义变量、函数混合。 无论在 UI 通过 useDesigner,还是在组件元信息通过 selector 都可访问这些变量与函数。 总结一个基本可用的可视化搭建框架在本章就算设计完了。但这只是可视化搭建问题的冰山一角,未来的章节,笔者会逐渐为大家介绍更多可视化搭建的设计。 但无论框架未来怎么发展,也永远会基于这前三章的基本设定,总结一下,这三章的基本设定就是:设计一个逻辑与 UI 分离的可视化搭建协议,数据流、组件元信息、组件实例是永远的铁三角,数据流可以对接任意已存在的实现,或基于 Designer 规范实现,组件元信息与组件实例仅存储最基本信息,得益于数据流的自定义能力,以及无论何处都有完全的数据流访问能力,使业务框架既遵循规则,又可以千变万化。 抛开具体 API 设计或者命名不谈,一个有简洁、抽象,又提供极少量 API 却能满足所有业务定制诉求,是可视化搭建永远追求的目标。只要熟悉了这套规范,就可以几乎仅根据业务表现,一眼猜出是基于哪些 API 封装实现的,那么维护成本与理解成本将大大降低,规范的意义就体现在这里。 也许有同学会觉得,现在各个大厂都有无数可视化搭建的实现,可视化搭建概念都已经烂大街了,为什么还要重新设计一个呢? 因为也许数量不代表质量,维护的时间越久,参与的同学越多,越容易使设计变得冗余,概念变得复杂,要对抗这些递增的熵,唯有不断重新设计,从零开始反思方案。 下一讲理论思考会少一些,介绍可视化搭建框架会考虑内置哪些变量与方法。 讨论地址是:精读《画布与组件元信息数据流》· Issue ##466 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"自动批处理与冻结","path":"/wiki/WebWeekly/可视化搭建/自动批处理与冻结.html","content":"当前期刊数: 279 性能在可视化搭建也是极为重要的,如何尽可能减少业务感知,最大程度的提升性能是关键。 其实声明式一定程度上可以说是牺牲了性能换来了可维护性,所以在一个完全声明式的框架下做性能优化还是非常有挑战的。我们采取了两种策略来优化性能,分别是自动批处理与冻结。 自动批处理首先,框架内任何状态更新都不会立即触发响应,而是统一收集起来后,一次性触发响应,如下面的例子: const divMeta: ComponentMeta = { // ... fetcher: ({ selector, setRuntimeProps, componentId }) => { const name = selector(({ props }) => props.name) const email = selector(({ props }) => props.email) fetch('...', { data: { name, email } }).then((res) => { setRuntimeProps(componentId, old => ({ ...old ?? {}, data: res.data })) }) }}const App = () => { const { setProps } = useDesigner() const onClick = useCallback(() => { setProps('1', props => ({ ...props, name: 'bob' })) setProps('1', props => ({ ...props, email: '666@qq.com' })) }, [])} 上面例子中,fetcher 通过 selector 监听了 props.name 与 props.email,当连续调用两次 setProps 分别修改 props.name 与 props.email 时,只会合并触发一次 fetcher 而不是两次,这种设计让业务代码减少了重复执行的次数,简化了业务逻辑复杂度。 另一方面,在自动批处理的背后,还有一个框架如何执行 selector 的性能优化点,即框架是否能感知到 fetcher 依赖了 props.name 与 props.email?如果框架知道,那么当比如 props.appId 或者其他 state. 状态变化时,根本不需要执行 fetcher 内的 selector 判断返回引用是否变化,这能减少巨大的碎片化堆栈时间。 一个非常有效的收集方式是利用 Proxy,将 selector 内用到的数据代理化,利用代理监听哪些函数绑定了哪些变量,并在这些变量变化时按需重新执行。 笔者用一段较为结构化的文字描述这背后的性能优化是如何发生的。 一、组件元信息声明式依赖了某些值 比如下面的代码,在 meta.fetcher 利用 selector 获取了 props.name 与 props.email 的值,并在这些值变化时重新执行 fetcher。 const divMeta: ComponentMeta = { // ... fetcher: ({ selector, setRuntimeProps, componentId }) => { const name = selector(({ props }) => props.name) const email = selector(({ props }) => props.email) }} 在这背后,其实 selector 内拿到的 props 或者 state 都已经是 Proxy 代理对象,框架内部会记录这些调用关系,比如这个例子中,会记录组件 ID 为 1 的组件,fetcher 绑定了 props.name 与 props.email。 二、状态变化 当任何地方触发了状态变化,都不会立刻计算,而是在 nextTick 时机触发清算。比如: setProps('1', props => ({ ...props, name: 'bob' }))setProps('1', props => ({ ...props, email: '666@qq.com' })) 虽然连续触发了两次 setProps,但框架内只会在 nextTick 时机总结出发生了一次变化,此时组件 ID 为 1 的组件实例 props.name 与 props.email 发生了变化。 接着,会从内部 selector 依赖关系的缓存中找到,发现只有 fetcher 函数依赖了这两个值,所以就会精准的执行 fetcher 中两个 selector,执行结果发现相比之前的值引用变化了,最后判定需要重新执行 fetcher,至此响应式走完了一次流程。 当然在 fetcher 函数内可能再触发 setProps 等函数修改状态,此时会立刻进入判定循环直到所有循环走完。另外假设此次状态变化没有任何 meta 声明式函数依赖了,那么即便画布有上千个组件,每个组件实例绑定了十几个 meta 声明式函数,此时都不会触发任何一个函数的执行,性能不会随着画布组件增加而恶化。 冻结冻结可以把组件的状态凝固,从而不再响应任何事件,也不会重新渲染。 const chart: ComponentMeta = { /** 默认 false */, defaultFreeze: true} 或者使用 setFreeze 修改冻结状态: const { setFreeze } = useDesigner()// 设置 id 1 的组件为冻结态setFreeze('1', true) 为什么要提供冻结能力?当仪表盘内组件数量过多时,业务上会考虑做按需加载,或者按需查询。但因为组件间存在关联关系,可视化搭建框架(我们用 Designer 指代)在初始化依然会执行一些初始函数,比如 init,同时组件依然会进行一次初始化渲染,虽然业务层会做一些简化处理,比如提前 Return null, 但组件数量多了之后想要扣性能依然还有优化空间。 所以 Designer 就提供了冻结能力,从根本上解决视窗外组件造成的性能影响。为什么可以根本解决性能影响呢?因为处于冻结态的组件: 前置性。通过 defaultFreeze 在组件元信息初始化设置为 false,那么所有初始化逻辑都不会执行。 不会响应任何状态变更,连内置的 selector 执行都会直接跳过,完全屏蔽了这个组件的存在,可以让 Designer 内部调度逻辑大大提效。 不会触发重渲染。如果组件初始化就设置为冻结,那么初始化渲染也不会执行。 怎么使用冻结能力?建议统一把所有组件 defaultFreeze 设置为 true,然后找一个地方监听滚动或者视窗的变化,通过 setFreeze 响应式的把视窗内组件解冻,把移除视窗的组件冻结。 特别注意,如果有组件联动,冻结了触发组件会导致联动失效,因此业务最好把那些 即便不在视窗内,也要作用联动 的组件保持解冻状态。 总结总结一下,首先因为声明式代码中修改状态的地方很分散,甚至执行时机都交由框架内部控制,因此手动 batch 肯定是不可行的,基于此得到了更方便,性能全方面优化了的自动 batch。 其次是业务层面的优化,当组件在视窗外后,对其所有响应监听都可以停止,所以我们想到定义出冻结的概念,让业务自行决定哪些组件处于冻结态,同时冻结的组件从元信息的所有回调函数,到渲染都会完全停止,可以说,画布即便存在一万个冻结状态的组件,也仅仅只有内存消耗,完全可以做到 0 CPU 消耗。 讨论地址是:精读《自动批处理与冻结》· Issue ##484 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法 - 二叉搜索树》","path":"/wiki/WebWeekly/算法/《算法 - 二叉搜索树》.html","content":"当前期刊数: 203 二叉搜索树的特性是,任何一个节点的值: 都大于左子树任意节点。 都小于右子树任意节点。 因为二叉搜索树的特性,我们可以更高效的应用算法。 精读还记得 《算法 - 二叉树》 提到的 二叉树的最近公公祖先 问题吗?如果这是一颗二叉搜索树,是不是存在更巧妙的解法?你可以暂停先思考一下。 二叉搜索树的最近公共祖先二叉搜索树的最近公共祖先是一道简单题,题目如下: 给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。 百度百科中最近公共祖先的定义为:“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。” 第一个判断条件是相同的,即当前节点值等于 p 或 q 任意一个,则当前节点就是其最近公共祖先。 如果不是呢?同时考虑二叉搜索树与公共祖先的特性可以发现: 如果 p q 两个节点分别位于当前节点的左 or 右边,则当前节点符合要求。 如果 p q 值一个大于,一个小于当前节点,说明 p q 分布在当前节点左右两侧。 基于以上考虑,可以仅通过值大小来判断,因此题目就被简化了。 接下来看一道入门题,即如何验证一颗二叉树是二叉搜索树。 验证二叉搜索树验证二叉搜索树是一道中等题,题目如下: 给定一个二叉树,判断其是否是一个有效的二叉搜索树。 假设一个二叉搜索树具有如下特征: 节点的左子树只包含小于当前节点的数。 节点的右子树只包含大于当前节点的数。 所有左子树和右子树自身必须也是二叉搜索树。 这道题看上去就应该用非常优雅的递归来实现。 二叉搜索树最重要的就是对节点值的限制,我们如果能正确卡住每个节点的值,就可以判断了。 如何判断节点值是否正确呢?我们可以用递归的方式倒推,即从根节点开始,假设根节点值为 x,那么左树节点的值就必须小于 x,再往左,那么值就要小于(假设第一个左节点值为 x1) x1,右树也是一样判断,因此就可以写出答案: function isValidBST(node: TreeNode, min = -Infinity, max = Infinity) { if (node === null) return true // 判断值范围是否合理 if (node.val < min || node.val > max) return false // 继续递归,并且根据二叉搜索树特定,进一步缩小最大、最小值的锁定范围 return // 左子树值 max 为当前节点值 isValidBST(node.left, min, node.val) && // 右子树值 min 为当前节点值 isValidBST(node.right, node.val, max) &&} 接下来看一些简单的二叉搜索树操作问题,比如删除二叉搜索树中的节点。 删除二叉搜索树中的节点删除二叉搜索树中的节点是一道中等题,题目如下: 给定一个二叉搜索树的根节点 root 和一个值 key,删除二叉搜索树中的 key 对应的节点,并保证二叉搜索树的性质不变。返回二叉搜索树(有可能被更新)的根节点的引用。 一般来说,删除节点可分为两个步骤: 首先找到需要删除的节点; 如果找到了,删除它。 说明: 要求算法时间复杂度为 O(h),h 为树的高度。 要删除二叉搜索树的节点,找到节点本身并不难,因为如果值小了,就从左子树找;如果值大了,就从右子树找,这本身查找起来是非常简单的。难点在于,如何保证删除元素后,这棵树还是一颗二叉搜索树? 假设我们删除的是叶子结点,很显然,二叉搜索树任意子树都是二叉搜索树,我们又没有破坏其他节点的关系,因此直接删除就行了,最简单。 如果删除的不是叶子结点,那么谁来 “上位” 代替这个节点呢?题目要求复杂度为 O(h) 显然不能重新构造,我们需要仔细考虑。 假设删除的节点存在右节点,那么肯定从右节点找到一个代替值移上来,找谁呢?找右节点的最小值呀,最小值很好找的,找完代替后,相当于 问题转移为删除这个最小值节点,递归就完事了。 假设删除的节点存在左节点,但是没有右节点,那就从左节点找一个最大的替换掉,同理递归删除找到的节点。 可以看到,删除二叉搜索树,为了让二叉搜索树性质保持不变,需要不断进行重复子问题的递归删除节点。 当你掌握二叉搜索树特性后,可以尝试构造二叉搜索树了,下面就是一道让你任意构造二叉搜索树的题目:不同的二叉搜索树。 不同的二叉搜索树不同的二叉搜索树是一道中等题,题目如下: 给你一个整数 n ,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。 这道题重点在于动态规划思维 + 笛卡尔积组合的思维。 需要将所有可能性想象为确定了根节点后,左右子树到底有几种组合方式? 举个例子,假设 n=10,那么这 10 个节点,假设我取第 3 个节点为根节点,那么左子树有 2 个节点,右子树有 7 个节点,这种组合情况就有 DP(2) * DP(7) 这么多,假设 DP(n) 表示 n 个节点能组成任意二叉搜索树的数量。 这仅是第 3 个节点为根节点的情况,实际上每个节点作为根节点都是不同的树(轴对称也算不同的),那么我们就要从第 1 个节点计算到第 n 个节点。 因此答案就出来了,我们先考虑特殊情况 DP(0)=1 DP(1)=1,所以: function numTrees(n: number) { const dp: number[] = [1, 1] for (let i = 2; i <= n; i++) { for (let j = 1; j <= i; j++) { dp[i] += dp[j - 1] * dp[i - j] } } return dp[n]} 最后再看一道找值题,并不是找最大值,而是找第 k 大值。 二叉搜索树的第 K 大节点二叉搜索树的第 K 大节点是一道简单题,题目如下: 给定一棵二叉搜索树,请找出其中第 k 大的节点。 这道题之所以简单,是因为二叉搜索树的中序遍历是从小到大的,因此只要倒序中序遍历,就可以找到第 k 大的节点。 倒序中序遍历,即右、根、左。 这道题就解决啦。 总结二叉搜索树的特性很简单,就是根节点值夹在左右子树中间,利用这个特性几乎可以解决一切相关问题。 但通过上面几个例子可以发现,仅熟悉二叉搜索树特性还是不够的,一些题目需要结合二叉树中序遍历、公共祖先特征等通用算法思路结合来解决,因此学会融会贯通很重要。 讨论地址是:精读《算法 - 二叉搜索树》· Issue ##337 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法 - 二叉树》","path":"/wiki/WebWeekly/算法/《算法 - 二叉树》.html","content":"当前期刊数: 201 二叉树是一种数据结构,并且拥有种类复杂的分支,本文作为入门篇,只介绍一些基本二叉树的题型,像二叉搜索树等等不在此篇介绍。 二叉树其实是链表的升级版,即链表同时拥有两个 Next 指针,就变成了二叉树。 二叉树可以根据一些特性,比如搜索二叉树,将查找的时间复杂度降低为 logn,而且堆这种数据结构,也是一种特殊的二叉树,可以以 O(1) 的时间复杂度查找最大值或者最小值。所以二叉树的变种很多,都可以很好的解决具体场景的问题。 精读要入门二叉树,就必须理解二叉树的三种遍历策略,分别是:前序遍历、中序遍历、后序遍历,这些都属于深度优先遍历。 所谓前中后,就是访问节点值在什么时机,其余时机按先左后右访问子节点。比如前序遍历,就是先访问值,再访问左右;后续遍历就是先访问左右,再访问值;中序遍历就是左,值,右。 用递归方式遍历树非常简单: function visitTree(node: TreeNode) { // 三选一:前序遍历 // console.log(node.val) visitTree(node.left) // 三选一:中序遍历 // console.log(node.val) visitTree(node.right) // 三选一:后序遍历 // console.log(node.val)} 当然题目需要我们巧妙利用二叉树三种遍历的特性来解题,比如重建二叉树。 重建二叉树重建二叉树是一道中等题,题目如下: 输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。 例如 前序遍历 preorder = [3,9,20,15,7] 中序遍历 inorder = [9,3,15,20,7] 先给你二叉树前序与中序遍历结果,让你重建二叉树,这种逆向思维的题目就难了不少。 仔细观察遍历特性可以看出,我们也许能推测出一些关键节点的位置,再通过数组切割递归一下就能解题。 前序遍历第一个访问的一定是根节点,因此 3 一定是根节点,然后我们在中序遍历找到 3,这样 左边就是所有左子树的中序遍历结果,右边就是所有右子树的中序遍历结果,我们只要再找到 左子树的前序遍历结果与右子树的前序遍历结果,就可以递归了,终止条件是左或右子树只有一个值,那样就代表叶子节点。 那么怎么找左右子树的前序遍历呢?上面例子中,我们找到了 3 的左右子树的中序遍历结果,由于前序遍历优先访问左子树,因此我们数一下中序遍历中,3 左边的数量,只有一个 9,那么我们从前序遍历的 3,9,20,15,7 在 3 之后推一位,那么 9 就是左子树前序遍历结果,9 后面的 20,15,7 就是右子树的前序遍历结果。 最后只要递归一下就能解题了,我们将输入不断拆解为左右子树的的输入,直到达到终止条件。 解决此题的关键是,不仅要知道如何写前中后序遍历,还要知道前序遍历第一个节点是根节点,后序遍历最后一个节点是根节点,中序遍历以根节点为中心,左右分别是其左右子树,这几个重要延伸特征。 说完了反向,我们说正向,即递归一棵二叉树。 其实二叉树除了递归,还有一种常见的遍历方法是利用栈进行广度优先遍历,典型题目有从上到下打印二叉树。 从上到下打印二叉树从上到下打印二叉树是一道简单题,题目如下: 从上到下按层打印二叉树,同一层的节点按从左到右的顺序打印,每一层打印到一行。 这道题要求从左到右顺序打印,完全遵循广度优先遍历,我们可以在二叉树递归时,先不要急着读取值,而是按照左、中、右,遇到左右子树节点,就推入栈的末尾,利用 while 语句不断循环,直到栈空为止。 利用展开时追加到栈尾,并不断循环处理栈元素的方式非常优雅,而且符合栈的特性。 当然如果题目要求倒序打印,你就可以以 右、中、左 的顺序进行处理。 接下来看看深度优先遍历,典型题目是二叉树的深度。 二叉树的深度二叉树的深度是一道简单题,题目如下: 输入一棵二叉树的根节点,求该树的深度。从根节点到叶节点依次经过的节点(含根、叶节点)形成树的一条路径,最长路径的长度为树的深度。 由于二叉树有多种分支,在遍历前,我们并不知道哪条路线是最深的,所以必须利用递归尝试。 我们可以转换一下思路,用函数式语义方式来理解。假设我们有了这样一个函数 deep 来求二叉树深度,那么这个函数内容是什么呢?二叉树只可能存在左右子树,所以 deep 必然是左右子树的最大深度的最大值 +1(它自己)。 而求左右子树深度可以复用 deep 函数形成递归,我们只需要考虑边界情况,即访问节点不存在时,返回深度 0 即可,因此代码如下: function deep(node: TreeNode) { if (!node) return 0 return Math.max(deep(node.left), deep(node.right)) + 1} 从这可以看出,二叉树一般能用比较优雅的递归函数解决,如果你的解题思路不包含递归,往往就不是最优雅的解法。 类似优雅的题目还有,平衡二叉树。 平衡二叉树平衡二叉树是一道简单题,题目如下: 输入一棵二叉树的根节点,判断该树是不是平衡二叉树。如果某二叉树中任意节点的左右子树的深度相差不超过 1,那么它就是一棵平衡二叉树。 同理,我们设函数 isBalance 就是答案函数,那么一个平衡二叉树的特征,必然是其左右子树也是平衡的,所以可以写成: function isBalance(node: TreeNode) { if (root == null) return true return isBalance(node.left) && isBalance(node.right)} 但是哪里不对,左右子树平衡还不够啊,万一左右子树之间深度相差超过 1 就坏了,所以还要求一下左右子树的深度,我们复用上题的函数 deep,整理一下如下: function isBalance(node: TreeNode) { if (root == null) return true return isBalance(root.left) && isBalance(root.right) && Math.abs(deep(root.left) - deep(root.right)) < 2} 这道题提醒我们,不是所有递归都能完美写成仅自己调用自己的模式,不同题目要辅以其他函数,要敏锐的察觉到还缺少哪些条件。 还有一种递归,不是简单的函数自身递归自身,而是要构造出另一个函数进行递归,原因是递归参数不同。典型的题目有对称的二叉树。 对称的二叉树对称的二叉树是一道简单题,题目如下: 请实现一个函数,用来判断一棵二叉树是不是对称的。如果一棵二叉树和它的镜像一样,那么它是对称的。 我们要注意,一颗二叉树的镜像比较特殊,比如最左节点与最右节点互为镜像,但它们的父节点并不相同,因此 isSymmetric(tree) 这样的参数是无法子递归的,我们必须拆解为左右子树作为参数,让它们进行相等判断,在传参时,将父级不同,但互为镜像的左右节点传入即可。 所以我们必须起一个新函数 isSymmetricNew(left, right),将 left.left 与 right.right 对比,将 left.right 与 right.left 对比即可。 具体代码就不写了,然后注意一下边界情况即可。 这道题的重点是,由于镜像的关系,并不拥有相同的父节点,因此必须用一个新参数的函数进行递归。 那如果这道题反过来呢?要求构造一个二叉树镜像呢? 二叉树的镜像二叉树的镜像是一道简单题,题目如下: 请完成一个函数,输入一个二叉树,该函数输出它的镜像。 判断镜像比较容易,但构造镜像就要想一想了: 例如输入: 4 / \\ 2 7 / \\ / \\1 3 6 9镜像输出: 4 / \\ 7 2 / \\ / \\9 6 3 1 观察发现,其实镜像可以理解为左右子树互换,同时 其各子树的左右子树再递归互换,这就构成了一个递归: function mirrorTree(node: TreeNode) { if (node === null) return null const left = mirrorTree(node.left) const right = mirrorTree(node.right) node.left = right node.right = left return node} 我们要从下到上,因此先生成递归好的左右子树,再进行当前节点的互换,最后返回根节点即可。 接下来介绍一些有一定难度的经典题。 二叉树的最近公共祖先二叉树的最近公共祖先是一道中等题,题目如下: 给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。 题目很简短,也很明确,就是寻找最近的公共祖先。显然,根节点是所有节点的公共祖先,但不一定是最近的。 我们还是用递归,先考虑特殊情况:如果任意节点等于当前节点,那么当前节点一定就是最近公共祖先,因为另一个节点一定在其子节点中。 然后,利用递归思想思考,假设我们利用 lowestCommonAncestor 函数分别找到左右子节点的最近公共祖先会怎样? function lowestCommonAncestor(node, a, b) { const left = lowestCommonAncestor(node.left) const right = lowestCommonAncestor(node.right)} 如果左右节点都找不到,说明只可能当前节点是最近公共子节点: if (!left && !right) return node 如果左节点找不到,则右节点就是答案,否则相反: if (!left) return rightreturn left 这里巧妙利用了函数语义进行结果判断。 二叉树的右视图二叉树的右视图是一道中等题,题目如下: 给定一棵二叉树,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。 想象一束光照,从二叉树右侧向左照射,自上而下读取即是答案。 其实这道题可以认为是一道融合题。右侧的光束可以认为是分层照射的,那么当我们用广度优先算法遍历时,对于每一层,都找到最后一个节点打印,并且按顺序打印就是最终答案。 有一道二叉树的题目,是根据树的深度,按照广度优先遍历打印成二维数组,记录树的深度其实也有巧妙办法,即在栈尾追加元素时,增加一个深度 key,那么访问时自然就可以读到深度值。 完全二叉树的节点个数完全二叉树的节点个数是一道中等题,题目如下: 给你一棵 完全二叉树 的根节点 root ,求出该树的节点个数。 完全二叉树 的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1 ~ 2^h 个节点。 用递归解决这道题的话,关键要分几种情况探讨完全二叉树。 由于最底层可能没有填满,但最底层一定有节点,而且是按照从左到右填的,那么递归遍历左节点就可以获取树的最大深度,通过最大深度我们可以快速计算出节点个树,前提是二叉树必须是满的。 但最底层节点可能不满,那怎么办呢?分情况即可,首先,如果一直按照 node.right....right 递归获得右侧节点深度,发现和最大深度相同,那么就是一个满二叉树,直接计算出结果即可。 我们再看 node.right...left 的深度如果等于最大深度,说明 node.left 也就是左子树是个满二叉树,可以通过数学公式 2^n-1 快速算出节点个树。 如果不等于最大深度呢?则说明右子树深度减 1 是满二叉树,也可以通过数学公式快速计算节点个数,再通过递归计算另一边即可。 总结从题目中可以感受到,二叉树的解题魅力在于递归,二叉树问题中,我们可以同时追求优雅与答案。 讨论地址是:精读《算法 - 二叉树》· Issue ##331 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法 - 动态规划》","path":"/wiki/WebWeekly/算法/《算法 - 动态规划》.html","content":"当前期刊数: 198 很多人觉得动态规划很难,甚至认为面试出动态规划题目是在为难候选人,这可能产生一个错误潜意识:认为动态规划不需要掌握。 其实动态规划非常有必要掌握: 非常锻炼思维。动态规划是非常锻炼脑力的题目,虽然有套路,但每道题解法思路差异很大,作为思维练习非常合适。 非常实用。动态规划听起来很高级,但实际上思路和解决的问题都很常见。 动态规划用来解决一定条件下的最优解,比如: 自动寻路哪种走法最优? 背包装哪些物品空间利用率最大? 怎么用最少的硬币凑零钱? 其实这些问题乍一看都挺难的,毕竟都不是一眼能看出答案的问题。但得到最优解又非常重要,谁能忍受游戏中寻路算法绕路呢?谁不希望背包放的东西更多呢?所以我们一定要学好动态规划。 精读动态规划不是魔法,它也是通过暴力方法尝试答案,只是方式更加 “聪明”,使得实际上时间复杂度并不高。 动态规划与暴力、回溯算法的区别上面这句话也说明了,所有动态规划问题都能通过暴力方法解决!是的,所有最优解问题都可以通过暴力方法尝试(以及回溯算法),最终找出最优的那个。 暴力算法几乎可以解决一切问题。回溯算法的特点是,通过暴力尝试不同分支,最终选择结果最优的线路。 而动态规划也有分支概念,但不用把每条分支尝试到终点,而是在走到分叉路口时,可以直接根据前面各分支的表现,直接推导出下一步的最优解!然而无论是直接推导,还是前面各分支判断,都是有条件的。动态规划可解问题需同时满足以下三个特点: 存在最优子结构。 存在重复子问题。 无后效性。 存在最优子结构即子问题的最优解可以推导出全局最优解。 什么是子问题?比如寻路算法中,走完前几步就是相对于走完全程的子问题,必须保证走完全程的最短路径可以通过走完前几步推导出来,才可以用动态规划。 不要小看这第一条,动态规划就难在这里,你到底如何将最优子结构与全局最优解建立上关系? 对于爬楼梯问题,由于每层台阶都是由前面台阶爬上来的,因此必然存在一个线性关系推导。 如果变成二维平面寻路呢?那么就升级为二维问题,存在两个变量 i,j 与上一步之间关系了。 如果是背包问题,同时存在物品数量 i、物品重量 j 和物品质量 k 三个变量呢?那就升级为三位问题,需要寻找三个之间的关系。 依此类推,复杂度可以上升到 N 维,维度越高思考的复杂度就越高,空间复杂度就越需要优化。 存在重复子问题即同一个子问题在不同场景下存在重复计算。 比如寻路算法中,同样两条路线的计算中,有一段路线是公共的,是计算的必经之路,那么只算一次就好了,当计算下一条路时,遇到这个子路,直接拿第一次计算的缓存即可。典型例子是斐波那契数列,对于 f(3) 与 f(4),都要计算 f(1) 与 f(2),因为 f(3) = f(2) + f(1),而 f(4) = f(3) + f(2) = f(2) + f(1) + f(2)。 这个是动态规划与暴力解法的关键区别,动态规划之所以性能高,是因为 不会对重复子问题进行重复计算,算法上一般通过缓存计算结果或者自底向上迭代的方式解决,但核心是这个场景要存在重复子问题。 当你觉得暴力解法可能很傻,存在大量重复计算时,就要想想是哪里存在重复子问题,是否可以用动态规划解决了。 无后效性即前面的选择不会影响后面的游戏规则。 寻路算法中,不会因为前面走了 B 路线而对后面路线产生影响。斐波那契数列因为第 N 项与前面的项是确定关联,没有选择一说,所以也不存在后效性问题。 什么场景存在后效性呢?比如你的人生是否能通过动态规划求最优解?其实是不行的,因为你今天的选择可能影响未来人生轨迹,比如你选择了计算机这个职业,会直接影响到工作的领域,接触到的人,后面的人生路线因此就完全变了,所以根本无法与选择了土木工程的你进行比较,因为人生赛道都变了。 有同学可能觉得这样局限是不是很大?其实不然,无后效性的问题仍然很多,比如背包放哪件物品、当前走哪条路线、用了哪些零钱,都不会影响整个背包大小、整张地图的地形、以及你最重要付款的金额。 解法套路 - 状态转移方程解决动态规划问题的核心就是写出状态转移方程,所谓状态转移,即通过某些之前步骤推导出未来步骤。 状态转移方程一般写为 dp(i) = 一系列 dp(j) 的计算,其中 j < i。 其中 i 与 dp(i) 的含义很重要,一般 dp(i) 直接代表题目的答案,i 就有技巧了。比如斐波那契数列,dp(i) 表示的答案就是最终结果,i 表示下标,由于斐波那契数列直接把状态转移方程告诉你了 f(x) = f(x-1) + f(x-2),那么根本连推导都不必了。 对于复杂问题,难在如何定义 i 的含义,以及下一步状态如何通过之前状态推导。 这个做多了题目就有体会,如果没有,那即便再如何解释也难以说明,所以后面还是直接看例子吧。 先举一个最简单的动态规划例子 - 爬楼梯来说明问题。 爬楼梯问题爬楼梯是一道简单题,题目如下: 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?(给定 n 是一个正整数) 首先 dp(i) 就是问题的答案(解法套路,dp(i) 大部分情况就是答案,这样解题思路会最简化),即爬到第 i 阶台阶的方法数量,那么 i 自然就是要爬到第几阶台阶。 我们首先看是否存在 最优子结构?因为只能往上爬,所以第 i 阶台阶有几种爬方完全取决于前面有几种爬方,而一次只能爬 1 或 2 个台阶,所以第 i 阶台阶只可能从第 i-1 或 i-2 个台阶爬上来的,所以第 i 个台阶的爬法就是 i-1 与 i-2 总爬法之和。所以显然有最优子结构,连状态转移方程都呼之欲出了。 再看是否存在 存在重复子问题,其实爬楼梯和斐波那契数列类似,最终的状态转移方程是一样的,所以显然存在重复子问题。当然直观来看也容易分析出,10 阶台阶的爬法包含了 8、9 阶的爬法,而 9 阶台阶爬法包含了 8 阶的,所以存在重复子问题。 最后看是否 无后效性?由于前面选择一次爬 1 个或 2 个台阶并不会影响总台阶数,也不会影响你下一次能爬的台阶数,所以无后效性。如果你爬了 2 个台阶,因为太累,下次只能爬 1 个台阶,就属于有后效性了。或者只要你一共爬了 3 次 2 阶,就会因为太累而放弃爬楼梯,直接下楼休息,那么问题提前结束,也属于有后效性。 所以爬楼梯的状态转移方程为: dp(i) = dp(i-1) + dp(i-2) dp(1) = 1 dp(2) = 2 注意,因为 1、2 阶台阶无法应用通用状态转移方程,所以要特殊枚举。这种枚举思路在代码里其实就是 递归终结条件,也就是作为函数 dp(i) 不能无限递归,当 i 取值为 1 或 2 时直接返回枚举结果(对这道题而言)。所以在写递归时,一定要优先写上递归终结条件。 然后我们考虑,对于第一阶台阶,只有一种爬法,这个没有争议吧。对于第二阶台阶,可以直接两步跨上来,也可以走两个一步,所以有两种爬法,也很容易理解,到这里此题得解。 关于代码部分,仅这道题写一下,后面的题目如无特殊原因就不写代码了: function dp(i: number) { switch (i) { case 1: return 1; case 2: return 2; default: return dp(i - 1) + dp(i - 2); }}return dp(n); 当然这样写重复计算了子结构,所以我们不要每次傻傻的执行 dp(i - 1)(因为这样计算了超多重复子问题),我们需要用缓存兜底: const cache: number[] = [];function dp(i: number) { switch (i) { case 1: cache[i] = 1; break; case 2: cache[i] = 2; break; default: cache[i] = cache[i - 1] + cache[i - 2]; } return cache[i];}// 既然用了缓存,最好子底向上递归,这样前面的缓存才能优先算出来for (let i = 1; i <= n; i++) { dp(i);}return cache[n]; 当然这只是简单的一维线性缓存,更高级的缓存模式还有 滚动缓存。我们观察发现,这道题缓存空间开销是 O(n),但每次缓存只用了上两次的值,所以计算到 dp(4) 时,cache[1] 就可以扔掉了,或者说,我们可以滚动利用缓存,让 cache[3] 占用 cache[1] 的空间,那么整体空间复杂度可以降低到 O(1),具体做法是: const cache: [number, number] = [];function dp(i: number) { switch (i) { case 1: cache[i % 2] = 1; break; case 2: cache[i % 2] = 2; break; default: cache[i % 2] = cache[(i - 1) % 2] + cache[(i - 2) % 2]; } return cache[i % 2];}for (let i = 1; i <= n; i++) { dp(i);}return cache[n % 2]; 通过取余,巧妙的让缓存永远交替占用 cache[0] 与 cache[1],达到空间利用最大化。当然,这道题因为状态转移方程是连续用了前两个,所以可以这么优化,如果遇到用到之前所有缓存的状态转移方程,就无法使用滚动缓存方案了。然而还有更高级的多维缓存,这个后面提到的时候再说。 接下来看一个进阶题目,最大子序和。 最大子序和最大子序和是一道简单题,题目如下: 给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。 首先按照爬楼梯的套路,dp(i) 就表示最大和,由于整数数组可能存在负数,所以越多数相加,和不一定越大。 接着看 i,对于数组问题,大部分 i 都可以代表以第 i 位结尾的字符串,那么 dp(i) 就表示以第 i 位结尾的字符串的最大和。 可能你觉得以 i 结尾,就只能是 [0-i] 范围的值,那么 [j-i] 范围的字符串不就被忽略了?其实不然,[j-i] 如果是最大和,也会被包含在 dp(i) 里,因为我们状态转移方程可以选择不连上 dp(i-1)。 现在开始解题:首先题目是最大和的连续子数组,一般连续的都比较简单,因为对于 dp(i),要么和前面连上,要么和前面断掉,所以状态转移方程为: dp(i) = dp(i-1) + nums[i] 如果 dp(i-1) > 0。 dp(i) = nums[i] 如果 dp(i-1) <= 0。 怎么理解呢?就是第 i 个状态可以直接由第 i-1 个状态推导出来,既然 dp(i) 是指以第 i 个字符串结尾的最大和,那么 dp(i-1) 就是以第 i-1 个字符串结尾的最大和,而且此时 dp(i-1) 已经算出来了,那么 dp(i) 怎么推导就清楚了: 因为字符串是连续的,所以 dp(i) 要么是 dp(i-1) + nums[i],要么就直接是 nums[i],所以选择哪种,取决于前面的 dp(i-1) 是否是正数,因为以 i 结尾一定包含 nums[i],所以 nums[i] 不管是正还是负,都一定要带上。 所以容易得知,dp(i-1) 如果是正数就连起来,否则就不连。 好了,经过这么详细的解释,相信你已经完全了解动态规划的解题套路,后面的题目解释方式我就不会这么啰嗦了! 这道题如果再复杂一点,不连续怎么办呢?让我们看看最长递增子序列问题吧。 最长递增子序列最长递增子序列是一道中等题,题目如下: 给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。 子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。 其实之前的 精读《DOM diff 最长上升子序列》 有详细解析过这道题,包括还有更优的贪心解法,不过我们这次还是聚焦在动态规划方法上。 这道题与上一道的区别就是,首先递增,其次不连续。 按照套路,dp(i) 就表示以第 i 个字符串结尾的最长上升子序列长度,那么重点是,dp(i) 怎么通过之前的推导出来呢? 由于是不连续的,因此不能只看 dp(i-1) 了,因为 nums[i] 项与 dp(j)(其中 0 <= j < i)组合后都可能达到最大长度,因此需要遍历所有 j,尝试其中最大长度的组合。 所以状态转移方程为: dp[i] = max(dp[j]) + 1,其中 0<=j<i 且 num[j]<num[i]。 这道题的出现,预示着较为复杂的状态转移方程的出现,即第 i 项不是简单由 i-1 推导,而是由之前所有 dp(j) 推导,其中 0<=j<i。 除此之外,还有推导变种,即根据 dp(dp(i)) 推导,即函数里套函数,这类问题由于加深了一层思考脑回路,所以相对更难。我们看一道这样的题目:最长有效括号。 最长有效括号最长有效括号是道困难题,题目如下: 给你一个只包含 '(' 和 ')' 的字符串,找出最长有效(格式正确且连续)括号子串的长度。 这道题之所以是困难题,就因为状态转移方程存在嵌套思维。 我们首先按套路定义 dp(i) 为答案,即以第 i 下标结尾的字符串中最长有效括号长度。看出来了吗?一般字符串题目中,i 都是以字符串下标结尾来定义,很少有定义为开头或者别的定义行为。当然非字符串问题就不是这样了,这个在后面再说。 我们继续题目,如果 s[i] 是 (,那么不可能组成有效括号,因为最右边一定不闭合,所以考虑 s[i] 为 ) 的场景。 如果 s[i-1] 为 (,那么构成了 ...() 之势,最后两个自成合法闭合,所以只要看前面的即可,即 dp(i-2),所以这种场景的状态转移方程为: dp(i) = dp(i-2) + 2 如果 s[i-1] 是 ) 呢?构成了 ...)) 的状态,那么只有 i-1 是合法闭合的,且这个合法闭合段之前必须是 ( 与第 i 项形成闭合,才构成此时最长有效括号长度,所以这种场景的状态转移方程为: dp(i) = dp(i-1) + dp(i - dp(i-1) - 2) + 2,你可以结合下面的图来理解: 可以看到,dp(i-1) 就是第二条横线的长度,然后如果红色括号匹配的话,长度又 +2,最后别忘了最左边如果有满足匹配的也要带上,这就是 dp(i - dp(i-1) - 2),所以加到一起就是这种场景的括号最大长度。 到这里,一维动态规划问题深度基本上探索完了,在进入多维动态规划问题前,还有一类一维动态规划问题,属于表达式不难,也没有这题这么复杂的嵌套 DP,但是思维复杂度极高,你一定不要盯着全流程看,那样复杂度太高,你需要充分认可 dp(i-x) 已经算出来部分的含义,进行高度抽象的思考。 栅栏涂色栅栏涂色是一道困难题,题目如下: 有 k 种颜色的涂料和一个包含 n 个栅栏柱的栅栏,每个栅栏柱可以用其中一种颜色进行上色。 你需要给所有栅栏柱上色,并且保证其中相邻的栅栏柱 最多连续两个 颜色相同。然后,返回所有有效涂色的方案数。 这道题 k 和 n 都非常巨大,常规暴力解法甚至普通 DP 都会超时。选择 i 的含义也很重要,这里 i 到底代表用几种颜色还是几个栅栏呢?选择栅栏会好做一些,因为栅栏是上色的主体。这样 dp(i) 就表示上色前 i 个栅栏的所有涂色方案。 首先看下递归终止条件。由于最多连续两个颜色相同,因此 dp(0) 与 dp(1) 分别是 k 与 k*k,因为每个栅栏随便刷颜色,自由组合。那么 dp(2) 有三个栅栏,非法情况是三个栅栏全同色,所以用所有可能减掉非法即可,非法场景只有 k 中,所以结果是 k*k*k - k。 那么考虑一般情况,对于 dp(i) 有几种涂色方案呢?直接思考情况太多,我们把情况一分为二,考虑 i 与 i-1 颜色相同与不同两种情况考虑。 如果 i 与 i-1 颜色相同,那么为了合法,i-1 肯定不能与 i-2 颜色相同了,否则就三个同色,这样的话,不管 i-2 是什么颜色,i-1 与 i 都只能少取一种颜色,少取的颜色就是 i-2 的颜色,因此 [i-1,i] 这个区间有 k-1 中取色方案,前面有 dp(i-2) 种取色方案,相乘就是最终方案数:dp(i-2) * (k-1)。 这背后其实存在动态思维,即每种场景的 k-1 都是不同的颜色组合,只是无论前面 dp(i-2) 是何种组合,后面两个栅栏一定有 k-1 种取法,虽然颜色组合的色值不同,但颜色组合数量是不变的,所以可以统一计算。理解这一点非常关键。 如果 i 与 i-1 颜色不同,那么第 i 项只有 k-1 种取法,一样也是动态的,因为永远不能和 i-1 颜色相同。最后乘上 dp(i-1) 的取色方案,就是总方案数:dp(i-1) * (k-1)。 所以最后总方案数就是两者之和,即 dp(i) = dp(i-2) * (k-1) + dp(i-1) * (k-1)。 这道题的不同之处在于,变化太多,任何一个栅栏取的颜色都会影响后面栅栏要取的颜色,乍一看觉得是个有后效性的题目,无法用动态规划解决。但实际上,虽然有后效性,但如果进行合理的拆解,后面栅栏的总可能性 k-1 是不变的,所以考虑总可能性数量,是无后效性的,因此站在方案总数上进行抽象思考,才可能破解此题。 接下来介绍多维动态规划,从二维开始。二维动态规划就是用两个变量表示 DP,即 dp(i,j),一般在二维数组场景出现较多,当然也有一些两个数组之间的关系,也属于二维动态规划,为了继续探讨字符串问题,我选择了字符串问题的二维动态规划范例,编辑距离这道题来说明。 编辑距离编辑距离是一道困难题,题目如下: 给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数。 你可以对一个单词进行如下三种操作: 插入一个字符 删除一个字符 替换一个字符 只要是字符串问题,基本上 i 都表示以第 i 项结尾的字符串,但这道题有两个单词字符串,为了考虑任意匹配场景,必须用两个变量表示,即 i j 分别表示 word1 与 word2 结尾下标时,最少操作次数。 那么对于 dp(i,j) 考虑 word1[i] 与 word2[j] 是否相同,最后通过双重递归,先递归 i,在递归内再递归 j,答案就出来了。 假设最后一个字符相同,即 word1[i] === word2[j] 时,由于最后一个字符不用改就相同了,所以操作次数就等价于考虑到前一个字符,即 dp(i,j) = dp(i-1,j-1) 假设最后一个字符不同,那么 最后一步 有三种模式可以得到: 假设是替换,即 dp(i,j) = dp(i-1,j-1) + 1,因为替换最后一个字符只要一步,并且和前面字符没什么关系,所以前面的最小操作次数直接加过来。 假设是插入,即 word1 插入一个字符变成 word2,那么只要变换到这一步再 +1 插入操作就行了,变换到这一步由于插入一个就行了,因此 word1 比 word2 少一个单词,其它都一样,要变换到这一步,就要进行 dp(i,j-1) 的变换,因此 dp(i,j) = dp(i,j-1) + 1。。 假设是删除,即 word1 删除一个字符变成 word2,同理,要进行 dp(i-1,j) 的变化后多一步删除,因此 dp(i,j) = dp(i-1,j) + 1。 由于题目取操作最少次数,所以这三种情况取最小即可,即 dp(i,j) = min(dp(i-1,j-1), dp(i,j-1), dp(i-1,j)) + 1。 所以同时考虑了最后一个字符是否相同后,合并了的状态转移方程就是最终答案。 我们再考虑终止条件,即 i 或 j 为 -1 时的情况,因为状态转移方程 i 和 j 不断减小,肯定会减少到 0 或 -1,因为 0 是字符串还有一个字符,相对比如考虑 -1 字符串为空时方便,因此我们考虑 -1 时作为边界条件。 当 i 为 -1 时,即 word1 为空,此时要变换为 word2 很显然,只有插入 j 次是最小操作次数,因此此时 dp(i,j) = j;同理,当 j 为 -1 时,即 word2 为空,此时要删除 i 次,因此操作次数为 i,所以 dp(i,j) = i。 非字符串问题说到这,相信你在字符串动规问题上已经如鱼得水了,我们再看看非字符串场景的动规问题。非字符串场景的动规比较经典的有三个,第一是矩形路径最小距离,或者最大收益;第二是背包问题以及变种;第三是打家劫舍问题。 这些问题解决方式都一样,只是对于 dp(i) 的定义略有区别,比如对于矩形问题来说,dp(i,j) 表示走到 i,j 格子时的最小路径;对于背包问题,dp(i,j) 表示装了第 i 个物品时,背包还剩 j 空间时最大价格;对于打家劫舍问题,dp(i) 表示打劫到第 i 个房间时最大收益。 因为篇幅问题这里就不一详细介绍了,只简单说明一下矩形问题于打家劫舍问题。 对于矩形问题,状态转移方程重点看上个状态是如何转移过来的,一般矩形只能向右或者向下移动,路途可能有一些障碍物不能走,我们要做分支判断,然后选择一条符合题目最值要求的路线作为当前 dp(i) 的转移方程即可。 对于打家劫舍问题,由于不能同时打劫相邻的房屋,所以对于 dp(i),要么为了打劫 i-1 而不打劫第 i 间,或者打劫 i-2 于第 i 间,取这两种终态的收益最大值即可,即 dp(i) = max(dp(i-1), dp(i-2) + coins[i])。 总结动态规划的核心分为三步,首先定义清楚状态,即 dp(i) 是什么;然后定义状态转移方程,这一步需要一些思考技巧;最后思考验证一下正确性,即尝试证明你写的状态转移方程是正确的,在这个过程要做到状态转移的不重不漏,所有情况都被涵盖了进来。 动态规划最经典的还是背包问题,由于篇幅原因,可能下次单独出一篇文章介绍。 讨论地址是:精读《算法 - 动态规划》· Issue ##327 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"组件值与联动","path":"/wiki/WebWeekly/可视化搭建/组件值与联动.html","content":"当前期刊数: 273 组件联动是指几个组件相互关联。也就是当一个组件状态变化时,其他组件可以响应。 组件联动是多对多关系的,且目的分为一次性与持续性: 多对多关系:即一个组件可以同时被多个组件联动;多个组件可以同时联动一个组件。 一次性与持续性:一次性事件可以被覆盖,持续性事件会同时生效,且要考虑叠加关系。 一定程度上,持续性事件可以覆盖一次性事件的场景:组件永远响应最后一个过来的事件即可。 接下来我们引入 组件值 与 值联动 两个概念,来实现持续性联动功能。 组件值每个组件实例都有一个唯一的组件值。 我们可以通过 getValue(componentId) 与 setValue(componentId, value) 访问或更新组件值: const table = { componentName: "table", runtimeProps: ({ componentId, setValue }) => ({ // 给组件注入 onChange 函数,在其触发时更新当前组件实例的组件值 onChange: (value) => setValue(componentId, value), }),}; 也可以通过 componentMeta.value 声明组件值,比如下面的例子,让组件值与 props.value 同步: const table = { componentName: "table", // 声明 value 的值为组件 props.value 的返回值,并随着组件 props.value 的更新而更新 value: ({ selector }) => selector(({ props }) => props.value),}; 以上两种方式任选一种使用即可。 为什么一个组件实例只有一个组件值? 一个组件可能同时拥有多个状态,比如该组件内部有一个输入框,还有一个按钮,可能输入框的值,与按钮的点击状态都会对其他组件产生联动效果。但这并不意味着一个组件实例需要多个组件值,我们可以将组件值定义为对象,并合理规划不同的 key 描述不同维度的值: // 组件值结构{ // 组件内输入框的值 text: '123', // 组件内按钮被按下的次数 buttonClickTimes: false} 为什么不用 props.value 代替组件值? 理论上可以,但这样限定了组件对 props 的定义。也许有的组件用 props.value 描述输入框的值,但也有比如 Check 组件,用 props.checked 表示当前选中状态。只有抽象一个定义与组件元信息的规则,让业务自由对接,才可以让组件值适配任意类型的组件。 值联动有了组件值这个概念,就可以以组件实例为粒度,设计组件的关联关系了。 为了让组件关联更加灵活,我们的设计需要满足以下几种能力: 联动关系支持多对多。 可以随着全局数据状态变化,或者组件自身 props 变化,随时改变组件关联关系。 一个组件可以定义其他几个组件的关联关系,哪怕自己不参与到联动关系链中。 当组件实例被删除时,由它定义的联动关系立刻失效。 估我们采用 componentMeta.valueRelates 声明式定义值联动关系: const table = { componentName: "table", valueRelates: ({ componentId, selector }) => { return [ { sourceComponentId: componentId, // 自己为触发源 targetComponentId: selector(({ props }) => props.targetComponentId), // 目标组件 ID 为 props.targetComponentId }, ]; },}; 这样设计可以同时满足以上四个要求,解释如下: 可以在任意组件实例定义多个联动关系,自然可以实现多对多联动。 valueRelates 引入 selector 可以响应 state 或 props 的变化,可以由任意状态驱动联动关系更新。 如果 source 与 target 都不指向自己,则自己不参与到联动关系链中。 声明式定义方式,自然在组件实例被销毁时失效。 那么组件如何响应联动呢?重点就在这里,组件可以通过 selector(({ relates }) =>) 的 relates 拿到自己当前的联动状态,比如: const table = { componentName: "table", runtimeProps: ({ selector }) => { // relates 结构如下,对于每一个作用于自己的组件实例 ID 与最新 value 值都可以拿到 // [{ // sourceComponentId: 'abc', // value: '123' // }] const relates = selector(({ relates }) => relates); return { status: relates.length > 0 ? "linked" : "free", }; },}; 如果我们在 runtimeProps 里使用 selector 监听 relates,就可以在联动状态变化时,驱动组件渲染,并传入联动相关状态;如果在 fetcher 里使用 selector 监听 relates,就可以在联动状态变化时,驱动组件触发查询,等等。 以后我们拓展越来越多的组件元信息回调函数,支持了 selector 之后,都可以声明式的响应 relates 变化,也就是组件可以声明式灵活响应联动,真正意义上让联动可以用在任何场景。 框架没有对联动做太多的联动内置行为,实现的都是灵活规则,虽然业务需要补全不少声明,但胜在灵活与用法统一。 描述联动行为不同的联动可能做不同的事,比如一个输入框组件,可能同时有以下两种作用: 让另一个组件查询条件增加 “where name=” 当前输入框的值。 当组件的值为 “delete” 时,让画布另一个组件隐藏。 为了区分联动的功能,可以在 valueRelates 增加 payload 参数,描述该联动的目的: const table = { componentName: "table", valueRelates: ({ componentId, selector }) => { return [ { sourceComponentId: componentId, targetComponentId: selector(({ props }) => props.targetComponentId), // 作用为目标组件的查询筛选条件 payload: "filter", }, { sourceComponentId: componentId, targetComponentId: selector(({ props }) => props.targetComponentId), // 作用为目标组件是否隐藏 payload: "hide", }, ]; },}; 然后目标组件就可以根据实际情况,在 fetcher 过滤 relates 中 payload="filter" 的值,在 runtimeProps 过滤 relates 中 payload="hide" 的值。 用持续联动实现一次性联动每一次组件更新 value 值后,都会刷新对目标组件 relates 的位置,具体来说,会将其置顶,所以目标组件可以根据 relates 先来后到顺序判断,比如在联动效果冲突时,让排在前面的优先生效。 比如: const table = { componentName: "table", runtimeProps: ({ selector }) => { // 找到最初生效的,payload 为 color 的联动,覆盖 props.color const relateColor = selector(({ relates }) => relates.find((each) => each.payload === "color") ); return { color: relateColor, }; },}; 当另一个组件触发 value 变化时,它会排在目标组件 relates 最前面,这样的话,如果目标组件按照如上方式编写响应代码,就总会响应最后一次生效的联动。 总结这一节介绍了如何设置联动,并引出了组件值概念。 在框架层定义抽象的组件值概念,并通过声明式或调用式对接到 state 状态或组件 props,这种抽象理念会贯穿整个框架的设计过程。相似的 valueRelates 也具有声明式能力,并将联动作用通过 selector 的 relates 对象传递给组件实例使用,让联动的消费灵活度大大增加。 可视化搭建框架设计思路可能都大同小异,但可惜的是,许多搭建框架都对比如联动、查询等场景做了定制化约束,使每个框架或多或少存在着私有协议,而我在这个系列想强调的是,可以进一步抽象,让框架提供业务自由定义协议的能力,而不是提供某个固定的协议。 讨论地址是:精读《组件值与联动》· Issue ##469 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法 - 回溯》","path":"/wiki/WebWeekly/算法/《算法 - 回溯》.html","content":"当前期刊数: 200 如何尝试走迷宫呢?遇到障碍物就从头 “回溯” 继续探索,这就是回溯算法的形象解释。 更抽象的,可以将回溯算法理解为深度遍历一颗树,每个叶子结点都是一种方案的终态,而对某条路线的判断可能在访问到叶子结点之前就结束。 相比动态规划,回溯可以解决的问题更复杂,尤其是针对具有后效性的问题。 动态规划之所以无法处理有后效性问题,原因是其 dp(i)=F(dp(j)) 其中 0<=j<i 导致的,因为 i 通过 i-1 推导,如果 i-1 的某种选择会对 i 的选择产生影响,那么这个推导就是无效的。 而回溯,由于每条分支判断是相互独立的,互不影响,所以即便前面的选择具有后效性,这个后效性也可以在这条选择线路持续影响下去,而不影响其他分支。 所以回溯是一种适用性更广的算法,但相对的,其代价(时间复杂度)也更高,所以只有当没有更优算法时,才应当考虑回溯算法。 精读经过上述思考,回溯算法的实现思路就清晰了:递归或迭代。由于两者可以相互转换,而递归理解成本较低,因此我更倾向于递归方式解决问题。 这里必须提到一点,即工作与算法竞赛思维的区别:由于递归调用堆栈深度较大,整体性能不如迭代好,且迭代写法不如递归自然,所以做算法题时,为了提升那么一点儿性能,以及不经意间流露自己的实力,可能大家更倾向用迭代方式解决问题。 但工作中,大部分是性能不敏感场景,可维护性反而是更重要的,所以工程代码建议用更易理解的递归方式解决问题,把堆栈调用交给计算机去做。 其实算法代码追求更简短,能写成一行的绝不换行也是同样的道理,希望大家能在不同环境里自由切换习惯,而不要拘泥于一种风格。 用递归解决回溯的套路不止一种,我介绍一下自己常用的 TS 语言方法: function func(params: any[], results: any[] = []) { // 消耗 params 生成 currentResult const { currentResult, restParams } = doSomething(params); // 如果 params 还有剩余,则递归消耗,直到 params 耗尽为止 if (restParams.length > 0) func(restParams, results.concat(currentResult));} 这里 params 就类似迷宫后面的路线,而 results 记录了已走的最佳路线,当 params 路线消耗完了,就走出了迷宫,否则终止,让其它递归继续走。 所以回溯逻辑其实挺好写的,难在如何判断这道题应该用回溯做,以及如何优化算法复杂度。 先从两道入门题讲起,分别是电话号码的字母组合与复原 IP 地址。 电话号码的字母组合电话号码的字母组合是一道中等题,题目如下: 给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。 给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。 电话号码数字对应的字母其实是个映射表,比如 2 映射 a,b,c,3 映射 d,e,f,那么 2,3 能表示的字母组合就有 3x3=9 种,而要打印出比如 ad、ae 这种组合,肯定要用穷举法,穷举法也是回溯的一种,只不过每一种可能性都要而已,而复杂点儿的回溯可能并不是每条路径都符合要求。 所以这道题就好做了,只要构造出所有可能的组合就行。 接下来我们看一道类似,但有一定分支合法判断的题目,复原 IP 地址。 复原 IP 地址复原 IP 地址是一道中等题,题目如下: 给定一个只包含数字的字符串,用以表示一个 IP 地址,返回所有可能从 s 获得的 有效 IP 地址 。你可以按任何顺序返回答案。 有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 ‘.’ 分隔。 例如:”0.1.2.201” 和 “192.168.1.1” 是 有效 IP 地址,但是 “0.011.255.245”、”192.168.1.312” 和 “192.168@1.1“ 是 无效 IP 地址。 首先肯定一个一个字符读取,问题就在于,一个字符串可能表示多种可能的 IP,比如 25525511135 可以表示为 255.255.11.135 或 255.255.111.35,原因在于,11.135 和 111.35 都是合法的表示,所以我们必须用回溯法解决问题,只是回溯过程中,会根据读取数据动态判定增加哪些新分支,以及哪些分支是非法的。 比如读取到 [1,1,1,3,5] 时,由于 11 和 111 都是合法的,因为这个位置的数字只要在 0~255 之间即可,而 1113 超过这个范围,所以被忽略,所以从这个场景中分叉出两条路: 当前项:11,余项 135。 当前项:111,余项 35。 之后再递归,直到非法情况终止,比如以及满了 4 项但还有剩余数字,或者不满足 IP 范围等。 可见,只要梳理清楚合法与非法的情况,直到如何动态生成新的递归判断,这道题就不难。 这道题输入很直白,直接给出来了,其实不是每道题的输入都这么容易想,我们看下一道全排列。 全排列全排列是一道中等题,题目如下: 给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。 与还原 IP 地址类似,我们也是消耗给的输入,比如 123,我们可以先消耗 1,余下 23 继续组合。但与 IP 复原不同的是,第一个数字可以是 1 2 3 中的任意一个,所以其实在生成当前项时有所不同:当前项可以从所有余项里挑选,然后再递归即可。 比如 123 的第一次可以挑选 1 或 2 或 3,对于 1 的情况,还剩 23,那么下次可以挑选 2 或 3,当只剩一项时,就不用挑了。 全排列的输入虽然不如还原 IP 地址的输入直白,但好歹是基于给出的字符串推导而出的,那么再复杂点的题目,输入可能会拆解为多个,这需要你灵活思考,比如括号生成题目。 括号生成括号生成是一道中等题,题目如下: 数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。 示例:输入:n = 3 输出:[“((()))”,”(()())”,”(())()”,”()(())”,”()()()”] 这道题基本思路与上一题很像,而且由于题目问的是所有可能性,而不是最优解,所以无法用动规,所以我们考虑回溯算法。 上一道 IP 题目的输入是已知字符串,而这道题的输入就要你动动脑经了。这道题的输入是字符串吗?显然不是,因为输入是括号数量,那么只有一个括号数量就够了吗?不够,因为题目要求有效括号,那什么是有效括号?闭合的才是,所以我们想到用左右括号数量表示这个数字,即输入是 n,那么转化为 open=n, close=n。 有了输入,如何消耗输入呢?我们每一步都可以用一个左括号 open 或一个右括号 close,但第一个必须是 open,且当前已消耗 close 数量必须小于已消耗 open 数量时,才可以加上 close,因为一个 close 左边必须有个 open 形成合法闭合。 所以这道题就迎刃而解了。回顾来看,回溯的入参要能灵活思考,而这个思考取决于你的经验,比如遇到括号问题,下意识就直到拆解为左右括号。所以算法之间是相通的,适当的知识迁移可以事半功倍。 好了,在此我们先打住,其实不是所有题目都可以用回溯解决,但有些题目看上去只是回溯题目的变种,但其实不然。我们回到上一道全排列题,与之比较像的是 下一个排列,这道题看上去好像是基于全排列衍生的,但却无法用回溯算法解决,我们看看这道题。 下一个排列下一个排列是一道中等题,题目如下: 实现获取 下一个排列 的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。 如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。 必须 原地 修改,只允许使用额外常数空间。 比如: 输入:nums = [1,2,3] 输出:[1,3,2] 输入:nums = [3,2,1] 输出:[1,2,3] 如果你在想,能否借鉴全排列的思想,在全排列过程中自然推导出下一个排列,那大概率是想不通的,因为从整体推导到局部的效率太低,这道题直接给出一个局部值,我们必须用相对 “局部的方法” 快速推导出下一个值,所以这道题无法用回溯算法解决。 \b 对于 3,2,1 的例子,由于已经是最大排列了,所以下个排列只能是初始化的 1,2,3 升序,这个是特例。除此之外,都有下一个更大排列,以 1,2,3 为例,更大的是 1,3,2 而不是 2,1,3。 我们再观察长一点的例子,比如 3,2,1,4,5,6,可以发现,无论前面如何降序,只要最后几个是升序的,只要把最后两个扭转即可:3,2,1,4,6,5。 如果是 3,2,1,4,5,6,9,8,7 呢?显然 9,8,7 任意相邻交换都会让数字变得更小,不符合要求,我们还是要交换 5,6 .. 不 6,9,因为 65x 比 596 要大更多。到这里我们得到几个规律: 尽可能交换后面的数。交换 5,6 会比交换 6,9 更大,因为 6,9 更靠后,位数更小。 我们将 3,2,1,4,5,6,9,8,7 分为两段,分别是前段 3,2,1,4,5,6 和后段 9,8,7,我们要让前段尽可能大的数和后段尽可能小的数交换,同时还要保证,后段尽可能小的数比前段尽可能大的数还要 大。 为了满足第二点,我们必须从后向前查找,如果是升序就跳过,直到找到一个数字 j 比 j-1 小,那么前段作为交换的就是第 j 项,后段要找一个最小的数与之交换,由于搜索的算法导致后段一定是降序的,因此从后向前找到第一个比 j 大的项交换即可。 最后我们发现,交换后也不一定是完美下一项,因为后段是降序的,而我们已经把前面一个尽可能最小的 “大” 位改大了,后面一定要升序才满足下一个排列,因此要把后段进行升序排列。 因为后段已经满足降序了,因此采用双指针交换法相互对调即可变成升序,这一步千万不要用快排,会导致整体时间复杂度提高 O(nlogn)。 最后由于只扫描了一次 + 反转后段一次,所以算法复杂度是 O(n)。 从这道题可以发现,不要轻视看似变种的题目,从全排列到下一个排列,可能要完全换一个思路,而不是对回溯进行优化。 我们继续回到回溯问题,回溯最经典的问题就是 N 皇后,也是难度最大的题目,与之类似的还有解决数独问题,不过都类似,我们这次还是以 N 皇后作为代表来理解。 N 皇后问题N 皇后问题是一道困难题,题目如下: n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。 给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。 每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。 皇后的攻击范围非常广,包括横、纵、斜,所以当 n<4 时是无解的,而神奇的时,n>=4 时都有解,比如下面两个图: 这道题显然具有 “强烈的” 后效性,因为皇后攻击范围是由其位置决定的,换而言之,一个皇后位置确定后,其他皇后的可能摆放位置会发生变化,因此只能用回溯算法。 那么如何识别合法与非法位置呢?核心就是根据横、纵、斜三种攻击方式,建立四个数组,分别存储哪些行、列、撇、捺位置是不能放置的,然后将所有合法位置都作为下一次递归的可能位置,直到皇后放完,或者无位置可放为止。 容易想到的就是四个数组,分别存储被占用的下标,这样的话,只是递归中条件判断分支复杂一些,其它其实并无难度。 这道题的空间复杂度进阶算法是,利用二进制方式,使用 4 个数字 代替四个下标数组,每个数组转化为二进制时,1 的位置代表被占用,0 的位置代表未占用,通过位运算,可以更快速、低成本的进行位置占用,与判断当前位置是否被占用。 这里只提一个例子,就可以感受到二进制魅力: 由于按照行看,一行只能放一个皇后,所以每次都从下一行看起,因此行限制就不用看了(至少下一行不可能和前面的行冲突),所以我们只要记录列、撇、捺三个位置即可。 不同之处在于,我们采用二进制的数字,只要三个数字即可表示列、撇、捺。二进制位中的 1 表示被占用,0 表示不被占用。 比如列、撇、捺分别是变量 x,y,z,对应二进制可能是: 0000001 0010000 0001100 “非” 逻辑是任意为 1 就是 1,因此 “非” 逻辑可以将所有 1 合并,即 x | y | z 即 0011101。 然后将这个结果取反,用非逻辑,即 ~(x | y | z),结果是 1100010,那这里所有的 1 就表示可放的位置,我们记这个变量为 p,通过 p & -p 不断拿最后一位 1 得到安放位置,即可调用递归了。 从这道题可以发现,N 皇后难度不在于回溯算法,而在于如何利用二进制写出高效的回溯算法。所以回溯算法考察的比较综合,因为算法本身很模式化,而且相对比较 “笨拙”,所以需要将更多重心放在优化效率上。 总结回溯算法本质上是利用计算机高速计算能力,将所有可能都尝试一遍,唯一区别是相对暴力解法,可能在某个分支提前终止(枝剪),所以其实是一个较为笨重的算法,当题目确实具有后效性,且无法用贪心或者类似下一排列这种巧妙解法时,才应该采用。 最后我们要总结对比一下回溯与动态规划算法,其实动态规划算法的暴力递归过程就与回溯相当,只是动态规划可以利用缓存,存储之前的结果,避免重复子问题的重复计算,而回溯因为面临的问题具有后效性,不存在重复子问题,所以无法利用缓存加速,所以回溯算法高复杂度是无法避免的。 回溯算法被称为 “通用解题方法”,因为可以解决许多大规模计算问题,是利用计算机运算能力的很好实践。 讨论地址是:精读《算法 - 回溯》· Issue ##331 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法题 - 统计可以被 K 整除的下标对数目》","path":"/wiki/WebWeekly/算法/《算法题 - 统计可以被 K 整除的下标对数目》.html","content":"当前期刊数: 284 今天我们看一道 leetcode hard 难度题目:统计可以被 K 整除的下标对数目。 题目给你一个下标从 0 开始、长度为 n 的整数数组 nums 和一个整数 k ,返回满足下述条件的下标对 (i, j) 的数目: 0 <= i < j <= n - 1 且 nums[i] * nums[j] 能被 k 整除。 示例 1: 输入:nums = [1,2,3,4,5], k = 2输出:7解释:共有 7 对下标的对应积可以被 2 整除:(0, 1)、(0, 3)、(1, 2)、(1, 3)、(1, 4)、(2, 3) 和 (3, 4)它们的积分别是 2、4、6、8、10、12 和 20 。其他下标对,例如 (0, 2) 和 (2, 4) 的乘积分别是 3 和 15 ,都无法被 2 整除。 思考首先想到的是动态规划,一个长度为 n 的数组结果与长度为 n-1 的关系是什么? 首先 n-1 时假设算好了一个结果 result,那么长度为 n 时,新产生的匹配是下标 [0, n-1] 与下标 n 数字的匹配关系,假设这些关系中有 q 个满足题设,则最终答案是 result + q。 这种想法适合 (i, j) 满足任意关系的题目,代码如下: function countPairs(nums: number[], k: number): number { if (nums.length < 2) { return 0 } const dpCache: Record<number, number> = {} for (let i = 1; i < nums.length; i++) { switch (i) { case 1: if (nums[0] * nums[1] % k === 0) { dpCache[1] = 1 } else { dpCache[1] = 0 } break default: // [0,i-1] 洗标范围内与 i 下标组合,看看有多少种可能 let currentCount = 0 for (let j = 0; j <= i - 1; j++) { if (nums[j] * nums[i] % k === 0) { currentCount++ } } dpCache[i] = dpCache[i - 1] + currentCount } } return dpCache[nums.length - 1]}; 很可惜超时了,因为回头想想,虽然思路是 dp,但本质上是暴力解法,时间复杂度是 O(n²)。 为了 AC,必须采用更低复杂度的算法。 利用最大公约数解题如果只循环一次数组,那么必须在循环到数组每一项的时候,就能立刻知道该项与其他哪几项的乘积符合 nums[i] * nums[j] 能被 k 整除,这样的话累加一下就能得到答案。 也就是说,拿到数字 nums[i] 与 k,我们要知道有哪些 nums[j] 是满足要求的。 当然,如果把所有剩余数字循环一遍来找满足条件的 nums[j],那时间复杂度就还是 O(n²),但不循环似乎无法继续思考了,这道题很容易在这里陷入僵局。 接下来就要发散思维了,先想这个问题:满足条件的 nums[j] 要满足 nums[i] * nums[j] % k === 0,那除了通过遍历把每一项 nums[j] 拿到真正的算一遍之外,还有什么更快的办法呢? 除了真的算一下之外,想想 nums[j] 还要具备什么特性?这个特性最好和倍数有关,因为如果我们计算所有数字倍数出现的个数,时间复杂度会比较低。 nums[i] 与 k 的最大公约数就满足这个条件,因为我们希望的是 nums[j] * nums[i] 是 k 的倍数,那么 nums[j] 最小的值就是 k / nums[i],但这个除出来可能不是整数,那必须保证 k 除以的数字是一个整数,这个除数用 nums[i] 与 k 的最大公约数最划算。nums[j] 可以更大,只要是这个结果的倍数就行了,总结一下,nums[j] 要满足是 k / gcd(nums[i], k) 的倍数。 再重点解释下原因,我们假设 nums[i] = 2, k=100,此时是 k 比较大的情况,那么其最大公约数一定小于等于 nums[i],因此 k / 最大公约数 * nums[j] 得到的数字一定大于 k / nums[i] * nums[j],毕竟最大公约数比 nums[i] 小嘛,而 k / nums[i] * nums[j] 就是不考虑 nums[j] 是整数情况下让 k 可以整除 nums[i] * nums[j] 时,nums[j] 取的最小值的情况,因此 nums[j] 只要是 k / 最大公约数 的倍数就行了。 反之,如果 k 比 nums[i] 小,比如 nums[i] = 100, k=2,此时最大公约数是小于等于 k 的,但用一个比 k 还要大的 nums[i] 作为乘法的一边,乘出来的结果肯定大于 k,所以不用担心 nums[i] * nums[j] < k 的情况,所以 nums[j] 只要是 k / 最大公约数 的倍数就行了。 综上,无论如何 nums[j] 只要是 k / 最大公约数 的倍数就行了。 所以对于每一个 nums[i],我们能快速计算出 x = k / gcd(nums[i], k),接下来只要找到 nums 所有数字中,是 x 倍数的有多少累加起来就行了。这一步也不能鲁莽,因为数组长度非常大,性能更好的方案是:先从1开始到最大值,计算出每个数字的倍数有几个,存在一个 map 表里,之后找倍数有几个直接从 map 表里获取就行了。 比如有数字 1 ~ 10,我们要计算每个数字的倍数出现了几次,大概是这么算的: 1,2,3… 数到 10,那么 1 的倍数有 10 个数字。 2,4,6,8,10 数 5 次,那么 2 的倍数有 5 个数字。 3,6,9 数 3 次,那么 3 的倍数有 3 个数字。 以此类推,我们发现一个规律,即对于长度为 n 的数组,要数的总次数为 n + n/2 + n/3 + ... + 1,这是一个调和数列,具体怎么证明的笔者已经忘了,但可以记住它的值趋向于欧拉常数 + ln(n+1),这就是要数的次数,所以用这个方案,整体时间复杂度是 O(nlnn),比 O(n²) 小了很多。 所以我们只要 “暴力” 的从 1 开始到 nums 最大的数字,把所有数字的倍数都提前计算出来,最后的时间复杂度反而会更小,这是非常神奇的结论。为了避免计算多余的倍数关系,反而时间复杂度是 O(n²),而暴力计算所有数字倍数的时间复杂度竟然是 O(nlnn),这个可以背下来。 接下来就简单了,直接上代码。 用 js 实现 gcd(最大公约数)计算可以用辗转相除法: function gcd(left: number, right: number) { return right === 0 ? left : gcd(right ,left % right)} 整体代码实现: function countPairs(nums: number[], k: number): number { // nums 最大的数字 let max = 0 nums.forEach(num => max = Math.max(num, max)) // Map<数字x, 数字x 倍数在 nums 中出现的次数> const mutipleMap: Record<number, number> = {} // 先遍历一次 nums,将其倍数次自增 nums.forEach(num => { if (mutipleMap[num] === undefined) { mutipleMap[num] = 1 } else { mutipleMap[num]++ } }) // 按以下规律数倍数出现的次数,但忽略自身 // 1,2,3...,max // 2,4,6...,max // 3,6,9...,max for (let i = 1; i <= max; i++) { for (let j = i * 2; j <= max; j+=i) { if (mutipleMap[i] === undefined) { mutipleMap[i] = 0 } mutipleMap[i] += mutipleMap[j] ?? 0 } } // 答案 let result = 0 // k / gcd(num, k) 的数组出现的次数累加 nums.forEach(num => { const targetMutiple = k / gcd(num, k) result += mutipleMap[targetMutiple] ?? 0 }) // 排除自己乘以自己满足条件的情况 nums.forEach(num => { if (num * num % k === 0) { result-- } }) return result / 2}; 有几个注意要点。 第一个是 for (let j = i * 2,之所以要乘以 2,是因为在前面遍历 nums 时,自己的倍数已经被算过一次,比如 3,6,9 的 3 已经被初始化算过一次,所以从 3*2=6 开始就行了。 第二个是 mutipleMap[i] += mutipleMap[j],比如 i=3,j=9 时,因为 9 是 3 的倍数,所以此时 3 的倍数可以继承 9 的倍数的数量,而数字是不断变大的,所以不会重复。 第三个是 if (num * num % k === 0) { result-- },因为题目要求 0 <= i < j <= n - 1,但我们计算倍数时,比如 9 是 3 的倍数,但 9 可以通过 3 * 3 得到,这种不合规的数据要过滤掉。 第四个是 return result / 2,因为在最后累加次数时,把每个数字与其他数字都判断了一遍,假设 1, 3 是合法的,那么 3, 1 也肯定是合法的,但因为 i < j 的要求,我们要把 3, 1 干掉,所有合法的结果都存在顺序颠倒的 case,所以除以 2. 总结这道题很容易栽在动态规划超时的坑上面,要解决此题需要跨越两座大山: 想到最大公约数与另一个数字之间的关系。 意识到暴力计算倍数的时间复杂度是 O(nlnn)。 最后,本题还隐含了 n + n/2 + n/3 + ... + 1 为什么极限是 O(nlnn) 的知识,背后有一个 调和数列 的大知识背景,感兴趣的同学可以深入了解。 讨论地址是:精读《算法 - 统计可以被 K 整除的下标对数目》· Issue ##495 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法题 - 地下城游戏》","path":"/wiki/WebWeekly/算法/《算法题 - 地下城游戏》.html","content":"当前期刊数: 286 今天我们看一道 leetcode hard 难度题目:地下城游戏。 恶魔们抓住了公主并将她关在了地下城 dungeon 的 右下角 。地下城是由 m x n 个房间组成的二维网格。我们英勇的骑士最初被安置在 左上角 的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。 骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。 有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为负整数,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为正整数,则表示骑士将增加健康点数)。 为了尽快解救公主,骑士决定每次只 向右 或 向下 移动一步。 返回确保骑士能够拯救到公主所需的最低初始健康点数。 注意:任何房间都可能对骑士的健康点数造成威胁,也可能增加骑士的健康点数,包括骑士进入的左上角房间以及公主被监禁的右下角房间。 输入:dungeon = [[-2,-3,3],[-5,-10,1],[10,30,-5]] 输出:7 解释:如果骑士遵循最佳路径:右 -> 右 -> 下 -> 下 ,则骑士的初始健康点数至少为 7 。 思考挺像游戏的一道题,首先只能向下或向右移动,所以每个格子可以由上面或左边的格子移动而来,很自然想到可以用动态规划解决。 再想一想,该题必须遍历整个地下城而无法取巧,因为最低健康点数无法由局部数据算出,这是因为如果不把整个地下城走完,肯定不知道是否有更优路线。 动态规划二维迷宫用两个变量 i j 定位,其中 dp[i][j] 描述第 i 行 j 列所需的最低 HP。 但最低所需 HP 无法推断出是否能继续前进,我们还得知道当前 HP 才行,比如: // 从左到右走3 -> -5 -> 6 -> -9 在数字 6 的位置所需最低 HP 是 3,但我们必须知道在 6 时勇者剩余 HP 才能判断 -9 会不会直接导致勇者挂了,因此我们将 dp[i][j] 结果定义为一个数组,第一项表示当前 HP,第二项表示初始所需最低 HP。 代码实现如下: function calculateMinimumHP(dungeon: number[][]): number { // dp[i][j] 表示 i,j 位置 [当前HP, 所需最低HP] const dp = Array.from(dungeon.map(item => () => [0, 0])) // dp[i][j] = 所需最低HP最低(dp[i-1][j], dp[i][j-1]) dp[0][0] = [ dungeon[0][0] > 0 ? 1 + dungeon[0][0] : 1, dungeon[0][0] > 0 ? 1 : 1 - dungeon[0][0] ] for (let i = 0; i < dungeon.length; i++) { for (let j = 0; j < dungeon[0].length; j++) { if (i === 0 && j === 0) { continue } const paths = [] if (i > 0) { paths.push([i - 1, j]) } if (j > 0) { paths.push([i, j - 1]) } const pathResults = paths.map(path => { let leftMaxHealth = dp[path[0]][path[1]][0] + dungeon[i][j] // 剩余HP大于 0 则无需刷新最低HP,否则尝试刷新取最大值 let lowestNeedHealth = dp[path[0]][path[1]][1] if (leftMaxHealth <= 0) { // 最低要求HP补上差价 lowestNeedHealth += 1 - leftMaxHealth // 最低需要HP已补上,所以剩余HP也变成了 1 leftMaxHealth = 1 } return [leftMaxHealth, lowestNeedHealth] }) // 找到 pathResults 中 lowestNeedHealth 最小项 let minLowestNeedHealth = Infinity let minIndex = 0 pathResults.forEach((pathResult, index) => { if (pathResult[1] < minLowestNeedHealth) { minLowestNeedHealth = pathResult[1] minIndex = index } }) dp[i][j] = [pathResults[minIndex][0], pathResults[minIndex][1]] } } return dp[dungeon.length - 1][dungeon[0].length - 1][1]}; 首先计算初始位置 dp[0][0],因为只看这一个点,因此如果有恶魔,最少初始 HP 为能击败恶魔后自己剩 1 HP 就行了,如果房间是空的,至少自己 HP 得是 1(否则勇者进迷宫之前就挂了),如果有魔法球,那么初始 HP 为 1(一样防止进迷宫前挂了)。 初始 HP 稍有不同,如果房间是空的或者有恶魔,那打完恶魔之后最多剩 1 HP 最经济,所以此时 HP 初始值就是 1,如果有魔法球,那么一方面为了防止进入迷宫前自己就挂了,得有个初始 1 的 HP,魔法球又必须得吃,所以 HP 是 1 + 魔法球。 接着就是状态转移方程了,由于 dp[i][j] 可以由 dp[i-1][j] 或 dp[i][j-1] 移动得到(注意 i 或 j 为 0 时的场景),因此我们判断一下从哪条路过来的最低初始 HP 最低就行了。 如果进入当前房间后,房间是空的,有魔法球,或者当前 HP 可以打败恶魔,则不影响最低初始 HP,如果当前 HP 不足以击败恶魔,则我们把缺的 HP 给勇者在初始时补上,此时极限一些还剩 1 HP,得到一个最经济的结果。 然后我们提交代码发现,无法 AC!下面是一个典型挂掉的例子: 1 -3 30 -2 0-3 -3 -3 我们把 DP 中间过程输出,发现右下角的 5 大于最优答案 3. [ [ 2, 1 ], [ 1, 3 ], [ 4, 3 ] [ 2, 1 ], [ 1, 2 ], [ 1, 2 ] [ 1, 3 ], [ 1, 5 ], [ 1, 5 ]] 观察发现,勇者先往右走到头,再往下走到头答案就是 3,问题出在 i=1,j=2 处,也就是中间行最右列的 [1, 2]。但从这一点来看,勇者从左边过来比从上面过来需要的初始 HP 少,因为左边是 [1, 2] 上面是 [4, 3],但这导致了答案不是最优解,因为此时剩余 HP 不够,右下角是一个攻击为 3 的恶魔,而如果此时我们选择了初始 HP 高一些的 [4, 3],换来了更高的当前 HP,在不用补初始 HP 的情况就能把右下角恶魔干掉,整体是更划算的。 如果此时我们在玩游戏,读读档也就能找到最优解了,但悲剧的是我们在写一套算法,我们发现当前 DP 项居然还可能由后面的值(攻击力为 3 的恶魔)决定! 用专业的话来说就是有后效性导致无法使用 DP。 我们在判断每一步最优解时,其实有两个同等重要的因素影响判断,一个是初始最少所需 HP,它的重要度不言而喻,我们最终就希望这个答案尽可能小;但还有当前 HP 呢,当前 HP 高意味着后面的路会更好走,但我们如果不往后看,就不知道后面是否有恶魔,自然也不知道要不要留着高当前 HP 的路线,所以根本就无法根据前一项下结论。 因为考虑的因素太多了,我们得换成游戏制作者的视角,假设作为游戏设计者,而不是玩家,你会真的从头玩一遍吗?如果真的要设计这种条件很极限的地下城,设计者肯定从结果倒推啊,结果我们勇者就只剩 1 HP 了,至于路上会遇到什么恶魔或者魔法球,反过来倒推就一切尽在掌握了。所以我们得采用从右下角开始走的逆向思维。 逆向思维为什么从结果倒推,DP 判断条件就没有后效性了呢? 先回忆一下从左上角出发的情况,为什么除了最低初始 HP 外还要记录当前 HP?原因是当前 HP 决定了当前房间的怪物勇者能否打得过,如果打不过,我们得扩大最低初始 HP 让勇者能在仅剩 1 HP 的情况险胜当前房间的恶魔。但这个当前 HP 值不仅要用来辅助计算最低初始 HP,它还有一个越大越好的性质,因为后面房间可能还有恶魔,得留一些 HP 预防风险,而 “最低初始 HP” 尽可能低与 “当前 HP” 尽可能高,这两个因素无法同时考虑。 那为什么从右下角,以终为始的考虑就可以少判断一个条件了呢?首先最低初始 HP 我们肯定要判断的,因为答案要的就是这个,那当前 HP 呢?当前 HP 重要吗?不重要,因为你已经拯救到公主了,而且是以最低 HP 1 点的状态救到了公主,按故事路线逆着走,遇到恶魔房间,恶魔攻击是多少我就给你加多少初始 HP,遇到魔法球恢复了我就给你扣对应初始 HP,总之能让你正好战胜恶魔,魔法球补给你的 HP 我也扣掉,就可以了。核心区别是,此时当前 HP 已经不会影响最低初始 HP 了,因为初始 HP 就是从头推的,我们反着走地下城,每次实际上都是在判断这个点作为起点时的状态,所以与之前的路径无关。 代码很简单,如下: function calculateMinimumHP(dungeon: number[][]): number { // dp[i][j] 表示 i,j 位置最少HP const dp = Array.from(dungeon.map(item => () => [0, 0])) // 右下角起始 HP 1,遇到怪物加血,遇到魔法球扣血,实际上就是 -dungeon 计算 const si = dungeon.length - 1 const sj = dungeon[0].length - 1 dp[si][sj] = dungeon[si][sj] > 0 ? 1 : 1 - dungeon[si][sj] for (let i = si; i >= 0; i--) { for (let j = sj; j >= 0; j--) { if (i === si && j === sj) { continue } const paths = [] if (i < si) { paths.push([i + 1, j]) } if (j < sj) { paths.push([i, j + 1]) } const pathResults = paths.map(path => dp[path[0]][path[1]] - dungeon[i][j]) // 选出最小 HP 作为 dp[i][j],但不能小于 1 dp[i][j] = Math.max(Math.min(...pathResults), 1) } } return dp[0][0]}; 逆向思维为什么就能减少当前 HP(或者说路径和,或者说所有之前节点的影响)判断呢?我猜你大概率还是没彻底明白。因为这个思考非常关键,可以说是这道题 99% 的困难所在,还是画个图解释一下: 上图是勇者正常探险的思路,下面是逆向(或公主救勇者)的思路。 总结该题很容易想到使用动态规划解决,但因为目标是求最低的初始健康点需求,所以按照勇者路径走的话,后续未探索的路径会影响到目标,所以我们需要从公主角度反向寻找勇者,才可以保证动态规划的每个判断点都只考虑一个影响因素。 讨论地址是:精读《算法 - 地下城游戏》· Issue ##498 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法题 - 通配符匹配》","path":"/wiki/WebWeekly/算法/《算法题 - 通配符匹配》.html","content":"当前期刊数: 283 今天我们看一道 leetcode hard 难度题目:通配符匹配。 题目给你一个输入字符串 (s) 和一个字符模式 (p) ,请你实现一个支持 '?' 和 '*' 匹配规则的通配符匹配: '?' 可以匹配任何单个字符。 '*' 可以匹配任意字符序列(包括空字符序列)。判定匹配成功的充要条件是:字符模式必须能够 完全匹配 输入字符串(而不是部分匹配)。 示例 1: 输入:s = "aa", p = "a"输出:false解释:"a" 无法匹配 "aa" 整个字符串。 思考最直观的思考是模拟匹配过程,以 s = “abc”, p = “abd” 为例,匹配过程是这样的: “a” 匹配 “a”,通过 “b” 匹配 “b”,通过 “c” 不匹配 “d”,失败 只要匹配过程有任何一个字符匹配失败,则整体匹配失败。如果没有 '?' 与 '*' 号,题目则异常简单,只要一个指针按顺序扫描,扫描过程每个字符必须相等,且同时结束才算成功,否则判断失败。 加上 '?' 依然很简单,因为 '?' 号一定会消耗掉,只是它可以匹配任何字符,所以还是一个指针扫描,遇到 p 中 '?' 号时,跳过判等继续向后扫描即可。 加上 '*' 号时该题成为 hard 的第一个原因。由于 '*' 可以匹配空字符,也可以匹配任意多个字符,所以遇到 p 中 '*' 时有三种处理可能性: 当做没见过 '*',直接判等,不消耗 s,并匹配 p 的下一个字符。此时对应 '*' 不匹配任何字符。 直接消耗掉 '*' 判等,同时消耗 s 与 p。此时 '*' 与 '?' 的作用等价。 不消耗 '*',但是消耗 s。此时对应 '*' 匹配多个字符而可以不消耗自己的特性。 很容易想到写一个递归的实现,代码如下: function isMatch(s: string, p: string): boolean { return myIsMatch(s.split(''), p.split(''))};function myIsMatch(sArr: string[], pArr: string[]): boolean { // 如果 s p 都匹配完了,或 p 还剩任意数量的 *,都算匹配通过 if ( (sArr.length === 0 && pArr.length === 0) || (sArr.length === 0 && pArr.every(char => char === '*')) ) { return true } // 如果任意一项长度为 0,另一项不为 0,则匹配失败 if ( (sArr.length === 0 && pArr.length !== 0) || (sArr.length !== 0 && pArr.length === 0) ) { return false } const newSArr = [...sArr] const newPArr = [...pArr] const sShfit = newSArr.shift() const pShift = newPArr.shift() // 此时 sShfit、pShift 一定都存在 switch(pShift) { case '?': // 无条件判过 return myIsMatch(newSArr, newPArr) case '*': // 无条件判过,其中有以下几种情况 // 消耗 *、消耗 sShfit // 消耗 *、不消耗 sShfit // 不消耗 *、消耗 sShfit return ( myIsMatch(newSArr, newPArr) || myIsMatch([sShfit, ...newSArr], newPArr) || myIsMatch(newSArr, [pShift, ...newPArr]) ) default: if (sShfit !== pShift) { return false } else { return myIsMatch(newSArr, newPArr) } }} 非常简洁清晰的代码,即判断 pShfit(p 下一个字符)的状态,根据我们分析的可能性判断匹配命中的条件,比如当 pShfit 为 '?' 时直接判定下一组字符,而为 '*' 时,三种可能性都可以判对,其余情况必须在当前字符相等时,才继续判断下一组字符。 然而上面的代码无法 AC,原因是性能不达标,无论如何优化都无法 AC,这是该题成为 hard 的第二个原因。 遇到思路正确,但遇到比较复杂的用例超时,此时 99% 的情况应该换到动态规划思路,而该题动态规划思路是比较难想到的。 动态规划思路之所以动态规划思路难想到,是因为我们大脑的局限性造成的。因为人类最自然理解事物的方式是线性还原该场景的每一幕,对于这道题,我们自然会假设匹配是从第一个字符开始的,匹配完后进行下一个字符的匹配,直到判断失败。 但动态规划的思路是寻找 dp(i) 与 dp(i-1) 甚至 i-n 的关系,这使得直观上觉得不可能,因为想到 '*' 号的匹配可能存在不消耗 '*' 号的情况,此时向前回溯感觉就像字符串从后向前匹配了一样。但仔细想想会发现,从后向前匹配的结果与从前向后的匹配结果是相同的,因此这条路是可行的。 之所以从前向后与从后向前判断是等价的,最简单的理由是把 s 与 p 字符串倒序,此时从前向后匹配在逻辑上完全等价于倒序前的从后向前匹配。 接下来要思考的是状态转移方程,首先由于 '*' 的存在,导致 s 与 p 的游标可能不同,所以我们要定义两个游标,分别是 si、pi。 所以 dp(si, pi) 可以确定下来了。 接下来要如何转移,取决于 p[pi] 的值: 为非 '?' 或 '*' 时,如果 s[si] === p[pi],则整体能否 match 取决于 dp(si-1, pi-1) 能否 match。 展开说一下,因为此时 s 与 p 字符都会消耗,所以上一个状态是 si, pi 同时减 1。 为 '?' 时,不用判断当前字符是否相同,整体能否 match 取决于 dp(si-1, pi-1) 能否 match。 为 '*' 时: 如果该 '*' 不匹配任何字符,则可以认为这个字符不存在,pi 回退一位,所以整体能否 match 取决于 dp(si, pi-1) 的结果。 如果该 '*' 匹配字符,则当前肯定能匹配上,但整体能否 match 取决于之前的结果,之前结果分两种: 消耗该 '*',则等价于 dp(si-1, pi-1) 的结果。 不消耗该 '*',则等价于 dp(si-1, pi) 的结果。 由于所有的分支包含了所有可能性,因此上面逻辑梳理是不重不漏的。 特别的,消耗该 '*' 等价于 dp(si-1, pi-1) 的 case 可以忽略,因为已经被上述逻辑覆盖了,具体是怎么覆盖的呢?见下面的表达: 消耗该 '*' 等价于 dp(si-1, pi-1) 这个场景等价于: 不消耗该 '*',等价于 dp(si-1, pi)。 接着该 '*' 不匹配任何字符。 看到了吗,如果不消耗该 '*' 匹配字符后,接着再让其不匹配任何字符,就等价于消耗该 '*' 匹配字符! 所以这块是一个性能优化点,看你能不能意识到,这样可以少一个逻辑分支的执行。 代码如下: function isMatch(s: string, p: string): boolean { // key 为 si_pi const resultSet = new Set<string>() // 初始值 // 俩空字符串 match resultSet.add('0_0') // 为了让 0_0 命中空字符串,在 s,p 前面补上空字符串 s = ' ' + s p = ' ' + p for (let si = 0; si < s.length; si++) { for (let pi = 0; pi < p.length; pi++) { switch(p[pi]) { case '?': // 只要 [si-1, pi-1] match, [si, pi] 就 match if (resultSet.has(`${si-1}_${pi-1}`)) { resultSet.add(`${si}_${pi}`) } break case '*': // * 可以匹配空字符,则等价于 [si, pi-1] // * 可以匹配 1~oo 个字符, 如果 [si-1, pi-1] match & si > 0, 可以等价于 [si-1, pi] if ( resultSet.has(`${si}_${pi-1}`) || (si > 0 && resultSet.has(`${si-1}_${pi}`)) ) { resultSet.add(`${si}_${pi}`) } break default: // [si-1, pi-1] match & 最后一个字符也相等, [si, pi] 就 match if (resultSet.has(`${si-1}_${pi-1}`) && s[si] === p[pi]) { resultSet.add(`${si}_${pi}`) } } } } return resultSet.has(`${s.length-1}_${p.length-1}`)}; 其中我们用 Set 结构很方便的定义 dp 缓存,然后给字符串前缀塞了空格,目的是方便在 si = 0, pi = 0 时收敛到 match 的情况,这样 dp 就能转起来了,否则 s[0] 和 p[0] 可能不匹配,让 dp(0, 0) 找不到一个稳定的落点(服务很到位)。 动态规划 * 号处理详解dp 思路中,可能有些同学不好理解 p[pi] = '*' 时的推演逻辑,我们展开画个图就清楚了: s = a b c dp = a b c d * 如果 * 不用于匹配,则结果等价于 s = a b c dp = a b c d 这个例子显然符合 p 可以匹配 s 的直觉。 如果 * 用于匹配,且消耗 * 比较好理解,s 与 p 各退一个字符;但不消耗 * 还是要画个图说明: s = a b c dp = a b c d * '*' 匹配了 s 最后一个字符 d,但自己又不消耗,则等价于: s = a b cp = a b c d * 从左到右看不太好理解,但从右到左看就比较容易了,可以认为 '*' 把 s 的最后一个字符 d “吃掉了”,但自己没有被消耗。要理解到这一步,还需要理解到 '*' 从左到右与从右到左匹配都是等价的这个事实。 如果非要从左到右看,也可以解释得通:既然 '*' 已经确定要在不消耗自己的情况下把 s 最后一个 d “吃掉”,那么这个 d 写于不写是等价的,所以可以把它从末尾 “抹去”。 总结从这道题可以看出,该题 hard 点不在于动态规划,不然理解了动态规划大家都能秒杀 hard 题了,这与面试时大部分面试者实际反应不符。 本题真正难点在于: 首先为了能 AC,正匹配的思路走不通,如果你不能抛下从左到右匹配字符串的成见,就没办法逼自己试试动态规划,因为动态规划是向前推导的,很多人过不去这个坎。 短时间内很难理解到 '*' 号匹配从左向右吃,与从右向左吃最终结果是等价的,所以潜意识会觉得 dp 思路无法处理 '*' 号匹配规则,非得整出个 dp(i+1) 才能理解,这样就迟迟无法下笔了。 不得不说 p[pi] = '*' 时结果等价于 dp(si-1, pi) 是具有思维跳跃的,因为它满足 dp 利用历史结果推导的结构,同时在匹配逻辑上又确实是等价的,能否想到这一步是这道题解题的关键。 如果你在其他地方看到本题的题解,但是在 p[pi] = '*' 时等价于 dp(si-1, pi) 这一步没看懂,大概率是那个题解忽略了这个 “神之细节”,而这个 “神之细节” 却是你在做题时真正的思维卡点,请确保这一点可以在你正序思考时推导出来,而不是看了答案后觉得这个转移方程有道理,从答案反推总是轻而易举的,但解题时却需要跳跃性思维。 最后,本文的实现还留了一些优化项可以更进一步,留给阅读本文的你探索: dp 缓存是否可以用滚动数组优化空间消耗。 两层 for 循环还是比较笨拙的,在某些情况下其实可以提前终止。 当字符串 p 存在多个连续 * 时效果与单个 * 是一样的,可以提前简化 p 的复杂度。 讨论地址是:精读《算法 - 二叉搜索树》· Issue ##493 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Tableau 入门》","path":"/wiki/WebWeekly/商业思考/《Tableau 入门》.html","content":"当前期刊数: 115 1. 引言引用著名瑞典统计学家 Hans Rosling 的一句话:想法来源于数字、信息,再到理解。 分析数据的最好方式是可视化,因为可视化承载的信息密度更高,甚至可以从不同维度对数据进行交互式分析。今天要精读的文章就分析了经典可视化分析工具 Tableau:data-visualisation-made-easy。 2. 精读Tableau 是一款广泛用于智能商业的强大数据分析工具,通过不同可交互的图表和仪表盘帮助你获得业务洞见。 安装Tableau 提供了三种使用方式: Tableau Desktop 拥有 14 天免费试用的桌面版,可以将工作数据存储在计算机本地,如果你是学生或老师可以获得一年的免费使用权。 Tableau Public 公开版完全免费,和桌面版的唯一区别是,所有数据都无法保存在本地,只能保存在 Tableau 服务器的云端,而且是公开的。 Tableau Online 网页版也完全免费,是 Tableau Public 的网页版。 连接数据源安装好 Tableau 后,第一步就是连接数据源。它支持连接本地或云端的数据源,本地最常用的数据源可以从 Excel 转换。这里是一份 样例数据,包含了一个超市几年内的销售情况,我们可以用这份数据练手。 下载好这份数据后,选择从 Excel 导入,确认后将 Orders 表拖拽到右侧区域,如下图所示: 可以看到,导入的数据格式有些问题,这是因为这份 Excel 文件表头有一些描述信息干扰。勾选 Use Data Interpreter 后,可以开启数据解析功能,自动分析出你想要的表结构: 可以看到表结构已经正常了,在数据清洗的过程中,Tableau 强大的数据分析功能已经初见端倪。你甚至可以点击 Review ths results 看看它是如何清洗数据的:点击后会下载一份分析 Excel,其中过滤掉的数据会被标记,自动分析出的表结构会被高亮。 数据可视化在页面最底部有几个切换项,依次是 Data Source:数据源、Sheet:工作簿,后面跟随的三个按钮可以继续创建多个 Sheet、Dashboard、Story,这些后面都会讲到。首先点击 Sheet 进入可视化分析的工作簿: 可以看到,Orders 表的字段已经被自动分析成 维度 度量 了。维度和度量是数据分析中重要的概念: 维度: 维度是不能被计数的字段,一般为字符串或离散的值,用来描述数据的维度。 度量: 度量是可以被计数的字段,一般为数字、日期等连续的值,用来描述数据的量。 右侧空白区域是图表展示区域,可以响应拖拽交互,顶部的 Columns、Rows 表示列与行,Filters 是过滤器,拖拽字段上去可以对此字段进行过滤,Marks 是标记,Tableau 将图表所有辅助标记功能都抽象为:颜色、大小、文本、具体值、工具提示。举个例子,如果将销量 Sales 字段拖拽到大小区域,那么任何能描述大小的图表,都会以销量的多少来决定大小,比如散点图。 右上角的 Show Me 是图表自动推荐区域,当你拖拽不同字段的时候,Tableau 会自动展示合适的图表,但你也可以点击 Show Me 进行图表切换。 那么开始动手吧!首先我们要看看大盘数据如何,也就是这家超市的总利润、质量、销量: 在左侧维度栏目下,最后一个字段 Measure Names 表示所有度量的集合。 将 Measure Names 拖拽到画布的空白区域。 移除我们不关心的 Row ID, Discount 等字段。 可以看到,总利润大概是总销量的 10%。如果想展示横向表格,将 Measure Names 从 Rows 拖拽到 Columns 即可。 Tips: 为了方便区分,Tableau 贴心的将维度标记为蓝色,度量标记为绿色。同时可以看到,Tableau 对于单指标拖拽,默认采取表格方式渲染。 接下来我们要看每一年的详细销量与利润: 将 Order Date 与 Sales 拖拽到 Rows。 右键 Sales,将类型从连续改成非连续,这样就会自动变成表格展示。 为了展示利润,将 Profit 字段拖拽到 Marks 的 Text 字段上。 我们可以看到,无论是销量还是利润都在逐年上升。接下来我们想具体看看每个月份的数据: 右键 Order Date,将日期维度从年切换到月。 我们可以看到,销量较高的月份分布在:3、9、11、12 月。注意由于没有对年份做筛选,这里的每月统计数据是整合了 2013~2016 四年份的。也就是 1 月的数据其实代表了 2013.1 + 2014.1 + 2015.1 + 2016.1 共四个 1 月份数据的总和。 接下来我们想了解销量与利润增长的趋势: 将 Order Date 拖拽到 Columns。 将 Sales 拖拽到 Rows,此时会出现一条线。接下来将 Profit 拖拽到 左 Y 轴。 这里就涉及到线图拖拽交互设计了,线图一共有三种拖拽方式。如果将一个新字段拖拽到左 Y 轴,就会在左 Y 轴多出一条线;如果拖拽到中间图表区域,则这个字段会当作已有字段的工具提示;如果拖拽到右 Y 轴,则会自动变成双轴图。 从上图中能看到,销量增长明显,但利润增长缓慢,看来经营是存在一定问题的,还要继续分析问题在哪。 我们再看看数据按月分布情况,同样右击 Order Date,选择 月 粒度: 上图可以明显看到三个峰值出现在 3、9、11 月份,然而这段期间利润增长幅度却不大,可以看出这段期间采取了薄利多销的手段。 再从地区维度分析数据: 将 Regions 和 Sales 拖拽到 Columns。 切换到饼图。 将 Sales 拖拽到 Marks Pane 的 Label 上。 可以看到东西部地区是销量最高的区域。接下来我们想看具体城市的销量: 将 States 拖拽到画布空白区域,此时会自动出现地图并定位到美国。将 Profits 拖拽到 Color。 将地区切换到 Filled Map,将 Profits 拖拽到 Label。 这样就绘制了一张地区,颜色越深利润越高,数字表示销量。 可以看到数值越大的区域一般颜色也越深,但这不是分析利润/销量性价比的最佳方式,我们先只看到加州和纽约是销售业绩最好的区域,而科罗拉多州虽然销量不错,但利润却是负的。 上面的地图对地形比较直观,但要分析销售健康度,还是用散点图更合适。我们想看看城市销量/利润的健康度分布: Profit 拖拽到 Columns,Sales 拖拽到 Rows,此时散点图出现,但只有一个点(之所以出现散点图,是因为横纵轴拖拽的都是度量)。 我们想按城市下钻,只要把 State 拖拽到 Detail 即可。 可以看到,遥遥领先的城市有三个,加州是销售之王。 由于还没有介绍到筛选条件,这里简略介绍一下,其实还可以将年份拖拽到筛选条件,只看 2013 年的分布图,也可以点击或圈选其中某些点选择排除某些城市。 现在需要进一步分析明细数据,将不同商品种类按年份细分,看按月的销量,并看看这些月份的利润如何: 此时需要用到高亮表格。首先将 Category 和 Order Date 拖拽到 Rows,简单的表格出现了。 将 Order Date 再拖拽到 Columns,并右键将其粒度改为月。 在 Show Me 中切换为 Highlight Table,重新将 Order Date(Year)拖拽回 Rows。 为了展示颜色与文字,将 Profit 拖拽到 Color,Sales 拖拽到 Label。 可以看到,办公套件和科技产品业绩最好,其中办公套件在 2015 年 12 月销量利润双丰收,科技产品在 2015 年 10 月与 2016 年 3 月销量利润双丰收。整体来看前半年是淡季。 但这张图无法看到销量与利润性价比关系,我们要找出利润率最高的商品和利润率最低的商品: 将 Proft 拖拽到 Columns。 将 Sub-Category 拖拽到 Rows。 切换到 Horizontal Bars。 将销量 Sales 拖拽到 Color。 可以明显看到 Copiers 就是性价比之王,拥有最高的利润,但销量却不是很高(颜色深度中等),而桌子是性价比最低的,利润为负,而且销量不低。 其他功能除了上面基本可视化分析能力之外,Tableau 还有许多辅助功能。 筛选器在按月分布的折线图中,如果我们只想看某一年的,可以将 Order Date 拖拽到 Filters 区域,只勾选想要保留的年份: Tablueau 这种交互等价于 Sql 中 in 语句,当然 Tablueau 还支持更复杂的条件或代码表达式,这里只是将更友好的筛选方式优先展示区来。 上卷下钻Tableau 支持任意维度之间的上卷下钻,只要你将他们分好组。 比如将 Order Date、Order ID、Ship Date、Ship Mode 拖拽到一起,成为 Orders 组;将 Category、Sub-Category、Product ID Product Name 形成 Product 组: 我们就可以将 Product 直接拖拽到画布区域,并选择矩形树图,通过点击指标上的 “+” “-” 号进行上卷或下钻: 上卷下钻是顺序相关的,比如 Product - Order Date 表示在产品类目基础上,对每个类目按日期下钻。而 Order Date - Product 这个顺序,表示在日期分布的基础上,对日期按产品类目下钻,了解不同日期下每个产品的分布情况。 趋势线为使用趋势线,先制作一个双轴图: 将 Sales 与 Profit 拖拽到 Rows。 将 Order Date 拖拽到 Columns 并切换到月维度。 选择 Show Me 的 Dual Combination 即混合图。 点击 Analytics Tab,将 Trend Line 拖入 chart 中: 趋势图有几种算法,比如线性,Log 或指数,因此在做趋势分析前,首先要判断自己的业务属于哪种增长阶段,如果是爆发期可以选择指数,平稳期可以选择线性等等。 预测回到按月分布的图表,如果我们想预测未来销量和利润的走势,可以使用预测功能: 切换到 Analytics Tab,并将 Forecast 拖拽到图表中。 可以点击右键配置预测参数。 预测趋势有一个浅色区域,表示预测范围。 聚类象限图的四象限是多维度综合判断的法则,然而 Tableau 支持的聚类分析可以自动做到这些: 切换到 Analytics Tab,选择 Clusters。 可以选择自动聚类个数,也可以手动指定个数。 从上图可以看到,指定了 4 个分类,最右上角加州就是最突出的一组,整个聚类只有它一个元素,而画面偏左下角的也是一类,这些是业绩较差的一组数据。使用了 K 均值聚类算法,并且当你点击右键查看详细星系时,还能把组间、组内方差展示出来: 仪表板仪表板可以将多个 Sheets 内容聚合在一起并自由布局,但仪表板最精髓的功能是图表联动功能: 点击任意图表,选择 “作为筛选条件”。 Tableau 的所有图表都支持点选,排除等操作,那么点选这类操作本质上其实是个筛选的过程,比如柱状图点击了某根柱子,可以认为是选择了这根柱子当前的维度值作为筛选条件。 当一个 Sheet 作为筛选条件后,类似点选这种操作产生的筛选就会作用于其他同数据集的图表,因此如上图所示,当点击了条形图的某一根柱子时,上面的销量地图也自动做了筛选,仅展示当前选中的产品的销量分布。 故事Story 更像是 PPT,将分析后有价值或有意义的图表组合在一起,再配合上说明,得出一些结论: 如上图所示,比如得到这家超市的大盘数据,这一般也是数据分析的最后一步,最后生成报表。 3. 总结Tableau 的交互式分析思路印证了这句话: 数字、信息,再到理解最终才能产生 Idea。我们从拿到 Excel 导入数据集开始,数据就已经变成了维度和度量的信息,再经过主动思考,将同一份数据进行不同维度的展示,最终得出加州销量最好、家具销售业绩最差、而桌子是负利润的主要来源等等洞见。 通过原文对 Tablueau 功能的分析能看到,Tableau 的核心资产是具备交互式分析能力的图表,这些图表通过智能推荐的方式展示出来,可以在不知道如何分析数据时找到一些灵感,真正做到以数据角度思考,图表展示只是辅助的视觉效果。 目前国内还处于报表制作的时代,即先选择报表再配数据集,这种使用思路是展示数据优先,而不是分析数据优先,笔者认为原因在于国内大部分做报表的业务场景都处于最末端,也就是数据洞见已经有了,再使用 BI 将这个洞见还原出来。而 BI 工具真正想做的还是在前面 “分析洞见” 这一步,希望数据分析师能可以通过 BI 平台挖掘出商业洞见。 要走到这一步,需要国内 BI 平台与使用 BI 的人都发展到下一阶段,而这种探索式数据分析功能早在 2012 年就在国外由 Tableau 团队实现,相信未来三年内国内一定能迎来一波探索式数据分析浪潮! 讨论地址是:精读《Tableau 入门》 · Issue ##192 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法 - 滑动窗口》","path":"/wiki/WebWeekly/算法/《算法 - 滑动窗口》.html","content":"当前期刊数: 199 滑动窗口算法是较为入门题目的算法,一般是一些有规律数组问题的最优解,也就是说,如果一个数组问题可以用动态规划解,但又可以使用滑动窗口解决,那么往往滑动窗口的效率更高。 双指针也并不局限在数组问题,像链表场景的 “快慢指针” 也属于双指针的场景,其快慢指针滑动过程中本身就会产生一个窗口,比如当窗口收缩到某种程度,可以得到一些结论。 因此掌握滑动窗口非常基础且重要,接下来按照我的经验给大家介绍这个算法。 精读滑动窗口使用双指针解决问题,所以一般也叫双指针算法,因为两个指针间形成一个窗口。 什么情况适合用双指针呢?一般双指针是暴力算法的优化版,所以: 如果题目较为简单,且是数组或链表问题,往往可以尝试双指针是否可解。 如果数组存在规律,可以尝试双指针。 如果链表问题限制较多,比如要求 O(1) 空间复杂度解决,也许只有双指针可解。 也就是说,当一个问题比较有规律,或者较为简单,或较为巧妙时,可以尝试双指针(滑动窗口)解法。 我们还是拿例子说明,首先是两数之和。 两数之和两数之和是一道简单题,实际上和滑动窗口没什么关系,但为了引出三数之和,还是先讲这道题。题目如下: 给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。 你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。 暴力解法就是穷举所有两数之和,发现和为 target 结束,显然这种做法有点慢,我们换一种思路。 由于可以用空间换时间,又只有两个数,我们可以对题目进行转化,即通过一次遍历,将 nums 每一项都减去 target,然后找到后面任意一项值为前面的结果,即表示它们和为 target。 可以用哈希表 map 加速查询,即将每一项 target - num 作为 key,如果后面任何一个 num 作为 key 可以在 map 中找到,则得解,且上一个数的原始值可以存在 map 的 value 中。这要仅需遍历一次,时间复杂度为 O(n)。 之所以说这道题,是因为这道题是单指针,即只有一个指针在数组中移动,并配合哈希表快速求解。对于稍微复杂的问题,单指针就不够了,需要用双指针解决(一般来说不会用到三或以上指针),那复杂点的题目就是三数之和了。 三数之和三数之和是一道中等题,别以为只是两数之和的加强版,其思路完全不同。题目如下: 给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。 由于超过了两个数,所以不能像双指针一样求解了,因为即便用了哈希表存储,也会在遍历时遇到 “两数之和” 的问题,而哈希表方案无法继续嵌套使用,即无法进一步降低复杂度。 为了降低时间复杂度,我们希望只遍历一次数组,这就需要数组满足一定条件我们才能用滑动窗口,所以我们对数组进行排序,使用快排的时间复杂度为 O(nlogn),时间复杂度已超出两数之和,不过因为题目复杂,这个牺牲是无法避免的。 假设从小到大排序,那我们就拿到一个递增数组了,此时经典滑动窗口方法就可用了!怎么滑动呢?首先创建两个指针,分别叫 left 与 right,通过不断修改 left 与 right,让它们在数组间滑动,这个窗口大小就是符合题目要求的,当滑动完毕时,返回所有满足条件的窗口即可,记录其实很简单,只要在滑动过程中记录一下就行。 首先排除异常值,即数组长度过小,然后对于常规情况,我们拿一个全局变量存储当前窗口数的和,这样 right + 1 只要累加 nums[right+1],left + 1 只要减去 nums[left] 即可快速拿到求和。 由于需要考虑所有情况,所以需要一次数组遍历,对于每次遍历的起始点 i,如果 nums[i] > 0 则直接跳过,因为数组排序后是递增的,后面的和只会永远大于 0;否则进行窗口滑动,先形成三个点 [i, i+1, n-1],这样保持 i 不动,不断包夹后两个数字即可,只要它们的和大于 0,就将第三个点左移(数字会变小),否则将第二个点右移(数字会变大),其实第二个和第三个数就是滑动窗口。 这样的话时间复杂度是 O(n²),因为存在两次遍历,忽略快排较小的时间复杂度。 那么四数之和,五数之和呢? 四数之和该题和三数之和完全一样,除了要求变成四个数。 首先还是排序,然后双重递归,即确定前两个数不变,不断包夹后两个数,后两个数就是 i+1 和 n-1,算法和三数之和一样,所以最终时间复杂度为 O(n³)。 那么 N 数之和(N > 2)都可以采用这个思路解决。 为什么没有更优的方法呢?我想可能因为: 无论几数之和,快排一次时间复杂度都是固定的,所以沿用三数之和的方案其实占了排序算法便宜。 滑动窗口只能用两个指针进行移动,而没有三指针但又保持时间复杂度不变的窗口滑动算法存在。 所以对于 N 数之和,通过排序付出了 O(nlogn) 时间复杂度之后,可以用滑动窗口,将 2 个数时间复杂度优化为 O(n),所以整体时间复杂度就是 O(N - 2 + 1 个 n),即 O(N-1 个 n),而最小的时间复杂度 O(n²) 比 O(nlogn) 大,所以总是忽略快排的时间复杂度,所以三数之和时间复杂度是 O(n²),四数之和时间复杂度为 O(n³),依此类推。 可以看到,我们从最简单的两数之和,到三数之和、四数之和,跨入了滑动窗口的门槛,本质上是利用排序后数组有序的特性,让我们在不用遍历数组的前提下,可以对窗口进行滑动,这是滑动窗口算法的核心思想。 为了加强这个理解,再看一道类似的题目,无重复字符的最长子串。 无重复字符的最长子串无重复字符的最长子串是一道中等题,题目如下: 给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。 由于最长子串是连续的,所以显然可以考虑滑动窗口解法。其实确定了滑动窗口解法后,问题很简单,只要设定 left 和 right,并用一个哈希 Set 记录哪些元素存在过,在过程中记录最大长度,并尝试 right 右移,如果右移过程中发现出现重复字符,则 left 右移,直到消除这个重复字符为止。 解法并不难,但问题是,我们要想清楚,为什么用滑动窗口遍历一次就可以做到 不重不漏?即这道题时间复杂度只有 O(n) 呢? 只要想明白两个问题: 由于子串是连续的,既然不存在跳跃的情况,只要一次滑动窗口内能包含所有解,就涵盖了所有情况。 一次滑动窗口内不包含什么?由于我们只将 right 右移,且出现重复后尝试将 left 右移到不重复后,right 再继续右移,这忽略了出现重复后, right 左移的情况。 我们重点看二个问题,显然,如果 abcd 这四个连续的字符不重复,那么 left 右移后,bcd 也显然不重复,所以如果此时就可以将 right 右移形成 bcda 的窗口继续找下去,而不需要尝试 bc 这种情况,因为这种情况虽然不重复,但一定不是最优解。 好了,通过这个例子我们看到,滑动窗口如何缩小窗口范围其实不难,但更要注重的是,背后对于为什么可以用滑动窗口的思考,滑动窗口有没有做到不重不漏,如果没有想清楚,可能整个思路都错了。 那么滑动窗口的应用已经说透了?其实没有,我们上面只说了缩小窗口这种比较单一的脑回路,其实双指针构成的滑动窗口不一定都是那么正常滑的,一种有意思的场景是快慢指针,即是以相对速度决定窗口如何滑动。 关于快慢指针,经典的题目有环形链表、删除有序数组中的重复项。 环形链表环形链表是一道简单题,题目如下: 给定一个链表,判断链表中是否有环。 如果不是进阶要求空间复杂度 O(1),我们可以在遍历时稍稍 “污染” 一下原始链表,这样总能发现是否走了回头路。 但要求空间开销必须是常数,我们不得不考虑快慢指针。说实话第一次看到这道题时,如果能想到快慢指针的解法,绝对是相当聪明的,因为必须要有知识迁移的能力。怎么迁移呢?想象学校在开运动会,相信每次都有一个跑的最慢的同学,慢到被最快的同学追了一圈。 等等,操场不就是环形链表吗?只要有人跑得慢,就会被跑得快的追上,追上不就是相遇了吗? 所以快慢指针分别跑,只要相遇则判定为环形链表,否则不是环形链表,且一定有一个指针先走完。 那么细枝末节就是优化效率了,慢指针到底慢多少呢? 有人会说,运动会上,跑步慢的人如果想被快的人追上,最好就不要跑。对,但环形链表问题中,链表不是操场,可能只有某一段是环,也就是跑步慢的人至少要跑到环里,才可能与跑得快人的相遇,但跑得慢的人又不知道哪里开始成环,这就是难点。 你有没有想过,为什么快排用二分法,而不是三分法?为什么每次中间来一刀,可以最快排完?原因是二分可以用最小的 “深度” 将数组切割为最小粒度。那么同理,快慢指针中,慢指针要想被尽快追上,速度可能最好是快指针的一半。那从逻辑上分析,为什么呢? 直观来看,如果慢指针太慢,可能大部分时间都在进入环形之前的位置转悠,快指针虽然快,但永远在环里跑,所以总是无法遇到慢指针,这给我们的启示是,慢指针不能太慢;如果慢指针太快,几乎速度和快指针一样,就像两个运动员都互不相让的争夺第一一样,他们真的想相遇,估计得连续跑几个小时吧,所以慢指针也不能过快。所以这样分析下来,慢指针只能取折中的一半速度。 但用一半的慢速真的能最快相遇吗?不一定,举一个例子,假设链表是完美环形,一共有 [1,6] 共 6 个节点,那么慢指针一次走 1 步,快指针一次走 2 步,那么一共是 2,3 3,5 4,1 5,3 6,5 1,1 共走 6 步,但如果快指针一次走 3 步呢?一共是 2,4 3,1 4,4 3 步。这么说一般速度不一定最优?其实不是的,计算机在链表寻址时,节点访问的消耗也要考虑进去,后者虽然看上去更快,但其实访问链表 next 的次数更多,对计算机来说,还不如第一种来得快。 所以准确来说,不是快指针比慢指针快一倍速度,而是慢指针一次走一步,快指针一次走两步最优,因为相遇时,总移动步数最少。 再说一个简单问题,即用快慢指针判断链表中倒数第k个节点或者链表中点。 判断链表中点快指针是慢指针速度 2 倍,当快指针到达尾部,慢指针的位置就是链表中点。 链表中倒数第k个节点链表中倒数第k个节点是一道简单题,题目如下: 输入一个链表,输出该链表中倒数第 k 个节点。为了符合大多数人的习惯,本题从 1 开始计数,即链表的尾节点是倒数第 1 个节点。 这道题就是判断链表中点的变种,只要让慢指针比快指针慢 k 个节点,当快指针到达末尾时,慢指针就指向倒数第 k+1 个节点了。这道题注意一下数数别数错了即可。 接下来终于说道快慢指针的另一种经典用法题型,删除有序数组中的重复项了。 删除有序数组中的重复项删除有序数组中的重复项是一道简单题,题目如下: 给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。 这道题,要原地删除重复元素,并返回长度,所以只能用快慢指针。但怎么用呢?快多少慢多少? 其实这道题快多少慢多少并不像前面题目一样预设好了,而是根据遇到的实际数字来判断。 我们假设慢指针是 slow 快指针是 fast,注意变量命名也有意思,同样是双指针问题,有的是 slow right,有的是 slow fast,重点在于用何种方法移动指针。 我们只要让 fast 扫描完全表,把所有不重复的挪到一起就好了,这样时间复杂度是 O(n),具体做法是: 让 slow 和 fast 初始都指向 index 0。 由于是 有序数组,所以就算有重复也一定连在一起,所以可以让 fast 直接往后扫描,只有遇到和 slow 不同的值,才把其和 slow+1 交换,然后 slow 自增,继续递归,直到 fast 走到数组尾部结束。 做完这套操作后,slow 的下标值就是答案。 可以看到,这道题对于慢指针要如何慢,其实是根据值来判断的,如果 fast 的值与 slow\b 一样,那么 slow 就一直等着,因为相同的值要被忽略掉,让 fast 走就是在跳过重复值。 说完了常见的双指针用法,我们再来看一些比较难啃的特殊问题,这里主要讲两个,分别是 盛最多水的容器 与 接雨水。 盛最多水的容器盛最多水的容器\b是一道中等题,题目如下: 给你 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。 建议先仔细读一读题目再继续,这道题相对比较复杂。 好了,为什么说这是一道双指针题目呢?因为我们看怎么计算容纳水的体积?其实这道题就简化为长乘宽。 长度就是选取的两个柱子的间距,宽就是其中最短柱子的高度。问题就是,虽然柱子间距越远,长度越大,但宽度不一定最大,一眼是没法看出来最优解的。 所以还是得多次尝试,那怎么样可以用最少的尝试次数,但又不重不漏呢?定义 left right 两个指针,分别指向 0 与 n-1 即首尾两个位置,此时长度是最大的(柱子间距离是最远的),接下来尝试一下别的柱子,试哪个呢? 较长的那个?如果新的比较短的更短,那么宽度更短了;如果新的比较短的更长,也没用,因为较短的决定了水位。 较短的那个?如果新的较长,那么才有机会整体体积更大。 所以我们移动较短的那个,并每次计算一下体积,最后当两根柱子相遇时结束,过程中最大体积就是全局最大体积。 这道题双指针的移动规则比较巧妙,与上面普通题目不一样,重点不是在是否会运用滑动窗口算法,而是能否找到移动指针的规则。 当然你可能会说,为什么两个指针要定义在最两端,而非别的地方?因为这样就无法控制变量了。 如果指针选在中间位置,那么指针外移时,柱子的间距与柱子长度同时变化,就很难找到一条完美路线。比如我们移动较短的柱子,是因为较短的柱子确定了最低水位,改变它,可能让最低水位变高,但问题是两根柱子的间距也在变大,这样移动较短还是较长的柱子哪个更优就说不准了。 说实话这种方法不太容易想到,需要多找几种选择尝试才能发现。当然,算法如果按照固定套路就能推导出来,也就没有难度了,所以要接受这种思维跳跃。 接下来我们看一道更特殊的滑动窗口问题,接雨水,它甚至分为多段滑动窗口。 接雨水接雨水是一道困难题,题目如下: 给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。 与盛雨水不同,这道接雨水看的是整体,我们要算出能接的所有水的数量。 其实相比上一道题,这道题还算比较好切入,因为我们从左到右计算即可。思考发现,只有产生了 “凹槽” 才能接到雨水,而凹槽由它两边最高的柱子决定,那什么范围算一段凹槽呢? 显然凹槽是可以明确分组的,一个凹槽也无法被分割为多个凹槽,就像你看水坑一样,无论有多少,多深的坑在一起,总能一个一个数清楚,所以我们就从左到右开始数。 怎么数凹槽呢?用滑动窗口办法,每个窗口就是一个凹槽,那么窗口的起点 left 就是左边第一根柱子,有以下情况: 如果直接相邻的右边柱子更高(或一样高),那从它开始向右看,根本无法接雨水,所以直接抛弃,left++。 如果直接相邻的右边柱子更矮,那就有产生凹槽的机会。 那么继续往右看,如果右边一直都更矮,那也接不到雨水。 如果右边出现一个高一些的,就可以接到雨水,那问题是怎么算能接多少,以及找到哪结束呢? 只要记录最左边柱子高度,右边柱子的结束判断条件是 “遇到一个与最左边一样高的柱子”,因为一个凹槽能接多少水,取决于最短的柱子。当然,如果右边没有柱子了,虽然比最左边低一点,但只要比最深的高,也算一个结束点。 这道题,一旦遇到凹槽结束点,left 就会更新,开始新的一轮凹槽计算,所以存在多个滑动窗口。从这道题可以看出,滑动窗口题型相当灵活,不仅判断条件因题而异,窗口数量可能也有多个。 总结滑动窗口本质是双指针的玩法,不同题目有不同的套路,从最简单的按照规律包夹,到快慢指针,再到无固定套路的因题而异的特殊算法。 其实按照规律包夹的套路属于碰撞指针范畴,一般对于排序好的数组,可以一步一步判断,或者用二分法判断,总之不用根据整体遍历来判断,效率自然高。 快慢指针也有套路可循,但具体快多少,或者慢多少,可能具体场景要具体看。 对于无固定套路的滑动窗口,就要根据题目仔细品味啦,如果所有套路都能总结出来,算法也少了乐趣。 讨论地址是:精读《算法 - 滑动窗口》· Issue ##328 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法题 - 最小覆盖子串》","path":"/wiki/WebWeekly/算法/《算法题 - 最小覆盖子串》.html","content":"当前期刊数: 285 今天我们看一道 leetcode hard 难度题目:最小覆盖子串。 题目给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。 注意: 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。如果 s 中存在这样的子串,我们保证它是唯一的答案。 示例 1: 输入:s = "ADOBECODEBANC", t = "ABC"输出:"BANC"解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。 思考最容易想到的思路是,s 从下标 0~n 形成的子串逐个判断是否满足条件,如: ADOBEC.. DOBECO.. OBECOD.. 因为最小覆盖子串是连续的,所以该方法可以保证遍历到所有满足条件的子串。代码如下: function minWindow(s: string, t: string): string { // t 剩余匹配总长度 let tLeftSize = t.length // t 每个字母对应出现次数表 const tCharCountMap = {} for (const char of t) { if (!tCharCountMap[char]) { tCharCountMap[char] = 0 } tCharCountMap[char]++ } let globalResult = '' for (let i = 0; i < s.length; i++) { let currentResult = '' let currentTLeftSize = tLeftSize const currentTCharCountMap = { ...tCharCountMap } // 找到以 i 下标开头,满足条件的字符串 for (let j = i; j < s.length; j++) { currentResult += s[j] // 如果这一项在 t 中存在,则减 1 if (currentTCharCountMap[s[j]] !== undefined && currentTCharCountMap[s[j]] !== 0) { currentTCharCountMap[s[j]]-- currentTLeftSize-- } // 匹配完了 if (currentTLeftSize === 0) { if (globalResult === '') { globalResult = currentResult } else if (currentResult.length < globalResult.length) { globalResult = currentResult } break } } } return globalResult}; 我们用 tCharCountMap 存储 t 中每个字符出现的次数,在遍历时每次找到出现过的字符就减去 1,直到 tLeftSize 变成 0,表示 s 完全覆盖了 t。 这个方法因为执行了 n + n-1 + n-2 + … + 1 次,所以时间复杂度是 O(n²),无法 AC,因此我们要寻找更快捷的方案。 滑动窗口追求性能的降级方案是滑动窗口或动态规划,该题目计算的是字符串,不适合用动态规划。 那滑动窗口是否合适呢? 该题要计算的是满足条件的子串,该子串肯定是连续的,滑动窗口在连续子串匹配问题上是不会遗漏结果的,所以肯定可以用这个方案。 思路也很容易想,即:如果当前字符串覆盖 t,左指针右移,否则右指针右移。就像一个窗口扫描是否满足条件,需要右指针右移判断是否满足条件,满足条件后不一定是最优的,需要左指针继续右移找寻其他答案。 这里有一个难点是如何高效判断当前窗口内字符串是否覆盖 t,有三种想法: 第一种想法是对每个字符做一个计数器,再做一个总计数器,每当匹配到一个字符,当前字符计数器与总计数器 +1,这样直接用总计数器就能判断了。但这个方法有个漏洞,即总计数器没有包含字符类型,比如连续匹配 100 个 b,总计数器都 +1,此时其实缺的是 c,那么当 c 匹配到了之后,总计数器的值并不能判定出覆盖了。 第一种方法的优化版本可能是二进制,比如用 26 个 01 表示,但可惜每个字符出现的次数会超过 1,并不是布尔类型,所以用这种方式取巧也不行。 第二种方法是笨方法,每次递归时都判断下 s 字符串当前每个字符收集的数量是否超过 t 字符串每个字符出现的数量,坏处是每次递归都至多多循环 25 次。 笔者想到的第三种方法是,还是需要一个计数器,但这个计数器 notCoverChar 是一个 Set<string> 类型,记录了每个 char 是否未 ready,所谓 ready 即该 char 在当前窗口内出现的次数 >= 该 char 在 t 字符串中出现的次数。同时还需要有 sCharMap、tCharMap 来记录两个字符串每个字符出现的次数,当右指针右移时,sCharMap 对应 char 计数增加,如果该 char 出现次数超过 t 该 char 出现次数,就从 notCoverChar 中移除;当左指针右移时,sCharMap 对应 char 计数减少,如果该 char 出现次数低于 t 该 char 出现次数,该 char 重新放到 notCoverChar 中。 代码如下: function minWindow(s: string, t: string): string { // s 每个字母出现次数表 const sCharMap = {} // t 每个字母对应出现次数表 const tCharMap = {} // 未覆盖的字符有哪些 const notCoverChar = new Set<string>() // 计算各字符在 t 出现次数 for (const char of t) { if (!tCharMap[char]) { tCharMap[char] = 0 } tCharMap[char]++ notCoverChar.add(char) } let leftIndex = 0 let rightIndex = -1 let result = '' let currentStr = '' // leftIndex | rightIndex 超限才会停止 while (leftIndex < s.length && rightIndex < s.length) { // 未覆盖的条件:notCoverChar 长度 > 0 if (notCoverChar.size > 0) { // 此时窗口没有 cover t,rightIndex 右移寻找 rightIndex++ const nextChar = s[rightIndex] currentStr += nextChar if (sCharMap[nextChar] === undefined) { sCharMap[nextChar] = 0 } sCharMap[nextChar]++ // 如果 tCharMap 有这个 nextChar, 且已收集数量超过 t 中数量,此 char ready if ( tCharMap[nextChar] !== undefined && sCharMap[nextChar] >= tCharMap[nextChar] ) { notCoverChar.delete(nextChar) } } else { // 此时窗口正好 cover t,记录最短结果 if (result === '') { result = currentStr } else if (currentStr.length < result.length) { result = currentStr } // leftIndex 即将右移,将 sCharMap 中对应 char 数量减 1 const previousChar = s[leftIndex] sCharMap[previousChar]-- // 如果 previousChar 在 sCharMap 数量少于 tCharMap 数量,则不能 cover if (sCharMap[previousChar] < tCharMap[previousChar]) { notCoverChar.add(previousChar) } // leftIndex 右移 leftIndex++ currentStr = currentStr.slice(1, currentStr.length) } } return result}; 其中还用了一些小缓存,比如 currentStr 记录当前窗口内字符串,这样当可以覆盖 t 时,随时可以拿到当前字符串,而不需要根据左右指针重新遍历。 总结该题首先要排除动态规划,并根据连续子串特性第一时间想到滑动窗口可以覆盖到所有可能性。 滑动窗口方案想到后,需要想到如何高性能判断当前窗口内字符串可以覆盖 t,notCoverChar 就是一种不错的思路。 讨论地址是:精读《算法 - 最小覆盖子串》· Issue ##496 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《为什么专家不再关心技术细节》","path":"/wiki/WebWeekly/商业思考/《为什么专家不再关心技术细节》.html","content":"当前期刊数: 103 1. 引言本周的精读是有感而发。 笔者接触前端已有八年,观察了不少前端大牛的发展路径,发现成功的人都具有相似的经历: 初期技术热情极大 -> 大量标志性技术项目 -> 转向综合性思考 -> 带团队/关注方法论 也就是专家们变得越来越不关心技术细节。需要说明是的,这里说的专家不再关心细节,不代表成为专家后学不会细节,也不代表专家不了解细节。 早期挺难理解这种转变的,笔者在学校里的知名度来自于前端做得精深,一根筋钻研技术的人眼里是容不下沙子的,所以当初为一些前辈转到管理特别不理解,认为他们背叛了前端。 不过笔者的观念也在逐渐发生转变,渐渐自己也在朝着当初反感的方向发展,觉得这一定不是偶然,所以就整理了一下感悟,希望可以证明这个发展路径的必然性。 2. 精读 Warn:本文所说的技术专家,仅针对研究上层技术的专家,不包括底层技术专家。在 Google 底层专家人数极少,大部分专家都要走业务技术的路线。 首先我们要明确技术员与科学家的区别,为业务提供技术支持都是技术员,所以前端是一门技术,不是科学。 另外,技术的发展需要商业推动,没有使用场景的国家是很难推动技术进步的,科学除外。 所以业务技术是具备可持续发展的路线,毕竟大家都要吃饭,有业务价值的项目会活下来,附着在业务上的技术才能活下来,才有可能开枝散叶。 本文将从三个点去解释,为什么专家看上去越来越远离技术细节。 2.1 技术细节对个人的重要性是在变化的随着工作年限增加,技术细节重要性在慢慢降低,反之技术视野重要性在慢慢增加。 在找工作初期,技术细节是重要的敲门砖大学毕业的那段时间,技术细节是一块重要的敲门砖,只有掌握好技术,才会有公司愿意要你。 这也是为什么说毕业生不要一进公司就谈战略,因为时机不对。 技术不是科学,普通人下功夫可以学会学习技术不需要很聪明的头脑,只要肯下功夫,拥有不错的理解能力,任何人都可以把技术细节搞清楚。 也就是学习技术细节是没有技术门槛,随着年龄的增加,如果只累积了大家都能学会的内容,那么当旧知识被淘汰后,学习新知识的速度又不如年轻人快,会逐渐失去经验优势。 那么如何利用无门槛的特征,将其变为门槛呢?任何年龄段学习技术细节都很容易,应该在你需要深入细节的时候再深入进去,不需要深入的时候把时间花在了解宏观架构上。 就是培养高效的学习能力,能准确判断某个技术细节是否有必要掌握,如需要该如何快速掌握核心内容,并在掌握之后不留恋,可以快速抽身出来继续全局性思考。这种思维是有门槛的,技术专家都可以做到这一点。 做成事不一定要搞懂细节乍一看有点匪夷所思:不了解细节怎么能做成事? 虽然理解技术细节可以做成事,但做成事不一定需要理解业务细节。 这要看怎么理解业务与技术的关系,比如建设 “数据联邦”,光是了解各个不同的存储系统技术细节可能就要花很久,而实际上是没必要将所有技术细节都弄懂的,只要定好一个通用交互规范,各存储系统各自封装一套符合这个规范的交互接口即可。 做成事往往需要宏观的技术思维,需要将许多技术点链接在一起。举个例子,做成事就类似于军官指挥作战,做成的目的是通过制定打法赢得战争,而不是自己冲锋陷阵并测量敌人壕沟的宽度。关心技术细节只是最终落实到每个人具体实施项中的一部分,技术细节的目标累加起来才能做成事。 2.2 搞清楚业务对技术的真实诉求业务期望通过技术实现功能,所以技术专家要做的是如何更好的实现业务需求,这就意味着理解业务需求是第一重要的能力。试想一个不能理解业务要做什么的人,即便懂得再多技术细节,对业务也是没有价值的。 业务思维是解决问题,技术思维是创造问题拥有技术思维的人,容易沉迷于解决不切实际的问题,或者是别人解决过的问题。这种思维对技术学习是非常有帮助的,但如果长期不能转变这种思维,对公司来说是无法创造什么价值的。 拥有业务思维的人,首先要懂业务,只有懂业务,跟着对的业务,才能对未来有信心,知道自己的付出可以换来回报。 懂业务后,才知道如何通过技术帮助业务获得成功。 比如在一家创业公司,老板的眼光很准,进入的时机较早,市场是一片蓝海。你通过分析后,发现要帮助业务占领市场,只要利用某个成熟技术框架快速迭代,就可以在短期帮助业务赢得市场。但是这个框架定制能力不强,如果新需求来了可能需要花时间重构掉。此时技术思维的人只会考虑代码维护性,提出自研一套框架,而拥有业务思维的技术专家会决定先用成熟的技术快速作出原型,等业务稳定后再重构掉。 当然现在互联网市场竞争很激烈,低技术门槛的蓝海基本已都变成了红海,上面提到的场景可能比较少见,我们更多需要决策的是未来几年内业务的收益是否值得现在投入的研发资源。 两个会写框架的人,不如一个能决策的人另一个简单的例子就是,假如技术专家只会一头扎在技术细节里,对各种前端框架的实现了如指掌,大家都能造出优雅、易用、可维护,而且还带有各自 “特色优势” 的框架或者轮子,那么团队很容易陷入两个专家屁股决定脑袋的技术纷争中。这种情况下,两名技术专家的产出甚至不如一个实习生大,毕竟实习生直接拿来开源框架上手,99% 的情况可靠性比前端专家自己造的轮子更好。 从另一个方面来说,现阶段前端界能写出 React、Vue 框架的人太多了,已经写出来的类 React、Vue 的框架也数不过来。去掉为了练手而做的项目,真正希望推广出去给别人用的还占绝大多数,这是开源界典型的问题:重复低水平造轮子不需要理由,推广给你用也不需要负责任。由于框架属于互联网虚拟资产,边界成本为零,这决定了框架市场一定是个大寡头市场,不可能有类似的项目通过一些不痛不痒的特色分一杯羹。那么就算招 10 个会写框架的人进入公司架构组,最后只有两种可能:要么架构臃肿,每个人都把自己的一部分功劳加入进去;要么就是选择一个更不好的方案,这样不会损害任何一位架构师的利益。 所以现在公司更倾向于内部培养人才,因为内部的人了解业务需要什么,创造的价值往往比空降的架构师更大。 宽广的技术视野更容易借力现在技术点越来越多,如果什么技术细节都要详细了解,最终一定不能有很好的全局视野。比较好的状态是找几个重点深入了解,其他的技术点在掌握了全局技术视野后再考虑深入。 在互联网初期,很多技术框架还不完善,技术借力的意义不大,毕竟也没有多少东西可用。 但是现在无论前端还是后端的技术、轮子已经眼花缭乱了,能掌握这些已有技术的人,价值已经逐渐大于会完整了解某些技术细节的人。一个优秀的专家应该能快速定位要解决的业务问题是否有成熟的技术方案,如何以最小的投入产出比实现,同时保持良好的维护性应变业务维护。 2.3 仅仅技术好是无法成为专家的技术专家真的代表技术壁垒很强的人吗?是的,但只有技术能力是不够的。 为什么开源项目后期要寻找协作者?我做开源项目的初期,所有框架和源码都事必躬亲,觉得自己有更好的点子可以胜过其他框架。初期很少有贡献者参与,当然我也不愿意其他贡献者参与,毕竟他们不了解设计理念,只有我自己的修改可以让我满意。 还有谁比作者更了解他的开源项目呢?那为什么一个大型开源项目运作到后期,基本都是协作者在维护? 因为开源是一件系统化的事情,如果你想长期维护他,必须建立好文档系统,让你的思路可复制,让他人可参与。如果开源项目只有你一个人懂,那么同时维护两个、四个、六个的时候,你定会发现力不从心。 至于一些开源大神一人维护几百甚至上千 Repo,背后一定有更多的贡献者支持,一个人就算辞职在家专职做开源,也很难同时维护超过 10 个开源项目。你需要拥有开放的心态让更多人加入进来,将成就感和荣誉感分一些给贡献者,他们才会持续为项目贡献。 能够调用资源才能成为专家开源界就是项目抢占关注度的游戏。假设开源社区总人数为 100,你的项目能够吸引到 10 个人浏览,5 个人使用,2 个人贡献,基本就能存活下来。而开源社区至少有 100 个项目,社区总人数不足以支持每一个项目,只有获得足够关注度的项目才能保持长青。 公司内也是如此,专家级以上的 Title 会要求协作能力,可以调动身边甚至其他部门资源的人才能在公司发挥更大的价值。 CEO 通过顶层设计调动了全公司资源,而业务线总裁通过任务拆解调动了整个业务线的人,通过层层目标拆解,并保证每一层都能充分调动下一层所有资源,公司才能高效的运转。 如果一直关心技术细节,你永远是一个孤立节点,在任何维度的组织中都是最底层,就算 24 小时不睡觉,也最多算两个人力资源。想要突破一天 24 小时的限制,就要花时间让别人认同你的设计,并朝着一个方向努力,你的节点才能上移,但随之而来的是承担更多风险,比如分配给别人的任务给弄砸了,为公司带来了不良影响,那么负责人就要背锅。 3. 总结总结一下,本文的观点是: 技术细节学习难度不大,在需要深入的时候再深入了解最佳。 想要做成事,需要更宏观的技术思维,所以专家渐渐变得眼光宽阔,格局很大。 专家拥有快速学习技术细节的能力,只是这已不是其核心竞争力,所以与其写技术细节的文章,不如写方法论的思考带来的价值更大。 指引方向比走路更重要,专家都要逐渐成为引路人。 技术最终为业务服务,懂技术细节和让业务先赢没有必然的关系,所以在深入技术细节之前,要先理解业务,把握方向,防止技术细节出现路线问题。 讨论地址是:精读《为什么专家不再关心技术细节》 · Issue ##153 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《从 0 到 1》","path":"/wiki/WebWeekly/商业思考/《从 0 到 1》.html","content":"当前期刊数: 131 1 引言《从 0 到 1》是一本创业经典,创业非常有魅力,需要多种维度的商业知识,包括基础经济学、公司经济学、商业学、公司金融学、甚至历史学等等。 为什么要懂历史学?因为《从 0 到 1》这本书的作者是 彼得·蒂尔,他是 Paypal 的创始人和投资家,想读懂他的书就必须读懂他自己的创业经历,而 Paypal 的成长经历需要以考究历史的思维学习,了解什么是 Paypal 黑帮,他与其他公司的关系,为什么 Paypal 是继英特尔时隔 20 年之后的互联网黄埔军校。 为什么要懂商业学?本书第一句话就是 “在商业上机会只有一次”,这是商业基本准则之一。商业不是物理学,没有必然因果关系,没有商业必胜法。同时,商业也是训练多维度思考的战场,对一个商业结果的解读多种多样,我们需要避免对结果的简单归因、过度解读、甚至是本末倒置。《从 0 到 1》这本书抓住了创业成功的精髓。 《从 0 到 1》这本书,就是在商业这种复杂环境下,尝试总结一套通用的成功经验。然而前面我也说了,商业没有必胜法,那什么才是驱动成功与发展的根本引擎?就是创新。 2 概述 & 精读未来的挑战什么人能胜任未来的挑战?彼得蒂尔认为,有创新能力的人可以,所以他面试时喜欢问:“有什么你与其他人有不同看法,但你觉得却很重要的事”。能真正回答好这个问题的人才算具备了基本创新能力。 人类技术演进分为 水平进步与垂直进步,水平进步是从 1 到 N 的规模化应用,而垂直进步是从 0 到 1 的创造,虽然水平进步可以给发展中国家带来巨大发展速度,但真正推动历史变革的还在于垂直进步。 对于创业团队,独立思考与速度很重要,因此团队规模要尽量小。彼得蒂尔对 Paypal 的管理理念重点有二:招人越像越好、极端聚焦,Paypal 在早期时隔工程师都是 UIUC 毕业的,5 个非技术人员都是彼得蒂尔在斯坦福校友网络认识的,背景非常趋同,因此沟通成本非常低,决策效率很高。彼得蒂尔要求员工的年终总结必须明确写出 “对公司最有价值的一个贡献”,只能写一个。 根据 Paypal 发展经历来看,难怪《从 0 到 1》这本书会强调小团队高灵活的重要程度,因为 Paypal 就是这么起家的。 像 1999 年那样狂欢1993 年网景公司的成立拉开互联网时代的序幕,Paypal 就是这个时代成立的。 互联网狂欢兴起: 互联网泡沫破裂: 自 1999 年之后,市场学会了保守,主要有四条: 循序渐进的发展。 保持精简和灵活。 不要贸然开辟新市场。 专注产品而不是营销。 显然,1999 年互联网泡沫破裂后的美国企业家害怕了,逐渐走向了保守。然而彼得蒂尔认为,1999 年互联网泡沫破裂的虽然惨烈,但正因如此才带来了美国未来几十年的增长。 保守无法带来成功,相反,这四条的反面反而更正确: 大胆尝试胜过平庸保守。 坏计划也好过没有计划。 竞争性市场对收益有负面影响。 营销和产品同样重要。 狂妄自大的尝试必定导致大部分人悲惨的失败,但我们别无选择,创业必须创新,必须实现从 0 到 1。 所以彼得蒂尔反直觉的观点就是,我们不能因为吸取 1999 年的教训就变得保守,反而美国需要 1999 年那股狂热驱动新的创新。 所有成功的企业都是不同的彼得蒂尔完美解释了垄断的价值。 市场分为充分竞争与完全垄断,看上去充分竞争的市场更有活力,更健康,但实则不然。充分竞争将利润完全吞噬,只有完全垄断才能获得持久价值,最终对市场有利。 对创业者来说也一样,如果你相信充分竞争,你只会创建一家同质化的公司,扎到红海里拼命挣扎,这不会给你带来持久的利益,也不会给市场带来真正的发展。 垄断者为了逃避垄断保护法,会竭尽全力证明自己没有取得垄断地位(甚至随时会被市场吃掉),同理,竞争者为了自我麻痹或争取到投资,也会竭尽全力证明自己还有机会,市场并未形成垄断。 然而无论怎么说,真正为市场创造独一无二价值的还是垄断者,虽然他们看起来很可恶。 不仅在商业如此,互联网公司内部技术竞争也一样:低水平的重复竞争挑战者会竭尽全力证明自己所在的领域不存在垄断,然后投入人力做一个注定会失败的项目,不仅无法为公司产生新的价值,还带来了资源内耗。相反,那个垄断者才是为公司源源不断带来价值的引擎,虽然竞争者们都厌恶它。这也是为什么阿里鼓励高水平竞争,禁止低水平重复轮子。 竞争意识大家觉得竞争理所应当,但其实竞争更多带来的是伤害。 在奇葩说里听到薛兆丰这么一句话:“求职者你们的竞争对手不是企业,而是其他求职者”。说的很有道理,真正的伤害是在竞争中产生的,而存在供需关系的公司与求职者之间哪存在什么竞争?直白一点说,如果整个市场只有一个应聘者,哪怕小学没毕业,阿里腾讯也会抢着要。 竞争使我们过度看中过去的机会,而忽略创造新的可能性。 就像 Paypal 与 X 合并一样,彼得蒂尔发现这两家公司的竞争关系是恶性的,只有合并后形成垄断才能创造新的价值。而 X 公司的创始人就是埃隆·马斯克,虽然最后因为极力推广 X 品牌被合并后的 Paypal 请出局后,依然在 Paypal 被 20 多亿美元收购后,获得了一亿多美元回报,才创建了特斯拉和太空探索公司,真正为社会创造新的价值。 后发优势既然垄断如此重要,那么如何打造垄断? 首先一个企业的价值是它未来创造利润的总和。也许你会奇怪,为什么企业现在的资产不算做企业价值呢?企业价值一般指的是企业市值,企业市值描述的企业价值其实是它的 当前投资价值,一个不能在未来创造利润的企业,就算现在坐拥几千亿美元的资产,对你来说也是没有投资价值的。 建立企业垄断,可以建立企业的护城河,比如专利技术或者网络效应;或者先进入小市场,逐步扩大范围,就像亚马逊从图书在线交易切入,随后扩张到全品类。与你的对手产生放大收益,你不能仅仅取代你的对手,最好能为它赋能。这些都是企业的后发优势。 成功不是中彩票虽然大部分成功创业者都会将一半功劳归功于运气,但你最好不要真的相信,否则为什么有那么多连续失败的创业者呢?如果创业需要运气,那为什么彼得蒂尔要写《从 0 到 1》这本书,为什么我还要精读它呢? 成功者的运气是靠努力换来的。 国家就是一个巨大的创业,彼得蒂尔对当下各国对未来看法划出了四象限图: 明确乐观的未来:1950~1970 的美国,当时美国创新能力和工程应用都在上升期,未来是明确且乐观的。 不明确乐观的未来:1982 至今的美国,由于技术发展遇到了瓶颈,比如生物制药和医疗都有巨大不确定性,人们只知道未来是美好的,但不知道何时可以到来。 明确悲观的未来:现在的中国,由于缺乏核心创新能力,现在中国迅猛发展其实在吃发达国家创新的红利,只是将这些技术规模化应用,所以发展方向是明确的,但一旦红利吃完,不确定自己是否能找到新的突破点,因此对未来是悲观的。 不明确悲观的未来:现在的欧洲,技术红利和规模化都吃完了,不知道未来该怎么走,也不知道走向哪里。 不论国家还是公司,在这个时代想要拥有最好的未来,就是不明确乐观的未来,虽然这个乐观是不明确的,也就是需要运气,但只要在正确的方向努力,总是可能会成功。如果你真的相信比尔盖兹成功来源于运气,那请理解这是一个明确的运气,而不是不明确的运气,并不是所有方向的创业都可能走向成功。 向钱看当爱因斯坦宣称复利是“世界第八大奇迹”,因为钱可以生钱,本质原因是指数级增长。指数级增长之所以如此可怕,还因为并没有证据表明爱英斯坦说过这句话,但因为他的影响力有指数级影响力,所有有影响力的话可能都会 “归功给他”。 风险投资领域也是如此,一家风投最成功的项目带来的收益可能超过其他所有项目的总和,所以风投才会不断给有发展潜力的企业加注,这都是因为指数级效应。 所以如果你创业的公司不能成为幂次法则指数增长的类型,最好尽快换一个项目,因为做一个平庸的项目是没有意义的,世界的天枰都会为头部项目加码。 秘密企业只有创新才能获得成功,那一定是发现了新的 “商业秘密”。 但现在社会发展遇到了瓶颈,大家都不愿意探索新的秘密,主要有四个原因: 认为已经没有新的秘密。就像探索世界一样,当地球完全被开发,已经没有探索的必要。 规避风险。害怕没有找到秘密而耽误自己的人生。 自满。安于现状,认为不需要探寻新的秘密。 扁平化。由于互联网对社会的连接,我们更容易觉得竞争是全球化的,如果有新的秘密,一定会更优秀的人发现,而显然我不是最优秀的人,所以我没有必要去发觉秘密,那些最优秀的人会帮我做到。 想要扭转这个悲观思想,你需要意识到现代分工是极度专业化的,不同领域间往往很难竞争,一个物理学家可能难于解决情感问题,要相信还有许多未被关注的细分领域可能存在蓝海。 基础决定命运就像宪法决定了国家基础一样,企业最初决定的重要思想对未来发展起到决定因素,比如行业方向与招聘要求。 因此初创公司一定要确保创始人团队之间是否有默契,所有权、经营权和控制权是否分配合理,不要有兼职员工,最好以股权激励员工。 在技术领域做架构设计也是如此,架构基础决定了未来发展命运,我们必须尽可能保证早期架构设计的合理性,并坚持这些原则,就像坚持宪法一样。 黑手党式的机制为什么 Paypal 早期员工被称为 Paypal 黑帮?其实彼得蒂尔创建的 Paypal 由于触及到金融领域,相关利益方非常复杂,对于没有政府背景的他来说几乎是不可能做成的。 Paypal 招来的早期员工必须极度认同其企业文化,认同 “创造虚拟货币代替美元” 这个疯狂的想法。 Paypal 黑帮对公司的使命有着近乎于 “邪教” 般的信仰,唯一区别是,他们做的事情本身并不坏。 顾客不会自动上门销售和技术同样重要。 在工程技术界,技术打造的产品功能界限清晰,不是生效就是失效,而销售界,需要通过精心设计活动来打动用户的芳心,但却不能改变产品的实质性内容。技术内容是务实的,销售内容是务虚的,但我们不能说务实一定比务虚重要。 销售的技巧也随着业务场景的不同而不同。 复杂营销。当面对大企业客户时,甚至要克服政治惰性说服政府太空飞船采用你们公司的技术,而一旦完成协议的签署,哪怕只有几单,也足够维持公司后续发展了。 人员营销。和复杂营销相反,需要从具体场景逐渐深入,比如 Box 公司的云存储服务,首先卖给了斯坦福睡眠诊所,之后逐步扩展到整个斯坦福大学,但如果 Box 一开始就和斯坦福的校长洽谈整个学校的云服务方案,可能一开始就会失败。 病毒式营销。Paypal 的增长过程就是病毒式营销的范例,通过邀请机制传播给好友,并给最多 20 美元的奖励,也就是获客成本 20 元支撑了 Paypal 病毒式营销的成立。 然而 Paypal 也不是漫无目的的砸钱,首先它砸钱有自己的原因,因为 Paypal 是一个拥有网络效应的项目,因此拥有越多的用户就能带来越多的未来价值,这是 Paypal 可以选择烧钱营销的最大原因。 其次 Paypal 也选择了两个聪明的营销方式,第一是通过邮箱营销,由于当时世界上拥有邮箱的用户很少,都是一些对新技术持有开放态度的用户,因此邮件营销的人群就比较正确。后来 Paypal 发现,eBay 有部分商家甚至主动在商户页面贴出注册 Paypal 的链接,不仅是为了赚取佣金,更因为 Paypal 网络支付的最大场景就是电商交易平台,因此后续 Paypal 重点转向 eBay 推广。 人类和机器机器未来并不是为了取代人类,而是辅助人类更高效工作。 在 精读《刷新》 中,微软 CEO 萨提亚·纳德拉也提到了人与机器的关系 - “机器替代人类工作的过程,也是人类逐渐拾回作为人的尊严的过程。人本就应该将时间用于思考与创造,而不是重复性劳动。” 有意思的是,彼得蒂尔在创立 Paypal 过程中由于遇到不法分子盗刷信用卡的问题,因此专门研究网络安全并研发出验证码、数据分析等一直沿用至今的重要网络安全技术,甚至在 Paypal 被 eBay 收购后,彼得蒂尔还专门成立了 Clarium Capital 公司为政府提供安全服务,其核心技术就是在 Paypal 期间为了对抗支付安全问题时打下基础的。 所以彼得蒂尔在思考机器和人类关系时,会重点关注机器帮助人类提升价值的领域。其中有一句话触达了问题本质:“机器不会有利己的诉求,因此价值最终会转移至人类”。 只要机器永远不要求自我价值的实现,人类和机器就能和平共处下去。 绿色能源与特斯拉由于彼得蒂尔与埃隆·马斯克曾经互为敌友关系,因此就关注到了特斯拉与绿色能源的问题。 彼得蒂尔认为,绿色能源技术要思考好如下 7 个问题: 工程问题,如果一个新技术不能带来本质的突破,那么其未来增长价值就不明显,公司的未来也不够清晰,狂热的投资注定引发泡沫。新能源技术目前带来的提升不是数倍的,因此前景不明确,无法说服大家一定去用这个产品。 时机问题,目前新能源领域技术并没有质的突破,现在进入注定面临技术储备不足的问题。 垄断问题,新能源技术是否能够垄断?新能源公司可能在故意隐瞒自己在市场中的渺小程度,其实相对于全球能源市场,新能源只是很小的子版块,整个行业总市值可能都不大。 人员问题。新能源是个技术问题,但现在融资需要 CEO 们西装革履的到处募集资金,这是严重的人员问题。 销售问题。人们对新能源领域、新能源汽车的接受程度有多大?是否足够便捷? 持久问题。随着中国在新能源市场的加入,导致美国新能源企业增长疲软,所以指责中国的声音很多。这是个危险的信号,如果成为垄断者需要以指责的方式进行,注定会失败。另外化石燃料随着液压破碎法的成熟,导致 2008 年天然气价格下降了 70% 多,新能源已不再是解决能源问题的唯一破局方式。 秘密问题。节省能源是一个政治正确的问题,大家都在呼吁要环保,那么这就证明环保项目一定有市场?不一定。 特斯拉的成功是因为解决了这 7 个问题,并且从实际的小领域切入,并且和政府以及其他企业达成了技术合作。这说明,在能源 2.0 市场中,企业面临的主要挑战是如何找到一个正确的小型市场。 创始人的悖论这个章节,彼得蒂尔分析了各种名人或创业者的特质,内容非常丰富,由于篇幅限制就不展开了,而且由于笔者在这方面缺乏相应的阅历,很难原汁原味的还原出他对每个名人的评价,因此细节还是推荐阅读原文。 以下只能做简单的总结,只能理解到其中部分思想: 伟人都拥有矛盾的两面性,企业需要极端的创始人,平庸的人往往很难成为好的创始人。 伟人的两面性与其成功路径存在相互塑造的过程,很难说是因为存在矛盾才导致了其成功,还是在成功的过程中塑造了其矛盾的性格。 伟人往往都会亲手终结自己的良好形象,除非英年早逝。 当然,这并不是说为了成功,我们必须成为这样的人,这个章节只是对创始人悖论这个现象的一种解读,可能这是一种自然现象,我们不需要模仿,只需要理解。 对未来的预期哲学家尼克·博斯特罗姆描述了四种预测未来的理论: 兴衰交替。由于历史总是呈现繁荣与衰败的交替,因此未来也很可能逃不出这个循环。 未来稳定发展。按照当今世界发展节奏,最后所有国家都进入发达国家行列,人民生活水平整体提高。 毁灭性衰落。由于地缘政治原因,未来不可避免会发生毁灭性冲突,人类文明可能呈断崖式下跌。 奇点。非常难以预测的加速发展,以至于发展到现在人类难以理解的高度。因为这个概念本身突出的就是 “发展到难以理解的高度”,因此试图去理解它的思考都反而会偏题,因此把它当作一种无法预测的未来吧。 笔者发现,现代大师人物写的书,最后都有对未来的预测,而且大家对未来的预测不同与书籍观点间的差异,往往都是很趋同的,这到底是英雄所见略同还是人类顶级大脑能到达的高度已经达到天花板?这是一个开放问题。 最后,保持独立思考是我们能重构世界的最佳方式。 4 总结那到底什么是创新?巴菲特说过,商业最重要的是护城河,护城河不是什么产品质量、高素质员工、巨大的市场份额。真正的护城河是:企业无形资产比如品牌、高客户转换成本、成本优势、网络效应。Paypal 创新的找到了符合网络效应的业务场景:“网络货币”。 为什么 “网络货币” 拥有网络效应呢?所谓网络效应是指,每新增一个用户,就会对产品价值带来指数级提升。支付网络每增加一个人,不但你可以参与交易,还让交易网络变得更大,让更多交易成为可能,甚至成为全球通用货币,获得比国家货币更强的流通性,而这个质变只需要更多的用户加入即可,这就是它的网络效应。 《从 0 到 1》是一本创新思维的启蒙书,但想要深入理解这本书提供的概念,基本的经济学、商业知识是必不可少的,至少要理解到创新指的是为企业构筑护城河,而网络效应是 Paypal 的一个重要护城河。 类似拥有网络效应的还有 Uber 和 Airbnb,但他们创新思维不同,导致网络效应的大小也不同。Airbnb 的网络效应是全球的,因为场景天然是 “旅游时自有房屋出租”,每成交一对商家与客户,都可能是跨地区的,而且客户也有自己的房子,可能下次自己就会成为商家。而 Uber 业务场景天然是同城的叫车服务,因此无法形成全球的网络效应壁垒,这也是为什么 Uber 无法竞争过中国的滴滴,但 Airbnb 的全球市场地位无人能撼动。 商业领域远远不止于此,研究商业就像研究历史,每个公司都能给我们带来巨大启发。而商业最迷人的地方就在它的非必然性,就算你反复研究历史,熟读《从 0 到 1》这本书,他也无法给你带来必胜的商业操作路径。但这本书真正能带来的是正确而成功的信念,只要确定你的方向是正确的 “创新”,至少你可以正视失败,坦然开启下一段创业旅程,而说不定哪一次就成功了呢。 讨论地址是:精读《从 0 到 1》 · Issue ##219 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《刷新》","path":"/wiki/WebWeekly/商业思考/《刷新》.html","content":"当前期刊数: 116 1. 引言微软的市值已经突破一万亿美元了,我们很难想象当年僵化而封闭的微软是怎么涅槃重生的。从仅支持自家 Windows 到收购 Github、从失去移动操作系统市场到与 AWS 平分云服务市场、从 Windows 收费升级到 Win10 限时免费升级、从咒骂 Linux 是癌症到大部分云服务都跑在 Linux 操作系统上、从反垄断、数据隐私被诉讼大户,到 Facebook Google 被监管部门调查时却可以置身事外,微软一定从内部发生了彻底的变革。 新微软的变革经验值得我们学习,《刷新》 就是一本介绍这场变革的书,它的作者是领导这场变革的现任微软 CEO 萨提亚·纳德拉。 这本书的关键词是:同理心、文化变革、成长型思维。 2. 精读本书围绕家庭与事业两个层面展开,从家庭中得到的领悟帮助作者更好的工作。 萨提亚的家庭作者萨提亚·纳德拉的母亲是一名教师,父亲是一个勤奋的印度高级官员,然而他的成长环境相当宽松,使作者从小就懂得独立思考并按照自己的意愿做事。母亲难以兼顾事业与家庭而选择放弃工作,让作者体会到女性工作的不公平,父亲追求上进心态与行为帮助作者得到更多职业发展机会。而孩子扎因天生的重度大脑性瘫痪使作者学着站在孩子的角度思考问题,学会真正理解同理心。 作者爱好的运动是板球,这是印度最受欢迎的运动。这项运动带给作者的除了热血沸腾之外,还有对团队合作的理解:好的领导不仅自己能力要出色,还要能帮助队员提升信心,发挥队员的潜力,而专业技能优秀的球员,如果不能进行良好的团队合作,最坏的情况甚至会损害团队整体利益。 微软面对怎样的危机危机往往是多个维度体现的,且相辅相成。微软面临的两大主要危机分别是 员工失去信心 与 业绩下滑,员工失去信心是内因,引发了业绩下滑的外因。 在最糟糕的时候,微软内部帮派林立,各部门负责人只想巩固自己的地盘,这让微软失去了创新领域竞争的机会。科技行业的业务趋势总是处于 三浪叠加状态: 旧的领域业绩已经在下滑,但基数大,往往也是公司发家的根基,对部门负责人自己来说,再吃几年老本对自己的利益最大,但这终将导致公司走向失败。 当前领域增长已经逐渐放慢,但未来仍有很大增长空间,这些业务被寄予了厚望。 新的领域尚不清晰,但一旦探索到正确的方向,增长速度甚至会年年翻番,这些业务会在未来几年内成为公司的收入支柱。 微软的个人计算机 Windows 操作系统太过成功,使微软在移动端浪潮下没能将足够的资源投入到移动端业务中,真正的创新部门被边缘化,旧领域部门掌握着绝对话语权,如果 CEO 不能作出改变,公司将走向不可逆的衰亡。 业务上,微软也在这三个主要方向全面落后: 操作系统领域:微软个人计算机出货量和财务增长已陷入停滞,而苹果、谷歌的智能手机和平板电脑销量正在上升。 搜索领域:谷歌的搜索和在线广告收入也在持续增长,而微软的搜索技术才刚起步,市场份额只有竞争对手的零头。 云技术领域:亚马逊推出的 AWS 已经在市场建立起领导地位,微软由于 Windows 原因,不愿意接受云计费模式,还在固守一次性买卖思维,甚至连云产品都没有。 微软是如何转型的站在首席执行官视角,转型一定是从文化转型开始的,只有转变了企业文化,才能充分激发每一个人的潜力,使公司朝着正确方向发展。作者在成为微软 CEO 后,在文化上作出的改变主要分为三点: 找到微软公司的新使命。显然,让每个人都拥有一台电脑这个目标已经达成了,为了推动微软继续前进,作者将新的目标设定为:赋能大众,通过做平台、工具,来提升全社会各组织、团体的工作效率、医疗效率、组织效率等等。 建立耳目一新、出人意料的伙伴关系。不论是 Linux 、苹果公司还是亚马逊,一方面是强劲竞争对手,但另一些领域也有合作的价值,比如将微软办公套件通过 IOS 平台普惠到大众这种部分领域合作的心态是不可或缺的。微软封闭的文化也在这一点上真正转向了开放,独占的思维模式如果走不通,合作能带来更多的机会。 同理心。微软高级副总裁沈向洋在 2019 年极客大会的分享也提到了这一点,微软通过制造辅助设备帮助帕金森患者正常完成写字、绘画。从广义上说,微软正式通过同理心,站在用户角度思考,才领悟到如何才能真正的帮助用户,比如一位安卓用户需要在手机查看 Word 文档,那么让 Word 支持安卓平台,推出基于云平台的 Office 365 就是一个自然的行为。 在文化转型的推动下,微软在业务上也进行了一系列积极的调整: 将云业务放到核心位置。这一点和阿里的云战略转型很像。云业务一开始都不怎么赚钱,需要大量资金和人才投入,在数年后才能看到回报,微软最大的问题是如何打破公司内资源分配不均匀的问题。通过一系列人事调整与战略制定,微软的云业务走上了正规,现在已经与 AWS 平分市场份额。 在可能的领域与竞争对手达成合作。除了推出 IOS 平台的 Office 套件外,必应还成为了雅虎搜索的搜索引擎,微软甚至放弃了排他性条款,允许雅虎同时使用其他公司的搜索引擎服务,即便如此,必应引擎现在仍驱动着大部分雅虎搜索功能,而良好的开放心态也加速必应搜索引擎能力的迭代。 推动部门之间员工的协作。随着文化变革,微软内部部门孤岛的情况有了好转,从不接收其他部门意见的 Windows 研发部门开始采纳其他部门员工提出的建议。笔者了解到 Facebook 的大部分源码每个员工都有充分权限参与修改,维护一个系统不只是相应业务线员工的特权,来自其他部门的创意往往更优秀。 三条领导原则无论是推动文化变革,还是推动业务增长,都需要高级、中层管理人员的实施,作者给出了三点领导原则: 向共事的人传递明确信息。传达信息是领导者每天都在做的事情,领导者应该把信息交流重点放在事情上,而不是人上,也就是关注如何把事情做好,而不是讨论谁更聪明。 领导者要产生能量,不仅在自己团队中,还要在整个公司中。领导者身处在多个圈子中,有自己管理的团队的圈子,也有来自上级组织的圈子,有来自公司级横向委员会的圈子,也有核心管理层的圈子,作者站在 CEO 的角度,要求领导者要将最高一层圈子放在首要地位,也就是整体利益大于局部利益。 找到取得成功和让事情发生的方式。也就是正确的做事,懂得平衡长期利益与短期利益,不走极端;让团队成员找到自己热爱的工作方式;能跨越边界,全球化思维。 其它本文要突出的介绍的内容已经结束,本书还有最后几个部分笔者简要带过: 三大变革: 作者提出未来可能由技术引领行业变革的三个方向:混合现实、人工智能和量子计算。这就是跨越边界的思维方式,微软积极布局的这三个前沿领域,对准的是未来的 “第三浪”。 隐私、安全和言论自由: 捍卫隐私、安全与言论自由也是微软转型的重要内容,微软通过积极与监管部门合作,通过实际行动捍卫言论自由,使得微软从政府监管对象逐渐转变为监管原则的捍卫者,这也是近年来科技巨头纷纷作出一个改变。 人与机器的关系: 不要把机器与人想成竞争关系,要理解为机器辅助人类的关系。同时机器也是释放人类创造力的最重要方式,虽然在变革前期会导致大量失业,但消失的旧行业都是重复性高的,创造出来的新行业更能激发人类的创造力。有一句话笔者印象最深刻:机器替代人类工作的过程,也是人类逐渐拾回作为人的尊严的过程。人本就应该将时间用于思考与创造,而不是重复性劳动。 3. 总结引导微软一系列变革的源泉可以认为是 “同理心”,因为同理心可以练就开放的性格,指引正确的方向。微软 CEO 萨提亚从家庭与生活中养成了同理心,并将其运用在公司的变革上,最终让微软每一位员工都能换位思考,利用同理心做正确的事,这种思想的传导是最难的一步,作者做到了。 对于我们的思考是,无论是公司的管理者,还是基层员工,都应该培养自己的同理心,因为有同理心的人不仅能更好的工作,在生活中也能更融洽的与人相处。 在工作中,同理心也是突破职业天花板的能力之一,想要提升为客户带来的价值,首先要接触并理解客户,站在客户视角思考问题,在面临内部矛盾或外部竞争时,仍能坚守为客户创造价值的目标,下一步改革的方向就会变得清晰,矛盾会逐渐化解,竞争也不会是一个问题,用户想要的不是竞争,而是被赋能,持有这种心态做事,与竞争对手合作就是利益最大化的选择了。 微软的首席执行官萨提亚正因为抱有同理心,才能作出超越竞争、封闭的决策,这对还没能掌握这一心智的公司来说,是种降维打击。一个用一切手段赋能用户、在核心能力不惧竞争(云计算)、在可合作领域充分合作的公司是极其强大的。 讨论地址是:精读《刷新》 · Issue ##196 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《当我在分享的时候,我在做什么?》","path":"/wiki/WebWeekly/商业思考/《当我在分享的时候,我在做什么?》.html","content":"当前期刊数: 137 1 引言很荣幸被评为公司年度十佳作者,被要求写了这篇命题作文。 虽然我写了几年文章,稍稍学会了如何总结,但从来没想过要给自己 “做分享” 这件事做一个总结。这次我决定挑战一下自己,应邀写下这篇文章,谈谈我自己做分享这件事。 我将从 Why、What、How 三个角度去说明做分享这件事,分别阐述为什么做分享,做什么分享,以及如何做分享。 2 精读Why - 为什么要分享在构思《前端精读》这个专栏的时候,那时网上的聚合专栏有很多,一般是每周收集一些优秀的技术、思考文章汇聚成一个列表,特别是一些知名度较高的头部专栏,用户阅读量很大,内容又多质量又好。当时我很羡慕这种模式,因为这种模式不用自己写文章,只要收集文章就可以了,而且在用户的监督下,也会促进你多阅读、多思考。 当时还萌生了另一个想法,就是现在《前端精读》的模式,每周找到一篇文章精读,并分享文章和自己对文章的观点。萌生这个想法的原因是,当时看了一些文章,觉得还不过瘾,想着如果把一系列关联知识串起来文章会更有价值,可是我并不能要求原文作者做这件事,因此就决定自己写关于这些文章的精读,将自己融会贯通后的理解展现给读者。但这样做有一些风险,首先就是自己写文章的要求比较高,我不能确定自己是否能坚持下来,其次是一周只写一篇,总感觉接收的信息量不如做聚合模式的大,毕竟别人一周就能看二、三十篇文章,而自己只能看一篇。 让我下决定的原因是看了一篇商业文章,是著名商业顾问刘润的一个观点:商业世界存在点、线、面、体,比如做一家杂志社就是一个点,做互联网信息收集入口就是线,而微博、微信都是面,整个社交行业就是体,每高一个维度都会对下一维度造成降维打击,所以科技行业才演变这么快,实际上是所处维度的不同。但高维也有自己脆弱的一面,就是竞争非常激烈,一个行业体中,通常是容不下太多面的。 同理,对写作来说,聚合专栏就是线,就像淘宝连接买家与卖家一样,聚合专栏收集优秀作者的文章,利用自己的流量分发给读者,但这个领域必然竞争激烈,当读者有了更好的线,为什么还需要差一些的呢?但做点就不一样了,你可以被无数线和想做线的人需要,你产出自己原创的价值,不会受到太大竞争影响。实际上我的经历也是这样,我可以将文章投放到各个平台(各个线上),这些线都成为了放大我影响力的工具,有越多的线,点的价值就越大,毕竟,想做线的人太多了。 在这里稍稍插一句,反过来,如果所有人都做点,只有极少数人做线,那线必定形成垄断,就像品牌商垄断农民货物一样,因为农民无法直达消费者,只能以很低的价格把农产品卖给品牌商,同样,消费者也只能通过品牌商买到货,所以品牌商就可以肆意加价。但互联网的发展改变了这些,无论是社交电商还是直播带货,都让生产者有了直接触达消费者的机会,就不用担心被中间商赚取差价;再者,如果大家觉得中间商有利可图,大量的品牌出现,生产者完全可以同时给多个品牌商供货,而在互联网分享的信息不会因为在一个平台的传播而消失,我们可以说文章与知识传播的边际成本完全为零,所以可以最大化利用多平台给自己带来优势。 所以我决定做一个点,将《前端精读》这个招牌培养起来。 What - 做什么分享因为我的爱好与职业是前端,所以看上去要做什么分享这件事很简单,只要分享前端技术相关内容就可以了。但这几年持续下来发现,事情远远没有这么简单。 在分享刚一开始的时候,肚子里憋着一堆想说的话,恨不得一天写一篇精读,但奈何精力与表达能力有限,勉强以一周一篇的节奏坚持下来。写作的内容都是自己最熟悉、最想表达的前端技术内容,而且过程中为了活跃团队气氛,还拉上大家一起参与,坚持了蛮长一段时间。然而很快就遇到了第一个问题,坚持力问题。 持续做一件事情总会觉得枯燥,加上业务变得更有前途,大家都越来越忙碌,逐渐出现了下周找不到人写精读的情况,此时我选择顶上空缺。但毕竟那时候精读没有多少人关注,成就感不高,加上没有养成写作习惯,写一篇文章往往要花费一整周的精力,连续写两周就觉得非常痛苦,毕竟把自己的知识与想法写成文章有着不小的成本,对自己非常熟悉的知识感觉写下来有些浪费时间,逐渐觉得枯燥。 在枯燥的过程中,我逐渐培养出更快的写作速度,但一个严重的问题也渐渐浮现出来,我渐渐发现自己的存量知识已经见底,有时间写文章,但却不知道写什么。每周我都会从网上的聚合专栏寻找优秀的文章,但与其说寻找还不如说是过滤,因为很多知识我并没有深入了解,特别是技术领域大部分是英文文章,光看下来就费劲了,更别说写下自己的精读理解。但周更的频率不能停,我只能逼着着自己啃英文文章,从一眼看下去脑袋全懵的状态硬是培养到一眼扫下去就能评估出文章是否值得精读,这是个漫长的习惯过程,因为初期效率很低,唯一坚持下去的理由就是我知道未来阅读速度会越来越快,读英文文章的速度最终是可以追平读中文文章速度的。 渐渐的我可以通过快速阅读,每周掌握一些新知识,并通过与存量知识进行碰撞产生出新的理解,这解决了 “无话可写” 的尴尬情况,毕竟没有人能保证自己的存量知识够自己写 50 篇、100 篇的文章,现在精读更新到 100 多篇,绝大多数内容都是我新学到的,这也是写作带给我无比受益的地方。 前端内容写多了,不免觉得自己知识面还是太狭隘了,每周不是捣鼓新设计模式,就是研究新语法,关注技术新进展,这只能把自己培养成一颗 “黄金螺丝钉”,如果我未来能坚持十年,写了十年基础技术知识,可能也最多成为一颗 “钻石螺丝钉” 而已。我第一次非技术细节文章的尝试是第一百零三期的 精读《为什么专家不再关心技术细节》,这篇文章也道出了我对个人成长的看法:你想发挥更大的价值,就要能影响更多的人,研究 100 年前端技术成为不了马云;同理,让马云写前端,他也不可能一个人写出阿里巴巴。 实际上写作就是一件价值放大的事情,你将自己的优秀理念输出给其他人,让别人写出的代码和你一样优秀,这就可以提升整个团队的工作效率。但这还远远不够,代码只是软件研发流程的一部分,我逐渐发现,把握业务方向、做好团队管理这两大能力才能最大化输出自己的价值,所以后面又写了一些例如 精读《前端未来展望》 对前端进行综合展望,精读《刷新》 对领导力进行领悟,以及一些极客公园系列文章增强对商业的理解,这些看似偏离前端技术的文章最终都是为前端服务,一个优秀的前端 Leader 具备的素质至少有:敏锐的商业嗅觉、清晰的理解业务方向、管理好团队,管理好人才、同时还是一个方向的技术大拿。 通过对商业、业务、管理的学习与写作,我并没有发现在专业知识上有多少延误,反而觉得自己以前认为是核心竞争力的 “技术思考” 变得越来越廉价,毕竟就前端技术甚至所有业务技术来说,理解任何一个技术点都没有绝对的壁垒,只要花费足够的时间就行了,难就难在需要花多久去理解,是否可以快速理解技术,理解业务。 How - 如何做分享写作的时候,只要明确文章主旨,句句点题就不会写的太差,一定不要企图将你的想法在一篇文章中全部表达,毕竟你没写的不代表你不知道,而东拼西凑的文章对读者没什么益处,毕竟读者是为了某个明确目的来读文章的,如果内容和标题关系不大,读者大概率会选择离开。 上面是最基本的写作技巧,我就不继续展开了,接下来要重点聊聊的是前端精读是怎么做分享的。我会从如何写作、如何坚持、如何形成正循环三个方面谈谈自己的感受。 首先是写作方式,前端精读的命题很明确,就是基于某个文章或者观点进行精读,因此每篇文章都有一个明确的主题。第二步是摘要,将文章内容精简的表达出来,这可以锻炼你的总结能力,也让读者能了解到背景知识。第三步是精读,这一步需要你有一些私藏干货,毕竟把文章直接翻译一遍是没有任何价值的,我在精读自己不熟悉领域的文章时经常遇到这个问题,此时我一般会找几篇类似的文章结合阅读,并找到一些可以互补的观点,这样的精读可以让文章的观点更加饱满。最后是总结,总结时可以点题,将重要内容再梳理一遍,也可以进行延伸,指出更进一步的思考方向。 为了让分享坚持下来,我在每周结束之前都会提前立好下周精读的 Flag,在 Github 开一个 issue,这样不仅可以提醒我周末的写作,还可以收获很多来自社区的讨论与反馈,让文章聚集了社区的智慧。这种提前立 Flag 的做法让我想到了自家小区物业费的收取方式,每年年初都会提前征收一整年的物业费,抛开商业手法不谈,这至少意味着物业对业务整整一年的承诺,这种承诺支撑了物业后续一整年的服务,也支撑了每周下一次的精读文章。 同时,我还找到了一种正循环模式促进写作,分别是让写作与工作、与分享、与生活结合。很自然的,我参与的数据中台工作本身就具有很大挑战性,工作中的内容与思考往往会成为精读内容的来源之一,比如之前写过的《手写 SQL 编译器》系列,因为数据工作中真的要用到这些知识。当我参加一些前端大会时,也可以顺便将分享稿整理成精读,将本来就要分享的内容分析得更彻底,有一种借力打力的感觉。在生活中,参加一些论坛,看过的书都可以成为精读的题材,无论是商业的,人文的,还是历史的,对多元化思维有帮助的内容都可以分享。 3 总结回到主题,当我分享的时候,我在做什么?相信看完上面的内容,你已经得到了答案。 当我在分享时,我在传播知识,扩大自己的影响力,这是显而易见动作。但在这背后,我同时也在践行终身学习理念,每次分享都是一次新知识的学习,是一次知识边界的拓展。也许是一次对工作的思考,也许是一次对生活的感悟,然而每一次都是成长的记录。 思想不会因为传播给他人而减少,每一次分享都是在创造永不磨灭的价值,希望看到这篇文章的你也能认知到分享对自己、对他人的帮助,相信分享的力量,相信积累的力量。 讨论地址是:精读《当我在分享的时候,我在做什么?》 · Issue ##229 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《数据之上·智慧之光 - 2018》","path":"/wiki/WebWeekly/商业思考/《数据之上·智慧之光 - 2018》.html","content":"当前期刊数: 106 1. 引言本周精读内容是:《数据之上 智慧之光》,由帆软软件公司出品。 帆软公司是国内一家做大数据 BI 和分析平台的提供商,主打产品是 FineBI。笔者所在阿里数据中台也处于数据分析应用的前沿,本次精读的文章就是帆软公司的 《数据之上 智慧之光 2018》,感谢提供这份国内数据市场研究报告,让我们更深入全面的了解国内数据市场的发展方向。 随着 5G 的逐渐推行,网速比 4G 提高了 100 倍,将会为物联网打下通信基础,未来的世界将人与物、物与物进行互联。随着越来越多的设备接入网络,产生数据,而未来还有 6G、7G 将网速继续提高至 1 万倍、1 百万倍,利用卫星实现全球网络覆盖,将现实与虚拟融合等等,无不需要强大的数据处理分析技术才能掌握。 数据的总量将呈几何倍数上升,如果不能提前对数据的存储、处理、挖掘和分析提出一套解决方案,那么 5G 时代的海量数据就是人类社会的累赘,如果有一套数据处理与分析的方案,我们就有可能掌握海量的数据为自己所用,利用数据进一步推动人类社会向前发展。 上面是对未来的畅想,那么我国现阶段国内的数据市场的容量、需求是什么样呢?《数据之上 智慧之光》这本书给了我们答案。 PS:本文使用 2018 年的数据。 2. 精读大数据行业发展趋势2018 年中国大数据产业规模预计 329 亿元人民币,同比增长 39.4%。可以看到增长速度逐年增加,预计在 2020 年数据市场规模可达 586 亿元人民币。 笔者查了一下,2018 年全国网上零售额为 90065 亿元,比数据市场规模多了一个数量级,所以我国的数据产业其实还在萌芽期,可能还需要 5 到 10 年才能完全成熟,这也意味着目前数据市场是一片蓝海,从后面的数据和国内数据应用使用情况也可以看出来。 另外,各企业在大数据领域的投入资金与部门组织都同比 2017 年有所增加,其中接近四成的受访企业已经在应用大数据,较 2016 年提升了 4.5%,暂不考虑大数据的企业从 2016 年 7.8% 下降到 6.8%。 从微观角度观察社会也能发现这样的趋势,近些年研究大数据的公司明显增多,许多公司都逐渐设立了 “数据分析” 岗位和部门,可视化大屏在 toB 与 toG 领域都越来越得到重视。 企业数据应用情况数据应用分为数据采集、数据治理、数据处理、数据分析这四大阶段,其中数据采集是获取数据的最重要方式,而数据治理是将分散在各种不同形态数据库的文件用统一方式管理起来,比如形成数据联邦,这是数据使用前最重要的一步治理。数据处理就是将数据按照业务需求进行计算,而不同量级的数据计算方式会不同,特别是大数据场景要分为离线计算与实时计算,只有极为重要、实时性要求强的指标才进行实时计算,现在正处于离线与实时计算混合的混合计算转型期。数据分析一般通过 BI 平台完成,也是分析数据最重要的一步,BI 也经历了漫长的版本迭代,第一阶段是数据报表阶段,第二阶段是具备分析能力与数据挖掘能力的分析阶段,第三阶段是机器自动识别用户意图的智能化分析阶段。 从智慧之光的调查结果来看,只有 22.47% 的企业实用了 BI 系统,而使用 BI 系统的企业中,超过七成认为 BI 项目能较好的满足现在的需求。说明未来还会有更多企业使用 BI,BI 的市场还有 4 倍的增长空间。 在数据应用成熟度方面,仅有 3.5% 的企业处于数据盈利阶段,也就是大部分企业对数据的治理还在投入阶段,但无需质疑,持续对数据进行投入一定能得到回报,但短期来看会拖累财务报表。 再看目前企业的数据价值需求,看看业务方对 BI 工具的期望有哪些。 期望从高到低分别是: (72.8%) 整合多系统数据,打通数据壁垒 (69.1%) 提高报表数据效率,更快更准更省事 (53.7%) 辅助管理预测,提高决策成功率 (51.4%) 提高生产效率,降低人力成本 (50.0%) 数据结合管理,优化管理方式 (47.8%) 业务监管分析,促进业务增至 这个排列顺序基本上也是 BI 平台迭代的顺序。 BI 刚起步时都要先做数据整合,对于大部分公司,数据孤岛的情况还是很普遍的,甚至有大量数据分散在各自工作人员电脑的 Excel 文件中,已存在的各业务平台见数据无法打通也很普遍,如果不能将多套系统间数据打通,你就没有对数据的掌控力。像阿里云的 Dataphin 就可以帮助企业建立数仓,建立一套数据资产管理体系,其中第一步就是帮助你打通数据壁垒。 解决取数问题后,就可以建设 BI 平台了,BI 平台初期基本以构建报表为主,而构建报表的方式根据发展阶段也各有不同,下面是智慧之光中一张很经典的 BI 发展阶段: 在 IT-完全主导型阶段,主要任务就是制作报表,而业务人员能配置的部分只有 BI 模版的 5%,剩余 95% 都需要 IT 人员参与开发,不仅浪费人力资源,而且对业务线的时间成本也很高。 IT-强主导型阶段,BI 平台具有一定的配置能力,业务有 20% 的自主配置权,而 IT 仍需完成 80% 的工作。 在业务强主导型阶段,BI 层 80% 的工作都可以由业务方完成,IT 人员只参与 20%,这 20% 可能包括复杂场景的定制,比如电子表格或者复杂的分析功能。这个阶段真正实现了更快更准更省事。 业务完全主导型阶段,基本上 BI 层不需要 IT 人员参与,业务同学可以完全主导对 BI 平台的拓展,或者 BI 平台已经能满足业务线几乎所有的诉求,同时业务还能参与数据模型的控制,让业务能力下沉到数据层。到这个阶段的企业已经非常少了,也许只有少数互联网巨头可以达到这个阶段。 智能自助型,这个阶段不需要 IT 人员参与,业务仅需参与 1%,原因是 99% 的需求都有人工智能自动分析出来,也就是将业务数据拿到后,计算机已经知道该怎么看这份数据了。智能自主型在国内还处于概念阶段,在国外 BI 工具比如 PowerBI 与 Tableau 已经在这个领域深耕多年了,然而门槛比较高,目前效果应该还不太理想,因为这个阶段一旦成熟,国内的 BI 企业将面临巨大冲击,之所以国内处于业务强主导阶段的 BI 平台依然存在,除了数据安全的理由之外,只能认为国外智能自助型 BI 平台依然 “不够智能”。 通过上面的分析可以总结出,BI 平台不仅业务发展阶段迥异,对技术人才的要求在不同阶段也不一样,技术层面需要以 后端 -> 前端 -> ETL -> AI 人才 的递进态势演变,对技术人员来说,如何在 BI 技术演变的过程中不断自我学习,满足下个阶段的技术要求,是非常严峻的挑战。 另一个值得关注的是企业数据来源,根据 2016 与 2017 年的对比,来自企业内部的数据正在逐渐增多,从外部购买的数据从 16.7% 降低到 15.1%,而从政府免费开放的数据比例从 13.5% 提升到了 14.6%。这表示企业正在逐渐摆脱对外部购买数据的依赖,转而产生更多自己业务的数据,而政府也在逐渐加强开放数据建设,努力减少各企业间数据资源的壁垒。 企业数据使用方式根据调查显示: (70.0%)使用传统的 SQL + Excel 分析数据 (64.8%)使用业务系统自带的报表或分析功能 (35.6%)使用 BI 工具 (10.8%)手工写代码 首先频繁的手工写代码只有 10% 不到的比例,这是因为稍稍有点长远打算的企业,都会打造一支技术团队,而业务也会给技术团队打造一些生产效能提升的工具,只有 10% 左右的企业无法割舍短期利益,导致所有数据分析需求都要手工写代码。 大部分企业依然采用 SQL + Excel 分析数据,这个结果在情理之中,因为 SQL + Excel 都是现成的工具,不需要研发成本,而 Excel 的强大分析能力也基本满足了业务需求。但这种模式无法共享分析结果,存在数据安全隐患,且无法进入分析与智能阶段。 使用业务系统自带的报表或分析功能也占了 64.8% 的比例,笔者所了解到的中小型公司也的确属于这个阶段,公司内不同业务线都有自己的业务平台,每个业务平台内都有或多或少的数据分析和报表能力,这对大部分企业来说够用了,但对于要建立 数据中台 的企业来说,分散在各业务系统的数据与报表能力,反而是一种阻碍。PS:阿里数据中台已进入 2.0 阶段,但对大部分企业来说,是不可能越过数据中台 1.0,直接进入 2.0 的,就像不可能跳过 5G 做 6G 一样。 只有 35.6% 的企业在使用 BI 工具,因为使用 BI 工具需要一定门槛,比如做数据治理等,当然也可以直接订购阿里云的 Dataphin 快速接入 QuickBI。 在企业使用 BI 时,选型的考虑因素也很有意思: (69.1%)产品是否高效易用 (59.2%)产品是否稳定性高,性能好 (58.5%)产品是否拥有丰富强大的功能 (51.4%)产品是否具备大数据分析能力 (33.6%)采购成本 (31.2%)生态与学习资源 (24.4%)厂商本身的实力 可以看到,BI 工具靠自身实力吃饭的,而不依赖公司光环,因为业务方对实用性要求更大。 69.1% 的企业看中是否高效易用,说明目前国内企业对 BI 培训能力较弱,希望有高投入产出比,同时也说明了 BI 自身的特性,它是面向非技术人员的产品,如果易用性不强,只是功能强大是没有用的。 59.2% 的企业看中稳定性和性能,这是因为对数据分析来说,看报表是高频操作,业务方会使用 BI 查看 KPI 报表,发日报或月报,用户是无法忍受频繁使用的产品稳定性出现问题的。 第三点就是功能是否强大,对一款面向用户的工具来说,如果功能有欠缺,就意味着无法满足业务需求。比如对折线图做归一化,如果 BI 平台的折线图自身不支持这个功能,使用者也没办法立马拉上一名前端同学拓展出这个功能,因为 BI 平台表面看上去易用,但底层设计复杂,一旦遇到功能不支持,除了等待更新外,没有更好的办法。 最后一个超过 50% 的用户期待就是具备大数据分析能力,这是因为企业数据量级普遍都很大,而 BI 平台底层的多维建模一般采用 OLAP 查询,遇到海量数据可能要等上几十分钟,需要 BI 平台内置一些数据加速的功能。ROLAP 给予关系型数据库,特点是兼容性强、灵活性强,但查询速度慢,而 MOLAP 是实现将各维度数据计算好,查询时直接映射到多为数据库访问,性能好,但是对存储空间的依赖极高,需要付出大量的金钱才能支撑这种模式的查询。 下面是企业对 BI 功能要求: 可以看到,对报表能力需求量最大,说明报表是 BI 工具基础的要求,也说明我国对数据的使用方式还停留在最初级的阶段。 另一个就是移动 BI 需求,在移动端看报表,PC 端做报表已经非常普遍了。 之所以数据填报排到了第三名,是因为不同公司并不是所有数据都统一管理,BI 支持数据填报,就可以将遗漏的数据录入进去。 相信在未来,这个条形图最长边会逐渐移动到腰部。 最后是企业面临的综合挑战: (64.8%)数据的整合与治理 (58.1%)与管理层及业务部门的配合 (51.8%)数据人才的培养 (49.8%)数据分析工具的选择 (42.4%)IT 部门自身的能力提升 (38.1%)衡量数据分析的价值产出 (27.6%)公司重视程度或预算投入 (14.1%)项目风险的控制 数据整合与治理是最大问题再次反映了我国数据可视化处于较为初级阶段,第二名的 “与管理层及业务部门的配合”,也印证了这一点,如何将数据价值传达给管理层,让管理层认可前期投入在未来是可以得到回报的,是在企业里做数据分析比较头疼的问题,而其他业务部门如果不予配合,不将数据交给数据中台部门,又难以解决数据整合的问题,而这个往往又依赖管理层的决定,因此管理层与业务部门的配合问题是相辅相成的。 第三名是数据人才培养的问题,这个问题笔者认为还好,前几年流行大数据人才,近几年流行 AI 人才,我国数据人才应该有不少的储备。 后面几项最重要的就是 衡量数据分析的价值产出,任何做数据的部门,如果不能让数据为公司带来价值,这件事件就没有可持续性。笔者建议从数据整合后的管理提效,节省机器成本的角度计算出收益,从数据分析平台为其他业务部门提供的决策依据,计算出为业绩提高作出的贡献,再从对公司内部做报表、邮件的研发人力节省,管理层快速查看公司整体实时数据分析的角度计算出软贡献价值。 3. 总结尽管 BI 平台与数据分析可以为公司带来巨大的价值,但制作 BI 平台的成本是相当大的,而且 BI 平台具有马太效应,目前国际第一梯队的 Tableau、PowerBI 无论是吸引的人才,投入的资源,市场份额都远超追赶者的总和。 从 17-18,18-19 的 BI 四维度对比可以看出,低端 BI 的角逐正在越来越激烈,行业龙头 PowerBI 与 Tableau 位置越来越稳,国内 BI 龙头 FineBI,以及正在逐渐发力的 QuickBI 希望能挤进国际梯队,在 BI 技术领域拉平与发达国家的差距。 PS:目前国内市场的情况,反而不适应 PowerBI 与 Tableau 阶段的 BI 工具,给国产 BI 工具创造了发展机遇,我们要抓住这次机遇带领中国数据市场走向第三代增强分析型,并使国内 BI 工具在国际市场占有一席之地。 讨论地址是:精读《数据之上·智慧之光 - 2018》 · Issue ##162 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《极客公园 IFX - 上》","path":"/wiki/WebWeekly/商业思考/《极客公园 IFX - 上》.html","content":"当前期刊数: 135 1 引言这次是极客大会十周年,也正好告别了 2019 年,因此主题是总结互联网前 10 年的发展,并预测下一个 10 年的变化。 这次是前半部分的大会感悟。 2 精读微信的成功腾讯和米聊分别在 2010.12、2011.01 上线,起初他们的用户基数相当,每天都有恐怖的 10% 用户量增长,然而这两家的差距在 2011.07 开始拉大,之后微信便占有绝对优势,米聊彻底失败。 所以有人说微信抄袭米聊,毕竟微信起步比米聊晚了一个月,然而微信的胜出有更深层的原因。 大家都知道移动端即时通讯是一个唯一寡头市场,因此当米聊看到微信开始反超的时候,就已经知道这场战争已经结束。当时小米重点业务还在手机,米聊是团队试水的一款产品,但看到歪打误撞进入一个如此蓝海的市场,小米自己也很纠结要不要把资源都投入到米聊上。 反观微信,当时手机 QQ 也在做,本来怎么也轮不到微信出场,但张小龙、马化腾、张志东在微信建立了深夜小组,每天晚上都即时同步微信的进展,这让微信即时获取到了腾讯内部资源,在各种关键节点帮了很多忙,甚至让手机 QQ 技术大牛直接支持微信改善高并发问题,快速完成 QQ 好友导入功能。 雷军总结到 “如果腾讯一年后才有所反应,米聊胜率是 50%,如果是腾讯两三个月就有反应,米聊应该 100% 会死掉”。 很巧的是,张志东事后也总结过一句话 “如果我们当初没有看清这个趋势,没有在微信起量的事后,看清这个本质,微信胜出概率也只有 50%”。 然而腾讯的反应实在太快了,米聊之后只好走差异化社区路线。 微信后面的发展也非常精彩,通过源于用户需求的少量功能,比如微信红包,不断引爆微信的增长。夸张的是,微信装机量的增长率始终与智能手机渗透率持平,这说明 微信吃掉了所有新增的流量红利。 微信的发展,是一个 工具到平台,平台到生态的演进过程。 真正让微信建立生态的是小程序。小程序是一个去中心化模式,当大家都想像公众号一样抢一波风口红利时,微信做的正是去中心化,微信不给任何小程序导量,每个小程序的流量入口都需要开发者自己经营,这种商业模式才可持续发展。 对公众号也是一样的态度:公众号要持续创造价值,没有初始红利。理解了这一点才理解了现在微信生态一系列做法,只有每位贡献者持续创造价值的生态才是可持续的,生态绝不是在创建之初让抢到先手的用户瓜分平台流量红利,这样是不可持续的。 移动终端的中场战事前十年,手机设备制造厂商的格局发生了很大变化。国内经历了从小米,到 OPPO、VIVO,再到华为的演化。 印象深刻的是看了一个雷军创办小米前夕的访谈视频,雷军说 “大家看到苹果的成功,却没有看到这片蓝海的机会,现在手机制造领域竞争太不激烈了”。同时为了对抗苹果,谷歌开源了安卓源代码,小米利用这个机会打造一款符合中国人口味的手机操作系统,并借助用户社区与性价比优势一举占领了早期市场。 2015-2018 年出现了 OV 领跑的情况,即 OPPO、VIVO 后来居上,有两点原因:小米还在强调各项参数指标,但 OV 宣传的概念很易懂 “充电五分钟,通话两小时”;同时 OV 还注意到了下沉市场,通过各种综艺节目冠名与 平均 25 万家线下门店布局,超越了小米。 上面两点分别对应了创业的早期与扩张期,然而 2019 年产业进入成熟期,手机出货量开始下降,市场逐渐进入零和博弈阶段,此时大玩家华为入场,华为的入场姿势是投入数万名研发资源进行饱和式攻击,成熟的市场比拼的不是营销而是技术,从争夺用户变成留存用户,这个阶段华为胜出了。 值得关注的是,从苹果收入年报来看,其中软件服务收入占比正在逐年升高,这也代表了一种未来发展趋势,在垄断了硬件后将收入来源逐渐转化为软件和服务。第三天的 OnePlus 手机恰恰是反其道而行之,仅通过硬件赚钱,商业模式也运转的很好,这个到后面再细说。这就是商业的有趣之处,第一商业历史的精彩程度不亚于国家战争史,第二商业模式没有万能法则,两种完全相反的模式都能活得很好,这是它最有魅力的地方。 支付宝支付宝是典型的工具场景,这次分享核心观点是:只要把工具分内的事情做好,自然会赢得用户,赢得市场。 第一个例子是早期 PC 支付时代,由于支付需要跳转到各大银行网银页面,整个链路长达 7 次跳转,用户整体付款成功率只有 60%,马云为此在年会上把支付宝团队狠批了一顿,这也促使支付宝在次年研发了快捷支付,将银行支付流程替换为支付宝自己的支付流程,支付成功率提高到了 95%。但这个改动是艰苦的,有一句话印象深刻:“为了用户体验,能做的都做了,不能做的也都做了”。 无论是二维码支付、芝麻信用还是小程序,都是由用户对工具的需求催生出来的。其中芝麻信用是因为支付宝解决了淘宝上买家与卖家的信任问题,但社会依然存在大量信任问题,芝麻信用的初心就是将淘宝信用解决方案推广到全社会。不积跬步,无以至千里,任何了不起的方案起步都是解决一个具体的问题。 拼多多拼多多给人的刻板印象是“下沉市场”,然而这既不是拼多多的起点,也不是拼多多的终点。 在创立拼多多之前,黄峥创建了一个“拼好货”的应用,这个应用瞄准城市人群,本来可以在这个垂直领域深耕,但在拼好多过程中,黄峥发现微信用户已经达到 7 亿日活,有一大半人群还没有网购习惯,但具备了网购能力,因为正好赶上微信红包培养了用户付款习惯。 为什么淘宝、京东不在微信里卖货? 原因是担心成为微信的货架。为什么淘宝当初要切断百度搜索入口?因为一旦用户培养了在百度搜索淘宝的习惯,淘宝就无法成为第一级用户触达者,一旦百度推荐自家电商产品或者切断淘宝流量,淘宝将遭受灭顶之灾。 在微信也一样,淘宝和京东都不希望被微信扼住喉咙。但这毕竟是“巨头”担心的事情,就一个创业公司来说,成为微信的货架又如何?这是个很大的市场空白,迟早有人补位。 拼多多切入点是下沉市场,下沉市场的特点是“有用户,没商品”,因此拼团很好的解决了这个问题,既提高了购买量,提升了物流、供应商效率,大量的订单量也提升了拼多多对供应商谈判的筹码,导致拼多多可以以低价提供给买家,低价又促使买家下更多的单,形成一个小飞轮。 下沉市场只是拼多多的第一刀,举一个爆品的例子:拼多多与商家合作推出了爆品玻璃碗,又大、又厚、耐高温,一下子成为了爆品,让商家与拼多多双赢。重点在于,打造爆品对促进飞轮运作太有用了,爆品意味着大量单一订单,拼多多对单一商品谈价能力提高到极限,商家制作成本压低到极限,爆品是效率最高的社会生产和消费方式。 在这个过程中,拼多多主动帮助商家打造爆品,“平台”干预商家带来双赢可能是未来一个强有力的竞争武器。 美团的商业逻辑“不设限”是对美团比较好的理解。大家都觉得美团什么都做,其实美团就是坚信“按照规律做事”,从模仿美国的 facebook - 校内网、twitter - 饭否、groupon - 美团,好的借鉴也是一种成功哲学。 四纵三横的思想,更透彻理解不同平台做的事情: 咨询 通信 娱乐 电商 搜索 百度 QQ 热血传奇 淘宝网 社交 新浪微博 人人网 开心网 蘑菇街 移动 今日头条 微信 练好基本功,提升工作效率,管理层按规律做事,合适的事找合适的人,没做过的事就自己探索,这是美团总结的经验。 字节跳动字节跳动的估值几乎是百度的两倍了,为什么看似体量更大、资源更多的百度会被字节跳动超越?大家都很感兴趣这个话题。 字节跳动核心能力是个 性化推荐引擎,旗下产品 “社交、自拍、咨询、教育、金融理财、短视频、问答、电商”都利用了技术中台输出的个性化推荐算法作为核心竞争力。 字节跳动推出的成功产品很多,像今日头条、抖音、火山、西瓜,背后的方法论就是“产品、技术、文化”。 产品上,地毯式孵化许多产品,并且根据上面总结的领域乘以个性化推荐进行了许多尝试,比如社交 X 个性化推荐,短视频 X 个性化推荐,咨询 X 个性化推荐。产品迭代也是个逐步的过程,比如抖音从直播,到小学生短视频工具,最终找到了城市潮人工具这个最合适的定位。 技术上,首先是大量从百度挖人,而且挖的都是核心技术架构骨干。其次,打造了技术中台:技术部分为“算法组、互娱组、产品技术组、垂直产品组”,最核心的技术人员在算法组,为所有产品横向赋能。总结一下就是豪华技术团队 + 技术能力中台化。 文化上,字节跳动保持很大的信息透明度,比如新员工可以查看所有历史工作资料与聊天记录,公司所有决定都是透明可查询的,公司管理扁平化。 共享出行与共享经济滴滴2010 ~ 2019 年,共享出行的代表就是滴滴,这个话题从滴滴开始剖析了整个共享经济行业,非常有意思。 切入点是 融资。BAT 上市融资额度分别是:百度:1.112 亿美元、阿里巴巴 69.88 亿美元、腾讯 0.2188 亿美元,总额 71.2 亿美元。而滴滴到目前为止的融资已经达到 208 亿美元, 滴滴融资超过 BAT 总和,这说明了什么?这说明滴滴走了一条不正常的商业路线,即先疯狂再冷静的烧钱路线。 当一个行业增长速度极速增加时,老玩家将失去优势和壁垒,所以谁能更快扩张谁就能成为最终赢家,此时如果有大量资本投入快速占领市场,让企业成为这个领域的绝对霸主,投资者就可以通过上市退出的方式把之前烧的钱赚回来。然而这种烧钱商业模式是有前提的,即 极度充裕的资本 + 清晰的结构性机会,滴滴的结构性机会非常清晰,先垄断再收割。 传统商业模式:融资 -> 赚钱。 非常态的商业模式:融资 -> 烧钱 -> 烧钱 -> 烧钱… -> 赚大钱。 Uber 创始人 特拉维斯·卡兰尼克 说了一句很经典的话,翻译过来就是:一个赛道上只要出现一个 “疯子”,所有人都必须变成 “疯子”。即一旦你所在的领域开始有公司利用融资 + 烧钱的方式运作时,你也必须这么做,否则你的市场会被对手抢走。 然而也可以看到这几年大量烧钱的公司开始合并,比如滴滴和快的打车、同城和赶集网、美团和大众点评、携程和去哪儿,这些公司合并的背后都是投资人运作的,那为什么要合并呢?道理很简单,双方投资人都在砸钱,谁也扳不倒谁,此时投资人会计算现在烧的钱在垄断市场后能否收回来,如果收不回来,双方投资人都不傻,大家为了不赔本,一定会促使两家公司合并,这样才能停止烧钱,即时上市止血。 有意思的是,滴滴从抢单模式变成派单模式,就体现了烧钱抢市场到精细化运营考虑盈利的一种转变。 摩拜和 OFO摩拜和 OFO 的发展本应该比较平静的,因为共享单车要解决的问题是 “看得见和愿意骑”,投放更多的车可以解决看得见问题,提升骑行体验可以解决愿意骑的问题,然而大量投资人从滴滴大战中大赚了一笔,想要把模式复制到共享单车领域,战斗就开始了。 由于资本的投入,摩拜和 OFO 重点都放在了“投更多的车”上,但这种抢占市场的方式并不像滴滴一样合理: 滴滴将大量私家车借给没车的人使用,本质是将“私人交通工具”变成“公共交通工具”,提升了“私人交通工具”的利用效率,对社会有益的事情自然能站得住脚。 共享单车的问题在于,大家不会把自家自行车骑出来借给别人用,毕竟开着汽车可以带乘客,但骑着自行车带人变成服务也太奇怪了。 所以各公司大量制造新的自行车投入市场,要解决的是公共交通问题,但这些自行车并没总在路上跑着,而是在街头大量闲置, 这样其实降低了自行车的工具利用效率,从根本来看没有创造剩余价值,因此盈利模式不太明朗。 更多共享模式后来出现的共享充电宝、共享车位、共享雨伞等等细分领域的创业,本来资本也想走烧钱模式,但发现走不通,还是回到了最初健康的模式。根本原因可能是这些行业无法产生寡头垄断,无法通过烧钱的方式快速占领市场并回收资本。 产业互联网与衰退期看未来十年,互联网也许进入了一个“衰退周期”,互联网从纯线上变成与产业结合,比如软硬件都做,或者线上线下结合才能继续破局,反过来说,以前纯线上一本万利的高速扩张模式一去不复返了,互联网要深度与社会结合,发挥更多实际的价值才能得到自身成长,这是一个泡沫破裂的过程,也是互联网回归到真实价值的过程。 如果资本不充裕了,对创业者来说也还有机会,比如相应的会带来低人力成本与低广告投放成本。 最后,周航宣传了一个创业孵化项目,即投资人与创业者深度交流几个月,在这几个月内让创业者得到成长,让投资人能看清创业者是否具备潜力,这种投资者与创业者培养感情的孵化方式是比较新颖的,相对面试来说,有更多机会呆在一起可以看人看得更清楚,投资者与创业者更容易建立信任关系。 语言 AI 的未来构想搜狗在 AI 语音布局很久了,我们熟悉的搜狗产品有“搜狗输入法”和“搜狗搜索”,这两个都是语言入口,所以搜狗基于语言来布局。 语言 AI 的发展方向是自然交互 + 知识计算。自然交互指人机自然的语言交互,利用语音技术、图像技术、视觉技术识别;知识计算指的是利用知识对语言进行处理,比如翻译、问答、对话。综合两者有可能产生未来的智能助理。 语音皮肤在知识付费领域就有应用场景,通过识别人的声音,将其特征提取后把另一个人的声音音色覆盖掉,这样就能让任何人代替讲师录制音频了。同样在导航语音也有类似适用场景,后面百度地图的分享会提到。 发生在边缘的 AI 计算革命所谓边缘计算指的是去中心化的本地分散运算,比如自动驾驶,就是发生在每个车上的本地计算。为什么不是云计算?因为本地计算一般都需要即时响应,尤其是自动驾驶只有几百毫秒的生命线,万一网络出现延迟,后果是谁也承担不起的。 边缘计算产生的数据量非常庞大,一辆自动驾驶汽车平均每天产生 600-1000 TB 量的计算,而且自动驾驶 L1 - L5 需要的算力也是呈指数级增长的,要解决这个问题,自研芯片与算法的软硬配合是一种突破方式。 地平线公司要做的是智能互联的底层,做手机领域的思科,做智能化时代的底层基础设施。 通往人机交互“终极自由”的 AI 之路报告显示全球有 26% 的手机用户每天使用手机超过 7 小时,35 岁以下人群平均每天解锁手机,人类都要成为手机的奴隶了,看似拓展了人类生活自由,但反而感觉人类被手机束缚住了。 原因有几块: 交互方式不自然:按键和触屏都不方便。 智能手机不智能:appStore 就是智能手机了?就算有语音助手加持,也无法理解连续语义。 解决办法就是更自然的,让人类感受不到的电子设备交互方式,比如微型音频设备,AR 眼镜,体内芯片等外挂方式,交互上需要进化为语音交互、手势交互、脑波信号等。 目前这个阶段,智能手表和智能耳机都是较能符合这个进步趋势的尝试。 地图的破局比较有意思的是利用 20 秒对话训练,可以产生一个你自己语音包,用你自己的声音导航。 另一个功能是预测第二天路况,并根据到达时间推荐一个合适的出发时间。 百度地图不止于导航,在如何挖掘地图额外价值方面也在做积极的尝试。 一起创造【所见及所能】的平行世界外号科技介绍了一款产品:远距离二维码。 我们现在看到的二维码基本都是近距离的,近距离二维码可以:支付、加好友、账号登录、近距离信息获取等。 而远距离二维码是相对于近距离二维码的,在极端情况下甚至可以达到一公里的距离。 远距离二维码的适用场景有四种: 远距离信息获取:服务机器人定位导航、无人机遂窗配送、电子围栏。 高精度定位:实时物流、室内定位报警。 增强现实:景区 AR 改造、AR 多人游戏、室内沉浸式导航、机场电子指示牌。 数据重建:室内测距和建模。 当科技拉近我们与世界的距离这个演讲者是一名了不起的盲人曹军,他创立了保益科技帮助盲人像明眼人一样生活。 记忆最深刻的一句话是:不要总以为帮助盲人就是出一款盲人专用手机、盲人专用 App,其实盲人最大诉求是像普通人一样享受科技的便利,普通人能用的手机、能用的 App、能开的车,盲人也都想用, 普通人应该想办法把自己用的手机、软件改造成盲人可以使用的版本。这是最大的换位思考。 鹏友说 - 傅盛傅盛带领的猎豹做智能机器人已经有几年了,今年有了最新进展,出货量达到 5000 台。 傅盛提到一点非常关键,就是机器人这个名字起的很不好,总让人觉得机器就应该拥有人一样的智慧,其实我们这个阶段还做不到,而且行业也不需要那样聪明的机器,要的而是一个服务工具。 举个例子,博物馆的导游可以被机器人替代,因为一方面机器人信息储备量大,工作效率高,而且还能听懂任何国家语言,这样一个机器人甚至能胜过好几位资深导游,而导游这种场景也相对局限,容易实现。 机器人也不一定要长得像人,在不同领域可以做出不同体型,适配不同的工作场景。机器在某些垂直领域完全可以超越人类。 探秘人工智能背后的【硬核英雄】未来 10 年定制化数据服务领域可能分为 5 大块: 设备的定制化 比如无人车的场景,从多摄像头到摄像头 + 激光雷达的方案,随着业务场景不断多元化,对设备定制要求也会不断提高。 场景的定制化 还是无人驾驶场景,为了保证在多场景的安全性,需要模拟出许多情况下的交通场景,比如不同光线强度、角度、不同车道、不同车型、不同类似司机、人群和环境。 样本的定制化 今天很多 AI 是以人为中心,人群可以根据不同肤色、不同语言、不同年龄段、不同爱好等进行区分,所以根据基于样本的定制也是一大趋势。 工作的协同化 和 工作的专业化,即随着分工不断细化,协同度与专业化程度都会提高。 智能交通九号机器人这家公司为了解决开车与步行之间存在的空白的问题,九号机器人提供了大量代步机器,比如智能滑板车,智能电动车,所有车辆都是“电动化、网络化”的,预测下一个 10 年会 加上“智能化”。 开车与步行之间的机器人除了代步,还有快递和配送这个巨大场景,而这个场景的优势在于,低速场景的机器人自动驾驶危险系数小,技术上较容易实现,因此可以快速投入到线下上进快速迭代。 未来十年可能是去智能化的十年,即所有的硬件都是智能硬件,所有车辆都是机器人,即智能化会极大的普及。 可折叠手机介绍了联想集团出的一款可折叠手机,据说是全球首款无痕的可折叠手机 Moto Razr。 从视频来看,无痕可折叠的最大秘密在于,并没有将屏幕折叠到 180 度这个死角,折叠到 180 度目前没有任何一个屏幕材料不产生折痕,这款手机通过非常精巧的设计,让 在外部折叠到 180 度时,内部屏幕仅折叠 100 度左右。 下一个十年:科技链接健康下一个十年,科技会更加关注健康领域,比如手环检测心跳是否异常,或者通过智能设备检测健康是否达标,以决定是否要去医院就诊,甚至以此决定医保的折扣率。 未来的年轻人吃什么?这个标题有点标题党嫌疑,其实说的是一个减肥棒产品,吃了可以减肥。 一个原始年轻人的食谱,碳水化合物、脂肪、蛋白质含量分别占 22%-40%、28%-58%、19%-35%,总结起来就是低糖、优质蛋白质。 而进入农耕时代,一个年轻人的食谱,脂肪、蛋白质、碳水化合物分别占 10%-20%,10%-20%,50%-70%,即碳水化合物太多,糖分过多,而摄入蛋白质的量严重不足,这带来了大量肥胖问题。 解决办法就是做一个低糖、优脂、优蛋白的产品,所以这款产品最终效果就是“无糖、易吸收的小分子蛋白、好吃”,至于好吃是怎么做到的,因为做了这两个方面的优化: 食材可见:比如大块杏仁碎、大块黄桃粒等。 口味丰富:芝士、椰子、巧克力、曲奇。 我一位朋友当场就订购了几箱,说实话还是蛮有诱惑力的,产品叫 ffit8,可以天猫自行搜索。 极客大会每个人都送了几袋,尝了一下还是蛮好吃的,有甜味,但为什么说无糖呢,查了一下原因,原来用的是低聚异麦芽糖,这种麦芽糖难以被吸收,所以也就可以认为是无糖的啦。 3 总结前十年,无论巨头还是创业公司都经历了起起伏伏,商业路上哪有一帆风顺,唯有真正为社会创造价值,为用户解决问题的企业才可能成功。 最后留下一道思考题,你对互联网上个十年有什么感悟吗? 讨论地址是:精读《极客公园 IFX - 上》 · Issue ##225 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《智能商业》","path":"/wiki/WebWeekly/商业思考/《智能商业》.html","content":"当前期刊数: 108 1. 引言智能商业 是阿里巴巴前总参谋长曾鸣于 2018-11 出版的商业图书,对最近 20 年中国商业以及互联网发展有着深刻的总结,并描述了未来智能商业的蓝图。 笔者之所以读这本书,是因为笔者所在阿里巴巴数据中台,需要更深刻的理解数据,而《智能商业》就提到了数据时代的变革,对笔者工作有所帮助。 但读完这本书后,笔者发现不同人站在不同视角会有不同的理解:如果你是一名数据行业从业者,你可以理解数据在当今行业发展中如何起到作用;如果你是企业高管,你会领悟到商业平台发展的规则;如果你是一名创业者,你能体会到点线面体的存在,找到自己的定位;如果你是一名管理者,你能领域到管理模式正在发生的变化;如果你是一名传统行业从业者,你能体会到为什么互联网会对传统行业带来这么大的冲击;如果你是一名社会评论家,你会找到衡量智能时代对人类社会带来影响的标尺,等等。商业是推动人类社会发展的源动力,甚至也是文化与战争的源头,智能商业正因为将商业讲的通透,才摆脱了普通商业书籍枯燥的理论体系,从社会实践中总结理论,最终能上升到富有哲理的思考。 智能商业一书中有许多关键词,比如 “三浪叠加” “网络协同” “数据智能” “C2B” “S2B2C” “点线面体” “创造力革命” “网红” “互联网 X” 等等,能将这些关键词串起来的,笔者认为是 “商业演化”,在近几十年范围内,商业模式存在一些不变底层逻辑(“三浪叠加” “网络协同” “数据智能”),而在大趋势下存在不断演变的商业模式(“C2B” “S2B2C” “点线面体” “创造力革命” “网红” “互联网 X”)。 读完书后会发现,这么多的关键词,最终都为了实现 “C2B” 这个商业最终演化目标,即便是远在十八世纪的工业革命,也在为 C2B 模式打下让物质资源极大丰富的生产力基础,而网络协同和数据智能,都为了让商业规模更大,精准度更强,可以个性化识别每个用户的需求。新的组织模式也是为了更高效服务用户,整合社会 “点线面体” 的生态关系最终可以形成 “C2B” 的服务网络,而网红、互联网 X 都是 C2B 转型在不同阶段、不同行业的尝试。 2. 精读智能商业全书分为六个章节,分别是 “智能商业”、“商业模式变革”、“战略变革”、“组织变革”、“案例分析”、“关于未来”。 笔者看过一些类似的书评,将书中的观点一一枚举出来,这样的解读笔者认为是难以抓到重点的。看似把重点一一提取了出来,但没有一条 “逻辑线” 将其贯穿,分散的理解任何一个知识点都不会有太大的帮助。而这条 “逻辑线” 其实就是作者的目录组织结构。 任何一本书,写作的目的是作者为了全面阐述一个观点,书中的重点都是一个个割裂的小观点,作者会通过目录方式组织一条最合理的逻辑路线,将这些重点串联起来,最终引出作者想阐述的大观点(智能商业),因此请跟着笔者从这本书的章节结构开始,有一个连贯的理解。 前言 前言笔者认为是最精彩的部分,因为提到了一个核心概念 “三浪叠加”,中国人口众多,土地广袤,互联网发展程度不均衡,因此任何互联网模式都可能存在,再加上互联网自身演化很快,当第二浪盖过第一浪时,第三浪已经悄然形成了,只从规模上可能难以分辨处于尾声的第一浪与处于巅峰的第二浪,更难分辨出还没有起色的第三浪在哪。读完这本书如果能看清楚中国商业发展的前三浪,并预测出未来三浪,目的就达到了。 智能商业 第一章的名字和书名一样,表示我们现在正处于智能商业时代。通过对中国社会的分析,解释了为什么商业时代发展的这么快,而且为什么创业方向那么多,有些行业快速崛起,有些行业快速衰退,而想要抓住未来,就要把握住互联网机遇,利用网络协同与数据智能实现智能商业,然后为什么这样的智能商业模式可以胜出。 商业模式变革 第二章讲的是商业模式由传统的 B2C 逐渐演变到 C2B,而在 C2B 演变的过程中,一种过渡阶段 S2B2C 正在快速崛起,而这些名词并非人为创造,而是商业发展自然演化而来的,能理解到 S2B2C 是通向 C2B 的自然演化路径,自然就能理解现在一些企业模式(比如网红、大搜车)等,也能自然理解 S2B2C 的不足(毕竟是过渡阶段),未来的战略方向自然就清晰了。 战略变革 前两章分别介绍了什么是智能商业,为什么要做智能商业,以及商业模式的演变,那第三章就自然要介绍企业战略变革了。第三章介绍了企业战略如何转型才能应对智能商业的节奏,比如何制定战略计划,以及通过 点-线-面-体 理解企业在市场中的定位,理解了这一点,不仅能理解各企业在市场中定位,还能理解之间相互关系,以及 点-线-面-体 的定位是可以改变的,抓住机遇的企业会逐渐向上发展,失去机遇的企业会逐渐向下退化。 理解了 点-线-面-体 的特性,可以更好的找准自己的定位,越往上资源越多,但排他性就越强,大部分时候,做一个深耕垂直行业的点,虽然同质化可能很多,但竞争不是排他性的,而且有线与面的平台支撑,特别是合理利用多个 “面” 后,可能爆发出强劲的商业价值,比如网红就是同时利用多个 “面” 的典型例子。 从战略变革这一章可以看到,这本书虽然前两章站在 BAT 高级战略的视角俯瞰商业演化,看似与普通企业,普通个人没什么关系,但读到战略变革这一章时,可以明显体会到理解 “面” 与 “体” 角度下商业思维后,可以给 “点” 与 “线” 带来巨大的战略价值。 组织变革 第四章是组织变革,因为当战略变革后,必须轮到组织变革了。工业革命带来了生产力的极大提高,那互联网则带来了创造力革命的浪潮,没有统一机器的约束,每个人都能充分发挥自己的创造力 - 前提是组织管理模式要支持。一个新的组织管理模式不是自上而下的分配任务,而是自下而上,充分发挥每个人创造力的 “赋能” 管理模式。都是互联网的管理模式是打平的,其实这是终极的理想情况,通过形成自组织协同网络,充分调动每一个人的创造力。 案例分析 读到第五章就没有多少新概念了,但第五章是真正把前四章理论映射到现实案例的实战环节,这一章我们能看懂许多企业战略背后的战略模式,都可以归纳到网络协同、数据智能的布局,商业模式都在向 C2B 转型,旧的面被新的面取代而下降为线,线抓住了机遇逐渐发展成面,多个面相互协同逐渐形成了 “体” 等多个维度的变化。 关于未来 第六章是对未来的判断,重点在互联网与传统产业如何碰撞,提出的 互联网 x 概念背后有着更深刻的含义。如果你今年听说了 “产业物联网” 这个名词,可以甄别一下相应的企业,是仅仅将互联网技术运用到了传统行业,还是将传统行业从底层的运作逻辑就互联网化了呢?互联网不仅是一种技术,更是一种思维,互联网思维可以将被传统行业束缚住的各个流程逐渐还原到最原始、高效的模样。 比如说传统工程需要提前计算销量固化产能,但加入了互联网快速反馈的网络,就可以实时调整产能,当然这需要整个生产流程的互联网化,将整个环节都做到快速反馈。 结语 - 新文明:感受未来已来 印象最深的是引用了经济学家周其仁的一句话:“文明的一次次传承和复兴,就是一步步找回对人的尊重”。害怕机器取代人类的思想还是被局限在现有的世界观、价值观之中的,将工人固定在工厂流水线,或者程序员每天写着相似的业务逻辑,本身就是一种践踏人类尊严的行为,而计算机可以逐步取代这些低创造性的工作,可以理解为抢了那些人的饭碗,但站在历史长河的角度,何不是还给人类以尊严? 智能商业首先是分析互联网巨头都至少做对了这三个方向中的两个:在线化、智能化、网络化。 在线化是指将业务都搬到互联网上,这基本是必备的一条。智能化是利用算法打造竞争优势,比如谷歌搜索算法。网络化就是形成多方共赢的协作网络,比如广告主与网站主通过谷歌搜索形成网络化协作。 简介提到的 网络协同与数据智能 就是指后两者,它们之间要形成一种反馈闭环就形成了智能商业的双螺旋: 网络协同 产生数据,通过 数据智能 进行学习,进一步优化 网络协同。 网络协同 需要建立起一张多角色之间的协同网,比如优步组织的司机与乘客的协同网。协同网络越复杂,经济效益越大、门槛越高,比如淘宝的协同网络非常复杂,体现在协同者多(买家,卖家,物流,客服,淘女郎)等等,他们之间也有相互关联,各角色对网络需求粘性强,网络的不可替代性就高。 数据智能 现在所有企业都没有充分利用数据,数据的潜在价值是无穷的,理论上可以利用数据做任何战略决策、管理决策。 而网络化与智能化叠加,会产生黑洞效应,也就是数据越多越吸附数据,网络协同越多就越容易扩张出新的协同。 作者对 互 联 网 这三个字的拆字解读也更容易让我们理解互联网的本质: 联: 联接,从 PC 互联网开始,到移动互联网,再到万物互联,联接内容越来越多。 互: 交互,从一对多的门户时代,到通过关注方式的微博时代,再到社交朋友圈时代,交互越来越简单,越来越频繁,也越来越精准。 网: 网络协同。 看了这么多概念,不知道你是否能理解智能商业的概念呢?也许每个人都有自己的体会,也许智能商业概念难以被定义,但 网络协同、数据智能 一定是核心,谁能充分利用这两股力量,将其充分发挥黑洞效应,形成一套更广泛的“互”,更多的“联”,更复杂的“网”络协同,谁就能更好利用互联网实现智能商业。 商业模式变革商业领域较为常见的模式有 B2B、B2C、C2C。 B2B 代表企业是阿里巴巴、中化网,阿里巴巴是水平 B2B,是指企业与客户之间是平行关系;而中化网属于垂直 B2B,帮助企业寻找上下游合作伙伴。 B2C 代表企业是亚马逊、天猫、京东,也就是直接把商品卖给消费者。 C2C 代表企业是易贝、淘宝,即个人用户服务与个人,淘宝主要是个人用户开网店卖给个人。 而商业模式的变革,是指这些模式最终都要演化为 C2B 模式,即个人提出需求,企业快速满足。按照笔者理解,C2B 是由客户驱动的模式,虽然只是简单的单词调整位置,但背后需要企业做巨大的转型,不仅组织结构需要调整,还需要企业具有第一章说的 “智能商业” 属性,因为只有将服务在线化,通过数据智能与网络协同,才能精准触达每一位消费者,了解每个人的需求,快速服务与消费者。 然而快速服务消费者的需求还需要背后的供应链平台支持,所以 C2B 将以客户驱动的模式一直改造到背后的供应链逻辑。 然而 C2B 模式跨度太大,最近还诞生了一种过渡模式,就是 S2B2C 的模式,S 指的是供应平台,通过对小 B 的赋能,让小 B 直接服务于 C。这种模式是看场景的,因为只有 S2B 的价值大于单纯的 B,这个模式才行得通,所以在比如汽车、医药行业,小 B 急需 S 赋的业务场景可以做起来,而在本身就有大 B 存在的行业,就算有 S 赋能,小 B 依然竞争不过大 B,就不适合 S2B2C 这种模式。 另外 S2B2C 的模式也在升级,未来的产品可能会同时透出 S 于 B 的品牌,因为只透出 B 的品牌,可能导致 S 不能很好的掌握消费者需求,只透出 S 的品牌,就变成了传统加盟模式,而加盟模式最大的问题是无法发挥每个小 B 的积极性触达客户,加盟本质上还是 B2C,比如肯德基,一个大品牌对应每个消费者,就算加盟再多店铺也不会改变这一点,但是 S2B2C 比如网红模式,淘宝平台给网红赋能,网红通过自己的品牌吸引能力圈住一批客户,带来非常高的转化率,这就结合了两者优势。 另外也提到了云集,笔者以前认为云集是一种传销模式,和微商差不多,但其实云集要做的事情就是 S2B2C,将供应链完全打通后,包括网络系统一并提供给小 B,云集的小 B 就是任何有微信的用户,用户的资源就是他的朋友(朋友圈),所以云集号称没有商品就能做卖家,因为它的 S 服务做得好,集成性高,给小 B 带来的便利性就高。但问题是 小 B 到 C 环节是云集的弱势环节,拥有朋友圈的普通人与网红有本质的区别,普通人随意转发消息也许会带来朋友的反感与屏蔽,而普通人也不能为客户带来更大的价值,反观网红,他们可以得到粉丝的认可,成为粉丝的榜样,但是你愿意认可朋友圈里随便一个人成为你的榜样吗? 第二章总的来说解读了目前出现的网红现象,以及一些做的较好的独角兽(比如土巴兔、大搜车),其实他们都属于 S2B2C 的模式,而他们最终的目的地是 C2B。 战略变革既然商业模式变革了,战略也要变革。之前也说过互联网处于三浪叠加状态,从 B2B 开始产生了很多新模式,从 C2B 到 S2B2C,比如 S2B2C 的模式也是在发展过程中逐渐发现的新模式,因此企业对战略的制定要采取一种高效反馈闭环,核心在于做战略实验。 首先确定几个未来可能的战略方向,各投入一些人力尝试,尝试一年后自然会发现正确的方向,此时再将其他方向合并到正确方向。比如 2011 年阿里巴巴独立了三个子公司 - 淘宝、天猫、一淘,是为了赌未来的局势到底是 B2C,还是 C2C,还是一个搜索引擎指向无数小 B2C。最终发现由于中国网络基础设施还不成熟,导致独立 B2C 成本太高,因此 一淘 就回到了阿里巴巴。 因此当你发现公司在同时做几个相似的业务时,先不要急着觉得公司傻,这样做是在浪费资源,但你是否能看清楚这几个业务间微妙的差别?也许你不能猜到哪一个才是未来方向(能猜到你就当 CEO 吧),但至少能理解公司这样做的战略意图,而不是做什么都是淘宝。 对于企业战略选择,作者给出的建议是 点-线-面-体。也就是企业一定要在这其中找到自己的定位。 根据笔者读后的理解,点就是各种各样服务的角色,比如卖家、模特、独立开发者都属于点。线就是连接点与面沟通桥梁,比如微商或微博大 V 都属于线,原因是他们联接了平台与点。面就是指平台,比如淘宝属于面,因为它撬动了整个行业的资源,对上面无数个点赋能,联接了无数个点,面也是竞争最激烈的一环,也就是所谓的生态竞争,如果面对点的赋能力度不够,点也许就被其他的面吸引过去了。体是最大的概念,由多个 相互协同的面 组成,比如物流平台、网购平台、支付平台这三个面之间相互协作,才能逐渐形成体。 顺带一提,体不是一开始就形成,面也不是谁设计出来的,而是先有一个简单构想,根据市场需求逐步演化过来的,比如淘宝就是由 BBS 演化过来的,那 BBS 就是淘宝的基因,因此淘宝可以协同那么多点,可以快速反馈用户需求,可以演化出支付、物流、云业务并各自独立发展成新的面。 点-线-面-体 定位越上升,拥有的资源就越多,但面对的变化挑战就越多,其中“面”的竞争最为激烈,比如传统媒体本来是面,但在门户网站出现有,就降维到了点,微博的出现又使门户网站降为成线,而微信的出现使微博降为成线。 所以看似风光的 BAT 都选择了最为艰难的 “体” 的打造,而笔者认为,到了体这个级别,将撬动巨量的社会资源,带来巨大的回报,但排他性也是最强的。一个最完整的 “体” 本质上就是一个全面的协同网络 - 国家,国家与国家之间的排斥性大家可以想象,因此留给体的位置并不多,而新体的出现必然会与旧体展开生死决战。因此如果创业,将自己定位为“点”是比较靠谱的,因为有大量的“面”资源可用,只要能找到自己的亮点,就算有竞争,也不会收到太大的影响。 组织变革战略变革后,就轮到组织变革了。组织变革的目的是最大程度激发员工的创造力,因此自上而下的结构是不适合了,需要一种新的组织形态与管理思路。 这种新的管理思路就是 “赋能” 的思路,一方面,赋能的思路可以提升员工的自主程度,充分发挥其创造力,一方面,赋能可以转变管理者的管理方式,使一个经理能管理十几、二十几个下属。互联网行业的工资都很高,尤其是顶尖人才,对于金钱的渴望已经不是找工作的最大决定因素,“成就感” “使命感” 更容易受这些顶尖人才的青睐,因此 “赋能” 的管理思路也是招募到顶尖人才的方法。 最后作者提到了 “自组织协同网”,这是一个非常超前的概念,也源于企业最大的痛点 - 如何衡量 KPI。 随着商业环境复杂性提高,几个核心指标远不能反应一个企业真实情况。有句话说,如果你只看一个指标,那最后达成的方式一定是你最不愿意看到的,比如淘宝为了冲刺销量 KPI,出了全年免网购费用的年卡,也许一天就能完成全年 KPI,但未来一年内可能会亏空整个公司老本。因此利用数据,从多个维度衡量指标是唯一的解法,换个说法,就是用复杂性对抗复杂性。 通过将公司所有业务数据化,训练出一个逐步优化的模型,是可能从所有维度逐渐趋向最真实反馈公司表现的多维度指标的,衡量员工工作绩效方式也同理。 读完这一段,笔者感受到数据最终也会被用在员工身上这句话,简单来说就是晋升答辩不用写 PPT 了,年底通过上千、上万种维度对你进行综合测评,直接出结果。现在已经能感受到公司在这个方向发力了,第一步是将所有开发过程数据化,也许离这一天已经不远。 案例分析案例分析十分精彩,由于篇幅限制,笔者就不洋洋洒洒的转述了,如果感兴趣强烈推荐读原文,笔者至少还会再读一遍。 从案例分析中,有两个核心观点笔者在此处提一下。 第一个是平台演化的自然性,作者以淘宝的发展历程作为案例,说明了淘宝并不是顶层设计的产物,而是根据市场反馈的产物,唯有如此才能在高速变化的时代搭建一个平台。 第二个是网红案例,网红不仅完成了点到线的演化,而且是综合利用了多个“面”的案例,通过综合利用社交平台(微博),电商平台(淘宝),快速反应供应链平台(由网红推动产生的新型供应链),结合这三个平台,网红这个线被赋予前所未有的能量,带来了巨大收益。 关于未来读完本书的目的,不仅是了解当下的智能商业,更是为了思考未来。 在这个大变革时代,未来战略是难以预测的,所以凭空去勾勒未来蓝图没有什么意义,我们要在通过战略实验快速试探出未来几年的方向,在第二浪即将到达巅峰时,找到第三浪并积极布局。 其实本书只能给出寻找战略方向的方法论,而不能给出具体的未来发展方向是什么,因为这套方法论本身就是通过战略实验快速寻找方向的过程,唯有投入资源去做尝试,仔细观察身边发生的变化,才能逐渐找到未来的新商业模式。未来的商业模式也是在逐步演变的,受到的影响因素太多,因此大概处于一种 “不可观测” 的状态,但至少未来十年内 C2B 的模式,笔者认为是一个固定的大方向,而传统行业与互联网结合的产业互联网也是新的发展机遇,利用互联网优化传统行业的各个环节,是一个确定的方向标。 无论未来商业怎么发展,都会为消费者带来越来越好的体验,这是一个消费为王的时代,根据消费者的需求,掀起从平台到供应链的全方位改造,目的是带来更好的消费体验。 3. 总结读完了智能商业,笔者留下一个思考题:尝试站在智能商业的角度,分析你熟悉的公司各处于什么发展阶段,走的是什么商业模式? 讨论地址是:精读《智能商业》 · Issue ##169 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《极客公园 IFX - 下》","path":"/wiki/WebWeekly/商业思考/《极客公园 IFX - 下》.html","content":"当前期刊数: 136 1 引言这次是极客大会十周年,也正好告别了 2019 年,因此主题是总结互联网前 10 年的发展,并预测下一个 10 年的变化。 这次是后半部分的大会感悟。 2 精读一头在慢赛道下奔跑的大象大象保险是一个互联网保险公司,可能在大家印象中保险公司是一个老而慢的行业,复杂的条款,繁琐的理赔流程,精心规划的商业套路等等。顺带一提,巴菲特就是利用收购的众多保险公司收集的保费进行杠杆投资,才取得了平均年化 20% 左右的神话。所以保险是一个比较难做,且在慢赛道的行业。 大象保险则利用科技的手段对保险流程进行优化,在初期尝试了几种有意思的互联网保险业务,比如上班下雨保险、堵车保险等,也取得过一些成果,现在尝试将自己平台化,将沉淀的互联网保险能力提供给其他互联网保险公司。 大象保险沉淀了一个业务中台,包括各种保险险种库、智能化投保决策、以及沉淀了大量数据,基于这个业务中台拓展出一个新品牌“象保保”,这是一个代理人数字化营销平台,集成险种库、出单管理、海报、计划书、课程、健康服务等一系列互联网保险基础能力,既服务于自己,又能服务于其他保险公司。 这种商业思路确实是很棒的,亚马逊的 AWS(亚马逊云)、FBA(第三方物流服务)、Amazon Go(即拿即走线下超市)都是前期研发投入高 + 固定成本很高的项目,这些项目诞生之初就服务于亚马逊自己这个大客户,等成熟后就拿到市场上检验,赋能其他行业。阿里内部服务成熟后上云也是一样的思维。 bilibili CEO 专访从专访中了解到,bilibili CEO 陈睿不是 b 站最早的创始人,而是后面加入的,一开始他兼职管理 b 站业务,并承诺自己公司上市后就加入,结果所在公司猎豹真的上市了,而他也正式加入 b 站,追求他的爱好。 b 站独特魅力在于 UGC 内容持续的创作,这样公司不用花功夫进行内容创作,而用户的智慧聚集起庞大的创作力量影响力又非常之大,同时用户自己创作的内容会更容易得到用户自己的认可,所以 b 站用户付费的意愿都很高。 所以陈睿一直强调的是一种社区文化生态,这种生态可以带来很强的归属感与认同感。 b 站的品类很多,按照热度排序可能是:动画、番剧、游戏、娱乐、国创、数码、科技、音乐、生活、舞蹈、放映厅、时尚等等,而 b 站的收入来源游戏业务占了一半,2019 Q3 游戏收入达 9.3 亿元,其余收入来源分别是直播和增值服务业务、广告业务、电商以及其他业务。这种收入模型对社区类创业者来说比较有借鉴意义。 如何把读书这件小事做到极致樊登读书的 CEO 樊登过来了,樊登是知识付费四天王之一,知识付费的四天王分别是:吴晓波、罗振宇、樊登和李善友。 樊登真的很有演讲功力,笔者觉得樊登是极客大会 3 天所有演讲中讲的最好的没有之一,与他同台演讲的都是各大独角兽 CEO 级别人物,也包括百度等大公司事业部总经理,但就演讲能力而言,距离樊登还是差得太远。 这次樊登演讲主题是复杂体系 vs 简单体系,总结后其实就是一句话:复杂体系是自然生长出来的,简单体系是规划出来的,现在创业环境不适合简单体系,只有复杂体系才能应对这个世界的复杂性。 其中提到一个有意思的点:所有 KPI 都是错的,因为 KPI 是预测未来的工具,所有对未来的预测都是不准确的。在 KPI 压力下人的动作会产生变形,比如只追求结果不追求过程, 最终导致饮鸩止渴,不利于长期发展。樊登解决问题的方法挺有意思的,他对线下门店指定的 KPI 长达 100 多条,非常非常细致,但想要一一检验是不可能的,每到发奖金的时候,就随机抽取三条进行检验,由于不知道最终会检验哪一条,这样门店想要拿到奖金就需要本本分分做好每一点细节,做真正产生价值的事情。 樊登读书这款 App 笔者也听了一个星期,里面讲的内容很有针对性,都是职场、心灵、生活相关的,与完善自我紧密相关,特别是一款《逆商》的解读,非常有意思,推荐大家读一读。 大组织土壤中创新如何发芽结果主讲人是阿里创新事业部总裁朱顺炎,大公司总是被诟病创新能力差,毕竟层级复杂体系庞大,看上去好像创新确实很困难。 阿里创新事业部有四个法宝: 大家没有生存压力,不需要为了短期变现而产生动作的变形。 CEO 深知创新是从小应用成长起来的,所以让创新项目从小开始独立孵化。之所以要独立孵化,也是认识到组合的产品创新能力是很脆弱的,真正成功的产品必须要独立撑起一片天。 给更有创造力的年轻人机会。 没有不变的业务,只有不变的文化,通过培养文化进行企业传承。 一起期待拥有长线计划的阿里创新事业部可以给市场带来更多有价值的产品吧! 面对不确定的未来,我们应该如何决策大众汽车中国的 CEO 介绍到,中国已经成为世界最大的汽车市场之一。 PS1:其实中国不仅正在成为汽车最大的市场,中国其实在各个维度都在成为全球最大的消费市场。 PS2:大众汽车的历史很有意思,尤其是保时捷和大众的收购大战以保时捷发起,最终却被大众反收购,这段历史非常有趣。 这次分享讨论了三个问题: 电动汽车出行肯定会实现吗? 关于这个问题,大众汽车的答案是肯定的。这句话很有意思,我记得去年参加这个大会时,许多初创新能源汽车制造公司就自己与老牌车场相比有什么优势时提到,老牌车场虽然实力雄厚,但航空母舰转身非常困难,这些大厂其实难以很快投入电动汽车的研发。从现在阶段来看,行业又发生了变化,老牌大厂纷纷加入实现了“掉头”,进入电动车行业,并且针对自动驾驶领域开始做技术合作与整合。 软件公司和汽车公司谁将引领汽车行业的未来? 大众中国 CEO 通过四个力:责任里、靠谱力、盈利力、可持续力四个方面对大众汽车进行了全面夸赞,总之想表达的观点就是,汽车公司实力雄厚,可以通过再造一个规模一万人的软件公司,对互联网造车公司进行降维打击。 出行服务会颠覆传统汽车制造商吗? 自行车厂商倒可以有这种担心,但汽车厂商不必有,因为每个人其实都梦想有一辆属于自己的车。在之前共享出行行业里也提到了,交通分为公共交通与私人交通,对于两点一线比如上班场景,就非常适合私人交通,因为大家对时间和稳定性要求非常强烈,毕竟谁都不想上班迟到。对于临时的交通需求,大家对公共交通需求更大,毕竟公共交通便捷性更强,特别是人在国外时,总不能在国外给自己也买辆车吧。 产业物联网中的机制成长从何而来G7 去年成长了 5 倍,这是一家智能物流服务公司,提供货车智能服务。 去年的极客大会有介绍过 G7,几年就不再详细介绍了。G7 之所以有这么快速的成长,一方面是自己产品做的好,另一方面可能离不开整个中国产业互联网的腾飞,由于物流行业这几年快速发展,各个物流公司都在不断融资买货车提升自己的运力,对智能货车的服务需求才会不断增加,同时中国经济也进入了互联网广泛赋能各产业的阶段,这就是去年一直提的“产业互联网”,G7 作为一个平台,横向服务中国所有物流公司,享受到了中国发展的红利,得以快速发展。 也许未来 10 年还会迎来更加巨大的产业互联网机会,那些既做软件也做硬件的公司可以迟到这波趋势的红利。互联网将成为线下产业的钢铁侠外衣,对线下产业来说,得到互联网的加持可以大大提高运作效率,对互联网来说,线下产业发展的红利将带来极高的自然增速。 VIPKID 鹏友说VIPKID 创始人米雯娟谈了 VIPKID 最近的运营情况,比较有感触的是教育这块拉新的方式,一般教育领域花费都是比较高的,而且不仅仅是钱的问题,将孩子的成长托付给任何一家机构,家长都会特别谨慎,这是人之常情,所以大部分培训班很多新客都要通过老客推荐的方式获取。 VIPKID 起步是依靠朋友圈传播,但随着项目的起量,需要通过广告方式推广,最高的推广费用达到平均获客成本 8000 元,不过现在已经回归到正常水平,大概 4000 元左右,现在有 50% 的新客是通过老客推广的,无需费用。 一加手机一加手机在国外销售非常火爆,最近一款 90 HZ 屏的产品使其又火了一把。之所以做 90 HZ 屏就是为了“更”流畅的使用体验,当被问及这么做性价比如何时,刘作虎回答的是:这就是高端品牌的极致追求,有的时候体验就提升那么一点,用户就会选择你。 一加手机做的是高端手机,操作系统主打的是简洁,不会有任何广告,盈利方式则是其较高的定价。而相比手机大厂,一加手机的突破点在于集中力量做旗舰手机,通过集中投入研发资源达到单点突破。 最近一加也在做电视了,目的是为了占领客厅市场,可能因为手机卖的比较火,资金链比较充裕所以做了更大的布局。 解题 - 社区零售新物种的进化之道每日优鲜的 CFO 王珺带来的一场分享,介绍每日优鲜是如何利用新技术实现新零售突破的。 每日优鲜业务的难度有三点: 社区零售中最难的业态:大规模分布式连锁。 社区零售中最难的品类:生鲜非标品。 社区零售的三大挑战:体验、成本、复制。 生鲜零售难度确实很大:生鲜对保存时间短,运输过程中易磨损,品质管理层次不齐,我们来看每日优鲜是如何解决这些问题的。 每日优鲜通过部署 “前置仓” 解决物流问题。几乎所有物流业务想要提效,比如推出次日达甚至当日达业务,几乎都必须用前置仓解决。每日优鲜的前置仓甚至可以实现平均送达时间 36 分钟,而且价格比线下超市便宜 10%,这是怎么做到的呢? 每日优鲜分别从租金、人工、损耗三个方案解决问题。 首先是租金,每日优鲜专门租一些高性价比的地段,租金便宜但距离配送地点也不远的地方。 其次是人工,通过智能化的调度中心,减少了店员数量,但能保持服务效率。 最后的损耗,比如仓储管理,也通过合理的计算提升货物周转率,在配送服务方面,通过聚合订单,本来一个骑手一天只能送 20 单,但每日优鲜的骑手一天可以送 70 单,这是因为平均出车一次可以覆盖 10 位客户,这都取决于平台派单算法的优化。 对于规模化扩张方式,每日优鲜也有自己的做法。1.0 信息化阶段,利用系统辅助人,达到现在的高效率。未来 2.0 是智能化时代,用系统取代人,将成本压缩到极致。 智能汽车的白银时代小鹏汽车的 CEO 何小鹏认为 2020-2025 年是电动车的白银时代,即拥有高度辅助功能(L3),2025 年之后是黄金时代,即受限场景的无人驾驶时代(准 L4)。 值得注意的是,小鹏汽车去年累计交付 1.3 万辆,虽然和传统汽车厂不在一个数量级,但其智能化数据还是比较亮眼的。 小鹏汽车明年要发行的新款有两大特色。基础能力包括:超长续航 + 超快充电 + 安全。特色能力是:主打高端的超级轿跑,配合顶级音响设备,再加上支持 L3 级别的智能化,看上去还是有一定竞争力的。 之前大众中国区 CEO 的分享也提到,传统汽车公司也开始进入电动车、自动驾驶领域了,纷纷开始组建硬件、软件子公司与团队,在软件上能否快速赶超走在前面的互联网造车公司是关键,如果传统汽车公司像华为入局手机制造业一样,以碾压性的资源投入快速实现 L4,并拉拢一批生态厂商制定标准规范,创业公司就比较难了,现在这个阶段正是互联网创业公司打时间差的最后时机。 智能新物种带来的智慧生态新体验这次分享的嘉宾是美的集团 IOT 事业部总经理余尚锋,讲了关于未来家电的畅想。简单来说,未来的家电会万物互联,手机将不再是唯一入口,任何屏幕都可以是入口,任何家电都拥有智能,都可以拥有所有计算能力。 这具有很强的启发意义,未来家庭中可能会存在一个计算中心,所有设备都只是屏幕,是这个计算中心人机交互的输出界面,正因为如此,你的手机才屏幕才可以被卫生间镜子自动替代,就连煤气灶的显示屏也可以刷微信、玩游戏。 AI 落地产业的这一年分别由三角兽、杉树科技、文远知行三家公司的创始人谈一谈 AI 落地产业,这三家公司都是做的比较好的垂直领域公司,其中三角兽做的自然语言理解技术已经广泛运用于许多 Top 互联网公司,像百度语音助手也调用了其服务;杉树科技通过深度学习、机器学习、运筹学帮助滴滴、顺丰、京东等等公司做最优的决策;文远知行是一家做 L4 自动驾驶技术的公司,今年也在北京投放了十几辆限定区域的自动驾驶载客汽车试运行。 可以发现,这些公司都掌握核心 AI 技术,并成为互联网头部大公司坚实合作伙伴,通过对某个领域的极致钻研“坐在了大公司旁边”。 水滴公司 鹏友说水滴公司的 CEO 沈鹏之前曾在美团就职,担任美团外卖全国业务负责人,可谓年少有为。在美团担任高管期间经历了许多磨练,也曾降职到地区负责人锤炼自己业务能力与管理能力,但即便如此,创立水滴公司后依然遇到许多挫折,沈鹏的感悟是,管理创业团队的难度比在公司当高管要难多了。 水滴公司的业务是帮助有困难的人,业务板块分为水滴商城与水滴互助,水滴商城提供一些高性价比的事前保障,水滴互助则是帮助遭遇重大疾病或变故的人筹集资金,通过参加水滴互助也让更多人了解到事前保证的重要性,促进了水滴商城的业务量。 3 总结互联网真真切切渗透到社会每一个角落,从纯线上到与产业结合,从提升社会效率到关注人类健康,涉及到生活的方方面面,每一位公司的 CEO 都非常聪明,让互联网技术最大程度在各自领域发挥着价值。 商业领域如果有唯一不变的真理,那就是为人类带来价值的公司才能基业长青。你还了解哪些利用互联网给人类创造价值的公司吗?欢迎留言。 讨论地址是:精读《极客公园 IFX - 下》 · Issue ##226 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《谁在世界中心》","path":"/wiki/WebWeekly/商业思考/《谁在世界中心》.html","content":"当前期刊数: 114 1. 引言谁在世界中心 是一本介绍地缘政治的书,这本书以海洋为连接世界的主要桥梁,介绍了当今全球视野下海洋争霸的政治格局。 谁征服了海洋,谁就征服了世界。陆地霸权注定无法拥有全球视野,只有海洋霸权才能征服世界,如今中国已成为海洋贸易霸主,但海洋的武力霸主仍然是美国,如果中国想成为新的全球霸主,就要突破旧的海洋霸权封锁,成为新的海洋霸主。 当然想成为海洋霸主是非常困难的,这涉及到多方政治力量的博弈,但我们可以通过《谁在世界中心》这本书了解地缘政治关系,让我们看清当下,布局未来。 《谁在世界中心》共五章,分别介绍了当下谁在主宰世界、东亚与西太平洋、东南亚与南海、南亚与印度洋、俄罗斯与北冰洋。 之所以标题都是地区与海的关系,是因为陆地与海洋的博弈就是海洋霸权的逻辑。本书需要结合地图理解,因此笔者会贴一些书中地图,围绕着地图讲解本书。 2. 精读谁在主宰世界现在 美、俄、欧、中 是这个舞台的主角,但谁也不能仅凭一个地区征服世界,因此与一些重要地区结盟,并成为地区的领导者才可能成为世界的霸主。边缘地带理论 就是指,控制了大陆板块的边缘地区,就可以对大陆进行封锁,进而控制大陆。在将眼光放到边缘地区之前,先看看现在世界舞台上的主要政治力量: 俄罗斯 - 大陆的征服者。俄罗斯一直有扩张的野心,但是在苏联解体后,值保有大部分欧亚大陆中心地带,目前已经失去领衔主演的资格。 欧盟 - 世界的发现者。作为大航海时代的开启者,欧洲史就是浓缩的世界史,并且随着疯狂的资本掠夺积累了大量原始资本。但由于英国在欧洲板块处于海洋势力,无法完全控制大陆,因此极力避免欧洲土地上出现一家独大的情况,这导致了美国的崛起。当然现在欧盟的成立也标志着欧洲进入了漫长的整合时期,德国由于其较差的地缘位置(二战后海外利益尽失),更愿意以裹挟欧盟的方式让自己成为主角。 印度 - 低纬度地区的代言人。由于低纬度炎热的气候,印度人并不热衷于国际事务,但和美国一样,印度也发展了自己的地缘优势以及人口优势,希望代表低纬度地区参与大国游戏。但是要承受另一个边缘地区国家 - 中国的压力。 中国 - 世界中心最有力的挑战者。中国拥有极大的战略纵深,集体主义文化,拥有挑战世界霸主的潜力,但在这个道路上还需解决许多问题,尤其是如何突破由美国主导的 “新世界岛俱乐部” 的封锁。 美国 - “新世界岛俱乐部” 的缔造者。北美是新世界岛的中心地区,英国和日本是新世界岛的外围地区,分别用来控制 “欧亚大陆西边缘地区(西欧)” 与 “欧亚大陆东边缘地区(中国)”。 为什么拥有海洋就拥有了世界?“海权论” 有三个主要观点: 谁掌握了世界核心的咽喉航道、运河和航线,谁就掌握了世界经济和能源运输之门。 谁掌握了世界经济和能源运输之门,谁就掌握了世界各国的经济和安全命脉。 谁掌握了世界各国的经济和安全命脉,谁就控制了全世界。 但独霸海洋非常困难,因此美国奉行的是 “边缘地带理论”。也就是通过控制欧亚大陆东西两端的边缘地带,进而控制了欧亚大陆核心地区,封锁住欧亚大陆的强国,以此保证美国世界霸主的地位。 从上图可以看出,以北美为 “新世界岛” 的中心地区,通过控制日本与英国,牵制住西欧与中国。美国实际上也做到了这一点。而随着印度的崛起,美国也找到了澳大利亚作为遏制印度的桥头堡。 那么中国怎么崛起呢?很显然,中国需要组建属于自己的 “世界岛俱乐部”,取代由美国主导的 “旧世界岛俱乐部”: 与欧亚大陆中心地带的大部分国家(主要是俄罗斯)结盟。 将 “欧亚大陆南边缘地区”(印度)拉入同盟。 寻找可能的 “世界岛外围地区”,并使之倒向同盟(日本、韩国、朝鲜等)。 但就目前状况来看,中印关系竞争与合作同时上升,俄国由于前苏联的老大地位暂时不愿意放下身段,日本更处于美国为中心的俱乐部中,因此这条路困难重重。之所以将日本拉进来,一方面是因为与印、俄结盟不足以取得与 “旧世界岛俱乐部” 竞争的优势,一方面是中日地缘距离近,且日本国民性格敬仰强大的对手,另一方面日本是美国牵制中国的力量,拉拢过来不仅可以打消美国的算盘,还能增强东亚整体实力。 第一章总览了世界地缘政治关系的全貌,并为中国崛起指出了道路。后面几章则具体介绍各个存在联动的政治板块间的具体博弈情况,做到知己知彼。 东亚与西太平洋 参与东亚与西太平洋博弈的主要国家有:中国、朝鲜、韩国、日本、俄罗斯。中国是参与板块博弈的核心,比如俄罗斯会在朝鲜半岛问题方面发表意见,但不会干涉钓鱼岛问题,而中国都参与其中。 东亚平原如此广袤,以至于东亚地区的民族都认为控制了这片核心区就控制了世界中心。但随着西方殖民者从海路上到来,中国人才明白自己并不是世界的中心,但长期 “中央之国的心态” 影响着我们每一个人。 中国农耕区域总是受到来自北方三个势力的威胁:“东北森林渔猎民族”、“蒙古高原草原游牧民族”、“青藏高原高原游牧民族”,这是由于农耕的生产方式稳定,创造的财富大,因此源源不断吸引这些外来者的入侵,有趣的是,每一次农耕区域都能同化外来的入侵者,而 “中国” 的传统观念也是同化他们的重要因素。所以到后面会讲到为何印度人进取心不如中国强,原因就在中国需要长期与北方威胁斗争,而印度不需要,印度由于地缘位置,导致不会受到太多来自边远民族的入侵,这个在分析印度时会讲到。 日本、朝鲜半岛由于地理阻隔,在东亚大陆统一时得保持独立。而朝鲜半岛与大陆相连却一直没有被征服的原因是,从地图上看,想要入侵朝鲜半岛必须沿着海岸线,但通过辽西走廊进入辽河平原时,辽河平原地理气候的不稳定性容易切断朝鲜半岛与东亚核心区脆弱的地缘联系,导致渗入半岛的人口要么退回,要么融于当地族群。 东亚面临西太平洋区域被外包包夹形成四个 “内海”,可以形容为 “第一岛链” 与 “第二岛链”,美国正是通过控制这些岛链来控制 “欧亚大陆东边缘地区” 的。 第一岛链包括:日本群岛、琉球群岛、冲绳岛、台湾岛、南至菲律宾群岛、大巽他群岛。其中日本是势力最大的岛链,在日本 “大东亚共荣圈” 计划中,极盛时期控制的范围如下图所示: 而中国想要成为世界霸主,就要构建以中国为主导核心的 “东亚核心圈 + 东盟十国”,见下图。 对日本来说,如今已没有实力做这个核心圈的老大,但最起码希望和中国共同主导,但核心圈只有一家独大才能发挥称霸世界的力量,中国与日本还有很多问题需要解决。相比欧盟,虽然也在融合(3 + 10 模式,即三个核心 - 法、德、英 + 10 个其他国家 不包含俄罗斯),但由于地缘特点不可能出现一家独大的情况。 第二岛链包括:从日本岛作为起点,南经小笠原诸国、火山列岛、马里亚纳群岛、关岛、雅浦岛、帕劳群岛,直至哈马黑拉岛等岛群。 不过第二岛链的威胁远没有第一岛链大。 从西太平洋向东看看美国。对美国来说,太平洋所有岛屿都是他进攻的跳板。如上图所示,美国通过诸多岛屿作为跳板进攻,在二战中,甚至跳过了对某些战略要地的争夺,通过前沿岛屿作为基地,直接攻击日本本岛。 东南亚与南海 东南亚区域分位:中南半岛(缅甸、越南与印度支那、泰国)、南洋群岛、文莱、巴厘岛以及东帝汶、马六甲海峡等重要区域。 首先看中南半岛: 中南半岛由 5 个国家组成,从西到东分别是:缅甸、泰国、柬埔寨、老挝、越南,其中缅、老、 越与中国接壤,除了老挝外都有足够的海岸线。这些国家大部分是殖民时代的遗产,英法分别在缅甸、越南发力,将泰国定位缓冲国。法国人曾将柬埔寨、老挝、越南合并成 “印支联邦” 与英国对抗,虽然现在又分裂成三个国家,因此却为越南埋下了大国梦。 缅甸在位置上,可以在陆地及海洋延伸中国的地缘影响力,而且也曾成为支持中国抗战的重要援助物资运输线。在缅甸东边是 “金三角地区”: 金三角地区 盛产鸦片,首先是金三角环境适合种植鸦片,其次由于所处缅甸、老挝、泰国交界处特别适合逃避法律打击。解决问题的办法就是联合执法,在 “湄公河惨案” 后,由中国主导的联合执法开发形成常态,金三角成为中国拓展自己地缘影响力的重要抓手。 中国与中南半岛虽然地缘上存在天然阻隔,但在云贵高原与克钦邦之间存在的南方丝绸之路、中印缅之间存在因抗日战争运输物资而修建了史迪威公路。这些重要的交通枢纽对维系中、缅两国的共同利益有着推动作用。 越南 一直想成为中南半岛的强国,但先后被清朝打压、后与法美中几大国相继开战,始终没有得到什么实际利益。越南狭长的地形使其一直存在南北分裂的风险。 泰国 之所以能在西方殖民者将土地瓜分完毕时仍保持独立,是凭借其高超的平衡技巧,成为了英法殖民地之间的缓冲国。 克拉地峡是继马六甲海峡后另一个有价值的航线,是否能开挖取决于各方利益平衡,尤其是这样会切断泰国南北,导致加大泰国南部的分裂倾向。 南洋群岛由 6 个国家组成,分别是:印尼、马拉西亚、菲律宾、文莱、新加坡、东帝汶。 “下南洋” 期间,在西方殖民者的推动下,大量华人下南洋开发,因此南洋群岛留着部分华夏民族血液。在新加坡,甚至因为华人占据了 75% 的人口,马来西亚为了在脱离英国殖民统治后保证马来人获得多数票,因此将新加坡排除在马来西亚联邦之外,才使得新加坡独立建国。 接着看文莱、巴厘岛和东帝汶: 文莱 在马来西亚中是个弹丸小国,但因为在西方殖民者接入之前,文莱的前身 “渤泥国” 的势力范围很大,因此在被殖民者打碎野心的情况下,文莱有着强烈独立的愿望,从争取 “保护国” 的地位到最终独立,文莱一路走来很不容易。但是文莱被 “林梦地区” 一分为二,马来西亚也不会容忍文莱有更多的领土要求,两者僵持不下。但我们相信,身处这种状况的文莱更希望获得外部力量的支持,作为与南海隔海相望的中国将会是其理想的盟友。 巴厘岛 不仅是度假胜地,在 14 世纪末至 15 世纪初,在伊斯兰教强大压力下,坚守印度教的少数马来人从爪哇岛移民至巴厘岛,因为宗教信仰的不同,这里引起恐怖分子的关注。 东帝汶 是欧洲殖民者划分殖民地的产物,南部的澳大利亚觊觎其丰富油矿资源而积极干涉东帝汶的事物。反过来想,如果中国控制了东帝汶区域,就可以对澳大利亚施加政治影响力。 相比南洋群岛,南海 离中国更近。如果要控制南海,就要分别控制位于南海五个方向的:东沙群岛、西沙群岛、黄岩岛、中沙大环礁、南沙群岛。 中国想要经略南海,首先要提升自己的综合实力。最近能够在南海问题上有所突破,本质上还是中国综合实力得到了提高。但经略南海不代表占领南海,而是要与南海周边的国家进行博弈,合纵连横。搁置争议,共同开发是最好的策略,如果中国能够掌握深海石油勘采技术,至少能在投资、技术层面让多方面获益。 从中国海上突围角度来看,有三条航线可选:南海-马六甲海峡-印度洋航线、印尼通道-印度洋航线、西太平洋-南太平洋-印度洋航线 马六甲海峡 是南海的咽喉,被新加坡控制,且战时容易被封锁。备选方案印尼通道是个不错的选择,而且相比马六甲海峡三国(新加坡、马来西亚、印度尼西亚),印尼通道 只要和印尼搞好关系即可。然而印尼也可能被日本拉拢,但由于印尼与中国没有直接利益冲突,站队日本对印尼来说得不到什么好处。 南亚与印度洋 南亚包括 7 个国家,分别是南亚次大陆的:尼泊尔、不丹、巴基斯坦、印度、孟加拉国 和印度洋上的岛国:斯里兰卡、马尔代夫。 由于 “印巴分治”,巴基斯坦于 1947 年独立,但由于东西距离太远,中间隔着印度,因此东边独立成了孟加拉国。不丹处于印度保护国状态,而斯里兰卡除了地理阻隔外,有意识的选择了不同的宗教,也是一直保持独立的重要原因。 再往南的马尔代夫给人留下的印象就是度假胜地,但这个海拔只有 1.2 米的岛国,随着气候变暖可能是最先消失的国家。 印度 之所以走上与中国不同的道路,主要因为外部压力相对较小。之前也介绍了中国长期受到北方民族的入侵,是因为中国北方有足够大的阶梯地形让北方民族适应 “低原反应”,而印度北方的山脉没有足够的缓冲区,为印度形成了天然的防护屏障。 虽然热带气候可以让文明较早发展,但没有边缘民族入侵压力,会让文明变得非常脆弱,也缺乏扩张的动力。从融合的角度来说,印度虽然也融合了其他民族,但相比 中国的 “家天下”,印度属于 “种姓” 文明框架。 “家天下” 的模式每个人都有平等的机会,而 “种姓” 制度确保了阶级固化,加上热带地区物产丰富,不至于出现被饿死的情况,因此这种制度得以稳定下来。 克什米尔 是印度河的上游,在工业化时代,掌握了上游就可以控制下游的水资源,现在印巴两国共享上印度河平原,任何一方都不会轻易放弃这块战略要地。 中国想要扩大自己在印度洋的影响力,就需要找到 缅甸、巴基斯坦、斯里兰卡、东帝汶、肯尼亚 这五个点做支持。如今中国一带一路计划,为东亚各国修筑高铁等基础设施,就是拓展中国外交空间的良好手段,加深经济的合作才有可能迎来政治合作。 俄罗斯与北冰洋俄国 虽然北临北冰洋,但是没有不冻港是无法通航的。俄国人通过不平等条约使中国东部边界从 库页岛 移到了 乌苏里江,因此俄国成为了第二个同时可以对三个洋(太平洋、大西洋、北冰洋)施加地缘影响力的国家。 不过好在中俄存在 “背靠背” 的战略伙伴关系,因此有合作的空间(俄国要应对西欧,中国要应对东南亚)。但俄罗斯的海岸线很短,这导致俄国在海权争霸的舞台只能当配角,但这个地缘结构不是一成不变的,如果全球变暖导致北冰洋融化,看到的将是另一个格局: 如果北冰洋融化后可以通航,俄罗斯将成为北冰洋地缘势力最强大的国家,其次是加拿大与阿拉斯加。如果俄国没有短视将阿拉斯加卖给美国,俄国将为成为北冰洋唯一的霸主。 3. 总结《谁在世界中心》这本书一定要看着地图读,这样会发现板块运动随机产生的变化竟然会对世界政治格局产生这么重大的影响,一个国家能否独立最重要的还是看地缘位置。 这本书更是一本中国崛起的地缘解决方案指南,其中一些解决方案在商业逻辑中可以拿来借鉴: 竞争是一个过程,唯有不断参与其中,才有可能掌握话语权,主导权,最终达到政治目的。任何领土都是通过与周边地区漫长博弈后逐渐确立下来的,想得到利益首先得参与到游戏中。 各玩家实力是动态变化的,即便是无法通航的北冰洋,都可能因为温室效应变成不冻港,因此提前看到趋势并提前准备是必须的。 想从对方获得利益,首先要了解对方想获得什么利益,自己有什么筹码,这样才容易促成合作。在寻找盟友前,先站在对方角度掂量一下自己是否合适。 不可能一家独大,想成为霸主,必须建立一个生态。以前是小弟听大哥的话,现在大哥得给小弟好处,才能得到小弟的忠诚。 已有霸主的地位不是一朝一夕就能摧毁的,就像中国想突破马六甲海峡的封锁,在不突破整体海洋封锁的前提下是不可能的,因为海洋霸权是一个全球化整体,美国封锁亚洲有完整的第一岛链、第二岛链逻辑,解决问题的视角要全面。 如今处于大变革的和平时代,国家之间看似和平,实则在进行经济扩张,大国之间要学会不撕破脸的竞争方式。而这个多方博弈的复杂性,使得阴谋几乎不可能得逞,大国的政策都是阳谋,比的是谁更能顺势而为,拉拢更多合作者。 讨论地址是:精读《谁在世界中心》 · Issue ##189 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《@umijs use-request》源码","path":"/wiki/WebWeekly/源码解读/《@umijs use-request》源码.html","content":"当前期刊数: 151 1 引言与组件生命周期绑定的 Utils 非常适合基于 React Hooks 来做,比如可以将 “发请求” 这个功能与组件生命周期绑定,实现一些便捷的功能。 这次以 @umijs/use-request 为例子,分析其功能思路与源码。 2 简介@umijs/use-request 支持以下功能: 默认自动请求:在组件初次加载时自动触发请求函数,并自动管理 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 做下面几件与组件相关的事: 存储与当前组件实例绑定的 mutable、immutable 数据。 主动触发调用组件 rerender。 访问到组件初始化、销毁时机的钩子。 上面这些功能就可以基于这些基础能力拓展了: 默认自动请求 在组件初始时机取数。由于和组件生命周期绑定,可以很方便实现各组件相互隔离的取数顺序强保证:可以利用取数闭包存储 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 中,不仅提升了代码抽象程度,也提升了可维护性。 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 零依赖实现,需要: 有缓存则不需响应,当对应缓存结束后会通知,同时也支持了请求预加载功能。 为支持并行请求,所有请求都通过 fetches 独立管理。 // 第一次默认执行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 定义出来并暴露给用户: 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 时机里判断: 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 函数中: 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 同步缓存: // cacheuseEffect(() => { if (cacheKey) { setCache(cacheKey, { fetches, newstFetchKey: newstFetchKey.current, }); }}, [cacheKey, fetches]); 在初始化 Fetch 实例时优先采用缓存: 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 即可,源码里采取全局统一监听的方式: 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 构造函数里注册: 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 数据结果即可: function mutate(data: any) { if (typeof data === "function") { this.setState({ data: data(this.state.data) || {}, }); } else { this.setState({ data, }); }} 值得注意的是,cancel、refresh、mutate 都必须在初次请求完成后才有意义,所以初次返回的函数是一个抛错: 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 函数中: 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 保护: // 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《极客公园 2019》","path":"/wiki/WebWeekly/商业思考/《极客公园 2019》.html","content":"当前期刊数: 90 1 引言上周参加的 极客公园 2019 充满了科技前沿的思考,而且给 “互联网寒冬” 带来了未来的期望中,可以看到前端将发挥越来越重要的作用。 这篇文章将以前端的视角解读这次极客公园。 本次极客公园的主题是 WHY NOT: 一些人看到世界现在的样子,会选择「就这样吧」而另一些人看到世界可能的样子,会思考「为什么不能更好一些?」 多问问 WHY NOT,少说多做,不妥协,去改变,将会帮你、创业者、甚至中国渡过这次互联网寒冬。 2 精读极客公园持续三天,每天有十几场来自互联网一线企业的嘉宾演讲。本文按照顺序介绍笔者在现场得到的感悟。 DAY1 看看世界的改变第一天围绕着 2018 年互联网与社会的改变,介绍了许多优秀的项目。通过几个公司的案例,让我们了解现在互联网的发展阶段。 最大的感触是:最近几年热炒的概念与口号,真的有不少踏踏实实的人做到了。 WHY NOT? Louis Rossetto - WIRED《连线》杂志创始人 Louis Rossetto 介绍了它创办《连线》杂志的回忆。 《连线》杂志的创办过程就是一个 WHY NOT 的过程:在最早创业时,没有场地,没有员工,也没有钱,仅凭借一腔热情从零打造了团队,并且利用热情与理想拉到了风投。 也许这个故事在当下已经不再适用,但 Louis Rossetto 的创业历程确实体现了 WHY NOT 精神,想让世界变得更好,你会感染周围的人,最后成就事业。 未来城市:数据的信仰与 AI 的社会责任 郑 宇 京东副总裁 - 京东数字科技首席数据科学家 郑宇 介绍了京东数字科技在智能城市做的努力。 京东抓住了数据、算法的机遇,将原有的京东金融、京东城市等组合成为 京东数字科技 的子品牌,这次重点介绍了京东城市操作系统。 随着云技术的普及,云计算已经成为基础设施,京东智能城市做的是基于任意云的“城市操作系统”,并可承载许多业务“软件”: 任意云服务 > 城市操作系统 > 城市服务 城市操作系统提供了一系列数据处理算法与人工智能技术,创业者或者合作企业可以利用这些技术实现城市业务,比如预测城市交通、空气指数、人流量等。人工智能技术的结合,让这些业务更为智能,不仅仅是数据检测与统计,更能预测未来变化,以及更精准的划区预测。 同时 郑宇 还对人才提出了新的期待,他们需要同时懂算法、人工智能与城市规划的人才,而现有的教育几乎不会产生这种跨学科,跨专业的人才。最大的感触就是,随着互联网科技与现实的紧密结合,对复合型人才的要求会越来越高,这对人才教育,与我们自身学习都带来了巨大挑战。 从沃尔玛看零售的创新与进化 Ben Hassing 沃尔玛中国 - 电子商务及科技高级副总裁 沃尔玛包括其子品牌 山姆会员店 都在寻求与中外企业的合作,以建立互惠互利的生态,目的就是帮助自己实现互联网转型,或者说电商转型。 基本上可以看作 一个传统行业大佬如何机智面对互联网转型挑战,但就从支付合作风波事件来看,国外巨头可能也在奉行借力打力的原则,这有点像中国巨头在印度、东南亚市场搞的那一套,扶持一个,打一群。 后流量时代:分布式 AI 与零售新增长 陈 磊 - 拼多多 CTO 核心概念是分布式云计算。现在云计算基本是集中化云计算,你的数据存在云端,但不属于你,你不仅无权查看云服务商采集了哪些数据,也无权查看他们存储了哪些数据,更无权查看他们如何将这些数据结合到算法,并怎样服务于你。 这确实戳到了当代人的一个痛点。无论你是用百度、头条、还是其他软件刷信息流的时候,如果出来一条你很感兴趣,但只想看一次的信息,你也许不敢点开,因为你猜点开后下次会给你推荐更多。这就体现了用户对于无权修改云服务商算法时的自卑。 陈磊 希望未来云计算可以是分布式的,算法开源出来,并且各大服务商允许用户自行上传自己的算法。这可以理解为用户有权自定义对作用于自己的数据的处理方式,并且根据自己的意愿随时调整算法,比如什么时候点击意图算是 “我感兴趣”,什么时候 “只看一下而已,不需要给我打标签”。 数字经济下半场,我们需要什么样的「组织」? 王慧文 - 美团联合创始人 & 高级副总裁 王慧文是个段子手,但同时思想像刀一样锐利,考虑问题像程序员一样有逻辑。 这次印象最深的是 “愚昧之巅” 与 “绝望之谷” 的概念。 人的成长可能有一段转折,也就是在达到愚昧巅峰时,需要跌入绝望之谷,才能爬向真正的智慧之巅。 但人的成长往往卡在 “愚昧之巅”,自以为达到了智慧之巅,而如果没有人推他一把,可能十几年都走不到 “绝望之谷”。 王慧文 提到了公司领导的担当之一,就是将处在愚昧之巅的下属推下去。但由于大家都不希望被泼冷水,领导可能担心伤了和气而选择不作为,长期来看对下属的成长是没有用的,但领导也没有义务推你,所以这需要有担当的领导去做。 同时在推别人时,还要当心因你处在愚昧之巅,别人处在智慧之巅,而闹出笑话来。 必然 张 鹏 极客公园 - 创始人 & 总裁 创新来自于灵感,而这个时代机会又只留给创新者,那么人为成功仅靠运气肯定是消极的。 为了让我们看到在随机的创新中,潜藏的必然,极客公园在第一天下午的论坛展示了那些 “不靠运气” 的创业者的经历,希望我们可以学到他们洞察必然的经验。 第一章:为何你觉得机会在减少,他们却抓得住时代? 刘梦媛 - 衣二三创始人 & CEO 衣二三的商业模式非常有趣,每月缴纳固定金额,就可以免费试穿任何衣服,甚至可以一天换一件。如今服装行业快消大行其道,就是因为快消收益高。她抓住机会,将快消转成慢消,提高消费者体验的同时,提升商家利润。 衣二三是会员制,对会员: 每月缴纳几百元就可以享受无数衣服的使用权,如果发现喜欢的衣服,也可以以低价折扣买回来。 每件衣服都是千元以上的高端服饰,但作为普通白领,也可以一天换一件穿。 不合适可以自然退回,生活就是试衣间。 对品牌商: 高端衣服收取不小的租金。 大概 4 个月左右衣服就可卖出。 通过试穿频率预测爆款。 总收入大幅提升。 而衣二三也可以通过自动化流水线自动清洗衣物,等于做了一个共享衣橱。 她成功的 必然 在于抓住了用户和品牌商的痛点。用户的痛点是:“穷”,但永远想低价穿高价衣服,还想天天换。品牌商的痛点是:不卖出去无法预测爆款,用户购买价格高导致顾虑心强,且难以体验真正穿在日常生活中的感受,只能降价降品做快消。 她通过将衣服的 “所有权” 转换为 “使用权” 巧妙的解决了这个问题。 黄 峥 - 拼多多创始人 & CEO 笔者不止一次在思考,拼多多到底是如何快速从青铜变成王者的,这次大会给了我一点启发。 首先黄峥的团队已经创建十年了,期间不断试水许多创业项目,拼多多只是我们看到的最后一个,所以并不能说他的成功来的突然。 其次黄峥的普通家庭背景,让他对基层人民拥有更强的同理心,他知道消费降级与消费升级并不是一个矛盾的事情,事实上这个矛盾经常同时发生在一个人身上:比如你在花几百万买个学区房的同时,在吃完火锅会选择团购省几十块钱的零头。 所以他抓住了人们在日常消费品上追求 “省” 的刚需,将团购提升到战略层次。 另外团购也不是简单的砸钱补贴,而是通过量与供应商直接谈价,将品牌商抬高的价格挤压掉。 作为一个前端,移动互联网对我来说,不过就是 PC 网页变成了移动页面,如果不看 APP,移动的 HTML5 本质上还是 PC 那一套技术。移动互联网对我来说和 PC 在技术上没有区别。 但 黄峥 看到的移动互联网则不同,他看到的是三四线城市,原本没有网线的地方,可以通过 4G 网络联网,移动互联网拉平了城市通信,电商又拉平了城市物流,这是一个新的时代,旧的事情也许可以重新做一遍。 在旧时代,品牌商与工厂建立关系,通过广告将工厂制作的产品投放给消费者。而移动互联网时代消费者已经有机会通过移动网络直接触达工厂,通过 “团购” 建立起的熟人信任链的坚固程度可能会超过对 “品牌” 的信任程度,那么就可以在部分领域将品牌商挤出市场,让消费者与工厂直连,将品牌商榨取的利润重新返还给消费者,从而让我们看到不可思议的团购价。 虽然补贴、假货可能是确实存在的情况,但 黄峥 能从移动互联网看到的这些 必然 让我非常敬佩。 第二章:当我们在谈产业互联网时,我们在谈什么? 翟学魂 - G7 创始人 & CEO G7 是货车智能兼容系统,目前它的体量可能足以整合中国的货车智能体系。 货车司机是比较危险的职业,首先货车在高速上一旦发生事故基本上都是很严重的,其次货车运途长,人难免会困或者分神,加上高速公路环境,更容易产生事故。另外高速公路上长时间开车非常单调,有些货车司机会忍不住一边看电影一边开车,这后果不用说了,但让一个人长期精神紧绷的盯着高速路也确实挺痛苦的。 G7 最新的进展,是通过在货车上安装智能硬件,解决一系列自动化问题。最重要的是通过人脸识别自动监控司机是否有危险行为,这样就可以实时监控到可能发生的异常,转而让人工监督员打电话去提醒。在最近的几年内,接入这个平台的货车没有发生一次车毁人亡的事件。 G7 抓住的 必然 就是将互联网结合到货车这个垂直的产业,不仅提升了效率,还挽救了许多货车司机的生命。 第三章:大家都在说的数据,到底有什么价值? 张 鹏 - 极客公园创始人 & 总裁 简单来说,数据就是能源,阿里也在说数据是 “新能源”。 张鹏打了一个很形象的比方。 在石油开采的初期,人类不知道如何有效利用石油,只能作为燃料销售。但现在我们的基本化工原料就是石油,石油转化为肥料,肥料产生玉米,玉米转化为我们生活中 90% 以上的糖制品等等,这种产业链将石油的价值指数放大。 数据也是一样,数据在初期就是流量,甚至可以打包出售(比如卖身份证信息等黑产)。但随着我们对数据挖掘能力的提高,是不是也可以像石油一样,将数据结合算法与 AI,转化为决策依据,转化为自动价值,转化为健康预测等等呢?数据的挖掘方式还有许多等待我们去发现。 “大数据时代” 反而是数据挖掘的初级阶段,因为我们的数据处理方式有限,就像挤海绵一样,一大块海绵只能挤出几滴水。在未来的高级数据挖掘时代,可能是 “小数据时代”,通过少量数据就能提取许多有效信息。 第四章:科技从业者们将面临怎样的空前挑战? 周 航 - 顺为资本投资合伙人 周航 基本上是站在投资者的角度看待 2019 的互联网寒冬。笔者最近也很困惑,为什么互联网会突然进入寒冬,周航 的话回答了我的困惑。 ofo 可以说是互联网寒冬的导火索。三级火箭是互联网企业利用资本运作的基本模式,小米就是很经典的例子。互联网公司通过免费产品吸引用户,这是第一级火箭,之后通过互动产品留住用户,这是第二级火箭,最后通过将用户分发到游戏、商品等内容,榨取利润。 所以之前很多互联网公司都在不计后果的烧钱,给投资人讲的就是自己的三级火箭。因为只要吸引了流量,未来就可以通过第三级火拿到回报,那么投资人投入越多,未来的收益也就越大,所以投资人会疯狂投资,公司也会疯狂融资,抢占市场,而且希望能垄断用户。 但微信的活跃用户已达到 10 亿意味着中国有能力使用互联网的人群都接入了互联网,也就意味着互联网流量红利消失了,直接导致了第三级火箭赚取的收益已经抵不上拉新流量的成本了,那这种利用资本滚雪球的商业模式也就玩不转了,因此这个商业模式就宣告破产,同时投资人手里的钱也损失了不少,创业者暂时找不到其他短期高回报的项目,两者夹击导致了互联网寒冬的到来。 知道了寒冬的原因,解决方案就不难想了。 最近比较热的产业互联网就是一条路,摒弃资本的炒作,回归到价值上,将互联网技术应用到各个垂直产业,带来实实在在的效率提升,是走出互联网寒冬的基本方法。 最后一个观点就是顺势而为。所有成功的创业公司都是在国家发展路线中踩对了点,通过观察环境,让自身跟着大趋势走,才能得到成功。这个点在后面的 小鱼在家、大疆无人机里都有提到。 DAY2 聊聊创新的本质这个时代比的是创新速度,只有快速创新才可能取得成功,那么第二天就围绕着如何创新,介绍了大量理论知识与实践经验。 创新相对论 王小川 - 搜狗 CEO 主要从 “感性” 与 “理性” 理解创新。主要讲的是,现在互联网不要过于注重理性的功能堆积,而要用感性去优化用户体验。 感性的是主观的,而理性是客观的,但人们需要的感动与创新,恰恰只有主观能做到。 因为看到所以相信,说明你是客观的人;因为相信所以看到,说明你是主观的人,主观的人更可能改变世界。 另外搜狗去年发布的 AI 合成主播是比较惊艳的,只需要录入话语,就可以自动生成主播视频,这可以进一步解放人类,让人类时间投入更有价值的创造性活动中去,这个在后面的嘉宾中也有提到。 AI 科技创新的本质是什么? 李志飞 - 出门问问创始人 & CEO 李志飞 从三个层次说明了创新与产品的关系: 产品需求 -> 创新 技术创新 -> 新产品 多产品抽象需求 -> 平台级创新 第一点是最自然的,也是中小企业最适合做的,因为业务驱动创新是最务实的做法。 第二点最难做,因为技术驱动的创新需要前期投入很多,比如最早做无人车的公司,投入了几十亿美金,走了许多弯路,最后还不一定能拿到结果,转化为商品。 第三点适合大公司,由多条业务线产品需求做整合与抽象,整理出了平台级的创新。比如上面说的 “京东城市操作系统”,就是在多条城市业务线需求上层做的抽象创新,可以赋能更多业务。 另外劝解了创业公司不要拿来主义,因为拿来主义可以低成本弯道超车,久而久之,就没有人愿意做创新的领头羊。 机器人成为人类伙伴之前的「必修课」 熊友军 - 优必选 CTO 最大感触就是说到了 人形机器人 是未来最有价值的机器人形态。 人形机器人首先对人类友好,其次可以复用现有社会为人类建造的各种设施,比如楼梯,门 等基础设施。现代社会的环境接口都是以人为交互对象设计的,所以人形机器人可以天然利用这些环境接口。 现在优必选的人形机器人已经可以画画、端茶送水了,其核心控制系统不仅要保证功能的实现,还要保证动作的 “柔韧性”,防止误伤了人类。 一个明显的突破是,当机器人手臂在做动作时,如果人的手碰上去,机器人的手会以你按压的角度进行动作倾斜。如果继续保持原有动作,可能与人的触碰产生直接碰撞,导致伤到人,但优必选的柔韧性设计让机器人运动路径考虑到了外界触碰,并作出反馈,这个在我看来是很大的进步。 如何让无人驾驶变成「老司机」? 王京傲 百度执行总监 - Apollo 平台研发总经理 百度的 Apollo 已经踏踏实实做了两年,从最初我们的怀疑,到现在稳定版本迭代,量产,百度如果继续保持这个节奏,确实可能在无人驾驶领域合作生态中独树一帜。 Apollo 1.0 实现封闭场地循迹自动驾驶,这个版本比较 low,一是封闭场地,一是根据路线来跑。 Apollo 1.5 安装了雷达,可以自动躲避障碍物。 Apollo 2.0 可以在简单路况下自动驾驶,可以识别信号灯。 Apollo 2.5 实现限定区域高速自动驾驶。 Apollo 3.0 主要是量产了,以班车作为业务场景去突破,班车是很好的固定路线试验田。 Apollo 3.5 支持城市路况自动驾驶,支持了复杂路况,而且是拥有量产能力的。 可以看到,百度的无人车确实在摸着石头过河,一步一个脚印,从跑 Demo 到灰度,再批量发布。相信未来 Apollo 还会发布 4.0 5.0 等重量级版本,百度无人车开源是一个杀手锏,只要功能做的好,帮助到未来智能造车的中小企业,将是一个巨大的市场。 我们平时都聚焦在大车厂的智能车计划,但就像阿里巴巴的理念,帮助中小企业一样,中小企业才是市场的中坚力量,未来无人驾驶行业一定会涌入大量中小企业玩家,谁服务好他们,谁就是下一个平台。 AutoML:让机器学习可以为人人所用 卢一峰 - Google 资深工程师 AutoML 可以自动完成 AI 算法和模型训练。 AutoML 分为算法机器人与执行机器人,算法机器人负责写出算法,然后交给执行机器人执行,执行结果反馈到算法机器人那用来改进算法,由此完成一个训练闭环,通过不断训练,得到一个相对较好的算法。 卢一峰 提到的关键点是,未来数据不会缺,算力不会算,缺的是算法专家,所以现在尝试通过 AutoML 解决算法专家的瓶颈,并且获得了比人类编写的算法更高效的算法。 未来让每个人都理解算法原理是不可能的,至少几十年内不太可能,但十几年内,算法就可能成为整个社会的基础设施,其实我们只要学会利用算法解决问题就行了。 AutoML 已经帮助各个行业自动识别图像、文字和意图,做到了将 AI 赋能给普通大众,降低了 AI 的使用门槛。 另外也引发了我的思考,为什么门槛最高的算法专家是第一个被证明可以取代的呢?或者说顶尖算法专家不会被取代,但至少入门或中级的算法工程师将极有可能不再需要。 也许是因为深度学习比较模式化,或者说过于理性化,不需要感性的人或者业务参与,这样就导致了无论算法还是训练都可以被完整抽象出来。而普通的技术工种其实是在和业务,在和人打交道,人是最大的变量,能被完全抽象的领域其实很少。 中国式经济魔方中潜藏的创新机会 汪 华 创新工场 - 联合创始人 & 管理合伙人 中国经济之所以比喻为魔方,是为了说明中国市场有多个维度,中国是多元经济,有个多个层次的机会。 这个话题非常大,更详细内容推荐查看 文字记录。 主要分为四个维度说,分别是 人口地域、前端后端、发展阶段、行业分化,这四个维度在中国是不均匀的。 在西方国家,发展进程是线性的,比如从个人纺织发展到品牌经济,再发展到去品牌化。而中国等发展中国家由于领土过大,且受到外来经济、文化影响,各个层次发展都不均匀,这也带来了中国式的潜力,比如为什么有了淘宝和京东,还可以创造出 “拼多多”。 人口地域的差距:核心互联网网民、小城青年、小城主流这三种人分布在一线到四五线的城市中,大家对消费的认知处在不同层次。 前端后端的差距:移动互联网是中国互联网的前端,移动支付普及率中国已经远超其他发达国家,但在物流、自动化的后端领域,中国还是远远落后于发达国家。所以上面说的 G7 等产业互联网就有机会加入改造中国的大后端。 行业分化的差距:交通、教育、文化娱乐、医疗这些行业在加速发展,而食品,服装等行业整体来看处于下降阶段,因此如果你进入了一个上升的行业,将有更广阔的发展空间。 发展阶段的差距:国内外、发展中国家和发达国家的发展阶段差距很大,同为发展中国家的中国、东南亚也有很大区别,所以将眼光投入海外市场也是新的机会。 所以整体看来下,中国可能是目前地球上最有创新、创业机会的国家,我们都是幸运的。 人类量化自我后,可穿戴的下一步在哪里? 黄 汪 华米科技 - 创始人、董事长 & CEO 华米是一家硬件制造公司,给众多智能硬件制造企业做设备,其中小米品牌生产线的小米手环出货总量达到 5000 万台。 但这家公司没有止步于此,他看到了智能硬件收集数据背后的巨大值,通过数据采集整理出了 《运动白皮书》、《睡眠白皮书》等大数据报告,得出的数据可以用于医疗健康等有价值的领域。 一个核心观点是:从数据量化世界,但量化自我。华米等企业都逐渐将数据使用的重点,从城市数字化转化到我们 “人” 的身上,无论是现在取得的各种数据分析报告,还是未来的潜力都很巨大,果然 “人” 才是最重要的服务对象。 后面讲到的 Magic Leap 公司所做的事情,也同样体现了将科技力量运用于人的例子。 传统企业在消亡,传统行业在崛起 徐 琨 - Testin 云测总裁 Testin 是一家云测试公司,拥有很多机房和几乎所有移动设备机型,通过自动跑任务的方式完成测试,有许多政府企业客户。 不过徐琨分享的主题,则与他公司天然线上线下结合的属性有关。 他提出的重要观点是:互联网+ 几乎等于烧钱,现在已经不适用了,而真正有机会的是传统企业通过 传统行业 x 互联网 取得更大的价值。 互联网企业在资本与流量红利的推动下快速发展,但现在已到了尾声,传统企业的路还要重新走一遍,比如经验、管理理念。但在互联网企业走进线下时,我们发现传统企业走进互联网的速度更快。 他举了一个 传统行业 x 互联网 的例子:现在各电商巨头都在布局新零售,在线下开店,似乎规模很大。但其实传统线下零售巨头也在更快速的接入互联网,现在一个简单的线下超市基本已经用上和新零售体验店一样的技术,更不要说上文提到的沃尔玛等巨头,他们都在积极与互联网公司合作,快速实现自我转型。 作为一个最大电商公司的员工,我有感受到来自传统企业快速转型带来的压力。传统企业并不是双手举过头顶,缴械投降地等待接受互联网公司的改造,而是已经从内部驱动开始互联网化,这就形成了 线下 -> 线上 vs 线上 -> 线下 的两股强大力量,现在正处在转型过渡阶段,偶尔有摩擦,但合作与相互赋能是主旋律,但当转型进入尾声,传统企业是否愿意与互联网公司一起瓜分线下市场的蛋糕?除非这种合作带来了共赢,否则如果是一个零和博弈,最后一定会打起来。 不过笔者还是相信,线上线下整合后,可以进一步促进消费,扩大市场,产生的额外利润应该足以稳固传统企业与互联网企业的合作。 Keep Evolving 王 宁 Keep - 创始人 & CEO Keep 的创始人王宁口才非常好,现在 Keep 已经从我脑海中一个健身视频公司,变身为一家推动全新生活方式的富有活力的公司。 Keep 应该是从做健身视频开始的,健身视频包含了一些互动特性,提高了很多人健身频率,但 Keep 远不止于此。 王宁 一直在强调健身数据、社交互动带来的改变。大家通过健身的方式可以相互认识,相互督促,相互 PK,而 Keep 也在致力让其 App 走出手机,收集用户更多的数据,因此推出了三个生活场景: 面向家庭的 Keepkit,面向城市的 Keepland,面向生活的 Keepup。 面向家庭的 Keepkit:Keep 终于制造了诸如跑步机、手环、体脂智能称秤等硬件设备,拓展业务边界的同时,带来了更好健身体验,也利于收集更多用户数据。 面向城市的 Keepland:有点像公共 KTV 空间之类的理念,通过包下一大块布置了大量 Keepkit 设备的场地,用户就像去健身房一样按时计费,而不需要买下设备或寻找空间,同时这种线下多人强互动的场景让 Keep 走出了 App,走向了生活。 面向生活的 Keepup:没有详细展开,大致是一种科技运动设备。 就这么自然的,Keep 与智能硬件结合了起来,也完成了与线下的打通,这是 Keep 最正确的发展路线。 白手起家创业指南:忘掉大趋势,沉迷小创造 猫 助 多抓鱼 - 创始人 多抓鱼是一个微信起家的二手书交易工具。和拼多多一样,猫助 抓住了现在 4G 与物流 基础设施的能力,把以前做不了的事情重新做了一遍,并取得了成功。 很神奇的是,多抓鱼二手书是全上门收取的,而且卖书的人不需要付快递费,毕竟书本身就不贵。但让我吃惊的是,现在上门收书的成本竟然只有 2 块多。 十年前的许多不靠谱想法,现在是可以重新审视一遍了,同时未来 5G 时代的来临也必将带来新的机会。 工具的价值演进 张海龙 CODING - 创始人 & CEO Coding 最早的印象是做代码托管服务的,由此产生了一些周边的尝试,比如项目买卖平台等。但今年可以看到,Coding 已经有了自己的核心价值定位:云开发。 从项目管理、持续集成、测试管理、部署管理都全部在云端,Coding 还提供了云代码编辑器,可以直接在云环境下写代码,共享云端的环境,从一定程度上是提高了开发效率。 其中触动比较大的一点是:有些大公司的产品经理还在用 Excel 管理项目计划,这一点还是蛮戳中痛点的。开发的环节很多,从需求到项目管理,再到研发,每一步的自动化程度都完全不同,有的团队也许在用最先进的协同编辑与云构建,但 PM 还在用电子表格缓慢的统计项目进展。 将项目生命周期整体来看,自动化每个环节,并且搬到云上,是未来一个大趋势。 顺带一提,运维工程师在很多大型公司已经高度自动化了,部署流程正在下沉到开发工程师人群。 洞察:产业深处需要什么样的计算机视觉? 柯 严 扩博智能 - CTO 扩博智能在机器视觉领域有所建树,利用这些技术解决新零售行业与风电行业的问题。 主要说到利用无人机 + 视觉识别,完成风机叶片的自动巡检,提高了大约 20 倍的巡检效率。 可以看到,机器学习、智能硬件、图形处理这几个随机组合,可以造就许多创业机会。现在流行说产业互联网,互联网技术为产业赋能,通过智能硬件 + 图形处理的 扩博智能 就是一个典型例子。 新造车到底有没有在认真造车? 戴 雷 拜腾 - 联合创始人 & 总裁 智能造车也是这几年很热的话题,也许在未来 5 ~ 10 年,智能造车可以有突破性进展。 智能造车的最大局限,在于生产流水线的改进速度远低于软件的改进速度,也许 5 年内都难以修改造车流程的某个磨具,所以智能造车是一个需要时间的行业,也是一个传统工程与互联网软件结合与碰撞的行业。 戴雷 将智能造车分为三大流派:互联网造车派,传统造车转型派,传统造车“叛逃”派。他就是一个从德国造车巨头企业出来的创业者,因为传统车厂体系太庞大,想要转型非常困难。所以他选择了到中国创业,同时拥有传统车厂的造车经验与互联网团队的他,在 19 年将会造出一些可以投放到市场的智能汽车。 现在到了互联网与传统行业深度融合的时代,可喜的是,看到了双方都在积极的拥抱对方,从整体上看,线上线下结合的速度正在越来越快。 聊聊 XR 的新世界 John Gaeta Magic Leap - 创意策略 SVP 这又是一个烧脑的话题。Magic Leap 是一家做增强现实的前沿科技公司,之前网上热传的一个虚拟现实技术 - 一个篮球场的鲸鱼 动画,就出自这家公司。 Magic Leap 公司技术很前沿,所以说起来有一种很魔幻的感觉。这次演讲的主题 XR 就表示了,这家公司会利用 VR、AR、MR、CR 等技术(篇幅限制,不介绍这些概念,此处可以自行查阅资料),将数字与现实更好的结合,并服务于个人。 这场主要有四个重要概念:空间计算、感知场、生活流、个人 AI。 空间计算指的是下一代计算机计算对象是空间,也就是为我们人类感知的空间做计算。比如你戴上了一个可穿戴设备,那计算机算法就会对针对你在这个空间中的方位,你的目光,你的动作,与周围进行的交互进行计算,利用 MR 技术增强显示世界的显示内容,辅助你更便捷的生活在现实世界。 感知场指我们解读现实世界的能力,通过计算机可以增强虚拟与现实的互动,比如你通过 MR 眼镜在桌子上放了一个球,当你用手把它弹开时,球会飞走,而你的手也有触感。 生活流指的是你生活产生的全部信息,就像流计算一样实时上传与计算,最后更好的服务于你。 个人 AI 便是字面意思,为个人服务的 AI,或者说仅为你服务的 AI。这个 AI 将会像机器猫一样全方位照顾你,帮助你更好的生活。但这方面还在探索中,所能想想到的一切未来机器助力人类的场景都包含在 个人 AI 含义中。 最直观的震撼是,现在 Magic Leap 的 MR 眼镜,已经可以比较真实的模拟 “篮球场的鲸鱼” 画面了,而几年前的宣传视频还是后期合成的。他们很早就想象到了未来,并以后期处理的效果展示出来。现在,他们完成了部分承诺,我们可以用 MR 眼镜看电影,而电影的主人公与场景会直接出现在你的客厅或卧室,看起来几乎没有违和感。 至少在看电影场景下,就非常令人激动。从 2D 电子版上看到的电影就足以令人激动了,现在我们可以身处电影的环境中,而且改造的场景就在你的客厅! DAY3 谈谈人和企业持续成长的方法论互联网企业已经发展到一个瓶颈,ofo 事件后,大家都知道烧钱没有用了,因为流量红利消失后,流量成本已经超过收益,同时互联网企业与传统企业的摩擦加剧,资本和风口难以再使互联网企业披荆斩棘。 想要继续增长,可能视角要回到人与管理上面。 打造机器人时代的 OS 傅 盛 - 猎豹移动董事长 & CEO 傅盛 的核心观点是,利用 AI 帮助更多人脱离生产力工作,转向创造性工作。 猎豹做了 AI 主播,提高了主播服务效率,但可能却替代许多主播的职业,因此他才会谈到这个观点。这个观点笔者也在《刷新》一书中看到类似的描述。 每次工业革命,或者机器人革命,都有大量人类工作岗位被替代,但放在长期来看,最终其实会导致人类岗位的增加。因为机器肯定都在解放重复性的岗位,或者聪明一点的机器人也是从比较没有创造性的岗位开始替代人类,随着生产力的提高,人们拥有更多的时间做更有意义的事情,就会自然催生难度更高的岗位,需要的人才也会更多。 比如在农业时代,人们需要大量劳作才能吃饱,那人才只要满足农田这个市场即可。但工业革命后,农业不需要那么多人了,人类才有机会创造出计算机市场,把人才投向计算机市场。而计算机市场的工作难读大于农业市场,所以需要更多的人才,更高的要求,最终创造的就业比农业时代多得多。 印度市场的成长观察 许达来 - 顺为资本创始合伙人 & CEO 印度内部出于相对割裂状态,有 20 多种语言,这是它与中国最大的区别。因此印度的本土化很重要,同一个区域可能就有数个讲着不同语言的印度人,他们彼此之间可能还无法交流。 不同的语言也导致了不同的文化差异,所以去印度创办企业,必须找印度本地人合伙,才有可能作出符合印度文化的产品。而去印度投资,也最好投资本土企业,因为印度的环境复杂,本土企业成功的概率相对较大。 比较有感触的是,提到了最近两年印度的飞速发展,印度从网线安装率很低的时代,一下跨越到移动互联网 4G 时代,开车的司机都可以看在车上看视频了。这说明相对落后的国家与地区,已经实现跨越式发展,可能直接跳过 PC 时代直接进入移动互联网时代。 如果对印度市场布局,一定要意识到印度是个割裂的市场,与本地企业合作,同时做好拥抱变化的准备,印度的发展肯定比十年前的中国快。 科技 × 创意 新娱乐时代的成长法则 刘文峰 爱奇艺 - CTO 爱奇艺运用 AI 的方式非常有趣,在人工智能领域,他们主打两个战略:zoomAI 与 homeAI。 zoomAI 主要是利用机器学习进行画质修复,将比较老的 480p 电影转成 720p,画质上得到了大幅提升(让我想到了 魔兽争霸 3 重制版,现在如果是视频领域,为了高清分辨率已经不需要重新开发了)。 homeAI 核心是读懂视频。它可以读懂视频中的人物、场景、情节,并结合语音交互,快速跳转到某个情节,或者查找演员信息,或只看某个人,这个确实大幅提升了看电视剧的体验。 就在几年前,视频技术的核心还在前端的视频解码与后端的负载均衡,如今已经将战场蔓延到 画质修复与读懂情节,视频领域的门槛实现了跨越式提高,我希望这些 AI 技术可以开放出来,赋能每一家视频提供商,因为这些新技术背后的研发成本太过巨大,以后若成为每一家视频网站公司的功能标配,则这项技术必须实现平台化赋能,或者服务化。 Think 的长期主义 赵 泓 ThinkPad - 联想集团副总裁,中国区中小企业事业部总经理 核心话题就是 “以不变应万变”,主要在说 ThinkPad 系列在不断变化的市场中,一直坚持以自己的节奏打磨产品,最后用户很买单。 值得提炼的是,ThinkPad 根据用户需求去做产品,根据不同的用户场景,制造了不同系列的电脑,比如适合商务旅行的 X 系列,或者工程师专用的 T 系列。其中提到了为什么不把 ThinkPad 边框做小,原因是要考虑防摔。 其实可以看出来,我们每个人都要具有接受两种相反价值观的能力。像 ThinkPad 推崇的长期主义,我们可以看到好的地方,因为这个给 ThinkPad 带来了 26 年不衰的竞争力。但同时也要知道企业的 S 型生命成长曲线,许多公司没有跨国这个曲线就彻底没落了。也许在未来人机交互迁移到 MR 时,坚守智能电脑的坚持就要被打破,但如果长期来看你的赛道是安全的,那就坚持下去。 激进还是保守?看透创业的「快与慢」 方三文 雪球 - 创始人 & 董事长 雪球是一个投资交流社区。因为这个节目是座谈,所以聊的内容比较琐碎。 一个有意思的点是,方三文提到了雪球社区会经常冒出一些出自 “不知名” 用户的专业评论文章,进而提到了一个概念:社区资源重组。也就是在大家能平等交流的互联网环境下,非头部流量因为有发声的机会,因此会获得自己的机会。 在时代切换中,重新理解技术的力量 沈向洋 微软 - 全球执行副总裁 沈向洋作为微软全球执行副总裁,是非常重量级嘉宾,他讲到了微软的转型,收购 Github,以及微软的文化,以及几年前对人工智能的准确预测,内容非常有价值。 结合他推荐的《刷新》一书,我得以更好得理解他所说的微软。 微软是一家老牌巨头,几乎在九十年代的互联网企业中,微软是活到最后的。由于没有赶上移动互联网浪潮,中间一度掉队,但现在又迎头赶上了,这中间做了不少努力。 微软以前是一个领地意识很强的公司,产权的官司没有少打,但在更换新的 CEO 后,为了弥补错过的移动互联网带来的损失,微软变得更加开放了。 微软通过与竞争伙伴建立长期合作关系,在赋能生产效率领域又重新回到了巅峰。收购领英有助于微软开拓职场关系的边疆,这与服务开发者是密不可分的,同时微软也在想办法提高对女性雇员的平等待遇,领英的数据也有助于这项分析。收购 Github 就更体现了微软赋能开发者的意图,虽然网上有许多逃离 Github 的负面言论,但实际上在微软收购 Github 后,Github 用户增加了 800 万,这比过去 6 年的总和还要多。 现在微软期待的未来蓝图是,让世界变成计算机,让计算无处不在。其实这些与其他科技巨头的愿景差不多,最打动我的是微软关注的人文情怀。 微软现在确实越来越关注科技造福人类的方向,不仅是帮助普通人提高办公效率,还要帮助患有先天疾病,或残障人士无障碍的使用技术。微软最近技术公平性,平等为人类赋能的领域做了很多,这可能与微软 CEO 萨提亚的出身有关,他知道自己是赶上了美国对印度人才敞开大门的黄金时期才获得了就业机会,它对家乡,对世界都拥有平等获取知识与成就的同理心,大公司的 CEO 都拥有这种担当。 技术型公司的成长启示录 高欣欣 将门 - 创始合伙人 & CEO赵 勇 格灵深瞳 - 创始人 & CEO宋晨枫 小鱼在家 - 创始人 & CEO 高欣欣 作为主持人采访了 赵勇 与 宋晨枫。 格灵深瞳是一家技术驱动的公司,拥有一批机器学习的专家,但在创业初期并没有找好业务方向,以至于后来团队重组,重新聚焦到摄像头与人的识别、数据分析上,才渐渐实现了盈利。 从格灵深瞳身上吸取的教训是,在创业初期,得到融资后容易迷失方向,业务遍地开花,但最后难以商业落地。专注做一件事是关键词。 小鱼在家与百度合作的小度在家发展的很好,宋晨枫 讲到创业公司寻找方向阶段,与成熟后,与大公司的竞合关系。 创业公司初期其实是在下赌注,如果你赌的风口对了,就能顺利进入下个阶段 - 大佬的台桌。上了大佬的台桌,你会看到三座大山,以及脱颖而出的竞争对手,你要选择与谁合作,与谁竞争。听下来这个问题是没有标准答案的,不同公司有不同的选择,而 小鱼在家 选择了与百度合作。 后面的访谈提到了团队管理经验,基本上是找到底层操作系统(学习能力、素质)与业务能力与当前阶段所匹配的人。同时也再次强调了创业团队要招比自己更优秀的人,这与 BAT 的招人标准不谋而合。 如何用 30 年的时间实现一个最初的想法? 葛 珂 金山办公 - CEO 核心词是 时间的沉淀。 办公软件领域需要耐得住寂寞,而且非常需要技术驱动,金山办公的 WPS 系列从支持中文,到现在通过模版满足用户需求以打通市场,一共走了 30 年。 这个例子与 ThinkPad 那场分享比较像,虽然我很尊敬微软,但办公软件方面,中国必须有自己的核心技术,否则在国家安全方面是得不到保障的。 创新的偶然与必然 谢阗地 大疆创新 - 品牌负责人 大疆无人机已经是智能硬件的代表了,现在最新一代的大疆无人机 2.0 搭载了强大的人工智能系统,甚至可以识别不同的植物喷洒不同的农药。 大疆的分享有亮点启发: 第一是智能硬件创业市场非常广阔,因为之前 扩博智能 分享的无人机案例其实与大疆无人机使用的技术很想,只是服务的业务场景不同。同样的底层技术运用到不同的行业,可以成就不同的伟大公司。硬件领域相对来说寡头比较少,小玩家都比较有机会占领属于自己的细分领域市场。 第二是关于大疆为什么会成功,这个成功很偶然,来源于大疆团队早期对无人机技术的研究,等无人机应用市场成熟了,就自然而然的推进了市场。正因为有前几年的技术沉淀,所以大疆无人机技术上领先竞争对手好几年。 这个第二点和 小鱼在家 的 “创业公司在赌未来方向” 挺像,小鱼在家 与 大疆都在早期赌对了方向,所以在市场成熟起来后可以快速实现规模化。 这个顺势而为的理念与前面的 顺为资本 谈到的类似,国家和时代需要什么样的技术,做这个技术的人就能取得成功。 50 后 VS 90 后:创业改变了我们什么? 曾德钧 猫王收音机 - 创始人齐俊元 Teambition - 创始人 & CEO 猫王收音机是一款音响产品,在智能音响时代,幸好没有参与到其中,恰恰坚守住了自己的特色,反而对古典美的追求成为了稀缺的东西。我在想,智能音响在互联网大佬眼里其实都是入口,大家都在补贴,砸开用户家中智能硬件的切入点,如果猫王收音机也去竞争,这将是两个维度的碰撞,拿你的核心与别人的诱饵碰,一定会失败的。 猫王收音机表达的也是长期主义,和 Thinkpad 演讲的很像,其精髓是,在这个新事物快速取代旧事物的时代,我们还可以发现一些可以被留下来的东西。 Teambition 是提高团队工作效率的工具,比如任务管理、协同等功能,和 Coding 的云开发平台类似,不过这个更注重于点子的记录与管理,项目进展管理。 比较有感触的点是:创始人对团队产品决策时要拿捏好力度,这对大公司的领导层同样适用。管理层要参与到产品设计中,产品才会更有活力,员工对产品的重视程度会更高,但管理层如果急于证明自己的正确性,往往会扼杀其他人的思考,所以一名睿智的管理者既要参与到产品设计中,又要客观评价事情,最大程度激发每一个员工的创造力。 一个 30 多年始终保持创造力的组织,经历了什么? Ed Catmull 皮克斯动画 - 联合创始人 & 总裁, 迪士尼动画工作室 - 总裁 迪士尼动画的创意给我们的印象深刻,这次迪士尼的总裁 Ed Catmull 带给我们最有启发的一点,就是迪士尼的创新来自于快速试错。 迪士尼很多创意在初期都是非常糟糕的,但敢于承认自己会犯错,且积极改正,造就了迪士尼的成功。 笔者想到一个不太恰当的比方,就好比写前端页面样式时,完美的动画都是一步步试出来的。一个好的动画,都是通过最原始,最简单的代码一步步尝试和改进,每一个时间参数都要微调,最后用户看到的只是经过无数次调试后的效果,当然会惊讶为什么我们能做的这么棒,其实创造的过程需要尝试。 3 总结微软的转型、投资人的建议、产业互联网,都需要 WHY NOT 的精神。 我们需要冷静下来,理解为什么中国有拼多多式的机会,为什么互联网会进入寒冬,新的时代为什么由数据驱动,互联网为什么要与产业结合。以上的解读可以回答这些问题,我们每个互联网从业者都需要认真思考世界正在发生变化的原因。 讨论地址是:精读《极客公园 2019》 · Issue ##126 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《Epitath 源码 - renderProps 新用法》","path":"/wiki/WebWeekly/源码解读/《Epitath 源码 - renderProps 新用法》.html","content":"当前期刊数: 75 1 引言很高兴这一期的话题是由 epitath 的作者 grsabreu 提供的。 前端发展了 20 多年,随着发展中国家越来越多的互联网从业者涌入,现在前端知识玲琅满足,概念、库也越来越多。虽然内容越来越多,但作为个体的你的时间并没有增多,如何持续学习新知识,学什么将会是个大问题。 前端精读通过吸引优质的用户,提供最前沿的话题或者设计理念,虽然每周一篇文章不足以概括这一周的所有焦点,但可以保证你阅读的这十几分钟没有在浪费时间,每一篇精读都是经过精心筛选的,我们既讨论大家关注的焦点,也能找到仓库角落被遗忘的珍珠。 2 概述在介绍 Epitath 之前,先介绍一下 renderProps。 renderProps 是 jsx 的一种实践方式,renderProps 组件并不渲染 dom,但提供了持久化数据与回调函数帮助减少对当前组件 state 的依赖。 RenderProps 的概念react-powerplug 就是一个 renderProps 工具库,我们看看可以做些什么: <Toggle initial={true}> {({ on, toggle }) => <Checkbox checked={on} onChange={toggle} />}</Toggle> Toggle 就是一个 renderProps 组件,它可以帮助控制受控组件。比如仅仅利用 Toggle,我们可以大大简化 Modal 组件的使用方式: class App extends React.Component { state = { visible: false }; showModal = () => { this.setState({ visible: true }); }; handleOk = e => { this.setState({ visible: false }); }; handleCancel = e => { this.setState({ visible: false }); }; render() { return ( <div> <Button type="primary" onClick={this.showModal}> Open Modal </Button> <Modal title="Basic Modal" visible={this.state.visible} onOk={this.handleOk} onCancel={this.handleCancel} > <p>Some contents...</p> <p>Some contents...</p> <p>Some contents...</p> </Modal> </div> ); }}ReactDOM.render(<App />, mountNode); 这是 Modal 标准代码,我们可以使用 Toggle 简化为: class App extends React.Component { render() { return ( <Toggle initial={false}> {({ on, toggle }) => ( <Button type="primary" onClick={toggle}> Open Modal </Button> <Modal title="Basic Modal" visible={on} onOk={toggle} onCancel={toggle} > <p>Some contents...</p> <p>Some contents...</p> <p>Some contents...</p> </Modal> )} </Toggle> ); }}ReactDOM.render(<App />, mountNode); 省掉了 state、一堆回调函数,而且代码更简洁,更语义化。 renderProps 内部管理的状态不方便从外部获取,因此只适合保存业务无关的数据,比如 Modal 显隐。 RenderProps 嵌套问题的解法renderProps 虽然好用,但当我们想组合使用时,可能会遇到层层嵌套的问题: <Counter initial={5}> {counter => { <Toggle initial={false}> {toggle => { <MyComponent counter={counter.count} toggle={toggle.on} />; }} </Toggle>; }}</Counter> 因此 react-powerplugin 提供了 compose 函数,帮助聚合 renderProps 组件: import { compose } from 'react-powerplug'const ToggleCounter = compose( <Counter initial={5} />, <Toggle initial={false} />)<ToggleCounter> {(toggle, counter) => ( <ProductCard {...} /> )}</ToggleCounter> 使用 Epitath 解决嵌套问题Epitath 提供了一种新方式解决这个嵌套的问题: const App = epitath(function*() { const { count } = yield <Counter /> const { on } = yield <Toggle /> return ( <MyComponent counter={count} toggle={on} /> )})<App /> renderProps 方案与 Epitath 方案,可以类比为 回调 方案与 async/await 方案。Epitath 和 compose 都解决了 renderProps 可能带来的嵌套问题,而 compose 是通过将多个 renderProps merge 为一个,而 Epitath 的方案更接近 async/await 的思路,利用 generator 实现了伪同步代码。 3 精读Epitath 源码一共 40 行,我们分析一下其精妙的方式。 下面是 Epitath 完整的源码: import React from "react";import immutagen from "immutagen";const compose = ({ next, value }) => next ? React.cloneElement(value, null, values => compose(next(values))) : value;export default Component => { const original = Component.prototype.render; const displayName = `EpitathContainer(${Component.displayName || "anonymous"})`; if (!original) { const generator = immutagen(Component); return Object.assign( function Epitath(props) { return compose(generator(props)); }, { displayName } ); } Component.prototype.render = function render() { // Since we are calling a new function to be called from here instead of // from a component class, we need to ensure that the render method is // invoked against `this`. We only need to do this binding and creation of // this function once, so we cache it by adding it as a property to this // new render method which avoids keeping the generator outside of this // method's scope. if (!render.generator) { render.generator = immutagen(original.bind(this)); } return compose(render.generator(this.props)); }; return class EpitathContainer extends React.Component { static displayName = displayName; render() { return <Component {...this.props} />; } };}; immutagenimmutagen 是一个 immutable generator 辅助库,每次调用 .next 都会生成一个新的引用,而不是自己发生 mutable 改变: import immutagen from "immutagen";const gen = immutagen(function*() { yield 1; yield 2; return 3;})(); // { value: 1, next: [function] }gen.next(); // { value: 2, next: [function] }gen.next(); // { value: 2, next: [function] }gen.next().next(); // { value: 3, next: undefined } compose看到 compose 函数就基本明白其实现思路了: const compose = ({ next, value }) => next ? React.cloneElement(value, null, values => compose(next(values))) : value; const App = epitath(function*() { const { count } = yield <Counter />; const { on } = yield <Toggle />;}); 通过 immutagen,依次调用 next,生成新组件,且下一个组件是上一个组件的子组件,因此会产生下面的效果: yield <A>yield <B>yield <C>// 等价于<A> <B> <C /> </B></A> 到此其源码精髓已经解析完了。 存在的问题crimx 在讨论中提到,Epitath 方案存在的最大问题是,每次 render 都会生成全新的组件,这对内存是一种挑战。 稍微解释一下,无论是通过 原生的 renderProps 还是 compose,同一个组件实例只生成一次,React 内部会持久化这些组件实例。而 immutagen 在运行时每次执行渲染,都会生成不可变数据,也就是全新的引用,这会导致废弃的引用存在大量 GC 压力,同时 React 每次拿到的组件都是全新的,虽然功能相同。 4 总结epitath 巧妙的利用了 immutagen 的不可变 generator 的特性来生成组件,并且在递归 .next 时,将顺序代码解析为嵌套代码,有效解决了 renderProps 嵌套问题。 喜欢 epitath 的同学赶快入手吧!同时我们也看到 generator 手动的步骤控制带来的威力,这是 async/await 完全无法做到的。 是否可以利用 immutagen 解决 React Context 与组件相互嵌套问题呢?还有哪些其他前端功能可以利用 immutagen 简化的呢?欢迎加入讨论。 5 更多讨论 讨论地址是:精读《Epitath - renderProps 新用法》 · Issue ##106 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《Htm - Hyperscript 源码》","path":"/wiki/WebWeekly/源码解读/《Htm - Hyperscript 源码》.html","content":"当前期刊数: 82 1 引言htm 是 preact 作者的新尝试,利用原生 HTML 规范支持了类 JSX 的写法。 2 概要htm 没有特别的文档,假如你用过 JSX,那只需要记住下面三个不同点: className -> class。 标签引号可选(回归 html 规范):<div class=foo>。 支持 HTML 模式的注释:<div><!-- don't delete this! --></div>。 另外支持了可选结束标签、快捷组件 End 标签,不过这些自己发明的语法不建议记忆。 用法也没什么特别的地方,你可以利用 HTML 原生规范,用直觉去写 JSX: html` <div class="app"> <${Header} name="ToDo's (${page})" /> <ul> ${todos.map( todo => html` <li>${todo}</li> ` )} </ul> <button onClick=${() => this.addTodo()}>Add Todo</button> <${Footer}>footer content here<//> </div>`; 很显然,由于跳过了 JSX 编译,换成了原生的 Template Strings ,所以所有组件、属性部分都需要改成 ${} 语法,比如: <${Header}> 这种写法略显别扭,但整体上还是蛮直观的。 你不一定非要用在项目环境中,但当你看到这种语法时,内心一定情不自禁的 WoW,竟然还有这种写法! 下面将带你一起分析 htm 的源码,看看作者是如何做到的。 3 精读你可以先自己尝试阅读,源码加上注释一共 90 行:源码。 好了,欢迎继续阅读。 首先你要认识到, htm + vhtml 才等于你上面看到的 DEMO。 HtmHtm 是一个 dom template 解析器,它可以将任何 dom template 解析成一颗语法树,而这个语法树的结构是: interface VDom { tag: string; props: { [attrKey: string]: string; }; children: VDom[];} 我们看一个 demo: function h(tag, props, ...children) { return { tag, props, children };}const html = htm.bind(h);html` <div>123</div>`; // { tag: "div", props: {}, children: ["123"] } 那具体是怎么做语法解析的呢? 其实实现方式有点像脑经急转弯,毕竟解析 dom template 是浏览器引擎做的事,规范也早已定了下来,有了规范和实现,当然没必要重复造轮子,办法就是利用 HTML 的 AST 生成我们需要的 AST。 首先创建一个 template 元素: const TEMPLATE = document.createElement("template"); 再装输入的 dom template 字符串塞入(作者通过正则,机智的将自己支持的额外语法先转化为标准语法,再交给 HTML 引擎): TEMPLATE.innerHTML = str; 最后我们会发现进入了 walk 函数,通过 localName 拿到标签名;attributes 拿到属性值,通过 firstChild 与 nextSibling 遍历子元素继续走 walk,最后 tag props children 三剑客就生成了。 可能你还没看完,就已经结束了。笔者分析这个库,除了告诉你作者的机智思路,还想告诉你的是,站在巨人的肩膀造轮子,真的事半功倍。 VDomVDom 是个抽象概念,它负责将实体语法树解析为 DOM。这个工具可以是 preact、vhtml,或者由你自己来实现。 当然,你也可以利用这个 AST 生成 JSON,比如: import htm from "htm";import jsxobj from "jsxobj";const html = htm.bind(jsxobj);console.log(html` <webpack watch mode=production> <entry path="src/index.js" /> </webpack>`);// {// watch: true,// mode: 'production',// entry: {// path: 'src/index.js'// }// } 读到这,你觉得还有哪些 “VDom” 可以写呢?其实任何可以根据 tag props children 推导出的结构都可以写成解析插件。 4 总结htm 是一个教科书般借力造论子案例: 利用 innerHTML 会自动生成的标准 AST,解析出符合自己规范的 AST,这其实是进一步抽象 AST。 利用原有库进行 DOM 解析,比如 preact 或 vhtml。 基于第二点,所以可以生成任何目标代码,比如 json,pdf,excel 等等。 不过这也带来了一个问题:依赖原生 DOM API 会导致无法运行在 NodeJS 环境。 想一想你现在开发的工具库,有没有可以借力的地方呢?有哪些点可以通过借力做得更好从而实现双赢呢?欢迎留下你的思考。 讨论地址是:精读《Htm - Hyperscript 源码》 · Issue ##114 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《Inject Instance 源码》","path":"/wiki/WebWeekly/源码解读/《Inject Instance 源码》.html","content":"当前期刊数: 110 1. 引言本周精读的源码是 inject-instance 这个库。 这个库的目的是为了实现 Class 的依赖注入。 比如我们通过 inject 描述一个成员变量,那么在运行时,这个成员变量的值就会被替换成对应 Class 的实例。这等于让 Class 具备了申明依赖注入的能力: import {inject} from 'inject-instance'import B from './B'class A { @inject('B') private b: B public name = 'aaa' say() { console.log('A inject B instance', this.b.name) }} 试想一下,如果成员函数 b 是通过 New 出来的: class A { private b = new B() say() { console.log('A inject B instance', this.b.name) }} 这个 b 就不具备依赖注入的特点,因为被注入的 b 是外部已经初始化好的,而不是实例化 A 时动态生成的。 需要依赖注入的一般都是框架级代码,比如定义数据流,存在三个 Store 类,他们之间需要相互调用对方实例: class A { @inject('B') private b: B}class B { @inject('C') private c: C}class C { @inject('A') private a: A} 那么对于引用了数据流 A、B、C 的三个组件,要保证它们访问到的是同一组实例 A B C 该怎么办呢? 这时候我们需要通过 injectInstance 函数统一实例化这些类,保证拿到的实例中,成员变量都是属于同一份实例: import injectInstance from 'inject-instance'const instances = injectInstance(A, B, C)instances.get('A')instances.get('B')instances.get('C') 那么框架底层可以通过调用 injectInstance 方式初始化一组 “正确注入依赖关系的实例”,拿 React 举例,这个动作可以发生在自定义数据流的 Provider 函数里: <Provider stores={{ A, B, C }}> <Root /></Provider> 那么在 Provider 函数内部通过 injectInstance 实例化的数据流,可以保证 A B C 操作的注入实例都是当前 Provider 实例中的那一份。 2. 精读那么开始源码的解析,首先是整体思路的分析。 我们需要准备两个 API: inject 与 injectInstance。 inject 用来描述要注入的类名,值是与 Class 名相同的字符串,injectInstance 是生成一系列实例的入口函数,需要生成最终生效的实例,并放在一个 Map 中。 injectinject 是个装饰器,它的目的有两个: 修改 Class 基类信息,使其实例化的实例能拿到对应字段注入的 Class 名称。 增加一个字段描述注入了那些 Key。 const inject = (injectName: string): any => (target: any, propertyKey: string, descriptor: PropertyDescriptor): any => { target[propertyKey] = injectName // 加入一个标注变量 if (!target['_injectDecorator__injectVariables']) { target['_injectDecorator__injectVariables'] = [propertyKey] } else { target['_injectDecorator__injectVariables'].push(propertyKey) } return descriptor} target[propertyKey] = injectName 这行代码中,propertyKey 是申明了注入的成员变量名称,比如 Class A 中,propertyKey 等于 b,而 injectName 表示这个值需要的对应实例的 Class 名,比如 Class A 中,injectName 等于 B。 而 _injectDecorator__injectVariables 是个数组,为 Class 描述了这个类参与注入的 key 共有哪些,这样可以在后面 injectInstance 函数中拿到并依次赋值。 injectInstance这个函数有两个目的: 生成对应的实例。 将实例中注入部分的成员变量替换成对应实例。 代码不长,直接贴出来: const injectInstance = (...classes: Array<any>) => { const classMap = new Map<string, any>() const instanceMap = new Map<string, any>() classes.forEach(eachClass => { if (classMap.has(eachClass.name)) { throw `duplicate className: ${eachClass.name}` } classMap.set(eachClass.name, eachClass) }) // 遍历所有用到的类 classMap.forEach((eachClass: any) => { // 实例化 instanceMap.set(eachClass.name, new eachClass()) }) // 遍历所有实例 instanceMap.forEach((eachInstance: any, key: string) => { // 遍历这个类的注入实例类名 if (eachInstance['_injectDecorator__injectVariables']) { eachInstance['_injectDecorator__injectVariables'].forEach((injectVariableKey: string) => { const className = eachInstance.__proto__[injectVariableKey]; if (!instanceMap.get(className)) { throw Error(`injectName: ${className} not found!`); } // 把注入名改成实际注入对象 eachInstance[injectVariableKey] = instanceMap.get(className); }); } // 删除这个临时变量 delete eachInstance['_injectDecorator__injectVariables']; }); return instanceMap} 可以看到,首先我们将传入的 Class 依次初始化: // 遍历所有用到的类classMap.forEach((eachClass: any) => { // 实例化 instanceMap.set(eachClass.name, new eachClass())}) 这是必须提前完成的,因为注入可能存在循环依赖,我们必须在解析注入之前就生成 Class 实例,此时需要注入的字段都是 undefined。 第二步就是将这些注入字段的 undefined 替换为刚才实例化 Map instanceMap 中对应的实例了。 我们通过 __proto__ 拿到 Class 基类在 inject 函数中埋下的 injectName,配合 _injectDecorator__injectVariables 拿到 key 后,直接遍历所有要替换的 key, 通过类名从 instanceMap 中提取即可。 __proto__ 仅限框架代码中使用,业务代码不要这么用,造成额外理解成本。 所以总结一下,就是提前实例化 + 根据 inject 埋好的信息依次替换注入的成员变量为刚才实例化好的实例。 3. 总结希望读完这篇文章,你能理解依赖注入的使用场景,使用方式,以及一种实现思路。 框架实现依赖注入都是提前收集所有类,统一初始化,通过注入函数打标后全局替换,这是一种思维套路。 如果有其他更有意思的依赖注入实现方案,欢迎讨论。 讨论地址是:精读《Inject Instance 源码》 · Issue ##176 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《react-easy-state 源码》","path":"/wiki/WebWeekly/源码解读/《react-easy-state 源码》.html","content":"当前期刊数: 98 1. 引言react-easy-state 是个比较有趣的库,利用 Proxy 创建了一个非常易用的全局数据流管理方式。 import React from "react";import { store, view } from "react-easy-state";const counter = store({ num: 0 });const increment = () => counter.num++;export default view(() => <button onClick={increment}>{counter.num}</button>); 上手非常轻松,通过 store 创建一个数据对象,这个对象被任何 React 组件使用时,都会自动建立双向绑定,任何对这个对象的修改,都会让使用了这个对象的组件重渲染。 当然,为了实现这一点,需要对所有组件包裹一层 view。 2. 精读这个库利用了 nx-js/observer-util 做 Reaction 基础 API,其他核心功能分别是 store view batch,所以我们就从这四个点进行解读。 Reaction这个单词名叫 “反应”,是实现双向绑定库的最基本功能单元。 拥有最基本的两个单词和一个概念:observable observe 与自动触发执行的特性。 import { observable, observe } from "@nx-js/observer-util";const counter = observable({ num: 0 });const countLogger = observe(() => console.log(counter.num));// 会自动触发 countLogger 函数内回调函数的执行。counter.num++; 在第 35 期精读 精读《dob - 框架实现》 “抽丝剥茧,实现依赖追踪” 一节中有详细介绍实现原理,这里就不赘述了。 有了一个具有反应特性的函数,与一个可以 “触发反应” 的对象,那么实现双向绑定更新 View 就不远了。 storereact-easy-state 的 store 就是 observable(obj) 包装一下,唯一不同是,由于支持本地数据: import React from 'react'import { view, store } from 'react-easy-state'export default view(() => { const counter = store({ num: 0 }) const increment = () => counter.num++ return <button={increment}>{counter.num}</div>}) 所以当监测到在 React 组件内部创建 store 且是 Hooks 环境时,会返回: return useMemo(() => observable(obj), []); 这是因为 React Hooks 场景下的 Function Component 每次渲染都会重新创建 Store,会导致死循环。因此利用 useMemo 并将依赖置为 [] 使代码在所有渲染周期内,只在初始化执行一次。 更多 Hooks 深入解读,可以阅读 精读《useEffect 完全指南》。 view根据 Function Component 与 Class Component 的不同,分别进行两种处理,本文主要介绍对 Function Component 的处理方式,因为笔者推荐使用 Function Component 风格。 首先最外层会套上 memo,这类似 PureComponent 的效果: return memo(/**/); 然后构造一个 forceUpdate 用来强制渲染组件: const [, forceUpdate] = useState(); 之后,只要利用 observe 包裹组件即可,需要注意两点: 使用刚才创建的 forceUpdate 在 store 修改时调用。 observe 初始化不要执行,因为初始化组件自己会渲染一次,再渲染一次就会造成浪费。 所以作者通过 scheduler lazy 两个参数完成了这两件事: const render = useMemo( () => observe(Comp, { scheduler: () => setState({}), lazy: true }), []);return render; 最后别忘了在组件销毁时取消监听: useEffect(() => { return () => unobserve(render);}, []); batch这也是双向绑定数据流必须解决的经典问题,批量更新合并。 由于修改对象就触发渲染,这个过程太自动化了,以至于我们都没有机会告诉工具,连续的几次修改能否合并起来只触发一次渲染。 尤其是 For 循环修改变量时,如果不能合并更新,在某些场景下代码几乎是不可用的。 所以 batch 就是为解决这个问题诞生的,让我们有机会控制合并更新的时机: import React from "react";import { view, store, batch } from "react-easy-state";const user = store({ name: "Bob", age: 30 });function mutateUser() { // this makes sure the state changes will cause maximum one re-render, // no matter where this function is getting invoked from batch(() => { user.name = "Ann"; user.age = 32; });}export default view(() => ( <div> name: {user.name}, age: {user.age} </div>)); react-easy-state 通过 scheduler 模块完成 batch 功能,核心代码只有五行: export function batch(fn, ctx, args) { let result; unstable_batchedUpdates(() => (result = fn.apply(ctx, args))); return result;} 利用 unstable_batchedUpdates,可以保证在其内执行的函数都不会触发更新,也就是之前创建的 forceUpdate 虽然被调用,但是失效了,等回调执行完毕时再一起批量更新。 同时代码里还对 setTimeout setInterval addEventListener WebSocket 等公共方法进行了 batch 包装,让这些回调函数中自带 batch 效果。 4. 总结好了,react-easy-state 神奇的效果解释完了,希望大家在使用第三方库的时候都能理解背后的原理。 PS:最后,笔者目前不推荐在 Function Component 模式下使用任何三方数据流库,因为官方功能已经足够好用了! 讨论地址是:精读《react-easy-state》 · Issue ##144 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《react-intersection-observer 源码》","path":"/wiki/WebWeekly/源码解读/《react-intersection-observer 源码》.html","content":"当前期刊数: 156 1 引言IntersectionObserver 可以轻松判断元素是否可见,在之前的 精读《用 React 做按需渲染》 中介绍了原生 API 的方法,这次刚好看到其 React 封装版本 react-intersection-observer,让我们看一看 React 封装思路。 2 简介react-intersection-observer 提供了 Hook useInView 判断元素是否在可视区域内,API 如下: 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 值: 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 更新旧节点: // 中间部分 1const 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 才合理: // 中间部分 2useEffect(() => { 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 文件中,这个文件有三个核心函数: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 函数使用: 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. Make sure the `ref` is only used by a single <Observer /> instance. %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 的第二个参数回调: 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 存储。 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,如果出现传递断裂,就会判定当前组件不在视图内,比如: const Component = () => { const [ref, inView] = useInView(); return <Child ref={ref} />;};const Child = ({ loading, ref }) => { if (loading) { // 这一步会判定为 inView:false return <Spin />; } return <div ref={ref}>Child</div>;}; 如果你的代码基于 inView 做了阻止渲染的判定,那么这个组件进入 loading 后就无法改变状态了。为了避免这种情况,要么不要让 ref 的传递断掉,要么当没有拿到 ref 对象时判定 inView 为 true。 4 总结分析了这么多 React- 类的库,其核心思想有两个: 将原生 API 转换为框架特有 API,比如 React 系列的 Hooks 与 ref。 处理生命周期导致的边界情况,比如 dom 被更新时先 unobserve 再重新 observe。 看过 react-intersection-observer 的源码后,你觉得还有可优化的地方吗?欢迎讨论。 讨论地址是:react-intersection-observer 源码》· Issue ##257 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React PowerPlug 源码》","path":"/wiki/WebWeekly/源码解读/《React PowerPlug 源码》.html","content":"当前期刊数: 92 1. 引言React PowerPlug 是利用 render props 进行更好状态管理的工具库。 React 项目中,一般一个文件就是一个类,状态最细粒度就是文件的粒度。然而文件粒度并非状态管理最合适的粒度,所以有了 Redux 之类的全局状态库。 同样,文件粒度也并非状态管理的最细粒度,更细的粒度或许更合适,因此有了 React PowerPlug。 比如你会在项目中看到这种眼花缭乱的 state: class App extends React.PureComponent { state = { name: 1, isLoading: false, isFetchUser: false, data: {}, disableInput: false, validate: false, monacoInputValue: "", value: "" }; render() { /**/ }} 其实真正 App 级别的状态并没有那么多,很多 诸如受控组件 onChange 临时保存的无意义 Value 找不到合适的地方存储。 这时候可以用 Value 管理局部状态: <Value initial="React"> {({ value, set, reset }) => ( <> <Select label="Choose one" options={["React", "Preact", "Vue"]} value={value} onChange={set} /> <Button onClick={reset}>Reset to initial</Button> </> )}</Value> 可以看到,这个问题本质上应该拆成新的 React 类解决,但这也许会导致项目结构更混乱,因此 RenderProps 还是必不可少的。 今天我们就来解读一下 React PowerPlug 的源码。 2. 精读2.1. Value这是一个值操作的工具,功能与 Hooks 中 useState 类似,不过多了一个 reset 功能(Hooks 其实也未尝不能有,但 Hooks 确实没有 Reset)。 用法<Value initial="React"> {({ value, set, reset }) => ( <> <Select label="Choose one" options={["React", "Preact", "Vue"]} value={value} onChange={set} /> <Button onClick={reset}>Reset to initial</Button> </> )}</Value> 源码 源码地址 原料:无 State 只存储一个属性 value,并赋初始值为 initial: export default { state = { value: this.props.initial };} 方法有 set reset。 set 回调函数触发后调用 setState 更新 value。 reset 就是调用 set 并传入 this.props.initial 即可。 2.2. ToggleToggle 是最直接利用 Value 即可实现的功能,因此放在 Value 之后说。Toggle 值是 boolean 类型,特别适合配合 Switch 等组件。 既然 Toggle 功能弱于 Value,为什么不用 Value 替代 Toggle 呢?这是个好问题,如果你不担心自己代码可读性的话,的确可以永远不用 Toggle。 用法<Toggle initial={false}> {({ on, toggle }) => <Checkbox onClick={toggle} checked={on} />}</Toggle> 源码 源码地址 原料:Value 核心就是利用 Value 组件,value 重命名为 on,增加了 toggle 方法,继承 set reset 方法: export default { toggle: () => set(on => !on);} 理所因当,将 value 值限定在 boolean 范围内。 2.3. Counter与 Toggle 类似,这也是继承了 Value 就可以实现的功能,计数器。 用法<Counter initial={0}> {({ count, inc, dec }) => ( <CartItem productName="Lorem ipsum" unitPrice={19.9} count={count} onAdd={inc} onRemove={dec} /> )}</Counter> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 count,增加了 inc dec incBy decBy 方法,继承 set reset 方法。 与 Toggle 类似,Counter 将 value 限定在了数字,那么比如 inc 就会这么实现: export default { inc: () => set(value => value + 1);} 这里用到了 Value 组件 set 函数的多态用法。一般 set 的参数是一个值,但也可以是一个函数,回调是当前的值,这里返回一个 +1 的新值。 2.4. List操作数组。 用法<List initial={['##react', '##babel']}> {({ list, pull, push }) => ( <div> <FormInput onSubmit={push} /> {list.map({ tag }) => ( <Tag onRemove={() => pull(value => value === tag)}> {tag} </Tag> )} </div> )}</List> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 list,增加了 first last push pull sort 方法,继承 set reset 方法。 export default { list: value, first: () => value[0], last: () => value[Math.max(0, value.length - 1)], set: list => set(list), push: (...values) => set(list => [...list, ...values]), pull: predicate => set(list => list.filter(complement(predicate))), sort: compareFn => set(list => [...list].sort(compareFn)), reset}; 为了利用 React Immutable 更新的特性,因此将 sort 函数由 Mutable 修正为 Immutable,push pull 同理。 2.5. Set存储数组对象,可以添加和删除元素。类似 ES6 Set。和 List 相比少了许多功能函数,因此只承担添加、删除元素的简单功能。 用法需要注意的是,initial 是数组,而不是 Set 对象。 <Set initial={["react", "babel"]}> {({ values, remove, add }) => ( <TagManager> <FormInput onSubmit={add} /> {values.map(tag => ( <Tag onRemove={() => remove(tag)}>{tag}</Tag> ))} </TagManager> )}</Set> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 values 且初始值为 [],增加了 add remove clear has 方法,保留 reset 方法。 实现依然很简单,add remove clear 都利用 Value 提供的 set 进行赋值,只要实现几个操作数组方法即可: const unique = arr => arr.filter((d, i) => arr.indexOf(d) === i);const hasItem = (arr, item) => arr.indexOf(item) !== -1;const removeItem = (arr, item) => hasItem(arr, item) ? arr.filter(d => d !== item) : arr;const addUnique = (arr, item) => (hasItem(arr, item) ? arr : [...arr, item]); has 方法则直接复用 hasItem。核心还是利用 Value 的 set 函数一招通吃,将操作目标锁定为数组类型罢了。 2.6. mapMap 的实现与 Set 很像,类似 ES6 的 Map。 用法与 Set 不同,Map 允许设置 Key 名。需要注意的是,initial 是对象,而不是 Map 对象。 <Map initial={{ sounds: true, music: true, graphics: "medium" }}> {({ set, get }) => ( <Tings> <ToggleCheck checked={get("sounds")} onChange={c => set("sounds", c)}> Game Sounds </ToggleCheck> <ToggleCheck checked={get("music")} onChange={c => set("music", c)}> Bg Music </ToggleCheck> <Select label="Graphics" options={["low", "medium", "high"]} selected={get("graphics")} onSelect={value => set("graphics", value)} /> </Tings> )}</Map> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 values 且初始值为 {},增加了 set get clear has delete 方法,保留 reset 方法。 由于使用对象存储数据结构,操作起来比数组方便太多,已经不需要再解释了。 值得吐槽的是,作者使用了 != 判断 has: export default { has: key => values[key] != null;} 这种代码并不值得提倡,首先是不应该使用二元运算符,其次比较推荐写成 values[key] !== undefined,毕竟 set('null', null) 也应该算有值。 2.7. stateState 纯粹为了替代 React setState 概念,其本质就是换了名字的 Value 组件。 用法值得注意的是,setState 支持函数和值作为参数,是 Value 组件本身支持的,State 组件额外适配了 setState 的另一个特性:合并对象。 <State initial={{ loading: false, data: null }}> {({ state, setState }) => { const onStart = data => setState({ loading: true }); const onFinish = data => setState({ data, loading: false }); return ( <DataReceiver data={state.data} onStart={onStart} onFinish={onFinish} /> ); }}</State> 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 state 且初始值为 {},增加了 setState 方法,保留 reset 方法。 setState 实现了合并对象的功能,也就是传入一个对象,并不会覆盖原始值,而是与原始值做 Merge: export default { setState: (updater, cb) => set( prev => ({ ...prev, ...(typeof updater === "function" ? updater(prev) : updater) }), cb );} 2.8. Active这是一个内置鼠标交互监听的容器,监听了 onMouseUp 与 onMouseDown,并依此判断 active 状态。 用法<Active> {({ active, bind }) => ( <div {...bind}> You are {active ? "clicking" : "not clicking"} this div. </div> )}</Active> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 active 且初始值为 false,增加了 bind 方法。 bind 方法也巧妙利用了 Value 提供的 set 更新状态: export default { bind: { onMouseDown: () => set(true), onMouseUp: () => set(false) }}; 2.9. Focus与 Active 类似,Focus 是当 focus 时才触发状态变化。 用法<Focus> {({ focused, bind }) => ( <div> <input {...bind} placeholder="Focus me" /> <div>You are {focused ? "focusing" : "not focusing"} the input.</div> </div> )}</Focus> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 focused 且初始值为 false,增加了 bind 方法。 bind 方法与 Active 如出一辙,仅是监听时机变成了 onFocus 和 onBlur。 2.10. FocusManager不知道出于什么考虑,FocusManager 的官方文档是空的,而且 Help wanted。。 正如名字描述的,这是一个 Focus 控制器,你可以直接调用 blur 来取消焦点。 用法笔者给了一个例子,在 5 秒后自动失去焦点: <FocusFocusManager> {({ focused, blur, bind }) => ( <div> <input {...bind} placeholder="Focus me" onClick={() => { setTimeout(() => { blur(); }, 5000); }} /> <div>You are {focused ? "focusing" : "not focusing"} the input.</div> </div> )}</FocusFocusManager> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 focused 且初始值为 false,增加了 bind blur 方法。 blur 方法直接调用 document.activeElement.blur() 来触发其 bind 监听的 onBlur 达到更新状态的效果。 By the way, 还监听了 onMouseDown 与 onMouseUp: export default { bind: { tabIndex: -1, onBlur: () => { if (canBlur) { set(false); } }, onFocus: () => set(true), onMouseDown: () => (canBlur = false), onMouseUp: () => (canBlur = true) }}; 可能意图是防止在 mouseDown 时触发 blur,因为 focus 的时机一般是 mouseDown。 2.11. Hover与 Focus 类似,只是触发时机为 Hover。 用法<Hover> {({ hovered, bind }) => ( <div {...bind}> You are {hovered ? "hovering" : "not hovering"} this div. </div> )}</Hover> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 hovered 且初始值为 false,增加了 bind 方法。 bind 方法与 Active、Focus 如出一辙,仅是监听时机变成了 onMouseEnter 和 onMouseLeave。 2.12. Touch与 Hover 类似,只是触发时机为 Hover。 用法<Touch> {({ touched, bind }) => ( <div {...bind}> You are {touched ? "touching" : "not touching"} this div. </div> )}</Touch> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 touched 且初始值为 false,增加了 bind 方法。 bind 方法与 Active、Focus、Hover 如出一辙,仅是监听时机变成了 onTouchStart 和 onTouchEnd。 2.13. Field与 Value 组件唯一的区别,就是支持了 bind。 用法这个用法和 Value 没区别: <Field> {({ value, set }) => ( <ControlledField value={value} onChange={e => set(e.target.value)} /> )}</Field> 但是用 bind 更简单: <Field initial="hello world"> {({ bind }) => <ControlledField {...bind} />}</Field> 源码 源码地址 原料:Value 依然利用 Value 组件,value 保留不变,初始值为 '',增加了 bind 方法,保留 set reset 方法。 与 Value 的唯一区别是,支持了 bind 并封装 onChange 监听,与赋值受控属性 value。 export default { bind: { value, onChange: event => { if (isObject(event) && isObject(event.target)) { set(event.target.value); } else { set(event); } } }}; 2.14. Form这是一个表单工具,有点类似 Antd 的 Form 组件。 用法<Form initial={{ firstName: "", lastName: "" }}> {({ field, values }) => ( <form onSubmit={e => { e.preventDefault(); console.log("Form Submission Data:", values); }} > <input type="text" placeholder="Your First Name" {...field("firstName").bind} /> <input type="text" placeholder="Your Last Name" {...field("lastName").bind} /> <input type="submit" value="All Done!" /> </form> )}</Form> 源码 源码地址 原料:Value 依然利用 Value 组件,value 重命名为 values 且初始值为 {},增加了 setValues field 方法,保留 reset 方法。 表单最重要的就是 field 函数,为表单的每一个控件做绑定,同时设置一个表单唯一 key: export default { field: id => { const value = values[id]; const setValue = updater => typeof updater === "function" ? set(prev => ({ ...prev, [id]: updater(prev[id]) })) : set({ ...values, [id]: updater }); return { value, set: setValue, bind: { value, onChange: event => { if (isObject(event) && isObject(event.target)) { setValue(event.target.value); } else { setValue(event); } } } }; }}; 可以看到,为表单的每一项绑定的内容与 Field 组件一样,只是 Form 组件的行为是批量的。 2.15. IntervalInterval 比较有意思,将定时器以 JSX 方式提供出来,并且提供了 stop resume 方法。 用法<Interval delay={1000}> {({ start, stop }) => ( <> <div>The time is now {new Date().toLocaleTimeString()}</div> <button onClick={() => stop()}>Stop interval</button> <button onClick={() => start()}>Start interval</button> </> )}</Interval> 源码 源码地址 原料:无 提供了 start stop toggle 方法。 实现方式是,在组件内部维护一个 Interval 定时器,实现了组件更新、销毁时的计时器更新、销毁操作,可以认为这种定时器的生命周期绑定了 React 组件的生命周期,不用担心销毁和更新的问题。 具体逻辑就不列举了,利用 setInterval clearInterval 函数基本上就可以了。 2.16. ComposeCompose 也是个有趣的组件,可以将上面提到的任意多个组件组合使用。 用法<Compose components={[Counter, Toggle]}> {(counter, toggle) => ( <ProductCard {...productInfo} favorite={toggle.on} onFavorite={toggle.toggle} count={counter.count} onAdd={counter.inc} onRemove={counter.dec} /> )}</Compose> 源码 源码地址 原料:无 通过递归渲染出嵌套结构,并将每一层结构输出的值存储到 propsList 中,最后一起传递给组件。这也是为什么每个函数 value 一般都要重命名的原因。 在 精读《Epitath 源码 - renderProps 新用法》 文章中,笔者就介绍了利用 generator 解决高阶组件嵌套的问题。 在 精读《React Hooks》 文章中,介绍了 React Hooks 已经实现了这个特性。 所以当你了解了这三种 “compose” 方法后,就可以在合适的场景使用合适的 compose 方式简化代码。 3. 总结看完了源码分析,不知道你是更感兴趣使用这个库呢,还是已经跃跃欲试开始造轮子了呢?不论如何,这个库的思想在日常的业务开发中都应该大量实践。 另外 Hooks 版的 PowerPlug 已经 4 个月没有更新了(非官方):react-powerhooks,也许下一个维护者/贡献者 就是你。 讨论地址是:精读《React PowerPlug 源码》 · Issue ##129 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《sqorn 源码》","path":"/wiki/WebWeekly/源码解读/《sqorn 源码》.html","content":"当前期刊数: 73 1 引言前端精读《手写 SQL 编译器系列》 介绍了如何利用 SQL 生成语法树,而还有一些库的作用是根据语法树生成 SQL 语句。 除此之外,还有一种库,是根据编程语言生成 SQL。sqorn 就是一个这样的库。 可能有人会问,利用编程语言生成 SQL 有什么意义?既没有语法树规范,也不如直接写 SQL 通用。对,有利就有弊,这些库不遵循语法树,但利用简化的对象模型快速生成 SQL,使得代码抽象程度得到了提高。而代码抽象程度得到提高,第一个好处就是易读,第二个好处就是易操作。 数据库特别容易抽象为面向对象模型,而对数据库的操作语句 - SQL 是一种结构化查询语句,只能描述一段一段的查询,而面向对象模型却适合描述一个整体,将数据库多张表串联起来。 举个例子,利用 typeorm,我们可以用 a 与 b 两个 Class 描述两张表,同时利用 ManyToMany 装饰器分别修饰 a 与 b 的两个字段,将其建立起 多对多的关联,而这个映射到 SQL 结构是三张表,还有一张是中间表 ab,以及查询时涉及到的 left join 操作,而在 typeorm 中,一条 find 语句就能连带查询处多对多关联关系。 这就是这种利用编程语言生成 SQL 库的价值,所以本周我们分析一下 sqorn 这个库的源码,看看利用对象模型生成 SQL 需要哪些步骤。 2 概述我们先看一下 sqorn 的语法。 const sq = require("sqorn-pg")();const Person = sq`person`, Book = sq`book`;// SELECTconst children = await Person`age < ${13}`;// "select * from person where age < 13"// DELETEconst [deleted] = await Book.delete({ id: 7 })`title`;// "delete from book where id = 7 returning title"// INSERTawait Person.insert({ firstName: "Rob" });// "insert into person (first_name) values ('Rob')"// UPDATEawait Person({ id: 23 }).set({ name: "Rob" });// "update person set name = 'Rob' where id = 23" 首先第一行的 sqorn-pg 告诉我们 sqorn 按照 SQL 类型拆成不同分类的小包,这是因为不同数据库支持的方言不同,sqorn 希望在语法上抹平数据库间差异。 其次 sqorn 也是利用面向对象思维的,上面的例子通过 sq`person` 生成了 Person 实例,实际上也对应了 person 表,然后 Person`age < ${13}` 表示查询:select * from person where age < 13 上面是利用 ES6 模板字符串的功能实现的简化 where 查询功能,sqorn 主要还是利用一些函数完成 SQL 语句生成,比如 where delete insert 等等,比较典型的是下面的 Example: sq.from`book`.return`distinct author` .where({ genre: "Fantasy" }) .where({ language: "French" });// select distinct author from book// where language = 'French' and genre = 'Fantsy' 所以我们阅读 sqorn 源码,探讨如何利用实现上面的功能。 3 精读我们从四个方面入手,讲明白 sqorn 的源码是如何组织的,以及如何满足上面功能的。 方言为了实现各种 SQL 方言,需要在实现功能之前,将代码拆分为内核代码与拓展代码。 内核代码就是 sqorn-sql 而拓展代码就是 sqorn-pg,拓展代码自身只要实现 pg 数据库自身的特殊逻辑, 加上 sqorn-sql 提供的核心能力,就能形成完整的 pg SQL 生成功能。 实现数据库连接 sqorn 不但生成 query 语句,也会参与数据库连接与运行,因此方言库的一个重要功能就是做数据库连接。sqorn 利用 pg 这个库实现了连接池、断开、查询、事务的功能。 覆写接口函数 内核代码想要具有拓展能力,暴露出一些接口让 sqorn-xx 覆写是很基本的。 context内核代码中,最重要的就是 context 属性,因为人类习惯一步一步写代码,而最终生成的 query 语句是连贯的,所以这个上下文对象通过 updateContext 存储了每一条信息: { name: 'limit', updateContext: (ctx, args) => { ctx.lim = args }}{ name: 'where', updateContext: (ctx, args) => { ctx.whr.push(args) }} 比如 Person.where({ name: 'bob' }) 就会调用 ctx.whr.push({ name: 'bob' }),因为 where 条件是个数组,因此这里用 push,而 limit 一般仅有一个,所以 context 对 lim 对象的存储仅有一条。 其他操作诸如 where delete insert with from 都会类似转化为 updateContext,最终更新到 context 中。 创建 builder不用太关心下面的 sqorn-xx 包名细节,这一节主要目的是说明如何实现 Demo 中的链式调用,至于哪个模块放在哪并不重要(如果要自己造轮子就要仔细学习一下作者的命名方式)。 在 sqorn-core 代码中创建了 builder 对象,将 sqorn-sql 中创建的 methods merge 到其中,因此我们可以使用 sq.where 这种语法。而为什么可以 sq.where().limit() 这样连续调用呢?可以看下面的代码: for (const method of methods) { // add function call methods builder[name] = function(...args) { return this.create({ name, args, prev: this.method }); };} 这里将 where delete insert with from 等 methods merge 到 builder 对象中,且当其执行完后,通过 this.create() 返回一个新 builder,从而完成了链式调用功能。 生成 query上面三点讲清楚了如何支持方言、用户代码内容都收集到 context 中了,而且我们还创建了可以链式调用的 builder 对象方便用户调用,那么只剩最后一步了,就是生成 query。 为了利用 context 生成 query,我们需要对每个 key 编写对应的函数做处理,拿 limit 举例: export default ctx => { if (!ctx.lim) return; const txt = build(ctx, ctx.lim); return txt && `limit ${txt}`;}; 从 context.lim 拿取 limit 配置,组合成 limit xxx 的字符串并返回就可以了。 build 函数是个工具函数,如果 ctx.lim 是个数组,就会用逗号拼接。 大部分操作比如 delete from having 都做这么简单的处理即可,但像 where 会相对复杂,因为内部包含了 condition 子语法,注意用 and 拼接即可。 最后是顺序,也需要在代码中确定: export default { sql: query(sql), select: query(wth, select, from, where, group, having, order, limit, offset), delete: query(wth, del, where, returning), insert: query(wth, insert, value, returning), update: query(wth, update, set, where, returning)}; 这个意思是,一个 select 语句会通过 wth, select, from, where, group, having, order, limit, offset 的顺序调用处理函数,返回的值就是最终的 query。 4 总结通过源码分析,可以看到制作一个这样的库有三个步骤: 创建 context 存储结构化 query 信息。 创建 builder 供用户链式书写代码同时填充 context。 通过若干个 SQL 子处理函数加上几个主 statement 函数将其串联起来生成最终 query。 最后在设计时考虑到 SQL 方言的话,可以将模块拆成 核心、SQL、若干个方言库,方言库基于核心库做拓展即可。 5 更多讨论 讨论地址是:精读《sqorn 源码》 · Issue ##103 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《react-snippets - Router 源码》","path":"/wiki/WebWeekly/源码解读/《react-snippets - Router 源码》.html","content":"当前期刊数: 241 造轮子就是应用核心原理 + 周边功能的堆砌,所以学习成熟库的源码往往会受到非核心代码干扰,Router 这个 repo 用不到 100 行源码实现了 React Router 核心机制,很适合用来学习。 精读Router 快速实现了 React Router 3 个核心 API:Router、navigate、Link,下面列出基本用法,配合理解源码实现会更方便: const App = () => ( <Router routes={[ { path: '/home', component: <Home /> }, { path: '/articles', component: <Articles /> } ]} />)const Home = () => ( <div> home, <Link href="/articles">go articles</Link>, <span onClick={() => navigate('/details')}>or jump to details</span> </div>) 首先看 Router 的实现,在看代码之前,思考下 Router 要做哪些事情? 接收 routes 参数,根据当前 url 地址判断渲染哪个组件。 当 url 地址变化时(无论是用户触发还是自己的 navigate Link 触发),渲染新 url 对应的组件。 所以 Router 是一个路由渲染分配器与 url 监听器: export default function Router ({ routes }) { // 存储当前 url path,方便其变化时引发自身重渲染,以返回新的 url 对应的组件 const [currentPath, setCurrentPath] = useState(window.location.pathname); useEffect(() => { const onLocationChange = () => { // 将 url path 更新到当前数据流中,触发自身重渲染 setCurrentPath(window.location.pathname); } // 监听 popstate 事件,该事件由用户点击浏览器前进/后退时触发 window.addEventListener('popstate', onLocationChange); return () => window.removeEventListener('popstate', onLocationChange) }, []) // 找到匹配当前 url 路径的组件并渲染 return routes.find(({ path, component }) => path === currentPath)?.component} 最后一段代码看似每次都执行 find 有一定性能损耗,但其实根据 Router 一般在最根节点的特性,该函数很少因父组件重渲染而触发渲染,所以性能不用太担心。 但如果考虑做一个完整的 React Router 组件库,考虑了更复杂的嵌套 API,即 Router 套 Router 后,不仅监听方式要变化,还需要将命中的组件缓存下来,需要考虑的点会逐渐变多。 下面该实现 navigate Link 了,他俩做的事情都是跳转,有如下区别: API 调用方式不同,navigate 是调用式函数,而 Link 是一个内置 navigate 能力的 a 标签。 Link 其实还有一种按住 ctrl 后打开新 tab 的跳转模式,该模式由浏览器对 a 标签默认行为完成。 所以 Link 更复杂一些,我们先实现 navigate,再实现 Link 时就可以复用它了。 既然 Router 已经监听 popstate 事件,我们显然想到的是触发 url 变化后,让 popstate 捕获,自动触发后续跳转逻辑。但可惜的是,我们要做的 React Router 需要实现单页跳转逻辑,而单页跳转的 API history.pushState 并不会触发 popstate,为了让实现更优雅,我们可以在 pushState 后手动触发 popstate 事件,如源码所示: export function navigate (href) { // 用 pushState 直接刷新 url,而不触发真正的浏览器跳转 window.history.pushState({}, "", href); // 手动触发一次 popstate,让 Route 组件监听并触发 onLocationChange const navEvent = new PopStateEvent('popstate'); window.dispatchEvent(navEvent);} 接下来实现 Link 就很简单了,有几个考虑点: 返回一个正常的 <a> 标签。 因为正常 <a> 点击后就发生网页刷新而不是单页跳转,所以点击时要阻止默认行为,换成我们的 navigate(源码里没做这个抽象,笔者稍微优化了下)。 但按住 ctrl 时又要打开新 tab,此时用默认 <a> 标签行为就行,所以此时不要阻止默认行为,也不要继续执行 navigate,因为这个 url 变化不会作用于当前 tab。 export function Link ({ className, href, children }) { const onClick = (event) => { // mac 的 meta or windows 的 ctrl 都会打开新 tab // 所以此时不做定制处理,直接 return 用原生行为即可 if (event.metaKey || event.ctrlKey) { return; } // 否则禁用原生跳转 event.preventDefault(); // 做一次单页跳转 navigate(href) }; return ( <a className={className} href={href} onClick={onClick}> {children} </a> );}; 这样的设计,既能兼顾 <a> 标签默认行为,又能在点击时优化为单页跳转,里面对 preventDefault 与 metaKey 的判断值得学习。 总结从这个小轮子中可以学习到一下几个经验: 造轮子之前先想好使用 API,根据使用 API 反推实现,会让你的设计更有全局观。 实现 API 时,先思考 API 之间的关系,能复用的就提前设计好复用关系,这样巧妙的关联设计能为以后维护减少很多麻烦。 即便代码无法复用的地方,也要尽量做到逻辑复用。比如 pushState 无法触发 popstate 那段,直接把 popstate 代码复用过来,或者自己造一个状态沟通就太 low 了,用浏览器 API 模拟事件触发,既轻量,又符合逻辑,因为你要做的就是触发 popstate 行为,而非只是更新渲染组件这个动作,万一以后再有监听 popstate 的地方,你的触发逻辑就能很自然的应用到那儿。 尽量在原生能力上拓展,而不是用自定义方法补齐原生能力。比如 Link 的实现是基于 <a> 标签拓展的,如果采用自定义 <span> 标签,不仅要补齐样式上的差异,还要自己实现 ctrl 后打开新 tab 的行为,甚至 <a> 默认访问记录行为你也得花高成本补上,所以错误的设计方向会导致事倍功半,甚至无法实现。 讨论地址是:精读《react-snippets - Router 源码》· Issue ##418 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《syntax-parser 源码》","path":"/wiki/WebWeekly/源码解读/《syntax-parser 源码》.html","content":"当前期刊数: 93 1. 引言syntax-parser 是一个 JS 版语法解析器生成器,具有分词、语法树解析的能力。 通过两个例子介绍它的功能。 第一个例子是创建一个词法解析器 myLexer: import { createLexer } from "syntax-parser";const myLexer = createLexer([ { type: "whitespace", regexes: [/^(\\s+)/], ignore: true }, { type: "word", regexes: [/^([a-zA-Z0-9]+)/] }, { type: "operator", regexes: [/^(\\+)/] }]); 如上,通过正则分别匹配了 “空格”、“字母或数字”、“加号”,并将匹配到的空格忽略(不输出)。 分词匹配是从左到右的,优先匹配数组的第一项,依此类推。 接下来使用 myLexer: const tokens = myLexer("a + b");// tokens:// [// { "type": "word", "value": "a", "position": [0, 1] },// { "type": "operator", "value": "+", "position": [2, 3] },// { "type": "word", "value": "b", "position": [4, 5] },// ] 'a + b' 会按照上面定义的 “三种类型” 被分割为数组,数组的每一项都包含了原始值以及其位置。 第二个例子是创建一个语法解析器 myParser: import { createParser, chain, matchTokenType, many } from "syntax-parser";const root = () => chain(addExpr)(ast => ast[0]);const addExpr = () => chain(matchTokenType("word"), many(addPlus))(ast => ({ left: ast[0].value, operator: ast[1] && ast[1][0].operator, right: ast[1] && ast[1][0].term }));const addPlus = () => chain("+"), root)(ast => ({ operator: ast[0].value, term: ast[1] }));const myParser = createParser( root, // Root grammar. myLexer // Created in lexer example.); 利用 chain 函数书写文法表达式:通过字面量的匹配(比如 + 号),以及 matchTokenType 来模糊匹配我们上面词法解析出的 “三种类型”,就形成了完整的文法表达式。 syntax-parser 还提供了其他几个有用的函数,比如 many optional 分别表示匹配多次和匹配零或一次。 接下来使用 myParser: const ast = myParser("a + b");// ast:// [{// "left": "a",// "operator": "+",// "right": {// "left": "b",// "operator": null,// "right": null// }// }] 2. 精读按照下面的思路大纲进行源码解读: 词法解析 词汇与概念 分词器 语法解析 词汇与概念 重新做一套 “JS 执行引擎” 实现 Chain 函数 引擎执行 何时算执行完 “或” 逻辑的实现 many, optional, plus 的实现 错误提示 & 输入推荐 First 集优化 词法解析词法解析有点像 NLP 中分词,但比分词简单的时,词法解析的分词逻辑是明确的,一般用正则片段表达。 词汇与概念 Lexer:词法解析器。 Token:分词后的词素,包括 value:值、position:位置、type:类型。 分词器分词器 createLexer 函数接收的是一个正则数组,因此思路是遍历数组,一段一段匹配字符串。 我们需要这几个函数: class Tokenizer { public tokenize(input: string) { // 调用 getNextToken 对输入字符串 input 进行正则匹配,匹配完后 substring 裁剪掉刚才匹配的部分,再重新匹配直到字符串裁剪完 } private getNextToken(input: string) { // 调用 getTokenOnFirstMatch 对输入字符串 input 进行遍历正则匹配,一旦有匹配到的结果立即返回 } private getTokenOnFirstMatch({ input, type, regex }: { input: string; type: string; regex: RegExp; }) { // 对输入字符串 input 进行正则 regex 的匹配,并返回 Token 对象的基本结构 }} tokenize 是入口函数,循环调用 getNextToken 匹配 Token 并裁剪字符串直到字符串被裁完。 语法解析语法解析是基于词法解析的,输入是 Tokens,根据文法规则依次匹配 Token,当 Token 匹配完且完全符合文法规范后,语法树就出来了。 词法解析器生成器就是 “生成词法解析器的工具”,只要输入规定的文法描述,内部引擎会自动做掉其余的事。 这个生成器的难点在于,匹配 “或” 逻辑失败时,调用栈需要恢复到失败前的位置,而 JS 引擎中调用栈不受代码控制,因此代码需要在模拟引擎中执行。 词汇与概念 Parser:语法解析器。 ChainNode:连续匹配,执行链四节点之一。 TreeNode:匹配其一,执行链四节点之一。 FunctionNode:函数节点,执行链四节点之一。 MatchNode:匹配字面量或某一类型的 Token,执行链四节点之一。每一次正确的 Match 匹配都会消耗一个 Token。 重新做一套 “JS 执行引擎”为什么要重新做一套 JS 执行引擎?看下面的代码: const main = () => chain(functionA(), tree(functionB1(), functionB2()), functionC());const functionA = () => chain("a");const functionB1 = () => chain("b", "x");const functionB2 = () => chain("b", "y");const functionC = () => chain("c"); 假设 chain('a') 可以匹配 Token a,而 chain(functionC)) 可以匹配到 Token c。 当输入为 a b y c 时,我们该怎么写 tree 函数呢? 我们期望匹配到 functionB1 时失败,再尝试 functionB2,直到有一个成功为止。 那么 tree 函数可能是这样的: function tree(...funs) { // ... 存储当前 tokens for (const fun of funs) { // ... 复位当前 tokens const result = fun(); if (result === true) { return result; } }} 不断尝试 tree 中内容,直到能正确匹配结果后返回这个结果。由于正确的匹配会消耗 Token,因此需要在执行前后存储当前 Tokens 内容,在执行失败时恢复 Token 并尝试新的执行链路。 这样看去很容易,不是吗? 然而,下面这个例子会打破这个美好的假设,让我们稍稍换几个值吧: const main = () => chain(functionA(), tree(functionB1(), functionB2()), functionC());const functionA = () => chain("a");const functionB1 = () => chain("b", "y");const functionB2 = () => chain("b");const functionC = () => chain("y", "c"); 输入仍然是 a b y c,看看会发生什么? 线路 functionA -> functionB1 是 a b y 很显然匹配会通过,但连上 functionC 后结果就是 a b y y c,显然不符合输入。 此时正确的线路应该是 functionA -> functionB2 -> functionC,结果才是 a b y c! 我们看 functionA -> functionB1 -> functionC 链路,当执行到 functionC 时才发现匹配错了,此时想要回到 functionB2 门也没有!因为 tree(functionB1(), functionB2()) 的执行堆栈已退出,再也找不回来了。 所以需要模拟一个执行引擎,在遇到分叉路口时,将 functionB2 保存下来,随时可以回到这个节点重新执行。 实现 Chain 函数用链表设计 Chain 函数是最佳的选择,我们要模拟 JS 调用栈了。 const main = () => chain(functionA, [functionB1, functionB2], functionC)();const functionA = () => chain("a")();const functionB1 = () => chain("b", "y")();const functionB2 = () => chain("b")();const functionC = () => chain("y", "c")(); 上面的例子只改动了一小点,那就是函数不会立即执行。 chain 将函数转化为 FunctionNode,将字面量 a 或 b 转化为 MatchNode,将 [] 转化为 TreeNode,将自己转化为 ChainNode。 我们就得到了如下的链表: ChainNode(main) └── FunctionNode(functionA) ─ TreeNode ─ FunctionNode(functionC) │── FunctionNode(functionB1) └── FunctionNode(functionB2) 至于为什么 FunctionNode 不直接展开成 MatchNode,请思考这样的描述:const list = () => chain(',', list)。直接展开则陷入递归死循环,实际上 Tokens 数量总有限,用到再展开总能匹配尽 Token,而不会无限展开下去。 那么需要一个函数,将 chain 函数接收的不同参数转化为对应 Node 节点: const createNodeByElement = ( element: IElement, parentNode: ParentNode, parentIndex: number, parser: Parser): Node => { if (element instanceof Array) { // ... return TreeNode } else if (typeof element === "string") { // ... return MatchNode } else if (typeof element === "boolean") { // ... true 表示一定匹配成功,false 表示一定匹配失败,均不消耗 Token } else if (typeof element === "function") { // ... return FunctionNode }}; createNodeByElement 函数源码 引擎执行引擎执行其实就是访问链表,通过 visit 函数是最佳手段。 const visit = tailCallOptimize( ({ node, store, visiterOption, childIndex }: { node: Node; store: VisiterStore; visiterOption: VisiterOption; childIndex: number; }) => { if (node instanceof ChainNode) { // 调用 `visitChildNode` 访问子节点 } else if (node instanceof TreeNode) { // 调用 `visitChildNode` 访问子节点 visitChildNode({ node, store, visiterOption, childIndex }); } else if (node instanceof MatchNode) { // 与当前 Token 进行匹配,匹配成功则调用 `visitNextNodeFromParent` 访问父级 Node 的下一个节点,匹配失败则调用 `tryChances`,这会在 “或” 逻辑里说明。 } else if (node instanceof FunctionNode) { // 执行函数节点,并替换掉当前节点,重新 `visit` 一遍 } }); 由于 visit 函数执行次数至多可能几百万次,因此使用 tailCallOptimize 进行尾递归优化,防止内存或堆栈溢出。 visit 函数只负责访问节点本身,而 visitChildNode 函数负责访问节点的子节点(如果有),而 visitNextNodeFromParent 函数负责在没有子节点时,找到父级节点的下一个子节点访问。 function visitChildNode({ node, store, visiterOption, childIndex}: { node: ParentNode; store: VisiterStore; visiterOption: VisiterOption; childIndex: number;}) { if (node instanceof ChainNode) { const child = node.childs[childIndex]; if (child) { // 调用 `visit` 函数访问子节点 `child` } else { // 如果没有子节点,就调用 `visitNextNodeFromParent` 往上找了 } } else { // 对于 TreeNode,如果不是访问到了最后一个节点,则添加一次 “存档” // 调用 `addChances` // 同时如果有子元素,`visit` 这个子元素 }}const visitNextNodeFromParent = tailCallOptimize( ( node: Node, store: VisiterStore, visiterOption: VisiterOption, astValue: any ) => { if (!node.parentNode) { // 找父节点的函数没有父级时,下面再介绍,记住这个位置叫 END 位。 } if (node.parentNode instanceof ChainNode) { // A B <- next node C // └── node <- current node // 正如图所示,找到 nextNode 节点调用 `visit` } else if (node.parentNode instanceof TreeNode) { // TreeNode 节点直接利用 `visitNextNodeFromParent` 跳过。因为同一时间 TreeNode 节点只有一个分支生效,所以它没有子元素了 } }); 可以看到 visitChildNode 与 visitNextNodeFromParent 函数都只处理好了自己的事情,而将其他工作交给别的函数完成,这样函数间职责分明,代码也更易懂。 有了 vist visitChildNode 与 visitNextNodeFromParent,就完成了节点的访问、子节点的访问、以及当没有子节点时,追溯到上层节点的访问。 visit 函数源码 何时算执行完当 visitNextNodeFromParent 函数访问到 END 位 时,是时候做一个了结了: 当 Tokens 正好消耗完,完美匹配成功。 Tokens 没消耗完,匹配失败。 还有一种失败情况,是 Chance 用光时,结合下面的 “或” 逻辑一起说。 “或” 逻辑的实现“或” 逻辑是重构 JS 引擎的原因,现在这个问题被很好解决掉了。 const main = () => chain(functionA, [functionB1, functionB2], functionC)(); 比如上面的代码,当遇到 [] 数组结构时,被认为是 “或” 逻辑,子元素存储在 TreeNode 节点中。 在 visitChildNode 函数中,与 ChainNode 不同之处在于,访问 TreeNode 子节点时,还会调用 addChances 方法,为下一个子元素存储执行状态,以便未来恢复到这个节点继续执行。 addChances 维护了一个池子,调用是先进后出: function addChances(/* ... */) { const chance = { node, tokenIndex, childIndex }; store.restChances.push(chance);} 与 addChance 相对的就是 tryChance。 下面两种情况会调用 tryChances: MatchNode 匹配失败。节点匹配失败是最常见的失败情况,但如果 chances 池还有存档,就可以恢复过去继续尝试。 没有下一个节点了,但 Tokens 还没消耗完,也说明匹配失败了,此时调用 tryChances 继续尝试。 我们看看神奇的存档回复函数 tryChances 是如何做的: function tryChances( node: Node, store: VisiterStore, visiterOption: VisiterOption) { if (store.restChances.length === 0) { // 直接失败 } const nextChance = store.restChances.pop(); // reset scanner index store.scanner.setIndex(nextChance.tokenIndex); visit({ node: nextChance.node, store, visiterOption, childIndex: nextChance.childIndex });} tryChances 其实很简单,除了没有 chances 就失败外,找到最近的一个 chance 节点,恢复 Token 指针位置并 visit 这个节点就等价于读档。 addChance 源码 tryChances 源码 many, optional, plus 的实现这三个方法实现的也很精妙。 先看可选函数 optional: export const optional = (...elements: IElements) => { return chain([chain(...elements)(/**/)), true])(/**/);}; 可以看到,可选参数实际上就是一个 TreeNode,也就是: chain(optional("a"))();// 等价于chain(["a", true])(); 为什么呢?因为当 'a' 匹配失败后,true 是一个不消耗 Token 一定成功的匹配,整体来看就是 “可选” 的意思。 进一步解释下,如果 'a' 没有匹配上,则 true 一定能匹配上,匹配 true 等于什么都没匹配,就等同于这个表达式不存在。 再看匹配一或多个的函数 plus: export const plus = (...elements: IElements) => { const plusFunction = () => chain(chain(...elements)(/**/), optional(plusFunction))(/**/); return plusFunction;}; 能看出来吗?plus 函数等价于一个新递归函数。也就是: const aPlus = () => chain(plus("a"))();// 等价于const aPlus = () => chain(plusFunc)();const plusFunc = () => chain("a", optional(plusFunc))(); 通过不断递归自身的方式匹配到尽可能多的元素,而每一层的 optional 保证了任意一层匹配失败后可以及时跳到下一个文法,不会失败。 最后看匹配多个的函数 many: export const many = (...elements: IElements) => { return optional(plus(...elements));}; many 就是 optional 的 plus,不是吗? 这三个神奇的函数都利用了已有功能实现,建议每个函数留一分钟左右时间思考为什么。 optional plus many 函数源码 错误提示 & 输入推荐错误提示与输入推荐类似,都是给出错误位置或光标位置后期待的输入。 输入推荐,就是给定字符串与光标位置,给出光标后期待内容的功能。 首先通过光标位置找到光标的 **上一个 Token**,再通过 findNextMatchNodes 找到这个 Token 后所有可能匹配到的 MatchNode,这就是推荐结果。 那么如何实现 findNextMatchNodes 呢?看下面: function findNextMatchNodes(node: Node, parser: Parser): MatchNode[] { const nextMatchNodes: MatchNode[] = []; let passCurrentNode = false; const visiterOption: VisiterOption = { onMatchNode: (matchNode, store, currentVisiterOption) => { if (matchNode === node && passCurrentNode === false) { passCurrentNode = true; // 调用 visitNextNodeFromParent,忽略自身 } else { // 遍历到的 MatchNode nextMatchNodes.push(matchNode); } // 这个是画龙点睛的一笔,所有推荐都当作匹配失败,通过 tryChances 可以找到所有可能的 MatchNode tryChances(matchNode, store, currentVisiterOption); } }; newVisit({ node, scanner: new Scanner([]), visiterOption, parser }); return nextMatchNodes;} 所谓找到后续节点,就是通过 Visit 找到所有的 MatchNode,而 MatchNode 只要匹配一次即可,因为我们只要找到第一层级的 MatchNode。 通过每次匹配后执行 tryChances,就可以找到所有 MatchNode 节点了! 再看错误提示,我们要记录最后出错的位置,再采用输入推荐即可。 但光标所在的位置是期望输入点,这个输入点也应该参与语法树的生成,而错误提示不包含光标,所以我们要 执行两次 visit。 举个例子: select | from b; | 是光标位置,此时语句内容是 select from b; 显然是错误的,但光标位置应该给出提示,给出提示就需要正确解析语法树,所以对于提示功能,我们需要将光标位置考虑进去一起解析。因此一共有两次解析。 findNextMatchNodes 函数源码 First 集优化构建 First 集是个自下而上的过程,当访问到 MatchNode 节点时,其值就是其父节点的一个 First 值,当父节点的 First 集收集完毕后,,就会触发它的父节点 First 集收集判断,如此递归,最后完成 First 集收集的是最顶级节点。 篇幅原因,不再赘述,可以看 这张图。 generateFirstSet 函数源码 3. 总结这篇文章是对 《手写 SQL 编译器》 系列的总结,从源码角度的总结! 该系列的每篇文章都以图文的方式介绍了各技术细节,可以作为补充阅读: 精读《手写 SQL 编译器 - 词法分析》 精读《手写 SQL 编译器 - 文法介绍》 精读《手写 SQL 编译器 - 语法分析》 精读《手写 SQL 编译器 - 回溯》 精读《手写 SQL 编译器 - 语法树》 精读《手写 SQL 编译器 - 错误提示》 精读《手写 SQL 编译器 - 性能优化之缓存》 精读《手写 SQL 编译器 - 智能提示》 讨论地址是:精读《syntax-parser 源码》 · Issue ##133 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《robot 源码 - 有限状态机》","path":"/wiki/WebWeekly/源码解读/《robot 源码 - 有限状态机》.html","content":"当前期刊数: 122 1 概述本期精读的是有限状态机管理工具 robot 源码。 有限状态机是指有限个数的状态之间相互切换的数学模型,在业务与游戏开发中有限状态都很常见,包括发请求也是一种有限状态机的模型。 笔者将在简介中介绍这个库的使用方式,在精读中介绍实现原理,最后总结在业务中使用的价值。 2 简介这个库的核心就是利用 createMachine 创建一个有限状态机: import { createMachine, state, transition } from 'robot3';const machine = createMachine({ inactive: state( transition('toggle', 'active') ), active: state( transition('toggle', 'inactive') )});export default machine; 如上图所示,我们创建了一个有限状态机 machine,包含了两种状态:inactive 与 active,并且可以通过 toggle 动作在两种状态间做切换。 与 React 结合则有 react-robot: import { useMachine } from 'react-robot';import React from 'react';import machine from './machine' function App() { const [current, send] = useMachine(machine); return ( <button type="button" onClick={() => send('toggle')}> State: {current.name} </button> )} 通过 useMachine 拿到的 current.name 表示当前状态值,send 用来发送改变状态的指令。 至于为什么要用有限状态机管理工具,官方文档举了个例子 - 点击编辑后进入编辑态,点击保存后返回原始状态的例子: 点击 Edit 按钮后,将进入下图的状态,点击 Save 后如果输入的内容校验通过保存后再回到初始状态: 如果不用有限状态机,我们首先会创建两个变量存储是否处于编辑态,以及当前输入文本是什么: let editMode = false;let title = ''; 如果再考虑和后端的交互,就会增加三个状态 - 保存中、校验、保存是否成功: let editMode = false;let title = '';let saving = false;let validating = false;let saveHadError = false; 就算使用 React、Vue 等框架数据驱动 UI,我们还是免不了对复杂状态进行管理。如果使用有限状态机实现,将是这样的: import { createMachine, guard, immediate, invoke, state, transition, reduce } from 'robot3';const machine = createMachine({ preview: state( transition('edit', 'editMode', // Save the current title as oldTitle so we can reset later. reduce(ctx => ({ ...ctx, oldTitle: ctx.title })) ) ), editMode: state( transition('input', 'editMode', reduce((ctx, ev) => ({ ...ctx, title: ev.target.value })) ), transition('cancel', 'cancel'), transition('save', 'validate') ), cancel: state( immediate('preview', // Reset the title back to oldTitle reduce(ctx => ({ ...ctx, title: ctx.oldTitle }) ) ), validate: state( // Check if the title is valid. If so go // to the save state, otherwise go back to editMode immediate('save', guard(titleIsValid)), immediate('editMode') ) save: invoke(saveTitle, transition('done', 'preview'), transition('error', 'error') ), error: state( // Should we provide a retry or...? )}); 其中 immediate 表示直接跳到下一个状态,reduce 则可以对状态机内部数据进行拓展。比如 preview 返回了 oldTitle,那么 cancle 时就可以通过 ctx.oldTitle 拿到;invoke 表示调用第一个函数后,再执行 state。 通过上面的代码我们可以看到使用状态机的好处: 状态清晰,先罗列出某个业务逻辑的全部状态,避免遗漏。 状态转换安全。比如 preview 只能切换到 edit 状态,这样就算在错误的状态发错指令也不会产生异常情况。 3 精读robot 重要的函数有 createMachine, state, transition, immediate,下面一一拆解说明。 createMachinecreateMachine 表示创建状态机: export function createMachine(current, states, contextFn = empty) { if(typeof current !== 'string') { contextFn = states || empty; states = current; current = Object.keys(states)[0]; } if(d._create) d._create(current, states); return create(machine, { context: valueEnumerable(contextFn), current: valueEnumerable(current), states: valueEnumerable(states) });} 可以看到,如果传递了一个对象,通过 Object.keys(states)[0] 拿到第一个状态作为当前状态(标记在 current),最终将保存三个属性: context 当前状态机内部属性,初始化是空的。 current 当前状态。 states 所有状态,也就是 createMachine 传递的第一个参数。 再看 create 函数: let create = (a, b) => Object.freeze(Object.create(a, b)); 也就是创建了一个不修改的对象作为状态机。 这个是 machine 对象: let machine = { get state() { return { name: this.current, value: this.states[this.current] }; }}; 也就是说,状态机内部的状态管理是通过对象完成的,并提供了 state() 函数拿到当前的状态名和状态值。 statestate 用来描述状态支持哪些转换: export function state(...args) { let transitions = filter(transitionType, args); let immediates = filter(immediateType, args); let desc = { final: valueEnumerable(args.length === 0), transitions: valueEnumerable(transitionsToMap(transitions)) }; if(immediates.length) { desc.immediates = valueEnumerable(immediates); desc.enter = valueEnumerable(enterImmediate); } return create(stateType, desc);} transitions 与 immediates 表示从 args 里拿到 transition 或 immediate 的结果。 方法是通过如下方式定义 transition 与 immediate: export let transition = makeTransition.bind(transitionType);export let immediate = makeTransition.bind(immediateType, null);function filter(Type, arr) { return arr.filter(value => Type.isPrototypeOf(value));} 那么如果一个函数是通过 immediate 创建的,就可以通过 immediateType.isPrototypeOf() 的校验,此方法适用范围很广,在任何库里都可以用来校验拿到对应函数创建的对象。 如果参数数量为 0,表示这个状态是最终态,无法进行转换。最后通过 create 创建一个对象,这个对象就是状态的值。 transitiontransition 是写在 state 中描述当前状态可以如何变换的函数,其实际函数是 makeTransistion: function makeTransition(from, to, ...args) { let guards = stack(filter(guardType, args).map(t => t.fn), truthy, callBoth); let reducers = stack(filter(reduceType, args).map(t => t.fn), identity, callForward); return create(this, { from: valueEnumerable(from), to: valueEnumerable(to), guards: valueEnumerable(guards), reducers: valueEnumerable(reducers) });} 由于: export let transition = makeTransition.bind(transitionType);export let immediate = makeTransition.bind(immediateType, null); 可见 from 为 null 即表示立即转换到状态 to。transition 最终返回一个对象,其中 guards 是从 transition 或 immediate 参数中找到的,由 guards 函数创建的对象,当这个对象回调函数执行成功时此状态才生效。 ...args 对应 transition('toggle', 'active') 或 immediate('save', guard(titleIsValid)),而 stack(filter(guardType, args).map(t => t.fn), truthy, callBoth) 这句话就是从 ...args 中寻找是否有 guards,reducers 同理。 最后看看状态是如何改变的,设置状态改变的函数是 transitionTo: function transitionTo(service, fromEvent, candidates) { let { machine, context } = service; for(let { to, guards, reducers } of candidates) { if(guards(context)) { service.context = reducers.call(service, context, fromEvent); let original = machine.original || machine; let newMachine = create(original, { current: valueEnumerable(to), original: { value: original } }); let state = newMachine.state.value; return state.enter(newMachine, service, fromEvent); } }} 可以看到,如果存在 guards,则需要在 guards 执行返回成功时才可以正确改变状态。同时 reducers 可以修改 context 也在 service.context = reducers.call(service, context, fromEvent); 这一行体现了出来。最后通过生成一个新的状态机,并将 current 标记为 to。 最后我们看 state.enter 这个函数,这个函数在 state 函数中有定义,其本质是继承了 stateType: let stateType = { enter: identity }; 而 identity 这个函数就是立即执行函数: let identity = a => a; 因此相当于返回了新的状态机。 4 总结有限状态机相比普通业务描述,其实是增加了一些状态间转化的约束来达到优化状态管理的目的,并且状态描述也会更规范一些,在业务中具有一定的实用性。 当然并不是所有业务都适用有限状态机,因为新框架还是有一些学习成本要考虑。最后通过源码的学习,我们又了解到一些新的框架级小技巧,可以灵活应用到自己的框架中。 讨论地址是:精读《robot 源码 - 有限状态机》 · Issue ##209 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《unstated 与 unstated-next 源码》","path":"/wiki/WebWeekly/源码解读/《unstated 与 unstated-next 源码》.html","content":"当前期刊数: 130 1 引言unstated 是基于 Class Component 的数据流管理库,unstated-next 是针对 Function Component 的升级版,且特别优化了对 Hooks 的支持。 与类 redux 库相比,这个库设计的别出心裁,而且这两个库源码行数都特别少,与 180 行的 unstated 相比,unstated-next 只有不到 40 行,但想象空间却更大,且用法符合直觉,所以本周精读就会从用法与源码两个角度分析这两个库。 2 概述首先问,什么是数据流?React 本身就提供了数据流,那就是 setState 与 useState,数据流框架存在的意义是解决跨组件数据共享与业务模型封装。 还有一种说法是,React 早期声称自己是 UI 框架,不关心数据,因此需要生态提供数据流插件弥补这个能力。但其实 React 提供的 createContext 与 useContext 已经能解决这个问题,只是使用起来稍显麻烦,而 unstated 系列就是为了解决这个问题。 unstatedunstated 解决的是 Class Component 场景下组件数据共享的问题。 相比直接抛出用法,笔者还原一下作者的思考过程:利用原生 createContext 实现数据流需要两个 UI 组件,且实现方式冗长: const Amount = React.createContext(1);class Counter extends React.Component { state = { count: 0 }; increment = amount => { this.setState({ count: this.state.count + amount }); }; decrement = amount => { this.setState({ count: this.state.count - amount }); }; render() { return ( <Amount.Consumer> {amount => ( <div> <span>{this.state.count}</span> <button onClick={() => this.decrement(amount)}>-</button> <button onClick={() => this.increment(amount)}>+</button> </div> )} </Amount.Consumer> ); }}class AmountAdjuster extends React.Component { state = { amount: 0 }; handleChange = event => { this.setState({ amount: parseInt(event.currentTarget.value, 10) }); }; render() { return ( <Amount.Provider value={this.state.amount}> <div> {this.props.children} <input type="number" value={this.state.amount} onChange={this.handleChange} /> </div> </Amount.Provider> ); }}render( <AmountAdjuster> <Counter /> </AmountAdjuster>); 而我们要做的,是将 setState 从具体的某个 UI 组件上剥离,形成一个数据对象实体,可以被注入到任何组件。 这就是 unstated 的使用方式: import React from "react";import { render } from "react-dom";import { Provider, Subscribe, Container } from "unstated";class CounterContainer extends Container { state = { count: 0 }; increment() { this.setState({ count: this.state.count + 1 }); } decrement() { this.setState({ count: this.state.count - 1 }); }}function Counter() { return ( <Subscribe to={[CounterContainer]}> {counter => ( <div> <button onClick={() => counter.decrement()}>-</button> <span>{counter.state.count}</span> <button onClick={() => counter.increment()}>+</button> </div> )} </Subscribe> );}render( <Provider> <Counter /> </Provider>, document.getElementById("root")); 首先要为 Provider 正名:Provider 是解决单例 Store 的最佳方案,当项目与组件都是用了数据流,需要分离作用域时,Provider 便派上了用场。如果项目仅需单 Store 数据流,那么与根节点放一个 Provider 等价。 其次 CounterContainer 成为一个真正数据处理类,只负责存储与操作数据,通过 <Subscribe to={[CounterContainer]}> RenderProps 方法将 counter 注入到 Render 函数中。 unstated 方案本质上利用了 setState,但将 setState 与 UI 剥离,并可以很方便的注入到任何组件中。 类似的是,其升级版 unstated-next 本质上利用了 useState,利用了自定义 Hooks 可以与 UI 分离的特性,加上 useContext 的便捷性,利用不到 40 行代码实现了比 unstated 更强大的功能。 unstated-nextunstated-next 用 40 行代码号称 React 数据管理库的终结版,让我们看看它是怎么做到的! 还是从思考过程说起,笔者发现其 README 也提供了对应思考过程,就以其 README 里的代码作为案例。 首先,使用 Function Component 的你会这样使用数据流: function CounterDisplay() { let [count, setCount] = useState(0); let decrement = () => setCount(count - 1); let increment = () => setCount(count + 1); return ( <div> <button onClick={decrement}>-</button> <p>You clicked {count} times</p> <button onClick={increment}>+</button> </div> );} 如果想将数据与 UI 分离,利用 Custom Hooks 就可以完成,这不需要借助任何框架: function useCounter() { let [count, setCount] = useState(0); let decrement = () => setCount(count - 1); let increment = () => setCount(count + 1); return { count, decrement, increment };}function CounterDisplay() { let counter = useCounter(); return ( <div> <button onClick={counter.decrement}>-</button> <p>You clicked {counter.count} times</p> <button onClick={counter.increment}>+</button> </div> );} 如果想将这个数据分享给其他组件,利用 useContext 就可以完成,这不需要借助任何框架: function useCounter() { let [count, setCount] = useState(0); let decrement = () => setCount(count - 1); let increment = () => setCount(count + 1); return { count, decrement, increment };}let Counter = createContext(null);function CounterDisplay() { let counter = useContext(Counter); return ( <div> <button onClick={counter.decrement}>-</button> <p>You clicked {counter.count} times</p> <button onClick={counter.increment}>+</button> </div> );}function App() { let counter = useCounter(); return ( <Counter.Provider value={counter}> <CounterDisplay /> <CounterDisplay /> </Counter.Provider> );} 但这样还是显示使用了 useContext 的 API,并且对 Provider 的封装没有形成固定模式,这就是 usestated-next 要解决的问题。 所以这就是 unstated-next 的使用方式: import { createContainer } from "unstated-next";function useCounter() { let [count, setCount] = useState(0); let decrement = () => setCount(count - 1); let increment = () => setCount(count + 1); return { count, decrement, increment };}let Counter = createContainer(useCounter);function CounterDisplay() { let counter = Counter.useContainer(); return ( <div> <button onClick={counter.decrement}>-</button> <p>You clicked {counter.count} times</p> <button onClick={counter.increment}>+</button> </div> );}function App() { return ( <Counter.Provider> <CounterDisplay /> <CounterDisplay /> </Counter.Provider> );} 可以看到,createContainer 可以将任何 Hooks 包装成一个数据对象,这个对象有 Provider 与 useContainer 两个 API,其中 Provider 用于对某个作用域注入数据,而 useContainer 可以取到这个数据对象在当前作用域的实例。 对 Hooks 的参数也进行了规范化,我们可以通过 initialState 设定初始化数据,且不同作用域可以嵌套并赋予不同的初始化值: function useCounter(initialState = 0) { let [count, setCount] = useState(initialState); let decrement = () => setCount(count - 1); let increment = () => setCount(count + 1); return { count, decrement, increment };}const Counter = createContainer(useCounter);function CounterDisplay() { let counter = Counter.useContainer(); return ( <div> <button onClick={counter.decrement}>-</button> <span>{counter.count}</span> <button onClick={counter.increment}>+</button> </div> );}function App() { return ( <Counter.Provider> <CounterDisplay /> <Counter.Provider initialState={2}> <div> <div> <CounterDisplay /> </div> </div> </Counter.Provider> </Counter.Provider> );} 可以看到,React Hooks 已经非常适合做状态管理,而生态应该做的事情是尽可能利用其能力进行模式化封装。 有人可能会问,取数和副作用怎么办?redux-saga 和其他中间件都没有,这个数据流是不是阉割版? 首先我们看 Redux 为什么需要处理副作用的中间件。这是因为 reducer 是一个同步纯函数,其返回值就是操作结果中间不能有异步,且不能有副作用,所以我们需要一种异步调用 dispatch 的方法,或者一个副作用函数来存放这些 “脏” 逻辑。 而在 Hooks 中,我们可以随时调用 useState 提供的 setter 函数修改值,这早已天然解决了 reducer 无法异步的问题,同时也实现了 redux-chunk 的功能。 而异步功能也被 useEffect 这个 React 官方 Hook 替代。我们看到这个方案可以利用 React 官方提供的能力完全覆盖 Redux 中间件的能力,对 Redux 库实现了降维打击,所以下一代数据流方案随着 Hooks 的实现是真的存在的。 最后,相比 Redux 自身以及其生态库的理解成本(笔者不才,初学 Redux 以及其周边 middleware 时理解了好久),Hooks 的理解学习成本明显更小。 很多时候,人们排斥一个新技术,并不是因为新技术不好,而是这可能让自己多年精通的老手艺带来的 “竞争优势” 完全消失。可能一个织布老专家手工织布效率是入门学员的 5 倍,但换上织布机器后,这个差异很快会被抹平,老织布专家面临被淘汰的危机,所以维护这份老手艺就是维护他自己的利益。希望每个团队中的老织布工人都能主动引入织布机。 再看取数中间件,我们一般需要解决 取数业务逻辑封装 与 取数状态封装,通过 redux 中间件可以封装在内,通过一个 dispatch 解决。 其实 Hooks 思维下,利用 swr useSWR 一样能解决: function Profile() { const { data, error } = useSWR("/api/user");} 取数的业务逻辑封装在 fetcher 中,这个在 SWRConfigContext.Provider 时就已注入,还可以控制作用域!完全利用 React 提供的 Context 能力,可以感受到实现底层原理的一致性和简洁性,越简单越优美的数学公式越可能是真理。 而取数状态已经封装在 useSWR 中,配合 Suspense 能力,连 Loading 状态都不用关心了。 3 精读unstated我们再梳理一下 unstated 这个库做了哪些事情。 利用 Provider 申明作用范围。 提供 Container 作为可以被继承的类,继承它的 Class 作为 Store。 提供 Subscribe 作为 RenderProps 用法注入 Store,注入的 Store 实例由参数 to 接收到的 Class 实例决定。 对于第一点,Provider 在 Class Component 环境下要初始化 StateContext,这样才能在 Subscribe 中使用: const StateContext = createReactContext(null);export function Provider(props) { return ( <StateContext.Consumer> {parentMap => { let childMap = new Map(parentMap); if (props.inject) { props.inject.forEach(instance => { childMap.set(instance.constructor, instance); }); } return ( <StateContext.Provider value={childMap}> {props.children} </StateContext.Provider> ); }} </StateContext.Consumer> );} 对于第二点,对于 Container,需要提供给 Store setState API,按照 React 的 setState 结构实现了一遍。 值得注意的是,还存储了一个 _listeners 对象,并且可通过 subscribe 与 unsubscribe 增删。 _listeners 存储的其实是当前绑定的组件 onUpdate 生命周期,然后在 setState 时主动触发对应组件的渲染。onUpdate 生命周期由 Subscribe 函数提供,最终调用的是 this.setState,这个在 Subscribe 部分再说明。 以下是 Container 的代码实现: export class Container<State: {}> { state: State; _listeners: Array<Listener> = []; constructor() { CONTAINER_DEBUG_CALLBACKS.forEach(cb => cb(this)); } setState( updater: $Shape<State> | ((prevState: $Shape<State>) => $Shape<State>), callback?: () => void ): Promise<void> { return Promise.resolve().then(() => { let nextState; if (typeof updater === "function") { nextState = updater(this.state); } else { nextState = updater; } if (nextState == null) { if (callback) callback(); return; } this.state = Object.assign({}, this.state, nextState); let promises = this._listeners.map(listener => listener()); return Promise.all(promises).then(() => { if (callback) { return callback(); } }); }); } subscribe(fn: Listener) { this._listeners.push(fn); } unsubscribe(fn: Listener) { this._listeners = this._listeners.filter(f => f !== fn); }} 对于第三点,Subscribe 的 render 函数将 this.props.children 作为一个函数执行,并把对应的 Store 实例作为参数传递,这通过 _createInstances 函数实现。 _createInstances 利用 instanceof 通过 Class 类找到对应的实例,并通过 subscribe 将自己组件的 onUpdate 函数传递给对应 Store 的 _listeners,在解除绑定时调用 unsubscribe 解绑,防止不必要的 renrender。 以下是 Subscribe 源码: export class Subscribe<Containers: ContainersType> extends React.Component< SubscribeProps<Containers>, SubscribeState> { state = {}; instances: Array<ContainerType> = []; unmounted = false; componentWillUnmount() { this.unmounted = true; this._unsubscribe(); } _unsubscribe() { this.instances.forEach(container => { container.unsubscribe(this.onUpdate); }); } onUpdate: Listener = () => { return new Promise(resolve => { if (!this.unmounted) { this.setState(DUMMY_STATE, resolve); } else { resolve(); } }); }; _createInstances( map: ContainerMapType | null, containers: ContainersType ): Array<ContainerType> { this._unsubscribe(); if (map === null) { throw new Error( "You must wrap your <Subscribe> components with a <Provider>" ); } let safeMap = map; let instances = containers.map(ContainerItem => { let instance; if ( typeof ContainerItem === "object" && ContainerItem instanceof Container ) { instance = ContainerItem; } else { instance = safeMap.get(ContainerItem); if (!instance) { instance = new ContainerItem(); safeMap.set(ContainerItem, instance); } } instance.unsubscribe(this.onUpdate); instance.subscribe(this.onUpdate); return instance; }); this.instances = instances; return instances; } render() { return ( <StateContext.Consumer> {map => this.props.children.apply( null, this._createInstances(map, this.props.to) ) } </StateContext.Consumer> ); }} 总结下来,unstated 将 State 外置是通过自定义 Listener 实现的,在 Store setState 时触发收集好的 Subscribe 组件的 rerender。 unstated-nextunstated-next 这个库只做了一件事情: 提供 createContainer 将自定义 Hooks 封装为一个数据对象,提供 Provider 注入与 useContainer 获取 Store 这两个方法。 正如之前解析所说,unstated-next 可谓将 Hooks 用到了极致,认为 Hooks 已经完全具备数据流管理的全部能力,我们只要包装一层规范即可: export function createContainer(useHook) { let Context = React.createContext(null); function Provider(props) { let value = useHook(props.initialState); return <Context.Provider value={value}>{props.children}</Context.Provider>; } function useContainer() { let value = React.useContext(Context); if (value === null) { throw new Error("Component must be wrapped with <Container.Provider>"); } return value; } return { Provider, useContainer };} 可见,Provider 就是对 value 进行了约束,固化了 Hooks 返回的 value 直接作为 value 传递给 Context.Provider 这个规范。 而 useContainer 就是对 React.useContext(Context) 的封装。 真的没有其他逻辑了。 唯一需要思考的是,在自定义 Hooks 中,我们用 useState 管理数据还是 useReducer 管理数据的问题,这个是个仁者见仁的问题。不过我们可以对自定义 Hooks 进行嵌套封装,支持一些更复杂的数据场景,比如: function useCounter(initialState = 0) { const [count, setCount] = useState(initialState); const decrement = () => setCount(count - 1); const increment = () => setCount(count + 1); return { count, decrement, increment };}function useUser(initialState = {}) { const [name, setName] = useState(initialState.name); const [age, setAge] = useState(initialState.age); const registerUser = userInfo => { setName(userInfo.name); setAge(userInfo.age); }; return { user: { name, age }, registerUser };}function useApp(initialState) { const { count, decrement, increment } = useCounter(initialState.count); const { user, registerUser } = useUser(initialState.user); return { count, decrement, increment, user, registerUser };}const App = createContainer(useApp); 4 总结借用 unstated-next 的标语:“never think about React state management libraries ever again” - 用了 unstated-next 再也不要考虑其他 React 状态管理库了。 而有意思的是,unstated-next 本身也只是对 Hooks 的一种模式化封装,Hooks 已经能很好解决状态管理的问题,我们真的不需要 “再造” React 数据流工具了。 讨论地址是:精读《unstated 与 unstated-next 源码》 · Issue ##218 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《vue-lit 源码》","path":"/wiki/WebWeekly/源码解读/《vue-lit 源码》.html","content":"当前期刊数: 229 vue-lit 基于 lit-html + @vue/reactivity 仅用 70 行代码就给模版引擎实现了 Vue Composition API,用来开发 web component。 概述<my-component></my-component><script type="module"> import { defineComponent, reactive, html, onMounted, onUpdated, onUnmounted } from 'https://unpkg.com/@vue/lit' defineComponent('my-component', () => { const state = reactive({ text: 'hello', show: true }) const toggle = () => { state.show = !state.show } const onInput = e => { state.text = e.target.value } return () => html` <button @click=${toggle}>toggle child</button> <p> ${state.text} <input value=${state.text} @input=${onInput}> </p> ${state.show ? html`<my-child msg=${state.text}></my-child>` : ``} ` }) defineComponent('my-child', ['msg'], (props) => { const state = reactive({ count: 0 }) const increase = () => { state.count++ } onMounted(() => { console.log('child mounted') }) onUpdated(() => { console.log('child updated') }) onUnmounted(() => { console.log('child unmounted') }) return () => html` <p>${props.msg}</p> <p>${state.count}</p> <button @click=${increase}>increase</button> ` })</script> 上面定义了 my-component 与 my-child 组件,并将 my-child 作为 my-component 的默认子元素。 import { defineComponent, reactive, html, onMounted, onUpdated, onUnmounted} from 'https://unpkg.com/@vue/lit' defineComponent 定义 custom element,第一个参数是自定义 element 组件名,必须遵循原生 API customElements.define 对组件名的规范,组件名必须包含中划线。 reactive 属于 @vue/reactivity 提供的响应式 API,可以创建一个响应式对象,在渲染函数中调用时会自动进行依赖收集,这样在 Mutable 方式修改值时可以被捕获,并自动触发对应组件的重渲染。 html 是 lit-html 提供的模版函数,通过它可以用 Template strings 原生语法描述模版,是一个轻量模版引擎。 onMounted、onUpdated、onUnmounted 是基于 web component lifecycle 创建的生命周期函数,可以监听组件创建、更新与销毁时机。 接下来看 defineComponent 的内容: defineComponent('my-component', () => { const state = reactive({ text: 'hello', show: true }) const toggle = () => { state.show = !state.show } const onInput = e => { state.text = e.target.value } return () => html` <button @click=${toggle}>toggle child</button> <p> ${state.text} <input value=${state.text} @input=${onInput}> </p> ${state.show ? html`<my-child msg=${state.text}></my-child>` : ``} `}) 借助模版引擎 lit-html 的能力,可以同时在模版中传递变量与函数,再借助 @vue/reactivity 能力,让变量变化时生成新的模版,更新组件 dom。 精读阅读源码可以发现,vue-lit 巧妙的融合了三种技术方案,它们配合方式是: 使用 @vue/reactivity 创建响应式变量。 利用模版引擎 lit-html 创建使用了这些响应式变量的 HTML 实例。 利用 web component 渲染模版引擎生成的 HTML 实例,这样创建的组件具备隔离能力。 其中响应式能力与模版能力分别是 @vue/reactivity、lit-html 这两个包提供的,我们只需要从源码中寻找剩下的两个功能:如何在修改值后触发模版刷新,以及如何构造生命周期函数的。 首先看如何在值修改后触发模版刷新。以下我把与重渲染相关代码摘出来了: import { effect} from 'https://unpkg.com/@vue/reactivity/dist/reactivity.esm-browser.js'customElements.define( name, class extends HTMLElement { constructor() { super() const template = factory.call(this, props) const root = this.attachShadow({ mode: 'closed' }) effect(() => { render(template(), root) }) } }) 可以清晰的看到,首先 customElements.define 创建一个原生 web component,并利用其 API 在初始化时创建一个 closed 节点,该节点对外部 API 调用关闭,即创建的是一个不会受外部干扰的 web component。 然后在 effect 回调函数内调用 html 函数,即在使用文档里返回的模版函数,由于这个模版函数中使用的变量都采用 reactive 定义,所以 effect 可以精准捕获到其变化,并在其变化后重新调用 effect 回调函数,实现了 “值变化后重渲染” 的功能。 然后看生命周期是如何实现的,由于生命周期贯穿整个实现流程,因此必须结合全量源码看,下面贴出全量核心代码,上面介绍过的部分可以忽略不看,只看生命周期的实现: let currentInstanceexport function defineComponent(name, propDefs, factory) { if (typeof propDefs === 'function') { factory = propDefs propDefs = [] } customElements.define( name, class extends HTMLElement { constructor() { super() const props = (this._props = shallowReactive({})) currentInstance = this const template = factory.call(this, props) currentInstance = null this._bm && this._bm.forEach((cb) => cb()) const root = this.attachShadow({ mode: 'closed' }) let isMounted = false effect(() => { if (isMounted) { this._bu && this._bu.forEach((cb) => cb()) } render(template(), root) if (isMounted) { this._u && this._u.forEach((cb) => cb()) } else { isMounted = true } }) } connectedCallback() { this._m && this._m.forEach((cb) => cb()) } disconnectedCallback() { this._um && this._um.forEach((cb) => cb()) } attributeChangedCallback(name, oldValue, newValue) { this._props[name] = newValue } } )}function createLifecycleMethod(name) { return (cb) => { if (currentInstance) { ;(currentInstance[name] || (currentInstance[name] = [])).push(cb) } }}export const onBeforeMount = createLifecycleMethod('_bm')export const onMounted = createLifecycleMethod('_m')export const onBeforeUpdate = createLifecycleMethod('_bu')export const onUpdated = createLifecycleMethod('_u')export const onUnmounted = createLifecycleMethod('_um') 生命周期实现形如 this._bm && this._bm.forEach((cb) => cb()),之所以是循环,是因为比如 onMount(() => cb()) 可以注册多次,因此每个生命周期都可能注册多个回调函数,因此遍历将其依次执行。 而生命周期函数还有一个特点,即并不分组件实例,因此必须有一个 currentInstance 标记当前回调函数是在哪个组件实例注册的,而这个注册的同步过程就在 defineComponent 回调函数 factory 执行期间,因此才会有如下的代码: currentInstance = thisconst template = factory.call(this, props)currentInstance = null 这样,我们就将 currentInstance 始终指向当前正在执行的组件实例,而所有生命周期函数都是在这个过程中执行的,因此当调用生命周期回调函数时,currentInstance 变量必定指向当前所在的组件实例。 接下来为了方便,封装了 createLifecycleMethod 函数,在组件实例上挂载了一些形如 _bm、_bu 的数组,比如 _bm 表示 beforeMount,_bu 表示 beforeUpdate。 接下来就是在对应位置调用对应函数了: 首先在 attachShadow 执行之前执行 _bm - onBeforeMount,因为这个过程确实是准备组件挂载的最后一步。 然后在 effect 中调用了两个生命周期,因为 effect 会在每次渲染时执行,所以还特意存储了 isMounted 标记是否为初始化渲染: effect(() => { if (isMounted) { this._bu && this._bu.forEach((cb) => cb()) } render(template(), root) if (isMounted) { this._u && this._u.forEach((cb) => cb()) } else { isMounted = true }}) 这样就很容易看懂了,只有初始化渲染过后,从第二次渲染开始,在执行 render(该函数来自 lit-html 渲染模版引擎)之前调用 _bu - onBeforeUpdate,在执行了 render 函数后调用 _u - onUpdated。 由于 render(template(), root) 根据 lit-html 的语法,会直接把 template() 返回的 HTML 元素挂载到 root 节点,而 root 就是这个 web component attachShadow 生成的 shadow dom 节点,因此这句话执行结束后渲染就完成了,所以 onBeforeUpdate 与 onUpdated 一前一后。 最后几个生命周期函数都是利用 web component 原生 API 实现的: connectedCallback() { this._m && this._m.forEach((cb) => cb())}disconnectedCallback() { this._um && this._um.forEach((cb) => cb())} 分别实现 mount、unmount。这也说明了浏览器 API 分层的清晰之处,只提供创建和销毁的回调,而更新机制完全由业务代码实现,不管是 @vue/reactivity 的 effect 也好,还是 addEventListener 也好,都不关心,所以如果在这之上做完整的框架,需要自己根据实现 onUpdate 生命周期。 最后的最后,还利用 attributeChangedCallback 生命周期监听自定义组件 html attribute 的变化,然后将其直接映射到对 this._props[name] 的变化,这是为什么呢? attributeChangedCallback(name, oldValue, newValue) { this._props[name] = newValue} 看下面的代码片段就知道原因了: const props = (this._props = shallowReactive({}))const template = factory.call(this, props)effect(() => { render(template(), root)}) 早在初始化时,就将 _props 创建为响应式变量,这样只要将其作为 lit-html 模版表达式的参数(对应 factory.call(this, props) 这段,而 factory 就是 defineComponent('my-child', ['msg'], (props) => { .. 的第三个参数),这样一来,只要这个参数变化了就会触发子组件的重渲染,因为这个 props 已经经过 Reactive 处理了。 总结vue-lit 实现非常巧妙,学习他的源码可以同时了解一下几种概念: reative。 web component。 string template。 模版引擎的精简实现。 生命周期。 以及如何将它们串起来,利用 70 行代码实现一个优雅的渲染引擎。 最后,用这种模式创建的 web component 引入的 runtime lib 在 gzip 后只有 6kb,但却能享受到现代化框架的响应式开发体验,如果你觉得这个 runtime 大小可以忽略不计,那这就是一个非常理想的创建可维护 web component 的 lib。 讨论地址是:精读《vue-lit 源码》· Issue ##396 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《zustand 源码》","path":"/wiki/WebWeekly/源码解读/《zustand 源码》.html","content":"当前期刊数: 227 zustand 是一个非常时髦的状态管理库,也是 2021 年 Star 增长最快的 React 状态管理库。它的理念非常函数式,API 设计的很优雅,值得学习。 概述首先介绍 zustand 的使用方法。 创建 store通过 create 函数创建 store,回调可拿到 get set 就类似 Redux 的 getState 与 setState,可以获取 store 瞬时值与修改 store。返回一个 hook 可以在 React 组件中访问 store。 import create from 'zustand'const useStore = create((set, get) => ({ bears: 0, increasePopulation: () => set(state => ({ bears: state.bears + 1 })), removeAllBears: () => set({ bears: 0 })})) 上面例子是全局唯一的 store,也可以通过 createContext 方式创建多实例 store,结合 Provider 使用: import create from 'zustand'import createContext from 'zustand/context'const { Provider, useStore } = createContext()const createStore = () => create(...)const App = () => ( <Provider createStore={createStore}> ... </Provider>) 访问 store通过 useStore 在组件中访问 store。与 redux 不同的是,无论普通数据还是函数都可以存在 store 里,且函数也通过 selector 语法获取。因为函数引用不可变,所以实际上下面第二个例子不会引发重渲染: function BearCounter() { const bears = useStore(state => state.bears) return <h1>{bears} around here ...</h1>}function Controls() { const increasePopulation = useStore(state => state.increasePopulation) return <button onClick={increasePopulation}>one up</button>} 如果嫌访问变量需要调用多次 useStore 麻烦,可以自定义 compare 函数返回一个对象: const { nuts, honey } = useStore(state => ({ nuts: state.nuts, honey: state.honey }), shallow) 细粒度 memo利用 useCallback 甚至可以跳过普通 compare,而仅关心外部 id 值的变化,如: const fruit = useStore(useCallback(state => state.fruits[id], [id])) 原理是 id 变化时,useCallback 返回值才会变化,而 useCallback 返回值如果不变,useStore 的 compare 函数引用对比就会为 true,非常巧妙。 set 合并与覆盖set 函数第二个参数默认为 false,即合并值而非覆盖整个 store,所以可以利用这个特性清空 store: const useStore = create(set => ({ salmon: 1, tuna: 2, deleteEverything: () => set({ }, true), // clears the entire store, actions included})) 异步所有函数都支持异步,因为修改 store 并不依赖返回值,而是调用 set,所以是否异步对数据流框架来说都一样。 监听指定变量还是用英文比较表意,即 subscribeWithSelector,这个中间件可以让我们把 selector 用在 subscribe 函数上,相比于 redux 传统的 subscribe,就可以有针对性的监听了: import { subscribeWithSelector } from 'zustand/middleware'const useStore = create(subscribeWithSelector(() => ({ paw: true, snout: true, fur: true })))// Listening to selected changes, in this case when "paw" changesconst unsub2 = useStore.subscribe(state => state.paw, console.log)// Subscribe also exposes the previous valueconst unsub3 = useStore.subscribe(state => state.paw, (paw, previousPaw) => console.log(paw, previousPaw))// Subscribe also supports an optional equality functionconst unsub4 = useStore.subscribe(state => [state.paw, state.fur], console.log, { equalityFn: shallow })// Subscribe and fire immediatelyconst unsub5 = useStore.subscribe(state => state.paw, console.log, { fireImmediately: true }) 后面还有一些结合中间件、immer、localstorage、redux like、devtools、combime store 就不细说了,都是一些细节场景。值得一提的是,所有特性都是正交的。 精读其实大部分使用特性都在利用 React 语法,所以可以说 50% 的特性属于 React 通用特性,只是写在了 zustand 文档里,看上去像是 zustand 的特性,所以这个库真的挺会借力的。 创建 store 实例任何数据流管理工具,都有一个最核心的 store 实例。对 zustand 来说,便是定义在 vanilla.ts 文件的 createStore 了。 createStore 返回一个类似 redux store 的数据管理实例,拥有四个非常常见的 API: export type StoreApi<T extends State> = { setState: SetState<T> getState: GetState<T> subscribe: Subscribe<T> destroy: Destroy} 首先 getState 的实现: const getState: GetState<TState> = () => state 就是这么简单粗暴。再看 state,就是一个普通对象: let state: TState 这就是数据流简单的一面,没有魔法,数据存储用一个普通对象,仅此而已。 接着看 setState,它做了两件事,修改 state 并执行 listenser: const setState: SetState<TState> = (partial, replace) => { const nextState = typeof partial === 'function' ? partial(state) : partial if (nextState !== state) { const previousState = state state = replace ? (nextState as TState) : Object.assign({}, state, nextState) listeners.forEach((listener) => listener(state, previousState)) }} 修改 state 也非常简单,唯一重要的是 listener(state, previousState),那么这些 listeners 是什么时候注册和声明的呢?其实 listeners 就是一个 Set 对象: const listeners: Set<StateListener<TState>> = new Set() 注册和销毁时机分别是 subscribe 与 destroy 函数调用时,这个实现很简单、高效。对应代码就不贴了,很显然,subscribe 时注册的监听函数会作为 listener 添加到 listeners 队列中,当发生 setState 时便会被调用。 最后我们看 createStore 的定义与结尾: function createStore(createState) { let state: TState const setState = /** ... */ const getState = /** ... */ /** ... */ const api = { setState, getState, subscribe, destroy } state = createState(setState, getState, api) return api} 虽然这个 state 是个简单的对象,但回顾使用文档,我们可以在 create 创建 store 利用 callback 对 state 赋值,那个时候的 set、get、api 就是上面代码倒数第二行传入的: import { create } from 'zustand'const useStore = create((set, get) => ({ bears: 0, increasePopulation: () => set(state => ({ bears: state.bears + 1 })), removeAllBears: () => set({ bears: 0 })})) 至此,初始化 store 的所有 API 的来龙去脉就梳理清楚了,逻辑简单清晰。 create 函数的实现上面我们说清楚了如何创建 store 实例,但这个实例是底层 API,使用文档介绍的 create 函数在 react.ts 文件定义,并调用了 createStore 创建框架无关数据流。之所 create 定义在 react.ts,是因为返回的 useStore 是一个 Hooks,所以本身具有 React 环境特性,因此得名。 该函数第一行就调用 createStore 创建基础 store,因为对框架来说是内部 API,所以命名也叫 api: const api: CustomStoreApi = typeof createState === 'function' ? createStore(createState) : createStateconst useStore: any = <StateSlice>( selector: StateSelector<TState, StateSlice> = api.getState as any, equalityFn: EqualityChecker<StateSlice> = Object.is) => /** ... */ 接下来所有代码都在创建 useStore 这个函数,我们看下其内部实现: 简单来说就是利用 subscribe 监听变化,并在需要的时候强制刷新当前组件,并传入最新的 state 给到 useStore。所以第一步当然是创建 forceUpdate 函数: const [, forceUpdate] = useReducer((c) => c + 1, 0) as [never, () => void] 然后通过调用 API 拿到 state 并传给 selector,并调用 equalityFn(这个函数可以被定制)判断状态是否发生了变化: const state = api.getState()newStateSlice = selector(state)hasNewStateSlice = !equalityFn( currentSliceRef.current as StateSlice, newStateSlice) 如果状态变化了,就更新 currentSliceRef.current: useIsomorphicLayoutEffect(() => { if (hasNewStateSlice) { currentSliceRef.current = newStateSlice as StateSlice } stateRef.current = state selectorRef.current = selector equalityFnRef.current = equalityFn erroredRef.current = false}) useIsomorphicLayoutEffect 是同构框架常用 API 套路,在前端环境是 useLayoutEffect,在 node 环境是 useEffect: 说明一下 currentSliceRef 与 newStateSlice 的功能。我们看 useStore 最后的返回值: const sliceToReturn = hasNewStateSlice ? (newStateSlice as StateSlice) : currentSliceRef.currentuseDebugValue(sliceToReturn)return sliceToReturn 发现逻辑是这样的:如果 state 变化了,则返回新的 state,否则返回旧的,这样可以保证 compare 函数判断相等时,返回对象的引用完全相同,这个是不可变数据的核心实现。另外我们也可以学习到阅读源码的技巧,即要经常跳读。 那么如何在 selector 变化时更新 store 呢?中间还有一段核心代码,调用了 subscribe,相信你已经猜到了,下面是核心代码片段: useIsomorphicLayoutEffect(() => { const listener = () => { try { const nextState = api.getState() const nextStateSlice = selectorRef.current(nextState) if (!equalityFnRef.current(currentSliceRef.current as StateSlice, nextStateSlice)) { stateRef.current = nextState currentSliceRef.current = nextStateSlice forceUpdate() } } catch (error) { erroredRef.current = true forceUpdate() } } const unsubscribe = api.subscribe(listener) if (api.getState() !== stateBeforeSubscriptionRef.current) { listener() // state has changed before subscription } return unsubscribe}, []) 这段代码要先从 api.subscribe(listener) 看,这使得任何 setState 都会触发 listener 的执行,而 listener 利用 api.getState() 拿到最新 state,并拿到上一次的 compare 函数 equalityFnRef 执行一下判断值前后是否发生了改变,如果改变则更新 currentSliceRef 并进行一次强制刷新(调用 forceUpdate)。 context 的实现注意到 context 语法,可以创建多个互不干扰的 store 实例: import create from 'zustand'import createContext from 'zustand/context'const { Provider, useStore } = createContext()const createStore = () => create(...)const App = () => ( <Provider createStore={createStore}> ... </Provider>) 首先我们知道 create 创建的 store 是实例间互不干扰的,问题是 create 返回的 useStore 只有一个实例,也没有 <Provider> 声明作用域,那么如何构造上面的 API 呢? 首先 Provider 存储了 create 返回的 useStore: const storeRef = useRef<TUseBoundStore>()storeRef.current = createStore() 那么 useStore 本身其实并不实现数据流功能,而是将 <Provider> 提供的 storeRef 拿到并返回: const useStore: UseContextStore<TState> = <StateSlice>( selector?: StateSelector<TState, StateSlice>, equalityFn = Object.is) => { const useProviderStore = useContext(ZustandContext) return useProviderStore( selector as StateSelector<TState, StateSlice>, equalityFn )} 所以核心逻辑还是是现在 create 函数里,context.ts 只是利用 ReactContext 将 useStore “注入” 到组件,且利用 ReactContext 特性,这个注入可以存在多个实例,且不会相互影响。 中间件中间件其实不需要怎么实现。比如看这个 redux 中间件的例子: import { redux } from 'zustand/middleware'const useStore = create(redux(reducer, initialState)) 可以将 zustand 用法改变为 reducer,实际上是利用了函数式理念,redux 函数本身可以拿到 set, get, api,如果想保持 API 不变,则原样返回 callback 就行了,如果想改变用法,则返回特定的结构,就是这么简单。 为了加深理解,我们看看 redux 中间件源码: export const redux = ( reducer, initial ) => ( set, get, api ) => { api.dispatch = action => { set(state => reducer(state, action), false, action) return action } api.dispatchFromDevtools = true return { dispatch: (...a) => api.dispatch(...a), ...initial }} 将 set, get, api 封装为 redux API:dispatch 本质就是调用 set。 总结zustand 是一个实现精巧的 React 数据流管理工具,自身框架无关的分层合理,中间件实现巧妙,值得学习。 讨论地址是:精读《zustand 源码》· Issue ##392 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《12 个评估 JS 库你需要关心的事》","path":"/wiki/WebWeekly/前沿技术/《12 个评估 JS 库你需要关心的事》.html","content":"当前期刊数: 74 1 引言作者给出了从 12 个角度全面分析 JS 库的可用性,分别是: 特性。 稳定性。 性能。 包生态。 社区。 学习曲线。 文档。 工具。 发展历史。 团队。 兼容性。 趋势。 下面总结一下作者的观点。 2 概述 & 精读特性当你调研一个 JS 库,功能当然是最重要的,就好比 React 的用于开发 UI 界面非常方便,这是流行起来的一部分因素。 但同时 React 解决的问题很聚焦,于是把例如 Router 和 Store 部分交给社区给解决方案,这就让 Vue 的官方维护生态模式发展了起来。但这更多取决于你的偏好,像 lodash 这种精简的库也会长盛不衰,重要的是这个库提供的能力是否解决了你的业务问题。 评分:A - 化腐朽为神奇。B - 更优雅的解决方案。C - 比现有方案差。 稳定性这个库如果经常出 BUG,那显然无法在生产环境使用。最好经过严格的测试,保证这个库一定不会出错,这样我们就可以专心排查业务的问题了。 评分:A - BUG 很少,方便调试。B - 不会影响你的稳定性,比如出 BUG 概率和你的业务代码相近。C - 引入该库会让你背线上故障。 性能如果让用户 15 秒才能打开网页,那一切都是徒劳。 拿 PReact 为例子,为什么 API 相同的轮子可以活下来?因为体积小,而且 PReact 把宣传重点放在性能上。 如何一句话说明白你不是在造无用的轮子?性能更好。 评分:A - 小体积,高性能,支持各种黑科技特性比如 Tree shaking。B - 对性能没有影响。C - 导致性能降低。 包生态用过 monaco-editor 吗?大家都在用 webpack 但它却走 amd 路线,我不知道你用什么方法让它支持 commonjs 的,但这一定耽误了你不少时间。 包生态包括第三方包的成熟度,包的使用难易度,支持多少种模块化方案,是否支持 TS,有没有管理好自己的依赖等等。 开箱即用是最好的,有长期维护组织的更佳。 同时不要有太多相互竞争的社区方案为佳。比如工具库用 lodash 这很容易,但 React 数据流方案选择哪个?太多的竞争对手不断写软文抢夺用户(程序员)的注意力,试图说服他们加班重构。 评分:A - 方案唯一且生态运作良好,维护记录标准规范且顺畅。B - 很多新晋网红包,且竞争选择多。C - 没有人给你做包,想用要自己封装。 社区能否快速在 Stack Overflow 搜到问题的答案能反映出社区的活跃度,不论是官方文档还是第三方进行的问答。 社区越活跃,帮你提前踩的坑就越多,如果你遇到一个大家都没有遇到过的问题,并不代表你用得有多深度,而可能你根本就用错库了。 评分:A - 各种论坛每日都很活跃,Github issue 问题日清。B - 论坛/聊天室不太活跃。C - 除了作者自吹的文档,再也找不到任何相关信息了。 学习曲线不要以为把库功能做的强大,就算难用点也会有用户跪舔,这是幻觉。 Vue 之所以那么火爆,是因为原生 HTML 的门槛比 JSX 低,而使用 React 的用户往往都觉得 JSX 比 HTML 门槛低。我也不知道该怎么描述,从 JS 可以产生一切的角度,学习 HTML 反而被认为是高门槛的体现。 所以认清现实,JSX Star 多并不是其理论有多先进(理论确实先进),而是很多人觉得整体学习维护成本比 HTML 低。 评分:A - 一天就能成为这个库的熟练搬砖工。B - 浪费了一周时间才能投入使用。C - 学了一周才发现之前的理解是错的,而且认识到这只是个开始。 文档写文档的人一般都是库的作者,这种人一般经验会比较丰富,写起文档一般不会考虑初学者的感受,所以找到一份对初学者友好的文档还是挺不容易的。 对于库的维护者,要站在初学者角度去写文档,站在使用者角度,如果文档开头就看不懂的话,最好尽早换个文档或者换个库。 评分:A - 专门维护文档站点、视频、图片、示例项目,再好一点的话可以有专门基金会组织编程比赛,通过某三岁孩子可以一天入门强力影射技术生态的完备性。B - 有最基本的 Readme 和 API 文档。C - Readme 写的是 Create react app,其他的只能查源码了。 工具工具可以从多个维度体现出这个库的优势,首先是确实带来了使用方便,其次展示了团队维护实力的雄厚(精力溢出到可以做周边工具了)。 Redux 之所以这么火,Redux dev tools 功不可没,笔者读过一些心理学书籍,也经历过一些技术选型,看到 Redux dev tools 的图形化界面后,大脑因为受到视觉冲击比理性的逻辑思考大太多,潜意识里给 Redux 加了不少分,导致讨论结果都变得不太理性了。 如果你的库能图形化表达,或者做一个 PPT 或者辅助工具,那一定会大大加分。(React chrome 插件在打开 react 做的网页时亮起来真的很酷,这个勋章很有仪式感,以至于我不想换一个框架) 评分:A - 两个以上的工具,包括浏览器拓展、代码编辑器拓展、CLI 工具或者 SaaS 服务,实力碾压的话,会有许多花哨的辅助工具出现。B - 一个工具。C - 没有工具。 发展历史一个 Star 10K 的库,如果最早提交是十天前,就算不是刷的也最好也不要用,因为不知道哪天作者就不再维护了。 历史越悠久的库使用风险越小,除非它所在的面被淘汰(技术栈、生态、编程语言等等)。 评分:A - 4 年以上历史,有权威认证。B - 1-4 年历史,已经有不少人使用过了。C - 作者自己都没用过就安利你用到线上去。 团队看谁是这个库背后的男人。大公司广泛使用的开源库,并且有一定国际影响力,而且大厂也有成功开源历史经验的话,就会增加说服力。 但 Vue 就是个例外,几乎凭尤大一人之力打造,对这种情况,笔者想说的是,一个真心热爱技术并践行全职维护的人,也许比一个背着 KPI 的团队维护副产品更靠谱。 评分:A - 一线大厂,品质权威认证。B - 中型团队维护,并且有清晰的分工记录。C - 工作之余顺便开源出去,就没打算对这个库负责。 兼容性除了浏览器兼容性,库 API 的兼容性也非常重要。当你很容易联系到作者,并且改动 API 的建议被很快采纳时,你就要小心了。 React Router 3 -> 4 升级带来的阵痛大家都有体会过,babel7 放弃 stage 0-4 也带来不少吐槽,Angular1 和 Angular2 的区分直接让很多人粉转黑了。虽然许多时候频繁的更新是为了增添新功能,但如果带来 API 兼容问题,反而会招来反感。 假如你们团队维护的 10 年间,因为某个库作者非常勤奋的更新导致以时间为维度,均匀分布了数十种不同的版本,你会发誓下一个项目不再使用这个库了。 评分:A - 总是能兼容升级,实在不行就提前警告并告知在某个版本会废弃,并提供迁移工具,比如 React。B - 有 Break Change 但是文档把升级改动写的很清楚。C - 突然到来的小版本升级让你不得不重构之前的调用代码。 趋势炒作也好,讨论也好,保持大家对这个库的新鲜关注非常重要,因为这能连带的让这个库做好上面说的很多点。 但注意过分的炒作,可能会降低这个库的稳定性,毕竟在用户爆发式增长之前,最好有一部分当小白鼠。 评分:A - 是 HackNews 的明星话题,Star 成千上万,各种会议以此为名(Vue conf,React conf)。B - 几百 Star,有一些讨论。C - 别看现在 Star 少,迟早有一天我会超过那啥那啥。 搬家成本这个是作者补充的比较重要的一天:如果哪天不用这个库了,换成别的成本有多大? 这方面测试库做的很好,很多主流测试库比如 Jest、Ava、Mocha、Jasmine 等之间都有互转的脚本,业界基本达成了一些共识和规范。 比较坑的是 React、Vue、Angluar,使用之后你基本就被绑定了,至今没有谁可以无缝做各大框架的迁移。当然 JS 的年龄还很短,而且说不好未来还会被新语言、技术、容器颠覆而成为历史,标准化不是做不到而是需要时间,也许就在十几年之后,但是今天就是做不到。 3 总结下次技术选型讨论时,可以拿出规则一条一条比对了! 然后技术选型只是基础库,利用这些基础可以维护好自己的开源库,把更多时间用在创造业务价值上。 仔细思考就会发现,程序员开发的工具库也适合点线面体的概念。一个库 react-button 就是一个点,而它所在的线 react 如果被人抛弃了,无数个 react-xxx 也会翻船。而 react、vue、angluar 这些线都在 js 引擎这个面上,当可以用 C## 写 WebAssembly 时,Reason、Blazor、Dart 就会逐渐成为浏览器的主角,react 之类的库统统要回炉打造。而当未来人机互联不需要浏览器作为媒介时,js 引擎这个面依附的体 - 人机交互场景也被打翻了,这一浪又会引起多大的变化。 所以技术选型是为了解决当下业务问题,仔细考虑好几个因素,适合解决业务场景就足够了。 4 更多讨论 讨论地址是:精读《12 个评估 JS 库你需要关心的事》 · Issue ##104 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《15 大 LOD 表达式 - 上》","path":"/wiki/WebWeekly/前沿技术/《15 大 LOD 表达式 - 上》.html","content":"当前期刊数: 216 通过上一篇 精读《什么是 LOD 表达式》 的学习,你已经理解了什么是 LOD 表达式。为了巩固理解,结合场景复习是最有效的手段,所以这次我们结合 Top 15 LOD Expressions 这篇文章学习 LOD 表达式的 15 大应用场景,因篇幅限制,本文介绍 1~8 场景。 1. 客户下单频次各下单次数的顾客数量是多少? 柱状图的 Y 轴显然是 count([customerID]),因为要统计 当前维度下的客户总数。 这里插一句,对于柱状图的 Y 轴,在 sql 里就是对 X 轴 group by 后的聚合,因此 Y 轴就是对 X 轴各项的汇总。 柱状图的 X 轴要表达的是以何种粒度拆解,比如我们是看各城市数据,还是看各省数据。在这个场景下也不例外,我们要看 各下单次数下的数据,那么如何把下单次数转化为维度呢? 我们需要用 FIX 表达式制作一个维度字段,表示各顾客下单次数。很显然数据库是没有这个维度的,而且这个维度需要按照客户 ID group by 后,按照订单 ID count 聚合才能得到,因此可以利用 FIX 表达式:{ fixed [customerID] : count([orderId]) } 描述。 2. 阵列分析当我们看年客户销售量时,即便是逐年增长的,我们也会有一个疑问:每年销量中,首单在各年份的顾客分别贡献了多少? 因为关系到老客忠诚度和新客拓展速度,新客与老客差距过大都不好,那我们如何让 2021 年的柱状图按照 2019、2020、2021 年首单的顾客分层呢?这就是阵列分析。 我们要画一个柱状图,X、Y 轴分别是 [Year]、sum([Sales])。 为了让柱状图分层,我们需要一个表示颜色图例的维度字段,比如我们拖入已有的性别维度,每根柱子就会被划分为男、女两块。但问题是,我们制作并不存在的 “首单年份维度”? 答案是利用 FIX 表达式:{ fixed [customerID] : min([orderDate]) }。 3. 日利润指标分析 每年各月份的盈利、亏损天数分布。如下图: 列是年到月的下钻,比较好实现,只要拖入字段 [year] 并下钻到月粒度,移除季度粒度即可。 行是 “高收益”、“正收益”、“亏损” 的透视图,值是在当前月份中天数。 那么如何计算高收益、亏损状态呢?因为最终粒度是天,所以我们要按天计,首先就要得到每天的利润总和,这些中间过程可以利用 LOD 的字段来完成,即创建一个 日利润字段(profitPerDay):{ fixed [orderDate] : sum([profit]) }。 由于我们对利润总量不敏感,只希望拆分为三个阶段,所以利用 IF THEN 生成一个新字段 日利润指标(dailyProfitKPI):IF [profitPerDay] > 2000 THEN "Highly Profitable" ELSEIF [profitPerDay] <= 0 THEN "unprofitable" ELSE "profitable" END。 所以创建的 [dailyProfitKPI] 指标是个维度,即如果当前行所在的天利润汇总如果大于 2000,值就是 “Highly Profitable”。所以在行上拖入 count(distinct [orderDate]),把 [dailyProfitKPI] 拖入行的颜色透视即可。 4. 占总体百分比LOD 表达式的一大特色就是计算跨详细级别的占比,比如我们要看 欧洲各国的销量在全世界占比: 显然这个图里所有国家之和不是 100%,因为欧洲加起来也才不到百分之二十,然而在当前详细级别下,是拿不到全球总销售量的,所以我们可以利用 FIX 表达式来实现:sum([sales]) / max({ sum([sales]) })。 这里解释两点: 之所以用 max 是因为 LOD 表达式只是一个字段,并没有聚合方式,运算必须在相同详细级别下进行,由于总销量只有一条数据,所以我们用 max 或者 min 甚至 sum 都行,结果都是一样的。 如果不加维度限制,就可以省略 “fix” 申明,所以 { sum([sales]) } 实际上就是 FIX 表达式,它表示 { fixed : sum([sales]) }。 5. 新客增长趋势看着年客户增长趋势图,你有没有想过,这个趋势图肯定永远是向上的?也就是说,看着趋势图朝上走,不一定说明业务做得好。 如果公司每年都比去年发展的好,每年的新增新客数应该要比去年多,所以 每年新客增长趋势图 才比较有意义,如果你看到这个趋势图的趋势朝上,说明每年的新客都比去年多,说明公司摆脱了惯性,每年都获得了新的增长。 所以我们要加一个筛选条件。新增一个维度字段,当这一单客户是今年新客时为 true,否则为 false,这样我们筛选时,只看这个字段为 true 的结果就行了。 那么这个字段怎么来呢?思路是,获取客户首单年份,如果首单年份与当前下单年份相同,值为 true,否则为 false。 我们利用 LOD 创建首单年份字段 [firstOrderDate]:{ fixed [customerId] : min([orderDate]) },然后创建筛选字段 [newOrExist]: IFF([firstOrderDate] = [orderDate], 'true', 'false')。 6. 销量对比分析入下图条形图所示,右侧是每项根据选择的分类的对比数据: 对比值计算方式是,用 当前的销量减去当前选中分类的销量。相信你可以猜到,但前分类的销量与当前视图详细级别无关,只与用户选择的 Category 有关。 如果我们已经有一个度量字段 - 选中分类销量 selectedSales,应该再排除当前 category 维度的干扰,所以可用 EXCLUDE 表达式描述 selectedCategorySales: { exclude [category] : sum([selectedSales]) }。 接下来是创建 selectedSales 字段。背景知识是 [parameters].[category] 可以获得当前选中的维度值,那我们可以写个 IF 表达式,在维度等于选中维度时聚合销量,不就是选中销量吗?所以公式是:IF [category] = [parameters].[category] THEN sales ELSE 0 END。 最后对比差异,只要创建一个 [diff] 字段,表达式为 sum(sales) - sum(selectedCategorySales) 即可。 7. 平均最高交易额如下图所示,当前的详细级别是国家,但我们却要展示每个国家平均最高交易额: 显然,要求平均最高交易额,首先要计算每个销售代表的最高交易额,由于这个详细级别比国家低,我们可以利用 INCLUDE 表达式计算销售代表最高交易额 largestSalesByRep: { include [salesRep] : max([sales]) },并对这个度量字段求平均即可。 从这个例子可以看出,如果我们在一个较高的详细级别,比如国家,此时的 sum([sales]) 是根据国家详细级别汇总的,而忽略了销售代表这个详细级别。但如果要展示每个国家的平均最高交易额,就必须在销售代表这个详细级别求 max([sales]),由于是各国家的,所以我们不用 { fixed [salesRep] },而是 { include [salesRep] },这样最终计算的详细级别是:[country],[salesRep],这样才能算出销售在每个国家的最高交易额(因为也许某些销售同时在不同国家销售)。 8. 实际与目标在第六个例子 - 销量对比分析中,我们可以看到销量绝对值的对比,这次,我们需要计算实际销售额与目标的差距百分比: 如上图所示,左上角展示了实际与目标的差值;右上角展示了每个地区产品目标完成率;下半部分展示了每个产品实际销量柱状图,并用黑色横线标记出目标值。 左上角非常简单,[diffActualTraget]: [profit] - [targetProfit],只要将当前利润与目标利润相减即可。 右上角需要分为几步拆解。我们的最终目标是计算每个地区产品目标完成率,显然公式是 当前完成产品数/总产品数。总产品数比较简单,在已有地区维度拆解下,计算下产品总数就行了,即 count(distinct [product]);难点是当前完成产品数,这里我们又要用到 INCLUDE,为什么呢?因为地区粒度比产品粒度高,我们看地区汇总的时候,就不知道各产品的完成情况了,所以必须 INCLUDE product 维度计算利润目标差,公式是 [diffProductActualTraget] :{ include [product] : sum(diffActualTraget) },然后当这个值大于 0 就认为完成了目标,我们可以再创建一个字段,即完成目标数,如果达成目标就是 1,否则是 0,这样便于求 “当前完成产品数”:aboveTargetProductCount: IFF([diffProductActualTraget] > 0, 1, 0),那么当前完成产品数就是 sum([diffProductActualTraget]),所以产品目标完成率就是 sum([diffProductActualTraget]) / count(distinct [product]),将这个字段拖入指标,按照百分比格式化,就得到结果了。 总结通过上面的例子,我们可以总结出实际业务场景中几条使用心法: 首先对计算公式进行拆解,判断拆解后的字段是否数据集里都有,如果都有的话就结束了,说明是个简单需求。 如果数据集里没有,而且发现数据详细级别与当前不符(比如要得到每个国家销量,但当前维度是城市),就要用 FIXED 表达式固定详细级别。 如果不是明确的按照某个详细级别计算,就不要使用 FIXED,因为不太灵活。 当计算时要跳过某个指定详细级别,但又要保留视图里的详细级别时,使用 EXCLUDE 表达式。 如果计算涉及到比视图低的详细级别,比如计算平均或者最大最小时,使用 INCLUDE 表达式。 使用 FIXED 表达式创建的字段也可以进行二次计算,合理拆解多个计算字段并组合,会让逻辑更加清晰,易于理解。 讨论地址是:精读《15 大 LOD 表达式 - 上》· Issue ##369 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《use-what-changed 源码》","path":"/wiki/WebWeekly/源码解读/《use-what-changed 源码》.html","content":"当前期刊数: 155 1 引言使用 React Hooks 的时候,经常出现执行次数过多甚至死循环的情况,我们可以利用 use-what-changed 进行依赖分析,找到哪个变量引用一直在变化。 据一个例子,比如你尝试在 Class 组件内部渲染 Function 组件,Class 组件是这么写的: class Parent extends React.PureComponent { state = { text: "text", }; render() { return <Child setText={(text) => this.setState({ text })} />; }} 子组件是这么写的: 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 使用方式如下: 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]);} 将参数像依赖数组一样传入,刷新页面就可以在控制台看到引用或值是否变化,如果变化,对应行会展示 ✅ 并打印出上次的值与当前值: 第一步是存储上一次依赖项的值,利用 useRef 实现: function useWhatChanged(dependency?: any[]) { const dependencyRef = React.useRef(dependency);} 然后利用 useEffect,对比 dependency 与 dependencyRef 的引用即可找到变化项: 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]); 直接对比 deps 引用,不想等则将 changed 设为 true。 调试模式下,利用 console.table 打印出表格。 依赖项是 dependency,当依赖项变化时才打印 whatChanged。 以上就是其源码的核心逻辑,当然我们还可以简化输出,仅当有引用变化时才打印表格,否则只输出简单的 Log 信息: if (isDevelopment) { if (changed) { console.table(whatChanged); } else { console.log(whatChanged); }} babel 插件最后 use-what-changed 还提供了 babel 插件,只通过注释就能打印 useMemo、useEffect 等依赖变化信息。babel 配置如下: { "plugins": [ [ "@simbathesailor/babel-plugin-use-what-changed", { "active": process.env.NODE_ENV === "development" // boolean } ] ]} 使用方式简化为: // uwc-debugReact.useEffect(() => { // console.log("some thing changed , need to figure out")}, [a, b, c, d]); 将 Hooks 的 deps 数组直接转化为 use-what-changed 的入参。 3 总结use-what-changed 补充了 Hooks 依赖变化的调试方法,对于 React 组件重渲染分析可以利用 React Dev Tool,可以参考 精读《React 性能调试》。 还有哪些实用的 Hooks 调试工具呢?欢迎分享。 讨论地址是:精读《use-what-changed 源码》· Issue ##256 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《15 大 LOD 表达式 - 下》","path":"/wiki/WebWeekly/前沿技术/《15 大 LOD 表达式 - 下》.html","content":"当前期刊数: 217 接着上一篇 精读《15 大 LOD 表达式 - 上》 ,这次继续总结 Top 15 LOD Expressions 这篇文章的 9~15 场景。 9. 某时间段内最后一天的值如何实现股票平均每日收盘价与当月最后一天收盘价的对比趋势图? 如图所示,要对比的并非是某个时间段,而是当月最后一天的收盘价,因此必须要借助 LOD 表达式。 设想原表如下: Date Ticker Adj Close 29/08/2013 SYMC $1 28/08/2013 SYMC $2 27/08/2013 SYMC $3 我们按照月进行聚合作为横轴,求 avg([Adj Close]) 作为纵轴即可。但计算对比我们需要一个 Max Date 字段如下: Date Ticker Adj Close Max, Date 29/08/2013 SYMC $1 29/08/2013 28/08/2013 SYMC $2 29/08/2013 27/08/2013 SYMC $3 29/08/2013 如果我们使用 max(Date) 表达式,在聚合后结果是可以看到 Max Date 的: Month of Date Ticker Avg, Adj Close Max, Date 08/2013 SYMC $2 29/08/2013 原因是,max(Date) 是一个聚合表达式,只能在 group by 聚合 sql 下生效。但如果我们要计算最后一天的收盘价,就要执行 sum([Close value on last day],表达式如下: [Close value on last day] = if [Max Date] = [Date] then [Adj Close] else 0 end。 但问题是,这个表达式计算的明细级别是以天为粒度的,我们 max(Date) 在天粒度下是算不出来的: Date Ticker Adj Close Max, Date 29/08/2013 SYMC $1 28/08/2013 SYMC $2 27/08/2013 SYMC $3 原因就是上面说过的,聚合表达式不能在非聚合的明细级别中出现。因此我们利用 { include : max([Date]) } 表达式就能轻松实现下面的效果了: Date Ticker Adj Close { include : max([Date]) } 29/08/2013 SYMC $1 29/08/2013 28/08/2013 SYMC $2 29/08/2013 27/08/2013 SYMC $3 29/08/2013 { include : max([Date]) } 表达式没有给定 include 参数,意味着永远以当前视图的明细级别计算,因此这个字段下推到明细表做计算时,也可以出现在明细表的每一行。接着按照上面的思路组装表达式即可。 拓展一下,如果横轴我们按年进行聚合,那么对比值就是每年最后一天的收盘价。原因是 { include : max([Date]) } 会以当前年这个粒度计算 max([Date]),自然是当年的最后一天,然后下推到明细表,整整一年 365 行数据中,[Close value on last day] 大概是这样: Date Ticker Adj Close [Close value on last day] 31/12/2013 SYMC $1 $1 30/12/2013 SYMC $2 $1 … … … … 03/01/2013 SYMC $7 $1 02/01/2013 SYMC $8 $1 01/01/2013 SYMC $9 $1 接着对比值按照 sum([Close value on last day]) 聚合即可。 10. 复购阵列如下图所示,希望查看客户第一次购买到第二次购买间隔季度的复购阵列: 关键在于如何求第一次与第二次购买的季度时间差。首先可以通过 [1st purchase] = { fixed [customer id] : min([order date]) } 计算每位客户首次购买时间。 如何计算第二次购买时间?这里有个小技巧。首先利用 [repeat purchase] = iif([order date] > [1st purchase], [order date], null) 得到一个新列,首次购买的那一行值为 null,我们可以利用 min 函数计算时忽略 null 的特性,得到第二次购买时间:[2nd purchase] = { fixed [customer id] : min([repeat purchase]) }。 最后利用 datediff 函数得到间隔的季度数:[quarters repeat to purchase] = datediff('quarter', [1st prechase], [2nd purchase])。 11. 范围平均值差异百分比如下图所示,我们希望将趋势图的每个点,与选定区域(图中两个虚线范围内)的均值做一个差异百分比,并生成一个新的折线图放在上方。 重点是上面折线图 y 轴字段,差异百分比如何表示。首先我们要生成一个只包含指定区间的收盘值: [Close value in reference period] = IF [Date] >= [Start reference date] AND [Date] <= [End reference date] THEN [Adj close] END,这段表达式只在日期在制定区间内时,才返回 [Adj close],也就是只包含这个区间内的值。 第二步,计算制定区间的平均值,这个用 FIX 表达式即可:[Average daily close value between ref date] = { fixed [Ticker] : AVG([Close value in reference period]) }。 第三步,计算百分比差异:[percent different from ref period] = ([Adj close] - [Average daily close value between ref date]) / [Average daily close value between ref date]。 最后就是用 [percent different from ref period] 这个字段绘制上面的图形了。 12. 相对周期过滤如果我们想对比两个周期数据差异,可能会遇到数据不全导致的错误。比如今年 3 月份数据只产出到 6 号,但却和去年 3 月整月的数据进行对比,显然是不合理的。我们可以利用 LOD 表达式解决这个问题: 相对周期过滤的重点是,不能直接用日期进行对比,因为今年数据总是比去年大。比如因为今年最新数据到 11.11 号,那么去年 11.11 号之后的数据都要被过滤掉。 首先找到最新数据是哪一天,利用不包含条件的 FIX 表达式即可:[max date] = { max([date]) }。 然后利用 datepart 函数计算当前日期是今年的第几天: [day of year of max date] = datepart('dayofyear', [max date]),[day of year of order date] = datepart('dayofyear', [order date])。 所以 [day of year of max date] 就是一个卡点,任何超过今年这么多天的数据都要过滤掉。因此我们创建一个过滤条件:[period filter] = [day of year of order date] <= [day of year of max date]。 把 [period filter] 字段作为筛选条件即可。 13. 用户登陆频率如何绘制一个用户每个月登陆频率? 要计算这个指标,得用用户总活跃时间除以总登陆次数。 首先计算总活跃时间:利用 FIX 表达式计算用户最早、最晚的登陆时间: [first login] = { fixed [user id] : min([log in date]) } [last login] = { fixed [user id] : max([log in date]) } 计算其中月份 diff,就是用户活跃月数: [total months user is active] = datediff("month", [first login], [last login]) 总登录次数比较简单,也是固定用户 ID 后,对登陆日期计数即可: [numbers of logins per user] = { fixed [user id] : count([login date]) } 最后,我们用两者相除,得到用户登陆频率: [login frequency] = [total months user is active] / [numbers of logins per user] 制作图表就很简单了,把 [login frequency] 移到横轴,count distinct 用户 ID 作为纵轴即可。 14. 比例笔刷这个是 LOD 最常见的场景,比如求各品类销量占此品类总销量的贡献占比? sum(sales) / sum({ fixed [category] : sum(sales) }) 即可。 当前详细级别是 category + country,我们固定品类,就可以得到各品类在所有国家的累积销量。 15. 按客户群划分的年度购买频率如何证明老客户忠诚度更高? 我们可以如下图,按照客户群(2011 年、2012 年客户)作为图例,观察他们每年购买频次分布。 如上图所示,我们发现顾客注册时间越早,各购买频次的比例都更高,所以证明了老顾客忠诚度更高这一结论。注意这里看的是至少购买 N 次,所以每条线相比才具有说服力。如果是购买 N 次,则可能老顾客购买 1 次较少,购买 10 次较多,难以直接对比。 首先我们生成图例字段,即按最早照购买年份划分顾客群:[Cohort] = { fixed [customer id] : min(Year([order date])) } 然后就和我们第一个例子类似,计算每个订单数量下,有多少顾客。唯一的区别是,我们不仅按照顾客 ID group,还要进一步对最早购买日期做拆分,即:{ fixed [customer id], [Cohort] : count([order id]) }。 上面的字段作为 X 轴,Y 轴和第一个例子类似:count(customer id),但我们想查看的是至少购买 N 次,也就是这个购买次数是累计值,即至少购买 9 次 = 购买 9 次 + 购买 10 次 + … 购买 MAX 次。所以是一种 DESC 的 windowsum,整体表达式应该类似 [Running Total] = WINDOW_SUM(count(customer id)), 0, LAST())。 最后,因为实际 Y 轴计算的是占比,所以用刚才计算的至少购买 N 次指标除以各 Cohort 下总购买次数,即 [Running Total] / sum({ fixed [Cohort] : count([customer id]) })。 总结上面的几个例子,都是基于 fixed、include、exclude 这几个基本 LOD 用法的叠加。但从实际例子来看,我们会发现真正的难点不在与 LOD 表达式的语法,而在于我们如何精确理解需求,拆解成合理的计算步骤,并在需要运行 LOD 的计算步骤正确的使用。 LOD 表达式看上去很神奇,似乎可以和数据 “神奇” 的贴合在一起,我们要理解到 LOD 背后就是表之间的 join,而不同明细级别就表示不同的 group by 规则这一背后原理,就能比较好的理解为什么 LOD 表达式能这么运作了。 讨论地址是:精读《15 大 LOD 表达式 - 下》· Issue ##370 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《2021 前端新秀回顾》","path":"/wiki/WebWeekly/前沿技术/《2021 前端新秀回顾》.html","content":"当前期刊数: 226 2021 JavaScript Rising Stars 每年都会对前端开源项目进行点评,其依据是去年 Star 的增幅。Star 虽然只是一个维度,但至少反应了流行度,根据这个排行榜可以大体分析出前端社区的趋势。 精读该榜单包含整体榜单、前端框架、Node 框架、构建工具、Vue 生态、React 生态、CSS-In-JS、测试、移动端、桌面、静态建站、状态管理、GraphQL 共 13 个子榜单,都是前端开源最活跃的几个领域,下面分别介绍。 整体榜单第一名 zx 是一个命令行工具,它基于 Node 语法拓展了 Bash 支持,可以非常方便的进行 Node 与 Bash 之间的输入输出,就像 Node 原生就支持 Bash 一样。它解决了离不开 Bash,但 Bash 写起大段逻辑不如 Node 自然的痛点。 第二名 vite 是去年最闪耀的星,它是一个 bundless 概念的前端构建工具,最初服务于 vue,后来进行框架无关升级后,在 react、angular 生态都大受欢迎。它解决了 webpack 编译太慢,其他 bundless 方案不够开箱即用且存在大量兼容问题的痛点。 第三名 next.js 2016 年开始的项目,是一个大而全的 React 全家桶,定位就是各大厂都会自己做一套的前端一体化框架,但它更时髦,不断加入许多流行功能比如 Server Component。这和 next.js 所在的明星公司 Vercel 有关,这家公司挖了大量开源知名人物,包括 Svelte 作者与 React 团队核心成员,所以也许未来社区的新玩具会先用在 next.js 再独立开源。它给出了前端最佳实践,并解决了没有精力持续给项目进行全方位优化,或追逐不上潮流的问题,因为 next.js 本身正在成为前端潮流的发源地。 第四名 react 不用多说了,数据驱动、响应式编程、函数式的领军框架,它改变了前端开发效率。 第五名 tauri 比 electron 更轻量的桌面应用开发框架,基于任何前端框架。它解决了前端开发者遇到桌面应用开发场景时各平台巨大的原生开发学习成本的痛点。 第六名 Tailwind CSS 是 css 框架,它提供了大量语义化 className,提供了许多最佳实践,让你有机会把 css 打理的井然有序。它解决了前端项目 css 杂乱无章又没有人真的在意的痛点。 第七名 vscode 宇宙级 IDE,它解决了程序员没有真正趁手软件写代码的痛点。 第八名 Slidev 是一个把 markdown 渲染成 PPT 的框架,基于 vite + vue 等技术栈开发。用它开发的 PPT 非常简洁美观,非常适合在公开场合分享时使用,不仅看起来赏心悦目,还可以不经意间切换到 Markdown 源码 hotfix 一下小错误,展示出你的极客精神。它解决了你真的只想展示几句话,但又要以 PPT 方式 show 出来的痛点。 第九名 NocoDB 是一个支持多种数据源的数据库 UI 管理工具。但其实它有更大的格局,即对标 airtable,即用 NocoDB 连接数据库后,一切数据可视化的操作与功能都成为了可能,且提供了大量工作常用的甘特图、电子表格等视图,并可互相转换,最终其实数据存储到连接的数据库,但你无需关心细节。它解决了基于二维表格数据开发各类生产工具需投入大量研发资源的痛点。 第十名 Vue 和 React 一样不多说了。 前端框架第一名 react 在整体榜单里了。 第二名 Vue 也在整体榜单里了。 第三名 svelte 是一个类似 vue 的框架,但特色是极度重视编译时,而忽略运行时,即运行时除了必要逻辑外是完全不引入任何 runtime 框架的。说实话我觉得和 vue、react 相比在正儿八经项目中并没有核心优势,因为它并没有那种魔法能力,可以极大的减少大型项目体积与提升性能,反而会受制于其语法与编译时的特性产生副作用。但唯一一个好处是框架无关,即利用 svelte 编译的组件几乎没有额外运行时框架代码,可以最低成本,最大隔离性的与其他项目结合。 第四名 angular 笔者已经很久没有关注 angular 框架了,无法给出什么点评。但从 svelte 新增热度超过 angular 来看,可能大部分开发者对 angular 的态度和我一样。 第五名 solid 类似 svelte,提前编译,按需打包,重要的是,其类似 React useEffect 的 API createEffect 在依赖变化后,仅该函数会重新执行,而不会导致整个组件重新执行,在点对点更新上做得更极致。 前端框架的亮点是 svelte 与 solid 的概念,即重编译时,轻运行时,更加原子化的更新粒度,与更直接的调用原生浏览器方法带来性能提升。很难不让人觉得这是一个前端框架新趋势,但我翻了不少资料发现,这种创新带来的收益在正常项目里微乎其微,所以实际上 2021 年前端框架还是没能跳出三巨头创造新的概念,而以 svelte 与 solid 为代表的 “静态化” 框架只能算微创新。 Node 框架第一名 next.js 在整体榜单里了,在 Node 框架一骑绝尘。 第二名 nest 是一个 node 版 server 框架,支持传统的 Controller、Module、Service,支持用装饰器申明路由、控制器等,语法上比较时髦。 第三名 Strapi 专门为 API 场景服务,提供了一个 API 管理后台,解决了只需要一个便捷 API 管理,而不希望了解一个大而全的后端框架的痛点。 第四名 remix 其实和 next.js 定位差不多,由 react-router 作者开发,才开源不久,需要进一步观察。 第五名 nuxt.js 是 vue 领域的 next.js。 值得一提的是,svelte 也有自己的专属框架 sveltekit,所以 Node 后端框架之争大部分其实在打全栈的牌,毕竟 Node 的优势就是支持 js 语言,而当前端应用基于某个框架编写时,如果有一个 Node 框架可以无缝集成这个前端框架,它就比非 Node 框架更优。 不过大厂几乎都是前后端分离的,所以这种全栈优势框架在国内没有太多出场机会,如果你是一个个人博主,还是首推使用全栈框架建站。 构建工具第一名 vite 在整体榜单里了,在构建工具里也是一骑绝尘。 第二名 esbuild 是用 go 编写的构建工具,适用使用范围更广,其压缩模块在 bundless 还未成熟时就被各大构建全家桶提前集成了,而 vite 也是基于 esbuild 进行编译的,但 vite 的火热度更高,说明了整体 bundless 方案已在 2021 年成熟了。 第三名 swc 因采用 rust 编写而知名,类似 esbuild,但因为依托 rust 编译到 wasm 的特性,支持了在线编译器,非常方便。swc 还被大量新生代构建工具作为基建,这在 精读《Rust 是 JS 基建的未来》 时提到过。 第四名 turborepo 是用 go 写的 monorepo 项目管理工具,是 lerna 的替代品。 第五名 nx 也是一个 monorepo 管理工具。 与框架不同,构建工具往往呈现套娃结构,不是你中有我,就是我中有你,每个热门库都重点解决某一块关键问题,不断套娃套娃,最后套成一个很棒的全家桶。 Vue 生态第一名 Slidev 在整体榜单里了。 第二名 Vue Element Admin 基于 vue 的管理后台,在权限验证有一些最佳实践,使用 vuex 管理状态。 第三名 Headless UI 是一个完全无样式的基础组件库,支持 React 与 Vue,官网的例子都是利用 Tailwind CSS 内置样式组合而成的。它解决了 UI 组件库绑定样式后,自定义样式 “实际上非常恶心” 的痛点。 第四名 Naive UI 是一个 Vue 组件库,没有太多特别之处,但竟然上了排行榜。看了一下 star 趋势,在 2021.6 月份 star 涨幅是之后的十倍,估计刚开源推广了一波,后续涨幅很慢了,不出意外明年会跌出这个榜单。 第五名 vue-next 即 vue3,star 数量只有 vue2 的 13%,但今年 star 增幅有 vue2 的一半。 vue3 还自带了状态管理库 pinia,其生态已经非常完备。 React 生态第一名 next.js 在整体榜单里了。 第二名 Ant Design 虽然立志成为西湖区最好的 React 组件库,但事实上已经成为了全球最好的 React 组件库。 第三名 MUI 就是大名鼎鼎的 material design UI 组件库,我对它影响最深的是按钮点击后出现的水波纹,这是 material design 的一大特色。早在 2014 年就创建了,在 Ant Design 没火的时候,是开源组件库首选。 第四名 remix 在 Node 框架榜单里了,和 next.js 一样,是绑定了 React 生态的 Node 框架,所以也出现在 React 生态中。 第五名 react-use 是很小巧的 React Hook 库,提供了如 usePrevious、useDebounce 等常用的 Hook。 看完整个 React 生态榜单,无论是优质生态库数量,还是去年增长的 Star 数,都比 Vue 生态更胜一筹。这背后是无副作用的纯函数与自动依赖收集的响应式视图之争,甚至在 React 生态里也有比如 mobx-react 等优质 MVVM 库,这两种编程范式都会长期并存。 CSS-In-JS第一名 vanilla-extract 作为 2021 年的黑马,主打零运行时与 TS 支持。零运行时是通过 @vanilla-extract/webpack-plugin 插件在编译时就完成内容输出。 第二名 styled-components 是推出最早,也最成熟的一个 CSS-In-JS 框架,虽然版本间出现过运行时不兼容让我放弃过,但不得不说是这个方向的鼻祖。 第三名 stitches 和第一名很像,也主打零运行时,不过没有提对 TS 是否友好。 第四名 Twin 基于 Tailwind CSS 实现了 CSS-In-JS 版的语法,可以认为是内置了一套最佳实践的 CSS-In-JS 库,也没解决太大的痛点,只是如果你同时喜欢 Tailwind CSS 与 CSS-In-JS,可能会爱屋及乌的选择 Twin。 第五名 Emotion 也是一个相对完备的库,基本上 CSS-In-JS 各类语法都能支持。 相比传统 CSS-In-JS 库,第一名 vanilla-extract 的零运行时是一大亮点,是这个方向的新趋势。 测试第一名 Playwright 是一个跨浏览器跨平台的测试框架,可以利用 js 代码打开任意 url 地址截图或者对比,解决了搭建自动化测试平台需要从零开始编写底层框架的痛点。 第二名 Storybook 是非常有名的文档工具,很多开源组件、项目的文档都基于 Storybook 创建。神奇的是它还支持单元测试,在你访问 UI 组件时进行测试并打印出测试结果。Storybook 已经变成了一个 all-in-one 的组件开发工具。 第三名 Cypress 与 Playwright 且诞生比较早,但由于不支持多 tab 页面,且仅支持 js,所以仅在前端流行,在测试工程师角度却不如支持多语言的 Playwright 好用。 第四名 Puppeteer 是 2017 年谷歌推出基于 Chrome 无头浏览器的测试工具,但 2020 年微软的 Playwright 具有跨浏览器特性还是更胜一筹。 第五名 Jest 是代码级别单测工具的佼佼者,覆盖了全框架,只要你想对代码进行单元测试,选 Jest 是不会错的。 测试框架围绕单测与浏览器测试这两个子领域,2021 年在浏览器测试领域出现了跨浏览器这个特色方向,在单测领域没有太大变化,顶多出了一个 Vitest 让单测跑得更快,这个库在 2022 年稳定后可能会大放异彩,甚至可能因为 Vite 流行的原因取代 Jest。 移动端第一名 ReactNative 是基于 React 的 Mobile Native 开发框架,笔者用过一段时间,只能说不能抱有太大期待,因为极大的局限了 web 语法,如果你觉得仅掌握前端知识就可以轻松使用,那么一定会让你失望,不要一开始就抱着这种期待。另外跨端真是非常痛,比如 SwitchAndroid、SwitchIOS 让你感受不到 Write Once, Run everywhere(虽然官方也没这么说)。 第二名 Ionic 是一个跨前端框架的跨平台构建工具,解决了 ReactNative 无法 Run everywhere 的痛点,但也带来了不够灵活的问题,即无法使用平台特定特性。 第三名 Expo 是基于 ReactNative 的一站式跨端开发工具,它的 App 使用非常傻瓜化,并且内置了调试能力,可以说是把 ReactNative 要踩的坑帮你踩完了。 第四名 Quasar 可以认为是 Vue 版的 ReactNative。 第五名 Flipper 是一个 Native 应用调试工具,可以认为是手机应用版本的 Chrome DevTools,支持连接远程终端,解决了手机应用难以用电脑调试的痛点。 其实还少了 Flutter 这个优秀框架,虽然不属于前端方向,但就像前端脚手架越来越多用 Rust、Go 写一样,Native 用 Dart 也是可以接受的。 从前端角度看移动端,唯一需求就是 Write Once,Run Anywhere,然后再把调试体验做好一些,Native 的兼容性、拓展性做强一些,就是一个完美方案了。 说到跨端,基于 Flutter 的 kraken 也绝对值得一提,它利用 Flutter 高一执行渲染层能力,并解决了 Dart 生态对前端不友好的问题,做了一个 html+css+js 到 dart 的桥接层,如果明年可以在手淘稳定覆盖大量场景,那一定是个值得考虑的方案。 总结还有更多榜单就不一一总结了,如果觉得不过瘾,可以去 2021 JavaScript Rising Stars 翻翻这些 top star 项目的介绍和源码深入了解一下。 最后总结一下 2021 前端领域的几个关键特征: 编程语言全面开花。以后 JS 开发者不等于前端开发者了,因为 Go、Rust、Dart、C++ 语言都可以为前端服务,并且 2021 年是真的有不少场景做到了生产环境可用,不论我们接不接受,前端不止有 JS 一种语言了。 前端开发全家桶逐渐产生技术壁垒。在前几年,抄一个前端全家桶很容易,在过程中还可以学到很多底层知识,但现在前端全家桶的积累越来越多,涉及的领域越来越广,甚至 next.js 引入的特性会超越你自己调制的全家桶,这说明全家桶的知识量已经逐渐达到个人知识广度的极限,如果你没有足够精力持续学习,跟进时代步伐的最好方式是使用一个成熟的全家桶。 讨论地址是:精读《2021 前端新秀回顾》· Issue ##390 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《30 行 js 代码创建神经网络》","path":"/wiki/WebWeekly/前沿技术/《30 行 js 代码创建神经网络》.html","content":"当前期刊数: 33 本期精读的文章是:30 行 js 代码创建神经网络。 懒得看文章?没关系,稍后会附上文章内容概述,同时,更希望能通过阅读这一期的精读,穿插着深入阅读原文。 1 引言 自从 Alpha Go 打败了李世石,大家对深度学习的体感更加的强烈,人工智能也越来越多的出现在大家的生活之中。很多人也会谈论,程序员什么时候会被人工智能给替代?与其慌张,在人工智能的潮流下,不断学习新的人工智能相关技术,武装自己,才是硬道理。 本文介绍了如何使用Synaptic.js 创建简单的神经网络,解决异或运算的问题。 2 内容概要神经网络中的神经元和突触对神经网络有所了解的人都知道,神经网络就是构建类似人脑的神经系统,在人脑的神经系统中,存在一种非常重要的细胞,叫神经元。在神经网络中,你可以把神经元理解为一个函数,它接受一些输入返回一些输出结果,其中Sigmoid 神经元是一种非常常用的神经元,这种神经元以 Sigmoid 函数 作为激活函数。Sigmoid 函数接受任意的数值,输出 0 到 1 之间的值,大家可以看看常见的几种 Sigmoid 函数的函数曲线。 知道了 Sigmoid 函数了,我们可以看一个具体的 Sigmoid 神经元 例子。 在这个例子中 7 和 3 是权重参数,-2 是偏差,最左边的 1 和 0 是 输入层 中的两个节点,通过如下的计算,得到了一个 隐藏层 节点 5。 然后将节点 5 输入到一个 Sigmoid 函数,得到一个 输出层 节点 1。 如何构建神经网络有了神经元,将所有的神经元连接起来,就构建了一个 神经网络。如下图,神经元间的箭头,可以理解为是一种 “突触”。 完成神经网络的构建了,你可以用来识别手写数字、垃圾邮件判断等众多领域。当然就像上面的例子,好的模型依赖于正确的权重 和 偏差的选择。在实际工作中,每次完成神经网络的训练,我们都会拿训练的结果来对测试样式进行预测,得到算法的准确率,然后尝试选择更好的权重和偏差,期望达到更好的准确度,这个学习的过程称为反向传播。通过大量的学习后,最终才会得到更好的预测准确率。 代码实现下面附上代码的实现。 const { Layer, Network } = window.synaptic;var inputLayer = new Layer(2);var hiddenLayer = new Layer(3);var outputLayer = new Layer(1);inputLayer.project(hiddenLayer);hiddenLayer.project(outputLayer);var myNetwork = new Network({ input: inputLayer, hidden: [hiddenLayer], output: outputLayer});// train the network - learn XORvar learningRate = .3;for (var i = 0; i < 20000; i++) { // 0,0 => 0 myNetwork.activate([0,0]); myNetwork.propagate(learningRate, [0]); // 0,1 => 1 myNetwork.activate([0,1]); myNetwork.propagate(learningRate, [1]); // 1,0 => 1 myNetwork.activate([1,0]); myNetwork.propagate(learningRate, [1]); // 1,1 => 0 myNetwork.activate([1,1]); myNetwork.propagate(learningRate, [0]);} 简单而言,运行 myNetwork.activate([0,0]) 时,[0, 0]是输入值,它对应的异或运算的结果是 false, 也就是 0。 这个是前向的传播,所以称为 激活 网络,每次前向传播之后,我们需要做一次反向传播来更新权重和偏差。 反向传播就是通过这行代码来做的: myNetwork.propagate(learningRate, [0]),其中 learningRate 是告诉神经网络如何调整权重的常量,第二个参数 [0]是异或运算的结果。 经过 20000 次学习,我们得到如下的结果: console.log(myNetwork.activate([0,0])); -> [0.015020775950893527]console.log(myNetwork.activate([0,1]));->[0.9815816381088985]console.log(myNetwork.activate([1,0]));-> [0.9871822457132193]console.log(myNetwork.activate([1,1]));-> [0.012950087641929467] 对运算结果取最近的整数,我们就可以得到正确异或运算的结果。 3 精读读原文的时候,大家可能主要对反向传播如何修正权重和偏差会有所疑问,作者给出的引文A Step by Step Backpropagation Example — by Matt Mazur 很详细的解释了整个过程。 方便大家理解,我以上面的异或运算的例子,简单的分析一下整个过程。其中我们选取的激活函数是:Logistic 函数。 当我们输[0, 0]时,我们选取一些任意的权重和偏差,计算过程如下: 为了方便后续的推到,我们可以通过一些符号来简单的描述一下h1和最终记过output计算的过程: 计算得到结果 o 后,我们可以通过平方误差函数 来计算误差。我们在上面的运算中得到的 output = 0.73673, 目标值是对 [0,0] 取异或运算的值,也就是: target = 0,带入上面的公式得到的误差值为: 0.271385。 到这里我们进行反向传播的过程,也就是说我们需要确认新的参数: w1, w2, w3, w4, w5, w6, b1, b2. 我们先计算新的 w5,这里是通过计算误差函数相对于w5的偏导来得到新的参数的,我们可以通过下面的链式求导来计算偏导。 得到 w5 对应的偏导后,我们通过下面的公式来计算新的w5参数: 其中, 0.3 就是我们在代码中设置的 learningRate 的值。 重复上面相似的过程,我们可以计算其他参数的值,这里就不再累述。 4. 总结本文介绍了使用Synaptic.js 创建简单的神经网络,解决异或运算的问题过程,也对反向传播的过程进行了简单的解释。文中实现神经网络的代码非常简单,包行注释也不超过 30 行,但是只会代码会有点囫囵吞枣的感觉,大家可以参考文章中给的引文,了解更多的算法原理。 相关资料1. A Step by Step Backpropagation Example — by Matt Mazur 2. Hackers Guide to Neural Nets — by Andrej Karpathy 3. NeuralNetworksAndDeepLarning — by Michael Nielsen 4. Synaptic.js 更多讨论 讨论地址是:精读《30 行 js 代码创建神经网络》 · Issue ##45 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《@types react 值得注意的 TS 技巧》","path":"/wiki/WebWeekly/前沿技术/《@types react 值得注意的 TS 技巧》.html","content":"当前期刊数: 147 1 引言从 @types/react 源码中挖掘一些 Typescript 使用技巧吧。 2 精读泛型 extends泛型可以指代可能的参数类型,但指代任意类型范围太模糊,当我们需要对参数类型加以限制,或者确定只处理某种类型参数时,就可以对泛型进行 extends 修饰。 问题:React.lazy 需要限制返回值是一个 Promise<T> 类型,且 T 必须是 React 组件类型。 方案: 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,第二个参数是初始化参数,其实第二个参数的类型是第一个参数中回调函数第一个参数的类型,那我们怎么将这两个参数的关系联系到一起呢? 方案: 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,而且传入参数不同,返回值的类型也不同。 方案: 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 元素,我们希望这个函数具备类型收窄的功能。 方案: 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 等固定属性。 方案: 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 表示这种类型的变量可以作为函数执行: const App: FunctionComponent = () => <div />;App.displayName = "App"; 3 总结看完文章内容,相信你已经可以独立读懂 @types/react 这个包的所有类型定义! 更多基础内容可以阅读 精读《Typescript2.0 - 2.9》 与 精读《Typescript 3.2 新特性》,由于 TS 更新频繁,后续 TS 技巧可能继续以阅读源码方式进行,希望这次选用的 React 类型源码可以让你印象深刻。 讨论地址是:精读《@types/react 值得注意的 TS 技巧》 · Issue ##245 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《API 设计原则》","path":"/wiki/WebWeekly/前沿技术/《API 设计原则》.html","content":"当前期刊数: 23 本期精读的文章是:API 设计原则 1 引言 优秀的 API 之于代码,就如良好内涵对于每个人。好的 API 不但利于使用者理解,开发时也会事半功倍,后期维护更是顺风顺水。 一个骨灰级资深的同事跟我说过,任何在成长的代码库,至少半年到一年就要重构一次,否则失去的不仅是活力,更失去了可维护性与可用性。 2 内容概要由于本文已经是翻译后的文章,概要只列出不涉及 c++ 概念的思路框架,细节请移步译文。 好 API 的 6 个特质极简且完备、语义清晰简单、符合直觉、易于记忆和引导 API 使用者写出可读代码。 静态多态尽量减少继承,让相似的类具备相似的 API,而不是统一继承一个父类。因为统一继承会带来 API public 数量过多,父级无意义的方法对用户产生误导。 基于属性的 API属性指的是对象状态,通过属性为粒度的 API,有利于使用者理解 API 的含义,但需注意关联属性的顺序性。 API 语义和文档比如传值 -1 的含义是什么?如果 API 文档不像 http status codes 一样健全,建议通过枚举的方式增加可读性。 命名的艺术不要使用缩写,保持一致性。类命名以功能分组作为后缀,比零散命名更易懂。 函数命名要体现出是否包含副作用,参数过多时以对象作为传参,布尔参数改为枚举类型,或者分解为两个语义化 API。 3 精读以下精读是对原文观点的补充。 Const 入参eslint 有一条规则,不要直接改变入参的值。这个规则的初衷是解决函数副作用问题,禁止可能产生副作用代码的产生。但却可以通过如下方式避免: function (num) { let scopeNum = num scopeNum = 5} 这是从包含指针类型编程语言学习过来的,因为当 *num 表示指针时,代表代码可能产生副作用(修改入参的风险)。而 js 并不总是这样的,不但没有指针申明,基本类型也总是通过拷贝进入传参,非基本类型通过引用传递,也就是会发生通过如上代码绕过检测,却依然产生副作用(改变函数入参)的情况。 为了避免副作用,建议引入 flow 或 typescript,通过 const 关键字与约定约束入参行为: function (const num) { ...} 将没有副作用函数的所有入参定义为 const 类型,静态检查阶段就禁止了对值的直接修改,同时因为有这个关键字的约束,在函数体内也约定不要通过引用浅拷贝修改它的值。 但这也无法彻底避免,仍然可以通过如下写法绕过检测,修改入参: function (const num) { const scopeNum = { ...num } scopeNum.a.b = 'c'} 在 js 中没有完美的方式避免对入参的修改,但通过对入参修饰 const 关键字,可以对使用者明确这是纯函数,对开发者提示不要写有副作用的代码。 c++ 的 const 定义从编译开始就完全杜绝了修改的可能性,虽然有 const_cast “去” const 行为,但仍然不会改变入参的值(虽然可以后续对值修改,指针指向保持不变,但用 const 修饰的入参值永远不会改变)。 统一关键字库所有 api 定义之前,先抽离业务和功能语义的关键字,统一关键字库; 可以更好的让多人协作看起来如出一辙, 而且关键字库 更能够让调用者感觉到 符合直觉、语义清晰; 关键字库也是项目组新同学 PREDO 的内容之一, 很有带入感; 单一职责接口设计尽量要做到 单一职责,最细粒度化; 可以使用组合的方式把多个解耦的单个接口组合在一起作为一个大的功能项接口; 接口设计的单一职责,也更方便多人协作时候的扩展和组合; 面向未来的多态对于接口参数的扩展,我们要做到面向扩展开放,面向修改关闭; 升级做到要兼容,否则会导致大批量的下游不可用。 同时也要避免过度设计,当抽象功能只有一处使用时,尽量不要过早抽象。 不要重复局部命名class User { // good setName() {} // bad setUserName() {}} 在有上下文环境的调用中,减少不必要的描述可以提高 API 的精简和清晰度。 同时要避免过度使用解构,因为解构会丢失上下文,让我们对变量来源一无所知: const { setName } = this.props.store.userconst { setVisible } = this.props.store.article 上述 setName setVisible 脱离了 user article 作用域,当隔着几百行调用时,早已不知所云。 4 总结参考优秀类库是设计 API 很好的方法之一,比如本文 c++ 参考的 Qt、js 可以参考 jQuery。 当 API 稳定后,需要花时间整理文档,因为写文档的思考过程可能推动着你重构和优化代码。 最后,如果有精力,最好每半年重构一次(然后完整跑一遍测试)! 讨论地址是:精读《API 设计原则》 · Issue ##34 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Ant Design 3","path":"/wiki/WebWeekly/前沿技术/《Ant Design 3.html","content":"当前期刊数: 41 精读《Ant Design 3.0 背后的故事》引言2018 年初,蚂蚁金服 See Conf 上第一个分享《Ant Design 3.0 背后的故事》给很多人带来了启发。主题精彩又深刻,值得反复咀嚼。 内容概要设计体系Atomic Design 书中提到模块化思路以及原子级的模块抽象的方法启发了很多设计师。而解读者中较为说法较作者认同。设计体系是一个具包容性且充满生命力的东西。包容性指的是从组件库到设计语言到设计方法等所有和产品设计相关的方面。而生命力指的是它并非静态的内容,而是可以应对不断变化的环境,是一个不断进化的过程。 Ant Design System蚂蚁的设计体系中设计语言、设计资产以及体验策略是设计体系最核心的三块内容,分别对应的解决设计体系中的 Consistency、Efficiency 以及 Better UX 的目标抽象成三个关键词。接着对这三部分有详细的解说。 设计语言设计语言的核心是设计价值观,最初的版本是微小、确定以及幸福感,分别指代我们的细节微创新、模块化思路以及在研发体验上的追求。而 3.0 版在保留 『确定』的基础上,又发掘出了『自然』。Ant Design 认为,一切看似自然的事物在背后都是有数学/物理规律可循的。 主字号、字阶和行高提出两个问题:多大的主字号是自然的?多大的行高是自然的? 第一个问题来源于肉眼到物体之间的距离,物体的高度以及这个三角形的角度,构成了一个三角函数的关系。我们把实际的数值传入得出 14px 这个标准字号。字阶的生长规律来源于经典的字阶和古典音阶具备韵律上的相似性,因此用幂函数的字体计算公式来表达之间的关系。由 14px 这个基础再乘上一个系数。第二个问题来源于字阶函数的反增长。得到了一组原始的行高数组。 基于此再进行一定的调整。最终得出每一个字号之间的间隔都为 16px,行高和字体之间都相差 8。 布局与色彩布局 3.0 也是基于和字体相似的思路完成的,来源于斐波那契双数组的启发。帮助设计师在布局设计决策中更好的实现动态的秩序感。 色彩相对于字体以及布局来说更加会偏向与感性,但依然可以通过搭建三维模拟空间的方式,通过大量观察和调试,掌握了不同颜色在自然光照下对应的 HSB 的变化规律,最终形成了我们 3.0 的色板。 设计资产设计资产是以 『模块化』为核心思路展开的。 在过去的一年 AntD 在 Atomic Design 以及 GE predix design 的启发上,形成了符合自己特点的 E (examples)、T (template)、C(components)、G(global styles) 的抽象思路。 体验策略体验策略的核心思路是以任务为导向的。主要通过四个方面去构建体验策略:流程与方法、度量体系、运营活动和最佳实践。 精读整个分享感受颇深,但就『自然』这个关键词才生了我自己的疑问:主字号、字阶和行高是否存在关系? 在梳理的这层关系上,缺少了对字体的讨论,而字体又是非常关键的因素。我在之后,断断续续查阅了不少资料。写下关系背后还要考虑的问题。 字体 我在查阅资料的时候,发现 x-height 在西文字体中的概念。在英文字体的设计中,字体的高度体包含三部份,以基线 (baseline) 为中央,以上称之上行区域 (ascender area),基准线内称之为 x-height,以下称为下行区域 (descender area)。小写西文字母中的核心部件都位于 x-height 位置中,这一位置也被称为排版的核心位置,是引导视线流动的关键。放一张在 wikimedia 上的图: 每一种西文字体的 x-height 是不一样的。非常幸运,Jukka Korpela 做了一网站专门可以测量 web 上字体的 x-height。其中,Arial 的 X-HEIGHT RATIO 是 0.519,而 Tahoma 是 0.545,Times New Roman 是 0.448。Arial 和 Times New Roman 之间的比例差距大概 17%。 西文字体在正文中很少用全大写字体排版也是因为小写字体有一种错落的美感,但问题是每一种字体在同一字号下的字体大小有一定差距的。这里就带出了一些问题,我们常常在英文排版中遇到多种字体混排的情况,为了体现不同的信息,比如 code,比如特殊信息等等。 这是第一个问题,第二个问题是中文字体没有 x-height,也就是说中文字体就等同于西文字体的全大写,错落的美感都没有。而且中文有一个问题是因为字形之前的差异,每个字之间的留白都不尽相同,看上去又会差一些。一般情况下,靠行间距来弥补视觉差,但总体上要排版达到西文字体的效果要花一些功夫。 屏幕 我们的字体大小使用的是 points(pt),points 是一个物理衡量,它的标准是 72 points per inch(PPI)。但我们不同设备的 PPI 都是不一样的,那么造成了同样的设定在不同屏幕下看到的字体也会有差异。 Macbook Pro 的 PPI 是 220,Dell XPS 的 PPI 是 165,iPhone 7 有 326,但 iPhone 7p 的 PPI 有 401,而一般 HDTV 的 PPI 是 30。其中,iPhone,Macbook 都是 retina 屏。 在目前这个时代,越来越多的设备在 web 化,那么我们所接触到的设备会越来越多。PPI 的不同造成了我们所需要基准字体的不同,一套设计语言是否考虑更普适的情况,还是考虑不同设备之间的情况,这是一个问题。 目前来看,我们的确可以根据不同 PPI 去设定,但这个设定自然带来极高的成本。 视觉角度 我们虽然定义了一个 14px,但在不同的视觉角度下看到的 14px 其实也是不一样的。这个 14px 的原始值是基于人与设备一定的角度下算出来的。我们知道,人对于不同设备,甚至在不同环境下看到屏幕的距离和角度都是不同的,因为字体物理大小不等视觉大小。 基于此,我认为普适的字体基准是不存在的,只能在很多条件的约束下给定的一个值,对于公式也是一样的。我们在一定的范围内,公式表达出一种自然的特征,但这种自然是有范围的。 总结曾经有国外的设计师有写文用黄金比例来构建字号与行高的关系,在一片喝彩中看到了资深设计师的反对,主要也是从以上和一些其它因素来说关系是比较难设定。 今天看到我们的设计与理性之间建立的关系,我还是比较坚信建立这种关系背后带来的是更大的价值。"},{"title":"《2017 前端性能优化备忘录》","path":"/wiki/WebWeekly/前沿技术/《2017 前端性能优化备忘录》.html","content":"当前期刊数: 28 本期精读的文章是:Front End Performance Checklist 2017 现在随着 web 应用的复杂性日益增加,其性能优化就会显得尤为必要,同时会给性能指标分析带来新的挑战,因为性能指标之间的差异性非常大,这取决于使用的设备、浏览器、协议、网络类型以及其它能够对性能产生影响的潜在因素(如:CDN、ISP、cache、proxy、firewall、load balancer、server 等)。 1 引言 本文提供了解决如何让网站响应更加迅速、访问更加流畅等前端性能优化问题的方法,读者们可以提供一些在实际场景中的性能优化问题以及解决方案,可泛谈优化策略,亦可针对性深入讨论某个优化方法。 2 内容概要文中列举了很多不同的性能优化策略、模型或方法,如下: 制定目标网站速度快于他人 20%根据 psychological research 指出,网站最少在速度上比别人快 20%,才能让用户感觉到比别人的更快。这个速度说的并不是整个页面的加载时间,而是启动渲染时间,首次有效渲染时间,交互时间。 控制响应时间在 100ms,控制帧速在 60 帧/秒RAIL performance model 提出的性能优化指标:务必在用户初始操作后的 100ms 内提供反馈。考虑到存在响应时间不足 100ms 的情况,页面最迟要在 50ms 的时候,把控制权交给主线程。 针对动画,其每一帧都需要在 16ms 内完成,这样才能保证每秒 60 帧(一秒/60=16.6ms),如果可以的话最好能在 10ms 内完成。 控制首次有效渲染时间在 1.25s,控制 SpeedIndex 在 1000控制启动渲染时间在 1s 以内,且速度指数在 1000 以内,对于首次有效渲染时间,最好可以优化到 1.25s 以内。 环境搭建做好构建工具的选型不要过度使用那些酷炫的技术栈,坚持选择适合开发环境的工具,如 Grunt、Gulp、Webpack、PostCSS,或者组合起来的工具。只要这个工具运行的速度够快,而且没有给项目维护带来太大问题,就够了。 渐进增强在构建前端结构的时,应始终将渐进增强作为指导原则。首先设计并且构建核心体验,再完善为高性能浏览器设计的高级特性的相关体验。 前端框架最好使用那些支持服务器端渲染的框架,如 Angular,React,Ember 等。所选的框架要保证是被广泛使用并且经过考验的。不同框架对性能有着不同程度的影响,同时对应着不同的优化策略,所以要清楚的了解所选择框架的每个方面。 AMP 或 Instant Articles Google 的 AMP 技术会提供一套可靠的性能优化框架(基于免费的 CDN 网络) Facebook 的 Instant Articles 技术可以在 Facebook 上提升网站的性能。 合理利用 CDN根据网站的动态数据量,可以将部分内容给静态网站生成工具生成一个静态版本,将其置于 CDN 上,从而避免数据库的请求,亦可选择基于 CDN 的静态主机平台,通过交互组件丰富页面。 优化构建确定优先级将网站的所有文件(js,图片,字体,第三方 script 文件,多媒体内容等)进行分门别类。根据优先级区分基础核心内容,高性能浏览器设计的升级体验,附加内容等。具体细节可参考 Improving Smashing Magazine’s Performance。 使用 cutting-the-mustard 技术使用 cutting-the-mustard 技术能够实现不同类型的浏览器载入不同类型的资源(传统浏览器载入核心型资源,现代浏览器载入增强型资源)。在载入资源时要严格遵守相应的规则:页面加载时应首先载入 Core 资源,然后在 DomContentLoaded 事件触发时载入 Enhancement 资源,最后在 Load 事件触发时载入 Extras 资源。 micro-optimization 和 progressive booting 使用 skeleton screens 代替 loading indicator 展示 使用能够加速 App 初始化渲染的技术,如 tree-shaking、code-splitting 针对服务端渲染增加预编译环节 使用 Optimize.js 来加快初始加载速度,其原理是包装优先级高的调用函数 渐进启动,先通过使用服务器端渲染快速完成首次有效渲染,浏览器再通过少量的 JS 代码就可以让交互时间接近于首次有效渲染时间。 正确设置 HTTP cache header需要正确设置 expires、cache-control、max-age 以及其它 HTTP 缓存响应头。请使用 Cache-control: immutable,可以参考 Heroku’s primer on HTTP caching headers、HTTP caching primer以及缓存之最佳实践。 减少使用第三方库,异步加载 JS想要在不等 js 执行完就开始渲染页面,可以通过在 HTML 的 script 标签上添加 defer 以及 async 属性来实现。减少第三方库和脚本的使用,尤其是社交网站的分享按键和 iframe 嵌入等。 合理优化图片 要实现图片的响应式,应尽可能地使用带有 srcset、sizes 属性的 HTML 标签,如 <picture> 使用 WebP 格式的图片 图片优化进阶 可以使用渐进式 JPEG 图片 可以使用压缩工具对不同格式的图片进行压缩,如 JPEG 图片用 mozJPEG 压缩、PNG 图片用 Pingo 压缩、GIF 图片用 Lossy GIF 压缩、SVG 图片用 SVGOMG 压缩 可以通过过滤掉不必要的图片细节(通过给图片添加高斯模糊滤镜实现)来减小文件的大小 可以使用 PhotoShop 导出(质量在 0-10%)的图片用于做背景图 可以使用多张背景图的技巧来提高对图片性能感知的能力 优化 web 字体 如果使用开源字体,可以使用字体库中的子集或自己归类的子集来压缩文件大小 浏览器对 WOFF2 的支持度较高,当浏览器不支持 WOFF2 时,可以将 WOFF、OTF 作为备用 可以从 Comprehensive Guide to Font-Loading Strategies 中选择一些针对字体优化的策略 可以使用 service worker 来达到字体缓存持久化 关于如何快速入门字体优化的教程 快速推送 critical CSS 文件为了保证能够让浏览器快速渲染,会将所有用于首屏渲染的 CSS 文件整合成一个文件(即 critical CSS),以 <style> 的行内形式内嵌到 <head>,这样可以减少 critical 渲染路径。由于 HTTP 数据包大小的限制,因此 critical CSS 文件大小不能超过 14KB。 HTTP/2 协议可以让 critical CSS 用单个 CSS 文件存储,通过服务器推送 CSS 文件的传输方式来减少 HTML 文件数据量,由于存在高速缓存问题,因此需要建立带有缓存的 HTTP/2 服务器传输机制。 tree-shaking 和 code-splitting 机制减轻负载 Tree-shaking 机制能够帮助清理生产环境中的冗余代码。可以通过 Webpack2 Tree-Shaking 机制来清理冗余的 exports 代码或者使用 UnCSS、Helium 工具来清理冗余的 CSS 代码 code splitting 机制是 Webpack 的另一个特性,它能够将构建的代码分成多个 chunk,并且对 chunk 按需载入。只要在代码中定义了分离点(split point),Webpack 便会处理好相关的输出文件,不仅能够较少文件数据量,而且还能对代码做到按需载入。 用 Rollup 来 export 代码也能够取得不错的效果 提升渲染性能可以通过使用 css containment 属性的方式来达到隔离性能开销大的组件,限制浏览器样式的范围,限制作用在 canvas 以外的布局和绘制工作中,限制用在第三方工具上,以确保页面滚动和出现动画效果时没有延迟。推荐使用 CSS 属性 will-change,该属性能够在元素的属性改变之前通知浏览器。 需要衡量浏览器在处于运行时渲染模式下的性能,可以参考浏览器渲染优化、如何正确的使用 GPU。 优化网络环境,加快网络传输 使用 skeleton screen 或者使用懒加载的方式载入字体或者开销大的组件,如视频、iframe、图片等 dns-prefetch,能够让浏览器在后台进程执行一次 DNS 查询 preconnect,能够让浏览器在后台进程发起一次握手(DNS,TCP,TLS) prefetch,能够让浏览器发起对资源的请求 prerender,能够让浏览器在后台进程渲染出特定的页面 preload,在不执行资源的前提下,预先拿到该资源 HTTP/2为 HTTP/2 环境的搭建做好准备从目前来看,浏览器对 HTTP/2 支持度还不错,使用 HTTP/2 后,就可以利用 service worker 以及 HTTP/2 的服务器推送功能来获取更显著的性能提升。 在项目进行 HTTPS 改造时,需要评估 HTTP/1.1 项目的用户基数,需要针对这类用户构建并发送符合 HTTP2 规范的报头。 正确部署 HTTP/2需要在载入大模块以及并行载入小模块之间找到一个平衡点。 将所有视图都分散到小模块中,然后在项目构建的过程中完成对小模块的压缩,最后通过 scount approach 以及异步的方式来分别实现对模块的引用及载入,对一个文件将不再需要重新下载整个样式清单或 js 文件 HTTP/2 环境下打包 js 文件时存在问题,由于向浏览器发送很多 js 小文件的过程中会存在很多问题。 首先,文件压缩的优势被破坏。在压缩大文件的过程中,借助 dictionary reuse 可以达到优化性能的目的,然而单个小文件就不能。其次,浏览器不能针对一些工作流进行优化 确保服务器的安全性需要检查是否正确设置 HTTP 请求头部,如 strict-transport-security,使用 Snyk 工具排除已知的漏洞以及使用 SSL Server Test 网站来检查证书是否失效。 尽量保证从外部引入的插件以及 js 脚本的载入是通过 HTTPS 协议的,发起 HTTP 请求同时设置 strict-transport-security 以及 content-security-policy HTTP 请求头。 服务器和 CDN 是否支持 HTTP/2通过 Is TLS Fast Yet 来查看不同服务器和 CDN 对 HTTP/2 的兼容情况。 Brotli 或 Zopfli 压缩算法 Brotli,是 Google 开源的无损数据格式,其压缩效率要远高于 Gzip 和 Deflate Zopfli 压缩算法,能够将数据编码成 Deflate、Gzip、Zlib 数据格式。用 Zopfli 算法压缩过后的文件能够比同样用 Zlib 算法压缩的文件小 3%-8% 激活 OCSP stapling激活服务器的 OCSP stapling,可以减少 TLS 握手所需的时间,加速 TLS 握手过程。 使用 IPv6因为 IPv6 自带 NDP 以及路由优化,能够让网站的载入速度提升 10%-15%。 HPACK 压缩算法如果网站使用了 HTTP/2,需要检查服务器有没有执行 HPACK 对 HTTP 的响应头进行压缩,来减少不必要的消耗。 使用 service worker如果网站切换到 HTTPS,可以使用 pragmatist-service-worker 通过 service worker cache 来缓存静态资源、离线页面等,也可以从缓存中拿数据。参考当前浏览器对 service worker 的支持程度。 测试与监控监控警告 通过 Report-URI.io 工具监控混合内容中出现的警告 通过 Mixed Content Scan 工具扫描支持 HTTPS 的网站是否存在混合内容 使用 Devtools在 DevTool 中选一个调试工具来对每一个功能进行检查,确保知道如何分析渲染性能和控制台输出、明白如何调试 JS 以及编辑 CSS 样式。参考开发者工具的调试技巧。 使用代理浏览器或过时浏览器测试完成 Chrome 和 Firefox 的测试是不够的,还需要关注部分区域占比较高的浏览器,如 UC 浏览器、Opera Min 等, 也需要了解一下受关注国家的平均网速。 持续监控在进行快速、无限制的测试时,最好使用一个个人的 WebPageTest 实例。建立一个能自动预警的性能预算监听。建立自己的用户时间标记从而测量并监测具体商用的数据。使用 SpeedCurve 对性能的变化进行监控,同时利用 New Relic 获取 WebPageTest 没法提供的数据。SpeedTracker,Lighthouse 和 Calibre 都是不错的选择。 部署私密的 WebPageTest 测试环境,有助于快速构建测试用例。针对性能开销大的环节建立自动报警机制,可以使用 SpeedCurve 对性能的变化进行监控,利用 New Relic 获取 WebPageTest 无法提供的数据。 3 精读这一部分会介绍一些上述没有提到的方法,主要是利用 Devtools 工具对性能优化策略或方法进行深入的解读和分析。 通过 Devtools 排查渲染性能问题页面代码被转换成屏幕上显示的像素,这个转换过程可以简单归纳为以下流程,包含五个关键步骤: Javascript Style Layout Paint Composite Timeline通过 Chrome Timeline 对页面进行 Record,其中绿色波浪线就是页面的帧率。波浪线越高表示帧率越高,反之亦然,帧率区域上边标红一行区域,表示有问题的帧,凡是标红的帧都是存在问题的,排查问题时,需要着重关注帧率低和标红的区域。 需要逐一排查带红色角标的帧,即是有问题的帧: 点击选中该帧,可以看到详细的耗时和简单的问题描述: Javascript Profiler如果发现运行时间很长的 JavaScript 代码,则可以开启 DevTools 中 JavaScript profiler 选项,可以看到页面中的函数调用链路,就能分析出 JavaScript 代码对于页面渲染性能的影响,从而发现并修复 JavaScript 代码中性能低下的部分。那么如何修复 JavaScript 代码中性能问题呢? 使用 requestAnimationFrame假设页面上有一个动画效果,想在动画刚刚发生的那一刻运行一段 JavaScript 代码。那么唯一能保证这个运行时机的,就是 requestAnimationFrame。而大部分代码都是用 setTimeout 或 setInterval 来实现页面中的动画效果。这种实现方式的问题是,setTimeout 或 setInterval 中指定的回调函数的执行时机是无法保证的,如果是在帧结束的时候被执行,就意味着可能失去这一帧的信息,也就是发生 jank。 降低代码复杂度或者使用 Web WorkersJavaScript 代码是运行在浏览器的主线程上的。与此同时,浏览器的主线程还负责样式计算、布局,甚至绘制等的工作。可以想象,如果 JavaScript 代码运行时间过长,就会阻塞主线程上其他的渲染工作,很可能就会导致帧丢失。 因此,需要规划 JavaScript 代码的运行时机和运行耗时,或在浏览器空闲的时候来来运行更多的 JavaScript 代码。 也可以把纯计算工作放到 Web Workers 中做,前提是这些计算工作不会涉及 DOM 元素的存取。一般来说,JavaScript 中的数据处理工作,如排序或搜索比较适合这种处理方式。 如果 JavaScript 代码需要存取 DOM 元素,即必须在主线程上运行,那么可以考虑批处理的方式,把任务细分为若干个小任务,每个小任务耗时很少,各自放在一个 requestAnimationFrame 中回调运行。 Render(Style & Layout)render 部分包括 Recalculate Style 和 Layout,如果发现 render 部分耗时较长,需要分别从这两部分进行分析。如果这一帧,触发了强制 layout,Timeline 会用红色角标标出,这是需要进行优化的地方。 如果需要具体分析 Recalculate Style,可以选中 Recalculate Style 部分,查看受影响的元素个数、触发 Recalculate Style 函数以及警告提示。 如果需要分析 Layout,可以选中 Layout 部分,同 Recalculate Style 一样。 那么如何提升 Render 部分的性能问题呢? 降低样式计算和复杂度添加或移除一个 DOM 元素、修改元素属性和样式类、应用动画效果等操作,都会引起 DOM 结构的改变,从而导致浏览器需要重新计算每个元素的样式、对页面或其一部分重新布局(多数情况下),这就是所谓的样式计算。 因此需要减少执行样式计算的元素的个数,降低样式选择器的复杂度,使用基于 class 的方式,如以 BEM (Block, Element, Modifier)的方式编写 CSS 代码,能达到最好的样式计算的性能,因为这种方式建议对每个 DOM 元素都只使用一个样式 class。 避免大规模、复杂的布局布局,就是浏览器计算 DOM 元素的几何信息的过程:元素大小和在页面中的位置。 尽可能避免触发布局,当修改了元素的样式属性之后,浏览器会将会检查为了使这个修改生效是否需要重新计算布局以及更新渲染树。对于 DOM 元素的几何属性的修改,比如 width/height/left/top 等,都需要重新计算布局。通过 DevTools Timeline 可以查看页面性能的分解图,从而判断布局过程是否是页面性能的瓶颈,参考能触发布局、绘制或渲染层合并的 CSS 属性清单 使用 flexbox 替代老的布局模型,在相同数量的元素下 Flexbox 布局,不仅达到了同样的显示效果,而且时间消耗也大大降低,因此需要在对页面布局模型的性能分析的基础之上,来选择一种性能最优的布局方式,而且应该努力避免同时触发所有布局 避免强制同步布局事件的发生,将一帧画面渲染到屏幕上的处理顺序是执行 JavaScript 脚本、样式计算、布局。但还可以强制浏览器在执行 JavaScript 脚本之前先执行布局过程,这就是所谓的强制同步布局。为了避免触发不必要的布局过程,应该首先批量读取元素样式属性,然后再对样式属性进行写操作,过早地同步执行样式计算和布局是潜在的页面性能的瓶颈之一 避免快速连续的布局,如果想确保编写的读写操作是安全的,你可以使用 FastDOM,它能帮你自动完成读写操作的批处理,还能避免意外地触发强制同步布局或快速连续的布局 PaintPaint(绘制)其实是生成元素呈现的像素的过程。在页面的整个被解析、执行、渲染的过程中,Paint 通常来说是代价最高的一步,因此尽量减少 Paint 时间,甚至避免 Paint 的发生,对页面性能的提升有着很重要的作用。 如何触发 Paint 触发了 Layout,那么一定会触发 Paint 改变元素的一些非几何属性,如背景、颜色、阴影等,不会触发 Layout,但是依然会触发 Paint 如何定位 PaintTimeline 中绿色部分就是 Paint 部分,Summary 会展示绘制的总体情况,包括绘制的元素、元素本身绘制耗时、元素子元素绘制耗时。如果发现绘制的区域超过了本来期望的区域,那么就是需要优化的。更加详细的信息,可以切换至 Paint Profiler,包括了每个具体 Paint 的调用和 Paint 区域截图。当页面发生 Paint 时,如果发现不期望的区域进行了 Paint,那么这里就是可以优化的。 如何优化 Paint 提升元素渲染层为合成层,页面的绘制并非是在单层画面里完成的,浏览器的渲染原理,是浏览器将 DOM tree 映射成 GraphicsLayer tree,中间是经过了 RenderObject、RenderLayer 的一系列映射。元素所在的层提升为合成层后可以减少 Repaint 使用 transform 或 opacity 实现动画,对于独立的合成层应用 transform 和 opacity 是不会触发 Repaint 的,因此尽量对 transform 或 opactiy 应用动画来实现效果 减少绘制区域,对于不需要重新绘制的区域应尽量避免绘制,已减少绘制区域,比如一个 fix 在页面顶部的固定不变的导航 header,在页面底部某个区域 Repaint 时,整个屏幕包括 fix 的 header 也会被重绘,而对于固定不变的区域,期望其并不会被重绘,因此可以通过之前的方法,将其提升为独立的合成层 降低绘制复杂度,对于无法避免的 Paint,需要尽可能的减少 Paint 的消耗,有些效果的 Paint 代价十分昂贵,比如绘制一个阴影可能就比绘制一个边框更加耗时,因此开发过程中,需要研究能够实现相同的效果,同时却能达到更小的 Paint 消耗的方法 Composite渲染层合并,对页面中 DOM 元素的绘制是在多个层上进行的。在每个层上完成绘制过程之后,浏览器会将所有层按照合理的顺序合并成一个图层,然后显示在屏幕上。 提升为合成层简单说来有以下优点: 合成层的位图,会交由 GPU 合成,比 CPU 处理更快 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层 对于 transform 和 opacity 效果,不会触发 layout 和 paint 对于诸如 fixed 的合成层,移动时不会触发 repaint 提升动画效果的元素合成层的好处是不会影响到其他元素的绘制,因此,为了减少动画元素对其他元素的影响,从而减少 paint,可以把动画效果中的元素提升为合成层。提升合成层的最好方式是使用 CSS 的 will-change 属性。 合理管理合成层创建一个新的合成层并不是无消耗的,它得消耗额外的内存和管理资源。实际上,在内存资源有限的设备上,合成层带来的性能改善,可能远远赶不上过多合成层开销给页面性能带来的负面影响。同时,由于每个渲染层的纹理都需要上传到 GPU 处理,因此还需要考虑 CPU 和 GPU 之间的带宽问题、以及有多大内存供 GPU 处理这些纹理的问题。 防止层爆炸同合成层重叠也会使元素提升为合成层,虽然有浏览器的层压缩机制,但是也有很多无法进行压缩的情况。因此显式声明的合成层,还可能由于重叠原因不经意间产生一些不在预期的合成层,极端一点可能会产生大量的额外合成层,出现层爆炸的现象。 3 总结现在随着 web 应用的复杂性日益增加,其性能优化的重要性越来越突出,且性能优化的方法、技巧、工具也越来越丰富和复杂,本文所展示的内容仅仅只是管中窥豹,希望读者们可以在此讨论一些在实际场景中的性能优化问题以及解决方案。 讨论地址是:精读《2017 前端性能优化备忘录》 · Issue ##39 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《CSS Animations vs Web Animations API》","path":"/wiki/WebWeekly/前沿技术/《CSS Animations vs Web Animations API》.html","content":"当前期刊数: 16 本期精读文章 CSS Animations vs Web Animations API | CSS-Tricks 译文地址 CSS Animation 与 Web Animation API 之争 1. 引言 前端是一个很神奇的工种,一个合格的前端至少要熟练的使用 3 个技能,html、css 和 javascript。在传统的前端开发领域它们三个大多时候是各司其职,分别负责布局、样式以及交互。而在当代的前端开发中,由于多种原因 javascript 做的事情愈来愈多,大有一统全栈之势。服务端的 nodejs,让前端同学可以用自己的语言来开发 server。即便是在前端,我们现在好像也很少写 html 了,在 React 中出来了 JSX,在其他的开发体系中也有与之类似的前端模板代替了 html。我们好像也很少写 css 了,sass、less、stylus 等预处理器以及 css in js 出现。此外,很多 css 领域的的工作也可以通过 javascript 以更加优雅和高效的方式实现。今天我们来一起聊聊 CSS 动画与 WEB Animation API 的优劣。 2. 内容概要JavaScript 规范确实借鉴了很多社区内的优秀类库,通过原生实现的方式提供更好的性能。WAAPI 提供了与 jQuery 类似的语法,同时也做了很多补充,使得其更加的强大。同时 W3C 官方也为开发者提供了 web-animations/web-animations-js polyfill。下面简单回顾下文章内容: WAAPI 提供了很简洁明了的,我们可以直接在 dom 元素上直接调用 animate 函数: var element = document.querySelector('.animate-me');var animation = element.animate(keyframes, 1000); 第一个参数是一个对象数组,每个对象表示动画中的一帧: var keyframes = [ { opacity: 0 }, { opacity: 1 }]; 这与 css 中的 keyframe 定义类似: 0% { opacity: 0;}100% { opacity: 1;} 第二个参数是 duration,表示动画的时间。同时也支持在第二个参数中传入配置项来指定缓动方式、循环次数等。 var options = { iterations: Infinity, // 动画的重复次数,默认是 1 iterationStart: 0, // 用于指定动画开始的节点,默认是 0 delay: 0, // 动画延迟开始的毫秒数,默认 0 endDelay: 0, // 动画结束后延迟的毫秒数,默认 0 direction: 'alternate', // 动画的方向 默认是按照一个方向的动画,alternate 则表示交替 duration: 700, // 动画持续时间,默认 0 fill: 'forwards', // 是否在动画结束时回到元素开始动画前的状态 easing: 'ease-out', // 缓动方式,默认 "linear"}; 有了这些配置项,基本可以满足开发者的动画需求。同时,文中也提到了在 WAAPI 中很多专业术语与 CSS 变量有所不同,不过这些变化也更显简洁。 在 dom 元素上调用 animate 函数之后返回 animation 对象,或者通过 ele.getAnimation 方法获取 dom 上的 animation 对象。借此开发者可以通过 promise 和 event 两种方式对动画进行操作: 1. event 方式myAnimation.onfinish = function() { element.remove();} 2. promise 方式myAnimation.finished.then(() => element.remove()) 通过这种方式相对 dom 事件获取更加的简洁优雅。 3. 精读参与本次精度的同学主要来自 前端外刊评论 - 知乎专栏 的留言,该部分主要由文章评论总结而出。 WAAPI 优雅简洁web animation 的 api 设计优雅而又全面。文中比对了常见的 WAAPI 与 CSS Animation 对照关系,我们可以看到 WAAPI 更加简洁,而且语法上也更加容易为开发者接受。确实,在写一些复杂的动画逻辑时,需要灵活控制性强的接口。我们可以看到,在处理串连多个动画、截取完整动画的一部分时更加方便。如果非要说有什么劣势,个人在开发中感觉 keyframe 的很多只都只能使用字符串,不过这也是将 css 写在 js 中最常见的一种方式了。 低耦合CSS 动画中,如果需要控制动画或者过渡的开始或结束只能通过相应的 dom 事件来监听,并且在回调函数中操作,这也是受 CSS 本身语言特性约束所致。也就是说很多情况下,想要完成一个动画需要结合 CSS 和 JS 来共同完成。使用 WAAPI 则有 promise 和 event 两种方式与监听 dom 事件相对应。从代码可维护性和完整性上看 WAAPI 有自身语言上的优势。 兼容性和流畅度兼容性上 WAAPI 常用方法已经兼容了大部分现代的浏览器。如果想现在就玩玩 WAAPI,可以使用官方提供的 polyfill。而 CSS 动画我们也用了很久,基本作为一种在现代浏览器中提升体验的方式,对于老旧的浏览器只能用一些优雅的降级方案。至于流畅度的问题,文中也提到性能与 CSS 动画一般,而且提供了性能优化的方案。 4. 总结目前看来,CSS 动画可以做到的,使用 WAAPI 同样可以实现。至于浏览器支持问题,WAAPI 尚需要 polyfill 支持,不过 CSS 动画也同样存在兼容性问题。可能现在新的 API 的接受度还不够,但正如文章结尾处所说:『现有的规范和实现看起来更像是一项伟大事业的起点。』 讨论地址是:精读《CSS Animations vs Web Animations API》 · Issue ##22 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《AsyncAwait 优越之处》","path":"/wiki/WebWeekly/前沿技术/《AsyncAwait 优越之处》.html","content":"当前期刊数: 4 本期精读的文章是:6 Reasons Why JavaScript’s Async/Await Blows Promises Away 1 引言 我为什么要选这篇文章呢? 前端异步问题处理一直是一个老大难的问题,前有 Callback Hell 的绝望,后有 Promise/Deferred 的规范混战,从 Generator 配合 co 所向披靡,到如今 Async/Await 改变世界。为什么异步问题如此难处理,Async/Await 又能在多大程度上解决我们开发和调试过程中遇到的难点呢?希望这篇文章能给我们带来一些启发。 当然,本文不是一篇针对前端异步问题综合概要性的文章,更多的是从 Async/Await 的优越性谈起。但这并不妨碍我们从 Async/Await 的特点出发,结合自己在工作、开发过程中的经验教训,认真的思考和总结如何更优雅、更高效的处理异步问题。 2 内容概要Async/Await 的优点: 语法简洁清晰,节省了很多不必要的匿名函数 直接使用 try…catch… 进行异常处理 添加条件判断更符合直觉 减少不必要的中间变量 更清晰明确的错误堆栈 调试时可以轻松给每个异步调用加断点 Async/Await 的局限: 降低了我们阅读理解代码的速度,此前看到 .then() 就知道是异步,现在需要识别 async 和 await 关键字 目前支持 Async/Await 的 Node.js 版本(Node 7)并非 LTS 版本,但是下一个 LTS 版本很快将会发布 可以看出,文中提到 Async/Await 的优势大部分都是从开发调试效率提升层面来讲的,提到的问题或者说局限也只有不痛不痒的两点。 让我们来看看参与精读的同学都提出了哪些深度观点: 3 精读本次提出独到观点的同学有:@javie007 @流形 @camsong @Turbe Xue @淡苍 @留影 @黄子毅 精读由此归纳。 Async/Await 并不是什么新鲜概念参与精读的很多同学都提出来,Async/Await 并不是什么新鲜的概念,事实的确如此。 早在 2012 年微软的 C## 语言发布 5.0 版本时,就正式推出了 Async/Await 的概念,随后在 Python 和 Scala 中也相继出现了 Async/Await 的身影。再之后,才是我们今天讨论的主角,ES 2016 中正式提出了 Async/Await 规范。 以下是一个在 C## 中使用 Async/Await 的示例代码: public async Task<int> SumPageSizesAsync(IList<Uri> uris) { int total = 0; foreach (var uri in uris) { statusText.Text = string.Format("Found {0} bytes ...", total); var data = await new WebClient().DownloadDataTaskAsync(uri); total += data.Length; } statusText.Text = string.Format("Found {0} bytes total", total); return total;} 再看看在 JavaScript 中的使用方法: async function createNewDoc() { let response = await db.post({}); // post a new doc return await db.get(response.id); // find by id} 不难看出两者单纯在异步语法上,并没有太多的差异。这也是为什么 Async/Await 推出后,获得不少赞许和亲切感的原因之一吧。 其实在前端领域,也有不少类 Async/Await 的实现,其中不得不提到的就是知名网红之一的老赵写的 wind.js,站在今天的角度看,windjs 的设计和实现不可谓不超前。 Async/Await 是如何实现的根据 Async/Await 的规范 中的描述 —— 一个 Async 函数总是会返回一个 Promise —— 不难看出 Async/Await 和 Promise 存在千丝万缕的联系。这也是为什么不少参与精读的同学都说,Async/Await 不过是一个语法糖。 单谈规范太枯燥,我们还是看看实际的代码。下面是一个最基础的 Async/Await 例子: async function test() { const img = await fetch('tiger.jpg');} 使用 Babel 转换后: 'use strict';var test = function() { var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee() { var img; return regeneratorRuntime.wrap(function _callee$(_context) { while (1) { switch (_context.prev = _context.next) { case 0: _context.next = 2; return fetch('tiger.jpg'); case 2: img = _context.sent; case 3: case 'end': return _context.stop(); } } }, _callee, this); })); return function test() { return _ref.apply(this, arguments); };}();function _asyncToGenerator(fn) { return function() { var gen = fn.apply(this, arguments); return new Promise(function(resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function(value) { step("next", value); }, function(err) { step("throw", err); }); } } return step("next"); }); };} 不难看出,Async/Await 的实现被转换成了基于 Promise 的调用。值得注意的是,原来只需 3 行代码即可解决的问题,居然被转换成了 52 行代码,这还是基于执行环境中已经存在 regenerator 的前提之一。如果要在兼容性尚不是非常理想的 Web 环境下使用,代码 overhead 的成本不得不纳入考虑。 Async/Await 真的是更优秀的替代方案吗不知道是个人观察偏差,还是大家普遍都有这样的看法。在国内前端圈子里,并没有对 Async/Await 的出现表现出多么大的兴趣,几种常见的观点是:「还不是基于 Promise 的语法糖,没什么意思」、「现在使用 co 已经能完美解决异步问题,不需要再引入什么新的概念」、「浏览器兼容性这么差,用 Babel 编译又需要引入不少依赖,使用成本太高」等等。 在本次精读中,也有不少同学指出了使用 Async/Await 的局限性。 比如,使用 Async/Await 并不能很好的支持异步并发。考虑下面这种情况,一个模块需要发送 3 个请求并在获得结果后才能进行渲染,3 个请求之间没有依赖关系。如果使用 Async/Await,写法如下: async function mount() { const result1 = await fetch('a.json'); const result2 = await fetch('b.json'); const result3 = await fetch('c.json'); render(result1, result2, result3);} 这样的写法在异步上确实简洁不少,但是 3 个异步请求是顺序执行的,并没有充分利用到异步的优势。要想实现真正的异步,还是需要依赖 Promise.all 封装一层: async function mount() { const result = await Promise.all([ fetch('a.json'), fetch('b.json'), fetch('c.json') ]); render(...result);} 此外,正如在上文中提到的,async 函数默认会返回一个 Promise,这也意味着 Promise 中存在的问题 async 函数也会遇到,那就是 —— 默认会静默的吞掉异常。 所以,虽然 Async/Await 能够使用 try…catch… 这种符合同步习惯的方式进行异常捕获,你依然不得不手动给每个 await 调用添加 try…catch… 语句,否则,async 函数返回的只是一个 reject 掉的 Promise 而已。 异步还有哪些问题需要解决虽然处理异步问题的技术一直在进步,但是在实际工程实践中,我们对异步操作的需求也在不断扩展加深,这也是为什么各种 flow control 的库一直兴盛不衰的原因之一。 在本次精读中,大家肯定了 Async/Await 在处理异步问题的优越性,但也提到了其在异步问题处理上的一些不足: 缺少复杂的控制流程,如 always、progress、pause、resume 等 缺少中断的方法,无法 abort 当然,站在 EMCA 规范的角度来看,有些需求可能比较少见,但是如果纳入规范中,也可以减少前端程序员在挑选异步流程控制库时的纠结了。 3 总结Async/Await 的确是更优越的异步处理方案,但我们相信这一定不是终极处理方案。随着前端工程化的深入,一定有更多、更复杂、更精细的异步问题出现,同时也会有迎合这些问题的解决方案出现,比如精读中很多同学提到的 RxJS 和 js-csp。 讨论地址是:那些年我们处理过的异步问题 · Issue ##6 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Caches API》","path":"/wiki/WebWeekly/前沿技术/《Caches API》.html","content":"当前期刊数: 88 1 引言caches 这个 API 是针对 Request Response 的。caches 一般结合 Service Worker 使用,因为请求级别的缓存与具有页面拦截功能的 Service Worker 最配。 本周精读的文章是 cache-api,介绍了浏览器缓存接口的基本语法。 2 概述浏览器拥有全局变量 caches 操作缓存。 caches 包含任意命名空间,可以通过 caches.open 创建或访问。 const myCache = await caches.open("myCache"); 添加缓存通过 add 添加缓存。由于 caches 缓存是基于请求的,因此参数可以是一个 URL 地址,或一个完整的 Request 对象: // URL onlymyCache.add("/subscribe");// Full request objectmyCache.add(new Request('/subscribe', { method: "GET", headers: new Headers({ 'Content-Type': 'text/html' }), /* more request options */}); 每执行 add 时,浏览器都会主动请求并缓存返回的 Response。 可以通过 addAll 批量添加缓存: myCache.addAll(["/subscribe", "/assets/images/profile.png"]); 读取缓存通过 match 读取缓存。与 add 类似,参数可以是 URL 地址或完整 Request 对象,同时支持 matchAll: const res = await myCache.match("/subscribe"); 更新缓存通过 add 或 put 更新缓存。 当某个请求缓存需要更新时,你可以重新执行 add 操作。 同时 put 也可以更新缓存,你可以手动构造返回值,这样浏览器就不需要发请求了: const request = new Request("/subscribe");const fetchResponse = await fetch(request);myCache.put(request, fetchResponse); 销毁缓存通过 delete 销毁缓存。 你可以销毁某个路径的缓存: myCache.delete("/subscribe"); 也可以销毁某个缓存命名空间: caches.delete("myCache"); 结合 service Worker可以利用 addEventListener('fetch') 监听浏览器请求时机,并在匹配到缓存时,直接替换为返回结果,当缓存不存在时才继续发请求。 self.addEventListener("fetch", (e) => { e.respondWith( // Check if item exists in cache caches.match(e.request).then((cachedResponse) => { // If found in cache, return cached response if (cachedResponse) return cachedResponse; // If not found, fetch over network return fetch(e.request); }); );}); 3 精读笔者利用 caches API + service worker 实现了纯浏览器端的后端渲染。 首先基于下面三个基本事实: 利用 service worker 可以拦截请求。 caches 可以主动 put 修改缓存。 react-dom/server 可以在浏览器端执行。 这三个能力组合一下,我们真的可以实现前端 SSR: 打开页面时,利用 web worker 调用 react-dom/server 构造一个 SSR 字符串。 利用 caches.put 添加当前页面缓存,将 react-root 部分塞入构造好的 SSR 字符串。 下次打开页面时,优先命中缓存,仿佛是后端提供了 SSR 服务,但其实服务是由上一次浏览器提供的。 前端渲染有几个好处: 不消耗服务器计算资源,如果页面有百万 UV,可能一天就能节省几十万元服务器电费。 不消耗服务器存储资源,如果页面是千人千面的,后端 SSR 存储成本巨大,但分摊到个人电脑就不成问题。 不需要写两套代码。虽然服务端渲染重复利用前端资源,但 DOM 环境等都是模拟出来的,且前端代码还存在内存泄露风险,许多 SSR 的前端代码必须判断前后端环境,给维护造成了巨大负担。在前端渲染下这不成问题,我们的口号是:前端代码请交给浏览器执行。 笔者将这套前端渲染能力封装在 前端工程化工具 Pri 中,开启配置项 useServiceWorker=true clientServerRender=true 尝试。 后面有机会单独选一篇精读介绍 前端渲染,你也可以直接参考笔者 简陋的实现:由于 service worker 必须存在一个实体文件,因此脚手架会自动生成它,所以你看到的运行代码是一堆字符串。 4 总结前端渲染是一个较为极端的例子,caches 更多用来缓存简单的静态页面,静态博文,或者不经常变动的后端接口。 留下一个思考题:你还能想到 caches 的其他用法吗?欢迎留言。 讨论地址是:精读《Caches API》 · Issue ##124 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《Compilers are the New Frameworks》","path":"/wiki/WebWeekly/前沿技术/《Compilers are the New Frameworks》.html","content":"当前期刊数: 49 本期精读文章 《Compilers are the New Frameworks》 1 引言本期文章篇幅短小却言简意骇,文中开头作者就抛出自己的观点 Web 框架正在从运行库转变为优化编译器。 作者主要从编译性能方面入手,也提到 WebAssembly 可能将会是下一代 Web 应用的落脚点,因此他也建议 Web 开发者们深入了解学习编译器的工作原理。 2 概述目前业界流行使用一整套工具来搭建前端项目,如 webpack、webpack-dev-server、babel、scss、react、redux、react-router …,在项目开发期间需要花费大量时间去进行工程性能优化、编写大量的构建配置项等,从现在前端工程的复杂度以及前端开发的工作量来看,前端框架已经不能再仅仅只是一个单独的视图层或数据处理层,而应该是一套相对完整的框架,它不仅提供如何编写前端页面的方法,同时也应该考虑代码构建编译的性能、页面间路由的跳转、新语法的兼容等一系列问题。 这也正是本期精读文章抛出的观点,Web 框架正在从运行库转变为优化编译器,或者说 Web 框架需要将优化编译性能考虑进去。 PriJs & UmiJsPriJs & UmiJs 二者正是以上述观点为基础的,基于 react 并包含了工具 & 路由 & 性能优化 & 数据流等强约定弱配置的前端一站式框架,通过约定、自动生成和解析代码等方式来辅助开发,减少开发者在性能&配置&路由&构建上耗费的时间,可以更专注于业务逻辑。 构建工具webpack 是目前主流的前端代码构建工具,但其复杂的配置一直是前端开发者头疼之处,PriJs & UmiJs 框架内部解决了这一难题,它们将 webpack 复杂的性能优化配置全部内置化,使项目在 0 配置的基础上直接支持 PWA、Automatic code splitting、Tree Shaking、Auto dll、Import on demand、Auto pick shared modules、Scope Hoist、Dynamic import、Service Worker、Sass Loader 等。 页面&路由PriJs & UmiJs 提供页面生成模版,并自动根据项目页面生成路由,通过单页面或多页面特性决定路由跳转的类型,默认提供 404 页面。 数据流PriJs & UmiJs 虽然是基于 react 的前端一站式框架,暂不支持 vue、angular 等,但并不局限数据流的使用的方式,可以根据项目需求使用任意数据流方式,如 redux、mobx 等。 插件机制PriJs & UmiJs 提供了灵活的插件机制,使项目能够拥有强大的定制能力,通过插件机制可以变更 webpack 配置、修改路由规则、修改页面模版、新增命令、使用任意数据流、定制项目规范和约定等。 其它此外,PriJs 还支持 markdown 格式、支持 Deploy to github pages、支持 Typescript 等。 PriJs & UmiJs 前端一站式框架实际上是提供了一整套的前端开发解决方案,它不仅仅只是单纯的一个运行库,而是将构建性能&工具&路由等一系列问题全部解决,这种做法在一定程度上不正是在说明 Compilers are the New Frameworks。 读者们对此肯定有很多不同的观点和看法,不妨各抒己见。 3 精读精读文章作者建议 Web 开发者学习编译器工作原理,对于前端开发者来说可以从与前端现在和未来息息相关的 JIT 和 WebAssembly 入手学习编译器相关原理。 JITJIT(Just-in-Time)主要是针对 javascript 这一解释型语言所做的性能优化,即浏览器引入编译器来解决解释器性能低效的问题,形成混合的模式。 监视器浏览器在 js 引擎中增加一个监视器,用于监控通过解释器的代码的运行情况,并将同一行代码运行若干次标记为 warm,将同一行代码运行很多次标记为 hot。 基线器JIT 会将 warm 代码段放到基线编译器中,并将编译结果存储起来。该代码段的每一行都会被编译成一个 stub,并以 行号 + 变量类型 为索引。如果监视器监视到了执行同样的代码和变量类型,就直接将对应的已编译版本提交给浏览器执行,而不用重新通过解释器来翻译,通过这样的做法可以加快执行速度。 优化器JIT 会将 hot 代码段放到优化编译器中进行代码优化,不过需要遵循优化规则:即如果代码循环中每次迭代的对象都有相同的形状,那么就认为它以后迭代的对象的形状也是相同的。但 javascript 是没有类型定义的,就无法确保每次代码迭代的对象都会具有相同类型,因此在代码运行前会检查其规则是否合理,如果合理则执行优化代码,如果不合理则丢弃优化代码,重新回到解释器或基线器。大多数浏览器为了防止引起 优化 - 丢弃优化 的无限循环,一般会对优化次数做限制,比如 JIT 做了超过 10 次 优化 - 丢弃优化 的操作,那么就不再执行优化编译。 JIT 在优化提升 javascript 性能的同时也会增加多余的其它开销,主要是对代码的监视和编译时间的开销,具体包括: 优化和丢弃优化的开销 监视器存储的内存开销 丢弃优化时恢复存储的内存开销 基线版本和优化后版本的内存开销 而 WebAssembly 从更底层去解决这部分多余开销,进一步提升 Web 应用的性能。 WebAssembly为什么说 WebAssembly 更为高效,性能更好? 在 JS 引擎中性能消耗的分布大致为:将源码转为解释器可运行代码 -> 基线&优化编译器的运行 -> 优化-丢弃优化的过程 -> 执行代码 -> 垃圾回收&内存清理,这个过程是交叉进行的。 而 WebAssembly 却只要简单的三个步骤即可完成 JS 引擎的整个交叉执行过程。 Parse当到达浏览器时,JS 源码需要被解析成 AST(抽象语法树)变成字节码提供给引擎编译,而 WebAssembly 却不需要这种转换,因为其本身就是字节码,因此它只需对代码进行 decode 并检查其正确性即可。 Compile + Optimize这是执行代码编译和优化的阶段,在这个阶段 WebAssembly 的性能优于 JS 的主要原因为: WebAssembly 是有类型定义的代码,不需要在编译前运行代码来获取变量类型 WebAssembly 不需要像 JS 那样当变量类型改变时需要将代码编译成不同版本 WebAssembly 不需要在编译阶段做太多的优化工作 Re-optimize当 JIT 在执行 JS 阶段发现变量类型不合理,就会丢弃优化代码重新进行 优化 - 丢弃优化 的循环,而 WebAssembly 中的变量类型都是确定的,JIT 不需要检查变量类型的合理性,因此并没有重优化阶段。 Execute如果开发者了解 JIT 的内部实现机制,当然是可以针对性的写出符合 JIT 标准的代码,使之具有更高的执行效率,但通常开发者为了代码可读性更好而使用的编码模式往往却不适合编译器对代码的优化,而且不同浏览器的优化规则也不尽相同,导致 JS 的执行效率并不高。 WebAssembly 正是为了编译器而设计的,很多 JIT 为 JS 所做的优化 WebAssembly 并不需要,使得 WebAssembly 专注于提供执行效率更高的指令。 Garbage collectionJS 不支持开发者手动清理内存,而是由 JS 引擎自动做垃圾回收,因此垃圾回收的时机并不可控,有可能会在一个不合适的时机执行,而且也会增加代码执行的开销。而对于 WebAssembly 而言,其内存操作是由开发者手动控制的,虽然会增加一些开发成本,不过这也使的代码执行效率更高。 4 总结本文从 Web 框架正在从运行库转变为优化编译器 这一观点切入,讨论了 PriJs & UmiJs 前端框架的思路转变,简洁的描述了 JIT 的工作原理以及 WebAssembly 相比于 JS 的性能优势。 本文主要希望读者可以积极参与讨论此观点,因此并没有长篇剖析 JIT & WebAssembly 的深刻原理,但我相信深入学习编译器的工作原理对 Web 开发者来说绝对是受益匪浅的事情,后续文章将对 WebAssembly 进行深入探讨和剖析。 5 更多讨论 讨论地址是:精读《Compilers are the New Frameworks》 · Issue ##69 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周末发布。"},{"title":"《DOM diff 原理详解》","path":"/wiki/WebWeekly/前沿技术/《DOM diff 原理详解》.html","content":"当前期刊数: 190 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 如图所示,理想的 Dom diff 自然是滴水不漏的复用所有能复用的,实在遇到新增或删除时,才执行插入或删除。这样的操作最贴近 Jquery 时代我们手写的 Dom diff 性能。 可惜程序无法猜到你的想法,想要精确复用就必须付出高昂的代价:时间复杂度 O(n³) 的 diff 算法,这显然是无法接受的,因此理想的 Dom diff 算法无法被使用。 关于 O(n³) 的由来。由于左树中任意节点都可能出现在右树,所以必须在对左树深度遍历的同时,对右树进行深度遍历,找到每个节点的对应关系,这里的时间复杂度是 O(n²),之后需要对树的各节点进行增删移的操作,这个过程简单可以理解为加了一层遍历循环,因此再乘一个 n。 简化的 Dom diff 如图所示,只按层比较,就可以将时间复杂度降低为 O(n)。按层比较也不是广度遍历,其实就是判断某个节点的子元素间 diff,跨父节点的兄弟节点也不必比较。 这样做确实非常高效,但代价就是,判断的有点傻,比如 ac 明明是一个移动操作,却被误识别为删除 + 新增。 好在跨 DOM 复用在实际业务场景中很少出现,因此这种笨拙出现的频率实际上非常低,这时候我们就不要太追求学术思维上的严谨了,毕竟框架是给实际项目用的,实际项目中很少出现的场景,算法是可以不考虑的。 下面是同层 diff 可能出现的三种情况,非常简单,看图即可: 那么同层比较是怎么达到 O(n) 时间复杂度的呢?我们来看具体框架的思路。 Vue 的 Dom diffVue 的 Dom diff 一共 5 步,我们结合下图先看前三步: 如图所示,第一和第二步分别从首尾两头向中间逼近,尽可能跳过首位相同的元素,因为我们的目的是 尽量保证不要发生 dom 位移。 这种算法一般采用双指针。如果前两步做完后,发现旧树指针重合了,新树还未重合,说明什么?说明新树剩下来的都是要新增的节点,批量插入即可。很简单吧?那如果反过来呢?如下图所示: 第一和第二步完成后,发现新树指针重合了,但旧树还未重合,说明什么?说明旧树剩下来的在新树都不存在了,批量删除即可。 当然,如果 1、2、3、4 步走完之后,指针还未处理完,那么就进入一个小小算法时间了,我们需要在 O(n) 时间复杂度内把剩下节点处理完。熟悉算法的同学应该很快能反映出,一个数组做一些检测操作,还得把时间复杂度控制在 O(n),得用一个 Map 空间换一下时间,实际上也是如此,我们看下图具体做法: 如图所示,1、2、3、4 步走完后,Old 和 New 都有剩余,因此走到第五步,第五步分为三小步: 遍历 Old 创建一个 Map,这个就是那个换时间的空间消耗,它记录了每个旧节点的 index 下标,一会好在 New 里查出来。 遍历 New,顺便利用上面的 Map 记录下下标,同时 Old 在 New 中不存在的说明被删除了,直接删除。 不存在的位置补 0,我们拿到 e:4 d:3 c:2 h:0 这样一个数组,下标 0 是新增,非 0 就是移过来的,批量转化为插入操作即可。 最后一步的优化也很关键,我们不要看见不同就随便移动,为了性能最优,要保证移动次数尽可能的少,那么怎么才能尽可能的少移动呢?假设我们随意移动,如下图所示: 但其实最优的移动方式是下面这样: 为什么呢?因为移动的时候,其他元素的位置也在相对变化,可能做了 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 更长,所以选择后者。 换成程序去做,可以采用贪心 + 二分法进行查找,详细可以看这道题 最长递增子序列,时间复杂度 O(nlogn)。由于该算法得出的结果顺序是乱的,Vue 采用提前复制数组的方式辅助找到了正确序列。 React 的 Dom diff 假设这么一种情况,我们将 a 移到了 c 后,那么框架从最终状态倒推,如何最快的找到这个动机呢?React 采用了 仅右移策略,即对元素发生的位置变化,只会将其移动到右边,那么右边移完了,其他位置也就有序了。 我们看图说明: 遍历 Old 存储 Map 和 Vue 是一样的,然后就到了第二步遍历 New,b 下标从原来的 1 变成了 0,需要左移才行,但我们不左移,我们只右移,因为所有右移做完后,左移就等于自动做掉了(前面的元素右移后,自己自然被顶到前面去了,实现了左移的效果)。 同理,c 下标从 2 变成了 1,需要左移才行,但我们继续不动。 a 的下标从 0 变成 2,终于可以右移了! 后面的 d、e 下标没变,就不用动。我们纵观整体可以发现,b 和 c 因为前面的 a 被抽走了,自然发生了左移。这就是用一个右移代替两个左移的高效操作。 同时我们发现,这也确实找到了我们开始提到的最佳位移策略。 那这个算法真的有这么聪明吗?显然不是,这个算法只是歪打误撞碰对了而已,有用右移替代左移的算法,就有用左移替代右移的算法,既然选择了右移替代左移,那么一定丢失了左移代替右移的效率。 什么时候用左移代替右移效率最高?就是把数组最后一位移到第一位的场景: 显然左移只要一步,那么右移就是 n-1 步,在这个例子就是 4 步,我们看右移算法图解: 首先找到 e,位置从 4 变成了 0,但我们不能左移!所以只能保持不动,悲剧从此开始。 虽然算法已经不是最优了,但该做的还是要做,其实之前有一个 lastIndex 概念没有说,因为 e 已经在 4 的位置了,所以再把 a 从 0 挪到 1 已经不够了,此时 a 应该从 0 挪到 5。 方法就是记录 lastIndex = max(oldIndex, newIndex) => lastIndex = max(4, 0),下一次移动到 lastIndex + 1 也就是 5: 发现 a 从 0 变成了 5(注意,此时考虑到 lastIndex 因素),所以右移。 同理,b、c、d 也一样。我们最后发现,发生了 4 次右移,e 也因为自然左移了 4 次到达了首位,符合预期。 所以这是一个有利有弊的算法。新增和删除比较简单,和 Vue 差不多。 PS:最新版 React Dom diff 算法如有更新,欢迎在评论区指出,因为这种算法看来不如 Vue 的高效。 总结Dom diff 总结有这么几点考虑: 完全对比 O(n³) 无法接受,故降级为同层对比的 O(n) 方案。 为什么降级可行?因为跨层级很少发生,可以忽略。 同层级也不简单,难点是如何高效位移,即最小步数完成位移。 Vue 为了尽量不移动,先左右夹击跳过不变的,再找到最长连续子串保持不动,移动其他元素。 React 采用仅右移方案,在大部分从左往右移的业务场景中,得到了较好的性能。 讨论地址是:精读《DOM diff 原理详解》· Issue ##308 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《BI 搭建 - 筛选条件》","path":"/wiki/WebWeekly/前沿技术/《BI 搭建 - 筛选条件》.html","content":"当前期刊数: 166 筛选条件是 BI 搭建的核心概念,我们大部分所说的探索式分析、图表联动也都属于筛选条件的范畴,其本质就是一个组件对另一个组件的数据查询起到筛选作用。 筛选组件是如何作用的我们最常见的筛选条件就是表单场景的查询控件,如下图所示: 若干 “具有输出能力” 的组件作为筛选组件,点击查询按钮时触发其作用组件重新取数。 注意这里 “具有输出能力” 的组件不仅是输入框等具有输入性质的组件,其实所有具备交互能力的组件都可以,甚至可以由普通组件承担筛选触发的能力: 一个表格的表头点击也可以触发筛选行为,或者柱状图的一个柱子被点击都可以,只要进行到这层抽象,组件间联动本质也属于筛选行为。 同样重要的,筛选作用的组件也可以是具备输入能力的组件: 当目标组件是具备筛选能力组件时,这就是筛选联动场景了,所以 筛选联动也属于普通筛选行为。至于目标组件触发取数后,是否立即修改其筛选值,进而触发后续的筛选联动,就完全由业务特性决定了。 一个组件也可以自己联动自己筛选,比如折线图点击下钻的场景,就是自己触发了筛选,作用到自己的例子。 什么是筛选组件任何组件都可以是筛选组件。 可能最容易理解的是输入框、下拉框、日期选择器等具备输入特征的组件,这些组件只能说天然适合作为筛选组件,但不代表系统设计要为这些组件特殊处理。 扩大想一想,其实普通的按钮、表格、折线图等等 具有展示属性的组件也具有输入特性的一面,比如按钮被点击时触发查询、单元格被点击时想查询当前城市的数据趋势、折线图某条线被点击时希望自身从年下钻到月等等。 所以 不存在筛选组件这概念,而是任何组件都具有筛选的能力,因此筛选是一种任何组件都具有的能力,而不局限在某几个组件上,一旦这么设计,可以做到以下几点: 实现输入类组件到展示类组件的筛选,符合基本筛选诉求。 实现展示类组件到展示类组件的筛选,属于图表联动图表的高级功能。 实现输入类组件到输入类组件的筛选,属于筛选联动功能。 实现组件自身到自身的筛选,实现下钻功能。 下面介绍 bi-designer 的筛选条件设计。 筛选条件设计基于上述分析,bi-designer 在组件元信息中没有增加所谓的筛选组件类型,而是将其设定为一种筛选能力,任何组件都能触发。 如何触发筛选组件调用 onFilterChange 即可完成筛选动作: import { useDesigner } from "@alife/bi-designer";const InputFilter = () => { const { onFilterChange } = useDesigner(); return ( <input onChange={(event) => () => onFilterChange(event.target.value)} /> );}; 但这种开发方式违背了 低侵入 的设计理念,我们可以采用组件与引擎解构的方式,让输入框变更的时候直接调用 props.onChange ,这个组件保持了最大的独立性: const InputFilter = ({ onChange }) => { return <input onChange={(event) => () => onChange(event.target.value)} />;}; 那渲染引擎怎么将 onFilterChange 映射到 props.onChange 呢?如下配置 DSL 即可: { "props": { "onChange": { "type": "JSExpression", "value": "this.onFilterChange" } }} 筛选影响哪些组件一般筛选组件会选择作用于的目标组件,类似下图: 这些信息会存储在筛选组件的组件配置中,即 componentInstance.props,筛选目标组件在 componentMeta.eventConfigs 组件元信息的事件中配置: 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 推导出最终取数参数: 最终,组件元信息只要写一个 getFetchParam 回调函数即可,可以自动拿到作用于它的筛选组件,而不用关心是哪些配置导致了关联,只要响应式的去处理筛选作用即可。 import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { // 组装取数参数 getFetchParam: ({ componentInstance, filters }) => { // 结合 componentInstance 与 filters.map... 返回取数参数 },}; 筛选组件间联动带来的频繁取数问题对于筛选联动的复杂场景,会遇到频繁取数的问题。 假设国家、省、市三级联动筛选条件同时 filterFetch 作用于一个表格,这个表格取数的筛选条件需要同时包含国家、省、市三个参数,但我们又设置了 国家、省、市 这三个筛选组件之间的 filterFetch 作为筛选联动,那么国家切换后、省改变、联动市改变,这个过程筛选值会变化三次,但我们只想表格组件取数函数仅执行最后的一次,怎么办呢? 如上图所示,其实每个筛选条件在渲染引擎数据流中还存储了一个 ready 状态,表示筛选条件是否就绪,一个组件关联的筛选条件只要有一个 ready 不为 true,组件就不会触发取数。 因此我们需要在筛选变化的过程中,总是保证一个筛选组件的 ready 为 false,等筛选间联动完毕了,所有筛选器的 ready 为 true,组件才会取数,我们可以使用 filterReady 筛选依赖配置: 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,其他流程没有任何感知。 若干筛选组件聚合成一个查询控件除了联动外,也会存在防止频繁查询的诉求,希望将多个筛选条件绑定成一个大筛选组件,在点击 “查询” 按钮时再取数: 可以利用 筛选作用域 轻松实现此功能,只需要两步: 筛选组件设置独立筛选作用域import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { // 通过 componentInstance 判断,如果是全局筛选器内部,则设置 filterScope filterScope: ({ componentInstance }) => ["my-custom-scope-name"],}; 这样,这批筛选组件就与其作用的组件属于不同的 筛选作用域 了,所以筛选不会对其立即生效,功能实现了一半。 确认按钮点击时调用 submitFilterScopeimport { useDesigner } from '@alife/bi-designer'const componentMeta: Interfaces.ComponentMeta = { const { submitFilterScope } = useDesigner() // 点击确认按钮时,调用 submitFilterScope('my-custom-scope-name')}; 你可以在点击查询按钮后调用 submitFilterScope 并传入对应作用域名称,这样作用域内筛选组件就会立即对其 target 组件生效了。 至于确认按钮、UI 上的聚合,这些你可以写一个自定义组件去做,利用 ComponentLoader 把筛选组件聚合到一起加载,总之功能与 UI 是解耦的。 如果你对原理感兴趣,可以再多看一下这张图: 突破筛选作用域然而实际场景中,可能存在更复杂的组合,见下面的例子: 筛选器 1 同时对 筛选器 2、表格 产生筛选作用 filterFetch,但对 表格 的作用希望通过查询按钮拦截住,而对 筛选器 2 的作用希望能立即生效,对于这个例子有两种方式解决: 最简单的方式就是将 筛选器 1、筛选器 2 设置为相同作用域 group1,这样就通过作用域分割自然实现了效果,而且这本质上是两个筛选器 UI 不在一起,但筛选作用域相同的例子: 但是再变化一下,如果筛选器 2 也对表格产生筛选作用,那我们将 筛选器 1、筛选器 2 放入同一个 group1 等于对表格的查询都会受到 “查询” 按钮的控制,但 我们又希望筛选器 2 可以立即作用于表格: 如图所示,我们只能将 筛选器 1 的筛选作用域设置为 group1,这样 筛选器 2 与 表格 属于同一个筛选作用域,他们之间筛选会立即生效,我们只要解决 筛选器 1 不能立即作用于 筛选器 2 的问题即可,可以通过 ignoreFilterScope 方式突破筛选作用域: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Deno 1","path":"/wiki/WebWeekly/前沿技术/《Deno 1.html","content":"当前期刊数: 150 1 引言Deno 是什么?Deno 和 Node 有什么关系?Deno 和我有什么关系? Deno 将于 2020-05-13 发布 1.0,如果你还有上面的疑惑,可以和我一起通过 Deno 1.0: What you need to know 这篇文章一起了解 Deno 基础知识。 希望你带着疑问思考,未来 10 年看今天,会不会出现 Deno 官方生态壮大,完全替代 Node 进而影响到 Web 生态的局面呢?这个思考结果会影响到你未来职业发展,你需要学会自己思考,并对这个思考结果负责。 2 介绍 & 精读Deno 的作者是 Ryan Dahl,他是 Nodejs 背后的策划者,曾经说过 我对 Nodejs 感到遗憾的 10 件事。这也是为什么新开一个坑的原因,但 Deno 并不定位为 Nodejs 的替代品,从整体功能来看,Deno 有更大的野心,据我的推测是想要取代现在陈旧的前后端开发模式,让 Deno 一统前后端开发全流程。 Nodejs 是由 C++ 写的,而 Deno 则是由 Rust 写的,并选择了 Tokio 这个异步编程框架,并使用 V8 引擎解析 Javascript,并内置了对 Ts 的解析。 安装Deno 支持如下安装方式: Shell: curl -fsSL https://deno.land/x/install/install.sh | sh PowerShell: iwr https://deno.land/x/install/install.ps1 -useb | iex Homebrew: brew install deno Chocolatey: choco install deno 脚本执行方式为 deno run,可以类比为 node,但功能不同且支持远程文件,实际上远程依赖是 Deno 的一大特色,也是有争议的地方: deno run https://deno.land/std/examples/welcome.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 " });} 安全性Deno 是默认安全的,这体现在默认没有环境、网络访问权限、文件读写权限、运行子进程的能力。所以如果直接运行一个依赖权限的文件会报错: 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 等: deno --allow-read=/etc 上面表示 /etc 文件夹下的文件拥有文件读权限。 除了直接加参数调用、Bash 脚本调用外,还可以用 Make 运行,或者使用类似的 drake 启动。 或者使用 deno install 命令,将脚本转化为一个快捷指令: 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 生态官方库,都保证会持续维护,你更相信谁呢?官方是否有优势要取决于官方自身的实力。 内置 TypescriptDeno 内置支持了 TS,因此不需要 ts-node 我们就可以用 deno run test.ts 运行 Typescript 文件。值得注意的是,Deno 内部也是利用 Typescript 引擎解析为 Js 后交由 V8 引擎解析,因此本质上没太大的变化,只是这样 Deno 的生态会更规范。 由于内置了 TS 支持,自然也不需要写 tsconfig.json 配置了,但你依然可以定制它: deno run -c tsconfig.json [file-to-run.ts] Deno 默认还开启了 TS 严格模式,所以看到这里,可以认为 Deno 是为了构建高质量理想库而诞生的运行环境,基于已有的生态来做,但做了更多内置技术选型,这和 Facebook 的 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 规范,但引用路径必须加上后缀: 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 去中心化” 这件事,虽然引用了网络源,但会引发下面几个问题: 实际上还存在一个 “node_modules”,只是用户看不到。 网络下载速度放到运行时,第一次启动还是很慢。 普通模式下无 lock,必须配合 deps.ts 使用,这个后面会提到。 即使被打上 “中心化恶人” 的 npm 也有去中心化的一面,因为 npm 支持私有化部署,无论是速度还是稳定性都可以由公司自己掌控,从稳定性来说还是 npm 拥有压倒性优势。 三方库Deno 还有第三方库生态,截止目前共有 221 个三方库。 由于 Deno 走网络资源,我们可以借助 Pika 提供的 CDN 服务直接引用网络资源包: import * as pkg from "https://cdn.pika.dev/preact@^10.3.0"; 虽然这样看上去很轻量,但对公司来说还是需要自建一个 “Pika” 保障稳定性,以及做全球 CDN 缓存等的工作。 告别 package.jsonnpm 生态下包信息存放在 package.json,包含但不限于下面的内容: 项目元信息。 项目依赖和版本号。 依赖还进行分类,比如 dependencies、devDependencies 甚至 peerDependencies。 标记入口,main 和 module,还有 TS 用的 types 与 typings,脚手架的 bin 等等。 npm scripts。 随着标准的不断更新,package.json 信息已经非常臃肿了。 对于 Deno 来说,则使用 deps.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 时,就可以这么写了: // import { assert } from "https://deno.land/std@v0.39.0/testing/asserts.ts";import { assert } from "./deps.ts"; 如果需要锁定依赖,可以通过 deno --lock=lock.json 方式申明。 deno docdeno doc <filename> 命令可以根据文件按照 JS Doc 规则生成文档,同时也支持 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> { // ...} 生成文档如下: function send(_: Context, path: string, options: SendOptions): Promise<string | undefined>Asynchronously fulfill a response with a file from the local file system. deno 本身文档就是用这个命令生成的,可以 访问官方文档 查看使用效果。 内置工具链前端 Javascript 工具链相当混乱,虽然业界已有 Umi 等框架做了开箱即用的封装,但回到 Javascript 设计的初衷就是可以在浏览器直接使用的,包括浏览器对不依赖构建工具的模块化支持,注定了未来 Webpack 一定会被消灭。 Deno 通过内置一套工具链的方式解决这个问题,包括: 测试:提供 deno test 命令与 Deno.test() 测试函数。 格式化:提供 vscode 插件。 编译:提供 deno bundle 命令。 不过值得注意的是,在最重要的编译环节,deno bundle 目前提供的能力是相对欠缺的,比如还不支持 Tree Shaking。 用 Rust 等语言提升构建效率是业界一直在尝试的事,比如 @陈成 就基于 esbuild 做了 @umijs/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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《DOM diff 最长上升子序列》","path":"/wiki/WebWeekly/前沿技术/《DOM diff 最长上升子序列》.html","content":"当前期刊数: 192 在 精读《DOM diff 原理》 一文中,我们提到了 Vue 使用了一种贪心 + 二分的算法求出最长上升子序列,但并没有深究这个算法的原理,因此特别开辟一章详细说明。 另外,最长上升子序列作为一道算法题,是非常经典的,同时在工业界具有实用性,且有一定难度的,因此希望大家务必掌握。 精读什么是最长上升子序列?就是求一个数组中,最长连续上升的部分,如下图所示: 如果序列本身就是上升的,那就直接返回其本身;如果序列没有任何一段是上升的,则返回任何一个数字都可以。图中可以看到,虽然 3, 7, 22 也是上升的,但因为 22 之后接不下去了,所以其长度是有 3,与 3, 7, 8, 9, 11, 12 比起来,肯定不是最长的,因此找起来并不太容易。 在具体 DOM diff 场景中,为了保证尽可能移动较少的 DOM,我们需要 保持最长上升子序 不动,只移动其他元素。为什么呢?因为最长上升子序列本身就相对有序,只要其他元素移动完了,答案也就出来了。还是这个例子,假设原本的 DOM 就是这样一个递增顺序(当然应该是 1 2 3 4 连续的下标,不过对算法来说是否连续间隔不影响,只要递增即可): 如果保持最长上升子序不变,只需要移动三次即可还原: 其他任何移动方式都不会小于三步,因为我们已经最大程度保持已经有序的部分不动了。 那么问题是,如何将这个最长上升子序列找出来?比较容易想到的解法分别有:暴力、动态规划。 暴力解法 时间复杂度: O(2ⁿ) 我们最终要生成一个最长子序列长度,那么就来模拟生成这个子序列的过程吧,只不过这个过程是暴力的。 暴力模拟生成一个子序列怎么做呢?就是从 [0,n] 范围内每次都尝试选或不选当前数,前提是后选的数字要比前面的大。由于数组长度为 n,每个数字都可以选或不选,也就是每个数字有两种选择,所以最多会生成 2ⁿ 个结果,从里面找到最长的长度,即为答案: 这么傻试下去,必然能试出最长的那一段,在遍历过程中记录最长的那一段即可。 由于这个方法效率太低了,所以并不推荐,但这种暴力思维还是要掌握的。 动态规划 时间复杂度: O(n²) 如果用动态规划思路考虑此问题,那么 DP(i) 的定义按照经验为:以第 i 个字符串结尾时,最长子序列长度。 这里有个经验,就是动规一般 DP 返回值就是答案,字符串问题常常是以第 i 个字符串结尾,这样扫描一遍即可。而且最长子序列是有重复子问题的,即第 i 个的答案运算中,包括了前面一些的计算,为了不重复计算,才使用动态规划。 那么就看第 i 项的结果和前面哪些结果有关系了,为了方便理解如图所示: 假设我们看 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 项组合起来更长哦?我们可以举个反例: 很显然,1, 2, 3, 4 组合起来是最长的上升子序列,如果你只看 5, 4,那么得出的答案只能是 4。 正是由于不连续这个特点,我们对于第 i 项,需要和第 j 项依次对比,其中 j=[0,i-1],只有和所有前项都比一遍,我们才放心,第 i 项找到的结果确实是最长的: 那么时间复杂度怎么算呢?动态规划解法中,我们首先从 0 循环到 n,然后对于其中每个 i,都做了一遍 [0,i-1] 的额外循环,所以计算次数是 1 + 2 + ... + n = n * (n + 1) / 2,剔除常数后,数量级是 O(n²)。 贪心 + 二分 时间复杂度: O(nlogn) 说实话,一般能想到动态规划解法就很不错了,再进一步优化时间复杂度就非常难想了。如果你没做过这道题,并且想挑战一下,读到这里就可以停止了。 好,公布答案了,说实话这个方法不像正常人类思维想出来的,具有很大的思维跳跃性,因此我也无法给出思维推导过程,直接说结论吧:贪心 + 二分法。 如果非要说是怎么想的,我们可以从时间复杂度上事后诸葛亮一下,一般 n² 时间复杂度再优化就会变成 nlogn,而一次二分查找的时间复杂度是 logn,所以就拼命想办法结合吧。 具体方案就一句话:用栈结构,如果值比栈内所有值都大则入栈,否则替换比它大的最小数,最后栈的长度就是答案: 先解释下时间复杂度,因为操作原因,栈内存储的数字都是升序的,因此可以采用二分法比较与插入,复杂度为 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,由于每个数字都比之前的大,所以没什么好思考的,直接塞到栈里: 遇到 9 的时候精彩了,此时 9 不是最大的,我们为了抓住稳稳的幸福,干脆把比 9 稍大一点的 11 替换了,这样会产生什么结果? 首先数组长度没变,因为替换操作不会改变数组长度,此时如果 9 后面没有值了,我们也不亏,此时输出的长度 4 依然是最优的答案。我们继续,下一步遇到 11,我们还是把比它稍大的 15 替换掉: 此时我们替换了最后一个数字,发现 3, 7, 9, 11 终于是个合理的顺序了,而且长度和 3, 7, 11, 15 一样,但是更有潜力,接下来 12 就理所应当的放到最后,拿到了最终答案:5。 到这里其实并没有说清楚这个算法的精髓,我们还是回到 3, 7, 9, 15 这一步,搞清楚 9 为什么可以替换掉 11。 假设 9 后面是一个很大的 99,那么下一步 99 会直接追加到后面: 此时我们拿到的是 3, 7, 9, 15, 99,但是你仔细看会发现,原序列里 9 在 15 后面的,因为我们的插入导致 9 放到 15 前面了,所以这显然不是正确答案,但长度却是正确的,因为这个答案就相当于我们选择了 3, 7, 11, 15, 99!为什么可以这么理解呢?因为 只要没有替换到最后一个数,我们心里的那个队列其实还是原始队列。 **即,只要栈没有被替换完,新插入的值永远只起到一个占位作用,目的是为了让新来的值好插入,但如果真的没有新来的值可插入了,那虽然栈内容不对,但至少长度是对的,因为 9 在没替换完的时候其实不是 9,它只是一个占位,背后的值还是 11**。所以不管怎么换,只要没替换掉最后一个,这个替换操作都是无效的,我们再拿一个例子来看: 可见,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 呢?我们看看能替换完是什么情况: 可见,当替换到 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 翻脸了,因为它的实力已经超出原来老大实力了。 那我们前面看似无关紧要的替换,其实就为了不断寻找未来可能的最优解,直到有出头之日那一天,如果没有出头之日,做一个小弟也挺好,长度还是对的;如果有出头之日,那最大长度就更新了,所以这种贪心可以同时兼顾正确性与效率。 最后我们看看,如何在找到答案的同时,还能找到正确的序列呢? 找出正确的序列找出正确的序列并不容易,让我们看下面这个情况: 贪心算法结束后,总长度是对的,但很明显顺序还是错的。为了方便计算,我们存储时转化为下标: 并且使用二维数组存储,这样被替换的数字可以被保留下来。当计算完毕后,我们从最后一位开始向前查找,一旦发现一个值不是单调递减的,就向数组上方继续查找,直到首节点。 因此上面的例子,最终顺序下标是 [0, 1, 2, 3, 4, 5, 9],对应数字为 [10, 20, 30, 40, 50, 60, 61],而且这个数字是潜力最大的最长子序列。 总结那么 Vue 最终采用贪心计算最长上升子序列,付出了多少代价呢?其实就是 O(n) 与 O(nlogn) 的关系,我们看图: 可以看到,O(nlogn) 时间复杂度增长趋势勉强可以接受,特别是在工程场景中,一个父节点的子节点个数不可能太多的情况下,不会占用太多分析的时间,带来的好处就是最少的 DOM 移动次数。是比较完美的算法与工程结合的实践。 讨论地址是:精读《DOM diff 最长上升子序列》· Issue ##310 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Elements of Web Dev》","path":"/wiki/WebWeekly/前沿技术/《Elements of Web Dev》.html","content":"当前期刊数: 51 1 引言本周精读, 来一起总结 web 开发的环节, 知识块和技能点. 是不是像 xx 速成班宣传的一样, 培训三个月, 经验顶三年, 入职 BAT, 年薪三十万? 本文虽然是罗列知识点, 但我想很有意义. 对于学习的人来说, 提供一个路线图. 对从业者来说, 对全局有更好的把控, 利于看到自己的强项和不足. 对组建团队, 更能起到一个点将谱的作用. 在网上我没有搜到任何深入全面的总结, 提供的那几篇已经算稍微好一些的了. 其他的要么太过笼统(前端-后端-数据-运维, 完毕)要么太细太窄(并不是不好, 只是和本文性质不一样). Generalist 和 Specialist 之间永远是一对辩证矛盾, 持续思考. 本文提供了有层级的列表形式, 如果有兴趣的读者可以把它做成概念图形式, 相互关联与距离相关, 可能会有意料之外的效果. 2 列表列表形式, 方便搜索浏览, 加上一些解释和列举 Backend authentication, oauth API design, RESTful, GraphQL payment integration social integration session/cookie management user management Server, e.g. nginx, connection model, conf, rewrite CRM Deployment, Env management, Container rollback/rollforward no downtime, non-disruptive deployment deploy to downstreams, e.g. npm, chrome extension store artefact management, e.g. gzips, OS specific builds container technology e.g. Docker, AWS ami DB schema design ORM language/env specific driver test/seed data backup batching, DB perfgomance query syntax, e.g. SQL, mongo query syntax connection pool/concurrent connection management connection restriction e.g. localhost only MessagingQueue/MiddlewareTesting parallel execution UI automated testing browser/OS compatibility, e.g. headless browser, cloud solutions screenshot diff regression unit test, isolation mocking integration test techniques coverage (line coverage, path coverage etc.), permutations Security CSRF, XSS, SQL injection, DDoS, brute force etc. automated tools OS OS differences, e.g. filesystem, path separator run daemon, startup job, process manager ssh everything bash, zsh, powershell, e.g. wildcard, expansion, syntax everything *nix, du, filesystem, ps, process model, netstat, pipes networking HTTP, and everything it entails e.g. CORS, MIME types, chunked websocket webworker, service worker proxy Web visualization technologies and principles webgl 2D/3D coord system and calculations Web standards, e.g. webassemblyperformance tuning frontend: lighthouse backend: load balancing, perf monitoring and profiling Source control work flow tagging, release, branching PR, collaboration commit message conventions Project management, Product management main success scenario PRD doc, sketch milestone, timeline, estimate daily report, weekly report Software Monitoring performance monitor exception monitor alert and alarm rules logging Engineering lint, prettier, custom rules, autofix editor, IDE, plugins, e.g. intellisense debugging, remote debug, debug mobile Analytics heatmap conversion bounce rate Frontend data flow state management componentization transpile, packing tool, e.g. webpack gulp coffeescript Typescript templating, e.g. handlebar ajax, jsonp etc. Design / styling cascading rules preprocessor, e.g. scss less box model z-index flexbox design principles, layout, color, theme 3 参考阅读https://medium.com/coderbyte/a-guide-to-becoming-a-full-stack-developer-in-2017-5c3c08a1600c https://medium.com/codingthesmartway-com-blog/the-2018-roadmap-to-fullstack-web-development-8884ff02557a https://www.lynda.com/learning-paths/Web/become-a-full-stack-web-developer 4 更多讨论 讨论地址是:精读《Elements of Web Development》 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Function Component 入门》","path":"/wiki/WebWeekly/前沿技术/《Function Component 入门》.html","content":"当前期刊数: 104 1. 引言如果你在使用 React 16,可以尝试 Function Component 风格,享受更大的灵活性。但在尝试之前,最好先阅读本文,对 Function Component 的思维模式有一个初步认识,防止因思维模式不同步造成的困扰。 2. 精读什么是 Function Component?Function Component 就是以 Function 的形式创建的 React 组件: function App() { return ( <div> <p>App</p> </div> );} 也就是,一个返回了 JSX 或 createElement 的 Function 就可以当作 React 组件,这种形式的组件就是 Function Component。 所以我已经学会 Function Component 了吗? 别急,故事才刚刚开始。 什么是 Hooks?Hooks 是辅助 Function Component 的工具。比如 useState 就是一种 Hook,它可以用来管理状态: function Counter() { const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </div> );} useState 返回的结果是数组,数组的第一项是 值,第二项是 赋值函数,useState 函数的第一个参数就是 默认值,也支持回调函数。更详细的介绍可以参考 Hooks 规则解读。 先赋值再 setTimeout 打印我们再将 useState 与 setTimeout 结合使用,看看有什么发现。 创建一个按钮,点击后让计数器自增,但是延时 3 秒后再打印出来: function Counter() { const [count, setCount] = useState(0); const log = () => { setCount(count + 1); setTimeout(() => { console.log(count); }, 3000); }; return ( <div> <p>You clicked {count} times</p> <button onClick={log}>Click me</button> </div> );} 如果我们 在三秒内连续点击三次,那么 count 的值最终会变成 3,而随之而来的输出结果是。。? 012 嗯,好像对,但总觉得有点怪? 使用 Class Component 方式实现一遍呢?敲黑板了,回到我们熟悉的 Class Component 模式,实现一遍上面的功能: class Counter extends Component { state = { count: 0 }; log = () => { this.setState({ count: this.state.count + 1 }); setTimeout(() => { console.log(this.state.count); }, 3000); }; render() { return ( <div> <p>You clicked {this.state.count} times</p> <button onClick={this.log}>Click me</button> </div> ); }} 嗯,结果应该等价吧?3 秒内快速点击三次按钮,这次的结果是: 333 怎么和 Function Component 结果不一样? 这是用好 Function Component 必须迈过的第一道坎,请确认完全理解下面这段话: 首先对 Class Component 进行解释: 首先 state 是 Immutable 的,setState 后一定会生成一个全新的 state 引用。 但 Class Component 通过 this.state 方式读取 state,这导致了每次代码执行都会拿到最新的 state 引用,所以快速点击三次的结果是 3 3 3。 那么对 Function Component 而言: useState 产生的数据也是 Immutable 的,通过数组第二个参数 Set 一个新值后,原来的值会形成一个新的引用在下次渲染时。 但由于对 state 的读取没有通过 this. 的方式,使得 每次 setTimeout 都读取了当时渲染闭包环境的数据,虽然最新的值跟着最新的渲染变了,但旧的渲染里,状态依然是旧值。 为了更容易理解,我们来模拟三次 Function Component 模式下点击按钮时的状态: 第一次点击,共渲染了 2 次,setTimeout 生效在第 1 次渲染,此时状态为: function Counter() { const [0, setCount] = useState(0); const log = () => { setCount(0 + 1); setTimeout(() => { console.log(0); }, 3000); }; return ...} 第二次点击,共渲染了 3 次,setTimeout 生效在第 2 次渲染,此时状态为: function Counter() { const [1, setCount] = useState(0); const log = () => { setCount(1 + 1); setTimeout(() => { console.log(1); }, 3000); }; return ...} 第三次点击,共渲染了 4 次,setTimeout 生效在第 3 次渲染,此时状态为: function Counter() { const [2, setCount] = useState(0); const log = () => { setCount(2 + 1); setTimeout(() => { console.log(2); }, 3000); }; return ...} 可以看到,每一个渲染都是一个独立的闭包,在独立的三次渲染中,count 在每次渲染中的值分别是 0 1 2,所以无论 setTimeout 延时多久,打印出来的结果永远是 0 1 2。 理解了这一点,我们就能继续了。 如何让 Function Component 也打印 3 3 3?所以这是不是代表 Function Component 无法覆盖 Class Component 的功能呢?完全不是,我希望你读完本文后,不仅能解决这个问题,更能理解为什么用 Function Component 实现的代码更佳合理、优雅。 第一种方案是借助一个新 Hook - useRef 的能力: function Counter() { const count = useRef(0); const log = () => { count.current++; setTimeout(() => { console.log(count.current); }, 3000); }; return ( <div> <p>You clicked {count.current} times</p> <button onClick={log}>Click me</button> </div> );} 这种方案的打印结果就是 3 3 3。 想要理解为什么,首先要理解 useRef 的功能:通过 useRef 创建的对象,其值只有一份,而且在所有 Rerender 之间共享。 所以我们对 count.current 赋值或读取,读到的永远是其最新值,而与渲染闭包无关,因此如果快速点击三下,必定会返回 3 3 3 的结果。 但这种方案有个问题,就是使用 useRef 替代了 useState 创建值,那么很自然的问题就是,如何不改变原始值的写法,达到同样的效果呢? 如何不改造原始值也打印 3 3 3?一种最简单的做法,就是新建一个 useRef 的值给 setTimeout 使用,而程序其余部分还是用原始的 count: function Counter() { const [count, setCount] = useState(0); const currentCount = useRef(count); useEffect(() => { currentCount.current = count; }); const log = () => { setCount(count + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ( <div> <p>You clicked {count} times</p> <button onClick={log}>Click me</button> </div> );} 通过这个例子,我们引出了一个新的,也是 **最重要的 Hook - useEffect**,请务必深入理解这个函数。 useEffect 是处理副作用的,其执行时机在 每次 Render 渲染完毕后,换句话说就是每次渲染都会执行,只是实际在真实 DOM 操作完毕后。 我们可以利用这个特性,在每次渲染完毕后,将 count 此时最新的值赋给 currentCount.current,这样就使 currentCount 的值自动同步了 count 的最新值。 为了确保大家准确理解 useEffect,笔者再啰嗦一下,将其执行周期拆解到每次渲染中。假设你在三秒内快速点击了三次按钮,那么你需要在大脑中模拟出下面这三次渲染都发生了什么: 第一次点击,共渲染了 2 次,useEffect 生效在第 2 次渲染: function Counter() { const [1, setCount] = useState(0); const currentCount = useRef(0); useEffect(() => { currentCount.current = 1; // 第二次渲染完毕后执行一次 }); const log = () => { setCount(1 + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ...} 第二次点击,共渲染了 3 次,useEffect 生效在第 3 次渲染: function Counter() { const [2, setCount] = useState(0); const currentCount = useRef(0); useEffect(() => { currentCount.current = 2; // 第三次渲染完毕后执行一次 }); const log = () => { setCount(2 + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ...} 第三次点击,共渲染了 4 次,useEffect 生效在第 4 次渲染: function Counter() { const [3, setCount] = useState(0); const currentCount = useRef(0); useEffect(() => { currentCount.current = 3; // 第四次渲染完毕后执行一次 }); const log = () => { setCount(3 + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ...} 注意对比与上面章节展开的 setTimeout 渲染时有什么不同。 要注意的是,useEffect 也随着每次渲染而不同的,同一个组件不同渲染之间,useEffect 内闭包环境完全独立。对于本次的例子,useEffect 共执行了 四次,经历了如下四次赋值最终变成 3: currentCount.current = 0; // 第 1 次渲染currentCount.current = 1; // 第 2 次渲染currentCount.current = 2; // 第 3 次渲染currentCount.current = 3; // 第 4 次渲染 请确保理解了这句话再继续往下阅读: **setTimeout 的例子,三次点击触发了四次渲染,但 setTimeout 分别生效在第 1、2、3 次渲染中,因此值是 0 1 2**。 **useEffect 的例子中,三次点击也触发了四次渲染,但 useEffect 分别生效在第 1、2、3、4 次渲染中,最终使 currentCount 的值变成 3**。 用自定义 Hook 包装 useRef是不是觉得每次都写一堆 useEffect 同步数据到 useRef 很烦?是的,想要简化,就需要引出一个新的概念:自定义 Hooks。 首先介绍一下,自定义 Hooks 允许创建自定义 Hook,只要函数名遵循以 use 开头,且返回非 JSX 元素,就是 Hooks 啦!自定义 Hooks 内还可以调用包括内置 Hooks 在内的所有自定义 Hooks。 也就是我们可以将 useEffect 写到自定义 Hook 里: function useCurrentValue(value) { const ref = useRef(0); useEffect(() => { ref.current = value; }, [value]); return ref;} 这里又引出一个新的概念,就是 useEffect 的第二个参数,dependences。dependences 这个参数定义了 useEffect 的依赖,在新的渲染中,只要所有依赖项的引用都不发生变化,useEffect 就不会被执行,且当依赖项为 [] 时,useEffect 仅在初始化执行一次,后续的 Rerender 永远也不会被执行。 这个例子中,我们告诉 React:仅当 value 的值变化了,再将其最新值同步给 ref.current。 那么这个自定义 Hook 就可以在任何 Function Component 调用了: function Counter() { const [count, setCount] = useState(0); const currentCount = useCurrentValue(count); const log = () => { setCount(count + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ( <div> <p>You clicked {count} times</p> <button onClick={log}>Click me</button> </div> );} 封装以后代码清爽了很多,而且最重要的是将逻辑封装起来,我们只要理解 useCurrentValue 这个 Hook 可以产生一个值,其最新值永远与入参同步。 看到这里,也许有的小伙伴已经按捺不住迸发的灵感了:将 useEffect 第二个参数设置为空数组,这个自定义 Hook 就代表了 didMount 生命周期! 是的,但笔者建议大家 不要再想生命周期的事情,这样会阻碍你更好的理解 Function Component。因为下一个话题,就是要告诉你:永远要对 useEffect 的依赖诚实,被依赖的参数一定要填上去,否则会产生非常难以察觉与修复的 BUG。 将 setTimeout 换成 setInterval 会怎样我们回到起点,将第一个 setTimeout Demo 中换成 setInterval,看看会如何: function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>;} 这个例子将引发学习 Function Component 的第二个拦路虎,理解了它,才深入理解了 Function Component 的渲染原理。 首先介绍一下引入的新概念,**useEffect 函数的返回值**。它的返回值是一个函数,这个函数在 useEffect 即将重新执行时,会先执行上一次 Rerender useEffect 第一个回调的返回函数,再执行下一次渲染的 useEffect 第一个回调。 以两次连续渲染为例介绍,展开后的效果是这样的: 第一次渲染: function Counter() { useEffect(() => { // 第一次渲染完毕后执行 // 最终执行顺序:1 return () => { // 由于没有填写依赖项,所以第二次渲染 useEffect 会再次执行,在执行前,第一次渲染中这个地方的回调函数会首先被调用 // 最终执行顺序:2 } }); return ...} 第二次渲染: function Counter() { useEffect(() => { // 第二次渲染完毕后执行 // 最终执行顺序:3 return () => { // 依此类推 } }); return ...} 然而本 Demo 将 useEffect 的第二个参数设置为了 [],那么其返回函数只会在这个组件被销毁时执行。 读懂了前面的例子,应该能想到,这个 Demo 希望利用 [] 依赖,将 useEffect 当作 didMount 使用,再结合 setInterval 每次时 count 自增,这样期望将 count 的值每秒自增 1。 然而结果是: 111... 理解了 setTimeout 例子的读者应该可以自行推导出原因:setInterval 永远在第一次 Render 的闭包中,count 的值永远是 0,也就是等价于: function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(0 + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>;} 然而罪魁祸首就是 没有对依赖诚实 导致的。例子中 useEffect 明明依赖了 count,依赖项却非要写 [],所以产生了很难理解的错误。 所以改正的办法就是 对依赖诚实。 永远对依赖项诚实一旦我们对依赖诚实了,就可以得到正确的效果: function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, [count]); return <h1>{count}</h1>;} 我们将 count 作为了 useEffect 的依赖项,就得到了正确的结果: 123... 既然漏写依赖的风险这么大,自然也有保护措施,那就是 eslint-plugin-react-hooks 这个插件,会自动订正你的代码中的依赖,想不对依赖诚实都不行! 然而对这个例子而言,代码依然存在 BUG:每次计数器都会重新实例化,如果换成其他费事操作,性能成本将不可接受。 如何不在每次渲染时重新实例化 setInterval?最简单的办法,就是利用 useState 的第二种赋值用法,不直接依赖 count,而是以函数回调方式进行赋值: function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>;} 这这写法真正做到了: 不依赖 count,所以对依赖诚实。 依赖项为 [],只有初始化会对 setInterval 进行实例化。 而之所以输出还是正确的 1 2 3 ...,原因是 setCount 的回调函数中,c 值永远指向最新的 count 值,因此没有逻辑漏洞。 但是聪明的同学仔细一想,就会发现一个新问题:如果存在两个以上变量需要使用时,这招就没有用武之地了。 同时使用两个以上变量时?如果同时需要对 count 与 step 两个变量做累加,那 useEffect 的依赖必然要写上一种某一个值,频繁实例化的问题就又出现了: function Counter() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + step); }, 1000); return () => clearInterval(id); }, [step]); return <h1>{count}</h1>;} 这个例子中,由于 setCount 只能拿到最新的 count 值,而为了每次都拿到最新的 step 值,就必须将 step 申明到 useEffect 依赖中,导致 setInterval 被频繁实例化。 这个问题自然也困扰了 React 团队,所以他们拿出了一个新的 Hook 解决问题:useReducer。 什么是 useReducer先别联想到 Redux。只考虑上面的场景,看看为什么 React 团队要将 useReducer 列为内置 Hooks 之一。 先介绍一下 useReducer 的用法: const [state, dispatch] = useReducer(reducer, initialState); useReducer 返回的结构与 useState 很像,只是数组第二项是 dispatch,而接收的参数也有两个,初始值放在第二位,第一位就是 reducer。 reducer 定义了如何对数据进行变换,比如一个简单的 reducer 如下: function reducer(state, action) { switch (action.type) { case "increment": return { ...state, count: state.count + 1 }; default: return state; }} 这样就可以通过调用 dispatch({ type: 'increment' }) 的方式实现 count 自增了。 那么回到这个例子,我们只需要稍微改写一下用法即可: function Counter() { const [state, dispatch] = useReducer(reducer, initialState); const { count, step } = state; useEffect(() => { const id = setInterval(() => { dispatch({ type: "tick" }); }, 1000); return () => clearInterval(id); }, [dispatch]); return <h1>{count}</h1>;}function reducer(state, action) { switch (action.type) { case "tick": return { ...state, count: state.count + state.step }; }} 可以看到,我们通过 reducer 的 tick 类型完成了对 count 的累加,而在 useEffect 的函数中,竟然完全绕过了 count、step 这两个变量。所以 useReducer 也被称为解决此类问题的 “黑魔法”。 其实不管被怎么称呼也好,其本质是让函数与数据解耦,函数只管发出指令,而不需要关心使用的数据被更新时,需要重新初始化自身。 仔细的读者会发现这个例子还是有一个依赖的,那就是 dispatch,然而 dispatch 引用永远也不会变,因此可以忽略它的影响。这也体现了无论如何都要对依赖保持诚实。 这也引发了另一个注意项:尽量将函数写在 useEffect 内部。 将函数写在 useEffect 内部为了避免遗漏依赖,必须将函数写在 useEffect 内部,这样 eslint-plugin-react-hooks 才能通过静态分析补齐依赖项: function Counter() { const [count, setCount] = useState(0); useEffect(() => { function getFetchUrl() { return "https://v?query=" + count; } getFetchUrl(); }, [count]); return <h1>{count}</h1>;} getFetchUrl 这个函数依赖了 count,而如果将这个函数定义在 useEffect 外部,无论是机器还是人眼都难以看出 useEffect 的依赖项包含 count。 然而这就引发了一个新问题:将所有函数都写在 useEffect 内部岂不是非常难以维护? 如何将函数抽到 useEffect 外部?为了解决这个问题,我们要引入一个新的 Hook:useCallback,它就是解决将函数抽到 useEffect 外部的问题。 我们先看 useCallback 的用法: function Counter() { const [count, setCount] = useState(0); const getFetchUrl = useCallback(() => { return "https://v?query=" + count; }, [count]); useEffect(() => { getFetchUrl(); }, [getFetchUrl]); return <h1>{count}</h1>;} 可以看到,useCallback 也有第二个参数 - 依赖项,我们将 getFetchUrl 函数的依赖项通过 useCallback 打包到新的 getFetchUrl 函数中,那么 useEffect 就只需要依赖 getFetchUrl 这个函数,就实现了对 count 的间接依赖。 换句话说,我们利用了 useCallback 将 getFetchUrl 函数抽到了 useEffect 外部。 为什么 useCallback 比 componentDidUpdate 更好用回忆一下 Class Component 的模式,我们是如何在函数参数变化时进行重新取数的: class Parent extends Component { state = { count: 0, step: 0 }; fetchData = () => { const url = "https://v?query=" + this.state.count + "&step=" + this.state.step; }; render() { return <Child fetchData={this.fetchData} count={count} step={step} />; }}class Child extends Component { state = { data: null }; componentDidMount() { this.props.fetchData(); } componentDidUpdate(prevProps) { if ( this.props.count !== prevProps.count && this.props.step !== prevProps.step // 别漏了! ) { this.props.fetchData(); } } render() { // ... }} 上面的代码经常用 Class Component 的人应该很熟悉,然而暴露的问题可不小。 我们需要理解 props.count props.step 被 props.fetchData 函数使用了,因此在 componentDidUpdate 时,判断这两个参数发生了变化就触发重新取数。 然而问题是,这种理解成本是不是过高了?如果父级函数 fetchData 不是我写的,在不读源码的情况下,我怎么知道它依赖了 props.count 与 props.step 呢?更严重的是,如果某一天 fetchData 多依赖了 params 这个参数,下游函数将需要全部在 componentDidUpdate 覆盖到这个逻辑,否则 params 变化时将不会重新取数。可以想象,这种方式维护成本巨大,甚至可以说几乎无法维护。 换成 Function Component 的思维吧!试着用上刚才提到的 useCallback 解决问题: function Parent() { const [ count, setCount ] = useState(0); const [ step, setStep ] = useState(0); const fetchData = useCallback(() => { const url = 'https://v/search?query=' + count + "&step=" + step; }, [count, step]) return ( <Child fetchData={fetchData} /> )}function Child(props) { useEffect(() => { props.fetchData() }, [props.fetchData]) return ( // ... )} 可以看出来,当 fetchData 的依赖变化后,按下保存键,eslint-plugin-react-hooks 会自动补上更新后的依赖,而下游的代码不需要做任何改变,下游只需要关心依赖了 fetchData 这个函数即可,至于这个函数依赖了什么,已经封装在 useCallback 后打包透传下来了。 不仅解决了维护性问题,而且对于 只要参数变化,就重新执行某逻辑,是特别适合用 useEffect 做的,使用这种思维思考问题会让你的代码更 “智能”,而使用分裂的生命周期进行思考,会让你的代码四分五裂,而且容易漏掉各种时机。 useEffect 对业务的抽象非常方便,笔者举几个例子: 依赖项是查询参数,那么 useEffect 内可以进行取数请求,那么只要查询参数变化了,列表就会自动取数刷新。注意我们将取数时机从触发端改成了接收端。 当列表更新后,重新注册一遍拖拽响应事件。也是同理,依赖参数是列表,只要列表变化,拖拽响应就会重新初始化,这样我们可以放心的修改列表,而不用担心拖拽事件失效。 只要数据流某个数据变化,页面标题就同步修改。同理,也不需要在每次数据变化时修改标题,而是通过 useEffect “监听” 数据的变化,这是一种 “控制反转” 的思维。 说了这么多,其本质还是利用了 useCallback 将函数独立抽离到 useEffect 外部。 那么进一步思考,可以将函数抽离到整个组件的外部吗? 这也是可以的,需要灵活运用自定义 Hooks 实现。 将函数抽到组件外部以上面的 fetchData 函数为例,如果要抽到整个组件的外部,就不是利用 useCallback 做到了,而是利用自定义 Hooks 来做: function useFetch(count, step) { return useCallback(() => { const url = "https://v/search?query=" + count + "&step=" + step; }, [count, step]);} 可以看到,我们将 useCallback 打包搬到了自定义 Hook useFetch 中,那么函数中只需要一行代码就能实现一样的效果了: function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const [other, setOther] = useState(0); const fetch = useFetch(count, step); // 封装了 useFetch useEffect(() => { fetch(); }, [fetch]); return ( <div> <button onClick={() => setCount(c => c + 1)}>setCount {count}</button> <button onClick={() => setStep(c => c + 1)}>setStep {step}</button> <button onClick={() => setOther(c => c + 1)}>setOther {other}</button> </div> );} 随着使用越来越方便,我们可以将精力放到性能上。观察可以发现,count 与 step 都会频繁变化,每次变化就会导致 useFetch 中 useCallback 依赖的变化,进而导致重新生成函数。然而实际上这种函数是没必要每次都重新生成的,反复生成函数会造成大量性能损耗。 换一个例子就可以看得更清楚: function Parent(props) { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const [other, setOther] = useState(0); const drag = useDraggable(props.dom, count, step); // 封装了拖拽函数 useEffect(() => { // dom 变化时重新实例化 drag() }, [drag])} 假设我们使用 Sortablejs 对某个区域进行拖拽监听,这个函数每次都重复执行的性能损耗非常大,然而这个函数内部可能因为仅仅要上报一些日志,所以依赖了没有实际被使用的 count step 变量: function useDraggable(dom, count, step) { return useCallback(() => { // 上报日志 report(count, step); // 对区域进行初始化,非常耗时 // ... 省略耗时代码 }, [dom, count, step]);} 这种情况,函数的依赖就特别不合理。虽然依赖变化应该触发函数重新执行,但如果函数重新执行的成本非常高,而依赖只是可有可无的点缀,得不偿失。 利用 Ref 保证耗时函数依赖不变一种办法是通过将依赖转化为 Ref: function useFetch(count, step) { const countRef = useRef(count); const stepRef = useRef(step); useEffect(() => { countRef.current = count; stepRef.current = step; }); return useCallback(() => { const url = "https://v/search?query=" + countRef.current + "&step=" + stepRef.current; }, [countRef, stepRef]); // 依赖不会变,却能每次拿到最新的值} 这种方式比较取巧,将需要更新的区域与耗时区域分离,再将需更新的内容通过 Ref 提供给耗时的区域,实现性能优化。 然而这样做对函数的改动成本比较高,有一种更通用的做法解决此类问题。 通用的自定义 Hooks 解决函数重新实例化问题我们可以利用 useRef 创造一个自定义 Hook 代替 useCallback,使其依赖的值变化时,回调不会重新执行,却能拿到最新的值! 这个神奇的 Hook 写法如下: function useEventCallback(fn, dependencies) { const ref = useRef(null); useEffect(() => { ref.current = fn; }, [fn, ...dependencies]); return useCallback(() => { const fn = ref.current; return fn(); }, [ref]);} 再次体会到自定义 Hook 的无所不能。 首先看这一段: useEffect(() => { ref.current = fn;}, [fn, ...dependencies]); 当 fn 回调函数变化时, ref.current 重新指向最新的 fn 这个逻辑中规中矩。重点是,当依赖 dependencies 变化时,也重新为 ref.current 赋值,此时 fn 内部的 dependencies 值是最新的,而下一段代码: return useCallback(() => { const fn = ref.current; return fn();}, [ref]); 又仅执行一次(ref 引用不会改变),所以每次都可以返回 dependencies 是最新的 fn,并且 fn 还不会重新执行。 假设我们对 useEventCallback 传入的回调函数称为 X,则这段代码的含义,就是使每次渲染的闭包中,回调函数 X 总是拿到的总是最新 Rerender 闭包中的那个,所以依赖的值永远是最新的,而且函数不会重新初始化。 React 官方不推荐使用此范式,因此对于这种场景,利用 useReducer,将函数通过 dispatch 中调用。 还记得吗?dispatch 是一种可以绕过依赖的黑魔法,我们在 “什么是 useReducer” 小节提到过。 随着对 Function Component 的使用,你也渐渐关心到函数的性能了,这很棒。那么下一个重点自然是关注 Render 的性能。 用 memo 做 PureRender在 Fucntion Component 中,Class Component 的 PureComponent 等价的概念是 React.memo,我们介绍一下 memo 的用法: const Child = memo((props) => { useEffect(() => { props.fetchData() }, [props.fetchData]) return ( // ... )}) 使用 memo 包裹的组件,会在自身重渲染时,对每一个 props 项进行浅对比,如果引用没有变化,就不会触发重渲染。所以 memo 是一种很棒的性能优化工具。 下面就介绍一个看似比 memo 难用,但真正理解后会发现,其实比 memo 更好用的渲染优化函数:useMemo。 用 useMemo 做局部 PureRender相比 React.memo 这个异类,React.useMemo 可是正经的官方 Hook: const Child = (props) => { useEffect(() => { props.fetchData() }, [props.fetchData]) return useMemo(() => ( // ... ), [props.fetchData])} 可以看到,我们利用 useMemo 包裹渲染代码,这样即便函数 Child 因为 props 的变化重新执行了,只要渲染函数用到的 props.fetchData 没有变,就不会重新渲染。 这里发现了 useMemo 的第一个好处:更细粒度的优化渲染。 所谓更细粒度的优化渲染,是指函数 Child 整体可能用到了 A、B 两个 props,而渲染仅用到了 B,那么使用 memo 方案时,A 的变化会导致重渲染,而使用 useMemo 的方案则不会。 而 useMemo 的好处还不止这些,这里先留下伏笔。我们先看一个新问题:当参数越来越多时,使用 props 将函数、值在组件间传递非常冗长: function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const fetchData = useFetch(count, step); return <Child fetchData={fetchData} setCount={setCount} setStep={setStep} />;} 虽然 Child 可以通过 memo 或 useMemo 进行优化,但当程序复杂时,可能存在多个函数在所有 Function Component 间共享的情况,此时就需要新 Hook: useContext 来拯救了。 使用 Context 做批量透传在 Function Component 中,可以使用 React.createContext 创建一个 Context: const Store = createContext(null); 其中 null 是初始值,一般置为 null 也没关系。接下来还有两步,分别是在根节点使用 Store.Provider 注入,与在子节点使用官方 Hook useContext 拿到注入的数据: 在根节点使用 Store.Provider 注入: function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const fetchData = useFetch(count, step); return ( <Store.Provider value={{ setCount, setStep, fetchData }}> <Child /> </Store.Provider> );} 在子节点使用 useContext 拿到注入的数据(也就是拿到 Store.Provider 的 value): const Child = memo((props) => { const { setCount } = useContext(Store) function onClick() { setCount(count => count + 1) } return ( // ... )}) 这样就不需要在每个函数间进行参数透传了,公共函数可以都放在 Context 里。 但是当函数多了,Provider 的 value 会变得很臃肿,我们可以结合之前讲到的 useReducer 解决这个问题。 使用 useReducer 为 Context 传递内容瘦身使用 useReducer,所有回调函数都通过调用 dispatch 完成,那么 Context 只要传递 dispatch 一个函数就好了: const Store = createContext(null);function Parent() { const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 }); return ( <Store.Provider value={dispatch}> <Child /> </Store.Provider> );} 这下无论是根节点的 Provider,还是子元素调用都清爽很多: const Child = useMemo((props) => { const dispatch = useContext(Store) function onClick() { dispatch({ type: 'countInc' }) } return ( // ... )}) 你也许很快就想到,将 state 也通过 Provider 注入进去岂不更妙?是的,但此处请务必注意潜在性能问题。 将 state 也放到 Context 中稍稍改造下,将 state 也放到 Context 中,这下赋值与取值都非常方便了! const Store = createContext(null);function Parent() { const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 }); return ( <Store.Provider value={{ state, dispatch }}> <Count /> <Step /> </Store.Provider> );} 对 Count Step 这两个子元素而言,可需要谨慎一些,假如我们这么实现这两个子元素: const Count = memo(() => { const { state, dispatch } = useContext(Store); return ( <button onClick={() => dispatch("incCount")}>incCount {state.count}</button> );});const Step = memo(() => { const { state, dispatch } = useContext(Store); return ( <button onClick={() => dispatch("incStep")}>incStep {state.step}</button> );}); 其结果是:无论点击 incCount 还是 incStep,都会同时触发这两个组件的 Rerender。 其问题在于:memo 只能挡在最外层的,而通过 useContext 的数据注入发生在函数内部,会 绕过 memo。 当触发 dispatch 导致 state 变化时,所有使用了 state 的组件内部都会强制重新刷新,此时想要对渲染次数做优化,只有拿出 useMemo 了! useMemo 配合 useContext使用 useContext 的组件,如果自身不使用 props,就可以完全使用 useMemo 代替 memo 做性能优化: const Count = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( <button onClick={() => dispatch("incCount")}> incCount {state.count} </button> ), [state.count, dispatch] );};const Step = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( <button onClick={() => dispatch("incStep")}>incStep {state.step}</button> ), [state.step, dispatch] );}; 对这个例子来说,点击对应的按钮,只有使用到的组件才会重渲染,效果符合预期。 结合 eslint-plugin-react-hooks 插件使用,连 useMemo 的第二个参数依赖都是自动补全的。 读到这里,不知道你是否联想到了 Redux 的 Connect? 我们来对比一下 Connect 与 useMemo,会发现惊人的相似之处。 一个普通的 Redux 组件: const mapStateToProps = state => ({count: state.count});const mapDispatchToProps = dispatch => dispatch;@Connect(mapStateToProps, mapDispatchToProps)class Count extends React.PureComponent { render() { return ( <button onClick={() => this.props.dispatch("incCount")}> incCount {this.props.count} </button> ); }} 一个普通的 Function Component 组件: const Count = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( <button onClick={() => dispatch("incCount")}> incCount {state.count} </button> ), [state.count, dispatch] );}; 这两段代码的效果完全一样,Function Component 除了更简洁之外,还有一个更大的优势:全自动的依赖推导。 Hooks 诞生的一个原因,就是为了便于静态分析依赖,简化 Immutable 数据流的使用成本。 我们看 Connect 的场景: 由于不知道子组件使用了哪些数据,因此需要在 mapStateToProps 提前写好,而当需要使用数据流内新变量时,组件里是无法访问的,我们要回到 mapStateToProps 加上这个依赖,再回到组件中使用它。 而 useContext + useMemo 的场景: 由于注入的 state 是全量的,Render 函数中想用什么都可直接用,在按保存键时,eslint-plugin-react-hooks 会通过静态分析,在 useMemo 第二个参数自动补上代码里使用到的外部变量,比如 state.count、dispatch。 另外可以发现,Context 很像 Redux,那么 Class Component 模式下的异步中间件实现的异步取数怎么利用 useReducer 做呢?答案是:做不到。 当然不是说 Function Component 无法实现异步取数,而是用的工具错了。 使用自定义 Hook 处理副作用比如上面抛出的异步取数场景,在 Function Component 的最佳做法是封装成一个自定义 Hook: const useDataApi = (initialUrl, initialData) => { const [url, setUrl] = useState(initialUrl); const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, data: initialData }); useEffect(() => { let didCancel = false; const fetchData = async () => { dispatch({ type: "FETCH_INIT" }); try { const result = await axios(url); if (!didCancel) { dispatch({ type: "FETCH_SUCCESS", payload: result.data }); } } catch (error) { if (!didCancel) { dispatch({ type: "FETCH_FAILURE" }); } } }; fetchData(); return () => { didCancel = true; }; }, [url]); const doFetch = url => setUrl(url); return { ...state, doFetch };}; 可以看到,自定义 Hook 拥有完整生命周期,我们可以将取数过程封装起来,只暴露状态 - 是否在加载中:isLoading 是否取数失败:isError 数据:data。 在组件中使用起来非常方便: function App() { const { data, isLoading, isError } = useDataApi("https://v", { showLog: true });} 如果这个值需要存储到数据流,在所有组件之间共享,我们可以结合 useEffect 与 useReducer: function App(props) { const { dispatch } = useContext(Store); const { data, isLoading, isError } = useDataApi("https://v", { showLog: true }); useEffect(() => { dispatch({ type: "updateLoading", data, isLoading, isError }); }, [dispatch, data, isLoading, isError]);} 到此,Function Component 的入门概念就讲完了,最后附带一个彩蛋:Function Component 的 DefaultProps 怎么处理? Function Component 的 DefaultProps 怎么处理?这个问题看似简单,实则不然。我们至少有两种方式对 Function Component 的 DefaultProps 进行赋值,下面一一说明。 首先对于 Class Component,DefaultProps 基本上只有一种大家都认可的写法: class Button extends React.PureComponent { defaultProps = { type: "primary", onChange: () => {} };} 然而在 Function Component 就五花八门了。 利用 ES6 特性在参数定义阶段赋值function Button({ type = "primary", onChange = () => {} }) {} 这种方法看似很优雅,其实有一个重大隐患:没有命中的 props 在每次渲染引用都不同。 看这种场景: const Child = memo(({ type = { a: 1 } }) => { useEffect(() => { console.log("type", type); }, [type]); return <div>Child</div>;}); 只要 type 的引用不变,useEffect 就不会频繁的执行。现在通过父元素刷新导致 Child 跟着刷新,我们发现,每次渲染都会打印出日志,也就意味着每次渲染时,type 的引用是不同的。 有一种不太优雅的方式可以解决: const defaultType = { a: 1 };const Child = ({ type = defaultType }) => { useEffect(() => { console.log("type", type); }, [type]); return <div>Child</div>;}; 此时不断刷新父元素,只会打印出一次日志,因为 type 的引用是相同的。 我们使用 DefaultProps 的本意必然是希望默认值的引用相同, 如果不想单独维护变量的引用,还可以借用 React 内置的 defaultProps 方法解决。 利用 React 内置方案React 内置方案能较好的解决引用频繁变动的问题: const Child = ({ type }) => { useEffect(() => { console.log("type", type); }, [type]); return <div>Child</div>;};Child.defaultProps = { type: { a: 1 }}; 上面的例子中,不断刷新父元素,只会打印出一次日志。 因此建议对于 Function Component 的参数默认值,建议使用 React 内置方案解决,因为纯函数的方案不利于保持引用不变。 最后补充一个父组件 “坑” 子组件的经典案例。 不要坑了子组件我们做一个点击累加的按钮作为父组件,那么父组件每次点击后都会刷新: function App() { const [count, forceUpdate] = useState(0); const schema = { b: 1 }; return ( <div> <Child schema={schema} /> <div onClick={() => forceUpdate(count + 1)}>Count {count}</div> </div> );} 另外我们将 schema = { b: 1 } 传递给子组件,这个就是埋的一个大坑。 子组件的代码如下: const Child = memo(props => { useEffect(() => { console.log("schema", props.schema); }, [props.schema]); return <div>Child</div>;}); 只要父级 props.schema 变化就会打印日志。结果自然是,父组件每次刷新,子组件都会打印日志,也就是 子组件 [props.schema] 完全失效了,因为引用一直在变化。 其实 子组件关心的是值,而不是引用,所以一种解法是改写子组件的依赖: const Child = memo(props => { useEffect(() => { console.log("schema", props.schema); }, [JSON.stringify(props.schema)]); return <div>Child</div>;}); 这样可以保证子组件只渲染一次。 可是真正罪魁祸首是父组件,我们需要利用 Ref 优化一下父组件: function App() { const [count, forceUpdate] = useState(0); const schema = useRef({ b: 1 }); return ( <div> <Child schema={schema.current} /> <div onClick={() => forceUpdate(count + 1)}>Count {count}</div> </div> );} 这样 schema 的引用能一直保持不变。如果你完整读完了本文,应该可以充分理解第一个例子的 schema 在每个渲染快照中都是一个新的引用,而 Ref 的例子中,schema 在每个渲染快照中都只有一个唯一的引用。 3. 总结所以使用 Function Component 你入门了吗? 本次精读留下的思考题是:Function Component 开发过程中还有哪些容易犯错误的细节? 讨论地址是:精读《Function Component 入门》 · Issue ##157 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Function VS Class 组件》","path":"/wiki/WebWeekly/前沿技术/《Function VS Class 组件》.html","content":"当前期刊数: 95 1. 引言为什么要了解 Function 写法的组件呢?因为它正在变得越来越重要。 那么 React 中 Function Component 与 Class Component 有何不同? how-are-function-components-different-from-classes 这篇文章带来了一个独特的视角。 顺带一提,以后会用 Function Component 代替 Stateless Component 的说法,原因是:自从 Hooks 出现,函数式组件功能在不断丰富,函数式组件不再需要强调其无状态特性,因此叫 Function Component 更为恰当。 2. 概述原文事先申明:并没有对 Function 与 Classes 进行优劣对比,而仅仅进行特性对比,所以不接受任何吐槽。 这两种写法没有好坏之分,性能差距也几乎可以忽略,而且 React 会长期支持这两种写法。 Capture props对比下面两段代码。 Class Component: class ProfilePage extends React.Component { showMessage = () => { alert("Followed " + this.props.user); }; handleClick = () => { setTimeout(this.showMessage, 3000); }; render() { return <button onClick={this.handleClick}>Follow</button>; }} Function Component: function ProfilePage(props) { const showMessage = () => { alert("Followed " + props.user); }; const handleClick = () => { setTimeout(showMessage, 3000); }; return <button onClick={handleClick}>Follow</button>;} (在线 Demo) 这两个组件都描述了同一个逻辑:点击按钮 3 秒后 alert 父级传入的用户名。 如下父级组件的调用方式: <ProfilePageFunction user={this.state.user} /><ProfilePageClass user={this.state.user} /> 那么当点击按钮后的 3 秒内,父级修改了 this.state.user,弹出的用户名是修改前的还是修改后的呢? Class Component 展示的是修改后的值: Function Component 展示的是修改前的值: 那么 React 文档中描述的 props 不是不可变(Immutable) 数据吗?为啥在运行时还会发生变化呢? 原因在于,虽然 props 不可变,是 this 在 Class Component 中是可变的,因此 this.props 的调用会导致每次都访问最新的 props。 而 Function Component 不存在 this.props 的语法,因此 props 总是不可变的。 为了便于理解,笔者补充一些代码注解: Function Component: function ProfilePage(props) { setTimeout(() => { // 就算父组件 reRender,这里拿到的 props 也是初始的 console.log(props); }, 3000);} Class Component: class ProfilePage extends React.Component { render() { setTimeout(() => { // 如果父组件 reRender,this.props 拿到的永远是最新的。 // 并不是 props 变了,而是 this.props 指向了新的 props,旧的 props 找不到了 console.log(this.props); }, 3000); }} 如果希望在 Class Component 捕获瞬时 Props,可以: const props = this.props;,但这样的代码很蹩脚,所以如果希望拿到稳定的 props,使用 Function Component 是更好的选择。 Hooks 也具有 capture value 特性看下面的代码: function MessageThread() { const [message, setMessage] = useState(""); const showMessage = () => { alert("You said: " + message); }; const handleSendClick = () => { setTimeout(showMessage, 3000); }; const handleMessageChange = e => { setMessage(e.target.value); }; return ( <> <input value={message} onChange={handleMessageChange} /> <button onClick={handleSendClick}>Send</button> </> );} (在线 Demo) 在点击 Send 按钮后,再次修改输入框的值,3 秒后的输出依然是 点击前输入框的值。这说明 Hooks 同样具有 capture value 的特性。 利用 useRef 可以规避 capture value 特性: function MessageThread() { const latestMessage = useRef(""); const showMessage = () => { alert("You said: " + latestMessage.current); }; const handleSendClick = () => { setTimeout(showMessage, 3000); }; const handleMessageChange = e => { latestMessage.current = e.target.value; };} 只要将赋值与取值的对象变成 useRef,而不是 useState,就可以躲过 capture value 特性,在 3 秒后得到最新的值。 这说明了利用 Function Component + Hooks 可以实现 Class Component 做不到的 capture props、capture value,而且 React 官方也推荐 新的代码使用 Hooks 编写。 3. 精读原文 how-are-function-components-different-from-classes 从一个侧面讲述了 Function Component 与 Class Component 的不同点,之所以将 Function Component 与 Class Component 相提并论,几乎都要归功于 Hooks API 的出现,有了 Hooks,Function Component 的能力才得以向 Class Component 看齐。 关于 React Hooks,之前的两篇精读分别有过介绍: 精读《React Hooks》 精读《怎么用 React Hooks 造轮子》 但是,虽然 Hook 已经发布了稳定版本,但周边生态跟进还需要时间(比如 useRouter)、最佳实践整理还需要时间,因此不建议重构老代码。 为了更好的使用 Function Component,建议时常与 Class Component 的功能做对比,方便理解和记忆。 下面整理一些常见的 Function Component 问题: 非常建议完整阅读 React Hooks FAQ。 怎么替代 shouldComponentUpdate说实话,Function Component 替代 shouldComponentUpdate 的方案并没有 Class Component 优雅,代码是这样的: const Button = React.memo(props => { // your component}); 或者在父级就直接生成一个自带 memo 的子元素: function Parent({ a, b }) { // Only re-rendered if `a` changes: const child1 = useMemo(() => <Child1 a={a} />, [a]); // Only re-rendered if `b` changes: const child2 = useMemo(() => <Child2 b={b} />, [b]); return ( <> {child1} {child2} </> );} 相比之下,Class Component 的写法通常是: class Button extends React.PureComponent {} 这样就自带了 shallowEqual 的 shouldComponentUpdate。 怎么替代 componentDidUpdate由于 useEffect 每次 Render 都会执行,因此需要模拟一个 useUpdate 函数: const mounting = useRef(true);useEffect(() => { if (mounting.current) { mounting.current = false; } else { fn(); }}); 更多可以查看 精读《怎么用 React Hooks 造轮子》 怎么替代 forceUpdateReact 官方文档提供了一种方案: const [ignored, forceUpdate] = useReducer(x => x + 1, 0);function handleClick() { forceUpdate();} 每次执行 dispatch 时,只要 state 变化就会触发组件更新。当然 useState 也同样可以模拟: const useUpdate = () => useState(0)[1]; 我们知道 useState 下标为 1 的项是用来更新数据的,而且就算数据没有变化,调用了也会刷新组件,所以我们可以把返回一个没有修改数值的 setValue,这样它的功能就仅剩下刷新组件了。 更多可以查看 精读《怎么用 React Hooks 造轮子》 state 拆分过多useState 目前的一种实践,是将变量名打平,而非像 Class Component 一样写在一个 State 对象里: class ClassComponent extends React.PureComponent { state = { left: 0, top: 0, width: 100, height: 100 };}// VSfunction FunctionComponent { const [left,setLeft] = useState(0) const [top,setTop] = useState(0) const [width,setWidth] = useState(100) const [height,setHeight] = useState(100)} 实际上在 Function Component 中也可以聚合管理 State: function FunctionComponent() { const [state, setState] = useState({ left: 0, top: 0, width: 100, height: 100 });} 只是更新的时候,不再会自动 merge,而需要使用 ...state 语法: setState(state => ({ ...state, left: e.pageX, top: e.pageY })); 可以看到,更少的黑魔法,更可预期的结果。 获取上一个 props虽然不怎么常用,但是毕竟 Class Component 可以通过 componentWillReceiveProps 拿到 previousProps 与 nextProps,对于 Function Component,最好通过自定义 Hooks 方式拿到上一个状态: function Counter() { const [count, setCount] = useState(0); const prevCount = usePrevious(count); return ( <h1> Now: {count}, before: {prevCount} </h1> );}function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }); return ref.current;} 通过 useEffect 在组件渲染完毕后再执行的特性,再利用 useRef 的可变特性,让 usePrevious 的返回值是 “上一次” Render 时的。 可见,合理运用 useEffect useRef,可以做许多事情,而且封装成 CustomHook 后使用起来仍然很方便。 未来 usePrevious 可能成为官方 Hooks 之一。 性能注意事项useState 函数的参数虽然是初始值,但由于整个函数都是 Render,因此每次初始化都会被调用,如果初始值计算非常消耗时间,建议使用函数传入,这样只会执行一次: function FunctionComponent(props) { const [rows, setRows] = useState(() => createRows(props.count));} useRef 不支持这种特性,需要写一些冗余的函判定是否进行过初始化。 掌握了这些,Function Component 使用起来与 Class Component 就几乎没有差别了! 4. 总结Function Component 功能已经可以与 Class Component 媲美了,但目前最佳实践比较零散,官方文档推荐的一些解决思路甚至不比社区第三方库的更好,可以预料到,Class Component 的功能会被五花八门的实现出来,那些没有被收纳进官方的 Hooks 乍看上去可能会眼花缭乱。 总之选择了 Function Component 就同时选择了函数式的好与坏。好处是功能强大,几乎可以模拟出任何想要的功能,坏处是由于可以灵活组合,如果自定义 Hooks 命名和实现不够标准,函数与函数之间对接的沟通成本会更大。 讨论地址是:精读《Stateless VS Class 组件》 · Issue ##137 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Headless 组件用法与原理》","path":"/wiki/WebWeekly/前沿技术/《Headless 组件用法与原理》.html","content":"当前期刊数: 259 Headless 组件即无 UI 组件,框架仅提供逻辑,UI 交给业务实现。这样带来的好处是业务有极大的 UI 自定义空间,而对框架来说,只考虑逻辑可以让自己更轻松的覆盖更多场景,满足更多开发者不同的诉求。 我们以 headlessui-tabs 为例看看它的用法,并读一读 源码。 概述headless tabs 最简单的用法如下: import { Tab } from "@headlessui/react";function MyTabs() { return ( <Tab.Group> <Tab.List> <Tab>Tab 1</Tab> <Tab>Tab 2</Tab> <Tab>Tab 3</Tab> </Tab.List> <Tab.Panels> <Tab.Panel>Content 1</Tab.Panel> <Tab.Panel>Content 2</Tab.Panel> <Tab.Panel>Content 3</Tab.Panel> </Tab.Panels> </Tab.Group> );} 以上代码没有做任何逻辑定制,只用 Tab 及其提供的标签把 tabs 的结构描述出来,此时框架能提供最基础的 tabs 切换特性,即按照顺序,点击 Tab 时切换内容到对应的 Tab.Panel。 此时没有任何额外的 UI 样式,甚至连 Tab 选中态都没有,如果需要进一步定制,需要用框架提供的 RenderProps 能力拿到状态后做业务层的定制,比如选中态: <Tab as={Fragment}> {({ selected }) => ( <button className={selected ? "bg-blue-500 text-white" : "bg-white text-black"} > Tab 1 </button> )}</Tab> 要实现选中态就要自定义 UI,如果使用 RenderProps 拓展,那么 Tab 就不应该提供任何 UI,所以 as={Fragment} 就表示该节点作为一个逻辑节点而非 UI 节点(不产生 dom 节点)。 类似的,框架将 tabs 组件拆分为 Tab 标题区域 Tab 与 Tab 内容区域 Tab.Panel,每个部分都可以用 RenderProps 定制,而框架早已根据业务逻辑规定好了每个部分可以做哪些逻辑拓展,比如 Tab 就提供了 selected 参数告知当前 Tab 是否处于选中态,业务就可以根据它对 UI 进行高亮处理,而框架并不包含如何做高亮的处理,因此才体现出该 tabs 组件的拓展性,但响应的业务开发成本也较高。 Headless 的拓展性可以拿一个场景举例:如果业务侧要定制 Tab 标题,我们可以将 Tab.List 包裹在一个更大的标题容器内,在任意位置添加标题 jsx,而不会破坏原本的 tabs 逻辑,然后将这个组件作为业务通用组件即可。 再看更多的配置参数: 控制某个 Tab 是否可编辑: <Tab disabled>Tab 2</Tab> Tab 切换是否为手动按 Enter 或 Space 键: <Tab.Group manual> 默认激活 Tab: <Tab.Group defaultIndex={1}> 监听激活 Tab 变化: <Tab.Group onChange={(index) => { console.log('Changed selected tab to:', index) }}> 受控模式: <Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}> 用法就介绍到这里。 精读由此可见,Headless 组件在 React 场景更多使用 RenderProps 的方式提供 UI 拓展能力,因为 RenderProps 既可以自定义 UI 元素,又可以拿到当前上下文的状态,天然适合对 UI 的自定义。 还有一些 Headless 框架如 TanStack table 还提供了 Hooks 模式,如: const table = useReactTable(options)return <table {table.getTableProps()}></table> Hooks 模式的好处是没有 RenderProps 那么多层回调,代码层级看起来舒服很多,而且 Hooks 模式在其他框架也逐渐被支持,使组件库跨框架适配的成本比较低。但 Hooks 模式在 React 场景下会引发不必要的全局 ReRender,相比之下,RenderProps 只会将重渲染限定在回调函数内部,在性能上 RenderProps 更优。 分析的差不多,我们看看 headlessui-tabs 的 源码。 首先组件要封装的好,一定要把内部组件通信问题给解决了,即为什么包裹了 Tab.Group 后,Tab 与 Tab.Panel 就可以产生联动?它们一定要访问共同的上下文数据。答案就是 Context: 首先在 Tab.Group 利用 ContextProvider 包裹一层上下文容器,并封装一个 Hook 从该容器提取数据: // 导出的别名就叫 Tab.Groupconst Tabs = () => { return ( <TabsDataContext.Provider value={tabsData}> {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_TABS_TAG, name: "Tabs", })} </TabsDataContext.Provider> );};// 提取数据方法function useData(component: string) { let context = useContext(TabsDataContext); if (context === null) { let err = new Error( `<${component} /> is missing a parent <Tab.Group /> component.` ); if (Error.captureStackTrace) Error.captureStackTrace(err, useData); throw err; } return context;} 所有子组件如 Tab、Tab.Panel、Tab.List 都从 useData 获取数据,而这些数据都可以从当前最近的 Tab.Group 上下文获取,所以多个 tabs 之间数据可以相互隔离。 另一个重点就是 RenderProps 的实现。其实早在 75.精读《Epitath 源码 - renderProps 新用法》 我们就讲过 RenderProps 的实现方式,今天我们来看一下 headlessui 的封装吧。 核心代码精简后如下: function _render<TTag extends ElementType, TSlot>( props: Props<TTag, TSlot> & { ref?: unknown }, slot: TSlot = {} as TSlot, tag: ElementType, name: string) { let { as: Component = tag, children, refName = 'ref', ...rest } = omit(props, ['unmount', 'static']) let resolvedChildren = (typeof children === 'function' ? children(slot) : children) as | ReactElement | ReactElement[] if (Component === Fragment) { return cloneElement( resolvedChildren, Object.assign( {}, // Filter out undefined values so that they don't override the existing values mergeProps(resolvedChildren.props, compact(omit(rest, ['ref']))), dataAttributes, refRelatedProps, mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref) ) ) } return createElement( Component, Object.assign( {}, omit(rest, ['ref']), Component !== Fragment && refRelatedProps, Component !== Fragment && dataAttributes ), resolvedChildren )} 首先为了支持 Fragment 模式,所以当制定 as={Fragment} 时,就直接把 resolvedChildren 作为子元素,否则自己就作为 dom 载体 createElement(Component, ..., resolvedChildren) 来渲染。 而体现 RenderProps 的点就在于 resolvedChildren 处理的这段: let resolvedChildren = typeof children === "function" ? children(slot) : children; 如果 children 是函数类型,就把它当做函数执行并传入上下文(此处为 slot),返回值是 JSX 元素,这就是 RenderProps 的本质。 再看上面 Tab.Group 的用法: render({ ourProps, theirProps, slot, defaultTag: DEFAULT_TABS_TAG, name: "Tabs",}); 其中 slot 就是当前 RenderProps 能拿到的上下文,比如在 Tab.Group 中就提供 selectedIndex,在 Tab 就提供 selected 等等,在不同的 RenderProps 位置提供便捷的上下文,对用户使用比较友好是比较关键的。 比如 Tab 内已知该 Tab 的 index 与 selectedIndex,那么给用户提供一个组合变量 selected 就可能比分别提供这两个变量更方便。 总结我们总结一下 Headless 的设计与使用思路。 作为框架作者,首先要分析这个组件的业务功能,并抽象出应该拆分为哪些 UI 模块,并利用 RenderProps 将这些 UI 模块以 UI 无关方式提供,并精心设计每个 UI 模块提供的状态。 作为使用者,了解这些组件分别支持哪些模块,各模块提供了哪些状态,并根据这些状态实现对应的 UI 组件,响应这些状态的变化。由于最复杂的状态逻辑已经被框架内置,所以对于 UI 状态多样的业务甚至可以每个组件重写一遍 UI 样式,对于样式稳定的场景,业务也可以按照 Headless + UI 作为整体封装出包含 UI 的组件,提供给各业务场景调用。 讨论地址是:精读《Headless 组件用法与原理》· Issue ##444 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Immutable 结构共享》","path":"/wiki/WebWeekly/前沿技术/《Immutable 结构共享》.html","content":"当前期刊数: 9 本期精读的文章是:Immutable 结构共享是如何实现的 鉴于 mobx-state-tree 的发布,实现了 mutable 到 immutable 数据的自由转换,将 mobx 写法的数据流,无缝接入 redux 生态,或继续使用 mobx 生态。 这是将事务性,可追溯性与依赖追踪特性的结合,同时解决开发体验与数据流可维护性。万一这种思路火了呢?我们先来预热下其重要特征,结构共享。 1 引言 结构共享不仅仅是 “结构共享” 那么简单,背后包含了 Hash maps tries 与 vector tries 结构的支持,如果让我们设计一个结构共享功能,需要考虑哪些点呢?本期精读的文章给了答案。 2 内容概要使用 Object.assign 作用于大对象时,速度会成为瓶颈,比如拥有 100,000 个属性的对象,这个操作耗费了 134ms。性能损失主要原因是 “结构共享” 操作需要遍历近 10 万个属性,而这些引用操作耗费了 100ms 以上的时间。 解决办法就是减少引用指向的操作数量,而且由于引用指向到任何对象的损耗都几乎一致(无论目标对象极限小或者无穷大,引用消耗时间都几乎没有区别),我们需要一种精心设计的树状结构将打平的引用建立深度,以减少引用操作次数,vector tries 就是一种解决思路: 上图的 key: t0143c274,通过 hash 后得到的值为 621051904(与 md5 不同,比如 hash(“a”) == 0,hash(“c”) == 2),转化为二进制后,值是 10010 10000 01001 00000 00000 00000,这个路径是唯一的,同时,为了减少树的深度,按照 5bit 切分,切分后的路径也是唯一的。因此寻址路径就如上图所示。 因此结构共享的核心思路是以空间换时间。 3 精读本精读由 rccoder ascoders cisen BlackGanglion jasonslyvia TingGe twobin camsong 讨论而出,以及我个人的吐血阅读论文原文总结而成。 Immutable 树结构的特性以 camsong 的动态图形象介绍一下共享的操作流程: 但是,当树越宽(子节点越多)时,相应树的高度会下降,随之查询效率会提高,但更新效率则会下降(试想一下极限情况,就相当于线性结构)。为寻求更新与查询的平衡,我们便选择了 5bit 一分割。 因此最终每个节点拥有 2^5=32 个子节点,同时通过 Vector trie 和 Hash maps trie 压缩空间结构,使其深度最小,性能最优。 Vector trie通过这篇文章查看详细介绍。 其原理是,使用二叉树,将所有值按照顺序,从左到右存放于叶子节点,当需要更新数据时,只将其更新路径上的节点生成新的对象,没有改变的节点继续共用。 Hash maps trieImmutablejs 对于 Map,使用了这种方式优化,并且通过树宽与树高的压缩,形成了文中例图中的效果(10010 10000 聚合成了一个节点,并且移除了同级的空节点)。 树宽压缩: 树高压缩: 再结合 Vector trie,实现结构共享,保证其更新性能最优,同时查询路径相对较优。 Object.assign 是否可替代 Immutable? 结构共享指的是,根节点的引用改变,但对没修改的节点,引用依然指向旧节点。所以Object.assign 也能实现结构共享 见如下代码: const objA = { a: 1, b: 2, c: 3 }const objB = Object.assign({}, objA, { c: 4 })objA === objB // falseobjA.a === objB.a // trueobjA.b === objB.b // true 证明 Object.assign 完全可以胜任 Immutable 的场景。但正如文章所述,当对象属性庞大时, Object.assign 的效率较低,因此在特殊场景,不适合使用 Object.assign 生成 immutable 数据。但是大部分场景还是完全可以使用 Object.assign 的,因为性能不是瓶颈,唯一繁琐点在于深层次对象的赋值书写起来很麻烦。 Map 性能比 Object.assign 更好,是否可以替代 Immutable? 当一层节点达到 1000000 时,immutable.get 查询性能是 object.key 的 10 倍以上。 就性能而言可以替代 Immutable,但就结合 redux 使用而言,无法替代 Immutable。 redux 判断数据更新的条件是,对象引用是否变化,而且要满足,当修改对象子属性时,父级对象的引用也要一并修改。Map 跪在这个特性上,它无法使 set 后的 map 对象产生一份新的引用。 这样会导致,Connect 了 style 对象,其 backgroundColor 属性变化时,不会触发 reRender。因此虽然 Map 性能不错,但无法胜任 Object.assign 或 immutablejs 库对 redux 的支持。 3 总结数据结构共享要达到真正可用,需要借助 Hash maps tries 和 vector tries 数据结构的帮助,在上文中已经详细阐述。既然清楚了结构共享怎么做,就更加想知道 mobx-state-tree 是如何做到 mutable 数据到 immutable 数据转换了,敬请期待下次的源码分析(不一定在下一期)。 如何你对原理不是很关心,那拿走这个结论也不错:在大部分情况可以使用 Object.assign 代替 Immutablejs,只要你不怕深度赋值的麻烦语法;其效果与 Immutablejs 一模一样,唯一,在数据量巨大的字段上,可以使用 Immutablejs 代替以提高性能。 讨论地址是:Immutable 结构共享是如何实现的? · Issue ##14 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Excel JS API》","path":"/wiki/WebWeekly/前沿技术/《Excel JS API》.html","content":"当前期刊数: 225 Excel 现在可利用 js 根据单元格数据生成图表、表格,或通过 js 拓展自定义函数拓展内置 Excel 表达式。 我们来学习一下 Excel js API 开放是如何设计的,从中学习到一些开放 API 设计经验。 API 文档:Excel JavaScript API overview 精读Excel 将利用 JS API 开放了大量能力,包括用户能通过界面轻松做到的,也包括无法通过界面操作做到的。 为什么需要开放 JS APIExcel 已经具备了良好的易用性,以及 formula 这个强大的公式。在之前 精读《Microsoft Power Fx》 提到过,formula 就是 Excel 里的 Power FX,属于画布低代码语言,不过在 Excel 里叫做 “公式” 更合适。 已经具备这么多能力,为何还需要 JS API 呢?一句话概括就是,在 JS API 内可以使用 formula,即 JS API 是公式能力的超集,它包含了对 Excel 工作簿的增删改查、数据的限制、RangeAreas 操作、图表、透视表,甚至可以自定义 formula 函数。 也就是说,JS API 让 Excel “可编程化”,即以开发者视角对 Excel 进行二次拓展,包括对公式进行二次拓展,使 Excel 覆盖更多场景。 JS API 可以用在哪些地方从 Excel 流程中最开始的工作薄、工作表环节,到最细节的单元格数据校验都可通过 JS API 支持,目前看来 Excel JS API 并没有设置能力边界,而且还会不断完善,将 Excel 全生命周期中一切可编程的地方开放出来。 首先是对工作薄、工作表的操作,以及对工作表用户操作的监听,或者对工作表进行只读设置。这一类 API 的目的是对 Excel 这个整体进行编程操作。 第二步就是对单元格级别进行操作,比如对单元格进行区域选中,获取选中区域,或者设置单元格属性、颜色,或者对单元格数据进行校验。自定义公式也在这个环节,因为单元格的值可以是公式,而公式可以利用 JS API 拓展。 最后一步是拓展行为,即在单元格基础上引入图表、透视表拓展。虽然这些功能在 UI 按钮上也可以操作出来,但 JS API 可以实现 UI 界面配置不出来的逻辑,对于非常复杂的逻辑行为,即便 UI 可以配置出来,可读性也远没有代码高。除了表格透视表外、还可以创建一些自定义形状,基本的几何图形、图片和 SVG 都支持。 JS API 设计比较有趣的是,Excel 并没有抽象 “单元格” 对象,即便我们所有人都认为单元格就是 Excel 的代表。 这么做是出于 API 设计的合理性,因为 Excel 使用 Range 概念表示连续单元格。比如: Excel.run(function (context) { var sheet = context.workbook.worksheets.getActiveWorksheet(); var headers = [ ["Product", "Quantity", "Unit Price", "Totals"] ]; var headerRange = sheet.getRange("B2:E2"); headerRange.values = headers; headerRange.format.fill.color = "##4472C4"; headerRange.format.font.color = "white"; return context.sync();}); 可以发现,Range 让 Excel 聚焦在批量单元格 API,即把单元格看做一个范围,整体 API 都可以围绕一个范围去设计。这种设计理念的好处是,把范围局限在单格单元格,就可以覆盖 Cell 概念,而聚焦在多个单元格时,可以很方便的基于二维数据结构创建表格、折线图等分析图形,因为二维结构的数据才是结构化数据。 或者可以说,结构化数据是 Excel 最核心的概念,而单元格无法体现结构化。结构化数据的好处是,一张工作表就是一个可以用来分析的数据集,在其之上无论是基于单元格的条件格式,还是创建分析图表,都是一种数据二次分析行为,这都得益于结构化数据,所以 Excel JS API 必然围绕结构化数据进行抽象。 再从 API 语法来看,除了工作薄这个级别的 API 采用了 Excel.createWorkbook(); 之外,其他大部分 API 都是以下形式: Excel.run(function (context) { // var sheet = context.workbook.worksheets.getItem("Sample"); // 对 sheet 操作 .. return context.sync();}); 最外层的函数 Excel.run 是注入 context 用的,而且也可以保证执行的时候 Excel context 已经准备好了。而 context.sync() 是同步操作,即使当前对 context 的操作生效。所以 Excel JS API 是命令式的,也不会做类似 MVVM 的双向绑定,所以在操作过程中数据和 Excel 状态不会发生变化,直到执行 context.sync()。 注意到这点后,就可以理解为什么要把某些代码写在 context.sync().then 里了,比如: Excel.run(function (ctx) { var pivotTable = context.workbook.worksheets.getActiveWorksheet().pivotTables.getItem("Farm Sales"); // Get the totals for each data hierarchy from the layout. var range = pivotTable.layout.getDataBodyRange(); var grandTotalRange = range.getLastRow(); grandTotalRange.load("address"); return context.sync().then(function () { // Sum the totals from the PivotTable data hierarchies and place them in a new range, outside of the PivotTable. var masterTotalRange = context.workbook.worksheets.getActiveWorksheet().getRange("E30"); masterTotalRange.formulas = [["=SUM(" + grandTotalRange.address + ")"]]; });}).catch(errorHandlerFunction); 这个从透视表获取数据的例子,只有执行 context.sync() 后才能拿到 grandTotalRange.address。 总结微软还在 Office 套件 Excel、Outlook、Word 中推出了 ScriptLab 功能,就可以在 Excel 的 ScriptLab 里编写 Excel JS API。 在 Excel JS API 之上,还有一个 通用 API,定义为跨应用的通用 API,这样 Excel JS API 就可以把精力聚焦在 Excel 产品本身能力上。 讨论地址是:精读《Excel JS API》· Issue ##387 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《JS with 语法》","path":"/wiki/WebWeekly/前沿技术/《JS with 语法》.html","content":"当前期刊数: 205 with 是一个不推荐使用的语法,因为它的作用是改变上下文,而上下文环境对开发者影响很大。 本周通过 JavaScript’s Forgotten Keyword (with) 这篇文章介绍一下 with 的功能。 概述下面是一种使用 with 的例子: with (console) { log('I dont need the "console." part anymore!');} 我们往上下文注入了 console 对象,而 console.log 这个属性就被注册到了这个 Scope 里。 再比如: with (console) { with (['a', 'b', 'c']) { log(join('')); // writes "abc" to the console. }} 通过嵌套,我们可以追加注入上下文。其中 with (['a', 'b', 'c']) 其实是把 ['a', 'b', 'c'] 的返回值对象注入到了上下文,而数组对象具有 .join 成员函数,所以可以直接调用 join('') 输出 "abc"。 为了不让结果这么 Magic,建议以枚举方式申明要注入的 key: with ({ myProperty: 'Hello world!' }) { console.log(myProperty); // Logs "Hello world!"} 那为什么不推荐使用 with 呢?比如下面的情况: function getAverage(min, max) { with (Math) { return round((min + max) / 2); }}getAverage(1, 5); 注入的上下文可能与已有上下文产生冲突,导致输出结果为 NaN。 所以业务代码中不推荐使用 with,而且实际上在 严格模式 下 with 也是被禁用的。 精读由于 with 定义的上下文会优先查找,因此在前端沙盒领域是一种解决方案,具体做法是: const sandboxCode = `with(scope) { ${code} }`new Function('scope', sandboxCode) 这样就把所有 scope 定义的对象限定住了。但如果访问 scope 外的对象还是会向上冒泡查找,我们可以结合 Proxy 来限制查找范围,这样就能完成一个可用性尚可的沙盒。 第二种 with 的用法是前端模版引擎。 我们经常看到模版引擎里会有一些 forEach、map 等特殊用法,这些语法完全可以通过 with 注入。当然并不是所有模版引擎都是这么实现的,还有另一种方案是,现将模版引擎解析为 AST,再根据 AST 构造并执行,如果把这个过程放到编译时,那么 JSX 就是一个例子。 最后关于 with 注入上下文,还有一个误区,那就是认为下面的代码仅仅注入了 run 属性: with ({ run: () => {} }) { run()} 其实不然,因为 with 会在整个原型链上查找,而 {} 的原型链是 Object.prototype,这就导致挂在了许多非预期的属性。 如果想要挂载一个纯净的对象,可以使用 Object.create() 创建对象挂载到 with 上。 总结with 的使用场景很少,一般情况下不推荐使用。 如果你还有其他正经的 with 使用场景,可以告知我,或者给出评论。 讨论地址是:精读《JS with 语法》· Issue ##343 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《JS 引擎基础之 Shapes and Inline Caches》","path":"/wiki/WebWeekly/前沿技术/《JS 引擎基础之 Shapes and Inline Caches》.html","content":"当前期刊数: 62 1 引言本期精读的文章是:JS 引擎基础之 Shapes and Inline Caches 一起了解下 JS 引擎是如何运作的吧! JS 的运作机制可以分为 AST 分析、引擎执行两个步骤: JS 源码通过 parser(分析器)转化为 AST(抽象语法树),再经过 interpreter(解释器)解析为 bytecode(字节码)。 为了提高运行效率,optimizing compiler(优化编辑器)负责生成 optimized code(优化后的机器码)。 本文主要从 AST 之后说起。 2 概述JS 的解释器、优化器JS 代码可能在字节码或者优化后的机器码状态下执行,而生成字节码速度很快,而生成机器码就要慢一些了。 V8 也类似,V8 将 interpreter 称为 Ignition(点火器),将 optimizing compiler 称为 TurboFan(涡轮风扇发动机)。 可以理解为将代码先点火启动后,逐渐进入涡轮发动机提速。 代码先快速解析成可执行的字节码,在执行过程中,利用执行中获取的数据(比如执行频率),将一些频率高的方法,通过优化编译器生成机器码以提速。 火狐使用的 Mozilla 引擎有一点点不同,使用了两个优化编译器,先将字节码优化为部分机器码,再根据这个部分优化后的代码运行时拿到的数据进行最终优化,生成高度优化的机器码,如果优化失败将会回退到部分优化的机器码。 笔者:不同前端引擎对 JS 优化方式大同小异,后面会继续列举不同前端引擎在解析器、编译器部分优化的方式。 微软的 Edge 浏览器,使用的 Chakra 引擎,优化方式与 Mozilla 很像,区别是第二个最终优化的编译器同时接收字节码和部分优化的机器码产生的数据,并且在优化失败后回退到第一步字节码而不是第二步。 Safari、React Native 使用的 JSC 引擎则更为极端,使用了三个优化编译器,其优化是一步步渐进的,优化失败后都会回退到第一步部分优化的机器码。 为什么不同前端引擎会使用不同的优化策略呢?这是由于 JS 要么使用解释器快速执行(生成字节码),或者优化成机器码后再执行,但优化消耗时间的并不总是小于字节码低效运行损耗的时间,所以有些引擎选择了多个优化编译器,逐层优化,尽可能在解析时间与执行效率中找到一个平衡点。 JS 的对象模型JS 是基于面向对象的,那么 JS 引擎是如何实现 JS 对象模型的呢?他们用了哪些技巧加速访问 JS 对象的属性? 和解析器、优化器一样,大部分主流 JS 引擎在对象模型实现上也很类似。 ECMAScript 规范确定了对象模型就是一个以字符串为 key 的字典,除了其值以外,还定义了 Writeable Enumerable Configurable 这些配置,表示这个 key 能否被重写、遍历访问、配置。 虽然规范定义了 [[]] 双括号的写法,那这不会暴露给用户,暴露给用户的是 Object.getOwnPropertyDescriptor 这个 API,可以拿到某个属性的配置。 在 JS 中,数组是对象的特殊场景,相比对象,数组拥有特定的下标,根据 ECMAScript 规范规定,数组下标的长度最大为 2³²−1。同时数组拥有 length 属性: length 只是一个不可枚举、不可配置的属性,并且在数组赋值时,会自动更新数值: 所以数组是特殊的对象,结构完全一致。 属性访问效率优化属性访问是最常见的,所以 JS 引擎必须对属性访问做优化。 ShapesJS 编程中,给不同对象相同的 key 名很常见,访问不同对象的同一个 propertyKey 也很常见: const object1 = { x: 1, y: 2 };const object2 = { x: 3, y: 4 };function logX(object) { console.log(object.x); // ^^^^^^^^}logX(object1);logX(object2); 这时 object1 与 object2 拥有一个相同的 shape。拿拥有 x、y 属性的对象来看: 如果访问 object.y,JS 引擎会先找到 key y,再查找 [[value]]。 如果将属性值也存储在 JSObject 中,像 object1 object2 就会出现许多冗余数据,因此引擎单独存储 Shape,与真实对象隔离: 这样具有相同结构的对象可以共享 Shape。所有 JS 引擎都是用这种方式优化对象,但并不都称为 Shape,这里就不详细罗列了,可以去原文查看在各引擎中 Shape 的别名。 Transition chains 和 Transition trees如果给一个对象增加了 key,JS 引擎如何生成新的 Shape 呢? 这种 Shape 链式创建的过程,称为 Transition chains: 开始创建空对象时,JSObject 和 Shape 都是空,当为 x 赋值 5 时,在 JSObject 下标 0 的位置添加了 5,并且 Shape 指向了拥有字段 x 的 Shape(x),当赋值 y 为 6 时,在 JSObject 下标 1 的位置添加了 6,并将 Shape 指向了拥有字段 x 和 y 的 Shape(x, y)。 而且可以再优化,Shape(x, y) 由于被 Shape(x) 指向,所以可以省略 x 这个属性: 笔者:当然这里说的主要是优化技巧,我们可以看出来,JS 引擎在做架构设计时没有考虑优化问题,而在架构设计完后,再回过头对时间和空间进行优化,这是架构设计的通用思路。 如果没有连续的父 Shape,比如分别创建两个对象: const object1 = {};object1.x = 5;const object2 = {};object2.y = 6; 这时要通过 Transition trees 来优化: 可以看到,两个 Shape(x) Shape(y) 分别继承 Shape(empty)。当然也不是任何时候都会创建空 Shape,比如下面的情况: const object1 = {};object1.x = 5;const object2 = { x: 6 }; 生成的 Shape 如下图所示: 可以看到,由于 object2 并不是从空对象开始的,所以并不会从 Shape(empty) 开始继承。 Inline Caches大概可以翻译为“局部缓存”,JS 引擎为了提高对象查找效率,需要在局部做高效缓存。 比如有一个函数 getX,从 o.x 获取值: function getX(o) { return o.x;} JSC 引擎 生成的字节码结构是这样的: get_by_id 指令是获取 arg1 参数指向的对象 x,并存储在 loc0,第二步则返回 loc0。 当执行函数 getX({ x: 'a' }) 时,引擎会在 get_by_id 指令中缓存这个对象的 Shape: 这个对象的 Shape 记录了自己拥有的字段 x 以及其对应的下标 offset: 执行 get_by_id 时,引擎从 Shape 查找下标,找到 x,这就是 o.x 的查找过程。但一旦找到,引擎就会将 Shape 保存的 offset 缓存起来,下次开始直接跳过 Shape 这一步: 以后访问 o.x 时,只要 Shape 相同,引擎直接从 get_by_id 指令中缓存的下标中可以直接命中要查找的值,而这个缓存在指令中的下标就是 Inline Cache. 数组存储优化和对象一样,数组的存储也可以被优化,而由于数组的特殊性,不需要为每一项数据做完整的配置。 比如这个数组: const array = ["##jsconfeu"]; JS 引擎同样通过 Shape 与数据分离的方式存储: JS 引擎将数组的值单独存储在 Elements 结构中,而且它们通常都是可读可配置可枚举的,所以并不会像对象一样,为每个元素做配置。 但如果是这种例子: // 永远不要这么做const array = Object.defineProperty([], "0", { value: "Oh noes!!1", writable: false, enumerable: false, configurable: false}); JS 引擎会存储一个 Dictionary Elements 类型,为每个数组元素做配置: 这样数组的优化就没有用了,后续的赋值都会基于这种比较浪费空间的 Dictionary Elements 结构。所以永远不要用 Object.defineProperty 操作数组。 通过对 JS 引擎原理的认识,作者总结了下面两点代码中的注意事项: 尽量以相同方式初始化对象,因为这样会生成较少的 Shapes。 不要混淆对象的 propertyKey 与数组的下标,虽然都是用类似的结构存储,但 JS 引擎对数组下标做了额外优化。 3 精读这次原理系列解读是针对 JS 引擎执行优化这个点的,而网页渲染流程大致如下: 可以看到 Script 在整个网页解析链路中位置是比较靠前的,JS 解析效率会直接影响网页的渲染,所以 JS 引擎通过解释器(parser)和优化器(optimizing compiler)尽可能对 JS 代码提效。 Shapes需要特别说明的是,Shapes 并不是原型链,原型链是面向开发者的概念,而 Shapes 是面向 JS 引擎的概念。 比如如下代码: const a = {};const b = {};const c = {}; 显然对象 a b c 之间是没有关联的,但共享一个 Shapes。 另外理解引擎的概念有助于我们站在语法层面对立面的角度思考问题:在 JS 学习阶段,我们会执着于思考如下几种创建对象方式的异同: const a = {};const b = new Object();const c = new f1();const d = Object.create(null); 比如上面四种情况,我们要理解在什么情况下,用何种方式创建对象性能最优。 但站在 JS 引擎优化角度去考虑,JS 引擎更希望我们都通过 const a = {} 这种看似最没有难度的方式创建对象,因为可以共享 Shape。而与其他方式混合使用,可能在逻辑上做到了优化,但阻碍了 JS 引擎做自动优化,可能会得不偿失。 Inline Caches对象级别的优化已经很极致了,工程代码中也没有机会帮助 JS 引擎做得更好,值得注意的是不要对数组使用 Object 对象下的方法,尤其是 defineProperty,因为这会让 JS 引擎在存储数组元素时,使用 Dictionary Elements 结构替代 Elements,而 Elements 结构是共享 PropertyDescriptor 的。 但也有难以避免的情况,比如使用 Object.defineProperty 监听数组变化时,就不得不破坏 JS 引擎渲染了。 笔者写 dob 的时候,使用 proxy 监听数组变化,这并不会改变 Elements 的结构,所以这也从另一个侧面证明了使用 proxy 监听对象变化比 Object.defineProperty 更优,因为 Object.defineProperty 会破坏 JS 引擎对数组做的优化。 4 总结本文主要介绍了 JS 引擎两个概念: Shapes 与 Inline Caches,通过认识 JS 引擎的优化方式,在编程中需要注意以下两件事: 尽量以相同方式初始化对象,因为这样会生成较少的 Shapes。 不要混淆对象的 propertyKey 与数组的下标,虽然都是用类似的结构存储,但 JS 引擎对数组下标做了额外优化。 5 更多讨论 讨论地址是:精读《JS 引擎基础之 Shapes and Inline Caches》 · Issue ##91 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《JS 数组的内部实现》","path":"/wiki/WebWeekly/前沿技术/《JS 数组的内部实现》.html","content":"当前期刊数: 239 每个 JS 执行引擎都有自己的实现,我们这次关注 V8 引擎是如何实现数组的。 本周主要精读的文章是 How JavaScript Array Works Internally?,比较简略的介绍了 V8 引擎的数组实现机制,笔者也会参考部分其他文章与源码结合进行讲解。 概述JS 数组的内部类型有很多模式,如: PACKED_SMI_ELEMENTS PACKED_DOUBLE_ELEMENTS PACKED_ELEMENTS HOLEY_SMI_ELEMENTS HOLEY_DOUBLE_ELEMENTS HOLEY_ELEMENTS PACKED 翻译为打包,实际意思是 “连续有值的数组”;HOLEY 翻译为孔洞,表示这个数组有很多孔洞一样的无效项,实际意思是 “中间有孔洞的数组”,这两个名词是互斥的。 SMI 表示数据类型为 32 位整型,DOUBLE 表示浮点类型,而什么类型都不写,表示数组的类型还杂糅了字符串、函数等,这个位置上的描述也是互斥的。 所以可以这么去看数组的内部类型:[PACKED, HOLEY]_[SMI, DOUBLE, '']_ELEMENTS。 最高效的类型 PACKED_SMI_ELEMENTS一个最简单的空数组类型默认为 PACKED_SMI_ELEMENTS: const arr = [] // PACKED_SMI_ELEMENTS PACKED_SMI_ELEMENTS 类型是性能最好的模式,存储的类型默认是连续的整型。当我们插入整型时,V8 会给数组自动扩容,此时类型还是 PACKED_SMI_ELEMENTS: const arr = [] // PACKED_SMI_ELEMENTSarr.push(1) // PACKED_SMI_ELEMENTS 或者直接创建有内容的数组,也是这个类型: const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS 自动降级当我们对数组使用骚操作时,V8 会默默的进行类型降级。比如突然访问到第 100 项: const arr = [1, 2, 3] // PACKED_SMI_ELEMENTSarr[100] = 4 // HOLEY_SMI_ELEMENTS 如果突然插入一个浮点类型,会降级到 DOUBLE: const arr = [1, 2, 3] // PACKED_SMI_ELEMENTSarr.push(4.1) // PACKED_DOUBLE_ELEMENTS 当然如果两个骚操作一结合,HOLEY_DOUBLE_ELEMENTS 就成功被你造出来了: const arr = [1, 2, 3] // PACKED_SMI_ELEMENTSarr[100] = 4.1 // HOLEY_DOUBLE_ELEMENTS 再狠一点,插入个字符串或者函数,那就到了最最兜底类型,HOLEY_ELEMENTS: const arr = [1, 2, 3] // PACKED_SMI_ELEMENTSarr[100] = '4' // HOLEY_ELEMENTS 从是否有 Empty 情况来看,PACKED > HOLEY 的性能,Benchmark 测试结果大概快 23%。 从类型来看,SMI > DOUBLE > 空类型。原因是类型决定了数组每项的长度,DOUBLE 类型是指每一项可能为 SMI 也可能为 DOUBLE,而空类型的每一项类型完全不可确认,在长度确认上会花费额外开销。 因此,HOLEY_ELEMENTS 是性能最差的兜底类型。 降级的不可逆性文中提到一个重点,表示降级是不可逆的,具体可以看下图: 其实要表达的规律很简单,即 PACKED 只会变成更糟的 HOLEY,SMI 只会往更糟的 DOUBLE 和空类型变,且这两种变化都不可逆。 精读为了验证文章的猜想,笔者使用 v8-debug 调试了一番。 使用 v8-debug 调试先介绍一下 v8-debug,它是一个 v8 引擎调试工具,首先执行下面的命令行安装 jsvu: npm i -g jsvu 然后执行 jsvu,根据引导选择自己的系统类型,第二步选择要安装的 js 引擎,选择 v8 和 v8-debug: jsvu// 选择 macos// 选择 v8,v8-debug 然后随便创建一个 js 文件,比如 test.js,再通过 ~/.jsvu/v8-debug ./test.js 就可以执行调试了。默认是不输出任何调试内容的,我们根据需求添加参数来输出要调试的信息,比如: ~/.jsvu/v8-debug ./test.js --print-ast 这样就会把 test.js 文件的语法树打印出来。 使用 v8-debug 调试数组的内部实现为了观察数组的内部实现,使用 console.log(arr) 显然不行,我们需要用 %DebugPrint(arr) 以 debug 模式打印数组,而这个 %DebugPrint 函数式 V8 提供的 Native API,在普通 js 脚本是不识别的,因此我们要在执行时添加参数 --allow-natives-syntax: ~/.jsvu/v8-debug ./test.js --allow-natives-syntax 同时,在 test.js 里使用 %DebugPrint 打印我们要调试的数组,如: const arr = []%DebugPrint(arr) 输出结果为: DebugPrint: 0x120d000ca0b9: [JSArray] - map: 0x120d00283a71 <Map(PACKED_SMI_ELEMENTS)> [FastProperties] 也就是说,arr = [] 创建的数组的内部类型为 PACKED_SMI_ELEMENTS,符合预期。 验证不可逆转换不看源码的话,姑且相信原文说的类型转换不可逆,那么我们做一个测试: const arr = [1, 2, 3]arr.push(4.1)console.log(arr);%DebugPrint(arr)arr.pop()console.log(arr);%DebugPrint(arr) 打印核心结果为: 1,2,3,4.1DebugPrint: 0xf91000ca195: [JSArray] - map: 0x0f9100283b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]1,2,3DebugPrint: 0xf91000ca195: [JSArray] - map: 0x0f9100283b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties] 可以看到,即便 pop 后将原数组回退到完全整型的情况,DOUBLE 也不会优化为 SMI。 再看下长度的测试: const arr = [1, 2, 3]arr[4] = 4console.log(arr);%DebugPrint(arr)arr.pop()arr.pop()console.log(arr);%DebugPrint(arr) 打印核心结果为: 1,2,3,,4DebugPrint: 0x338b000ca175: [JSArray] - map: 0x338b00283ae9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]1,2,3DebugPrint: 0x338b000ca175: [JSArray] - map: 0x338b00283ae9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties] 也证明了 PACKED 到 HOLEY 的不可逆。 字典模式数组还有一种内部实现是 Dictionary Elements,它用 HashTable 作为底层结构模拟数组的操作。 这种模式用于数组长度非常大的时候,不需要连续开辟内存空间,而是用一个个零散的内存空间通过一个 HashTable 寻址来处理数据的存储,这种模式在数据量大时节省了存储空间,但带来了额外的查询开销。 当对数组的赋值远大于当前数组大小时,V8 会考虑将数组转化为 Dictionary Elements 存储以节省存储空间。 做一个测试: const arr = [1, 2, 3];%DebugPrint(arr);arr[3000] = 4;%DebugPrint(arr); 主要输出结果为: DebugPrint: 0x209d000ca115: [JSArray] - map: 0x209d00283a71 <Map(PACKED_SMI_ELEMENTS)> [FastProperties]DebugPrint: 0x209d000ca115: [JSArray] - map: 0x209d00287d29 <Map(DICTIONARY_ELEMENTS)> [FastProperties] 可以看到,占用了太多空间会导致数组的内部实现切换为 DICTIONARY_ELEMENTS 模式。 实际上这两种模式是根据固定规则相互转化的,具体查了下 V8 源码: 字典模式在 V8 代码里叫 SlowElements,反之则叫 FastElements,所以要看转化规则,主要就看两个函数:ShouldConvertToSlowElements 和 ShouldConvertToFastElements。 下面是 ShouldConvertToSlowElements 代码,即什么时候转化为字典模式: static inline bool ShouldConvertToSlowElements( uint32_t used_elements, uint32_t new_capacity) { uint32_t size_threshold = NumberDictionary::kPreferFastElementsSizeFactor * NumberDictionary::ComputeCapacity(used_elements) * NumberDictionary::kEntrySize; return size_threshold <= new_capacity;}static inline bool ShouldConvertToSlowElements( JSObject object, uint32_t capacity, uint32_t index, uint32_t* new_capacity) { STATIC_ASSERT(JSObject::kMaxUncheckedOldFastElementsLength <= JSObject::kMaxUncheckedFastElementsLength); if (index < capacity) { *new_capacity = capacity; return false; } if (index - capacity >= JSObject::kMaxGap) return true; *new_capacity = JSObject::NewElementsCapacity(index + 1); DCHECK_LT(index, *new_capacity); if (*new_capacity <= JSObject::kMaxUncheckedOldFastElementsLength || (*new_capacity <= JSObject::kMaxUncheckedFastElementsLength && ObjectInYoungGeneration(object))) { return false; } return ShouldConvertToSlowElements(object.GetFastElementsUsage(), *new_capacity);} ShouldConvertToSlowElements 函数被重载了两次,所以有两个判断逻辑。第一处 new_capacity > size_threshold 则变成字典模式,new_capacity 表示新尺寸,而 size_threshold 是根据 3 * 已有尺寸 * 2 计算出来的。 第二处 index - capacity >= JSObject::kMaxGap 时变成字典模式,其中 kMaxGap 是常量 1024,也就是新加入的 HOLEY(孔洞) 大于 1024,则转化为字典模式。 而由字典模式转化为普通模式的函数是 ShouldConvertToFastElements: static bool ShouldConvertToFastElements( JSObject object, NumberDictionary dictionary, uint32_t index, uint32_t* new_capacity) { // If properties with non-standard attributes or accessors were added, we // cannot go back to fast elements. if (dictionary.requires_slow_elements()) return false; // Adding a property with this index will require slow elements. if (index >= static_cast<uint32_t>(Smi::kMaxValue)) return false; if (object.IsJSArray()) { Object length = JSArray::cast(object).length(); if (!length.IsSmi()) return false; *new_capacity = static_cast<uint32_t>(Smi::ToInt(length)); } else if (object.IsJSArgumentsObject()) { return false; } else { *new_capacity = dictionary.max_number_key() + 1; } *new_capacity = std::max(index + 1, *new_capacity); uint32_t dictionary_size = static_cast<uint32_t>(dictionary.Capacity()) * NumberDictionary::kEntrySize; // Turn fast if the dictionary only saves 50% space. return 2 * dictionary_size >= *new_capacity;} 重点是最后一行 return 2 * dictionary_size >= *new_capacity 表示字典模式仅节省了 50% 空间时,不如切换为普通模式(fast mode)。 具体就不测试了,感兴趣同学可以用上面介绍的方法使用 v8-debug 测试一下。 总结JS 数组使用方法非常灵活,但 V8 使用 C++ 实现时,必须转化为更底层的类型,所以为了兼顾性能,就做了快慢模式,而快模式又分了 SMI、DOUBLE;PACKED、HOLEY 模式分别处理来尽可能提升速度。 也就是说,我们在随意创建数组的时候,V8 会分析数组的元素构成与长度变化,自动分发到各种不同的子模式处理,以最大化提升性能。 这种模式使 JS 开发者获得了更好的开发者体验,而实际上执行性能也和 C++ 原生优化相差无几,所以从这个角度来看,JS 是一种更高封装层次的语言,极大降低了开发者学习门槛。 当然 JS 还提供了一些相对原生的语法比如 ArrayBuffer,或者 WASM 让开发者直接操作更底层的特性,这可以使性能控制更精确,但带来了更大的学习和维护成本,需要开发者根据实际情况权衡。 讨论地址是:精读《JS 数组的内部实现》· Issue ##414 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《JavaScript 错误堆栈处理》","path":"/wiki/WebWeekly/前沿技术/《JavaScript 错误堆栈处理》.html","content":"当前期刊数: 6 本期精读文章:JavaScript-Errors-and-Stack-Traces 中文版译文 1. 引言 错误处理无论对那种语言来说,都至关重要。在 JavaScript 中主要是通过 Error 对象和 Stack Traces 提供有价值的错误堆栈,帮助开发者调试。在服务端开发中,开发者可以将有价值错误信息打印到服务器日志中,而对于客户端而言就很难重现用户环境下的报错,我们团队一直在做一个错误监控的应用,在这里也和大家一起讨论下 js 异常监控的常规方式。 2. 内容概要了解 StackStack 部分主要在阐明 js 中函数调用栈的概念,它符合栈的基本特性『当调用时,压入栈顶。当它执行完毕时,被弹出栈』,简单看下面的代码: function c() {\ttry { var bar = baz; throw new Error()\t} catch (e) { console.log(e.stack);\t}}function b() {\tc();}function a() {\tb();}a(); 上述代码中会在执行到 c 函数的时候跑错,调用栈为 a -> b -> c,如下图所示: 很明显,错误堆栈可以帮助我们定位到报错的位置,在大型项目或者类库开发时,这很有意义。 认知 Error 对象紧接着,原作者讲到了 Error 对象,主要有两个重要属性 message 和 name 分别表示错误信息和错误名称。实际上,除了这两个属性还有一个未被标准化的 stack 属性,我们上面的代码也用到了 e.stack,这个属性包含了错误信息、错误名称以及错误栈信息。在 chrome 中测试打印出 e.stack 于 e 类似。感兴趣的可以了解下 Sentry 的 stack traces,它集成了 TraceKit,会对 Error 对象进行规范化处理。 如何使用堆栈追踪该部分以 NodeJS 环境为例,讲解了 Error.captureStackTrace,将 stack 信息作为属性存储在一个对象当中,同时可以过滤掉一些无用的堆栈信息。这样可以隐藏掉用户不需要了解的内部细节。作者也以 Chai 为例,内部使用该方法对代码的调用者屏蔽了不相关的实现细节。通过以 Assertion 对象为例,讲述了具体的内部实现,简单来说通过一个 addChainableMethod 链式调用工具方法,在运行一个 Assertion 时,将它设为标记,其后面的堆栈会被移除;如果 assertion 失败移除起后面所有内部堆栈;如果有内嵌 assertion,将当前 assertion 的方法放到 ssfi 中作为标记,移除后面堆栈帧; 3. 精读参与本次精读的同学有:范洪春、黄子毅、杨森、camsong,该部分由他们的观点总结而出。 captureStackTrace 方法优劣captureStackTrace 方法通过截取有意义报错堆栈,并统计上报,有助于排查问题。常用的断言库 chai 就是通过此方式屏蔽了库自身的调用栈,仅保留了用户代码的调用栈,这样用户会清晰的看到自己代码的调用栈。不过 Chai 的断言方式过分语义化,代码不易读。而实际上,现在有另外一款更黑科技的断言库正在崛起,那就是 power-assert。 直观的看一下 Chai.js 和 power-assert 的用法及反馈效果(以下代码及截图来自[小菜荔枝](http://www.jianshu.com/p/41ced3207a0c): const assert = require('power-assert');const should = require('should'); // 别忘记 npm install shouldconst obj = { arr: [1,2,3], number: 10};describe('should.js和power-assert的区别', () => { it('使用should.js的情况', () => { should(obj.arr[0]).be.equal(obj.number); // should api }); it('使用power-assert的情况', () => { assert(obj.arr[0] === obj.number); // 用assert就可以 });}); 抛 Error 对象的正确姿势在我们日常开发中一定要抛出标准的 Error 对象。否则,无法知道抛出的类型,很难对错误进行统一处理。正确的做法应该是使用 throw new Error(“error message here”),这里还引用了 Node.js 中推荐的异常处理方式: 区分操作异常和程序员的失误。操作异常指可预测的不可避免的异常,如无法连接服务器 操作异常应该被处理。程序员的失误不需要处理,如果处理了反而会影响错误排查 操作异常有两种处理方式:同步 (try……catch) 和异步(callback, event - emitter)两种处理方式,但只能选择其中一种。 函数定义时应该用文档写清楚参数类型,及可能会发生的合理的失败。以及错误是同步还是异步传给调用者的 缺少参数或参数无效是程序员的错误,一旦发生就应该 throw。传递错误时,使用标准的 Error 对象,并附件尽可能多的错误信息,可以使用标准的属性名 异步(Promise)环境下错误处理方式在 Promise 内部使用 reject 方法来处理错误,而不要直接调用 throw Error,这样你不会捕捉到任何的报错信息。 reject 如果使用 Error 对象,会导致捕获不到错误的情况,在我的博客中有讨论过这种情况:Callback Promise Generator Async-Await 和异常处理的演进,我们看以下代码: function thirdFunction() { return new Promise((resolve, reject) => { setTimeout(() => { reject('我可以被捕获') // throw Error('永远无法被捕获') }) })}Promise.resolve(true).then((resolve, reject) => { return thirdFunction()}).catch(error => { console.log('捕获异常', error) // 捕获异常 我可以被捕获}); 我们发现,在 macrotask 队列中,reject 行为是可以被 catch 到的,而此时 throw Error 就无法捕获异常,大家可以贴到浏览器运行试一试,第二次把 reject('我可以被捕获') 注释起来,取消 throw Error('永远无法被捕获') 的注释,会发现异常无法 catch 住。 这是因为 setTimeout 中 throw Error 无论如何都无法捕获到,而 reject 是 Promise 提供的关键字,自己当然可以 catch 住。 监控客户端 Error 报错文中提到的 try...catch 可以拿到出错的信息,堆栈,出错的文件、行号、列号等,但无法捕捉到语法错误,也没法去捕捉全局的异常事件。此外,在一些古老的浏览器下 try...catch 对 js 的性能也有一定的影响。 这里,想提一下另一个捕捉异常的方法,即 window.onerror,这也是我们在做错误监控中用到比较多的方案。它可以捕捉语法错误和运行时错误,并且拿到出错的信息,堆栈,出错的文件、行号、列号等。不过,由于是全局监测,就会统计到浏览器插件中的 js 异常。当然,还有一个问题就是浏览器跨域,页面和 js 代码在不同域上时,浏览器出于安全性的考虑,将异常内容隐藏,我们只能获取到一个简单的 Script Error 信息。不过这个解决方案也很成熟: 给应用内所需的 标签添加 crossorigin 属性; 在 js 所在的 cdn 服务器上添加 Access-Control-Allow-Origin: * HTTP 头; 4. 总结Error 和 Stack 信息对于日常开发来说,尤为重要。如果可以将 Error 统计并上报,更有助于我们排查信息,发现在用户环境下到底触发了什么错误,帮助我们提升产品的稳定性。 讨论地址是:JavaScript 中错误堆栈处理 · Issue ##9 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Javascript 事件循环与异步》","path":"/wiki/WebWeekly/前沿技术/《Javascript 事件循环与异步》.html","content":"当前期刊数: 30 本期精读的文章是: How JavaScript works: Event loop and the rise of Async programming + 5 ways to better coding with async/await 1 引言我为什么要选这篇文章呢? sessionstack 最近接连发了好几篇文章, 深入探讨 JS, 以及 JS 中一些内部原理. 文中也讲到了, 伴随深入了解 JS 中的一些工作原理, 才有可能写出更好的代码和程序. 而 JS 中 Event Loop, 我的感觉就像 JS 中的一门内科, 我们平时只注意外科创伤,却忽视了内科问题往往容易莫名其妙的生病。了解 JS Event Loop 的原理,对 setTimeout Promise 这种基础概念不再浮在表层,可以写出更可靠的代码,如果你是前端新人,不要总是因为这个问题挂在一面 :p。 2 内容概要从前我对 Event Loop 的理解也并不透彻,通过仔细阅读此文后, Event Loop 、宿主环境、js 线程三者之间关系更加透明了,希望读者读完后也能有所体会。 文中 Promise、async/await 部分就忽略了,本篇重点介绍 Event Loop 。 Event Loop 与 Call Stack、Web APIs 之间的关系 原文通过 16 个图表达了 5 行代码的执行过程,太长就只贴第一张图了。 Call Stack 是调用栈,Event Loop 就是本期的主角 - 事件循环,Web APIs 泛指宿主环境,比如 nodejs 中的 c++,前端中的浏览器。 任何同步的代码都只存在于 Call Stack 中,遵循先进后出,后进先出的规则,也就是只有异步的代码(不一定是回调)才会进入 Event Loop 中,哪些是异步代码呢?比如: setTimeout()setInterval()Promise.resolve().then()fetch().then() 所有这些异步代码在执行时,都不会进入 Call Stack,而是进入 Event Loop 队列,此时 JS 主线程执行完毕后,且异步时机到了,就会将异步回调中的代码推入 Call Stack 执行。 而控制异步什么时机开始执行,是由宿主环境决定的,因为此时 js 主线程已经调用完毕,除非 Event Loop 队列有内容,推送到 Call Stack 中,否则 js 引擎也不会再执行任何代码。比如通过 fetch 发送请求,当 js 调用浏览器发送请求后,直到浏览器主动告诉 js 请求完成了,期间 js 是无法干预任何的。 最终效果如下 gif 图所示: Microtask 与 MacrotaskEvent Loop 处理异步的方式也分两种,分别是 setTimeout 之流的 Macrotask,与 Promise 之流的 Microtask。 异步队列是周而复始循环执行的,可以看作是二维数组:横排是一个队列中的每一个函数,纵排是每一个队列。 Macrotask 的方式是将执行函数添加到新的纵排,而 Microtask 将执行函数添加到当前执行到队列的横排,因此 Microtask 方式的插入是轻量的,最快被执行到的。 3 精读Event Loop 内容不多,内容概要部分已经讲的比较彻底了,原文最后扯到了 Promise, async/await 的用法和注意点,不然是不会这么长的。 我最近写了一些 dob-react tests 测试文件,发现 componentWillMount 函数在 Microtask 时机 setState 不会触发 rerender: class Hello extends React.Component {\tasync componentWillMount() { await immediate(()=>{ this.setState({a:1}) }) } render() { /**/ }} 这种 immediate 函数的写法只会 render 一次: function immediate(fn) { return new Promise(resolve => { fn() resolve() });} 在线 Demo:http://jsfiddle.net/69z2wepo/90440/ 如果再套一层 setTimeout,哪怕是一层 Promise, 就会 render 两次: function immediate(fn) { return new Promise(resolve => Promise.resolve().then(() => { fn() resolve() }));} 在线 Demo:http://jsfiddle.net/69z2wepo/90441/ ps: 感谢读者们的回复,其实第一个 immediate 函数根本就写错了,执行 fn 的时机是同步的,还是老老实实的使用 Promise().resolve().then() 吧。 4 总结理解了事件循环之后,才是第一步,比如我就对 React 的生命周期中异步 setState 合并机制时而生效,时而不生效抱有疑问,所以想要写好稳健的业务代码还是挺难的,首先要理解这种 “内科” 知识,其次要读懂 react 源码,最后你还要保证不会忘。 讨论地址是:精读《Javascript 事件循环与异步》 · Issue ##41 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Microsoft Power Fx》","path":"/wiki/WebWeekly/前沿技术/《Microsoft Power Fx》.html","content":"当前期刊数: 211 Power Fx 是一门语言,虽然它被推荐的场景是低代码,但我们必须以一门语言角度看待它,才能更好的理解。 Power Fx 的创建是为了更好的辅助非专业开发人员,因此这门语言被设计的足够简单,希望这门语言可以同时服务于专业与非专业开发者,这是个非常崇高的理想。 本周我们就随着 Microsoft Power Fx 概述 这篇文章,详细了解一下这门语言是怎么做的。 概述Notify("this is a problem", Error) 这就是 Power Fx 语言的一个例子,乍一看没什么特别的。 Power Fx 描述的是画布应用公式语言,也就是说,这个编程语言是专门为画布引用设计的。 那什么是画布应用呢?低代码、网站搭建、BI、Web Excel 这些统统都是画布应用,所以 Power Fx 其实是一门适应画布场景的语言,直接面向用户。 那这种画布语言应该具备什么特性呢?Power Fx 团队已经有了一些思考: 简单:该语言设计本着简介简单的原则,这样才方便非开发人员上手。 Excel 一致性:可以帮助 Excel 开发者做知识迁移,一部分是和微软 Excel 太成功了有关,另一方面 Excel 表达式在画布语言领域探索确实深入,有可取性。对不能满足的尝试借鉴 SQL 这种声明性语言。 声明性:这个最重要,即描述做什么,而不是如何或何时做。这个有点像 Jquery 转到 React 模式时,过程式代码与数据驱动代码的区别。 函数式:函数式在灵活性和易用性上有天然优势,且无副作用的特性也利于理解逻辑与编译优化。 组合:即利用函数式这个特性,推荐利用已有函数组合成新功能,而不是将比如 Sort、Filter 等功能在每个组件上重复实现或者重复配置一遍。 强类型:类型对可维护性至关重要,再强大的低代码语言,如果没有类型支持,都不能称为易上手。 类型推理:可以自动推断类型。这个和强类型一样,有点 TS 的感觉,主要方便书写简洁代码。 不推荐面向对象:既然推荐了函数式,当然不推荐面向对象了。 可推展:开发者要拥有拓展函数与组件的能力,还要支持通过 Javascript 来拓展。 对开发人员友好:这门语言还要在与前面原则不冲突的情况下,尽量对开发人员友好。 语言的迭代:即当语法变更时,要帮助用户平滑迁移,毕竟这门语言直接面向普通用户而非专业开发者。Power Fx 提供了这个能力,对每个文档进行版本标记,并在升级后,通过 “兼容转换器” 自动将老语法升级为新语法。 无 undefined 值:为了简化语言带来的理解成本,移除了 undefined 值这个特定。 所以,基于这些考虑的 Power Fx 设计出来是这样的: 实时性 即无论任何 UI 或语法错误,都不会阻塞其它正常节点的工作,同时代码效果与错误信息实时反馈。这保证了在画布应用编写逻辑的良好体验,因为本身画布应用就是实时的,低代码能力本身也要与画布实时性浑然一体。 低代码特征 即任何 UI 组件都不需要描述类似 onChange 之类的回调,它们只要申明使用的变量,当这些变量变化时,程序会自动、异步、按需的更新使用到的组件。 与无代码结合 所谓无代码,就是通过 UI 表单可视化的对画布应用进行配置。 与无代码的结合方式是,任意属性都可以用低代码,即表达式编写,但也提供了 UI 表单供编辑,其中 UI 表单编辑后,可以用低代码二次加工,而用低代码编辑的属性,表单就无法编辑了,此时点击表单编辑会跳转到低代码编辑框。 精读创建一门不用学习就能上手的编程语言,需要足够简单,即从用户角度来理解事物:比如用户不知道回调函数等概念,那就屏蔽所谓的回调函数概念,让一切都是表达式。 这些表达式看起来很简单,也符合直觉,并且会自动驱动 UI 重绘,即声明式编程。 下面我们来讨论几个有意思的点: 为什么不用 Js大部分画布应用都是指 Web 应用了,即便是 Excel,现在也早已转型到 Web Excel,就微软来说,早早转型到 Office Online 就能看出来。 然而 Js 是浏览器内置支持的脚本语言,且上手成本也比较低,其实很多低代码平台内置的编程语言就是 Js,其好处是实现成本低(沙箱甚至 new function),而 Power Fx 在浏览器平台最终也要转换为 Js 执行,费这么大劲创造一门新语言,无非是觉得 Js 不够 “零门槛”。 首先第一点是不符合 Excel 表达式规范,我们不要忘了 Power Fx 也是有小心机的,它想利用 Excel 生态扩大用户群,所以第一目的是兼容 Excel 语法。比如 Excel 使用 & 链接字符串,而 Js 使用 + 连接,虽然我觉得显然 + 号更自然,但微软觉得还是要符合 Excel 用户习惯。说实话在这一点上,撇开 Excel 的语法,我很难看出为什么 & 连接字符串就 “更易上手”,而 + 连接字符串 “更适合程序员使用”。 但有些是认可的,比如移除了 undefined 值,确实让语言更好理解。 也许未来 Power Fx 会更进一步,引入类 SQL 描述性的语法,像写自然语言一样编程,在这种程度上,配合强类型提示,在特定场景会比 Js 更好用。 提供内置函数Js 提供了大量内置函数,这似乎不是 Power Fx 的专利,但 Power Fx 提供了许多 UI 级别的函数,这可比 Js 点到为止的 alert 强多了。 Power Fx 提供了 Confirm、Notify 用于弹出提示窗供用户输入,并且就算要形成逻辑,也只需要几乎一行代码: If( Confirm( "Are you sure?", {Title: "Delete Confirmation"} ), Remove( ThisItem ) ) 可以看到,这里充斥着异步操作: 等待用户输入。 删除元素。 但这些内置函数间的组合将异步效果转换为同步写法,这大大降低开发成本。 另一类内置函数则封装了业务属性,比如 User 可以获取当前用户信息。本来获取用户信息就需要代码开发,但低代码平台本身就实现了全套账号体系,因此低代码平台可以直接提供如 User().Email 函数访问当前用户的邮箱地址。 还有诸如 Reset 函数,可以重制控件为默认值,比如 Reset( TextInput1 ),这其实是把平台提供的所有上层能力抽象成低代码函数供用户调用,这样用户只要付出一点点学习成本,就可以获得比简单 UI 强大的多的应用编辑能力,这非常值得我们学习。 更多公式函数可以参考 文档。 提供对表的操作对表的操作 让应用数据管理可以和 Excel 同一概念来看待了,这个统一方式就是,把数据抽象成表。Power Fx 提供了系列函数用于表处理: AddColumns( Filter( Products, 'Quantity Requested' > 'Quantity Available' ), "Quantity To Order", 'Quantity Requested' - 'Quantity Available') 这些函数可以跨语言操作 Excel、Sql Server 等数据源的数据,学习成本与 SQL 类似,其实到这一步,对低代码用户的要求也不低,至少和熟练使用计算公式的 Excel 使用者相当。 总结UI 编辑能力局限但易上手,代码能力最强但难上手,Power Fx 给我们提供了一种折中方案,即提供一种 “高度封装的简化代码” 供用户使用。 纵观其它低代码平台,也有一类采用了另一种折中方案,即超强的复杂编辑 UI,登峰造极的产物便是逻辑编排,这个方向在特定领域也是不错的选择,参考: 精读《低代码逻辑编排》。 讨论地址是:精读《Microsoft Power Fx》· Issue ##355 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Monorepo 的优势》","path":"/wiki/WebWeekly/前沿技术/《Monorepo 的优势》.html","content":"当前期刊数: 102 1. 引言本周精读的文章是 The many Benefits of Using a Monorepo。 现在介绍 Monorepo 的文章很多,可以分为如下几类:直接介绍 Lerna API 的;介绍如何从独立仓库迁移到 Lerna 的;通过举例子说明 Monorepo 重要性的。 本文属于第三种,从 Android 与 IOS 的开发故事说明了 Monorepo 的重要性。 笔者之所以选择这篇文章,不是因为其故事写的好,而是认可这种具有普适性的解决思路。毕竟 Lerna 作为 Monorepo 的实现之一也并不尽善尽美,而不同场景对 Monorepo 依赖的原因、功能也有所不同,所以希望借这篇文章,从理论上解释清楚为什么会产生 Monorepo,以及 Monorepo 可以解决哪些问题,这样在工作遇到问题时,才能想清楚自己要的是什么。 2. 概述作者的一个项目是 PDF 服务,简称 PSPDFKit,需要同时兼顾 Android 与 IOS 平台,项目的发展经历了如下几个阶段。 初始阶段在 2011 到 2013 年间,PSPDFKit 仅支持 IOS 平台,但最终项目需要支持 Android,因此开了一个新仓库放置 Android 代码。Android 仓库的代码不仅在 UI 上不同,同时解析 PDF 文档的核心代码也不同,这是因为 IOS 平台上使用内置 PDF 渲染引擎同时做了一些业务拓展,但使用的 OC 代码无法在 Android 使用。 最终新建了两个仓库 PSPDFKit-Android 与 Core 。 仓库 Core 中代码依赖 Android 平台 JNI 的支持,所以并不能实现 Core 一处修改,两处都生效的愿望,而我们又希望两边功能始终兼容,且减少分支过多带来的潜在的冲突,因此花了很久才意识到应该将这两个仓库合并起来。 考虑使用 Monorepo由于 Android 的整套流程自己控制的,因此总是可以快速修复用户提出的 BUG,然而 IOS 提供的 CGPDF 总会遇上各种问题。所以在 2014 年,我们开启了一个庞大的项目,重写 IOS 的 Core 库。有三中方式可供选择: 在 IOS 代码中引用 PSPDFKit-Android。 将 PSPDFKit-Android 提取到 Core 仓库中并分别维护。 将 IOS 与 Android 代码合并到一个仓库中。 经过讨论,最终作者的团队选择了第三种方案,因此目录结构类似如下: - ios-platform- android-platform- core 特例Web 与后台服务代码一直是一个特例,我们认为这些内容相对独立,所以没有将其代码放置到 Monorepo 中。 直到一年后,开始探索 WebAssembly 时,PSPDFKit-web 模块就出现了,因为可以利用 WebAssembly 将 Core 的代码编译并在 Web 平台使用,因此 Core 仓库与 Web 仓库的关系变得非常紧密,最终,我们将 Web、Server 也都迁移到 Monorepo 中了。 问题Monorepo 瑕不掩瑜,但作者还是列举了一些缺陷。 由于源码在一起,仓库变更非常常见,存储空间也变得很大,甚至几 GB,CI 测试运行时间也会变长。即便如此,团队中任何人都不想回到 git submodules 多仓库的方式。 3. 精读总的来说,虽然拆分子仓库、拆分子 NPM 包(For web)是进行项目隔离的天然方案,但当仓库内容出现关联时,没有任何一种调试方式比源码放在一起更高效。 工程化的最终目的是让业务开发可以 100% 聚焦在业务逻辑上,那么这不仅仅是脚手架、框架需要从自动化、设计上解决的问题,这涉及到仓库管理的设计。 一个理想的开发环境可以抽象成这样: “只关心业务代码,可以直接跨业务复用而不关心复用方式,调试时所有代码都在源码中。” 在前端开发环境中,多 Git Repo,多 Npm 则是这个理想的阻力,它们导致复用要关心版本号,调试需要 Npm Link。 另外对于多仓库的缺点,文中还有一些没有提到的因素,这里一并列举出来: 管理、调试困难 多个 git 仓库管理起来天然是麻烦的。对于功能类似的模块,如果拆成了多个仓库,无论对于多人协作还是独立开发,都需要打开多个仓库页面。 虽然 vscode 通过 Workspaces 解决多仓库管理的问题,但在多人协作的场景下,无法保证每个人的环境配置一致。 对于共用的包通过 Npm 安装,如果不能接受调试编译后的代码,或每次 npm link 一下,就没有办法调试依赖的子包。 分支管理混乱 假如一个仓库提供给 A、B 两个项目用,而 B 项目优先开发了功能 b,无法与 A 项目兼容,此时就要在这个仓库开一个 feature/b 的分支支持这个功能,并且在未来合并到主干同步到项目 A。 一旦需要开分支的组件变多了,且之间出来依赖关联,分支管理复杂度就会呈指数上升。 依赖关系复杂 独立仓库间组件版本号的维护需要手动操作,因为源代码不在一起,所以没有办法整体分析依赖,自动化管理版本号的依赖。 三方依赖版本可能不一致 一个独立的包拥有一套独立的开发环境,难以保证子模块的版本和主项目完全一直,就存在运行结果不一致的风险。 占用总空间大 正常情况下,一个公司的业务项目只有一个主干,多 git repo 的方式浪费了大量存储空间重复安装比如 React 等大型模块,时间久了可能会占用几十 GB 的额外空间,对于没有外接硬盘的同学来说,定期清理不用的项目下 node_modules 也是一件麻烦事。 不利于团队协作 一个大项目可能会用到数百个二方包,不同二方包的维护频率不同,权限不同,仓库位置也不同,主仓库对它们的依赖方式也不同。 一旦其中一个包进行了非正常改动,就会影响到整个项目,而我们精力有限,只盯着主仓库,往往会栽在不起眼的二方包发布上。 所以对于一个非常复杂,又具有技术挑战的大型系统在协作人员多的情况下出现问题的概率非常大,需要通过 Review 制度避免错误的发生,那么将所有相关的源码聚合在一个仓库下,是更好管理的。 理想 monorepo 的设计参考 Lerna 的规范,以 packages 作为子模块根文件夹,笔者设计一个理想的 monorepo 结构: .├── packages│ ├─ module-a│ │ ├─ src ## 模块 a 的源码│ │ └─ package.json ## 自动生成的,仅模块 a 的依赖│ └─ module-b│ ├─ src ## 模块 b 的源码│ └─ package.json ## 自动生成的,仅模块 b 的依赖├── tsconfig.json ## 配置文件,对整个项目生效├── .eslintrc ## 配置文件,对整个项目生效├── node_modules ## 整个项目只有一个外层 node_modules└── package.json ## 包含整个项目所有依赖 所有全局配置文件只有一个,这样不会导致 IDE 遇到子文件夹中的配置文件,导致全局配置失效或异常。node_modules 也只有一个,既保证了项目依赖的一致性,又避免了依赖被重复安装,节省空间的同时还提高了安装速度。 兄弟模块之间通过模块 package.json 定义的 name 相互引用,保证模块之间的独立性,但又不需要真正发布或安装这个模块,通过 tsconfig.json 的 paths 与 webpack 的 alias 共同实现虚拟模块路径的效果。 再结合 Lerna 根据联动发布功能,使每个子模块都可以独立发布。 4. 总结Lerna 是业界知名度最高的 Monorepo 管理工具,功能完整。但由于通用性要求非常高,需要支持任意项目间 Monorepo 的组合,因此在 packages 文件夹下的配置文件还是与独立仓库保持一致,这样在 TS 环境下会造成配置截断的问题。同时包之间的引用也通过更通用的 symlink 完成,这导致了还是要在子模块目录存在 node_modules 文件夹,而且效果依赖项目初始化命令。 如果加一些限定条件,比如基于 Webpack + Typescript 环境的 Monorepo,可以换一套思路,利用这些工具自身运行时功能,减少更多模版代码或配置文件,进一步提升 Monorepo 的效果。 对于别名映射,对 symlink 与 alias 进行对比: symlink: 更通用,适合任何构建器。但需要初始化,且在每个关联模块下新增 node_modules 文件夹。 alias: 限定构建器。但不需要初始化,不新增文件夹,甚至可以运行时动态修改别名配置。 可见如果限定了构建器,别名映射可以做得更轻量,且无需初始化。 今天的问题是,你的项目需要使用 Monorepo 吗?你对 Monorepo 有其他要求吗? 讨论地址是:精读《Monorepo 的优势》 · Issue ##151 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《JS 中的内存管理》","path":"/wiki/WebWeekly/前沿技术/《JS 中的内存管理》.html","content":"当前期刊数: 29 本期精读的文章是: How JavaScript works: memory management + how to handle 4 common memory leaks 1 引言我为什么要选这篇文章呢? sessionstack 最近接连发了好几篇文章, 深入探讨 JS, 以及 JS 中一些内部原理. 文中也讲到了, 伴随深入了解 JS 中的一些工作原理, 才有可能写出更好的代码和程序. 而 JS 中的内存管理, 我的感觉就像 JS 中的一门副科, 我们平时不会太重视, 但是一旦出问题又很棘手. 所以可以通过平时多了解一些 JS 中内存管理问题, 在写代码中通过一些习惯, 避免内存泄露的问题. 2 内容概要内存生命周期 不管什么程序语言,内存生命周期基本是一致的: 分配你所需要的内存 使用分配到的内存(读, 写) 不需要时将其释放/归还 在 C 语言中, 有专门的内存管理接口, 像malloc() 和 free(). 而在 JS 中, 没有专门的内存管理接口, 所有的内存管理都是”自动”的. JS 在创建变量时, 自动分配内存, 并在不使用的时候, 自动释放. 这种”自动”的内存回收, 造成了很多 JS 开发并不关心内存回收, 实际上, 这是错误的. JS 中的内存回收引用垃圾回收算法主要依赖于引用的概念. 在内存管理的环境中, 一个对象如果有访问另一个对象的权限(隐式或者显式), 叫做一个对象引用另一个对象. 例如: 一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用). 引用计数垃圾收集这是最简单的垃圾收集算法.此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”. 如果没有引用指向该对象(零引用, 对象将被垃圾回收机制回收.示例: let arr = [1, 2, 3, 4];arr = null; // [1,2,3,4]这时没有被引用, 会被自动回收 限制: 循环引用在下面的例子中, 两个对象对象被创建并互相引用, 就造成了循环引用. 它们被调用之后不会离开函数作用域, 所以它们已经没有用了, 可以被回收了. 然而, 引用计数算法考虑到它们互相都有至少一次引用, 所以它们不会被回收. function f() { var o1 = {}; var o2 = {}; o1.p = o2; // o1 引用 o2 o2.p = o1; // o2 引用 o1. 这里会形成一个循环引用}f(); 实际例子: var div;window.onload = function(){ div = document.getElementById("myDivElement"); div.circularReference = div; div.lotsOfData = new Array(10000).join("*");}; 在上面的例子里, myDivElement 这个 DOM 元素里的 circularReference 属性引用了 myDivElement, 造成了循环引用. IE 6, 7 使用引用计数方式对 DOM 对象进行垃圾回收. 该方式常常造成对象被循环引用时内存发生泄漏. 现代浏览器通过使用标记-清除内存回收算法, 来解决这一问题. 标记-清除算法这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”. 这个算法假定设置一个叫做根root的对象(在 Javascript 里,根是全局对象). 定期的, 垃圾回收器将从根开始, 找所有从根开始引用的对象, 然后找这些对象引用的对象, 从根开始,垃圾回收器将找到所有可以获得的对象和所有不能获得的对象. 从 2012 年起, 所有现代浏览器都使用了标记-清除内存回收算法。所有对 JavaScript 垃圾回收算法的改进都是基于标记-清除算法的改进. 自动 GC 的问题尽管自动 GC 很方便, 但是我们不知道 GC 什么时候会进行. 这意味着如果我们在使用过程中使用了大量的内存, 而 GC 没有运行的情况下, 或者 GC 无法回收这些内存的情况下, 程序就有可能假死, 这个就需要我们在程序中手动做一些操作来触发内存回收. 什么是内存泄露?本质上讲, 内存泄露就是不再被需要的内存, 由于某种原因, 无法被释放. 常见的内存泄露案例1. 全局变量function foo(arg) { bar = "some text";} 在 JS 中处理未被声明的变量, 上述范例中的 bar时, 会把bar, 定义到全局对象中, 在浏览器中就是 window 上. 在页面中的全局变量, 只有当页面被关闭后才会被销毁. 所以这种写法就会造成内存泄露, 当然在这个例子中泄露的只是一个简单的字符串, 但是在实际的代码中, 往往情况会更加糟糕. 另外一种意外创建全局变量的情况. function foo() { this.var1 = "potential accidental global";}// Foo 被调用时, this 指向全局变量(window)foo(); 在这种情况下调用foo, this 被指向了全局变量window, 意外的创建了全局变量. 我们谈到了一些意外情况下定义的全局变量, 代码中也有一些我们明确定义的全局变量. 如果使用这些全局变量用来暂存大量的数据, 记得在使用后, 对其重新赋值为 null. 2. 未销毁的定时器和回调函数在很多库中, 如果使用了观察者模式, 都会提供回调方法, 来调用一些回调函数. 要记得回收这些回调函数. 举一个 setInterval 的例子. var serverData = loadData();setInterval(function() { var renderer = document.getElementById('renderer'); if(renderer) { renderer.innerHTML = JSON.stringify(serverData); }}, 5000); // 每 5 秒调用一次 如果后续 renderer 元素被移除, 整个定时器实际上没有任何作用. 但如果你没有回收定时器, 整个定时器依然有效, 不但定时器无法被内存回收, 定时器函数中的依赖也无法回收. 在这个案例中的 serverData 也无法被回收. 3. 闭包在 JS 开发中, 我们会经常用到闭包, 一个内部函数, 有权访问包含其的外部函数中的变量. 下面这种情况下, 闭包也会造成内存泄露. var theThing = null;var replaceThing = function () { var originalThing = theThing; var unused = function () { if (originalThing) // 对于 'originalThing'的引用 console.log("hi"); }; theThing = { longStr: new Array(1000000).join('*'), someMethod: function () { console.log("message"); } };};setInterval(replaceThing, 1000); 这段代码, 每次调用replaceThing时, theThing 获得了包含一个巨大的数组和一个对于新闭包someMethod的对象. 同时 unused 是一个引用了originalThing的闭包. 这个范例的关键在于, 闭包之间是共享作用域的, 尽管unused可能一直没有被调用, 但是someMethod 可能会被调用, 就会导致内存无法对其进行回收. 当这段代码被反复执行时, 内存会持续增长. 该问题的更多描述可见Meteor 团队的这篇文章. 4. DOM 引用很多时候, 我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 Map 中. var elements = { image: document.getElementById('image')};function doStuff() { elements.image.src = 'http://example.com/image_name.png';}function removeImage() { document.body.removeChild(document.getElementById('image')); // 这个时候我们对于 ##image 仍然有一个引用, Image 元素, 仍然无法被内存回收.} 上述案例中, 即使我们对于 image 元素进行了移除, 但是仍然有对 image 元素的引用, 依然无法对齐进行内存回收. 另外需要注意的一个点是, 对于一个 Dom 树的叶子节点的引用. 举个例子: 如果我们引用了一个表格中的td元素, 一旦在 Dom 中删除了整个表格, 我们直观的觉得内存回收应该回收除了被引用的 td外的其他元素. 但是事实上, 这个td 元素是整个表格的一个子元素, 并保留对于其父元素的引用. 这就会导致对于整个表格, 都无法进行内存回收. 所以我们要小心处理对于 Dom 元素的引用. 3 精读ES6 中引入WeakSet 和 WeakMap两个新的概念, 来解决引用造成的内存回收问题. WeakSet 和 WeakMap对于值的引用可以忽略不计, 他们对于值的引用是弱引用,内存回收机制, 不会考虑这种引用. 当其他引用被消除后, 引用就会从内存中被释放. JS 这类高级语言,隐藏了内存管理功能。但无论开发人员是否注意,内存管理都在那,所有编程语言最终要与操作系统打交道,在内存大小固定的硬件上工作。不幸的是,即使不考虑垃圾回收对性能的影响,2017 年最新的垃圾回收算法,也无法智能回收所有极端的情况。 唯有程序员自己才知道何时进行垃圾回收,而 JS 由于没有暴露显示内存管理接口,导致触发垃圾回收的代码看起来像“垃圾”,或者优化垃圾回收的代码段看起来不优雅、甚至不可读。 所以在 JS 这类高级语言中,有必要掌握基础内存分配原理,在对内存敏感的场景,比如 nodejs 代码做严格检查与优化。谨慎使用 dom 操作、主动删除没有业务意义的变量、避免提前优化、过度优化,在保证代码可读性的前提下,利用性能监控工具,通过调用栈定位问题代码。 同时对于如何利用 chrome 调试工具, 分析内存泄露的方法和技巧. 可以参考上期精读精读《2017 前端性能优化备忘录》 4 总结即便在 JS 中, 我们很少去直接去做内存管理. 但是我们在写代码的时候, 也要有内存管理的意识, 谨慎的处理可能会造成内存泄露的场景. 讨论地址是:精读《JS 中的内存管理》 · Issue ##40 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。 参考文章: MDN 的内存管理介绍"},{"title":"《Nestjs》文档","path":"/wiki/WebWeekly/前沿技术/《Nestjs》文档.html","content":"当前期刊数: 20 精读 《Nestjs 文档》本期精读的文章是:Nestjs 文档 体验一下 nodejs mvc 框架的优雅设计。 1 引言 Nestjs 是我见过的,将 Typescript 与 Nodejs Framework 结合的最好的例子。 2 内容概要Nestjs 不是一个新轮子,它是基于 Express、socket.io 封装的 nodejs 后端开发框架,对 Typescript 开发者提供类型支持,也能优雅降级供 Js 使用,拥有诸多特性,像中间件等就不展开了,本文重点列举其亮点特性。 2.1 Modules, Controllers, ProvidersNestjs 开发围绕着这三个单词,Modules 是最大粒度的拆分,表示应用或者模块。Controllers 是传统意义的控制器,一个 Module 拥有多个 Controller。Providers 一般用于做 Services,比如将数据库 CRUD 封装在 Services 中,每个 Service 就是一个 Provider。 2.2 装饰器路由装饰器路由是个好东西,路由直接标志在函数头上,做到了路由去中心化: @Controller()export class UsersController { @Get('users') getAllUsers() {} @Get('users/:id') getUser() {} @Post('users') addUser() {}} 以前用过 Go 语言框架 Beego,就是采用了中心化路由管理方式,虽然引入了 namespace 概念,但当协作者多、模块体量巨大时,路由管理成本直线上升。Nestjs 类似 namespace 的概念通过装饰器实现: @Controller('users')export class UsersController { @Get() getAllUsers(req: Request, res: Response, next: NextFunction) {}} 访问 /users 时会进入 getAllUsers 函数。可以看到其 namespace 也是去中心化的。 2.3 模块间依赖注入Modules, Controllers, Providers 之间通过依赖注入相互关联,它们通过同名的 @Module @Controller @Injectable 装饰器申明,如: @Controller()export class UsersController { @Get('users') getAllUsers() {}} @Injectable()export class UsersService { getAllUsers() { return [] }} @Module({ controllers: [ UsersController ], providers: [ UsersService ],})export class ApplicationModule {} 在 ApplicationModule 申明其内部 Controllers 与 Providers 后,就可以在 Controllers 中注入 Providers 了: @Controller()export class UsersController {\tconstructor(private usersService: UsersService) {} @Get('users') getAllUsers() { return this.usersService.getAllUsers() }} 2.4 装饰器参数与大部分框架从 this.req 或 this.context 等取请求参数不同,Nestjs 通过装饰器获取请求参数: @Get('/:id')public async getUser(\t@Response() res,\t@Param('id') id,) { const user = await this.usersService.getUser(id); res.status(HttpStatus.OK).json(user);} @Response 获取 res,@Param 获取路由参数,@Query 获取 url query 参数,@Body 获取 Http body 参数。 3 精读由于临近双十一,项目工期很紧张,本期精读由我独自完成 :p。 3.1 Typeorm有了如此强大的后端框架,必须搭配上同等强大的 orm 才能发挥最大功力,Typeorm 就是最好的选择之一。它也完全使用 Typescript 编写,使用方式具有同样的艺术气息。 3.1.1 定义实体每个实体对应数据库的一张表,Typeorm 在每次启动都会同步表结构到数据库,我们完全不用使用数据库查看表结构,所有结构信息都定义在代码中: @Entity()export class Card { @PrimaryGeneratedColumn({ comment: '主键', }) id: number; @Column({ comment: '名称', length: 30, unique: true, }) name: string = 'nick';} 通过 @Entity 将类定义为实体,每个成员变量对应表中的每一列,如上定义了 id name 两个列,同时列 id 通过 @PrimaryGeneratedColumn 定义为了主键列,列 name 通过参数定义了其最大长度、唯一的信息。 至于类型,Typeorm 通过反射,拿到了类型定义,自动识别 id 为数字类型、name 为字符串类型,当然也可以手动设置 type 参数。 对于初始值,使用 js 语法就好,比如将 name 初始值设置为 nick,在 new Card() 时已经带上了初始值。 3.1.2 自动校验光判断参数类型是不够的,我们可以使用 class-validator 做任何形式的校验: @Column({\tcomment: '配置 JSON',\tlength: 5000,})@Validator.IsString({ message: '必须为字符串' })@Validator.Length(0, 5000, { message: '长度在 0~5000' })content: string; 这里遇到一个问题:新增实体时,需要校验所有字段,但更新实体时,由于性能需要,我们一般不会一次查询所有字段,就需要指定更新时,不校验没有赋值的字段,我们通过 Typeorm 的 EventSubscriber 完成数据库操作前的代码校验,并控制新增时全字段校验,更新时只校验赋值的字段,删除时不做校验: @EventSubscriber()export class EverythingSubscriber implements EntitySubscriberInterface<any> { // 插入前校验 async beforeInsert(event: InsertEvent<any>) { const validateErrors = await validate(event.entity); if (validateErrors.length > 0) { throw new HttpException(getErrorMessage(validateErrors), 404); } } // 更新前校验 async beforeUpdate(event: UpdateEvent<any>) { const validateErrors = await validate(event.entity, { // 更新操作不会验证没有涉及的字段 skipMissingProperties: true, }); if (validateErrors.length > 0) { throw new HttpException(getErrorMessage(validateErrors), 404); } }} HttpException 会在校验失败后,终止执行,并立即返回错误给客户端,这一步体现了 Nestjs 与 Typeorm 完美结合。这带来的好处就是,我们放心执行任何 CRUD 语句,完全不需要做错误处理,当校验失败或者数据库操作失败时,会自动终止执行后续代码,并返回给客户端友好的提示: @Post()async add( @Res() res: Response, @Body('name') name: string, @Body('description') description: string,) { const card = await this.cardService.add(name, description); // 如果传入参数实体校验失败,会立刻返回失败,并提示 `@Validator.IsString({ message: '必须为字符串' })` 注册时的提示信息 // 如果插入失败,也会立刻返回失败 // 所以只需要处理正确情况 res.status(HttpStatus.OK).json(card);} 3.1.3 外键外键也是 Typeorm 的特色之一,通过装饰器语义化解释实体之间的关系,常用的有 @OneToOne @OneToMany @ManyToOne @ManyToMany 四种,比如用户表到评论表,是一对多的关系,可以这样设置实体: @Entity()export class User { @PrimaryGeneratedColumn({ comment: '主键', }) id: number; @OneToMany(type => Comment, comment => comment.user) comments?: Comment[];} @Entity()export class Comment { @PrimaryGeneratedColumn({ comment: '主键', }) id: number; @ManyToOne(type => User, user => user.Comments) @JoinColumn() user: User;} 对 User 来说,一个 User 对应多个 Comment,就使用 OneToMany 装饰器装饰 Comments 字段;对 Comment 来说,多个 Comment 对应一个 User,所以使用 ManyToOne 装饰 User 字段。 在使用 Typeorm 查询 User 时,会自动外键查询到其关联的评论,保存在 user.comments 中。查询 Comment 时,会自动查询到其关联的 User,保存在 comment.user 中。 3.2 部署可以使用 Docker 部署 Mysql + Nodejs,通过 docker-compose 将数据库与服务都跑在 docker 中,内部通信。 有一个问题,就是 nodejs 服务运行时,要等待数据库服务启动完毕,也就是有一个启动等待的需求。可以通过 environment 来拓展等待功能,以下是 docker-compose.yml: version: "2"services: app: build: ./ restart: always ports: - "5000:8000" links: - db - redis depends_on: - db - redis environment: WAIT_HOSTS: db:3306 redis:6379 通过 WAIT_HOSTS 指定要等待哪些服务的端口服务 ready。在 nodejs Dockerfile 启动的 CMD 加上一个 wait-for.sh 脚本,它会读取 WAIT_HOSTS 环境变量,等待端口 ready 后,再执行后面的启动脚本。 CMD ./scripts/docker/wait-for.sh && npm run deploy 以下是 wait.sh 脚本内容: ##!/bin/bashset -etimeout=${WAIT_HOSTS_TIMEOUT:-30}waitAfterHosts=${WAIT_AFTER_HOSTS:-0}waitBeforeHosts=${WAIT_BEFORE_HOSTS:-0}echo "Waiting for ${waitBeforeHosts} seconds."sleep $waitBeforeHosts## our target format is a comma separated list where each item is "host:ip"if [ -n "$WAIT_HOSTS" ]; then uris=$(echo $WAIT_HOSTS | sed -e 's/,/ /g' -e 's/\\s+/ /g' | uniq)fi## wait for each targetif [ -z "$uris" ]; then echo "No wait targets found." >&2; else for uri in $uris do host=$(echo $uri | cut -d: -f1) port=$(echo $uri | cut -d: -f2) [ -n "${host}" ] [ -n "${port}" ] echo "Waiting for ${uri}." seconds=0 while [ "$seconds" -lt "$timeout" ] && ! nc -z -w1 $host $port do echo -n . seconds=$((seconds+1)) sleep 1 done if [ "$seconds" -lt "$timeout" ]; then echo "${uri} is up!" else echo " ERROR: unable to connect to ${uri}" >&2 exit 1 fi doneecho "All hosts are up"fiecho "Waiting for ${waitAfterHosts} seconds."sleep $waitAfterHostsexit 0 4 总结Nestjs 中间件实现也很精妙,与 Modules 完美结合起来,由于篇幅限制就不展开了。 后端框架已经很成熟了,相反前端发展的就眼花缭乱了,如果前端可以舍弃 ie11 浏览器,我推荐纯 proxy 实现的 dob,配合 react 效率非常高。 讨论地址是:精读 《Nestjs 文档》 · Issue ##30 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Nuxtjs》","path":"/wiki/WebWeekly/前沿技术/《Nuxtjs》.html","content":"当前期刊数: 126 1 引言Nuxt 是基于 Vue 的前端开发框架,这次我们通过 Introduction toNuxtJS 视频了解框架特色以及前端开发框架的基本要素。 nuxt 与 next 结构很像,可以结合在一起看 视频介绍了 NuxtJs 的安装、目录结构、页面路由、导航模版、asyncData、meta、vueX。 这是一个入门级视频,所以上面所列举的特征都是一个前端开发框架的最核心的基本要素。一个前端开发框架,安装、目录结构、页面路由、导航模版一定是最要下功夫认真设计的。 asyncData 和 Vuex 都在解决数据问题,meta 则是通过约定语法控制网页 meta 属性,这部分值得与 React 体系做对比,在精读部分再展开。 Nuxtjs 前端开发框架不仅提供了脚手架的基本功能,还对项目结构、代码做了约定,以减少代码量。从这点可以看出,脚手架永远围绕两个核心目标:让每一行源码都在描述业务逻辑;让每个项目结构都相同且易读。 20 年前,几百行 HTML、Css、Js 代码就能完成一个完整的项目,只需要遵守 W3C 的基本规范就足够了,每一个项目代码都简单清晰,而且由于没有复杂的业务逻辑,导致代码结构也非常简单。但现在前端项目复杂度逐渐升高,一个大型项目源码数量可能达到几十万行、几百万行,这是 W3C 规范没有设想到的,因此出现了各种工程化与模块化方案解决这个复杂度问题,也引发了各个框架间约定的割裂,且设计合理程度各不相同。 Nuxtjs 等框架要做的就是定义支持现代大型项目的前端研发标准,这个规范具有网络效应,即用的人越多,价值越大。 接下来我们进入正题,看看 Nuxt 脚手架定义了怎样的开发规范。 2 概述安装使用 npx create-nuxt-app app-name 创建新项目。这个命令与 create-react-app 一样,区别主要是模版以及配置不同。 这个命令本质上是拉取一个模版到本地,并安装 nuxt 系列脚本作为项目依赖,并自动生成一系列 npmScripts: { "scripts": { "dev": "nuxt", "build": "nuxt build", "start": "nuxt start", "generate": "nuxt generate", "lint": "eslint --ext .js,.vue --ignore-path .gitignore .", "test": "jest" }, "dependencies": { "nuxt": "^2.0.0" }} 之后即可通过 npm start 等命令开发项目,对大部分项目来说,npmScripts 启动是最能达成共识的。 这种安装方式另一个好处是,依赖都被安装在了本地,即开发环境 100% 内置在项目中。Nuxt 没有采用全局 cli 命令方式执行,第一是 npmScripts 更符合大家通用习惯,不需要记住不同脚手架繁琐的名称与不同约定的启动命令,第二是全局脚手架一旦进行不兼容升级,老项目就面临维护难题。 目录结构├── .nuxt├── layouts├── pages├── store├── assets├── static├── middleware├── plugins├── nuxt.config.js pages 页面文件存放的目录,路径 + 文件名即路由名,关于更多约定路由的信息,在下一节页面路由详细说明。 layouts 模版文件存放的目录,文件名即模版名,页面可以通过定义模版在选择使用的模版。 store 全局数据流目录,在 vueX 章节介绍。 assets、static 分别存放不需被编译的资源文件与非 .vue 的静态文件,比如 scss 文件。 由于 .vue 文件集成了 html、js、css,因此一般不会再额外定义样式文件在 static 文件夹中。 当然,这是 Vue 生态的特别之处,在 React 生态中会存在大量 .scss 文件混杂在各个目录中,比较影响阅读。 middleware、plugins 中间件与插件,这两个目录是可选的,作为一种定制化拓展能力。 .nuxt 为实现约定路由等便捷功能,启动项目时需要自动生成一些文件作为真正项目入口,这些文件就存储在 .nuxt 目录下,gitingore 且无需手动修改。 nuxt.config.js nuxt 使用 js 文件作为配置文件,比 json 配置文件拓展性更好一些,这个文件也是整个项目唯一的配置文件。 基本上 pages、layouts、store、assets、以及唯一的配置文件基本成为现代前端开发框架的标配。 页面路由nuxt 支持约定路由: ├── pages│ ├── home.vue│ └── index.vue 上述目录结构描述了两个路由:/ 与 /home。 也支持参数路由,只要以下划线作为前缀命名文件,就定义了一个动态参数路由: ├── pages│ ├── videos│ │ └── _id.vue /videos/* 都会指向这个文件,且可以通过 $route.params.id 拿到这个 url 参数。 另一个特性是嵌套路由: ├── pages│ ├── videos│ │ └── index.vue│ └── videos.vue videos.vue 与 videos/index.vue 都指向 /videos 这个路由,如果这两个文件同时存在,那么外层的 videos 就会作为外层拦截所有 /videos 文件夹下的路由,可以通过 nuxt-child 透出子元素: ## pages/videos.vue<template> <div> videos <nuxt-child /> </div></template> 导航模版页面公共逻辑,比如导航条可以放在模版里,模版的目录在 layouts 文件夹下。 默认 layouts/default.vue 对所有页面生效,但也可以创建例如 layouts/videos.vue 特殊导航文件,在 pages/ 页面文件通过如下申明指定使用这个模版: <script> export default { layout: "videos" };</script> asyncDataasyncData 是 nuxt 支持的异步取数函数,可以替代 data。 data 函数: <script> export default { data() { return {}; } };</script> 对于异步场景,可以用 asyncData 替代: <script> export default { async asyncData() { return await fetch("/"); } };</script> metanuxt 允许在 .vue 页面文件自定义 head 标签信息: <script> export default { headr() { return { title: "", meta: { charset: "utf-8" } }; } };</script> 这是开发框架提供的特性,不过在 React 体系下可以通过 useTitle 等自定义 Hooks 解决此问题,将框架功能降维到代码功能,会更容易理解些。 vueXnuxt 集成了 vuex,在 store/ 文件夹下创建数据模型: export const state = () => ({ videos: [], currentVideo: {}})export const mutations = { SET_VIDEOS (state, videos) { state.videos = videos } SET_CURRENT_VIDEO (state, video) { state.currentVideo = video }} 接下来就能在 pages 文件夹下的页面组件使用了: <script> import { mapState } from "vuex"; export default { async fetch({ $axios, params, store }) { const reponse = await $axios.get(`/videos/${params.id}`); const video = response.data.data.arrtibutes; store.commit("SET_CURRENT_VIDEO", video); } };</script> 将 return 替换为 store.commit 即可,更多语法可以参考 vuex 文档。 3 精读Nuxtjs 框架做了几件事情: 统一执行命令。 统一开发框架。 统一目录与代码规范。 内置公共 utils 函数。 统一执行命令命令行是所有开发者每天都要用上十几次甚至几十次的场景,试想一下团队中项目分别有如下这么多不同的启动命令会怎么样? npm start. monkey dev. npm run ng. npm run bootstrap & banana start. … 我永远不知道下一个项目该如何启动,这大大降低了开发效率。更严重的是,有的项目可以通过 npm run docs 查看文档,有的项目不能;有的项目 npm run build 可以触发编译,有的项目却无需编译,等等,所谓的环境不一致或者说迁移成本,学习成本,都是由最开始负责搭建项目脚手架的同学对架构设计不一致导致的,然而没有必须用 monkey dev 才能运行起来的项目,但项目却可能因为被设计为 monkey dev 启动而显得与其他项目格格不入,甚至难以统一维护。 Nuxtjs 等前端开发框架统一执行命令就是为了解决这个问题,统一开发者习惯需要很长的时间周期,但这个趋势不可挡。 统一开发框架虽然现在 React、Vue、Angular 框架各有利弊,但如果一个团队的项目同时使用了两个以上的框架,没有人会觉得这是一件好事。 诚然每个框架都有自己的特点,在不同维度都一些优势,但三大框架能并存,说明各自都没有绝对的杀手锏来消灭对方。 对开源来说,多元化是活力的源动力,但对一家公司来说,多元化就是一场灾难,至今没有一个框架敢说自己的优势是 “与其他框架混合使用可以提升整体开发效率”。 前端开发框架要解决的最重要问题也是这一点,无论如何只能选择一种开发框架,Nuxtjs 选择了 Vue,Nextjs 选择了 React。 统一目录与代码规范目录和代码规范不会从根本上影响项目的通用性,因为不同的目录结构可以通过映射来兼容,不同的代码规范不会影响代码执行。所以目录与代码规范真正影响的是一个程序员对项目的 “解码成本”。 所谓解码成本,就是程序员理解项目逻辑所需要的成本。如果你是一个销售主管,让团队周报统一用一种格式汇总绝对比 “用自己喜欢的方式汇总” 效率高,而对编程也一样,一个完全不同的目录结构和代码规范对程序员来说是巨大的阅读阻碍,甚至可能引发恶心反应。 所以不同的目录结构和代码规范是没有必要的壁垒,除非你的团队已经对某种规范产生达成了牢固的共识,否则最好和其他团队共享相同的目录结构与代码规范。改变代码规范是一件很难得事情,但只要不同规范的团队间产生了长期合作关系,规范统一就势必会被提上议程,那么为何不能在公司层面早一点达成共识,提前消除这种痛苦呢? 所以统一目录与代码规范是前端开发框架需要优先确定的,很多时候不要去质疑为什么目录叫 layouts 而不叫 layout,因为这个规范背后形成的协同网络规模越大,叫什么名字就越不重要。 内置公共 utils 函数让业务开发更聚焦,还可以通过抽取通用的逻辑的方式解决,但需要解决两个问题: 虽然将公共函数抽成 npm 包可以解决代码复用问题,但关键是怎么保证你的代码能被别人复用? 如何让业务通用的 utils 代码有效沉淀并从项目中移除? 脚手架内置公共 utils 函数就为了解决这个问题。上面几个小节解决了通用命令、框架、规范,但实际代码中,router history fetch store 等等概念也都是可以统一的,没有一个项目必须用定制的 fetch 函数才能取数,但一开始就定制了 fetch 会导致耦合了不可预期的、没有必要的业务逻辑,成为理解与提效的阻碍。 所以统一这些能统一的包,是进一步提效的关键。也许有人会觉得断了自己造轮子的路,但就像我们如今都不会重写浏览器内核逻辑一样,稳定的逻辑不仅带来了全行业的提效,还催生了前端岗位带来大量的就业,同样的,统一底层通用函数,其实是断了无意义产出这条路,每个人都有追求更高价值事情的权利,不要把自己困在反复造 fetch 函数这个低水平的活里。 4 总结如果一个项目没有使用类似 Nuxtjs 开发框架,它面临的不仅仅是技术选型不统一的问题,久而久之这种项目势必成为 代码孤岛,当尘封在代码仓库几年后,一系列文档工具链接都失效后,就成为谁也不想碰,不敢碰的高危代码。 所以我们今天不仅要看到 Nuxtjs 提供的能力对项目开发有多么便捷,更要看到这类框架带来的协同效应有多么巨大,如果它不能成为整个前端的标准,至少要成为你们公司,或者你们团队的标准。 讨论地址是:精读《Nuxtjs》 · Issue ##213 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Nodejs V12》","path":"/wiki/WebWeekly/前沿技术/《Nodejs V12》.html","content":"当前期刊数: 113 1. 引言Node12 发布有几个月了,让我们跟随 Nodejs 12 一起看看 Node12 带来了哪些改变。 2. 概述Node12 与以往的版本不同,带来了许多重大升级,包括更多 V8 特性,Http 解析速度的提升,启动速度的提升,更好的诊断报告、内置堆分析工具,ESM 模块的更新等。 V8 引擎升级V8 升级带来了如下几个特性: zero-cost async 堆栈信息 原生支持了 async 堆栈信息,不会添加额外运行时内容。 参数数量不匹配时性能优化 即便参数传递多了或少了,现在都几乎不会影响 Node 的执行速度。 更快的 async async /await 已经比 promises 快了两个 microticks。 更快的 Js 解析速度 网页中的 V8 引擎一般花费 9.5% 时间在 JS 解析上,经过解析加速后,现在花费在 JS 解析上的时间降低到平均 7.5%。 可见 V8 引擎的升级不仅给 Node12 带来了福音,也会一定程度上提升网页的运行效率。 TLS 1.3 更好的安全性随着 Node12 的发布,TLS 从 1.2 升级到了 1.3,更安全且更易配置。通过使用 TLS 1.3,Node 程序可以减少 Https 握手所需时间来提升请求性能。 默认堆被正确配置了以前默认堆大小需要通过 -max-old-space-size 设置,而且默认值是一个固定值,现在这个默认值可以根据可用内存动态分配,这样当内存较小时,Node 不会让内存移除而报错,而是主动终止自己的进程。 默认的 http 解析器变为 llhttpnodejs 的 http-parser 已经非常难以维护和优化了,因此 llhttp 这个库,比 http-parser 快 156%,更重要的是,在 Node12 中,将默认解析器切换到了 llhttp。 提供诊断报告Node12 有一项实验功能,根据用户需求提供诊断报告,包括崩溃、性能下降、内存泄露、CPU 使用高等等。 堆内存 dump在以前,如果要将堆内存生成 dump 文件,需要在生产环境安装额外的模块,而 Node12 集成了这个功能。 更好的原生模块支持C++ 拓展 N-API 升级到版本 4,同时一个原生模块可以被 C++ 编写并发布到 npm,就像一个普通 JS 模块一样被引用。不过要注意一些区别: JS 模块 原生拓展 1. … 需要编译 否 如果预编译了则不用 2. … 是否可以运行在所有平台 是 如果预编译了则可以 3. … 是否兼容所有 Node 版本 是 否 4. … 会被加载多次 是 否 5. … 如果没有明确使用多线程,则线程安全 是 否 6. … 可以被销毁 是 否 Worker 被正式启用了--experimental-worker 实验开关已取消,默认支持 worker_threads。 要注意的是,执行 CPU 密集型任务时适合用 worker(大量计算),而执行 I/O 密集型任务时,Worker 反而没有 Node 内置的 I/O 操作性能好(读写文件)。 启动速度优化通过在构建时提前为内置库生成代码缓存,最终使启动时间加快 30%。 支持 ES6 moduleNode12 对 ES6 module 的支持依然处于实验阶段,需要通过 --experimental-modules 开启。 简单来说,就是支持了 Import Export 语法,不需要再转成 require 了!如果在 package.json 增加 "type": "module" 的配置,Node 将按照 ES6 module 方式处理。 新的编译器和平台要求由于升级到新的 V8 引擎以及内部改造,因此 Node12 在 Mac 与 Windows 之外的平台上,需要至少 GCC6 和 glibc 2.17。 3. 精读对于 V8 引擎升级、TLS 升级、堆配置自动化、http-parser 升级到 llhttp、启动速度优化都属于被动优化,代码无需改动,只要升级 Node 版本就可以享受。 支持 ES6 module 这个特性其实比较鸡肋,毕竟源码用 Ts 写的话,这些升级并不会对源码产生影响。 worker_threads 可以被默认启用,就像以前支持 async/await 一样,会带来 Nodejs 多线程更广泛的使用。 Node12 更新了 V8 引擎,随着 V8 的更新,很多 ES 新规范也落地了,比如 Class 成员函数、私有成员变量等等。 4. 总结Nodejs 仅有 10 年历史,但现在越来越被开发者欢迎,因为它可以让 JS 运行在服务端,是扩大 JS 生态的重要一环。从 Node 更新历史中可以看到,性能和语法能力稳步提升,一些服务端环境需要的诊断报告、堆栈分析能力都在逐渐完善,社区上也有 Alinode 与 egg、express、koa 等好用的服务框架,相对于前端翻天覆地的变化,对 Node 的评价只有一个字:稳。 讨论地址是:精读《Nodejs V12》 · Issue ##184 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Optional chaining》","path":"/wiki/WebWeekly/前沿技术/《Optional chaining》.html","content":"当前期刊数: 107 1. 引言备受开发者喜爱的特性 Optional chaining 在 2019.6.5 进入了 stage2,让我们详细读一下草案,了解一下这个特性的用法以及讨论要点。 借着这次精读草案,让我们了解一下一个完整草案的标准文档结构是怎样的。 一个新特性的文档,首先要描述 起因 是什么,也就是为什么要增加这个特性,大家不会没有理由的就增加一个特性。其次是其他语言是否有现成的实现版本,参考他们并进行归纳总结,可以增加思考角度的全面性。 第三点就是 语法介绍,也就进入了新特性的正题,这里要详细介绍所有可能的使用情况。第四点是 语义,也就是诠释语法的含义。 然后是可选的 是否有不支持的情况,对于不支持的点是否有意而为之,为什么?此处一般会留下讨论的 ISSUE。然后是 暂不考虑的点,是由于性价比低、使用场景少,或者实现成本高的原因,为什么某些已经想到的点暂不考虑,这里也会留下讨论的 ISSUE。 后面一般还有 “正在讨论的点”、“FAQ”、“草案进度”、“参考文献”、“相关问题”、“预先讨论资料” 等内容。 2. 概述&精读首先让我们回顾一下什么是 “Optional chaining”。 起因介绍当访问一个深层树形结构的对象时,我们总需要判断中间节点属性是否存在: var street = user.address && user.address.street; 而且很多 API 返回的属性都可能为 Null,而我们往往只想获取非 Null 时的结果: var fooInput = myForm.querySelector('input[name=foo]')var fooValue = fooInput ? fooInput.value : undefined 笔者这里补充,在人机交互的领域,可能为 Null 的情况很多。首先是交互行为模块很多,行为复杂,很容易导致数据分散且难以预测(可能为空),仅是 DOM 元素就需要太多兼容,因为 DOM 被修改的实际太多了,大家都在共享一个可变的结构;其次是交互过程中间状态很多,出现状态残缺的可能性也很大,就拿 SQL 解析为例:后端只要检测 Query 是否正确就可以了,但前端的 SQL 编辑器需要在输入不完整的情况下给出提示,也就是在语法树错误的情况下给出提示,因此需要进行容错。 而 Optional chaining 可以解决为了容错而写过多重复代码的问题: var street = user.address?.streetvar fooValue = myForm.querySelector('input[name=foo]')?.value 正如上面的例子:如果 user.address 为 undefined,那 street 拿到的就是 undefined,而不是报错。 配合另一个在 stage2 的新特性 Nullish Coalescing 做默认值处理非常方便: // falls back to a default value when response.setting is missing or nullish// (response.settings == null) or when respsonse.setting.animationDuration is missing// or nullish (response.settings.animationDuration == null)const animationDuration = response.settings?.animationDuration ?? 300; ?? 号可以理解为 “默认值场景下的 ||”: const response = { settings: { nullValue: null, height: 400, animationDuration: 0, headerText: '', showSplashScreen: false }};const undefinedValue = response.settings?.undefinedValue ?? 'some other default'; // result: 'some other default'const nullValue = response.settings?.nullValue ?? 'some other default'; // result: 'some other default'const headerText = response.settings?.headerText ?? 'Hello, world!'; // result: ''const animationDuration = response.settings?.animationDuration ?? 300; // result: 0const showSplashScreen = response.settings?.showSplashScreen ?? true; // result: false 0 || 1 的结果是 1,因为 0 判定为 false,而 || 在前面的变量为 false 型才继续执行,而我们想要的是 “前面的对象不存在时才使用后面的值”。?? 则代表了 “前面的对象不存在” 这个含义,即便值为 0 也会认为这个值是存在的。 Optional chaining 也可以用在方法上: iterator.return?.() 或者试图调用某些未被实现的方法: if (myForm.checkValidity?.() === false) { // skip the test in older web browsers // form validation fails return;} 比如某个旧版本浏览器不支持 myForm.checkValidity 方法,则不会报错,而是返回 false。 已有实现调研Optional chaining 在 C##、Swift、CoffeeScript、Kotlin、Dart、Ruby、Groovy 已经实现了,且实现方式均有差异,可以看到每个语言在实现语法时都是有取舍的,但是大方向基本是相同的。 想了解其他语言是如何实现 Optional chaining 的读者可以 点击阅读原文。 这些语言实现 Optional chaining 的差异基本在 语法、支持范围、边界情况处理 等不同,所以如果你每天要在不同语言之间切换工作,看似相同的语法,但不同的细节可能把你绕晕(所以会的语言多,只会让你变成一个速记字典,满脑子都是哪些语言在哪些语法讨论倾向哪一边,选择了哪些特性这些毫无意义的结论,如果不想记这些,基础语法都没有掌握怎么好意思说会这门语言呢?所以学 JS 就够了)。 语法Optional Chaining 的语法有三种使用场景: obj?.prop // optional static property accessobj?.[expr] // optional dynamic property accessfunc?.(...args) // optional function or method call 也就是将 . 替换为 ?.,但要注意第二行与第三行稍稍有点反直觉,比如在函数调用时,需要将 func(...args) 写为 func?.(...args)。至于为什么语法不是 func?(...args) 这种简洁一点的表达方式,在 FAQ 中有提到这个例子: obj?[expr].filter(fun):0 引擎难以判断 obj?[expr] 是 Optional Chaning,亦或这是一个普通的三元运算语句。 可见,要支持 ?. 这个看似简单的语法,在整个 JS 语法体系中要考虑的边界情况很多。 即便是 ?. 这样完整的用法,也需要注意 foo?.3:0 这种情况,不能将 foo?. 解析为 Optional chanining,而要将其解析为 foo? .3 : 0,这需要解析引擎支持 lookahead 特性。 语义**当 ?. 前面的变量值为 null 或 undefined 时,?. 返回的结果为 undefined**。 a?.b // undefined if `a` is null/undefined, `a.b` otherwise.a == null ? undefined : a.ba?.[x] // undefined if `a` is null/undefined, `a[x]` otherwise.a == null ? undefined : a[x]a?.b() // undefined if `a` is null/undefineda == null ? undefined : a.b() // throws a TypeError if `a.b` is not a function // otherwise, evaluates to `a.b()`a?.() // undefined if `a` is null/undefineda == null ? undefined : a() // throws a TypeError if `a` is neither null/undefined, nor a function // invokes the function `a` otherwise 短路所谓短路,就是指引入了 Optional chaining 后,某些看似一定会执行的语句在特定情况下会短路(终止执行),比如: a?.[++x] // `x` is incremented if and only if `a` is not null/undefineda == null ? undefined : a[++x] 第一个例子,如果 a 时 null/undefined,就不会执行 ++x。 原因是这段代码部分等价于 a == null ? undefined : a[++x],如果 a == null 为真,自然不会执行 a[++x] 这个语句。但由于 Optional chaining 使这个语句变得 “简洁了”,虽然带来了便利,但也可能导致看不清完整的执行逻辑,引发误判。 所以看到 ?. 语句时,一定要反射性的思考一下,这个语句会触发 “短路”。 长“短路”Optional chaining 在 JS 的规范中,作用域仅限于调用处。看下面的例子: a?.b.c(++x).d // if `a` is null/undefined, evaluates to undefined. Variable `x` is not incremented. // otherwise, evaluates to `a.b.c(++x).d`.a == null ? undefined : a.b.c(++x).d 可以看到 ?. 仅在 a?. 这一层生效,而不是对后续的 b.c、c(++x).d 继续生效。而对于 C+ 与 CoffeeScript,这个语法是对后续所有 get 生效的(这里再次提醒,不要用 CoffeeScript 了,因为对于相同语法,语义都发生了变化,对你与你的同事都是巨大的理解负担,或者说没有人愿意注意,为什么代码在 CoffeeScript 里不报错,而转移到 JS 就报错了,是因为 Optional chaining 语义不一致造成的。)。 正因为 Optional chaining 在 JS 语法中仅对当前位置起保护作用,因此一个调用语句中允许出现多个 ?. 调用: a?.b[3].c?.(x).da == null ? undefined : a.b[3].c == null ? undefined : a.b[3].c(x).d // (as always, except that `a` and `a.b[3].c` are evaluated only once) 上面这段代码,对 a?.b、c?.(x) 的访问与调用是安全的,而对于 b[3]、 b[3].c、c?.(x).d 的调用是不安全的。 在 FAQ 环节也提到了,为什么不学习 C## 与 CoffeeScript 的语义,将安全保护从 a?. 之后就一路 “贯穿” 下去? 原因是 JS 对 Optional chaining 的理解不同导致的。Optional chaining 仅仅是安全访问保护,不代表 try catch,也就是它不会捕获异常,举一个例子: a?.b() 这个调用,在 a.b 不是一个函数时依然会报错,原因就是 Optional chaining 仅提供了对属性访问的安全保护,不代表对整个执行过程进行安全保护,该抛出异常还是会抛出异常,因此 Optional chaining 没有必要对后面的属性访问安全性负责。 笔者认为 TC39 对这个属性的理解是合理的,否则用 try catch 就能代替 Optional chaining 了。让一个特性仅实现分内的功能,是每个前端从业者都要具备的思维能力。 PS:笔者再多提一句,在任何技术设计领域,这个概念都适用。想想你设计的功能,写过的函数,如果为了图方便,扩大了其功能,终究会带来整体设计的混乱,适得其反。 边界情况 - 分组我们知道,JS 代码可以通过括号的方式进行分组,分组内的代码拥有更高的执行优先级。那么在 Optional chaining 场景下考虑这个情况: (a?.b).c(a == null ? undefined : a.b).c 与不带括号的进行对比: a?.b.ca == null ? undefined : a.b.c 我们会发现,由于括号提高了优先级,导致在 a 为 null/undefined 时,解析出了 undefined.c 这个必定报错的荒谬语法。因此我们不要试图为 Optional chaining 进行括号分组,这样会打破逻辑顺序,使安全保护不但不生效,反而导致报错。 Optional delete中文大概可以翻译为 “安全删除” 吧,也就是 JS 的 Optional chaining 支持下面的使用方式: delete a?.ba == null ? true : delete a.b 这样不论 b 是否存在,得到的都是 b 删除成功的信号(返回值 true)。 至于为什么要支持 Optional delete,草案里也有提到,笔者认为非常有意思: 讨论重点应该是 “我们为什么不支持 Optional delete”,而不是 “我们为什么要支持 Optional delete”,有点像反证法的思路。由于 Optional delete 具备一定的使用场景,而且支持方式零成本(改写为 a == null ? true : delete a.b 即可),所以就支持它吧! 不支持的特性下面三个特性不支持,原因是没什么使用场景: 安全的 construction:new a?.() 安全的 template literal:a?.`string` 上面两者的结合:new a?.b(), a?.b`string` 首先看 new 一个对象,如果 new 出来的结果是 undefined,那这个返回值使用起来也没有意义。 对于第二个安全的 template literal 来说,比如下面的语法: a?.b`c` 会被解析为 a == null ? undefined : a.b`c` 那么对于下面这种翻译结果: a == null ? undefined : a.b `c` 目前不会有人这么写代码,因为这种语法的使用场景一般都是 “前面的属性必定存在时的简化语法”,比如 styled-components 的: div` width: 300px;` 而如果解析为: (a == null ? undefined : a?.b) `c` 则更不会有人愿意尝试这种写法,所以安全的 template literal 这种需求是不存在的,自然第三种需求也是不存在的。 下面一个不支持的特性,虽然有一定使用场景,但依然被否定的: 安全的赋值:a?.b = c 讨论 ISSUE 笔者总结一下,一共有这几种令人烦恼的地方,导致大家不想支持 安全赋值 特性: 短路特性导致的理解成本: 比如 a?.b = c(),如果 a 为 null/undefined,那么函数 c() 就不会被执行,这种语法太违背开发者的常识,如果支持这个特性带来的理解负担会很大。 连带考虑场景很多: 如果支持了这种看似简单的赋值场景,那么至少还有下面五种赋值场景需要考虑到: 简单赋值: a?.b = c 聚合赋值: a?.b += c, a?.b >>= c 自增,自减: a?.b++, --a?.b 解构赋值: { x: a?.b } = c, [ a?.b ] = c for 循环中的临时赋值: for (a?.b in c), for (a?.b of c) 总和这几种考虑,支持安全赋值会带来更多灵活的用法,导致代码复杂度陡增(想想你的同事大量使用上面的后四种例子,你绝对想要找他决斗,因为这种写法和乱用 window 变量一样,在 JS 允许的框架内写出难以维护的逻辑,像是钻了法律的孔子),因此 TC39 决定不支持这种用法,从源头上杜绝被滥用。 以上不支持的功能点会在静态编译时被禁止,但以后也许会重新讨论。 另外对于 Class 的私有变量是否支持 a?.##b a?.##b() 还在讨论中,这取决于私有成员变量草案是否能最终落地。 暂不讨论的点目前有两个 Optional chaining 功能点暂不讨论,分别是 Optional spread 与 Optional destructuring 对于 Optional spread,建议是: const arr = [...?listOne, ...?listTwo];foo(...?args); 但由于可以结合 Nullish Coalescing 达到同样的效果: foo(...args ?? []) 所以暂时不深入讨论,因为存在意义不大。 对于 Optional destructuring,建议是: // const baz = obj?.foo?.bar?.baz; const { baz } = obj?.foo?.bar?; 也就是对于解构用法,在最后一个位置添加 ?,使其能安全的解构。 但由于基于这个特性会演变出太多的使用变体: ‪const {foo ?: {bar ?: {baz}}} = obj? 或者 const { foo?: { bar?: { baz } }} = obj; 对开发者的理解成本压力较大,毕竟 Optional chaining 的出发点只是 ?. 这么简单。而且对于默认值,我们又有 ?? 语法可以快速满足,因此这个特性的讨论也被搁置了。 余下的 Q&A大部分 Q&A 在上面的解读都有提及,下面列出剩余的两个 Q&A: 为什么语法是 ?. 而不是 .? ?原因是与三元运算符冲突了,思考下面的用法: 1.?foo : bar 在 js 中,1. 等价于 1,那么这就是一个标准的三元运算表达式,因此 .? 语法会产生歧义,只能选择 ?.。 为什么 null?.b 的结果不是 null 呢?由于 . 表达式不关心 . 前面对象的类型,因为它的目的是访问 . 后面的属性,因此不会因为 null?.b 就返回 null,而是统一返回 undefined。 最后,需要 TC39 最终审核后,Optional chaining 才能进入 Stage3,我们拭目以待吧! 3. 总结写一篇 JS 特性草案的完整解读真的很累,以后也许很少有机会这么完整的解读草案了,但希望借着这次解读 Optional chaining 的机会,让大家理解 TC39 是如何制定草案的,草案都在讨论什么,怎么讨论的,流程有哪些。 同时,还希望让大家意识到,为一个语言添加一个看似简单的新特性有多么的不容易,一个简单的 ?. 语法就牵涉到与三元运算符、分组、解构等等已存在语法的交织与冲突,所以想要安全又妥当的添加一个新特性,参与讨论的人必须对 JS 语言有完整全面的理解,同时也要对边界情况考虑的很周全,懂得对语法融会贯通。 最后,希望大家可以意识到,JS 这么重量级的语言,一个新的语法特性其实也是这么三言两语讨论下来的,其中不乏有一些拍脑袋的地方、对于“即可也可”的情况,稍稍结合一些具体案例就定下来其中一种的现象也是存在的,甚至对于某些规范点根本不存在一个完美的 “真理”,比如为什么语法是 ?. 而不是 a&.b(Ruby 使用的就是 &.),认清了这种情况存在,就不会执着于 “语法的学习”,而转向更底层,更有用的 “语义的学习”,并能通过阅读 TC39 的草案了解其他语言的实现差异,从而快速掌握其他语言的语法。 讨论地址是:精读《Optional chaining》 · Issue ##165 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Prisma 的使用》","path":"/wiki/WebWeekly/前沿技术/《Prisma 的使用》.html","content":"当前期刊数: 213 ORM(Object relational mappers) 的含义是,将数据模型与 Object 建立强力的映射关系,这样我们对数据的增删改查可以转换为操作 Object(对象)。 Prisma 是一个现代 Nodejs ORM 库,根据 Prisma 官方文档 可以了解这个库是如何设计与使用的。 概述Prisma 提供了大量工具,包括 Prisma Schema、Prisma Client、Prisma Migrate、Prisma CLI、Prisma Studio 等,其中最核心的两个是 Prisma Schema 与 Prisma Client,分别是描述应用数据模型与 Node 操作 API。 与一般 ORM 完全由 Class 描述数据模型不同,Primsa 采用了一个全新语法 Primsa Schema 描述数据模型,再执行 prisma generate 产生一个配置文件存储在 node_modules/.prisma/client 中,Node 代码里就可以使用 Prisma Client 对数据增删改查了。 Prisma SchemaPrimsa Schema 是在最大程度贴近数据库结构描述的基础上,对关联关系进行了进一步抽象,并且背后维护了与数据模型的对应关系,下图很好的说明了这一点: 可以看到,几乎与数据库的定义一模一样,唯一多出来的 posts 与 author 其实是弥补了数据库表关联外键中不直观的部分,将这些外键转化为实体对象,让操作时感受不到外键或者多表的存在,在具体操作时再转化为 join 操作。下面是对应的 Prisma Schema: datasource db { provider = "postgresql" url = env("DATABASE_URL")}generator client { provider = "prisma-client-js"}model Post { id Int @id @default(autoincrement()) title String content String? @map("post_content") published Boolean @default(false) author User? @relation(fields: [authorId], references: [id]) authorId Int?}model User { id Int @id @default(autoincrement()) email String @unique name String? posts Post[]} datasource db 申明了链接数据库信息;generator client 申明了使用 Prisma Client 进行客户端操作,也就是说 Prisma Client 其实是可以替换实现的;model 是最核心的模型定义。 在模型定义中,可以通过 @map 修改字段名映射、@@map 修改表名映射,默认情况下,字段名与 key 名相同: model Comment { title @map("comment_title") @@map("comments")} 字段由下面四种描述组成: 字段名。 字段类型。 可选的类型修饰。 可选的属性描述。 model Tag { name String? @id} 在这个描述里,包含字段名 name、字段类型 String、类型修饰 ?、属性描述 @id。 字段类型字段类型可以是 model,比如关联类型字段场景: model Post { id Int @id @default(autoincrement()) // Other fields comments Comment[] // A post can have many comments}model Comment { id Int // Other fields Post Post? @relation(fields: [postId], references: [id]) // A comment can have one post postId Int?} 关联场景有 1v1, nv1, 1vn, nvn 四种情况,字段类型可以为定义的 model 名称,并使用属性描述 @relation 定义关联关系,比如上面的例子,描述了 Commenct 与 Post 存在 nv1 关系,并且 Comment.postId 与 Post.id 关联。 字段类型还可以是底层数据类型,通过 @db. 描述,比如: model Post { id @db.TinyInt(1)} 对于 Prisma 不支持的类型,还可以使用 Unsupported 修饰: model Post { someField Unsupported("polygon")?} 这种类型的字段无法通过 ORM API 查询,但可以通过 queryRaw 方式查询。queryRaw 是一种 ORM 对原始 SQL 模式的支持,在 Prisma Client 会提到。 类型修饰类型修饰有 ? [] 两种语法,比如: model User { name String? posts Post[]} 分别表示可选与数组。 属性描述属性描述有如下几种语法: model User { id Int @id @default(autoincrement()) isAdmin Boolean @default(false) email String @unique @@unique([firstName, lastName])} @id 对应数据库的 PRIMARY KEY。 @default 设置字段默认值,可以联合函数使用,比如 @default(autoincrement()),可用函数包括 autoincrement()、dbgenerated()、cuid()、uuid()、now(),还可以通过 dbgenerated 直接调用数据库底层的函数,比如 dbgenerated("gen_random_uuid()")。 @unique 设置字段值唯一。 @relation 设置关联,上面已经提到过了。 @map 设置映射,上面也提到过了。 @updatedAt 修饰字段用来存储上次更新时间,一般是数据库自带的能力。 @ignore 对 Prisma 标记无效的字段。 所有属性描述都可以组合使用,并且还存在需对 model 级别的描述,一般用两个 @ 描述,包括 @@id、@@unique、@@index、@@map、@@ignore。 ManyToManyPrisma 在多对多关联关系的描述上也下了功夫,支持隐式关联描述: model Post { id Int @id @default(autoincrement()) categories Category[]}model Category { id Int @id @default(autoincrement()) posts Post[]} 看上去很自然,但其实背后隐藏了不少实现。数据库多对多关系一般通过第三张表实现,第三张表会存储两张表之间外键对应关系,所以如果要显式定义其实是这样的: model Post { id Int @id @default(autoincrement()) categories CategoriesOnPosts[]}model Category { id Int @id @default(autoincrement()) posts CategoriesOnPosts[]}model CategoriesOnPosts { post Post @relation(fields: [postId], references: [id]) postId Int // relation scalar field (used in the `@relation` attribute above) category Category @relation(fields: [categoryId], references: [id]) categoryId Int // relation scalar field (used in the `@relation` attribute above) assignedAt DateTime @default(now()) assignedBy String @@id([postId, categoryId])} 背后生成如下 SQL: CREATE TABLE "Category" ( id SERIAL PRIMARY KEY);CREATE TABLE "Post" ( id SERIAL PRIMARY KEY);-- Relation table + indexes -------------------------------------------------------CREATE TABLE "CategoryToPost" ( "categoryId" integer NOT NULL, "postId" integer NOT NULL, "assignedBy" text NOT NULL "assignedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("categoryId") REFERENCES "Category"(id), FOREIGN KEY ("postId") REFERENCES "Post"(id));CREATE UNIQUE INDEX "CategoryToPost_category_post_unique" ON "CategoryToPost"("categoryId" int4_ops,"postId" int4_ops); Prisma Client描述好 Prisma Model 后,执行 prisma generate,再利用 npm install @prisma/client 安装好 Node 包后,就可以在代码里操作 ORM 了: import { PrismaClient } from '@prisma/client'const prisma = new PrismaClient() CRUD使用 create 创建一条记录: const user = await prisma.user.create({ data: { email: 'elsa@prisma.io', name: 'Elsa Prisma', },}) 使用 createMany 创建多条记录: const createMany = await prisma.user.createMany({ data: [ { name: 'Bob', email: 'bob@prisma.io' }, { name: 'Bobo', email: 'bob@prisma.io' }, // Duplicate unique key! { name: 'Yewande', email: 'yewande@prisma.io' }, { name: 'Angelique', email: 'angelique@prisma.io' }, ], skipDuplicates: true, // Skip 'Bobo'}) 使用 findUnique 查找单条记录: const user = await prisma.user.findUnique({ where: { email: 'elsa@prisma.io', },}) 对于联合索引的情况: model TimePeriod { year Int quarter Int total Decimal @@id([year, quarter])} 需要再嵌套一层由 _ 拼接的 key: const timePeriod = await prisma.timePeriod.findUnique({ where: { year_quarter: { quarter: 4, year: 2020, }, },}) 使用 findMany 查询多条记录: const users = await prisma.user.findMany() 可以使用 SQL 中各种条件语句,语法如下: const users = await prisma.user.findMany({ where: { role: 'ADMIN', }, include: { posts: true, },}) 使用 update 更新记录: const updateUser = await prisma.user.update({ where: { email: 'viola@prisma.io', }, data: { name: 'Viola the Magnificent', },}) 使用 updateMany 更新多条记录: const updateUsers = await prisma.user.updateMany({ where: { email: { contains: 'prisma.io', }, }, data: { role: 'ADMIN', },}) 使用 delete 删除记录: const deleteUser = await prisma.user.delete({ where: { email: 'bert@prisma.io', },}) 使用 deleteMany 删除多条记录: const deleteUsers = await prisma.user.deleteMany({ where: { email: { contains: 'prisma.io', }, },}) 使用 include 表示关联查询是否生效,比如: const getUser = await prisma.user.findUnique({ where: { id: 19, }, include: { posts: true, },}) 这样就会在查询 user 表时,顺带查询所有关联的 post 表。关联查询也支持嵌套: const user = await prisma.user.findMany({ include: { posts: { include: { categories: true, }, }, },}) 筛选条件支持 equals、not、in、notIn、lt、lte、gt、gte、contains、search、mode、startsWith、endsWith、AND、OR、NOT,一般用法如下: const result = await prisma.user.findMany({ where: { name: { equals: 'Eleanor', }, },}) 这个语句代替 sql 的 where name="Eleanor",即通过对象嵌套的方式表达语义。 Prisma 也可以直接写原生 SQL: const email = 'emelie@prisma.io'const result = await prisma.$queryRaw( Prisma.sql`SELECT * FROM User WHERE email = ${email}`) 中间件Prisma 支持中间件的方式在执行过程中进行拓展,看下面的例子: const prisma = new PrismaClient()// Middleware 1prisma.$use(async (params, next) => { console.log(params.args.data.title) console.log('1') const result = await next(params) console.log('6') return result})// Middleware 2prisma.$use(async (params, next) => { console.log('2') const result = await next(params) console.log('5') return result})// Middleware 3prisma.$use(async (params, next) => { console.log('3') const result = await next(params) console.log('4') return result})const create = await prisma.post.create({ data: { title: 'Welcome to Prisma Day 2020', },})const create2 = await prisma.post.create({ data: { title: 'How to Prisma!', },}) 输出如下: Welcome to Prisma Day 2020 1 2 3 4 5 6 How to Prisma! 1 2 3 4 5 6 可以看到,中间件执行顺序是洋葱模型,并且每个操作都会触发。我们可以利用中间件拓展业务逻辑或者进行操作时间的打点记录。 精读ORM 的两种设计模式ORM 有 Active Record 与 Data Mapper 两种设计模式,其中 Active Record 使对象背后完全对应 sql 查询,现在已经不怎么流行了,而 Data Mapper 模式中的对象并不知道数据库的存在,即中间多了一层映射,甚至背后不需要对应数据库,所以可以做一些很轻量的调试功能。 Prisma 采用了 Data Mapper 模式。 ORM 容易引发性能问题当数据量大,或者性能、资源敏感的情况下,我们需要对 SQL 进行优化,甚至我们需要对特定的 Mysql 的特定版本的某些内核错误,对 SQL 进行某些看似无意义的申明调优(比如在 where 之前再进行相同条件的 IN 范围限定),有的时候能取得惊人的性能提升。 而 ORM 是建立在一个较为理想化理论基础上的,即数据模型可以很好的转化为对象操作,然而对象操作由于屏蔽了细节,我们无法对 SQL 进行针对性调优。 另外,得益于对象操作的便利性,我们很容易通过 obj.obj. 的方式访问某些属性,但这背后生成的却是一系列未经优化(或者部分自动优化)的复杂 join sql,我们在写这些 sql 时会提前考虑性能因素,但通过对象调用时却因为成本低,或觉得 ORM 有 magic 优化等想法,写出很多实际上不合理的 sql。 Prisma Schema 的好处其实从语法上,Prisma Schema 与 Typeorm 基于 Class + 装饰器的拓展几乎可以等价转换,但 Prisma Schema 在实际使用中有一个很不错的优势,即减少样板代码以及稳定数据库模型。 减少样板代码比较好理解,因为 Prisma Schema 并不会出现在代码中,而稳定模型是指,只要不执行 prisma generate,数据模型就不会变化,而且 Prisma Schema 也独立于 Node 存在,甚至可以不放在项目源码中,相比之下,修改起来会更加慎重,而完全用 Node 定义的模型因为本身是代码的一部分,可能会突然被修改,而且也没有执行数据库结构同步的操作。 如果项目采用 Prisma,则模型变更后,可以执行 prisma db pull 更新数据库结构,再执行 prisma generate 更新客户端 API,这个流程比较清晰。 总结Prisma Schema 是 Prisma 的一大特色,因为这部分描述独立于代码,带来了如下几个好处: 定义比 Node Class 更简洁。 不生成冗余的代码结构。 Prisma Client 更加轻量,且查询返回的都是 Pure Object。 至于 Prisma Client 的 API 设计其实并没有特别突出之处,无论与 sequelize 还是 typeorm 的 API 设计相比,都没有太大的优化,只是风格不同。 不过对于记录的创建,我更喜欢 Prisma 的 API: // typeorm - save APIconst userRepository = getManager().getRepository(User)const newUser = new User()newUser.name = 'Alice'userRepository.save(newUser)// typeorm - insert APIconst userRepository = getManager().getRepository(User)userRepository.insert({ name: 'Alice',})// sequelizeconst user = User.build({ name: 'Alice',})await user.save()// Mongooseconst user = await User.create({ name: 'Alice', email: 'alice@prisma.io',})// prismaconst newUser = await prisma.user.create({ data: { name: 'Alice', },}) 首先存在 prisma 这个顶层变量,使用起来会非常方便,另外从 API 拓展上来说,虽然 Mongoose 设计得更简洁,但添加一些条件时拓展性会不足,导致结构不太稳定,不利于统一记忆。 Prisma Client 的 API 统一采用下面这种结构: await prisma.modelName.operateName({ // 数据,比如 create、update 时会用到 data: /** ... */, // 条件,大部分情况都可以用到 where: /** ... */, // 其它特殊参数,或者 operater 特有的参数}) 所以总的来说,Prisma 虽然没有对 ORM 做出革命性改变,但在微创新与 API 优化上都做得足够棒,github 更新也比较活跃,如果你决定使用 ORM 开发项目,还是比较推荐 Prisma 的。 在实际使用中,为了规避 ORM 产生笨拙 sql 导致的性能问题,可以利用 Prisma Middleware 监控查询性能,并对性能较差的地方采用 prisma.$queryRaw 原生 sql 查询。 讨论地址是:精读《Prisma 的使用》· Issue ##362 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React 18》","path":"/wiki/WebWeekly/前沿技术/《React 18》.html","content":"当前期刊数: 202 React 18 带来了几个非常实用的新特性,同时也没有额外的升级成本,值得仔细看一看。 下面是几个关键信息: React 18 工作小组。利用社区讨论 React 18 发布节奏与新特性。 发布计划。目前还没有正式发布,不过 @alpha 版已经可用了,安装 alpha 版。 React 18 新特性介绍。虽然还未正式发布,但特性介绍可以先行,本周精读主要就是解读这篇文档。 精读总的来说,React 18 带来了 3 大新特性: Automatic batching。 Concurrent APIS。 SSR for Suspense。 同时为了开启新的特性,需要进行简单的 render 函数升级。 Automatic batchingbatching 是指,React 可以将回调函数中多个 setState 事件合并为一次渲染。 也就是说,setState 并不是实时修改 State 的,而将多次 setState 调用合并起来仅触发一次渲染,既可以减少程序数据状态存在中间值导致的不稳定性,也可以提升渲染性能。可以理解为如下代码所示: function handleClick() { setCount((c) => c + 1); setFlag((f) => !f); // 仅触发一次渲染} 但可惜的是,React 18 以前,如果在回调函数的异步调用中执行 setState,由于丢失了上下文,无法做合并处理,所以每次 setState 调用都会立即触发一次重渲染: function handleClick() { // React 18 以前的版本 fetch(/*...*/).then(() => { setCount((c) => c + 1); // 立刻重渲染 setFlag((f) => !f); // 立刻重渲染 });} 而 React 18 带来的优化便是,任何情况都可以合并渲染了!即使在 promise、timeout 或者 event 回调中调用多次 setState,也都会合并为一次渲染: function handleClick() { // React 18+ fetch(/*...*/).then(() => { setCount((c) => c + 1); setFlag((f) => !f); // 仅触发一次渲染 });} 当然如果你非要 setState 调用后立即重渲染也行,只需要用 flushSync 包裹: function handleClick() { // React 18+ fetch(/*...*/).then(() => { ReactDOM.flushSync(() => { setCount((c) => c + 1); // 立刻重渲染 setFlag((f) => !f); // 立刻重渲染 }); });} 开启这个特性的前提是,将 ReactDOM.render 替换为 ReactDOM.createRoot 调用方式。 新的 ReactDOM Render API升级方式很简单: const container = document.getElementById("app");// 旧 render APIReactDOM.render(<App tab="home" />, container);// 新 createRoot APIconst root = ReactDOM.createRoot(container);root.render(<App tab="home" />); API 修改的主要原因还是语义化,即当我们多次调用 render 时,不再需要重复传入 container 参数,因为在新的 API 中,container 已经提前绑定到 root 了。 ReactDOM.hydrate 也被 ReactDOM.hydrateRoot 代替: const root = ReactDOM.hydrateRoot(container, <App tab="home" />);// 注意这里不用调用 root.render() 这样的好处是,后续如果再调用 root.render(<Appx />) 进行重渲染,我们不用关心这个 root 来自 createRoot 或者 hydrateRoot,因为后续 API 行为表现都一样,减少了理解成本。 Concurrent APIS首先要了解 Concurrent Mode 是什么。 简单来说,Concurrent Mode 就是一种可中断渲染的设计架构。什么时候中断渲染呢?当一个更高优先级渲染到来时,通过放弃当前的渲染,立即执行更高优先级的渲染,换来视觉上更快的响应速度。 有人可能会说,不对啊,中断渲染后,之前渲染的 CPU 执行不就浪费了吗,换句话说,整体执行时长增加了。这句话是对的,但实际上用户对页面交互及时性的感知是分为两种的,第一种是即时输入反馈,第二种是这个输入带来的副作用反馈,比如更新列表。其中,即使输入反馈只要能优先满足,即便副作用反馈更慢一些,也会带来更好的体验,更不用说副作用反馈大部分情况会因为即使输入反馈的变化而作废。 由于 React 将渲染 DOM 树机制改为两个双向链表,并且渲染树指针只有一个,指向其中一个链表,因此可以在更新完全发生后再切换指针指向,而在指针切换之前,随时可以放弃对另一颗树的修改。 以上是背景输入。React 18 提供了三个新的 API 支持这一模式,分别是: startTransition。 useDeferredValue。 <SuspenseList>。 后两个文档还未放出,所以本文只介绍第一个 API:startTransition。首先看一下用法: import { startTransition } from "react";// 紧急更新:setInputValue(input);// 标记回调函数内的更新为非紧急更新:startTransition(() => { setSearchQuery(input);}); 简单来说,就是被 startTransition 回调包裹的 setState 触发的渲染 被标记为不紧急的渲染,这些渲染可能被其他紧急渲染所抢占。 比如这个例子,当 setSearchQuery 更新的列表内容很多,导致渲染时 CPU 占用 100% 时,此时用户又进行了一个输入,即触发了由 setInputValue 引起的渲染,此时由 setSearchQuery 引发的渲染会立刻停止,转而对 setInputValue 渲染进行支持,这样用户的输入就能快速反映在 UI 上,代价是搜索列表响应稍慢了一些。而一个 transition 被打断的状态可以通过 isPending 访问到: import { useTransition } from "react";const [isPending, startTransition] = useTransition(); 其实这比较符合操作系统的设计理念,我们知道在操作系统是通过中断响应底层硬件事件的,中断都非常紧急(因为硬件能存储的消息队列非常有限,操作系统不能即使响应,硬件的输入可能就丢失了),因此要支持抢占式内核,并在中断到来时立刻执行中断(可能把不太紧急的操作放到下半部执行)。 对前端交互来说,用户角度发出的 “中断” 一般来自键盘或鼠标的操作,但不幸的是,前端框架甚至是 JS 都过于上层,它们无法自动识别: 哪些代码是紧急中断产生的。比如 onClick 就一定是用户鼠标点击产生的吗?不一定,可能是 xxx.onClick 主动触发的,而非用户触发。 用户触发的就一定是紧急中断吗?不一定,比如键盘输入后,setInputValue 是紧急的,而更新查询列表的 setSearchQuery 就是非紧急的。 我们要理解到前端场景对用户操作感知的局限性,才能理解为什么必须手动指定更新的紧急程度,而不能像操作系统一样,上层程序无需感知中断的存在。 SSR for Suspense完整名称是:Streaming SSR with selective hydration。 即像水流一样,打造一个从服务端到客户端持续不断的渲染管线,而不是 renderToString 那样一次性渲染机制。selective hydration 表示选择性水合,水合指的是后端内容打到前端后,JS 需要将事件绑定其上,才能响应用户交互或者 DOM 更新行为,而在 React 18 之前,这个操作必须是整体性的,而水合过程可能比较慢,会引起全局的卡顿,所以选择性水合可以按需优先进行水合。 所以这个特性其实是转为 SSR 准备的,而功能启用载体就是 Suspense(所以以后不要再认为 Suspense 只是一个 loading 作用)。其实在 Suspense 设计之初,就是为了解决服务端渲染问题,只是一开始只实装了客户端测的按需加载功能,后面你会逐渐发现 React 团地逐渐赋予了 Suspense 更多强大能力。 SSR for Suspense 解决三个主要问题: SSR 模式下,如果不同模块取数效率不同,会因为最慢的一个模块拖慢整体 HTML 吞吐时间,这可能导致体验还不如非 SSR 来的好。举一个极端情况,假设报表中一个组件依赖了慢查询,需要五分钟数据才能出来,那么 SSR 的后果就是白屏时间拉长到 5 分钟。 即便 SSR 内容打到了页面上,由于 JS 没有加载完毕,所以根本无法进行 hydration,整个页面处于无法交互状态。 即便 JS 加载完了,由于 React 18 之前只能进行整体 hydration,可能导致卡顿,导致首次交互响应不及时。 在 React 18 的 server render 中,只要使用 pipeToNodeWritable 代替 renderToString 并配合 Suspense 就能解决上面三个问题。 使用 pipeToNodeWriteable 可以看 这个例子。 最大的区别在于,服务端渲染由简单的 res.send 改成了 res.socket,这样渲染就从单次行为变成了持续性的行为。 那么 React 18 的 SSR 到底有怎样的效果呢?这篇介绍文档 的图建议看一看,非常直观,这里我简要描述一下: 被 <Suspense> 包裹的区块,在服务端渲染时不会阻塞首次吞吐,而且在这个区块准备完毕后(包括异步取数)再实时打到页面中(以 HTML 模式,此时还没有 hydration),在此之前返回的是 fallback 的内容。 hydration 的过程也是逐步的,这样不会导致一下执行所有完整的 js 导致页面卡顿(hydration 其实就是 React 里写的回调注册、各类 Hooks,整个应用的量非常庞大)。 hydration 因为被拆成多部,React 还会提前监听鼠标点击,并提前对点击区域优先级进行 hydration,甚至能抢占已经在其他区域正在进行中的 hydration。 那么总结一下,新版 SSR 性能提高的秘诀在于两个字:按需。 而这个难点在于,SSR 需要后端到前端的配合,在 React 18 之前,后端到前端的过程完全没有优化,而现在将 SSR HTML 的吞吐改成多次,按需,并且水合过程中还支持抢占,因此性能得到进一步提升。 总结结合起来看,React 18 关注点在于更快的性能以及用户交互响应效率,其设计理念处处包含了中断与抢占概念。 以后提起前端性能优化,我们就多了一些应用侧的视角(而不仅仅是工程化视角),从以下两个应用优化视角有效提升交互反馈速度: 随时中断的框架设计,第一优先级渲染用户最关注的 UI 交互模块。 从后端到前端 “顺滑” 的管道式 SSR,并将 hydration 过程按需化,且支持被更高优先级用户交互行为打断,第一优先水合用户正在交互的部分。 讨论地址是:精读《React 18》· Issue ##336 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Conf 2019 - Day1》","path":"/wiki/WebWeekly/前沿技术/《React Conf 2019 - Day1》.html","content":"当前期刊数: 127 1 引言React Conf 2019 在今年 10 月份举办,内容质量还是一如既往的高,如果想进一步学习前端或者 React,这个大会一定不能错过。 希望前端精读成为你学习成长路上的布道者,所以本期精读就介绍 React Conf 2019 - Day1 的相关内容。 总的来看,React Conf 今年的内容视野更广了,不仅仅有技术内容,还有宣扬公益、拓展到移动端、后端,最后还有对 web 发展的总结与展望。 前端世界正变得越来越复杂,可以看到大家对未来都充满了希望,永不停歇的探索精神是这场大会的主旋律。 2 概述 & 精读本期大会思想、设计上的内容较多,具体实现层内容较少,因为行业领导者需要引领规范,而真正技术价值在于思维模型与算法,理解了解题思路,实现它其实并不难。 开发者体验与用户体验 开发者体验:DX(develop experience) 用户体验:UX(user experience) 技术人解决的问题总是围绕 DX 与 UX,而一般来说,优化了 DX 往往会带来 UX 的提升,这是因为一个解决开发者体验的技术创新往往也会带来用户体验的升级,至少也能让开发者有更好的心情、更充足的时间做出好产品。 如何优化开发者体验呢? 易上手 React 确实致力于解决这个问题,因为 React 实际上是一个开发者桥梁,无论你开发 web、ios 还是单片机,都可以通过一套统一的语法去实现。React 是一个协议标准(读到 reactReconciler 章节会更有体感),React 像 HTML,但 React 不止能构建 HTML 应用,React 希望构建一切。 高效开发 React 解决调试、工具问题,让开发者更高效的完成工作,这也是开发者体验重要组成部分。 弹性 React 编写的程序拥有良好可维护性,包括数据驱动、模块化等等特征都是为了更好服务于不同规模的团队。 对于 UX 问题,React 也有 Concurrent mode、Suspense 等方案。 虽然 React 还不完美,但 React 致力于解决 DX 与 UX 的目标和效果都是我们有目共睹的,更好的 DX、UX 一定是前端技术未来发展的大趋势。 样式方案Facebook 使用 css-in-js,而今年的 React conf 给出了一种技术方案,将 413 kb 的样式文件体积降低到 74kb! 一步步了解这个方案,从用法开始: const styles = stylex.create({ blue: { color: "blue" }, red: { color: "red" }});function MyComponent(props) { return <span className={styles("blue", "red")}>I'm red now!</span>;} 如上是这个方案的写法,通过 stylex.create 创建样式,通过 styles() 使用样式。 主题方案 如果使用 CSS 变量定义主题,那么换肤就可以由最外层 class 轻松决定了: .old-school-theme { --link-text: blue;}.text-link { color: var(--link-text);} 字体颜色具体的值由外层 class 决定,因此外层的 class 就可以控制所有子元素的样式: <div class="old-school-theme"> <a class="text-link" href="..."> I'm blue! </a></div> 将其封装成 React 组件,也不需要用 context 等 JS 能力,而是包裹一层 class 即可。 function ThemeProvider({ children, theme }) { return <div className={themes[theme]}>{children}</div>;} 图标方案 下面是设计师给出的 svg 代码: <svg viewBox="0 0 100 100"> <path d="M9 25C8 25 8..." /></svg> 将其包装为 React 组件: function SettingsIcon(props) { return ( <SVGIcon viewBox="0 0 100 100" {...props}> <path d="M9 25C8 25 8..." /> </SVGIcon> );} 结合上面提到的主题方案,就可以控制 svg 的主题颜色。 const styles = stylex.create({ primary: { fill: "var(--primary-icon)" }, gighlight: { fill: "var(--highlight-icon)" }});function SVGIcon(color, ...props) { return ( <svg> {...props} className={styles({ primary: color === "primary", highlight: color === "highlight" })} {children} </svg> );} 减少样式大小的秘密 const styles = stylex.create({ blue: { color: "blue" }, default: { color: "red", fontSize: 16 }});function MyComponent(props) { return <span className={styles("default", props.isBlue && "blue")} />;} 对于上述样式文件代码,最终会编译成 c1、c2、c3 三个 class: .c1 { color: blue;}.c2 { color: red;}.c3 { font-size: 16px;} 出乎意料的是,并没有根据 blue 和 default 生成对应的 class,而是根据实际样式值生成 class,这样做有什么好处呢? 首先是加载顺序,class 生效的顺序与加载顺序有关,而按照样式值生成的 class 可以精确控制样式加载顺序,使其与书写顺序对应: // 效果可能是 blue 而不是 red<div className="blue red" />// 效果一定是 red,因为 css-in-js 在最终编排 class 时,虽然两种样式都存在,但书写顺序导致最后一个优先级最高,// 合并的时候就会舍弃失效的那个 class<div className={styles('blue', 'red')} /> 这么做永远不会出现头疼的样式覆盖问题。 更重要的是,随着样式文件的增多,class 总量会减少。这是因为新增的 class 涵盖的属性可能已经被其他 class 写到并生成了,此时会直接复用对应属性生成的 class 而不会生成新的: <Component1 className=".class1"/><Component2 className=".class2"/> .class1 { background-color: mediumseagreen; cursor: default; margin-left: 0px;}.class2 { background-color: thistle; cursor: default; justify-self: flex-start; margin-left: 0px;} 正如这个 Demo 所示,正常情况的 class1 与 class2 存在许多重复定义的属性,但换成 css-in-js 的方案,编译后的效果等价于将 class 复用并拆解了: <Component1 classNames=".classA .classB .classD"><Component2 classNames=".classA .classC .classD .classE"> .classA { cursor: default;}.classB { background-color: mediumseagreen;}.classC { background-color: thistle;}.classD { margin-left: 0px;}.classE { justify-self: flex-start;} 这种方式不仅节省空间、还能自动计算样式优先级避免冲突,并将 413 kb 的样式文件体积降低到 74kb。 字体大小方案rem 的好处是相对的字体大小,使用 rem 作为单位可以很方便实现网页字体大小的切换。 但问题是现在工业设计都习惯了以 px 作为单位,所以一种全新的编译方案产生了:在编译阶段将 px 自动转换成 rem。 这等于让以 px 为单位的字体大小可以跟随根节点字体大小随意缩放。 代码检测静态检测类型错误、拼写错误、浏览器兼容问题。 在线检测 dom 节点元素问题,比如是否有可访问性,比如替代文案 aria-label。 提升加载速度普通网页的加载流程是这样的: 先加载代码,然后会渲染页面,在渲染的同时发取数请求,等取数完成后才能渲染出真实数据。 那么如何改善这个情况呢?首先是预取数,提前解析出请求并在脚本加载的同时取数,可以节省大量时间: 那么下载的代码可以再拆分吗?注意到并不是所有代码都作用于 UI 渲染,我们可以将模块分为 ImportForDisplay 与 importForAfterDisplay : 这样就可以优先加载与 UI 相关的代码,其余逻辑代码在页面展示出之后再加载: 这样可以实现源码分段加载,并分段渲染: 对取数来说也是如此,并不是所有取数都是初始化渲染阶段必须用上的。可以通过 relay 的特性 @defer 标记出可以延迟加载的数据: fragment ProfileData on User { classNameprofile_picture { ... } ...AdditionalData @defer} 这下取数也可以分段了,首屏的数据会优先加载: 利用 relay 还可以以数据驱动方式结合代码拆分: ... on Post { ... on PhotoPost { @module('PhotoComponent.js') photo_data } ... on VideoPost { @module('VideoComponent.js') video_data } ... on SongPost { @module('SongComponent.js') song_data }} 这样首屏数据中也只会按需加载用到的部分,请求时间可以再次缩短: 可以看到,与 relay 结合可以进一步优化加载性能。 加载体验可以 React.Suspense 与 React.lazy 动态加载组件。通过 fallback 指定元素的占位图可以提升加载体验: <React.Suspense fallback={<MyPlaceholder />}> <Post> <Header /> <Body /> <Reactions /> <Comments /> </Post></React.Suspense> Suspense 可以被嵌套,资源会按嵌套顺序加载,保证一个自然的视觉连贯性。 智能文档通过解析 Markdown 自动生成文档大家已经很熟悉了,也有很多现成的工具可以用,但这次分享的文档系统有意思之处在于,可以动态修改源码并实时生效。 不仅如此,还利用了 Typescript + MonacoEditor 在网页上做语法检测与 API 自动提示,这种文档体验上升了一个档次。 虽然没有透露技术实现细节,但从热更新的操作来看像是把编译工作放在了浏览器 web worker 中,如果是这种实现方式,原理与 CodeSandbox 实现原理 类似。 GraphQL and Stuff这一段在安利利用接口自动生成 Typescript 代码提升前后端联调效率的工具,比如 go2dts。 我们团队也开源了基于 swagger 的 Typescript 接口自动生成工具 pont,欢迎使用。 React Reconciler这是知识密度最大的一节,介绍了如何使用 React Reconclier。 React Reconclier 可以创建基于任何平台的 React 渲染器,也可以理解为通过 React Reconclier 可以创建自定义的 ReactDOM。 比如下面的例子,我们尝试用自定义函数 ReactDOMMini 渲染 React 组件: import React from "react";import logo from "./logo.svg";import ReactDOMMini from "./react-dom-mini";import "./App.css";function App() { const [showLogo, setShowLogo] = React.useState(true); let [color, setColor] = React.useState("red"); React.useEffect(() => { let colors = ["red", "green", "blue"]; let i = 0; let interval = setInterval(() => { i++; setColor(colors[i % 3]); }, 1000); return () => clearInterval(interval); }); return ( <div className="App" onClick={() => { setShowLogo(show => !show); }} > <header className="App-header"> {showLogo && <img src={logo} className="App-logo" alt="logo /" />} // 自创语法 <p bgColor={color}> Edit <code>src/App.js</code> and save to reload. </p> <a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer" > Learn React{" "} </a> </header> </div> );}ReactDOMMini.render(<App />, codument.getElementById("root")); ReactDOMMini 是利用 ReactReconciler 生成的自定义组件渲染函数,下面是完整的代码: import ReactReconciler from "react-reconciler";const reconciler = ReactReconciler({ createInstance( type, props, rootContainerInstance, hostContext, internalInstanceHandle ) { const el = document.createElement(type); ["alt", "className", "href", "rel", "src", "target"].forEach(key => { if (props[key]) { el[key] = props[key]; } }); // React 事件代理 if (props.onClick) { el.addEventListener("click", props.onClick); } // 自创 api bgColor if (props.bgColor) { el.style.backgroundColor = props.bgColor; } return el; }, createTextInstance( text, rootContainerInstance, hostContext, internalInstanceHandle ) { return document.createTextNode(text); }, appendChildToContainer(container, child) { container.appendChild(child); }, appendChild(parent, child) { parent.appendChild(child); }, appendInitialChild(parent, child) { parent.appendChild(child); }, removeChildFromContainer(container, child) { container.removeChild(child); }, removeChild(parent, child) { parent.removeChild(child); }, insertInContainerBefore(container, child, before) { container.insertBefore(child, before); }, insertBefore(parent, child, before) { parent.insertBefore(child, before); }, prepareUpdate( instance, type, oldProps, newProps, rootContainerInstance, currentHostContext ) { let payload; if (oldProps.bgColor !== newProps.bgColor) { payload = { newBgCOlor: newProps.bgColor }; } return payload; }, commitUpdate( instance, updatePayload, type, oldProps, newProps, finishedWork ) { if (updatePayload.newBgColor) { instance.style.backgroundColor = updatePayload.newBgColor; } }});const ReactDOMMini = { render(wahtToRender, div) { const container = reconciler.createContainer(div, false, false); reconciler.updateContainer(whatToRender, container, null, null); }};export default ReactDOMMini; 笔者拆解一下说明: React 之所以具备跨平台特性,是因为其渲染函数 ReactReconciler 只关心如何组织组件与组件间关系,而不关心具体实现,所以会暴露出一系列回调函数。 创建实例 由于 React 组件本质是一个描述,即 tag + 属性,所以 Reconciler 不关心元素是如何创建的,需要通过 createInstance 拿到组件基本属性,在 Web 平台利用 DOM API 实现: createInstance( type, props, rootContainerInstance, hostContext, internalInstanceHandle ) { const el = document.createElement(type); ["alt", "className", "href", "rel", "src", "target"].forEach(key => { if (props[key]) { el[key] = props[key]; } }); // React 事件代理 if (props.onClick) { el.addEventListener("click", props.onClick); } // 自创 api bgColor if (props.bgColor) { el.style.backgroundColor = props.bgColor; } return el; } 之所以说 React 对 DOM 事件都做了一层代理,是因为 JSX 的所有函数都没有真正透传给 DOM,而是通过类似 el.addEventListener("click", props.onClick) 的方式代理实现的。 而自定义这个函数,我们甚至能创建例如 bgColor 这种特殊语法,只要解析引擎实现了这个语法的 Handler。 除此之外,还有 创建、删除实例 的回调函数,我们都要利用 DOM 平台的 API 重新实现一遍,这样不仅可以实现对浏览器 API 的兼容,还可以对接到比如 react-native 等非 WEB 平台。 更新组件 实现了 prepareUpdate 与 commitUpdate 才能完成组件更新。 prepareUpdate 返回的 payload 被 commitUpdate 函数接收到,并根据接收到的信息决定如何更新实例节点。这个实例节点就是 createInstance 回调函数返回的对象,所以如果在 WEB 环境返回的 instance 就是 DOMInstance,后续所有操作都使用 DOMAPI。 总结一下:react 主要用平台无关的语法生成具有业务含义的 AST,而利用 react-reconciler 生成的渲染函数可以解析这个 AST,并提供了一系列回调函数实现完整的 UI 渲染功能,react-dom 现在也是基于 react-reconciler 写的。 图标体积优化Facebook 团队通过优化,将图标大小从 4046.05KB 降低到了 132.95kb,体积减少了惊人的 96.7%,减少体积占总包体积的 19.6%! 实现方式很简单,下面是原始图标使用的代码: <FontAwesomeIcon icon="coffee" /><Icon icon={["fab", "twitter"]} /><Button leftIcon="user" /><FeatureGroup.Item icon="info" /><FeatureGroup.Item icon={["fail", "info"]} /> 在编译期间通过 AST 分析,将所有字符串引用换成了图标实例的引用,利用 webpack 的 tree-shaking 功能实现按需加载,从而删除了没有使用到的图标。 import {faCoffee,faInfo,faUser} from "@fontawesome/free-solid-svg-icons"import {faTwitter} from '@fontawesome/free-brands-svg-icons'import {faInfo as faInfoFal} from '@fontawesome/pro-light-svg-icons'<FontAwesomeIcon icon={faCoffee} /><Icon icon={faTwitter} /><Button leftIcon={faUser} /><FeatureGroup.Item icon={faInfo} /><FeatureGroup.Item icon={faInfoFal} /> 替换工具 的链接放出来了,感兴趣的同学可以点进去了解更多。 这也从某种意义上说明了 iconFont 注定被淘汰,因为字体文件目前无法按需加载,只有全部使用 SVG 图标的项目才能使用这种优化。 Git & Github这一节介绍了基本 Git 知识以及 Github 用法,笔者略过比较水的部分,直接列出两个可能你不知道的点: 干预 Github 项目主要语言检测 如果你提交的代码包含许多自动生成的文件,可能你实际使用的语言不会被 Github 解析为主要语言,这时候可以通过 .gitattributes 文件忽略指定文件夹的检测: static/* linguist-vendored 这样语言文件占比统计就会忽略 static/ 文件夹。 Git hooks 的技巧 以下是几个比较具有启发的点,我们可以利用 Git hooks 做点什么: 阻止提交到 master。 在 commit 之前执行 prettier/eslint/jest 检测。 检测代码规范、合并冲突、检测是否有大文件。 commit 成功后给出提示或记录到日志。 但 Git hooks 仍然有局限性: 容易被绕过:–no-verifuy –no-merge –no-checkout —force。 本地 hooks 无法提交,导致项目开发规则可能不尽相同。 无法替代 CI、服务端分支保护、Code Review。 可以畅想一下,在 WebIDE 环境可以通过自定义 git 命令禁止检测绕过,自然解决第二条环境不一致的问题。 GraphQL + TypescriptGraphQL 是没有类型支持的,如果要手动创建一遍类型文件是非常痛苦的: interface GetArticleData { getArticle: { id: number; title: string; };}const query = graphql(gql` query getArticle { article { id title } }`);apolloClient.query<GetArticleData>(query); 同样的代码分散在两处维护一定会带来问题,我们可以利用比如 typed-graphqlify 这种库解决类型问题: import { params, types, query } from "typed-graphqlify";const getArticleQuery = { article: params({ id: types.number, title: types.string })};const gqlString = query("getUser", getUserQuery); 只要一遍定义就可以自动生成 GQLString,并且拿到 Typescript 类型。 React 文档国际化即便是谷歌翻译也不是很靠谱,国际化文档还是要靠人肉,Nat Alison 利用 Github 充分发动各国人民的力量,共同打造了一个个 reactjs group 下的国际化仓库。 国际化仓库命名规则是 reactjs/xx.reactjs.org,比如简体中文的国际化仓库是:https://github.com/reactjs/zh-hans.reactjs.org 从仓库的 readme 可以看到维护规则是这样的: 请 fork 这个仓库。 基于 fork 后的仓库中 master 分支拉取一个新的分支(名字自取)。 翻译(校对)你所选择的文章,提交到新的分支。 此时提交 Pull Request 到该仓库。 会有专人 Review 该 Pull Request,当两人以上通过该 Pull Request 时,你的翻译将被合并到仓库中。 删除你所创建的分支(如继续参与,参考同步流程)。 之后定期从 React 官方文档项目拉取最新代码即可保持文档的同步更新。 你需要 redux 吗?关于数据流的话题目前没有什么新意,但这次 React Conf 关于数据流总结的算是比较真诚的,总结了以下几个点: 全局数据流现在不是必须的,比如 Redux,但也不能说完全不能用,至少在全局状态较为复杂时有必要使用。 不要只使用一种数据流方案,根据状态的作用域确定方案比较好。 工程技术与科学不同,工程世界没有最好的方案,只有更好的方案。 就算有了完美方案也不要停止学习的步伐,总会有新知识产生。 web 历史很精彩的演讲,不过新鲜内容并不多,比较有感触一点是:以前的网页地址对应到的是服务器磁盘的某个具体文件,比如早期 php 应用,现在后端不再是文件化而是服务化了,这层抽象让服务端摆脱了对文件结构的依赖,可以构建更多复杂动态逻辑,也支持了前后端分离的技术方案。 3 总结这届 React Conf 让我们看到前端更多的可能性,我们不仅要关注技术实现细节,更要关注行业标准以及团队愿景。 React 团队的愿景是让 React 包罗万象,提升全球开发者的开发体验、提升全球产品的用户体验,基于这个目标,React Conf 自然不能只包含 DOM Diff、Reconciler 等等技术细节,更需要展示 React 如何帮助全球开发者,如何让这些开发者帮助到用户,如何推动行业标准的演进,如何让 React 打破国界、语言的壁垒。 相比其他前端大会非常多的干货来说,React Conf 虽然显得主题比较杂,但这正是人文情怀的体现,我相信只有带着更高的使命愿景,真诚帮助他人的技术团队才可以走得更远。 讨论地址是:精读《React Conf 2019 - Day1》 · Issue ##214 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Conf 2019 - Day2》","path":"/wiki/WebWeekly/前沿技术/《React Conf 2019 - Day2》.html","content":"当前期刊数: 129 1 引言这是继 精读《React Conf 2019 - Day1》 之后的第二篇,补充了 React Conf 2019 第二天的内容。 2 概述 & 精读第二天的内容更为精彩,笔者会重点介绍比较干货的部分。 Fast refreshFast refresh 是更好的 react-hot-loader 替代方案,目前仅支持 react-native 平台,很快就会支持 react-dom 平台。 相比不支持 Function component、无法错误恢复、更新经常失灵的 hot reloading 来说,fast refresh 还拥有以下几个优点: 状态保持。 支持 Function Component Hooks。 更快的更新速度。 Fast refresh 更新速度更快,是基于 Function Component 生成了 “签名”,从而最大成都避免销毁重渲染,尽可能保持对组件的 rerender 刷新。下面介绍签名机制的工作原理。 Fast refresh 对每个 Function component 都生成了一份专属签名,用以描述这个组件核心状态,当这个核心状态改变时,就只能销毁重渲染了,但对于不触及核心的修改就能进行代价非常小的 rerender。 这个签名包含了 hooks 和参数名: // signature: "useState{isLoggedIn}"function ExampleComponent() { const [isLoggedIn, setIsLoggedIn] = useState(true);} 比如当参数名变更时,这个组件的逻辑已发生改动,此时只能销毁并重渲染了。因此实际上通过对签名的对比来判断是否要销毁并重刷新组件: // signature: "useState{isLoggedOut}"function ExampleComponent() { const [isLoggedOut, setIsLoggedOut] = useState(true);} 同理,当 hooks 从 useState 改成了 useReducer,签名也会发生变化从而导致彻底的重渲染。 但除此之外,比如对样式的修改、Dom 结构的修改都不会触发签名的变化,从而保证了 “对不触及逻辑的改动进行高效的轻量 renreder”。 然而 Fast refresh 也有如下局限性: 还不能友好支持 Class component。 混合导出 React 和非 React 组件时无法精确的 hot reload。 更高的内存要求。 可以看到,Fast Refresh 随着功能推广与内置,现在已经覆盖了 Facebook 95% 以上 hot reload 场景了: 这部分内容不仅揭开了 hot reload 技术内幕,还对其功能进行了进一步优化,2019 年的 React 开发体系已经进入精细化阶段。 重写 React devtoolsReact devtools 的更新终于被正式介绍了,本来笔者以为新的 devtools 只是支持了 hooks,但听完分享后发现还有更多有用的改进,包括: 更高的性能。 更多特性支持。 更好用户体验。 找到节点渲染链路 并不是每个 React 节点都参与渲染,新版 React devtools 可以展示出 rendered by: 调试 Suspense 在 Day1 中讲到的 Suspense 特性可以在 React devtools 调试了: 通过点击时钟 icon,可以模拟 Suspense 处于 pendding 或 ready 状态。 增强调试能力 可以通过点击直接跳转到组件源码: 最新版已增强至点击按钮后直接通过 Source 打开源码位置,这样可以快速通过 UI 寻找到代码。同时还可以看到,通过点击 debugger 按钮将当前组件信息打到控制台调试。 除此之外还可以动态修改组件的 props 与 hook state,大大增强了调试能力。 profiler 分析工具也得到了增强,现在可以看到每个组件被渲染了几次以及重新渲染的原因: 比如上图组件被渲染了 4 次,主要有两个原因:Hooks 改变与 Props 改变。 除此之外,还优化了更多细节体验,比如高亮搜索、HOC 的展示优化、嵌套层级过多时不会占用过多的横向宽度等等。 react codemodcodemod 是一个代码重构的方式,通过 AST 方式精准触达代码,我们可以认为 codemod 是一个更聪明的“查找/替换”。 codemod 主要有以下三种使用方式: 重命名。 代码排序。 一定程度的代码替换。 接下来就讲到 react codemod 了,它是 react 场景的 codemod 解决方案,facebook 是这么使用 react codemod 的: 迁移 facebook 代码。 涉及几万个组件。 修复了 3500 个文件的 React.PropTypes。 修复了 8500 个文件的生命周期 unsafe。 修复了 20000 个文件的 createClass 转 JSX。 使用方式: npx react-codemod React-PropTypes-to-prop-types 可以看到,通过 cli 对文件进行一次性重构处理。除此之外,再列举几种使用场景: create-element-to-jsx 将 React.createElement 转换为 JSX。 error-boundaries 将 unstable_handleError 改为 componentDidCatch。 findDOMNode 将 React.createClass 中 this.getDOMNode() 改为 React.findDOMNode。 sort-comp 将 Class Component 生命周期按照规范排序,eslint-plugin-react 插件也有相同能力。 理论上来讲,所有 codemode 做的事情都可以替换为 eslint 的 autofix 来完成,比如 sort-comp 就同时被 codemode 和 eslint 支持。 Suspense要理解 Suspense,就要理解 Suspense 与普通 loading 有什么区别。 从代码角度来说,Suspense 可以类比为 try/catch 的体验。为了简化代码复杂度,我们可以用 try/catch 包裹代码,从而简化 try 区块代码复杂度,并将兜底代码放在 catch 区块: try { // 只要考虑正确情况} catch { // 错误时 fallback} Suspense 也一样,它在渲染 React 组件时如果遇到了 Promise 抛出的 Error,就会进入 fallback,所以 fallback 含义是 Loading 中状态: <Suspense fallback={<Spinner />}> <ProfilePage /></Suspense> 与此同时,实际业务组件中的取数也不需要担心取数是否正在进行中,只要直接处理拿到数据的情况就好了: function ProfileDetails() { // 直接使用 user,不用担心失败。 const user = resource.user.read(); return <h1>{user.name}</h1>;} 进一步的,如果要处理组件渲染的异常,再使用 ErrorBoundary 包裹即可,此时的 fallback 含义是组件加载异常的错误状态: function Home(props) { return ( <ErrorBoundary fallback={<ErrorMessage />}> <Suspense fallback={<Placeholder />}> <Composer /> </Suspense> </ErrorBoundary> );} Suspense 模式的取数好处是 “fetch on render”,即渲染与取数同时进行,而普通模式的取数是 “fetch after render”,即渲染完成后再通过 useEffect 取数,此时取数时机已晚。 队列加载 假设 Composer 与 NewsFeed 组件内部都通过 useQuery 取数,那么并行取数时加载机制如下: 这可能有两个问题:组件内部加载顺序不统一与组件间加载顺序不统一。 如果组件内部有图片,可能图片与组件渲染实际不一致,此时可以利用 Suspense 统一 hold 所有子组件的特性,将图片加载改为 Suspense 模式: <div> <YourImage src={uri} alt={...} /> <MoreComposer /></div> 同一个 Suspense 可以等待所有子元素都 Ready 后才会一把渲染出 UI,因此可以看到网页被一次性刷新而不是分部刷新。 第二个问题是组件间加载顺序不统一,可能导致先渲染了文章内容,再渲染出文章头部,此时如果区块高度不固定,文章头部可能会撑开,导致文章内容下移,用户的阅读体验会遭到打断。可以通过 suspense ordering 解决这个问题: function Home(props) { return ( <SuspenseList revealOrder="forwards"> <Suspense fallback={<ComposerFallback />}> <Composer /> </Suspense> <Suspense fallback={<FeedFallback />}> <NewsFeed /> </Suspense> </SuspenseList> );} 比如 forwards 表示从上到下,那么一定会先渲染头部再渲染文章内容,这样文章内容就不会都抖动了。 Render as you fetch相比 “fetch on render”,更高级别的优化是 “Render as you fetch”,即取数在渲染时机之前。 比如页面路由的跳转、Hover 到一个区块,此时如果取数由这个动作触发,就可以再次将取数时机提前,Facebook 为此创造了一个新的 Hook:usePreloadedQuery。 用法是,在某个事件中取数,比如点击页面跳转按钮时,通过 preloadQuery 预取数,得到的结果并不是取数结果,而是一个标识,在渲染组件中,把这个标识传给 usePreloadedQuery 可以拿到真实取数结果: // 组件 A 的 onClickconst reference = preloadQuery(query, variables);// 组件 B 的 renderconst data = usePreloadedQuery(query, reference); 可以看到,取数真正触发的时机在渲染函数执行之前,所以在 usePreloadedQuery 调用时取数肯定已经在路上,甚至已经完成。相比之下,普通的 useQuery 函数存在下面几个问题: 由于取数过程存在状态变化,可能导致组件在 “取数无意义” 状态下重新渲染多次。 可能取数还未完成就触发重渲染。 没有取消的机制,没有清除结果的机制。 没有办法唯一标识组件。 preloadQuery 的好处就是将取数时机与 UI 分离,这样可以更细粒度的控制逻辑: 调用 preloadQuery 时: 在组件销毁时取消取数。 有新取数触发时取消取数。 销毁一些轮询机制。 渲染组件调用 usePreloadedQuery 时: 不会再触发取数,不会触发意外的 re-render。 不需要清空,因为取数不在这里发起。 不需要清理轮询。 可见 preloadQuery 相比 useQuery 的确有了一些体验提升,然而这个优化比较追求极致,对大部分国内项目来说可能还走不到 facebook 这么极致的性能优化,所以投入产出比显得不是那么高,而且这个开发方式对开发者不是太友好,因为它让请求的时机割裂到两个模块中。 但毕竟用户体验是大于开发者体验的,React 尽量通过提高开发者体验来间接提高用户体验,使双方都满意,但像 preloadQuery 就无法两者兼顾了,为了用户体验可以适当的降低一些开发者体验。 如何维护代码这个分享讲述了如何提升代码维护效率,毕竟一个月后可能连自己写的代码都看不懂了。hydrosquall 通过类比地图的方式解释了程序员是如何维护代码的。 首先看我们是如何认路的。认路分为三个层次: 随意走走。 通过一些地标判断方向。 有方向的寻路。 通过跟随同伴或者了解更多本地信息找到目的地。 地图。 通过 GPS 定位。 通过模拟地图方式指出路线。 可以看到这三种方式是逐层递进的,那么类比到代码就有意思了: 随意走走(滚动查看源代码 + ctrl/f 查找代码 + grep 搜索)。 入口(找到入口节点,查看数据结构)。 标记(查看代码注释、查看 README)。 发信号弹(断点、console.log 等调试行为) 找到方向。 git blame 查看 owner,或直接根据文档找到 codeowners。 地图。 幸运的话你可以找到一份架构流程图。 可以看到,地图有几种抽象层次,比如忽略了细节的纽约地铁线路图: 或者是包含丰富地面信息的地铁线路图: 抽象到什么层次取决于用户使用的场景,那么代码抽象也是如此。hydrosquall 做了一个工具自动分析出代码调用关系:js-callgraph 这就像路牌一样,可以更高效的看出代码结构,也包括了数据流结构,由于篇幅限制,感兴趣的同学可以看 原视频 了解更多。 写作与写代码本章讲了写作(小说)与写代码的关联,总结出如下几个重点: 写小说和写代码都是创造行为。 写代码需要抽象思维,写小说也要有抽象思维构造人物和情节。 Show, don’t tell,写作天然就是申明式的,和数据驱动很相似。 更多可以去看 原视频。 移动端动画最佳实践首先要使用一个真实的手机设备调试,否则可能出现 PC Chrome 一切正常,而手机上实际效果性能很差的情况! 手势下拉退出 利用 react-spring 和 react-use-gesture 做一个下滑消失的 Demo: import { animated, useSpring } from "react-spring";import { useDrag } from "react-use-gesture";const [{ y }, set] = useSpring(() => { y: 0;}); 首先定义一个 y 纵向位置,通过 useDrag 将拖拽操作与 UI 绑定,通过回调将其与 y 数据绑定: const bind = useDrag(({ last, movement: [, movementY], memo = y.value }) => { if (last) { // 拖拽结束时,如果偏移量超过 50 则效果和结束一样,直接将 y 设置为 100 const notificationClosed = movementY > 50; return set({ y: notificationClosed ? 100 : 0, onReset: notificationClosed && removeNotification }); } // y 的位置区间在 0~100 set([{ y: clamp(0, 100, memo + movementY) }]); return memo;}); 将 useDrag 与 y 绑定后,就可以用在 UI 组件上了: <StyledNotification as={animated.div} onTouchStart={bind().onTouchStart} style={{ opacity: y.interpolate([0, 100], [1, 0]), transform: y.interpolate(y => `translateY(${y}px)`) }}/> 将 opacity 与 transform 与位置 y 绑定就可以做出下拉消失的效果。 滑动的洞见 接着讲到了滑动的三个洞见: 要立刻响应,任何延迟都会造成用户额外精神负担。 滚动速度衰减可以提升用户体验: 接着我们需要预测用户的意图,比如在一个类似微信消息列表页左右滑动时: 是否想取消手势交互? 是否想展示出更多交互按钮? 是否想删除所有内容? 这需要更多设计思考。 橡皮筋滚动,即列表页可以一直向下拉,上面部分像橡皮筋一样可以被拉出空白页的效果。 在设计手势动画时要考虑三个要点: 使用移动增量作为手势动画的基准点。 动画和手势应该随时可以被中断,通过 springs 即可实现。 完成手势后的动画速度应该与手势速度相当,这样视觉体验更自然。 最后提到了动画兼容性与性能,比如尽量只使用 transform 与 opacity 可以保证移动端的流畅度,不同移动设备的默认手势效果不同,最好通过 touch-action 禁用默认行为以达到更好的兼容性与效果。 唱片与 ReactJ.Dash 拥有十年软件开发经验,同时也卖过很多唱片,他介绍了唱片行业与软件开发的共同点。 唱片行业需要音乐编排能力,这与编码能力类似,都存在良好的设计模式,并且需要团队合作,开发过程中会遇到一些痛苦的经历,但最终完成音乐和项目时都会获得满足的喜悦。 函数式编程 Declaratives UIs are the future, and the future is Comonadic. - Phil Freeman 申明式 UI 是未来,未来则是 Comonadic。 所谓申明式 UI 可以用下面的公式表达: type render = (state: State) => View; 然后用一段公式介绍了 Comonadic: class Functor w => Comonad w where extract :: w a -> a duplicate :: w a -> w (w a) extend :: (w a -> a) -> w a -> w b 用 JS 版本做一个解释: const Store = ({ state, render }) => ({ extend: f => Store({ state, render: state => f(Store({ state, render })) }), extract: () => render(state)}); extract 调用后会进行申明式渲染 UI,即 render(state)。 extend 表示拓展,接收一个拓展函数作为参数,返回一个新的 Store 对象。这个拓展函数可以拿到 state、render 并返回新的 state 作为 extract 时 render 的输入。使用例子是这样的: const App = Store({ state: { msg: "World" }, render: ({ msg }) => <p>Hello {msg}</p>});App.extend(({ state }) => state.msg === "World" ? { msg: "ReactConf" } : state).extract(); // <p> Hello ReactConf </p> 然而尴尬的是,笔者看了很久也没看懂 Store 函数,最后运行了一下发现这个 Demo 抛出了异常 😂。 下面是笔者稍微修改后的例子,至少能跑起来: const Store = ({ state, render }) => ({ extend: f => Store({ state, render: state => render(f({ state, render })) }), extract: () => render(state)});const app = Store({ state: { msg: "Hello World" }, render: ({ msg }) => console.log("render " + msg)});app .extend(({ state }) => { return { msg: state.msg + " extend1" }; }) .extend(({ state }) => { return { msg: state.msg + " extend2" }; }) .extract(); // render Hello World extend2 extend1 然而作者的意思仍是未解之谜,希望对函数式了解的同学可以在评论区指点一下。 wick editorwick editor 是一个开源的动画、游戏制作软件。 wick editor 是一个动画制作工具,但拓展了一些 js 编程能力,因此可以很好的将动画与游戏结合在一起: 演讲介绍了 wick editor 的演化过程: 从很简陋的 MVP 版本开始(1 周) 到 Pre-Alpha(4 月) Alpha(5 月) Beta(1.5 年) 重点是 1.0 版本采用 React 重写了!继 Beta 之后又经历了 1 年: 这个团队最棒的地方是,将游戏与教育结合,针对不同场景做了很多用户调研并根据反馈持续改进。 React Selectreact-select 的作者 Jed Watson 被请来啦。作为一个看上去很简单组件(select)的开发者,却拥有如此大的关注量(1.8w star),那作者有着怎样的心路历程呢? react-select 看似简单的名字背后其实有挺多的功能,比如作者列举了一些功能层面的内容: autocomplete - 输入时搜索。 单、多选。 focus 管理。 下拉框层级与位置,比如可以放在根 DOM 节点,也可以作为当前节点的子元素。 异步下拉框内容。 键盘、触控。 Createble,即在搜索时如果没有内容可以动态创建。 等等。 在设计层面: 申明式。 可以被定制。 性能要求。 等等。 随着 Star 逐渐上涨,越来越多的需求被提出,核心库代码量越来越大,甚至许多需求之间都是相互冲突的,而且作者每天都会被上百个 Issue 与 PR 吵醒。做一个业务 Select 可能只要 5 分钟,但做一个开源 Select 却要 5 年,原因是一个简单的 Select 如何满足所有不同业务场景?这绝对是个巨大的挑战。 比如用户即需要受控也要非受控的组件,如何满足好这个需求同时又让代码更可维护呢? 假设我们拥有一个受控的组件 SelectComponent,那么它的主要 props 是 value 与 onChange,如果要拓展成一个既支持 defaultValue(非受控)又支持 value(受控)的组件,我们可以创建一个 manageState 组件对 SelectComponent 进行封装: const manageState = SelectComponent => ({ value: valueProps, onChange: onChangeProp, defaultValue, ...props}) => { const [valueState, setValue] = useState(defaultValue); const value = valueProps !== undefined ? valueProps : valueState; const onChange = (newValue, actionMeta) => { if (typeof onChangeProp === "function") { onChangeProp(newValue, actionMeta); } setValue(newValue); }; return <SelectComponent {...props} value={value} onChange={onChange}>}; 这样就可以组合为一个受控/非受控的综合 Select 组件: import BaseSelect from "./Select";import manageState from "./manageState";export default manageState(Select); 同理对异步的封装也可以放在 makeAsync 函数中: const makeAsync = SelectComponent => ({ getOptions, defaultOptions, ...props}) => { const [options, setOptions] = useState(defaultOptions); const [isLoading, setIsLoading] = useState(false); const onInputChange = async newValue => { setIsLoading(true); const newOptions = await getOptions(newValue); setIsLoading(false); setOptions(newOptions); }; return ( <SelectComponent {...props} options={options} isLoading={isLoading} onInputChange={onInputChange} /> );}; 可以看到,SelectComponent 是一个完全受控的数据驱动的 UI,无论是 manageState 还是 makeAsync 都是对数据处理的拓展,所以这三者之间才可以融洽的组合: import BaseSelect from "./Select";import manageState from "./manageState";import makeAsync from "./async";export default manageState(Select);export const AsyncSelect = manageState(makeAsync(Select)); 后面还有一些风格化、开源协作的思考,这里就不展开了,对这部分感兴趣的同学可以查看原视频了解更多。 React + 政府财政透明项目usaspending.gov 这个网站使用 React 建设,可以查看美国政府支持财政的明细,通过流畅的体验让更多用户可以了解国家财政支出,进一步推动财政支出的透明化。由于并不涉及前端技术的介绍,主要是产品介绍,因此精读就不详细展开了。 顺便说一句,智能分析数据就用 QuickBI,QuickBI 是我们团队研发的一款智能 BI 服务平台,如果你将美国政府的财政支持作为数据集输入,你会分析得更透彻。 React + 星舰模拟器最后介绍的是使用 React 制作的星舰模拟器,看上去像一个游戏: 有星系图、船体、驾驶员信息、武器装备、燃料、通信等等内容。甚至可以模拟太空驾驶,进行任务,可以实时多人协同。对太空迷们的吸引力很大,感兴趣的同学建议直接观看 视频。 3 总结第二天的内容非常全面,涉及了 React API、开发者周边、codemod 工具、代码维护、写作/音乐与代码、动画、函数式编程、看似简单的 React 组件、使用 React 制作的各种脑洞大开的项目,等等。 React Conf 要展示的是一个完整的 React 世界,第一天提到了 React 是一个桥梁,正因为这个桥梁,连接了各行各业不同的人群以及不同的项目,大家都有一个共同的语言:React。 “We not only react code, but react the world”。 讨论地址是:精读《React Conf 2019 - Day2》 · Issue ##217 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Error Boundaries》","path":"/wiki/WebWeekly/前沿技术/《React Error Boundaries》.html","content":"当前期刊数: 148 1 引言Error Boundaries 是 React16 提出来用来捕获渲染时错误的概念,今天我们一起读一读 A Simple Guide to Error Boundaries in React 这篇文章,了解一下这个重要机制。 2 概述Error Boundaries 可以用来捕获渲染时错误,API 如下: 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 官方文档 里提到了四种无法 Catch 的错误场景: 回调事件。由于回调事件执行时机不在渲染周期内,因此无法被 Error Boundary Catch 住,如有必要得自行 try/catch。 异步。比如 setTimeout 或 requestAnimationFrame,和第一条同理。 服务端渲染。 Error Boundary 组件自身触发的错误。因为只能捕获其子组件的错误。 这也是使用 Error Boundaries 最容易有疑问的地方。除了上面的情况,笔者结合自身经验再列举几种异常边界场景。 无法捕获编译时错误很明显,即便是 React 官方 API Error Boundary 也只能捕获运行时错误,而对编译时错误无能为力。 编译时错误包括不限于编译环境错误、运行前的框架错误检查提示、TS/Flow 类型错误等,这些都是 Error Boundary 无法捕获的,而且没有更好的办法 Catch 住,遇到编译错误就在编译时解决吧,仅关注运行时错误就好了。 可以作用于 Function Component虽然函数式组件无法定义 Error Boundary,但 Error Boundary 可以捕获函数式组件的错误,因此可以曲线救国: // ErrorBoundary 组件class ErrorBoundary extends React.Component { // ...}// 可以捕获所有组件异常,包括 Function Component 的子组件const App = () => { return ( <ErrorBoundary> <Child /> </ErrorBoundary> );}; 对 Hooks 也可生效对于 Hooks 中异常也可以生效,比如下面的代码: 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): 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 有很多宝藏,建议抽时间逐条阅读。 4 总结Error Boundary 可以捕获所有子元素渲染时异常,包括 render、各生命周期函数,但也有很多使用限制,希望你可以正确使用它。 错误捕获也不是万能的,更多时候我们要避免并及时修复错误,通过错误捕获降低出错时对用户体验的影响,并在第一时间内监控起来并快速修复。 最后,你有明明正确使用了 Error Boundary 却依然无法 Catch 住的错误 Case 吗? 讨论地址是:精读《React Error Boundaries》 · Issue ##246 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Hooks 数据流》","path":"/wiki/WebWeekly/前沿技术/《React Hooks 数据流》.html","content":"当前期刊数: 146 1 引言React Hooks 渐渐被国内前端团队所接受,但基于 Hooks 的数据流方案却还未固定,我们有 “100 种” 类似的选择,却各有利弊,让人难以取舍。 本周笔者就深入谈一谈对 Hooks 数据流的理解,相信读完文章后,可以从百花齐放的 Hooks 数据流方案中看到本质。 2 精读基于 React Hooks 谈数据流,我们先从最不容易产生分歧的基础方案说起。 单组件数据流单组件最简单的数据流一定是 useState: function App() { const [count, setCount] = useState();} useState 在组件内用是毫无争议的,那么下个话题就一定是跨组件共享数据流了。 组件间共享数据流跨组件最简单的方案就是 useContext: 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 已经为你想好解决方案了。 数据流与组件解耦unstated-next 可以帮你把上面例子中,定义在 App 中的数据单独出来,形成一个自定义数据管理 Hook: 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,毫无争议: 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 的方式合并更新。 然而还有性能问题: function ChildCount() { const { count } = Counter.useContainer();}function ChildFoo() { const { foo } = Counter.useContainer();} 更新 foo 时,ChildCount 和 ChildFoo 同时会执行,但 ChildCount 没用到 foo 呀?这个原因是 Counter.useContainer 提供的数据流是一个引用整体,其子节点 foo 引用变化后会导致整个 Hook 重新执行,继而所有引用它的组件也会重新渲染。 此时我们发现可以利用 Redux useSelector 实现按需更新。 按需更新首先我们利用 Redux 对数据流做一次改造: 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 的引用是不变的,但对于其他场景就不一定了。 举个例子: function Child() { const user = useSelector((state) => ({ user: state.user }), shallowEqual); return <UserPage user={user} />;} 假设 user 对象在每次数据流更新引用都会发生变化,那么 shallowEqual 自然是不起作用,那我们换成 deepEqual深对比呢?结果是引用依然会变,只是重渲染不那么频繁了: 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 引用不会频繁改变: function Child() { const user = useSelector( (state) => ({ user: state.user }), // 当 user 值变化时才重渲染 deepEqual ); const userDeep = useDeepMemo(() => user, [user]); return <UserPage user={userDeep} />;} 当然这是比较极端的情况,只要看到 deepEqual 与 useSelector 同时作用了,就要问问自己其返回的值的引用会不会发生意外变化。 缓存查询函数对于极限场景,即便控制了重渲染次数与返回结果的引用最大程度不变,还是可能存在性能问题,这最后一块性能问题就处在查询函数上。 上面的例子中,查询函数比较简单,但如果查询函数非常复杂就不一样了: 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 根据参数引用进行缓存。 想象一下,如果 state.user 的引用不频繁变化,但 verySlowFunction 非常慢,理想情况是 state.user 引用变化后才重新执行 verySlowFunction,但上面的例子中,useSelector 并不知道还能这么优化,只能傻傻的每次渲染重复执行 verySlowFunction,哪怕 state.user 没有变。 此时我们要告诉引用,state.user 是否变化才是重新执行的关键: 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 函数: 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 实例,因此这个函数还需要再包装一层高阶形态: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Hooks 最佳实践》","path":"/wiki/WebWeekly/前沿技术/《React Hooks 最佳实践》.html","content":"当前期刊数: 120 简介React 16.8 于 2019.2 正式发布,这是一个能提升代码质量和开发效率的特性,笔者就抛砖引玉先列出一些实践点,希望得到大家进一步讨论。 然而需要理解的是,没有一个完美的最佳实践规范,对一个高效团队来说,稳定的规范比合理的规范更重要,因此这套方案只是最佳实践之一。 精读环境要求 拥有较为稳定且理解函数式编程的前端团队。 开启 ESLint 插件:eslint-plugin-react-hooks。 组件定义Function Component 采用 const + 箭头函数方式定义: const App: React.FC<{ title: string }> = ({ title }) => { return React.useMemo(() => <div>{title}</div>, [title]);};App.defaultProps = { title: 'Function Component'} 上面的例子包含了: 用 React.FC 申明 Function Component 组件类型与定义 Props 参数类型。 用 React.useMemo 优化渲染性能。 用 App.defaultProps 定义 Props 的默认值。 FAQ 为什么不用 React.memo? 推荐使用 React.useMemo 而不是 React.memo,因为在组件通信时存在 React.useContext 的用法,这种用法会使所有用到的组件重渲染,只有 React.useMemo 能处理这种场景的按需渲染。 没有性能问题的组件也要使用 useMemo 吗? 要,考虑未来维护这个组件的时候,随时可能会通过 useContext 等注入一些数据,这时候谁会想起来添加 useMemo 呢? 为什么不用解构方式代替 defaultProps? 虽然解构方式书写 defaultProps 更优雅,但存在一个硬伤:对于对象类型每次 Rerender 时引用都会变化,这会带来性能问题,因此不要这么做。 局部状态局部状态有三种,根据常用程度依次排列: useState useRef useReducer 。 useStateconst [hide, setHide] = React.useState(false);const [name, setName] = React.useState('BI'); 状态函数名要表意,尽量聚集在一起申明,方便查阅。 useRefconst dom = React.useRef(null); useRef 尽量少用,大量 Mutable 的数据会影响代码的可维护性。 但对于不需重复初始化的对象推荐使用 useRef 存储,比如 new G2() 。 useReducer局部状态不推荐使用 useReducer ,会导致函数内部状态过于复杂,难以阅读。 useReducer 建议在多组件间通信时,结合 useContext 一起使用。 FAQ 可以在函数内直接申明普通常量或普通函数吗? 不可以,Function Component 每次渲染都会重新执行,常量推荐放到函数外层避免性能问题,函数推荐使用 useCallback 申明。 函数所有 Function Component 内函数必须用 React.useCallback 包裹,以保证准确性与性能。 const [hide, setHide] = React.useState(false); const handleClick = React.useCallback(() => { setHide(isHide => !isHide)}, []) useCallback 第二个参数必须写,eslint-plugin-react-hooks 插件会自动填写依赖项。 发请求发请求分为操作型发请求与渲染型发请求。 操作型发请求操作型发请求,作为回调函数: return React.useMemo(() => { return ( <div onClick={requestService.addList} /> )}, [requestService.addList]) 渲染型发请求渲染型发请求在 useAsync 中进行,比如刷新列表页,获取基础信息,或者进行搜索, 都可以抽象为依赖了某些变量,当这些变量变化时要重新取数 : const { loading, error, value } = useAsync(async () => { return requestService.freshList(id);}, [requestService.freshList, id]); 组件间通信简单的组件间通信使用透传 Props 变量的方式,而频繁组件间通信使用 React.useContext 。 以一个复杂大组件为例,如果组件内部拆分了很多模块, 但需要共享很多内部状态 ,最佳实践如下: 定义组件内共享状态 - store.tsexport const StoreContext = React.createContext<{ state: State; dispatch: React.Dispatch<Action>;}>(null)export interface State {};export interface Action { type: 'xxx' } | { type: 'yyy' };export const initState: State = {};export const reducer: React.Reducer<State, Action> = (state, action) => { switch (action.type) { default: return state; }}; 根组件注入共享状态 - main.tsimport { StoreContext, reducer, initState } from './store'const AppProvider: React.FC = props => { const [state, dispatch] = React.useReducer(reducer, initState); return React.useMemo(() => ( <StoreContext.Provider value={{ state, dispatch }}> <App /> </StoreContext.Provider> ), [state, dispatch])}; 任意子组件访问/修改共享状态 - child.tsimport { StoreContext } from './store'const app: React.FC = () => { const { state, dispatch } = React.useContext(StoreContext); return React.useMemo(() => ( <div>{state.name}</div> ), [state.name])}; 如上解决了 多个联系紧密组件模块间便捷共享状态的问题 ,但有时也会遇到需要共享根组件 Props 的问题,这种不可修改的状态不适合一并塞到 StoreContext 里,我们新建一个 PropsContext 注入根组件的 Props: const PropsContext = React.createContext<Props>(null)const AppProvider: React.FC<Props> = props => { return React.useMemo(() => ( <PropsContext.Provider value={props}> <App /> </PropsContext.Provider> ), [props])}; 结合项目数据流参考 react-redux hooks。 debounce 优化比如当输入框频繁输入时,为了保证页面流畅,我们会选择在 onChange 时进行 debounce 。然而在 Function Component 领域中,我们有更优雅的方式实现。 其实在 Input 组件 onChange 使用 debounce 有一个问题,就是当 Input 组件 受控 时, debounce 的值不能及时回填,导致甚至无法输入的问题。 我们站在 Function Component 思维模式下思考这个问题: React scheduling 通过智能调度系统优化渲染优先级,我们其实不用担心频繁变更状态会导致性能问题。 如果联动一个文本还觉得慢吗? onChange 本不慢,大部分使用值的组件也不慢,没有必要从 onChange 源头开始就 debounce 。 找到渲染性能最慢的组件(比如 iframe 组件),对一些频繁导致其渲染的入参进行 useDebounce 。 下面是一个性能很差的组件,引用了变化频繁的 text (这个 text 可能是 onChange 触发改变的),我们利用 useDebounce 将其变更的频率慢下来即可: const App: React.FC = ({ text }) => { // 无论 text 变化多快,textDebounce 最多 1 秒修改一次 const textDebounce = useDebounce(text, 1000) return useMemo(() => { // 使用 textDebounce,但渲染速度很慢的一堆代码 }, [textDebounce])}; 使用 textDebounce 替代 text 可以将渲染频率控制在我们指定的范围内。 useEffect 注意事项事实上,useEffect 是最为怪异的 Hook,也是最难使用的 Hook。比如下面这段代码: useEffect(() => { props.onChange(props.id)}, [props.onChange, props.id]) 如果 id 变化,则调用 onChange。但如果上层代码并没有对 onChange 进行合理的封装,导致每次刷新引用都会变动,则会产生严重后果。我们假设父级代码是这么写的: class App { render() { return <Child id={this.state.id} onChange={id => this.setState({ id })} /> }} 这样会导致死循环。虽然看上去 <App> 只是将更新 id 的时机交给了子元素 <Child>,但由于 onChange 函数在每次渲染时都会重新生成,因此引用总是在变化,就会出现一个无限死循环: 新 onChange -> useEffect 依赖更新 -> props.onChange -> 父级重渲染 -> 新 onChange… 想要阻止这个循环的发生,只要改为 onChange={this.handleChange} 即可,**useEffect 对外部依赖苛刻的要求,只有在整体项目都注意保持正确的引用时才能优雅生效。** 然而被调用处代码怎么写并不受我们控制,这就导致了不规范的父元素可能导致 React Hooks 产生死循环。 因此在使用 useEffect 时要注意调试上下文,注意父级传递的参数引用是否正确,如果引用传递不正确,有两种做法: 使用 useDeepCompareEffect 对依赖进行深比较。 使用 useCurrentValue 对引用总是变化的 props 进行包装: function useCurrentValue<T>(value: T): React.RefObject<T> { const ref = React.useRef(null); ref.current = value; return ref;}const App: React.FC = ({ onChange }) => { const onChangeCurrent = useCurrentValue(onChange)}; onChangeCurrent 的引用保持不变,但每次都会指向最新的 props.onChange,从而可以规避这个问题。 总结如果还有补充,欢迎在文末讨论。 如需了解 Function Component 或 Hooks 基础用法,可以参考往期精读: 精读《React Hooks》 精读《怎么用 React Hooks 造轮子》 精读《useEffect 完全指南》 精读《Function Component 入门》 讨论地址是:精读《React Hooks 最佳实践》 · Issue ##202 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Hooks》","path":"/wiki/WebWeekly/前沿技术/《React Hooks》.html","content":"当前期刊数: 79 1 引言React Hooks 是 React 16.7.0-alpha 版本推出的新特性,想尝试的同学安装此版本即可。 React Hooks 要解决的问题是状态共享,是继 render-props 和 higher-order components 之后的第三种状态共享方案,不会产生 JSX 嵌套地狱问题。 状态共享可能描述的不恰当,称为状态逻辑复用会更恰当,因为只共享数据处理逻辑,不会共享数据本身。 不久前精读分享过的一篇 Epitath 源码 - renderProps 新用法 就是解决 JSX 嵌套问题,有了 React Hooks 之后,这个问题就被官方正式解决了。 为了更快理解 React Hooks 是什么,先看笔者引用的下面一段 renderProps 代码: function App() { return ( <Toggle initial={false}> {({ on, toggle }) => ( <Button type="primary" onClick={toggle}> Open Modal </Button> <Modal visible={on} onOk={toggle} onCancel={toggle} /> )} </Toggle> )} 恰巧,React Hooks 解决的也是这个问题: function App() { const [open, setOpen] = useState(false); return ( <> <Button type="primary" onClick={() => setOpen(true)}> Open Modal </Button> <Modal visible={open} onOk={() => setOpen(false)} onCancel={() => setOpen(false)} /> </> );} 可以看到,React Hooks 就像一个内置的打平 renderProps 库,我们可以随时创建一个值,与修改这个值的方法。看上去像 function 形式的 setState,其实这等价于依赖注入,与使用 setState 相比,这个组件是没有状态的。 2 概述React Hooks 带来的好处不仅是 “更 FP,更新粒度更细,代码更清晰”,还有如下三个特性: 多个状态不会产生嵌套,写法还是平铺的(renderProps 可以通过 compose 解决,可不但使用略为繁琐,而且因为强制封装一个新对象而增加了实体数量)。 Hooks 可以引用其他 Hooks。 更容易将组件的 UI 与状态分离。 第二点展开说一下:Hooks 可以引用其他 Hooks,我们可以这么做: import { useState, useEffect } from "react";// 底层 Hooks, 返回布尔值:是否在线function useFriendStatusBoolean(friendID) { const [isOnline, setIsOnline] = useState(null); function handleStatusChange(status) { setIsOnline(status.isOnline); } useEffect(() => { ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange); }; }); return isOnline;}// 上层 Hooks,根据在线状态返回字符串:Loading... or Online or Offlinefunction useFriendStatusString(props) { const isOnline = useFriendStatusBoolean(props.friend.id); if (isOnline === null) { return "Loading..."; } return isOnline ? "Online" : "Offline";}// 使用了底层 Hooks 的 UIfunction FriendListItem(props) { const isOnline = useFriendStatusBoolean(props.friend.id); return ( <li style={{ color: isOnline ? "green" : "black" }}>{props.friend.name}</li> );}// 使用了上层 Hooks 的 UIfunction FriendListStatus(props) { const status = useFriendStatusString(props); return <li>{status}</li>;} 这个例子中,有两个 Hooks:useFriendStatusBoolean 与 useFriendStatusString, useFriendStatusString 是利用 useFriendStatusBoolean 生成的新 Hook,这两个 Hook 可以给不同的 UI:FriendListItem、FriendListStatus 使用,而因为两个 Hooks 数据是联动的,因此两个 UI 的状态也是联动的。 顺带一提,这个例子也可以用来理解 对 React Hooks 的一些思考 一文的那句话:“有状态的组件没有渲染,有渲染的组件没有状态”: useFriendStatusBoolean 与 useFriendStatusString 是有状态的组件(使用 useState),没有渲染(返回非 UI 的值),这样就可以作为 Custom Hooks 被任何 UI 组件调用。 FriendListItem 与 FriendListStatus 是有渲染的组件(返回了 JSX),没有状态(没有使用 useState),这就是一个纯函数 UI 组件, 利用 useState 创建 ReduxRedux 的精髓就是 Reducer,而利用 React Hooks 可以轻松创建一个 Redux 机制: // 这就是 Reduxfunction useReducer(reducer, initialState) { const [state, setState] = useState(initialState); function dispatch(action) { const nextState = reducer(state, action); setState(nextState); } return [state, dispatch];} 这个自定义 Hook 的 value 部分当作 redux 的 state,setValue 部分当作 redux 的 dispatch,合起来就是一个 redux。而 react-redux 的 connect 部分做的事情与 Hook 调用一样: // 一个 Actionfunction useTodos() { const [todos, dispatch] = useReducer(todosReducer, []); function handleAddClick(text) { dispatch({ type: "add", text }); } return [todos, { handleAddClick }];}// 绑定 Todos 的 UIfunction TodosUI() { const [todos, actions] = useTodos(); return ( <> {todos.map((todo, index) => ( <div>{todo.text}</div> ))} <button onClick={actions.handleAddClick}>Add Todo</button> </> );} useReducer 已经作为一个内置 Hooks 了,在这里可以查阅所有 内置 Hooks。 不过这里需要注意的是,每次 useReducer 或者自己的 Custom Hooks 都不会持久化数据,所以比如我们创建两个 App,App1 与 App2: function App1() { const [todos, actions] = useTodos(); return <span>todo count: {todos.length}</span>;}function App2() { const [todos, actions] = useTodos(); return <span>todo count: {todos.length}</span>;}function All() { return ( <> <App1 /> <App2 /> </> );} 这两个实例同时渲染时,并不是共享一个 todos 列表,而是分别存在两个独立 todos 列表。也就是 React Hooks 只提供状态处理方法,不会持久化状态。 如果要真正实现一个 Redux 功能,也就是全局维持一个状态,任何组件 useReducer 都会访问到同一份数据,可以和 useContext 一起使用。 大体思路是利用 useContext 共享一份数据,作为 Custom Hooks 的数据源。具体实现可以参考 redux-react-hook。 利用 useEffect 代替一些生命周期在 useState 位置附近,可以使用 useEffect 处理副作用: useEffect(() => { const subscription = props.source.subscribe(); return () => { // Clean up the subscription subscription.unsubscribe(); };}); useEffect 的代码既会在初始化时候执行,也会在后续每次 rerender 时执行,而返回值在析构时执行。这个更多带来的是便利,对比一下 React 版 G2 调用流程: class Component extends React.PureComponent<Props, State> { private chart: G2.Chart = null; private rootDomRef: React.ReactInstance = null; componentDidMount() { this.rootDom = ReactDOM.findDOMNode(this.rootDomRef) as HTMLDivElement; this.chart = new G2.Chart({ container: this.rootDom, forceFit: true, height: 300 }); this.freshChart(this.props); } componentWillReceiveProps(nextProps: Props) { this.freshChart(nextProps); } componentWillUnmount() { this.chart.destroy(); } freshChart(props: Props) { // do something this.chart.render(); } render() { return <div ref={ref => (this.rootDomRef = ref)} />; }} 用 React Hooks 可以这么做: function App() { const ref = React.useRef(null); let chart: G2.Chart = null; React.useEffect(() => { chart = new G2.Chart({ container: ReactDOM.findDOMNode(ref.current) as HTMLDivElement, width: 500, height: 500 }); // do something chart.render(); return () => chart.destroy(); }, []); return <div ref={ref} />;} 可以看到将细碎的代码片段结合成了一个完整的代码块,更易维护。 现在介绍了 useState useContext useEffect useRef 等常用 hooks,更多可以查阅:内置 Hooks,相信不久的未来,这些 API 又会成为一套新的前端规范。 3 精读Hooks 带来的约定Hook 函数必须以 “use” 命名开头,因为这样才方便 eslint 做检查,防止用 condition 判断包裹 useHook 语句。 为什么不能用 condition 包裹 useHook 语句,详情可以见 官方文档,这里简单介绍一下。 React Hooks 并不是通过 Proxy 或者 getters 实现的(具体可以看这篇文章 React hooks: not magic, just arrays),而是通过链表实现的,每次 useState 都会改变下标,如果 useState 被包裹在 condition 中,那每次执行的下标就可能对不上,导致 useState 导出的 setter 更新错数据。 虽然有 eslint-plugin-react-hooks 插件保驾护航,但这第一次将 “约定优先” 理念引入了 React 框架中,带来了前所未有的代码命名和顺序限制(函数命名遭到官方限制,JS 自由主义者也许会暴跳如雷),但带来的便利也是前所未有的(没有比 React Hooks 更好的状态共享方案了,约定带来提效,自由的代价就是回到 renderProps or HOC,各团队可以自行评估)。 笔者认为,React Hooks 的诞生,也许来自于这个灵感:“不如通过增加一些约定,彻底解决状态共享问题吧!” React 约定大于配置脚手架 nextjs umi 以及笔者的 pri 都通过有 “约定路由” 的功能,大大降低了路由配置复杂度,那么 React Hooks 就像代码级别的约定,大大降低了代码复杂度。 状态与 UI 的界限会越来越清晰因为 React Hooks 的特性,如果一个 Hook 不产生 UI,那么它可以永远被其他 Hook 封装,虽然允许有副作用,但是被包裹在 useEffect 里,总体来说还是挺函数式的。而 Hooks 要集中在 UI 函数顶部写,也很容易养成书写无状态 UI 组件的好习惯,践行 “状态与 UI 分开” 这个理念会更容易。 不过这个理念稍微有点蹩脚的地方,那就是 “状态” 到底是什么。 function App() { const [count, setCount] = useCount(); return <span>{count}</span>;} 我们知道 useCount 算是无状态的,因为 React Hooks 本质就是 renderProps 或者 HOC 的另一种写法,换成 renderProps 就好理解了: <Count>{(count, setCount) => <App count={count} setCount={setCount} />}</Count>;function App(props) { return <span>{props.count}</span>;} 可以看到 App 组件是无状态的,输出完全由输入(Props)决定。 那么有状态无 UI 的组件就是 useCount 了: function useCount() { const [count, setCount] = useState(0); return [count, setCount];} 有状态的地方应该指 useState(0) 这句,不过这句和无状态 UI 组件 App 的 useCount() 很像,既然 React 把 useCount 成为自定义 Hook,那么 useState 就是官方 Hook,具有一样的定义,因此可以认为 useCount 是无状态的,useState 也是一层 renderProps,最终的状态其实是 useState 这个 React 内置的组件。 我们看 renderProps 嵌套的表达: <UseState> {(count, setCount) => ( <UseCount> {" "} {/**虽然是透传,但给 count 做了去重,不可谓没有作用 */} {(count, setCount) => <App count={count} setCount={setCount} />} </UseCount> )}</UseState> 能确定的是,App 一定有 UI,而上面两层父级组件一定没有 UI。为了最佳实践,我们尽量避免 App 自己维护状态,而其父级的 RenderProps 组件可以维护状态(也可以不维护状态,做个二传手)。因此可以考虑在 “有状态的组件没有渲染,有渲染的组件没有状态” 这句话后面加一句:没渲染的组件也可以没状态。 4 总结把 React Hooks 当作更便捷的 RenderProps 去用吧,虽然写法看上去是内部维护了一个状态,但其实等价于注入、Connect、HOC、或者 renderProps,那么如此一来,使用 renderProps 的门槛会大大降低,因为 Hooks 用起来实在是太方便了,我们可以抽象大量 Custom Hooks,让代码更加 FP,同时也不会增加嵌套层级。 5 更多讨论 讨论地址是:精读《React Hooks》 · Issue ##111 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《React Router v6》","path":"/wiki/WebWeekly/前沿技术/《React Router v6》.html","content":"当前期刊数: 145 1 引言React Router v6 alpha 版本发布了,本周通过 A Sneak Peek at React Router v6 这篇文章分析一下带来的改变。 2 概述 更名为 一个不痛不痒的改动,使 API 命名更加规范。 // v5import { 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: // v6import { BrowserRouter, Routes, Route } from "react-router-dom";function App() { return ( <BrowserRouter> <Routes> <Route path="/" element={<Home />} /> <Route path="profile/*" element={<Profile />} /> </Routes> </BrowserRouter> );} 升级在 v5 版本里,想要给组件传参数是不太直观的,需要利用 RenderProps 的方式透传 routeProps: 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 的拼接实现子路由: // v5import { 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 表示相对路径: // v6import { BrowserRouter, Routes, Route, Link, Outlet } from "react-router-dom";// Approach ##1function 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 placefunction 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 等操作: // v5import { useHistory } from "react-router-dom";function MyButton() { let history = useHistory(); function handleClick() { history.push("/home"); } return <button onClick={handleClick}>Submit</button>;} 而在 v6 版本中,可以通过 useNavigate 直接实现这个常用操作: // v6import { 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 } 参数指定: // v5history.push("/home");history.replace("/home");// v6navigate("/home");navigate("/home", { replace: true }); 更小的体积 8kb由于代码几乎重构,v6 版本的代码压缩后体积从 20kb 缩小到 8kb。 3 精读react-router v6 源码中有一段比较核心的理念,笔者拿出来与大家分享,对一些框架开发是大有裨益的。我们看 useRoutes 这段代码节选: 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 代码节选: 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 上: const Input = () => { // Input 组件在画布中会自动生成一个 id,但这个 id 组件无法通过 props 拿到}; 此时如果我们允许 Input 组件内部再创建一个子元素,又希望这个子元素的 id 是由 Input 推导出来的,我们可能需要用户这么做: const Input = ({ id }) => { return <ComponentLoader id={id + "1"} />;}; 这样做有两个问题: 将 id 暴露给 Input 组件,违背了之前设计的简洁性。 组件需要对 id 进行拼装,很麻烦。 这里遇到的问题和 React Router 遇到的一样,我们可以将代码简化成下面这样,但功能不变吗? const Input = () => { return <ComponentLoader id="1" />;}; 答案是可以做到,我们可以利用 Context 实现这种方案。关键点就在于,渲染 Input 但组件容器需要包裹一个 Provider: const ComponentLoader = ({ id, element }) => { <Context.Provider value={{ id }}>{element}</Context.Provider>;}; 那么对于内部的组件来说,在不同层级下调用 useContext 拿到的 id 是不同的,这正是我们想要的效果: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React Router4","path":"/wiki/WebWeekly/前沿技术/《React Router4.html","content":"当前期刊数: 32 本期精读的文章是:React Router 进阶:嵌套路由,代码分割,转场动画等等。 懒得看文章?没关系,稍后会附上文章内容概述,同时,更希望能通过阅读这一期的精读,穿插着深入阅读原文。 1 引言 React Router4.0 出来之前,许多人都对其夸张的变化感到不适,但其实 4.0 说不定真的是一个非常正确的改动。 也许,说 4.0 不好的人,正是另一个消极版的小红点,希望这篇文章可以让大家意识到,React Router4.0 对大多数人真的很棒! 2 内容概要React Router4.0 正式版发布了,生态也逐渐完善了起来,是时候推一波与其完美结合的实用工具了! 代码分割通过 react-loadable,可以做到路由级别动态加载,或者更细粒度的模块级别动态加载: const AsyncHome = Loadable({ loader: () => import('../components/Home/Home'), loading: LoadingPage})<Route exact path="/" component={AsyncHome} /> 当然上面展示的是 ReactRouter 中的用法,AsyncHome 可以在任何 JSX 中引用,这样就提升到了模块级别的动态加载。 注意,无论是 webpack 的 Tree Shaking,还是动态加载,都只能以 Commonjs 的源码为分析目标,对 node_modules 中代码不起作用,所以 npm 包请先做好拆包。或者类似 antd 按照约定书写组件,并提供一种 webpack-loader 自动完成按需加载。 转场动画通过 React Router Transition (Ant Motion 也很好用) 可以实现路由级别的动画: <Router> <AnimatedSwitch atEnter={{ opacity: 0 }} atLeave={{ opacity: 0 }} atActive={{ opacity: 1 }} className="switch-wrapper" > <Route exact path="/" component={Home} /> <Route path="/about/" component={About}/> <Route path="/etc/" component={Etc}/> </AnimatedSwitch></Router> 并提供了一些生命周期的回调,具体可以参考文档。现在动画的思路比较靠谱的也大致是这种:通过添加/移除 class 的方式,利用 css3 做动效。 滚动条复位当页面回退时,将滚动条恢复到页面最顶部,可以让单页路由看起来更加正常。由于 React Router4.0 中,路由是一种组件,我们可以利用 componentDidUpdate 简单完成滚动条复位的功能: <Router history={history}> <ScrollToTop> <div> <Switch> <Route exact path="/" component={Home} /> <Route path="/about" component={About} /> <Route path="*" component={NotFound} /> </Switch> </div> </ScrollToTop></Router> @withRouterclass ScrollToTop extends Component { componentDidUpdate(prevProps) { if (this.props.location !== prevProps.location) { window.scrollTo(0, 0) } } render() { return this.props.children }} 非通过 Route 渲染的组件,可以通过 withRouter 拿到路由信息,仅当其为 Router 的子元素时有效。 嵌套路由React Router4.0 嵌套路由与 3.0 不同,是通过组件 Route 的嵌套实现的。 在任何组件,都可以使用如下代码实现嵌套路由: <Route path={`${this.props.match.url}/:id`} component={NestComponent} /> 这样将路由功能切分到各个组件中,我现在的项目甚至已经没有 route.js 文件了,路由由 layout 与各个组件自身承担。这种设计思路与 Nestjs 的描述性路由具有相同的思想 - 在 nodejs 中,我们可以通过装饰器,在任意一个 Action 上描述其访问的 URL: @POST("/api/service")async someAction() {} 服务端渲染浏览器端,需要一个专属的入口文件,使用 BrowserRouter 与 location 对接: <BrowserRouter> <App /></BrowserRouter> 服务器端,BrowserRouter 变成了 StaticRouter: renderToString( <StaticRouter location={req.url} context={context} > <html> <body> <App /> </body> </html> </StaticRouter>) 与浏览器不同的是,React Router 无法根据 location 自动判断当前所在页面,而需要你把 req.url 传给 StaticRouter,后续的路由渲染逻辑双端都是通用的。 如果存在跳转 Redirect,会通过 context.url 告诉你,所以后面会跟上跳转处理逻辑: if (context.url) { res.writeHead(301, { Location: context.url }) res.end()} else { res.write(markup) res.end()} 3 精读React Router 从 3.0 到 4.0 的改动,想来想去,认为是对于 URL 这个资源理解的变化。 URL 即浏览器地址,在前端数据化统一的浪潮下,其实 URL 也可以被看作是一种参数,在 React 中即一个 props 属性。 单页应用,如果从传统多页应用角度来思考,可能认为不过是一种体验的优化,或者是一种 “伪单页”,毕竟本质上单页应用只是一个页面而已。但换个角度想想,网站何尝不是一个整体,而网址的变化只是一种状态呢? 当我们做一个 Tabs 组件时,会发觉做得越来越像浏览器原生 tab,当用户给你提需求,在刷新浏览器时,能自动打开上一次打开的 Tab,我们的做法就是将当前打开的 Tab 信息保存在 URL 中,刷新时读取再切换过去。这证明了 URL 表示的就是一种状态。 而页面路由的状态化,是将模拟 Tab 的思路应用到了浏览器级别的 Tab。URL 是一种状态,在前端,可以通过浏览器地址自动获取,在后端,可以通过 req.url 获取,甚至可以手动传入来覆盖。 传统的开发思路:我们为每个 URL 编写独立的页面或者模块。 新的开发思路:URL 是一个状态,代码读取这个状态作出不同展现,展现得完全不同时,可以看作传统模式的页面切换;但还可以做到只有某一块区域展现得不同。 4. 总结也许 React Router4.0 带给我们的思考是,放下对网页“页面”的刻板印象,其实网站本没有页面,有的只是状态。 讨论地址是:精读《React Router4.0 进阶概念》》 · Issue ##43 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《React Server Component》","path":"/wiki/WebWeekly/前沿技术/《React Server Component》.html","content":"当前期刊数: 193 截止目前,React Server Component 还在开发与研究中,因此不适合投入生产环境使用。但其概念非常有趣,值得技术人学习。 目前除了国内各种博客、知乎解读外,最一手的学习资料有下面两处: Dan 的 Server Component 介绍视频 Server Component RFC 草案 我会结合这些一手资料,与一些业界大牛的解读,系统的讲清楚 React Server Component 的概念,以及我对它的一些理解。 首先我们来看,为什么需要提出 Server Component 这个概念: Server Component 概念的提出,是为了解决 “用户体验、可维护性、性能” 这个不可能三角,所谓不可能三角就是,最多同时满足两条,而无法三条都同时满足。 简单解释一下,用户体验体现在页面更快的响应、可维护性体现在代码应该高内聚低耦合、性能体现在请求速度。 保障 用户体验、可维护性,用一个请求拉取全部数据,所有组件一次性渲染。但当模块不断增多,无用模块信息不敢随意删除,请求会越来越大,越来越冗余,导致瓶颈卡在取数这块,也就是 性能不好。 保障 用户体验、性能,考虑并行取数,之后流程不变,那么以后业务逻辑新增或减少一个模块,我们就要同时修改并行取数公共逻辑与对应业务模块,可维护性不好。 保障 可维护性、性能,可以每个模块独立取数,但在父级渲染完才渲染子元素的情况下,父子取数就变成了串行,页面加载被阻塞,用户体验不好。 一言蔽之,在前后端解耦的模式下,唯一连接的桥梁就是取数请求。要把用户体验做好,取数就要提前并行发起,而前端模块是独立维护的,所以在前端做取数聚合这件事,必然会破坏前端可维护性,而这并行这件事放在后端的话,会因为后端不能解析前端模块,导致给出的聚合信息滞后,久而久之变得冗余。 要解决这个问题,就必须加深前端与后端的联系,所以像 GraphQL 这种前后端约定方案是可行的,但因为其部署成本高,收益又仅在前端,所以难以在后端推广。 Server Component 是另一种方案,通过启动一个 Node 服务辅助前端,但做的不是 API 对接,而是运行前端同构 js 代码,直接解析前端渲染模块,从中自动提取请求并在 Node 端直接与服务器通信,因为服务端间通信成本极低、前端代码又不需要做调整,请求数据也是动态按需聚合的,因此同时解决了 “用户体验、可维护性、性能” 这三个问题。 其核心改进点如下图所示: 如上图所示,这是前后端正常交互模式,可以看到,Root 与 Child 串行发了两个请求,因为网络耗时与串行都是严重阻塞部分,因此用红线标记。 Server Component 可以理解为下图,不仅减少了一次网络损耗,请求也变成了并行,请求返回结果也从纯数据变成了一个同时描述 UI DSL 与数据的特殊结构: 到此,恭喜你已经理解了 Server Component 核心概念,如果你只想泛泛了解一下,读到这里就可以结束了。如果你还想深入了解其实现细节,请继续阅读。 概述概括的说,Server Component 就是让组件拥有在服务端渲染的能力,从而解决不可能三角问题。也正因为这个特性,使得 Server Component 拥有几种让人眼前一亮的特性,都是纯客户端组件所不具备的: 运行在服务端的组件只会返回 DSL 信息,而不包含其他任何依赖,因此 Server Component 的所有依赖 npm 包都不会被打包到客户端。 可以访问服务端任何 API,也就是让组件拥有了 Nodejs 能拥有的能力,你理论上可以在前端组件里干任何服务端才能干的事情。 Server Component 与 Client Component 无缝集成,可以通过 Server Component 无缝调用 Client Component。 Server Component 会按需返回信息,在当前逻辑下,走不到的分支逻辑的所有引用都不会被客户端引入。比如 Server Component 虽然引用了一个巨大的 npm 包,但某个分支下没有用到这个包提供的函数,那客户端也不会下载这个巨大的 npm 包到本地。 由于返回的不是 HTML,而是一个 DSL,所以服务端组件即便重新拉取,已经产生的 State 也会被维持住。比如说 A 是 ServerComponent,其子元素 B 是 Client Component,此时对 B 组件做了状态修改比如输入一些文字,此时触发 A 重新拉取 DSL 后,B 已经输入的文字还会保留。 可以无缝与 Suspense 结合,并不会因为网络原因导致连 Suspense 的 loading 都不能及时展示。 共享组件可以同时在服务端与客户端运行。 三种组件Server Component 将组件分为三种:Server Component、Client Component、Shared Component,分别以 .server.js、.client.js、.js 后缀结尾。 其中 .client.js 与普通组件一样,但 .server.js 与 .js 都可能在服务端运行,其中: .server.js 必然在服务端执行。 .js 在哪执行要看谁调用它,如果是 .server.js 调用则在服务端执行,如果是 .client.js 调用则在客户端执行,因此其本质还要接收服务端组件的约束。 下面是 RFC 中展示的 Server Component 例子: // Note.server.js - Server Componentimport db from 'db.server'; // (A1) We import from NoteEditor.client.js - a Client Component.import NoteEditor from 'NoteEditor.client';function Note(props) { const {id, isEditing} = props; // (B) Can directly access server data sources during render, e.g. databases const note = db.posts.get(id); return ( <div> <h1>{note.title}</h1> <section>{note.body}</section> {/* (A2) Dynamically render the editor only if necessary */} {isEditing ? <NoteEditor note={note} /> : null } </div> );} 可以看到,这就是 Node 与 React 混合语法。服务端组件有着苛刻的限制条件:不能有状态,且 props 必须能被序列化。 很容易理解,因为服务端组件要被传输到客户端,就必须经过序列化、反序列化的过程,JSX 是可以被序列化的,props 也必须遵循这个规则。另外服务端不能帮客户端存储状态,因此服务端组件不能用任何 useState 等状态相关 API。 但这两个问题都可以绕过去,即将状态转化为组件的 props 入参,由 .client.js 存储,见下图: 或者利用 Server Component 与 Client Component 无缝集成的能力,将状态与无法序列化的 props 参数都放在 Client Component,由 Server Component 调用。 优点零客户端体积这句话听起来有点夸张,但其实在 Server Component 限定条件下还真的是。看下面代码: // NoteWithMarkdown.jsimport marked from 'marked'; // 35.9K (11.2K gzipped)import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)function NoteWithMarkdown({text}) { const html = sanitizeHtml(marked(text)); return (/* render */);} marked 与 sanitize-html 都不会被下载到本地,所以如果只有这一个文件传输,客户端的理论增加体积就是 render 函数序列化后字符串大小,可能不到 1KB。 当然这背后也是限制换来的,首先这个组件没有状态,无法在客户端实时执行,而且在服务端运行也可能消耗额外计算资源,如果某些 npm 包计算复杂度较高的话。 这个好处可以理解为,marked 这个包仅在服务端读取到内存一次,以后只要后客户端想用,只需要在服务端执行 marked API 并把输出结果返回给客户端,而不需要客户端下载 marked 这个包了。 拥有完整服务端能力由于 Server Component 在服务端执行,因此可以执行 Nodejs 的任何代码。 // Note.server.js - Server Componentimport fs from 'react-fs';function Note({id}) { const note = JSON.parse(fs.readFile(`${id}.json`)); return <NoteWithMarkdown note={note} />;} 我们可以把对请求的理解拔高一个层次,即 request 只是客户端发起的一个 Http 请求,其本质是访问一个资源,在服务端就是个 IO 行为。对于 IO,我们还可以通过 file 文件系统写入删除资源、db 通过 sql 语法直接访问数据库,或者 request 直接在服务器本地发出请求。 运行时 Code Split我们都知道 webpack 可以通过静态分析,将没有使用到的 import 移出打包,而 Server Component 可以在运行时动态分析,将当前分支逻辑下没有用到的 import 移出打包: // PhotoRenderer.jsimport React from 'react';// one of these will start loading *once rendered and streamed to the client*:import OldPhotoRenderer from './OldPhotoRenderer.client.js';import NewPhotoRenderer from './NewPhotoRenderer.client.js';function Photo(props) { // Switch on feature flags, logged in/out, type of content, etc: if (props.useNewPhotoRenderer) { return <NewPhotoRenderer {...props} />; } else { return <OldPhotoRenderer {...props} />; }} 这是因为 Server Component 构建时会进行预打包,运行时就是一个动态的包分发器,完全可以通过当前运行状态比如 props.xxx 来区分当前运行到哪些分支逻辑,而没有运行到哪些分支逻辑,并且仅告诉客户端拉取当前运行到的分支逻辑的缺失包。 纯前端模式与之类似的写法是: const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js'));const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js')); 只是这种写法不够原生,且实际场景往往只有前端框架把路由自动包一层 Lazy Load,而普通代码里很少出现这种写法。 无客户端往返的数据端取数一般考虑到取数网络消耗,我们往往会将其处理成异步,然后在数据返回前展示 Loading: // Note.jsfunction Note(props) { const [note, setNote] = useState(null); useEffect(() => { // NOTE: loads *after* rendering, triggering waterfalls in children fetchNote(props.id).then(noteData => { setNote(noteData); }); }, [props.id]); if (note == null) { return "Loading"; } else { return (/* render note here... */); }} 这是因为单页模式下,我们可以快速从 CDN 拿到这个 DOM 结构,但如果再等待取数,整体渲染就变慢了。而 Server Component 因为本身就在服务端执行,因此可以将拿 DOM 结构与取数同时进行: // Note.server.js - Server Componentfunction Note(props) { // NOTE: loads *during* render, w low-latency data access on the server const note = db.notes.get(props.id); if (note == null) { // handle missing note } return (/* render note here... */);} 当然这个前提是网络消耗敏感的情况,如果本身就是一个慢 SQL 查询,耗时几秒的情况下,这样做反而适得其反。 减少 Component 层次看下面的例子: // Note.server.js// ...imports...function Note({id}) { const note = db.notes.get(id); return <NoteWithMarkdown note={note} />;}// NoteWithMarkdown.server.js// ...imports...function NoteWithMarkdown({note}) { const html = sanitizeHtml(marked(note.text)); return <div ... />;}// client sees:<div> <!-- markdown output here --></div> 虽然在组件层面抽象了 Note 与 NoteWithMarkdown 两个组件,但由于真正 DOM 内容实体只有一个简单的 div,所以在 Server Component 模式下,返回内容就会简化为这个 div,而无需包含那两个抽象的组件。 限制Server Component 模式下有三种组件,分别是 Server Component、Client Component、Shared Component,其各自都有一些使用限制,如下: Server Component: ❌ 不能用 useState、useReducer 等状态存储 API。 ❌ 不能用 useEffect 等生命周期 API。 ❌ 不能用 window 等仅浏览器支持的 API。 ❌ 不能用包含了上面情况的自定义 Hooks。 ✅ 可无缝访问服务端数据、API。 ✅ 可渲染其他 Server/Client Component Client Component: ❌ 不能引用 Server Component。 ✅ 但可以在 Server Component 中出现 Client Component 调用 Server Component 的情况,比如 <ClientTabBar><ServerTabContent /></ClientTabBar>。 ❌ 不能调用服务端 API 获取数据。 ✅ 可以用一切 React 与浏览器完整能力。 Shared Component: ❌ 不能用 useState、useReducer 等状态存储 API。 ❌ 不能用 useEffect 等生命周期 API。 ❌ 不能用 window 等仅浏览器支持的 API。 ❌ 不能用包含了上面情况的自定义 Hooks。 ❌ 不能引用 Server Component。 ❌ 不能调用服务端 API 获取数据。 ✅ 可以同时在服务器与客户端使用。 其实不难理解,因为 Shared Component 同时在服务器与客户端使用,因此兼具它们的劣势,带来的好处就是更强的复用性。 精读要快速理解 Server Component,我觉得最好也是最快的方式,就是找到其与十年前 PHP + HTML 的区别。看下面代码: $link = mysqli_connect('localhost', 'root', 'root');mysql_select_db('test', $link);$result = mysql_query('select * from table');while($row=mysql_fetch_assoc($result)){ echo "<span>".$row["id"]."</span>";} 其实 PHP 早就是一套 “Server Component” 方案了,在服务端直接访问 DB、并返回给客户端 DOM 片段。 React Server Component 在折腾了这么久后,可以发现,最大的区别是将返回的 HTML 片段改为了 DSL 结构,这其实是浏览器端有一个强大的 React 框架在背后撑腰的结果。而这个带来的好处除了可以让我们在服务端能继续写 React 语法,而不用退化到 “PHP 语法” 以外,更重要的是组件状态得以维持。 另一个重要不同是,PHP 无法解析现在前端生态下任何 npm 包,所以无从解析模块化的前端代码,所以虽然直觉上感觉 PHP 效率与 Server Component 并无区别,但背后的成本是得写另一套不依赖任何 npm 包、JSX 的语法来返回 HTML 片段,Server Component 大部分特性都无法享受到,而且代码也无法复用。 所以,本质上还是 HTML 太简单了,无法适应如今前端的复杂度,而普通后端框架虽然后端能力强大,但在前端能力上还停留在 20 年前(直接返回 DOM),唯有 Node 中间层方案作为桥梁,才能较好的衔接现代后端代码与现代前端代码。 PHP VS Server Component其实在 PHP 时代,前后端都可以做模块化。后端模块化显而易见,因为可以将后端代码模块化的开发,最后打包至服务器运行。前端也可以在服务端模块化开发,只要我们将前后端代码剥离出来即可,下图青色是后端部分,红色是前端部分: 但这有个问题,因为后端服务对浏览器来说是无状态的,所以后端模块化本身就符合其功能特征,但前端页面显示在用户浏览器,每次都通过路由跳转到新页面,显然不能最大程度发挥客户端持续运行的优势,我们希望在保持前端模块化的基础上,在浏览器端有一个持续运行的框架优化用户体验,因此 Server Component 其实做了下面的事情: 这样做有两大好处: 兼顾了 PHP 模式下优势,即前后端代码无缝混合,带来一系列体验和能力增强。 前后端还是各自模块化编写,图中红色部分是随前端项目整体打包的,因此开发还是保留了模块化特点,且在浏览器上还保持了 React 现代框架运行,无论是单页还是数据驱动等特性都能继续使用。 总结Server Component 还没有成熟,但其理念还是很靠谱的。 想要同时实现 “用户体验、可维护性、性能”,重后端,或者重前端的方案都不可行,只有在前后端取得一种平衡才能达到。Server Component 表达了一种职业发展理念,即未来前后端还是会走向全栈,这种全栈是前后端同时做深,从而让程序开发达到纯前端或纯后端无法达到的高度。 2021 年国内开发环境依然比较落后,所谓全栈,往往指的是 “前后端都懂一点”,各端都做不深,难以孵化出 Server Component 这种概念。当然,这也是我们继续向世界学习的动力。 也许 PHP 与 Server Component 的区别,就是检验一个人是真全栈还是伪全栈的试金石,快去问问你的同事吧! 讨论地址是:精读《React Server Component》· Issue ##311 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React useEvent RFC》","path":"/wiki/WebWeekly/前沿技术/《React useEvent RFC》.html","content":"当前期刊数: 240 useEvent 要解决一个问题:如何同时保持函数引用不变与访问到最新状态。 本周我们结合 RFC 原文与解读文章 What the useEvent React hook is (and isn’t) 一起了解下这个提案。 借用提案里的代码,一下就能说清楚 useEvent 是个什么东西: function Chat() { const [text, setText] = useState(''); // ✅ Always the same function (even if `text` changes) const onClick = useEvent(() => { sendMessage(text); }); return <SendButton onClick={onClick} />;} onClick 既保持引用不变,又能在每次触发时访问到最新的 text 值。 为什么要提供这个函数,它解决了什么问题,在概述里慢慢道来。 概述定义一个访问到最新 state 的函数不是什么难事: function App() { const [count, setCount] = useState(0) const sayCount = () => { console.log(count) } return <Child onClick={sayCount} />} 但 sayCount 函数引用每次都会变化,这会直接破坏 Child 组件 memo 效果,甚至会引发其更严重的连锁反应(Child 组件将 onClick 回调用在 useEffect 里时)。 想要保证 sayCount 引用不变,我们就需要用 useCallback 包裹: function App() { const [count, setCount] = useState(0) const sayCount = useCallback(() => { console.log(count) }, [count]) return <Child onClick={sayCount} />} 但即便如此,我们仅能保证在 count 不变时,sayCount 引用不变。如果想保持 sayCount 引用稳定,就要把依赖 [count] 移除,这会导致访问到的 count 总是初始值,逻辑上引发了更大问题。 一种无奈的办法是,维护一个 countRef,使其值与 count 保持同步,在 sayCount 中访问 countRef: function App() { const [count, setCount] = useState(0) const countRef = React.useRef() countRef.current = count const sayCount = useCallback(() => { console.log(countRef.current) }, []) return <Child onClick={sayCount} />} 这种代码能解决问题,但绝对不推荐,原因有二: 每个值都要加一个配套 Ref,非常冗余。 在函数内直接同步更新 ref 不是一个好主意,但写在 useEffect 里又太麻烦。 另一种办法就是自创 hook,如 useStableCallback,这本质上就是这次提案的主角 - useEvent: function App() { const [count, setCount] = useState(0) const sayCount = useEvent(() => { console.log(count) }) return <Child onClick={sayCount} />} 所以 useEvent 的内部实现很可能类似于自定义 hook useStableCallback。在提案内也给出了可能的实现思路: // (!) Approximate behaviorfunction useEvent(handler) { const handlerRef = useRef(null); // In a real implementation, this would run before layout effects useLayoutEffect(() => { handlerRef.current = handler; }); return useCallback((...args) => { // In a real implementation, this would throw if called during render const fn = handlerRef.current; return fn(...args); }, []);} 其实很好理解,我们将需求一分为二看: 既然要返回一个稳定引用,那最后返回的函数一定使用 useCallback 并将依赖数组置为 []。 又要在函数执行时访问到最新值,那么每次都要拿最新函数来执行,所以在 Hook 里使用 Ref 存储每次接收到的最新函数引用,在执行函数时,实际上执行的是最新的函数引用。 注意两段注释,第一个是 useLayoutEffect 部分实际上要比 layoutEffect 执行时机更提前,这是为了保证函数在一个事件循环中被直接消费时,不可能访问到旧的 Ref 值;第二个是在渲染时被调用时要抛出异常,这是为了避免 useEvent 函数被渲染时使用,因为这样就无法数据驱动了。 精读其实 useEvent 概念和实现都很简单,下面我们聊聊提案里一些有意思的细节吧。 为什么命名为 useEvent提案里提到,如果不考虑名称长短,完全用功能来命名的话,useStableCallback 或 useCommittedCallback 会更加合适,都表示拿到一个稳定的回调函数。但 useEvent 是从使用者角度来命名的,即其生成的函数一般都被用于组件的回调函数,而这些回调函数一般都有 “事件特性”,比如 onClick、onScroll,所以当开发者看到 useEvent 时,可以下意识提醒自己在写一个事件回调,还算比较直观。(当然我觉得主要原因还是为了缩短名称,好记) 值并不是真正意义上的实时虽然 useEvent 可以拿到最新值,但和 useCallback 拿 ref 还是有区别的,这个差异体现在: function App() { const [count, setCount] = useState(0) const sayCount = useEvent(async () => { console.log(count) await wait(1000) console.log(count) }) return <Child onClick={sayCount} />} await 前后输出值一定是一样的,在实现上,count 值仅是调用时的快照,所以函数内异步等待时,即便外部又把 count 改了,当前这次函数调用还是拿不到最新的 count,而 ref 方法是可以的。在理解上,为了避免夜长梦多,回调函数尽量不要写成异步的。 useEvent 也救不了手残如果你坚持写出 onSomething={cond ? handler1 : handler2} 这样的代码,那么 cond 变化后,传下去的函数引用也一定会变化,这是 useEvent 无论如何也避免不了的,也许解救方案是 Lint and throw error。 其实将 cond ? handler1 : handler2 作为一个整体包裹在 useEvent 就能解决引用变化的问题,但除了 Lint,没有人能防止你绕过它。 可以用自定义 hook 代替 useEvent 实现吗?不能。虽然提案里给了一个近似解决方案,但实际上存在两个问题: 在赋值 ref 时,useLayoutEffect 时机依然不够提前,如果值变化后立即访问函数,拿到的会是旧值。 子组件 layout effect 在父组件之前执行,拿到的也是旧值。 生成的函数被用在渲染并不会给出错误提示。 总结useEvent 显然又给 React 增加了一个官方概念,在结结实实增加了理解成本的同时,也补齐了 React Hooks 在实践中缺失的重要一环,无论你喜不喜欢,问题就在那,解法也给了,挺好。 讨论地址是:精读《React useEvent RFC》· Issue ##415 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React 代码整洁之道》","path":"/wiki/WebWeekly/前沿技术/《React 代码整洁之道》.html","content":"当前期刊数: 34 本期精读的文章是:React 代码整洁之道。 1 引言编程也是艺术行为,当我们思考代码复用、变量命名时,就是在进行艺术思考。 可能这篇文章没法提高面试能力、开发效率,因为涉及的内容都是 “软能力”。但如果与我一样,时常害怕自己代码不够优雅,那就在茶余饭后看看这篇文章,也许,可以解决一部分你心中的困惑。 2 内容概要作者整理了几个好的思维习惯,尝试认同它,再看看如何实践。 不冗余避免重复代码段,对 JSX 同理: // Dirtyconst MyComponent = () => ( <div> <OtherComponent type="a" className="colorful" foo={123} bar={456} /> <OtherComponent type="b" className="colorful" foo={123} bar={456} /> </div>);// Cleanconst MyOtherComponent = ({ type }) => ( <OtherComponent type={type} className="colorful" foo={123} bar={456} />);const MyComponent = () => ( <div> <MyOtherComponent type="a" /> <MyOtherComponent type="b" /> </div>); 但也不要过度优化,过度优化和搞破坏没什么区别。 可预测、可测试如果使用 Jest 测试,可以考虑截图测试插件:Jest Image Snapshot 自我解释尽可能减少代码中的注释。可以通过让变量名更语义化、只注释复杂、潜在逻辑,来减少注释量,同时也提高了可维护性,毕竟不用总在代码与注释之间同步了。 // Dirtyconst fetchUser = (id) => ( fetch(buildUri`/users/${id}`) // Get User DTO record from REST API .then(convertFormat) // Convert to snakeCase .then(validateUser) // Make sure the the user is valid);// Cleanconst fetchUser = (id) => ( fetch(buildUri`/users/${id}`) .then(snakeToCamelCase) .then(validateUser)); 上面的例子,方法 convertFormat 含义是 “转换格式”,太过于笼统,以至于不得不添加注释。如果换成 snakeToCamelCase (转换为驼峰风格),这个名字就解释了自己的功能。 斟酌变量名 布尔值或者返回值是布尔类型的函数,命名以 is has should 开头: // Dirtyconst done = current >= goal;// Cleanconst isComplete = current >= goal; 函数以其效果命名,而不是怎么做的来命名 // Dirtyconst loadConfigFromServer = () => { ...};// Cleanconst loadConfig = () => { ...}; 很多时候我也经常犯这种错误,毕竟写代码的时候总要考虑实现,一不小心就将实现的方式带入了函数名中。 遵循设计模式这里的设计模式,并不是指工程上的,而是更广泛的开发中的设计模式,比如 “你应该使用 tslint 校验代码格式” “typescript 开启 stricts 模式” “编写一个 React 函数,应当将 React 作为 peerDependency” 等等(当然,不要随意设置 peerDependency 也是一种江湖约定)。 对于 React,遵循以下几个最佳实践: 单一责任原则, 确保每个功能都完整完成一项功能,比如更细粒度的组件拆分,同时也更利于测试。 不要把组件的内部依赖强加给使用方。 lint 规则尽量严格。 根据我的体验,尤为痛恨违背第二条的组件,比如当 React 组件使用了数据流,但必须依赖项目初始化该数据流才能执行,如果不是被生活所迫,我才不会使用这种组件。 第三条也一样,如果你是一个知名轮子的作者,请毫不留情的使用最严格的 lint 规则。如果使用者的 lint 规则比你还严格,你的组件将无法使用。 考虑到以上几点并不会降低编码速度编写整洁的代码在开始一定会放慢开发速度,因为你需要转变自己的思维模式,但随着不断迭代,它的带来的效率提升会逐渐弥补前面的损失,并不断带来开发效率的提升。 写组件库也是同理,用脚写固然能快速完成,但后续往往要重构掉。我很羡慕函数式工作环境的开发者,他们几乎只要为每个功能写一遍,剩下的就是记住并调用它。 在 React 中的实践略过几个没意思的例子。。 在 React 使用 defaultProps 代替在代码中动态判断 显然,利用 React 组件的规则,将入参的默认值预先定义好是最高效的。但顺带一句,如果在 ts 最严格的 stricts 模式里,依然会报错:变量可能未定义。这是因为 defaultProps 依然是个约定,而不能通过强类型推导出,目前还没有更优雅的解决思路。 渲染与判断逻辑分开 // Dirtyclass User extends Component { state = { loading: true }; render() { const { loading, user } = this.state; return loading ? <div>Loading...</div> : <div> <div> First name: {user.firstName} </div> <div> First name: {user.lastName} </div> ... </div>; } componentDidMount() { fetchUser(this.props.id) .then((user) => { this.setState({ loading: false, user })}) }}// Cleanimport RenderUser from './RenderUser';class User extends Component { state = { loading: true }; render() { const { loading, user } = this.state; return loading ? <Loading /> : <RenderUser user={user} />; } componentDidMount() { fetchUser(this.props.id) .then(user => { this.setState({ loading: false, user })}) }} 逻辑与渲染分离,便于维护,其次便于测试。 当然有人可能会问 “就算逻辑与渲染分离了,但组成的大组件不还是逻辑耦合的吗”,对,这就像函数单一指责一样,render 是过程代码,但过程中涉及到的逻辑,分配给单一指责的渲染组件渲染,如果把逻辑与渲染写在一起,就类似一个函数把功能全做完,这样做显然诸事不利。 提倡无状态组件// Dirtyclass TableRowWrapper extends Component { render() { return ( <tr> {this.props.children} </tr> ); }}// Cleanconst TableRowWrapper = ({ children }) => ( <tr> {children} </tr>); 性能是一个原因,原文比较强调性能与代码量。我认为 stateless 重点在于阻碍了内部状态的使用,移除了生命周期,所以提高了组件的可控性,也就拓宽了组件的使用场景。 受控与非受控组件都有其适用场景,像非常基础的底层组件库,往往倾向提供两套机制,通过 value 与 defaultValue 决定是否受控。拥有这样能力的组件源码就没法通过 stateless 写,所以无状态组件的面向对象并不是基础底层组件,而且这些基础组件也没必要完全无状态,两者都提供是最好的选择。 说到这,也就是考虑到成本问题,那么无状态组件也就更适合上层具有业务含义的组件。页面级别组件状态太多,不适合,所以我认为无状态组件比较适合 Wrapper 层,也就是对基础组件包裹并增强业务能力这一层。 解构// Dirtyconst splitLocale = locale.split('-');const language = splitLocale[0];const country = splitLocale[1];// Cleanconst [language, country] = locale.split('-'); ES6 新增的语法可以提升不少代码可读性,需要刻意训练去培养这个习惯。 3 精读本周精读已经融于内容概要中 ^_^。最后推荐在 typescript 中开启 strict 模式,强制使用良好的开发习惯。 // BadonChange(value => console.log(value.name))// DirtyonChange((value) => { if (!value) { value = {} } console.log(value.name)})// CleanonChange((value = {}) => console.log(value.name))// CleanonChange(value => console.log(value?.name)) 不要信任任何回调函数给你的变量,它们随时可能是 undefined,使用初始值是个不错的选择,但有的时候初始值没什么意义,使用 ?. 语法可以安全的访问属性,是时候抛弃 _.get 了。 4. 总结我要回去重构代码了,你呢? 更多讨论 讨论地址是:精读《React 代码整洁之道》 · Issue ##46 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《React 八种条件渲染》","path":"/wiki/WebWeekly/前沿技术/《React 八种条件渲染》.html","content":"当前期刊数: 61 1 引言本期精读的文章是:8 React conditional rendering methods 介绍了八种 React 条件渲染方式。 模版条件渲染非常常见,遇到的时候往往会随机选择一种方式使用,那么怎么写会有较好的维护性呢?先一起了解下有哪八种条件渲染方式吧! 2 概述IF/ELSE既然 JSX 支持 js 与 html 混写,那么交替使用就能解决条件渲染的问题: function render() { if (renderComponent1) { return <Component1 />; } else { return <div />; }} return null如果不想渲染空元素,最好使用 null 代替空的 div: function render() { if (renderComponent1) { return <Component1 />; } else { return null; }} 这样对 React 渲染效率有提升。 组件变量将组件赋值到变量,就可以在 return 前任意修改它了。 function render() { let component = null; if (renderComponent1) { component = <Component1 />; } return component;} 三元运算符三元运算符的语法如下: condition ? expr_if_true : expr_if_false 用在 JSX 上也很方便: function render() { return renderComponent1 ? <Component1 /> : null;} 但三元运算符产生嵌套时,理解成本会变得很高。 &&这个是最常用了,因为代码量最少。 function render() { return renderComponent1 && <Component1 />;} IIFEIIFE 含义是立即执行函数,也就是如下代码: (function myFunction(/* arguments */) { // ...})(/* arguments */); 当深陷 JSX 代码中,又想写一大块逻辑时,除了回到上方,还可以使用 IIFE: function render() { return ( <div> {(() => { if (renderComponent1) { return <Component1 />; } else { return <div />; } })()} </div> );} 子组件这是 IIFE 的变种,也就是把这段立即执行函数替换成一个普通函数: function render() { return ( <div> <SubRender /> </div> );}function SubRender() { if (renderComponent1) { return <Component1 />; } else { return <div />; }} IF 组件做一个条件渲染组件 IF 代替 js 函数的 if: <If condition={true}> <span>Hi!</span></If> 这个组件实现也很简单 const If = props => { const condition = props.condition || false; const positive = props.then || null; const negative = props.else || null; return condition ? positive : negative;}; 高阶组件高阶组件,就是返回一个新组件的函数,并且接收一个组件作为参数。 那么我们就能在高阶组件里写条件语句,返回不同的组件即可: function higherOrderComponent(Component) { return function EnhancedComponent(props) { if (condition) { return <AnotherComponent {...props} />; } return <Component {...props} />; };} 3 精读这么多方法都能实现条件渲染,那么重点在于可读性与可维护性。 比如通过调用函数实现组件渲染: <div>{renderButton()}</div> 看上去还是比较冗余,如果使用 renderButton getter 定义,我们就可以这么写它: <div>{button}</div> 其实我们想要的就是 button,而不是 renderButton。那么还可以进一步,干脆封装成 JSX 组件: <div> <Button /></div> 是否要付出这些努力,取决于应用的复杂度。如果应用复杂度非常高,那你应当尽量使用最后一种封装,让每个文件的逻辑尽量独立、简单。 如果应用复杂度比较低,那么注意不要过度封装,以免把自己绕进去。 所以看来这又是一个没有固定答案的问题,选择何种方式封装,取决于应用复杂度。 应用复杂度对任何代码封装,都会增加这段 连接逻辑 的复杂度。 假定无论如何代码的复杂度都是恒定不变的,下面这段代码,连接复杂度为 0,而对于 render 函数而言,逻辑复杂度是 100: function render() { if (renderComponent) { return isOk ? <Component1 /> : <Component2 />; } else { return <div />; }} 下面这段代码拆成了两个函数,逻辑复杂度对 render SubComponent 来说都是 50,但连接复杂度是 50: function render() { if (renderComponent) { return <SubComponent>; } else { return <div />; }}function SubComponent() { return isOk ? <Component1 /> : <Component2 />} 可以看到,我们通过函数拆分,降低了每个函数的逻辑复杂度,但却提高了连接复杂度。 下面来做一个比较,我们假设一个正常的程序员,可以一次性轻松记忆 10 个函数。如果再多,函数之间的调用关系就会让人摸不着头脑。 应用较小时在应用代码量比较小时,假设一共有 10 个函数,如果做了逻辑抽象,拆分出了 10 个子函数,那么总逻辑复杂度不变,函数变成了 20 个。 此时小王要修改此项目,他需要找到关键代码的位置。 如果没有做逻辑抽象,小王一下子就记住了 10 个函数,并且很快完成了需求。 如果应用做了逻辑抽象,他需要理解的逻辑复杂度是不变的,但是要读的函数变成了 20 个。小王需要像侦探一样在线索中不断跳转,他还是只找了 10 个关键函数,但一共也就 20 个函数,逻辑并不复杂,这值得吗? 小王心里可能会嘀咕:简单的逻辑瞎抽象,害我文件找了半天! 应用较大时此时应用代码量比较大,假设一共有 500 个函数,我们不考虑抽象后带来的复用好处,假设都无法复用,那么做了逻辑抽象后,那么总逻辑复杂度不变,函数变成了 1000 个。 此时小王接到了需求,终于维护了一个大项目。 小王知道这个项目很复杂,从一开始就没觉得能理解项目全貌,所以把自己当作一名侦探,准备一步步探索。 现在有两种选择,一种是在未做逻辑抽象时探索,一种是在做过逻辑抽象后探索。 如果没做逻辑抽象,小王需要面对 500 个这种函数: function render() { if (renderComponent) { return isOk ? <Component1 /> : <Component2 />; } else { return isReady ? <Component3 /> : <Component4 />; }} 如果做了逻辑抽象,小王需要面对 1000 个这种函数: function render() { if (renderComponent) { return <Component1And2 />; } else { return <Component3And4 />; }} 在项目庞大后,总函数数量并不会影响对线索的查找,而总线索深度也几乎总是固定的,一般在 5 层左右。 小王理解 5 个或 10 个函数成本都差不多,但没有做逻辑抽象时,这 5 个函数各自参杂了其他逻辑,反而影响对函数的理解。 这时做逻辑抽象是合适的。 4 总结所以总的来说,笔者更倾向使用子函数、子组件、IF 组件、高阶组件做条件渲染,因为这四种方式都能提高程序的抽象能力。 往往抽象后的代码会更具有复用性,单个函数逻辑更清晰,在切面编程时更利于理解。 当项目很简单时,整个项目的理解成本都很低,抽象带来的复杂度反而让项目变成了需要切面编程的时候,就得不偿失了。 总结一下: 当项目很简单,或者条件渲染的逻辑确认无法复用时,推荐在代码中用 && 或者三元运算符、IIFE 等直接实现条件渲染。 当项目很复杂时,尽量都使用 子函数、子组件、IF 组件、高阶组件 等方式做更有抽象度的条件渲染。 在做逻辑抽象时,考虑下项目的复杂度,避免因为抽象带来的成本增加,让本可以整体理解的项目变得支离破碎。 5 更多讨论 讨论地址是:精读《React 八种条件渲染》 · Issue ##90 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《React 性能调试》","path":"/wiki/WebWeekly/前沿技术/《React 性能调试》.html","content":"当前期刊数: 149 1 引言在数据中台做 BI 工具经常面对海量数据的渲染处理,除了组件本身性能优化之外,经常要排查整体页面性能瓶颈点,尤其是维护一些性能做得并不好的旧代码时。 React 性能调试是面对这种问题的必修课,借助 Profiling React.js Performance 这篇文章一起学习一下这个技能吧。 2 精读本文介绍了众多性能检测工具与方法。 React ProfilerProfiler 这个 API 是一种运行时 Debug 的补充,可以通过其 callback 拿到组件渲染信息,用法如下: 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 查看更直观更简洁的渲染耗时: 其中 Ranked 可以展示按照渲染耗时排序后的结果,Interations 需要配合 Tracing API 使用,在后面会提到。 Tracing API利用 scheduler/tracing 提供的 trace API,我们可以记录某个动作的耗时,比如 “点击添加按钮收藏一个电影” 耗时多久: 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 中可以看到动作触发的耗时: 这个动作还可以是渲染,比如可以记录 ReactDOM 渲染的耗时: import { unstable_trace as trace } from "scheduler/tracing";trace("initial render", performance.now(), () => { ReactDom.render(<App />, document.getElementById("app"));}); 甚至还可以追踪异步的耗时: import { unstable_trace as trace, unstable_wrap as wrap,} from "scheduler/tracing";trace("Some event", performance.now(), () => { setTimeout( wrap(() => { // 异步操作 }) );}); 有了 Profiler 与 trace 这两件武器,我们可以监控任意元素的渲染耗时与交互耗时,几乎可以涵盖所有性能监控需要。 Puppeteer我们还可以利用 Puppeteer 实现自动化操作并打印报告: 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 面板,就会得到一份自动的性能检测报告: 这张图相当重要,是浏览器综合运行开销分析的利器,最上面分为 4 个部分: FPS:每秒帧数,绿色竖线越高表示 FPS 越高,出现红线则表示出现了卡顿。 CPU:CPU 资源,用面积图展示消耗 CPU 资源的事件。 NET:网络消耗,每条横杠表示一种资源的加载。 HEAP:内存水位,由于短时间内看不出来是否会内存溢出,一般只用来简单看看内存消耗是否符合预期,对于内存溢出的检测需要用持续监控上报的方式。 下面会有一张 Network 详细图解,比如这张图: 细线表示等待的时间,粗线表示实际加载的情况,其中浅色部分表示服务器等待时间,即从发送下载请求到服务器响应第一个字节的时间。这部分可以看出资源并行加载阻塞情况以及资源服务器响应时间是否存在问题。 Timings 展示了几个重要时间节点,这里列举一部分: FP:First Paint,第一次绘制。 FCP:First Contentful Paint,第一次内容绘制。 LCP:Largest Contentful Paint,最大内容绘制。 DCL:Document Content Loaded,DOM 内容加载完毕。 再下面是 JS 计算消耗,用了一张火焰图,火焰图是性能分析的常用可视化工具。以下面这张图为例: 看火焰图首先看跨度最长的函数,也就是最长的那条线,这是最耗时的部分,从左到右是浏览器脚本的调用顺序,从上到下是函数嵌套的顺序。 我们可以看到鼠标位置的 34 这个函数虽然长,但并不是性能瓶颈,因为下面执行的 n 函数长度和它一样,表示 34 函数的性能几乎无损耗,其性能由其调用的 n 函数决定。 我们可以利用这种方式一步步排查到叶子结点,找到对性能影响最大的元子函数。 User Timing API我们还可以利用 performance.mark 自定义性能检测节点: // Record the time before running a taskperformance.mark("Movies:updateStart");// Do some work// Record the time after running a taskperformance.mark("Movies:updateEnd");// Measure the difference between the start and end of the taskperformance.measure("moviesRender", "Movies:updateStart", "Movies:updateEnd"); 这些节点可以在上面介绍的 Performance 面板中展示出来用于自定义分析。 3 总结利用 Performance 进行通用性能分析,利用 React Profiler 进行 React 定制性能分析,这两个结合在一起几乎可以完成任何性能检测。 一般来说,首先应该用 React Profiler 进行 React 层面的问题筛查,这样更直观,更容易定位问题。如果某些问题跳出了 React 框架范围,或者不再能以组件粒度进行度量,我们可以回到 Performance 面板进行通用性能分析。 讨论地址是:精读《React 性能调试》 · Issue ##247 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《React 的多态性》","path":"/wiki/WebWeekly/前沿技术/《React 的多态性》.html","content":"当前期刊数: 63 1 引言本周精读的文章是:surprising-polymorphism-in-react-applications,看看作者是如何解释这个多态性含义的。 读完文章才发现,文章标题改为 Redux 的多态性更妥当,因为整篇文章都在说 Redux,而 Redux 使用场景不局限于 React。 2 概述Redux immutable 特性可能产生浏览器无法优化的性能问题,也就是浏览器无法做 shapes 优化,也就是上一篇精读《JS 引擎基础之 Shapes and Inline Caches》 里提到的。 先看看普通的 redux 的 reducer: const todo = (state = {}, action) => { switch (action.type) { case "ADD_TODO": return { id: action.id, text: action.text, completed: false }; case "TOGGLE_TODO": if (state.id !== action.id) { return state; } return Object.assign({}, state, { completed: !state.completed }); default: return state; }}; 我们简化一下使用场景,假设基于这个 reducer todo,生成了两个新 store s1 s2: const s1 = todo( {}, { type: "ADD_TODO", id: 1, text: "Finish blog post" });const s2 = todo(s1, { type: "TOGGLE_TODO", id: 1}); 看上去很常见,也的确如此,我们每次 dispatch 都会根据 reducer 生成新的 store 树,而且是一个新的对象。然而对 js 引擎而言,这样的代码可能做不了 Shapes 优化(关于 Shapes 优化建议阅读上一期精读 Shapes 优化),也就是最需要做优化的全局 store,在生成新 store 时无法被浏览器优化,这个问题很容易被忽视,但的确影响不小。 至于为什么会阻止 js 引擎的 shapes 优化,看下面的代码: // transition-trees.jslet a = {x:1, y:2, z:3};let b = {};b.x = 1;b.y = 2;b.z = 3;console.log("a is", a);console.log("b is", b);console.log("a and b have same map:", %HaveSameMap(a, b)); 通过 node --allow-natives-syntax test.js 执行,通过调用 node 原生函数 %HaveSameMap 判断这种情况下 a 与 b 是否共享一个 shape(v8 引擎的 Shape 实现称为 Map)。 结果是 false,也就是 js 引擎无法对 a b 做 Shapes 优化,这是因为 a 与 b 对象初始化的方式不同。 同样,在 Redux 代码中常用的 Object.assign 也有这个问题: 因为新的对象以 {} 空对象作为最初状态,js 引擎会为新对象创建 Empty Shape,这与原对象的 Shape 一定不同。 顺带一提 es6 的解构语法也存在同样的问题,因为 babel 将解构最终解析为 Object.assign: 对这种尴尬的情况,作者的建议是对所有对象赋值时都是用 Object.assign 以保证 js 引擎可以做 Shapes 优化: let a = Object.assign({}, {x:1, y:2, z:3});let b = Object.assign({}, a);console.log("a is", a);console.log("b is", b);console.log("a and b have same map:", %HaveSameMap(a, b)); // true 3 精读这篇文章需要与上一篇 精读《JS 引擎基础之 Shapes and Inline Caches》 连起来看容易理解。 作者描述的性能问题是引擎级别的 Shapes 优化问题,读过上篇精读就很容易知道,只有相同初始化方式的对象才被 js 引擎做优化,而 Redux 频繁生成的 immutable 全局 store 是否能被优化呢?答案是“往往不能”,因为 immutable 赋值问题,我们往往采用 Object.assign 或者解构方式赋值,这种方式产生的新对象与原对象的 Shape 不同,导致 Shape 无法复用。 这里解释一下疑惑,为什么说 immutable 对象之间也要优化呢?这不是两个不同的引用吗?这是因为 js 引擎级别的 Shapes 优化就是针对不同引用的对象,将对象的结构:Shape 与数据分离开,这样可以大幅优化存储效率,对数组也一样,上一篇精读有详细介绍。 所以笔者更推荐使用比如 immutable-js 这种库操作 immutable 对象,而不是 Object.assign,因为封装库内部是可能通过统一对象初始化方式利用 js 引擎进行优化的。 4 总结原文提到的多态是指多个相同结构对象,被拆分成了多个 Shape;而单态是指这些对象可以被一个 Shape 复用。 笔者以前也经历过从 Object.assign 到 Immutablejs 库,最后又回到解构新语法的经历,觉得在层级不深情况下解构语法可以代替 Immutablejs 库。 通过最近两篇精读的分析,我们需要重新思考这样做带来的优缺点,因为在 js 环境中,Object.assign 的优化效率比 Immutablejs 库更低。 最后,也完全没必要现在就开始重构,因为这只是 js 运行环境中很小一部分影响因素,比如为了引入 Immutablejs 让你的网络延时增加了 100%?所以仅在有必要的时候优化它。 5 更多讨论 讨论地址是:精读《React 的多态性》 · Issue ##92 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《React\"s new Context API》","path":"/wiki/WebWeekly/前沿技术/《React's new Context API》.html","content":"当前期刊数: 45 本周精读的文章是 React’s new Context API。 1 引言React 即将推出全新的 Context api,让我们一起看看。 2 概述像 react-redux、mobx-react、react-router 都使用了旧 Context api,可谓 context 无处不在。 新版 Context 语法是这样的: const ThemeContext = React.createContext('light')class ThemeProvider extends React.Component { state = {theme: 'light'} render() { return ThemeContext.provide(this.state.theme, this.props.children) }}const ThemeConsumer = ({children}) => ThemeContext.consume(children)class App extends React.Component { render() { <ThemeProvider> <ThemeConsumer>{val => <div>{val}</div>}</ThemeConsumer> </ThemeProvider> }} React.createContext 创建新的 Context 并赋初始值。返回的对象包含 provider 与 consumer。 provide 是一个容器,它所有的子元素都能通过 consumer 访问到这个 Context 的值。 其实这种思想在 react-broadcast 已经被实现,现在变成了官方 API。 当然如果多个 Context 同时存在,可能会出现 jsx 的嵌套地狱,不过这个情况可以通过拆分模块,或者以如下方式定义多重 Consumer 来解决: function ThemeAndLanguageConsumer({children}) { return ( <LanguageConsumer> {language => ( <ThemeConsumer> {theme => children({language, theme})} </ThemeConsumer> )} </LanguageConsumer> )}class App extends React.Component { render() { <AppProviders> <ThemeAndLanguageConsumer> {({theme, language}) => <div>{theme} and {language}</div>} </ThemeAndLanguageConsumer> </AppProviders> }} 3 精读最大的问题是,React17 会废除旧 Context 这个 api,许许多多的库需要升级,不过到时候也应该会出现 codemod 自动更新吧。 从 15.0 升级到 16.0 时因为项目中大量使用 React.PropTypes 的地方需要重构,从 16.0 升级到 17.0 时,就不是项目要升级了,而是比如 react-redux 这类库要偷偷升级 context 的用法,可见 React 大版本间生态完全兼容是不可能了。 Context 多层嵌套问题一种方式是通过构造原文中描述的 ThemeAndLanguageConsumer 聚合 Consumer 解决,也可以使用比如 react-context-composer 这种库优雅的解决。摘自 如何解读 react 16.3 引入的新 context api@淡苍 绕过 shouldComponentUpdate像 redux、mobx - react 这些库,都使用了 forceUpdate 绕过 shouldComponentUpdate 机制。原因是这些全局状态管理工具接管了自己的组件更新时机,纵使保留组件原本的更新机制,但当数据流发生变化时,需要绕过一切阻碍,直接触发目标组件的一整套渲染生命周期。 好在新的 Context api 也拥有如此特性,可以在 context 改变时,直接更新即使 shouldComponentUpdate: false 的组件。 是否还需要 redux正如很多人说的,这要看我们是怎么使用 redux 了。 在之前一篇精读 前端数据流哲学 中,我提到了 redux、mobx、rxjs 这三大流派的竞争力。 其中 redux 其实是最没有竞争力的数据流框架,我们暂且抛开函数式和优雅性不说,从功能上说,看看 redux 到底做了啥?利用 react 特性,利用全局数据流解决组件间数据通信问题。抛开 react-redux,只看 redux,剩下不能再简单的 Action 与 Reducer。 再看 mobx,稍微好一点,其主打能力是自动追踪变量引用,当变量被修改时自动刷新视图,可见它的竞争力不仅仅在组件数据的打通,自动绑定带来的效率提升是一大亮点。 最后是 rxjs,其主打能力压根不在 react,核心竞争力在数据处理能力,与数据源的抽象,做到了副作用隔离在数据处理流程之外。 可见技术框架也是如此,核心竞争力在哪,未来就在哪。 是否 flux 多 store 思想再度崛起?我觉得几乎不可能。 新的 Context API 给了开发者创造多个 context 的能力,可不是在项目中创建多个 store,制造混乱的呀。我们之前说过,除了数据流框架,像 react-router,或者一些国际化组件也会使用到 context 传递数据,本质上是需要 context 解决对数据透传的控制能力。 举个例子,国际化参数可以让组件一层一层透传,但调用到 node_modules 组件时,我们无法修改其 dom 结构,怎么让这个参数强制透传呢?所以必须使用 context 对所有需要国际化的组件注入 props,而这个注入变量由顶层 Provider 控制。比如 antd local-provider。 然而共享一个 context 可能会冲突啊,现在你创建你的,我创建我的,咱们都互不影响,未来数据流框架大家会用的更爽,甚至一个项目可以同时并存多套数据流框架,因为互不影响嘛。 4 总结然而新的 Context api 并不是银弹,无法解决所有问题,更不能解决业务组件与项目数据流绑定,导致的耦合问题。 因为不论怎么组织数据流,官方提供了怎样的 api,只要我们想给组件注入数据,那么注入的那个节点就一定依赖一个特性的项目环境,或者变量,比如某个 consumer。 数据流框架也无法被取代,因为数据流框架的核心竞争力不在数据的依赖注入上,而是对数据的处理。 当然这次变化带来最乐观的改变是,react 拥有了一个稳定好用的依赖注入官方 api,在处理国际化这种需要拿 Context 小用一下的场景,可以不依赖第三方库了!代码如下: const Locale = React.createContext({ text: 'menu'})class MyComponent extends React.Component { render() { <Locale.consume> {text => ( <span>This is the {text}</span> )} </Locale.consume> }}class App extends React.Component { render() { <Locale.provide> <MyComponent /> </Locale.provide> }} 5 更多讨论 讨论地址是:精读《React’s new Context API》 · Issue ##64 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《React 高阶组件》","path":"/wiki/WebWeekly/前沿技术/《React 高阶组件》.html","content":"当前期刊数: 12 本期精读文章是:React Higher Order Components in depth 1 引言高阶组件( higher-order component ,HOC )是 React 中复用组件逻辑的一种进阶技巧。它本身并不是 React 的 API,而是一种 React 组件的设计理念,众多的 React 库已经证明了它的价值,例如耳熟能详的 react-redux。 高阶组件的概念其实并不难,我们能通过类比高阶函数迅速掌握。高阶函数是把函数作为参数传入到函数中并返回一个新的函数。这里我们把函数替换为组件,就是高阶组件了。 const EnhancedComponent = higherOrderComponent(WrappedComponent); 当然了解高阶组件的概念只是万里长征第一步,精读文章在阐述其概念与实现外,也强调了其重要性与局限性,以及与其他方案的比较,让我们一起来领略吧。 2 内容概要高阶组件常见有两种实现方式,一种是 Props Proxy,它能够对 WrappedComponent 的 props 进行操作,提取 WrappedComponent state 以及使用其他元素来包裹 WrappedComponent。Props Proxy 作为一层代理,具有隔离的作用,因此传入 WrappedComponent 的 ref 将无法访问到其本身,需要在 Props Proxy 内完成中转,具体可参考以下代码,react-redux 也是这样实现的。 此外各个 Props Proxy 的默认名称是相同的,需要根据 WrappedComponent 来进行不同命名。 function ppHOC(WrappedComponent) { return class PP extends React.Component { // 实现 HOC 不同的命名 static displayName = `HOC(${WrappedComponent.displayName})`; getWrappedInstance() { return this.wrappedInstance; } // 实现 ref 的访问 setWrappedInstance(ref) { this.wrappedInstance = ref; } render() { return <WrappedComponent { ...this.props, ref: this.setWrappedInstance.bind(this), } /> } }}@ppHOCclass Example extends React.Component { static displayName = 'Example'; handleClick() { ... } ...}class App extends React.Component { handleClick() { this.refs.example.getWrappedInstance().handleClick(); } render() { return ( <div> <button onClick={this.handleClick.bind(this)}>按钮</button> <Example ref="example" /> </div> ); }} 另一种是 Inheritance Inversion,HOC 类继承了 WrappedComponent,意味着可以访问到 WrappedComponent 的 state、props、生命周期和 render 等方法。如果在 HOC 中定义了与 WrappedComponent 同名方法,将会发生覆盖,就必须手动通过 super 进行调用了。通过完全操作 WrappedComponent 的 render 方法返回的元素树,可以真正实现渲染劫持。这种方案依然是继承的思想,对于 WrappedComponent 也有较强的侵入性,因此并不常见。 function ppHOC(WrappedComponent) { return class ExampleEnhance extends WrappedComponent { ... componentDidMount() { super.componentDidMount(); } componentWillUnmount() { super.componentWillUnmount(); } render() { ... return super.render(); } }} 3 精读本次提出独到观点的同学有:@monkingxue @alcat2008 @淡苍 @camsong,精读由此归纳。 HOC 的适用范围对比 HOC 范式 compose(render)(state) 与父组件(Parent Component)的范式 render(render(state)),如果完全利用 HOC 来实现 React 的 implement,将操作与 view 分离,也未尝不可,但却不优雅。HOC 本质上是统一功能抽象,强调逻辑与 UI 分离。但在实际开发中,前端无法逃离 DOM ,而逻辑与 DOM 的相关性主要呈现 3 种关联形式: 与 DOM 相关,建议使用父组件,类似于原生 HTML 编写 与 DOM 不相关,如校验、权限、请求发送、数据转换这类,通过数据变化间接控制 DOM,可以使用 HOC 抽象 交叉的部分,DOM 相关,但可以做到完全内聚,即这些 DOM 不会和外部有关联,均可 DOM 的渲染适合使用父组件,这是 React JSX 原生支持的方式,清晰易懂。最好是能封装成木偶组件(Dumb Component)。HOC 适合做 DOM 不相关又是多个组件共性的操作。如 Form 中,validator 校验操作就是纯数据操作的,放到了 HOC 中。但 validator 信息没有放到 HOC 中。但如果能把 Error 信息展示这些逻辑能够完全隔离,也可以放到 HOC 中(可结合下一小节 Form 具体实践详细了解)。数据请求是另一类 DOM 不相关的场景,react-refetch 的实现就是使用了 HOC,做到了高效和优雅: connect(props => ({ usersFetch: `/users?status=${props.status}&page=${props.page}`, userStatsFetch: { url: `/users/stats`, force: true }}))(UsersList) HOC 的具体实践HOC 在真实场景下的运行非常多,之前笔者在 基于 Decorator 的组件扩展实践 一文中也提过使用高阶组件将更细粒度的组件组合成 Selector 与 Search。结合精读文章,这次让我们通过 Form 组件的抽象来表现 HOC 具有的良好扩展机制。 Form 中会包含各种不同的组件,常见的有 Input、Selector、Checkbox 等等,也会有根据业务需求加入的自定义组件。Form 灵活多变,从功能上看,表单校验可能为单组件值校验,也可能为全表单值校验,可能为常规检验,比如:非空、输入限制,也可能需要与服务端配合,甚至需要根据业务特点进行定制。从 UI 上看,检验结果显示的位置,可能在组件下方,也可能是在组件右侧。 直接裸写 Form,无疑是机械而又重复的。将 Form 中组件的 value 经过 validator,把 value,validator 产生的 error 信息储存到 state 或 redux store 中,然后在 view 层完成显示。这条路大家都是相同的,可以进行复用,只是我们面对的是不同的组件,不同的 validator,不同的 view 而已。对于 Form 而言,既要满足通用,又要满足部分个性化的需求,以往单纯的配置化只会让使用愈加繁琐,我们所需要抽象的是 Form 功能而非 UI,因此通过 HOC 针对 Form 的功能进行提取就成为了必然。 至于 HOC 在 Form 上的具体实现,首先将表单中的组件(Input、Selector…)与相应 validator 与组件值回调函数名(trigger)传入 Decorator,将 validator 与 trigger 相绑定。Decorator 完成了各种不同组件与 Form 内置 Store 间 value 的传递、校验功能的抽象,即精读文章中提到 Props Proxy 方式的其中两种作用:提取 state 与 操作 props function formFactoryFactory({ validator, trigger = 'onChange', ...}) { return FormFactory(WrappedComponent) { return class Decorator extends React.Component { getBind(trigger, validator) { ... } render() { const newProps = { ...this.props, [trigger]: this.getBind(trigger, validator), ... } return <WrappedComponent {...newProps} /> } } }}// 调用formFactoryFactory({ validator: (value) => { return value !== ''; }})(<Input placeholder="请输入..." />) 当然为了考虑个性化需求,Form Store 也向外暴露很多 API,可以直接获取和修改 value、error 的值。现在我们需要对一个表单的所有值提交到后端进行校验,根据后端返回,分别列出各项的校验错误信息,就需要借助相应项的 setError 去完成了。 这里主要参考了 rc-form 的实现方式,有兴趣的读者可以阅读其源码。 import { createForm } from 'rc-form';class Form extends React.Component { submit = () => { this.props.form.validateFields((error, value) => { console.log(error, value); }); } render() { const { getFieldError, getFieldDecorator } = this.props.form; const errors = getFieldError('required'); return ( <div> {getFieldDecorator('required', { rules: [{ required: true }], })(<Input />)} {errors ? errors.join(',') : null} <button onClick={this.submit}>submit</button> </div> ); }}export createForm()(Form); 4 总结React 始终强调组合优于继承的理念,期望通过复用小组件来构建大组件使得开发变得简单而又高效,与传统面向对象思想是截然不同的。高阶函数(HOC)的出现替代了原有 Mixin 侵入式的方案,对比隐式的 Mixin 或是继承,HOC 能够在 Devtools 中显示出来,满足抽象之余,也方便了开发与测试。当然,不可过度抽象是我们始终要秉持的原则。希望读者通过本次阅读与讨论,能结合自己具体的业务开发场景,获得一些启发。 讨论地址是:精读《深入理解 React 高阶组件》 · Issue ##18 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《React16 新特性》","path":"/wiki/WebWeekly/前沿技术/《React16 新特性》.html","content":"当前期刊数: 83 React16 新特性1 引言于 2017.09.26 Facebook 发布 React v16.0 版本,时至今日已更新到 React v16.6,且引入了大量的令人振奋的新特性,本文章将带领大家根据 React 更新的时间脉络了解 React16 的新特性。 2 概述按照 React16 的更新时间,从 React v16.0 ~ React v16.6 进行概述。 React v16.0 render 支持返回数组和字符串、Error Boundaries、createPortal、支持自定义 DOM 属性、减少文件体积、fiber; React v16.1 react-call-return; React v16.2 Fragment; React v16.3 createContext、createRef、forwardRef、生命周期函数的更新、Strict Mode; React v16.4 Pointer Events、update getDerivedStateFromProps; React v16.5 Profiler; React v16.6 memo、lazy、Suspense、static contextType、static getDerivedStateFromError(); React v16.7(~Q1 2019) Hooks; React v16.8(~Q2 2019) Concurrent Rendering; React v16.9(~mid 2019) Suspense for Data Fetching; 下面将按照上述的 React16 更新路径对每个新特性进行详细或简短的解析。 3 精读React v16.0render 支持返回数组和字符串// 不需要再将元素作为子元素装载到根元素下面render() { return [ <li/>1</li>, <li/>2</li>, <li/>3</li>, ];} Error BoundariesReact15 在渲染过程中遇到运行时的错误,会导致整个 React 组件的崩溃,而且错误信息不明确可读性差。React16 支持了更优雅的错误处理策略,如果一个错误是在组件的渲染或者生命周期方法中被抛出,整个组件结构就会从根节点中卸载,而不影响其他组件的渲染,可以利用 error boundaries 进行错误的优化处理。 class ErrorBoundary extends React.Component { state = { hasError: false }; componentDidCatch(error, info) { this.setState({ hasError: true }); logErrorToMyService(error, info); } render() { if (this.state.hasError) { return <h1>数据错误</h1>; } return this.props.children; }} createPortalcreatePortal 的出现为 弹窗、对话框 等脱离文档流的组件开发提供了便利,替换了之前不稳定的 API unstable_renderSubtreeIntoContainer,在代码使用上可以做兼容,如: const isReact16 = ReactDOM.createPortal !== undefined;const getCreatePortal = () => isReact16 ? ReactDOM.createPortal : ReactDOM.unstable_renderSubtreeIntoContainer; 使用 createPortal 可以快速创建 Dialog 组件,且不需要牵扯到 componentDidMount、componentDidUpdate 等生命周期函数。 并且通过 createPortal 渲染的 DOM,事件可以从 portal 的入口端冒泡上来,如果入口端存在 onDialogClick 等事件,createPortal 中的 DOM 也能够被调用到。 import React from "react";import { createPortal } from "react-dom";class Dialog extends React.Component { constructor() { super(props); this.node = document.createElement("div"); document.body.appendChild(this.node); } render() { return createPortal(<div>{this.props.children}</div>, this.node); }} 支持自定义 DOM 属性以前的 React 版本 DOM 不识别除了 HTML 和 SVG 支持的以外属性,在 React16 版本中将会把全部的属性传递给 DOM 元素。这个新特性可以让我们摆脱可用的 React DOM 属性白名单。笔者之前写过一个方法,用于过滤非 DOM 属性 filter-react-dom-props,16 之后即可不再需要这样的方法。 减少文件体积React16 使用 Rollup 针对不同的目标格式进行代码打包,由于打包工具的改变使得库文件大小得到缩减。 React 库大小从 20.7kb(压缩后 6.9kb)降低到 5.3kb(压缩后 2.2kb) ReactDOM 库大小从 141kb(压缩后 42.9kb)降低到 103.7kb(压缩后 32.6kb) React + ReactDOM 库大小从 161.7kb(压缩后 49.8kb)降低到 109kb(压缩后 43.8kb) FiberFiber 是对 React 核心算法的一次重新实现,将原本的同步更新过程碎片化,避免主线程的长时间阻塞,使应用的渲染更加流畅。 在 React16 之前,更新组件时会调用各个组件的生命周期函数,计算和比对 Virtual DOM,更新 DOM 树等,这整个过程是同步进行的,中途无法中断。当组件比较庞大,更新操作耗时较长时,就会导致浏览器唯一的主线程都是执行组件更新操作,而无法响应用户的输入或动画的渲染,很影响用户体验。 Fiber 利用分片的思想,把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,在每个小片执行完之后,就把控制权交还给 React 负责任务协调的模块,如果有紧急任务就去优先处理,如果没有就继续更新,这样就给其他任务一个执行的机会,唯一的线程就不会一直被独占。 因此,在组件更新时有可能一个更新任务还没有完成,就被另一个更高优先级的更新过程打断,优先级高的更新任务会优先处理完,而低优先级更新任务所做的工作则会完全作废,然后等待机会重头再来。所以 React Fiber 把一个更新过程分为两个阶段: 第一个阶段 Reconciliation Phase,Fiber 会找出需要更新的 DOM,这个阶段是可以被打断的; 第二个阶段 Commit Phase,是无法被打断的,完成 DOM 的更新并展示; 在使用 Fiber 后,需要检查与第一阶段相关的生命周期函数,避免逻辑的多次或重复调用: componentWillMount componentWillReceiveProps shouldComponentUpdate componentWillUpdate 与第二阶段相关的生命周期函数: componentDidMount componentDidUpdate componentWillUnmount React v16.1Call Return(react-call-return npm)react-call-return 目前还是一个独立的 npm 包,主要是针对 父组件需要根据子组件的回调信息去渲染子组件场景 提供的解决方案。 在 React16 之前,针对上述场景一般有两个解决方案: 首先让子组件初始化渲染,通过回调函数把信息传给父组件,父组件完成处理后更新子组件 props,触发子组件的第二次渲染才可以解决,子组件需要经过两次渲染周期,可能会造成渲染的抖动或闪烁等问题; 首先在父组件通过 children 获得子组件并读取其信息,利用 React.cloneElement 克隆产生新元素,并将新的属性传递进去,父组件 render 返回的是克隆产生的子元素。虽然这种方法只需要使用一个生命周期,但是父组件的代码编写会比较麻烦; React16 支持的 react-call-return,提供了两个函数 unstable_createCall 和 unstable_createReturn,其中 unstable_createCall 是 父组件使用,unstable_createReturn 是 子组件使用,父组件发出 Call,子组件响应这个 Call,即 Return。 在父组件 render 函数中返回对 unstable_createCall 的调用,第一个参数是 props.children,第二个参数是一个回调函数,用于接受子组件响应 Call 所返回的信息,第三个参数是 props; 在子组件 render 函数返回对 unstable_createReturn 的调用,参数是一个对象,这个对象会在 unstable_createCall 第二个回调函数参数中访问到; 当父组件下的所有子组件都完成渲染周期后,由于子组件返回的是对 unstable_createReturn 的调用所以并没有渲染元素,unstable_createCall 的第二个回调函数参数会被调用,这个回调函数返回的是真正渲染子组件的元素; 针对普通场景来说,react-call-return 有点过度设计的感觉,但是如果针对一些特定场景的话,它的作用还是非常明显,比如,在渲染瀑布流布局时,利用 react-call-return 可以先缓存子组件的 ReactElement,等必要的信息足够之后父组件再触发 render,完成渲染。 import React from "react";import { unstable_createReturn, unstable_createCall } from "react-call-return";const Child = props => { return unstable_createReturn({ size: props.children.length, renderItem: (partSize, totalSize) => { return ( <div> {props.children} {partSize} / {totalSize} </div> ); } });};const Parent = props => { return ( <div> {unstable_createCall( props.children, (props, returnValues) => { const totalSize = returnValues .map(v => v.size) .reduce((a, b) => a + b, 0); return returnValues.map(({ size, renderItem }) => { return renderItem(size, totalSize); }); }, props )} </div> );}; React v16.2FragmentFragment 组件其作用是可以将一些子元素添加到 DOM tree 上且不需要为这些元素提供额外的父节点,相当于 render 返回数组元素。 render() { return ( <Fragment> Some text. <h2>A heading</h2> More text. <h2>Another heading</h2> Even more text. </Fragment> );} React v16.3createContext全新的 Context API 可以很容易穿透组件而无副作用,其包含三部分:React.createContext,Provider,Consumer。 React.createContext 是一个函数,它接收初始值并返回带有 Provider 和 Consumer 组件的对象; Provider 组件是数据的发布方,一般在组件树的上层并接收一个数据的初始值; Consumer 组件是数据的订阅方,它的 props.children 是一个函数,接收被发布的数据,并且返回 React Element; const ThemeContext = React.createContext("light");class ThemeProvider extends React.Component { state = { theme: "light" }; render() { return ( <ThemeContext.Provider value={this.state.theme}> {this.props.children} </ThemeContext.Provider> ); }}class ThemedButton extends React.Component { render() { return ( <ThemeContext.Consumer> {theme => <Button theme={theme} />} </ThemeContext.Consumer> ); }} createRef / forwardRefReact16 规范了 Ref 的获取方式,通过 React.createRef 取得 Ref 对象。 // before React 16··· componentDidMount() { const el = this.refs.myRef } render() { return <div ref="myRef" /> }···// React 16+ constructor(props) { super(props) this.myRef = React.createRef() } render() { return <div ref={this.myRef} /> }··· React.forwardRef 是 Ref 的转发, 它能够让父组件访问到子组件的 Ref,从而操作子组件的 DOM。 React.forwardRef 接收一个函数,函数参数有 props 和 ref。 const TextInput = React.forwardRef((props, ref) => ( <input type="text" placeholder="Hello forwardRef" ref={ref} />));const inputRef = React.createRef();class App extends Component { constructor(props) { super(props); this.myRef = React.createRef(); } handleSubmit = event => { event.preventDefault(); alert("input value is:" + inputRef.current.value); }; render() { return ( <form onSubmit={this.handleSubmit}> <TextInput ref={inputRef} /> <button type="submit">Submit</button> </form> ); }} 生命周期函数的更新React16 采用了新的内核架构 Fiber,Fiber 将组件更新分为两个阶段:Render Parse 和 Commit Parse,因此 React 也引入了 getDerivedStateFromProps 、 getSnapshotBeforeUpdate 及 componentDidCatch 等三个全新的生命周期函数。同时也将 componentWillMount、componentWillReceiveProps 和 componentWillUpdate 标记为不安全的方法。 static getDerivedStateFromProps(nextProps, prevState)getDerivedStateFromProps(nextProps, prevState) 其作用是根据传递的 props 来更新 state。它的一大特点是无副作用,由于处在 Render Phase 阶段,所以在每次的更新都会触发该函数, 在 API 设计上采用了静态方法,使其无法访问实例、无法通过 ref 访问到 DOM 对象等,保证了该函数的纯粹高效。 为了配合未来的 React 异步渲染机制,React v16.4 对 getDerivedStateFromProps 做了一些改变, 使其不仅在 props 更新时会被调用,setState 时也会被触发。 如果改变 props 的同时,有副作用的产生,这时应该使用 componentDidUpdate; 如果想要根据 props 计算属性,应该考虑将结果 memoization 化; 如果想要根据 props 变化来重置某些状态,应该考虑使用受控组件; static getDerivedStateFromProps(props, state) { if (props.value !== state.controlledValue) { return { controlledValue: props.value, }; } return null;} getSnapshotBeforeUpdate(prevProps, prevState)getSnapshotBeforeUpdate(prevProps, prevState) 会在组件更新之前获取一个 snapshot,并可以将计算得的值或从 DOM 得到的信息传递到 componentDidUpdate(prevProps, prevState, snapshot) 函数的第三个参数,常常用于 scroll 位置定位等场景。 componentDidCatch(error, info)componentDidCatch 函数让开发者可以自主处理错误信息,诸如错误展示,上报错误等,用户可以创建自己的 Error Boundary 来捕获错误。 componentWillMount(nextProps, nextState)componentWillMount 被标记为不安全,因为在 componentWillMount 中获取异步数据或进行事件订阅等操作会产生一些问题,比如无法保证在 componentWillUnmount 中取消掉相应的事件订阅,或者导致多次重复获取异步数据等问题。 componentWillReceiveProps(nextProps) / componentWillUpdate(nextProps, nextState)componentWillReceiveProps / componentWillUpdate 被标记为不安全,主要是因为操作 props 引起的 re-render 问题,并且对 DOM 的更新操作也可能导致重新渲染。 Strict ModeStrictMode 可以在开发阶段开启严格模式,发现应用存在的潜在问题,提升应用的健壮性,其主要能检测下列问题: 识别被标志位不安全的生命周期函数 对弃用的 API 进行警告 探测某些产生副作用的方法 检测是否使用 findDOMNode 检测是否采用了老的 Context API class App extends React.Component { render() { return ( <div> <React.StrictMode> <ComponentA /> </React.StrictMode> </div> ); }} React v16.4Pointer Events指针事件是为指针设备触发的 DOM 事件。它们旨在创建单个 DOM 事件模型来处理指向输入设备,例如鼠标,笔 / 触控笔或触摸(例如一个或多个手指)。指针是一个与硬件无关的设备,可以定位一组特定的屏幕坐标。拥有指针的单个事件模型可以简化创建 Web 站点和应用程序,并提供良好的用户体验,无论用户的硬件如何。但是,对于需要特定于设备的处理的场景,指针事件定义了一个 pointerType 属性,用于检查产生事件的设备类型。 React 新增 onPointerDown / onPointerMove / onPointerUp / onPointerCancel / onGotPointerCapture / onLostPointerCapture / onPointerEnter / onPointerLeave / onPointerOver / onPointerOut 等指针事件。 这些事件只能在支持 指针事件 规范的浏览器中工作。如果应用程序依赖于指针事件,建议使用第三方指针事件 polyfill。 React v16.5ProfilerReact 16.5 添加了对新的 profiler DevTools 插件的支持。这个插件使用 React 的 Profiler 实验性 API 去收集所有 component 的渲染时间,目的是为了找出 React App 的性能瓶颈,它将会和 React 即将发布的 时间片 特性完全兼容。 React v16.6memoReact.memo() 只能作用在简单的函数组件上,本质是一个高阶函数,可以自动帮助组件执行 shouldComponentUpdate(),但只是执行浅比较,其意义和价值有限。 const MemoizedComponent = React.memo(props => { /* 只在 props 更改的时候才会重新渲染 */}); lazy / SuspenseReact.lazy() 提供了动态 import 组件的能力,实现代码分割。 Suspense 作用是在等待组件时 suspend(暂停)渲染,并显示加载标识。 目前 React v16.6 中 Suspense 只支持一个场景,即使用 React.lazy() 和 <React.Suspense> 实现的动态加载组件。 import React, { lazy, Suspense } from "react";const OtherComponent = lazy(() => import("./OtherComponent"));function MyComponent() { return ( <Suspense fallback={<div>Loading...</div>}> <OtherComponent /> </Suspense> );} static contextTypestatic contextType 为 Context API 提供了更加便捷的使用体验,可以通过 this.context 来访问 Context。 const MyContext = React.createContext();class MyClass extends React.Component { static contextType = MyContext; componentDidMount() { const value = this.context; } componentDidUpdate() { const value = this.context; } componentWillUnmount() { const value = this.context; } render() { const value = this.context; }} getDerivedStateFromErrorstatic getDerivedStateFromError(error) 允许开发者在 render 完成之前渲染 Fallback UI,该生命周期函数触发的条件是子组件抛出错误,getDerivedStateFromError 接收到这个错误参数后更新 state。 class ErrorBoundary extends React.Component { state = { hasError: false }; static getDerivedStateFromError(error) { // Update state so the next render will show the fallback UI. return { hasError: true }; } componentDidCatch(error, info) { // You can also log the error to an error reporting service logErrorToMyService(error, info); } render() { if (this.state.hasError) { // You can render any custom fallback UI return <h1>Something went wrong.</h1>; } return this.props.children; }} React v16.7(~Q1 2019)HooksHooks 要解决的是状态逻辑复用问题,且不会产生 JSX 嵌套地狱,其特性如下: 多个状态不会产生嵌套,依然是平铺写法; Hooks 可以引用其他 Hooks; 更容易将组件的 UI 与状态分离; Hooks 并不是通过 Proxy 或者 getters 实现,而是通过数组实现,每次 useState 都会改变下标,如果 useState 被包裹在 condition 中,那每次执行的下标就可能对不上,导致 useState 导出的 setter 更新错数据。 更多 Hooks 使用场景可以阅读下列文章: 精读《怎么用 React Hooks 造轮子》 function App() { const [open, setOpen] = useState(false); return ( <> <Button type="primary" onClick={() => setOpen(true)}> Open Modal </Button> <Modal visible={open} onOk={() => setOpen(false)} onCancel={() => setOpen(false)} /> </> );} React v16.8(~Q2 2019)Concurrent RenderingConcurrent Rendering 并发渲染模式是在不阻塞主线程的情况下渲染组件树,使 React 应用响应性更流畅,它允许 React 中断耗时的渲染,去处理高优先级的事件,如用户输入等,还能在高速连接时跳过不必要的加载状态,用以改善 Suspense 的用户体验。 目前 Concurrent Rendering 尚未正式发布,也没有详细相关文档,需要等待 React 团队的正式发布。 React v16.9(~mid 2019)Suspense for Data FetchingSuspense 通过 ComponentDidCatch 实现用同步的方式编写异步数据的请求,并且没有使用 yield / async / await,其流程:调用 render 函数 -> 发现有异步请求 -> 暂停渲染,等待异步请求结果 -> 渲染展示数据。 无论是什么异常,JavaScript 都能捕获,React 就是利用了这个语言特性,通过 ComponentDidCatch 捕获了所有生命周期函数、render 函数等,以及事件回调中的错误。如果有缓存则读取缓存数据,如果没有缓存,则会抛出一个异常 promise,利用异常做逻辑流控制是一种拥有较深的调用堆栈时的手段,它是在虚拟 DOM 渲染层做的暂停拦截,代码可在服务端复用。 import { fetchMovieDetails } from "../api";import { createFetch } from "../future";const movieDetailsFetch = createFetch(fetchMovieDetails);function MovieDetails(props) { const movie = movieDetailsFetch.read(props.id); return ( <div> <MoviePoster src={movie.poster} /> <MovieMetrics {...movie} /> </div> );} 4 总结从 React16 的一系列更新和新特性中我们可以窥见,React 已经不仅仅只在做一个 View 的展示库,而是想要发展成为一个包含 View / 数据管理 / 数据获取 等场景的前端框架,以 React 团队的技术实力以及想法,笔者还是很期待和看好 React 的未来,不过它渐渐地已经对开发新手们不太友好了。 5 更多讨论 讨论地址是:精读《React16 新特性》 · Issue ##115 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《Records & Tuples for React》","path":"/wiki/WebWeekly/前沿技术/《Records & Tuples for React》.html","content":"当前期刊数: 224 继前一篇 精读《Records & Tuples 提案》,已经有人在思考这个提案可以帮助 React 解决哪些问题了,比如这篇 Records & Tuples for React,就提到了许多 React 痛点可以被解决。 其实我比较担忧浏览器是否能将 Records & Tuples 性能优化得足够好,这将是它能否大规模应用,或者说我们是否放心把问题交给它解决的最关键因素。本文基于浏览器可以完美优化其性能的前提,一切看起来都挺美好,我们不妨基于这个假设,看看 Records & Tuples 提案能解决哪些问题吧! 概述Records & Tuples Proposal 提案在上一篇精读已经介绍过了,不熟悉可以先去看一下提案语法。 保证不可变性虽然现在 React 也能用 Immutable 思想开发,但大部分情况无法保证安全性,比如: const Hello = ({ profile }) => { // prop mutation: throws TypeError profile.name = 'Sebastien updated'; return <p>Hello {profile.name}</p>;};function App() { const [profile, setProfile] = React.useState(##{ name: 'Sebastien', }); // state mutation: throws TypeError profile.name = 'Sebastien updated'; return <Hello profile={profile} />;} 归根结底,我们不会总使用 freeze 来冻结对象,大部分情况下需要人为保证引用不被修改,其中的潜在风险依然存在。但使用 Record 表示状态,无论 TS 还是 JS 都会报错,立刻阻止问题扩散。 部分代替 useMemo比如下面的例子,为了保障 apiFilters 引用不变,需要对其 useMemo: const apiFilters = useMemo( () => ({ userFilter, companyFilter }), [userFilter, companyFilter],);const { apiData, loading } = useApiData(apiFilters); 但 Record 模式不需要 memo,因为 js 引擎会帮你做类似的事情: const {apiData,loading} = useApiData(##{ userFilter, companyFilter }) 用在 useEffect这段写的很啰嗦,其实和代替 useMemo 差不多,即: const apiFilters = ##{ userFilter, companyFilter };useEffect(() => { fetchApiData(apiFilters).then(setApiDataInState);}, [apiFilters]); 你可以把 apiFilters 当做一个引用稳定的原始对象看待,如果它确实变化了,那一定是值改变了,所以才会引发取数。如果把上面的 ## 号去掉,每次组件刷新都会取数,而实际上都是多余的。 用在 props 属性可以更方便定义不可变 props 了,而不需要提前 useMemo: <ExpensiveChild someData={##{ attr1: 'abc', attr2: 'def' }} />; 将取数结果转化为 Record这个目前还真做不到,除非用性能非常差的 JSON.stringify 或 deepEqual,用法如下: const fetchUserAndCompany = async () => { const response = await fetch( `https://myBackend.com/userAndCompany`, ); return JSON.parseImmutable(await response.text());}; 即利用 Record 提案的 JSON.parseImmutable 将后端返回值也转化为 Record,这样即便重新查询,但如果返回结果完全不变,也不会导致重渲染,或者局部变化也只会导致局部重渲染,而目前我们只能放任这种情况下全量重渲染。 然而这对浏览器实现 Record 的新能优化提出了非常严苛的要求,因为假设后端返回的数据有几十 MB,我们不知道这种内置 API 会导致多少的额外开销。 假设浏览器使用非常 Magic 的办法做到了几乎零开销,那么我们应该在任何时候都用 JSON.parseImmutable 解析而不是 JSON.parse。 生成查询参数也是利用了 parseImmutable 方法,让前端可以精确发送请求,而不是每次 qs.parse 生成一个新引用就发一次请求: // This is a non-performant, but working solution.// Lib authors should provide a method such as qs.parseRecord(search)const parseQueryStringAsRecord = (search) => { const queryStringObject = qs.parse(search); // Note: the Record(obj) conversion function is not recursive // There's a recursive conversion method here: // https://tc39.es/proposal-record-tuple/cookbook/index.html return JSON.parseImmutable( JSON.stringify(queryStringObject), );};const useQueryStringRecord = () => { const { search } = useLocation(); return useMemo(() => parseQueryStringAsRecord(search), [ search, ]);}; 还提到一个有趣的点,即到时候配套工具库可能提供类似 qs.parseRecord(search) 的方法把 JSON.parseImmutable 包装掉,也就是这些生态库想要 “无缝” 接入 Record 提案其实需要做一些 API 改造。 避免循环产生的新引用即便原始对象引用不变,但我们写几行代码随便 .filter 一下引用就变了,而且无论返回结果是否变化,引用都一定会改变: const AllUsers = [ { id: 1, name: 'Sebastien' }, { id: 2, name: 'John' },];const Parent = () => { const userIdsToHide = useUserIdsToHide(); const users = AllUsers.filter( (user) => !userIdsToHide.includes(user.id), ); return <UserList users={users} />;};const UserList = React.memo(({ users }) => ( <ul> {users.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul>)); 要避免这个问题就必须 useMemo,但在 Record 提案下不需要: const AllUsers = ##[ ##{ id: 1, name: 'Sebastien' }, ##{ id: 2, name: 'John' },];const filteredUsers = AllUsers.filter(() => true);AllUsers === filteredUsers;// true 作为 React key这个想法更有趣,如果 Record 提案保证了引用严格不可变,那我们完全可以拿 item 本身作为 key,而不需要任何其他手段,这样维护成本会大大降低。 const list = ##[ ##{ country: 'FR', localPhoneNumber: '111111' }, ##{ country: 'FR', localPhoneNumber: '222222' }, ##{ country: 'US', localPhoneNumber: '111111' },];<> {list.map((item) => ( <Item key={item} item={item} /> ))}</> 当然这依然建立在浏览器非常高效实现 Record 的前提,假设浏览器采用 deepEqual 作为初稿实现这个规范,那么上面这坨代码可能导致本来不卡的页面直接崩溃退出。 TS 支持也许到时候 ts 会支持如下方式定义不可变变量: const UsersPageContent = ({ usersFilters,}: { usersFilters: ##{nameFilter: string, ageFilter: string}}) => { const [users, setUsers] = useState([]); // poor-man's fetch useEffect(() => { fetchUsers(usersFilters).then(setUsers); }, [usersFilters]); return <Users users={users} />;}; 那我们就可以真的保证 usersFilters 是不可变的了。因为在目前阶段,编译时 ts 是完全无法保障变量引用是否会变化。 优化 css-in-js采用 Record 与普通 object 作为 css 属性,对 css-in-js 的区别是什么? const Component = () => ( <div css={##{ backgroundColor: 'hotpink', }} > This has a hotpink background. </div>); 由于 css-in-js 框架对新的引用会生成新 className,所以如果不主动保障引用不可变,会导致渲染时 className 一直变化,不仅影响调试也影响性能,而 Record 可以避免这个担忧。 精读总结下来,其实 Record 提案并不是解决之前无法解决的问题,而是用更简洁的原生语法解决了复杂逻辑才能解决的问题。这带来的优势主要在于 “不容易写出问题代码了”,或者让 Immutable 在 js 语言的上手成本更低了。 现在看下来这个规范有个严重担忧点就是性能,而 stage2 并没有对浏览器实现性能提出要求,而是给了一些建议,并在 stage4 之前给出具体性能优化建议方案。 其中还是提到了一些具体做法,包括快速判断真假,即对数据结构操作时的优化。 快速判真可以采用类似 hash-cons 快速判断结构相等,可能是将一些关键判断信息存在 hash 表中,进而不需要真的对结构进行递归判断。 快速判假可以通过维护散列表快速判断,或者我觉得也可以用上数据结构一些经典算法,比如布隆过滤器,就是用在高效快速判否场景的。 Record 降低了哪些心智负担其实如果应用开发都是 hello world 复杂度,那其实 React 也可以很好的契合 immutable,比如我们给 React 组件传递的 props 都是 boolean、string 或 number: <ExpensiveChild userName="nick" age={18} isAdmin />; 比如上面的例子,完全不用关心引用会变化,因为我们用的原始类型本身引用就不可能变化,比如 18 不可能突变成 19,如果子组件真的想要 19,那一定只能创建一个新的,总之就是没办法改变我们传递的原始类型。 如果我们永远在这种环境下开发,那 React 结合 immutable 会非常美妙。但好景不长,我们总是要面对对象、数组的场景,然而这些类型在 js 语法里不属于原始类型,我们了解到还有 “引用” 这样一种说法,两个值不一样对象可能是 === 全等的。 可以认为,Record 就是把这个顾虑从语法层面消除了,即 ##{ a: 1 } 也可以看作像 18,19 一样的数字,不可能有人改变它,所以从语法层面你就会像对 19 这个数字一样放心 ##{ a: 1 } 不会被改变。 当然这个提案面临的最大问题就是 “如何将拥有子结构的类型看作原始类型”,也许 JS 引擎将它看作一种特别的字符串更贴合其原理,但难点是这又违背了整个语言体系对子结构的默认认知,Box 装箱语法尤其别扭。 总结看了这篇文章的畅想,React 与 Records & Tulpes 结合的一定会很好,但前提是浏览器对其性能优化必须与 “引用对比” 大致相同才可以,这也是较为少见,对性能要求如此苛刻的特性,因为如果没有性能的加持,其便捷性将毫无意义。 讨论地址是:精读《Records & Tuples for React》· Issue ##385 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Records & Tuples 提案》","path":"/wiki/WebWeekly/前沿技术/《Records & Tuples 提案》.html","content":"当前期刊数: 223 immutablejs、immer 等库已经让 js 具备了 immutable 编程的可能性,但还存在一些无解的问题,即 “怎么保证一个对象真的不可变”。 如果不是拍胸脯担保,现在还真没别的办法。或许你觉得 frozen 是个 good idea,但它内部仍然可以增加非 frozen 的 key。 另一个问题是,当我们 debug 调试应用数据的时候,看到状态发生 [] -> [] 变化时,无论在控制台、断点、redux devtools 还是 .toString() 都看不出来引用有没有变化,除非把变量值分别拿到进行 === 运行时判断。但引用变与没变可是一个大问题,它甚至能决定业务逻辑的正确与否。 但现阶段我们没有任何处理办法,如果不能接受完全使用 Immutablejs 定义对象,就只能摆胸脯保证自己的变更一定是 immutable 的,这就是 js 不可变编程被许多聪明人吐槽的原因,觉得在不支持 immutable 的编程语言下强行应用不可变思维是一种很别扭的事。 proposal-record-tuple 解决的就是这个问题,它让 js 原生支持了 不可变数据类型(高亮、加粗)。 概述 & 精读JS 有 7 种原始类型:string, number, bigint, boolean, undefined, symbol, null. 而 Records & Tuples 提案一下就增加了三种原始类型!这三种原始类型完全是为 immutable 编程环境服务的,也就是说,可以让 js 开出一条原生 immutable 赛道。 这三种原始类型分别是 Record, Tuple, Box: Record: 类对象结构的深度不可变基础类型,如 ##{ x: 1, y: 2 }。 Tuple: 类数组结构的深度不可变基础类型,如 ##[1, 2, 3, 4]。 Box: 可以定义在上面两个类型中,存储对象,如 ##{ prop: Box(object) }。 核心思想可以总结为一句话:因为这三个类型为基础类型,所以在比较时采用值对比(而非引用对比),因此 ##{ x: 1, y: 2} === ##{ x: 1, y: 2 }。这真的解决了大问题!如果你还不了解 js 不支持 immutable 之痛,请不要跳过下一节。 js 不支持 immutable 之痛虽然很多人都喜欢 mvvm 的 reactive 特征(包括我也写了不少 mvvm 轮子和框架),但不可变数据永远是开发大型应用最好的思想,它可以非常可靠的保障应用数据的可预测性,同时不需要牺牲性能与内存,它使用起来没有 mutable 模式方便,但它永远不会出现预料外的情况,这对打造稳定的复杂应用至关重要,甚至比便捷性更加重要。当然可测试也是个非常重要的点,这里不详细展开。 然而 js 并不原生支持 immutable,这非常令人头痛,也造成了许多困扰,下面我试图解释一下这个困扰。 如果你觉得非原始类型按照引用对比很棒,那你一定一眼能看出下面的结果是正确的: assert({ a: 1 } !== { a: 1 }) 但如果是下面的情况呢? console.log(window.a) // { a: 1 }console.log(window.b) // { a: 1 }assert(window.a === window.b) // ??? 结果是不确定,虽然这两个对象长得一样,但我们拿到的 scope 无法推断其是否来自同一个引用,如果来自于相同的引用,则断言通过,否则即便看上去值一样,也会 throw error。 更大的麻烦是,即便这两个对象长得完全不一样,我们也不敢轻易下结论: console.log(window.a) // { a: 1 }// do some change..console.log(window.b) // { b: 1 }assert(window.a === window.b) // ??? 因为 b 的值可能在中途被修改,但确实与 a 来自同一个引用,我们无法断定结果到底是什么。 另一个问题则是应用状态变更的扑朔迷离。试想我们开发了一个树形菜单,结构如下: { "id": "1", "label": "root", "children": [{ "id": "2", "label": "apple", }, { "id": "3", "label": "orange", }]} 如果我们调用 updateTreeNode('3', { id: '3', title: 'banana' }),在 immutable 场景下我们仅更新 id 为 “1”, “3” 组件的引用,而 id 为 “2” 的引用不变,那么这棵树节点 “2” 就不会重渲染,这是血统纯正的 immutable 思维逻辑。 但当我们保存下这个新状态后,要进行 “状态回放”,会发现其实应用状态进行了一次变更,整个描述 json 变成了: { "id": "1", "label": "root", "children": [{ "id": "2", "label": "apple", }, { "id": "3", "label": "banana", }]} 但如果我们拷贝上面的文本,把应用状态直接设置为这个结果,会发现与 “应用回放按钮” 的效果不同,这时 id “2” 也重渲染了,因为它的引用变化了。 问题就是我们无法根据肉眼观察出引用是否变化了,即便两个结构一模一样,也无法保证引用是否相同,进而导致无法推断应用的行为是否一致。如果没有人为的代码质量管控,出现非预期的引用更新几乎是难以避免的。 这就是 Records & Tuples 提案要解决问题的背景,我们带着这个理解去看它的定义,就更好学习了。 Records & Tuples 在用法上与对象、数组保持一致Records & Tuples 提案说明,不可变数据结构除了定义时需要用 ## 符号申明外,使用时与普通对象、数组无异。 Record 用法与普通 object 几乎一样: const proposal = ##{ id: 1234, title: "Record & Tuple proposal", contents: `...`, // tuples are primitive types so you can put them in records: keywords: ##["ecma", "tc39", "proposal", "record", "tuple"],};// Accessing keys like you would with objects!console.log(proposal.title); // Record & Tuple proposalconsole.log(proposal.keywords[1]); // tc39// Spread like objects!const proposal2 = ##{ ...proposal, title: "Stage 2: Record & Tuple",};console.log(proposal2.title); // Stage 2: Record & Tupleconsole.log(proposal2.keywords[1]); // tc39// Object functions work on Records:console.log(Object.keys(proposal)); // ["contents", "id", "keywords", "title"] 下面的例子说明,Records 与 object 在函数内处理时并没有什么不同,这个在 FAQ 里提到是一个非常重要的特性,可以让 immutable 完全融入现在的 js 生态: const ship1 = ##{ x: 1, y: 2 };// ship2 is an ordinary object:const ship2 = { x: -1, y: 3 };function move(start, deltaX, deltaY) { // we always return a record after moving return ##{ x: start.x + deltaX, y: start.y + deltaY, };}const ship1Moved = move(ship1, 1, 0);// passing an ordinary object to move() still works:const ship2Moved = move(ship2, 3, -1);console.log(ship1Moved === ship2Moved); // true// ship1 and ship2 have the same coordinates after moving Tuple 用法与普通数组几乎一样: const measures = ##[42, 12, 67, "measure error: foo happened"];// Accessing indices like you would with arrays!console.log(measures[0]); // 42console.log(measures[3]); // measure error: foo happened// Slice and spread like arrays!const correctedMeasures = ##[ ...measures.slice(0, measures.length - 1), -1];console.log(correctedMeasures[0]); // 42console.log(correctedMeasures[3]); // -1// or use the .with() shorthand for the same result:const correctedMeasures2 = measures.with(3, -1);console.log(correctedMeasures2[0]); // 42console.log(correctedMeasures2[3]); // -1// Tuples support methods similar to Arraysconsole.log(correctedMeasures2.map(x => x + 1)); // ##[43, 13, 68, 0] 在函数内处理时,拿到一个数组或 Tuple 并没有什么需要特别注意的区别: const ship1 = ##[1, 2];// ship2 is an array:const ship2 = [-1, 3];function move(start, deltaX, deltaY) { // we always return a tuple after moving return ##[ start[0] + deltaX, start[1] + deltaY, ];}const ship1Moved = move(ship1, 1, 0);// passing an array to move() still works:const ship2Moved = move(ship2, 3, -1);console.log(ship1Moved === ship2Moved); // true// ship1 and ship2 have the same coordinates after moving 由于 Record 内不能定义普通对象(比如定义为 ## 标记的不可变对象),如果非要使用普通对象,只能包裹在 Box 里,并且在获取值时需要调用 .unbox() 拆箱,并且就算修改了对象值,在 Record 或 Tuple 层面也不会认为发生了变化: const myObject = { x: 2 };const record = ##{ name: "rec", data: Box(myObject)};console.log(record.data.unbox().x); // 2// The box contents are classic mutable objects:record.data.unbox().x = 3;console.log(myObject.x); // 3console.log(record === ##{ name: "rec", data: Box(myObject) }); // true 另外不能在 Records & Tuples 内使用任何普通对象或 new 对象实例,除非已经用转化为了普通对象: const instance = new MyClass();const constContainer = ##{ instance: instance};// TypeError: Record literals may only contain primitives, Records and Tuplesconst tuple = ##[1, 2, 3];tuple.map(x => new MyClass(x));// TypeError: Callback to Tuple.prototype.map may only return primitives, Records or Tuples// The following should work:Array.from(tuple).map(x => new MyClass(x)) 语法Records & Tuples 内只能使用 Record、Tuple、Box: ##{}##{ a: 1, b: 2 }##{ a: 1, b: ##[2, 3, ##{ c: 4 }] }##[]##[1, 2]##[1, 2, ##{ a: 3 }] 不支持空数组项: const x = ##[,]; // SyntaxError, holes are disallowed by syntax 为了防止引用追溯到上层,破坏不可变性质,不支持定义原型链: const x = ##{ __proto__: foo }; // SyntaxError, __proto__ identifier prevented by syntaxconst y = ##{ ["__proto__"]: foo }; // valid, creates a record with a "__proto__" property. 也不能在里面定义方法: ##{ method() { } } // SyntaxError 同时,一些破坏不可变稳定结构的特性也是非法的,比如 key 不可以是 Symbol: const record = ##{ [Symbol()]: ##{} };// TypeError: Record may only have string as keys 不能直接使用对象作为 value,除非用 Box 包裹: const obj = {};const record = ##{ prop: obj }; // TypeError: Record may only contain primitive valuesconst record2 = ##{ prop: Box(obj) }; // ok 判等判等是最核心的地方,Records & Tuples 提案要求 == 与 === 原生支持 immutable 判等,是 js 原生支持 immutable 的一个重要表现,所以其判等逻辑与普通的对象判等大相径庭: 首先看上去值相等,就真的相等,因为基础类型仅做值对比: assert(##{ a: 1 } === ##{ a: 1 });assert(##[1, 2] === ##[1, 2]); 这与对象判等完全不同,而且把 Record 转换为对象后,判等就遵循对象的规则了: assert({ a: 1 } !== { a: 1 });assert(Object(##{ a: 1 }) !== Object(##{ a: 1 }));assert(Object(##[1, 2]) !== Object(##[1, 2])); 另外 Records 的判等与 key 的顺序无关,因为有个隐式 key 排序规则: assert(##{ a: 1, b: 2 } === ##{ b: 2, a: 1 });Object.keys(##{ a: 1, b: 2 }) // ["a", "b"]Object.keys(##{ b: 2, a: 1 }) // ["a", "b"] Box 是否相等取决于内部对象引用是否相等: const obj = {};assert(Box(obj) === Box(obj));assert(Box({}) !== Box({})); 对于 +0 -0 之间,NaN 与 NaN 对比,都可以安全判定为相等,但 Object.is 因为是对普通对象的判断逻辑,所以会认为 ##{ a: -0 } 不等于 ##{ a: +0 },因为认为 -0 不等于 +0,这里需要特别注意。另外 Records & Tulpes 也可以作为 Map、Set 的 key,并且按照值相等来查找: assert(##{ a: 1 } === ##{ a: 1 });assert(##[1] === ##[1]);assert(##{ a: -0 } === ##{ a: +0 });assert(##[-0] === ##[+0]);assert(##{ a: NaN } === ##{ a: NaN });assert(##[NaN] === ##[NaN]);assert(##{ a: -0 } == ##{ a: +0 });assert(##[-0] == ##[+0]);assert(##{ a: NaN } == ##{ a: NaN });assert(##[NaN] == ##[NaN]);assert(##[1] != ##["1"]);assert(!Object.is(##{ a: -0 }, ##{ a: +0 }));assert(!Object.is(##[-0], ##[+0]));assert(Object.is(##{ a: NaN }, ##{ a: NaN }));assert(Object.is(##[NaN], ##[NaN]));// Map keys are compared with the SameValueZero algorithmassert(new Map().set(##{ a: 1 }, true).get(##{ a: 1 }));assert(new Map().set(##[1], true).get(##[1]));assert(new Map().set(##[-0], true).get(##[0])); 对象模型如何处理 Records & Tuples对象模型是指 Object 模型,大部分情况下,所有能应用于普通对象的方法都可无缝应用于 Record,比如 Object.key 或 in 都可与处理普通对象无异: const keysArr = Object.keys(##{ a: 1, b: 2 }); // returns the array ["a", "b"]assert(keysArr[0] === "a");assert(keysArr[1] === "b");assert(keysArr !== ##["a", "b"]);assert("a" in ##{ a: 1, b: 2 }); 值得一提的是如果 wrapper 了 Object 在 Record 或 Tuple,提案还准备了一套完备的实现方案,即 Object(record) 或 Object(tuple) 会冻结所有属性,并将原型链最高指向 Tuple.prototype,对于数组跨界访问也只能返回 undefined 而不是沿着原型链追溯。 Records & Tuples 的标准库支持对 Record 与 Tuple 进行原生数组或对象操作后,返回值也是 immutable 类型的: assert(Object.keys(##{ a: 1, b: 2 }) !== ##["a", "b"]);assert(##[1, 2, 3].map(x => x * 2), ##[2, 4, 6]); 还可通过 Record.fromEntries 和 Tuple.from 方法把普通对象或数组转成 Record, Tuple: const record = Record({ a: 1, b: 2, c: 3 });const record2 = Record.fromEntries([##["a", 1], ##["b", 2], ##["c", 3]]); // note that an iterable will also workconst tuple = Tuple(...[1, 2, 3]);const tuple2 = Tuple.from([1, 2, 3]); // note that an iterable will also workassert(record === ##{ a: 1, b: 2, c: 3 });assert(tuple === ##[1, 2, 3]);Record.from({ a: {} }); // TypeError: Can't convert Object with a non-const value to RecordTuple.from([{}, {} , {}]); // TypeError: Can't convert Iterable with a non-const value to Tuple 此方法不支持嵌套,因为标准 API 仅考虑一层,递归一般交给业务或库函数实现,就像 Object.assign 一样。 Record 与 Tuple 也都是可迭代的: const tuple = ##[1, 2];// output is:// 1// 2for (const o of tuple) { console.log(o); }const record = ##{ a: 1, b: 2 };// TypeError: record is not iterablefor (const o of record) { console.log(o); }// Object.entries can be used to iterate over Records, just like for Objects// output is:// a// bfor (const [key, value] of Object.entries(record)) { console.log(key) } JSON.stringify 会把 Record & Tuple 转化为普通对象: JSON.stringify(##{ a: ##[1, 2, 3] }); // '{"a":[1,2,3]}'JSON.stringify(##[true, ##{ a: ##[1, 2, 3] }]); // '[true,{"a":[1,2,3]}]' 但同时建议实现 JSON.parseImmutable 将一个 JSON 直接转化为 Record & Tuple 类型,其 API 与 JSON.parse 无异。 Tuple.prototype 方法与 Array 很像,但也有些不同之处,主要区别是不会修改引用值,而是创建新的引用,具体可看 appendix。 由于新增了三种原始类型,所以 typeof 也会新增三种返回结果: assert(typeof ##{ a: 1 } === "record");assert(typeof ##[1, 2] === "tuple");assert(typeof Box({}) === "box"); Record, Tuple, Box 都支持作为 Map、Set 的 key,并按照其自身规则进行判等,即 const record1 = ##{ a: 1, b: 2 };const record2 = ##{ a: 1, b: 2 };const map = new Map();map.set(record1, true);assert(map.get(record2)); const record1 = ##{ a: 1, b: 2 };const record2 = ##{ a: 1, b: 2 };const set = new Set();set.add(record1);set.add(record2);assert(set.size === 1); 但不支持 WeakMap、WeakSet: const record = ##{ a: 1, b: 2 };const weakMap = new WeakMap();// TypeError: Can't use a Record as the key in a WeakMapweakMap.set(record, true); const record = ##{ a: 1, b: 2 };const weakSet = new WeakSet();// TypeError: Can't add a Record to a WeakSetweakSet.add(record); 原因是不可变数据没有一个可预测的垃圾回收时机,这样如果用在 Weak 系列反而会导致无法及时释放,所以 API 不匹配。 最后提案还附赠了理论基础与 FAQ 章节,下面也简单介绍一下。 理论基础为什么要创建新的原始类型,而不是像其他库一样在上层处理?一句话说就是让 js 原生支持 immutable 就必须作为原始类型。假如不作为原始类型,就不可能让 ==, === 操作符原生支持这个类型的特定判等,也就会导致 immutable 语法与其他 js 代码仿佛处于两套逻辑体系下,妨碍生态的统一。 开发者会熟悉这套语法吗?由于最大程度保证了与普通对象与数组处理、API 的一致性,所以开发者上手应该会比较容易。 为什么不像 Immutablejs 一样使用 .get .set 方法操作?这会导致生态割裂,代码需要关注对象到底是不是 immutable 的。一个最形象的例子就是,当 Immutablejs 与普通 js 操作库配合时,需要写出类似如下代码: state.jobResult = Immutable.fromJS( ExternalLib.processJob( state.jobDescription.toJS() )); 这有非常强的割裂感。 为什么不使用全局 Record, Tuple 方法代替 ## 申明?下面给了两个对比: // with the proposed syntaxconst record = ##{ a: ##{ foo: "string", }, b: ##{ bar: 123, }, c: ##{ baz: ##{ hello: ##[ 1, 2, 3, ], }, },};// with only the Record/Tuple globalsconst record = Record({ a: Record({ foo: "string", }), b: Record({ bar: 123, }), c: Record({ baz: Record({ hello: Tuple( 1, 2, 3, ), }), }),}); 很明显后者没有前者简洁,而且也打破了开发者对对象、数组 Like 的认知。 为什么采用 ##[]/##{} 语法?采用已有关键字可能导致歧义或者兼容性问题,另外其实还有 {| |} [| |] 的 提案,但目前 ## 的赢面比较大。 为什么是深度不可变?这个提案喷了一下 Object.freeze: const object = { a: { foo: "bar", },};Object.freeze(object);func(object); 由于只保障了一层,所以 object.a 依然是可变的,既然要 js 原生支持 immutable,希望的肯定是深度不可变,而不是只有一层。 另外由于这个语法会在语言层面支持不可变校验,而深度不可变校验是非常重要的。 FAQ如何基于已有不可变对象创建一个新不可变对象?大部分语法都是可以使用的,比如解构: // Add a Record fieldlet rec = ##{ a: 1, x: 5 }##{ ...rec, b: 2 } // ##{ a: 1, b: 2, x: 5 }// Change a Record field##{ ...rec, x: 6 } // ##{ a: 1, x: 6 }// Append to a Tuplelet tup = ##[1, 2, 3];##[...tup, 4] // ##[1, 2, 3, 4]// Prepend to a Tuple##[0, ...tup] // ##[0, 1, 2, 3]// Prepend and append to a Tuple##[0, ...tup, 4] // ##[0, 1, 2, 3, 4] 对于类数组的 Tuple,可以使用 with 语法替换新建一个对象: // Change a Tuple indexlet tup = ##[1, 2, 3];tup.with(1, 500) // ##[1, 500, 3] 但在深度修改时也遇到了绕不过去的问题,目前有一个 提案 在讨论这件事,这里提到一个有意思的语法: const state1 = ##{ counters: ##[ ##{ name: "Counter 1", value: 1 }, ##{ name: "Counter 2", value: 0 }, ##{ name: "Counter 3", value: 123 }, ], metadata: ##{ lastUpdate: 1584382969000, },};const state2 = ##{ ...state1, counters[0].value: 2, counters[1].value: 1, metadata.lastUpdate: 1584383011300,};assert(state2.counters[0].value === 2);assert(state2.counters[1].value === 1);assert(state2.metadata.lastUpdate === 1584383011300);// As expected, the unmodified values from "spreading" state1 remain in state2.assert(state2.counters[2].value === 123); counters[0].value: 2 看上去还是蛮新颖的。 与 Readonly Collections 的关系?互补。 可以基于 Class 创建 Record 实例吗?目前不考虑。 TS 也有 Record 与 Tuple 关键字,之间的关系是?熟悉 TS 的同学都知道只是名字一样而已。 性能预期是?这个问题挺关键的,如果这个提案性能不好,那也无法用于实际生产。 当前阶段没有对性能提出要求,但在 Stage4 之前会给出厂商优化的最佳实践。 总结如果这个提案与嵌套更新提案一起通过,在 js 使用 immutable 就得到了语言层面的保障,包括 Immutablejs、immerjs 在内的库是真的可以下岗啦。 讨论地址是:精读《Records & Tuples 提案》· Issue ##384 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Rekit Studio》","path":"/wiki/WebWeekly/前沿技术/《Rekit Studio》.html","content":"当前期刊数: 44 前端精读专栏,给大家拜年了! 趁着过年,先聊几句咱们前端精读: 前端精读不仅仅是知识的搬运工!前端精读不仅仅是知识的搬运工!重要的话重复两遍。 论搬运知识量,我们比不上前端周刊;论翻译水平,我们比不上专业翻译计划。各位读者也一定不是冲着这两点来的,恰好,我们也不是为这两点做的。 前端精读能带给读者的,是对如今互联网海量信息的思考能力。 想这么一个问题,当人人都能订阅鱼龙混的前端消息,真的是每天扫一眼就天下大势尽在手中了吗?说的不好听一点,可能啥都看个皮毛,最后什么都跟不上。 更可怕的是,多如牛毛的消息早已钝化了我们的神经,让我们养成了浮躁的性格,似乎提到一个单词,点一个头就心领神会了,其实什么也不会。而当真正好的思想出现时,可能已经被淹没在质量层次不齐的海洋中,就算有眼力识别出来,也早已对其麻木,更何况大部分人还做不到辨别消息的质量。 以前,前端精读想把有误导性的,不正确的文章挑出来加以指正。现在发现行不通,其一是具有误导性文章太多,实在是挑不完,其二是现在更多的文章如同白开水一般,味如嚼蜡,你挑不出毛病,但也挑不出亮点。 所以前端精读把我们认为有价值,值得大家关注的东西挑出来,深入的写一篇读后感。也许不是每一期选题都值得深入了解,但我们只求在广袤的知识沙漠中,画一个小圈,不断扎根。 好了,废话不多说,本周精读的文章是:introducing-rekit-studio-a-real-ide-for-react-and-redux-development。 1 引言以前,我们不断完善前端基础设施建设,现在前端不缺工具和库了,下一步怎么发展? 发展方向有很多,比如继续完善框架和库、争论数据流的取舍、推动 ECMA 规范打造未来蓝图、投入新语言的怀抱、可视化规范与平台的建设等等。 有一个没啥技术含量的领域正在成长,就是前端工具链整合。 我这里说的没技术含量可不是贬义,所谓有技术含量的 “造轮子” 才是贬义呢。当我们陶醉于前端技术能力时,产品和后端往往就认为我们是写网页的,根本没啥深奥技术,如果改个文案都喊着成本高,更会被人看不起。 前端的职责就是提升人机交互体验,这不是大话空话,蚂蚁的体验技术才代表了前端技术的精髓,代表了互联网大行业对前端的期望。 前端工具链的整合,拿的都是已有的技术,目标也是把复杂的项目维护变简单,最终要推动的是解放前端开发人员的精力,让我们不再陶醉于自己的一亩三分地,转而将精力投入到业务与人机交互体验的提升中。 正如之前所说,现在不缺前端基础设施了,我们对项目管理的思路也要有所转变。JS 无所不能,但做项目不能无法无天,约定产生效率,工具链保证约定。 当我们用工具链保证了项目结构的约定,就可以抽象出项目的逻辑结构。 有人走在更前面,Rekit Studio,就是根据文件结构解析出逻辑结构的工具,让开发以逻辑结构管理项目,真的可能带来项目维护、开发成本的大幅优化。 2 概述 一图胜千言,仔细看完图,不然文章就漏掉了一半。 Rekit Studio 以逻辑视图重新组织了项目,文件目录不见了,取而代之的是路由、Action、组件等,原本若干文件拼凑成的 Action 被聚合成一个按钮,统一管理。 同时支持在线管理本地文件、集成了 https://microsoft.github.io/monaco-editor/ 在线编辑文件,以及在线构建、测试等功能。 同时利用和弦图分析了路由与数据流之间绑定关系,路由与文件绑定关系,可以很轻松找到被遗弃的孤立节点。项目维护时,以看图代替看代码,效率至少提升 2 3 倍。 Cli 与可视化等效Rekit Studio 还提供了 Rekit CLI 可以完全用命令行达到可视化的效果,在比如插件化、二次开发,或者特定场景下保留了通用拓展的能力。 只是辅助工具,而非必须Rekit Studio 虽然拥有强大项目管理能力,但它不参与项目具体开发流程,项目可以脱离它独自开发,并且 Rekit 也不会内置任何 npm 包在项目中。 也就是说,Rekit Studio 的设计初衷,是为了增强项目管理能力,而不是参与到项目的开发流程中。 3 精读传统的云端开发应该不会大规模普及,毕竟网页的体验和本地 IDE 差距还是非常大的。 但网页优势在与图形化表达,以及脚本化,如果一个按钮可以帮你管理许多本地文件,那这种混合式云端开发的价值就非常大。 Rekit Studio 的尝试,给我们打开了一个网页管理本地文件的脑洞,再结合 next.js 看看,可以碰撞出什么火花呢? next.jsnext.js 支持许多约定,比如自动路由: 在 pages 下创建的文件会自动识别为路由,url 路径就是以 pages 开头的文件路径。 正因为如此,所以 next.js 对项目拥有强力的约束能力,支持了更多特性: code splitting因为路由和构建脚本都有 next.js 控制,因此支持将页面级别模块按需加载。 静态文件处理由于 next.js 包含对 node 端控制,自然可以处理静态文件:将 static 文件夹下的文件路由到 /static 路径。 页面请求数据每个页面级组件都支持静态函数 getInitialProps,这个方法的返回值不仅会注入到 props,更可以在 ssr 时预加载这部分数据。 页面预加载成为 Prefetching Pages,只要在 Link 标签添加 prefetch 属性,就会在空闲阶段预加载这个页面的按需 js 文件,几乎同时保证了整包的用户体验,与按需加载带来的 js 文件体积优化,只要用户别点击的太快。 Dynamic Import支持动态 import,这个是 webpack 刚支持不久的 TC39 新特性,可以按需加载任何文件与 npm 包。详情见 react-loadable. 自定义配置next.js 支持自定义错误处理、自定义 webpack、babel 和 next.js 导出配置等。比较有用的是 publishPath,因为大公司开发的 js 文件肯定会存储在专门的 CDN 节点。 静态 html 文件导出主要目的是做 GitHub Pages,这个功能与去年火起来的 gatsby 比较像。天下技术一大抄,之前还有 hexo、jekyll 几乎功能与 gatsby 一摸一样,起码应该在 readme 里写个 Inspired by hexo 吧。 到这里,next.js 核心功能差不多介绍完了,大家可以发现,next.js 对项目自动化管理能力很强,唯独缺少了可视化管理功能。 尝试结合 Rekit Studio 与 next.js实在对不起大家,这里要做一个硬广。 因为我同时看好 next.js 对项目约定化管理能力与 Rekit Studio 的可视化辅助能力,同时又很欣赏 parcel 的零配置理念,因此基于 parcel 做了一个三合一项目工具链:pri。 我真不是为了赚 star,这个项目在写文章时是 0 star,而且是过年这几天刚写的,很多功能还没开发完,就又赶着写精读了,所以不指望通过这篇文章赚粉,而是希望抛砖引玉,看看能不能吸引志同道合的朋友。 此刻想吐槽的同学别着急,等过段时间我写一篇彻彻底底的硬广软文时,再吐槽也不急。 硬广时间结束。下面重点说说为什么做 pri,以及制作过程中的一些思考。 各取所长提取这三个框架的精华: 融化在项目中的脚手架 - next.js。 网页也能管理代码 - Rekit Studio。 坚持零配置到底 - parcel。 我为什么觉得这三点叠加起来一起提高项目的开发效率和可维护性? 融化在项目中的脚手架都说一个项目中一百个文件可能有一百种写法,这就是为什么要约法三章。不仅约束目录结构,我们还约束 npm 包,固定 react/vue/ag 的版本号,提交时不仅强制 lint,还强制检测文件结构是否符合约定。 项目开发时,遵守约定可能带来一点的不自由的感觉,但是对项目进度影响微乎其微,不稳定的项目结构对后期维护成本的影响,可能导致 “这个文案改不了”。 构建脚本也固化下来,云构建时使用的就是项目依赖的脚手架做编译,脚手架不再游离于项目之外。 最后不用说的,满足条件后,就可以前面罗列的 next.js 强大的功能。 网页也能管理代码我看中的可不是 Rekit Studio 在线写代码的功能,那个是鸡肋!而是按照规范自动生成文件的功能,恰恰可以解决约定带来的不适感。 任何上手的人,不需要了解约定,就可以通过可视化界面看到项目拥有几个路由、数据流、组件等等,然后在网页上一键创建新页面,新数据流、新组件,不仅省去了机械化劳动,省去了查看约定规范文档的时间,还带来可模版市场的可能性。 可视化带来的不仅是项目理解成本大大降低,由于项目约定的存在,网页管理可以更加智能。甚至是自动检测项目是否有文件存在异常、通过语法树,比如 typescript 包分析各文件中语法层面的关联,让可视化界面显示更加详细的项目关系图。 当新版本脚手架发布时,如果对项目约定产生了修改,也可以按照规则写出固定的升级方案,并通过可视化界面提示用户如何一步步升级到新版约定结构,甚至一键升级。 正因为对项目拥有强力约束,所以脚手架才知道老项目该如何升级。唯一的缺点是,不要有任何项目开发细节游离于规则之外,这需要对业务方案进行完整设计,当产生新需求时,将其固化到规则中,而不是任其自由发展。 坚持零配置到底parcel 坚持认为,如果提供了配置文件,那和 webpack 有什么区别呢?pri 的理念也一样,如果提供了配置文件,那抛开可视化功能,和 next.js 以及其他脚手架又有什么区别呢? 配置是为了扩大通用范围而产生的,设想 webpack 如果只解决简单的 commonjs 脚本引用,那也不需要复杂的配置。parcel 内置了对 sass less typescript png json html 等等文件的处理,所以也不需要配置。 但是,没有配置的 webpack(且不说 4.0)无法解决基本项目开发需要,而无配置的 parcel 几乎可以胜任任何项目开发,而它唯一的缺点就是,可能无法胜任未来某个新语法的支持。但只要 parcel 继续维护,这个语法需求足够大,都可以继续内置进去。 可以看到,parcel 与 webpack 的竞争,是开源界一场配置战争的缩影,大到对所有类型项目的支持,parcel 都敢坚持无配置,那么小到某条业务线的管理,真的还需要配置吗? 对于 publicUrl 这种参数,或者页面 title 之类的本身就无法确定的项目,还是需要提供配置的,当然这种配置是可控的。 项目开发中,遇到新需求,就将这个特殊处理逻辑内置到管理脚本中,恰恰解决了程序员最头疼的 “历史包袱” 问题。 同时对特殊逻辑内置的取舍、讨论,可以促进项目积极的发展,而不是配置能力过强,导致开发者时不时偷偷给项目增加一些新逻辑,以满足业务临时需求,累积到最后导致项目无人能接手。 4 总结谈来谈去,并没有涉及到复杂的算法或者新技术,讨论的只是一种项目管理思想的不断自我挑战,整合与创新。 我并没有打算留下一个中庸的结局,我现在正在积极投入文中提及的三条思路的整合开发,并相信这是未来的趋势之一,并且确实能解决项目开发与维护遇到的难题。 我列出我认为应当拥有的所有功能与特性,欢迎大家在评论区补充,或者给 pri 提 issue: 功能 页面即路由。 支持 layouts 布局。 404 处理。 自定义配置。 - 主要解决 publicUrl 等无法给出标准值的配置。 内置数据流并自动绑定到页面。 前端环境变量。 - 可以由自定义配置拓展,内置基本变量,比如是本地环境还是生产打包。 Serve static files。 项目单测。 生成静态 HTML,支持 github pages。 特征 项目可视化管理仪表盘。 - 可视化管理代码,根据约定规范。 typescript 支持(个人偏好的)。 tslint(jslint)在执行任何脚本时强制校验。 Dynamic import。 热更新 | HMR(parcel 内置)。 code splitting(parcel 内置)。 公共依赖提取(parcel 内置)。 自动补全项目配置文件。 - 比如 .gitignore tslint.json 等等,可以以 merge 的方式保证基础配置存在。 PWA 支持。 Tree Shaking(parcel 暂时不支持,webpack 支持)。 Scope Hoist(parcel 暂时不支持,webpack 支持)。 Prefetching. 5 更多讨论 讨论地址是:精读《Rekit Studio》 · Issue ##63 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《REST, GraphQL, Webhooks, & gRPC 如何选型》","path":"/wiki/WebWeekly/前沿技术/《REST, GraphQL, Webhooks, & gRPC 如何选型》.html","content":"当前期刊数: 72 1 引言每当项目进入联调阶段,或者提前约定接口时,前后端就会聚在一起热火朝天的讨论起来。可能 99% 的场景都在约定 Http 接口,讨论 URL 是什么,入参是什么,出参是什么。 有的团队前后端接口约定更加高效,后端会拿出接口定义代码,前端会转换成(或自动转成)Typescript 定义文件。 但这些工作都针对于 Http 接口,今天通过 when-to-use-what-rest-graphql-webhooks-grpc 一文,抛开联调时千遍一律的 Http 接口,一起看看接口还可以怎么约定,分别适用于哪些场景,你现在处于哪个场景。 2 概述本文主要讲了四种接口设计方案,分别是:REST、gRPC、GraphQL、Webhooks,下面分别介绍一下。 RESTREST 也许是最通用,也是最常用的接口设计方案,它是 无状态的,以资源为核心,针对如何操作资源定义了一系列 URL 约定,而操作类型通过 GET POST PUT DELETE 等 HTTP Methods 表示。 REST 基于原生 HTTP 接口,因此改造成本很小,而且其无状态的特性,降低了前后端耦合程度,利于快速迭代。 随着未来发展,REST 可能更适合提供微服务 API。 使用举例: curl -v -X GET https://api.sandbox.paypal.com/v1/activities/activities?start_time=2012-01-01T00:00:01.000Z&amp;end_time=2014-10-01T23:59:59.999Z&amp;page_size=10 \\-H "Content-Type: application/json" \\-H "Authorization: Bearer Access-Token" gRPCgRPC 是对 RPC 的一个新尝试,最大特点是使用 protobufs 语言格式化数据。 RPC 主要用来做服务器之间的方法调用,影响其性能最重要因素就是 序列化/反序列化 效率。RPC 的目的是打造一个高效率、低消耗的服务调用方式,因此比较适合 IOT 等对资源、带宽、性能敏感的场景。而 gRPC 利用 protobufs 进一步提高了序列化速度,降低了数据包大小。 使用举例: gRPC 主要用于服务之间传输,这里拿 Nodejs 举例: 定义接口。由于 gRPC 使用 protobufs,所以接口定义文件就是 helloworld.proto: // The greeting service definition.service Greeter { // Sends a greeting rpc SayHello (HelloRequest) returns (HelloReply) {} // Sends another greeting rpc SayHelloAgain (HelloRequest) returns (HelloReply) {}}// The request message containing the user's name.message HelloRequest { string name = 1;}// The response message containing the greetingsmessage HelloReply { string message = 1;} 这里定义了服务 Greeter,拥有两个方法:SayHello 与 SayHelloAgain,通过 message 关键字定义了入参与出参的结构。 事实上利用 protobufs,传输数据时仅传送很少的内容,作为代价,双方都要知道接口定义规则才能序列化/反序列化。 定义服务器: function sayHello(call, callback) { callback(null, { message: "Hello " + call.request.name });}function sayHelloAgain(call, callback) { callback(null, { message: "Hello again, " + call.request.name });}function main() { var server = new grpc.Server(); server.addProtoService(hello_proto.Greeter.service, { sayHello: sayHello, sayHelloAgain: sayHelloAgain }); server.bind("0.0.0.0:50051", grpc.ServerCredentials.createInsecure()); server.start();} 我们在 50051 端口支持了 gRPC 服务,并注册了服务 Greeter,并对 sayHello sayHelloAgain 方法做了一些业务处理,并返回给调用方一些数据。 定义客户端: function main() { var client = new hello_proto.Greeter( "localhost:50051", grpc.credentials.createInsecure() ); client.sayHello({ name: "you" }, function(err, response) { console.log("Greeting:", response.message); }); client.sayHelloAgain({ name: "you" }, function(err, response) { console.log("Greeting:", response.message); });} 可以看到,客户端和服务端同时需要拿到 proto 结构,客户端数据发送也要依赖 proto 包提供的方法,框架会内置做掉序列化/反序列化的工作。 也有一些额外手段将 gRPC 转换为 http 服务,让网页端也享受到其高效、低耗的好处。但是不要忘了,RPC 最常用的场景是 IOT 等硬件领域,网页场景也许不会在乎节省几 KB 的流量。 GraphQLGraphQL 不是 REST 的替代品,而是另一种交互形式:前端决定后端的返回结果。 GraphQL 带来的最大好处是精简请求响应内容,不会出现冗余字段,前端可以决定后端返回什么数据。但要注意的是,前端的决定权取决于后端支持什么数据,因此 GraphQL 更像是精简了返回值的 REST,而后端接口也可以一次性定义完所有功能,而不需要逐个开发。 再次强调,相比 REST 和 gRPC,GraphQL 是由前端决定返回结果的反模式。 使用举例: 原文推荐参考 GitHub GraphQL API 比如查询某个组织下的成员,REST 风格接口可能是: curl -v https://api.github.com/orgs/:org/members 含义很明确,但问题是返回结果不明确,必须实际调试才知道。换成等价的 GraphQL 是这样的 query { organization(login: "github") { members(first: 100) { edges { node { name avatarUrl } } } }} 返回的结果和约定的格式结构一致,且不会有多余的字段: { "data": { "organization": { "members": { "edges": [ { "node": { "name": "Chris Wanstrath", "avatarUrl": "https://avatars0.githubusercontent.com/u/2?v=4" } }, { "node": { "name": "Justin Palmer", "avatarUrl": "https://avatars3.githubusercontent.com/u/25?v=4" } } ] } } }} 但是能看出来,这样做需要一个系统帮助你写 query,很多框架都提供这个功能,比如 apollo-client。 Webhooks如果说 GraphQL 颠覆了前后端交互模式,那 Webhooks 可以说是彻头彻尾的反模式了,因为其定义就是,前端不主动发送请求,完全由后端推送。 它最适合解决轮询问题。或者说轮询就是一种妥协的行为,当后端不支持 Webhooks 模式时。 使用举例: Webhooks 本身也可以由 REST 或者 gRPC 实现,所以就不贴代码了。举个常用例子,比如你的好友发了一条朋友圈,后端将这条消息推送给所有其他好友的客户端,就是 Webhooks 的典型场景。 最后作者给出的结论是,这四个场景各有不同使用场景,无法相互替代: REST:无状态的数据传输结构,适用于通用、快速迭代和标准化语义的场景。 gRPC:轻量的传输方式,特殊适合对性能高要求或者环境苛刻的场景,比如 IOT。 GraphQL: 请求者可以自定义返回格式,某些程度上可以减少前后端联调成本。 Webhooks: 推送服务,主要用于服务器主动更新客户端资源的场景。 3 精读REST 并非适用所有场景本文给了我们一个更大的视角看待日常开发中的接口问题,对于奋战在一线的前端同学,接触到 90% 的接口都是非 REST 规则的 Http 接口,能真正落实 REST 的团队其实非常少。这其实暴露了一个重要问题,就是 REST 所带来的好处,在整套业务流程中到底占多大的比重? 不仅接口设计方案的使用要分场景,针对某个接口方案的重要性也要再继续细分:在做一个开放接口的项目,提供 Http 接口给第三方使用,这时必须好好规划接口的语义,所以更容易让大家达成一致使用 REST 约定;而开发一个产品时,其实前后端不关心接口格式是否规范,甚至在开发内网产品时,性能和冗余都不会考虑,效率放在了第一位。所以第一点启示是,不要埋冤当前团队业务为什么没有使用某个更好的接口约定,因为接口约定很可能是业务形态决定的,而不是凭空做技术对比从而决定的。 gRPC 是服务端交互的首选前端同学转 node 开发时,很喜欢用 Http 方式进行服务器间通讯,但可能会疑惑,为什么公司内部 Java 或者 C++ 写的服务都不提供 Http 方式调用,而是另外一个名字。了解 gRPC 后,可以认识到这些平台都是对 RPC 方式的封装,服务器间通信对性能和延时要求非常高,所以比较适合专门为性能优化的 gRPC 等服务。 GraphQL 需要配套GraphQL 不是 REST 的替代品,所以不要想着团队从 Http 接口迁移到 GraphQL 就能提升 X% 的开发效率。GraphQL 方案是一种新的前后端交互约定,所以上手成本会比较高,同时,为了方便前端同学拼 query,等于把一部分后端工作量转移给了前端,如果此时没有一个足够好用的平台快速查阅、生成、维护这些定义,开发效率可能不升反降。 总的来说,对外开放 API 或者拥有完整配套的场景,使用 GraphQL 是比较理想的,但对于快速迭代,平台又不够成熟的团队,继续使用标准 Http 接口可以更快完成项目。 Webhooks 解决特殊场景问题对于第三方平台验权、登陆等 没有前端界面做中转的场景,或者强安全要求的支付场景等,适合用 Webhooks 做数据主动推送。说白了就是在前端无从参与,或者因为前端安全问题不适合参与时,就是 Webhooks 的场景。很显然 Webhooks 也不是 Http 的替代品,不过的确是一种新的前后端交互方式。 对于慢查询等场景,前端普遍使用轮询完成,这和 Socket 相比体验更弱,但无状态的特性反而会降低服务器负担,所以慢查询和即时通讯要区分对待,用户对消息及时性的敏感程度决定了使用哪种方案。 4 总结最后,上面总结的内容一定还有许多疏漏,欢迎补充。 5 更多讨论 讨论地址是:精读《REST, GraphQL, Webhooks, & gRPC 如何选型》 · Issue ##102 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《Rust 是 JS 基建的未来》","path":"/wiki/WebWeekly/前沿技术/《Rust 是 JS 基建的未来》.html","content":"当前期刊数: 218 Rust Is The Future of JavaScript Infrastructure 这篇文章讲述了 Rust 正在 JS 基建圈流行的事实:Webpack、Babel、Terser、Prettier、ESLint 这些前些年才流行起来的工具都已有了 Rust 替代方案,且性能有着 10~100 倍的提升。 前端基建的迭代浪潮从未停歇,当上面这些工具给 Gulp、js-beautify、tslint 等工具盖上棺材盖时,基于 Rust 的新一代构建工具已经悄悄将棺材盖悬挂在 webpack、babel、prettier、terser、eslint 它们头上,不知道哪天就会盖上。 原文已经有了不错的 中文翻译,值得一提的是,原文一些英文名词对应着特定中文解释,记录如下: low-level programming:低级编程 底层编程。 ergonomics:人体工程学 人机工程学。 opinionated:自以为是,固执的 开箱即用的。 critical adoption:批判性采用 技术选型临界点。 精读本文不会介绍 Rust 如何使用,而会重点介绍原文提到的 Rust 工具链的一些基本用法,如果你感兴趣,可以立刻替换现有的工具库! swcswc 是基于 Rust 开发的一系列编译、打包、压缩等工具,并且被广泛应用于更多更上层的 JS 基建,大大推动了 Rust 在 JS 基建的影响力,所以要第一个介绍。 swc 提供了一系列原子能力,涵盖构建与运行时: @swc/cli@swc/cli 可以同时构建 js 与 ts 文件: const a = 1 npm i -D @swc/clinpx swc ./main.ts## output:## Successfully compiled 1 file with swc.## var a = 1; 具体功能与 babel 类似,都可以让浏览器支持先进语法或者 ts,只是 @swc/cli 比 babel 快了至少 20 倍。可以通过 .swcrc 文件做 自定义配置。 @swc/core你可以利用 @swc/core 制作更上层的构建工具,所以它是 @swc/cli 的开发者调用版本。基本 API 来自官网开发者文档: const swc = require("@swc/core");swc .transform("source code", { // Some options cannot be specified in .swcrc filename: "input.js", sourceMaps: true, // Input files are treated as module by default. isModule: false, // All options below can be configured via .swcrc jsc: { parser: { syntax: "ecmascript", }, transform: {}, }, }) .then((output) => { output.code; // transformed code output.map; // source map (in string) }); 其实就是把 cli 调用改成了 node 调用。 @swc/wasm-web@swc/wasm-web 可以在浏览器运行时调用 wasm 版的 swc,以得到更好的性能。下面是官方的例子: import { useEffect, useState } from "react";import initSwc, { transformSync } from "@swc/wasm-web";export default function App() { const [initialized, setInitialized] = useState(false); useEffect(() => { async function importAndRunSwcOnMount() { await initSwc(); setInitialized(true); } importAndRunSwcOnMount(); }, []); function compile() { if (!initialized) { return; } const result = transformSync(`console.log('hello')`, {}); console.log(result); } return ( <div className="App"> <button onClick={compile}>Compile</button> </div> );} 这个例子可以在浏览器运行时做类似 babel 的事情,无论是低代码平台还是在线 coding 平台都可以用它做运行时编译。 @swc/jest@swc/jest 提供了 Rust 版本的 jest 实现,让 jest 跑得更快。使用方式也很简单,首先安装: npm i @swc/jest 然后在 jest.config.js 配置文件中,将 ts 文件 compile 指向 @swc/jest 即可: module.exports = { transform: { "^.+\\\\.(t|j)sx?$": ["@swc/jest"], },}; swc-loaderswc-loader 是针对 webpack 的 loader 插件,代替 babel-loader: module: { rules: [ { test: /\\.m?js$/, exclude: /(node_modules)/, use: { // `.swcrc` can be used to configure swc loader: "swc-loader" } } ];} swcpack增强了多文件 bundle 成一个文件的功能,基本可以认为是 swc 版本的 webpack,当然性能也会比 swc-loader 方案有进一步提升。 截至目前,该功能还在测试阶段,只要安装了 @swc/cli 就可使用,通过创建 spack.config.js 后执行 npx spack 即可运行,和 webpack 的使用方式一样。 DenoDeno 的 linter、code formatter、文档生成器采用 swc 构建,因此也算属于 Rust 阵营。 Deno 是一种新的 js/ts 运行时,所以我们总喜欢与 node 进行类比。quickjs 也一样,这三个都是一种对 js 语言的运行器,作为开发者,需求永远是更好的性能、兼容性与生态,三者几乎缺一不可,所以当下虽然不能完全代替 Nodejs,但作为高性能替代方案是很香的,可以基于他们做一些跨端跨平台的解析器,比如 kraken 就是基于 quickjs + flutter 实现的一种高性能 web 渲染引擎,是 web 浏览器的替代方案,作为一种跨端方案。 esbuildesbuild 是较早被广泛使用的新一代 JS 基建,是 JS 打包与压缩工具。虽然采用 Go 编写,但性能与 Rust 不相上下,可以与 Rust 风潮放在一起看。 esbuild 目前有两个功能:编译和压缩,理论上分别可代替 babel 与 terser。 编译功能的基本用法: require('esbuild').transformSync('let x: number = 1', { loader: 'ts',})// 'let x = 1; ' 压缩功能的基本用法: require('esbuild').transformSync('fn = obj => { return obj.x }', { minify: true,})// 'fn=n=>n.x; ' 压缩功能比较稳定,适合用在生产环境,而编译功能要考虑兼容 webpack 的地方太多,在成熟稳定后才考虑能在生产环境使用,目前其实已经有不少新项目已经在生产环境使用 esbuild 的编译功能了。 编译功能与 @swc 类似,但因为 Rust 支持编译到 wasm,所以 @swc 提供了 web 运行时编译能力,而 esbuild 目前还没有看到这种特性。 RomeRome 是 Babel 作者做的基于 Nodejs 的前端基建全家桶,包含但不限于 Babel, ESLint, webpack, Prettier, Jest。目前 计划使用 Rust 重构,虽然还没有实现,但我们姑且可以把 Rome 当作 Rust 的一员。 rome 是个全家桶 API,所以你只需要 yarn add rome 就完成了所有环境准备工作。 rome bundle 打包项目。 rome compile 编译单个文件。 rome develop 调试项目。 rome parse 解析文件抽象语法树。 rome analyzeDependencies 分析依赖。 Rome 还将文件格式化与 Lint 合并为了 rome check 命令,并提供了友好 UI 终端提示。 其实我并不太看好 Rome,因为它负担太重了,测试、编译、Lint、格式化、压缩、打包的琐碎事情太多,把每一块交给社区可能会做得更好,这不现在还在重构中,牵一发而动全身。 NAPI-RSNAPI-RS 提供了高性能的 Rust 到 Node 的衔接层,可以将 Rust 代码编译后成为 Node 可调用文件。下面是官网的例子: ##[js_function(1)]fn fibonacci(ctx: CallContext) -> Result<JsNumber> { let n = ctx.get::<JsNumber>(0)?.try_into()?; ctx.env.create_int64(fibonacci_native(n))} 上面写了一个斐波那契数列函数,直接调用了 fibonacci_native 函数实现。为了让这个方法被 Node 调用,首先安装 CLI:npm i @napi-rs/cli。 由于环境比较麻烦,因此需要利用这个脚手架初始化一个工作台,我们在里面写 Rust,然后再利用固定的脚本发布 npm 包。执行 napi new 创建一个项目,我们发现入口文件肯定是个 js,毕竟要被 node 引用,大概长这样(我创建了一个 myLib 包): const { loadBinding } = require('@node-rs/helper')/** * __dirname means load native addon from current dir * 'myLib' is the name of native addon * the second arguments was decided by `napi.name` field in `package.json` * the third arguments was decided by `name` field in `package.json` * `loadBinding` helper will load `myLib.[PLATFORM].node` from `__dirname` first * If failed to load addon, it will fallback to load from `myLib-[PLATFORM]` */module.exports = loadBinding(__dirname, 'myLib', 'myLib') 所以 loadBinding 才是入口,同时项目文件夹下存在三个系统环境包,分别供不同系统环境调用: @cool/core-darwin-x64 macOS x64 平台。 @cool/core-win32-x64 Windows x64 平台。 @cool/core-linux-arm64-gnu Linux aarch64 平台。 @node-rs/helper 这个包的作用是引导 node 执行预编译的二进制文件,loadBinding 函数会尝试加载当前平台识别的二进制包。 将 src/lib.rs 的代码改成上面斐波那契数列的代码后,执行 npm run build 编译。注意在编译前需要安装 rust 开发环境,只要一行脚本即可安装,具体看 rustup.rs。然后把当前项目整体当作 node 包发布即可。 发布后,就可以在 node 代码中引用啦: import { fibonacci } from 'myLib'function hello() { let result = fibonacci(10000) console.log(result) return result} NAPI-RS 作为 Rust 与 Node 的桥梁,很好的解决了 Rust 渐进式替换现有 JS 工具链的问题。 Rust + WebAssemblyRust + WebAssembly 说明 Rust 具备编译到 wasm 的能力,虽然编译后代码性能会变得稍慢,但还是比 js 快很多,同时由于 wasm 的可移植性,让 Rust 也变得可移植了。 其实 Rust 支持编译到 WebAssembly 也不奇怪,因为本来 WebAssembly 的定位之一就是作为其他语言的目标编译产物,然后它本身支持跨平台,这样它就很好的完成了传播的使命。 WebAssembly 是一个基于栈的虚拟机 (stack machine),所以跨平台能力一流。 想要将 Rust 编译为 wasm,除了安装 Rust 开发环境外,还要安装 wasm-pack。 安装后编译只需执行 wasm-pack build 即可。更多用法可以查看 API 文档。 dprintdprint 是用 rust 编写的 js/ts 格式化工具,并提供了 dprint-node 版本,可以直接作为 node 包,通过 npm 安装使用,从 源码 可以看到,使用 NAPI-RS 实现。 dprint-node 可以直接在 Node 中使用: const dprint = require('dprint-node');dprint.format(filePath, code, options); 参数文档。 ParcelParcel 严格来说算是上一代 JS 基建,它出现在 Webpack 之后,Rust 风潮之前。不过由于它已经采用 SWC 重写,所以姑且算是跟上了时髦。 总结前端全家桶已经有了一整套 Rust 实现,只是对于存量项目的编译准确性需要大量验证,我们还需要时间等待这些库的成熟度。 但毫无疑问的是,Rust 语言对 JS 基建支持已经较为完备了,剩下的只是工具层逻辑覆盖率的问题,都可以随时间而解决。而用 Rust 语言重写后的逻辑带来的巨幅性能提升将为社区注入巨大活力,就像原文说的,前端社区可以为了巨大性能提升而引入 Rust 语言,即便这可能导致为社区贡献门槛的提高。 讨论地址是:精读《Rust 是 JS 基建的未来》· Issue ##371 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《SQL vs Flux》","path":"/wiki/WebWeekly/前沿技术/《SQL vs Flux》.html","content":"当前期刊数: 69 1 引言 对时序数据的处理有两种方式,如图所示,右边是 SQL,左边是自定义查询语言,也称为 NoSQL,处于中间地带的称为 SQL-LIKE 语言。 本文通过对比 SQL 阵营的 TimescaleDB 与 NoSQL 阵营的 InfluxDB,试图给出一些对比。 2 概述TimescaleDBTimescaleDB 完全接受了 SQL 语法,因此几乎没有什么学习门槛,更通过可视化操作优化了使用方式。 InfluxDBInfluxDB 创造了一种新的查询语言,这里是 Flux 文法.(了解更多文法相关知识,可以移步 精读《手写 SQL 编译器 - 文法介绍》) InfluxDB 为什么创造 Flux 语法InfluxDB 之所以创造 Flux 语法,而不使用 SQL,主要有两个原因: 更强的查询功能:SQL 无法轻松完成时序查询。 时间序列的查询需要基于流的函数模型,而不是 SQL 的代数模型。 所谓流模型,就类似 JS 函数式编程中类似概念: source.pipe( map(x => x + x), mergeMap(...), filter(...)) 更强的查询功能?InfluxDB 拿下面例子举例: Flux: from(db:"telegraf") |> range(start:-1h) |> filter(fn: (r) => r._measurement == "foo") |> exponentialMovingAverage(size:-10s) SQL: select id, temp, avg(temp) over (partition by group_nr order by time_read) as rolling_avgfrom ( select id, temp, time_read, interval_group, id - row_number() over (partition by interval_group order by time_read) as group_nr from ( select id, time_read, 'epoch'::timestamp + '900 seconds'::interval * (extract(epoch from time_read)::int4 / 900) as interval_group, temp from readings ) t1) t2order by time_read; 虽然看上去 SQL 写法比 Flux 长了不少,但其实 Flux 代码的核心在于实现了自定义函数 exponentialMovingAverage,而 PostgreSQL 也有 创建函数 的能力。 通过 SQL 定义一个自定义函数: CREATE OR REPLACE FUNCTION exponential_moving_average_sfunc(state numeric, next_value numeric, alpha numeric)RETURNS numeric LANGUAGE SQL AS$$SELECT CASE WHEN state IS NULL THEN next_value ELSE alpha * next_value + (1-alpha) * state END$$;CREATE AGGREGATE exponential_moving_average(numeric, numeric)(sfunc = exponential_moving_average_sfunc, stype = numeric); 之后可以像 Flux 函数一样的调用: SELECT time, exponential_moving_average(value, 0.5) OVER (ORDER BY time)FROM telegraphWHERE measurement = 'foo' and time > now() - '1 hour'; 可见从函数定义上也和 Flux 打成平手,作者认为既然功能相同,而基于 SQL 的语言学习成本更低,所以不需要创造一个新的语言。 关于语法糖与 SQL 标准作者认为,虽然有观点认为,Flux 的语法糖比 SQL 更简洁,但代码的可维护性并不是行数越少越好,而是是否容易被人类理解。 对于创造一个函数标准可能破坏 SQL 的可移植性,作者认为那也比完全创造一个新语法要强。 基于流的函数模型强于 SQL 代数模型?诚然,从功能角度来看,当然函数模型强于代数模型,因为代数模型只是在描述事物,而不能精准控制执行的每一步。 但我们要弄清楚 SQL 的场景,是通过描述一个无顺序的查询问题,让数据库给出结果。而在查询过程中,数据库可以对 SQL 语句作出一些优化。 反观函数模型,是在用业务代码描述查询请求,这种代码是无法被自动优化的,虽然为用户提供了更底层的控制,但其代价是无法被数据库执行引擎所优化。 如果你更看中查询语言,而不是具体执行逻辑,SQLl 依然是最好的选择。 3 总结之所以制作这一期精读,是为了探索 SQL 与其他查询语言的关系,去理解为什么 SQL 沿用至今。 SQL 与其他函数类查询语言不在一个层面上,如果用语法糖、可操纵性抨击 SQL,只能得出看似正确,实则荒谬的结论。 SQL 是一个查询语言,与普通编程语言相比,它还在上层,最终会转化为关系代数执行,但关系代数会遵循一些等价的转换规律,比如交换律、结合律、过滤条件拆分等等,通过预估每一步的时间开销,将 SQL 执行顺序重新组合,可以提高执行效率。 如果有多个 SQL 同时执行,还可以整合成一个或多个新的 SQL,合并重复的查询请求。 在数据驱动商业的今天,SQL 依然是数据查询最通用的解决方案。 4 更多讨论 讨论地址是:精读《SQL vs Flux》 · Issue ##96 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《Scheduling in React》","path":"/wiki/WebWeekly/前沿技术/《Scheduling in React》.html","content":"当前期刊数: 99 1. 引言这次介绍的文章是 scheduling-in-react,简单来说就是 React 的调度系统,为了得到更顺滑的用户体验。 毕竟前端做到最后,都是体验优化,前端带给用户的价值核心就在于此。 2. 概述文章从 Dan 在 JSConf 提到的 Demo 说起: 这是一个测试性能的 Demo,随着输入框字符的增加,下方图表展示的数据量会急速提升。在 Synchronous 与 Debounced 模式下的效果都不尽如人意,只有 Concurrent 模式下看起来是顺畅的。 那么为什么普通的 Demo 会很卡呢? 这就涉及到浏览器 Event Loop 规则了。 JS 是单线程的,浏览器同一时间只能做一件事情,而肉眼能识别的刷新频率在 60FPS 左右,这意味着我们需要在 16ms 之内完成 Demo 中的三件事:响应用户输入,做动画,Dom 渲染。 然而目前几乎所有框架都使用同步渲染模式,这意味着如果一个渲染函数执行时间超过了 16ms,则不可避免的发生卡顿。 总结一下有两个主要问题: 长时间运行的任务造成页面卡顿,我们需要保证所有任务能在几毫秒内完成,这样才能保证页面的流畅。 不同任务优先级不同,比如响应用户输入的任务优先级就高于动画。这个很好理解。 React 调度机制为了解决这个问题,React16 通过 Concurrent(并行渲染) 与 Scheduler(调度)两个角度解决问题: Concurrent: 将同步的渲染变成可拆解为多步的异步渲染,这样可以将超过 16ms 的渲染代码分几次执行。 Scheduler: 调度系统,支持不同渲染优先级,对 Concurrent 进行调度。当然,调度系统对低优先级任务会不断提高优先级,所以不会出现低优先级任务总得不到执行的情况。 为了保证不产生阻塞的感觉,调度系统会将所有待执行的回调函数存在一份清单中,在每次浏览器渲染时间分片间尽可能的执行,并将没有执行完的内容 Hold 住留到下个分片处理。 Concurrent 的正式 API 会在 2019 Q2 发布,现在可以通过 <React.unstable_ConcurrentMode> API 方式调用: ReactDOM.render( <React.unstable_ConcurrentMode> <App /> </React.unstable_ConcurrentMode>, rootElement); 只申明这个是不够的,因为我们还没有申明各函数执行的优先级。我们可以通过 npm i scheduler 包来申明函数的优先级: import { unstable_next } from "scheduler";function SearchBox(props) { const [inputValue, setInputValue] = React.useState(); function handleChange(event) { const value = event.target.value; setInputValue(value); unstable_next(function() { props.onChange(value); sendAnalyticsNotification(value); }); } return <input type="text" value={inputValue} onChange={handleChange} />;} 在 unstable_next() 作用域下的代码优先级是 Normal,那么产生的效果是: 如果 props.onChange(value) 可以在 16ms 内执行完,则与不使用 unstable_next 没有区别。 如果 props.onChange(value) 的执行时间过长,可能这个函数会在下次几次的 Render 中陆续执行,不会阻塞后续的高优先级任务。 调度带来的限制调度系统也存在两个问题。 调度系统只能有一个,如果同时存在两个调度系统,就无法保证调度正确性。 调度系统能力有限,只能在浏览器提供的能力范围内进行调度,而无法影响比如 Html 的渲染、回收周期。 为了解决这个问题,Chrome 正在与 React、Polymer、Ember、Google Maps、Web Standars Community 共同创建一个 浏览器调度规范,提供浏览器级别 API,可以让调度控制更底层的渲染时机,也保证调度器的唯一性。 3. 精读关于 React 调度系统的剖析,可以读 深入剖析 React Concurrent 这篇文章,感谢我们团队的 淡苍 提供。 简单来说,一次 Render 一般涉及到许多子节点,而 Fiber 架构在 Render 阶段可以暂停,一个一个节点的执行,从而实现了调度的能力。 React 调度能力的限制 这意味着,如果你的 React 应用目前是流畅的,开启 Concurrent 并不会对你的应用带来性能体验上的提升,如果你的 React 应用目前是卡顿的,或者在某些场景下是卡顿的,那么 Concurrent 或许可以挽救你一下,带来一些改变。 正如《深入剖析 React Concurrent》一文提到的,如果你的应用没有性能问题,就不要指望 React 调度能力有所帮助了。 这也是在说,如果一段代码逻辑不存在性能问题,就不需要使用 Concurrent 优化,因为这种优化是无效的。我们需要能分辨哪些逻辑需要优化,哪些逻辑不要。 从现在开始尝试 Function Component为了配合 React Schedule 的实现,学会使用 Function Component 模式编写组件是很重要的,因为: Class Component 的生命周期概念阻碍了 React 调度系统对任务的拆分。 调度系统可能对 componentWillMount 重复调用,使得 Class Component 模式下很容易写出错误的代码。 Function Component 遵循了更严格的副作用分离,这使得 Concurrent 执行过程不会引发意外效果。 React.lazy与 Concurrent 一起发布的,还有 React 组件动态 import 与载入方案。正常的组件载入是这样的: import OtherComponent from "./OtherComponent";function MyComponent() { return ( <div> <OtherComponent /> </div> );} 但如果使用了 import() 动态载入,可以使用 React.lazy 让动态引入的组件像普通组件一样被使用: const OtherComponent = React.lazy(() => import("./OtherComponent"));function MyComponent() { return ( <div> <OtherComponent /> </div> );} 如果要加入 Loading,就可以配合 Suspense 一起使用: import React, { lazy, Suspense } from "react";const OtherComponent = lazy(() => import("./OtherComponent"));function MyComponent() { return ( <Suspense fallback={<div>Loading...</div>}> <OtherComponent /> </Suspense> );} 和 Concurrent 类似,React.lazy 方案也是一种对性能有益的组件加载方案。 调度分类调度分 4 个等级: Immediate:立即执行,最高优先级。 render-blocking:会阻塞渲染的优先级,优先级类似 requestAnimationFrame。如果这种优先级任务不能被执行,就可能导致 UI 渲染被 block。 default:默认优先级,普通的优先级。优先级可以理解为 setTimeout(0) 的优先级。 idle:比如通知等任务,用户看不到或者不在意的。 目前建议的 API 类似如下: function mytask() { ...}myQueue = TaskQueue.default("render-blocking") 先创建一个执行队列,并设置队列的优先级。 taskId = myQueue.postTask(myTask, <list of args>); 再提交队列,拿到当前队列的执行 id,通过这个 id 可以判断队列何时执行完毕。 myQueue.cancelTask(taskId); 必要的时候可以取消某个函数的执行。 4. 总结随着 Hooks 的发布,即将到来的 Concurrent 与 Suspense 你是否准备好了呢? 笔者希望大家一起思考,这三种 API 会给前端开发带来什么样的改变?欢迎留言! 讨论地址是:精读《Scheduling in React》 · Issue ##146 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Rest vs Spread 语法》","path":"/wiki/WebWeekly/前沿技术/《Rest vs Spread 语法》.html","content":"当前期刊数: 261 符号 ... 在 JS 语言里同时被用作 Rest 与 Spread 两个场景,本周我们就结合 Rest vs Spread syntax in JavaScript 聊聊这两者的差异以及一些坑。 概述Spread... 作为 Spread 含义时,效果为扩散对象的属性: const obj = { a: 1, b: 2, c: 3,};const newObj = { ...obj,};console.log(newObj);// { a: 1, b: 2, c: 3 } ... 符号很形象的表示了把对象中所有属性拿出来平铺的含义。说到平铺,Spread 放在函数参数时,也表示将对象中每个 properties 拿出来作为平铺参数: const arr = [1, 2, 3];const sum = (a, b, c) => a + b + c;console.log(sum(...arr)); // Outputs: 6// ^// sum(1, 2, 3) Rest... 作 Rest 含义时,表示将多个值收集为一个数组,如用在函数定义的位置: const sum = (...args) => { return args.reduce((acc, curr) => acc + curr, 0); // ^ // [1, 2, 3]};console.log(sum(1, 2, 3));// 6 当然也可以在 ... 前面放置其他变量,这样 ... 仅聚合剩余的变量。... 之后不能再定义变量或者 ...: const sum = (a, b, ...restOfArguments) => { return a + b + restOfArguments.reduce((acc, curr) => acc + curr, 0); // ^ ^ ^ // 1 2 [3, 4, 5]};console.log(sum(1, 2, 3, 4, 5));// 15 精读Rest 处理 Set 与 MapSet 与 Map 都可以通过数组模式赋初值: const mySet = new Set(["a", "b", "c"]);const myMap = new Map([ ["a", 1], ["b", 2], ["c", 3],]); 在 ... 符号作 Rest 用途时,可以将其解构为数组: [...mySet] // ['a', 'b', 'c'][...myMap] // [ ['a', 1], ['b', 2], ['c', 3] ] 特别的,Map 与 Set 仅支持数组方式解构,不支持对象模式解构: {...mySet} // {}{...myMap} // {} 但对于一个普通数组,是同时支持数组与对象模式解构的: const arr = ['a', 'b', 'c'][...arr] // ['a', 'b', 'c']{...arr} // {0: 'a', 1: 'b', 2: 'c'} 这是因为数组变量有潜在的下标,这些下标可以转换为对象的 Key,而 Map Set 不存在下标,所以转换为对象找不到 Key,因此就不支持对象模式的解构。 更具体的原因与对象的可迭代性有关,虽然 Map 与 Set 都支持迭代,但如果用 for key of 来测试,会发现它们的 key 是 undefined。 Spread 会丢失 get() 与 set()Spread 并不代表完整复制整个对象,它能拷贝这个对象属性定义中的瞬时值,比如: const obj = { a: 1, get b() { return 2; },};const newObj = { ...obj }; newObj.b 属性不再是 get() 方法,而是固定值 2,这在 get() 函数内返回非固定值,或希望懒加载代码时会产生问题。 究其原因,Spread 毕竟不是在定义对象,更恰当的理解应该是 “访问对象”,所以访问的结果就是执行 get()。 Rest 会跳过不可枚举属性const err = new Error('error'){...error} // {} Error 拥有两个不可枚举属性 message 与 stack,所以不会被 Rest 收集到,遇到这种场景可以使用其他方式,如直接访问 error.message。 总结... 用在赋值位置含义为 Spread,用在参数收集位置含义为 Rest,同时因为该语法写起来很简单,因此有一些默认逻辑小心不要掉坑里,比如默认会执行对象属性的 getter,会跳过不可枚举属性等。 讨论地址是:精读《Rest vs Spread 语法》· Issue ##447 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Spring 概念》","path":"/wiki/WebWeekly/前沿技术/《Spring 概念》.html","content":"当前期刊数: 163 spring 是 Java 非常重要的框架,且蕴含了一系列设计模式,非常值得研究,本期就通过 Spring 学习 这篇文章了解一下 spring。 spring 为何长寿spring 作为一个后端框架,拥有 17 年历史,这在前端看来是不可思议的。前端几乎没有一个框架可以流行超过 5 年,就最近来看,react、angular、vue 三大框架可能会活的久一点,他们都是前端相对成熟阶段的产物,我们或多或少可以看出一些设计模式。然而这些前端框架与 spring 比起来还是差距很大,我们来看看 spring 到底强大在哪。 设计模式设计模式是一种思想,不依附于任何编程语言与开发框架。比如你学会了工厂设计模式,可以在后端用,也可以转到前端用,可以在 Go 语言用,也可以在 Typescript 用,可以在 React 框架用,也可以在 Vue 里用,所以设计模式是一种具有迁移能力的知识,学会后可以受益整个职业生涯,而语言、框架则不具备迁移性,前端许多同学都把精力花在学习框架特性上,遇到前端技术迭代时期就尴尬了,这就是为什么大公司面试要问框架原理,就是看看你能否抓住一些不变的东西,所以洋洋洒洒的说上下文相关的细节也不是面试官想要的,真正想听到的是你抽象后对框架原理共性的总结。 spring 框架就用到了许多设计模式,包括: 工厂模式:用工厂生产对象实例来代替原始的 new。所谓工厂就是屏蔽实例话的细节,调用处无需关心实例化对象需要的环境参数,提升可维护性。spring 的 BeanFactory 创建 bean 对象就是工厂模式的体现。代理模式:允许通过代理对象访问目标对象。Spring 实现 AOP 就是通过动态代理模式。单例模式:单实例。spring 的 bean 默认都是单例。包装器模式:将几个不同方法通用部分抽象出来,调用时通过包装器内部引导到不同的实现。比如 spring 连接多种数据库就使用了包装器模式简化。观察者模式:这个前端同学很熟悉,就是事件机制,spring 中可以通过 ApplicationEvent 实践观察者模式。适配器模式:通过适配器将接口转换为另一个格式的接口。spring AOP 的增强和通知就使用了适配器模式。模板方法模式:父类先定义一些函数,这些函数之间存在调用关联,将某些设定为抽象函数等待子类继承时去重写。spring 的 jdbcTemplate、hibernateTemplate 等数据库操作类使用了模版方法模式。 全家桶spring 作为一个全面的 java 框架,提供了系列全家桶满足各种场景需求:spring mvc、spring security、spring data、spring boot、spring cloud。 spring boot:简化了 spring 应用配置,约定大于配置的思维。 spring data:是一个数据操作与访问工具集,比如支持 jdbc、redis 等数据源操作。 spring cloud:是一个微服务解决方案,基于 spring boot,集成了服务发现、配置管理、消息总线、负载均衡、断路器、数据监控等各种服务治理能力。 spring security:支持一些安全模型比如单点登录、令牌中继、令牌交换等。 spring mvc:MVC 思想的 web 框架。 IOCIOC(Inverse of Control)控制反转。IOC 是 Spring 最核心部分,因为所有对象调用都离不开 IOC 模式。 假设我们有三个类:Country、Province、City,最大的类别是国家,其次是省、城市,国家类需要调用省类,省类需要调用城市类: 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)参数,我们就要改动所有类代码: 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 的代码,而不用改动其他类的代码: 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 类。然而这样做也是有成本的,就是类实例化步骤会稍微繁琐一些: City city = new City(1000);Province province = new Province(city);Country country = new Country(province); 这就是控制反转,由 Country 依赖 Province 变成了类依赖框架(上面的实例化代码)注入。 然而手动维护这种初始化依赖是繁琐的,spring 提供了 bean 容器自动做这件事,我们只需要利用装饰器 Autowired 就可以自动注入依赖: @Componentpublic class Country { @Autowired private Province province;}@Componentpublic class Province { @Autowired public City city;}@Componentpublic class City { } 实际上这种自动分析并实例化的手段,不仅比手写方便,还能解决循环依赖的问题。在实际场景中,两个类相互调用是很常见的,假设现在有 A、B 类相互依赖: @Componentpublic class A { @Autowired private B b;}@Componentpublic class B { @Autowired public A a;} 那么假设我们想获取 A 实例,会经历这样一个过程: 获取 A 实例 -> 实例化不完整 A -> 检测到注入 B -> 实例化不完整 B -> 检测到注入 A -> 注入不完整 A -> 得到完整 B -> 得到完整 A -> 返回 A 实例 其实 spring 仅支持单例模式下非构造器的循环依赖,这是因为其内部有一套机制,让 bean 在初始化阶段先提前持有对方引用地址,这样就可以同时实例化两个对象了。 除了方便之外,IOC 配合 spring 容器概念还可以使获取实例时不用关心一个类实例化需要哪些参数,只需要直接申明获取即可,这样在类的数量特别多,尤其是大量代码不是你写的情况下,不需要阅读类源码也可以轻松获取实例,实在是大大提升了可维护性。 说到这就提到了 Bean 容器,在 spring 概念中,Bean 容器是对 class 的加强,如果说 Class 定义了类的基本含义,那 Bean 就是对类进行使用拓展,告诉我们应该如何实例化与使用这个类。 举个例子,比如利用注解描述的这段 Bean 类: @Configurationpublic class CityConfig { @Scope("prototype") @Lazy @Bean(initMethod = "init", destroyMethod = "destroy") public City city() { return new City() }} 可以看到,额外描述了是否延迟加载,是否单例,初始化与析构函数分别是什么等等。 下面给出一个从 Bean 获取实例的例子,采用比较古老的 xml 配置方式: public interface City { Int getPeople();} public class CityImpl implements City { public Int getPeople() { return 1000; }} 接下来用 xml 描述这个 bean: <?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 的实例。 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 构造函数要传递什么参数,不需要关心它依赖哪些其他的类,只要这一句话就可以拿到实例,是不是在维护项目时省心了很多。 AOPAOP(Aspect Oriented Program)面向切面编程。 AOP 是为了解决主要业务逻辑与次要业务逻辑之间耦合问题的。主要业务逻辑比如登陆、数据获取、查询等,次要业务逻辑比如性能监控、异常处理等等,次要业务逻辑往往有:不重要、和业务关联度低、贯穿多处业务逻辑的特性,如果没有好的设计模式,只能在业务代码里将主要逻辑与次要逻辑混合起来,但 AOP 可以做到主要、次要业务逻辑隔离。 使用 AOP 就是在定义在哪些地方(类、方法)切入,在什么地方切入(方法前、后、前后)以及做什么。 比如说,我们想在某个方法前后分别执行两个函数计算执行时间,下面是主要业务逻辑: @Component("work")public class Work { public void do() { System.out.println("执行业务逻辑"); }} 再定义切面方法: @Component@Aspectclass 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() 可以执行原函数: @Component@Aspectclass 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Serverless 给前端带来了什么》","path":"/wiki/WebWeekly/前沿技术/《Serverless 给前端带来了什么》.html","content":"当前期刊数: 94 1. 引言Serverless 是一种 “无服务器架构”,让用户无需关心程序运行环境、资源及数量,只要将精力 Focus 到业务逻辑上的技术。 现在公司已经实现 DevOps 化,正在向 Serverless 迈进,而为什么前端要关注 Serverless? 对业务前端同学: 会改变前后端接口定义规范。 一定会改变前后端联调方式,让前端参与服务器逻辑开发,甚至 Node Java 混部。 大大降低 Nodejs 服务器维护门槛,只要会写 JS 代码就可以维护 Node 服务,而无需学习 DevOps 相关知识。 对一个自由开发者: 未来服务器部署更弹性,更省钱。 部署速度更快,更不易出错。 前端框架总是带入后端思维,而 Serverless 则是把前端思维带入了后端运维。 前端开发者其实是最早享受到 “Serverless” 好处的群体。他们不需要拥有自己的服务,甚至不需要自己的浏览器,就可以让自己的 JS 代码均匀、负载均衡的运行在每一个用户的电脑中。 而每个用户的浏览器,就像现在最时髦,最成熟的 Serverless 集群,从远程加载 JS 代码开始冷启动,甚至在冷启动上也是卓越领先的:利用 JIT 加速让代码实现毫秒级别的冷启动。不仅如此,浏览器还是实现了 BAAS 服务的完美环境,我们可以调用任何函数获取用户的 Cookie、环境信息、本地数据库服务,而无需关心用户用的是什么电脑,连接了怎样的网络,甚至硬盘的大小。 这就是 Serverless 理念。通过 FAAS(函数即服务)与 BAAS(后台即服务)企图在服务端制造前端开发者习以为常的开发环境,所以前端开发者应该更能理解 Serverless 带来的好处。 2. 精读FAAS(函数即服务) + BAAS(后台即服务) 可以称为一个完整的 Serverless 的实现,除此之外,还有 PASS(平台即服务)的概念。而通常平台环境都通过容器技术实现,最终都为了达到 NoOps(无人运维),或者至少 DevOps(开发&运维)。 简单介绍一下这几个名词,防止大家被绕晕: FAAS - Function as a service 函数即服务,每一个函数都是一个服务,函数可以由任何语言编写,除此之外不需要关心任何运维细节,比如:计算资源、弹性扩容,而且可以按量计费,且支持事件驱动。业界大云厂商都支持 FAAS,各自都有一套工作台、或者可视化工作流来管理这些函数。 BAAS - Backend as a service 后端及服务,就是集成了许多中间件技术,可以无视环境调用服务,比如数据即服务(数据库服务),缓存服务等。虽然下面还有很多 XAAS,但组成 Serverless 概念的只有 FAAS + BAAS。 PAAS - Platform as a service 平台即服务,用户只要上传源代码就可以自动持续集成并享受高可用服务,如果速度足够快,可以认为是类似 Serverless。但随着以 Docker 为代表的容器技术兴起,以容器为粒度的 PAAS 部署逐渐成为主流,是最常用的应用部署方式。比如中间件、数据库、操作系统等。 DAAS - Data as a service 数据即服务,将数据采集、治理、聚合、服务打包起来提供出去。DAAS 服务可以应用 Serverless 的架构。 IAAS - Infrastructure as a Service 基础设施即服务,比如计算机存储、网络、服务器等基建设施以服务的方式提供。 SAAS - Software as a Service 软件即服务,比如 ERP、CRM、邮箱服务等,以软件为粒度提供服务。 容器 容器就是隔离了物理环境的虚拟程序执行环境,而且环境可被描述、迁移。比较热门的容器技术是 Docker。 随着容器数量增多,就出现了管理容器集群的技术,比较有名的容器编排平台是 Kubernetes。容器技术是 Serverless 架构实现的一种选择,也是实现的基础。 NoOps 就是无人运维,比较理想主义,也许要借助 AI 的能力才能实现完全无人运维。 无人运维不代表 Serverless,Serverless 可能也需要人运维(至少现在),只是开发者不再需要关心环境。 DevOps 笔者觉得可以理解为 “开发即运维”,毕竟出了事情,开发要被问责,而一个成熟的 DevOps 体系可以让更多的开发者承担 OP 的职责,或者与 OP 更密切的合作。 回到 Serverless,未来后端开发的体验可能与前端相似:不需要关心代码运行在哪台服务器(浏览器),无需关心服务器环境(浏览器版本)、不用担心负载均衡(前端从未担心过)、中间件服务随时调用(LocalStorage、Service Worker)。 前端同学对 Serverless 应该尤为激动。就拿笔者亲身经历举例吧。 从做一款游戏说起笔者非常迷恋养成类游戏,养成游戏最常见的就是资源建造、收集,或者挂机时计算资源的 读秒规则。笔者在开发游戏的时候,最初是将客户端代码与服务端代码完全分成两套实现的: // ... UI 部分,画出一个倒计时伐木场建造进度条const currentTime = await requestBuildingProcess();const leftTime = new Date().getTime() - currentTime;// ... 继续倒计时读条// 读条完毕后,每小时木头产量 + 100,更新到客户端计时器store.woodIncrement += 100; 为了游戏体验,用户可以在不刷新浏览器的情况下,看到伐木场建造进度的读条,以及 嘭 一下建造完毕,并且发现每秒钟多获得了 100 点木材!但是当伐木场 建造完成前、完成时、完成后的任意时间点刷新浏览器,都要保持逻辑的统一,而且数据需要在后端离线计算。 此时就要写后端代码了: // 每次登陆时,校验当前登陆const currentTime = new Date().getTime()// 获取伐木场当前状态if ( /* 建造中 */) { // 返回给客户端当前时间 const leftTime = building.startTime - currentTime res.body = leftTime} else { // 建造完毕 store.woodIncrement += 100} 很快,建筑的种类多了起来,不同的状态、等级产量都不同,前后端分开维护成本会越来越大,我们需要做配置同步。 配置同步为了做前后端配置同步,可以将配置单独托管起来前后端共用,比如新建一个配置文件,专门存储游戏信息: export const buildings = { wood: { name: "..", maxLevel: 100, increamentPerLevel: 50, initIncreament: 100 } /* .. and so on .. */}; 这虽然复用了配置,但前后端都有一些共同的逻辑可以复用,比如 根据建筑建造时间判断建筑状态,判断 N 秒后建筑的产量等等。 而 Serverless 带来了进一步优化的空间。 在 Serverless 环境下做游戏试想一下,可以在服务器以函数粒度执行代码,我们可以这样抽象游戏逻辑: // 根据建筑建造时间判断建筑状态export const getBuildingStatusByTime = (instanceId: number, time: number) => { /**/};// 判断建筑生产量export const getBuildingProduction = (instanceId: number, lastTime: number) => { const status = getBuildingStatusByTime(instanceId, new Date().getTime()); switch (status) { case "building": return 0; case "finished": // 根据 (当前时间 - 上次打开时间)* 每秒产量得到总产量 return; /**/ }};// 前端 UI 层,每隔一秒调用一次 getBuildingProduction 函数,及时更新生产数据// 前端入口函数export const frontendMain = () => { /**/};// 后端 根据每次打开时间,调用一次 getBuildingProduction 函数并入库// 后端入口函数export const backendMain = () => { /**/}; 利用 PASS 服务,将前后端逻辑写在一起,将 getBuildingProduction 函数片段上传至 FAAS 服务,这样就可以同时共享前后端逻辑了! 在文件夹视图下,可以做如下结构规划: .├── client ## 前端入口├── server ## 后端入口├── common ## 共享工具函数,可以包含 80% 的通用游戏逻辑 也许有人会问:前后端共享代码不止有 Serverless 才能做到。 的确如此,如果代码抽象足够好,有成熟的工程方案支持,是可以将一份代码分别导出到浏览器与服务器的。但 Serverless 基于函数粒度功能更契合前后端复用代码的理念,它的出现可能会推动更广泛的前后端代码复用,这虽然不是新发明,但足够称为一个伟大的改变。 前后端的视角对于前端开发者,会发现后台服务变简单了。对于后端开发者,发现服务做厚了,面临的挑战更多了。 更简单的后台服务传统 ECS 服务器在租赁时,CentOS 与 AliyunOS 的环境选择就足够让人烦恼。对个人开发者而言,我们要搭建一个完整的持续集成服务是很困难的,而且面临的选择很多,让人眼花缭乱: 可以在服务器安装数据库等服务,本地直联服务器的数据库进行开发。 可以本地安装 Docker 连接本地数据库服务,将环境打包成镜像整体部署到服务器。 将前后端代码分离,前端代码在本地开发,服务端代码在服务器开发。 甚至服务器的稳定性,需要 PM2 等工具进行管理。当服务器面临攻击、重启、磁盘故障时,打开复杂的工作台或登陆 Shell 后一通操作才能恢复。这怎么让人专心把精力放在要做的事情上呢? Serverless 解决了这个问题,因为我们要上传的只是一个代码片段,不再需要面对服务器、系统环境、资源等环境问题,外部服务也有封装好的 BAAS 体系支持。 实际上在 Serverless 出来之前,就有许多后端团队利用 FAAS 理念简化开发流程。 为了减少写后端业务逻辑时,环境、部署问题的干扰,许多团队会将业务逻辑抽象成一个个区块(Block),对应到代码片段或 Blockly,这些区块可以独立维护、发布,最后将这些代码片段注入到主程序中,或动态加载。如果习惯了这种开发方式,那也更容易接受 Serverless。 更厚的后台服务站在后台角度,事情就变得比较复杂了。相对于提供简单的服务器和容器,现在要对用户屏蔽执行环境,将服务做得更厚。 笔者通过一些文章了解到,Serverless 的推行还面临着如下一些挑战: Serverless 各厂商实现种类很多,想让业务做到多云部署,需要抹平差异。 成熟的 PASS 服务其实是伪 Serverless,后续该如何标准化。 FAAS 冷启动需要重新加载代码、动态分配资源,导致冷启动速度很慢,除了预热,还需要经济的优化方式。 对于高并发(比如双 11 秒杀)场景的应用,无需容量评估是很危险的事情,但如果真能做到完全弹性化,就省去了烦恼的容量评估。 存量应用如何迁移。业界的 Serverless 服务厂商大部分都没有解决存量应用迁移的问题。 Serverless 的特性导致了无状态,而复杂的互联网应用都是有状态的,因此挑战在不改变开发习惯的情况下支持状态。 所幸的是,这些问题都已经在积极处理中,而且不少有了已经落地的解决方案。 Serverless 给后台带来的好处远比面临的挑战多: 推进前后端一体化。进一步降低 Node 写服务端代码的门槛,免除应用运营的学习成本。笔者曾经遇到过自己申请的数据库服务被迁移到其他机房而导致的应用服务中断,以后再也不需要担心了,因为数据库作为 BAAS 服务,是不需要关心在哪部署,是否跨机房,以及如何做迁移的。 提高资源利用效率。杜绝应用独占资源,换成按需加载必然能减少不必要的资源消耗,而且将服务均摊到集群的每一台机器,拉平集群的 CPU 水位。 降低云平台使用门槛。无需运维,灵活拓展,按价值服务,高可用,这些能力在吸引更多客户的同时,完全按需计费的特性也减少了用户开销,达到双赢。 利用 Serverless 尝试服务开放笔者在公司负责一个大型 BI 分析平台建设,BI 分析平台的底层能力之一就是可视化搭建。 那么可视化搭建能力该如何开放呢?现在比较容易做到的是组件开放,毕竟前端可以与后端设计相对解耦,利用 AMD 加载体系也比较成熟。 现在遇到的一个挑战就是后端能力开放,因为当对取数能力有定制要求时,可能需要定制后端数据处理的逻辑。目前能做到的是利用 maven3、jdk7 搭建本地开发环境测试,如果想上线,还需要后端同学的协助。 如果后端搭建一个特有的 Serverless BAAS 服务,那么就可以像前端组件一样进行线上 Coding,调试,甚至灰度发布进行预发测试。现在前端云端开发已经有了不少成熟的探索,Serverless 可以统一前后端代码在云端开发的体验,而不需要关心环境。 Serverless 应用架构设计看了一些 Serverless 应用架构图,发现大部分业务都可以套用这样一张架构图: 将业务函数抽象成一个个 FAAS 函数,将数据库、缓存、加速等服务抽象成 BAAS 服务。 上层提供 Restful 或事件触发机制调用,对应到不同的端(PC、移动端)。 想要拓展平台能力,只要在端上做开放(组件接入)与 FAAS 服务做开放(后端接入)即可。 收益与挑战Serverless 带来了的收益与挑战并存,本文站在前端角度聊一聊。 收益一:前端更 Focus 在前端体验技术,而不需要具备太多应用管理知识。 最近看了很多前端前辈写的总结文,最大的体会就是回忆 “前端在这几年到底起到了什么作用”。我们往往会夸大自己的存在感,其实前端存在的意义就是解决人机交互问题,大部分场景下,都是一种锦上添花的作用,而不是必须。 回忆你最自豪的工作经历,可能是掌握了 Node 应用的运维知识、前端工程体系建设、研发效能优化、标准规范制定等,但真正对业务起效的部分,恰恰是你觉得写得最不值得一提的业务代码。前端花了太多的时间在周边技术上,而减少了很多对业务、交互的思考。 即便是大公司,也难以招到既熟练使用 Nodejs,又具备丰富运维知识的人,同时还要求他前端技术精湛,对业务理解深刻,鱼和熊掌几乎不可兼得。 Serverless 可以有效解决这个问题,前端同学只需要会写 JS 代码而无需掌握任何运维知识,就可以快速实现自己的整套想法。 诚然,了解服务端知识是有必要的,但站在合理分工的角度,前端就应该 focus 在前端技术上。前端的核心竞争力或者带来的业务价值,并不会随着了解多一些运维知识而得到补充,相反,这会吞噬掉我们本可以带来更多业务价值的时间。 语言的进化、浏览器的进化、服务器的进化,都是从复杂到简单,底层到封装的过程,而 Serverless 是后端 + 运维作为一个整体的进一步封装的过程。 收益二:逻辑编排带来的代码高度复用、可维护,拓展 云+端 的能力。 云+端 是前端开发的下个形态,提供强大的云编码能力,或者通过插件将端打造为类似云的开发环境。其最大好处就是屏蔽前端开发环境细节,理念与 Serverless 类似。 之前有不少团队尝试过利用 GraphQL 让接口 “更有弹性”,而 Serverless 则是更彻底的方案。 我自己的团队就尝试过 GraphQL 方案,但由于业务非常复杂,难以用标准的模型描述所有场景的需求,因此不适合使用 GraphQL。恰恰是一套基于 Blockly 的可视化后端开发平台坚持了下来,而且取得了惊人的开发提效。这套 Blockly 通用化抽象后几乎可以由 Serverless 来代替。所以 Serverless 可以解决复杂场景下后端研发提效的问题。 Serverless 在融合了云端开发后,就可以通过逻辑编排进一步可视化调整函数执行顺序、依赖关系。 笔者之前在百度广告数据处理团队使用过这种平台计算离线日志,每个 MapReduce 计算节点经过可视化后,就可以轻松看出故障时哪个节点在阻塞,还可以看到最长执行链路,并为每个节点重新分配执行权重。即便逻辑编排不能解决开发的所有痛点,但在某个具体业务场景下一定可以大有作为。 挑战一:Serverless 可以完全取消前端转后端的门槛? 前端同学写 Node 代码最容易犯的毛病就是内存溢出。 浏览器 + Tab 天然是一个用完即关的场景,UI 组件与逻辑创建与销毁也非常频繁,因此前端同学很少,也不太需要关心 GC 问题。而 GC 在后端开发场景中是一个早已养成的习惯,因此 Nodejs 程序缓存溢出是大家最关注的问题。 Serverless 应用是动态加载,长时间不用就会释放的,因此一般来说不需要太担心 GC 的问题,就算内存溢出,在内存被占满前可能已经进程被释放,或者被监测到异常强制 Kill 掉。 但毕竟 FAAS 函数的加载与释放完全是由云端控制的,一个常用的函数长时间不卸载也是有可能的,因此 FAAS 函数还是要注意控制副作用。 所以 Serverless 虽然抹平了运维环境,但服务端基本知识还需要了解,必须意识到代码跑在前端还是后端。 挑战二:性能问题 Serverless 的冷启动会导致性能问题,而让业务方主动关心程序的执行频率或者性能要求,再开启预热服务又重新将研发拖入了运维的深渊中。 即便是业界最成熟的亚马逊 Serverless 云服务,也无法做到业务完全不关心调用频率,就可以轻松应付秒杀场景。 因此目前 Serverless 可能更适合结合合适的场景使用,而不是任何应用都强行套用 Serverless。 虽然可以通过定期运行 FAAS 服务来保证程序一直 Online,但笔者认为这还是违背了 Serverless 的理念。 挑战三:如何保证代码可迁移性 有一张很经典的 Serverless 定位描述图: 网络、存储、服务、虚拟家、操作系统、中间件、运行时、数据都不需要关心了,甚至连应用层都只需要关心其中函数部分,而不需要关心其他比如启动、销毁部分。 前面总拿这点当优势,但也可以反过来认为是个劣势。 当你的代码完全依赖某个公有云环境后,你就失去了整体环境的掌控力,甚至代码都只能在特定的云平台才能运行。 不同云平台提供的 BAAS 服务规范可能不同,FAAS 的入口、执行方式也可能不同,想要采用多云部署就必须克服这个问题。 现在许多 Serverless 平台都在考虑做标准化,但同时也有一些自下而上的工具库抹平一些差异,比如 Serverless Framework 等。 而我们写 FAAS 函数时,也尽量将与平台绑定的入口函数写得轻一些,将真正的入口放在通用的比如 main 函数中。 3. 总结Serverless 的价值远比挑战大,其理念可以切实解决许多研发效能问题。 但目前 Serverless 发展阶段仍处于早期,国内的 Serverless 也处于尝试阶段,而且执行环境存在诸多限制,也就是并没有完全实现 Serverless 的美好理念,因此如果什么都往上套一定会踩坑。 可能在 3-5 年后,这些坑会被填平,那么你是选择加入填坑大军,还是选一个合适的场景使用 Serverless 呢? 讨论地址是:精读《Serverless 给前端带来了什么》 · Issue ##135 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《State of CSS 2022》","path":"/wiki/WebWeekly/前沿技术/《State of CSS 2022》.html","content":"当前期刊数: 257 本周读一读 State of CSS 2022 介绍的 CSS 特性。 概述2022 已经支持的特性@layer解决业务代码的 !important 问题。为什么业务代码需要用 !important 解决问题?因为 css 优先级由文件申明顺序有关,而现在大量业务使用动态插入 css 的方案,插入的时机与 js 文件加载与执行时间有关,这就导致了样式优先级不固定。 @layer 允许业务定义样式优先级,层越靠后优先级越高,比如下面的例子,override 定义的样式优先级比 framework 高: @layer framework, override;@layer override { .title { color: white; }}@layer framework { .title { color: red; }} subgridsubgrid 解决 grid 嵌套 grid 时,子网格的位置、轨迹线不能完全对齐到父网格的问题。只要在子网格样式做如下定义: .sub-grid { display: grid; grid-template-columns: subgrid; grid-template-rows: subgrid;} @container@container 使元素可以响应某个特定容器大小。在 @container 出来之前,我们只能用 @media 响应设备整体的大小,而 @container 可以将相应局限在某个 DOM 容器内: // 将 .container 容器的 container-name 设置为 abc.container { container-name: abc;} // 根据 abc 容器大小做出响应@container abc (max-width: 200px) { .text { font-size: 14px; }} 一个使用场景是:元素在不同的 .container 元素内的样式可以是不同的,取决于当前所在 .container 的样式。 hwb支持 hwb(hue, whiteness, blackness) 定义颜色: .text { color: hwb(30deg 0% 20%);} 三个参数分别表示:角度 [0-360],混入白色比例、混入黑色比例。角度对应在色盘不同角度的位置,每个角度都有属于自己的颜色值,这个函数非常适合直观的从色盘某个位置取色。 lch, oklch, lab, oklab, display-p3 等lch(lightness, chroma, hue),即亮度、饱和度和色相,语法如: .text { color: lch(50%, 100, 100deg);} chroma(饱和度) 指颜色的鲜艳程度,越高色彩越鲜艳,越低色彩越暗淡。hue(色相) 指色板对应角度的颜色值。 oklch(lightness, chroma, hue, alpha),即亮度、饱和度、色相和透明度。 .text { color: oklch(59.69% 0.156 49.77 / 0.5);} 更多的就不一一枚举说明了,总之就是颜色表达方式多种多样,他们之间也可以互相转换。 color-mix()css 语法支持的 mix,类似 sass 的 mix() 函数功能: .text { color: color-mix(in srgb, ##34c9eb 10%, white);} 第一个参数是颜色类型,比如 hwb、lch、lab、srgb 等,第二个参数就是要基准颜色与百分比,第三个参数是要混入的颜色。 color-contrast()让浏览器自动在不同背景下调整可访问颜色。换句话说,就是背景过深时自动用白色文字,背景过浅时自动用黑色文字: .text { color: color-contrast(black);} 还支持更多参数,详情见 Manage Accessible Design System Themes With CSS Color-Contrast()。 相对颜色语法可以根据语法类型,基于某个语法将某个值进行一定程度的变化: .text { color: hsl(from var(--color) h s calc(l - 20%));} 如上面的例子,我们将 --color 这个变量在 hsl 颜色模式下,将其 l(lightness) 亮度降低 20%。 渐变色 namespace现在渐变色也支持申明 namespace 了,比如: .text { background-image: linear-gradient(to right in hsl, black, white);} 这是为了解决一种叫 灰色死区 的问题,即渐变色如果在色盘穿过了饱和度为 0 的区域,中间就会出现一段灰色,而指定命名空间比如 hsl 后就可以绕过灰色死区。 因为 hsl 对应色盘,渐变的逻辑是在色盘上沿圆弧方向绕行,而非直接穿过圆心(圆心饱和度低,会呈现灰色)。 accent-coloraccent-color 主要对单选、多选、进度条这些原生输入组件生效,控制的是它们的主题色: body { accent-color: red;} 比如这样设置之后,单选与多选的背景色会随之变化,进度条表示进度的横向条与圆心颜色也会随之变化。 inertinert 是一个 attribute,可以让拥有该属性的 dom 与其子元素无法被访问,即无法被点击、选中、也无法通过快捷键选中: <div inert>...</div> COLRv1 FontsCOLRv1 Fonts 是一种自定义颜色与样式的矢量字体方案,浏览器支持了这个功能,用法如下: @import url(https://fonts.googleapis.com/css2?family=Bungee+Spice);@font-palette-values --colorized { font-family: "Bungee Spice"; base-palette: 0; override-colors: 0 hotpink, 1 cyan, 2 white;}.spicy { font-family: "Bungee Spice"; font-palette: --colorized;} 上面的例子我们引入了矢量图字体文件,并通过 @font-palette-values --colorized 自定义了一个叫做 colorized 的调色板,这个调色板通过 base-palette: 0 定义了其继承第一个内置的调色板。 使用上除了 font-family 外,还需要定义 font-palette 指定使用哪个调色板,比如上面定义的 --colorized。 视口单位除了 vh、vw 等,还提供了 dvh、lvh、svh,主要是在移动设备下的区别: dvh: dynamic vh, 表示内容高度,会自动忽略浏览器导航栏高度。 lvh: large vh, 最大高度,包含浏览器导航栏高度。 svh: small vh, 最小高度,不包含浏览器导航栏高度。 :has()可以用来表示具有某些子元素的父元素: .parent:has(.child) {} 表示选中那些有用 .child 子节点的 .parent 节点。 即将支持的特性@scope可以让某个作用域内样式与外界隔绝,不会继承外部样式: @scope (.card) { header { color: var(--text); }} 如上定义后,.card 内 header 元素样式就被确定了,不会受到外界影响。如果我们用 .card { header {} } 来定义样式,全局的 header {} 样式定义依然可能覆盖它。 样式嵌套@nest 提案时 css 内置支持了嵌套,就像 postcss 做的一样: .parent { &:hover { // ... }} prefers-reduced-data@media 新增了 prefers-reduced-data,描述用户对资源占用的期望,比如 prefers-reduced-data: reduce 表示期望仅占用很少的网络带宽,那我们可以在这个情况下隐藏所有图片和视频: @media (prefers-reduced-data: reduce) { picture, video { display: none; }} 也可以针对 reduce 情况降低图片质量,至于要压缩多少效果取决于业务。 自定义 media 名称允许给 @media 自定义名称了,如下定义了很多自定义 @media: @custom-media --OSdark (prefers-color-scheme: dark);@custom-media --OSlight (prefers-color-scheme: light);@custom-media --pointer (hover) and (pointer: coarse);@custom-media --mouse (hover) and (pointer: fine);@custom-media --xxs-and-above (width >= 240px);@custom-media --xxs-and-below (width <= 240px); 我们就可以按照自定义名称使用了: @media (--OSdark) { :root { … }} media 范围描述支持表达式以前只能用 @media (min-width: 320px) 描述宽度不小于某个值,现在可以用 @media (width >= 320px) 代替了。 @property@property 允许拓展 css 变量,描述一些修饰符: @property --x { syntax: "<length>"; initial-value: 0px; inherits: false;} 上面的例子把变量 x 定义为长度类型,所以如果错误的赋值了字符串类型,将会沿用其 initial-value。 scroll-startscroll-start 允许定义某个容器从某个位置开始滚动: .snap-scroll-y { scroll-start-y: var(--nav-height);} :snapped:snapped 这个伪类可以选中当前滚动容器中正在被响应的元素: .card:snapped { --shadow-distance: 30px;} 这个特性有点像 IOS select 控件,上下滑动后就像个左轮枪一样转动元素,最后停留在某个元素上,这个元素就处于 :snapped 状态。同时 JS 也支持了 snapchanging 与 snapchanged 两种事件类型。 :toggle()只有一些内置 html 元素拥有 :checked 状态,:toggle 提案是用它把这个状态拓展到每一个自定义元素: button { toggle-trigger: lightswitch;}button::before { content: "🌚 ";}html:toggle(lightswitch) button::before { content: "🌝 ";} 上面的例子把 button 定义为 lightswitch 的触发器,且定义当 lightswitch 触发或不触发时的 css 样式,这样就可以实现点击按钮后,黑脸与黄脸的切换。 anchor()anchor() 可以将没有父子级关系的元素建立相对位置关系,更详细的用法可以看 CSS Anchored Positioning。 selectmenuselectmenu 允许将任何元素添加为 select 的 option: <selectmenu> <option>Option 1</option> <option>Option 2</option> <option>Option 3</option></selectmenu> 还支持更复杂的语法,比如对下拉内容分组: <selectmenu class="my-custom-select"> <div slot="button"> <span class="label">Choose a plant</span> <span behavior="selected-value" slot="selected-value"></span> <button behavior="button"></button> </div> <div slot="listbox"> <div popup behavior="listbox"> <div class="section"> <h3>Flowers</h3> <option>Rose</option> <option>Lily</option> <option>Orchid</option> <option>Tulip</option> </div> <div class="section"> <h3>Trees</h3> <option>Weeping willow</option> <option>Dragon tree</option> <option>Giant sequoia</option> </div> </div> </div></selectmenu> 总结CSS 2022 新特性很大一部分是将 HTML 原生能力暴露出来,赋能给业务自定义,不过如果这些状态完全由业务来实现,比如 Antd <Select> 组件早已实现自定义下拉选项与样式,既然 HTML 没有提供自定义能力,就按照其交互用 DIV + JS 模拟一套实现出来,定制空间更大。 但也有很多能力依赖浏览器实现,或者本身更适合实现在 CSS 侧,比如 @scope、subgrid、对颜色的处理等。 讨论地址是:精读《State of CSS 2022》· Issue ##442 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Suspense 改变开发方式》","path":"/wiki/WebWeekly/前沿技术/《Suspense 改变开发方式》.html","content":"当前期刊数: 143 1 引言很多人都用过 React Suspense,但如果你认为它只是配合 React.lazy 实现异步加载的蒙层,就理解的太浅了。实际上,React Suspense 改变了开发规则,要理解这一点,需要作出思想上的改变。 我们结合 Why React Suspense Will Be a Game Changer 这篇文章,带你重新认识 React Suspense。 2 概述异步加载是前端开发的重要环节,也是一直以来样板代码最严重的场景之一,原文通过三种取数方案的对比,逐渐找到一种最佳的异步取数方式。 在讲解这三种取数方案之前,首先通过下面这张图说明了 Suspense 的功能: 从上图可以看出,子元素在异步取数时会阻塞父组件渲染,并一直冒泡到最外层第一个 Suspense,此时 Suspense 不会渲染子组件,而是渲染 fallback,当所有子组件异步阻塞取消后才会正常渲染。 下面介绍文中给出的三种取数方式,首先是最原始的本地状态管理方案。 本地异步状态管理,直白但不利于维护在 Suspense 方案出来之前,我们一般都在代码中利用本地状态管理异步数据。 即便代码做了一定抽象,那也只是把逻辑从一个文件移到了另一个问题,可维护性与可拓展性都没有本质的改变,因此基本可以用下面的结构说明: 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 做状态共享,我们将取数的数据管理与逻辑代码写在父组件,子组件专心用于展示,效果会好一些,代码如下: 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 进行异步处理,代码处理大概是这样的: 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 的魔力,笔者特意把这段代码单独拿出来说明: 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 源码》 有介绍过,即 Suspense 要求代码 suspended,即抛出一个可以被捕获的 Promise 异常,在这个 Promise 结束后再渲染组件,因此取数函数需要在 Pending 状态时抛出一个 Promise,使其可以被 Suspense 捕获到。 另外,关于文中提到的 fallback 最小出现时间的保护间隔,目前还是一个 Open Issue,也许有一天 React 官方会提供支持。 不过即便官方不支持,我们也有方式实现,即让这个逻辑由 fallback 组件实现: <Suspense fallback={MyFallback} />;const MyFallback = () => { // 计时器,200 ms 以内 return null,200 ms 后 return <Spin />}; 4 总结之所以说 Suspense 开发方式改变了开发规则,是因为它做到了将异步的状态管理与 UI 组件分离,所有 UI 组件都无需关心 Pending 状态,而是当作同步去执行,这本身就是一个巨大的改变。 另外由于状态的分离,我们可以利用纯 UI 组件拼装任意粒度的 Pending 行为,以整个 App 作为一个大的 Suspense 作为兜底,这样 UI 彻底与异步解耦,哪里 Loading,什么范围内 Loading,完全由 Suspense 组合方式决定,这样的代码显然具备了更强的可拓展性。 讨论地址是:精读《Suspense 改变开发方式》 · Issue ##238 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《SolidJS》","path":"/wiki/WebWeekly/前沿技术/《SolidJS》.html","content":"当前期刊数: 255 SolidJS 是一个语法像 React Function Component,内核像 Vue 的前端框架,本周我们通过阅读 Introduction to SolidJS 这篇文章来理解理解其核心概念。 为什么要介绍 SolidJS 而不是其他前端框架?因为 SolidJS 在教 React 团队正确的实现 Hooks,这在唯 React 概念与虚拟 DOM 概念马首是瞻的年代非常难得,这也是开源技术的魅力:任何观点都可以被自由挑战,只要你是对,你就可能脱颖而出。 概述整篇文章以一个新人视角交代了 SolidJS 的用法,但本文假设读者已有 React 基础,那么只要交代核心差异就行了。 渲染函数仅执行一次SolidJS 仅支持 FunctionComponent 写法,无论内容是否拥有状态管理,也无论该组件是否接受来自父组件的 Props 透传,都仅触发一次渲染函数。 所以其状态更新机制与 React 存在根本的不同: React 状态变化后,通过重新执行 Render 函数体响应状态的变化。 Solid 状态变化后,通过重新执行用到该状态代码块响应状态的变化。 与 React 整个渲染函数重新执行相对比,Solid 状态响应粒度非常细,甚至一段 JSX 内调用多个变量,都不会重新执行整段 JSX 逻辑,而是仅更新变量部分: const App = ({ var1, var2 }) => ( <> var1: {console.log("var1", var1)} var2: {console.log("var2", var2)} </>); 上面这段代码在 var1 单独变化时,仅打印 var1,而不会打印 var2,在 React 里是不可能做到的。 这一切都源于了 SolidJS 叫板 React 的核心理念:面向状态驱动而不是面向视图驱动。正因为这个差异,导致了渲染函数仅执行一次,也顺便衍生出变量更新粒度如此之细的结果,同时也是其高性能的基础,同时也解决了 React Hooks 不够直观的顽疾,一箭 N 雕。 更完善的 Hooks 实现SolidJS 用 createSignal 实现类似 React useState 的能力,虽然看上去长得差不多,但实现原理与使用时的心智却完全不一样: const App = () => { const [count, setCount] = createSignal(0); return <button onClick={() => setCount(count() + 1)}>{count()}</button>;}; 我们要完全以 SolidJS 心智理解这段代码,而不是 React 心智理解它,虽然它长得太像 Hooks 了。一个显著的不同是,将状态代码提到外层也完全能 Work: const [count, setCount] = createSignal(0);const App = () => { return <button onClick={() => setCount(count() + 1)}>{count()}</button>;}; 这是最快理解 SolidJS 理念的方式,即 SolidJS 根本没有理 React 那套概念,SolidJS 理解的数据驱动是纯粹的数据驱动视图,无论数据在哪定义,视图在哪,都可以建立绑定。 这个设计自然也不依赖渲染函数执行多次,同时因为使用了依赖收集,也不需要手动申明 deps 数组,也完全可以将 createSignal 写在条件分支之后,因为不存在执行顺序的概念。 派生状态用回调函数方式申明派生状态即可: const App = () => { const [count, setCount] = createSignal(0); const doubleCount = () => count() * 2; return <button onClick={() => setCount(count() + 1)}>{doubleCount()}</button>;}; 这是一个不如 React 方便的点,因为 React 付出了巨大的代价(在数据变更后重新执行整个函数体),所以可以用更简单的方式定义派生状态: // Reactconst App = () => { const [count, setCount] = useState(0); const doubleCount = count * 2; // 这块反而比 SolidJS 定义的简单 return ( <button onClick={() => setCount((count) => count + 1)}> {doubleCount} </button> );}; 当然笔者并不推崇 React 的衍生写法,因为其代价太大了。我们继续分析为什么 SolidJS 这样看似简单的衍生状态写法可以生效。原因在于,SolidJS 收集所有用到了 count() 的依赖,而 doubleCount() 用到了它,而渲染函数用到了 doubleCount(),仅此而已,所以自然挂上了依赖关系,这个实现过程简单而稳定,没有 Magic。 SolidJS 还支持衍生字段计算缓存,使用 createMemo: const App = () => { const [count, setCount] = createSignal(0); const doubleCount = () => createMemo(() => count() * 2); return <button onClick={() => setCount(count() + 1)}>{doubleCount()}</button>;}; 同样无需写 deps 依赖数组,SolidJS 通过依赖收集来驱动 count 变化影响到 doubleCount 这一步,这样访问 doubleCount() 时就不用总执行其回调的函数体,产生额外性能开销了。 状态监听对标 React 的 useEffect,SolidJS 提供的是 createEffect,但相比之下,不用写 deps,是真的监听数据,而非组件生命周期的一环: const App = () => { const [count, setCount] = createSignal(0); createEffect(() => { console.log(count()); // 在 count 变化时重新执行 });}; 这再一次体现了为什么 SolidJS 有资格 “教” React 团队实现 Hooks: 无 deps 申明。 将监听与生命周期分开,React 经常容易将其混为一谈。 在 SolidJS,生命周期函数有 onMount、onCleanUp,状态监听函数有 createEffect;而 React 的所有生命周期和状态监听函数都是 useEffect,虽然看上去更简洁,但即便是精通 React Hooks 的老手也不容易判断哪些是监听,哪些是生命周期。 模板编译为什么 SolidJS 可以这么神奇的把 React 那么多历史顽疾解决掉,而 React 却不可以呢?核心原因还是在 SolidJS 增加的模板编译过程上。 以官方 Playground 提供的 Demo 为例: function Counter() { const [count, setCount] = createSignal(0); const increment = () => setCount(count() + 1); return ( <button type="button" onClick={increment}> {count()} </button> );} 被编译为: const _tmpl$ = /*##__PURE__*/ template(`<button type="button"></button>`, 2);function Counter() { const [count, setCount] = createSignal(0); const increment = () => setCount(count() + 1); return (() => { const _el$ = _tmpl$.cloneNode(true); _el$.$$click = increment; insert(_el$, count); return _el$; })();} 首先把组件 JSX 部分提取到了全局模板。初始化逻辑:将变量插入模板;更新状态逻辑:由于 insert(_el$, count) 时已经将 count 与 _el$ 绑定了,下次调用 setCount() 时,只需要把绑定的 _el$ 更新一下就行了,而不用关心它在哪个位置。 为了更完整的实现该功能,必须将用到模板的 Node 彻底分离出来。我们可以测试一下稍微复杂些的场景,如: <button> count: {count()}, count+1: {count() + 1}</button> 这段代码编译后的模板结果是: const _el$ = _tmpl$.cloneNode(true), _el$2 = _el$.firstChild, _el$4 = _el$2.nextSibling;_el$4.nextSibling;_el$.$$click = increment;insert(_el$, count, _el$4);insert(_el$, () => count() + 1, null); 将模板分成了一个整体和三个子块,分别是字面量、变量、字面量。为什么最后一个变量没有加进去呢?因为最后一个变量插入直接放在 _el$ 末尾就行了,而中间插入位置需要 insert(_el$, count, _el$4) 给出父节点与子节点实例。 精读SolidJS 的神秘面纱已经解开了,下面笔者自问自答一些问题。 为什么 createSignal 没有类似 hooks 的顺序限制?React Hooks 使用 deps 收集依赖,在下次执行渲染函数体时,因为没有任何办法标识 “deps 是为哪个 Hook 申明的”,只能依靠顺序作为标识依据,所以需要稳定的顺序,因此不能出现条件分支在前面。 而 SolidJS 本身渲染函数仅执行一次,所以不存在 React 重新执行函数体的场景,而 createSignal 本身又只是创建一个变量,createEffect 也只是创建一个监听,逻辑都在回调函数内部处理,而与视图的绑定通过依赖收集完成,所以也不受条件分支的影响。 为什么 createEffect 没有 useEffect 闭包问题?因为 SolidJS 函数体仅执行一次,不会存在组件实例存在 N 个闭包的情况,所以不存在闭包问题。 为什么说 React 是假的响应式?React 响应的是组件树的变化,通过组件树自上而下的渲染来响应式更新。而 SolidJS 响应的只有数据,甚至数据定义申明在渲染函数外部也可以。 所以 React 虽然说自己是响应式,但开发者真正响应的是 UI 树的一层层更新,在这个过程中会产生闭包问题,手动维护 deps,hooks 不能写在条件分支之后,以及有时候分不清当前更新是父组件 rerender 还是因为状态变化导致的。 这一切都在说明,React 并没有让开发者真正只关心数据的变化,如果只要关心数据变化,那为什么组件重渲染的原因可能因为 “父组件 rerender” 呢? 为什么 SolidJS 移除了虚拟 dom 依然很快?虚拟 dom 虽然规避了 dom 整体刷新的性能损耗,但也带来了 diff 开销。对 SolidJS 来说,它问了一个问题:为什么要规避 dom 整体刷新,局部更新不行吗? 对啊,局部更新并不是做不到,通过模板渲染后,将 jsx 动态部分单独提取出来,配合依赖收集,就可以做到变量变化时点对点的更新,所以无需进行 dom diff。 为什么 signal 变量使用 count() 不能写成 count?笔者也没找到答案,理论上来说,Proxy 应该可以完成这种显式函数调用动作,除非是不想引入 Mutable 的开发习惯,让开发习惯变得更加 Immutable 一些。 props 的绑定不支持解构由于响应式特性,解构会丢失代理的特性: // ✅const App = (props) => <div>{props.userName}</div>;// ❎const App = ({ userName }) => <div>{userName}</div>; 虽然也提供了 splitProps 解决该问题,但此函数还是不自然。该问题比较好的解法是通过 babel 插件来规避。 createEffect 不支持异步没有 deps 虽然非常便捷,但在异步场景下还是无解: const App = () => { const [count, setCount] = createSignal(0); createEffect(() => { async function run() { await wait(1000); console.log(count()); // 不会触发 } run(); });}; 总结SolidJS 的核心设计只有一个,即让数据驱动真的回归到数据上,而非与 UI 树绑定,在这一点上,React 误入歧途了。 虽然 SolidJS 很棒,但相关组件生态还没有起来,巨大的迁移成本是它难以快速替换到生产环境的最大问题。前端生态想要无缝升级,看来第一步是想好 “代码范式”,以及代码范式间如何转换,确定了范式后再由社区竞争完成实现,就不会遇到生态难以迁移的问题了。 但以上假设是不成立的,技术迭代永远都以 BreakChange 为代价,而很多时候只能抛弃旧项目,在新项目实践新技术,就像 Jquery 时代一样。 讨论地址是:精读《SolidJS》· Issue ##438 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Tasks, microtasks, queues and schedules》","path":"/wiki/WebWeekly/前沿技术/《Tasks, microtasks, queues and schedules》.html","content":"当前期刊数: 162 1 引言本周跟着 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看下面代码的输出顺序: console.log("script start");setTimeout(function () { console.log("setTimeout");}, 0);Promise.resolve() .then(function () { console.log("promise1"); }) .then(function () { console.log("promise2"); });console.log("script end"); 正确答案是 script start, script end, promise1, promise2, setTimeout,在线程中,同步脚本执行优先级最高,然后 promise 任务会存放到 Microtasks,setTimeout 任务会存放到 Tasks,Microtasks 会优先于 Tasks 执行。 Microtasks 中文可以翻译为微任务,只要有 Microtasks 插入,就会不断执行 Microtasks 队列直到结束,在结束前都不会执行到 Tasks。 点击冒泡 + 任务下面给出了更复杂的例子,提前说明后面的例子 Chrome、Firefox、Safari、Edge 浏览器的结果完全不一样,但只有 Chrome 的运行结果是对的!为什么 Chrome 是对的呢,请看下面的分析: <div class="outer"> <div class="inner"></div></div> // Let's get hold of those elementsvar outer = document.querySelector(".outer");var inner = document.querySelector(".inner");// Let's listen for attribute changes on the// outer elementnew 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 elementsinner.addEventListener("click", onClick);outer.addEventListener("click", onClick); 点击 inner 区块后,正确输出顺序应该是: clickpromisemutateclickpromisemutatetimeouttimeout 逻辑如下: 点击触发 onClick 函数入栈。 立即执行 console.log('click') 打印 click。 console.log('timeout') 入栈 Tasks。 console.log('promise') 入栈 microtasks。 outer.setAttribute('data-random') 的触发导致监听者 MutationObserver 入栈 microtasks。 onClick 函数执行完毕,此时线程调用栈为空,开始执行 microtasks 队列。 打印 promise,打印 mutate,此时 microtasks 已空。 执行冒泡机制,outer div 也触发 onClick 函数,同理,打印 promise,打印 mutate。 都执行完后,执行 Tasks,打印 timeout,打印 timeout。 模拟点击冒泡 + 任务如果将触发 onClick 行为由点击改为: inner.click(); 结果会不同吗?答案是会(单元测试与用户行为不符合,单测也有无解的时候)。然而四大浏览器的执行结果也是完全不一样,但从逻辑上讲仍然 Chrome 是对的,让我们看下 Chrome 的结果: clickclickpromisemutatepromisetimeouttimeout 逻辑如下: inner.click() 触发 onClick 函数入栈。 立即执行 console.log('click') 打印 click。 console.log('timeout') 入栈 Tasks。 console.log('promise') 入栈 microtasks。 outer.setAttribute('data-random') 的触发导致监听者 MutationObserver 入栈 microtasks。 由于冒泡改为 js 调用栈执行,所以此时 js 调用栈未结束,不会执行 microtasks,反而是继续执行冒泡,outer 的 onClick 函数入栈。 立即执行 console.log('click') 打印 click。 console.log('timeout') 入栈 Tasks。 console.log('promise') 入栈 microtasks。 MutationObserver 由于还没调用,因此这次 outer.setAttribute('data-random') 的改动实际上没有作用。 js 调用栈执行完毕,开始执行 microtasks,按照入栈顺序,打印 promise,mutate,promise。 microtasks 执行完毕,开始执行 Tasks,打印 timeout,timeout。 3 精读基于任务调度这么复杂,且浏览器实现方式很不同,下面两件事是我很不推荐的: 业务逻辑 “巧妙” 依赖了 microtasks 与 Tasks 执行逻辑的微妙差异。 死记硬背调用顺序。 且不说依赖了调用顺序的业务逻辑本身就很难维护,不同浏览器之间对任务调用顺序还是不同的,这可能源于对 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《TC39 与 ECMAScript 提案》","path":"/wiki/WebWeekly/前沿技术/《TC39 与 ECMAScript 提案》.html","content":"当前期刊数: 15 本期精读文章是:TC39, ECMAScript, and the Future of JavaScript 1 引言 觉得 es6 es7 动不动就加新特性很烦?提案的讨论已经放开了,每个人都可以做 js 的主人,赶快与我一起了解下有哪些特性在日程中! 2 内容概要TC39 是什么?包括哪些人?一个推动 JavaScript 发展的委员会,由各个主流浏览器厂商的代表构成。 为什么会出现这样一个组织?从标准到落地是一个漫长的过程,相信大家上次阅读 web components 就能体会到标准到浏览器支持是一个漫长的过程。 TC39 这群人主要的工作是什么?制定 ECMAScript 标准,标准生成的流程,并实现。 标准的流程是什么样的?包括五个步骤: stage0 strawman 任何讨论、想法、改变或者还没加到提案的特性都在这个阶段。只有 TC39 成员可以提交。 stage1 proposal(1)产出一个正式的提案。(2)发现潜在的问题,例如与其他特性的关系,实现难题。(3)提案包括详细的 API 描述,使用例子,以及关于相关的语义和算法。 stage2 draft(1)提供一个初始的草案规范,与最终标准中包含的特性不会有太大差别。草案之后,原则上只接受增量修改。(2)开始实验如何实现,实现形式包括 polyfill, 实现引擎(提供草案执行本地支持),或者编译转换(例如 babel) stage3 candidate(1)候选阶段,获得具体实现和用户的反馈。此后,只有在实现和使用过程中出现了重大问题才会修改。(1)规范文档必须是完整的,评审人和 ECMAScript 的编辑要在规范上签字。(2)至少要在一个浏览器中实现,提供 polyfill 或者 babel 插件。 stage4 finished(1)已经准备就绪,该特性会出现在下个版本的 ECMAScript 规范之中。。(2)需要通过有 2 个独立的实现并通过验收测试,以获取使用过程中的重要实践经验。 一般可以去哪里查看 TC39 标准的进程呢?stage0 的提案 https://github.com/tc39/proposals/blob/master/stage-0-proposals.mdstage1 - 4 的提案 https://github.com/tc39/proposals 我们怎么在程序中应用这些新特性呢?babel 的插件:babel-presets-stage-0 babel-presets-stage-1 babel-presets-stage-2 babel-presets-stage-3 babel-presets-stage-4 3 精读本次提出独到观点的同学有:@huxiaoyun @monkingxue @jasonslyvia @ascoders,精读由此归纳。 3.1 Stage 4 大家庭Array.prototype.includesassert([1, 2, 3].includes(2) === true);assert([1, 2, 3].includes(4) === false);assert([1, 2, NaN].includes(NaN) === true);assert([1, 2, -0].includes(+0) === true);assert([1, 2, +0].includes(-0) === true);assert(["a", "b", "c"].includes("a") === true);assert(["a", "b", "c"].includes("a", 1) === false); 这个 api 很方便,没有悬念的进入了草案中。 曾争议过是否使用 Array.prototype.contains,但由于 不兼容因素 而换成了 includes。 Exponentiation operator// x ** ylet squared = 2 ** 2;// same as: 2 * 2let cubed = 2 ** 3;// same as: 2 * 2 * 2 列表中进入了 stage4,但其 git 仓库 readme 还停留在 stage3。。 虽然已经有 Math.pow 了,但由于其他语言都支持此方式,js 也就支持了。 Object.values/Object.entriesObject.values({\ta: 1,\tb: 2,\tc: Symbol(),}) // [1, 2, Symbol()]Object.entries({\ta: 1,\tb: 2,\tc: Symbol(),}) // [["a", 1], ["b", 2], ["c", Symbol()]] 也没有什么争议,Object.keys 都有了,获取 values、entries 也是合理的。 TC39 会议中有争辩过为何不返回迭代器,原因挺有意思,因为 Object.keys 返回的是数组,所以这两个 api 还是与老大哥统一吧。 String.prototype.padStart / String.prototype.padEnd"foo".padStart(5, "bar") // bafoo"foo".padEnd(5, "bar") // fooba 解决了字符串补齐需求,很棒! Object.getOwnPropertyDescriptorsObject.getOwnPropertyDescriptors({ a: 1})// { a: {// configurable: true,// enumberable: true,// value: 1,// writable: true// } } 特别是 babel 与 typescript 处理 class property decorator 方式不同的时候(typescript 处理得更成熟一些),会导致 babel 处理装饰器时,成员变量不设置默认值时,configurable 默认为 false,通过这个函数检查变量的配置很方便。 Trailing commas in function parameter lists and callsfunction clownPuppiesEverywhere( param1, param2, // Next parameter that's added only has to add a new line, not modify this line ) { /* ... */ } js 终于原生支持了,以前不支持的时候多加逗号还会报错,需要预编译工具删除最后一个逗号,现在终于名正言顺了。 Async functions这个不用多说了,都说好用。 Shared memory and atomics这是 ECMAScript 共享内存与 Atomics 的规范,涉及内容非常多,主要涉及到 asm.js。 asm.js 是一种性能解决方案,比如可以定义一个精确的 64k 堆: var heap = new ArrayBuffer( 0x10000 ) Lifting template literal restrictionstyled.div` background-color: red;` styled.div = text => {} 就可以处理了,目前使用最多在 styled-components 库里,这种场景还是蛮方便的。 3.2 Stage 3 大家庭Function.prototype.toString revision对函数的 toString 规则进行了修改:http://tc39.github.io/Function-prototype-toString-revision/##sec-function.prototype.tostring 当调用内置函数或 .bind 后函数,toString 方法会返回 NativeFunction。 global为 ECMAScript 规范添加 global 变量,同构代码再也不用这么写了: var getGlobal = function () {\t// the only reliable means to get the global object is\t// `Function('return this')()`\t// However, this causes CSP violations in Chrome apps.\tif (typeof self !== 'undefined') { return self; }\tif (typeof window !== 'undefined') { return window; }\tif (typeof global !== 'undefined') { return global; }\tthrow new Error('unable to locate global object');}; 虽然前端环境与 nodejs 区别很大,但既然提案进入了 stage3,说明大家非常关注 js 整体的生态,只要整体方向良性发展,相信不久将会进入 stage4。 Rest/Spread Propertieslet { x, y, ...z } = { x: 1, y: 2, a: 3, b: 4 };x; // 1y; // 2z; // { a: 3, b: 4 } 不得不说,非常常用,而且 babel,jsTransform,typescript 均支持,感觉很快会进入 stage4. Asynchronous Iterationconst { value, done } = syncIterator.next();asyncIterator.next().then(({ value, done }) => /* ... */); for await (const line of readLines(filePath)) { console.log(line);} async function* readLines(path) { let file = await fileOpen(path); try { while (!file.EOF) { yield await file.readLine(); } } finally { await file.close(); }} 异步迭代器实现了 async await 与 generator 的结合。然而 async await 是使用 generator 的语法糖,generator 也可以通过 switch 等流程控制函数模拟。更重要的是异步在 generator 中本身就可以实现,我在《Callback Promise Generator Async-Await 和异常处理的演进》 文章中提过。 语法的修改一定不能为了方便(在 ECMAScript 中可能出现),但这种混杂的方式容易让人混淆 await 与 generator 之间的关系,是否进入 stage4 还需仔细斟酌。 import()import(`./section-modules/${link.dataset.entryModule}.js`) .then(module => { module.loadPageInto(main); }) .catch(err => { main.textContent = err.message; }); 这个提案主要增加了函数调用版的 import,而 webpack 等构建工具也在积极实现此规范,并作为动态加载的最佳范例。希望这种“官方 Amd”可以早日加入草案。 RegExp Lookbehind Assertionsjavascript 正则表达式一直不支持后行断言,不过现在已经进入 stage3,相信不久会进入 stage4. 前向断言: /\\d+(?=%)/.exec("100% of US presidents have been male") // ["100"]/\\d+(?!%)/.exec("that’s all 44 of them") // ["44"] 后向断言: /(?<=\\$)\\d+/.exec("Benjamin Franklin is on the $100 bill") // ["100"]/(?<!\\$)\\d+/.exec("it’s is worth about €90") // ["90"] 后向断言会获取某个字符后面跟的内容,在获取美刀等货币单位上有很大用途。chrome 可以使用 chrome.exe --js-flags="--harmony-regexp-lookbehind" 命令开启。 RegExp Unicode Property Escapesconst regexGreekSymbol = /\\p{Script=Greek}/u;regexGreekSymbol.test('π');// → true 以上 π 字符是一个希腊字符,通过指定 \\p{Script=Greek} 就可以匹配这个字符了! 虽然可以通过引用希腊字符(或者其他编码)表做正则处理,当每当更新表时,更新起来会非常麻烦,不如让浏览器原生支持 \\p{UnicodePropertyName=UnicodePropertyValue} 的正则语法,帮助开发人员解决这个烦恼。 RegExp named capture groupslet re = /(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})/u;let result = re.exec('2015-01-02');// result.groups.year === '2015';// result.groups.month === '01';// result.groups.day === '02';// result[0] === '2015-01-02';// result[1] === '2015';// result[2] === '01';// result[3] === '02'; let {groups: {one, two}} = /^(?<one>.*):(?<two>.*)$/u.exec('foo:bar');console.log(`one: ${one}, two: ${two}`); // prints one: foo, two: bar 同时,还支持 反向引用能力,可以通过 \\k<name> 的语法,在正则中表示同一种匹配类型,这个和 ts 范型很像: let duplicate = /^(?<half>.*).\\k<half>$/u;duplicate.test('a*b'); // falseduplicate.test('a*a'); // true 总体来看非常给力,毫无意义的下标也是正则反人类的原因之一,这个提案通过的话,正则会变得更加可读。 s (dotAll) flag for regular expressions/foo.bar/s.test('foo bar');// → true 通过添加了新的标识符 /s,表示 . 这个标志可以匹配任何值。原因是觉得现在正则的做法比较反人类: /foo[^]bar/.test('foo bar');// → true/foo[\\s\\S]bar/.test('foo bar');// → true 从保守派角度来看,可能因为掌握了 [^] [\\s\\S] 这种奇技淫巧而沾沾自喜,借此提高正则的门槛,让初学者“看不懂”,而高级语言的第一要义是可读性,RegExp Unicode Property Escapes 与 RegExp named capture groups 进入草案就是表明了对正则语义化改进的决心,相信这个提案也会被采纳。 Legacy RegExp features in JavaScript该提案主要针对 RegExp 遗留的静态属性进行梳理。平时很少接触,希望了解的人解读一下。 3.3 Stage2 大家庭function.sent metapropertygenerator 的第一个 .next 参数会被抛弃,因为第一次 next 没有对应上任何 yield,如下代码就会产生疑惑: function *adder(total=0) { let increment=1; while (true) { switch (request = yield total += increment) { case undefined: break; case "done": return total; default: increment = Number(request); } }}let tally = adder();tally.next(0.1); // argument will be ignoredtally.next(0.1);tally.next(0.1);let last=tally.next("done");console.log(last.value); //1.2 instead of 0.3 当引入 function.sent 后,可以接收来自 next 的传值,包括初始传值: function *adder(total=0) { let increment=1; do { switch (request = function.sent){ case undefined: break; case "done": return total; default: increment = Number(request); } yield total += increment; } while (true)}let tally = adder();tally.next(0.1); // argument no longer ignoredtally.next(0.1);tally.next(0.1);let last=tally.next("done");console.log(last.value); //0.3 这是个很棒的特性,也不存在语意兼容问题,但 api 还是比较怪,而且自此 yield 接收参数也变得没有意义,况且如今 async await 逐渐成为主流,这种修正没有强烈刚需。而且 yield 的语意本身没有错误,这个提案比较危险。 String.prototype.{trimStart,trimEnd}既然 padStart 与 padEnd 都进入了 stage4,trimStart trimEnd 这两个 api 也非常常用,而且从 ES5 将 String.prototype.trim 引入了标准来看,这两个非常有望晋升到 stage3。 Class Fieldsclass Counter extends HTMLElement { x = 0; ##y = 1;} 类成员变量,有了它 js 就完整了。虽然觉得似有变量符号很难看,但成员变量绝对是非常有用的语法,在 react 中已经很常用了: class Todo extends React.Component { state = { //.. }} Promise.prototype.finally就像 try/catch/finally 一样,try return 了都能执行 finally,是非常方便的,对 promise 来说也是如此,bluebird Q 等库已经实现了此功能。 但是库实现不足以使其纳入标准,只有当这些需求足够常用和通用时才会考虑。第三方库可能从竞争力角度考虑,多支持一种功能、少些一行代码就是多一份筹码,但语言规范是不能在乎这些的。 Class and Property Decorators类级别的装饰器已经进入 stage2 了,但现代前端开发中已经非常常用,很可能会进一步进入 stage3. 如果这个提案被废弃,那么大部分现代 js 代码将面临大量使用不存在语法的窘境。不过乐观的是,目前还找不到更好的装饰器替代方案,而在 python 中也存在装饰器模式可以参考。 Intl.Segmenter// Create a segmenter in your localelet segmenter = new Intl.Segmenter("fr", {granularity: "word"});// Get an iterator over a stringlet iterator = segmenter.segment("Ceci n'est pas une pipe");// Iterate over it!for (let {segment, breakType} of iterator) { console.log(`segment: ${segment} breakType: ${breakType}`); break;}// logs the following to the console:// segment: Ceci breakType: letter Intl.Segmenter 可以帮助分析单词断句分析,可能在 nlp 领域比较有用,在文本编辑器自动选中功能中也很有用。 虽然不是刚需,但 js 作为网页交互的语言,确实需要解决分析用户输入的问题。 Arbitrary-precision Integers新增了基本类型:整数类型,以及 Integer api 与字面语法 1234n。 目前 js 使用 64 位浮点数处理所有计算,直接导致了运算效率低下,这个提案弥补了 js 的计算缺点,希望可以早日进入草案。 提案名称由 Integer 改为 BigInt。 import.meta提出了使用 import.meta 获取当前模块的域信息。类比 nodejs 存在 __dirname 等信息标志当前脚本信息,通过浏览器加载的模块也应当拥有这种能力。 目前 js 可以通过如下方式获取脚本信息: const theOption = document.currentScript.dataset.option; 这样污染了全局变量,脚本信息应当存储在脚本作用域中,因此提案希望将脚本信息存储在脚本的 import.meta 变量中,因此可以这么使用: (async () => { const response = await fetch(new URL("../hamsters.jpg", import.meta.url)); const blob = await response.blob(); const size = import.meta.scriptElement.dataset.size || 300; const image = new Image(); image.src = URL.createObjectURL(blob); image.width = image.height = size; document.body.appendChild(image);})(); 3.4 Stage1 大家庭Date.parse fallback semantics通过字符串格式化日期一直是跨浏览器的痛点,本提案希望通过新增 Date.parse 标准完成这个功能。 “The function first attempts to parse the format of the String according to the rules(including extended years) called out in Date Time String Format (20.3.1.16). If theString does not conform to that format the function may fall back to anyimplementation-specific heuristics or implementation-specific date formats.” 正如提案所说,“如果字符串不满足 ISO 8601 格式,可以返回你想返回的任何值” 这样迷惑开发者是没有任何意义的,这样只会让开发者越来越不相信 js 是跨平台的语言。 这么重要的规范居然才 stage1,必须要顶上去。 export * as ns from “mod”; statementsexport * as someIdentifier from "someModule"; 很方便的 api,很多时候希望导出某个模块的全部接口,又不希望命名冲突,可以少写一行 import。 export v from “mod”; statements这个提案与 export * as ns from “mod”; statements 冲突了,感觉 export * as ns from “mod”; statements 提案更清晰一些。 Observable可观察类型可以从 dom 事件、轮询等触发事件中创建监听并订阅: function listen(element, eventName) { return new Observable(observer => { // Create an event handler which sends data to the sink let handler = event => observer.next(event); // Attach the event handler element.addEventListener(eventName, handler, true); // Return a cleanup function which will cancel the event stream return () => { // Detach the event handler from the element element.removeEventListener(eventName, handler, true); }; });}// Return an observable of special key down commandsfunction commandKeys(element) { let keyCommands = { "38": "up", "40": "down" }; return listen(element, "keydown") .filter(event => event.keyCode in keyCommands) .map(event => keyCommands[event.keyCode])}let subscription = commandKeys(inputElement).subscribe({ next(val) { console.log("Received key command: " + val) }, error(err) { console.log("Received an error: " + err) }, complete() { console.log("Stream complete") },}); 这个名字和 Object.observe 很像,不过没什么关系。该功能已经被 RxJS、XStream 等库实现。 String##matchAll目前正则表达式想要匹配全部的语法不够语义化,提案希望通过 matchAll 返回迭代器来遍历匹配结果,很赞! 现在匹配全部只能使用 while ((result = patt.exec(str)) != null) 这种方式遍历,不优雅。 WeakRefs弱引用,提案地址文档:https://github.com/tc39/proposal-weakrefs/blob/master/specs/Weak%20References%20for%20EcmaScript.pdf 有点像 OC 的弱引用,当对象被释放时,当前持有弱引用的对象也会被 GC 回收,但似乎还没有开始讨论,js 越来越底层了? Frozen Realms增强了 Realms 提案,利用不可变结构,实现结构共享。 Math ExtensionsMath 函数的拓展包含的函数:https://rwaldron.github.io/proposal-math-extensions/ 这个函数拓展很给力,特别是设计游戏,计算角度的时候: Math.DEG_PER_RAD // Math.PI / 180 Math.DEG_PER_RAD 是一种单位,让角度可以用 0~360 为周期的数字表示,比如射击子弹时的角度、或者做可视化时都非常有用,类比 css 中的:transform: rotate(180deg);。 of and from on collection constructors该提案设计了 Set、Map 类型的 of from 方法,具体见此:https://tc39.github.io/proposal-setmap-offrom/ 问题由于: Reflect.construct(Array, [1,2,3]) // [1,2,3]Reflect.construct(Set, [1,2,3]) // Uncaught TypeError: undefined is not a function 因为 Set 接收的参数是数组,而 construct 会调用 CreateListFromArrayLike 将参数打平,变成了 new Set(1, 2, 3) 传入,实际上是语法错误的,因此作者提议新增下 Set、Map 的 of from 方法。 Set、Map 在国内环境用的比较少,也很少有人计较这个问题,不过从技术角度来看,确实需要修复。。 Generator arrow functions (=>*)还是挺有必要的,毕竟都出箭头函数了,也要支持一下箭头函数的 generator 语法。 Promise.try同理,各大库都有实现,好处是所有错误都可以通过 .catch 捕获,而不用担心同步、异步错误的抛出。 Null Propagation超级有用,看代码就知道了: const firstName = message.body?.user?.firstName || 'default' 该功能完全等同: const firstName = (message && message.body && message.body.user &&\tmessage.body.user.firstName) || 'default' 希望立刻进入 stage4. Math.signbit: IEEE-754 sign bit当值为 负数 或 -0 时返回 true。由于 Math.sign 不区分 +0 与 -0,因此提案建议增加此函数,而且此函数在 c、c++、go 语言都有实现。 Error stacks提案建议将 Error.prototype.stack 作为标准,这对错误上报与分析特别有用,强烈支持。 do expressionsreturn ( <nav> <Home /> { do { if (loggedIn) { <LogoutButton /> } else { <LoginButton /> } } } </nav>) jsx 再也不用写得超长了,styled-components 中被诟病的分支判断难以阅读的问题也会烟消云散,因为我们有 do! Realmslet realm = new Realm();let outerGlobal = window;let innerGlobal = realm.global;let f = realm.evalScript("(function() { return 17 })");f() === 17 // trueReflect.getPrototypeOf(f) === outerGlobal.Function.prototype // falseReflect.getPrototypeOf(f) === innerGlobal.Function.prototype // true Realms 提供了 global 环境的隔离,eval 执行代码时不再会污染全局,简直是测试的福利,脑洞很大。 Temporal与 Date 类似,但功能更强: var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, options);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, options);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, options);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, 456789);var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, 456789, options);// add/subtract time (Dec 31 2017 23:00 + 2h = Jan 1 2018 01:00)var addHours = new temporal.LocalDateTime(2017, 12, 31, 23, 00).add(2, 'hours');// add/subtract months (Mar 31 - 1M = Feb 28)var addMonths = new temporal.LocalDateTime(2017,03,31).subtract(1, 'months'); // add/subtract years (Feb 29 2020 - 1Y = Feb 28 2019)var subtractYears = new temporal.LocalDateTime(2020, 02, 29).subtract(1, 'years'); 还自带时区转换 api 等等,如果进入草案,可以放弃 moment 这个重量级库了。 Float16 on TypedArrays, DataView, Math.hfround由于大多数 WebGL 纹理需要半精度以上的浮点数计算,推荐了 4 个 api: Float16Array DataView.prototype.getFloat16 DataView.prototype.setFloat16 Math.hfround(x) Atomics.waitNonblockingvar sab = new SharedArrayBuffer(4096);var ia = new Int32Array(sab);ia[37] = 0x1337;test1();function test1() { Atomics.waitNonblocking(ia, 37, 0x1337, 1000).then(function (r) { console.log("Resolved: " + r); test2(); });}var code = `var ia = null;onmessage = function (ev) { if (!ia) { console.log("Aux worker is running"); ia = new Int32Array(ev.data); } console.log("Aux worker is sleeping for a little bit"); setTimeout(function () { console.log("Aux worker is waking"); Atomics.wake(ia, 37); }, 1000);}`;function test2() { var w = new Worker("data:application/javascript," + encodeURIComponent(code)); w.postMessage(sab); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved: " + r); test3(w); });}function test3(w) { w.postMessage(false); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 1: " + r); }); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 2: " + r); }); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 3: " + r); }); } 该 api 可以在多线程操作中,有顺序的操作同一个内存地址,如上代码变量 ia 虽然在多线程中执行,但每个线程都会等资源释放后再继续执行。 Numeric separators1_000_000_000 // Ah, so a billion101_475_938.38 // And this is hundreds of millionslet fee = 123_00; // $123 (12300 cents, apparently)let fee = 12_300; // $12,300 (woah, that fee!)let amount = 12345_00; // 12,345 (1234500 cents, apparently)let amount = 123_4500; // 123.45 (4-fixed financial)let amount = 1_234_500; // 1,234,500 提案希望 js 支持分隔符使大数字阅读性更好(不影响计算),很多语言都有实现,很人性化。 4 总结每个草案都觉得很靠谱,涉及语义化、无障碍、性能、拓展语法、连接 nodejs 等方面,虽然部分提案从语言设计角度是错误的,但 js 运行在网页端,涉及到人机交互、网络加载等问题,遇到的问题自然比任何语言都要复杂,每个提案都是从实践中出发,相信这种道路是正确的。 由于篇幅与时间限制,stage0 的提案等下次再讨论。特别提一点,stage0 的 Cancellation API 很值得大家关注,取消异步操作是人心所向,大势所趋啊。 感谢所有参与讨论的同学,你们的支持会转化为我们的动力,每周更新,风雨无阻。 讨论地址是:精读《TC39, ECMAScript, and the Future of JavaScript》 · Issue ##21 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。 访问 原始文章地址 , 获得更好阅读效果。"},{"title":"《This 带来的困惑》","path":"/wiki/WebWeekly/前沿技术/《This 带来的困惑》.html","content":"当前期刊数: 13 1 引言 javascript 的 this 是个头痛的话题,本期精读的文章更是引出了一个观点,避免使用 this。我们来看看是否有道理。 本期精读的文章是:classes-complexity-and-functional-programming 2 内容概要javascript 语言的 this 是个复杂的设计,相比纯对象与纯函数,this 带来了如下问题: const person = new Person('Jane Doe')const getGreeting = person.getGreeting// later...getGreeting() // Uncaught TypeError: Cannot read property 'greeting' of undefined at getGreeting 初学者可能突然将 this 弄丢导致程序出错,甚至在 react 中也要使用 bind 的方式,使回调可以访问到 setState 等函数。 this 也不利于测试,如果使用纯函数,可以通过入参出参做测试,而不需要预先初始化环境。 所以我们可以避免使用 this,看如下的例子: function setName(person, strName) { return Object.assign({}, person, {name: strName})}// bonus function!function setGreeting(person, newGreeting) { return Object.assign({}, person, {greeting: newGreeting})}function getName(person) { return getPrefixedName('Name', person.name)}function getPrefixedName(prefix, name) { return `${prefix}: ${name}`}function getGreetingCallback(person) { const {greeting, name} = person return (subject) => `${greeting} ${subject}, I'm ${name}`}const person = {greeting: 'Hey there!', name: 'Jane Doe'}const person2 = setName(person, 'Sarah Doe')const person3 = setGreeting(person2, 'Hello')getName(person3) // Name: Sarah DoegetGreetingCallback(person3)('Jeff') // Hello Jeff, I'm Sarah Doe 这样 person 实例是个纯对象,没有将方法挂载到原型链上,简单易懂。 或者可以将属性放在上级作用域,避免使用 this,就避免了 this 丢失带来的隐患: function getPerson(initialName) { let name = initialName const person = { setName(strName) { name = strName }, greeting: 'Hey there!', getName() { return getPrefixedName('Name') }, getGreetingCallback() { const {greeting} = person return (subject) => `${greeting} ${subject}, I'm ${name}` }, } function getPrefixedName(prefix) { return `${prefix}: ${name}` } return person} 以上代码没有用到 this,也不会因为 this 产生的问题所困扰。 3 精读本文作者认为,class 带来的困惑主要在于 this,这主要因为成员函数会挂到 prototype 下,虽然多个实例共享了引用,但因此带来的隐患就是 this 的不确定性。js 有许多种 this 丢失情况,比如 隐式绑定 别名丢失隐式绑定 回调丢失隐式绑定 显式绑定 new绑定 箭头函数改变this作用范围 等等。 由于在 prototype 中的对象依赖 this,如果 this 丢了,就访问不到原型链,不但会引发报错,在写代码时还需要注意 this 的作用范围是很头疼的事。因此作者有如下解决方案: function getPerson(initialName) { let name = initialName const person = { setName(strName) { name = strName } } return person} 由此生成的 person 对象不但是个简单 object,由于没有调用 this,也不存在 this 丢失的情况。 这个观点我是不认可的。当然做法没有问题,代码逻辑也正确,也解决了 this 存在的原型链访问丢失问题,但这并不妨碍使用 this。我们看以下代码: class Person { setName = (name) => { this.name = name }}const person = new Person()const setName = person.setNamesetName("Jane Doe")console.log(person) 这里用到了 this,也产生了别名丢失隐式绑定,但 this 还能正确访问的原因在于,没有将 setName 的方法放在原型链上,而是放在了每个实例中,因此无论怎么丢失 this,也仅仅丢失了原型链上的方法,但 this 无论如何会首先查找其所在对象的方法,只要方法不放在原型链上,就不用担心丢失的问题。 至于放在原型链上会节约多个实例内存开销问题,函数式也无法避免,如果希望摆脱 this 带来的困扰,class 的方式也可以解决问题。 3.1 this 丢失的情况3.1.1 默认绑定在严格模式与非严格模式下,默认绑定有所区别,非严格模式 this 会绑定到上级作用域,而 use strict 时,不会绑定到 window。 function foo(){ console.log(this.count) // 1 console.log(foo.count) // 2}var count = 1foo.count = 2foo() function foo(){ "use strict" console.log(this.count) // TypeError: count undefined}var count = 1foo() 3.1.2 隐式绑定当函数被对象引用起来调用时,this 会绑定到其依附的对象上。 function foo(){ console.log(this.count) // 2}var obj = { count: 2, foo: foo}obj.foo() 3.1.3 别名丢失隐式绑定调用函数引用时,this 会根据调用者环境而定。 function foo(){ console.log(this.count) // 1}var count = 1var obj = { count: 2, foo: foo}var bar = obj.foo // 函数别名bar() 3.1.4 回调丢失隐式绑定这种情况类似 react 默认的情况,将函数传递给子组件,其调用时,this 会丢失。 function foo(){ console.log(this.count) // 1}var count = 1var obj = { count: 2, foo: foo}setTimeout(obj.foo) 3.2 this 绑定修复3.2.1 bind 显式绑定使用 bind 属于显示绑定。 function foo(){ console.log(this.count) // 1}var obj = { count: 1}foo.call(obj)var bar = foo.bind(obj)bar() 3.2.2 es6 绑定这种情况类似使用箭头函数创建成员变量,以下方式等于创建了没有挂载到原型链的匿名函数,因此 this 不会丢失。 function foo(){ setTimeout(() => { console.log(this.count) // 2 })}var obj = { count: 2}foo.call(obj) 3.2.3 函数 bind除此之外,我们还可以指定回调函数的作用域,达到 this 指向正确原型链的效果。 function foo(){ setTimeout(function() { console.log(this.count) // 2 }.bind(this))}var obj = { count: 2}foo.call(obj) 关于块级作用域也是 this 相关的知识点,由于现在大量使用 let const 语法,甚至在 if 块下也存在块级作用域: if (true) { var a = 1 let b = 2 const c = 3}console.log(a) // 1console.log(b) // ReferenceErrorconsole.log(c) // ReferenceError 4 总结要正视 this 带来的问题,不能因为绑定丢失,引发非预期的报错而避免使用,其根本原因在于 javascript 的原型链机制。这种机制是非常好的,将对象保存在原型链上,可以方便多个实例之间共享,但因此不可避免带来了原型链查找过程,如果对象运行环境发生了变化,其原型链也会发生变化,此时无法享受到共享内存的好处,我们有两种选择:一种是使用 bind 将原型链找到,一种是比较偷懒的将函数放在对象上,而不是原型链上。 自动 bind 的方式 react 之前在框架层面做过,后来由于过于黑盒而取消了。如果为开发者隐藏 this 细节,框架层面自动绑定,看似方便了开发者,但过分提高开发者对 this 的期望,一旦去掉黑魔法,就会有许多开发者不适应 this 带来的困惑,所以不如一开始就将 this 问题透传给开发者,使用自动绑定的装饰器,或者回调处手动 bind(this),或将函数直接放在对象中都可以解决问题。"},{"title":"《Typescript 3","path":"/wiki/WebWeekly/前沿技术/《Typescript 3.html","content":"当前期刊数: 84 1 引言Typescript 3.2 发布了几个新特性,主要变化是类型检查更严格,对 ES6、ES7 一些时髦功能拓展了类型支持。 2 概要下面挑一些相对重要配置介绍。 strictBindCallApply对 bind call apply 更严格的类型检测。 比如如下可以检测出 apply 函数参数数量和类型的错误: function foo(a: number, b: string): string { return a + b;}let a = foo.apply(undefined, [10]); // error: too few argumnts 特别对一些 react 老代码,函数需要自己 bind(this),在没有用箭头函数时,可能经常使用 this.foo = this.foo.bind(this),这时类型可能会不准,但升级到 TS3.2 后,可以准确捕获到错误了。 Object spread 类型自动合并现在 Object spread 类型可以自动合并了: // Returns 'T & U'function merge<T, U>(x: T, y: U) { return { ...x, ...y };} Object rest 类型自动剔除const { x, y, z, ...rest } = obj; 当我们使用了 Object rest 语法时,rest 的类型其实是 obj 类型剔除了 x y z 这三个 key 的类型,现在 ts 已经能自动做到了! 下面是实现方式: interface XYZ { x: any; y: any; z: any;}type DropXYZ<T> = Pick<T, Exclude<keyof T, keyof XYZ>>;function dropXYZ<T extends XYZ>(obj: T): DropXYZ<T> { let { x, y, z, ...rest } = obj; return rest;} 通过 Pick & Exclude 达到剔除 obj 属性的效果,具体可以参考之前的精读:精读《Typescript2.0 - 2.9》。 tsconfig 配置集成支持 node_modules这是一个福音,以往在 tsconfig.json 为了继承一个配置,我们需要这么写: { "extends": "../node_modules/@my-team/tsconfig-base/tsconfig.json"} TS3.2 内置了 node_modules 解析,因此就可以更清晰的描述了: { "extends": "@my-team/tsconfig-base"} 内置 BigInt 类型新增了 bigint 类型,再也不会把 bigint 和 number 混淆了。 declare let foo: number;declare let bar: bigint;foo = bar; // error: Type 'bigint' is not assignable to type 'number'.bar = foo; // error: Type 'number' is not assignable to type 'bigint'. 3 精读这次改动意图非常明显,是为了跟上 JS 的新语法。随着 JS 规范发展,TS 类型必然要得到补充,像 Object spread 与 Object rest 在项目中使用已经非常普遍了,及时完善类型支持,有助于对项目类型的约束。 strictBindCallApply 基本可以算是对 React 社区的回馈。在 React 很早期的版本是支持函数自动 bind 的,后来觉得过于 magic 就移除了,由于当时没有箭头函数,大家只好在调用处 .bind(this) 一下。 后来有人发现 .bind(this) 会导致函数引用变化,对 Mutable 性能优化不友好,所以许多代码都在 constructor 位置进行类似 this.fooBind = this.foo.bind(this) 这样的赋值,如今 TS3.2 对这种 bind 过后的函数也具备了严格的类型推测,将会有一大批代码从中受益。 顺带一提,最近 Babel 7.2.0 发布,也带来了一些新特性支持,比如: 提前支持私有属性: class Person { ##age = 19; ##increaseAge() { this.##age++; } birthday() { this.##increaseAge(); alert("Happy Birthday!"); }} 提前支持 pipleline Operator: const result = 2 |> double |> 3 + ## |> toStringBase(2, ##); // "111" 整个 JS 生态一篇欣欣向荣的景象。不过 TS 对 ES 规范支持还是比较保守的,比如 Babel 6.x 就支持的 optional chain,现在也没有得到支持,原因是 “等待进入 Stage3”。追踪 ISSUE 可以参考:https://github.com/Microsoft/TypeScript/issues/16 如果不清楚 Stage3 的含义,可以阅读前端精读之前的一篇文章:精读 TC39 与 ECMAScript 提案。 4 总结这次的版本升级几乎没带来什么新语法,只是纯粹的类型检测能力增强,所以如果希望进一步提高代码质量,就尽快升级把。 讨论地址是:精读《Typescript 3.2 新特性》 · Issue ##117 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《Typescript 4","path":"/wiki/WebWeekly/前沿技术/《Typescript 4.html","content":"当前期刊数: 237 新增 Awaited 类型Awaited 可以将 Promise 实际返回类型抽出来,按照名字可以理解为:等待 Promise resolve 了拿到的类型。下面是官方文档提供的 Demo: // A = stringtype A = Awaited<Promise<string>>;// B = numbertype B = Awaited<Promise<Promise<number>>>;// C = boolean | numbertype C = Awaited<boolean | Promise<number>>; 捆绑的 dom lib 类型可以被替换TS 因开箱即用的特性,捆绑了所有 dom 内置类型,比如我们可以直接使用 Document 类型,而这个类型就是 TS 内置提供的。 也许有时不想随着 TS 版本升级而升级连带的 dom 内置类型,所以 TS 提供了一种指定 dom lib 类型的方案,在 package.json 申明 @typescript/lib-dom 即可: { "dependencies": { "@typescript/lib-dom": "npm:@types/web" }} 这个特性提升了 TS 的环境兼容性,但一般情况还是建议开箱即用,省去繁琐的配置,项目更好维护。 模版字符串类型也支持类型收窄export interface Success { type: `${string}Success`; body: string;}export interface Error { type: `${string}Error`; message: string;}export function handler(r: Success | Error) { if (r.type === "HttpSuccess") { // 'r' has type 'Success' let token = r.body; }} 模版字符串类型早就支持了,但现在才支持按照模版字符串在分支条件时,做类型收窄。 增加新的 –module es2022虽然可以使用 –module esnext 保持最新特性,但如果你想使用稳定的版本号,又要支持顶级 await 特性的话,可以使用 es2022。 尾递归优化TS 类型系统支持尾递归优化了,拿下面这个例子就好理解: type TrimLeft<T extends string> = T extends ` ${infer Rest}` ? TrimLeft<Rest> : T;// error: Type instantiation is excessively deep and possibly infinite.type Test = TrimLeft<" oops">; 在没有做尾递归优化前,TS 会因为堆栈过深而报错,但现在可以正确返回执行结果了,因为尾递归优化后,不会形成逐渐加深的调用,而是执行完后立即退出当前函数,堆栈数量始终保持不变。 JS 目前还没有做到自动尾递归优化,但可以通过自定义函数 TCO 模拟实现,下面放出这个函数的实现: function tco(f) { var value; var active = false; var accumulated = []; return function accumulator(...rest) { accumulated.push(rest); if (!active) { active = true; while (accumulated.length) { value = f.apply(this, accumulated.shift()); } active = false; return value; } };} 核心是把递归变成 while 循环,这样就不会产生堆栈。 强制保留 importTS 编译时会把没用到的 import 干掉,但这次提供了 --preserveValueImports 参数禁用这一特性,原因是以下情况会导致误移除 import: import { Animal } from "./animal.js";eval("console.log(new Animal().isDangerous())"); 因为 TS 无法分辨 eval 里的引用,类似的还有 vue 的 setup 语法: <!-- A .vue File --><script setup>import { someFunc } from "./some-module.js";</script><button @click="someFunc">Click me!</button> 支持变量 import type 声明之前支持了如下语法标记引用的变量是类型: import type { BaseType } from "./some-module.js"; 现在支持了变量级别的 type 声明: import { someFunc, type BaseType } from "./some-module.js"; 这样方便在独立模块构建时,安全的抹去 BaseType,因为单模块构建时,无法感知 some-module.js 文件内容,所以如果不特别指定 type BaseType,TS 编译器将无法识别其为类型变量。 类私有变量检查包含两个特性,第一是 TS 支持了类私有变量的检查: class Person { ##name: string;} 第二是支持了 ##name in obj 的判断,如: class Person { ##name: string; constructor(name: string) { this.##name = name; } equals(other: unknown) { return other && typeof other === "object" && ##name in other && // <- this is new! this.##name === other.##name; }} 该判断隐式要求了 ##name in other 的 other 是 Person 实例化的对象,因为该语法仅可能存在于类中,而且还能进一步类型缩窄为 Person 类。 Import 断言支持了导入断言提案: import obj from "./something.json" assert { type: "json" }; 以及动态 import 的断言: const obj = await import("./something.json", { assert: { type: "json" }}) TS 该特性支持了任意类型的断言,而不关心浏览器是否识别。所以该断言如果要生效,需要以下两种支持的任意一种: 浏览器支持。 构建脚本支持。 不过目前来看,构建脚本支持的语法并不统一,比如 Vite 对导入类型的断言有如下两种方式: import obj from "./something?raw"// 或者自创的语法 blob 加载模式const modules = import.meta.glob( './**/index.tsx', { assert: { type: 'raw' }, },); 所以该导入断言至少在未来可以统一构建工具的语法,甚至让浏览器原生支持后,就不需要构建工具处理 import 断言了。 其实完全靠浏览器解析要走的路还有很远,因为一个复杂的前端工程至少有 3000~5000 个资源文件,目前生产环境不可能使用 bundless 一个个加载这些资源,因为速度太慢了。 const 只读断言const obj = { a: 1} as constobj.a = 2 // error 通过该语法指定对象所有属性为 readonly。 利用 realpathSync.native 实现更快加载速度对开发者没什么感知,就是利用 realpathSync.native 提升了 TS 加载速度。 片段自动补全增强在 Class 成员函数与 JSX 属性的自动补全功能做了增强,在使用了最新版 TS 之后应该早已有了体感,比如 JSX 书写标签输入回车后,会自动根据类型补全内容,如: <App cla />// ↑回车↓// <App className="|" />// ↑光标自动移到这里 代码可以写在 super() 前了JS 对 super() 的限制是此前不可以调用 this,但 TS 限制的更严格,在 super() 前写任何代码都会报错,这显然过于严格了。 现在 TS 放宽了校验策略,仅在 super() 前调用 this 会报错,而执行其他代码是被允许的。 这点其实早就该改了,这么严格的校验策略让我一度以为 JS 就是不允许 super() 前调用任何函数,但想想也觉得不合理,因为 super() 表示调用父类的 constructor 函数,之所以不自动调用,而需要手动调用 super() 就是为了开发者可以灵活决定哪些逻辑在父类构造函数前执行,所以 TS 之前一刀切的行为实际上导致 super() 失去了存在的意义,成为一个没有意义的模版代码。 类型收窄对解构也生效了这个特性真的很厉害,即解构后类型收窄依然生效。 此前,TS 的类型收窄已经很强大了,可以做到如下判断: function foo(bar: Bar) { if (bar.a === '1') { bar.b // string 类型 } else { bar.b // number 类型 }} 但如果提前把 a、b 从 bar 中解构出来就无法自动收窄了。现在该问题也得到了解决,以下代码也可以正常生效了: function foo(bar: Bar) { const { a, b } = bar if (a === '1') { b // string 类型 } else { b // number 类型 }} 深度递归类型检查优化下面的赋值语句会产生异常,原因是属性 prop 的类型不匹配: interface Source { prop: string;}interface Target { prop: number;}function check(source: Source, target: Target) { target = source; // error! // Type 'Source' is not assignable to type 'Target'. // Types of property 'prop' are incompatible. // Type 'string' is not assignable to type 'number'.} 这很好理解,从报错来看,TS 也会根据递归检测的方式查找到 prop 类型不匹配。但由于 TS 支持泛型,如下写法就是一种无限递归的例子: interface Source<T> { prop: Source<Source<T>>;}interface Target<T> { prop: Target<Target<T>>;}function check(source: Source<string>, target: Target<number>) { target = source;} 实际上不需要像官方说明写的这么复杂,哪怕是 props: Source<T> 也足以让该例子无限递归下去。TS 为了确保该情况不会出错,做了递归深度判断,过深的递归会终止判断,但这会带来一个问题,即无法识别下面的错误: interface Foo<T> { prop: T;}declare let x: Foo<Foo<Foo<Foo<Foo<Foo<string>>>>>>;declare let y: Foo<Foo<Foo<Foo<Foo<string>>>>>;x = y; 为了解决这一问题,TS 做了一个判断:递归保护仅对递归写法的场景生效,而上面这个例子,虽然也是很深层次的递归,但因为是一个个人肉写出来的,TS 也会不厌其烦的一个个递归下去,所以该场景可以正确 Work。 这个优化的核心在于,TS 可以根据代码结构解析哪些是 “非常抽象/启发式” 写法导致的递归,哪些是一个个枚举产生的递归,并对后者的递归深度检查进行豁免。 增强的索引推导下面的官方文档给出的例子,一眼看上去比较复杂,我们来拆解分析一下: interface TypeMap { "number": number; "string": string; "boolean": boolean;}type UnionRecord<P extends keyof TypeMap> = { [K in P]: { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void; }}[P];function processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) { record.f(record.v);}// This call used to have issues - now works!processRecord({ kind: "string", v: "hello!", // 'val' used to implicitly have the type 'string | number | boolean', // but now is correctly inferred to just 'string'. f: val => { console.log(val.toUpperCase()); }}) 该例子的目的是实现 processRecord 函数,该函数通过识别传入参数 kind 来自动推导回调函数 f 中 value 的类型。 比如 kind: "string",那么 val 就是字符串类型,kind: "number",那么 val 就是数字类型。 因为 TS 这次更新解决了之前无法识别 val 类型的问题,我们不需要关心 TS 是怎么解决的,只要记住 TS 可以正确识别该场景(有点像围棋的定式,对于经典例子最好逐一学习),并且理解该场景是如何构造的。 如何做到呢?首先定义一个类型映射: interface TypeMap { "number": number; "string": string; "boolean": boolean;} 之后定义最终要的函数 processRecord: function processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) { record.f(record.v);} 这里定义了一个泛型 K,K extends keyof TypeMap 等价于 K extends 'number' | 'string' | 'boolean',所以这里是限定了以下泛型 K 的取值范围,值为这三个字符串之一。 重点来了,参数 record 需要根据传入的 kind 决定 f 回调函数参数类型。我们先想象以下 UnionRecord 类型怎么写: type UnionRecord<K extends keyof TypeMap> = { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void;} 如上,自然的想法是定义一个泛型 K,这样 kind 与 f, p 类型都可以表示出来,这样 processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) 的 UnionRecord<K> 就表示了将当前接收到的实际类型 K 传入 UnionRecord,这样 UnionRecord 就知道实际处理什么类型了。 本来到这里该功能就已经结束了,但官方给的 UnionRecord 定义稍有些不同: type UnionRecord<P extends keyof TypeMap> = { [K in P]: { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void; }}[P]; 这个例子特意提升了一个复杂度,用索引的方式绕了一下,可能之前 TS 就无法解析这种形式吧,总之现在这个写法也被支持了。我们看一下为什么这个写法与上面是等价的,上面的写法简化一下如下: type UnionRecord<P extends keyof TypeMap> = { [K in P]: X}[P]; 可以解读为,UnionRecord 定义了一个泛型 P,该函数从对象 { [K in P]: X } 中按照索引(或理解为下标) [P] 取得类型。而 [K in P] 这种描述对象 Key 值的类型定义,等价于定义了复数个类型,由于正好 P extends keyof TypeMap,你可以理解为类型展开后是这样的: type UnionRecord<P extends keyof TypeMap> = { 'number': X, 'string': X, 'boolean': X}[P]; 而 P 是泛型,由于 [K in P] 的定义,所以必定能命中上面其中的一项,所以实际上等价于下面这个简单的写法: type UnionRecord<K extends keyof TypeMap> = { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void;} 参数控制流分析这个特性字面意思翻译挺奇怪的,还是从代码来理解吧: type Func = (...args: ["a", number] | ["b", string]) => void;const f1: Func = (kind, payload) => { if (kind === "a") { payload.toFixed(); // 'payload' narrowed to 'number' } if (kind === "b") { payload.toUpperCase(); // 'payload' narrowed to 'string' }};f1("a", 42);f1("b", "hello"); 如果把参数定义为元组且使用或并列枚举时,其实就潜在包含了一个运行时的类型收窄。比如当第一个参数值为 a 时,第二个参数类型就确定为 number,第一个参数值为 b 时,第二个参数类型就确定为 string。 值得注意的是,这种类型推导是从前到后的,因为参数是自左向右传递的,所以是前面推导出后面,而不能是后面推导出前面(比如不能理解为,第二个参数为 number 类型,那第一个参数的值就必须为 a)。 移除 JSX 编译时产生的非必要代码JSX 编译时干掉了最后一个没有意义的 void 0,减少了代码体积: - export const el = _jsx("div", { children: "foo" }, void 0);+ export const el = _jsx("div", { children: "foo" }); 由于改动很小,所以可以借机学习一下 TS 源码是怎么修改的,这是 PR DIFF 地址。 可以看到,修改位置是 src/compiler/transformers/jsx.ts 文件,改动逻辑为移除了 factory.createVoidZero() 函数,该函数正如其名,会创建末尾的 void 0,除此之外就是大量的 tests 文件修改,其实理解了源码上下文,这种修改并不难。 JSDoc 校验提示JSDoc 注释由于与代码是分离的,随着不断迭代很容易与实际代码产生分叉: /** * @param x {number} The first operand * @param y {number} The second operand */function add(a, b) { return a + b;} 现在 TS 可以对命名、类型等不一致给出提示了。顺便说一句,用了 TS 就尽量不要用 JSDoc,毕竟代码和类型分离随时有不一致的风险产生。 总结从这两个更新来看,TS 已经进入成熟期,但 TS 在泛型类的问题上依然还处于早期阶段,有大量复杂的场景无法支持,或者没有优雅的兼容方案,希望未来可以不断完善复杂场景的类型支持。 讨论地址是:精读《Typescript 4.5-4.6 新特性》· Issue ##408 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Typescript 4》","path":"/wiki/WebWeekly/前沿技术/《Typescript 4》.html","content":"当前期刊数: 158 1 引言随着 Typescript 4 Beta 的发布,又带来了许多新功能,其中 Variadic Tuple Types 解决了大量重载模版代码的顽疾,使得这次更新非常有意义。 2 简介可变元组类型考虑 concat 场景,接收两个数组或者元组类型,组成一个新数组: function concat(arr1, arr2) { return [...arr1, ...arr2];} 如果要定义 concat 的类型,以往我们会通过枚举的方式,先枚举第一个参数数组中的每一项: 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 次重载,代码几乎不可维护: 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]; 如果我们采用批量定义的方式,问题也不会得到解决,因为参数类型的顺序得不到保证: function concat<T, U>(arr1: T[], arr2, U[]): Array<T | U>; 在 Typescript 4,可以在定义中对数组进行解构,通过几行代码优雅的解决可能要重载几百次的场景: 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,返回除第一项外剩下元素: function tail(arg) { const [_, ...result] = arg; return result;} 同样告诉 TS T 是数组类型,且 arr: readonly [any, ...T] 申明了 T 类型表示除第一项其余项的类型,TS 可自动将 T 类型关联到对象 rest: 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 只能将类型解构放在最后一个位置: type Strings = [string, string];type Numbers = [number, number];// [string, string, number, number]type StrStrNumNum = [...Strings, ...Numbers]; 如果你尝试将 [...Strings, ...Numbers] 这种写法,将会得到一个错误提示: A rest element must be last in a tuple type. 但在 Typescript 4 版本支持了这种语法: type Strings = [string, string];type Numbers = number[];// [string, string, ...Array<number | boolean>]type Unbounded = [...Strings, ...Numbers, boolean]; 对于再复杂一些的场景,例如高阶函数 partialCall,支持一定程度的柯里化: function partialCall(f, ...headArgs) { return (...tailArgs) => f(...headArgs, ...tailArgs);} 我们可以通过上面的特性对其进行类型定义,将函数 f 第一个参数类型定义为有顺序的 [...T, ...U]: 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);} 测试效果如下: 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() 时才会提示参数数量不正确。 元组标记下面两个函数定义在功能上是一样的: function foo(...args: [string, number]): void { // ...}function foo(arg0: string, arg1: number): void { // ...} 但还是有微妙的区别,下面的函数对每个参数都有名称标记,但上面通过解构定义的类型则没有,针对这种情况,Typescript 4 支持了元组标记: type Range = [start: number, end: number]; 同时也支持与解构一起使用: type Foo = [first: number, second?: string, ...rest: any[]]; Class 从构造函数推断成员变量类型构造函数在类实例化时负责一些初始化工作,比如为成员变量赋值,在 Typescript 4,在构造函数里对成员变量的赋值可以直接为成员变量推导类型: class Square { // Previously: implicit any! // Now: inferred to `number`! area; sideLength; constructor(sideLength: number) { this.sideLength = sideLength; this.area = sideLength ** 2; }} 如果对成员变量赋值包含在条件语句中,还能识别出存在 undefined 的风险: class Square { sideLength; constructor(sideLength: number) { if (Math.random()) { this.sideLength = sideLength; } } get area() { return this.sideLength ** 2; // ~~~~~~~~~~~~~~~ // error! Object is possibly 'undefined'. }} 如果在其他函数中初始化,则 TS 不能自动识别,需要用 !: 显式申明类型: 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; }} 短路赋值语法针对以下三种短路语法提供了快捷赋值语法: a &&= b; // a && (a = b)a ||= b; // a || (a = b)a ??= b; // a ?? (a = b) catch error unknown 类型Typescript 4.0 之后,我们可以将 catch error 定义为 unknown 类型,以保证后面的代码以健壮的类型判断方式书写: 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 工厂函数: { "compilerOptions": { "target": "esnext", "module": "commonjs", "jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment" }} 还可以通过注释方式覆盖单文件的配置: // 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> </>); 以上代码编译后解析结果如下: // 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 数据流的值,其调用方式是这样的: 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,但类型定义就麻烦了,通过重载可以这么做: 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 的例子,我们可以避免类型重载,而通过枚举的方式支持: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Typescript infer 关键字》","path":"/wiki/WebWeekly/前沿技术/《Typescript infer 关键字》.html","content":"当前期刊数: 207 Infer 关键字用于条件中的类型推导。 Typescript 官网也拿 ReturnType 这一经典例子说明它的作用: type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any; 理解为:如果 T 继承了 (...args: any[]) => any 类型,则返回类型 R,否则返回 any。其中 R 是什么呢?R 被定义在 extends (...args: any[]) => infer R 中,即 R 是从传入参数类型中推导出来的。 精读我们可以从两个视角来理解 infer,分别是需求角度与设计角度。 需求角度理解 infer实现 infer 这个关键字一定是背后存在需求,这个需求是普通 Typescript 能力无法满足的。 设想这样一个场景:实现一个函数,接收一个数组,返回第一项。 我们无法用泛型来描述这种类型推导,因为泛型类型是一个整体,而我们想要返回的是入参其中某一项,我们并不能通过类似 T[0] 的写法拿到第一项类型: function xxx<T>(...args: T[]): T[0] 而实际上不支持这种写法也是合理的,因为这次是获取第一项类型,如果 T 是一个对象,我们想返回其中 onChange 这个 Key 的返回值类型,就不知道如何书写了。所以此时必须用一种新的语法实现,就是 infer。 设计角度理解 infer从类型推导功能来看,泛型功能非常强大,我们可以用泛型描述调用时才传入的类型,并提前将它描述在类型表达式中: function xxx<T>(value: T): { result: T } 但我们发现 T 这个泛型太整体化了,我们还不具备从中 Pick 子类型的能力。也就是对于 xxx<{label: string}> 这个场景,T = {label: string},但我们无法将 R 定义为 {label: R} 这个位置,因为泛型是一个不可拆分的整体。 而且实际上为了类型安全,我们也不能允许用户描述任意的类型位置,万一传入的类型结构不是 {label: xxx} 而是一个回调 () => void,那子类型推导岂不是建立在了错误的环境中。 所以考虑到想要拿到 {label: infer R},首先参数必须具备 {label: xxx} 的结构,所以正好可以将 infer 与条件判断 T extends xxx ? A : B 结合起来用,即: type GetLabelTypeFromObject<T> = T extends { label: infer R } ? R : nevertype Result = GetLabelTypeFromObject<{ label: string }>;// type Result = string 即如果 T 遵循 { label: any } 这样一个结构,那么我可以将这个结构中任何变量位置替换为 infer xxx,如果传入类型满足这个结构(TS 静态解析环节判断),则可以基于这个结构体继续推导,所以在推导过程中我们就可以使用 infer xxx 推断的变量类型。 回过头来看第一个需求,拿到第一个参数类型就可以用 infer 实现了: type GetFirstParamType<T> = T extends (...args: infer R) => any ? R[0] : never 可以理解为,如果此时 T 满足 (...args: any) => any 这个结构,同时我们用 infer R 表示 R 这个临时变量指代第一个 any 运行时类型,那么整个函数返回的类型就是 R。如果 T 都不满足 (...args: any) => any 这个结构,比如 GetFirstParamType<number>,那这种推导根本无从谈起,直接返回 never 类型兜底,当然也可以自定义比如 any 之类的任何类型。 概述我们理解了 infer 含义后,再结合 conditional infer 这篇文章理解里面的例子,有助于加深记忆。 type ArrayElementType<T> = T extends (infer E)[] ? E : T;// type of item1 is `number`type item1 = ArrayElementType<number[]>;// type of item1 is `{name: string}`type item2 = ArrayElementType<{ name: string }>; 可以看到,ArrayElementType 利用了条件推断与 infer,表示了这样一个逻辑:如果 T 类型是一个数组,且我们将数组的每一项定义为 E 类型,那么返回类型就为 E,否则为 T 整体类型本身。 所以对于 item1 是满足结构的,所以返回 number,而 item2 不满足结构,所以返回其类型本身。 特别补充一点,对于下面的例子返回什么呢? type item3 = ArrayElementType<[number, string]>; 答案是 number | string,原因是我们用多个 infer E((infer E)[] 相当于 [infer E, infer E]... 不就是多个变量指向同一个类型代词 E 嘛)同时接收到了 number 和 string,所以可以理解为 E 时而为 number 时而为 string,所以是或关系,这就是协变。 那如果是函数参数呢? type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : nevertype T21 = Bar<{ a: (x: string) => void; b: (x: number) => void }>; // string & number 发现结果是 string & number,也就是逆变。但这个例子也是同一个 U 时而为 string 时而为 number 呀,为什么是且的关系,而不是或呢? 其实协变或逆变与 infer 参数位置有关。在 TypeScript 中,对象、类、数组和函数的返回值类型都是协变关系,而函数的参数类型是逆变关系,所以 infer 位置如果在函数参数上,就会遵循逆变原则。 逆变与协变: 协变(co-variant):类型收敛。 逆变(contra-variant):类型发散。 关于逆变与协变更深入的话题可以再开一篇文章了,这里就不细讲了,对于 infer 理解到这里就够啦。 总结infer 关键字让我们拥有深入展开泛型的结构,并 Pick 出其中任何位置的类型,并作为临时变量用于最终返回类型的能力。 对于 Typescript 类型编程,最大的问题莫过于希望实现一个效果却不知道用什么语法,infer 作为一个强大的类型推导关键字,势必会在大部分复杂类型推导场景下派上用场,所以在遇到困难时,可以想想是不是能用 infer 解决问题。 讨论地址是:精读《Typescript infer 关键字》· Issue ##346 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Typescript2","path":"/wiki/WebWeekly/前沿技术/《Typescript2.html","content":"当前期刊数: 58 1 引言精读原文是 typescript 2.0-2.9 的文档: 2.0-2.8,2.9 草案. 我发现,许多写了一年以上 Typescript 开发者,对 Typescript 对理解和使用水平都停留在入门阶段。造成这个现象的原因是,Typescript 知识的积累需要 刻意练习,使用 Typescript 的时间与对它的了解程度几乎没有关系。 这篇文章精选了 TS 在 2.0-2.9 版本中最重要的功能,并配合实际案例解读,帮助你快速跟上 TS 的更新节奏。 对于 TS 内部优化的用户无感部分并不会罗列出来,因为这些优化都可在日常使用过程中感受到。 2 精读由于 Typescript 在严格模式下的许多表现都与非严格模式不同,为了避免不必要的记忆,建议只记严格模式就好了! 严格模式导致的大量边界检测代码,已经有解了直接访问一个变量的属性时,如果这个变量是 undefined,不但属性访问不到,js 还会抛出异常,这几乎是业务开发中最高频的报错了(往往是后端数据异常导致的),而 typescript 的 strict 模式会检查这种情况,不允许不安全的代码出现。 在 2.0 版本,提供了 “非空断言标志符” !. 解决明确不会报错的情况,比如配置文件是静态的,那肯定不会抛出异常,但在 2.0 之前的版本,我们可能要这么调用对象: const config = { port: 8000};if (config) { console.log(config.port);} 有了 2.0 提供的 “非空断言标志符”,我们可以这么写了: console.log(config!.port); 在 2.8 版本,ts 支持了条件类型语法: type TypeName<T> = T extends string ? "string" 当 T 的类型是 string 时,TypeName 的表达式类型为 “string”。 这这时可以构造一个自动 “非空断言” 的类型,把代码简化为: console.log(config.port); 前提是框架先把 config 指定为这个特殊类型,这个特殊类型的定义如下: export type PowerPartial<T> = { [U in keyof T]?: T[U] extends object ? PowerPartial<T[U]> : T[U]}; 也就是 2.8 的条件类型允许我们在类型判断进行递归,把所有对象的 key 都包一层 “非空断言”! 此处灵感来自 egg-ts 总结 增加了 never object 类型当一个函数无法执行完,或者理解为中途中断时,TS 2.0 认为它是 never 类型。 比如 throw Error 或者 while(true) 都会导致函数返回值类型时 never。 和 null undefined 特性一样,never 等于是函数返回值中的 null 或 undefined。它们都是子类型,比如类型 number 自带了 null 与 undefined 这两个子类型,是因为任何有类型的值都有可能是空(也就是执行期间可能没有值)。 这里涉及到很重要的概念,就是预定义了类型不代表类型一定如预期,就好比函数运行时可能因为 throw Error 而中断。所以 ts 为了处理这种情况,将 null undefined 设定为了所有类型的子类型,而从 2.0 开始,函数的返回值类型又多了一种子类型 never。 TS 2.2 支持了 object 类型, 但许多时候我们总把 object 与 any 类型弄混淆,比如下面的代码: const persion: object = { age: 5};console.log(persion.age); // Error: Property 'age' does not exist on type 'object'. 这时候报错会出现,有时候闭个眼改成 any 就完事了。其实这时候只要把 object 删掉,换成 TS 的自动推导就搞定了。那么问题出在哪里? 首先 object 不是这么用的,它是 TS 2.3 版本中加入的,用来描述一种非基础类型,所以一般用在类型校验上,比如作为参数类型。如果参数类型是 object,那么允许任何对象数据传入,但不允许 3 "abc" 这种非对象类型: declare function create(o: object | null): void;create({ prop: 0 }); // 正确create(null); // 正确create(42); // 错误create("string"); // 错误create(false); // 错误create(undefined); // 错误 而一开始 const persion: object 这种用法,是将能精确推导的对象类型,扩大到了整体的,模糊的对象类型,TS 自然无法推断这个对象拥有哪些 key,因为对象类型仅表示它是一个对象类型,在将对象作为整体观察时是成立的,但是 object 类型是不承认任何具体的 key 的。 增加了修饰类型TS 在 2.0 版本支持了 readonly 修饰符,被它修饰的变量无法被修改。 在 TS 2.8 版本,又增加了 - 与 + 修饰修饰符,有点像副词作用于形容词。举个例子,readonly 就是 +readonly,我们也可以使用 -readonly 移除只读的特性;也可以通过 -?: 的方式移除可选类型,因此可以延伸出一种新类型:Required<T>,将对象所有可选修饰移除,自然就成为了必选类型: type Required<T> = { [P in keyof T]-?: T[P] }; 可以定义函数的 this 类型也是 TS 2.0 版本中,我们可以定制 this 的类型,这个在 vue 框架中尤为有用: function f(this: void) { // make sure `this` is unusable in this standalone function} this 类型是一种假参数,所以并不会影响函数真正参数数量与位置,只不过它定义在参数位置上,而且永远会插队在第一个。 引用、寻址支持通配符了简单来说,就是模块名可以用 * 表示任何单词了: declare module "*!text" { const content: string; export default content;} 它的类型可以辐射到: import fileContent from "./xyz.txt!text"; 这个特性很强大的一个点是用在拓展模块上,因为包括 tsconfig.json 的模块查找也支持通配符了!举个例子一下就懂: 最近比较火的 umi 框架,它有一个 locale 插件,只要安装了这个插件,就可以从 umi/locale 获取国际化内容: import { locale } from "umi/locale"; 其实它的实现是创建了一个文件,通过 webpack.alias 将引用指了过去。这个做法非常棒,那么如何为它加上类型支持呢?只要这么配置 tsconfig.json: { "compilerOptions": { "paths": { "umi/*": ["umi", "<somePath>"] } }} 将所有 umi/* 的类型都指向 <somePath>,那么 umi/locale 就会指向 <somePath>/locale.ts 这个文件,如果插件自动创建的文件名也恰好叫 locale.ts,那么类型就自动对应上了。 跳过仓库类型报错TS 在 2.x 支持了许多新 compileOptions,但 skipLibCheck 实在是太耀眼了,笔者必须单独提出来说。 skipLibCheck 这个属性不但可以忽略 npm 不规范带来的报错,还能最大限度的支持类型系统,可谓一举两得。 拿某 UI 库举例,某天发布的小版本 d.ts 文件出现一个漏洞,导致整个项目构建失败,你不再需要提 PR 催促作者修复了!skipLibCheck 可以忽略这种报错,同时还能保持类型的自动推导,也就是说这比 declare module "ui-lib" 将类型设置为 any 更强大。 对类型修饰的增强TS 2.1 版本可谓是针对类型操作革命性的版本,我们可以通过 keyof 拿到对象 key 的类型: interface Person { name: string; age: number;}type K1 = keyof Person; // "name" | "age" 基于 keyof,我们可以增强对象的类型: type NewObjType<T> = { [P in keyof T]: T[P] }; Tips:在 TS 2.8 版本,我们可以以表达式作为 keyof 的参数,比如 keyof (A & B)。Tips:在 TS 2.9 版本,keyof 可能返回非 string 类型的值,因此从一开始就不要认为 keyof 的返回类型一定是 string。 NewObjType 原封不动的将对象类型重新描述了一遍,这看上去没什么意义。但实际上我们有三处拓展的地方: 左边:比如可以通过 readonly 修饰,将对象的属性变成只读。 中间:比如将 : 改成 ?:,将对象所有属性变成可选。 右边:比如套一层 Promise<T[P]>,将对象每个 key 的 value 类型覆盖。 基于这些能力,我们拓展出一系列上层很有用的 interface: Readonly。把对象 key 全部设置为只读,或者利用 2.8 的条件类型语法,实现递归设置只读。 Partial。把对象的 key 都设置为可选。 Pick<T, K>。从对象类型 T 挑选一些属性 K,比如对象拥有 10 个 key,只需要将 K 设置为 "name" | "age" 就可以生成仅支持这两个 key 的新对象类型。 Extract<T, U>。是 Pick 的底层 API,直到 2.8 版本才内置进来,可以认为 Pick 是挑选对象的某些 key,Extract 是挑选 key 中的 key。 Record<K, U>。将对象某些属性转换成另一个类型。比较常见用在回调场景,回调函数返回的类型会覆盖对象每一个 key 的类型,此时类型系统需要 Record 接口才能完成推导。 Exclude<T, U>。将 T 中的 U 类型排除,和 Extract 功能相反。 Omit<T, K>(未内置)。从对象 T 中排除 key 是 K 的属性。可以利用内置类型方便推导出来:type Omit<T, K> = Pick<T, Exclude<keyof T, K>> NonNullable。排除 T 的 null 与 undefined 的可能性。 ReturnType。获取函数 T 返回值的类型,这个类型意义很大。 InstanceType。获取一个构造函数类型的实例类型。 以上类型都内置在 lib.d.ts 中,不需要定义就可直接使用,可以认为是 Typescript 的 utils 工具库。 单独拿 ReturnType 举个例子,体现出其重要性: Redux 的 Connect 第一个参数是 mapStateToProps,这些 Props 会自动与 React Props 聚合,我们可以利用 ReturnType<typeof currentMapStateToProps> 拿到当前 Connect 注入给 Props 的类型,就可以打通 Connect 与 React 组件的类型系统了。 对 Generators 和 async/await 的类型定义TS 2.3 版本做了许多对 Generators 的增强,但实际上我们早已用 async/await 替代了它,所以 TS 对 Generators 的增强可以忽略。需要注意的一块是对 for..of 语法的异步迭代支持: async function f() { for await (const x of fn1()) { console.log(x); }} 这可以对每一步进行异步迭代。注意对比下面的写法: async function f() { for (const x of await fn2()) { console.log(x); }} 对于 fn1,它的返回值是可迭代的对象,并且每个 item 类型都是 Promise 或者 Generator。对于 fn2,它自身是个异步函数,返回值是可迭代的,而且每个 item 都不是异步的。举个例子: function fn1() { return [Promise.resolve(1), Promise.resolve(2)];}function fn2() { return [1, 2];} 在这里顺带一提,对 Array.map 的每一项进行异步等待的方法: await Promise.all( arr.map(async item => { return await item.run(); })); 如果为了执行顺序,可以换成 for..of 的语法,因为数组类型是一种可迭代类型。 泛型默认参数了解这个之前,先介绍一下 TS 2.0 之前就支持的函数类型重载。 首先 JS 是不支持方法重载的,Java 是支持的,而 TS 类型系统一定程度在对标 Java,当然要支持这个功能。好在 JS 有一些偏方实现伪方法重载,典型的是 redux 的 createStore: export default function createStore(reducer, preloadedState, enhancer) { if (typeof preloadedState === "function" && typeof enhancer === "undefined") { enhancer = preloadedState; preloadedState = undefined; }} 既然 JS 有办法支持方法重载,那 TS 补充了函数类型重载,两者结合就等于 Java 方法重载: declare function createStore( reducer: Reducer, preloadedState: PreloadedState, enhancer: Enhancer);declare function createStore(reducer: Reducer, enhancer: Enhancer); 可以清晰的看到,createStore 想表现的是对参数个数的重载,如果定义了函数类型重载,TS 会根据函数类型自动判断对应的是哪个定义。 而在 TS 2.3 版本支持了泛型默认参数,可以减少某些场景函数类型重载的代码量,比如对于下面的代码: declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;declare function create<T extends HTMLElement, U extends HTMLElement>( element: T, children: U[]): Container<T, U[]>; 通过枚举表达了泛型默认值,以及 U 与 T 之间可能存在的关系,这些都可以用泛型默认参数解决: declare function create<T extends HTMLElement = HTMLDivElement, U = T[]>( element?: T, children?: U): Container<T, U>; 尤其在 React 使用过程中,如果用泛型默认值定义了 Component: .. Component<Props = {}, State = {}> .. 就可以实现以下等价的效果: class Component extends React.PureComponent<any, any> { //...}// 等价于class Component extends React.PureComponent { //...} 动态 ImportTS 从 2.4 版本开始支持了动态 Import,同时 Webpack4.0 也支持了这个语法(在 精读《webpack4.0%20 升级指南》 有详细介绍),这个语法就正式可以用于生产环境了: const zipUtil = await import("./utils/create-zip-file"); 准确的说,动态 Import 实现于 webpack 2.1.0-beta.28,最终在 TS 2.4 版本获得了语法支持。 在 TS 2.9 版本开始,支持了 import() 类型定义: const zipUtil: typeof import('./utils/create-zip-file') = await import('./utils/create-zip-file') 也就是 typeof 可以作用于 import() 语法,而不真正引入 js 内容。不过要注意的是,这个 import('./utils/create-zip-file') 路径需要可被推导,比如要存在这个 npm 模块、相对路径、或者在 tsconfig.json 定义了 paths。 好在 import 语法本身限制了路径必须是字面量,使得自动推导的成功率非常高,只要是正确的代码几乎一定可以推导出来。好吧,所以这也从另一个角度推荐大家放弃 require。 Enum 类型支持字符串从 Typescript 2.4 开始,支持了枚举类型使用字符串做为 value: enum Colors { Red = "RED", Green = "GREEN", Blue = "BLUE"} 笔者在这提醒一句,这个功能在纯前端代码内可能没有用。因为在 TS 中所有 enum 的地方都建议使用 enum 接收,下面给出例子: // 正确{ type: monaco.languages.types.Folder;}// 错误{ type: 75;} 不仅是可读性,enum 对应的数字可能会改变,直接写 75 的做法存在风险。 但如果前后端存在交互,前端是不可能发送 enum 对象的,必须要转化成数字,这时使用字符串作为 value 会更安全: enum types { Folder = "FOLDER"}fetch(`/api?type=${monaco.languages.types.Folder}`); 数组类型可以明确长度最典型的是 chart 图,经常是这样的二维数组数据类型: [[1, 5.5], [2, 3.7], [3, 2.0], [4, 5.9], [5, 3.9]] 一般我们会这么描述其数据结构: const data: number[][] = [[1, 5.5], [2, 3.7], [3, 2.0], [4, 5.9], [5, 3.9]]; 在 TS 2.7 版本中,我们可以更精确的描述每一项的类型与数组总长度: interface ChartData extends Array<number> { 0: number; 1: number; length: 2;} 自动类型推导自动类型推导有两种,分别是 typeof: function foo(x: string | number) { if (typeof x === "string") { return x; // string } return x; // number} 和 instanceof: function f1(x: B | C | D) { if (x instanceof B) { x; // B } else if (x instanceof C) { x; // C } else { x; // D }} 在 TS 2.7 版本中,新增了 in 的推导: interface A { a: number;}interface B { b: string;}function foo(x: A | B) { if ("a" in x) { return x.a; } return x.b;} 这个解决了 object 类型的自动推导问题,因为 object 既无法用 keyof 也无法用 instanceof 判定类型,因此找到对象的特征吧,再也不要用 as 了: // Badfunction foo(x: A | B) { // I know it's A, but i can't describe it. (x as A).keyofA;}// Goodfunction foo(x: A | B) { // I know it's A, because it has property `keyofA` if ("keyofA" in x) { x.keyofA; }} 4 总结Typescript 2.0-2.9 文档整体读下来,可以看出还是有较强连贯性的。但我们可能并不习惯一步步学习新语法,因为新语法需要时间消化、同时要连接到以往语法的上下文才能更好理解,所以本文从功能角度,而非版本角度梳理了 TS 的新特性,比较符合学习习惯。 另一个感悟是,我们也许要用追月刊漫画的思维去学习新语言,特别是 TS 这种正在发展中,并且迭代速度很快的语言。 5 更多讨论 讨论地址是:精读《Typescript2.0 - 2.9》 · Issue ##85 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《V8 引擎 Lazy Parsing》","path":"/wiki/WebWeekly/前沿技术/《V8 引擎 Lazy Parsing》.html","content":"当前期刊数: 100 1. 引言本周精读的文章是 V8 引擎 Lazy Parsing,看看 V8 引擎为了优化性能,做了怎样的尝试吧! 这篇文章介绍的优化技术叫 preparser,是通过跳过不必要函数编译的方式优化性能。 2. 概述 & 精读解析 Js 发生在网页运行的关键路径上,因此加速对 JS 的解析,就可以加速网页运行效率。 然而并不是所有 Js 都需要在初始化时就被执行,因此也不需要在初始化时就解析所有的 Js!因为编译 Js 会带来三个成本问题: 编译不必要的代码会占用 CPU 资源。 在 GC 前会占用不必要的内存空间。 编译后的代码会缓存在磁盘,占用磁盘空间。 因此所有主流浏览器都实现了 Lazy Parsing(延迟解析),它会将不必要的函数进行预解析,也就是只解析出外部函数需要的内容,而全量解析在调用这个函数时才发生。 预解析的挑战本来预解析也不难,因为只要判断一个函数是否会立即执行就可以了,只有立即执行的函数才需要被完全解析。 使得预解析变复杂的是变量分配问题。原文通过了堆栈调用的例子说明原因: Js 代码的执行在堆栈上完成,比如下面这个函数: function f(a, b) { const c = a + b; return c;}function g() { return f(1, 2); // The return instruction pointer of `f` now points here // (because when `f` `return`s, it returns here).} 这段函数的调用堆栈如下: 首先是全局 This globalThis,然后执行到函数 f,再对 a b 进行赋值。在执行 f 函数时,通过 <rip g>(return instruction pointer) 保存 g 堆栈状态,再保存堆栈跳出后返回位置的指针 <save fp>(frame pointer),最后对变量 c 赋值。 这看上去没有问题,只要将值存在堆栈就搞定了。但是将变量定义到函数内部就不一样了: function make_f(d) { // ← declaration of `d` return function inner(a, b) { const c = a + b + d; // ← reference to `d` return c; };}const f = make_f(10);function g() { return f(1, 2);} 将变量 d 申明在函数 make_f 中,且在返回函数 inner 中用到了 d。那么函数的调用栈就变成了这样: 需要创建一个 context 存储函数 f 中变量 d 的值。 也就是说,如果一个在函数内部定义的变量被子 Scope 使用时,Js 引擎需要识别这种情况,并将这个变量值存储在 context 中。 所以对于函数定义的每一个入参,我们需要知道其是否会被子函数引用。也就是说,在 preparser 阶段,我们只要少能分析出哪些变量被内部函数引用了。 难以分辨的引用预处理器中跟踪变量的申明与引用很复杂,因为 Js 的语法导致了无法从部分表达式推断含义,比如下面的函数: function f(d) { function g() { const a = ({ d } 我们不清楚第三行的 d 到底是不是指代第一行的 d。它可能是: function f(d) { function g() { const a = ({ d } = { d: 42 }); return a; } return g;} 也可能只是一个自定义函数参数,与上面的 d 无关: function f(d) { function g() { const a = ({ d }) => d; return a; } return [d, g];} 惰性 parse在执行函数时,只会将最外层执行的函数完全编译并生成 AST,而对内部模块只进行 preparser。 // This is the top-level scope.function outer() { // preparsed function inner() { // preparsed }}outer(); // Fully parses and compiles `outer`, but not `inner`. 为了允许惰性编译函数,上下文指针指向了 ScopeInfo 的对象(从代码中可以看到,ScopeInfo 包含上下文信息,比如当前上下文是否有函数名,是否在一个函数内等等),当编译内部函数时,可以利用 ScopeInfo 继续编译子函数。 但是为了判断惰性编译函数自身是否需要一个上下文,我们需要再次解析内部的函数:比如我们需要知道某个子函数是否对外层函数定义的变量有所引用。 这样就会产生递归遍历: 由于代码总会包含一些嵌套,而编译工具更会产生 IIFE(立即调用函数) 这种多层嵌套的表达式,使得递归性能比较差。 而下面有一种办法可以将时间复杂度简化为线性:将变量分配的位置序列化为一个密集的数组,当惰性解析函数时,变量会按照原先的顺序重新创建,这样就不需要因为子函数可能引用外层定义变量的原因,对所有子函数进行递归惰性解析了。 按照这种方式优化后的时间复杂度是线性的: 针对模块化打包的优化由于现代代码几乎都是模块化编写的,构建起在打包时会将模块化代码封装在 IIFE(立即调用的闭包)中,以保证模拟模块化环境运行。比如 (function(){....})()。 这些代码看似在函数中应该惰性编译,但其实这些模块化代码从一开始就要被编译,否则反而会影响性能,因此 V8 有两种机制识别这些可能被立即调用的函数: 如果函数是带括号的,比如 (function(){...}),就假设它会被立即调用。 从 V8 v5.7 / Chrome 57 开始,还会识别 uglifyJS 的 !function(){...}(), function(){...}(), function(){...}() 这种模式。 然而在浏览器引擎解析环境比较复杂,很难对函数进行完整字符串匹配,因此只能对函数头进行简单判断。所以对于下面这种匿名函数的行为,浏览器是不识别的: // pre-parserfunction run(func) { func()}run(function(){}) // 在这执行它,进行 full parser 上面的代码看上去没毛病,但由于浏览器只检测被括号括住的函数,因此这个函数不被认为是立即执行函数,因此在后续执行时会被重复 full-parse。 也有一些代码辅助转换工具帮助 V8 正确识别,比如 optimize-js,会将代码做如下转换。 转换前: !function (){}()function runIt(fun){ fun() }runIt(function (){}) 转换后: !(function (){})()function runIt(fun){ fun() }runIt((function (){})) 然而在 V8 v7.5+ 已经很大程度解决了这个问题,因此现在其实不需要使用 optimize-js 这种库了~ 4. 总结JS 解析引擎在性能优化做了不少工作,但同时也要应对代码编译器产生的特殊 IIFE 闭包,防止对这种立即执行闭包进行重复 parser。 最后,不要试图总是将函数用括号括起来,因为这样会导致惰性编译的特性无法启用。 讨论地址是:精读《V8 引擎 Lazy Parsing》 · Issue ##148 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《V8 引擎特性带来的的 JS 性能变化》","path":"/wiki/WebWeekly/前沿技术/《V8 引擎特性带来的的 JS 性能变化》.html","content":"当前期刊数: 22 本期精读的文章是:V8 引擎特性带来的的 JS 性能变化 1 引言 定时刷新一下对 js 的三观,防止经验变成坑。(对 IE 等浏览器的三观需保持不变) 2 内容概要try catch 对性能的影响忽略不计try catch 非常有用,特别在 node 端更是一个常规操作。前端框架也越来越多采用了异常捕获的方式,结合 async await 让代码组织得更加优雅,详细可以看我的这篇博文 统一异常捕获。react mixins 也喜欢 try 住 render 方法,包括 16 版本自动 try 住了所有 render,try catch 可谓无处不在。 node 8 版本之后 try 内部函数性能损耗可以忽略不计。 但是当前版本仍然存在安全隐患,将 这里的代码 拷贝到 chrome 控制台,当前页面会进入无限死循环。 此例子对 try catch 块做了大量循环,官方说法是在某些代码组合情况下陷入无限优化循环。 解决 delete 性能问题js 正在变得越来越简单,该 delete 的地方也不会犹豫是否写成 undefined,以提升性能为代价降低代码可读性了。 arguments 转数组性能已不是问题在 node8.3 版本及以上,该使用拓展运算符获取参数,不但没有性能问题,可读性也大大提高,结合 ts 时也能得到类型支持。 bind 对性能影响可以忽略但是在 react 中副作用仍需警惕。由于 ui 组件复用次数在大部分场景及其有限,强烈推荐使用箭头函数书写成员函数(在我的另一篇精读 This 带来的困惑 有详细介绍),而且在 node8 中,箭头函数的性能是最好的。 函数调用对性能影响越来越小对函数调用优化的越来越好,不需要过于担心注释与空白、函数间调用对性能的影响. 32 64 位数字计算性能node8 对超长数字计算性能还是较低,大概是 32 位数字性能的 2/3,所以尽量用字符串处理大数。 遍历 object基本用法有 for in Object.keys Object.values. 在 node8 中,for in 将变得更慢,但任然比其他两种方法快,所以,尽早取消不必要的优化。 创建对象创建对象速度在 node8 得到极大提升,似乎是面向对象编程的福音。 多态函数的性能问题当函数或者对象存在多种类型参数时,在 node8 中性能没什么优化,但单态函数性能大幅提升。所以尽量让对象内部属性单态是比较有用的,比如尽量不要对字符串数组 push 一个数字。 3 精读try catch 的问题在 v8 优化之前,前端 try catch 存在挺大的性能问题,导致许多老旧的项目很少有使用异常的场景,而经验丰富的程序员也会极力避免使用 try catch,在必须使用 try catch 的地方,将代码逻辑封装在函数中,try 住函数而不是代码块,以降低性能损失。 现在是推翻这些经验的时候了,合理的异常处理还能够优化用户体验。 前端代码最容易出错的逻辑在于对后端数据的处理,一旦后端数据出错,前端整条数据处理链路难免报错或者抛出异常。这种场景最适合将异常 try 住,显示提示文案,同时也避免代码内部对数据格式过多的兼容处理。 语句数量对性能的影响由于语句数量对性能影响已经忽略不计了,以前推崇的写法可以说再见了: // 提倡var i = 1;var j = "hello";var arr = [1,2,3];var now = new Date(); // 避免var i = 1, j = "hello", arr = [1,2,3], now = new Date(); 4 总结这波 v8 优化带来了一些 js 性能上的改变,但在 js 性能优化中只解决了很小一块问题,而 js 在前端性能优化又只是冰山一角,dom 与 样式 的优化对性能影响也非常重大,我们仍然应该重视代码质量,提高代码性能。 讨论地址是:精读《V8 引擎特性带来的的 JS 性能变化》 · Issue ##33 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Vue3","path":"/wiki/WebWeekly/前沿技术/《Vue3.html","content":"当前期刊数: 109 1. 引言Vue 3.0 的发布引起了轩然大波,让我们解读下它的 function api RFC 详细了解一下 Vue 团队是怎么想的吧! 首先官方回答了几个最受关注的问题: Vue 3.0 是否有 break change,就像 Python 3 / Angular 2 一样? 不,100% 兼容 Vue 2.0,且暂未打算废弃任何 API(未来也不)。之前有草案试图这么做,但由于用户反馈太猛,被撤回了。 Vue 3.0 的设计盖棺定论了吗? 没有呀,这次精读的稿子就是 RFC(Request For Comments),翻译成中文就是 “意见征求稿”,还在征求大家意见中哦。 这 RFC 咋这么复杂? RFC 是写给贡献者/维护者的,要考虑许多边界情况与细节,所以当然会复杂很多喽!当然 Vue 本身使用起来还是很简单的。 Vue 本身 Mutable + Template 就注定了是个用起来简单(约定 + 自然),实现起来复杂(解析 + 双绑)的框架。 这次改动很像在模仿 React,为啥不直接用 React? 首先 Template 机制还是没变,其次模仿的是 Hooks 而不是 React 全部,如果你不喜欢这个改动,那你更不会喜欢用 React。 PS: 问这个问题的人,一定没有同时理解 React 与 Vue,其实这两个框架到现在差别蛮大的,后面精读会详细说明。 下面正式进入 Vue 3.0 Function API 的介绍。 2. 概述Vue 函数式基本 Demo: <template> <div> <span>count is {{ count }}</span> <span>plusOne is {{ plusOne }}</span> <button @click="increment">count++</button> </div></template><script>import { value, computed, watch, onMounted } from 'vue'export default { setup() { // reactive state const count = value(0) // computed state const plusOne = computed(() => count.value + 1) // method const increment = () => { count.value++ } // watch watch(() => count.value * 2, val => { console.log(`count * 2 is ${val}`) }) // lifecycle onMounted(() => { console.log(`mounted`) }) // expose bindings on render context return { count, plusOne, increment } }}</script> 函数式风格的入口是 setup 函数,采用了函数式风格后可以享受如下好处:类型自动推导、减少打包体积。 setup 函数返回值就是注入到页面模版的变量。我们也可以返回一个函数,通过使用 value 这个 API 产生属性并修改: import { value } from 'vue'const MyComponent = { setup(props) { const msg = value('hello') const appendName = () => { msg.value = `hello ${props.name}` } return { msg, appendName } }, template: `<div @click="appendName">{{ msg }}</div>`} 要注意的是,value() 返回的是一个对象,通过 .value 才能访问到其真实值。 为何 value() 返回的是 Wrappers 而非具体值呢?原因是 Vue 采用双向绑定,只有对象形式访问值才能保证访问到的是最终值,这一点类似 React 的 useRef() API 的 .current 规则。 那既然所有 value() 返回的值都是 Wrapper,那直接给模版使用时要不要调用 .value 呢?答案是否定的,直接使用即可,模版会自动 Unwrapping: const MyComponent = { setup() { return { count: value(0) } }, template: `<button @click="count++">{{ count }}</button>`} 接下来是 Hooks,下面是一个使用 Hooks 实现获得鼠标实时位置的例子: function useMouse() { const x = value(0) const y = value(0) const update = e => { x.value = e.pageX y.value = e.pageY } onMounted(() => { window.addEventListener('mousemove', update) }) onUnmounted(() => { window.removeEventListener('mousemove', update) }) return { x, y }}// in consuming componentconst Component = { setup() { const { x, y } = useMouse() const { z } = useOtherLogic() return { x, y, z } }, template: `<div>{{ x }} {{ y }} {{ z }}</div>`} 可以看到,useMouse 将所有与 “处理鼠标位置” 相关的逻辑都封装了进去,乍一看与 React Hooks 很像,但是有两个区别: useMouse 函数内改变 x、y 后,不会重新触发 setup 执行。 x y 拿到的都是 Wrapper 而不是原始值,且这个值会动态变化。 另一个重要 API 就是 **watch**,它的作用类似 React Hooks 的 useEffect,但实现原理和调用时机其实完全不一样。 watch 的目的是监听某些变量变化后执行逻辑,比如当 id 变化后重新取数: const MyComponent = { props: { id: Number }, setup(props) { const data = value(null) watch(() => props.id, async (id) => { data.value = await fetchData(id) }) }} 之所以要 watch,因为在 Vue 中,setup 函数仅执行一次,所以不像 React Function Component,每次组件 props 变化都会重新执行,因此无论是在变量、props 变化时如果想做一些事情,都需要包裹在 watch 中。 后面还有 unwatching、生命周期函数、依赖注入,都是一些语法定义,感兴趣可以继续阅读原文,笔者就不赘述了。 3. 精读对于 Vue 3.0 的 Function API + Hooks 与 React Function Component + Hooks,笔者做一些对比。 Vue 与 React 逻辑结构React Function Component 与 Hooks,虽然在实现原理上,与 Vue3.0 存在 Immutable 与 Mutable、JSX 与 Template 的区别,但逻辑理解上有着相通之处。 const MyComponent = { setup(props) { const x = value(0) const setXRandom = () => { x.value = Math.random() } return { x, setXRandom } }, template: ` <button @onClick="setXRandom"/>{{x}}</button> `} 虽然在 Vue 中,setup 函数仅执行一次,看上去与 React 函数完全不一样(React 函数每次都执行),但其实 Vue 将渲染层(Template)与数据层(setup)分开了,而 React 合在了一起。 我们可以利用 React Hooks 将数据层与渲染层完全隔离: // 类似 vue 的 setup 函数function useMyComponentSetup(props) { const [x, setX] = useState(0) const setXRandom = useCallback(() => { setX(Math.random()) }, [setX]) return { x, setXRandom }}// 类似 vue 的 template 函数function MyComponent(props: { name: String }) { const { x, setXRandom } = useMyComponentSetup(props) return ( <button onClick={setXRandom}>{x}</button> )} 这源于 JSX 与 Template 的根本区别。JSX 使模版与 JS 可以写在一起,因此数据层与渲染层可以耦合在一起写(也可以拆分),但 Vue 采取的 Template 思路使数据层强制分离了,这也使代码分层更清晰了。 而实际上 Vue3.0 的 setup 函数也是可选的,再配合其支持的 TSX 功能,与 React 真的只有 Mutable 的区别了: // 这是个 Vue 组件const MyComponent = createComponent((props: { msg: string }) => { return () => h('div', props.msg)}) 我们很难评价 Template 与 JSX 的好坏,但为了更透彻的理解 Vue 与 React,需要抛开 JSX&Template,Mutable&Immutable 去看,其实去掉这两个框架无关的技术选型,React@16 与 Vue@3 已经非常像了。 Vue3.0 的精髓是学习了 React Hooks 概念,因此正好可以用 Hooks 在 React 中模拟 Vue 的 setup 函数。 关于这两套技术选型,已经是相对完美的组合,不建议在 JSX 中再实现类似 Mutable + JSX 的花样来(因为喜欢 Mutable 可以用 Vue 呀): Vue:Mutable + Template React:Immutable + JSX 真正影响编码习惯的就是 Mutable 与 Immutable,使用 Vue 就坚定使用 Mutable,使用 React 就坚定使用 Immutable,这样能最大程度发挥两套框架的价值。 Vue Hooks 与 React Hooks 的差异先看 React Hooks 的简单语法: const [ count, setCount ] = useState(0)const setToOne = () => setCount(1) Vue Hooks 的简单语法: const count = value(0)const setToOne = () => count.value = 1 之所以 React 返回的 count 是一个数字,是因为 Immutable 规则,而 Vue 返回的 count 是个对象,拥有 count.value 属性,也是因为 Vue Mutable 规则导致,这使得 Vue 定义的所有变量都类似 React 中 useRef 定义变量,因此不存 React capture value 的特性。 关于 capture value 更多信息,可以阅读 精读《Function VS Class 组件》 Capute Value 介绍 另外,对于 Hooks 的值变更机制也不同,我们看 Vue 的代码: const Component = { setup() { const { x, y } = useMouse() const { z } = useOtherLogic() return { x, y, z } }, template: `<div>{{ x }} {{ y }} {{ z }}</div>`} 由于 setup 函数仅执行一次,怎么做到当 useMouse 导致 x、y 值变化时,可以在 setup 中拿到最新的值? 在 React 中,useMouse 如果修改了 x 的值,那么使用 useMouse 的函数就会被重新执行,以此拿到最新的 x,而在 Vue 中,将 Hooks 与 Mutable 深度结合,通过包装 x.value,使得当 x 变更时,引用保持不变,仅值发生了变化。所以 Vue 利用 Proxy 监听机制,可以做到 setup 函数不重新执行,但 Template 重新渲染的效果。 这就是 Mutable 的好处,Vue Hooks 中,不需要 useMemo useCallback useRef 等机制,仅需一个 value 函数,直观的 Mutable 修改,就可以实现 React 中一套 Immutable 性能优化后的效果,这个是 Mutable 的魅力所在。 Vue Hooks 的优势笔者对 RFC 中对 Vue、React Hooks 的对比做一个延展解释: 首先最大的不同:setup 仅执行一遍,而 React Function Component 每次渲染都会执行。 Vue 的代码使用更符合 JS 直觉。 这句话直截了当戳中了 JS 软肋,JS 并非是针对 Immutable 设计的语言,所以 Mutable 写法非常自然,而 Immutable 的写法就比较别扭。 当 Hooks 要更新值时,Vue 只要用等于号赋值即可,而 React Hooks 需要调用赋值函数,当对象类型复杂时,还需借助第三方库才能保证进行了正确的 Immutable 更新。 对 Hooks 使用顺序无要求,而且可以放在条件语句里。 对 React Hooks 而言,调用必须放在最前面,而且不能被包含在条件语句里,这是因为 React Hooks 采用下标方式寻找状态,一旦位置不对或者 Hooks 放在了条件中,就无法正确找到对应位置的值。 而 Vue Function API 中的 Hooks 可以放在任意位置、任意命名、被条件语句任意包裹的,因为其并不会触发 setup 的更新,只在需要的时候更新自己的引用值即可,而 Template 的重渲染则完全继承 Vue 2.0 的依赖收集机制,它不管值来自哪里,只要用到的值变了,就可以重新渲染了。 不会再每次渲染重复调用,减少 GC 压力。 这确实是 React Hooks 的一个问题,所有 Hooks 都在渲染闭包中执行,每次重渲染都有一定性能压力,而且频繁的渲染会带来许多闭包,虽然可以依赖 GC 机制回收,但会给 GC 带来不小的压力。 而 Vue Hooks 只有一个引用,所以存储的内容就非常精简,也就是占用内存小,而且当值变化时,也不会重新触发 setup 的执行,所以确实不会造成 GC 压力。 必须要总包裹 useCallback 函数确保不让子元素频繁重渲染。 React Hooks 有一个问题,就是完全依赖 Immutable 属性。而在 Function Component 内部创建函数时,每次都会创建一个全新的对象,这个对象如果传给子组件,必然导致子组件无法做性能优化。 因此 React 采取了 useCallback 作为优化方案: const fn = useCallback(() => /* .. */, []) 只有当第二个依赖参数变化时才返回新引用。但第二个依赖参数需要 lint 工具确保依赖总是正确的(关于为何要对依赖诚实,感兴趣可以移步 精读《Function Component 入门》 - 永远对依赖诚实)。 回到 Vue 3.0,由于 setup 仅执行一次,因此函数本身只会创建一次,不存在多实例问题,不需要 useCallback 的概念,更不需要使用 lint 插件 保证依赖书写正确,这对开发者是实实在在的友好。 不需要使用 useEffect useMemo 等进行性能优化,所有性能优化都是自动的。 这也是实在话,毕竟 Mutable + 依赖自动收集就可以做到最小粒度的精确更新,根本不会触发不必要的 Rerender,因此 useMemo 这个概念也不需要了。 而 useEffect 也需要传递第二个参数 “依赖项”,在 Vue 中根本不需要传递 “依赖项”,所以也不会存在用户不小心传错的问题,更不需要像 React 写一个 lint 插件保证依赖的正确性。(这也是笔者想对 React Hooks 吐槽的点,React 团队如何保障每个人都安装了 lint?就算装了 lint,如果 IDE 有 BUG,导致没有生效,随时可能写出依赖不正确的 “危险代码”,造成比如死循环等严重后果) 4. 总结通过对比 Vue Hooks 与 React Hooks 可以发现,Vue 3.0 将 Mutable 特性完美与 Hooks 结合,规避了一些 React Hooks 的硬伤。所以我们可以说 Vue 借鉴了 React Hooks 的思想,但创造出来的确实一个更精美的艺术品。 但 React Hooks 遵循的 Immutable 也有好的一面,就是每次渲染中状态被稳定的固化下来了,不用担心状态突然变更带来的影响(其实反而要注意状态用不变更带来的影响),对于数据记录、程序运行的稳定性都有较高的可预期性。 最后,对于喜欢 Mutable 的开发者,Vue 3.0 是你的最佳选择,基于 React + Mutable 搞的一些小轮子做到顶级可能还不如 Vue 3.0。对于 React 开发者来说,坚持你们的 Immutable 信仰吧,Vue 3.0 已经将 Mutable 发挥到极致,只有将 React Immutable 特性发挥到极致才能发挥 React 的最大价值。 讨论地址是:精读《Vue3.0 Function API》 · Issue ##173 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Web Components 的困境》","path":"/wiki/WebWeekly/前沿技术/《Web Components 的困境》.html","content":"当前期刊数: 10 本期精读的文章是:The broken promise of Web Components 以及对这篇文章的回应: Regarding the broken promise of Web Components 1 引言我为什么要选这篇文章呢? 就在前几天的 Google I/O 2017 上, Polymer 正式发布了 Polymer 2.0 版本. 来看一下 Polymer 2.0 的一些变化: 使用 Shadow DOM v1 代替 Polymer.dom. Shady DOM 从 Polymer 中分离出来。 使用 标准的 ES6 类和 Custom Elements v1 来自定义元素. 还有数据系统的改进和生命周期的变更. 可以看到, Polymer 的这次升级主要是将 Shadow Dom 和 Custom Elements 升级到 v1 版本, 以获得更多浏览器的原生支持. 下一 代 Web Components - v1 规范,Chrome 已经支持了,Web Components 规范中的 2 个主要部分 - Shadow Dom 和 Custom Elements. Safari 在 10 版本中, 支持了 Shadow DOM v1 规范并且完成了在 Webkit 内核中对 Custom Elements v1 规范的实现;Firefox 对 Shadow DOM 和 Custom Elements v1 规范 支持正在开发中;Edge 也将对 Shadow DOM 和 Custom Elements 支持规划到他们的开发 roadmap 中。 这段时间, 大家都在讨论 react, vue, angular, 这些框架. 或者 该使用 redux 还 是 mobx 做数据管理. 在这个契机下, 我想我们可以不单单去思考这些框架, 也可以更多地去思考和了解 Web Components 标准. 对于 Web Components 标准有一些思考. 所以我选了一篇关于 Web Components 的文章, 想让大家对于 Web Components 的发展, 和 Web Componets 与现在的主流框架如何协作有更多的思考和讨论. 2 内容概要The broken promise of Web Components原文作者 dmitriid 主要是在喷 Web Components 从 2011 年到 2017 年这 6 年间毫无进展, 一共产出了 6 份标准, 其中两份已经被弃用. 几乎只有一个主流浏览器(chrome) 支持. Web Components 这些规范强依赖 JS 的实现 Custom Elements 是 JS 脚本的一部分 HTML Templates 的出现就是为了被 JS 脚本使用 Shadow Dom 也需要配合 JS 脚本使用 只有 HTML imports 可以脱离 JS 脚本使用 Web Components 操作 DOM 属性都是字符串 元素的内容模型(Content Model)比较奇怪 为了突破限制使用不同的方法来传递数据 CSS 作用域, 可以见上次精读《请停止 css-in-js 的行为》 来看一下 Polymer 的 核心成员 Rob Dodson 对于本文的回应: Regarding the broken promise of Web Components Web Components 特性需要被浏览器支持,必须有平缓的过渡,良好的兼容,以及成熟的方案,因此推进速度会比较慢一些。 React 很棒, 但是也不要忽略其他基于 Web Components 的优秀库比如 Amp 对于 DOM 更新的抽象比如 React/JSX 很赞, 但是也带来了一些损耗. 在旧的移动设备上, 加载一个大的 js 包性能依旧不理想, 最佳的做法是拆分你的 JS 包, 按需加载. 使用 JSX 和 虚拟 DOM 是很酷, 也可以直接把 JSX 用在 Web Components 内, 像SkateJS库, 已经在做这个事情了. 没有标准的数据绑定, Polymer 的数据绑定, 现在是基于MDV, 很多开发者更倾向于基于 Observables 或者 ES6 Proxies 的数据绑定方案. 处理组件的字符串属性是很烦人, 但是由于每一个组件都是一个类的实例, 可以利用 ES6 的 getters/setters 来改变属性. Rob Dodson 对于 Web Components 依然充满信心, 但是也承认推进标准总会有各种阻碍, 不可能像推荐框架一样快速把事情解决. 3 精读本次提出独到观点的同学有:@camsong @黄子毅 @杨森 @rccoder @alcat2008精读由此归纳。 标准与框架Web Components 作为一个标准,骨子里的进度就会落后于当前可行的技术体系。正如文中所说,浏览器厂商 ship 一个新功能是很严肃的,很可能会影响到一票的线上业务,甚至会影响到一个产业(遥想当年 Chrome Extension 禁用 NPAPI时的一片哀鸿遍野,许多返利插件都使用了这种技术)。那么 Web Components 的缓慢推进也在情理之中了.即使真的有一天这个标准建立起来,Web Components 作为浏览器底层特性不应该拿出来和 React 这类应用层框架相比较. 未来 Web Components 会做为浏览器非常重要的特性存在。API 偏低层操作,会易用性不够. 在很长时间内开发者依旧会使用 React/Vue/Angular/Polymer 这样的框架,Web Components 可能会做为这些框架的底层做一些 浏览器层面上的支持. 不需要 vendor 的自定义组件间调用在 Webpack 大行其道的时代,想在运行时做到组件即引即用变得很困难,因为这些组件大多是通过 React/Vue/Angular 开发的。不得不考虑引入一大堆 Vendor 包,这些 Vendor 里可能还必须包含 React 这类两个版本不能同时使用的库。目前我们团队在做组件化方案时就遇到这个问题,只能想办法避免两个版本的出现。你可以说这是 React 或 Webpack 引入的问题,但并没有看到 Web Compnents 标准化的解决方案。我想未来 Web Components 可能会作为浏览器的底层, 出现基于底层的标准方案来做组件间的相互应用的方法. 为什么对 Web components 讨论不断俗话说,成也萧何,败也萧何。正如原文提及的,现在网页规模越来越大,需求也越来越灵活,html 与 css 的能力已经严重不足,我们才孤注一掷的上了 js 的贼船:JSX 和 Css module,因为 Web components 依托在 html 模版语言上,当然没办法与 js 的灵活性媲美。 但使用前端框架的问题也日益暴露,随着前端框架种类的增多,同一个框架不同版本之间无法共存,导致组件无法跨框架复用,甚至只能固定在框架的某个版本,这与前端未来的模块化发展是相违背的,我们越是与之抗衡,就越希望 Web components 能站出来解决这个问题,因为浏览器原生支持模块化,相当于将 react angular vue 的能力内置在浏览器中,而且一定会向前兼容(这也是 Web components 推进缓慢的原因)。 4 总结我觉得 Web Components 作为浏览器底层特性不应该拿出来和 React, vue 这类应用层框架相比较. Web Components 的方向以及提供的价值都不会跟 应用框架一致. 而 Web Components 作为未来的 Web 组件标准 , 它在任何生态中都可以运行良好. 我倒是更加期待应用层去基于 Web Components 去做更多的实现, 让组件超越框架存在, 可以在不同技术栈中使用. 讨论地址是:精读《Web Components 的困境》 · Issue ##15 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《Web fonts_ when you need them, when you don’t》","path":"/wiki/WebWeekly/前沿技术/《Web fonts_ when you need them, when you don’t》.html","content":"当前期刊数: 21 精读《Web fonts: when you need them, when you don’t》本期精读让我们来聊一聊 Web Fonts,文章地址:https://hackernoon.com/web-fonts-when-you-need-them-when-you-dont-a3b4b39fe0ae 文章简介文章分析了 Web Fonts 的优劣具体使用场景。 主要观点 作者用一张流程图非常言简意赅地概括了文章的上半部分。 当然上半部分作者也讲了很多案例,其中一个很明显的案例就是维基百科利用字体来提升阅读体验,通过文章内的对比,能直观感受到这一点 文章后半部分着力介绍了怎么解决 Web Font 的带来的弊端:认识 FOUT 带来的问题,如何使用现有的前端解决方案来尽可能避免这个问题,以及样式上优雅降级的几个方案。 把文章带入自己的开发环境作为一个中文开发者,在我们的开发技术栈中,Web Fonts 绝对是属于使用频率比较低的那一类的。本次精读选择这篇文章,也正是一探这一个不常见的领域。 对于英文字母,26 个字母可以解决大部分的问题,算上大小写和基本符号,一张 ASCII 码标就可以包含住。让我再扩展一下,到大部分的西文书写系统,几百个字符就能解决多语言显示的问题了。但是对于汉语而言,Web Fonts 真的是,想说爱你不容易,因为常用的汉字就有几千个(你想象中国还有《千字文》这种儿童读物……)。字体这东西跟字符数量直接挂钩,是很难通过压缩来获得性能提升的。 通常的想法就是用多少,取多少,但是这个方法也就只能适用于标题美化等场景。对于一个系统性的前端工程,我们不可能去实现一个动态字符的字体文件(就是统计这个页面上会产生多少个字符,为这个字符集去生成一个字符子集) 虽然汉字书写系统和西文书写系统天生存在差异,但是把作者在文章中提出来的几个问题再站在中文的角度上再来看一下,也可以得到一个比较客观的答案。以下是我作为一个普通开发者的自问自答: 字体对你的品牌很关键吗?(需要特性字体的中文 LOGO 基本都用 PNG 和 SVG 解决了,Web Font 不实用。) 字体让你的文字阅读起来更容易了吗?(我平时开发产品没有成片聚集的文字,用无衬线字体就能满足需求。Web Font 很好,我选择“微软雅黑”。)(注:泛指那些好用的支持全字集的系统自带字体;成片的文字适用衬线字体,个人认为中文的衬线字体,不同的字体带来的阅读体验还是有明显差别的。) 你需要在不同设备上显示一样的字体吗?(好像还没这么苛求吧……微软雅黑好看,安卓上的 Roboto 也很不错啊,Roboto 这种字体还针对移动设备有优化,何乐而不为。) 用了 Web Font 你会更开心吗?(在 icon 中使用 iconfont 让我们告别了 PNG Sprite 图,嗯这很开心。至于文字上用 Web Font,有好用的系统字体你不用,你这是何苦呢)(作者也说了,可能折腾半天还没系统字体看着舒服,那就是一行 font-family 的事情) 不同产品有着不同的场景,多像文章里问问自己会有最合适的答案。 关于 FOUT 和 FOIT文章中大篇幅地在安利你使用 Web Font,但是也很直白地指出了 Web Font 最大的问题,就是这个 FOUT——Flash Of Unstyled Text。连作者毫不避讳地说了句:“噢我的老天,这太丑了!” 具体表现是采用了 Web Font 的文案会存在闪动,这个的根本原因在于相比于系统字体,Web Font 最大的弊端在于它是异步加载的,你没有办法避免下载它所用的时间。文章中举了一个例子,在一个图文为主的页面中,一个 542KB 的字体文件,在第 9 秒才加载完成。在那之前只能以系统字体来展示,而在第 9 秒加载完成的时候,还会出现替换字体的情况,文字会突然跳动。 比 FOUT 更为极端的情况的是 FOIT——Flash Of Invisible Text。很多浏览器的行为,并不是默认展示系统字体,而是直接隐藏。那么即使在极快的网速下也很难避免存在一个几百毫秒的时间滞后。 不过好在,有一个 font-display 的属性,可以在声明@font-face 的时候配合使用。对于未加载 Web Fonts 的时候,auto 属性可以选择隐藏也就是会产生 FOIT,swap 会产生替换也就是会产生 FOUT,还有 fallback 和 optional 可以控制先 FOIT 后 FOUT 来达到折中方案。 还有一个思路,那就是预加载,对于字体,浏览器还是能够有效缓存的,如果能够做好预加载,还是不会太影响用户体验的。文章中就提到了一个方案,调用 link 的 rel=preload 来做预加载。因为通常加载字体是在 CSS 中的@font-face 被读到的时候才去加载的,那么就会出现先加载 CSS,后加载字体的情况。如果利用 link 预加载,那么在 CSS 中的@font-face 被读到前就已经开始加载了,那么字体加载和 CSS 加载就可以同时加载,提升速度。 当然 JS 是万能的,也有一些库在支持这方面功能,例如 bramstein/fontfaceobserver 这样的。 愚以为,FO*T 这种情况既然无法避免还是要具体情况具体分析的。如果你的用户网速够快,那么隐藏文字会更好,用户无感知;如果网速不确定,而且是文章为主的内容,那么内容至上就应该先用替代字体显示;如果你正在将 Web Font 应用在图标等东西上,那么我们自然不愿意看到满屏的方框方框,这种时候就选择隐藏吧。 文章也提了一点,如果你的字体授权很贵,但用户端深受 FO*T 折磨,那你还费这钱干嘛。 结论如果能解决 FO*T 的副作用,Web Font 怎么舒服怎么用。但是中文字体大,常用西文字体诸如 Google 字体库又时常被墙,对于中国开发者,Web Font 想说爱你不容易。(还是乖乖用微软雅黑吧,逃…… 彩蛋文章里有一段很精彩的话,摘抄出来翻译一下: 如果这个世界上有这样一个 Sketch 或者 Photoshop 的插件,可以在你每次打开一个文件的时候,延迟十秒才显示出字体;那么世界上就没有那么多多余的字体了。 (译者注:删光设计师的电脑里的奇怪字体也能达到相同目的,哈哈哈~)"},{"title":"《Tableau 探索式模型》","path":"/wiki/WebWeekly/前沿技术/《Tableau 探索式模型》.html","content":"当前期刊数: 117 1. 引言Tableau 探索式分析功能非常强大,各种功能组合似乎有着无限的可能性。 今天笔者会分析这种探索式模型解题思路,一起看看这种探索式分析功能是如何做到的。 2. 精读要掌握探索式分析,先要掌握探索式分析背后的思维模型。 理解数据有分析意义的数据一般是表结构,即分为行与列,列定义了数据含义,行则构成了数据明细。 当我们将数据作为 “原材料” 使用时,需要将这些明细数据封装为 “数据集” 的概念来理解,数据集概念中,数据就是一个个字段,对于字段,要理解 “维度” 与 “度量” 这两个概念。 维度维度是不能被计数的字段,一般为字符串或离散的值,用来描述数据的维度。 度量度量是可以被计数的字段,一般为数字、日期等连续的值,用来描述数据的量。 我们首先要将数据集字段归类到维度与度量,才能提高数据分析的效率。数据分析就是从不同维度下看度量值,先想清楚要看的是什么数据,比如销量还是利润?这些字段都属于度量,然后想一想要怎么看这些度量,是看总数、拆解到年看、还是按地区看呢?这些字段都属于维度。 维度和度量是可以单独看的,如果单看维度,那只能看这个维度的明细,比如看 订单日期 这个字段: 需要注意的时,维度与度量字段还可以分为 连续 与 离散 。 连续值是连续关系,即任意两个值之间可以计算差值。 离散值是离散关系,即任意两个值之间无法计算差值,无法以连续的方式去理解。 一般来说,维度字段都是离散的,度量字段都是连续的。从字段类型意义上也能得出相同的结论:维度字段一般为字符串或日期类型,字符串类型都是离散的,度量字段一般为数字类型,数字天生就可以连续。 值得注意的是,连续与离散其实与字段类型、维度度量并无关系,比如维度的日期字段就是可连续的,而就算是字符串类型,也可以以字符串长度等方式 “定义” 一种连续的计算方式。对数字类型的度量字段来说,我们也可以忽略数字之间的联系,将数字看待为字符串,这样数字之间就是离散的。 上图的 “离散方式看日期” 就是看维度的直观方式,但仍可以用 “连续方式看日期”: 离散方式下单看维度只有一条条数据,数据间并无排序规则,而以连续方式看维度,维度就会以某种方式排序:比如上图以时间类型进行排序。此时展示方式也从表格切换为了柱状图,因为表格适合展示离散数据,柱状图的一根柱子就可以展示连续数据。 单看度量时,由于 度量要依附于维度展示,因此仅有度量时,只能看这个度量的 聚合 概念: 如上图所示,单看销量这个度量字段时,我们只能将数据集中所有销量字段聚合在一起来看,但这种聚合方式也可以分成若干种计算类型 - 求和、平均值、中位数、计数、计数去重、最小值、最大值、方差等等: 这些能力之间都是 “正交” 的,即单看度量这一个字段,可以以这么多种类型进行计算,那么按维度拆分后,度量依然可以享受如上不同的计算方式。 也可以用连续方式看度量: 与连续-维度不同,连续-度量图形中除了最后一个值,其他过渡数值都是无效的,因为连续-度量只有一个值。连续-维度也要注意,由于以连续的方式画出图形,中间不存在的点也被 “无缝连接” 了。 数据之间也可以存在父子级关系,有父子级关系就可以进行上卷下钻了,这种父子级关系被称为 “层系字段”: 上图的 Orders 就是一个层系字段。层系字段是几个字段的排序组合,由上到下依次构成下钻关系,从下到上则是上卷的关系。 层系只有维度字段才能有层系,因为度量是不能被拆分的,只有维度才可以被拆分。 维度的拆分可以是有逻辑含义的,也可以是任意的。 有逻辑含义的层系 最典型有逻辑含义的层系字段就是时间了。一个好的 BI 系统识别到日期字段后,应该将拿到的日期字段进行归类,比如判断日期字段粒度到天,则自动生成一个日期层系字段,自动聚合到年,并允许用户随意切换: 如果数据集字段值精确到月,则层系只能最多展开到月。 日期层系的逻辑含义在于,年、季度、月、天这种下钻关系是天然从大到小的关系,符合自然理解。 任意层系 如果层系字段不代表日期,就只能以业务含义组合层系字段了。比如可以将层系按照 订单日期 -> 商品 ID -> 运货日期的方式组合: 这种下钻方式,可以看到每个订单日期下有哪些商品,每个商品分别运货日期是什么。 也可以按照商品 ID 拆分出不同的订单日期与运货日期,这种层系组合方式就是以商品 ID 为主要视角: 可以看到,不同思维角度会按照不同的方式组合层系。比如一家大公司要查看财务问题,维度有:BU、日期,度量有:销量。 那么有两种下钻方式:BU -> 日期、日期 -> BU。无论哪种下钻方式,都能看到每个 BU 按日期销量的明细,但 BU -> 日期 能看到每个 BU 按日期聚合的总销量,而 日期 -> BU 能看到不同日期按 BU 聚合的总销量,前者更易对比出 BU 之间差异,后者更易对比出日期之间的差异。 理解配置配置是探索式分析的入口,要理解分析模型首先得理解配置模型。 Table 主要配置分为行、列、标记与筛选。通过这四个配置区域可以组合成千变万化的数据洞察模型。既然如此,让我们看看这种配置思路是什么,以及为何这四种配置相互组合就能覆盖整个探索式分析场景? 我们不需要考虑三维数据分析场景,因为三维透视的关系,图形丢失了精确大小关系,没有精度的数据是没有分析价值的。由于在二位平面中分析数据,大部分图表都可以用 “行、列” 方式进行配置。 也许有人会问,为什么不用维度与度量替代行列呢?这是一个很好的问题,有数据分析经验的人会站在维度与度量角度思考问题,因此对于任意图表,只要配置维度、度量即可呀?笔者从三个方面说说自己的理解: 探索式分析思路中,不关心图表是什么,也不关心图表如何展示,因此图表是千变万化的,比如折线图可以横过来,条形图也可以变成柱状图,因此 你将维度放到列,就是一个柱状图,你将维度放到行,就是一个条形图 。 将精力真正放到你要拖拽的字段上。由于字段已经有维度、度量的区别,配置区域就不要再限定维度与度量了,减少理解成本。 维度与度量可以同时放在行或列上,这是探索式分析的另一个精髓能力,看下图: 做探索式分析功能时,要跳出思维定式:为什么条形图的纵轴不能放维度呢?如上图所示,如果行拖拽了两个不同的度量,那么可以出现两条线或者双轴图,但当拖拽一个维度一个度量时,可以对图表进行 分面 ,比如观察 2013 ~ 2016 年不同顾客对销量的贡献。 行表格类的行、图表类的纵轴。一般建议放置度量字段。 列表格类的列、图表类的横轴。一般建议放置维度字段。 如上所示,无论行还是列,都可以进行任意维度度量组合,且字段数量不限,而且可以在任何层级进行下钻。对图表来说,多个维度时需要进行分面处理: 如上图所示,将列放置两个维度字段成为柱状图,那么横轴就要同时表示两个维度,如上图所示。如果横轴还有更多的维度,可以再不断对横轴进行拆分。 横轴(列)多维度字段的顺序也会影响图表的展现。上图最后一个字段是 Category 默认是离散的,所以这个离值就决定了图表使用柱状图,图表类型由维度周最后一个字段连续或离散决定。 比如我们对调 Order Date 与 Category 会怎样? 我们得到了三个不同类目近 12 个月的趋势,之所以是折线图,因为图表的维度轴(列)是连续的。如果我们对 Order Date 进行天级别的下钻: 可以看到,下钻功能本质上就是维度轴支持对多个维度字段拆分处理。只要图表支持了维度轴任意维度字段的分面展示,那么配置端就可以将下钻按照拖了多个字段的方式去理解了。 如果我们将折线图切换为表格,会发生什么? 我们会发现,原本存在于列的 Category 被自动挪到了行,原本存在于行的 Sales 被挪到了 “标记” 区域。在正式介绍 “标记” 区域前,先理解一下为何会发生这种转变: 表格类组件是双维度组件,折线图是单维度组件。 也就是表格的行与列都是维度,而折线图横轴作为维度后,纵轴就要作为度量。上面的例子中,折线图维度有两个字段,虽然通过分面方式渲染出来了,但当切换为支持双维度的表格后, 可以将多余的一个维度挪到表格组件另一个维度区域中。 而表格行与列都是维度的情况下,单元格的值就需要用 “标记” 中文本来表示,因此原折线图的度量字段自动转移到了 “标记” 区域。 标记标记区域也采取字段拖拽的方式,即对字段进行标记。 标记区域分为 颜色、大小、标签、详细信息、工具提示、路径。标记正如其名,是作用于图表上的标记,即不会对图表框架有实质性影响的辅助标记信息。 对不同图表来说,影响最大的是行与列,它能决定用什么图表,如何拆分数据。而标记往往是改变图表中辅助性元素,比如文字或者颜色等等。 工具提示不影响任何图像显示,仅仅在提示信息中新增字段信息。 对图表来说,指的是 Tooltip 提示信息增加对应的字段: 从上图可以看到,利润字段放在工具提示区域,则图表的 Tooltip 会新增利润这个字段的信息。值得关注的是,Tableau 所有图表都支持 Tooltip 包括表格: 这保证了配置统一,行为统一。 大小控制图表大小。 对于线图,控制线的粗细;对于气泡图控制气泡大小;对于柱状图控制柱子粗细;但是对面积图与表格没有明显作用。这得益于 Tableau 将每个图表大小属性尽可能抽象出来。 文本即直接展示在图表上的文本。 对普通图表来说,文本体现为 Label,即直接展示在图表上的文字。比如柱状图默认是没有 Label 文字的,要将对应字段拖拽到文本标记上才会出现。 这体现出与普通报表构思的不同。对普通报表来说,Label 是通过一个勾选项开启的,Label 对应的值就是图表度量这个字段的值。而 Tableau 将标签值以字段方式开放拖拽,就有了展示与值分开的可能性,可适用范围更广。 有人觉得长度和数字一定要对应上,这也是对数据理解不同导致的。Tableau 将文本(标签)列在标记里,说明文本和颜色、大小一样,都是一种附加的信息展示维度,很多时候不需要两种方式展示同一种信息,反而需要图形以更多方式以不同维度展示信息。 颜色控制图表的颜色。 比如在度量为销量时,可以将利润作为颜色,甚至再将折扣作为文本,通过一个折线图同时看多种度量信息: 与之对比,我们可以将利润放在右 Y 轴作为双轴图达到相同的效果: 标记就是为了在不增加行、列字段数量基础上,通过颜色、大小、标签、工具提示等维度展示出额外信息。 详细信息如果将度量拖拽到详细信息,会发现完全没有作用。因为 “详细信息” 只有拖拽维度字段才生效。“详细信息” 其实是用作下钻的,拖拽一个维度字段后,可以按照这个维度进行下钻。 如上图所示,将销售按照产品线拆解成三条线。但这三条线无法分辨,因此可以使用颜色来拆分维度: 这样就能将拆解的内容按不同颜色展示。因此, 对标记作用的字段如果是维度字段,且作用于颜色、大小、标签、详细信息时,会额外进行维度进行拆解,并对拆解后的内容进行颜色或大小区分。 相信读到这里会有个疑问:按照维度进行拆解与维度拖拽多个字段进行字段有什么区别?我们试一下看看效果,将产品类目维度拖拽到销量所在的行,对销量进行销量维度的拆分: 可以看到,在行、列进行的多维度拆分使用的是分面策略,而在标记中对维度进行拆分使用的是单图表多轴方式来实现。 除此之外的区别在于,在标记进行的维度拆分默认作用于度量,而行列上的多维度拆分可以任意作用于维度或度量。 同时配置端要限制 能拆分的只有维度或离散状态的度量 ,也就是只有离散状态的字段可以被拆分。如上图所示,我们不能将 Category 拖拽到 Sales 右侧,除非将 Sales 设置为离散类型。Tips:Tables 对维度与度量分别分配了蓝色、绿色,当我们将绿色度量字段设置为离散类型时,这个度量字段会变成蓝色,也就是当作了维度字段进行处理。 最后,标记区域不仅能拖拽字段,还可以单击后修改详细配置,比如修改颜色详细配置: 或者对工具提示的 Tooltip 内容进行定制: 筛选器Tableau 将所有筛选条件都收敛到筛选器中,我们可以通过拖拽字段的方式对某个字段进行筛选: 如上图所示,比如只看办公用品与科技产品。但其实除了这个通用功能之外,Tableau 还支持更强大的图表交互功能,即点击或圈选图表后,可以对选中的点(字段值)进行保留或排除: 当我们选择排除这几个点时,会自动生成一份对维度字段的筛选条件排除掉选中日期,所以图表是完全数据驱动的: 一般来说 如果属性存在下钻关系会如何呢?无论是行列中对维度的下钻,还是通过标记对维度进行了拆解,筛选都是对 字段层系 生效的: 如上图所示,对下钻后的字段进行筛选,那么筛选条件也会自动构造出临时的字段层系,并对这个临时层系进行筛选。 可以看到,我们不仅能在字段配置区动态组成层系字段,在筛选器中也可以生成临时层系进行筛选,我们需要支持任意层系组合的字段,并作用于筛选器、行列,甚至是标记上。 顺带一提,我们还可以对设置了筛选的字段层系组合拖拽到任意地方使用: 要处理这种场景,我们需要让所有字段都拥有筛选能力,普通字段等于没有筛选条件,我们也可以对一个包含了筛选条件的字段拖拽到任何位置作用。 刚才是对维度进行的筛选,有没有对度量进行筛选的场景呢?有,但我们只能手动将度量字段拖拽到筛选器位置进行手动筛选: 如果我们进行图表内的圈选操作,增加的筛选条件一定是按维度来的: 这么理解这一行为:维度是离散的,勾选操作能表达的含义有限,比如勾选折线图的某些点,如何知道我们要勾选的是维度的那几个月,还是度量的利润范围呢? 由于最终勾选操作落地在点上,而不是区间上(连续值也不适合进行圈选),所以默认按对维度进行筛选是最准确的理解。如果上图的操作意图中,你想勾选的不是 6~12 月的区间,而是销量在 13k ~ 45.5k,则需要手动拖拽利润字段,并精确输入筛选范围: 值得注意的是,对连续型度量进行筛选前,还可选择聚合方式:比如对求和的值进行范围筛选,或者对最大值进行范围筛选,功能十分强大。 理解图表图表是数据可视化的载体,只有数据与配置,没有各式各样的图表,很难产生直观的数据洞察。 可以说, 按照探索式分析的思路,当配置好数据与配置后,可以有多种可视化载体去展示这种配置信息。 比如行、列分别拖拽了日期与销量,那么折线图、表格、散点图、柱状图都可以满足需求,但如果行所在的字段是离散的,那么折线图、散点图就不适合了,这就需要图表推荐功能根据配置推荐合适的图形展示。 Tableau 内置的图表分为 N 大类 - 表格、地图、柱折面饼、散点/象限图 、以及直方图、盒须图、甘特图、靶心图等。可见分析数据,不需要太多种类可视化展现方式,但对于每个图表组件来说,都需要修炼深厚的内功,做好一个表格、折线图并不简单。 行与列表格、地图、柱折面饼、散点/象限图等都可以用行与列描述基本架构: 表格天然拥有行与列,对调后则代表转置。表格的行与列必须是维度字段,如果拖拽度量字段上去会自动切换为其他图表,再切回来则会把度量字段挪动到 “文本” 标记区域中。 地图行与列就是经纬度,当维度字段放到 “详细信息” 时,根据地理映射表转化为经纬度自动生成经纬度放在行与列。 柱折面饼、散点/象限图都是直角坐标系的图形,以维度字段作为维度轴,以度量字段作为度量轴。 行列的下钻在行或列存在多个维度字段时,图表要进行相应下钻。表格对于行下钻如下图所示: 上图也可以理解为展示出 Order Date 与 Order ID 的明细数据,按照 Order Date 分组且列合并。 下钻就是一步步接近明细数据的过程,但目的不是为了看明细表,而是看某些维度下按其他维度拆分的详细信息。 图表下钻和表格思路是一致的: 对于维度轴多维度下钻,将每个维度轴下钻到更细粒度。图表在行与列同时下钻时,与表格的表现稍有不同。仅从轴来看拆解方式是相同的,内部展示了多套轴: 可以认为,当行或列上最后一个字段为度量时,就会切换为图表展示,因为图表适合展示连续状态。 如果排除上图蓝色区域,剩下的区域就是个交叉表,交叉表只是行与列同时存在维度字段的场景,仅有行或列时就变成了普通表格;而图形的下钻和表格下钻机理相同,只是把 “单元格” 的文本换成了柱子或线。 所以对任何图表的下钻,都是对轴的下钻, 相同的是单元格属性永远不会改变,表格的单元格是文本,图形单元格是图形,一个简单折线图可以理解为对整体行与列单元格进行 “连续打通”: 如果继续对行列添加维度进行下钻,其实是对轴进行下钻。排除度量字段不看,就是一个交叉表的下钻过程,如下图所示蓝色框圈住的部分就是一组大的单元格: 由于最后一个字段是度量,因此在叶子结点的展开就不是表格模式的单元格,而是连续的线条了。 经过上面的总结,我们要意识到,在探索式分析场景对行列的下钻,表格与图表的逻辑是通用的,实现时也要整体考虑。将轴功能抽离成通用部分来做,表格与图表的区别只是对最后一个字段单元格是离散处理还是连续处理。 层系的下钻 层系字段下钻与拖多个字段表现一致,但由于存在父子关系,因此在图表上可以展现出 “展开” “收起” 按钮,点击后并不是对图表本身进行操作,而是发送一个事件对 “行” 进行操作,最后通过数据驱动完成展开或收起动作。 不适合行列的图表饼图就不适合行列,因为饼图是根据离散维度进行拆分,扇叶大小可以由一个度量字段决定,因此对饼图来说,行就对应到 “颜色”、列就对应到新增的 “角度” 这个标记: 没有维度轴的图表只有行配置的图形推荐用表格,但柱状图、折线图也可以支持这种情况,只要把横轴忽略即可: 从样式上来看没有横轴,其实这种情况是把所有维度的横轴都聚合后的表现。 连续与离散值我们分别看看连续与离散作用于维度和度量时的区别。 作用于度量图表要能适配对连续或离散值的处理。比如对销量来说,如果切换为离散值,则当成字符串展示: 如果将销量切换为连续值,则单元格就要使用线条长度代表值的大小,即连续性的值要能够产生 “对比感”: 上图组件是表格,本身适合展示离散值,但可以看到对连续值展示做了适配。对于适合展示连续值的图形,则无法做离散适配: 比如这个柱状图,如果将销量切换为离散,则会自动切换到表格,因为对于双离散值用柱折面饼展示是无意义的。 作用于维度如上图所示,就是维度使用了离散字段的例子,由于维度是离散的,因此使用柱状图展示,因为柱子间也是隔离的。 对于连续型字段作用于维度,默认适合散点图,因为散点图的行与列都是度量,适合作为默认推荐: 但能用散点图的就也能用线图, 当维度是连续日期字段时,适合用折线图而不是散点图。因为日期虽然连续,但 本身不适合做比较 ,因此作为一种连续型维度展示比较合适;而散点图两个轴都适合连续型度量,因此不适合方日期这种连续型维度字段。 当然也具备将折线图随时切换为散点图的能力,但这种图形没有什么业务价值: 因此我们对折线图进行标记:行适合连续型维度字段,对散点图进行标记:行列都适合连续型度量字段,就可以根据配置 实现推荐图表的功能。 标记除了饼图支持 “角度”、线图支持 “路径” 这些特殊标记外,所有图表都支持下面五种通用标记:“工具提示”、“大小”、“文本”、“颜色”、“详细信息”。 工具提示 比较简单,所有图表都支持鼠标 Hover 后弹出 Tooltip 即可,并且这个 Tooltip 允许自定义和拓展工具提示字段。 大小 则只有折、柱、散三种图支持,因为这三种图分别有可以描述的大小的线条粗细、柱子宽度、圆圈半径。 文本 对应柱折面饼的 Label、对应表格,矩形树状图,地图的 单元格内容。 颜色、详细信息 则比较特殊,下面详细说明: 拖拽已有字段到详细信息 - 没有任何效果: 因为本身就在看这个字段的详细信息,因此没有效果。 但如果拖拽已有字段到颜色,则可以根据数值大小或分类进行按颜色区分: 等于开启了图表筛选功能,当颜色筛选条件字段是连续型时,出现筛选滑块,是离散型时,出现图例: 如果拖拽字段不存在于行和列上,对于度量字段,会根据值进行颜色排序(度量拖拽到详细信息依然没有效果): 如上图所示,我们可以从长度看利润,从颜色深度看销量。 如果拖拽字段不存在于行和列上,且是维度字段,则会先进行维度拆分,之后如果选择的是 “颜色” 标记区域,还会对同一组的拆分标记颜色区分。 由于标记区域对维度的拆分是不分行于列的,因此每个图表会根据自身情况进行合适的拆分。 比如条形图如果按某个新维度拆分,则会采取 “堆积柱状图” 的策略: 如果是折线图,则会采取 “多条线” 的策略: 如果是散点图,只要将拆分后多出来的点打散出来即可。由于散点图的维度拆分不像折线图和柱状图可以分段,因此如果不采用按颜色打散,是无法分辨分组的: 之所以说探索式分析的复杂度很高,是因为其可能性公式为: 字段 x 离散连续 x 行列 x 行列下钻 x 标记种类 x 筛选 x 图表 这种组合的笛卡尔积几乎是无穷无尽的。 轴交互图表一些特定功能是隐藏在轴交互里的。拿折线图来说,一共有 5 个拖拽交互位置,如下图所示: 一般这些区域是用来拖拽度量字段的,所以如果拖拽了维度字段过来,最终会被归类到行列或标记上。 拖拽维度维度拖拽到底部 1 区域等于替换列字段 : 维度拖拽到图表中 4 区域等于拖到了颜色标记 : 维度拖拽到左侧 3 区域等于对行进行下钻: 同理拖拽到最上面区域等于对列进行下钻。 拖拽度量让我们看看拖拽度量时的情况。度量能拖拽的范围更多。比如拖拽到右轴 5 区域,则形成了双轴图: 拖拽到左侧 2 区域则表示在图中额外增加一个轴: 要注意的是,上图的行显示 “度量值”,这是个特殊的字段,并通过筛选器筛选出拖拽的两个字段 Profit 和 Sales。除了拖拽以外,还可以通过将左侧 “度量值” 字段直接拖入行实现: 如上图所示,将度量值放到行,并按度量名称进行颜色标记,就得到了拖拽度量到左侧 2 区域的效果。 这也说明了所有图表交互最终都是通过映射到配置完成,所有能拖拽的操作都可以通过配置配出来 。 对表格来说,能拖拽的区域是行、列、单元格: 拖拽到行或列于拖拽到字段配置区域的行或列没有区别,拖拽到单元格等于拖拽到文本标记区域。通过图表于配置区域结合的方式,即便不完全理解配置的人也可以通过将字段拖拽到图表上得到直观的操作感。 点击、圈选交互所有图表都支持点击、圈选的方式选中 “点”。对表格来说,点就是单元格: 对柱状图来说,点就是柱子: 对折线图来说,点就是节点: 对饼图来说,点就是扇叶: 所有的点被选中后都有基本高亮功能,最重要的是能对选中的点进行保留、排除、局部排序等等。 比如我们可以对上图饼图选中的几个扇形区域进行从小到大排序: 我们也可以排除某些点,这个在配置章节有提到过,这个操作最终将转化为新增筛选条件: 最后,选中状态在单图表中看似只有高亮效果,但是在多图表联动时,高亮的选中区域会组成一个临时的筛选条件,作用于所有相同数据集的图表,并对这些图表的筛选结果做高亮处理。 3. 总结理解了探索模型对数据、配置、图表的理解,就能学会探索式思维分析数据,对制作探索式 BI 也有借鉴意义。 讨论地址是:精读《Tableau 探索式模型》 · Issue ##199 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Hooks 取数 - swr 源码》","path":"/wiki/WebWeekly/源码解读/《Hooks 取数 - swr 源码》.html","content":"当前期刊数: 128 1 引言取数是前端业务的重要部分,也经历过几次演化: fetch 的兼容性已经足够好,足以替换包括 $.post 在内的各种取数封装。 原生用得久了,发现拓展性更好、支持 ssr 的同构取数方案也挺好,比如 isomorphic-fetch、axios。 对于数据驱动场景还是不够,数据流逐渐将取数封装起来,同时针对数据驱动状态变化管理进行了 data isLoading error 封装。 Hooks 的出现让组件更 Reactive,我们发现取数还是优雅回到了组件里,swr 就是一个教科书般的例子。 swr 在 2019.10.29 号提交,仅仅 12 天就攒了 4000+ star,平均一天收获 300+ star!本周精读就来剖析这个库的功能与源码,了解这个 React Hooks 的取数库的 Why How 与 What。 2 概述首先介绍 swr 的功能。 为了和官方文档有所区别,笔者以探索式思路介绍这个它,但例子都取自官方文档。 2.1 为什么用 Hooks 取数首先回答一个根本问题:为什么用 Hooks 替代 fetch 或数据流取数? 因为 Hooks 可以触达 UI 生命周期,取数本质上是 UI 展示或交互的一个环节。 用 Hooks 取数的形式如下: import useSWR from "swr";function Profile() { const { data, error } = useSWR("/api/user", fetcher); if (error) return <div>failed to load</div>; if (!data) return <div>loading...</div>; return <div>hello {data.name}!</div>;} 首先看到的是,以同步写法描述了异步逻辑,这是因为渲染被执行了两次。 useSWR 接收三个参数,第一个参数是取数 key,这个 key 会作为第二个参数 fetcher 的第一个参数传入,普通场景下为 URL,第三个参数是配置项。 Hooks 的威力还不仅如此,上面短短几行代码还自带如下特性: 可自动刷新。 组件被销毁再渲染时优先启用本地缓存。 在列表页中浏览器回退可以自动记忆滚动条位置。 tabs 切换时,被 focus 的 tab 会重新取数。 当然,自动刷新或重新取数也不一定是我们想要的,swr 允许自定义配置。 2.2 配置上面提到,useSWR 还有第三个参数作为配置项。 独立配置 通过第三个参数为每个 useSWR 独立配置: useSWR("/api/user", fetcher, { revalidateOnFocus: false }); 配置项可以参考 文档。 可以配置的有:suspense 模式、focus 重新取数、重新取数间隔/是否开启、失败是否重新取数、timeout、取数成功/失败/重试时的回调函数等等。 第二个参数如果是 object 类型,则效果为配置项,第二个 fetcher 只是为了方便才提供的,在 object 配置项里也可以配置 fetcher。 全局配置 SWRConfig 可以批量修改配置: import useSWR, { SWRConfig } from "swr";function Dashboard() { const { data: events } = useSWR("/api/events"); // ...}function App() { return ( <SWRConfig value={{ refreshInterval: 3000 }}> <Dashboard /> </SWRConfig> );} 独立配置优先级高于全局配置,在精读部分会介绍实现方式。 最重量级的配置项是 fetcher,它决定了取数方式。 2.3 自定义取数方式自定义取数逻辑其实分几种抽象粒度,比如自定义取数 url,或自定义整个取数函数,而 swr 采取了相对中间粒度的自定义 fetcher: import fetch from "unfetch";const fetcher = url => fetch(url).then(r => r.json());function App() { const { data } = useSWR("/api/data", fetcher); // ...} 所以 fetcher 本身就是一个拓展点,我们不仅能自定义取数函数,自定义业务处理逻辑,甚至可以自定义取数协议: import { request } from "graphql-request";const API = "https://api.graph.cool/simple/v1/movies";const fetcher = query => request(API, query);function App() { const { data, error } = useSWR( `{ Movie(title: "Inception") { releaseDate actors { name } } }`, fetcher ); // ...} 这里回应了第一个参数称为取数 Key 的原因,在 graphql 下它则是一段语法描述。 到这里,我们可以自定义取数函数,但却无法控制何时取数,因为 Hooks 写法使取数时机与渲染时机结合在一起。swr 的条件取数机制可以解决这个问题。 2.4 条件取数所谓条件取数,即 useSWR 第一个参数为 null 时则会终止取数,我们可以用三元运算符或函数作为第一个参数,使这个条件动态化: // conditionally fetchconst { data } = useSWR(shouldFetch ? "/api/data" : null, fetcher);// ...or return a falsy valueconst { data } = useSWR(() => (shouldFetch ? "/api/data" : null), fetcher); 上例中,当 shouldFetch 为 false 时则不会取数。 第一个取数参数推荐为回调函数,这样 swr 会 catch 住内部异常,比如: // ... or throw an error when user.id is not definedconst { data, error } = useSWR(() => "/api/data?uid=" + user.id, fetcher); 如果 user 对象不存在,user.id 的调用会失败,此时错误会被 catch 住并抛到 error 对象。 实际上,user.id 还是一种依赖取数场景,当 user.id 发生变化时需要重新取数。 2.5 依赖取数如果一个取数依赖另一个取数的结果,那么当第一个数据结束时才会触发新的取数,这在 swr 中不需要特别关心,只需按照依赖顺序书写 useSWR 即可: function MyProjects() { const { data: user } = useSWR("/api/user"); const { data: projects } = useSWR(() => "/api/projects?uid=" + user.id); if (!projects) return "loading..."; return "You have " + projects.length + " projects";} swr 会尽可能并行没有依赖的请求,并按依赖顺序一次发送有依赖关系的取数。 可以想象,如果手动管理取数,当依赖关系复杂时,为了确保取数的最大可并行,往往需要精心调整取数递归嵌套结构,而在 swr 的环境下只需顺序书写即可,这是很大的效率提升。优化方式在下面源码解读章节详细说明。 依赖取数是自动重新触发取数的一种场景,其实 swr 还支持手动触发重新取数。 2.6 手动触发取数trigger 可以通过 Key 手动触发取数: import useSWR, { trigger } from "swr";function App() { return ( <div> <Profile /> <button onClick={() => { // set the cookie as expired document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; // tell all SWRs with this key to revalidate trigger("/api/user"); }} > Logout </button> </div> );} 大部分场景不必如此,因为请求的重新触发由数据和依赖决定,但遇到取数的必要性不由取数参数决定,而是时机时,就需要用手动取数能力了。 2.7 乐观取数特别在表单场景时,数据的改动是可预期的,此时数据驱动方案只能等待后端返回结果,其实可以优化为本地先修改数据,等后端结果返回后再刷新一次: import useSWR, { mutate } from "swr";function Profile() { const { data } = useSWR("/api/user", fetcher); return ( <div> <h1>My name is {data.name}.</h1> <button onClick={async () => { const newName = data.name.toUpperCase(); // send a request to the API to update the data await requestUpdateUsername(newName); // update the local data immediately and revalidate (refetch) mutate("/api/user", { ...data, name: newName }); }} > Uppercase my name! </button> </div> );} 通过 mutate 可以在本地临时修改某个 Key 下返回结果,特别在网络环境差的情况下加快响应速度。乐观取数,表示对取数结果是乐观的、可预期的,所以才能在结果返回之前就预测并修改了结果。 2.8 Suspense 模式在 React Suspense 模式下,所有子模块都可以被懒加载,包括代码和请求都可以被等待,只要开启 suspense 属性即可: import { Suspense } from "react";import useSWR from "swr";function Profile() { const { data } = useSWR("/api/user", fetcher, { suspense: true }); return <div>hello, {data.name}</div>;}function App() { return ( <Suspense fallback={<div>loading...</div>}> <Profile /> </Suspense> );} 2.9 错误处理onErrorRetry 可以统一处理错误,包括在错误发生后重新取数等: useSWR(key, fetcher, { onErrorRetry: (error, key, option, revalidate, { retryCount }) => { if (retryCount >= 10) return; if (error.status === 404) return; // retry after 5 seconds setTimeout(() => revalidate({ retryCount: retryCount + 1 }), 5000); }}); 3 精读3.1 全局配置在 Hooks 场景下,包装一层自定义 Context 即可实现全局配置。 首先 SWRConfig 本质是一个定制 Context Provider: const SWRConfig = SWRConfigContext.Provider; 在 useSWR 中将当前配置与全局配置 Merge 即可,通过 useContext 拿到全局配置: config = Object.assign({}, defaultConfig, useContext(SWRConfigContext), config); 3.2 useSWR 的一些细节从源码可以看到更多细节用心,useSWR 真的比手动调用 fetch 好很多。 兼容性 useSWR 主体代码在 useEffect 中,但是为了将请求时机提前,放在了 UI 渲染前(useLayoutEffect),并兼容了服务端场景: const useIsomorphicLayoutEffect = IS_SERVER ? useEffect : useLayoutEffect; 非阻塞 请求时机在浏览器空闲时,因此请求函数被 requestIdleCallback 包裹: window["requestIdleCallback"](softRevalidate); softRevalidate 是开启了去重的 revalidate: const softRevalidate = () => revalidate({ dedupe: true }); 即默认 2s 内参数相同的重复取数会被取消。 性能优化 由于 swr 的 data、isValidating 等数据状态是利用 useState 分开管理的: let [data, setData] = useState( (shouldReadCache ? cacheGet(key) : undefined) || config.initialData);// ...let [isValidating, setIsValidating] = useState(false); 而取数状态变化时往往 data 与 isValidating 要一起更新,为了仅触发一次更新,使用了 unstable_batchedUpdates 将更新合并为一次: unstable_batchedUpdates(() => { setIsValidating(false); // ... setData(newData);}); 其实还有别的解法,比如使用 useReducer 管理数据也能达到相同性能效果。目前源码已经从unstable_batchedUpdates切换为 useReducer管理 dispatch(newState); 3.3 初始缓存当页面切换时,可以暂时以上一次数据替换取数结果,即初始化数据从缓存中拿: const shouldReadCache = config.suspense || !useHydration();// stale: get from cachelet [data, setData] = useState( (shouldReadCache ? cacheGet(key) : undefined) || config.initialData); 上面一段代码在 useSWR 的初始化期间,useHydration 表示是否为初次加载: let isHydration = true;export default function useHydration(): boolean { useEffect(() => { setTimeout(() => { isHydration = false; }, 1); }, []); return isHydration;} 3.4 支持 suspenseSuspense 分为两块功能:异步加载代码与异步加载数据,现在提到的是异步加载数据相关的能力。 Suspense 要求代码 suspended,即抛出一个可以被捕获的 Promise 异常,在这个 Promise 结束后再渲染组件。 核心代码就这一段,抛出取数的 Promise: throw CONCURRENT_PROMISES[key]; 等取数完毕后再返回 useSWR API 定义的结构: return { error: latestError, data: latestData, revalidate, isValidating}; 如果没有上面 throw 的一步,在取数完毕前组件就会被渲染出来,所以 throw 了请求的 Promise 使得这个请求函数支持了 Suspense。 3.5 依赖的请求翻了一下代码,没有找到对循环依赖特别处理的逻辑,后来看了官方文档才恍然大悟,原来是通过 try/catch 并巧妙结合 React 的 UI=f(data) 机制实现依赖取数的。 看下面这段代码: const { data: user } = useSWR("/api/user");const { data: projects } = useSWR(() => "/api/projects?uid=" + user.id); 怎么做到智能按依赖顺序请求呢?我们看 useSWR 取数函数的主体逻辑: const revalidate = useCallback( async() => { try { // 设置 isValidation 为 true // 取数、onSuccess 回调 // 设置 isValidation 为 false // 设置缓存 // unstable_batchedUpdates } catch (err) { // 撤销取数、缓存等对象 // 调用 onError回调 } }, [key])useIsomorphicLayoutEffect( ()=>{ .... }, [key,revalidate,...]) 每次渲染的时候,SWR 会试着执行 key 函数(例如 () => “/api/projects?uid=” + user.id),如果这个函数抛出异常,那么就意味着它的依赖还没有就绪(user === undefined),SWR 将暂停这个数据的请求。在任一数据完成加载时,由于 setState 触发重渲染,上述 Hooks 会被重选执行一遍(再次检查数据依赖是否就绪)然后对就绪的数据发起新的一轮请求。 另外对于一些正常请求碰到 error(shouldRetryOnError 默认为 true)的情况下,下次取数的时机是: const count = Math.min(opts.retryCount || 0, 8);const timeout = ~~((Math.random() + 0.5) * (1 << count)) * config.errorRetryInterval; 重试时间基本按 2 的指数速度增长。 所以 swr 会优先按照并行方式取数,存在依赖的取数会重试,直到上游 Ready。这种简单的模式稍稍损失了一些性能(没有在上游 Ready 后及时重试下游),但不失为一种巧妙的解法,而且最大化并行也使得大部分场景性能反而比手写的好。 4 总结笔者给仔细阅读本文的同学留下两道思考题: 关于 Hooks 取数还是在数据流中取数,你怎么看呢? swr 解决依赖取数的方法还有更好的改进办法吗? 讨论地址是:精读《Hooks 取数 - swr 源码》 · Issue ##216 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Immer","path":"/wiki/WebWeekly/源码解读/《Immer.html","content":"当前期刊数: 48 本周精读的仓库是 immer。 1 引言Immer 是最近火起来的一个项目,由 Mobx 作者 Mweststrate 研发。 了解 mobx 的同学可能会发现,Immer 就是更底层的 Mobx,它将 Mobx 特性发扬光大,得以结合到任何数据流框架,使用起来非常优雅。 2 概述麻烦的 ImmutableImmer 想解决的问题,是利用元编程简化 Immutable 使用的复杂度。举个例子,我们写一个纯函数: const addProducts = products => { const cloneProducts = products.slice() cloneProducts.push({ text: "shoes" }) return cloneProducts} 虽然代码并不复杂,但写起来内心仍隐隐作痛。我们必须将 products 拷贝一份,再调用 push 函数修改新的 cloneProducts,再返回它。 如果 js 原生支持 Immutable,就可以直接使用 push 了!对,Immer 让 js 现在就支持: const addProducts = produce(products => { products.push({ text: "shoes" })}) 很有趣吧,这两个 addProducts 函数功能一模一样,而且都是纯函数。 别扭的 setState我们都知道,react 框架中,setState 支持函数式写法: this.setState(state => ({ ...state, isShow: true})) 配合解构语法,写起来仍是如此优雅。那数据稍微复杂些呢?我们就要默默忍受 “糟糕的 Immutable” 了: this.setState(state => { const cloneProducts = state.products.slice() cloneProducts.push({ text: "shoes" }) return { ...state, cloneProducts }}) 然而有了 Immer,一切都不一样了: this.setState(produce(state => (state.isShow = true)))this.setState(produce(state => state.products.push({ text: "shoes" }))) 方便的柯里化上面讲述了 Immer 支持柯里化带来的好处。所以我们也可以直接把两个参数一次性消费: const oldObj = { value: 1 }const newObj = produce(oldObj, draft => (draft.value = 2)) 这就是 Immer:Create the next immutable state by mutating the current one. 3 精读虽然笔者之前在这方面已经有所研究,比如做出了 Mutable 转 Immutable 的库:dob-redux,但 Immer 实在是太惊艳了,Immer 是更底层的拼图,它可以插入到任何数据流框架作为功能增强,不得不赞叹 Mweststrate 真的是非常高瞻远瞩。 所以笔者认真阅读了它的源代码,带大家从原理角度认识 Immer。 Immer 是一个支持柯里化,仅支持同步计算的工具,所以非常适合作为 redux 的 reducer 使用。 Immer 也支持直接 return value,这个功能比较简单,所以本篇会跳过所有对 return value 的处理。PS: mutable 与 return 不能同时返回不同对象,否则弄不清楚到哪种修改是有效的。 柯里化这里不做拓展介绍,详情查看 curry。我们看 produce 函数 callback 部分: produce(obj, draft => { draft.count++}) obj 是个普通对象,那黑魔法一定出现在 draft 对象上,Immer 给 draft 对象的所有属性做了监听。 所以整体思路就有了:draft 是 obj 的代理,对 draft mutable 的修改都会流入到自定义 setter 函数,它并不修改原始对象的值,而是递归父级不断浅拷贝,最终返回新的顶层对象,作为 produce 函数的返回值。 生成代理第一步,也就是将 obj 转为 draft 这一步,为了提高 Immutable 运行效率,我们需要一些额外信息,因此将 obj 封装成一个包含额外信息的代理对象: { modified, // 是否被修改过 finalized, // 是否已经完成(所有 setter 执行完,并且已经生成了 copy) parent, // 父级对象 base, // 原始对象(也就是 obj) copy, // base(也就是 obj)的浅拷贝,使用 Object.assign(Object.create(null), obj) 实现 proxies, // 存储每个 propertyKey 的代理对象,采用懒初始化策略} 在这个代理对象上,绑定了自定义的 getter setter,然后直接将其扔给 produce 执行。 getterproduce 回调函数中包含了用户的 mutable 代码。所以现在入口变成了 getter 与 setter。 getter 主要用来懒初始化代理对象,也就是当代理对象子属性被访问的时候,才会生成其代理对象。 这么说比较抽象,举个例子,下面是原始 obj: { a: {}, b: {}, c: {}} 那么初始情况下,draft 是 obj 的代理,所以访问 draft.a draft.b draft.c 时,都能触发 getter setter,进入自定义处理逻辑。可是对 draft.a.x 就无法监听了,因为代理只能监听一层。 代理懒初始化就是要解决这个问题,当访问到 draft.a 时,自定义 getter 已经悄悄生成了新的针对 draft.a 对象的代理 draftA,因此 draft.a.x 相当于访问了 draftA.x,所以能递归监听一个对象的所有属性。 同时,如果代码中只访问了 draft.a,那么只会在内存生成 draftA 代理,b c 属性因为没有访问,因此不需要浪费资源生成代理 draftB draftC。 当然 Immer 做了一些性能优化,以及在对象被修改过(modified)获取其 copy 对象,为了保证 base 是不可变的,这里不做展开。 setter当对 draft 修改时,会对 base 也就是原始值进行浅拷贝,保存到 copy 属性,同时将 modified 属性设置为 true。这样就完成了最重要的 Immutable 过程,而且浅拷贝并不是很消耗性能,加上是按需浅拷贝,因此 Immer 的性能还可以。 同时为了保证整条链路的对象都是新对象,会根据 parent 属性递归父级,不断浅拷贝,直到这个叶子结点到根结点整条链路对象都换新为止。 完成了 modified 对象再有属性被修改时,会将这个新值保存在 copy 对象上。 生成 Immutable 对象当执行完 produce 后,用户的所有修改已经完成(所以 Immer 没有支持异步),如果 modified 属性为 false,说明用户根本没有改这个对象,那直接返回原始 base 属性即可。 如果 modified 属性为 true,说明对象发生了修改,返回 copy 属性即可。但是 setter 过程是递归的,draft 的子对象也是 draft(包含了 base copy modified 等额外属性的代理),我们必须一层层递归,拿到真正的值。 所以在这个阶段,所有 draft 的 finalized 都是 false,copy 内部可能还存在大量 draft 属性,因此递归 base 与 copy 的子属性,如果相同,就直接返回;如果不同,递归一次整个过程(从这小节第一行开始)。 最后返回的对象是由 base 的一些属性(没有修改的部分)和 copy 的一些属性(修改的部分)最终拼接而成的。最后使用 freeze 冻结 copy 属性,将 finalized 属性设置为 true。 至此,返回值生成完毕,我们将最终值保存在 copy 属性上,并将其冻结,返回了 Immutable 的值。 Immer 因此完成了不可思议的操作:Create the next immutable state by mutating the current one。 源码读到这里,发现 Immer 其实可以支持异步,只要支持 produce 函数返回 Promise 即可。最大的问题是,最后对代理的 revoke 清洗,需要借助全局变量,这一点阻碍了 Immer 对异步的支持。 4 总结读到这,如果觉得不过瘾,可以看看 redux-box 这个库,利用 immer + redux 解决了 reducer 冗余 return 的问题。 同样我们也开始思考并设计新的数据流框架,笔者在 2018.3.24 的携程技术沙龙将会分享 《mvvm 前端数据流框架精讲》,分享这几年涌现的各套数据流技术方案研究心得,感兴趣的同学欢迎报名参加。 5 更多讨论 讨论地址是:精读《Immer.js》源码》 · Issue ##68 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《async await 是把双刃剑》","path":"/wiki/WebWeekly/前沿技术/《async await 是把双刃剑》.html","content":"当前期刊数: 55 本周精读内容是 《async/await 是把双刃剑》。 1 引言终于,async/await 也被吐槽了。Aditya Agarwal 认为 async/await 语法让我们陷入了新的麻烦之中。 其实,笔者也早就觉得哪儿不对劲了,终于有个人把实话说了出来,async/await 可能会带来麻烦。 2 概述下面是随处可见的现代化前端代码: (async () => { const pizzaData = await getPizzaData(); // async call const drinkData = await getDrinkData(); // async call const chosenPizza = choosePizza(); // sync call const chosenDrink = chooseDrink(); // sync call await addPizzaToCart(chosenPizza); // async call await addDrinkToCart(chosenDrink); // async call orderItems(); // async call})(); await 语法本身没有问题,有时候可能是使用者用错了。当 pizzaData 与 drinkData 之间没有依赖时,顺序的 await 会最多让执行时间增加一倍的 getPizzaData 函数时间,因为 getPizzaData 与 getDrinkData 应该并行执行。 回到我们吐槽的回调地狱,虽然代码比较丑,带起码两行回调代码并不会带来阻塞。 看来语法的简化,带来了性能问题,而且直接影响到用户体验,是不是值得我们反思一下? 正确的做法应该是先同时执行函数,再 await 返回值,这样可以并行执行异步函数: (async () => { const pizzaPromise = selectPizza(); const drinkPromise = selectDrink(); await pizzaPromise; await drinkPromise; orderItems(); // async call})(); 或者使用 Promise.all 可以让代码更可读: (async () => { Promise.all([selectPizza(), selectDrink()]).then(orderItems); // async call})(); 看来不要随意的 await,它很可能让你代码性能降低。 3 精读仔细思考为什么 async/await 会被滥用,笔者认为是它的功能比较反直觉导致的。 首先 async/await 真的是语法糖,功能也仅是让代码写的舒服一些。先不看它的语法或者特性,仅从语法糖三个字,就能看出它一定是局限了某些能力。 举个例子,我们利用 html 标签封装了一个组件,带来了便利性的同时,其功能一定是 html 的子集。又比如,某个轮子哥觉得某个组件 api 太复杂,于是基于它封装了一个语法糖,我们多半可以认为这个便捷性是牺牲了部分功能换来的。 功能完整度与使用便利度一直是相互博弈的,很多框架思想的不同开源版本,几乎都是把功能完整度与便利度按照不同比例混合的结果。 那么回到 async/await 它的解决的问题是回调地狱带来的灾难: a(() => { b(() => { c(); });}); 为了减少嵌套结构太多对大脑造成的冲击,async/await 决定这么写: await a();await b();await c(); 虽然层级上一致了,但逻辑上还是嵌套关系,这不是另一个程度上增加了大脑负担吗?而且这个转换还是隐形的,所以许多时候,我们倾向于忽略它,所以造成了语法糖的滥用。 理解语法糖虽然要正确理解 async/await 的真实效果比较反人类,但为了清爽的代码结构,以及防止写出低性能的代码,还是挺有必要认真理解 async/await 带来的改变。 首先 async/await 只能实现一部分回调支持的功能,也就是仅能方便应对层层嵌套的场景。其他场景,就要动一些脑子了。 比如两对回调: a(() => { b();});c(() => { d();}); 如果写成下面的方式,虽然一定能保证功能一致,但变成了最低效的执行方式: await a();await b();await c();await d(); 因为翻译成回调,就变成了: a(() => { b(() => { c(() => { d(); }); });}); 然而我们发现,原始代码中,函数 c 可以与 a 同时执行,但 async/await 语法会让我们倾向于在 b 执行完后,再执行 c。 所以当我们意识到这一点,可以优化一下性能: const resA = a();const resC = c();await resA;b();await resC;d(); 但其实这个逻辑也无法达到回调的效果,虽然 a 与 c 同时执行了,但 d 原本只要等待 c 执行完,现在如果 a 执行时间比 c 长,就变成了: a(() => { d();}); 看来只有完全隔离成两个函数: (async () => { await a(); b();})();(async () => { await c(); d();})(); 或者利用 Promise.all: async function ab() { await a(); b();}async function cd() { await c(); d();}Promise.all([ab(), cd()]); 这就是我想表达的可怕之处。回调方式这么简单的过程式代码,换成 async/await 居然写完还要反思一下,再反推着去优化性能,这简直比回调地狱还要可怕。 而且大部分场景代码是非常复杂的,同步与 await 混杂在一起,想捋清楚其中的脉络,并正确优化性能往往是很困难的。但是我们为什么要自己挖坑再填坑呢?很多时候还会导致忘了填。 原文作者给出了 Promise.all 的方式简化逻辑,但笔者认为,不要一昧追求 async/await 语法,在必要情况下适当使用回调,是可以增加代码可读性的。 4 总结async/await 回调地狱提醒着我们,不要过度依赖新特性,否则可能带来的代码执行效率的下降,进而影响到用户体验。同时,笔者认为,也不要过度利用新特性修复新特性带来的问题,这样反而导致代码可读性下降。 当我翻开 redux 刚火起来那段时期的老代码,看到了许多过度抽象、为了用而用的代码,硬是把两行代码能写完的逻辑,拆到了 3 个文件,分散在 6 行不同位置,我只好用字符串搜索的方式查找线索,最后发现这个抽象代码整个项目仅用了一次。 写出这种代码的可能性只有一个,就是在精神麻木的情况下,一口气喝完了 redux 提供的全部鸡汤。 就像 async/await 地狱一样,看到这种 redux 代码,我觉得远不如所谓没跟上时代的老前端写出的 jquery 代码。 决定代码质量的是思维,而非框架或语法,async/await 虽好,但也要适度哦。 PS: 经过讨论,笔者把原文 async/await 地狱标题改成了 async/await 是把双刃剑。因为 async/await 并没有回调地狱那么可怕,称它为地狱有误导的可能性。 5 更多讨论 讨论地址是:精读《async/await 是把双刃剑》 · Issue ##82 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《class static block》","path":"/wiki/WebWeekly/前沿技术/《class static block》.html","content":"当前期刊数: 210 class-static-block 提案于 2021.9.1 进入 stage4,是一个基于 Class 增强的提案。 本周我们结合 ES2022 feature: class static initialization blocks 这篇文章一起讨论一下这个特性。 概述为什么我们需要 class static block 这个语法呢?其中一个原因是对 Class 静态变量的灵活赋值需求。以下面为例,我们想在 Class 内部对静态变量做批量初始化,就不得不写一个无用的 _ 变量用来做初始化的逻辑: class Translator { static translations = { yes: 'ja', no: 'nein', maybe: 'vielleicht', }; static englishWords = []; static germanWords = []; static _ = initializeTranslator( // (A) this.translations, this.englishWords, this.germanWords);}function initializeTranslator(translations, englishWords, germanWords) { for (const [english, german] of Object.entries(translations)) { englishWords.push(english); germanWords.push(german); }} 而且我们为什么把 initializeTranslator 写在外面呢?就因为在 Class 内部不能写代码块,但这造成一个严重的问题,是外部函数无法访问 Class 内部属性,所以需要做一堆枯燥的传值。 从这个例子看出,我们为了自定义一段静态变量初始化逻辑,需要做出两个妥协: 在外部定义一个函数,并接受大量 Class 成员变量传参。 在 Class 内部定义一个无意义的变量 _ 用来启动这个函数逻辑。 这实在太没有代码追求了,我们在 Class 内部做掉这些逻辑不就简洁了吗?这就是 class static block 特性: class Translator { static translations = { yes: 'ja', no: 'nein', maybe: 'vielleicht', }; static englishWords = []; static germanWords = []; static { // (A) for (const [english, german] of Object.entries(this.translations)) { this.englishWords.push(english); this.germanWords.push(german); } }} 可以看到,static 关键字后面不跟变量,而是直接跟一个代码块,就是 class static block 语法的特征,在这个代码块内部,可以通过 this 访问 Class 所有成员变量,包括 ## 私有变量。 原文对这个特性使用介绍就结束了,最后还提到一个细节,就是执行顺序。即所有 static 变量或区块都按顺序执行,父类优先执行: class SuperClass { static superField1 = console.log('superField1'); static { assert.equal(this, SuperClass); console.log('static block 1 SuperClass'); } static superField2 = console.log('superField2'); static { console.log('static block 2 SuperClass'); }}class SubClass extends SuperClass { static subField1 = console.log('subField1'); static { assert.equal(this, SubClass); console.log('static block 1 SubClass'); } static subField2 = console.log('subField2'); static { console.log('static block 2 SubClass'); }}// Output:// 'superField1'// 'static block 1 SuperClass'// 'superField2'// 'static block 2 SuperClass'// 'subField1'// 'static block 1 SubClass'// 'subField2'// 'static block 2 SubClass' 所以 Class 内允许有多个 class static block,父类和子类也可以有,不同执行顺序结果肯定不同,这个选择权交给了使用者,因为执行顺序和书写顺序一致。 精读结合提案来看,class static block 还有一个动机,就是给了一个访问私有变量的机制: let getX;export class C { ##x constructor(x) { this.##x = { data: x }; } static { // getX has privileged access to ##x getX = (obj) => obj.##x; }}export function readXData(obj) { return getX(obj).data;} 理论上外部无论如何都无法访问 Class 私有变量,但上面例子的 readXData 就可以,而且不会运行时报错,原因就是其整个流程都是合法的,最重要的原因是,class static block 可以同时访问私有变量与全局变量,所以可以利用其做一个 “里应外合”。 不过我并不觉得这是一个好点子,反而像一个 “BUG”,因为任何对规定的突破都会为可维护性埋下隐患,除非这个特性用在稳定的工具、框架层,用来做一些便利性工作,最终提升了应用编码的体验,这种用法是可以接受的。 最后要意识到,class static block 本质上并没有增加新功能,我们完全可以用普通静态变量代替,只是写起来很不自然,所以这个特性可以理解为对缺陷的补充,或者是语法完善。 总结总的来说,class static block 在 Class 内创建了一个块状作用域,这个作用域内拥有访问 Class 内部私有变量的特权,且这个块状作用域仅在引擎调用时初始化执行一次,是一个比较方便的语法。 原文下方有一些反对声音,说这是对 JS 的复杂化,也有诸如 JS 越来越像 Java 的声音,不过我更赞同作者的观点,也就是 Js 中 Class 并不是全部,现在越来越多代码使用函数式语法,即便使用了 Class 的场景也会存在大量函数申明,所以 class static block 这个提案对开发者的感知实际上并不大。 讨论地址是:精读《class static block》· Issue ##351 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《css-in-js 杀鸡用牛刀》","path":"/wiki/WebWeekly/前沿技术/《css-in-js 杀鸡用牛刀》.html","content":"当前期刊数: 27 本期精读的文章是:css-in-js 杀鸡用牛刀 1 引言 继 精读《请停止 css-in-js 的行为》 这篇文章之后,我们又读了一篇抵制 css-in-js 的文章,虽然大部分观点都有道理,但部分存在可商榷之处,让我们分析一下这篇文章,了解 css 还做了哪些努力,以及 css-in-js 会如何发展。 2 内容概要2.1 结构/行为 vs 样式作者认为,模块化 jsx 让 html 结构与行为耦合在一起是很有价值的,然而样式却不应该与模块耦合起来,因为样式是一种全局行为。许多时候需要对网站进行全局的设计,将样式分散到模块中会导致更多的理解成本。 2.2 松耦合与紧耦合将样式与模块松耦合,系统会获得更大的自由度与拓展性。如果样式与结构松耦合,一套看似相似的的元素,可能拥有完全不同的底层结构。然而交互必须与结构紧耦合,因为交互依赖于结构。 2.3 视觉一致性问题局部样式会阻碍视觉一致性,只有全局化样式才能保证视觉一致性。 2.4 代码复用问题如果每个组件维护自己的样式,那么会存在许多样式代码复制粘贴的问题,复制粘贴的代码可维护性极低。 3 精读无论是 css-in-js 还是 css 预编译的尝试,各自都具有强大优点,本文对 css-in-js 提出的质疑我认为是欠妥当的,下面谈谈 css-in-js 如何解决作者提出的问题,以及简单介绍 OOCSS, SMACSS, BEM, ITCSS, 和 ECSS 的思路。 3.1 css-in-js 依然具备视觉一致性文中提出,网站样式要从全局考虑,模块化样式行为的优点是解决了样式冲突问题,但因此也削弱了对全局样式的把控。 开发单个组件的样式分为两种情况,分别是明确风格的组件与样式独立的组件,在样式独立组件中,由于不确定会被哪些主题的网站所引用,因此无论是全局 css 还是局部 css,都无法控制样式。在明确风格的情况下,可以先把此风格的基色确定下来,无论是抽成 sass 变量还是 js 变量,都具有可复用性。 全局 css 的开发,适合自上而下控制,组件通过定义 class 而不需要关心具体样式,通过全局 class 统一调控整体风格。而 css-in-js 是自下而上的,但需要预先抽出整体风格的样式模块,其效果与全局 css 是等价的。 全局 css 控制风格: <style>\t.container{}\t.list-item{}\t.submit-button{}</style><div className="container">\t<div className="list-item"></div>\t<div className="list-item"></div>\t<div className="submit-button"></div></div> css-in-js 风格: const CommonContainer = styled.div``const CommonListItem = styled.div``const CommonSubmitButton = css``export const Container = styled(CommonContainer)``export const ListItem = styled(CommonListItem)``export const CommonSubmitButton = styled.div`\t${CommonSubmitButton}` 而 css-in-js 运行时的样式解析,让我们更轻易的切换主题。比如我们抽出一个公共样式包,业务代码中的色值都从此样式包中引用,那么在不同的环境下,公共样式包可能通过所在宿主环境的判断,返回给业务代码不同的色值,甚至与宿主环境配合,从宿主环境拿到注入的颜色,实现一套代码在运行时轻松换肤。 3.2 css-in-js 仍具备代码复用性文中观点提出,css-in-js 这种局部样式行为,会导致公共样式、方法难以复用,导致各个模块参杂着大量重复代码。因为 sass 通过定义全局变量、mixins 方法让样式更具有复用性。 我觉得这是一种误解,在 css-in-js 模式中,通过全局合理的设计,使用 js 文件存放颜色变量、公共方法、可能会复用的 css 代码块,其复用能力远大于 sass。 3.3 OOCSSOOCSS 成为 css 的面向对象加强版,每个 class 只处理一件事: .size {width: 25%;}.bgBlue {background:blue}.g-bd2{margin:0 0 10px;} 网易 NEC 就大量使用了这种思想。 这样的好处在于避免了 class 之间的冗余,让我们更容易创建可复用的 class,也不会在命名上纠结。 然而,先不说 oocss 带来的巨大零散 class 导致的维护成本,以及修改 class 导致的巨大风险,class 的本意是语义化,如果让 class 使用一堆对象描述堆砌,我们将很难定位一个元素,也很难描述这个元素的含义。 3.4 SMACSS为 css 分类SMACSS 认为 css 有 5 个类别: Base 基础样式 Layout 布局样式 Module 模块样式 State 状态样式 Theme 主题样式 我们通过这 5 种类别来拼凑出完整的 class,我感觉就是对 OOCSS 的进一步规范和约束。 命名规则对这 5 种类别,在命名时要加上对应前缀,分别是: Base 属于基础元素,比如 div p,不需要命名 Layout 使用 .l- 或 .layout-前缀 Module 使用模块名命名,比如文章区块就叫 .article State 使用 .is- 前缀,比如 .is-show Theme 使用 .theme- 前缀 我觉得这样在语义化的基础上,拆分了状态、主题、布局,着实增强了 css 可读性。 最小化适配深度尽可能减少适配层级,虽然增加适配层级会减少冲突发生率,但是会增加额外的阅读负担,以及一些 bug(旧版 ie 层级超过 255 导致样式失效)。 像 css-modules 这种解决方案恰恰反其道而行之,通过层级避免冲突,通过预编译解决阅读负担,然而在没有预编译的情况下,最小化适配深度原则依然是最有效的。 3.5 BEMBEM 规范更像是 SMACSS 分类的加强版,通过 __element 表述后代,--modifier 表述状态,比如: .article {}.article__label {} /* label 元素 */.article__label--selected {} /* label 元素处于被选中状态 */ 3.6 ITCSS类似 SMACSS 对 css 元素进行了分层: Settings – 与预处理器一起使用,包含颜色、字体等定义 Tools – 工具与方法,比如 mixins,Settings 与 Tools 都不会产生任何 css 代码,仅仅是辅助函数与变量 Generic – 通用层,比如 reset html、body 的样式 Elements – 对通用元素的样式重置,比如 a p div 等元素的样式重置 Objects – 类似 OOCSS 中的对象,描述一些常用的基础状态 Components – 对组件样式的定义,一个 UI 元素基本由 Objects 与 Components 组成 Utilities – 工具类,比如 .hidden ITCSS 的分层是非常有借鉴意义的,即便在 css-in-js 设计中,也可以参考此模式定义结构。 3.7 ECSSECSS 的规范是这样的:.nsp-Component_ChildNode-variant nsp 一个尽量简短的命名空间 Component 文件名 ChildNode 子元素名 variant 额外内容 例子: <div class="tl-MediaObject"> <a href="##" class="tl-MediaObject_Link"> <img class="tl-MediaObject_Media" src="mini.jpg" alt="User"> </a> <div class="tl-MediaObject_Attribution">@BF 14 minutes ago</div></div> 更多细节可以看此 PPT 4 总结虽然我认为这篇文章提出的 css-in-js 缺点大部分存在漏洞,但它警示了我们,css 设计的初衷是全局化控制样式,即便产生了样式冲突、混乱的问题,但我们仍要记住,在模块化开发的今天,仍要保持网站风格的整体性,即便使用了 css-in-js 的开发方式。 虽然作者呼吁我们不要只顾着 css-in-js,要放眼看看 OOCSS, SMACSS, BEM, ITCSS, 和 ECSS 等基于原生 css 的解决方案,但我觉得把这些思想运用到 css-in-js 是个不错的选择 :p 讨论地址是:精读《css-in-js 杀鸡用牛刀》 · Issue ##38 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《dob - 框架使用》","path":"/wiki/WebWeekly/前沿技术/《dob - 框架使用》.html","content":"当前期刊数: 38 本系列分三部曲:《框架实现》 《框架使用》 与 《跳出框架看哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。 本篇是 《框架使用》。 1 引言现在我们团队也在重新思考数据流的价值,在业务不断发展,业务场景增多时,一个固定的数据流方案可能难以覆盖所有场景,在所有业务里都用得爽。特别在前端数据层很薄的场景下,在数据流治理上花功夫反倒是本末倒置。 业务场景通常很复杂,但是对技术的探索往往只追求理想情况下的效果,所以很多人草草阅读完别人的经验,给自己业务操刀时,会听到一些反对的声音,而实际效果也差强人意。 所以在阅读文章之前,应该先认识到数据流只是项目中非常微小的一环,而且每个具体方案都很看场景,就算用对了路子,带来的提效也不一定很明显。 2017 年 Redux 依然是主流,可能到 18 年还是。大家吐槽归吐槽,最终活还是得干,Redux 还是得用,就算分析出 js 天生不适合函数式,也依然一条路走到黑,因为谁也不知道未来会如何发展,redux 生态虽然用得繁琐,但普适性强,忍一忍,生活也能继续过。 Dob 和 Mobx 类似,也只是数据流中响应式方案的一个分支,思考也是比较理想化的,因此可能也摆脱不了中看不中用的命运,谁叫业务场景那么多呢。 不过相对而言,应该算是接地气一些,它既没有要求纯函数式和分离副作用,也没有 cyclejs 那么抽象,只要入门的面向对象,就可以用好。 2 精读 dob 框架使用使用 redux 时,很多时候是傻傻分不清要不要将结构化数据拍平,再分别订阅,或者分不清订阅后数据处理应该放在组件上还是全局。这是因为 redux 破坏了 react 分形设计,在 最近的一次讨论记录 有说到。而许多基于 redux 的分形方案都是 “伪” 分形的,偷偷利用 replaceReducer 做一些动态 reducer 注册,再绑定到全局。 讨论理想数据流方案比较痛苦,而且引言里说到,很多业务场景下收益也不大,所以可以考虑结合工程化思维解决,将组件类型区分开,分为普通组件与业务组件,普通组件不使用数据流,业务组件绑定全局数据流,可以避免纠结。 Store 如何管理 使用 Mobx 时,文档告诉我们它具有依赖追踪、监听等许多能力,但没有好的实践例子做指导,看完了 todoMvc 觉得学完了 90%,在项目中实践后发现无从下手。 所谓最佳实践,是基于某种约定或约束,让代码可读性、可维护性更好的方案。约定是活的,不遵守也没事,约束是死的,不遵守就无法运行。约束大部分由框架提供,比如开启严格模式后,禁止在 Action 外修改变量。然而纠结最多的地方还是在约定上,我在写 dob 框架前后,总结出了一套使用约定,可能仅对这种响应式数据流管用。 使用数据流,第一要做的事情就是管理数据,要解决 Store 放在哪,怎么放的问题。其实还有个前置条件:要不要用 Store 的问题。 要不要用 store首先,最简单的组件肯定不需要用数据流。那么组件复杂时,如果数据流本身具有分形功能,那么可用可不用。所谓具有分形功能的数据流,是贴着 react 分形功能,将其包装成任具有分形能力的组件: import { combineStores, observable, inject, observe } from 'dob'import { Connect } from 'dob-react'@observableclass Store { name = 123 }class Action { @inject(Store) store: Store changeName = () => { this.store.name = 456 }}const stores = combineStores({ Store, Action })@Connect(stores)class App extends React.Component<typeof stores, any> { render() { return <div onClick={this.props.Action.changeName}>{this.props.Store.name}</div> }}ReactDOM.render(<App /> , document.getElementById('react-dom')) dob 就是这样的框架,上面例子中,点击文字可以触发刷新,**即便根 dom 节点没有 Provider**。这意味着这个组件不论放到任何环境,都可以独立运行,成为任何项目中的一部分。这种组件虽然用了数据流,但是和普通 React 组件完全无区别,可以放心使用。 如果是伪分形的数据流,可能在 ReactDOM.render 需要特定的 Provider 配合才可使用,那么这个组件就不具备可迁移能力。如果别人不幸安装了这种组件,就需要在项目根目录安装一个全家桶。 问:虽然数据流+组件具备完全分形能力,但若此组件对 props 有响应式要求,那还是有对该数据流框架的隐形依赖。 答:是的,如果组件要求接收的 props 是 observable 化的,以便在其变化时自动 rerender,那当某个环境传递了普通 props,这个组件的部分功能将失效。其实 props 属于 react 的通用连接桥梁,因此组件只应该依赖普通对象的 props,内部可以再对其 observable 化,以具备完备的可迁移能力。 怎么用 storeReact 虽然可以完全模块化,但实际项目中模块一定分为通用组件与业务组件,页面模块也可以当作业务组件。复杂的网站由数据驱动比较好,既然是数据驱动,那么可以将业务组件与数据的连接移到顶层管理,一般通过页面顶层包裹 Provider 实现: import { combineStores, observable, inject, observe } from 'dob'import { Connect } from 'dob-react'@observableclass Store { name = 123 }class Action { @inject(Store) store: Store changeName = () => { this.store.name = 456 }}const stores = combineStores({ Store, Action })ReactDOM.render( <Provider {...store}> <App /> </Provider> , document.getElementById('react-dom')) 本质上只是改变了 Store 定义的位置,而组件使用方式依然不变: @Connectclass App extends React.Component<typeof stores, any> { render() { return <div onClick={this.props.Action.changeName}>{this.props.Store.name}</div> }} 有一个区别是 @Connect 不需要带参数了,因为如果全局注册了 Provider,会默认透传到 Connect 中。与分形相反,这种设计会导致组件无法迁移到其他项目单独运行,但好处是可以在本项目中任意移动。 分形的组件对结构强依赖,只要给定需要的 props 就可以完成功能,而全局数据流的组件几乎可以完全不依赖结构,所有 props 都从全局 store 获取。 其实说到这里,可以发现这两点是难以合二为一的,我们可以预先将组件分为业务耦合与非业务耦合两种,让业务耦合的组件依赖全局数据流,让非业务耦合组件保持分形能力。 如果有更好的 Store 管理方式,可以在我的 github 和 知乎 深入聊聊。 每个组件都要 Connect 吗 对于 Mvvm 思想的库,Connect 概念不仅仅在于注入数据(与 redux 不同),还会监听数据的变化触发 rerender。那么每个组件需要 Connect 吗? 从数据流功能来说,没有用到数据流的组件当然不需要 Connect,但业务组件保持着未来不确定性(业务不确定),所以保持每个业务组件的 Connect 便于后期维护。 而且 Connect 可能还会做其他优化工作,比如 dob 的 Connect 不仅会注入数据,完成组件自动 render,还会保证组件的 PureRender,如果对 dob 原理感兴趣,可以阅读 精读《dob - 框架实现》。 其实个议题只是非常微小的点,不过现实就是讽刺的,很多时候多会纠结在这种小点子上,所以单独花费篇幅说几句。 数据流是否要扁平化Store 扁平化有很大原因是 js 对 immutable 支持力度不够,导致对深层数据修改非常麻烦导致的,虽然 immutable.js 这类库可以通过字符串快速操作,但这种使用方式必然会被不断发展的前端浪潮所淹没,我们不可能看到 js 标准推荐我们使用字符串访问对象属性。 通过字符串访问对象属性,和 lodash 的 _.get 类似,不过对于安全访问属性,也已经有 proposal-optional-chaining 的提案在语法层面解决,同样 immutable 的便捷操作也需要一种标准方式完成。实际上不用等待另一个提案,利用 js 现有能力就可以模拟原生 immutable 支持的效果。 dob-redux 可以通过类似 this.store.articles.push(article) 的 mutable 写法,实现与 react-redux 的对接,内部自然做掉了类似 immutable.set 的事情,感兴趣可以读读我的这篇文章:Redux 使用可变数据结构,介绍了这个黑魔法的实现原理。 有点扯远了,那么数据流扁平化本质解决的是数据格式规范问题。比如 normalizr 就是一种标准数据规范的推进,很多时候我们都将冗余、或者错误归类的数据存入 Store,那维护性自然比较差,Redux 推崇的应当是正确的数据格式化,而不是一昧追求扁平化。 对于前端数据流很薄的场景,也不是随便处理数据就完事了。还有许多事可做,比如使用 node 微服务对后端数据标准化、封装一些标准格式处理组件,把很薄的数据做成零厚度,业务代码可以对简单的数据流完全无感知等等。 异步与副作用 Redux 自然而然用 action 隔离了副作用与异步,那在只有 action 的 Mvvm 开发模式中,异步需要如何隔离?Mvvm 真的完美解决了 Redux 避而远之的异步问题吗? 在使用 dob 框架时,异步后赋值需要非常小心: @Action async getUserInfo() { const userInfo = await fetchUser() this.store.user.data = userInfo // 严格模式下将会报错,因为脱离了 Action 作用域。} 原因是 await 只是假装用同步写异步,当一个 await 开始时,当前函数的栈已经退出,因此后续代码都不在一个 Action 中,所以一般的解法是显示申明 Action 的显示申明大法: @Action async getUserInfo() { const userInfo = await fetchUser() Action(() => { this.store.user.data = userInfo })} 这说明了异步需要当心!Redux 将异步隔离到 Reducer 之外很正确,只要涉及到数据流变化的操作是同步的,外面 Action 怎么千奇百怪,Reducer 都可以高枕无忧。 其实 redux 的做法与下面代码类似: @Action async getUserInfo() { // 类 redux action const userInfo = await fetchUser() this.setUserInfo(userInfo)}@Action async setUserInfo(userInfo) { // 类 redux reduer this.store.user.data = userInfo} 所以这是 dob 中对异步的另一种处理方法,称作隔离大法吧。所以在响应式框架中,显示申明大法与隔离大法都可以解决异步问题,代码也显得更加灵活。 请求自动重发响应式框架的另一个好处在于可以自动触发,比如自动触发请求、自动触发操作等等。 比如我们希望当请求参数改变时,可以自动重发,一般的,在 react 中需要这么申明: componentWillMount() { this.fetch({ url: this.props.url, userName: this.props.userName })}componentWillReceiveProps(nextProps) { if ( nextProps.url !== this.props.url || nextProps.userName !== this.props.userName ) { this.fetch({ url: nextProps.url, userName: nextProps.userName }) }} 在 dob 这类框架中,以下代码的功能是等价的: import { observe } from 'dob'componentWillMount() { this.signal = observe(() => { this.fetch({ url: this.props.url, userName: this.props.userName }) })} 其神奇地方在于,observe 回调函数内用到的变量(observable 后的变量)改变时,会重新执行此回调函数。而 componentWillReceiveProps 内做的判断,其实是利用 react 的生命周期手工监听变量是否改变,如果改变了就触发请求函数,然而这一系列操作都可以让 observe 函数代劳。 observe 有点像更自动化的 addEventListener: document.addEventListener('someThingChanged', this.fetch) 所以组件销毁时不要忘了取消监听: this.signal.unobserve() 最近我们团队也在探索如何更方便的利用这一特性,正在考虑实现一个自动请求库,如果有好的建议,也非常欢迎一起交流。 类型推导如果你在使用 redux,可以参考 你所不知道的 Typescript 与 Redux 类型优化 优化 typescript 下 redux 类型的推导,如果使用 dob 或 mobx 之类的框架,类型推导就更简单了: import { combineStores, Connect } from 'dob'const stores = combineStores({ Store, Action })@Connectclass Component extends React.PureComponent<typeof stores, any> { render() { this.props.Store // 几行代码便获得了完整类型支持 }} 这都得益于响应式数据流是基于面向对象方式操作,可以自然的推导出类型。 Store 之间如何引用复杂的数据流必然存在 Store 与 Action 之间相互引用,比较推荐依赖注入的方式解决,这也是 dob 推崇的良好实践之一。 当然依赖注入不能滥用,比如不要存在循环依赖,虽然手握灵活的语法,但在下手写代码之前,需要对数据流有一套较为完整的规划,比如简单的用户、文章、评论场景,我们可以这么设计数据流: 分别建立 UserStore ArticleStore ReplyStore: import { inject } from 'dob'class UserStore { users}class ReplyStore { @inject(UserStore) userStore: UserStore replys // each.user}class ArticleStore { @inject(UserStore) userStore: UserStore @inject(ReplyStore) replyStore: ReplyStore articles // each.replys each.user} 每个评论都涉及到用户信息,所以 ReplyStore 注入了 UserStore,每个文章都包含作者与评论信息,所以 ArticleStore 注入了 UserStore 与 ReplyStore,可以看出 Store 之间依赖关系应当是树形,而不是环形。 最终 Action 对 Store 的操作也是通过注入来完成,而由于 Store 之间已经注入完了,Action 可以只操作对应的 Store,必要的时候再注入额外 Store,而且也不会存在循环依赖: class UserAction { @inject(UserStore) userStore: UserStore}class ReplyAction { @inject(ReplyStore) replyStore: ReplyStore}class ArticleAction { @inject(ArticleStore) articleStore: ArticleStore} 最后,不建议在局部 Store 注入全局 Store,或者局部 Action 注入全局 Store,因为这会破坏局部数据流的分形特点,切记保证非业务组件的独立性,把全局绑定交给业务组件处理。 Action 的错误处理比较优雅的方式,是编写类级别的装饰器,统一捕获 Action 的异常并抛出: const errorCatch = (errorHandler?: (error?: Error) => void) => (target: any) => { Object.getOwnPropertyNames(target.prototype).forEach(key => { const func = target.prototype[key] target.prototype[key] = async (...args: any[]) => { try { await func.apply(this, args) } catch (error) { errorHandler && errorHandler(error) } } }) return target}const myErrorCatch = errorCatch(error => { // 上报异常信息 error})@myErrorCatchclass ArticleAction { @inject(ArticleStore) articleStore: ArticleStore} 当任意步骤触发异常,await 之后的代码将停止执行,并将异常上报到前端监控平台,比如我们内部的 clue 系统。关于异常处理更多信息,可以访问我较早的一篇文章:Callback Promise Generator Async-Await 和异常处理的演进。 3 总结准确区分出业务与非业务组件、写代码前先设计数据流的依赖关系、异步时注意分离,就可以解决绝大部分业务场景的问题,实在遇到特殊情况可以使用 observe 监听数据变化,由此可以拓展出比如请求自动重发的功能,运用得当可以解决余下比较棘手的特殊需求。 虽然数据流只是项目中非常微小的一环,但如果想让整个项目保持良好的可维护性,需要把各个环节做精致。 这篇文章写于 2017 年最后一天,祝大家元旦快乐! 更多讨论 讨论地址是:精读《dob - 框架使用》 · Issue ##53 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《dob - 框架实现》","path":"/wiki/WebWeekly/前沿技术/《dob - 框架实现》.html","content":"当前期刊数: 35 本系列分三部曲:《框架实现》 《框架使用》 与 《跳出框架看哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。 本篇是 《框架实现》。 本周精读的文章是 dob 文档,如果不熟悉 API,可以简单读一读,文中有些地方会提到一些函数。 1 引言我觉得数据流与框架的关系,有点像网络与人的关系。 在网络诞生前,人与人之间连接点较少,大部分消息都是通过人与人之间传递,虽然信息整体性不强,但信息在局部非常完备:当你想开一家门面,找到经验丰富的经理人,可以一手包办完。 网络诞生后,如果想通过纯网络的方式,学习如何开门面,如果不是对网络很熟悉,一时半会也难以学习到全套流程。 数据流对框架来说,就像网络对人一样,总是存在着模块功能的完备性与项目整体性的博弈。 全局性强了,对整体性强要求的项目(频繁交互数据)友好,顺便利于测试,因为不利于测试的 UI 与数据关系被抽离开了。 局部性强了,对弱关联的项目友好,这样任何模块都能不依赖全局数据,自己完成所有功能。 对数据流的研究,大多集中于 “优化在某些框架的用法” “基于场景改良” “优化全局与局部数据流间关系” “函数式与面向对象之争” “对输入抽象” “数据格式转换” 这几方面。这里面参杂着统一与分离,类比到网络与人,也许最终只有人脑搬到网络中,才可以达到最终状态。 虚的就说这么多,本篇讲的是 《框架实现》,我们先钻到细节里。 2 精读 dob 框架实现dob 是个类似 mobx 的框架,实现思路都很类似,如果难以读懂 mobx 的源码,可以先参考 dob 的实现原理。 抽丝剥茧,实现依赖追踪 MVVM 思路中,依赖追踪是核心。 dob 中 observe 类似 mobx 的 autorun,是使用频率最高的依赖监听工具。 写作时,已经有许多文章将 vue 源码翻来覆去研究过了,因此这里就不长篇大论 MVVM 原理了。 依赖追踪分为两部分,分别是 依赖收集 与 触发回调,如果把这两个功能合起来,就是 observe 函数,分开的话,就是较为底层的 Reaction: Reaction 双管齐下,一边监听用到了哪些变量,另一边在这些变量改变后,执行回调函数。Observe 利用 Reaction 实现(简化版): function observe(callback) { const reaction = new Reaction(() => { reaction.track(callback) }) reaction.run()} reaction.run() 在初始化就执行 new Reaction 的回调,而这个回调又恰好执行 reaction.track(callback)。所以 callback 函数中用到的变量被记录了下来,当变量更改时,会触发 new Reaction 的回调,又重新收集一轮依赖,同时执行了 callback。 这样就实现了回调函数用到的变量被改变后,重新执行这个回调函数,这就是 observe。 为什么依赖追踪只支持同步函数 依赖收集无法得到触发时的环境信息。 依赖收集由 getter、setter 完成,但触发时,却无法定位触发代码位于哪个函数中,所以为了依赖追踪(即变量与函数绑定),需要定义一个全局的变量标示当前执行函数,当各依赖收集函数执行没有交叉时,可以正常运作: 上图右侧白色方块是函数体,getter 表示其中访问到某个变量的 getter,经由依赖收集后,变量被修改时,左侧控制器会重新调用其所在的函数。 但是,当函数嵌套函数时,就会出现异常: 由于采用全局变量标记法,当回调函数嵌套起来时,当内层函数执行完后,实际作用域已回到了外层,但依赖收集无法获取这个堆栈改变事件,导致后续 getter 都会误绑定到内层函数。 异步(回调)也是同理,虽然写在一个函数体内,但执行的堆栈却不同,因此无法实现正确的依赖收集。 所以需要一些办法,将嵌套的函数放在外层函数执行完毕后,再执行: 换成代码描述如下: observe(()=>{ console.log(1) observe(()=>{ console.log(2) }) console.log(3)})// 需要输出 1,3,2 当然这不是简单 setTimeout 异步控制就可以,因为依赖收集是同步的,我们要在同步基础上,实现函数执行顺序的变换。 我们可以逐层分解,在每一层执行时,子元素如果是 observe,就会临时放到队列里并跳过,在父 observe 执行完毕后,检查并执行队列,两层嵌套时执行逻辑如下图所示: 这些努力,就是为了保证在同步执行时,所有 getter 都能绑定到正确的回调函数。 如何结合 React observe 如何到 render observe 可以类比到 React 的 render,它们都具有相同的特征:是同步函数,同时 observe 的运行机制也符合了 render 函数的需求,不是吗? 如果将 observe 用到 react render 函数,当任何 render 函数使用到的变量发生改动,对应的 render 函数就会重新执行,实现 UI 刷新。 要实现结合,用到两个小技巧:聚合生命周期、替换 render 函数,用图才能解释清楚: 以上是简化版,正式版本使用 reaction 实现,可以更清晰的区分依赖收集与 rerender 阶段。 如何避免在 view 中随意修改变量为了使用起来具有更好的可维护性,需要限制依赖追踪的功能,使值不能再随意的修改。可见,强大的功能,不代表在数据流场景的高可用性,恰当的约束反而会更好。 因此引入 Action 概念,在 Action 中执行的变量修改,不仅会将多次修改聚合成一次 render,而且不在 Action 中的变量修改会抛出异常。 Action 类似进栈出栈,当栈深度不为 0 时,进行的任何的变量修改,拦截到后就可以抛出异常了。 有层次的实现 Debug 一层一层功能逐渐冒泡。 调试功能,在依赖追踪、与 react 结合这一层都需要做,怎样分工配合才能保证功能不冗余,且具有良好的拓展性呢? 数据流框架的 Debug 分为数据层和 UI 层,顺序是 dob 核心记录 debug 信息 -> dob-devtools 读取再加工,强化 UI 信息。 在 UI 层不止可以简单的将对象友好展示出来,更可以通过额外信息采集,将 Action 与 UI 元素绑定,让用户找到任意一次 Action 触发时,rerender 了哪些 UI 元素,以及每个 UI 元素分别与哪些 Action 绑定。 由于数据流需要一个 Provider 提供数据源,与 Connect 注入数据,所以可以将所有与数据流绑定的 UI 元素一一映射到 Debug UI,就像一面镜子一样映射: 通过 Debug UI,将 debug 信息与 UI 一一对应,实现 dob-react-devtools 的效果。 Debug 功能如何解耦 解耦还能方便许多功能拓展,比如支持 redux。 我得答案是事件。通过精心定义的一系列事件,制造出一个具有生命周期的工具库! 在所有 getter setter 节点抛出相关信息,Debug 端订阅这些事件,找到对自己有用的,记录下来。例如: event.on("get", info => { // 不在调试模式 if (!globalState.useDebug) { return } // 记录调用堆栈..}) Dob 目前支持这几种事件钩子: get: 任何数据发生了 getter。 set: 任何数据发生了 setter。 deleteProperty: 任何数据的 key 被移除时。 runInAction: 调用了 Action。 startBatch: 任意 Action 入栈。 endBatch: 任意 Action 出栈。 并且在关键生命周期节点,还要遵守调用顺序,比如以下是 Action 触发后,到触发 observe 的顺序: startBatch -> debugInAction -> ...multiple nested startBatch and endBatch -> debugOutAction -> reaction -> observe 如果未开启 debug,执行顺序简化为: startBatch -> ...multiple nested startBatch and endBatch -> reaction -> observe 订阅了这些事件,可以完成类似 redux-dev-tools 的功能。 3 总结由于篇幅有限,本文介绍的《框架实现》均是一些上层设计,很少有代码讲解。因为我觉得一篇引发思考的文章不应该贴太多的代码,况且人脑处理图形的效率远远高于文字、略高于代码,所以通过一些图来展示。如果想看代码实现,可以读 dob 源码。 如希望详细了解依赖注入实现流程,请看 从零开始用 proxy 实现 mobx。 下一篇是 《框架使用》,会站在使用者的角度思考数据流。当然不是下一篇精读,因为要换换胃口,也给我一些缓冲时间去整理。 更多讨论 讨论地址是:精读《dob - 框架实现》 · Issue ##48 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《how we position and what we compare》","path":"/wiki/WebWeekly/前沿技术/《how we position and what we compare》.html","content":"当前期刊数: 37 摘要本期精读文章以一个简单的例子,抽丝剥茧细数讲述如何面向用户可视化设计,探索用户最终的目的,化繁为简,化多为少,揉和 N 张图至一张图,并传达更多的深意。本文原文:http://www.storytellingwithdata.com/blog/2017/12/14/how-we-position-and-what-we-compare 下面是案例优化的具体步骤: 庖丁解牛 - 可视化案例优化可视化的时候,一定要优先考虑用户能够比较什么,然后把这些数据放到一个基准上,并把要比较的东西放到最近的地方。这样用户就可以快速简便的处理比较数据。图中有一系列类似分面的柱状图,代表了 Q1 ~ Q3 三个季度的账户 targeted 筛选 -> engaged 订阅 -> pitched 投放 -> adopted 采用的四个状态的百分比,这四个状态是呈漏斗式的数据,分别是上一个数据的子集。这个案列中,用户希望能够在一张图中比较所有的数据。如下是基础原始图: 这张原始也不乏一些优点,每个数据都清晰的表明了含义,但还是需要花费一些的时间找出图中数据表达的最重含义。但是是不是有更好的办法重新排列数据呢?我们来整理下这张图需要对比的内容把。 左上角,我们可以对比 Q1 北美 的四条柱状图,因为这四个数据相邻,并且是在一个坐标系中。 最上面一排,我们可以对比 Q1 北美和 EMEA engaged 订阅(两条紫色柱子)的数据,这个对比需要我们横向用手指去比较,需要参照物,存在一定的门槛。 基于第 2 点,我们可以考虑转换下思维,转换下数据呈现的类型可以更快速的比较。文中作者从自己在银行的职业生涯中发现线性图形的角度可以更快速的比较。所以就有了下面一张图 再进一步去除无用的柱状图,并把三个纵向的图合并,进一步简化成下面的图 可以看到坐标轴也有一些小优化,增加了字母标注每个步骤,不只是单一的颜色标注,每条线也能够区分出来它的归属(Q1 ~ Q3)。除此之外,我们还有一些非常特殊的信息需要透传,用户虽然在对比以前的数据,但是对当前的现状也是非常关注的,所以增加一些额外的实时信息以及数据解读,可以辅助用户做出下一步决策。 最后进行色彩优化,就可以看到如下最终的方案: 从这个方案,可以清晰的解读出: 每个阶段,北美的数据都比其他地区低 相比其他区域,为什么 APAC 的传播度有了突然的提升? 最需要关注 Q3 的数据,以及最终阶段的数据 最后从这篇文章的可视化案例优化样板,可以总结出以下步骤: 明确用户看图目标,根据需要转换图形表现形式 合理合并不需要拆分的数据,尽量减少数据的平铺 猜测探索用户下一步,突出当前实时现状、异常点、决策意见 利用色彩给数据分类,避免用户混淆 虽然这篇文章给出了比较场景下的优化案例,但是数据是僵硬的,呈现形式是多样的,其他场景下也会有其他更适合的优化方法。可视化和语言一样,前者是把数据翻译成图形,后者是人的思维翻译成文字。不过这也带来与语言雷同的障碍,换种方式表达可能传达的含义就完全不同。如果数据的表达方式能和语言一样,有一些固定的语法和规则,就可以大大减少表现上的歧义。因为人脑处理图形效率高于文字,所以现在很多大数据还是属于沉睡状态。我们前端做的是提升人机交互效率,通过图形可以几倍甚至几百倍提高理解速度。现在我们做的还只是把结构化的数据做到让人读懂,未来可能数据直接原始化存储,基于深度学习+可视化,转化成图形化数据,就可以让人类读懂图,甚至让机器也读懂,实现自我大数据解析。 未来,一切不可预期~~~ 讨论地址是:精读《how we position and what we compare》 · Issue ##50 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《js 模块化发展》","path":"/wiki/WebWeekly/前沿技术/《js 模块化发展》.html","content":"当前期刊数: 1 这次是前端精读期刊与大家第一次正式碰面,我们每周会精读并分析若干篇精品好文,试图讨论出结论性观点。没错,我们试图通过观点的碰撞,争做无主观精品好文的意见领袖。 我是这一期的主持人 —— 黄子毅 本期精读的文章是:evolutionOfJsModularity。 懒得看文章?没关系,稍后会附上文章内容概述,同时,更希望能通过阅读这一期的精读,穿插着深入阅读原文。 1 引言 如今,Javascript 模块化规范非常方便、自然,但这个新规范仅执行了 2 年,就在 4 年前,js 的模块化还停留在运行时支持,10 年前,通过后端模版定义、注释定义模块依赖。对经历过来的人来说,历史的模块化方式还停留在脑海中,反而新上手的同学会更快接受现代的模块化规范。 但为什么要了解 Javascript 模块化发展的历史呢?因为凡事都有两面性,了解 Javascript 模块化规范,有利于我们思考出更好的模块化方案,纵观历史,从 1999 年开始,模块化方案最多维持两年,就出现了新的替代方案,比原有的模块化更清晰、强壮,我们不能被现代模块化方式限制住思维,因为现在的 ES2015 模块化方案距离发布也仅仅过了两年。 2 内容概要直接定义依赖 (1999): 由于当时 js 文件非常简单,模块化方式非常简单粗暴 —— 通过全局方法定义、引用模块。这种定义方式与现在的 commonjs 非常神似,区别是 commonjs 以文件作为模块,而这种方法可以在任何文件中定义模块,模块不与文件关联。 闭包模块化模式 (2003): 用闭包方式解决了变量污染问题,闭包内返回模块对象,只需对外暴露一个全局变量。 模版依赖定义 (2006): 这时候开始流行后端模版语法,通过后端语法聚合 js 文件,从而实现依赖加载,说实话,现在 go 语言等模版语法也很流行这种方式,写后端代码的时候不觉得,回头看看,还是挂在可维护性上。 注释依赖定义 (2006): 几乎和模版依赖定义同时出现,与 1999 年方案不同的,不仅仅是模块定义方式,而是终于以文件为单位定义模块了,通过 lazyjs 加载文件,同时读取文件注释,继续递归加载剩下的文件。 外部依赖定义 (2007): 这种定义方式在 cocos2d-js 开发中普遍使用,其核心思想是将依赖抽出单独文件定义,这种方式不利于项目管理,毕竟依赖抽到代码之外,我是不是得两头找呢?所以才有通过 webpack 打包为一个文件的方式暴力替换为 commonjs 的方式出现。 Sandbox 模式 (2009): 这种模块化方式很简单,暴力,将所有模块塞到一个 sandbox 变量中,硬伤是无法解决命名冲突问题,毕竟都塞到一个 sandbox 对象里,而 Sandbox 对象也需要定义在全局,存在被覆盖的风险。模块化需要保证全局变量尽量干净,目前为止的模块化方案都没有很好的做到这一点。 依赖注入 (2009): 就是大家熟知的 angular1.0,依赖注入的思想现在已广泛运用在 react、vue 等流行框架中。但依赖注入和解决模块化问题还差得远。 CommonJS (2009): 真正解决模块化问题,从 node 端逐渐发力到前端,前端需要使用构建工具模拟。 Amd (2009): 都是同一时期的产物,这个方案主要解决前端动态加载依赖,相比 commonJs,体积更小,按需加载。 Umd (2011): 兼容了 CommonJS 与 Amd,其核心思想是,如果在 commonjs 环境(存在 module.exports,不存在 define),将函数执行结果交给 module.exports 实现 Commonjs,否则用 Amd 环境的 define,实现 Amd。 Labeled Modules (2012): 和 Commonjs 很像了,没什么硬伤,但生不逢时,碰上 Commonjs 与 Amd,那只有被人遗忘的份了。 YModules (2013): 既然都出了 Commonjs Amd,文章还列出了此方案,一定有其独到之处。其核心思想在于使用 provide 取代 return,可以控制模块结束时机,处理异步结果;拿到第二个参数 module,修改其他模块的定义(虽然很有拓展性,但用在项目里是个搅屎棍)。 ES2015 Modules (2015): 就是我们现在的模块化方案,还没有被浏览器实现,大部分项目已通过 babel 或 typescript 提前体验。 3 精读本次提出独到观点的同学有:流形,黄子毅,苏里约,camsong,杨森,淡苍,留影,精读由此归纳。 从语言层面到文件层面的模块化 从 1999 年开始,模块化探索都是基于语言层面的优化,真正的革命从 2009 年 CommonJS 的引入开始,前端开始大量使用预编译。 这篇文章所提供的模块化历史的方案都是逻辑模块化,从 CommonJS 方案开始前端把服务端的解决方案搬过来之后,算是看到标准物理与逻辑统一的模块化。但之后前端工程不得不引入模块化构建这一步。正是这一步给前端开发无疑带来了诸多的不便,尤其是现在我们开发过程中经常为了优化这个工具带了很多额外的成本。 从 CommonJS 之前其实都只是封装,并没有一套模块化规范,这个就有些像类与包的概念。我在 10 年左右用的最多的还是 YUI2,YUI2 是用 namespace 来做模块化的,但有很多问题没有解决,比如多版本共存,因此后来 YUI3 出来了。 YUI().use('node', 'event', function (Y) { // The Node and Event modules are loaded and ready to use. // Your code goes here!}); YUI3 的 sandbox 像极了差不多同时出现的 AMD 规范,但早期 yahoo 在前端圈的影响力还是很大的,而 requirejs 到 2011 年才诞生,因此圈子不是用着 YUI 要不就自己封装一套 sandbox,内部使用 jQuery。 为什么模块化方案这么晚才成型,可能早期应用的复杂度都在后端,前端都是非常简单逻辑。后来 Ajax 火了之后,web app 概念的开始流行,前端的复杂度也呈指数级上涨,到今天几乎和后端接近一个量级。工程发展到一定阶段,要出现的必然会出现。 前端三剑客的模块化展望 从 js 模块化发展史,我们还看到了 css html 模块化方面的严重落后,如今依赖编译工具的模块化增强在未来会被标准所替代。 原生支持的模块化,解决 html 与 css 模块化问题正是以后的方向。 再回到 JS 模块化这个主题,开头也说到是为了构建 scope,实则提供了业务规范标准的输入输出的方式。但文章中的 JS 的模块化还不等于前端工程的模块化,Web 界面是由 HTML、CSS 和 JS 三种语言实现,不论是 CommonJS 还是 AMD 包括之后的方案都无法解决 CSS 与 HTML 模块化的问题。 对于 CSS 本身它就是 global scope,因此开发样式可以说是喜忧参半。近几年也涌现把 HTML、CSS 和 JS 合并作模块化的方案,其中 react/css-modules 和 vue 都为人熟知。当然,这一点还是非常依赖于 webpack/rollup 等构建工具,让我们意识到在 browser 端还有很多本质的问题需要推进。 对于 css 模块化,目前不依赖预编译的方式是 styled-component,通过 js 动态创建 class。而目前 css 也引入了与 js 通信的机制 与 原生变量支持。未来 css 模块化也很可能是运行时的,所以目前比较看好 styled-component 的方向。 对于 html 模块化,小尤最近爆出与 chrome 小组调研 html Modules,如果 html 得到了浏览器,编辑器的模块化支持,未来可能会取代 jsx 成为最强大的模块化、模板语言。 对于 js 模块化,最近出现的 <script type="module"> 方式,虽然还没有得到浏览器原生支持,但也是我比较看好的未来趋势,这样就连 webpack 的拆包都不需要了,直接把源代码传到服务器,配合 http2.0 完美抛开预编译的枷锁。 上述三种方案都不依赖预编译,分别实现了 html、css、js 模块化,相信这就是未来。 模块化标准推进速度仍然缓慢 2015 年提出的标准,在 17 年依然没有得到实现,即便在 nodejs 端。 这几年 TC39 对语言终于重视起来了,慢慢有动作了,但针对模块标准制定的速度,与落实都非常缓慢,与 javascript 越来越流行的趋势逐渐脱节。nodejs 至今也没有实现 ES2015 模块化规范,所有 jser 都处在构建工具的阴影下。 Http 2.0 对 js 模块化的推动 js 模块化定义的再美好,浏览器端的支持粒度永远是瓶颈,http 2.0 正是考虑到了这个因素,大力支持了 ES 2015 模块化规范。 幸运的是,模块化构建将来可能不再需要。随着 HTTP/2 流行起来,请求和响应可以并行,一次连接允许多个请求,对于前端来说宣告不再需要在开发和上线时再做编译这个动作。 几年前,模块化几乎是每个流行库必造的轮子(YUI、Dojo、Angular),大牛们自己爽的同时其实造成了社区的分裂,很难积累。有了 ES2015 Modules 之后,JS 开发者终于可以像 Java 开始者十年前一样使用一致的方式愉快的互相引用模块。 不过 ES2015 Modules 也只是解决了开发的问题,由于浏览器的特殊性,还是要经过繁琐打包的过程,等 Import,Export 和 HTTP 2.0 被主流浏览器支持,那时候才是彻底的模块化。 Http 2.0 后就不需要构建工具了吗? 看到大家基本都提到了 HTTP/2,对这项技术解决前端模块化及资源打包等工程问题抱有非常大的期待。很多人也认为 HTTP/2 普及后,基本就没有 Webpack 什么事情了。 不过 Webpack 作者 @sokra 在他的文章 webpack & HTTP/2 里提到了一个新的 Webpack 插件 AggressiveSplittingPlugin。简单的说,这款插件就是为了充分利用 HTTP/2 的文件缓存能力,将你的业务代码自动拆分成若干个数十 KB 的小文件。后续若其中任意一个文件发生变化,可以保证其他的小 chunk 不需要重新下载。 可见,即使不断的有新技术出现,也依然需要配套的工具来将前端工程问题解决方案推向极致。 模块化是大型项目的银弹吗? 只要遵循了最新模块化规范,就可以使项目具有最好的可维护性吗? Js 模块化的目的是支持前端日益上升的复杂度,但绝不是唯一的解决方案。 分析下 JavaScript 为什么没有模块化,为什么又需要模块化:这个 95 年被设计出来的时候,语言的开发者根本没有想到它会如此的大放异彩,也没有将它设计成一种模块化语言。按照文中的说法,99 年也就是 4 年后开始出现了模块化的需求。如果只有几行代码用模块化是扯,初始的 web 开发业务逻辑都写在 server 端,js 的作用小之又小。而现在 spa 都出现了,几乎所有的渲染逻辑都在前端,如果还是没有模块化的组织,开发过程会越来越难,维护也是更痛苦。 文中已经详细说明了模块化的发展和优劣,这里不准备做过多的讨论。我想说的是,在模块化之后还有一个模块间耦合的问题,如果模块间耦合度大也会降低代码的可重用性或者说复用性。所以也出现了降低耦合的观察者模式或者发布/订阅模式。这对于提升代码重用,复用性和避免单点故障等都很重要。说到这里,还想顺便提一下最近流行起来的响应式编程(RxJS),响应式编程中有一个很核心的概念就是 observable,也就是 Rx 中的流(stream)。它可以被 subscribe,其实也就是观察者设计模式。 补充阅读 JavaScript 模块化七日谈 JavaScript 模块化编程简史(2009-2016) 总结未来前端复杂度不断增加已成定论,随着后端成熟,自然会将焦点转移到前端领域,而且服务化、用户体验越来越重要,前端体验早不是当初能看就行,任何网页的异常、视觉的差异,或文案的模糊,都会导致用户流失,支付中断。前端对公司营收的影响,渐渐与后端服务宕机同等严重,所以前端会越来越重,异常监控,性能检测,工具链,可视化等等都是这几年大家逐渐重视起来的。 我们早已不能将 javascript 早期玩具性质的模块化方案用于现代越来越重要的系统中,前端界必然出现同等重量级的模块化管理方案,感谢 TC39 制定的 ES2015 模块化规范,我们已经离不开它,哪怕所有人必须使用 babel。 话说回来,标准推进的太慢,我们还是把编译工具当作常态,抱着哪怕支持了 ES2015 所有特性,babel 依然还有用的心态,将预编译进行到底。一句话,模块化仍在路上。js 模块化的矛头已经对准了 css 与 html,这两位元老也该向前卫的 js 学习学习了。 未来 css、html 的模块化会自立门户,还是赋予 js 更强的能力,让两者的模块化依附于 js 的能力呢?目前 html 有自立门户的苗头(htmlModules),而 css 迟迟没有改变,社区出现的 styled-component 已经用 js 将 css 模块化得很好了,最新 css 规范也支持了与 js 的变量通信,难道希望依附于 js 吗?这里希望得到大家更广泛的讨论。 我也认同,毕竟压缩、混淆、md5、或者利用 nonce 属性对 script 标签加密,都离不开本地构建工具。 据说 http2 的优化中,有个最佳文件大小与数量的比例,那么还是脱离不了构建工具,前端未来会越来越复杂,同时也越来越美好。 至此,对于 javascript 模块化讨论已接近尾声,对其优缺点也基本达成了一致。前端复杂度不断提高,促使着模块化的改进,代理(浏览器、node) 的支持程度,与前端特殊性(流量、缓存)可能前端永远也离不开构建工具,新的标准会让这些工作做的更好,同时取代、增强部分特征,前端的未来是更加美好的,复杂度也更高。 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《null _= 0_》","path":"/wiki/WebWeekly/前沿技术/《null _= 0_》.html","content":"当前期刊数: 25 本期精读的文章是:null >= 0? 1 引言 你是如何看待 null >= 0 为 true 这个结果的呢?要么选择勉强接受,要么跟着我一探究竟吧。 2 内容概要大于判断javascript 在判断 a > b 时,记住下面 21 步判断法: 调用 b 的 ToPrimitive(hit Number) 方法. 调用 a 的 ToPrimitive(hit Number) 方法. 如果此时 Result(1) 与 Result(2) 都是字符串,跳到步骤 16. 调用 ToNumber(Result(1)). 调用 ToNumber(Result(2)). 如果 Result(4) 为 NaN, return undefined. 如果 Result(5) 为 NaN, return undefined. 如果 Result(4) 和 Result(5) 是相同的数字,return false. 如果 Result(4) 为 +0, Result(5) 为 -0, return false. 如果 Result(4) 为 -0, Result(5) 为 +0, return false. 如果 Result(4) 为 +∞, return false. 如果 Result(5) 为 +∞, return true. 如果 Result(5) 为 -∞, return false. 如果 Result(4) 为 -∞, return true. 如果 Result(4) 的数值大小小于 Result(5),return true,否则 return false. 如果 Result(2) 是 Result(1) 的前缀 return false. (比如 “ab” 是 “abc” 的前缀) 如果 Result(1) 是 Result(2) 的前缀, return true. 找到一个位置 k,使得 a[k] 与 b[k] 不相等. 取 m 为 a[k] 字符的数值. 取 n 为 b[k] 字符的数值. 如果 m < n, return true,否则 return false. ToPrimitive 会按照顺序优先使用存在的值:valueOf()、toString(),如果都没有,会抛出异常。ToPrimitive(hit Number) 表示隐转数值类型 所以 null > 0 结果为 false。 等于判断现在看看 a == b 时的表现(三等号会严格判断类型,两等号反而是最复杂的情况)。 如果 a 与 b 的类型相同,则: 如果 Type(b) 为 undefined,return true. 如果 Type(b) 为 null,return true. 如果 Type(b) 为 number,则: 如果 b 为 NaN,return false. 如果 a 为 NaN,return false. 如果 a 与 b 数值相同,return true. 如果 a 为 +0,b 为 -0,return true. 如果 a 为 -0,b 为 +0,return true. 否则 return false. 如果 Type(b) 为 string,且 a 与 b 是完全相同的字符串,return true,否则 return false. 如果 Type(b) 是 boolean,如果都是 true 或 false,return true,否则 return false. 如果 a 与 b 是同一个对象引用,return true,否则 return false. 如果 a 为 null,b 为 undefined,return true. 如果 a 为 undefined,b 为 null,return true. 如果 Type(a) 为 number,Type(b) 为 string,返回 a == ToNumber(b) 的结果. 如果 Type(a) 为 string,Type(b) 为 number,返回 ToNumber(a) == b 的结果. 如果 Type(a) 为 boolean,返回 ToNumber(a) == b 的结果. 如果 Type(b) 为 boolean,返回 a == ToNumber(b) 的结果. 如果 Type(a) 是 string 或 number,且 Type(b) 是对象类型,返回 a == ToPrimitive(b) 的结果. 如果 Type(a) 是对象类型,且 Type(b) 是 string 或 number,返回 ToPrimitive(a) == b 的结果. 否则 return false. 所以 null == 0 走到了第 10 步,返回了默认的 false。 大于等于判断javascript 是这么定义大于等于判断的: 如果 a < b 为 false,则 a >= b 为 true 所以 null >= 0 为 true,因为 null < 0 是 false. 3 精读关于 toPrimitive拓展一下,我们可以通过 Symbol.toPrimitive 定义某个 class 的 ToPrimitive 行为,比如: class AnswerToLifeAndUniverseAndEverything { [Symbol.toPrimitive](hint) { if (hint === 'string') { return 'Like, 42, man'; } else if (hint === 'number') { return 42; } else { // when pushed, most classes (except Date) // default to returning a number primitive return 42; } }} 还有不按套路出牌的情况?按上面的道理,我们可以举一反三: {} >= {} // true 可是这是为何呢? null >= {} // false 仔细读过上文应该不难发现,如果 ToPrimitive(hit Number) 出现了 NaN,将直接 return undefined,也就是打印出 false,而下面是隐式转换表,{} 的结果是 NaN,因此结果是 false。 4 总结NaN 在 javascript 是个特殊存在,只有 isNaN 可以准确判断到它,而且使用它进行比较判断时,会直接 return false. javascript 隐式转换有一套优先级规则,而且不同值的隐式转换还需要对照表记忆,还存在 ToPrimitive(hint Number) ToPrimitive(hint String) ToPrimitive(hint Boolean) 三份表,记忆起来确实有点复杂。 因此推荐比较判断时,尽量使用 ===,通过 Typescript Flow 等强类型语言约束变量类型,尽量不要做不同类型变量间的比较。 讨论地址是:精读《null >= 0?》 · Issue ##36 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《pipe operator for JavaScript》","path":"/wiki/WebWeekly/前沿技术/《pipe operator for JavaScript》.html","content":"当前期刊数: 228 Pipe Operator (|>) for JavaScript 提案给 js 增加了 Pipe 语法,这次结合 A pipe operator for JavaScript: introduction and use cases 文章一起深入了解这个提案。 概述Pipe 语法可以将函数调用按顺序打平。如下方函数,存在三层嵌套,但我们解读时需要由内而外阅读,因为调用顺序是由内而外的: const y = h(g(f(x))) Pipe 可以将其转化为正常顺序: const y = x |> f(%) |> g(%) |> h(%) Pipe 语法有两种风格,分别来自 Microsoft 的 F## 与 Facebook 的 Hack。 之所以介绍这两个,是因为 js 提案首先要决定 “借鉴” 哪种风格。js 提案最终采用了 Hack 风格,因此我们最好把 F## 与 Hack 的风格都了解一下,并对其优劣做一个对比,才能知其所以然。 Hack Pipe 语法Hack 语法相对冗余,在 Pipe 时使用 % 传递结果: '123.45' |> Number(%) 这个 % 可以用在任何地方,基本上原生 js 语法都支持: value |> someFunction(1, %, 3) // function callsvalue |> %.someMethod() // method callvalue |> % + 1 // operatorvalue |> [%, 'b', 'c'] // Array literalvalue |> {someProp: %} // object literalvalue |> await % // awaiting a Promisevalue |> (yield %) // yielding a generator value F## Pipe 语法F## 语法相对精简,默认不使用额外符号: '123.45' |> Number 但在需要显式声明参数时,为了解决上一个 Pipe 结果符号从哪来的问题,写起来反而更为复杂: 2 |> $ => add2(1, $) await 关键字 - Hack 优F## 在 await yield 时需要特殊语法支持,而 Hack 可以自然的使用 js 内置关键字。 // Hackvalue |> await %// F##value |> await F## 代码看上去很精简,但实际上付出了高昂的代价 - await 是一个仅在 Pipe 语法存在的关键字,而非普通 await 关键字。如果不作为关键字处理,执行逻辑就变成了 await(value) 而不是 await value。 解构 - F## 优正因为 F## 繁琐的变量声明,反而使得在应对解构场景时得心应手: // F##value |> ({ a, b }) => someFunction(a, b)// Hackvalue |> someFunction(%.a, %.b) Hack 也不是没有解构手段,只是比较繁琐。要么使用立即调用函数表达式 IIFE: value |> (({ a, b }) => someFunction(a, b))(%) 要么使用 do 关键字: value |> do { const { a, b } = %; someFunction(a, b) } 但 Hack 虽败犹荣,因为解决方法都使用了 js 原生提供的语法,所以反而体现出与 js 已有生态亲和性更强,而 F## 之所以能优雅解决,全都归功于自创的语法,这些语法虽然甜,但割裂了 js 生态,这是 F## like 提案被放弃的重要原因之一。 潜在改进方案虽然选择了 Hack 风格,但 F## 与 Hack 各有优劣,所以列了几点优化方案。 利用 Partial Application Syntax 提案降低 F## 传参复杂度F## 被诟病的一个原因是传参不如 Hack 简单: // Hack2 |> add2(1, %)// F##2 |> $ => add2(1, $) 但如果利用处于 stage1 的提案 Partial Application Syntax 可以很好的解决问题。 这里就要做一个小插曲了。js 对柯里化没有原生支持,但 Partial Application Syntax 提案解决了这个问题,语法如下: const add = (x, y) => x + y;const addOne = add~(1, ?);addOne(2); // 3 即利用 fn~(?, arg) 的语法,将任意函数柯里化。这个特性解决 F## 传参复杂问题简直绝配,因为 F## 的每一个 Pipe 都要求是一个函数,我们可以将要传参的地方记为 ?,这样返回值还是一个函数,完美符合 F## 的语法: // F##2 |> add~(1, ?) 上面的例子拆开看就是: const addOne = add~(1, ?)2 |> addOne 想法很美好,但 Partial Application Syntax 得先落地。 融合 F## 与 Hack 语法在简单情况下使用 F##,需要利用 % 传参时使用 Hack 语法,两者混合在一起写就是: const resultArray = inputArray |> filter(%, str => str.length >= 0) // Hack |> map(%, str => '['+str+']') // Hack |> console.log // F## 不过这个 提案 被废弃了。 创造一个新的操作符如果用 |> 表示 Hack 语法,用 |>> 表示 F## 语法呢? const resultArray = inputArray |> filter(%, str => str.length >= 0) // Hack |> map(%, str => '['+str+']') // Hack |>> console.log // F## 也是看上去很美好,但这个特性连提案都还没有。 如何用现有语法模拟 Pipe即便没有 Pipe Operator (|>) for JavaScript 提案,也可以利用 js 现有语法模拟 Pipe 效果,以下是几种方案。 Function.pipe()利用自定义函数构造 pipe 方法,该语法与 F## 比较像: const resultSet = Function.pipe( inputSet, $ => filter($, x => x >= 0) $ => map($, x => x * 2) $ => new Set($)) 缺点是不支持 await,且存在额外函数调用。 使用中间变量说白了就是把 Pipe 过程拆开,一步步来写: const filtered = filter(inputSet, x => x >= 0)const mapped = map(filtered, x => x * 2)const resultSet = new Set(mapped) 没什么大问题,就是比较冗余,本来可能一行能解决的问题变成了三行,而且还声明了三个中间变量。 复用变量改造一下,将中间变量变成复用的: let $ = inputSet$ = filter($, x => x >= 0)$ = map($, x => x * 2)const resultSet = new Set($) 这样做可能存在变量污染,可使用 IIFE 解决。 精读Pipe Operator 语义价值非常明显,甚至可以改变编程的思维方式,在串行处理数据时非常重要,因此命令行场景非常常见,如: cat "somefile.txt" | echo 因为命令行就是典型的输入输出场景,而且大部分都是单输入、单输出。 在普通代码场景,特别是处理数据时也需要这个特性,大部分具有抽象思维的代码都进行了各种类型的管道抽象,比如: const newValue = pipe( value, doSomething1, doSomething2, doSomething3) 如果 Pipe Operator (|>) for JavaScript 提案通过,我们就不需要任何库实现 pipe 动作,可以直接写成: const newValue = value |> doSomething1(%) |> doSomething2(%) |> doSomething3(%) 这等价于: const newValue = doSomething3(doSomething2(doSomething1(value))) 显然,利用 pipe 特性书写处理流程更为直观,执行逻辑与阅读逻辑是一致的。 实现 pipe 函数即便没有 Pipe Operator (|>) for JavaScript 提案,我们也可以一行实现 pipe 函数: const pipe = (...args) => args.reduce((acc, el) => el(acc)) 但要实现 Hack 参数风格是不可能的,顶多实现 F## 参数风格。 js 实现 pipe 语法的考虑从 提案 记录来看,F## 失败有三个原因: 内存性能问题。 await 特殊语法。 割裂 js 生态。 其中割裂 js 生态是指因 F## 语法的特殊性,如果有太多库按照其语法实现功能,可能导致无法被非 Pipe 语法场景所复用。 甚至还有部分成员反对 隐性编程(Tacit programming),以及柯里化提案 Partial Application Syntax,这些会使 js 支持的编程风格与现在差异过大。 看来处于鄙视链顶端的编程风格在 js 是否支持不是能不能的问题,而是想不想的问题。 pipe 语法的弊端下面是普通 setState 语法: setState(state => ({ ...state, value: 123})) 如果改为 immer 写法如下: setState(produce(draft => draft.value = 123)) 得益于 ts 类型自动推导,在内层 produce 里就已经知道 value 是数值类型,此时如果输入字符串会报错,而如果其在另一个上下文的 setState 内,类型也会随着上下文的变化而变化。 但如果写成 pipe 模式: produce(draft => draft.value = 123) |> setState 因为先考虑的是如何修改数据,此时还不知道后面的 pipe 流程是什么,所以 draft 的类型无法确定。所以 pipe 语法仅适用于固定类型的数据处理流程。 总结pipe 直译为管道,潜在含义是 “数据像流水线一样被处理”,也可以形象理解为每个函数就是一个不同的管道,显然下一个管道要处理上一个管道的数据,并将结果输出到下一个管道作为输入。 合适的管道数量与体积决定了一条生产线是否高效,过多的管道类型反而会使流水线零散而杂乱,过少的管道会让流水线笨重不易拓展,这是工作中最大的考验。 讨论地址是:精读《pipe operator for JavaScript》· Issue ##395 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《pnpm》","path":"/wiki/WebWeekly/前沿技术/《pnpm》.html","content":"当前期刊数: 253 pnpm 全称是 “Performant NPM”,即高性能的 npm。它结合软硬链接与新的依赖组织方式,大大提升了包管理的效率,也同时解决了 “幻影依赖” 的问题,让包管理更加规范,减少潜在风险发生的可能性。 使用 pnpm 很容易,可以使用 npm 安装: npm i pnpm -g 之后便可用 pnpm 代替 npm 命令了,比如最重要的安装包步骤,可以使用 pnpm i 代替 npm i,这样就算把 pnpm 使用起来了。 pnpm 的优势用一个比较好记的词描述 pnpm 的优势那就是 “快、准、狠”: 快:安装速度快。 准:安装过的依赖会准确复用缓存,甚至包版本升级带来的变化都只 diff,绝不浪费一点空间,逻辑上也严丝合缝。 狠:直接废掉了幻影依赖,在逻辑合理性与含糊的便捷性上,毫不留情的选择了逻辑合理性。 而带来这些优势的点子,全在官网上的这张图上: 所有 npm 包都安装在全局目录 ~/.pnpm-store/v3/files 下,同一版本的包仅存储一份内容,甚至不同版本的包也仅存储 diff 内容。 每个项目的 node_modules 下有 .pnpm 目录以打平结构管理每个版本包的源码内容,以硬链接方式指向 pnpm-store 中的文件地址。 每个项目 node_modules 下安装的包结构为树状,符合 node 就近查找规则,以软链接方式将内容指向 node_modules/.pnpm 中的包。 所以每个包的寻找都要经过三层结构:node_modules/package-a > 软链接 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a > 硬链接 ~/.pnpm-store/v3/files/00/xxxxxx。 经过这三层寻址带来了什么好处呢?为什么是三层,而不是两层或者四层呢? 依赖文件三层寻址的目的第一层接着上面的例子思考,第一层寻找依赖是 nodejs 或 webpack 等运行环境/打包工具进行的,他们的在 node_modules 文件夹寻找依赖,并遵循就近原则,所以第一层依赖文件势必要写在 node_modules/package-a 下,一方面遵循依赖寻找路径,一方面没有将依赖都拎到上级目录,也没有将依赖打平,目的就是还原最语义化的 package.json 定义:即定义了什么包就能依赖什么包,反之则不行,同时每个包的子依赖也从该包内寻找,解决了多版本管理的问题,同时也使 node_modules 拥有一个稳定的结构,即该目录组织算法仅与 package.json 定义有关,而与包安装顺序无关。 如果止步于此,这就是 npm@2.x 的包管理方案,但正因为 npm@2.x 包管理方案最没有歧义,所以第一层沿用了该方案的设计。 第二层从第二层开始,就要解决 npm@2.x 设计带来的问题了,主要是包复用的问题。所以第二层的 node_modules/package-a > 软链接 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a 寻址利用软链接解决了代码重复引用的问题。相比 npm@3 将包打平的设计,软链接可以保持包结构的稳定,同时用文件指针解决重复占用硬盘空间的问题。 若止步于此,也已经解决了一个项目内的包管理问题,但项目不止一个,多个项目对于同一个包的多份拷贝还是太浪费,因此要进行第三步映射。 第三层第三层映射 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a > 硬链接 ~/.pnpm-store/v3/files/00/xxxxxx 已经脱离当前项目路径,指向一个全局统一管理路径了,这正是跨项目复用的必然选择,然而 pnpm 更进一步,没有将包的源码直接存储在 pnpm-store,而是将其拆分为一个个文件块,这在后面详细讲解。 幻影依赖幻影依赖是指,项目代码引用的某个包没有直接定义在 package.json 中,而是作为子依赖被某个包顺带安装了。代码里依赖幻影依赖的最大隐患是,对包的语义化控制不能穿透到其子包,也就是包 a@patch 的改动可能意味着其子依赖包 b@major 级别的 Break Change。 正因为这三层寻址的设计,使得第一层可以仅包含 package.json 定义的包,使 node_modules 不可能寻址到未定义在 package.json 中的包,自然就解决了幻影依赖的问题。 但还有一种更难以解决的幻影依赖问题,即用户在 Monorepo 项目根目录安装了某个包,这个包可能被某个子 Package 内的代码寻址到,要彻底解决这个问题,需要配合使用 Rush,在工程上通过依赖问题检测来彻底解决。 peer-dependences 安装规则pnpm 对 peer-dependences 有一套严格的安装规则。对于定义了 peer-dependences 的包来说,意味着为 peer-dependences 内容是敏感的,潜台词是说,对于不同的 peer-dependences,这个包可能拥有不同的表现,因此 pnpm 针对不同的 peer-dependences 环境,可能对同一个包创建多份拷贝。 比如包 bar peer-dependences 依赖了 baz^1.0.0 与 foo^1.0.0,那我们在 Monorepo 环境两个 Packages 下分别安装不同版本的包会如何呢? - foo-parent-1 - bar@1.0.0 - baz@1.0.0 - foo@1.0.0- foo-parent-2 - bar@1.0.0 - baz@1.1.0 - foo@1.0.0 结果是这样(引用官网文档例子): node_modules└── .pnpm ├── foo@1.0.0_bar@1.0.0+baz@1.0.0 │ └── node_modules │ ├── foo │ ├── bar -> ../../bar@1.0.0/node_modules/bar │ ├── baz -> ../../baz@1.0.0/node_modules/baz │ ├── qux -> ../../qux@1.0.0/node_modules/qux │ └── plugh -> ../../plugh@1.0.0/node_modules/plugh ├── foo@1.0.0_bar@1.0.0+baz@1.1.0 │ └── node_modules │ ├── foo │ ├── bar -> ../../bar@1.0.0/node_modules/bar │ ├── baz -> ../../baz@1.1.0/node_modules/baz │ ├── qux -> ../../qux@1.0.0/node_modules/qux │ └── plugh -> ../../plugh@1.0.0/node_modules/plugh ├── bar@1.0.0 ├── baz@1.0.0 ├── baz@1.1.0 ├── qux@1.0.0 ├── plugh@1.0.0 可以看到,安装了两个相同版本的 foo,虽然内容完全一样,但却分别拥有不同的名称:foo@1.0.0_bar@1.0.0+baz@1.0.0、foo@1.0.0_bar@1.0.0+baz@1.1.0。这也是 pnpm 规则严格的体现,任何包都不应该有全局副作用,或者考虑好单例实现,否则可能会被 pnpm 装多次。 硬连接与软链接的原理要理解 pnpm 软硬链接的设计,首先要复习一下操作系统文件子系统对软硬链接的实现。 硬链接通过 ln originFilePath newFilePath 创建,如 ln ./my.txt ./hard.txt,这样创建出来的 hard.txt 文件与 my.txt 都指向同一个文件存储地址,因此无论修改哪个文件,都因为直接修改了原始地址的内容,导致这两个文件内容同时变化。进一步说,通过硬链接创建的 N 个文件都是等效的,通过 ls -li ./ 查看文件属性时,可以看到通过硬链接创建的两个文件拥有相同的 inode 索引: ls -li ./84976912 -rw-r--r-- 2 author staff 489 Jun 9 15:41 my.txt84976912 -rw-r--r-- 2 author staff 489 Jun 9 15:41 hard.txt 其中第三个参数 2 表示该文件指向的存储地址有两个硬链接引用。硬链接如果要指向目录就麻烦多了,第一个问题是这样会导致文件的父目录有歧义,同时还要将所有子文件都创建硬链接,实现复杂度较高,因此 Linux 并没有提供这种能力。 软链接通过 ln -s originFilePath newFilePath 创建,可以认为是指向文件地址指针的指针,即它本身拥有一个新的 inode 索引,但文件内容仅包含指向的文件路径,如: 84976913 -rw-r--r-- 2 author staff 489 Jun 9 15:41 soft.txt -> my.txt 源文件被删除时,软链接也会失效,但硬链接不会,软链接可以对文件夹生效。因此 pnpm 虽然采用了软硬结合的方式实现代码复用,但软链接本身也几乎不会占用多少额外的存储空间,硬链接模式更是零额外内存空间占用,所以对于相同的包,pnpm 额外占用的存储空间可以约等于零。 全局安装目录 pnpm-store 的组织方式pnpm 在第三层寻址时采用了硬链接方式,但同时还留下了一个问题没有讲,即这个硬链接目标文件并不是普通的 NPM 包源码,而是一个哈希文件,这种文件组织方式叫做 content-addressable(基于内容的寻址)。 简单来说,基于内容的寻址比基于文件名寻址的好处是,即便包版本升级了,也仅需存储改动 Diff,而不需要存储新版本的完整文件内容,在版本管理上进一步节约了存储空间。 pnpm-store 的组织方式大概是这样的: ~/.pnpm-store- v3 - files - 00 - e4e13870602ad2922bfc7.. - e99f6ffa679b846dfcbb1.. .. - 01 .. - .. .. - ff .. 也就是采用文件内容寻址,而非文件位置寻址的存储方式。之所以能采用这种存储方式,是因为 NPM 包一经发布内容就不会再改变,因此适合内容寻址这种内容固定的场景,同时内容寻址也忽略了包的结构关系,当一个新包下载下来解压后,遇到相同文件 Hash 值时就可以抛弃,仅存储 Hash 值不存在的文件,这样就自然实现了开头说的,pnpm 对于同一个包不同的版本也仅存储其增量改动的能力。 总结pnpm 通过三层寻址,既贴合了 node_modules 默认寻址方式,又解决了重复文件安装的问题,顺便解决了幻影依赖问题,可以说是包管理的目前最好的创新,没有之一。 但其苛刻的包管理逻辑,使我们单独使用 pnpm 管理大型 Monorepo 时容易遇到一些符合逻辑但又觉得别扭的地方,比如如果每个 Package 对于同一个包的引用版本产生了分化,可能会导致 Peer Deps 了这些包的包产生多份实例,而这些包版本的分化可能是不小心导致的,我们可能需要使用 Rush 等 Monorepo 管理工具来保证版本的一致性。 讨论地址是:精读《pnpm》· Issue ##435 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《proposal-extractors》","path":"/wiki/WebWeekly/前沿技术/《proposal-extractors》.html","content":"当前期刊数: 258 proposal-extractors 是一个关于解构能力增强的提案,支持在直接解构时执行自定义逻辑。 概述const [first, second] = arr;const { name, age } = obj; 以上就是解构带来的便利,如果没有解构语法,相同的实现我们需要这么做: const first = arr[0];const second = arr[1];const name = obj.name;const age = obj.age; 但上面较为原始的方法可以在对象赋值时进行一些加工,比如: const first = someLogic(arr[0]);const second = someLogic(arr[1]);const name = someLogic(obj.name);const age = someLogic(obj.age); 解构语法就没那么简单了,想要实现类似的效果,需要退化到多行代码实现,冗余度甚至超过非解构语法: const [first: firstTemp, second: secondTemp] = arrconst {name: nameTemp, age: ageTemp} = objconst first = someLogic(firstTemp)const second = someLogic(secondTemp)const name = someLogic(nameTemp)const age = someLogic(ageTemp) proposal-extractors 提案就是用来解决这个问题,希望保持解构语法优雅的同时,加一些额外逻辑: const SomeLogic(first, second) = arr // 解构数组const SomeLogic{name, age} = obj // 解构对象 稍稍有点别扭,使用 () 解构数组,使用 {} 解构对象。我们再看 SomeLogic 的定义: const SomeLogic = { [Symbol.matcher]: (value) => { return { matched: true, value: value.toString() + "特殊处理" }; },}; 这样我们拿到的 first、second、name、age 变量就都变成字符串了,且后缀增加了 '特殊处理' 这四个字符。 为什么用 () 表示数组解构呢?主要是防止出现赋值歧义: // 只有一项时,[] 到底是下标含义还是解构含义呢?const SomeLogic[first] = arr 精读proposal-extractors 提案提到了 BindingPattern 与 AssignmentPattern: // binding patternsconst Foo(y) = x; // instance-array destructuringconst Foo{y} = x; // instance-object destructuringconst [Foo(y)] = x; // nestingconst [Foo{y}] = x; // ..const { z: Foo(y) } = x; // ..const { z: Foo{y} } = x; // ..const Foo(Bar(y)) = x; // ..const X.Foo(y) = x; // qualified names (i.e., a.b.c)// assignment patternsFoo(y) = x; // instance-array destructuringFoo{y} = x; // instance-object destructuring[Foo(y)] = x; // nesting[Foo{y}] = x; // ..({ z: Foo(y) } = x); // ..({ z: Foo{y} } = x); // ..Foo(Bar(y)) = x; // ..X.Foo(y) = x; // qualified names (i.e., a.b.c) 从例子来看,BindingPattern 相比 AssignmentPattern 只是前面多了一个 const 标记。那么 BindingPattern 与 AssignmentPattern 分别表示什么含义呢? BindingPattern 与 AssignmentPattern 是解构模式下的特有概念。 BindingPattern 需要用 const let 等变量定义符描述。比如下面的例子,生成了 a、d 两个新对象,我们称这两个对象被绑定了(binding)。 const obj = { a: 1, b: { c: 2 } };const { a, b: { c: d },} = obj;// Two variables are bound: `a` and `d` AssignmentPattern 无需用变量定义符描述,只能用已经定义好的变量,所以可以理解为对这些已经存在的变量赋值。比如下面的例子,将对象的 a b 分别绑定到数组 numbers 的每一项。 const numbers = [];const obj = { a: 1, b: 2 };({ a: numbers[0], b: numbers[1] } = obj); proposal-extractors 是针对解构的增强提案,自然也要支持 BindingPattern 与 AssignmentPattern 这两种模式。 总结proposal-extractors 提案维持了解构的优雅(自定义解构仍仅需一行代码),但引入了新语法(自定义处理函数、对数组使用 () 号解构的奇怪记忆),在过程式代码中并没有太大的优势,但结合其他特性可能有意想不到的便利,比如结合 Declarations-in-Conditionals 后可以快速判断是否是某个类的实例并同时解构 if / while let bindings。 讨论地址是:精读《proposal-extractors》· Issue ##443 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《react-rxjs》","path":"/wiki/WebWeekly/前沿技术/《react-rxjs》.html","content":"当前期刊数: 46 本周精读的代码是 react-rxjs。 1 引言本周精读的是 git 仓库 - react-rxjs,它给出了一个思路,让 rxjs 更好的与 react 结合。 2 概述View 层View 层设计没商量,至少应该看不出 rxjs 的痕迹,它做到了: // view.tsxexport default (props) => ( <div> {props.number} <button onClick={props.inc}>+</button> <button onClick={props.dec}>-</button> </div>) Container 层链接 View 与 Store 的层,同样也看不出 rxjs 的痕迹: import { inject } from 'react-rxjs'import store$, { inc, dec } from './store'import MyComponent from './view'const props = (storeState: number): MyProps => ({ number: storeState, inc, dec})export default inject(store$, props)(MyComponent) 这里 storeState 就是 store 全部数据,注意 react-rxjs 是多 store 思想,所以 inject 第一个参数传入不同的 store,组件就会与对应的 store 绑定。 Store 层这里代码就很有意思了,必须将 rxjs 与 action 对接起来: import { createStore } from 'react-rxjs'const inc$ = new Subject<void>()const dec$ = new Subject<void>()const reducer$: Observable<(state: number) => number> = Observable.merge( inc$.map(() => (state: number) => state + 1), dec$.map(() => (state: number) => state - 1))const store$ = createStore("example", reducer$, 0)export inc = () => inc$.next()export dec = () => dec$.next()export default store$ 如果转换成 redux 思维,action 就是下面的 inc 函数: const inc$ = new Subject<void>()export inc = () => inc$.next() reducer 就是下面的 reducer$,整个 store 对应 Observable.merge,switch case 的地方被 inc$、dec$ 自动识别出来了。 const reducer$: Observable<(state: number) => number> = Observable.merge( inc$.map(() => (state: number) => state + 1), dec$.map(() => (state: number) => state - 1)) 笔者优化一下代码结构,让 action 与 reducer 看起来更内聚: const inc$ = new Subject<void>()export inc = () => inc$.next()const incReducer = inc$.map(() => (state: number) => state + 1)const dec$ = new Subject<void>()export dec = () => dec$.next()const decReducer = dec$.map(() => (state: number) => state - 1)const reducer$: Observable<(state: number) => number> = Observable.merge( incReducer, decReducer) 3 精读让我们聚焦到 Action 部分: const inc$ = new Subject<void>()export inc = () => inc$.next() 可以看出,Action 功能很弱,我们只能触发 reducer,却无法 mergeMap 等流汇总的处理。 上周和叔叔讨论了 Rxjs 的一种代码组织方式:将 Rxjs 切成两部分使用,第一部分是数据源的抽象、聚合;第二部分是,对已经聚合过的单一数据源订阅后进行处理,这里处理过程只能包含对这个数据源的操作,不能再 merge 其他数据源。 这恰恰也是 Rxjs 在数据流中发挥的两大作用。分别是抽象,或者说是对副作用的隔离;以及强大的流处理能力。 react-rxjs 虽然代码看上去很简单,但 Action 部分没有足够的抽象能力,举例子说就是无法进行流的 merge,因为 Subject 自己就是一个事件触发器,想要进行流合并,必须发生在 reducer 中: const incReducer = inc$.merge(requestUser$).map(() => (state: number) => state + 1) 但这样就丧失了 Action 与 Reducer 一一对应的关系,因为 reducer 可以擅自 merge 任意数据流,那就完全不受控制了。 所以回到第二个约定:对已经聚合过的单一数据源订阅后进行处理,此时不能包含任何 merge 操作。 可以总结一下,react-rxjs 的方式是解决了 rxjs 与 react 结合繁琐的问题,但如果遵守开发约定,Action 的功能就很弱,无法进行进一步抽象,如果不遵守开发约定,就可以解决 Action 能力弱的问题,但带来的是 Reducer 与 Action 脱离关系,这在项目维护中是不可接受的。 所以 react-rxjs 是一个看上去方便,但实践起来会发现怎么都不舒服的方案。 redux-observable我们再看 redux-observable 这个库,就很容易理解为什么这么做了。 const pingEpic = action$ => action$.filter(action => action.type === 'PING') .delay(1000) // Asynchronously wait 1000ms then continue .mapTo({ type: 'PONG' });// later...dispatch({ type: 'PING' }); redux-observable 只有一个数据源,在 dispatch 的过程触发事件,进入 action 逻辑。其实每个 action 都源自对同一个数据源的订阅,通过 action.type 的筛选来确保执行了正确的 action。 所以每次 dispatch,包括 mapTo 也是 dispatch,都会触发数据源的事件派发,然后所有 Action 因为订阅了这个数据源,所以都会执行,最后被 .filter 逻辑拦截后,执行到正确的 Action。整个 Action 间调用的链路打个比方,就像我们使用微信一样,当触发任何消息,都会将其送到后台服务器,服务器给所有客户端发消息(假设系统设计的有问题,没有在服务端做 filter。。),每个客户端根据用户名做一个筛选,如果不是发给自己的消息,就过滤掉。然后,任何人与人之间的消息发送,都会走一遍这个流程。 reducer 与 redux 的 reducer 一摸一样: const pingReducer = (state = { isPinging: false }, action) => { switch (action.type) { case 'PING': return { isPinging: true }; case 'PONG': return { isPinging: false }; default: return state; }} redux-observable 的设计比 react-rxjs 好在哪呢?我认为好在遵循了上面总结的两条经验: 第一部分是数据源的抽象、聚合;第二部分是,对已经聚合过的单一数据源订阅后进行处理,这里处理过程只能包含对这个数据源的操作,不能再 merge 其他数据源。 Action 之间的 dispatch 就是第一部分对数据源的整合,这里包括所有副作用。Reducer 只需要挑选合适的 ActionType 绑定,这样确保了 Reducer 中处理操作一定是对单一数据源的,不存在对其他数据源 merge,换句话说就是和 Action 一一对应。 所以整体来看,我认为 redux-observable 比 react-rxjs 要靠谱。 但是 react-rxjs 抛开了 redux 繁琐的样板代码,而 redux-observable 样板代码只会比 react-redux 要多。如果要投入项目使用,比较好的方式是按照 dva 的思路,减少 redux-observable 的样板代码。 4 总结最后稍稍聊一下 cyclejs,因为用这个库,基本就脱离了 react 生态,我们 react 系开发者只能干瞪眼看看。 cyclejs 就一个目的,解决 react + rxjs 中阴魂不散的循环依赖问题:视图的回调函数可以产生数据源(observable),但视图又可能依赖这个数据源。 就是解决 A 依赖 B,B 又依赖 A 的问题,而且它做到了: function main(sources) { const input$ = sources.DOM.select('.field').events('input') const name$ = input$.map(ev => ev.target.value).startWith('') const vdom$ = name$.map(name => div([ label('Name:'), input('.field', {attrs: {type: 'text'}}), hr(), h1('Hello ' + name), ]) ) return { DOM: vdom$ }} 可以看到,最让我们不舒服的部分,就是 sources.DOM.select('.field') 和 input('.field') 这个循环节,为什么呢?因为初始化函数还没有返回 DOM 节点,为啥就能选中 DOM 节点?而且还作为参数参与这个 DOM 的生成。 可惜 React 无法解决这个问题,我们只能通过预定义数据源来解决:首先定义一个数据源,DOM 订阅它,Action 触发时找到这个数据源,手动调用 .next()。或者 redux-observable 这样,全局只有一个数据源。 总的来说,笔者认为 rxjs 还是难以落地到 react 业务代码中,究其本质,就是没有 cyclejs 这种机制解决数据源引起的循环依赖问题。 5 更多讨论 讨论地址是:精读《react-rxjs》 · Issue ##65 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《recoil》","path":"/wiki/WebWeekly/前沿技术/《recoil》.html","content":"当前期刊数: 152 1 引言Recoil 是 Facebook 公司出的数据流管理方案,有一定思考的价值。 Recoil 是基于 Immutable 的数据流管理方案,这也是它值得被拿出来看的最重要原因,如果要用 Mutable 方式管理 React 数据流,直接看 mobx-react 就足够了。 然而 React Immutable 特性带来的可预测性非常利于调试和维护: 断点调试时变量的值与当前执行位置无关,已创建过的值不会突然 Mutable 突变,非常可预测。 在 React 框架下组件更新机制单一,只有引用变化才触发重渲染,而没有 Mutable 模式下 ForceUpdate 的心智负担。 当然 Immutable 模式下存在一定编码心智负担,所以各有优劣。 但 Recoil 和 Redux 一样,并不代表 React 官方数据流管理方案,因此不用带着官方光环去看它。 2 简介Recoil 解决 React 全局数据流管理的问题,采用分散管理原子状态的设计模式,支持派生数据与异步查询,在基本功能上可以覆盖 Redux。 状态作用域和 Redux 一样,全局数据流管理需要存在作用域 RecoilRoot: import React from "react";import { RecoilRoot } from "recoil";function App() { return ( <RecoilRoot> <CharacterCounter /> </RecoilRoot> );} RecoilRoot 在被嵌套时,最内层的 RecoilRoot 会覆盖外层的配置及状态值。 定义数据与 Redux 集中定义 initState 不同,Recoil 采用 atom 以分散方式定义数据: const textState = atom({ key: "textState", default: "",}); 其中 key 必须在 RecoilRoot 作用域内唯一,也可以认为是 state 树打平时 key 必须唯一的要求。 default 定义默认值,既然数据定义分散了,默认值定义也是分散的。 读取数据与 Redux 的 Connect 或 useSelector 类似,Recoil 采用 Hooks 方式读取数据: import { useRecoilValue } from "recoil";function App() { const text = useRecoilValue(textState);} useRecoilValue 与 useSetRecoilState 都可以获取数据,区别是 useRecoilState 还可以获取写数据的函数: import { useRecoilState } from "recoil";function App() { const [text, setText] = useRecoilState(useRecoilState);} 修改数据与 Redux 集中定义纯函数 reducer 修改数据不同,Recoil 采用 Hooks 方式写数据。 除了上面提到的 useRecoilState 之外,还有一个 useSetRecoilState 可以仅获取写函数: 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 用于只读不订阅场景: 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 支持派生值,这是比较有特色的功能: 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 函数写成异步即可: 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> );} 异步状态可以被 Suspense 捕获。 异步过程报错可以被 ErrorBoundary 捕获。 如果不想用 Suspense 阻塞异步,可以换 useRecoilValueLoadable 这个 API 在当前组件内管理异步状态: 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 本身是原子化状态管理的,所以这个问题相对好解决: const myMultipliedState = selectorFamily({ key: "MyMultipliedNumber", get: (multiplier) => ({ get }) => { return get(myNumberState) * multiplier; },});function MyComponent() { const number = useRecoilValue(myMultipliedState(100));} 当外部传参 multiplier 与依赖值 myNumberState 不变时,就不会重新计算。 Recoil 在 get 与 set 函数定义 Atom 时,内部会自动生成依赖,这个部分做的比较好。 依赖外部变量使用了 Family 后缀,比如 selector -> selectorFamily;atom -> atomFamily。 3 精读Recoil 以原子化方式对状态进行分离管理,确实比较契合 Immutable 的编程模式,尤其在缓存处理时非常亮眼,但编程领域中,优势换一个角度看往往就变成了劣势,我们还是要客观评价一下 Recoil。 Immutable 心智负担API 较多,在简介中也提到了,这可能是 Immutable 自带的硬伤,而不仅仅是 Recoil 的问题。 Immutable 模式中,对数据流只有读与写两种诉求,而申明式编程讲究的是数据变化后 UI 自动 Rerender,那么对数据的读自然而然就被赋予了订阅其变化后触发 Rerender 的期待,但是写与读不同,为什么 setState 强调用回调方式写数据?因为回调方式的写不依赖读,有写诉求的组件没必要与读挂上钩,也就是写组件的地方不一定要订阅对应数据。 Recoil 提供了 useRecoilState 作为读写双重 API,仅在既读又写的场景使用,而 useRecoilValue 仅仅是为了简化 API,替换为 useRecoilState 不会有性能损失,而 useSetRecoilValue 则必须认真对待,在仅写不读的场景必须严格使用这个 API。 那 useState 为什么默认是读写的?因为 useState 是单组件状态管理的场景,一个定义在组件内的状态不可能只写不读,但 Recoil 是全局状态解决方案,读写分离的场景下,对于只写的组件很有必要脱离对数据的订阅实现性能最大化。 条件访问数据这也是 Hooks 的通病,由于 Hooks 不能写在条件语句中,因此要利用 Hooks 获取一个带有条件判断的数据时,必须回到 selector 模式: 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 状态管理的基本功: 对象的读与写分离,做到最优按需渲染。 派生的值必须严格缓存,并在命中缓存时引用保证严格相等。 原子存储的数据相互无关联,所有关联的数据都使用派生值方式推导。 讨论地址是:精读《recoil》· Issue ##251 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《setState 做了什么》","path":"/wiki/WebWeekly/前沿技术/《setState 做了什么》.html","content":"当前期刊数: 87 1 引言setState 是 React 框架最常用的命令,它是用来更新状态的,这也是 React 框架划时代的功能。 但是 setState 函数是 react 包导出的,他们又是如何与 react-dom react-native react-art 这些包结合的呢? 通过 how-does-setstate-know-what-to-do 这篇文章,可以解开这个秘密。 2 概述setState 函数是在 React.Component 组件中调用的,所以最自然的联想是,更新 DOM 的逻辑在 react 包中实现。 但是 react 却可以和 react-dom react-native react-art 这些包打配合,甚至与 react-dom/server 配合在服务端运行,那可以肯定 react 包中不含有 DOM 更新逻辑。 所以可以推断,平台相关的 UI 更新逻辑分布在平台相关的包里,react 包只做了代理。 React 引擎不在 react 包里从 react 0.14 版本之后,引擎代码就从 react 包中抽离了,react 包仅仅做通用接口抽象。 也就是说,react 包定义了标准的状态驱动模型的 API,而 react-dom react-native react-art 这些包是在各自平台的具体实现。 各平台具体的渲染引擎实现被称为 reconciler,通过这个链接可以看到 react-dom react-native react-art 这三个包的 reconciler 实现。 这说明了 react 包仅告诉你 React 拥有哪些语法,而并不关心如何实现他们,所以我们需要结合 react 包与 react-xxx 一起使用。 对于 context,react 包仅仅会做如下定义: // A bit simplifiedfunction createContext(defaultValue) { let context = { _currentValue: defaultValue, Provider: null, Consumer: null }; context.Provider = { $$typeof: Symbol.for("react.provider"), _context: context }; context.Consumer = { $$typeof: Symbol.for("react.context"), _context: context }; return context;} 具体用到时,由 react-dom 和 react-native 决定用何种方式实现 MyContext.Provider 这个 API。 这也说明了,如果你不同步升级 react 与 react-dom 版本的话,就可能碰到这样的报错:fail saying these types are invalid,原因是 API 定义与实现不匹配。 setState 怎么调用平台实现每个平台对 UI 更新逻辑的实现,会封装在 updater 函数里,所以不同平台代码会为组件添加各自的 updater 实现: // Inside React DOMconst inst = new YourComponent();inst.props = props;inst.updater = ReactDOMUpdater;// Inside React DOM Serverconst inst = new YourComponent();inst.props = props;inst.updater = ReactDOMServerUpdater;// Inside React Nativeconst inst = new YourComponent();inst.props = props;inst.updater = ReactNativeUpdater; 不同于 props, updater 无法被直接调用,因为这个 API 是由 react 引擎在 setState 时调用的: // A bit simplifiedsetState(partialState, callback) { // Use the `updater` field to talk back to the renderer! this.updater.enqueueSetState(this, partialState, callback);} 关系可以这么描述:react -> setState -> updater <- react-dom 等。 HooksHooks 的原理与 setState 类似,当调用 useState 或 useEffect 时,其内部调用如下: // In React (simplified a bit)const React = { // Real property is hidden a bit deeper, see if you can find it! __currentDispatcher: null, useState(initialState) { return React.__currentDispatcher.useState(initialState); }, useEffect(initialState) { return React.__currentDispatcher.useEffect(initialState); } // ...}; ReactDOM 提供了 __currentDispatcher(简化的说法): // In React DOMconst prevDispatcher = React.__currentDispatcher;React.__currentDispatcher = ReactDOMDispatcher;let result;try { result = YourComponent(props);} finally { // Restore it back React.__currentDispatcher = prevDispatcher;} 可以看到,Hooks 的原理与 setState 基本一致,但需要注意 react 与 react-dom 之间传递了 dispatch,虽然你看不到。但这个 dispatch 必须对应到唯一的 React 实例,这就是为什么 Hooks 不允许同时加载多个 React 实例的原因。 和 updater 一样,dispatch 也可以被各平台实现重写,比如 react-debug-hooks 就重写了 dispatcher。 由于需要同时实现 readContext, useCallback, useContext, useEffect, useImperativeMethods, useLayoutEffect, useMemo, useReducer, useRef, useState,工程量比较浩大,建议了解基本架构就足够了,除非你要深入参与 React 生态建设。 3 精读与其他 React 分析文章不同,本文并没有过于刨根问题的上来就剖析 reconciler 实现,而是问了一个最基本的疑问:为什么 setState 来自 react 包,但实现却在 react-dom 里?React 是如何实现这个 magic 的? 通过这个疑问,我们了解了 React 更上层的抽象能力,如何用一个包制定规范,用 N 包去实现它。 接口的力量在日常编程中,接口也拥有的强大力量,下面举几个例子。 UI 组件跨三端的接口由于 RN、Weex、Flutter 的某些不足,越来越多的人选择 “一个思想三端实现” 的方式做跨三端的 UI 组件,这样既兼顾了性能,又可以照顾到平台差异性,对不同平台组件细节做定制优化。 要实施这个方案,最大问题就是接口约定。一定要保证三套实现遵循同一套 API 接口,业务代码才可以实现 “针对任意一个平台编写,自动移植到其他平台”。 比较常用的做法是,通过一套统一的 API 文件约束,固定组件的输入输出,不同平台的组件做平台具体实现。这个思想和 React 如出一辙。 当然 RN 这些框架本身也是同一接口在不同平台实现的典型,只是做的不够彻底,JS 与 Native 的通信导致了性能不如原生。 通用数据查询服务通用数据查询服务也比较流行,通过磨平各数据库语法,让用户通过一套 SQL 查询各种类型数据库的数据。 这个方案中,一套通用的查询语法就类似 React 定义的 API,执行阶段会转化为各数据库平台的 SQL 方言。 小程序融合方案现在这种方案很火。通过基于 template 或者 jsx 的语法,一键发布到各平台小程序应用。 这种方案一定会抽象一套通用语法,甚至几乎等价与 react 与 react-dom 的关系:所有符合规范的语法,转化为各小程序平台的实现。 4 总结这种分平台实现方案与跨平台方案还是有很大区别的,像 JAVA 虚拟机本质还是一套实现方案。而分平台的实现可以带来最原生的性能与体验,同样收到的约束也最大,应该其 API 应该是所有平台支持的一个子集。 另外,这种方案不仅可以用于 一套规范,不同平台的实现,甚至可以用在 “同一平台的实现”。 无论是公司还是开源节界,都有许多重复的轮子或者平台,如果通过技术委员会约定一套平台的实现规范,大家都遵循这个规范开发平台,那未来就比较好做收敛,或者说收敛的第一步都是先统一 API 规范。 留下一个思考题:还有没有利用 setState 规范与实现分离的思想案例?欢迎留下你的答案。 讨论地址是:精读《setState 做了什么》 · Issue ##122 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《snowpack》","path":"/wiki/WebWeekly/前沿技术/《snowpack》.html","content":"当前期刊数: 153 1 引言基于 webpack 构建的大型项目开发速度已经非常慢了,前端开发者已经逐渐习惯忍受超过 100 秒的启动时间,超过 30 秒的 reload 时间。即便被寄予厚望的 webpack5 内置了缓存机制也不会得到质的提升。但放到十年前,等待时间是几百毫秒。 好在浏览器支持了 ESM import 模块化加载方案,终于原生支持了文件模块化,这使得本地构建不再需要处理模块化关系并聚合文件,这甚至可以将构建时间从 30 秒降低到 300 毫秒。 当然基于 ESM import 的构建框架不止 snowpack 一个,还有比如基于 vue 的 vite,因为浏览器支持模块化是一个标准,而不与任何框架绑定,未来任何构建工具都会基于此特性开发,这意味着在未来的五年,前端构建一定会回到十年前的速度,这个趋势是明显、确定的。 ESM import 带来的最直观的改变有下面三点: node_modules 完全不需要参与到构建过程,仅这一点就足以让构建效率提升至少 10 倍。 模块化交给浏览器管理,修改任何组件都只需做单文件编译,时间复杂度永远是 O(1),reload 时间与项目大小无关。 浏览器完全模块化加载文件,不存在资源重复加载问题,这种原生的 TreeShaking 还可以做到访问文件时再编译,做到单文件级别的按需构建。 所以可以说 ESM import 模式下的开发效率,能做到与十年前修改 HTML 单文件的零构建效率几乎相当。 2 简介 & 精读snowpack 核心特征: 开发模式启动仅需 50ms 甚至更少。 热更新速度非常快。 构建时可以结合任何 bundler,比如 webpack。 内置支持 TS、JSX、CSS Modules 等。 支持自定义构建脚本以及三方插件。 安装yarn add --dev snowpack 通过 snowpack.config.json 文件配置,并能自动读取 babel.config.json 生效 babel 插件。 开发调试调试 snowpack dev,编译 snowpack build,会自动以 src/index 作为应用入口进行编译。 snowpack dev 命令几乎是零耗时的,因为文件仅会在被浏览器访问时进行按需编译,因此构建速度是理想的最快速。 当浏览器访问文件时,snowpack 会将文件做如下转换: // 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 相同: 也可以指定以 webpack 作为构建器: // snowpack.config.json{ // Optimize your production builds with Webpack "plugins": [ [ "@snowpack/plugin-webpack", { /* ... */ } ] ]} 除了默认构建方式之外,还支持自定义文件处理,通过 snowpack.config.json 配置 scripts 指定: { "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 就不一一列举了,详细可以见 官方文档。 我们可以从构建命令体会到 snowpack 的理念,将源码以流式方式编译后,直接部署到本地 server 提供的 URL 地址,浏览器通过一个 main 入口以 ESM import 的方式加载这些文件。 所以所有加载与构建逻辑都是按需的,snowpack 要做的只是将本地文件逐个构建好并启动本地服务给浏览器调用。 前端开发离不开 node_modules,snowpack 通过 snowpack install 的方式支持了这一点。 snowpack install这个命令已经被 snowpack dev 内置了,所以 snowpack install 仅用来理解原理。 以下是 snowpack install 执行的结果: ✔ 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 这个静态资源地址的引用: import * as ReactDOM from "react-dom";// 转换import * as React from "/web_modules/react.js"; 但同时可以看到 snowpack 对前端生态的高要求,如果某些包通过 webpack 别名设置了一些 magic 映射,就无法通过文件路径直接映射,所以 snowpack 生态成熟需要一段时间,但模块标准化一定是趋势,不规范的包在未来几年内会逐步被淘汰。 2020 年适合使用 snowpack 吗答案是还不适合用在生产环境。 当然用在开发环境还是可以的,但需要承担三个风险: 开发与生产环境构建结果不一致的风险。 项目生态存在非 ESM import 模块化包而导致大量适配成本的风险。 项目存在大量 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《useEffect 完全指南》","path":"/wiki/WebWeekly/前沿技术/《useEffect 完全指南》.html","content":"当前期刊数: 96 1. 引言工具型文章要跳读,而文学经典就要反复研读。如果说 React 0.14 版本带来的各种生命周期可以类比到工具型文章,那么 16.7 带来的 Hooks 就要像文学经典一样反复研读。 Hooks API 无论从简洁程度,还是使用深度角度来看,都大大优于之前生命周期的 API,所以必须反复理解,反复实践,否则只能停留在表面原地踏步。 相比 useState 或者自定义 Hooks 而言,最有理解难度的是 useEffect 这个工具,希望借着 a-complete-guide-to-useeffect 一文,深入理解 useEffect。 原文非常长,所以概述是笔者精简后的。作者是 Dan Abramov,React 核心开发者。 2. 概述unLearning,也就是学会忘记。你之前的学习经验会阻碍你进一步学习。 想要理解好 useEffect 就必须先深入理解 Function Component 的渲染机制,Function Component 与 Class Component 功能上的不同在上一期精读 精读《Function VS Class 组件》 已经介绍,而他们还存在思维上的不同: Function Component 是更彻底的状态驱动抽象,甚至没有 Class Component 生命周期的概念,只有一个状态,而 React 负责同步到 DOM。 这是理解 Function Component 以及 useEffect 的关键,后面还会详细介绍。 由于原文非常非常的长,所以笔者精简下内容再重新整理一遍。原文非常长的另一个原因是采用了启发式思考与逐层递进的方式写作,笔者最大程度保留这个思维框架。 从几个疑问开始假设读者有比较丰富的前端 & React 开发经验,并且写过一些 Hooks。那么你也许觉得 Function Component 很好用,但美中不足的是,总有一些疑惑萦绕在心中,比如: 🤔 如何用 useEffect 代替 componentDidMount? 🤔 如何用 useEffect 取数?参数 [] 代表什么? 🤔useEffect 的依赖可以是函数吗?是哪些函数? 🤔 为何有时候取数会触发死循环? 🤔 为什么有时候在 useEffect 中拿到的 state 或 props 是旧的? 第一个问题可能已经自问自答过无数次了,但下次写代码的时候还是会忘。笔者也一样,而且在三期不同的精读中都分别介绍过这个问题: 精读《React Hooks》 精读《怎么用 React Hooks 造轮子》 精读《Function VS Class 组件》 但第二天就忘记了,因为 用 Hooks 实现生命周期确实别扭。 讲真,如果想彻底解决这个问题,就请你忘掉 React、忘掉生命周期,重新理解一下 Function Component 的思维方式吧! 上面 5 个问题的解答就不赘述了,读者如果有疑惑可以去 原文 TLDR 查看。 要说清楚 useEffect,最好先从 Render 概念开始理解。 每次 Render 都有自己的 Props 与 State可以认为每次 Render 的内容都会形成一个快照并保留下来,因此当状态变更而 Rerender 时,就形成了 N 个 Render 状态,而每个 Render 状态都拥有自己固定不变的 Props 与 State。 看下面的 count: function Counter() { const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </div> );} 在每次点击时,count 只是一个不会变的常量,而且也不存在利用 Proxy 的双向绑定,只是一个常量存在于每次 Render 中。 初始状态下 count 值为 0,而随着按钮被点击,在每次 Render 过程中,count 的值都会被固化为 1、2、3: // During first renderfunction Counter() { const count = 0; // Returned by useState() // ... <p>You clicked {count} times</p>; // ...}// After a click, our function is called againfunction Counter() { const count = 1; // Returned by useState() // ... <p>You clicked {count} times</p>; // ...}// After another click, our function is called againfunction Counter() { const count = 2; // Returned by useState() // ... <p>You clicked {count} times</p>; // ...} 其实不仅是对象,函数在每次渲染时也是独立的。这就是 Capture Value 特性,后面遇到这种情况就不会一一展开,只描述为 “此处拥有 Capture Value 特性”。 每次 Render 都有自己的事件处理解释了为什么下面的代码会输出 5 而不是 3: const App = () => { const [temp, setTemp] = React.useState(5); const log = () => { setTimeout(() => { console.log("3 秒前 temp = 5,现在 temp =", temp); }, 3000); }; return ( <div onClick={() => { log(); setTemp(3); // 3 秒前 temp = 5,现在 temp = 5 }} > xyz </div> );}; 在 log 函数执行的那个 Render 过程里,temp 的值可以看作常量 5,执行 setTemp(3) 时会交由一个全新的 Render 渲染,所以不会执行 log 函数。而 3 秒后执行的内容是由 temp 为 5 的那个 Render 发出的,所以结果自然为 5。 原因就是 temp、log 都拥有 Capture Value 特性。 每次 Render 都有自己的 EffectsuseEffect 也一样具有 Capture Value 的特性。 useEffect 在实际 DOM 渲染完毕后执行,那 useEffect 拿到的值也遵循 Capture Value 的特性: function Counter() { const [count, setCount] = useState(0); useEffect(() => { document.title = `You clicked ${count} times`; }); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </div> );} 上面的 useEffect 在每次 Render 过程中,拿到的 count 都是固化下来的常量。 如何绕过 Capture Value利用 useRef 就可以绕过 Capture Value 的特性。可以认为 ref 在所有 Render 过程中保持着唯一引用,因此所有对 ref 的赋值或取值,拿到的都只有一个最终状态,而不会在每个 Render 间存在隔离。 function Example() { const [count, setCount] = useState(0); const latestCount = useRef(count); useEffect(() => { // Set the mutable latest value latestCount.current = count; setTimeout(() => { // Read the mutable latest value console.log(`You clicked ${latestCount.current} times`); }, 3000); }); // ...} 也可以简洁的认为,ref 是 Mutable 的,而 state 是 Immutable 的。 回收机制在组件被销毁时,通过 useEffect 注册的监听需要被销毁,这一点可以通过 useEffect 的返回值做到: useEffect(() => { ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange); };}); 在组件被销毁时,会执行返回值函数内回调函数。同样,由于 Capture Value 特性,每次 “注册” “回收” 拿到的都是成对的固定值。 用同步取代 “生命周期”Function Component 不存在生命周期,所以不要把 Class Component 的生命周期概念搬过来试图对号入座。Function Component 仅描述 UI 状态,React 会将其同步到 DOM,仅此而已。 既然是状态同步,那么每次渲染的状态都会固化下来,这包括 state props useEffect 以及写在 Function Component 中的所有函数。 然而舍弃了生命周期的同步会带来一些性能问题,所以我们需要告诉 React 如何比对 Effect。 告诉 React 如何对比 Effects虽然 React 在 DOM 渲染时会 diff 内容,只对改变部分进行修改,而不是整体替换,但却做不到对 Effect 的增量修改识别。因此需要开发者通过 useEffect 的第二个参数告诉 React 用到了哪些外部变量: useEffect(() => { document.title = "Hello, " + name;}, [name]); // Our deps 直到 name 改变时的 Rerender,useEffect 才会再次执行。 然而手动维护比较麻烦而且可能遗漏,因此可以利用 eslint 插件自动提示 + FIX: 不要对 Dependencies 撒谎如果你明明使用了某个变量,却没有申明在依赖中,你等于向 React 撒了谎,后果就是,当依赖的变量改变时,useEffect 也不会再次执行: useEffect(() => { document.title = "Hello, " + name;}, []); // Wrong: name is missing in dep 这看上去很蠢,但看看另一个例子呢? function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>;} setInterval 我们只想执行一次,所以我们自以为聪明的向 React 撒了谎,将依赖写成 []。 “组件初始化执行一次 setInterval,销毁时执行一次 clearInterval,这样的代码符合预期。” 你心里可能这么想。 但是你错了,由于 useEffect 符合 Capture Value 的特性,拿到的 count 值永远是初始化的 0。相当于 setInterval 永远在 count 为 0 的 Scope 中执行,你后续的 setCount 操作并不会产生任何作用。 诚实的代价笔者稍稍修改了一下标题,因为诚实是要付出代价的: useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id);}, [count]); 你老实告诉 React “嘿,等 count 变化后再执行吧”,那么你会得到一个好消息和两个坏消息。 好消息是,代码可以正常运行了,拿到了最新的 count。 坏消息有: 计时器不准了,因为每次 count 变化时都会销毁并重新计时。 频繁 生成/销毁 定时器带来了一定性能负担。 怎么既诚实又高效呢?上述例子使用了 count,然而这样的代码很别扭,因为你在一个只想执行一次的 Effect 里依赖了外部变量。 既然要诚实,那只好 想办法不依赖外部变量: useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id);}, []); setCount 还有一种函数回调模式,你不需要关心当前值是什么,只要对 “旧的值” 进行修改即可。这样虽然代码永远运行在第一次 Render 中,但总是可以访问到最新的 state。 将更新与动作解耦你可能发现了,上面投机取巧的方式并没有彻底解决所有场景的问题,比如同时依赖了两个 state 的情况: useEffect(() => { const id = setInterval(() => { setCount(c => c + step); }, 1000); return () => clearInterval(id);}, [step]); 你会发现不得不依赖 step 这个变量,我们又回到了 “诚实的代价” 那一章。当然 Dan 一定会给我们解法的。 利用 useEffect 的兄弟 useReducer 函数,将更新与动作解耦就可以了: const [state, dispatch] = useReducer(reducer, initialState);const { count, step } = state;useEffect(() => { const id = setInterval(() => { dispatch({ type: "tick" }); // Instead of setCount(c => c + step); }, 1000); return () => clearInterval(id);}, [dispatch]); 这就是一个局部 “Redux”,由于更新变成了 dispatch({ type: "tick" }) 所以不管更新时需要依赖多少变量,在调用更新的动作里都不需要依赖任何变量。 具体更新操作在 reducer 函数里写就可以了。在线 Demo。 Dan 也将 useReducer 比作 Hooks 的的金手指模式,因为这充分绕过了 Diff 机制,不过确实能解决痛点! 将 Function 挪到 Effect 里在 “告诉 React 如何对比 Diff” 一章介绍了依赖的重要性,以及对 React 要诚实。那么如果函数定义不在 useEffect 函数体内,不仅可能会遗漏依赖,而且 eslint 插件也无法帮助你自动收集依赖。 你的直觉会告诉你这样做会带来更多麻烦,比如如何复用函数?是的,只要不依赖 Function Component 内变量的函数都可以安全的抽出去: // ✅ Not affected by the data flowfunction getFetchUrl(query) { return "https://hn.algolia.com/api/v1/search?query=" + query;} 但是依赖了变量的函数怎么办? 如果非要把 Function 写在 Effect 外面呢?如果非要这么做,就用 useCallback 吧! function Parent() { const [query, setQuery] = useState("react"); // ✅ Preserves identity until query changes const fetchData = useCallback(() => { const url = "https://hn.algolia.com/api/v1/search?query=" + query; // ... Fetch data and return it ... }, [query]); // ✅ Callback deps are OK return <Child fetchData={fetchData} />;}function Child({ fetchData }) { let [data, setData] = useState(null); useEffect(() => { fetchData().then(setData); }, [fetchData]); // ✅ Effect deps are OK // ...} 由于函数也具有 Capture Value 特性,经过 useCallback 包装过的函数可以当作普通变量作为 useEffect 的依赖。useCallback 做的事情,就是在其依赖变化时,返回一个新的函数引用,触发 useEffect 的依赖变化,并激活其重新执行。 useCallback 带来的好处在 Class Component 的代码里,如果希望参数变化就重新取数,你不能直接比对取数函数的 Diff: componentDidUpdate(prevProps) { // 🔴 This condition will never be true if (this.props.fetchData !== prevProps.fetchData) { this.props.fetchData(); }} 反之,要比对的是取数参数是否变化: componentDidUpdate(prevProps) { if (this.props.query !== prevProps.query) { this.props.fetchData(); }} 但这种代码不内聚,一旦取数参数发生变化,就会引发多处代码的维护危机。 反观 Function Component 中利用 useCallback 封装的取数函数,可以直接作为依赖传入 useEffect,**useEffect 只要关心取数函数是否变化,而取数参数的变化在 useCallback 时关心,再配合 eslint 插件的扫描,能做到 依赖不丢、逻辑内聚,从而容易维护。** 更更更内聚除了函数依赖逻辑内聚之外,我们再看看取数的全过程: 一个 Class Component 的普通取数要考虑这些点: 在 didMount 初始化发请求。 在 didUpdate 判断取数参数是否变化,变化就调用取数函数重新取数。 在 unmount 生命周期添加 flag,在 didMount didUpdate 两处做兼容,当组件销毁时取消取数。 你会觉得代码跳来跳去的,不仅同时关心取数函数与取数参数,还要在不同生命周期里维护多套逻辑。那么换成 Function Component 的思维是怎样的呢? 笔者利用 useCallback 对原 Demo 进行了改造。 function Article({ id }) { const [article, setArticle] = useState(null); // 副作用,只关心依赖了取数函数 useEffect(() => { // didCancel 赋值与变化的位置更内聚 let didCancel = false; async function fetchData() { const article = await API.fetchArticle(id); if (!didCancel) { setArticle(article); } } fetchData(); return () => { didCancel = true; }; }, [id]); // ...} 当你真的理解了 Function Component 理念后,就可以理解 Dan 的这句话:虽然 useEffect 前期学习成本更高,但一旦你正确使用了它,就能比 Class Component 更好的处理边缘情况。 useEffect 只是底层 API,未来业务接触到的是更多封装后的上层 API,比如 useFetch 或者 useTheme,它们会更好用。 3. 精读原文有 9000+ 单词,非常长。但同时也配合一些 GIF 动图生动解释了 Render 执行原理,如果你想用好 Function Component 或者 Hooks,这篇文章几乎是必读的,因为没有人能猜到什么是 Capture Value,然而不能理解这个概念,Function Component 也不能用的顺手。 重新捋一下这篇文章的思路: 从介绍 Render 引出 Capture Value 的特性。 拓展到 Function Component 一切均可 Capture,除了 Ref。 从 Capture Value 角度介绍 useEffect 的 API。 介绍了 Function Component 只关注渲染状态的事实。 引发了如何提高 useEffect 性能的思考。 介绍了不要对 Dependencies 撒谎的基本原则。 从不得不撒谎的特例中介绍了如何用 Function Component 思维解决这些问题。 当你学会用 Function Component 理念思考时,你逐渐发现它的一些优势。 最后点出了逻辑内聚,高阶封装这两大特点,让你同时领悟到 Hooks 的强大与优雅。 可以看到,比写框架更高的境界是发现代码的美感,比如 Hooks 本是为增强 Function Component 能力而创造,但在抛出问题-解决问题的过程中,可以不断看到规则限制,换一个角度打破它,最后体会到整体的逻辑之美。 从这篇文章中也可以读到如何增强学习能力。作者告诉我们,学会忘记可以更好的理解。我们不要拿生命周期的固化思维往 Hooks 上套,因为那会阻碍我们理解 Hooks 的理念。 另补充一些零碎的内容。 useEffect 还有什么优势useEffect 在渲染结束时执行,所以不会阻塞浏览器渲染进程,所以使用 Function Component 写的项目一般都拥有更好的性能。 自然符合 React Fiber 的理念,因为 Fiber 会根据情况暂停或插队执行不同组件的 Render,如果代码遵循了 Capture Value 的特性,在 Fiber 环境下会保证值的安全访问,同时弱化生命周期也能解决中断执行时带来的问题。 useEffect 不会在服务端渲染时执行。 由于在 DOM 执行完毕后才执行,所以能保证拿到状态生效后的 DOM 属性。 4. 总结最后,提两个最重要的点,来检验你有没有读懂这篇文章: Capture Value 特性。 一致性。将注意放在依赖上(useEffect 的第二个参数 []),而不是关注何时触发。 你对 “一致性” 有哪些更深的解读呢?欢迎留言回复。 讨论地址是:精读《useEffect 完全指南》 · Issue ##138 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《useRef 与 createRef 的区别》","path":"/wiki/WebWeekly/前沿技术/《useRef 与 createRef 的区别》.html","content":"当前期刊数: 141 1 引言useRef 是常用的 API,但还有一个 createRef 的 API,你知道他们的区别吗?通过 React.useRef and React.createRef: The Difference 这篇文章,你可以了解到何时该使用它们。 2 概述其实原文就阐述了这样一个事实:useRef 仅能用在 FunctionComponent,createRef 仅能用在 ClassComponent。 第一句话是显然的,因为 Hooks 不能用在 ClassComponent。 第二句话的原因是,createRef 并没有 Hooks 的效果,其值会随着 FunctionComponent 重复执行而不断被初始化: 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 所有渲染周期的实例,理论上在任何地方都可以做修改,比如: function App() { const valueRef = React.useRef(); valueRef.current += 1; return <div />;} 但其实上面的修改方式是不规范的,React 官方文档里要求我们避免在 Render 函数中直接修改 Ref,请先看下面的 FunctionComponent 生命周期图: 从图中可以发现,在 Render phase 阶段是不允许做 “side effects” 的,也就是写副作用代码,这是因为这个阶段可能会被 React 引擎随时取消或重做。 修改 Ref 属于副作用操作,因此不适合在这个阶段进行。我们可以看到,在 Commit phase 阶段可以做这件事,或者在回调函数中做(脱离了 React 生命周期)。 当然有一种情况是可以的,即 懒初始化: 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》 Ref 不仅可以拿到组件引用、创建一个 Mutable 副作用对象,还可以配合 useEffect 存储一个较老的值,最常用来拿到 previousProps,React 官方利用 Ref 封装了一个简单的 Hooks 拿到上一次的值: function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }); return ref.current;} 由于 useEffect 在 Render 完毕后才执行,因此 ref 的值在当前 Render 中永远是上一次 Render 时候的,我们可以利用它拿到上一次 Props: function App(props) { const preProps = usePrevious(props);} 要实现这个功能,还是要归功于 ref 可以将值 “在各个不同的 Render 闭包中传递的特性”。最后,不要滥用 Ref,Mutable 引用越多,对 React 来说可维护性一般会越差。 4 总结你还挖掘了 useRef 哪些有意思的使用方式?欢迎在评论区留言。 讨论地址是:精读《useRef 与 createRef 的区别》 · Issue ##236 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《When You “Git” in Trouble- a Version Control Story》","path":"/wiki/WebWeekly/前沿技术/《When You “Git” in Trouble- a Version Control Story》.html","content":"当前期刊数: 36 本期精读的文章是:When You “Git” in Trouble - a Version Control Story 1 引言git 作为目前最流行的版本控制系统,它拥有众多的用户并管理着数量庞大的实际软件项目。 本文主要通过一个实际的例子来描述,当项目(代码)仓库出现问题时如何使用 git 进行有效的维护,并分享一些 git 使用经验以及分析 git 的内部实现机制。 2 内容概要我们在管理项目代码仓库时,经常会碰到一些棘手的问题,比如:在使用 git 的过程中,有时会不小心丢失 commit 信息。如果实际场景中发生了类似的问题,该如何使用 git 找回丢失的 commit 呢? 首先,在 git 中想要找回丢失的 commit,就需要找出那些 commit 的 SHA,然后添加一个指向它的分支。 由于 git 会记录下每次修改 HEAD 的操作,当执行提交或修改分支的命令时 reflog 就会更新(执行 git update-ref 命令也可以更新 reflog),因此可以执行 git reflog 命令来查看当前的状态。 $ git reflog1a410ef HEAD@{0}: 1a410efbd13591db07496601ebc7a059dd55cfe9: updating HEADab1afef HEAD@{1}: ab1afef80fac8e34258ff41fc1b867c702daa24b: updating HEAD 执行 git log -g 命令可以查看更加详细 reflog 的日志。 $ git log -gcommit 1a410efbd13591db07496601ebc7a059dd55cfe9Reflog: HEAD@{0}Reflog message: updating HEAD third commitcommit ab1afef80fac8e34258ff41fc1b867c702daa24bReflog: HEAD@{1}Reflog message: updating HEAD modified repo a bit 确定丢失的 commit 后,就可以在这个 commit 上创建一个新分支将其恢复过来。比如,在 commit (ab1afef) 上创建 new-branch 分支,即可找回丢失的 commit 数据。 $ git branch new-branch ab1afef$ git log --pretty=oneline new-branchab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit484a59275031909e19aadb7c92262719cfcdf19a added repo.rb1a410efbd13591db07496601ebc7a059dd55cfe9 third commitcac0cab538b970a37ea1e769cbbde608743bc96d second commitfdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit 如果引起 commit 丢失的原因并没有记录在 reflog 中,即没有在 .git/logs/ 中(因为 reflog 数据是保存在 .git/logs/ 目录下的),这样就会导致丢失的 commit 不会被任何东西引用。这种情况应该如何恢复 commit 数据呢? 这里可以执行 git fsck –full 命令,该命令会检查仓库的数据完整性,会显示所有未被其他对象引用的所有对象。 $ git fsck --fulldangling blob d670460b4b4aece5915caf5c68d12f560a9fe3e4dangling commit ab1afef80fac8e34258ff41fc1b867c702daa24bdangling tree aea790b9a58f6cf6f2804eeac9f0abbe9631e4c9dangling blob 7108f7ecb345ee9d0084193f147cdad4d2998293 这样就可以从 dangling commit 中找到丢失的 commit 信息,确定丢失的 commit 后,就可以在这个 commit 上创建一个新分支将其恢复过来。 至此,文章分享了一个在实际工作中维护项目仓库时经常会遇到的难题的解决思路和方法。 3 精读上述文章中执行 git fsck 命令时出现了 blob、tree、commit 等关键词,虽然我们经常会看到此类的关键词,但可能并不清楚其真正含义,因此,下面内容将从 blob、tree、commit 这三个内部对象去深入分析 git 内部数据结构管理的机制。 git 的版本控制实际就是对文件进行管理和控制,其管理方法就是为每个文件生成(key, object)的结构,并利用 sha-1 加密算法,对每一个文件生成唯一的字符序列作为 hash_key,且文件改变就会生成新的 (key, object)。 执行 git init 初始化一个本地仓库,查看隐藏目录 .git 中的目录结构。其中,objects 目录下只有 info 和 pack 两个空文件夹,没有任何其他文件被记录下来。 blob 对象在当前项目仓库中添加文件 file1.js,执行 git hash-object file1.js,生成一个 40 字符长度的 hash-key 序列:08219db9b0969fa29cf16fd04df4a63964da0b69。 执行 git add file_1.txt,objects 中多了一个 08 对象。 其实是 40 位 hash-key 的前两位作为目录名,后 38 位 作为文件名,这个对象里面的内容就是 file1.js 的内容,可以查看该对象的内容和类型。 执行 git cat-file -p [hash-key] 可以查看已经存在的 object 对象内容; 执行 git cat-file -t [hash-key] 可以查看已经存在的 object 对象类型; git object 有四种类型,第一种类型 blob,用来储存文件内容。 tree 对象blob 对象用于存储对应文件的内容,tree 对象可以理解为存储目录,其树节点信息包含:文件名,hash-key,文件类型、权限等,这样就可以组织整个需要控制文件的结构。 在当前项目仓库中添加文件夹 dir1,在 dir1 中添加文件 file2.js。执行 git add 将内容加入到暂存区,执行 git hash-object dir1/file2.js 查看生成的 hash-key:30d67d4672d5c05833b7192cc77a79eaafb5c7ad。 查看 objects 目录,只新增了一个 30 目录,即 30d67d4672d5c05833b7192cc77a79eaafb5c7ad 对应的 file2.js 文件。 说明 file2.js 文件生成了 hash-key,但 dir1 目录并没有生成 tree 对象,tree 对象是在 commit 的过程中生成的,其生成会根据 .git 目录下的 index 文件的内容来创建。git add 的操作就是将文件的信息保存到 index 文件中,在 commit 时,根据 index 的内容来生成 tree 对象。 执行 git ls-files –stage 命令,可以看到 index 中包含了创建 tree 对象的信息:文件类型(100644)、hash-key、目录结构以及文件名。 进行一次 commit,生成 commit 对象和 tree 对象。其中,master^{tree} 表示 master 分支所指向的 tree 对象。 该 tree 对象是当前对应的目录,目录下有一个名为 dir1 的 tree 对象和 file1.js 的 blob 对象。 查看 dir1 对应的 tree 对象的内容,该 tree 对象只包含 file2.js 的信息。 当前项目的 git 仓库内部结构图如下: commit 对象只有在执行 git commit 时,才会根据 index 记录的内容生成 tree 对象,则 commit 对象中会有两个内容: 代表项目目录的 tree 对象的 key 上一个 commit 的 key 项目中 objects 目录的内容: 目前每个目录里有一个对象,共 5 个对象,之前的总体 tree 图只包含了 4 个对象,执行 git log 查看 commit 记录。 objects 中 65 对应的文件夹里面的文件就是 commit 对象,它指向项目目录 tree 以及上一次的 commit,由于是第一个 commit,因此不存在上一个 commit。 commit 对象内容指向项目目录 tree,所以能获取到一个 commit,可以得到当前完整的文件状况,objects 结构图如下: 再新增一个目录 dir2,该目录下新增文件 file3.js,执行 git add 和 git commit 后查看新的 commit 信息。 新的 commit 指向了上一个 commit,还指向了一个新生成的 tree,该 tree 表示了新的项目目录情况,查看该 tree 的内容。 这个 tree 包含了当前的文件目录和内容,目前对象完整的图如下: 可以看到 commit 对象指向了工作目录 tree,因此只要切换 commit,就可以随意切换对应的版本内容,当前 .git/objects 目录内容如下: git 版本控制住要就是围绕这三类内部对象展开,分别为 blob(记录文件内容),tree(目录结构),commit(工作目录 tree 以及提交历史)。 4 总结本文从一个实际问题即如何使用 git 维护丢失的 commit 入手,并给出相应的解决思路和方案,以及通过 git 内部三种对象来分析其内部工作机制,希望能过解决读者们对 git 存在困惑的地方,同时也希望读者们积极参与每周精度的讨论,各抒己见,分享自身在实际工作中遇到的问题及其解决思路。 讨论地址是:精读《When You “Git” in Trouble - a Version Control Story》 · Issue ##49 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《web reflow》","path":"/wiki/WebWeekly/前沿技术/《web reflow》.html","content":"当前期刊数: 242 网页重排(回流)是阻碍流畅性的重要原因之一,结合 What forces layout / reflow 这篇文章与引用,整理一下回流的起因与优化思考。 借用这张经典图: 网页渲染会经历 DOM -> CSSOM -> Layout(重排 or reflow) -> Paint(重绘) -> Composite(合成),其中 Composite 在 精读《深入了解现代浏览器四》 详细介绍过,是在 GPU 进行光栅化。 那么排除 JS、DOM、CSSOM、Composite 可能导致的性能问题外,剩下的就是我们这次关注的重点,reflow 了。从顺序上可以看出来,重排后一定重绘,而重绘不一定触发重排。 概述什么时候会触发 Layout(reflow) 呢?一般来说,当元素位置发生变化时就会。但也不尽然,因为浏览器会自动合并更改,在达到某个数量或时间后,会合并为一次 reflow,而 reflow 是渲染页面的重要一步,打开浏览器就一定会至少 reflow 一次,所以我们不可能避免 reflow。 那为什么要注意 reflow 导致的性能问题呢?这是因为某些代码可能导致浏览器优化失效,即明明能合并 reflow 时没有合并,这一般出现在我们用 js API 访问某个元素尺寸时,为了保证拿到的是精确值,不得不提前触发一次 reflow,即便写在 for 循环里。 当然也不是每次访问元素位置都会触发 reflow,在浏览器触发 reflow 后,所有已有元素位置都会记录快照,只要不再触发位置等变化,第二次开始访问位置就不会触发 reflow,关于这一点会在后面详细展开。现在要解释的是,这个 ”触发位置等变化“,到底有哪些? 根据 What forces layout / reflow 文档的总结,一共有这么几类: 获得盒子模型信息 elem.offsetLeft, elem.offsetTop, elem.offsetWidth, elem.offsetHeight, elem.offsetParent elem.clientLeft, elem.clientTop, elem.clientWidth, elem.clientHeight elem.getClientRects(), elem.getBoundingClientRect() 获取元素位置、宽高的一些手段都会导致 reflow,不存在绕过一说,因为只要获取这些信息,都必须 reflow 才能给出准确的值。 滚动 elem.scrollBy(), elem.scrollTo() elem.scrollIntoView(), elem.scrollIntoViewIfNeeded() elem.scrollWidth, elem.scrollHeight elem.scrollLeft, elem.scrollTop 访问及赋值 对 scrollLeft 赋值等价于触发 scrollTo,所有导致滚动产生的行为都会触发 reflow,笔者查了一些资料,目前主要推测是滚动条出现会导致可视区域变窄,所以需要 reflow。 focus() elem.focus() (源码) 可以根据源码看一下注释,主要是这一段: // Ensure we have clean style (including forced display locks).GetDocument().UpdateStyleAndLayoutTreeForNode(this) 即在聚焦元素时,虽然没有拿元素位置信息的诉求,但指不定要被聚焦的元素被隐藏或者移除了,此时必须调用 UpdateStyleAndLayoutTreeForNode 重排重绘函数,确保元素状态更新后才能继续操作。 还有一些其他 element API: elem.computedRole, elem.computedName elem.innerText (源码) innerText 也需要重排后才能拿到正确内容。 获取 window 信息 window.scrollX, window.scrollY window.innerHeight, window.innerWidth window.visualViewport.height / width / offsetTop / offsetLeft (源码) 和元素级别一样,为了拿到正确宽高和位置信息,必须重排。 document 相关 document.scrollingElement 仅重绘 document.elementFromPoint elementFromPoint 因为要拿到精确位置的元素,必须重排。 Form 相关 inputElem.focus() inputElem.select(), textareaElem.select() focus、select 触发重排的原因和 elem.focus 类似。 鼠标事件相关 mouseEvt.layerX, mouseEvt.layerY, mouseEvt.offsetX, mouseEvt.offsetY (源码) 鼠标相关位置计算,必须依赖一个正确的排布,所以必须触发 reflow。 getComputedStylegetComputedStyle 通常会导致重排和重绘,是否触发重排取决于是否访问了位置相关的 key 等因素。 Range 相关 range.getClientRects(), range.getBoundingClientRect() 获取选中区域的大小,必须 reflow 才能保障精确性。 SVG大量 SVG 方法会引发重排,就不一一枚举了,总之使用 SVG 操作时也要像操作 dom 一样谨慎。 contenteditable被设置为 contenteditable 的元素内,包括将图像复制到剪贴板在内,大量操作都会导致重排。(源码) 精读What forces layout / reflow 下面引用了几篇关于 reflow 的相关文章,笔者挑几个重要的总结一下。 repaint-reflow-restylerepaint-reflow-restyle 提到现代浏览器会将多次 dom 操作合并,但像 IE 等其他内核浏览器就不保证有这样的实现了,因此给出了一个安全写法: // badvar left = 10, top = 10;el.style.left = left + "px";el.style.top = top + "px"; // better el.className += " theclassname"; // or when top and left are calculated dynamically... // betterel.style.cssText += "; left: " + left + "px; top: " + top + "px;"; 比如用一次 className 的修改,或一次 cssText 的修改保证浏览器一定触发一次重排。但这样可维护性会降低很多,不太推荐。 avoid large complex layoutsavoid large complex layouts 重点强调了读写分离,首先看下面的 bad case: function resizeAllParagraphsToMatchBlockWidth() { // Puts the browser into a read-write-read-write cycle. for (var i = 0; i < paragraphs.length; i++) { paragraphs[i].style.width = box.offsetWidth + 'px'; }} 在 for 循环中不断访问元素宽度,并修改其宽度,会导致浏览器执行 N 次 reflow。 虽然当 JavaScript 运行时,前一帧中的所有旧布局值都是已知的,但当你对布局做了修改后,前一帧所有布局值缓存都会作废,因此当下次获取值时,不得不重新触发一次 reflow。 而读写分离的话,就代表了集中读,虽然读的次数还是那么多,但从第二次开始就可以从布局缓存中拿数据,不用触发 reflow 了。 另外还提到 flex 布局比传统 float 重排速度快很多(3ms vs 16ms),所以能用 flex 做的布局就尽量不要用 float 做。 really fixing layout thrashingreally fixing layout thrashing 提到了用 fastdom 实践读写分离: ids.forEach(id => { fastdom.measure(() => { const top = elements[id].offsetTop fastdom.mutate(() => { elements[id].setLeft(top) }) })}) fastdom 是一个可以在不分离代码的情况下,分离读写执行的库,尤其适合用在 reflow 性能优化场景。每一个 measure、mutate 都会推入执行队列,并在 window.requestAnimationFrame 时机执行。 总结回流无法避免,但需要控制在正常频率范围内。 我们需要学习访问哪些属性或方法会导致回流,能不使用就不要用,尽量做到读写分离。在定义要频繁触发回流的元素时,尽量使其脱离文档流,减少回流产生的影响。 讨论地址是:精读《web reflow》· Issue ##420 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《webpack4","path":"/wiki/WebWeekly/前沿技术/《webpack4.html","content":"当前期刊数: 47 本周精读的是 webpack4.0 一些变化,以及 typescript 该怎么做才能最大化利用 webpack4.0 的所有特性。 1 引言前段时间尝试了 parcel 作为构建工具,就像农村人享受了都市的生活,就再也回不去了一样,发现无配置真是前端构建工具的大趋势,用起来非常方便快捷,再也不想碰 webpack 的配置了。 可是实践一段实践后,发现 parcel 还是不够成熟,主要体现在暂时不支持一些 rollup 优秀特性:Tree shaking、Scope Hoist,大型项目打包速度反而比 webpack3.0 慢。由于笔者完全零配置,当发现构建速度急速下降时,自然把矛头指向了 parcel :p. 就在前几周,webpack4.0 发布了,也拥抱了零配置,我想,是时候再回到 webpack 了。可是,文档好少,怎么迁移呢? 就在这几天,webpack 文档发布了 4.0 版本,虽然遗留了大量旧文档,不过也足够参考了。 2 精读笔者尝试了 webpack node api,尝试了很久,发现被坑了。文档里只字未提 mode 模式,4.0 环境下 compiler 总是提示没有 mode 的 warning。 读了一些文档,发现 webpack4.0 大力度宣传的是 cli 方式启动,里面提到了最重要的 webpack --mode 模式,可见 webpack4.0 更推崇的是让开发者使用高度封装的 cli,而不是使用 node 方式开发(那 node 文档也应该更新呀)。笔者又看了一圈,发现 webpack-dev-server 的 webpack 版本升到了 4.0,ts-loader 也升级到了 4.0,可能生态已经全部准备好了。 使用 webpack cli、webpack-dev-server cli安装 webpack^4.1.1 webpack-cli^2.0.10 webpack-dev-server^3.1.0,以及创建一个公共配置文件 webpack.config.ts: export default { entry, output, module: { rules }, resolve, resolveLoader, devServer: { https: true, open: true, overlay: { warnings: true, errors: true }, port }} 记得用 tsc 转换为 webpack.config.js 作为 cli 入口。 开发模式下使用 webpack-dev-server: webpack-dev-server --mode development --progress --hot --hotOnly --config ./webpack.config.js 生产环境 build 使用 webpack: webpack --mode production --progress --config ./webpack.config.js 开发/生产模式,都以 webpack.config.ts 作为配置,其中 devServer 项仅在开发模式下,对 webpack-dev-server 生效。 一旦开启了 --mode production,会自动开启代码压缩、scope hoist 等插件,以及自动传递环境变量给 lib 包,所以已经不需要 plugins 这个配置项了。同理,开启了 --mode development 会自动开启 sourceMap 等开发插件,我们只要关心更简单的配置,这就是 4.0 零配置的重要改变。 mode=production, mode=development 具体内置了哪些配置,可以参考这篇文章:webpack 4 终于知道「约定优于配置」了。恰恰有意思的是,webpack4 这么做,就是不想我们浪费时间了解这些机制,社区应该会慢慢习惯零配置的开发方式。 当然,虽然说零配置,但配置文件基本三板斧还是非常有必要配置:entry output module。 我们可能还要给配置文件传一些参数,比如定制多种开发模式的入口,通过 --env 传递: webpack-dev-server --mode development --env.entry ./src/main.tsx webpack.config.ts 接收: const entry = yargs.argv.env.entry 使用 typescript + webpack简单来说,只需要 ts-loader 就够了。在 webpack.config.ts 中增加新的 rules: { module: { rules: [{ test: /\\.(tsx|ts)?$/, use: ["ts-loader"] }] }} 注意 tsconfig.json 中模块解析策略使用: "module": "esnext"。 原因是 webpack 需要 es6 import 语句,才能进行 tree shaking 或者动态 import 优化,我们不再让 ts-loader 包办模块设置,换句话说,我们采用白名单方式看待 typescript 以及 babel,只让他做我们需要的工作,剩下的丢给 webpack 处理,可以获得最大程度性能优化。 如果仅使用 webpack + typescript,建议将 ts 编译输出模式调整为 es3,因为 webpack 自带的压缩工具对 es6 语法还存在报错,而且也不会做兼容处理。 使用 typescript + babel + webpcak注意处理顺序,ts -> babel -> webpack。 因为多出了 babel,我们将 ts 编译兼容模式关闭:"target": "esnext",模块也不要解析:"module": "esnext",ts-loader 仅仅将 typescript 代码转换成 js,其他一切优化都不要做,将 esnext 原生代码直接传给 babel 处理。 babel 这一层的职责是对代码进行兼容处理,不要压缩,也不要把 import 转成 require。笔者发现 babel 直接解析 import 代码会无法处理,因此需要 stage-2 preset: { presets: [ ["env", { modules: false, }], ["stage-2"] ], plugins: [ ["transform-runtime"] ], comments: true} 从上面配置可以看到,babel 这层对 esnext 的代码进行了浏览器兼容处理(env 插件),直接透传 import(stage-2 插件让 babel 识别 esModule),以及支持 async await(transform-runtime) 插件。 本来想用 env 替代 transform-runtime 的功能,笔者暂时没有查询到可行方式,欢迎读者补充。 另外要允许 babel 保留注释(comments: true),因为 webpack import 支持自定义 chunkName 是通过注释的方式: import(/* webpackChunkName: "src" */ "./src") 配合 react-loadable 使用更佳: Loadable({ loader: () => import(/* webpackChunkName: "src" */ "./src"), loading: (): any => null}) 因为 react-loadable 让页面按 chunk 方式打包,而 webpack 又会自动 picke shared chunks,配合给每个 page chunks 通过 webpackChunkName 定义名称,webpack 可以给每个共享 chunks 更加可读的名字,比如:vendor~src,about,login,你就知道这个是 src about login 三个页面间公共模块。 可能已经有人看出瑕疵了,给每个文件增加 webpackChunkName 注释既麻烦又不优雅,而且只要有一个开发者没有加这个注释,上面说的可读 chunks 可能就缺少了某个模块名。 这就要笔者之前一篇精读来看了:精读《Rekit Studio》,项目可以通过约定的方式定义页面,入口文件通过 cli 自动生成,不就既减少业务代量,又统一加上了 webpackChunkName 嘛? 这里小小安利下集成了这个思路的项目脚手架 pri,使用了 ts + babel + webpack4.0,上述的小优化也是内置的功能之一。 webpack4 带来的是适配成本的大幅优化社区似乎有部分声音在抱怨,webpack 又发新版本,我们又要适配一轮。其实 webpack 这么做恰恰没有带来适配成本,出问题的在于我们对 webpack 的使用方式与理念。 如果我们开始就将 webpack 当作一体化打包方案,开发调试使用 webpack-dev-server cli,开发环境编译使用 webpack cli,那么 webpack4 其实只是补充了开发环境这个最重要的配置变量而已。类比 parcel 的两个命令: parcel index.htmlparcel build index.html 对应: webpack-dev-server --mode developmentwebpack --mode production 所以 webpack4 几乎是有史以来最方便使用与迁移的版本,前提是使用思维得正确,舍得将编译环节全权交给两个官方的 Cli。 3 总结只要合理的使用 typescript、babel,让各自只发挥最小功能,将原生的模块化代码抛给 webpack,再配合 --mode production 配置,webpack 会自动开启一切可能的插件优化你的项目,而我们再不需要阅读形形色色的 webpack 插件了,更令人激动的是,随着 webpack 版本升级,优化会不断升级,而我们只要留着 --mode 参数,不需要改一行配置。 总结起来,就是不用关心优化相关的配置,我们只需要配置业务相关的 entry output module,这就是 webpack4.0. 我以前为了实现第一次编译完后立即打开浏览器的功能,写了一共 200 行的 customCompiler 以及 format-webpack-message,而且利用 koa 开了一个 server,利用 await 和 flags 等待第一次编译完的时机,并利用 opn 库打开网页。 其实用 cli 只需要 webpack-dev-server --open。 随着新的一波零配置浪潮,真的不应该在编译配置上花那么多时间了。 4 番外 - prefetch读者自习阅读就会发现,这不是一篇单纯 webpack4 升级指南,仔细阅读可以发现文中蕴藏的一些工程优化思路。文章末尾再给一波福利,分析一下 prefetch 优化是什么,以及怎么做。 现代浏览器支持了以下两种语法: <link rel="preload" /><link rel="prefetch" /> 兼容性自己查 Caniuse,笔者重点在功能上。preload 收集当前用到的资源,prefetch 收集未来用到的资源。 页面本质上也是未来一种资源,如果认为用户会点击另一个页面(如果对产品没自信,或者 pv 过低可以忽略这个功能),就可以用 prefetch 让浏览器在空闲时间下载下一个页面的 chunk 文件。 前端包体积优化效率一般和用户体验是违背的,既然下一个页面在另一个 chunk 中,用户点击后必然会产生 loading。可是如果结合了 prefetch,鱼和熊掌就兼得了(正常用户不可能页面还没加载完就立刻点按钮跳页,所以唯一的缺点几乎不会对正常用户产生影响)。 api 有了,那么最大的问题就是,当前页面怎么知道要加载哪些 chunks?一般两种做法: 全量模式 使用比如 preload-webpack-plugin 插件,将所有生成的 chunk 都作为 prefetch 资源,在所有页面中。几乎所有规模的项目都不会产生过多的 chunks,所以这个方案理论上不够优雅,但能解决实际问题。 按需模式,是理论和实践双重优雅的方案,是否要这么做取决于您是否有代码洁癖。方法是提供一个定制的 Link 标签,根据 URL 地址按需生成 prefetch 标签。这种方案最大缺陷是,如果用户不按照约定使用内置的 Link,prefetch 规则将会无效。 5 更多讨论 讨论地址是:精读《webpack4.0 升级指南》 · Issue ##66 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《一种 Hooks 数据流管理方案》","path":"/wiki/WebWeekly/前沿技术/《一种 Hooks 数据流管理方案》.html","content":"当前期刊数: 206 维护大型项目 OR UI 组件模块时,一定会遇到全局数据传递问题。 维护项目时,像全局用户信息、全局项目配置、全局功能配置等等,都是跨模块复用的全局数据。 维护 UI 组件时,调用组件的入口只有一个,但组件内部会继续拆模块,分文件,对于这些组件内模块而言,入口文件的参数也就是全局数据。 这时一般有三种方案: props 透传。 上下文。 全局数据流。 props 透传方案,因为任何一个节点掉链子都会导致参数传递失败,因此带来的维护成本与心智负担都特别大。 上下文即 useContext 利用上下文共享全局数据,带来的问题是更新粒度太粗,同上下文中任何值的改变都会导致重渲染。有一种较为 Hack 的解决方案 use-context-selector,不过这个和下面说到的全局数据流很像。 全局数据流即利用 react-redux 等工具,绕过 React 更新机制进行全局数据传递的方案,这种方案较好解决了项目问题,但很少有组件会使用。以前也有过不少利用 Redux 做局部数据流的方案,但本质上还是全局数据流。现在 react-redux 支持了局部作用域方案: import { shallowEqual, createSelectorHook, createStoreHook } from 'react-redux'const context = React.createContext(null)const useStore = createStoreHook(context)const useSelector = createSelectorHook(context)const useDispatch = createDispatchHook(context) 因此是机会好好梳理一下数据流管理方案,做一个项目、组件通用的数据流管理方案。 精读对项目、组件来说,数据流包含两种数据: 可变数据。 不可变数据。 对项目来说,可变数据的来源有: 全局外部参数。 全局项目自定义变量。 不可变数据来源有: 操作数据或行为的函数方法。 全局外部参数指不受项目代码控制的,比如登陆用户信息数据。全局项目自定义变量是由项目代码控制的,比如定义了一些模型数据、状态数据。 对组件来说,可变数据的来源有: 组件被调用时的传参。 全局组件自定义变量。 不可变数据来源有: 组件被调用时的传参。 操作数据或行为的函数方法。 对组件来说,被调用时的传参既可能是可变数据,也可能是不可变数据。比如传入的 props.color 可能就是可变数据,而 props.defaultValue、props.onChange 就是不可变数据。 当梳理清楚项目与组件到底有哪些全局数据后,我们就可以按照注册与调用这两步来设计数据流管理规范了。 数据流调用首先来看调用。为了同时保证使用的便捷与应用程序的性能,我们希望使用一个统一的 API useXXX 来访问所有全局数据与方法,并满足: {} = useXXX() 只能引用到不可变数据,包括变量与方法。 { value } = useXXX(state => ({ value: state.value })) 可以引用到可变数据,但必须通过选择器来调用。 比如一个应用叫 gaea,那么 useGaea 就是对这个应用全局数据的唯一调用入口,我可以在组件里这么调用数据与方法: const Panel = () => { // appId 是应用不可变数据,所以即使是变量也可以直接获取,因为它不会变化,也不会导致重渲染 // fetchData 是取数函数,内置发送了 appId,所以绑定了一定上下文,也属于不可变数据 const { appId, fetchData } = useGaea() // 主题色可能在运行时修改,只能通过选择器获取 // 此时这个组件会额外在 color 变化时重渲染 const { color } = useGaea(state => ({ color: state.theme?.color }))} 比如一个组件叫 Menu,那么 useMenu 就是这个组件的全局数据调用入口,可以这么使用: // SubMenu 是 Menu 组件的子组件,可以直接使用 useMenuconst SubMenu = () => { // defaultValue 是一次性值,所以处理时做了不可变处理,这里已经是不可变数据了 // onMenuClick 是回调函数,不管传参引用如何变化,这里都处理成不可变的引用 const { defaultValue, onMenuClick } = useMenu() // disabled 是 menu 的参数,需要在变化时立即响应,所以是可变数据 const { disabled } = useMenu(state => ({ disabled: state.disabled })) // selectedMenu 是 Menu 组件的内部状态,也作为可变数据调用 const { selectedMenu } = useMenu(state => ({ selectedMenu: state.selectedMenu }))} 可以发现,在整个应用或者组件的使用 Scope 中,已经做了一层抽象,即不关心数据是怎么来的,只关心数据是否可变。这样对于组件或应用,随时可以将内部状态开放到 API 层,而内部代码完全不用修改。 数据流注册数据流注册的时候,我们只要定义三种参数: dynamicValue: 动态参数,通过 useInput(state => state.xxx) 才能访问到。 staticValue: 静态参数,引用永远不会改变,可以直接通过 useInput().xxx 访问到。 自定义 hooks,入参是 staticValue getState setState,这里可以封装自定义方法,并且定义的方法都必须是静态的,可以直接通过 useInput().xxx 访问到。 const { useState: useInput, Provider } = createHookStore<{ dynamicValue: { fontSize: number } staticValue: { onChange: (value: number) => void }}>(({ staticValue }) => { const onCustomChange = React.useCallback((value: number) => { staticValue.onChange(value + 1) }, [staticValue]) return React.useMemo(() => ({ onCustomChange }), [onCustomChange])}) 上面的方法暴露了 Provider 与 useInput 两个对象,我们首先需要在组件里给它传输数据。比如我写的是组件 Input,就可以这么调用: function Input({ onChange, fontSize }) { return ( <Provider dynamicValue={{fontSize}} staticValue={{onChange}}> <InputComponent /> </Provider> )} 如果对于某些动态数据,我们只想赋初值,可以使用 defaultDynamicValue: function Input({ onChange, fontSize }) { return ( <Provider dynamicValue={{fontSize}} defaultDynamicValue={{count: 1}}> <InputComponent /> </Provider> )} 这样 count 就是一个动态值,必须通过 useInput(state => ({ count: state.count })) 才能取到,但又不会因为外层组件 Rerender 而被重新赋值为 1。所有动态值都可以通过 setState 来修改,这个后面再说。 这样所有 Input 下的子组件就可以通过 useInput 访问到全局数据流的数据啦,我们有三种访问数据的场景。 一:访问传给 Input 组件的 onChange。 因为 onChange 是不可变对象,因此可以通过如下方式访问: function InputComponent() { const { onChange } = useInput()} 二:访问我们自定义的全局 Hooks 函数 onCustomChange: function InputComponent() { const { onCustomChange } = useInput()} 三:访问可能变化的数据 fontSize。由于我们需要在 fontSize 变化时让组件重渲染,又不想让上面两种调用方式受到 fontSize 的影响,需要通过如下方式访问: function InputComponent() { const { fontSize } = useInput(state => ({ fontSize: state.fontSize }))} 最后在自定义方法中,如果我们想修改可变数据,都要通过 updateStore 封装好并暴露给外部,而不能直接调用。具体方式是这样的,举个例子,假设我们需要定义一个应用状态 status,其可选值为 edit 与 preview,那么可以这么去定义: const { useState: useInput, Provider } = createHookStore<{ dynamicValue: { isAdmin: boolean status: 'edit' | 'preview' }}>(({ getState, setState }) => { const toggleStatus = React.useCallback(() => { // 管理员才能切换应用状态 if (!getState().isAdmin) { return } setState(state => ({ ...state, status: state.status === 'edit' ? 'preview' : 'edit' })) }, [getState, setState]) return React.useMemo(() => ({ toggleStatus }), [toggleStatus])}) 下面是调用: function InputComponent() { const { toggleStatus } = useInput() return ( <button onClick={toggleStatus} /> )} 而且整个链路的类型定义也是完全自动推导的,这套数据流管理方案到这里就讲完了。 总结对全局数据的使用,最方便的就是收拢到一个 useXXX API,并且还能区分静态、动态值,并在访问静态值时完全不会导致重渲染。 而之所以动态值 dynamicValue 需要在 Provider 里定义,是因为当动态值变化时,会自动更新数据流中的数据,使整个应用数据与外部动态数据同步。而这个更新步骤就是通过 Redux Store 来完成的。 本文特意没有给出实现源码,感兴趣的同学可以自己实现一个试一试。 讨论地址是:精读《一种 Hooks 数据流管理方案》· Issue ##345 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《不再需要 JS 做的 5 件事》","path":"/wiki/WebWeekly/前沿技术/《不再需要 JS 做的 5 件事》.html","content":"当前期刊数: 238 关注 JS 太久,会养成任何功能都用 JS 实现的习惯,而忘记了 HTML 与 CSS 也具备一定的功能特征。其实有些功能用 JS 实现吃力不讨好,我们要综合使用技术工具,而不是只依赖 JS。 5 things you don’t need Javascript for 这篇文章就从 5 个例子出发,告诉我们哪些功能不一定非要用 JS 做。 概述使用 css 控制 svg 动画原文绘制了一个放烟花的 例子,本质上是用 css 控制 svg 产生动画效果,核心代码: .trail { stroke-width: 2; stroke-dasharray: 1 10 5 10 10 5 30 150; animation-name: trail; animation-timing-function: ease-out;}@keyframes trail { from, 20% { stroke-width: 3; stroke-dashoffset: 80; } 100%, to { stroke-width: 0.5; stroke-dashoffset: -150; }} 可以看到,主要使用 stroke-dasharray 控制线条实虚线的样式,再利用动画效果对 stroke-dashoffset 产生变化,从而实现对线条起始点进行位移,实现线条 “绘图” 的效果,且该 css 样式对 svg 绘制的路径是生效的。 sidebar可以完全使用 css 实现 hover 时才出现的侧边栏: nav { position: 'absolute'; right: 100%; transition: 0.2s transform;}nav:hover,nav:focus-within { transform: translateX(100%);} 核心在于 hover 时设置 transform 属性可以让元素偏移,且 translateX(100%) 可以位移当前元素宽度的身位。 另一个有意思的是,如果使用 TABS 按键聚焦到 sidebar 内元素也要让 sidebar 出来,可以直接用 :focus-within 实现。如果需要 hover 后延迟展示可以使用 transition-delay 属性。 sticky position使用 position: sticky 来黏住一个元素: .square { position: sticky; top: 2em;} 这样该元素会始终展示在其父容器内,但一旦其出现在视窗时,当 top 超过 2em 后就会变为 fixed 定位并保持原位。 使用 JS 判断还是挺复杂的,你得设法监听父元素滚动,并且在定位切换时可能产生一些抖动,因为 JS 的执行与 CSS 之间是异步关系。但当我们只用 CSS 描述这个行为时,浏览器就有办法解决转换时的抖动问题。 手风琴菜单使用 <details> 标签可以实现类似一个简易的折叠手风琴效果: <details> <summary>title</summary> <p>1</p> <p>2</p></details> 在 <details> 标签内的 <summary> 标签内容总是会展示,且点击后会切换 <details> 内其他元素的显隐藏。虽然这做不了特殊动画效果,但如果只为了做一个普通的展开折叠功能,用 HTML 标签就够了。 暗色主题虽然直觉上暗色主题好像是一种定制业务逻辑,但其实因为暗色主题太过于普遍,以至于操作系统和浏览器都内置实现了,而 CSS 也实现了对应的方法判断当前系统的主题到底是亮色还是暗色:prefers-color-scheme。 所以如果系统要实现暗色系主题,最好可以和操作系统设置保持一致,这样用户体验也会更好: @media (prefers-color-scheme: light) { /** ... */}@media (prefers-color-scheme: dark) { /** ... */}@media (prefers-color-scheme: no-preference) { /** ... */} 如果使用 Checkbox 勾选是否开启暗色主题,也可以仅用 CSS 变量判断,核心代码是: ##checkboxId:checked ~ .container { background-color: black;} ~ 这个符号表示,selector1 ~ selector2 时,为选择器 selector1 之后满足 selector2 条件的兄弟节点设置样式。 精读除了上面例子外,笔者再追加几个例子。 幻灯片滚动幻灯片滚动即每次滚动有固定的步长,把子元素完整的展示在可视区域,不可能出现上下或者左右两个子元素各出现一部分的 “割裂” 情况。 该场景除了用浏览器实现幻灯片外,在许多网站首页也被频繁使用,比如将首页切割为 5 个纵向滚动的区块,每个区块展示一个产品特性,此时滚动不再是连续的,而是从一个区块到另一个区块的完整切换。 其实这种效果无需 JS 实现: html { scroll-snap-type: y mandatory;}.child { scroll-snap-align: start;} 这样便将页面设置为精准捕捉子元素滚动位置,在滚轮触发、鼠标点击滚动条松手或者键盘上下按键时,scroll-snap-type: y mandatory 可以精准捕捉这一垂直滚动行为,并将子元素完全滚动到可视区域。 颜色选择器使用 HTML 原生就能实现颜色选择器: <input type="color" value="##000000"> 该选择器的好处是性能、可维护性都非常非常的好,甚至可以捕捉桌面的颜色,不好的地方是无法对拾色器进行定制。 总结关于 CSS 可以实现哪些原本需要 JS 做的事,有很多很好的文章,比如: youmightnotneedjs。 You-Dont-Need-JavaScript。 以及本文简介里介绍的 5 things you don’t need Javascript for。 但并不是读了这些文章,我们就要尽量用 CSS 实现所有能做的事,那样也没有必要。CSS 因为是描述性语言,它可以精确控制样式,但却难以精确控制交互过程,对于标准交互行为比如幻灯片滑动、动画可以使用 CSS,对于非标准交互行为,比如自定义位置弹出 Modal、用 svg 绘制完全自定义路径动画尽量还是用 JS。 另外对于交互过程中的状态,如果需要传递给其他元素响应,还是尽量使用 JS 实现。虽然 CSS 伪类可以帮我们实现大部分这种能力,但如果我们要监听状态变化发一个请求什么的,CSS 就无能为力了,或者我们需要非常 trick 的利用 CSS 实现,这也违背了 CSS 技术选型的初衷。 最后,能否在合适的场景选择 CSS 方案,也是技术选型能力的一种,不要忘了 CSS 适用的领域,不要什么功能都用 JS 实现。 讨论地址是:精读《不再需要 JS 做的 5 件事》· Issue ##413 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《web streams》","path":"/wiki/WebWeekly/前沿技术/《web streams》.html","content":"当前期刊数: 214 Node stream 比较难理解,也比较难用,但 “流” 是个很重要而且会越来越常见的概念(fetch 返回值就是流),所以我们有必要认真学习 stream。 好在继 node stream 之后,又推出了比较好用,好理解的 web streams API,我们结合 Web Streams Everywhere (and Fetch for Node.js)、2016 - the year of web streams、ReadableStream、WritableStream 这几篇文章学一下。 node stream 与 web stream 可以相互转换:.fromWeb() 将 web stream 转换为 node stream;.toWeb() 将 node stream 转换为 web stream。 精读stream(流)是什么? stream 是一种抽象 API。我们可以和 promise 做一下类比,如果说 promise 是异步标准 API,则 stream 希望成为 I/O 的标准 API。 什么是 I/O?就是输入输出,即信息的读取与写入,比如看视频、加载图片、浏览网页、编码解码器等等都属于 I/O 场景,所以并不一定非要大数据量才算 I/O,比如读取一个磁盘文件算 I/O,同样读取 "hello world" 字符串也可以算 I/O。 stream 就是当下对 I/O 的标准抽象。 为了更好理解 stream 的 API 设计,以及让你理解的更深刻,我们先自己想一想一个标准 I/O API 应该如何设计? I/O 场景应该如何抽象 API?read()、write() 是我们第一个想到的 API,继续补充的话还有 open()、close() 等等。 这些 API 确实可以称得上 I/O 场景标准 API,而且也足够简单。但这些 API 有一个不足,就是缺乏对大数据量下读写的优化考虑。什么是大数据量的读写?比如读一个几 GB 的视频文件,在 2G 慢网络环境下访问网页,这些情况下,如果我们只有 read、write API,那么可能一个读取命令需要 2 个小时才能返回,而一个写入命令需要 3 个小时执行时间,同时对用户来说,不论是看视频还是看网页,都无法接受这么长的白屏时间。 但为什么我们看视频和看网页的时候没有等待这么久?因为看网页时,并不是等待所有资源都加载完毕才能浏览与交互的,许多资源都是在首屏渲染后再异步加载的,视频更是如此,我们不会加载完 30GB 的电影后再开始播放,而是先下载 300kb 片头后就可以开始播放了。 无论是视频还是网页,为了快速响应内容,资源都是 在操作过程中持续加载的,如果我们设计一个支持这种模式的 API,无论资源大还是小都可以覆盖,自然比 read、wirte 设计更合理。 这种持续加载资源的行为就是 stream(流)。 什么是 streamstream 可以认为在形容资源持续流动的状态,我们需要把 I/O 场景看作一个持续的场景,就像把一条河的河水导流到另一条河。 做一个类比,我们在发送 http 请求、浏览网页、看视频时,可以看作一个南水北调的过程,把 A 河的水持续调到 B 河。 在发送 http 请求时,A 河就是后端服务器,B 河就是客户端;浏览网页时,A 河就是别人的网站,B 河就是你的手机;看视频时,A 河是网络上的视频资源(当然也可能是本地的),B 河是你的视频播放器。 所以流是一个持续的过程,而且可能有多个节点,不仅网络请求是流,资源加载到本地硬盘后,读取到内存,视频解码也是流,所以这个南水北调过程中还有许多中途蓄水池节点。 将这些事情都考虑到一起,最后形成了 web stream API。 一共有三种流,分别是:writable streams、readable streams、transform streams,它们的关系如下: readable streams 代表 A 河流,是数据的源头,因为是数据源头,所以只可读不可写。 writable streams 代表 B 河流,是数据的目的地,因为要持续蓄水,所以是只可写不可读。 transform streams 是中间对数据进行变换的节点,比如 A 与 B 河中间有一个大坝,这个大坝可以通过蓄水的方式控制水运输的速度,还可以安装滤网净化水源,所以它一头是 writable streams 输入 A 河流的水,另一头提供 readable streams 供 B 河流读取。 乍一看很复杂的概念,但映射到河水引流就非常自然了,stream 的设计非常贴近生活概念。 要理解 stream,需要思考下面三个问题: readable streams 从哪来? 是否要使用 transform streams 进行中间件加工? 消费的 writable streams 逻辑是什么? 还是再解释一下,为什么相比 read()、write(),stream 要多这三个思考:stream 既然将 I/O 抽象为流的概念,也就是具有持续性,那么读取的资源就必须是一个 readable 流,所以我们要构造一个 readable streams(未来可能越来越多函数返回值就是流,也就是在流的环境下工作,就不用考虑如何构造流了)。对流的读取是一个持续的过程,所以不是调用一个函数一次性读取那么简单,因此 writable streams 也有一定 API 语法。正是因为对资源进行了抽象,所以无论是读取还是消费,都被包装了一层 stream API,而普通的 read 函数读取的资源都是其本身,所以才没有这些额外思维负担。 好在 web streams API 设计都比较简单易用,而且作为一种标准规范,更加有掌握的必要,下面分别说明: readable streams读取流不可写,所以只有初始化时才能设置值: const readableStream = new ReadableStream({ start(controller) { controller.enqueue('h') controller.enqueue('e') controller.enqueue('l') controller.enqueue('l') controller.enqueue('o') controller.close() }}) controller.enqueue() 可以填入任意值,相当于是将值加入队列,controller.close() 关闭后,就无法继续 enqueue 了,并且这里的关闭时机,会在 writable streams 的 close 回调响应。 上面只是 mock 的例子,实际场景中,读取流往往是一些调用函数返回的对象,最常见的就是 fetch 函数: async function fetchStream() { const response = await fetch('https://example.com') const stream = response.body;} 可见,fetch 函数返回的 response.body 就是一个 readable stream。 我们可以通过以下方式直接消费读取流: readableStream.getReader().read().then({ value, done } => {}) 也可以 readableStream.pipeThrough(transformStream) 到一个转换流,也可以 readableStream.pipeTo(writableStream) 到一个写入流。 不管是手动 mock 还是函数返回,我们都能猜到,读取流不一定一开始就充满数据,比如 response.body 就可能因为读的比较早而需要等待,就像接入的水管水流较慢,而源头水池的水很多一样。我们也可以手动模拟读取较慢的情况: const readableStream = new ReadableStream({ start(controller) { controller.enqueue('h') controller.enqueue('e') setTimeout(() => { controller.enqueue('l') controller.enqueue('l') controller.enqueue('o') controller.close() }, 1000) }}) 上面例子中,如果我们一开始就用写入流对接,必然要等待 1s 才能得到完整的 'hello' 数据,但如果 1s 后再对接写入流,那么瞬间就能读取整个 'hello'。另外,写入流可能处理的速度也会慢,如果写入流处理每个单词的时间都是 1s,那么写入流无论何时执行,都比读取流更慢。 所以可以体会到,流的设计就是为了让整个数据处理过程最大程度的高效,无论读取流数据 ready 的多迟、开始对接写入流的时间有多晚、写入流处理的多慢,整个链路都是尽可能最高效的: 如果 readableStream ready 的迟,我们可以晚一点对接,让 readableStream 准备好再开始快速消费。 如果 writableStream 处理的慢,也只是这一处消费的慢,对接的 “水管” readableStream 可能早就 ready 了,此时换一个高效消费的 writableStream 就能提升整体效率。 writable streams写入流不可读,可以通过如下方式创建: const writableStream = new WritableStream({ write(chunk) { return new Promise(resolve => { // 消费的地方,可以执行插入 dom 等等操作 console.log(chunk) resolve() }); }, close() { // 可读流 controller.close() 时,这里被调用 },}) 写入流不用关心读取流是什么,所以只要关心数据写入就行了,实现写入回调 write。 write 回调需要返回一个 Promise,所以如果我们消费 chunk 的速度比较慢,写入流执行速度就会变慢,我们可以理解为 A 河流引水到 B 河流,就算 A 河流的河道很宽,一下就把河水全部灌入了,但 B 河流的河道很窄,无法处理那么大的水流量,所以受限于 B 河流河道宽度,整体水流速度还是比较慢的(当然这里不可能发生洪灾)。 那么 writableStream 如何触发写入呢?可以通过 write() 函数直接写入: writableStream.getWriter().write('h') 也可以通过 pipeTo() 直接对接 readableStream,就像本来是手动滴水,现在直接对接一个水管,这样我们只管处理写入就行了: readableStream.pipeTo(writableStream) 当然通过最原始的 API 也可以拼装出 pipeTo 的效果,为了理解的更深刻,我们用原始方法模拟一个 pipeTo: const reader = readableStream.getReader()const writer = writableStream.getWriter()function tryRead() { reader.read().then(({ done, value }) => { if (done) { return } writer.ready().then(() => writer.write(value)) tryRead() })}tryRead() transform streams转换流内部是一个写入流 + 读取流,创建转换流的方式如下: const decoder = new TextDecoder()const decodeStream = new TransformStream({ transform(chunk, controller) { controller.enqueue(decoder.decode(chunk, {stream: true})) }}) chunk 是 writableStream 拿到的包,controller.enqueue 是 readableStream 的入列方法,所以它其实底层实现就是两个流的叠加,API 上简化为 transform 了,可以一边写入读到的数据,一边转化为读取流,供后面的写入流消费。 当然有很多原生的转换流可以用,比如 TextDecoderStream: const textDecoderStream = TextDecoderStream() readable to writable streams下面是一个包含了编码转码的完整例子: // 创建读取流const readableStream = new ReadableStream({ start(controller) { const textEncoder = new TextEncoder() const chunks = textEncoder.encode('hello', { stream: true }) chunks.forEach(chunk => controller.enqueue(chunk)) controller.close() }})// 创建写入流const writableStream = new WritableStream({ write(chunk) { const textDecoder = new TextDecoder() return new Promise(resolve => { const buffer = new ArrayBuffer(2); const view = new Uint16Array(buffer); view[0] = chunk; const decoded = textDecoder.decode(view, { stream: true }); console.log('decoded', decoded) setTimeout(() => { resolve() }, 1000) }); }, close() { console.log('writable stream close') },})readableStream.pipeTo(writableStream) 首先 readableStream 利用 TextEncoder 以极快的速度瞬间将 hello 这 5 个字母加入队列,并执行 controller.close(),意味着这个 readableStream 瞬间就完成了初始化,并且后面无法修改,只能读取了。 我们在 writableStream 的 write 方法中,利用 TextDecoder 对 chunk 进行解码,一次解码一个字母,并打印到控制台,然后过了 1s 才 resolve,所以写入流会每隔 1s 打印一个字母: h## 1s latere## 1s laterl## 1s laterl## 1s laterowritable stream close 这个例子转码解码处理的还不够优雅,我们不需要将转码与解码写在流函数里,而是写在转换流中,比如: readableStream .pipeThrough(new TextEncoderStream()) .pipeThrough(customStream) .pipeThrough(new TextDecoderStream()) .pipeTo(writableStream) 这样 readableStream 与 writableStream 都不需要处理编码与解码,但流在中间被转化为了 Uint8Array,方便被其它转换流处理,最后经过解码转换流转换为文字后,再 pipeTo 给写入流,这样写入流拿到的就是文字了。 但也并不总是这样,比如我们要传输一个视频流,可能 readableStream 原始值就已经是 Uint8Array,所以具体要不要对接转换流看情况。 总结streams 是对 I/O 抽象的标准处理 API,其支持持续小片段数据处理的特性并不是偶然,而是对 I/O 场景进行抽象后的必然。 我们通过水流的例子类比了 streams 的概念,当 I/O 发生时,源头的流转换是有固定速度的 x M/s,目标客户端比如视频的转换也是有固定速度的 y M/s,网络请求也有速度并且是个持续的过程,所以 fetch 天然也是一个流,速度时 z M/s,我们最终看到视频的速度就是 min(x, y, z),当然如果服务器提前将 readableStream 提供好,那么 x 的速度就可以忽略,此时看到视频的速度是 min(y, z)。 不仅视频如此,打开文件、打开网页等等都是如此,浏览器处理 html 也是一个流的过程: new Response(stream, { headers: { 'Content-Type': 'text/html' },}) 如果这个 readableStream 的 controller.enqueue 过程被刻意处理的比较慢,网页甚至可以一个字一个字的逐步呈现:Serving a string, slowly Demo。 尽管流的场景如此普遍,但也没有必要将所有代码都改成流式处理,因为代码在内存中执行速度很快,变量的赋值是没必要使用流处理的,但如果这个变量的值来自于一个打开的文件,或者网络请求,那么使用流进行处理是最高效的。 讨论地址是:精读《web streams》· Issue ##363 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《什么是 LOD 表达式》","path":"/wiki/WebWeekly/前沿技术/《什么是 LOD 表达式》.html","content":"当前期刊数: 215 LOD 表达式在数据分析领域很常用,其全称为 Level Of Detail,即详细级别。 精读什么是详细级别,为什么需要 LOD?你一定会有这个问题,我们来一步步解答。 什么是详细级别可以尝试这么发问:你这个数据有多详细? 得到的回答可能是: 数据是汇总的,抱歉看不到细节,不过如果您正好要看总销量的话,这儿都给您汇总好了。。 详细?这直接就是原始表数据,30 亿条,这够详细了吧?如果觉得还不够详细,那只好把业务过程再拆分一下重新埋点了。 详细程度越高,数据量越大,详细程度越低,数据就越少,就越是汇总的数据。 人很难在详细程度很高的 30 亿条记录里看到有价值的信息,所以数据分析的过程也可以看作是 对数据汇总计算的过程,这背后数据详细程度在逐渐降低。 BI 工具的详细级别如果没有 LOD 表达式,一个 BI 查询的详细程度是完全固定的: 如果表格拖入度量,没有维度,那就是最高详细级别,因为最终只会汇总出一条记录。 如果折线图拖入维度,那结果就是根据这个维度内分别聚合度量,数据更详细了,详细粒度为当前维度,比如日期。 如果我们要更详细的数据,就需要在维度上拖入更多字段,直到达到最详细的明细表级别的粒度。然而同一个查询不可能包含不同详细粒度,因为详细粒度由维度组合决定,不可改变,比如下面表格的例子: 行:国家 省 城市列:GDP 这个例子中,详细级别限定在了城市这一级汇总,城市下更细粒度的数据就看不到了,每一条数据都是城市粒度的,我们不可能让查询结果里出现按照国家汇总的 GDP,或者看到更详细粒度的每月 GDP 信息,更不可能让城市粒度的 GDP 与国家粒度 GDP 在一起做计算,算出城市 GDP 在国家中占比。 但是,类似上面例子的需求是很多的,而且很常见,BI 工具必须想出一种解法,因此诞生了 LOD:LOD 就是一种表达式,允许我们在一个查询中描述不同的详细粒度。 从表达式计算来看详细级别表达式计算必须限定在同样的详细粒度,这是铁律,为什么呢? 试想一下下面两张不同详细粒度的表: 总销售额: 10000 各城市销售额: 北京 3000上海 7000 如果我们想在各城市销售额中,计算贡献占比,那么就要写出 [各城市销售额] / [总销售额] 的计算公式,但显然这是不可能的,因为前者有两条数据,后者只有一条数据,根本无法计算。 我们能做的一定是数据行数相同,那么无论是 IF ELSE、CASE WHEN,还是加减乘除都可以按照行粒度进行了。 LOD 给了我们跨详细粒度计算的能力,其本质还是将数据详细粒度统一,但我们可以让某列数据来自于一个完全不同详细级别的计算: 城市 销售额 总销售额北京 3000 10000上海 7000 10000 如图表,LOD 可以把数据加工成这样,即虽然总销售额与城市详细粒度不同,但还是添加到了每一行的末尾,这样就可以进行计算了。 因此 LOD 可以按照任意详细级别进行计算,将最终产出 “贴合” 到当前查询的详细级别中。 LOD 表达式分为三种能力,分别是 FIXED、INCLUDE、EXCLUDE。 FIXED{ fixed [省份] : sum([GDP]) } 按照城市这个固定详细粒度,计算每个省份的 DGP,最后合并到当前详细粒度里。 假如现在的查询粒度是省份、城市,那么 LOD 字段的添加逻辑如下图所示: 可见,本质是两个不同 sql 查询后 join 的结果,内部的 sum 表示在 FIXED 表达式内的聚合方式,外部的 sum 表示,如果 FIXED 详细级别比当前视图详细级别低,应该如何聚合。在这个例子中,FIXED 详细级别较高,所以 sum 不起作用,换成 avg 效果也相同,因为合并详细级别是,是一对多关系,只有合并时多对一关系才需要聚合。 最外层聚合方式一般在 INCLUDE 表达式中发挥作用。 EXCLUDE{ exclude [城市] : sum([GDP]) } 在当前查询粒度中,排除城市这个粒度后计算 GDP,最后合并到当前详细粒度中。 假如现在的查询粒度是省份、城市、季节,那么 LOD 字段的添加逻辑如下图所示: 如图所示,EXCLUDE 在当前视图详细级别的基础上,排除一些维度,所得到的详细级别一定会更高。 INCLUDE{ include [城乡] : avg([GDP]) } 在当前查询粒度中,额外加上城乡这个粒度后计算 GDP,最后合并到当前详细粒度中。 这类的例子比较难理解,且在 sum 情况下一般无实际意义,因为计算结果不会有差异,必须在类似 avg 场景下才有意义,我们还是结合下图来看: 这就是 avg 算不准的问题,即不同详细级别计算的平均值是不同的,但 sum、count 等不会随着详细级别变化而影响计算结果,所以当涉及到 avg 计算时,可以通过 INCLUDE 表达式指定计算的详细级别,以保证数据口径准确性。 LOD 字段怎么用除了上面的例子中,直接查出来展示给用户外,LOD 字段更常用的是作为中间计算过程,比如计算省份 GDP 占在国内占比。因为 LOD 已经将不同详细粒度计算结果合并到了当前的详细粒度里,所以如下的计算表达式: sum([GDP]) / sum({ fixed [国家] : sum([GDP]) }) 看似是跨详细粒度计算,其实没有,实际计算时还是一行一行来算的,后面的 LOD 表达式只是在逻辑上按照指定的详细粒度计算,但最终会保持与当前视图详细粒度一致,因此可以参与计算。 我们后面会继续解读 tableau 整理的 Top 15 LOD 表达式业务场景,更深入的理解 LOD 表达式。 总结LOD 表达式让你轻松创建 “脱离” 当前视图详细级别的计算字段。 或许你会疑惑,为什么不主动改变当前视图详细级别来实现同样的效果?比如新增或减少一个维度。 原因是,LOD 往往用于跨详细级别的计算,比如算部分相对总体的占比,计算当条记录是否为用户首单等等,更多的场景会在下次精读中解读。 讨论地址是:精读《什么是 LOD 表达式》· Issue ##365 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《使用 CSS 属性选择器》","path":"/wiki/WebWeekly/前沿技术/《使用 CSS 属性选择器》.html","content":"当前期刊数: 81 1 引言虽然现在 Css Module 与 Css-in-js 更流行,但使用它们会导致过分依赖 滥用 class 做唯一定位,违背了 Css 选择器的初衷。 本期精读的文章是:attribute-selectors-splicing-html-dna-css,带你重新理解强大的 Css 选择器。 2 概要Css Module 与 Css-in-js 大部分场景使用 className 作为选择器,那么本文以选择器为重点,看看选择器有哪些实用的用法。 属性选择器如果你想选择包含 title 属性的 div: div[title] 选择包含 title 属性的子元素,只需要加个空格: div [title] 选择 title 内容是 dna 的元素: div[title="dna"] 选择 title 属性包含 dna 单词的元素: 注意 dna 需要是单词,也就是用空格分割,比如 “my beautiful dna” 或 “mutating dna is fun!” div[title~="dna"] 和正则类似,选择 title 属性中,以 dna 结尾的元素: div[title$="dna"] 以 dna 开头: div[title^="dna"] 如果希望选择 dna 或 dna-zh,但不希望匹配 dnaer,可以: 这种场景一般用在国际化,比如 en en-us 就可以用 |="en" div[title|="dna"] 只要包含 dna 这三个字符就选中: div[title*="dna"] 真的很像正则,你可以用 i 标识匹配时大小写不敏感: div[title*="dna" i] 如果你想找到一个 a 标签,拥有 title 属性并且 className 以 genes 结尾,可以这样: a[title][class$="genes"] 获取标签的值可以用 attr 标识符拿到当前选择器选中元素的属性,比如当 hover 状态时,在文字尾部显示其 title 属性: .joke:hover:after { content: "Answer:" attr(title); display: block;} 其它用法本文还介绍了一些实用技巧,比如 根据输入框类型设置样式 input[type="email"] { color: papayawhip;}input[type="tel"] { color: thistle;} 改变下载标签的 icon a[download][href$="pdf"]:after { content: url(pdf-icon.svg);} 当然也可以选中一些老代码进行样式重写,比如: <div bgcolor="##000000" color="##FFFFFF">Old, holey genes</div> div[bgcolor="##000000"] { /*override*/ background-color: ##222222 !important;} 不过这种用法要谨慎,写的越多越难以维护。 结合一些新标签功能 比如 details 标签是 html 原生的手风琴折叠组件: <details> <summary>List of Genes</summary> Roddenberry Hackman </details> 我们可以使用属性选择器,定义其打开时的样式: details[open] { background-color: hotpink;} 为没有 async 标记的 script 标签着色,算是友情提示哪儿有错误: script[src]:not([async]) { display: block; width: 100%; height: 1em; background-color: red;}script:after { content: attr(src);} 为 JS 事件着色,比如触发的鼠标事件可以作为选择器: [OnMouseOver] { color: burlywood;}[OnMouseOver]:after { content: "JS: " attr(OnMouseOver);} 选中隐藏元素: [hidden],[type="hidden"] { display: block;} 还有更多就不一一列举了,感兴趣的读者可以跳转到原文继续阅读。大部分内容其实都写在了 w3school 选择器参考手册,只是结合一篇文章来读,可以理解得更深刻,同时文章里确实有一些新鲜的选择器,比如 JS 事件选择器,HTML5 属性标签选择器等等。 3 精读这篇文章确实说明了 Css 选择器的强大性,但回到 css module 或者 css-in-js 的工程代码里,我们往往难以做太多的实践,有如下几个原因: 一直在担心的 DOM 结构变动业务开发中,大量需求涌入,也许过了一周,DOM 结构就已经面目全非了,而且就算是一个普通的圣杯布局,可能老版本用 Table 布局,后面进来一个年轻小伙子直接用 div + flex 重构了,你会担心之前写的 table 选择器在某一天全部失效。 也许今天的 div 选择器,明天因为语义化改造就换成了 article 标签。 最大原因是 一种视觉界面对应的实现方式太多,不仅标签可以各异,css 属性还有 table、block、flex、grid 可选,同时 grid 属性还会导致视觉结构与 DOM 结构不完全对应。 如果你今天用 css 选择器做了一套完全贴合现在 DOM 结构的 css 文件,这个 css 文件也许是后面 dom 结构改动的噩梦。 你敢做全局样式覆盖吗我们排除标签,仅对属性做全局覆盖,的确可以部分绕开 DOM 结构的限制,但是这样的全局样式覆盖,不同的人有不同看法。 小明的团队非常懂得 css 运用,他们每天都会花一个小时讨论项目的 css 架构,并对通用需求样式做了抽象,并且每个人都很认可这个方案,在他们的团队,一个非常酷炫的按钮与动画效果,通过 <button animate /> 就可以完成,页面间交互非常流畅,用户体验统一,前端代码也非常简洁和优雅。 小白的团队水平参差不齐,有人永远只使用 table 布局,有人却总想将一些试验阶段 css 属性用在生产环境,小白自己抽象了一个全局样式 css 文件,可团队没什么时间沟通,甚至有人私下也注入了不少全局 css 样式,总有人抱怨自己的样式被全局覆盖了,最后小白甚至不得不在自己页面入口处写上 *: unset 清空各种奇怪的全局样式干扰,他想清空那该死的全局 css 样式文件,但他知道这样做带来的是更大的灾难。 可以看到,并不是每个团队都适合做全局样式覆盖。 JS 模块化思维的影响为什么一个项目安装了几百个 npm 三方包,却依然可以正常运行?因为好的三方包都是遵守模块化的,同时也不产生副作用,这样被使用时的效果就可以被预期,试想一下几百个 npm 包里同时定义了不同规范的全局 css 覆盖,你的项目会成为什么样。 当然 js 与 css 是不适合放在一起比较的,css 大多是业务级别的,也就是能写 css 只有做业务的你,第三方包一般是不会提供 css 定义干扰你的项目的。 然而大部分 UI 组件库是自带样式的,他们有自己的设计哲学,但为什么现在你会反感,而当初使用 Bootstrap 不会? 使用 Bootstrap 的时代,Bootstrap 一般是作为项目第一个依赖安装的,我们明确知道它会注入全局样式。我们会泡在他的官方文档目录,一条条理解他做的全局样式规则,他提供的各种 class。 然而现在是一个 Css-in-js 的时代,或者至少是 css-in-npm 的时代,什么都用 npm 装,什么都是模块化的,很多时候我们用一个 UI 组件仅仅是为了在某一处地方使用,而不想接受他带来的全局样式污染,视觉设计哲学,更不想看他的 css 文档。所以好的组件库往往 css 使用的很收敛,尽量不要对用户项目环境造成影响。 如果你项目的样式已经被不得不安装的第三方包全局覆盖得面目全非,每一次对全局样式修改都如履薄冰,可能你会比较反感 css 选择器,你会推崇更安全的 css modules,或甚至是 css-in-js,让每个组件的 className 都唯一,做到标签粒度的隔离。 4 总结笔者认为,在一个确定的环境中,比如一个组件,一个独立负责的模块,是比较适合用 css 选择器的,这样可以让样式代码更易读,DOM 结构更清爽。但请一定注意作用域,如果不是大家一起达成的共识,最好不要放到全局样式中。 就算项目的风格非常明确,a 标签一定要用红色,在把这条规则放到全局样式之前,请思考一下,这样会不会破坏了某个用 a 标签模拟按钮的组件库的样式? css 属性选择器的强大功能,需要有良好的项目管理做支撑,或者通过技术手段比如 shadow dom 做支撑。不过 shadow dom 的支持程度 现在仍然很低,所以使用编译工具做的隔离,在某种程度上模拟了 Css 选择器,承担了 Css 选择器 + shadow dom 的功能。 一切样式都用 className 控制,也许是 shadow dom 出来前的一种妥协方案,这篇文章更多是在描述 Css 选择器设计之美,但需要我们理性去使用。 讨论地址是:精读《使用 CSS 属性选择器》 · Issue ##113 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《使用 css 变量生成颜色主题》","path":"/wiki/WebWeekly/前沿技术/《使用 css 变量生成颜色主题》.html","content":"当前期刊数: 118 作者:五灵 本周工作中遇到类似颜色主题的问题,在查资料的时候,看到这个视频,觉得讲得很清楚,而且趣味性丰富,所以想拿出来讲讲这个很有意思的主题。 视频链接: CSSconf EU 2018 | Dag-Inge Aas & Ida Aalen: Generating Colors with JS and CSS Custom Properties 1. 精读CSS 变量CSS 变量及 CSS Variables(Custom Properties),目前几乎都已经被主流浏览器所支持,但是估计还有一部分读者不熟悉这个功能,简单列举一下使用方法: :root { --bg-color: brown; // 定义颜色变量}.btn { // 直接使用颜色预定义的颜色变量 background-color: var(--bg-color);} Web 内容无障碍指南的对比度Web 内容无障碍指南的对比度指的是 W3C 组织发布的 《Web Content Accessibility Guidelines (WCAG)》,这个指南中涵盖了让 Web 内容更易于访问的各种建议,其中针对网页的颜色对比度发布了规范。 在 Chrome 中对于颜色编辑的时候,打开颜色选择器也会看到当前颜色的对比度值(Contrast ratio)。 网页颜色的对比度值在 1:1 到 21:1 之间,文本和图像文本的的对比度最小值为 4.5:1,也就是说低于这个值得对比度都不符合标准。 我们看一下列举的几种颜色对比度,对比度越高,也越有利于阅读。对比度越低,对于一些存在视力障碍或色觉缺陷的用户,可能就无法阅读。 演讲中的颜色解决方案演讲在最开始首先讲了挪威的一个法律,不符合 Web 内容无障碍指南的站点在挪威是非法的,所以挪威的 Web 开发者非常注重站点的内容无障碍。 首先讲了使用 css 变量的方式,支持各种颜色主题的切换。 利用 js 去设置颜色变量,支持主题的颜色切换。 但是紧接着就提出了问题,如果用户可以随意切换颜色主题背景色,那一些按钮的文字可读性如何去保障呢?如果用户选择了与按钮颜色想接近的背景色,我们又该怎么处理了,紧接着这个演讲给出了根据明度决定按钮文字颜色是黑色还是白色的方案。 根据明度决定是黑色还是白色 具体代码如下,大致原理是把彩色转为灰度的颜色,有一个著名的心理学公式:Gray = R*0.299 + G*0.587 + B*0.114,然后在根据颜色灰度决定使用黑色的主题还是白色的主题。 if (red*0.299 + green*0.587 + blue*0.114) > 186 use ##000000 else use ##ffffff 可读性的问题解决了,但是紧接着又遇到了一个问题,如果用户选取的颜色很浅呢,与背景颜色的对比度小于 4.5,该怎么处理呢。 寻找对比度更强的颜色,增强可读性 演讲中给出的解决方法是不断的加深当前用户选择的颜色,循环获取到对比度最高的同色系颜色。代码如下: 获取了一个更深的颜色后,通过给按钮加一个外边框的方式,优化整体的可读性。 文章最后还介绍了,通过给定一个主题色,获取第二第三主题色的方式,通过将颜色放到 HSL 的颜色轮上,转动 hue 的值 60 度,得到一个新的第二主题色。不过演讲者也没有说清楚为什么要这么做,只是说了这么做是出于经验,觉得这样能够得到一个恰当的主题色盘。 衍生的纯 css 解决方案演讲中提供颜色变更的解决方案基本都是基于 JS 计算的,后来有人在 css-tricks 抛出一篇文章说,这个功能基于 css 就可以完全实现,其实关于颜色的原理都是一致的,只是觉得这个实现更加 magic,但是功能都能够完全满足。比如这篇文章中,关于根据明度决定按钮文字是黑色还是白色的代码如下: :root { --light: 80; /* 文字颜色变化的临界值 */ --threshold: 60;}.btn { /* 会被解析成黑色或者白色 */ --switch: calc((var(--light) - var(--threshold)) * -100%); color: hsl(0, 0%, var(--switch));} 可视化图表对于颜色的应用在可视化图表当中,对于颜色的应用要比 Web 要谨慎的多。我们在做 Web 开发的时候,也不妨来看一下可视化图表当中对于颜色应用的一些规范。在可视化图表中,选择的颜色不可以过于随意,每次颜色的变更都是图表信息的改变,都为图表增加了新的数据,图表的每一种颜色也是要表达的信息。列举一些图表中的颜色使用规范,比如: 不建议使用多种颜色表达同种数据 在多条行图表中,不要使用不同的颜色或颜色轮中对立面的颜色。颜色对比过强会使读者无法专心于数据。 一般而言,应避免颜色的主体性表现,避免使用具有特殊意义的颜色。比如使用红色和绿色表示销售额的变化。 当然对于可视化图表来说,并不是遵循了一些色彩使用的准则,就可以得到一个优雅呈现的可视化图表。注重图表呈现的最重要的视觉元素,在视觉信息角度减少用户,减少用户视觉疲劳也很重要。 3. 相关链接CSS 前景背景自动配色技术简介: https://www.zhangxinxu.com/wordpress/2018/11/css-background-color-font-auto-match/纯 css 解决方案:https://css-tricks.com/switch-font-color-for-different-backgrounds-with-css/获取颜色的 Demo: https://confrere.com/a11y/test/颜色色盘推荐的文章:https://blog.graphiq.com/finding-the-right-color-palettes-for-data-visualizations-fcd4e707a283 讨论地址是:精读《使用 css 变量生成颜色主题》 · Issue ##203 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《低代码逻辑编排》","path":"/wiki/WebWeekly/前沿技术/《低代码逻辑编排》.html","content":"当前期刊数: 197 逻辑编排是用可视化方式描述逻辑,在一般搭建场景中用于代替逻辑描述部分。 更进一步的逻辑编排是前后端逻辑混排,一般出现在一站式 paas 平台,今天就介绍一个全面实现了逻辑编排的 paas 工具 node-red,本周精读的内容是其介绍视频:How To Create Your First Flow In Node-RED,介绍了如果利用纯逻辑编排实现一个天气查询应用,以及部署与应用迁移。 概述想要在本地运行 Node-RED 很简单,只要下面两条命令: npm install -g --unsafe-perm node-rednode-red 之后你就可以看到这个逻辑编排界面了: 我们可以利用这些逻辑节点构建前端网站、后端服务,以及大部分开发工作。光这么说还比较抽象,我们接下来会详细介绍每个逻辑节点的作用,让你了解这些逻辑节点是如何规划设计的,以及逻辑编排到底是怎么控制研发规范来提高研发效率的。 Node-RED 截止目前共有 42 个逻辑节点,按照通用、功能、网络、序列、解析、存储分为六大类。 所有节点都可能有左右连接点,左连接点是输入,右连接点是输出,特殊节点可能有多个输入或多个输出,其实对应代码也不难理解,就是入参和出参。 下面依次介绍每个节点的功能。 通用通用节点处理通用逻辑,比如手动输入数据、调试、错误捕获、注释等。 inject 手动输入节点。可以定期产生一些输入,由下一个节点消费。 举个例子,比如可以定期产生一些固定值,如这样一个这个对象: return { payload: new Date(), topic: "abc",}; 当然这里是用 UI 表单配置的: 之后就是消费,几乎后面任何节点都可以消费,比如利用 change 节点来设置一些环境变量时,或者利用 template 节点设置 html 模版时,都可以拿到这里输入的变量。如果在模版里,变量通过 {{msg.payload}} 访问,如果是其它表单,甚至可以通过下拉框直接枚举选择。 然而这个节点往往用来设置静态变量,更多的输入情况是来自其它程序或者用户的,比如 http in,这个后面会讲到。其实通过这种组合关系,我们可以把任意节点的输入从生产节点替换为 inject 节点,从而实现一些 mock 效果,而 inject 节点也支持配置定时自动触发: debug 用来调试的,当任何输出节点连接到 debug 的输入后,将会在控制台打印出输出信息,方便调试。 比如我们将 inject 的输入连上 debug 的输入,就可以在触发数据后在控制台看到打印结果: 当然如果你把输入连接到 debug,那么原有逻辑就中断了,然而任何输出节点都可以无限制的输出给其它节点,你只要同时把输出连接到 debug 与功能节点就行了: complete 监听某些节点触发完成动作。通过这个节点,我们可以捕获任意节点触发的动作,可以接入 debug 节点打印日志,或者 function 节点处理一下逻辑。 可以监听全部节点,也可以用可视化方式选择要监听哪些节点: catch 错误捕获节点,当任何或指定节点触发错误时输出,输出的格式为: error.message 字符串错误消息。error.source.id 字符串引发错误的节点的ID。error.source.type 字符串引发错误的节点的类型。error.source.name 字符串引发错误的节点的名称。(如果已设置) 其实每个节点都有固定输出格式,这些固定格式限制了开发灵活度,但熟练掌握后可以大大提升开发效率,因为所有同类型节点格式都是一样的,这是逻辑编排带来规则约束的好处。 status 监听节点状态变化。 link in 只能连接 link out。link in、link out 就像一个传送门,用来整理逻辑编排节点,使之看上去易于维护。 比如下面的例子,在一个天气 http in 服务后,穿插了许多逻辑处理节点,有处理响应 html 内容的 template 节点,也有处理请求查询城市天气的 http request 服务,整体逻辑虽然聚合,但比较杂乱: 较好的方式是分类,即类似代码开发中的模块化行为,将天气服务导出,其他任何用到的模块直接导入,这个导入动作就是通过 link in 实现的,link out -> link in 只是一个空间位置的变换,传输值是不会变的: 这样模块看起来清晰了许多,如果要知道各个 “传送门” 见连接关系,只要鼠标点击其中一个就可以给出提示,看起来十分方便: link out 和 link in 成对出现,用来导出输入值,后面对接 link out 可以像传送门一样将值传送过去,在视觉上不会形成连接线。 comment 注释,配合 link 系列使用,可以让逻辑编排 UI 更易于维护。 结合原视频的例子,对于天气服务,有创建环境变量逻辑,有查询逻辑,其中查询天气还分为查询当前天气、连续 5 天天气、查询国家信息,我们可以在 UI 上讲每块逻辑分组,并利用 comment 组件标记好注释,方便阅读: 功能功能型节点,一般用于处理业务逻辑,所以包含了基础的 if else、js 代码、模版处理等等功能模块。 function 最核心的 js 函数模块,你可以用它做任何事: 其输入会传导到 msg 对象,可以通过代码修改 msg 对象后再通过输出节点传导出去。 当然也可以访问和修改节点、流程、全局变量,这个在 change 节点里介绍。 switch 对应代码的 switch,只是用起来更加方便,因为我们可以根据不同 case 导出不同的节点: 注意看上图,因为有三条分支,所以节点的导出项也变成了三个,我们可以根据不同逻辑走不同的连接: change 用来改变环境变量。环境变量分为三种,分别是当前节点、流程(画布)、全局(跨应用)。也就是说,变量可以存储在某个节点上,也可以存储在整个画布上,也可以跨画布存储在全局。 访问参数分别为 msg.、flow.、global.,设置这些参数后,就像全局变量一样,任何节点都可以在任何地方使用,比较方便。 比如应用固定了一些 URL 地址,直接把一串字符串写死在某个 http in 节点里并不明智,因为后面的 html 或者其它节点里可能会访问它,一旦你进行修改,影响面会非常广,因此最好将其设置为全局变量,在节点中通过变量方式访问: 其实在控制台,可以看到这三种变量的值: 当我们利用 change 节点赋值后,可以通过调试面板查看不同作用域全局变量的值: range 区间映射,将一个范围的值映射到另一个范围。其实通过 function 模块也能完成,只是因为比较常用所以封装了一个特殊节点。其实用户也可以自己封装节点,具体方式可以参考 官方文档。 上图很容易理解,比如数据分析中归一化就可以用这个节点实现。 template 以模版方式生成字符串或 json。 其实本质上也可以被 function 代替,只是用来写模版的话有高亮,维护起来比较方便。 内置了 mustache 模版语法,通过 {{}} 方式使用变量。 比如我们通过 inject 注入一个变量给 template,并通过 debug 打印,流程是这样的: 其中 inject 是这么配置的: 可以看到,将 msg.name 设置为一个字符串,然后通过 template 访问 name: delay 延迟发消息,一个快捷的工具,可以放在任何输入与输出中间,比如让上面的例子中,inject 触发后 5s 再打印结果,可以这么配置: trigger 一个消息触发器,相比 inject,可以更灵活的设置何时重新触发。 从配置可以看出,首先和 inject 一样发送一条消息,然后可以等待,或者等待被重置,或者周期性触发(这样就和 inject 一样),其中 “发送第二条消息到单独的输出” 和 switch 一样会多一个输出口。 然后有重置条件,即 payload 为什么值时重置。 通过这个组件可以看出来,其实每个节点都可以用 function 节点实现,只不过通过定制一个节点,可以用 UI 而非代码的方式配置,使用起来更方便。 exec 执行系统命令,比如 ls 等,这个在系统后台执行而非前端,所以是一个相当危险的节点。 我们可以在配置中写入任何命令: rbe 异常报告节点(Report by Exception),比如说当输入变化时进行阻塞。 网络用于创建网络服务,比如 http、socket、tcp、udp 等等,因为其它都不常用,这次仅介绍 http 服务。 http in 创建一个 http 服务,可以是任何接口或者 web 服务。 当你把 Method 设置为 post,连接到 http response 就创建了后端接口;当设置为 get 请求,并连接 template 写上 html 模版,并连接到 http response 就创建了 web 服务。 虽然这种方式创建 web 服务难以使用 react 或 vue 框架,不过自定义节点还是为其创造了可能性,或许真的可以把前端模块化文件定义为节点相互串联。 http response http 返回,只能对接 http in 的输出,总是与 http in 成对使用。 如果只用了 http in 但没有用 http response,就相当于后端代码里处理了请求,但没有调用类似: res.send("hello word"); 来向客户端发送内容。 http request 与 http in 创建一个 http 服务不同,http request 直接发送一个网络请求并将返回值导入到输出节点。 视频中获取天气的例子,就用了 http request 发起请求获取天气信息: 不难看出,发送请求后,又使用了 function 节点处理返回结果。不过在逻辑编排中还是期望少使用 function 节点,因为除非有很好的命名,否则难以看出来节点含义,如果 function 处理内容过多或者 function 区块过多,就失去了逻辑编排的意义。 序列序列是对数组进行处理的节点。 split 对应代码的 split,将字符串变为数组。 join 对应代码的 join,一般与 split 配合使用,方便处理字符串。 sort 对应代码 sort,只能根据 key 做简单的升序降序处理,对于简单场景比较方便,但对于复杂场景可能还会使用 function 节点代替。 batch 批量接收输入流后,根据数量进行打包后统一输出,等于批量打包,可以按照数量或者时间间隔进行分组: 解析 很容易理解,专门处理上述格式的数据,并按照数据特征输出,比如 csv 数据,可以每行一条消息的方式输出,或者打包为一个大数组以一条消息输出。 当然也可以被 function 节点代替,那么解析方式与输出方式都可以自定义。 存储持久化存储,一般存储为文件。 file 输出为文件。 file in 以文件作为输入,并将文件结果作为输出。 watch 监听目录或文件的修改。 精读看了上面 node-red 功能后,相信你对逻辑编排已经有较为体系化的认识了。 逻辑编排的目的是为了让非研发人群也可以快速上手研发工作,因此注定是为 paas 工具服务的,而逻辑编排到底好不好用,取决于节点功能是否完备,以及各节点之间通信是否顺畅,像 node-red 逻辑编排方案,在完备性上做的较为成熟,可以说只要熟练掌握了几个核心节点规则,使用起来还是非常提效的。 逻辑编排也有天然缺点,就是当所有节点都退化为 function 节点后,会存在两个问题: 所有节点都是 function 节点,即便有说明,但内部实现逻辑非常自由,导致逻辑编排无法起到约束输入输出的作用。 退化到代码函数式调用,本质上与写代码无异。逻辑编排之所以提效,很大程度上是我要的业务逻辑刚好与节点功能匹配,以低成本 UI 配置的方式实现效率才高。 然而这也是有解决方法的,如果你的业务无法被现有的逻辑编排节点满足,你可以尝试抽象一下,自己梳理出业务常用的节点,并用合理的配置封装,只要常用业务逻辑可以被封装为逻辑节点,逻辑编排就还有为业务提效的空间。 总结逻辑编排是一种极端,即用 UI 方式描述通用业务逻辑,降低非专业开发人员的上手门槛。通过对 node-red 的分析可以发现,一个较为完备的逻辑编排系统还是能带来价值的。 然而针对非专业开发人员降本提效还有一种极端,就是完全代码化,但是把代码模块化、函数库、工具链甚至低代码平台建设的非常完备,以至于写代码的效率根本不低,这条路走到极致也不错,因为既然要深入开发系统,同样是投入时间学习,为什么学习写代码就一定比学习拖拽 UI 效率低呢?如果有高度封装的函数与工具辅助,效率不见得比 UI 拖拽来的低。 然而 node-red 在创建前端 UI 的模版上还可以再增强一下,把 template 从节点升级为 UI 搭建画布,逻辑编排仅用来处理逻辑,这样对大型全栈项目的前端开发体验会更好。 讨论地址是:精读《低代码逻辑编排》· Issue ##319 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《Webpack5 新特性 - 模块联邦》","path":"/wiki/WebWeekly/前沿技术/《Webpack5 新特性 - 模块联邦》.html","content":"当前期刊数: 144 1 引言先说结论:Webpack5 模块联邦让 Webpack 达到了线上 Runtime 的效果,让代码直接在项目间利用 CDN 直接共享,不再需要本地安装 Npm 包、构建再发布了! 我们知道 Webpack 可以通过 DLL 或者 Externals 做代码共享时 Common Chunk,但不同应用和项目间这个任务就变得困难了,我们几乎无法在项目之间做到按需热插拔。 模块联邦是 Webpack5 新内置的一个重要功能,可以让跨应用间真正做到模块共享,所以这周让我们通过 webpack-5-module-federation-a-game-changer-in-javascript-architecture 这篇文章了解什么是 “模块联邦” 功能。 2 概述 & 精读NPM 方式共享模块想象一下正常的共享模块方式,对,就是 NPM。 如下图所示,正常的代码共享需要将依赖作为 Lib 安装到项目,进行 Webpack 打包构建再上线,如下图: 对于项目 Home 与 Search,需要共享一个模块时,最常见的办法就是将其抽成通用依赖并分别安装在各自项目中。 虽然 Monorepo 可以一定程度解决重复安装和修改困难的问题,但依然需要走本地编译。 UMD 方式共享模块真正 Runtime 的方式可能是 UMD 方式共享代码模块,即将模块用 Webpack UMD 模式打包,并输出到其他项目中。这是非常普遍的模块共享方式: 对于项目 Home 与 Search,直接利用 UMD 包复用一个模块。但这种技术方案问题也很明显,就是包体积无法达到本地编译时的优化效果,且库之间容易冲突。 微前端方式共享模块微前端:micro-frontends (MFE) 也是最近比较火的模块共享管理方式,微前端就是要解决多项目并存问题,多项目并存的最大问题就是模块共享,不能有冲突。 由于微前端还要考虑样式冲突、生命周期管理,所以本文只聚焦在资源加载方式上。微前端一般有两种打包方式: 子应用独立打包,模块更解耦,但无法抽取公共依赖等。 整体应用一起打包,很好解决上面的问题,但打包速度实在是太慢了,不具备水平扩展能力。 模块联邦方式终于提到本文的主角了,作为 Webpack5 内置核心特性之一的 Federated Module: 从图中可以看到,这个方案是直接将一个应用的包应用于另一个应用,同时具备整体应用一起打包的公共依赖抽取能力。 让应用具备模块化输出能力,其实开辟了一种新的应用形态,即 “中心应用”,这个中心应用用于在线动态分发 Runtime 子模块,并不直接提供给用户使用: 对微前端而言,这张图就是一个完美的主应用,因为所有子应用都可以利用 Runtime 方式复用主应用的 Npm 包和模块,更好的集成到主应用中。 模块联邦的使用方式如下: 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,插件有几个重要参数: name 当前应用名称,需要全局唯一。 remotes 可以将其他项目的 name 映射到当前项目中。 exposes 表示导出的模块,只有在此申明的模块才可以作为远程依赖被使用。 shared 是非常重要的参数,指定了这个参数,可以让远程加载的模块对应依赖改为使用本地项目的 React 或 ReactDOM。 比如设置了 remotes: { app_two: "app_two_remote" },在代码中就可以直接利用以下方式直接从对方应用调用模块: import { Search } from "app_two/Search"; 这个 app_two/Search 来自于 app_two 的配置: // 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《What\"s new in javascript》","path":"/wiki/WebWeekly/前沿技术/《What's new in javascript》.html","content":"当前期刊数: 105 1. 引言本周精读的内容是:Google I/O 19。 2019 年 Google I/O 介绍了一些激动人心的 JS 新特性,这些特性有些已经被主流浏览器实现,并支持 polyfill,有些还在草案阶段。 我们可以看到 JS 语言正变得越来越严谨,不同规范间也逐渐完成了闭环,而且在不断吸纳其他语言的优秀特性,比如 WeakRef,让 JS 在成为使用范围最广编程语言的同时,也越成为编程语言的集大成者,让我们有信心继续跟随 JS 生态,不用被新生的小语种分散精力。 2. 精读本视频共介绍了 16 个新特性。 private class fields私有成员修饰符,用于 Class: class IncreasingCounter { ##count = 0; get value() { return this.##count; } increment() { this.##count++; }} 通过 ## 修饰的成员变量或成员函数就成为了私有变量,如果试图在 Class 外部访问,则会抛出异常: const counter = new IncreasingCounter()counter.##count// -> SyntaxErrorcounter.##count = 42// -> SyntaxError 虽然 ## 这个关键字被吐槽了很多次,但结论已经尘埃落定了,只是个语法形式而已,不用太纠结。 目前仅 Chrome、Nodejs 支持。 Regex matchAll正则匹配支持了 matchAll API,可以更方便进行正则递归了: const string = 'Magic hex number: DEADBEEF CAFE'const regex = /\\b\\p{ASCII_Hex_Digit}+\\b/gu/for (const match of string.matchAll(regex)) { console.log(match)}// Output:// ['DEADBEEF', index: 19, input: 'Magic hex number: DEADBEEF CAFE']// ['CAFE', index: 28, input: 'Magic hex number: DEADBEEF CAFE'] 相比以前在 while 语句里循环正则匹配,这个 API 真的是相当的便利。And more,还顺带提到了 Named Capture Groups,这个在之前的 精读《正则 ES2018》 中也有提到,具体可以点过去阅读,也可以配合 matchAll 一起使用。 Numeric literals大数字面量的支持,比如: 1234567890123456789 * 123;// -> 151851850485185200000 这样计算结果是丢失精度的,但只要在数字末尾加上 n,就可以正确计算大数了: 1234567890123456789n * 123n;// -> 151851850485185185047n 目前 BigInt 已经被 Chrome、Firefox、Nodejs 支持。 BigInt formatting为了方便阅读,大数还支持了国际化,可以适配成不同国家的语言表达形式: const nf = new Intl.NumberFormat("fr");nf.format(12345678901234567890n);// -> '12 345 678 901 234 567 890' 记住 Intl 这个内置变量,后面还有不少国际化用途。 同时,为了方便程序员阅读代码,大数还支持带分隔符的书写方式,可以使用 useGrouping 属性配置,默认为 true: const nf = new Intl.NumberFormat("fr", { useGrouping: true });nf.format(12345678901234567890n);// -> '12 345 678 901 234 567 890' 目前已经被 Chrome、Firefox、Nodejs 支持。 flat & flatmap等价于 lodash flatten 功能: const array = [1, [2, [3]]];array.flat();// -> [1, 2, [3]] 还支持自定义深度,如果支持 Infinity 无限层级: const array = [1, [2, [3]]];array.flat(Infinity);// -> [1, 2, 3] 这样我们就可以配合 .map 使用: [2, 3, 4].map(duplicate).flat(); 因为这个用法太常见,js 内置了 flatMap 函数代替 map,与上面的效果是等价的: [2, 3, 4].flatMap(duplicate); 目前已经被 Chrome、Firefox、Safari、Nodejs 支持。 fromEntriesfromEntries 是 Object.fromEntries 的语法,用来将对象转化为数组的描述: const object = { x: 42, y: 50, abc: 9001 };const entries = Object.entries(object);// -> [['x', 42], ['y', 50]] 这样就可以对对象的 key 与 value 进行加工处理,并通过 fromEntries API 重新转回对象: const object = { x: 42, y: 50, abc: 9001 }const result = Object.fromEntries( Object.entries(object) .filter(([ key, value]) => key.length === 1) .map(([ key, value ]) => [ key, value * 2]))// -> { x: 84, y: 100 } 不仅如此,还可以将 object 快速转化为 Map: const map = new Map(Object.entries(object)); 目前已经被 Chrome、Firefox、Safari、Nodejs 支持。 Map to Object conversionfromEntries 建立了 object 与 map 之间的桥梁,我们还可以将 Map 快速转化为 object: const objectCopy = Object.fromEntries(map); 目前已经被 Chrome、Firefox、Safari、Nodejs 支持。 globalThis 业务代码一般不需要访问全局的 window 变量,但是框架与库一般需要,比如 polyfill。 访问全局的 this 一般会做四个兼容,因为 js 在不同运行环境下,全局 this 的变量名都不一样: const getGlobalThis = () => { if (typeof self !== "undefined") return self; // web worker 环境 if (typeof window !== "undefined") return window; // web 环境 if (typeof global !== "undefined") return global; // node 环境 if (typeof this !== "undefined") return this; // 独立 js shells 脚本环境 throw new Error("Unable to locate global object");}; 因此整治一下规范也合情合理: globalThis; // 在任何环境,它就是全局的 this 目前已经被 Chrome、Firefox、Safari、Nodejs 支持。 Stable sort就是稳定排序结果的功能,比如下面的数组: const doggos = [ { name: "Abby", rating: 12 }, { name: "Bandit", rating: 13 }, { name: "Choco", rating: 14 }, { name: "Daisy", rating: 12 }, { name: "Elmo", rating: 12 }, { name: "Falco", rating: 13 }, { name: "Ghost", rating: 14 }];doggos.sort((a, b) => b.rating - a.rating); 最终排序结果可能如下: [ { name: "Choco", rating: 14 }, { name: "Ghost", rating: 14 }, { name: "Bandit", rating: 13 }, { name: "Falco", rating: 13 }, { name: "Abby", rating: 12 }, { name: "Daisy", rating: 12 }, { name: "Elmo", rating: 12 }]; 也可能如下: [ { name: "Ghost", rating: 14 }, { name: "Choco", rating: 14 }, { name: "Bandit", rating: 13 }, { name: "Falco", rating: 13 }, { name: "Abby", rating: 12 }, { name: "Daisy", rating: 12 }, { name: "Elmo", rating: 12 }]; 注意 choco 与 Ghost 的位置可能会颠倒,这是因为 JS 引擎可能只关注 sort 函数的排序,而在顺序相同时,不会保持原有的排序规则。现在通过 Stable sort 规范,可以确保这个排序结果是稳定的。 目前已经被 Chrome、Firefox、Safari、Nodejs 支持。 Intl.RelativeTimeFormatIntl.RelativeTimeFormat 可以对时间进行语义化翻译: const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });rtf.format(-1, "day");// -> 'yesterday'rtf.format(0, "day");// -> 'today'rtf.format(1, "day");// -> 'tomorrow'rtf.format(-1, "week");// -> 'last week'rtf.format(0, "week");// -> 'this week'rtf.format(1, "week");// -> 'next week' 不同语言体系下,format 会返回不同的结果,通过控制 RelativeTimeFormat 的第一个参数 en 决定,比如可以切换为 ta-in。 Intl.ListFormatListFormat 以列表的形式格式化数组: const lfEnglish = new Intl.ListFormat("en");lfEnglish.format(["Ada", "Grace"]);// -> 'Ada and Grace' 可以通过第二个参数指定连接类型: const lfEnglish = new Intl.ListFormat("en", { type: "disjunction" });lfEnglish.format(["Ada", "Grace"]);// -> 'Ada or Grace' 目前已经被 Chrome、Nodejs 支持。 Intl.DateTimeFormat -> formatRangeDateTimeFormat 可以定制日期格式化输出: const start = new Date(startTimestamp);// -> 'May 7, 2019'const end = new Date(endTimestamp);// -> 'May 9, 2019'const fmt = new Intl.DateTimeFormat("en", { year: "numeric", month: "long", day: "numeric"});const output = `${fmt.format(start)} - ${fmt.format(end)}`;// -> 'May 7, 2019 - May 9, 2019' 最后一句,也可以通过 formatRange 函数代替: const output = fmt.formatRange(start, end);// -> 'May 7 - 9, 2019' 目前已经被 Chrome 支持。 Intl.Locale定义国际化本地化的相关信息: const locale = new Intl.Locale("es-419-u-hc-h12", { calendar: "gregory"});locale.language;// -> 'es'locale.calendar;// -> 'gregory'locale.hourCycle;// -> 'h12'locale.region;// -> '419'locale.toString();// -> 'es-419-u-ca-gregory-hc-h12' 目前已经被 Chrome、Nodejs 支持。 Top-Level await支持在根节点生效 await,比如: const result = await doSomethingAsync();doSomethingElse(); 目前还没有支持。 Promise.allSettled/Promise.anyPromise.allSettled 类似 Promise.all、Promise.any 类似 Promise.race,区别是,在 Promise reject 时,allSettled 不会 reject,而是也当作 fulfilled 的信号。 举例来说: const promises = [ fetch("/api-call-1"), fetch("/api-call-2"), fetch("/api-call-3")];await Promise.allSettled(promises); 即便某个 fetch 失败了,也不会导致 reject 的发生,这样在不在乎是否有项目失败,只要拿到都结束的信号的场景很有用。 对于 Promise.any 则稍有不同: const promises = [ fetch("/api-call-1"), fetch("/api-call-2"), fetch("/api-call-3")];try { const first = await Promise.any(promises); // Any of ths promises was fulfilled. console.log(first);} catch (error) { // All of the promises were rejected.} 只要有子项 fulfilled,就会完成 Promise.any,哪怕第一个 Promise reject 了,而第二个 Promise fulfilled 了,Promise.any 也会 fulfilled,而对于 Promise.race,这种场景会直接 rejected。 如果所有子项都 rejected,那 Promise.any 也只好 rejected 啦。 目前已经被 Chrome、Firefox 支持。 WeakRefWeakRef 是从 OC 抄过来的弱引用概念。 为了解决这个问题:当对象被引用后,由于引用的存在,导致对象无法被 GC。 所以如果建立了弱引用,那么对象就不会因为存在的这段引用关系而影响 GC 了! 具体用法是: const obj = {};const weakObj = new WeakRef(obj); 使用 weakObj 与 obj 没有任何区别,唯一不同时,obj 可能随时被 GC,而一旦被 GC,弱引用拿到的对象可能就变成 undefined,所以要做好错误保护。 3. 总结JS 这几个特性提升了 JS 语言的成熟性、完整性,而且看到其访问控制能力、规范性、国际化等能力有着重加强,解决的都是 JS 最普遍遇到的痛点问题。 那么,这些 JS 特性中,你最喜欢哪一条呢?想吐槽哪一条呢?欢迎留言。 讨论地址是:精读《What’s new in javascript》 · Issue ##159 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《全链路体验浏览器挖矿》","path":"/wiki/WebWeekly/前沿技术/《全链路体验浏览器挖矿》.html","content":"当前期刊数: 39 本期精读的文章是: coinhive 官方文档及Monero 官方文档 懒得看文章?没关系。 咦,怎么是官方文档? 本期精读有所不同,注重实操,先操作获取感性认识,然后再介绍相关的概念,由浅入深,力求不纠缠细节,但不遮盖环节。阅读本文需要对加密货币有一些基本常识 (如果你是一个开发者但是完全没了解过加密货币,可以参考这里)。希望同学们看完本文能对加密货币领域有一个更深更切实的感受。如果要了解更多细节,文末总结的延伸阅读链接列表是最好的开始。 要注意一点,文中很多说明是默认基于 XMR 和 BTC 的,他们两个又同源,机制非常相似。所以很多命题判断并不适用于所有的成千上万的加密货币. 正相反,新的币种层出不穷,几乎所有的惯例都被打破,所有可能性都被尝试。这一点以下不再做说明。 1 引言首先,干货。10 行代码,5 分钟,不需部署不需构建直接浏览器开挖。 本地创建文件 test.html,粘贴如下代码: <!doctype html><html><head> <title>Mining</title></head><body> <div class="coinhive-miner" style="width: 256px; height: 310px" data-key="MUtCJzIDhrs01ERrf3qlqdawo35N0CYD"> <em>Loading...</em> </div> <script src="https://authedmine.com/lib/simple-ui.min.js" async></script></body></html> 本地双击打开。等待 JS 加载,点击 widget “Start Mining”。开始挖矿! 如下图 不错,数字已经在跳动,风扇开始工作,永无尽头的挖矿已经开始了。那么重要的是,挖出来的加密货币在哪呢?原来上面的代码里用的还是我的 API key,所以还没挖到你自己那里,所以请继续下面的步骤: 在 coinhive 注册账号并登陆。它是做什么的?别急,后面会详细讲。在 coinhive/settings 找到自己的 API keypair,把 public key 复制出来,形如 MUtCJzIDhrs01ERrf3qlqdawo35N0CYD 替换上面代码中的 data-key 部分,重新开始挖矿。好了,现在挖出的 Monero (这是啥?详见下一节) 已经会到你自己的 coinhive 账户中。用下图来说明,你名下总计算过的 Hash 个数为 264K,当前难度换算为 0.00002 个 Monero(Symbol:XMR)。当前难度为 66G 一个 block,一个 block reward 5.87 XMR,得到一个 XMR 是 11.268G。264K/11.268G = 0.0000234。这就是你目前的收益。 查 bitfinex 可得现在 XMR 价格在 375 美元 (当你看到本文的时候,价格可能早就又波动到不知哪里去了),所以你 (以及你忠实勤劳的电脑) 获得的实际收益为 0.000234 * 375 USD = 0.008775 USD,快到一分钱了 :) 怎么样,有没有一种浏览器点开即玩一刀 999 级的感觉。以上操作的便捷直接,是建立在无数前人大量的开发和基础设施建设之上的。越是领域早期的工作,越步履维艰,收货也越丰厚。如今加密货币已经走到了一个成年期,逐渐稳定成熟起来。 接下来我们聚焦到上面过程的每个环节,了解下拼图的每一块是怎样被构成全图的。 2 聚焦让我们从最终端最接近用户的环节开始,逐一聚焦,最后走完整条链路。 2.1 从浏览器说起本文标题叫浏览器挖矿,也是和贴合前端的部分。那么为什么可以在浏览器里挖矿?为什么可以很多用户在多个终端浏览器同时为同一个人 (你) 挖矿? 我们知道,挖矿是对加密货币产生机制的俗称。主流大多采取 Proof of Work (PoW) 机制。最常见的 PoW 方式就是由网络中所有节点作为矿工,每个节点都基于 blockchain 前面 block 已有信息计算一个新信息。这个新信息的计算方式往往是某种 hash function,并且人为地被设置为需要巨大计算力和时间才能完成 (其具体难度一般也会实时调整)。当一个节点幸运地 (也依靠强大的算力) 第一个计算出结果后,会把这个结果广播到网络中。其他所有节点会验证这个结果 (我们知道非对称加密算法,验证便宜而计算昂贵),一旦证实就会停下手里的计算,承认这个计算结果。新的计算结果创造出新的块,区块链的高度增加一层,然后计算继续下去。每一个块的生成一般在 2-10 分钟。这个过程就被叫做挖矿. 既然是通用计算,既然是算一个 hash 值,那么民用级 CPU 和 GPU,浏览器或任何沙盒,虚拟机,移动设备当然就都可以。在我们的例子中,计算过程被做成分布式,每个用户可以各自计算,结果按 chunk 发回 master 汇总。这样就实现了终端用户 - 浏览器 - 共同贡献计算资源 - 换为 XMR 的过程。 这就是对整个链路的一个描述。从中我们会生出一些疑问,比如: 给我看看具体算什么 hash?为什么要算 XMR 而不是比特币或者其他?既然第一个算出的赢家通吃所有,为什么我的收益却是线性的?这种描述来看岂不是算力最大的一方永远都能算出结果而其他人颗粒无收吗? 要看具体算法,没有问题。bitcoin 在这里,XMR 则看CryptoNote Standard 008 读完两个算法我们就有了以上疑问的答案: 2.1.1 为什么要算 XMR 而不是比特币或者其他XMR 不是唯一选择,但是 BTC 是一个不可选的选择。因为 double SHA-265 在专业级 GPU 上会比 CPU 上快 10^4 倍 (更多信息)。这样一百万用户合力浏览器挖矿还不如一架子双路 Titan,就失去了分布到终端用户的意义。而 CryptoNight 在 GPU 上只比同价值 CPU 快 2 倍。另外 CryptoNight 算法也被设计为不适用ASIC。 怎么实现的?CryptoNight 算法开宗明义地写明,运算主体是 Memory-Hard Loop,而不是 Computation-Hard Loop。每个循环都要在内存中检索。实际运行 CryptoNight 时,CPU 都会用最快,最接近 ALU 也是容量最小的 L3 Cache。换到 GPU,显存虽然很大,却没有 L3 Cache 一样的极致读写速度优化,而且由于内存读写成了瓶颈,GPU 中的大量 ALU 也没了用武之地。下图简略地描述了 CryptoNight 循环体的结构: 2.1.2 算力最大的一方永远都能算出结果看了比特币具体算法,你应该明白了 hash 是靠不停改变 nonce 来生成的。随机取一个值,算了不对,再随机取另一个 nonce 值..。既然是随机取,就不会存在赢家恒赢. 2.1.3 为什么我的收益却是线性的这是一个隐蔽但是却非常重要的问题。答案是,本来确实是赢家通吃。如果你的算力足够大,挖矿时间足够长之后总会轮到你,但是收益会有大幅波动. 就是因为如此,矿工们逐渐建立了矿池组织。大家把算力都投入到一起,合力算,然后不管这次实际是谁算出来,都按照贡献的算力比例分配收益。矿池是一个加密货币建立之初,完全推崇去中心化时没有预料到的结构,也产生了深远的影响。现在来自中国矿池的算力早已超过网络 50%,他们会在挖出的块中打上矿池标记,而这些矿池在加密货币的分叉,路线图中也扮演举足轻重的角色. 所以你的算力并不是直接投入 XMR 网络中,而是投入一个矿池。在我们的例子里矿池就是 coinhive,只不过是一个比较特殊的矿池,特殊在矿池成员都运作在浏览器中。这就是为什么你会得到线性收益而不是 all or none. 自古以来各行业都会自发产生行业工会,建立类似保险和行业守则 / 规范这些人人为我我为人人的机制。在 crypto 行业也不例外。这是意料之外而情理之中. 2.2 在浏览器里发生了什么,或 coinhive 干了什么好,我们搞清了一些基本的 Monero 挖矿机制,下面来看看 coinhive。已经知道 coinhive 帮我们接入它的矿池,让再小的算力也能按比例得到产出。但是还有什么呢?最关键的一点,coinhive 是怎样把一个完整的 mining 过程拆分成小块,让一个或许并不强大的设备上的浏览器,也能快速接收 task,快速完成并且即时上传的呢? 废话不多说,打开源码。本项目没有开源,构建完成后的在https://coinhive.com/lib/coinhive.min.js。先做初步 format 处理,发现有些工作完成在后端,worker shard 一侧。以下用松散的伪码总结一下 client side 的main success scenario流程 (注意很多地方简化了): - start- loadWorkerResource- load worker-asmjs.min.js- CRYPTONIGHT_WORKER_BLOB = createObjectURL(Blob(response_of_worker-asmjs.min.js))- _startNow -> _connectAfterSelfTest- selfTest -> verify(testJob), testJob = { verify_id: "1", nonce: "204f150c", result: "6a9c7dea83b079ce0e012907dd6929bcb0aeec3c1f06c032ca7c3386432bca00", blob: "0606c6d8cfd005cad45b0306350a730b0354d52f1b6d671063824287ce4a82c971d109d56d1f1b00000000ee2d1d4fd7c18bdc1b24abb902ac8ecc3d201ffb5904de9e476a7bbb0f9ec1ab04" }; verify = if (!this._isReady) { this.verifyJob = job } else { this.worker.postMessage(job) } // 实例化若干个JobThread,每个对应一个worker,worker实际执行asmjs.min.js- _connect // verify成功,终于建立连接。根据public key固定hash到一个shard池然后随机选一个shard,建立websocket- websocket.onmessage: if (type==job) work()- work: do { hash(input,output) } while !(meetTarget(output)); websocket.postMessage({nonce,output}) // hash done successfully。submit 看一下这个过程,结合cns003 XMR blockchain specs。XMR 的整体 hash input 很小,是: - size of [block_header,Merkle root hash,and the number of transactions] in bytes (varint)- block_header,- Merkle root hash,- number of transactions (varint). 这样用 websocket 发过来毫无问题。之后就是完全独立的计算,调整 nonce 来算不同的 hash 结果。target 就是当前难度的一个指示. 这样整条链路就比较清晰了。再思考一下以下问题: 2.2.1 为什么 XMR 适宜分布式客户端计算因为能够利用每个用户的 CPU 和其中的高速 L3 Cache。这是中心化执行难以具备的条件. 任何时候当考虑要不要把某项操作推到客户端进行时,都要想明白可以利用客户端的哪个资源,这个资源在客户端是否有明显优势,是否比后端中心化执行更有利。很多时候答案是,优势并不明显。那么引入的网络通信成本,法规成本,额外开销可能就并不值得. 2.3 最后的步骤到了这里,最后剩余步骤就很标准模式化了。coinhive 作为矿场,代管着用户生产的加密货币。用户发请求提出 XMR,就需要提到一个自己的钱包地址保管,比如 MyMonero。也可以直接提到 Exchange 交易所,在其中交易成其他币种,包括法币,然后电汇等等形式提现. 如果对钱包或者交易所感兴趣 (这两个也是很大的话题,比如钱包分硬件钱包和软件钱包,离线冷钱包和线上热钱包,private key 和 recover seeds。交易所有多种多样的交易对,有杠杆,期货,空和多,多种挂单类型等等),可以看这里和这里. 3 更多讨论 讨论地址是:精读《全链路体验浏览器挖矿》 · Issue ##55 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。 4 延伸阅读http://piotrpasich.com/introduction-bitcoin-for-developers https://en.wikipedia.org/wiki/Monero_(cryptocurrency) http://www.righto.com/2014/02/bitcoin-mining-hard-way-algorithms.html https://cryptonote.org/cns/cns001.txtcns001-008 是 Specs 集合 https://en.bitcoin.it/wiki/Why_a_GPU_mines_faster_than_a_CPU https://en.wikipedia.org/wiki/Application-specific_integrated_circuit https://github.com/txbits/txbits"},{"title":"《初探 Reason 与 GraphQL》","path":"/wiki/WebWeekly/前沿技术/《初探 Reason 与 GraphQL》.html","content":"当前期刊数: 40 本期精读的文章是: Exploring Reason and GraphQL 1 引言2018 年了,Reason 生态发展了不少,而且正好看到一篇文章的作者也抱着这种心态尝鲜 React + graphql,索性调研一下,看看这套前沿的方案是否有落地对可能性。 2 内容概要一切皆模块在 reason 中,一切皆模块,而且不需要手动申明导出与引用,这个是 js 的痛点。以下面的代码为例: open Data;let typeDef = {| type Author { id: Int! firstName: String lastName: String posts: [Post] ## the list of Posts by this author }|};type resolvers = {. "posts": Js.Array.t(post)};let resolvers = { "posts": (author: Data.author) => Js.Array.filter((post) => post###authorId === author###id, posts)}; 第一行的 open 类似 js 中的 import,不同的是,js 中需要通过 Data.post 访问对象,而 reason 可以直接访问 post。不过也可以补全引用,比如 Data.author。 在定义 graphQL 类型时,graphql-tools 允许通过 [Post] 的语法将文章对象关联到作者。 内置不可变数据类型检测reason 中,一切类型都是 immutable 的,如果使用如下代码直接修改 post.votes,则会报错: Mutation: { upvotePost: (_, { postId }) => { const post = find(posts, { id: postId }); if (!post) { throw new Error(`Couldn't find post with id ${postId}`); } post.votes += 1; return post; },}, 可以通过 ref 告诉编译器,votes 可能是 mutable 的: type post = {. "id": int, "authorId": int, "title": string, "votes": ref(int)}; 最后作者介绍了如何通过 apollo-server 搭建后端代码,与 reason 结合使用。 我试了下,真的非常方便,后端定义好接口,会自动生成一份在线文档供前端查询,完全屏蔽了接口这一层,只要搜索要查询的元素即可。 3 精读graphql前端后沟通成本一直是个问题,以至于很多团队想做一个 “接口查询平台” 之类的系统。 当然,无论是解析后端代码也好,平台录入也好,还是 mock 平台反推,都不太理想: 解析后端代码,工作量比较大,而且还需要约定一些格式,其实越做越像 graphql,投入的话还不如考虑使用 graphql。 一条条接口录入方案是可行的,技术成本也几乎为零,但问题是后续代码变动会导致平台与实际接口不一致,或者某些项目甚至绕过了接口录入,导致一些接口游离在平台之外,无法聚合管理。 先通过 mock 平台联调,再读取 mock 平台数据,生成接口列表同样存在后端代码变动导致 mock 结构过期的问题。 如果不考虑需求变动,后端采用 graphql 其实是成本最小的选择,其一是类似 apollo-server 这类框架做了一个 IDE 供查询实体,同时绕过了接口,直接暴露数据,效率更高。其二是可以做到代码变动后文档实时同步,只要后端代码更新,文档也会自动更新。 不过对于后端代码并不掌握在前端的团队来说,如果不推动后端改造成 graphql,是无法享受到这个好处的,这时如果搭建一个 node 版 graphql 桥梁,那又如何衔接这个桥梁与后端呢?所以使用 graphql 的若不是第一手后端代码,使用后也不会有多少效果。 更多细节可以访问 GraphQL and Relay 浅析,那篇是基于 relay 的,现在 apollo-server 看上去是更轻量级的方案。 reason最近的 3.0 版本使用 JavaScript 的 application/abstraction 语法代替了 OCaml 的语法,看上去稍微顺眼一些了: myFunction(arg1, arg2) // 3.0 语法myFunction arg1 arg2 // 2.0 语法 能看出来 reason 在往 js 开发社区靠,不过大部分语法对 js 开发者都比较陌生,相比于 typescript,跳跃性有点太大了。 reason react使用 reason 写一个 react 组件是这样的: let component = ReasonReact.reducerComponent("Greeting");let make = (~name, _children) => { ...component, initialState: () => 0, /* here, state is an `int` */ render: (self) => { let greeting = "Hello " ++ name ++ ". You've clicked the button " ++ string_of_int(self.state) ++ " time(s)!"; <div>{ReasonReact.stringToElement(greeting)}</div> }}; ~name 称为 Labeled Arguments,也就是,调用函数时,可以无视顺序,显示指定入参名:make(~name=5),initialState 对应 reactjs 中 state,其他与 reactjs 都很像。 reason react 更新 state相比 react 的 setState,reason react 提供了 reducer 支持,这里可以类比到 redux: let make = (_children) => { ...component, initialState: () => {count: 0, show: false}, reducer: (action, state) => switch (action) { | Click => ReasonReact.Update({...state, count: state.count + 1}) | Toggle => ReasonReact.Update({...state, show: ! state.show}) }, render: (self) => { let message = "Clicked " ++ string_of_int(self.state.count) ++ " times(s)"; <div> <MyDialog onClick={_event => self.send(Click)} onSubmit={_event => self.send(Toggle)} /> {ReasonReact.stringToElement(message)} </div> }}; 除了类型提示支持模式匹配(ts 也支持了)比较完美之外,其他和 redux 还真没啥区别。 至于 immutable 特性,reason 本身也只支持 immutable 检测而已,同时支持了结构语法,可以较为方便进行 immutable 计算(es 也支持了)。 如果想在复杂场景深入使用 immutable,可以看看这个 Reason + BuckleScript bindings to Immutable.js。 4 总结graphql 很惊艳,但如果不能应用到后端第一手代码就没什么用。 reason 整体看上去比初版 react + redux 生态强大了太多,但是与现在的前端生态链 typescript + react + redux* 最新特征比起来,唯一惊艳的地方,就是对 ocaml 用户较为友好,另外在各大支持编译到 js 语言,纷纷支持 Assembly 编译后,这些语言更加趋同了,相比之下 ts 更适合用在生产环境。 5 更多讨论 讨论地址是:精读《初探 Reason 与 GraphQL》 · Issue ##56 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《利用 GPT 解读 PDF》","path":"/wiki/WebWeekly/前沿技术/《利用 GPT 解读 PDF》.html","content":"当前期刊数: 277 ChatPDF 最近比较火,上传 PDF 文件后,即可通过问答的方式让他帮你总结内容,比如让它帮你概括核心观点、询问问题,或者做观点判断。 背后用到了几个比较时髦的技术,还好有 ChatGPT for YOUR OWN PDF files with LangChain 解释了背后的原理,我觉得非常精彩,因此记录下来并做一些思考,希望可以帮到大家。 技术思路概括由于 GPT 非常强大,只要你把 PDF 文章内容发给他,他就可以解答你对于该文章的任何问题了。– 全文完。 等等,那么为什么要提到 langChain 与 vector dataBase?因为 PDF 文章内容太长了,直接传给 GPT 很容易超出 Token 限制,就算他允许无限制的 Token 传输,可能一个问题可能需要花费 10~100 美元,这个 成本 也是不可接受的。 因此黑魔法来了,下图截取自视频 ChatGPT for YOUR OWN PDF files with LangChain: 我们一步步解读: 找一些库把 PDF 内容文本提取出来。 把这些文本拆分成 N 份更小的文本,用 openai 进行文本向量化。 当用户提问时,对用户提问进行向量化,并用数学函数计算与 PDF 已向量化内容的相似程度。 把最相似的文本发送给 openai,让他总结并回答你的问题。 利用 GPT 解读 PDF 的实现步骤我把视频里每一步操作重新介绍一遍,并补上自己的理解。 登录 colab你可以在本地电脑运行 python 一步步执行,也可以直接登录 colab 这个 python 运行平台,它提供了很方便的 python 环境,并且可以一步步执行代码并保存,非常适合做研究。 只要你有谷歌账号就可以使用 colab。 安装依赖要运行一堆 gpt 相关函数,需要安装一些包,虽然本质上都是不断给 gpt openapi 发 http 请求,但封装后确实会语义化很多: !pip install langchain!pip install openai!pip install PyPDF2!pip install faiss-cpu!pip install tiktoken 其中 tiktoken 包是教程里没有的,我执行某处代码时被提示缺少这个包,大家可以提前按上。接下来提前引入一些后面需要用到的函数: from PyPDF2 import PdfReaderfrom langchain.embeddings.openai import OpenAIEmbeddingsfrom langchain.text_splitter import CharacterTextSplitterfrom langchain.vectorstores import ElasticVectorSearch, pinecone, Weaviate, FAISS 定义 openapi token为了调用 openapi 服务,需要先申请 token,当你申请到 token 后,通过如下方式定义: import osos.environ["OPENAI_API_KEY"] = "***" 默认 langchain 与 openai 都会访问 python 环境的 os.environ 来寻找 token,所以这里定义后,接下来就可以直接调用服务了。 如果你还没有 GPT openapi 的账号,详见 保姆级注册教程。(可惜的是中国被墙了,为了学习第一手新鲜知识,你需要自己找 vpn,甚至花钱买国外手机号验证码接收服务,虽然过程比较坎坷,但亲测可行)。 读取 PDF 内容为了方便在 colab 平台读取 PDF,你可以先把 PDF 上传到自己的 Google Drive,它是谷歌推出的个人云服务,集成了包括 colab 与文件存储等所有云服务(PS:微软类似的服务叫 One Drive,好吧,理论上你用哪个巨头的服务都行)。 传上去之后,在 colab 运行如下代码,会弹开一个授权网页,授权后就可以访问你的 drive 路径下资源了: from google.colab import drivedrive.mount('/content/gdrive', force_remount=True)root_dir = "/content/gdrive/My Drive/"reader = PdfReader('/content/gdrive/My Drive/2023_GPT4All_Technical_Report.pdf') 我们读取了 2023_GPT4All_Technical_Report.pdf 报告,这是一个号称本地可跑对标 GPT4 的服务(测评)。 将 PDF 内容文本化并拆分为多个小 chunk首先执行如下代码读取 PDF 文本内容: raw_text = ''for i, page in enumerate(reader.pages): text = page.extract_text() if text: raw_text += text 接下来要为调用 openapi 服务对文本向量化做准备,因为一次调用的 token 数量有限制,因此我们需要将一大段文本拆分为若干小文本: text_splitter = CharacterTextSplitter( separator = " ", chunk_size = 1000, chunk_overlap = 200, length_function = len,)texts = text_splitter.split_text(raw_text) 其中 chunk_size=1000 表示一个 chunk 有 1000 个字符,而 chunk_overlap 表示下一个 chunk 会重复上一个 chunk 最后 200 字符的内容,方便给每个 chunk 做衔接,这样可以让找相似性的时候尽量多找几个 chunk,找到更多的上下文。 向量化来了!最重要的一步,利用 openapi 对之前拆分好的文本 chunk 做向量化: embeddings = OpenAIEmbeddings()docsearch = FAISS.from_texts(texts, embeddings) 就是这么简单,docsearch 是一个封装对象,在这一步已经循环调用了若干次 openapi 接口将文本转化为非常长的向量。 文本向量化又是一个深水区,可以看下这个 介绍视频,简单来说就是一把文本转化为一系列数字,表示 N 维的向量,利用数学计算相似度,可以把文字处理转化为连续的数字进行数学处理,甚至进行文字加减法(比如 北京-中国+美国=华盛顿)。 总之这一步之后,我们本地就拿到了各段文本与其向量的对应关系,比如 “这是一段文字” 对应的向量为 [-0.231, 0.423, -0.2347831, ...]。 利用 chain 生成问答服务接下来要串起完整流程了,初始化一个 QA chain 表示与 GPT 使用 chat 模型进行问答: from langchain.chains.question_answering import load_qa_chainfrom langchain.llms import OpenAIchain = load_qa_chain(OpenAI(), chain_type="stuff") 接下来就可以问他 PDF 相关问题了: query = "who are the main author of the article?"docs = docsearch.similarity_search(query)chain.run(input_documents=docs, question=query)## The main authors of the article are Yuvanesh Anand, Zach Nussbaum, Brandon Duderstadt, Benjamin Schmidt, and Andriy Mulyar. 当然也可以用中文提问,openapi 会调用内置模块翻译给你: query = "训练 GPT4ALL 的成本是多少?"docs = docsearch.similarity_search(query)chain.run(input_documents=docs, question=query)## 根据文章,大约四天的工作,800美元的GPU成本(包括几次失败的训练)和500美元的OpenAI API开销。我们发布的模型gpt4all-lora大约在Lambda Labs DGX A100 8x 80GB上需要八个小时的训练,总成本约为100美元。 QA 环节发生了什么?根据我的理解,当你问出 who are the main author of the article? 这个问题时,发生了如下几步。 第一步:调用 openapi 将问题进行向量化,得到一堆向量。 第二步:利用数学函数与本地向量数据库进行匹配,找到匹配度最高的几个文本 chunk(之前我们拆分的 PDF 文本内容)。 第三步:把这些相关度最高的文本发送给 openapi,让他帮我们归纳。 对于第三步是否结合了 langchain 进行多步骤对答还不得而知,下次我准备抓包看一下这个程序与 openapi 的通信内容,才能解开其中的秘密。 当然,如果问题需要结合 PDF 所有内容才能概括出来,这种向量匹配的方式就不太行了,因为他总是发送与问题最相关的文本片段。但是呢,因为第三步的秘密还没有解决,很有可能当内容片段不够时,gpt4 会询问寻找更多相似片段,这样不断重复知道 gpt4 觉得可以回答了,再给出答案(想想觉得后背一凉)。 总结解读 PDF 的技术思路还可以用在任意问题上,比如网页搜索: 网页搜索就是一个典型的从知识海洋里搜索关键信息并解读的场景,只要背后将所有网页信息向量化,存储在某个向量数据库,就可以做一个 GPT 搜索引擎了,步骤是:一、将用户输入关键字分词并向量化。二:在数据库进行向量匹配,把匹配度最高的几个网页内容提取出来。三:把这些内容喂给 GPT,让他总结里面的知识并回答用户问题。 向量化可以解决任意场景模糊化匹配,比如我自己的备忘录会存储许多平台账号与密码,但有一天搜索 ChatGPT 密码却没搜到,后来发现关键词写成了 OpenAPI。向量化就可以解决这个问题,他可以将无法匹配的关键词也在备忘录里搜索到。 配合向量化搜索,再加上 GPT 的思考与总结能力,一个超级 AI 助手可做的事将会远远超过我们的想象。 留给大家一个思考题:结合向量化与 GPT 这两个能力,你还能想到哪些使用场景? 讨论地址是:精读《利用 GPT 解读 PDF》· Issue ##479 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《前后端渲染之争》","path":"/wiki/WebWeekly/前沿技术/《前后端渲染之争》.html","content":"当前期刊数: 3 本期精读的文章是:Here’s why Client-side Rendering Won 1 引言 我为什么要选这篇文章呢? 十年前,几乎所有网站都使用 ASP、Java、PHP 这类做后端渲染,但后来随着 jQuery、Angular、React、Vue 等 JS 框架的崛起,开始转向了前端渲染。从 2014 年起又开始流行了同构渲染,号称是未来,集成了前后端渲染的优点,但转眼间三年过去了,很多当时壮心满满的框架(rendr、Lazo)从先驱变成了先烈。同构到底是不是未来?自己的项目该如何选型?我想不应该只停留在追求热门和拘泥于固定模式上,忽略了前后端渲染之“争”的“核心点”,关注如何提升“用户体验”。 原文分析了前端渲染的优势,并没有进行深入探讨。我想以它为切入口来深入探讨一下。 明确三个概念:「后端渲染」指传统的 ASP、Java 或 PHP 的渲染机制;「前端渲染」指使用 JS 来渲染页面大部分内容,代表是现在流行的 SPA 单页面应用;「同构渲染」指前后端共用 JS,首次渲染时使用 Node.js 来直出 HTML。一般来说同构渲染是介于前后端中的共有部分。 2 内容概要前端渲染的优势 局部刷新。无需每次都进行完整页面请求 懒加载。如在页面初始时只加载可视区域内的数据,滚动后 rp 加载其它数据,可以通过 react-lazyload 实现 富交互。使用 JS 实现各种酷炫效果 节约服务器成本。省电省钱,JS 支持 CDN 部署,且部署极其简单,只需要服务器支持静态文件即可 天生的关注分离设计。服务器来访问数据库提供接口,JS 只关注数据获取和展现 JS 一次学习,到处使用。可以用来开发 Web、Serve、Mobile、Desktop 类型的应用 后端渲染的优势 服务端渲染不需要先下载一堆 js 和 css 后才能看到页面(首屏性能) SEO 服务端渲染不用关心浏览器兼容性问题(随着浏览器发展,这个优点逐渐消失) 对于电量不给力的手机或平板,减少在客户端的电量消耗很重要 以上服务端优势其实只有首屏性能和 SEO 两点比较突出。但现在这两点也慢慢变得微不足道了。React 这类支持同构的框架已经能解决这个问题,尤其是 Next.js 让同构开发变得非常容易。还有静态站点的渲染,但这类应用本身复杂度低,很多前端框架已经能完全囊括。 3 精读本次提出独到观点的同学有:@javie007 @杨森 @流形 @camsong @Turbe Xue @淡苍 @留影 @FrankFang @alcat2008 @xile611 @twobin @黄子毅 精读由此归纳。 大家对前端和后端渲染的现状基本达成共识。即前端渲染是未来趋势,但前端渲染遇到了首屏性能和 SEO 的问题。对于同构争议最多,在此我归纳一下。 前端渲染遇到的问题前端渲染主要面临的问题有两个 SEO、首屏性能。 SEO 很好理解。由于传统的搜索引擎只会从 HTML 中抓取数据,导致前端渲染的页面无法被抓取。前端渲染常使用的 SPA 会把所有 JS 整体打包,无法忽视的问题就是文件太大,导致渲染前等待很长时间。特别是网速差的时候,让用户等待白屏结束并非一个很好的体验。 同构的优点同构恰恰就是为了解决前端渲染遇到的问题才产生的,至 2014 年底伴随着 React 的崛起而被认为是前端框架应具备的一大杀器,以至于当时很多人为了用此特性而放弃 Angular 1 而转向 React。然而近 3 年过去了,很多产品逐渐从全栈同构的理想化逐渐转到首屏或部分同构。让我们再一次思考同构的优点真是优点吗? 有助于 SEO 首先确定你的应用是否都要做 SEO,如果是一个后台应用,那么只要首页做一些静态内容宣导就可以了。如果是内容型的网站,那么可以考虑专门做一些页面给搜索引擎时到今日,谷歌已经能够可以在爬虫中执行 JS 像浏览器一样理解网页内容,只需要往常一样使用 JS 和 CSS 即可。并且尽量使用新规范,使用 pushstate 来替代以前的 hashstate。不同的搜索引擎的爬虫还不一样,要做一些配置的工作,而且可能要经常关注数据,有波动那么可能就需要更新。第二是该做 sitemap 的还得做。相信未来即使是纯前端渲染的页面,爬虫也能很好的解析。 共用前端代码,节省开发时间 其实同构并没有节省前端的开发量,只是把一部分前端代码拿到服务端执行。而且为了同构还要处处兼容 Node.js 不同的执行环境。有额外成本,这也是后面会具体谈到的。 提高首屏性能 由于 SPA 打包生成的 JS 往往都比较大,会导致页面加载后花费很长的时间来解析,也就造成了白屏问题。服务端渲染可以预先使到数据并渲染成最终 HTML 直接展示,理想情况下能避免白屏问题。在我参考过的一些产品中,很多页面需要获取十几个接口的数据,单是数据获取的时候都会花费数秒钟,这样全部使用同构反而会变慢。 同构并没有想像中那么美 性能 把原来放在几百万浏览器端的工作拿过来给你几台服务器做,这还是花挺多计算力的。尤其是涉及到图表类需要大量计算的场景。这方面调优,可以参考 walmart 的调优策略。 个性化的缓存是遇到的另外一个问题。可以把每个用户个性化信息缓存到浏览器,这是一个天生的分布式缓存系统。我们有个数据类应用通过在浏览器合理设置缓存,双十一当天节省了 70% 的请求量。试想如果这些缓存全部放到服务器存储,需要的存储空间和计算都是很非常大。 不容忽视的服务器端和浏览器环境差异 前端代码在编写时并没有过多的考虑后端渲染的情景,因此各种 BOM 对象和 DOM API 都是拿来即用。这从客观层面也增加了同构渲染的难度。我们主要遇到了以下几个问题: document 等对象找不到的问题 DOM 计算报错的问题 前端渲染和服务端渲染内容不一致的问题 由于前端代码使用的 window 在 node 环境是不存在的,所以要 mock window,其中最重要的是 cookie,userAgent,location。但是由于每个用户访问时是不一样的 window,那么就意味着你得每次都更新 window。而服务端由于 js require 的 cache 机制,造成前端代码除了具体渲染部分都只会加载一遍。这时候 window 就得不到更新了。所以要引入一个合适的更新机制,比如把读取改成每次用的时候再读取。 export const isSsr = () => ( !(typeof window !== 'undefined' && window.document && window.document.createElement && window.setTimeout)); 原因是很多 DOM 计算在 SSR 的时候是无法进行的,涉及到 DOM 计算的的内容不可能做到 SSR 和 CSR 完全一致,这种不一致可能会带来页面的闪动。 内存溢出 前端代码由于浏览器环境刷新一遍内存重置的天然优势,对内存溢出的风险并没有考虑充分。比如在 React 的 componentWillMount 里做绑定事件就会发生内存溢出,因为 React 的设计是后端渲染只会运行 componentDidMount 之前的操作,而不会运行 componentWillUnmount 方法(一般解绑事件在这里)。 异步操作 前端可以做非常复杂的请求合并和延迟处理,但为了同构,所有这些请求都在预先拿到结果才会渲染。而往往这些请求是有很多依赖条件的,很难调和。纯 React 的方式会把这些数据以埋点的方式打到页面上,前端不再发请求,但仍然再渲染一遍来比对数据。造成的结果是流程复杂,大规模使用成本高。幸运的是 Next.js 解决了这一些,后面会谈到。 simple store(redux) 这个 store 是必须以字符串形式塞到前端,所以复杂类型是无法转义成字符串的,比如 function。 总的来说,同构渲染实施难度大,不够优雅,无论在前端还是服务端,都需要额外改造。 首屏优化再回到前端渲染遇到首屏渲染问题,除了同构就没有其它解法了吗?总结以下可以通过以下三步解决 分拆打包 现在流行的路由库如 react-router 对分拆打包都有很好的支持。可以按照页面对包进行分拆,并在页面切换时加上一些 loading 和 transition 效果。 交互优化 首次渲染的问题可以用更好的交互来解决,先看下 linkedin 的渲染 有什么感受,非常自然,打开渲染并没有白屏,有两段加载动画,第一段像是加载资源,第二段是一个加载占位器,过去我们会用 loading 效果,但过渡性不好。近年流行 Skeleton Screen 效果。其实就是在白屏无法避免的时候,为了解决等待加载过程中白屏或者界面闪烁造成的割裂感带来的解决方案。 部分同构 部分同构可以降低成功同时利用同构的优点,如把核心的部分如菜单通过同构的方式优先渲染出来。我们现在的做法就是使用同构把菜单和页面骨架渲染出来。给用户提示信息,减少无端的等待时间。 相信有了以上三步之后,首屏问题已经能有很大改观。相对来说体验提升和同构不分伯仲,而且相对来说对原来架构破坏性小,入侵性小。是我比较推崇的方案。 3 总结我们赞成客户端渲染是未来的主要方向,服务端则会专注于在数据和业务处理上的优势。但由于日趋复杂的软硬件环境和用户体验更高的追求,也不能只拘泥于完全的客户端渲染。同构渲染看似美好,但以目前的发展程度来看,在大型项目中还不具有足够的应用价值,但不妨碍部分使用来优化首屏性能。做同构之前 ,一定要考虑到浏览器和服务器的环境差异,站在更高层面考虑。 附:Next.js 体验Next.js 是时下非常流行的基于 React 的同构开发框架。作者之一就是大名鼎鼎的 Socket.io 的作者 Guillermo Rauch。它有以下几个亮点特别吸引我: 巧妙地用标准化的解决了请求的问题。同构和页面开发类似,异步是个大难题,异步中难点又在接口请求。Next.js 给组件新增了 getInitialProps 方法来专门处理初始化请求,再也不用手动往页面上塞 DATA 和调用 ReactDOMServer.renderToString 使用 styled-jsx 解决了 css-in-js 的问题。这种方案虽然不像 styled-component 那样强大,但足够简单,可以说是最小的成本解决了问题 Fast by default。页面默认拆分文件方式打包,支持 Prefetch 页面预加载 全家桶式的的解决方案。简洁清晰的目录结构,这一点 Redux 等框架真应该学一学。不过全家桶的方案比较适合全新项目使用,旧项目使用要评估好成本 讨论地址是:前后端渲染之争 · Issue ##5 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《函数缓存》","path":"/wiki/WebWeekly/前沿技术/《函数缓存》.html","content":"当前期刊数: 160 1 引言函数缓存是重要概念,本质上就是用空间(缓存存储)换时间(跳过计算过程)。 对于无副作用的纯函数,在合适的场景使用函数缓存是非常必要的,让我们跟着 https://whatthefork.is/memoization 这篇文章深入理解一下函数缓存吧! 2 概述假设又一个获取天气的函数 getChanceOfRain,每次调用都要花 100ms 计算: 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 calculationshowWeatherReport(); // (!) Triggers the calculationshowWeatherReport(); // (!) Triggers the calculation 很显然这样太浪费计算资源了,当已经计算过一次天气后,就没有必要再算一次了,我们期望的是后续调用可以直接拿上一次结果的缓存,这样可以节省大量计算。因此我们可以做一个 memoizedGetChanceOfRain 函数缓存计算结果: 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);} 在每次调用时判断优先用缓存,如果没有缓存则调用原始函数并记录缓存。这样当我们多次调用时,除了第一次之外都会立即从缓存中返回结果: showWeatherReport(); // (!) Triggers the calculationshowWeatherReport(); // Uses the calculated resultshowWeatherReport(); // Uses the calculated resultshowWeatherReport(); // Uses the calculated result 然而对于有参数的场景就不适用了,因为缓存并没有考虑参数: function showWeatherReport(city) { let result = getChanceOfRain(city); // Pass the city console.log("The chance of rain tomorrow is:", result);}showWeatherReport("Tokyo"); // (!) Triggers the calculationshowWeatherReport("London"); // Uses the calculated answer 由于参数可能性很多,所以有三种解决方案: 1. 仅缓存最后一次结果仅缓存最后一次结果是最节省存储空间的,而且不会有计算错误,但带来的问题就是当参数变化时缓存会立即失效: 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 calculationshowWeatherReport("Tokyo"); // Uses the calculated resultshowWeatherReport("Tokyo"); // Uses the calculated resultshowWeatherReport("London"); // (!) Triggers the calculationshowWeatherReport("London"); // Uses the calculated result 在极端情况下等同于没有缓存: showWeatherReport("Tokyo"); // (!) Triggers the calculationshowWeatherReport("London"); // (!) Triggers the calculationshowWeatherReport("Tokyo"); // (!) Triggers the calculationshowWeatherReport("London"); // (!) Triggers the calculationshowWeatherReport("Tokyo"); // (!) Triggers the calculation 2. 缓存所有结果第二种方案是缓存所有结果,使用 Map 存储缓存即可: // 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 calculationshowWeatherReport("London"); // (!) Triggers the calculationshowWeatherReport("Tokyo"); // Uses the calculated resultshowWeatherReport("London"); // Uses the calculated resultshowWeatherReport("Tokyo"); // Uses the calculated resultshowWeatherReport("Paris"); // (!) Triggers the calculation 这么做带来的弊端就是内存溢出,当可能参数过多时会导致内存无限制的上涨,最坏的情况就是触发浏览器限制或者页面崩溃。 3. 其他缓存策略介于只缓存最后一项与缓存所有项之间还有这其他选择,比如 LRU(least recently used)只保留最小化最近使用的缓存,或者为了方便浏览器回收,使用 WeakMap 替代 Map。 最后提到了函数缓存的一个坑,必须是纯函数。比如下面的 CASE: // Inside the magical npm packagefunction getChanceOfRain() { // Show the input box! let city = prompt("Where do you live?"); // ... calculation ...}// Our codefunction showWeatherReport() { let result = getChanceOfRain(); console.log("The chance of rain tomorrow is:", result);} getChanceOfRain 每次会由用户输入一些数据返回结果,导致缓存错误,原因是 “函数入参一部分由用户输入” 就是副作用,我们不能对有副作用的函数进行缓存。 这有时候也是拆分函数的意义,将一个有副作用函数的无副作用部分分解出来,这样就能局部做函数缓存了: // 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);} 最后,我们可以将缓存函数抽象为高阶函数: 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; };} 这样生成新的缓存函数就方便啦: let memoizedGetChanceOfRain = memoize(getChanceOfRain);let memoizedGetNextEarthquake = memoize(getNextEarthquake);let memoizedGetCosmicRaysProbability = memoize(getCosmicRaysProbability); isCalculated 与 lastResult 都存储在 memoize 函数生成的闭包内,外部无法访问。 3 精读通用高阶函数实现函数缓存原文的例子还是比较简单,没有考虑函数多个参数如何处理,下面我们分析一下 Lodash memoize 函数源码: 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 交给用户自己管理,看这段代码: key = resolver ? resolver.apply(this, args) : args[0]; 也就是缓存的 key 默认是执行函数时第一个参数,也可以通过 resolver 拿到参数处理成新的缓存 key。 在执行函数时也传入了参数 func.apply(this, args)。 最后 cache 也不再使用默认的 Map,而是允许用户自定义 lodash.memoize.Cache 自行设置,比如设置为 WeakMap: _.memoize.Cache = WeakMap; 什么时候不适合用缓存以下两种情况不适合用缓存: 不经常执行的函数。 本身执行速度较快的函数。 对于不经常执行的函数,本身就不需要利用缓存提升执行效率,而缓存反而会长期占用内存。对于本身执行速度较快的函数,其实大部分简单计算速度都很快,使用缓存后对速度没有明显的提升,同时如果计算结果比较大,反而会占用存储资源。 对于引用的变化尤其重要,比如如下例子: function addName(obj, name){ return { ...obj, name: }} 为 obj 添加一个 key,本身执行速度是非常快的,但添加缓存后会带来两个坏处: 如果 obj 非常大,会在闭包存储完整 obj 结构,内存占用加倍。 如果 obj 通过 mutable 方式修改了,则普通缓存函数还会返回原先结果(因为对象引用没有变),造成错误。 如果要强行进行对象深对比,虽然会避免出现边界问题,但性能反而会大幅下降。 4 总结函数缓存非常有用,但并不是所有场景都适用,因此千万不要极端的将所有函数都添加缓存,仅限于计算耗时、可能重复利用多次,且是纯函数的。 讨论地址是:精读《函数缓存》· Issue ##261 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《依赖注入简介》","path":"/wiki/WebWeekly/前沿技术/《依赖注入简介》.html","content":"当前期刊数: 256 精读文章:Dependency Injection in JS/TS – Part 1 概述依赖注入是将函数内部实现抽象为参数,使我们更方便控制这些它们。 原文按照 “如何解决无法做单测的问题、统一依赖注入的入口、如何自动保证依赖顺序正确、循环依赖怎么解决、自上而下 vs 自下而上编程思维” 的思路,将依赖注入从想法起点,到延伸出来的特性连贯的串了起来。 如何解决无法做单测的问题如果一个函数内容实现是随机函数,如何做测试? export const randomNumber = (max: number): number => { return Math.floor(Math.random() * (max + 1));}; 因为结果不受控制,显然无法做单测,那将 Math.random 函数抽象到参数里问题不就解决了! export type RandomGenerator = () => number;export const randomNumber = ( randomGenerator: RandomGenerator, max: number): number => { return Math.floor(randomGenerator() * (max + 1));}; 但带来了一个新问题:这样破坏了 randomNumber 函数本身接口,而且参数变得复杂,不那么易用了。 工厂函数 + 实例模式为了方便业务代码调用,同时导出工厂函数和方便业务用的实例不就行了! export type RandomGenerator = () => number;export const randomNumberImplementation = ({ randomGenerator }: Deps) => (max: number): number => { return Math.floor(randomGenerator() * (max + 1)); };export const randomNumber = (max: number) => randomNumberImplementation(Math.random, max); 这样乍一看是不错,单测代码引用 randomNumberImplementation 函数并将 randomGenerator mock 为固定返回值的函数;业务代码引用 randomNumber,因为内置了 Math.random 实现,用起来也是比较自然的。 只要每个文件都遵循这种双导出模式,且业务实现除了传递参数外不要有额外的逻辑,这种代码就能同时解决单测与业务问题。 但带来了一个新问题:代码中同时存在工厂函数与实例,即同时构建与使用,这样职责不清晰,而且因为每个文件都要提前引用依赖,依赖间容易形成循环引用,即便上从具体函数层面看,并没有发生函数间的循环引用。 统一依赖注入的入口用一个统一入口收集依赖就能解决该问题: import { secureRandomNumber } from "secureRandomNumber";import { makeFastRandomNumber } from "./fastRandomNumber";import { makeRandomNumberList } from "./randomNumberList";const randomGenerator = Math.random;const fastRandomNumber = makeFastRandomNumber(randomGenerator);const randomNumber = process.env.NODE_ENV === "production" ? secureRandomNumber : fastRandomNumber;const randomNumberList = makeRandomNumberList(randomNumber);export const container = { randomNumber, randomNumberList,};export type Container = typeof container; 上面的例子中,一个入口文件同时引用了所有构造函数文件,所以这些构造函数文件之间就不需要相互依赖了,这解决了循环引用的大问题。 然后我们依次实例化这些构造函数,传入它们需要的依赖,再用 container 统一导出即可使用,对使用者来说无需关心如何构建,开箱即用。 但带来了一个新问题:统一注入的入口代码要随着业务文件的变化而变化,同时,如果构造函数之间存在复杂的依赖链条,手动维护起顺序将是一件越来越复杂的事情:比如 A 依赖 B,B 依赖 C,那么想要初始化 C 的构造函数,就要先初始化 A 再初始化 B,最后初始化 C。 如何自动保证依赖顺序正确那有没有办法固定依赖注入的模板逻辑,让其被调用时自动根据依赖关系来初始化呢?答案是有的,而且非常的漂亮: // container.tsimport { makeFastRandomNumber } from "./fastRandomNumber";import { makeRandomNumberList } from "./randomNumberList";import { secureRandomNumber } from "secureRandomNumber";const dependenciesFactories = { randomNumber: process.env.NODE_ENV !== "production" ? makeFastRandomNumber : () => secureRandomNumber, randomNumberList: makeRandomNumberList, randomGenerator: () => Math.random,};type DependenciesFactories = typeof dependenciesFactories;export type Container = { [Key in DependenciesFactories]: ReturnValue<DependenciesFactories[Key]>;};export const container = {} as Container;Object.entries(dependenciesFactories).forEach(([dependencyName, factory]) => { return Object.defineProperty(container, dependencyName, { get: () => factory(container), });}); 最核心的代码在 Object.defineProperty(container) 这部分,所有从 container[name] 访问的函数,都会在调用时才被初始化,它们会经历这样的处理链条: 初始化 container 为空,不提供任何函数,也没有执行任何 factory。 当业务代码调用 container.randomNumber 时,触发 get(),此时会执行 randomNumber 的 factory 并将 container 传入。 如果 randomNumber 的 factory 没有用到任何依赖,那么 container 的子 key 并不会被访问,randomNumber 函数就成功创建了,流程结束。 关键步骤来了,如果 randomNumber 的 factory 用到了任何依赖,假设依赖是它自己,那么会陷入死循环,这是代码逻辑错误,报错是应该的;如果依赖是别人,假设调用了 container.abc,那么会触发 abc 所在的 get(),重复第 2 步,直到 abc 的 factory 被成功执行,这样就成功拿到了依赖 很神奇,固定的代码逻辑竟然会根据访问链路自动嗅探依赖树,并用正确的顺序,从没有依赖的那个模块开始执行 factory,一层层往上,直到顶部包的依赖全部构建完成。其中每一条子模块的构建链路和主模块都是分型的,非常优美。 循环依赖怎么解决这倒不是说如何解决函数循环依赖问题,因为: 如果函数 a 依赖了函数 b,而函数 b 又依赖了函数 a,这个相当于 a 依赖了自身,神仙都救不了,如果循环依赖能解决,就和声明发明了永动机一样夸张,所以该场景不用考虑解决。 依赖注入让模块之间不引用,所以不存在函数间循环依赖问题。 为什么说 a 依赖了自身连神仙都救不了呢? a 的实现依赖 a,要知道 a 的逻辑,得先了解依赖项 a 的逻辑。 依赖项 a 的逻辑无从寻找,因为我们正在实现 a,这样递归下去会死循环。 那依赖注入还需要解决循环依赖问题吗?需要,比如下面代码: const aFactory = ({ a }: Deps) => () => { return { value: 123, onClick: () => { console.log(a.value); }, }; }; 这是循环依赖最极限的场景,自己依赖自己。但从逻辑上来看,并没有死循环,如果 onClick 触发在 a 实例化之后,那么它打印 123 是合乎情理的。 但逻辑容不得模糊,如果不经过特殊处理,a.value 还真就解析不出来。 这个问题的解法可以参考 spring 三级缓存思路,放到精读部分聊。 自上而下 vs 自下而上编程思维原文做了一下总结和升华,相当有思考价值:依赖注入的思维习惯是自上而下的编程思维,即先思考包之间的逻辑关系,而不需要真的先去实现它。 相比之下,自下而上的编程思维需要先实现最后一个无任何依赖的模块,再按照顺序实现其他模块,但这种实现顺序不一定符合业务抽象的顺序,也限制了实现过程。 精读我们讨论对象 A 与对象 B 相互引用时,spring 框架如何用三级缓存解决该问题。 无论用 spring 还是其他框架实现了依赖注入,当代码遇到这样的形式时,就碰到了 A B 循环引用的场景: class A { @inject(B) b; value = "a"; hello() { console.log("a:", this.b.value); }}class B { @inject(A) a; value = "b"; hello() { console.log("b:", this.a.value); }} 从代码执行角度来看,应该都可以正常执行 a.hello() 与 b.hello() 才对,因为虽然 A B 各自循环引用了,但他们的 value 并没有构成循环依赖,只要能提前拿到他们的值,输出自然不该有问题。 但依赖注入框架遇到了一个难题,初始化 A 依赖 B,初始化 B 依赖 A,让我们看看 spring 三级缓存的实现思路: spring 三级缓存的含义分别为: 一级缓存 二级缓存 三级缓存 实例 半成品实例 工厂类 实例:实例化 + 完成依赖注入初始化的实例. 半成品实例:仅完成了实例化。 工厂类:生成半成品实例的工厂。 先说流程,当 A B 循环依赖时,框架会按照随机顺序初始化,假设先初始化 A 时: 一:寻找实例 A,但一二三级缓存都没有,因此初始化 A,此时只有一个地址,添加到三级缓存。堆栈:A。 一级缓存 二级缓存 三级缓存 模块 A ✓ 模块 B 二:发现实例 A 依赖实例 B,寻找实例 B,但一二三级缓存都没有,因此初始化 B,此时只有一个地址,添加到三级缓存。堆栈:A->B。 一级缓存 二级缓存 三级缓存 模块 A ✓ 模块 B ✓ 三:发现实例 B 依赖实例 A,寻找实例 A,因为三级缓存找到,因此执行三级缓存生成二级缓存。堆栈:A->B->A。 一级缓存 二级缓存 三级缓存 模块 A ✓ ✓ 模块 B ✓ 四:因为实例 A 的二级缓存已被找到,因此实例 B 完成了初始化(堆栈变为 A->B),压入一级缓存,并清空三级缓存。堆栈:A。 一级缓存 二级缓存 三级缓存 模块 A ✓ ✓ 模块 B ✓ 五:因为实例 A 依赖实例 B 的一级缓存被找到,因此实例 A 完成了初始化,压入一级缓存,并清空三级缓存。堆栈:空。 一级缓存 二级缓存 三级缓存 模块 A ✓ 模块 B ✓ 总结依赖注入本质是将函数的内部实现抽象为参数,带来更好的测试性与可维护性,其中可维护性是 “只要申明依赖,而不需要关心如何实例化带来的”,同时自动初始化容器也降低了心智负担。但最大的贡献还是带来了自上而下的编程思维方式。 依赖注入因为其神奇的特性,需要解决循环依赖问题,这也是面试常问的点,需要牢记。 讨论地址是:精读《依赖注入简介》· Issue ##440 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《入坑 React 前没有人会告诉你的事》","path":"/wiki/WebWeekly/前沿技术/《入坑 React 前没有人会告诉你的事》.html","content":"当前期刊数: 8 本期精读的文章是一个组合: 一篇是 Gianluca Guarini 写的 《Things nobody will tell you about React.js》,我将它译作 《那些入坑 React 前没有人会提醒你的事》,因为作者行文中明显带着对 React 的批判和失望。 另一篇则是 Facebook 员工,也是 Redux 作者的 Dan Abramov 针对上文的回复 《Hey, thanks for feedback!》。 1 引言 我为什么要选这篇文章呢? 我们团队最早在 2014 年中就确定了 React 作为未来的发展方向,那个时候很多人都还在感叹 Angular(那时候还是 Angular 1)是一个多么超前的框架,很多人甚至听都没有听说过 React。 在不到三年的时间里,React 社区迅速的发展壮大,许多 Angular、Ember、Knockout 等框架的拥趸,或主动或被动的都逐渐开始向 React 看齐。 站在 React 已经繁荣昌盛、无需四处布道宣传的今天,我们不妨冷静下来问问自己,React 真的是一个完美的框架吗?社区里一直不缺少吐槽的声音,这周我们就来看看,React 到底有哪些槽点。 2 内容概要Gianluca Guarini 着重吐槽的点在于: React 项目文件组织规范不统一,社区中 Starter Kit 太多(100+),新手不知道该怎么组织文件 由于 React 只关心 View 层,开发者就要面临选择 mobx 还是 redux 的纠结,无论选择哪种都会带来一系列的问题(重新配置构建脚本,更新 eslint 规则等) 如果选了 mobx,会发现 mobx 无法保证自己的 store 不被外部更新(官方建议是加上特殊的前缀) 如果选了 redux,会发现要实现同样的功能需要写很多的重复代码(这也是为什么社区中有海量的 redux helper 存在) 路由用起来也很蛋疼,因为 React Router 几乎是社区中唯一的选择,但是这货版本更新太快,一不小心就用了废弃的 API 用 JSX 的时候总是要嵌很多没必要的 div 或 span 要上手一个 React 应用,要配置很多的构建工具和规则才能看到效果 … Dan Abramov 的回复: 「React 16.0 引入的 Fiber 架构会导致现有代码全部需要重构」的说法是不对的,因为新的架构做到了向后兼容,而且 Facebook 内部超过 3 万个组件都能无痛迁移到新架构上 缺少统一脚手架的问题,可以通过 create-react-app 解决 觉得 redux 和 mobx 繁琐的话,对于刚刚上手的小应用不建议使用 React Router 升级太频繁?2015 年发布的 1.0,2016 年 2 月发布的 2.0,2016 年 10 月发布的 3.0。虽然 4.0 紧接着 3.0 马上就发布了,但是 React Router 很早就已经公布了这样的升级计划。 … 3 精读本次提出独到观点的同学有:@rccoder @Turbe Xue @Pines-Cheng @An Yan @淡苍 @黄子毅 @宾彬 @cisen @Bobo 精读由此归纳。 很高兴能看到不少新同学积极参与到精读的讨论中来,每一个人的声音都是社区发展的一份力量。 React 上手困难很早之前我们去四处布道 React 的时候,都会强调 React 很简单,因为它的 public API 非常之少,React 完整的文档 1 个小时就能看完。 那么说「React 上手困难」又是从何谈起呢?参与精读的同学中有不少都有 Vue 的使用经验(包括本周吐槽文的作者),所以不免会把两个框架上手的难易程度放在心里做个对比。 都说没有对比就没有伤害,大家普遍的观点是 Vue 上手简单、文档清晰、构建工具完善、脚手架统一……再反观 React,虽然 Dan 在文章里做了不少解释,但引用 @An Yan 的原话,『他也只是在说「事情没有那么糟糕」』。 所以说,大家认为的 React 上手困难,很大程度上不是 React 本身,而是 React 附带的生态圈野蛮发展太快,导致新人再进入的时候普遍感觉无所适从。虽然官方的 create-react-app 缓解了这一问题,但还没有从根本程度上找到解法。 状态管理的迷思在今时今日的前端圈子里,说 React 不说 Redux 就像说 Ruby 却不说 Rails 一样,总感觉缺点儿什么。 因为 React 将自己定位成 View 层的解决方案,所以对于中大型业务来说一个合适的状态管理方案是不可或缺的。从最早的 Backbone Model,到 Flux,再到 reflux、Redux,再到 mobx 和 redux-observable,你不得不感叹 React 社区的活力是多么强大。 然而当你真正开始做新项目架构的时候,你到底是选 Redux 还是 Mobx,疑惑是封装解决方案如 dva 呢? @淡苍 认为,Redux 与 MobX,React 两大状态管理方案,各有千秋,Redux 崇尚自由,扩展性好,却也带来了繁琐,一个简单的异步请求都必须引入中间件才能解决,MobX 上手容易,Reactive 避免不必要的渲染,带来性能提升,但相对封闭,不利于业务抽象,缺少最佳实践。至于如何选择?根据具体场景与需求判断。 不难看出,想要做好基于 React 的前端架构,你不仅需要对自己的业务了如指掌,还需要对各种解决方案的特性以及适合怎样的业务形态了如指掌。在 React 社区,永远没有标准解决方案。 Redux 亦非万能解Redux 在刚刚推出的时候凭借酷炫的 devtool 和时间旅行功能,瞬间俘获了不少工程师的心。 但当你真正开始使用 Redux 的时候,你会发现你不仅需要学习很多新的概念,如 reducer、store、dispatch、action 等,还有很多基础的问题都没有标准解法,最典型的例子就是异步 action。虽然 Redux 的 middleware 机制提供了实现异步 action 的可能性,但是对于小白来说去 dispatch 一个非 Object 类型的 action 之前需要先了解 thunk 的概念,还要给 Redux 添加一个 redux-thunk 中间件实属难题。 不仅如此,在前端工程中常见的表单处理,Redux 社区也一直没有给出完美的解法。前有简单的 util 工具 redux-form-utils,后有庞大复杂的 redux-form,还有 rc-component 实现的一套基于 HOC 的解决方案。若没有充分的了解和调研,你将如何选择? 这还没有提到最近非常火热的 redux-saga 和 redux-observable,虽然 Dan 说如果你不需要的话完全可以不用了解,但是如果你不了解他们的话怎么知道自己需不需要呢? React 与 Vue 之争Vue 之所以觉得入门简单,因为一开始就提供了 umd 的引入方式,这与传统 js 开发的习惯一致,以及 Avalon 多年布道的铺垫,大家可以很快接受一个不依赖于构建的 Vue。 React 因为引入了 JSX 概念,本可以以 umd 方式推广,但为了更好的 DX 所以上来就推荐大家使用 JSX,导致新手觉得门槛高。 React + Mobx 约等于一个复杂的 Vue,但这不是抛弃 React 的理由。为什么大家觉得 Vuex 比 Redux 更适合 Vue 呢?因为 Vuex 简单,而 Redux 麻烦,这已经将两个用户群划分开了。 一个简单的小公司,就是需要这种数据流简单,不需要编译,没有太多技术选型要考虑的框架,他们看中的是开发效率,可维护性并不是第一位,这点根本性的导致了这两类人永远也撮合不到一块。 而 Vue 就是解决了这个问题,帮助了那么多开发者,仅凭这点就非常值得称赞,而我们不应该从 React 维护性的角度去抨击谁好谁坏,因为站在我们的角度,大部分中小公司的开发者是不 care 的。 React 用户圈汇集了一批高端用户,他们不断探索技术选型,为开源社区迸发活力,如果大家都转向 Vue,这块摊子就死了,函数式、响应式编程的演进也会从框架的大统一而暂时终止,起码这是不利于技术进步的,也是不可能发生的。Vue 在自己的领域做好,将 React 敏捷思想借鉴过来,帮助更多适合场景的开发者,应该才是作者的目的。 小贴士:如何在开源社区优雅的撕逼开源社区撕逼常有,各种嘴炮也吃充斥在社区里,甚至有人在 Github 上维护了一份开源社区撕逼历史。虽然说做技术的人有争论很正常,但是撕的有理有据令人信服的案例却不多。这次 Facebook 的员工 Dan Abramov 就做出了很好的表率。面对咄咄逼人的文章,逐条回复,不回避、不扯淡且态度保持克制,实属难能可贵。 3 总结React 开发者们也不要因为产生了 Mobx 这种亲 Vue 派而产生焦虑,这也是对特定业务场景的权衡,未来更多更好的数据流方案还会继续诞生,技术社区对技术的优化永无止尽。 比如 mobx-state-tree 就是一种 redux 与 mobx 结合的大胆尝试,作者在很早之前也申明了,Mobx 一样可以做时间旅行,只要遵守一定的开发规范。 最后打个比方:安卓手机在不断进步,体验越来越逼近苹果,作为一个逼格高的用户,果断换苹果吧。但作为 java 开发人员的你,是否要为此换到 oc 流派呢?换,或者不换,其实都一样,安卓和苹果已经越来越像了。 讨论地址是:那些入坑 React 前没有人会提醒你的事 · Issue ##13 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《前端与 BI》","path":"/wiki/WebWeekly/前沿技术/《前端与 BI》.html","content":"当前期刊数: 121 简介商业智能(Business Intelligence)简称 BI,即通过数据挖掘与分析找到商业洞察,助力商业成功。 一个完整的 BI 链路包含数据采集、数据清洗、数据挖掘、数据展现,其本质是对数据进行多维分析。前端的主要工作在数据展现环节,由于展示方式繁多、分析模型复杂且数据量大,前端环节的复杂度很高。 在 BI 做前端非常有挑战,开发者需要充分理解数据概念,而本身复杂度较高的可视化建站也只是 BI 的基础能力,想要建设 BI 的上层能力,比如探索式分析和数据洞察,都需要在前后端引入更复杂的计算模型。 本文作为一个引子,简单介绍笔者做 BI 的经验,后面如果有机会再写一个系列文章对细节进行阐述。 精读国内目前处于 BI 1.0 阶段,也就是报表阶段,因此笔者将阐述这个阶段 BI 的核心开发概念。 BI 2.0 探索式分析阶段是国内数据分析最前沿领域,这部分等开发完成后再分享。 BI 1.0 阶段的核心概念包括 数据集、渲染引擎、数据模型、可视化 这四个技术模块。 数据集数据集即数据的集合,在 BI 领域更多指一种标准化的数据结构。 任何数据都可以封装成数据集,比如 txt 文本、excel、mysql 数据库等等。 数据集的基本形态是二维表格,列头表示字段,每一行就是一份数据,数据展示就是通过对这些数据字段进行多维度分析。 数据集导入一般来说数据集导入有两种方式,分别是本地文件上传与数据库链接。本地文件上传又分为多种文件类型处理,比如对 excel 的解析,可能还包括数据清洗;数据库链接分析可视化导入与 SQL 输入。 可视化导入需要提前对数据库进行结构分析,绘制出表结构与字段结构,不用理解 SQL 也可以进行可视化操作。 SQL 输入可以利用 monaco-editor 等 web 代码编辑器作为输入框,最好能结合智能提示提高 sql 编写效率。sql 智能提示可以参考往期精读 精读《手写 SQL 编译器 - 智能提示》。 数据集建模数据集建模一般包含 维度度量建模、字段配置、层系建模。 维度度量建模需要智能分析出字段属于维度还是度量,一般会结合字段实际的值或者字段名来智能判断字段类型,如果数据库信息中已存储了字段类型,就可以 100% 准确归类。 字段配置即对字段进行增删或修改,还可以新增聚合字段或对比字段。 聚合字段是指将一个字段表达式封装为一个新字段,这里也会用到一个简单的 sql 编辑器,只需要支持四则运算、字段提示、以及一些基本函数的组合即可。 对比字段是指新增的字段是基于已有字段在某个时间周期内的对比,比如对 UV 字段的年同比就可以封装为一个对比字段。对比字段在前端技术上没有什么难度,仅需理解概念即可。 渲染引擎渲染引擎包括了对报表进行编辑与渲染的引擎,理论上可以合二为一。 渲染引擎的重要模块包括:画布拖拽、组件编辑、事件中心。 画布拖拽其实包含了组件自定义开发流程,到 CDN 发布、CDN 加载、组件拖拽、画布排版等一系列技术点,每个点展开都有写不完的细节,但好在这套功能属于通用建站基础功能点,本文就不再赘述。 组件编辑中,基本属性的编辑与属于通用建站领域的表单模型范畴,一般通过 UISchema 来描述通用表单,这块也不再赘述。组件编辑的另一部分就是数据编辑,这部分在后面数据模型章节里详细讲。 事件中心是渲染引擎部分,此功能在编辑状态需要禁用。这个功能可以实现图表联动、上卷下钻等数据能力。一个通用事件中心一般包括 事件触发 与 事件响应 两部分,基本结构如下: interface Event { trigger: | { type: 'callback'; callbackName: string; } | { type: 'listener'; eventName: string; } | { type: 'system'; name: string; }; action: | { type: 'dispatch'; eventName: string; } | { type: 'jumpUrl'; url: string; }} trigger 即事件触发,包括基本的系统事件 system,比如定时器或者初始化自动触发;组件的回调 callback 比如当按钮被点击时;事件监听 listener 比如另一个事件被触发时,这个事件可能来自于 action。 action 即事件响应,包括基本的事件触发 dispatch,可以触发其他事件,可以构成一个事件链路;其他的 action 就是数据相关,可以用来做条件联动、字段联动、数据集联动等等,因为实现各异这里不做介绍。 事件机制还需要支持值传递,即事件触发源的值可以传递到事件响应方。值传递可以在触发源内部进行,比如当触发源是回调函数时,函数参数就自然作为值传递过去,触发源通过 ...args 方式接收。 数据钻取配置了层系的字段都可以进行数据钻取。层系可以在数据集配置,也可以在报表编辑页配置,可以理解为一个顺序有关的文件夹,将文件夹作为字段使用时,默认生效的是第一个子元素,之后可以按照顺序分别进行下钻。 比如 “地区” 层系包含了国家、省、市、区,那么就可以按照这个层级进行数据上卷下钻。 如果一个字段是层系字段,图表需要有对应的操作区域进行上卷下钻,数据编辑区域也可以进行同样操作。数据钻取的计算过程不在图表内部处理,而是触发一个状态后,由渲染引擎将这个层系字段实例状态改为下钻到第 N 层,并且每下钻一次就多拿到一列的数据,由图表组件进行下钻展示。 一般来说下钻后数据仍是全量的,有时候为了避免数据量过大,比如在柱状图点击某个柱子进行下钻,只想看这个柱子下钻后的数据:比如 2017、2018、2019 年三年的数据,下钻到月后数据量是 3 x 12 = 36 条,但如果仅在 2019 年进行下钻,只想看 2019 年的 12 条数据,可以转化为下钻 + 筛选条件的模式:全局下钻展开后 36 条,在 2019 年上点击下钻后,增加一个筛选条件(年 = 2019),这样就达到了效果,整个流程对图表组件是无感知的。 数据模型与通用表单模型 UISchema 相对应,数据模型笔者称之为 CubeSchema,因为 BI 领域对数据的多维处理模型成为 Cube 立方体,数据配置即表示如何对这个立方体进行查询,因此其配置表单成为 CubeSchema。 不管是探索式分析还是 BI 1.0 的报表阶段,数据模型的基本概念是通用的(探索式分析固定了行列,且增加了标记):将字段放置到不同的区域,这些区域的划分方式可以按照功能:横轴、纵轴;按照概念:维度、度量;按照探索分析思路:固化为行、列等等。 这块可能涉及到的技术点有:拖拽、批量选择+拖拽、双击后按照维度度量自动添加、图表切换后区域字段自动迁移、对字段拖拽的系列配置:限制数量、限制类型、限制数据集、是否重复等等。 拖拽可以用 react-beautiful-dnd 等库,与渲染引擎拖拽方案基本类似,遇到有层系的数据集还需支持嵌套层级的拖拽。 图表切换后字段迁移,可以将每个拖拽区域设置若干类型: { "dataType": ["dimension"]} 这样在切换后,维度类型的字段可以自动迁移到维度类型区域,如果对应区域字段数量达到了 limit 限制,就继续填充到下一个区域,直到字段用尽或区域填充完为止。 如果在探索式分析场景里,需要提前对字段进行维度度量建模,在切换时按照图表情况进行相应的处理。比如折线图切换到表格的情况:折线图是天然一个维度(主轴) + N 个度量的场景,表格是天然两个维度(行、列)+ 1 个度量的场景(也可以支持多个,对单元格进行再切分即可),那么从折线图切换到表格时,度量就会落到标记的文本区域;如果从拥有行和列的表格切换到柱状图(之所以无法切换到折线图,是因为表格的度量值一般是离散的,而折线图度量值一般是连续的),表格的行与列的字段会落到柱状图的维度轴,表现效果是对维度轴进行下钻。 精读《Tableau 探索式模型》 了解更多探索式分析。 数据模型还包括数据分析相关配置,比如设置对比字段,或者均值线等分析功能。这些数据计算工作放在后端,前端需要将配置项整理到取数接口中,并按照数据驱动的方式展现。 对于对比字段等 “拓展字段” 的分析功能,可以拓展通用取数接口,图表组件无感知,相当于多添加了几个隐藏字段;去特殊值等对标准数据进行操作的情况图表组件也无需感知。 聚类、均值线等需要图表组件额外展示的部分抽象为一套固定的数据格式透传给图表组件,由图表组件自行处理。 可以看出来,都是取数 + 展示,普通的前端业务与 BI 业务开发的区别: 普通前端业务是以业务逻辑为核心的,根据业务需要确定接口格式;BI 业务是以数据为核心的,围绕数据计算模型确定一套固定的接口格式,取数不依赖组件,所有组件对标准数据都有对应的展现。 可视化与普通可视化组件不同,BI 可视化组件需要对接 CubeSchema 模型,同时还要支持 大数据性能优化、边界数据展示优化、交互响应。 对接 CubeSchema 即统一对接二维表格的数据,大部分组件都是二维以上结构展示,因此对接起来并不困难,有一些一维数据结构的组件比如单指标块就要舍弃其中的某一维,需要确定一套规则。 二维以上部分是较为通用的,虽然计算模型是基于 Cube N 维的,但组件可以通过标准轴进行多维度展开,或者说下钻来实现类似效果。对于折线图来说,轴的含义有限,可以用分面的方式展示多维数据。当然也有一些组件只适合展示特定维度数量的数据。 大数据性能优化可视化组件特别需要关注性能优化,因为 BI 查询出的数据量可能非常大,特别是多层下钻或基于地理的数据。 技术手段包括 GPU 渲染、缓存 canvas、多线程运算等,业务手段包括数据抽样、按需渲染可视区域、限制数据条数等等。 边界数据展示优化永远不知道数据集会给出怎样的数据,因此 BI 边界情况特别多,可能点非常密集,也可能丢失一些数据导致渲染异常。图表组件需要利用避让算法将密集的数据打散或着色,目的是为了容易阅读,对于丢失的异常数据也要有保护性的补全机制。 交互响应包括上卷下钻、点选、圈选、高亮等交互操作,这些操作反馈到渲染引擎导致数据变化并将新的数据灌入图表组件。 业务逻辑上这些交互操作并不复杂,难点在使用的可视化库是否有这个能力,以及如何统一交互行为。 总结BI 领域的四大方向:数据集、渲染引擎、数据模型与可视化都有许多可以做深的技术点,每一块都需要深入沉淀几年技术经验才能做好,需要大量优秀人才通力协作才有可能做好。 目前我们在阿里数据中台正在打造一款面向未来的优秀 BI 工具,如果 BI 领域让你觉得有挑战,随时欢迎你的加入,联系邮箱:ziyi.hzy@alibaba-inc.com 讨论地址是:精读《前端与 BI》 · Issue ##208 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《前端数据流哲学》","path":"/wiki/WebWeekly/前沿技术/《前端数据流哲学》.html","content":"当前期刊数: 42 本系列分三部曲:《框架实现》 《框架使用》 与 《数据流哲学》,这三篇是我对数据流阶段性的总结,正好补充之前过时的文章。 本篇是收官之作 《前端数据流哲学》。 1 引言写这篇文章时,很有压力,如有不妥之处,欢迎指正。 同时,由于这是一篇佛系文章,所以不会得出你应该用 某某 框架的结论,你应该当作消遣来阅读。 2 精读首先数据流管理模式,比较热门的分为三种。 函数式、不可变、模式化。典型实现:Redux - 简直是正义的化身。 响应式、依赖追踪。典型实现:Mobx。 响应式,和楼上区别是以流的形式实现。典型实现:Rxjs、xstream。 当然还有第四种模式,裸奔,其实有时候也挺健康的。 数据流使用通用的准则是:副作用隔离、全局与局部状态的合理划分,以上三种数据流管理模式都可以实现,唯有是否强制的区别。 2.1 从时间顺序说起一直在思考如何将这三个思维串起来,后来想通了,按照时间顺序串起来就非常自然。 暂时略过 Prototype、jquery 时代,为什么略过呢?因为当时前端还在野蛮人时代,生存问题都没有解决,哪还有功夫思考什么数据流,设计模式?前端也是那时候被觉得比后端水的。 好在前端发展越来越健康,大坑小坑被不断填上,加上硬件性能的提高,同时需求又越来越复杂,是时候想想该如何组织代码了。 最先映入眼帘的是 angular,搬来的 mvvm 思想真是为前端开辟了新的世界,发现代码还可以这么写!虽然 angluar 用起来很重,但 mvvm 带来的数据驱动思想已经越来越深入人心,随后 react 就突然火起来了。 其实在 react 火起来之前,有一个框架一步到位,进入了 react + mobx 时代,对,就是 avalon。avalon 也非常火,但是一个框架要成功,必须天时、地利、人和,当时时机不对,大家处于 angular 疲惫期,大多投入了 react 的怀抱。 可能有些主观,但我觉得 react 能火起来,主要因为大家认为它就是轻量 angular + 继承了数据驱动思想啊,非常符合时代背景,同时一大波概念被炒得火热,状态驱动、单向数据流等等,基本上用过 angular 的人都跟上了这波节奏。 虽然 react 内置了分形数据流管理体系,但总是强调自己只是 View 层,于是数据层增强的框架不断涌现,从 flux、reflux、到 redux。不得不说,react 真的推动了数据流管理的独立,让我们重新认识了数据流管理的重要性。 redux 概念太超前了,一步到位强制把副作用隔离掉了,但自己又没有深入解决带来的代码冗余问题,让我们又爱又恨,于是一部分人把目光转向了 mobx,这个响应式数据流框架,这个没有强制分离副作用,所以写起来很舒服的框架。 当然 mobx 如果仅仅是 mvvm 就不会火起来了,毕竟 angular 摆在那。主要是乘上了 react 这趟车,又有很多质疑 angular 脏检测效率的声音,mobx 也火了起来。当然,作为前端的使命是优化人机交互,所以我们都知道,用户习惯是最难改变的,直到现在,redux 依然是绝对主流。 mobx 还在小范围推广时,另一个更偏门的领域正刚处于萌芽期,就是 rxjs 为代表的框架,和 mobx 公用一个 observable 名词,大家 mobx 都没搞清楚,更是很少人会去了解 rxjs。 当 mobx 逐渐展露头角时,笔者做了一个类似的库:dob。主要动机是 mobx 手感还不够完美,对于新赋值变量需要用一些 extendObservable 等 api 修饰,正好发现浏览器对 proxy 支持已经成熟,因此笔者后来几乎所有个人项目几乎都用 dob 替代了 mobx。 这一时期三巨头之一的 vue 火了起来,成功利用:如果 ”react + mobx 很好用,那为什么不用 vue?“ 的 flag 打动了我。 一直到现在,前端已经发展到可谓五花八门的地步,typescript 打败 flow 几乎成为了新的 js,出现了 ember、clojurescript 之后,各大语言也纷纷出了到 js 的编译实现,陆陆续续的支持编译到 webassembly,react 作者都弃坑 js 创造了新语言 reason。 之前写过一篇初步认识 reason 的精读。 能接下来这一套精神洗礼的前端们,已经养出内心波澜不惊的功夫,小众已经不会成为跨越舒适区的门槛,再学个 rxjs 算啥呢?(开个玩笑,rxjs 社区不乏深耕多年的巨匠)所以最近 rxjs 又被炒的火热。 所以,从时间顺序来看,我们可以从 redux - mobx - rxjs 的顺序解读这三个框架。 2.2 redux 带来了什么redux 是强制使用全局 store 的框架,尽管无数人在尝试将其做到局部化。 当然,一方面是由于时代责任,那时需要一个全局状态管理工具,弥补 react 局部数据流的不足。最重要的原因,是 redux 拥有一套几乎洁癖般完美的定位,就是要清晰、可回溯。 几乎一切都是为了这两个词准备的。第一步就要从分离副作用下手,因为副作用是阻碍代码清晰、以及无法回溯的第一道障碍,所以 action + reducer 概念闪亮登场,完美解决了副作用问题。可能是参考了 koa 中间件的设计思路,redux middleware 将 action 对接到 reducer 的黑盒的控制权暴露给了开发者。 由 redux middleware 源码阅读引发的函数式热,可能又拉近了开发者对 rxjs 的好感。同时高阶函数概念也在中间件源码中体现,几乎是为 react 高阶组件做铺垫。 社区出现了很多方案对 redux 异步做支持,从 redux-thunk 到 redux-saga,redux 带来的异步隔离思想也逐渐深入人心。同时基于此的一套高阶封装框架也层出不穷,建议用一个就好,比如 dva。 第二步就是解决阻碍回溯的“对象引用”机制,将 immutable 这套庞大思想搬到了前端。这下所有状态都不会被修改,基于此的 redux-dev-tools “时光机” 功能让人印象深刻。 Immutable 具体实现可以参考笔者之前写的一篇精读:精读 Immutable 结构共享。 当然,由于很像事件机制的 dispatch 导致了 redux 对 ts 支持比较繁琐,所以对 redux 的项目,维护的时候需要频繁使用全文搜索,以及至少在两个文件间来回跳跃。 2.3 mobx 带来了什么mobx 是一个非常灵活的 TFRP 框架,是 FRP 的一个分支,将 FRP 做到了透明化,也可以说是自动化。 从函数式(FP),到 FRP,再到 TFRP,之间只是拓展关系,并不意味着单词越长越好。 之前说过了,由于大家对 redux 的疲劳,让 mobx 得以迅速壮大,不过现在要从另一个角度分析。 mobx 带来的概念从某种角度看,与 rxjs 很像,比如,都说自己的 observable 有多神奇。那么 observable 到底是啥呢? 可以把 observable 理解为信号源,每当信号变化时,函数流会自动执行,并输出结果,对前端而言,最终会使视图刷新。这就是数据驱动视图。然而 mobx 是 TFRP 框架,每当变量变化时,都会自动触发数据源的 dispatch,而且各视图也是自动订阅各数据源的,我们称为依赖追踪,或者叫自动依赖绑定。 笔者到现在还是认为,TFRP 是最高效的开发方式,自动订阅 + 自动发布,没什么比这个更高效了。 但是这种模式有一个隐患,它引发了副作用对纯函数的污染,就像 redux 把 action 与 reducer 合起来了一样。同时,对 props 的直接修改,也会导致与 react 对 props 的不可变定义冲突。因此 mobx 后来给出了 action 解决方案,解决了与 react props 的冲突,但是没有解决副作用未强制分离的问题。 笔者认为,副作用与 mutable 是两件事,关于 mutable 与副作用的关系,后文会有说明。也就是 mobx 没有解决副作用问题,不代表 TFRP 无法分离副作用,而且 mutable 也不一定与 可回溯 冲突,比如 mobx-state-tree,就通过 mutable 的方式,完成了与 redux 的对接。 前端对数据流的探索还在继续,mobx 先提供了一套独有机制,后又与 redux 找到结合点,前端探索的脚步从未停止。 2.4 rxjs 带来了什么rxjs 是 FRP 的另一个分支,是基于 Event Stream 的,所以从对 view 的辅助作用来说,相比 mobx,显得不是那么智能,但是对数据源的定义,和 TFRP 有着本质的区别,似的 rxjs 这类框架几乎可以将任何事件转成数据源。 同时,rxjs 其对数据流处理能力非常强大,当我们把前端的一切都转为数据源后,剩下的一切都由无所不能的 rxjs 做数据转换,你会发现,副作用已经在数据源转换这一层完全隔离了,接下来会进入一个美妙的纯函数世界,最后输出到 dom driver 渲染,如果再加上虚拟 dom 的点缀,那岂不是。。岂不就是 cyclejs 吗? 多提一句,rxjs 对数据流纯函数的抽象能力非常强大,因此前端主要工作在于抽一个工具,将诸如事件、请求、推送等等副作用都转化为数据源。cyclejs 就是这样一个框架:提供了一套上述的工具库,与 dom 对接增加了虚拟 dom 能力。 rxjs 给前端数据流管理方案带来了全新的视角,它的概念由 mobx 引发,但解题思路却与 redux 相似。 rxjs 带来了两种新的开发方式,第一种是类似 cyclejs,将一切前端副作用转化为数据源,直接对接到 dom。另一种是类似 redux-observable,将 rxjs 数据流处理能力融合到已有数据流框架中, redux-observable 将 action 与 reducer 改造为 stream 模式,对 action 中副作用行为,比如发请求,也提供了封装好的函数转化为数据源,因此,将 redux middleware 中的副作用,转移到了数据源转换做成中,让 action 保持纯函数,同时增强了原本就是纯函数的 reducer 的数据处理能力,非常棒。 如果说 redux-saga 解决了异步,那么 redux-observable 就是解决了副作用,同时赠送了 rxjs 数据处理能力。 回头看一下 mobx,发现 rxjs 与 mobx 都有对 redux 的增强方案,前端数据流的发展就是在不断交融。 我们不但在时间线上,将 redux、mobx、rxjs 串了起来,还发现了他们内在的关联,这三个思想像一张网,复杂的交织在一起。 2.5 可以串起来些什么了我们发现,redux 和 rxjs 完全隔离了副作用,是因为他们有一个共性,那就是对前端副作用的抽象。 redux 通过在 action 做副作用,将副作用隔离在 reducer 之外,使 reducer 成为了纯函数。 rxjs 将副作用先转化为数据源,将副作用隔离在管道流处理之外。 唯独 mobx,缺少了对副作用抽象这一层,所以导致了代码写的比 redux 和 rxjs 更爽,但副作用与纯函数混杂在一起,因此与函数式无缘。 有人会说,mobx 直接 mutable 改变对象也是导致副作用的原因,笔者认为是,也不是,看如下代码: obj.a = 1 这段代码在 js 中铁定是 mutable 的?不一定,同样在 c++ 这些可以重载运算符的语言中也不一定了,setter 语法不一定会修改原有对象,比如可以通过 Object.defineProperty 来重写 obj 对象的 setter 事件。 由此我们可以开一个脑洞,通过运算符重载,让 mutable 方式得到 immutable 的结果。在笔者博客 Redux 使用可变数据结构 有说明原理和用法,而且 mobx 作者 mweststrate 是这么反驳那些吐槽 mobx 缺少 redux 历史回溯能力的声音的: autorun(() => { snapshots.push(Object.assign({}, obj))}) 思路很简单,在对象有改动时,保存一张快照,虽然性能可能有问题。这种简单的想法开了个好头,其实只要在框架层稍作改造,便可以实现 mutable 到 immutable 的转换。 比如 mobx 作者的新作:immer 通过 proxy 元编程能力,将 setter 重写为 Object.assign() 实现 mutable 到 immutable 的转换。 笔者的 dob-redux 也通过 proxy,调用 Immutablejs.set() 实现 mutable 到 immutable 的转换。 组件需要数据流吗真的是太看场景了。首先,业务场景的组件适合绑定全局数据流,业务无关的通用组件不适合绑定全局数据流。同时,对于复杂的通用组件,为了更好的内部通信,可以绑定支持分形的数据流。 然而,如果数据流指的是 rxjs 对数据处理的过程,那么任何需要数据复杂处理的场合,都适合使用 rxjs 进行数据计算。同时,如果数据流指的是对副作用的归类,那任何副作用都可以利用 rxjs 转成一个数据源归一化。当然也可以把副作用封装成事件,或者 promise。 对于副作用归一化,笔者认为更适合使用 rxjs 来做,首先事件机制与 rxjs 很像,另外 promise 只能返回一次,而且之后 resolve reject 两种状态,而 Observable 可以返回多次,而且没有内置的状态,所以可以更加灵活的表示状态。 所以对于各类业务场景,可以先从人力、项目重要程度、后续维护成本等外部条件考虑,再根据具体组件在项目中使用场景,比如是否与业务绑定来确定是否使用,以及怎么使用数据流。 可能在不远的未来,布局和样式工作会被 AI 取代,但是数据驱动下数据流选型应该比较难以被 AI 取代。 再次理解 react + mobx 不如用 vue 这句话首先这句话很有道理,也很有分量,不过笔者今天将从一个全新的角度思考。 经过前面的探讨,可以发现,现在前端开发过程分为三个部分:副作用隔离 -> 数据流驱动 -> 视图渲染。 先看视图渲染,不论是 jsx、或 template,都是相同的,可以互相转化的。 再看副作用隔离,一般来说框架也不解决这个问题,所以不管是 react/ag/vue + redux/mobx/rxjs 任何一种组合,最终你都不是靠前面的框架解决的,而是利用后面的 redux/mobx/rxjs 来解决。 最后看数据流驱动,不同框架内置的方式不同。react 内置的是类 redux 的方式,vue/angular 内置的是类 mobx 的方式,cyclejs 内置了 rxjs。 这么来看,react + redux 是最自然的,react + mobx 就像 vue + redux 一样,看上去不是很自然。也就是 react + mobx 别扭的地方仅在于数据流驱动方式不同。对于视图渲染、副作用隔离,这两个因素不受任何组合的影响。 就数据流驱动问题来看,我们可以站在更高层面思考,比如将 react/vue/angular 的语法视为三种 DSL 规范,那其实可以用一种通用的 DSL 将其描述,并转换对应的 DSL 对接不同框架(阿里内部已经有这种实现了)。而这个 DSL 对框架内置数据流处理过程也可以屏蔽,举个例子: <button onClick={() => { setState(() => { data: { name: 'nick' } })}}> {data.name}</button> 如果我们将上面的通用 jsx 代码转换为通用 DSL 时,会使用通用的方式描述结构以及方法,而转化为具体 react/vue/angluar 代码时,就会转化为对应内置数据流方案的实现。 所以其实内置数据流是什么风格,在有了上层抽象后,是可以忽略的,我们甚至可以利用 proxy,将 mutable 的代码转换到 react 时,改成 immutable 模式,转到 vue 时,保持 mutable 形式。 对框架封装的抽象度越高,框架之间差异就越小,渐渐的,我们会从框架名称的讨论中解放,演变成对框架 + 数据流哪种组合更加合适的思考。 3 总结最近梳理了一下 gaea-editor - 笔者做的一个 web designer,重新思考了其中插件机制,拿出来讲一讲。 首先大体说明一下,这个编辑器使用 dob 作为数据流,通过 react context 共享数据,写法和 mobx 很像,不过这不是重点,重点是插件拓展机制也深度使用了数据流。 什么是插件拓展机制?比如像 VScode 这些编辑器,都拥有强大的拓展能力,开发者想要添加一个功能,可以不用学习其深奥的框架内容,而是读一下简单明了的插件文档,使用插件完成想要功能的开发。解耦的很美好,不过重点是插件的能力是否强大,插件可以触及内核哪些功能、拿到哪些信息、拥有哪些能力? 笔者的想法比较激进,为了让插件拥有最大能力,这个 web designer 所有内核代码都是用插件写的,除了调用插件的部分。所以插件可以随意访问和修改内核中任何数据,包括 UI。 让 UI 拥有通用能力比较容易,gaea-editor 使用了插槽方式渲染 UI,也就是任何插件只要提供一个名字,就能嵌入到申明了对应名字的 UI 插槽中,而插件自己也可以申明任意数量的插槽,内核中也有几个内置的插槽。这样插件的 UI 能力极强,任何 UI 都可以被新的插件替代掉,只要申明相同的名字即可。 剩下一半就是数据能力,笔者使用了依赖注入,将所有内核、插件的 store、action 全量注入到每一个插件中: @Connectclass CustomPlugin extends React.PureComponent { render() { // this.props.Actions, this.props.Stores }} 同时,每个插件可以申明自己的 store,程序初始化时会合并所有插件的 store 到内存中。因此插件几乎可以做任何事,重写一套内核也没有问题,那么做做拓展更是轻松。 其实这有点像 webpack 等插件的机制: export default (context) => {} 每次申明插件,都可以从函数中拿到传来的数据,那么通过数据流的 Connect 能力,将数据注入到组件,也是一种强大的插件开发方式。 更多思考通过上面插件机制的例子会发现,数据流不仅定义了数据处理方式、副作用隔离,同时依赖注入也在数据流功能列表之中,前端数据流是个很宽泛的概念,功能很多。 redux、mobx、rxjs 都拥有独特的数据处理、副作用隔离方式,同时对应的框架 redux-react、mobx-react、cyclejs 都补充了各种方式的依赖注入,完成了与前端框架的衔接。正是应为他们纷纷将内核能力抽象了出来,才让 redux+rxjs mobx+rxjs 这些组合成为了可能。 未来甚至会诞生一种完全无数据管理能力的框架,只做纯 view 层,内核原生对接 redux、mobx、rxjs 也不是没有可能,因为框架自带的数据流与这些数据流框架比起来,太弱了。 react stateless-component 就是一种尝试,不过现在这种纯 view 层组件配合数据流框架的方式还比较小众。 纯 view 层不代表没有数据流管理功能,比如 props 的透传,更新机制,都可以是内置的。 不过笔者认为,未来的框架可能会朝着 view 与数据流完全隔离的方式演化,这样不但根本上解决了框架 + 数据流选择之争,还可以让框架更专注于解决 view 层的问题。 从有到无HTML5 有两个有意思的标签:details, summary。通过组合,可以达到 details 默认隐藏,点击 summary 可以 toggle 控制 details 下内容的效果: <details> <summary>标题</summary> <p>内容</p> </details> 更是可以通过 css 覆盖,完全实现 collapse 组件的效果。 当然就 collapse 组件来说,因为其内部维持了状态,所以控制折叠面板的 打开/关闭 状态,而 HTML5 的 details 也通过浏览器自身内部状态,对开发者只暴露 css。 在未来,浏览器甚至可能提供更多的原生上层组件,而组件内部状态越来越不需要开发者关心,甚至,不需要开发者再引用任何一个第三方通用组件,HTML 提供足够多的基础组件,开发者只需要引用 css 就能实现组件库更换,似乎回到了 bootstrap 时代。 有人会说,具有业务含义的再上层组件怎么提供?别忘了 HTML components,这个规范配合浏览器实现了大量原生组件后,可能变得异常光彩夺目,DSL 再也不需要了,HTML 本身就是一套通用的 DSL,框架更不需要了,浏览器内置了一套框架。 插一句题外话,所有组件都通过 html components 开发,就真正意义上实现了抹平框架,未来不需要前端框架,不需要 react 到 vue 的相互转化,组件加载速度提高一个档次,动态组件 load 可能只需要动态加载 css,也不用担心不同环境/框架下开发的组件无法共存。前端发展总是在进两步退一步,不要形成思维定式,每隔一段时间,需要重新审视下旧的技术。 话题拉回来,从浏览器实现的 details 标签来看,内部一定有状态机制,假如这套状态机制可以提供给开发者,那数据流的 数据处理、副作用隔离、依赖注入 可能都是浏览器帮我们做了,redux 和 mobx 会立刻失去优势,未来潜力最大的可能是拥有强大纯函数数据流处理能力的 rxjs。 当然在 2018 年,redux 和 mobx 依然会保持强大的活力,就算在未来浏览器内置的数据流机制,rxjs 可能也不适合大规模团队合作,尤其在现在有许多非前端岗位兼职前端的情况下。 就像现在 facebook、google 的模式一样,在未来的更多年内,前后端,甚至 dba 与算法岗位职能融合,每个人都是全栈时,可能 rxjs 会在更大范围被使用。 纵观前端历史,数据流框架从无到有,但在未来极有可能从有变到无,前端数据流框架消失了,但前端数据流思想永远保留了下来,变得无处不在。 4 更多讨论 讨论地址是:精读《前端数据流哲学》 · Issue ##58 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《前端未来展望》","path":"/wiki/WebWeekly/前沿技术/《前端未来展望》.html","content":"当前期刊数: 111 1. 引言前端展望的文章越来越不好写了,随着前端发展的深入,需要拥有非常宽广的视野与格局才能看清前端的未来。 笔者根据自身经验,结合下面几篇文章发表一些总结与感悟: A Look at JavaScript’s Future 前端开发 20 年变迁史 前端开发编程语言的过去、现在和未来 绕过技术纷争,哪些技术决定前端开发者的未来? 未来前端的机会在哪里? 读完这几篇文章可以发现,即便是最资深的前端从业者,每个人看前端未来也有不同的侧重点。这倒不是因为视野的局限,而是现在前端领域太多了,专精其中某几个领域就足够了,适量比全面更好。 同时前端底层也在逐渐封闭,虽然目睹了前端几十年变迁的开发者仍会对一些底层知识津津乐道,但通往底层的大门已经一扇扇逐渐关闭了,将更多的开发者挤到上层区域建设,所以仅学会近几年的前端知识依然能找到不错的工作。 然而上层建设是不封顶的,有人看到了山,有人看到了星球,不同业务环境,不同视野的人看到的东西都不同。 有意思的是国内和国外看到前端未来的视角也不同:国内看到的是追求更多的参与感、影响力,国外看到的是对新特性的持续跟进。 2. 精读前端可以从多个角度理解,比如规范、框架、语言、社区、场景以及整条研发链路。 看待前端未来的角度随着视野不同也会有变化,比如 Serverless 是未来,务实的思考是:前端在 Serverless 研发链路中仅处于使用方,并不会因为用了 Serverless 而提升了技术含量。更高格局的思考是:怎么推动 Serverless 的建设,不把自己局限在前端。 所以当我们读到不同的人对前端理解的时候,有人站在一线前端研发的角度,有人站在全栈的角度,也有人站在业务负责人的角度。其实国内前端发展也到了这个阶段,老一辈的前端开拓者们已经进入不同的业务领域,承担着更多不同的职能分工,甚至是整个大业务线的领导者,这说明两点: 前辈已经用行动指出了前端突破天花板的各种方向。 同是前端未来展望,不同的文章侧重的格局不同,两个标题相同的文章内容可能大相径庭。 笔者顺着这些文章分析角度,发表一些自己的看法。 框架在前端早期,也就是 1990 年浏览器诞生的时候,JS 没有良好的设计,浏览器也没有全面的实现,框架还没出来,浏览器之间就打起来了。 这也给前端发展定了一个基调:凭实力说话。 后面诞生的 Prototype、jquery 都是为了解决时代问题而诞生的,所以有种时代造就前端框架的感觉。 但到了最近几年,React、Angular、Vue 大有前端框架引领新时代的势头,前端要做的不再是填坑,而是模式创新。国内出现的小程序浪潮是个意料之外的现象,虽然群雄割据为开发者适配带来了一定成本,但本质上是中国在前端底层领域争取话语权的行为,而之所以各大公司不约而同的推出自己的小程序,则是商业、经济发展到了这个阶段的自然产物。 在原生开发领域,像 RN、Flutter 也是比较靠谱的移动端开发框架,RN 就长在 React 上,而 Flutter 的声明式 UI 也借鉴了前端框架的思路。每个框架都想往其他框架的领域渗透,所以标准总是很相近,各自的特色并没有宣传的那么明显,这个阶段只选用一种框架是明智的选择,未来这些框架之间会有更多使用场景争夺,但更多的是融合,推动新的开发方式提高生产力。 在数据驱动 UI 的方式上,具有代表性的是 React 的 Immutable 模式与 Vue 的 MVVM 观察者模式,前者模式虽然新颖,但是符合 JS 语言自然运行机制,Vue 的 MVVM 模式也相当好,特别是 Vue3.0 的 API 巧妙的解决了 React Hooks 无法解决的难题。如果 Vue 继续保持蓬勃的发展势头,未来前端 MVVM 模式甚至可能标准化,那么 Vue 是作为标准化的事实规范,还是和 JQuery 一样的命运,还需观察。 语言JS 语言本身有满多缺陷的,但通过 babel 前端工程师可以提前享受到大部分新特性,这在很大程度上抵消了早期语言设计带来的问题。 横向对比来看,我们还可以把编程语言分为:前端语言、后端语言、能编译到 JS 的语言。 之所以有 “能编译到 JS 的语言” 这一类,是因为 JS Runtime 几乎是前端跨平台的通用标准,能编译到 JS 就代表了可跨平台,然而现在 “能编译到 JS 的语言” 除了紧贴 JS 做类型增强的 TS 外,其他并没有火起来,有工具链生态不匹配的原因,也有各大公司之间利益争夺的原因。 后端语言越来越贴场景化,比如 Go 主打轻量级高并发方案,Python 以其易用性占领了大部分大数据、人工智能的运算场景。 与此对应的是前端语言的同质化,前端语言绑定在前端框架的趋势越来越明显,比如 IOS 平台只能用 OC 和 Swift,安卓只能用 JAVA 和 Kotlin,Flutter 只支持 Dart,与其说这些语言更适合这些平台特性,不如说背后是谷歌、苹果、微软等巨头对平台生态掌控权的争夺。Web 与移动端要解决的问题是类似的:如何高效管理 UI 状态,现在大部分都采用数据驱动的思路,通过 JSX 或 Template 的方式描述出 UI DSL(更多可参考 前端开发编程语言的过去、现在和未来 UI DSL 一节)、以及性能提升:渲染和计算分离(这里又分为并发与调度两种实现思路,目的和效果是类似的)。 所以编程语言的未来也没什么悬念,前端领域如果有的选就用 JS,没得选只能依附所在平台绑定的语言,而前端语言最近正在完成一轮升级大迁徙:JS -> TS,JAVA -> Kotlin,OC -> Swift,前端语言的特性、易用性正在逐步趋同。需要说明的是,如果仅了解这些语言的语法,对编程能力是毫无帮助的,了解平台特性,解决业务问题,提供更好的交互体验才是前端应该不断追求的目标,随着前端、Native 开发者之间的流动,前端领域语言层面差异会会来越小,大家越关注上层,越倾向抹平语言差异,甚至可能 All in JS,这不是因为 JS 有多大野心,而是因为在解决的问题趋同、业务优先的大背景下,大家都需要减少语言不通带来的障碍,最好的办法就是统一语言,从人类语言的演变就可以发现,要解决的问题趋同(人类交流)、与国家绑定的小众语言一直都有生存空间、语法大同小异,但不同语言都有一定自己的特色(比如法语表意更精确)、跨语言学习成本高,所以当国际化协作频繁时,一定会催生一套官方语言(英语),而使用基数大的语言可能会发展为通用国际语言(中文)。 将编程语言的割裂、统一比作人类语言来看,就能理解现状,和未来发展趋势了。 可视化前面也说过,前端的底层在逐渐封闭,而可视化就是前端的上层。 所以笔者很少提到工程化,原因就是未来前端开发者接触工程化的机会越来越少,工程化机制也越来越完善,前端会逐渐回归到自己的本质 - 人机交互,而交互的重要媒介就是图形,无论组件库还是智能化设计稿 To Code 都为了解放简单、模式化的交互工作,专业前端将更多聚集到图形化领域。 图形和数据是分不开的,所以图形化还要考虑性能问题与数据转换。 可视化是对性能要求最高的,因此像 web worker、GPU 加速都是常见处理手段,WASM 技术也会用到可视化中。具体到某个图表或大屏的性能优化,还会涉及数据抽样算法,分层渲染等,仅仅性能优化领域就有不少探索的空间。性能问题一般还伴随着数据量大,所以数据序列化方案也要一并考虑。 可视化图形学是非常学术的领域,从图形语法到交互语法,从一图一做的简单场景,到可视化分析场景的灵活拓展能力,再到探索式分析的图形语法完备性要求,可视化库想要一层层支持不同业务场景的需求,要有一个清晰的分层设计。 仅可视化的图形学领域,就足够将所有时间投入了,未来做可视化的前端会越来越专业,提供的工具库接口也越来越有一套最佳实践沉淀,对普通前端越来越友好。 BI 可视化分析就是前端深造的一个方向,跟随 BI 发展阶段,对前端的要求也在不断变化:工程化、组件化、搭建技术、渲染引擎、可视化、探索式、智能化,跟上产品对技术能力的要求,其实是相当有挑战性的。 编辑器编辑器方向主要有 IDE(Web IDE)、富文本编辑器。 IDE 方向 国产做的比较好的是 HBuilder,国际上做的比较好的是 VSCode,由于微软还同时推出了 Web 版 MonacoEditor,让 Web IDE 开发的门槛大大降低。 作为使用者,现在和未来的主流可能都是微软系,毕竟微软在操作系统、IDE 方面人才储备和经验积累很多。但随着云服务的变迁,引导着开发方式升级,IDE 游戏规则可能迎来重大改变 - 云化。云化使得作为开发者拥有更多竞争的机会,因为云上 IDE 市场现在还是蓝海,现在很多创业公司和大公司内部都在走这个方向,这标志着中国计算机技术往更底层的技术发展,未来会有更多的话语权。 从发展阶段来说,前端也发展到了 Web IDE 这个时代。对大公司来说,内部有许许多多割裂的工程化孤岛,不仅消耗大量优秀的前端同学去维护,也造成内部物料体系、工程体系难以打通,阻碍了内部技术流通,而云 IDE 天生的中心化环境管理可以解决这个问题,同时还能带来抹平计算机环境差异、统一编译环境、源码不落盘、甚至实现自动的多人协作也成为了可能,而云 IDE 因为在云上,也不止于 IDE,还可以很方便的集成流程,将研发全链路打通,因此在阿里内部也成为了今年四大方向之一。 所以今年可以明显看到的是,前端又在逐步替代低水平重复的 UI 设计,从设计稿生成代码,到研发链路上云,这种顶层设计正在进一步收窄前端底层建设,所以未来会有更多专业前端涌入可视化领域。 富文本编辑器方向 是一个重要且小众的领域,老牌做的较好的是 UEditor 系列,现在论体验和周边功能完善度,做得最好的是语雀编辑器。开源也有很多优秀的实现,比如 Quill、DraftJS、Slate 等等,但现在富文本编辑器核心能力是功能完备性(是否支持视频、脑图、嵌入)、性能、服务化功能打通了多少(是否支持在线解析 pdf、ppt 等文件)、交互自然程度(拷贝内容的智能识别)等等。如果将眼光放到全球,那国外有大量优秀富文本编辑器案例,比如 Google Docs、Word Online、iCloud Pages 等等。 最好用的富文本编辑器往往不开源,因为投入的技术研发成本是巨大的,本身这项技术就是一个产品,卖点就是源码。 富文本编辑器功能强度可以分为三个级别:L0~L2: L0:利用浏览器自带的输入框,主要指 contenteditable 实现。 L1:在 L0 的基础上通过 DOM API 自主实现增删改的功能,自定义能力非常强。 L2:从输入框、光标开始自主研发,完全不依赖浏览器特性,如果研发团队能力强,可以实现任何功能,典型产品比如 Google Docs。 无论国内外都鲜有进入 L2 强度的产品,除了超级大公司或者主打编辑器的创业公司。 所以编辑器方向中,无论 IDE 方向,还是富文本编辑器方向,都值得深入探索,其中 IDE 方向更偏工程化一些,考验体系化思维,编辑器方向更偏经验与技术,考验基本功和架构设计能力。 智能化笔者认为智能化离前端这个工种是比较远的,智能化最终服务前后端,给前后端开发效率带来一个质的提升,而在此之前,作为前端从业者无非有两种选择:加入智能化开拓者队伍,或者准备好放弃可能被智能化替代的工作内容,积极投身于智能化解放开发者双手后,更具有挑战性的工作。这种挑战性的工作恰好包括了上面分析过的四个点:语言、框架、可视化、编辑器。 类比商业智能化,商业智能化包括网络协同和数据智能,也就是大量的网络协同产生海量数据,通过数据智能算法促进更好的算法模型、更高效的网络协同,形成一个反馈闭环。前端智能化也是类似,不管是自动切图、生成图片、页面,或者自动生成代码,都需要算法和前端工程师之间形成协同关系,并完成一个高效的反馈闭环,算法将是前端工程师手中的开发利器,且越规模化的使用功效越大。 另一种智能化方向是探索 BI 与可视化结合的智能化,通过功能完备的底层图表库,与后端通用 Cube 计算模型,形成一种探索式分析型 BI 产品,Tableau 就是典型的案例,在这个智能化场景中,需要对数据、产品、可视化全面理解的综合性人才,是前端职业生涯另一个突破点。 3. 总结本文列举的五点显然不能代表前端的全貌,还遗漏了太多方面,比如工程化、组件化、Serverless 等,但 语言、框架、可视化、编辑器、智能化 这五个点是笔者认为前端,特别是国内前端值得持续发力,可以做深的点,成为任何一个领域的专家都足以突破前端工程师成长的天花板。 最后,前端是最贴近业务的技术之一,业务的未来决定了前端的未来,创造的业务价值决定了前端的价值,从现在开始锻炼自己的商业化思考能力与产品意识,看得懂业务,才能看到未来。 讨论地址是:精读《前端未来展望》 · Issue ##178 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《加密媒体扩展》","path":"/wiki/WebWeekly/前沿技术/《加密媒体扩展》.html","content":"当前期刊数: 26 首图来自 https://www.cablelabs.com/meet-connectivity-enabler-alberto-campos 精读加密媒体扩展(Encrypted Media Extensions,EME)本期精读的文章是: W3C 发布加密媒体扩展(Encrypted Media Extensions,EME)正式推荐标准 感谢 xekri 提供 hacker news 上的热门讨论帖:https://news.ycombinator.com/item?id=15278883 1 引言 制定 Web 标准的行业组织 W3C 发表了加密媒体扩展(Encrypted Media Extensions,EME)的推荐规格,使得受争议的 HTML5 DRM 成为 Web 的正式标准。W3C 的新闻稿称,“EME 是一个应用编程接口(API),允许无插件播放 Web 浏览器中受保护(加密的)内容,它可以无缝地作用于所有主要的平台。W3C 的媒体资源扩展标准(Media Source Extensions, MSE)提供传送媒体视频的 API,而 EME 提供了处理加密内容的 API。MSE 和 EME 的组合是当今最常见的做法,允许 Web 开发人员在不使用插件的情况下也可以通过 Web 提供商业品质的视频。”在 W3C 成员批准该规格的最终投票中,58.4% 支持,30.8% 反对,10.8% 弃权。电子前哨基金会(EFF)随后发表了致 W3C 的公开信,谴责 W3C 放弃了共识,宣布辞职抗议。 —— 摘自《HTML5 DRM 正式成为 Web 标准,EFF 辞职抗议》 以上,是我 17 年 9 月 19 日晚收到的一条推送消息。我当时在写《关于 React 系前端技术的思考》,可是它让我意识到,该关注下 背后的故事了。17 年下半年发生了两件有趣的撕 X 事件:Facebook 将部分开源项目的防专利流氓证书 “BSD + Pattern” 重新授权为 “MIT” 和 W3C 发布 HTML5 版权保护的 EME 推荐标准。一时,似乎著作权、版权和开源、分享,甚至普世、网络中立性,这些声音开始在不少人耳边盘绕。 “无论如何,在当前的现实中,法律是保护著作权的。” 那么,我以 EME 为切入点,和大家聊聊 HTML 5 中如何保护知识产权吧。 2 内容概要接下来,我将为大家分享一些基本概念、背景和 EME 对利益相关方的影响。 在精读部分,将重点汇总浏览器对 MSE 和 EME 的支持情况;分享现代播放器的技术原理, MSE 和 EME 组合的播放器示例,加深大家对现代播放器的相关技术的理解。最后,推荐一些较实用、成熟的开源技术。 基本概念 DRM:数字版权管理(Digital Rights Management)是以一定的计算方法,实现对数字内容的保护, 也可以解释为, 内容数字版权加密保护技术。 EME:加密媒体扩展(Encrypted Media Extensions)是 W3C 提出的一种规范,用于在 Web 浏览器和 DRM 代理软件之间提供通信通道。 MSE:媒体源扩展(Media Source Extensions)是一项 W3C 规范,它扩展了 HTMLMediaElement,允许 JavaScript 生成媒体流以支持回放。这可以用于自适应流(adaptive streaming)及随时间变化的视频直播流(live streaming)等应用场景。 CDM:内容解密模块(Content Decryption Module),客户端或者使用端软件或硬件提供的一个机制,可以播放加密内容。 背景长期以来,“多方利益”模式的 W3C ,以或标准化引领、或被各方优良实践推动再制定标准的方式,来影响着互联网的发展。 2011 年时 Silverlight 、HTML5 及 Flash 还是最受热捧的 RIA (富互联网应用) 技术。当时,Silverlight 的 PlayReady DRM、 Flash 的 Flash Media Rights Management(FMRM),在版权保护上已十分成熟。而 HTML5 还处于 未指明编码标准的萌芽状态、更谈不上版权保护。 随着移动互联网、视频直播、职能家电等等互联网快速发展,浏览器插件一度成为网络恶意攻击的重灾区,给网络用户安全性带来很大隐患。微软和许多企业都鼓励用户、开发者使用 HTML5 的通信协议,标准化通信可以极大增加网络安全性。其中包括 W3C 的 Media Source Extensions (MSE)、 Encrypted Media Extensions (EME),MPEG 的 MPEG-DASH 和 Common Encryption (CENC)。 终于,内容提供商(如 Netflix、Adobe、CableLabs 等)从 Flash、Silverlight 插件播放器过渡到统一的 HTML5 视频播放;各大浏览器公司(如 Google, Microsoft, Apple)也逐步抛弃了过时的媒体插件。 EME 作为 HTML 5 DRM 版权保护方案中的一员,虽然从 2012 年提案开始就颇多争议,但是事实上已被各浏览器以捆绑闭源的 CDM 的沙箱化方式“悄悄”分发。现在,W3C 只是给了它应有的名分罢了。 EME 对 Web 产生的影响W3C 理事长 Tim Berners-Lee 在《W3C Blog: 关于 HTML5 标准中的加密媒体扩展(EME)》中阐述了 EME 对内容分发商、媒体、用户、开发者、安全技术研究人员的影响。 对多数人的影响大概是,可以提供一个相对安全的在线环境使用户可以获取高品质商业级的 Web 音视频等内容,并便捷的就此进行在线互动。 下图是内容提供商分发他们电影的选择渠道和优缺点。 图 1. 取自《ON EME IN HTML5》 值得注意的是,安全技术研究人员还是有些影响的。中国虽然没有所谓的“数字千年著作权法案”,可是毕竟还是保护网络安全和著作权的。 精读浏览器支持情况以下是截取 caniuse 网站统计的 EME 和 ESM 的支持情况(点击图片可跳转到对应网址): 现代播放器的技术原理《视频直播技术详解——现代播放器原理》中,将典型的播放器分解为:UI、多媒体引擎和解码器。如下图: UI:含皮肤、自定义特性(如播放列表、分享等)和业务逻辑部分(广告、设备兼容性逻辑和认证管理等); 多媒体引擎:处理所有播放控制相关逻辑,如描述文件解析、视频片段拉取、自适应码率规则设定和切换等。它拥有非常多的不同组件和特性,从字幕到截图到广告插入等等。 解码器和 DEM 管理器:解码器解码并渲染视频内容;DRM 则通过解密过程来控制是否有权播放。解码器和 DRM 管理器与操作系统平台密切绑定。 图 :解码器、渲染器和 DRM 工作流程图 图 DRM 管理器 今天,在传输工作室生产的付费内容的时候,DRM 是必要的。这些内容必须防止被盗,因此 DRM 的代码和工作过程都向终端用户和开发者屏蔽了。解密过的内容不会离开解码层,因此也不会被拦截。 为了标准化 DRM 以及为各平台的实现提供一定的互通性,几个 Web 巨头一起创建了通用加密标准Common Encryption (CENC) 和通用的多媒体加密扩展Encrypted Media Extensions,以便为多个 DRM 提供商(例如,EME 可用于 Edge 平台上的 Playready 和 Chrome 平台上的 Widewine)构建一套通用的 API,这些 API 能够从 DRM 授权模块读取视频内容加密密钥用于解密。 CENC 声明了一套标准的加密和密钥映射方法,它可用于在多个 DRM 系统上解密相同的内容,只需要提供相同的密钥即可。 在浏览器内部,基于视频内容的元信息,EME 可以通过识别它使用了哪个 DRM 系统加密,并调用相应的解密模块(Content Decryption Module, CDM)解密 CENC 加密过的内容。解密模块 CDM 则会去处理内容授权相关的工作,获得密钥并解密视频内容。 CENC 没有规定授权的发放、授权的格式、授权的存储、以及使用规则和权限的映射关系等细节,这些细节的处理都由 DRM 提供商负责。 MSE 和 EME 组合的播放器示例结合 cpearce/mse-eme 做简要说明,代码可参见对应的 Github 仓库。 index.html:模拟内容服务商视频播放网页,获取 EME 设置(本例中 eme.js),通过调用 MSE 模块(本例中 mse.js) 逐块加载视频片段并控制播放。 resources.js:模拟 License(Key) server,与 CDM 模块交互并提供解密媒体资源所需的 key; media:模拟 Key System 和 Packaging service。主要功能是提供一种内容保护(DRM)机制,实际应用中常见的 Key System 有 Clear Key、Playready、Widevine 等;另外,作为 Packaging Service,提供编码并加密媒体资源以供发布和播放使用。 eme.js: 模拟 EME 通信模块。主要包括监听 MediaKeys 的 message 和 keystatuseschange 变化;发起证书请求;最后,通过 License(key) 解密 video/audio 流; mse.js:模拟媒体源扩展模块,通过调用浏览器提供的 MSE API,来控制视频流播放逻辑。 成熟的开源技术 开源的视频播放器 个人点评 video.js 和其插件。设备检测与配置逻辑的 videojs-contrib-hls 、广告 videojs-contrib-ads 免费开源的 HTML5 和 Flash 播放器,通过强大的插件应用于 400,000 网站。采用 Apache License, Version 2.0 授权 JW Player 号称世界上最流行的嵌入播放器,应用于 200 万网站、每月 13 亿播放次数。采用 Creative Commons license 授权 Shaka Player Google 开源的基于 MSE + EME 的 JavaScript 库,支持 DASH、HLS 等。采用 Apache License 2.0 授权 dash.js 一个支持 MPEG DASH 的参考实现,适合研究学习。采用 BSD 授权 总结目前来看,DRM 市场还是分散状态。只有考虑到各浏览器厂商的 DRM 系统,才能让所有浏览器来支持 DRM 播放。 期待随着标准的发布,注重著作权、版权的互联网能够很快地向有序方向发展。 讨论地址是:精读《W3C 发布加密媒体扩展(Encrypted Media Extensions,EME)正式推荐标准》 · Issue ##37 · dt-fe/weekly如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《前端调试技巧》","path":"/wiki/WebWeekly/前沿技术/《前端调试技巧》.html","content":"当前期刊数: 11 本期精读的文章是:debugging-tips-tricks 编码只是开发过程中的一小部分,为了使我们工作更加高效,我们必须学会调试,并擅长调试。 1 引言 梵高这幅画远景漆黑一片,近景的咖啡店色彩却反差很大,他只是望着黑夜中温暖的咖啡馆,交织着矛盾与孤独。代码不可能没有 BUG,调试与开发也始终交织在一起,我们在这两种矛盾中不断成长。 2 内容概要文中列举了常用调试技巧,如下: Debugger在代码中插入 debugger 可以在其位置触发断点调试。 Console.dir使用 console.dir 命令,可以打印出对象的结构,而 console.log 仅能打印返回值,在打印 document 属性时尤为有用。 ps: 大部分时候,对象返回值就是其结构 使用辅助工具,语法高亮、linting它可以帮助我们快速定位问题,其实 flow 与 typescript 也起到了很好的调试作用。 浏览器拓展使用类似 ReactDTools VueDTools 调试对应框架。 借助 DevToolsChrome Dev Tools 非常强大,dev-tips 列出了 100 多条它可以做的事。 移动端调试工具最靠谱的应该是 eruda,可以内嵌在任何 h5 页面,充当 DevTools 控制台的作用。 实时调试不需要预先埋点,比如 document.activeElement 可以打印最近 focus 过的元素,因为打开控制台导致失去焦点,但我们可以通过此 api 获取它。 结构化打印对象瞬时状态JSON.stringify(obj, null, 2) 可以结构化打印出对象,因为是字符串,不用担心引用问题。 数组调试通过 Array.prototype.find 快速寻找某个元素。 3 精读本精读由 rccoder ascoders NE-SmallTown BlackGanglion jasonslyvia alcat2008 DanielWLam HsuanXyz huxiaoyun vagusX 讨论而出。 移动端真机测试由于 webview 不一定支持连接 chrome 控制台调试,只有真机测试才能复现真实场景。 browserstack dynatrace 都是真机测试平台,公司内部应该也会搭建这种平台。 移动端控制台 Chrome 远程调试 app 支持后,连接 usb 或者局域网,即可通过 Dev Tools 调试 webview 页面。 Weinre 通过页面加载脚本,与 pc 端调试器通信。 通过内嵌控制台解决,比如 eruda VConsole Rosin fiddler 的一个插件,协助移动页面调试。 jsconsole 在本地部署后,手机访问对应 ip,可以测试对应浏览器的控制台。 请求代理charles Fiddler 可以抓包,更重要是可以代理请求。假数据、边界值测试、开发环境代码加载,每一项都非常有用。 定制 Chrome 拓展对于特定业务场景也可以通过开发 chrome 插件来做,比如分析自己网站的结构、版本、代码开发责任人、一键切换开发环境。 在用户设备调试把控制台输出信息打到服务器,本地通过与服务器建立 socket 链接实时查看控制台信息。要知道实时根据用户 id 开启调试信息,并看用户真是环境的控制台打印信息是非常有用的,能解决很多难以复现问题。 代码中可以使用封装过的 console.log,当服务端开启调试状态后,对应用户网页会源源不断打出 log。 DOM 断点、事件断点 DOM 断点,在 dom 元素右键,选择 (Break on subtree modifications),可以在此 dom 被修改时触发断点,在不确定 dom 被哪段 js 脚本修改时可能有用。 Event Listener Breakpoints,神器之一,对于任何事件都能进入断点,比如 click,touch,script 事件统统能监听。 使用错误追踪平台对错误信息采集、分析、报警是很必要的,这里有一些对外服务:sentry trackjs 黑盒调试SourceMap 可以精准定位到代码,但有时候报错是由某处代码统一抛出的,比如 invariant 让人又爱又恨的库,所有定位全部跑到这个库里了(要你有何用),这时候,可以在 DevTools 源码中右键,选中 BlackBox Script,它就变成黑盒了,下次 log 的定位将会是准确的。 FireFox、Chrome。 删除无用的 cssCss 不像 Js 一样方便分析规则是否存在冗余,Chrome 帮我们做了这件事:CSS Tracker。 在 Chrome 快速查找元素Chrome 会记录最后插入的 5 个元素,分别以 $0 ~ $4 的方式在控制台直接输出。 Console.table以表格形式打印,对于对象数组尤为合适。 监听特定函数调用monitor 有点像 proxy,用 monitor 包裹住的 function,在其调用后,会在控制台输出其调用信息。 > function func(num){}> monitor(func)> func(3)// < function func called with arguments: 3 模拟发送请求利器 PostManPostMan, FireFox 控制台 Network 也支持此功能。 找到控制台最后一个对象有了 $_,我们就不需要定义新的对象来打印值了,比如: > [1, 2, 3, 4]< [1, 2, 3, 4]> $_.length// < 4 更多控制台相关技巧可以查看:command-line-reference。 3 总结虽然在抛砖引玉,但整理完之后发现仍然是块砖头,调试技巧繁多,里面包含了通用的、不通用的,精读不可能一一列举。希望大家能根据自己的业务场景,掌握相关的调试技巧,让工作更加高效。 讨论地址是:精读《前端调试技巧》 · Issue ##17 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《可维护性思考》","path":"/wiki/WebWeekly/前沿技术/《可维护性思考》.html","content":"当前期刊数: 212 PS: 所有没给原文链接的精读都是原创,本篇也是原创。 前端精读之前写了 23 篇设计模式总结文,再加上 6 种设计原则,开闭、单一职责、依赖倒置、接口分离、迪米特法则、里氏替换原则,基本上对代码的可维护性有了全面深刻的理解。 但你我在工作中都会不断遇到烂代码,快要无法维护的大型项目,想一想,仅凭设计模式就能解决这些问题吗?为什么不断膨胀的大型项目总是变得越来越难以维护,而复杂度更高的真实世界,但没有人觉得快要崩塌了呢? 设计模式考虑的是代码之间的关系,设计原则考虑的是模块以及项目间的关系,那是否存在更上层的思考,解决大型项目越来越难维护的问题? 精读先考虑一下,为什么真实世界没有可维护性问题? 真实世界为什么没有可维护问题这个问题看起来有点傻,因为从来没有人会发出这样的抱怨 “我们的产品、科技、概念太多了,多到我觉得无法在这个世界活下去了”。但是在代码世界,程序员经常会抱怨,项目的概念太多、设计过于复杂,以至于他无法继续再维护下去了,是时候寻找下一份工作了。 一种显而易见的解释是,生活中,我们都是小角色,活在自己的天空下并不需要触及那么多概念,而程序员在项目中基本扮演了上帝的角色,必须为每一个细节操心。 但这并不完全解释得通。我们以为自己接触的东西不多,但实际上日常生活的知识太多了,就拿家电来说,每个人都会同时接触几十种家电,大到空调冰箱洗衣机,小到手机牙刷充电器,即便这些产品被大量标准化,但每个产品用起来都有大量细节的区别,但没有一个人觉得学习使用一个新剃须刀是一种负担,也并不觉得一款设计得不好的牙刷,会对整个牙刷行业造成怎样负面的冲击。 这背后的原因是:拷贝。正因为我们用的每一件东西都是拷贝,所以即使用坏了也不会对其它相同物品产生任何影响。但代码世界则不同,因为代码调用关系的存在,复用的越优雅,破坏力也就越大。一栋大楼断了几块钢筋尚可支撑,但换在代码世界,只要断了一块钢筋,就意味着这栋大楼所有钢筋都断了。这就是程序员最痛恨的问题之一,就是为什么改了一处看似人畜无害的代码,却导致一场故障。 从这个角度来说,代码世界是无法吸取真实世界经验的。而且代码世界的这种副作用,在商业上是有巨大正向价值的,即软件的边际成本几乎为零,这是实体产品做不到的,因此软件需要付出可维护性代价,似乎是这种极低边际成本的代价。 虽然通过借鉴真实世界的经验,使自己维护成本变成零是不可能的,但真实世界对软件世界确实有可借鉴之处,下面我们就来探讨几个有意思的点。 真实世界不断屏蔽复杂度不知道你会不会有过这样的思考:面试官总是问原理,就是担心我只会用框架,而缺乏基础。但基础是什么呢?懂得 js,java 算是基础吗?也可以说不算,因为这些语言背后的编译原理好像才是基础,编译原理背后还有操作系统,操作系统运行在硬件上,而硬件的原理呢?从 CPU 设计到背后的硅是如何制作的,等等,这样下去,似乎永远也无法掌握原理。 但当我们从软件推导到硬件时,可以很自然的发现,没有人觉得掌握硅胶的制作过程是一件必须的事,我们可以一直使用硅胶制作的产品,但却可以不用了解硅胶制作的原理。 真实世界总是不断屏蔽复杂度,作为消费者时,我们面对的商品总是经过精心包装,简单易用的,只有我们工作时,才需要对某个专业领域的原理有所了解。 这个道理可以迁移到代码世界,即对于一个庞大而复杂的项目,不能指望每位开发者都了解全部原理后才能工作,我们需要在大多数时候把开发者当作消费者来看待,提供精美而稳定的接口。要做到这一点,需要一个类似下图的架构设计: 从图中可以看出,即便是业务层代码,我也不需要关心过于底层的实现,底层的代码就像脚下被压实了的土地,只需要在上面走就行了。 然而最让人崩溃的是下面的设计: 为了解决一个问题,需要面对无穷无尽的上下文,这就是维护成本高的最主要原因。 为什么觉得维护成本高作为开发者,已经习惯了评价代码维护成本高还是低,今天我们换个视角,想一想为什么你会觉得维护成本高? 对维护成本的感受不完全是客观的,我画了一个四象限图: 左边是和人相关部分,包括你对代码的理解能力,以及对项目的熟悉度。 理解能力越强,越不容易觉得维护成本高;对项目越熟悉,哪怕是屎山代码,也会觉得重构后可维护性并不会提高,因为自己对项目会变得不熟悉。 右边是和项目相关部分,包括业务本身的复杂度,以及这背后的技术抽象实现的质量。 业务本身越复杂,维护成本就会越高,因为信息量不可避免的增大了,我们永远不能只盯着 Hello World 的 Demo 研究框架;代码质量体现了技术对业务的抽象,抽象的好,复杂度曲线就会比较贴合业务真实复杂度,抽象的不好,Hello World Demo 也能够新人进来喝一壶。 在这四个关键词中,业务复杂度是几乎无法改变的,对项目熟悉也需要一个过程,所以重点应该放在理解能力与代码质量两部分。 无论是个人理解能力,还是代码质量,目标都是帮助我们快速理解项目,也就是说,只要能快速理解技术项目在做什么,我如何快速融入,就会觉得可维护性高,反之则觉得不好维护。 所以一个简单的项目,或者一个分层合理,文档清晰的大型项目都会让人觉得可维护性好。在这一点上,需要向真实世界学习的经验就是,即便在软件世界,也并不是了解所有原理,所有犄角旮旯的逻辑才表明技高一筹,带着这种思想工作只会让大家陷入无尽的内卷和理解焦虑。我们要给大家思想减负,不需要理解的模块、代码设计,就不要轻易展示出来,将每个模块开发所需了解的最小知识设定好,最大程度减少开发者的理解负担。 当然要补充一句,这并不意味着局限开发者的成长和学习空间,其它知识随时敞开大门,只是理解它们并不是日常开发所必要的,这些知识形成文档可以用完即弃,不用成为长期记忆。说到这,就引出了真实世界第二个有趣的地方,就是说明书。 真实世界的说明书我回头想想也挺不可思议的,无论快递买来任何需要组装的东西,按照说明书的指引最终都可以组装好,而且装好之后就可以把说明书扔了,完全没有认知负担。 与其说快递包裹的说明书太完善了,不如说说明不完善,不好用的商品根本卖不出去。我们早已习惯极度易用的商品,及其详尽的说明书了,这是商业社会持续发展,长期博弈后的结果,而且会稳定持续下去。试想一下,如果我们参与维护的项目也有精巧的设计,完善的文档,那维护就不是什么问题,按照文档说的一步步来就行了。 那为什么大部分情况,我们接手的项目就像一个没有说明书的乐高呢?这应该是商品与代码的本质区别了,即商品质量好不好,是由买家用钞票投票的,做得好用,说明书完善的商品才能存活下来,但这背后的技术实现是看不到的,也没有人可以投票,即便技术人员吐槽代码无法维护,但如果项目取得了商业上的成功,也只会越做越大,技术债越滚越多。 技术项目的买家是程序员,但程序员没有拒签的办法,导致无论项目质量如何都要接受,没有市场机制的作用,就导致了烂代码随处可见。 要解决这个问题,首先要意识到这个问题,即技术项目质量本质上是无人长期、持续关心的,你可能会说,技术 Leader 会关心呀?但这和业务驱动相比实在是太弱了。产品有用户侧钞票的投票,无论管理者换多少人,还是会从源头持续提供动力,但项目质量总是要反复强调,间歇性整治,并且不同的 Leader 关心程度也不同,因为这背后没有源动力,除非项目质量影响到用户那头的现金供给了,但这种情况发生时,说明项目早已烂透了。 正是因为技术质量缺乏源动力,或者说源动力传导链路太长,我们才要人为的不断加强重视,重视文档、重视使用体验、重视是否符合设计模式。只有长期主义者才能坚持做代码质量治理,因为坚信总有一天,代码质量会影响到业务发展。 总结这次从真实世界借鉴了一些经验到软件世界,我们从借鉴真实世界的屏蔽复杂度,谈到了为什么真实世界的说明书这么好用,但技术项目文档却总是缺胳膊少腿的问题。 我们总结出的经验是,设计原则与设计模式固然可以提升可维护性,但归根结底还是动力的问题,提升代码质量本身就是一件缺乏动力去做的事,或者长期被认为是重要不紧急的事,往往很难找出理由现在就去做,但没有人觉得不应该做。 所以想要提升可维护性,找到为什么现在,立刻,马上就要做技术优化的原因,并立即开始优化才是最重要的。 讨论地址是:精读《可维护性思考》· Issue ##359 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《可视化搭建思考 - 富文本搭建》","path":"/wiki/WebWeekly/前沿技术/《可视化搭建思考 - 富文本搭建》.html","content":"当前期刊数: 161 1 引言「可视化搭建系统」——从设计到架构,探索前端的领域和意义 这篇文章主要分析了现阶段可视化搭建的几种表现形式和实现原理,并重点介绍了基于富文本的可视化搭建思路,让人耳目一新。 基于富文本的可视化搭建看似很新颖,但其实早就被广泛使用了,任何一个富文本编辑器几乎都有插入表格功能,这就是一个典型插入自定义组件的场景。 使用过 语雀 的同学应该知道,这个产品的富文本编辑器可以插入各种各样自定义区块,是 “最像搭建” 的富文本编辑器。 那么积木式搭建和富文本搭建存在哪些差异,除了富文本更倾向于记录静态内容外,还有哪些差异,两者是否可以结合?本文将围绕这两点进行讨论。 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 的能力。 但富文本搭建有着显著的特征,就是光标。 积木式搭建和富文本搭建的区别富文本以文本为中心,因此编辑文字的光标会常驻,编辑的核心逻辑是排版文字,并考虑如何在文字周围添加一些自定义区块。 有了光标后,圈选也非常重要,因为大家编辑文字时有一种很自然的想法是,任何文字圈选后复制,可以粘贴到任何地方,那么所有插入到富文本中的自定义组件也要支持被圈选,被复制。 实际上富文本内插入自定义区块也可以转换为积木式搭建方案解决,比如下面的场景: 文本 A图表 B文本 C 我们在文本 A 与 文本 C 之间插入图表 B,也可以理解为拖拽了三个组件:文本组件 A + 图表组件 B + 文本组件 C,然后分别编辑这三个组件,微调样式后可以达到与富文本一样的编辑效果,甚至加上自由布局后,在布局能力上会超越富文本。 虽然功能层面上富文本略有输给积木式搭建,但富文本在编辑体验上是胜出的,对于文字较多的场景,我们还是会选择富文本方式编辑而不是积木式搭建拖拽 N 个文本组件。 所以微软 OneNote 也吸取了这个经验,毕竟笔记本主要还是记录文字,因此还是采用富文本的编辑模式,但创造性的加入了一个个独立区块,点击任何区域都会创造一个区块,整个文档可以由一个区块构成,也可以是多个区块组合而成,这样对于连贯性的文字场景可以采用一个富文本区块,对于自定义区块较多,比如大部分是图片和表格的,还可以回到积木式搭建的体验。由于 OneNote 采用绝对定位模拟流式布局的思路,当区块重叠时还可以自动挤压底部区块,因此多区块模式下编辑体验还是相对顺畅的。 可以看出来这是一种结合的尝试,从前端角度来看,富文本本质上是对一个 div 进行 contenteditable 申明,那么一个应用可以整体是 contenteditable 的,也可以局部几个区块是,这种代码层面的自由度体现在搭建上就是积木式搭建可以与富文本搭建自由结合。 积木式搭建与富文本搭建如何结合对于积木式搭建来说,富文本只是其中一个组件,在不考虑有富文本组件时是完全没有富文本能力的。比如一个搭建平台只提供了几个图表和基础控件,你是不可能在其基础上使用富文本能力的,甚至连写静态文本都做不到。 所以富文本只是搭建中一个组件,就像 contenteditable 也只能依附于一个标签,整个网页还是由标签组成的。但对于一个提供了富文本组件的积木式搭建系统来说,文字与控件混排又是一个痛点,毕竟要以一个个区块组件的方式去拖拽文本节点,成本比富文本模式大得多。 所以理想情况是富文本与整个搭建系统使用同一套 DSL 描述结构,富文本只是在布局上有所简化,简化为简单的平铺模式即可,但因为 DSL 描述打通,富文本也可以描述使用搭建提供的任意组件嵌套在内,所以只要用户愿意,可以将富文本组件拉到最大,整个页面都基于富文本模式去搭建,这就变成了富文本搭建,也可以将富文本缩小,将普通控件以积木方式拖拽到画布中,走积木式搭建路线。 用代码方式描述积木式搭建: <bar-chart /><div> <p>header</p> <line-chart /> <p>footer</p></div> 上述模式需要拖拽 bar-chart、div、p、line-chart、p 共 5 个组件。富文本模式则类似下面的结构: <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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《国际化布局 - Logical Properties》","path":"/wiki/WebWeekly/前沿技术/《国际化布局 - Logical Properties》.html","content":"当前期刊数: 86 1 引言“一带一路” 正在积极推动中国的国际化进程,前端网站也面临着前所未有的国际化挑战。那么怎么才能积极响应 “一带一路” 战略,推动网站的国际化工作呢?可以先从国际化布局开始考虑。 本周精读的文章是:new-css-logical-properties,通过一种新的 CSS 技术,实现国际化布局。 CSS Logical Properties 是一种新的 CSS 布局方案,嗯对,和几年前的 Flex 布局、Grid 布局一样,CSS Logical Properties 方案不出意外的受到了微软的阻挠: 不过没关系,不论是 Flex、Grid 我们都挺过来了,Proxy 虽然还不被微软支持,不过已经在 Edge 被支持了。相信 CSS Logical Properties 也一样,现在可以率先使用在国外环境,国内等若干年后 Edge 支持或者被淘汰了,就可以用上了。 2 概述旧的盒子模型告诉我们左右上下这四个方向,但在新的模型中,请记住 inline-start inline-end block-start block-end: (LTR)对应关系如下: 左: inline-start 右: inline-end 上: block-start 下: block-end 这些适用于 margin padding border 修饰,比如 margin-left 中,left -> 左 -> inline-start -> margin-inline-start 这有点像把坐标系概念引入了布局,对于不同国家,inline 与 block 的方向是不同的: 在东亚绝大多数国家、英美系国家 padding-inline-start = padding-left 在阿拉伯国家 padding-inline-start = padding-right 在日本 padding-inline-start = padding-top 以中国和英美系国家的阅读顺序为基准的话,阿拉伯国家等于把左右颠倒了,而日本是把网页沿顺时针旋转 90 度。 为什么 inline 表示从左右,block 表示上下呢?还记得 display: inline 吗?此时排版是从左到右排布的,而 display: block 的排版是从上到下的。 宽高width height 也需要换成 inline-size 与 block-size,整理如下(LTR): width: inline-size min-width: min-inline-size max-width: max-inline-size height: block-size min-height: min-inline-size max-height: max-inline-size 下图是 Box Model 与 Logical 的对比: 绝对定位对于绝对定位属性 top/right/left/bottom top: inset-block-start bottom: inset-block-end left: inset-inline-start right: inset-inline-end 记得方式与 上下左右 表相同,在前面加上 inset 前缀。 尽管这样描述起来很复杂: .popup { position: fixed; inset-block-start: 0; /*top - in English*/ inset-block-end: 0; /*bottom - in English*/ inset-inline-start: 0; /*left - in English*/ inset-inline-end: 0; /*right - in English*/} 但是这种属性支持聚合写法: .popup { position: fixed; inset: 0 0 0 0; /*top, right, bottom, left - in English*/} Float对于 float 的两个值 left right,可以很容易推测出来,会被 inline-start 与 inline-end 取代(LTR): float: left = float: inline-start float: right = float: inline-end Text-aligntext-align 也有 left right 属性,分别取代为 start end(LTR): text-align :left = text-align: start text-align :right = text-align: end Css Grid 与 Flexbox使用 css grid 与 flexbox 布局方案的网页,将在支持的浏览器上自动享受国际化布局调整,不需要改变语法。 Writing-mode目前为止,看到的是 Css 对排版含义的规范化,Grid 与 Flexbox 由于 API 比较新,定义的较为规范,所以不用变,而旧的 display, position, width, height, float 等 API 需要进行语义化改造。 现在就要聊到最关键的布局国际化部分,我们至今为止遇到的网页都是从上到下的,但其他文化却不同。可以通过配置 writing-mode 让整个网页布局改变: writing-mode: horizontal-tb = 从上到下writing-mode: vertical-rl = 从右到左 比如日本文化writing-mode: vertical-lr = 从左到右 比如蒙古文化 至今还没有见过从下到上的网页,也许这证明了从下到上是最不合理的阅读方式。 Direction这是一个排版属性,writing-mode 是控制网页方向的,而 direction 是控制文字对齐方向的。 目前只有两个配置:rtl 与 ltr: html { direction: rtl;} 其实 writing-mode 与 direction 结合起来也没什么问题,比如网页布局变成 vertical-rl - 从右到左,那么 direction 的 ltr 就等于是从上到下了。 最后还有一些悬而未决的问题,比如如何开启智能布局?一种方式是: html { flow-mode: physical; /*or*/ flow-mode: logical;} 另外,像 @meta 配置中的 max-width 也要替换为 max-inline-size, line-height 需要被替换为 line-size,border-width 需要被替换为 border-size 等等。 3 精读整个 Logical Properties 规范看下来是个不可逆的趋势,也代表着 W3C 规范在排版方面的全球化工作。 为什么要改造语法第一个问题就是这个,我们习以为常的 left top right bottom 语法都需要改成 inline-start block-end 等略微晦涩的语法,而且你可以发现,新语法与旧语法是完全一对一对等的,也就是完全可以交给某个转换程序去做! 可以看出,这是一个习惯问题,W3C 希望重塑国际化布局的语义,而原有的 left top 等无法承担这些语义,所以只好换掉。 新版规范要求开发者做出一个抽象,把自己国家的习惯抽象成习惯无关的描述。但对于每个前端从业者来说,left top 等描述估计已经成为肌肉记忆了,想要改变规范还是挺难的,未来前端社区也许会出现三种解决方案: 保守派 - 利用 babel 将原有语法与新语法做一对一映射转换,比如 position: left -> position: inset-inline-start。这种方案 成本最小,且不改变开发者习惯,所以最有可能被国内公司率先采用。在商业环境推动一件事情,最大的阻力无非是 成本 与 共识,这次的布局规范同时触及了这两个点,可能让团队倾向于做保守派。 兼容派 - 其实就是两面派,利用 babel 工具做映射这一点与保守派相同,但是新代码推荐用新语法编写,如果团队中有人不遵循新规范,也会被工具自动转换为新规范。这种软要求会导致团队布局代码存在两套,但最终效果却没有问题的神奇效果,长远来说不利于维护,但不失为一种较为妥协的策略。 改革派 - 利用脚本,将项目里旧规范替换成新规范,并让团队未来的代码遵循新的布局规范编写。很显然,这派抓住了迁移成本小这个优势,但没有考虑到人这个因素的习惯迁移成本,如何说服其他人理解新规范,并做到让 “未来加入的同事” 也能认同并遵循这套新规范,也许是最大的不确定因素。 为什么 Flex Grid 语法不需改造?这次改造是冲着 left right width height 等明显带有文化色彩的语法来的。 然而 Flex 语法已经将方向定义转化为抽象的 start 与 end,而 center 是没有歧义的,所以 FlexBox 语法不用改。 而 Grid 是一种拆分单元格的语法,也不涉及具体上下左右的描述,所以也符合国际化语义。 4 总结那么为什么 W3C 到现在才改语法,难道以前没有想到吗?也许还真是,或者处于推广成本的考量,或者当时的文明发展阶段还没有意识到文化差异会导致布局方式有所不同。 当出现 Logical Properties 特性时,说明人类的全球化已经突破了翻译维度,开始向比如布局方式等其它维度蔓延了。 除了布局需要国际化,使用数字的习惯也需要国际化,可以阅读这篇拓展文章 和欧洲人打交道一定要知道他们数字写法,否则吃大亏!。 那么除了这些,还有哪些维度的国际化策略呢?除了语言的翻译,国际化还有哪些工作需要准备?欢迎在下面留言。 讨论地址是:精读《国际化布局 - Logical Properties》 · Issue ##121 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《图解 ES 模块》","path":"/wiki/WebWeekly/前沿技术/《图解 ES 模块》.html","content":"当前期刊数: 52 精读《图解 ES 模块》ES 模块为 JavaScript 开发者带来了官方并且标准化的模块系统。模块标准化来之不易,用了近 10 年的时间。漫长的等待就要宣告结束了。随着五月份(2018)即将发布的 Firefox 60,几乎所有的主流浏览器都将支持 ES 模块,并且 Node 模块工作组也正尝试将 ES 模块支持到 Node 环境。本期精读文章和大家一起了解 ES 模块,讨论它能够解决的问题以及与其他模块系统间的差别。 1. 引言精读文章主要讨论了下面几点: 模块旨在解决哪些问题; 模块为开发者带来哪些; ES 模块化的工作机制; ES 模块化的现状; 2. 内容概要模块旨在解决哪些问题JavaScript 开发可以简单地抽象成维护变量,赋值和计算操作。大量的代码在用于操作变量,开发者需要懂得如何去组织和维护这些变量。JavaScript 提供了一种方式,即函数作用域。在一个函数内只需要考虑这个函数的变量问题。不必去担心其他函数会操作这些变量。当然,随之带来的问题是,变量无法共享,无法在不同的函数之间相互共享变量。如果想要在作用域外共享变量,只能通过外层作用域,或者全局作用域。 jQuery 时代,只要 $ 变量在全局作用域下,就可以加载任何的插件,不过它本身存在问题的。 首先,要保障 script 标签的顺序。如果顺序错乱,应用将会抛错。比如函数用到了全局作用域的 $ 函数,但没有找到,就会抛错了。 这就使得维护代码变得很复杂。移除旧代码会像轮盘赌游戏一样,无法预料将会发生什么。不同部分代码之间存在隐形的依赖。所有函数都可以访问全局变量,根本无法知道哪个函数属于哪个脚本。 还有,存储在全局的变量可以被任何作用域中的代码修改。代码可能遭到恶意的修改。 模块为开发者带来哪些模块提供了更好的方式来组织变量和函数,把相关的变量和函数组织到一起。具体就是将这些函数和变量放到一个模块作用域内,实现在模块间共享变量。 与函数作用域不同的是,模块内部的变量实现了在其他模块内共享。而且可以指定哪些变量、类或者函数可以共享。 在其他模块中共享,被称为 export。这就出现了模块间的依赖,是一种很明确的关系,当移除一个模块时可以准确的知道哪些模块会出错。 一旦有了模块间导出和引用变量的能力,我们就可以将代码打成小包。然后就可以像乐高玩具那样组合,再组合。使用小模块就可以创建出各类应用。 模块非常有用,这也就出现了很多种类的 JavaScript 模块。目前存在两种主流的模块系统。CJS 是 Nodejs 遗留下来的。ESM 是一个 JavaScript 的新规范。浏览器已经支持了 ESM,并且 Node 也在添加支持。 ES 模块化的工作机制模块化开发会将依赖构建为树形结构。通过 import 语句通知浏览器或者 Node 去加载相关的代码。这些依赖树会有一个根节点作为入口文件,从入口可以找到依赖的其他代码。 在浏览器环境下这些文件需要被转化为一种叫做『模块记录』的数据结构。紧接着,模块记录需要被转化为模块实例。每个实例包含了两个东西:代码和状态。 代码就像是指令集。如果仅通过代码并不能做什么,还需要一些原始的材料来应用这些指令。状态就提供了原始的材料。状态其实就是这些变量的值。当然,这些变量仅仅是内存中存储值的别名。 模块将代码和状态结合到一起。 从入口文件到完整的模块树形实例,主要经过了下面三个步骤: 构建:查找,下载,然后将所有的文件转化为模块记录。 安装:将所有导出的变量放到内存中,此时的变量并没有被赋值。然后将导出和导入变量全部放到内存中。我们称之为链接。 赋值:执行代码,将变量值添加到内存中。 之所以说 ES 模块是异步的,正是因为 ES 模块将这三个步骤划分开。实际上在 CJS 中模块和相关的依赖都是一次完成加载,安装和赋值的。 ES 模块需要借助模块加载器来实现这三步。加载器在不同的平台下有不同的规范,浏览器端就是 HTML 规范。 1. 构建确认从哪里加载文件所包含的模块,查找加载文件加载器比较关心的是查找并且下载到文件。首先需要找到入口文件。在 HTML 中通过一个 script 标签。 但是接下来要如何找到模块直接依赖的文件树呢? 这就是 import 语句出场的时候了,它可以通知加载器去哪里找到其他的模块。 模块规范需要注意的一件事就是:它们有时候需要处理浏览器和 Node 两个不同的环境。每个宿主环境处理模块标识符的方式不同。为了能够实现这个,它使用了一个模块识别算法,用来区分不同的平台。目前,有些 Node 模块规范是无法在浏览器端工作的,不过也正在持续修复中。 在修复前,浏览器仅仅会接收 URL 模块标识符,通过 URL 来加载模块文件。不过,在转化之前你并不知道模块有哪些依赖项,并且你在加载文件前是没有办法转化文件的。 这就意味着我们必须一层一层的遍历文件树,转化文件并找出依赖,最后查找并且加载这些依赖。如果主线程正在等待去下载这些文件,那么很多的任务会堆积在队列中。这是因为浏览器环境下下载用了很长时间。 阻塞主线程会导致应用所需的模块变得很慢。将构建过程分片进行实现了在全部下载前进行获取和构建。这种差分构建的方式是 ES 模块和 CJS 模块最本质的不同。 CJS 的做法很不同,主要是由于相对于通过网络请求从文件系统加载文件耗时更少。这意味着 Node 可以在加载文件的时候阻塞主线程。文件加载完毕后,进行实例化和计算。这也就以为着在返回模块实例前完成遍历整个树,加载,实例化并且计算依赖。 在 Node 环境下,你可以在模块内部声明变量。在查找下一个模块前,都在执行这个模块里的代码。这意味着在执行模块前,变量会有一个值。但在 ES 模块中,需要事先构建整个模块树。 将文件转化为一个模块记录在我们加载文件后,我们需要将它转化为一个模块记录。这会让浏览器理解模块的不同部分。一旦模块记录被创建,就会被放在一个模块映射中。这意味着当它被请求时,加载器可以从映射中拉出来。 在浏览器中你只要将 type="module" 放在 script 标签上。这会通知浏览器这个文件应该被转化为一个模块。同样,只有模块才能够被导入,浏览器也就知道了模块中有哪些引用。 不过在 Node 中,并没有 HTML 标签,所以也没有地方声明 type 属性。社区内的一种方式就是使用 .mjs 扩展。使用这个扩展告诉 Node 这个文件是一个模块。 无论哪种方式,加载器将决定是否将文件转化为一个模块。如果是一个模块并且有导入的话,它就会开始处理直到所有的文件被获取和转化。 2. 安装我之前提到了,实例由代码和状态结合而成的。状态在内存中,所以安装这一步基本是关于如何在写入到内存。 首先,JS 引擎创建一个模块环境记录。这会为模块记录维护变量。然后在内存中开辟空间,让这些变量可以被导出。模块环境记录会基础追踪内存中的值导出的每个变量。内存空间并不会获取到变量的值,而是计算后得到值。 为了实例化模块树,引擎将会完成一个叫做深度优先的后序遍历。这意味从树的底部开始,底部的依赖不会再依赖其他的东西,并且创建它们的导出。 引擎会绘制出一个模块下的所有导出。然后绘制这个模块的所有导入。注意,导出和导入在内存中指向同一个地址。这里和 CJS 模块有区别,在 CJS 中所有导出对象的值都是一个拷贝。与之相反,ES 模块使用了类似绑定的东西。模块会指向内存这种的同一个地址。这意味着当导出模块修改了一个值,这个修改会在不在导入模块时表现出来。 有导出值的模块会在任何时候修改这些值,不过导入模块不会改变他们导入的值。也就是说,如果一个模块引入了一个对象,它可以改变对象的属性值。 像这样动态绑定的原因就是可以在不执行代码的情况下连接所有的模块。 在这一步的最后,我们我们会将实例和内存地址连接起来。 3. 赋值最后一步就是填充内存空间。JS 引擎通过执行顶层的代码来完成,也就是函数外的代码。如果遇到类似异步调用的情况,还可能会出现一些负面的影响。 由于这种负面影响,赋值得到的结果可能是不相同的。这也是模块映射机制出现的一个原因。模块映射会通过 URL 来缓存模块,所以每个模块仅会有一个模块记录。这会确保每个模块只执行一次。就像初始化一样,这也是一个深度优先的后序遍历。 再说一下循环依赖的情况,需要遍历树。通常是一个很长的循环。但是为了解释这个问题,我们做一个简短的例子。 我们先看一下 CJS 是如何工作的。首先,模块会执行 require 语句。然后加载 counter 模块。 ounter 模块接着会访问导出对象里的 message。但由于这个还没有在模块中计算,会返回 undefined。JS 引擎会为本地变量分配内存空间,并且将值赋为 undefined。 Evaluation continues down to the end of the counter module’s top level code. We want to see whether we’ll get the correct value for message eventually (after main.js is evaluated), so we set up a timeout. Then evaluation resumes on main.js. 继续向下计算会执行到 counter 模块的顶部代码。这里设置了一个延时看是否可以正确的获取到 message 的值。 message 变量会被初始化后添加到内存中。不过由于这两者间并没有关联,加载模块后还是 undefined。 如果导出时用了动态绑定处理的,counter 模块最终会拿到准确的值。在执行 setTimeout 后,main.js 会执行完成并且拿到值的。 3. 精读 & 总结模块化提供了更好的方式来组织变量和函数,把相关的变量和函数组织到一起。具体就是将这些函数和变量放到一个模块作用域内,实现在模块间共享变量。与函数作用域不同的是,模块内部的变量实现了在其他模块内共享。而且可以指定哪些变量、类或者函数可以共享。 由于 Nodejs 的缘故,目前看来 CJS 模块系统是使用数量更大。目前的 CJS 还无法兼容新的 ESM,不过 Node 工作组也正在这方面努力尝试中。而这两个模块系统最大的区别就是运行时。CJS 是一个动态的模块系统,而 ESM 只是静态模块系统。动态模块的导出只有在执行后才能得到,并且可以添加和删除,而静态模块则不可以,导入和导出是不可变化的。 而目前我们大都是通过 webpack 的构建工具之上使用 ESM,它可以在一定程度上模拟环境。期待 Node 工作组实现对 ESM 的早日支持。"},{"title":"《前端职业规划 - 2021 年》","path":"/wiki/WebWeekly/前沿技术/《前端职业规划 - 2021 年》.html","content":"当前期刊数: 196 不知道你上次思考前端职业规划是什么时候? 如果你是一位学生,你肯定对前端这个职业感到陌生,你虽然没有经验,但却对未来充满好奇,你有大把时间来思考,但可能摸不着方向,有种拳头打在棉花上的无力感。 如果你已经参加了工作,不论是刚开始实习,还是工作了 3 年、5 年甚至 10 年,一定觉得非常充实,但真正用于思考的时间足够吗?如果维持现状,再过 5 年自己的提升点在哪里?如果你对这些结论不清晰,很可能是缺乏了对职业规划的思考。 这种缺乏职业规划的焦虑已经发展成为了商机。当你没有清晰职业规划,正在迷茫的时候,培训机构站出来说,是不是对职业规划充满焦虑?如果是,可以订购我们的课程,名牌大厂 P10 带你跑赢职场。其实课程确实是干货,但一个具体课程并不能代替你自己的思考,你需要自己想明白自己想要的,而不是被别人灌输思想,因为职场没有标准路线,但培训机构的文案确实有标准写法。 所以这篇前端职业规划是站在我自己角度写的,你如果也在思考长线发展问题,可以作为参考。 我总结出三个主要思考方向,分别是 知识分类、领域深耕、经济视角。 知识分类 指的是你对知识的理解是否成体系。现在全球每天新增的知识,一个人穷尽一生也学不完,如果不建立一套你自己的知识筛选标准,长期发展就无从谈起。 领域深耕 是实践,天天学习也是没有用的,你必须要做出什么有价值的事情,才能为行业带来贡献,或者说将知识转化为财富。当然不同职业学习与实践的比例是不同的,比如理论物理可能模糊了学习与实践的边界,而在职场环境的工程师,更容易区分什么是学习,什么是实践。 经济视角 是说你要能够带着经济视角看问题。可以说没有经济活动,我们一切学习、生产、职业都没有任何意义,因为推动我们学习、推动社会生产的动力是交易,没有经济活动就没有需求,需求是推动一切活动的基础。稍微理解了经济和生产的关系,就能理解为什么技术要为商业服务,因为任何技术都要有转化为商业价值的潜力才值得被研究,大到社会价值,小到产品价值,都一样。 下面我分别讲讲自己对每个方向的理解。 知识分类作为前端,为了保持技术敏锐度,我们会订阅许多专栏了解新知识。仅我知道的周更专栏就有 30 个,其实根据一些专门整理好的专栏检索网站,每周甚至可以看到超过 100 种不同的前端专栏。大部分专栏都在做文章聚合,每篇专栏聚合的文章一般有 5 篇到 30 篇不等,这样即便去除重复,一周至少有几百篇新的前端技术文章等你去读,所以有些同学会觉得焦虑,甚至喊出学不动了。 我每周写前端精读恰好也要找一些文章阅读,但几年下来,我恰恰觉得每周根本找不到有用的素材。就以本周的 javascript weekly 为例,我摘了一些文章标题: DOM Events: A Way to Visualize and Experiment with the DOM Event System。 Introducing WebContainers: Run Node.js Natively in the Browser。 New & Updated Course: Complete Intro to React v6 with Brian Holt Parcel 2 Beta 3: A Wild Rust Appears! 2D Optics Demos in JavaScript A Complete Beginner’s Guide to Next.js How to Create Reusable Web Components with Lit and Vue 第一篇是通过可视化帮你理解 DOM 事件的文章,UI 很有意思,但 DOM 事件作为前端基础,精读实在不适合拿过来炒冷饭,这个知识点讲一遍就行了,没必要做成 UI 后再讲一遍。 第二篇是讲一项技术可以让 Node 运行在浏览器的,这确实是一个新技术,但现阶段我们没必要为这项技术找场景,只要知道有这个东西就行了,没必要仔细阅读。第三篇是对 React 的完整教程,非常体系化,但没有新东西,适合前端新人读,所以也不需要看。 再后面几篇分别是框架升级带来的特性介绍、一个有趣的可视化效果、Next.js 新手入门、如何用 Lit 框架开发组件。这些知识从直觉来看属于可读可不读的,读了吧觉得好像对自己没什么成长,不读又觉得错过了什么,真的像鸡肋。 如果你看到这些 Feed 流也有犹豫的感觉,我建议你建立一套前端知识分类体系。就像学习武功,如果你不了解什么是基本功,什么是花拳绣腿,那么每天面临几百本推送过来的 “武学新闻” 确实是无从学起,而且也学不过来。 在技术领域,知识分类体系是有规可循的,大致可以讲知识分为两种类型:通用、行业知识。 通用知识是指最为基础、适用面也最大的知识,比如数理化,这些知识我们上学时都学过,工作中用到的知识都是建立在这些通用知识基础之上的,比如没有一定数学基础就难以学习计算机可视化领域,因为其中会大量运用数学知识。 通用知识最有用,也最保值,所以学校时就安排给我们了,那么大学其实就在教通用行业知识,所以这个阶段如果没有打牢的基础,想要弥补也很简单,只要按照大学教材温习一遍就好了,对于计算机领域的通用知识一般有计算机原理、操作系统、设计模式、编译原理、数据结构、算法等。 领域通用知识看上去比较死板,而初入工作的同学一般都在做拧螺丝钉的事,往往会忽略行业通用知识的重要性,但当你不断深入接触公司核心技术时,会发现大量运用了大学里教的那些通用知识,等用到的时候再学就迟了。 如果说行业通用知识的保值时间是 30 年,那接下来提到的行业专用知识的保值时间只有 1 年。行业专用知识就是我们在 Weekly 上看到的大部分内容,也包括培训班帮我们速成的前端框架、API 等知识。这些知识非常有用,接地气,而且刚接触工作时第一时间就要用到,但这些知识最大的问题就是太过于上层,以至于同类产品过多,可替代性强,知识点可以随着新版本发布全变了样。 就像项目脚手架工具,现在每天都会出一个基于 webpack 或者 rollup 包装的新品牌,这种脚手架就不值得学习,你也不需要把新出的脚手架当作新知识,因为这些知识的生命周期大部分不到一年,大多没有人用,最重要的是除了名字以外,组成要素里没有任何新知识,所以读完源码也学不到新知识。更最重要的是,你无法根据这些知识生产同类产品,所以如果你真的想学脚手架相关知识,认真读好一个主流脚手架源码就行了,以后除了工作中用到,不需要看任何使用文档。 对于架构能力也一样,我们在工作中通过踩坑甚至把一个项目做失败得出的经验,可能只是设计模式这本书里提到的一个常见误区;我们在设计一个非常复杂的系统时,用到的模块通信设计,可能只是操作系统设计里的一种常见通信方法。一个能理解操作系统复杂度的人,基本上可以处理与其等价复杂度的软件工程问题,而软件工程的复杂度其实很难超越操作系统,所以与其在项目里试错,不如从这些基础知识里找答案。 所以如果你想在职业规划上更进一步,检查一下自己的基础是否牢固。如果你通用知识特别扎实,就可以快速学会行业基础知识,根据行业基础知识,你甚至可以独立创造任何一个新的框架,这些框架都会成为别人学习到的行业专用知识,如果另一位同学没有打基础,把时间都用在学习你做的框架上,那么他的职业发展一定程度会被你左右,而他如果只停留在用的阶段,而不了解实现原理,从长期来看,你的职业天花板一定会更高。 关于哪些是通用基础知识、行业基础知识、行业专用知识,这里不给出具体的建议,相信每个人都会有自己的判断。 领域深耕 这段思考 不适用于 刚参加工作的前端同学。 前端有一句有名的鸡汤 “前端不是因为做交互界面,而是因为站在业务的最前端”,其实这句话是有问题的,我觉得每一位工作经验超过三年的前端同学都有一种在业务领域的无力感。 其实最核心的业务模型天然在后端,这是因为前端只是一个用户与业务系统交互的窗口,没有前端,用户也可以和接口直接交互,只是这么做成本很大,所以为了降低用户上手难度,或者带来更好的用户体验,才需要不断升级 UI 界面,所以 UI 界面和后端往往是多对一的关系,移动端、小程序、网页对应的接口都是一套,目的就是为了方便任何场景用户都能轻松触达业务,所以作为前端,首先要对前端存在的原因有正确的认识。 注意这里说的是业务模型,没有提到体验深度,如果讲究体验深度,自然只有前端能做到。然而前端本质还是锦上添花的部分,因为在任何行业耕耘久了,如果仅仅只考虑前端,那么目标永远是体验度量、研发提效的事情,很少触及到业务层,以至于前端在业务价值的体现不直接,比较难解释体验度量、研发提效与最终业务增长之间的关系。 所以对于有一定工作经验的前端同学,想要更进一步,一定要在业务领域深耕。 那么如何在业务领域深耕呢?首先你要抛开前端视角,用业务眼光看问题,否则还是会陷入无尽的交互细节。首先要了解你所在的领域,比如笔者在的数据领域,要知道行业的历史、现状和未来,有哪些产品,每种产品的商业模式是什么,产品之间有什么关联,现在的产品距离头部产品还有哪些差距,今年产品目标主要解决什么问题,三年目标是什么等等。每个同学首先都应该理解产品,其次再产生研发、产品经理的分工。 然后审视一下自己的工作,在产品核心能力里扮演者什么角色?比如做 BI 工具,其核心是数据分析能力与报表可视化分析能力,如果你总在做类似报表列表页、个人中心这种通用中后台的工作,你就要想想,这些工作是不是可以外包出去,如果不行,那就想办法做一些领域搭建,往通用领域转吧。 当你审视了自己工作,发现核心产品能力与你工作内容不相符,而你又不想转到前端中后台通用领域一直做研发提效的事情,这时候你就要想办法和老板沟通改变一下工作内容了,你可以找一些前端也能接触强业务模型的领域,比如 BI 分析,数据可视化等等。其实通用领域也有不少深水区,比如语雀背后的富文本编辑器、流程图、研发工作台、业务组件库等等都是可以做深的通用领域,当你想再上一层楼时,就要像玉伯一样成为语雀整个产品的引领者,这样你其实又进入了知识协作、生产力工具这个专业领域。 如果你既不想往通用技术领域发展,又无法改变工作内容,就尝试承担更多职责吧,如果可能的话,尝试参与后端业务逻辑的开发,这样可以帮助你深入、全面理解业务逻辑。其实前端 + 产品的路线也可以很好在专业领域做深,前端 + 后端路线也可以,你需要根据自己团队实际情况做出调整。 任何产品的研发团队都要有产品全局观,这就是刚才说的在技术之外,你对你所在业务领域的理解程度,理解程度越高,技术方向就越明确,但如果你的职业规划是再继续攀爬,就要成为整个产品负责人了。现在的年轻人非常上进,许多公司都在尝试采取活水政策,让想更进一步的年轻人尝试新方向开疆拓土,而不是留在一个成熟的团队里内卷。 经济视角做职业规划的另一个目的当然是升职加薪了,但是你的薪资并不能无限膨胀,其增长大致还是符合市场规律的。另外任何工作都是一笔经济账,我们要带着技术、产品和经济视角看业务,才能做出合理的判断。 因为去年疫情原因,全球远程办公得到了积极实践,并且在未来依然有增长潜力,因此作为用人单位方,必定会逐渐放眼全球去看人力成本问题,因为在哪都能办公。从全球软件开发数据来看,美国的工资水平最高,中国软件工程师的工资也紧随其后,所以在软件领域中国已经不存在劳动力成本低廉的优势了,尤其当你工作经验丰富后,要竞争中高级岗位,中国软件公司开的薪资放眼全球都不低。 然而国家之间技术发展阶段、教育水平仍然存在差距,如果同样的资深技术专家岗位,国内与国外开的薪资持平,但中国的软件工程师架构水平完全不及美国的软件工程师,那么长期来看,这种错配会造成企业用人成本浪费,企业会在一定程度想办法优化一下人员构成的。因此作为前端,或者软件工程师,你必须清楚长期而言,你要和全球的软件工程师竞争,所以你还要充分了解你的领域在全球范围的发展阶段,人才水平如何。 以上是个人的经济账,接下来谈谈业务的经济账。 首先你要了解自己的技术是怎样转化为收入,覆盖自己工资的。我们首先看市场竞争,市场竞争通过价格调节供需关系,我们做的产品成本、售价很清楚,是否值得做一目了然。然而对于复杂产品需要多人协作,如果人与人之间再通过市场化机制合作,往往容易产生低效的结果,比如我做的按钮按照 3 元一个的价格卖给后端,那为了提升我的价值,我会提价到 5 元一个,然而倾向于给产品加更多的按钮,这样都在看短期利益,谁也不会为产品长期发展负责。 所以公司是一个相对大锅饭的组织,谁也不要给自己工作定价,大家都尽可能的打磨产品,月底按照合同约定给固定薪酬。这样做确实解决了产品长期发展的问题,但这套机制成熟后,尤其在大公司,刚毕业就去拧螺丝钉的同学很可能永远没有机会了解何为成本,没有成本概念,就难以想清楚为什么做事要考虑投入产出比,或者觉得 ROI 这个词很高级,其实这个词一点不高级,只是公司将它屏蔽了,但如果这导致你做技术完全不考虑成本,只追求让你激动的技术细节,或者只做你感兴趣的技术方向,那其实是不成熟的表现,你做的事情可能也难以被业务认可。 如果你想往更高层次发展,成本意识是一定要培养的,可以了解一下人力成本、机器成本、以及接入二方、三方服务的外部成本,了解这些成本后,再算算产品年营收是否能覆盖这些成本,如果想继续加人,那明年产品营收相应要翻多少,现在市场空间允许产品翻这么多吗?如果想提供更好的服务,要加机器,那么你的业务方是否会因为服务变好变得更多?衡量业务方增多带来的价值一般从订单价格,MAU 来看,如果服务外部,直接看价格是否覆盖成本就行了,如果服务内部,就看 MAU 是否值得投入这些机器成本。 然而也不能只看钱,市场份额也很重要。如果 Chrome 对研发投入只看年营收,那现在 IE 估计还是主流浏览器。其实 Chrome 在确立霸主地位后,对谷歌产品生态的打通、W3C 的话语权、开发者吸引力有很大提升,这些看不见的影响面难以直接转化为金钱来统计,所以如果你认为产品市场份额的提升可以带来长线价值,那么也可以把市场份额作为目标之一。 最后经济视角也不仅仅让我们停留在算业务帐上,经济学的边际收益理论可以指导我们优先做边际收益更大的事。当前业务产品矩阵中,拓展哪些产品可以快速弥补不足,如果做技术优化,优化哪些模块带来产品收益、可维护性收益最大,如果时刻能想清楚这些问题,那每年的产品、技术方向就不会跑偏。 总结总结一下文中提到的三个思考方向,其实是职业生涯发展中可能遇到的三种问题。 工作时间久了就会发现,哪怕依然有学习的激情,但保持刚毕业那会的学习方式已经难有突破了,你会发现:工作实践用到的知识不会很多,反复读或者写入门技术文章,只会让自己停留在校招生的技术水平;自己所处的职业也限制了进一步发展,你需要思考怎么打破职业天花板;甚至只钻研技术领域都是不够的,大家都在谈成本,你在谈技术,天然就不在一个频道上。 本文也给出了对应的三个解决方案,知识分类 帮助你解决反复学习无用的、入门知识的问题;领域深耕 帮助你解决职业天花板的问题;经济视角 帮助你解决技术单一视角的问题。 其实职业有天花板很正常,没有哪个职业上升通道是一路无阻的,但人是活的,你可以逐渐改变自己,在适当的时候多看看业务、经济问题,学习知识也不要仅停留在表面,虽然这些你工作中可能根本用不到,但这其实是悖论,因为你没掌握某些知识,所以也没机会接触那些工作,想打破悖论只能从痛苦的自我打破边界开始。 与一般前端职业规划不同,我并没有说很多前端领域专有名词,或者点名要学哪些框架,因为我觉得人之间智商差距并不大,必须掌握的知识工作几年都能学会,而真正能拉开人之间差距的,不是智商,而是学习方法,或者学习路线,如果你把时间用在错误的地方,或者错误的阶段,终将积累成巨大差距。 希望我的思考可以对你有帮助。 讨论地址是:精读《前端职业规划 - 2021 年》· Issue ##317 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《前端深水区》","path":"/wiki/WebWeekly/前沿技术/《前端深水区》.html","content":"当前期刊数: 119 作者:五灵 简介其实关于前端深水区的讨论,已经有了很多,也有了很多相关的文章。我也想借这篇关于深水区的讨论文章,讲一下自己对于深水区的理解。原文链接:技术路线:前端开发已进入深水区 本期精读,@camsong、@arcthur、@ascoders 都有贡献观点。 概述原文对于深水区的想法,讲的很清楚,还是建议读者去读一下原文。对比 2010 年,整个前端生态已经翻新了好几遍,直到近几年的 Node BFF、IDE Cloud,抑或是客户端 AI,还是 Serverless 的建设,前端想要深度参与的话,单纯依靠原来的 HTML/CSS/JS 三件套技能也远远不够了。再抛开技术,整个互联网创业生态也重构了好几遍。无论是技术层面还是意识层面,如今的前端开发已经进入深水区。 深水区需要哪些技能深水区需要是四个核心能力,分别是:技术、产品、业务和管理能力。 面对深水压力不需紧张其实何止前端开发,整个技术行业都已步入深水区,只是前端工程师的感知来的晚一些而已。只要把眼光投向深水区,问题就会一个接一个的浮上来,当越来越多问题浮起来的时候,就是你慢慢沉向深水区的时候,这时候不需要太过紧张。 精读深水区的理解首先需要达成一致,并不只是一个维度的加深,而是全方位多方面的困难同时加击,压强升高、光线减少、温度剧变等等。 对应到文中总结的解法就是需要『技术创新、流程优化、团队合作、影响大盘、驱动业务、商业决策和团队管理』。但你展开想一下,把这个角色换成后端、无线端、甚至是 UED,是不是也能完美匹配。所以这些能力应该是技术人员发展到一定程度面临的普遍问题而不仅仅是前端。 但这些能力是否有个更好的概括?当然有,就是明确一个方向并带领一群人完成目标并实线商业价值。这其实就是商业或者说业务的整个运作过程。 这其实也在抛一个命题,前端发展到一定程度就一定要转业务吗?是也不是。当然要转,但并不是全转。全转业务你过去的积累有什么用?不转业务单纯前端能发挥的影响力就会受限。所以答案是利用前端技术优势同时补充业务能力推动商业流程。 所以此文并不是严格上讲前端技术的深水区,或者作者肯定认为他能接触的前端技术已经到瓶颈,且没有想到突破口。 怎么去定义深水区,@流形 认为是需要建立技术壁垒或学术壁垒。当我们看待一向技术,如果在投入一到两年就可以对齐,那么显然技术本身的深度是可观的,如果是十年才能对齐,这时候除了会影响经济或政治外,不会有人会去重做,只能使用。用另一个类似的概念反摩尔定律来对应深水区说,每隔两年,技术不能显著带来效能的成倍提升。 深水区值得关注的方向业务领导力也就是原文提到的 “技术创新、流程优化、团队合作、影响大盘、驱动业务、商业决策、团队管理” 等能力,一个拥有领导力的人发挥的价值远超自身孤立的价值。 业务价值发挥业务价值是技术人的最终目标,比如数据库技术想发挥业务价值,就要做到高效、稳定,价值越大往往技术难度就越大。 值得庆幸的是,前端的业务价值与技术难度往往不成正比,有时候将客户的业务场景固化成一套模版,整合起来赋能给更多客户,这等于将商业模型作为能力赋予了其他客户,但本身并没有用到一些高级技术。前端能做的不仅是内部提效和外部体验,因为前端是人机交互的入口,才有机会将业务思考打包到代码中,直接透出给客户。 端技术的发展 数字孪生。那么在端上的仿真能力需要大幅提高,那么结合模型自动生成,不同物体的建模能力等都是很大挑战 虚拟实现。这点上就不赘述,从 FB 重点发展 Oculus,微软发展 HoloLens 可以看到这个趋势,从互动的未来来看,这不是终局,但是最适合今天要突破的技术。 可视分析。数据在人类面前还是过分难懂,结合数据的分析系统在各行各业正在渗透,端上结合可视化的能力就显得非常重要。 更多的,像边缘计算,前端安全等领域都是非常深入的领域。这些问题,已经不是一年就能完全突破的,需要 3-5 年,甚至 10 年时间。 前端深入体系 但对于我所处的大数据环境来说,确实接触了前端技术深水区。来源于端计算能力 + 网络基建 + 大数据的爆炸式增长。编辑器:复杂的开发离不开代码,前端们一直孜孜不倦的把 IDE 引入 web,VS Code 做了很成功的尝试但还是需要一层壳套着。且对于大数据处理这样的领域,需要定制的能力远超过通用的 Manaco editor 等能提供。 表格类数据处理能力:比尔盖茨最引以为豪的微软软件是 Excel。你永远不知道 Excel 有多少种酷的用法来解决用户问题。能否把 Excel 引入到 web?同时对数百万条数据做交叉分析,这对性能和架构都有很大的挑战。 可视化数据展现:大数据的一个典型特征就是价值稀疏性,如何把蕴含的价值展现出来,需要了解图形学、统计学、交互色彩等各种能力。大学老师教的内容终于能派生用场了。 总结在局部领域前端已经有可能深入,当然前端技能上说这些也不能用 HTML, CSS, JS 来解决,需要开发者有深入学科的背景。但今天前端面向还是产品功能的需要,在端上更强调的还是产品功能为主。我们做一款复杂产品,更多还会在工程上纠结。如果没在功能的深入性上思考更多,以对应真正技术发展,那么深水区还远。 正如前面所说,深水区会压强升高、光线减少、温度剧变,需要自己发光发热和更多的坚持。 跨过深水区,让其他人处在浅水区就能做事,这或许就是你走出深水区的标志。就像 Alan Perlis 说的一句话『简单不先于复杂,而是在复杂之后』,也许未来看来你今天挣扎的深水区只是个小泥坑。 讨论地址是:精读《前端深水区》 · Issue ##193 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《在浏览器运行 serverRender》","path":"/wiki/WebWeekly/前沿技术/《在浏览器运行 serverRender》.html","content":"当前期刊数: 54 本周精读内容是 《在浏览器运行 serverRender》。 这里是效果页,先睹为快:client-ssr。 1 引言在服务端 ssr 成为常识的今天,前端 ssr 是我最近的新尝试,效果可以点击上面链接查看。说说前端 ssr 有哪些好处: 不消耗服务器资源。对,不消耗服务器资源,完美的分布式运行!对于百万 UV 甚至更高的页面,服务器成本减少了几十万或者上百万。 前后端分离,首先 ssr 不需要部署服务器,其次前端代码也不需要担心质量问题导致的内存泄露了,同时可以不必时刻注意使用同构的三方库,只需要考虑前端可运行! 不需要后端缓存服务,对于千人千面的复杂页面,对后端 ssr 来说缓存规模庞大的无法计算。 相比后端 ssr,在前端可以绕过复杂的权限系统,同时 http 请求的权限问题也无需关心。 因为第一点,对于不支持后端服务的 github pages 也能做到 ssr。 相对的,缺点是: 需要客户端支持 serviceWorker。 第二次首屏才会生效。后端 ssr 可以做到访问前预缓存 ssr 结果。 可能破坏前端页面状态,因为在同一个环境偷偷执行了一些页面逻辑。不过这个缺点可以通过 web worker 执行 ssr 解决,还在调研中。 service worker 拦截入口 html 风险很高,一旦代码有故障可能导致严重后果,需要提前考虑完备的回滚方案。 像缓存清空时机等问题,前后端 ssr 都会遇到,所以不列在优缺点中。 2 精读本篇精读分享的是前端 ssr 方案具体实现步骤。 我们先了解整体流程: service worker 拦截首页service worker 可以在浏览器尝试请求首屏 html 之前的时机拦截,此时如果 caches 命中,直接将 response 扔给浏览器,那么服务端将完全不会收到请求,完成了最高效的缓存命中。 当然第一次没有缓存,所以在没有命中缓存时,会同步的做两件事: 发送请求,拿到后端返回的 response,扔给浏览器。这是最普通的请求逻辑。 当前端代码 ready 后,postMessage 给浏览器,索要 ssr 内容。 附上代码片段: self.addEventListener("fetch", event => { if ( event.request.mode === "navigate" && event.request.method === "GET" && event.request.headers.get("accept").includes("text/html") ) { event.respondWith( caches.open(SSR_BUNDLE_VERSION).then(cache => { return cache.match(event.request).then(response => { // 命中缓存,直接返回结果。 if (response) { return response; } return fetch(event.request).then(response => { const newResponse = response.clone(); return newResponse .text() .then(text => { // 通知浏览器,执行 ssr 并且返回内容。 self.clients.matchAll().then(clients => { if (!clients || !clients.length) { return; } clients.forEach(client => { client.postMessage({ type: "getServerRenderContent", pathname: new URL(event.request.url, location).pathname }); }); }); return response; }) .catch(err => response); }); }); }) ); }}); 当然还需要一个监听,用来拿浏览器的 ssr 内容,并缓存到 caches 中,比较简单就省略了。 浏览器执行 ssr监听就不说了,主要是如何利用 react-router 与 react-loadable 完成前端 ssr。 首先根据 service worker 告诉我们的 pathname,拿到对应 loadable 的实例,并通过 loadable.preload() 预先加载 chunk,当 chunk 加载完毕时,资源已经准备好了。 我们利用给 StaticRouter 传递当前的 pathname,让 react-router 模拟出需要 ssr 的页面内容,通过 renderToString 拿到 ssr 的结果。 附上代码片段: if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener("message", event => { if (event.data.type === "getServerRenderContent") { const baseHrefRegex = new RegExp( escapeRegExp("${projectConfig.baseHref}"), "g" ); const matchRouterPath = event.data.pathname.replace(baseHrefRegex, ""); const loadableMap = pageLoadableMap.get( matchRouterPath === "/" ? "/" : trimEnd(matchRouterPath, "/") ); if (loadableMap) { loadableMap.preload().then(() => { const ssrResult = renderToString( <StaticRouter location={event.data.pathname} context={{}}> <App /> </StaticRouter> ); if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage({ type: "serverRenderContent", pathname: event.data.pathname, content: ssrResult }); } }); } } });} 这里需要优化,利用 web worker 执行 ssr 才可以用于生产环境。 最后,等待用户的下一次刷新,service worker 会帮我们把 ssr 内容作为首屏给用户一个惊喜的。 3 总结同样这次只是抛砖引玉,希望大家能提出建议一起帮助我们完善这个方案。 此方案正式用在生产环境后,会再写一篇文章介绍实践过程。 4 更多讨论 讨论地址是:精读《在浏览器运行 serverRender》 · Issue ##80 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《如何为 TS 类型写单测》","path":"/wiki/WebWeekly/前沿技术/《如何为 TS 类型写单测》.html","content":"当前期刊数: 260 如何为 TS 类型写单测呢? 最简单的办法就是试探性访问属性,如果该属性访问不到自然会在异常时出现错误,如: import { myLib } from "code";myLib.update; // 正确 如上所示,如果 myLib 没有正确的开放 update 属性将会提示错误。但这种单测并不是我们要讲的类型。想一想,如果我们只开放 .update API 给用户,但框架内部可以使用全量的 .update、.add、.remove 方法,如何验证框架没有把不必要的属性也开放给了用户呢? 一种做法是直接访问类型提示,此时会出现错误下划线: myLib.add ~~~ // Property 'add' does not exist on type MyLib 此时说明代码逻辑正常,但却抛出了 ts 错误,这可能会阻塞 CI 流程,而且我们也无从判断这个报错是否 “实际山是逻辑正确的表现”,所以 “不能出现某个属性” 就不能以直接访问属性的方式实现了,我们要做一些曲线方案。 利用特殊类型方法我们可以利用 extends 构造三元类型表达式,逻辑是如果 myLib 拥有 .add 属性就返回 a 类型,否则返回 b 类型。因为 myLib 不该提供 .add 属性,所以下一步判断该新类型一定符合 b 即可: const check: typeof myLib extends { add: any } ? number : number[] = [];check.length; // 该行在没有 .add 属性时不会报错,反之则报错 因为我们给的默认值是字符串,而预期正确的结果也是进入 number[] 类型分支,所以 check.length 正常,如果某次改动误将 .add 提供了出来,check.length 就会报错,因为我们给值 [] 定义了 number 类型,访问 .length 属性肯定会出错。 利用赋值语句判断另一种简化的办法是利用 true or false 判断变量类型是否匹配,如: const check: typeof fn extends (a: any) => any ? true : false = true; 如果 fn 满足 (a: any) => any 类型,则 check 的类型限定为 true,否则为 false,所以当 fn 满足条件时该表达式正确,当 fn 不满足条件式,我们将变量 true 赋值给类型 false 的对象,会出现报错。 可以将 ts 转换为 js 吗?也许你会有疑问,可以将 ts 类型校验错误转换为 js 对象吗?这样就可以用 expect 等断言结合到测试框架流程中了。很可惜,至少现在是不行的,只能做到利用 js 变量推导类型,不能利用类型生成变量。 总结总结一下,如果想判断某些类型定义未暴露给用户,而实际上在 js 变量里是拥有这些属性的,就只能用类型方案判断正确性了。 比如变量 myLib 实际上拥有 .update 与 .add 方法,但提供给用户的类型定义刻意将 .add 隐藏了,此时校验方式是,利用一个跳板变量 check,使用 extends 判断其是否包含 add 属性,再利用特殊类型方法或者直接用赋值语句判断 extends 是否成立。 讨论地址是:精读《如何为 TS 类型写单测》· Issue ##446 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《如何利用 Nodejs 监听文件夹》","path":"/wiki/WebWeekly/前沿技术/《如何利用 Nodejs 监听文件夹》.html","content":"当前期刊数: 59 1 引言本期精读的文章是:How to Watch for Files Changes in Node.js,探讨如何监听文件的变化。 如果想使用现成的库,推荐 chokidar 或 node-watch,如果想了解实现原理,请往下阅读。 2 概述使用 fs.watchfile使用 fs 内置函数 watchfile 似乎可以解决问题: fs.watchFile(dir, (curr, prev) => {}); 但你可能会发现这个回调执行有一定延迟,因为 watchfile 是通过轮询检测文件变化的,它并不能实时作出反馈,而且只能监听一个文件,存在效率问题。 使用 fs.watch使用 fs 的另一个内置函数 watch 是更好的选择: fs.watch(dir, (event, filename) => {}); watch 通过操作系统提供的文件更改通知机制,在 Linux 操作系统使用 inotify,在 macOS 系统使用 FSEvents,在 windows 系统使用 ReadDirectoryChangesW,而且可以用来监听目录的变化,在监听文件夹的场景中,比创建 N 个 fs.watchfile 效率高出很多。 $ node file-watcher.js[2018-05-21T00:55:52.588Z] Watching for file changes on ./button-presses.log[2018-05-21T00:56:00.773Z] button-presses.log file Changed[2018-05-21T00:56:00.793Z] button-presses.log file Changed[2018-05-21T00:56:00.802Z] button-presses.log file Changed[2018-05-21T00:56:00.813Z] button-presses.log file Changed 但当我们修改一个文件时,回调却执行了 4 次!原因是文件被写入时,可能触发多次写操作,即使只保存了一次。但我们不需要这么敏感的回调,因为通常认为一次保存就是一次修改,系统底层写了几次文件我们并不关心。 因而可以进一步判断是否触发状态是 change: fs.watch(dir, (event, filename) => { if (filename && event === "change") { console.log(`${filename} file Changed`); }}); 这样做可以一定程度解决问题,但作者发现 Raspbian 系统不支持 rename 事件,如果归类为 change,会导致这样的判断毫无意义。 作者要表达的意思是,在不同平台下,fs.watch 的规则可能会不同,原因是 fs.watch 分别使用了各平台提供的 api,所以无法保证这些 api 实现规则的统一性。 优化方案一:对比文件修改时间基于 fs.watch,增加了对修改时间的判断: let previousMTime = new Date(0);fs.watch(dir, (event, filename) => { if (filename) { const stats = fs.statSync(filename); if (stats.mtime.valueOf() === previousMTime.valueOf()) { return; } previousMTime = stats.mtime; console.log(`${filename} file Changed`); }}); log 由 4 个变成了 3 个,但依然存在问题。我们认为文件内容变化才算有修改,但操作系统考虑的因素更多,所以我们再尝试对比文件内容是否变化。 笔者补充:另外一些开源编辑器可能先清空文件再写入,也会影响到触发回调的次数。 优化方案二:校验文件 md5只有文件内容变化了,才认为触发了改动,这下总可以了吧: let md5Previous = null;fs.watch(dir, (event, filename) => { if (filename) { const md5Current = md5(fs.readFileSync(buttonPressesLogFile)); if (md5Current === md5Previous) { return; } md5Previous = md5Current; console.log(`${filename} file Changed`); }}); log 终于由 3 个变成了 2 个,为什么多出一个?可能的原因是,在文件保存过程中,系统可能会触发多个回调事件,也许存在中间态。 优化方案三:加入延迟机制我们尝试延迟 100 毫秒进行判断,也许能避开中间状态: let fsWait = false;fs.watch(dir, (event, filename) => { if (filename) { if (fsWait) return; fsWait = setTimeout(() => { fsWait = false; }, 100); console.log(`${filename} file Changed`); }}); 这下 log 变成一个了。很多 npm 包在这里使用了 debounce 函数控制触发频率,才将触发频率修正。 而且我们需要结合 md5 与延迟机制共同作用,才能得到相对精准的结果: let md5Previous = null;let fsWait = false;fs.watch(dir, (event, filename) => { if (filename) { if (fsWait) return; fsWait = setTimeout(() => { fsWait = false; }, 100); const md5Current = md5(fs.readFileSync(dir)); if (md5Current === md5Previous) { return; } md5Previous = md5Current; console.log(`${filename} file Changed`); }}); 3 精读作者讨论了一些实现文件夹监听的基本方式,可以看出,使用了各平台原生 API 的 fs.watch 并不那么靠谱,但这也我们监听文件的唯一手段,所以需要基于它进行一系列优化。 而实际场景中,还需要考虑区分文件夹与文件、软连接、读写权限等情况。 另外用在生产环境的库,也基本使用 50 到 100 毫秒解决重复触发的问题。 所以无论 chokidar 或 node-watch,都大量使用了文中提及的技巧,再加上对边界条件的处理,对软连接、权限等情况处理,将所有可能情况都考虑到,才能提供较为准确的回调。 比如判断文件写入操作是否完毕,也需要通过轮询的方式: function awaitWriteFinish() { // ...省略 fs.stat( fullPath, function(err, curStat) { // ...省略 if (prevStat && curStat.size != prevStat.size) { this._pendingWrites[path].lastChange = now; } if (now - this._pendingWrites[path].lastChange >= threshold) { delete this._pendingWrites[path]; awfEmit(null, curStat); } else { timeoutHandler = setTimeout( awaitWriteFinish.bind(this, curStat), this.options.awaitWriteFinish.pollInterval ); } }.bind(this) ); // ...省略} 可以看出,第三方 npm 库都采取不信任操作系统回调的方式,根据文件信息完全重写了判断逻辑。 可见,信任操作系统的回调,就无法抹平所有操作系统间的差异,唯有统一重写文件的 “写入”、“删除”、“修改” 等逻辑,才能保证在全平台的兼容性。 4 总结利用 nodejs 监听文件夹变化很容易,但提供准确的回调却很难,主要难在两点: 抹平操作系统间的差异,这需要在结合 fs.watch 的同时,增加一些额外校验机制与延时机制。 分清楚操作系统预期与用户预期,比如编辑器的额外操作、操作系统的多次读写都应该被忽略,用户的预期不会那么频繁,会忽略极小时间段内的连续触发。 另外还有兼容性、权限、软连接等其他因素要考虑,fs.watch 并不是一个开箱可用的工程级别 api。 5 更多讨论 讨论地址是:精读《如何利用 Nodejs 监听文件夹》 · Issue ##87 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《如何做好 CodeReview》","path":"/wiki/WebWeekly/前沿技术/《如何做好 CodeReview》.html","content":"当前期刊数: 142 1 引言任何软件都是协同开发的,所以 CodeReview 非常重要,它可以帮助你减少代码质量问题,提高开发效率,提升稳定性,同时还能保证软件架构的稳定性,防止代码结构被恶意破坏导致难以维护。 所以 CodeReview 机制是否健全是一个工程团队能否长期健康发展的决定因素之一,这次我们读一篇关于 CodeReview 如何做得更好的文章: 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 尽可能反馈逻辑问题而不是格式问题。 针对新人的 CodeReviewGood CodeReview 对任何人都是用相同评判标准,可以遵循上面几点注意事项。 Better CodeReview 会对新人区分对待,对新人给予对多的耐心、解释和评论,甚至给出解决方法,并更积极的给出鼓励。 任何人到一家新公司都有适应过程,一视同仁是 base 要求,但如果能给予新人更多关怀就更好啦。 跨办公区、时区的 CodeReviewGood CodeReview 仅在工作时间有重叠的时间范围内进行 CodeReview,这样能保证对方可以积极响应,在必要时进行语音、视频沟通。 Better CodeReview 会注意到更本质的问题,留意跨团队协作的必要性,如果某个模块经常被另一个时区同时修改,也许可以将这个模块交给对方维护,或者将 CodeReview 交给对方团队内部进行会更加高效。 笔者所在公司也有跨时区协作情况,但绝大部分场景会避免跨时区的 CodeReview,因为 CodeReview 一般会在同一时区团队内部进行,这样效率更高,应对跨时区协作时,往往也是电话、视频会议优先。 公司支持Good CodeReview 会得到公司组织支持,公司能意识到这么做虽然看起来占用开发时间,但长远来看提升了开发效率,因此能任何 CodeReview 价值。 Better CodeReview 会得到公司进一步支持,公司甚至不断研发并完善 CodeReview 系统与流程,通过系统化方案保证上面几项 CodeReview 注意事项是否有在团队内落实,可以全员参与。 CodeReview 也是一种团队文化和公司文化,公司文化带来的是规章制度与系统工具,团队文化带来的是良好 CodeReview 氛围与更高 CodeReview 的效率。 3 总结总结一下,良好的 CodeReview 需要做到以下几点: 更全面,从正确性到系统影响评估。 注意语气,从给出建设性一觉到换位思考。 及时完成审阅,从充分讨论到随机应变。 加强交流,从面对面交流到灵活选择最高效的沟通方式。 区分重点,从添加标记到利用工程化工具自动解决。 对新人要更友好。 尽量避免跨时区协作,必要时选择视频会议。 最后,希望 CodeReview 能够得到公司的支持,如果你们公司还没有认可 CodeReview 的价值,可以将这篇文章分享给你的领导。 讨论地址是:精读《如何做好 CodeReview》 · Issue ##237 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《如何在 nodejs 使用环境变量》","path":"/wiki/WebWeekly/前沿技术/《如何在 nodejs 使用环境变量》.html","content":"当前期刊数: 60 1 引言本期精读的文章是:如何在 nodejs 使用环境变量。 介绍了开发与生产环境如何管理环境变量。 这里环境变量指的是数据库密码等重要数据,而不是指普通变量传参。 2 概述环境变量历史悠久,在运行第一行 JAVA 代码之前,你就得将环境变量设置好。 可问题是,系统变量并不易用,比如结尾是否要使用分号,JAVA_HOME 与 PATH 在哪些程序中功能相同?而且与操作系统绑定,在操作系统级别设置的变量,给 JAVA 级别的程序用还好,但用来存数据库密码就不合适了。 在 Node 中,我们怎样使用环境变量呢?作者给出了如下的建议: 通过命令行传递PORT=65534 node bin/www 这是最基本、最常用的方式,可是当变量数量过多,不免觉得很崩溃: PORT=65534 DB_CONN="mongodb://react-cosmos-db:swQOhAsVjfHx3Q9VXh29T9U8xQNVGQ78lEQaL6yMNq3rOSA1WhUXHTOcmDf38Q8rg14NHtQLcUuMA==@react-cosmos-db.documents.azure.com:19373/?ssl=true&replicaSet=globaldb" SECRET_KEY=b6264fca-8adf-457f-a94f-5a4b0d1ca2b9 node bin/www 作者提到,这种代码没有拓展性。作者认为,对工程师来说,可拓展性甚至比能正确运行更为重要。 使用 .env 文件很显然,命令行写不下了就写到文件里: PORT=65534DB_CONN="mongodb://react-cosmos-db:swQOhAsVjfHx3Q9VXh29T9U8xQNVGQ78lEQaL6yMNq3rOSA1WhUXHTOcmDf38Q8rg14NHtQLcUuMA==@react-cosmos-db.documents.azure.com:10255/?ssl=true&replicaSet=globaldb"SECRET_KEY="b6264fca-8adf-457f-a94f-5a4b0d1ca2b9" 通过 dotenv 这个 npm 包可以读取 .env 文件的配置到 Nodejs 程序中。 npm install dotenv --save 安装后,直接调用它解析,就可以从环境变量中拿到 .env 文件的配置信息了: require("dotenv").config();var MongoClient = require("mongodb").MongoClient;// Reference .env vars off of the process.env objectMongoClient.connect( process.env.DB_CONN, function(err, db) { if (!err) { console.log("We are connected"); } }); 这有个问题,不要将配置文件发送到 Git 仓库,可能会泄漏隐私数据。然而 VSCode 帮你解决了这个问题(什么,你不用 VSCode?) VSCode 启动配置 VSCode 可以配置 Node 启动配置,在这里可以设置环境变量: 为了和 .env 文件打通,我们可以在配置里设置 envFile 属性: { "envFile": "${workspaceFolder}/.env"} 程序中依然使用 dotenv 读取环境变量。这么做将配置保留在 VSCode 中,而不是代码中,不用再担心不小心上传了配置文件啦! 使用 Npm Scripts作者推荐了一个良好的习惯:使用 npm start 运行项目,而不是暴露出 Node 命令。那么首先在 VSCode launch.json 中配置 Npm 模式: 记住,需要给 Node 脚本添加 --inspect 参数,才能触发 VSCode debugger 的钩子: 这样一来,通过 npm start 就可以启动 Node,并读取配置在 VSCode 的环境变量。 生产环境的环境变量上面介绍了本地开发如何使用环境变量,但在生产环境,环境变量必须得换个方式管理。 不知道作者与微软是什么关系,这块推荐了微软的 Azure 管理环境变量。 主要思路是通过一个不赚差价的中间商提供环境变量管理服务。通过 Azure CLI 启动你的 Node 项目,就可以从云服务平台拿到环境变量信息。 3 精读环境变量管理是非常重要的问题,以前还看到将公司数据库密码提交到 Github 的例子,反面教材非常多。 本文介绍了许多本地开发使用环境变量的方式,笔者补充一下生产环境使用环境变量的经验。 私有部署如果你在一个高自动化运维水平的公司,这个问题已经被私有 Git + 私有云服务器天然解决了。 是的,部署私有 Git,把数据库密码提交到 Git 仓库才是最完美的方案! 持久化配置服务通过自建,或者开源的 Azure 持久化配置服务存储环境变量,在服务器利用 SDK 获取它。 一般云服务商都会打包这项服务,因为只有服务器和持久化配置服务都由一个供应商提供,供应商才能将持久化配置与服务器权限形成关联,让第三方服务器即便拿到 Token 也无法访问配置。 加密服务如果安全级别特别高,内部 Git 都不允许提交,又要防止第三方(比如某宽带运营商)拦截到信息,就要使用加密服务了。 流程一般是: 在加密平台注册,拿到密钥。 在加密平台设置环境变量,加密平台会对内容进行加密。 利用 Node SDK 获取到加密平台输出的密文。 利用 SDK 和密钥解密成明文。 4 总结对待在基础设施完备公司的同学,可能不需要关心环境变量安全性问题。对于自己搭建博客,或者使用第三方服务器的同学,这篇文章告诉我们三个注意点: 不要将重要环境变量提交到公开的 Git 仓库。 本地通过 VSCode 调试环境变量既方便又安全。 生产环境通过云服务商提供的环境变量配置服务拿到环境变量。 5 更多讨论 讨论地址是:精读《如何在 nodejs 使用环境变量》 · Issue ##89 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《如何安全地使用 React context》","path":"/wiki/WebWeekly/前沿技术/《如何安全地使用 React context》.html","content":"当前期刊数: 17 精读《如何安全地使用 React context》本期精读文章是:How to safely use React context 1 引言在 React 源码中,context 始终存在,却在 React 0.14 的官方文档中才有所体现。在目前最新的官方文档中,仍不建议使用 context,也表明 context 是一个实验性的 API,在未来 React 版本中可能被更改。那么哪些场景下需要用到 context,而哪些情况下应该避免使用,context 又有什么坑呢?让我们一起来讨论一下。 2 内容概要React context 可以把数据直接传递给组件树的底层组件,而无需中间组件的参与。Redux 作者 Dan Abramov 为 contenxt 的使用总结了一些注意事项: 如果你是一个库的作者,需要将信息传递给深层次组件时,context 在一些情况下可能无法更新成功。 如果是界面主题、本地化信息,context 被应用于不易改变的全局变量,可以提供一个高阶组件,以便在 API 更新时只需修改一处。 如果库需要你使用 context,请它提供高阶组件给你。 正如 Dan 第一条所述,在 React issue 中,经常能找到 React.PureComponent、shouldComponentUpdate 与包含 Context 的库结合后引发的一些问题。原因在于 shouldComponentUpdate 会切断子树的 rerender,当 state 或 props 没有发生变化时,可能意外中断上层 context 传播。也就是当 shouldComponentUpdate 返回 false 时,context 的变化是无法被底层所感知的。 因此,我们认为 context 应该是不变的,在构造时只接受 context 一次,使用 context,应类似于依赖注入系统来进行。结合精读文章的示例总结一下思路,不变的 context 中包含可变的元素,元素的变化触发自身的监听器实现底层组件的更新,从而绕过 shouldComponentUpdate。 最后作者提出了 Mobx 下的两种解决方案。context 中的可变元素可用 observable 来实现,从而避免上述事件监听器编写,因为 observable 会帮你完成元素改变后的响应。当然 Provider + inject 也可以完成,具体可参考精读文章中的代码。 3 精读本次提出独到观点的同学有:@monkingxue @alcat2008 @ascoders,精读由此归纳。 context 的使用场景 In some cases, you want to pass data through the component tree without having to pass the props down manually at every level. context 的本质在于为组件树提供一种跨层级通信的能力,原本在 React 只能通过 props 逐层传递数据,而 context 打破了这一层束缚。 context 虽然不被建议使用,但在一些流行库中却非常常见,例如:react-redux、react-router。究其原因,我认为是单一顶层与多样底层间不是单纯父子关系的结果。例如:react-redux 中的 Provider,react-router 中的 Router,均在顶层控制 store 信息与路由信息。而对于 Connect 与 Route 而言,它们在 view 中的层级是多样化的,通过 context 获取顶层 Provider 与 Router 中的相关信息再合适不过。 context 的坑 context 相当于一个全局变量,难以追溯数据源,很难找到是在哪个地方中对 context 进行了更新。 组件中依赖 context,会使组件耦合度提高,既不利于组件复用,也不利于组件测试。 当 props 改变或是 setState 被调用,getChildContext 也会被调用,生成新的 context,但 shouldComponentUpdate 返回的 false 会 block 住 context,导致没有更新,这也是精读文章的重点内容。 4 总结正如精读文章开头所说,context 是一个非常强大的,具有很多免责声明的特性,就像伊甸园中的禁果。的确,引入全局变量似乎是应用混乱的开始,而 context 与 props/state 相比也实属异类。在业务代码中,我们应抵制使用 context,而在框架和库中可结合场景适当使用,相信 context 也并非洪水猛兽。 讨论地址是:精读《How to safely use React context》· Issue ##23 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布"},{"title":"《如何比较 Object 对象》","path":"/wiki/WebWeekly/前沿技术/《如何比较 Object 对象》.html","content":"当前期刊数: 157 1 引言Object 类型的比较是非常重要的基础知识,通过 How to Compare Objects in JavaScript 这篇文章,我们可以学到四种对比方法:引用对比、手动对比、浅对比、深对比。 2 简介引用对比下面三种对比方式用于 Object,皆在引用相同是才返回 true: === == Object.is() const hero1 = { name: "Batman",};const hero2 = { name: "Batman",};hero1 === hero1; // => truehero1 === hero2; // => falsehero1 == hero1; // => truehero1 == hero2; // => falseObject.is(hero1, hero1); // => trueObject.is(hero1, hero2); // => false 手动对比写一个自定义函数,按照对象内容做自定义对比也是一种方案: function isHeroEqual(object1, object2) { return object1.name === object2.name;}const hero1 = { name: "Batman",};const hero2 = { name: "Batman",};const hero3 = { name: "Joker",};isHeroEqual(hero1, hero2); // => trueisHeroEqual(hero1, hero3); // => false 如果要对比的对象 key 不多,或者在特殊业务场景需要时,这种手动对比方法其实还是蛮实用的。 但这种方案不够自动化,所以才有了浅对比。 浅对比浅对比函数写法有很多,不过其效果都是标准的,下面给出了一种写法: 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 下有特殊的意义。 下面给出了使用例子: const hero1 = { name: "Batman", realName: "Bruce Wayne",};const hero2 = { name: "Batman", realName: "Bruce Wayne",};const hero3 = { name: "Joker",};shallowEqual(hero1, hero2); // => trueshallowEqual(hero1, hero3); // => false 如果对象层级再多一层,浅对比就无效了,此时需要使用深对比。 深对比深对比就是递归对比对象所有简单对象值,遇到复杂对象就逐个 key 进行对比,以此类推。 下面是一种实现方式: 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] 也可作用于数组,因此数组和对象都可以采用相同方式处理。 有了深对比,再也不用担心复杂对象的比较了: const hero1 = { name: "Batman", address: { city: "Gotham", },};const hero2 = { name: "Batman", address: { city: "Gotham", },};deepEqual(hero1, hero2); // => true 但深对比会造成性能损耗,不要小看递归的作用,在对象树复杂时,深对比甚至会导致严重的性能问题。 3 精读常见的引用对比引用对比是最常用的,一般在做 props 比较时,只允许使用引用对比: this.props.style !== nextProps.style; 如果看到有深对比的地方,一般就要有所警觉,这里是真的需要深对比吗?是不是其他地方写法有问题导致的。 比如在某处看到这样的代码: deepEqual(this.props.style, nextProps.style); 可能是父组件一处随意拼写导致的: const Parent = () => { return <Child style={{ color: "red" }} />;}; 一个只解决局部问题的同学可能会采用 deepEqual,OK 这样也能解决问题,但一个有全局感的同学会这样解决问题: this.props.style === nextProps.style; const Parent = () => { const style = useMemo(() => ({ color: "red" }), []); return <Child style={style} />;}; 从性能上来看,Parent 定义的 style 只会执行一次且下次渲染几乎没有对比损耗(依赖为空数组),子组件引用对比性能最佳,这样的组合一定优于 deepEqual 的例子。 常见的浅对比浅对比也在判断组件是否重渲染时很常用: shouldComponentUpdate(nextProps) { return !shallowEqual(this.props, nextProps)} 原因是 this.props 这个对象引用的变化在逻辑上是无需关心的,因为应用只会使用到 this.props[key] 这一层级,再考虑到 React 组件生态下,Immutable 的上下文保证了任何对象子属性变化一定导致对象整体引用变化,可以放心的进行浅对比。 最少见的就是手动对比和深对比,如果你看到一段代码中使用了深对比,大概率这段代码可以被优化为浅对比。 4 总结虽然今天总结了 4 种比较 Object 对象的方式,但在实际项目中,应该尽可能使用引用对比,其次是浅对比和手动对比,最坏的情况是使用深对比。 讨论地址是:精读《如何比较 Object 对象》· Issue ##258 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《如何编译前端项目与组件》","path":"/wiki/WebWeekly/前沿技术/《如何编译前端项目与组件》.html","content":"当前期刊数: 89 1 引言说到前端编译方案,也就是如何打包项目,如何编译组件,可选方案有很多,比如: 通过 webpack / parcel / gulp 构建项目。 通过 parcel / gulp / babel 构建组件。 如果你喜欢零配置的 parcel,那么项目和组件都可以拿它来编译。 如果你业务比较复杂,需要使用 webpack 做深度定制,那么常见组合是:项目 - webpack,组件 - gulp。 但项目与组件的编译存在异同点,不同构建工具支持的生态也存在异同点。 webpack parcel gulp 生态的区别 babel 一般不会解析模块,也就是一般仅做代码预处理,而不会改变文件结构,也对 require、import 语句不敏感。 webpack / parcel 主要就是解决模块化打包问题,因为浏览器还不支持(现在部分支持 type="module")。 gulp 理论上可以将 babel、webpack、parcel 作为插件,但这是后来的事。历史上由于 gulp 是作为 grunt 的替代品出现,当时要解决的问题是处理浏览器兼容问题,打包 scss 或 less,做一些公共资源替换,雪碧图等,最后可以顺带合并到一个文件,但模块化功能远远比 webpack 弱,基本上只能合并,但不能 “理解模块概念”。 项目构建与组件构建的区别项目构建的目的主要在于发布 CDN,所以大家一般不在乎构建脚本的通用性。换句话说,无论项目使用了怎样的构建方式,怎样理解 import 语句,甚至写出 require.context 等自定义语法,只要最终编译出符合浏览器规范的代码(考虑到兼容性)就足够。 组件构建的目的主要在于发布 NPM,除了 ESNext 规范会使用 Babel 编译成 ES3,大部分代码写的很收敛,甚至对 SASS 的使用都要与 Typescript 插件一起组合成复杂的 Gulp Task。 所以往往大家会对项目采取复杂的构建约束策略,而对组件的编译采取相对简单的办法,确保发布代码的通用性。 所以在大部分项目使用 webpack 支持 worker-loader 时,编写组件时发现这段代码不灵了。或者至少你得付出一些代价,因为组件的调试依然可以利用 webpack-dev-server,这时可以加上 worker-loader,但由于 gulp 没有靠谱的 worker 插件,你的组件可能需要将 Worker 引用部分原样输出,希望由引用它的项目做掉对 worker-loader 的支持。 其实这种心态是很危险的,不仅导致了组件不通用,甚至引发了各构建工具的 Tree Shaking 优化。原因就是构建组件的代码太原始,冗余的代码没有删除,甚至直接引用的 SASS 代码仍然保留,更危险的是带上了一些特殊 webpack loader 才支持的语法。 之所以说 Antd 是一个拥有优秀基因的前端组件库,是因为他遵循了前端组件最基本的代码素养: 编译后的代码全部符合基本 JS 规范,换个角度来说,使用 webpack 内置基本 js loader 就能完全解析。 将 css 代码抽离出来,这样不会强制项目对 node_modules 的代码应用 css-loader。 所以一个 靠谱的组件库 的产出文件,应该符合基本 ES 模块化规范,且不包括任何特殊语法。 但是这引发了一个新的问题:组件开发体验比项目差很多。 比如组件想使用雪碧图自动优化、想使用 worker-loader 方便快捷的调用多线程,想用自己的 css modules,甚至想把项目里一堆 PostCSS 快捷语法搬过来时怎么办?难道组件开发就不能获得与项目开发一样的体验吗? 要解决这个问题,笔者介绍一种基于 webpack 的通用构建方案,让本地调试、CDN 打包、ES6 -> ES3 转换 都使用统一套配置代码,同一套 loader。 2 精读核心思想只有一句话:利用 webpack-node-externals 忽略 Webpack 对指向 node_modules 的 require 或 import 语句: 进行项目/组件调试时,开启 development 模式。 进行项目编译时,开启 production 模式。 进行组件编译时,开启 production 模式,且利用 webpack-node-externals 插件忽略 node_modules。 可以想像,根据第三条,如果所有组件都按照这个模式输出代码,那么 webpack 对 node_modules 编译时,只需要将所有 require 代码进行合并,不需要执行任何 loader,也不需要压缩,不需要 TreeShaking,因为这些在组件代码编译时全部已经做好了,这种构建效率几乎达到最大。 实际案例我们拿支持 typescript、sass、css-modules、worker-loader 的场景作为案例。 我们创建三个文件 entry.tsx entry.worker.ts 与 entry.scss: entry.scss: .container { border: 1px solid ##ccc;}.primary { color: blue; &:hover { color: green; }} entry.worker.ts: import hello from "hello";const ctx: Worker = self as any;ctx.onmessage = event => { ctx.postMessage(hello());};export default null as any; entry.tsx: import * as React from "react";import styles from "./entry.scss";import * as MyWorker from "./parser.worker";const worker = new MyWorker();export default () => ( <div className={styles.container}> <button className={styles.primary}>Click Me.</button> </div>); 在上面三个文件中,我们分别利用了 Typescript 编译、SCSS 编译、css-modules 解析、worker-loader 解析(利用 webpack 自动生成字符串代码并利用 Blob URL 方式载入,这样就不需要创建新文件也可以用 worker 了,也不会存在跨域问题)。 为了支持这几个特性对如上代码做调试、项目发布、组件发布,我们分别看下这三个场景该如何配置编译脚本。 本地调试本地调试是不用区分组件与项目的。因为无论何种情况,都需要进行基本的项目编译,载入所有自定义 loader 并打成一个 bundle 包。 此时我们只要维护一份 webpack 配置即可: const webpackConfig = { mode: "development", module: { rules: [ { test: /\\.worker\\.tsx?$/, use: { loader: "worker-loader", options: { inline: true } }, include: path.join(projectRootPath, "src") }, { test: /\\.tsx?$/, use: [ [ "babel-loader", { plugins: [ [ "babel-plugin-react-css-modules", { filetypes: { ".scss": { syntax: "postcss-scss" } } } ] ] } ], "ts-loader" ], include: path.join(projectRootPath, "src") }, { test: /\\.scss$/, use: [ "style-loader", [ "css-loader", { importLoaders: 1, modules: true } ], "sass-loader" ], include: path.join(projectRootPath, "src") } ] }};export default webpackConfig; 利用这个配置加上 webpack-dev-server 即可完成组件与项目的本地调试。 项目发布项目发布时,需要将所有代码打入到一个 bundle 包,此时只需使用 webpack-cli 即可,对配置做如下修改: export default { ...webpackConfig, mode: "production"}; 组件发布组件发布时,依然使用 webpack-cli 构建,但利用 webpack-node-externals 忽略对 node_modules 的解析。 import * as nodeExternals from "webpack-node-externals";export default { ...webpackConfig, mode: "production", externals: [nodeExternals()]}; 此时编译的组件代码,包含了 Typescript 编译、SCSS 编译、css-modules 解析、worker-loader 解析,但所有 node_modules 代码都保持原样,比如下面的代码: 做了代码去重、按需加载、打包、压缩,但因为保持了 require 原样,因此大小只有源码体积。 同时上述三个场景都在复用 webpack 一套代码的基础上,利用了 webpack 的生态,因此维护性和拓展性都很强。后续再加入新功能,再也不需要到处找 babel 或 gulp 的插件了! 3 总结本文从 webpack 为切入点,但其实还可以从 parcel 或 gulp 为切入点,实现前端项目、组件构建体系的统一。 不过从可定制性来看,webpack 插件生态更完善,所以笔者选择了 webpack。 留下一个思考题:你的项目、组件是如何构建的呢?是用了一套代码,还是两套呢? 讨论地址是:精读《如何编译前端项目与组件》 · Issue ##125 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《对 Markdown 的思考》","path":"/wiki/WebWeekly/前沿技术/《对 Markdown 的思考》.html","content":"当前期刊数: 230 Markdown 即便在 2022 年也非常常用,比如这篇文章依然采用 Markdown 编写。 但 Markdown 是否应该成为文本编辑领域的默认技术选型呢?答案是否定的。我找到了一篇批判无脑使用 Markdown 作为技术选型的好文 Thoughts On Markdown,它提到 Markdown 在标准化、结构化、组件化都存在硬伤,如果你真的想做一个现代化的文本结构编辑器,不要采用 Markdown。 概述Markdown 流传甚广,甚至已成为我们的第二语言。Markdown 最早的解析器由 John Gruber 在 2004 年基于 Perl 编写发布,那时候 Markdown 只有一个目的,即为了方便网络写作。 网络写作必须基于 HTML 规范,而 HTML 规范对大部分人上手成本太高,因此 Markdown 就是基于文本创建的更易理解,或者说上手成本更低,甚至傻瓜化的一种语法,而要解析这个语法需要配套一个解析器,将这种语法文本最终转化为 HTML。 而数字化发展到今天,Markdown 已不再适合当下的写作场景了,主要原因有二: Markdown 不再适合当下富交互、内容形态的编写。 Markdown 纯文本的开发体验不再满足当代开发者日益提高的体验需求。 首先还是从 Markdown 思想开始介绍。 Markdown 的核心思想Markdown 最大优势就是好上手,不需要接触 HTML 这种复杂的嵌套语句(虽然对程序员来说 HTML 也简单到处于鄙视链底端)。原文抽象了三个优势: 基于文本的合适抽象。虽然 HTML 甚至代码都是文本,但 “合适” 这个词很重要,即任何文本都可以是 Markdown,只要加一点点小标记就能描述专业结构,学习成本极低。 有大量生态工具。比如语法解析器、高亮、格式转换、格式化、渲染等工具完备。 编辑内容便于维护。比如 Markdown 很方便作为源码存储,而其他格式的富文本可能并不方便在源码里维护。 如果把 Markdown 与数据库表结构做比较,那数据库的理解成本真是太高了。 但是在如今后端即服务的时代,数据库访问越来越轻松,甚至出现大量如 AirTable 等 SAAS 产品将结构化数据快速转化为应用,其实接触了这些后才真正发现,结构化数据对开发者有多重要。Markdown 用来写写文章还是不错的,但用来表达逻辑结构最后一定会引发灾难后果,原文作者的团队就深受 Markdown 技术选型的困扰,被迫解决大量远超预期的难题。 如果真的要在 Markdown 的坑越走越深,就必须使用语法拓展来满足自定义诉求。 Markdown 语法拓展最初 Markdown 语法是不支持表格的,如果想用 Markdown 绘制一张表格,只能使用原生 HTML 标签:<table></table>,当然,这也说明了 Markdown 本质就是给 HTML 加强了便捷的语法,我们完全可以将其当 HTML 使用。 然而并不是所有创作平台都支持 <table></table> 语法的,笔者自己就经常受到困扰,比如有些平台会屏蔽原生 HTML 语法,已保障所谓的 “安全性” 或者内容体验的 “一致性”,而这些平台为了弥补缺失的绘制表格能力,往往会支持一些自定义语法,更糟糕的是不支持,这就说到了 Markdown 的语法拓展。 Markdown 有哪些拓展呢?比如:multiMarkdown、commonMark、Github Flavored Markdown 等等。 这里随便举个例子,比如标准 MD 格式,其实第一行最后要加两个空格才能换行,但 GFM 取消了这个限制。这虽然更方便了,但暴露出平台间规范的不一致性,导致 Markdown 跨平台基本一定被坑。 而各平台拓展的语法,我们是否有足够的精力学习和记忆呢?先不说能不能记得下来,首先值不值得学习就是个问题,为什么一个网络写作平台需要占用写手学习与认知成本,而不是想办法去简化写作流程呢?所以语法拓展看似很美好,但放在写手角度,或者整个互联网各平台林立的角度来看,这种非标准的做法一定不靠谱,没有用户觉得你的平台有资格 “教他语法”,除非你是微信,钉钉或者飞书。 原文提到的观点是: 作为写手,你不知道 Markdown 哪些语法可用,哪些语法不可用。 标准规范存在一些 模糊地带 导致开发者实现时也会遇到各种纠结。 原文还提到一个语法拓展导致理解成本增高的例子:slack 平台自定义的 mrkdown 就不支持 [link](https://slack.com) 方式描述链接,而使用了 <link|https://slack.com> 语法。 总结来说,Markdown 语法拓展本应该是件好事,但实际无标准导致了标准的百花齐放,使 Markdown 成为了实际上没有标准的状态,整体来看弊端更多。 Markdown 面向的用户群Markdown 的对自己的定位其实很不清晰,这也导致了一直不想确定标准化语法。 最初 Markdown 是服务给熟悉 HTML 的人提供的标记语言,而后来面向用户群实质上转向了开发者,因为开发者才会想到拓展语法以满足更复杂的使用场景,Markdown 原生语法无法适应越来越复杂的视觉展示形态。 如今 Markdown 的主要用户已经是开发人员与对代码感兴趣的人了,这倒不是说开发者有多喜欢它,而是在说 Markdown 的受众变窄了。如今任何一款面向非开发者群体的文档编辑器都不会采用 Markdown 了,而是所见即所得的 WYSIWYG(what you see is what you want)模式。 这个转变的过程是痛苦的,但现在来看,富文本编辑器不应用用 Markdown 语法,而是 WYSIWYG 模式已经是共识了。 从段落到区块、从文章到应用简单来说,即 Markdown 已经不适应当前 HTML 丰富的生态了,能轻松描述段落的标记语言,遇到富有交互的组件区块时,不得不引入例如 MDX 等方案,但这样的方案根本只适合程序员群体,完全无法移植。 网络浏览形态也从简单的文章发展到具有整体性的应用,这些应用拥有复杂的布局、样式与交互,如果你尝试基于 Markdown 拓展语法来支持,最后可能发现还不如直接用原生 HTML。 对结构化内容的诉求从编程角度理解就是 “组件复用”。Markdown 原生语法无法实现内容的复用,如果必须要复用内容,只能将其重复写在每一处,势必造成巨大同步成本。 比如 Jekyll 就提出了 FrontMatter 概念用来创建复用的变量: ---food: Pizza---<h1>{{ page.food }}</h1> WYSIWYG 编辑器不应将 HTML 作为底层数据结构虽然浏览器真正将 HTML 作为底层数据结构,但这并不代表所见即所得的编辑器也可以如此,这也是为什么浏览器只能提供从源码到 UI 的输出,而不能提供从 UI 编辑到源码的反向输入。 因为用户的输入与 HTML 并不是一一对应关系,其中存在大量模糊地带,比如当前光标处在粗体与细体文字中间,那下一个输入到底算加粗还是不加粗呢?从 UI 上看不到加粗标签。再有,如果 HTML 存在冗余,其实当前光标所在位置已经被加粗标签包裹了好几层,但因为光标所在区域又被另一个样式标签覆盖成非加粗模式,当再次输入时可能就跳出了覆盖范围,重新变成了加粗,这个过程符合用户预期吗?从技术上,这种复杂标签结构也几乎无法被处理,因为组合花样实在太多。 现代大多数编辑器都以 JSON 格式存储数据结构,就因为其结构化且易于检索。 结构化最重要的体现是,其生成的 HTML 结构可以是稳定的,即对于一个既加粗又标红的文字,一定包裹在一个 <strong style="color: red"> 标签里,而不是 <strong><div style="color: red">,也就是这种模式根本没把 HTML 作为结构化数据去看待,自然就不会出现歧义。 Markdown 也是一样,其本身也会出现类似 HTML 标签的二义性,不适合作为底层数据结构存储。 精读批判 Markdown 的文章不多见,笔者也是看了之后才恍然发现 Markdown 竟然有这么多缺点。笔者结合自己的经验谈谈 Markdown 的缺点吧。 不支持富交互的无奈Markdown 仅能支持简单的 HTML 结构,而无法描述逻辑区块。Github 上大部分 Readme 都采用图片来实现这些功能,包括状态卡片、构建结果、个人信息名片等,可惜交互能力还是太弱,我觉得有朝一日 Github 应该会推出比如 Block 小区块的概念,让这些区块可以直接插入 Markdown 成为一个可交互的元素。 MDX 解决了 Markdown 的痛点吗?看似完美兼容 JSX 与 Markdown 的 MDX 曾经也是笔者写作的救命稻草,但该方案移植性是一大痛点,组件只能在自己部署的网站用,如果你想把文章发布到另一个平台,完全不可能。 这还仅是笔者的视角,如果从 Markdown 生态来看,MDX 面向用户仅是程序员群体,根本没有解决其使命 “方便网络写作”,而程序员最终也会抛弃 MDX 而转向开发所见即所得编辑器解决问题。 Markdown 到 HTML 的转换存在逻辑问题Markdown 本质上还是一种脱离 HTML 的文本表示结构,看上去解耦很优雅,实际上会遇到不少不一致的问题。 比如说连续敲击多个空格会出现什么情况呢?在 Markdown 会变成一个引用区块,那如何才能展示多个空格呢?谁也不知道,可能需要查阅具体平台提供的额外语法才可以做到。 这种大体上用起来方便,但细节无法定制,甚至用户无法控制的情况会大大伤害已经深度使用 Markdown 的用户,此时用户要么硬着头皮发明新语法解决这些漏洞,要么就完全放弃 Markdown 了。 结构化能力不足看上去 Markdown 的语法挺具有结构化的,但实际上 Markdown 的结构化不具有强约束力。 拿 JSON 作对比,比如我们可以用 JSON 拓展出 https://json-schema.org/ 结构,这个结构甚至可以反推出一个完整的表单应用,其原因是 JSON 可以针对每一个 Key、层级下定义,首先有结构,其次才有内容。 而 Markdown 正好反过来,是先有内容,再有结构。比如我们可以在 Markdown 任何地方写任何 HTML 标签,或者任意段落的问题,这些内容是无法被序列化的,即便我们按照浏览器解析 HTML 的规则解析成 JSON,也无法从中方便的提取信息。 背后的根本原因是,Markdown 本身定位就是 “近乎于 UI 渲染结果” 的,而实际上浏览器渲染 UI 背后是需要一套严谨的 HTML 语法,因为 UI 与背后语法并不能一一建立映射,一个稳定的渲染逻辑只能是从源码推导到渲染,而不能从渲染反推出源码。Markdown 本身定位就近乎于渲染结果,所以结构化能力不足是天然的问题。 总结记得语雀早期内部试用时,编辑态还是采用 Markdown 的,但后来很快就把 Markdown 的编辑入口下掉了,这件事还引发了不少开发者的不满,甚至还有一些 Markdown 编辑的插件被开发出来,一度很受欢迎。但渐渐的我们都习惯用所见即所得方式编辑了,Markdown 唯一留给我们的印象就是快捷键,比如 #### 后敲入空格可以生成 h3 标题段落,而语雀编辑器也在富交互组件区块上越走越远,要是当年被 Markdown 锁定住了技术,也不可能有今天这么高级的编辑体验。 所以技术前瞻性真的很重要,Markdown 所有程序员都爱,但提前看到它在当前互联网发展阶段的局限性,并设计一套结构化数据代替 Markdown 结构不是所有人都能想到的,我们需要以动态的眼光看待技术,也要放下技术人的偏见,把偏爱让位于产品定位。 讨论地址是:精读《对 Markdown 的思考》· Issue ##397 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《对前端架构的理解 - 分层与抽象》","path":"/wiki/WebWeekly/前沿技术/《对前端架构的理解 - 分层与抽象》.html","content":"当前期刊数: 254 可能一些同学会认为前端比较简单而不需要架构,或者因为前端交互细节杂而乱难以统一抽象,所以没办法进行架构设计。这个理解是片面的,虽然一些前端项目是没有仔细考虑架构就堆起来的,但这不代表不需要架构设计。任何业务程序都可以通过代码堆砌的方式实现功能,但背后的可维护性、可拓展性自然也就千差万别了。 为什么前端项目也要考虑架构设计?有如下几点原因: 从必要性看,前后端应用都跑在计算机上,计算机从硬件到操作系统,再到上层库都是有清晰架构设计与分层的,应用程序作为最上层的一环也是嵌入在整个大架构图里的。 从可行性看,交互虽然多而杂,但这不构成不需要架构设计的理由。对计算机基础设计来说,也面临着多种多样的输入设备与输出设备,进而产生的标准输入输出的抽象,那么前端也应当如此。 从广义角度看,大部分通用的约定与模型早已沉淀下来了,如编程语言,前端框架本身就是业务架构的一部分,用 React 哪怕写个 “Hello World” 也使用了数据驱动的设计理念。 从必要性看,虽然操作系统和各类基础库屏蔽了底层实现,让业务可以仅关心业务逻辑,大大解放了生产力,但一款应用必然是底层操作系统与业务层代码协同才能运行的,从应用程序往下有一套逻辑井然的架构分层设计,如果到了业务层没有很好的架构设计,技术抽象是一团乱麻,很难想象这样形成的整体运行环境是健康的。 业务模块的架构设计应当类似计算机基础的架构设计,从需求分析出发,设计有哪些业务子模块,并定义这些子模块的职责与子模块之间的关系。子模块的设计取决于业务的特性,子模块间的分层取决于业务的拓展能力。 比如一个绘图软件设计时只要需要组件子系统与布局子系统,它们之间互相独立,也能无缝结合。对于 BI 软件来说,就增加了筛选联动与通用数据查询的概念,因此对应的也会增加筛选联动模型、数据模型、图形语法这几个子模块,并按照其作用关系上下分层: 如果分层清晰而准确,可以看出这两个业务上层具有相同的抽象,即最上层都是组件与布局的结合,而筛选联动与数据查询,以及从数据模型映射到图元关系的映射功能都属于附加项,这些项移除了也不影响系统的运行。如果不这么设计,可能就理不清系统之间的相似点与差异点,导致功能耦合,要维护一个大系统可能要时刻关系各模块之间的相互影响,这样的系统即不清晰,也不够可拓展,关键是要维护它的理解成本也高。 从可行性看,前端的特点在于用户输入的触点非常多,但这不妨碍我们抽象标准输入接口,比如用户点击按钮或者输入框是输入,那键盘快捷键也是一种输入方式,URL 参数也是一种输入方式,在业务前置的表单配置也是一种输入方式,如果输入方式很多,对标准输入的抽象就变得重要,使业务代码的实际复杂度不至于真的膨胀到用户使用的复杂度那么高。 不止输入触点多,前端系统的功能组合也非常多,比如图形绘制软件,画布可以放任意数量的组件,每个组件有任意多的配置,组件之间还可以相互影响。这种系统属于开放式系统,用户很容易试出开发者都未曾想到过的功能组合,有些时候开发者都惊叹这些新的组合竟然能一起工作!用户会感叹软件能力的强大,但开发者不能真的把这些功能组合一一尝试来解决冲突,必须通过合理的分层抽象来保证功能组合的稳定性。 其实这种挑战也是计算机面临的问题,如何设计一个通用架构的计算机,使上面可以运行任何开发者软件,且软件之间可以相互独立,也可以相互调用,系统还不容易产生 BUG。从这个角度来看,计算机的底层架构设计对前端架构设计是有参考意义的,大体上来说,计算机通过硬件、操作系统、软件这个三个分层解决了要计算一切的难题。 冯·诺依曼体系就解决了硬件层面的问题。为了保证软件层的可拓展性,通过 CPU、存储、输入输出设备的抽象解决了计算、存储、拓展的三个基本能力。再细分来看,CPU 也仅仅支持了三个基本能力:数学计算、条件控制、子函数。这使得计算机底层设计既是稳定的,设计因素也是可枚举的,同时拥有了强大的拓展能力。 操作系统也一样,它不需要知道软件具体是怎么执行的,只需要给软件提供一个安全的运行环境,使软件不会受到其他软件的干扰;提供一些基本范式统一软件的行为,比如多窗口系统,防止软件同时在一块区域绘图而相互影响;提供一些基础的系统调用封装给上层的语言进行二次封装,而考虑到这些系统调用封装可能会随着需求而拓展,进而采用动态链接库的方式实现,等等。操作系统为了让自身功能稳定与可枚举,对自己与软件定义了清晰的边界,无论软件怎么拓展,操作系统不需要拓展。 回到前端业务,想要保障一个复杂的绘图软件代码清晰与好的可维护性,一样需要从最底层稳定的模块开始网上,一步步构建模块间依赖关系,只有这样,模块内逻辑才能可枚举,模块与模块间才敢大胆的组合,各自设计各自的拓展点,使整个系统最终拥有强大的拓展能力,但细看每个子模块又都是简单清晰、可枚举可测试的代码逻辑。 以 BI 系统举例,划分为组件、筛选、布局、数据模型四个子系统的话: 对组件系统来说,任何组件实现都可接入,这就使这个 BI 系统不仅可以展示报表,也可以展示普通的按钮,甚至表单,可以搭建任意数据产品,或者可以搭建任意的网站,能力拓展到哪完全由业务决定。 对筛选系统来说,任何组件之间都能关联,不一定是筛选器到图表,也可以是图表到图表,这样就支持了图表联动。不仅是 BI 联动场景,即便是做一个表单联动都可以复用这个筛选能力,使整个系统实现统一而简单。 对布局系统来说,不关心布局内的组件是什么,有哪些关联能力,只要做好布局就行。这样画布系统容易拓展为任何场景,比如生产效率工具、仪表盘、ppt 或者大屏,而对其他系统无影响。 对数据模型系统来说,其承担了数据配置到 sql 查询,最后映射到图形通道展示的过程,它本身是对组件系统中,统计图表类型的抽象实现,因此虽然逻辑复杂,但也不影响其他子系统的设计。 从广义角度看,前端业务代码早就处于一系列架构分层中,也就是编程语言与前端框架。编程语言与前端框架会自带一些设计模式,以减少混用代码范式带来的沟通成本,其实架构设计本身也要解决代码一致性问题,所以这些内容都是架构设计的一环。 前端框架带来的数据驱动特性本身就很大程度上解决了前端代码在复杂应用下可维护问题,大大降低了过程代码带来的复杂度。React 或 Vue 框架本身也起到了类似操作系统的操作,即定义上层组件(软件规格)的规格,为组件渲染和事件响应抹平浏览器差异(硬件差异),并提供组件渲染调度功能(软件调度)。同时也提供了组件间变量传递(进程通信),让组件与组件间通信符合统一的接口。 但是没有必要把每个组件都类比到进程来设计,也就是说,组件与组件之间不用都通过通信方式工作。比较合适的类比粒度是模块,把一个大模块抽象为组件,模块与模块间互相不依赖,用数据通信来交流。小粒度组件就做成状态无关的元件,注意相似功能的组件接口尽量保持一致,这样就能体验到类似多态的好处。 所以话说回来,遵循前端框架的代码规范不是一件可有可无的事情,业务架构设计从编程语言和前端框架时就已经开始了,如果一个组件不遵循框架的最佳实践,就无法参与到更上层的业务架构规划里,最终可能导致项目混乱,或者无架构可言。所以重视架构设计从代码规范就要开始。 所以前端架构设计是必要的,那怎么做好前端架构设计呢?这个话题太过于庞大,本次就从操作系统借鉴一些灵感,先谈一谈对分层与抽象的理解。 没有绝对的分层分层是架构设计的重点,但一个模块在分层的位置可能会随着业务迭代而变化,类比到操作系统举两个例子: 语音输入现在由各个软件自行提供,背后的语音识别与 NLP 能力可能来自各大公司的 AI 中台,或者一些提供 AI 能力的云服务。但语音输入能力成熟后,很可能会成为操作系统内置能力,因为语音输入与键盘输入都属于标准输入,只是语音输入难度更大,操作系统短期难以内置,所以目前发展在各个上层应用里。 Go 语言的协程实现在编程语言层,但其对标的线程实现在操作系统层,协程运行在用户态,而线程运行在内核态。但如果哪天操作系统提供了更高效的线程,内存占用也采用动态递增的逻辑,说不定协程就不那么必要了。 按理说语音输入属于标准输入的一部分,应该实现在操作系统的通用输入层,协程也属于多任务处理的一部分,应该实现在操作系统多任务处理层,但它们都被是现在了更上层,有的在编程语言层,有的在业务服务层。之所以产生了这些意外,是因为通用输入输出层与多任务处理层的需求并没有想象中那么稳定,随着技术的迭代,需要对其拓展时,因为内置在底层不方便拓展,只能在更上层实现了。 当然我们也要注意到的是,即便这些拓展点实现在更上层,但对软件工程师来说并没有特别大的侵入性影响,比如 goroutine,程序员并不接触操作系统提供的 API,所以编程语言层对操作系统能力的拓展对程序员是透明的;语音输入就有一点影响了,如果由操作系统来实现,可能就变成与键盘输出保持一致的事件结构了,但由业务层实现就有无数种 API 格式了,业务流程可能也更加复杂,比如增加鉴权。 从计算机操作系统的例子我们可以学习到两点: 站在分层合理性视角对输入做进一步的抽象与整合。比如将语音识别封装到标准的输入事件,让其逻辑上成为标准输入层。 业务架构的设计必然也会遇到分层不满足业务拓展性的场景。 业务分层与硬件、操作系统不同的是,业务分层中,几乎所有层都方便修改与拓展,因此如果遇到分层不合理的设计,最好将其移动到应该归属的层。操作系统与硬件层不方便随意拓展的原因是版本更新的频率和软件更新的频率不匹配。 同时,也要意识到分层需要一个演进过程,等新模块稳定后再移动到其归属所在层可能更好,因为从上层挪到底层意味着更多被模块共享使用,就像我们不会轻易把软件层某个包提供的函数内置到编程语言一样,也不会随意把编程语言实现的函数内置到操作系统内置的系统调用。 在前端领域的一个例子是,如果一个搭建平台项目中已经有了一套组件元信息描述,最好先让其在业务代码里跑一段时间,观察一下元信息定义的属性哪些有缺失,哪些是不必要的,等业务稳定一段时间后,再把这套元信息运行时代码抽成一个通用包提供给本业务,甚至其他业务使用。但即便这个能力沉淀到了通用包,也不代表它就是永远不能被迭代的,操作系统的多任务管理都有协程来挑战,何况前端一个抽象包的能力呢?所以要慎重抽象,但抽象后也要敢于质疑挑战。 没有绝对的抽象抽象粒度永远是架构设计的难题。 计算机把一切都理解为数据。计算结果是数据,执行程序的代码也是数据,所以 CPU 只要专注于对数据的计算,再加上存储与输入输出,就可以完成一切工作。想一想这样抽象的伟大之处:所有程序最终对计算机来说都是这三个概念,CPU 在计算时无需关心任何业务含义,这也使得它可以计算任何业务。 另一个有争议的抽象是 Unix 一切皆文件的抽象,该抽象使文件、进程、线程、socket 等管理都抽象为文件的 API,且都拥有特定的 “文件路径”,比如你甚至可以通过 /proc 访问到进程文件夹,ls 可以看到所有运行的进程。当然进程不是文件,这只是说明了 Unix 的一种抽象哲学,即 “文件” 本身就是一种抽象,开发和可以用理解文件的方式理解一切事物,这带来了巨大的理解成本降低,也使许多代码模式可以不关心具体资源类型。但这样做的争议点在于,并不是一切资源都适合抽象成文件,比如输入输出中的显示器,它作为一个呈现五彩缤纷像素点的载体,实在难以用文件系统来统一描述。 计算机设计与操作系统设计已经给了我们很明显的启发,即一切能抽象的都要尽可能的抽象,如此才能提高系统各模块内的稳定性。但从如 Unix 一切皆文件的抽象来看,有时候的技术抽象难免被当时的业务需求所局限,当输入输出设备的种类增加后,这种极致的抽象未必能永远合适。但永远要相信抽象,因为假若所有资源都可以被文件抽象所描述,且使用起来没有不便捷的地方,为什么还要造其他的抽象概念呢?如无必要勿增实体。 比如 BI 场景的筛选、联动、下钻场景是否都能抽象为组件与组件间的联动关系呢?如果一套标准联动设计可以解决这三个场景,那自然不需要为某个具体场景单独引入概念。从原始场景来看,无论筛选、联动还是下钻场景都是修改组件的取数参数以改变查询条件,我们就可以抽象出一种组件间联动的规范,使其可以驱动取数参数的变化,但未来需求可能引入更多的可能性,如在筛选时触发一些额外的追加分析查询,此时之前的抽象就收到了挑战,我们需要权衡维持统一性的收益与通用接口不适用于特殊场景带来成本之间的平衡。 抽象的方式是无数的,哪种更好取决于业务如何变化,不用过于纠结完美的抽象,就连 Unix 一切皆文件的最基础抽象都备受争议,业务抽象的稳定性肯定会更差,也更需要随着需求变化而调整。 总结我们从计算机与操作系统的架构设计出发,探讨了前端架构设计的必要性,并从分层与抽象两个角度分析了架构设计时的考量,希望你在架构设计遇到拿捏不定的问题时,可以向下借助计算机的架构设计获得一些灵感或支持。 讨论地址是:精读《对前端架构的理解 - 分层与抽象》· Issue ##436 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《对低代码搭建的理解》","path":"/wiki/WebWeekly/前沿技术/《对低代码搭建的理解》.html","content":"当前期刊数: 159 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《增强现实与可视化》","path":"/wiki/WebWeekly/前沿技术/《增强现实与可视化》.html","content":"当前期刊数: 43 增强现实与可视化引言增强现实,Augmented Reality,简称 AR。在 VR 的热潮已经褪去,AI 当下正红的技术圈里,AR 似乎已经成为了过气网红。但似乎在现在,我们可以来冷静地看待一下增强现实这个概念。 做前端的最终还是要和用户打交道的,增强现实是不是为用户界面带来了新的可能?可视化作为前端的一个细分领域,这篇文章正是从这个角度出发来重新认识增强现实与可视化。 内容概要移动设备为我们带来了巨大的便利,但是在各种好处下,有一点不容忽视:屏幕太小,根本没有更多的空间来放置更多的内容。对于数据可视化而言,更大的空间意味着更好的数据分析能力。而在空间这件事情上,AR 正是很好地解决了这个问题。当下,AR 的主要形态还是将虚拟信息作为图层叠加在现实图像之上的形态,这样的 AR 其实更接近于混合现实。例如使用面部追踪给你脸上加上小动物的 Snapchat Lenses,可以在沙发茶几上玩 MineCraft 的微软 HoloLens,和苹果最近新出的 ARKit 都是这种。 所以要怎么通过增强现实来解决当下移动端屏幕空间不足的问题呢?在 AR 的概念下,一切皆屏幕,现实之上的任何地方都可以展示信息。对于增强现实的未来,文章给出了 3 个可能的方向。首先,需要提供更好的针对个人的视觉体验。其次,要更好地利用 3D 展示。最后,让屏幕悬浮在任何地方。 但是我们也不妨从反面重新理性地看到了增强现实。是不是有了足够空间展示东西我们就要把东西一股脑都展示出来呢?并不是的,增强现实是和真实世界的混合。所以增强现实下的可视化,也不是也不是简单地照搬网页。在增强现实下,我们所展示的每一个虚拟物体,是需要有现实依据的。但是也不必拘泥于现实,增强现实也可以改造所展示的世界。增强现实并不是简单的做加法,只能构建出各类虚拟物品叠加在世界上。同样也能改造世界,使事物变形和消失。 所以回到整体在增强现实下的可视化,增强现实是一个完全不同的世界。我们原先的可视化相关的经验并不一定都能应用到增强现实的世界中。文章中也针对我们常见的可视化元素进行了辨识分析,对增强现实下的可视化的基本元素进行了分析。 精读前端是什么?对于这样一个本源性的话题。每个人都有自己不同的理解。我也不会说书式得非得确定这个问题的答案。但是可以确定的是,如果一切的描述要带上设备端的前提,是短视的。 从 PC Web 到移动 App,前端并没有消失,各大网站也都纷纷开始将自己的网站搬到了手机上以移动 APP 的形式存在。同样的,如果有一天增强现实来到我们的生活。前端作为和用户界面直接接触的工作,毫无疑问已经会是很重要的一部分。 再看可视化。移动化的大潮让我们开始习惯移动设备,触屏之类的交互的确让我们的信息获取变得更加方便。但是这并不代表全部,特殊行业的人依旧离不开传统的 PC,尤其是数据分析人员,对于他们而言,更大的屏幕等于一切。尤其是金融分析师,你给他们四块屏幕都不够用。增强现实下的可视化的确是对这些人的一个重要利好。原因无非就是增强现实的可展现区域相当于整个人眼的视角,远远大于任何我们可以感知到的屏幕。 如果只是在我们的眼睛上盖上一层信息,那是远远不够的,这样的增强现实仅仅是对屏幕的扩展。增强现实最关键的一点是和现实世界的充分结合:例如配合地理信息和我们的地理定位所带来的信息可视化展示;通过图像识别对物体的感知后,在物体上进行信息可视化展示;等等。文章的几个建议中,最后一个特别有趣——让屏幕悬浮在任何地方。这有两层含义,一是要充分利用好上面所提到的增强现实下的空间,二是我们需要清楚地认识到,人类对于二维世界的感知能力还是远远超出三维世界的。过度利用现实世界所构建出的 3D 场景,可能并不能为我们的分析带来太多进步。所以,二维的图形信息展示可能依然是增强现实可视化中很重要的一部分。 这篇文章更加引起我兴趣的地方在于它对于可视化基本元素进行了分析——颜色,亮度,纹理,尺寸,方向、形状、位置……这些从我们平时的显示器屏幕或者手机屏幕上所展示的可视化元素会带来完全不一样的表现。增强现实的增强在于我们可以对现实的展示进行改造,纹理是很有趣的一块。一般来说我们平时做可视化分析,常用到阴影,虚线这些纹理,但是现实世界里的纹理就太丰富了,我们可以充分利用现实花纹了,让展现更直观更融合。 增强现实的另一个难点在于带着设备的人随着不断走动,所有的展示都在变化。所以我们传统意义上关于大小值的比对的思路可能都不适用了。 大的东西一定大吗?不一定,可能它只是比较近而已。 而反观我们传统的可视化理念,都免不了对于大的线、柱、饼来表示大的东西。这一套理念可能在增强现实里就不够用了。这也是增强现实对于可视化的矛盾之处。 增强现实下的可视化还有很多路要走。 总结增强现实对于可视化带来了很多新的可能,很重要的一点就在于,当下我们的屏幕空间太小,很多东西都为了展示做出了取舍。增强现实下“一切皆为屏幕”的理念,为数据分析的展现带来了巨大的屏幕空间。我们可以用更高级的手段来进行可视化分析。 文章也分析了增强现实对可视化的利弊。虽然增强现实的设备还不普及,但是现在增强现实设备开始增多。的确增强现实带来了更多的在可视化上的可能性,但是用的不好一样会出大问题。现在成熟的可视化设计经验在增强现实下可能并不会适用。 想起之前看到的一句话,”Zen for monks, not for merchants.”。增强现实在当先依旧是一个玩乐的东西,但是利用好增强现实来展示信息,作为解决可视化难的问题的一条重要出路,来发挥真正的价值,还是很值得一看的。 讨论 讨论地址: 精读《增强现实与可视化》 欢迎大家前来讨论"},{"title":"《寻找框架设计的平衡点》","path":"/wiki/WebWeekly/前沿技术/《寻找框架设计的平衡点》.html","content":"当前期刊数: 133 1 引言尤雨溪 在 2019 JSConf 的分享 Seeking the Balance in Framework Design 十分精彩,道出了如何进行合理的前端框架设计与框架选型。 正如所说,框架对比不能只停留在 Star 数量、Npm 下载量、Stackoverflow 问题量这些简单的数据对比,而要深入到技术细节进行比较。比较框架有多种不同维度,这次分享就从服务范围、渲染机制、状态机制这三个维度进行对比。 2 概述这次分享的精彩之处在于不偏不倚的站在客观立场分析了框架各维度好的一面与坏的一面,从中我们不仅能学习到一些框架知识,还能培养思辨能力。 服务范围服务范围是个比较难翻译的单词,在原 PPT 中用了 “Scope” 这个单词表示,可以理解为 “作用域、框架的承诺功能范围、服务配套齐全程度”。比如提供的是一个工具库还是整体框架,插件管理是集中式还是依赖生态。 React 是典型的小服务范围框架,核心包只实现了基本功能,而其他生态基本靠社区拓展;Angular 是典型大服务范围框架,官方对所有业务场景都做了最佳实践能力覆盖;Vue 处在中间区域,通过功能分层,既拥有小服务范围的能力,又可以搭配官方插件实现更多场景化能力。 小服务范围优势概念少,易上手 小的服务范围代表了小的学习成本,因为暴露的基本能力较少,概念也会比较少,对新人上手比较友好。 生态繁荣,百花齐放 由于很多功能没有被官方实现,社区就有机会填补这些空白,因此会冒出许多第三方库,而且一旦做得好,就有机会成为 “事实标准”,因此开发者会更加积极参与到社区开发,自己做的框架 “上升空间” 也非常大。 同时,社区的力量会导致多元化,因此整体生态完整度与创新性都会非常亮眼,而且具有持续迭代的能力。 核心维护成本低 官方维护的核心代码较少,因此维护成本大大降低,而且官方可以将精力放在更多核心能力增强上,比如 Suspense 等,而不是将精力消耗在生态插件上。 小服务范围的劣势复杂场景要引入新概念 复杂场景无法支持时,就要引入新的概念解决,这导致后续技术选型可能产生分歧,并带来持续的新概念理解成本。 非官方的开发模式逐渐产生 随着时间的流逝,会逐渐涌出一些新的设计模式,成为当下几乎是必不可少的方案,但却不会出现在官方文档中,造成选型时的疑惑。Redux 就是一个例子。 生态变化快,碎片化且持续流失 非官方的生态也意味着不稳定,而且缺乏统一的管理,碎片化的模块之间可能经常出现不兼容的问题。 而且任何模块都可能被时代无情的淘汰,就像 Flux 到 Redux 再到 Hooks,带来额外的迁移成本和认知成本。谁也不希望自己的项目架构 “变得过时”,或者随时面临被新架构取代的风险,但第三方社区几乎一定代表未来会出现一种模式取代现有模式,只是时间早晚而已。 大服务范围的优势大部分业务场景都被内置解决 减少不必要的技术方案调研与纷争,大服务范围的框架内置的方案就能解决几乎 100% 业务问题,团队再也不会为通用架构问题烦恼了。 生态稳定、连贯 稳定是指,官方维护作为背书,几乎不会存在一些生态包突然不维护、与已有版本不兼容、被植入恶意程序等等意外情况。 连贯是指,官方会统一考虑一个改动在所有生态插件造成的影响,并以一个最合理的思路做整体改造,生态包无论是接口还是兼容性都不需要担心,设计思路也会一脉相承。 大服务范围的劣势前期上手成本高 全家桶的概念导致上手难度偏高,因为必须理解所有内置概念后才能开始项目。 如果内置模块无法满足业务,会觉得有些死板 一旦发生内置功能无法满足业务的场景,就很难拓展了,因为 all in one 的思路本质上就是排斥自定义拓展的,这点从 angular-cli 就能看出来。 之所以觉得死板,是因为这种情况没办法用优雅的方式解决,只能在现有约束的框架内通过某些 “Hack” 方式解决,自然会有种死板的感觉。 中等服务范围的优势分层设计,允许新特性渐进加入 Vue 通过分层设计做到了折中,即官方还是会维护生态,只不过生态不是必须的,可以按需使用。这样做的好处是兼顾了一些优势。 低学习门槛 与小服务范围框架一样,对于核心包来说学习成本都比较低。 依然有最佳实践解决所有业务问题 和大服务范围框架一样,拥有全套官方最佳实践,但不内置,不强求一定要使用,因此你可以按需使用。 中等服务范围的劣势维护成本高 和大服务范围框架一样,虽然生态不强求,但毕竟官方还是要持续维护的,因此维护成本高的问题依然存在。 生态多样性不高 虽然生态是按需的,但毕竟中等服务范围的框架官方会实现一套标准生态插件,这会极大影响社区生态的发展空间,导致 “非官方插件没人愿意做”,因此生态多样性会差一些。 渲染机制渲染机制区别主要在 JSX vs Template 之间,不同的表达方式之间还是存在一些很本质的区别,然而正如一开始所说,无法一言蔽之,必须从多个角度拆解的看。 JSX 的优势纯 JS 表达 UI 单这一点就非常重要了,满足了 All In Js 的幻想。毕竟 Html、Css 相比 Js 来说,模块化能力和灵活性都很弱,将其都收敛到 Js 不仅表达方式更统一,更重要的是都获得了与 Js 一样的模块化、灵活性、Typescript 支持等能力。 视图即数据 将视图看作一种数据,让针对视图的逻辑测试成为可能。 同时也将视图概念泛化了,因为数据是平台无关的,一份描述视图的 DSL 可以运行在任何平台。 JSX 的劣势开销大 页面节点越多,Diff 开销就越大。 动态渲染很难性能优化 由于所有 DOM 节点都是动态生成,因此无法根据初始状态结构进行安全的优化。相比之下,Template 模式可以确定哪部分属于变量,哪部分是固定的,对固定部分的 Diff 检测都可以跳过。 动态调度虽然改善了性能,但依赖更重的运行时 React ConcurrentMode 是一个调度优化器,但实现的逻辑也比较复杂,加重了运行时负担。 Template 的优势原生性能 由于 Template 对节点进行直接渲染,因此与原生性能一致。 Runtime 更小 由于不需要额外优化,运行时代码会小很多。 Template 的劣势被 Template 语法约束,且无法拓展 对于 Template 不支持的,只能选择接受,因为除了框架自己,没有人能拓展 Template 的特性。当遇到一些非常动态场景,但 Template 不支持的情况,只能选择接受,并用比较 Hack 的方式绕过解决,除此之外别无他法。 模版冗长 JSX 可以利用循环语句或者变量赋值进行模版区块的复用,但 Template 模式每次新模版都要一行一行的打出来,这种冗长的开发体验不太友好。 运行时解析开销或者依赖编译期逻辑 要么通过编译器预先生成 AST,要么运行时动态将 Template 解析成 AST,无论哪种方案都有额外的开销,一种是工程依赖的开销,一种是运行时动态解析的性能开销。 VDom + Template 的特色Vue 在 Template 基础上支持了虚拟 DOM,因此兼具两者特色。 性能上,在编译时就进行 AST 解析,减少了运行时解析开销。 功能上,支持模版与 JSX 两种语法。 状态机制状态机制 尤雨溪 在 JSConf 提到要单独拆出来讲,因为内容较多,时间可能不够,本次精读也限于篇幅原因略过: Mutable vs Immutable。 依赖追踪 vs 脏检测。 响应式 vs 模拟响应式。 显然,状态机制方案更是仁者见仁智者见智的事情,同样得从多个维度进行独立分析,并根据实际业务场景具体选择。 最后,意识到没有一个绝对均衡的框架设计方案,因为在工程领域,没有最好只有更好。 3 精读我们再延伸谈一谈为什么框架设计要寻找平衡点。 框架设计没有银弹 与数学公式不同,框架设计甚至整个工程技术设计都没有所谓的真理,所谓条条大路通罗马,实现同一个技术目标的众多方案之间也许就是平行关系,可以根据不同维度列出一二三的对比,但无法得出一个总的结论,孰优孰劣。 使用场景不同 不同使用场景决定了对框架诉求的不同。 比如开发非常定制、炫酷的可视化大屏,那么前端开发框架基本也用不上,因为关注点不会聚焦在项目路由、UI 描述、甚至是数据流,而是聚焦在性能、图形渲染等问题。解决这些领域的框架可能是 虚幻 4、Unity 等游戏引擎,但普通的前端开发框架绝不会涉足这种领域,框架一定要确定自己功能范围。 即便仅局限在 Web 领域,也需要考虑是否要支持非 Web 场景,那么将 HTML 抽象成一个通用 DSL 就可能是一种选择,但非 Web 领域毕竟不是主打业务领域,在这种业务场景周边生态维护可能就比较少,这也是需要取舍的地方。 使用的人不同 不同团队对框架的要求也不同。 刚起步的小团队可能更需要保姆式的框架,因为这样最节省人力成本。对于规模较大的团队,希望对框架拥有较大定制能力时,小服务范围的框架可能更受青睐。当然框架作者可以像 Vue 一样做出渐进式官方能力增强方案,以此满足不同需求的用户,但毕竟也不能将生态完全交给社区,还是要做取舍。 所以当遇到更新更酷的框架时,需要冷静思考的不只是这个框架带来的收益与花费的迁移成本哪个更高,以及团队能否接受这套框架的开发习惯,更需要思考的是这个框架自身做了哪些权衡,如果这些权衡与 React、Vue、Angular 类似,那么仅仅变化了语法或者语言的改动其实意义不大,此时需要慎重考虑。 4 总结这次没有提到的状态机制对比,你能分别列举出优缺点吗?欢迎留言。 讨论地址是:精读《寻找框架设计的平衡点》 · Issue ##223 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《快速上手构建 ARKit 应用》","path":"/wiki/WebWeekly/前沿技术/《快速上手构建 ARKit 应用》.html","content":"当前期刊数: 50 精读《快速上手构建 ARKit 应用》原文地址: how-to-make-your-own-arkit-app-in-5-minutes-using-react-native 引言ARKit 是苹果推出的增强现实套装,而 react-native-arkit 是基于此的上层封装。对于前端开发而言,这可能是最快上手 ARKit 的方式了,本周精读让我们来初窥 ARKit 和 React Native ARKit 这个库。 概要本次精读我们带来的是一篇《快速上手构建 ARKit 应用》,原文链接如上。原文标题更加直接,直译的话是“如何在 5 分钟里利用 react native 搭建出你自己的 ARKit 应用”。确实,这篇文章整体也非常明确,以跑起整个 ARKit Demo 为最直接最主要的目的。 跑起 ARKit,也很简单。硬件上,只要有一台 iPhone 6S 以上的手机;软件上,只要准备好最新版本的 XCode 和日常开发要用的 Node 环境了就好。按照react-native-arkit的里面的 README 就可以跑起来了。这个库不 3 精读在开始精读前,我先抛出我的问题三连:Why AR? Why ARKit? Why React Native ARKit? 3.1 Why AR?在之前的第 43 期精读评论中,我们探讨了 AR 对于和前端结合的可能性。总的来说,AR 把前端开发不再局限在有限的屏幕空间上,对于可视化等对前端展示空间有强烈需求的细分领域,AR 是一个很值得研究的内容。如果对于这一块内容有兴趣,欢迎回看第 43 期精读评论 《精读〈增强现实与可视化〉》。 3.2 Why ARKit?为什么选择 ARKit 入手进行实验?其因有二。第一,相比于 Microsoft HoloLens 的价格,售价只有它三分之一的 iPhone X 无论是体积重量,还是性价比,抑或是保有量都是大大占优的。噢对,说到保有量,iPhone 6S 及以上都支持 ARKit。所以说 iPhone 是我们身边最容易接触到的 AR 设备是不为过的。第二,ARKit 对于硬件的利用能力非一般的前端库可以做到的。大部分的 AR 前端库可以做到利用陀螺仪来构建一个三维立体空间。但是 ARKit 更进一步,他利用高频调用摄像头,通过对图像进行识别分析,可以进行空间感知,例如可以识别出一个平面。而这些都是 ARKit 所提供的,我们只需要调用它的能力就好了。对于开发者而言,ARKit 会比一般的 AR 库更近一步。 3.3 Why React Native ARKit?对于当下的前端开发,所有事情可以分为两种——0. 可以用 JavaScript 写的 1. 其他。至于为什么选择react-native-arkit这个库,原因自然也可以理解。相比于用原生的 Swift 来开发,React Native 的开发方式对于前端而言明显是更加容易上手了。对于尝试新东西,这也未尝不可。 3.4 About Demo相比于原文中从初始化开始的步骤,官方还提供了一个已经配置好的官方 Demo。使用这个,如果环境没有问题,的确只需要 5 分钟就可以跑起来一个 ARKit 应用了。 上面的图片来自原文,可以看到,在react-native-arkit这个库里面的所支持的 9 种基本图形和文字。使用如下已经封装好的 React Native 组件就可以直接使用了。 <ARKit.Box pos={{ x: 0, y: 0, z: 0 }} shape={{ width: 0.1, height: 0.1, length: 0.1, chamfer: 0.01 }}/> 几何构造上面的一个视频片段是我们在跑起来 Demo 后的立体效果。可以很清楚地看到,ARKit 感知到了房间这个立方体空间后所构建出来的 AR 的效果。 平面识别而最后的这段视频会更加有趣一些,中央的红圈的出现逻辑是停留在最近识别出的一个平面上。我们可以看到首先识别出了地面,红圈随地面而动;再移向桌面时,很快又识别出了桌面,重新生成了一个停留在桌面上的红圈。通过这一段可以看出无论是明暗划分明显的地面,还是堆满杂物的桌面,ARKit 都可以很轻松的识别出来。 4. 总结苹果的 ARKit 对空间平面的感知能力胜过了一般的 AR 渲染库。而 iPhone 6S 就能跑的特性又让我们觉得 AR 其实并没有那么遥远。在此基础之上的 React Native 封装react-native-arkit,让我们通过 JS 就拥有操作 ARKit 的能力。这的确是一个快速上手 ARKit 的方式。 5 更多讨论讨论地址是:精读《快速上手构建 ARKit 应用》 · Issue ##70 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周末发布。"},{"title":"《怎么用 React Hooks 造轮子》","path":"/wiki/WebWeekly/前沿技术/《怎么用 React Hooks 造轮子》.html","content":"当前期刊数: 80 1 引言上周的 精读《React Hooks》 已经实现了对 React Hooks 的基本认知,也许你也看了 React Hooks 基本实现剖析(单向链表),但理解实现原理就可以用好了吗?学的是知识,而用的是技能,看别人的用法就像刷抖音一样(哇,饭还可以这样吃?),你总会有新的收获。 这篇文章将这些知识实践起来,看看广大程序劳动人民是如何发掘 React Hooks 的潜力的(造什么轮子)。 首先,站在使用角度,要理解 React Hooks 的特点是 “非常方便的 Connect 一切”,所以无论是数据流、Network,或者是定时器都可以监听,有一点 RXJS 的意味,也就是你可以利用 React Hooks,将 React 组件打造成:任何事物的变化都是输入源,当这些源变化时会重新触发 React 组件的 render,你只需要挑选组件绑定哪些数据源(use 哪些 Hooks),然后只管写 render 函数就行了! 2 精读参考了部分 React Hooks 组件后,笔者按照功能进行了一些分类。 由于 React Hooks 并不是非常复杂,所以就不按照技术实现方式去分类了,毕竟技术总有一天会熟练,而且按照功能分类才有持久的参考价值。 DOM 副作用修改 / 监听做一个网页,总有一些看上去和组件关系不大的麻烦事,比如修改页面标题(切换页面记得改成默认标题)、监听页面大小变化(组件销毁记得取消监听)、断网时提示(一层层装饰器要堆成小山了)。而 React Hooks 特别擅长做这些事,造这种轮子,大小皆宜。 由于 React Hooks 降低了高阶组件使用成本,那么一套生命周期才能完成的 “杂耍” 将变得非常简单。 下面举几个例子: 修改页面 title效果:在组件里调用 useDocumentTitle 函数即可设置页面标题,且切换页面时,页面标题重置为默认标题 “前端精读”。 useDocumentTitle("个人中心"); 实现:直接用 document.title 赋值,不能再简单。在销毁时再次给一个默认标题即可,这个简单的函数可以抽象在项目工具函数里,每个页面组件都需要调用。 function useDocumentTitle(title) { useEffect( () => { document.title = title; return () => (document.title = "前端精读"); }, [title] );} 在线 Demo 监听页面大小变化,网络是否断开效果:在组件调用 useWindowSize 时,可以拿到页面大小,并且在浏览器缩放时自动触发组件更新。 const windowSize = useWindowSize();return <div>页面高度:{windowSize.innerWidth}</div>; 实现:和标题思路基本一致,这次从 window.innerHeight 等 API 直接拿到页面宽高即可,注意此时可以用 window.addEventListener('resize') 监听页面大小变化,此时调用 setValue 将会触发调用自身的 UI 组件 rerender,就是这么简单! 最后注意在销毁时,removeEventListener 注销监听。 function getSize() { return { innerHeight: window.innerHeight, innerWidth: window.innerWidth, outerHeight: window.outerHeight, outerWidth: window.outerWidth };}function useWindowSize() { let [windowSize, setWindowSize] = useState(getSize()); function handleResize() { setWindowSize(getSize()); } useEffect(() => { window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); }; }, []); return windowSize;} 在线 Demo 动态注入 css效果:在页面注入一段 class,并且当组件销毁时,移除这个 class。 const className = useCss({ color: "red"});return <div className={className}>Text.</div>; 实现:可以看到,Hooks 方便的地方是在组件销毁时移除副作用,所以我们可以安心的利用 Hooks 做一些副作用。注入 css 自然不必说了,而销毁 css 只要找到注入的那段引用进行销毁即可,具体可以看这个 代码片段。 DOM 副作用修改 / 监听场景有一些现成的库了,从名字上就能看出来用法:document-visibility、network-status、online-status、window-scroll-position、window-size、document-title。 组件辅助Hooks 还可以增强组件能力,比如拿到并监听组件运行时宽高等。 获取组件宽高效果:通过调用 useComponentSize 拿到某个组件 ref 实例的宽高,并且在宽高变化时,rerender 并拿到最新的宽高。 const ref = useRef(null);let componentSize = useComponentSize(ref);return ( <> {componentSize.width} <textArea ref={ref} /> </>); 实现:和 DOM 监听类似,这次换成了利用 ResizeObserver 对组件 ref 进行监听,同时在组件销毁时,销毁监听。 其本质还是监听一些副作用,但通过 ref 的传递,我们可以对组件粒度进行监听和操作了。 useLayoutEffect(() => { handleResize(); let resizeObserver = new ResizeObserver(() => handleResize()); resizeObserver.observe(ref.current); return () => { resizeObserver.disconnect(ref.current); resizeObserver = null; };}, []); 在线 Demo,对应组件 component-size。 拿到组件 onChange 抛出的值效果:通过 useInputValue() 拿到 Input 框当前用户输入的值,而不是手动监听 onChange 再腾一个 otherInputValue 和一个回调函数把这一堆逻辑写在无关的地方。 let name = useInputValue("Jamie");// name = { value: 'Jamie', onChange: [Function] }return <input {...name} />; 可以看到,这样不仅没有占用组件自己的 state,也不需要手写 onChange 回调函数进行处理,这些处理都压缩成了一行 use hook。 实现:读到这里应该大致可以猜到了,利用 useState 存储组件的值,并抛出 value 与 onChange,监听 onChange 并通过 setValue 修改 value, 就可以在每次 onChange 时触发调用组件的 rerender 了。 function useInputValue(initialValue) { let [value, setValue] = useState(initialValue); let onChange = useCallback(function(event) { setValue(event.currentTarget.value); }, []); return { value, onChange };} 这里要注意的是,我们对组件增强时,组件的回调一般不需要销毁监听,而且仅需监听一次,这与 DOM 监听不同,因此大部分场景,我们需要利用 useCallback 包裹,并传一个空数组,来保证永远只监听一次,而且不需要在组件销毁时注销这个 callback。 在线 Demo,对应组件 input-value。 做动画利用 React Hooks 做动画,一般是拿到一些具有弹性变化的值,我们可以将值赋给进度条之类的组件,这样其进度变化就符合某种动画曲线。 在某个时间段内获取 0-1 之间的值这个是动画最基本的概念,某个时间内拿到一个线性增长的值。 效果:通过 useRaf(t) 拿到 t 毫秒内不断刷新的 0-1 之间的数字,期间组件会不断刷新,但刷新频率由 requestAnimationFrame 控制(不会卡顿 UI)。 const value = useRaf(1000); 实现:写起来比较冗长,这里简单描述一下。利用 requestAnimationFrame 在给定时间内给出 0-1 之间的值,那每次刷新时,只要判断当前刷新的时间点占总时间的比例是多少,然后做分母,分子是 1 即可。 在线 Demo,对应组件 use-raf。 弹性动画效果:通过 useSpring 拿到动画值,组件以固定频率刷新,而这个动画值以弹性函数进行增减。 实际调用方式一般是,先通过 useState 拿到一个值,再通过动画函数包住这个值,这样组件就会从原本的刷新一次,变成刷新 N 次,拿到的值也随着动画函数的规则变化,最后这个值会稳定到最终的输入值(如例子中的 50)。 const [target, setTarget] = useState(50);const value = useSpring(target);return <div onClick={() => setTarget(100)}>{value}</div>; 实现:为了实现动画效果,需要依赖 rebound 库,它可以实现将一个目标值拆解为符合弹性动画函数过程的功能,那我们需要利用 React Hooks 做的就是在第一次接收到目标值是,调用 spring.setEndValue 来触发动画事件,并在 useEffect 里做一次性监听,再值变时重新 setValue 即可。 最神奇的 setTarget 联动 useSpring 重新计算弹性动画部分,是通过 useEffect 第二个参数实现的: useEffect( () => { if (spring) { spring.setEndValue(targetValue); } }, [targetValue]); 也就是当目标值变化后,才会进行新的一轮 rerender,所以 useSpring 并不需要监听调用处的 setTarget,它只需要监听 target 的变化即可,而巧妙利用 useEffect 的第二个参数可以事半功倍。 在线 Demo Tween 动画明白了弹性动画原理,Tween 动画就更简单了。 效果:通过 useTween 拿到一个从 0 变化到 1 的值,这个值的动画曲线是 tween。可以看到,由于取值范围是固定的,所以我们不需要给初始值了。 const value = useTween(); 实现:通过 useRaf 拿到一个线性增长的值(区间也是 0 ~ 1),再通过 easing 库将其映射到 0 ~ 1 到值即可。这里用到了 hook 调用 hook 的联动(通过 useRaf 驱动 useTween),还可以在其他地方举一反三。 const fn: Easing = easing[easingName];const t = useRaf(ms, delay);return fn(t); 发请求利用 Hooks,可以将任意请求 Promise 封装为带有标准状态的对象:loading、error、result。 通用 Http 封装效果:通过 useAsync 将一个 Promise 拆解为 loading、error、result 三个对象。 const { loading, error, result } = useAsync(fetchUser, [id]); 实现:在 Promise 的初期设置 loading,结束后设置 result,如果出错则设置 error,这里可以将请求对象包装成 useAsyncState 来处理,这里就不放出来了。 export function useAsync(asyncFunction: any, params: any[]) { const asyncState = useAsyncState(options); useEffect(() => { const promise = asyncFunction(); asyncState.setLoading(); promise.then( result => asyncState.setResult(result);, error => asyncState.setError(error); ); }, params);} 具体代码可以参考 react-async-hook,这个功能建议仅了解原理,具体实现因为有一些边界情况需要考虑,比如组件 isMounted 后才能相应请求结果。 Request Service业务层一般会抽象一个 request service 做统一取数的抽象(比如统一 url,或者可以统一换 socket 实现等等)。假如以前比较 low 的做法是: async componentDidMount() { // setState: 改 isLoading state try { const data = await fetchUser() // setState: 改 isLoading、error、data } catch (error) { // setState: 改 isLoading、error }} 后来把请求放在 redux 里,通过 connect 注入的方式会稍微有些改观: @Connect(...)class App extends React.PureComponent { public componentDidMount() { this.props.fetchUser() } public render() { // this.props.userData.isLoading | error | data }} 最后会发现还是 Hooks 简洁明了: function App() { const { isLoading, error, data } = useFetchUser();} 而 useFetchUser 利用上面封装的 useAsync 可以很容易编写: const fetchUser = id => fetch(`xxx`).then(result => { if (result.status !== 200) { throw new Error("bad status = " + result.status); } return result.json(); });function useFetchUser(id) { const asyncFetchUser = useAsync(fetchUser, [id]); return asyncFetchUser;} 填表单React Hooks 特别适合做表单,尤其是 antd form 如果支持 Hooks 版,那用起来会方便许多: function App() { const { getFieldDecorator } = useAntdForm(); return ( <Form onSubmit={this.handleSubmit} className="login-form"> <FormItem> {getFieldDecorator("userName", { rules: [{ required: true, message: "Please input your username!" }] })( <Input prefix={<Icon type="user" style={{ color: "rgba(0,0,0,.25)" }} />} placeholder="Username" /> )} </FormItem> <FormItem> <Button type="primary" htmlType="submit" className="login-form-button"> Log in </Button> Or <a href="">register now!</a> </FormItem> </Form> );} 不过虽然如此,getFieldDecorator 还是基于 RenderProps 思路的,彻底的 Hooks 思路是利用之前说的 组件辅助方式,提供一个组件方法集,用解构方式传给组件。 Hooks 思维的表单组件效果:通过 useFormState 拿到表单值,并且提供一系列 组件辅助 方法控制组件状态。 const [formState, { text, password }] = useFormState();return ( <form> <input {...text("username")} required /> <input {...password("password")} required minLength={8} /> </form>); 上面可以通过 formState 随时拿到表单值,和一些校验信息,通过 password("pwd") 传给 input 组件,让这个组件达到受控状态,且输入类型是 password 类型,表单 key 是 pwd。而且可以看到使用的 form 是原生标签,这种表单增强是相当解耦的。 实现:仔细观察一下结构,不难发现,我们只要结合 组件辅助 小节说的 “拿到组件 onChange 抛出的值” 一节的思路,就能轻松理解 text、password 是如何作用于 input 组件,并拿到其输入状态。 往简单的来说,只要把这些状态 Merge 起来,通过 useReducer 聚合到 formState 就可以实现了。 为了简化,我们只考虑对 input 的增强,源码仅需 30 几行: export function useFormState(initialState) { const [state, setState] = useReducer(stateReducer, initialState || {}); const createPropsGetter = type => (name, ownValue) => { const hasOwnValue = !!ownValue; const hasValueInState = state[name] !== undefined; function setInitialValue() { let value = ""; setState({ [name]: value }); } const inputProps = { name, // 给 input 添加 type: text or password get value() { if (!hasValueInState) { setInitialValue(); // 给初始化值 } return hasValueInState ? state[name] : ""; // 赋值 }, onChange(e) { let { value } = e.target; setState({ [name]: value }); // 修改对应 Key 的值 } }; return inputProps; }; const inputPropsCreators = ["text", "password"].reduce( (methods, type) => ({ ...methods, [type]: createPropsGetter(type) }), {} ); return [ { values: state }, // formState inputPropsCreators ];} 上面 30 行代码实现了对 input 标签类型的设置,监听 value onChange,最终聚合到大的 values 作为 formState 返回。读到这里应该发现对 React Hooks 的应用都是万变不离其宗的,特别是对组件信息的获取,通过解构方式来做,Hooks 内部再做一下聚合,就完成表单组件基本功能了。 实际上一个完整的轮子还需要考虑 checkbox radio 的兼容,以及校验问题,这些思路大同小异,具体源码可以看 react-use-form-state。 模拟生命周期有的时候 React15 的 API 还是挺有用的,利用 React Hooks 几乎可以模拟出全套。 componentDidMount效果:通过 useMount 拿到 mount 周期才执行的回调函数。 useMount(() => { // quite similar to `componentDidMount`}); 实现:componentDidMount 等价于 useEffect 的回调(仅执行一次时),因此直接把回调函数抛出来即可。 useEffect(() => void fn(), []); componentWillUnmount效果:通过 useUnmount 拿到 unmount 周期才执行的回调函数。 useUnmount(() => { // quite similar to `componentWillUnmount`}); 实现:componentWillUnmount 等价于 useEffect 的回调函数返回值(仅执行一次时),因此直接把回调函数返回值抛出来即可。 useEffect(() => fn, []); componentDidUpdate效果:通过 useUpdate 拿到 didUpdate 周期才执行的回调函数。 useUpdate(() => { // quite similar to `componentDidUpdate`}); 实现:componentDidUpdate 等价于 useMount 的逻辑每次执行,除了初始化第一次。因此采用 mouting flag(判断初始状态)+ 不加限制参数确保每次 rerender 都会执行即可。 const mounting = useRef(true);useEffect(() => { if (mounting.current) { mounting.current = false; } else { fn(); }}); Force Update效果:这个最有意思了,我希望拿到一个函数 update,每次调用就强制刷新当前组件。 const update = useUpdate(); 实现:我们知道 useState 下标为 1 的项是用来更新数据的,但数据必须有变化才会触发 render,因此我们可以这样设计: const useUpdate = () => { const [, setState] = useState(0); return () => setState(cnt => cnt + 1);}; 或者利用 useReducer 做一个简单的 Action 来支持: const [, forceRender] = useReducer(s => s + 1, 0); 感谢:感谢用户 cike8899 对此处的勘误,并提供示例代码。 对于 getSnapshotBeforeUpdate, getDerivedStateFromError, componentDidCatch 目前 Hooks 是无法模拟的。 isMounted很久以前 React 是提供过这个 API 的,后来移除了,原因是可以通过 componentWillMount 和 componentWillUnmount 推导。自从有了 React Hooks,支持 isMount 简直是分分钟的事。 效果:通过 useIsMounted 拿到 isMounted 状态。 const isMounted = useIsMounted(); 实现:看到这里的话,应该已经很熟悉这个套路了,useEffect 第一次调用时赋值为 true,组件销毁时返回 false,注意这里可以加第二个参数为空数组来优化性能。 const [isMount, setIsMount] = useState(false);useEffect(() => { if (!isMount) { setIsMount(true); } return () => setIsMount(false);}, []);return isMount; 在线 Demo 存数据上一篇提到过 React Hooks 内置的 useReducer 可以模拟 Redux 的 reducer 行为,那唯一需要补充的就是将数据持久化。我们考虑最小实现,也就是全局 Store + Provider 部分。 全局 Store效果:通过 createStore 创建一个全局 Store,再通过 StoreProvider 将 store 注入到子组件的 context 中,最终通过两个 Hooks 进行获取与操作:useStore 与 useAction: const store = createStore({ user: { name: "小明", setName: (state, payload) => { state.name = payload; } }});const App = () => ( <StoreProvider store={store}> <YourApp /> </StoreProvider>);function YourApp() { const userName = useStore(state => state.user.name); const setName = userAction(dispatch => dispatch.user.setName);} 实现:这个例子的实现可以单独拎出一篇文章了,所以笔者从存数据的角度剖析一下 StoreProvider 的实现。 对,Hooks 并不解决 Provider 的问题,所以全局状态必须有 Provider,但这个 Provider 可以利用 React 内置的 createContext 简单搞定: const StoreContext = createContext();const StoreProvider = ({ children, store }) => ( <StoreContext.Provider value={store}>{children}</StoreContext.Provider>); 剩下就是 useStore 怎么取到持久化 Store 的问题了,这里利用 useContext 和刚才创建的 Context 对象: const store = useContext(StoreContext);return store; 更多源码可以参考 easy-peasy,这个库基于 redux 编写,提供了一套 Hooks API。 封装原有库是不是 React Hooks 出现后,所有的库都要重写一次?当然不是,我们看看其他库如何做改造。 RenderProps to Hooks这里拿 react-powerplug 举例。 比如有一个 renderProps 库,希望改造成 Hooks 的用法: import { Toggle } from 'react-powerplug'function App() { return ( <Toggle initial={true}> {({ on, toggle }) => ( <Checkbox checked={on} onChange={toggle} /> )} </Toggle> )}↓ ↓ ↓ ↓ ↓ ↓import { useToggle } from 'react-powerhooks'function App() { const [on, toggle] = useToggle() return <Checkbox checked={on} onChange={toggle} />} 效果:假如我是 react-powerplug 的维护者,怎么样最小成本支持 React Hook? 说实话这个没办法一步做到,但可以通过两步实现。 export function Toggle() { // 这是 Toggle 的源码 // balabalabala..}const App = wrap(() => { // 第一步:包 wrap const [on, toggle] = useRenderProps(Toggle); // 第二步:包 useRenderProps}); 实现:首先解释一下为什么要包两层,首先 Hooks 必须遵循 React 的规范,我们必须写一个 useRenderProps 函数以符合 Hooks 的格式,那问题是如何拿到 Toggle 给 render 的 on 与 toggle?正常方式应该拿不到,所以退而求其次,将 useRenderProps 拿到的 Toggle 传给 wrap,让 wrap 构造 RenderProps 执行环境拿到 on 与 toggle 后,调用 useRenderProps 内部的 setArgs 函数,让 const [on, toggle] = useRenderProps(Toggle) 实现曲线救国。 const wrappers = []; // 全局存储 wrappersexport const useRenderProps = (WrapperComponent, wrapperProps) => { const [args, setArgs] = useState([]); const ref = useRef({}); if (!ref.current.initialized) { wrappers.push({ WrapperComponent, wrapperProps, setArgs }); } useEffect(() => { ref.current.initialized = true; }, []); return args; // 通过下面 wrap 调用 setArgs 获取值。}; 由于 useRenderProps 会先于 wrap 执行,所以 wrappers 会先拿到 Toggle,wrap 执行时直接调用 wrappers.pop() 即可拿到 Toggle 对象。然后构造出 RenderProps 的执行环境即可: export const wrap = FunctionComponent => props => { const element = FunctionComponent(props); const ref = useRef({ wrapper: wrappers.pop() }); // 拿到 useRenderProps 提供的 Toggle const { WrapperComponent, wrapperProps } = ref.current.wrapper; return createElement(WrapperComponent, wrapperProps, (...args) => { // WrapperComponent => Toggle,这一步是在构造 RenderProps 执行环境 if (!ref.current.processed) { ref.current.wrapper.setArgs(args); // 拿到 on、toggle 后,通过 setArgs 传给上面的 args。 ref.current.processed = true; } else { ref.current.processed = false; } return element; });}; 以上实现方案参考 react-hooks-render-props,有需求要可以拿过来直接用,不过实现思路可以参考,作者的脑洞挺大。 Hooks to RenderProps好吧,如果希望 Hooks 支持 RenderProps,那一定是希望同时支持这两套语法。 效果:一套代码同时支持 Hooks 和 RenderProps。 实现:其实 Hooks 封装为 RenderProps 最方便,因此我们使用 Hooks 写核心的代码,假设我们写一个最简单的 Toggle: const useToggle = initialValue => { const [on, setOn] = useState(initialValue); return { on, toggle: () => setOn(!on) };}; 在线 Demo 然后通过 render-props 这个库可以轻松封装出 RenderProps 组件: const Toggle = ({ initialValue, children, render = children }) => renderProps(render, useToggle(initialValue)); 在线 Demo 其实 renderProps 这个组件的第二个参数,在 Class 形式 React 组件时,接收的是 this.state,现在我们改成 useToggle 返回的对象,也可以理解为 state,利用 Hooks 机制驱动 Toggle 组件 rerender,从而让子组件 rerender。 封装原本对 setState 增强的库Hooks 也特别适合封装原本就作用于 setState 的库,比如 immer。 useState 虽然不是 setState,但却可以理解为控制高阶组件的 setState,我们完全可以封装一个自定义的 useState,然后内置对 setState 的优化。 比如 immer 的语法是通过 produce 包装,将 mutable 代码通过 Proxy 代理为 immutable: const nextState = produce(baseState, draftState => { draftState.push({ todo: "Tweet about it" }); draftState[1].done = true;}); 那这个 produce 就可以通过封装一个 useImmer 来隐藏掉: function useImmer(initialValue) { const [val, updateValue] = React.useState(initialValue); return [ val, updater => { updateValue(produce(updater)); } ];} 使用方式: const [value, setValue] = useImmer({ a: 1 });value(obj => (obj.a = 2)); // immutable 3 总结本文列出了 React Hooks 的以下几种使用方式以及实现思路: DOM 副作用修改 / 监听。 组件辅助。 做动画。 发请求。 填表单。 模拟生命周期。 存数据。 封装原有库。 欢迎大家的持续补充。 4 更多讨论 讨论地址是:精读《怎么用 React Hooks 造轮子》 · Issue ##112 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《我不再使用高阶组件》","path":"/wiki/WebWeekly/前沿技术/《我不再使用高阶组件》.html","content":"当前期刊数: 31 本期精读的文章是:我不再使用高阶组件。 懒得看文章?没关系,稍后会附上文章内容概述,同时,更希望能通过阅读这一期的精读,穿插着深入阅读原文。 1 引言 React 与 Vue 相比,组件为一等公民是最大特色之一。 由于组件可以作为一个 props 向下传递,因此 React 具备了高度抽象化的能力,Vue 虽然更易上手,但因 template 特点,没有所谓 props 传递组件这种概念,但这样导致在抽象能力上落后于 React。可能这是 JSX 与 template 之间的差异吧,也是变量与字符串之间的差异,变量同名但含义不同,所以可抽象,而模版靠规则和名称确定含义。 当然 Vue 也有 babel-plugin-transform-vue-jsx 这里不做展开。 强大的组件能力,导致了实践的多样性,高阶组件就是其一。高阶组件的特点是,JSX 描述的子元素,会注入到父级组件的 this.props.children 中,因此可以无入侵增强组件能力,常用比如权限、跳转、埋点、异常、描述、注入等等。 高阶组件也带来了使用中的困扰,作者这篇文章阐述了高阶组件存在的问题,值得我们了解。 2 内容概要高阶组件由于可嵌套,如果有一环高阶组件没有将内部 wrappedComponent 暴露出来,会导致后续叠加的高阶组件都无法获取、注入到原始组件。 另外就算所有高阶组件都遵循了规范,组件也难以察觉被注入的数据是由哪些高阶组件提供的,而且高阶组件之间互相隔离,导致可能存在覆盖 props 的危险情况,这些问题高阶组件都束手无策。这体现出约定比约束更加效率,但约定的可维护性低于约束。 因此更好的解决思路可能是叫做 render props render callback function as child 这些名字的方法,组件定义如下: // Contrived example for simplicityimport React, { Component } from 'react';import PropTypes from 'prop-types';class Caffeinate extends Component { propTypes = { children: PropTypes.func.isRequired }; state = { coffee: "Americano" }; render() { return this.props.children(this.state.coffee); }} 直接将函数作为子元素,可以认为是一个匿名组件: render( <Caffeinate> {(beverage) => <div>Drinking an {beverage}.</div>} </Caffeinate>, document.querySelector("##root"));//=> Drinking an Americano. 这种用法在 React Motion React Router 里都有采用。 3 精读本质是将组件作为参数我们看另一种写法: import React, { Component } from 'react';import PropTypes from 'prop-types';class Caffeinate extends Component { propTypes = { children: PropTypes.func.isRequired }; state = { coffee: "Americano" }; render() { return this.props.child(this.state.coffee); }}// usagerender( <Caffeinate child={ (beverage) => <div>Drinking an {beverage}.</div> } />, document.querySelector("##root")); render props 本质上与上面这种很常规的写法没什么不同,差异在于利用了 props.children,将参数写在 JSX 的子元素中。相比高阶组件用法,这样嵌套下来,看得清楚数据流动,解决了高阶组件反复嵌套导致的各类问题: render( <RenderProps1> {(title) => <div> <h1>{title}</h1> <RenderProps2> {(name) => <RenderProps3> {(age) => { <div>{name}, {age}</div> }} </RenderProps3> } </RenderProps2> </div>} </RenderProps1>) 与高阶组件对比与 HOC 相比,render props 开放性提升明显,原本 HOC 所做的功能抽象可通过 render Props 获取,而 render 也可以访问到父级的一切: Render Props 存在的问题 this.props.children 不该作为函数调用。 渲染粒度变大,表格等需要性能优化的场景不适合。 renderProps 渲染的并不是 React 组件,无法为其单独使用 redux,mobx dob 等依赖收集粒度也放不下去。 renderProps 为了解耦,让控制权从上到下传递,而底层实现不需要了解上层实现,这是解决 JSX 修改组件模版问题的方法之一,作为优化点之一,可以考虑让传入的 props 自身作为一个组件: const View = ({title}) => <div>{title}</div>// ...render() { return ( <Component view={View} /> )} 简约即美与其绕那么大一圈,还不如回归到最普通的 props 传参,这说明 renderProps 作为其中一种特例,可能观赏价值大于其实用价值。其控制放权的思想也是为了解决组件 dom 结构定制化的问题。 但是这也涉及到限度的问题,以下就是两种极端: render() { return this.props.children} render() { return ( <div> <Header /> <Sidebar> <Toolbox> <ul> <li>..</li> <li>{this.props.secondLi}</li> <li>..</li> </ul> </Toolbox> </Sidebar> <Footer /> </div> )} 可以看出,写出这两种代码的目的,都为了从外部控制组件结构,以至于最大限度提高组件的复用能力。其实很难在不了解组件自身含义时,妄下一个通用的结论,说 “你只要这么写,就能保证任何组件都非常通用”。 比如 Card 组件可以将 title extra 设定为 ReactNode,加上 children,其实用性已经足够了: render() { return ( <Container> <Title> {this.props.title} {this.props.extra} </Title> <Body> {this.props.children} </Body> </Container> )} 再比如 Modal 也只需要对 Header Footer children 支持 ReactNode 就可以保证足够的通用性。 在业务场景,由于代码修改频率较高,复用性重要程度就没那么高。 4. 总结作者也提到了,高阶组件在某些场景很有用,所以不会完全拒绝使用。 在不为组件做注入的场景下是高阶组件的好场景,利用其生命周期实现权限、埋点,在层级少的时候用作依赖注入也非常方便。 其实程序员在思考这些最佳实践时,与艺术家的思考方式很类似,况且这些最佳实践在不同场景、不同团队,不同项目下都有所侧重,所以不用逮着所谓最完美的实践把代码全部重构,以后也全部用一种风格写代码。就像陶瓷艺术家也不会说:我再也不做彩瓷了,因为白瓷这种颜色非常简约,在我心中是完美的,因此我宁愿一辈子只做白瓷。 这一期也想表达一个积极含义,精读周刊是不会 give up 的! 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《持续集成 vs 持续交付 vs 持续部署》","path":"/wiki/WebWeekly/前沿技术/《持续集成 vs 持续交付 vs 持续部署》.html","content":"当前期刊数: 101 一、摘要相信大家以前应该接触过持续集成(Continuous integration)持续交付(continuous delivery)持续发布(continuous deployment)的概念,下面我们来说说三者的差异以及团队如何入手 CI/CD。 作者:猫神。 二、差异2.1 CI 持续集成开发者尽量时时刻刻合并开发分支至主干分支。避免直到发布日才开始合并,掉入集成地狱。无论何时新分支集成至项目,持续集成可以自动化测试持续验证应用是否正常。 2.2 CD 持续交付持续交付是持续集成的扩展,可以保证稳定的发布产品新特性。这意味着基于自动化测试,你可以也可以一键自动化发布。理论上,持续交付可以决定是按天,按周,按双周发布产品。如果确实希望能够享受持续交付的好处,那么应该尽快发布到新产品中。一旦出现问题时能尽早排除。 2.3 CD 持续部署持续部署是持续交付的下一步。通过这一步,每个新特性都自动的部署到产品中。但是如果出现未通过的测试用例将会终止自动部署。持续部署可以加速用户反馈新特性,避免发布日带来的压力。开发可以着力于开发系统,开发结束后几分钟就可以触达到用户。 三、协作CI/CD 具体是个什么样的流程呢,如下图所示,差异仅在于是否自动部署。 现在开发都讲究投入产出比,那么 CI/CD 具体需要做些什么呢? Continuous Intergretion 持续集成投入: 需要为每个新特性编写测试用例 需要搭建持续集成服务器,监控主干仓库,并自动运行测试用例 开发需要尽量频繁的合并分支,至少一天一次 产出: 更少的 bug,因为自动化测试可以回归测试产品 编译部署产品更简化,因为集成的问题都尽早的解决了 开发者可以尽量减少上下文切换,因为构建的时候就暴露问题,尽早解决了 测试成本降低,因为 CI 服务器可以一秒运行几百个测试用例 测试团队花更少的时间测试,可以重点关注测试上的改进。 Continuous delivery 持续交付投入: 需要有持续集成的基础,测试用例需要覆盖足够的代码 部署需要自动化,用户只需要手动触发,剩余的部署应该自动化 团队需要增加新特性标志,避免未完成的新特性进入待发布的产品 产出: 部署软件变得非常简单。团队不需要花费 n 天准备发布。 可以提高发布频率,加速新特性触达用户进程。 小的更改,对决策的压力要小得多,可以更快地迭代。 Continuous deployment 持续部署投入: 测试必须要做到足够。测试的质量将决定发布的质量。 文档建设需要和产品部署保持同步。 新特性的发布需要协调其他部门,包括售后支持&市场&推广等。 产出: 快速的发布节奏,因为每个新特性一旦完成都会自动的发布给用户。 发布风险降低,修复问题更容易,因为每次变更都是小步迭代发布。 用户可以看到持续性的优化和质量提升,而不是非要等到按月,按季度,甚至按年 如果开发的是一个新项目,暂时还没有任何用户,那么每次提交代码后发布将会特别简单,可以随时随地发布。一旦产品开始开发后,就需要提高测试文化,并确保在构建应用程序时增加代码覆盖率。当您准备好面向用户发布时,您将有一个非常好的连续部署过程,在该过程中,所有新的更改都将在自动发布到生产环境之前进行测试。 如果正在开发的是一个老系统,就需要放慢节奏,开始打造持续集成&持续交付。首先可以完成一些简单可自动化执行的单元测试,不需要考虑复杂的端到端的测试。另外,应该尽快尝试自动化部署,搭建可以自动化部署的临时环境。因为自动化部署,可以让开发者去优化测试用例,而不是停下来联调发布。一旦开始按日发布产品,我们可以考虑持续部署,但一定要保证团队已经准备好这种方式,文档 & 售后支持 & 市场。这些步骤都需要加入到新产品发布节奏中,因为和用户直接打交道的是他们。 四、如何开始持续集成4.1 了解测试类型为了获得 CI 的所有好处,每次代码变更后,我们需要自动运行测试用例。我们需要在每个分支运行测试用例,而不是仅仅在主干分支。这样可以最快速的找到问题,最小化问题影响面。在初始阶段并不需要实现所有的测试类型。一开始可以以单元测试入手,随着时间扩展覆盖面。 单元测试:范围非常小,验证每个独立方法级别的操作。 集成测试:保证模块间运行正常,包括多个模块、多个服务。 验收测试:与集成测试类似,但是仅关注业务 case,而不是模块内部本身。 UI 测试:从用户的角度保证呈现正确运行。 并不是所有的测试都是对等的,实际运行中可以做些取舍。 单元测试实现起来既快成本又低,因为它们主要是对小代码块进行检查。另一方面,UI 测试实施起来很复杂,运行起来很慢,因为它们通常需要启动一个完整的环境以及多个服务来模拟浏览器或移动行为。因此,实际情况可能希望限制复杂的 UI 测试的数量,并依赖基础上良好的单元测试来快速构建,并尽快获得开发人员的反馈。 4.2 自动运行测试要采用持续集成,您需要对推回到主分支的每个变更运行测试。要做到这一点,您需要有一个服务来监视您的存储库,并听取对代码库的新推送。您可以从企业预置型解决方案和云端解决方案中进行选择。您需要考虑以下因素来选择服务器: 代码托管在哪里?CI 服务可以访问您的代码库吗?您对代码的生存位置有特殊的限制吗? 应用程序需要哪些操作系统和资源?应用程序环境是否受支持?能安装正确的依赖项来构建和测试软件吗? 测试需要多少资源?一些云应用程序可能对您可以使用的资源有限制。如果软件消耗大量资源,可能希望将 CI 服务器宿主在防火墙后面。 团队中有多少开发人员?当团队实践 CI 时,每天都会将许多更改推回到主存储库中。对于开发人员来说,要获得快速的反馈,您需要减少构建的队列时间,并且您需要使用能够提供正确并发性的服务或服务器。在过去,通常需要安装一个独立的 CI 服务器,如 Bamboo 或 Jenkins,但现在您可以在云端找到更简单的解决方案。例如,如果您的代码托管在 BitBucket 云上,那么您可以使用存储库中的 Pipelines 功能在每次推送时运行测试,而无需配置单独的服务器或构建代理,也无需限制并发性。 使用代码覆盖率查找未测试的代码。一旦您采用了自动化测试,最好将它与一个测试覆盖工具结合起来,帮助了解测试套件覆盖了多少代码库。代码覆盖率定在 80%以上是很好的,但要注意不要将高覆盖率与良好的测试套件混淆。代码覆盖工具将帮助您找到未经测试的代码,但在一天结束的时候,测试的质量会产生影响。如果刚开始,不要急于获得代码库的 100%覆盖率,而是使用测试覆盖率工具来找出应用程序的关键部分,这些部分还没有测试并从那里开始。 重构是一个添加测试的机会。如果您将要对应用程序进行重大更改,那么应该首先围绕可能受到影响的特性编写验收测试。这将为您提供一个安全网,以确保在重构代码或添加新功能后,原始行为不会受到影响。 五、接受 CI 文化自动化测试是 CI 的关键,但同时也需要团队成员接受 CI 文化,并不是心血来潮晒两天鱼,并且需要保证编译畅通无阻。QA 可以帮助团队建设测试文化。他们不再需要手动测试应用程序的琐碎功能,现在他们可以投入更多的时间来提供支持开发人员的工具,并帮助他们采用正确的测试策略。一旦开始采用持续集成,QA 工程师将能够专注于使用更好的工具和数据集促进测试,并帮助开发人员提高编写更好代码的能力。 尽早集成。如果很长时间不合并代码,代码冲突的风险就越高,代码冲突的范围就越广。如果发现某些分支会影响已经存在的分支,需要增加发布关闭标签,避免发布时两个分支冲突。 保证编译时时刻刻畅通。一旦发现任何编译问题,立刻修复,否则可能会带来更多的错误。测试套件需要尽快反馈测试结果,或者优先返回短时间测试(单元测试)的结果,否则开发者可能就切换回开发了。一旦编译出错,需要通知给开发者,或者更进一步给出一个 dashboard,每个人都可以在这里查看编译结果。 把测试用例纳入流程的一部分。确保每个分支都有自动化测试用例。似乎编写测试用例拖慢了项目节奏,但是它可以减少回归时间,减少每次迭代带来的 bug。而且每次测试通过后,将会非常有信息合并到主干分支,因为新增的内容不影响以前的功能。 修 bug 的时候编写测试用例。把 bug 的每个场景都编写成测试用例,避免再次出现。 六、集成测试 5 个步骤 从最严格的代码部分入手测试 搭建一个自动构建的服务自动运行测试用例,在每次提交代码后。 确保团队成员每天合并变更 代码出现问题及时修复 为每个新实现的操作编写测试用例。可能看着很简单,但是要求团队能够真正落地。一开始你需要放慢发布的脚步,需要和 pd、用户沟通确保不上线没有测试用例的新功能。我们的建议是从小处入手,通过简单的测试来适应新的例程,然后再着手实现更复杂更难管理的测试套件。 七、说说笔者的团队以上文章主要是说明团队实现 CI/CD 的取舍和可行性步骤。下面来说说希望 CI/CD 给笔者团队带来什么样的变化。目前笔者团队已经实现前端项目发布编译工程化,采用的是基于 webpack 的自建工具云构建模式。但现在面临的问题是 1. 交互的系统比较多,交互系统提供的接入源变更后,需要人工通知其他系统手动触发编译,而且每次手动编译都需要在本地切换到指定分支,然后手动触发云构建,2. 多人协作,分支拆分较细,需要手动合并分支,触发编译。整个流程冗长,而且中间存在人力沟通成本,容易产生沟通误差。所以首先希望解决的是 CI 自动化,当依赖变更后或者分支合并后,自动集成,自动编译。当然生产环境暂时还不敢瞎搞,但大部分重复编译的工作量主要集中在预发环境,所以手动部署生产环境的成本还是可以接受的。CI 自动化之前,需要提供系统之间交互的单元测试用例,每次 CI 后自动运行单元测试用例,最好能打通 QA 的测试用例,进行回归测试。流程对比如下: 可以看出引入CI后,我们的成本是需要搭建CI服务器,新增单元测试、打通回归测试案例,但前者可以加快系统编译效率,后者可以进一步的提升代码质量,减少回归测试时间,这些成本都是可以接受的。市面上已有很多开源持续集成工具,例如我们熟悉的Jenkins,还有TeamCity、Travis CI、GO CD、Bamboo、Gitlab CI、CircleCI……等等等等。目前还在继续调研中,这片文章应该会有第二篇,说说后续的实践和CD。 讨论地址是:精读《持续集成 vs 持续交付 vs 持续部署》 · Issue ##147 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《我们为何弃用 css-in-js》","path":"/wiki/WebWeekly/前沿技术/《我们为何弃用 css-in-js》.html","content":"当前期刊数: 263 emotion 排名第二的维护者 Sam 所在公司弃用了 css-in-js 方案,引起了不小的讨论:Why We’re Breaking Up with CSS-in-JS 概述 & 精读原文很有有条理,先从 css-in-js 优点说起,再转而谈到缺点,说明了 css-in-js 这个新事物拥有明显的优点与缺点;然后从性能问题作为切入点,说明自己所在的公司为什么不得不抛弃 css-in-js;最后告诉读者目前自己的解决方案是 css-modules。 之后还有一点儿延展性思考,即目前还诞生了一批编译时 css-in-js 方案,但面对性能问题时依然徒劳。 让我们花点儿时间了解下作者的具体思路吧。 css-in-js 的优缺点css-in-js 作为一个理念较新的开发思路,拥有如下几个明显的优缺点。 优点: 无全局样式冲突。就像 js 文件天然支持模块化的好处一样,原生 css 因为没有模块化能力,天然容易导致全局样式污染,如果不是特意用 BEM 方式命名,想要避免冲突就只能借助 css-in-js 了。(css-modules 也一样能做到) 与 js 代码合在一起。天然融合进 js 代码方便模块化管理,使 css 可以与某个局部模块绑定。(css-modules 也一样能做到,只是必须单独拆一个样式文件) 能将 js 变量应用到样式上。虽然 css 变量也能解决这个问题,但不如 css-in-js 那么直观,inline-style 也能解决这个问题,但会产生大量重复的局部样式,且这个优势 css-modules 做不到。 缺点: css-in-js 运行时解析的实现版本增加了运行时性能压力,尤其在 React18 调度机制模式下,存在无法解决的性能问题(运行时插入样式会导致 React 渲染暂停,浏览器解析一遍样式,渲染再继续,然后浏览器又解析一遍样式)。 增加了包体积。相比原生或者 css-modules 方案来说,增加了运行时框架代码 8kb 左右。 让 ReactDevTools 结构变得复杂,因为 css-in-js 会包裹额外的 React 组件层用来实现样式插入。 除了上述缺点外,css-in-js 还有三点深度使用后才能察觉的坑: 多个不同(甚至是相同)版本的 css-in-js 库同时加载时可能导致错误。笔者用 styled-components 就遇到了类似问题,甚至语法会产生不兼容的情况,虽然这些问题都可以被解决,但花费的额外时间需要计算一样,相比 css-in-js 得到的收益是否值得。 样式插入优先级无法自定义,这就导致产生样式覆盖时,业务对样式覆盖的优先级无法产生稳定的预期。class 优先级由 header 定义顺序决定,而非 className 的字符顺序决定,而 header 定义顺序又由资源加载与 css-in-js 插入执行时机决定,导致业务几乎不可能有稳定的样式覆盖顺序。这里产生的问题就是业务代码不断增多的 !important 定义。 不同 React 版本的 SSR,css-in-js 需要适配不同的实现,这对框架作者不太友好。 除了性能问题以外,其他问题都可以忍,但偏偏在性能问题上,css-in-js 遇到了无解的场景。 无解的性能问题第一条缺点提到的运行时解析,是 css-in-js 方案永远跨不过去的困境,即便对于编译时 css-in-js 方案来说,也免不了在渲染时做额外的逻辑执行拖慢渲染速度: function App() { return <div css={{ color: "red" }} />; // 就是这种代码导致了性能问题} 原因是当 React 重渲染组件时,需要重新解析样式定义,并序列化 className,当渲染非常频繁时会导致明显的性能瓶颈,而解决方法是把样式定义抽出来,但这样就损失了第三个优点,即无法读取 js 变量了: const myCss = css({ backgroundColor: "blue", width: 100, height: 100,}); 不得不说 React 的渲染机制实在是太有问题了,如果换成 SolidJS 这个问题就好办了,因为运行时的样式代码仅会运行一次,组件重渲染也不会导致这段解析代码被重复执行,此时 css-in-js 在样式变化时再做一次精确样式更新,性能问题就可以被解决了。 换成 css modulescss-modules 同时支持优点一和二,而优点三可以通过一些特定语法糖绕过:通过 :import :export 伪类做 css 变量的导入导出,用 webpack-loader 实现 js 中引用 css 变量,用 css variable 实现 css 引用 js 变量。 所以当性能问题是绕不过去的话题,而 css-modules 在性能最优的情况下,有一些曲线方案可以同时支持 css-in-js 的优点,也就能理解为什么作者要弃用 css-in-js 了。 包体积真的变大了吗原文谈到的 css-in-js 增加了 8~16kb 其实是在强行堆缺点了,除非你的项目只有一行 css 定义。如果我们只考虑传输时的包体积与 HTML 中样式定义数量,而忽略运行时产生的性能负担,那么 css-in-js 在大型项目无疑是最优的。 原因就是 css-in-js 样式是按需插入的,没有渲染的组件就不会插入样式。甚至渲染了的组件也不一定会插入样式,因为 css-in-js 可以对包含相同样式定义的场景做 className 合并,类似于 webpack 打包时,可以把不同模块公共代码抽到一个 chunk 里。 编译时 css-in-js 方案是出路吗理论上是出路,但限制了 css-in-js 的灵活性。从 vanilla-extract 等编译时 css-in-js 框架来看,确实解决了运行时 css-in-js 性能问题,但带来了更多语法限制,比如必须预先定义样式再使用: import { style } from '@vanilla-extract/css'const myStyle = style({ display: 'flex', paddingTop: '3px'})const App = () => <div className={myStyle}/> 编译时 css-in-js 想要做到通用性,只能提供一个 className,这样就不受任何框架和环境的限制了,但这样也限制了声明语法的灵活性,显然不可以用内联方式定义样式。 而且这种编译时的方案本质上和 css-modules 是一样的,背后都是定义了一些静态样式名,只是说这些样式问题以 .sass 定义还是 .ts 定义,如果用 .ts 定义,配合编译工具可以使代码原生 import 的更加舒服。 所以使用了编译时 css-in-js 方案,本质上还是抛弃了运行时 css-in-js,投向了变种的 css-modules 阵营。 总结css-in-js 本身方向是对的,即把 css 与 js 融合,但太过灵活的运行时 css-in-js 方案遇到了几乎不可解的性能问题,编译时的 css-in-js 方案可能是更好的出路。 css-in-js 这个名字本身就表示它拥有 in js 的灵活性,而编译时 css-in-js 方案本质因为是 css-module,所以不可避免拥有一些比较奇怪的限制,如果 js 里的代码不能像真的 js 一样灵活,可能还不如回到 .scss 或者 .less 的后缀更好理解一些。 讨论地址是:精读《我们为何弃用 css-in-js》· Issue ##450 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《我在阿里数据中台大前端》","path":"/wiki/WebWeekly/前沿技术/《我在阿里数据中台大前端》.html","content":"当前期刊数: 134 1 引言当下互联网行业里面最流行的就是 ABC: A: AI 人工智能 B: BIG DATA C: CLOUD 而阿里经济体中的 ABC,其中的 BIG DATA,即是我们 DT https://dt.alibaba.com/ ,我们用大数据赋能商业,创造价值。 而我们说数据中台,其实阿里提出的中台只有两个:业务中台与数据中台。业务中台的目的是让业务能够快速落地,数据中台的目的是完成数据的采集、建设、管理、使用这四个环节,让数据从生产到使用过程变得丝般顺滑,不仅不让数据资产成为累赘,还会最大限度发挥出数据潜藏的价值。 笔者所在的就是数据中台的大前端团队,既为阿里经济体提供数据服务,又着力为上云企业打造属于自己的数据中台,处在前端技术、商业模式、产品设计的最前沿,且听我慢慢道来。 2 精读全链路数据能力从能力上看,数据中台处理数据的方方面面,从数据产生开始就进行追踪,不仅打通了数据采集、存储、处理、查询、消费的全链路,还用以下几种方式赋能业务:研发数据管理平台并监控数据质量,研发生意参谋等数据分析产品直接服务大、中、小商家,提供统一数据服务标准化数据使用流程,将数据分析的算法能力服务化,将支撑内部的数据服务上云搭建客户自己的数据中台,研发 BI 平台完成数据决策的最后一环。 全链路数据技术从技术架构上看,从底层的数据采集技术开始,逐步向上建设了数据计算与管理能力、数据服务、数据平台、数据应用与数据安全。 从使用者角度来看,现在的公司对数据的诉求可以概括为以下几点: 数据从哪来,如何完全数字化:对应全链路数据采集服务。 如何得到想要的数据:数据计算、建模与管理服务。 如何使用数据:统一数据服务平台。 如何利用数据做商业决策:BI 平台。 如何保障数据安全:数据安全服务。 对阿里而言,还会额外考虑下面几点: 如何让数据服务横向支撑所有业务线:数据服务平台化,数据智能化服务平台与 BI 平台。 如何让数据服务普惠到每一个企业:数据服务全面上云。 如何让数据服务更有价值:打通阿里经济体的数据体系,让数据相互产生化学反应。 当然,挑战性也非常大,首先是数据壁垒的挑战,要说服其他团队将数据交给你管理绝非易事。其次是价值挑战,如何证明数据中台存在的价值,并做到肉眼可见的业务增值。最后是技术挑战,对前端来说,几十款数据产品的搭建、几十万张数据报表的搭建,需要一个足够好用的数据产品搭建平台来支持;数据分析产品的下一代探索式分析也对 BI 引擎提出了新的要求;数据可视化远比普通可视化复杂,不仅要考虑大数据下的性能与可读性,还要理解商业,做出能体现数据分析价值的图表。 不论是数据搭建还是数据可视化,都是前端垂直领域的另一条好赛道,不仅有沉甸甸的业务价值,还有全新数据领域的的前端技术挑战,而且随着数据中台影响力的持续扩大,我们的前端技术也会带来业界越来越大的影响力。 如何建设和管理数据想要数据用的好,首先要管的好,在大数据时代,企业必须建立一套自己的标准数仓系统对数据的采集、运维调度做全链路管理,让大数据变成好数据,让好数据可以发挥价值。 Dataphin 数仓建设平台。 数仓的建设需要从物理空间与逻辑空间,也就是底层的表开始整理,通过对数据的采集、清洗、结构化,产出一套规范的数据定义。 所谓规范的数据定义即口径、算法、命名均一致的数据规范,降低数据二义性,提升数据查找效率与准确性。之后对数据建模,建模即是对数据的进一步抽象,可能是抽象为一个 Cube 模型,这样在顶层认知上,所有数据都是不同维度的 Cube,方便统一理解。 最后通过对数据进行在线的、离线的调度计算,产出数据资产。 如何看数据或导出一个 Excel 文件仔细品味,或如双十一媒体大屏般夺目,或如股票操盘手般紧盯着屏幕,或随时随地的手机浏览。在哪看,怎么看,看什么,决定着同一份数据可带来不同的效果,产生不同的价值。 稳:双十一大屏,零点起得来,24 点收得住,每个彩蛋的出现,每个数字的跳动,如丝般顺滑,这不是播放 VCR,每一帧画面都是真实的数据展现。容:即是生意参谋用户的浏览器兼容,又是多端用户的兼容,也是 BI 分析结果的数据大容量。有容乃大,方显前端功底。 “如何看数据” 这恰是做为数据前端人的使命和责任。 不同的人,不同的端,不同的需求,这恰是给数据前端的挑战。而让用户透过数据创造价值,也正是数据前端人的价值。 如何分析数据大数据浪潮之下,必然会诞生各式各样的数据产品,产品化的方式可以降低数据应用的门槛。我们希望人人都能成为数据分析师,于是 BI (商业智能)产品应运而生,作为大数据行业中的一个重要领域,BI 产品用大数据的方式解决了企业的业务分析需求,支撑企业进行数字化转型,从经验驱动决策转变为数据驱动决策,进而给企业带来超额收益。 QuickBI 数据分析工具。 人人都是数据分析师的情况在不断增强。 根据 Gartner 对 2020 年 BI 产品发展趋势预测: 到 2020 年,为用户提供对内部和外部数据策划目录的访问权限的组织将从分析投资中获得两倍的业务价值。 到 2020 年,业务部门的数据和分析专家数量的增速将是 IT 部门专家的 3 倍,这会迫使企业重新考虑其组织模式和技能。 到 2021 年,自然语言处理和会话分析这两个功能,会在新用户、特别是一线工作人员中,将分析和商业智能产品的使用率从 35% 提升到 50% 以上。 快速增涨的市场规模。 根据中国电子信息产业发展研究院发布的《中国大数据产业发展水平评估报告》,预计 2019 年我国大数据核心产业规模突破 5700 亿元,未来 2-3 年的市场规模的增长率仍将保持 35% 左右。未来切入这部分应用环节,BI 商业智能的潜在市场规模将在数百亿的市场空间。 大数据与前端。 前端的职业发展除了提升自己的技能技术储备之外,选择合适行业方向和研究领域也尤为重要。如果用路和车的关系来比喻的话,把前端技能比作车的话,各个行业都是路,有的路是乡间小路,有的路是城乡公路,而大数据行业当之无愧是行业中的上高速公路,路况更好,路面更宽,如果你拥有一辆好车,为什么不来高速公路上飞驰呢? 大数据下的前端面临哪些挑战?以 BI 为例,BI 领域的四大方向:数据集、渲染引擎、数据模型与可视化都有许多可以做深的技术点,每一块都需要深入沉淀几年技术经验才能做好,需要大量优秀人才通力协作才有可能做好。你也可以阅读 精读《前端与 BI》 了解更多 BI 相关知识。 我们是数据中台大前端 “ 前端不是因为我们用 JavaScript,而是因为我们站在业务最前端,解决业务端的问题,所以我们是前端 ”。 BI 分析产品、做数据可视化、做产品搭建 .. 我们早已经跳出了“前端”的传统概念范畴。我们做大数据表格优化、 Web Excel、 SQL 编辑器、智能可视化。在数据中台,我们有着天然的复杂业务场景和海量数据优势,迫使你向自己提出更大的挑战来解决业务上的问题。如果你热爱挑战、热爱技术,请加入我们吧。 在这里,你可以愉快的使用 React、TypesScript 写业务代码,尝试最新、最炫酷的 React Hooks 新特性,我们团队一直走在前端技术路线的最前沿,渴求技术创新。 你也不需要担心伙伴的代码风格问题,因为我们有着严格的代码规;你不必担心每个人的代码都是一座孤岛,因为我们会对每一行代码做严格的 review;你不必担心你的成长空间,我们有定期的技术分享、团队内小竞赛,还有足够复杂的业务场景支撑;你也不必担心你会因工作日渐消瘦,下午茶和海量小零食等你来! 4 总结大数据前端人才缺口在 100 人以上,由于业务增长非常非常迅猛,春节前条件放宽、特批急召! 如果你对我们感兴趣,请立刻把简历发送到邮箱 ziyi.hzy@alibaba-inc.com 吧!绝无仅有的好机会,响应速度绝对超乎你的想象! 讨论地址是:精读《我在阿里数据中台大前端》 · Issue ##224 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《手写 JSON Parser》","path":"/wiki/WebWeekly/前沿技术/《手写 JSON Parser》.html","content":"当前期刊数: 139 1 引言JSON.parse 是浏览器内置的 API,但如果面试官让你实现一个怎么办?好在有人已经帮忙做了这件事,本周我们一起精读这篇 JSON Parser with Javascript 文章吧,再温习一遍大学时编译原理相关知识。 2 概述 & 精读要解析 JSON 首先要理解语法概念,之前的 精读《手写 SQL 编译器 - 语法分析》 系列也有介绍过,不过本文介绍的更形象,看下面这个语法图: 这是关于 Object 类型的语法描述图,从左向右看,根据箭头指向只要能走出这个迷宫就属于正确语法。 比如第一行 { → whitespace → } 表示 { } 属于合法的 JSON 语法。 再比如观察向下的一条最长路线:{ → whitespace → string → whitespace → : → value → } 表示 { string : value } 属于合法的 JSON 语法。 你可能会问,双引号去哪儿了?这就是语法树最核心的概念了,这张图是关于 Object 类型的 产生式,同理还有 string、value 的产生式,产生式中可以嵌套其他产生式,甚至形成环路,以此拥有描述纷繁多变语法的能力。 最后我们再看一个环路,即 { → whitespace → string … , → whitespace → string … , … },我们发现,只要不走回头路,这条路是可以一直 “绕圈” 下去的,因此 Object 类型拥有了任意数量子字段的能力,只是每形成一个子字段,必须经过 , 号分割。 实现 Parser首先实现一个基本结构: function fakeParseJSON(str) { let i = 0; // TODO} i 表示访问字符的下标,当 i 走到字符串结尾表示遍历结束。 然后是下一步,用几个函数描述解析语法的过程: 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 编译器 - 语法分析》 系列文章了解更多。 由于 Object 第一个元素前面不允许加逗号,因此可以利用 initial 做一个初始化判定,在初始时机不会吃掉逗号: 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: 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 即可: 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 的语法图: 我们只需要吃逗号和 parseValue 即可: 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 是许多种基础类型的 “或” 关系组成的: 我们只需要继续拆解分析即可: 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: function fakeParseJSON(str) { // ... function parseKeyword(name, value) { if (str.slice(i, i + name.length) === name) { i += name.length; return value; } }} 如上所示,只要在 name 与对应字符相等时,返回第二个传入参数即可。 处理异常输入一个完整的语法解析功能需要包含错误处理,错误的情况主要分两种: 非法字符。 非正常结尾。 原文提到的 JSON 错误提示优化非常棒,想想你在开发中突然看到下面的提示,是不是很蒙圈: Unexpected token "a" 既然我们是自己写的 JSON 解析器,就可以进行更友好的异常提示,比如: // 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 可以查看 原文。 3 总结这篇文章通过一个具体的例子解释如何做语法分析,对于词法解析入门非常直观,如果你想更深入理解语法解析,或者写一个通用语法解析器,可以阅读语法解析系列入门文章,笔者通过实际例子带你一步一步做一个完备的词法解析工具! 语法解析入门系列文章,建议阅读顺序: 精读《手写 SQL 编译器 - 词法分析》 精读《手写 SQL 编译器 - 文法介绍》 精读《手写 SQL 编译器 - 语法分析》 精读《手写 SQL 编译器 - 回溯》 精读《手写 SQL 编译器 - 语法树》 精读《手写 SQL 编译器 - 错误提示》 精读《手写 SQL 编译器 - 性能优化之缓存》 精读《手写 SQL 编译器 - 智能提示》 syntax-parser 这个零依赖的通用语法解析库就是根据上述文章一步一步完成的,看完了上面文章,就彻底理解了这个库的源码。 讨论地址是:精读《手写 JSON Parser》 · Issue ##233 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《捕获所有异步 error》","path":"/wiki/WebWeekly/前沿技术/《捕获所有异步 error》.html","content":"当前期刊数: 209 成熟的产品都有较高的稳定性要求,仅前端就要做大量监控、错误上报,后端更是如此,一个未考虑的异常可能导致数据错误、服务雪崩、内存溢出等等问题,轻则每天焦头烂额的处理异常,重则引发线上故障。 假设代码逻辑没有错误,那么剩下的就是异常错误了。 由于任何服务、代码都可能存在外部调用,只要外部调用存在不确定性,代码就可能出现异常,所以捕获异常是一个非常重要的基本功。 所以本周就精读 How to avoid uncaught async errors in Javascript 这篇文章,看看 JS 如何捕获异步异常错误。 概述之所以要关注异步异常,是因为捕获同步异常非常简单: try { ;(() => { throw new Error('err') })()} catch (e) { console.log(e) // caught} 但异步错误却无法被直接捕获,这不太直观: try { ;(async () => { throw new Error('err') // uncaught })()} catch (e) { console.log(e)} 原因是异步代码并不在 try catch 上下文中执行,唯一的同步逻辑只有创建一个异步函数,所以异步函数内的错误无法被捕获。 要捕获 async 函数内的异常,可以调用 .catch,因为 async 函数返回一个 Promise: ;(async () => { throw new Error('err')})().catch((e) => { console.log(e) // caught}) 当然也可以在函数体内直接用 try catch: ;(async () => { try { throw new Error('err') } catch (e) { console.log(e) // caught }})() 类似的,如果在循环体里捕获异常,则要使用 Promise.all: try { await Promise.all( [1, 2, 3].map(async () => { throw new Error('err') }) )} catch (e) { console.log(e) // caught} 也就是说 await 修饰的 Promise 内抛出的异常,可以被 try catch 捕获。 但不是说写了 await 就一定能捕获到异常,一种情况是 Promise 内再包含一个异步: new Promise(() => { setTimeout(() => { throw new Error('err') // uncaught }, 0)}).catch((e) => { console.log(e)}) 这个情况要用 reject 方式抛出异常才能被捕获: new Promise((res, rej) => { setTimeout(() => { rej('err') // caught }, 0)}).catch((e) => { console.log(e)}) 另一种情况是,这个 await 没有被执行到: const wait = (ms) => new Promise((res) => setTimeout(res, ms));(async () => { try { const p1 = wait(3000).then(() => { throw new Error('err') }) // uncaught await wait(2000).then(() => { throw new Error('err2') }) // caught await p1 } catch (e) { console.log(e) }})() p1 等待 3s 后抛出异常,但因为 2s 后抛出了 err2 异常,中断了代码执行,所以 await p1 不会被执行到,导致这个异常不会被 catch 住。 而且有意思的是,如果换一个场景,提前执行了 p1,等 1s 后再 await p1,那异常就从无法捕获变成可以捕获了,这样浏览器会怎么处理? const wait = (ms) => new Promise((res) => setTimeout(res, ms));(async () => { try { const p1 = wait(1000).then(() => { throw new Error('err') }) await wait(2000) await p1 } catch (e) { console.log(e) }})() 结论是浏览器 1s 后会抛出一个未捕获异常,但再过 1s 这个未捕获异常就消失了,变成了捕获的异常。 这个行为很奇怪,当程序复杂时很难排查,因为并行的 Promise 建议用 Promise.all 处理: await Promise.all([ wait(1000).then(() => { throw new Error('err') }), // p1 wait(2000),]) 另外 Promise 的错误会随着 Promise 链传递,因此建议把 Promise 内多次异步行为改写为多条链的模式,在最后 catch 住错误。 还是之前的例子,Promise 无法捕获内部的异步错误: new Promise((res, rej) => { setTimeout(() => { throw Error('err') }, 1000) // 1}).catch((error) => { console.log(error)}) 但如果写成 Promise Chain,就可以捕获了: new Promise((res, rej) => { setTimeout(res, 1000) // 1}) .then((res, rej) => { throw Error('err') }) .catch((error) => { console.log(error) }) 原因是,用 Promise Chain 代替了内部多次异步嵌套,这样多个异步行为会被拆解为对应 Promise Chain 的同步行为,Promise 就可以捕获啦。 最后,DOM 事件监听内抛出的错误都无法被捕获: document.querySelector('button').addEventListener('click', async () => { throw new Error('err') // uncaught}) 同步也一样: document.querySelector('button').addEventListener('click', () => { throw new Error('err') // uncaught}) 只能通过函数体内 try catch 来捕获。 精读我们开篇提到了要监控所有异常,仅通过 try catch、then 捕获同步、异步错误还是不够的,因为这些是局部错误捕获手段,当我们无法保证所有代码都处理了异常时,需要进行全局异常监控,一般有两种方法: window.addEventListener('error') window.addEventListener('unhandledrejection') error 可以监听所有同步、异步的运行时错误,但无法监听语法、接口、资源加载错误。而 unhandledrejection 可以监听到 Promise 中抛出的,未被 .catch 捕获的错误。 在具体的前端框架中,也可以通过框架提供的错误监听方案解决部分问题,比如 React 的 Error Boundaries、Vue 的 error handler,一个是 UI 组件级别的,一个是全局的。 回过头来看,本身 js 提供的 try catch 错误捕获是非常有效的,之所以会遇到无法捕获错误的经常,大多是因为异步导致的。 然而大部分异步错误,都可以通过 await 的方式解决,我们唯一要注意的是,await 仅支持一层,或者说一条链的错误监听,比如这个例子是可以监听到错误的: try { await func1()} catch (err) { // caught}async function func1() { await func2()}async function func2() { throw Error('error')} 也就是说,只要这一条链内都被 await 住了,那么最外层的 try catch 就能捕获异步错误。但如果有一层异步又脱离了 await,那么就无法捕获了: async function func2() { setTimeout(() => { throw Error('error') // uncaught })} 针对这个问题,原文也提供了例如 Promise.all、链式 Promise、.catch 等方法解决,因此只要编写代码时注意对异步的处理,就可以用 try catch 捕获这些异步错误。 总结关于异步错误的处理,如果还有其它未考虑到的情况,欢迎留言补充。 讨论地址是:精读《捕获所有异步 error》· Issue ##350 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《插件化思维》","path":"/wiki/WebWeekly/前沿技术/《插件化思维》.html","content":"当前期刊数: 53 本周精读内容是 《插件化思维》。没有参考文章,资料源自 webpack、fis、egg 以及笔者自身开发经验。 1 引言用过构建工具的同学都知道,grunt, webpack, gulp 都支持插件开发。后端框架比如 egg koa 都支持插件机制拓展,前端页面也有许多可拓展性的要求。插件化无处不在,所有的框架都希望自身拥有最强大的可拓展能力,可维护性,而且都选择了插件化的方式达到目标。 我认为插件化思维是一种极客精神,而且大量可拓展、需要协同开发的程序都离不开插件机制支撑。 没有插件化,核心库的代码会变得冗余,功能耦合越来越严重,最后导致维护困难。插件化就是将不断扩张的功能分散在插件中,内部集中维护逻辑,这就有点像数据库横向扩容,结构不变,拆分数据。 2 精读理想情况下,我们都希望一个库,或者一个框架具有足够的可拓展性。这个可拓展性体现在这三个方面: 让社区可以贡献代码,而且即使代码存在问题,也不会影响核心代码的稳定性。 支持二次开发,满足不同业务场景的特定需求。 让代码以功能为纬度聚合起来,而不是某个片面的逻辑结构,在代码数量庞大的场景尤为重要。 我们都清楚插件化应该能解决问题,但从哪下手呢?这就是笔者希望分享给大家的经验。 做技术设计时,最好先从使用者角度出发,当设计出舒服的调用方式时,再去考虑实现。所以我们先从插件使用者角度出发,看看可以提供哪些插件使用方式给开发者。 2.1 插件化分类插件化许多都是从设计模式演化而来的,大概可以参考的有:命令模式,工厂模式,抽象工厂模式等等,笔者根据个人经验,总结出三种插件化形式: 约定/注入插件化。 事件插件化。 插槽插件化。 最后还有一个不算插件化实现方式,但效果比较优雅,姑且称为分形插件化吧。下面一一解释。 2.1.1 约定/注入插件化按照某个约定来设计插件,这个约定一般是:入口文件/指定文件名作为插件入口,文件形式.json/.ts 不等,只要返回的对象按照约定名称书写,就会被加载,并可以拿到一些上下文。 举例来说,比如只要项目的 package.json 的 apollo 存在 commands 属性,会自动注册新的命令行: { "apollo": { "commands": [{ "name": "publish", "action": "doPublish" }] }} 当然 json 能力很弱,定义函数部分需要单独在 ts 文件中完成,那么更广泛的方式是直接写 ts 文件,但按照文件路径决定作用,比如:项目的 ./controllers 存在 ts 文件,会自动作为控制器,响应前端的请求。 这种情况根据功能类型决定对 ts 文件代码结构的要求。比如 node 控制器这层,一个文件要响应多个请求,而且逻辑单一,那就很适合用 class 的方式作为约定,比如: export default class User { async login(ctx: Context) { ctx.json({ ok: true }); }} 如果功能相对杂乱,没有清晰的功能入口规划,比如 gulp 这种插件,那用对象会更简洁,而且更倾向于用一个入口,因为主要操作的是上下文,而且只需要一个入口,内部逻辑种类无法控制。所以可能会这样写: export default (context: Context) => { // context.sourceFiles.xx}; 举例:fis、gulp、webpack、egg。 2.1.2 事件插件化顾名思义,通过事件的方式提供插件开发的能力。 这种方式的框架之间跨界更大,比如 dom 事件: document.on("focus", callback); 虽然只是普通的业务代码,但这本质上就是插件机制: 可拓展:可以重复定义 N 个 focus 事件相互独立。 事件相互独立:每个 callback 之间互相不受影响。 也可以解释为,事件机制就是在一些阶段放出钩子,允许用户代码拓展整体框架的生命周期。 service worker 就更明显,业务代码几乎完全由一堆事件监听构成,比如 install 时机,随时可以新增一个监听,将 install 时机进行 delay,而不需要侵入其他代码。 在事件机制玩出花样的应该算 koa 了,它的中间件洋葱模型非常有名,换个角度理解,可以认为是能控制执行时机的事件插件化,也就是只要想把执行时机放在所有事件执行完毕时,把代码放在 next() 之后即可,如果想终止插件执行,可以不调用 next()。 举例:koa、service worker、dom events。 2.1.3 插槽插件化这种插件化一般用在对 UI 元素的拓展。react 的内置数据流是符合组件物理结构的,而 redux 数据流是符合用户定义的逻辑结构,那么对于 html 布局来说也是一样:html 默认布局是物理结构,那插槽布局方式就是 html 中的 redux。 正常 UI 组织逻辑是这样的: <div> <Layout> <Header> <Logo /> </Header> <Footer> <Help /> </Footer> </Layout></div> 插槽的组织方式是这样的: { position: "root", View: <Layout>{insertPosition("layout")}</Layout>} { position: "layout", View: [ <Header>{insertPosition("header")}</Header>, <Footer>{insertPosition("footer")}</Footer> ]} { position: "header", View: <Logo />} { position: "footer", View: <Help />} 这样插件中的代码可以不受物理结构的约束,直接插入到任何插入点。 更重要的是,实现了 UI 解耦,父元素就不需要知道子元素的具体实例。一般来说,决定一个组件状态的都是其父元素而不是子元素,比如一个按钮可能在 <ButtonGroup/> 中表现为一种组合态的样式。但不可能说 <ButtonGroup/> 因为有了 <Select/> 作为子元素,自身的逻辑而发生变化的。 这就意味着,父元素不需要知道子元素的实例,比如 Tabs: <Tabs>{insertPosition(`tabs-${this.state.selectedTab}`)}</Tabs> 当然有些情况看似是例外,比如 Tree 的查询功能,就依赖子元素 TreeNode 的配合。但它依赖的是基于某个约定的子元素,而不是具体子元素的实例,父级只需要与子元素约定接口即可。真正需要关心物理结构的恰恰是子元素,比如插入到 Tree 子元素节点的 TreeNode 必须实现某些方法,如果不满足这个功能,就不要把组件放在 Tree 下面;而 Tree 的实现就无需顾及啦,只需要默认子元素有哪些约定即可。 举例:gaea-editor。 2.1.4 分型插件化代表 egg,特点是插件结构与项目结构分型,也就是组成大项目的小插件,自身结构与项目结构相同。 因为对于 node server 的插件来说,要实现的功能应该是项目功能的子集,而本身 egg 对功能是按照目录结构划分的,所以插件的目录结构与项目一致,看起来也很美观。 举例:egg。 当然不是所有插件都能写成目录分形的,这也恰好解释了 egg 与 koa 之间的关系:koa 是 node 框架,与项目结构无关,egg 是基于 koa 上层的框架,将项目结构转化成 server 功能,而插件需要拓展的也是 server 功能,恰好可以用项目结构的方式写插件。 2.2 核心代码如何加载插件一个支持插件化的框架,核心功能是整合插件以及定义生命周期,与功能相关的代码反而可以通过插件实现,下一小节再展开说明。 2.2.1 确定插件加载形式根据 2.1 节的描述,我们根据项目的功能,找到一个合适的插件使用方式,这会决定我们如何执行插件。 2.2.2 确定插件注册方式插件注册方式非常多样,这里举几个例子: 通过 npm 注册:比如只要 npm 包符合某个前缀,就会自动注册为插件,这个很简单,不举例子了。 通过文件名注册:比如项目中存在 xx.plugin.ts 会自动做到插件引用,当然这一般作为辅助方案使用。 通过代码注册:这个很基础,就是通过代码 require 就行,比如 babel-polyfill,不过这个要求插件执行逻辑正好要在浏览器运行,场景比较受限。 通过描述注册:比如在 package.json 描述一个属性,表明了要加载的插件,比如 .babelrc: { "presets": ["es2015"]} 自动注册:比较暴力,通过遍历可能存在的位置,只要满足插件约定的,会自动注册为插件。这个行为比较像 require 行为,会自动递归寻找 node_modules,当然别忘了像 require 一样提供 paths 让用户手动配置寻址起始路径。 2.2.3 确定生命周期确定插件注册方式后,一般第一件事就是加载插件,后面就是根据框架业务逻辑不同而不同的生命周期了,插件在这些生命周期中扮演不同的功能,我们需要通过一些方式,让插件能够影响这些过程。 2.2.4 插件对生命周期的拦截一般通过事件、回调函数的方式,支持插件对生命周期的拦截,最简单的例子比如: document.on("click", callback); 就是让插件拦截了 click 这个事件,当然这个事件与 dom 的生命周期相比微乎其微,但也算是一个微小的生命周期,我们也可以 event.stopPropagation() 阻止冒泡,来影响这个生命周期的逻辑。 2.2.5 插件之间的依赖与通信插件之间难免有依赖关系,目前有两种方式处理,分为:依赖关系定义在业务项目中,与依赖关系定义在插件中。 稍微解释下,依赖关系定义在业务项目中,比如 webpack 的配置,我们在业务项目里是这么配的: { "use": ["babel-loader", "ts-loader"]} 在 webpack 中,执行逻辑是 ts-loader -> babel-loader,当然这个规则由框架说了算,但总之插件加载执行肯定有个顺序,而且与配置写法有关,而且配置需要写在项目中(至少不在插件中)。 另一种行为,将插件依赖写在插件中,比如 webpack-preload-plugin 就是依赖 html-webpack-plugin。 这两种场景各不同,一个是业务有关的顺序,也就是插件无法做主的业务逻辑问题,需要把顺序交给业务项目配置;一种是插件内部顺序,也就是业务无需关心的顺序问题,由插件自己定义就好啦。注意框架核心一般可能要同时支持这两种配置方式,最终决定插件的加载顺序。 插件之间通信也可以通过 hook 或者 context 方式支持,hook 主要传递的是时机信息,而 context 主要传递的是数据信息,但最终是否能生效,取决于上面说到的插件加载顺序。 context 可以拿 react 做个类比,一般都有作用域的,而且与执行顺序严格相关。 hook 等于插件内部的一个事件机制,由一个插件注册。业界有个比较好的实现,叫 tapable,这里简单介绍一下。 利用 tapable 在 A 插件注册新 hook: const SyncWaterfallHook = require("tapable").SyncWaterfallHook;compilation.hooks.htmlWebpackPluginAlterChunks = new SyncWaterfallHook([ "chunks", "objectWithPluginRef"]); 在 A 插件某个地方使用此 hook,实现某个特定业务逻辑。 const chunks = compilation.hooks.htmlWebpackPluginAlterChunks.call(chunks, { plugin: self}); B 插件可以拓展此 hook,来改变 A 的行为: compilation.hooks.htmlWebpackPluginAlterChunks.tap( "HtmlWebpackIncludeSiblingChunksPlugin", chunks => { const ids = [] .concat(...chunks.map(chunk => [...chunk.siblings, chunk.id])) .filter(onlyUnique); return ids.map(id => allChunks[id]); }); 这样,A 拿到的 chunks 就被 B 修改掉了。 2.3 核心功能的插件化2.2 开头说到,插件化框架的核心代码主要功能是对插件的加载、生命周期的梳理,以及实现 hook 让插件影响生命周期,最后补充上插件的加载顺序以及通信,就比较完备了。 那么写到这里,衡量代码质量的点就在于,是不是所有核心业务逻辑都可以由插件完成?因为只有用插件实现核心业务逻辑,才能检验插件的能力,进而推导出第三方插件是否拥有足够的拓展能力。 如果核心逻辑中有一部分代码没有通过插件机制编写,不仅让第三方插件也无法拓展此逻辑,而且还不利于框架的维护。 所以这主要是个思想,希望开发者首先明确哪些功能应该做成插件,以及将哪些插件固化为内置插件。 笔者认为应该提前思考清楚三点: 2.3.1 哪些插件需要内置这个是业务相关的问题,但总体来看,开源的,基础功能以及体现核心竞争力的可以内置,可以开源与核心竞争力都比较好理解,主要说下基础功能: 基础功能就是一个业务的架子。因为插件机制的代码并不解决任何业务问题,一个没有内置插件的框架肯定什么都不是,所以选择基础功能就尤为重要。 举个例子,比如做构建工具,至少要有一个基本的配置作为模版,其他插件通过拓展这个配置来修改构建效果。那么这个基本配置就决定了其他插件可以如何修改它,也决定了这个框架的配置基调。 比如:create-react-app 对 dev 开发时的模版配置。如果没有这个模版,本地就无法开发,所以这个插件必须内置,而且需要考虑如何让其他插件对其拓展,这个在 2.3.2 节详细说明。 另一种情况就是非常基本,而又不需要再拓展加工的可以做成内置插件,比如 babel 对 js 模块的 commonjs 分析逻辑就不需要暴露出来,因为这个标准已经确定,既不需要拓展,又是 babel 运行的基础,所以肯定要内置。 2.3.2 插件是依赖型还是完全正交的功能完全正交的插件是最完美的,因为它既不会影响其他插件,也不需要依赖任何插件,自身也不需要被任何插件拓展。 在写非正交功能的插件时就要担心了,我们还是分为三个点去看: 2.3.2.1 依赖其他插件的插件举个例子,比如插件 X 需要拓展命令行,在执行 npm start 时统计当前用户信息并打点。那么这个插件就要知道当前登陆用户是谁。这个功能恰好是另一个 “用户登陆” 插件完成的,那么插件 X 就要依赖 “用户登陆” 插件了。 这种情况,根据 2.2.5 插件依赖小节经验,需要明确这个插件是插件级别依赖,还是项目级别依赖。 当然,这种情况是插件级别依赖,我们把依赖关系定义在插件 X 中即可,比如 package.json: "plugin-dep": ["user-login"] 另一种情况,比如我们写的是 babel-loader 插件,它在 ts 项目中依赖 ts-loader,那只能在项目中定义依赖了,此时需要补充一些文档说明 ts 场景的使用顺序。 2.3.2.2 依赖并拓展其他插件的插件如果插件 X 在以来 “用户登陆” 插件的基础上,还要拓展登陆时获取的用户信息,比如要同时获取用户的手机号,而 “用户登陆” 插件默认并没有获取此信息,但可以通过扩展方式实现,插件 X 需要注意什么呢? 首先插件 X 最好不要减少另一个插件的功能(具体拓展方式,参考 2.2.5 节,这里假设插件都比较具有可拓展性),否则插件 X 可能破坏 “用户登录” 插件与其他插件之间的协作。 减少功能的情况非常普遍,为了加深理解,这里举一个例子:某个插件直接 pipeTemplate 拓展模版内容,但插件 X 直接返回了新内容,而没有 concat 原有内容,就是减少了功能。 但也不是所有情况都要保证不减少功能,比如当缺少必要的配置项时,可以直接抛出异常,提前终止程序。 其次,要确保增加的功能尽可能少的与其他插件产生可能的冲突。拿拓展 webpack 配置举例,现在要拓展对 node_modules js 文件的处理,让这些文件过一遍 babel。 不好的做法是直接修改原有对 js 的 rules,增加一项对 node_modules 的 include,以及 babel-loader。因为这样会破坏原先插件对项目内 js 文件的处理,可能项目的 js 文件不需要 babel 处理呢? 比较好的做法是,新增一个 rules,单独对 node_modules 的 js 文件处理,不要影响其他规则。 2.3.2.3 可能被其他插件拓展的插件这点是最难的,难在如何设计拓展的粒度。 由于所有场景都类似,我们拿对模版的拓展举例子,其他场景可以类比:插件 X 定义了入口文件的基础内容,但还要提供一些 hook 供其他插件修改入口文件。 假设入口文件一般是这样的: import * as React from "react";import * as ReactDOM from "react-dom";import { App } from "./app";ReactDOM.render(<App />, document.getELementById("root")); 这种最简单的模版,其实内部要考虑以下几点潜在拓展需求: 在某处需要插入其他代码,怎么支持? 如何保证插入代码的顺序? 用 react-lite 替换 react,怎么支持? dev 模式需要用 hot(App) 替换 App 作为入口,怎么支持? 模版入口 div 的 id 可能不是 root,怎么支持? 模版入口 div 是自动生成的,怎么支持? 用在 reactNative,没有 document,怎么支持? 后端渲染时,需要用 ReactDOM.hydrate 而不是 ReactDOM.render,怎么支持? 以上 8 种场景可能会不同组合,需要保证任意组合都能正确运行,所以无法全量模版替换,那怎么办? 笔者此处给出一种解决方案,供大家参考。另外要注意,这个方案随着考虑到的使用场景增多,是要不断调整变化的。 get( "entry", ` ${get("importBefore", "")} ${get("importReact", `import * as React from "react"`)} ${get("importReactDOM", `import * as ReactDOM from "react-dom"`)} import { App } from "./app" ${get("importAfter", "")} ${get("renderMethod", `ReactDOM.render`)}(${get( "renderApp", "<App/>" )}, ${get("rootElement", `document.getELementById("root")`)}) ${get("renderAfter", "")}`); 以上八种情况读者脑补一下,不详细说明了。 2.3.3 内置插件如何与第三方插件相处内置的插件与第三方插件的冲突点在于,内置插件如果拓展性很差,那还不如不要内置,内置了反而阻碍第三方插件的拓展。 所以参考 2.3.2.3 节,为内置插件考虑最大化的拓展机制,才能确保内置插件的功能不会变成拓展性瓶颈。 每新增一个内置的插件,都在消灭一部分拓展能力,因为由插件拓展后的区块拥有的拓展能力,应该是逐渐减弱的。这里比较拗口,可以比喻为,一条小溪流,插件就是层层的水处理站,每新增一个处理站就会改变下游水势变化,甚至可能将水拦住,下游一滴水也拿不到。 2.3.1 节说了哪些插件需要内置,而这一节想说明的是,谨慎增加内置插件数量,因为内置的越多,框架拓展能力就越弱。 2.4 哪些场景可以插件化最后梳理下插件化适用场景,笔者根据有限的经验列出一下一些场景。 2.4.1 前后端框架如果你要做一个前/后端开发框架,插件化是必然,比如 react 的生命周期,koa 的中间件,甚至业务代码用到的 request 处理,都是插件化的体现。 2.4.2 脚手架支持插件化的脚手架具有拓展性,社区方便提供插件,而且脚手架为了适配多种代码,功能可插拔是非常重要的。 2.4.3 工具库一些小的工具库,比如管理数据流的 redux 提供的中间件机制,就是让社区贡献插件,完善自身的功能。 2.4.4 需要多人协同的复杂业务项目如果业务项目很复杂,同时又有多人协作完成,最好按照功能划分来分工。但是分工如果只是简单的文件目录分配方式,必然导致功能的不均匀,也就是每个人开发的模块可能不能访问所有系统能力,或者涉及到与其他功能协同时,文件相互引用带来代码的耦合度提高,最终导致难以维护。 插件化给这种项目带来的最大优势就是,每一个人开发的插件都是一个拥有完整功能的个体,这样只需要关心功能的分配,不用担心局部代码功能不均衡;插件之间的调用框架层已经做掉了,所以协同不会发生耦合,只需要申明好依赖关系。 插件化机制良好的项目开发,和 git 功能分支开发的体验有相似之处,git 给每个功能或需求开一个分支,而插件化可以让每个功能作为一个插件,而 git 功能分支之间是无关联的,所以只有功能之间正交的需求才能开多个分支,而插件机制可以考虑到依赖情况,进行更复杂的功能协同。 3 总结现在还没有找到对插件化系统化思考的文章,所以这一篇算是抛砖引玉,大家一定有更多的框架开发心得值得分享。 同时也想借这篇文章提高大家对插件化必要性的重视,许多情况插件化并不是小题大做,因为它能带来更好的分工协作,而分工的重要性不言而喻。 4 更多讨论 讨论地址是:精读《插件化思维》 · Issue ##75 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《数据搭建引擎 bi-designer API-组件》","path":"/wiki/WebWeekly/前沿技术/《数据搭建引擎 bi-designer API-组件》.html","content":"当前期刊数: 165 bi-designer 是阿里数据中台团队自研的前端搭建引擎,基于它开发了阿里内部最大的数据分析平台,以及阿里云上的 QuickBI。 bi-designer 目前没有开源,因此文中使用的私有 npm 源 @alife/bi-designer 是无法在公网访问的。 本文介绍 bi-designer 组件的使用 API。 组件加载组件实例定义在元信息 - element 中: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { element: () => <div />,}; 异步加载使用 React.lazy 即可实现异步加载组件: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { // 懒加载 element: React.lazy(async () => import("./real-component")),}; 懒加载的组件会自动完成加载,如需自定义加载 Loading 效果,可以阅读 组件异步、错误处理 文档。 组件异步、错误处理 组件源码异步加载或者进行 Suspense 取数时,会调用 ComponentMeta.suspenseFallback 渲染。 组件渲染出错时,会调用 ComponentMeta.errorFallback 渲染。 异步加载import { Interfaces } from "@alife/bi-designer";const SuspenseFallback: Interfaces.InnerComponentElement = ({ componentInstance, componentmeta,}) => { return <span>Loading</span>;};const componentMeta = { componentName: "suspense-custom-fallback", element: React.lazy(async () => { await sleep(2000); return Promise.resolve({ default: () => null }); }), suspenseFallback,}; 上面例子中,对异步加载的组件定义了 suspenseFallback 来处理异步中的状态。 错误处理import { Interfaces } from "@alife/bi-designer";const errorFallback: Interfaces.ErrorFallbackElement = ({ componentInstance, componentmeta, error,}) => { return <span>错误:{error.toString()}</span>;};const componentMeta = { componentName: "error-custom-fallback", element: () => { throw new Error("error!"); }, errorFallback,}; 上面例子中, errorFallback 处理了组件抛出的任何错误。 error :当前组件报错信息。 容器组件容器元素可以被拖入子元素,只要将 isContainer 设置为 true 即可: export const yourComponentMeta: Interfaces.ComponentMeta = { componentName: "yourComponent", element: YourComponent, isContainer: true,}; 之后可以从 props.children 访问到子元素: const YourComponent = ({ children }) => { return <div>{children}</div>;}; 多插槽容器组件多插槽容器即一个容器内部有多个位置可响应拖拽。 实现多插槽容器组件注意两点即可: 这个大容器组件本身不为容器类型,因为我们要拖入到子元素,不需要拖入到它自己本身。 内部通过 ComponentLoader 添加容器类组件作为子元素。 比如我们要利用 Antd Card 实现一个多插槽容器,首先把 Card 申明为普通组件: export const cardComponentMeta: Interfaces.ComponentMeta = { componentName: "card", element: CardComponent,}; 在实现 Card 功能时,我们在两处内部可拖拽区域调用 ComponentLoader 加载一个事先定义好的容器组件 div : import { ComponentLoader, useDesigner } from '@alife/bi-designer'const CardComponent: Interfaces.ComponentElement = () => { const { useKeepComponentLoaders } = useDesigner() useKeepComponentLoaders(['1']) return ( <Card actions={[...]} > <ComponentLoader id="1" componentName="div" props={{style: { minHeight: 30 }}} /> </Card> );}; 总结一下,我们可以利用 ComponentLoader 在组件内部加载任意组件,如果加载的是容器组件,就相当于增加了一块内部插槽。这种插槽可以插入理论上无数种容器组件,根据业务需求而定,比如上面这种最简单的 div 容器,可以是这么实现的: const Div: Interfaces.ComponentElement = ({ children, style }) => { return ( <div style={{ width: "100%", height: "100%", ...style }}>{children}</div> );}; Tabs 容器组件Tabs 容器可以看作动态数量的多插槽容器: import { ComponentLoader, useDesigner } from "@alife/bi-designer";const TabsComponent: Interfaces.ComponentElement = ({ tabs }) => { const { useKeepComponentLoaders } = useDesigner(); useKeepComponentLoaders(tabs?.map((each) => each.key)); return ( <div> <Tabs> {tabs?.map((each) => ( <Tabs.TabPane tab={`Tab${each.title}`} key={each.key}> /* 举个例子,拿 div 这个组件作为 TabPane 的容器 */ <ComponentLoader id={each.key} componentName="div" /> </Tabs.TabPane> ))} </Tabs> </div> );}; Tabs 根据配置动态渲染 TabPane ,为每个 TabPane 塞入一个容器即可。 注意, useKeepComponentLoaders 函数可以让数据变化后某个子 Tab 消失时,及时做画布脏数据清除。另外即便数据不是动态的,也要及时更新这个函数,比如某次更新, ComponentLoader id 为 3 的值从代码移除了,也要把 3 这个 id 从 useKeepComponentLoaders 中移除。 组件宽高对于能自适应高度的组件,最佳方案是设置 100% 的宽高: import { Interfaces } from "@alife/bi-designer";const CustomComponent: Interfaces.ComponentElement = () => { return <div style={{ width: "100%", height: "100%", minHeight: 50 }} />;}; 流式布局下 height: ‘100%’ 高度会坍塌,因此可以设置个最小高度固定值兜底,或者通过 props 让用户配置。 如果组件不支持自适应宽高,比如渲染 canvas、svg 等图表时,需要自己监听宽高,或者利用 容器拓展组件 props 功能,在容器算好宽高具体值,再传入组件。 当然也可以直接设置一个默认高度,或者根据内容动态撑开组件,在流式布局、磁贴布局下可以自动撑开容器(磁贴布局编辑模式下拖拽的高度允许被运行时自动撑大),在自由布局下无法撑开,会出现内滚动条。 组件配置默认值组件配置表单的默认值在 ComponentMeta.props 中定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { props: [ { name: "title", defaultValue: "标题", }, ],}; Props 描述了组件入参信息,包括: interface MetaProps { /** * 属性名 */ name: string; /** * 属性类型 */ type?: string; /** * 属性描述 */ description?: string; /** * 默认值 */ defaultValue?: any;} 如果只设置默认值,只需要关心 name 和 defaultValue 。 组件配置表单组件配置表单在 ComponentMeta.propsSchema 中定义: import { Interfaces } from '@alife/bi-designer'const componentMeta: Interfaces.ComponentMeta = { platform: 'fbi', // 平台名称 propsSchema: { style: { color: { title: 'Color', type: 'color', redirect: 'color', }, }, },} platform :项目类型。不同项目类型的 propsSchema 结构可能不同,其他取数逻辑可能也不同。 propsSchema :表单配置结构,符合 UISchema 规范。对于特殊表单可能使用自己的规范。 组件配置修改回调组件配置修改回调在每次组件实例信息被修改时触发,在 ComponentMeta.onPropsChange 中定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { onPropsChange: ({ prevProps, currentProps, componentMeta }) => { return { ...currentProps, color: "red", }; },}; prevProps :上一次组件配置。 currentProps :当前组件配置。 componentMeta :组件元信息。 Return :新的组件配置。 跨组件关联配置更新当画布任何组件变化时,组件都可以在 ComponentMeta.onPageChange 监听到,并修改自己的组件配置: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { onPageChange: ({ props, pageSchema }) => { // 当关联的组件找不到时清空 if ( !pageSchema?.componentInstances?.some((each) => each.id === props.value) ) { return { ...props, // 清空 props.value value: "", }; } // 返回值会更新当前组件配置 return props; },}; props :当前组件配置。 pageSchema :页面信息。 Return :新的组件配置。 假设组件配置中用到了其他组件 id 等数据,可以在 onPageChange 回调时做判断,如果目标组件不存在,对当前组件的部分配置内容做更新。 组件隐藏组件隐藏可以通过 hide 设置: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { hide: ({ componentInstance, mode }) => true,}; componentInstance :组件实例信息。 mode :当前模式,比如组件仅编辑模式隐藏,可以判断 ({ mode }) => mode === ‘edit’ 。 属性值类型 - JSSlotJSSlot 是一种配置类型,可以将组件某个 props 参数设置为另一个组件实例,运行时作为 React Node 传参。 import { Interfaces } from "@alife/bi-designer";// 组件直接使用 props.header 作为 JSXconst ComponentWithJSSlot: Interfaces.ComponentElement = ({ header }) => { return ( <div> header 元素: {header} </div> );};// DSL 中增加 Slot 描述const defaultPageSchema: Interfaces.PageSchema = { componentInstances: { tg43g42f: { id: "tg43g42f", componentName: "js-slot-component", index: 0, props: { header: { type: "JSSlot", value: ["child1", "child2"], }, }, }, child1: { id: "child1", componentName: "input", parentId: "tg43g42f", index: 0, isSlot: true, }, child2: { id: "child2", componentName: "input", parentId: "tg43g42f", index: 1, isSlot: true, }, },}; isSlot :标识节点是 JSSlot 类型。 type: ‘JSSlot’ :标记属性为 JSSlot 类型, value 数组存储 Slot 组件 id。 属性值类型 - JSFunctionJSFunction 是一种配置类型,可以将组件某个 props 参数设置为自定义函数。 import { Interfaces } from "@alife/bi-designer";// 组件直接使用 props.onClick 作为函数调用const FunctionComponent: Interfaces.ComponentElement = ({ onClick }) => { return <div onClick={onClick} />;};// DSL 中增加 Function 描述const defaultPageSchema: Interfaces.PageSchema = { componentInstances: { test: { id: "tg43g42f", componentName: "functionComponent", index: 0, props: { onClick: { type: "JSFunction", value: 'function onClick() { console.log("123") }', }, }, }, },}; type: ‘JSFunction’ :标记属性为 JSFunction 类型, value 用字符串存储函数体。函数中可以使用 上下文数据对象 与 工具类拓展。 属性值类型 - JSExpressionJSExpression 是一种配置类型,可以将组件某个 props 参数设置为自定义表达式。 import { Interfaces } from "@alife/bi-designer";// 组件直接使用 props.variable 作为变量直接渲染const ExpressionComponent: Interfaces.ComponentElement = ({ variable }) => { return <div>JSExpression:{variable}</div>;};// DSL 中增加 Expression 描述const defaultPageSchema: Interfaces.PageSchema = { componentInstances: { test: { id: "tg43g42f", componentName: "expressionComponent", props: { variable: { type: "JSExpression", value: '"1" + "2"', }, }, }, },}; type: ‘JSExpression’ :标记属性为 JSExpression 类型, value 用字符串存储表达式。表达式可以使用 上下文数据对象、与 工具类拓展。 组件状态持久化组件自身在运行时可以通过 updateComponentById 函数将状态持久化到配置中: import { Interfaces, useDesigner } from "@alife/bi-designer";import * as fp from "lodash/fp";const componentMeta: Interfaces.ComponentMeta = { element: Component,};const Component: Interfaces.ComponentElement = ({ id, count }) => { const { updateComponentById } = useDesigner(); const handleIncCount = React.useCallback(() => { updateComponentById(id, (each) => fp.set("props.count", each?.props?.count + 1, each) ); }, [id, updateComponentById]); return <div onClick={handleIncCount}>{count}</div>;}; 注意:由于 updateComponentById 修改的是画布 DSL,因此在非编辑模式下,此 DSL 无法持久化。对于此模式下产生的脏数据清理问题,同 组件配置订正。 动态创建组件组件内可以动态创建任何其他组件,通过 props.ComponentLoader 实现: import { Interfaces, useDesigner, ComponentLoader } from "@alife/bi-designer";const Card: Interfaces.ComponentElement = () => { const { useKeepComponentLoaders } = useDesigner(); useKeepComponentLoaders(["1"]); return ( <ComponentLoader id="1" componentName="button" props={{ color: "red" }} /> );}; useKeepComponentLoaders :与下面动态创建的组件 id 保持同步,以便引擎管理动态组件。ComponentLoader 参数说明: id :动态组件的唯一 id,在同一个组件内,动态组件的 id 需要保持唯一。 componentName :组件名。 props :组件 Props,可选。 动态组件嵌套动态组件可以任意嵌套: import { Interfaces, useDesigner, ComponentLoader } from "@alife/bi-designer";const Card: Interfaces.ComponentElement = ({ ComponentLoader, useKeepComponentLoaders,}) => { const { useKeepComponentLoaders } = useDesigner(); useKeepComponentLoaders(["1", "2"]); return ( <ComponentLoader id="1" componentName="div"> 这是子元素: <ComponentLoader id="2" componentName="button" /> </ComponentLoader> );}; 组件配置未 Ready 时不渲染可以在组件容器或通用容器层对组件渲染做拦截,比如判断某些配置不满足,展示一个兜底图而不是直接渲染组件: import { Interfaces, useDesigner } from "@alife/bi-designer";import * as fp from "lodash/fp";const componentMeta: Interfaces.ComponentMeta = { element: Component, container: Container,};const Container: Interfaces.InnerComponentElement = ({ componentInstance, children,}) => { if (!componentInstance?.props?.count) { // 不满足渲染条件 return <div>count 配置未 ready,不渲染组件</div>; } // 渲染 children,children 即组件本身 return children;}; 配置未 Ready 时不取数只要 getFetchParam 抛出异常即可暂停取数: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { getFetchParam: ({ componentInstance, componentMeta, filters, context }) => { if (componentInstance.props?.count !== "5") { // count 不为 '5' 则不取数 throw Error("Not Ready"); } return "123"; },}; 这个错误可以通过 props.fetchError 访问到,组件和容器层都可以拦截: import { Interfaces } from "@alife/bi-designer";class PropsNotReadyError extends Error {}const componentMeta: Interfaces.ComponentMeta = { getFetchParam: ({ componentInstance, componentMeta, filters, context }) => { if (componentInstance.props?.count !== "5") { throw PropsNotReadyError("Not Ready"); } return "123"; }, container: Wrapper,};const Wrapper: Interfaces.InnerComponentElement = ({ componentInstance }) => { if (componentInstance.props.fetchError instanceof PropsNotReadyError) { return <div>不满足取数条件</div>; }}; 组件取数组件是否初始化取数在 ComponentMeta.initFetch 中定义;生成取数参数在 ComponentMeta.getFetchParam 中定义;组件取数函数在 ComponentMeta.fetcher 中定义 import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { // 组件是否开启自动取数,当取数参数变化时(getFetchParam 控制)会触发自动取数 autoFetch: ({ componentInstance, componentMeta }) => true, // 组件是否默认取数,仅 autoFetch 为 true 时生效 initFetch: ({ componentInstance, componentMeta }) => true, // 组装取数参数 getFetchParam: ({ componentInstance, componentMeta, filters, context }) => { return { name: componentInstance?.props?.name }; }, // 取数函数 fetcher: async ({ componentMeta, param, context }) => { // 根据当前组件信息 componentInstance 与筛选条件组件&值 filters 进行取数 return await customFetcher(param.name); },}; componentInstance :当前组件实例信息。 getFetchParam :取数开始的回调,用来组装取数参数。返回 null 或 undefined 不会触发取数。 filters :作用于此组件的筛选信息,在 组件筛选 文档有进一步阐述。包含的 key 有: componentInstance :筛选条件组件实例信息。 filterValue :筛选条件的当前筛选值。 payload :自定义传参,由组件筛选的 eventConfigs 定义,具体见文档 组件筛选 - 传递额外筛选信息 。 context :上下文,可以访问 useDesigner 一切内容。 做了取数配置后,组件就可以通过 props 拿到数据了: import { useDesigner } from "@alife/bi-designer";const NameList: Interfaces.ComponentElement = () => { const { data, isFetching, isFilterReady } = useDesigner(); if (!isFilterReady) { return <Spin>筛选条件未 Ready</Spin>; } if (isFetching) { return <Spin>取数中</Spin>; } return ( <div> {data.map((each: any, index: number) => ( <div key={index}>{each}</div> ))} </div> );}; data 取数结果。 isFetching 是否在取数中。 isFilterReady 筛选条件是否 Ready,在组件筛选一节会详细说明,此处理解为一种特殊取数 Hold 状态。 fetchError 取数错误。 还可以 在引擎层配置全局组件取数配置,组件级配置的优先级高于引擎层的。 组件主动取数通过 fetchData 可以主动取数: const NameList: Interfaces.ComponentElement = ({ fetchData }) => { const { fetchData } = useDesigner(); return <button onClick={fetchData}>主动取数</button>;}; fetchData :主动取数函数,调用后可以立即重新取数。 主动取数调用后,取数结果依然通过 props.data 返回。 自定义取数参数fetchData 可以传入参数 getFetchParam 自定义取数参数: const NameList: Interfaces.ComponentElement = ({ fetchData }) => { const { fetchData } = useDesigner(); const handleFetchData = React.useCallback(() => { fetchData({ getFetchParam: ({ param, context }) => ({ ...param, top: 1, }), }); }, [fetchData]); return <button onClick={handleFetchData}>主动取数</button>;}; 要注意,非独立取数模式下即便修改了取数参数,下一次由外部触发的取数会重置取数参数。 独立取数独立取数可以通过 standalone 参数申明,此时触发取数不会导致组件 Rerender 并拿到新 data ,而是返回一个 Promise 由组件自行处理。 const NameList: Interfaces.ComponentElement = ({ fetchData }) => { const { fetchData } = useDesigner(); const handleFetchData = React.useCallback(async () => { const data = await fetchData({ standalone: true, }); // 组件自己处理取数结果 data }, [fetchData]); return <button onClick={handleFetchData}>主动取数</button>;}; 这种独立取数场景可以适应下钻等组件自由取数的场景。 独立取数模式下当然也可以结合 getFetchParam 一起使用。 主动取消取数通过 cancelFetch 可以主动取消取数: const NameList: Interfaces.ComponentElement = ({ cancelFetch }) => { const { cancelFetch } = useDesigner(); return <button onClick={cancelFetch}>取消取数</button>;}; cancelFetch :取消取数函数,调用后立即生效。取数完成后再调用则无作用。 优化取数性能是否重新取数由 getFetchParam 返回值是否有变化决定,默认写法会进行 deepEqual 判断: import { Interfaces } from "@alife/bi-design";const componentMeta: Interfaces.ComponentMeta = { getFetchParam: ({ componentInstance }) => { // 引擎会对返回值进行深对比 return { name: componentInstance?.props?.name }; },}; 但是下面两种情况可能会产生性能问题: 返回值数据结构非常大,导致频繁 deepEqual 开销明显增大。 生成取数参数的逻辑本身就耗时,导致频繁执行 getFetchParam 函数本身的开销明显增大。 我们对这种情况提供了一种优化方案,利用 shouldFetch 主动阻止不必要的取数,具体参考 组件阻止自动取数。 组件取数事件钩子如果想在取数后做一些更新,但不想触发额外的重渲染,可以在“组件取数事件钩子”里做。 取数完成后afterFetch 钩子在取数完成后执行: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { afterFetch: ({ data, context, componentInstance }) => { context.updateComponentById(componentInstance.id, (each) => fp.set("props.value", "newValue", each) ); },}; data :取数结果,即 fetcher 的返回值。 context :上下文。 componentInstance :组件实例信息。 componentMeta :组件元信息。 在取数钩子触发的数据流变更事件(比如 updateComponentById )不会触发额外重渲染,其渲染时机与取数结束后时机合并。 组件定时取数对于需要定时刷新重新取数的实时数据,可以配置 autoFetchInterval 实现定时自动取数功能: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { autoFetchInterval: () => 1000,}; autoFetchInterval :自动重新取数间隔,单位 ms,不设置则无此功能。 组件强制取数正常情况取数参数变化才会重新取数,但如有强制取数的诉求,可执行 forceFetch : import { useDesigner } from "@alife/bi-designer";export default () => { const { forceFetch } = useDesigner(); // 指定某个组件重新取数 // forceFetch('jtw4x8ns')}; forceFetch :强制取数函数,传参为组件 ID。 组件筛选触发筛选行为任何组件都可以作为筛选条件,只要实现 onFilterChange 接口就具备了筛选能力,通过 filterValue 可以拿到当前组件筛选值,下面创建一个具有筛选功能的组件: import { useDesigner } from "@alife/bi-designer";const SelectFilter = () => { const { filterValue, onFilterChange } = useDesigner(); return ( <span> <Select value={filterValue} onChange={onFilterChange}> // ... </Select> </span> );}; 当组件触发 onFilterChange 时则视为触发筛选,其作用的组件会触发 组件取数。 通过表达式设置任意 key注意, onFilterChange 与 filterValue 可以映射到组件任意 key,只需要如下定义: { props: { onChange: { type: "JSExpression", value: "this.onFilterChange" }, value: { type: "JSExpression", value: "this.filterValue" } }} 组件的 props.onChange 与 props.value 就拥有了 onFilterChange 与 filterValue 的能力。 设置筛选作用的组件那么如何定义被作用的组件呢?由于筛选关联属于运行时能力,我们需要用到 组件运行时配置 功能。 运行时能力中,筛选关联功能属于 ComponentMeta.eventConfigs 中 filterFetch 部分能力 ,即筛选条件的作用范围,在列表中的组件会在当前组件触发 onFilterChange 时触发取数: import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => createComponentInstancesArray(pageSchema?.componentInstances) // 找到画布中所有 name-list 组件 ?.filter((each) => each.componentName === "name-list") ?.map((each) => ({ // 事件类型是筛选触发取数 type: "filterFetch", // 条件由当前组件触发 source: componentInstance.id, // 作用于找到的 name-list 组件 target: each.id, })),}; 上面的例子,我们通过 eventConfigs 将所有组件名为 name-list 都做了绑定,当然你也可以根据 componentInstance.props 根据组件当前配置来绑定,自由使用。 同理,还可以实现条件反向绑定,只要设置 source 和 target 即可,source 是触发 onFilterChange 的组件,target 是被作用取数的组件。 注意: componentInstances 包含所有组件,包括自身及 root 根节点,如果要绑定所有组件,一般情况下需要排除掉自身和 root 节点: { eventConfigs: componentInstances?.filter( // 不选中 root 节点 (each) => each.componentName !== "root" && // 不选中自己 each.componentId === componentInstance.id ); // ...} 传递额外筛选信息考虑到筛选条件正向、反向绑定,或者同一个筛选条件组件针对同一个组件有多个不同筛选功能,bi-designer 支持 source 与 target 重复的多对多,比如: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "filterFetch", source: componentInstance.id, target: 1, payload: "作用于取数参数", }, { type: "filterFetch", source: componentInstance.id, target: 1, payload: "作用于字段筛选", }, ],}; 在上面的例子中,我们可以将当前组件连续绑定多个同一个目标( target ),为了区分作用,我们可以申明 payload ,这个 payload 最终会传递到 target 组件的 getFetchParam.filters 参数中,可以通过 eachFilter.payload 访问,具体见文档 组件取数 。 对于同一个组件连续绑定多个相同目标组件场景较少,但对于 A 组件配置绑定 B,B 组件配置被 A 绑定的场景还是很多的。 筛选依赖筛选条件间存在的依赖关系称为筛选依赖。 筛选 Ready 依赖筛选 Ready 依赖由 filterReady 定义: import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => createComponentInstancesArray(pageSchema?.componentInstances) // 找到画布中所有 input 组件 ?.filter((each) => each.componentName === "input") ?.map((each) => ({ type: "filterReady", source: each.id, target: componentInstance.id, })),}; target 依赖 source ,当筛选条件 source 变化时, target 组件的筛选就会失效并且被置空。 source :一旦触发 onFilterChange 。 target :组件筛选 Ready 就置为 false,且 filterValue 置为 null。 筛选 Value 依赖筛选 Value 依赖由 filterValue 定义: import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => createComponentInstancesArray(pageSchema?.componentInstances) // 找到画布中所有 input 组件 ?.filter((each) => each.componentName === "input") ?.map((each) => ({ type: "filterValue", source: each.id, target: componentInstance.id, })),}; target 依赖 source ,当筛选条件 source 变化时, target 组件的 filterValue 将被赋值为 from 的 filterValue 。 source :一旦触发 onFilterChange 。 target :组件 filterValue 就会被置为 source 组件 filterValue 的值。 组件筛选默认值默认情况下,组件筛选器的默认值为 undefined ,并且后续筛选条件变更由组件 onFilterChange 行为控制(具体可以看 组件筛选 文档)。 但如果配置了筛选默认值,或者默认从 URL 参数等,让组件筛选拥有默认值,这个需求也是非常合理的,可以通过 defaultFilterValue 定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { // 组件筛选默认值 defaultFilterValue: ({ componentInstance }) => componentInstance.props.defaultFilterValue,}; 注意此为筛选条件默认值,后续筛选条件变化不会再受此参数控制。 组件主题风格组件可以通过两种方式读取主题风格配置: JS:通过例如 props.theme.primaryColor 读取。 CSS:通过例如 var(–primaryColor) 读取。 JS 模式import { themeSelector, useDesigner } from "@alife/bi-designer";const Component: Interfaces.ComponentElement = () => { const { theme } = useDesigner(themeSelector()); return <div style={{ color: theme?.primaryColor }}>文本</div>;}; CSS 模式import "./index.scss";const Component: Interfaces.ComponentElement = () => { return <div className="custom-text">文本</div>;}; .custom-text { color: var(--primaryColor);} CSS 模式的 Key 与 JS 变量的 Key 完全相同。 组件国际化组件配置通过 JSExpression 方式使用国际化: const defaultPageSchema: Interfaces.PageSchema = { componentInstances: { test: { id: "tg43g42f", componentName: "expressionComponent", props: { variable: { type: "JSExpression", value: 'this.i18n["中国"]', }, }, }, },}; 通过 this.i18n 即可根据 key 访问国际化内容。 国际化内容配置 - 配置国际化。 JSExpression 说明 - JSExpression。 组件配置订正当组件实例版本低于最新版本号时,说明产生了回滚,也会按照顺序依次订正。 注:需要考虑数据回滚的组件,在发布前要把 undo 逻辑写好并测试后提前上线,之后再进行项目正式上线,以保证回滚后可以正确执行 undo 。 组件配置订正在 ComponentMeta.revises 中定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { revises: [ { version: 1, redo: async (prevProps) => { return prevProps; }, undo: async (prevProps) => { return prevProps; }, }, ],}; version :订正的版本号。 redo :升级到这个版本订正逻辑。 undo :回退到这个版本订正逻辑。 Return :新的组件 props 。 组件吸顶全局吸顶组件吸顶通过 ComponentMeta.fixTop 定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { fixTop: ({ componentInstance }) => true,}; 配置 fixTop 后即可吸顶,不需要组件做额外支持。 如果置顶的组件具有筛选功能,吸顶后仍具有筛选功能。 组件内吸顶通过 ComponentMeta.fixTopInsideParent 来设置组件在父容器内吸顶。 平滑取消滚动: 设置 ComponentMeta.smoothlyFadeOut 可以实现该效果。 直接让组件回到原位置: 不需要任何配置。 import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { fixTop: () => true, fixTopInsideParent: () => true, smoothlyFadeOut: () => true,}; 设置吸顶组件自定义样式设置 ComponentMeta.getFixTopStyle 来自定义组件吸顶后的样式,一般拿来设置 zIndex 。 type getFixTopStyle = (componentInfo: { componentInstance: ComponentInstance; componentMeta: ComponentMeta; dom: HTMLElement; context: any;}) => React.CSSProperties;import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { getFixTopStyle: () => ({ zIndex: 1000000, }),}; 组件渲染完成标识默认组件渲染完毕不需要主动上报,下面是自动上报机制: 组件 initFetch 为 false 时,组件 DOM Ready 作为渲染完成时机。 组件 initFetch 为 true 时,组件取数完毕后且 DOM Ready 作为渲染完成时机。 主动上报渲染完成标识对于特殊组件,比如 DOM 渲染完毕不是时机加载完毕时机时,可以选择主动上报: import { Interfaces, useDesigner } from "@alife/bi-designer";const customOnRendered: Interfaces.ComponentElement = () => { const { onRendered } = useDesigner(); return <div onClick={onRendered}>点我后这个组件才算渲染完成</div>;};const customOnRenderedMeta: Interfaces.ComponentMeta = { manualOnRendered: true,}; manualOnRendered :设置为 true 时禁用自动上报。 onRendered :主动上报组件渲染完毕,仅第一次生效。 组件阻止自动取数对于需要精细化控制取数时机的场景,可以使用 shouldFetch 控制组件取数时机: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { shouldFetch: ({ prevComponentInstance, nextComponentInstance, prevFilters, nextFilters, componentMeta, context, }) => true,}; shouldFetch 返回 false 则阻止自动取数逻辑,不会执行到 getFetchParam 与 fetcher 。 prevComponentInstance :上一次组件实例信息。 nextComponentInstance :下一次组件实例信息。 prevFilters :上一次筛选条件信息。 nextFilters :下一次筛选条件信息。 componentMeta :组件元信息。 context :上下文。 对于取数参数没变化时仍要重新取数,参考 组件强制取数。 shouldFetch 不会阻塞 组件强制取数、组件定时自动取数、组件主动取数。 shouldFetch 会阻塞 initFetch=true 初始化取数。 组件按需取数默认 bi-designer 取数是全量并发的,也就是无论组件是否出现在可视区域内,都会第一时间取数,但取数结果不会造成非可视区域组件的刷新。 如果考虑到浏览器请求并发限制,需要优先发起可视区域内组件的取数,可以将 fetchOnlyActive 设置为 true : const componentMeta = { componentName: "line-chart", fetchonlyActive: () => true,}; 当组件开启此功能后: 在可视区域内组件才会发起自动取数。 当组件从非可视区域出现在可视区域时,如果需要则会自动发起取数。 组件回调事件组件回调可以触发事件,通过运行时配置 ComponentMeta.eventConfigs 中 callback 定义: import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "callback", callbackName: "onClick", }, ],}; callbackName :回调函数名。 定义了回调时机后,我们可以触发一些 action 实现自定义效果,在后面的 更新组件 Props、更新组件配置、更新取数参数 了解详细内容。 事件 - 更新组件 Props更新组件配置属于 Action 之 setProps : import { Interfaces } from '@alife/bi-designer'const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [{ type: 'callback', callbackName: 'onClick', source: componentInstance.id, target: componentInstance.id action: { type: 'setProps', setProps: (props, eventArgs) => { return { ...props, color: 'red' } } } }]} 如上配置,效果是将 props.color 设置为 red 。 eventArgs 是事件参数,比如 onClick 如下调用: props.onClick("jack", 19); setProps: (props, eventArgs) => { return { ...props, name: eventArgs[0], age: eventArgs[1], };}; 如果有多个事件同时作用于同一个组件的 setProps ,则 setProps 函数会依次触发多次。 事件 - 更新取数参数更新组件取数参数属于 Action 之 setFetchParam : import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "callback", callbackName: "onClick", action: { type: "setFetchParam", setFetchParam: (param, eventArgs) => { return { ...param, count: true, }; }, }, }, ],}; 如上配置,效果是在取数参数中增加一项 count:true 。 事件 - 更新筛选条件更新筛选条件属于 Action 之 setFilterValue : import { Interfaces } from "@alife/bi-designer";const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "callback", callbackName: "onClick", action: { type: "setFilterValue", setFilterValue: (filterValue, eventArgs) => { return "uv"; }, }, }, ],}; 如上配置,效果是将目标组件的筛选条件值改为 uv 。 总结以上就是结合了通用搭建与 BI 特色功能的搭建引擎对组件功能的支持,如果你对功能、或者 API 有任何问题或建议,欢迎联系我。 讨论地址是:精读《数据搭建引擎 bi-designer API-组件》· Issue ##269 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《数据搭建引擎 bi-designer API-设计器》","path":"/wiki/WebWeekly/前沿技术/《数据搭建引擎 bi-designer API-设计器》.html","content":"当前期刊数: 164 bi-designer 是阿里数据中台团队自研的前端搭建引擎,基于它开发了阿里内部最大的数据分析平台,以及阿里云上的 QuickBI。 bi-designer 目前没有开源,因此文中使用的私有 npm 源 @alife/bi-designer 是无法在公网访问的。 本文介绍 bi-designer 设计器的使用 API。 bi-designer 设计有如下几个特点: 心智统一:编辑模式与渲染模式统一。 通用搭建:支持接入任意通用 npm 组件。 低入侵:围绕数据分析能力做了增强,但对组件代码无入侵。 渲染画布做搭建,第一步是将画布渲染出来,需要用到 Designer 与 Canvas 组件: import { Designer, Canvas } from '@alife/bi-designer'export () => ( <Designer> <Canvas /> </Designer>) Designer:数据容器,用于管理渲染引擎数据流。 参数 defaultPageSchema:页面 DSL 默认值。 参数 defaultMode:控制编辑渲染状态,edit or render。 Canvas:渲染画布的所有组件,会根据 DSL 结构将组件一一渲染出来。 编辑模式编辑模式 = 渲染画布(编辑模式)+ 拓展一些自定义面板。 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 的结构大致如下: { "componentInstances": { "1": { "id": "1", "componentName": "root", }, "2": { "id": "2", "parentId": "1", "componentName": "button", } }} 根据 id parentId 关系描述了组件父子关系,对于同一个父节点在流式布局下的顺序,还会增加 index 标记顺序。 注册组件DSL 描述信息中最重要的是 componentName,为了告诉渲染引擎这个组件是什么,我们需要将组件元信息(componentMetas)传递给 Designer: 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 属性定义: 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 中某个组件信息: 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 作为组件配置面板,这个配置面板中,最重要的是这块代码: 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 设置主题风格: import { Designer } from '@alife/bi-designer'const App = () => ( <Designer defaultPageSchema={{ theme: { primaryColor: '##333' } }} />) 我们也可以在运行时使用 setTheme 动态修改主题风格,做到动态切换主题: const { setTheme, theme } = useDesigner();return <Button onClick={() => { setTheme({ ...theme, primaryColor: '##ffffff' })}} /> 这些主题颜色,组件可以通过 css 变量拿到: .ok-button { color: var(--primaryColor);} 获取组件数据数据分析引擎中,组件是由数据驱动展示的,这些数据可能来自 OLAP 数据集,或者普通 URL 接口,但无论如何数据都是一个组件重要组成部分,因此对组件的取数与数据操作是 bi-designer 的一个重点。 可以利用 fetchStateSelector 获取任意组件的数据信息,包括取数状态、数据、是否有查询错误等: 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 加载一个画布中已有组件,与其共享同一套数据: import { ComponentLoader } from '@alife/bi-designer'const App = () => { return <ComponentLoader id="some-id-already-exist" />} 加载一个额外的新组件如果这个组件不需要响应事件,只是做简单的渲染,那就不需要记录到数据流中,此时仅申明 componentName 即可: import { ComponentLoader } from '@alife/bi-designer'const App = () => { return <ComponentLoader componentName="button" />} 但这种方式加载的组件存在如下问题: 其组件 id 不会存储到 pageSchema ,后端可能无法做一些校验。 无法响应事件,因为事件响应前提是组件信息存在于 pageSchema 中。 加载一个有事件功能的额外新组件通过申明 id 与 componentName 加载一个全新组件,为了在其销毁时做有效清理,请将其 id 记录到 useKeepComponentLoaders 中。 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 随时渲染一个组件副本: import { ComponentLoader } from '@alife/bi-designer'// ... 任意可拿到 componentInstance 处return ( <ComponentLoader id={componentInstance.id} />) 那么全屏就是将组件渲染到一个新容器内,非常 easy。 局部配置覆盖可以通过 DesignerProvider 实现干涉其子元素 useDesigner 获取信息的能力: 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 定义: 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 访问: { "componentInstances": { "1": { "id": "1", "componentName": "button", "props": { "text": { "type": "JSExpression", "value": "this.i18n['你好']" } } } }} 容器拓展组件 propscomponentMeta.container 可以定义组件外层容器,但有的时候我们想在容器做一点事情,比如获取宽高,以 props 的方式传递给子组件。 因为子组件以 children 的方式书写不易拓展,因此提供了 PropsProvider 来拓展子组件拿到的 props: 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 的使用方式是这样: import { useDesigner } from '@alife/bi-designer'export default () => { const { undo, redo } = useDesigner() // 撤销调用 undo() // 重做调用 redo()} 是不是觉得很简单?是的,因为所有值得撤销重做的操作在引擎内部使用了 HistoryManager 管理,因此引擎知道每一个可以被撤销或者重做的操作,直接调用函数即可。 组件复制执行 copyComponent 命令即可复制组件,比如: const App() { const { copyComponent } = useDesigner() // 复制组件 copyComponent(componentInstance) } copyComponent 的参数分别为: function copyComponent( componentInstance?: ComponentInstance, parentId?: string, index?: number) 如不指定 parentId ,默认复制到自己父元素下。 如不指定 index ,默认复制到当前元素下方。 组件模版如果觉得某些组件配置可能被复用,可以在画布组件右上角增加一个 “添加到组件模版” 按钮,bi-designer 也提供了生成、添加组件模版的方法。 创建组件模版利用 createCombine 函数从画布中已有组件创建出组件模版,也可以将其生成结果持久化,作为一个固定的组件模版: const ComponentContainer: Interfaces.InnerComponentElement = ({ componentInstance }) => { const { createCombine } = useDesigner(); const setToCombine = React.useCallback(() => { // 创建组件模版 const combine = createCombine(componentInstance.id) }, [createCombine]);} createCombine 的参数就是画布中组件的 id。 添加组件模版到画布利用 addCombine 函数将组件模版添加到画布,第一个参数就是上面生成的 combine 对象: const App = () => { const { addCombine } = useDesigner(); const addComponent = React.useCallback(() => { // 创建组件模版 const combine = addCombine(combine, parentId) }, [addCombine]);} 渲染完成标识当画布中所有组件都完成渲染了,可能要做一些监控上报,或者告诉截图软件可以截图了,bi-designer 提供了这种回调时机 onRendered: 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 字段描述当前画布中有哪些 “边节点”: import { Designer } from '@alife/bi-designer';const App = ({ defaultPageSchema }) => ( <Designer defaultPageSchema={{ ...defaultPageSchema, edges: [] }} />) 可以看到,只要任意拓展 pageSchema 即可。 2. 通过 useDesigner 拿到拓展字段首先定义一个 edgesSelector : import { DesignerState } from '@alife/bi-designer';export const edgesSelector = () => (state: DesignerState) => { return { // 从 pageSchema.edges 读取 edges edges: state.pageSchema?.edges as Edge[], };}; 在需要读取的地方结合 useDesigner : import { useDesigner } from '@alife/bi-designer';import { edgesSelector } from './selector'const Panel = () => { // 自带类型 const { edges } = useDesigner(edgesSelector())} 3. 通过 useDesigner 修改拓展字段通过 setPageSchema 更新拓展字段: 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 进行临时数据存取,要注意非结构化数据是无法监听变化的,引用永远保持不变: 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 可以阻止画布添加组件: import { Designer } from '@alife/bi-designer'const App = () => ( <Designer shouldAddComponents={({addedComponentInstancesArray, pageSchema}) => { // 阻止添加 return false }} />) addedComponentInstancesArray :添加的组件, ComponentInstance[] 类型。 shouldMoveComponents() 返回 false 可以阻止画布移动组件: import { Designer } from '@alife/bi-designer'const App = () => ( <Designer shouldmoveComponents={({movedComponentInstancesArray, targetComponentInstance, pageSchema}) => { // 阻止移动 return false }} />) movedComponentInstancesArray :移动的组件,ComponentInstance[] 类型。 taragetComponentInstance :要移动到的父组件实例信息, ComponentInstance 类型。 shouldDeleteComponents() 返回 false 可以阻止画布删除组件: import { Designer } from '@alife/bi-designer'const App = () => ( <Designer shouldDeleteComponents={({deletedComponentInstancesArray, pageSchema}) => { // 阻止删除 return false }} />) deletedComponentInstancesArray :删除的组件, ComponentInstance[] 类型。 仅刷新可视区域组件默认组件都会以按需加载的方式渲染,即对于不在可视区域的组件,不会触发任何重渲染,以此提升交互操作的效率,以及首屏速度。 对于筛选条件等可能影响到其他组件的组件,可以通过 ComponentMeta.keepActive 强制保持激活状态: import { Interfaces } from '@alife/bi-designer'const componentMeta: Interfaces.ComponentMeta = { keepActive: true} keepActive:组件始终保持激活状态,即不出现在可视区域也会被渲染与响应刷新,默认关闭。 对于特殊场景比如截图,可能要求所有组件强制为 active 状态,可以通过 forceActive 函数实现: import { Interfaces, useDesigner } from '@alife/bi-designer'const Test: Interfaces.ComponentElement = () => { const { forceActive, cancelForceActive } = useDesigner() // forceActive() 强制所有组件 active // cancelForceActive() 取消强制 active,组件根据实际情况 active}; 可以通过 getSnapshot().actives 获取任意组件当前瞬时 active 状态: 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 修改上下文。 举例子: // 初始化 pageSchemaconst 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 就可以持有状态: import { Interfaces } from '@alife/bi-designer'const statefulComponentMeta: Interfaces.ComponentMeta = { stateful: true} 被有状态的容器包裹的组件 this.state 与 this.setState 都局限在当前状态容器内,也就是当前状态容器内组件的 state 是互通的,且一个有状态容器与外部环境是隔离的,可以独立运行。 工具类拓展工具类拓展可以通过上下文访问,如下是拓展方式: 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 :内容。 用法: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《新一代前端构建工具对比》","path":"/wiki/WebWeekly/前沿技术/《新一代前端构建工具对比》.html","content":"当前期刊数: 195 本周精读的文章是 Comparing the New Generation of Build Tools。 前端工程领域近期出了不少新工具,这些新工具都运用了一些新技术或者跨领域技术,实现了一些突破,因此有必要了解一下这些工具都有什么特性,以及是否可以投入生产环境。 由于原文比较啰嗦,所以具体用法和支持细节不在这里展开,如果想进一步了解细节,可以直接阅读 原文。 精读按照从底层到上层的封装粒度,以 esbuild、snowpack、vite、wmr 的顺序介绍。 esbuildesbuild 使用 go 语言编写,由于相对 node 更为底层,且不提供 AST 操作能力,所以代码执行效率更高,根据其官方 benchmark 介绍提速有 10~100 倍: esbuild 有两大功能,分别是 bundler 与 minifier,其中 bundler 用于代码编译,类似 babel-loader、ts-loader;minifier 用于代码压缩,类似 terser。 使用 esbuild 编译代码方法如下: esbuild.build({ entryPoints: ["src/app.jsx"], outdir: "dist", define: { "process.env.NODE_ENV": '"production"' }, watch: true,}); 但由于 esbuild 无法操作 AST,所以一些需要操作 AST 的 babel 插件无法与之兼容,导致生产环境很少直接使用 esbuild 的 bundler 模块。 幸运的是 minifier 模块可以直接替换 terser 使用,可以用于生产环境: esbuild.transform(code, { minify: true,}); 由于 esbuild 牺牲了一些包大小换取了更高的执行效率,因此压缩后包体积会稍微大一些,不过也就是 177KB 与 165KB 的区别,几乎可以忽略。 esbuild 比较底层,所以可以与后续介绍的上层构建工具结合使用,当然根据工具设计理念,是否内置,内置到什么程度,以及是否允许通过插件替换就是另一回事了。 snowpacksnowpack 是一个相对轻量的 bundless 方案,之前也写过一篇 精读 snowpack,其实 bundless 就是利用浏览器支持的 ESM import 特性,利用浏览器进行模块间依赖加载,而不需要在编译时进行。 跳过编译时依赖加载可以省很多事,比如不用考虑 tree shaking 问题,也不用为了最终产物加速而使用缓存,相当于这些工作交给最终执行的浏览器了,而浏览器作为最终运行时容器,比编译时工具更了解应该如何按需加载。 仅从编译时来看,修改单个文件的编译速度与项目整体大小有关,而若不考虑整体项目,仅编译单个文件(最多递归一下有限的依赖模块,解决比如 TS 类型变量判断问题)时间复杂度一定是 O(1) 的。 实际上我们很少单独使用 snowpack,因为其编译使用的 esbuild 还未达到 1.0 稳定版本,在生态兼容与产物稳定性上存在风险,所以编译打包时往往采用 rollup 或 webpack,但这种割裂也导致了开发与生产环境不一致,这往往代表着更大的风险,因此在 vite 框架可以看到这块的取舍。 snowpack 是开箱即用的: // package.json"scripts": { "start": "snowpack dev", "build": "snowpack build"}, 我们还可以增加 snowpack.config.js 配置文件开启 remote 模式: // snowpack.config.jsmodule.exports = { packageOptions: { "source": "remote", }}; remote 模式是 Streaming Imports,即不用安装对应的 npm 包到本地,snowpack 自动从 skypack 读取文件并缓存起来。 snowpack 看起来更多是对 bundless 纯粹的尝试,而不是一个适合满足日常开发的工具,因为日常开发需要一个一站式工具,这就是后面说的 vite 与 wmr。 vite可以理解为结合了 snowpack 特色的一站式构建工具,从开发到发布全套流程都帮你搞定。 涉及的用法非常多,具体内容可以看 官方文档。 与 snowpack 不同的是,snowpack 生产打包的产物是独立的文件,而 vite 没有采用 esbuild 而是 rollup 打包,目的是为了打包为一个整体,并规避 esbuild 不稳定的风险。 另外由于 vite 集成化更高,比 snowpack 多了许多功能,比如 css 拆分、多页、使用 esbuild 进行依赖预构建、monorepo 支持、对多框架支持、SSR 等等。具体可以看 文档介绍。然而原文说这有利有弊,好处是开箱即用,弊端是缺乏定制的灵活性。 其实革命性突破主要是 bundless,在这基础上发展出一系列便捷的功能,这值得每一个工程化团队学习。其实就算决定再造一个轮子,也是维持 90% 功能不变的基础上,在默认的偏好设置做一些微调,而这些大多可以用 插件 解决。 总结下来,Vite 是一个既积极拥抱新特性,又为生产环境考虑的工程化全家桶,相比之下,技术栈过于前沿的工具只能称为玩具,而 Vite 是真的可以用一用的。 wmr由 preact 作者开发,可以理解为 preact 版的 vite。所以对于 preact 技术栈的开发者更加友好,集成度更高。 原文提到的另一个特色是,wmr 使用了 htm 转换 JSX,使其获得了更加精确的报错体验,即可以精确到源码行的同时指定到具体列。 综合功能和 vite 差不多,单页 + ssr 都支持,如果你平时使用 preact,或者想开发一个体积极小的项目,可以考虑用 wmr 全家桶。 总结新一代前端构建工具最大特色有两个:更底层的语言编写、bundless,如果用一个词描述就是高性能。积极拥抱浏览器新特性或者知识跨界都可以帮助前端领域取得新的突破。 另外构建工具已经变得越来越集成化,从仅用于编译的 esbuild,到支持开发的 snowpack,再到内置了最佳实践、甚至支持比如 ssr 等后端能力、最后到垂直场景的 vitePress,每抽象一次,都更开箱即用,但带来的灵活性降低也成为各团队自己造轮子的理由,越上层越是有自己造轮子的冲动。 这和可视化领域很像,可视化从最底层的 svg、canvas、webgl 到基于其封装的命令式框架,再到数据驱动开发框架、完全 JSON 配置化的图表库、甚至到零配置,根据数据猜配置的智能化项目,也是配置越来越少,但灵活度越来越低,使用什么层次的完全看项目对细节的要求。 不过工程化相对还是标准化的,因为可视化面向的是用户,而工程化面向的是程序员,我们不能控制用户需求,但可以控制程序员的开发习惯 :P。 最后,除了升级你的构建工具外,换一台 M1 芯片电脑也可以极大提升开发效率,笔者亲测 webpack 构建速度提升 3 倍! 讨论地址是:精读《新一代前端构建工具对比》· Issue ##316 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《最佳前端面试题》及面试官技巧","path":"/wiki/WebWeekly/前沿技术/《最佳前端面试题》及面试官技巧.html","content":"当前期刊数: 19 本期精读的文章是:The-Best-Frontend-JavaScript-Interview-Questions 讨论前端面试哪些问题,以及如何面试。 1 引言 又到了招聘的季节,如何为自己的团队找到真正优秀的人才?问哪些问题更合适?我们简单总结一把。 2 内容概要The-Best-Frontend-JavaScript-Interview-Questions 从 概念 - 算法 coding - 调试 - 设计 这 4 步全面了解候选人的基本功。 3 精读本精读由 ascoders camsong jasonslyvia 讨论而出。 网络技术发展非常迅速,前端变化尤为快,对优秀人才的面试方式在不同时期会有少许不同。 整体套路在面试之前,第一步要询问自己,是否对当前岗位的职责、要求有清晰的认识?不知道自己岗位要招什么样的人,也无法组织好面试题。 认真阅读简历,这是对候选人起码的尊重,同时也是对自己的负责。阅读简历是为了计划面试流程,不应该对所有候选人都准备相同的问题。 具体流程我们一般会通过: 开场白 候选人自我介绍 面试 附加信息 结束 开场白是最重要的,毕竟候选人如果拒绝了本次面试,后面的流程都不会存在。其次,通过候选人自我介绍,了解简历中你所疑惑的地方。简历是为了突出重点,快速判断是否基本匹配岗位要求,一旦确认了面试,全面了解候选人经验是对双方的负责。接下来重点讨论面试过程。 开放性问题面试的目的是挖掘对方的优点,而不是拿面试官自己的知识点与对方知识点做交集,看看能否匹配上 80%。但受主观因素影响,又不宜询问太多开放性问题,因此开放问题很讲究技巧。 正如上面所说,我推荐以开放性问题开场,这样便于了解候选人的经历、熟悉哪些技术点,便于后面的技术提问。如果开场就以准备好的题目展开车轮战,容易引起候选人心里紧张,同时我们问的问题不一定是候选人所在行的,技术问题不是每一个都那么重要,很多时候我们只看到了候选人的冰山一角,但此时气氛已经尴尬,很多时候会遗漏优秀人才。 开放性问题最好基于行为面试法询问(Star 法则): Situation: 场景 - 当时是怎样的场景 Task: 任务 - 当时的任务是什么 Action: 我采取了怎样的行动 Result: 达到了什么样的结果 行为面试法的好处在于还原当时场景,不但让面试官了解更多细节,也开拓了面试者的思维,让面试过程更加高效、全面。 举一个例子,比如考察候选人是否聪明,star 法则会这样询问: 在刚才的项目中,你提到了公司业务发展很快,人手不够,你是如何应对的呢? 相比不推荐的 “假设性问题” 会如此提问: 假如让你学习一个新技术,你会如何做? 更不推荐的是 “引导性问题”: 你觉得自己聪明吗? 相比于 star 法则,其他方式的提问,不但让候选人觉得突兀,不好回答,而且容易被主观想法带歪,助长了面试中投机的气氛。至于对 star 法则都精心编排的候选人,我还没有遇到过,如果遇到了肯定会劝他转行做演员 —— 开玩笑的,会通过后续技术问题甄别候选人是否有真本领。 技术问题亘古不变的问题就是考察基本功了,然而基本功随着技术的演进会有所调整,Html Css Js 这三个维度永远是不变的,但旧的 api 是否考察,取决于是否有最新 api 代替了它,如果有,在浏览器兼容性达标的基础上,可以只考察替代的 api,当然了解历史会更好。 比如 proxy 与 defineProperty 需要结合考察,因为 proxy 不兼容任何 IE 浏览器,候选人需要全面了解这两种用法。 变的地方在于对候选人使用技术框架的提问。在开放性问题中已经做好了铺垫,那无论候选人时以什么框架开发的,或者不使用框架开发,最好按照候选人的使用习惯提问。比如候选人使用 Angular 框架的开发经验较多,就重点考察对 Angular 框架设计、实现原理是否了解,实际使用中是否遇到过问题,以及对问题的解决方法,这也回到了 star 法则。 如果候选人能总结出比如当前流行的 Vue React Angular 这三个框架核心实现思想的异同,就是加分项。 对与老旧的问题,比如 jquery 的问题,也会问与设计思想相关的问题,比如候选人不知道 $.delegate,也不知道其已被 $on 在 Jq3.0 取代,这不代表候选人能力不行,最多说明候选人比较年轻。此时应该通过引导的方式,让其思考如何优化 $.bind 方法的性能,通过逐步引导,判断候选人的思维活跃度有多强。 如何防止被套路把面试官经验抛出来,怕不怕让候选人有所准备呢? —— 说实在的,几乎所有候选人都是有准备的,也不差这一篇文章。 以上是开玩笑。 面试主要是看候选人基础有多扎实,和思维能力。基础主要指的是,候选人提前了解了多少前端相关知识,比如对闭包的理解,对原生 api 的理解?如果候选人没接触过这两个知识点,会有两种情况: 这些知识点看完需要多久?如果是闭包和原生 api 的定义与用法,候选人这方面的缺陷可以通过 5 分钟来弥补,那么这种问题到底想考什么?我们真的在乎这 5 分钟看文档的时间吗?此时应该了解候选人对知识点的感悟,或者学习方式,因为这两点的差距可能几年都无法弥补 如果候选人学习能力非常强,但几乎所有前端知识点都不了解,弥补完大概一共要花 1000*5 分钟,这时候量变引发质变了,是不是说明候选人本身对技术的热情存在问题? 通过了基础问题还远远不够。甚至当问一个复杂的问题的时候,如果候选人瞬间把答案完美流畅表达出来,说明这个问题基本上白问了。 技术面更应该考察候选人的思考过程和基于此来表达出的技术能力和项目经验。如果候选人基础没有落下太多,思维足够灵活,在过往项目中主动学习,并主导解决过项目问题,说明已经比较优秀了,我们招的每一人都应当拥有激情与学习能力。 所以,当问到候选人不了解的知识点时,通过引导并挖掘出候选人拥有多少问题解决能力,才是最大的权重项,如果这个问题候选人也提前准备了,那说明准备对了。 非技术相关最后考察候选人的发展潜力与工作态度,我们一般通过询问简单的算法问题,进一步了解候选人是否对技术真正感兴趣,而不只是对前端工程感兴趣。同时,算法问题也考察候选人解决抽象问题的能力,或者让候选人设计一个组件,通过对组件需求的不断升级,考察候选人是否能及时给出解决方案。 最后是工作态度,首先会考察人品,对不懂的知识点装懂是违背诚信的行为,任何团队都不会要的。同时,不正视自己技术存在的盲点,将是技术发展的最大阻碍。不过这里也不怕被候选人套路,如果全部都回答不懂那也不用考虑了。 3 总结由于经验不多,只能编出这些体会,希望求职者多一些真诚,少一些套路,就一定会找到满意的工作。 讨论地址是:精读《最佳前端面试题》及前端面试官技巧 · Issue ##27 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《架构设计之 DCI》","path":"/wiki/WebWeekly/前沿技术/《架构设计之 DCI》.html","content":"当前期刊数: 14 本期精读文章是:The DCI Architecture 1 引言随着前端 ES6 ES7 的一路前行, 我们大前端借鉴和引进了各种其他编程语言中的概念、特性、模式;我们可以使用函数式 Functional 编程设计,可以使用面向对象 OOP 的设计,可以使用面向接口的思想,也可以使用 AOP,可以使用注解,代理、反射,各种设计模式; 在大前端辉煌发展、在数据时代的当下 我们一起阅读了一篇设计相关的老文:《The DCI Architecture》一起来再探索和复习一下 相关的设计和思想 2 内容摘要DCI 是数据 Data 场景 Context 交互 Interactions 简称, 重点是关注 数据的不同场景的交互行为, 是面向对象系统 状态和行为的一种范式设计;DCI 在许多方面是许多过去范式的统一,多年来这些模式已经成为面向对象编程的辅助工具。 尽管面向切面的编程(AOP)也有其他用途,但 DCI 满足了许多 AOP 的应用以及 Aspects 在解决问题方面的许多目标。根据 AOP 的基本原理,DCI 基于深层次的反射或元编程。与 Aspects 不同,角色聚合并组合得很好。Context 提供角色集之间的关联的范围关闭,而 Aspect 仅与应用它们的对象配对。在许多时候,虽然混合本身缺乏我们在 Context 语义中发现的动力 ,但 DCI 反映了混合风格策略。DCI 实现了多范式设计的许多简单目标,能够将过程逻辑与对象逻辑分开。然而,DCI 具有比多范式设计提供的更强大的技术更好的耦合和内聚效果 结合 ATM 汇款场景案例,讲解了一下 DCI角色提供了和用户相关 自然的边界,以转账为例,我们实际谈论的是钱的转移,以及源账户和目标账户的角色,算法(用例 角色行为集合)应该是这样:1.账户拥有人选择从一个账户到另外一个账户的钞票转移。2.系统显示有效账户3.用户选择源账户4.系统显示存在的有效账户5.账户拥有人选择目标账户。6.系统需要数额7.账户拥有人输入数额8.钞票转移 账户进行中(确认金额 修改账户等操作) 设计者的工作就是把这个用例转化为类似交易的算法,如下:1.源账户开始交易事务2.源账户确认余额可用3.源账户减少其帐目4.源账户请求目标账户增加其帐目5.源账户请求目标账户更新其日志 log6.源账户结束交易事务7.源账户显示给账户拥有人转账成功。 template <class ConcreteAccountType>class TransferMoneySourceAccount: public MoneySource{private: ConcreteDerived *const self() { return static_cast<ConcreteDerived*>(this); } void transferTo(Currency amount) { // This code is reviewable and // meaningfully testable with stubs! beginTransaction(); if (self()->availableBalance() < amount) { endTransaction(); throw InsufficientFunds(); } else { self()->decreaseBalance(amount); recipient()->increaseBalance (amount); self()->updateLog("Transfer Out", DateTime(), amount); recipient()->updateLog("Transfer In", DateTime(), amount); } gui->displayScreen(SUCCESS_DEPOSIT_SCREEN); endTransaction(); } 3 精读本次提出独到观点的同学有:@ascoders、@TingGe、@zy,精读由此归纳。 尝试从人类思维角度出发 理解DCI 即 数据(data) 场景(context) 交互(interactive)。 DCI 之所以被提出,是因为传统 mvc 代码,在越来越丰富的交互需求中变得越来越难读。有人会觉得,复杂的需求 mvc 也可以 cover 住,诚然如此,但很少有人能只读一遍源码就能理解程序处理了哪些事情,这是因为人类思维与 mvc 的传统程序设计思想存在鸿沟,我们需要脑补内容很多,才会觉得难度。 现在仍有大量程序使用面向对象的思想表达交互行为,当我们把所有对象之间的关联记录在脑海中时,可能对象之间交互行为会比较清楚,但任无法轻松理解,因为对象的封装会导致内聚性不断增加,交互逻辑会在不同对象之间跳转,对象之间的嵌套关系在复杂系统中无疑是一个理解负担。 DCI 尝试从人类思维角度出发,举一个例子:为什么在看电影时会轻轻松松的理解故事主线呢?回想一下我们看电影的过程,看到一个画面时,我们会思考三件事: 画面里有什么人或物? 人或物发生了什么行为、交互? 现在在哪?厨房?太空舱?或者原始森林? 很快把这三件事弄清楚,我们就能快速理解当前场景的逻辑,并且轻松理解该场景继续发生的状况,即便是盗梦空间这种烧脑的电影,当我们搞清楚这三个问题后,就算街道发生了 180 度扭曲,也不会存在理解障碍,反而可以吃着爆米花享受,直到切换到下一个场景为止。 当我们把街道扭曲 180 度的能力放在街道对象上时,理解就变的复杂了:这个函数什么时候被调用?为什么不好好承载车辆而自己发生扭曲?这就像电影开始时,把电影里播放的所有关于街道的状态都走马灯过一遍:我们看到街道通过了车辆、又卷曲、又发生了爆炸,实在觉得莫名其妙。 理解代码也是如此,当交互行为复杂时,把交互和场景分别抽象出来,以场景为切入点交互数据。 举个例子,传统的 mvc 可能会这么组织代码: UserModel: class My { private name = "ascoders" // 名字 private skills = ["javascript", "nodejs", "切图"] // 技能 private hp = 100 // 生命值?? private account = new Account() // 账户相关} UserController: class Controller { private my = new My() private account = new Account() private accountController = new AccountController() public cook() { // 做饭 } public coding() { // 写代码 } public fireball() { // 搓火球术。。? } public underAttack() { // 受到攻击?? } public pay() { // 支付,用到了 account 与 accountController }} 这只是我自己的行为,当我这个对象,与文章对象、付款行为发生联动时,就发生了各种各样的跳转。到目前为止我还不是非常排斥这种做法,毕竟这样是非常主流的,前端数据管理中,不论是 redux,还是 mobx,都类似 MVC。 不论如何,尝试一下 DCI 的思路吧,看看是否会像看电影一样轻松的理解代码: 以上面向对象思想主要表达了 4 个场景,家庭、工作、梦境、购物: home.scene.scala work.scene.scala dream.scene.scala buy.scene.scala 以程序员工作为例,在工作场景下,写代码可以填充我们的钱包,那么我们看到一个程序员的钱包: codingWallet.scala: case class CodingWallet(name: String, var balance: Int) { def coding(line: Int) { balance += line * 1 }} 写一行代码可以赚 1 块钱,它不需要知道在哪个场景被使用,程序员的钱包只要关注把代码变成钱。 交互是基于场景的,所以交互属于场景,写代码赚钱的交互,放在工作场景中: work.scene.scala: object MoneyTransferApp extends App { @context class MoneyTransfer(wallet: CodingWallet, time: int) { // 在这个场景中,工作 1 小时,可以写 100 行代码 // 开始工作! wallet.working role wallet { def working() { wallet.coding(time) } } } // 钱包默认有 3000 元 val wallet = CodingWallet("wallet", 3000) // 初始化工作场景,工作了 1 小时 new MoneyTransfer(wallet, 1) // 此时钱包一共拥有 3100 元 println(wallet.balance)} 小结:,就是把数据与交互分开,额外增加了场景,交互属于场景,获取数据进行交互。原文的这张图描述了 DCI 与 MVC 之间的关系: 发现并梳理现代前端模式和概念的蛛丝马迹现代前端受益于低门槛和开放,伴随 OO 和各种 MV* 盛行,也出现了越来越多的概念、模式和实践。而 DCI 作为 MVC 的补充,试图通过引入函数式编程的一些概念,来平衡 OO 、数据结构和算法模型。值得我们津津乐道的如 Mixins、Multiple dispatch、 依赖注入(DI)、Multi-paradigm design、面向切面编程(AOP)都是不错的。如果对这些感兴趣,深挖下 AngularJS 在这方面的实践会有不少收获。当然,也有另辟途径的,如 Flux 则采用了 DDD/CQRS 架构。 软件架构设计,是一个很大的话题,也是值得每位工程师长期实践和思考的内容。个人的几点体会: 一个架构,往往强调职责分离,通过分层和依赖原则,来解决程序内、程序间的相互通讯问题; 知道最好的几种可能的架构,可以轻松地创建一个适合的优化方案; 最后,必须要记住,程序必须遵循的架构。 分享些架构相关的文章: Comparison of Architecture presentation patterns MVP(SC),MVP(PV),PM,MVVM and MVC The DCI Architecture: A New Vision of Object-Oriented Programming 干净的架构 The Clean Architecture MVC 的替代方案 展示模式架构比较 MVP(SC),MVP(PV),PM,MVVM 和 MVC Software Architecture Design 【译】什么是 Flux 架构?(兼谈 DDD 和 CQRS) 结合 DCI 设想开发的过程中使用到一些设计方法和原则我们在开发的过程中多多少少都会使用到一些设计方法和原则DCI 重点是关注 数据的不同场景的交互行为, 是面向对象系统 状态和行为的一种范式设计; 它能够将过程逻辑与对象逻辑分开,是一种典型的行为模式设计;很好的点是 它根据 AOP 的基本原理,DCI 提出基于 AOP 深层次的元编程(可以理解成面向接口编程), 去促使系统的内聚效果和降低耦合度; 举个例子:在一个 BI 系统中, 在业务的发展中, 这个系统使用到了多套的 底层图表库,比如: Echarts, G2,Recharts, FusionChart; 等等; 那么问题来了, 如何去同时支持 这些底层库, 并且达到很容易切换的一个效果? 如何去面向未来的考虑 将来接入更多类型的图表? 如何去考虑扩展业务 对图表的日益增强的业务功能(如: 行列转换、智能格式化 等等) 带着这些问题, 我们再来看下 DCI 给我们的启示, 我们来试试看相应的解法: 图表的模型数据就是 数据 Data , 我们可以把[日益增强的业务功能] 认为是各个场景交互 Interactions; 接入更多类型的图表咋么搞?不同类型的图表其实是图表数据模型的转换,我们也可以把这些转换的行为过程作为一个个的切片(Aspect),每个切片都是独立的, 松耦合的 ; 接入多套底层库怎么搞? 每个图形库的 build 方法,render 方法 , resize 方法,repaint 方法 都不一样 ,怎么搞 ? 我们可以使用 DCI 提到的元编程- 我们在这里理解为面向接口编程, 我们分装一层 统一的接口;利用面向接口的父类引用指向子类对象 我们就可以很方便的 接入更多的 implement 接入更多的图形库(当然,一个系统统一一套是最好的); 4 总结DCI 是数据 Data 场景 Context 交互 Interactions 的简称,DCI 是一种特别关注行为的设计模式(行为模式),DCI 关注数据不同场景的交互行为, 是面向对象 状态和行为的一种范式设计;DCI 尝试从人类思维,过程化设计一些行为;DCI 也会使用一些面向切面和接口编程的设计思想去达到高内聚低耦合的目标。 讨论地址是:精读《架构设计 之 DCI》 · Issue ##20 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题, 欢迎来一起学习 共同探索。"},{"title":"《模态框的最佳实践》","path":"/wiki/WebWeekly/前沿技术/《模态框的最佳实践》.html","content":"当前期刊数: 2 本期精读的文章是:best practices for modals overlays dialog windows。 1 引言 我为什么要选这篇文章呢? 前端工程师今天在外界是怎么定位的。很多人以为前端都应该讨论架构层面的问题,其实不仅仅在此,我们不应该忽视交互体验这件事。 对于用户体验的追求前端工程师从来没有停止过,而模态框在产品中的出现出现过很多争议,我想知道我们是怎么思考这件事的。 2 内容概要来自 Wikipedia 的定义:模态框是一个定位于应用视窗顶层的元素。它创造了一种模式让自身保持在一个最外层的子视察下显示,并让主视窗失效。用户必须在回到主视窗前在它上面做交互动作。 模态框用处 抓住用户的吸引力 需要用户输入 在上下文下显示额外的信息 不在上下文下显示额外的信息 不要用模态框显示错误、成功或警告的信息。保持它们在页面上。 模态框的组成 退出的方式。可以是模态框上的一个按钮,可以是键盘上的一个按键,也可以是模态框外的区域。 描述性的标题。标题其实给了用户一个上下文信息。让用户知道他现在在哪个位置作操作。 按钮的内容。它一定要是可行动的,可以理解的。不要试图让按钮的内容让用户迷惑,如果你尝试做一个取消动作,但框内有一个取消的按钮,那么我是要取消一个取消呢,还是继续我的取消。 大小与位置。模态框的大小不要太大或太小,不应该。模态框的位置建议在视窗中间偏上的位置,因为在移动端如果太低的话会失去很多信息。 焦点。模态框的出现一定要吸引你的注意力,建议键盘的焦点也切换到框内。 用户发起。不要对用户造成惊吓。用用户的动作,比如一个按钮的点击来触发模态框的出现。 模态框在移动端 模态框在移动端总是不是玩转得很好。其中一个原因是一般来说模态框都太大了,占用了太多空间。建议增加设备的按键或内置的滚动条来操作,用户可以左移或放大缩小来抓住模态框。 无障碍访问 快捷键。我们应该考虑在打开,移动,管理焦点和关闭时增加对模态框的快捷键。 ARIA。在前端代码层面加上 aria 的标识,如 Role = “dialog” , aria-hidden, aria-label 3 精读模态框定位首先,Modal 与 Toast、Notification、Message 以及 Popover 都会在某个时间点被触发弹出一个浮层,但与 Modal(模态框)还是有所不同的。定义上看,上述组件都不属于模态框,因为模态框有一个重要的特性,即阻塞原来主视窗下的操作,只能在框内作后续动作。也就是说模态框从界面上彻底打断了用户心流。 当然,这也是我们需要讨论的问题,如果只是一般的消息提醒,可以用信息条、小红点等交互形式,至少是不阻塞用户操作的。在原文末引用的 10 Guidelines to Consider when using Overlays 一文中,第 8 条强调了模态框不到万不得以不应该使用。这时我们应该思考什么情况下你非常希望他不要离开页面,来读框内的信息或作操作呢? 反过来说,模态框有什么优点呢?要知道比起页面跳转来说,模态框的体验还是要轻量的多。例如,用户在淘宝上看中了一款商品,想登陆购买,此时弹出登陆模态框的体验就要远远好于跳转到登陆页面,因为用户在模态框中登陆后,就可以直接购买了。其次,模态框的内容对于当前页面来说是一种衍生或补充,可以让用户更为专注去阅读或者填写一些内容。 也就是说,当我们设计好模态框出现的时机,流畅的弹出体验,必要的上下文信息,以及友好的退出反馈,还是完全可以提升体验的。模态框的目的在于吸引注意,但一定需要提供额外的信息,或是一个重要的用户操作,或是一份重要的协议确认。在本页面即可完成流程或信息告知。 合理的使用模态框我们也总结了一些经验,更好地使用模态框。 内容是否相关。模态框是作为当前页面的一种衍生或补充,如果其内容与当前内容毫不相干,那么可以使用其他操作(如新页面跳转)来替代模态框; 模态框内部应该避免有过多的操作。模态框应该给用户一种看完即走,而且走的流畅潇洒的感觉,而不是利用繁杂的交互留住或牵制住用户; 避免出现一个以上的模态框。出现多个模态框会加深了产品的垂直深度,提高了视觉复杂度,而且会让用户烦躁起来; 不要突然打开或自动打开模态框,这个操作应该是用户主动触发的; 还有两种根据实际情况来定义: 大小。对于模态框的大小应该要有相对严格的限制,如果内容过多导致模态框或页面出现滚动条,一般来说这种体验很糟糕,但如果用于展示一些明细内容,我们可能还是会考虑使用滚动条来做; 开启或关闭动画。现在有非常多的设计倾向于用动画完成流畅的过渡,让 Modal 变得不再突兀,dribble 上有很多相关例子。但在一些围绕数据来做复杂处理的应用中,如 ERP、CRM 产品中用户通常关注点都在一个表单和围绕表单做的一系列操作,页面来回切换或复杂的看似酷炫的动画可能都会影响效率。用户需要的是直截了当的完成操作,这时候可能就不需要动画,用户想要的就是快捷的响应。 举两个例子,Facebook 在这方面给我们很好的 demo,它的分享模态框与主视窗是在同一个位置,给人非常流畅的体验。还看到一个细节,从主视窗到模态框焦点上的字体会变大。对比微博,它就把照片等分享形式直接展示出来,焦点在输入框上时也没有变化。 第二个例子是 Quora,Quora 主页呈现的是 Feed 流,点击标题就会打开一个模态框展示它回答的具体内容,内容里面是带有滚动条的,按 ESC 键就可以关闭。非常流畅的体验。相比较之下知乎首页想要快速看内容得来回切换。 可访问性的反思Accessibility 翻译过来是『无障碍访问』,是对不同终端用户的体验完善。每一个模态框,都要有通过键盘关闭的功能,通常使用 ESC 键。似乎我们程序员多少总会把我们自我的惯性思维带进实现的产品,尤其是当我们敲着外置的键盘,用着 PC 的时候。 下面的这些问题都是对可访问性的反思: 用户可能没有鼠标,或者没有键盘,甚至可能既没有鼠标也没有键盘,只使用的是语音控制?你让这些用户如何退出 很多的 Windows PC 都已经获得了很好的触屏支持,而你的网页依旧只支持了键盘跟鼠标? 在没有苹果触摸板的地方,横向滚动条是不是一个逆天的设计? 在网页里,使用 Command(Ctrl) and +/- 和使用触摸板的缩放事件是两个不同的表现? 如果你的终端用户没有好用的触摸板,但是他的确看不清你的网页上的内容。如果他用了前者,你能不能保证你的网页依然能够正常展示内容? 可访问性一直都是产品极其忽视的,在文章的最佳实践最后特别强调了它是怎么做的,对我们这些开发者是很好的督促。 模态框代码实现层面前端开发还是少不了代码层面的实现,业务代码对于有状态或无状态模态框的使用方式存在普遍问题。 对有状态模态框来说,很多库会支持 .show 直接调用的方式,那么模态框内部渲染逻辑,会在此方法执行时执行,没有什么问题。不过现在流行无状态模态框(Stateless Modal),模态框的显示与否交由父级组件控制,我们只要将模态框代码预先写好,由外部控制是否显示。 这种无状态模态框的方式,在模态框需要显示复杂逻辑的场景中,会自然将初始化逻辑写在父级,当模态框出现在循环列表中,往往会引发首屏触发 2-30 次模态框初始化运算,而这些运算最佳状态是模态框显示时执行一次,由于模态框同一时间只会出现一个,最次也是首屏初始化一次,但下面看似没问题的代码往往会引发性能危机: const TdElement = data.map(item => { return ( <Td> <Button>详情</Button> <Modal show={item.show} /> </Td> )}); 上面代码初始化执行了 N 个模态框初始化代码,显然不合适。对于 table 操作列中触发的模态框,所有行都复用同一个模态框,通过父级中一个状态变量来控制展示的内容: class Table extends Component { static state = { activeItem: null, }; render() { const { activeItem } = this.state; return ( <div> <Modal show={!!activeItem} data={activeItem} /> </div> ); }} 这种方案减少了节点数,但是可能会带来的问题是,每次模态框被展示的时候,触发是会是模态框的更新 (componentDidUpdate) 而不是新增。当然结合 table 中操作的特点,我们可以这样优化: {activeItem ? <Modal show={true} data={activeItem} /> : null} 补充阅读总结这篇讲的是最佳实践,而且是 UX 层面的。但我们还是看到一些同学提出了相反的意见,我总结下就是不同的产品或不同的用户带给我们不同的认识。这时候是不是要死守着『最佳实践』呢?这时候,对于产品而言,我们可以采集用户研究的方法去判断,用数据结论代替感官上的结论。 另外,可访问性在这两年时不时会在一些文章中看到,但非常少。这是典型的长尾需求,很多研发在做产品只考虑 90% 的用户,不清楚我们放弃的一部分用户的需求。这是从产品到研发整体的思考的缺失。 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《深入了解现代浏览器一》","path":"/wiki/WebWeekly/前沿技术/《深入了解现代浏览器一》.html","content":"当前期刊数: 219 Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第一篇。 虽然本文写于 2018 年,但如今依然值得学习,因为浏览器实现非常复杂,从细节开始学习很容易迷失方向,缺乏整体感,而这篇文章从宏观层面开始介绍,几乎没有涉及代码实现,全都是思路性的描述,非常适合培养对浏览器整体框架性思维。 原文有非常多形象的插图与动图,便于加深对知识的理解,所以也推荐直接阅读原文。 概述文章先从 CPU、GPU、操作系统开始介绍,因为这些是浏览器运行的基座。 CPU、GPU、操作系统、应用的关系CPU 即中央处理器,可以处理几乎所有计算。以前的 CPU 是单核的,现在大部分笔记电脑都是多核的,专业服务器甚至有高达 100 多核的。CPU 计算能力很强,但只能一件件事处理, GPU 一开始是为图像处理设计的,即主要处理像素点,所以拥有大量并行的处理简单事物的能力,非常适合用来做矩阵运算,而矩阵运算又是计算机图形学的基础,所以大量用在可视化领域。 CPU、GPU 都是计算机硬件,这些硬件各自都提供了一些接口供汇编语言调用;而操作系统则基于它们之上用 C 语言(如 linux)将硬件管理了起来,包括进程调度、内存分配、用户内核态切换等等;运行在操作系统之上的则是应用程序了,所以应用程序不直接和硬件打交道,而是通过操作系统间接操作硬件。 为什么应用程序不能直接操作硬件呢?这样做有巨大的安全隐患,因为硬件是没有任何抽象与安全措施的,这意味着理论上一个网页可以通过 js 程序,在你打开网页时直接访问你的任意内存地址,读取你的聊天记录,甚至读取历史输入的银行卡密码进行转账操作。 显然,浏览器作为一个应用程序,运行在操作系统之上。 进程与线程为了让程序运行的更安全,操作系统创造了进程与线程的概念(linux 对进程与线程的实现是同一套),进程可以分配独立的内存空间,进程内可以创建多个线程进行工作,这些线程共享内存空间。 因为线程间共享内存空间,因此不需通信就能交流,但内存地址相互隔离的进程间也有通信需求,需通过 IPC(Inter Process Communication)进行通信。 进程之间相互独立,即一个进程挂了不会影响到其它进程,而在一个进程中可以创建一个新进程,并与之通信,所以浏览器就采用了这种策略,将 UI、网络、渲染、插件、存储等模块进程独立,并且任意挂掉后都可以被重新唤起。 浏览器架构浏览器可以拆分为许多独立的模块,比如: 浏览器模块(Browser):负责整个浏览器内行为协调,调用各个模块。 网络模块(Network):负责网络 I/O。 存储模块(Storage):负责本地 I/O。 用户界面模块(UI):负责浏览器提供给用户的界面模块。 GPU 模块:负责绘图。 渲染模块(Renderer):负责渲染网页。 设备模块(Device):负责与各种本地设备交互。 插件模块(Plugin):负责处理各类浏览器插件。 基于这些模块,浏览器有两种可用的架构设计,一种是少进程,一种是多进程。 少进程是指将这些模块放在一个或有限的几个进程里,也就是每个模块一个线程,这样做的好处是最大程度共享了内存空间,对设备要求较低,但问题是只要一个线程挂了都会导致整个浏览器挂掉,因此稳定性较差。 多进程是指为每个模块(尽量)开辟一个进程,模块间通过 IPC 通信,因此任何模块挂掉都不会影响其它模块,但坏处是内存占用较大,比如浏览器 js 解析与执行引擎 V8 就要在这套架构下拷贝多份实例运行在每个进程中。 Chrome 多进程架构的优势Chrome 尽量为每个 tab 单独创建一个进程,所以我们才能在某个 tab 未响应时,从容的关闭它,而其它 tab 不会受到影响。不仅是 tab 间,一个 tab 内的 iframe 间也会创建独立的进程,这样做是为了保护网站的安全性。 服务化 - 单/多进程弹性架构Chrome 并不满足于采用一种架构,而是在不同环境下切换不同的架构。Chrome 将各功能模块化后,就可以自由决定当前将哪些模块放在一个进程中,将哪些模块启动独立进程,即可以在运行时决定采用哪套进程架构。 这样做的好处是,可以在资源受限的机器上开启单进程模式,以尽量节约内存开销,实际上在手机应用上就是这么做的;而在资源丰富、内核数量充足的机器上采用独立进程模式,虽然消耗了更多资源,但获得了更好的稳定性。 Iframe 独占进程site-isolation 将同一个 tab 内不同 iframe 包裹在不同的进程内运行,以确保 iframe 间资源的独占性,以及安全性。该功能直到 2018.7 才更新,是因为背后有许多复杂的工作要处理,比如开发者工具的调试、网页的全局搜索功能,都不能因为进程的隔离而受到影响,Chrome 必须让每个进程单独响应这些操作,并最终聚合在一起,让用户感受不到进程间的阻隔。 精读本文从浏览器如何基于操作系统提供的进程、线程概念构建自己的应用程序开始,从硬件、操作系统、软件的分层开始,介绍到浏览器是如何划分模块的,并且分配进程或线程给这些模块运行,这背后的思考非常有价值。 从宏观角度看,要设计一个安全稳定、高性能、具有拓展性的浏览器,首先要把各功能模块划分清楚,并定义好各模块的通信关系,在各业务场景下制定一套模块协作的流程。 浏览器的主从架构类似应用程序的主从模式,浏览器的 Browser 模块可以看作主模块,它本身用于协调其它模块的运行,并维持其它各模块的正常工作,在其它模块失去响应时等待或重新唤起,或者在模块销毁时进行内存回收。 各从模块也分工明确,比如在浏览器敲击 URL 地址时,会先通过 UI 模块响应用户的输入,并判断输入是否为 URL 地址,因为输入的可能是其它非法参数,或一些查询或设置命令。若输入的确实是 URL 地址,则校验通过后,会通知 Network 网络模块发送请求,UI 模块就不再关心请求是如何处理了。Network 模块也是相对独立的,仅处理请求的发送与接收,如果接收到的是 HTML 网页,则交给 Renderer 模块进行渲染。 有了这些相对独立且分工明确的模块划分后,将这些模块作为线程或进程管理就都不会影响它们的业务逻辑了,唯一影响的就是内存是否共享,以及某个模块 crash 后是否会影响到其它模块了,所以基于这个架构,判断设备类型,以采用单进程或多进程模式就变得简单了很多,且这个进程弹性架构本身也不需要入侵各模块业务逻辑,本身就是一套独立的机制。 浏览器作为非常复杂的应用程序,想要持续维护,就必须对每个功能点都进行合理的设计,让模块间高内聚、低耦合,这样才不至于让任何修改牵一发而动全身。 tab、iframe 进程隔离微前端的沙箱隔离方案也比较火,这里可以和浏览器 tab/iframe 隔离做个对比。 基于 js 运行时的沙箱方案大多都因为吐槽 iframe 慢而诞生的,一般会基于 with 改变沙箱代码的上下文,修改访问的全局对象引用,但基于 js 原型链特征,为了阻断向原型链追溯到主应用代码,一般会采用 proxy 对 with mock 的变量进行访问阻断。 还有一些方案利用创建空 iframe 获取到 document 变量传递给沙箱,一定程度做到了访问隔离,且对 document 添加的监听会随 iframe 销毁而销毁,便于控制。 还有一些更加彻底的尝试,将 js 代码扔到 web worker 运行,并通过 mock 模拟了 worker 运行时缺失的 dom API。 对比这些方案可以发现,只有最后 worker 的方案是最彻底的,因为浏览器创建的 worker 进程是完全资源隔离的,想要和浏览器主线程通信只能利用 postMessage,虽然有一些基于 ArrayBuffer 的内存共享方案,但因为支持的数据类型具有针对性,也不会存在安全问题。 回到浏览器开发者的视角,为什么 iframe 隔离要花费九牛二虎之力拆分多进程,最后再费很大功夫拼接回来,还原出一个相对无缝的体验?浏览器厂商其实完全可以利用上面提到的 js 运行时能力,对 API 语法进行改造,创建一个逻辑上的沙盒环境。 我认为本质原因是浏览器要实现的沙盒必须是进程层面的,也就是对内存访问权限的绝对隔离,因为逻辑层面的隔离可能随着各浏览器厂商实现差异,或 API 本身存在的逻辑漏洞而导致越权情况的出现,所以如果需要构造一个完全安全的沙盒,最好利用浏览器提供的 API 创建新的进程处理沙盒代码。 总结本文介绍了浏览器是如何基于操作系统做宏观架构设计的,主要就说了一件事,即对进程,线程模型的弹性使用。同时在 tab、iframe 的设计中也要考虑到安全性要求,在必要的时候采用进程,在浏览器自身模块间因为没有安全性问题,所以可对进程模型进行灵活切换。 讨论地址是:精读《深入了解现代浏览器一》· Issue ##374 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《正交的 React 组件》","path":"/wiki/WebWeekly/前沿技术/《正交的 React 组件》.html","content":"当前期刊数: 132 1 引言搭配了合适的设计模式的代码,才可拥有良好的可维护性,The Benefits of Orthogonal React Components 这篇文章就重点介绍了正交性原理。 所谓正交,即模块之间不会相互影响。想象一个音响的音量与换台按钮间如果不是正交关系,控制音量同时可能影响换台,这样的设备很难维护: 前端代码也一样,UI 与数据处理逻辑分离就是一种符合正交原则的设计,这样有利于长期代码质量维护。 2 概述一个拥有良好正交性的 React App 会按照如下模块分离设计: UI 元素(展示型组件)。 取数逻辑(fetch library, REST or GraphQL)。 全局状态管理(redux)。 持久化(local storage, cookies)。 文中通过两个例子说明。 让组件与取数逻辑正交比如一个展示雇员列表组件 <EmployeesPage>: import React, { useState } from "react";import axios from "axios";import EmployeesList from "./EmployeesList";function EmployeesPage() { const [isFetching, setFetching] = useState(false); const [employees, setEmployees] = useState([]); useEffect(function fetch() { (async function() { setFetching(true); const response = await axios.get("/employees"); setEmployees(response.data); setFetching(false); })(); }, []); if (isFetching) { return <div>Fetching employees....</div>; } return <EmployeesList employees={employees} />;} 这样设计看上去没问题,但其实违背了正交原则,因为 EmployeesPage 既负责渲染 UI 又关心取数逻辑。正交的写法如下: import React, { Suspense } from "react";import EmployeesList from "./EmployeesList";function EmployeesPage({ resource }) { return ( <Suspense fallback={<h1>Fetching employees....</h1>}> <EmployeesFetch resource={resource} /> </Suspense> );}function EmployeesFetch({ resource }) { const employees = resource.employees.read(); return <EmployeesList employees={employees} />;} Suspense 将 loading 状态剥离到父级组件,因此子组件只需要关心如何用数据,不需关心如何取数据(以及 loading 态)。 让组件与滚动监听正交比如一个滚动到一定距离就出现 “jump to top” 的组件 <ScrollToTop>,可能会这么实现: import React, { useState, useEffect } from "react";const DISTANCE = 500;function ScrollToTop() { const [crossed, setCrossed] = useState(false); useEffect(function() { const handler = () => setCrossed(window.scrollY > DISTANCE); handler(); window.addEventListener("scroll", handler); return () => window.removeEventListener("scroll", handler); }, []); function onClick() { window.scrollTo({ top: 0, behavior: "smooth" }); } if (!crossed) { return null; } return <button onClick={onClick}>Jump to top</button>;} 可以看到,在这个组件中,按钮与滚动状态判断逻辑混合在了一起。如果我们将 “滚动到一定距离就渲染 UI” 抽象成通用组件 IfScrollCrossed 呢? import { useState, useEffect } from "react";function useScrollDistance(distance) { const [crossed, setCrossed] = useState(false); useEffect( function() { const handler = () => setCrossed(window.scrollY > distance); handler(); window.addEventListener("scroll", handler); return () => window.removeEventListener("scroll", handler); }, [distance] ); return crossed;}function IfScrollCrossed({ children, distance }) { const isBottom = useScrollDistance(distance); return isBottom ? children : null;} 有了 IfScrollCrossed,我们就能专注写 “点击按钮跳转到顶部” 这个 UI 组件了: function onClick() { window.scrollTo({ top: 0, behavior: "smooth" });}function JumpToTop() { return <button onClick={onClick}>Jump to top</button>;} 最后将他们拼装在一起: import React from "react";// ...const DISTANCE = 500;function MyComponent() { // ... return ( <IfScrollCrossed distance={DISTANCE}> <JumpToTop /> </IfScrollCrossed> );} 这么做,我们的 <JumpToTop> 与 <IfScrollCrossed> 组件就是正交关系,而且逻辑更清晰。不仅如此,这样的抽象使 <IfScrollCrossed> 可以被其他场景复用: import React from "react";// ...const DISTANCE_NEWSLETTER = 300;function OtherComponent() { // ... return ( <IfScrollCrossed distance={DISTANCE_NEWSLETTER}> <SubscribeToNewsletterForm /> </IfScrollCrossed> );} Main 组件上面例子中,<MyComponent> 就是一个 Main 组件,Main 组件封装一些脏逻辑,即它要负责不同模块的组装,而这些模块之间不需要知道彼此的存在。 一个应用会存在多个 Main 组件,它们负责拼装各种作用域下的脏逻辑。 正交设计的好处 容易维护: 正交组件逻辑相互隔离,不用担心连带影响,因此可以放心大胆的维护单个组件。 易读: 由于逻辑分离导致了抽象,因此每个模块做的事情都相对单一,很容易猜测一个组件做的事情。 可测试: 由于逻辑分离,可以采取逐个击破的思路进行单测。 权衡如果不采用正交设计,因为模块之间的关联导致应用最终变得难以维护。但如果将正交设计应用到极致,可能会多处许多不必要的抽象,这些抽象的复用仅此一次,造成过度设计。 3 精读正交设计一定程度可以理解为合理抽象,完全不抽象与过度抽象都是不可取的,因此列举了四块需要抽象的要点:UI 元素、取数逻辑、全局状态管理、持久化。 全局状态管理注入到组件,就是一种正交的抽象模式,即组件不用关心数据从哪来,而直接使用数据,而数据管理完全交由数据流层管理。 取数逻辑往往是可能被忽略的一环,无论是像原文中直接关心到 fetch 方法的 UI 组件,还是利用取数工具库关心了 loading 状态: import useSWR from "swr";function Profile() { const { data, error } = useSWR("/api/user", fetcher); if (error) return <div>failed to load</div>; if (!data) return <div>loading...</div>; return <div>hello {data.name}!</div>;} 虽然将取数生命周期封装到自定义 hook useSWR 中,但 error 信息对 UI 组件来说就是一个脏数据:这让这个 UI 组件不仅要渲染数据,还要担心取数是否会失败,或者是否在 loading 中。 好在 Suspense 模式解决了这个问题: import { Suspense } from "react";import useSWR from "swr";function Profile() { const { data } = useSWR("/api/user", fetcher, { suspense: true }); return <div>hello, {data.name}</div>;}function App() { return ( <Suspense fallback={<div>loading...</div>}> <Profile /> </Suspense> );} 这样 <Profile> 只要专注于做数据渲染,而不用担心 useSWR('/api/user', fetcher, { suspense: true }) 这个取数过程发生了什么、是否取数失败、是否在 loading 中。因为取数状态由 Suspense 管理,而取数是否意外失败由 ErrorBoundary 管理。 合理的抽象使组件逻辑变得更简单,从而组件嵌套使用使不用担心额外影响。尤其在大型项目中,不要担心正交抽象会使本来就很多的模块数量再次膨胀,因为相比于维护 100 个相互影响,内部逻辑复杂的模块,维护 200 个职责清晰,相互隔离的模块也许会更轻松。 4 总结从正交设计角度来看,Hooks 解决了状态管理与 UI 分离的问题,Suspense 解决了取数状态与 UI 分离的问题,ErrorBoundary 解决了异常与 UI 分离的问题。 在你看来,React 还有哪些逻辑需要与 UI 分离?分别使用哪些方法呢?欢迎留言。 讨论地址是:精读《正交的 React 组件》 · Issue ##221 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《深入了解现代浏览器三》","path":"/wiki/WebWeekly/前沿技术/《深入了解现代浏览器三》.html","content":"当前期刊数: 221 Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第三篇。 概述本篇宏观的介绍 renderer process 做了哪些事情。 浏览器 tab 内 html、css、javascript 内容基本上都由 renderer process 的主线程处理,除了一些 js 代码会放在 web worker 或 service worker 内,所以浏览器主线程核心工作就是解析 web 三剑客并生成可交互的用户界面。 解析阶段首先 renderer process 主线程会解析 HTML 文本为 DOM(Document Object Model),直译为中文就是文档对象模型,所以首先要把文本结构化才能继续处理。不仅是浏览器,代码的解析也得首先经历 Parse 阶段。 对于 HTML 的 link、img、script 标签需要加载远程资源的,浏览器会调用 network thread 优先并行处理,但遇到 script 标签就必须停下来优先执行,因为 js 代码可能会改变任何 dom 对象,这可能导致浏览器要重新解析。所以如果你的代码没有修改 dom 的副作用,可以添加 async、defer 标签,或 JS 模块的方式使浏览器不必等待 js 的执行。 样式计算只有 DOM 是不够的,style 标签申明的样式需要作用在 DOM 上,所以基于 DOM,浏览器要生成 CSSOM,这个 CSSOM 主要是基于 css 选择器(selector)确定作用节点的。 布局有了 DOM、CSSOM 仍然不足以绘制网页,因为我们仅知道结构和样式,但不知道元素的位置,这就需要生成 LayoutTree 以描述布局的结构。 LayoutTree 和 DOM 结构很像了,但比如 display: none 的元素不会出现在 LayoutTree 上,所以 LayoutTree 仅考虑渲染结构,而 DOM 是一个综合描述结构,它不适合直接用来渲染。 原文特别提到,LayoutTree 有个很大的技术难点,即排版,Chrome 专门有一整个团队在攻克这个技术难题。为什么排版这么难?可以从这几个例子中体会冰山一角:盒模型间碰撞、字体撑开内容导致换行,引发更大区域的重新排版、一个盒模型撑开挤压另一个盒模型,但另一个盒模型大小变化后内容排版也随之变化,导致盒模型再次变化,这个变化又导致了外部其它盒模型的布局变化。 布局最难的地方在于,需要对所有奇奇怪怪的布局定式做一个尽量合理的处理,而很多时候布局定式间规则是相互冲突的。而且这还不考虑布局引擎的修改在数亿网页上引发未知 BUG 的风险。 绘图有了 DOM、CSSOM、LayoutTree 就够了吗?还不行,还缺少最后一环 PaintRecord,这个指绘图记录,它会记录元素的层级关系,以决定元素绘制的顺序。因为 LayoutTree 仅决定了物理结构,但不决定元素的上下空间结构。 有了 DOM、CSSOM、LayoutTree、PaintRecord 之后,终于可以绘图了。然而当 HTML 变化时,重绘的代价是巨大的,因为上面任何一步的计算结果都依赖前面一步,HTML 改变时,需要对 DOM、CSSOM、LayoutTree、PaintRecord 进行重新计算。 大部分时候浏览器都可以在 16ms 内完成,使 FPS 保持在 60 左右,但当页面结构过于复杂,这些计算本身超过了 16ms,或其中遇到 js 代码的阻塞,都会导致用户感觉到卡顿。当然对于 js 卡顿问题可以通过 requestAnimationFrame 把逻辑运算分散在各帧空闲时进行,也可以独立到 web worker 里。 合成绘图的步骤称为 rasterizing(光栅化)。在 Chrome 最早发布时,采用了一种较为简单的光栅化方案,即仅渲染可视区域内的像素点,当滚动后,再补充渲染当前滚动位置的像素点。这样做会导致渲染永远滞后于滚动。 现在一般采用较为成熟的合成技术(compositing),即将渲染内容分层绘制与渲染,这可以大大提升性能,并可通过 CSS 属性 will-change 手动申明为一个新层(不要滥用)。 浏览器会根据 LayoutTree 分析后得到 LayerTree(层树),并根据它逐层渲染。 合成层会将绘图内容切分为多个栅格并交由 GPU 渲染,因此性能会非常好。 精读从渲染分层看性能优化本篇提到了浏览器渲染的 5 个重要环节:解析、样式、布局、绘图、合成,是前端开发者日常工作中对浏览器体感最深的部分,也是优化最常发生在的部分。 其实从性能优化角度来看,解析环节可以被替代为 JS 环节,因为现代 JS 框架往往没有什么 HTML 模版内容要解析,几乎全是 JS 操作 DOM,所以可以看作 5 个新环节:JS、样式、布局、绘图、合成。 值得注意的是,几乎每层的计算都依赖上层的结果,但并不是每层都一定会重复计算,我们需要尤其注意以下几种情况: 修改元素几何属性(位置、宽高等)会触发所有层的重新计算,因为这是一个非常重量级的修改。 修改某个元素绘图属性(比如颜色和背景色),并不影响位置,则会跳过布局层。 修改比如 transform 属性会跳过布局与绘图层,这看上去很不可思议。 对于第三点,由于 transform 的内容会提升到合成层并交由 GPU 渲染,因此并不会与浏览器主线程的布局、绘图放在一起处理,所以视觉上这个元素的确产生了位移,但它和修改 left、top 的位移在实现上却有本质的不同。 所以站在浏览器开发者的角度,可以轻松理解为什么这种优化不是奇技淫巧了,因为本身浏览器的实现就把布局、绘图与合成层的行为分离开了,不同的代码底层方案不同,性能肯定会不同。你可以通过 csstriggers 查看不同 css 属性会引发哪些层的重计算。 当然作为开发者还是可以吐槽,为什么浏览器不能 “自动把 left top 与 transform 的实现细节屏蔽,并自动进行合理的分层”,然而如果浏览器厂商做不到这一点,开发者还是主动去了解实现原理吧。 隐式合成层、层爆炸、层自动合并除了 transform、will-change 属性外,还有很多种情况元素会提升到合成层,比如 video、canvas、iframe,或 fixed 元素,但这些都有明确的规则,所以属于显示合成。 而隐式合成是指元素没有被特别标记,但也被提升到合成层的情况,这种情况常见发生在 z-index 元素产生重叠时,下方的元素显示申明提升到合成层,则浏览器为了保证 z-index 覆盖关系,就要隐式把上方的元素提升到合成层。 层爆炸是指隐式合成的原因,当 css 出现一些复杂行为时(比如轨迹动画),浏览器无法实时捕捉哪些元素位于当前元素上方,所以只好把所有元素都提升到合成层,当合成层数量过多,主线程与 GPU 的通信可能会成为瓶颈,反而影响性能。 浏览器也会支持层自动合并,比如隐式提升到合成层时,多个元素会自动合并在一个合成层里。但这种方式也并不总是靠谱,自动处理毕竟猜不到开发者的意图,所以最好的优化方式是开发者主动干预。 我们只要注意将所有显示提升到合成层的元素放在 z-index 的上方,这样浏览器就有了判断依据,不用再担惊受怕会不会这个元素突然移动到某个元素的位置,导致压住了那个元素,于是又不得不把这个元素给隐式提升到合成层以保证它们之间顺序的正确性,因为这个元素本来就位于其它元素的最上方。 总结读完这篇文章,希望你能根据浏览器在渲染进程的实现原理,总结出更多代码级别的性能优化经验。 最后想要吐槽的是,浏览器规范由于是逐步迭代的,因此看似都在描述位置的 css 属性其实背后实现原理是不同的,虽然这个规则体现在 W3C 规范上,但如果仅从属性名是很难看出来端倪的,因此想要做极致性能优化就必须了解浏览器实现原理。 讨论地址是:精读《深入了解现代浏览器三》· Issue ##379 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《深入了解现代浏览器二》","path":"/wiki/WebWeekly/前沿技术/《深入了解现代浏览器二》.html","content":"当前期刊数: 220 Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第二篇。 概述本篇重点介绍了 浏览器路由跳转后发生了什么,下一篇会介绍浏览器的渲染进程是如何渲染网页的,环环相扣。 在上一篇介绍了,browser process 包含 UI thread、network thread 和 storage thread,当我们在浏览器菜单栏输入网址并敲击回车时,这套动作均由 browser process 的 UI thread 响应。 接下来,按照几种不同的路由跳转场景,分别介绍了内部流程。 普通的跳转第一步,UI thread 响应输入,并判断是否为一个合法的网址,当然输入的也可能是个搜索协议,这就会导致分发到另外的服务处理。 第二步,如果第一步输入的是合法网址,则 UI thread 会通知 network thread 获取网页内容,network thread 会寻找合适的协议处理网络请求,一般会通过 DNS 协议 寻址,通过 TLS 协议 建立安全链接。如果服务器返回了比如 301 重定向信息,network thread 会通知 UI thread 这个信息,再启动一遍第二步。 第三步,读取响应内容,在这一步 network thread 会首先读取首部一些字节,即我们常说的响应头,其中包含 Content-Type 告知返回内容是什么。如果返回内容是 HTML,则 network thread 会将数据传送给 renderer process。这一步还会校验安全性,比如 CORB 或 cross-site 问题。 第四步,寻找 renderer process。一旦所有检查都完成,network thread 会通知 UI thread 已经准备好跳转了(注意此时并没有加载完所有数据,第三步只是检查了首字节),UI thread 会通知 renderer process 进行渲染。为了提升性能,UI thread 在通知 network thread 的同时就会实例化一个 renderer process 等着,一旦 network thread 完毕后就可以立即进入渲染阶段,如果检查失败则丢弃提前实例化的 renderer process。 第五步,确认导航。第四步后,browser process 通过 IPC 向 renderer process 传送 stream(精读《web streams》)数据。此时导航会被确认,浏览器的各个状态(比如导航状态、前进后退历史)将会被修改,同时为了方便 tab 关闭后快速恢复,会话记录会被存储在硬盘。 额外步骤,加载完成。当 renderer process 加载完成后(具体做了什么下一篇会说明),会通知 browser process onLoad 事件,此时浏览器完成最终加载完毕状态,loading 圆圈也会消失,各类 onLoad 的回调触发。注意此时 js 可能会继续加载远程资源,但这都是加载状态完成后的事了。 跳转到别的网站当你准备跳转到别的网站时,在执行普通跳转流程前,还会响应 beforeunload 事件,这个事件注册在 renderer process,所以 browser process 需要检查 renderer process 是否注册了这个响应。注册 beforeunload 无论如何都会拖慢关闭 tab 的速度,所以如无必要请勿注册。 如果跳转是 js 发出的,那么执行跳转就由 renderer process 触发,browser process 来执行,后续流程就是普通的跳转流程。要注意的是,当执行跳转时,会触发原网站 unload 等事件(网页生命周期),所以这个由旧的 renderer process 响应,而新网站会创建一个新的 renderer process 处理,当旧网页全部关闭时,才会销毁旧的 renderer process。 也就是说,即便只有一个 tab,在跳转时,也可能会在短时间内存在多个 renderer process。 Service WorkerService Worker 可以在页面加载前执行一些逻辑,甚至改变网页内容,但浏览器仍然把 Service Worker 实现在了 renderer process 中。 当 Service Worker 被注册后,会被丢到一个作用域中,当 UI thread 执行时会检查这个作用域是否注册了 Service Worker,如果有,则 network thread 会创建一个 renderer process 执行 Service Worker(因为是 js 代码)。然后网络响应会被 Service Worker 接管。 但这样会慢一步,所以 UI thread 往往会在注册 Service Worker 的同时告诉 network thread 发送请求,这就是 Navigation Preload 机制。 本文介绍了网页跳转时发生的步骤,涉及 browser process、UI thread、network thread、renderer process 的协同。 精读也许你会有疑问,为什么是 renderer process 而不是 renderer thread?因为相比 process(进程)相比 thread(线程),之间数据是被操作系统隔离的,为了网页间无法相互读取数据(mysite.com 读取你 baidu.com 正在输入的账号密码),浏览器必须为每个 tab 创建一个独立的进程,甚至每个 iframe 都必须是独立进程。 读完第二篇,应该能更深切的感受到模块间合理分工的重要性。 UI thread 处理浏览器 UI 的展现与用户交互,比如当前加载的状态变化,历史前进后退,浏览器地址栏的输入、校验与监听按下 Enter 等事件,但不会涉及诸如发送请求、解析网页内容、渲染等内容。 network thread 也仅处理网络相关的事情,它主要关心通信协议、安全协议,目标就是快速准确的找到网站服务器,并读取其内容。network thread 会读取内容头做一些前置判断,读取内容和 renderer process 做的事情是有一定重合的,但 network thread 读取内容头仅为了判断内容类型,以便交给渲染引擎还是下载管理器(比如一个 zip 文件),所以为了不让渲染引擎知道下载管理器的存在,读取内容头必须由 network thread 来做。 与 renderer process 的通信也是由 browser process 来做的,也就是 UI thread、network thread 一旦要创建或与 renderer process 通信,都会交由它们所在的 browser process 处理。 renderer process 仅处理渲染逻辑,它不关心是从哪来的,比如是网络请求过来的,还是 Service Worker 拦截后修改的,也不关心当前浏览器状态是什么,它只管按照约定的接口规范,在指定的节点抛出回调,而修改应用状态由其它关心的模块负责,比如 onLoad 回调触发后,browser process 处理浏览器的状态就是一个例子。 再比如 renderer process 里点击了一个新的跳转链接,这个事情发生在 renderer process,但会交给 browser process 处理,因为每个模块解耦的非常彻底,所以任何复杂工作都能找到一个能响应它的模块,而这个模块也只要处理这个复杂工作的一部分,其余部分交给其它模块就好了,这就是大型应用维护的秘诀。 所以在浏览器运行周期里,有着非常清晰的逻辑链路,这些模块必须事先规划设计好,很难想象这些模块分工是在开发中逐渐形成的。 最后提到加速优化,Chrome 惯用技巧就是,用资源换时间。即宁可浪费潜在资源,也要让事物尽可能的并发,这些从提前创建 renderer process、提前发起 network process 都能看出来。 总结深入了解现代浏览器二介绍了网页跳转时发生的,browser process 与 renderer process 是如何协同的。 也许这篇文章可以帮助你回答 “聊聊在浏览器地址栏输入 www.baidu.com 并回车后发生了什么事儿吧!” 讨论地址是:精读《深入了解现代浏览器二》· Issue ##375 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《深入了解现代浏览器四》","path":"/wiki/WebWeekly/前沿技术/《深入了解现代浏览器四》.html","content":"当前期刊数: 222 Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第四篇。 概述前几章介绍了浏览器的基础进程、线程以及它们之间协同的关系,并重点说到了渲染进程是如何处理页面绘制的,那么最后一章也就深入到了浏览器是如何处理页面中事件的。 全篇站在浏览器实现的视角思考问题,非常有趣。 输入进入合成器这是第一小节的标题。乍一看可能不明白在说什么,但这句话就是本文的核心知识点。为了更好的理解这句话,先要解释输入与合成器是什么: 输入:不仅包括输入框的输入,其实所有用户操作在浏览器眼中都是输入,比如滚动、点击、鼠标移动等等。 合成器:第三节说过的,渲染的最后一步,这一步在 GPU 进行光栅化绘图,如果与浏览器主线程解耦的化效率会非常高。 所以输入进入合成器的意思是指,在浏览器实际运行的环境中,合成器不得不响应输入,这可能会导致合成器本身渲染被阻塞,导致页面卡顿。 “non-fast” 滚动区域由于 js 代码可以绑定事件监听,而且事件监听中存在一种 preventDefault() 的 API 可以阻止事件的原生效果比如滚动,所以在一个页面中,浏览器会对所有创建了此监听的区块标记为 “non-fast” 滚动区域。 注意,只要创建了 onwheel 事件监听就会标记,而不是说调用了 preventDefault() 才会标记,因为浏览器不可能知道业务什么时候调用,所以只能一刀切。 为什么这种区域被称为 “non-fast”?因为在这个区域触发事件时,合成器必须与渲染进程通信,让渲染进程执行 js 事件监听代码并获得用户指令,比如是否调用了 preventDefault() 来阻止滚动?如果阻止了就终止滚动,如果没有阻止才会继续滚动,如果最终结果是不阻止,但这个等待时间消耗是巨大的,在低性能设备比如手机上,滚动延迟甚至有 10~100ms。 然而这并不是设备性能差导致的,因为滚动是在合成器发生的,如果它可以不与渲染进程通信,那么即便是 500 元的安卓机也可以流畅的滚动。 注意事件委托更有意思的是,浏览器支持一种事件委托的 API,它可以将事件委托到其父节点一并监听。 这本是一个非常方便的 API,但对浏览器实现可能是一个灾难: document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault(); }}); 如果浏览器解析到上面的代码,只能用无语来形容。因为这意味着必须对全页面都进行 “non-fast” 标记,因为代码委托的是整个 document!这会导致滚动非常慢,因为在页面任何地方滚动都要发生一次合成器与渲染进程的通信。 所以最好的办法就是不要写这种监听。但还有一种方案是,告诉浏览器你不会 preventDefault(),这是因为 chrome 通过对应用源码统计后发现,大约 80% 的事件监听没有 preventDefault(),而仅仅是做别的事情,所以合成器应该可以与渲染进程的事件处理并行进行,这样既不卡顿,逻辑也不会丢失。所以添加了一种 passive: true 的标记,标识当前事件可以并行处理: document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault() } }, {passive: true}); 这样就不会卡顿了,但 preventDefault() 也会失效。 检查事件是否可取消对于 passive: true 的情况,事件就实际上变得不可取消了,所以我们最好在代码里做一层判断: document.body.addEventListener('touchstart', event => { if (event.cancelable && event.target === area) { event.preventDefault() } }, {passive: true}); 然而这仅仅是阻止执行没有意义的 preventDefault(),并不能阻止滚动。这种情况下,最好的办法是通过 css 申明来阻止横向移动,因为这个判断不会发生在渲染进程,所以不会导致合成器与渲染进程的通信: ##area { touch-action: none;} 事件合并由于事件触发频率可能比浏览器帧率还要高(1 秒 120 次),如果浏览器坚持对每个事件都进行响应,而一次事件都必须在 js 里响应一次的话,会导致大量事件阻塞,因为当 FPS 为 60 时,一秒也仅能执行 60 次事件响应,所以事件积压是无法避免的。 为了解决这个问题,浏览器在针对可能导致积压的事件,比如滚动事件时,将多个事件合并到一次 js 中,仅保留最终状态。 如果不希望丢掉事件中间过程,可以使用 getCoalescedEvents 从合并事件中找回每一步事件的状态: window.addEventListener('pointermove', event => { const events = event.getCoalescedEvents(); for (let event of events) { const x = event.pageX; const y = event.pageY; // draw a line using x and y coordinates. }}); 精读只要我们认识到事件监听必须运行在渲染进程,而现代浏览器许多高性能 “渲染” 其实都在合成层采用 GPU 做,所以看上去方便的事件监听肯定会拖慢页面流畅度。 但就这件事在 React 17 中有过一次讨论 Touch/Wheel Event Passiveness in React 17(实际上在即将到来的 18 该问题还在讨论中 React 18 not passive wheel / touch event listeners support),因为 React 可以直接在元素上监听 Touch、Wheel 事件,但其实框架采用了委托的方式在 document(后在 app 根节点)统一监听,这就导致了用户根本无从决定事件是否为 passive,如果框架默认 passive,会导致 preventDefault() 失效,否则性能得不到优化。 就结论而言,React 目前还是对几个受影响的事件 touchstart touchmove wheel 采用 passive 模式,即: const Test = () => ( <div // 没有用的,无法阻止滚动,因为委托处默认 passive onWheel={event => event.preventDefault()} > ... </div>) 虽然结论如此而且对性能友好,但并不是一个让所有人都能满意的方案,我们看看当时 Dan 是如何思考,并给了哪些解决方案的。 首先背景是,React 16 事件委托绑定在 document 上,React 17 事件委托绑定在 App 根节点上,而根据 chrome 的优化,绑定在 document 的事件委托默认是 passive 的,而其它节点的不会,因此对 React 17 来说,如果什么都不做,仅改变绑定节点位置,就会存在一个 Break Change。 第一种方案是坚持 Chrome 性能优化的精神,委托时依然 pasive 处理。这样处理至少和 React 16 一样,preventDefault() 都是失效的,虽然不正确,但至少不是 BreakChange。 第二种方案即什么都不做,这导致原本默认 passive 的因为绑定到非 document 节点上而 non-passive 了,这样做不仅有性能问题,而且 API 会存在 BreackChange,虽然这种做法更 “原生”。 touch/wheel 不再采用委托,意味着浏览器可以有更少的 “non-fast” 区域,而 preventDefault() 也可以生效了。 最终选择了第一个方案,因为暂时不希望在 React API 层面出现行为不一致的 BreakChange。 然而 React 18 是一次 BreakChange 的时机,目前还没有进一步定论。 总结从浏览器角度看待问题会让你具备上帝视角而不是开发者视角,你不会再觉得一些奇奇怪怪的优化逻辑是 Hack 了,因为你了解浏览器背后是如何理解与实现的。 不过我们也会看到一些和实现强绑定的无奈,在前端开发框架实现时造成了不可避免的困扰。毕竟作为一个不了解浏览器实现的开发者,自然会认为 preventDefault() 绑定在滚动事件时,一定可以阻止默认滚动行为呀,但为什么因为: 浏览器分为合成层和渲染进程,通信成本较高导致滚动事件监听会引发滚动卡顿。 为了避免通信,浏览器默认为 document 绑定开启 passive 策略减少 “non-fast” 区域。 开启了 passive 的事件监听 preventDefault() 会失效,因为这层实现在 js 里而不是 GPU。 React16 采用事件代理,把元素 onWheel 代理到 document 节点而非当前节点。 React17 将 document 节点绑定下移到了 App 根节点,因此浏览器优化后的 passive 失效了。 React 为了保持 API 不发生 BreakChange,因此将 App 根节点绑定的事件委托默认补上了 passive,使其表现与绑定在 document 一样。 总之就是 React 与浏览器实现背后的纠纷,导致滚动行为阻止失效,而这个结果链条传导到了开发者身上,而且有明显感知。但了解背后原因后,你应该能理解一下 React 团队的痛苦吧,因为已有 API 确实没有办法描述是否 passive 这个行为,所以这是个暂时无法解决的问题。 讨论地址是:精读《深入了解现代浏览器四》· Issue ##381 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《深度学习 - 函数式之美》","path":"/wiki/WebWeekly/前沿技术/《深度学习 - 函数式之美》.html","content":"当前期刊数: 125 1 引言函数式语言在深度学习领域应用很广泛,因为函数式与深度学习模型的契合度很高,The Beauty of Functional Languages in Deep Learning — Clojure and Haskell 就很好的诠释了这个道理。 通过这篇文章可以加深我们对深度学习与函数式编程的理解。 2 概述与精读深度学习是机器学习中基于人工神经网络模型的一个分支,通过模拟多层神经元的自编码神经网络,将特征逐步抽象化,这需要多维度、大数据量的输入。TensorFlow 和 PyTorch 是比较著名的 Python 深度学习框架,同样 Keras 在 R 语言中也很著名。然而在生产环境中,基于 性能和安全性 的考虑,一般会使用函数式语言 Clojure 或 Haskell。 在生产环境中,可能要并发出里几百万个参数,因此面临的挑战是:如何高效、安全的执行这些运算。 所以为什么函数式编程语言可以胜任深度学习的计算要求呢? 深度学习的计算模型本质上是数学模型,而数学模型本质上和函数式编程思路是一致的:数据不可变且函数间可以任意组合。这意味着使用函数式编程语言可以更好的表达深度学习的计算过程,因此更容易理解与维护,同时函数式语言内置的 Immutable 数据结构也保障了并发的安全性。 另外函数式语言的函数之间都是相互隔离的,即便在多线程环境下也不会发生竞争和死锁的情况,函数式编程语言会自动处理这些情况。 比如说 Clojure,它甚至可在两个同时修改同一引用的程序并发运行时,自动重试其中之一,而不需要手动加锁: (import ‘(java.util.concurrent Executors))(defn test-stm [nitems nthreads niters] (let [refs (map ref (repeat nitems 0)) pool (Executors/newFixedThreadPool nthreads) tasks (map (fn [t] (fn [] (dotimes [n niters] (dosync (doseq [r refs] (alter r + 1 t)))))) (range nthreads))] (doseq [future (.invokeAll pool tasks)] (.get future)) (.shutdown pool) (map deref refs)))(test-stm 10 10 10000) -> (550000 550000 550000 550000 550000 550000 550000 550000 550000 550000) 上面的代码创建了引用(refs),同时创建了多个线程自增这个引用对象,按理说每个线程都修改这个引用会导致竞争状态出现,但从结果来看是正常的,说明 Clojure 引擎在执行时会自动解决这个问题。实际上当两个线程出现竞争而失败时,Clojure 会自动重试其中之一。 原文介绍 Clojure 的另一个优势是并行效率高: (defn calculate-pixels-2 [] (let [n (* *width* *height*) work (partition (/ n 16) (range 0 n)) result (pmap (fn [x] (doall (map (fn [p] (let [row (rem p *width*) col (int (/ p *height*))] (get-color (process-pixel (/ row (double *width*)) (/ col (double *height*)))))) x))) work)] (doall (apply concat result)))) 使用 partition 结合 pmap 可以使并发效率达到最大化,也就是 CPU 几乎都消耗在实际计算上,而不是并行的任务管理与上下文切换。Clojure 凭借 partition 对计算进行分区,采取分而治之并对分区计算结果进行合并的思路优化了并发性能。 原文介绍 Clojure 另一个特性是函数链式调用: ;; pipe arg to function(-> "x" f1) ; "x1";; pipe. function chaining(-> "x" f1 f2) ; "x12" 其中 (-> "x" f1 f2) 等价于 f2(f1("x")),这种描述不仅更简洁清晰,也更接近于实际数学模型。 原文介绍 最后,Clojure 还具备计算安全性,计算过程不会修改已有的数据,因此在神经网络的任何一层的原始值都会保留,每层计算都可以独立运行且函数永远幂等。 Haskell 也有独特的优势,它具有类型推断、惰性求值等特性,被认为更适合用于机器学习。 类型推断即 Haskell 类型都是静态的,如果试图赋予错误的类型会报错。 Haskell 的另一个优势是可以非常清晰的描述数学模型。 想想一般数学模型是怎么描述函数的: fn => f1 = 1 f2 = 9 f3 = 16 n > 2, fn = 3fn-3 + 2fn-2 + fn-1 一般语言用 if-else 描述等价关系,但 Haskell 可以几乎原汁原味的还原函数定义过程: solve :: Int -> Intergersolve 1 = 1solve 2 = 9solve 3 = 16solve n = 3 * solve (n - 3) + 2 * solve (n - 2) + solve (n - 1) 这使得阅读 Haskell 代码和阅读数学公式一样轻松。 原文 Haskell 另一个优势是惰性求值,即计算会在真正用到时才进行,而不会在计算前提前消费掉,比如: let x = [1..]let y = [2,4 ..]head (tail tail( (zip x y))) 可以看到,x 与 y 分别是 1,2,3,4,5,6... 与 2,4,6,8... 的无限数组,而 zip 函数将其整合为一个新数组 (1,2),(2,4),(3,6),(4,8)... 这也是无限数组,如果将 zip 函数执行完那么程序就会永远执行下去。但 Haskell 却不会陷入死循环,而是直接输出第一位数字 1。这就是惰性计算的特性,无论数组有多长,只有真正用到某项时才对其进行计算,所以哪怕初始数据量或计算量很大,实际消耗的运算资源只取决于这次计算实际用到的部分。 由于深度学习数据量巨大,惰性求值可以忽略海量数据输入,大大提升计算性能。 3 总结本文介绍了为什么深度学习更适合使用函数式语言,以及介绍了 Clojure 与 Haskell 语言的共性:安全性、高性能,以及各自独有的特性,证明了为何这两种语言更适合用在深度学习中。 在前端领域说到函数式或函数之美,大部分时候想到的是 Class Component 与 Function Component 的关系,这个理解是较为片面的。通过本文我们可以了解到,函数式的思想与数学表达式思想如出一辙,以写数学公式的思维方式写代码,就是一种较好的函数式编程思路。 函数式应该只有表达式,没有语句,这是因为函数式是为了处理运算而诞生的,因此很适合用在深度学习领域。 讨论地址是:精读《深度学习 - 函数式之美》 · Issue ##212 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《正则 ES2018》","path":"/wiki/WebWeekly/前沿技术/《正则 ES2018》.html","content":"当前期刊数: 91 1. 引言本周精读的文章是 regexp-features-regular-expressions。 这篇文章介绍了 ES2018 正则支持的几个重要特性: Lookbehind assertions - 后行断言 Named capture groups - 命名捕获组 s (dotAll) Flag - . 匹配任意字符 Unicode property escapes - Unicode 属性转义 2. 概述还在用下标匹配内容吗?匹配任意字符只有 [\\w\\W] 吗?现在正则有更简化的写法了,事实上正则正在变得更加易用,是时候更新对正则的认知了。 2.1. Lookbehind assertions完整的断言定义分为:正/负向断言 与 先/后行断言 的笛卡尔积组合,在 ES2018 之前仅支持先行断言,现在终于支持了后行断言。 解释一下这四种断言: 正向先行断言 (?=...) 表示之后的字符串能匹配 pattern。 const re = /Item(?= 10)/;console.log(re.exec("Item"));// → nullconsole.log(re.exec("Item5"));// → nullconsole.log(re.exec("Item 5"));// → nullconsole.log(re.exec("Item 10"));// → ["Item", index: 0, input: "Item 10", groups: undefined] 负向先行断言 (?!...) 表示之后的字符串不能匹配 pattern。 const re = /Red(?!head)/;console.log(re.exec("Redhead"));// → nullconsole.log(re.exec("Redberry"));// → ["Red", index: 0, input: "Redberry", groups: undefined]console.log(re.exec("Redjay"));// → ["Red", index: 0, input: "Redjay", groups: undefined]console.log(re.exec("Red"));// → ["Red", index: 0, input: "Red", groups: undefined] 在 ES2018 后,又支持了两种新的断言方式: 正向后行断言 (?<=...) 表示之前的字符串能匹配 pattern。 先行时字符串放前面,pattern 放后面;后行时字符串放后端,pattern 放前面。先行匹配以什么结尾,后行匹配以什么开头。 const re = /(?<=€)\\d+(\\.\\d*)?/;console.log(re.exec("199"));// → nullconsole.log(re.exec("$199"));// → nullconsole.log(re.exec("€199"));// → ["199", undefined, index: 1, input: "€199", groups: undefined] 负向后行断言 (?<!...) 表示之前的字符串不能匹配 pattern。 注:下面的例子表示 meters 之前 不能匹配 三个数字。 const re = /(?<!\\d{3}) meters/;console.log(re.exec("10 meters"));// → [" meters", index: 2, input: "10 meters", groups: undefined]console.log(re.exec("100 meters"));// → null 文中给了一个稍复杂的例子,结合了 正向后行断言 与 负向后行断言: 注:下面的例子表示 meters 之前 能匹配 两个数字,且 之前 不能匹配 数字 35. const re = /(?<=\\d{2})(?<!35) meters/;console.log(re.exec("35 meters"));// → nullconsole.log(re.exec("meters"));// → nullconsole.log(re.exec("4 meters"));// → nullconsole.log(re.exec("14 meters"));// → ["meters", index: 2, input: "14 meters", groups: undefined] 2.2. Named Capture Groups命名捕获组可以给正则捕获的内容命名,比起下标来说更可读。 其语法是 ?<name>: const re = /(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})/;const [match, year, month, day] = re.exec("2020-03-04");console.log(match); // → 2020-03-04console.log(year); // → 2020console.log(month); // → 03console.log(day); // → 04 也可以在正则表达式中,通过下标 \\1 直接使用之前的捕获组,比如: 解释一下,\\1 代表 (\\w\\w) 匹配的内容而非 (\\w\\w) 本身,所以当 (\\w\\w) 匹配了 'ab' 后,\\1 表示的就是对 'ab' 的匹配了。 console.log(/(\\w\\w)\\1/.test("abab")); // → true// if the last two letters are not the same// as the first two, the match will failconsole.log(/(\\w\\w)\\1/.test("abcd")); // → false 对于命名捕获组,可以通过 \\k<name> 的语法访问,而不需要通过 \\1 这种下标: 下标和命名可以同时使用。 const re = /\\b(?<dup>\\w+)\\s+\\k<dup>\\b/;const match = re.exec("I'm not lazy, I'm on on energy saving mode");console.log(match.index); // → 18console.log(match[0]); // → on on 2.3. s (dotAll) Flag虽然正则中 . 可以匹配任何字符,但却无法匹配换行符。因此聪明的开发者们用 [\\w\\W] 巧妙的解决了这个问题。 然而这终究是个设计缺陷,在 ES2018 支持了 /s 模式,这个模式下,. 等价于 [\\w\\W]: console.log(/./s.test(" ")); // → trueconsole.log(/./s.test("\\r")); // → true 2.4. Unicode Property Escapes正则支持了更强大的 Unicode 匹配方式。在 /u 模式下,可以用 \\p{Number} 匹配所有数字: u 修饰符可以识别所有大于 0xFFFF 的 Unicode 字符。 const regex = /^\\p{Number}+$/u;regex.test("²³¹¼½¾"); // trueregex.test("㉛㉜㉝"); // trueregex.test("ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫ"); // true \\p{Alphabetic} 可以匹配所有 Alphabetic 元素,包括汉字、字母等: const str = "漢";console.log(/\\p{Alphabetic}/u.test(str)); // → true// the \\w shorthand cannot match 漢console.log(/\\w/u.test(str)); // → false 终于有简便的方式匹配汉字了。 2.5. 兼容表可以到 原文 查看兼容表,总体上只有 Chrome 与 Safari 支持,Firefox 与 Edge 都不支持。所以大型项目使用要再等几年。 3. 精读文中列举的四个新特性是 ES2018 加入到正则中的。但正如兼容表所示,这些特性基本还都不能用,所以不如我们再温习一下 ES6 对正则的改进,找一找与 ES2018 正则变化的结合点。 3.1. RegExp 构造函数优化当 RegExp 构造函数第一个参数是正则表达式时,允许指定第二个参数 - 修饰符(ES5 会报错): new RegExp(/book(?=s)/giu, "iu"); 不痛不痒的优化,,毕竟大部分时间构造函数不会这么用。 3.2. 字符串的正则方法将字符串的 match()、replace()、search、split 方法内部调用时都指向到 RegExp 的实例方法上,比如 String.prototype.match 指向 RegExp.prototype[Symbol.match]。 也就是正则表达式原本应该由正则实例触发,但现在却支持字符串直接调用(方便)。但执行时其实指向了正则实例对象,让逻辑更为统一。 举个例子: "abc".match(/abc/g) / // 内部执行时,等价于 abc / g[Symbol.match]("abc"); 3.3. u 修饰符概述中,Unicode Property Escapes 就是对 u 修饰符的增强,而 u 修饰符是在 ES6 中添加的。 u 修饰符的含义为 “Unicode 模式”,用来正确处理大于 \\uFFFF 的 Unicode 字符。 同时 u 修饰符还会改变以下正则表达式的行为: 点字符原本支持单字符,但在 u 模式下,可以匹配大于 0xFFFF 的 Unicode 字符。 将 \\u{61} 含义由匹配 61 个 u 改编为匹配 Unicode 编码为 61 号的字母 a。 可以正确识别非单字符 Unicode 字符的量词匹配。 \\S 可以正确识别 Unicode 字符。 u 模式下,[a-z] 还能识别 Unicode 编码不同,但是字型很近的字母,比如 \\u212A 表示的另一个 K。 基本上,在 u 修饰符模式下,所有 Unicode 字符都可以被正确解读,而在 ES2018,又新增了一些 u 模式的匹配集合来匹配一些常见的字符,比如 \\p{Number} 来匹配 ¼。 3.4. y 修饰符y 修饰符是 “粘连”(sticky)修饰符。 y 类似 g 修饰符,都是全局匹配,也就是从上次成功匹配位置开始,继续匹配。y 的区别是,必须是上一次匹配成功后的下一个位置就立即匹配才算成功。 比如: /a+/g.exec("aaa_aa_a"); // ["aaa"] 3.5. flags通过 flags 属性拿到修饰符: const regex = /[a-z]*/gu;regex.flags; // 'gu' 4. 总结本周精读借着 regexp-features-regular-expressions 这篇文章,一起理解了 ES2018 添加的正则新特性,又顺藤摸瓜的整理了 ES6 对正则做的增强。 如果你擅长这种扩散式学习方式,不妨再进一步温习一下整个 ES6 引入的新特性,笔者强烈推荐阮一峰老师的 ECMAScript 6 入门 一书。 ES2018 引入的特性还太新,单在对 ES6 特性的使用应该和对 ES3 一样熟练。 如果你身边的小伙伴还对 ES6 特性感到惊讶,请把这篇文章分享给他,防止退化为 “只剩项目经验的 JS 入门者”。 讨论地址是:精读《正则 ES2018》 · Issue ##127 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《民工叔单页数据流方案》","path":"/wiki/WebWeekly/前沿技术/《民工叔单页数据流方案》.html","content":"当前期刊数: 5 本周精读文章:单页应用的数据流方案探索 1 引言 前几期精读了前端模块化、语法相关的文章,这次讨论另一个举足轻重的话题:数据流。数据流在前端的地位与工程化、可视化、组件化是一样重要的,没有好的数据流框架与思想的指导,业务代码长期肯定倾向于不可维护的状态,当项目不断增加功能后,管理数据变得更加重要。 早期前端是没有数据流概念的,因为前端非常薄,每个页面只要展示请求数据,不需要数据流管理。 随着前端越来越复杂,框架越来越内聚,数据流方案由分到合,由合又到了分,如今数据流逐渐从框架中解绑,形成了一套通用体系,供各个框架使用。 虽然数据流框架很多,但基本上可以分为 双向数据流党、单向数据流党、响应式数据流党,分别以 Mobx、Redux、Rxjs 为代表呈现三国鼎立之状,顺带一提,对 css 而言也有 css in js 和纯 css党 势均力敌,前端真是不让人省心啊。这次我们来看看民工叔徐飞在 QConf 分享的主题:单页应用的数据流方案探索。 2 内容概要文中主要介绍了响应式编程理念,提到的观点,主要有: Reactive 数据封装 数据源,数据变更的归一 局部与全局状态的归一 分形思想 action 分散执行 app 级别数据处理,推荐前端 Orm 整体来看,核心思路是推荐组件内部完成数据流的处理,不用关心使用了 Redux Mobx 或者 Rxjs,也不用关心这些库是否有全局管理的野心,如果全局管理那就挂载到全局,但组件内部还是局部管理。 最后谈到了 Rxjs、xstream 响应式数据流的优势,但并未放出框架,仅仅指点了思想,让一些读者心里痒痒。但现在太多”技术大牛“把”业界会议“当成了打广告,或者工作汇报的机会,所谓授人以鱼不如授人以渔,这篇文章卓尔不群。 3 精读一切技术都要看业务场景,民工叔的 单页应用数据流方案 解决的是重前端的复杂业务场景,虽然现在前端几乎全部单页化,但单页也不能代表业务数据流是复杂的,比如偏数据展示型的中台单页应用就不适合使用这套方案。 此文讨论的是纯数据流方案,与 Dom 结合的方案可以参考 cyclejs,但这个库主要搭建了 Reactive -> Dom 的桥梁,使用起来还要参考此文的思路。 3.1 响应式数据流是最好的方案吗?我认为前端数据流方案迭代至今,并不存在比如:面向对象 -> 函数式 -> 响应式,这种进化链路,不同业务场景下都有各自优势。 面向对象以 Mobx 为代表,轻前端用的较多,因为复杂度集中在后端,前端做好数据展示即可,那么直接拥抱 js 这种基于对象的语言,结合原生 Map Proxy Reflect 将副作用进行到底,开发速度快得飞起。 数据存储方式按照视图形态来,因为视图之间几乎毫无关联,而且特别是数据产品,后端数据量巨大,把数据处理过程搬到前端是不可能的(为了推导出一个视图形态数据,需要动辄几 GB 的原始数据运算,存储和性能都不适合在前端做)。 函数式以 Haskell 为代表,金融行业用的比较多,可能原因是金融对数据正确性非常敏感,不仅函数式适合分布式计算,更重要的是无副作用让数据计算更安全可靠。 个人认为最重要的原因是,金融行业本来很少有副作用,像前端天天与 Dom 打交道的,副作用完全逃不了。 响应式以 Rxjs 为代表,重前端更适合使用。对于 React native 等 App 级别的开发,考虑到数据一致性(比如修改昵称后回退到文章详情,需同步作者修改后的昵称),优先考虑原始类型存储,更适合抽象出前端 Orm 作为数据源。 其实 Orm 作为数据源,面向对象也很适合,但响应式编程的高层次抽象,使其对数据源、数据变动的依赖可插拔,中等规模使用大对象作为数据源,App 级别使用 Orm 作为数据源,因地制宜。 3.2 分形思想分形思想即充血组件的升级版,特点是同时支持贫血组件的被外部控制能力。 分形的优点分形保证了两点: 组件和数据流融为整体,与外部数据流隔离,甚至将数据处理也融合在数据管道中,便于调试。 便于组件复用,因为数据流作为组件的一部分。 如果结合文中的 本地状态 概念,局部数据也放在全局,就出现了第三点好处: 创建局部数据等于创建了全局数据,这样代码调试可局部,可整体,更加灵活。 本地状态 可以参考 dva 框架的设计,如果没有全局 Redux 就创建一个,否则就挂载到全局 Redux 上。 分形的缺点对于聊天室或者在线 IDE 等,全局数据居多,很多交叉绑定的情况,就不适合分形思想,反而纯 Redux 思想更合适。 3.3 数据形态,是原始数据还是视图数据?我认为这也是分业务场景,文章提到不应该太偏向视图结构数据,是有道理的,意思是说,在适合原始结构数据时,就不要倾向于视图结构数据了。但有必要补充一下,在后端做了大量工作的中台场景,前端数据层非常薄,同时拿到的数据也是后端服务集群计算后的离线数据,显然原始数据结构不可能放在前端,这时候就不要使用原始数据存储了。 3.4 从原始数据到视图数据的处理过程放在哪文中推荐放在 View 中处理,因为考虑到不想增加额外的 Store,但不知道这个 Store 是否包含组件局部的 Store。业务组件推荐使用内部数据流操作,但最终还是会将视图数据存在全局 Store 中,只是对组件而言,是局部的,对项目而言是全局的,而且这样对特定的情况,比如其他组件复用数据变更的监听可以支持到。 总结我们到头来还是没有提供一个完美的解决方案,但提供了一个完整的思路,即在不同场景下,如何选择最合适的数据流方案。 最后,不要盲目选型,就像上面提到的,这套方案对复杂场景非常棒,但也许你的业务完全不适合。不要纠结于文中为何没有给出系统化解决方案的 Coding 库,我们需要了解响应式数据流的优势,同时要看清自己的业务场景,打造一套合适的数据流方案。 最后的最后,如有不错的数据流方案,解决了特定场景的痛点,欢迎留言。 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《源码学习》","path":"/wiki/WebWeekly/前沿技术/《源码学习》.html","content":"当前期刊数: 112 1. 引言javascript-knowledge-reading-source-code 这篇文章介绍了阅读源码的重要性,精读系列也已有八期源码系列文章,分别是: 精读《Immer.js》源码 精读《sqorn 源码》 精读《Epitath 源码 - renderProps 新用法》 精读《Htm - Hyperscript 源码》 精读《React PowerPlug 源码》 精读《syntax-parser 源码》 精读《react-easy-state 源码》 精读《Inject Instance 源码》 笔者自己的感悟是,读过大量源码的程序员有以下几个特质: 思考具有系统性,主要体现在改一处代码模块时,会将项目所有文件串联起来整体考虑,提前评估影响面。 思考具有前瞻性,对已实现的方案可以快速评价所处阶段(临时 or 标准 or 可拓展),将边界情况提前解决,将框架 BUG 降低到最小程度。 代码实现更优雅,有大量源码经验做支撑,解决同样问题时,这些程序员可以用更短的行数、更合适的三方库解决问题,代码可读性更好,模块拆分更合理,更利于维护。 既然阅读源码这么重要,那么怎么才能读好源码呢?本周精读的文章就是一篇方法论文章,告诉你如何更好的阅读源码。 2. 概述原文分三个部分:阅读源码的好处、阅读源码的技巧、以及 Redux Connect 的案例研究。 阅读源码的好处阅读源码有助于理解抽象的概念,比如虚拟 DOM;有助于做方案调研,而不仅仅只看 Github star 数量;了解优秀框架目录结构的设计;看到一些陌生的工具函数,还可能激发你对 JS 规范的查阅,这种问题驱动的方式也是笔者推荐的 JS 规范学习方式。 阅读源码的技巧最好的阅读源码方式是看文章,如果源码的作者有写源码解读文章,这就是最省力的方式。虽然直接看代码可以了解到所有细节,但当你不清楚设计思路时,仅看源码可能会找不到方向,而读源码的最终目的是找到核心的设计理念,如果一个框架没有自己核心设计理念,这个框架也不值得诞生,更不值得被阅读。如果框架的作者已经将框架核心理念写成了文章,那读文章就是最佳方案。 还有一种方式是断点,写一个最小程序,在框架执行入口出打下断点,然后按照执行路径一步步理解。虽然执行路径中会存在大量无关的函数干扰精力,但如果你足够有耐心,当断点走完时一定会有所收获。 原文还提到了一种看源码方式,即没有目的的寻宝。在寻找框架主要思路的过程中,遇到一些有意思的函数,可以停下来仔细阅读,可能会发现一些对你有启发的代码片段。 Redux Connect 案例研究原文以 Redux Connect 作为案例介绍研究思路。 首先看到 Connect 的功能 “包装组件” 后,就要问自己两个问题: Connect 是如何实现包装组件后原样返回组件,但却增强组件功能的?(高阶组件知识) 了解这个设计模式后,如何利用已有的文档实现它? 通过创建一个使用 Connect 的基本程序: class MarketContainer extends Component {}const mapDispatchToProps = dispatch => { return { updateSummary: (summary, start, today) => dispatch(updateSummary(summary, start, today)) }}export default connect(null, mapDispatchToProps)(MarketContainer); 比如从生成 connect 函数的 createConnect 我们就可以学习到 Facade Pattern - 门面模式。 从 createConnect 函数调用处: export function createConnect({ connectHOC = connectAdvanced, mapStateToPropsFactories = defaultMapStateToPropsFactories, mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories, mergePropsFactories = defaultMergePropsFactories, selectorFactory = defaultSelectorFactory} = {}) 我们可以学习到解构默认函数参数的知识点。 总之,在学习源码的过程中,可以了解到一些新的 JS 特性,一些设计模式,这些都是额外的宝藏,不断理解并学会运用到自己写的框架里,就实现了源码学习的目的。 3. 精读原文介绍了学习源码的两个技巧,并利用 Redux Connect 实例说明了源码学习过程中可以学到许多周边知识,都让我们受益匪浅。 笔者结合之前写过的八篇源码分析文章,把最重要的设计思路提取出来,以实际的例子展示阅读源码能给我们思维带来哪些帮助。 Immerjs 源码的精华Immer 可以让我们以 Mutable 的方式更新对象,最终得到一个 Immutable 对象: this.setState(produce(state => (state.isShow = true))) 详细源码解读可以阅读 这里。 核心思路是利用 Proxy 把脏活累活做掉。上面的例子中,state 已经是一个代理(Proxy)对象,通过自定义 setting 不断递归进行浅拷贝,最后返回一个新引用的顶层对象作为 produce 的返回值。 从 Immerjs 中,我们学到了 Proxy 可以化腐朽为神奇的用法,比看任何 Proxy 介绍文章都直观。 sqorn 源码的精华sqorn 是一个 sql orm,举例来看: const sq = require("sqorn-pg")();const Person = sq`person`, Book = sq`book`;// SELECTconst children = await Person`age < ${13}`;// "select * from person where age < 13" 详细源码解读可以阅读 这里 核心思路是在链式调用过程中创建 context 存储结构,并在链式调用的时候不断填充 context 信息,最终拿到的是一个结构化 context 对象,生成 sql 语句也就简单了。 从 sqorn 中,我们学到了如何实现链式调用 init().a().b().c().print() 最后拿到一个综合的结果,原理是内部维护了一个不断修改的对象。不论前端 React Vue 还是后端框架 Koa 等,一般都有内置的 context,一般实现这种优雅语法的框架内部都会维护 context。 Epitath 源码的精华Epitath 在 React Hooks 之前出来,解决了高阶函数地狱的问题: const App = epitath(function*() { const { count } = yield <Counter /> const { on } = yield <Toggle /> return ( <MyComponent counter={count} toggle={on} /> )})<App /> 详细源码解读可以阅读 这里 其核心是利用 generator 的迭代,将 React 组件的平级结构还原成嵌套结构,将嵌套写法打平了: yield <A>yield <B>yield <C>// 等价于<A> <B> <C /> </B></A> 从 epitath 中,我们了解到 generator 原来可以这么用,正因为其执行是多次迭代的,因此我们可以利用这个特性,改变代码运行结构。 Htm - Hyperscript 源码的精华Htm 将模版语法很自然的融入到了 html 中: html` <div class="app"> <${Header} name="ToDo's (${page})" /> <ul> ${todos.map( todo => html` <li>${todo}</li> ` )} </ul> <button onClick=${() => this.addTodo()}>Add Todo</button> <${Footer}>footer content here<//> </div>`; 详细源码解读可以阅读 这里 其核心是怎么根据模版拿到 dom 元素的 AST?拿到 AST 后就方便生成后续内容了。 作者的办法是: const TEMPLATE = document.createElement("template");TEMPLATE.innerHTML = str; 这样 TEMPLATE 就自带了 AST 解析,这是利用浏览器自带的 AST 解析拿到了 AST。从 Htm 中,我们学到了 innerHTML 可以生成标准 AST,所以只要有浏览器运行环境,需要拿 AST 的时候,不需要其他库,innerHTML 就是最好的方案。 React PowerPlug 源码的精华React PowerPlug 是一个利用 render props 进行状态管理的工具库。 它可以在 JSX 中对任意粒度插入状态管理: <Value initial="React"> {({ value, set, reset }) => ( <> <Select label="Choose one" options={["React", "Preact", "Vue"]} value={value} onChange={set} /> <Button onClick={reset}>Reset to initial</Button> </> )}</Value> 详细源码解读可以阅读 这里 这个库的核心就是利用 render props 解决 JSX 局部状态管理的痛点,通过读源码了解 render props 的使用方式是这个源码带给你的最大价值。 syntax-parser 源码的精华syntax-parser 是一个 JS 版语法解器生成器,笔者也是作者,使用方式: import { createParser, chain, matchTokenType, many } from "syntax-parser";const root = () => chain(addExpr)(ast => ast[0]);const addExpr = () => chain(matchTokenType("word"), many(addPlus))(ast => ({ left: ast[0].value, operator: ast[1] && ast[1][0].operator, right: ast[1] && ast[1][0].term }));const addPlus = () => chain("+"), root)(ast => ({ operator: ast[0].value, term: ast[1] }));const myParser = createParser( root, // Root grammar. myLexer // Created in lexer example.); 详细源码解读可以阅读 这里 syntax-parser 的核心是利用双向链表实现了可回溯的语法解析器,了解了这个库,你可以自己实现 JS 调用堆栈,并在任意时候返回某个之前的执行状态重新执行。同时这个库的源码也会加强你对链表的理解,以及拓展你对链表使用场景的想象。 react-easy-state 源码的精华react-easy-state 利用 Proxy 创建了一个简易的全局数据流管理方式: import React from "react";import { store, view } from "react-easy-state";const counter = store({ num: 0 });const increment = () => counter.num++;export default view(() => <button onClick={increment}>{counter.num}</button>); 详细源码解读可以阅读 这里 react-easy-state 利用了 observer-util 实现主要功能,从中我们能学到最有价值的就是 Proxy 与 React 结合的设计理念,即利用 getter setter 实现数据与视图的双向绑定,或者叫依赖追踪,更多细节就不在这里展开,感兴趣可以阅读笔者之前写的 抽丝剥茧,实现依赖追踪 一节。 Inject Instance 源码的精华inject-instance 是一个 Class 实现依赖注入的库: import {inject} from 'inject-instance'import B from './B'class A { @inject('B') private b: B public name = 'aaa' say() { console.log('A inject B instance', this.b.name) }} 详细源码解读可以阅读 这里 主要对我们有两个启发,第一可以利用装饰器为对象存储一些额外信息,这些信息在必要的时候我们可以用到;第二是依赖注入并不复杂,通过提前实例化后,可以解决循环依赖的问题,即所有循环依赖问题都可以通过加一个父级解决。 4. 总结阅读代码不是目的,读懂源码背后要表达的核心设计思路才是目的。比如写脚手架,阅读了大量脚手架源码的人写出的代码,与一个没有经验的人写出的代码会有天壤之别,这之间的差距就是对一些设计模式、三方库、结构设计的经验差距。 只学习理论太空洞,只看代码又太局限,学会从代码中看出理论才是最佳学习方式。 讨论地址是:精读《源码学习》 · Issue ##179 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《现代 JavaScript 概览》","path":"/wiki/WebWeekly/前沿技术/《现代 JavaScript 概览》.html","content":"当前期刊数: 24 本期精读的文章是: Glossary of Modern JavaScript Concepts: Part 1 Glossary of Modern JavaScript Concepts: Part 2 1 引言我为什么要选这篇文章呢? 之所以选这篇文章, 是因为非常认同作者写这两篇文章的原因. 作者在文中说, 现代 JavaScript 的很多概念和思想在快速被传播和扩展, 很多新概念出现在前端相关的博客和文档中, 这些概念对于很多前端开发人员来说, 仍然很陌生. 因此我们有必要来学习一下现代的这些 JavaScript 的概念, 看这些概念在现在 JavaScript 的库或应用中是怎么被使用的. 2 内容概要文章讲了很多现代 JavaScript 中的概念, 罗列如下: 纯函数和副作用在了解纯函数之前, 首先要了解副作用. 副作用是指改变了其作用域外的状态. 副作用的举例有调用了一个 API, 操作了一个 DOM 节点, 弹出了一个弹窗, 或者改变了一条数据等. 而纯函数则是指 函数的返回值仅仅由参数决定, 当给同样的参数时, 返回值是固定的. Stateful 和 Stateless (有状态和无状态)Stateless 无状态, 有点像纯函数, 不管理自己的数据或状态, 结果取决于参数. 而 Stateful, 有状态, 指的是函数自己有自己的运行状态, 可以修改自己的状态. 在现代 JavaScript 开发中, 处理状态, 显得很重要. 可变对象与不可变对象可变对象与不可变对象概念很清楚, 可变对象指的是在创建后值仍可以被改变, 不可变对象指的是创建后值无法被改变. 相比于其他语言, 可变对象与不可变对象在 JavaScript 中更加模糊, 当你了解函数式编程时, 你会听到很多不可变对象的好处. 在 JavaScript 中, 你可以通过 Object.freeze(obj), 让一个对象变得不可变, 但是注意这是浅层的冻结对象, 如果有一个属性的值是个对象, 那这个对象中的属性是可以被修改的. 现在 JavaScript 也出现了 npm deep-freeze , Immutable.js 这些库来帮助你在 JavaScript 中实现不可变对象. Imperative and Declarative Programming(命令式和声明式编程)命令式编程, 描述一段代码的逻辑怎么被显式调用去改变程序的状态. 声明式编程, 描述一段代码的逻辑, 而不需要描述如何完成这段逻辑. JavaScript 可以同时被写为命令式和声明式编程方式, 但是随着函数式编程的兴起, 声明式编程将变得更加普遍. 高阶函数函数作为 JavaScript 的一等公民, 可以跟普通数据类型一样, 被存储, 或者被作为值传参. 而高阶函数就是一种函数 可以接收另外一个函数作为入参, 或者返回一个函数作为结果. 函数式编程 FP上面我们了解的 纯函数, 无状态, 不可变对象, 命令式编程, 和高阶函数, 都是很重要的函数式编程组成. 函数式编程通过以下方式包含上述概念: 关键函数实现使用纯函数, 没有副作用. 数据不可变 函数 无状态 声明式代码去管理副作用和执行命令式编程 Hot and Cold ObservablesObservables 和数组类似, 只不过数组是被保存在内存中, 而 Observables 的每一个元素则是异步加入进来. 我们可以订阅这些 observables. Hot Observables 容易会被执行, 即使我们没有订阅它们. 比如说 用户的操作界面的 按钮点击事件, 鼠标移动, 窗口大小改变, 这些都是 Hot Observables. 而 cold observable 则是需要我们去订阅, 并且会在我们订阅的时候开始执行. 响应式编程 RP响应式编程, 可以看作是面向异步事件流的编程, 声明式的, 表述去做什么, 而不是怎么做. 函数式响应型编程 FRP函数式响应型编程简而言之,就是对事件或者行为给予声明式的反馈. FRP 具有两个很明显的特点: 函数或者类型有明确的定义 操作的是连续变化的值 作用域和闭包闭包作为最常见的面试题经常被提及, 但是很多资深的前端开发都解释不清楚闭包, 即使他们理解闭包. 作者首先介绍了全局作用域和局部作用域, 作用域作为许多 JS 开发人员最开始学习的知识, 理解作用域对于编写优秀的代码至关重要. 闭包的形成在于, 当一个在函数内声明的函数可以引用外部函数的局部变量. 就形成了闭包. 单向数据流和双向数据流随着现在各种 SPA 框架的兴起, 理解数据流概念, 对于现在 JS 开发者越来越重要, React 被认为是单向数据流的典范, 使用 Model 作为唯一的数据来源, 控制 View 的渲染. 在 View 层用事件的方式通知 Model 更新, 在反应到 View 层的变化上. 数据沿着一个方向流动, UI 永远不会更新 Model, 而是通过事件或者 setState 方法. 在双向数据绑定中, 数据是在两个方向上流动的, JS 可以更新 Model 数据, View 层 也可以更新 Model 数据. AngularJs 的 1.x 版本是双向数据流的典型实现. 早在 2009 年, 双向绑定是 Angualr 最受欢迎的特性之一, 但是 Angular 把这一特性抛弃了. 现在很多流行的框架和库都使用了单向数据流(React,Angular,Inferno,Redux 等). 单向数据流倡导的是清晰的架构, 数据流动更加清晰和易管理. 对于单向数据流来说说了点 View 自动更新数据的便利, 但也得到了清晰的数据流. JS 框架中的变化侦测: 脏检查, getter 和 setter, 虚拟 DOM变化侦测对于现代 SPA 应用来说很重要. 当用户更新一些内容时, 应用必须以一种方法知道这种变化, 并做出反应更新. AngularJS 1.x 使用的是脏检查的方式, 具体做法是对 View 中涉及到的 Model 进行深度比较. 脏检查的优点在于它的简单和可预测, 不涉及到 API 和对象的变更. 但是正因为涉及到大量比较, 也很低效. Ember 和 Backbone 是使用 getters 和 setters 来做变化侦测, 这样涉及到数据修改时, 都会触发变更事件. 而 React 是使用了虚拟 Dom 来做变化侦测, React 通过 setState 方法来通知变更, 使用虚拟 Dom 来比较是否发生了数据变化. Web Components 组件Web 组件是 Web 平台上可复用的基础组件, 而 Web Components 则定义了一些规范来实现这些可复用组件.规范包括: 自定义元素 HTML Template Shadow Dom HTML imports 引入 Web Components 本身并不能代替 SPA 框架的功能, 但是它的想法和核心概念, 在很多 SPA 框架中都有体现. Smart 和 Dumb 组件现在 Web 的开发严重依赖组件, 而很多时候我们把组件分成 Smart 组件和 Dumb 组件. Smart 组件, 又叫容器组件, 在组件内处理各种业务逻辑, 通常也管理 Dumb 组件,响应 Dumb 组件的事件. Dumb 组件, 又叫展示组件, 通常被写成纯函数, 依赖于外部的数据和方法, 专注于展现数据. JIT 编译Just-In-time(JIT)编译指的是代码的运行时, 被编译成机器代码的过程. 在 JavaScript 运行时, JIT 能够找到代码的特定模式, 而这些模式可以让 JavaScript 更快的被执行. AOT 编译Ahead-Of-Time(AOT), 指的是编写的代码在运行之前, 被翻译成机器代码的过程. AOT 给 tree shaking 带来了可能, 使用 AOT 预编译, 对于生产环境下的代码有以下好处: 更少的异步请求, 模板和样式内联在 JS 内 更小的体积 更早的检查到模板错误 更好的安全性 Tree ShakingTree Shaking 是指打包 JS 模块时, 通过对代码的静态分析, 排除掉不用的代码的机制. Tree Shaking 技术建立在 ES2015 模块的, import 和 export 上, 支持我们导入特定的内容,而不是整个库. import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 这样我们只导入了 BehaviorSubject, 而没有导入整个 Rxjs 库. 3 精读文中讲到的现代 JavaScript 已经很多了, 再对理解的现代 JavaScript 补充几条: Dependent injection(依赖注入)通过控制反转,父级不需要关心子实现细节,将子类可能用到的实例都初始化好,由子类决定引入哪些依赖。还有一个好处是维持了单实例,这一点在数据流中尤为重要,如果 store 不是单例的,那数据流必然乱了套,既希望传给子类使用,又要维持单例,依赖注入是很好的解决方案。 Symbol Reflect ProxySymbol 是 ES6 中加入的一种新的数据类型, 每一个 Symbol 都是独一无二的, 不与其它 Symbol 重复. ES6 中的 Proxy , 则是通过 Proxy 方法, 实现对于对象的一层拦截. 提供一种机制, 代理对象的操作. 而 Reflect 是一个内置的对象,它提供可拦截 JavaScript 操作的方法。方法与代理处理程序的方法相同。 这三篇文章非常详细介绍了这三位 API:symbol reflect proxy Server rendering前端对后端渲染的热度降了很多,主要是盲目跟风的氛围消停了,真正需要的团队已经稳定的用起来了。后端渲染的理念很新颖,一定程度帮助了 html 认识到自己的不足,就像 Angular, React, Vue 对 webComponents 的冲击一样,或许未来十年可以用上 ECMAScript 标准提供的功能,但业务不能等待技术,现在唯有不断折腾,直到被消灭或者招安。 4 总结伴随着各种框架的热度, 理解这些现代 JavaScript 概念变得越来越重要, 大家可以以这个作为概览, 详细去学习和了解现代 JavaScript 的概念. 讨论地址是:精读《现代 JavaScript 概览》 · Issue ##35 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《现代 js 框架存在的根本原因》","path":"/wiki/WebWeekly/前沿技术/《现代 js 框架存在的根本原因》.html","content":"当前期刊数: 57 1 引言深入思考为何前端需要框架,以及 web components 是否可以代替前端框架? 原文地址,建议先阅读原文,或者阅读概述。 2 概述现在前端框架非常多了,如果让我们回答 “为什么要用前端框架” 这个问题,你觉得是下面这些原因吗? 组件化。 拥有强大的开源社区。 拥有大量第三方库解决大部分问题。 拥有大量现成的第三方组件。 拥有浏览器拓展/工具帮助快速 debug。 友好的支持单页应用。 不,这些都不是根本原因,最多算前端框架的营销手段。作者给出的最根本原因是: 解决 UI 与状态同步的难题。 作者假设了一个没有前端框架的项目,就像 Jquery 时代,我们需要手动同步状态与 UI。就像下面的代码: addAddress(address) { // state logic const id = String(Dat.now()) this.state = this.state.concat({ address, id }) // UI logic this.updateHelp() const li = document.createElement('li') const span = document.createElement('span') const del = document.createElement('a') span.innerText = address del.innerText = 'delete' del.setAttribute('data-delete-id', id) this.ul.appendChild(li) li.appendChild(del) li.appendChild(span) this.items[id] = li} 首先更新效率是个问题,最大问题还是同步问题。试想多次与服务器交互,在同步过程中漏执行了一步,会导致之后的 UI 与状态逐渐脱节。 因为我们只能一步步同步状态与 UI,却无法保证每个瞬间 UI 与状态是完全同步的,任何一个疏忽都会导致 UI 与状态脱节,而我们除了不断检查 UI 与数据是否对应,毫无办法。 所以现代框架最重要的帮助是保持 UI 与状态的同步。 如何做到有两种思路: 组件级重渲染:比如 React,当状态改版后,映射出改变后的虚拟 DOM,最终改变当前组件映射的真实 DOM,这个过程被称为 reconciliation。 监听修改:比如 Angluar 和 Vue.js,状态改变直接触发对应 DOM 节点中 value 值的变化。 这里稍微说明下,React 虽然是整体渲染,但在虚拟 DOM 作用下,效率不比 observable 低。observable 在值不能完整映射 UI 时,也需要做更大范围的 rerender。另外,Vue.js 与 Angluar 也早已采用了虚拟 DOM。 这三个框架已经融会贯通,作者提到的两种思路现在已经是一种混合技术了。 那 web components 呢?大家经常会拿 React, Angluar, Vue.js 与 web components 做比较,可 web components 最大的问题就是,没有解决 UI 与状态同步。 web components 只提供了模版语法,自定义标签解决 html 的问题,并没有给出一套状态与 UI 同步的方法。 所以就算使用 web components,我们可能还需要一个框架做 UI 同步,比如 Vue.js 或者 stenciljs。 作者还提供了一段简短的 UI 状态同步实例,这里略过。 最后给出了四点总结: 现代 js 框架主要在解决 UI 与状态同步的问题。 仅使用原生 js 难以写出复杂、高效、又容易维护的 UI 代码。 Web components 没有解决这个主要问题。 虽然使用虚拟 DOM 库很容易造一个解决问题的框架,但不建议你真的这么做! 3 精读作者的核心观点是,现代前端框架主要解决 UI 与状态同步的问题,这是毫无疑问的,也提到了包括 web components 也依然没有解决这个问题。 这可能是 web 开发最核心的问题了。 最初开发者的精力都在前端标准化上,诞生了一系列解决标准化问题的库,最有知名度的是 jquery。当前端进入 react 时代后,可以看到精力从解决标准化到解决 web 规范与实践的冲突,这个冲突正是作者说的问题。 前端三剑客问题就出现在 html、js、css 三者分离上。 html、css、js 各是一套独立的体系,但 js 又能同时控制 html 与 css,那为了解决同步问题,最好将控制权全部交给 js。 这样 web components 的问题也就好理解了,web components 解决的是 html 问题,注定与 js 无关。 html 官方规范估计很难出现现代框架的设计了,因为官方设计中前端三剑客是相互分离的方案,为了解决现阶段前端框架的问题,html 必须由 js 完全接管,这几乎就是 jsx,或者支持 template 语法的 html,可这与最初网页设计思路是违背的。 html 是独立的,甚至可以不依赖 js 运行,这天然导致了 UI 与状态同步这个难题。 为什么一定要用 jshtml 不依赖 js 的设计可能已经跟不上前端发展步伐了,也许 jsx 或者 template 才是真正的未来。 诚然,html 现在的设计可以在不支持 js 的浏览器执行,但就在最近,所有现代浏览器都支持了 service worker,它是凌驾于 html 执行时机之上的 js 脚本,甚至可以拦截 html 请求。一个不支持 js 的浏览器,可能也无法支持 service worker,禁用 js 的坚持可能只剩下安全性保护。 而实际上现代 web 页面都使用了 js 完全主导网页渲染,所以这已经从技术问题上升到了社会问题,如今禁用 js 的浏览器还有多少网页可以正常访问?除了某些超大型网站对禁用 js 状态做了特殊优化以外,现在几乎没有前端项目会考虑禁用 js 的情况了,因为我们不会假设 React、Angluar、Vue.js 框架代码无法运行。 所以为什么不融合 html 与 js 呢?既然事实上 UI 已经与 js 绑定了,那 w3c 为何不将 jsx 或者 template 列为标准呢?也许为了向前兼容,规范永远也迈不出这一步吧。 幸运的是,这并不妨碍现代前端框架的大量普及,而且势不可挡。 4 总结也许 UI 与状态同步的问题是前端发展的最大阻力,虽然现代化框架已经解决了这个问题,但 w3c 标准却一直无法往这个方向发力,导致 web 的下一个发展方向难以依靠标准规范来推动。前端日新月异的发展,很大一部分是规范的发展带来的,而现在我们进入了一个由工业化领导的时代,规范很可能永远也跟不上来,随之而来的是工业化社区也难以做进一步突破。 前端不仅是 web,或者也许下一个突破并不在 web,而是 ar/vr 或者下一个人机交互场景。同样,web 也不仅是前端三剑客,如果认为 React、Angluar、Vue.js 带来的工业化规范就是新的规范,前端才有动力向后发展,比如基于虚拟 DOM 的新框架、新语言。 所以笔者推导出现代前端开发的本质,是将 js、html 的平行关系变成了 js 包含 html 的关系,正如上面所说,这可能背离了 w3c 的初衷,但这就是现在的潮流。 最后总结一下观点: 也是原作者的,现代 js 框架主要在解决 UI 与状态同步的问题。 传统的前端三剑客正面临着进一步发展乏力的危机。 现代前端框架正在告诉我们新的三剑客:js(虚拟 dom、虚拟 css)。 5 更多讨论 讨论地址是:精读《现代 js 框架存在的根本原因》 · Issue ##84 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《用 React 做按需渲染》","path":"/wiki/WebWeekly/前沿技术/《用 React 做按需渲染》.html","content":"当前期刊数: 154 1 引言BI 平台是阿里数据中台团队非常重要的平台级产品,要保证报表编辑与浏览的良好体验,性能优化是必不可少的。 当前 BI 工具普遍是报表形态,要知道报表形态可不仅仅是一张张图表组件,与这些组件关联的筛选条件和联动关系错综复杂,任何一个筛选条件变化就会导致其关联项重新取数并重渲染组件,而报表数据量非常大,一个表格组件加载百万量级的数据稀松平常,为了维持这么大量级数据量下的正常展示,按需渲染是必须要做的功课。 这里说的按需渲染不是指 ListView 无限滚动,因为报表的布局模式有流式布局、磁贴布局和自由布局三套,每种布局风格差异很大,无法用固定的公式计算组件是否可见,因此我们选择初始化组件全量渲染,阻止非首屏内组件的重渲染。因为初始条件下还没有获取数据,全量渲染不会造成性能问题,这是这套方案成立的前提。 所以我今天就专门介绍如何利用 DOM 判断组件在画布中是否可见这个技术方案,从架构设计与代码抽象的角度一步步分解,不仅希望你能轻松理解这个技术方案如何实现,也希望你能掌握这其中的诀窍,学会举一反三。 2 精读我们以 React 框架为例,做按需渲染的思维路径是这样的: 得到组件 active 状态 -> 阻塞非 active 组件的重渲染。 这里我选择从结果入手,先考虑如何阻塞组件渲染,再一步步推导出判断组件是否可见这个函数怎么写。 阻塞组件重渲染我们需要一个 RenderWhenActive 组件,支持一个 active 参数,当 active 为 true 时这一层是透明的,当 active 为 false 时阻塞所有渲染。 再具体描述一下,其效果是这样的: inActive 时,任何 props 变化都不会导致组件渲染。 从 inActive 切换到 active 时,之前作用于组件的 props 要立即生效。 如果切换到 active 后 props 没有变化,也不应该触发重渲染。 从 active 切换到 inActive 后不应触发渲染,且立即阻塞后续重渲染。 我们可以写一个 RenderWhenActive 组件轻松实现此功能: const RenderWhenActive = React.memo(({ children }) => children, (prevProps, nextProps) => ( !nextProps.active)) 获取组件 active 状态在进一步思考之前,我们先不要掉到 “如何判断组件是否显示” 这个细节中,可以先假设 “已经有了这样一个函数”,我们应该如何调用。 很显然我们需要一个自定义 Hook:useActive 判断组件是否是激活态,并拿到 active 返回值传递给 RenderWhenActive 组件: const ComponentLoader = ({ children }) => { const active = useActive(); return <RenderWhenActive active={active}>{children}</RenderWhenActive>;}; 这样,渲染引擎利用 ComponentLoader 渲染的任何组件就具备了按需渲染的功能。 实现 useActive到现在,组件与 Hook 侧的流程已经完整串起来了,我们可以聚焦于如何实现 useActive 这个 Hook。 利用 Hooks 的 API,可以在组件渲染完毕后利用 useEffect 判断组件是否 Active,并利用 useState 存储这个状态: 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: /** * 监听元素是否可见的抽象类 */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 实现的新潮方法,由于后者有兼容性要求,前者就作为兜底方案实现。 因此我们可以定义两套对应方法: 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.. }} 最后再做一个总类作为调用入口: /** * 监听元素是否可见总类 */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 函数中,核心思想是判断两个矩形(容器与要判断的组件)是否存在包含关系,如果包含成立则代表可见,如果包含不成立则不可见。 下面是完整实现函数: 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。 计算 root 与 component 长度之和 sumOfWidth 与宽度之和 sumOfHeight。 计算 root 与 component 长度之和 + 两倍间距 sumOfWidthWithGap 与 宽度之和 + 两倍间距 sumOfHeightWithGap。 sumOfWidthWithGap - sumOfWidth 的差值就是横向 gap 距离,sumOfHeightWithGap - sumOfHeight 的差值就是横向 gap 距离,两个值都为负数表示在内部。 其中的关键是,从横向角度来看,下面的公式可以理解为宽度之和 + 两倍的宽度间距: // 长度之和 + 两倍间距(交叉则间距为负)const sumOfWidthWithGap = Math.abs( rootComponentRect.left + rootComponentRect.right - componentRect.left - componentRect.right); 而 sumOfWidth 是宽度之和,这之间的差值就是两倍间距值,正数表示横向没有交集。当横纵两个交集都是负数时,代表存在交叉或者包含在内部。 监听组件是否可见 - 原生版本如果浏览器支持 IntersectionObserver 这个 API 就好办多了,以下是完整代码: 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 元素被销毁后,导致后续监听失效,因此需要在元素隐藏时加入下面的代码: // 因为虚拟 dom 更新导致实际 dom 更新,也会在此触发,判断 dom 丢失则重新监听if (!document.body.contains(changes[0].target)) { this.intersectionObserver.unobserve(changes[0].target); this.intersectionObserver.observe(document.getElementById(this.targetDomId));} 当元素判断不在可视区域时,也包含了元素被销毁。 因此通过 body.contains 判断元素是否被销毁,如果被销毁则重新监听新的 DOM 实例。 3 总结总结一下,按需渲染的逻辑的适用面不仅仅在渲染引擎,但对于 ProCode 场景直接编写的代码中,要加入这段逻辑就显得侵入性较强。 或许可视区域内按需渲染可以做到前端开发框架内部,虽然不属于标准框架功能,但也不完全属于业务功能。 这次留下一个思考题,如果让手写的 React 代码具备按需渲染功能,怎么设计更好呢? 讨论地址是:精读《用 React 做按需渲染》· Issue ##254 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《用 Reduce 实现 Promise 串行执行》","path":"/wiki/WebWeekly/前沿技术/《用 Reduce 实现 Promise 串行执行》.html","content":"当前期刊数: 77 1 引言本周精读的文章是 why-using-reduce-to-sequentially-resolve-promises-works,讲了如何利用 reduce 实现 Promise 串行执行。 在 async/await 以前 Promise 串行执行还是比较麻烦的,希望根据这篇文章可以理清楚串行 Promise 的思维脉络。 2 概述除了依赖 async promise-fun 等工具库,最常用的队列操作就是 Array.prototype.reduce() 了: let result = [1, 2, 5].reduce((accumulator, item) => { return accumulator + item;}, 0); // <-- Our initial value.console.log(result); // 8 最后一个值 0 是起始值,每次 reduce 返回的值都会作为下次 reduce 回调函数的第一个参数,直到队列循环完毕,因此可以进行累加计算。 那么将 reduce 的特性用在 Promise 试试: function runPromiseByQueue(myPromises) { myPromises.reduce( (previousPromise, nextPromise) => previousPromise.then(() => nextPromise()), Promise.resolve() );} 当上一个 Promise 开始执行(previousPromise.then),当其执行完毕后再调用下一个 Promise,并作为一个新 Promise 返回,下次迭代就会继续这个循环。 const createPromise = (time, id) => () => new Promise(solve => setTimeout(() => { console.log("promise", id); solve(); }, time) );runPromiseByQueue([ createPromise(3000, 1), createPromise(2000, 2), createPromise(1000, 3)]); 得到的输出是: promise 1promise 2promise 3 3 精读Reduce 是同步执行的,在一个事件循环就会完成(更多请参考 精读《Javascript 事件循环与异步》),但这仅仅是在内存快速构造了 Promise 执行队列,展开如下: new Promise((resolve, reject) => { // Promise ##1 resolve();}) .then(result => { // Promise ##2 return result; }) .then(result => { // Promise ##3 return result; }); // ... and so on! Reduce 的作用就是在内存中生成这个队列,而不需要把这个冗余的队列写在代码里! 更简单的方法感谢 eos3tion 同学补充,在 async/await 的支持下,runPromiseByQueue 函数可以更为简化: async function runPromiseByQueue(myPromises) { for (let value of myPromises) { await value(); }} 多亏了 async/await,代码看起来如此简洁明了。 不过要注意,这个思路与 reduce 思路不同之处在于,利用 reduce 的函数整体是个同步函数,自己先执行完毕构造 Promise 队列,然后在内存异步执行;而利用 async/await 的函数是利用将自己改造为一个异步函数,等待每一个 Promise 执行完毕。 更多 Promise 拓展天猪 同学分享的 promise-fun 除了串行 Promise 解决方案,还提供了一系列 Promise 功能拓展(有一些逐渐被 ES 标准采纳,比如 finally 已经进入 Stage 4),如果你的项目还无法使用 async/await,是不需要自己重新写一遍的,当然本文的原理还是需要好好理解。 Stage 相关可以进行拓展阅读 精读《TC39 与 ECMAScript 提案》。 4 总结Promise 串行队列一般情况下用的不多,因为串行会阻塞,而用户交互往往是并行的。那么对于并行发请求,前端按串行顺序接收 Response 也是一个有意思的话题,留给大家思考。 5 更多讨论 讨论地址是:精读《用 Reduce 实现 Promise 串行执行》 · Issue ##109 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《用 css grid 重新思考布局》","path":"/wiki/WebWeekly/前沿技术/《用 css grid 重新思考布局》.html","content":"当前期刊数: 124 1 引言Flex 与 Grid 相比就像功能键盘和触摸屏。触摸屏的控制力相比功能键盘来说就像是降维打击,因为功能键盘只能上下左右控制(x、y 轴),而触摸屏打破了布局障碍,直接从(z 轴)触达,这样 无论 UI 内部布局再复杂,都可以通过 touch 直接定位。 Flex 是一维布局方式,我们需要不断嵌套 Div 才能形成复杂结构,而一旦布局产生了变化,原有嵌套结构如果不能 “兼容变化” 到新结构,代码就需要重构。而 Grid 就像触摸屏一样,可以二维布局,即便布局方式做了翻天覆地的调整,也仅需少量修改就能适配。 这就是这次精读 用 css grid 重新思考布局 的原因,理解这个革命性布局技术给布局,甚至代码逻辑组织带来的变化。 2 概述作者首先抛出了 Flex 的问题,其实是 block float flex 这三种布局模式的通病: 布局结构由 Div 层级结构描述,导致 Div 层级复杂且遇到结构变更时难以维护。 定制能力弱。Flex 布局有一些不受控制的智能设定,比如宽度 50% 的子元素会被同级元素挤到 50% 以下,这种智能化在某些场景是需要的,但由于没有提供像 Grid 的 minmax 之类的 API,所以定制型不足。 举个例子,上图的结构用 Flex 描述可能是这样的: <div class="card"> <div class="profile-sidebar"> <img src="https://i.pravatar.cc/125?image=3" alt="" class="profile-img" /> <ul class="social-list"> <li> <a href="##" class="social-link" ><i class="fab fa-dribbble-square"></i ></a> </li> <li> <a href="##" class="social-link" ><i class="fab fa-facebook-square"></i ></a> </li> <li> <a href="##" class="social-link" ><i class="fab fa-twitter-square"></i ></a> </li> </ul> </div> <div class="profile-body"> <h2 class="profile-name">Ramsey Harper</h2> <p class="profile-position">Graphic Designer</p> <p class="profile-info"> Lorem ipsum dolor sit amet consectetur adipisicing elit. Facere a tempore, dignissimos odit accusantium repellat quidem, sit molestias dolorum placeat quas debitis ipsum esse rerum? </p> </div></div> 利用 HTML 嵌套结构,我们将图形纵向分成两大块,然后在每块内部继续嵌套划分布局,这是最经典的布局行为了。 样式文件里,我们需要对每层布局进行描述,同时支持多分辨率弹性布局,包括顶层 card 容器在内的一些样式需要做一定调整: .card { width: 80%; margin: 0 auto; display: flex; flex-direction: column; max-width: 600px; background: ##005e9b; flex-basis: 250px; color: white; padding: 2em; text-align: center;}.profile-info { font-weight: 300; opacity: 0.7;}.profile-sidebar { margin-right: 2em; text-align: center;}.profile-name { letter-spacing: 1px; font-size: 2rem; margin: 0.75em 0 0; line-height: 1;}.profile-name::after { content: ""; display: block; width: 2em; height: 1px; background: ##5bcbf0; margin: 0.5em auto 0.65em; opacity: 0.25;}.profile-position { text-transform: uppercase; font-size: 0.875rem; letter-spacing: 3px; margin: 0 0 2em; line-height: 1; color: ##5bcbf0;}.profile-img { max-width: 100%; border-radius: 50%; border: 2px solid white;}.social-list { list-style: none; justify-content: space-evenly; display: flex; min-width: 125px; max-width: 175px; margin: 0 auto; padding: 0;}.social-link { color: ##5bcbf0; opacity: 0.5;}.social-link:hover,.social-link:focus { opacity: 1;}.bio { padding: 2em; display: flex; flex-direction: column; justify-content: center;}@media (min-width: 450px) { .bio { text-align: left; max-width: 350px; }}.bio-title { color: ##0090d1; font-size: 1.25rem; letter-spacing: 1px; text-transform: uppercase; line-height: 1; margin: 0;}.bio-body { color: ##555;}.profile { display: flex; align-items: flex-start;}@media (min-width: 450px) { .card { flex-direction: row; text-align: left; } .profile-name::after { margin-left: 0; }} 让我们看看 Grid 是怎么做的吧!Grid 有许多 API,我们重点看 grid-template-areas 这个属性,利用它,我们可以不关心模块的 HTML 结构,直接平铺方式描述: <div class="card"> <img src="https://i.pravatar.cc/125?image=3" alt="" class="profile-img" /> <ul class="social-list"> <li> <a href="##" class="social-link"><i class="fab fa-dribbble-square"></i></a> </li> <li> <a href="##" class="social-link"><i class="fab fa-facebook-square"></i></a> </li> <li> <a href="##" class="social-link"><i class="fab fa-twitter-square"></i></a> </li> </ul> <h2 class="profile-name">Ramsey Harper</h2> <p class="profile-position">Graphic Designer</p> <p class="profile-info"> Lorem ipsum dolor sit amet consectetur adipisicing elit. Facere a tempore, dignissimos odit accusantium repellat quidem, sit molestias dolorum placeat quas debitis ipsum esse rerum? </p></div> 可以看到,使用 Grid 可以将 UI 结构与 HTML 结构分离,HTML 结构仅描述包含关系,我们只需在样式文件中描述具体 UI 结构。 样式文件只截取 Grid 相关部分: .card { width: 80%; margin: 0 auto; display: flex; flex-direction: column; max-width: 600px; background: ##005e9b; flex-basis: 250px; color: white; padding: 2em; text-align: left; display: grid; grid-template-columns: 1fr 3fr; grid-column-gap: 2em; grid-template-areas: "image name" "image position" "social description";}.profile-name { grid-area: name;}.profile-position { grid-area: position;}.profile-info { grid-area: description;}.profile-img { grid-area: image;}.social-list { grid-area: social;} 可以看到,grid-template-areas 是进一步抽象的语法,将页面结构通过直观的文本描述,无论是理解还是修改都更为轻松。 这种描述方式适配不同分辨率下也具有优势,只要重组 grid-template-areas 即可: @media (min-width: 600px) { .card { text-align: left; grid-template-columns: 1fr 3fr; grid-template-areas: "image name" "image position" "social description"; }} 归根结底,Grid 通过二维结构描述,将子元素布局控制收到了父级,使布局描述更加直观。 最后作者也提到,Flex 依然有使用场景,即简单的一维结构,或者 space-between 等 Flex 独有语法的情况。因此推荐整体、复杂的二维布局采用 Grid,一维的简单布局采用 Flex。 3 精读Grid 的布局思路给了我很多启发,HTML 结构与 UI 结构的分离有助于减少 DIV 的层级结构,使代码看上去更清晰。 也许有人会疑惑,Grid 无非将 HTML 布局部分功能挪到了 CSS,整体复杂度应该不变。其实,从 grid-template-areas 这个 API 可以看到,Grid 不仅仅将布局功能抽到 CSS 中,更是将布局描述进行了一层抽象,使代码更易维护。 抽象,再抽象为什么 Grid 可以对布局进行抽象?因为 Grid 将二维结构都掌握在手中,得到了更大的布局能力,才能进一步将结构化语法抽象为字符串的描述。 抽象的好处是不言而喻的,你觉得一堆嵌套的 DIV 与下面的代码,哪个更易读呢? .card { grid-template-areas: "image name" "image position" "social description";} 这就是抽象的好处,一般来说,代码抽象程度越高就越易读,越易维护。 再看一个 Chrome Grid 插件,将 Grid 可视化显示出来,并可以以 UI 方式进行调整: UI 是对文本的再抽象,同时可以规避一些不可能存在的语法,比如: .card { grid-template-areas: "image name" "image position" "social image";} 布局只能以凸多边形方式拓展,不可能分离,也不可能突然插入一个其他模块而变成凹多边形。因此 UI 可以将这个错误规避,并简化为横竖多条线的方式对 UI 进行划分,显然这种描述方式效率更高。 不得不说,Grid 以及图形化插件的探索,是布局领域的一大进步,是不断抽象的尝试,要解决的问题只有一个:如何提供一种更直观的描述 UI 的方式。 布局对模块化的影响Grid 将布局方式提高了一个维度,会直接影响到 JS 模块化方式。 尤其是以 JSX 组织代码的情况下,一个模块等于 UI + JS,通过嵌套方式的布局会让我们更倾向于站在 UI 视角划分模块。 比如对于上图模块,如果用 Flex 方式布局,我们可能会首先创建模块 X 作为左侧容器,子元素是 A 和 B,创建模块 Y 作为右侧容器,子元素是 C 以及新容器 Z,Z 容器的子元素是 D 和 E。 如果你的第一印象是这么组织代码,不得不承认模块化会受到布局方式的影响。虽然许多时候这样划分是正确的,但当这 5 个模块各自没有关联时,我们创建的容器 X、Y、Z 就失去了复用性,在新的组合场景我们又要重新组合一遍。 但是在 Grid 语法中,我们不需要 X、Y、Z,只需要用 css grid generator 按照上图的方式拖拖拽拽即可自动生成如下布局代码: .parent { display: grid; grid-template-columns: 3fr repeat(2, 1fr); grid-template-rows: repeat(5, 1fr); grid-column-gap: 0px; grid-row-gap: 0px;}.div1 { grid-area: 1 / 1 / 3 / 2;}.div2 { grid-area: 3 / 1 / 6 / 2;}.div3 { grid-area: 1 / 2 / 2 / 4;}.div4 { grid-area: 2 / 2 / 6 / 3;}.div5 { grid-area: 2 / 3 / 6 / 4;} 其实 grid-template-columns grid-template-rows 组合起来使用比 grid-template-areas 更强大,但是纯代码方式描述没有 grid-template-areas 直观,可是配合一些可视化系统就非常直观了: 将 A ~ E 这 5 个模块布局抽出来后,它们之间的关系就打平了,我们可以完全从逻辑视角审视如何做模块化了。 4 总结CSS Grid 本质上是一种二维布局的语法,相比 Block、Flex 等一维布局方案,多了一个维度可以同时从行与列角度定义布局,因此派生出 grid-template-areas 等语法,整体上更内聚更直观,抽象度也更高了。 理解了这些也就理解了布局未来的发展方向,让布局与 Dom 分离 一直是前端的一个梦想,开发 UI 部分时,只需关心页面由哪些模块组成,去实现这些模块就行了,而不需要关心模块之间应该如何组合。在描述组合时,可以通过可视化或比较抽象的字符串描述布局的结构,并对应到写好的模块上,这样的代码维护性远高于用 DIV 描述结构的方案。 讨论地址是:精读《用 css grid 重新思考布局》 · Issue ##211 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《磁贴布局 - 功能分析》","path":"/wiki/WebWeekly/前沿技术/《磁贴布局 - 功能分析》.html","content":"当前期刊数: 265 磁贴布局三部曲:功能分析、实现分析、性能优化的第一部 - 功能分析。 因为需要做自由布局与磁贴布局混排,以及磁贴布局嵌套,所以要实现一套磁贴分析功能,所以本系列不是简单的介绍使用 react-grid-layout 这个库就行了,而是深入分析磁贴布局的特性,以及重头实现一遍。 对磁贴布局不熟悉的话,react-grid-layout 也是个很好的 Demo 体验页,大家可以先体验一下再阅读文章。 精读简单碰撞磁贴布局最重要的就是碰撞了,用过 Demo 就会发现,磁贴左右不会碰撞,只有上下会产生碰撞,这是因为网页天然是从上而下阅读的,因此垂直碰撞比水平碰撞更自然。 那么垂直的碰撞方向是什么样的呢?实际上只有自上而下的碰撞,没有自下而上的碰撞 为了讲清楚这个原理,先看下面的例子: [-----] [-----]| A | → | B |[-----] [-----] 如上所示,将方块 A 移动到方块 B 的位置,如果此时 A 的 Y 轴位置小于等于 B,则会将 B 挤下去。结果如下所示: [-----]| A |[-----][-----]| B |[-----] 如果 A 的 Y 轴位置比 B 大,则碰撞结果是 A 跑到了 B 的下面: [-----][-----] | B || A | → [-----][-----] 结果如下所示: [-----]| B |[-----][-----]| A |[-----] 如果 A 挤到 B 和 C 中间会如何呢?见下图: [-----][-----] | B || A | → [-----][-----] [-----] [ C ] [-----] 很容易想到,A 会落到 B 与 C 的中间位置。那问题来了,实现的时候,当时 A 放到 B 的下方,还是认为 A 放到 C 的上方? 乍一看会觉得,这不一样吗?对这个例子来说是的,但对其他例子就不同了。实际上应该始终认为是 A 放到了 B 的下方。原因的话,我们举一个反例就行,假设认为 A 放到了 C 的上方,那么看下面的例子: [-----][-----] | B || A | → [-----][-----] [ X ] [-----] [ C ] [-----] 如上图所示,B 和 C 中间夹了一个狭长的 X,此时 A 拖入 B 和 C 的中间,并未与 X 产生碰撞,那结果一定是 A 落在了 B 的下方。如果落在 C 的上方,A 就悬空了。 所以磁贴布局模式下,组件始终只能落在另一个组件下面,除了 Y 轴为 0 的情况下,可以定到组件上方。 连续碰撞连续碰撞是指当磁贴布局产生碰撞而导致位置变化后,需要重新调整整体位置,或者继续与其他组件位置产生碰撞的情况,首先看下面这个简单例子: [-----]| A |[-----] ↓[-----]| B |[-----][-----]| C |[-----] 如果把 A 拖动到 B 位置,遵循简单碰撞原理,必须 Y 轴高于 B 的 Y 轴才会置于 B 下方,此时会把 C 顶上去。但仅做到这一步,A 原来的位置会产生空缺,需要重新吸附到顶部,这就是连续碰撞: [ ] [-----]|Empty| | B |[ ] [-----][-----] [-----]| B | [ A ][-----] → Remove Empty [-----][-----] [-----]| A | [ C ][-----] [-----][-----]| C |[-----] 这时候你可能会想,结果不就是 B 和 A 交换了位置嘛,实际上用 react-grid-layout 看起来效果也是如此,那么代码实现时是不是不用这么麻烦?直接判断 A 与 B 是否产生位置交换,如果交换了,按照交换的方式处理不就行了吗? 听上去很美好,因为按照 A 与 B 交换的思路处理效果一致,而且性能更优,因为不用重新计算 C 组件被挤走,然后 A、B、C 再重新挤上去。但实际上交换方案是不可行的,我们看下面的例子: [-----] | A | [-----] ↓[-------------]| B |[-------------][-----]| C |[-----] 如果把 A 和 B 位置交换,会发现 C 悬空了。之所以上面的例子可以用交换思路,是因为 A 与 B 交换后,A 还可以 “挡住” C 的上移。但这个例子因为 B 很长,但 A 很短,A、B 交换后,A 就挡不住 C 的上移了: [-------------]| B |[-------------][-----] [-----]| C | | A |[-----] [-----] 所以为了保证任何时候位移都不会产生 BUG,需要老老实实的分两步来判断:1. 判断 A 移到 B 的底部。2. 新的 A 把下面组件挤走,同时如果上面还有空位置,需要整体向上位移。 看起来还是比较消耗性能的,但通过一些优化手段是可以极大减少计算量的,我们到系列的 “性能优化” 部分再说。 碰撞边界 case我们再考虑两个极端情况,第一种是要碰撞的组件过于矮的时候,第二种是要碰撞的组件过高的时候。 首先是过矮的情况,我们看下面 5 种情况: [-----]| | ← [ A ]| B || |[-----][-----]| || C || |[-----] 上面的情况插入到 B 的上方(假设 B 上方没有元素了,如果有的话,假设 B 上方为 X,那么应该认为 A 插入到 X 的底部)。 [-----]| || B || | ← [ A ][-----][-----]| || C || |[-----] 上面的情况插入到 B 的下方。 [-----]| || B || |[-----][-----]| | ← [ A ]| C || |[-----] 上面的情况插入到 B 的下方。 [-----]| || B || |[-----][-----]| || C || | ← [ A ][-----] 上面的情况插入到 C 的下方。 [-----]| || B || | [---][-----] ← [ A ][-----] [---]| || C || |[-----] 上面的情况和简单碰撞里提到的例子一样,碰撞位置在 B 与 C 之间,还是会认为插入到 B 的下方。 总结一下,过矮的情况下很多时候拖动组件只会与一个组件产生碰撞,当拖拽中心点在碰撞组件中心点上方时,插入到碰撞组件上方的组件下面(如果上方没有组件则插入到顶部)。当然插入到上方组件下面也不是真的找到上方组件是什么,具体如何做我们等到【实现分析】篇再讲。反之,如果中心点相对在下方,就插入到碰撞组件的下方。如果同时碰撞了多个组件,则忽略中心点偏移量靠上的碰撞,仅考虑中心点偏移量靠下的碰撞。 关于中心点上方其实也可以进一步优化,比如当目标碰撞组件太长的时候,可能比较难移到下方,此时在还没有拖拽到中心点下方时就要做下方碰撞判定了,此时判断依据可以优化为:碰撞时,拖拽组件的 Y 只要比目标组件的 Y 大(或者再加一个常数阈值,该阈值由拖拽组件高度决定,比如是高度的 1/3),那么就认为拖入到目标组件底部,比如: [-----]| | [---]| | ← [ A ]| B | [---]| || |[-----] 如上图所示,虽然 A 的中心点在 B 中心点上方,但因为 A.y - B.y > A.height / 3,所以判定插入到 B 的下方。当然这也会导致拖入超高组件上方很困难,所以要不要这么设定看用户喜好。 再看组件过高的情况: [---][-----] [ ]| B | ← [ A ][-----] [ ][-----] [---]| C |[-----] 上面的情况插入 B 的上方(如果 B 上面还有组件 X,则判定为插入该 X 下方)。 [-----] [---]| B | [ ][-----] ← [ A ][-----] [ ]| C | [---][-----] 上面的情况插入 B 的下方。 [-----]| B |[-----][-----] [---]| C | [ ][-----] ← [ A ] [ ] [---] 上面的情况插入 C 的下方。 总结一下,当拖拽组件过高时,还是维持中心点判断规则,但更可能同时碰撞到多个组件,此时沿用 “中心点偏移量靠上的碰撞” 的原则就行了。但这里有一个较大的区别,拖拽组件矮的时候最多同时和两个组件碰撞,但拖拽组件高的时候,可能同时和 N 个组件碰撞,如下图所示: [-----] [---]| B | [ ][-----] [ ][-----] [ ]| C | [ ][-----] ← [ A ][-----] [ ]| D | [ ][-----] [ ][-----] [ ]| E | [---][-----] 此时就要看和哪个组件碰撞的优先级最高了。我们单从 B、C、D、E 的角度看,A 分别应该放在 B 下方、C 下方、D 上方、E 上方,其中 B 下方与 C 上方是同一个位置,但与 D 上方、E 上方都不是同一个位置,此时就要看拖拽到哪个位置产生的位移最小了,因为最小的位移是最不突兀的,最符合用户的预期。 另一个边界情况就是拖拽组件过高时,如果中心点还未移动到下方,但高度却超出了下面组件下方,也要视为拖拽到下方: [-----]| || || || A || || || |[-----] ↓[-----]| B |[-----] 如上图所示,A 非常高,B 很矮,当 A 往下移动时,可能 A 的底部都超出 B 底部了(可以优化为 B 的中间),但 A 的中心点仍然在 B 中心点上方,此时在用户已经认为可以交换位置了,所以判断是否移动到下方多了一个优先判断条件:拖拽组件底部超出目标组件底部。同理拖拽到上方也类似。 要注意的是,这个例子与下面的例子表现并不一致,下面的例子 A 向左移时,应该放置 B 的上方,而上面的例子却放置 B 的下方: [-----] | | | | | | ← | A |[-----] | || B | | |[-----] | | [-----] 发现了吗?单从垂直位置来看,都是 A 的底部超过了 B 底部,但有时候和 B 互换,有时候却不互换。区分方法就是该碰撞发生时,这两个区块是否已经发生过碰撞。如果未发生过碰撞则严格根据中心点偏移量判断,偏移量靠上则放在上方,反之下方;已经处于碰撞状态则根据顶部或底部判断,顶部超出目标中心点则放上方,底部超出目标中心点则放下方。 碰撞边界与静态区块如果没有静态组件,碰撞边界就只有容器顶部。加上静态组件后,产生位移时要判断加上一段位移是否会把静态组件挤走,如果会挤走,则该拖拽位置无效。 固定步长磁贴布局为了方便对齐,往往会把父容器切割为 12 或者 6 等分,此时拖拽位置就不会完全跟手,当拖拽没有超过临界点的时候,实际拖拽位置不会跟随移动。 总结磁贴布局的功能主要聚焦在组件间碰撞逻辑上,目标是让用户能够自然的布局,所以组件间碰撞逻辑也要尽可能自然,符合直觉。 讨论地址是:精读《磁贴布局 - 功能分析》· Issue ##458 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《磁贴布局 - 功能实现》","path":"/wiki/WebWeekly/前沿技术/《磁贴布局 - 功能实现》.html","content":"当前期刊数: 266 经过上一篇 精读《磁贴布局 - 功能分析》 的分析,这次我们进入实现环节。 精读实现磁贴布局前,先要实现最基础的组件拖拽流程,然后我们才好在拖拽的基础上增加磁贴效果。 基础拖拽能力对布局抽象来说,它关心的就是 可拖拽的组件 与 容器 的 DOM,至于这些 DOM 是如何创建的都可以不用关心,在这个基础上,甚至可以再做一套搭建或者布局框架层,专门实现对 DOM 的管理,但这篇文章还是聚焦在布局的实现层。 布局组件首先要收集到有哪些可拖拽组件与容器,假设业务层将这些 DOM 生成好传给了布局: const elementMap: Record< string, { dom: HTMLElement; x: number; y: number; width: number; height: number; }> = {};const containerMap: Record< string, { dom: HTMLElement; rectX: number; rectY: number; width: number; height: number; }> = {}; elementMap 表示可拖拽的组件信息,包括其 DOM 实例,以及相对于父容器的 x、y、width、height。 containerMap 表示容器组件信息,之所以存储 rectX 与 rectY 这两个相对浏览器绝对定位,是因为容器的直接父组件可能是 element,比如 Card 组件可以同时渲染 Header 与 Footer,这两个位置都可以拖入 element,所以这两个位置都是 container,它们是相对父 element Card 定位的,所以存储绝对定位方便计算。 接下来给 elementMap 的每一个组件绑定鼠标按下事件作为 onDragStart 时机: Object.keys(elementMap).forEach((componentId) => { elementMap[componentId].dom.onmousedown = () => { // 记录拖拽开始 };}); 然后在 document 监听 onMouseMove 与 onMouseUp,分别作为 onDrag 与 onDragEnd 时机,这样我们就抽象了拖拽的前、中、后三个阶段: function onDragStart(context, componentId) { context.dragComponent = componentId;}function onDrag(context, event) { // 根据 context.dragComponent 响应组件的拖动 // 将 element x、y 改为 event.clientX、event.clientY 即可}function onDragEnd(context) { context.dragComponent = undefined;} 这样最基础的拖拽能力就做好了,在实际代码中,可能包含进一步的抽象这里为了简化先忽略,比如可能对所有事件的监听进行 Action 化,以便单测在任何时候模拟用户输入。 磁贴布局影响因子磁贴布局入场后,仅影响 onDrag 阶段。在之前的逻辑中,拖拽是完全自由的,那么磁贴布局就会约束两点: 对当前拖拽组件位置做约束。 可能把其他组件挤走。 对拖拽组件位置的约束是由背后的 “松手 DOM” 决定的,也就是拖拽时 element 是实时跟手的,但如果拖拽位置无法放置,就会在松手时修改落地位置,这个落地位置我们叫做 safePosition,即当前组件的安全位置。 所以 onDrag 就要计算一个新的 safePosition,它应该如何计算,由磁贴的碰撞方式决定,我们可以在 onDrag 函数里做如下抽象: function onDrag(context, event) { // 根据 context.dragComponent 响应组件的拖动 const { safeX, safeY } = collision(context, event.clientX, event.clientY); // 实时的把组件位置改为 event.clientX、event.clientY // 把背后实际落点 DOM 位置改为 safeX、safeY // onDragEnd 时,再把组件位置改为 safeX、safeY,让组件落在安全的位置上} 接下来就到了重点函数 collision 的实现部分,它需要囊括磁贴布局的所有核心逻辑。 collision 函数包括两大模块,分别是拖入拖出模块与碰撞模块。拖入拖出判断当前拖拽位置是否进入了一个新容器,或者离开了当前容器;碰撞模块判断当前拖拽位置是否与其他 element 产生了碰撞,并做出相应的碰撞效果。 除此之外,磁贴布局还允许组件按照重力影响向上吸附,因此我们需要做一个 runGravity 函数,把所有组件按照重力作用排列。 function collision(context, x, y) { // 先做拖入拖出判断 if (judgeDragInOrOut(context, event)) { // 如果判定为拖入或拖出,则不会产生碰撞,提前 return // 但是拖出时需要对原来的父节点做一次 runGravity // 拖入时不用对原来父节点做 runGravity return { safeX: x, safeY: y }; } // 碰撞模块 return gridCollsion(context, x, y);} 为什么拖入时不用对原来父节点做 runGravity: 假设一个 element 从上向下移动入一个 container,那么一旦拖入 container 就会在其上方产生 Empty 区域,如果此时 container 立即受重力作用挤了上去,但鼠标还没松手,可能鼠标位置又立即落在了 container 之外,导致组件触发了拖出。因此拖入时,先不要立刻对原先所在的父容器作用重力,这样可以维持拖入时结构的稳定。 拖入拖出模块拖入拖出判断很简单,即一个 element 如果有 x% 进入了 container 就判定为拖入,有 y% 离开了 container 就判定为离开。 碰撞模块碰撞模块 gridCollsion 比较复杂,这里展开来讲。首先需要写一个矩形相交函数判断两个组件是否产生了碰撞: function gridCollsion(context, x, y) { Object.keys(context.elementMap).forEach((componentId) => { // 判断 context.dragComponent 与 context.elementMap[componentId] 是否相交,相交则认为产生了碰撞 });} 如果没有产生碰撞,那我们要根据重力影响计算落点 safeY(横向不受重力作用且一定跟手,所以不用算 safeX)。此时直接调用 runGravity 函数,传一个 extraBox,这个 extraBox 就是当前鼠标位置产生的 box,这个 box 因为没有与任何组件产生碰撞,直接判断一下在重力的作用下,该 extraBox 会落在哪个位置即可,这个位置就是 safeY: function gridCollision(context, x, y) { // 在某个父容器内计算重力,同时塞入一个 extraBox,返回这个 extraBox 生效重力后的 Y:extraBoxY const { extraBoxY } = runGravity(context, parentId, extraBox); return { safeY: extraBoxY };} 没有产生碰撞的逻辑相对简单,如果产生了碰撞的逻辑是这样的: // 是否为初始化碰撞。初始化碰撞优先级最低,所以只要发生过非初始碰撞,与其他组件的初始碰撞也视为非初始碰撞let isInitCollision = true;Object.keys(context.elementMap).forEach((componentId) => { // 判断 context.dragComponent 与 context.elementMap[componentId] 是否相交 const intersect = areRectanglesOverlap(); // 相交 if (intersect.isIntersect) { // 1. 在 context 存储一个全局变量,判断当前组件之前是否相交过,以此来判断是否要修改 isInitCollision // 2. 判断产生碰撞后,该碰撞会导致鼠标位置的 box,也就是 extraBox 放到该组件之上还是之下 }}); 首先要确定当前碰撞是否为初始化碰撞,且一旦有一个组件不是初始化碰撞,就认为没有发生初始化碰撞。原因是初始化碰撞的位置判断比较简单,直接根据 source 与 target element 的水平中心点的高低来判断落地位置。如果 source 水平中心点位置比 target 的高,则放到 target 上方,否则放在 target 下方。 如果是非初始化碰撞逻辑会复杂一些,比如下面的例子: // [---] [ C ]// [ B ]// [---]// ↑// [-------]// [ A ]// [-------] 当 A 组件向上移动时,因为已经与 B 产生了碰撞,所以就会尝试判断合适置于 B 之上,否则永远会把自己锁在 B 的下方。实际上,我们希望 A 的上边缘超过 B 的水平中心点就产生交换,此时 A 的水平中心点还在 B 的水平中心点之下,所以此时按照两种不同的判断规则会产生不同的位置判定,区分的手段就是 A 与 B 是否已经处于相交状态。 现在终于把插入位置算好了(根据是否初始化碰撞,判断 extraBox 落在哪个 element 的上方或者下方),那么就进入 runGravity 函数: function runGravity(context, parentId, extraBox) {} 这个函数针对某个父容器节点生效重力,因此在不考虑 extraBox 的情况下逻辑是这样的: 先拿到该容器下所有子 element,对这些 element 按照 y 从小到大排序,然后依次计算落点,已经计算过的组件会计算到碰撞影响范围内,也就是新的组件 y 要尽可能小,但如果水平方向与已经算过的组件存在重叠,那么只能顶到这些组件的下方。 如果有 extraBox 的话,问题就复杂了一些,看下面的图: // [---] [ C ]// [ B ]// [---]// ↑// [-------]// [ A ]// [-------]// A 这个 extraBox before B// 这个例子应该按照 C -> A -> B 的顺序计算重力// 规则:如果有 before ids(ids y,bottom 都一样),则把排序结果中 y >= ids.y & bottom < ids[0].bottom 的组件抽出来放到 ids 第一个组件之前// [-------]// [ A ]// [-------]// ↓// [---] [ C ]// [ B ]// [---]// A 这个 extraBox after B// 这个例子应该按照 C -> A -> B 的顺序计算重力// 规则:如果有 after ids(ids y,bottom 都一样),则把排序结果中 y <= ids.y & bottom > ids[0].bottom 的组件抽出来放到 ids 最后一个组件之后 因为 extraBox 是一个插入性质的位置,所以计算方式肯定有所不同。以第一个例子为例:当 A 向上移动并可以与 B 产生交换时,最后希望的结果自上至下是 C -> A -> B,但因为 C 和 B 的 y 都是 0,如果我们把 A 与 B 交换理解为 A 的 y 变成 0 从而把 B 挤下去,那么 A 也会把 C 挤下去,导致结果不对。 因此重要的是计算重力的优先级,上面的例子重力计算顺序应该是先算 C,再算 A,再算 B,这个逻辑的判断依据如上面注释所说。 上面说的都是 isInitCollision=false 的算法,如果 isInitCollision=true,则 extraBox 按照 y 顺序普通插入即可。原因看下图: // [-------] [-]// [ ] [ ]// [ ] [D]// [ A ] → [ ]// [ ] [-]// [ ] [-----------------]// [-------] [ ]// [-----] [ C ]// [ B ] [ ]// [-----] [-----------------] 当将 A 向右移动直到与 C 碰撞时,按照 y 来计算重力优先级时结果是正确的。如果按照 extraBox 已产生过碰撞的算法,则会认为 A 放到 C 的上方,但因为 B 相对于 C 满足 y >= ids.y & bottom < ids[0].bottom,所以会被提取到 C 的前面计算,导致 B 放在了 A 前面,产生了错误结果。因为这种碰撞被误判为 “A 从 C 的下方向上移动,直到与 C 交换,此时 B 依然要置于 A 的上方”,但实际上并没有产生这样的移动,而是 A 与 C 的一次初始化碰撞,因此不能适用这个算法。 总结因为篇幅有限,本文仅介绍磁贴布局实现最关键的部分,其他比如步长功能,如果后续有机会再单独整理成一篇文章发出来。 从上面的讨论可以发现,在每次移动时都要重新计算 safe 位置的落点,而这个落点又依赖 runGravity 函数,如果每次都要把容器下所有组件排序,并一一计算落点位置的话,时间复杂度达到了 O(n²),如果画布有 100 个组件,就会至少循环一万次,对性能压力是比较大的。因此磁贴布局也要做性能优化,这个我们放到下篇文章介绍。 讨论地址是:精读《磁贴布局 - 功能实现》· Issue ##459 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《磁贴布局 - 性能优化》","path":"/wiki/WebWeekly/前沿技术/《磁贴布局 - 性能优化》.html","content":"当前期刊数: 267 经过上一篇 精读《磁贴布局 - 功能实现》 的介绍,这次我们进入性能优化环节。 精读磁贴布局性能优化方式有很多,比如通过空间换时间,存储父子关系的索引,方便快速查找到目标组件。但有一个最核心的性能优化点,即碰撞性能优化。 试想,最朴素的判断组件碰撞方法是什么?一般会遍历画布所有的组件,根据当前组件位置与目标组件位置的相对位置判断是否产生碰撞,所以仅判断单个组件碰撞时,时间复杂度是 O(n)。 但磁贴布局的碰撞判断涉及整个画布,因为一个组件的移动可能引发另一个组件的移动,形成一系列连环布局变化,比如下面这个情况: [---] [ ] [ A ] [ ] ↑ [---][---------][ B ][---------] [---] [ C ] [---] [-------] [ D ] [-------] 比如将 B 向上移动,每个组件落下来时都要做独立的碰撞判定。因为最终碰撞结果是很难预测的,只能一个组件一个组件的判断。比如上面的例子,结果如下: [---------][ B ][---------] [---] [---] [ C ] [ ] [---] [ A ] [ ] [---] [-------] [ D ] [-------] 可以看到,D 本来是紧紧靠着 C 的,但因为 A 组件移下来了,且 A 比 C 高,所以 D 紧靠的组件就从 C 变成 A 了,这个在 C 做独立碰撞判断之前,是难以通过画布的结构分析出来的,更不用说结合上画布的整体大小缩放、栅格数量的变化后产生的影响,组件最终落点必须每个组件通过正确顺序依次判定碰撞后才能确定。 因此磁贴碰撞的时间复杂度是 O(n²),比如页面中有 100 个组件,就至少要遍历 10000 次才能完成一次布局计算,这样在比较极限的情况下,比如页面有 1000 个组件时,布局计算肯定非常耗时。 栅格碰撞判定法再思考一个问题,正是由于磁贴布局的碰撞判定,导致 磁贴布局不可能存在组件重叠的情况,因此即便画布存在 1000 个组件,只要组件宽高不是特别小(比如每个组件 1px 宽高,挤满 1000px 区域),都不可能聚集在某个小区域内,而是分散在很大的范围,那么与当前组件过远的组件就根本不需要做碰撞判定,因为他们不可能相交。 再类比到人判断碰撞的视角,当画布有 1000 个组件时,我们也能一眼看出来某个组件与哪些组件相交,但这个判断来自于肉眼在可视区域一扫而过,而不是把 1000 个组件全部看一遍。这说明人眼判定碰撞是经过优化的:以这个组件为圆心,上下左右扩大一定的范围扫一眼是否有碰撞就够了。 因此我们模拟人眼找碰撞的思路,把画布分为若干的栅格,记录每个组件所在的栅格,这样碰撞判定时,只要在组件所在栅格内进行判定就行了。 如下将画布分为若干栅格: [---] │ │ │ │ [ A ] │ │ │ │ [---] │ │ │ │────────┼────────┼────────┼────────┼──────── [-----] │ │ │ [ B ] │ [---]│ │ [-----][C] │ [ G ]│ │────────┼────────┼───[---]┼────────┼──────── │ │ [E] │ [F] │ │ │ [-----------] │ │ │ [ ] │────────┼────────┼───[ D ]─┼──────── │ │ [ ] │ │ │ [-----------] │ │ │ │ │ 这样当判定如下组件碰撞时,要对比的组件如下: A:对比组件无。 B:对比组件 C。 D:对比组件 E、F、G 由于一个区域承载组件数量是固定的,所以 O(n²) 时间复杂度就优化为了 O(n x P) 其中 P 对每个组件来说都是常数,因此时间复杂度最终为 O(n)。 当然这里存在几个注意事项: 需要空间换时间,即存储每个组件属于哪些区域,以及每个区域有哪些组件,这样拖拽判定时无需遍历所有组件。 栅格大小不宜过大,栅格过大则划分栅格的意义就不大了,因为一个栅格内组件数还是很多。 栅格大小不宜过小,这样每个组件可能横跨很多栅格,导致栅格数量本身的循环次数甚至会超越组件树,就变成了负优化。 关于栅格大小,一般磁贴布局会设置 cols rowHeight 两个选项,以这两个选项的正整数倍为跨度设置栅格是比较合适的,这样会尽可能减少栅格的无效面积。 不同场景下的栅格计算上面说了 组件碰撞 如何使用栅格计算,我们再总结一下:判定组件碰撞,只要找到当前组件所在的栅格 areas,遍历每一个栅格区域内的组件即可。 除了碰撞判断外,磁贴拖拽过程中还有两个场景需要计算组件间碰撞关系,主要包括 落点位置 与 落点后组件排序 两个场景。 比如下面的例子: 蓝色框为鼠标拖动组件时,鼠标的实时位置,而红色背景正方形表示 落点位置,红色正方形下方的组件属于 落点后组件,这些组件因为红色正方形的位置插入,需要重新计算位置。 为了最大程度利用栅格优化性能,这两种情况需要分别判断。 落点位置由于磁贴布局的重力是垂直向上的,因此落点只会落在当前组件的上方,也就是落点只会与上方组件碰撞,因此考虑垂直向上的栅格区域即可。而且过程中还是可以优化的,即一格一格向上查找,只要在某个格内查到碰撞组件,就可以终止查找了: [---] │ │ [ A ] │ │ [---] │ │────────┼────────┼───────── [-----] │ [ B ] │ [-----] │────────┼────────┼─────────[-----] │ │[ C ] │ │[-----] │ │────────┼────────┼───────── [-----] │ [ D ] │ [-----] │ 如上面的例子,移动 D 时: 先考虑 D 所在区域是否有组件垂直区域可碰撞,因为 D 所在区域只有自己,所以跳过。 在考虑 D 区域上方一格区域,发现组件 C,且与 D 在垂直位置可碰撞,因此 D 的落点位置放在 C 的下方。 查找结束,再向上的区域直接跳过。 因此落点位置的查找时间复杂度是 O(1)。 落点后组件排序落点位置决定后,由于落点位置毕竟发生了变化,落点之后的组件都要重新按照磁贴向上的重力作用排序,所以此时组件查找范围是包含落点所在区域内,垂直向下的所有区域: [---] │ │ [ A ] │ │ [---] │ │────────┼────────┼───────── [-----] │ [ B ] │ [-----] │────────┼────────┼─────────[-----] │ │[ C ] │ │[-----] │ │────────┼────────┼───────── [-----] │ [ D ] │ [-----] │ 如上面的例子,移动 A 时,A 所在区域下方所有区域都要重新判断落点,也就是 B、C、D 组件所在区域。其他区域不受影响。我们假设所有组件均匀的平铺在所有区域,那么最坏的情况下(移动的组件在最顶部,那么一整条高度的区域都要搜索)纵向区域的组件数是 logn,所以时间复杂度理论上是 O(logn)。但一般情况磁贴布局高度远大于宽度,所以可能往较坏的 O(n) 复杂度发展,但不论如何,这个线性性能是可接受的。 总结经过优化,磁贴布局在拖拽前、中、后各个阶段的计算复杂度均为 O(n),即一个拥有 500 个组件实例的复杂画布,也只要在每次拖动时循环 500 次计算位置,而配合空间换时间的一些 Map 映射关系配合,500 次计算加起来最多消耗 23 ms,而 1000 个组件实例也最多 46 ms 的消耗,但超过 1000 个组件实例的画布几乎是不可能存在的,况且这里 log(n) 的 n 指的是每个容器内的组件,因此只要单个容器内组件数量几乎不会超过特别多,所以性能是没有问题的。 讨论地址是:精读《磁贴布局 - 性能优化》· Issue ##461 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《算法基础数据结构》","path":"/wiki/WebWeekly/前沿技术/《算法基础数据结构》.html","content":"当前期刊数: 194 掌握了不同数据结构的特点,可以让你在面对不同问题时,采用合适的数据结构处理,达到事半功倍的效果。 所以这次我们详细介绍各类数据结构的特点,希望你可以融会贯通。 精读数组 数组非常常用,它是一块连续的内存空间,因此可以根据下标直接访问,其查找效率为 O(1)。 但数组的插入、删除效率较低,只有 O(n),原因是为了保持数组的连续性,必须在插入或删除后对数组进行一些操作:比如插入第 K 个元素,需要将后面元素后移;而删除第 K 个元素,需要将后面元素前移。 链表 链表是为了解决数组问题而发明出来的,它提升了插入、删除效率,而牺牲了查找效率。 链表的插入、删除效率是 O(1),因为只要将对应位置元素断链、重连就可以完成插入、删除,而无需关心其他节点。 相应的查找效率就低了,因为存储空间不是连续的,所以无法像数组一样通过下标直接查找,而需要通过指针不断搜索,所以查找效率为 O(n)。 顺带一提,链表可以通过增加 .prev 属性改造为双向链表,也可以通过定义两个 .next 形成二叉树(.left .right)或者多叉树(N 个 .next)。 栈 栈是一种先入后出的结构,可以用数组模拟。 const stack: number[] = []// 入栈stack.push(1)// 出栈stack.pop() 堆 堆是一种特殊的完全二叉树,分为大顶堆与小顶堆。 大顶堆指二叉树根节点是最大的数,小顶堆指二叉树根节点是最小的数。为了方便说明,以下以大顶堆举例,小顶堆的逻辑与之相反即可。 大顶堆中,任意节点都比其叶子结点大,所以根节点是最大的节点。这种数据结构的优势是可以以 O(1) 效率找到最大值(小顶堆找最小值),因为直接取 stack[0] 就是根节点。 这里稍微提一下二叉树与数组结构的映射,因为采用数组方式操作二叉数,无论操作还是空间都有优势:第一项存储的是节点总数,对于下标为 K 的节点,其父节点下标是 floor(K / 2),其子节点下标分别是 K * 2、K * 2 + 1,所以可以快速定位父子位置。 而利用这个特性,可以将插入、删除的效率达到 O(logn),因为可以通过上下移动的方式调整其他节点顺序,而对于一个拥有 n 个节点的完全二叉树,树的深度为 logn。 哈希表 哈希表就是所谓的 Map,不同 Map 实现方式不同,常见的有 HashMap、TreeMap、HashSet、TreeSet。 其中 Map 和 Set 实现类似,所以以 Map 为例讲解。 首先将要存储的字符求出其 ASCII 码值,再根据比如余数等方法,定位到一个数组的下标,同一个下标可能对应多个值,因此这个下标可能对应一个链表,根据链表进一步查找,这种方法称为拉链法。 如果存储的值超过一定数量,链表的查询效率就会降低,可能会升级为红黑树存储,总之这样的增、删、查效率为 O(1),但缺点是其内容是无序的。 为了保证内容有序,可以使用树状结构存储,这种数据结构称为 HashTree,这样时间复杂度退化为 O(logn),但好处是内容可以是有序的。 树 & 二叉搜索树 二叉搜索树是一种特殊二叉树,更复杂的还有红黑树,但这里就不深入了,只介绍二叉搜索树。 二叉搜索树满足对于任意节点,left 的所有节点 < 根节点 < right 的所有节点,注意这里是所有节点,因此在判断时需要递归考虑所有情况。 二叉搜索树的好处在于,访问、查找、插入、删除的时间复杂度均为 O(logn),因为无论何种操作都可以通过二分方式进行。但在最坏的情况会降级为 O(n),原因是多次操作后,二叉搜索树可能不再平衡,最后退化为一个链表,就变成了链表的时间复杂度。 更好的方案有 AVL 树、红黑树等,像 JAVA、C++ 标准库实现的二叉搜索树都是红黑树。 字典树 字典树多用于单词搜索场景,只要给定一个单独开头,就可以快速查找到后面有几种推荐词。 比如上面的例子,输入 “o”,就可以快速查找到后面有 “ok” 与 “ol” 两个单词。要注意的是,每个节点都要有一个属性 isEndOfWord 表示到当前为止是否为一个完整的单词:比如 go 与 good 两个都是完整的单词,但 goo 不是,因此第二个 o 与第四个 d 都有 isEndOfWord 标记,表示读到这里就查到一个完整的单词了,叶子结点的标记也可以省略。 并查集 并查集用来解决团伙问题,或者岛屿问题,即判断多个元素之间是属于某个集合。并查集的英文是 Union and Find,即归并与查找,因此并查集数据结构可以写成一个类,提供两个最基础的方法 union 与 find。 其中 union 可以将任意两个元素放在一个集合,而 find 可以查找任意元素属于哪个根集合。 并查集使用数组的数据结构,只是有以下特殊含义,设下标为 k: nums[k] 表示其所属的集合,如果 nums[k] === k 表示它是这个集合的根节点。 如果要数一共有几个集合,只要数有多少满足 nums[k] === k 条件的数目即可,就像数有几个团伙,只要数有几个老大即可。 并查集的实现不同,数据也会有微妙的不同,高效的并查集在插入时,会递归将元素的值尽量指向根老大,这样查找判断时计算的快一些,但即便指向的不是根老大,也可以通过递归的方式找到根老大。 布隆过滤器 Bloom Filter 只是一个过滤器,可以用远远超过其他算法的速度把未命中的数据排除掉,但未排除的也可能实际不存在,所以需要进一步查询。 布隆过滤器是如何做到这一点的呢?就是通过二进制判断。 如上图所示,我们先存储了 a、b 两个数据,将其转化为二进制,将对应位置改为 1,那么当我们再查询 a 或 b 时,因为映射关系相同,所以查到的结果肯定存在。 但查询 c 时,发现有一项是 0,说明 c 一定不存在;但查询 d 时,恰好两个都查到是 1,但实际 d 是不存在的,这就是其产生误差的原因。 布隆过滤器在比特币与分布式系统中使用广泛,比如比特币查询交易是否在某个节点上,就先利用布隆过滤器挡一下,以快速跳过不必要的搜索,而分布式系统计算比如 Map Reduce,也通过布隆过滤器快速过滤掉不在某个节点的计算。 总结最后给出各数据结构 “访问、查询、插入、删除” 的平均、最差时间复杂度图: 这个图来自 bigocheatsheet,你也可以点开链接直接访问。 学习了这些基础数据结构之后,希望你可以融会贯通,善于组合这些数据结构解决实际的问题,同时还要意识到没有任何一个数据结构是万能的,否则就不会有这么多数据结构需要学习了,只用一个万能的数据结构就行了。 对于数据结构的组合,我举两个例子: 第一个例子是如何以 O(1) 平均时间复杂度查询一个栈的最大或最小值。此时一个栈是不够的,需要另一个栈 B 辅助,遇到更大或更小值的时候才入栈 B,这样栈 B 的第一个数就是当前栈内最大或最小的值,查询效率是 O(1),而且只有在出栈时才需要更新,所以平均时间复杂度整体是 O(1)。 第二个例子是如何提升链表查找效率,可以通过哈希表与链表结合的思路,通过空间换时间的方式,用哈希表快速定位任意值在链表中的位置,就可以通过空间翻倍的牺牲换来插入、删除、查询时间复杂度均为 O(1)。虽然哈希表就能达到这个时间复杂度,但哈希表是无序的;虽然 HashTree 是有序的,但时间复杂度是 O(logn),所以只有通过组合 HashMap 与链表才能达到有序且时间复杂度更优,但牺牲了空间复杂度。 包括最后说的布隆过滤器也不是单独使用的,它只是一个防火墙,用极高的效率阻挡一些非法数据,但没有阻挡住的不一定就是合法的,需要进一步查询。 所以希望你能了解到各个数据结构的特征、局限以及组合的用法,相信你可以在实际场景中灵活使用不同的数据结构,以实现当前业务场景的最优解。 讨论地址是:精读《算法基础数据结构》· Issue ##312 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《精通 console","path":"/wiki/WebWeekly/前沿技术/《精通 console.html","content":"当前期刊数: 138 1 引言本周精读的文章是 Mastering JS console.log like a Pro,一起来更全面的认识 console 吧! 2 概述 & 精读console 的功能主要在于控制台打印,它可以打印任何字符、对象、甚至 DOM 元素和系统信息,下面一一介绍。 console.log( ) | info( ) | debug( ) | warn( ) | error( )直接打印字符,区别在于展示形态的不同: 新版 chrome 控制台可以将打印信息分类: log() 与 info() 都对应 info,warn() 对应 warnings,error() 对应 errors,而 debug() 对应 verbose,因此建议在合适的场景使用合适的打印习惯,这样排查问题时也可以有针对性的筛选。 比如调试信息可以用 console.debug 仅在调试环境下输出,调试者即便开启了调试参数也不会影响正常 info 的查看,因为调试信息都输出在 verbose 中。 使用占位符 %o — 对象 %s — 字符串 %d — 数字 如下所示,可通过占位符在一行中插入不同类型的值: 添加 CSS 样式 %c - 样式 可以总结出,console 支持输出复杂的内容,其输出能力堪比 HTML,但输入能力太弱,仅为字符串,因此采用了占位符 + 多入参修饰的设计模式解决这个问题。 console.dir( )按 JSON 模式输出。笔者在这里也补充一句:console.log() 会自动判断类型,如果内容是 DOM 属性,则输出 DOM 树,但 console.dir 会强制以 JSON 模式输出,用在 DOM 对象时可强制转换为 JSON 输出。 输出 HTML 元素按照 HTML ELements 结构输出: 这种输出结构和 Elements 打印形式是一致的,如果要看详细属性,可以使用 console.dir()。 console.table在控制台打印一个表格,属于功能增强。虽然仅文本也可以在控制台打印出漂亮的表格,但浏览器调试控制台的功能更强大,console.table 只是其富文本能力的一个体现。 console.group( ) & console.groupEnd( )接下来是另一个富文本能力,按分组输出: 这种带有副作用的 API 显然是为方便阅读而设计的,然而在需要输出大量动态结构化数据的场景下,还需要进行结构转换,是比较麻烦的地方。 console.count( )count() 用来打印调用次数,一般用在循环或递归函数中。接收一个 label 参数以定制输出,默认直接输出 1 2 3 数字。 console.assert( )console 版断言工具,当且仅当第一个参数值为 false 时才打印第二个参数作为输出。 这种输出结果为 error,所以也可被 console.error + 代码级别断言所取代。 console.trace( )打印此时的调用栈,在打印辅助调试信息时非常有用。 console.time( )打印代码执行时间,性能优化和监控场景比较常见。 console.memory打印内存使用情况。 console.clear( )清空控制台输出。 3 总结console 提供了如此多的输出规范,其实也是在变相制定开发规范,毕竟离开发者最近的就是调试控制台,如果你的项目打印规范与标准规范有差异,那么调试时信息看起来就会很别扭。 可以看到,大部分开源库都良好的遵循了这套规范,比如三方库绝不会输出 log(),而且将错误、警告与调试信息正确分开,并尽量少的用 CSS 样式、分组、table 等功能,因为这些功能干扰性较强,不能保证所有用户都可接受。 相对的,项目源码就比较适合使用一些醒目的自定义规范,只要这套规则能被很好的执行起来。 最后留下一个讨论点:console 可以作为调试、招聘信息、隐藏菜单的投放点,你还看到过哪些有意思的 console 使用方式呢?欢迎留言。 讨论地址是:精读《精通 console.log》 · Issue ##228 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《用 Babel 创造自定义 JS 语法》","path":"/wiki/WebWeekly/前沿技术/《用 Babel 创造自定义 JS 语法》.html","content":"当前期刊数: 123 1 引言在写这次精读之前,我想谈谈前端精读可以为读者带来哪些价值,以及如何评判这些价值。 前端精读已经写到第 123 篇了,大家已经不必担心它突然停止更新,因为我已养成每周写一篇文章的习惯,而读者也养成了每周看一篇的习惯。所以我想说的其实是一种更有生命力的自媒体运作方式,定期更新。一个定期更新的专栏比一个不不定期更新的专栏更有活力,也更受读者喜爱,因为读者能看到文章之间的联系,跟随作者一起成长。个人学习也是如此,养成定期学习的习惯,比在培训班突击几个月更有用,学会在生活中规律的学习,甚至好过读几年名牌大学。 前端精读想带给读者的不仅是一篇篇具体的内容和知识,知识是无穷无尽的,几万篇文章也说不完,但前端精读一直沿用了“引言-概述-精读-总结”这套学习模式,无论是前端任何领域的问题,还是对人生和世界的思考都可以套用,希望能为读者提供一套学习思维框架,让你能学习到如何找到好的文章,以及如何解读它。 至今已经选择了许多源码解读的题材,与培训思维的源码解读不同,我希望你不要带着面试的目的学习源码,因为这样会让你只局限在 react、vue 这种热门的框架上。前端精读选取的框架类型之所以广泛,是希望你能静下心来,吸取不同框架风格与作者的优势,培养一种优雅编码的气质。 进入正题,这次选择的文章 《用 Babel 创造自定义 JS 语法》 也是培养编码气质的一类文章,虽然对你实际工作用处不大,但这篇文章可以培养几个程序员梦寐以求的能力:深入理解 Babel、深入理解框架拓展机制。理解一个复杂系统或培养框架思维不是一朝一夕的,但持续阅读这种文章可以让你越来越接近掌握它。 之所以选择 Babel,是因为 Babel 处理的一直是语法树相关的底层逻辑,编译原理是程序世界的基座之一,拥有很大的学习价值。所以我们的目的并不是像文章标题说的 - 创造一个自定义 JS 语法,因为你创造的语法只会让 JS 复杂体系更加混乱,但可以让你理解 Babel 解析标准 JS 语法的原理,以及看待新语法提案时,拥有从实现层面思考的能力。 最后,不必多说,能重温 Babel 经典的插件机制,你可以发现 Babel 的插件拓展机制和 Antrl4 很像,在设计业务模块拓展方案时也可以作为参考。 2 概述我们要利用 Babel 实现 function @@ 的新语法,用 @@ 装饰的函数会自动柯里化: // '@@' makes the function `foo` curriedfunction @@ foo(a, b, c) { return a + b + c;}console.log(foo(1, 2)(3)); // 6 可以看到,function @@ foo 描述的函数 foo 支持 foo(1, 2)(3) 这种柯里化调用。 实现方式分为两步: Fork babel 源码。 创建一个 babel 转换器插件。 不要畏惧这些步骤,“如果你读完了这篇文章,你将成为同事眼中的 Babel 大神” - 原文。 首先 Fork babel 源码到本地,执行下面的命令可以初始化并编译 babel: $ make bootstrap$ make build babel 使用 Makefile 执行编译命令,并且采用 monorepo 管理,我们这次要关心的是 package/babel-parser 这个模块。 词法首先要了解词法知识,更详细的可以阅读原文或精读之前的一篇系列文章:精读《词法分析》。 要解析语法,首先要进行词法分析。任何语法输入都是一个字符串,比如 function @@ foo(a, b, c),词法分析就是要将这个长度为 24 的字符拆分为一个个有语义的单词片段:function @@ foo ( a .. 由于 @@ 是我们创造的语法,所以我们第一个任务就是让 babel 词法分析可以识别它。 下面是 package/babel-parser 的文件结构: - src/ - tokenizer/ - parser/ - plugins/ - jsx/ - typescript/ - flow/ - ...- test/ 可以看到,分为词法分析 tokenizer,语法分析 parser,以及支持一些特殊语法的插件,以及测试用例 test。 推荐使用 Test-driven development (TDD) - 测试驱动开发的方式,就是先写测试用例,再根据测试用例开发。这种开发方式在后端或者 babel 这种底层框架很常见,因为 TDD 方式开发的逻辑能保证测试用例 100% 覆盖,同时先看测试用例也是个很好的切面编程思维。 // packages/babel-parser/test/curry-function.jsimport { parse } from '../lib';function getParser(code) { return () => parse(code, { sourceType: 'module' });}describe('curry function syntax', function() { it('should parse', function() { expect(getParser(`function @@ foo() {}`)()).toMatchSnapshot(); });}); 可以利用 jest 直接测试这段代码: BABEL_ENV=test node_modules/.bin/jest -u packages/babel-parser/test/c 结果会出现如下报错: SyntaxError: Unexpected token (1:9)at Parser.raise (packages/babel-parser/src/parser/location.js:39:63)at Parser.raise [as unexpected] (packages/babel-parser/src/parser/util.js:133:16)at Parser.unexpected [as parseIdentifierName] (packages/babel-parser/src/parser/expression.js:2090:18)at Parser.parseIdentifierName [as parseIdentifier] (packages/babel-parser/src/parser/expression.js:2052:23)at Parser.parseIdentifier (packages/babel-pars 第 9 个字符就是 @,说明程序现在还不支持函数前面的 @ 解析。我们还可以在错误堆栈中找到报错位置,并把当前 Token 与下一个 Token 打印出来: // packages/babel-parser/src/parser/expression.jsparseIdentifierName(pos: number, liberal?: boolean): string { if (this.match(tt.name)) { // ... } else { console.log(this.state.type); // current token console.log(this.lookahead().type); // next token throw this.unexpected(); }} this.state.type 代表当前 Token,this.lookahead().type 表示下一个 Token。lookahead 是词法分析的专有词,表示向后查看。打印之后,我们会发现输出了两个 @ Token: TokenType { label: '@', // ...} 下一步,我们需要让 babel 词法分析识别 @@ 这个 Token。首先需要注册这个 Token: // packages/babel-parser/src/tokenizer/types.jsexport const types: { [name: string]: TokenType } = { // ... at: new TokenType('@'), atat: new TokenType('@@'),}; 注册了之后,我们要在遍历 Token 时增加判断 “如果当前字符是 @ 且下一个字符也是 @,则整体构成了 @@ Token 并且光标向后移动两格”: // packages/babel-parser/src/tokenizer/index.jsgetTokenFromCode(code: number): void { switch (code) { // ... case charCodes.atSign: // if the next character is a `@` if (this.input.charCodeAt(this.state.pos + 1) === charCodes.atSign) { // create `tt.atat` instead this.finishOp(tt.atat, 2); } else { this.finishOp(tt.at, 1); } return; // ... }} 再次运行测试文件,输出变成了: // current tokenTokenType { label: '@@', // ...}// next tokenTokenType { label: 'name', // ...} 到这一步,已经能正确解析 @@ Token 了。 语法词法已经可以将 @@ 解析为 atat Token,下一步我们就要利用这个 Token,让生成的 AST 结构中包含柯里化函数的信息,并利用 babel 插件在解析时实现柯里化功能。 首先我们可以在 Babel AST explorer 看到 AST 解析的结构,我们拿 generator 函数测试,因为这个函数结构与柯里化函数类似: 可以看到,babel 通过 generator async 属性来标识函数是否为 generator 或者 async 函数。同理,增加一个 curry 属性就可以实现第一步了: 要实现如上效果,只需在词法分析 parser/statement 文件的 parseFunction 处新增 atat 解析即可: // packages/babel-parser/src/parser/statement.jsexport default class StatementParser extends ExpressionParser { // ... parseFunction<T: N.NormalFunction>( node: T, statement?: number = FUNC_NO_FLAGS, isAsync?: boolean = false ): T { // ... node.generator = this.eat(tt.star); node.curry = this.eat(tt.atat); }} eat 是吃掉的意思,实际上可以理解为吞掉这个 Token,这样做有两个效果:1. 为函数添加了 curry 属性 2. 吞掉了 @@ 标识,保证所有 Token 都被识别是 AST 解析正确的必要条件。 关于递归下降语法分析的更多知识,可以参考 精读《手写 SQL 编译器 - 语法分析》,或者阅读原文。 我们再次执行测试函数,发现测试通过了,一切都在预料中。 babel 插件现在我们得到了标记了 curry 的 AST,那么最后需要一个 babel 解析插件,实现柯里化。 首先我们通过修改 babel 源码的方式实现的效果,是可以转化为自定义 babel parser 插件的: // babel-plugin-transformation-curry-function.jsimport customParser from './custom-parser';export default function ourBabelPlugin() { return { parserOverride(code, opts) { return customParser.parse(code, opts); }, };} 这样就可以实现修改 babel 源码一样的效果,这也是做框架常用的插件机制。 其次我们要理解如何实现柯里化。柯里化可以通过柯里函数包装后实现: function currying(fn) { const numParamsRequired = fn.length; function curryFactory(params) { return function (...args) { const newParams = params.concat(args); if (newParams.length >= numParamsRequired) { return fn(...newParams); } return curryFactory(newParams); } } return curryFactory([]);}// fromfunction @@ foo(a, b, c) { return a + b + c;}// toconst foo = currying(function foo(a, b, c) { return a + b + c;}) 柯里化函数通过构造参数数量相关的递归,当参数传入不足时返回一个新函数,并持久化之前传入的参数,最后当参数齐全后一次性调用函数。 我们需要做的是,将 @@ foo 解析为 currying() 函数包裹后的新函数。 下面就是我们熟悉的 babel 插件部分了: // babel-plugin-transformation-curry-function.jsexport default function ourBabelPlugin() { return { // ... visitor: { FunctionDeclaration(path) { if (path.get('curry').node) { // const foo = curry(function () { ... }); path.node.curry = false; path.replaceWith( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(path.get('id.name').node), t.callExpression(t.identifier('currying'), [ t.toExpression(path.node), ]) ), ]) ); } }, }, };} FunctionDeclaration 就是 AST 的 visit 钩子,这个钩子在执行到函数时被触发,我们通过 path.get('curry') 拿到 柯里化函数,并利用 replaceWith 将这个函数构造为一个被 currying 函数包裹的新函数。 剩下最后一个问题:currying 函数源码放在哪里。 第一种方式,创建类似 babel-plugin-transformation-curry-function 这样的插件,在 babel 解析时将 currying 函数注册到全局,这是全局思维的方案。 第二种是模块化解决方案,创建一个自定义的 @babel/helpers,注册一个 currying 标识: // packages/babel-helpers/src/helpers.jshelpers.currying = helper("7.6.0")` export default function currying(fn) { const numParamsRequired = fn.length; function curryFactory(params) { return function (...args) { const newParams = params.concat(args); if (newParams.length >= numParamsRequired) { return fn(...newParams); } return curryFactory(newParams); } } return curryFactory([]); }`; 在 visit 函数使用 addHelper 方式拿到 currying: path.replaceWith( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(path.get('id.name').node), t.callExpression(this.addHelper("currying"), [ t.toExpression(path.node), ]) ), ])); 这样在 babel 转换后,就会自动 import helper,并引用 helper 中导出的 currying。 最后原文末尾留下了一些延伸阅读内容,感兴趣的同学可以 点击到原文。 3 精读读完这篇文章,相信你不仅对 babel 插件有了更深刻的认识,而且还掌握了如何为 js 添加新语法这种黑魔法。 我来帮你从 babel 这篇文章总结一些编程模型和知识点,借助 babel 创造自定义语法的实例,加深对它们的理解。 TDDTest-driven development 即测试驱动的开发模式。 从文章的例子可以看出,创造一个新语法,可以先在测试用例先写上这个语法,通过执行测试命令通过报错堆栈一步步解决问题。这种方式开发可以让测试覆盖率更高,目的更专注,更容易保障代码质量。 联想编程联想编程不属于任何编程模型,但从简介的思路来看,作者把 “为 babel 创建一个新 js 语法” 看作一种探案式探索过程,通过错误堆栈和代码阅读,一步一步通过合理联想实现最终目的。 在 AST 那一节,还借助了 Babel AST explorer 工具查看 AST 结构,通过联想到 generator 函数找到类似的 AST 结构,并找到拓展 AST 的突破口。 随着解决问题的不同,联想方式也不同,如果能够举一反三,对不同场景都能合理的联想,才算是具备了技术专家的软素质。 词法、语法分析词法、语法分析属于编译原理的知识,理解词法拆分、递归下降,可以帮助你技术走的更深。 不论是 Babel 插件的使用、还是 Babel 增加自定义 JS 语法,都要具备基本编译原理知识。编译原理知识还能帮助你开发在线编辑器,做智能语法提示等等。 插件机制如下是 babel 自定义 parser 的插件拓展方式: export default function ourBabelPlugin() { return { parserOverride(code, opts) { return customParser.parse(code, opts); }, };} 这只是插件拓展的一种,有申明式,也有命令式;有用 JS 书写的,也有用 JSON 书写的。babel 选择了通过对象方式拓展,是比较适合对 AST 结构统一处理的。 做框架首先要确定接口规范,比如 parser,先按照接口规范实现一套官方解析,对接时按照接口进行对接,就可以自然而然被用户自定义插件替代了。 可以参考的文章: 精读《插件化思维》 柯里化柯里化是面试经常考察的一个知识点,我们能学到的有两点:理解递归、理解如何将函数变成柯里化。 这里再拓展一下,我们还可以想到 JS 尾递归优化。如何快速写一个支持尾递归的函数? const fn = tailCallOptimize(() => { if ( /* xxx */ ) { fn() }}) 通过封装 tailCallOptimize 函数,可以很方便的构造一个支持尾递归的函数,这个函数可以这么写: export function tailCallOptimize<T>(f: T): T { let value: any; let active = false; const accumulated: any[] = []; return function accumulator(this: any) { accumulated.push(arguments); if (!active) { active = true; while (accumulated.length) { value = (f as any).apply(this, accumulated.shift()); } active = false; return value; } };} 感兴趣的读者可以在评论里解释一下这个函数的原理。 AST visit遍历 AST 树常采用的方案是做一个遍历器 visitor,所以在遍历过程中进行拓展常采用 babel 这种方式: return { // ... visitor: { FunctionDeclaration(path) { if (path.get('curry').node) { // const foo = curry(function () { ... }); path.node.curry = false; path.replaceWith( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(path.get('id.name').node), t.callExpression(t.identifier('currying'), [ t.toExpression(path.node), ]) ), ]) ); } }, },}; visitor 下每一个 key 名都是遍历过程中的拓展点,比如上面的例子,我们可以对函数定义位置进行拓展和改写。 内置函数注册babel 提供了两种内置函数注册方式,一种类似 polyfill,在全局注册 window 级的变量,另一种是模块化的方式。 除此之外,可以学习的是 babel 通过 this.addHelper("currying") 这种插件拓展方式,在编译后会自动从 helper 引入对应的模块,前提是 @babel/helper 需要注册 currying 这个 helper。 babel 将编译过程隐藏了起来,通过一些高度封装的函数调用,以较为语义化方式书写插件,这样写出来的代码也容易理解。 4 总结《用 Babel 创造自定义 JS 语法》这篇文章虽然说的是 babel 相关知识,但可以从中提取到许多通用知识,这就是现在还去理解 babel 的原因。 从某个功能点为切面,走一遍框架的完整流程是一种高效的进阶学习方式,如果你也有看到类似这样的文章,欢迎推荐出来。 讨论地址是:精读《用 Babel 创造自定义 JS 语法》 · Issue ##210 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《结合 React 使用原生 Drag Drop API》","path":"/wiki/WebWeekly/前沿技术/《结合 React 使用原生 Drag Drop API》.html","content":"当前期刊数: 140 1 引言拖拽是前端非常常见的交互操作,但显然拖拽是强 DOM 交互的,而 React 绕过了 DOM 这一层,那么基于 React 的拖拽方案就必定值得聊一聊。 结合 How To Use The HTML Drag-And-Drop API In React 这篇文章,让我们谈谈 React 拖拽这些事。 2 概述原文说的比较简单,笔者先快速介绍其中重点部分。 首先拖拽主要的 API 有 4 个:dragEnter dragLeave dragOver drop,分别对应拖入、拖出、正在当前元素范围内拖拽、完成拖入动作。 基于这些 API,我们可以利用 React 实现一个拖入区域: 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,顺便复习一下用法: ...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 增加拖入文件、设置拖入状态即可: 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: 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 当作函数执行以达到效果: 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 可以这么写: const useDnd = ({ componentId }) => { const dragProps = {} const dropProps = {} return { dragProps, dropProps }} 接下来,我们就要分别实现 drag 与 drop 了。 对 drag 来说,只要实现 onDragStart 与 onDragEnd 即可: const dragProps = { onDragStart: ev => { ev.stopPropagation() ev.dataTransfer.setData('componentId', componentId) }, onDragEnd: ev => { // 做一些拖拽结束的清理工作 }} stopPropagation 的作用在原文简介中已经介绍过了,setData 则是通知拖拽方,当前拖拽的组件 id 是什么,这是由于拖拽由 drag 发起而由 drop 响应,因此必须有个数据传输过程,而 dataTransfer 就最适合做这件事。 对于 drop 来说,只要实现 onDragOver 与 onDrop 即可: const dropProps = { onDragOver: ev => { // 做一些样式处理,提示用户此时松手会将元素放置在何处 }, onDrop: ev => { ev.stopPropagation() const componentId = ev.dataTransfer.getData('componentId') // 通过 componentId 修改数据,通过 React Rerender 刷新 UI }} 重点在 onDrop,它是实现拖拽效果的 “真正执行处”,最终通过修改 UI 的方式更新数据。 存在一种场景,一个容器既可以被拖动,也可以被拖入,这种情况一般这个组件是个容器,但这个容器可以被拖入到其他容器中,可以自由嵌套。 实现这种场景的方式就是将 DragContainer 与 DropContainer 作用到一个组件上: const Box = ( <DragContainer> {({ dragProps }) => ( <DropContainer> {({ dropProps }) => { <div {...dragProps} {...dropProps} /> }} </DropContainer> )} </DragContainer>) 之所以能嵌套,在于 HTML5 的 API 允许一个元素同时拥有 onDragStart、onDrop 这两种属性,而上面的语法不过是同时将这两种属性传给组件 DOM。 所以,动手实现一个拖拽库就是这么简单,只要活用 HTML5 的拖拽 API,结合 React 一些特殊语法便够了。 4 总结最后留下一个思考题,许多具有拖拽功能的系统都具备 “拖拽 placeholder” 的功能,即拖拽元素的过程中,在其 “落点” 位置展示一条横线或竖线,引导出松手后元素位置落点,如图所示: 那么这条辅助线是通过什么方式实现的呢?欢迎在评论区留言!如果你有辅助线实现方案解析的文章,欢迎分享,也可以期待笔者未来专门写一篇 “拖拽 placeholder” 实现剖析的精读。 讨论地址是:精读《手写 JSON Parser》 · Issue ##233 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《维护好一个复杂项目》","path":"/wiki/WebWeekly/前沿技术/《维护好一个复杂项目》.html","content":"当前期刊数: 264 现在许多国内互联网公司的项目都持续了五年左右,美国老牌公司如 IBM 的项目甚至持续维护了十五年,然而这些项目却有着截然不同的维护成本,有的公司项目运作几年后维护成本依然与初创期不大,可以保持较为高效的迭代速度,但有的项目甚至改几个文案都会导致线上事故,研发效率变得越来越慢。 根据笔者的经验,尝试总结一些持续维护项目变得难以维护的原因,以及如何设计才能保持良好的可维护性。 精读心态如果不真心对待自己的项目,其实是很难做到良好可维护性的,所以第一点就是需要一个良好的心态。 作为项目管理者,一个项目一旦交给一位同学开发,那么就要完全信任这位同学的能力,因为实际上你已经不可能实质性的影响到开发细节了。有人可能觉得好的流程或者事后 CodeReview 能发现一些问题,但这永远是杯水车薪,比如下面这个例子: 小张接到任务研发透视表,要求这个透视表具有良好的开发体验并做好单测。 那怎么样做单测才算是有效的,如何同时保证开发体验呢?不同人会有不同的想法,也会有不同的结果。 有主人翁心态的小张对于一个有一定经验,又对项目真正上心的小张来说,开发过程可能是这样的。 首先上来先写主要功能,比如考虑数据模型、绘图技术方案后,决定采用图形语法方式定义数据结构,在做了一系列高性能前置考虑后,快速做出来了一个原型,包含表格的渲染、操作、翻页、冻结等等功能。 但随着需求的深入,小张发现做到下钻、排序时,不知道为何影响到了列冻结的功能,而代码架构其实没什么大问题,抽象的也很好,主要就是一些细节的代码调用漏掉了,只要补上就立马打通了任督二脉,整套功能再度行云流水了起来。但不知道下次做树状展示结构时会不会又把之前的功能影响了,这始终是个隐患,于是小张开始思考先把单测加上再继续开发功能。 由于出问题的场景有很小部分是大量操作后偶然引发的,普通的函数式单测也无法保证覆盖的全面,因此小张决定做一个单测录制功能,他首先把对表格的所有操作 Action 化,让一套 json 可以描述所有用户操作,然后又在本地开发界面做了一个单测录制功能,即在页面上对表格功能拖拖拽拽时,就会实时生成这套用户操作 json,再把当时页面结构与内部状态记录下来作为对比依据,单测就还原这套 json 并与基准状态做对比就行了。 小张很快录制了很多原子操作的单测,比如表格的各种空数据状态、单行单列渲染、列冻结行冻结;然后又把一些功能混合的场景结合起来,比如列冻结时排序,翻页后进行下钻;最后又把一些随机复杂的功能组合在一起,形成一些日常容易出问题的特殊单测 case,比如表格单页后突然清空数据,再强制冻结第二列,再灌入3列数据并对第2行做排序,再取消列冻结并翻到第4页。以后每当遇到一个边界 case 时,小张都会把这个问题 case 记录到单测,验证确实运行失败,再进行修复,直到包含这个单测在内的所有单测都验证通过后,才算开发完成。 打工人小张对于为了混口饭吃的小张来说,开发过程可能是这样的。 首先上来写主要功能,把各种表格功能做完后,也遇到了一样的边界 case 难题,此时小张本来想 case by case 修复,但又想到 leader 要求他写单测,觉得倒也不坏,就创建了单测目录。 怎么写单测呢?首先小张把遇到的问题修了,毕竟谁也不希望自己手里的 bug 太多,但至于录到单测就太麻烦了,反正大家也不知道这个 case,修掉了就再也不会出来了吧,那就只把 leader 要求的几个基本功能单测加上去,看下覆盖率也达到硬性指标就行了。 大团队代码总是容易走向混乱假设你是 leader,你不知道自己团队的小张到底是主人翁小张还是打工人小张,企图通过 code review 来统一提升团队的代码质量,实际上可行吗? 如果不幸遇上了打工人小张,他在 code review 时展示的代码结构就不是能做整体单测的抽象,你只能看着单测文件硬提一些比如 “多加一些单测,多考虑一些情况” 的建议,实际上完全达不到主人翁小张做的效果。 这背后的原因是影响代码质量的因素太多,比如 Action 化,比如各种极端 case 的录入,比如全流程的单测形式,这些对代码来说都是质变,但 code review 时看到的代码就是不够抽象,不够 Action 化的,不可能把代码推翻重写一遍,只能在已有代码基础上提优化建议,而到这个时候,神仙也没法让打工人小张的代码优化为主人翁小张的,除非推翻重写。 这就是心态的影响力,能把项目做好的细节很多,而且细节之间还是环环相扣的,比如不把代码 Action 化就不方便做整体单测,但如果开发者打一开始就没想好好设计,code review 时又有多少人能想到这一点呢?想到了此时再提可能也为时太晚,一切都已成定局。 这些年笔者看过不少久经历史的代码,因为大公司有大量的开发者维护同一个项目,每个人开发时的心态都各有不同,会发现总能看到那些模块是用打工人心态做出来的,而你想彻底优化就只能彻底重写,但碍于项目体量太大时间上不允许,只能沿着打工人思路洋洋洒洒的继续写下去。 所以拥有一个良好,正面或者说积极的主人翁心态来写代码,一般来说都可以维护好复杂项目。 解耦复杂项目的复杂指的是什么呢?是指功能多吗?其实不然。 如果仅从功能多就判定这个项目复杂,那我们身处的社会才是最复杂的系统,但社会中的每个玩家都没有觉得吃穿住行很难,核心原因就在于了解我们用到的场景只需要少量的知识,而做出一个行动要得到正确的结果,也不会造成太大的影响。比如出门买菜,只要做个公交车到菜市场,扫一下码就完成了交易,而不需要对背后的城市公交体系与菜市场背后的金融体系有任何深入的了解,你不需要理解公交车是哪儿来的,菜农手里的菜是从哪儿收购的。 但代码世界就很有趣了,在代码世界买个菜可能会导致世界毁灭。这就导致每一个项目开发人员,哪怕是去买个菜,也要受过总统级训练,对各种国家级大事做出正确的预案,为什么会这样呢? 因为代码世界的逻辑是不同开发者码出来的,在实现世界底层逻辑时可能就埋下了耦合的种子,导致你不知道为什么买菜会触发那么严重的事情。举个例子,改一个文案导致系统崩溃,原因可能是某处错误兜底逻辑用字面量判断了这个文案,而你把文案改了,这个判断就失效了。有的程序员挺难的,在这种项目环境下生存,每一步修改都要小心翼翼。 这个问题的解决办法就是解耦,在这里我们不细说具体怎么解耦,因为每个场景的解耦方式都不同。我们只需要理解几乎所有的业务逻辑都可以用解耦的方式做,就行了。只要你按照这样的大思路去设计系统,不论路径是怎样的,最终都能设计出一个漂亮的系统级方案。 比如做一个 BI 系统,看上去里面有各种复杂的模块可能会产生相互影响,比如数据处理、仪表盘搭建、大屏搭建、图表、GIS 地图等,在设计之初就要假装其他模块不存在,来考虑每个模块必要的输入是哪些。 比如布局,它仅仅用于对画布进行布局,为了保证布局系统是完全解耦的,必须让项目支持在无布局的环境下运行。为了做到这一点,就必须让布局真的 “只做布局”,而不存储当前画布结构,这样才不会因为布局系统被移除时,影响组件的联动,因为组件联动需要利用画布结构 API。 图层列表也可以和布局解耦,因为图层列表只关心画布的组件树结构,而不关心布局是如何实现的,所以画布的组件树结构就像生活中的金钱,大家都可以用它交易,而无需关心它流向了何方,被谁使用。 数据逻辑与画布结构无关,只需要关心表达式以及用户对维度度量的配置、聚合方式以及图表本身的特性进行查询 sql 拼接即可,唯一用到的通用资源是当前组件实例信息修改后,需要更新到画布的组件树上。 社会也是建立在这种底层认同上,才能这么解耦的,所以在复杂项目中一定要有一个大家都认可的底层概念,这个概念应该尽可能通用化(想想金钱什么都能买,如果只能买蔬菜就麻烦了)、贯穿整个业务逻辑(金钱是现代社会任何交易都必须的媒介)。 许多项目被诟病难改,往往是没有遵循这条逻辑,硬生生把可以不相关的概念耦合了。比如某个筛选器条件变化时,对某个组件做特殊操作,这个场景可以控制反转为,这个组件在接收到某些筛选条件时,自己做特定的操作。因为对 BI 系统来说,筛选器的输出要作为图表绘图的输入,在这个底层框架下,就不要再开辟一条筛选器关心到具体图表的逻辑了。 总结维护好一个复杂项目很难,这次分享了两个实践中有用的方案,第一个抱有主人翁心态设计代码,要在设计之初就做好考量,不要寄希望于对没有好好设计的系统做缝缝补补。第二是深入理解为什么现代社会的运作巧妙之处,尽可能把代码架构组织一定程度映射到社会的运作机制上,目前来看,社会最适合代码借鉴的思路就是解耦,再利用庞大的分工协作网络完成单人无法完成的工作。 讨论地址是:精读《维护好一个复杂项目》· Issue ##454 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《编写有弹性的组件》","path":"/wiki/WebWeekly/前沿技术/《编写有弹性的组件》.html","content":"当前期刊数: 97 1. 引言读了 精读《useEffect 完全指南》 之后,是不是对 Function Component 的理解又加深了一些呢? 这次通过 Writing Resilient Components 一文,了解一下什么是有弹性的组件,以及为什么 Function Component 可以做到这一点。 2. 概述相比代码的 Lint 或者 Prettier,或许我们更应该关注代码是否具有弹性。 Dan 总结了弹性组件具有的四个特征: 不要阻塞数据流。 时刻准备好渲染。 不要有单例组件。 隔离本地状态。 以上规则不仅适用于 React,它适用于所有 UI 组件。 不要阻塞渲染的数据流不阻塞数据流的意思,就是 不要将接收到的参数本地化, 或者 使组件完全受控。 在 Class Component 语法下,由于有生命周期的概念,在某个生命周期将 props 存储到 state 的方式屡见不鲜。 然而一旦将 props 固化到 state,组件就不受控了: class Button extends React.Component { state = { color: this.props.color }; render() { const { color } = this.state; // 🔴 `color` is stale! return <button className={"Button-" + color}>{this.props.children}</button>; }} 当组件再次刷新时,props.color 变化了,但 state.color 不会变,这种情况就阻塞了数据流,小伙伴们可能会吐槽组件有 BUG。这时候如果你尝试通过其他生命周期(componentWillReceiveProps 或 componentDidUpdate)去修复,代码会变得难以管理。 然而 Function Component 没有生命周期的概念,**所以没有必须要将 props 存储到 state**,直接渲染即可: function Button({ color, children }) { return ( // ✅ `color` is always fresh! <button className={"Button-" + color}>{children}</button> );} 如果需要对 props 进行加工,可以利用 useMemo 对加工过程进行缓存,仅当依赖变化时才重新执行: const textColor = useMemo( () => slowlyCalculateTextColor(color), [color] // ✅ Don’t recalculate until `color` changes); 不要阻塞副作用的数据流发请求就是一种副作用,如果在一个组件内发请求,那么在取数参数变化时,最好能重新取数。 class SearchResults extends React.Component { state = { data: null }; componentDidMount() { this.fetchResults(); } componentDidUpdate(prevProps) { if (prevProps.query !== this.props.query) { // ✅ Refetch on change this.fetchResults(); } } fetchResults() { const url = this.getFetchUrl(); // Do the fetching... } getFetchUrl() { return "http://myapi/results?query" + this.props.query; // ✅ Updates are handled } render() { // ... }} 如果用 Class Component 的方式实现,我们需要将请求函数 getFetchUrl 抽出来,并且在 componentDidMount 与 componentDidUpdate 时同时调用它,还要注意 componentDidUpdate 时如果取数参数 state.query 没有变化则不执行 getFetchUrl。 这样的维护体验很糟糕,如果取数参数增加了 state.currentPage,你很可能在 componentDidUpdate 中漏掉对 state.currentPage 的判断。 如果使用 Function Component,可以通过 useCallback 将整个取数过程作为一个整体: 原文没有使用 useCallback,笔者进行了加工。 function SearchResults({ query }) { const [data, setData] = useState(null); const [currentPage, setCurrentPage] = useState(0); const getFetchUrl = useCallback(() => { return "http://myapi/results?query=" + query + "&page=" + currentPage; }, [currentPage, query]); useEffect(() => { const url = getFetchUrl(); // Do the fetching... }, [getFetchUrl]); // ✅ Refetch on change // ...} Function Component 对 props 与 state 的数据都一视同仁,且可以将取数逻辑与 “更新判断” 通过 useCallback 完全封装在一个函数内,再将这个函数作为整体依赖项添加到 useEffect,如果未来再新增一个参数,只要修改 getFetchUrl 这个函数即可,而且还可以通过 eslint-plugin-react-hooks 插件静态分析是否遗漏了依赖项。 Function Component 不但将依赖项聚合起来,还解决了 Class Component 分散在多处生命周期的函数判断,引发的无法静态分析依赖的问题。 不要因为性能优化而阻塞数据流相比 PureComponent 与 React.memo,手动进行比较优化是不太安全的,比如你可能会忘记对函数进行对比: class Button extends React.Component { shouldComponentUpdate(prevProps) { // 🔴 Doesn't compare this.props.onClick return this.props.color !== prevProps.color; } render() { const onClick = this.props.onClick; // 🔴 Doesn't reflect updates const textColor = slowlyCalculateTextColor(this.props.color); return ( <button onClick={onClick} className={"Button-" + this.props.color + " Button-text-" + textColor} > {this.props.children} </button> ); }} 上面的代码手动进行了 shouldComponentUpdate 对比优化,但是忽略了对函数参数 onClick 的对比,因此虽然大部分时间 onClick 确实没有变化,因此代码也不会有什么 bug: class MyForm extends React.Component { handleClick = () => { // ✅ Always the same function // Do something }; render() { return ( <> <h1>Hello!</h1> <Button color="green" onClick={this.handleClick}> Press me </Button> </> ); }} 但是一旦换一种方式实现 onClick,情况就不一样了,比如下面两种情况: class MyForm extends React.Component { state = { isEnabled: true }; handleClick = () => { this.setState({ isEnabled: false }); // Do something }; render() { return ( <> <h1>Hello!</h1> <Button color="green" onClick={ // 🔴 Button ignores updates to the onClick prop this.state.isEnabled ? this.handleClick : null } > Press me </Button> </> ); }} onClick 随机在 null 与 this.handleClick 之间切换。 drafts.map(draft => ( <Button color="blue" key={draft.id} onClick={ // 🔴 Button ignores updates to the onClick prop this.handlePublish.bind(this, draft.content) } > Publish </Button>)); 如果 draft.content 变化了,则 onClick 函数变化。 也就是如果子组件进行手动优化时,如果漏了对函数的对比,很有可能执行到旧的函数导致错误的逻辑。 所以尽量不要自己进行优化,同时在 Function Component 环境下,在内部申明的函数每次都有不同的引用,因此便于发现逻辑 BUG,同时利用 useCallback 与 useContext 有助于解决这个问题。 时刻准备渲染确保你的组件可以随时重渲染,且不会导致内部状态管理出现 BUG。 要做到这一点其实挺难的,比如一个复杂组件,如果接收了一个状态作为起点,之后的代码基于这个起点派生了许多内部状态,某个时刻改变了这个起始值,组件还能正常运行吗? 比如下面的代码: // 🤔 Should prevent unnecessary re-renders... right?class TextInput extends React.PureComponent { state = { value: "" }; // 🔴 Resets local state on every parent render componentWillReceiveProps(nextProps) { this.setState({ value: nextProps.value }); } handleChange = e => { this.setState({ value: e.target.value }); }; render() { return <input value={this.state.value} onChange={this.handleChange} />; }} componentWillReceiveProps 标识了每次组件接收到新的 props,都会将 props.value 同步到 state.value。这就是一种派生 state,虽然看上去可以做到优雅承接 props 的变化,但 父元素因为其他原因的 rerender 就会导致 state.value 非正常重置,比如父元素的 forceUpdate。 当然可以通过 不要阻塞渲染的数据流 一节所说的方式,比如 PureComponent, shouldComponentUpdate, React.memo 来做性能优化(当 props.value 没有变化时就不会重置 state.value),但这样的代码依然是脆弱的。 健壮的代码不会因为删除了某项优化就出现 BUG,不要使用派生 state 就能避免此问题。 笔者补充:解决这个问题的方式是,1. 如果组件依赖了 props.value,就不需要使用 state.value,完全做成 受控组件。2. 如果必须有 state.value,那就做成内部状态,也就是不要从外部接收 props.value。总之避免写 “介于受控与非受控之间的组件”。 补充一下,如果做成了非受控组件,却想重置初始值,那么在父级调用处加上 key 来解决: <EmailInput defaultEmail={this.props.user.email} key={this.props.user.id} /> 另外也可以通过 ref 解决,让子元素提供一个 reset 函数,不过不推荐使用 ref。 不要有单例组件一个有弹性的应用,应该能通过下面考验: ReactDOM.render( <> <MyApp /> <MyApp /> </>, document.getElementById("root")); 将整个应用渲染两遍,看看是否能各自正确运作? 除了组件本地状态由本地维护外,具有弹性的组件不应该因为其他实例调用了某些函数,而 “永远错过了某些状态或功能”。 笔者补充:一个危险的组件一般是这么思考的:没有人会随意破坏数据流,因此只要在 didMount 与 unMount 时做好数据初始化和销毁就行了。 那么当另一个实例进行销毁操作时,可能会破坏这个实例的中间状态。一个具有弹性的组件应该能 随时响应 状态的变化,没有生命周期概念的 Function Component 处理起来显然更得心应手。 隔离本地状态很多时候难以判断数据属于组件的本地状态还是全局状态。 文章提供了一个判断方法:“想象这个组件同时渲染了两个实例,这个数据会同时影响这两个实例吗?如果答案是 不会,那这个数据就适合作为本地状态”。 尤其在写业务组件时,容易将业务数据与组件本身状态数据混淆。 根据笔者的经验,从上层业务到底层通用组件之间,本地状态数量是递增的: 业务 -> 全局数据流 -> 页面(完全依赖全局数据流,几乎没有自己的状态) -> 业务组件(从页面或全局数据流继承数据,很少有自己状态) -> 通用组件(完全受控,比如 input;或大量内聚状态的复杂通用逻辑,比如 monaco-editor) 3. 精读再次强调,一个有弹性的组件需要同时满足下面 4 个原则: 不要阻塞数据流。 时刻准备好渲染。 不要有单例组件。 隔离本地状态。 想要遵循这些规则看上去也不难,但实践过程中会遇到不少问题,笔者举几个例子。 频繁传递回调函数Function Component 会导致组件粒度拆分的比较细,在提高可维护性同时,也会导致全局 state 成为过去,下面的代码可能让你觉得别扭: const App = memo(function App() { const [count, setCount] = useState(0); const [name, setName] = useState("nick"); return ( <> <Count count={count} setCount={setCount}/> <Name name={name} setName={setName}/> </> );});const Count = memo(function Count(props) { return ( <input value={props.count} onChange={pipeEvent(props.setCount)}> );});const Name = memo(function Name(props) { return ( <input value={props.name} onChange={pipeEvent(props.setName)}> );}); 虽然将子组件 Count 与 Name 拆分出来,逻辑更加解耦,但子组件需要更新父组件的状态就变得麻烦,我们不希望将函数作为参数透传给子组件。 一种办法是将函数通过 Context 传给子组件: const SetCount = createContext(null)const SetName = createContext(null)const App = memo(function App() { const [count, setCount] = useState(0); const [name, setName] = useState("nick"); return ( <SetCount.Provider value={setCount}> <SetName.Provider value={setName}> <Count count={count}/> <Name name={name}/> </SetName.Provider> </SetCount.Provider> );});const Count = memo(function Count(props) { const setCount = useContext(SetCount) return ( <input value={props.count} onChange={pipeEvent(setCount)}> );});const Name = memo(function Name(props) { const setName = useContext(SetName) return ( <input value={props.name} onChange={pipeEvent(setName)}> );}); 但这样会导致 Provider 过于臃肿,因此建议部分组件使用 useReducer 替代 useState,将函数合并到 dispatch: const AppDispatch = createContext(null)class State = { count = 0 name = 'nick'}function appReducer(state, action) { switch(action.type) { case 'setCount': return { ...state, count: action.value } case 'setName': return { ...state, name: action.value } default: return state }}const App = memo(function App() { const [state, dispatch] = useReducer(appReducer, new State()) return ( <AppDispatch.Provider value={dispatch}> <Count count={count}/> <Name name={name}/> </AppDispatch.Provider> );});const Count = memo(function Count(props) { const dispatch = useContext(AppDispatch) return ( <input value={props.count} onChange={pipeEvent(value => dispatch({type: 'setCount', value}))}> );});const Name = memo(function Name(props) { const dispatch = useContext(AppDispatch) return ( <input value={props.name} onChange={pipeEvent(pipeEvent(value => dispatch({type: 'setName', value})))}> );}); 将状态聚合到 reducer 中,这样一个 ContextProvider 就能解决所有数据处理问题了。 memo 包裹的组件类似 PureComponent 效果。 useCallback 参数变化频繁在 精读《useEffect 完全指南》 我们介绍了利用 useCallback 创建一个 Immutable 的函数: function Form() { const [text, updateText] = useState(""); const handleSubmit = useCallback(() => { const currentText = text; alert(currentText); }, [text]); return ( <> <input value={text} onChange={e => updateText(e.target.value)} /> <ExpensiveTree onSubmit={handleSubmit} /> </> );} 但这个函数的依赖 [text] 变化过于频繁,以至于在每个 render 都会重新生成 handleSubmit 函数,对性能有一定影响。一种解决办法是利用 Ref 规避这个问题: function Form() { const [text, updateText] = useState(""); const textRef = useRef(); useEffect(() => { textRef.current = text; // Write it to the ref }); const handleSubmit = useCallback(() => { const currentText = textRef.current; // Read it from the ref alert(currentText); }, [textRef]); // Don't recreate handleSubmit like [text] would do return ( <> <input value={text} onChange={e => updateText(e.target.value)} /> <ExpensiveTree onSubmit={handleSubmit} /> </> );} 当然,也可以将这个过程封装为一个自定义 Hooks,让代码稍微好看些: function Form() { const [text, updateText] = useState(""); // Will be memoized even if `text` changes: const handleSubmit = useEventCallback(() => { alert(text); }, [text]); return ( <> <input value={text} onChange={e => updateText(e.target.value)} /> <ExpensiveTree onSubmit={handleSubmit} /> </> );}function useEventCallback(fn, dependencies) { const ref = useRef(() => { throw new Error("Cannot call an event handler while rendering."); }); useEffect(() => { ref.current = fn; }, [fn, ...dependencies]); return useCallback(() => { const fn = ref.current; return fn(); }, [ref]);} 不过这种方案并不优雅,React 考虑提供一个更优雅的方案。 有可能被滥用的 useReducer在 精读《useEffect 完全指南》 “将更新与动作解耦” 一节里提到了,利用 useReducer 解决 “函数同时依赖多个外部变量的问题”。 一般情况下,我们会这么使用 useReducer: const reducer = (state, action) => { switch (action.type) { case "increment": return { value: state.value + 1 }; case "decrement": return { value: state.value - 1 }; case "incrementAmount": return { value: state.value + action.amount }; default: throw new Error(); }};const [state, dispatch] = useReducer(reducer, { value: 0 }); 但其实 useReducer 对 state 与 action 的定义可以很随意,因此我们可以利用 useReducer 打造一个 useState。 比如我们创建一个拥有复数 key 的 useState: const [state, setState] = useState({ count: 0, name: "nick" });// 修改 countsetState(state => ({ ...state, count: 1 }));// 修改 namesetState(state => ({ ...state, name: "jack" })); 利用 useReducer 实现相似的功能: function reducer(state, action) { return action(state);}const [state, dispatch] = useReducer(reducer, { count: 0, name: "nick" });// 修改 countdispatch(state => ({ ...state, count: 1 }));// 修改 namedispatch(state => ({ ...state, name: "jack" })); 因此针对如上情况,我们可能滥用了 useReducer,建议直接用 useState 代替。 4. 总结本文总结了具有弹性的组件的四个特性:不要阻塞数据流、时刻准备好渲染、不要有单例组件、隔离本地状态。 这个约定对代码质量很重要,而且难以通过 lint 规则或简单肉眼观察加以识别,因此推广起来还是有不小难度。 总的来说,Function Component 带来了更优雅的代码体验,但是对团队协作的要求也更高了。 讨论地址是:精读《编写有弹性的组件》 · Issue ##139 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 special Sponsors DevOps 全流程平台 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《衡量用户体验》","path":"/wiki/WebWeekly/前沿技术/《衡量用户体验》.html","content":"当前期刊数: 68 衡量用户体验已经不是一个新话题。最近也关注了一些话题来写写这方面的感受。 前言从某种意义上说,定性反馈是 UX 设计师最常用的武器。今天从诸多交互出版物或文章中看到,Metrics-Driven Design 概念的发展和越来越受重视的趋势。但两者取一都是片面的,倾向任何一者都会出现问题,追求定性总是在小样本下。定量分析总是忽视用户的反馈,以为数据就可以说明问题。充其量只是达到局部高点。一位 UX 设计师称之为 tunnel vision。 像 Booking 网站给出了『洞察数据,情感驱动』作为他们的设计准则。这里总是听上去像是在讲交互视觉范畴,而用户体验是不是指交互视觉呢。我觉得体验度量是不是就是度量交互视觉呢。 用户体验 产品用户体验不仅是指交互视觉,我的理解用户体验反映用户与产品从认知,使用到传播整个情感的连接和反馈。能力上包括了产品设计与功能实现,用户交互界面,以及系统承载能力。 上图是 CUBI Mobel:CUBI UX - User Experience Model。完整地说明了用户体验从内容、商业目标、交互、用户目标四个方面组合。 以初创产品为例,以流量增长为主要目的,这时用户交互与系统能力并不一定是重点。对于上面这张图而言,背后隐含着动态的权重,这个权重来自于产品的发展阶段目标。 以成熟产品为例,流量增长的红利已经减弱或消失,这时精细化运营就非常重要,这时用户交互能力和系统能力就与产品目标一样重要。这时的权重就有所改变。 体验度量现代数据分析建立的背景是海量,全量数据。然而产品根据受众的不同,有 To C,To B,To D,在 To C 中不同产品的消费者群体又有细分,但总体来说面向消费者的产品一定需要流量。现代 To C 分析思路是在上述也讲到,是从流量分析 -> 细分人群的消费者洞察 -> 经营链路的全域分析过程,已经有非常多的商业分析思路在这几年沉淀,如 AARRR。在体验度量研究上,有 Google 的 HEART 模型。 但 To B 或 To D,使用的人群和频率远远比 To C 低。从一开始就是从专业领域沉淀和积累的过程而成就,往往依靠产品经理的经验与行业习惯就满足了,我们经常看到对这类产品我们不会以『美观』来强调,而以『清晰』来强调。反馈这样产品的体验,定性分析起到决定性的作用。可以说,好的设计工作由定量与定性的信息一起来解答问题。 比如,我们通过对设计一组反馈问题来统计用户对产品的满意度,其中 Bayes’ Probability 在小样本中可以起到很好的作用,又回归到定量分析中。 再比如,对 To B 或 To D 研究用抽样用户行为作单体分析也是一种行之有效的方法。推荐一定志愿者,在特定的数据采集中,持续跟踪志愿者的行为,细化收集几个关键指标:如平均完成一次关键路径的时长、关键路径完成效率等数据来研究产品可用性等作为体验 KPI 来衡量。 此外,AB 测试在这种场景下,也会是一种较为常用的方法,通过研究关键路径的转化来看更优方案,并能够智能化的切换不同分层样本,得到更精准的分析结果。 综上,不同定位的产品,面向的市场和人员所定义的度量方法更是不同的。 我试着列了几类 产品商业阶段性目标和最终目标。营收提高,优化结构,工程效率提高,质量提高,能力覆盖。 工程研发过程及能力。投入产出比,系统成本,系统稳定性,创新性。 用户可用性。满意度,过程效率,过程质量。 总结产品数据分析对我们来说很多关注点主要在数据上,有明确行动指引或明确的智能化方案。而用户体验分析是一个更广泛的概念,从刚开始说的这张图开始,它包括了商业,也包含产品其它方面。 我们做互联网产品,往往以商业目标为导向。但一个产品发展长远,是立体来看的,必然包含了技术的创新,交互视觉的创新。技术能力对于用户目标,交互与内容的帮助是非常大的,我们对于技术的期待并不只在性能上,稳定性上。也应该在专业,沉浸,丰富这样的关键词上来衡量可预见的未来。 这是一个很大的话题,我只是管中窥豹,希望有更多人跳出思维圈,利用数据,不局限于数据。"},{"title":"《自由 + 磁贴混合布局》","path":"/wiki/WebWeekly/前沿技术/《自由 + 磁贴混合布局》.html","content":"当前期刊数: 281 本篇精读来自笔者代码实践,没有原文出处请谅解。 早些我们介绍过了 磁贴布局 - 功能分析 与实现,现在我们来做一个更进一步的思考,如何让磁贴布局与自由布局混合实现? 让磁贴布局与自由布局混合实现,从效果来看就是让画布同时存在磁贴与自由布局两种布局状态的组件,并且可以随时切换。接下来我们分析实现该方案的技术要点。 磁贴与自由布局的差异磁贴布局与自由布局在交互上有很多差异,比如: 磁贴布局不能重叠,自由布局可以重叠。 磁贴布局可以向上方吸引,自由布局不会被吸引。 磁贴布局不存在自动吸附概念,但自由布局可以支持对齐,吸附等功能。 这些交互时差异都容易在运行时分开处理弥补,真正需要从顶层设计的是 单位的差异。 自由布局因为位置固定,所以一般以像素描述位置;磁贴布局因为宽高是按照比例来的,往往以不带单位的 {w:1, h:2} 等相对数字描述位置,在渲染时再根据当前视窗大小缩放。 但在磁贴与自由混合的情况下,一个组件的布局选择磁贴还是自由可以由父容器来决定,或者自身来决定,这就引发了一个挑战: 一个组件的状态可能随时被切换到磁贴或自由,同时混用两种单位论上也可以实现,但计算成本比较高,所以最好采用一种单位来存储与计算,那么 同时适配磁贴与自由的单位就是像素。 用像素实现磁贴布局因为自由布局使用像素计算非常容易,所以我们只讲磁贴布局下如何用像素计算。 像素模式下所有磁贴组件的位置、大小都是像素: { "layoutMode": "grid", "x": 100, "y": 100, "width": 150, "height": 150} 如上所示,磁贴模式的组件与自由布局组件的差异仅在 layoutMode 值的区别,位置描述是完全一样的。 为了让磁贴布局组件可以适配屏幕大小缩放,需要存储画布根节点宽度 rootWidth,比如宽度为 150 的组件是在画布 rootWidth 为 1000 时保存下来的,那么在画布宽度为 2000 的屏幕尺寸打开时,组件宽度就要放大到 300. 自由布局对齐磁贴布局自由布局在大部分情况下是无法对齐磁贴布局的,因为即便我们将这两种布局的位置统一使用像素描述,但磁贴布局还是免不了会在不同尺寸的屏幕间缩放,也就是磁贴布局组件的位置是不固定的,而自由布局组件的位置是固定的,所以自由布局组件某条边对齐了磁贴布局的组件,也只在当前画布宽度下生效,一旦换一个尺寸屏幕就会产生偏移。 一种维持自由与磁贴组件相对位置的办法是 “整体随访”,即画布中所有组件位置都按照画布大小缩放,实现该方案有两种技术路线: scale 画布整体缩放。 仅位置、宽高的缩放。 第一种缩放方式会同时缩放组件内字体、图表等元素的大小,而第二种方案不会,我们可以根据实际场景灵活选择来实现,但两种方式都可以达到自由布局与磁贴布局稳定对齐的效果。 总结自由与磁贴混合布局模式下,还有更多值得我们思考的地方,比如: 是否允许磁贴布局与自由布局的组件产生碰撞。 怎么设计才能在同时多选了磁贴与自由布局组件时,批量拖动。 磁贴布局组件在拖入更小的容器时,宽度按照画布尺寸缩放,还是按照该容器尺寸缩放。 自由布局成组模式下,组内组件如何支持磁贴布局。 甚至,能否将浏览器最早支持的流式布局模式一起加入混合?混合布局模式还有很多值得深入思考的地方。 讨论地址是:精读《自由 + 磁贴混合布局》· Issue ##488 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《自由布局吸附线的实现》","path":"/wiki/WebWeekly/前沿技术/《自由布局吸附线的实现》.html","content":"当前期刊数: 282 本篇精读来自笔者代码实践,没有原文出处请谅解。 自由布局吸附线的效果如下图所示: 那么如何实现吸附线呢?我们先归纳一下吸附线的特征: 正在拖动的 box 与其他 box 在水平或垂直位置距离接近时,会显示对齐线。 当吸附作用产生时,鼠标在一定范围内移动都不会改变组件位置,这样鼠标对齐就产生了一定的容错性,用户不需要一像素一像素的调整位置。 当鼠标拖动的足够远时,吸附作用消失,此时 box 跟手移动。 根据这些规则,我们首先要实现的就是判断当前拖动 box 与哪些组件的边足够接近。 判断 box 离哪条边最近距离最近的边可能不止一条,水平与垂直位置要分别判断。我们以水平位置为例,垂直同理。 拖动 box 在水平位置可能有 上、中、下 三条边可以产生吸附,而其他 box 同样也有 上、中、下 三条边可以与之产生交互,因此对于每一个目标 box,我们需要计算 9 个距离: source 上 vs target 上 source 上 vs target 中 source 上 vs target 下 source 中 vs target 上 source 中 vs target 中 source 中 vs target 下 source 下 vs target 上 source 下 vs target 中 source 下 vs target 下 因为 source 的每条边最多只能出现一条吸附线,所以按照 source 聚合一下每条边的最近 target 边: source 上 vs min(target 上、中、下) = min 上 source 中 vs min(target 上、中、下) = min 中 source 下 vs min(target 上、中、下) = min 下 可以想象,当 source 与 target box 完全一样大时,最多产生三条吸附线(上 vs 上,中 vs 中,下 vs 下)。但一旦 box 高度不同,结果就不一样了,所以我们还需要计算 source 上、中、下 最接近的距离是多少: source 所有位置最小距离 = min(min 上、min 中、min 下) 然后按照 source 所有位置最小距离筛选 min 上、min 中、min 下,留下来的就是要 source 距离 target 水平位置最近的吸附线。 我们还需要设置吸附阈值,否则所有鼠标位置都会产生吸附。所以当 source 所有位置最小距离大于吸附阈值时,就不产生吸附效果了。 产生吸附效果吸附的实现方式与拖拽的实现方式有关。 假设拖拽的实现方式是:dragStart 时记录鼠标的起始位置 mouseStartX(Y 同理),在 drag 时产生了位移 movementX,那么组件当前位置就是 mouseStartX + movementX。 如果我们可以拿到吸附产生的反向位移 snapX,那么组件位置就可以实现为: mouseStartX + movementX + snapX 可以想象当鼠标从上往下移动时,当产生吸附时,snapX 会产生反向作用抵消 box 的向下位移,从而保证 box 在吸附时在垂直方向没有产生移动,这样吸附效果就实现了。 snapX 的值如何计算呢?其实就是上一步的 “source 所有位置最小距离” 取反。 resize 时中间对齐线需要放大双倍吸附力resize 与 drag 不同,设想鼠标拖动 box 的下方边缘向下做 resize,此时除了组件移动外,还产生了组件高度变高的效果,那么从上、中、下三段观察 box,其位置与鼠标位移的变化关系是: 上:位置不变。 中:位置向下位移为鼠标位移 * 0.5 下:位置向下位移为鼠标位移 * 1 因此如果中间位置产生了吸附线,为了抵消鼠标向下移动,需要产生两倍的 snap 反向位移: mouseStartX + movementX + snapX * 2 总结我们梳理了吸附的判断条件与吸附作用如何生效,以及 resize 时中间线吸附的特殊处理逻辑。 自由布局除了吸附之外,还有哪些边界的交互,如何实现呢?希望大家思考与留言。 讨论地址是:精读《自由布局吸附线的实现》· Issue ##490 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《请停止 css-in-js 的行为》","path":"/wiki/WebWeekly/前沿技术/《请停止 css-in-js 的行为》.html","content":"当前期刊数: 7 本周精读文章:请停止 css-in-js 的行为 1 引言 这篇文章表面是在讲 CSS in JS,实际上是 CSS Modules 支持者与 styled-components 拥趸之间的唇枪舌剑、你来我往。从 2014 年 Vjeux 的演讲开始,css-in-js 的轮子层出不穷。终于过了三年,鸡血时期已经慢慢过去,大家开始冷静思考了。 2 内容概要styled-componentsstyled-components 利用 ES6 的 tagged template 语法创建 react 纯样式组件。消除了人肉在 dom 和 css 之间做映射和切换的痛苦,并且有大部分编辑器插件的大力支持(语法高亮等)。此外,styled-components 在 ReactNaive 中尤其适用。 styled-components 简单易学,引用官方源码: import React from 'react';import styled from 'styled-components';const Title = styled.h1` font-size: 1.5em; text-align: center; color: palevioletred;`;<Title> Hello World, this is my first styled component!</Title> css-modules顾名思义,css-modules 将 css 代码模块化,可以很方便的避免本模块样式被污染。并且可以很方便的复用 css 代码。 // 全局变量:global(.className) { background-color: blue;}// 本地变量,其它模块无法污染.className { background-color: blue;}.title { // 复用 className 类的样式 composes: className; color: red;} react-css-modules值得一提的是,文章的作者也是 react-css-modules 的作者。 react-css-modules 代码示例: import React from 'react';import CSSModules from 'react-css-modules';import styles from './table.css';class Table extends React.Component { render () { return <div styleName='table'> <div styleName='row'> <div styleName='cell'>A0</div> <div styleName='cell'>B0</div> </div> </div>; }}export default CSSModules(Table, styles); react-css-modules 引入了 styleName,将本地变量和全局变量很清晰的分开。并且也避免了每次对 styles 对象的引用,本地 className 名也不用总是写成 camelCase。 另外,使用 react-css-modules,可以方便的覆盖本地变量的样式: import customStyles from './table-custom-styles.css';<Table styles={customStyles} />; 文章内容3 精读参与本次精读的同学有 黄子毅,杨森 和 camsong。该部分由他们的观点总结而出。 CSS 本身有不少缺陷,如书写繁琐(不支持嵌套)、样式易冲突(没有作用域概念)、缺少变量(不便于一键换主题)等不一而足。为了解决这些问题,社区里的解决方案也是出了一茬又一茬,从最早的 CSS prepocessor(SASS、LESS、Stylus)到后来的后起之秀 PostCSS,再到 CSS Modules、Styled-Components 等。更有甚者,有人维护了一份完整的 CSS in JS 技术方案的对比。截至目前,已有 49 种之多。 Styled-components 优缺点优点使用成本低如果是要做一个组件库,让使用方拿着 npm 就能直接用,样式全部自己搞定,不需要依赖其它组件,如 react-dnd 这种,比较适合。 更适合跨平台适用于 react-native 这类本身就没有 css 的运行环境。 缺陷缺乏扩展性样式就像小孩的脸,说变就变。比如是最简单的 button,可能在用的时候由于场景不同,就需要设置不同的 font-size,height,width,border 等等,如果全部使用 css-in-js 那将需要把每个样式都变成 props,如果这个组件的 dom 还有多层级呢?你是无法把所有样式都添加到 props 中。同时也不能全部设置成变量,那就丧失了单独定制某个组件的能力。css-in-js 生成的 className 通常是不稳定的随机串,这就给外部想灵活覆盖样式增加了困难。 css-modules 优缺点优点1、CSS Modules 可以有效避免全局污染和样式冲突,能最大化地结合现有 CSS 生态和 JS 模块化能力 2、与 SCSS 对比,可以避免 className 的层级嵌套,只使用一个 className 就能把所有样式定义好。 缺点:1、与组件库难以配合 2、会带来一些使用成本,本地样式覆盖困难,写到最后可能一直在用 :global。 关于 scss/less无论是 sass 还是 less 都有一套自己的语法,postcss 更支持了自定义语法,自创的语法最大特点就是雷同,格式又不一致,增加了无意义的学习成本。我们更希望去学习和使用万变不离其宗的东西,而不愿意使用各种定制的“语法糖”来“提高效率”。 就 css 变量与 js 通信而言,虽然草案已经考虑到了这一点,通过表达式与 attribute 通信,使用 js 与 attribute 同步。不难想象,这种情况维护的变量值最终是存储在 js 中更加妥当,然而 scss 给大家带来的 css first 思想根深蒂固,导致许多基础库的变量完全存储在 _variable.scss 文件中,现在无论是想适应 css 的新特性,还使用 css-in-js 都有巨大的成本,导致项目几乎无法迁移。反过来,如果变量存储在 js 中,就像草案中说的一样轻巧,你只要换一种方式实现 css 就行了。 总结在众多解决方案中,没有绝对的优劣。还是要结合自己的场景来决定。 我们团队在使用过 scss 和 css modules 后,仍然又重新选择了使用 scss。css modules 虽然有效解决了样式冲突的问题,但是带来的使用成本也很大。尤其是在写动画(keyframe)的时候,语法尤其奇怪,总是出错,难以调试。并且我们团队在开发时,因为大家书写规范,也从来没有碰到过样式冲突的问题。 Styled-components 笔者未曾使用过,但它消除人肉在 dom 和 css 之间做映射的优点,非常吸引我。而对于样式扩展的问题,其实也有比较优雅的方式。 const CustomedButton = styled(Button)` color: customedColor;`; 如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。"},{"title":"《设计完美的日期选择器》","path":"/wiki/WebWeekly/前沿技术/《设计完美的日期选择器》.html","content":"当前期刊数: 18 1. 摘要日期选择器作为基础组件重要不可或缺的一员,大家已经快习惯它一成不变的样子,输入框+日期选择弹出层。但到业务中,这种墨守成规的样子真的能百分百契合业务需求吗。这篇文章从多个网站的日期选择场景出发,企图归纳出日期选择器的最佳实践。这篇文章对移动端的日期选择暂无涉猎,都是 PC 端,列举出通用场景,每个类型日期选择器需要考虑的设计。文章链接:Designing The Perfect Date And Time Picker感谢本期评论官 @黄子毅 @流形 @王亮 @赵阳 @不知名的花瓣工程师 2. 设计原则2.1 通用设计1)明确需求,是实现日期选择、日期区间选择、时间选择 2)用户选中日期后是否需要自动触发下一步?尤其是在某些固定业务流程中 3)日期选择器是否是最佳的日期选择方法?如果提供预定义的日期选择按钮是不是更快呢? 4)如何避免展示不可用日期? 5)是否需要根据上下文自动定位? 适用于生日选择场景。 2.2 输入框设计1)用户是否可以自定义输入日期,还是只能通过点击选择程序给出的日期?有时候直接输入的效率明显高于点击选择,在很多银行流水查询的场景中就提供自定义输入。 2)用户自定义输入如何保证日期格式正确性? 3)是否需要提供预设场景输入? 比如昨天,三天前,七天前,30 天前?像很多数据分析场景,分析师会关注数据周期,比如流量的周环比,月环比,年环比。 4)是否需要包含默认值?如果有默认,应该是什么?像 google flight 根据用户历史数据提供默认值,临近节假日默认填充节假日。同时像有些数据场景,数据存在延迟,需要默认提供 T-1/T-2 ,避免用户选择当天。 5)当用户激活输入框时,是否保留默认值? 6)是否提供重置按钮? 7)是否提供『前一项』『现在』『后一项』导航?这个设计点我第一次看到,专门附图说明。 2.3 日期弹出层设计1)理想状态下,任何日期选择都应该在三步之内完成 2)日期选择弹出层的触发方式? 是点输入框就还是点日期小图标? 3)默认情况下,展示多少周、月、天? 4)周的定义是周一到周日 还是 周日到周六? 5)如何提示当前时间和当前时间? 6)是否需要提供『前一项』『现在』『后一项』导航?如果提供,选择天、月、年的场景下如何展示? 7)提示用户最关心的信息,比如 价格、公共假期,可采用背景色、点标记 8)是否用户点击非弹出层自动关闭弹出层?是否需要提供关闭按钮? 9)是否可以不和输入框联动? 10)用户可以重置选中的日期吗? 2.4 日期区间设计1)理想状态下,任何日期区间选择需要在六步之内完成 2)用户选中后是否立刻做背景色提示? 3)当用户选择时,区间是否需要随着用户动作改变?比如用户 hover 时,动态改变选中区间。 4)是否提供快捷键切换 日、月、年选择? 5)是分成两个日期选择器还是采用区间形式? 6)如何去除某些特殊时间点? 比如春节、节假日。 2.5 时间选择设计1)最简单的方法是竖直的日期,水平的时间选择 2)更有用的是先提供日期还是时间选择? 时间选择可以作为一个过滤项,移除某些不可用的日期,这个也很有用。 3)提供最常使用的时间片段,并提供快捷键选择。 3. 文章中亮点设计3.1 google flight 这个案例在最小的范围内提供用户找出最优选择。虽然第一眼看到这个方法,我懵了一秒,但仔细一看发现这种展现方法完美的给出了各种组合。 3.2 春夏秋冬 这个案例另辟蹊径增加了季节的概念,在某些旅游、机票类业务场景季节是非常必要的概念,提供超出月更粗粒度的日期范围选择。 3.3 枚举选择时间 使用一系列的按钮代替时间选择器,比如像我们的作息时间表,大部分是把时间划分成有规律的时间段供用户选择,固化用户选择。 3.4 对话式交互 采用与用户交互的方式选择日期,如果今后应用上 AI,单纯的日期选择器是不是会消失不见呢?.. 3.5 特殊标识周末在机票、旅行场景中,周末是大家最有可能出行的时间点,采用竖线划分的方式着重标注提醒。 4. 总结 总得来说,日期选择器是一个业务组件,虽然现有很多组件库把它纳入 UI 基础组件。但在每个不通的业务场景和需求下的展现形式、交互都会有所有不同。首先一定一定要明确确定需要日期选择器的场景,尤其是与日期强关联的业务,比如机票定价、日程安排,结合到日期选择器中更直观,提高用户对信息的检索效率。满足用户需求场景的同时,尽量减少用户操作链路。 看到最后点个赞呗,给你比小心心 ❤ ~~"},{"title":"《谈谈 Web Workers》","path":"/wiki/WebWeekly/前沿技术/《谈谈 Web Workers》.html","content":"当前期刊数: 76 1 引言本周精读的文章是 speedy-introduction-to-web-workers,是一篇 Web Workers 快速入门的文章,借精读这篇文章的机会,谈谈对 Web Workers 的理解与运用。 2 概述 就像分工,你只负责编码,而你的朋友负责设计,那你就可以专心把自己的事情做好,而且更快速的完成任务。 本文通过一个比方,描述了 Web Workers 的两大特征: 高效。 并行。 因为浏览器是单线程的,任何大量耗时的 JS 任务都会卡住界面,使浏览器无法响应任何操作,这样的用户体验非常糟糕。Web Workers 可以将耗时任务拆解出去,降低主线程的压力,避免主线程无响应。 但 CPU 资源是有限的,Web Workers 并不能增加总体运行效率,算上通信的损耗,整体计算效率会有一定的下降。 创建 Web Workersconst worker = new Worker("../src/worker.js"); 上述代码中,worker 就是一个 Web Workers 实例,执行的代码是 ../src/worker.js 路径下的文件。 收发消息Web Workers 用来执行异步脚本,只要掌握了它与主线程通信的方式,就可以在指定时机运行异步脚本,并在运行完时将结果传递给主线程。 主线程接收发 Web Workers 消息const worker = new Worker("../src/worker.js");worker.onmessage = e => {};worker.postMessage("Marco!"); 每个 worker 实例通过 onmessage 接收消息,通过 postMessage 发送消息。 Web Workers 收发主线程消息self.onmessage = e => {};self.postMessage("Marco!"); 和主线程代码类似,在 Web Workers 代码中,也是 onmessage 接收消息,这个消息来自主线程或者其它 Workers。也可以通过 postMessage 发送消息。 销毁 Web Workersworker.terminate(); 文章内容就这么多,是不是有写太简单了呢!笔者结合自己的使用经验,再补充一些知识。 3 精读对象转移(Transferable Objects)对象转移就是将对象引用零成本转交给 Web Workers 的上下文,而不需要进行结构拷贝。 这里要解释的是,主线程与 Web Workers 之间的通信,并不是对象引用的传递,而是序列化/反序列化的过程,当对象非常庞大时,序列化和反序列化都会消耗大量计算资源,降低运行速度。 上面的图充分证明了,大对象传递,使用对象转移各项指标都优于结构拷贝。 对象转移使用方式很简单,给 postMessage 增加一个参数,把对象引用传过去即可: var ab = new ArrayBuffer(1);worker.postMessage(ab, [ab]); 浏览器兼容性也不错:Currently Chrome 17+, Firefox, Opera, Safari, IE10+。更具体内容,可以看 Transferable Objects: Lightning Fast!。 需要注意的是,对象引用转移后,原先上下文就无法访问此对象了,需要在 Web Workers 再次将对象还原到主线程上下文后,主线程才能正常访问被转交的对象。 如何不用 JS 文件创建 Web WorkersWeb Workers 优势这么大,但用起来需要在同域下创建一个 JS 文件实在不方便,尤其在前后端分离做的比较彻底的团队,前端团队能控制的仅仅是一个 JS 文件。那么下面给出几个不用 JS 文件,就创建 Web Workers 的方法: webpack 插件 - worker-loaderworker-loader 是一个 webpack 插件,可以将一个普通 JS 文件的全部依赖提取后打包并替换调用处,以 Blob 形式内联在源码中。 import Worker from "worker-loader!./file.worker.js";const worker = new Worker(); 上述代码的魔术在于,转化成下面的方式执行: const blob = new Blob([codeFromFileWorker], { type: "application/javascript" });const worker = new Worker(URL.createObjectURL(blob)); Blob URL第二种方式由第一种方式自然带出:如果不想用 webpack 插件,那自己通过 Blob 的方式创建也可以: const code = ` importScripts('https://xxx.com/xxx.js'); self.onmessage = e => {};`;const blob = new Blob([code], { type: "application/javascript" });const worker = new Worker(URL.createObjectURL(blob)); 看上去代码更轻量一些,不过问题是当遇到复杂依赖时,如果不能把依赖都转化为脚本通过 importScripts 方式引用,就无法访问到主线程环境中的包。如果真的遇到了这个问题,可以用第一种 webpack 插件的方式解决,这个插件会自动把文件所有依赖都打包进源码。 管理 postMessage 队列为什么 postMessage 会形成队列,为什么要管理它? 首先在 Web Workers 架构设计上就必须做成队列,因为调用 postMessage 时,对应的 Web Workers 不一定完成了初始化,所以浏览器底层必须管理一个队列,在 Web Workers 初始化完毕时,依次消费,这样才能确保任何时候发出的 postMessage 都能被 Web Workers 接收到。 其次,为什么要手动维护这个队列,原因可能取决于如下几点: 业务原因,前面的 postMessage 还没来得及消费,就不要发送新的消息,或者丢弃新的消息,这时候需要通过双向通信拿到 Web Workers 的执行结果回执,手动控制队列。 性能原因,一般 Web Workers 都会被用来执行耗时的同步运算,如果运算时间比较长,那短期塞入多个消息队列是没有意义的。 如上图所示,对于每次用户输入都要进行的 SQL Parser 很耗时,及时放在 Web Workers 也可能导致将 Workers 撑爆到无响应,这是不仅要使用多 Workers 缓冲池,还要对待执行队列进行过滤,因为用户永远只关心最后一次输入的 Parser 结果。 由于 Web Workers 运算被卡住时,除了销毁 Worker 没有别的办法,而销毁 Worker 的成本比较高,不能对每一个用户输入都销毁并新建 Web Workers,所以利用 Workers 缓冲池,当缓冲池满了,新的消费队列又进来的时候,可以销毁全部 Workers 缓冲池,换一批新缓冲池重新消费用户输入。 4 总结Web Workers 是拆解异步计算的好帮手,vscode 网页版也通过 Web Workers 异步完成代码提示和高亮,笔者有对比过,发现 Web Workers 性能提升非常明显。 管理好你的 Web Workers 消息队列,谨防同步计算让 Web Workers 失去响应,建立一个智能的消息队列,根据业务需求设计一个最好的队列消费模型吧! 5 更多讨论 讨论地址是:精读《谈谈 Web Workers》 · Issue ##108 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《默认、命名导出的区别》","path":"/wiki/WebWeekly/前沿技术/《默认、命名导出的区别》.html","content":"当前期刊数: 204 从代码可维护性角度出发,命名导出比默认导出更好,因为它减少了因引用产生重命名情况的发生。 但命名导出与默认导出的区别不止如此,在逻辑上也有很大差异,为了减少开发时在这方面栽跟头,有必要提前了解它们的区别。 本周找来了这方面很好的的文章:export-default-thing-vs-thing-as-default,先描述梗概,再谈谈我的理解。 概述一般我们认为,import 导入的是引用而不是值,也就是说,当导入对象在模块内值发生变化后,import 导入的对象值也应当同步变化。 // module.jsexport let thing = 'initial';setTimeout(() => { thing = 'changed';}, 500); 上面的例子,500ms 后修改导出对象的值。 // main.jsimport { thing as importedThing } from './module.js';const module = await import('./module.js');let { thing } = await import('./module.js');setTimeout(() => { console.log(importedThing); // "changed" console.log(module.thing); // "changed" console.log(thing); // "initial"}, 1000); 1s 后输出发现,前两种输出结果变了,第三种没有变。也就是对命名导出来说,前两种是引用,第三种是值。 但默认导出又不一样: // module.jslet thing = 'initial';export { thing };export default thing;setTimeout(() => { thing = 'changed';}, 500); // main.jsimport { thing, default as defaultThing } from './module.js';import anotherDefaultThing from './module.js';setTimeout(() => { console.log(thing); // "changed" console.log(defaultThing); // "initial" console.log(anotherDefaultThing); // "initial"}, 1000); 为什么对默认导出的导入结果是值而不是引用? 原因是默认导出可以看作一种对 “default 赋值” 的特例,就像 export default = thing 这种旧语法表达的一样,本质上是一种赋值,所以拿到的是值而不是引用。 那么默认导出的另一种写法 export { thing as default } 也是如此吗?并不是: // module.jslet thing = 'initial';export { thing, thing as default };setTimeout(() => { thing = 'changed';}, 500); // main.jsimport { thing, default as defaultThing } from './module.js';import anotherDefaultThing from './module.js';setTimeout(() => { console.log(thing); // "changed" console.log(defaultThing); // "changed" console.log(anotherDefaultThing); // "changed"}, 1000); 可见,这种默认导出,导出的都是引用。所以导出是否是引用,不取决于是否是命名导出,而是取决于写法。不同的写法效果不同,哪怕相同含义的不同写法,效果也不同。 难道是写法的问题吗?是的,只要是 export default 导出的都是值而不是引用。但不幸的是,存在一个特例: // module.jsexport default function thing() {}setTimeout(() => { thing = 'changed';}, 500); // main.jsimport thing from './module.js';setTimeout(() => { console.log(thing); // "changed"}, 1000); 为什么 export default function 是引用呢?原因是 export default function 是一种特例,这种写法就会导致导出的是引用而不是值。如果我们用正常方式导出 Function,那依然遵循前面的规则: // module.jsfunction thing() {}export default thing;setTimeout(() => { thing = 'changed';}, 500); 只要没有写成 export default function 语法,哪怕导出的对象是个 Function,引用也不会变化。所以取决效果的是写法,而与导出对象类型无关。 对于循环引用也有时而生效,时而不生效的问题,其实也取决于写法。下面的循环引用是可以正常工作的: // main.jsimport { foo } from './module.js';foo();export function hello() { console.log('hello');} // module.jsimport { hello } from './main.js';hello();export function foo() { console.log('foo');} 为什么呢?因为 export function 是一种特例,JS 引擎对其做了全局引用提升,所以两个模块都能各自访问到。下面方式就不行了,原因是不会做全局提升: // main.jsimport { foo } from './module.js';foo();export const hello = () => console.log('hello'); // module.jsimport { hello } from './main.js';hello();export const foo = () => console.log('foo'); 所以是否生效取决于是否提升,而是否提升取决于写法。当然下面的写法也会循环引用失败,因为这种写法会被解析为导出值: // main.jsimport foo from './module.js';foo();function hello() { console.log('hello');}export default hello; 作者的探索到这里就结束了,我们来整理一下思路,尝试理解其中的规律。 精读可以这么理解: 导出与导入均为引用时,最终才是引用。 导入时,除 {} = await import() 外均为引用。 导出时,除 export default thing 与 export default 123 外均为引用。 对导入来说,{} = await import() 相当于重新赋值,所以具体对象的引用会丢失,也就是说异步的导入会重新赋值,而 const module = await import() 引用不变的原因是 module 本身是一个对象,module.thing 的引用还是不变的,即便 module 是被重新赋值的。 对导出来说,默认导出可以理解为 export default = thing 的语法糖,所以 default 本身就是一个新的变量被赋值,所以基础类型的引用无法被导出也很合理。甚至 export default '123' 是合法的,而 export { '123' as thing } 是非法的也证明了这一点,因为命名导出本质是赋值到 default 变量,你可以用已有变量赋值,也可以直接用一个值,但命名导出不存在赋值,所以你不能用一个字面量作命名导出。 而导出存在一个特例,export default function,这个我们尽量少写就行了,写了也无所谓,因为函数保持引用不变一般不会引发什么问题。 为了保证导入的总是引用,一方面尽量用命名导入,另一方面要注意命名导出。如果这两点都做不到,可以尽量把需要维持引用的变量使用 Object 封装,而不要使用简单变量。 最后对循环依赖而言,只有 export default function 存在声明提升的 Magic,可以保证循环依赖正常 Work,但其他情况都不支持。要避免这种问题,最好的办法是不要写出循环依赖,遇到循环依赖时使用第三个模块作中间人。 总结一般我们都希望 import 到的是引用而不是瞬时值,但因为语义与特殊语法糖的原因,导致并不是所有写法效果都是一致的。 我也认为不需要背下来这些导入导出细枝末节的差异,只要写模块时都用规范的命名导入导出,少用默认导出,就可以在语义与实际表现上规避掉这些问题啦。 讨论地址是:精读《export 默认/命名导出的区别》· Issue ##342 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《迭代器 Iterable》","path":"/wiki/WebWeekly/前沿技术/《迭代器 Iterable》.html","content":"当前期刊数: 262 本周精读的文章是 Iterables 与 Iteration protocols,按照为什么需要迭代器、迭代器是如何设计的,我们还能怎么利用迭代器展开来讲。 概述为什么需要迭代器因为用 for ... of 循环数组非常方便,但如果仅数组才支持这个语法就太过于麻烦了,比如我们自然会希望 for ... of 可以遍历字符串的每个字符,希望 new Set([1, 2, 3]) 可以快速初始化一个新的 Set。 以上提到的能力 JS 都支持,那么为什么 JS 引擎知道字符串该如何遍历?如何知道数组 [1, 2, 3] 与 Set 类型每一个 Key 之间的对应关系?实现这些功能背后的原理就是迭代器(Iterables)。 因为 Array、Set 都是可迭代的,所以他们都可以被 for ... of 遍历,JS 引擎也自然知道他们之间相互转换的关系。 迭代器是如何设计的有两种定义迭代器的方法,分别是独立定义与合并在对象里定义。 独立定义为对象拓展 [Symbol.iterator] 属性即可。之所以规范采用 [Symbol.iterator] 是为了防止普通的字面量 Key 与对象自身的 OwnProperties 冲突: const obj = {}obj[Symbol.iterator] = function() { return { someValue: 1, next() { // 可通过 this.someValue 访问与修改该值,可定义任意数量的变量作为迭代过程中的辅助变量 if (...) { return { done: false, value: this.current++ } // 表示迭代还没完,当前值为 value } return { done: true } // 表示迭代完毕 } };}; 在 for ... of 时,只要没有读到 done: true 就会一直循环。 合并在对象里定义简化一点可以将迭代定义在对象里: let range = { from: 1, to: 5, [Symbol.iterator]() { this.current = this.from; return this; }, next() { if (this.current <= this.to) { return { done: false, value: this.current++ }; } else { return { done: true }; } },}; 这么定义的缺点是并行迭代对象时可能触发 BUG,因为每个迭代间共享了同一份状态变量。 手动控制迭代迭代器也可以自定义触发,方法如下: const myObj = iterable[Symbol.iterator]();myObj.next(); // { value: 1, done: false }myObj.next(); // { value: 2, done: false }myObj.next(); // { value: 3, done: false }myObj.next(); // { done: true } 当 done 为 true 时你就知道迭代停止了。手动控制迭代的好处是,你可以自由控制 next() 触发的时机与频率,甚至提前终止,带来了更大的自由度。 可迭代与 ArrayLike 的区别如果不了解迭代器,可能会以为 for of 是通过下标访问的,也就会把一个对象能否用 obj[index] 访问与是否可迭代弄混。 读过上面的介绍,你应该理解到可迭代的原因是实现了 [Symbol.iterator],而与对象是否是数组,或者 ArrayLike 没有关系。 // 该对象可迭代,不是 ArrayLikeconst range = { from: 1, to: 5,};range[Symbol.iterator] = function () { // ...}; // 该对象不可迭代,是 ArrayLikeconst range = { "0": "a", "1": "b", length: 2,}; // 该对象可迭代,是 ArrayLikeconst range = { "0": "a", "1": "b", length: 2,};range[Symbol.iterator] = function () { // ...}; 顺带一提,js 的数组类型就是典型既可迭代,又属于 ArrayLike 的类型。 精读可迭代的内置类型String、Array、TypedArray、Map、Set 都支持迭代,其表现为: const myString = "abc";for (let val of myString) { console.log(val);} // 'a', 'b', 'c'const myArr = ["a", "b", "c"];for (let val of myArr) { console.log(val);} // 'a', 'b', 'c'const myMap = [ ["1", "a"], ["2", "b"], ["3", "c"],];for (let val of myMap) { console.log(val);} // ['1', 'a'], ['2', 'b'], ['3', 'c']const mySet = new Set(["a", "b", "c"]);for (let val of mySet) { console.log(val);} // 'a', 'b', 'c' 可迭代对象可以适用哪些 API可迭代对象首先支持上文提到的 for ... of 与 for ... in 语法。 另外就是许多内置函数的入参支持传入可迭代对象:Map() WeakMap() Set() WeakSet() Promise.all() Promise.allSettled() Promise.race() Promise.any() Array.from()。 如 Array.from 语法,可以将可迭代对象变成真正的数组,该数组的下标就是执行 next() 的次数,值就是 next().value: Array.from(new Set(["1", "2", "3"])); // ['1', '2', '3'] generator 也是迭代器的一种,属于异步迭代器,所以你甚至可以将 yield 一个 generator 函数作为上面这些内置函数的参数: new Set( (function* () { yield 1; yield 2; yield 3; })()); 最后一种就是上周精读提到的 精读《Rest vs Spread 语法》,解构本质也是用迭代器进行运算的: const range = { from: 1, to: 5, [Symbol.iterator]() { this.current = this.from; return this; }, next() { if (this.current <= this.to) { return { done: false, value: this.current++ }; } else { return { done: true }; } },};[...range]; // [1, 2, 3, 4, 5] 总结生活中,我们可以数苹果的数量,数大楼的窗户,数杂乱的衣物有多少个,其实不同的场景这些对象的排列形式都不同,甚至老师在黑板写的 0~10,我们按照这 4 个字符也能从 1 数到 10,这背后的原理抽象到程序里就是迭代器。 一个对象黑盒,不论内部怎么实现,如果我们能按照顺序数出内部结构,那么这个对象就是可迭代的,这就是 [Symbol.iterator] 定义要解决的问题。 生活中与程序中都有一些默认的迭代器,可以仔细领悟一下它们之间的关系。 讨论地址是:精读《迭代器 Iterable》· Issue ##448 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《重新思考 Redux》","path":"/wiki/WebWeekly/前沿技术/《重新思考 Redux》.html","content":"当前期刊数: 56 本周精读内容是 《重新思考 Redux》。 1 引言《重新思考 Redux》是 rematch 作者 Shawn McKay 写的一篇干货软文。 dva 之后,有许多基于 redux 的状态管理框架,但大部分都很局限,甚至是倒退。但直到看到了 rematch,总算觉得 redux 社区又进了一步。 这篇文章的宝贵之处在于,抛开 Mobx、RXjs 概念,仅针对 redux 做深入的重新思考,对大部分还在使用 redux 的工程场景非常有帮助。 2 概述比较新颖的是,作者给出一个公式,评价一个框架或工具的质量: 工具质量 = 工具节省的时间/使用工具消耗的时间 如果这样评估原生的 redux,我们会发现,使用 redux 需要额外花费的时间可能超过了其节省下来的时间,从这个角度看,redux 是会降低工作效率的。 但 redux 的数据管理思想是正确的,复杂的前端项目也确实需要这种理念,为了更有效率的使用 redux,我们需要使用基于 redux 的框架。作者从 6 个角度阐述了基于 redux 的框架需要解决什么问题。 简化初始化redux 初始化代码涉及的概念比较多,比如 compose thunk 等等,同时将 reducer、initialState、middlewares 这三个重要概念拆分成了函数方式调用,而不是更容易接受的配置方式: const store = preloadedState => { return createStore( rootReducer, preloadedState, compose(applyMiddleware(thunk, api), DevTools.instrument()) );}; 如果换成配置方式,理解成本会降低不少: const store = new Redux.Store({ instialState: {}, reducers: { count }, middlewares: [api, devTools]}); 笔者注:redux 的初始化方式非常函数式,而下面的配置方式就更面向对象一些。相比之下,还是面向对象的方式更好理解,毕竟 store 是一个对象。instialState 也存在同样问题,相比显示申明,将 preloadedState 作为函数入参就比较抽象了,同时 redux 对初始 state 的赋值也比较隐蔽,createStore 时统一赋值比较别扭,因为 reducers 是分散的,如果在 reducers 中赋值,要利用 es 的默认参数特性,看起来更像业务思考,而不是 redux 提供的能力。 简化 Reducersredux 的 reducer 粒度太大,不但导致函数内手动匹配 type,还带来了 type、payload 等理解成本: const countReducer = (state, action) => { switch (action.type) { case INCREMENT: return state + action.payload; case DECREMENT: return state - action.payload; default: return state; }}; 如果用配置的方式设置 reducers,就像定义一个对象一样,会更清晰: const countReducer = { INCREMENT: (state, action) => state + action.payload, DECREMENT: (state, action) => state - action.payload}; 支持 async/awaitredux 支持动态数据还是挺费劲的,需要理解高阶函数,理解中间件的使用方式,否则你不会知道为什么这样写是对的: const incrementAsync = count => async dispatch => { await delay(); dispatch(increment(count));}; 为什么不抹掉理解成本,直接允许 async 类型的 action 呢? const incrementAsync = async count => { await delay(); dispatch(increment(count));}; 笔者注:我们发现 rematch 的方式,dispatch 是 import 进来的(全局变量),而 redux 的 dispatch 是注入进来的,乍一看似乎 redux 更合理,但其实我更推崇 rematch 的方案。经过长期实践,组件最好不要使用数据流,项目的数据流只用一个实例完全够用了,全局 dispatch 的设计其实更合理,而注入 dispatch 的设计看似追求技术极致,但忽略了业务使用场景,导致画蛇添足,增加了不必要的麻烦。 将 action + reducer 改为两种 actionredux 抽象的 action 与 reducer 的职责很清晰,action 负责改 store 以外所有事,而 reducer 负责改 store,偶尔用来做数据处理。这种概念其实比较模糊,因为往往不清楚数据处理放在 action 还是 reducer 里,同时过于简单的 reducer 又要写 action 与之匹配,感觉过于形式化,而且繁琐。 重新考虑这个问题,我们只有两类 action:reducer action 与 effect action。 reducer action:改变 store。 effect action:处理异步场景,能调用其他 action,不能修改 store。 同步的场景,一个 reducer 函数就能处理,只有异步场景需要 effect action 处理掉异步部分,同步部分依然交给 reducer 函数,这两种 action 职责更清晰。 不再显示申明 action type不要在用一个文件存储 Action 类型了,const ACTION_ONE = 'ACTION_ONE' 其实重复写了一遍字符串,直接用对象的 key 表示 action 的值,再加上 store 的 name 为前缀保证唯一性即可。 同时 redux 建议使用 payload key 来传值,那为什么不强制使用 payload 作为入参,而要通过 action.payload 取值呢?直接使用 payload 不但视觉上减少代码数量,容易理解,同时也强制约束了代码风格,让建议真正落地。 Reducer 直接作为 ActionCreatorredux 调用 action 比较繁琐,使用 dispatch 或者将 reducer 经过 ActionCreator 函数包装。为什么不直接给 reducer 自动包装 ActionCreator 呢?减少样板代码,让每一行代码都有业务含义。 最后作者给出了一个 rematch 完整的例子: import { init, dispatch } from "@rematch/core";import delay from "./makeMeWait";const count = { state: 0, reducers: { increment: (state, payload) => state + payload, decrement: (state, payload) => state - payload }, effects: { async incrementAsync(payload) { await delay(); this.increment(payload); } }};const store = init({ models: { count }});dispatch.count.incrementAsync(1); 3 精读我觉得本文基本上把 redux 存在的工程问题分析透彻了,同时还给出了一套非常好的实现。 细节的极致优化首先是直接使用 payload 而不是整个 action 作为入参,加强了约束同时简化代码复杂度: increment: (state, payload) => state + payload; 其次使用 async 在 effects 函数中,使用 this.increment 函数调用方式,取代 put({type: "increment"})(dva),在 typescript 中拥有了类型支持,不但可以用自动跳转代替字符串搜索,还能校验参数类型,在 redux 框架中非常难得。 最后在 dispatch 函数,也提供了两种调用方式: dispatch({ type: "count/increment", payload: 1 });dispatch.count.increment(1); 如果为了更好的类型支持,或者屏蔽 payload 概念,可以使用第二种方案,再一次简化 redux 概念。 内置了比较多的插件rematch 将常用的 reselect、persist、immer 等都集成为了插件,相对比较强化插件生态的概念。数据流对数据缓存,性能优化,开发体验优化都有进一步施展的空间,拥抱插件生态是一个良好的发展方向。 比如 rematch-immer 插件,可以用 mutable 的方式修改 store: const count = { state: 0, reducers: { add(state) { state += 1; return state; } }}; 但是当 state 为非对象时,immer 将不起作用,所以最好能养成 return state 的习惯。 最后说一点瑕疵的地方,reducers 申明与调用参数不一致。 Reducers 申明与调用参数不一致比如下面的 reducers: const count = { state: 0, reducers: { increment: (state, payload) => state + payload, decrement: (state, payload) => state - payload }, effects: { async incrementAsync(payload) { await delay(); this.increment(payload); } }}; 定义时 increment 是两个参数,而 incrementAsync 调用它时,只有一个参数,这样可能造成一些误导,笔者建议保持参数对应关系,将 state 放在 this 中: const count = { state: 0, reducers: { increment: payload => this.state + payload, decrement: payload => this.state - payload }, effects: { async incrementAsync(payload) { await delay(); this.increment(payload); } }}; 当然 rematch 的方式保持了函数的无副作性质,可以看出是做了一些取舍。 4 总结重复一下作者提出工具质量的公式: 工具质量 = 工具节省的时间/使用工具消耗的时间 如果一个工具能节省开发时间,但本身带来了很大使用成本,在想清楚如何减少使用成本之前,不要急着用在项目中,这是我得到的最大启发。 最后感谢 rematch 作者精益求精的精神,给 redux 带来进一步的极致优化。 5 更多讨论 讨论地址是:精读《重新思考 Redux》 · Issue ##83 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《高性能表格》","path":"/wiki/WebWeekly/前沿技术/《高性能表格》.html","content":"当前期刊数: 191 每个前端都想做一个完美的表格,业界也在持续探索不同的思路,比如钉钉表格、语雀表格。 笔者所在数据中台团队也对表格有着极高的要求,尤其是自助分析表格,需要兼顾性能与交互功能,本文便是记录自助分析表格高性能的研发思路。 精读要做表格首先要选择基于 DOM 还是 Canvas,这是技术选型的第一步。比如钉钉表格就是 基于 Canvas 实现的,当然这不代表 Canvas 实现就比 DOM 实现要好,从技术上各有利弊: Canvas 渲染效率比 DOM 高,这是浏览器实现导致的。 DOM 可拓展性比 Canvas 好,渲染自定义内容首选 DOM 而非 Canvas。 技术选型要看具体的业务场景,钉钉表格其实就是在线 Excel,Excel 这种形态决定了单元格内一定是简单文本加一些简单图标,因此不用考虑渲染自定义内容的场景,所以选择 Canvas 渲染在未来也不会遇到不好拓展的麻烦。 而自助分析表格天然可能拓展图形、图片、操作按钮到单元格中,对轴的拖拽响应交互也非常复杂,为了不让 Canvas 成为以后拓展的瓶颈,还是选择 DOM 实现比较妥当。 那问题来了,既然 DOM 渲染效率天然比 Canvas 低,我们应该如何用 DOM 实现一个高性能表格呢? 其实业界已经有许多 DOM 表格优化方案了,主要以按需渲染、虚拟滚动为主,即预留一些 Buffer 区域用于滑动时填充,表格仅渲染可视区域与 Buffer 区域部分。但这些方案都不可避免的存在快速滑动时白屏问题,笔者通过不断尝试终于发现了一种完美解决的方案,我们一起往下看吧! 单元格使用 DIV 绝对定位即每个单元格都是用绝对定位的 DIV 实现,整个表格都是有独立计算位置的 DIV 拼接而成的: 这样做的前提是: 所有单元格位置都要提前计算,这里可以利用 web worker 做并行计算。 单元格合并仅是产生一个更大的单元格,它的定位方式与小单元格并无差异。 带来的好处是: 滚动时,单元格可以最大程度实现复用。 对于合并的单元格,只会让可视区域渲染的总单元格数更小,更利于性能提升,而不是带来性能负担。 如图所示有 16 个单元格,当我们向右下滑动一格时,中间 3x3 即 9 个格子的区域是完全不会重新渲染的,这样零散的绝对定位分布可以最大程度维持单元格本来的位置。我们可以认为,任何一格单元格只要自身不超出屏幕范围,就不会随着滚动而重渲染。 如果你采用 React 框架来实现,只要将每个格子的 key 设置为唯一的即可,比如当前行列号。 模拟滚动而非原生滚动一般来说,轴因为逻辑特殊,其渲染逻辑和单元格会分开维护,因此我们将表格分为三个区域:横轴、纵轴、单元格。 显然,常识是横轴只能纵向滚动,纵轴只能横向滚动,单元格可以横纵向滚动,那么横向和纵向滚动条就只能出现在单元格区域: 这样会存在三个问题: 单元格使用原生滚动,横纵轴只能在单元格区域监听滚动后,通过 .scroll 模拟滚动,这必然会导致单元格与轴滚动有一定错位,即轴的滚动有几毫秒的滞后感。 鼠标放在轴上时无法滚动,因为只有单元格是 overflow: auto 的,而轴区域 overflow: hidden 无法触发滚动。 快速滚动出现白屏,即便留了 Buffer 区域,在快速滚动时也无能为力,这是因为渲染速度跟不上滚动导致的。 经过一番思考,我们只要将方案稍作调整,就能同时解决上面三个问题:即不要使用原生的滚动条,而是使用 .scroll 代替滚动,用 mousewheel 监听滚动的触发: 这样做带来什么变化呢? 轴、单元格区域都使用 .scroll 触发滚动,使得轴和单元格不会出现错位,因为轴和单元格都是用 .scroll 触发的滚动。 任何位置都能监听滚动,使得轴上也能滚动了,我们不再依赖 overflow 属性。 快速滚动时惊喜的发现不会白屏了,原因是用 js 控制触发的滚动发生在渲染完成之后,所以浏览器会在滚动发生前现完成渲染,这相当有趣。 模拟滚动时,实际上整个表格都是 overflow: hidden 的,浏览器就不会给出自带滚动条了,我们需要用 DIV 做出虚拟滚动条代替,这个相对容易。 零 buffer 区域当我们采用模拟滚动方案时,相当于采用了在滚动时 “高频渲染” 的方案,因此不需要使用截留,更不要使用 Buffer 区域,因为更大的 Buffer 区域意味着更大的渲染开销。 当我们把 Buffer 区域移除时,发现整个屏幕内渲染单元格在 1000 个以内时,现代浏览器甚至配合 Windows 都能快速完成滚动前刷新,并不会影响滚动的流畅性。 当然,滚动过快依然不是一件好事,既然滚动是由我们控制的,可以稍许控制下滚动速度,控制在每次触发 mousewheel 位移不超过 200 左右最佳。 预计算像单元格合并、行列隐藏、单元格格式化等计算逻辑,最好在滚动前提前算掉,否则在快速滚动时实时计算必然会带来额外的计算成本损耗。 但是这种预计算也有弊端,当单元格数量超过 10w 时,计算耗时一般会超过 1 秒,单元格数量超过 100w 时,计算耗时一般会超过 10 秒,用预计算的牺牲换来滚动的流畅,还是有些遗憾,我们可以再思考以下,能否降低预计算的损耗? 局部预计算局部预计算就是一种解决方案,即便单元格数量有一千万个,但我们如果仅计算前 1w 个单元格呢?那无论数据量有多大,都不会出现丝毫卡顿。 但局部预计算有着明显缺点,即表格渲染过程中,局部计算结果并不总等价于全局计算结果,典型的有列宽、行高、跨行跨列的计算字段。 我们需要针对性解决,对于单元格宽高计算,必须采用局部计算,因为全量计算的损耗非常大。但局部计算肯定是不准确的,如下图所示: 但出于性能考虑,我们初始化可能仅能计算前三行的高度,此时,我们需要在滚动时做两件事情: 在快速滚动的时候,向 web worker 发送预计要滚动到的位置,增量计算这些位置文字宽度,并实时修正列总宽。(因为列总宽算完只要存储最大值,所以已计算的数量级会被压缩为 O(1))。 宽度计算完毕后,快速刷新当前屏幕单元格宽度,但在宽度校准的同时,维持可视区域内左对齐不变,如下图所示: 这样滚动过程中虽然单元格会被突然撑开,但位置并不会产生相对移动,与提前全量撑开后视觉内容相同,因此用户体验并不会有实际影响,但计算时间却由 O(row * column) 下降到 O(1),只要计算一个常数量级的单元格数目。 计算字段也是同理,可以在滚动时按片预计算,但要注意仅能在计算涉及局部单元格的情况下进行,如果这个计算是全局性质的,比如排名,那么局部排序的排名肯定是错误的,我们必须进行全量计算。 好在,即便是全量计算,我们也只需要考虑一部分数据,假设行列数量都是 n,可以将计算复杂度由 O(n²) 降低为 O(n): 这种计算字段的处理无法保证支持无限数量级的数据,但可以大大降低计算时间,假设 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《手写 SQL 编译器 - 回溯》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 回溯》.html","content":"当前期刊数: 67 1 引言上回 精读《手写 SQL 编译器 - 语法分析》 说到了如何利用 Js 函数实现语法分析时,留下了一个回溯问题,也就是存档、读档问题。 我们把语法分析树当作一个迷宫,有直线有岔路,而想要走出迷宫,在遇到岔路时需要提前进行存档,在后面走错时读档换下一个岔路进行尝试,这个功能就叫回溯。 上一篇我们实现了 分支函数,在分支执行失败后回滚 TokenIndex 位置并重试,但在函数调用栈中,如果其子函数执行完毕,堆栈跳出,我们便无法找到原来的函数栈重新执行。 为了更加详细的描述这个问题,举一个例子,存在以下岔路: a -> tree() -> c -> b1 -> b1' -> b2 -> b2' 上面描述了两条判断分支,分别是 a -> b1 -> b1' -> c 与 a -> b2 -> b2' -> c,当岔路 b1 执行失败后,分支函数 tree 可以复原到 b2 位置尝试重新执行。 但设想 b1 -> b1' 通过,但 b1 -> b1' -> c 不通过的场景,由于 b1' 执行完后,分支函数 tree 的调用栈已经退出,无法再尝试路线 b2 -> b2' 了。 要解决这个问题,我们要 通过链表手动构造函数执行过程,这样不仅可以实现任意位置回溯,还可以解决左递归问题,因为函数并不是立即执行的,在执行前我们可以加一些 Magic 动作,比如调换执行顺序!这文章主要介绍如何通过链表构造函数调用栈,并实现回溯。 2 精读假设我们拥有了这样一个函数 chain,可以用更简单的方式表示连续匹配: const root = (tokens: IToken[], tokenIndex: number) => match('a', tokens, tokenIndex) && match('b', tokens, tokenIndex) && match('c', tokens, tokenIndex)↓ ↓ ↓ ↓ ↓ ↓const root = (chain: IChain) => chain('a', 'b', 'c') 遇到分支条件时,通过数组表示取代 tree 函数: const root = (tokens: IToken[], tokenIndex: number) => tree( line(match('a', tokens, tokenIndex) && match('b', tokens, tokenIndex)), line(match('c', tokens, tokenIndex) && match('d', tokens, tokenIndex)))↓ ↓ ↓ ↓ ↓ ↓const root = (chain: IChain) => chain([ chain('a', 'b'), chain('c', 'd')]) 这个 chain 函数有两个特质: 非立即执行,我们就可以 预先生成执行链条 ,并对链条结构进行优化、甚至控制执行顺序,实现回溯功能。 无需显示传递 Token,减少每一步匹配写的代码量。 封装 scanner、matchToken我们可以制作 scanner 函数封装对 token 的操作: const query = "select * from table;";const tokens = new Lexer(query);const scanner = new Scanner(tokens); scanner 拥有两个主要功能,分别是 read 读取当前 token 内容,和 next 将 token 向下移动一位,我们可以根据这个功能封装新的 matchToken 函数: function matchToken( scanner: Scanner, compare: (token: IToken) => boolean): IMatch { const token = scanner.read(); if (!token) { return false; } if (compare(token)) { scanner.next(); return true; } else { return false; }} 如果 token 消耗完,或者与比对不匹配时,返回 false 且不消耗 token,当匹配时,消耗一个 token 并返回 true。 现在我们就可以用 matchToken 函数写一段匹配代码了: const query = "select * from table;";const tokens = new Lexer(query);const scanner = new Scanner(tokens);const root = matchToken(scanner, token => token.value === "select") && matchToken(scanner, token => token.value === "*") && matchToken(scanner, token => token.value === "from") && matchToken(scanner, token => token.value === "table") && matchToken(scanner, token => token.value === ";"); 我们最终希望表达成这样的结构: const root = (chain: IChain) => chain("select", "*", "from", "table", ";"); 既然 chain 函数作为线索贯穿整个流程,那 scanner 函数需要被包含在 chain 函数的闭包里内部传递,所以我们需要构造出第一个 chain。 封装 createChainNodeFactory我们需要 createChainNodeFactory 函数将 scanner 传进去,在内部偷偷存起来,不要在外部代码显示传递,而且 chain 函数是一个高阶函数,不会立即执行,由此可以封装二阶函数: const createChainNodeFactory = (scanner: Scanner, parentNode?: ChainNode) => ( ...elements: any[]): ChainNode => { // 生成第一个节点 return firstNode;}; 需要说明两点: chain 函数返回第一个链表节点,就可以通过 visiter 函数访问整条链表了。 (...elements: any[]): ChainNode 就是 chain 函数本身,它接收一系列参数,根据类型进行功能分类。 有了 createChainNodeFactory,我们就可以生成执行入口了: const chainNodeFactory = createChainNodeFactory(scanner);const firstNode = chainNodeFactory(root); // const root = (chain: IChain) => chain('select', '*', 'from', 'table', ';') 为了支持 chain('select', '*', 'from', 'table', ';') 语法,我们需要在参数类型是文本类型时,自动生成一个 matchToken 函数作为链表节点,同时通过 reduce 函数将链表节点关联上: const createChainNodeFactory = (scanner: Scanner, parentNode?: ChainNode) => ( ...elements: any[]): ChainNode => { let firstNode: ChainNode = null; elements.reduce((prevNode: ChainNode, element) => { const node = new ChainNode(); // ... Link node node.addChild(createChainChildByElement(node, scanner, element)); return node; }, parentNode); return firstNode;}; 使用 reduce 函数对链表上下节点进行关联,这一步比较常规所以忽略掉,通过 createChainChildByElement 函数对传入函数进行分类,如果 传入函数是字符串,就构造一个 matchToken 函数塞入当前链表的子元素,当执行链表时,再执行 matchToken 函数。 重点是我们对链表节点的处理,先介绍一下链表结构。 链表结构class ChainNode { public prev: ChainNode; public next: ChainNode; public childs: ChainChild[] = [];}class ChainChild { // If type is function, when run it, will expend. public type: "match" | "chainNode" | "function"; public node?: IMatchFn | ChainNode | ChainFunctionNode;} ChainNode 是对链表节点的定义,这里给出了和当前文章内容相关的部分定义。这里用到了双向链表,因此每个 node 节点都拥有 prev 与 next 属性,分别指向上一个与下一个节点,而 childs 是这个链表下挂载的节点,可以是 matchToken 函数、链表节点、或者是函数。 整个链表结构可能是这样的: node1 <-> node2 <-> node3 <-> node4 |- function2-1 |- matchToken2-1 |- node2-1 <-> node2-2 <-> node2-3 |- matchToken2-2-1 对每一个节点,都至少存在一个 child 元素,如果存在多个子元素,则表示这个节点是 tree 节点,存在分支情况。 而节点类型 ChainChild 也可以从定义中看到,有三种类型,我们分别说明: matchToken 类型这种类型是最基本类型,由如下代码生成: chain("word"); 链表执行时,match 是最基本的执行单元,决定了语句是否能匹配,也是唯一会消耗 Token 的单元。 node 类型链表节点的子节点也可能是一个节点,类比嵌套函数,由如下代码生成: chain(chain("word")); 也就是 chain 的一个元素就是 chain 本身,那这个 chain 子链表会作为父级节点的子元素,当执行到链表节点时,会进行深度优先遍历,如果执行通过,会跳到父级继续寻找下一个节点,其执行机制类比函数调用栈的进出关系。 函数类型函数类型非常特别,我们不需要递归展开所有函数类型,因为文法可能存在无限递归的情况。 好比一个迷宫,很多区域都是相同并重复的,如果将迷宫完全展开,那迷宫的大小将达到无穷大,所以在计算机执行时,我们要一步步展开这些函数,让迷宫结束取决于 Token 消耗完、走出迷宫、或者 match 不上 Token,而不是在生成迷宫时就将资源消耗完毕。函数类型节点由如下代码生成: chain(root); 所有函数类型节点都会在执行到的时候展开,在展开时如果再次遇到函数节点仍会保留,等待下次执行到时再展开。 分支普通的链路只是分支的特殊情况,如下代码是等价的: chain("a");chain(["a"]); 再对比如下代码: chain(["a"]);chain(["a", "b"]); 无论是直线还是分支,都可以看作是分支路线,而直线(无分支)的情况可以看作只有一条分叉的分支,对比到链表节点,对应 childs 只有一个元素的链表节点。 回溯现在 chain 函数已经支持了三种子元素,一种分支表达方式: chain("a"); // MatchNodechain(chain("a")); // ChainNodechain(foo); // FunctionNodechain(["a"]); // 分支 -> [MatchNode] 而上文提到了 chain 函数并不是立即执行的,所以我们在执行这些代码时,只是生成链表结构,而没有真正执行内容,内容包含在 childs 中。 我们需要构造 execChain 函数,拿到链表的第一个节点并通过 visiter 函数遍历链表节点来真正执行。 function visiter( chainNode: ChainNode, scanner: Scanner, treeChances: ITreeChance[]): boolean { const currentTokenIndex = scanner.getIndex(); if (!chainNode) { return false; } const nodeResult = chainNode.run(); let nestedMatch = nodeResult.match; if (nodeResult.match && nodeResult.nextNode) { nestedMatch = visiter(nodeResult.nextNode, scanner, treeChances); } if (nestedMatch) { if (!chainNode.isFinished) { // It's a new chance, because child match is true, so we can visit next node, but current node is not finished, so if finally falsely, we can go back here. treeChances.push({ chainNode, tokenIndex: currentTokenIndex }); } if (chainNode.next) { return visiter(chainNode.next, scanner, treeChances); } else { return true; } } else { if (chainNode.isFinished) { // Game over, back to root chain. return false; } else { // Try again scanner.setIndex(currentTokenIndex); return visiter(chainNode, scanner, treeChances); } }} 上述代码中,nestedMatch 类比嵌套函数,而 treeChances 就是实现回溯的关键。 当前节点执行失败时由于每个节点都包含 N 个 child,所以任何时候执行失败,都给这个节点的 child 打标,并判断当前节点是否还有子节点可以尝试,并尝试到所有节点都失败才返回 false。 当前节点执行成功时,进行位置存档当节点成功时,为了防止后续链路执行失败,需要记录下当前执行位置,也就是利用 treeChances 保存一个存盘点。 然而我们不知道何时整个链表会遭遇失败,所以必须等待整个 visiter 执行完才知道是否执行失败,所以我们需要在每次执行结束时,判断是否还有存盘点(treeChances): while (!result && treeChances.length > 0) { const newChance = treeChances.pop(); scanner.setIndex(newChance.tokenIndex); result = judgeChainResult( visiter(newChance.chainNode, scanner, treeChances), scanner );} 同时,我们需要对链表结构新增一个字段 tokenIndex,以备回溯还原使用,同时调用 scanner 函数的 setIndex 方法,将 token 位置还原。 最后如果机会用尽,则匹配失败,只要有任意一次机会,或者能一命通关,则匹配成功。 3 总结本篇文章,我们利用链表重写了函数执行机制,不仅使匹配函数拥有了回溯能力,还让其表达更为直观: chain("a"); 这种构造方式,本质上与根据文法结构编译成代码的方式是一样的,只是许多词法解析器利用文本解析成代码,而我们利用代码表达出了文法结构,同时自身执行后的结果就是 “编译后的代码”。 下次我们将探讨如何自动解决左递归问题,让我们能够写出这样的表达式: const foo = (chain: IChain) => chain(foo, bar); 好在 chain 函数并不是立即执行的,我们不会立即掉进堆栈溢出的漩涡,但在执行节点的过程中,会导致函数无限展开从而堆栈溢出。 解决左递归并不容易,除了手动或自动重写文法,还会有其他方案吗?欢迎留言讨论。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 回溯》 · Issue ##96 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《手写 SQL 编译器 - 性能优化之缓存》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 性能优化之缓存》.html","content":"当前期刊数: 78 1 引言重回 “手写 SQL 编辑器” 系列。这次介绍如何利用缓存优化编译器执行性能。 可以利用 First 集 与 Match 节点缓存 这两种方式优化。 本文会用到一些图做解释,下面介绍图形规则: First 集优化,是指在初始化时,将整体文法的 First 集找到,因此在节点匹配时,如果 Token 不存在于 First 集中,可以快速跳过这个文法,在文法调用链很长,或者 “或” 的情况比较多时,可以少走一些弯路: 如图所示,只要构建好了 First 集,不论这个节点的路径有多长,都可以以最快速度判断节点是否不匹配。如果节点匹配,则继续深度遍历方式访问节点。 现在节点不匹配时性能已经最优,那下一步就是如何优化匹配时的性能,这时就用到 Match 节点缓存。 Match 节点缓存,指在运行时,缓存节点到其第一个终结符的过程。与 First 集相反,First 集可以快速跳过,而 Match 节点缓存可以快速找到终结符进行匹配,在非终结符很多时,效果比较好: 如图所示,当匹配到节点时,如果已经构建好了缓存,可以直接调到真正匹配 Token 的 Match 节点,从而节省了大量节点遍历时间。 这里需要注意的是,由于 Tree 节点存在分支可能性,因此缓存也包含将 “沿途” Chances 推入 Chances 池的职责。 2 精读那么如何构建 First 集与 Match 节点缓存呢?通过两张图解释。 构建 First 集 如图所示,构建 First 集是个自下而上的过程,当访问到 MatchNode 节点时,就可以收集作为父节点的 First 集了!父集判断 First 集收集完毕的话,就会触发它的父节点 First 集收集判断,如此递归,最后完成 First 集收集的是最顶级节点。 构建 Match 节点缓存 如图所示,访问节点时,如果没有缓存,则会将这个节点添加到 Match 缓存查找队列,同时路途遇到 TreeNode,也会将下一个 Chance 添加到缓存查找队列。直到遇到了第一个 MatchNode 节点,则这个节点是 “Match 缓存查找队列” 所有节点的 Match 节点缓存,此时这些节点的缓存就可以生效了,指向这个 MatchNode,同时清空缓存查找队列,等待下一次查找。 3 总结拿 select a, b, c, d from e 这个语句做测试: node 节点访问次数 First 集优化 First 集 + Match 节点缓存优化 784 669 652 从这个简单 Demo 来看,提效了 16% 左右。不过考虑到文法结构会影响到提效,对于层级更深的文法、能激活深层级文法的输入可以达到更好的效率提升。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 性能优化之缓存》 · Issue ##110 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《手写 SQL 编译器 - 文法介绍》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 文法介绍》.html","content":"当前期刊数: 65 1 引言文法用来描述语言的语法规则,所以不仅可以用在编程语言上,也可用在汉语、英语上。 2 精读我们将一块语法规则称为 产生式,使用 “Left → Right” 表示任意产生式,用 “Left => Right” 表示产生式的推导过程,比如对于产生式: E → iE → E + E 我们进行推导时,可以这样表示:E => E + E => i + E => i + i + E => i + i + i 也有使用 Left : Right 表示产生式的例子,比如 ANTLR。BNF 范式通过 Left ::= Right 表示产生式。 举个例子,比如 SELECT * FROM table 可以被表达为: S → SELECT * FROM table 当然这是最固定的语法,真实场景中,* 可能被替换为其他单词,而 table 不但可能有其他名字,还可能是个子表达式。 一般用大写的 S 表示文法的开头,称为开始符号。 终结符与非终结符 下面为了方便书写,使用 BNF 范式表示文法。 终结符就是语句的终结,读到它表示产生式分析结束,相反,非终结符就是一个新产生式的开始,比如: <selectStatement> ::= SELECT <selectList> FROM <tableName><selectList> ::= <selectField> [ , <selectList> ]<tableName> ::= <tableName> [ , <tableList> ] 所有 ::= 号左边的都是非终结符,所以 selectList 是非终结符,解析 selectStatement 时遇到了 selectList 将会进入 selectList 产生式,而解析到普通 SELECT 单词就不会继续解析。 对于有二义性的文法,可以通过 上下文相关文法 方式描述,也就是在产生式左侧补全条件,解决二义性: aBc -> a1c | a2cdBe -> d3e 一般产生式左侧都是非终结符,大写字母是非终结符,小写字母是终结符。 上面表示,非终结符 B 在 ac 之间时,可以解析为 1 或 2,而在 de 之间时,解析为 3。但我们可以增加一个非终结符让产生式可读性更好: B -> 1 | 2C -> 3 这样就将上下文相关文法转换为了上下文无关文法。 上下文无关文法根据是否依赖上下文,文法分为 上下文相关文法 与 上下文无关文法,一般来说 上下文相关文法 都可以转换为一堆 上下文无关文法 来处理,而用程序处理 上下文无关文法 相对轻松。 SQL 的文法就是上下文相关文法,在正式介绍 SQL 文法之前,举一个简单的例子,比如我们描述等号(=)的文法: SELECT CASE WHEN bee = 'red' THEN 'ANGRY' ELSE 'NEUTRAL' END AS BeeStateFROM bees;SELECT * from bees WHERE bee = 'red'; 上面两个 SQL 中,等号前后的关键字取决于当前是在 CASE WHEN 语句里,还是在 WHERE 语句里,所以我们认为等号所在位置的文法是上下文相关的。 但是当我们将文法粒度变细,将 CASE WHEN 与 WHERE 区块分别交由两块文法解决,将等号这个通用的表达式抽离出来,就可以不关心上下文了,这种方式称为 上下文无关文法。 附上一个 mysql 上下文无关文法集合。 左推导与右推导上面提到的推导符号 => 在实际运行过程中,显然有两种方向左和右: E + E => ? 从最左边的 E 开始分析,称为左推导,对语法解析来说是自顶向下的方式,常用方法是递归下降。 从最右边的 E 开始分析,称为右推导,对语法解析来说是自底向上的方式,常用方法是移进、规约。 右推导过程比左推导过程复杂,所以如果考虑手写,最好使用左推导的方式。 左推导的分支预测比如 select <selectList> 的 selectList 产生式,它可以表示为: <SelectList> ::= <SelectList> , <SelectField> | <SelectField> 由于它可以展开:SelectList => SelectList , a => SelectList , b, a => c, b, a。 但程序执行时,读到这里会进入死循环,因为 SelectList 可以被无限展开,这就是左递归问题。 消除左递归消除左递归一般通过转化为右递归的方式,因为左递归完全不消耗 Token,而右递归可以通过消耗 Token 的方式跳出死循环。 Token 见上一期精读 精读《手写 SQL 编译器 - 词法分析》 <SelectList> ::= <SelectField> <G><G> ::= , <SelectList> | null 这其实是一个通用处理,可以抽象出来: E → E + FE → F E → FGG → + FGG → null 不过我们也不难发现,通过通用方式消除左递归后的文法更难以阅读,这是因为用死循环的方式解释问题更容易让人理解,但会导致机器崩溃。 笔者建议此处不要生硬的套公式,在套了公式后,再对产生式做一些修饰,让其更具有语义: <SelectList> ::= <SelectField> | , <SelectList> 提取左公因式即便是上下文无关的文法,通过递归下降方式,许多时候也必须从左向右超前查看 K 个字符才能确定使用哪个产生式,这种文法称为 LL(k)。 但如果每次超前查看的内容都有许多字符相同,会导致第二次开始的超前查看重复解析字符串,影响性能。最理想的情况是,每次超前查看都不会对已确定的字符重复查看,解决方法是提取左公因式。 设想如下的 sql 文法: <Field> ::= <Text> as <Text> | <Text> as<String> | <Text> <Text> | <Text> 其实 Text 本身也是比较复杂的产生式,最坏的情况需要对 Text 连续匹配六遍。我们将 Text 公因式提取出来就可以仅匹配一遍,因为无论是何种 Field 产生式,都必定先遇到 Text: <Field> ::= <Text> <F><F> ::= <G> | <Text><G> ::= as <H><H> ::= <space> <Text> | <String> 和消除左递归一样,提取左公因式也会降低文法的可读性,需要进行人为修复。不过提取左公因式的修复没办法在文法中处理,在后面的 “函数式” 处理环节是有办法处理的,敬请期待。 结合优先级对 SQL 的文法来说不存在优先级的概念,所以从某种程度来说,SQL 的语法复杂度还不如基本的加减乘除。 3 总结在实现语法解析前,需要使用文法描述 SQL 的语法,文法描述就是语法分析的主干业务代码。 下一篇将介绍语法分析相关知识,帮助你一步步打造自己的 SQL 编译器。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 文法介绍》 · Issue ##94 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《手写 SQL 编译器 - 语法分析》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 语法分析》.html","content":"当前期刊数: 66 1 引言接着上周的文法介绍,本周介绍的是语法分析。 以解析顺序为角度,语法分析分为两种,自顶而下与自底而上。 自顶而下一般采用递归下降方式处理,称为 LL(k),第一个 L 是指从左到右分析,第二个 L 指从左开始推导,k 是指超前查看的数量,如果实现了回溯功能,k 就是无限大的,所以带有回溯功能的 LL(k) 几乎是最强大的。LL 系列一般分为 LL(0)、LL(1)、LL(k)、LL(∞)。 自底而上一般采用移进(shift)规约(reduce)方式处理,称为 LR,第一个 L 也是从左到右分析,第二个 R 指从右开始推导,而规约时可能产生冲突,所以通过超前查看一个符号解决冲突,就有了 SLR,后面还有功能更强的 LALR(1) LR(1) LR(k)。 通过这张图可以看到 LL 家族与 LR 家族的能力范围: 如图所示,无论 LL 还是 LR 都解决不了二义性文法,还好所有计算机语言都属于无二义性文法。 值得一提的是,如果实现了回溯功能的 LL(k) -> LL(∞),那么能力就可以与 LR(k) 所比肩,而 LL 系列手写起来更易读,所以笔者采用了 LL 方式书写,今天介绍如何手写无回溯功能的 LL。 另外也有一些根据文法自动生成 parser 的库,比如兼容多语言的 antlr4 或者对 js 支持比较友好的 pegjs。 2 精读递归下降可以理解为走多出口的迷宫: 我们先根据 SQL 语法构造一个迷宫,进迷宫的不是探险家,而是 SQL 语句,这个 SQL 语句会拿上一堆令牌(切分好的 Tokens,详情见 精读:词法分析),迷宫每前进一步都会要求按顺序给出令牌(交上去就没收),如果走到出口令牌刚好交完,就成功走出了迷宫;如果出迷宫时手上还有令牌,会被迷宫工作人员带走。这个迷宫会有一些分叉,在分岔路上会要求你亮出几个令牌中任意一个即可通过(LL1),有的迷宫允许你失败了存档,只要没有走出迷宫,都可以读档重来(LLk),理论上可以构造一个最宽容的迷宫,只要还没走出迷宫,可以在分叉处任意读档(LL∞),这个留到下一篇文章介绍。 词法分析首先对 SQL 进行词法分析,拿到 Tokens 列表,这些就是探险家 SQL 带上的令牌。 根据上次讲的内容,我们对 select a from b 进行词法分析,可以拿到四个 Token(忽略空格与注释)。 Match 函数递归下降最重要的就是 Match 函数,它就是迷宫中索取令牌的关卡。每个 Match 函数只要匹配上当前 Token 便将 Token index 下移一位,如果没有匹配上,则不消耗 Token: function match(word: string) { const currentToken = tokens[tokenIndex] // 拿到当前所在的 Token if (currentToken.value === word) { // 如果 Token 匹配上了,则下移一位,同时返回 true tokenIndex++ return true } // 没有匹配上,不消耗 Token,但是返回 false return false} Match 函数就是精简版的 if else,试想下面一段代码: if (token[tokenIndex].value === 'select') {\ttokenIndex++} else {\treturn false}if (token[tokenIndex].value === 'a') {\ttokenIndex++} else {\treturn false} 通过不断对比与移动 Token 进行判断,等价于下面的 Match 实现: match('select') && match('a') 这样写出来的语法分析代码可读性会更强,我们能专注精神在对文法的解读上,而忽略其他环境因素。 顺便一提,下篇文章笔者会带来更精简的描述方法: chain('select', 'a') 让函数式语法更接近文法形式。 最后这种语法不但描述更为精简,而且拥有 LL(∞) 的查找能力,拥有几乎最强大的语法分析能力。 语法分析主体函数既然关卡(Match)已经有了,下面开始构造主函数了,可以开始画迷宫了。 举个最简单的例子,我们想匹配 select a from b,只需要这么构造主函数: let tokenIndex = 0function match() { /* .. */ }const root = () => match("select") && match("a") && match("from") && match("b")tokens = lexer("select a from b")if (root() && tokenIndex === tokens.length) { // sql 解析成功} 为了简化流程,我们把 tokens、tokenIndex 作为全局变量。首先通过 lexer 拿到 select a from b 语句的 Tokens:['select', ' ', 'a', ' ', 'from', ' ', 'b'],注意在语法解析过程中,注释和空格可以消除,这样可以省去对空格和注释的判断,大大简化代码量。所以最终拿到的 Tokens 是 ['select', 'a', 'from', 'b']。 很显然这样与我们构造的 Match 队列相吻合,所以这段语句顺利的走出了迷宫,而且走出迷宫时,Token 正好被消费完(tokenIndex === tokens.length)。 这样就完成了最简单的语法分析,一共十几行代码。 函数调用函数调用是 JS 最最基础的知识,但用在语法解析里可就不那么一样了。 考虑上面最简单的语句 select a from b,显然无法胜任真正的 SQL 环境,比如 select [位置] from b 这个位置可以放置任意用逗号相连的字符串,我们如果将这种 SQL 展开描述,将非常复杂,难以阅读。恰好函数调用可以帮我们完美解决这个问题,我们将这个位置抽象为 selectList 函数,所以主语句改造如下: const root = () => match("select") && selectList() && match("from") && match("b") 这下能否解析 select a, b, c from table 就看 selectList 这个函数了: const selectList = match("a") && match(",") && match("b") && match(",") && match("c") 显然这样做不具备通用性,因为我们将参数名与数量固定了。考虑到上期精读学到的文法,我们可以这样描述 selectList: selectList ::= word (',' selectList)?word ::= [a-zA-Z] 故意绕过了左递归,采用右递归的写法,因而避开了语法分析的核心难点。 ? 号是可选的意思,与正则的 ? 类似。 这是一个右递归文法,不难看出,这个文法可以如此展开: selectList => word (‘,’ selectList)? => a (‘,’ selectList)? => a, word (‘,’ selectList)? => a, b, word (‘,’ selectList)? => a, b, word => a, b, c 我们一下遇到了两个问题: 补充 word 函数。 如何描述可选参数。 同理,利用函数调用,我们假定拥有了可选函数 optional,与函数 word,这样可以先把 selectList 函数描述出来: const selectList = () => word() && optional(match(",") && selectList()) 这样就通过可选函数 optional 描述了文法符号 ?。 我们来看 word 函数如何实现。需要简单改造下 match 使其支持正则,那么 word 函数可以这样描述: const word = () => match(/[a-zA-Z]*/) 而 optional 不是普通的 match 函数,从调用方式就能看出来,我们提到下一节详细介绍。 注意 selectList 函数的尾部,通过右递归的方式调用 selectList,因此可以解析任意长度以 , 分割的字段列表。 Antlr4 支持左递归,因此文法可以写成 selectList ::= selectList (, word)? | word,用在我们这个简化的代码中会导致堆栈溢出。 在介绍 optional 函数之前,我们先引出分支函数,因为可选函数是分支函数的一种特殊形式(猜猜为什么?)。 分支函数我们先看看函数 word,其实没有考虑到函数作为字段的情况,比如 select a, SUM(b) from table。所以我们需要升级下 selectList 的描述: const selectList = () => field() && optional(match(",") && selectList())const field = () => word() 这时注意 field 作为一个字段,也可能是文本或函数,我们假设拥有函数处理函数 functional,那么用文法描述 field 就是: field ::= text | functional | 表示分支,我们用 tree 函数表示分支函数,那么可以如此改写 field: const field = () => tree(word(), functional()) 那么改如何表示 tree 呢?按照分支函数的特性,tree 的职责是超前查看,也就是超前查看 word 是否符合当前 Token 的特征,如何符合,则此分支可以走通,如果不符合,同理继续尝试 functional。 若存在 A、B 分支,由于是函数式调用,若 A 分支为真,则函数堆栈退出到上层,若后续尝试失败,则无法再回到分支 B 继续尝试,因为函数栈已经退出了。这就是本文开头提到的 回溯 机制,对应迷宫的 存档、读档 机制。要实现回溯机制,要模拟函数执行机制,拿到函数调用的控制权,这个下篇文章再详细介绍。 根据这个特性,我们可以写出 tree 函数: function tree(...args: any[]) { return args.some(arg => arg())} 按照顺序执行 tree 的入参,如果有一个函数执行为真,则跳出函数,如果所有函数都返回 false,则这个分支结果为 false。 考虑到每个分支都会消耗 Token,所以我们需要在执行分支时,先把当前 TokenIndex 保存下来,如果执行成功则消耗,执行失败则还原 Token 位置: function tree(...args: any[]) { const startTokenIndex = tokenIndex return args.some(arg => { const result = arg() if (!result) { tokenIndex = startTokenIndex // 执行失败则还原 TokenIndex } return result });} 可选函数可选函数就是分支函数的一个特例,可以描述为: func? => func | ε ε 表示空,也就是这个产生式解析到这里永远可以解析成功,而且不消耗 Token。借助分支函数 tree 执行失败后还原 TokenIndex 的特性,我们先尝试执行它,执行失败的话,下一个 ε 函数一定返回 true,而且会重置 TokenIndex 且不消耗 Token,这与可选的含义是等价的。 所以可以这样描述 optional 函数: const optional = fn => tree(fn, () => true) 基本的运算连接上面通过对 SQL 语句的实践,发现了 match 匹配单个单词、 && 连接、tree 分支、ε 空字符串的产生式这四种基本用法,这是符合下面四个基本文法组合思想的: G ::= ε 空字符串产生式,对应 () => true,不消耗 Token,总是返回 true。 G ::= t 单词匹配,对应 match(t)。 G ::= x y 连接运算,对应 match(x) && match(y)。 G ::= xG ::= y 并运算,对应 tree(x, y)。 有了这四种基本用法,几乎可以描述所有 SQL 语法。 比如简单描述一下 select 语法: const root = () => match("select") && select() && match("from") && table()const selectList = () => field() && optional(match(",") && selectList())const field = () => tree(word, functional)const word = () => match(/[a-zA-Z]+/) 3 总结递归下降的 SQL 语法解析就是一个走迷宫的过程,将 Token 从左到右逐个匹配,最终能找到一条路线完全贴合 Token,则 SQL 解析圆满结束,这个迷宫采用空字符串产生式、单词匹配、连接运算、并运算这四个基本文法组合就足以构成。 掌握了这四大法宝,基本的 SQL 解析已经难不倒你了,下一步需要做这些优化: 回溯功能,实现它才可能实现 LL(∞) 的匹配能力。 左递归自动消除,因为通过文法转换,会改变文法的结合律与语义,最好能实现左递归自动消除(左递归在上一篇精读 文法 有说明)。 生成语法树,仅匹配语句的正确性是不够的,我们还要根据语义生成语法树。 错误检查,在错误的地方给出建议,甚至对某些错误做自动修复,这个在左 SQL 智能提示时需要用到。 错误恢复。 下篇文章会介绍如何实现回溯,让递归下降达到 LL(∞) 的效果。 从本文不难看出,通过函数调用方式我们无法做到 迷宫存档和读档机制,也就是遇到岔路 A B 时,如果 A 成功了,函数调用栈就会退出,而后面迷宫探索失败的话,我们无法回到岔路 B 继续探索。而 回溯功能就赋予了这个探险者返回岔路 B 的能力。 为了实现这个功能,几乎要完全推翻这篇文章的代码组织结构,不过别担心,这四个基本组合思想还会保留。 下篇文章也会放出一个真正能运行的,实现了 LL(∞) 的代码库,函数描述更精简,功能(比这篇文章的方法)更强大,敬请期待。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 语法分析》 · Issue ##95 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《手写 SQL 编译器 - 语法树》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 语法树》.html","content":"当前期刊数: 70 1 引言重回 “手写 SQL 编辑器” 系列。之前几期介绍了 词法、文法、语法的解析,以及回溯功能的实现,这次介绍如何生成语法树。 基于 《回溯》 一文介绍的思路,我们利用 JS 实现一个微型 SQL 解析器,并介绍如何生成语法树,如何在 JS SQL 引擎实现语法树生成功能! 解析目标是: select name, version from my_table; 文法: const root = () => chain(selectStatement, many(";", selectStatement));const selectStatement = () => chain("select", selectList, fromClause);const selectList = () => chain(matchWord, many(",", matchWord));const fromClause = () => chain("from", matchWord);const statement = () => chain( "select", selectList, "from", chain(tableName, [whereStatement, limitStatement]) ); 这是本文为了方便说明,实现的一个精简版本。完整版见我们的开源仓库 cparser。 root 是入口函数,many() 包裹的文法可以执行任意次,所以 chain(selectStatement, many(";", selectStatement)); 表示允许任意长度的 selectStatement 由 ; 号连接,selectList 的写法也同理。 matchWord 表示匹配任意单词。 语法树是人为对语法结构的抽象,本质上,如果我们到此为止,是可以生成一个 基本语法树 的,这个语法树是多维数组,比如: const fromClause = () => chain("from", matchWord); 这个文法生成的默认语法树是:['from', 'my_table'],只不过 from my_table 具体是何含义,只有当前文法知道(第一个标志无含义,第二个标志表示表名)。 fromClause 返回的语法树作为结果被传递到文法 selectStatement 中,其结果可能是:['select', [['name', 'version']], ['from', 'my_table']]。 大家不难看出问题:当默认语法树聚集在一起,就无法脱离文法结构单独理解语法含义了,为了脱离文法结构理解语法树,我们需要将其抽象为一个有规可循的结构。 2 精读通过上面的分析,我们需要对 chain 函数提供修改局部 AST 结构的能力: const selectStatement = () => chain("select", selectList, fromClause)(ast => ({ type: "statement", variant: "select", result: ast[1], from: ast[2] })); 我们可以通过额外参数对默认语法树进行改造,将多维数组结构改变为对象结构,并增加 type variant 属性标示当前对象的类型、子类型。比如上面的例子,返回的对象告诉使用者:“我是一个表达式,一个 select 表达式,我的结果是 result,我的来源表是 from”。 那么,chain 函数如何实现语法树功能呢? 对于每个文法(每个 chain 函数),其语法树必须等待所有子元素执行完,才能生成。所以这是个深度优先的运行过程。 下图描述了 chain 函数执行机制: 生成结构中有四个基本结构,分别是 Chain、Tree、Function、Match,足以表达语法解析需要的所有逻辑。(不包含 可选、多选 逻辑)。 每个元素的子节点全部执行完毕,才会生成当前节点的语法树。实际上,每个节点执行完,都会调用 callParentNode 访问父节点,执行到了这个函数,说明子元素已成功执行完毕,补全对应节点的 AST 信息即可。 对于修改局部 AST 结构函数,需等待整个 ChainNode 执行完毕才调用,并将返回的新 AST 信息存储下来,作为这个节点的最终 AST 信息并传递给父级(或者没有父级,这就是根结点的 AST 结果)。 3 总结本文介绍了如何生成语法树,并说明了 默认语法树 的存在,以及我们之所以要一个定制的语法树,是为了更方便的理解含义。 同时介绍了如何通过 JS 运行一套完整的语法解析器,以及如何提供自定义 AST 结构的能力。 本文介绍的模型,只是为了便于理解而定制的简化版,了解全部细节,请访问 cparser。 最后说一下为何要做这个语法解析器。如今有许多开源的 AST 解析工具,但笔者要解决的场景是语法自动提示,需要在语句不完整,甚至错误的情况,给出当前光标位置的所有可能输入。所以通过完整重写语法解析器内核,在解析的同时,生成语法树的同时,也给出光标位置下一个可能输入提示,在通用错误场景自动从错误中恢复。 目前在做性能优化,通用 SQL 文法还在陆续完善中,目前仅可当学习参考,不要用于生产环境。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 语法树》 · Issue ##99 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《手写 SQL 编译器 - 错误提示》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 错误提示》.html","content":"当前期刊数: 71 1 引言 编译器除了生成语法树之外,还要在输入出现错误时给出恰当的提示。 比如当用户输入 select (name,这是个未完成的 SQL 语句,我们的目标是提示出这个语句未完成,并给出后续的建议: ) - + % / * . ( 。 2 精读分析一个 SQL 语句,现将 query 字符串转成 Token 数组,再构造文法树解析,那么可能出现错误的情况有两种: 语句错误。 文法未完成。 给出错误提示的第一步是判断错误发生。 通过这张 Token 匹配过程图可以发现,当深度优先遍历文法节点时,匹配成功后才会返回父元素继续往下走。而当走到父元素没有根节点了才算匹配成功;当尝试 Chance 时没有机会了,就是错误发生的时机。 所以我们只要找到最后一个匹配成功的节点,再根据最后成功与否,以及搜索出下一个可能节点,就能知道错误类型以及给出建议了。 function onMatchNode(matchNode, store) { const matchResult = matchNode.run(store.scanner); if (!matchResult.match) { tryChances(matchNode, store); } else { const restTokenCount = store.scanner.getRestTokenCount(); if (matchNode.matching.type !== "loose") { if (!lastMatch) { lastMatch = { matchNode, token: matchResult.token, restTokenCount }; } } callParentNode(matchNode, store, matchResult.token); }} 所以在运行语法分析器时,在遇到匹配节点(MatchNode)时,如果匹配成功,就记录下这个节点,这样我们最终会找到最后一个匹配成功的节点:lastMatch。 之后通过 findNextMatchNodes 函数找到下一个可能的推荐节点列表,作为错误恢复的建议。 findNextMatchNodes 函数会根据某个节点,找出下一节点所有可能 Tokens 列表,这个函数后面文章再专门介绍,或者你也可以先阅读 源码. 语句错误也就是任何一个 Token 匹配失败。比如: select * from table_name as table1 error_string; 这里 error_string 就是冗余的语句。 通过语法解析器分析,可以得到执行失败的结果,然后通过 findNextMatchNodes 函数,我们可以得到下面分析结果: 可以看到,程序判断出了 error_string 这个 Token 属于错误类型,同时给出建议,可以将 error_string 替换成这 14 个建议字符串中任意一个,都能使语句正确。 之所以失败类型判断为错误类型,是因为查找了这个正确 Token table1 后面还有一个没有被使用的 error_string,所以错误归类是 wrong。 注意,这里给出的是下一个 Token 建议,而不是全部 Token 建议,因此推荐了 where 表示 “或者后面跟一个完整的 where 语句”。 文法未完成和语句错误不同,这种错误所有输入的单词都是正确的,但却没有写完。比如: select * 通过语法解析器分析,可以得到执行失败的结果,然后通过 findNextMatchNodes 函数,我们可以得到下面分析结果: 可以看到,程序判断出了 * 这个 Token 属于未完成的错误类型,建议在后面补全这 14 个建议字符串中任意一个。比较容易联想到的是 where,但也可以是任意子文法的未完成状态,比如后面补充 , 继续填写字段,或者直接跟一个单词表示别名,或者先输入 as 再跟别名。 之所以失败类型判断为未完成,是因为最后一个正确 Token * 之后没有 Token 了,但语句解析失败,那只有一个原因,就是语句为写完,因此错误归类是 inComplete。 找到最易读的错误类型在一开始有提到,我们只要找到最后一个匹配成功的节点,就可以顺藤摸瓜找到错误原因以及提示,但最后一个成功的节点可能和我们人类直觉相违背。举下面这个例子: select a from b where a = '1' ~ -- 这里手滑了 正常情况,我们都认为错误点在 ~,而最后一个正确输入是 '1'。但词法解析器可不这么想,在我初版代码里,判断出错误是这样的: 提示是 where 错了,而且提示是 .,有点摸不着头脑。 读者可能已经想到了,这个问题与文法结构有关,我们看 fromClause 的文法描述: const fromClause = () => chain( "from", tableSources, optional(whereStatement), optional(groupByStatement), optional(havingStatement) )(); 虽然实际传入的 where 语句多了一个 ~ 符号,但由于文法认为整个 whereStatement 是可选的,因此出错后会跳出,跳到 b 的位置继续匹配,而 显然 groupByStatement 与 havingStatement 都不能匹配到 where,因此编译器认为 “不会从 b where a = '1' ~” 开始就有问题吧?因此继续往回追溯,从 tableName 开始匹配: const tableName = () => chain([matchWord, chain(matchWord, ".", matchWord)()])(); 此时第一次走的 b where a = '1' ~ 路线对应 matchWord,因此尝试第二条路线,所以认为 where 应该换成 .。 要解决这个问题,首先要 承认这个判断是对的,因为这是一种 错误提前的情况,只是人类理解时往往只能看到最后几步,所以我们默认用户想要的错误信息,是 正确匹配链路最长的那条,并对 onMatchNode 作出下面优化: 将 lastMatch 对象改为 lastMatchUnderShortestRestToken: if ( !lastMatchUnderShortestRestToken || (lastMatchUnderShortestRestToken && lastMatchUnderShortestRestToken.restTokenCount > restTokenCount)) { lastMatchUnderShortestRestToken = { matchNode, token: matchResult.token, restTokenCount };} 也就是每次匹配到正确字符,都获取剩余 Token 数量,只保留最后一匹配正确 且剩余 Token 最少的那个。 3 总结做语法解析器错误提示功能时,再次刷新了笔者三观,原来我们以为的必然,在编译器里对应着那么多 “可能”。 当我们遇到一个错误 SQL 时,错误原因往往不止一个,你可以随便截取一段,说是从这一步开始就错了。语法解析器为了让报错符合人们的第一直觉,对错误信息做了 过滤,只保留剩余 Token 数最短的那条错误信息。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 错误提示》 · Issue ##101 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"《手写 SQL 编译器 - 词法分析》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 词法分析》.html","content":"当前期刊数: 64 1 引言因为工作关系,需要开发支持众多方言的 SQL 编辑器,所以复习了一下编译原理相关知识。 相比编译原理专家,我们只需要了解部分编译原理即可实现 SQL 编辑器,所以这是一篇写给前端的编译原理文章。 解析 SQL 可以分为如下四步: 词法分析,将 SQL 字符串拆分成包含关键词识别的字符段(Tokens)。 语法分析,利用自顶向下或自底向上的算法,将 Tokens 解析为 AST,可以手动,也可以自动。 错误检测、恢复、提示推断,都需要利用语法分析产生的 AST。 语义分析,做完这一步就可以执行 SQL 语句了,不过对前端而言,不需要深入到这一步,可以跳过。 2 精读词法分析就像刀削面的过程,拿着一段字符串(面条)一端不断下刀,当面条被切完也就完成了词法分析,所以词法分析是 字符串 -> 一堆字符段 的过程。 流程很简单,难点就在下刀的分寸了,每次砍几厘米呢? 回到词法分析,为了准备切分,我们需要定义 SQL 的 Token 有哪些类型,即 Token 分类。 Token 分类SQL 的 Token 可以分为如下几类: 注释。 关键字(SELECT、CREATE)。 操作符(+、-、>=)。 开闭合标志((、CASE)。 占位符(?)。 空格。 引号包裹的文本、数字、字段。 方言语法(${variable})。 可以看到,在词法分析阶段,我们的 Tokens 不需要关心关键词是什么,只要识别是不是关键词即可,因为关键词的辨认会留到语法分析时处理。涉及到语意处理就要考虑上下文,而这都不是词法分析阶段要考虑的。 同样,操作符、空格、文本、占位符等构成了 SQL 语句的其他部分,最后通过开闭合标志比如左括号和右括号,让 SQL 支持子语句。 再强调一次,虽然 SQL 支持子语句,但并不是放在任何位置都是合理的,其他类型 Token 同理,但是词法分析不需要考虑 Token 是否合理,只要切分即可。 用正则逐段分词像大多数语言一样,SQL 为了方便人类阅读,采用从左到右的书写方式,因此分词方向也从左到右。 我们为每个 Token 类型写一个函数,比如匹配空格的匹配函数: function getTokenWhitespace(restStr: string) { const matches = restStr.match(/^(\\s+)/); if (matches) { return { type, value: matches[1] }; }} restStr 表示掐去头部剩下的 SQL 字符串,所有匹配函数都拿 restStr 进行匹配,已经匹配的不需要再处理。 通过正则 /^(\\s+)/ 匹配到第一个以空格开头的空格(读起来有点别扭),匹配时必须保证以你要匹配的内容开头,而且只匹配一次,这样才不会在切词时发生遗漏。 同理匹配 /**/ 类型注释时,也能通过正则轻而易举的实现: function getTokenBlockComment(restStr: string) { const matches = restStr.match(/^(\\/\\*[^]*?(?:\\*\\/|$))/); if (matches) { return { type, value: matches[1] }; }} 其中 (?:\\*/\\) 表示匹配到以 */ 结尾处,而 (?:\\*\\/|$) 后面的 |$ 表示或者直接匹配到结尾(如果一直没有遇到 */ 那后面全部当作注释)。 所以只要 Token 分类得当,并且能为每一个分类写一个头匹配正则,分词功能就实现了 90%。 方言拓展为了支持某些方言,需要从分词时就开始做考虑。比如 ${variable} 作为一种变量用法时,我们需要在普通字段的正则匹配中,加入一项 \\$\\{[a-zA-Z0-9]+\\} 匹配。 如果要支持纯中文作为字段,可以再补充 |\\u4e00-\\u9fa5。 分词主流程有了一个个分词函数,再补充一个不断匹配、切割字符串、再匹配的主函数即可,这一步更简单: while (sqlStr) { token = getTokenWhitespace(sqlStr, token) || getTokenBlockComment(sqlStr, token); sqlStr = sqlStr.substring(token.value.length); tokens.push(token);} 上面的函数每取一次 Token,都将取到的 Token 长度丢掉,继续匹配剩下的字符串,直到字符串被切分完为止。 有些特殊情况需要拿到上次的 Token 才能判断下一个 Token 该如何切割,所以将 Token 传给每一个下一步 Match 函数。 最后,执行这个主函数,分词就完成了! 3 总结分词比较简单,到这里就全部结束了。后面即将进入深水区语法分析,敬请期待。 4 更多讨论 讨论地址是:精读《手写 SQL 编译器 - 词法分析》 · Issue ##93 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。"},{"title":"查无此人","path":"/wiki/MySql/basics/select.html","content":"基本单字段查询SELECT first_name FROM user; 多字段查询SELECT first_name,gender,class_id... FROM user; 全字段查询 不可定义顺序 且性能不友好 SELECT * FROM user; 条件条件运算符 < > = != <>(官方推荐的不等于) >= <= SELECT name_id FROM user WHERE age>=18; 年龄大于等于18 SELECT name_id FROM user WHERE age<>18; 年龄不等于18 逻辑运算符 && || ! and or not SELECT name_id FROM user WHERE (age>=18 AND age<=65) OR salary>=20000; 年龄18-65,或者工资大于2w 模糊条件 like : 模糊匹配 %任意字符可零 _单个字符 between and : 查询区间范围 包含区间值 in : 查询等于列表的值 不支持模糊匹配 <=> : 安全等于 is null : 不能判断 age=null 只能 age IS NULL 或者 age <=> null is not null SELECT name_id FROM user WHERE full_name LIKE '_柒%'; 全名第二个字是柒,其它随意 SELECT name_id FROM user WHERE age BETWEEN 18 AND 65; 年龄18-65 SELECT name_id FROM user WHERE age IN (18,20,22); 年龄18,20,22 排序 order by 字段(可使用别名) DESC|ASC DESC 降序 ASC 升序(默认可不写) 单个排序SELECT *,salary(IFNULL(salary,0)) AS 年薪 FROM user WHERE age >= 18 ORDER BY 年薪 DESC; 多个排序SELECT *,salary(IFNULL(salary,0)) AS 年薪 FROM user WHERE age >= 18 ORDER BY 年薪 DESC,age ASC; 连接查询 sql-92 标准: 仅支持内连接 sql-99 标准: 内连接+外连接(左外,右外,全外)+交叉连接 - 内连接 A∩B 左外连接 A∩B∪A A为主表 右外连接 A∩B∪B B为主表 全外连接 A∪B 内连接等值连接 两张表意义一样的字段,以此建立等值连接 两张表的交集 n表等值连接至少需要n-1个连接条件 n表顺序没有要求 一般需要取别名 简单使用例: 查询用户对应的全部订单号SELECT custom_name,buy_id FORM customs,buys WHERE customs.buy_id = buys.id 表取别名 提高语句简洁度 区分多个重名字段 如果取别名则不可使用源表名 案例例: 查询每个街道对应的区市省 SELECT p.name 省,ci.name 市,co.name 县,t.name 街道FROM town t,country co,city ci,province p WHERE t.country_id = co.country_id AND co.city_id = ci.city_id AND ci.province_id = p.province_id LIMIT 20; 非等值连接 和等值连接基本一致,条件非等而已,条件取一定关系 连接规则由等号以外的运算符组成。>,=,<,,>=,<=,<>,!=,between等 案例 自连接 类似等值连接,两表有同意字段可当一张表使用 案例 外连接 用于查询主表中有 从表中没有的记录 select 查询列表 from 表1 别名[连接类型]join 表2 别名on 连接条件[where筛选条件][group by. 分组][having筛选条件][order by排序列表] 左外连接 以左边的表为主,查询的数据包括左边表所有的数据以及左右表有交集的数据 还有左表不符合条件的记录,并在右表相应列中填NULL。 left [outer]可省 右外连接 以右边的表为主,查询的数据包括右边表所有的数据以及左右表有交集的数据 还有右表不符合条件的记录,并在左表相应列中填NULL。 right [outer]可省 全连接 AUB 查询出所有单身狗,单身女,和cp组 full [outer]可省 或者 union 左右 达到全外连接 (SELECT * FROM user1 t1) UNION (SELECT * FROM user1_copy t2); -- 注:union会对相同的结果进行去重(SELECT * FROM user1 t1) UNION ALL (SELECT * FROM user1_copy t2); -- 注:union all则查询的是两边全部的数据,不会对数据进行去重 自连接 连接操作不仅可以在两个表之间进行,也可以是一个表与其自己进行连接,成为表的自身连接,也就是所谓的自连接。 **自连接查询其实等同于连接查询,需要两张表,只不过它的左表(父表)和右表(子表)都是自己。做自连接查询的时候,是自己和自己连接,分别给父表和子表取两个不同的别名,然后附上连接条件。** 实例 普通查询SELECT a.*,b.nameFROM SUBJECT a , SUBJECT bWHERE a.`pno`=b.`cno`; 显然没有先行课的被忽略掉了,因此我们可以用左关联结合自连接来查询,便于观察。 自查询SELECT a.*,b.nameFROM SUBJECT a LEFT JOIN SUBJECT bON a.`pno`=b.`cno`; 交叉连接 就是用99标准的语法实现的笛卡尔乘积 cross SELECT b.* ,bo.*FROM beauty bCROSS JOIN boys boON bo.id=b.boyfriend_id; Noteon 和 where条件的区别如下 on条件是在生成临时表时使用的条件,它不管on中的条件是否为真,都会返回左边表中的记录。 where条件是在临时表生成好后,再对临时表进行过滤的条件。 而 inner join(内连接)没这个特殊性,则条件放在on中和where中,返回的结果集是相同的。 等值连接列名相同 使用等值连接 使用using"},{"title":"《设计模式 - Bridge 桥接模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Bridge 桥接模式》.html","content":"当前期刊数: 173 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 能力隔离、将组件功能与平台功能隔离,只有做到了抽象部分与实现部分的隔离,才可以通过组合满足更多场景。 结构图 Abstraction:定义抽象类的接口。 RefinedAbstraction:扩充 Abstraction。 Implementor:定义实现类的接口,该接口可以与 Abstraction 接口不一致。 ConcreteImplementor:实现 Implementor 接口并定义它的具体实现。 抽象部分就是 Abstraction,实现部分就是 Implementor,在这个结构图中,它们是分离的,可以各自独立变化的,桥接模式,就是指 imp 这个桥,通过 Implementor 实现 Abstraction 接口,就算是桥接上了,这种组合的桥接相比普通的类实现更灵活,更具有拓展性。 代码例子对于完全版桥接模式,Implementor 可以有多套实现,Abstraction 不需关心具体用的是哪一种实现,而是通过抽象工厂方式封装。下面举一个简单版的例子。 下面例子使用 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 也可以利用其基础能力画线了。 弊端不要过度抽象,桥接模式是为了让类的职责更单一,维护更便捷,但如果只是个小型项目,桥接模式会增加架构设计的复杂度,而且不正确的模块拆分,把本来关联的逻辑强制解耦,在未来会导致更大的问题。 另外桥接模式也有简单与复杂模式之分,只有一种实现的场景就不要用抽象工厂做过度封装了。 总结桥接模式让我们重新审视类的设计是否合理,把类中不相关,或者说相互独立的维度抽出去,由桥接模式做桥接的方式使用,这样会使每个类功能更内聚,代码量更少更清晰,组合能力更强大,更容易做拓展。 下图做了一个简单的解释: 讨论地址是:精读《设计模式 - Bridge 桥接模式》· Issue ##280 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Abstract Factory 抽象工厂》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Abstract Factory 抽象工厂》.html","content":"当前期刊数: 167 Abstract Factory(抽象工厂)Abstract Factory(抽象工厂)属于创建型模式,工厂类模式抽象程度从低到高分为:简单工厂模式 -> 工厂模式 -> 抽象工厂模式。 意图:提供一个接口以创建一系列相关或相互依赖的对象,而无须指定它们具体的类。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 汽车工厂我们都知道汽车有很多零部件,随着工业革命带来的分工,很多零件都可以被轻松替换。但实际生活中我们消费者不愿意这样,我们希望买来的宝马车所包含的零部件都是同一系列的,以保证最大的匹配度,从而带来更好的性能与舒适度。 所以消费者不愿意到轮胎工厂、方向盘工厂、车窗工厂去一个个采购,而是将需求提给了宝马工厂这家抽象工厂,由这家工厂负责组装。那你是这家工厂的老板,已知汽车的组成部件是固定的,只是不同配件有不同的型号,分别来自不同的制造厂商,你需要推出几款不同组合的车型来满足不同价位的消费者,你会怎么设计? 迷宫游戏你做一款迷宫游戏,已知元素有房间、门、墙,他们之间的组合关系是固定的,你通过一套算法生成随机迷宫,这套算法调用房间、门、墙的工厂生成对应的实例。但随着新资料片的放出,你需要生成具有新功能的房间(可以回复体力)、新功能的门(需要魔法钥匙才能打开)、新功能的墙(可以被炸弹破坏),但修改已有的迷宫生成算法违背了开闭原则(需要在已有对象进行修改),如果你希望生成迷宫的算法完全不感知新材料的存在,你会怎么设计? 事件联动假设我们做一个前端搭建引擎,现在希望做一套关联机制,以实现点击表格组件单元格,可以弹出一个模态框,内部展示一个折线图。已知业务方存在定制表格组件、模态框组件、折线图组件的需求,但组件之间联动关系是确定的,你会怎么设计? 意图解释在汽车工厂的例子中,我们已知车子的构成部件,为了组装成一辆车子,需要以一定方式拼装部件,而具体用什么部件是需要可拓展的。 在迷宫游戏的例子中,我们已知迷宫的组成部分是房间、门、墙,为了生成一个迷宫,需要以某种算法生成许多房间、门、墙的实例,而具体用哪种房间、哪种门、哪种墙是这个算法不关心的,是需要可被拓展的。 在事件联动的例子中,我们已知这个表格弹出趋势图的交互场景基本组成元素是表格组件、模态框组件、折线图组件,需要以某种联动机制让这三者间产生联动关系,而具体是什么表格、什么模态框组件、什么折线图组件是这个事件联动所不关心的,是需要可以被拓展的,表格可以被替换为任意业务方注册的表格,只要满足点击 onClick 机制就可以。 意图:提供一个接口以创建一系列相关或相互依赖的对象,而无须指定它们具体的类。 这三个例子不正是符合上面的意图吗?我们要设计的抽象工厂就是要 创建一系列相关或相互依赖的对象,在上面的例子中分别是汽车的组成配件、迷宫游戏的素材、事件联动的组件。而无须指定它们具体的类,也就说明了我们不关心车子方向盘用的是什么牌子,迷宫的房间是不是普通房间,联动机制的折线图是不是用 Echarts 画的,我们只要描述好他们之间的关系即可,这带来的好处是,未来我们拓展新的方向盘、新的房间、新的折线图时,不需要修改抽象工厂。 结构图 AbstractFactory 就是我们要的抽象工厂,描述了创建产品的抽象关系,比如描述迷宫如何生成,表格和趋势图怎么联动。 至于具体用什么方向盘、用什么房间,是由 ConcreteFactory 实现的,所以我们可能有多个 ConcreteFactory,比如 ConcreteFactory1 实例化的墙壁是普通墙壁,ConcreteFactory2 实例化的墙壁是魔法墙壁,但其对 AbstractFactory 的接口是一致的,所以 AbstractFactory 不需要关心具体调用的是哪一个工厂。 AbstractProduct 是产品抽象类,描述了比如方向盘、墙壁、折线图的创建方法,而 ConcreteProduct 是具体实现产品的方法,比如 ConcreteProduct1 创建的表格是用 canvas 画的,折线图是用 G2 画的,而 ConcreteProduct2 创建的表格是用 div 画的,折线图是用 Echarts 画的。 这样,当我们要拓展一个用 Echarts 画的折线图,用 svg 画的表格,用 div 画的模态框组成的事件机制时,只需要再创建一个 ConcreteFactory3 做相应的实现即可,再将这个 ConcreteFactory3 传递给 AbstractFactory,并不需要修改 AbstractFactory 方法本身。 代码例子下面例子使用 javascript 编写。 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 新增每一个元素,还要修改抽象工厂,以将新元素与旧元素间建立联系,违背了开闭原则。 因此,对于已有元素固定的系统,适合使用抽象工厂,反之不然。 总结抽象工厂对新增已有产品的实现适用,对新增一个产品种类不适用,可以参考结合了例子的下图加深理解: 拓展一个熔岩素材包是 增加一种产品风格,适合使用抽象工厂设计模式;拓展一个陷阱是 增加一个产品种类,不适合使用抽象工厂设计模式。为什么呢?看下图: 创建迷宫这个抽象工厂做的事情,是把已有的房间、门、墙壁建立关联,因为操作的是抽象类,所以拓展一套具体实现(熔岩素材包)对这个抽象工厂没有感知,这样做很容易。 但如果新增一个产品种类 - 陷阱,可以看到,抽象工厂必须将陷阱与前三者重新建立关联,这就要修改抽象工厂,不符合开闭原则。同时,如果我们已有素材包 1 ~素材包 999,就需要同时增加 999 个对应的陷阱实现(普通陷阱、魔法陷阱、熔岩陷阱),其工作量会非常大。 因此,只有产品种类稳定时,需要频繁拓展产品风格时才适合用抽象工厂设计模式。 讨论地址是:精读《设计模式 - Abstract Factory 抽象工厂》· Issue ##271 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Builder 生成器》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Builder 生成器》.html","content":"当前期刊数: 168 Builder(生成器)Builder(生成器)属于创建型模式,针对的是单个复杂对象的创建。 意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 搭乐高积木乐高积木是很典型的随机拼装场景,你有很多乐高积木,要搭一个小房子都太复杂了,可能不得不看着说明书一步步操作,这就像创建一个复杂的对象,要传入非常多的参数,而且顺序还不能错。 如果不考虑拼装乐高过程中的乐趣,你只是想快速得到一个标准的房子,怎么样才可以最快最省事? 工厂流水线制作一个罐头要经历许多步骤,而其中一些步骤比如制作罐头是通用的,可以用这个罐头装很多东西,比如红枣罐头、黄桃罐头,那工厂流水线是怎么做到灵活可拓展的呢? 创建数据库连接池建立一个数据库连接池,我们需要传入数据库的地址、用户名与密码、还有要创建多少大小的连接池,缓存的位置等等。 考虑到数据库必须正确连接后才有效,创建时必须校验传入的数据库地址与密码的正确性,甚至存储方式与数据库类型还有关系,这是一个简单的 new 实例化可以解决的吗? 意图解释在乐高积木的例子中,我们为了得到一个房子其实不需要关心每一个积木应该如何摆放,我们只要交给组装工厂(一个人或者一个程序)产出标准房子就行了,这其中参数可能是 .setHouseType().build() 设置房屋类型,而不需要 new House(block1, block2, ... block999) 传递这些没必要的参数。其中组装工厂就是生成器。 在工厂流水线的例子中,流水线就是生成器,一个流水线可以不通过不同组合生成不同作用的工厂,黄桃罐头的流水线可以理解为 new Builder().组装罐头().放入黄桃().build(),红枣罐头的流水线可以理解为 new Builder().组装罐头().放入红枣().build(),我们可以复用生成器最基础的函数 组装罐头() 将其用于创建不同的产品中,复用了组装基础能力。 在创建数据库例子中,我们可以先设置一些必要的参数再创建,比如 new Builder().setUrl().setPassword().setType().build(),这样在最终执行 build 函数的时候,可以对参数中存在关联的进行校验,而得到的对象也无法再被修改,这样比直接暴露数据库连接池对象,再一个值一个值 Set 多了如下好处: 对象无法被修改,保护了程序稳定性,减少了维护复杂度。 可以对参数关联进行一次性校验。 在创建对象之前不会存在中间态,即创建了对象实例,但缺少部分参数,这可能导致对象无法正确 work。 意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。 我们再理解一次意图,所谓构建与表示分离,就是指一个对象 Person 并不是简单的 new Person() 就可以实例化出来的,如果可以,那就是构建与表示一体。所谓构建与表示分离,就是指 Person 只能描述,而不能通过 new Person() 实例化,将实例化工作通过 Builder 实现,这样同样一个构建过程可以创建不同的 Person 实例。 在乐高积木的例子中,通过乐高创建的房子并不是 new House() 出来,而是将构建与表示分离了,工厂流水线中我们创建一个黄桃罐头,不是通过 new 黄桃罐头(),而是通过流水线不同拼装方式来完成,在数据库例子中,我们没有通过 new DB() 的方式创建数据库,而是通过 Builder 来创建,这都体现了构建与表示的分离。 结构图 Director 指导器,用来指导构建过程。 Builder 生成器接口,用来提供一系列构建对象的方法,以及最终的 build 生成对象函数,这个函数里可以做一些参数校验。 ConcreteBuilder 是 Builder 的具体实现。 实际上,Builder 模式抽象层次可高可低,我们上面三个例子都没有用到指导器与生成器接口,这是因为在代码不太复杂的情况下,可以使用简化模型。 代码例子下面例子使用 javascript 编写。 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,只要两者都严格按照接口实现,我们可以: 替换任意 Director,使创建的过程做任意修改。 替换任意 Builder,使创建的实现做任意修改。 做了任意的改动,都可以得到不同的房子实现,这就是创建与表示分离的好处,我们可以通过同样的构建过程创建不同的表示。 这个 director.create(): 在搭乐高积木的例子,表示用乐高搭建房屋的过程。 在工程流水线的例子,表示罐头的组装构成。 在创建数据库连接池的例子,表示数据库连接池的创建过程。 而 Builder 以及其函数 buildA buildB 等方法表示具体制造方法,比如: 在搭乐高积木的例子,表示如何盖房子,如何刷油漆。 在工程流水线的例子,表示如何做一个罐头,如何添加黄桃。 在创建数据库连接池的例子,表示如何设置数据库地址,如何设置用户名密码等。 对于数据库的例子中,我们不仅可以保证创建对象的便捷性,因为不需要传入过多参数,也保证了对象的正确校验,同时生成的实例也是不可变的。 更重要的是,如果使用完整模式,我们可以替换 Director 来修改创建数据库的方式,替换 Builder 来修改具体方法,比如 .setUserName 这个函数不做具体实现,而是统计性能,build() 函数创建的不是一个数据库连接实例,而是一个测试实例。 再比如前端同一个方法在 JS 和 Node 环境下运行效果不一样,我们可以实现 BrowserBuild 与 NodeBuild,实现相同的接口,这样可以共享相同的创建过程,创建不同环境可以运行的实例。 可以看到,使用 Builder 模式可以保证创建对象的便捷与稳定性,还留了足够的拓展空间改变对象的创建过程与创建方法,具有极强的拓展性。 弊端任何设计模式都有其适用场景,反过来也说明了在某些场景下不适用。 实例化对象非常繁琐,重复定义了许多对象成员变量的 set 方法,而且也不如 new 看的直观,也就是场景足够简单时,不需要任何地方都用 Builder 实例化对象。 一个对象只有一种表示时,没必要做如此地步的抽象。 上面的例子都是相对复杂的,假设我们的搭房子的例子中,我们不是用乐高积木搭建,而是用两块半成品模板拼起来就得到一个房子,那就没有必要使用 Builder 模式,直接 new House() 即可。 再者,如果我们只需要生产各种罐头,而不需要生产汽车,那么就没必要过度抽象 Builder,把创建汽车的方法也囊括进去,最后,如果我们的对象只有一种表示时,没有必要抽象 Builder,也就是流水线如果只生产黄桃罐头,就没必要把各个生产环节变成可拆卸的,因为也没有重新组合的需要。 总结Builder 模式对于创建一个复杂对象特别有用,可以看下图加深理解: 最后总结一下何时适合用 Builder 模式:只有当创建过程允许被构造对象有不同表示,或者对象复杂到对象描述与创建对象过程值得分离时,才使用 Builder 设计模式。 讨论地址是:精读《设计模式 - Builder 生成器》· Issue ##273 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Adapter 适配器模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Adapter 适配器模式》.html","content":"当前期刊数: 172 Adapter(适配器模式)Adapter(适配器模式)属于结构型模式,别名 wrapper,结构性模式关注的是如何组合类与对象,以获得更大的结构,我们平常工作大部分时间都在与这种设计模式打交道。 意图:将一个类的接口转换成客户希望的另一个接口。Adapter 模式使得原本由于接口不兼容而不能在一起工作的那些类可以一起工作。 这个设计模式的意图很好懂,就是把接口不兼容问题抹平。注意,也仅仅能解决接口不一致的问题,而不能解决功能不一致的问题。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 接口转换器插座的种类很多,我们都用过许多适配器,将不同的插头进行转换,可以在不替换插座的情况下正常使用。 USB 接口转换也同样精彩,有将 TypeC 接口转换为 TypeA 的,也有将 TypeA 接口转换为 TypeC 的,支持双向转换。 接口转换器就是我们在生活中使用到的适配器模式,因为厂商并没有生产一个新的插座,我们也没有因为接口不适配而换一个手机,一切只需要一个接口转换器即可,这就是运用设计模式的收益。 数据库 ORMORM 屏蔽了 SQL 这一层,带来的好处是不需要理解不同 SQL 语法之间的区别,对于通用功能,ORM 会根据不同的平台,比如 Postgresql、Mysql 进行 SQL 的转换。 对 ORM 来说,屏蔽不同平台的差异,就是利用适配器模式做到的。 API Deprecated当一个广泛使用的库进行了含有 break change 的升级时,往往要留给开发者足够的时间去升级,而不能升级后就直接挂掉,因此被废弃的 API 要标记为 deprecated,而这种被废弃标记的 API 的实际实现,往往是使用新的 API 替代,这种场景正是使用了适配器模式,将新的 API 适配到旧的 API,实现 API Deprecated。 意图解释上面三个例子都满足下面两个条件: API 不兼容:因为接口的不同;数据库 SQL 语法的不同;框架 API 的不同。 但能力已支持:插座都拥有充电或读取能力;不同的 SQL 都拥有查询数据库能力;新 API 覆盖了旧 API 的能力。 这样就可以通过适配器满足 Adapter 的意图: 意图:将一个类的接口转换成客户希望的另一个接口。Adapter 模式使得原本由于接口不兼容而不能在一起工作的那些类可以一起工作。 结构图适配器的实现分为继承与组合模式。 下面是名词解释: Adapter 适配器,把 Adeptee 适配成 Target。 Adaptee 被适配的内容,比如不兼容的接口。 Target 适配为的内容,比如需要用的接口。 继承: 适配器继承 Adaptee 并实现 Target,适用场景是 Adaptee 与 Target 结构类似的情况,因为这样只需要实现部分差异化即可。 组合: 组合的拓展性更强,但工作量更大,如果 Target 与 Adaptee 结构差异较大,适合用组合模式。 代码例子下面例子使用 typescript 编写。 继承: interface ITarget { // 标准方式是 hello hello: () => void}class Adaptee { // 要被适配的类方法叫 sayHello sayHello() { console.log('hello') }}// 适配器继承 Adaptee 并实现 ITargetclass Adapter extends Adaptee implements ITarget { hello() { // 用 sayHello 对接到 hello super.sayHello() }} 组合: interface ITarget { // 标准方式是 hello hello: () => void}class Adaptee { // 要被适配的类方法叫 sayHello sayHello() { console.log('hello') }}// 适配器继承 Adaptee 并实现 ITargetclass Adapter implements ITarget { private adaptee: Adaptee constructor(adaptee: Adaptee) { this.adaptee = adaptee } hello() { // 用 adaptee.sayHello 对接到 hello this.adaptee.sayHello() }} 弊端使用适配器模式本身就可能是个问题,因为一个好的系统内部不应该做任何侨界,模型应该保持一致性。只有在如下情况才考虑使用适配器模式: 新老系统接替,改造成本非常高。 三方包适配。 新旧 API 兼容。 统一多个类的接口。一般可以结合工厂方法使用。 总结适配器模式也符合开闭原则,在不对原有对象改造的前提下,构造一个适配器就能完成模块衔接。 适配器模式的实现分为类与对象模式,类模式用继承,对象模式用组合,分别适用于 Adaptee 与 Target 结构相似与结构差异较大的场景,在任何情况下,组合模式都是灵活性最高的。 最后用一张图概括一下适配器模式的思维: 讨论地址是:精读《设计模式 - Adapter 适配器模式》· Issue ##279 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《手写 SQL 编译器 - 智能提示》","path":"/wiki/WebWeekly/编译原理/《手写 SQL 编译器 - 智能提示》.html","content":"当前期刊数: 85 1 引言词法、语法、语义分析概念都属于编译原理的前端领域,而这次的目的是做 具备完善语法提示的 SQL 编辑器,只需用到编译原理的前端部分。 经过连续几期的介绍,《手写 SQL 编译器》系列进入了 “智能提示” 模块,前几期从 词法到文法、语法,再到构造语法树,错误提示等等,都是为 “智能提示” 做准备。 由于智能提示需要对词法分析、语法分析做深度定制,所以我们没有使用 antlr4 等语法分析器生成工具,而是创造了一个 JS 版语法分析生成器 syntax-parser。 这次一口气讲完如何从 syntax-parser 到做一个具有智能提示功能的 SQL 编辑器。 2 精读从语法解析、智能提示和 SQL 编辑器封装三个层次来介绍,这三个层次就像俄罗斯套娃一样具有层层递进的关系。 为了更清晰展现逻辑层次,同时满足解耦的要求,笔者先从智能提示整体设计架构讲起。 智能提示的架构syntax-parser 是一个 JS 版的语法分析器生成器,除了类似 antlr4 基本语法分析功能外,还支持专门为智能提示优化的功能,后面会详细介绍。整体架构设计如下图所示: 首先需要实现 SQL 语法,我们利用语法分析器生成器 syntax-parser,生成一个 SQL 语法分析器,这一步其实是利用 syntax-parser 能力完成了 sql lexer 与 sql parser。 为了解析语法树含义,我们需要在 sql parser 基础之上编写一套 sql reader,包含了一些分析函数解析语法树的语义。 利用 monaco-editor 生态,利用 sql reader 封装 monaco-editor 插件,同时实现 用户 <=> 编辑器 间的交互,与 编辑器 <=> 语义分析器 间的交互。 语法解析器syntax-parser 分为词法分析、语法分析两步。词法分析主要利用正则构造一个有穷自动机,大家都学过的 “编译原理” 里有更完整的解读,或者移步 精读《手写 SQL 编译器 - 词法分析》,这里主要介绍语法分析。 词法分析的输入是语法分析输出的 Tokens。Tokens 就是一个个单词,Token 结构存储了单词的值、位置、类型。 我们需要构造一个执行链条消费这些 Token,也就是可以执行文法扫描的程序。我们用四种类型节点描述文法,如下图所示: 如果不了解文法概念,可以阅读 精读《手写 SQL 编译器 - 文法介绍》 能消耗 Token 的只有 MatchNode 节点,ChainNode 节点描述先后关系(比如 expr -> name id),TreeNode 节点描述并列关系(比如 factor -> num | id),FunctionNode 是函数节点,表示还未展开的节点(如果把文法匹配比做迷宫探险,那这是个无限迷宫,无法穷尽展开)。 如何用 syntax-parser 描述一个文法,可以访问文档,现在我们已经描述了一个文法树,应该如何解析呢? 我们先找到一个非终结符作为根节点,深度遍历所有非终结符节点,遇到 MatchNode 时如果匹配,就消耗一个 Token 并继续前进,否则文法匹配失败。 遇到 ChainNode 会按照顺序执行其子节点;遇到 FunctionNode(非终结符节点)会执行这个函数,转换为一个非 FunctionNode 节点,如下图所示: 遇到 TreeNode 节点时保存这个节点运行状态并继续执行,在 MatchNode 匹配失败时可以还原到此节点继续尝试下个节点,如下图所示: 这样就具备了最基本的语法分析功能,如需更详细阅读,可以移步 精读《手写 SQL 编译器 - 语法分析》。 我们还做了一些优化,比如 First 集优化与路径缓存优化。限于篇幅,分布在以下几篇文章: 精读《手写 SQL 编译器 - 回溯》 精读《手写 SQL 编译器 - 语法树》 精读《手写 SQL 编译器 - 错误提示》 精读《手写 SQL 编译器 - 性能优化之缓存》 SQL 编辑器重点在于如何做输入提示,也就是如何在用户光标位置给出恰当的提示。这就是我们定制 SQL 编辑器的原因,输入提示与语法检测需要分开来做,而语法树并不能很好解决输入提示的问题。 智能提示为了找到一个较为完美的语法提示方案,通过查阅大量资料,我决定将光标作为一个 Token 考虑来实现智能提示。 思考我们用 | 表示光标所在位置,那么下面的 SQL 应该如何处理? select | from b; 从语法角度来看,它是错的,因为实际上是一个不完整语句 “select from b;” 从提示角度来看,它是对的,因为这是一个正确的输入过程,光标位置再输入一个单词就正确了。 你会发现,从语法和提示角度来看同一个输入,结果往往是矛盾的,所以我们需要分两条线程分别处理语法与提示。 但输入错误时,我们是无法构造语法树的,而智能提示的时机往往都是语句语法错误的时机,用过 AST 工具的人都知道。可是没有语法树,我们怎么做到智能的提示呢?试想如下语句: select c.| from ( select * from dt;) c; 面对上面这个语句,很显然 c. 没有写完,一般的语法树解析器提示你语法错误。你可能想到这几种方案: 字符串匹配方式强行提示。但很显然这样提示不准确,没有完整语法树,是无法做精确解析的。而且当语法复杂时,字符串解析方案几乎无从下手。 把光标位置用一个特殊的字符串补上,先构造一个临时正确的语句,生成 AST 后再找到光标位置。 一般我们会采取第二种方案,看上去相对靠谱。处理过程是这样的: select c.$my_custom_symbol$ from ... 之后在 AST 中找到 $my_custom_symbol$ 字符串,对应的节点就是光标位置。实际上这可以解决大部分问题,除了关键字。 这种方案唯有关键字场景不兼容,试想一下: select a |from b;## select a $my_custom_symbol$ from b; 你会发现,“补全光标文字” 法,在关键字位置时,会把原本正确的语句变成错误的语句,根本解析不出语法树。 我们在 syntax-parser 解析引擎层就解决了这个问题,解决方案是 连同光标位置一起解析。 两个假设我们做两个基本假设: 需要自动补全的位置分为 “关键字” 与 “非关键字”。 “非关键字” 位置基本都是由字符串构成的。 关键字: 因此针对第一种假设,syntax-parser 内置了 “关键字提示” 功能。因为 syntax-parser 可以拿到你配置的文法,因此当给定光标位置时,可以拿到当前位置前一个 Token,通过回溯和平行尝试,将后面所有可能性提示出来,如下图: 输入是 select a |,灰色部分是已经匹配成功的部分,而我们发现光标位置前一个 Token 正是红色标识的 word,通过尝试运行推导,我们发现,桔红色标记的 ',' 和 'from' 都是 word 可能的下一个确定单词,这种单词就是 SQL 语法中的 “关键字”,syntax-parser 会自动告诉你,光标位置可能的输入是 [',', 'from']。 所以关键字的提示已经在 syntax-parser 层内置解决了!而且无论语法正确与否,都不影响提示结果,因为算法是 “寻找光标位置前一个 Token 所有可能的下一个 Token”,这可以完全由词法分析器内置支持。 非关键字: 针对非关键字,我们解决方案和用特殊字符串补充类似,但也有不同: 在光标位置插入一个新 Token,这个 Token 类型是特殊的 “光标类型”。 在 word 解析函数加一个特殊判断,如果读到 “光标类型” Token,也算成功解析,且消耗 Token。 因此 syntax-parser 总是返回两个 AST 信息: { "ast": {}, "cursorPath": []} 分别是语法树详细信息,与光标位置在语法树中的访问路径。 对于 select a | 的情况,会生成三个 Tokens:['select', 'a', 'cursor'],对于 select a| 的情况,会生成两个 Tokens:['select', 'a'],也就是光标与字符相连时,不会覆盖这个字符。 cursorPath 的生成也比 “字符串补充” 方案更健壮,syntax-parser 生成的 AST 会记录每一个 Token 的位置,最终会根据光标位置进行比对,进而找到光标对应语法树上哪个节点。 对 .| 的处理: 可能你已经想到了,.| 情况是很通用的输入场景,比如 user. 希望提示出 user 对象的成员函数,或者 SQL 语句表名存在项目空间的情况,可能 tableName 会存在 .| 的语法。 .| 状况时,语法是错误的,此时智能提示会遇到挑战。根据查阅的资料,这块也有两种常见处理手法: 在 . 位置加上特殊标识,让语法解析器可以正确解析出语法树。 抹去 .,先让语法正确解析,再分析语法树拿到 . 前面 Token 的属性,推导出后面的属性。 然而这两种方式都不太优雅,syntax-parser 选择了第三种方式:隔空打牛。 通过抽象,我们发现,无论是 user.name 还是 udf:count() 这种语法,都要求在某个制定字符打出时(比如 . 或 :),提示到这个字符后面跟着的 Token。 此时光标焦点在 . 而非之后的字符上,那我们何不将光标偷偷移到 . 之后,进行空光标 Token 补位呢!这样不但能完全复用之前的处理思想,还可以拿到我们真正想拿到的位置: select a(.|) from b;## select a. (|) from b 对比后发现,第一行拥有 4 个 Token,语法错误,而经过修改的第二行拥有 5 个 Token(一个光标补位),语法正确,且光标所在位置等价于第一行我们希望提示的位置,此问题得以解决。 SQL 编辑器封装我们拥有了内置 “智能提示” 功能的语法解析器,定制了一套自定义的 SQL 词法、文法描述,便完成了 sql-lexer 与 sql-parser 这一层。由于 SQL 文法完善工作非常庞大,且需要持续推进,这里举流计算中,申明动态维表的例子: CREATE TABLE dwd_log_pv_wl_ri( PRIMARY KEY(rowkey), PERIOD FOR SYSTEM_TIME) WITH () 要支持这种语法,我们在非终结符 tableOption 下增加两个分支即可: const tableOption = () => chain([ chain(stringOrWord, dataType)(), chain("primary", "key", "(", primaryKeyList, ")")(), chain("period", "for", "system_time")() ])(); sql-reader: 为了方便解析 SQL 语法树,我们在 sql-reader 内置了几个常用方法,比如: 找到距离光标位置最近的父节点。比如 select a, b, | from d 会找到这个 selectStatement。 根据表源找到所有提供的字段。表源是指 from 之后跟的语法,不但要考虑嵌套场景,别名,分组,方言,还要追溯每个字段来源于哪张表(针对 join 或 union 的情况)。 有了 sql-reader,我们可以保证在这种层层嵌套 + 别名混淆 + select * 这种复杂的场景下,仍然能追溯到字段的最原始名称,最原始的表名: 这样上层业务拓展时,可以拿到足够准、足够多的信息,具有足够好的拓展型。 monaco-editor plugin: 我们也支持了更上层的封装,Monaco Editor 插件级别的,只需要填一些参数:获取表名、获取字段的回调函数就能 Work,统一了内部业务的调用方式: import { monacoSqlAutocomplete } from '@alife/monaco-sql-plugin';// Get monaco and editor.monacoSqlAutocomplete(monaco, editor, { onInputTableField: async tableName => { // ...}, onInputTableName: async () => { // ... }, onInputFunctionName: async () => { // ... }, onHoverTableName: async cursorInfo => { // ... }, onHoverTableField: (fieldName, extra) => { // ... }, onHoverFunctionName: functionName => { // ... }}); 比如实现了 onInputTableField 接口,我们可以拿到当前表名信息,轻松实现字段提示: 你也许会看到,上图中鼠标位置有错误提示(红色波浪线),但依然给出了正确的推荐提示。这得益于我们对 syntax-parser 内部机制的优化,将语法检查与智能提示分为两个模块独立处理,经过语法解析,虽然抛出了语法错误,但因为有了光标的加入,最终生成了语法树。 再比如实现了 onHoverFunctionName,可以自定义鼠标 hover 在函数时的提示信息: 得益于 sql-reader,我们对 sql 语句做了层层解析,所以才能把自动提示做到极致。比如在做字段自动提示时,经历了如下判断步骤: 而你只需要实现 onInputTableField,告诉程序每个表可以提供哪些字段,整个流程就会严格的层层检查表名提供对原始字段与 selectList 描述的输出字段,找到映射关系并逐级传递、校验,最终 Merge 后一直冒泡到当前光标位置所在语句,形成输入建议。 4 总结整个智能提示的封装链条如下: syntax-parser -> sql-parser -> monaco-editor-plugin 对应关系是: 语法解析器生成器 -> SQL 语法解析器 -> 编辑器插件 这样逻辑层次清晰,解耦,而且可以从任意节点切入,进行自定义,比如: 从 syntax-parser 开始使用 从最底层开始使用,也许有两个目的: 上层封装的 sql-parser 不够好用,我重写一个 sql-parser’ 以及 monaco-editor-plugin’。 我的场景不是 SQL,而是流程图语法、或 Markdown 语法的自动提示。 针对这种情况,首先将目标文法找到,转化成 syntax-parser 的语法,比如: chain(word, "=>", word); 再仿照 sql-parser -> monaco-editor-plugin 的结构把上层封装依次实现。 从 sql-parser 开始使用 也许你需要的仅仅是一颗 SQL 语法树?或者你的输出目标不是 SQL 编辑器而是一个 UI 界面?那可以试试直接使用 sql-parser。 sql-parser 不仅可以生成语法树,还能找到当前光标位置所在语法树的节点,找到 SQL 某个语法返回的所有字段列表等功能,基于它,甚至可以做 UI 与 SQL 文本互转的应用。 从 monaco-editor-plugin 开始使用 也许你需要支持自动提示的 SQL 编辑器,那太棒了,直接用 monaco-editor-plugin 吧,根据你的业务场景或个人喜好,实现一个定制的 monaco-editor 交互插件。 目前我们只开源最底层的 syntax-parser,这也是业务无关的语法解析引擎生成器,期待您的使用与建议! 讨论地址是:精读《手写 SQL 编译器 - 智能提示》 · Issue ##118 · dt-fe/weekly 如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。"},{"title":"《设计模式 - Chain of Responsibility 职责链模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Chain of Responsibility 职责链模式》.html","content":"当前期刊数: 179 Chain of Responsibility(职责链模式)Chain of Responsibility(职责链模式)属于行为型模式。行为型模式不仅描述对象或类的模式,还描述它们之间的通信模式,比如对操作的处理应该如何传递等等。 意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。 几乎所有设计模式,在了解到它之前,笔者就已经在实战中遇到过了,因此设计模式的确是从实践中得出的真知。但另一方面,如果没有实战的理解,单看设计模式是枯燥的,而且难以理解的,因此大家学习设计模式时,要结合实际问题思考。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 中间件机制设想我们要为一个后端框架实现中间件(知道 Koa 的同学可以理解为 Koa 的洋葱模型),在代码中可以插入任意多个中间件,每个中间件都可以对请求与响应进行处理。 由于每个中间件只响应自己感兴趣的请求,因此只有运行时才知道这个中间件是否会处理请求,那么中间件机制应该如何设计,才能保证其功能和灵活性呢? 通用帮助文案如果一个大型系统中,任何一个模块点击都会弹出帮助文案,但并不是每个模块都有帮助文案的,如果一个模块没有帮助文案,则显示其父级的帮助文案,如果再没有,就继续冒泡到整个应用,展示应用级别的兜底帮助文案。这种系统应该如何设计? JS 事件冒泡机制其实 JS 事件冒泡机制就是个典型的职责链模式,因为任何 DOM 元素都可以监听比如 onClick,不仅可以自己响应事件,还可以使用 event.stopPropagation() 阻止继续冒泡。 意图解释JS 事件冒泡机制对前端来说太常见了,但我们换个角度,站在点击事件的角度理解,就能重新发现其设计的精妙之处: 点击事件是叠加在每层 dom 上的,由于 dom 对事件的处理和绑定是动态的,浏览器本身不知道哪些地方会处理点击事件,但又要让每层 dom 拥有对点击事件的 “平等处理权”,所以就产生了冒泡机制,与事件阻止冒泡功能。 通用帮助文案和 JS 事件冒泡很类似,只是把点击事件换成了弹出帮助文案罢了,其场景机理是一样的。 说到这,我们可以再重新理解一下职责链模式的意图: 意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。 请求指的是某个触发机制产生的请求,是一个通用概念。“避免请求的发送者和接收者之间的耦合关系”,指的是如果我们只有一个对象有处理请求的机会,那接收者就与发送者之间耦合了,其他接收者必须通过这个接收者才能继续处理,这种模式不够灵活。 后半句描述的是如何设计,可以实现这个灵活的模式,即将对象连成一条链,沿着链条传递该请求,直到有一个对象处理它为止。还要理解到,任何一个对象都拥有阻断请求继续传递的能力。 在中间件机制的例子中,后端 Web 框架对 Http 请求的处理就是个运用职责链模式的典型案例,因为后端框架要处理的请求是平行关系,任何请求都可能要求被响应,但对请求的处理是通过插件机制拓展的,且对每个请求的处理都是一个链条,存在处理、加工、再处理的逻辑关系。 结构图 Handler 就是对请求的处理,可以看到这里是一条环路,只要处理完之后就可以交给下一个 Handler 进行处理,可以在中途拦截后中断,也可以穿透整条链路。 ConcreteHandler 是具体 Handler 的实现,他们都需要继承 Handler 以具备相同的 HandleRequest 方法,这样每一个处理中间件就都拥有了处理能力,使得这些对象连成的链条可以对请求进行传递。 代码例子职责链实现方式非常多,比如 Koa 的洋葱模型实现原理就值得再写一篇文章,感兴趣的同学可以阅读 co 源码。这里仅介绍最简单场景的实现方案。 职责链的简单实现模式也分为两种,一种是每个对象本身维护到下一个对象的引用,另一种是由 Handler 维护后继者。 下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Command 命令模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Command 命令模式》.html","content":"当前期刊数: 180 Command(命令模式)Command(命令模式)属于行为型模式。 意图:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 点菜是命令模式为什么顾客会找服务员点菜,而不是直接冲到后厨盯着厨师做菜?因为做菜比较慢,肯定会出现排队的现象,而且有些菜可能是一起做效率更高,所以将点菜和做菜分离比较容易控制整体效率。 其实这个社会现象就对应编程领域的命令模式:点菜就是一个个请求,点菜员记录的菜单就是将请求生成的对象,点菜员不需要关心怎么做菜、谁来做,他只要把菜单传到后厨即可,由后厨统一调度。 大型软件系统的操作菜单大型软件操作系统都有一个特点,即软件非常复杂,菜单按钮非常多。但由于菜单按钮本身并没有业务逻辑,所以通过菜单按钮点击后触发的业务行为不适合由菜单按钮完成,此时可利用命令模式生成一个或一系列指令,由软件系统的实现部分来真正执行。 浏览器请求排队浏览器的请求不仅会排队,还会取消、重试,因此是个典型的命令模式场景。如果不能将 window.fetch 序列化为一个个指令放入到队列中,是无法实现请求排队、取消、重试的。 意图解释意图:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。 一个请求指的是来自客户端的一个操作,比如菜单按钮点击。重点在点击后并不直接实现,而是将请求封装为一个对象,可以理解为从直接实现: function onClick() { // ... balabala 实现逻辑} 改为生成一个对象,序列化这个请求: function onClick() { concreteCommand.push({ // ... 描述这个请求 }) // 执行所有命令队列 concreteCommand.executeAll()} 看上去繁琐了一些,但得到了后面所说的好处:“从而使你可用不同的请求对客户进行参数化”,也就是可以对任何请求进行参数化存储,我们可以在任意时刻调用。 这相当于掌握了执行时机,可以在任意时刻调用,以实现排队或记录日志,如果再记录下反向操作信息,就可以实现撤销重做了。 结构图 Command 是命令的接口,一般固定有一个 execute 方法。 ConcreteCommand 是命令接口的实现,它会注入具体执行者 Receiver,它实现的 execute 方法会调用 receiver.execute 来具体执行。 Invoker 是执行请求的命令,其实上面都在推入命令,并没有真正执行,如果排队结束或点击撤销重做时,就触发了 Invoker 实际,就该调用对应的 Command 执行啦。 代码例子下面例子使用 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(): class Invoker { push(command) { // 队列里推入命令 this.commands.push(command) } execute() { this.commands.forEach(command => command.execute()) // 别忘了清空 this.commands }} 弊端命令模式需要注意序列化大小,一般分为: 仅记录操作。 记录全量快照。 全量快照共享内存。 记录操作是较为精细的管理方式,并且可以延伸出协同编辑功能。记录快照要注意尽量共享内存,防止快照过大,而且协同编辑场景因为快照无法做冲突处理,所以快照模式在协同编辑场景无法应用。 另外要识别没必要使用命令模式的场景,对于没有撤销重做的前端大部分场景来说,都无需改为命令模式。 总结命令模式本质上就是将操作抽象为可序列化的命令,使操作可以在合适的时间执行,这种设计带来了许多额外好处。 利用命令模式可以达到高内聚低耦合的效果,提升代码可维护性,也可以实现撤销重做、协同编辑等功能性需求。 讨论地址是:精读《设计模式 - Command(命令模式)》· Issue ##295 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Decorator 装饰器模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Decorator 装饰器模式》.html","content":"当前期刊数: 175 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 模式相比生成子类更为灵活” 这句话的含义是,组合比继承更灵活,当可拓展的功能很多时,继承方案会产生大量的子类,而组合可以提前写好处理函数,在需要时动态构造,显然是更灵活的。 结构图 ConcreteComponent 指的是需要被装饰的组件,可以看到,装饰器 Decorator 与他都继承同一个类,这样能保证 API 的一致,才保证无论装饰多少层,始终符合 Component 类型。 装饰器如果有多种,就要将 Decorator 申明为抽象类,ConcreteDecoratorA、ConcreteDecoratorB 分别实现它们,如果只有一种装饰器,可以退化到 Decorator 自身就是一种实现。 代码例子下面例子使用 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 是一个透明的包装,只要保证包装的透明性,就可以最大限度发挥装饰器模式的优势。 最后总结一个装饰器应用图: 讨论地址是:精读《设计模式 - Decorator 装饰器模式》· Issue ##286 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Composite 组合模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Composite 组合模式》.html","content":"当前期刊数: 174 Composite(组合模式)Composite(组合模式)属于结构型模式,是一种统一管理树形结构的抽象方式。 意图:将对象组合成树形结构以表示 “部分 - 整体” 的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 公司组织关系树公司组织关系可能分为部门与人,其中人属于部门,有的人有下属,有的人没有下属。如果我们统一将部门、人抽象为组织节点,就可以方便的统计某个部门下有多少人、财务数据等等,而不用关心当前节点是部门还是人。 操作系统的文件夹与文件操作系统的文件夹与文件也是典型的树状结构,为了方便递归出文件夹内文件数量或者文件总大小,我们最好设计的时候就将文件夹与文件抽象为文件,这样每个节点都拥有相同的方法添加、删除、查找子元素,而不需要关心当前节点是文件夹或是文件。 搭建平台的组件与容器容器与组件的关系很小,用户常常认为容器也是一种组件,但搭建平台实现时,容器与组件稍有不同,不同之处在于容器可以嵌套子元素,而组件不可以。如果因此搭建平台就将组件分为容器与组件,会导致 API 割裂为两套,不利于组件开发者维护与用户理解,比较好的设计思路是将组件与容器统一看成组件,组件只是一种没有子元素的特殊容器,这样组件与容器就可以拥有相同的 API,统一理解与操作了。 意图解释意图:将对象组合成树形结构以表示 “部分 - 整体” 的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。 比较好理解,组合是指多个对象虽然有一定差异,但共同组合成了一个树形结构,那么对象之间就一定存在 “部分 - 整体” 的关系,组合模式要求我们抽象一个对象 Component 作为统一操作模型,叶子结点与非叶子结点都实现了所有功能,即便是没有子元素的叶子结点,为了强调透明性,还是具备比如 getChildren 方法,只不过永远都返回 null。 结构图 其中 Component 是组合中对象声明接口,一般会实现所有公共类的所有接口,还要提供一个接口管理其子组件。 Leaf 表示叶子结点,没有子结点,相应的 Composite 就是有子结点的节点。 可以看到,组合模式就是将树状结构中所有节点统一抽象了,我们不需要关心叶子结点与非叶子结点的差异,而可以通过组合模式的抽象屏蔽掉这些差异,统一处理。 代码例子下面例子使用 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 可能会失败,其本质上还是将节点的区别暴露给了业务层。 总结组合模式是针对树状结构这个特定场景的统一抽象方案,对降低系统复杂度有很重要的意义,同时也不要忘了过度抽象是有害的,我们要拿捏其中的度。 下图做了一个简单的解释: 程序中始终关注 Component 就行了,树状结构的差异已经被抹平。 讨论地址是:精读《设计模式 - Composite 组合模式》· Issue ##284 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"只寻常道","path":"/wiki/MySql/basics/usually.html","content":"常用命令 查看所有数据库show databases 打开指定的库use 库名 查看当前库的所有表show tables 查看其它库的所有表show tables from 库名 创建表create table 表名{name type, name type...} 查看表结构desc 表名 查看服务器版本select Version() 单行函数字符函数长度:length 获取参数的字节个数 UTF-8:汉字三字节 GBK:汉字两字节 SELECT LENGTH("柒拾柒Web"); 返回12 拼接:concat 拼接字符串 SELECT CONCAT("2","_","33"); 返回”2_33” 大小写:upper|lower 前者变大写,后者小写 SELECT UPPER("webgray"); 返回”WEBGRAY” 裁剪:substr 裁剪字符串,字符串首个索引从 1 开始 (str,索引起始) (str,索引起始,步长) SELECT SUBSTR("阿珍爱上了阿强",3,3); 返回”爱上了” 索引instr **查找第一次起始索引,无则返回 0 ** (源数据,查找值) select instr(13145556,4); select instr("阿珍爱上了阿强","爱"); 去空:trim 默认去前后空格,可指定内容 (元数据) (指定内容 FROM 源数据) select trim(" 阿珍 "); select trim("-" FROM "------阿珍---"); select trim(1 FROM 11122211); 填充:lpad|rpad 左右填充补齐 (源数据,长度,填充内容) SELECT LPAD("阿强",10,"珍"); 替换:replace 替换 (源数据,旧数据,新数据) SELECT REPLACE("111靓仔111",1,""); 字符例题 查询员工姓名,工资,以及工资提高20%的结果SELECT e_name , e_salary , e_salary*1.2 "new salary" FROM employees; 将员工的姓名以首字母排序,并写出姓名长度SELECT LENGTH(e_name) 长度, SUBSTR(e_name,1,1) 首字符, e_name FROM employees; 数学函数四舍五入:round 四舍五入 (源数据,保留小数点位数) select round(3.1415926535 , 7); 取整:ceil|floor 向上向下取整 小数:truncate 截断小数 (数据,截断位数) 取余:mod 取余: a-a/b*b (被除数,除数) 日期函数now 当前sql语句的 日期+时间 例如: 2022-09-10 17:29:54 sysdate 当前函数的耗时 日期+时间 (时间精度参数 0~6) 例如: 2022-09-10 17:29:54 curdate 日期 例如: 2022-09-10 curtime 时间 例如: 17:29:54 时间比较 时间输入合法即可比较 DATE_FORMAT 将数据库中的date数据格式化为String类型(常用) (date,format) STR_TO_DATE 将指定的时间格式的字符串按照格式转换为 DATETIME 类型的值。str要与format的格式保持一致,否则会报错。 (string,format) 流程控制如果:if 类似三元运算符,只能if else (条件 , 成立 , 不成立) 选择:case 类似 switch case 语境一: 处理等值 当switch case CASE 条件WHEN 常量1 THEN 语句….END 语境二: 处理区间 当多重if CASEWHEN 条件1 THEN 语句….END 其它函数常量SELECT 100; SELECT 'Web Gray'; 运算表达式SELECT 3*4; 版本SELECT VERSION(); 取别名 便于理解 便于区分重复字段 SELECT first_name FORM user AS 姓名; SELECT first_name 姓名,gender "性 别" FROM user; 去重SELECT DISTINCT class_id from user; 加号SELECT '123'+4; 若强转成功作加法,失败为零 SELECT null+4; 有null为null 判断NullIFNULL 函数判断并赋予默认值 SELECT IFNULL(class_id,1903) AS 班级,student_id FROM student; 转义 默认脏转义符为 ‘\\‘ 可通过 ESCAPE 自定义 SELECT name_id FROM user WHERE full_name LIKE '$_柒%' ESCAPE '$'; 转义符变为’$’ 分组聚合函数简单使用SELECT SUM(salary) 求和, AVG(salary) 平均, MAX(salary) 最高, MIN(salary) 最低, COUNT(DISTINCT salary) 个数 FROM employees; 参数支持 SUM , AVG 数值类型 MAX , MIN , COUNT 任何类型 都忽略 null 都支持 DISTINCT 去重 分组函数详情count 统计行数 : SELECT COUNT(*) FROM table_name; SELECT COUNT(1) FORM table_name; SELECT COUNT(字段名) FROM table_name; 统计效率 : MYISAM 存储引擎下 , COUNT(*)最优, 引擎有对应计数器. INNODB 存储引擎下 , COUNT(*)|COUNT(1) 都差不多. 比 COUNT(字段名) 效率高. group by 位于语句末尾,因为分组函数查询的值为一行,协同查询的字段是 group by 后的字段 例: 查找男女同学中各自的最大年龄SELECT MIN(brithday),gender FROM student_test GROUP BY gender; having能分组前筛选就尽量用,相比having性能差 分组后筛选时使用,位于 group by 语句末尾,筛选的数据源是分组. 分组案例 例: 查找男女同学中各自的最大年龄SELECT MIN(brithday),gender FROM student_test GROUP BY gender; 例: 查找姓”唐”男女同学中各自的最大年龄SELECT MIN(brithday),gender FROM student_test WHERE student_name LIKE "唐%" GROUP BY gender; 例: 统计名字字数大于1的其余各字数的人数SELECT COUNT(*) 人数,LENGTH(student_name) 名字长度 FROM student_test GROUP BY LENGTH(student_name) HAVING 名字长度 > 3;"},{"title":"《设计模式 - Facade 外观模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Facade 外观模式》.html","content":"当前期刊数: 176 Facade(外观模式)Facade(外观模式)属于结构型模式,是一种日常开发中经常被使用到的设计模式。 意图:为子系统中的一组接口提供一个一致的界面,Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 意图解释图书管理员图书馆是一个非常复杂的系统,虽然图书按照一定规则摆放,但也只有内部人员比较清楚,作为一位初次来的访客,想要快速找到一本书,最好的办法是直接问图书管理员,而不是先了解这个图书馆的设计,因为你可能要来回在各个楼宇间奔走,借书的流程可能也比较长。 图书管理员就起到了简化图书馆子系统复杂度的作用,我们只要凡事询问图书管理员即可,而不需要关心他是如何与图书馆内部系统打交道的。 最多跑一次便民服务浙江省推出的最多跑一次服务非常方便,很多办事流程都简化了,无论是证件办理还是业务受理,几乎只要跑一次,而必须要持续几天的流程也会通过手机短信或者 App 操作完成后续流程。 这就相当于外观模式,因为政府系统内部的办事流程可能没有太大变化,但通过抽象出 Facade(外观),让普通市民可以直接与便民办事处连接,而不需要在车管所与驾校之间来回奔波,背后的事情没有少,只是便民办事处帮你做了。 Iphone 快捷指令功能手机的 App 非常多,而我们需要了解每个功能在哪个 App 上才能运用自如,而快捷指令功能可以将 App 的某些功能单独提取出来,形成一套新的功能组,我们可以只接触到 “拍照” “付款” “计算”,而不用管背后是调用了支付宝还是微信、系统内置摄像机还是其他摄像 App,也不用关心这个 App 内部功能的入口在哪里,这些对接都在快接指令中自动完成。 快捷指令也是一种外观模式。 意图解释意图:为子系统中的一组接口提供一个一致的界面,Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。 为降低一个拥有多个接口的子系统内部复杂性,我们需要一个外观来屏蔽内部的复杂性,因此外观模式就是定义一个高层接口,这个接口直连子系统的内部实现,但调用这个高层接口的人不需要关心子系统内部的实现,这样,对于不想了解子系统内部实现的人来说,提高了易用度。 当然如果想要深度定制,就可以绕过外观模式,直接使用子系统提供的类,所以说并不是有了外观模式就必须通过外观调用,而是根据实际需要判断使用哪种调用方式。 结构图 可以看到,Facade 直接指向子系统中的类,而子系统的类不会反向指向 Facade。 代码例子下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Factory Method 工厂方法》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Factory Method 工厂方法》.html","content":"当前期刊数: 169 Factory Method(工厂方法)Factory Method(工厂方法)属于创建型模式,利用工厂方法创建对象实例而不是直接用 New 关键字实例化。 理解如何写出工厂方法很简单,但理解为什么要用工厂方法就需要动动脑子了。工厂方法看似简单的将 New 替换为一个函数,其实是体现了面向接口编程的思路,它创建的对象其实是一个符合通用接口的通用对象,这个对象的具体实现可以随意替换,以达到通用性目的。 意图:定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method 使一个类的实例化延迟到其子类。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 换灯泡我自己在家换过灯泡,以前我家里灯坏掉的时候,我看着这个奇形怪状的灯管,心里想,这种灯泡和这个灯座应该是一体的,市场上估计很难买到适配我这个灯座的灯泡了。结果等我把灯泡拧下来,跑到门口的五金店去换的时候,店员随便给了我一个灯泡,我回去随便拧了一下居然就能用了。 我买这个灯泡的过程就用到了工厂模式,而正是得益于这种模式,让我可以方便在家门口就买到可以用的灯泡。 卡牌对战游戏卡牌对战中,卡牌有一些基本属性,比如攻防、生命值,也符合一些通用约定,比如一回合出击一起等等,那么对于战斗系统来说,应该怎样实例化卡牌呢?如何批量操作卡牌,而不是通用功能也要拿到每个卡牌的实例才能调用?另外每个卡牌有特殊能力,这些特殊能力又应该如何拓展呢? 实现任意图形拖拽系统一个可以被交互操作的图形,它可以用鼠标进行拉伸、旋转或者移动,不同图形实现这些操作可能并不相同,要存储的数据也不一样,这些数据应该独立于图形存储,我们的系统如果要对接任意多的图形,具备强大拓展能力,对象关系应该如何设计呢? 意图解释在使用工厂方法之前,我们就要创建一个 用于创建对象的接口,这个接口具备通用性,所以我们可以忽略不同的实现来做一些通用的事情。 换灯泡的例子来说,我去门口五金店买灯泡,而不是拿到灯泡材料自己 New 一个出来,就是因为五金店这个 “工厂” 提供给我的灯泡符合国家接口标准,而我家里的灯座也符合这个标准,所以灯座不需要知道对接的灯泡是具体哪个实例,什么颜色,什么形状,这些都无所谓,只要灯泡符合国家标准接口,就可以对接上。 对卡牌对战的系统来说,所有卡牌都应该实现同一种接口,所以卡牌对战系统拿到的卡牌应该就是简单的 Card 类型,这种类型具备基本的卡片操作交互能力,系统就调用这些能力完成基本流程就好了,如果系统直接实例化具体的卡片,那不同的卡片类型会导致系统难以维护,卡片间操作也无法抽象化。 正是这种模式,使得我们可以在卡牌的具体实现上做一些特殊功能,比如修改卡片攻击时效果,修改卡牌销毁时效果。 对图形拖拽系统来说,用到了 “连接平行的类层次” 这个特性,所谓连接平行的类层次,就是指一个图形,与其对应的操作类是一个平行抽象类,而一个具体的图形与具体的操作类则是另一个平行关系,系统只要关注最抽象的 “通用图形类” 与 “通用操作类” 即可,操作时,底层可能是某个具体的 “圆类” 与 “圆操作类” 结合使用,具体的类有不同的实现,但都符合同一种接口,因此操作系统才可以把它们一视同仁,统一操作。 意图:定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method 使一个类的实例化延迟到其子类。 所以接口是非常重要的,工厂方法第一句话就是 “定义一个用于创建对象的接口”,这个接口就是 Creator,让子类,也就是具体的创建类(ConcreteCreator)决定要实例化哪个类(ConcreteProduct)。 所谓使一个类的实例化延迟到其子类,是因为抽象类不知道要实例化哪个具体类,所以实例化动作只能由具体的子类去做,这样绕一圈的好处是,我们可以将任意多对象看作是同一类事物,做统一的处理,比如 无论何种灯泡实例都满足通用的灯座接口,所有工厂实例化的卡牌都具备玩一局卡牌游戏的基本功能,任何图形与交互类都满足特定功能关系,这种思想让生活和设计得到了大幅简化。 结构图 Creator 就是工厂方法,ConcreteCreator 是实现了 Creator 的具体工厂方法,每一个具体工厂方法生产一个具体的产品 ConcreteProduct,每个具体的产品都实现通用产品的特性 Product。 代码例子下面例子使用 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: function main(anyCreator: Creator) { const product = anyCreator.createProduct()} 在外面调用 main 函数时,实际传进去的是一个具体工厂,比如 myCreator,但关键是 main 函数不用关心到底是哪一个具体工厂,只要知道是个工厂就行了,具体对象创建过程交给了其子类。 你也许也发现了,这就是抽象工厂中其中的一步,所以抽象工厂使用了工厂方法。 弊端工厂方法中,每创建一种具体的子类,就要写一个对应的 ConcreteCreate,这相对比较笨重,但有意思的是,如果将创建多个对象放到一个 ConcreteCreate 中,就变成了 简单工厂模式,新增产品要修改已有类不符合开闭模式,反而推荐写成本文说的这种模式。 彼之毒药吾之蜜糖,要知道没有一种设计模式解决所有问题,没有一种设计模式没有弊端,而这个弊端不代表这个设计模式不好,一个弊端的出现可能是为了解决另一个痛点。 要接受不完美的存在,这么多种设计模式就是对应了不同的业务场景,为合适的场景选择一种能将优势发扬光大,以至于能掩盖弊端,就算进行了合理的架构设计。 总结工厂方法并不是简单把 New 的过程换成了函数,而是抽象出一套面向接口的设计模式: 你看,我要做灯泡,可以直接做具体的灯泡,也可以定一个灯泡接口,通过灯泡工厂拿到具体灯泡,灯泡工厂对待所有灯泡的只做流程都是一样的,不管是中世纪风灯泡,还是复古灯泡,还是普通白织灯,都是一模一样的制作流程,具体怎么做由具体的子类去实现,这样我们可以统一管理 “灯泡” 这一个通用概念,而忽略不同灯泡之间不太重要的差别,程序的可维护性得到了大幅提升。 讨论地址是:精读《设计模式 - Factory Method 工厂方法》· Issue ##274 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Interpreter 解释器模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Interpreter 解释器模式》.html","content":"当前期刊数: 181 Interpreter(解释器模式)Interpreter(解释器模式)属于行为型模式。 意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器。这个解释器使用该表示来解释语言中的句子。 任何一门语言,无论是日常语言还是编程语言都有明确的语法,只要有语法就可以用文法描述,并通过语法解释器将字符串的语言结构化。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 SQL 解释器SQL 是一种描述语言,所以也适用于解释器模式。不同的 SQL 方言有不同的语法,我们可以根据某种特定的 SQL 方言定制一套适配它的文法表达式,再利用 antlr 解析为一颗语法书。在这个例子中,antlr 就是解释器。 代码编译器程序语言也因为其天然是字符串的原因,和 SQL、日常语言都类似,需要一种模式解析后才能工作。不同的语言有不同的文法表示,我们只需要一个类似 antlr 的通用解释器,通过传入不同的文法表示,返回不同的对象结构。 自然语言处理自然语言处理也是解释器的一种,首先自然语言处理一般只能处理日常语言的子集,因此先定义好支持的范围,再定义一套分词系统与文法表达式,并将分词后的结果传入灌入了此文法表达式的解释器,这样解释器可以返回结构化数据,根据结构化数据再进行分析与加工。 意图解释意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器。这个解释器使用该表示来解释语言中的句子。 对于给定的语言,可以是 SQL、代码或自然语言,“定义它的文法的一种表示” 即文法可以有多种表示,只需定义一种。要注意的是,不同文法执行效率会有差异。 “并定义一个解释器”,这个解释器就是类似 antlr 的东西,传给它一个文法表达式,就可以解析句子了。即:解释器(语言, 文法) = 抽象语法树。 我们可以直接把文法定义耦合到解释器里,但这样做会导致语法复杂时,解释器难以维护。比较好的方式是定义一套与解释器解耦的文法表达式,通过预处理器最终生成解释器。 结构图 Context 是其他上下文变量,AbstractExpression 是抽象语法表达式。 可以看到,TerminalExpression(终结符)与 NonterminalExpression(非终结符) 都继承于 AbstractExpression,终结符指的是没有后续展开的符号,非终结符相反,所以非终结符又指向了 AbstractExpression,如此递归。 代码例子下面例子使用 typescript 编写。 假设我们要实现以下文法: sum ::= number + numbernumber ::= 1 | 2 表达一个最简单的加法文法,其中加法表达式 sum 和 number 都是非终结符,而 +、1、2 是终结符。这个例子只能做到 1 与 2 的加法,通过这个简单例子,了解一下解释器模式的精髓吧: // 抽象表达式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") // trueadd.interpreter("1 + 2") // trueadd.interpreter("1 + 3") // falseadd.interpreter("2 - 1") // false 遇到非终结符则继续调用,只有终结符才能直接判断,原理很简单。 弊端上面的例子是比较低效场景,因为当语法复杂后,类的数目会明显增多,难以维护,此时需要用一个通用语法解析器,了解更多可以看笔者之前的文章:精读《手写 SQL 编译器 - 语法分析》 系列。 总结解释器是一种思维,将复杂语法解析抽象为一个个独立的终结符与非终结符各自判断,只要每个文法自己的判断做好了,剩下的工作就是组装文法。 这种将单个逻辑判断与文法组装解耦的做法,可以使逻辑判断与文法组装独立变换,使复杂语法解析转化为一个个具体的简单问题。 讨论地址是:精读《设计模式 - Interpreter 解释器模式》· Issue ##296 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Iterator 迭代器模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Iterator 迭代器模式》.html","content":"当前期刊数: 182 Iterator(迭代器模式)Iterator(迭代器模式)属于行为型模式。 意图:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。 这种设计模式要解决的根本问题是,聚合的种类有很多,比如对象、链表、数组、甚至自定义结构,但遍历这些结构时,不同结构的遍历方式又不同,所以我们必须了解每种结构的内部定义才能遍历。 比如数组我们可以利用 length + for 循环,对象我们可以 Object.keys,链表比较麻烦,需要内部暴露出元素的 next 以操作指向下一个元素。 迭代器模式可以做到用同一种 API 遍历任意类型聚合对象,且不用关心聚合对象的内部结构。 这种模式和 Array.from 有点像,但其实真正的迭代器在 JS 里是 obj[Symbol.iterator](),也就是一个对象实现了 [Symbol.interator],就认为是可遍历的。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 迭代器的例子非常简单,我们平时工作中有大量使用到。 generatorgenerator 天生为迭代器的 API: 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 的场景中,迭代器不仅用来遍历聚合,还用于执行代码。 数组迭代器我们可以用迭代器的方式遍历数组: 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 对象也可以用迭代器方式遍历: 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 内部的存储结构也没有意义,如果我们不用迭代器进行遍历,那么对于复杂结构的遍历成本是非常高的。 结构图 Aggregate: 聚合,需要定义创建迭代器的接口。比如前端规范的 [Symbol.iterator](),或者这里定义的 CreateIterator()。 Iterator: 迭代器,定义了访问与遍历的 API。 迭代器的定义很简单,实现时要考虑的因素可不少,包括: 健壮性。即迭代过程中增加、删除元素后,还能正常遍历。或者遍历空聚合时也要能正常工作。 外部控制迭代还是内部。即类似 KOA 由插件调用 next() 控制迭代,还是由外层统一控制迭代。 如何定义遍历算法。即便对于对象这种简单场景,也存在深度优先和广度优先、冒泡与捕获这几种遍历顺序,迭代器可以提供选择或者拓展的方式,自定义遍历算法。 代码例子下面例子使用 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 循环会比迭代器方便很多,没必要为了用设计模式而用设计模式。迭代器仅在以下情况可以考虑用于数组: 这个数组比较特殊,是 N 维数组,需要一次性遍历完,那么可以用迭代器。 同时遍历数组和其他类型的聚合,则不论数组还是其他聚合,都用相同的迭代器模式遍历最好。 总结迭代器模式比较好理解,这里补充几个相关设计模式: 迭代器可以和组合模式配合,在组合结构内进行递归,这样一个迭代器就可以遍历完所有组合。 可以用工厂模式 + 多态模式,实例化不同的迭代器的实例。 迭代器模式还可以与备忘录模式配合使用,当我们要还原迭代器状态时,适合在迭代器内部使用备忘录模式进行状态存储。 讨论地址是:精读《设计模式 - Iterator 迭代器模式》· Issue ##298 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Memoto 备忘录模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Memoto 备忘录模式》.html","content":"当前期刊数: 184 Memento(备忘录模式)Memento(备忘录模式)属于行为型模式,是针对如何捕获与恢复对象内部状态的设计模式。 意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。 其实备忘录模式思想非常简单,其核心是定义了一个 Memoto(备忘录) 封装对象,由这个对象处理原始对象的状态捕获与还原,其他地方不需要感知其内部数据结构和实现原理,而且 Memoto 对象本身结构也非常简单,只有 getState 与 setState 一存一取两个方法,后面会详细讲解。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 撤销重做如果撤销重做涉及到大量复杂对象,每个对象内部状态的存储结构都不同,如果一个一个处理,很容易写出 case by case 的冗余代码,而且在拓展一种新对象结构时(如嵌入 ppt),还需要在撤销重做时对相应结构做处理。备忘录思维相当于一种统一封装思维,不管这个对象结构如何,都可以保存在一个 Memoto 对象中,通过 setState 设置对象状态与 getState 获取对象状态,这样对于任何类型的对象,画布都可以通过统一的 API 操作进行存取了。 游戏保存玩过游戏的同学都知道,许多游戏支持设置与读取多种存档,如果转换为代码模式,我们可能希望有这样一种 API 进行多存档管理: // 创建一盘游戏。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 这个抽象的备忘录类。 结构图 Originator:创建、读取备忘录的发起者。 Memento:备忘录,专门存储原始对象状态,并且防止 Originator 之外的对象读取。 Caretaker:备忘录管理者,一般用数组或链表管理一堆备忘录,在撤销重做或者版本管理时会用到。 代码例子下面例子使用 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(); }} 下面是一个简化版客户端使用的例子: // 实例化发起者,比如画布、文章管理器、游戏管理器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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Flyweight 享元模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Flyweight 享元模式》.html","content":"当前期刊数: 177 Flyweight(享元模式)Flyweight(享元模式)属于结构型模式,是一种共享对象的设计模式。 意图:运用共享技术有效地支持大量细粒度的对象。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 富文本编辑器的字母对象富文本编辑器在英文环境下,其中的文本由大量字母组成,为了便于做统一的格式化、计算等处理,需要将每个字母都存储为对象,但这样存储的代价太大了。 已知英文字母一共 26 个,所以文档中存在大量重复使用的字母,而每个字母除了位置信息外,其它信息都是相同且只读的,那么有办法降低富文本场景巨大的字母对象数量吗? 网盘存储当我们上传一部电影时,有时候几十 GB 的内容不到一秒就上传完了,这是网盘提示你,“已采用极速技术秒传”,你会不会心生疑惑,这么厉害的技术为什么不能每次都生效? 另外,网盘存储时,同一部电影可能都会存放在不同用户的不同文件夹中,而且电影文件又特别巨大,和富文本类似,电影文件也只有存放位置是不同的,而其余内容都特别巨大且只读,有什么办法能优化存储呢? 大型多人游戏玩多人游戏时,为了防止外挂,一般对象的创建与计算是在服务器完成的,那如何保证一个玩家拾取物品后,另一个玩家看到的物品会消失? 其实道理已经不言而喻了,虽然在不同客户端之间,游戏对象是相互独立的,但在一局游戏中,所有玩家的对象在服务器是共享的。 意图解释“共享” 就是享元模式的精髓,将那些大量的,具有很多内部状态而外部状态很少的对象进行共享,就是享元模式的使用方式。 意图:运用共享技术有效地支持大量细粒度的对象。 共享技术可以理解为缓存,当一个对象创建后,再次访问相同对象时,就不再创建新的对象了,而只有在访问没有被缓存过的对象时,才创建新对象,并立即缓存起来。 这样做可以有效支持大量细粒度的对象,在富文本例子中,无数的字母就是大量细粒度对象,在网盘存储中,电影文件就是大量细粒度对象,在大型多人游戏中,每局游戏内存在大量细粒度对象。 这些细粒度对象都拥有相同的特征: 量特别大,这个很容易理解。 具有大量内部状态,且不随着客户端的不同而改变。 富文本的字母,不因为展示到不同语句中而发生变化,变化的只有状态;电影文件,不因为放在不同用户的文件夹中而对电影内容产生变化,变化的只有属于哪些用户,放在哪些文件夹里;多人游戏中,同一把武器对象,不因为有多个人的电脑独立运行而拥有更多的弹药,变化的只有在哪些客户端被访问。 具有少量外部状态,甚至没有外部状态。在上面已经解释了,字母的位置、电影的位置、游戏对象的客户端都是外部状态,这些外部状态相比于其内部状态来说,大小微乎其微,且方便分离存储。 遇到这种情况,我们就可以将对象内部状态共享,外部状态独立存储,从而节省大量空间。 尤其是对于网盘的场景,承诺给用户 2 TB 的存储空间,这个用户看到其他人分享了 100 个电影,就点击 “下载到我的网盘”,此时虽然占用了自己 1 TB 的网盘空间,但实际上网盘运营商并没有增加 1 TB 的存储空间,实际可能增加了 1kb 的存储空间,记录了存储位置,这就是网盘鸡贼的地方,并不占用空间的内容,却占用了用户真金白银购买的存储空间。 当然,这就是享元模式的价值,对网盘公司来说,价值巨大,对用户来说,没有价值。所以享元模式的价值体现在全局,比如对整个富文本编辑器来说,减少了巨量字母对象数量,但对于每一个字母对象而言,并没有任何优化。 结构图 对于 Client 而言,下图描述了如何共享 Flyweight: Flyweight: 共享接口,通过这个接口可以操作对象的外部状态。 ConcreteFlyweight: 实现 Flyweight 接口的对象,这个对象是可被共享的。 UnsharedConcreteFlyweight: 不被共享的对象,因为在享元模式中,实际上并不是所有对象都可以被共享。 FlyweightFactory: 创建并管理 Flyweight 对象,通过其返回的 Flyweight 对象,如果已创建,则会返回之前创建的那个,没有的话才会创建一个新的。 Client: 使用 Flyweight 的客户端。 通过第二个图可以明显看到,两个不同的 Client 持有了相同 aConcreteFlyweight 引用。 代码例子下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Observer 观察者模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Observer 观察者模式》.html","content":"当前期刊数: 185 Observer(观察者模式)Observer(观察者模式)属于行为型模式。 意图:定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。 拿项目的 npm 依赖举例子:npm 包与项目是一对多的关系(一个 npm 包被多个项目使用),当 npm 包发布新版本时,如果所有依赖于它的项目都能得到通知,并自动更新这个包的版本号,那么就解决了包版本更新的问题,这就是观察者模式要解决的基本问题。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 对象与视图双向绑定在 精读《设计模式 - Proxy 代理模式》 中我们也提到了双向绑定概念,只不过代理是实现双向绑定的一个具体方案,而观察者模式才是在描述双向绑定这个概念。 观察者模式在最初提出的时候,就举了数据与 UI 相互绑定的例子。即同一份数据可以同时渲染为表格与柱状图,那么当操作表格更新数据时,如何让柱状图的数据也刷新?从这个场景引出了对观察者模式的定义,即 “数据” 与 “UI” 是一对多的关系,我们需要一种设计模式实现当 “数据” 变化时,所有依赖于它的 “UI” 都得到通知并自动更新。 拍卖拍卖由一个拍卖员与多为拍卖者组成。拍卖时,由 A 同学喊出的竞价(我出 100)就是观察者向目标发出的 setState 同时,此时拍卖员喊出(有人出价 100,还有更高的吗?)就是一个 notify 通知行为,拍卖员通知了现场竞价全员,刷新了他们对当前最高价的信息。 聊天室聊天室由一个中央服务器与多个客户端组成。客户端发送消息后,就是向中央服务器发送了 setState 更新请求,此时中央服务器通知所有处于同一聊天室的客户端,更新他们的信息,从而完成一次消息的发送。 意图解释数据与 UI 的例子已经详细说明了其意图含义,这里就不赘述了。 结构图 Subject: 目标,即例子中的 “数据”。 Observer: 观察者,即例子中的 “表格”、“柱状图”。 还是以数据与 UI 同步为例,当表格发生操作修改数据时,表格这个 TableObserver 会调用 Subject(数据)的 setState,此时数据被更新了。然后数据这个 Subject 维护了所有监听(包括表格 TableObserver 与柱状图 ColumnChartObserver),此时 setState 内会调用 notify 遍历所有监听,并依次调用 Update 方法,每个监听的 Update 方法都会调用 getState 获取最新数据,从而实现表格更新后 -> 更新数据 -> 表格、柱状图同时刷新。 为了更好的理解,以这张协作图为例: aConcreteSubject: 对应例子中的数据。 aConcreteObserver: 对应例子中的表格。 anotherConcreteObserver: 对应例子中的柱状图。 代码例子下面例子使用 typescript 编写。 PS: 为了简化处理,就不定义 Subject 接口与 ConcreteSubject 了,而是直接用 Subject 类代替。Observer 也同理。 // 目标,管理所有观察者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 很轻松的实现: 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Mediator 中介者模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Mediator 中介者模式》.html","content":"当前期刊数: 183 Mediator(中介者模式)Mediator(中介者模式)属于行为型模式。 意图:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。 前端开发中,最常用的 “数据驱动” 其实就最好的诠释了中介者模式。 想一个这样的场景: 按钮点击后,表单提交。按钮需要调用所有表单项获取表单值。 表单关联,当勾选了城市后,才出现满意度 Input 框,此时城市勾选按钮需要引用满意度 Input 框。 甚至会出现循环引用,两个输入框是互斥的,输入了一个,另一个输入框就要 Disable。 当新增加一个表单项时,需要重新建立所有引用关系。 以上过程式编程方式,维护大型项目几乎是不可能的。然而数据驱动可以很好的解决这个问题,所有表单项都依赖数据,并修改数据,这样当 Input 框联动 Check 时,Input 并不需要感知 Checkbox 的存在,他只要关联数据、修改数据就行了,Checkbox 也只要关联数据和修改数据,这样不但逻辑可以独立完成,甚至可以解决循环引用的问题。 在数据驱动的例子中,数据就是中介。 所有 UI 之间都不会相互引用,而是通过数据这个中介来协同工作,这样做带来的明显好处是可以处理复杂项目,且易于维护。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 数据驱动正如开篇说的,数据驱动是中介者非常经典的例子,正是因为引入了 “数据中介者”,才让前端项目的复杂度可以呈几何倍数递增,而代码的逻辑复杂度仅线性递增。因为 UI 是杂乱的且动态的,UI 间依赖会导致关系网非常复杂,且关系网一旦形成,增加一个新元素或修改就变得异常困难。 中介者模式则避开了 UI 间依赖的关系网,通过数据层统一调度,UI 受控响应,可以大大减少逻辑复杂度。 解决循环依赖循环依赖几乎只能利用中介者模式解决: import { b } from './b'export const a = 'a' import { a } from './a'export const b = 'b' 当双方相互引用时,构成循环依赖,不仅对于模块化来说是有问题的,从逻辑上也是讲不通的,因为一定存在递归调用的问题。这是,引入第三方中介者就不仅仅是一种设计模式思维了,而是 a、b 模块中原本就有一些内容是两边公用的,一定需要提出来,而统一提出来的地方就是中介者模式的中介者部分。 企业组织架构一个树状企业组织架构中,每个非叶子结点都是中介者,需要给他的子节点分配任务,并协调他们的工作,这样一来,叶子结点不需要有全局观即可工作,因为他们只需负责 “去做自己的事情”,而不需要关心 “是如何协同的”。 如图所示,环境部不需要关心人事部做了什么,只要专注做好环境事物即可,他们之间的协调由总经理处理,这是一种分工协作的体现。 而只存在于理论中的网状企业管理模型,则是没有中介者的例子,所有节点都是非叶子结点,并相互引用,这样一来每个人既要做自己的工作,又要处理自己与公司里其他几万人的协同,几乎是一件不可能完成的事情,所以从设计模式角度来看,也更倾向于使用树状而不是网状模式管理企业。 意图解释意图:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。 中介者模式非常好理解,直接看字面意思即可。所谓的对象交互,指的是对象之间是如何协同的,中介者做的是处理对象间协同的工作,而不是 “替每个对象干活”。 最后一句 “可以独立地改变他们之间的交互”,指的是对象之间协同方式不是一成不变的,比如一个输入框组件,只要实现自己的输入功能就行了,而不需要关心是如何与外界交互的。外界可以通过将其嵌入到表单中,成为表单项的一部分,也可以将其包裹一层符号后缀,成为一个专门输入金额的金额输入框。 结构图 Mediator:中介者接口,定义一些通信 API。 ConcreteMediator:具体的中介者,继承 Mediator,协调各个对象。 Colleague:同事类,比如之前提到的输入框、文本框,每个同事之间只要知道中介者即可,他们之间不需要知道对方的存在。 代码例子下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Proxy 代理模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Proxy 代理模式》.html","content":"当前期刊数: 178 Proxy(代理模式)Proxy(代理模式)属于结构型模式,通过访问代理对象代替访问原始对象,以获得一些设计上的便捷。 意图:为其他对象提供一种代理以控制这个对象的访问。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 获得文本对象长度获得一个文本对象长度,必须要真正渲染出来,而渲染是比较耗时的,我们可能只在某些场景下需要访问文本对象长度,而更多时候只需要读取文本内容,这两种操作耗时是完全不同的,如何做到业务层调用无感知,来优化执行耗时呢? 代理模式可以解决这个问题,我们将业务层使用的文本对象替换为代理对象,这个代理对象初始化并不渲染文本,而是在调用文本长度时才渲染。 对象访问保护某个大型系统开发完了,突然要求增加代码访问权限体系,不同模块对相同的底层对象拥有不同访问权限,此时这个权限控制逻辑如果写入底层对象,就违背了开闭原则,而对象本身的实现也不再纯粹,增加了维护成本,如何做到不修改对象本身,实现权限控制呢? 代理模式也能解决,将底层对象导出替换为代理对象,由代理对象控制访问权限即可。 对象与视图双向绑定Angular 或 Vue 这类前端框架采用双向绑定视图更新技术,即对象修改后,使用到的视图会自动刷新,这就需要做到以下两点: 在对象被访问时,记录调用的视图绑定。 在对象被修改时,刷新调用它的视图。 问题是,在业务代码使用对象与修改对象的地方插入这段逻辑,显然会增加巨大的维护成本,如何做到业务层无感知呢? 代理模式可以很好的解决这个问题,其实业务层拿到的对象已经是代理对象了,它在被访问与被修改时,都会执行固定的钩子做视图绑定与视图刷新。 意图解释意图:为其他对象提供一种代理以控制这个对象的访问。 代理模式的意图很容易理解,就是通过代理对象代替原始对象的访问。 这只是代理模式的实现方式,代理模式真正的难点不在于理解它是如何工作的,而是理解哪些场景适合用代理,或者说创建了代理对象,怎么用才能发挥它的价值。 在上面例子中,已经举出了几种常见代理使用场景: 对开销大的对象使用代理,以按需使用。 对需要保护的对象进行代理,在代理层做权限控制。 在对象访问与修改时要执行一些其他逻辑,适合在代理层做。 结构图 使用时关系如下: Subject 定义的是 RealSubject 与 Proxy 共用的接口,这样任何使用 RealSubject 的地方都可以使用 Proxy。 RealSubject 指的是原始对象,Proxy 是一个代理实体。 关系图中可以看出,当客户端要访问 subject 时,第一层访问的是 Proxy 代理,由这个代理将 realSubject 转发给客户端。 代码例子下面例子使用 typescript 编写。 // 对象 objconst 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Singleton 单例模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Singleton 单例模式》.html","content":"当前期刊数: 171 Singleton(单例模式)Singleton(单例模式)属于创建型模式,提供一种对象获取方式,保证在一定范围内是唯一的。 意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。 其实单例模式在前端体会的不明显,原因有: 前端代码本身在单机运行,创建的任何变量都是天然分布式的,不需要担心影响另一个用户。 后端代码是一对多的,分辨出哪些资源是请求间共享的,哪些是请求内独有的很重要。 另外我们说到单例,是隐含了一个范围的,指的是在某个范围内单例,比如在一个上下文中,还是一个房间中,还是一个进程,一个线程中单例,不同场景范围会不同。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 多人游戏的共享物品玩过游戏的同学都知道,我们在每局游戏中使用的公共物品在当前房间中是唯一的,但在游戏房间间却不是唯一的,所以这些公共物品肯定有不同的类去描述,那每局游戏中怎么拿公共物品,可以保证拿到的是当前局内唯一的? Redux 数据流其实前端的 Redux 数据流本身就是单例模式,在一个应用中,数据是唯一的,但可以有不同的 UI 使用这份唯一的数据,甚至把一个表格组件展示在两个不同地方,比如全屏模式,但数据依然是一份,我们没有必要为了全屏展示表格,就让它再发一次取数请求,完全可以和原来的表格共享一份数据。 数据库连接池每个 SQL 查询都依赖数据库连接池,如果每次查询都建立一次数据库连接池,则建立连接的速度会远远慢于 SQL 查询速度,因此你会怎么设计数据库连接池的获取方法? 意图解释单例模式的意图很简单,几乎就是其字面含义: 意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。 对于多人游戏的共享物品,比如一口锅,要保证在一局游戏内唯一,就要提供一种方法访问到唯一实例。 Redux 数据流的 connect 装饰器就是全局访问点的一种设计。 数据库连接池可以提前初始化好,并通过固定 API 提供这个唯一实例。 结构图 Singleton 是单例模式的接口,客户只能通过其定义的 instance() 访问实例,以保证单例。 代码例子下面例子使用 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 改成多实例,都是有可能的,在系统设计之初就要考虑到未来是否还会保持单例。 可测试性不好,因为单例是全局共享的,无法保证测试用例间的隔离。 无法使用构造函数传参。 另外单例模式还可以被工厂方法所替代,所以不用特别纠结一种设计模式,可以结合使用,工厂函数也可以内嵌单例模式。 总结单例模式概念、用法都简单,是架构设计常用方案,但要充分理解到单例模式的弊端,防止不恰当的使用。 讨论地址是:精读《设计模式 - Singleton 单例模式》· Issue ##278 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Strategy 策略模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Strategy 策略模式》.html","content":"当前期刊数: 187 Strategy(策略模式)Strategy(策略模式)属于行为型模式。 意图:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。 策略是个形象的表述,所谓策略就是方案,我们都知道任何事情都有多种方案,而且不同方案都能解决问题,所以这些方案可以相互替换。我们将方案从问题中抽象出来,这样就可以抛开问题,单独优化方案了,这就是策略模式的核心思想。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 地图导航我们去任何地方都可以选择步行、骑车、开车、公交,不同的方案都可以帮助我们到达目的地,那么很明显应该将这些方案变成策略封装起来,接收的都是出发点和目的地,输出的都是路线。 布局方式比如我们做一个报表系统,在 PC 使用珊格布局,在移动端使用流式布局,其实内容还是那些,只是布局方式会随着不同终端大小做不同的适配,那么布局的适配就是一种策略,它可以与报表内容无关。 我们可以将布局策略单独抽取出来,以后甚至可以适配电视机、投影仪等等不同尺寸的场景,而不需要对其他代码做任何改动,这就是将布局策略从代码中解耦出来的好处。 排序算法当我们调用 .sort 时,使用的是什么排序算法?可能是冒泡、快速、插入排序?其实无论何种排序算法,本质上做的事情都是一样的,我们可以事先将排序算法封装起来,针对不同特性的数组调用不同的排序算法。 意图解释意图:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。 算法可以理解为策略,我们制定许多解决某个场景的策略,这些策略都可以独立的解决这个场景的问题,这样下次遇到这个场景时,我们就可以选择任何策略来解决,而且我们还可以脱离场景,单独优化策略,只要接口不变即可。 这个意图本质上就是解耦,解耦之后才可以分工。想想一个复杂的系统,如果所有策略都耦合在业务逻辑里,那么只有懂业务的人才能小心翼翼的维护,但如果将策略与业务解耦,我们就可以独立维护这些策略,为业务带来更灵活的变化。 结构图 Strategy: 策略公共接口。 ConcreteStrategy: 具体策略,实现了上面这个接口。 只要你的策略符合接口,就满足策略模式的条件。 代码例子下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Prototype 原型模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Prototype 原型模式》.html","content":"当前期刊数: 170 Prototype(原型模式)Prototype(原型模式)属于创建型模式,既不是工厂也不是直接 New,而是以拷贝的方式创建对象。 意图:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 做钥匙很显然,为了房屋安全,要尽量做到一把钥匙只能开一扇门,每把钥匙结构都多多少少不一样,却又很相似,做钥匙的人按照你给的钥匙一模一样做一个新的,这属于什么模式呢? 两种状态表当网站做不停机维护时,假设维护内容是给每个高级会员账户多打 100 元现金,现在需要改数据库表。已知: 数据库表有几千万条数据,其中高级会员有几千位,为了方便调用已经缓存在中间层了,且数据库对应 ID 更新后对应缓存也会更新。 几千条数据修改语句执行完需要几分钟,这几分钟内无法接受用户数据不同步的问题。 一种常见的做法是,我们生成一份高级会员列表的拷贝,代替数据库缓存的结果,数据库只要读到对应会员 ID 就从拷贝列表中获取,数据表新增一列状态标志,操作完后这个拷贝移除,更新高级会员缓存。 但是如何生成高级会员列表拷贝呢?如果直接从几千万条用户数据中重新查询,会有较高的数据库查询成本。 模版组件通用搭建系统中,我们可以将某个拖拽到页面的区块设置为 “模版”,这个模版可以作为一个新组件被重新拖拽到任意位置,实例化任意次。实际上,这是一种分段式复制粘贴,你会如何实现这个功能呢? 意图解释解决上面问题的办法都很简单,就是基于已有对象进行复制即可,效率比 New 一个,或者工厂模式都要高。 意图:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。 所谓原型实例,就是被选为拷贝模版的那个对象,比如做钥匙例子中,你给老板的样板钥匙;两种状态表中的已有缓存高级会员列表;模版组件中选中的那个组件。然后,通过拷贝这些原型创建你想要的对象即可。 我们抽象思考一下,如果每把钥匙都遵循 Prototype 接口,提供了 clone() 方法以复制自己,那就可以快速复制任意一把钥匙。钥匙工厂可无法解决每把钥匙不一样的问题,我们要的就是和某个钥匙一模一样的副本,复制一份钥匙最简单。 高级会员状态表例子中,查询数据库的成本是高昂的,但如果仅仅复制已经查询好的列表,时间可以忽略不计,因此最经济的方案是直接复制,而不是通过工厂模式重新连接数据库并执行查询。 模版组件更是如此,我们根本没有定义那么多组件实例的基类,只要每个组件提供一个 clone() 函数,就可以立即复制任意组件实例,这无疑是最经济实惠的方案。 看到这里,你应该知道了,原型模式的精髓是对象要提供 clone() 方法,而这个 clone() 方法实现难度有高有低。 一般来说,原型模式的拷贝建议用深拷贝,毕竟新对象最好不要影响到旧对象,但是在深拷贝性能问题较大的情况下,可以考虑深浅拷贝结合,也就是将在新对象中,不会修改的数据使用浅拷贝,可能被修改的数据使用深拷贝。 结构图 Client 是发出指令的客户端,Prototype 是一个接口,描述了一个对象如何克隆自身,比如必须拥有 clone() 方法,而 ConcretePrototype 就是克隆具体的实现,不同对象有不同的实现来拷贝自身。 代码例子下面例子使用 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 模式还是有类似之处的,在隐藏创建对象细节这一点上。 使用的时候,我们就可以这样创建一个新对象: const newComponent = oldComponent.clone() 这里有两个注意点:一般来说,如果要二次修改生成的对象,不建议给 clone 函数加参数,因为这样会导致接口的不一致。 我们可以为对象实例提供一些 set 函数进行二次修改。另外,clone 函数要考虑性能,就像前面说过的,可以考虑深浅拷贝结合的方式,同时要注意当对象存在引用关系甚至循环引用时,甚至不一定能实现拷贝函数。 弊端每个设计模式必有弊端,但就像每一期都说的,有弊端不代表设计模式不好用,而是指在某种场景喜爱存在问题,我们只要规避这些场景,在合理的场景使用对应设计模式即可。 原型模式的弊端: 每个类都要实现 clone 方法,对类的实现是有一定入侵的,要修改已有类时,违背了开闭原则。 当类又调用了其他对象时,如果要实现深拷贝,需要对应对象也实现 clone 方法,整体链路可能会特别长,实现起来比较麻烦。 总结原型模式一般与工厂模式搭配使用,一般工厂方法接收一个符合原型模式的实例,就可以调用它的 clone 函数创建返回新对象啦。 代码大概是这样: // buildComponentFactory 内部通过 targetComponent.clone() 创建对象,而不是 New 或者调用其他工厂函数。const newComponent = buildComponentFactory(new Component()) 最后来一张图快速理解原型模式: 讨论地址是:精读《设计模式 - Prototype 原型模式》· Issue ##277 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Visitor 访问者模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Visitor 访问者模式》.html","content":"当前期刊数: 189 Visitor(访问者模式)Visitor(访问者模式)属于行为型模式。 意图:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。 访问者,顾名思义,就是对象访问的一种设计模式,我们可以在不改变要访问对象的前提下,对访问对象的操作做拓展。 举例子由于能应用访问者模式的场景很少,所以这里只举一个例子。 建造游戏中的资源设计假设你制作一款城市建造游戏,游戏的基础资源只有毛皮、木材、铜矿、铁矿。你需要用这些资源建造各种,比如造楼房、做衣服、制作家具、门、空调、甚至锅、健身房、游泳馆等。记住一个前提,就是你想把游戏设计的非常逼真,所以每种资源的不同使用方法都非常定制,不是简单的消耗 N 个数量就能完成,比如制作家具时,需要用到毛皮和木材,此时毛皮和木材对环境、制作人、资金都有不同的要求。 常见的想法是,我们将资源的所有使用方法都枚举在资源类中,这样资源就在用到不同场景时,调用不同方法即可。但问题是资源本身其实较为固定,我们每增加一种用途就修改一次木材、铁矿的类会显得非常麻烦。 能不能在增加新用途时,不修改原始资源类呢?答案是可以用访问者模式。 意图解释意图:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。 第一句话指明了 Visitor 的作用,即 “作用于某对象结构中的各元素的操作”,也就是 Visitor 是用于操作对象元素的。“它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作” 也就是说,你可以只修改 Visitor 本身完成新操作的定义,而不需要修改原本对象。 这看上去比较奇怪,给对象定义新的操作,竟然不用修改对象本身,而通过改另外一个对象就可以?这就是 Visitor 设计的奇妙之处,它将对象的操作权移交给了 Visitor。 结构图 Visitor:访问者接口。 ConcreteVisitor:具体的访问者。 Element 可以被访问者使用的元素,它必须定义一个 Accept 属性,接收 visitor 对象。这是实现访问者模式的关键。 ObjectStructure:对象结构,存储了多个 Element,利用 Visitor 进行批量操作。 可以看到,要实现操作权转让到 Visitor,核心是元素必须实现一个 Accept 函数,将这个对象抛给 Visitor: class ConcreteElement implements Element { public accept(visitor: Visitor) { visitor.visit(this) }} 从上面代码可以看出这样一条链路:Element 通过 accept 函数接收到 Visitor 对象,并将自己的实例抛给 Visitor 的 visit 函数,这样我们就可以在 Visitor 的 visit 方法中拿到对象实例,完成对对象的操作。 代码例子下面例子使用 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,可以看到,经历了如下过程: // 先创建元素const element = new ConcreteElement()// 访问者 Xconst visitorX = new ConcreteVisitorX()// 访问者 Yconst 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 方法: public accept(visitor: Visitor) { visitor.visit(this)} 所以又调用了 Visitor 的 visit 函数,不同的是,此时的参数是具体 Element 类型,所以可能调用到的是具体对某个元素处理的 visit 方法,比如: public visit(concreteElementA: ConcreteElementA) { console.log('X 操作 A')} 最终就输出了 “X 操作 A” 这段话。 我们可以看到这样的程序拓展性有这么些: Element 元素的所有子类都不用频繁修改,只要修改 Visitor 即可。 一个 Visitor 可以选择性的操作任何类型的 Element 子类,只要申明了处理函数即可处理,不申明就不会命中,比较方便。在城市建造的例子中,可以提现为锅需要用铁制作,但不需要消耗木材,所以不需要定义木材的 visit 方法。 可以定义多种 Visitor,对同一种 Element 子类也可以有不同的操作,这在我们城市建造的例子中,可以体现为门和窗户,对铁矿的使用是不同的。 由此一来,我们就能在城市建造的例子中拓展出任意多种使用资源的场景,而无需让资源感知到这些场景的存在。 弊端访问者模式使用场景非常有限,请确定你的场景满足以上情况再使用。如果资源并不需要频繁修改和拓展,那么就没必要使用访问者模式。 总结访问者模式的精髓,就是在不断拓展的业务场景中,防止基础元素代码不断膨胀。 假设我们这款城市建造游戏有 20 人团队开发,每周发布 2 个版本,每个版本新增了几种资源的组合使用方式,由于资源一共就木材、铁矿、铜矿那么几种,如果你作为团队负责人,任大家随意修改这些资源基础类,过不了半年就会发现,木材类的成员方法突破了 100 种,而且以每天新增 2 种的速度不断增加,你会明显发现自己精心打造的程序即将变成一堆屎山。 更要命的是,你还搞不清楚哪些场景的用法是打包的,当一种使用场景下线时,已存在的成员方法还不敢删除。 假设你用了访问者模式,会发现,每天因为迭代而新增的那几个方法,都会放到一个新 Visitor 文件下,比如一种纳米材料的门板在游戏 V1.5 版本被引进,它对材料的使用会体现在新增一个 Visitor 文件,资源本身的类不会被修改,这既不会引发协同问题,也使功能代码按照场景聚合,不论维护还是删除的心智负担都非常小。 访问者模式背后的思考本质还是,基础的元素数量一般不会随着程序迭代产生太大变化,而对这些基础元素的使用方式或组合使用会随着程序迭代不断更新,我们将变化更快的通过 Visitor 打包提取出来,自然会更利于维护。 讨论地址是:精读《设计模式 - Visitor 访问者模式》· Issue ##306 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - State 状态模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - State 状态模式》.html","content":"当前期刊数: 186 State(状态模式)State(状态模式)属于行为型模式。 意图:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。 简单来说,就是将 “一个大 class + 一堆 if else” 替换为 “一堆小 class”。一堆小 class 就是一堆状态,用一堆状态代替 if else 会更好拓展与维护。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 团队接口人团队是由很多同学组成的,但有一位接口人 TL,这位 TL 可能一会儿和产品经理谈需求,一会儿和其他 TL 谈规划,一会儿和 HR 谈人事,总之要做很多事情,很显然一个人是忙不过来的。TL 通过将任务分发给团队中每个同学,而不让他们直接和产品经理、其他 TL、HR 接触,那么这位 TL 的办事效率就会相当高,因为每个同学只负责一块具体的业务,而 TL 在不同时刻叫上不同的同学,让他们出面解决他们负责的专业领域问题,那么在外面看,这位 TL 团队能力很广,在内看,每个人负责的事情也比较单一。 台灯按钮我们经常会看到只有一个按钮的台灯,但是可以通过按钮调节亮度,大概是如下一个循环 “关 -> 弱光 -> 亮 -> 强光 -> 关”,那么每次按按钮后,要跳转到什么状态,其实和当前状态有关。我们可以用 if else 解决这个问题,也可以用状态模式解决。 用状态模式解决,就是将这四个状态封装为四个类,每个类都执行按下按钮后要跳转到的状态,这样未来新增一种模式,只要改变部分类即可。 数据库连接器在数据库连接前后,这个连接器的状态显然非常不同,我们如果仅用一个类描述数据库连接器,则内部免不了写大量分支语句进行状态判断。那么此时有更好的方案吗?状态模式告诉我们,可以创建多个不同状态类,比如连接前、连接中、连接后三种状态类,在不同时刻内部会替换为不同的子类,它们都继承同样的父类,所以外面看上去不需要感知内部的状态变化,内部又可以进行状态拆分,进行更好的维护。 意图解释意图:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。 重点在 “内部状态” 的理解,也就是状态改变是由对象内部触发的,而不是外部,所以 外部根本无需关心对象是否用了状态模式,拿数据库连接器的例子来说,不管这个类是用 if else 堆砌的,还是用状态模式做的,都完全不妨碍它对外提供的稳定 API(接口问题),所以状态模式实质上是一种内聚的设计模式。 结构图 State: 状态接口,类比为台灯状态。 ConcreteState: 具体状态,都继承于 State,类比为台灯的强光、弱光状态。 代码例子下面例子使用 typescript 编写。 abstract class Context { abstract setState(state: State): void;}// 定义状态接口interface State { // 模拟台灯点亮 show: () => string}interface Light { click: () => void}type LightState = State & Lightclass TurnOff implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '关灯' } // 按下按钮 public click() { this.context.setState(new WeakLight(this.context)) }}class WeakLight implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '弱光' } // 按下按钮 public click() { this.context.setState(new StandardLight(this.context)) }}class StandardLight implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '亮' } // 按下按钮 public click() { this.context.setState(new StrongLight(this.context)) }}class StrongLight implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '强光' } // 按下按钮 public click() { this.context.setState(new TurnOff(this.context)) }}// 台灯class Lamp extends Context { // 当前状态 ##currentState: LightState = new TurnOff(this) setState(state: LightState) { this.##currentState = state } getState() { return this.##currentState } // 按下按钮 click() { this.getState().click() }}const lamp = new Lamp() // 关闭console.log(lamp.getState().show()) // 关灯lamp.click() // 弱光console.log(lamp.getState().show()) // 弱光lamp.click() // 亮console.log(lamp.getState().show()) // 亮lamp.click() // 强光console.log(lamp.getState().show()) // 强光lamp.click() // 关闭console.log(lamp.getState().show()) // 关闭 其实有很多种方式来实现,不必拘泥于形式,大体上只要保证由多个类实现不同状态,每个类实现到下一个状态切换就好了。 弊端该用 if else 的时候还是要用,不要但凡遇到 if else 就使用状态模式,那样就是书读傻了。一定要判断,是否各状态间差异很大,且使用状态模式后维护性比 if else 更好,才应该用状态模式。 总结在合适场景下,状态模式可以使代码更符合开闭原则,每个类独立维护时,逻辑也更精简、聚焦,更易维护。 讨论地址是:精读《设计模式 - State 状态模式》· Issue ##303 · dt-fe/weekly 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"《设计模式 - Template Method 模版模式》","path":"/wiki/WebWeekly/设计模式/《设计模式 - Template Method 模版模式》.html","content":"当前期刊数: 188 Template Method(模版模式)Template Method(模版模式)属于行为型模式。 意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。TemplateMethod 使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。 举例子如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 模版文件我们办事打印的文件就是模版文件,只需要写上个人基本信息再签字就可以了,我们不需要做太多的重复劳动,因为某些场景下大部分内容是可以固化下来的。比如买卖房屋,那大部分甲方乙方的条款是固定的,最大的变化是甲方与乙方的不同,我们在模版上签字时,就是利用了模版模式减少了大量写条款的时间。 实例化实例化也可以认为是模版模式的某种表现形式,因为对于工厂方法,我们传入不同的初始值可能给出不同结果,那么实际上就是用很少的代码撬动了很大一块功能,起到了抽象作用。 Vue 模版Vue 模版更符合我们对模版直觉的理解。这个场景中,模版指的是 HTML 模版,我们只需要在模版中以 {} 形式描述一些变量,就可以生成一块只有局部变量变化的模版 DOM,非常方便。 意图解释意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。TemplateMethod 使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。 这个设计模式初衷是用于面向对象的,所以考虑的是如何在类中运用模版模式。首先定义一个父类,实现了一些算法,再将需要被子类重载的方法提出来,子类重载这些部分方法后,即可利用父类实现好的算法做一些功能。 比如说父类方法 function a() { b() + c() },此时子类只需要重定义 b 与 c 方法,即可复用 a 的算法(b 与 c 相加)。当然这个例子比较简单,当算法较为复杂时,模版模式的好处将凸显出来。 结构图 ConcreteClass: 具体的父类。可以看到父类中实现了 TemplateMethod,其调用了 primitiveOperation1 与 primitiveOperation2, 所以子类只需要重载这两个方法,即可享用 TemplateMethod 提供的算法。 假设 TemplateMethod 是 OpenDocument 打开文档的作用,那么 primitiveOperation1 可能是 CanOpen 校验,primitiveOperation2 可能是 ReadDocument 读取文档方法。 我们只要专心实现具体的细节方法,而不需要关心他们之间是如何相互作用的,父级会帮我们实现它。之后我们就可以调用子类的 OpenDocument 实现打开文档了。 代码例子下面例子使用 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 如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。 关注 前端精读微信公众号 版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)"},{"title":"公众号标签","path":"/wiki/YuDaoBoot/公众号手册/公众号标签/公众号标签.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号标签 本章节,讲解公众号标签的相关内容,支持对标签进行创建、查询、修改、删除等操作,也可以对用户进行打标签、取消标签等操作,对应 《微信公众号官方文档 —— 用户标签管理》 (opens new window) 文档。 # 1. 表结构 公众号粉丝对应 mp_tag 表,结构如下图所示: 而给用户打上标签后,存储在 mp_user 表的 tag_ids 字段中(多个标签之间用 , 分隔),不单独存储关联表。 # 2. 标签管理界面 前端:/@views/mp/tag (opens new window) 后端:MpTagController (opens new window) # 3. 同步标签 点击标签管理界面的【同步】按钮,可以从公众号同步所有的标签信息,存储到 mp_tag 表中。 对应后端的 MpTagServiceImpl (opens new window) 的 syncTag 方法。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号粉丝 公众号消息 ← 公众号粉丝 公众号消息→"},{"title":"公众号图文","path":"/wiki/YuDaoBoot/公众号手册/公众号图文/公众号图文.html","content":"开发指南公众号手册 芋道源码 2023-01-30 目录 公众号图文 本章节,讲解公众号图文的相关内容,包括两部分: ① 在 [公众号管理 -> 图文草稿箱] 菜单中,创建一个图文草稿。如下图所示: ② 点击【发布】按钮,将图文草稿发布到公众号,成为一个图文记录,展示在 [公众号管理 -> 图文发表记录] 菜单中。如下图所示: # 1. 表结构 暂无,全部基于微信公众号提供的 API 接口。 图文草稿箱:《微信公众号官方文档 —— 草稿箱》 (opens new window) 图文发表记录:《微信公众号官方文档 —— 发布能力》 (opens new window) # 2. 图文草稿箱界面 前端:/@views/mp/draft (opens new window) 后端:MpDraftController (opens new window) # 3. 图文发表记录界面 前端:/@views/mp/freePublish (opens new window) 后端:MpFreePublishController (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号素材 公众号统计 ← 公众号素材 公众号统计→"},{"title":"公众号粉丝","path":"/wiki/YuDaoBoot/公众号手册/公众号粉丝/公众号粉丝.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号粉丝 本章节,讲解公众号粉丝的相关内容,包括关注、取消关注等等,对应 《微信公众号官方文档 —— 获取用户列表》 (opens new window) 文档。 # 1. 表结构 公众号粉丝对应 mp_user 表,结构如下图所示: 注意,自 2021-12-27 开始,公众号接口不再返回头像和昵称,只能通过微信公众号的网页登录获取。因此,表中的 avatar 和 nickname 字段,往往是空的。 # 2. 粉丝管理界面 前端:/@views/mp/user (opens new window) 后端:MpUserController (opens new window) # 3. 同步粉丝 点击粉丝管理界面的【同步】按钮,可以 异步 从公众号同步所有的粉丝信息,存储到 mp_user 表中。如果你的粉丝较多,可能需要等待一段时间。 对应后端的 MpUserServiceImpl (opens new window) 的 syncUser 方法。 # 4. 关注 SubscribeHandler 用户关注公众号时,会触发 SubscribeHandler (opens new window) 处理器,新增或修改 mp_user 粉丝信息。 # 5. 取关 UnsubscribeHandler 用户取消关注公众号时,会触发 UnsubscribeHandler (opens new window) 处理器,标记 mp_user 粉丝信息为取消关注,设置 subscribe_status 字段为 0。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号接入 公众号标签 ← 公众号接入 公众号标签→"},{"title":"公众号消息","path":"/wiki/YuDaoBoot/公众号手册/公众号消息/公众号消息.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号消息 本章节,讲解公众号消息的相关内容,对应 [公众号管理 -> 消息管理] 菜单。如下图所示: # 1. 表结构 公众号消息对应 mp_message 表,结构如下图所示: ① type 字段:消息类型,包括文本、图片、语音、视频、小视频、图文、音乐、地理位置、链接、事件等类型,对应 mp_message_type 字典。 ② send_from 字段:消息发送方,分成两类: 【接收】用户发送给公众号:接收普通消息 (opens new window)、接收事件推送 (opens new window) 【发送】公众号发给用户:被动回复用户消息 (opens new window)、客服消息 (opens new window) # 2. 消息管理界面 前端:/@views/mp/message (opens new window) 后端:MpMessageController (opens new window) # 3.【接收】 # 3.1 接收普通消息 对应 《微信公众号官方文档 —— 接收普通消息》 (opens new window) 文档。 当用户向公众账号发消息时,会被 MessageReceiveHandler (opens new window) 处理,记录到 mp_message 表,消息类型为文本、图片、语音、视频、小视频、地理位置、链接。如下图所示: # 3.2 接收事件消息 对应 《微信公众号官方文档 —— 接收事件推送》 (opens new window) 文档。 在用户和公众号产交互的过程中,会被 MessageReceiveHandler (opens new window) 处理,记录到 mp_message 表,消息类型仅为事件。 # 4.【发送】 # 4.1 被动回复用户消息 对应 《微信公众号官方文档 —— 被动回复用户消息》 (opens new window) 文档。 在被动回复用户消息时,统一由 MpMessageServiceImpl (opens new window) 的 sendOutMessage 方法来构建回复消息,也会记录到 mp_message 表,消息类型为文本、图片、语音、视频、音乐、图文。如下图所示: # 4.2 主动发送客服消息 对应 《微信公众号官方文档 —— 客服消息》 (opens new window) 文档。 点击消息管理界面的【消息】按钮,可以主动发送客服消息给用户。如下图所示: 主动发送客服消息,统一由 MpMessageServiceImpl (opens new window) 的 sendKefuMessage 方法来构建客服消息,也会记录到 mp_message 表,消息类型为文本、图片、语音、视频、音乐、图文。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号标签 自动回复 ← 公众号标签 自动回复→"},{"title":"公众号素材","path":"/wiki/YuDaoBoot/公众号手册/公众号素材/公众号素材.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号素材 本章节,讲解公众号素材的相关内容,包括图片、语音、视频素材,不包括图文素材。对应 [公众号管理 -> 素材管理] 菜单,如下图所示: 在配置公众号的自动回复、菜单的自动回复、主动给用户发送消息时,都可以使用素材。 # 1. 表结构 公众号素材对应 mp_material 表,结构如下图所示: ① type 字段:素材类型。对应微信的素材类型,包括 image 图片、voice 语音、video 视频。 ② media_id 字段:素材的媒体编号,对应微信公众号的 media_id。 ③ permanent 字段:是否永久。true 代表 永久素材 (opens new window),false 代表 临时素材 (opens new window)。 ④ mp_url 字段:公众号存储素材的 URL 地址,有且仅有永久素材才有。 ⑤ url 字段:存储在自己文件服务器上的 URL 地址,解决临时素材只在微信服务器上保存 3 天的问题,也解决图片素材的 mp_url 无法在自己管理后台显示的问题。 # 2. 素材管理界面 前端:/@views/mp/material (opens new window) 后端:MpMaterialController (opens new window) # 3. 永久素材 对应 《微信公众号官方文档 —— 永久素材》 (opens new window) 文档。 MpMaterialController (opens new window) 的 uploadPermanentMaterial 方法对应的接口,实现了上传【永久】素材到公众号。如下图所示: # 4. 临时素材 对应 《微信公众号官方文档 —— 临时素材》 (opens new window) 文档。 ① 来源一:主动发送客服消息给用户时,如果是图片、语音、视频素材,需要先上传到微信服务器,获得到 media_id 后,才能发送给用户。 此时,可调用 MpMaterialController (opens new window) 的 uploadTemporaryMaterial 方法对应的接口,实现了上传【临时】素材到公众号。如下图所示: ② 来源二:在接收到用户消息时,如果是图片、语音、视频素材,需要先下载到自己的文件服务器上,避免超过 3 天后无法访问的问题。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号菜单 公众号图文 ← 公众号菜单 公众号图文→"},{"title":"公众号接入","path":"/wiki/YuDaoBoot/公众号手册/公众号接入/公众号接入.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号接入 本章节,讲解如果将你的公众号,接入到系统中。步骤如下: 第一步,申请公众号(可选) 第二步,在系统中,添加公众号账号 第三步,在公众号中,配置接入信息 # 1. 配置步骤 本小节,手把手教你如何将公众号接入到系统中。 # 第一步,申请公众号(可选) 友情提示:如果你已经有公众号,可以忽略这一步。 ① 如果你还没有公众号,可以申请一个测试帐号。 申请地址:微信公众平台接口测试帐号申请 (opens new window) ② 申请完成后,获得一个测试号。如下图所示: # 第二步,添加公众号账号 点击 [公众号管理 -> 账号管理] 菜单,添加一个公众号账号。如下图所示: # 第三步,配置接入信息 ① 找一个内网穿透工具,转发到本地的 48080 端口。例如说,ngrok (opens new window)、frp (opens new window)、natapa (opens new window) 等等。 这里,我们使用 natapp 作为内网传统工具。访问 https://natapp.cn/tunnel/buy/free (opens new window) 地址,免费购买一个隧道。如下图所示: ② 购买完成后,参考 《NATAPP 1 分钟快速新手图文教程》 (opens new window) 文档,将 natapp 进行启动。如下图所示: ③ 打开微信公众号界面,填写 URL 和 Token 信息。如下图所示: 点击提交后,看到“配置成功”提示,说明配置成功。 # 2. 实现代码 本小节,将介绍如何实现公众号接入的代码。 # 2.1 表结构 公众号账号对应 mp_account 表,结构如下图所示: # 2.2 账号管理界面 前端:/@views/mp/account (opens new window) 后端:MpAccountController (opens new window) # 2.3 配置接入回调 在 第三步,配置接入信息 时,微信公众号会回调系统的 GET /admin-api/mp/open/{appID} 接口,进行接入配置的验证。对应 MpOpenController (opens new window) 类的 checkSignature 方法,如下图所示: 对应 《微信公众号官方文档 —— 接入指南》 (opens new window) 文档。 友情提示: 项目使用的微信工具开发包是 weixin-java-mp (opens new window),超级好用! # 2.4 消息处理 配置接入完成后,用户发给公众号的消息,公众号都会回调到 POST /admin-api/mp/open/{appID} 接口,进行消息的处理。对应 MpOpenController (opens new window) 类的 handleMessage 方法,如下图所示: 核心逻辑是第二步,再解析到消息后,交给 WxMpMessageRouter 进行消息的处理。WxMpMessageRouter 在 DefaultMpServiceFactory (opens new window) 初始化,设置每种消息对应的 handler (opens new window) 处理器。如下图所示: 具体每个处理器的实现,后续每个章节单独详细讲解。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 功能开启 公众号粉丝 ← 功能开启 公众号粉丝→"},{"title":"公众号统计","path":"/wiki/YuDaoBoot/公众号手册/公众号统计/公众号统计.html","content":"开发指南公众号手册 芋道源码 2023-01-30 目录 公众号统计 本章节,讲解公众号统计的相关内容,包括用户、消息、接口分析。对应 [公众号管理 -> 数据统计] 菜单,如下图所示: # 1. 表结构 暂无,全部基于微信公众号提供的 API 接口。 用户增减数据 + 累计用户数据:《微信公众号官方文档 —— 用户分析》 (opens new window) 消息概况数据:《微信公众号官方文档 —— 消息分析》 (opens new window) 接口分析数据:《微信公众号官方文档 —— 接口分析》 (opens new window) # 2. 数据统计界面 前端:/@views/mp/statistics (opens new window) 后端:MpStatisticsController (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号图文 功能开启 ← 公众号图文 功能开启→"},{"title":"功能开启","path":"/wiki/YuDaoBoot/公众号手册/功能开启/功能开启.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 功能开启 微信公众号 (opens new window)的功能,由 yudao-module-mp (opens new window) 模块实现,对应前端代码为 @/views/mp (opens new window) 目录。 主要包括如下 10 个功能(菜单): 功能 描述 账号管理 配置接入的微信公众号,可支持多个公众号 数据统计 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 粉丝管理 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 消息管理 查看粉丝发送的消息列表,可主动回复粉丝消息 自动回复 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 标签管理 对公众号的标签进行创建、查询、修改、删除等操作 菜单管理 自定义公众号的菜单,也可以从公众号同步菜单 素材管理 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 图文草稿箱 新增常用的图文素材到草稿箱,可发布到公众号 图文发表记录 查看已发布成功的图文素材,支持删除操作 考虑到编译速度,默认 yudao-module-mp 模块是关闭的,需要手动开启。步骤如下: 第一步,开启 yudao-module-mp 模块 第二步,导入公众号的 SQL 数据库脚本 第三步,重启后端项目,确认功能是否生效 # 1. 第一步,开启模块 ① 修改根目录的 pom.xml (opens new window) 文件,取消 yudao-module-mp 模块的注释。如下图所示: ② 修改 yudao-server 目录的 pom.xml (opens new window) 文件,引入 yudao-module-mp 模块。如下图所示: ③ 点击 IDEA 右上角的【Reload All Maven Projects】,刷新 Maven 依赖。如下图所示: # 2. 第二步,导入 SQL 将 mp.sql (opens new window) 文件导入到数据库中。如下图所示: 以 mp_ 作为前缀的表,就是公众号模块的表。 # 3. 第三步,重新项目 重启后端项目,然后访问前端的公众号菜单,确认功能是否生效。如下图所示: 至此,我们就成功开启了公众号的功能 🙂 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 大屏设计器 公众号接入 ← 大屏设计器 公众号接入→"},{"title":"自动回复","path":"/wiki/YuDaoBoot/公众号手册/自动回复/自动回复.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 自动回复 本章节,讲解自动回复的相关内容,对应 [公众号管理 -> 自动回复] 菜单。如下图所示: 在用户关注、发送消息时,公众号可以自动回复消息给用户。 # 1. 表结构 自动回复对应 mp_auto_reply 表,结构如下图所示: type 字段:回复类型, 1 - 关注回复:用户关注公众号时 3 - 关键字回复:消息类型为文本时,匹配到关键字 2 - 消息回复:没有匹配到关键字时,根据消息类型 # 2. 自动回复界面 前端:/@views/mp/autoReply (opens new window) 后端:MpAutoReplyController (opens new window) # 3. 关注回复 用户关注公众号时,被动回复用户消息,由 MpAutoReplyServiceImpl (opens new window) 的 replyForSubscribe 方法来生成回复内容。如下图所示: # 4. 消息回复 & 关键字回复 用户发送消息给公众号时,自动回复消息给用户,分为两种情况: 关键字回复:消息类型为文本时,匹配到关键字,自动回复消息 消息回复:没有匹配到关键字时,根据消息类型,自动回复消息 这两种情况,由 MessageAutoReplyHandler (opens new window) 调用 MpAutoReplyServiceImpl (opens new window) 的 replyForMessage 方法来生成回复内容。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号消息 公众号菜单 ← 公众号消息 公众号菜单→"},{"title":"Icon 图标","path":"/wiki/YuDaoBoot/前端手册 Vue 2/Icon 图标/Icon 图标.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 Icon 图标 Element UI 内置多种 Icon 图标,可参考 Element Icon 图标 (opens new window) 的文档。 在项目的 /src/assets/icons/svg (opens new window) 目录下,自定义了 Icon 图标,默认注册到全局中,可以在项目中任意地方使用。如下图所示: # 1. 使用方式 <!-- 示例一: icon-class 为 icon 的名字 class-name 为 icon 的自定义 class--><svg-icon icon-class="password" class-name='custom-class' /><!-- 示例二: icon 为 Element UI 的图标--><el-button icon="el-icon-plus">新增</el-button><!-- 示例三:结合上述两示例 --><el-button> <svg-icon icon-class="password" class-name='custom-class' /> 新增</el-button> # 2. 自定义图标 ① 访问 https://www.iconfont.cn/ ( opens new window) 地址,搜索你想要的图标,下载 SVG 格式。如下图所示: 友情提示:其它 SVG 图标网站也可以。 ② 将 SVG 图标添加到 @/icons/svg ( opens new window) 目录下,然后进行使用。 <svg-icon icon-class="helpless" /> # 3. 改变颜色 <svg-icon /> 默认会读取其父级的 color fill: currentColor; 。 你可以改变父级的 color ,或者直接改变 fill 的颜色即可。 疑问: 如果你遇到图标颜色不对,可以参照本 issue ( opens new window) 进行修改 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 菜单路由 字典数据 ← 菜单路由 字典数据→"},{"title":"字典数据","path":"/wiki/YuDaoBoot/前端手册 Vue 2/字典数据/字典数据.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 字典数据 本小节,讲解前端如何使用 [系统管理 -> 字典管理] 菜单的字典数据,例如说字典数据的下拉框、单选 / 多选按钮、高亮展示等等。 # 1. 全局缓存 用户登录成功后,前端会从后端获取到全量的字典数据,缓存在 store 中。如下图所示: 这样,前端在使用到字典数据时,无需重复请求后端,提升用户体验。 不过,缓存暂时未提供刷新,所以在字典数据发生变化时,需要用户刷新浏览器,进行重新加载。 # 2. DICT_TYPE 在 dict.js (opens new window) 文件中,使用 DICT_TYPE 枚举了字典的 KEY。如下图所示: 后续如果有新的字典 KEY,需要你自己进行添加。 # 3. DictTag 字典标签 <dict-tag /> (opens new window) 组件,翻译字段对应的字典展示文本,并根据 colorType、cssClass 进行高亮。使用示例如下: <!-- type: 字典 KEY value: 字典值--><dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_TYPE" :value="row.logType" /> # 4. 字典工具类 在 dict.js (opens new window) 文件中,提供了字典工具类,方法如下: // 获取 dictType 对应的数据字典数组export function getDictDatas(dictType) { /** 省略代码 */ }// 获得 dictType + value 对应的字典展示文本export function getDictDataLabel(dictType, value) { /** 省略代码 */ } 结合 Element UI 的表单组件,使用示例如下: <!-- radio 单选框 --><el-radio v-for="dict in this.getDictDatas(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="parseInt(dict.value)">{{dict.label}}</el-radio><!-- select 下拉框 --><el-select v-model="form.code" placeholder="请选择渠道编码" clearable> <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE)" :key="dict.value" :label="dict.label" :value="dict.value"/></el-select> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 Icon 图标 系统组件 ← Icon 图标 系统组件→"},{"title":"开发规范","path":"/wiki/YuDaoBoot/前端手册 Vue 2/开发规范/开发规范.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 开发规范 # 1. view 页面 在 @views (opens new window) 目录下,每个模块对应一个目录,它的所有功能的 .vue 都放在该目录里。 一般来说,一个路由对应一个 .vue 文件。 # 2. api 请求 在 @/api (opens new window) 目录下,每个模块对应一个 .api 文件。 每个 API 方法,会调用 request 方法,发起对后端 RESTful API 的调用。 # 2.1 请求封装 @/utils/request (opens new window) 基于 axios (opens new window) 封装,统一处理 GET、POST 方法的请求参数、请求头,以及错误提示信息等。 # 2.1.1 创建 axios 实例 baseURL 基础路径 timeout 超时时间 实现代码 import axios from 'axios'// 创建 axios 实例const service = axios.create({ // axios 中请求配置有 baseURL 选项,表示请求 URL 公共部分 baseURL: process.env.VUE_APP_BASE_API + '/admin-api/', // 此处的 /admin-api/ 地址,原因是后端的基础路径为 /admin-api/ // 超时 timeout: 10000}) # 2.1.2 Request 拦截器 Authorization、tenant-id 请求头 GET 请求参数的拼接 实现代码 import { getToken } from '@/utils/auth'import { getTenantEnable } from "@/utils/ruoyi";import Cookies from "js-cookie";service.interceptors.request.use(config => { // 是否需要设置 token const isToken = (config.headers || {}).isToken === false if (getToken() && !isToken) { config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改 } // 设置租户 if (getTenantEnable()) { const tenantId = Cookies.get('tenantId'); if (tenantId) { config.headers['tenant-id'] = tenantId; } } // get 请求映射 params 参数 if (config.method === 'get' && config.params) { let url = config.url + '?'; for (const propName of Object.keys(config.params)) { const value = config.params[propName]; var part = encodeURIComponent(propName) + "="; if (value !== null && typeof(value) !== "undefined") { if (typeof value === 'object') { for (const key of Object.keys(value)) { let params = propName + '[' + key + ']'; var subPart = encodeURIComponent(params) + "="; url += subPart + encodeURIComponent(value[key]) + "&"; } } else { url += part + encodeURIComponent(value) + "&"; } } } url = url.slice(0, -1); config.params = {}; config.url = url; } return config}, error => { console.log(error) Promise.reject(error)}) # 2.1.3 Response 拦截器 Token 失效、登录过期时,跳回首页 请求失败,Message 错误提示 实现代码 import { Notification, MessageBox, Message } from 'element-ui'import store from '@/store'import errorCode from '@/utils/errorCode'import Cookies from "js-cookie";export let isRelogin = { show: false };service.interceptors.response.use(res => { // 未设置状态码则默认成功状态 const code = res.data.code || 200; // 获取错误信息 const msg = errorCode[code] || res.data.msg || errorCode['default'] if (code === 401) { if (!isRelogin.show) { isRelogin.show = true; MessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' } ).then(() => { isRelogin.show = false; store.dispatch('LogOut').then(() => { location.href = '/index'; }) }).catch(() => { isRelogin.show = false; }); } return Promise.reject('无效的会话,或者会话已过期,请重新登录。') } else if (code === 500) { Message({ message: msg, type: 'error' }) return Promise.reject(new Error(msg)) } else if (code !== 200) { Notification.error({ title: msg }) return Promise.reject('error') } else { // 请求成功! return res.data } }, error => { console.log('err' + error) let { message } = error; if (message === "Network Error") { message = "后端接口连接异常"; } else if (message.includes("timeout")) { message = "系统接口请求超时"; } else if (message.includes("Request failed with status code")) { message = "系统接口" + message.substr(message.length - 3) + "异常"; } Message({ message: message, type: 'error', duration: 5 * 1000 }) return Promise.reject(error) }) # 2.2 交互流程 一个完整的前端 UI 交互到服务端处理流程,如下图所示: 以 [系统管理 -> 用户管理] 菜单为例,查看它是如何读取用户列表的。代码如下: // ① api/system/user.jsimport request from '@/utils/request'// 查询用户列表export function listUser(query) { return request({ url: '/system/user/page', method: 'get', params: query })}// ② views/system/user/index.vueimport { listUser } from "@/api/system/user";export default { data() { userList: null, loading: true }, methods: { getList() { this.loading = true listUser().then(response => { this.userList = response.rows this.loading = false }) } }} # 2.3 自定义 baseURL 基础路径 如果想要自定义的 baseURL 基础路径,可以通过 baseURL 进行直接覆盖。示例如下: export function listUser(query) { return request({ url: '/system/user/page', method: 'get', params: query, baseURL: 'https://www.iocoder.cn' // 自定义 })} # 3. component 组件 ① 在 @/components ( opens new window) 目录下,实现全局 组件,被所有模块所公用。例如说,富文本编辑器、各种各搜索组件、封装的分页组件等等。 ② 每个模块的业务组件,可实现在 views 目录下,自己模块的目录的 components 目录下,避免单个 .vue 文件过大,降低维护成功。例如说, @/views/pay/app/components/xxx.vue。 # 4. style 样式 ① 在 @/styles ( opens new window) 目录下,实现全局 样式,被所有页面所公用。 ② 每个 .vue 页面,可在 <style /> 标签中添加样式,注意需要添加 scoped 表示只作用在当前页面里,避免造成全局的样式污染。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 服务监控 菜单路由 ← 服务监控 菜单路由→"},{"title":"系统组件","path":"/wiki/YuDaoBoot/前端手册 Vue 2/系统组件/系统组件.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 系统组件 # 1. 引入三方组件 除了 Element UI 组件以及项目内置的系统组件,有时还需要引入其它三方组件 (opens new window)。 # 1.1 如何安装 这里,以引入 vue-count-to (opens new window) 为例。在终端输入下面的命令完成安装: ## 加上 --save 参数,会自动添加依赖到 package.json 中去。npm install vue-count-to --save # 1.2 如何注册 Vue 注册组件有两种方式:全局注册、局部注册。 # 1.2.1 局部注册 在对应的 Vue 页面中,使用 components 属性来注册组件。代码如下: <template> <countTo :startVal='startVal' :endVal='endVal' :duration='3000'></countTo></template><script>import countTo from 'vue-count-to';export default { components: { countTo }, // components 属性 data () { return { startVal: 0, endVal: 2017 } }}</script> # 1.2.2 全局注册 ① 在 main.js ( opens new window) 中,全局注册组件。代码如下: import countTo from 'vue-count-to'Vue.component('countTo', countTo) ② 在对应的 Vue 页面中,直接使用组件,无需注册。代码如下: <template> <countTo :startVal='startVal' :endVal='endVal' :duration='3000'></countTo></template> # 2. 系统组件 项目使用到的相关组件。 # 2.1 基础框架组件 element-ui ( opens new window) vue-element-admin ( opens new window) # 2.2 树形选择组件 vue-treeselect ( opens new window) 在 menu/index.vue ( opens new window) 的使用案例: <el-form-item label="上级菜单"> <treeselect v-model="form.parentId" :options="menuOptions" :normalizer="normalizer" :show-count="true" placeholder="选择上级菜单"/></el-form-item> # 2.3 表格分页组件 el-pagination (opens new window),二次封装成 pagination (opens new window) 组件。 在 notice/index.vue (opens new window) 的使用案例: <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize" @pagination="getList"/> # 2.4 工具栏右侧组件 right-toolbar (opens new window) 在 notice/index.vue (opens new window) 的使用案例: <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> # 2.5 文件上传组件 file-upload (opens new window) # 2.6 图片上传组件 图片上传组件 image-upload (opens new window) 图片预览组件 image-preview (opens new window) # 2.7 富文本编辑器 quill (opens new window),二次封装成 Editor (opens new window) 组件。 在 notice/index.vue (opens new window) 的使用案例: <el-form-item label="内容"> <editor v-model="form.content" :min-height="192"/></el-form-item> # 2.8 表单设计组件 ① 表单设计组件 form-generator (opens new window) 在 build/index.vue (opens new window) 中使用,效果如下图: ② 表单展示组件 parser (opens new window),基于 form-generator (opens new window) 封装。 在 processInstance/create.vue (opens new window) 的使用案例: <parser :key="new Date().getTime()" :form-conf="detailForm" @submit="submitForm" /> # 2.9 工作流组件 bpmn-process-designer (opens new window),二次封装成 bpmnProcessDesigner (opens new window) 工作流设计组件 ① 工作流设计组件 my-process-designer (opens new window),在 bpm/model/modelEditor.vue (opens new window) 中使用案例: <!-- 流程设计器,负责绘制流程等 --><my-process-designer :key="`designer-${reloadIndex}`" v-model="xmlString" v-bind="controlForm" keyboard ref="processDesigner" @init-finished="initModeler" @save="save"/><!-- 流程属性器,负责编辑每个流程节点的属性 --><my-properties-panel :key="`penal-${reloadIndex}`" :bpmn-modeler="modeler" :prefix="controlForm.prefix" class="process-panel" :model="model" /> ② 工作流展示组件 my-process-viewer (opens new window),在 bpm/model/modelEditor.vue (opens new window) 中使用案例: <my-process-viewer key="designer" v-model="bpmnXML" v-bind="bpmnControlForm" :activityData="activityList" :processInstanceData="processInstance" :taskData="tasks" /> # 2.10 Cron 表达式组件 vue-crontab (opens new window),二次封装成 crontab (opens new window) 组件。 在 job/index.vue (opens new window) 的使用案例: <crontab @hide="openCron=false" @fill="crontabFill" :expression="expression"></crontab> # 2.11 内容复制组件 clipboard (opens new window),使用可见 文档 (opens new window)。 在 codegen/index.vue (opens new window) 的使用案例: <el-link :underline="false" icon="el-icon-document-copy" style="float:right" v-clipboard:copy="item.code" v-clipboard:success="clipboardSuccess"> 复制</el-link> # 3. 其它推荐组件 推荐一些其它组件,可自己引入后使用。 Tree Table 树形表格:使用文档 (opens new window) Excel 前端直接导出:使用文档 (opens new window) CodeMirror 代码编辑器:使用文档 (opens new window) wangEditor 文本编辑器:使用文档 (opens new window) mavonEditor Markdown 编辑器:使用文档 (opens new window) # 4. 自定义组件 在 @/components (opens new window) 目录下,创建 .vue 文件,在通过 components 进行注册即可。 # 4.1 创建使用 新建一个简单的 a 组件来举例子。 ① 在 @/components/ 目录下,创建 test 文件,再创建 a.vue 文件。代码如下: <!-- 子组件 --><template> <div>这是a组件</div></template> ② 在其它 Vue 页面,导入并注册后使用。代码如下: <!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa></testa> <!-- 3. 使用 --> </div></template><script>import a from "@/components/a"; // 1. 引入export default { components: { testa: a } // 2. 注册};</script> # 4.2 组件通信 基于上述的 a 示例组件,讲解父子组件如何通信。 ① 子组件通过 props 属性,来接收父组件传递的值。代码如下: <!-- 子组件 --><template> <div>这是a组件 name:{{ name }}</div></template><script> export default { props: { // 1. props 的 name 进行接收 name: { type: String, default: "" }, } };</script><!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa :name="name"></testa> <!-- 2. :name 传入 --> </div></template><script>import a from "@/components/a";export default { components: { testa: a }, data() { return { name: "芋道" }; },};</script> ② 子组件通过 $emit 方法,让父组件监听到自定义事件。代码如下: <!-- 子组件 --><template> <div> 这是a组件 name:{{ name }} <button @click="click">发送</button> </div></template><script>export default { props: { name: { type: String, default: "" }, }, data() { return { message: "我是来自子组件的消息" }; }, methods: { click() { this.$emit("ok", this.message); // 1. $emit 方法,通知 ok 事件,message 是参数 }, },};</script><!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa :name="name" @ok="ok"></testa> 子组件传来的值 : {{ message }} </div></template><script>import a from "@/components/a";export default { components: { testa: a }, data() { return { name: "芋道", message: "" }; }, methods: { ok(message) { // 2. 声明 ok 方法,监听 ok 自定义事件 this.message = message; }, },};</script> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 字典数据 通用方法 ← 字典数据 通用方法→"},{"title":"菜单路由","path":"/wiki/YuDaoBoot/前端手册 Vue 2/菜单路由/菜单路由.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 菜单路由 前端项目基于 element-ui-admin 实现,它的 路由和侧边栏 (opens new window) 是组织起一个后台应用的关键骨架。 侧边栏和路由是绑定在一起的,所以你只有在 @/router/index.js (opens new window) 下面配置对应的路由,侧边栏就能动态的生成了,大大减轻了手动重复编辑侧边栏的工作量。 当然,这样就需要在配置路由的时候,遵循一些约定的规则。 # 1. 路由配置 首先,我们了解一下本项目配置路由时,提供了哪些配置项: // 当设置 true 的时候该路由不会在侧边栏出现 如 401,login 等页面,或者如一些编辑页面 /edit/1hidden: true // (默认 false)// 当设置 noRedirect 的时候该路由在面包屑导航中不可被点击redirect: 'noRedirect'// 1. 当你一个路由下面的 children 声明的路由大于 1 个时,自动会变成嵌套的模式。例如说,组件页面// 2. 只有一个时,会将那个子路由当做根路由显示在侧边栏。例如说,如引导页面// 若你想不管路由下面的 children 声明的个数都显示你的根路由,// 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由alwaysShow: truename: 'router-name' // 设定路由的名字,一定要填写不然使用 <keep-alive> 时会出现各种问题meta: { roles: ['admin', 'editor'] // 设置该路由进入的权限,支持多个权限叠加 title: 'title' // 设置该路由在侧边栏和面包屑中展示的名字 icon: 'svg-name' // 设置该路由的图标,支持 svg-class,也支持 el-icon-x element-ui 的 icon noCache: true // 如果设置为 true,则不会被 <keep-alive> 缓存(默认 false) breadcrumb: false // 如果设置为 false,则不会在breadcrumb面包屑中显示(默认 true) affix: true // 如果设置为 true,它则会固定在 tags-view 中(默认 false) // 当路由设置了该属性,则会高亮相对应的侧边栏。 // 这在某些场景非常有用,比如:一个文章的列表页路由为:/article/list // 点击文章进入文章详情页,这时候路由为 /article/1,但你想在侧边栏高亮文章列表的路由,就可以进行如下设置 activeMenu: '/article/list'} 普通示例 { path: '/system/test', component: Layout, redirect: 'noRedirect', hidden: false, alwaysShow: true, meta: { title: '系统管理', icon : "system" }, children: [{ path: 'index', component: (resolve) => require(['@/views/index'], resolve), name: 'Test', meta: { title: '测试管理', icon: 'user' } }]} 外链示例 { path: 'https://www.iocoder.cn', meta: { title: '芋道源码', icon : "guide" }} # 2. 路由 项目的路由分为两种:静态路由、动态路由。 # 2.1 静态路由 静态路由,代表那些不需要动态判断权限的路由,如登录页、404、个人中心等通用页面。 在 @/router/index.js ( opens new window) 的 constantRoutes ,就是配置对应的公共路由。如下图所示: # 2.2 动态路由 动态路由,代表那些需要根据用户动态判断权限,并通过 addRoutes ( opens new window) 动态添加的页面,如用户管理、角色管理等功能页面。 在用户登录成功后,会触发 @/store/modules/permission.js ( opens new window) 请求后端的菜单 RESTful API 接口,获取用户有权限 的菜单列表,并转化添加到路由中。如下图所示: 友情提示: 动态路由可以在 [系统管理 -> 菜单管理] 进行新增和修改操作,请求的后端 RESTful API 接口是 /admin-api/system/list-menus ( opens new window) 动态路由在生产环境下会默认使用路由懒加载,实现方式参考 loadView ( opens new window) 方法的判断 # 2.3 路由跳转 使用 router.push 方法,可以实现跳转到不同的页面。 // 简单跳转this.$router.push({ path: "/system/user" });// 跳转页面并设置请求参数,使用 `query` 属性this.$router.push({ path: "/system/user", query: {id: "1", name: "芋道"} }); # 3. 菜单管理 项目的菜单在 [系统管理 -> 菜单管理] 进行管理,支持无限 层级,提供目录、菜单、按钮三种类型。如下图所示: 菜单可在 [系统管理 -> 角色管理] 被分配给角色。如下图所示: # 3.1 新增目录 ① 大多数情况下,目录是作为菜单的【分类】: ② 目录也提供实现【外链】的能力: # 3.2 新增菜单 # 3.3 新增按钮 # 4. 权限控制 前端通过权限控制,隐藏用户没有权限的按钮等,实现功能级别的权限。 友情提示:前端的权限控制,主要是提升用户体验,避免操作后发现没有权限。 最终在请求到后端时,还是会进行一次权限的校验。 # 4.1 v-hasPermi 指令 v-hasPermi ( opens new window) 指令,基于权限字符,进行权限的控制。 <!-- 单个 --><el-button v-hasPermi="['system:user:create']">存在权限字符串才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasPermi="['system:user:create', 'system:user:update']">包含权限字符串才能看到</el-button> # 4.2 v-hasRole 指令 v-hasRole ( opens new window) 指令,基于角色标识,机进行的控制。 <!-- 单个 --><el-button v-hasRole="['admin']">管理员才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasRole="['role1', 'role2']">包含角色才能看到</el-button> # 4.3 结合 v-if 指令 在某些情况下,它是不适合使用 v-hasPermi 或 v-hasRole 指令,如元素标签组件。此时,只能通过手动设置 v-if,通过使用全局权限判断函数,用法是基本一致的。 <template> <el-tabs> <el-tab-pane v-if="checkPermi(['system:user:create'])" label="用户管理" name="user">用户管理</el-tab-pane> <el-tab-pane v-if="checkPermi(['system:user:create', 'system:user:update'])" label="参数管理" name="menu">参数管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin'])" label="角色管理" name="role">角色管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin','common'])" label="定时任务" name="job">定时任务</el-tab-pane> </el-tabs></template><script>import { checkPermi, checkRole } from "@/utils/permission"; // 权限判断函数export default{ methods: { checkPermi, checkRole }}</script> # 5. 页面缓存 由于目前 keep-alive 和 router-view 是强耦合的,而且查看 Vue 的文档和源码不难发现 keep-alive 的 include 默认是优先匹配组件的 name ,所以在编写路由 router 和路由对应的 view component 的时候一定要确保两者的 name 是完全一致的。 注意,切记 view component 的 name 命名时候尽量保证唯一性,切记不要和某些组件的命名重复了,不然会递归引用最后内存溢出等问题。 友情提示:页面缓存是什么? 简单来说,Tab 切换时,开启页面缓存的 Tab 保持原本的状态,不进行刷新(不请求数据)。 详细可见 Vue 文档 —— KeepAlive ( opens new window) # 5.1 静态路由的示例 ① router 路由的 name 声明如下: { path: 'create-form', component: ()=>import('@/views/form/create'), name: 'createForm', meta: { title: 'createForm', icon: 'table' }} ② view component 的 name 声明如下: export default { name: 'createForm'} 一定要保证两者的名字相同,切记写重或者写错。默认如果不写 name 就不会被缓存,详情见 issue (opens new window)。 # 5.2 动态路由的示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 开发规范 Icon 图标 ← 开发规范 Icon 图标→"},{"title":"通用方法","path":"/wiki/YuDaoBoot/前端手册 Vue 2/通用方法/通用方法.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 通用方法 本小节,分享前端项目的常用方法。 # 1. $tab 对象 @tab 对象,由 plugins/tab.js (opens new window) 实现,用于 Tab 标签相关的操作。它有如下方法: ① 打开页签 this.$tab.openPage("用户管理", "/system/user");this.$tab.openPage("用户管理", "/system/user").then(() => { // 执行结束的逻辑}) ② 修改页签 const obj = Object.assign({}, this.$route, { title: "自定义标题" })this.$tab.updatePage(obj);this.$tab.updatePage(obj).then(() => { // 执行结束的逻辑}) ③ 关闭页签 // 关闭当前 tab 页签,打开新页签const obj = { path: "/system/user" };this.$tab.closeOpenPage(obj);// 关闭当前页签,回到首页this.$tab.closePage();// 关闭指定页签const obj = { path: "/system/user", name: "User" };this.$tab.closePage(obj);this.$tab.closePage(obj).then(() => { // 执行结束的逻辑}) ④ 刷新页签 // 刷新当前页签this.$tab.refreshPage();// 刷新指定页签const obj = { path: "/system/user", name: "User" };this.$tab.refreshPage(obj);this.$tab.refreshPage(obj).then(() => { // 执行结束的逻辑}) ⑤ 关闭所有页签 this.$tab.closeAllPage();this.$tab.closeAllPage().then(() => { // 执行结束的逻辑}) ⑥ 关闭左侧页签 this.$tab.closeLeftPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeLeftPage(obj);this.$tab.closeLeftPage(obj).then(() => { // 执行结束的逻辑}) ⑦ 关闭右侧页签 this.$tab.closeRightPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeRightPage(obj);this.$tab.closeRightPage(obj).then(() => { // 执行结束的逻辑}) ⑧ 关闭其它页签 this.$tab.closeOtherPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeOtherPage(obj);this.$tab.closeOtherPage(obj).then(() => { // 执行结束的逻辑}) # 2. $modal 对象 @modal 对象,由 plugins/modal.js (opens new window) 实现,用于做消息提示、通知提示、对话框提醒、二次确认、遮罩等。它有如下方法: ① 提供成功、警告和错误等反馈信息 this.$modal.msg("默认反馈");this.$modal.msgError("错误反馈");this.$modal.msgSuccess("成功反馈");this.$modal.msgWarning("警告反馈"); ② 提供成功、警告和错误等提示信息 this.$modal.alert("默认提示");this.$modal.alertError("错误提示");this.$modal.alertSuccess("成功提示");this.$modal.alertWarning("警告提示"); ③ 提供成功、警告和错误等通知信息 this.$modal.notify("默认通知");this.$modal.notifyError("错误通知");this.$modal.notifySuccess("成功通知");this.$modal.notifyWarning("警告通知"); ④ 提供确认窗体信息 this.$modal.confirm('确认信息').then(function() { // ...}).then(() => { // ...}).catch(() => {}); ⑤ 提供遮罩层信息 // 打开遮罩层this.$modal.loading("正在导出数据,请稍后...");// 关闭遮罩层this.$modal.closeLoading(); # 3. $auth 对象 @auth 对象,由 plugins/auth.js (opens new window) 实现,用于验证用户是否拥有某(些)权限或角色。它有如下方法: ① 验证用户权限 // 验证用户是否具备某权限this.$auth.hasPermi("system:user:add");// 验证用户是否含有指定权限,只需包含其中一个this.$auth.hasPermiOr(["system:user:add", "system:user:update"]);// 验证用户是否含有指定权限,必须全部拥有this.$auth.hasPermiAnd(["system:user:add", "system:user:update"]); ② 验证用户角色 // 验证用户是否具备某角色this.$auth.hasRole("admin");// 验证用户是否含有指定角色,只需包含其中一个this.$auth.hasRoleOr(["admin", "common"]);// 验证用户是否含有指定角色,必须全部拥有this.$auth.hasRoleAnd(["admin", "common"]); # 4. $cache 对象 @auth 对象,由 plugins/cache.js (opens new window) 实现,基于 session 或 local 实现不同级别的缓存。它有如下方法: 对象名称 缓存类型 session 会话级缓存,通过 sessionStorage (opens new window) 实现 local 本地级缓存,通过 localStorage (opens new window) 实现 ① 读写 String 缓存 // local 普通值this.$cache.local.set('key', 'local value')console.log(this.$cache.local.get('key')) // 输出 'local value'// session 普通值this.$cache.session.set('key', 'session value')console.log(this.$cache.session.get('key')) // 输出 'session value' ② 读写 JSON 缓存 // local JSON值 this.$cache.local.setJSON('jsonKey', { localProp: 1 })console.log(this.$cache.local.getJSON('jsonKey')) // 输出 '{localProp: 1}'// session JSON值this.$cache.session.setJSON('jsonKey', { sessionProp: 1 })console.log(this.$cache.session.getJSON('jsonKey')) // 输出 '{sessionProp: 1}' ③ 删除缓存 this.$cache.local.remove('key')this.$cache.session.remove('key') # 5. $download 对象 $download 对象,由 plugins/download.js (opens new window) 实现,用于各种类型的文件下载。它有如下方法: 方法列表 this.$download.excel(data, fileName);this.$download.word(data, fileName);this.$download.zip(data, fileName);this.$download.html(data, fileName);this.$download.markdown(data, fileName); 在 user/index.vue (opens new window) 页面中,导出 Excel 文件的代码如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 系统组件 配置读取 ← 系统组件 配置读取→"},{"title":"IDE 调试","path":"/wiki/YuDaoBoot/前端手册 Vue 3/IDE 调试/IDE 调试.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-13 目录 IDE 调试 除了使用 Chrome 调试 JS 代码外,我们也可以使用 IDEA / WebStorm 或 VS Code 进行代码的调试。 # 1. IDEA 调试 友情提示:WebStorm 也支持。 ① 使用 npm 命令将前端项目运行起来,例如说 npm run dev。耐心等待项目启动成功~ ② 点击链接,Windows 需按住 Ctrl + Shift + 鼠标左键,MacOS 需要按住 Shift + Command + 鼠标左键。如下图所示: ③ 点击后,会跳出一个独立的 Chrome 窗口。如下图所示: ④ 打个断点,例如说 /src/api/login/index.ts 的登录接口。如下图所示: ⑤ 使用管理后台进行登录,可以看到成功进入断点。如下图所示: # 2. VS Code 调试 ① 使用 npm 命令将前端项目运行起来,例如说 npm run dev。耐心等待项目启动成功~ ② 点击 VS Code 左侧的运行和调试,然后启动 Launch,之后会跳出一个独立的 Edge 窗口。如下图所示: ③ 打个断点,例如说 /src/api/login/index.ts 的登录接口。如下图所示: ④ 使用管理后台进行登录,可以看到成功进入断点。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/13, 23:26:46 国际化 【v1.7.3】开发中 ← 国际化 【v1.7.3】开发中→"},{"title":"CRUD 组件","path":"/wiki/YuDaoBoot/前端手册 Vue 3/CRUD 组件/CRUD 组件.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-05 目录 CRUD 组件 管理后台的功能,一般就是 CRUD 增删改查,可以拆分 3 个部分:“列表”、“新增/修改”、“详情”,如下图所示: 部分 组件 示例 列表 Search + Table 新增 / 修改 Form 详情 Descriptions # 1. 基础组件 涉及到 4 个前端基础组件,如下所示: 组件 文档 Search (opens new window) 查询组件 (opens new window) Table (opens new window) 表格组件 (opens new window) Form (opens new window) 表单组件 (opens new window) Descriptions (opens new window) 描述组件 (opens new window) # 2. CRUD 组件 由于以上 4 个组件都需要 Schema 或者 columns 的字段,如果每个组件都写一遍的话,会造成大量重复代码,所以提供 useCrudSchemas 来进行统一的数据生成。 ① useCrudSchemas:位于 src/hooks/web/useCrudSchemas.ts (opens new window) 内 ② useCrudSchemas 可以理解成一个 JSON 配置,示例如下: useCrudSchemas 示例 <script setup lang="ts">import { CrudSchema, useCrudSchemas } from '@/hooks/web/useCrudSchemas'const crudSchemas = reactive<CrudSchema[]>([ { field: 'index', label: t('tableDemo.index'), type: 'index', form: { show: false }, detail: { show: false } }, { field: 'title', label: t('tableDemo.title'), search: { show: true }, form: { colProps: { span: 24 } }, detail: { span: 24 } }, { field: 'author', label: t('tableDemo.author') }, { field: 'display_time', label: t('tableDemo.displayTime'), form: { component: 'DatePicker', componentProps: { type: 'datetime', valueFormat: 'YYYY-MM-DD HH:mm:ss' } } }, { field: 'importance', label: t('tableDemo.importance'), formatter: (_: Recordable, __: TableColumn, cellValue: number) => { return h( ElTag, { type: cellValue === 1 ? 'success' : cellValue === 2 ? 'warning' : 'danger' }, () => cellValue === 1 ? t('tableDemo.important') : cellValue === 2 ? t('tableDemo.good') : t('tableDemo.commonly') ) }, form: { component: 'Select', componentProps: { options: [ { label: '重要', value: 3 }, { label: '良好', value: 2 }, { label: '一般', value: 1 } ] } } }, { field: 'pageviews', label: t('tableDemo.pageviews'), form: { component: 'InputNumber', value: 0 } }, { field: 'content', label: t('exampleDemo.content'), table: { show: false }, form: { component: 'Editor', colProps: { span: 24 } }, detail: { span: 24 } }, { field: 'action', width: '260px', label: t('tableDemo.action'), form: { show: false }, detail: { show: false } }])const { allSchemas } = useCrudSchemas(crudSchemas)</script> ③ 字段的详细说明,可见 useCrudSchemas 文档 (opens new window)。 # 3. 实战案例 项目的 [系统管理 -> 邮箱管理] 相关的功能,都使用 CRUD 实现,你可以自己去学习。 功能 代码 邮箱账号 src/views/system/mail/account (opens new window) 邮箱模版 src/views/system/mail/template (opens new window) 邮箱记录 src/views/system/mail/log (opens new window) # 4. 常见问题 # 4.1 如何隐藏某个字段? 如 formSchema 不需要 field 为 createTime 的字段,可以使用 form: { show: false } 或 isForm: false 进行过滤,其他组件同理。 # 4.2 如何使用数据字典? 设置 dictType 字典的类型,和 dictClass 字典的数据类型。 # 4.3 如何使用 API 获取数据? 使用 api 来获取接口数据,需要主动 return 数据。 # 4.4 如何结合 Slot 自定义? 如果想要自定义,可以结合 Slot 来实现。具体有哪些 Slot,阅读对应基础组件的文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/05, 22:46:09 配置读取 国际化 ← 配置读取 国际化→"},{"title":"配置读取","path":"/wiki/YuDaoBoot/前端手册 Vue 2/配置读取/配置读取.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 配置读取 在 [基础设施 -> 配置管理] 菜单,可以动态修改配置,无需重启服务器即可生效。 提示 对应 《后端手册 —— 配置中心》 文档。 # 1. 读取配置 前端调用 /@api/infra/config (opens new window) 的 #getConfigKey(configKey) 方法,获取指定 key 对应的配置的值。代码如下: export function getConfigKey(configKey) { return request({ url: '/infra/config/get-value-by-key?key=' + configKey, method: 'get' })} # 2. 实战案例 在 src/views/infra/server/index.vue ( opens new window) 页面中,获取 key 为 \"url.skywalking\" 的配置的值。代码如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:01 通用方法 开发规范 ← 通用方法 开发规范→"},{"title":"Icon 图标","path":"/wiki/YuDaoBoot/前端手册 Vue 3/Icon 图标/Icon 图标.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 Icon 图标 Element Plus 内置多种 Icon 图标,可参考 Element Plus —— Icon 图标 (opens new window) 的文档。 在项目的 /src/assets/svgs (opens new window) 目录下,自定义了 Icon 图标,默认注册到全局中,可以在项目中任意地方使用。如下图所示: # 1. Icon 图标组件 友情提示: 该小节,基于 《vue element plus admin —— Icon 图标组件 》 (opens new window) 的内容修改。 Icon 组件位于 src/components/Icon (opens new window) 内,用于项目内组件的展示,基本支持所有图标库(支持按需加载,只打包所用到的图标),支持使用本地 svg 和 Iconify (opens new window) 图标。 提示 在 Iconify (opens new window) 上,你可以查询到你想要的所有图标并使用,不管是不是 element-plus 的图标库。 # 1.1 基本用法 如果以 svg-icon: 开头,则会在本地中找到该 svg 图标,否则,会加载 Iconify 图标。代码如下: <template> <!-- 加载本地 svg --> <Icon icon="svg-icon:peoples" /> <!-- 加载 Iconify --> <Icon icon="ep:aim" /></template> # 1.2 useIcon 如果需要在其他组件中如 ElButton 传入 icon 属性,可以使用 useIcon。代码如下: <script setup lang="ts">import { useIcon } from '@/hooks/web/useIcon'import { ElButton } from 'element-plus'const icon = useIcon({ icon: 'svg-icon:save' })</script><template> <ElButton :icon="icon"> button </ElButton></template> useIcon 的 props 属性如下: 属性 说明 类型 可选值 默认值 icon 图标名 string - - color 图标颜色 string - - size 图标大小 number - 16 # 2. 自定义图标 ① 访问 https://www.iconfont.cn/ (opens new window) 地址,搜索你想要的图标,下载 SVG 格式。如下图所示: 友情提示:其它 SVG 图标网站也可以。 ② 将 SVG 图标添加到 /src/assets/svgs (opens new window) 目录下,然后进行使用。 <Icon icon="svg-icon:helpless" /> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 菜单路由 字典数据 ← 菜单路由 字典数据→"},{"title":"国际化","path":"/wiki/YuDaoBoot/前端手册 Vue 3/国际化/国际化.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 国际化 友情提示: 该章节,基于 《vue element plus admin —— 国际化》 (opens new window) 的内容修改。 如果你使用的 vscode 开发工具,则推荐安装 I18n-ally (opens new window) 这个插件 # 1. I18n-ally 插件 安装了该插件后,你的代码内可以实时看到对应的语言内容 # 2. 配置默认语言 在 src/store/modules/locale.ts (opens new window) 内配置 currentLocale 为其他语言。 查看代码 import { defineStore } from 'pinia'import { store } from '../index'import zhCn from 'element-plus/es/locale/lang/zh-cn'import en from 'element-plus/es/locale/lang/en'import { CACHE_KEY, useCache } from '@/hooks/web/useCache'import { LocaleDropdownType } from '@/types/localeDropdown'const { wsCache } = useCache()const elLocaleMap = { 'zh-CN': zhCn, en: en}interface LocaleState { currentLocale: LocaleDropdownType localeMap: LocaleDropdownType[]}export const useLocaleStore = defineStore('locales', { state: (): LocaleState => { return { currentLocale: { lang: wsCache.get(CACHE_KEY.LANG) || 'zh-CN', elLocale: elLocaleMap[wsCache.get(CACHE_KEY.LANG) || 'zh-CN'] }, // 多语言 localeMap: [ { lang: 'zh-CN', name: '简体中文' }, { lang: 'en', name: 'English' } ] } }, getters: { getCurrentLocale(): LocaleDropdownType { return this.currentLocale }, getLocaleMap(): LocaleDropdownType[] { return this.localeMap } }, actions: { setCurrentLocale(localeMap: LocaleDropdownType) { // this.locale = Object.assign(this.locale, localeMap) this.currentLocale.lang = localeMap?.lang this.currentLocale.elLocale = elLocaleMap[localeMap?.lang] wsCache.set(CACHE_KEY.LANG, localeMap?.lang) } }})export const useLocaleStoreWithOut = () => { return useLocaleStore(store)} # 3. 语言文件 在 src/locales (opens new window) 可以配置具体的语言。 目前项目中的语言都是没有拆分的,全部放一起,后续会考虑拆分出来,比较好维护。 # 4. 语言导入逻辑说明 在 src/plugins/vueI18n/index.ts (opens new window) 内可以看到 const defaultLocal = await import(`../../locales/${locale.lang}.ts`) 这会导入 src/locales 文件语言包。 # 5. 使用 引入项目自带的 useI18n 注意不要引入 vue-i18n 的 useI18n import { useI18n } from '/@/hooks/web/useI18n'const { t } = useI18n()const title = t('common.menu') # 6. 切换语言 切换语言需要使用 src/hooks/web/useLocale.ts ( opens new window) import { useLocale } from '@/hooks/web/useLocale'const { changeLocale } = useLocale()changeLocale('en') # 7. 新增新语言 # 7.1 语言文件 在 src/locales ( opens new window) 增加对应语言的文件即可 # 7.2 新增语言 目前项目自带的语言只有 zh_CN 和 en 两种 如果需要新增,按以下操作即可 在 src/locales ( opens new window) 下语言文件 在 types/global.d.ts ( opens new window) 给 LocaleType 添加对应的类型 在 src/store/modules/locale.ts localeMap 中添加对应语言 # 8. 远程读取语言数据 目前项目会在 src/main.ts 内等待 setupI18n 这个函数执行完之后才会渲染界面,所以只需在 setupI18n 内的 createI18nOptions 发送 ajax 请求,将对应的数据设置到 i18n 实例上即可。 const createI18nOptions = async (): Promise<I18nOptions> => { const localeStore = useLocaleStoreWithOut() const locale = localeStore.getCurrentLocale const localeMap = localeStore.getLocaleMap // 这里改为远程请求即可。 const defaultLocal = await import(`../../locales/${locale.lang}.ts`) const message = defaultLocal.default ?? {} setHtmlPageLang(locale.lang) localeStore.setCurrentLocale({ lang: locale.lang // elLocale: elLocal }) return { legacy: false, locale: locale.lang, fallbackLocale: locale.lang, messages: { [locale.lang]: message }, availableLocales: localeMap.map((v) => v.lang), sync: true, silentTranslationWarn: true, missingWarn: false, silentFallbackWarn: true }} # 8.1 useLocale 代码: src/hooks/web/useLocale.ts ( opens new window) 当手动切换语言的时候会触发 useLocale 函数,useLocale 也是异步函数,只需等待接口返回响应的数据后,再进行设置即可 export const useLocale = () => { // Switching the language will change the locale of useI18n // And submit to configuration modification const changeLocale = async (locale: LocaleType) => { const globalI18n = i18n.global // 改为远程获取 const langModule = await import(`../../locales/${locale}.ts`) globalI18n.setLocaleMessage(locale, langModule.default) setI18nLanguage(locale) } return { changeLocale }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/05, 22:46:09 CRUD 组件 IDE 调试 ← CRUD 组件 IDE 调试→"},{"title":"字典数据","path":"/wiki/YuDaoBoot/前端手册 Vue 3/字典数据/字典数据.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-04-17 目录 字典数据 本小节,讲解前端如何使用 [系统管理 -> 字典管理] 菜单的字典数据,例如说字典数据的下拉框、单选 / 多选按钮、高亮展示等等。 # 1. 全局缓存 用户登录成功后,前端会从后端获取到全量的字典数据,缓存在 store 中。如下图所示: 这样,前端在使用到字典数据时,无需重复请求后端,提升用户体验。 不过,缓存暂时未提供刷新,所以在字典数据发生变化时,需要用户刷新浏览器,进行重新加载。 # 2. DICT_TYPE 在 dict.ts (opens new window) 文件中,使用 DICT_TYPE 枚举了字典的 KEY。如下图所示: 后续如果有新的字典 KEY,需要你自己进行添加。 # 3. DictTag 字典标签 <dict-tag /> (opens new window) 组件,翻译字段对应的字典展示文本,并根据 colorType、cssClass 进行高亮。使用示例如下: <!-- type: 字典 KEY value: 字典值--><dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_TYPE" :value="row.logType" /> 【推荐】注意,一般情况下使用 CRUD schemas 方式,不需要直接使用 <dict-tag />,而是通过 columns 的 dictType 和 dictClass 属性即可。如下图所示: # 4. 字典工具类 在 dict.ts (opens new window) 文件中,提供了字典工具类,方法如下: // 获取 dictType 对应的数据字典数组【object】export const getDictOptions = (dictType: string) => {{ /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【int】export const getIntDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【string】export const getStrDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【boolean】export const getBoolDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【object】export const getDictObj = (dictType: string, value: any) => { /** 省略代码 */ } 结合 Element Plus 的表单组件,使用示例如下: <template> <!-- radio 单选框 --> <el-radio v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="parseInt(dict.value)" > {{dict.label}} </el-radio> <!-- select 下拉框 --> <el-select v-model="form.code" placeholder="请选择渠道编码" clearable> <el-option v-for="dict in getStrDictOptions(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE)" :key="dict.value" :label="dict.label" :value="dict.value" /> </el-select></template><script setup lang="tsx">import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'</script> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 Icon 图标 系统组件 ← Icon 图标 系统组件→"},{"title":"系统组件","path":"/wiki/YuDaoBoot/前端手册 Vue 3/系统组件/系统组件.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-12-31 目录 系统组件 # 1. 常用组件 # 1.1 Editor 富文本组件 基于 wangEditor (opens new window) 封装 Editor 组件:位于 src/components/Editor (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/editor.html (opens new window) 实战案例:src/views/system/notice/form.vue (opens new window) TODO # 1.2 Dialog 弹窗组件 对 Element Plus 的 Dialog 组件进行封装,支持最大化、最大高度等特性 Dialog 组件:位于 src/components/Dialog (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/dialog.html (opens new window) 实战案例:src/views/system/dept/DeptForm.vue (opens new window) # 1.3 ContentWrap 包裹组件 对 Element Plus 的 ElCard 组件进行封装,自带标题、边距 ContentWrap 组件:位于 src/components/ContentWrap (opens new window) 内 实战案例:src/views/system/post/index.vue (opens new window) # 1.4 Pagination 分页组件 对 Element Plus 的 Pagination (opens new window) 组件进行封装 Pagination 组件:位于 src/components/Pagination (opens new window) 内 实战案例:src/views/system/post/index.vue (opens new window) # 1.5 UploadFile 上传文件组件 对 Element Plus 的 Upload (opens new window) 组件进行封装,上传文件到文件服务 UploadFile 组件:位于 src/components/UploadFile/src/UploadFile.vue (opens new window) 内 实战案例:暂无 # 1.6 UploadImg 上传图片组件 对 Element Plus 的 Upload (opens new window) 组件进行封装,上传图片到文件服务 UploadImg 组件:位于 src/components/UploadFile/src/UploadImg.vue (opens new window) 内 实战案例:src/views/system/oauth2/client/ClientForm.vue (opens new window) # 2. 不常用组件 # 2.1 EChart 图表组件 基于 Apache ECharts (opens new window) 封装,自适应窗口大小 EChart 组件:位于 src/components/EChart (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/echart.html (opens new window) 实战案例:src/views/mp/statistics/index.vue (opens new window) # 2.2 InputPassword 密码输入框 对 Element Plus 的 Input 组件进行封装 InputPassword 组件:位于 src/components/InputPassword (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/input-password.html (opens new window) 实战案例:src/views/Profile/components/ResetPwd.vue (opens new window) # 2.3 ContentDetailWrap 详情包裹组件 用于展示详情,自带返回按钮。 ContentDetailWrap 组件:位于 src/components/ContentDetailWrap (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/content-detail-wrap.html (opens new window) 实战案例:暂无 # 2.4 ImageViewer 图片预览组件 将 Element Plus 的 ImageViewer (opens new window) 组件函数化,通过函数方便创建组件 ImageViewer 组件:位于 src/components/ImageViewer (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/image-viewer.html (opens new window) 实战案例:暂无 # 2.5 Qrcode 二维码组件 基于 qrcode (opens new window) 封装 Qrcode 组件:位于 src/components/Qrcode (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/qrcode.html (opens new window) 实战案例:暂无 # 2.6 Highlight 高亮组件 Highlight 组件:位于 src/components/Highlight (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/highlight.html (opens new window) 实战案例:暂无 # 2.6.1 Infotip 信息提示组件 基于 Highlight 组件封装 Infotip 组件:位于 src/components/Infotip (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/infotip.html (opens new window) 实战案例:暂无 # 2.7 Error 缺省组件 用于各种占位图组件,如 404、403、500 等错误页面。 Error 组件:位于 src/components/Error (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/error.html (opens new window) 实战案例:403.vue (opens new window)、404.vue (opens new window)、500.vue (opens new window) # 2.8 Sticky 黏性组件 Sticky 组件:位于 src/components/Sticky (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/sticky.html (opens new window) 实战案例:暂无 # 2.9 CountTo 数字动画组件 CountTo 组件:位于 src/components/CountTo (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/count-to.html (opens new window) 实战案例:暂无 # 2.10 useWatermark 水印组件 为元素设置水印 useWatermark 组件:位于 src/hooks/web/useWatermark.ts (opens new window) 内 详细文档:vue-element-plus-admin-doc/hooks/useWatermark.html (opens new window) 实战案例:暂无 # 2.11 form-create 动态表单生成器 详细文档:http://www.form-create.com/ (opens new window) ① 实战案例 - 表单设计:src/views/infra/build/index.vue (opens new window) ② 实战案例 - 表单展示:src/views/bpm/processInstance/detail/index.vue (opens new window) # 2.12 bpmn-js 工作流组件 核心是基于 bpmn-js (opens new window) 封装 # 2.12.1 MyProcessDesigner 流程设计组件 MyProcessDesigner 组件:位于 src/components/bpmnProcessDesigner/package/designer/index.ts (opens new window) 内,基于 https://gitee.com/MiyueSC/bpmn-process-designer (opens new window) 项目适配 实战案例:src/views/bpm/model/editor/index.vue (opens new window) # 2.12.2 MyProcessViewer 流程展示组件 MyProcessViewer 组件:位于 src/components/bpmnProcessDesigner/package/designer/index2.ts (opens new window) 内 实战案例:src/views/bpm/processInstance/detail/ProcessInstanceBpmnViewer.vue (opens new window) # 3. 组件注册 友情提示: 该小节,基于 《vue element plus admin —— 组件注册 》 (opens new window) 的内容修改。 组件注册可以分成两种类型:按需引入、全局注册。 # 3.1 按需引入 项目目前的组件注册机制是按需注册,是在需要用到的页面才引入。 <script setup lang="ts">import { ElBacktop } from 'element-plus'import { useDesign } from '@/hooks/web/useDesign'const { getPrefixCls, variables } = useDesign()const prefixCls = getPrefixCls('backtop')</script><template> <ElBacktop :class="`${prefixCls}-backtop`" :target="`.${variables.namespace}-layout-content-scrollbar .${variables.elNamespace}-scrollbar__wrap`" /></template> 注意:tsx 文件内不能使用全局注册组件,需要手动引入组件使用。 # 3.2 全局注册 如果觉得按需引入太麻烦,可以进行全局注册,在 src/components/index.ts (opens new window),添加需要注册的组件。 以 Icon 组件进行了全局注册,举个例子: import type { App } from 'vue'import { Icon } from './Icon'export const setupGlobCom = (app: App<Element>): void => { app.component('Icon', Icon)} 如果 Element Plus 的组件需要全局注册,在 src/plugins/elementPlus/index.ts (opens new window) 添加需要注册的组件。 以 Element Plus 中只有 ElLoading 与 ElScrollbar 进行全局注册,举个例子: import type { App } from 'vue'// 需要全局引入一些组件,如 ElScrollbar,不然一些下拉项样式有问题import { ElLoading, ElScrollbar } from 'element-plus'const plugins = [ElLoading]const components = [ElScrollbar]export const setupElementPlus = (app: App) => { plugins.forEach((plugin) => { app.use(plugin) }) components.forEach((component) => { app.component(component.name, component) })} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 字典数据 通用方法 ← 字典数据 通用方法→"},{"title":"菜单路由","path":"/wiki/YuDaoBoot/前端手册 Vue 3/菜单路由/菜单路由.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-12-31 目录 菜单路由 前端项目基于 vue-element-plus-admin 实现,它的 路由和侧边栏 (opens new window) 是组织起一个后台应用的关键骨架。 侧边栏和路由是绑定在一起的,所以你只有在 @/router/index.js (opens new window) 下面配置对应的路由,侧边栏就能动态的生成了,大大减轻了手动重复编辑侧边栏的工作量。 当然,这样就需要在配置路由的时候,遵循一些约定的规则。 # 1. 路由配置 首先,我们了解一下本项目配置路由时,提供了哪些配置项: /*** redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击* name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题* meta : { hidden: true 当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false) alwaysShow: true 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式, 只有一个时,会将那个子路由当做根路由显示在侧边栏, 若你想不管路由下面的 children 声明的个数都显示你的根路由, 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则, 一直显示根路由(默认 false) title: 'title' 设置该路由在侧边栏和面包屑中展示的名字 icon: 'svg-name' 设置该路由的图标 noCache: true 如果设置为true,则不会被 <keep-alive> 缓存(默认 false) breadcrumb: false 如果设置为false,则不会在breadcrumb面包屑中显示(默认 true) affix: true 如果设置为true,则会一直固定在tag项中(默认 false) noTagsView: true 如果设置为true,则不会出现在tag中(默认 false) activeMenu: '/dashboard' 显示高亮的路由路径 followAuth: '/dashboard' 跟随哪个路由进行权限过滤 canTo: true 设置为true即使hidden为true,也依然可以进行路由跳转(默认 false) }**/ # 1.1 普通示例 注意事项: 整个项目所有路由 name 不能重复 所有的多级路由最终都会转成二级路由,所以不能内嵌子路由 除了 layout 对应的 path 前面需要加 /,其余子路由都不要以 / 开头 { path: '/level', component: Layout, redirect: '/level/menu1/menu1-1/menu1-1-1', name: 'Level', meta: { title: t('router.level'), icon: 'carbon:skill-level-advanced' }, children: [ { path: 'menu1', name: 'Menu1', component: getParentLayout(), redirect: '/level/menu1/menu1-1/menu1-1-1', meta: { title: t('router.menu1') }, children: [ { path: 'menu1-1', name: 'Menu11', component: getParentLayout(), redirect: '/level/menu1/menu1-1/menu1-1-1', meta: { title: t('router.menu11'), alwaysShow: true }, children: [ { path: 'menu1-1-1', name: 'Menu111', component: () => import('@/views/Level/Menu111.vue'), meta: { title: t('router.menu111') } } ] }, { path: 'menu1-2', name: 'Menu12', component: () => import('@/views/Level/Menu12.vue'), meta: { title: t('router.menu12') } } ] }, { path: 'menu2', name: 'Menu2Demo', component: () => import('@/views/Level/Menu2.vue'), meta: { title: t('router.menu2') } } ]} # 1.2 外链示例 只需要将 path 设置为需要跳转的 HTTP 地址即可。 { path: '/external-link', component: Layout, meta: { name: 'ExternalLink' }, children: [ { path: 'https://www.iocoder.cn', meta: { name: 'Link', title: '芋道源码' } } ]} # 2. 路由 项目的路由分为两种:静态路由、动态路由。 # 2.1 静态路由 静态路由,代表那些不需要动态判断权限的路由,如登录页、404、个人中心等通用页面。 在 @/router/modules/remaining.ts ( opens new window) 的 remainingRouter ,就是配置对应的公共路由。如下图所示: # 2.2 动态路由 动态路由,代表那些需要根据用户动态判断权限,并通过 addRoutes ( opens new window) 动态添加的页面,如用户管理、角色管理等功能页面。 在用户登录成功后,会触发 @/store/modules/permission.ts ( opens new window) 请求后端的菜单 RESTful API 接口,获取用户有权限 的菜单列表,并转化添加到路由中。如下图所示: 友情提示: 动态路由可以在 [系统管理 -> 菜单管理] 进行新增和修改操作,请求的后端 RESTful API 接口是 /admin-api/system/list-menus ( opens new window) 动态路由在生产环境下会默认使用路由懒加载,实现方式参考 import.meta.glob('../views/**/* .{vue,tsx}') ( opens new window) 方法的判断 补充说明: 最新的代码,部分逻辑重构到 @/permission.ts ( opens new window) # 2.3 路由跳转 使用 router.push 方法,可以实现跳转到不同的页面。 const { push } = useRouter()// 简单跳转push('/job/job-log');// 跳转页面并设置请求参数,使用 `query` 属性push('/bpm/process-instance/detail?id=' + row.processInstance.id) # 3. 菜单管理 项目的菜单在 [系统管理 -> 菜单管理] 进行管理,支持无限 层级,提供目录、菜单、按钮三种类型。如下图所示: 菜单可在 [系统管理 -> 角色管理] 被分配给角色。如下图所示: # 3.1 新增目录 ① 大多数情况下,目录是作为菜单的【分类】: ② 目录也提供实现【外链】的能力: # 3.2 新增菜单 # 3.3 新增按钮 # 4. 权限控制 前端通过权限控制,隐藏用户没有权限的按钮等,实现功能级别的权限。 友情提示:前端的权限控制,主要是提升用户体验,避免操作后发现没有权限。 最终在请求到后端时,还是会进行一次权限的校验。 # 4.1 v-hasPermi 指令 v-hasPermi ( opens new window) 指令,基于权限字符,进行权限的控制。 <!-- 单个 --><el-button v-hasPermi="['system:user:create']">存在权限字符串才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasPermi="['system:user:create', 'system:user:update']">包含权限字符串才能看到</el-button> # 4.2 v-hasRole 指令 v-hasRole ( opens new window) 指令,基于角色标识,机进行的控制。 <!-- 单个 --><el-button v-hasRole="['admin']">管理员才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasRole="['role1', 'role2']">包含角色才能看到</el-button> # 4.3 结合 v-if 指令 在某些情况下,它是不适合使用 v-hasPermi 或 v-hasRole 指令,如元素标签组件。此时,只能通过手动设置 v-if,通过使用全局权限判断函数,用法是基本一致的。 <template> <el-tabs> <el-tab-pane v-if="checkPermi(['system:user:create'])" label="用户管理" name="user">用户管理</el-tab-pane> <el-tab-pane v-if="checkPermi(['system:user:create', 'system:user:update'])" label="参数管理" name="menu">参数管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin'])" label="角色管理" name="role">角色管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin','common'])" label="定时任务" name="job">定时任务</el-tab-pane> </el-tabs></template><script>import { checkPermi, checkRole } from "@/utils/permission"; // 权限判断函数export default{ methods: { checkPermi, checkRole }}</script> # 5. 页面缓存 开启缓存有 2 个条件 路由设置 name,且不能重复 路由对应的组件加上 name ,与路由设置的 name 保持一致 友情提示:页面缓存是什么? 简单来说,Tab 切换时,开启页面缓存的 Tab 保持原本的状态,不进行刷新。 详细可见 Vue 文档 —— KeepAlive ( opens new window) # 5.1 静态路由的示例 ① router 路由的 name 声明如下: { path: 'menu2', name: 'Menu2', component: () => import('@/views/Level/Menu2.vue'), meta: { title: t('router.menu2') }} ② view component 的 name 声明如下: <script setup lang="ts"> defineOptions({ name: 'Menu2'})</script> 注意: keep-alive 生效的前提是:需要将路由的 name 属性及对应的页面的 name 设置成一样。 因为:include - 字符串或正则表达式,只有名称匹配的组件会被缓存 # 5.2 动态路由的示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 开发规范 Icon 图标 ← 开发规范 Icon 图标→"},{"title":"通用方法","path":"/wiki/YuDaoBoot/前端手册 Vue 3/通用方法/通用方法.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 通用方法 本小节,分享前端项目的常用方法。 # 1. 缓存配置 友情提示: 该小节,基于 《vue element plus admin —— 项目配置「缓存配置 」》 (opens new window) 的内容修改。 # 1.1 说明 在项目中,你可以看到很多地方都使用了 wsCache.set 或者 wsCache.get,这是基于 web-storage-cache (opens new window) 进行封装,采用 hook 的形式。 该插件对HTML5 localStorage 和 sessionStorage 进行了扩展,添加了超时时间,序列化方法。可以直接存储 json 对象,同时可以非常简单的进行超时时间的设置。 本项目默认是采用 sessionStorage 的存储方式,如果更改,可以直接在 useCache.ts (opens new window) 中把 type: CacheType = 'sessionStorage' 改为 type: CacheType = 'localStorage',这样项目中的所有用到的地方,都会变成该方式进行数据存储。 如果只想单个更改,可以传入存储类型 const { wsCache } = useCache('localStorage'),既可只适用当前存储对象。 注意: 更改完默认存储方式后,需要清除浏览器缓存并重新登录,以免造成不可描述的问题。 # 1.2 示例 # 2. message 对象 # 2.1 说明 message 对象,由 src/hooks/web/useMessage.ts (opens new window) 实现,基于 ElMessage、ElMessageBox、ElNotification 封装,用于做消息提示、通知提示、对话框提醒、二次确认等。 # 2.2 示例 # 3. download 对象 # 3.1 说明 $download 对象,由 util/download.ts (opens new window) 实现,用于 Excel、Word、Zip、HTML 等类型的文件下载。 # 3.2 示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 21:23:40 系统组件 配置读取 ← 系统组件 配置读取→"},{"title":"开发规范","path":"/wiki/YuDaoBoot/前端手册 Vue 3/开发规范/开发规范.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-04-17 目录 开发规范 # 0. 实战案例 本小节,提供大家开发管理后台的功能时,最常用的普通列表、树形列表、新增与修改的表单弹窗、详情表单弹窗的实战案例。 # 0.1 普通列表 可参考 [系统管理 -> 岗位管理] 菜单: API 接口:/src/api/system/post/index.ts (opens new window) 列表界面:/src/views/system/post/index.vue (opens new window) 表单界面:/src/views/system/post/PostForm.vue (opens new window) 为什么界面拆成列表和表单两个 Vue 文件? 每个 Vue 文件,只实现一个功能,更简洁,维护性更好,Git 代码冲突概率低。 # 0.2 树形列表 可参考 [系统管理 -> 部门管理] 菜单: API 接口:/src/api/system/dept/index.ts (opens new window) 列表界面:/src/views/system/dept/index.vue (opens new window) 表单界面:/src/views/system/dept/DeptForm.vue (opens new window) # 0.3 高性能列表 可参考 [系统管理 -> 地区管理] 菜单,对应 /src/views/system/area/index.vue (opens new window) 列表界面 基于 Virtualized Table 虚拟化表格 (opens new window) 实现,解决一屏里超过 1000 条数据记录时,就会出现卡顿等性能问题。 # 0.4 详情弹窗 可参考 [基础设施 -> API 日志 -> 访问日志] 菜单,对应 /src/views/infra/apiAccessLog/ApiAccessLogDetail.vue (opens new window) 详情弹窗 # 1. view 页面 在 @views (opens new window) 目录下,每个模块对应一个目录,它的所有功能的 .vue 都放在该目录里。 一般来说,一个路由对应一个 index.vue 文件。 # 2. api 请求 在 @/api (opens new window) 目录下,每个模块对应一个 index.ts API 文件。 API 方法:会调用 request 方法,发起对后端 RESTful API 的调用。 interface 类型:定义了 API 的请求参数和返回结果的类型,对应后端的 VO 类型。 # 2.1 请求封装 /src/config/axios/index.ts (opens new window) 基于 axios (opens new window) 封装,统一处理 GET、POST 方法的请求参数、请求头,以及错误提示信息等。 # 2.1.1 创建 axios 实例 baseURL 基础路径 timeout 超时时间,默认为 30000 毫秒 实现代码 /src/config/axios/service.ts import axios from 'axios'const { result_code, base_url, request_timeout } = config// 创建 axios 实例const service: AxiosInstance = axios.create({ baseURL: base_url, // api 的 base_url timeout: request_timeout, // 请求超时时间 withCredentials: false // 禁用 Cookie 等信息}) # 2.1.2 Request 拦截器 【重点】Authorization、tenant-id 请求头 GET 请求参数的拼接 实现代码 /src/config/axios/service.ts import axios, { AxiosInstance, AxiosRequestHeaders, AxiosResponse, AxiosError, InternalAxiosRequestConfig} from 'axios'import { getAccessToken, getTenantId } from '@/utils/auth'const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLEservice.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // 是否需要设置 token let isToken = (config!.headers || {}).isToken === false whiteList.some((v) => { if (config.url) { config.url.indexOf(v) > -1 return (isToken = false) } }) if (getAccessToken() && !isToken) { (config as Recordable).headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token } // 设置租户 if (tenantEnable && tenantEnable === 'true') { const tenantId = getTenantId() if (tenantId) (config as Recordable).headers['tenant-id'] = tenantId } const params = config.params || {} const data = config.data || false if ( config.method?.toUpperCase() === 'POST' && (config.headers as AxiosRequestHeaders)['Content-Type'] === 'application/x-www-form-urlencoded' ) { config.data = qs.stringify(data) } // get参数编码 if (config.method?.toUpperCase() === 'GET' && params) { let url = config.url + '?' for (const propName of Object.keys(params)) { const value = params[propName] if (value !== void 0 && value !== null && typeof value !== 'undefined') { if (typeof value === 'object') { for (const val of Object.keys(value)) { const params = propName + '[' + val + ']' const subPart = encodeURIComponent(params) + '=' url += subPart + encodeURIComponent(value[val]) + '&' } } else { url += `${propName}=${encodeURIComponent(value)}&` } } } // 给 get 请求加上时间戳参数,避免从缓存中拿数据 // const now = new Date().getTime() // params = params.substring(0, url.length - 1) + `?_t=${now}` url = url.slice(0, -1) config.params = {} config.url = url } return config }, (error: AxiosError) => { // Do something with request error console.log(error) // for debug Promise.reject(error) }) # 2.1.3 Response 拦截器 访问令牌 AccessToken 过期时,使用刷新令牌 RefreshToken 刷新,获得新的访问令牌 刷新令牌失败(过期)时,跳回首页进行登录 请求失败,Message 错误提示 实现代码 /src/config/axios/service.ts import axios, { AxiosInstance, AxiosRequestHeaders, AxiosResponse, AxiosError, InternalAxiosRequestConfig} from 'axios'import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'import { getAccessToken, getRefreshToken, removeToken, setToken } from '@/utils/auth'// 需要忽略的提示。忽略后,自动 Promise.reject('error')const ignoreMsgs = [ '无效的刷新令牌', // 刷新令牌被删除时,不用提示 '刷新令牌已过期' // 使用刷新令牌,刷新获取新的访问令牌时,结果因为过期失败,此时需要忽略。否则,会导致继续 401,无法跳转到登出界面]// 是否显示重新登录export const isRelogin = { show: false }import errorCode from './errorCode'import { resetRouter } from '@/router'import { useCache } from '@/hooks/web/useCache'service.interceptors.response.use( async (response: AxiosResponse<any>) => { const { data } = response const config = response.config if (!data) { // 返回“[HTTP]请求没有返回值”; throw new Error() } const { t } = useI18n() // 未设置状态码则默认成功状态 const code = data.code || result_code // 二进制数据则直接返回 if ( response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer' ) { return response.data } // 获取错误信息 const msg = data.msg || errorCode[code] || errorCode['default'] if (ignoreMsgs.indexOf(msg) !== -1) { // 如果是忽略的错误码,直接返回 msg 异常 return Promise.reject(msg) } else if (code === 401) { // 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了 if (!isRefreshToken) { isRefreshToken = true // 1. 如果获取不到刷新令牌,则只能执行登出操作 if (!getRefreshToken()) { return handleAuthorized() } // 2. 进行刷新访问令牌 try { const refreshTokenRes = await refreshToken() // 2.1 刷新成功,则回放队列的请求 + 当前请求 setToken((await refreshTokenRes).data.data) config.headers!.Authorization = 'Bearer ' + getAccessToken() requestList.forEach((cb: any) => { cb() }) requestList = [] return service(config) } catch (e) { // 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。 // 2.2 刷新失败,只回放队列的请求 requestList.forEach((cb: any) => { cb() }) // 提示是否要登出。即不回放当前请求!不然会形成递归 return handleAuthorized() } finally { requestList = [] isRefreshToken = false } } else { // 添加到队列,等待刷新获取到新的令牌 return new Promise((resolve) => { requestList.push(() => { config.headers!.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token 请根据实际情况自行修改 resolve(service(config)) }) }) } } else if (code === 500) { ElMessage.error(t('sys.api.errMsg500')) return Promise.reject(new Error(msg)) } else if (code === 901) { ElMessage.error({ offset: 300, dangerouslyUseHTMLString: true, message: '<div>' + t('sys.api.errMsg901') + '</div>' + '<div> &nbsp; </div>' + '<div>参考 https://doc.iocoder.cn/ 教程</div>' + '<div> &nbsp; </div>' + '<div>5 分钟搭建本地环境</div>' }) return Promise.reject(new Error(msg)) } else if (code !== 200) { if (msg === '无效的刷新令牌') { // hard coding:忽略这个提示,直接登出 console.log(msg) } else { ElNotification.error({ title: msg }) } return Promise.reject('error') } else { return data } }, (error: AxiosError) => { console.log('err' + error) // for debug let { message } = error const { t } = useI18n() if (message === 'Network Error') { message = t('sys.api.errorMessage') } else if (message.includes('timeout')) { message = t('sys.api.apiTimeoutMessage') } else if (message.includes('Request failed with status code')) { message = t('sys.api.apiRequestFailed') + message.substr(message.length - 3) } ElMessage.error(message) return Promise.reject(error) })const refreshToken = async () => { axios.defaults.headers.common['tenant-id'] = getTenantId() return await axios.post(base_url + '/system/auth/refresh-token?refreshToken=' + getRefreshToken())}const handleAuthorized = () => { const { t } = useI18n() if (!isRelogin.show) { isRelogin.show = true ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), { confirmButtonText: t('login.relogin'), cancelButtonText: t('common.cancel'), type: 'warning' }) .then(() => { const { wsCache } = useCache() resetRouter() // 重置静态路由表 wsCache.clear() removeToken() isRelogin.show = false window.location.href = '/' }) .catch(() => { isRelogin.show = false }) } return Promise.reject(t('sys.api.timeoutMessage'))} # 2.2 交互流程 一个完整的前端 UI 交互到服务端处理流程,如下图所示: 继续以 [系统管理 -> 岗位管理] 菜单为例,查看它是如何读取岗位列表的。代码如下: // ① api/system/post/index.tsimport request from '@/config/axios'// 查询岗位列表export const getPostPage = async (params: PageParam) => { return await request.get({ url: '/system/post/page', params })}// ② views/system/post/index.vue<script setup lang="tsx">const loading = ref(true) // 列表的加载中const total = ref(0) // 列表的总页数const list = ref([]) // 列表的数据const queryParams = reactive({ pageNo: 1, pageSize: 10, code: '', name: '', status: undefined})/** 查询岗位列表 */const getList = async () => { loading.value = true try { const data = await PostApi.getPostPage(queryParams) list.value = data.list total.value = data.total } finally { loading.value = false }}</script> # 3. component 组件 # 3.1 全局组件 在 @/components ( opens new window) 目录下,实现全局组件,被所有模块所公用。 例如说,富文本编辑器、各种各搜索组件、封装的分页组件等等。 # 3.2 模块内组件 每个模块的业务组件,可实现在 views 目录下,自己模块的目录的 components 目录下,避免单个 .vue 文件过大,降低维护成功。 例如说, @/views/pay/app/components/xxx.vue: # 4. style 样式 ① 在 @/styles ( opens new window) 目录下,实现全局 样式,被所有页面所公用。 ② 每个 .vue 页面,可在 <style /> 标签中添加样式,注意需要添加 scoped 表示只作用在当前页面里,避免造成全局的样式污染。 更多也可以看看如下两篇文档: 《vue-element-plus-admin —— 项目配置「样式配置」》 ( opens new window) 《vue-element-plus-admin —— 样式》 ( opens new window) # 5. 项目规范 可参考 《vue-element-plus-admin —— 项目规范》 ( opens new window) 文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:16 配置读取 菜单路由 ← 配置读取 菜单路由→"},{"title":"配置读取","path":"/wiki/YuDaoBoot/前端手册 Vue 3/配置读取/配置读取.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-07 目录 配置读取 在 [基础设施 -> 配置管理] 菜单,可以动态修改配置,无需重启服务器即可生效。 提示 对应 《后端手册 —— 配置中心》 文档。 # 1. 读取配置 前端调用 /@api/infra/config/index.ts (opens new window) 的 #getConfigKey(configKey) 方法,获取指定 key 对应的配置的值。代码如下: // 根据参数键名查询参数值export const getConfigKey = (configKey: string) => { return request.get({ url: '/infra/config/get-value-by-key?key=' + configKey })} # 2. 实战案例 在 src/views/infra/server/index.vue ( opens new window) 页面中,获取 key 为 \"url.skywalking\" 的配置的值。代码如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:01 通用方法 CRUD 组件 ← 通用方法 CRUD 组件→"},{"title":"Excel 导入导出","path":"/wiki/YuDaoBoot/后端手册/Excel 导入导出/Excel 导入导出.html","content":"开发指南后端手册 芋道源码 2022-03-27 目录 Excel 导入导出 项目的 yudao-spring-boot-starter-excel (opens new window) 技术组件,基于 EasyExcel 实现 Excel 的读写操作,可用于实现最常见的 Excel 导入导出等功能。 EasyExcel 的介绍? EasyExcel 是阿里开源的 Excel 工具库,具有简单易用、低内存、高性能的特点。 在尽可用节约内存的情况下,支持百万行的 Excel 读写操作。例如说,仅使用 64M 内存,20 秒完成 75M(46 万行 25 列)Excel 的读取。并且,还有极速模式能更快,但是内存占用会在100M 多一点。 # 1. Excel 导出 以 [系统管理 -> 岗位管理] 菜单为例子,讲解它 Excel 导出的实现。 # 1.1 后端导入实现 在 PostController (opens new window) 类中,定义 /admin-api/system/post/export 导出接口。代码如下: ① 将从数据库中查询出来的列表,转换成对应的 PostExcelVO 列表。 ② 将 PostExcelVO 列表,转换成 Excel 文件,返回给前端。 # 1.1.1 PostExcelVO 类 创建 PostExcelVO (opens new window) 类,岗位 Excel 导出的 VO 类。它有两个作用,代码如下: ① 每个字段上的 @ExcelProperty (opens new window) 注解,声明 Excel Head 头部的名字。 ② 每个字段的值,就是它对应的 Excel Row 行的数据值。 因此,最终 Excel 导出的效果如下: 另外,在上述代码的红线部分,@ExcelProperty 注解的 converter 属性是 DictConvert 转换器,通过它将 status = 1 转换成“开启”列,status = 0 转换成”禁用”列。稍后,我们会在 「3. 字段转换器」 小节来详细讲讲。 # 1.1.2 ExcelUtils 写入 ExcelUtils 的 #write(...) (opens new window) 方法,将列表以 Excel 响应给前端。代码如下图: # 1.2 前端导入实现 在 post/index.vue (opens new window) 界面,定义 #handleExport() 操作,代码如下图: # 2. Excel 导入 以 [系统管理 -> 用户管理] 菜单为例子,讲解它 Excel 导出的实现。 # 2.1 后端导入实现 在 UserController (opens new window) 类中,定义 /admin-api/system/user/import 导入接口。代码如下: 将前端上传的 Excel 文件,读取成 UserImportExcelVO 列表。 # 2.1.1 UserImportExcelVO 类 创建 UserImportExcelVO (opens new window) 类,用户 Excel 导入的 VO 类。它的作用和 Excel 导入是一样的,代码如下: 对应使用的 Excel 导入文件如下: # 2.1.2 ExcelUtils 读取 ExcelUtils 的 #read(...) (opens new window) 方法,读取 Excel 文件成列表。代码如下图: # 2.2 前端导入实现 在 user/index.vue (opens new window) 界面,定义 Excel 导入的功能,代码如下图: # 3. 字段转换器 EasyExcel 定义了 Converter (opens new window) 接口,用于实现字段的转换。它有两个核心方法: ① #convertToJavaData(...) 方法:将 Excel Row 对应表格的值,转换成 Java 内存中的值。例如说,Excel 的“状态”列,将“状态”列转换成 status = 1,”禁用”列转换成 status = 0。 ② #convertToExcelData(...) 方法:恰好相反,将 Java 内存中的值,转换成 Excel Row 对应表格的值。例如说,Excel 的“状态”列,将 status = 1 转换成“开启”列,status = 0 转换成”禁用”列。 # 3.1 DictConvert 实现 以项目中提供的 DictConvert (opens new window) 举例子,它实现 Converter 接口,提供字典数据的转换。代码如下: 实现的代码比较简单,自己看看就可以明白。 # 3.1 DictConvert 使用示例 在需要转换的字段上,声明注解 @ExcelProperty 的 converter 属性是 DictConvert 转换器,注解 @DictFormat (opens new window) 为对应的字典数据的类型。示例如下: # 4. 更多 EasyExcel 注解 基于 《EasyExcel 中的注解 》 (opens new window) 文章,整理相关注解。 # 4.1 @ExcelProperty 这是最常用的一个注解,注解中有三个参数 value、index、converter 分别代表列明、列序号、数据转换方式。value 和 index 只能二选一,通常不用设置 converter。 最佳实践 public class ImeiEncrypt { @ExcelProperty(value = "imei") private String imei;} # 4.2 @ColumnWith 用于设置列宽度的注解,注解中只有一个参数 value。value 的单位是字符长度,最大可以设置 255 个字符,因为一个 Excel 单元格最大可以写入的字符个数,就是 255 个字符。 最佳实践 public class ImeiEncrypt { @ColumnWidth(value = 18) private String imei;} # 4.3 @ContentFontStyle 用于设置单元格内容字体格式的注解。参数如下: 参数 含义 fontName 字体名称 fontHeightInPoints 字体高度 italic 是否斜体 strikeout 是否设置删除水平线 color 字体颜色 typeOffset 偏移量 underline 下划线 bold 是否加粗 charset 编码格式 # 4.4 @ContentLoopMerge 用于设置合并单元格的注解。参数如下: 参数 含义 eachRow columnExtend # 4.5 @ContentRowHeight 用于设置行高。参数如下: 参数 含义 value 行高,-1 代表自动行高 # 4.6 @ContentStyle 设置内容格式注解。参数如下: 参数 含义 dataFormat 日期格式 hidden 设置单元格使用此样式隐藏 locked 设置单元格使用此样式锁定 quotePrefix 在单元格前面增加`符号,数字或公式将以字符串形式展示 horizontalAlignment 设置是否水平居中 wrapped 设置文本是否应换行。将此标志设置为true通过在多行上显示使单元格中的所有内容可见 verticalAlignment 设置是否垂直居中 rotation 设置单元格中文本旋转角度。03版本的Excel旋转角度区间为-90°~90°,07版本的Excel旋转角度区间为0°~180° indent 设置单元格中缩进文本的空格数 borderLeft 设置左边框的样式 borderRight 设置右边框样式 borderTop 设置上边框样式 borderBottom 设置下边框样式 leftBorderColor 设置左边框颜色 rightBorderColor 设置右边框颜色 topBorderColor 设置上边框颜色 bottomBorderColor 设置下边框颜色 fillPatternType 设置填充类型 fillBackgroundColor 设置背景色 fillForegroundColor 设置前景色 shrinkToFit 设置自动单元格自动大小 # 4.7 @HeadFontStyle 用于定制标题字体格式。参数如下: 参数 含义 fontName 设置字体名称 fontHeightInPoints 设置字体高度 italic 设置字体是否斜体 strikeout 是否设置删除线 color 设置字体颜色 typeOffset 设置偏移量 underline 设置下划线 charset 设置字体编码 bold 设置字体是否家畜 # 4.8 @HeadRowHeight 设置标题行行高。参数如下: 参数 含义 value 设置行高,-1代表自动行高 # 4.9 @HeadStyle 设置标题样式。参数如下: 参数 含义 dataFormat 日期格式 hidden 设置单元格使用此样式隐藏 locked 设置单元格使用此样式锁定 quotePrefix 在单元格前面增加` 符号,数字或公式将以字符串形式展示 horizontalAlignment 设置是否水平居中 wrapped 设置文本是否应换行。将此标志设置为true 通过在多行上显示使单元格中的所有内容可见 verticalAlignment 设置是否垂直居中 rotation 设置单元格中文本旋转角度。03版本的Excel旋转角度区间为-90°~90°,07版本的Excel旋转角度区间为0°~180° indent 设置单元格中缩进文本的空格数 borderLeft 设置左边框的样式 borderRight 设置右边框样式 borderTop 设置上边框样式 borderBottom 设置下边框样式 leftBorderColor 设置左边框颜色 rightBorderColor 设置右边框颜色 topBorderColor 设置上边框颜色 bottomBorderColor 设置下边框颜色 fillPatternType 设置填充类型 fillBackgroundColor 设置背景色 fillForegroundColor 设置前景色 shrinkToFit 设置自动单元格自动大小 # 4.10 @ExcelIgnore 不将该字段转换成 Excel。 # 4.11 @ExcelIgnoreUnannotated 没有注解的字段都不转换 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 文件存储(上传下载) 系统日志 ← 文件存储(上传下载) 系统日志→"},{"title":"OAuth 2.0(SSO 单点登录)","path":"/wiki/YuDaoBoot/后端手册/OAuth 2.0(SSO 单点登录)/OAuth 2.0(SSO 单点登录).html","content":"开发指南后端手册 芋道源码 2022-09-27 目录 OAuth 2.0(SSO 单点登录) # OAuth 2.0 是什么? OAuth 2.0 的概念讲解,可以阅读如下三篇文章: 《理解 OAuth 2.0》 (opens new window) 《OAuth 2.0 的一个简单解释》 (opens new window) 《OAuth 2.0 的四种方式》 (opens new window) 重点是理解 授权码模式 和 密码模式,它们是最常用的两种授权模式。 本文,我们也会基于它们,分别实现 SSO 单点登录。 # OAuth 2.0 授权模式的选择? 授权模式的选择,其实非常简单,总结起来就是一张图: 问题一:什么场景下,使用客户端模式(Client Credentials)? 如果令牌拥有者是机器的情况下,那就使用客户端模式。 例如说: 开发了一个开放平台,提供给其它外部服务调用 开发了一个 RPC 服务,提供给其它内部服务调用 实际的案例,我们接入微信公众号时,会使用 appid 和 secret 参数,获取 Access token (opens new window) 访问令牌。 问题二:什么场景下,使用密码模式(Resource Owner Password Credentials)? 接入的 Client 客户端,是属于自己的情况下,可以使用密码模式。 例如说: 客户端是你自己公司的 App 或网页,然后授权服务也是你公司的 不过,如果客户端是第三方的情况下,使用密码模式的话,该客户端是可以拿到用户的账号、密码,存在安全的风险,此时可以考虑使用授权码或简化模式。 问题三:什么场景下,使用授权码模式(Authorization Code)? 接入的 Client 客户端,是属于第三方的情况下,可以使用授权码模式。例如说: 客户端是你自己公司的 App 或网页,作为第三方,接入 微信 (opens new window)、QQ (opens new window)、钉钉 (opens new window) 等等进行 OAuth 2.0 登录 当然,如果客户端是自己的情况下,也可以采用授权码模式。例如说: 客户端是腾讯旗下的各种游戏,可使用微信、QQ,接入 微信 (opens new window)、QQ (opens new window) 等等进行 OAuth 2.0 登录 客户端是公司内的各种管理后台(ERP、OA、CRM 等),跳转到统一的 SSO 单点登录,使用授权码模式进行授权 问题四:什么场景下,使用简化模式(Implicit)? 简化模式,简化 的是授权码模式的流程的 第二步,差异在于: 授权码模式:授权完成后,获得的是 code 授权码,需要 Server Side 服务端使用该授权码,再向授权服务器获取 Access Token 访问令牌 简化模式:授权完成后,Client Side 客户端直接获得 Access Token 访问令牌 暂时没有特别好的案例,感兴趣可以看看如下文档,也可以不看: 《QQ OAuth 2.0 开发指定 —— 开发攻略_Client-side》 (opens new window) 《百度 OAuth —— Implicit Grant 授权》 (opens new window) 问题五:该项目中,使用了哪些授权模式? 如上图所示,分成 外部授权 和 内部登录 两种方式。 ① 红色的“外部授权”:基于【授权码模式】,实现 SSO 单点登录,将用户授权给接入的客户端。客户端可以是内部的其它管理系统,也可以是外部的第三方。 ② 绿色的“内部登录”:管理后台的登录接口,还是采用传统的 /admin-api/system/auth/login (opens new window) 账号密码登录,并没有使用【密码模式】,主要考虑降低大家的学习成本,如果没有将用户授权给其它系统的情况下,这样做已经可以很好的满足业务的需要。当然,这里也可以将管理后台作为一个客户端,使用【密码模式】进行授权。 另外,考虑到 OAuth 2.0 使用的访问令牌 + 刷新令牌可以提供更好的安全性,所以即使是传统的账号密码登录,也复用了它作为令牌的实现。 # OAuth 2.0 技术选型? 实现 OAuth 2.0 的功能,一般采用 Spring Security OAuth (opens new window) 或 Spring Authorization Server (opens new window)(SAS) 框架,前者已废弃,被后者所替代。但是使用它们,会面临三大问题: 学习成本大:SAS 是新出的框架,入门容易精通难,引入项目中需要花费 1-2 周深入学习 排查问题难:使用碰到问题时,往往需要调试到源码层面,团队只有个别人具备这种能力 定制成本高:根据业务需要,想要在 SAS 上定制功能,对源码要有不错的掌控力,难度可能过大 ⚔ 因此,项目参考多个 OAuth 2.0 框架,自研实现 OAuth 2.0 的功能,具备学习成本小、排查问题容易、定制成本低的优点,支持多种授权模式,并内置 SSO 单点登录的功能。 友情提示:具备一定规模的互联网公司,基本不会直接采用 Spring Security OAuth 或 Spring Authorization Server 框架,也是采用自研的方式,更好的满足自身的业务需求与技术拓展。 🙂 另外,通过学习项目的 OAuth 2.0 实现,可以进一步加深对 OAuth 2.0 的理解,知其然而不知其所以然! 最终实现的整体架构,如下图所示: 详细的代码实现,我们在视频中进行讲解。 # 如何实现 SSO 单点登录? # 实战一:基于授权码模式,实现 SSO 单点登录 示例代码见 https://github.com/YunaiV/ruoyi-vue-pro/tree/master/yudao-example/yudao-sso-demo-by-code (opens new window) 地址,整体流程如下图所示: 具体的使用流程如下: ① 第一步,分别启动 ruoyi-vue-pro 项目的前端和后端。注意,前端需要使用 Vue2 版本,因为 Vue3 版本暂时没有实现 SSO 页面。 ② 第二步,访问 系统管理 -> OAuth 2.0 -> 应用管理 (opens new window) 菜单,新增一个应用(客户端),信息如下图: 客户端编号:yudao-sso-demo-by-code 客户端密钥:test 应用名:基于授权码模式,如何实现 SSO 单点登录? 授权类型:authorization_code、refresh_token 授权范围:user.read、user.write 可重定向的 URI 地址:http://127.0.0.1:18080 ps:如果已经有这个客户端,可以不用新增。 ③ 第三步,运行 SSODemoApplication (opens new window) 类,启动接入方的项目,它已经包含前端和后端部分。启动成功的日志如下: 2022-10-01 21:24:35.572 INFO 60265 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 18080 (http) with context path '' ④ 第四步,浏览器访问 http://127.0.0.1:18080/index.html (opens new window) 地址,进入接入方的 index.html 首页。因为暂未登录,可以点击「跳转」按钮,跳转到 ruoyi-vue-pro 项目的 SSO 单点登录页。 疑问:为什么没有跳转到 SSO 单点登录页,而是跳转到 ruoyi-vue-pro 项目的登录页? 因为在 ruoyi-vue-pro 项目也未登录,所以先跳转到该项目的登录页,使用账号密码进行登录。登录完成后,会跳转回 SSO 单点登录页,继续完成 OAuth 2.0 的授权流程。 ⑤ 第五步,勾选 \"访问你的个人信息\" 和 \"修改你的个人信息\",点击「同意授权」按钮,完成 code 授权码的申请。 ⑥ 第六步,完成授权后,会跳转到接入方的 callback.html 回调页,并在 URL 上可以看到 code 授权码。 ⑦ 第七步,点击「确认」按钮,接入方的前端会使用 code 授权码,向接入方的后端获取 accessToken 访问令牌。 而接入方的后端,使用接收到的 code 授权码,通过调用 ruoyi-vue-pro 项目的后端,获取到 accessToken 访问令牌,并最终返回给接入方的前端。 ⑧ 第八步,在接入方的前端拿到 accessToken 访问令牌后,跳转回自己的 index.html 首页,并进一步从 ruoyi-vue-pro 项目获取到该用户的昵称等个人信息。后续,你可以执行「修改昵称」、「刷新令牌」、「退出登录」等操作。 示例代码的具体实现,与详细的解析,可以观看如下视频: 02、基于授权码模式,如何实现 SSO 单点登录? (opens new window) 03、请求时,如何校验 accessToken 访问令牌? (opens new window) 04、访问令牌过期时,如何刷新 Token 令牌? (opens new window) 05、登录成功后,如何获得用户信息? (opens new window) 06、退出时,如何删除 Token 令牌? (opens new window) # 实战二:基于密码模式,实现 SSO 登录 示例代码见 https://github.com/YunaiV/ruoyi-vue-pro/tree/master/yudao-example/yudao-sso-demo-by-password (opens new window) 地址,整体流程如下图所示: 具体的使用流程如下: ① 第一步,分别启动 ruoyi-vue-pro 项目的前端和后端。注意,前端需要使用 Vue2 版本,因为 Vue3 版本暂时没有实现 SSO 页面。 ② 第二步,访问 系统管理 -> OAuth 2.0 -> 应用管理 (opens new window) 菜单,新增一个应用(客户端),信息如下图: 客户端编号:yudao-sso-demo-by-password 客户端密钥:test 应用名:基于密码模式,如何实现 SSO 单点登录? 授权类型:password、refresh_token 授权范围:user.read、user.write 可重定向的 URI 地址:http://127.0.0.1:18080 ps:如果已经有这个客户端,可以不用新增。 ③ 第三步,运行 SSODemoApplication (opens new window) 类,启动接入方的项目,它已经包含前端和后端部分。启动成功的日志如下: 2022-10-04 21:24:35.572 INFO 60265 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 18080 (http) with context path '' ④ 第四步,浏览器访问 http://127.0.0.1:18080/index.html (opens new window) 地址,进入接入方的 index.html 首页。因为暂未登录,可以点击「跳转」按钮,跳转到 login.html 登录页。 ⑤ 第五步,点击「登录」按钮,调用 ruoyi-vue-pro 项目的后端,获取到 accessToken 访问令牌,完成登录操作。 ⑥ 第六步,登录完成后,跳转回自己的 index.html 首页,并进一步从 ruoyi-vue-pro 项目获取到该用户的昵称等个人信息。后续,你可以执行「修改昵称」、「刷新令牌」、「退出登录」等操作。 示例代码的具体实现,与详细的解析,可以观看如下视频: 07、基于密码模式,如何实现 SSO 单点登录? (opens new window) # OAuth 2.0 表结构 每个表的具体设计,与详细的解析,可以观看如下视频: 08、如何实现客户端的管理? (opens new window) 09、单点登录界面,如何进行初始化? (opens new window) 10、单点登录界面,如何进行【手动】授权? (opens new window) 11、单点登录界面,如何进行【自动】授权? (opens new window) 12、基于【授权码】模式,如何获得 Token 令牌? (opens new window) 13、基于【密码】模式,如何获得 Token 令牌? (opens new window) 14、如何校验、刷新、删除访问令牌? (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/10/06, 20:13:06 三方登录 SaaS 多租户【字段隔离】 ← 三方登录 SaaS 多租户【字段隔离】→"},{"title":"SaaS 多租户【字段隔离】","path":"/wiki/YuDaoBoot/后端手册/SaaS 多租户【字段隔离】/SaaS 多租户【字段隔离】.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 SaaS 多租户【字段隔离】 本章节,将介绍多租户的基础知识、以及怎样使用多租户的功能。 相关的视频教程: 01、如何实现多租户的 DB 封装? (opens new window) 02、如何实现多租户的 Redis 封装? (opens new window) 03、如何实现多租户的 Web 与 Security 封装? (opens new window) 04、如何实现多租户的 Job 封装? (opens new window) 05、如何实现多租户的 MQ 与 Async 封装? (opens new window) 06、如何实现多租户的 AOP 与 Util 封装? (opens new window) 07、如何实现多租户的管理? (opens new window) 08、如何实现多租户的套餐? (opens new window) # 1. 多租户是什么? 多租户,简单来说是指一个业务系统,可以为多个组织服务,并且组织之间的数据是隔离的。 例如说,在服务上部署了一个 ruoyi-vue-pro (opens new window) 系统,可以支持多个不同的公司使用。这里的一个公司就是一个租户,每个用户必然属于某个租户。因此,用户也只能看见自己租户下面的内容,其它租户的内容对他是不可见的。 # 2. 数据隔离方案 多租户的数据隔离方案,可以分成分成三种: DATASOURCE 模式:独立数据库 SCHEMA 模式:共享数据库,独立 Schema COLUMN 模式:共享数据库,共享 Schema,共享数据表 # 2.1 DATASOURCE 模式 一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本也高。 优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。 缺点:增大了数据库的安装数量,随之带来维护成本和购置成本的增加。 # 2.2 SCHEMA 模式 多个或所有租户共享数据库,但一个租户一个表。 优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可以支持更多的租户数量。 缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵扯到其他租户的数据; 如果需要跨租户统计数据,存在一定困难。 # 2.3 COLUMN 模式 共享数据库,共享数据架构。租户共享同一个数据库、同一个表,但在表中通过 tenant_id 字段区分租户的数据。这是共享程度最高、隔离级别最低的模式。 优点:维护和购置成本最低,允许每个数据库支持的租户数量最多。 缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。 # 2.4 方案选择 一般情况下,可以考虑采用 COLUMN 模式,开发、运维简单,以最少的服务器为最多的租户提供服务。 租户规模比较大,或者一些租户对安全性要求较高,可以考虑采用 DATASOURCE 模式,当然它也相对复杂的多。 不推荐采用 SCHEMA 模式,因为它的优点并不明显,而且它的缺点也很明显,同时对复杂 SQL 支持一般。 提问:项目支持哪些模式? 目前支持最主流的 DATASOURCE 和 COLUMN 两种模式。而 SCHEMA 模式不推荐使用,所以暂时不考虑实现。 考虑到让大家更好的理解 DATASOURCE 和 COLUMN 模式,拆成了两篇文章: 《SaaS 多租户【字段隔离】》:讲解 COLUMN 模式 《SaaS 多租户【数据库隔离】》:讲解 DATASOURCE 模式 # 3. 多租户的开关 系统有两个配置项,设置为 true 时开启多租户,设置为 false 时关闭多租户。 注意,两者需要保持一致,否则会报错! 配置项 说明 配置文件 yudao.server.tenant 后端开关 VUE_APP_TENANT_ENABLE 前端开关 疑问:为什么要设置两个配置项? 前端登录界面需要使用到多租户的配置项,从后端加载配置项的话,体验会比较差。 # 4. 多租户的业务功能 多租户主要有两个业务功能: 业务功能 说明 界面 代码 租户管理 配置系统租户,创建对应的租户管理员 后端 (opens new window) 前端 (opens new window) 租户套餐 配置租户套餐,自定每个租户的菜单、操作、按钮的权限 后端 (opens new window) 前端 (opens new window) 下面,我们来新增一个租户,它使用 COLUMN 模式。 ① 点击 [租户套餐] 菜单,点击 [新增] 按钮,填写租户的信息。 ② 点击 [确认] 按钮,完成租户的创建,它会自动创建对应的租户管理员、角色等信息。 ③ 退出系统,登录刚创建的租户。 至此,我们已经完成了租户的创建。 # 5. 多租户的技术组件 技术组件 yudao-spring-boot-starter-biz-tenant (opens new window),实现透明化的多租户能力,针对 Web、Security、DB、Redis、AOP、Job、MQ、Async 等多个层面进行封装。 # 5.1 租户上下文 TenantContextHolder (opens new window) 是租户上下文,通过 ThreadLocal 实现租户编号的共享与传递。 通过调用 TenantContextHolder 的 #getTenantId() 静态方法,获得当前的租户编号。绝绝绝大多数情况下,并不需要。 # 5.2 Web 层【重要】 实现可见 web (opens new window) 包。 默认情况下,前端的每个请求 Header 必须带上 tenant-id,值为租户编号,即 system_tenant 表的主键编号。 如果不带该请求头,会报“租户的请求未传递,请进行排查”错误提示。 😜 通过 yudao.tenant.ignore-urls 配置项,可以设置哪些 URL 无需带该请求头。例如说: # 5.3 Security 层 实现可见 security (opens new window) 包。 主要是校验登录的用户,校验是否有权限访问该租户,避免越权问题。 # 5.4 DB 层【重要】 实现可见 db (opens new window) 包。 COLUMN 模式,基于 MyBatis Plus 自带的多租户 (opens new window)功能实现。 核心:每次对数据库操作时,它会自动拼接 WHERE tenant_id = ? 条件来进行租户的过滤,并且基本支持所有的 SQL 场景。 如下是具体方式: ① 需要开启多租户的表,必须添加 tenant_id 字段。例如说 system_users、system_role 等表。 CREATE TABLE `system_role` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID', `name` varchar(30) CHARACTER NOT NULL COMMENT '角色名称', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='角色信息表'; 并且该表对应的 DO 需要使用到 tenantId 属性时,建议继承 TenantBaseDO (opens new window) 类。 ② 无需开启多租户的表,需要添加表名到 yudao.tenant.ignore-tables 配置项目。例如说: 如果不配置的话,MyBatis Plus 会自动拼接 WHERE tenant_id = ? 条件,导致报 tenant_id 字段不存在的错误。 # 5.5 Redis 层【重要】 实现可见 redis (opens new window) 包。 由于 Redis 不同于 DB 有 tenant_id 字段,无法通过类似 WHERE tenant_id = ? 的方式过滤,所以需要通过在 Redis Key 上增加 :t{tenantId} 后缀的方式,进行租户之间的隔离。 例如说,假设 Redis Key 是 user:%d,示例是 user:1024;对应到多租户 1 的 Redis Key 是 user:t1:1024。 为什么 Redis Key 要多租户隔离呢? ① 在使用 DATASOURCE 模式时,不同库的相同表的 id 可能相同,例如说 A 库的用户,和 B 库的用户都是 1024,直接缓存会存在 Redis Key 的冲突。 ② 在所有模式下,跨租户可能存在相同的需要唯一的数据,例如说用户的手机号,直接缓存会存在 Redis Key 的冲突。 # 使用方式一:基于 Spring Cache + Redis【推荐】 只需要一步,在方法上添加 Spring Cache 注解,例如说 @Cachable、@CachePut、@CacheEvict。 具体的实现原理,可见 TenantRedisCacheManager (opens new window) 的源码。 注意!!!默认配置下,Spring Cache 都开启 Redis Key 的多租户隔离。如果不需要,可以将 Key 添加到 yudao.tenant.ignore-cache 配置项中。如下图所示: # 使用方式二:基于 RedisTemplate + TenantRedisKeyDefine 暂时没有合适的封装,需要在自己 format Redis Key 的时候,手动将 :t{tenantId} 后缀拼接上。 这也是为什么,我推荐你使用 Spring Cache + Redis 的原因! # 5.6 AOP【重要】 实现可见 aop (opens new window) 包。 ① 声明 @TenantIgnore (opens new window) 注解在方法上,标记指定方法不进行租户的自动过滤,避免自动拼接 WHERE tenant_id = ? 条件等等。 例如说:RoleServiceImpl (opens new window) 的 #initLocalCache() (opens new window) 方法,加载所有租户的角色到内存进行缓存,如果不声明 @TenantIgnore 注解,会导致租户的自动过滤,只加载了某个租户的角色。 // RoleServiceImpl.javapublic class RoleServiceImpl implements RoleService { @Resource @Lazy // 注入自己,所以延迟加载 private RoleService self; @Override @PostConstruct @TenantIgnore // 忽略自动多租户,全局初始化缓存 public void initLocalCache() { // ... 从数据库中,加载角色 } @Scheduled(fixedDelay = SCHEDULER_PERIOD, initialDelay = SCHEDULER_PERIOD) public void schedulePeriodicRefresh() { self.initLocalCache(); // <x> 通过 self 引用到 Spring 代理对象 }} 有一点要格外注意,由于 @TenantIgnore 注解是基于 Spring AOP 实现,如果是方法内部的调用,避免使用 this 导致不生效,可以采用上述示例的 <x> 处的 self 方式。 ② 使用 TenantUtils (opens new window) 的 #execute(Long tenantId, Runnable runnable) 方法,模拟指定租户( tenantId ),执行某段业务逻辑( runnable )。 例如说:在 TenantServiceImpl (opens new window) 的 #createTenant(...) 方法,在创建完租户时,需要模拟该租户,进行用户和角色的创建。如下图所示: # 5.7 Job【重要】 实现可见 job (opens new window) 包。 声明 @TenantJob (opens new window) 注解在 Job 类上,实现并行遍历每个租户,执行定时任务的逻辑。 # 5.8 MQ 实现可见 mq (opens new window) 包。 通过租户对 MQ 层面的封装,实现租户上下文,可以继续传递到 MQ 消费的逻辑中,避免丢失的问题。实现原理是: 发送消息时,MQ 会将租户上下文的租户编号,记录到 Message 消息头 tenant-id 上。 消费消息时,MQ 会将 Message 消息头 tenant-id,设置到租户上下文的租户编号。 # 5.9 Async 实现可见 YudaoAsyncAutoConfiguration (opens new window) 类。 通过使用阿里开源的 TransmittableThreadLocal (opens new window) 组件,实现 Spring Async 执行异步逻辑时,租户上下文可以继续传递,避免丢失的问题。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/03, 23:36:37 OAuth 2.0(SSO 单点登录) SaaS 多租户【数据库隔离】 ← OAuth 2.0(SSO 单点登录) SaaS 多租户【数据库隔离】→"},{"title":"Redis 缓存","path":"/wiki/YuDaoBoot/后端手册/Redis 缓存/Redis 缓存.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 Redis 缓存 yudao-spring-boot-starter-redis (opens new window) 技术组件,使用 Redis 实现缓存的功能,它有 2 种使用方式: 编程式缓存:基于 Spring Data Redis 框架的 RedisTemplate 操作模板 声明式缓存:基于 Spring Cache 框架的 @Cacheable 等等注解 # 1. 编程式缓存 友情提示: 如果你未学习过 Spring Data Redis 框架,可以后续阅读 《芋道 Spring Boot Redis 入门》 (opens new window) 文章。 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId></dependency> 由于 Redisson 提供了分布式锁、队列、限流等特性,所以使用它作为 Spring Data Redis 的客户端。 # 1.1 Spring Data Redis 配置 ① 在 application-local.yaml (opens new window) 配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。如下图所示: ② 在 YudaoRedisAutoConfiguration (opens new window) 配置类,设置使用 JSON 序列化 value 值。如下图所示: # 1.2 实战案例 以访问令牌 Access Token 的缓存来举例子,讲解项目中是如何使用 Spring Data Redis 框架的。 # 1.2.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-redis 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-redis</artifactId></dependency> # 1.2.2 OAuth2AccessTokenDO 新建 OAuth2AccessTokenDO ( opens new window) 类,访问令牌 Access Token 类。代码如下: 友情提示: ① 如果值是【简单】的 String 或者 Integer 等类型,无需创建数据实体。 ② 如果值是【复杂对象】时,建议在 dal/dataobject 包下,创建对应的数据实体。 # 1.2.3 RedisKeyConstants 为什么要定义 Redis Key 常量? 每个 yudao-module-xxx 模块,都有一个 RedisKeyConstants 类,定义该模块的 Redis Key 的信息。目的是,避免 Redis Key 散落在 Service 业务代码中,像对待数据库的表一样,对待每个 Redis Key。通过这样的方式,如果我们想要了解一个模块的 Redis 的使用情况,只需要查看 RedisKeyConstants 类即可。 在 yudao-module-system 模块的 RedisKeyConstants ( opens new window) 类中,新建 OAuth2AccessTokenDO 对应的 Redis Key 定义 OAUTH2_ACCESS_TOKEN 。如下图所示: # 1.2.4 OAuth2AccessTokenRedisDAO 新建 OAuth2AccessTokenRedisDAO ( opens new window) 类,是 OAuth2AccessTokenDO 的 RedisDAO 实现。代码如下: # 1.2.5 OAuth2TokenServiceImpl 在 OAuth2TokenServiceImpl ( opens new window) 中,只要注入 OAuth2AccessTokenRedisDAO Bean,非常简洁干净的进行 OAuth2AccessTokenDO 的缓存操作,无需关心具体的实现。代码如下: # 2. 声明式缓存 友情提示: 如果你未学习过 Spring Cache 框架,可以后续阅读 《芋道 Spring Boot Cache 入门》 ( opens new window) 文章。 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId></dependency> 相比来说 Spring Data Redis 编程式缓存,Spring Cache 声明式缓存的使用更加便利,一个 @Cacheable 注解即可实现缓存的功能。示例如下: @Cacheable(value = "users", key = "#id")UserDO getUserById(Integer id); # 2.1 Spring Cache 配置 ① 在 application.yaml ( opens new window) 配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。如下图所示: ② 在 YudaoCacheAutoConfiguration ( opens new window) 配置类,设置使用 JSON 序列化 value 值。如下图所示: # 2.2 常见注解 # 2.2.1 @Cacheable 注解 @Cacheable ( opens new window) 注解:添加在方法上,缓存方法的执行结果。执行过程如下: 1)首先,判断方法执行结果的缓存。如果有,则直接返回该缓存结果。 2)然后,执行方法,获得方法结果。 3)之后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。 4)最后,返回方法结果。 # 2.2.2 @CachePut 注解 @CachePut ( opens new window) 注解,添加在方法上,缓存方法的执行结果。不同于 @Cacheable 注解,它的执行过程如下: 1)首先,执行方法,获得方法结果。也就是说,无论是否有缓存,都会执行方法。 2)然后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。 3)最后,返回方法结果。 # 2.2.3 @CacheEvict 注解 @CacheEvict ( opens new window) 注解,添加在方法上,删除缓存。 # 2.3 实战案例 在 RoleServiceImpl ( opens new window) 中,使用 Spring Cache 实现了 Role 角色缓存,采用【被动读】的方案。原因是: 【被动读】相对能够保证 Redis 与 MySQL 的一致性 绝大数数据不需要放到 Redis 缓存中,采用【主动写】会将非必要的数据进行缓存 友情提示: 如果你未学习过 MySQL 与 Redis 一致性的问题,可以后续阅读 《Redis 与 MySQL 双写一致性如何保证? 》 ( opens new window) 文章。 ① 执行 #getRoleFromCache(...) 方法,从 MySQL 读取数据后,向 Redis 写入缓存。如下图所示: ② 执行 #updateRole(...) 或 #deleteRole(...) 方法,在更新或者删除 MySQL 数据后,从 Redis 删除缓存。如下图所示: # 2.4 过期时间 Spring Cache 默认使用 spring.cache.redis.time-to-live 配置项,设置缓存的过期时间,项目默认为 1 小时。 如果你想自定义过期时间,可以在 @Cacheable 注解中的 cacheNames 属性中,添加 #{过期时间} 后缀,单位是秒。如下图所示: 实现的原来,参考 《Spring @Cacheable 扩展支持自定义过期时间 》 ( opens new window) 文章。 # 3. Redis 监控 yudao-module-infra 的 redis ( opens new window) 模块,提供了 Redis 监控的功能。 点击 [基础设施 -> Redis 监控] 菜单,可以查看到 Redis 的基础信息、命令统计、内存信息。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:30:07 多数据源(读写分离) 本地缓存 ← 多数据源(读写分离) 本地缓存→"},{"title":"SaaS 多租户【数据库隔离】","path":"/wiki/YuDaoBoot/后端手册/SaaS 多租户【数据库隔离】/SaaS 多租户【数据库隔离】.html","content":"开发指南后端手册 芋道源码 2023-03-01 目录 SaaS 多租户【数据库隔离】 本章节,讲解 SaaS 租户的 DATASOURCE 模式,实现数据库级别的隔离。 注意,需要前置阅读 《SaaS 多租户【字段隔离】》 文档。 # 0. 极速体验 ① 克隆 https://gitee.com/zhijiantianya/ruoyi-vue-pro (opens new window) 仓库,并切换到 feature/dev-yunai 分支。 ② 创建 ruoyi-vue-pro-master、ruoyi-vue-pro-tenant-a、ruoyi-vue-pro-tenant-b 三个数据库。 ③ 下载 多租户多db.zip 并解压,将 SQL 导入到对应的数据库中。 友情提示: 随着版本的迭代,SQL 脚本可能过期。如果碰到问题,可以在星球给我反馈下。 ④ 启动前端和后端项目,即可愉快的体验了。 # 1. 实现原理 DATASOURCE 模式,基于 dynamic-datasource (opens new window) 进行拓展实现。 核心:每次对数据库操作时,动态切换到该租户所在的数据源,然后执行 SQL 语句。 # 2. 功能演示 我们来新增一个租户,使用 DATASOURCE 模式。 ① 点击 [基础设施 -> 数据源配置] 菜单,点击 [新增] 按钮,新增一个名字为 tenant-a 数据源。 然后,手动将如下表拷贝到 ruoyi-vue-pro 主库中的如下表,拷贝到 ruoyi-vue-pro-tenant-a 库中。如下图所示: system_deptsystem_login_logsystem_noticesystem_notify_messagesystem_operate_logsystem_postsystem_rolesystem_role_menusystem_social_usersystem_social_user_bindsystem_user_postsystem_user_rolesystem_users 友情提示: 随着版本的迭代,可能需要拷贝更多的表。如果碰到问题,可以在星球给我反馈下。 ② 点击 [基础设施 -> 租户管理] 菜单,点击 [新增] 按钮,新增一个名字为 土豆租户 的租户,并使用 tenant-a 数据源。如下图所示: 此时,在 ruoyi-vue-pro-tenant-a 库中,可以查询到对应的租户管理员、角色等信息。如下图所示: ③ 退出系统,登录刚创建的租户。 至此,我们已经完成了租户的创建。 补充说明: 后续在使用时,建议把拷贝到其它租户数据库的表,从 ruoyi-vue-pro 主库中进行删除。 目的是,主库只保留所有租户共享的全局表。例如说,菜单表、定时任表等等。 # 3. 创建表 在使用 DATASOURCE 模式时,数据库可以分为两种:主库、租户库。 # 3.1 主库 ① 存放所有租户共享的表。例如说:菜单表、定时任务表等等。如下图所示: ② 对应 master 数据源,配置在 application-{env}.yaml 配置文件。如下图所示: ③ 每个主库对应的 Mapper,必须添加 @Master (opens new window) 注解。例如说: # 3.2 租户库 ① 存放每个租户的表。例如说:用户表、角色表等等。 ② 在 [基础设施 -> 数据源配置] 菜单中,配置数据源。 ③ 每个主库对应的 Mapper,必须添加 @TenantDS 注解。例如说: # 3.3 租户字段 ① 考虑到拓展性,在使用 DATASOURCE 模式时,默认会叠加 COLUMN 模式,即还有 tenant_id 租户字段: 在 INSERT 操作时,会自动记录租户编号到 tenant_id 字段。 在 SELECT 操作时,会自动添加 WHERE tenant_id = ? 查询条件。 如果你不需要,可以直接删除 TenantDatabaseInterceptor (opens new window) 类,以及它的 Bean 自动配置。 拓展性,指的是部分【大】租户独立数据库,部分【小】租户共享数据。 ② 也因为叠加了 COLUMN 模式,主库的表需要根据情况添加 tenant_id 字段。 情况一:不需要添加 tenant_id 字段。例如说:菜单表、定时任务表等等。注意,需要把表名添加到 yudao.tenant.ignore-tables 配置项中。 情况二:需要 tenant_id 字段。例如说:访问日志表、异常日志表等等。目的,排查是哪个租户的系统级别的日志。 # 4. 多数据源事务 使用 DATASOURCE 模式后,可能一个操作涉及到多个数据源。例如说:创建租户时,即需要操作主库,也需要操作租户库。 考虑到多数据的数据一致性,我们会采用事务的方式,而使用 Spring 事务时,会存在多数据库无法切换的问题。不了解的胖友,可以阅读 《MyBatis Plus 的多数据源 @DS 切换不起作用了,谁的锅 》 (opens new window) 文章。 多数据源的事务方案,是一个老生常谈的问题。比较主流的,有如下两种,都是相对重量级的方案: 使用 Atomikos (opens new window) 实现 JTA 分布式事务,配置复杂,性能较差。 使用 Seata (opens new window) 实现分布式事务,使用简单,性能不错,但是需要额外引入 Seata Server 服务。 # 4.1 本地事务 考虑到项目是单体架构,不适合采用重量级的事务,因此采用 dynamic-datasource (opens new window) 提供的 “本地事务” 轻量级方案。 它的实现原理是:自定义 @DSTransactional (opens new window) 事务注解,替代 Spring @Transactional 事务注解。 在逻辑执行成功时,循环提交每个数据源的事务。 在逻辑执行失败时,循环回滚每个数据源的事务。 但是它存在一个风险点,如果数据库发生异常(例如说宕机),那么本地事务就可能会存在数据不一致的问题。例如说: ① 主库的事务提交 ② 租户库发生异常,租户的事务提交失败 结果:主库的数据已经提交,而租户库的数据没有提交,就会导致数据不一致。 因此,如果你的系统对数据一致性要求很高,那么请使用 Seata 方案。 # 4.2 使用示例 在最外层的 Service 方法上,添加 @DSTransactional 注解。例如说,创建租户的 Service 方法: 注意,里面不能嵌套有 Spring 自带的事务,就是上图中【黄圈】的 Service 方法不能使用 Spring @Transactional 注解,否则会导致数据源无法切换。 如果【黄圈】的 Service 自身还需要事务,那么可以使用 @DSTransactional 注解。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/02, 00:37:14 SaaS 多租户【字段隔离】 异常处理(错误码) ← SaaS 多租户【字段隔离】 异常处理(错误码)→"},{"title":"公众号菜单","path":"/wiki/YuDaoBoot/公众号手册/公众号菜单/公众号菜单.html","content":"开发指南公众号手册 芋道源码 2023-01-29 目录 公众号菜单 本章节,讲解公众号菜单的相关内容,对应 [公众号管理 -> 菜单管理] 菜单,对应 《微信公众号官方文档 —— 自定义菜单》 (opens new window) 文档。如下图所示: # 1. 表结构 公众号菜单对应 mp_menu 表,结构如下图所示: type 字段:按钮类型。如果类型为 CLICK 点击回复时,可进行文本、图片、语音、视频、图文、音乐消息。 # 2. 菜单管理界面 前端:/@views/mp/menu (opens new window) 后端:MpMenuController (opens new window) # 3. 点击回复 用户点击菜单按钮时,会接收事件消息,进而被 MenuHandler (opens new window) 处理。如果类型为 CLICK 点击回复时,自动回复对应的消息。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 自动回复 公众号素材 ← 自动回复 公众号素材→"},{"title":"三方登录","path":"/wiki/YuDaoBoot/后端手册/三方登录/三方登录.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 三方登录 系统对接国内多个第三方平台,实现三方登录的功能。例如说: 管理后台:企业微信、阿里钉钉 用户 App:微信公众号、微信小程序 友情提示:为了表述方便,本文主要使用管理后台的三方登录作为示例。 用户 App 也是支持该功能,你可以自己去体验一下。 # 1. 表结构 ① 三方登录完成时,系统会将三方用户存储到 system_social_user (opens new window) 表中,通过 type (opens new window) 标记对应的第三方平台。 ② 【未】关联本系统 User 的三方用户,需要在三方登录完成后,使用账号密码进行「绑定登录」,成功后记录到 system_social_user_bind (opens new window) 表中。 【已】关联本系统 User 的三方用户,在三方登录完成后,直接进入系统,即「快捷登录」。 # 2. 绑定登录 ① 使用浏览器访问 http://127.0.0.1:1024/login (opens new window) 地址,点击 [钉钉] 或者 [企业微信] 进行三方登录。此时,会调用 /admin-api/system/auth/social-auth-redirect (opens new window) 接口,获得第三方平台的登录地址,并进行跳转。 然后,使用 [钉钉] 或者 [企业微信] 进行扫码,完成三方登录。 ② 三方登录成功后,跳转回 http://127.0.0.1:1024/social-login (opens new window) 地址。此时,会调用 /admin-api/system/auth/social-login (opens new window) 接口,尝试「快捷登录」。由于该三方用户【未】关联管理后台的 AdminUser 用户,所以会看到 “未绑定账号,需要进行绑定” 报错。 ③ 输入账号密码,点击 [提交] 按钮,进行「绑定登录」。此时,会调用 /admin-api/system/auth/login (opens new window) 接口(在账号密码登录的基础上,额外带上 socialType + socialCode + socialState 参数)。成功后,即可进入系统的首页。 # 3. 快捷登录 退出系统,再进行一次三方登录的流程。 【相同】① 使用浏览器访问 http://127.0.0.1:1024/login (opens new window) 地址,点击 [钉钉] 或者 [企业微信] 进行三方登录。此时,会调用 /admin-api/system/auth/social-auth-redirect (opens new window) 接口,获得第三方平台的登录地址,并进行跳转。 【不同】② 三方登录成功后,跳转回 http://127.0.0.1:1024/social-login (opens new window) 地址。此时,会调用 /admin-api/system/auth/social-login (opens new window) 接口,尝试「快捷登录」。由于该三方用户【已】关联管理后台的 AdminUser 用户,所以直接进入系统的首页。 # 4. 绑定与解绑 访问 http://127.0.0.1:1024/user/profile (opens new window) 地址,选择 [社交信息] 选项,可以三方用户的绑定与解绑。 # 5. 配置文件 在 application-{env}.yaml (opens new window) 配置文件中,对应 justauth 配置项,填写你的第三方平台的配置信息。 系统使用 justauth-spring-boot-starter (opens new window) JustAuth (opens new window) 组件,想要对接其它第三方平台,只需要新增对应的配置信息即可。 疑问:yudao-spring-boot-starter-biz-social 技术组件的作用是什么? yudao-spring-boot-starter-biz-social (opens new window) 对 JustAuth 进行二次封装,提供微信小程序的集成。 # 6. 第三方平台的申请 阿里钉钉:https://justauth.wiki/guide/oauth/dingtalk/ (opens new window) 企业微信:https://justauth.wiki/guide/oauth/wechat_enterprise_qrcode/ (opens new window) 微信开放平台:https://justauth.wiki/guide/oauth/wechat_open/ (opens new window) 注意,如果第三方平台如果需要配置具体的授信地址,需要添加 /social-login 用于三方登录回调页、/user/profile 用于三方用户的绑定与解绑。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/07/06, 00:54:39 用户体系 OAuth 2.0(SSO 单点登录) ← 用户体系 OAuth 2.0(SSO 单点登录)→"},{"title":"代码生成(新增功能)","path":"/wiki/YuDaoBoot/后端手册/代码生成(新增功能)/代码生成(新增功能).html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 代码生成(新增功能) 大部分项目里,其实有很多代码是重复的,几乎每个模块都有 CRUD 增删改查的功能,而这些功能的实现代码往往是大同小异的。如果这些功能都要自己去手写,非常无聊枯燥,浪费时间且效率很低,还可能会写错。 所以这种重复性的代码,项目提供了 codegen (opens new window) 代码生成器,只需要在数据库中设计好表结构,就可以一键生成前后端代码 + 单元测试 + Swagger 接口文档 + Validator 参数校验。 下面,我们使用代码生成器,在 yudao-module-system 模块中,开发一个【用户组】的功能。 # 👍 相关视频教程 从零开始 05:如何 5 分钟,开发一个新功能? (opens new window) # 1. 数据库表结构设计 设计用户组的数据库表名为 system_group,其建表语句如下: CREATE TABLE `system_group` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '名字', `description` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '描述', `status` tinyint NOT NULL COMMENT '状态', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户组'; ① 表名的前缀,要和 Maven Module 的模块名保持一致。例如说,用户组在 yudao-module-system 模块,所以表名的前缀是 system_。 疑问:为什么要保持一致? 代码生成器会自动解析表名的前缀,获得其所属的 Maven Module 模块,简化配置过程。 ② 设置 ID 主键,一般推荐使用 bigint 长整形,并设置自增长。 ③ 正确设置每个字段是否允许空,代码生成器会根据它生成参数是否允许空的校验规则。 ④ 正确设置注释,代码生成器会根据它生成字段名与提示等信息。 ⑤ 添加 creator、create_time、updater、update_time、deleted 是必须设置的系统字段;如果开启多租户的功能,并且该表需要多租户的隔离,则需要添加 tenant_id 字段。 # 2. 代码生成 ① 点击 [基础设施 -> 代码生成] 菜单,点击 [基于 DB 导入] 按钮,选择 system_group 表,后点击 [确认] 按钮。 代码实现? 可见 CodegenBuilder (opens new window) 类,自动解析数据库的表结构,生成默认的配置。 ② 点击 system_group 所在行的 [编辑] 按钮,修改生成配置。后操作如下: 将 status 字段的显示类型为【下拉框】,字典类型为【系统状态】。 将 description 字段的【查询】取消。 将 id、name、description、status 字段的【示例】填写上。 字段信息 插入:新增时,是否传递该字段。 编辑:修改时,是否传递该字段。 列表:Table 表格,是否展示该字段。 查询:搜索框,是否支持该字段查询,查询的条件是什么。 允许空:新增或修改时,是否必须传递该字段,用于 Validator 参数校验。 字典类型:在显示类型是下拉框、单选框、复选框时,选择使用的字典。 示例:参数示例,用于 Swagger 接口文档的 example 示例。 将【上级菜单】设置为【系统管理】。 将【前端类型】设置为【Vue2 Element UI 标准模版】或【Vue3 Element Plus 标准模版】,具体根据你使用哪种管理后台。 生成信息 生成场景:分成管理后台、用户 App 两种,用于生成 Controller 放在 admin 还是 app 包。 上级菜单:生成场景是管理后台时,需要设置其所属的上级菜单。 前端类型: 提供多种 UI 模版。 【Vue3 Element Plus Schema 模版】,对应 《前端手册 Vue 3.X —— CRUD 组件》 说明。 后端的 application.yaml 配置文件中的 yudao.codegen.front-type 配置项,设置默认的 UI 模版,避免每次都需要设置。 完成后,点击 [提交] 按钮,保存生成配置。 ③ 点击 system_group 所在行的 [预览] 按钮,在线预览生成的代码,检查是否符合预期。 ④ 点击 system_group 所在行的 [生成代码] 按钮,下载生成代码的压缩包,双击进行解压。 代码实现? 可见 CodegenEngine (opens new window) 类,基于 Velocity 模板引擎,生成具体代码。模板文件,可见 resources/codegen (opens new window) 目录。 # 3. 代码运行 本小节,我们将生成的代码,复制到项目中,并进行运行。 # 3.1 后端运行 ① 将生成的后端代码,复制到项目中。操作如下图所示: ② 将 ErrorCodeConstants.java_手动操作 文件的错误码,复制到该模块 ErrorCodeConstants 类中,并设置对应的错误码编号,之后进行删除。操作如下图所示: ③ 将 h2.sql 的 CREATE 语句复制到该模块的 create_tables.sql 文件,DELETE 语句复制到该模块的 clean.sql。操作如下图: 疑问:`create_tables.sql` 和 `clean.sql` 文件的作用是什么? 项目的单元测试,需要使用到 H2 内存数据库,create_tables.sql 用于创建所有的表结构,clean.sql 用于每个单元测试的方法跑完后清理数据。 然后,运行 GroupServiceImplTest 单元测试,执行通过。 ④ 打开数据库工具,运行代码生成的 sql/sql.sql 文件,用于菜单的初始化。 ⑤ Debug 运行 YudaoServerApplication 类,启动后端项目。通过 IDEA 的 [Actuator -> Mappings] 菜单,可以看到代码生成的 GroupController 的 RESTful API 接口已经生效。 # 3.2 前端运行 ① 将生成的前端代码,复制到项目中。操作如下图所示: ② 重新执行 npm run dev 命令,启动前端项目。点击 [系统管理 -> 用户组管理] 菜单,就可以看到用户组的 UI 界面。 至此,我们已经完成了【用户组】功能的代码生成,基本节省了你 80% 左右的开发任务,后续可以根据自己的需求,进行剩余的 20% 的开发! # 4. 后续变更 随着业务的发展,已经生成代码的功能需要变更。继续以【用户组】举例子,它的 system_group 表需要新增一个分类 category 字段,此时不建议使用代码生成器,而是直接修改已经生成的代码: ① 后端:修改 GroupDO 数据实体类、GroupBaseVO 基础 VO 类、GroupExcelVO 导出结果 VO 类,新增 category 字段。 ② 前端:修改 index.vue 界面的列表和表单组件,新增 category 字段。 ③ 重新编译后后端,并进行启动。 over!非常简单方便,即保证了代码的整洁规范,又不增加过多的开发量。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/13, 08:15:12 新建模块 功能权限 ← 新建模块 功能权限→"},{"title":"分页实现","path":"/wiki/YuDaoBoot/后端手册/分页实现/分页实现.html","content":"开发指南后端手册 芋道源码 2022-03-26 目录 分页实现 前端:基于 Element UI 分页组件 Pagination (opens new window) 后端:基于 MyBatis Plus 分页功能,二次封装 以 [系统管理 -> 租户管理 -> 租户列表] 菜单为例子,讲解它的分页 + 搜索的实现。 # 1. 前端分页实现 # 1.1 Vue 界面 界面 tenant/index.vue (opens new window) 相关的代码如下: <template> <!-- 搜索工作栏 --> <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px"> <el-form-item label="租户名" prop="name"> <el-input v-model="queryParams.name" placeholder="请输入租户名" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="联系人" prop="contactName"> <el-input v-model="queryParams.contactName" placeholder="请输入联系人" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="联系手机" prop="contactMobile"> <el-input v-model="queryParams.contactMobile" placeholder="请输入联系手机" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="租户状态" prop="status"> <el-select v-model="queryParams.status" placeholder="请选择租户状态" clearable> <el-option v-for="dict in this.getDictDatas(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="dict.label" :value="dict.value"/> </el-select> </el-form-item> <el-form-item> <el-button type="primary" icon="el-icon-search" @click="handleQuery">搜索</el-button> <el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button> </el-form-item> </el-form> <!-- 列表 --> <el-table v-loading="loading" :data="list"> <!-- 省略每一列... --> </el-table> <!-- 分页组件 --> <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize" @pagination="getList"/></template><script>import { getTenantPage } from "@/api/system/tenant";export default { name: "Tenant", components: {}, data() { // 遮罩层 return { // 遮罩层 loading: true, // 显示搜索条件 showSearch: true, // 总条数 total: 0, // 租户列表 list: [], // 查询参数 queryParams: { pageNo: 1, pageSize: 10, // 搜索条件 name: null, contactName: null, contactMobile: null, status: undefined, }, } }, created() { this.getList(); }, methods: { /** 查询列表 */ getList() { this.loading = true; // 处理查询参数 let params = {...this.queryParams}; // 执行查询 getTenantPage(params).then(response => { this.list = response.data.list; this.total = response.data.total; this.loading = false; }); }, /** 搜索按钮操作 */ handleQuery() { this.queryParams.pageNo = 1; this.getList(); }, /** 重置按钮操作 */ resetQuery() { this.resetForm("queryForm"); this.handleQuery(); } }}</script> # 1.2 API 请求 请求 system/tenant.js ( opens new window) 相关的代码如下: import request from '@/utils/request'// 获得租户分页export function getTenantPage(query) { return request({ url: '/system/tenant/page', method: 'get', params: query })} # 2. 后端分页实现 # 2.1 Controller 接口 在 TenantController ( opens new window) 类中,定义 /admin-api/system/tenant/page 接口。代码如下: @Tag(name = "管理后台 - 租户")@RestController@RequestMapping("/system/tenant")public class TenantController { @Resource private TenantService tenantService; @GetMapping("/page") @Operation(summary = "获得租户分页") @PreAuthorize("@ss.hasPermission('system:tenant:query')") public CommonResult<PageResult<TenantRespVO>> getTenantPage(@Valid TenantPageReqVO pageVO) { PageResult<TenantDO> pageResult = tenantService.getTenantPage(pageVO); return success(TenantConvert.INSTANCE.convertPage(pageResult)); }} Request 分页请求,使用 TenantPageReqVO (opens new window) 类,它继承 PageParam 类 Response 分页结果,使用 PageResult 类,每一项是 TenantRespVO (opens new window) 类 # 2.1.1 分页参数 PageParam 分页请求,需要继承 PageParam (opens new window) 类。代码如下: @Schema(description="分页参数")@Datapublic class PageParam implements Serializable { private static final Integer PAGE_NO = 1; private static final Integer PAGE_SIZE = 10; @Schema(description = "页码,从 1 开始", required = true,example = "1") @NotNull(message = "页码不能为空") @Min(value = 1, message = "页码最小值为 1") private Integer pageNo = PAGE_NO; @Schema(description = "每页条数,最大值为 100", required = true, example = "10") @NotNull(message = "每页条数不能为空") @Min(value = 1, message = "每页条数最小值为 1") @Max(value = 100, message = "每页条数最大值为 100") private Integer pageSize = PAGE_SIZE;} 分页条件,在子类中进行定义。以 TenantPageReqVO 举例子,代码如下: @Schema(description = "管理后台 - 租户分页 Request VO")@Data@EqualsAndHashCode(callSuper = true)@ToString(callSuper = true)public class TenantPageReqVO extends PageParam { @Schema(description = "租户名", example = "芋道") private String name; @Schema(description = "联系人", example = "芋艿") private String contactName; @Schema(description = "联系手机", example = "15601691300") private String contactMobile; @Schema(description = "租户状态(0正常 1停用)", example = "1") private Integer status; @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @Schema(description = "创建时间") private LocalDateTime[] createTime;} # 2.1.2 分页结果 PageResult 分页结果 PageResult ( opens new window) 类,代码如下: @Schema(description = "分页结果")@Datapublic final class PageResult<T> implements Serializable { @Schema(description = "数据", required = true) private List<T> list; @Schema(description = "总量", required = true) private Long total;} 分页结果的数据 list 的每一项,通过自定义 VO 类,例如说 TenantRespVO (opens new window) 类。 # 2.2 Mapper 查询 在 TenantMapper (opens new window) 类中,定义 selectPage 查询方法。代码如下: @Mapperpublic interface TenantMapper extends BaseMapperX<TenantDO> { default PageResult<TenantDO> selectPage(TenantPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX<TenantDO>() .likeIfPresent(TenantDO::getName, reqVO.getName()) // 如果 name 不为空,则进行 like 查询 .likeIfPresent(TenantDO::getContactName, reqVO.getContactName()) .likeIfPresent(TenantDO::getContactMobile, reqVO.getContactMobile()) .eqIfPresent(TenantDO::getStatus, reqVO.getStatus()) // 如果 status 不为空,则进行 = 查询 .betweenIfPresent(TenantDO::getCreateTime, reqVO.getBeginCreateTime(), reqVO.getEndCreateTime()) // 如果 create 不为空,则进行 between 查询 .orderByDesc(TenantDO::getId)); // 按照 id 倒序 }} 针对 MyBatis Plus 分页查询的二次分装,在 BaseMapperX (opens new window) 中实现,主要是将 MyBatis 的分页结果 IPage,转换成项目的分页结果 PageResult。代码如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:58:24 参数校验 文件存储(上传下载) ← 参数校验 文件存储(上传下载)→"},{"title":"功能权限","path":"/wiki/YuDaoBoot/后端手册/功能权限/功能权限.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 功能权限 # 👍 相关视频教程 功能权限 01:如何设计一套权限系统? (opens new window) 功能权限 02:如何实现菜单的创建? (opens new window) 功能权限 03:如何实现角色的创建? (opens new window) 功能权限 04:如何给用户分配权限 —— 将菜单赋予角色? (opens new window) 功能权限 05:如何给用户分配权限 —— 将角色赋予用户? (opens new window) 功能权限 06:后端如何实现 URL 权限的校验? (opens new window) 功能权限 07:前端如何实现菜单的动态加载? (opens new window) 功能权限 08:前端如何实现按钮的权限校验? (opens new window) # 1. RBAC 权限模型 系统采用 RBAC 权限模型,全称是 Role-Based Access Control 基于角色的访问控制。 简单来说,每个用户拥有若干角色,每个角色拥有若干个菜单,菜单中存在菜单权限、按钮权限。这样,就形成了 “用户<->角色<->菜单” 的授权模型。 在这种模型中,用户与角色、角色与菜单之间构成了多对多的关系,如下图: # 2. Token 认证机制 安全框架使用的是 Spring Security (opens new window) + Token 方案,整体流程如下图所示: ① 前端调用登录接口,使用账号密码获得到认证 Token。响应示例如下: { "code":0, "msg":"", "data":{ "token":"d2a3cdbc6c53470db67a582bd115103f" }} 管理后台的登录实现,可见 代码 (opens new window) 用户 App 的登录实现,可见 代码 (opens new window) 疑问:为什么不使用 Spring Security 内置的表单登录? Spring Security 的登录拓展起来不方便,例如说验证码、三方登录等等。 Token 存储在数据库中,对应 system_oauth2_access_token 访问令牌表的 id 字段。考虑到访问的性能,缓存在 Redis 的 oauth2_access_token:%s (opens new window) 键中。 疑问:为什么不使用 JWT(JSON Web Token)? JWT 是无状态的,无法实现 Token 的作废,例如说用户登出系统、修改密码等等场景。 推荐阅读 《还分不清 Cookie、Session、Token、JWT?》 (opens new window) 文章。 默认配置下,Token 有效期为 30 天,可通过 system_oauth2_client 表中 client_id = default 的记录进行自定义: 修改 access_token_validity_seconds 字段,设置访问令牌的过期时间,默认 1800 秒 = 30 分钟 修改 refresh_token_validity_seconds 字段,设置刷新令牌的过期时间,默认 43200 秒 = 30 天 ② 前端调用其它接口,需要在请求头带上 Token 进行访问。请求头格式如下: ### Authorization: Bearer 登录时返回的 TokenAuthorization: Bearer d2a3cdbc6c53470db67a582bd115103f 具体的代码实现,可见 TokenAuthenticationFilter (opens new window) 过滤器 考虑到使用 Postman、Swagger 调试接口方便,提供了 Token 的模拟机制。请求头格式如下: ### Authorization: Bearer test用户编号Authorization: Bearer test1 其中 \"test\" 可自定义,配置项如下: ### application-local.yamlyudao: security: mock-enable: true # 是否开启 Token 的模拟机制 mock-secret: test # Token 模拟机制的 Token 前缀 # 3. 权限注解 # 3.1 @PreAuthorize 注解 @PreAuthorize ( opens new window) 是 Spring Security 内置的前置权限注解,添加在 接口方法上,声明需要的权限,实现访问权限的控制。 ① 基于【权限标识】的权限控制 权限标识,对应 system_menu 表的 permission 字段,推荐格式为 ${系统}:${模块}:${操作},例如说 system:admin:add 标识 system 服务的添加管理员。 使用示例如下: // 符合 system:user:list 权限要求@PreAuthorize("@ss.hasPermission('system:user:list')")// 符合 system:user:add 或 system:user:edit 权限要求即可@PreAuthorize("@ss.hasAnyPermissions('system:user:add,system:user:edit')") ② 基于【角色标识】的权限控制 权限标识,对应 system_role 表的 code 字段, 例如说 super_admin 超级管理员、tenant_admin 租户管理员。 使用示例如下: // 属于 user 角色@PreAuthorize("@ss.hasRole('user')")// 属于 user 或者 admin 之一@PreAuthorize("@ss.hasAnyRoles('user,admin')") 实现原理是什么? 当 @PreAuthorize 注解里的 Spring EL 表达式返回 false 时,表示没有权限。 而 @PreAuthorize(\"@ss.hasPermission('system:user:list')\") 表示调用 Bean 名字为 ss 的 #hasPermission(...) 方法,方法参数为 \"system:user:list\" 字符串。ss 对应的 Bean 是 PermissionServiceImpl (opens new window) 类,所以你只需要去看该方法的实现代码 (opens new window)。 # 3.2 @PreAuthenticated 注解 @PreAuthenticated (opens new window) 是项目自定义的认证注解,添加在接口方法上,声明登录的用户才允许访问。 主要使用场景是,针对用户 App 的 /app-app/** 的 RESTful API 接口,默认是无需登录的,通过 @PreAuthenticated 声明它需要进行登录。使用示例如下: // AppAuthController.java@PostMapping("/update-password")@Operation(summary = "修改用户密码", description = "用户修改密码时使用")@PreAuthenticatedpublic CommonResult<Boolean> updatePassword(@RequestBody @Valid AppAuthUpdatePasswordReqVO reqVO) { // ... 省略代码} 具体的代码实现,可见 PreAuthenticatedAspect (opens new window) 类。 # 4. 自定义权限配置 默认配置下,管理后台的 /admin-api/** 所有 API 接口都必须登录后才允许访问,用户 App 的 /app-api/** 所有 API 接口无需登录就可以访问。 如下想要自定义权限配置,设置定义 API 接口可以匿名(不登录)进行访问,可以通过下面三种方式: # 4.1 方式一:自定义 AuthorizeRequestsCustomizer 实现 每个 Maven Module 可以实现自定义的 AuthorizeRequestsCustomizer (opens new window) Bean,额外定义每个 Module 的 API 接口的访问规则。例如说 yudao-module-infra 模块的 SecurityConfiguration (opens new window) 类,代码如下: @Configuration("infraSecurityConfiguration")public class SecurityConfiguration { @Value("${spring.boot.admin.context-path:''}") private String adminSeverContextPath; @Bean("infraAuthorizeRequestsCustomizer") public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() { return new AuthorizeRequestsCustomizer() { @Override public void customize(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) { // Swagger 接口文档 registry.antMatchers("/swagger-ui.html").anonymous() .antMatchers("/swagger-resources/**").anonymous() .antMatchers("/webjars/**").anonymous() .antMatchers("/*/api-docs").anonymous(); // Spring Boot Actuator 的安全配置 registry.antMatchers("/actuator").anonymous() .antMatchers("/actuator/**").anonymous(); // Druid 监控 registry.antMatchers("/druid/**").anonymous(); // Spring Boot Admin Server 的安全配置 registry.antMatchers(adminSeverContextPath).anonymous() .antMatchers(adminSeverContextPath + "/**").anonymous(); } }; }} 友情提示 permitAll() 方法:所有用户可以任意访问,包括带上 Token 访问 anonymous() 方法:匿名用户可以任意访问,带上 Token 访问会报错 如果你对 Spring Security 了解不多,可以阅读艿艿写的 《芋道 Spring Boot 安全框架 Spring Security 入门 》 (opens new window) 文章。 # 4.2 方式二:@PermitAll 注解 在 API 接口上添加 @PermitAll (opens new window) 注解,示例如下: // FileController.java@GetMapping("/{configId}/get/{path}")@PermitAllpublic void getFileContent(HttpServletResponse response, @PathVariable("configId") Long configId, @PathVariable("path") String path) throws Exception { // ...} # 4.3 方式三:yudao.security.permit-all-urls 配置项 在 application.yaml 配置文件,通过 yudao.security.permit-all-urls 配置项设置,示例如下: yudao: security: permit-all-urls: - /admin-ui/** # /resources/admin-ui 目录下的静态资源 - /admin-api/xxx/yyy .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:40 代码生成(新增功能) 数据权限 ← 代码生成(新增功能) 数据权限→"},{"title":"单元测试","path":"/wiki/YuDaoBoot/后端手册/单元测试/单元测试.html","content":"开发指南后端手册 芋道源码 2022-04-04 目录 单元测试 项目使用 Junit5 + Mockito 实现单元测试,提升代码质量、重复测试效率、部署可靠性等。 截止目前,项目已经有 500+ 测试用例。 内容推荐 如果你想系统学习单元测试,可以阅读《有效的单元测试》 (opens new window)这本书,非常适合 Java 工程师。 如果只是想学习 Spring Boot Test 的话,可以阅读 《芋道 Spring Boot 单元测试 Test 入门 》 (opens new window) 文章。 # 1.测试组件 yudao-spring-boot-starter-test (opens new window) 是项目提供的测试组件,用于单元测试、集成测试等等。 # 1.1 快速测试的基类 测试组件提供了 4 种单元测试的基类,通过继承它们,可以快速的构建单元测试的环境。 基类 作用 BaseMockitoUnitTest (opens new window) 纯 Mockito 的单元测试 BaseDbUnitTest (opens new window) 使用内嵌的 H2 数据库的单元测试 BaseRedisUnitTest (opens new window) 使用内嵌的 Redis 缓存的单元测试 BaseDbAndRedisUnitTest (opens new window) 使用内嵌的 H2 数据库 + Redis 缓存的单元测试 疑问:什么是内嵌的 Redis 缓存? 基于 jedis-mock (opens new window) 开源项目,通过 RedisTestConfiguration (opens new window) 配置类,启动一个 Redis 进程。一般情况下,会使用 16379 端口。 # 1.2 测试工具类 ① RandomUtils (opens new window) 基于 podam (opens new window) 开源项目,实现 Bean 对象的随机生成。 ② AssertUtils (opens new window) 封装 Junit 的 Assert 断言,实现 Bean 对象的断言,支持忽略部分属性。 # 2. BaseDbUnitTest 实战案例 以字典类型模块的 DictTypeServiceImpl (opens new window) 为例子,讲解它的 DictTypeServiceTest (opens new window) 单元测试的编写实现。 # 2.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-test 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-test</artifactId> <scope>test</scope></dependency> # 2.2 新建 ut 配置文件 在 test/resources ( opens new window) 目录,新建单元测试的 application-unit-test.yaml ( opens new window) 配置文件,内容如下: # 2.3 添加 H2 SQL 脚本 修改 test/resources/sql ( opens new window) 目录的两个 H2 SQL 脚本: ① 在 create_tables.sql ( opens new window) 文件中,添加 system_dict_type 的 H2 建表语句。SQL 如下: CREATE TABLE IF NOT EXISTS "system_dict_type" ( "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "name" varchar(100) NOT NULL DEFAULT '', "type" varchar(100) NOT NULL DEFAULT '', "status" tinyint NOT NULL DEFAULT '0', "remark" varchar(500) DEFAULT NULL, "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted" bit NOT NULL DEFAULT FALSE, PRIMARY KEY ("id")) COMMENT '字典类型表'; 注意,H2 和 MySQL 的建表语句有区别,需要手动进行转换。如果你不想进行转换,可以使用 [基础设置 -> 代码生成] 菜单的代码生成器功能,如下图所示: ② 在 clean.sql (opens new window) 文件中,添加 system_dict_type 的清空数据的语句。SQL 如下: DELETE FROM "system_dict_type"; 每次单元测试的方法执行完后,会执行 clean.sql 脚本,进行数据的清理,保证每个单元测试的方法的数据隔离性。 # 2.3 新建 DictTypeServiceTest 类 新建 DictTypeServiceTest 测试类,继承 BaseMockitoUnitTest 基类,并完成它的配置。代码如下图所示: 属于自己模块的,使用 Spring 初始化成真实的 Bean,然后通过 @Resource 注入。例如说:dictTypeService、dictTypeMapper 属于别人模块的,使用 Spring @MockBean 注解,模拟 Mock 成一个 Bean 后注入。例如说:dictDataService 疑问:为什么有的进行 Mock,有的不进行 Mock 呢? 单元测试需要避免对外部的依赖,而 dictDataService 是外部依赖,所以需要 Mock 掉。 dictTypeMapper 某种程度来说,也是一种外部依赖,但是通过内嵌的 H2 内存数据库,进行“真实”的数据库操作,反而单元测试的编写效率更高,效果更好,所以不需要 Mock 掉。 另外,[基础设置 -> 代码生成] 菜单的代码生成器功能,已经生成了绝大多数的单元测试的逻辑,这里主要是希望让你了解单元测试的具体使用,所以并没有使用它。如下图所示: # 2.4 新增方法的单测 # 2.5 修改方法的单测 # 2.6 删除方法的单测 # 2.7 单条查询方法的单测 # 2.8 分页查询方法的单测 # 3. BaseMockitoUnitTest 实战案例 一些类由于不依赖 MySQL 和 Redis,可以通过继承 BaseMockitoUnitTest 基类,实现纯 Mockito 的单元测试。例如说 SmsSendServiceTest (opens new window) 单元测试类,代码如下: 具体 SmsSendServiceTest 的每个测试方法,和 DictTypeServiceTest 并没有什么差别,还是 Mock 模拟 + Assert 断言 + Verify 调用,你可以自己花点时间瞅瞅。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 工具类 Util 分布式锁 ← 工具类 Util 分布式锁→"},{"title":"参数校验","path":"/wiki/YuDaoBoot/后端手册/参数校验/参数校验.html","content":"开发指南后端手册 芋道源码 2022-03-26 目录 参数校验 项目使用 Hibernate Validator (opens new window) 框架,对 RESTful API 接口进行参数的校验,以保证最终数据入库的正确性。例如说,用户注册时,会校验手机格式的正确性,密码非弱密码。 如果参数校验不通过,会抛出 ConstraintViolationException 异常,被全局的异常处理捕获,返回“请求参数不正确”的响应。示例如下: { "code": 400, "data": null, "msg": "请求参数不正确:密码不能为空"} # 1. 参数校验注解 Validator 内置了 20+ 个参数校验注解,整理成常用与不常用的注解。 # 1.1 常用注解 注解 功能 @NotBlank 只能用于字符串不为 null ,并且字符串 #trim() 以后 length 要大于 0 @NotEmpty 集合对象的元素不为 0 ,即集合不为空,也可以用于字符串不为 null @NotNull 不能为 null @Pattern( value) 被注释的元素必须符合指定的正则表达式 @Max(value) 该字段的值只能小于或等于该值 @Min(value) 该字段的值只能大于或等于该值 @Range(min=, max=) 检被注释的元素必须在合适的范围内 @Size(max, min) 检查该字段的 size 是否在 min 和 max 之间,可以是字符串、数组、集合、Map 等 @Length(max, min) 被注释的字符串的大小必须在指定的范围内。 @AssertFalse 被注释的元素必须为 true @AssertTrue 被注释的元素必须为 false @Email 被注释的元素必须是电子邮箱地址 @URL( protocol=,host=,port=,regexp=,flags=) 被注释的字符串必须是一个有效的 URL # 1.2 不常用注解 注解 功能 @Null 必须为 null @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @DecimalMin(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @Digits(integer, fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内 @Positive 判断正数 @PositiveOrZero 判断正数或 0 @Negative 判断负数 @NegativeOrZero 判断负数或 0 @Future 被注释的元素必须是一个将来的日期 @FutureOrPresent 判断日期是否是将来或现在日期 @Past 检查该字段的日期是在过去 @PastOrPresent 判断日期是否是过去或现在日期 @SafeHtml 判断提交的 HTML 是否安全。例如说,不能包含 JavaScript 脚本等等 # 2. 参数校验使用 只需要三步,即可开启参数校验的功能。 〇 第零步,引入参数校验的 spring-boot-starter-validation ( opens new window) 依赖。一般不需要做,项目默认已经引入。 ① 第一步,在需要参数校验的类上,添加 @Validated ( opens new window) 注解,例如说 Controller、Service 类。代码如下: // Controller 示例@Validatedpublic class AuthController {}// Service 示例,一般放在实现类上@Service@Validatedpublic class AdminAuthServiceImpl implements AdminAuthService {} ② 第二步(情况一)如果方法的参数是 Bean 类型,则在方法参数上添加 @Valid (opens new window) 注解,并在 Bean 类上添加参数校验的注解。代码如下: // Controller 示例@Validatedpublic class AuthController { @PostMapping("/login") public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {}}// Service 示例,一般放在接口上public interface AdminAuthService { String login(@Valid AuthLoginReqVO reqVO, String userIp, String userAgent);}// Bean 类的示例。一般建议添加参数注解到属性上。原因:采用 Lombok 后,很少使用 getter 方法public class AuthLoginReqVO { @NotEmpty(message = "登录账号不能为空") @Length(min = 4, max = 16, message = "账号长度为 4-16 位") @Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母") private String username; @NotEmpty(message = "密码不能为空") @Length(min = 4, max = 16, message = "密码长度为 4-16 位") private String password;} ② 第二步(情况二)如果方法的参数是普通类型,则在方法参数上直接添加参数校验的注解。代码如下: // Controller 示例@Validatedpublic class DictDataController { @GetMapping(value = "/get") public CommonResult<DictDataRespVO> getDictData(@RequestParam("id") @NotNull(message = "编号不能为空") Long id) {}}// Service 示例,一般放在接口上public interface DictDataService { DictDataDO getDictData(@NotNull(message = "编号不能为空") Long id);} ③ 启动项目,模拟调用 RESTful API 接口,少填写几个参数,看看参数校验是否生效。 疑问:Controller 做了参数校验后,Service 是否需要做参数校验? 是需要的。Service 可能会被别的 Service 进行调用,也会存在参数不正确的情况,所以必须进行参数校验。 # 3. 自定义注解 如果 Validator 内置的参数校验注解不满足需求时,我们也可以自定义参数校验的注解。 在项目的 yudao-common (opens new window) 的 validation (opens new window) 包下,就自定义了多个参数校验的注解,以 @Mobile (opens new window) 注解来举例,它提供了手机格式的校验。 ① 第一步,新建 @Mobile 注解,并设置自定义校验器为 MobileValidator (opens new window) 类。代码如下: @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})@Retention(RetentionPolicy.RUNTIME)@Documented@Constraint( validatedBy = MobileValidator.class // 设置校验器)public @interface Mobile { String message() default "手机号格式不正确"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {};} ② 第二步,新建 MobileValidator (opens new window) 校验器。代码如下: public class MobileValidator implements ConstraintValidator<Mobile, String> { @Override public void initialize(Mobile annotation) { } @Override public boolean isValid(String value, ConstraintValidatorContext context) { // 如果手机号为空,默认不校验,即校验通过 if (StrUtil.isEmpty(value)) { return true; } // 校验手机 return ValidationUtils.isMobile(value); }} ③ 第三步,在需要手机格式校验的参数上添加 @Mobile 注解。示例代码如下: public class AppAuthLoginReqVO { @NotEmpty(message = "手机号不能为空") @Mobile // <=== here private String mobile;} # 4. 更多使用文档 更多关于 Validator 的使用,可以系统阅读 《芋道 Spring Boot 参数校验 Validation 入门 》 ( opens new window) 文章。 例如说,手动参数校验、分组校验、国际化 i18n 等等。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/07/06, 00:34:56 异常处理(错误码) 分页实现 ← 异常处理(错误码) 分页实现→"},{"title":"地区 & IP 库","path":"/wiki/YuDaoBoot/后端手册/地区 & IP 库/地区 & IP 库.html","content":"开发指南后端手册 芋道源码 2022-12-29 目录 地区 & IP 库 yudao-spring-boot-starter-biz-ip (opens new window) 业务组件,提供地区 & IP 库的封装。 # 1. 地区 AreaUtils (opens new window) 是地区工具类,可以查询中国的省、市、区县,也可以查询国外的国家。 它的数据来自 Administrative-divisions-of-China (opens new window) 项目,最终整理到项目的 area.csv (opens new window) 文件。每一行的数据,对应 Area (opens new window) 对象。代码所示: public class Area { /** * 编号 */ private Integer id; /** * 名字 */ private String name; /** * 类型 * * 枚举 {@link AreaTypeEnum} * 1 - 国家 * 2 - 省份 * 3 - 城市 * 4 - 地区, 例如说县、镇、区等 */ private Integer type; /** * 父节点 */ private Area parent; /** * 子节点 */ private List<Area> children;} AreaUtils 主要有如下两个方法: // AreaUtils.java/** * 获得指定编号对应的区域 * * @param id 区域编号 * @return 区域 */public static Area getArea(Integer id) { // ... 省略具体实现}/** * 格式化区域 * * 例如说: * 1. id = “静安区”时:上海 上海市 静安区 * 2. id = “上海市”时:上海 上海市 * 3. id = “上海”时:上海 * 4. id = “美国”时:美国 * 当区域在中国时,默认不显示中国 * * @param id 区域编号 * @param separator 分隔符 * @return 格式化后的区域 */public static String format(Integer id, String separator) { // ... 省略具体实现} 具体的使用,可见 AreaUtilsTest (opens new window) 测试类。 另外,管理后台提供了 [系统管理 -> 地区管理] 菜单,可以按照树形结构查看地区列表。如下图所示: 后端代码,对应 AreaController (opens new window) 的 /admin-api/system/area/tree 接口 前端代码,对应 system/area/index.vue (opens new window) 界面 # 2. IP IPUtils (opens new window) 是 IP 工具类,可以查询 IP 对应的城市信息。 它的数据来自 ip2region (opens new window) 项目,最终整理到项目的 ip2region.xdb (opens new window) 文件。 IPUtils 主要有如下两个方法: // IPUtils.java/** * 查询 IP 对应的地区编号 * * @param ip IP 地址,格式为 127.0.0.1 * @return 地区id */public static Integer getAreaId(String ip) { // ... 省略具体实现}/** * 查询 IP 对应的地区 * * @param ip IP 地址,格式为 127.0.0.1 * @return 地区 */public static Area getArea(String ip) { // ... 省略具体实现} 具体的使用,可见 IPUtilsTest (opens new window) 测试类。 另外,管理后台提供了 [系统管理 -> 地区管理] 菜单,也提供了 IP 查询城市的示例。如下图所示: 后端代码,对应 AreaController (opens new window) 的 /admin-api/system/area/get-by-ip 接口 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/27, 21:55:46 验证码 工作流(Flowable)会签、或签 ← 验证码 工作流(Flowable)会签、或签→"},{"title":"定时任务","path":"/wiki/YuDaoBoot/后端手册/定时任务/定时任务.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 定时任务 定时任务的使用场景主要如下: 时间驱动处理场景:每分钟扫描超时支付的订单,活动状态刷新,整点发送优惠券。 批量处理数据:按月批量统计报表数据,批量更新短信状态,实时性要求不高。 年度最佳定时任务:每个月初的工资单的推送!!! 如果你对定时任务了解不多,可以后续阅读 《芋道 Spring Boot 定时任务入门》 (opens new window) 文章。 项目基于 Quartz + MySQL 实现分布式定时任务,并提供 [基础设施 -> 定时任务] 菜单,进行定时任务的统一管理,支持动态控制任务的添加、修改、开启、暂停、删除、执行一次等操作。 yudao-spring-boot-starter-job (opens new window) 技术组件:基于 Quartz 框架的封装,提供简便的 JobHandler (opens new window) 接入,任务的执行、重试,执行日志的记录。 yudao-module-infra 的 job (opens new window) 业务模块,提供任务的动态管理,执行日志的存储。 # 1. Quartz 配置 在 application-local.yaml (opens new window) 配置文件中,通过 spring.quartz 配置项,设置 Quartz 使用 MySQL 实现集群。如下图所示: 考虑到 local 本地和 dev 测试环境使用相同的数据库,所以【本地】配置 spring.quartz.auto-startup 为 false,禁用本地执行定时任务的功能,影响测试环境。 # 2. 实战案例 以用户 Session 超时的定时任务举例子,讲解在项目中使用定时任务。 注意,需要修改 application-local.yaml 配置文件,将 spring.quartz.auto-startup 为 true,开启本地执行定任务的功能。 # 2.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-job 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-job</artifactId></dependency> # 2.2 UserSessionTimeoutJob 每个 yudao-module-xxx-biz 模块的 job 包,用于定义定时任务的 Job 类。 因此,在 yudao-module-system-biz 模块的 job 包下,创建 UserSessionTimeoutJob ( opens new window) 类,实现 JobHandler ( opens new window) 接口,执行用户 Session 超时 Job。如下图所示: 疑问:为什么添加 @TenantJob 注解? 声明 @TenantJob ( opens new window) 注解在 Job 类上,实现并行遍历每个租户,执行定时任务的逻辑。 更多多租户的内容,可见 《开发指南 —— SaaS 多租户》 ( opens new window) 文档。 # 2.3 配置任务 ① 点击 [新增] 按钮,填写定时任务 UserSessionTimeoutJob 的信息。如下图所示: 处理器的名字:对应的 Spring Bean 名字。例如说 UserSessionTimeoutJob 对应 userSessionTimeoutJob Cron 表达式:执行周期,可通过 [生成表达式] 功能,进行生成 重试次数、重试间隔:执行失败后,立即重试的次数以及重试的间隔时间 超时时间监控:执行超过该时间后,发送告警邮件给开发【暂不支持,未来实现】 常用的 Cron 表达式如下: 0 0 10,14,16 * * ? 每天上午 10 点,下午 2 点、4 点 0 0/30 9-17 * * ? 朝九晚五工作时间内,每半小时 0 0 12 ? * WED 表示每个星期三中午 12 点 0 0 12 * * ? 每天中午 12 点触发 0 15 10 ? * * 每天上午 10:15 触发 0 15 10 * * ? 每天上午 10:15 触发 0 15 10 * * ? * 每天上午 10:15 触发 0 15 10 * * ? 2005 2005 年的每天上午 10:15 触发 0 * 14 * * ? 在每天下午 2 点到下午 2:59 期间,每 1 分钟触发 0 0/5 14 * * ? 在每天下午 2 点到下午 2:55 期间,每 5 分钟触发 0 0/5 14,18 * * ? 在每天下午 2 点到 2:55 期间和下午 6 点到 6:55 期间,每 5 分钟触发 0 0-5 14 * * ? 在每天下午 2 点到下午 2:05 期间,每 1 分钟触发 0 10,44 14 ? 3 WED 每年三月的星期三的下午 2:10 和 2:44 触发 0 15 10 ? * MON-FRI 周一至周五的上午 10:15 触发 0 15 10 15 * ? 每月15日上午 10:15 触发 0 15 10 L * ? 每月最后一日的上午 10:15 触发 0 15 10 ? * 6L 每月的最后一个星期五上午 10:15 触发 0 15 10 ? * 6L 2002-2005 2002 年至 2005 年,每月的最后一个星期五上午 10:15 触发 0 15 10 ? * 6#3 每月的第三个星期五上午 10:15 触发 ② 点击 [更多 -> 任务详情] 按钮,可以查看任务的基础信息、后续的执行时间。如下图所示: # 2.4 测试任务 ① 点击 [更多 -> 执行一次] 按钮,立即执行一次 UserSessionTimeoutJob 定时任务。可以在 IDEA 控制台看到输出,如下图所示: ② 点击 [更多 -> 调度日志] 按钮,可以查看到 UserSessionTimeoutJob 的执行日志。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 本地缓存 异步任务 ← 本地缓存 异步任务→"},{"title":"多数据源(读写分离)","path":"/wiki/YuDaoBoot/后端手册/多数据源(读写分离)/多数据源(读写分离).html","content":"开发指南后端手册 芋道源码 2022-04-02 目录 多数据源(读写分离) yudao-spring-boot-starter-mybatis (opens new window) 技术组件,除了提供 MyBatis 数据库操作,还提供了如下 2 种功能: 数据连接池:基于 Alibaba Druid (opens new window) 实现,额外提供监控的能力。 多数据源(读写分离):基于 Dynamic Datasource (opens new window) 实现,支持 Druid 连接池,可集成 Seata (opens new window) 实现分布式事务。 # 1. 数据连接池 友情提示: 如果你未学习过 Druid 数据库连接池,可以后续阅读 《芋道 Spring Boot 数据库连接池入门》 (opens new window) 文章。 <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId></dependency> # 1.1 Druid 监控配置 在 application-local.yaml ( opens new window) 配置文件中,通过 spring.datasource.druid 配置项,仅仅设置了 Druid 监控相关的配置项目,具体数据库的设置需要使用 Dynamic Datasource 的配置项。如下图所示: # 1.2 Druid 监控界面 ① 访问后端的 /druid/index.html 路径,例如说本地的 http://127.0.0.1:48080/druid/index.html 地址,可以查看到 Druid 监控界面。如下图所示: ② 访问前端的 [基础设施 -> MySQL 监控] 菜单,也可以查看到 Druid 监控界面。如下图所示: 补充说明: 前端 [基础设施 -> MySQL 监控] 菜单,通过 iframe 内嵌后端的 /druid/index.html 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.druid 配置项。 # 2. 多数据源 友情提示: 如果你未学习过多数据源,可以后续阅读 《芋道 Spring Boot 多数据源(读写分离)入门》 ( opens new window) 文章。 <dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId></dependency> # 2.1 多数据源配置 在 application-local.yaml ( opens new window) 配置文件中,通过 spring.datasource.dynamic 配置项,配置了 Master-Slave 主从两个数据源。如下图所示: # 2.2 数据源切换 # 2.2.1 @Master 注解 在方法上添加 @Master ( opens new window) 注解,使用名字为 master 的数据源,即使用【主】库,一般适合【写】场景。示例如下图: 由于项目的 spring.datasource.dynamic.primary 为 master,默认使用【主】库,所以无需手动添加 @Master 注解。 # 2.2.2 @Slave 注解 在方法上添加 @Slave ( opens new window) 注解,使用名字为 slave 的数据源,即使用【从】库,一般适合【读】场景。示例如下图: # 2.2.3 @DS 注解 在方法上添加 @DS ( opens new window) 注解,使用指定名字的数据源,适合多数据源的情况。示例如下图: # 2.3 分布式事务 在使用 Spring @Transactional 声明的事务中,无法进行数据源的切换,此时有 3 种解决方案: ① 拆分成多个 Spring 事务,每个事务对应一个数据源。如果是【写】场景,可能会存在多数据源的事务不一致的问题。 ② 引入 Seata 框架,提供完整的分布式事务的解决方案,可学习 《芋道 Seata 极简入门 》 ( opens new window) 文章。 ③ 使用 Dynamic Datasource 提供的 @DSTransactional ( opens new window) 注解,支持多数据源的切换,不提供绝对可靠的多数据源的事务一致性(强于 ① 弱于 ②),可学习 《DSTransactional 实现源码分析 》 ( opens new window) 文章。 # 3. 分库分表 建议采用 ShardingSphere 的子项目 Sharding-JDBC 完成分库分表的功能,可阅读 《芋道 Spring Boot 分库分表入门 》 ( opens new window) 文章,学习如何整合进项目。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:25:01 数据库 MyBatis Redis 缓存 ← 数据库 MyBatis Redis 缓存→"},{"title":"工具类 Util","path":"/wiki/YuDaoBoot/后端手册/工具类 Util/工具类 Util.html","content":"开发指南后端手册 芋道源码 2022-04-04 目录 工具类 Util 本小节,介绍项目中使用到的工具类,避免大家重复造轮子。 # 1. Hutool 项目使用 Hutool (opens new window) 作为主工具库。Hutool 是国产的一个 Java 工具包,它可以帮助我们简化每一行代码,减少每一个方法,让 Java 语言也可以“甜甜的”。 yudao-common (opens new window) 模块的 util (opens new window) 包作为辅工具库,以 Utils 结尾,补充 Hutool 缺少的工具能力。 友情提示:常用的工具类,使用 ⭐ 标记,需要的时候可以找找有没对应的工具方法。 作用 Hutool 芋道 Utils 数组工具 ArrayUtil (opens new window) ArrayUtils (opens new window) ⭐ 集合工具 CollUtil (opens new window) CollectionUtils (opens new window) ⭐ Map 工具 MapUtil (opens new window) MapUtils (opens new window) Set 工具 SetUtils (opens new window) List 工具 ListUtil (opens new window) 文件工具 FileUtil (opens new window) FileTypeUtil (opens new window) FileUtils (opens new window) 压缩工具 ZipUtil (opens new window) IoUtils (opens new window) IO 工具 ZipUtil (opens new window) Resource 工具 ResourceUtil (opens new window) JSON 工具 JsonUtils (opens new window) 数字工具 NumberUtil (opens new window) NumberUtils (opens new window) 对象工具 ObjectUtil (opens new window) ObjectUtils (opens new window) 唯一 ID 工具 IdUtil (opens new window) ⭐ 字符串工具 StrUtil (opens new window) StrUtils (opens new window) 时间工具 DateUtil (opens new window) DateUtils (opens new window) 反射工具 ReflectUtil (opens new window) 异常工具 ExceptionUtil (opens new window) 随机工具 RandomUtil (opens new window) RandomUtils (opens new window) URL 工具 URLUtil (opens new window) HttpUtils (opens new window) Servlet 工具 ServletUtils (opens new window) Spring 工具 SpringUtil (opens new window) SpringAopUtils (opens new window) SpringExpressionUtils (opens new window) 分页工具 PageUtils (opens new window) 校验工具 ValidationUtil (opens new window) ValidationUtils (opens new window) 断言工具 Assert (opens new window) AssertUtils (opens new window) 强烈推荐: Guava 是 Google 开源的 Java 常用类库,如果你感兴趣,可以阅读 《Guava 学习笔记》 (opens new window) 文章。 # 2. Lombok Lombok (opens new window) 是一个 Java 工具,通过使用其定义的注解,自动生成常见的冗余代码,提升开发效率。 如果你没有学习过 Lombok,需要阅读下 《芋道 Spring Boot 消除冗余代码 Lombok 入门》 (opens new window) 文章。 在项目的根目录有 lombok.config (opens new window) 全局配置文件,开启链式调用、生成的 toString/hashcode/equals 方法需要调用父方法。如下图所示: # 3. MapStruct 项目使用 MapStruct (opens new window) 实现 VO、DO、DTO 等对象之间的转换。 如果你没有学习过 MapStruct,需要阅读下 《芋道 Spring Boot 对象转换 MapStruct 入门》 (opens new window) 文章。 在每个 yudao-module-xxx-biz 模块的 convert 包下,可以看到各个业务的 Convert 接口,如下图所示: # 4. HTTP 调用 ① 使用 Feign 实现声明式的调用,可参考《芋道 Spring Boot 声明式调用 Feign 入门 》 (opens new window)文章。 ② 使用 Hutool 自带的 HttpUtil (opens new window) 工具类。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/05/03, 18:13:25 配置管理 单元测试 ← 配置管理 单元测试→"},{"title":"分布式锁","path":"/wiki/YuDaoBoot/后端手册/分布式锁/分布式锁.html","content":"开发指南后端手册 芋道源码 2022-04-05 目录 分布式锁 yudao-spring-boot-starter-protection (opens new window) 技术组件,使用 Redis 实现分布式锁的功能,它有 2 种使用方式: 编程式锁:基于 Redisson (opens new window) 框架提供的各种 (opens new window)分布式锁 声明式锁:基于 Lock4j (opens new window) 框架的 @Lock4j 注解 Redis 分布式锁的实现原理? 参见 《Redis 实现原理与源码解析系列》 (opens new window) 文章。 # 1. 编程式锁 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId></dependency> # 1.1 Redisson 配置 无需配置。因为在 Redis 缓存 中,进行了 Spring Data Redis + Redisson 的配置。 # 1.2 实战案例 yudao-module-pay 模块的 notify ( opens new window) 功能,使用到分布式锁,确保每个 支付通知任务有且仅有一个在执行。下面,来看看这个案例是如何实现的。 友情提示: 建议你已经阅读过 《开发指南 —— Redis 缓存》 文档。 ① 在 RedisKeyConstants ( opens new window) 类中,定义通知任务使用的分布式锁的 Redis Key。如下图所示: ② 创建 PayNotifyLockRedisDAO ( opens new window) 类,使用 RedisClient 实现分布式锁的加锁与解锁。如下图所示: ③ 在 PayNotifyServiceImpl ( opens new window) 执行指定的支付通知任务时,通过 PayNotifyLockRedisDAO 获得分布式锁。如下图所示: 技术选型:为什么不使用 Lock4j 提供的 LockTemplate 实现编程式锁? 两者各有优势,选择 Redisson 主要考虑它支持的 Redis 分布式锁的类型较多:可靠性较高的红锁、性能较好的读写锁等等。 Lock4j 的 LockTemplate 也是不错的选择,一方面不强依赖 Redisson 框架,一方面支持 ZooKeeper 等等。 # 2. 声明式锁 <dependency> <groupId>com.baomidou</groupId> <artifactId>lock4j-redisson-spring-boot-starter</artifactId></dependency> # 2.1 Lock4j 配置 在 application-local.yaml ( opens new window) 配置文件中,通过 lock4j 配置项,添加 Lock4j 全局默认的分布式锁配置。如下图所示: # 2.2 使用案例 在需要使用到分布式锁的方法上,添加 @Lock4j 注解,非常方便。示例代码如下: @Servicepublic class DemoService { // 默认使用 lock4j 配置项 @Lock4j public void simple() { //do something } // 完全配置,支持 Spring EL 表达式 @Lock4j(keys = {"#user.id", "#user.name"}, expire = 60000, acquireTimeout = 1000) public User customMethod(User user) { return user; }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 22:48:53 单元测试 幂等性(防重复提交) ← 单元测试 幂等性(防重复提交)→"},{"title":"幂等性(防重复提交)","path":"/wiki/YuDaoBoot/后端手册/幂等性(防重复提交)/幂等性(防重复提交).html","content":"开发指南后端手册 芋道源码 2022-04-09 目录 幂等性(防重复提交) yudao-spring-boot-starter-protection (opens new window) 技术组件,由它的 idempotent (opens new window) 包,提供声明式的幂等特性,可防止重复请求。例如说,用户快速的双击了某个按钮,前端没有禁用该按钮,导致发送了两次重复的请求。 // UserController.java@Idempotent(timeout = 10, timeUnit = TimeUnit.SECONDS, message = "正在添加用户中,请勿重复提交")@PostMapping("/user/create")public String createUser(User user){ userService.createUser(user); return "添加成功";} # 1. 实现原理 它的实现原理非常简单,针对相同参数的方法,一段时间内,有且仅能执行一次。执行流程如下: ① 在方法执行前,根据参数对应的 Key 查询是否存在。 如果存在,说明正在执行中,则进行报错。 如果不在 ,则计算参数对应的 Key,存储到 Redis 中,并设置过期时间,即标记正在执行中。 默认参数的 Redis Key 的计算规则由 DefaultIdempotentKeyResolver ( opens new window) 实现,使用 MD5(方法名 + 方法参数),避免 Redis Key 过长。 ② 方法执行完成, 不会主动删除参数对应的 Key。 如果希望会主动删除 Key,可以使用 《开发指南 —— 分布式锁》 提供的 @Lock 来实现幂等性。 🙂 从本质上来说,idempotent 包提供的幂等特性,本质上也是基于 Redis 实现的分布式锁。 ③ 如果方法执行时间较长,超过 Key 的过期时间,则 Redis 会自动删除对应的 Key。因此,需要大概评估下,避免方法的执行时间超过过期时间。 # 2. @Idempotent 注解 @Idempotent ( opens new window) 注解,声明在方法上,表示该方法需要开启幂等性。代码如下: ① 对应的 AOP 切面是 IdempotentAspect ( opens new window) 类,核心就 10 行左右的代码,如下图所示: ② 对应的 Redis Key 的前缀是 idempotent:%s ,可见 IdempotentRedisDAO ( opens new window) 类,如下图所示: # 3. 使用示例 本小节,我们实现 /admin-api/infra/test-demo/get RESTful API 接口的幂等性。 ① 在 pom.xml 文件中,引入 yudao-spring-boot-starter-protection 依赖。 <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-protection</artifactId></dependency> ② 在 /admin-api/infra/test-demo/get RESTful API 接口的对应方法上,添加 @Idempotent 注解。代码如下: // TestDemoController.java@GetMapping("/get")@Idempotent(timeout = 10, message = "重复请求,请稍后重试")public CommonResult<TestDemoRespVO> getTestDemo(@RequestParam("id") Long id) { // ... 省略代码} ③ 调用 /admin-api/infra/test-demo/get RESTful API 接口,执行成功。 ④ 再次调用 /admin-api/infra/test-demo/get RESTful API 接口,被幂等性拦截,执行失败。 { "code": 900, "data": null, "msg": "重复请求,请稍后重试"} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/16, 01:42:17 分布式锁 限流熔断 ← 分布式锁 限流熔断→"},{"title":"异常处理(错误码)","path":"/wiki/YuDaoBoot/后端手册/异常处理(错误码)/异常处理(错误码).html","content":"开发指南后端手册 芋道源码 2022-03-25 目录 异常处理(错误码) 本章节,将讲解异常相关的统一响应、异常处理、业务异常、错误码这 4 块的内容。 # 1. 统一响应 后端提供 RESTful API 给前端时,需要响应前端 API 调用是否成功: 如果成功,成功的数据是什么。后续,前端会将数据渲染到页面上 如果失败,失败的原因是什么。一般,前端会将原因弹出提示给用户 因此,需要有统一响应,而不能是每个接口定义自己的风格。一般来说,统一响应返回信息如下: 成功时,返回成功的状态码 + 数据 失败时,返回失败的状态码 + 错误提示 在标准的 RESTful API 的定义,是推荐使用 HTTP 响应状态码 (opens new window) 作为状态码。一般来说,我们实践很少这么去做,主要原因如下: 业务返回的错误状态码很多,HTTP 响应状态码无法很好的映射。例如说,活动还未开始、订单已取消等等 学习成本高,开发者对 HTTP 响应状态码不是很了解。例如说,可能只知道 200、403、404、500 几种常见的 # 1.1 CommonResult ruoyi-vue-pro (opens new window) 项目在实践时,将状态码放在 Response Body 响应内容中返回。一共有 3 个字段,通过 CommonResult (opens new window) 定义如下: // 成功响应{ code: 0, data: { id: 1, username: "yudaoyuanma" }}// 失败响应{ code: 233666, message: "徐妈太丑了"} 可以增加 success 字段吗? 有些团队在实践时,会增加了 success 字段,通过 true 和 false 表示成功还是失败。 这个看每个团队的习惯吧。艿艿的话,还是偏好基于约定,返回 0 时表示成功。 失败时的 code 字段,使用全局的错误码,稍后在 「4. 错误码」 小节来讲解。 ① 在 RESTful API 成功时,定义 Controller 对应方法的返回类型为 CommonResult,并调用 #success(T data) (opens new window) 方法来返回。代码如下图: CommonResult 的 data 字段是泛型,建议定义对应的 VO 类,而不是使用 Map 类。 ② 在 RESTful API 失败时,通过抛出 Exception 异常,具体在 「2. 异常处理」 小节。 # 1.2 使用 @ControllerAdvice ? 在 Spring MVC 中,可以使用 @ControllerAdvice 注解,通过 Spring AOP 拦截修改 Controller 方法的返回结果,从而实现全局的统一返回。 使用 @ControllerAdvice 注解的实战案例? 如果你感兴趣的话,可以阅读 《芋道 Spring Boot SpringMVC 入门 》 (opens new window) 文章的「4. 全局统一返回 」小节。 为什么项目不采用这种方式呢?主要原因是,这样的方式“破坏”了方法的定义,导致一些隐性的问题。例如说,Swagger 接口定义错误,展示的响应结果不是 CommonResult。 还有个原因,部分 RESTful API 不需要自动包装 CommonResult 结果。例如说,第三方支付回调只需要返回 \"success\" 字符串。 # 2. 异常处理 RESTful API 发生异常时,需要拦截 Exception 异常,转换成统一响应的格式,否则前端无法处理。 # 2.1 Spring MVC 的异常 在 Spring MVC 中,通过 @ControllerAdvice + @ExceptionHandler 注解,声明将指定类型的异常,转换成对应的 CommonResult 响应。实现的代码,可见 GlobalExceptionHandler (opens new window) 类,代码如下: # 2.2 Filter 的异常 在请求被 Spring MVC 处理之前,是先经过 Filter 处理的,此时发生异常时,是无法通过 @ExceptionHandler 注解来处理的。只能通过 try catch 的方式来实现,代码如下: # 3. 业务异常 在 Service 发生业务异常时,如果进行返回呢?例如说,用户名已经存在,商品库存不足等。常用的方案选择,主要有两种: 方案一,使用 CommonResult 统一响应结果,里面有错误码和错误提示,然后进行 return 返回 方案二,使用 ServiceException 统一业务异常,里面有错误码和错误提示,然后进行 throw 抛出 选择方案一 CommonResult 会存在两个问题: 因为 Spring @Transactional 声明式事务,是基于异常进行回滚的,如果使用 CommonResult 返回,则事务回滚会非常麻烦 当调用别的方法时,如果别人返回的是 CommonResult 对象,还需要不断的进行判断,写起来挺麻烦的 因此,项目采用方案二 ServiceException 异常。 # 3.1 ServiceException 定义 ServiceException (opens new window) 异常类,继承 RuntimeException 异常类(非受检),用于定义业务异常。代码如下: 为什么继承 RuntimeException 异常? 大多数业务场景下,我们无需处理 ServiceException 业务异常,而是通过 GlobalExceptionHandler 统一处理,转换成对应的 CommonResult 对象,进而提示给前端即可。 如果真的需要处理 ServiceException 时,通过 try catch 的方式进行主动捕获。 # 3.2 ServiceExceptionUtil 在 Service 需抛出业务异常时,通过调用 ServiceExceptionUtil (opens new window) 的 #exception(ErrorCode errorCode, Object... params) 方法来构建 ServiceException 异常,然后使用 throw 进行抛出。代码如下: // ServiceExceptionUtil.javapublic static ServiceException exception(ErrorCode errorCode) { /** 省略参数 */ }public static ServiceException exception(ErrorCode errorCode, Object... params) { /** 省略参数 */ } 为什么使用 ServiceExceptionUtil 来构建 ServiceException 异常? 错误提示的内容,支持使用管理后台进行动态配置,所以通过 ServiceExceptionUtil 获取内容的配置与格式化。 # 4. 错误码 错误码,对应 ErrorCode (opens new window) 类,枚举项目中的错误,全局唯一,方便定位是谁的错、错在哪。 # 4.1 错误码分类 错误码分成两类:全局的系统错误码、模块的业务错误码。 # 4.1.1 系统错误码 全局的系统错误码,使用 0-999 错误码段,和 HTTP 响应状态码 (opens new window) 对应。虽然说,HTTP 响应状态码作为业务使用表达能力偏弱,但是使用在系统层面还是非常不错的。 系统错误码定义在 GlobalErrorCodeConstants (opens new window) 类,代码如下: # 4.1.2 业务错误码 模块的业务错误码,按照模块分配错误码的区间,避免模块之间的错误码冲突。 ① 业务错误码一共 10 位,分成 4 段,在 ServiceErrorCodeRange (opens new window) 分配,规则与代码如下图: ② 每个业务模块,定义自己的 ErrorCodeConstants 错误码枚举类。以 yudao-module-system 模块举例子,代码如下: # 4.2 错误码管理 在管理后台的 [系统管理 -> 错误码管理] 菜单,可以进行错误码的管理。 启动中的项目会每 60 秒,加载最新的错误码配置。所以,我们在修改完错误码的提示后,无需重启项目。 # 4.2.1 手动添加 点击 [新增] 按钮,进行错误码的手动添加。如下图所示: # 4.2.2 自动添加 通过 yudao.error-code.constants-class-list 配置项,设置需要自动添加的 ErrorCodeConstants 错误码枚举类。如下图所示: 项目启动时,会自动扫描对应的 ErrorCodeConstants 中的错误码,自动添加或修改错误码的配置。 注意,自动添加的错误码的类型为【自动生成】,一旦在管理后台手动 [编辑] 后,该错误码就不再支持自动修改。 自动添加是如何实现的? 参见 system/framework/errorcode (opens new window) 包的代码。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 SaaS 多租户【数据库隔离】 参数校验 ← SaaS 多租户【数据库隔离】 参数校验→"},{"title":"异步任务","path":"/wiki/YuDaoBoot/后端手册/异步任务/异步任务.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 异步任务 yudao-spring-boot-starter-job (opens new window) 技术组件,除了提供定时任务的功能,还提供了 Async 异步任务的能力。系统使用异步任务,提升执行效率。例如说: 操作日志模块 (opens new window),异步记录【操作日志】 访问日志模块 (opens new window),异步记录【访问日志】 友情提示: 如果你未学习过 Spring 异步任务,可以后续阅读 《芋道 Spring Boot 异步任务入门 》 (opens new window) 文章。 # 1. Async 配置 在 YudaoAsyncAutoConfiguration (opens new window) 配置类,设置使用 TransmittableThreadLocal (opens new window),解决异步执行时上下文传递的问题。如下图所示: 友情提示: 项目使用到 ThreadLocal 的地方,建议都使用 TransmittableThreadLocal 进行替换。 # 2. 引入依赖 以访问日志模块为例,讲解它如何使用异步任务,实现异步记录【访问日志】的功能。 # 2.1 引入依赖 在 yudao-module-system-infra 模块中,引入 yudao-spring-boot-starter-job 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-job</artifactId></dependency> # 2.2 添加 @Async 注解 在 ApiAccessLogServiceImpl ( opens new window) 的 #createApiAccessLogAsync(...) 方法上,添加 @Async 注解,声明它要异步执行。如下图所示: # 2.3 测试调用 随便请求一个 RESTful API 接口,可以看到在异步任务的线程池中,进行了访问日志的记录。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 定时任务 消息队列 ← 定时任务 消息队列→"},{"title":"敏感词","path":"/wiki/YuDaoBoot/后端手册/敏感词/敏感词.html","content":"开发指南后端手册 芋道源码 2022-12-31 目录 敏感词 本章节,介绍项目的敏感词功能,可用于文本检测,高效过滤色情、广告、敏感、暴恐等违规内容。例如说,用户昵称、评论、私信等文本内容,都可以使用敏感词功能进行过滤。 # 1. 实现原理 敏感词采用 前缀树 (opens new window) 算法,,核心代码见 SimpleTrie (opens new window) 类。 # 2. 使用教程 对应的管理后台,可以在 [系统管理 -> 敏感词] 菜单,进行敏感词的管理。如下图所示: 前端实现:sensitiveWord/index.vue (opens new window) 后端实现:SensitiveWordController (opens new window) # 2.1 添加敏感词 标签:用于敏感词分组,不同的场景会需要使用不同的敏感词,通过标签进行分组。 添加完敏感词后,刷新下界面。 # 2.2 测试敏感词 ① 输入检测文本为“你是白痴么?”,选择标签为“测试”,检测到有敏感词: ② 选择标签为“蔬菜”,检测到米有敏感词: # 3. 敏感词的使用 SensitiveWordApi (opens new window) 提供了敏感词的 API 接口,可以在任意地方使用。方法如下: public interface SensitiveWordApi { /** * 获得文本所包含的不合法的敏感词数组 * * @param text 文本 * @param tags 标签数组 * @return 不合法的敏感词数组 */ List<String> validateText(String text, List<String> tags); /** * 判断文本是否包含敏感词 * * @param text 文本 * @param tags 表述数组 * @return 是否包含 */ boolean isTextValid(String text, List<String> tags);} 使用步骤如下: ① 在需要使用的 yudao-module-*-biz 模块的 pom.xml 中,引入 yudao-module-system-api 依赖。代码如下: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 注入 SensitiveWordApi Bean,调用对应的方法即可。例如说: @Servicepublic class DemoService { @Resource private SensitiveWordApi sensitiveWordApi; public void demo() { sensitiveWordApi.validateText("你是白痴吗", Collections.singletonList("测试")); sensitiveWordApi.isTextValid("你是白痴吗", Collections.singletonList("蔬菜")); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/26, 21:09:52 数据脱敏 验证码 ← 数据脱敏 验证码→"},{"title":"数据库 MyBatis","path":"/wiki/YuDaoBoot/后端手册/数据库 MyBatis/数据库 MyBatis.html","content":"开发指南后端手册 芋道源码 2022-04-01 目录 数据库 MyBatis yudao-spring-boot-starter-mybatis (opens new window) 技术组件,基于 MyBatis Plus 实现数据库的操作。如果你没有学习过 MyBatis Plus,建议先阅读 《芋道 Spring Boot MyBatis 入门 》 (opens new window) 文章。 友情提示 MyBatis 是最容易读懂的 Java 框架之一,感兴趣的话,可以看看艿艿写的 《芋道 MyBatis 源码解析》 (opens new window) 系列,已经有 18000 人学习过! # 1. 实体类 BaseDO (opens new window) 是所有数据库实体的父类,代码如下: @Datapublic abstract class BaseDO implements Serializable { /** * 创建时间 */ @TableField(fill = FieldFill.INSERT) private Date createTime; /** * 最后更新时间 */ @TableField(fill = FieldFill.INSERT_UPDATE) private Date updateTime; /** * 创建者,目前使用 AdminUserDO / MemberUserDO 的 id 编号 * * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 */ @TableField(fill = FieldFill.INSERT) private String creator; /** * 更新者,目前使用 AdminUserDO / MemberUserDO 的 id 编号 * * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 */ @TableField(fill = FieldFill.INSERT_UPDATE) private String updater; /** * 是否删除 */ @TableLogic private Boolean deleted;} createTime + creator 字段,创建人相关信息。 updater + updateTime 字段,创建人相关信息。 deleted 字段,逻辑删除。 对应的 SQL 字段如下: `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', # 1.1 主键编号 id 主键编号,推荐使用 Long 型自增,原因是: 自增,保证数据库是按顺序写入,性能更加优秀。 Long 型,避免未来业务增长,超过 Int 范围。 对应的 SQL 字段如下: `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', 项目的 id 默认采用数据库自增的策略,如果希望使用 Snowflake 雪花算法,可以修改 application.yaml 配置文件,将配置项 mybatis-plus.global-config.db-config.id-type 修改为 ASSIGN_ID。如下图所示: # 1.2 逻辑删除 所有表通过 deleted 字段来实现逻辑删除,值为 0 表示未删除,值为 1 表示已删除,可见 application.yaml 配置文件的 logic-delete-value 和 logic-not-delete-value 配置项。如下图所示: ① 所有 SELECT 查询,都会自动拼接 WHERE deleted = 0 查询条件,过滤已经删除的记录。如果被删除的记录,只能通过在 XML 或者 @SELECT 来手写 SQL 语句。例如说: ② 建立唯一索引时,需要额外增加 delete_time 字段,添加到唯一索引字段中,避免唯一索引冲突。例如说,system_users 使用 username 作为唯一索引: 未添加前:先逻辑删除了一条 username = yudao 的记录,然后又插入了一条 username = yudao 的记录时,会报索引冲突的异常。 已添加后:先逻辑删除了一条 username = yudao 的记录并更新 delete_time 为当前时间,然后又插入一条 username = yudao 并且 delete_time 为 0 的记录,不会导致唯一索引冲突。 # 1.3 自动填充 DefaultDBFieldHandler (opens new window) 基于 MyBatis 自动填充机制,实现 BaseDO 通用字段的自动设置。代码如下如: # 1.4 “复杂”字段类型 MyBatis Plus 提供 TypeHandler 字段类型处理器,用于 JavaType 与 JdbcType 之间的转换。示例如下: 常用的字段类型处理器有: JacksonTypeHandler (opens new window):通用的 Jackson 实现 JSON 字段类型处理器。 JsonLongSetTypeHandler (opens new window):针对 Set<Long> 的 Jackson 实现 JSON 字段类型处理器。 另外,如果你后续要拓展自定义的 TypeHandler 实现,可以添加到 cn.iocoder.yudao.framework.mybatis.core.type (opens new window) 包下。 注意事项: 使用 TypeHandler 时,需要设置实体的 @TableName 注解的 @autoResultMap = true。 # 2. 编码规范 ① 数据库实体类放在 dal.dataobject 包下,以 DO 结尾;数据库访问类放在 dal.mysql 包下,以 Mapper 结尾。如下图所示: ② 数据库实体类的注释要完整,特别是哪些字段是关联(外键)、枚举、冗余等等。例如说: ③ 禁止在 Controller、Service 中,直接进行 MyBatis Plus 操作。原因是:大量 MyBatis 操作散落在 Service 中,会导致 Service 的代码越来乱,无法聚焦业务逻辑。 示例 错误 正确 并且,通过只允许将 MyBatis Plus 操作编写 Mapper 层,更好的实现 SELECT 查询的复用,而不是 Service 会存在很多相同且重复的 SELECT 查询的逻辑。 ④ Mapper 的 SELECT 查询方法的命名,采用 Spring Data 的 \"Query methods\" (opens new window) 策略,方法名使用 selectBy查询条件 规则。例如说: ⑤ 优先使用 LambdaQueryWrapper 条件构造器,使用方法获得字段名,避免手写 \"字段\" 可能写错的情况。例如说: ⑥ 简单的单表查询,优先在 Mapper 中通过 default 方法实现。例如说: # 3. CRUD 接口 BaseMapperX (opens new window) 接口,继承 MyBatis Plus 的 BaseMapper 接口,提供更强的 CRUD 操作能力。 # 3.1 selectOne #selectOne(...) (opens new window) 方法,使用指定条件,查询单条记录。示例如下: # 3.2 selectCount #selectCount(...) (opens new window) 方法,使用指定条件,查询记录的数量。示例如下: # 3.3 selectList #selectList(...) (opens new window) 方法,使用指定条件,查询多条记录。示例如下: # 3.4 selectPage 针对 MyBatis Plus 分页查询的二次分装,在 BaseMapperX (opens new window) 中实现,目的是使用项目自己的分页封装: 【入参】查询前,将项目的分页参数 PageParam (opens new window),转换成 MyBatis Plus 的 IPage 对象。 【出参】查询后,将 MyBatis Plus 的分页结果 IPage,转换成项目的分页结果 PageResult (opens new window)。代码如下图: 具体的使用示例,可见 TenantMapper (opens new window) 类中,定义 selectPage 查询方法。代码如下: @Mapperpublic interface TenantMapper extends BaseMapperX<TenantDO> { default PageResult<TenantDO> selectPage(TenantPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX<TenantDO>() .likeIfPresent(TenantDO::getName, reqVO.getName()) // 如果 name 不为空,则进行 like 查询 .likeIfPresent(TenantDO::getContactName, reqVO.getContactName()) .likeIfPresent(TenantDO::getContactMobile, reqVO.getContactMobile()) .eqIfPresent(TenantDO::getStatus, reqVO.getStatus()) // 如果 status 不为空,则进行 = 查询 .betweenIfPresent(TenantDO::getCreateTime, reqVO.getBeginCreateTime(), reqVO.getEndCreateTime()) // 如果 create 不为空,则进行 between 查询 .orderByDesc(TenantDO::getId)); // 按照 id 倒序 }} 完整实战,可见 《开发指南 —— 分页实现》 文档。 # 3.5 insertBatch #insertBatch(...) (opens new window) 方法,遍历数组,逐条插入数据库中,适合少量数据插入,或者对性能要求不高的场景。 示例如下: 为什么不使用 insertBatchSomeColumn 批量插入? 只支持 MySQL 数据库。其它 Oracle 等数据库使用会报错,可见 InsertBatchSomeColumn (opens new window) 说明。 未支持多租户。插入数据库时,多租户字段不会进行自动赋值。 # 4. 批量插入 绝大多数场景下,推荐使用 MyBatis Plus 提供的 IService 的 #saveBatch() (opens new window) 方法。示例 PermissionServiceImpl (opens new window) 如下: # 5. 条件构造器 继承 MyBatis Plus 的条件构造器,拓展了 LambdaQueryWrapperX (opens new window) 和 QueryWrapperX (opens new window) 类,主要是增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。例如说: 具体的使用示例如下: # 6. Mapper XML 默认配置下,MyBatis Mapper XML 需要写在各 yudao-module-xxx-biz 模块的 resources/mapper 目录下。示例 TestDemoMapper.xml (opens new window) 如下: 尽量避免数据库的连表(多表)查询,而是采用多次查询,Java 内存拼接的方式替代。例如说: # 7. 字段加密 EncryptTypeHandler (opens new window),基于 Hutool AES (opens new window) 实现字段的解密与解密。 例如说,数据源配置 (opens new window)的 password 密码需要实现加密存储,则只需要在该字段上添加 EncryptTypeHandler 处理器。示例代码如下: @TableName(value = "infra_data_source_config", autoResultMap = true) // ① 添加 autoResultMap = truepublic class DataSourceConfigDO extends BaseDO { // ... 省略其它字段 /** * 密码 */ @TableField(typeHandler = EncryptTypeHandler.class) // ② 添加 EncryptTypeHandler 处理器 private String password;} 另外,在 application.yaml 配置文件中,可使用 mybatis-plus.encryptor.password 设置加密密钥。 字段加密后,只允许使用精准匹配,无法使用模糊匹配。示例代码如下: @Test // 测试使用 password 查询,可以查询到数据public void testSelectPassword() { // mock 数据 DataSourceConfigDO dbDataSourceConfig = randomPojo(DataSourceConfigDO.class); dataSourceConfigMapper.insert(dbDataSourceConfig);// @Sql: 先插入出一条存在的数据 // 调用 DataSourceConfigDO result = dataSourceConfigMapper.selectOne(DataSourceConfigDO::getPassword, EncryptTypeHandler.encrypt(dbDataSourceConfig.getPassword())); // 重点:需要使用 EncryptTypeHandler 去加密查询字段!!!} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/11/12, 09:29:04 系统日志 多数据源(读写分离) ← 系统日志 多数据源(读写分离)→"},{"title":"数据库文档","path":"/wiki/YuDaoBoot/后端手册/数据库文档/数据库文档.html","content":"None"},{"title":"数据权限","path":"/wiki/YuDaoBoot/后端手册/数据权限/数据权限.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 数据权限 数据权限,实现指定用户可以操作指定范围的数据。例如说,针对员工信息的数据权限: 用户 数据范围 普通员工 自己 部门领导 所属部门的所有员工 HR 小姐姐 整个公司的所有员工 上述的这个示例,使用硬编码是可以实现的,并且也非常简单。但是,在业务快速迭代的过程中,类似这种数据需求会越来越多,如果全部采用硬编码的方式,无疑会给我们带来非常大的开发与维护成本。 因此,项目提供 yudao-spring-boot-starter-biz-data-permission (opens new window) 技术组件,只需要少量的编码,无需入侵到业务代码,即可实现数据权限。 友情提示:数据权限是否支持指定用户只能查看数据的某些字段? 不支持。权限可以分成三类:功能权限、数据权限、字段权限。 字段权限的控制,不属于数据权限,而是属于字段权限,会在未来提供,敬请期待。 # 1. 实现原理 yudao-spring-boot-starter-biz-data-permission 技术组件的实现原理非常简单,每次对数据库操作时,他会自动拼接 WHERE data_column = ? 条件来进行数据的过滤。 例如说,查看员工信息的功能,对应 SQL 是 SELECT * FROM system_users,那么拼接后的 SQL 结果会是: 用户 数据范围 SQL 普通员工 自己 SELECT * FROM system_users WHERE id = 自己 部门领导 所属部门的所有员工 SELECT * FROM system_users WHERE dept_id = 自己的部门 HR 小姐姐 整个公司的所有员工 SELECT * FROM system_users 无需拼接 明白了实现原理之后,想要进一步加入理解,后续可以找时间 Debug 调试下 DataPermissionDatabaseInterceptor (opens new window) 类的这三个方法: #processSelect(...) 方法:处理 SELECT 语句的 WHERE 条件。 #processUpdate(...) 方法:处理 UPDATE 语句的 WHERE 条件。 #processDelete(...) 方法:处理 DELETE 语句的 WHERE 条件。 # 2. 基于部门的数据权限 项目内置了基于部门的数据权限,支持 5 种数据范围: 全部数据权限:无数据权限的限制。 指定部门数据权限:根据实际需要,设置可操作的部门。 本部门数据权限:只能操作用户所在的部门。 本部门及以下数据权限:在【本部门数据权限】的基础上,额外可操作子部门。 仅本人数据权限:相对特殊,只能操作自己的数据。 # 2.1 后台配置 可通过管理后台的 [系统管理 -> 角色管理] 菜单,设置用户角色的数据权限。 实现代码? 可见 DeptDataPermissionRule (opens new window) 数据权限规则。 # 2.2 字段配置 每个 Maven Module, 通过自定义 DeptDataPermissionRuleCustomizer (opens new window) Bean,配置哪些表的哪些字段,进行数据权限的过滤。以 yudao-module-system 模块来举例子,代码如下: @Configuration(proxyBeanMethods = false)public class DataPermissionConfiguration { @Bean public DeptDataPermissionRuleCustomizer sysDeptDataPermissionRuleCustomizer() { return rule -> { // dept 基于部门的数据权限 rule.addDeptColumn(AdminUserDO.class); // WHERE dept_id = ? rule.addDeptColumn(DeptDO.class, "id"); // WHERE id = ? // user 基于用户的数据权限 rule.addUserColumn(AdminUserDO.class, "id"); // WHERE id = ?// rule.addUserColumn(OrderDO.class); // WHERE user_id = ? }; }} 注意,数据库的表字段必须添加: 基于【部门】过滤数据权限的表,需要添加部门编号字段,例如说 dept_id 字段。 基于【用户】过滤数据权限的表,需要添加部门用户字段,例如说 user_id 字段。 # 3. @DataPermission 注解 @DataPermission (opens new window) 数据权限注解,可声明在类或者方法上,配置使用的数据权限规则。 ① enable 属性:当前类或方法是否开启数据权限,默认是 true 开启状态,可设置 false 禁用状态。 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 使用示例如下,可见 UserProfileController (opens new window) 类: // UserProfileController.java@GetMapping("/get")@Operation(summary = "获得登录用户信息")@DataPermission(enable = false) // 关闭数据权限,避免只查看自己时,查询不到部门。public CommonResult<UserProfileRespVO> profile() { // .. 省略代码 if (user.getDeptId() != null) { DeptDO dept = deptService.getDept(user.getDeptId()); resp.setDept(UserConvert.INSTANCE.convert02(dept)); } // .. 省略代码} ② includeRules 属性,配置生效的 DataPermissionRule (opens new window) 数据权限规则。例如说,项目里有 10 种 DataPermissionRule 规则,某个方法只想其中的 1 种生效,则可以使用该属性。 ③ excludeRules 属性,配置排除的 DataPermissionRule (opens new window) 数据权限规则。例如说,项目里有 10 种 DataPermissionRule 规则,某个方法不想其中的 1 种生效,则可以使用该属性。 # 4. 自定义的数据权限规则 如果想要自定义数据权限规则,只需要实现 DataPermissionRule (opens new window) 数据权限规则接口,并声明成 Spring Bean 即可。需要实现的只有两个方法: public interface DataPermissionRule { /** * 返回需要生效的表名数组 * 为什么需要该方法?Data Permission 数组基于 SQL 重写,通过 Where 返回只有权限的数据 * * 如果需要基于实体名获得表名,可调用 {@link TableInfoHelper#getTableInfo(Class)} 获得 * * @return 表名数组 */ Set<String> getTableNames(); /** * 根据表名和别名,生成对应的 WHERE / OR 过滤条件 * * @param tableName 表名 * @param tableAlias 别名,可能为空 * @return 过滤条件 Expression 表达式 */ Expression getExpression(String tableName, Alias tableAlias);} #getTableNames() 方法:哪些数据库表,需要使用该数据权限规则。 #getExpression(...) 方法:当操作这些数据库表,需要额外拼接怎么样的 WHERE 条件。 下面,艿艿带你写个自定义数据权限规则的示例,它的数据权限规则是: 针对 system_dict_type 表,它的创建人 creator 要是当前用户。 针对 system_post 表,它的更新人 updater 要是当前用户。 具体实现代码如下: package cn.iocoder.yudao.module.system.framework.datapermission;import cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRule;import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;import com.google.common.collect.Sets;import net.sf.jsqlparser.expression.Alias;import net.sf.jsqlparser.expression.Expression;import net.sf.jsqlparser.expression.LongValue;import net.sf.jsqlparser.expression.operators.relational.EqualsTo;import org.springframework.stereotype.Component;import java.util.Set;@Component // 声明为 Spring Bean,保证被 yudao-spring-boot-starter-biz-data-permission 组件扫描到public class DemoDataPermissionRule implements DataPermissionRule { @Override public Set<String> getTableNames() { return Sets.newHashSet("system_dict_type", "system_post"); } @Override public Expression getExpression(String tableName, Alias tableAlias) { Long userId = SecurityFrameworkUtils.getLoginUserId(); assert userId != null; switch (tableName) { case "system_dict_type": return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, "creator"), new LongValue(userId)); case "system_post": return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, "updater"), new LongValue(userId)); default: return null; } }} ① 启动前端 + 后端项目。 ② 访问 [系统管理 -> 字典管理] 菜单,查看 IDEA 控制台,可以看到 system_dict_type 表的查询自动拼接了 AND creator = 1 的查询条件。 ② 访问 [系统管理 -> 岗位管理] 菜单,查看 IDEA 控制台,可以看到 system_post 表的查询自动拼接了 AND updater = 1 的查询条件。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:40 功能权限 用户体系 ← 功能权限 用户体系→"},{"title":"数据脱敏","path":"/wiki/YuDaoBoot/后端手册/数据脱敏/数据脱敏.html","content":"开发指南后端手册 芋道源码 2023-01-21 目录 数据脱敏 接口在返回一些敏感或隐私数据时,是需要进行脱敏处理,通常的手段是使用 * 隐藏一部分数据。例如说: 类型 原始数据 脱敏数据 手机 13248765917 132****5917 身份证 530321199204074611 530321**********11 银行卡 9988002866797031 998800********31 # 1. 脱敏组件 yudao-spring-boot-starter-desensitize (opens new window) 基于 Jackson 拓展,只需要在字段上添加脱敏注解,即可实现对该字段进行脱敏。 使用步骤如下: ① 在 pom.xml 引入该依赖,如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-desensitize</artifactId></dependency> ② 在字段上添加脱敏注解。如下所示: @Datapublic static class DesensitizeDemo { @MobileDesensitize // 手机号的脱敏注解 private String phoneNumber;} # 2. 内置脱敏注解 根据不同的脱敏处理方式,项目内置了两类脱敏注解:正则脱敏、滑块脱敏。 # 2.1 regex 正则脱敏 # 2.1.1 @RegexDesensitize 注解 正则脱敏注解 @RegexDesensitize ( opens new window):根据正则表达式,将原始数据进行替换处理。 public @interface RegexDesensitize { /** * 匹配的正则表达式(默认匹配所有) */ String regex() default "^[\\\\s\\\\S]*$"; /** * 替换规则,会将匹配到的字符串全部替换成 replacer */ String replacer() default "******";} 例如说:regex=123; replacer=****** 表示将 123 替换为 ****** 原始字符串 123456789 脱敏后字符串 ******456789 # 2.1.2 其它正则脱敏注解 项目内置了其它基于正则脱敏的常用注解,无需手动填写 regex、replacer 属性,更加方便。例如说: @Datapublic static class DesensitizeDemo { @EmailDesensitize private String email;} 所有注解如下: 注解 原始数据 脱敏数据 @EmailDesensitize (opens new window) example@gmail.com e****@gmail.com # 2.2 slider 滑块脱敏 # 2.2.1 @SliderDesensitize 注解 滑块脱敏注解 @SliderDesensitize (opens new window):根据设置的左右明文字符长度,中间部分全部替换为 *。 例如说:prefixKeep=3; suffixKeep=4; replacer=* 表示前 3 后 4 保持明文,中间都替换成 * 原始字符串 13248765917 脱敏后字符串 132****5917 # 2.2.2 其它滑块脱敏注解 项目内置了其它基于滑块脱敏的常用注解,无需手动填写 prefixKeep、suffixKeep、replacer 属性,更加方便。例如说: @Datapublic static class DesensitizeDemo { @MobileDesensitize private String mobile;} 所有注解如下: 注解 原始数据 脱敏数据 @MobileDesensitize (opens new window) 13248765917 132****5917 @FixedPhoneDesensitize (opens new window) 01086551122 0108*****22 @BankCardDesensitize (opens new window) 9988002866797031 998800********31 @PasswordDesensitize (opens new window) 123456 ****** @CarLicenseDesensitize (opens new window) 粤A66666 粤A6***6 @ChineseNameDesensitize (opens new window) 刘子豪 刘** @IdCardDesensitize (opens new window) 530321199204074611 530321**********11 # 3. 自定义脱敏注解 如果内置的注解无法满足你的需求,只需要自定义一个脱敏注解,并实现它的脱敏处理器即可。 例如说,我们要实现一个新的脱敏处理方法,将编号使用 MD5 或 SHA256 计算后返回。步骤如下: ① 创建 @DigestDesensitize 注解,使用 @DesensitizeBy (opens new window) 标记它使用的处理器。代码如下: import cn.iocoder.yudao.framework.desensitize.core.base.annotation.DesensitizeBy;import cn.iocoder.yudao.framework.desensitize.core.handler.DigestHandler;import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;import java.lang.annotation.*;@Documented@Target({ElementType.FIELD})@Retention(RetentionPolicy.RUNTIME)@JacksonAnnotationsInside@DesensitizeBy(handler = DigestHandler.class) // 使用 @DesensitizeBy 设置它的处理器public @interface DigestDesensitize { /** * 摘要算法,例如说:MD5、SHA256 */ String algorithm() default "md5";} ② 创建 DigestHandler 类,实现 DigestHandler (opens new window) 接口,将编号使用 MD5 或 SHA256 处理。代码如下: import cn.hutool.crypto.digest.DigestUtil;import cn.iocoder.yudao.framework.desensitize.core.annotation.DigestDesensitize;import cn.iocoder.yudao.framework.desensitize.core.base.handler.DesensitizationHandler;public class DigestHandler implements DesensitizationHandler<DigestDesensitize> { @Override public String desensitize(String origin, DigestDesensitize annotation) { String algorithm = annotation.algorithm(); return DigestUtil.digester(algorithm).digestHex(origin); }} 友情提示: ① 如果自定义的是基于正则脱敏的注解,可选择继承 AbstractRegexDesensitizationHandler (opens new window) 处理器。 ① 如果自定义的是基于滑块脱敏的注解,可选择继承 AbstractSliderDesensitizationHandler (opens new window) 处理器。 ③ 在需要使用的字段上,添加 @DigestDesensitize 注解。示例代码如下: @Datapublic static class DesensitizeDemo { @DigestDesensitize private String email;} 完事~ # 4. 脱敏工具类 Hutool 提供了 DesensitizedUtil (opens new window) 脱敏工具类,支持用户 ID、 中文名、身份证、座机号、手机号、 地址、电子邮件、 密码、车牌、银行卡号的脱敏处理。 使用方式,代码如下: DesensitizedUtil.desensitized("100", DesensitizedUtils.DesensitizedType.USER_ID)) = "0"DesensitizedUtil.desensitized("段正淳", DesensitizedUtils.DesensitizedType.CHINESE_NAME)) = "段**"DesensitizedUtil.desensitized("51343620000320711X", DesensitizedUtils.DesensitizedType.ID_CARD)) = "5***************1X"DesensitizedUtil.desensitized("09157518479", DesensitizedUtils.DesensitizedType.FIXED_PHONE)) = "0915*****79"DesensitizedUtil.desensitized("18049531999", DesensitizedUtils.DesensitizedType.MOBILE_PHONE)) = "180****1999"DesensitizedUtil.desensitized("北京市海淀区马连洼街道289号", DesensitizedUtils.DesensitizedType.ADDRESS)) = "北京市海淀区马********"DesensitizedUtil.desensitized("duandazhi-jack@gmail.com.cn", DesensitizedUtils.DesensitizedType.EMAIL)) = "d*************@gmail.com.cn"DesensitizedUtil.desensitized("1234567890", DesensitizedUtils.DesensitizedType.PASSWORD)) = "**********"DesensitizedUtil.desensitized("苏D40000", DesensitizedUtils.DesensitizedType.CAR_LICENSE)) = "苏D4***0"DesensitizedUtil.desensitized("11011111222233333256", DesensitizedUtils.DesensitizedType.BANK_CARD)) = "1101 **** **** **** 3256" 适合场景,逻辑里需要直接对某个变量进行脱敏处理,然后打印 logger 日志,或者存储到数据库中。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/26, 21:09:52 站内信配置 敏感词 ← 站内信配置 敏感词→"},{"title":"文件存储(上传下载)","path":"/wiki/YuDaoBoot/后端手册/文件存储(上传下载)/文件存储(上传下载).html","content":"开发指南后端手册 芋道源码 2022-03-17 目录 文件存储(上传下载) 项目支持将文件上传到三类存储器: 兼容 S3 协议的对象存储:支持 MinIO、腾讯云 COS、七牛云 Kodo、华为云 OBS、亚马逊 S3 等等。 磁盘存储:本地、FTP 服务器、SFTP 服务器。 数据库存储:MySQL、Oracle、PostgreSQL、SQL Server 等等。 技术选型? 优先,✔ 推荐方案 1。如果无法使用云服务,可以自己搭建一个 MinIO 服务。参见 《芋道 Spring Boot 对象存储 MinIO 入门 》 (opens new window) 文章。 其次,推荐方案 3。数据库的主从机制可以实现高可用,备份也方便,少量小文件问题不大。 最后,× 不推荐方案 2。主要是实现高可用比较困难,无法实现故障转移。 # 1. 快速入门 本小节,我们来添加个文件配置,并使用它上传下载文件。 # 1.1 新增配置 ① 打开 [基础设施 -> 文件管理 -> 文件配置] 菜单,进入文件配置的界面。 ② 点击 [新增] 按钮,选择存储器为【S3 对象存储器】,并填写七牛云的配置。如下图: 节点地址:s3-cn-south-1.qiniucs.com 存储 bucket:ruoyi-vue-pro accessKey:b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8 accessSecret:kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP 自定义域名:http://test.yudao.iocoder.cn 友善的眼神! 上述七牛云的配置,是艿艿为了大家方便体验,请勿在测试或生产环境体验。 ③ 添加完后,点击该配置所在行的 [测试] 按钮,测试配置是否正确。 ④ 测试通过后,点击该配置所在行的 [主配置] 按钮,设置它为默认的配置,后续使用它进行文件的上传。 # 1.2 上传文件 ① 点击 [基础设施 -> 文件管理 -> 文件列表] 菜单,进入文件列表的界面。 ② 点击 [上传文件] 按钮,选择要上传的文件。 ③ 上传完成后,如果想要删除,可点击该文件所在行的 [删除] 按钮。 # 2. 文件上传 项目提供了 2 种文件上传的方式,分别适合前端、后端使用。 # 2.1 方式一:前端上传 FileController (opens new window) 提供了 /admin-api/infra/file/upload RESTful API,用于前端直接上传文件。 // FileController.java@PostMapping("/upload")@Operation(summary = "上传文件")@OperateLog(logArgs = false) // 上传文件,没有记录操作日志的必要public CommonResult<String> uploadFile(FileUploadReqVO uploadReqVO) throws Exception { MultipartFile file = uploadReqVO.getFile(); String path = uploadReqVO.getPath(); return success(fileService.createFile(file.getOriginalFilename(), path, IoUtil.readBytes(file.getInputStream())));} 前端上传文件的代码如何实现,可见: 文件列表,文件上传 index.vue (opens new window) 个人中心,头像修改 userAvatar.vue (opens new window) # 2.2 方式二:后端上传 yudao-module-infra 的 FileApi (opens new window) 提供了 #createFile(...) 方法,用于后端需要上传文件的逻辑。 // FileApi.java/** * 保存文件,并返回文件的访问路径 * * @param path 文件路径 * @param content 文件内容 * @return 文件路径 */String createFile(String path, byte[] content); 例如说,个人中心修改头像时,需要进行头像的上传。如下图所示: 注意,需要使用到后端上传的 Maven 模块,需要引入 yudao-module-infra-api 依赖。例如说 yudao-module-system-biz 模块的 pom.xml 文件,引用如下: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-infra-api</artifactId> <version>${revision}</version></dependency> # 3. 文件下载 文件上传成功后,返回的是完整的 URL 访问路径 ,例如说 http://test.yudao.iocoder.cn/822aebded6e6414e912534c6091771a4.jpg ( opens new window) 。 不同的文件存储器,返回的 URL 路径的规则是不同的: ① 当存储器是【S3 对象存储】时,支持 HTTP 访问,所以直接使用 S3 对象存储返回的 URL 路径即可。 ② 当存储器是【数据库】【本地磁盘】等时,它们只支持存储,所以需要 FileController ( opens new window) 提供的 /admin-api/infra/file/{configId}/get/{path} RESTful API,读取文件内容后返回。 // FileController.java@GetMapping("/{configId}/get/**")@PermitAll@Operation(summary = "下载文件")@Parameter(name = "configId", description = "配置编号", required = true)public void getFileContent(HttpServletRequest request, HttpServletResponse response, @PathVariable("configId") Long configId) throws Exception { // 获取请求的路径 String path = StrUtil.subAfter(request.getRequestURI(), "/get/", false); if (StrUtil.isEmpty(path)) { throw new IllegalArgumentException("结尾的 path 路径必须传递"); } // 读取内容 byte[] content = fileService.getFileContent(configId, path); if (content == null) { log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path); response.setStatus(HttpStatus.NOT_FOUND.value()); return; } ServletUtils.writeAttachment(response, path, content);} # 4. 文件客户端 技术组件 yudao-spring-boot-starter-file ( opens new window) ,定义了 FileClient ( opens new window) 接口,抽象了文件客户端的方法。 public interface FileClient { /** * 获得客户端编号 * * @return 客户端编号 */ Long getId(); /** * 上传文件 * * @param content 文件流 * @param path 相对路径 * @return 完整路径,即 HTTP 访问地址 */ String upload(byte[] content, String path); /** * 删除文件 * * @param path 相对路径 */ void delete(String path); /** * 获得文件的内容 * * @param path 相对路径 * @return 文件的内容 */ byte[] getContent(String path);} FileClient 有 5 个实现类,使用不同存储器进行文件的上传与下载。UML 类图如所示: 文件上传的调用的 UML 时序图如下所示: # 5. S3 对象存储的配置 做的不错的云存储服务,都是兼容 S3 协议的。如何获取对应的 S3 配置,艿艿整理到了 S3FileClientConfig (opens new window) 配置类。 有一点要注意,云存储服务的 Bucket 需要设置为公共读,不然 URL 无法访问到文件。 并且,最好使用自定义域名,方便迁移到不同的云存储服务。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:51:54 分页实现 Excel 导入导出 ← 分页实现 Excel 导入导出→"},{"title":"新建模块","path":"/wiki/YuDaoBoot/后端手册/新建模块/新建模块.html","content":"开发指南后端手册 芋道源码 2022-03-02 目录 新建模块 本章节,将介绍如何新建名字为 yudao-module-demo 的示例模块,并添加 RESTful API 接口。 虽然内容看起来比较长,是因为艿艿写的比较详细,大量截图,保姆级教程!其实只有五个步骤,保持耐心,跟着艿艿一点点来。🙂 完成之后,你会对整个 项目结构 有更充分的了解。 # 👍 相关视频教程 从零开始 06:如何 5 分钟,创建一个新模块? (opens new window) # 1. 新建 demo 模块 ① 选择 File -> New -> Module 菜单,如下图所示: ② 选择 Maven 类型,并点击 Next 按钮,如下图所示: ③ 选择父模块为 yudao,输入名字为 yudao-module-demo,并点击 Finish 按钮,如下图所示: ④ 打开 yudao-module-demo 模块,删除 src 文件,如下图所示: ⑤ 打开 yudao-module-demo 模块的 pom.xml 文件,修改内容如下: 提示 <!-- --> 部分,只是注释,不需要写到 XML 中。 <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao</artifactId> <groupId>cn.iocoder.boot</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>yudao-module-demo</artifactId> <packaging>pom</packaging> <!-- 2. 新增 packaging 为 pom --> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块,主要实现 XXX、YYY、ZZZ 等功能。 </description></project> # 2. 新建 demo-api 子模块 ① 新建 yudao-module-demo-api 子模块,整个过程和“新建 demo 模块”是一致的,如下图所示: ② 打开 yudao-module-demo-api 模块的 pom.xml 文件,修改内容如下: <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao-module-demo</artifactId> <groupId>cn.iocoder.boot</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>yudao-module-demo-api</artifactId> <packaging>jar</packaging> <!-- 2. 新增 packaging 为 jar --> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块 API,暴露给其它模块调用 </description> <dependencies> <!-- 5. 新增 yudao-common 依赖 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-common</artifactId> </dependency> </dependencies></project> ③ 【可选】新建 cn.iocoder.yudao.module.demo 基础包,其中 demo 为模块名。之后,新建 api 和 enums 包。如下图所示: # 3. 新建 demo-biz 子模块 ① 新建 yudao-module-demo-biz 子模块,整个过程和“新建 demo 模块”也是一致的,如下图所示: ② 打开 yudao-module-demo-biz 模块的 pom.xml 文件,修改成内容如下: <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao-module-demo</artifactId> <groupId>cn.iocoder.boot</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <packaging>jar</packaging> <!-- 2. 新增 packaging 为 jar --> <artifactId>yudao-module-demo-biz</artifactId> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块,主要实现 XXX、YYY、ZZZ 等功能。 </description> <dependencies> <!-- 5. 新增依赖,这里引入的都是比较常用的业务组件、技术组件 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-demo-api</artifactId> <version>${revision}</version> </dependency> <!-- 业务组件 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-biz-operatelog</artifactId> </dependency> <!-- Web 相关 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-security</artifactId> </dependency> <!-- DB 相关 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-mybatis</artifactId> </dependency> <!-- Test 测试相关 --> <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-test</artifactId> </dependency> </dependencies></project> ③ 【必选】新建 cn.iocoder.yudao.module.demo 基础包,其中 demo 为模块名。之后,新建 controller.admin 和 controller.user 等包。如下图所示: ④ 打开 Maven 菜单,点击刷新按钮,让引入的 Maven 依赖生效。如下图所示: # 4. 新建 RESTful API 接口 ① 在 controller.admin 包,新建一个 DemoTestController 类,并新建一个 /demo/test/get 接口。代码如下: package cn.iocoder.yudao.module.demo.controller.admin;import cn.iocoder.yudao.framework.common.pojo.CommonResult;import io.swagger.annotations.Api;import io.swagger.annotations.ApiOperation;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;@Tag(name = "管理后台 - Test")@RestController@RequestMapping("/demo/test")@Validatedpublic class DemoTestController { @GetMapping("/get") @Operation(summary = "获取 test 信息") public CommonResult<String> get() { return success("true"); }} 注意,/demo 是该模块所有 RESTful API 的基础路径,/test 是 Test 功能的基础路径。 ① 在 controller.app 包,新建一个 AppDemoTestController 类,并新建一个 /demo/test/get 接口。代码如下: package cn.iocoder.yudao.module.demo.controller.app;import cn.iocoder.yudao.framework.common.pojo.CommonResult;import io.swagger.annotations.Api;import io.swagger.annotations.ApiOperation;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;@Tag(name = "用户 App - Test")@RestController@RequestMapping("/demo/test")@Validatedpublic class AppDemoTestController { @GetMapping("/get") @Operation(summary = "获取 test 信息") public CommonResult<String> get() { return success("true"); }} 在 Controller 的命名上,额外增加 App 作为前缀,一方面区分是管理后台还是用户 App 的 Controller,另一方面避免 Spring Bean 的名字冲突。 可能你会奇怪,这里我们定义了两个 /demo/test/get 接口,会不会存在重复导致冲突呢?答案,当然是并不会。原因是: controller.admin 包下的接口,默认会增加 /admin-api,即最终的访问地址是 /admin-api/demo/test/get controller.app 包下的接口,默认会增加 /app-api,即最终的访问地址是 /app-api/demo/test/get # 5. 引入 demo 模块 ① 在 yudao-server 模块的 pom.xml 文件,引入 yudao-module-demo-biz 子模块,并点击 Maven 刷新。如下图所示: ② 运行 YudaoServerApplication 类,将后端项目进行启动。启动完成后,使用浏览器打开 http://127.0.0.1:48080/doc.html (opens new window) 地址,进入 Swagger 接口文档。 ③ 打开“管理后台 - Test”接口,进行 /admin-api/demo/test/get 接口的调试,如下图所示: ④ 打开“用户 App - Test”接口,进行 /app-api/demo/test/get 接口的调试,如下图所示: # 6. 访问接口返回 404? 请检查,你新建的模块的 package 包名是不是在 cn.iocoder.yudao.module 下! 如果不是,修改 YudaoServerApplication (opens new window) 类,增加新建的模块的 package 包名。例如说: @SpringBootApplication(scanBasePackages = {"${yudao.info.base-package}.server", "${yudao.info.base-package}.module", "xxx.yyy.zzz"}) // xxx.yyy.zzz 是你新建的模块的 `package` 包名 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:40 删除功能 代码生成(新增功能) ← 删除功能 代码生成(新增功能)→"},{"title":"本地缓存","path":"/wiki/YuDaoBoot/后端手册/本地缓存/本地缓存.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 本地缓存 重要说明: ① 由于大家普遍反馈,“本地缓存”学习成本太高,一般 Redis 缓存足够满足大多数场景的性能要求,所以基本使用 Spring Cache + Redis 所替代。 也因此,本章节更多的,是讲解如何在项目中使用本地缓存。如果你不需要本地缓存,可以忽略本章节。 ② 项目中还保留了部分地方使用本地缓存,例如说:短信客户端、文件客户端、敏感词等。主要原因是,它们是“有状态”的 Java 对象,无法缓存到 Redis 中。 系统使用本地缓存,提升公用逻辑的执行性能。 例如说: 租户模块 (opens new window) 缓存租户信息,每次 RESTful API 校验租户是否禁用、过期时,无需读库。 部门模块 (opens new window) 缓存部门信息,每次数据权限校验时,无需读库。 权限模块 (opens new window) 缓存权限信息,每次功能权限校验时,无需读库。 # 1. 实现原理 本地缓存的实现,一共有两步,如下图所示: 项目启动时,初始化缓存:从数据库中读取数据,写入到本地缓存(例如说一个 Map 对象) 数据变化时,实时刷新缓存:(例如说通过管理后台修改数据)重新从数据库中读取数据,重新写入到本地缓存 # 2. 实战案例 以 角色模块 (opens new window) 为例,讲解如何实现角色信息的本地缓存。 # 2.1 初始化缓存 ① 在 RoleService (opens new window) 接口中,定义 #initLocalCache() 方法。代码如下: // RoleService.java/** * 初始化角色的本地缓存 */void initLocalCache(); 为什么要定义接口方法? 稍后实时刷新缓存时,会调用 RoleService 接口的该方法。 ② 在 RoleServiceImpl (opens new window) 类中,实现 #initLocalCache() 方法,通过 @PostConstruct 注解,在项目启动时进行本地缓存的初始化。代码如下: // RoleServiceImpl.java/** * 角色缓存 * key:角色编号 {@link RoleDO#getId()} * * 这里声明 volatile 修饰的原因是,每次刷新时,直接修改指向 */@Getterprivate volatile Map<Long, RoleDO> roleCache;/** * 初始化 {@link #roleCache} 缓存 */@Override@PostConstructpublic void initLocalCache() { // 注意:忽略自动多租户,因为要全局初始化缓存 TenantUtils.executeIgnore(() -> { // 第一步:查询数据 List<RoleDO> roleList = roleMapper.selectList(); log.info("[initLocalCache][缓存角色,数量为:{}]", roleList.size()); // 第二步:构建缓存 roleCache = CollectionUtils.convertMap(roleList, RoleDO::getId); });} 疑问:为什么使用 TenantUtils 的 executeIgnore 方法来执行逻辑? 由于 RoleDO 是多租户隔离,如果使用 TenantUtils 方法,会导致缓存刷新时,只加载某个租户的角色数据,导致本地缓存的错误。 所以,如果缓存的数据不存在多租户隔离的情况,可以不使用 TenantUtils 方法!!!! # 2.2 实时刷新缓存 为什么需要使用 Redis Pub/Sub 来实时刷新缓存?考虑到高可用,线上会部署多个 JVM 实例,需要通过 Redis 广播到所有实例,实现本地缓存的刷新。 友情提示: Redis Pub/Sub 的使用与讲解,可见 《开发指南 —— 消息队列》 文档。 # 2.2.1 RoleRefreshMessage 新建 RoleRefreshMessage (opens new window) 类,角色数据刷新 Message。代码如下: @Data@EqualsAndHashCode(callSuper = true)public class RoleRefreshMessage extends AbstractChannelMessage { @Override public String getChannel() { return "system.role.refresh"; }} # 2.2.2 RoleProducer ① 新建 RoleProducer ( opens new window) 类,RoleRefreshMessage 的 Producer 生产者。代码如下: @Componentpublic class RoleProducer { @Resource private RedisMQTemplate redisMQTemplate; /** * 发送 {@link RoleRefreshMessage} 消息 */ public void sendRoleRefreshMessage() { RoleRefreshMessage message = new RoleRefreshMessage(); redisMQTemplate.send(message); }} ② 在数据的新增 / 修改 / 删除等写入操作时,需要使用 RoleProducer 发送消息。如下图所示: # 2.2.3 RoleRefreshConsumer 新建 RoleRefreshConsumer (opens new window) 类,RoleRefreshMessage 的 Consumer 消费者,刷新本地缓存。代码如下: @Component@Slf4jpublic class RoleRefreshConsumer extends AbstractChannelMessageListener<RoleRefreshMessage> { @Resource private RoleService roleService; @Override public void onMessage(RoleRefreshMessage message) { log.info("[onMessage][收到 Role 刷新消息]"); roleService.initLocalCache(); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/03, 22:14:22 Redis 缓存 定时任务 ← Redis 缓存 定时任务→"},{"title":"消息队列","path":"/wiki/YuDaoBoot/后端手册/消息队列/消息队列.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 消息队列 yudao-spring-boot-starter-mq (opens new window) 技术组件,基于 Redis 实现分布式消息队列: 使用 Stream (opens new window) 特性,提供【集群】消费的能力。 使用 Pub/Sub (opens new window) 特性,提供【广播】消费的能力。 友情提示: 考虑到有部分同学对分布式消息队列了解的不多,所以在下文的广播消费、集群消费的描述,去除【消费者分组】的概念。如果你对这块感兴趣,可以看看艿艿写的系列文章: 《芋道 Spring Boot 消息队列 RocketMQ 入门》 (opens new window) 对应 lab-31 (opens new window) 《芋道 Spring Boot 消息队列 Kafka 入门》 (opens new window) 对应 lab-03-kafka (opens new window) 《芋道 Spring Boot 消息队列 RabbitMQ 入门》 (opens new window) 对应 lab-04-rabbitmq (opens new window) 《芋道 Spring Boot 消息队列 ActiveMQ 入门》 (opens new window) 对应 lab-32 (opens new window) # 1. 集群消费 集群消费,是指消息发送到 Redis 时,有且只会被一个消费者(应用 JVM 实例)收到,然后消费成功。如下图所示: # 1.1 使用场景 集群消费在项目中的使用场景,主要是提供可靠的、可堆积的异步任务的能力。例如说: 短信模块,使用它异步 (opens new window)发送短信。 邮件模块,使用它异步 (opens new window)发送邮件。 相比 《开发指南 —— 异步任务》 来说,Spring Async 在 JVM 实例重启时,会导致未执行完的任务丢失。而集群消费,因为消息是存储在 Redis 中,所以不会存在该问题。 # 1.2 实现源码 集群消费基于 Redis Stream 实现: 实现 AbstractStreamMessage (opens new window) 抽象类,定义【集群】消息。 使用 RedisMQTemplate (opens new window) 的 #send(message) (opens new window) 方法,发送消息。 实现 AbstractStreamMessageListener (opens new window) 接口,消费消息。 最终使用 YudaoMQAutoConfiguration (opens new window) 配置类,扫描所有的 AbstractStreamMessageListener 监听器,初始化对应的消费者。如下图所示: # 1.3 实战案例 以短信模块异步发送短息为例子,讲解集群消费的使用。 # 1.3.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-mq 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-mq</artifactId></dependency> # 1.3.2 SmsSendMessage 在 yudao-module-system-biz 的 mq/message/sms ( opens new window) 包下,创建 SmsSendMessage ( opens new window) 类,继承 AbstractStreamMessage 抽象类,短信发送消息。代码如下图: # 1.3.3 SmsProducer ① 在 yudao-module-system-biz 的 mq/producer/sms ( opens new window) 包下,创建 SmsProducer ( opens new window) 类,SmsSendMessage 的 Producer 生产者,核心是使用 RedisMQTemplate 发送 SmsSendMessage 消息。代码如下图: ② 发送短信时,需要使用 SmsProducer 发送消息。如下图所示: # 1.3.4 SmsSendConsumer 在 yudao-module-system-biz 的 mq/consumer/sms ( opens new window) 包下,创建 SmsSendConsumer ( opens new window) 类,SmsSendMessage 的 Consumer 消费者。代码如下图: # 2. 广播消费 广播消费,是指消息发送到 Redis 时,所有消费者(应用 JVM 实例)收到,然后消费成功。如下图所示: # 2.1 使用场景 例如说,在应用中,缓存了数据字典等配置表在内存中,可以通过 Redis 广播消费,实现每个应用节点都消费消息,刷新本地内存的缓存。 又例如说,我们基于 WebSocket 实现了 IM 聊天,在我们给用户主动发送消息时,因为我们不知道用户连接的是哪个提供 WebSocket 的应用,所以可以通过 Redis 广播消费。每个应用判断当前用户是否是和自己提供的 WebSocket 服务连接,如果是,则推送消息给用户。 # 2.2 实现源码 广播消费基于 Redis Pub/Sub 实现: 实现 AbstractChannelMessage ( opens new window) 抽象类,定义【广播】消息。 使用 RedisMQTemplate ( opens new window) 的 #send( message) ( opens new window) 方法,发送消息。 实现 AbstractChannelMessageListener ( opens new window) 接口,消费消息。 最终使用 YudaoMQAutoConfiguration ( opens new window) 配置类,扫描所有的 AbstractChannelMessageListener 监听器,初始化对应的消费者。如下图所示: # 2.3 实战案例 参见 《开发指南 —— 本地缓存》 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/04/22, 00:36:05 异步任务 配置管理 ← 异步任务 配置管理→"},{"title":"用户体系","path":"/wiki/YuDaoBoot/后端手册/用户体系/用户体系.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 用户体系 系统提供了 2 种类型的用户,分别满足对应的管理后台、用户 App 场景。 AdminUser 管理员用户,前端访问 yudao-ui-admin (opens new window) 管理后台,后端访问 /admin-api/** RESTful API 接口。 MemberUser 会员用户,前端访问 yudao-ui-user (opens new window) 用户 App,后端访问 /app-api/** RESTful API 接口。 虽然是不同类型的用户,他们访问 RESTful API 接口时,都通过 Token 认证机制,具体可见 《开发指南 —— 功能权限》。 # 1. 表结构 2 种类型的时候,采用不同数据库的表进行存储,管理员用户对应 system_users (opens new window) 表,会员用户对应 member_user (opens new window) 表。如下图所示: 为什么不使用统一的用户表? 确实可以采用这样的方案,新增 type 字段区分用户类型。不同用户类型的信息字段,例如说上图的 dept_id、post_ids 等等,可以增加拓展表,或者就干脆“冗余”在用户表中。 不过实际项目中,不同类型的用户往往是不同的团队维护,并且这也是绝大多团队的实践,所以我们采用了多个用户表的方案。 如果表需要关联多种类型的用户,例如说上述的 system_oauth2_access_token 访问令牌表,可以通过 user_type 字段进行区分。并且 user_type 对应 UserTypeEnum (opens new window) 全局枚举,代码如下: # 2. 如何获取当前登录的用户? 使用 SecurityFrameworkUtils (opens new window) 提供的如下方法,可以获得当前登录用户的信息: /** * 【最常用】获得当前用户的编号,从上下文中 * * @return 用户编号 */@Nullablepublic static Long getLoginUserId() { /** 省略实现 */ }/** * 获取当前用户 * * @return 当前用户 */@Nullablepublic static LoginUser getLoginUser() { /** 省略实现 */ }/** * 获得当前用户的角色编号数组 * * @return 角色编号数组 */@Nullablepublic static Set<Long> getLoginUserRoleIds() { /** 省略实现 */ } # 3. 账号密码登录 # 3.1 管理后台的实现 使用 username 账号 + password 密码进行登录,由 AuthController ( opens new window) 提供 /admin-api/system/auth/login 接口。代码如下: @PostMapping("/login")@Operation(summary = "使用账号密码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) { String token = authService.login(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} 如何关闭验证码? 参见 《后端手册 —— 验证码》 文档。 # 3.2 用户 App 的实现 使用 mobile 手机 + password 密码进行登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/login 接口。代码如下: @PostMapping("/login")@Operation(summary = "使用手机 + 密码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> login(@RequestBody @Valid AppAuthLoginReqVO reqVO) { String token = authService.login(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AppAuthLoginRespVO.builder().token(token).build());} # 4. 手机验证码登录 # 4.1 管理后台的实现 ① 使用 mobile 手机号获得验证码,由 AuthController ( opens new window) 提供 /admin-api/system/auth/send-sms-code 接口。代码如下: @PostMapping("/send-sms-code")@Operation(summary = "发送手机验证码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<Boolean> sendSmsCode(@RequestBody @Valid AuthSendSmsReqVO reqVO) { authService.sendSmsCode(getLoginUserId(), reqVO); return success(true);} ② 使用 mobile 手机 + code 验证码进行登录,由 AppAuthController (opens new window) 提供 /admin-api/system/auth/sms-login 接口。代码如下: @PostMapping("/sms-login")@Operation(summary = "使用短信验证码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) { String token = authService.smsLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} # 4.2 用户 App 的实现 ① 使用 mobile 手机号获得验证码,由 AppAuthController ( opens new window) 提供 /app-api/member/auth/send-sms-code 接口。代码如下: @PostMapping("/send-sms-code")@Operation(summary = "发送手机验证码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<Boolean> sendSmsCode(@RequestBody @Valid AppAuthSendSmsReqVO reqVO) { authService.sendSmsCode(getLoginUserId(), reqVO); return success(true);} ② 使用 mobile 手机 + code 验证码进行登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/sms-login 接口。代码如下: @PostMapping("/sms-login")@Operation(summary = "使用手机 + 验证码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> smsLogin(@RequestBody @Valid AppAuthSmsLoginReqVO reqVO) { String token = authService.smsLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AppAuthLoginRespVO.builder().token(token).build());} 如果用户未注册,会自动使用手机号进行注册会员用户。所以,/app-api/member/user/sms-login 接口也提供了用户注册的功能。 # 5. 三方登录 详细参见 《开发指南 —— 三方登录》 文章。 # 5.1 管理后台的实现 ① 跳转第三方平台,来获得三方授权码,由 AuthController (opens new window) 提供 /admin-api/system/auth/social-auth-redirect 接口。代码如下: @GetMapping("/social-auth-redirect")@Operation(summary = "社交授权的跳转")@Parameters({ @Parameter(name = "type", description = "社交类型", required = true), @Parameter(name = "redirectUri", description = "回调路径")})public CommonResult<String> socialAuthRedirect(@RequestParam("type") Integer type, @RequestParam("redirectUri") String redirectUri) { return CommonResult.success(socialUserService.getAuthorizeUrl(type, redirectUri));} ② 使用 code 三方授权码进行快登录,由 AuthController (opens new window) 提供 /admin-api/system/auth/social-login 接口。代码如下: @PostMapping("/social-login")@Operation(summary = "社交快捷登录,使用 code 授权码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> socialQuickLogin(@RequestBody @Valid AuthSocialQuickLoginReqVO reqVO) { String token = authService.socialLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} ③ 使用 socialCode 三方授权码 + username + password 进行绑定登录,直接使用 /admin-api/system/auth/login 账号密码登录的接口,区别在于额外带上 socialType + socialCode + socialState 参数。 # 5.2 用户 App 的实现 ① 跳转第三方平台,来获得三方授权码,由 AppAuthController (opens new window) 提供 /app-api/member/auth/social-auth-redirect 接口。代码如下: @GetMapping("/social-auth-redirect")@Operation(summary = "社交授权的跳转")@Parameters({ @Parameter(name = "type", description = "社交类型", required = true), @Parameter(name = "redirectUri", description = "回调路径")})public CommonResult<String> socialAuthRedirect(@RequestParam("type") Integer type, @RequestParam("redirectUri") String redirectUri) { return CommonResult.success(socialUserService.getAuthorizeUrl(type, redirectUri));} ② 使用 code 三方授权码进行快登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/social-login 接口。代码如下: @PostMapping("/social-login")@Operation(summary = "社交快捷登录,使用 code 授权码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> socialQuickLogin(@RequestBody @Valid AuthSocialQuickLoginReqVO reqVO) { String token = authService.socialLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} ③ 使用 socialCode 三方授权码 + username + password 进行绑定登录,直接使用 /app-api/system/auth/login 手机验证码登录的接口,区别在于额外带上 socialType + socialCode + socialState 参数。 ④ 【微信小程序特有】使用 phoneCode + loginCode 实现获取手机号并一键登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/weixin-mini-app-login 接口。代码如下: @PostMapping("/weixin-mini-app-login")@Operation(summary = "微信小程序的一键登录")public CommonResult<AppAuthLoginRespVO> weixinMiniAppLogin(@RequestBody @Valid AppAuthWeixinMiniAppLoginReqVO reqVO) { return success(authService.weixinMiniAppLogin(reqVO));} # 6. 注册 # 6.1 管理后台的实现 管理后台暂不支持用户注册,而是通过在 [系统管理 -> 用户管理] 菜单,进行添加用户,由 UserController ( opens new window) 提供 /admin-api/system/user/create 接口。代码如下: @PostMapping("/create")@Operation(summary = "新增用户")@PreAuthorize("@ss.hasPermission('system:user:create')")public CommonResult<Long> createUser(@Valid @RequestBody UserCreateReqVO reqVO) { Long id = userService.createUser(reqVO); return success(id);} # 6.2 用户 App 的实现 手机验证码登录时,如果用户未注册,会自动使用手机号进行注册会员用户。所以, /app-api/system/user/sms-login 接口也提供了用户注册的功能。 # 7. 用户登出 用户登出的功能,统一使用 Spring Security 框架,通过删除用户 Token 的方式来实现。代码如下: 差别在于使用的 API 接口不同,管理员用户使用 /admin-api/system/logout,会员用户使用 /app-api/member/logout。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:51:54 数据权限 三方登录 ← 数据权限 三方登录→"},{"title":"短信配置","path":"/wiki/YuDaoBoot/后端手册/短信配置/短信配置.html","content":"开发指南后端手册 芋道源码 2022-04-10 目录 短信配置 本章节,介绍项目的短信功能。该功能提供统一的短信 API 给其它模块,使它们可以快速接入短信功能,无需关心不同短信平台的具体对接。 短信采用异步发送,基于 Redis 消息队列,如下图所示: yudao-spring-boot-starter-biz-sms (opens new window) 业务组件:封装不同短信平台的客户端。 yudao-module-system 的 sms (opens new window) 业务模块,提供短信渠道、模板的配置,短信日志的查看,短信的发送等功能。 # 1. 表结构 # 2. 短信配置 本小节,讲解如何配置短信功能,整个过程如下: 新建一个短信【渠道】,配置对应短信平台的账号 新建一个短信【模版】,配置对应短信平台的模板 测试该短信模板,查看对应的短信【日志】,确认是否发送成功 # 2.1 新建短信渠道 ① 点击 [系统管理 -> 短信管理 -> 短信渠道] 菜单,查看短信渠道的列表。如下图所示: ② 点击 [新增] 按钮,选择渠道编码为【调试(钉钉)】,并填写信息如下图: 短信 API 的账号: 696b5d8ead48071237e4aa5861ff08dbadb2b4ded1c688a7b7c9afc615579859短信 API 的密钥: SEC5c4e5ff888bc8a9923ae47f59e7ccd30af1f14d93c55b4e2c9cb094e35aeed67 疑问 1:为什么选择渠道编码为【调试(钉钉)】? 该类型使用钉钉机器人来模拟短信发送,用于日常调试。 短信 API 的账号,对应机器人的 Webhook 的 access_token 参数 短信 API 的密钥,对应机器人的安全设置的加签 上图使用的配置,是艿艿自己的钉钉机器人。正式使用时,必须参考 《钉钉开放平台 —— 自定义机器人接入 》 (opens new window) 文档,申请自己的专属机器人。 疑问 2:可以选择其它渠道编码吗? 当然可以,这里主要考虑部分同学暂时没有申请短信平台,所以使用【调试(钉钉)】渠道编码。 不同短信平台的配置,可见 「6. 短信平台附录」 小节。 # 2.2 新建短信模板 ① 点击 [系统管理 -> 短信管理 -> 短信模板] 菜单,查看短信模板的列表。如下图所示: ② 点击 [新增] 按钮,选择刚创建的短信渠道,并填写信息如下图: 短信渠道编号:发送该短信模板时,使用的短信渠道,即使用哪个短信平台进行发送 模板编号:短信模板的唯一标识,使用短信 API 时,通过它标识使用的短信模板 模板内容:短信模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 短信 API 模板编号:短信平台的短信模板的编号,需要保证该模板在短信平台已经审核通过 开启状态:短信模板被禁用时,该短信模板将不发送短信,只记录短信日志 疑问:为什么设计短信模板的功能? 在一些场景下,需要修改短信模板所使用的短信平台。例如说:短信平台出现故障,或者切换短信平台等等。 此时,只需要修改短信模板的两个属性:短信渠道编号、短信 API 模板编号,无需重启应用。 # 2.3 查看短信日志 ① 使用钉钉,扫码 图片 加入机器人所在的【ruoyi-vue-pro 短信测试群】,查看测试短信的模拟发送。 ② 点击 [测试] 按钮,输入任一手机号,进行该短信模板的模拟发送。如下图所示: 友情提示:如果使用的短信渠道是阿里云、腾讯云等正式的短信平台,则会发送到填写的手机号中。例如说: ③ 点击 [系统管理 -> 短信管理 -> 短信日志] 采单,可以查看到每条短信的发送状态、接收状态。如下图所示: # 3. 短信发送 # 3.1 SmsSendApi 使用 SmsSendApi (opens new window) 进行短信的发送,支持多种用户类型。它的方法如下: # 3.2 实战案例 以工作流申请通过时,发送短信为例子,讲解 SmsSendApi 的使用。 ① 引入 yudao-module-system-api 依赖,如下图所示: ② 新建对应的短信模板,如下图所示: ③ 使用 Spring 注入 SmsSendApi Bean,调用对应的短信发送方法。如下图所示: # 4. 验证码发送 # 4.1 SmsCodeApi 使用 SmsCodeApi (opens new window) 进行【验证码】短信的发送,例如说:用户手机验证码登录、用户忘记密码等等。它的方法如下: 验证码使用 system_sms_code (opens new window) 表进行存储,默认每天最多发送 10 条,每分钟发送 1 条,有效期为 10 分钟,可通过 yudao.sms-code 配置项进行自定义: # 4.2 实战案例 以会员用户手机验证码登录为例子,讲解 SmsCodeApi 的使用。 ① 引入 yudao-module-system-api 依赖,如下图所示: ② 新建对应的短信模板,如下图所示: ③ 在 SmsSceneEnum (opens new window) 中,枚举会员用户的手机号登录的场景,如下图所示: ④ 使用 Spring 注入 SmsCodeApi Bean,调用对应的短信验证码的发送与使用方法。如下图所示: # 5. 短信客户端 yudao-spring-boot-starter-biz-sms (opens new window) 业务组件,对接阿里云、腾讯云等短信平台,提供统一的短信客户端,提供给 yudao-module-system 的 sms (opens new window) 业务模块来调用。 # 5.1 SmsClient SmsClient (opens new window) 接口,定义短信客户端的方法。代码如下: 每个短信平台,都对应一个 SmsClient 实现类。 # 5.2 SmsCodeMapping SmsCodeMapping (opens new window) 接口,定义短信平台错误码转换成 标准错误码 (opens new window) 的方法。代码如下: 每个短信平台,都对应一个 SmsCodeMapping 实现类。 # 5.3 对接其它短信平台 如果你想要对接其它短信平台,自定义一个 SmsClient + SmsCodeMapping 实现类,并使用 SmsClientFactoryImpl (opens new window) 进行创建。代码如下: # 6. 短信平台附录 一般情况下,建议接入 2-3 个短信平台,避免某个短信平台故障时,影响业务的正常运行。 例如说,手机验证码的短信平台 A 故障时,赶紧将短信验证码切换到短信平台 B 上,否则用户将无法正常登录或是注册。 # 6.1 阿里云 短信 API 的账号、密钥,可通过 阿里云 —— AccessKey (opens new window) 获取。 短信发送回调 URL,可通过 阿里云 —— 短信服务 —— 通用设置 (opens new window) 配置。 # 6.2 腾讯云 短信 API 的账号、密钥,可通过 腾讯云 —— API 密钥管理 (opens new window) 获取。 注意!!! 腾讯云需要额外使用 SDKAppID (opens new window) 参数,它的账号需要采用 SDKAppID secretId 格式,具体可见 TencentSmsChannelProperties (opens new window) 类。 短信发送回调 URL,可通过 腾讯云 —— 短信 —— 基础配置 (opens new window) 配置。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/28, 22:55:26 数据库文档 邮件配置 ← 数据库文档 邮件配置→"},{"title":"站内信配置","path":"/wiki/YuDaoBoot/后端手册/站内信配置/站内信配置.html","content":"开发指南后端手册 芋道源码 2023-01-28 目录 站内信配置 本章节,介绍项目的站内信功能。它在管理后台有三个菜单,分别是: ① 站内信模版:管理站内信的内容模版 ② 站内信管理:查看站内信的发送记录 ③ 我的站内信:查看发送给我的站内信 # 1. 表结构 # 2. 实现代码 前端代码:views/system/notify (opens new window) 后端代码:controller/admin/notify (opens new window) # 3. 站内信配置 本小节,讲解如何配置站内信功能,整个过程如下: 新建一个站内信【模版】,配置站内信的内容模版 测试该站内信模板,查看对应的站内信【记录】,确认是否发送成功 # 3.1 新建站内信模版 ① 点击 [系统管理 -> 站内信管理 -> 模板管理] 菜单,查看站内信模板的列表。如下图所示: ② 点击 [新增] 按钮,填写信息如下图: 模版编号:站内信模板的唯一标识,使用站内信 API 时,通过它标识使用的站内信模板 发件人名称:发送站内信显示的发件人名字 模板内容:站内信模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 模版类型:站内信的分类,可使用 system_notify_template_type 字典进行自定义 开启状态:站内信模板被禁用时,该站内信模板将不发送站内信,只打印 logger 日志 疑问:为什么设计站内信模板的功能? 在一些场景下,产品会希望修改发送站内信的内容、发送人昵称,此时只需要修改站内信模版的对应属性,无需重启应用。 # 3.2 测试站内信模版 ① 点击 [测试] 按钮,选择接收人为「芋道源码」,进行该站内信模板的模拟发送。如下图所示: ② 点击 [系统管理 -> 站内信管理 -> 消息记录] 菜单,可以查看到刚发送的站内信。如下图所示: ③ 点击右上角的 [消息] 图标,也可以查看到刚发送的站内信。如下图所示: # 4. 站内信发送 # 4.1 NotifyMessageSendApi 站内信配置完成后,可使用 NotifyMessageSendApi (opens new window) 进行站内信的发送,支持多种用户类型。它的方法如下: # 4.2 接入示例 以 yudao-module-infra 模块,需要发站内信为例子,讲解 SmsCodeApi 的使用。 ① 在 yudao-module-infra-biz 模块的 pom.xml (opens new window) 引入 yudao-module-system-api 依赖,如所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 在代码中注入 NotifyMessageSendApi Bean,并调用发送站内信的方法。代码如下: public class TestDemoServiceImpl implements TestDemoService { // 0. 注入 NotifyMessageSendApi Bean @Resource private NotifyMessageSendApi notifySendApi; public void sendDemo() { // 1. 准备参数 Long userId = 1L; // 示例中写死,你可以改成你业务中的 userId 噢 String templateCode = "test_01"; // 站内信模版,记得在【站内信管理】中配置噢 Map<String, Object> templateParams = new HashMap<>(); templateParams.put("key1", "奥特曼"); templateParams.put("key2", "变身"); // 2. 发送站内信 notifySendApi.sendSingleNotifylToAdmin(new NotifySendSingleToUserReqDTO() .setUserId(userId).setTemplateCode(templateCode).setTemplateParams(templateParams)); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/29, 19:16:45 邮件配置 数据脱敏 ← 邮件配置 数据脱敏→"},{"title":"系统日志","path":"/wiki/YuDaoBoot/后端手册/系统日志/系统日志.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 系统日志 项目提供 2 类 4 种系统日志: 审计日志:用户的操作日志、登录日志 API 日志:RESTful API 的访问日志、错误日志 # 1. 操作日志 操作日志,记录「谁」在「什么时间」对「什么对象」做了「什么事情」。 打开 [系统管理 -> 审计日志 -> 操作日志] 菜单,可以看到对应的列表,如下图所示: 操作日志的记录,由 yudao-spring-boot-starter-biz-operatelog (opens new window) 技术组件实现,OperateLogAspect (opens new window) 通过 Spring AOP 拦声明了 @OperateLog (opens new window) 注解的方法,异步记录日志。使用示例如下: 操作日志的存储,由 yudao-module-system 的 OperateLog (opens new window) 模块实现,记录到数据库的 system_operate_log (opens new window) 表。 # 1.1 @OperateLog 注解 @OperateLog 注解,一共有 6 个属性,如下图所示: module 属性:操作模块,例如说:用户、岗位、部门等等。为空时,默认会读取类上的 Swagger @Tag 注解的 name 属性。 name 属性:操作名,例如说:新增用户、修改用户等等。为空时,默认会读取方法的 Swagger @Operation 注解的 summary 属性。 type 属性:操作类型,在 OperateTypeEnum (opens new window) 枚举。目前有 GET 查询、CREATE 新增、UPDATE 修改、DELETE 删除、EXPORT 导出、IMPORT 导入、OTHER 其它,可进行自定义。 # 1.2 自动记录 操作日志往往记录的是针对某个对象的写操作,所以针对 POST、PUT、DELETE 等写请求,yudao-spring-boot-starter-biz-operatelog 组件会自动记录操作日志。 基于请求方法,转换出对应的 type 操作方法:POST 对应 CREATE 类型,PUT 对应 UPDATE 类型,DELETE 对应 DELETE 类型,其它对应 OTHER 类型。 基于 Swagger 注解,转换出对应的 module 操作模块、name 操作名。 因此,绝大多数 RESTful API 对应的方法,无需添加 @OperateLog 注解。例如说: 一般来说,只有两种场景需要添加 @OperateLog 注解。 ① 场景一:需要自定义 @OperateLog 注解的属性。例如说: ② 场景二:不想自动记录操作日志。例如说: # 1.3 后续优化 yudao-spring-boot-starter-biz-operatelog 组件目前提供的是轻量级的操作日志的解决方案,暂时未提供很好的记录操作对应、操作明细、拓展字段的能力。例如说: 【新增】2021-09-16 10:00 订单创建,订单号:NO.11089999,其中涉及变量订单号 “NO.11089999” 【修改】2021-09-16 10:00 用户小明修改了订单的配送地址:从 “金灿灿小区” 修改到 “银盏盏小区” 未来,艿艿会引入老友开源的 https://github.com/mouzt/mzt-biz-log (opens new window) 操作日志组件,优化项目的操作日志功能。大家记得给个 Star 哟! 目前,如果要记录具体的操作明细、拓展字段,可以调用 OperateLogUtils (opens new window) 的静态方法,代码如下: # 2. 登录日志 登录日志,记录用户的登录、登出行为,包括成功的、失败的。 打开 [系统管理 -> 审计日志 -> 登录日志] 菜单,可以看对应的列表,如下图所示: 登录日志的存储,由 yudao-module-system 的 LoginLog (opens new window) 模块实现,记录到数据库的 system_login_log (opens new window) 表。 登录类型通过 LoginLogTypeEnum (opens new window) 枚举,登录结果通过 LoginResultEnum (opens new window) 枚举,都可以自定义。代码如下: # 3. API 访问日志 API 访问日志,记录 API 的每次调用,包括 HTTP 请求、用户、开始时间、时长等等信息。 打开 [基础设施 -> API 日志 -> 访问日志] 菜单,可以看对应的列表,如下图所示: 访问日志的记录,由 yudao-spring-boot-starter-web (opens new window) 技术组件实现,通过 ApiAccessLogFilter (opens new window) 过滤 RESTful API 请求,异步记录日志。 访问日志的存储,由 yudao-module-infra 的 AccessLog (opens new window) 模块实现,记录到数据库的 infra_api_access_log (opens new window) 表。 # 4. API 错误日志 API 错误日志,记录每次 API 的异常调用,包括 HTTP 请求、用户、异常的堆栈等等信息。 打开 [基础设施 -> API 日志 -> 错误日志] 菜单,可以看对应的列表,如下图所示: 错误日志的记录,由 yudao-spring-boot-starter-web (opens new window) 技术组件实现,通过 GlobalExceptionHandler (opens new window) 拦截每次 RESTful API 的系统异常,异步记录日志。 错误日志的存储,由 yudao-module-infra 的 ErrorLog (opens new window) 模块实现,记录到数据库的 infra_api_error_log (opens new window) 表。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:58:24 Excel 导入导出 数据库 MyBatis ← Excel 导入导出 数据库 MyBatis→"},{"title":"配置管理","path":"/wiki/YuDaoBoot/后端手册/配置管理/配置管理.html","content":"开发指南后端手册 芋道源码 2022-04-04 目录 配置管理 在 [基础设施 -> 配置管理] 菜单,可以查看和管理配置,适合业务上需要动态的管理某个配置。 例如说:创建用户时,需要配置用户的默认密码,这个密码是不会变的,但是有时候需要修改这个默认密码,这个时候就可以通过配置管理来修改。 对应的后端代码是 yudao-module-infra 的 config (opens new window) 业务模块。 # 1. 配置的表结构 infra_config 的表结构如下: CREATE TABLE `infra_config` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '参数主键', `group` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '参数分组', `type` tinyint NOT NULL COMMENT '参数类型', `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数名称', `key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数键名', `value` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数键值', `sensitive` bit(1) NOT NULL COMMENT '是否敏感', `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='参数配置表'; key 字段,对应到 Spring Boot 配置文件的配置项,例如说 yudao.captcha.enable、sys.user.init-password 等等。 # 3. 后端案例 TODO 芋艿:待补充 # 4. 前端案例 后端提供了 /admin-api/infra/config/get-value-by-key (opens new window) RESTful API 接口,返回指定配置项的值。前端的使用示例如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:01 消息队列 工具类 Util ← 消息队列 工具类 Util→"},{"title":"邮件配置","path":"/wiki/YuDaoBoot/后端手册/邮件配置/邮件配置.html","content":"开发指南后端手册 芋道源码 2023-01-26 目录 邮件配置 本章节,介绍项目的邮件功能。它在管理后台有三个菜单,分别是: ① 邮箱账号:配置邮件的发送账号 ② 邮件模版:管理邮件的内容模版 ③ 邮件记录:查看邮件的发送记录 # 1. 表结构 # 2. 实现原理 邮件功能提供统一的 API 给其它模块,使它们可以快速实现发送邮件的功能,无需关心不同邮件平台的具体对接。 邮件采用异步发送,基于 Redis 消息队列,如下图所示: 前端代码:views/system/mail (opens new window) 后端代码:controller/admin/mail (opens new window) 最终使用 Hutool 的 MailUtil (opens new window) 发送邮件。 # 3. 邮箱配置 本小节,讲解如何配置邮件功能,整个过程如下: 新建一个邮箱【账号】,配置邮件的发送账号 新建一个邮件【模版】,配置邮件的内容模版 测试该邮件模板,查看对应的邮件【日志】,确认是否发送成功 # 3.1 新建邮箱账号 ① 点击 [系统管理 -> 邮件管理 -> 邮箱账号] 菜单,查看邮箱账号的列表。如下图所示: ② 点击 [新增] 按钮,添加一个邮箱账号,并填写信息如下图: 友情提示: 邮件发送基于 SMTP (opens new window) 协议实现,需要开通账号的 STMP 服务。例如说: 不同邮件平台的 SMTP 配置,可见 「5. 邮箱平台附录」 小节。 ③ 新增完成后,确认你的邮箱账号是否可以发送邮件,可通过如下代码: import cn.hutool.extra.mail.MailAccount;import cn.hutool.extra.mail.MailUtil;@Testpublic void testDemo() { MailAccount mailAccount = new MailAccount()// .setFrom("奥特曼 <ydym_test@163.com>") .setFrom("ydym_test@163.com") // 邮箱地址 .setHost("smtp.163.com").setPort(465).setSslEnable(true) // SMTP 服务器 .setAuth(true).setUser("ydym_test@163.com").setPass("WBZTEINMIFVRYSOE"); // 登录账号密码 String messageId = MailUtil.send(mailAccount, "7685413@qq.com", "主题", "内容", false); System.out.println("发送结果:" + messageId);} # 3.2 新建邮箱模版 ① 点击 [系统管理 -> 邮箱管理 -> 邮件模板] 菜单,查看邮件模板的列表。如下图所示: ② 点击 [新增] 按钮,选择刚创建的邮箱账号,并填写信息如下图: 邮箱账号:发送该邮件模板时,使用的邮件账号,即使用哪个邮箱进行发送邮件 模版编号:邮件模板的唯一标识,使用邮件 API 时,通过它标识使用的邮件模板 发件人名称:发送邮件显示的发件人名字 模板内容:邮件模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 开启状态:邮件模板被禁用时,该邮件模板将不发送邮件,只记录邮件日志 疑问:为什么设计邮件模板的功能? 在一些场景下,产品会希望修改发送邮件的标题、内容,甚至邮箱账号,此时只需要修改邮件模版的对应属性,无需重启应用。 # 3.3 查看邮件日志 ① 点击 [测试] 按钮,输入测试的收件邮箱地址,进行该邮件模板的模拟发送。如下图所示: ② 打开收件邮箱,查看邮件是否发送成功。如下图所示: ③ 点击 [系统管理 -> 邮箱管理 -> 邮件日志] 采单,可以查看到每条邮件的发送状态。如下图所示: # 4. 邮件发送 # 4.1 MailSendApi 邮箱配置 完成后,可使用 MailSendApi ( opens new window) 进行邮件的发送,支持多种用户类型。它的方法如下: # 4.2 接入示例 以 yudao-module-infra 模块,需要发邮件为例子,讲解 SmsCodeApi 的使用。 ① 在 yudao-module-infra-biz 模块的 pom.xml ( opens new window) 引入 yudao-module-system-api 依赖,如所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 在代码中注入 SmsCodeApi Bean,并调用发送邮件的方法。代码如下: public class TestDemoServiceImpl implements TestDemoService { // 0. 注入 MailSendApi Bean @Resource private MailSendApi mailSendApi; public void sendDemo() { // 1. 准备参数 Long userId = 1L; // 示例中写死,你可以改成你业务中的 userId 噢 String templateCode = "test_01"; // 邮件模版,记得在【邮箱管理】中配置噢 Map<String, Object> templateParams = new HashMap<>(); templateParams.put("key1", "奥特曼"); templateParams.put("key2", "变身"); // 2. 发送邮件 mailSendApi.sendSingleMailToAdmin(new MailSendSingleToUserReqDTO() .setUserId(userId).setTemplateCode(templateCode).setTemplateParams(templateParams)); }} # 5. 邮箱平台附录 《QQ 邮箱的 SMTP 设置》 ( opens new window) 《网易 163 邮箱的 SMTP 设置》 ( opens new window) 《QQ 邮箱、网易邮箱、腾讯企业邮箱、网易企业邮箱的 SMTP 设置》 ( opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/28, 22:55:26 短信配置 站内信配置 ← 短信配置 站内信配置→"},{"title":"验证码","path":"/wiki/YuDaoBoot/后端手册/验证码/验证码.html","content":"开发指南后端手册 芋道源码 2023-01-20 目录 验证码 项目基于 AJ-Captcha (opens new window) 实现行为验证码,包含滑动拼图、文字点选两种方式,UI 支持弹出和嵌入两种方式。如下图所示: 滑动拼图 文字点选 疑问:为什么采用行为验证码? 相比传统的「传统字符型验证码」的“展示验证码-填写字符-比对答案”的流程来说,「行为验证码」的“展示验证码-操作-比对答案”的流程,用户只需要使用鼠标产生指定的行为轨迹,不需要键盘手动输入,用户体验更好,更加难以被机器识别,更加安全可靠。 # 1. 交互流程 ① 用户访问应用页面,请求显示行为验证码 ② 用户按照提示要求完成验证码拼图/点击 ③ 用户提交表单,前端将第二步的输出一同提交到后台 ④ 验证数据随表单提交到后台后,后台需要调用 captchaService.verification (opens new window) 做二次校验 ⑤ 第 4 步返回校验通过/失败到产品应用后端,再返回到前端 # 2. 如何关闭验证码 管理后台的登录界面,默认开启验证码。如果需要关闭验证码,操作如下: ① 后端的 application-local.yaml 配置文件中,将 yudao.captcha.enabled (opens new window) 设置为 false。 ② 如果前端使用 yudao-ui-admin 项目,将 .env.local 配置文件中,将 VUE_APP_DOC_ENABLE (opens new window) 设置为 false。 如果前端使用 yudao-ui-admin-vue3 项目,将 .env 配置文件中,将 VITE_APP_CAPTCHA_ENABLE (opens new window) 设置为 false。 # 3. 接入场景 # 3.1 后端接入 ① yudao-spring-boot-starter-captcha (opens new window) 对 AJ-Captcha 进行封装,使用 Redis 存储验证码数据,保证分布式环境下的可用性。 由于 AJ-Captcha 对 Spring Boot 3.X 版本的支持还不完善,所以使用 captcha-plus (opens new window) 替代,它是基于 AJ-Captcha 进行增强。 使用时,需要在 pom.xml (opens new window) 引入该依赖,如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-captcha</artifactId></dependency> ② 验证码的配置,在 application.yaml (opens new window) 配置文件中,配置项如下: aj: captcha: jigsaw: classpath:images/jigsaw # 滑动验证,底图路径,不配置将使用默认图片;以 classpath: 开头,取 resource 目录下路径 pic-click: classpath:images/pic-click # 滑动验证,底图路径,不配置将使用默认图片;以 classpath: 开头,取 resource 目录下路径 cache-type: redis # 缓存 local/redis... cache-number: 1000 # local 缓存的阈值,达到这个值,清除缓存 timing-clear: 180 # local定时清除过期缓存(单位秒),设置为0代表不执行 type: blockPuzzle # 验证码类型 default两种都实例化。 blockPuzzle 滑块拼图 clickWord 文字点选 water-mark: 芋道源码 # 右下角水印文字(我的水印),可使用 https://tool.chinaz.com/tools/unicode.aspx 中文转 Unicode,Linux 可能需要转 unicode interference-options: 0 # 滑动干扰项(0/1/2) req-frequency-limit-enable: false # 接口请求次数一分钟限制是否开启 true|false req-get-lock-limit: 5 # 验证失败 5 次,get接口锁定 req-get-lock-seconds: 10 # 验证失败后,锁定时间间隔 req-get-minute-limit: 30 # get 接口一分钟内请求数限制 req-check-minute-limit: 60 # check 接口一分钟内请求数限制 req-verify-minute-limit: 60 # verify 接口一分钟内请求数限制 如果你想修改验证码的 图片,修改 resources/images (opens new window) 目录即可。 ③ 验证码的使用,可以参考 CaptchaController (opens new window) 和 AuthController (opens new window) 两个类的实现代码。 # 3.2 Vue2.X 管理后台 ① 验证码组件:Verifition (opens new window) ② 登录界面的接入:login.vue (opens new window) <!-- 图形验证码 --><Verify ref="verify" :captcha-type="'blockPuzzle'" :img-size="{width:'400px',height:'200px'}" @success="handleLogin" /> # 3.3 Vue3.X 管理后台 ① 验证码组件: Verifition ( opens new window) ② 登录界面的接入: LoginForm.vue ( opens new window) <Verify ref="verify" mode="pop" :captchaType="captchaType" :imgSize="{ width: '400px', height: '200px' }" @success="handleLogin"/> # 3.4 uni-app 用户 App ① 验证码组件: verifition ( opens new window) ② 登录界面的接入: login.vue ( opens new window) <Verify @success="pwdLogin" :mode="'pop'" :captchaType="'blockPuzzle'" :imgSize="{ width: '330px', height: '155px' }" ref="verify"></Verify> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/02/11, 02:22:37 敏感词 地区 & IP 库 ← 敏感词 地区 & IP 库→"},{"title":"限流熔断","path":"/wiki/YuDaoBoot/后端手册/限流熔断/限流熔断.html","content":"None"},{"title":"功能开启","path":"/wiki/YuDaoBoot/商城手册/功能开启/功能开启.html","content":"开发指南商城手册 芋道源码 2023-02-04 目录 功能开启 商城目前处于【开发】阶段,功能还在不断完善中,敬请期待! 完成时间不确定,主要前端的工作量比较大。如果你有兴趣一起开发,可以联系微信 wangwenbin-server 商城的功能,由 yudao-module-mall (opens new window) 模块实现,对应管理后台的前端代码为 @/views/mall (opens new window) 目录,用户前台的前端代码为 yudao-ui-admin (opens new window) 项目。 # 1. 功能介绍 主要拆分四大模块:商品中心、交易中心、营销中心、会员中心(待建设)。如下图所示: # 2. 功能开启 考虑到编译速度,默认 yudao-module-mall 模块是关闭的,需要手动开启。步骤如下: 第一步,开启 yudao-module-mall 模块 第二步,导入商城的 SQL 数据库脚本 第三步,重启后端项目,确认功能是否生效 # 2.1 开启模块 ① 修改根目录的 pom.xml (opens new window) 文件,取消 yudao-module-mall 模块的注释。如下图所示: ② 修改 yudao-server 目录的 pom.xml (opens new window) 文件,引入 yudao-module-mp 模块。如下图所示: ③ 点击 IDEA 右上角的【Reload All Maven Projects】,刷新 Maven 依赖。如下图所示: # 2.2 第二步,导入 SQL 点击 mall.sql 下载,然后导入到数据库中。 以 product_ 作为前缀的表,对应商品模块(中心)。 以 trade_ 作为前缀的表,对应交易模块(中心)。 以 promotion_ 作为前缀的表,对应营销模块(中心)。 【待建设】以 member_ 作为前缀的表,对应会员模块(中心)。 # 2.3 第三步,重新项目 重启后端项目,然后访问前端的商城菜单,确认功能是否生效。如下图所示: 至此,我们就成功开启了商城的功能 🙂 # 3. 项目进展 功能 用户 App 管理后台 商品列表 60% 90% 商品分类 已完成 已完成 商品品牌 已完成 已完成 商品属性 60% 已完成 订单列表 50% 80% 售后退款 50% 80% 价格计算 50% 已完成 购物车 50% 已完成 优惠劵 0% 已完成 秒杀活动 0% 已完成 限时折扣活动 0% 已完成 满减送活动 0% 已完成 收货地址 30% 100% 物流发货 0% 20% 支付退款 20% 100% .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 公众号统计 开发环境 ← 公众号统计 开发环境→"},{"title":"大屏设计器","path":"/wiki/YuDaoBoot/大屏手册/大屏设计器/大屏设计器.html","content":"开发指南大屏手册 芋道源码 2023-02-07 目录 大屏设计器 数据可视化,一般可以通过报表设计器、或者大屏设计器来实现。本小节,我们来讲解大屏设计器的功能开启。 大屏设计器,指的是通过拖拽图表或页面元素,无需编写代码即可制作数据大屏。如下图所示: 在项目中,通过集成市面上的报表引擎,实现了大屏设计器的能力。目前使用如下: 是否集成 是否开源 AJ-Report (opens new window) 集成中 开源 Go-View (opens new window) 集成中 开源 JimuReport (opens new window) 不集成 不开源 为什么不使用 JimuReport 报表引擎呢? 因为 JimuReport 的大屏设计器是商业化的,需要购买授权。我手头暂时没有授权,所以没办法集成~ # 1. 功能开启 yudao-module-report 也实现了大屏设计器的能力,考虑到编译速度,默认是关闭的。开启步骤如下: 第一步,开启 yudao-report-report 模块 第二步,导入报表的 SQL 数据库脚本 第三步,启动后端项目,确认功能是否生效 第四步,启动大屏设计器的前端项目 # 1.1 第一步,开启模块 ① 修改根目录的 pom.xml (opens new window) 文件,取消 yudao-module-report 模块的注释。 ② 修改 yudao-server 目录的 pom.xml (opens new window) 文件,引入 yudao-module-report 模块。如下图所示: ③ 点击 IDEA 右上角的【Reload All Maven Projects】,刷新 Maven 依赖。如下图所示: # 1.2 第二步,导入 SQL 导入 go-view.sql (opens new window) 脚本,初始化 Go-View 相关的表结构和数据。 # 1.3 第三步,启动后端项目 启动后端项目,看到 \"Init JimuReport Config [ 线程池 ] \" 说明开启成功。 # 1.4 第四步,启动前端项目(AJ-Report) TODO 开发中,预计 4 月份左右。 # 1.4 第四步,启动前端项目(Go-View) ① 克隆 yudao-ui-go-view (opens new window) 项目,执行如下命令进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ② 启动完成后,浏览器会自动打开 http://127.0.0.1:3000 (opens new window) 地址,可以看到前端界面。 ③ 访问 [报表管理 -> 大屏设计器] 菜单,可以查看对应的功能。如下图所示: # 2. 如何使用? # 2.1 AJ-Report 大屏设计器 TODO 开发中,预计 4 月份左右。 # 2.2 Go-View 大屏设计器 可以查看 Go-View 的官方文档,主要是: GoView 说明文档 —— 页面引导 (opens new window) GoView 说明文档 —— 常见问题 (opens new window) 如果你想了解在 Go-View 中,如何使用 SQL 或 HTTP 查询数据,可以查看内置的两个示例: 集成 Go-View 的代码实现? ① 后端:Go-View 的后端代码,主要在 go-view (opens new window) 包下实现。 ② 前端:在 @/views/report/go-view (opens new window) 文件,通过 IFrame 嵌入 Go-View 界面。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 报表设计器 功能开启 ← 报表设计器 功能开启→"},{"title":"报表设计器","path":"/wiki/YuDaoBoot/大屏手册/报表设计器/报表设计器.html","content":"开发指南大屏手册 芋道源码 2022-07-29 目录 报表设计器 数据可视化,一般可以通过报表设计器、或者大屏设计器来实现。本小节,我们来讲解报表设计器的功能开启。 报表设计器,指的是使用 Web 版设计器,通过类似于 Excel 操作风格,通过拖拽完成报表设计。如下图所示: 在项目中,通过集成市面上的报表引擎,实现了报表设计器的能力。目前使用如下: 是否集成 是否开源 JimuReport (opens new window) 已集成 不开源 AJ-Report (opens new window) 集成中 开源 UReport2 (opens new window) 不集成 开源 为什么不使用 UReport2 报表引擎呢? UReport2 基本处于不维护的状态,最后发版时间是 2018 年! # 1. 功能开启 yudao-module-report 实现了报表设计器的能力,考虑到编译速度,默认是关闭的。开启步骤如下: 第一步,开启 yudao-report-report 模块 第二步,导入报表的 SQL 数据库脚本 第三步,启动后端项目,确认功能是否生效 第四步,启动报表设计器的前端项目 # 1.1 第一步,开启模块 ① 修改根目录的 pom.xml (opens new window) 文件,取消 yudao-module-report 模块的注释。 ② 修改 yudao-server 目录的 pom.xml (opens new window) 文件,引入 yudao-module-report 模块。如下图所示: ③ 点击 IDEA 右上角的【Reload All Maven Projects】,刷新 Maven 依赖。如下图所示: # 1.2 第二步,导入 SQL 导入 jimureport.mysql5.7.create.sql (opens new window) 脚本,初始化 JimuReport 相关的表结构和数据。如果你是 Oracle、PostgreSQL 等其它数据库,需要自己使用 Navicat 进行转换。 # 1.3 第三步,启动后端项目 启动后端项目,看到 \"Init JimuReport Config [ 线程池 ] \" 说明开启成功。 # 1.4 第四步,启动前端项目(AJ-Report) TODO 开发中,预计 4 月份左右。 # 1.4 第四步,启动前端项目(JimuReport) ① JimuReport 前端项目内置在后端项目中,无需启动。 ② 访问 [报表管理 -> 报表设计器] 菜单,可以查看对应的功能。如下图所示: 可以看到,JimuReport 支持数据报表、图形报表、打印设计等能力。 # 2. 如何使用? # 2.1 AJ-Report 报表设计器 TODO 开发中,预计 4 月份左右。 # 2.2 JimuReport 报表设计器 可以查看 JimuReport 的官方文档,主要是: 快速入门 (opens new window) 操作手册(报表设计器) (opens new window) 注意,JimuReport 是商业化的产品,报表设计器的功能应该是免费的,大屏设计器的功能是收费的。 集成 JimuReport 的代码实现? ① 后端:在 jmreport (opens new window) 包下,进行 JimuReport 的集成。 ② 前端:在 @/views/report/jmreport (opens new window) 文件,通过 IFrame 嵌入 JimuReport 界面。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 工作流(Flowable)会签、或签 大屏设计器 ← 工作流(Flowable)会签、或签 大屏设计器→"},{"title":"工作流(Flowable)会签、或签","path":"/wiki/YuDaoBoot/工作流手册/工作流(Flowable)会签、或签/工作流(Flowable)会签、或签.html","content":"开发指南工作流手册 芋道源码 2022-03-07 目录 工作流(Flowable)会签、或签 项目基于 Flowable 实现了工作流的功能。本章节,我们将介绍工作流的相关功能。 以请假流程为例,讲解系统支持的两种表单方式的工作流: 流程表单:在线配置动态表单,无需建表与开发 业务表单:业务需建立独立的数据库表,并开发对应的表单、详情界面 整个过程包括: 定义流程:【管理员】新建流程、设计流程模型、并设置用户任务的审批人,最终发布流程 发起流程:【员工】选择流程,并发起流程实例 审批流程:【审批人】接收到流程任务,审批结果为通过或不通过 微信扫描下方二维码,加入后可观看视频! 01、如何集成 Flowable 框架? (opens new window) 02、如何实现动态的流程表单? (opens new window) 03、如何实现流程表单的保存? (opens new window) 04、如何实现流程表单的展示? (opens new window) 05、如何实现流程模型的新建? (opens new window) 06、如何实现流程模型的流程图的设计? (opens new window) 07、如何实现流程模型的流程图的预览? (opens new window) 08、如何实现流程模型的分配规则? (opens new window) 09、如何实现流程模型的发布? (opens new window) 10、如何实现流程定义的查询? (opens new window) 11、如何实现流程的发起? (opens new window) 12、如何实现我的流程列表? (opens new window) 13、如何实现流程的取消? (opens new window) 14、如何实现流程的任务分配? (opens new window) 15、如何实现会签、或签任务? (opens new window) 16、如何实现我的待办任务列表? (opens new window) 17、如何实现我的已办任务列表? (opens new window) 18、如何实现任务的审批通过? (opens new window) 19、如何实现任务的审批不通过? (opens new window) 20、如何实现流程的审批记录? (opens new window) 21、如何实现流程的流程图的高亮? (opens new window) 22、如何实现工作流的短信通知? (opens new window) 23、如何实现 OA 请假的发起? (opens new window) 24、如何实现 OA 请假的审批? (opens new window) # 0. 如何开启 bpm 模块? yudao-module-bpm 模块默认未启用,需要手动开启。步骤如下: ① 第一步,修改根目录的 pom.xml (opens new window) 文件,取消 yudao-module-bpm 模块的注释。 ① 第二步,修改 yudao-server 的 pom.xml (opens new window) 文件,取消 yudao-module-bpm-biz 依赖的注释,并进行 IDEA 的 Maven 刷新。 ③ 第三步,重启项目,看到 Property Source flowable-liquibase-override refreshed 说明开启成功。 另外,启动过程中,Flowable 会自动创建 ACT_ 和 FLW_ 开头的表。 如果启动中报 MySQL “Specified key was too long; max key length is 1000 bytes” (opens new window) 错误,可以将 MySQL 的缺省存储引擎设置为 innodb,即 default-storage-engine=innodb 配置项。 # 1. 请假流程【流程表单】 # 1.1 第一步:定义流程 登录账号 admin、密码 admin123 的用户,扮演【管理员】的角色,进行流程的定义。 ① 访问 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [新建流程] 按钮,填写流程标识、流程名称。如下图所示: 流程标识:对应 BPMN 流程文件 XML 的 id 属性,不能重复,新建后不可修改。 流程名称:对应 BPMN 流程文件 XML 的 name 属性。 <!-- 这是一个 BPMN XML 的示例,主要看 id 和 name 属性 --><?xml version="1.0" encoding="UTF-8"?><bpmn2:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn2="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" id="diagram_Process_1647305370393" targetNamespace="http://activiti.org/bpmn"> <bpmn2:process id="common-form" name="通用表单流程" isExecutable="true" /> <bpmndi:BPMNDiagram id="BPMNDiagram_1"> <bpmndi:BPMNPlane id="common-form_di" bpmnElement="common-form" /> </bpmndi:BPMNDiagram></bpmn2:definitions> ② 访问 [工作流程 -> 流程管理 -> 流程表单] 菜单,点击 [新增] 按钮,新增一个名字为 leave-form 的表单。如下图所示: 流程表单的实现? 基于 https://github.com/JakHuang/form-generator (opens new window) 项目实现的动态表单。 回到 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [修改流程] 按钮,配置表单类型为流程表单,选择名字为 leave-form 的流程表单。如下图所示: ③ 点击 [设计流程] 按钮,在线设计请假流程模型,包含两个用户任务:领导审批、HR 审批。如下图所示: 设计流程的实现? 基于 https://github.com/miyuesc/bpmn-process-designer (opens new window) 项目实现,它的底层是 bpmn-js (opens new window)。 ④ 点击 [分配规则] 按钮,设置用户任务的审批人。其中,规则类型用于分配用户任务的审批人,目前有 7 种规则:角色、部门成员、部门负责人、岗位、用户、用户组、自定义脚本,基本可以满足绝大多数场景,是不是非常良心。 设置【领导审批】的规则类型为自定义脚本 + 流程发起人的一级领导,如下图所示: 设置【HR 审批】的规则类型为岗位 + 人力资源,如下图所示: 规则类型的实现? 可见 BpmUserTaskActivityBehavior (opens new window) 代码,目前暂时支持分配一个审批人。 ⑤ 点击 [发布流程] 按钮,把定义的流程模型部署出去。部署成功后,就可以发起该流程了。如下图所示: 修改流程后,需要重新发布流程吗? 需要,必须重新发布才能生效。每次流程发布后,会生成一个新的流程定义,版本号从 v1 开始递增。 发布成功后,会部署新版本的流程定义,旧版本的流程定义将被挂起。当然,已经发起的流程不会受到影响,还是走老的流程定义。 # 1.2 第二步:发起流程 登录账号 admin、密码 admin123 的用户,扮演【员工】的角色,进行流程的发起。 ① 访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,点击 [发起流程] 按钮,可以看到可以选择的流程定义的列表。 ② 选择名字为通用表单的流程定义,发起请假流程。填写请假表单信息如下: ③ 点击提交成功后,可在我的流程中,可看到该流程的状态、结果。 ④ 点击 [详情] 按钮,可以查看申请的表单信息、审批记录、流程跟踪图。 # 1.2 第三步:审批流程(领导审批) 登录账号 test、密码 test123 的用户,扮演【审批人】的角色,进行请假流程的【领导审批】任务。 ① 访问 [工作流程 -> 任务管理 -> 待办任务] 菜单,可以查询到需要审批的任务。 ② 点击 [审批] 按钮,填写审批建议,并点击 [通过] 按钮,这样任务的审批就完成了。 ③ 访问 [工作流程 -> 任务管理 -> 已办任务] 菜单,可以查询到已经审批的任务。 此时,使用【员工】的角色,访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,可以看到流程流转到了【HR 审批】任务。 # 1.3 第三步:审批流程(HR 审批) 登录账号 hrmgr、密码 hr123 的用户,扮演【审批人】的角色,进行请假流程的【HR 审批】任务。 ① 访问 [工作流程 -> 任务管理 -> 待办任务] 菜单,点击 [审批] 按钮,填写审批建议,并点击 [通过] 按钮。 此时,使用【员工】的角色,访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,可以看到流程处理结束,最终审批通过。 # 2. 请假流程【业务表单】 根据业务需要,业务通过建立独立的数据库表(业务表)记录申请信息,而流程引擎只负责推动流程的前进或者结束。两者需要进行双向的关联: 每一条业务表记录,通过它的流程实例的编号( process_instance_id )指向对应的流程实例 每一个流程实例,通过它的业务键( BUSINESS_KEY_ ) 指向对应的业务表记录。 以项目中提供的 OALeave (opens new window) 请假举例子,它的业务表 bpm_oa_leave 和流程引擎的流程实例的关系如下图: 也因为业务建立了独立的业务表,所以必须开发业务表对应的列表、表单、详情页面。不过,审核相关的功能是无需重新开发的,原因是业务表已经关联对应的流程实例,流程引擎审批流程实例即可。 下面,我们以项目中的 OALeave (opens new window) 为例子,详细讲解下业务表单的开发与使用的过程。 # 2.0 第零步:业务开发 ① 新建业务表 bpm_oa_leave,建表语句如下: CREATE TABLE `bpm_oa_leave` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '请假表单主键', `user_id` bigint NOT NULL COMMENT '申请人的用户编号', `type` tinyint NOT NULL COMMENT '请假类型', `reason` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请假原因', `start_time` datetime NOT NULL COMMENT '开始时间', `end_time` datetime NOT NULL COMMENT '结束时间', `day` tinyint NOT NULL COMMENT '请假天数', `result` tinyint NOT NULL COMMENT '请假结果', `process_instance_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '流程实例的编号', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA 请假申请表'; process_instance_id 字段,关联流程引擎的流程实例对应的 ACT_HI_PROCINST 表的 PROC_INST_ID_ 字段 result 字段,请假结果,需要通过 Listener 监听回调结果,稍后来看看 ② 实现业务表的【后端】业务逻辑,具体代码可以看看如下两个类: BpmOALeaveController (opens new window) BpmOALeaveServiceImpl (opens new window) 重点是看流程发起的逻辑,它定义了 /bpm/oa/leave/create 给业务的表单界面调用,UML 时序图如下: 具体的实现代码比较简单,如下图所示: PROCESS_KEY 静态变量:是业务对应的流程模型的编号,稍后会进行创建编号为 oa_leave 的流程模型。 BpmProcessInstanceApi (opens new window) 定义了 #createProcessInstance(...) 方法,用于创建流程实例,业务无需关心底层是 Activiti 还是 Flowable 引擎,甚至未来可能的 Camunda 引擎。 ③ 实现业务表的【前端】业务逻辑,具体代码可以看看如下三个页面: leave/create.vue (opens new window) leave/detail.vue (opens new window) leave/index.vue (opens new window) 另外,在 router/index.js (opens new window) 中定义 create.vue 和 detail.vue 的路由,配置如下: { path: '/bpm', component: Layout, hidden: true, redirect: 'noredirect', children: [{ path: 'oa/leave/create', component: (resolve) => require(['@/views/bpm/oa/leave/create'], resolve), name: '发起 OA 请假', meta: {title: '发起 OA 请假', icon: 'form', activeMenu: '/bpm/oa/leave'} }, { path: 'oa/leave/detail', component: (resolve) => require(['@/views/bpm/oa/leave/detail'], resolve), name: '查看 OA 请假', meta: {title: '查看 OA 请假', icon: 'view', activeMenu: '/bpm/oa/leave'} } ]} 为什么要做独立的 `create.vue` 和 `index.vue` 页面? 创建流程时,需要跳转到 create.vue 页面,填写业务表的信息,才能提交流程。 审批流程时,需要跳转到 detail.vue 页面,查看业务表的信息。 ④ 实现业务表的【后端】监听逻辑,具体可见 BpmOALeaveResultListener (opens new window) 监听器。它实现流程引擎定义的 BpmProcessInstanceResultEventListener (opens new window) 抽象类,在流程实例结束时,回调通知它最终的结果是通过还是不通过。代码如下图: 至此,我们了解了 OALeave 使用业务表单所涉及到的开发,下面我们来定义对应的流程、发起该流程、并审批该流程。 # 2.1 第一步:定义流程 登录账号 admin、密码 admin123 的用户,扮演【管理员】的角色,进行流程的定义。 ① 访问 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [新建流程] 按钮,填写流程标识、流程名称。如下图所示: 注意,流程标识需要填 oa_leave。因为在 BpmOALeaveServiceImpl 类中,规定了对应的流程标识为 oa_leave。 ② 点击 [修改流程] 按钮,配置表单类型为业务表单,填写表单提交路由为 /bpm/oa/leave/create(用于发起流程时,跳转的业务表单的路由)、表单查看路由为 /bpm/oa/leave/detail(用于在流程详情中,点击查看表单的路由)。如下图所示: ③ 点击 [设计流程] 按钮,在线设计请假流程模型,包含两个用户任务:领导审批、HR 审批。如下图所示: 可以点击 oa_leave_bpmn.XML 进行下载,然后点击 [打开文件] 按钮,进行导入。 ④ 点击 [分配规则] 按钮,设置用户任务的审批人。 设置【领导审批】的规则类型为自定义脚本 + 流程发起人的一级领导,如下图所示: 设置【HR 审批】的规则类型为岗位 + 人力资源,如下图所示: ⑤ 点击 [发布流程] 按钮,把定义的流程模型部署出去。部署成功后,就可以发起该流程了。 # 2.1 第二步:发起流程 登录账号 admin、密码 admin123 的用户,扮演【员工】的角色,进行流程的发起。 ① 发起业务表单请假流程,两种路径: 访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,点击 [发起流程] 按钮,会跳转到流程模型 oa_leave 配置的表单提交路由。 访问 [工作流程 -> 请假查询] 菜单,点击 [发起请假] 按钮。 ② 填写一个小于等于 3 天的请假,只会走【领导审批】任务;填写一个大于 3 天的请假,在走完【领导审批】任务后,会额外走【HR 审批】任务。 后续的流程,和「1. 请假流程【流程表单】」是基本一致的,这里就不重复赘述,当然你还是要试着跑一跑,了解整个的过程。 # 2.3 第三步:审批流程(领导审批) 略~自己跑 # 2.4 第三步:审批流程(HR 审批) 略~自己跑 # 2. 流程通知 流程在发生变化时,会发送通知给相关的人。目前有三个场景会有通知,通过短信的方式。 # 3. 流程图示例 # 3.1 会签 定义:指同一个审批节点设置多个人,如 ABC 三人,三人会同时收到审批,需全部同意之后,审批才可到下一审批节点。 配置方式如下图所示: 重点是【完成条件】为 ${ nrOfCompletedInstances== nrOfInstances }。 # 3.2 或签 定义:指同一个审批节点设置多个人,如ABC三人,三人会同时收到审批,只要其中任意一人审批即可到下一审批节点。 配置方式如下图所示: 重点是【完成条件】为 ${ nrOfCompletedInstances== 1 }。 # 4. 如何使用 Activiti? Activiti 和 Flowable 提供的 Java API 是基本一致的,例如说 Flowable 的 org.flowable.engine.RepositoryService 对应 Activiti 的 org.activiti.engine .RepositoryService。所以,我们可以修改 import 的包路径来替换。 另外,在项目的老版本,我们也提供了 Activiti 实现,你可以具体参考下: yudao-spring-boot-starter-activiti (opens new window) yudao-module-bpm-biz-activiti (opens new window) # 4. 迭代计划 工作流的基本功能已经开发完成,当然还是有很多功能需要进行建设。已经整理在 https://gitee.com/zhijiantianya/ruoyi-vue-pro/issues/I4UPEU (opens new window) 链接中,你也可以提一些功能的想法。 如果您有参与工作流开发的想法,可以添加我的微信 wangwenbin10 ! 艿艿会带着你做技术方案,Code Review 你的每一行代码的实现。相信在这个过程中,你会收获不错的技术成长! .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 地区 & IP 库 报表设计器 ← 地区 & IP 库 报表设计器→"},{"title":"【v1.2.0】2021.12.15","path":"/wiki/YuDaoBoot/更新日志/【v1.2.0】2021.12.15/【v1.2.0】2021.12.15.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.2.0】2021.12.15 # 新增多租户、数据权限的功能 这个版本新增了多租户与数据权限两个重量级的功能,建议花点时间进行了解与学习。 # ⭐ New Features 【新增】多租户,支持 Web、Security、Job、MQ、Async、DB、Redis 组件 【新增】数据权限,内置基于部门过滤的规则 【新增】用户前台的昵称、头像的修改 【新增】用户前台的微信公众号、微信小程序的社交登录的 API 接口 完整功能,需要等基于 Uniapp 实现的用户前台一起~ 努力 coding 中,胖友可以 star 持续关注一波! 【优化】管理后台的登录成功后,LoginUser 使用统一方法补全信息 # 🐞 Bug Fixes 【修复】通知和字典查询接口的 @PreAuthorize 权限标识错误 【修复】代码生成的 Java 类路径缺少 modules 目录 【修复】代码生成的 Test 单元测试类的引入 Util 工具类的包路径不正确 # 🔨 Dependency Upgrades 【引入】mockito-inline 3.6.28:Mockito 提供对 final、static 的支持 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.3.0】2022.01.24 【v1.1.0】2021.10.25 ← 【v1.3.0】2022.01.24 【v1.1.0】2021.10.25→"},{"title":"【v1.1.0】2021.10.25","path":"/wiki/YuDaoBoot/更新日志/【v1.1.0】2021.10.25/【v1.1.0】2021.10.25.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.1.0】2021.10.25 # 增加管理后台的企业微信、钉钉等社交登录 新增管理后台的企业微信、钉钉等社交登录 新增用户前台(例如说,用户使用的小程序)的后端项目 yudao-user-server 新增公共服务 yudao-core-service 项目,通过 Jar 包的方式,提供 yudao-user-server 和 yudao-admin-server 的共享逻辑的复用 新增用户前台的手机登录、验证码登录 修复管理后台的用户头像上传 404 的问题,原因是请求路径不对 修复用户导入失败的问题,原因是 Lombok 链式与 cglib 读取属性有冲突 修复阿里云短信发送失败的问题,原因是 Opentracing 依赖的版本太低,调整成 0.31.0 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.2.0】2021.12.15 【v1.0.0】2021.05.03 ← 【v1.2.0】2021.12.15 【v1.0.0】2021.05.03→"},{"title":"【v1.0.0】2021.05.03","path":"/wiki/YuDaoBoot/更新日志/【v1.0.0】2021.05.03/【v1.0.0】2021.05.03.html","content":"开发指南更新日志 芋道源码 2022-03-07 目录 【v1.0.0】2021.05.03 # 初始版本 第一个版本,基于 RuoYi-Vue (opens new window) 重构,主要是三个方面: 代码的重构 技术选型的调整 后台功能的新增 因此,v1.0.0 的更新日志,分成这三方面来写。 # 代码的重构 调整整体代码结构,将多个 Maven Module 合并为单个,使用 Java package 进行拆分隔离,如 图 (opens new window) 所示。原因是:随着业务逻辑的逐步复杂,多个 Maven Module 的依赖关系的管理,会是一个很大的问题。 拆分 framework (opens new window) 为多个 Maven Module,按照 Web (opens new window)、Security (opens new window)、MyBatis (opens new window)、Redis (opens new window) 等不同组件,进行封装与拓展。 基于 JUnit5 (opens new window) 与 Mockito (opens new window),实现单元测试,保证功能的正确性,与代码的可维护性。一直自动化,一直爽! 增加 SpringBoot 多环境的配置文件,提供完善的 deploy.sh (opens new window) 部署脚本,以及 Jenkins 部署教程 (opens new window)。 优化 Spring Security (opens new window) 实现权限的代码,提升可读性和维护性。 增加本地缓存(菜单、角色、数据字典等等),提升性能。通过 Redis 订阅发布,实现缓存的实时刷新。 增加 VO (opens new window) 类,作为 API 接口的响应对象,避免数据库实体与前端的直接耦合。 优化 操作日志 (opens new window),支持读取 Swagger 作为日志的内容。 优化 定时任务 (opens new window),支持执行失败的重试,更完善的执行日志。 优化 codegen (opens new window) 代码生成器,在原先生成 Controller、Service、Mapper、数据库实体、Vue 代码的基础上,额外生成 VO、单元测试的代码。 调整文件改用 数据库 (opens new window) 存储,而不是文件系统。原因是,项目在部署多个服务节点时,文件需要做同步。未来,会增加阿里云、七牛云等存储云服务。 去除原有数据库的连表查询、递归查询,改为单表操作的方式,多次读取 + 内存拼接。 优化 Java 代码的格式,解决 IDEA 代码告警的问题。 # 后台功能的新增 增加 API 访问 (opens new window)与异常 (opens new window)日志,方便排查线上 API 的问题。 增加 全局错误码 (opens new window),统一业务异常的管理。管理后台会支持错误码的管理,支持提示文案的可配置化。 增加 短信模块 (opens new window),提供短信渠道、短息模板、短信日志的管理,对接阿里云、云片等主流短信平台。 增加 Redis Key (opens new window) 的管理,知道项目中使用到的 Redis Key 的格式、数据类型、过期时间、描述等等信息。 # 技术选型的调整 将 Spring Boot 版本,从 2.1.3 升级到 2.4.5 最新。 增加 bom (opens new window) 文件,统一 Maven 的依赖管理。 引入 MyBatis Plus (opens new window) 组件,简化 MyBatis 使用,提升开发效率。 引入 Redisson (opens new window) 组件,作为 Redis 的客户端,提供更强大的 Redis 操作。 基于 Redis 实现分布式消息队列的功能。接入 Redis Pub/Sub (opens new window) 实现广播消费,接入 Redis Stream (opens new window) 实现集群消费。 去除 fastjson (opens new window),统一使用 Jackson (opens new window) 作为 JSON 库,老爆安全漏洞的悲伤。 引入 MapStruct (opens new window) 组件,实现数据库实体与 VO 类之间的转换。 引入 Lombok (opens new window) 组件,生成 setter、getter 等常用方法,去除冗余代码。 引入 Spring Async (opens new window) 功能,实现异步任务。例如说,异步记录 API 访问日志、管理员操作日志等等。 魔改 Apollo (opens new window) 组件,接入本地数据库,实现内嵌的配置中心。通俗的说,我们可以将原本添加到 application.yaml 的配置项,改为添加到数据库中,项目启动会进行读取。 引入 Hutool (opens new window) 组件,去除大量重复的工具类,也避免原本 Util 存在一些 bug 的问题。 引入 Screw (opens new window) 组件,实现数据库文档的生成,虽然好像现在用途较少。 引入 EasyExcel (opens new window),提供 Excel 的导入与导出的功能。 实现 Idempotent (opens new window) 组件,实现幂等的功能,可以用来解决 HTTP 重复请求的问题。 引入 Lock4J (opens new window),实现声明式的分布式锁的功能。虽然 Redisson 内置了分布式锁的功能,但是通过注解声明一个 @Lock4j 注解的使用方式,更加便利,且满足绝大多数场景。 去除原有的服务监控,使用 SpringBoot Admin (opens new window) 替代,提供更完整的监控能力。 引入 SkyWalking (opens new window) 组件,实现链路追踪和日志服务的功能。通过链路追踪,我们可以看到一个 API 请求涉及到的 MySQL、Redis 等操作;通过日志服务,我们可以方便的看到每个服务实例的日志。 引入 Resilience4j (opens new window) 组件,实现限流、熔断等功能,保证服务的稳定性。 引入 Knife4j (opens new window),美化接口文档。原本所有 API 接口文档是缺失的,已经全部补全,可见 http://api-dashboard.yudao.iocoder.cn/doc.html (opens new window) 地址。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.1.0】2021.10.25 ← 【v1.1.0】2021.10.25"},{"title":"【v1.3.0】2022.01.24","path":"/wiki/YuDaoBoot/更新日志/【v1.3.0】2022.01.24/【v1.3.0】2022.01.24.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.3.0】2022.01.24 # 新增工作流的功能 基于 Activiti 7.X 版本实现工作流功能,支持可配置的动态表单、自定义的业务表单。 下个版本会提供基于 Flowable 6.X 版本实现的工作流! # 📈 Statistic 总代码行数:61594 源码代码行数:37931 注释行数:14225 单元测试用例数:278 # ⭐ New Features 【优化】引入 form generator 0.2.0 版本,并重构相关代码 【修改】修改部门负责人,从 String 字符串,调整成和后台用户的用户编号绑定 【新增】流程表单,支持动态进行表单的配置 【新增】工作组,用于支持指定工作组进行任务的审批 【新增】流程模型的管理,支持新增、导入、编辑、删除、发布流程模型 【新增】我的流程的管理,支持发起流程 【新增】待办任务的管理,支持任务的审批通过与不通过 【新增】已办任务的管理,支持详情的查看 【新增】任务分配规则,可指定角色、部门成员、部门负责人、用户、用户组、自定义脚本等维度,进行任务的审批 【新增】引入 bpmn-process-designer 0.0.1 版本,提供流程设计器的能力 【优化】新增 LambdaQueryWrapperX 类,改成使用 Lambda 的方式选择字段,避免手写导致字段不正确 # 🐞 Bug Fixes 【修复】biz-data-permission 组件的缓存机制,导致部分 SQL 未进行数据过滤 【修复】codegen 生成代码时,delete 接口补充 dataTypeClass 属性,避免 Swagger 打印 WARN 日志 【修复】Swagger 文档由于写错 @ApiImplicitParam 注解的 name 和 dataTypeClass 属性,导致文档生成失败 # 🔨 Dependency Upgrades 【升级】redisson from 3.16.3 to 3.16.6,解决 Stream 在调试场景下会存在 NPE 的问题 【升级】spring-boot from 2.4.5 to 2.4.12,最新的 Spring Boot 2.6.X 在等更流行一些,稳定第一 【升级】druid from 1.2.4 to 1.2.8,提升数据库连接池的稳定性 【升级】dynamic-datasource from 3.3.2 to 3.5.0,修复动态数据源切换的问题 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.4.0】2022-02-04 【v1.2.0】2021.12.15 ← 【v1.4.0】2022-02-04 【v1.2.0】2021.12.15→"},{"title":"【v1.4.0】2022-02-04","path":"/wiki/YuDaoBoot/更新日志/【v1.4.0】2022-02-04/【v1.4.0】2022-02-04.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.4.0】2022-02-04 # 重构成多 Maven Module 的代码结构 大版本重构,基于 Maven Module 的方式拆分多模块,希望大家多多提点建议! # 📈 Statistic 总代码行数:69118 源码代码行数:42571 注释行数:15847 单元测试用例数:278 # ⭐ New Features 【重构】大模块按照多 Maven Module 的方式拆分,提升可维护性,为后续重构 yudao-cloud 提供基础 【移除】将 yudao-core-service 模块移除,替换成每个 Maven Module 暴露对应的 yudao-module-***-api 模块 【新增】Spring Security 支持读取多种用户类型,从不同的数据库表,从而实现单项目提供管理后台、用户 APP 的不同 RESTful API 接口 【新增】Spring Security 新增 AuthorizeRequestsCustomizer 抽象类, 自定义每个 Maven Module 的 URL 的安全配置 【新增】代码生成器支持多 Maven Module 的方式生成代码,支持管理后台、用户 APP 两种场景的 RESTful API 的生成,支持 H2 SQL 脚本的生成 【新增】每次发布大版本时,将 yudao-ui-admin 编译后,放到 yudao-server 项目中,可以快速体验,无需搭建前端开发环境 【重构】将数据库文档调整到 tool 模块,更加明确 【优化】代码生成器的前端展示效果,例如说 Java 包路径合并 # 🐞 Bug Fixes 【修复】用户无权限访问 指定 API 时,未返回 FORBIDDEN 结果码 【修复】定时任务刷新本地缓存时,无租户上线文,导致查询报错 【修复】配置中心只加载了删除的配置 【修复】管理后台 UI 超时登录后,返回登录界面时,由于未登录加载不到信息,导致报错的问题 # 🔨 Dependency Upgrades 【升级】spring-boot from 2.4.12 to 2.5.9,最新的 Spring Boot 2.6.X 在等更流行一些,稳定第一 【升级】Spring Boot Admin from 2.3.2 to 2.6.2,提供更好的监控能力 【移除】Apache FreeMarker 依赖,修改 Screw 使用 Velocity 作为模板引擎 【升级】redisson from 3.16.6 to 3.16.8 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.5.0】2022-02-17 【v1.3.0】2022.01.24 ← 【v1.5.0】2022-02-17 【v1.3.0】2022.01.24→"},{"title":"【v1.5.0】2022-02-17","path":"/wiki/YuDaoBoot/更新日志/【v1.5.0】2022-02-17/【v1.5.0】2022-02-17.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.5.0】2022-02-17 # 重构成多 Maven Module 的代码结构 修复各种多 Maven Module 重构带来的 Bug,感谢大量群友的 PR 支持! 跟进 ruoyi-vue 3.4.0 ~ 3.8.1 版本,感谢这么优秀的开源项目! # 📈 Statistic 总代码行数:69299 源码代码行数:42687 注释行数:15888 单元测试用例数:278 # ⭐ New Features 【优化】使用 Lombok 简化 JsonUtils 工具类 #73 (opens new window) 【新增】兼容 Node 16 版本,通过升级 BPMN-JS 相关库 commit (opens new window) 【新增】前端的表格右侧工具栏组件支持显隐列,具体可见【用户管理】功能 commit (opens new window) 【新增】前端的菜单导航显示风格 TopNav(false 为 左侧导航菜单,true 为顶部导航菜单),支持布局的保存与重置 commit1 (opens new window) commit2 (opens new window) 【新增】前端的网页标题支持根据选择的菜单,动态展示标题 commit (opens new window) 【新增】字典标签样式回显,例如说开启的状态展示为 primary 蓝色,禁用的状态为 info 灰色 commit (opens new window) 【新增】前端的 iframe 组件,方便内嵌网页 commit (opens new window) 【新增】在基础设施-配置管理菜单,可通过修改 yudao.captcha.enable 配置项,动态修改登录是否需要验证码 commit (opens new window) 【新增】在代码生成的预览界面,支持一键复制代码 commit (opens new window) # 🐞 Bug Fixes 【修复】数据权限的 DEPT_AND_CHILD 范围时,未设置自己所在的部门 #72 (opens new window) 【修复】Knife4j 接口文档 404 的问题,原因是 spring.mvc.static-path-pattern 配置项,影响了基础路径 commit (opens new window) 【修复】修复文件访问地址错误 #68 (opens new window) 【修复】工作流程发起以及审批异常,由 @NotEmpty 校验、和 Long 类型异常导致 #73 (opens new window) 【修复】自定义 DefaultStreamMessageListenerContainerX 实现,解决 Redisson Stream 读取不到数据返回 null 导致 NPE 问题 commit (opens new window) 【修复】部门更新后,本地缓存不刷新的问题 #77 (opens new window) 【修复】获取拥有指定的角色用户时,返回错误的 id 编号 #79 (opens new window) # 🔨 Dependency Upgrades *【修复】Maven 构建的一些错误提示 #78 (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.5.1】2022-02-28 【v1.4.0】2022-02-04 ← 【v1.5.1】2022-02-28 【v1.4.0】2022-02-04→"},{"title":"【v1.6.0】2022-03-10","path":"/wiki/YuDaoBoot/更新日志/【v1.6.0】2022-03-10/【v1.6.0】2022-03-10.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.6.0】2022-03-10 # 支持 Flowable 工作流,发布开发文档 基于 Flowable 实现工作流,可见 yudao-module-bpm-impl-flowable (opens new window) 模块。 友情提示:原本 Activiti 实现的工作流,在 yudao-module-bpm-impl-activiti (opens new window) 模块,保持同步更新。 # 📈 Statistic 总代码行数:75008 源码代码行数:46416 注释行数:17132 单元测试用例数:341 # ⭐ New Features 【新增】 yudao-module-bpm-impl-flowable (opens new window) 模块,实现 Flowable 工作流 #88 (opens new window) 【新增】《开发文档》的简介、功能列表、快速启动、技术选型、项目结构、新建模块、SaaS 多租户等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 # 🐞 Bug Fixes 【修复】正常租户登录后退出,切换到过期租户时造成的 tenant.ignore-urls 配置失效的问题,比如无法获取验证码图片造成无法登录 #91 (opens new window) # 🔨 Dependency Upgrades 暂无,计划升级 Spring Boot 2.6.X .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.1】2022-03-21 【v1.5.1】2022-02-28 ← 【v1.6.1】2022-03-21 【v1.5.1】2022-02-28→"},{"title":"【v1.5.1】2022-02-28","path":"/wiki/YuDaoBoot/更新日志/【v1.5.1】2022-02-28/【v1.5.1】2022-02-28.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.5.1】2022-02-28 # 优化多租户功能,新增租户套餐,增强多租户封装 创建租户时,自动创建用户、角色等信息 支持租户套餐,自定义每个租户的菜单、操作、按钮等权限信息 # 📈 Statistic 总代码行数:71249 源码代码行数:43921 注释行数:16341 单元测试用例数:341 # ⭐ New Features 【新增】后端 yudao.tenant.enable 配置项,前端 VUE_APP_TENANT_ENABLE 配置项,用于开关租户功能。 commit (opens new window) 【优化】调整默认所有表开启多租户的特性,可通过 yudao.tenant.ignore-tables 配置项进行忽略,替代原本默认不开启的策略 commit (opens new window) 【新增】通过 yudao.tenant.ignore-urls 配置忽略多租户的请求,例如说 ,例如说短信回调、支付回调等 Open API commit (opens new window) 【新增】新增 @TenantIgnore 注解,标记指定方法,忽略多租户的自动过滤,适合实现跨租户的逻辑 commit (opens new window) 【新增】租户套餐的管理,可配置每个租户的可使用的功能权限 commit (opens new window) 【优化】新建租户时,自动创建对应的管理员账号、角色等基础信息 commit (opens new window) 【优化】Redis 最低版本 5.0.0 检测,解决搭建环境过程中无法理解 XREADGROUP 指令的报错 commit (opens new window) # 🐞 Bug Fixes 【修复】修复不支持根部门的问题 commit (opens new window) 【修复】错误码存在重复的问题 commit (opens new window) 【修复】角色的数据范围为仅本人时,登录后获取权限列表报错的问题 commit (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.5.9 to 2.5.10 【升级】mybatis-plus from 3.4.3.4 to 3.5.1 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.0】2022-03-10 【v1.5.0】2022-02-17 ← 【v1.6.0】2022-03-10 【v1.5.0】2022-02-17→"},{"title":"【v1.6.1】2022-03-21","path":"/wiki/YuDaoBoot/更新日志/【v1.6.1】2022-03-21/【v1.6.1】2022-03-21.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.6.1】2022-03-21 # 支持 OSS 云存储,优化代码生成 对应 版本 1.6.1 功能列表 (opens new window) # 📈 Statistic 总代码行数:77279 源码代码行数:47812 注释行数:17676 单元测试用例数:537 # ⭐ New Features 【优化】文件存储的功能,支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、SFTP、数据库等 #98 (opens new window) 【新增】《开发文档》的代码生成(新增功能)、功能权限、上传下载等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 【新增】开发环境下,管理后台每个菜单展示对应的《开发文档》的说明 code (opens new window) 【新增】《开发文档》的工作流、代码生成(新增功能)、功能权限、数据权限等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 【优化】将 yudao-module-tool 合并到 yudao-module-infra 模块,统一基础设施 #94 (opens new window) 【优化】代码生成时,额外生成 MyBatis Mapper XML 文件 #96 (opens new window) 【新增】开启 TopNav 时,没有子菜单的情况下,隐藏侧边栏 code (opens new window) # 🐞 Bug Fixes 【修复】仅本人数据权限时,个人中心会报错的问题 #97 (opens new window) 【修复】修改租户套餐的权限时,本地缓存刷新错误的问题 #99 (opens new window) 【修复】删除菜单、角色时,本地缓存未刷新的问题 code (opens new window) 【修复】登录界面输入不存在的租户时,导致后续请求报错的问题 code (opens new window) 【修复】登录超时刷新页面时,跳转登录页面还提示重新登录问题 code (opens new window) # 🔨 Dependency Upgrades 【升级】apollo-client from 1.7.0 to 1.9.2 【升级】guide from 4.1.0 to 5.1.0 :解决 Apollo 在 JDK 17 无法启动的问题 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.2】2022-06-05 【v1.6.0】2022-03-10 ← 【v1.6.2】2022-06-05 【v1.6.0】2022-03-10→"},{"title":"【v1.6.2】2022-06-05","path":"/wiki/YuDaoBoot/更新日志/【v1.6.2】2022-06-05/【v1.6.2】2022-06-05.html","content":"开发指南更新日志 芋道源码 2022-03-26 目录 【v1.6.2】2022-06-05 # 新增 OAuth 2.0、SSO 单点登录、多种数据库支持等功能 对应 版本 1.6.2 功能列表 (opens new window) # 📈 Statistic 总代码行数:84846 源码代码行数:52792 注释行数:19234 单元测试用例数:671 # ⭐ New Features 【新增】对 PostgreSQL 数据库的支持 #151 (opens new window) 感谢这个过程中怪物的帮助! 【新增】对 Oracle 数据库的支持 #152 (opens new window) 感谢这个过程中 安贞 (opens new window)、品霖的帮助! 【新增】对 SQL Server 数据库的支持 #153 (opens new window) 感谢这个过程中 Simon、蜉蝣无垠、牛希尧的帮助! 【新增】《开发指南 —— 后端手册》的接口文档、三方登录、异常处理(错误码)、参数校验、分页实现、系统日志、数据库 MyBatis、多数据源、缓存 Redis、本地缓存、定时任务、消息队列、配置中心、单元测试、分布式锁、幂等性、限流熔断、数据库文档、短信配置、开发环境... 【新增】《开发指南 —— 运维手册》的开发环境、Linux 部署、Docker 部署、Jenkins 部署、HTTPS 证书、服务监控... 【新增】《开发指南 —— 前端手册》的开发规范、菜单路由、Icon 图标、字典数据、系统组件、通用方法、配置读取... 【新增】手机验证码登录,美化登录界面,由 #155 (opens new window) 贡献 【新增】一键改包的程序,快速将项目的 Maven、包名等信息替换成你的 #110 (opens new window) 【新增】菜单新增是否缓存、是否隐藏的字段 #133 (opens new window) #172 (opens new window) 【新增】Spring Cache 声明式缓存,使用 Redis 存储 code (opens new window) 【新增】腾讯云短信,由 swpthebest (opens new window) 贡献 #118 (opens new window) 【新增】敏感词,由 dachuan 贡献 #121 (opens new window) 【新增】数据源配置,为多租户、代码生成支持动态数据源做准备 #138 (opens new window) 【新增】用户 Token 采用 OAuth2.0 的 Access Token + Refresh Token,提升安全性 #166 (opens new window) 【新增】基于 OAuth2.0 实现 SSO 单点登录 #176 (opens new window) 【新增】用户与岗位的关联表,由 anzhen-tech (opens new window) 贡献 #113 (opens new window) 【新增】MyBatis 字段的加解密功能 code (opens new window) 【新增】集成微信 Native、小程序的支付能力,支持 v2 和 v3 的回调数据处理 #142 (opens new window) 【优化】yudao-module-xx-impl 调整成 yudao-module-xx-biz,更加符合定位 code (opens new window) 【优化】简化三方登录的实现,降低理解成本 #137 (opens new window) 【优化】去除 yudao-module-system、yudao-module-infra 对 yudao-module-member 的依赖 #122 (opens new window) 【优化】yudao-framework-test 测试组件的封装,内置 Redis、DB 等多种快速测试的基类 code (opens new window) 【优化】配置指定默认的 npm 镜像源 #170 (opens new window) 【优化】字典管理、通知管理、岗位管理、角色管理、错误码管理的排序显示 #174 (opens new window) 【优化】前端 Token、账号、密码等信息,统一使用 LocalStorage 替代 Cookie 存储 code (opens new window) 【优化】上传文件的类型识别,增加基于 filename 的读取 code (opens new window) # 🐞 Bug Fixes 【修复】角色菜单集合复选框回显不正确 #107 (opens new window) 【修复】工作流 BPMN 图的 canvas 自适应,解决展示补全的问题 #104 (opens new window) 【修复】API 访问日志不记录的问题 code (opens new window) 【修复】修复忽略租户的 URL,未带租户会报错的问题 code (opens new window) 【修复】菜单无法使用外链的问题 code (opens new window) 【修复】代码生成器的 vue 模板中,导出 Excel 文件时,文件名未格式化的问题 #133 (opens new window) 【修复】代码生成时,对话框的日期选择器,在编辑情况下不能回显 #135 (opens new window) 【修复】在 Windows 下 ftp 上传和下载存在报错的问题 #156 (opens new window) 【修复】图片上传组件 ImageUpload 上传报错的问题 code (opens new window) 【修复】文件上传组件 FileUpload 上传报错的问题 code (opens new window) 【修复】form generator 组件上传文件、图片报错的问题 code (opens new window) 【修复】富文本编辑器的 Editor 的图片上传报错的问题 code (opens new window) 【修复】DO 生成模板,当主键是 String 类型,模板有误 #167 (opens new window) 【修复】创建用户不分配角色的情况会存在空指针 #171 (opens new window) 【修复】yudao-ui-admin 启动告警 #173 (opens new window) 【修复】新建的用户未分配角色时,操作自己信息回报错的问题 code (opens new window) 【修复】工作流的编辑无法撤回、crtl 选中的问题 code (opens new window) 【修复】支付宝通知回调 BUG 修复 #142 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.5.10 to 2.6.8 :修复 RCE 漏洞,并且 2.5.X 结束声明周期 【升级】redisson from 3.16.6 to 3.17.3 :提升 Redisson 客户端的稳定性 【升级】mysql-connector-java from 5.1.46 to 8.0.28 :提升 MySQL 客户端的性能 【升级】Knife4j from from 3.0.2 to 3.0.3 【升级】swagger-annotations from 1.5.22 to 1.6.6 【升级】spring-boot-admin from 2.6.2 to 2.6.7 【升级】fastjson from 1.2.73 to 2.0.5 【升级】resilience4j from 1.7.0 to 1.7.1 【升级】jackson from 2.12.6 to 2.13.3 【升级】spring-mvc from 5.3.16 to 5.3.20 【升级】spring-security from 5.5.5 to 5.6.5 【升级】hibernate-validator from 6.2.2 to 6.2.3 【升级】junit from 5.7.2 to 5.8.2 【升级】mockito from 3.9.0 to 4.0.0 【升级】mybatis-plus from 3.4.3.4 to 3.5.2 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.3】2022-07-29 【v1.6.1】2022-03-21 ← 【v1.6.3】2022-07-29 【v1.6.1】2022-03-21→"},{"title":"【v1.6.3】2022-07-29","path":"/wiki/YuDaoBoot/更新日志/【v1.6.3】2022-07-29/【v1.6.3】2022-07-29.html","content":"开发指南更新日志 芋道源码 2022-07-29 目录 【v1.6.3】2022-07-29 # 工作流支持会签或签、新增 Vue3 管理后台 # 📈 Statistic 总代码行数:81410 源码代码行数:50413 注释行数:30977 单元测试用例数:671 # ⭐ New Features 【新增】基于 Vue3 + ElementUI Plus 实现 yudao-ui-admin-vue3 (opens new window) 管理后台项目,已完成系统管理 + 基础设施等功能,工作流正在实现中,主要由 @xingyu4j (opens new window) 贡献 【新增】工作流支持会签、或签,可自定义任务分配方式 #212 (opens new window) 【新增】接口支持通过 @PermitAll 注解,允许匿名(未登录)进行访问 d9c2da7 (opens new window) 【新增】yudao.security.permit-all-urls 配置项,允许匿名(未登录)进行访问 d9c2da7 (opens new window) 【新增】Redis 缓存的查询与删除 由 @lwf_org (opens new window) 贡献 #211 (opens new window) 【优化】文件表增加 name 字段,记录上传的文件名,由 @manning233 (opens new window) 贡献 #186 (opens new window) 【优化】基于 Guava 实现 dict 字典数据的本地缓存 d320091 (opens new window) 【优化】基于 Guava 实现 tenant 租户数据的本地缓存 992e205 (opens new window) 【重构】新增 yudao-spring-boot-starter-biz-error-code 错误码组件,用于错误码的自动创建与加载 7a86a61 (opens new window) 【重构】新增 yudao-spring-boot-starter-banner 组件,用于项目启动时打印开发文档、接口文档等 69a3a83 (opens new window) 【新增】yudao.access-log.enable 访问日志的开关,默认在 local 环境关闭记录访问日志 9040b17 (opens new window) 【新增】yudao.error-code.enable 错误码的开关,默认在 local 环境关闭自动生成错误码 cca8375 (opens new window) 【新增】集成 Prometheus 监控点 4dfa816 (opens new window) 【移除】去除 Activiti 工作流的支持,专注提供基于 Flowable 提供更强大的工作流能力 【重构】时间区间的过滤条件,从开始和结束时间两个变量,修改为数组,由 @xingyu4j (opens new window) 贡献 dad10d8 (opens new window) # 🐞 Bug Fixes 【修复】流程审批不通过会报错的问题,由 @wzy_lc (opens new window) 贡献 #215 (opens new window) 【修复】Spring Boot Admin 的 prefer-ip 过期,由 @xingyu4j (opens new window) 贡献 63877cf (opens new window) 【修复】环境 test、stage、stage、prod 不打印日志的问题 8a6c48f (opens new window) 【修复】短信验证码的每日发送条数不正确 e5a7b84 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.6.8 to 2.6.10 【升级】hutool from 5.6.1 to 5.7.22 【升级】druid from 1.2.8 to 1.2.11 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.4】2022-08-22 【v1.6.2】2022-06-05 ← 【v1.6.4】2022-08-22 【v1.6.2】2022-06-05→"},{"title":"【v1.6.4】2022-08-22","path":"/wiki/YuDaoBoot/更新日志/【v1.6.4】2022-08-22/【v1.6.4】2022-08-22.html","content":"开发指南更新日志 芋道源码 2022-08-22 目录 【v1.6.4】2022-08-22 # 新增 uniapp 管理后台、报表设计器 # 📈 Statistic 总代码行数:87565 源码代码行数:54279 注释行数:19868 单元测试用例数:671 # ⭐ New Features 【新增】完善 Vue3 管理后台的工作流实现,由 @xingyu4j (opens new window) 贡献 #238 【新增】管理后台的移动端 yudao-ui-admin-uniapp 项目,采用 uni-app (opens new window) 方案,一份代码多终端适配,同时支持 APP、小程序、H5!#247 (opens new window) 【新增】集成积木报表,提供低代码报表设计器,由 @jiangqiang1996 (opens new window) 贡献 #237 (opens new window) 【新增】接入支付宝 PC 网站支付,由 @jiangqiang1996 (opens new window) 贡献 #240 (opens new window) 【优化】项目的启动速度,控制在 30 秒左右,默认不启动 bpm、visualization 模块 【优化】管理后台的弹窗支持滚动、拖拽,并点击背景布关闭,避免误操作,由 @颗粒 (opens new window) 贡献 #253 (opens new window) 【优化】一键改包,如果目标目录已存在,则不进行生成,由 @C (opens new window) 贡献 #229 (opens new window) # 🐞 Bug Fixes 【修复】Redis 7.0 监控查询 calls 数值超过 Integer 范围的异常,由 @lanyue52011 (opens new window) 贡献 #239 (opens new window) 【修复】前端表单设计器中动态数据,不能正常获取和更深层级的赋值错误的情况,由 @CorrectRoadH (opens new window) 贡献 #256 (opens new window) 【修复】代码生成功能中,点击同步,会清除已添加并存在的字段,由 @xrcoder (opens new window) 贡献 #249 (opens new window) 【修复】工作流与积木报表的依赖冲突,将 xercesImpl 升级到 2.12.0 版本,由 @shihy (opens new window) 贡献 #254 (opens new window) # 🔨 Dependency Upgrades 暂无 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.5】2022-12-01 【v1.6.3】2022-07-29 ← 【v1.6.5】2022-12-01 【v1.6.3】2022-07-29→"},{"title":"【v1.6.5】2022-12-01","path":"/wiki/YuDaoBoot/更新日志/【v1.6.5】2022-12-01/【v1.6.5】2022-12-01.html","content":"开发指南更新日志 芋道源码 2022-08-22 目录 【v1.6.5】2022-12-01 # 重构 Vue3 管理后台,优化稳定性 # 📈 Statistic 总代码行数:98088 源码代码行数:55926 注释行数:23265 单元测试用例数:671 # ⭐ New Features 【新增】管理后台登录时,使用滑块验证码,由 @xingyu4j (opens new window) 贡献 #238 (opens new window) 【新增】SSO 单点登录的示例,包括基于授权码模式、密码模式两种实现 #272 (opens new window) 【优化】提升 Vue3 实现管理后台的稳定性、兼容性,基于 vxe-table 解决 el-table 卡顿的问题,由 @xingyu4j (opens new window) 贡献 #271 (opens new window) #282 (opens new window) #283 (opens new window) #288 (opens new window) #291 (opens new window) #293 (opens new window) #299 (opens new window) #300 (opens new window) #314 (opens new window) #316 (opens new window) 【优化】使用 LocalDateTime 替换 Date,由 @xingyu4j (opens new window) 贡献 #292 (opens new window) 【新增】Spring Cache 在多租户下的支持,由 @whitedolphin (opens new window) 贡献 #257 (opens new window) 【新增】流程图 ServiceTask 的完成和 todo 高亮,增加 ServiceTask 节点的 hover 显示内容,由 @FinalFinancialFreedom (opens new window) 贡献 #260 (opens new window) 【移除】云片短信渠道,解决云片的安全风险 ea95115 (opens new window) 【移除】jasypt-spring-boot-starter 加密库使用 hutool AES 替代 ce3aefa (opens new window) 【移除】Apollo 配置中心,简化学习成本 a8cdf74 (opens new window) # 🐞 Bug Fixes 【修复】WxMaService 的 null key in entry 报错,由 @rayyer (opens new window) 贡献 #259 (opens new window) 【修复】导入用户后编辑报错,由 @wangjun (opens new window) 贡献 #258 (opens new window) 【修复】编辑流程模型时,不退出模拟直接保存,导致后续分配规则报错,由 @wangjun (opens new window) 贡献 #258 (opens new window) 【修复】数据权限,不支持隐式内连接的问题 【修复】\"定时任务 -> 调度日志 -> 详细\"里面,”执行时长“字段显示不正确的问题,由 @idevmo (opens new window) 贡献 #265 (opens new window) 【修复】Vue3 代码生成选择父菜单无效,生成的前端代码缺少字段以及格式错误,由 @jueyinghua (opens new window) 贡献 #286 (opens new window) 【修复】前端配置管理中参数分类显示错误,由 @guyuezb (opens new window) 贡献 #278 (opens new window) 【修复】短信接收报告回调时,设置 errorMsg 不正确,由 @Macro (opens new window) 贡献 #280 (opens new window) 【修复】当只修改模型并保存,再发布时,提示\"流程定义部署失败,原因:信息未发生变化\",由 @SuperHao (opens new window) 贡献 #284 (opens new window) 【修复】WXLitePayClient.java 中 copy 应忽略的字段,由 @chenlei65368 (opens new window) 贡献 #284 (opens new window) 【修复】阿里云 OSS 解析 region 时兼容带 https的 配置,由 @huangyemin (opens new window) 贡献 #276 (opens new window) 【修复】三级及以上菜单路由缓存失效问题,由 @咱哥丶 (opens new window) 贡献 #290 (opens new window) 【修复】钉钉登录时,重定向后 type 丢失导致报错的问题 7093ed3 (opens new window) 【修复】无法自定义 Icon 图标的问题 e403684 (opens new window) 【修复】访问数据库存储的文件,path 多层级时,无法访问的问题 92ace03 (opens new window) 【修复】S3 上传七牛云无 mime type 的问题,由 @石溪 (opens new window) 贡献 #313 (opens new window) 【修复】流程代办,日期时区转换错误,由 @zy_2021 (opens new window) 贡献 #309 (opens new window) # 🔨 Dependency Upgrades 【升级】spring boot from 2.6.10 to 2.7.6 【升级】flowable from 6.7.0 to 6.7.2 【升级】hutool from 5.7.22 to 5.8.9 【升级】velocity from 2.2 to 2.3 【升级】druid from 1.2.11 to 1.2.14 【升级】spring boot admin from 2.6.7 to 2.6.9 【升级】mapstruct from 1.4.1 to 1.5.3.Final 【升级】lombok from 1.16.14 to 1.18.24 【升级】mockito from 4.0.0 to 4.8.0 【升级】dynamic-datasource from 3.5.0 to 3.5.2 【升级】redisson from 3.17.4 to 3.17.7 【升级】easyexcel from 3.1.1 to 3.1.2 【升级】vue from 2.7.0 to 2.7.14 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.6.6】2023-01-05 【v1.6.4】2022-08-22 ← 【v1.6.6】2023-01-05 【v1.6.4】2022-08-22→"},{"title":"【v1.6.6】2023-01-05","path":"/wiki/YuDaoBoot/更新日志/【v1.6.6】2023-01-05/【v1.6.6】2023-01-05.html","content":"开发指南更新日志 芋道源码 2023-01-01 目录 【v1.6.6】2023-01-05 # 完善 Vue3 管理后台,新增 IP & 地区库 # 📈 Statistic 总代码行数:104298 源码代码行数:63656 注释行数:24708 单元测试用例数:602 # ⭐ New Features 【新增】yudao-spring-boot-starter-biz-ip (opens new window) 业务组件,提供地区 & IP 库的封装,由 @WangLH (opens new window) 贡献 0b5aa56 (opens new window) 【新增】《后端手册 —— 地区 & IP 库》 (opens new window) 文档 【新增】《后端手册 —— 敏感词》 (opens new window) 文档 【新增】《前端手册 Vue 3.x》 (opens new window) 文档 【优化】本地缓存的刷新实现,数据变更时,强制刷新,贡献 #3443aa6 (opens new window) 【新增】Vue3 XTable 组件,由 @xingyu4j (opens new window) 贡献 #349 (opens new window) 【优化】优化 Vue3 管理后台实现,由 @xingyu4j (opens new window) 贡献 #317 (opens new window) #322 (opens new window) #331 (opens new window) #335 (opens new window) #339 (opens new window) #343 (opens new window) 【优化】完善 Vue3 上传组件 && 提升打包速度,由 @xingyu4j (opens new window) 贡献 #337 (opens new window) 【重构】Vue3 头像上传,由 @xingyu4j (opens new window) 贡献 #338 (opens new window) 【新增】WebSocket 连接测试,由 @咱哥丶 (opens new window) 贡献 #348 (opens new window) # 🐞 Bug Fixes 【修复】字典类型逻辑删除时,唯一索引冲突的问题,由 @tangkc123 (opens new window) 贡献 #323 (opens new window) 【修复】pay 模块提交退款申请时,重复设置属性,由 @qshome (opens new window) 贡献 #325 (opens new window) 【修复】修改pay 模块创建支付单时,错误返回订单编号,由 @qshome (opens new window) 贡献 #324 (opens new window) 【修复】修改 pay 模块在微信支付时,支付过期时间格式化异常 (yyyy-MM-ddTHH:mm:ssXXX),由 @qshome (opens new window) 贡献 #329 (opens new window) 【修复】数据权限 SQL 存在多个表达式时,缺少括号问题,由 @与或非 (opens new window) 贡献 #328 (opens new window) 【修复】yudao-ui-admin-vue3 面包屑导航图标和文字不在同一水平线,由 @supine-win (opens new window) 贡献 #333 (opens new window) 【修复】yudao-module-system-api 的 ErrorCodeConstants 中错误码重复的问题,由 @王添翼 (opens new window) 贡献 #340 (opens new window) 【修复】DeptService 的 getDeptsByParentIdFromCache 在获取部门列表时,未处理多租户场景,贡献 #75b3a29 (opens new window) 【修复】前端 FileUpload 文件上传时,code 未使用 0 判断成功,由 @plimlips (opens new window) 贡献 #344 (opens new window) 【修复】Redis Stream 消息队列在重启 Java 进程时,由于 Consumer 未释放消息,导致消息丢失的问题,由 @与或非 (opens new window) 贡献 #332 (opens new window) 【修复】腾讯 COS 异常,Region 必传,由 @与或非 (opens new window) 贡献 #347 (opens new window) 【修复】DB 存储文件时,读取可能报错的问题,由 @与或非 (opens new window) 贡献 #346 (opens new window) 【修复】没有数据权限时,添加/修改用户的唯一手机、账号等字段的校验不正确,贡献 7912a54 (opens new window) 【修复】配置管理,配置是否可见判断写反了,由 @kinlon92 (opens new window) 贡献 #350 (opens new window) 【修复】上传视频无法预览,由 @与或非 (opens new window) 贡献 #352 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.7.6 to 2.7.7 【升级】mybatis-plus from 3.5.2 to 3.5.3 【升级】dynamic-datasource from 3.6.0 to 3.6.1 【升级】flowable from 6.7.2 to 6.8.0 【升级】lock4j from 2.2.2 to 2.2.3 【升级】podam from 7.2.9 to 7.2.11 【升级】jedis-mock from 1.0.4 to 1.0.5 【升级】transmittable-thread-local from 2.14.0 to 2.14.2 【升级】netty-all from 4.1.82 to 4.1.86 【升级】aliyun-java-sdk-core from 4.6.2 to 4.6.3 【升级】tencentcloud-sdk-java from 3.1.635 to 3.1.660 【升级】spring-boot-admin from 2.7.7 to 2.7.9 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.7.0】2023-01-30 【v1.6.5】2022-12-01 ← 【v1.7.0】2023-01-30 【v1.6.5】2022-12-01→"},{"title":"【v1.7.0】2023-01-30","path":"/wiki/YuDaoBoot/更新日志/【v1.7.0】2023-01-30/【v1.7.0】2023-01-30.html","content":"开发指南更新日志 芋道源码 2023-01-07 目录 【v1.7.0】2023-01-30 # 增加微信公众号的接入、邮箱、站内信、数据脱敏 # 📈 Statistic 总代码行数:119925 源码代码行数:73678 注释行数:27769 单元测试用例数:674 # ⭐ New Features 【新增】微信公众号功能,包括账号管理、数据统计、粉丝管理、消息管理、自动回复、标签管理、菜单管理、素材管理、图文草稿箱、图文发表记录,由 @芋道源码 (opens new window) 贡献 #382 (opens new window) 【新增】RESTful API 返回数据时,支持数据脱敏,由 @与或非 (opens new window) 贡献 #372 (opens new window) 【新增】邮箱功能:邮箱账号、邮件模版、邮件发送记录,由 @芋道源码 (opens new window) 贡献 #385 (opens new window) 【新增】站内信功能:站内信模版、站内信消息,由 @圆梦巨人 (opens new window)、@xrcoder (opens new window) 贡献 #385 (opens new window) 【新增】Vue3 管理后台新增 WebSocket 连接测试,由 @xingyu4j (opens new window) 贡献 #379 (opens new window) 【新增】配置 yaml 文件中自定义属性的提示,由 @与或非 (opens new window) 贡献 #373 (opens new window) 【优化】重构 Vue3 管理后台的路由代码生成逻辑,优化性能,由 @xingyu4j (opens new window) 贡献 #375 (opens new window) 【优化】Vue3 管理后台的第一次进入加载速度,由 @xingyu4j (opens new window) 贡献 #381 (opens new window) 【新增】Vue3 管理后台基于 unplugin-auto-import 实现自动导入,由 @xingyu4j (opens new window) 贡献 #376 (opens new window) 【优化】重构滑块验证码 captcha 的实现,由 @xingyu4j (opens new window) 贡献 #374 (opens new window) #376 (opens new window) 【优化】简化本地缓存的实现,优化 《后端手册 —— 本地缓存》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 #382 (opens new window) 【优化】代码生成列表的加载速度,由 @与或非 (opens new window) 贡献 #378 (opens new window) 【新增】《后端手册 —— 验证码》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 【新增】《后端手册 —— 数据脱敏》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 【新增】《公众号手册》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 # 🐞 Bug Fixes 【修复】积木报表:部分请求会报错:JmReportTokenServices 实现类 getUsername 方法返回值不允许为空,由 @与或非 (opens new window) 贡献 #358 (opens new window) 【修复】积木报表:分享报错,由 @与或非 (opens new window) 贡献 #357 (opens new window) 【修复】积木报表:API数据集解析时,提示数据为空,报表字段明细会被清空,由 @与或非 (opens new window) 贡献 #359 (opens new window) 【修复】yudao-ui-appi 的 refreshToken is not a function 问题修复,由 @chaining (opens new window) 贡献 #356 (opens new window) 【修复】Vue2 管理后台 Redis 监控 echarts 图表不显示,由 @zy_2021 (opens new window) 贡献 #354 (opens new window) 【修复】MyBatis Plus 升级导致 generatorTest 用例找不到对象爆红,由 @miozus (opens new window) 贡献 #365 (opens new window) 【修复】代码生成器读取不到 dataType 属性,导致无法正确生成代码,由 @与或非 (opens new window) 贡献 #370 (opens new window) 【修复】Xss 启用后,编辑器上传图片错误,由 @与或非 (opens new window) 贡献 #361 (opens new window) #383 (opens new window) 【修复】管理后台 uniapp 的令牌过期时,无法刷新令牌的 bug,由 @chaining (opens new window) 贡献 #360 (opens new window) 【修复】获取菜单返回了不可修改集合,导致无法排序的报错,由 @ambi (opens new window) 贡献 #371 (opens new window) 【修复】Vue2 管理后台的 tags 页签超过屏幕后,无法滚动导致无法选择后面的页签,由 @zhang.xionghui (opens new window) 贡献 #366 (opens new window) # 🔨 Dependency Upgrades 【升级】mybatis-plus from 3.5.3 to 3.5.3.1 【升级】spring-security from 3.7.5 to 3.7.6 【升级】spring-boot-admin from 2.7.9 to 2.7.10 【升级】minio from 8.4.6 to 8.5.1 【升级】knife4j from 3.0.3 to 4.0.0 【升级】vxe-table from 4.3.7 to 4.3.9 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 【v1.7.1】2023-03-05 【v1.6.6】2023-01-05 ← 【v1.7.1】2023-03-05 【v1.6.6】2023-01-05→"},{"title":"【v1.7.1】2023-03-05","path":"/wiki/YuDaoBoot/更新日志/【v1.7.1】2023-03-05/【v1.7.1】2023-03-05.html","content":"开发指南更新日志 芋道源码 2023-01-30 目录 【v1.7.1】2023-03-05 # 新增 Vue3 管理后台支持工作流、大屏设计器,升级 OpenAPI 3.0 接口文档 # 📈 Statistic 总代码行数:126673 源码代码行数:78532 注释行数:28594 单元测试用例数:782 # ⭐ New Features 【重构】Vue3 管理后台调整到 GitHub (opens new window)、Gitee (opens new window) 地址,逐步分离前端和后端仓库,保证 Git commit 日志的整洁! 【新增】Vue3 工作流的,由 @周建 (opens new window)、@xingyu4j (opens new window) 贡献 #397 (opens new window)、#401 (opens new window)、#407 (opens new window)、#6 (opens new window)、#7 (opens new window)、#12 (opens new window)、#14 (opens new window) 【新增】基于 Go-View 共建大屏设计器,支持 Vue2 和 Vue3 管理后台,由 @芋道源码 (opens new window) 贡献 #403 (opens new window) 【新增】支付收银台,接入支付宝的 PC、Wap、二维码、条码、App 等支付方式,由 @芋道源码 (opens new window) 贡献 #403 (opens new window) 【新增】接口文档使用 OpenAPI 3.0 实现,@xingyu4j (opens new window) 贡献 #380 (opens new window) 【优化】菜单新增 alwaysShow 总是展示、componentName 组件名,由 @芋道源码 (opens new window) 贡献 #408 (opens new window) 【优化】system 模块的 Service 逻辑单元测试,单测数量 423,方法行覆盖率 95%,行覆盖率 93%,由 @芋道源码 (opens new window) 贡献 #392 (opens new window) 【优化】infra 模块的 Service 逻辑单元测试,单测数量 81,方法行覆盖率 63%,行覆盖率 47%,由 @芋道源码 (opens new window) 贡献 #393 (opens new window) 【优化】清理单元测试多余的 SQL 脚本,由 @niu_dehua (opens new window) 贡献 #345 (opens new window) 【优化】《后端手册 —— 快速启动》 (opens new window)文档,由 @芋道源码 (opens new window) 贡献 【优化】解决 Vue2 管理后台,只有一个菜单时,不展父菜单/目录的情况,由 @zhang.xionghui (opens new window) 贡献 #394 (opens new window) 【优化】缓存部门的变量命名,由 @重楼 (opens new window) 贡献 #421 (opens new window) 【新增】《萌新必读 —— 快速启动(我是前端)》 (opens new window) 文档,适合前端同学启动前端项目 # 🐞 Bug Fixes 【修复】Vue3 管理后台的tagViews 左右两侧按钮不能垂直居中的问题,由 @AKING (opens new window) 贡献 #406 (opens new window) 【修复】项目启动,链接数据查询时控制台报错 SQLNonTransientConnectionException 异常,由 @zhang (opens new window) 贡献 #406 (opens new window) 【修复】Redis Pub/Sub 广播消费的容器,默认未启动的问题,由 @筱龙缘 (opens new window) 贡献 #415 (opens new window) 【修复】MySQL 连接为 Asia/Shanghai 本地时区,由 @小桂子 (opens new window) 贡献 #409 (opens new window) #410 (opens new window) 【修复】代码生成器的同步报错问题,由 @Rex (opens new window) 贡献 #413 (opens new window) 【修复】登录选择钉钉等第三方弹窗后,点击取消弹窗后恢复登录按钮 loading 状态,由 @thisliuyang (opens new window) 贡献 #217 (opens new window) 【修复】去掉 Swagger 自动配置类中的冗余配置,由 @zhangxingjia (opens new window) 贡献 #424 (opens new window) 【修复】用户详情不显示所属部门部门,由 @babylazsss (opens new window) 贡献 #424 (opens new window) 【修复】GitHub Action 自动 build 前端报错的问题,由 @六楼的雨 (opens new window) 贡献 #424 (opens new window) 【修复】Vue3 管理后台:新增”字典类型“的时候,字典类型的必填校验不通过,由 @六楼的雨 (opens new window) 贡献 #1 (opens new window) 【修复】Vue3 管理后台:字典点击表格红色报错修改;keepalive 缓存 toCamelCase 设置中去掉 ‘-’,保留驼峰命名;新增 Search 组件新增插槽传递;topActionSlots: false 报错修改;tagsView.ts 删除页面缓存优化;,由 @毕梅 (opens new window) 贡献 #2 (opens new window) 【修复】Vue3 管理后台:部分逻辑的规范代码(eslint),由 @孔思宇 (opens new window) 贡献 #4 (opens new window) 【修复】Vue3 管理后台:build script 增加内存配置(解决 nodejs 默认配置内存溢出),由 @孔思宇 (opens new window) 贡献 #5 (opens new window) 【修复】Vue3 管理后台:分配角色的权限 el-tree 组件 setCheckedKeys 设置一旦选中父级子级也被选中,由 @当时明月在 (opens new window) 贡献 #8 (opens new window) 【修复】Vue3 管理后台:XTable 中主题颜色不跟随项目主体一起切换,由 @毕梅 (opens new window) 贡献 #12 (opens new window) 【修复】Vue3 管理后台:角色提交问题修改;XTable var 修改,由 @毕梅 (opens new window) 贡献 #16 (opens new window) 【修复】Vue3 管理后台:Vite 由于 optimize.ts 缺少部门文件,导致二次 reload 的问题,由 @毕梅 (opens new window) 贡献 #19 (opens new window) 【修复】Vue3 管理后台:系统管理中 id 显示序号bug,由 @周建 (opens new window) 贡献 #18 (opens new window) 【修复】Vue3 管理后台:字典标签渲染问题不正确,由 @puhui999 (opens new window) 贡献 #15 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.7.7 to 2.7.8 【升级】easy-excel from 3.1.5 to 3.2.0 【升级】captcha-plus from 1.0.1 to 1.0.2 【升级】jedis-mock from 1.0.5 to 1.0.6 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/07, 20:14:22 【v1.7.2】2023-04-19 【v1.7.0】2023-01-30 ← 【v1.7.2】2023-04-19 【v1.7.0】2023-01-30→"},{"title":"一键改包","path":"/wiki/YuDaoBoot/萌新必读/一键改包/一键改包.html","content":"开发指南萌新必读 芋道源码 2022-03-27 目录 一键改包 项目提供了 ProjectReactor (opens new window) 程序,支持一键改包,包括 Maven 的 groupId、artifactId、Java 的根 package、前端的 title、数据库的 SQL 配置、应用的 application.yaml 配置文件等等。效果如下图所示: 友情提示:修改包名后,未来合并最新的代码可能会有一定的成本。 # 👍 相关视频教程 08、如何实现一键改包? (opens new window) # 操作步骤 ① 第一步,使用 IDEA (opens new window) 克隆 https://github.com/YunaiV/ruoyi-vue-pro (opens new window) 仓库的最新代码,并给该仓库一个 Star (opens new window)。 ② 第二步,打开 ProjectReactor 类,填写 groupIdNew、artifactIdNew、packageNameNew、titleNew 属性。如下图所示: ③ 第三步,执行 ProjectReactor 的 #main(String[] args) 方法,它会基于当前项目,复制一个新项目到 projectBaseDirNew 目录,并进行相关的改名逻辑。 13:02:36.765 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][开始获得需要重写的文件]13:02:41.530 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][需要重写的文件数量:2825,预计需要 5-10 秒]13:02:45.799 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][重写完成] ④ 第四步,使用 IDEA 打开 projectBaseDirNew 目录,参考 《开发指南 —— 快速启动》 文档,进行项目的启动。注意,一定要重新执行 SQL 的导入!!! 整个过程非常简单,如果碰到问题,请添加项目的技术交流群。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 19:00:28 代码热加载 删除功能 ← 代码热加载 删除功能→"},{"title":"【v1.7.3】开发中","path":"/wiki/YuDaoBoot/更新日志/【v1.7.3】开发中/【v1.7.3】开发中.html","content":"开发指南更新日志 芋道源码 2023-04-22 目录 【v1.7.3】开发中 # # 📈 Statistic 总代码行数: 源码代码行数: 注释行数: 单元测试用例数: # ⭐ New Features 【重构】Vue3 管理后台:公众号 MP 模块重构,功能增强,由 @dhb52 (opens new window) 贡献 #135 (opens new window) 【新增】Vue3 管理后台:菜单管理:添加刷新菜单缓存按钮,由 @puhui999 (opens new window) 贡献 #134 (opens new window) 【优化】Vue3 管理后台:升级 Vite 4.3.1,升级其它依赖,由 @xingyu4j (opens new window) 贡献 #53b6f0b (opens new window) # 🐞 Bug Fixes 【修复】代码生成:Vue3 标准模板缺少 baseURL 的格式化,由 @baayso (opens new window) 贡献 #462 (opens new window) 【修复】新建商品时商品分类状态判断错误,由 @LiZhongShi (opens new window) 贡献 #459 (opens new window) 【修复】缺少 ServletUtils 引用,由 @inypeacock (opens new window) 贡献 #461 (opens new window) 【修复】一键改包的”占位“文件影响改包工具运行,由 @anzhen-tech (opens new window) 贡献 #458 (opens new window) 【修复】尝试修复项目第一次打包失败报 Failed to execute goal org.apache.maven.plugins:maven-jar-plugin:3.3.0:jar,由 @芋道源码 (opens new window) 贡献 #91f63ff (opens new window) # 🔨 Dependency Upgrades .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:33 IDE 调试 【v1.7.2】2023-04-19 ← IDE 调试 【v1.7.2】2023-04-19→"},{"title":"交流群","path":"/wiki/YuDaoBoot/萌新必读/交流群/交流群.html","content":"开发指南萌新必读 芋道源码 2022-03-11 目录 交流群 # 🐱 反馈交流 如果有问题,可以通过 Gitee Issue (opens new window) 或者 Github Issue (opens new window) 进行反馈。 欢迎加入用户交流群,一起苦练技术基本功,每日精进 30 公里。 如果微信提示“提示对方被加好友过于频繁,请稍后再试?”,可以过一会再尝试下!🙂 项目关注和使用的人太多了~ .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 20:54:45 简介 视频教程 ← 简介 视频教程→"},{"title":"【v1.7.2】2023-04-19","path":"/wiki/YuDaoBoot/更新日志/【v1.7.2】2023-04-19/【v1.7.2】2023-04-19.html","content":"开发指南更新日志 芋道源码 2023-03-06 目录 【v1.7.2】2023-04-19 # 重构 Vue3 管理后台,提升易用性、稳定性 # 📈 Statistic 总代码行数:125001 源码代码行数:77128 注释行数:28642 单元测试用例数:789 # ⭐ New Features 【新增】《代码热加载》 (opens new window) 文档,提升开发效率。 【新增】Vue 管理后台:优化 VSCode 代码 Debugger 调试,使用 VSCode 自带的功能,由 @puhui999 (opens new window) 贡献 #117 (opens new window) 【新增】代码生成时,增加 UI 类型的选择,可生成 Vue2、Vue3 多种管理后台的代码,支持 CRUD Schema 模式,由 @芋道源码 (opens new window) 贡献 #453 (opens new window) 【新增】代码生成器,支持 VBEN 管理后台,由 @xingyu (opens new window) 贡献 #454 (opens new window) 【优化】Vue3 管理后台:去除 BPMNJS、FormCreate、Highlight 的全局引入,降低打包后的大小(6.6M -> 1.3M),由 @芋道源码 (opens new window) 贡献 #128 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 配置管理] 由 @芋道源码 (opens new window) 贡献 #24 (opens new window) 【重构】Vue3 管理后台:[SSO 登录] 由 @puhui999 (opens new window) 贡献 #107 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 数据源配置] 由 @xiaowuye (opens new window) 贡献 #25 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 通知公告] 由 @babylazsss (opens new window) 贡献 #26 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 文件管理] 由 @xiaowuye (opens new window) 贡献 #29 (opens new window)、#28 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 字典管理] 由 @Theo (opens new window) 贡献 #38 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 错误码管理] 由 @kinlon92 (opens new window) 贡献 #39 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 岗位管理] 由 @Chika (opens new window) 贡献 #44 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 登录日志] 由 @lour6498 (opens new window) 贡献 #41 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 客户端管理] 由 @yj441106 (opens new window) 贡献 #60 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 错误日志] 由 @oldBaby (opens new window) 贡献 #43 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 访问日志] 由 @oldBaby (opens new window) 贡献 #48 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 代码生成] 由 @xiaowuye (opens new window) 贡献 #68 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 定时任务] 由 @孔思宇 (opens new window) 贡献 #65 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 租户管理] 由 @东方白 (opens new window) 贡献 #40 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 租户套餐] 由 @puhui999 (opens new window) 贡献 #77 (opens new window)、#75 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 短信管理] 由 @puhui999 (opens new window) 贡献 #45 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 部门管理] 由 @凌太虚 (opens new window) 贡献 #36 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 敏感词管理] 由 @syd (opens new window) 贡献 #55 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 菜单管理] 由 @Theo (opens new window) 贡献 #54 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 用户管理] 由 @fessor (opens new window) 贡献 #67 (opens new window)、#76 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 角色管理] 由 @Chika (opens new window) 贡献 #63 (opens new window)、#85 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 站内信消息] 由 @咱哥丶 (opens new window) 贡献 #53 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 站内信消息] 由 @咱哥丶 (opens new window) 贡献 #53 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 账号管理] 由 @kinlon92 (opens new window) 贡献 #49 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 标签管理] 由 @矿泉水 (opens new window) 贡献 #50 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 数据统计] 由 @kinlon92 (opens new window) 贡献 #69 (opens new window)、#72 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 粉丝管理] 由 @dhb52 (opens new window) 贡献 #103 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 消息管理] 由 @&wxr (opens new window) 贡献 #58 (opens new window)、#70 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 图文草稿箱] 由 @dhb52 (opens new window) 贡献 #102 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 素材管理] 由 @dhb52 (opens new window) 贡献 #105 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 自动回复] 由 @dhb52 (opens new window) 贡献 #110 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品分类] 由 @孔思宇 (opens new window) 贡献 #82 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品属性] 由 @孔思宇 (opens new window) 贡献 #83 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品品牌] 由 @Aix (opens new window) 贡献 #104 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 商户信息] 由 @凌太虚 (opens new window) 贡献 #81 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 应用信息] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 支付订单] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 退款订单] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 我的流程] 由 @Chika (opens new window) 贡献 #93 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 已办任务] 由 @Chika (opens new window) 贡献 #90 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 待办任务] 由 @Chika (opens new window) 贡献 #93 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 请假查询] 由 @ZanGe丶 (opens new window) 贡献 #108 (opens new window) 【新增】Vue3 管理后台:增加全局权限判断函数 checkPermi 和 checkRole,由 @LinkLi (opens new window) 贡献 #22 (opens new window) 【新增】字典数据 starter 模块单元测试,由 @与或非 (opens new window) 贡献 #440 (opens new window) 【新增】多租住 Job 部分的单元测试,由 @与或非 (opens new window) 贡献 #27 (opens new window) 【优化】校验手机号码是否正确的正则,由 @冰是睡着的水 (opens new window) 贡献 #447 (opens new window) 【新增】PasswordEncoder 加密复杂度自定义,由 @Fanjc (opens new window) 贡献 #24 (opens new window) 【新增】Vue3 增加 @element-plus/icons-vue 依赖,由 @dhb52 (opens new window) 贡献 #101 (opens new window) 【优化】Vue3 管理后台:增加 Mp 账号 Select 下拉框组件,由 @dhb52 (opens new window) 贡献 #113 (opens new window)、#118 (opens new window) 【优化】Vue3 管理后台:使用 Editor 替代 WxEditor,移除 @vueup/vue-quill 依赖,由 @dhb52 (opens new window) 贡献 #121 (opens new window) 【优化】Vue3 管理后台:公众号消息独立 MessageTable 等组件,解决消息弹窗不重置的问题,由 @dhb52 (opens new window) 贡献 #121 (opens new window) 【优化】Vue3 管理后台:公众号的素材管理,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #126 (opens new window) 【优化】Vue3 管理后台:公众号的自动回复,拆分 ReplyTable 列表组件,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的消息回复组件,不同消息拆分不同表单,提升可维护性,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的草稿管理件,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的菜单管理,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue2 管理后台:将工作流的业务表单做为动态组件,直接显示到审批页面,不再需要点击查看,由 @疯狂的世界 (opens new window) 贡献 #432 (opens new window) 【优化】Vue3 管理后台:将工作流的业务表单做为动态组件,直接显示到审批页面,不再需要点击查看,由 @puhui999 (opens new window) 贡献 #130 (opens new window) 【重构】Vue3 管理后台:给所有组件添加 name 属性预防未知 bug!!! 由 @puhui999 (opens new window) 贡献 #125 (opens new window) # 🐞 Bug Fixes 【修复】Flowable 无法自动建表问题,由 @LinkLi (opens new window) 贡献 #427 (opens new window) 【修复】Vue3 管理后台:包含字典表的页面加载时报错,由 @毕梅 (opens new window) 贡献 #21 (opens new window) 【修复】Vue3 管理后台:ProcessDesigner.vue 编译错误(eslint),由 @孔思宇 (opens new window) 贡献 #23 (opens new window) 【修复】积木报告建表语句错误,由 @疯狂的世界 (opens new window) 贡献 #430 (opens new window) 【修复】基于 Spring Cloud Bus 实现的 Producer 抽象类,获取自己服务实例时获取不到,由 @Lee.J.Eric (opens new window) 贡献 #26 (opens new window) 【修复】修复某些情况下 ContextHolder 的 NPE 异常,由 @xuing (opens new window) 贡献 #225 (opens new window) 【修复】生成代码测试里面的时间问题(buildBetweenTime 方法),由 @xiaohe4966 (opens new window) 贡献 #228 (opens new window) 【修复】Vue3 管你后台的各种验收 bug,由 @周建 (opens new window) 贡献 #32 (opens new window)、#51 (opens new window)、#56 (opens new window)、#71 (opens new window)、#84 (opens new window) 【修复】PostgreSQLSQL 的 system_menu 表缺少 component_name、always_show 字段、缺少 system_mail_account、system_mail_log、system_mail_template、system_notify_message、system_notify_template 表,由 @libran (opens new window) 贡献 #435 (opens new window)、#435 (opens new window)、#436 (opens new window)、#437 (opens new window) 【修复】订单的创建时间差 8 小时的问题,由 @chop (opens new window) 贡献 #442 (opens new window) 【修复】Vue2 短信验证码登录问题,由 @打听幸福的下落 (opens new window) 贡献 #438 (opens new window) 【修复】工作流的审批任务列表的时间不正确的问题,由 @SuperHao (opens new window) 贡献 #426 (opens new window) 【修复】IP 查询时,因为空格导致异常问题,由 @chasel-jc (opens new window) 贡献 #31 (opens new window) 【修复】Spring Cloud 打包后,无法使用 java -jar 的问题,由 @lovezhike (opens new window) 贡献 #28 (opens new window) 【修复】点击遮罩层弹窗关闭后,页面就操作不了了会一直转圈的问题,由 @puhui999 (opens new window) 贡献 #78 (opens new window) 【修复】设置 vite basePath 后,重新登录跳转路由错误,由 @mgzu (opens new window) 贡献 #89 (opens new window) 【修复】在 Vue3 + Vite4 模块中,使用顶层 await打 包的时候报错,由 @puhui999 (opens new window) 贡献 #78 (opens new window) 【修复】Vue3 公众号素材选择时,获取 FreePublic 出错,以及分页溢出,由 @dhb52 (opens new window) 贡献 #96 (opens new window) 【修复】Vue3 公众号图文显示有误,articles 为数组,由 @dhb52 (opens new window) 贡献 #100 (opens new window) 【修复】xss 请求 Wrapper getAttribute 方法返回错误,由 @zhangxingjia (opens new window) 贡献 #451 (opens new window) 【修复】支付通知的通知 Transaction 不生效的问题,由 @kokoko (opens new window) 贡献 #450 (opens new window) 【修复】修复工作流创建流程时,流程名可能不存在的问题,由 @xushu (opens new window) 贡献 #439 (opens new window) 【修复】修复租户名的重复问题,由 @clockdotnet (opens new window) 贡献 #446 (opens new window) 【修复】Vue3 debugger 位置异常,由 @黄爱武 (opens new window) 贡献 #114 (opens new window) 【修复】Vue3 新增或修改菜单时,无法选择菜单图标的 Bug,由 @chongyul (opens new window) 贡献 #2 (opens new window) 【修复】Vue2 管理后台新增租户时,未校验账号、密码是否为空,由 @LiZhongShi (opens new window) 贡献 #456 (opens new window) 【修复】敏感词导出和字典数据编辑保存的两个 BUG,由 @clockdotnet (opens new window) 贡献 #457 (opens new window) 【修复】Vue3 管理后台:用户管理查询入参错误、站内信模板删除 API 调用错误,由 @AhJindeg (opens new window) 贡献 #132 (opens new window) # 🔨 Dependency Upgrades 【升级】knife4j from 4.0.0 to 4.1.0 【升级】spring-boot from 2.7.8 to 2.7.10 【升级】spring-doc 1.6.14 to 1.6.15 【升级】lombok from 1.18.24 to 1.18.26 【升级】druid from 1.2.15 to 1.2.16 【升级】jedis-mock from 1.0.6 to 1.0.7 【升级】hutool from 1.15.3 to 1.15.4 【升级】tika-core from 2.6.0 to 2.7.0 【升级】netty-all from 4.1.86.Final to 4.1.90.Final 【升级】minio from 8.5.1 to 8.5.2 【升级】tencentcloud-sdk-java from 3.1.676 to 3.1.715 【升级】alipay-sdk-java from 4.35.32.ALL to 4.35.79.ALL 【升级】ip-region from 2.6.6 to 2.7.0 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:33 【v1.7.3】开发中 【v1.7.1】2023-03-05 ← 【v1.7.3】开发中 【v1.7.1】2023-03-05→"},{"title":"代码热加载","path":"/wiki/YuDaoBoot/萌新必读/代码热加载/代码热加载.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 代码热加载 在日常开发中,我们需要经常修改 Java 代码,手动重启项目,查看修改后的效果。如果在项目小时,重启速度比较快,等待的时间是较短的。但是随着项目逐渐变大,重启的速度变慢,等待时间 1-2 min 是比较常见的。 这样就导致我们开发效率降低,影响我们的下班时间,哈哈哈~ 那么是否有方式能够实现,在我们修改完 Java 代码之后,能够不重启项目呢?答案是有的,通过 代码热加载 的方式。实现方案有三种: spring-boot-devtools【不推荐】 IDEA 自带 HowSwap 功能【推荐】 JRebel 插件【最推荐】 # 1. spring-boot-devtools spring-boot-devtools (opens new window) 是 Spring Boot 提供的开发者工具,它会监控当前应用所在的 classpath 下的文件发生变化,进行自动重启。 devtools 存在重启速度较慢的问题,所以不推荐! # 2. IDEA 自带 HowSwap 功能 该功能是 IDEA Ultimate 旗舰版的专属功能,不支持 IDEA Community 社区版。 # 2.1 如何使用 ① 设置 Spring Boot 启动类,开启 HotSwap 功能。如下图所示: ② Debug 运行该启动类,等待项目启动完成。 ③ 每次修改 Java 代码后,点击左下角的「热加载」按钮,即可实现代码热加载。如下图所示: # 2.2 存在问题 IDEA 自带 HowSwap 功能,支持比较有限,很多修改都不支持。例如说: 只能增加方法或字段但不可以减少方法或字段 只能增加可见性不能减少 只能维持已有方法的签名而不能修改等等。 你可以认为,只支持方法内的代码修改热加载。 如果想要相对完美的方案,建议使用 JRebel 插件。 # 3. JRebel 插件【最推荐】 JRebel 插件是目前最好用的热加载插件,它支持 IDEA Ultimate 旗舰版、Community 社区版。 # 3.1 如何安装 ① 点击 https://plugins.jetbrains.com/plugin/4441-jrebel-and-xrebel/versions (opens new window) 地址,必须下载 2022.4.1 版本。如下图所示: ② 打开 [Preference -> Plugins] 菜单,点击「Install Plugin from Disk...」按钮,选择刚下载的 JRebel 插件的压缩包。如下图所示: 安装完成后,需要重启 IDEA 生效。 ③ 打开 [Preference -> JRebel & XRebel] 菜单,输入 GUID address 为 https://jrebel.qekang.com/1e67ec1b-122f-4708-87d0-c1995dc0cdaa ,邮件随便写,完成 JRebel 的激活。如下图所示: 之后,点击「Work Offline」按钮,设置 JRebel 为离线,避免因为网络问题导致激活失效。如下图所示: # 3.2 如何使用 ① 点击「Debug With JRebel」按钮,使用 JRebel 启动项目。如下图所示: ② 每次修改 Java 代码后,点击左下角的「热加载」按钮,即可实现代码热加载。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/06, 01:37:36 项目结构 一键改包 ← 项目结构 一键改包→"},{"title":"删除功能","path":"/wiki/YuDaoBoot/萌新必读/删除功能/删除功能.html","content":"开发指南萌新必读 芋道源码 2022-10-17 目录 删除功能 项目内置功能较多,会存在一些你可能用不到的功能。一般的情况下,建议通过设置该功能对应的菜单为【禁用】,实现功能的“删除”。如下图所示: 后续,如果你又需要使用到该功能,只需要设置该功能对应的菜单为【开启】即可。 🙂 当然,如果你希望彻底删除功能,那么就需要采用删除代码的方式。整个过程如下: ① 【菜单】第一步,使用管理后台的菜单管理,删除对应的菜单、按钮。 ② 【数据库表】第二步,删除对应的数据库表。 ③ 【后端代码】第三步,删除对应的 Controller、Service、数据库实体等后端代码;然后启动后端项目,若存在代码报错,则继续删除相关联的代码,之后如此反复,直到成功。 ④ 【前端代码】第四步,删除对应的 View 和 API 等前端代码;然后启动前端项目,若存在代码报错,则继续删除相关联的代码,之后如此反复,直到成功。 下面,我们来举一些例子。 # 👍 相关视频教程 从零开始 07:如何有效的删除不用的功能? (opens new window) # 删除「多租户」功能 对应功能的文档:多租户 对应的关键字是 tenant # 第一步,删除菜单 删除“租户管理“下的所有菜单,从最里层的按钮开始。如下图所示: # 第二步,删除数据库表 删除 system_tenant 和 system_tenant_package 表。如下图所示: # 第三步,删除后端代码 ① 删除 yudao-module-system-api 模块的 api/tenant (opens new window) 包。 ② 删除 yudao-module-system-api 模块的 ErrorCodeConstants (opens new window) 类中,和租户、租户套餐相关的错误码。如下图所示: 如果想删除的更干净,可以把 system_error_code 表中,对应编号的错误码也都删除一下。 ③ 删除 yudao-module-system-biz 模块的如下包: api/tenant (opens new window) controller/admin/tenant (opens new window) service/tenant (opens new window) test/service/tenant (opens new window) dal/dataobject/tenant (opens new window) dal/mysql/tenant (opens new window) convert/tenant (opens new window) ④ 删除 yudao-spring-boot-starter-biz-tenant (opens new window) 模块。 然后,使用 IDEA 搜索 yudao-spring-boot-starter-biz-tenant 关键字,删除 Maven 中所有对它的定义与引用。如下图所示: 之后,使用 IDEA 刷新下 Maven 依赖。如下图所示: ⑤ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.db 不存在的错误,需要将继承 TenantBaseDO 的数据库实体,都改成继承 BaseDO 基类。 ⑥ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.aop 不存在的错误,需要去除对 @TenantIgnore 注解的使用。如下图所示: ⑦ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.module.system.service.tenant 不存在的错误,需要去除对 TenantService 的使用。如下图所示: ⑧ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.context 不存在的错误,需要去除对 TenantContextHolder 的使用。如下图所示: ⑨ 运行 YudaoServerApplication 启动类,终于成功了!!! ps:可以将 application.yaml 配置文件中,对应的 yudao.tenant 配置项给进一步删除。 # 第四步,删除前端代码 以 yudao-admin-ui 为示例~ ① 删除 View 和 API 的前端代码: views/system/tenant (opens new window) views/system/tenantPackage (opens new window) api/system/tenant.js (opens new window) api/system/tenantPackage.js (opens new window) ② 在 yudao-admin-ui 目录下,执行 npm run local 成功。访问登录页,结果访问白屏。需要清理 login.vue 页,涉及 tenant 关键字的代码。例如说: 刷新,成功访问登录界面。 ③ 在 yudao-admin-ui 目录下,搜索 tenant 或 Tenant 关键字,可进一步清理多租户的代码。例如说: # 第五步,测试验收 至此,我们已经完成了多租户的代码删除,还是蛮艰辛的~ 后续,你可以简单测试一下,看看是不是删除代码,导致一些小问题。 # 更多... 如果你有其它功能想要删除,可以在 Issue (opens new window) 留言,可以不断补充到该文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/06, 00:33:28 一键改包 新建模块 ← 一键改包 新建模块→"},{"title":"功能列表","path":"/wiki/YuDaoBoot/萌新必读/功能列表/功能列表.html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 功能列表 芋道,以开发者为中心,打造中国第一流的快速开发平台,全部开源,个人与企业可 100% 免费使用。 管理后台的电脑端:Vue3 提供 element-plus (opens new window)、vben(ant-design-vue) (opens new window) 两个版本,Vue2 提供 element-ui (opens new window) 版本 管理后台的移动端:采用 uni-app (opens new window) 方案,一份代码多终端适配,同时支持 APP、小程序、H5! 后端采用 Spring Boot、MySQL + MyBatis Plus、Redis + Redisson 数据库可使用 MySQL、Oracle、PostgreSQL、SQL Server、MariaDB、国产达梦 DM、TiDB 等 权限认证使用 Spring Security & Token & Redis,支持多终端、多种用户的认证系统,支持 SSO 单点登录 支持加载动态权限菜单,按钮级别权限控制,本地缓存提升性能 支持 SaaS 多租户系统,可自定义每个租户的权限,提供透明化的多租户底层封装 工作流使用 Flowable,支持动态表单、在线设计流程、会签 / 或签、多种任务分配方式 高效率开发,使用代码生成器可以一键生成前后端代码 + 单元测试 + Swagger 接口文档 + Validator 参数校验 集成微信小程序、微信公众号、企业微信、钉钉等三方登陆,集成支付宝、微信等支付与退款 集成阿里云、腾讯云等短信渠道,集成 MinIO、阿里云、腾讯云、七牛云等云存储服务 集成报表设计器、大屏设计器,通过拖拽即可生成酷炫的报表与大屏 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 🐼 内置功能 系统内置多种多种业务功能,可以用于快速你的业务系统: 系统功能 基础设施 工作流程 支付系统 会员中心 数据报表 商城系统 微信公众号 友情提示:本项目基于 RuoYi-Vue 修改,重构优化后端的代码,美化前端的界面。 额外新增的功能,我们使用 🚀 标记。 重新实现的功能,我们使用 ⭐️ 标记。 🙂 所有功能,都通过 单元测试 保证高质量。 # 系统功能 功能 描述 用户管理 用户是系统操作者,该功能主要完成系统用户配置 ⭐️ 在线用户 当前系统中活跃用户状态监控,支持手动踢下线 角色管理 角色菜单权限分配、设置角色按机构进行数据范围权限划分 菜单管理 配置系统菜单、操作权限、按钮权限标识等,本地缓存提供性能 部门管理 配置系统组织机构(公司、部门、小组),树结构展现支持数据权限 岗位管理 配置系统用户所属担任职务 🚀 租户管理 配置系统租户,支持 SaaS 场景下的多租户功能 🚀 租户套餐 配置租户套餐,自定每个租户的菜单、操作、按钮的权限 字典管理 对系统中经常使用的一些较为固定的数据进行维护 🚀 短信管理 短信渠道、短息模板、短信日志,对接阿里云等主流短信平台 🚀 邮件管理 邮箱账号、邮件模版、邮件发送日志,支持所有邮件平台 🚀 站内信 系统内的消息通知,支持站内信模版、站内信消息 🚀 操作日志 系统正常操作日志记录和查询,集成 Swagger 生成日志内容 ⭐️ 登录日志 系统登录日志记录查询,包含登录异常 🚀 错误码管理 系统所有错误码的管理,可在线修改错误提示,无需重启服务 通知公告 系统通知公告信息发布维护 🚀 敏感词 配置系统敏感词,支持标签分组 🚀 应用管理 管理 SSO 单点登录的应用,支持多种 OAuth2 授权方式 🚀 地区管理 展示省份、城市、区镇等城市信息,支持 IP 对应城市 # 基础设施 功能 描述 🚀 代码生成 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 🚀 系统接口 基于 Swagger 自动生成相关的 RESTful API 接口文档 🚀 数据库文档 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 表单构建 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 🚀 配置管理 对系统动态配置常用参数,支持 SpringBoot 加载 🚀 文件服务 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 🚀 文件服务 支持本地文件存储,同时支持兼容 Amazon S3 协议的云服务、开源组件 🚀 API 日志 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 MySQL 监控 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 Redis 监控 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 🚀 消息队列 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 🚀 Java 监控 基于 Spring Boot Admin 实现 Java 应用的监控 🚀 链路追踪 接入 SkyWalking 组件,实现链路追踪 🚀 日志中心 接入 SkyWalking 组件,实现日志中心 🚀 分布式锁 基于 Redis 实现分布式锁,满足并发场景 🚀 幂等组件 基于 Redis 实现幂等组件,解决重复请求问题 🚀 服务保障 基于 Resilience4j 实现服务的稳定性,包括限流、熔断等功能 🚀 日志服务 轻量级日志中心,查看远程服务器的日志 🚀 单元测试 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 # 工作流程 功能 描述 🚀 流程模型 配置工作流的流程模型,支持文件导入与在线设计流程图,提供 7 种任务分配规则 🚀 流程表单 拖动表单元素生成相应的工作流表单,覆盖 Element UI 所有的表单组件 🚀 用户分组 自定义用户分组,可用于工作流的审批分组 🚀 我的流程 查看我发起的工作流程,支持新建、取消流程等操作,高亮流程图、审批时间线 🚀 待办任务 查看自己【未】审批的工作任务,支持通过、不通过、转发、委派、退回等操作 🚀 已办任务 查看自己【已】审批的工作任务,未来会支持回退操作 🚀 OA 请假 作为业务自定义接入工作流的使用示例,只需创建请求对应的工作流程,即可进行审批 # 支付系统 功能 描述 🚀 商户信息 管理商户信息,支持 Saas 场景下的多商户功能 🚀 应用信息 配置商户的应用信息,对接支付宝、微信等多个支付渠道 🚀 支付订单 查看用户发起的支付宝、微信等的【支付】订单 🚀 退款订单 查看用户发起的支付宝、微信等的【退款】订单 ps:核心功能已经实现,正在对接微信小程序中... # 数据报表 功能 描述 🚀 报表设计器 支持数据报表、图形报表、打印设计等 🚀 大屏设计器 拖拽生成数据大屏,内置几十种图表组件 # 微信公众号 功能 描述 🚀 账号管理 配置接入的微信公众号,可支持多个公众号 🚀 数据统计 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 🚀 粉丝管理 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 🚀 消息管理 查看粉丝发送的消息列表,可主动回复粉丝消息 🚀 自动回复 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 🚀 标签管理 对公众号的标签进行创建、查询、修改、删除等操作 🚀 菜单管理 自定义公众号的菜单,也可以从公众号同步菜单 🚀 素材管理 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 🚀 图文草稿箱 新增常用的图文素材到草稿箱,可发布到公众号 🚀 图文发表记录 查看已发布成功的图文素材,支持删除操作 # 商城系统 建设中... # 会员中心 和「商城系统」一起开发 # 🐷 演示图 # 系统功能 模块 biu biu biu 登录 & 首页 用户 & 应用 租户 & 套餐 - 部门 & 岗位 - 菜单 & 角色 - 审计日志 - 短信 字典 & 敏感词 ) 错误码 & 通知 - # 工作流程 模块 biu biu biu 流程模型 表单 & 分组 - 我的流程 待办 & 已办 OA 请假 # 基础设施 模块 biu biu biu 代码生成 - 文档 - 文件 & 配置 定时任务 - API 日志 - MySQL & Redis - 监控平台 # 支付系统 模块 biu biu biu 商家 & 应用 支付 & 退款 --- # 数据报表 模块 biu biu biu 报表设计器 大屏设计器 # 移动端(管理后台) biu biu biu 目前已经实现登录、我的、工作台、编辑资料、头像修改、密码修改、常见问题、关于我们等基础功能。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:33 视频教程 快速启动(适合“后端”工程师) ← 视频教程 快速启动(适合“后端”工程师)→"},{"title":"接口文档","path":"/wiki/YuDaoBoot/萌新必读/接口文档/接口文档.html","content":"开发指南萌新必读 芋道源码 2022-03-26 目录 接口文档 项目使用 Swagger 实现 RESTful API 的接口文档,提供两种解决方案: *【推荐】 Apifox (opens new window):强大的 API 工具,支持 API 文档、API 调试、API Mock、API 自动化测试 Knife4j:简易的 API 工具,仅支持 API 文档、API 调试 为什么选择 Swagger 呢? Swagger 通过 Java 注解实现 API 接口文档的编写。相比使用 Java 注释的方式,注解提供更加规范的接口定义方式,开发体验更好。 如果你没有学习 Swagger,可以阅读 《芋道 Spring Boot API 接口文档 Swagger 入门 》 (opens new window) 文章。 # 1. Apifox 使用 本小节,我们来将项目中的 API 接口,一键导入到 Apifox 中,并使用它发起一次 API 的调用。 # 1.1 下载工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 # 1.2 API 导入 ① 先点击「示例项目」,再点击「+」按钮,选择「导入」选项。 ② 先选择「URL 导入」按钮,填写 Swagger 数据 URL 为 http://127.0.0.1:48080/v3/api-docs。 ③ 先点击「提交」按钮,再点击「确认导入」按钮,完成 API 接口的导入。 ④ 导入完成后,点击「接口管理」按钮,可以查看到 API 列表。 # 1.3 API 调试 ① 先点击右上角「请选择环境」,再点击「管理环境」选项,填写测试环境的地址为 http://127.0.0.1:48080,并进行保存。 ② 点击「管理后台 —— 认证」的「使用账号密码登录」接口,查看该 API 接口的定义。 ③ 点击「运行」按钮,填写 Headers 的 tenant-id 为 1,再点击 Body 的「自动生成」按钮,最后点击「发送」按钮。 # 2. Knife4j 使用 浏览器访问 http://127.0.0.1:48080/doc.html (opens new window) 地址,使用 Knife4j 查看 API 接口文档。 ① 点击任意一个接口,进行接口的调用测试。这里,使用「管理后台 - 用户个中心」的“获得登录用户信息”举例子。 ② 点击左侧「调试」按钮,并将请求头部的 header-id 和 Authorization 勾选上。 其中,header-id 为租户编号,Authorization 的 \"Bearer test\" 后面为用户编号(模拟哪个用户操作)。 ③ 点击「发送」按钮,即可发起一次 API 的调用。 # 3. Swagger 技术组件 ① 在 yudao-spring-boot-starter-web (opens new window) 技术组件的 swagger (opens new window) 包,实现了对 Swagger 的封装。 ② 如果想要禁用 Swagger 功能,可通过 springdoc.api-docs.enable 配置项为 false。一般情况下,建议 prod 生产环境进行禁用,避免发生安全问题。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/05, 11:33:32 快速启动(适合“前端”工程师) 技术选型 ← 快速启动(适合“前端”工程师) 技术选型→"},{"title":"简介","path":"/wiki/YuDaoBoot/萌新必读/简介/简介.html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 简介 yudao-vue-pro (opens new window),RuoYi-Vue 全新 Pro 版本,优化重构所有功能。 基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + UniApp 微信小程序,支持 RBAC 动态权限、数据权限、SaaS 多租户、Flowable 工作流、三方登录、支付、短信、商城等功能。 (opens new window) (opens new window) 😆 为开源继绝学,我辈义不容辞! 2017 年,艿艿创建「芋道源码」公众号,帮助了 20w+ 工程师学习优秀框架的源码。 2019 年,看了 Gitee 和 Github 非常多的业务开源项目,无法到达代码整洁、架构整洁。 于是,艿艿利用休息时间,每天肝到晚上 1 点多,如此便有了芋道管理后台 + 微信小程序。 # 🐴 严肃声明 现在、未来都不会有商业版本,所有代码全部开源! 「我喜欢写代码,乐此不疲」 「我喜欢做开源,以此为乐」 我 🐶 在上海艰苦奋斗,早中晚在 top3 大厂认真搬砖,夜里为开源做贡献。 如果这个项目让你有所收获,记得 Star 关注哦,这对我是非常不错的鼓励与支持。 # 🐳 项目关系 三个项目的功能对比,可见社区共同整理的 国产开源项目对比 (opens new window) 表格。 # 后端项目 项目 Star 简介 ruoyi-vue-pro (opens new window) (opens new window) (opens new window) 基于 Spring Boot 多模块架构 yudao-cloud (opens new window) (opens new window) (opens new window) 基于 Spring Cloud 微服务架构 Spring-Boot-Labs (opens new window) (opens new window) (opens new window) 系统学习 Spring Boot & Cloud 专栏 # 前端项目 项目 Star 简介 yudao-ui-admin-vue3 (opens new window) (opens new window) (opens new window) 基于 Vue3 + element-plus 实现的管理后台 yudao-ui-admin (opens new window) (opens new window) (opens new window) 基于 Vue2 + element-ui 实现的管理后台 yudao-ui-admin-uniapp (opens new window) (opens new window) (opens new window) 基于 uni-app + uni-ui 实现的管理后台的小程序 yudao-ui-go-view (opens new window) (opens new window) (opens new window) 基于 Vue3 + naive-ui 实现的大屏报表 yudao-ui-app (opens new window) (opens new window) (opens new window) 基于 uni-app + uview 实现的用户 App # 🐶 在线体验 演示地址【Vue3 + element-plus】:http://dashboard-vue3.yudao.iocoder.cn (opens new window) 演示地址【Vue3 + vben(ant-design-vue)】:http://dashboard-vben.yudao.iocoder.cn (opens new window) 演示地址【Vue2 + element-ui】:http://dashboard.yudao.iocoder.cn (opens new window) 如果你要搭建本地环境,可参考如下文档: 《开发指南 —— 快速启动(适合“后端”工程师)》 《开发指南 —— 快速启动(适合“前端”工程师)》 # 📚 国内顶级开源项目对比 社区整理,欢迎补充!传送门 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:33 交流群 交流群→"},{"title":"视频教程","path":"/wiki/YuDaoBoot/萌新必读/视频教程/视频教程.html","content":"开发指南萌新必读 芋道源码 2022-07-02 目录 视频教程 # 大纲 每个点都是大章节,包含 10-20 小节的视频。 每个视频,控制在 10 分钟左右,问题驱动,全程无废话,保证高质量的学习。 视频的内容,会带你理解整个系统的设计思想,每一个组件和模块的代码实现。 知其然,知其所以然!让你走出只会 CRUD 的困局~ 支持手机、平板、电脑设备,随时随地在线观看,无需下载! # 技术架构图 # 为什么学习该视频? 学习的过程中,往往会碰到如下的问题: 一个人瞎摸索,走弯路,效率低 一脸懵逼,不知道如何学习 遇到问题,无人解答,信心备受打击 遇到一些难题,自己无法透彻理解 知识面狭窄,不知道的太多 而通过这套视频,可以实现 “系统全面,效率高” 的效果。 # 获取方式 使用微信扫描下方二维码,即可获取~ # 从零开始 01、视频课程导读:项目简介、功能列表、技术选型 (opens new window) 02、在 Windows 环境下,如何运行前后端项目? (opens new window) 03、在 MacOS 环境下,如何运行前后端项目? (opens new window) 04、自顶向下,讲解项目的整体结构(上) (opens new window) 04、自顶向下,讲解项目的整体结构(下) (opens new window) 05、如何 5 分钟,开发一个新功能? (opens new window) 06、如何 5 分钟,创建一个新模块? (opens new window) 07、如何有效的删除不用的功能? (opens new window) 08、如何实现一键改包? (opens new window) # 用户认证 01、如何实现管理后台和微信小程序的用户? (opens new window) 02、如何实现用户的创建? (opens new window) 03、如何实现用户的账号密码登录? (opens new window) 04、如何实现用户的手机验证码登录? (opens new window) 05、如何实现用户的退出? (opens new window) 06、如何生成用户认证 Token 令牌? (opens new window) 07、如何校验用户认证 Token 令牌? (opens new window) 08、如何刷新用户认证 Token 令牌? (opens new window) 09、如何模拟用户认证 Token 令牌? (opens new window) 10、如何实现 URL 是否需要登录? (opens new window) 11、如何实现微信、钉钉等第三方登录? (opens new window) 12、如何实现微信小程序的一键登录? (opens new window) # 功能权限 01、如何设计一套权限系统? (opens new window) 02、如何实现菜单的创建? (opens new window) 03、如何实现角色的创建? (opens new window) 04、如何给用户分配权限 —— 将菜单赋予角色? (opens new window) 05、如何给用户分配权限 —— 将角色赋予用户? (opens new window) 06、后端如何实现 URL 权限的校验? (opens new window) 07、前端如何实现菜单的动态加载? (opens new window) 08、前端如何实现按钮的权限校验? (opens new window) # 数据权限 01、如何实现数据权限(内核)—— 原理剖析? (opens new window) 02、如何实现数据权限(内核)—— 源码实现:MyBatis 如何重写 SQL? (opens new window) 03、如何实现数据权限(内核)—— 源码实现:如何基于(数据规则)生成 WHERE 条件? (opens new window) 04、如何实现【部门级别】的数据权限 —— 入门使用? (opens new window) 05、如何实现【部门级别】的数据权限 —— 源码实现? (opens new window) 06、如何实现【自定义】的数据权限 —— 案例实战? (opens new window) # OAuth2 模块 01、快速入门 OAuth 2.0 授权? (opens new window) 02、基于授权码模式,如何实现 SSO 单点登录? (opens new window) 03、请求时,如何校验 accessToken 访问令牌? (opens new window) 04、访问令牌过期时,如何刷新 Token 令牌? (opens new window) 05、登录成功后,如何获得用户信息? (opens new window) 06、退出时,如何删除 Token 令牌? (opens new window) 07、基于密码模式,如何实现 SSO 单点登录? (opens new window) 08、如何实现客户端的管理? (opens new window) 09、单点登录界面,如何进行初始化? (opens new window) 10、单点登录界面,如何进行【手动】授权? (opens new window) 11、单点登录界面,如何进行【自动】授权? (opens new window) 12、基于【授权码】模式,如何获得 Token 令牌? (opens new window) 13、基于【密码】模式,如何获得 Token 令牌? (opens new window) 14、如何校验、刷新、删除访问令牌? (opens new window) # 工作流 01、如何集成 Flowable 框架? (opens new window) 02、如何实现动态的流程表单? (opens new window) 03、如何实现流程表单的保存? (opens new window) 04、如何实现流程表单的展示? (opens new window) 05、如何实现流程模型的新建? (opens new window) 06、如何实现流程模型的流程图的设计? (opens new window) 07、如何实现流程模型的流程图的预览? (opens new window) 08、如何实现流程模型的分配规则? (opens new window) 09、如何实现流程模型的发布? (opens new window) 10、如何实现流程定义的查询? (opens new window) 11、如何实现流程的发起? (opens new window) 12、如何实现我的流程列表? (opens new window) 13、如何实现流程的取消? (opens new window) 14、如何实现流程的任务分配? (opens new window) 15、如何实现会签、或签任务? (opens new window) 16、如何实现我的待办任务列表? (opens new window) 17、如何实现我的已办任务列表? (opens new window) 18、如何实现任务的审批通过? (opens new window) 19、如何实现任务的审批不通过? (opens new window) 20、如何实现流程的审批记录? (opens new window) 21、如何实现流程的流程图的高亮? (opens new window) 22、如何实现工作流的短信通知? (opens new window) 23、如何实现 OA 请假的发起? (opens new window) 24、如何实现 OA 请假的审批? (opens new window) # SaaS 多租户 01、如何实现多租户的 DB 封装? (opens new window) 02、如何实现多租户的 Redis 封装? (opens new window) 03、如何实现多租户的 Web 与 Security 封装? (opens new window) 04、如何实现多租户的 Job 封装? (opens new window) 05、如何实现多租户的 MQ 与 Async 封装? (opens new window) 06、如何实现多租户的 AOP 与 Util 封装? (opens new window) 07、如何实现多租户的管理? (opens new window) 08、如何实现多租户的套餐? (opens new window) # Web 组件 01、如何实现统一 API 前缀? (opens new window) 02、如何实现统一 API 响应? (opens new window) 03、如何实现 API 全局异常处理? (opens new window) 04、如何实现全局错误码? (opens new window) 05、如何实现 API 接口文档? (opens new window) 06、如何记录 API 访问日志? (opens new window) 07、如何校验 API 请求参数? (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/05, 16:00:35 交流群 功能列表 ← 交流群 功能列表→"},{"title":"快速启动(适合“前端”工程师)","path":"/wiki/YuDaoBoot/萌新必读/快速启动(适合“前端”工程师)/快速启动(适合“前端”工程师).html","content":"开发指南萌新必读 芋道源码 2023-03-05 目录 快速启动(适合“前端”工程师) 目标:在 本地 将前端项目运行起来,使用 远程 演示环境的后端服务。 整个过程非常简单,预计 5 分钟就可以完成,取决于大家的网速。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ 友情提示: 远程 演示环境的后端服务,只允许 GET 请求,不允许 POST、PUT、DELETE 等请求。 如果你要完整的后端服务,建议后续参考 《快速启动(我是后端)》 文档,将后端服务运行起来。 # 👍 相关视频教程 从零开始 02:在 Windows 环境下,如何运行前后端项目? (opens new window) 从零开始 03:在 MacOS 环境下,如何运行前后端项目? (opens new window) # 1. Apifox 接口工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 接口文档? 阅读 《开发指南 —— 接口文档》 呀~~ # 2. 启动 Vue3 + element-plus 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vue3.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run front ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 友情提示:Vue3 使用 Vite 构建,所以它存在如下的情况,都是正常的: 项目启动很快,浏览器打开需要等待 1 分钟左右,请保持耐心。 点击菜单,感觉会有一点卡顿,因为 Vite 采用懒加载机制。不用担心,最终部署到生产环境,就不存在这个问题了。 详细说明,可见 《为什么有人说 Vite 快,有人却说 Vite 慢?》 (opens new window) 文章。 # 3. 启动 Vue3 + vben(ant-design-vue) 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 + vben(ant-design-vue) 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vben.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run front ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 # 4. 启动 Vue2 管理后台 yudao-ui-admin (opens new window) 是前端 Vue2 管理后台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 在 yudao-ui-admin 目录下,执行如下命令,进行启动: # 进入项目目录cd yudao-ui-admin# 安装 Yarn,提升依赖的安装速度npm install --global yarn# 安装依赖yarn install# 启动服务npm run front ② 启动完成后,浏览器会自动打开 http://localhost:1024 (opens new window) 地址,可以看到前端界面。 # 5. 启动 uni-app 管理后台 yudao-ui-admin-uniapp (opens new window) 是前端 uni-app 管理后台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-admin-uniapp 目录。 然后,修改 config.js 配置文件的 baseUrl 后端服务的地址为 'http://api-dashboard.yudao.iocoder.cn。如下图所示: ③ 执行如下命令,安装 npm 依赖: # 进入项目目录cd yudao-ui-admin-uniapp# 安装 npm 依赖npm i ④ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: 友情提示:登录时,滑块验证码,在内存浏览器可能存在兼容性的问题,此时使用 Chrome 浏览器,并使用“开发者工具”,设置为 iPhone 12 Pro 模式! # 6. 启动 uni-app 用户前台 yudao-ui-app (opens new window) 是前端 uni-app 用户前台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-app 目录 然后,修改 config.js 配置文件的 baseUrl 后端服务的地址为 'http://api-dashboard.yudao.iocoder.cn/app-api。如下图所示: ③ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: # 7. 参与项目 如果你想参与到前端项目的开发,可以微信 wangwenbin-server 噢。 近期,重点开发 Vue3 管理后台、uniapp 商城,欢迎大家参与进来。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/15, 00:03:57 快速启动(适合“后端”工程师) 接口文档 ← 快速启动(适合“后端”工程师) 接口文档→"},{"title":"项目结构","path":"/wiki/YuDaoBoot/萌新必读/项目结构/项目结构.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 项目结构 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 👻 后端结构 后端采用模块化的架构,按照功能拆分成多个 Maven Module,提升开发与研发的效率,带来更好的可维护性。 一共有四类 Maven Module: Maven Module 作用 yudao-dependencies Maven 依赖版本管理 yudao-framework Java 框架拓展 yudao-module-xxx XXX 功能的 Module 模块 yudao-server 管理后台 + 用户 App 的服务端 下面,我们来逐个看看。 # 1. yudao-dependencies 该模块是一个 Maven Bom,只有一个 pom.xml (opens new window) 文件,定义项目中所有 Maven 依赖的版本号,解决依赖冲突问题。 详细的解释,可见 《微服务中使用 Maven BOM 来管理你的版本依赖 》 (opens new window) 文章。 从定位上来说,它和 Spring Boot 的 spring-boot-starter-parent (opens new window) 和 Spring Cloud 的 spring-cloud-dependencies (opens new window) 是一致的。 实际上,ruoyi-vue-pro 本质上还是个单体项目,直接在根目录 pom.xml (opens new window) 管理依赖版本会更加方便,也符合绝大多数程序员的认知。但是要额外考虑一个场景,如果每个 yudao-module-xxx 模块都维护在一个独立的 Git 仓库,那么 yudao-dependencies 就可以在多个 yudao-module-xxx 模块下复用。 # 2. yudao-framework 该模块是 ruoyi-vue-pro 项目的框架封装,其下的每个 Maven Module 都是一个组件,分成两种类型: ① 技术组件:技术相关的组件封装,例如说 MyBatis、Redis 等等。 Maven Module 作用 yudao-common 定义基础 pojo 类、枚举、工具类等 yudao-spring-boot-starter-web Web 封装,提供全局异常、访问日志等 yudao-spring-boot-starter-security 认证授权,基于 Spring Security 实现 yudao-spring-boot-starter-mybatis 数据库操作,基于 MyBatis Plus 实现 yudao-spring-boot-starter-redis 缓存操作,基于 Spring Data Redis + Redisson 实现 yudao-spring-boot-starter-mq 消息队列,基于 Redis 实现,支持集群消费和广播消费 yudao-spring-boot-starter-job 定时任务,基于 Quartz 实现,支持集群模式 yudao-spring-boot-starter-flowable 工作流,基于 Flowable 实现 yudao-spring-boot-starter-protection 服务保障,提供幂等、分布式锁、限流、熔断等功能 yudao-spring-boot-starter-file 文件客户端,支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、SFTP、数据库等 yudao-spring-boot-starter-excel Excel 导入导出,基于 EasyExcel 实现 yudao-spring-boot-starter-monitor 服务监控,提供链路追踪、日志服务、指标收集等功能 yudao-spring-boot-starter-captcha 验证码 Captcha,提供滑块验证码 yudao-spring-boot-starter-test 单元测试,基于 Junit + Mockito 实现 yudao-spring-boot-starter-banner 控制台 Banner,启动打印各种提示 yudao-spring-boot-starter-desensitize 脱敏组件:支持 JSON 返回数据时,将邮箱、手机等字段进行脱敏 ② 业务组件:业务相关的组件封装,例如说数据字典、操作日志等等。如果是业务组件,名字会包含 biz 关键字。 Maven Module 作用 yudao-spring-boot-starter-biz-tenant SaaS 多租户 yudao-spring-boot-starter-biz-data-permissionn 数据权限 yudao-spring-boot-starter-biz-dict 数据字典 yudao-spring-boot-starter-biz-operatelog 操作日志 yudao-spring-boot-starter-biz-pay 支付客户端,对接微信支付、支付宝等支付平台 yudao-spring-boot-starter-biz-sms 短信客户端,对接阿里云、腾讯云等短信服务 yudao-spring-boot-starter-biz-social 社交客户端,对接微信公众号、小程序、企业微信、钉钉等三方授权平台 yudao-spring-boot-starter-biz-weixin 微信客户端,对接微信的公众号、开放平台等 yudao-spring-boot-starter-biz-error-code 全局错误码 yudao-spring-boot-starter-biz-ip 地区 & IP 库 每个组件,包含两部分: core 包:组件的核心封装,拓展相关的功能。 config 包:组件的 Spring Boot 自动配置。 # 3. yudao-module-xxx 该模块是 XXX 功能的 Module 模块,目前内置了 8 个模块。 项目 说明 是否必须 yudao-module-system 系统功能 √ yudao-module-infra 基础设施 √ yudao-module-member 会员中心 x yudao-module-bpm 工作流程 x yudao-module-pay 支付系统 x yudao-module-report 大屏报表 x yudao-module-mall 商城系统 x yudao-module-mp 微信公众号 x 每个模块包含两个 Maven Module,分别是: Maven Module 作用 yudao-module-xxx-api 提供给其它模块的 API 定义 yudao-module-xxx-biz 模块的功能的具体实现 例如说,yudao-module-infra 想要访问 yudao-module-system 的用户、部门等数据,需要引入 yudao-module-system-api 子模块。示例如下: 疑问:为什么设计 `yudao-module-xxx-api` 模块呢? 明确需要提供给其它模块的 API 定义,方便未来迁移微服务架构。 模块之间可能会存在相互引用的情况,虽然说从系统设计上要尽量避免,但是有时在快速迭代的情况下,可能会出现。此时,通过只引用对方模块的 API 子模块,解决相互引用导致 Maven 无法打包的问题。 yudao-module-xxx-api 子模块的项目结构如下: 所在包 类 作用 示例 api Api 接口 提供给其它模块的 API 接口 AdminUserApi (opens new window) api DTO 类 Api 接口的入参 ReqDTO、出参 RespDTO LoginLogCreateReqDTO (opens new window) DeptRespDTO (opens new window) enums Enum 类 字段的枚举 LoginLogTypeEnum (opens new window) enums DictTypeConstants 类 数据字典的枚举 DictTypeConstants (opens new window) enums ErrorCodeConstants 类 错误码的枚举 ErrorCodeConstants (opens new window) yudao-module-xxx-biz 子模块的项目结构如下: 所在包 类 作用 示例 api ApiImpl 类 提供给其它模块的 API 实现类 AdminUserApiImpl (opens new window) controler.admin Controller 类 提供给管理后台的 RESTful API,默认以 admin-api/ 作为前缀。 例如 admin-api/system/auth/login 登录接口 AuthController (opens new window) controler.admin VO 类 Admin Controller 接口的入参 ReqVO、出参 RespVO AuthLoginReqVO (opens new window) AuthLoginRespVO (opens new window) controler.app Controller 类,以 App 为前缀 提供给用户 App 的 RESTful API,默认以 app-api/ 作为前缀。 例如 app-api/member/auth/login 登录接口 AppAuthController (opens new window) controler.app VO 类,以 App 为前缀 App Controller 接口的入参 ReqVO、出参 RespVO AppAuthLoginReqVO (opens new window) AppAuthLoginRespVO (opens new window) controler .http 文件 IDEA Http Client 插件 (opens new window),模拟请求 RESTful 接口 AuthController.http (opens new window) service Service 接口 业务逻辑的接口定义 AdminUserService (opens new window) service ServiceImpl 类 业务逻辑的实现类 AdminUserServiceImpl (opens new window) dal - Data Access Layer,数据访问层 dal.dataobject DO 类 Data Object,映射数据库表、或者 Redis 对象 AdminUserDO (opens new window) dal.mysql Mapper 接口 数据库的操作 AdminUserMapper (opens new window) dal.redis RedisDAO 类 Redis 的操作 LoginUserRedisDAO (opens new window) convert Convert 接口 DTO / VO / DO 等对象之间的转换器 UserConvert (opens new window) job Job 类 定时任务 UserSessionTimeoutJob (opens new window) mq - Message Queue,消息队列 mq.message Message 类 发送和消费的消息 DeptRefreshMessage (opens new window) mq.producer Producer 类 消息的生产者 DeptProducer (opens new window) mq.consumer Producer 类 消息的消费者 DeptRefreshConsumer (opens new window) framework - 模块自身的框架封装 framework (opens new window) 疑问:为什么 Controller 分成 Admin 和 App 两种? 提供给 Admin 和 App 的 RESTful API 接口是不同的,拆分后更加清晰。 疑问:为什么 VO 分成 Admin 和 App 两种? 相同功能的 RESTful API 接口,对于 Admin 和 App 传入的参数、返回的结果都可能是不同的。例如说,Admin 查询某个用户的基本信息时,可以返回全部字段;而 App 查询时,不会返回 mobile 手机等敏感字段。 疑问:为什么 DO 不作为 Controller 的出入参? 明确每个 RESTful API 接口的出入参。例如说,创建部门时,只需要传入 name、parentId 字段,使用 DO 接参就会导致 type、createTime、creator 等字段可以被传入,导致前端同学一脸懵逼。 每个 RESTful API 有自己独立的 VO,可以更好的设置 Swagger 注解、Validator 校验规则,而让 DO 保持整洁,专注映射好数据库表。 疑问:为什么操作 Redis 需要通过 RedisDAO? Service 直接使用 RedisTemplate 操作 Redis,导致大量 Redis 的操作细节和业务逻辑杂糅在一起,导致代码不够整洁。通过 RedisDAO 类,将每个 Redis Key 像一个数据表一样对待,清晰易维护。 总结来说,每个模块采用三层架构 + 非严格分层,如下图所示: # 4. yudao-server 该模块是后端 Server 的主项目,通过引入需要 yudao-module-xxx 业务模块,从而实现提供 RESTful API 给 yudao-ui-admin、yudao-ui-user 等前端项目。 本质上来说,它就是个空壳(容器)!如下图所示: # 👾 前端结构 前端一共有六个项目,分别是: 项目 说明 yudao-ui-admin-vue3 (opens new window) 基于 Vue3 + element-plus 实现的管理后台 yudao-ui-admin-vben (opens new window) 基于 Vue3 + vben(ant-design-vue) 实现的管理后台 yudao-ui-admin 基于 Vue2 + element-ui 实现的管理后台 yudao-ui-go-view (opens new window) 基于 Vue3 + naive-ui 实现的大屏报表 yudao-ui-admin-uniapp 基于 uni-app + uni-ui 实现的管理后台的小程序 yudao-ui-app 基于 uni-app + uview 实现的用户 App # 1. yudao-admin-ui-vue3 .├── .github # github workflows 相关├── .husky # husky 配置├── .vscode # vscode 配置├── mock # 自定义 mock 数据及配置├── public # 静态资源├── src # 项目代码│ ├── api # api接口管理│ ├── assets # 静态资源│ ├── components # 公用组件│ ├── hooks # 常用hooks│ ├── layout # 布局组件│ ├── locales # 语言文件│ ├── plugins # 外部插件│ ├── router # 路由配置│ ├── store # 状态管理│ ├── styles # 全局样式│ ├── utils # 全局工具类│ ├── views # 路由页面│ ├── App.vue # 入口vue文件│ ├── main.ts # 主入口文件│ └── permission.ts # 路由拦截├── types # 全局类型├── .env.base # 本地开发环境 环境变量配置├── .env.dev # 打包到开发环境 环境变量配置├── .env.gitee # 针对 gitee 的环境变量 可忽略├── .env.pro # 打包到生产环境 环境变量配置├── .env.test # 打包到测试环境 环境变量配置├── .eslintignore # eslint 跳过检测配置├── .eslintrc.js # eslint 配置├── .gitignore # git 跳过配置├── .prettierignore # prettier 跳过检测配置├── .stylelintignore # stylelint 跳过检测配置├── .versionrc 自动生成版本号及更新记录配置├── CHANGELOG.md # 更新记录├── commitlint.config.js # git commit 提交规范配置├── index.html # 入口页面├── package.json├── .postcssrc.js # postcss 配置├── prettier.config.js # prettier 配置├── README.md # 英文 README├── README.zh-CN.md # 中文 README├── stylelint.config.js # stylelint 配置├── tsconfig.json # typescript 配置├── vite.config.ts # vite 配置└── windi.config.ts # windicss 配置 # 2. yudao-ui-admin-vben .├── build # 打包脚本相关│ ├── config # 配置文件│ ├── generate # 生成器│ ├── script # 脚本│ └── vite # vite配置├── mock # mock文件夹├── public # 公共静态资源目录├── src # 主目录│ ├── api # 接口文件│ ├── assets # 资源文件│ │ ├── icons # icon sprite 图标文件夹│ │ ├── images # 项目存放图片的文件夹│ │ └── svg # 项目存放svg图片的文件夹│ ├── components # 公共组件│ ├── design # 样式文件│ ├── directives # 指令│ ├── enums # 枚举/常量│ ├── hooks # hook│ │ ├── component # 组件相关hook│ │ ├── core # 基础hook│ │ ├── event # 事件相关hook│ │ ├── setting # 配置相关hook│ │ └── web # web相关hook│ ├── layouts # 布局文件│ │ ├── default # 默认布局│ │ ├── iframe # iframe布局│ │ └── page # 页面布局│ ├── locales # 多语言│ ├── logics # 逻辑│ ├── main.ts # 主入口│ ├── router # 路由配置│ ├── settings # 项目配置│ │ ├── componentSetting.ts # 组件配置│ │ ├── designSetting.ts # 样式配置│ │ ├── encryptionSetting.ts # 加密配置│ │ ├── localeSetting.ts # 多语言配置│ │ ├── projectSetting.ts # 项目配置│ │ └── siteSetting.ts # 站点配置│ ├── store # 数据仓库│ ├── utils # 工具类│ └── views # 页面├── test # 测试│ └── server # 测试用到的服务│ ├── api # 测试服务器│ ├── upload # 测试上传服务器│ └── websocket # 测试ws服务器├── types # 类型文件├── vite.config.ts # vite配置文件└── windi.config.ts # windcss配置文件 # 3. yudao-admin-ui ├── bin // 执行脚本├── build // 构建相关 ├── public // 公共文件│ ├── favicon.ico // favicon 图标│ └── index.html // html 模板│ └── robots.txt // 反爬虫├── src // 源代码│ ├── api // 所有请求【重要】│ ├── assets // 主题、字体等静态资源│ ├── components // 全局公用组件│ ├── directive // 全局指令│ ├── icons // 图标│ ├── layout // 布局│ ├── plugins // 插件│ ├── router // 路由│ ├── store // 全局 store 管理│ ├── utils // 全局公用方法│ ├── views // 视图【重要】│ ├── App.vue // 入口页面│ ├── main.js // 入口 JS,加载组件、初始化等│ ├── permission.js // 权限管理│ └── settings.js // 系统配置├── .editorconfig // 编码格式├── .env.development // 开发环境配置├── .env.production // 生产环境配置├── .env.staging // 测试环境配置├── .eslintignore // 忽略语法检查├── .eslintrc.js // eslint 配置项├── .gitignore // git 忽略项├── babel.config.js // babel.config.js├── package.json // package.json└── vue.config.js // vue.config.js # 4. yudao-admin-ui-uniapp TODO 待补充 # 5. yudao-ui-app 建设中,基于 uniapp 实现... # 6. yudao-ui-go-view TODO 待补充 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 23:29:13 技术选型 代码热加载 ← 技术选型 代码热加载→"},{"title":"快速启动(适合“后端”工程师)","path":"/wiki/YuDaoBoot/萌新必读/快速启动(适合“后端”工程师)/快速启动(适合“后端”工程师).html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 快速启动(适合“后端”工程师) 目标:使用 IDEA 工具,将后端项目 ruoyi-vue-pro (opens new window) 运行起来,并按需启动前端项目。 整个过程非常简单,预计 10 分钟就可以完成,取决于大家的网速。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ # 👍 相关视频教程 从零开始 02:在 Windows 环境下,如何运行前后端项目? (opens new window) 从零开始 03:在 MacOS 环境下,如何运行前后端项目? (opens new window) # 1. 克隆代码 使用 IDEA (opens new window) 克隆 https://github.com/YunaiV/ruoyi-vue-pro (opens new window) 仓库的最新代码,并给该仓库一个 Star (opens new window)。 友情提示:IDEA 请使用至少 2020 版本,不知道怎么激活的可以看看 《IDEA 破解新招 - 无限重置30天试用期(适用于 2018、2019、2020、2021 所有版本) 》 (opens new window) 文章! 注意:不支持使用 Eclipse 启动项目,因为它没有支持 Lombok 和 Mapstruct 的插件。 克隆完成后,耐心等待 Maven 下载完相关的依赖。 友情提示:项目的每个模块的作用,可见 《开发指南 —— 项目结构》 文档。 使用的 SpringBoot 版本较新,所以需要下载一段时间。趁着这个时间,胖友可以给项目添加一个 Star (opens new window),支持下艿艿。 # 2. Apifox 接口工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 接口文档? 阅读 《开发指南 —— 接口文档》 呀~~ # 3. 初始化 MySQL 友情提示? 如果你是 PostgreSQL、Oracle、SQL Server 等其它数据库,也是可以的。 因为我主要使用 MySQL数据库为主,所以其它数据库的 SQL 文件可能存在滞后,可以加入 用户群 反馈。 项目使用 MySQL 存储数据,所以需要启动一个 MySQL 服务,建议使用 5.7 版本。 ① 创建一个名字为 ruoyi-vue-pro 数据库,执行对应数据库类型的 sql (opens new window) 目录下的 SQL 文件,进行初始化。 ② 默认配置下,MySQL 需要启动在 3306 端口,并且账号是 root,密码是 123456。如果不一致,需要修改 application-local.yaml 配置文件。 # 4. 初始化 Redis 项目使用 Redis 缓存数据,所以需要启动一个 Redis 服务。 一定要使用 5.0 以上的版本,项目使用 Redis Stream 作为消息队列。 不会安装的胖友,可以选择阅读下文,良心的艿艿。 Windows 安装 Redis 指南:http://www.iocoder.cn/Redis/windows-install (opens new window) Mac 安装 Redis 指南:http://www.iocoder.cn/Redis/mac-install (opens new window) 默认配置下,Redis 启动在 6379 端口,不设置账号密码。如果不一致,需要修改 application-local.yaml 配置文件。 # 5. 启动后端项目 yudao-server (opens new window) 是后端项目,提供管理后台、用户 APP 的 RESTful API 接口。 # 5.1 编译项目 第一步,使用 IDEA 打开 Terminal 终端,在 根目录 下直接执行 mvn clean install package '-Dmaven.test.skip=true' 命令,将项目进行初始化的打包,预计需要 1 分钟左右。成功后,控制台日志如下: [INFO] BUILD SUCCESS[INFO] ------------------------------------------------------------------------[INFO] Total time: 01:12 min[INFO] Finished at: 2022-02-12T09:52:38+08:00[INFO] Final Memory: 250M/2256M[INFO] ------------------------------------------------------------------------ JDK 版本的选择? 如下的 JDK 版本,是艿艿在本地测试通过的 JDK 8 版本:尽量保证 >= 1.8.0_144 JDK 11 版本:尽量保证 >= 11.0.14 JDK 17 版本:尽量保证 >= 17.0.2 如果 JDK 版本过低,包括 JDK 的小版本过低,也会 mvn 编译报错。例如说: “编译器(1.8.0_40)中出现编译错误“。此处,升级下 JDK 版本即可。 Maven 补充说明: ① 只有首次需要执行 Maven 命令,解决基础 pom.xml 文件不存在,导致报 BaseDbUnitTest 类不存在的问题。 ② 如果执行报 Unknown lifecycle phase “.test.skip=true” 错误,使用 mvn clean install package -Dmaven.test.skip=true 即可。 # 5.2 启动项目 第二步,执行 YudaoServerApplication (opens new window) 类,进行启动。 启动还是报类不存在? 可能是 IDEA 的 bug,点击 [File -> Invalidate Caches] 菜单,清空下缓存,重启后在试试看。 启动完成后,使用浏览器访问 http://127.0.0.1:48080 (opens new window) 地址,返回如下 JSON 字符串,说明成功。 友情提示:注意,默认配置下,后端项目启动在 48080 端口。 { "code": 401, "data": null, "msg": "账号未登录"} 如果报 “Command line is too long” 错误,参考 《Intellij IDEA 运行时报 Command line is too long 解决方法 》 (opens new window) 文章解决,或者直接点击 YudaoServerApplication 蓝字部分! # 5.3 启动其它模块 考虑到启动速度,默认值启动 system 系统服务,infra 基础设施两个模块。如果你需要启动其它模块,可以参考下面的文档: 《工作流手册 —— 工作流》 《公众号手册 —— 功能开启》 《大屏手册 —— 报表设计器》 《商城手册 —— 功能开启》 # 6. 启动前端项目【简易】 在 yudao-ui-static (opens new window) 项目中,提前编译好了前端项目的静态资源,可以直接体验和使用。操作步骤如下: ① 克隆 https://gitee.com/yudaocode/yudao-ui-static (opens new window) 项目,运行 UiConfiguration 类,进行启动。 ② 访问 http://127.0.0.1:2048/admin-ui-vue2/ (opens new window) 地址,可以看到 Vue2 管理后台。 ② 访问 http://127.0.0.1:2048/admin-ui-vue3/ (opens new window) 地址,可以看到 Vue3 + element-plus 管理后台。 ③ 访问 http://127.0.0.1:2048/admin-ui-vben/ (opens new window) 地址,可以看到 Vue3 + vben(ant-design-vue) 管理后台。 补充说明: 前端项目是不定期编译,可能不是最新版本。 如果需要最新版本,请继续往下看。 # 7. 启动前端项目【完整】 项目提供了多套前端项目,可以按需启动哈。 友情提示:可能胖友本地没有安装 Node.js 的环境,导致报错。可以参考如下文档安装: Windows 安装 Node.js 指南:http://www.iocoder.cn/NodeJS/windows-install (opens new window) Mac 安装 Node.js 指南:http://www.iocoder.cn/NodeJS/mac-install (opens new window) # 7.1 启动 Vue3 + element-plus 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 + element-plus 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vue3.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 友情提示:Vue3 使用 Vite 构建,所以它存在如下的情况,都是正常的: 项目启动很快,浏览器打开需要等待 1 分钟左右,请保持耐心。 点击菜单,感觉会有一点卡顿,因为 Vite 采用懒加载机制。不用担心,最终部署到生产环境,就不存在这个问题了。 详细说明,可见 《为什么有人说 Vite 快,有人却说 Vite 慢?》 (opens new window) 文章。 # 7.2 启动 Vue3 + vben(ant-design-vue) 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 + vben(ant-design-vue) 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vben.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 # 7.3 启动 Vue2 管理后台 yudao-ui-admin (opens new window) 是前端 Vue2 管理后台项目。 ① 在 yudao-ui-admin 目录下,执行如下命令,进行启动: # 进入项目目录cd yudao-ui-admin# 安装 Yarn,提升依赖的安装速度npm install --global yarn# 安装依赖yarn install# 启动服务npm run local ② 启动完成后,浏览器会自动打开 http://localhost:1024 (opens new window) 地址,可以看到前端界面。 # 7.4 启动 uni-app 管理后台 yudao-ui-admin-uniapp (opens new window) 是前端 uni-app 管理后台项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-admin-uniapp 目录 ③ 执行如下命令,安装 npm 依赖: # 进入项目目录cd yudao-ui-admin-uniapp# 安装 npm 依赖npm i ④ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: 友情提示:登录时,滑块验证码,在内存浏览器可能存在兼容性的问题,此时使用 Chrome 浏览器,并使用“开发者工具”,设置为 iPhone 12 Pro 模式! # 7.5 启动 uni-app 用户前台 yudao-ui-app (opens new window) 是前端 uni-app 用户前台项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-app 目录 ③ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: # 666. 彩蛋 至此,我们已经完成了项目 ruoyi-vue-pro (opens new window) 的启动。 胖友可以根据自己的兴趣,阅读相关源码。如果你想更快速的学习,可以看看 《视频教程 》 教程哟。 后面,艿艿会花大量的时间,继续优化这个项目。同时,输出与项目匹配的技术博客,方便胖友更好的学习与理解。 还是那句话,😆 为开源继绝学,我辈义不容辞! 嘿嘿嘿,记得一定要给 https://github.com/YunaiV/ruoyi-vue-pro (opens new window) 一个 star,这对艿艿真的很重要。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/15, 00:03:57 功能列表 快速启动(适合“前端”工程师) ← 功能列表 快速启动(适合“前端”工程师)→"},{"title":"技术选型","path":"/wiki/YuDaoBoot/萌新必读/技术选型/技术选型.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 技术选型 # 技术架构图 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 👻 后端 # 系统环境 框架 说明 版本 学习指南 JDK Java 开发工具包 >= 1.8.0 书单 (opens new window) Maven Java 管理与构建工具 >= 3.5.0 书单 (opens new window) Nginx 高性能 Web 服务器 - 文档 (opens new window) # 主框架 框架 说明 版本 学习指南 Spring Boot (opens new window) 应用开发框架 2.7.10 文档 (opens new window) Spring MVC (opens new window) MVC 框架 5.3.24 文档 (opens new window) Spring Security (opens new window) Spring 安全框架 5.7.6 文档 (opens new window) Hibernate Validator (opens new window) 参数校验组件 6.2.5 文档 (opens new window) # 存储层 框架 说明 版本 学习指南 MySQL (opens new window) 数据库服务器 >= 5.7 书单 (opens new window) Druid (opens new window) JDBC 连接池、监控组件 1.2.14 文档 (opens new window) MyBatis Plus (opens new window) MyBatis 增强工具包 3.5.3.1 文档 (opens new window) Dynamic Datasource (opens new window) 动态数据源 3.6.1 文档 (opens new window) Redis (opens new window) key-value 数据库 >= 5.0 书单 (opens new window) Redisson (opens new window) Redis 客户端 3.17.7 文档 (opens new window) # 中间件 框架 说明 版本 学习指南 Flowable (opens new window) 工作流引擎 6.8.0 文档 (opens new window) Quartz (opens new window) 任务调度组件 2.3.2 文档 (opens new window) Resilience4j (opens new window) 服务保障组件 1.7.1 文档 (opens new window) # 系统监控 框架 说明 版本 学习指南 Spring Boot Admin (opens new window) Spring Boot 监控平台 2.7.10 文档 (opens new window) SkyWalking (opens new window) 分布式应用追踪系统 8.5.0 文档 (opens new window) # 单元测试 框架 说明 版本 学习指南 JUnit (opens new window) Java 单元测试框架 5.8.2 - Mockito (opens new window) Java Mock 框架 4.8.0 - # 其它工具 框架 说明 版本 学习指南 Springdoc (opens new window) Swagger 文档 1.6.15 文档 (opens new window) Jackson (opens new window) JSON 工具库 2.13.3 MapStruct (opens new window) Java Bean 转换 1.5.3.Final 文档 (opens new window) Lombok (opens new window) 消除冗长的 Java 代码 1.18.26 文档 (opens new window) # 👾 前端 # 管理后台(Vue3 + ElementPlus) 框架 说明 版本 Vue (opens new window) vue 框架 3.2.45 Vite (opens new window) 开发与构建工具 4.0.1 Element Plus (opens new window) Element Plus 2.2.26 TypeScript (opens new window) JavaScript 的超集 4.9.4 pinia (opens new window) Vue 存储库 替代 vuex5 2.0.28 vueuse (opens new window) 常用工具集 9.6.0 vxe-table (opens new window) vue 最强表单 4.3.7 vue-i18n (opens new window) 国际化 9.2.2 vue-router (opens new window) vue 路由 4.1.6 windicss (opens new window) 下一代工具优先的 CSS 框架 3.5.6 iconify (opens new window) 在线图标库 3.0.0 wangeditor (opens new window) 富文本编辑器 5.1.23 # 管理后台(Vue3 + Vben + Ant-Design-Vue) 框架 说明 版本 Vue (opens new window) Vue 框架 3.2.47 Vite (opens new window) 开发与构建工具 4.3.0 ant-design-vue (opens new window) ant-design-vue 3.2.17 TypeScript (opens new window) JavaScript 的超集 5.0.4 pinia (opens new window) Vue 存储库 替代 vuex5 2.0.34 vueuse (opens new window) 常用工具集 9.13.0 vue-i18n (opens new window) 国际化 9.2.2 vue-router (opens new window) Vue 路由 4.1.6 windicss (opens new window) 下一代工具优先的 CSS 框架 3.5.6 iconify (opens new window) 在线图标库 3.1.0 # 管理后台(Vue2) 框架 说明 版本 学习指南 Node (opens new window) JavaScript 运行时环境 >= 12 - Vue (opens new window) JavaScript 框架 2.7.14 书单 (opens new window) Vue Element Admin (opens new window) 后台前端解决方案 2.5.10 # 管理后台(uni-app) 框架 说明 版本 uni-app 跨平台框架 2.0.0 uni-ui (opens new window) 基于 uni-app 的 UI 框架 1.4.20 # 用户 App 框架 说明 版本 学习指南 Vue (opens new window) JavaScript 框架 2.6.12 书单 (opens new window) UniApp (opens new window) 小程序、H5、App 的统一框架 - - .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 23:29:13 接口文档 项目结构 ← 接口文档 项目结构→"},{"title":"HTTPS 证书","path":"/wiki/YuDaoBoot/运维手册/HTTPS 证书/HTTPS 证书.html","content":"开发指南运维手册 芋道源码 2022-04-16 目录 HTTPS 证书 本小节,讲解如何在 Nginx 配置 SSL 证书,实现前端和后端使用 HTTPS 安全访问的功能。 考虑到各大云服务厂商的文档写的比较齐全,这里更多做汇总与整理。 😜 如果想要免费的 SSL 证书,请申请 DV 单域名证书。如果要配置多个域名,可以申请多个 DV 单域名证书。 友情提示:HTTPS 的学习资料? 《HTTPS 的工作原理》 (opens new window) 《面试官:你连 HTTPS 原理没搞懂,还给我讲“中间人攻击”?》 (opens new window) # 1. 阿里云 SSL【最常用】 阿里云 SSL 证书 (opens new window) 第一步,免费证书申购流程 (opens new window) 第二步,在 Nginx 或 Tengine 服务器上安装证书 (opens new window) ↑ 点击观看 ↑ (opens new window)# 2. FreeSSL【最便宜】 FreeSSL.cn (opens new window),一个提供免费 HTTPS 证书申请的网站。 《如何在 Nginx/Apache/Tomcat/IIS 自动部署证书?》 (opens new window) 疑问:有没其它类似的平台? OHTTPS (opens new window):免费提供 HTTPS 证书,支持一键申请、自动更新、自动部署的功能。 # 3. 腾讯云 SSL 腾讯云 SSL 证书 (opens new window) 第一步,免费 SSL 证书申请流程 (opens new window) 第二步,Nginx 服务器 SSL 证书安装部署 (opens new window) ↑ 点击观看 ↑ (opens new window)# 4. 华为云 SSL 云证书管理服务 CCM (opens new window) 第一步,SSL 证书申购流程 (opens new window) 第二步,下载与安装 SSL 证书 (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 Jenkins 部署 服务监控 ← Jenkins 部署 服务监控→"},{"title":"Docker 部署","path":"/wiki/YuDaoBoot/运维手册/Docker 部署/Docker 部署.html","content":"开发指南运维手册 芋道源码 2022-04-13 目录 Docker 部署 本小节,讲解如何将前端 + 后端项目,使用 Docker 容器,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: 注意:服务器的 IP 地址。 外网 IP:139.9.196.247 内网 IP:192.168.0.213 下属所有涉及到 IP 的配置,需要替换成你自己的。 # 1. 安装 Docker 执行如下命令,进行 Docker 的安装。 ## ① 使用 DaoCloud 的 Docker 高速安装脚本。参考 https://get.daocloud.io/#install-dockercurl -sSL https://get.daocloud.io/docker | sh## ② 设置 DaoCloud 的 Docker 镜像中心,加速镜像的下载速度。参考 https://www.daocloud.io/mirrorcurl -sSL https://get.daocloud.io/daotools/set_mirror.sh | sh -s http://f1361db2.m.daocloud.io## ③ 启动 Docker 服务systemctl start docker # 2. 配置 MySQL # 2.1 安装 MySQL(可选) 友情提示:使用 Docker 安装 MySQL 是可选步骤,也可以直接安装 MySQL,或者购买 MySQL 云服务。 ① 执行如下命令,使用 Docker 启动 MySQL 容器。 docker run -v /work/mysql/:/var/lib/mysql \\-p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 \\--restart=always --name mysql -d mysql 数据库文件,挂载到服务器的的 /work/mysql/ 目录下 端口是 3306,密码是 123456 ② 执行 ls /work/mysql 命令,查看 /work/mysql/ 目录的数据库文件。 # 2.2 导入 SQL 脚本 创建一个名字为 ruoyi-vue-pro 数据库,执行数据库对应的 sql (opens new window) 目录下的 SQL 文件,进行初始化。 # 3. 配置 Redis 友情提示:使用 Docker 安装 Redis 是可选步骤,也可以直接安装 Redis,或者购买 Redis 云服务。 执行如下命令,使用 Docker 启动 Redis 容器。 docker run -d --name redis --restart=always -p 6379:6379 redis:5.0.14-alpine 端口是 6379,密码未设置 # 4. 部署后端 # 4.1 修改配置 后端 dev 开发环境对应的是 application-dev.yaml (opens new window) 配置文件,主要是修改 MySQL 和 Redis 为你的地址。如下图所示: # 4.2 编译后端 在项目的根目录下,执行 mvn clean package -Dmaven.test.skip=true 命令,编译后端项目,构建出它的 Jar 包。如下图所示: 疑问:-Dmaven.test.skip=true 是什么意思? 跳过单元测试的执行。如果你项目的单元测试写的不错,建议使用 mvn clean package 命令,执行单元测试,保证交付的质量。 # 4.3 上传 Jar 包 在 Linux 服务器上创建 /work/projects/yudao-server 目录,使用 scp 命令或者 FTP 工具,将 yudao-server.jar 上传到该目录下。如下图所示: # 4.4 构建镜像 ① 在 /work/projects/yudao-server 目录下,新建 Dockerfile (opens new window) 文件,用于制作后端项目的 Docker 镜像。编写内容如下: ## AdoptOpenJDK 停止发布 OpenJDK 二进制,而 Eclipse Temurin 是它的延伸,提供更好的稳定性## 感谢复旦核博士的建议!灰子哥,牛皮!FROM eclipse-temurin:8-jre## 创建目录,并使用它作为工作目录RUN mkdir -p /yudao-serverWORKDIR /yudao-server## 将后端项目的 Jar 文件,复制到镜像中COPY yudao-server.jar app.jar## 设置 TZ 时区## 设置 JAVA_OPTS 环境变量,可通过 docker run -e "JAVA_OPTS=" 进行覆盖ENV TZ=Asia/Shanghai JAVA_OPTS="-Xms512m -Xmx512m"## 暴露后端项目的 48080 端口EXPOSE 48080## 启动后端项目ENTRYPOINT java ${JAVA_OPTS} -Djava.security.egd=file:/dev/./urandom -jar app.jar ② 执行如下命令,构建名字为 yudao-server 的 Docker 镜像。 cd /work/projects/yudao-serverdocker build -t yudao-server . ③ 在 /work/projects/yudao-server 目录下,新建 Shell 脚本 deploy.sh,使用 Docker 启动后端项目。编写内容如下: #!/bin/bashset -e## 第一步:删除可能启动的老 yudao-server 容器echo "开始删除 yudao-server 容器"docker stop yudao-server || truedocker rm yudao-server || trueecho "完成删除 yudao-server 容器"## 第二步:启动新的 yudao-server 容器 \\echo "开始启动 yudao-server 容器"docker run -d \\--name yudao-server \\-p 48080:48080 \\-e "SPRING_PROFILES_ACTIVE=dev" \\-v /work/projects/yudao-server:/root/logs/ \\yudao-serverecho "正在启动 yudao-server 容器中,需要等待 60 秒左右" 应用日志文件,挂载到服务器的的 /work/projects/yudao-server 目录下 通过 SPRING_PROFILES_ACTIVE 设置为 dev 开发环境 # 4.5 启动后端 ① 执行 sh deploy.sh 命令,使用 Docker 启动后端项目。日志如下: 开始删除 yudao-server 容器yudao-serveryudao-server完成删除 yudao-server 容器开始启动 yudao-server 容器0dfd3dc409a53ae6b5e7c5662602cf5dcb52fd4d7f673bd74af7d21da8ead9d5正在启动 yudao-server 容器中,需要等待 60 秒左右 ② 执行 docker logs yudao-server 命令,查看启动日志。看到如下内容,说明启动完成: 友情提示:如果日志比较多,可以使用 grep 进行过滤。 例如说:使用 docker logs yudao-server | grep 48080 2022-04-15 00:34:19.647 INFO 8 --- [main] [TID: N/A] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 48080 (http) # 5. 部署前端 友情提示: 本小节的内容,和 《开发指南 —— Linux 部署》 的「部署前端」是基本一致的。 # 5.1 修改配置 前端 dev 开发环境对应的是 .env.dev ( opens new window) 配置文件,主要是修改 VUE_APP_BASE_API 为你的后端项目的访问地址。如下图所示: # 5.2 编译前端 在 yudao-ui-admin 目录下,执行 npm run build:dev 命令,编译前端项目,构建出它的 dist 文件,里面是 HTML、CSS、JavaScript 等静态文件。如下图所示: 如下想要打包其它环境,可使用如下命令: npm run build:prod ## 打包 prod 生产环境npm run build:stage ## 打包 stage 预发布环境 其它高级参数说明【可暂时不看】: ① PUBLIC_PATH:静态资源地址,可用于七牛等 CDN 服务回源读取前端的静态文件,提升访问速度,建议 prod 生产环境使用。示例如下: ② VUE_APP_APP_NAME:二级部署路径,默认为 / 根目录,一般不用修改。 ③ mode:前端路由的模式,默认采用 history 路由,一般不用修改。可以通过修改 router/index.js (opens new window) 来设置为 hash 路由,示例如下: # 5.3 上传 dist 文件 在 Linux 服务器上创建 /work/projects/yudao-ui-admin 目录,使用 scp 命令或者 FTP 工具,将 dist 上传到 /work/nginx/html 目录下。如下图所示: # 5.4 启动前端? 前端无法直接启动,而是通过 Nginx 转发读取 /work/projects/yudao-ui-admin 目录的静态文件。 # 6. 配置 Nginx # 6.1 安装 Nginx Nginx 挂载到服务器的目录: /work/nginx/conf.d 用于存放配置文件 /work/nginx/html 用于存放网页文件 /work/nginx/logs 用于存放日志 /work/nginx/cert 用于存放 HTTPS 证书 ① 创建 /work/nginx 目录,并在该目录下新建 nginx.conf 文件,避免稍后安装 Nginx 报错。内容如下: user nginx;worker_processes 1;events { worker_connections 1024;}error_log /var/log/nginx/error.log warn;pid /var/run/nginx.pid;http { include /etc/nginx/mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"';# access_log /var/log/nginx/access.log main; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types text/plain application/x-javascript text/css application/xml application/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 include /etc/nginx/conf.d/*.conf; ## 加载该目录下的其它 Nginx 配置文件} ② 执行如下命令,使用 Docker 启动 Nginx 容器。 docker run -d \\--name nginx --restart always \\-p 80:80 -p 443:443 \\-e "TZ=Asia/Shanghai" \\-v /work/nginx/nginx.conf:/etc/nginx/nginx.conf \\-v /work/nginx/conf.d:/etc/nginx/conf.d \\-v /work/nginx/logs:/var/log/nginx \\-v /work/nginx/cert:/etc/nginx/cert \\-v /work/nginx/html:/usr/share/nginx/html ginx:alpine ③ 执行 docker ps 命令,查看到 Nginx 容器的状态是 UP 的。 下面,来看两种 Nginx 的配置,分别满足服务器 IP、独立域名的不同场景。 # 6.2 方式一:服务器 IP 访问 ① 在 /work/nginx/conf.d 目录下,创建 ruoyi-vue-pro.conf,内容如下: server { listen 80; server_name 139.9.196.247; ## 重要!!!修改成你的外网 IP/域名 location / { ## 前端项目 root /usr/share/nginx/html/yudao-admin-ui; index index.html index.htm; try_files $uri $uri/ /index.html; } location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://192.168.0.213:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://192.168.0.213:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }} 友情提示: [root] 指令在本地文件时,要使用 Nginx Docker 容器内的路径,即 /usr/share/nginx/html/yudao-admin-ui,否则会报 404 的错误。 ② 执行 docker exec nginx nginx -s reload 命令,重新加载 Nginx 配置。 友情提示:如果你担心 Nginx 配置不正确,可以执行 docker exec nginx nginx -t 命令。 ③ 执行 curl http://192.168.0.213/admin-api/ 命令,成功访问后端项目的内网地址,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} 执行 curl http://139.9.196.247:48080/admin-api/ 命令,成功访问后端项目的外网地址,返回结果一致。 ④ 请求 http://139.9.196.247:48080 (opens new window) 地址,成功访问前端项目的外网地址,,返回前端界面如下: # 6.3 方式二:独立域名访问 友情提示:在前端项目的编译时,需要把 `VUE_APP_BASE_API` 修改为后端项目对应的域名。 例如说,这里使用的是 http://api.iocoder.cn ① 在 /work/nginx/conf.d 目录下,创建 ruoyi-vue-pro2.conf,内容如下: server { ## 前端项目 listen 80; server_name admin.iocoder.cn; ## 重要!!!修改成你的前端域名 location / { ## 前端项目 root /usr/share/nginx/html/yudao-admin-ui; index index.html index.htm; try_files $uri $uri/ /index.html; }}server { ## 后端项目 listen 80; server_name api.iocoder.cn; ## 重要!!!修改成你的外网 IP/域名 ## 不要使用 location / 转发到后端项目,因为 druid、admin 等监控,不需要外网可访问。或者增加 Nginx IP 白名单限制也可以。 location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://192.168.0.213:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://192.168.0.213:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }} ② 执行 docker exec nginx nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://api.iocoder.cn/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://admin.iocoder.cn (opens new window) 地址,成功访问前端项目,返回前端界面如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 Linux 部署 Jenkins 部署 ← Linux 部署 Jenkins 部署→"},{"title":"Jenkins 部署","path":"/wiki/YuDaoBoot/运维手册/Jenkins 部署/Jenkins 部署.html","content":"开发指南运维手册 芋道源码 2022-04-15 目录 Jenkins 部署 本小节,讲解如何将前端 + 后端项目,使用 Jenkins 工具,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: 友情提示: 本文是 《开发指南 —— Linux 部署》 的加强版,差别在于使用 Jenkins 部署。 # 1. 安装 Jenkins 阅读 《芋道 Jenkins 极简入门 》 (opens new window) 文章,进行 Jenkins 的安装。 # 2. 部署后端 阅读 《芋道 Spring Boot 持续交付 Jenkins 入门 》 (opens new window) 文章,进行后端的部署。 可参考 Jenkins 配置如下: # 3. 部署前端 可参考 Jenkins 配置如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 Docker 部署 HTTPS 证书 ← Docker 部署 HTTPS 证书→"},{"title":"Linux 部署","path":"/wiki/YuDaoBoot/运维手册/Linux 部署/Linux 部署.html","content":"开发指南运维手册 芋道源码 2022-04-12 目录 Linux 部署 本小节,讲解如何将前端 + 后端项目,使用 Shell 脚本,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: # 1. 配置 MySQL # 1.1 安装 MySQL(可选) 友情提示:安装 MySQL 是可选步骤,也可以购买 MySQL 云服务。 ① 执行如下命令,进行 MySQL 的安装。 ## ① 安装 MySQL 5.7 版本的软件源 https://dev.mysql.com/downloads/repo/yum/rpm -Uvh https://dev.mysql.com/get/mysql57-community-release-el7-11.noarch.rpm## ② 安装 MySQL Server 5.7 版本yum install mysql-server --nogpgcheck## ③ 查看 MySQL 的安装版本。结果是 mysqld Ver 5.7.37 for Linux on x86_64 (MySQL Community Server (GPL))mysqld --version ② 修改 /etc/my.cnf 文件,在文末加上 lower_case_table_names=1 和 validate_password=off 配置,执行 systemctl restart mysqld 命令重启。 ③ 执行 grep password /var/log/mysqld.log 命令,获得 MySQL 临时密码。 2022-04-16T09:39:57.365086Z 1 [Note] A temporary password is generated for root@localhost: ZOKUaehW2e.e ④ 执行如下命令,修改 MySQL 的密码,设置允许远程连接。 ## ① 连接 MySQL Server 服务,并输入临时密码mysql -uroot -p## ② 修改密码,123456 可改成你想要的密码alter user 'root'@'localhost' identified by '123456';## ③ 设置允许远程连接use mysql;update user set host = '%' where user = 'root';FLUSH PRIVILEGES; # 1.2 导入 SQL 脚本 创建一个名字为 ruoyi-vue-pro 数据库,执行数据库对应的 sql ( opens new window) 目录下的 SQL 文件,进行初始化。 # 2. 配置 Redis 友情提示:安装 Redis 是可选步骤,也可以购买 Redis 云服务。 执行如下命令,进行 Redis 的安装。 ## ① 安装 remi 软件源yum install http://rpms.famillecollet.com/enterprise/remi-release-7.rpm## ② 安装最新 Redis 版本。如果想要安装指定版本,可使用 yum --enablerepo=remi install redis-6.0.6 -y 命令yum --enablerepo=remi install redis ## ③ 查看 Redis 的安装版本。结果是 Redis server v=6.2.6 sha=00000000:0 malloc=jemalloc-5.1.0 bits=64 build=4ab9a06393930489redis-server --version## ④ 启动 Redis 服务systemctl restart redis 端口是 6379,密码未设置 # 3. 部署后端 # 3.1 修改配置 后端 dev 开发环境对应的是 application-dev.yaml (opens new window) 配置文件,主要是修改 MySQL 和 Redis 为你的地址。如下图所示: # 3.2 编译后端 在项目的根目录下,执行 mvn clean package -Dmaven.test.skip=true 命令,编译后端项目,构建出它的 Jar 包。如下图所示: 疑问:-Dmaven.test.skip=true 是什么意思? 跳过单元测试的执行。如果你项目的单元测试写的不错,建议使用 mvn clean package 命令,执行单元测试,保证交付的质量。 # 3.3 上传 Jar 包 在 Linux 服务器上创建 /work/projects/yudao-server 目录,使用 scp 命令或者 FTP 工具,将 yudao-server.jar 上传到该目录下。如下图所示: 疑问:如果构建 War 包,部署到 Tomcat 下? 并不推荐采用 War 包部署到 Tomcat 下。如果真的需要,可以参考 《Deploy a Spring Boot WAR into a Tomcat Server》 (opens new window) 文章。 # 3.4 编写脚本 在 /work/projects/yudao-server 目录下,新建 Shell 脚本 deploy.sh,用于启动后端项目。编写内容如下: #!/bin/bashset -eDATE=$(date +%Y%m%d%H%M)# 基础路径BASE_PATH=/work/projects/yudao-server# 服务名称。同时约定部署服务的 jar 包名字也为它。SERVER_NAME=yudao-server# 环境PROFILES_ACTIVE=dev# heapError 存放路径HEAP_ERROR_PATH=$BASE_PATH/heapError# JVM 参数JAVA_OPS="-Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=$HEAP_ERROR_PATH"# SkyWalking Agent 配置#export SW_AGENT_NAME=$SERVER_NAME#export SW_AGENT_COLLECTOR_BACKEND_SERVICES=192.168.0.84:11800#export SW_GRPC_LOG_SERVER_HOST=192.168.0.84#export SW_AGENT_TRACE_IGNORE_PATH="Redisson/PING,/actuator/**,/admin/**"#export JAVA_AGENT=-javaagent:/work/skywalking/apache-skywalking-apm-bin/agent/skywalking-agent.jar# 停止:优雅关闭之前已经启动的服务function stop() { echo "[stop] 开始停止 $BASE_PATH/$SERVER_NAME" PID=$(ps -ef | grep $BASE_PATH/$SERVER_NAME | grep -v "grep" | awk '{print $2}') # 如果 Java 服务启动中,则进行关闭 if [ -n "$PID" ]; then # 正常关闭 echo "[stop] $BASE_PATH/$SERVER_NAME 运行中,开始 kill [$PID]" kill -15 $PID # 等待最大 120 秒,直到关闭完成。 for ((i = 0; i < 120; i++)) do sleep 1 PID=$(ps -ef | grep $BASE_PATH/$SERVER_NAME | grep -v "grep" | awk '{print $2}') if [ -n "$PID" ]; then echo -e ".\\c" else echo '[stop] 停止 $BASE_PATH/$SERVER_NAME 成功' break fi done # 如果正常关闭失败,那么进行强制 kill -9 进行关闭 if [ -n "$PID" ]; then echo "[stop] $BASE_PATH/$SERVER_NAME 失败,强制 kill -9 $PID" kill -9 $PID fi # 如果 Java 服务未启动,则无需关闭 else echo "[stop] $BASE_PATH/$SERVER_NAME 未启动,无需停止" fi}# 启动:启动后端项目function start() { # 开启启动前,打印启动参数 echo "[start] 开始启动 $BASE_PATH/$SERVER_NAME" echo "[start] JAVA_OPS: $JAVA_OPS" echo "[start] JAVA_AGENT: $JAVA_AGENT" echo "[start] PROFILES: $PROFILES_ACTIVE" # 开始启动 nohup java -server $JAVA_OPS $JAVA_AGENT -jar $BASE_PATH/$SERVER_NAME.jar --spring.profiles.active=$PROFILES_ACTIVE > nohup.out 2>&1 & echo "[start] 启动 $BASE_PATH/$SERVER_NAME 完成"}# 部署function deploy() { cd $BASE_PATH # 第一步:停止 Java 服务 stop # 第二步:启动 Java 服务 start}deploy 友情提示: 脚本的详细讲解,可见 《芋道 Jenkins 极简入门 》 (opens new window) 的「2.3 远程服务器配置 」小节。 如果你想要修改脚本,主要关注 BASE_PATH、PROFILES_ACTIVE、JAVA_OPS 三个参数。如下图所示: # 3.5 启动后端 ① 【可选】执行 yum install -y java-1.8.0-openjdk 命令,安装 OpenJDK 8。 友情提示:如果已经安装 JDK,可不安装。建议使用的 JDK 版本为 8、11、17 这三个。 ② 执行 sh deploy.sh 命令,启动后端项目。日志如下: [stop] 开始停止 /work/projects/yudao-server/yudao-server[stop] /work/projects/yudao-server/yudao-server 未启动,无需停止[start] 开始启动 /work/projects/yudao-server/yudao-server[start] JAVA_OPS: -Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/work/projects/yudao-server/heapError[start] JAVA_AGENT:[start] PROFILES: dev[start] 启动 /work/projects/yudao-server/yudao-server 完成 ③ 执行 tail -f nohup.out 命令,查看启动日志。看到如下内容,说明启动完成: 2022-04-13 00:06:20.049 INFO 1395 --- [main] [TID: N/A] c.i.yudao.server.YudaoServerApplication : Started YudaoServerApplication in 35.315 seconds (JVM running for 36.282) # 4. 部署前端 # 4.1 修改配置 前端 dev 开发环境对应的是 .env.dev ( opens new window) 配置文件,主要是修改 VUE_APP_BASE_API 为你的后端项目的访问地址。如下图所示: # 4.2 编译前端 在 yudao-ui-admin 目录下,执行 npm run build:dev 命令,编译前端项目,构建出它的 dist 文件,里面是 HTML、CSS、JavaScript 等静态文件。如下图所示: 如下想要打包其它环境,可使用如下命令: npm run build:prod ## 打包 prod 生产环境npm run build:stage ## 打包 stage 预发布环境 其它高级参数说明【可暂时不看】: ① PUBLIC_PATH:静态资源地址,可用于七牛等 CDN 服务回源读取前端的静态文件,提升访问速度,建议 prod 生产环境使用。示例如下: ② VUE_APP_APP_NAME:二级部署路径,默认为 / 根目录,一般不用修改。 ③ mode:前端路由的模式,默认采用 history 路由,一般不用修改。可以通过修改 router/index.js (opens new window) 来设置为 hash 路由,示例如下: # 4.3 上传 dist 文件 在 Linux 服务器上创建 /work/projects/yudao-ui-admin 目录,使用 scp 命令或者 FTP 工具,将 dist 上传到该目录下。如下图所示: # 4.4 启动前端? 前端无法直接启动,而是通过 Nginx 转发读取 /work/projects/yudao-ui-admin 目录的静态文件。 # 5. 配置 Nginx # 5.1 安装 Nginx 参考 Nginx 官方文档 (opens new window),安装 Nginx 服务。命令如下: ## 添加 yum 源yum install epel-releaseyum update## 安装 nginxyum install nginx## 启动 nginx nginx Nginx 默认配置文件是 /etc/nginx/nginx.conf。 下面,来看两种 Nginx 的配置,分别满足服务器 IP、独立域名的不同场景。 # 5.2 方式一:服务器 IP 访问 ① 修改 Nginx 配置,内容如下: worker_processes 1;events { worker_connections 1024;}http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 server { listen 80; server_name 192.168.225.2; ## 重要!!!修改成你的外网 IP/域名 location / { ## 前端项目 root /work/projects/yudao-ui-admin; index index.html index.htm; try_files $uri $uri/ /index.html; } location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://localhost:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://localhost:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }} ② 执行 nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://192.168.225.2/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://192.168.225.2 (opens new window) 地址,成功访问前端项目,返回前端界面如下: # 5.3 方式二:独立域名访问 友情提示:在前端项目的编译时,需要把 `VUE_APP_BASE_API` 修改为后端项目对应的域名。 例如说,这里使用的是 http://api.iocoder.cn ① 修改 Nginx 配置,内容如下: worker_processes 1;events { worker_connections 1024;}http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types text/plain application/x-javascript text/css application/xml application/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 server { ## 前端项目 listen 80; server_name admin.iocoder.cn; ## 重要!!!修改成你的前端域名 location / { ## 前端项目 root /work/projects/yudao-ui-admin; index index.html index.htm; try_files $uri $uri/ /index.html; } } server { ## 后端项目 listen 80; server_name api.iocoder.cn; ## 重要!!!修改成你的外网 IP/域名 ## 不要使用 location / 转发到后端项目,因为 druid、admin 等监控,不需要外网可访问。或者增加 Nginx IP 白名单限制也可以。 location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://localhost:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://localhost:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }} ② 执行 nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://api.iocoder.cn/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://admin.iocoder.cn (opens new window) 地址,成功访问前端项目,返回前端界面如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 开发环境 Docker 部署 ← 开发环境 Docker 部署→"},{"title":"开发环境","path":"/wiki/YuDaoBoot/运维手册/开发环境/开发环境.html","content":"开发指南运维手册 芋道源码 2022-04-11 目录 开发环境 在系统开发的经典模型,一般会分成 2 类 5 种环境: 【线下】本地环境(local)、开发环境(dev)、测试环境(test) 【线上】预发布环境(stage)、生产环境(prod) 每个环境、每个项目使用独立的二级域名 线下、线上各一套 MySQL 数据库,多个环境共享使用 每个环境对应一个配置文件,后端使用 application-{env}.yaml (opens new window) 文件,前端使用 .env.{env} (opens new window) 文件 友情提示:项目中暂时没有 test、stage、production 等环境的配置,需要自己创建。 另外,本文的 MySQL 数据库是基础设施的“泛指”,包括 Redis 缓存、MQ 消息队列,都需要线上线下独立。 # 1. 本地环境 后端工程师使用 application-local.yaml 配置文件,在本地电脑启动后端服务,连接线下 MySQL 数据库。考虑到不影响 dev、test 环境,会配置禁用定时任务、MQ 集群消费的执行。 前端工程师也会在本地电脑启动前端服务,一般不使用 .env.local 配置文件,而是使用 .env.dev 配置文件,访问 dev 环境的后端服务。如果需要和后端进行本地联调,可以使用 .env.local 配置文件。 # 2. 开发环境 dev 环境的用户是前端工程师、后端工程师,主要用于前后端的联调、又或者功能开发完后的自测。 一些公司可能不提供 dev 环境,直接使用 test 环境,适合团队规模较小的团队,可以降低服务器的成本。 不过,测试工程师可能比较反感 dev 和 test 环境不隔离,因为他们是按照测试用例,一轮一轮的进行验收。这个时候,如果前端或者后端工程师部署了 test 环境,“破坏”了他当前轮次的验收。 疑问:开发环境可以使用独立的 MySQL 数据库吗? 当然是可以的,提供更好的环境隔离性,避免开发阶段产生过多的脏数据,影响 test 环境的验收。 不过呢,这也带来额外的成本,部署程序到 test 环境时,需要做一次数据库的同步。 # 3. 测试环境 test 环境的用户是产品经理、测试工程师,主要用于他们的功能验收。 考虑到 test 环境的稳定性,一般建议由测试工程师使用 Jenkins 等工具,完成该环境的部署。具体的原因,上面 dev 环境已经解释了。 疑问:如果需要并行验收多个功能,怎么办? 并行验收多个功能时候,对应不同的 Git 分支,需要搭建多套测试环境。 # 4. 预发布环境 stage 环境的用户是产品经理、测试工程师,连接线上 MySQL 数据库,基于真实的数据,进行功能的全回归测试。 因为数据更加真实,且更具多样性,所以往往也会测试出较多的 Bug。比较好的解决方案,是将线上数据库定期脱敏,导入线下数据库。 考虑到 stage 环境的安全性,一般由技术经理、运维工程师进行部署。 一些公司可能不提供 stage 环境,直接上线到 production 环境,风险非常高,容易产生较多报错。 # 5. 生产环境 production 环境的用户是真实用户,即线上环境。一般发布上线时,会进行核心功能的快速测试,避免主流程存在问题。 考虑到 production 环境的问题排查效率,会给技术核心开放 MySQL 数据库的读权限。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:36 功能开启 Linux 部署 ← 功能开启 Linux 部署→"},{"title":"服务监控","path":"/wiki/YuDaoBoot/运维手册/服务监控/服务监控.html","content":"开发指南运维手册 芋道源码 2022-04-16 目录 服务监控 系统使用 Spring Boot Admin 和 SkyWalking 实现后端服务的监控。 # 1. Spring Boot Admin 阅读 《芋道 Spring Boot 监控工具 Admin 入门》 (opens new window) 文章,入门 Spring Boot Admin。 注意,Spring Boot Admin 是内嵌在 yudao-server 后端项目中,无需单独启动。 # 1.1 配置 在 application-local.yaml (opens new window) 配置文件中,通过 spring.boot.admin 配置项,设置 Spring Boot Admin 的配置。如下图所示: 疑问:prod 生产环境下,后端部署多个 JVM 进程时,spring.boot.admin.client.url 填写哪个 IP? 第一步,在 Nginx 中配置 /admin 路径,转发到多个 JVM 的 IP 上,使用 backup (opens new window) 参数实现主备。注意,该转发只允许内网访问,避免安全问题!!! 第二步,设置 spring.boot.admin.client.url 配置项,为 Nginx 的 内置 IP/admin 地址。 # 1.2 使用 ① 访问 http://127.0.0.1:48080/admin/applications (opens new window) 地址,可以在 Spring Boot Admin 中,查看到应用与实例的列表。如下图所示: ② 点击 yudao-server 应用,再点击实例,可以查看到该实例的细节信息。如下图所示: ③ 点击 [日志 -> 日志文件] 菜单,查看该示例的日志内容。如下图所示: 点击 [日志 -> 日志文件] 菜单,可动态修改 Logger 的日志级别,方便排查线上的某些 BUG。如下图所示: 补充说明:也可以通过前端的 [基础设施 -> Java 监控] 菜单。 前端 [基础设施 -> Java 监控] 菜单,通过 iframe 内嵌后端 /admin/applications 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.spring-boot-admin 配置项。 # 2. SkyWalking 阅读 《芋道 SkyWalking 极简入门》 (opens new window) 文章,入门 SkyWalking。 注意,SkyWalking 需要单独启动,预计需要 4 核 8G 的硬件资源。 # 2.1 配置 ① 在 logback-spring.xml (opens new window) 配置文件中,添加 SkyWalking 收集日志的 appender 配置。如下图所示: ② 修改 SkyWalking 在前端项目的 [基础设施 -> 监控平台] 对应的 skywaling/index.vue (opens new window) 文件,调整为你 SkyWalking 的访问地址。如下图所示: # 2.2 使用 ① 点击 [基础设施 -> 监控平台] 菜单,可以看到 SkyWalking 提供的监控平台。如下图所示: ② 点击 yudao-server 服务,查看该服务的监控信息。如下图所示: 补充说明: 前端 [基础设施 -> 监控平台] 菜单,通过 iframe 内嵌 http://skywalking.iocoder.cn 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.skywalking 配置项。 # 3. 更多监控系统 # 3.1 Prometheus 参见 《芋道 Prometheus + Grafana + Alertmanager 极简入门 》 (opens new window) 文章。 # 3.2 ELK 参见 芋道 ELK(Elasticsearch + Logstash + Kibana) 极简入门 (opens new window) 文章。 # 3.3 Sentry 参见 《Sentry 极简入门 》 (opens new window) 文章。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:25:01 HTTPS 证书 开发规范 ← HTTPS 证书 开发规范→"},{"title":"Icon 图标","path":"/wiki/YuDaoCloud/前端手册 Vue 2/Icon 图标/Icon 图标.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 Icon 图标 Element UI 内置多种 Icon 图标,可参考 Element Icon 图标 (opens new window) 的文档。 在项目的 /src/assets/icons/svg (opens new window) 目录下,自定义了 Icon 图标,默认注册到全局中,可以在项目中任意地方使用。如下图所示: # 1. 使用方式 <!-- 示例一: icon-class 为 icon 的名字 class-name 为 icon 的自定义 class--><svg-icon icon-class="password" class-name='custom-class' /><!-- 示例二: icon 为 Element UI 的图标--><el-button icon="el-icon-plus">新增</el-button><!-- 示例三:结合上述两示例 --><el-button> <svg-icon icon-class="password" class-name='custom-class' /> 新增</el-button> # 2. 自定义图标 ① 访问 https://www.iconfont.cn/ ( opens new window) 地址,搜索你想要的图标,下载 SVG 格式。如下图所示: 友情提示:其它 SVG 图标网站也可以。 ② 将 SVG 图标添加到 @/icons/svg ( opens new window) 目录下,然后进行使用。 <svg-icon icon-class="helpless" /> # 3. 改变颜色 <svg-icon /> 默认会读取其父级的 color fill: currentColor; 。 你可以改变父级的 color ,或者直接改变 fill 的颜色即可。 疑问: 如果你遇到图标颜色不对,可以参照本 issue ( opens new window) 进行修改 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 菜单路由 字典数据 ← 菜单路由 字典数据→"},{"title":"字典数据","path":"/wiki/YuDaoCloud/前端手册 Vue 2/字典数据/字典数据.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 字典数据 本小节,讲解前端如何使用 [系统管理 -> 字典管理] 菜单的字典数据,例如说字典数据的下拉框、单选 / 多选按钮、高亮展示等等。 # 1. 全局缓存 用户登录成功后,前端会从后端获取到全量的字典数据,缓存在 store 中。如下图所示: 这样,前端在使用到字典数据时,无需重复请求后端,提升用户体验。 不过,缓存暂时未提供刷新,所以在字典数据发生变化时,需要用户刷新浏览器,进行重新加载。 # 2. DICT_TYPE 在 dict.js (opens new window) 文件中,使用 DICT_TYPE 枚举了字典的 KEY。如下图所示: 后续如果有新的字典 KEY,需要你自己进行添加。 # 3. DictTag 字典标签 <dict-tag /> (opens new window) 组件,翻译字段对应的字典展示文本,并根据 colorType、cssClass 进行高亮。使用示例如下: <!-- type: 字典 KEY value: 字典值--><dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_TYPE" :value="row.logType" /> # 4. 字典工具类 在 dict.js (opens new window) 文件中,提供了字典工具类,方法如下: // 获取 dictType 对应的数据字典数组export function getDictDatas(dictType) { /** 省略代码 */ }// 获得 dictType + value 对应的字典展示文本export function getDictDataLabel(dictType, value) { /** 省略代码 */ } 结合 Element UI 的表单组件,使用示例如下: <!-- radio 单选框 --><el-radio v-for="dict in this.getDictDatas(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="parseInt(dict.value)">{{dict.label}}</el-radio><!-- select 下拉框 --><el-select v-model="form.code" placeholder="请选择渠道编码" clearable> <el-option v-for="dict in this.getDictDatas(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE)" :key="dict.value" :label="dict.label" :value="dict.value"/></el-select> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 Icon 图标 系统组件 ← Icon 图标 系统组件→"},{"title":"开发规范","path":"/wiki/YuDaoCloud/前端手册 Vue 2/开发规范/开发规范.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 开发规范 # 1. view 页面 在 @views (opens new window) 目录下,每个模块对应一个目录,它的所有功能的 .vue 都放在该目录里。 一般来说,一个路由对应一个 .vue 文件。 # 2. api 请求 在 @/api (opens new window) 目录下,每个模块对应一个 .api 文件。 每个 API 方法,会调用 request 方法,发起对后端 RESTful API 的调用。 # 2.1 请求封装 @/utils/request (opens new window) 基于 axios (opens new window) 封装,统一处理 GET、POST 方法的请求参数、请求头,以及错误提示信息等。 # 2.1.1 创建 axios 实例 baseURL 基础路径 timeout 超时时间 实现代码 import axios from 'axios'// 创建 axios 实例const service = axios.create({ // axios 中请求配置有 baseURL 选项,表示请求 URL 公共部分 baseURL: process.env.VUE_APP_BASE_API + '/admin-api/', // 此处的 /admin-api/ 地址,原因是后端的基础路径为 /admin-api/ // 超时 timeout: 10000}) # 2.1.2 Request 拦截器 Authorization、tenant-id 请求头 GET 请求参数的拼接 实现代码 import { getToken } from '@/utils/auth'import { getTenantEnable } from "@/utils/ruoyi";import Cookies from "js-cookie";service.interceptors.request.use(config => { // 是否需要设置 token const isToken = (config.headers || {}).isToken === false if (getToken() && !isToken) { config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改 } // 设置租户 if (getTenantEnable()) { const tenantId = Cookies.get('tenantId'); if (tenantId) { config.headers['tenant-id'] = tenantId; } } // get 请求映射 params 参数 if (config.method === 'get' && config.params) { let url = config.url + '?'; for (const propName of Object.keys(config.params)) { const value = config.params[propName]; var part = encodeURIComponent(propName) + "="; if (value !== null && typeof(value) !== "undefined") { if (typeof value === 'object') { for (const key of Object.keys(value)) { let params = propName + '[' + key + ']'; var subPart = encodeURIComponent(params) + "="; url += subPart + encodeURIComponent(value[key]) + "&"; } } else { url += part + encodeURIComponent(value) + "&"; } } } url = url.slice(0, -1); config.params = {}; config.url = url; } return config}, error => { console.log(error) Promise.reject(error)}) # 2.1.3 Response 拦截器 Token 失效、登录过期时,跳回首页 请求失败,Message 错误提示 实现代码 import { Notification, MessageBox, Message } from 'element-ui'import store from '@/store'import errorCode from '@/utils/errorCode'import Cookies from "js-cookie";export let isRelogin = { show: false };service.interceptors.response.use(res => { // 未设置状态码则默认成功状态 const code = res.data.code || 200; // 获取错误信息 const msg = errorCode[code] || res.data.msg || errorCode['default'] if (code === 401) { if (!isRelogin.show) { isRelogin.show = true; MessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' } ).then(() => { isRelogin.show = false; store.dispatch('LogOut').then(() => { location.href = '/index'; }) }).catch(() => { isRelogin.show = false; }); } return Promise.reject('无效的会话,或者会话已过期,请重新登录。') } else if (code === 500) { Message({ message: msg, type: 'error' }) return Promise.reject(new Error(msg)) } else if (code !== 200) { Notification.error({ title: msg }) return Promise.reject('error') } else { // 请求成功! return res.data } }, error => { console.log('err' + error) let { message } = error; if (message === "Network Error") { message = "后端接口连接异常"; } else if (message.includes("timeout")) { message = "系统接口请求超时"; } else if (message.includes("Request failed with status code")) { message = "系统接口" + message.substr(message.length - 3) + "异常"; } Message({ message: message, type: 'error', duration: 5 * 1000 }) return Promise.reject(error) }) # 2.2 交互流程 一个完整的前端 UI 交互到服务端处理流程,如下图所示: 以 [系统管理 -> 用户管理] 菜单为例,查看它是如何读取用户列表的。代码如下: // ① api/system/user.jsimport request from '@/utils/request'// 查询用户列表export function listUser(query) { return request({ url: '/system/user/page', method: 'get', params: query })}// ② views/system/user/index.vueimport { listUser } from "@/api/system/user";export default { data() { userList: null, loading: true }, methods: { getList() { this.loading = true listUser().then(response => { this.userList = response.rows this.loading = false }) } }} # 2.3 自定义 baseURL 基础路径 如果想要自定义的 baseURL 基础路径,可以通过 baseURL 进行直接覆盖。示例如下: export function listUser(query) { return request({ url: '/system/user/page', method: 'get', params: query, baseURL: 'https://www.iocoder.cn' // 自定义 })} # 3. component 组件 ① 在 @/components ( opens new window) 目录下,实现全局 组件,被所有模块所公用。例如说,富文本编辑器、各种各搜索组件、封装的分页组件等等。 ② 每个模块的业务组件,可实现在 views 目录下,自己模块的目录的 components 目录下,避免单个 .vue 文件过大,降低维护成功。例如说, @/views/pay/app/components/xxx.vue。 # 4. style 样式 ① 在 @/styles ( opens new window) 目录下,实现全局 样式,被所有页面所公用。 ② 每个 .vue 页面,可在 <style /> 标签中添加样式,注意需要添加 scoped 表示只作用在当前页面里,避免造成全局的样式污染。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 服务监控 菜单路由 ← 服务监控 菜单路由→"},{"title":"菜单路由","path":"/wiki/YuDaoCloud/前端手册 Vue 2/菜单路由/菜单路由.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-17 目录 菜单路由 前端项目基于 element-ui-admin 实现,它的 路由和侧边栏 (opens new window) 是组织起一个后台应用的关键骨架。 侧边栏和路由是绑定在一起的,所以你只有在 @/router/index.js (opens new window) 下面配置对应的路由,侧边栏就能动态的生成了,大大减轻了手动重复编辑侧边栏的工作量。 当然,这样就需要在配置路由的时候,遵循一些约定的规则。 # 1. 路由配置 首先,我们了解一下本项目配置路由时,提供了哪些配置项: // 当设置 true 的时候该路由不会在侧边栏出现 如 401,login 等页面,或者如一些编辑页面 /edit/1hidden: true // (默认 false)// 当设置 noRedirect 的时候该路由在面包屑导航中不可被点击redirect: 'noRedirect'// 1. 当你一个路由下面的 children 声明的路由大于 1 个时,自动会变成嵌套的模式。例如说,组件页面// 2. 只有一个时,会将那个子路由当做根路由显示在侧边栏。例如说,如引导页面// 若你想不管路由下面的 children 声明的个数都显示你的根路由,// 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由alwaysShow: truename: 'router-name' // 设定路由的名字,一定要填写不然使用 <keep-alive> 时会出现各种问题meta: { roles: ['admin', 'editor'] // 设置该路由进入的权限,支持多个权限叠加 title: 'title' // 设置该路由在侧边栏和面包屑中展示的名字 icon: 'svg-name' // 设置该路由的图标,支持 svg-class,也支持 el-icon-x element-ui 的 icon noCache: true // 如果设置为 true,则不会被 <keep-alive> 缓存(默认 false) breadcrumb: false // 如果设置为 false,则不会在breadcrumb面包屑中显示(默认 true) affix: true // 如果设置为 true,它则会固定在 tags-view 中(默认 false) // 当路由设置了该属性,则会高亮相对应的侧边栏。 // 这在某些场景非常有用,比如:一个文章的列表页路由为:/article/list // 点击文章进入文章详情页,这时候路由为 /article/1,但你想在侧边栏高亮文章列表的路由,就可以进行如下设置 activeMenu: '/article/list'} 普通示例 { path: '/system/test', component: Layout, redirect: 'noRedirect', hidden: false, alwaysShow: true, meta: { title: '系统管理', icon : "system" }, children: [{ path: 'index', component: (resolve) => require(['@/views/index'], resolve), name: 'Test', meta: { title: '测试管理', icon: 'user' } }]} 外链示例 { path: 'https://www.iocoder.cn', meta: { title: '芋道源码', icon : "guide" }} # 2. 路由 项目的路由分为两种:静态路由、动态路由。 # 2.1 静态路由 静态路由,代表那些不需要动态判断权限的路由,如登录页、404、个人中心等通用页面。 在 @/router/index.js ( opens new window) 的 constantRoutes ,就是配置对应的公共路由。如下图所示: # 2.2 动态路由 动态路由,代表那些需要根据用户动态判断权限,并通过 addRoutes ( opens new window) 动态添加的页面,如用户管理、角色管理等功能页面。 在用户登录成功后,会触发 @/store/modules/permission.js ( opens new window) 请求后端的菜单 RESTful API 接口,获取用户有权限 的菜单列表,并转化添加到路由中。如下图所示: 友情提示: 动态路由可以在 [系统管理 -> 菜单管理] 进行新增和修改操作,请求的后端 RESTful API 接口是 /admin-api/system/list-menus ( opens new window) 动态路由在生产环境下会默认使用路由懒加载,实现方式参考 loadView ( opens new window) 方法的判断 # 2.3 路由跳转 使用 router.push 方法,可以实现跳转到不同的页面。 // 简单跳转this.$router.push({ path: "/system/user" });// 跳转页面并设置请求参数,使用 `query` 属性this.$router.push({ path: "/system/user", query: {id: "1", name: "芋道"} }); # 3. 菜单管理 项目的菜单在 [系统管理 -> 菜单管理] 进行管理,支持无限 层级,提供目录、菜单、按钮三种类型。如下图所示: 菜单可在 [系统管理 -> 角色管理] 被分配给角色。如下图所示: # 3.1 新增目录 ① 大多数情况下,目录是作为菜单的【分类】: ② 目录也提供实现【外链】的能力: # 3.2 新增菜单 # 3.3 新增按钮 # 4. 权限控制 前端通过权限控制,隐藏用户没有权限的按钮等,实现功能级别的权限。 友情提示:前端的权限控制,主要是提升用户体验,避免操作后发现没有权限。 最终在请求到后端时,还是会进行一次权限的校验。 # 4.1 v-hasPermi 指令 v-hasPermi ( opens new window) 指令,基于权限字符,进行权限的控制。 <!-- 单个 --><el-button v-hasPermi="['system:user:create']">存在权限字符串才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasPermi="['system:user:create', 'system:user:update']">包含权限字符串才能看到</el-button> # 4.2 v-hasRole 指令 v-hasRole ( opens new window) 指令,基于角色标识,机进行的控制。 <!-- 单个 --><el-button v-hasRole="['admin']">管理员才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasRole="['role1', 'role2']">包含角色才能看到</el-button> # 4.3 结合 v-if 指令 在某些情况下,它是不适合使用 v-hasPermi 或 v-hasRole 指令,如元素标签组件。此时,只能通过手动设置 v-if,通过使用全局权限判断函数,用法是基本一致的。 <template> <el-tabs> <el-tab-pane v-if="checkPermi(['system:user:create'])" label="用户管理" name="user">用户管理</el-tab-pane> <el-tab-pane v-if="checkPermi(['system:user:create', 'system:user:update'])" label="参数管理" name="menu">参数管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin'])" label="角色管理" name="role">角色管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin','common'])" label="定时任务" name="job">定时任务</el-tab-pane> </el-tabs></template><script>import { checkPermi, checkRole } from "@/utils/permission"; // 权限判断函数export default{ methods: { checkPermi, checkRole }}</script> # 5. 页面缓存 由于目前 keep-alive 和 router-view 是强耦合的,而且查看 Vue 的文档和源码不难发现 keep-alive 的 include 默认是优先匹配组件的 name ,所以在编写路由 router 和路由对应的 view component 的时候一定要确保 两者的 name 是完全一致的。 注意,切记 view component 的 name 命名时候尽量保证唯一性,切记不要和某些组件的命名重复了,不然会递归引用最后内存溢出等问题。 友情提示:页面缓存是什么? 简单来说,Tab 切换时,开启页面缓存的 Tab 保持原本的状态,不进行刷新。 详细可见 Vue 文档 —— KeepAlive ( opens new window) # 5.1 静态路由的示例 ① router 路由的 name 声明如下: { path: 'create-form', component: ()=>import('@/views/form/create'), name: 'createForm', meta: { title: 'createForm', icon: 'table' }} ② view component 的 name 声明如下: export default { name: 'createForm'} 一定要保证两者的名字相同,切记写重或者写错。默认如果不写 name 就不会被缓存,详情见 issue (opens new window)。 # 5.2 动态路由的示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 开发规范 Icon 图标 ← 开发规范 Icon 图标→"},{"title":"系统组件","path":"/wiki/YuDaoCloud/前端手册 Vue 2/系统组件/系统组件.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 系统组件 # 1. 引入三方组件 除了 Element UI 组件以及项目内置的系统组件,有时还需要引入其它三方组件 (opens new window)。 # 1.1 如何安装 这里,以引入 vue-count-to (opens new window) 为例。在终端输入下面的命令完成安装: ## 加上 --save 参数,会自动添加依赖到 package.json 中去。npm install vue-count-to --save # 1.2 如何注册 Vue 注册组件有两种方式:全局注册、局部注册。 # 1.2.1 局部注册 在对应的 Vue 页面中,使用 components 属性来注册组件。代码如下: <template> <countTo :startVal='startVal' :endVal='endVal' :duration='3000'></countTo></template><script>import countTo from 'vue-count-to';export default { components: { countTo }, // components 属性 data () { return { startVal: 0, endVal: 2017 } }}</script> # 1.2.2 全局注册 ① 在 main.js ( opens new window) 中,全局注册组件。代码如下: import countTo from 'vue-count-to'Vue.component('countTo', countTo) ② 在对应的 Vue 页面中,直接使用组件,无需注册。代码如下: <template> <countTo :startVal='startVal' :endVal='endVal' :duration='3000'></countTo></template> # 2. 系统组件 项目使用到的相关组件。 # 2.1 基础框架组件 element-ui ( opens new window) vue-element-admin ( opens new window) # 2.2 树形选择组件 vue-treeselect ( opens new window) 在 menu/index.vue ( opens new window) 的使用案例: <el-form-item label="上级菜单"> <treeselect v-model="form.parentId" :options="menuOptions" :normalizer="normalizer" :show-count="true" placeholder="选择上级菜单"/></el-form-item> # 2.3 表格分页组件 el-pagination (opens new window),二次封装成 pagination (opens new window) 组件。 在 notice/index.vue (opens new window) 的使用案例: <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize" @pagination="getList"/> # 2.4 工具栏右侧组件 right-toolbar (opens new window) 在 notice/index.vue (opens new window) 的使用案例: <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> # 2.5 文件上传组件 file-upload (opens new window) # 2.6 图片上传组件 图片上传组件 image-upload (opens new window) 图片预览组件 image-preview (opens new window) # 2.7 富文本编辑器 quill (opens new window),二次封装成 Editor (opens new window) 组件。 在 notice/index.vue (opens new window) 的使用案例: <el-form-item label="内容"> <editor v-model="form.content" :min-height="192"/></el-form-item> # 2.8 表单设计组件 ① 表单设计组件 form-generator (opens new window) 在 build/index.vue (opens new window) 中使用,效果如下图: ② 表单展示组件 parser (opens new window),基于 form-generator (opens new window) 封装。 在 processInstance/create.vue (opens new window) 的使用案例: <parser :key="new Date().getTime()" :form-conf="detailForm" @submit="submitForm" /> # 2.9 工作流组件 bpmn-process-designer (opens new window),二次封装成 bpmnProcessDesigner (opens new window) 工作流设计组件 ① 工作流设计组件 my-process-designer (opens new window),在 bpm/model/modelEditor.vue (opens new window) 中使用案例: <!-- 流程设计器,负责绘制流程等 --><my-process-designer :key="`designer-${reloadIndex}`" v-model="xmlString" v-bind="controlForm" keyboard ref="processDesigner" @init-finished="initModeler" @save="save"/><!-- 流程属性器,负责编辑每个流程节点的属性 --><my-properties-panel :key="`penal-${reloadIndex}`" :bpmn-modeler="modeler" :prefix="controlForm.prefix" class="process-panel" :model="model" /> ② 工作流展示组件 my-process-viewer (opens new window),在 bpm/model/modelEditor.vue (opens new window) 中使用案例: <my-process-viewer key="designer" v-model="bpmnXML" v-bind="bpmnControlForm" :activityData="activityList" :processInstanceData="processInstance" :taskData="tasks" /> # 2.10 Cron 表达式组件 vue-crontab (opens new window),二次封装成 crontab (opens new window) 组件。 在 job/index.vue (opens new window) 的使用案例: <crontab @hide="openCron=false" @fill="crontabFill" :expression="expression"></crontab> # 2.11 内容复制组件 clipboard (opens new window),使用可见 文档 (opens new window)。 在 codegen/index.vue (opens new window) 的使用案例: <el-link :underline="false" icon="el-icon-document-copy" style="float:right" v-clipboard:copy="item.code" v-clipboard:success="clipboardSuccess"> 复制</el-link> # 3. 其它推荐组件 推荐一些其它组件,可自己引入后使用。 Tree Table 树形表格:使用文档 (opens new window) Excel 前端直接导出:使用文档 (opens new window) CodeMirror 代码编辑器:使用文档 (opens new window) wangEditor 文本编辑器:使用文档 (opens new window) mavonEditor Markdown 编辑器:使用文档 (opens new window) # 4. 自定义组件 在 @/components (opens new window) 目录下,创建 .vue 文件,在通过 components 进行注册即可。 # 4.1 创建使用 新建一个简单的 a 组件来举例子。 ① 在 @/components/ 目录下,创建 test 文件,再创建 a.vue 文件。代码如下: <!-- 子组件 --><template> <div>这是a组件</div></template> ② 在其它 Vue 页面,导入并注册后使用。代码如下: <!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa></testa> <!-- 3. 使用 --> </div></template><script>import a from "@/components/a"; // 1. 引入export default { components: { testa: a } // 2. 注册};</script> # 4.2 组件通信 基于上述的 a 示例组件,讲解父子组件如何通信。 ① 子组件通过 props 属性,来接收父组件传递的值。代码如下: <!-- 子组件 --><template> <div>这是a组件 name:{{ name }}</div></template><script> export default { props: { // 1. props 的 name 进行接收 name: { type: String, default: "" }, } };</script><!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa :name="name"></testa> <!-- 2. :name 传入 --> </div></template><script>import a from "@/components/a";export default { components: { testa: a }, data() { return { name: "芋道" }; },};</script> ② 子组件通过 $emit 方法,让父组件监听到自定义事件。代码如下: <!-- 子组件 --><template> <div> 这是a组件 name:{{ name }} <button @click="click">发送</button> </div></template><script>export default { props: { name: { type: String, default: "" }, }, data() { return { message: "我是来自子组件的消息" }; }, methods: { click() { this.$emit("ok", this.message); // 1. $emit 方法,通知 ok 事件,message 是参数 }, },};</script><!-- 父组件 --><template> <div style="text-align: center; font-size: 20px"> 测试页面 <testa :name="name" @ok="ok"></testa> 子组件传来的值 : {{ message }} </div></template><script>import a from "@/components/a";export default { components: { testa: a }, data() { return { name: "芋道", message: "" }; }, methods: { ok(message) { // 2. 声明 ok 方法,监听 ok 自定义事件 this.message = message; }, },};</script> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 字典数据 通用方法 ← 字典数据 通用方法→"},{"title":"配置读取","path":"/wiki/YuDaoCloud/前端手册 Vue 2/配置读取/配置读取.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 配置读取 在 [基础设施 -> 配置管理] 菜单,可以动态修改配置,无需重启服务器即可生效。 提示 对应 《后端手册 —— 配置中心》 文档。 # 1. 读取配置 前端调用 /@api/infra/config (opens new window) 的 #getConfigKey(configKey) 方法,获取指定 key 对应的配置的值。代码如下: export function getConfigKey(configKey) { return request({ url: '/infra/config/get-value-by-key?key=' + configKey, method: 'get' })} # 2. 实战案例 在 src/views/infra/server/index.vue ( opens new window) 页面中,获取 key 为 \"url.skywalking\" 的配置的值。代码如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:10 通用方法 开发规范 ← 通用方法 开发规范→"},{"title":"通用方法","path":"/wiki/YuDaoCloud/前端手册 Vue 2/通用方法/通用方法.html","content":"开发指南前端手册 Vue 2 芋道源码 2022-04-18 目录 通用方法 本小节,分享前端项目的常用方法。 # 1. $tab 对象 @tab 对象,由 plugins/tab.js (opens new window) 实现,用于 Tab 标签相关的操作。它有如下方法: ① 打开页签 this.$tab.openPage("用户管理", "/system/user");this.$tab.openPage("用户管理", "/system/user").then(() => { // 执行结束的逻辑}) ② 修改页签 const obj = Object.assign({}, this.$route, { title: "自定义标题" })this.$tab.updatePage(obj);this.$tab.updatePage(obj).then(() => { // 执行结束的逻辑}) ③ 关闭页签 // 关闭当前 tab 页签,打开新页签const obj = { path: "/system/user" };this.$tab.closeOpenPage(obj);// 关闭当前页签,回到首页this.$tab.closePage();// 关闭指定页签const obj = { path: "/system/user", name: "User" };this.$tab.closePage(obj);this.$tab.closePage(obj).then(() => { // 执行结束的逻辑}) ④ 刷新页签 // 刷新当前页签this.$tab.refreshPage();// 刷新指定页签const obj = { path: "/system/user", name: "User" };this.$tab.refreshPage(obj);this.$tab.refreshPage(obj).then(() => { // 执行结束的逻辑}) ⑤ 关闭所有页签 this.$tab.closeAllPage();this.$tab.closeAllPage().then(() => { // 执行结束的逻辑}) ⑥ 关闭左侧页签 this.$tab.closeLeftPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeLeftPage(obj);this.$tab.closeLeftPage(obj).then(() => { // 执行结束的逻辑}) ⑦ 关闭右侧页签 this.$tab.closeRightPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeRightPage(obj);this.$tab.closeRightPage(obj).then(() => { // 执行结束的逻辑}) ⑧ 关闭其它页签 this.$tab.closeOtherPage();const obj = { path: "/system/user", name: "User" };this.$tab.closeOtherPage(obj);this.$tab.closeOtherPage(obj).then(() => { // 执行结束的逻辑}) # 2. $modal 对象 @modal 对象,由 plugins/modal.js (opens new window) 实现,用于做消息提示、通知提示、对话框提醒、二次确认、遮罩等。它有如下方法: ① 提供成功、警告和错误等反馈信息 this.$modal.msg("默认反馈");this.$modal.msgError("错误反馈");this.$modal.msgSuccess("成功反馈");this.$modal.msgWarning("警告反馈"); ② 提供成功、警告和错误等提示信息 this.$modal.alert("默认提示");this.$modal.alertError("错误提示");this.$modal.alertSuccess("成功提示");this.$modal.alertWarning("警告提示"); ③ 提供成功、警告和错误等通知信息 this.$modal.notify("默认通知");this.$modal.notifyError("错误通知");this.$modal.notifySuccess("成功通知");this.$modal.notifyWarning("警告通知"); ④ 提供确认窗体信息 this.$modal.confirm('确认信息').then(function() { // ...}).then(() => { // ...}).catch(() => {}); ⑤ 提供遮罩层信息 // 打开遮罩层this.$modal.loading("正在导出数据,请稍后...");// 关闭遮罩层this.$modal.closeLoading(); # 3. $auth 对象 @auth 对象,由 plugins/auth.js (opens new window) 实现,用于验证用户是否拥有某(些)权限或角色。它有如下方法: ① 验证用户权限 // 验证用户是否具备某权限this.$auth.hasPermi("system:user:add");// 验证用户是否含有指定权限,只需包含其中一个this.$auth.hasPermiOr(["system:user:add", "system:user:update"]);// 验证用户是否含有指定权限,必须全部拥有this.$auth.hasPermiAnd(["system:user:add", "system:user:update"]); ② 验证用户角色 // 验证用户是否具备某角色this.$auth.hasRole("admin");// 验证用户是否含有指定角色,只需包含其中一个this.$auth.hasRoleOr(["admin", "common"]);// 验证用户是否含有指定角色,必须全部拥有this.$auth.hasRoleAnd(["admin", "common"]); # 4. $cache 对象 @auth 对象,由 plugins/cache.js (opens new window) 实现,基于 session 或 local 实现不同级别的缓存。它有如下方法: 对象名称 缓存类型 session 会话级缓存,通过 sessionStorage (opens new window) 实现 local 本地级缓存,通过 localStorage (opens new window) 实现 ① 读写 String 缓存 // local 普通值this.$cache.local.set('key', 'local value')console.log(this.$cache.local.get('key')) // 输出 'local value'// session 普通值this.$cache.session.set('key', 'session value')console.log(this.$cache.session.get('key')) // 输出 'session value' ② 读写 JSON 缓存 // local JSON值 this.$cache.local.setJSON('jsonKey', { localProp: 1 })console.log(this.$cache.local.getJSON('jsonKey')) // 输出 '{localProp: 1}'// session JSON值this.$cache.session.setJSON('jsonKey', { sessionProp: 1 })console.log(this.$cache.session.getJSON('jsonKey')) // 输出 '{sessionProp: 1}' ③ 删除缓存 this.$cache.local.remove('key')this.$cache.session.remove('key') # 5. $download 对象 $download 对象,由 plugins/download.js (opens new window) 实现,用于各种类型的文件下载。它有如下方法: 方法列表 this.$download.excel(data, fileName);this.$download.word(data, fileName);this.$download.zip(data, fileName);this.$download.html(data, fileName);this.$download.markdown(data, fileName); 在 user/index.vue (opens new window) 页面中,导出 Excel 文件的代码如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 系统组件 配置读取 ← 系统组件 配置读取→"},{"title":"CRUD 组件","path":"/wiki/YuDaoCloud/前端手册 Vue 3/CRUD 组件/CRUD 组件.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-05 目录 CRUD 组件 管理后台的功能,一般就是 CRUD 增删改查,可以拆分 3 个部分:“列表”、“新增/修改”、“详情”,如下图所示: 部分 组件 示例 列表 Search + Table 新增 / 修改 Form 详情 Descriptions # 1. 基础组件 涉及到 4 个前端基础组件,如下所示: 组件 文档 Search (opens new window) 查询组件 (opens new window) Table (opens new window) 表格组件 (opens new window) Form (opens new window) 表单组件 (opens new window) Descriptions (opens new window) 描述组件 (opens new window) # 2. CRUD 组件 由于以上 4 个组件都需要 Schema 或者 columns 的字段,如果每个组件都写一遍的话,会造成大量重复代码,所以提供 useCrudSchemas 来进行统一的数据生成。 ① useCrudSchemas:位于 src/hooks/web/useCrudSchemas.ts (opens new window) 内 ② useCrudSchemas 可以理解成一个 JSON 配置,示例如下: useCrudSchemas 示例 <script setup lang="ts">import { CrudSchema, useCrudSchemas } from '@/hooks/web/useCrudSchemas'const crudSchemas = reactive<CrudSchema[]>([ { field: 'index', label: t('tableDemo.index'), type: 'index', form: { show: false }, detail: { show: false } }, { field: 'title', label: t('tableDemo.title'), search: { show: true }, form: { colProps: { span: 24 } }, detail: { span: 24 } }, { field: 'author', label: t('tableDemo.author') }, { field: 'display_time', label: t('tableDemo.displayTime'), form: { component: 'DatePicker', componentProps: { type: 'datetime', valueFormat: 'YYYY-MM-DD HH:mm:ss' } } }, { field: 'importance', label: t('tableDemo.importance'), formatter: (_: Recordable, __: TableColumn, cellValue: number) => { return h( ElTag, { type: cellValue === 1 ? 'success' : cellValue === 2 ? 'warning' : 'danger' }, () => cellValue === 1 ? t('tableDemo.important') : cellValue === 2 ? t('tableDemo.good') : t('tableDemo.commonly') ) }, form: { component: 'Select', componentProps: { options: [ { label: '重要', value: 3 }, { label: '良好', value: 2 }, { label: '一般', value: 1 } ] } } }, { field: 'pageviews', label: t('tableDemo.pageviews'), form: { component: 'InputNumber', value: 0 } }, { field: 'content', label: t('exampleDemo.content'), table: { show: false }, form: { component: 'Editor', colProps: { span: 24 } }, detail: { span: 24 } }, { field: 'action', width: '260px', label: t('tableDemo.action'), form: { show: false }, detail: { show: false } }])const { allSchemas } = useCrudSchemas(crudSchemas)</script> ③ 字段的详细说明,可见 useCrudSchemas 文档 (opens new window)。 # 3. 实战案例 项目的 [系统管理 -> 邮箱管理] 相关的功能,都使用 CRUD 实现,你可以自己去学习。 功能 代码 邮箱账号 src/views/system/mail/account (opens new window) 邮箱模版 src/views/system/mail/template (opens new window) 邮箱记录 src/views/system/mail/log (opens new window) # 4. 常见问题 # 4.1 如何隐藏某个字段? 如 formSchema 不需要 field 为 createTime 的字段,可以使用 form: { show: false } 或 isForm: false 进行过滤,其他组件同理。 # 4.2 如何使用数据字典? 设置 dictType 字典的类型,和 dictClass 字典的数据类型。 # 4.3 如何使用 API 获取数据? 使用 api 来获取接口数据,需要主动 return 数据。 # 4.4 如何结合 Slot 自定义? 如果想要自定义,可以结合 Slot 来实现。具体有哪些 Slot,阅读对应基础组件的文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/05, 22:46:17 配置读取 国际化 ← 配置读取 国际化→"},{"title":"IDE 调试","path":"/wiki/YuDaoCloud/前端手册 Vue 3/IDE 调试/IDE 调试.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-13 目录 IDE 调试 除了使用 Chrome 调试 JS 代码外,我们也可以使用 IDEA / WebStorm 或 VS Code 进行代码的调试。 # 1. IDEA 调试 友情提示:WebStorm 也支持。 ① 使用 npm 命令将前端项目运行起来,例如说 npm run dev。耐心等待项目启动成功~ ② 点击链接,Windows 需按住 Ctrl + Shift + 鼠标左键,MacOS 需要按住 Shift + Command + 鼠标左键。如下图所示: ③ 点击后,会跳出一个独立的 Chrome 窗口。如下图所示: ④ 打个断点,例如说 /src/api/login/index.ts 的登录接口。如下图所示: ⑤ 使用管理后台进行登录,可以看到成功进入断点。如下图所示: # 2. VS Code 调试 ① 使用 npm 命令将前端项目运行起来,例如说 npm run dev。耐心等待项目启动成功~ ② 点击 VS Code 左侧的运行和调试,然后启动 Launch,之后会跳出一个独立的 Edge 窗口。如下图所示: ③ 打个断点,例如说 /src/api/login/index.ts 的登录接口。如下图所示: ④ 使用管理后台进行登录,可以看到成功进入断点。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/13, 23:26:36 国际化 【v1.7.3】开发中 ← 国际化 【v1.7.3】开发中→"},{"title":"Icon 图标","path":"/wiki/YuDaoCloud/前端手册 Vue 3/Icon 图标/Icon 图标.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 Icon 图标 Element Plus 内置多种 Icon 图标,可参考 Element Plus —— Icon 图标 (opens new window) 的文档。 在项目的 /src/assets/svgs (opens new window) 目录下,自定义了 Icon 图标,默认注册到全局中,可以在项目中任意地方使用。如下图所示: # 1. Icon 图标组件 友情提示: 该小节,基于 《vue element plus admin —— Icon 图标组件 》 (opens new window) 的内容修改。 Icon 组件位于 src/components/Icon (opens new window) 内,用于项目内组件的展示,基本支持所有图标库(支持按需加载,只打包所用到的图标),支持使用本地 svg 和 Iconify (opens new window) 图标。 提示 在 Iconify (opens new window) 上,你可以查询到你想要的所有图标并使用,不管是不是 element-plus 的图标库。 # 1.1 基本用法 如果以 svg-icon: 开头,则会在本地中找到该 svg 图标,否则,会加载 Iconify 图标。代码如下: <template> <!-- 加载本地 svg --> <Icon icon="svg-icon:peoples" /> <!-- 加载 Iconify --> <Icon icon="ep:aim" /></template> # 1.2 useIcon 如果需要在其他组件中如 ElButton 传入 icon 属性,可以使用 useIcon。代码如下: <script setup lang="ts">import { useIcon } from '@/hooks/web/useIcon'import { ElButton } from 'element-plus'const icon = useIcon({ icon: 'svg-icon:save' })</script><template> <ElButton :icon="icon"> button </ElButton></template> useIcon 的 props 属性如下: 属性 说明 类型 可选值 默认值 icon 图标名 string - - color 图标颜色 string - - size 图标大小 number - 16 # 2. 自定义图标 ① 访问 https://www.iconfont.cn/ (opens new window) 地址,搜索你想要的图标,下载 SVG 格式。如下图所示: 友情提示:其它 SVG 图标网站也可以。 ② 将 SVG 图标添加到 /src/assets/svgs (opens new window) 目录下,然后进行使用。 <Icon icon="svg-icon:helpless" /> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 菜单路由 字典数据 ← 菜单路由 字典数据→"},{"title":"国际化","path":"/wiki/YuDaoCloud/前端手册 Vue 3/国际化/国际化.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 国际化 友情提示: 该章节,基于 《vue element plus admin —— 国际化》 (opens new window) 的内容修改。 如果你使用的 vscode 开发工具,则推荐安装 I18n-ally (opens new window) 这个插件 # 1. I18n-ally 插件 安装了该插件后,你的代码内可以实时看到对应的语言内容 # 2. 配置默认语言 在 src/store/modules/locale.ts (opens new window) 内配置 currentLocale 为其他语言。 查看代码 import { defineStore } from 'pinia'import { store } from '../index'import zhCn from 'element-plus/es/locale/lang/zh-cn'import en from 'element-plus/es/locale/lang/en'import { CACHE_KEY, useCache } from '@/hooks/web/useCache'import { LocaleDropdownType } from '@/types/localeDropdown'const { wsCache } = useCache()const elLocaleMap = { 'zh-CN': zhCn, en: en}interface LocaleState { currentLocale: LocaleDropdownType localeMap: LocaleDropdownType[]}export const useLocaleStore = defineStore('locales', { state: (): LocaleState => { return { currentLocale: { lang: wsCache.get(CACHE_KEY.LANG) || 'zh-CN', elLocale: elLocaleMap[wsCache.get(CACHE_KEY.LANG) || 'zh-CN'] }, // 多语言 localeMap: [ { lang: 'zh-CN', name: '简体中文' }, { lang: 'en', name: 'English' } ] } }, getters: { getCurrentLocale(): LocaleDropdownType { return this.currentLocale }, getLocaleMap(): LocaleDropdownType[] { return this.localeMap } }, actions: { setCurrentLocale(localeMap: LocaleDropdownType) { // this.locale = Object.assign(this.locale, localeMap) this.currentLocale.lang = localeMap?.lang this.currentLocale.elLocale = elLocaleMap[localeMap?.lang] wsCache.set(CACHE_KEY.LANG, localeMap?.lang) } }})export const useLocaleStoreWithOut = () => { return useLocaleStore(store)} # 3. 语言文件 在 src/locales (opens new window) 可以配置具体的语言。 目前项目中的语言都是没有拆分的,全部放一起,后续会考虑拆分出来,比较好维护。 # 4. 语言导入逻辑说明 在 src/plugins/vueI18n/index.ts (opens new window) 内可以看到 const defaultLocal = await import(`../../locales/${locale.lang}.ts`) 这会导入 src/locales 文件语言包。 # 5. 使用 引入项目自带的 useI18n 注意不要引入 vue-i18n 的 useI18n import { useI18n } from '/@/hooks/web/useI18n'const { t } = useI18n()const title = t('common.menu') # 6. 切换语言 切换语言需要使用 src/hooks/web/useLocale.ts ( opens new window) import { useLocale } from '@/hooks/web/useLocale'const { changeLocale } = useLocale()changeLocale('en') # 7. 新增新语言 # 7.1 语言文件 在 src/locales ( opens new window) 增加对应语言的文件即可 # 7.2 新增语言 目前项目自带的语言只有 zh_CN 和 en 两种 如果需要新增,按以下操作即可 在 src/locales ( opens new window) 下语言文件 在 types/global.d.ts ( opens new window) 给 LocaleType 添加对应的类型 在 src/store/modules/locale.ts localeMap 中添加对应语言 # 8. 远程读取语言数据 目前项目会在 src/main.ts 内等待 setupI18n 这个函数执行完之后才会渲染界面,所以只需在 setupI18n 内的 createI18nOptions 发送 ajax 请求,将对应的数据设置到 i18n 实例上即可。 const createI18nOptions = async (): Promise<I18nOptions> => { const localeStore = useLocaleStoreWithOut() const locale = localeStore.getCurrentLocale const localeMap = localeStore.getLocaleMap // 这里改为远程请求即可。 const defaultLocal = await import(`../../locales/${locale.lang}.ts`) const message = defaultLocal.default ?? {} setHtmlPageLang(locale.lang) localeStore.setCurrentLocale({ lang: locale.lang // elLocale: elLocal }) return { legacy: false, locale: locale.lang, fallbackLocale: locale.lang, messages: { [locale.lang]: message }, availableLocales: localeMap.map((v) => v.lang), sync: true, silentTranslationWarn: true, missingWarn: false, silentFallbackWarn: true }} # 8.1 useLocale 代码: src/hooks/web/useLocale.ts ( opens new window) 当手动切换语言的时候会触发 useLocale 函数,useLocale 也是异步函数,只需等待接口返回响应的数据后,再进行设置即可 export const useLocale = () => { // Switching the language will change the locale of useI18n // And submit to configuration modification const changeLocale = async (locale: LocaleType) => { const globalI18n = i18n.global // 改为远程获取 const langModule = await import(`../../locales/${locale}.ts`) globalI18n.setLocaleMessage(locale, langModule.default) setI18nLanguage(locale) } return { changeLocale }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/05, 22:46:17 CRUD 组件 IDE 调试 ← CRUD 组件 IDE 调试→"},{"title":"字典数据","path":"/wiki/YuDaoCloud/前端手册 Vue 3/字典数据/字典数据.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-04-17 目录 字典数据 本小节,讲解前端如何使用 [系统管理 -> 字典管理] 菜单的字典数据,例如说字典数据的下拉框、单选 / 多选按钮、高亮展示等等。 # 1. 全局缓存 用户登录成功后,前端会从后端获取到全量的字典数据,缓存在 store 中。如下图所示: 这样,前端在使用到字典数据时,无需重复请求后端,提升用户体验。 不过,缓存暂时未提供刷新,所以在字典数据发生变化时,需要用户刷新浏览器,进行重新加载。 # 2. DICT_TYPE 在 dict.ts (opens new window) 文件中,使用 DICT_TYPE 枚举了字典的 KEY。如下图所示: 后续如果有新的字典 KEY,需要你自己进行添加。 # 3. DictTag 字典标签 <dict-tag /> (opens new window) 组件,翻译字段对应的字典展示文本,并根据 colorType、cssClass 进行高亮。使用示例如下: <!-- type: 字典 KEY value: 字典值--><dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_TYPE" :value="row.logType" /> 【推荐】注意,一般情况下使用 CRUD schemas 方式,不需要直接使用 <dict-tag />,而是通过 columns 的 dictType 和 dictClass 属性即可。如下图所示: # 4. 字典工具类 在 dict.ts (opens new window) 文件中,提供了字典工具类,方法如下: // 获取 dictType 对应的数据字典数组【object】export const getDictOptions = (dictType: string) => {{ /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【int】export const getIntDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【string】export const getStrDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【boolean】export const getBoolDictOptions = (dictType: string) => { /** 省略代码 */ }// 获取 dictType 对应的数据字典数组【object】export const getDictObj = (dictType: string, value: any) => { /** 省略代码 */ } 结合 Element Plus 的表单组件,使用示例如下: <template> <!-- radio 单选框 --> <el-radio v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="parseInt(dict.value)" > {{dict.label}} </el-radio> <!-- select 下拉框 --> <el-select v-model="form.code" placeholder="请选择渠道编码" clearable> <el-option v-for="dict in getStrDictOptions(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE)" :key="dict.value" :label="dict.label" :value="dict.value" /> </el-select></template><script setup lang="tsx">import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'</script> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 Icon 图标 系统组件 ← Icon 图标 系统组件→"},{"title":"开发规范","path":"/wiki/YuDaoCloud/前端手册 Vue 3/开发规范/开发规范.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-04-17 目录 开发规范 # 0. 实战案例 本小节,提供大家开发管理后台的功能时,最常用的普通列表、树形列表、新增与修改的表单弹窗、详情表单弹窗的实战案例。 # 0.1 普通列表 可参考 [系统管理 -> 岗位管理] 菜单: API 接口:/src/api/system/post/index.ts (opens new window) 列表界面:/src/views/system/post/index.vue (opens new window) 表单界面:/src/views/system/post/PostForm.vue (opens new window) 为什么界面拆成列表和表单两个 Vue 文件? 每个 Vue 文件,只实现一个功能,更简洁,维护性更好,Git 代码冲突概率低。 # 0.2 树形列表 可参考 [系统管理 -> 部门管理] 菜单: API 接口:/src/api/system/dept/index.ts (opens new window) 列表界面:/src/views/system/dept/index.vue (opens new window) 表单界面:/src/views/system/dept/DeptForm.vue (opens new window) # 0.3 高性能列表 可参考 [系统管理 -> 地区管理] 菜单,对应 /src/views/system/area/index.vue (opens new window) 列表界面 基于 Virtualized Table 虚拟化表格 (opens new window) 实现,解决一屏里超过 1000 条数据记录时,就会出现卡顿等性能问题。 # 0.4 详情弹窗 可参考 [基础设施 -> API 日志 -> 访问日志] 菜单,对应 /src/views/infra/apiAccessLog/ApiAccessLogDetail.vue (opens new window) 详情弹窗 # 1. view 页面 在 @views (opens new window) 目录下,每个模块对应一个目录,它的所有功能的 .vue 都放在该目录里。 一般来说,一个路由对应一个 index.vue 文件。 # 2. api 请求 在 @/api (opens new window) 目录下,每个模块对应一个 index.ts API 文件。 API 方法:会调用 request 方法,发起对后端 RESTful API 的调用。 interface 类型:定义了 API 的请求参数和返回结果的类型,对应后端的 VO 类型。 # 2.1 请求封装 /src/config/axios/index.ts (opens new window) 基于 axios (opens new window) 封装,统一处理 GET、POST 方法的请求参数、请求头,以及错误提示信息等。 # 2.1.1 创建 axios 实例 baseURL 基础路径 timeout 超时时间,默认为 30000 毫秒 实现代码 /src/config/axios/service.ts import axios from 'axios'const { result_code, base_url, request_timeout } = config// 创建 axios 实例const service: AxiosInstance = axios.create({ baseURL: base_url, // api 的 base_url timeout: request_timeout, // 请求超时时间 withCredentials: false // 禁用 Cookie 等信息}) # 2.1.2 Request 拦截器 【重点】Authorization、tenant-id 请求头 GET 请求参数的拼接 实现代码 /src/config/axios/service.ts import axios, { AxiosInstance, AxiosRequestHeaders, AxiosResponse, AxiosError, InternalAxiosRequestConfig} from 'axios'import { getAccessToken, getTenantId } from '@/utils/auth'const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLEservice.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // 是否需要设置 token let isToken = (config!.headers || {}).isToken === false whiteList.some((v) => { if (config.url) { config.url.indexOf(v) > -1 return (isToken = false) } }) if (getAccessToken() && !isToken) { (config as Recordable).headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token } // 设置租户 if (tenantEnable && tenantEnable === 'true') { const tenantId = getTenantId() if (tenantId) (config as Recordable).headers['tenant-id'] = tenantId } const params = config.params || {} const data = config.data || false if ( config.method?.toUpperCase() === 'POST' && (config.headers as AxiosRequestHeaders)['Content-Type'] === 'application/x-www-form-urlencoded' ) { config.data = qs.stringify(data) } // get参数编码 if (config.method?.toUpperCase() === 'GET' && params) { let url = config.url + '?' for (const propName of Object.keys(params)) { const value = params[propName] if (value !== void 0 && value !== null && typeof value !== 'undefined') { if (typeof value === 'object') { for (const val of Object.keys(value)) { const params = propName + '[' + val + ']' const subPart = encodeURIComponent(params) + '=' url += subPart + encodeURIComponent(value[val]) + '&' } } else { url += `${propName}=${encodeURIComponent(value)}&` } } } // 给 get 请求加上时间戳参数,避免从缓存中拿数据 // const now = new Date().getTime() // params = params.substring(0, url.length - 1) + `?_t=${now}` url = url.slice(0, -1) config.params = {} config.url = url } return config }, (error: AxiosError) => { // Do something with request error console.log(error) // for debug Promise.reject(error) }) # 2.1.3 Response 拦截器 访问令牌 AccessToken 过期时,使用刷新令牌 RefreshToken 刷新,获得新的访问令牌 刷新令牌失败(过期)时,跳回首页进行登录 请求失败,Message 错误提示 实现代码 /src/config/axios/service.ts import axios, { AxiosInstance, AxiosRequestHeaders, AxiosResponse, AxiosError, InternalAxiosRequestConfig} from 'axios'import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'import { getAccessToken, getRefreshToken, removeToken, setToken } from '@/utils/auth'// 需要忽略的提示。忽略后,自动 Promise.reject('error')const ignoreMsgs = [ '无效的刷新令牌', // 刷新令牌被删除时,不用提示 '刷新令牌已过期' // 使用刷新令牌,刷新获取新的访问令牌时,结果因为过期失败,此时需要忽略。否则,会导致继续 401,无法跳转到登出界面]// 是否显示重新登录export const isRelogin = { show: false }import errorCode from './errorCode'import { resetRouter } from '@/router'import { useCache } from '@/hooks/web/useCache'service.interceptors.response.use( async (response: AxiosResponse<any>) => { const { data } = response const config = response.config if (!data) { // 返回“[HTTP]请求没有返回值”; throw new Error() } const { t } = useI18n() // 未设置状态码则默认成功状态 const code = data.code || result_code // 二进制数据则直接返回 if ( response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer' ) { return response.data } // 获取错误信息 const msg = data.msg || errorCode[code] || errorCode['default'] if (ignoreMsgs.indexOf(msg) !== -1) { // 如果是忽略的错误码,直接返回 msg 异常 return Promise.reject(msg) } else if (code === 401) { // 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了 if (!isRefreshToken) { isRefreshToken = true // 1. 如果获取不到刷新令牌,则只能执行登出操作 if (!getRefreshToken()) { return handleAuthorized() } // 2. 进行刷新访问令牌 try { const refreshTokenRes = await refreshToken() // 2.1 刷新成功,则回放队列的请求 + 当前请求 setToken((await refreshTokenRes).data.data) config.headers!.Authorization = 'Bearer ' + getAccessToken() requestList.forEach((cb: any) => { cb() }) requestList = [] return service(config) } catch (e) { // 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。 // 2.2 刷新失败,只回放队列的请求 requestList.forEach((cb: any) => { cb() }) // 提示是否要登出。即不回放当前请求!不然会形成递归 return handleAuthorized() } finally { requestList = [] isRefreshToken = false } } else { // 添加到队列,等待刷新获取到新的令牌 return new Promise((resolve) => { requestList.push(() => { config.headers!.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token 请根据实际情况自行修改 resolve(service(config)) }) }) } } else if (code === 500) { ElMessage.error(t('sys.api.errMsg500')) return Promise.reject(new Error(msg)) } else if (code === 901) { ElMessage.error({ offset: 300, dangerouslyUseHTMLString: true, message: '<div>' + t('sys.api.errMsg901') + '</div>' + '<div> &nbsp; </div>' + '<div>参考 https://doc.iocoder.cn/ 教程</div>' + '<div> &nbsp; </div>' + '<div>5 分钟搭建本地环境</div>' }) return Promise.reject(new Error(msg)) } else if (code !== 200) { if (msg === '无效的刷新令牌') { // hard coding:忽略这个提示,直接登出 console.log(msg) } else { ElNotification.error({ title: msg }) } return Promise.reject('error') } else { return data } }, (error: AxiosError) => { console.log('err' + error) // for debug let { message } = error const { t } = useI18n() if (message === 'Network Error') { message = t('sys.api.errorMessage') } else if (message.includes('timeout')) { message = t('sys.api.apiTimeoutMessage') } else if (message.includes('Request failed with status code')) { message = t('sys.api.apiRequestFailed') + message.substr(message.length - 3) } ElMessage.error(message) return Promise.reject(error) })const refreshToken = async () => { axios.defaults.headers.common['tenant-id'] = getTenantId() return await axios.post(base_url + '/system/auth/refresh-token?refreshToken=' + getRefreshToken())}const handleAuthorized = () => { const { t } = useI18n() if (!isRelogin.show) { isRelogin.show = true ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), { confirmButtonText: t('login.relogin'), cancelButtonText: t('common.cancel'), type: 'warning' }) .then(() => { const { wsCache } = useCache() resetRouter() // 重置静态路由表 wsCache.clear() removeToken() isRelogin.show = false window.location.href = '/' }) .catch(() => { isRelogin.show = false }) } return Promise.reject(t('sys.api.timeoutMessage'))} # 2.2 交互流程 一个完整的前端 UI 交互到服务端处理流程,如下图所示: 继续以 [系统管理 -> 岗位管理] 菜单为例,查看它是如何读取岗位列表的。代码如下: // ① api/system/post/index.tsimport request from '@/config/axios'// 查询岗位列表export const getPostPage = async (params: PageParam) => { return await request.get({ url: '/system/post/page', params })}// ② views/system/post/index.vue<script setup lang="tsx">const loading = ref(true) // 列表的加载中const total = ref(0) // 列表的总页数const list = ref([]) // 列表的数据const queryParams = reactive({ pageNo: 1, pageSize: 10, code: '', name: '', status: undefined})/** 查询岗位列表 */const getList = async () => { loading.value = true try { const data = await PostApi.getPostPage(queryParams) list.value = data.list total.value = data.total } finally { loading.value = false }}</script> # 3. component 组件 # 3.1 全局组件 在 @/components ( opens new window) 目录下,实现全局组件,被所有模块所公用。 例如说,富文本编辑器、各种各搜索组件、封装的分页组件等等。 # 3.2 模块内组件 每个模块的业务组件,可实现在 views 目录下,自己模块的目录的 components 目录下,避免单个 .vue 文件过大,降低维护成功。 例如说, @/views/pay/app/components/xxx.vue: # 4. style 样式 ① 在 @/styles ( opens new window) 目录下,实现全局 样式,被所有页面所公用。 ② 每个 .vue 页面,可在 <style /> 标签中添加样式,注意需要添加 scoped 表示只作用在当前页面里,避免造成全局的样式污染。 更多也可以看看如下两篇文档: 《vue-element-plus-admin —— 项目配置「样式配置」》 ( opens new window) 《vue-element-plus-admin —— 样式》 ( opens new window) # 5. 项目规范 可参考 《vue-element-plus-admin —— 项目规范》 ( opens new window) 文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 配置读取 菜单路由 ← 配置读取 菜单路由→"},{"title":"菜单路由","path":"/wiki/YuDaoCloud/前端手册 Vue 3/菜单路由/菜单路由.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-12-31 目录 菜单路由 前端项目基于 vue-element-plus-admin 实现,它的 路由和侧边栏 (opens new window) 是组织起一个后台应用的关键骨架。 侧边栏和路由是绑定在一起的,所以你只有在 @/router/index.js (opens new window) 下面配置对应的路由,侧边栏就能动态的生成了,大大减轻了手动重复编辑侧边栏的工作量。 当然,这样就需要在配置路由的时候,遵循一些约定的规则。 # 1. 路由配置 首先,我们了解一下本项目配置路由时,提供了哪些配置项: /*** redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击* name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题* meta : { hidden: true 当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false) alwaysShow: true 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式, 只有一个时,会将那个子路由当做根路由显示在侧边栏, 若你想不管路由下面的 children 声明的个数都显示你的根路由, 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则, 一直显示根路由(默认 false) title: 'title' 设置该路由在侧边栏和面包屑中展示的名字 icon: 'svg-name' 设置该路由的图标 noCache: true 如果设置为true,则不会被 <keep-alive> 缓存(默认 false) breadcrumb: false 如果设置为false,则不会在breadcrumb面包屑中显示(默认 true) affix: true 如果设置为true,则会一直固定在tag项中(默认 false) noTagsView: true 如果设置为true,则不会出现在tag中(默认 false) activeMenu: '/dashboard' 显示高亮的路由路径 followAuth: '/dashboard' 跟随哪个路由进行权限过滤 canTo: true 设置为true即使hidden为true,也依然可以进行路由跳转(默认 false) }**/ # 1.1 普通示例 注意事项: 整个项目所有路由 name 不能重复 所有的多级路由最终都会转成二级路由,所以不能内嵌子路由 除了 layout 对应的 path 前面需要加 /,其余子路由都不要以 / 开头 { path: '/level', component: Layout, redirect: '/level/menu1/menu1-1/menu1-1-1', name: 'Level', meta: { title: t('router.level'), icon: 'carbon:skill-level-advanced' }, children: [ { path: 'menu1', name: 'Menu1', component: getParentLayout(), redirect: '/level/menu1/menu1-1/menu1-1-1', meta: { title: t('router.menu1') }, children: [ { path: 'menu1-1', name: 'Menu11', component: getParentLayout(), redirect: '/level/menu1/menu1-1/menu1-1-1', meta: { title: t('router.menu11'), alwaysShow: true }, children: [ { path: 'menu1-1-1', name: 'Menu111', component: () => import('@/views/Level/Menu111.vue'), meta: { title: t('router.menu111') } } ] }, { path: 'menu1-2', name: 'Menu12', component: () => import('@/views/Level/Menu12.vue'), meta: { title: t('router.menu12') } } ] }, { path: 'menu2', name: 'Menu2Demo', component: () => import('@/views/Level/Menu2.vue'), meta: { title: t('router.menu2') } } ]} # 1.2 外链示例 只需要将 path 设置为需要跳转的 HTTP 地址即可。 { path: '/external-link', component: Layout, meta: { name: 'ExternalLink' }, children: [ { path: 'https://www.iocoder.cn', meta: { name: 'Link', title: '芋道源码' } } ]} # 2. 路由 项目的路由分为两种:静态路由、动态路由。 # 2.1 静态路由 静态路由,代表那些不需要动态判断权限的路由,如登录页、404、个人中心等通用页面。 在 @/router/modules/remaining.ts ( opens new window) 的 remainingRouter ,就是配置对应的公共路由。如下图所示: # 2.2 动态路由 动态路由,代表那些需要根据用户动态判断权限,并通过 addRoutes ( opens new window) 动态添加的页面,如用户管理、角色管理等功能页面。 在用户登录成功后,会触发 @/store/modules/permission.ts ( opens new window) 请求后端的菜单 RESTful API 接口,获取用户有权限 的菜单列表,并转化添加到路由中。如下图所示: 友情提示: 动态路由可以在 [系统管理 -> 菜单管理] 进行新增和修改操作,请求的后端 RESTful API 接口是 /admin-api/system/list-menus ( opens new window) 动态路由在生产环境下会默认使用路由懒加载,实现方式参考 import.meta.glob('../views/**/* .{vue,tsx}') ( opens new window) 方法的判断 补充说明: 最新的代码,部分逻辑重构到 @/permission.ts ( opens new window) # 2.3 路由跳转 使用 router.push 方法,可以实现跳转到不同的页面。 const { push } = useRouter()// 简单跳转push('/job/job-log');// 跳转页面并设置请求参数,使用 `query` 属性push('/bpm/process-instance/detail?id=' + row.processInstance.id) # 3. 菜单管理 项目的菜单在 [系统管理 -> 菜单管理] 进行管理,支持无限 层级,提供目录、菜单、按钮三种类型。如下图所示: 菜单可在 [系统管理 -> 角色管理] 被分配给角色。如下图所示: # 3.1 新增目录 ① 大多数情况下,目录是作为菜单的【分类】: ② 目录也提供实现【外链】的能力: # 3.2 新增菜单 # 3.3 新增按钮 # 4. 权限控制 前端通过权限控制,隐藏用户没有权限的按钮等,实现功能级别的权限。 友情提示:前端的权限控制,主要是提升用户体验,避免操作后发现没有权限。 最终在请求到后端时,还是会进行一次权限的校验。 # 4.1 v-hasPermi 指令 v-hasPermi ( opens new window) 指令,基于权限字符,进行权限的控制。 <!-- 单个 --><el-button v-hasPermi="['system:user:create']">存在权限字符串才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasPermi="['system:user:create', 'system:user:update']">包含权限字符串才能看到</el-button> # 4.2 v-hasRole 指令 v-hasRole ( opens new window) 指令,基于角色标识,机进行的控制。 <!-- 单个 --><el-button v-hasRole="['admin']">管理员才能看到</el-button><!-- 多个,满足任一一个即可 --><el-button v-hasRole="['role1', 'role2']">包含角色才能看到</el-button> # 4.3 结合 v-if 指令 在某些情况下,它是不适合使用 v-hasPermi 或 v-hasRole 指令,如元素标签组件。此时,只能通过手动设置 v-if,通过使用全局权限判断函数,用法是基本一致的。 <template> <el-tabs> <el-tab-pane v-if="checkPermi(['system:user:create'])" label="用户管理" name="user">用户管理</el-tab-pane> <el-tab-pane v-if="checkPermi(['system:user:create', 'system:user:update'])" label="参数管理" name="menu">参数管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin'])" label="角色管理" name="role">角色管理</el-tab-pane> <el-tab-pane v-if="checkRole(['admin','common'])" label="定时任务" name="job">定时任务</el-tab-pane> </el-tabs></template><script>import { checkPermi, checkRole } from "@/utils/permission"; // 权限判断函数export default{ methods: { checkPermi, checkRole }}</script> # 5. 页面缓存 开启缓存有 2 个条件 路由设置 name,且不能重复 路由对应的组件加上 name ,与路由设置的 name 保持一致 友情提示:页面缓存是什么? 简单来说,Tab 切换时,开启页面缓存的 Tab 保持原本的状态,不进行刷新。 详细可见 Vue 文档 —— KeepAlive ( opens new window) # 5.1 静态路由的示例 ① router 路由的 name 声明如下: { path: 'menu2', name: 'Menu2', component: () => import('@/views/Level/Menu2.vue'), meta: { title: t('router.menu2') }} ② view component 的 name 声明如下: <script setup lang="ts"> defineOptions({ name: 'Menu2'})</script> 注意: keep-alive 生效的前提是:需要将路由的 name 属性及对应的页面的 name 设置成一样。 因为:include - 字符串或正则表达式,只有名称匹配的组件会被缓存 # 5.2 动态路由的示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 开发规范 Icon 图标 ← 开发规范 Icon 图标→"},{"title":"系统组件","path":"/wiki/YuDaoCloud/前端手册 Vue 3/系统组件/系统组件.html","content":"开发指南前端手册 Vue 3 芋道源码 2022-12-31 目录 系统组件 # 1. 常用组件 # 1.1 Editor 富文本组件 基于 wangEditor (opens new window) 封装 Editor 组件:位于 src/components/Editor (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/editor.html (opens new window) 实战案例:src/views/system/notice/form.vue (opens new window) TODO # 1.2 Dialog 弹窗组件 对 Element Plus 的 Dialog 组件进行封装,支持最大化、最大高度等特性 Dialog 组件:位于 src/components/Dialog (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/dialog.html (opens new window) 实战案例:src/views/system/dept/DeptForm.vue (opens new window) # 1.3 ContentWrap 包裹组件 对 Element Plus 的 ElCard 组件进行封装,自带标题、边距 ContentWrap 组件:位于 src/components/ContentWrap (opens new window) 内 实战案例:src/views/system/post/index.vue (opens new window) # 1.4 Pagination 分页组件 对 Element Plus 的 Pagination (opens new window) 组件进行封装 Pagination 组件:位于 src/components/Pagination (opens new window) 内 实战案例:src/views/system/post/index.vue (opens new window) # 1.5 UploadFile 上传文件组件 对 Element Plus 的 Upload (opens new window) 组件进行封装,上传文件到文件服务 UploadFile 组件:位于 src/components/UploadFile/src/UploadFile.vue (opens new window) 内 实战案例:暂无 # 1.6 UploadImg 上传图片组件 对 Element Plus 的 Upload (opens new window) 组件进行封装,上传图片到文件服务 UploadImg 组件:位于 src/components/UploadFile/src/UploadImg.vue (opens new window) 内 实战案例:src/views/system/oauth2/client/ClientForm.vue (opens new window) # 2. 不常用组件 # 2.1 EChart 图表组件 基于 Apache ECharts (opens new window) 封装,自适应窗口大小 EChart 组件:位于 src/components/EChart (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/echart.html (opens new window) 实战案例:src/views/mp/statistics/index.vue (opens new window) # 2.2 InputPassword 密码输入框 对 Element Plus 的 Input 组件进行封装 InputPassword 组件:位于 src/components/InputPassword (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/input-password.html (opens new window) 实战案例:src/views/Profile/components/ResetPwd.vue (opens new window) # 2.3 ContentDetailWrap 详情包裹组件 用于展示详情,自带返回按钮。 ContentDetailWrap 组件:位于 src/components/ContentDetailWrap (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/content-detail-wrap.html (opens new window) 实战案例:暂无 # 2.4 ImageViewer 图片预览组件 将 Element Plus 的 ImageViewer (opens new window) 组件函数化,通过函数方便创建组件 ImageViewer 组件:位于 src/components/ImageViewer (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/image-viewer.html (opens new window) 实战案例:暂无 # 2.5 Qrcode 二维码组件 基于 qrcode (opens new window) 封装 Qrcode 组件:位于 src/components/Qrcode (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/qrcode.html (opens new window) 实战案例:暂无 # 2.6 Highlight 高亮组件 Highlight 组件:位于 src/components/Highlight (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/highlight.html (opens new window) 实战案例:暂无 # 2.6.1 Infotip 信息提示组件 基于 Highlight 组件封装 Infotip 组件:位于 src/components/Infotip (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/infotip.html (opens new window) 实战案例:暂无 # 2.7 Error 缺省组件 用于各种占位图组件,如 404、403、500 等错误页面。 Error 组件:位于 src/components/Error (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/error.html (opens new window) 实战案例:403.vue (opens new window)、404.vue (opens new window)、500.vue (opens new window) # 2.8 Sticky 黏性组件 Sticky 组件:位于 src/components/Sticky (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/sticky.html (opens new window) 实战案例:暂无 # 2.9 CountTo 数字动画组件 CountTo 组件:位于 src/components/CountTo (opens new window) 内 详细文档:vue-element-plus-admin-doc/components/count-to.html (opens new window) 实战案例:暂无 # 2.10 useWatermark 水印组件 为元素设置水印 useWatermark 组件:位于 src/hooks/web/useWatermark.ts (opens new window) 内 详细文档:vue-element-plus-admin-doc/hooks/useWatermark.html (opens new window) 实战案例:暂无 # 2.11 form-create 动态表单生成器 详细文档:http://www.form-create.com/ (opens new window) ① 实战案例 - 表单设计:src/views/infra/build/index.vue (opens new window) ② 实战案例 - 表单展示:src/views/bpm/processInstance/detail/index.vue (opens new window) # 2.12 bpmn-js 工作流组件 核心是基于 bpmn-js (opens new window) 封装 # 2.12.1 MyProcessDesigner 流程设计组件 MyProcessDesigner 组件:位于 src/components/bpmnProcessDesigner/package/designer/index.ts (opens new window) 内,基于 https://gitee.com/MiyueSC/bpmn-process-designer (opens new window) 项目适配 实战案例:src/views/bpm/model/editor/index.vue (opens new window) # 2.12.2 MyProcessViewer 流程展示组件 MyProcessViewer 组件:位于 src/components/bpmnProcessDesigner/package/designer/index2.ts (opens new window) 内 实战案例:src/views/bpm/processInstance/detail/ProcessInstanceBpmnViewer.vue (opens new window) # 3. 组件注册 友情提示: 该小节,基于 《vue element plus admin —— 组件注册 》 (opens new window) 的内容修改。 组件注册可以分成两种类型:按需引入、全局注册。 # 3.1 按需引入 项目目前的组件注册机制是按需注册,是在需要用到的页面才引入。 <script setup lang="ts">import { ElBacktop } from 'element-plus'import { useDesign } from '@/hooks/web/useDesign'const { getPrefixCls, variables } = useDesign()const prefixCls = getPrefixCls('backtop')</script><template> <ElBacktop :class="`${prefixCls}-backtop`" :target="`.${variables.namespace}-layout-content-scrollbar .${variables.elNamespace}-scrollbar__wrap`" /></template> 注意:tsx 文件内不能使用全局注册组件,需要手动引入组件使用。 # 3.2 全局注册 如果觉得按需引入太麻烦,可以进行全局注册,在 src/components/index.ts (opens new window),添加需要注册的组件。 以 Icon 组件进行了全局注册,举个例子: import type { App } from 'vue'import { Icon } from './Icon'export const setupGlobCom = (app: App<Element>): void => { app.component('Icon', Icon)} 如果 Element Plus 的组件需要全局注册,在 src/plugins/elementPlus/index.ts (opens new window) 添加需要注册的组件。 以 Element Plus 中只有 ElLoading 与 ElScrollbar 进行全局注册,举个例子: import type { App } from 'vue'// 需要全局引入一些组件,如 ElScrollbar,不然一些下拉项样式有问题import { ElLoading, ElScrollbar } from 'element-plus'const plugins = [ElLoading]const components = [ElScrollbar]export const setupElementPlus = (app: App) => { plugins.forEach((plugin) => { app.use(plugin) }) components.forEach((component) => { app.component(component.name, component) })} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/04, 22:45:39 字典数据 通用方法 ← 字典数据 通用方法→"},{"title":"通用方法","path":"/wiki/YuDaoCloud/前端手册 Vue 3/通用方法/通用方法.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-01-01 目录 通用方法 本小节,分享前端项目的常用方法。 # 1. 缓存配置 友情提示: 该小节,基于 《vue element plus admin —— 项目配置「缓存配置 」》 (opens new window) 的内容修改。 # 1.1 说明 在项目中,你可以看到很多地方都使用了 wsCache.set 或者 wsCache.get,这是基于 web-storage-cache (opens new window) 进行封装,采用 hook 的形式。 该插件对HTML5 localStorage 和 sessionStorage 进行了扩展,添加了超时时间,序列化方法。可以直接存储 json 对象,同时可以非常简单的进行超时时间的设置。 本项目默认是采用 sessionStorage 的存储方式,如果更改,可以直接在 useCache.ts (opens new window) 中把 type: CacheType = 'sessionStorage' 改为 type: CacheType = 'localStorage',这样项目中的所有用到的地方,都会变成该方式进行数据存储。 如果只想单个更改,可以传入存储类型 const { wsCache } = useCache('localStorage'),既可只适用当前存储对象。 注意: 更改完默认存储方式后,需要清除浏览器缓存并重新登录,以免造成不可描述的问题。 # 1.2 示例 # 2. message 对象 # 2.1 说明 message 对象,由 src/hooks/web/useMessage.ts (opens new window) 实现,基于 ElMessage、ElMessageBox、ElNotification 封装,用于做消息提示、通知提示、对话框提醒、二次确认等。 # 2.2 示例 # 3. download 对象 # 3.1 说明 $download 对象,由 util/download.ts (opens new window) 实现,用于 Excel、Word、Zip、HTML 等类型的文件下载。 # 3.2 示例 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 21:23:33 系统组件 配置读取 ← 系统组件 配置读取→"},{"title":"Excel 导入导出","path":"/wiki/YuDaoCloud/后端手册/Excel 导入导出/Excel 导入导出.html","content":"开发指南后端手册 芋道源码 2022-03-27 目录 Excel 导入导出 项目的 yudao-spring-boot-starter-excel (opens new window) 技术组件,基于 EasyExcel 实现 Excel 的读写操作,可用于实现最常见的 Excel 导入导出等功能。 EasyExcel 的介绍? EasyExcel 是阿里开源的 Excel 工具库,具有简单易用、低内存、高性能的特点。 在尽可用节约内存的情况下,支持百万行的 Excel 读写操作。例如说,仅使用 64M 内存,20 秒完成 75M(46 万行 25 列)Excel 的读取。并且,还有极速模式能更快,但是内存占用会在100M 多一点。 # 1. Excel 导出 以 [系统管理 -> 岗位管理] 菜单为例子,讲解它 Excel 导出的实现。 # 1.1 后端导入实现 在 PostController (opens new window) 类中,定义 /admin-api/system/post/export 导出接口。代码如下: ① 将从数据库中查询出来的列表,转换成对应的 PostExcelVO 列表。 ② 将 PostExcelVO 列表,转换成 Excel 文件,返回给前端。 # 1.1.1 PostExcelVO 类 创建 PostExcelVO (opens new window) 类,岗位 Excel 导出的 VO 类。它有两个作用,代码如下: ① 每个字段上的 @ExcelProperty (opens new window) 注解,声明 Excel Head 头部的名字。 ② 每个字段的值,就是它对应的 Excel Row 行的数据值。 因此,最终 Excel 导出的效果如下: 另外,在上述代码的红线部分,@ExcelProperty 注解的 converter 属性是 DictConvert 转换器,通过它将 status = 1 转换成“开启”列,status = 0 转换成”禁用”列。稍后,我们会在 「3. 字段转换器」 小节来详细讲讲。 # 1.1.2 ExcelUtils 写入 ExcelUtils 的 #write(...) (opens new window) 方法,将列表以 Excel 响应给前端。代码如下图: # 1.2 前端导入实现 在 post/index.vue (opens new window) 界面,定义 #handleExport() 操作,代码如下图: # 2. Excel 导入 以 [系统管理 -> 用户管理] 菜单为例子,讲解它 Excel 导出的实现。 # 2.1 后端导入实现 在 UserController (opens new window) 类中,定义 /admin-api/system/user/import 导入接口。代码如下: 将前端上传的 Excel 文件,读取成 UserImportExcelVO 列表。 # 2.1.1 UserImportExcelVO 类 创建 UserImportExcelVO (opens new window) 类,用户 Excel 导入的 VO 类。它的作用和 Excel 导入是一样的,代码如下: 对应使用的 Excel 导入文件如下: # 2.1.2 ExcelUtils 读取 ExcelUtils 的 #read(...) (opens new window) 方法,读取 Excel 文件成列表。代码如下图: # 2.2 前端导入实现 在 user/index.vue (opens new window) 界面,定义 Excel 导入的功能,代码如下图: # 3. 字段转换器 EasyExcel 定义了 Converter (opens new window) 接口,用于实现字段的转换。它有两个核心方法: ① #convertToJavaData(...) 方法:将 Excel Row 对应表格的值,转换成 Java 内存中的值。例如说,Excel 的“状态”列,将“状态”列转换成 status = 1,”禁用”列转换成 status = 0。 ② #convertToExcelData(...) 方法:恰好相反,将 Java 内存中的值,转换成 Excel Row 对应表格的值。例如说,Excel 的“状态”列,将 status = 1 转换成“开启”列,status = 0 转换成”禁用”列。 # 3.1 DictConvert 实现 以项目中提供的 DictConvert (opens new window) 举例子,它实现 Converter 接口,提供字典数据的转换。代码如下: 实现的代码比较简单,自己看看就可以明白。 # 3.1 DictConvert 使用示例 在需要转换的字段上,声明注解 @ExcelProperty 的 converter 属性是 DictConvert 转换器,注解 @DictFormat (opens new window) 为对应的字典数据的类型。示例如下: # 4. 更多 EasyExcel 注解 基于 《EasyExcel 中的注解 》 (opens new window) 文章,整理相关注解。 # 4.1 @ExcelProperty 这是最常用的一个注解,注解中有三个参数 value、index、converter 分别代表列明、列序号、数据转换方式。value 和 index 只能二选一,通常不用设置 converter。 最佳实践 public class ImeiEncrypt { @ExcelProperty(value = "imei") private String imei;} # 4.2 @ColumnWith 用于设置列宽度的注解,注解中只有一个参数 value。value 的单位是字符长度,最大可以设置 255 个字符,因为一个 Excel 单元格最大可以写入的字符个数,就是 255 个字符。 最佳实践 public class ImeiEncrypt { @ColumnWidth(value = 18) private String imei;} # 4.3 @ContentFontStyle 用于设置单元格内容字体格式的注解。参数如下: 参数 含义 fontName 字体名称 fontHeightInPoints 字体高度 italic 是否斜体 strikeout 是否设置删除水平线 color 字体颜色 typeOffset 偏移量 underline 下划线 bold 是否加粗 charset 编码格式 # 4.4 @ContentLoopMerge 用于设置合并单元格的注解。参数如下: 参数 含义 eachRow columnExtend # 4.5 @ContentRowHeight 用于设置行高。参数如下: 参数 含义 value 行高,-1 代表自动行高 # 4.6 @ContentStyle 设置内容格式注解。参数如下: 参数 含义 dataFormat 日期格式 hidden 设置单元格使用此样式隐藏 locked 设置单元格使用此样式锁定 quotePrefix 在单元格前面增加`符号,数字或公式将以字符串形式展示 horizontalAlignment 设置是否水平居中 wrapped 设置文本是否应换行。将此标志设置为true通过在多行上显示使单元格中的所有内容可见 verticalAlignment 设置是否垂直居中 rotation 设置单元格中文本旋转角度。03版本的Excel旋转角度区间为-90°~90°,07版本的Excel旋转角度区间为0°~180° indent 设置单元格中缩进文本的空格数 borderLeft 设置左边框的样式 borderRight 设置右边框样式 borderTop 设置上边框样式 borderBottom 设置下边框样式 leftBorderColor 设置左边框颜色 rightBorderColor 设置右边框颜色 topBorderColor 设置上边框颜色 bottomBorderColor 设置下边框颜色 fillPatternType 设置填充类型 fillBackgroundColor 设置背景色 fillForegroundColor 设置前景色 shrinkToFit 设置自动单元格自动大小 # 4.7 @HeadFontStyle 用于定制标题字体格式。参数如下: 参数 含义 fontName 设置字体名称 fontHeightInPoints 设置字体高度 italic 设置字体是否斜体 strikeout 是否设置删除线 color 设置字体颜色 typeOffset 设置偏移量 underline 设置下划线 charset 设置字体编码 bold 设置字体是否家畜 # 4.8 @HeadRowHeight 设置标题行行高。参数如下: 参数 含义 value 设置行高,-1代表自动行高 # 4.9 @HeadStyle 设置标题样式。参数如下: 参数 含义 dataFormat 日期格式 hidden 设置单元格使用此样式隐藏 locked 设置单元格使用此样式锁定 quotePrefix 在单元格前面增加` 符号,数字或公式将以字符串形式展示 horizontalAlignment 设置是否水平居中 wrapped 设置文本是否应换行。将此标志设置为true 通过在多行上显示使单元格中的所有内容可见 verticalAlignment 设置是否垂直居中 rotation 设置单元格中文本旋转角度。03版本的Excel旋转角度区间为-90°~90°,07版本的Excel旋转角度区间为0°~180° indent 设置单元格中缩进文本的空格数 borderLeft 设置左边框的样式 borderRight 设置右边框样式 borderTop 设置上边框样式 borderBottom 设置下边框样式 leftBorderColor 设置左边框颜色 rightBorderColor 设置右边框颜色 topBorderColor 设置上边框颜色 bottomBorderColor 设置下边框颜色 fillPatternType 设置填充类型 fillBackgroundColor 设置背景色 fillForegroundColor 设置前景色 shrinkToFit 设置自动单元格自动大小 # 4.10 @ExcelIgnore 不将该字段转换成 Excel。 # 4.11 @ExcelIgnoreUnannotated 没有注解的字段都不转换 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/30, 21:15:08 文件存储(上传下载) 系统日志 ← 文件存储(上传下载) 系统日志→"},{"title":"OAuth 2.0(SSO 单点登录)","path":"/wiki/YuDaoCloud/后端手册/OAuth 2.0(SSO 单点登录)/OAuth 2.0(SSO 单点登录).html","content":"开发指南后端手册 芋道源码 2022-09-27 目录 OAuth 2.0(SSO 单点登录) # OAuth 2.0 是什么? OAuth 2.0 的概念讲解,可以阅读如下三篇文章: 《理解 OAuth 2.0》 (opens new window) 《OAuth 2.0 的一个简单解释》 (opens new window) 《OAuth 2.0 的四种方式》 (opens new window) 重点是理解 授权码模式 和 密码模式,它们是最常用的两种授权模式。 本文,我们也会基于它们,分别实现 SSO 单点登录。 # OAuth 2.0 授权模式的选择? 授权模式的选择,其实非常简单,总结起来就是一张图: 问题一:什么场景下,使用客户端模式(Client Credentials)? 如果令牌拥有者是机器的情况下,那就使用客户端模式。 例如说: 开发了一个开放平台,提供给其它外部服务调用 开发了一个 RPC 服务,提供给其它内部服务调用 实际的案例,我们接入微信公众号时,会使用 appid 和 secret 参数,获取 Access token (opens new window) 访问令牌。 问题二:什么场景下,使用密码模式(Resource Owner Password Credentials)? 接入的 Client 客户端,是属于自己的情况下,可以使用密码模式。 例如说: 客户端是你自己公司的 App 或网页,然后授权服务也是你公司的 不过,如果客户端是第三方的情况下,使用密码模式的话,该客户端是可以拿到用户的账号、密码,存在安全的风险,此时可以考虑使用授权码或简化模式。 问题三:什么场景下,使用授权码模式(Authorization Code)? 接入的 Client 客户端,是属于第三方的情况下,可以使用授权码模式。例如说: 客户端是你自己公司的 App 或网页,作为第三方,接入 微信 (opens new window)、QQ (opens new window)、钉钉 (opens new window) 等等进行 OAuth 2.0 登录 当然,如果客户端是自己的情况下,也可以采用授权码模式。例如说: 客户端是腾讯旗下的各种游戏,可使用微信、QQ,接入 微信 (opens new window)、QQ (opens new window) 等等进行 OAuth 2.0 登录 客户端是公司内的各种管理后台(ERP、OA、CRM 等),跳转到统一的 SSO 单点登录,使用授权码模式进行授权 问题四:什么场景下,使用简化模式(Implicit)? 简化模式,简化 的是授权码模式的流程的 第二步,差异在于: 授权码模式:授权完成后,获得的是 code 授权码,需要 Server Side 服务端使用该授权码,再向授权服务器获取 Access Token 访问令牌 简化模式:授权完成后,Client Side 客户端直接获得 Access Token 访问令牌 暂时没有特别好的案例,感兴趣可以看看如下文档,也可以不看: 《QQ OAuth 2.0 开发指定 —— 开发攻略_Client-side》 (opens new window) 《百度 OAuth —— Implicit Grant 授权》 (opens new window) 问题五:该项目中,使用了哪些授权模式? 如上图所示,分成 外部授权 和 内部登录 两种方式。 ① 红色的“外部授权”:基于【授权码模式】,实现 SSO 单点登录,将用户授权给接入的客户端。客户端可以是内部的其它管理系统,也可以是外部的第三方。 ② 绿色的“内部登录”:管理后台的登录接口,还是采用传统的 /admin-api/system/auth/login (opens new window) 账号密码登录,并没有使用【密码模式】,主要考虑降低大家的学习成本,如果没有将用户授权给其它系统的情况下,这样做已经可以很好的满足业务的需要。当然,这里也可以将管理后台作为一个客户端,使用【密码模式】进行授权。 另外,考虑到 OAuth 2.0 使用的访问令牌 + 刷新令牌可以提供更好的安全性,所以即使是传统的账号密码登录,也复用了它作为令牌的实现。 # OAuth 2.0 技术选型? 实现 OAuth 2.0 的功能,一般采用 Spring Security OAuth (opens new window) 或 Spring Authorization Server (opens new window)(SAS) 框架,前者已废弃,被后者所替代。但是使用它们,会面临三大问题: 学习成本大:SAS 是新出的框架,入门容易精通难,引入项目中需要花费 1-2 周深入学习 排查问题难:使用碰到问题时,往往需要调试到源码层面,团队只有个别人具备这种能力 定制成本高:根据业务需要,想要在 SAS 上定制功能,对源码要有不错的掌控力,难度可能过大 ⚔ 因此,项目参考多个 OAuth 2.0 框架,自研实现 OAuth 2.0 的功能,具备学习成本小、排查问题容易、定制成本低的优点,支持多种授权模式,并内置 SSO 单点登录的功能。 友情提示:具备一定规模的互联网公司,基本不会直接采用 Spring Security OAuth 或 Spring Authorization Server 框架,也是采用自研的方式,更好的满足自身的业务需求与技术拓展。 🙂 另外,通过学习项目的 OAuth 2.0 实现,可以进一步加深对 OAuth 2.0 的理解,知其然而不知其所以然! 最终实现的整体架构,如下图所示: 详细的代码实现,我们在视频中进行讲解。 # 如何实现 SSO 单点登录? # 实战一:基于授权码模式,实现 SSO 单点登录 示例代码见 https://github.com/YunaiV/ruoyi-vue-pro/tree/master/yudao-example/yudao-sso-demo-by-code (opens new window) 地址,整体流程如下图所示: 具体的使用流程如下: ① 第一步,分别启动 ruoyi-vue-pro 项目的前端和后端。注意,前端需要使用 Vue2 版本,因为 Vue3 版本暂时没有实现 SSO 页面。 ② 第二步,访问 系统管理 -> OAuth 2.0 -> 应用管理 (opens new window) 菜单,新增一个应用(客户端),信息如下图: 客户端编号:yudao-sso-demo-by-code 客户端密钥:test 应用名:基于授权码模式,如何实现 SSO 单点登录? 授权类型:authorization_code、refresh_token 授权范围:user.read、user.write 可重定向的 URI 地址:http://127.0.0.1:18080 ps:如果已经有这个客户端,可以不用新增。 ③ 第三步,运行 SSODemoApplication (opens new window) 类,启动接入方的项目,它已经包含前端和后端部分。启动成功的日志如下: 2022-10-01 21:24:35.572 INFO 60265 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 18080 (http) with context path '' ④ 第四步,浏览器访问 http://127.0.0.1:18080/index.html (opens new window) 地址,进入接入方的 index.html 首页。因为暂未登录,可以点击「跳转」按钮,跳转到 ruoyi-vue-pro 项目的 SSO 单点登录页。 疑问:为什么没有跳转到 SSO 单点登录页,而是跳转到 ruoyi-vue-pro 项目的登录页? 因为在 ruoyi-vue-pro 项目也未登录,所以先跳转到该项目的登录页,使用账号密码进行登录。登录完成后,会跳转回 SSO 单点登录页,继续完成 OAuth 2.0 的授权流程。 ⑤ 第五步,勾选 \"访问你的个人信息\" 和 \"修改你的个人信息\",点击「同意授权」按钮,完成 code 授权码的申请。 ⑥ 第六步,完成授权后,会跳转到接入方的 callback.html 回调页,并在 URL 上可以看到 code 授权码。 ⑦ 第七步,点击「确认」按钮,接入方的前端会使用 code 授权码,向接入方的后端获取 accessToken 访问令牌。 而接入方的后端,使用接收到的 code 授权码,通过调用 ruoyi-vue-pro 项目的后端,获取到 accessToken 访问令牌,并最终返回给接入方的前端。 ⑧ 第八步,在接入方的前端拿到 accessToken 访问令牌后,跳转回自己的 index.html 首页,并进一步从 ruoyi-vue-pro 项目获取到该用户的昵称等个人信息。后续,你可以执行「修改昵称」、「刷新令牌」、「退出登录」等操作。 示例代码的具体实现,与详细的解析,可以观看如下视频: 02、基于授权码模式,如何实现 SSO 单点登录? (opens new window) 03、请求时,如何校验 accessToken 访问令牌? (opens new window) 04、访问令牌过期时,如何刷新 Token 令牌? (opens new window) 05、登录成功后,如何获得用户信息? (opens new window) 06、退出时,如何删除 Token 令牌? (opens new window) # 实战二:基于密码模式,实现 SSO 登录 示例代码见 https://github.com/YunaiV/ruoyi-vue-pro/tree/master/yudao-example/yudao-sso-demo-by-password (opens new window) 地址,整体流程如下图所示: 具体的使用流程如下: ① 第一步,分别启动 ruoyi-vue-pro 项目的前端和后端。注意,前端需要使用 Vue2 版本,因为 Vue3 版本暂时没有实现 SSO 页面。 ② 第二步,访问 系统管理 -> OAuth 2.0 -> 应用管理 (opens new window) 菜单,新增一个应用(客户端),信息如下图: 客户端编号:yudao-sso-demo-by-password 客户端密钥:test 应用名:基于密码模式,如何实现 SSO 单点登录? 授权类型:password、refresh_token 授权范围:user.read、user.write 可重定向的 URI 地址:http://127.0.0.1:18080 ps:如果已经有这个客户端,可以不用新增。 ③ 第三步,运行 SSODemoApplication (opens new window) 类,启动接入方的项目,它已经包含前端和后端部分。启动成功的日志如下: 2022-10-04 21:24:35.572 INFO 60265 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 18080 (http) with context path '' ④ 第四步,浏览器访问 http://127.0.0.1:18080/index.html (opens new window) 地址,进入接入方的 index.html 首页。因为暂未登录,可以点击「跳转」按钮,跳转到 login.html 登录页。 ⑤ 第五步,点击「登录」按钮,调用 ruoyi-vue-pro 项目的后端,获取到 accessToken 访问令牌,完成登录操作。 ⑥ 第六步,登录完成后,跳转回自己的 index.html 首页,并进一步从 ruoyi-vue-pro 项目获取到该用户的昵称等个人信息。后续,你可以执行「修改昵称」、「刷新令牌」、「退出登录」等操作。 示例代码的具体实现,与详细的解析,可以观看如下视频: 07、基于密码模式,如何实现 SSO 单点登录? (opens new window) # OAuth 2.0 表结构 每个表的具体设计,与详细的解析,可以观看如下视频: 08、如何实现客户端的管理? (opens new window) 09、单点登录界面,如何进行初始化? (opens new window) 10、单点登录界面,如何进行【手动】授权? (opens new window) 11、单点登录界面,如何进行【自动】授权? (opens new window) 12、基于【授权码】模式,如何获得 Token 令牌? (opens new window) 13、基于【密码】模式,如何获得 Token 令牌? (opens new window) 14、如何校验、刷新、删除访问令牌? (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/10/06, 20:13:18 三方登录 SaaS 多租户【字段隔离】 ← 三方登录 SaaS 多租户【字段隔离】→"},{"title":"Redis 缓存","path":"/wiki/YuDaoCloud/后端手册/Redis 缓存/Redis 缓存.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 Redis 缓存 yudao-spring-boot-starter-redis (opens new window) 技术组件,使用 Redis 实现缓存的功能,它有 2 种使用方式: 编程式缓存:基于 Spring Data Redis 框架的 RedisTemplate 操作模板 声明式缓存:基于 Spring Cache 框架的 @Cacheable 等等注解 # 1. 编程式缓存 友情提示: 如果你未学习过 Spring Data Redis 框架,可以后续阅读 《芋道 Spring Boot Redis 入门》 (opens new window) 文章。 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId></dependency> 由于 Redisson 提供了分布式锁、队列、限流等特性,所以使用它作为 Spring Data Redis 的客户端。 # 1.1 Spring Data Redis 配置 ① 在 application-local.yaml (opens new window) 配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。如下图所示: ② 在 YudaoRedisAutoConfiguration (opens new window) 配置类,设置使用 JSON 序列化 value 值。如下图所示: # 1.2 实战案例 以访问令牌 Access Token 的缓存来举例子,讲解项目中是如何使用 Spring Data Redis 框架的。 # 1.2.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-redis 技术组件。如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-redis</artifactId></dependency> # 1.2.2 OAuth2AccessTokenDO 新建 OAuth2AccessTokenDO ( opens new window) 类,访问令牌 Access Token 类。代码如下: 友情提示: ① 如果值是【简单】的 String 或者 Integer 等类型,无需创建数据实体。 ② 如果值是【复杂对象】时,建议在 dal/dataobject 包下,创建对应的数据实体。 # 1.2.3 RedisKeyConstants 为什么要定义 Redis Key 常量? 每个 yudao-module-xxx 模块,都有一个 RedisKeyConstants 类,定义该模块的 Redis Key 的信息。目的是,避免 Redis Key 散落在 Service 业务代码中,像对待数据库的表一样,对待每个 Redis Key。通过这样的方式,如果我们想要了解一个模块的 Redis 的使用情况,只需要查看 RedisKeyConstants 类即可。 在 yudao-module-system 模块的 RedisKeyConstants ( opens new window) 类中,新建 OAuth2AccessTokenDO 对应的 Redis Key 定义 OAUTH2_ACCESS_TOKEN 。如下图所示: # 1.2.4 OAuth2AccessTokenRedisDAO 新建 OAuth2AccessTokenRedisDAO ( opens new window) 类,是 OAuth2AccessTokenDO 的 RedisDAO 实现。代码如下: # 1.2.5 OAuth2TokenServiceImpl 在 OAuth2TokenServiceImpl ( opens new window) 中,只要注入 OAuth2AccessTokenRedisDAO Bean,非常简洁干净的进行 OAuth2AccessTokenDO 的缓存操作,无需关心具体的实现。代码如下: # 2. 声明式缓存 友情提示: 如果你未学习过 Spring Cache 框架,可以后续阅读 《芋道 Spring Boot Cache 入门》 ( opens new window) 文章。 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId></dependency> 相比来说 Spring Data Redis 编程式缓存,Spring Cache 声明式缓存的使用更加便利,一个 @Cacheable 注解即可实现缓存的功能。示例如下: @Cacheable(value = "users", key = "#id")UserDO getUserById(Integer id); # 2.1 Spring Cache 配置 ① 在 application.yaml ( opens new window) 配置文件中,通过 spring.redis 配置项,设置 Redis 的配置。如下图所示: ② 在 YudaoCacheAutoConfiguration ( opens new window) 配置类,设置使用 JSON 序列化 value 值。如下图所示: # 2.2 常见注解 # 2.2.1 @Cacheable 注解 @Cacheable ( opens new window) 注解:添加在方法上,缓存方法的执行结果。执行过程如下: 1)首先,判断方法执行结果的缓存。如果有,则直接返回该缓存结果。 2)然后,执行方法,获得方法结果。 3)之后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。 4)最后,返回方法结果。 # 2.2.2 @CachePut 注解 @CachePut ( opens new window) 注解,添加在方法上,缓存方法的执行结果。不同于 @Cacheable 注解,它的执行过程如下: 1)首先,执行方法,获得方法结果。也就是说,无论是否有缓存,都会执行方法。 2)然后,根据是否满足缓存的条件。如果满足,则缓存方法结果到缓存。 3)最后,返回方法结果。 # 2.2.3 @CacheEvict 注解 @CacheEvict ( opens new window) 注解,添加在方法上,删除缓存。 # 2.3 实战案例 在 RoleServiceImpl ( opens new window) 中,使用 Spring Cache 实现了 Role 角色缓存,采用【被动读】的方案。原因是: 【被动读】相对能够保证 Redis 与 MySQL 的一致性 绝大数数据不需要放到 Redis 缓存中,采用【主动写】会将非必要的数据进行缓存 友情提示: 如果你未学习过 MySQL 与 Redis 一致性的问题,可以后续阅读 《Redis 与 MySQL 双写一致性如何保证? 》 ( opens new window) 文章。 ① 执行 #getRoleFromCache(...) 方法,从 MySQL 读取数据后,向 Redis 写入缓存。如下图所示: ② 执行 #updateRole(...) 或 #deleteRole(...) 方法,在更新或者删除 MySQL 数据后,从 Redis 删除缓存。如下图所示: # 2.4 过期时间 Spring Cache 默认使用 spring.cache.redis.time-to-live 配置项,设置缓存的过期时间,项目默认为 1 小时。 如果你想自定义过期时间,可以在 @Cacheable 注解中的 cacheNames 属性中,添加 #{过期时间} 后缀,单位是秒。如下图所示: 实现的原来,参考 《Spring @Cacheable 扩展支持自定义过期时间 》 ( opens new window) 文章。 # 3. Redis 监控 yudao-module-infra 的 redis ( opens new window) 模块,提供了 Redis 监控的功能。 点击 [基础设施 -> Redis 监控] 菜单,可以查看到 Redis 的基础信息、命令统计、内存信息。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:30:21 多数据源(读写分离) 本地缓存 ← 多数据源(读写分离) 本地缓存→"},{"title":"配置读取","path":"/wiki/YuDaoCloud/前端手册 Vue 3/配置读取/配置读取.html","content":"开发指南前端手册 Vue 3 芋道源码 2023-04-07 目录 配置读取 在 [基础设施 -> 配置管理] 菜单,可以动态修改配置,无需重启服务器即可生效。 提示 对应 《后端手册 —— 配置中心》 文档。 # 1. 读取配置 前端调用 /@api/infra/config/index.ts (opens new window) 的 #getConfigKey(configKey) 方法,获取指定 key 对应的配置的值。代码如下: // 根据参数键名查询参数值export const getConfigKey = (configKey: string) => { return request.get({ url: '/infra/config/get-value-by-key?key=' + configKey })} # 2. 实战案例 在 src/views/infra/server/index.vue ( opens new window) 页面中,获取 key 为 \"url.skywalking\" 的配置的值。代码如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:10 通用方法 CRUD 组件 ← 通用方法 CRUD 组件→"},{"title":"SaaS 多租户【字段隔离】","path":"/wiki/YuDaoCloud/后端手册/SaaS 多租户【字段隔离】/SaaS 多租户【字段隔离】.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 SaaS 多租户【字段隔离】 本章节,将介绍多租户的基础知识、以及怎样使用多租户的功能。 相关的视频教程: 01、如何实现多租户的 DB 封装? (opens new window) 02、如何实现多租户的 Redis 封装? (opens new window) 03、如何实现多租户的 Web 与 Security 封装? (opens new window) 04、如何实现多租户的 Job 封装? (opens new window) 05、如何实现多租户的 MQ 与 Async 封装? (opens new window) 06、如何实现多租户的 AOP 与 Util 封装? (opens new window) 07、如何实现多租户的管理? (opens new window) 08、如何实现多租户的套餐? (opens new window) # 1. 多租户是什么? 多租户,简单来说是指一个业务系统,可以为多个组织服务,并且组织之间的数据是隔离的。 例如说,在服务上部署了一个 yudao-cloud (opens new window) 系统,可以支持多个不同的公司使用。这里的一个公司就是一个租户,每个用户必然属于某个租户。因此,用户也只能看见自己租户下面的内容,其它租户的内容对他是不可见的。 # 2. 数据隔离方案 多租户的数据隔离方案,可以分成分成三种: DATASOURCE 模式:独立数据库 SCHEMA 模式:共享数据库,独立 Schema COLUMN 模式:共享数据库,共享 Schema,共享数据表 # 2.1 DATASOURCE 模式 一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本也高。 优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。 缺点:增大了数据库的安装数量,随之带来维护成本和购置成本的增加。 # 2.2 SCHEMA 模式 多个或所有租户共享数据库,但一个租户一个表。 优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可以支持更多的租户数量。 缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵扯到其他租户的数据; 如果需要跨租户统计数据,存在一定困难。 # 2.3 COLUMN 模式 共享数据库,共享数据架构。租户共享同一个数据库、同一个表,但在表中通过 tenant_id 字段区分租户的数据。这是共享程度最高、隔离级别最低的模式。 优点:维护和购置成本最低,允许每个数据库支持的租户数量最多。 缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。 # 2.4 方案选择 一般情况下,可以考虑采用 COLUMN 模式,开发、运维简单,以最少的服务器为最多的租户提供服务。 租户规模比较大,或者一些租户对安全性要求较高,可以考虑采用 DATASOURCE 模式,当然它也相对复杂的多。 不推荐采用 SCHEMA 模式,因为它的优点并不明显,而且它的缺点也很明显,同时对复杂 SQL 支持一般。 提问:项目支持哪些模式? 目前支持最主流的 DATASOURCE 和 COLUMN 两种模式。而 SCHEMA 模式不推荐使用,所以暂时不考虑实现。 考虑到让大家更好的理解 DATASOURCE 和 COLUMN 模式,拆成了两篇文章: 《SaaS 多租户【字段隔离】》:讲解 COLUMN 模式 《SaaS 多租户【数据库隔离】》:讲解 DATASOURCE 模式 # 3. 多租户的开关 系统有两个配置项,设置为 true 时开启多租户,设置为 false 时关闭多租户。 注意,两者需要保持一致,否则会报错! 配置项 说明 配置文件 yudao.server.tenant 后端开关 VUE_APP_TENANT_ENABLE 前端开关 疑问:为什么要设置两个配置项? 前端登录界面需要使用到多租户的配置项,从后端加载配置项的话,体验会比较差。 # 3. 多租户的业务功能 多租户主要有两个业务功能: 业务功能 说明 界面 代码 租户管理 配置系统租户,创建对应的租户管理员 后端 (opens new window) 前端 (opens new window) 租户套餐 配置租户套餐,自定每个租户的菜单、操作、按钮的权限 后端 (opens new window) 前端 (opens new window) 下面,我们来新增一个租户,它使用 COLUMN 模式。 ① 点击 [租户套餐] 菜单,点击 [新增] 按钮,填写租户的信息。 ② 点击 [确认] 按钮,完成租户的创建,它会自动创建对应的租户管理员、角色等信息。 ③ 退出系统,登录刚创建的租户。 至此,我们已经完成了租户的创建。 # 4. 多租户的技术组件 技术组件 yudao-spring-boot-starter-biz-tenant (opens new window),实现透明化的多租户能力,针对 Web、Security、DB、Redis、AOP、Job、MQ、Async 等多个层面进行封装。 # 4.1 租户上下文 TenantContextHolder (opens new window) 是租户上下文,通过 ThreadLocal 实现租户编号的共享与传递。 通过调用 TenantContextHolder 的 #getTenantId() 静态方法,获得当前的租户编号。绝绝绝大多数情况下,并不需要。 # 4.2 Web 层【重要】 实现可见 web (opens new window) 包。 默认情况下,前端的每个请求 Header 必须带上 tenant-id,值为租户编号,即 system_tenant 表的主键编号。 如果不带该请求头,会报“租户的请求未传递,请进行排查”错误提示。 😜 通过 yudao.tenant.ignore-urls 配置项,可以设置哪些 URL 无需带该请求头。例如说: # 4.3 Security 层 实现可见 security (opens new window) 包。 主要是校验登录的用户,校验是否有权限访问该租户,避免越权问题。 # 4.4 DB 层【重要】 实现可见 db (opens new window) 包。 COLUMN 模式,基于 MyBatis Plus 自带的多租户 (opens new window)功能实现。 核心:每次对数据库操作时,它会自动拼接 WHERE tenant_id = ? 条件来进行租户的过滤,并且基本支持所有的 SQL 场景。 如下是具体方式: ① 需要开启多租户的表,必须添加 tenant_id 字段。例如说 system_users、system_role 等表。 CREATE TABLE `system_role` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID', `name` varchar(30) CHARACTER NOT NULL COMMENT '角色名称', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='角色信息表'; 并且该表对应的 DO 需要使用到 tenantId 属性时,建议继承 TenantBaseDO (opens new window) 类。 ② 无需开启多租户的表,需要添加表名到 yudao.tenant.ignore-tables 配置项目。例如说: 如果不配置的话,MyBatis Plus 会自动拼接 WHERE tenant_id = ? 条件,导致报 tenant_id 字段不存在的错误。 # 4.5 Redis 层 实现可见 redis (opens new window) 包。 # 使用方式一:基于 Spring Cache + Redis【推荐】 只需要一步,在方法上添加 Spring Cache 注解,例如说 @Cachable、@CachePut、@CacheEvict。 具体的实现原理,可见 TenantRedisCacheManager (opens new window) 的源码。 注意!!!默认配置下,Spring Cache 都开启 Redis Key 的多租户隔离。如果不需要,可以将 Key 添加到 yudao.tenant.ignore-cache 配置项中。如下图所示: # 使用方式二:基于 RedisTemplate + TenantRedisKeyDefine 暂时没有合适的封装,需要在自己 format Redis Key 的时候,手动将 :t{tenantId} 后缀拼接上。 这也是为什么,我推荐你使用 Spring Cache + Redis 的原因! # 4.6 AOP【重要】 实现可见 aop (opens new window) 包。 ① 声明 @TenantIgnore (opens new window) 注解在方法上,标记指定方法不进行租户的自动过滤,避免自动拼接 WHERE tenant_id = ? 条件等等。 例如说:RoleServiceImpl (opens new window) 的 #initLocalCache() (opens new window) 方法,加载所有租户的角色到内存进行缓存,如果不声明 @TenantIgnore 注解,会导致租户的自动过滤,只加载了某个租户的角色。 // RoleServiceImpl.javapublic class RoleServiceImpl implements RoleService { @Resource @Lazy // 注入自己,所以延迟加载 private RoleService self; @Override @PostConstruct @TenantIgnore // 忽略自动多租户,全局初始化缓存 public void initLocalCache() { // ... 从数据库中,加载角色 } @Scheduled(fixedDelay = SCHEDULER_PERIOD, initialDelay = SCHEDULER_PERIOD) public void schedulePeriodicRefresh() { self.initLocalCache(); // <x> 通过 self 引用到 Spring 代理对象 }} 有一点要格外注意,由于 @TenantIgnore 注解是基于 Spring AOP 实现,如果是方法内部的调用,避免使用 this 导致不生效,可以采用上述示例的 <x> 处的 self 方式。 ② 使用 TenantUtils (opens new window) 的 #execute(Long tenantId, Runnable runnable) 方法,模拟指定租户( tenantId ),执行某段业务逻辑( runnable )。 例如说:在 TenantServiceImpl (opens new window) 的 #createTenant(...) 方法,在创建完租户时,需要模拟该租户,进行用户和角色的创建。如下图所示: # 4.7 Job【重要】 实现可见 job (opens new window) 包。 声明 @TenantJob (opens new window) 注解在 Job 类上,实现并行遍历每个租户,执行定时任务的逻辑。 # 4.8 MQ 实现可见 mq (opens new window) 包。 通过租户对 MQ 层面的封装,实现租户上下文,可以继续传递到 MQ 消费的逻辑中,避免丢失的问题。实现原理是: 发送消息时,MQ 会将租户上下文的租户编号,记录到 Message 消息头 tenant-id 上。 消费消息时,MQ 会将 Message 消息头 tenant-id,设置到租户上下文的租户编号。 # 4.9 Async 实现可见 YudaoAsyncAutoConfiguration (opens new window) 类。 通过使用阿里开源的 TransmittableThreadLocal (opens new window) 组件,实现 Spring Async 执行异步逻辑时,租户上下文可以继续传递,避免丢失的问题。 # 4.10 RPC 实现可见 mq (opens new window) 包。 RPC 使用 Feign 调用时,会自动将租户上下文的租户编号,设置到 HTTP 请求头 tenant-id 上。 在 Provider 服务端,会自动将 HTTP 请求头 tenant-id,设置到租户上下文的租户编号。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/03, 23:42:51 OAuth 2.0(SSO 单点登录) SaaS 多租户【数据库隔离】 ← OAuth 2.0(SSO 单点登录) SaaS 多租户【数据库隔离】→"},{"title":"SaaS 多租户【数据库隔离】","path":"/wiki/YuDaoCloud/后端手册/SaaS 多租户【数据库隔离】/SaaS 多租户【数据库隔离】.html","content":"None"},{"title":"代码生成(新增功能)","path":"/wiki/YuDaoCloud/后端手册/代码生成(新增功能)/代码生成(新增功能).html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 代码生成(新增功能) 大部分项目里,其实有很多代码是重复的,几乎每个模块都有 CRUD 增删改查的功能,而这些功能的实现代码往往是大同小异的。如果这些功能都要自己去手写,非常无聊枯燥,浪费时间且效率很低,还可能会写错。 所以这种重复性的代码,项目提供了 codegen (opens new window) 代码生成器,只需要在数据库中设计好表结构,就可以一键生成前后端代码 + 单元测试 + Swagger 接口文档 + Validator 参数校验。 下面,我们使用代码生成器,在 yudao-module-system 模块中,开发一个【用户组】的功能。 # 👍 相关视频教程 友情提示:虽然是基于 Boot 项目录制,但是 Cloud 一样可以学习。 从零开始 05:如何 5 分钟,开发一个新功能? (opens new window) # 1. 数据库表结构设计 设计用户组的数据库表名为 system_group,其建表语句如下: CREATE TABLE `system_group` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '名字', `description` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '描述', `status` tinyint NOT NULL COMMENT '状态', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户组'; ① 表名的前缀,要和 Maven Module 的模块名保持一致。例如说,用户组在 yudao-module-system 模块,所以表名的前缀是 system_。 疑问:为什么要保持一致? 代码生成器会自动解析表名的前缀,获得其所属的 Maven Module 模块,简化配置过程。 ② 设置 ID 主键,一般推荐使用 bigint 长整形,并设置自增长。 ③ 正确设置每个字段是否允许空,代码生成器会根据它生成参数是否允许空的校验规则。 ④ 正确设置注释,代码生成器会根据它生成字段名与提示等信息。 ⑤ 添加 creator、create_time、updater、update_time、deleted 是必须设置的系统字段;如果开启多租户的功能,并且该表需要多租户的隔离,则需要添加 tenant_id 字段。 # 2. 代码生成 ① 点击 [基础设施 -> 代码生成] 菜单,点击 [基于 DB 导入] 按钮,选择 system_group 表,后点击 [确认] 按钮。 代码实现? 可见 CodegenBuilder (opens new window) 类,自动解析数据库的表结构,生成默认的配置。 ② 点击 system_group 所在行的 [编辑] 按钮,修改生成配置。后操作如下: 将 status 字段的显示类型为【下拉框】,字典类型为【系统状态】。 将 description 字段的【查询】取消。 将 id、name、description、status 字段的【示例】填写上。 字段信息 插入:新增时,是否传递该字段。 编辑:修改时,是否传递该字段。 列表:Table 表格,是否展示该字段。 查询:搜索框,是否支持该字段查询,查询的条件是什么。 允许空:新增或修改时,是否必须传递该字段,用于 Validator 参数校验。 字典类型:在显示类型是下拉框、单选框、复选框时,选择使用的字典。 示例:参数示例,用于 Swagger 接口文档的 example 示例。 将【前端类型】设置为【Vue2 Element UI 标准模版】或【Vue3 Element Plus 标准模版】,具体根据你使用哪种管理后台。 生成信息 生成场景:分成管理后台、用户 App 两种,用于生成 Controller 放在 admin 还是 app 包。 上级菜单:生成场景是管理后台时,需要设置其所属的上级菜单。 前端类型: 提供多种 UI 模版。 【Vue3 Element Plus Schema 模版】,对应 《前端手册 Vue 3.X —— CRUD 组件》 说明。 后端的 application.yaml 配置文件中的 yudao.codegen.front-type 配置项,设置默认的 UI 模版,避免每次都需要设置。 完成后,点击 [提交] 按钮,保存生成配置。 ③ 点击 system_group 所在行的 [预览] 按钮,在线预览生成的代码,检查是否符合预期。 ④ 点击 system_group 所在行的 [生成代码] 按钮,下载生成代码的压缩包,双击进行解压。 代码实现? 可见 CodegenEngine (opens new window) 类,基于 Velocity 模板引擎,生成具体代码。模板文件,可见 resources/codegen (opens new window) 目录。 # 3. 代码运行 本小节,我们将生成的代码,复制到项目中,并进行运行。 # 3.1 后端运行 ① 将生成的后端代码,复制到项目中。操作如下图所示: ② 将 ErrorCodeConstants.java_手动操作 文件的错误码,复制到该模块 ErrorCodeConstants 类中,并设置对应的错误码编号,之后进行删除。操作如下图所示: ③ 将 h2.sql 的 CREATE 语句复制到该模块的 create_tables.sql 文件,DELETE 语句复制到该模块的 clean.sql。操作如下图: 疑问:`create_tables.sql` 和 `clean.sql` 文件的作用是什么? 项目的单元测试,需要使用到 H2 内存数据库,create_tables.sql 用于创建所有的表结构,clean.sql 用于每个单元测试的方法跑完后清理数据。 然后,运行 GroupServiceImplTest 单元测试,执行通过。 ④ 打开数据库工具,运行代码生成的 sql/sql.sql 文件,用于菜单的初始化。 ⑤ Debug 运行 YudaoServerApplication 类,启动后端项目。通过 IDEA 的 [Actuator -> Mappings] 菜单,可以看到代码生成的 GroupController 的 RESTful API 接口已经生效。 # 3.2 前端运行 ① 将生成的前端代码,复制到项目中。操作如下图所示: ② 重新执行 npm run dev 命令,启动前端项目。点击 [系统管理 -> 用户组管理] 菜单,就可以看到用户组的 UI 界面。 至此,我们已经完成了【用户组】功能的代码生成,基本节省了你 80% 左右的开发任务,后续可以根据自己的需求,进行剩余的 20% 的开发! # 4. 后续变更 随着业务的发展,已经生成代码的功能需要变更。继续以【用户组】举例子,它的 system_group 表需要新增一个分类 category 字段,此时不建议使用代码生成器,而是直接修改已经生成的代码: ① 后端:修改 GroupDO 数据实体类、GroupBaseVO 基础 VO 类、GroupExcelVO 导出结果 VO 类,新增 category 字段。 ② 前端:修改 index.vue 界面的列表和表单组件,新增 category 字段。 ③ 重新编译后后端,并进行启动。 over!非常简单方便,即保证了代码的整洁规范,又不增加过多的开发量。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/13, 08:14:58 新建服务 功能权限 ← 新建服务 功能权限→"},{"title":"三方登录","path":"/wiki/YuDaoCloud/后端手册/三方登录/三方登录.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 三方登录 系统对接国内多个第三方平台,实现三方登录的功能。例如说: 管理后台:企业微信、阿里钉钉 用户 App:微信公众号、微信小程序 友情提示:为了表述方便,本文主要使用管理后台的三方登录作为示例。 用户 App 也是支持该功能,你可以自己去体验一下。 # 1. 表结构 ① 三方登录完成时,系统会将三方用户存储到 system_social_user (opens new window) 表中,通过 type (opens new window) 标记对应的第三方平台。 ② 【未】关联本系统 User 的三方用户,需要在三方登录完成后,使用账号密码进行「绑定登录」,成功后记录到 system_social_user_bind (opens new window) 表中。 【已】关联本系统 User 的三方用户,在三方登录完成后,直接进入系统,即「快捷登录」。 # 2. 绑定登录 ① 使用浏览器访问 http://127.0.0.1:1024/login (opens new window) 地址,点击 [钉钉] 或者 [企业微信] 进行三方登录。此时,会调用 /admin-api/system/auth/social-auth-redirect (opens new window) 接口,获得第三方平台的登录地址,并进行跳转。 然后,使用 [钉钉] 或者 [企业微信] 进行扫码,完成三方登录。 ② 三方登录成功后,跳转回 http://127.0.0.1:1024/social-login (opens new window) 地址。此时,会调用 /admin-api/system/auth/social-login (opens new window) 接口,尝试「快捷登录」。由于该三方用户【未】关联管理后台的 AdminUser 用户,所以会看到 “未绑定账号,需要进行绑定” 报错。 ③ 输入账号密码,点击 [提交] 按钮,进行「绑定登录」。此时,会调用 /admin-api/system/auth/login (opens new window) 接口(在账号密码登录的基础上,额外带上 socialType + socialCode + socialState 参数)。成功后,即可进入系统的首页。 # 3. 快捷登录 退出系统,再进行一次三方登录的流程。 【相同】① 使用浏览器访问 http://127.0.0.1:1024/login (opens new window) 地址,点击 [钉钉] 或者 [企业微信] 进行三方登录。此时,会调用 /admin-api/system/auth/social-auth-redirect (opens new window) 接口,获得第三方平台的登录地址,并进行跳转。 【不同】② 三方登录成功后,跳转回 http://127.0.0.1:1024/social-login (opens new window) 地址。此时,会调用 /admin-api/system/auth/social-login (opens new window) 接口,尝试「快捷登录」。由于该三方用户【已】关联管理后台的 AdminUser 用户,所以直接进入系统的首页。 # 4. 绑定与解绑 访问 http://127.0.0.1:1024/user/profile (opens new window) 地址,选择 [社交信息] 选项,可以三方用户的绑定与解绑。 # 5. 配置文件 在 application-{env}.yaml (opens new window) 配置文件中,对应 justauth 配置项,填写你的第三方平台的配置信息。 系统使用 justauth-spring-boot-starter (opens new window) JustAuth (opens new window) 组件,想要对接其它第三方平台,只需要新增对应的配置信息即可。 疑问:yudao-spring-boot-starter-biz-social 技术组件的作用是什么? yudao-spring-boot-starter-biz-social (opens new window) 对 JustAuth 进行二次封装,提供微信小程序的集成。 # 6. 第三方平台的申请 阿里钉钉:https://justauth.wiki/guide/oauth/dingtalk/ (opens new window) 企业微信:https://justauth.wiki/guide/oauth/wechat_enterprise_qrcode/ (opens new window) 微信开放平台:https://justauth.wiki/guide/oauth/wechat_open/ (opens new window) 注意,如果第三方平台如果需要配置具体的授信地址,需要添加 /social-login 用于三方登录回调页、/user/profile 用于三方用户的绑定与解绑。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/30, 21:37:18 用户体系 OAuth 2.0(SSO 单点登录) ← 用户体系 OAuth 2.0(SSO 单点登录)→"},{"title":"分布式锁","path":"/wiki/YuDaoCloud/后端手册/分布式锁/分布式锁.html","content":"开发指南后端手册 芋道源码 2022-04-05 目录 分布式锁 yudao-spring-boot-starter-protection (opens new window) 技术组件,使用 Redis 实现分布式锁的功能,它有 2 种使用方式: 编程式锁:基于 Redisson (opens new window) 框架提供的各种 (opens new window)分布式锁 声明式锁:基于 Lock4j (opens new window) 框架的 @Lock4j 注解 Redis 分布式锁的实现原理? 参见 《Redis 实现原理与源码解析系列》 (opens new window) 文章。 # 1. 编程式锁 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId></dependency> # 1.1 Redisson 配置 无需配置。因为在 Redis 缓存 中,进行了 Spring Data Redis + Redisson 的配置。 # 1.2 实战案例 yudao-module-pay 模块的 notify ( opens new window) 功能,使用到分布式锁,确保每个 支付通知任务有且仅有一个在执行。下面,来看看这个案例是如何实现的。 友情提示: 建议你已经阅读过 《开发指南 —— Redis 缓存》 文档。 ① 在 RedisKeyConstants ( opens new window) 类中,定义通知任务使用的分布式锁的 Redis Key。如下图所示: ② 创建 PayNotifyLockRedisDAO ( opens new window) 类,使用 RedisClient 实现分布式锁的加锁与解锁。如下图所示: ③ 在 PayNotifyServiceImpl ( opens new window) 执行指定的支付通知任务时,通过 PayNotifyLockRedisDAO 获得分布式锁。如下图所示: 技术选型:为什么不使用 Lock4j 提供的 LockTemplate 实现编程式锁? 两者各有优势,选择 Redisson 主要考虑它支持的 Redis 分布式锁的类型较多:可靠性较高的红锁、性能较好的读写锁等等。 Lock4j 的 LockTemplate 也是不错的选择,一方面不强依赖 Redisson 框架,一方面支持 ZooKeeper 等等。 # 2. 声明式锁 <dependency> <groupId>com.baomidou</groupId> <artifactId>lock4j-redisson-spring-boot-starter</artifactId></dependency> # 2.1 Lock4j 配置 友情提示:以 yudao-module-system 服务为例子。 在 application-local.yaml ( opens new window) 配置文件中,通过 lock4j 配置项,添加 Lock4j 全局默认的分布式锁配置。如下图所示: # 2.2 使用案例 在需要使用到分布式锁的方法上,添加 @Lock4j 注解,非常方便。示例代码如下: @Servicepublic class DemoService { // 默认使用 lock4j 配置项 @Lock4j public void simple() { //do something } // 完全配置,支持 Spring EL 表达式 @Lock4j(keys = {"#user.id", "#user.name"}, expire = 60000, acquireTimeout = 1000) public User customMethod(User user) { return user; }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 11:00:42 单元测试 幂等性(防重复提交) ← 单元测试 幂等性(防重复提交)→"},{"title":"分页实现","path":"/wiki/YuDaoCloud/后端手册/分页实现/分页实现.html","content":"开发指南后端手册 芋道源码 2022-03-26 目录 分页实现 前端:基于 Element UI 分页组件 Pagination (opens new window) 后端:基于 MyBatis Plus 分页功能,二次封装 以 [系统管理 -> 租户管理 -> 租户列表] 菜单为例子,讲解它的分页 + 搜索的实现。 # 1. 前端分页实现 # 1.1 Vue 界面 界面 tenant/index.vue (opens new window) 相关的代码如下: <template> <!-- 搜索工作栏 --> <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px"> <el-form-item label="租户名" prop="name"> <el-input v-model="queryParams.name" placeholder="请输入租户名" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="联系人" prop="contactName"> <el-input v-model="queryParams.contactName" placeholder="请输入联系人" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="联系手机" prop="contactMobile"> <el-input v-model="queryParams.contactMobile" placeholder="请输入联系手机" clearable @keyup.enter.native="handleQuery"/> </el-form-item> <el-form-item label="租户状态" prop="status"> <el-select v-model="queryParams.status" placeholder="请选择租户状态" clearable> <el-option v-for="dict in this.getDictDatas(DICT_TYPE.COMMON_STATUS)" :key="dict.value" :label="dict.label" :value="dict.value"/> </el-select> </el-form-item> <el-form-item> <el-button type="primary" icon="el-icon-search" @click="handleQuery">搜索</el-button> <el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button> </el-form-item> </el-form> <!-- 列表 --> <el-table v-loading="loading" :data="list"> <!-- 省略每一列... --> </el-table> <!-- 分页组件 --> <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize" @pagination="getList"/></template><script>import { getTenantPage } from "@/api/system/tenant";export default { name: "Tenant", components: {}, data() { // 遮罩层 return { // 遮罩层 loading: true, // 显示搜索条件 showSearch: true, // 总条数 total: 0, // 租户列表 list: [], // 查询参数 queryParams: { pageNo: 1, pageSize: 10, // 搜索条件 name: null, contactName: null, contactMobile: null, status: undefined, }, } }, created() { this.getList(); }, methods: { /** 查询列表 */ getList() { this.loading = true; // 处理查询参数 let params = {...this.queryParams}; // 执行查询 getTenantPage(params).then(response => { this.list = response.data.list; this.total = response.data.total; this.loading = false; }); }, /** 搜索按钮操作 */ handleQuery() { this.queryParams.pageNo = 1; this.getList(); }, /** 重置按钮操作 */ resetQuery() { this.resetForm("queryForm"); this.handleQuery(); } }}</script> # 1.2 API 请求 请求 system/tenant.js ( opens new window) 相关的代码如下: import request from '@/utils/request'// 获得租户分页export function getTenantPage(query) { return request({ url: '/system/tenant/page', method: 'get', params: query })} # 2. 后端分页实现 # 2.1 Controller 接口 在 TenantController ( opens new window) 类中,定义 /admin-api/system/tenant/page 接口。代码如下: @Tag(name = "管理后台 - 租户")@RestController@RequestMapping("/system/tenant")public class TenantController { @Resource private TenantService tenantService; @GetMapping("/page") @Operation(summary = "获得租户分页") @PreAuthorize("@ss.hasPermission('system:tenant:query')") public CommonResult<PageResult<TenantRespVO>> getTenantPage(@Valid TenantPageReqVO pageVO) { PageResult<TenantDO> pageResult = tenantService.getTenantPage(pageVO); return success(TenantConvert.INSTANCE.convertPage(pageResult)); }} Request 分页请求,使用 TenantPageReqVO (opens new window) 类,它继承 PageParam 类 Response 分页结果,使用 PageResult 类,每一项是 TenantRespVO (opens new window) 类 # 2.1.1 分页参数 PageParam 分页请求,需要继承 PageParam (opens new window) 类。代码如下: @Schema(description="分页参数")@Datapublic class PageParam implements Serializable { private static final Integer PAGE_NO = 1; private static final Integer PAGE_SIZE = 10; @Schema(description = "页码,从 1 开始", required = true,example = "1") @NotNull(message = "页码不能为空") @Min(value = 1, message = "页码最小值为 1") private Integer pageNo = PAGE_NO; @Schema(description = "每页条数,最大值为 100", required = true, example = "10") @NotNull(message = "每页条数不能为空") @Min(value = 1, message = "每页条数最小值为 1") @Max(value = 100, message = "每页条数最大值为 100") private Integer pageSize = PAGE_SIZE;} 分页条件,在子类中进行定义。以 TenantPageReqVO 举例子,代码如下: @Schema(description = "管理后台 - 租户分页 Request VO")@Data@EqualsAndHashCode(callSuper = true)@ToString(callSuper = true)public class TenantPageReqVO extends PageParam { @Schema(description = "租户名", example = "芋道") private String name; @Schema(description = "联系人", example = "芋艿") private String contactName; @Schema(description = "联系手机", example = "15601691300") private String contactMobile; @Schema(description = "租户状态(0正常 1停用)", example = "1") private Integer status; @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @Schema(description = "创建时间") private LocalDateTime[] createTime;} # 2.1.2 分页结果 PageResult 分页结果 PageResult ( opens new window) 类,代码如下: @Schema(description = "分页结果")@Datapublic final class PageResult<T> implements Serializable { @Schema(description = "数据", required = true) private List<T> list; @Schema(description = "总量", required = true) private Long total;} 分页结果的数据 list 的每一项,通过自定义 VO 类,例如说 TenantRespVO (opens new window) 类。 # 2.2 Mapper 查询 在 TenantMapper (opens new window) 类中,定义 selectPage 查询方法。代码如下: @Mapperpublic interface TenantMapper extends BaseMapperX<TenantDO> { default PageResult<TenantDO> selectPage(TenantPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX<TenantDO>() .likeIfPresent(TenantDO::getName, reqVO.getName()) // 如果 name 不为空,则进行 like 查询 .likeIfPresent(TenantDO::getContactName, reqVO.getContactName()) .likeIfPresent(TenantDO::getContactMobile, reqVO.getContactMobile()) .eqIfPresent(TenantDO::getStatus, reqVO.getStatus()) // 如果 status 不为空,则进行 = 查询 .betweenIfPresent(TenantDO::getCreateTime, reqVO.getBeginCreateTime(), reqVO.getEndCreateTime()) // 如果 create 不为空,则进行 between 查询 .orderByDesc(TenantDO::getId)); // 按照 id 倒序 }} 针对 MyBatis Plus 分页查询的二次分装,在 BaseMapperX (opens new window) 中实现,主要是将 MyBatis 的分页结果 IPage,转换成项目的分页结果 PageResult。代码如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:58:32 参数校验 文件存储(上传下载) ← 参数校验 文件存储(上传下载)→"},{"title":"功能权限","path":"/wiki/YuDaoCloud/后端手册/功能权限/功能权限.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 功能权限 # 👍 相关视频教程 友情提示:虽然是基于 Boot 项目录制,但是 Cloud 一样可以学习。 功能权限 01:如何设计一套权限系统? (opens new window) 功能权限 02:如何实现菜单的创建? (opens new window) 功能权限 03:如何实现角色的创建? (opens new window) 功能权限 04:如何给用户分配权限 —— 将菜单赋予角色? (opens new window) 功能权限 05:如何给用户分配权限 —— 将角色赋予用户? (opens new window) 功能权限 06:后端如何实现 URL 权限的校验? (opens new window) 功能权限 07:前端如何实现菜单的动态加载? (opens new window) 功能权限 08:前端如何实现按钮的权限校验? (opens new window) # 1. RBAC 权限模型 系统采用 RBAC 权限模型,全称是 Role-Based Access Control 基于角色的访问控制。 简单来说,每个用户拥有若干角色,每个角色拥有若干个菜单,菜单中存在菜单权限、按钮权限。这样,就形成了 “用户<->角色<->菜单” 的授权模型。 在这种模型中,用户与角色、角色与菜单之间构成了多对多的关系,如下图: # 2. Token 认证机制 安全框架使用的是 Spring Security (opens new window) + Token 方案,整体流程如下图所示: ① 前端调用登录接口,使用账号密码获得到认证 Token。响应示例如下: { "code":0, "msg":"", "data":{ "token":"d2a3cdbc6c53470db67a582bd115103f" }} 管理后台的登录实现,可见 代码 (opens new window) 用户 App 的登录实现,可见 代码 (opens new window) 疑问:为什么不使用 Spring Security 内置的表单登录? Spring Security 的登录拓展起来不方便,例如说验证码、三方登录等等。 Token 存储在数据库中,对应 system_oauth2_access_token 访问令牌表的 id 字段。考虑到访问的性能,缓存在 Redis 的 oauth2_access_token:%s (opens new window) 键中。 疑问:为什么不使用 JWT(JSON Web Token)? JWT 是无状态的,无法实现 Token 的作废,例如说用户登出系统、修改密码等等场景。 推荐阅读 《还分不清 Cookie、Session、Token、JWT?》 (opens new window) 文章。 默认配置下,Token 有效期为 30 天,可通过 system_oauth2_client 表中 client_id = default 的记录进行自定义: 修改 access_token_validity_seconds 字段,设置访问令牌的过期时间,默认 1800 秒 = 30 分钟 修改 refresh_token_validity_seconds 字段,设置刷新令牌的过期时间,默认 43200 秒 = 30 天 ② 前端调用其它接口,需要在请求头带上 Token 进行访问。请求头格式如下: ### Authorization: Bearer 登录时返回的 TokenAuthorization: Bearer d2a3cdbc6c53470db67a582bd115103f 具体的代码实现,可见 TokenAuthenticationFilter (opens new window) 过滤器 考虑到使用 Postman、Swagger 调试接口方便,提供了 Token 的模拟机制。请求头格式如下: ### Authorization: Bearer test用户编号Authorization: Bearer test1 其中 \"test\" 可自定义,配置项如下: ### application-local.yamlyudao: security: mock-enable: true # 是否开启 Token 的模拟机制 mock-secret: test # Token 模拟机制的 Token 前缀 # 3. 权限注解 # 3.1 @PreAuthorize 注解 @PreAuthorize ( opens new window) 是 Spring Security 内置的前置权限注解,添加在 接口方法上,声明需要的权限,实现访问权限的控制。 ① 基于【权限标识】的权限控制 权限标识,对应 system_menu 表的 permission 字段,推荐格式为 ${系统}:${模块}:${操作},例如说 system:admin:add 标识 system 服务的添加管理员。 使用示例如下: // 符合 system:user:list 权限要求@PreAuthorize("@ss.hasPermission('system:user:list')")// 符合 system:user:add 或 system:user:edit 权限要求即可@PreAuthorize("@ss.hasAnyPermissions('system:user:add,system:user:edit')") ② 基于【角色标识】的权限控制 权限标识,对应 system_role 表的 code 字段, 例如说 super_admin 超级管理员、tenant_admin 租户管理员。 使用示例如下: // 属于 user 角色@PreAuthorize("@ss.hasRole('user')")// 属于 user 或者 admin 之一@PreAuthorize("@ss.hasAnyRoles('user,admin')") 实现原理是什么? 当 @PreAuthorize 注解里的 Spring EL 表达式返回 false 时,表示没有权限。 而 @PreAuthorize(\"@ss.hasPermission('system:user:list')\") 表示调用 Bean 名字为 ss 的 #hasPermission(...) 方法,方法参数为 \"system:user:list\" 字符串。ss 对应的 Bean 是 PermissionServiceImpl (opens new window) 类,所以你只需要去看该方法的实现代码 (opens new window)。 # 3.2 @PreAuthenticated 注解 @PreAuthenticated (opens new window) 是项目自定义的认证注解,添加在接口方法上,声明登录的用户才允许访问。 主要使用场景是,针对用户 App 的 /app-app/** 的 RESTful API 接口,默认是无需登录的,通过 @PreAuthenticated 声明它需要进行登录。使用示例如下: // AppAuthController.java@PostMapping("/update-password")@Operation(summary = "修改用户密码", description = "用户修改密码时使用")@PreAuthenticatedpublic CommonResult<Boolean> updatePassword(@RequestBody @Valid AppAuthUpdatePasswordReqVO reqVO) { // ... 省略代码} 具体的代码实现,可见 PreAuthenticatedAspect (opens new window) 类。 # 4. 自定义权限配置 默认配置下,管理后台的 /admin-api/** 所有 API 接口都必须登录后才允许访问,用户 App 的 /app-api/** 所有 API 接口无需登录就可以访问。 如下想要自定义权限配置,设置定义 API 接口可以匿名(不登录)进行访问,可以通过下面三种方式: # 4.1 方式一:自定义 AuthorizeRequestsCustomizer 实现 每个 Maven Module 可以实现自定义的 AuthorizeRequestsCustomizer (opens new window) Bean,额外定义每个 Module 的 API 接口的访问规则。例如说 yudao-module-infra 模块的 SecurityConfiguration (opens new window) 类,代码如下: @Configuration("infraSecurityConfiguration")public class SecurityConfiguration { @Value("${spring.boot.admin.context-path:''}") private String adminSeverContextPath; @Bean("infraAuthorizeRequestsCustomizer") public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() { return new AuthorizeRequestsCustomizer() { @Override public void customize(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) { // Swagger 接口文档 registry.antMatchers("/swagger-ui.html").anonymous() .antMatchers("/swagger-resources/**").anonymous() .antMatchers("/webjars/**").anonymous() .antMatchers("/*/api-docs").anonymous(); // Spring Boot Actuator 的安全配置 registry.antMatchers("/actuator").anonymous() .antMatchers("/actuator/**").anonymous(); // Druid 监控 registry.antMatchers("/druid/**").anonymous(); // Spring Boot Admin Server 的安全配置 registry.antMatchers(adminSeverContextPath).anonymous() .antMatchers(adminSeverContextPath + "/**").anonymous(); } }; }} 友情提示 permitAll() 方法:所有用户可以任意访问,包括带上 Token 访问 anonymous() 方法:匿名用户可以任意访问,带上 Token 访问会报错 如果你对 Spring Security 了解不多,可以阅读艿艿写的 《芋道 Spring Boot 安全框架 Spring Security 入门 》 (opens new window) 文章。 # 4.2 方式二:@PermitAll 注解 在 API 接口上添加 @PermitAll (opens new window) 注解,示例如下: // FileController.java@GetMapping("/{configId}/get/{path}")@PermitAllpublic void getFileContent(HttpServletResponse response, @PathVariable("configId") Long configId, @PathVariable("path") String path) throws Exception { // ...} # 4.3 方式三:yudao.security.permit-all-urls 配置项 在 application.yaml 配置文件,通过 yudao.security.permit-all-urls 配置项设置,示例如下: yudao: security: permit-all-urls: - /admin-ui/** # /resources/admin-ui 目录下的静态资源 - /admin-api/xxx/yyy .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:33 代码生成(新增功能) 数据权限 ← 代码生成(新增功能) 数据权限→"},{"title":"参数校验","path":"/wiki/YuDaoCloud/后端手册/参数校验/参数校验.html","content":"开发指南后端手册 芋道源码 2022-03-26 目录 参数校验 项目使用 Hibernate Validator (opens new window) 框架,对 RESTful API 接口进行参数的校验,以保证最终数据入库的正确性。例如说,用户注册时,会校验手机格式的正确性,密码非弱密码。 如果参数校验不通过,会抛出 ConstraintViolationException 异常,被全局的异常处理捕获,返回“请求参数不正确”的响应。示例如下: { "code": 400, "data": null, "msg": "请求参数不正确:密码不能为空"} # 1. 参数校验注解 Validator 内置了 20+ 个参数校验注解,整理成常用与不常用的注解。 # 1.1 常用注解 注解 功能 @NotBlank 只能用于字符串不为 null ,并且字符串 #trim() 以后 length 要大于 0 @NotEmpty 集合对象的元素不为 0 ,即集合不为空,也可以用于字符串不为 null @NotNull 不能为 null @Pattern( value) 被注释的元素必须符合指定的正则表达式 @Max(value) 该字段的值只能小于或等于该值 @Min(value) 该字段的值只能大于或等于该值 @Range(min=, max=) 检被注释的元素必须在合适的范围内 @Size(max, min) 检查该字段的 size 是否在 min 和 max 之间,可以是字符串、数组、集合、Map 等 @Length(max, min) 被注释的字符串的大小必须在指定的范围内。 @AssertFalse 被注释的元素必须为 true @AssertTrue 被注释的元素必须为 false @Email 被注释的元素必须是电子邮箱地址 @URL( protocol=,host=,port=,regexp=,flags=) 被注释的字符串必须是一个有效的 URL # 1.2 不常用注解 注解 功能 @Null 必须为 null @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 @DecimalMin(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 @Digits(integer, fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内 @Positive 判断正数 @PositiveOrZero 判断正数或 0 @Negative 判断负数 @NegativeOrZero 判断负数或 0 @Future 被注释的元素必须是一个将来的日期 @FutureOrPresent 判断日期是否是将来或现在日期 @Past 检查该字段的日期是在过去 @PastOrPresent 判断日期是否是过去或现在日期 @SafeHtml 判断提交的 HTML 是否安全。例如说,不能包含 JavaScript 脚本等等 # 2. 参数校验使用 只需要三步,即可开启参数校验的功能。 〇 第零步,引入参数校验的 spring-boot-starter-validation ( opens new window) 依赖。一般不需要做,项目默认已经引入。 ① 第一步,在需要参数校验的类上,添加 @Validated ( opens new window) 注解,例如说 Controller、Service 类。代码如下: // Controller 示例@Validatedpublic class AuthController {}// Service 示例,一般放在实现类上@Service@Validatedpublic class AdminAuthServiceImpl implements AdminAuthService {} ② 第二步(情况一)如果方法的参数是 Bean 类型,则在方法参数上添加 @Valid (opens new window) 注解,并在 Bean 类上添加参数校验的注解。代码如下: // Controller 示例@Validatedpublic class AuthController { @PostMapping("/login") public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {}}// Service 示例,一般放在接口上public interface AdminAuthService { String login(@Valid AuthLoginReqVO reqVO, String userIp, String userAgent);}// Bean 类的示例。一般建议添加参数注解到属性上。原因:采用 Lombok 后,很少使用 getter 方法public class AuthLoginReqVO { @NotEmpty(message = "登录账号不能为空") @Length(min = 4, max = 16, message = "账号长度为 4-16 位") @Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母") private String username; @NotEmpty(message = "密码不能为空") @Length(min = 4, max = 16, message = "密码长度为 4-16 位") private String password;} ② 第二步(情况二)如果方法的参数是普通类型,则在方法参数上直接添加参数校验的注解。代码如下: // Controller 示例@Validatedpublic class DictDataController { @GetMapping(value = "/get") public CommonResult<DictDataRespVO> getDictData(@RequestParam("id") @NotNull(message = "编号不能为空") Long id) {}}// Service 示例,一般放在接口上public interface DictDataService { DictDataDO getDictData(@NotNull(message = "编号不能为空") Long id);} ③ 启动项目,模拟调用 RESTful API 接口,少填写几个参数,看看参数校验是否生效。 疑问:Controller 做了参数校验后,Service 是否需要做参数校验? 是需要的。Service 可能会被别的 Service 进行调用,也会存在参数不正确的情况,所以必须进行参数校验。 # 3. 自定义注解 如果 Validator 内置的参数校验注解不满足需求时,我们也可以自定义参数校验的注解。 在项目的 yudao-common (opens new window) 的 validation (opens new window) 包下,就自定义了多个参数校验的注解,以 @Mobile (opens new window) 注解来举例,它提供了手机格式的校验。 ① 第一步,新建 @Mobile 注解,并设置自定义校验器为 MobileValidator (opens new window) 类。代码如下: @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})@Retention(RetentionPolicy.RUNTIME)@Documented@Constraint( validatedBy = MobileValidator.class // 设置校验器)public @interface Mobile { String message() default "手机号格式不正确"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {};} ② 第二步,新建 MobileValidator (opens new window) 校验器。代码如下: public class MobileValidator implements ConstraintValidator<Mobile, String> { @Override public void initialize(Mobile annotation) { } @Override public boolean isValid(String value, ConstraintValidatorContext context) { // 如果手机号为空,默认不校验,即校验通过 if (StrUtil.isEmpty(value)) { return true; } // 校验手机 return ValidationUtils.isMobile(value); }} ③ 第三步,在需要手机格式校验的参数上添加 @Mobile 注解。示例代码如下: public class AppAuthLoginReqVO { @NotEmpty(message = "手机号不能为空") @Mobile // <=== here private String mobile;} # 4. 更多使用文档 更多关于 Validator 的使用,可以系统阅读 《芋道 Spring Boot 参数校验 Validation 入门 》 ( opens new window) 文章。 例如说,手动参数校验、分组校验、国际化 i18n 等等。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/30, 21:15:08 异常处理(错误码) 分页实现 ← 异常处理(错误码) 分页实现→"},{"title":"单元测试","path":"/wiki/YuDaoCloud/后端手册/单元测试/单元测试.html","content":"开发指南后端手册 芋道源码 2022-04-04 目录 单元测试 项目使用 Junit5 + Mockito 实现单元测试,提升代码质量、重复测试效率、部署可靠性等。 截止目前,项目已经有 500+ 测试用例。 内容推荐 如果你想系统学习单元测试,可以阅读《有效的单元测试》 (opens new window)这本书,非常适合 Java 工程师。 如果只是想学习 Spring Boot Test 的话,可以阅读 《芋道 Spring Boot 单元测试 Test 入门 》 (opens new window) 文章。 # 1.测试组件 yudao-spring-boot-starter-test (opens new window) 是项目提供的测试组件,用于单元测试、集成测试等等。 # 1.1 快速测试的基类 测试组件提供了 4 种单元测试的基类,通过继承它们,可以快速的构建单元测试的环境。 基类 作用 BaseMockitoUnitTest (opens new window) 纯 Mockito 的单元测试 BaseDbUnitTest (opens new window) 使用内嵌的 H2 数据库的单元测试 BaseRedisUnitTest (opens new window) 使用内嵌的 Redis 缓存的单元测试 BaseDbAndRedisUnitTest (opens new window) 使用内嵌的 H2 数据库 + Redis 缓存的单元测试 疑问:什么是内嵌的 Redis 缓存? 基于 jedis-mock (opens new window) 开源项目,通过 RedisTestConfiguration (opens new window) 配置类,启动一个 Redis 进程。一般情况下,会使用 16379 端口。 # 1.2 测试工具类 ① RandomUtils (opens new window) 基于 podam (opens new window) 开源项目,实现 Bean 对象的随机生成。 ② AssertUtils (opens new window) 封装 Junit 的 Assert 断言,实现 Bean 对象的断言,支持忽略部分属性。 # 2. BaseDbUnitTest 实战案例 以字典类型模块的 DictTypeServiceImpl (opens new window) 为例子,讲解它的 DictTypeServiceTest (opens new window) 单元测试的编写实现。 # 2.1 引入依赖 在 yudao-module-system-biz 模块中,引入 yudao-spring-boot-starter-test 技术组件。如下所示: <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-test</artifactId> <scope>test</scope></dependency> # 2.2 新建 ut 配置文件 在 test/resources ( opens new window) 目录,新建单元测试的 application-unit-test.yaml ( opens new window) 配置文件,内容如下: # 2.3 添加 H2 SQL 脚本 修改 test/resources/sql ( opens new window) 目录的两个 H2 SQL 脚本: ① 在 create_tables.sql ( opens new window) 文件中,添加 system_dict_type 的 H2 建表语句。SQL 如下: CREATE TABLE IF NOT EXISTS "system_dict_type" ( "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "name" varchar(100) NOT NULL DEFAULT '', "type" varchar(100) NOT NULL DEFAULT '', "status" tinyint NOT NULL DEFAULT '0', "remark" varchar(500) DEFAULT NULL, "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted" bit NOT NULL DEFAULT FALSE, PRIMARY KEY ("id")) COMMENT '字典类型表'; 注意,H2 和 MySQL 的建表语句有区别,需要手动进行转换。如果你不想进行转换,可以使用 [基础设置 -> 代码生成] 菜单的代码生成器功能,如下图所示: ② 在 clean.sql (opens new window) 文件中,添加 system_dict_type 的清空数据的语句。SQL 如下: DELETE FROM "system_dict_type"; 每次单元测试的方法执行完后,会执行 clean.sql 脚本,进行数据的清理,保证每个单元测试的方法的数据隔离性。 # 2.3 新建 DictTypeServiceTest 类 新建 DictTypeServiceTest 测试类,继承 BaseMockitoUnitTest 基类,并完成它的配置。代码如下图所示: 属于自己模块的,使用 Spring 初始化成真实的 Bean,然后通过 @Resource 注入。例如说:dictTypeService、dictTypeMapper 属于别人模块的,使用 Spring @MockBean 注解,模拟 Mock 成一个 Bean 后注入。例如说:dictDataService 疑问:为什么有的进行 Mock,有的不进行 Mock 呢? 单元测试需要避免对外部的依赖,而 dictDataService 是外部依赖,所以需要 Mock 掉。 dictTypeMapper 某种程度来说,也是一种外部依赖,但是通过内嵌的 H2 内存数据库,进行“真实”的数据库操作,反而单元测试的编写效率更高,效果更好,所以不需要 Mock 掉。 另外,[基础设置 -> 代码生成] 菜单的代码生成器功能,已经生成了绝大多数的单元测试的逻辑,这里主要是希望让你了解单元测试的具体使用,所以并没有使用它。如下图所示: # 2.4 新增方法的单测 # 2.5 修改方法的单测 # 2.6 删除方法的单测 # 2.7 单条查询方法的单测 # 2.8 分页查询方法的单测 # 3. BaseMockitoUnitTest 实战案例 一些类由于不依赖 MySQL 和 Redis,可以通过继承 BaseMockitoUnitTest 基类,实现纯 Mockito 的单元测试。例如说 SmsSendServiceTest (opens new window) 单元测试类,代码如下: 具体 SmsSendServiceTest 的每个测试方法,和 DictTypeServiceTest 并没有什么差别,还是 Mock 模拟 + Assert 断言 + Verify 调用,你可以自己花点时间瞅瞅。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 12:11:24 工具类 Util 分布式锁 ← 工具类 Util 分布式锁→"},{"title":"地区 & IP 库","path":"/wiki/YuDaoCloud/后端手册/地区 & IP 库/地区 & IP 库.html","content":"开发指南后端手册 芋道源码 2022-12-29 目录 地区 & IP 库 yudao-spring-boot-starter-biz-ip (opens new window) 业务组件,提供地区 & IP 库的封装。 # 1. 地区 AreaUtils (opens new window) 是地区工具类,可以查询中国的省、市、区县,也可以查询国外的国家。 它的数据来自 Administrative-divisions-of-China (opens new window) 项目,最终整理到项目的 area.csv (opens new window) 文件。每一行的数据,对应 Area (opens new window) 对象。代码所示: public class Area { /** * 编号 */ private Integer id; /** * 名字 */ private String name; /** * 类型 * * 枚举 {@link AreaTypeEnum} * 1 - 国家 * 2 - 省份 * 3 - 城市 * 4 - 地区, 例如说县、镇、区等 */ private Integer type; /** * 父节点 */ private Area parent; /** * 子节点 */ private List<Area> children;} AreaUtils 主要有如下两个方法: // AreaUtils.java/** * 获得指定编号对应的区域 * * @param id 区域编号 * @return 区域 */public static Area getArea(Integer id) { // ... 省略具体实现}/** * 格式化区域 * * 例如说: * 1. id = “静安区”时:上海 上海市 静安区 * 2. id = “上海市”时:上海 上海市 * 3. id = “上海”时:上海 * 4. id = “美国”时:美国 * 当区域在中国时,默认不显示中国 * * @param id 区域编号 * @param separator 分隔符 * @return 格式化后的区域 */public static String format(Integer id, String separator) { // ... 省略具体实现} 具体的使用,可见 AreaUtilsTest (opens new window) 测试类。 另外,管理后台提供了 [系统管理 -> 地区管理] 菜单,可以按照树形结构查看地区列表。如下图所示: 后端代码,对应 AreaController (opens new window) 的 /admin-api/system/area/tree 接口 前端代码,对应 system/area/index.vue (opens new window) 界面 # 2. IP IPUtils (opens new window) 是 IP 工具类,可以查询 IP 对应的城市信息。 它的数据来自 ip2region (opens new window) 项目,最终整理到项目的 ip2region.xdb (opens new window) 文件。 IPUtils 主要有如下两个方法: // IPUtils.java/** * 查询 IP 对应的地区编号 * * @param ip IP 地址,格式为 127.0.0.1 * @return 地区id */public static Integer getAreaId(String ip) { // ... 省略具体实现}/** * 查询 IP 对应的地区 * * @param ip IP 地址,格式为 127.0.0.1 * @return 地区 */public static Area getArea(String ip) { // ... 省略具体实现} 具体的使用,可见 IPUtilsTest (opens new window) 测试类。 另外,管理后台提供了 [系统管理 -> 地区管理] 菜单,也提供了 IP 查询城市的示例。如下图所示: 后端代码,对应 AreaController (opens new window) 的 /admin-api/system/area/get-by-ip 接口 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/27, 21:56:08 验证码 注册中心 Nacos ← 验证码 注册中心 Nacos→"},{"title":"多数据源(读写分离)","path":"/wiki/YuDaoCloud/后端手册/多数据源(读写分离)/多数据源(读写分离).html","content":"开发指南后端手册 芋道源码 2022-04-02 目录 多数据源(读写分离) yudao-spring-boot-starter-mybatis (opens new window) 技术组件,除了提供 MyBatis 数据库操作,还提供了如下 2 种功能: 数据连接池:基于 Alibaba Druid (opens new window) 实现,额外提供监控的能力。 多数据源(读写分离):基于 Dynamic Datasource (opens new window) 实现,支持 Druid 连接池,可集成 Seata (opens new window) 实现分布式事务。 # 1. 数据连接池 友情提示: 如果你未学习过 Druid 数据库连接池,可以后续阅读 《芋道 Spring Boot 数据库连接池入门》 (opens new window) 文章。 <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId></dependency> # 1.1 Druid 监控配置 友情提示:以 yudao-module-system 服务为例子。 在 application-local.yaml ( opens new window) 配置文件中,通过 spring.datasource.druid 配置项,仅仅设置了 Druid 监控相关的配置项目,具体数据库的设置需要使用 Dynamic Datasource 的配置项。如下图所示: # 1.2 Druid 监控界面 ① 访问后端的 /druid/index.html 路径,例如说本地的 http://127.0.0.1:48080/druid/index.html 地址,可以查看到 Druid 监控界面。如下图所示: ② 访问前端的 [基础设施 -> MySQL 监控] 菜单,也可以查看到 Druid 监控界面。如下图所示: 补充说明: 前端 [基础设施 -> MySQL 监控] 菜单,通过 iframe 内嵌后端的 /druid/index.html 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.druid 配置项。 # 2. 多数据源 友情提示: 如果你未学习过多数据源,可以后续阅读 《芋道 Spring Boot 多数据源(读写分离)入门》 ( opens new window) 文章。 <dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId></dependency> # 2.1 多数据源配置 友情提示:以 yudao-module-system 服务为例子。 在 application-local.yaml ( opens new window) 配置文件中,通过 spring.datasource.dynamic 配置项,配置了 Master-Slave 主从两个数据源。如下图所示: # 2.2 数据源切换 # 2.2.1 @Master 注解 在方法上添加 @Master ( opens new window) 注解,使用名字为 master 的数据源,即使用【主】库,一般适合【写】场景。示例如下图: 由于项目的 spring.datasource.dynamic.primary 为 master,默认使用【主】库,所以无需手动添加 @Master 注解。 # 2.2.2 @Slave 注解 在方法上添加 @Slave ( opens new window) 注解,使用名字为 slave 的数据源,即使用【从】库,一般适合【读】场景。示例如下图: # 2.2.3 @DS 注解 在方法上添加 @DS ( opens new window) 注解,使用指定名字的数据源,适合多数据源的情况。示例如下图: # 2.3 分布式事务 在使用 Spring @Transactional 声明的事务中,无法进行数据源的切换,此时有 3 种解决方案: ① 拆分成多个 Spring 事务,每个事务对应一个数据源。如果是【写】场景,可能会存在多数据源的事务不一致的问题。 ② 引入 Seata 框架,提供完整的分布式事务的解决方案,可学习 《芋道 Seata 极简入门 》 ( opens new window) 文章。 ③ 使用 Dynamic Datasource 提供的 @DSTransactional ( opens new window) 注解,支持多数据源的切换,不提供绝对可靠的多数据源的事务一致性(强于 ① 弱于 ②),可学习 《DSTransactional 实现源码分析 》 ( opens new window) 文章。 # 3. 分库分表 建议采用 ShardingSphere 的子项目 Sharding-JDBC 完成分库分表的功能,可阅读 《芋道 Spring Boot 分库分表入门 》 ( opens new window) 文章,学习如何整合进项目。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:24:52 数据库 MyBatis Redis 缓存 ← 数据库 MyBatis Redis 缓存→"},{"title":"工具类 Util","path":"/wiki/YuDaoCloud/后端手册/工具类 Util/工具类 Util.html","content":"开发指南后端手册 芋道源码 2022-04-04 目录 工具类 Util 本小节,介绍项目中使用到的工具类,避免大家重复造轮子。 # 1. Hutool 项目使用 Hutool (opens new window) 作为主工具库。Hutool 是国产的一个 Java 工具包,它可以帮助我们简化每一行代码,减少每一个方法,让 Java 语言也可以“甜甜的”。 yudao-common (opens new window) 模块的 util (opens new window) 包作为辅工具库,以 Utils 结尾,补充 Hutool 缺少的工具能力。 友情提示:常用的工具类,使用 ⭐ 标记,需要的时候可以找找有没对应的工具方法。 作用 Hutool 芋道 Utils 数组工具 ArrayUtil (opens new window) ArrayUtils (opens new window) ⭐ 集合工具 CollUtil (opens new window) CollectionUtils (opens new window) ⭐ Map 工具 MapUtil (opens new window) MapUtils (opens new window) Set 工具 SetUtils (opens new window) List 工具 ListUtil (opens new window) 文件工具 FileUtil (opens new window) FileTypeUtil (opens new window) FileUtils (opens new window) 压缩工具 ZipUtil (opens new window) IoUtils (opens new window) IO 工具 ZipUtil (opens new window) Resource 工具 ResourceUtil (opens new window) JSON 工具 JsonUtils (opens new window) 数字工具 NumberUtil (opens new window) NumberUtils (opens new window) 对象工具 ObjectUtil (opens new window) ObjectUtils (opens new window) 唯一 ID 工具 IdUtil (opens new window) ⭐ 字符串工具 StrUtil (opens new window) StrUtils (opens new window) 时间工具 DateUtil (opens new window) DateUtils (opens new window) 反射工具 ReflectUtil (opens new window) 异常工具 ExceptionUtil (opens new window) 随机工具 RandomUtil (opens new window) RandomUtils (opens new window) URL 工具 URLUtil (opens new window) HttpUtils (opens new window) Servlet 工具 ServletUtils (opens new window) Spring 工具 SpringUtil (opens new window) SpringAopUtils (opens new window) SpringExpressionUtils (opens new window) 分页工具 PageUtils (opens new window) 校验工具 ValidationUtil (opens new window) ValidationUtils (opens new window) 断言工具 Assert (opens new window) AssertUtils (opens new window) 强烈推荐: Guava 是 Google 开源的 Java 常用类库,如果你感兴趣,可以阅读 《Guava 学习笔记》 (opens new window) 文章。 # 2. Lombok Lombok (opens new window) 是一个 Java 工具,通过使用其定义的注解,自动生成常见的冗余代码,提升开发效率。 如果你没有学习过 Lombok,需要阅读下 《芋道 Spring Boot 消除冗余代码 Lombok 入门》 (opens new window) 文章。 在项目的根目录有 lombok.config (opens new window) 全局配置文件,开启链式调用、生成的 toString/hashcode/equals 方法需要调用父方法。如下图所示: # 3. MapStruct 项目使用 MapStruct (opens new window) 实现 VO、DO、DTO 等对象之间的转换。 如果你没有学习过 MapStruct,需要阅读下 《芋道 Spring Boot 对象转换 MapStruct 入门》 (opens new window) 文章。 在每个 yudao-module-xxx-biz 模块的 convert 包下,可以看到各个业务的 Convert 接口,如下图所示: # 4. HTTP 调用 ① 使用 Feign 实现声明式的调用,可参考《芋道 Spring Boot 声明式调用 Feign 入门 》 (opens new window)文章。 ② 使用 Hutool 自带的 HttpUtil (opens new window) 工具类。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 11:00:42 异步任务 单元测试 ← 异步任务 单元测试→"},{"title":"幂等性(防重复提交)","path":"/wiki/YuDaoCloud/后端手册/幂等性(防重复提交)/幂等性(防重复提交).html","content":"开发指南后端手册 芋道源码 2022-04-09 目录 幂等性(防重复提交) yudao-spring-boot-starter-protection (opens new window) 技术组件,由它的 idempotent (opens new window) 包,提供声明式的幂等特性,可防止重复请求。例如说,用户快速的双击了某个按钮,前端没有禁用该按钮,导致发送了两次重复的请求。 // UserController.java@Idempotent(timeout = 10, timeUnit = TimeUnit.SECONDS, message = "正在添加用户中,请勿重复提交")@PostMapping("/user/create")public String createUser(User user){ userService.createUser(user); return "添加成功";} # 1. 实现原理 它的实现原理非常简单,针对相同参数的方法,一段时间内,有且仅能执行一次。执行流程如下: ① 在方法执行前,根据参数对应的 Key 查询是否存在。 如果存在,说明正在执行中,则进行报错。 如果不在 ,则计算参数对应的 Key,存储到 Redis 中,并设置过期时间,即标记正在执行中。 默认参数的 Redis Key 的计算规则由 DefaultIdempotentKeyResolver ( opens new window) 实现,使用 MD5(方法名 + 方法参数),避免 Redis Key 过长。 ② 方法执行完成, 不会主动删除参数对应的 Key。 如果希望会主动删除 Key,可以使用 《开发指南 —— 分布式锁》 提供的 @Lock 来实现幂等性。 🙂 从本质上来说,idempotent 包提供的幂等特性,本质上也是基于 Redis 实现的分布式锁。 ③ 如果方法执行时间较长,超过 Key 的过期时间,则 Redis 会自动删除对应的 Key。因此,需要大概评估下,避免方法的执行时间超过过期时间。 # 2. @Idempotent 注解 @Idempotent ( opens new window) 注解,声明在方法上,表示该方法需要开启幂等性。代码如下: ① 对应的 AOP 切面是 IdempotentAspect ( opens new window) 类,核心就 10 行左右的代码,如下图所示: ② 对应的 Redis Key 的前缀是 idempotent:%s ,可见 IdempotentRedisDAO ( opens new window) 类,如下图所示: # 3. 使用示例 本小节,我们实现 /admin-api/infra/test-demo/get RESTful API 接口的幂等性。 ① 在 pom.xml 文件中,引入 yudao-spring-boot-starter-protection 依赖。 <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-protection</artifactId></dependency> ② 在 /admin-api/infra/test-demo/get RESTful API 接口的对应方法上,添加 @Idempotent 注解。代码如下: // TestDemoController.java@GetMapping("/get")@Idempotent(timeout = 10, message = "重复请求,请稍后重试")public CommonResult<TestDemoRespVO> getTestDemo(@RequestParam("id") Long id) { // ... 省略代码} ③ 调用 /admin-api/infra/test-demo/get RESTful API 接口,执行成功。 ④ 再次调用 /admin-api/infra/test-demo/get RESTful API 接口,被幂等性拦截,执行失败。 { "code": 900, "data": null, "msg": "重复请求,请稍后重试"} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 12:11:24 分布式锁 数据库文档 ← 分布式锁 数据库文档→"},{"title":"异步任务","path":"/wiki/YuDaoCloud/后端手册/异步任务/异步任务.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 异步任务 yudao-spring-boot-starter-job (opens new window) 技术组件,除了提供定时任务的功能,还提供了 Async 异步任务的能力。系统使用异步任务,提升执行效率。例如说: 操作日志模块 (opens new window),异步记录【操作日志】 访问日志模块 (opens new window),异步记录【访问日志】 友情提示: 如果你未学习过 Spring 异步任务,可以后续阅读 《芋道 Spring Boot 异步任务入门 》 (opens new window) 文章。 # 1. Async 配置 在 YudaoAsyncAutoConfiguration (opens new window) 配置类,设置使用 TransmittableThreadLocal (opens new window),解决异步执行时上下文传递的问题。如下图所示: 友情提示: 项目使用到 ThreadLocal 的地方,建议都使用 TransmittableThreadLocal 进行替换。 # 2. 引入依赖 以访问日志模块为例,讲解它如何使用异步任务,实现异步记录【访问日志】的功能。 # 2.1 引入依赖 在 yudao-module-system-infra 模块中,引入 yudao-spring-boot-starter-job 技术组件。如下所示: <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-job</artifactId></dependency> # 2.2 添加 @Async 注解 在 ApiAccessLogServiceImpl ( opens new window) 的 #createApiAccessLogAsync(...) 方法上,添加 @Async 注解,声明它要异步执行。如下图所示: # 2.3 测试调用 随便请求一个 RESTful API 接口,可以看到在异步任务的线程池中,进行了访问日志的记录。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 12:11:24 本地缓存 工具类 Util ← 本地缓存 工具类 Util→"},{"title":"异常处理(错误码)","path":"/wiki/YuDaoCloud/后端手册/异常处理(错误码)/异常处理(错误码).html","content":"开发指南后端手册 芋道源码 2022-03-25 目录 异常处理(错误码) 本章节,将讲解异常相关的统一响应、异常处理、业务异常、错误码这 4 块的内容。 # 1. 统一响应 后端提供 RESTful API 给前端时,需要响应前端 API 调用是否成功: 如果成功,成功的数据是什么。后续,前端会将数据渲染到页面上 如果失败,失败的原因是什么。一般,前端会将原因弹出提示给用户 因此,需要有统一响应,而不能是每个接口定义自己的风格。一般来说,统一响应返回信息如下: 成功时,返回成功的状态码 + 数据 失败时,返回失败的状态码 + 错误提示 在标准的 RESTful API 的定义,是推荐使用 HTTP 响应状态码 (opens new window) 作为状态码。一般来说,我们实践很少这么去做,主要原因如下: 业务返回的错误状态码很多,HTTP 响应状态码无法很好的映射。例如说,活动还未开始、订单已取消等等 学习成本高,开发者对 HTTP 响应状态码不是很了解。例如说,可能只知道 200、403、404、500 几种常见的 # 1.1 CommonResult yudao-cloud (opens new window) 项目在实践时,将状态码放在 Response Body 响应内容中返回。一共有 3 个字段,通过 CommonResult (opens new window) 定义如下: // 成功响应{ code: 0, data: { id: 1, username: "yudaoyuanma" }}// 失败响应{ code: 233666, message: "徐妈太丑了"} 可以增加 success 字段吗? 有些团队在实践时,会增加了 success 字段,通过 true 和 false 表示成功还是失败。 这个看每个团队的习惯吧。艿艿的话,还是偏好基于约定,返回 0 时表示成功。 失败时的 code 字段,使用全局的错误码,稍后在 「4. 错误码」 小节来讲解。 ① 在 RESTful API 成功时,定义 Controller 对应方法的返回类型为 CommonResult,并调用 #success(T data) (opens new window) 方法来返回。代码如下图: CommonResult 的 data 字段是泛型,建议定义对应的 VO 类,而不是使用 Map 类。 ② 在 RESTful API 失败时,通过抛出 Exception 异常,具体在 「2. 异常处理」 小节。 # 1.2 使用 @ControllerAdvice ? 在 Spring MVC 中,可以使用 @ControllerAdvice 注解,通过 Spring AOP 拦截修改 Controller 方法的返回结果,从而实现全局的统一返回。 使用 @ControllerAdvice 注解的实战案例? 如果你感兴趣的话,可以阅读 《芋道 Spring Boot SpringMVC 入门 》 (opens new window) 文章的「4. 全局统一返回 」小节。 为什么项目不采用这种方式呢?主要原因是,这样的方式“破坏”了方法的定义,导致一些隐性的问题。例如说,Swagger 接口定义错误,展示的响应结果不是 CommonResult。 还有个原因,部分 RESTful API 不需要自动包装 CommonResult 结果。例如说,第三方支付回调只需要返回 \"success\" 字符串。 # 2. 异常处理 RESTful API 发生异常时,需要拦截 Exception 异常,转换成统一响应的格式,否则前端无法处理。 # 2.1 Spring MVC 的异常 在 Spring MVC 中,通过 @ControllerAdvice + @ExceptionHandler 注解,声明将指定类型的异常,转换成对应的 CommonResult 响应。实现的代码,可见 GlobalExceptionHandler (opens new window) 类,代码如下: # 2.2 Filter 的异常 在请求被 Spring MVC 处理之前,是先经过 Filter 处理的,此时发生异常时,是无法通过 @ExceptionHandler 注解来处理的。只能通过 try catch 的方式来实现,代码如下: # 3. 业务异常 在 Service 发生业务异常时,如果进行返回呢?例如说,用户名已经存在,商品库存不足等。常用的方案选择,主要有两种: 方案一,使用 CommonResult 统一响应结果,里面有错误码和错误提示,然后进行 return 返回 方案二,使用 ServiceException 统一业务异常,里面有错误码和错误提示,然后进行 throw 抛出 选择方案一 CommonResult 会存在两个问题: 因为 Spring @Transactional 声明式事务,是基于异常进行回滚的,如果使用 CommonResult 返回,则事务回滚会非常麻烦 当调用别的方法时,如果别人返回的是 CommonResult 对象,还需要不断的进行判断,写起来挺麻烦的 因此,项目采用方案二 ServiceException 异常。 # 3.1 ServiceException 定义 ServiceException (opens new window) 异常类,继承 RuntimeException 异常类(非受检),用于定义业务异常。代码如下: 为什么继承 RuntimeException 异常? 大多数业务场景下,我们无需处理 ServiceException 业务异常,而是通过 GlobalExceptionHandler 统一处理,转换成对应的 CommonResult 对象,进而提示给前端即可。 如果真的需要处理 ServiceException 时,通过 try catch 的方式进行主动捕获。 # 3.2 ServiceExceptionUtil 在 Service 需抛出业务异常时,通过调用 ServiceExceptionUtil (opens new window) 的 #exception(ErrorCode errorCode, Object... params) 方法来构建 ServiceException 异常,然后使用 throw 进行抛出。代码如下: // ServiceExceptionUtil.javapublic static ServiceException exception(ErrorCode errorCode) { /** 省略参数 */ }public static ServiceException exception(ErrorCode errorCode, Object... params) { /** 省略参数 */ } 为什么使用 ServiceExceptionUtil 来构建 ServiceException 异常? 错误提示的内容,支持使用管理后台进行动态配置,所以通过 ServiceExceptionUtil 获取内容的配置与格式化。 # 4. 错误码 错误码,对应 ErrorCode (opens new window) 类,枚举项目中的错误,全局唯一,方便定位是谁的错、错在哪。 # 4.1 错误码分类 错误码分成两类:全局的系统错误码、模块的业务错误码。 # 4.1.1 系统错误码 全局的系统错误码,使用 0-999 错误码段,和 HTTP 响应状态码 (opens new window) 对应。虽然说,HTTP 响应状态码作为业务使用表达能力偏弱,但是使用在系统层面还是非常不错的。 系统错误码定义在 GlobalErrorCodeConstants (opens new window) 类,代码如下: # 4.1.2 业务错误码 模块的业务错误码,按照模块分配错误码的区间,避免模块之间的错误码冲突。 ① 业务错误码一共 10 位,分成 4 段,在 ServiceErrorCodeRange (opens new window) 分配,规则与代码如下图: ② 每个业务模块,定义自己的 ErrorCodeConstants 错误码枚举类。以 yudao-module-system 模块举例子,代码如下: # 4.2 错误码管理 在管理后台的 [系统管理 -> 错误码管理] 菜单,可以进行错误码的管理。 启动中的项目会每 60 秒,加载最新的错误码配置。所以,我们在修改完错误码的提示后,无需重启项目。 # 4.2.1 手动添加 点击 [新增] 按钮,进行错误码的手动添加。如下图所示: # 4.2.2 自动添加 通过 yudao.error-code.constants-class-list 配置项,设置需要自动添加的 ErrorCodeConstants 错误码枚举类。如下图所示: 项目启动时,会自动扫描对应的 ErrorCodeConstants 中的错误码,自动添加或修改错误码的配置。 注意,自动添加的错误码的类型为【自动生成】,一旦在管理后台手动 [编辑] 后,该错误码就不再支持自动修改。 自动添加是如何实现的? 参见 system/framework/errorcode (opens new window) 包的代码。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/30, 21:15:08 SaaS 多租户【数据库隔离】 参数校验 ← SaaS 多租户【数据库隔离】 参数校验→"},{"title":"敏感词","path":"/wiki/YuDaoCloud/后端手册/敏感词/敏感词.html","content":"开发指南后端手册 芋道源码 2022-12-31 目录 敏感词 本章节,介绍项目的敏感词功能,可用于文本检测,高效过滤色情、广告、敏感、暴恐等违规内容。例如说,用户昵称、评论、私信等文本内容,都可以使用敏感词功能进行过滤。 # 1. 实现原理 敏感词采用 前缀树 (opens new window) 算法,,核心代码见 SimpleTrie (opens new window) 类。 # 2. 使用教程 对应的管理后台,可以在 [系统管理 -> 敏感词] 菜单,进行敏感词的管理。如下图所示: 前端实现:sensitiveWord/index.vue (opens new window) 后端实现:SensitiveWordController (opens new window) # 2.1 添加敏感词 标签:用于敏感词分组,不同的场景会需要使用不同的敏感词,通过标签进行分组。 添加完敏感词后,刷新下界面。 # 2.2 测试敏感词 ① 输入检测文本为“你是白痴么?”,选择标签为“测试”,检测到有敏感词: ② 选择标签为“蔬菜”,检测到米有敏感词: # 3. 敏感词的使用 SensitiveWordApi (opens new window) 提供了敏感词的 API 接口,可以在任意地方使用。方法如下: public interface SensitiveWordApi { /** * 获得文本所包含的不合法的敏感词数组 * * @param text 文本 * @param tags 标签数组 * @return 不合法的敏感词数组 */ List<String> validateText(String text, List<String> tags); /** * 判断文本是否包含敏感词 * * @param text 文本 * @param tags 表述数组 * @return 是否包含 */ boolean isTextValid(String text, List<String> tags);} 使用步骤如下: ① 在需要使用的 yudao-module-*-biz 模块的 pom.xml 中,引入 yudao-module-system-api 依赖。代码如下: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 在该 yudao-module-*-biz 模块的 RpcConfiguration (opens new window) 配置类,注入 SensitiveWordApi 接口。代码如下: @Configuration(proxyBeanMethods = false)@EnableFeignClients(clients = {SensitiveWordApi.class.class})public class RpcConfiguration {} ③ 注入 SensitiveWordApi Bean,调用对应的方法即可。例如说: @Servicepublic class DemoService { @Resource private SensitiveWordApi sensitiveWordApi; public void demo() { sensitiveWordApi.validateText("你是白痴吗", Collections.singletonList("测试")); sensitiveWordApi.isTextValid("你是白痴吗", Collections.singletonList("蔬菜")); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/27, 21:56:08 数据脱敏 验证码 ← 数据脱敏 验证码→"},{"title":"数据库文档","path":"/wiki/YuDaoCloud/后端手册/数据库文档/数据库文档.html","content":"None"},{"title":"数据库 MyBatis","path":"/wiki/YuDaoCloud/后端手册/数据库 MyBatis/数据库 MyBatis.html","content":"开发指南后端手册 芋道源码 2022-04-01 目录 数据库 MyBatis yudao-spring-boot-starter-mybatis (opens new window) 技术组件,基于 MyBatis Plus 实现数据库的操作。如果你没有学习过 MyBatis Plus,建议先阅读 《芋道 Spring Boot MyBatis 入门 》 (opens new window) 文章。 友情提示 MyBatis 是最容易读懂的 Java 框架之一,感兴趣的话,可以看看艿艿写的 《芋道 MyBatis 源码解析》 (opens new window) 系列,已经有 18000 人学习过! # 1. 实体类 BaseDO (opens new window) 是所有数据库实体的父类,代码如下: @Datapublic abstract class BaseDO implements Serializable { /** * 创建时间 */ @TableField(fill = FieldFill.INSERT) private Date createTime; /** * 最后更新时间 */ @TableField(fill = FieldFill.INSERT_UPDATE) private Date updateTime; /** * 创建者,目前使用 AdminUserDO / MemberUserDO 的 id 编号 * * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 */ @TableField(fill = FieldFill.INSERT) private String creator; /** * 更新者,目前使用 AdminUserDO / MemberUserDO 的 id 编号 * * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 */ @TableField(fill = FieldFill.INSERT_UPDATE) private String updater; /** * 是否删除 */ @TableLogic private Boolean deleted;} createTime + creator 字段,创建人相关信息。 updater + updateTime 字段,创建人相关信息。 deleted 字段,逻辑删除。 对应的 SQL 字段如下: `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', # 1.1 主键编号 id 主键编号,推荐使用 Long 型自增,原因是: 自增,保证数据库是按顺序写入,性能更加优秀。 Long 型,避免未来业务增长,超过 Int 范围。 对应的 SQL 字段如下: `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', 项目的 id 默认采用数据库自增的策略,如果希望使用 Snowflake 雪花算法,可以修改 application.yaml 配置文件,将配置项 mybatis-plus.global-config.db-config.id-type 修改为 ASSIGN_ID。如下图所示: # 1.2 逻辑删除 所有表通过 deleted 字段来实现逻辑删除,值为 0 表示未删除,值为 1 表示已删除,可见 application.yaml 配置文件的 logic-delete-value 和 logic-not-delete-value 配置项。如下图所示: ① 所有 SELECT 查询,都会自动拼接 WHERE deleted = 0 查询条件,过滤已经删除的记录。如果被删除的记录,只能通过在 XML 或者 @SELECT 来手写 SQL 语句。例如说: ② 建立唯一索引时,需要额外增加 delete_time 字段,添加到唯一索引字段中,避免唯一索引冲突。例如说,system_users 使用 username 作为唯一索引: 未添加前:先逻辑删除了一条 username = yudao 的记录,然后又插入了一条 username = yudao 的记录时,会报索引冲突的异常。 已添加后:先逻辑删除了一条 username = yudao 的记录并更新 delete_time 为当前时间,然后又插入一条 username = yudao 并且 delete_time 为 0 的记录,不会导致唯一索引冲突。 # 1.3 自动填充 DefaultDBFieldHandler (opens new window) 基于 MyBatis 自动填充机制,实现 BaseDO 通用字段的自动设置。代码如下如: # 1.4 “复杂”字段类型 MyBatis Plus 提供 TypeHandler 字段类型处理器,用于 JavaType 与 JdbcType 之间的转换。示例如下: 常用的字段类型处理器有: JacksonTypeHandler (opens new window):通用的 Jackson 实现 JSON 字段类型处理器。 JsonLongSetTypeHandler (opens new window):针对 Set<Long> 的 Jackson 实现 JSON 字段类型处理器。 另外,如果你后续要拓展自定义的 TypeHandler 实现,可以添加到 cn.iocoder.yudao.framework.mybatis.core.type (opens new window) 包下。 注意事项: 使用 TypeHandler 时,需要设置实体的 @TableName 注解的 @autoResultMap = true。 # 2. 编码规范 ① 数据库实体类放在 dal.dataobject 包下,以 DO 结尾;数据库访问类放在 dal.mysql 包下,以 Mapper 结尾。如下图所示: ② 数据库实体类的注释要完整,特别是哪些字段是关联(外键)、枚举、冗余等等。例如说: ③ 禁止在 Controller、Service 中,直接进行 MyBatis Plus 操作。原因是:大量 MyBatis 操作散落在 Service 中,会导致 Service 的代码越来乱,无法聚焦业务逻辑。 示例 错误 正确 并且,通过只允许将 MyBatis Plus 操作编写 Mapper 层,更好的实现 SELECT 查询的复用,而不是 Service 会存在很多相同且重复的 SELECT 查询的逻辑。 ④ Mapper 的 SELECT 查询方法的命名,采用 Spring Data 的 \"Query methods\" (opens new window) 策略,方法名使用 selectBy查询条件 规则。例如说: ⑤ 优先使用 LambdaQueryWrapper 条件构造器,使用方法获得字段名,避免手写 \"字段\" 可能写错的情况。例如说: ⑥ 简单的单表查询,优先在 Mapper 中通过 default 方法实现。例如说: # 3. CRUD 接口 BaseMapperX (opens new window) 接口,继承 MyBatis Plus 的 BaseMapper 接口,提供更强的 CRUD 操作能力。 # 3.1 selectOne #selectOne(...) (opens new window) 方法,使用指定条件,查询单条记录。示例如下: # 3.2 selectCount #selectCount(...) (opens new window) 方法,使用指定条件,查询记录的数量。示例如下: # 3.3 selectList #selectList(...) (opens new window) 方法,使用指定条件,查询多条记录。示例如下: # 3.4 selectPage 针对 MyBatis Plus 分页查询的二次分装,在 BaseMapperX (opens new window) 中实现,目的是使用项目自己的分页封装: 【入参】查询前,将项目的分页参数 PageParam (opens new window),转换成 MyBatis Plus 的 IPage 对象。 【出参】查询后,将 MyBatis Plus 的分页结果 IPage,转换成项目的分页结果 PageResult (opens new window)。代码如下图: 具体的使用示例,可见 TenantMapper (opens new window) 类中,定义 selectPage 查询方法。代码如下: @Mapperpublic interface TenantMapper extends BaseMapperX<TenantDO> { default PageResult<TenantDO> selectPage(TenantPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX<TenantDO>() .likeIfPresent(TenantDO::getName, reqVO.getName()) // 如果 name 不为空,则进行 like 查询 .likeIfPresent(TenantDO::getContactName, reqVO.getContactName()) .likeIfPresent(TenantDO::getContactMobile, reqVO.getContactMobile()) .eqIfPresent(TenantDO::getStatus, reqVO.getStatus()) // 如果 status 不为空,则进行 = 查询 .betweenIfPresent(TenantDO::getCreateTime, reqVO.getBeginCreateTime(), reqVO.getEndCreateTime()) // 如果 create 不为空,则进行 between 查询 .orderByDesc(TenantDO::getId)); // 按照 id 倒序 }} 完整实战,可见 《开发指南 —— 分页实现》 文档。 # 3.5 insertBatch #insertBatch(...) (opens new window) 方法,遍历数组,逐条插入数据库中,适合少量数据插入,或者对性能要求不高的场景。 示例如下: 为什么不使用 insertBatchSomeColumn 批量插入? 只支持 MySQL 数据库。其它 Oracle 等数据库使用会报错,可见 InsertBatchSomeColumn (opens new window) 说明。 未支持多租户。插入数据库时,多租户字段不会进行自动赋值。 # 4. 批量插入 绝大多数场景下,推荐使用 MyBatis Plus 提供的 IService 的 #saveBatch() (opens new window) 方法。示例 PermissionServiceImpl (opens new window) 如下: # 5. 条件构造器 继承 MyBatis Plus 的条件构造器,拓展了 LambdaQueryWrapperX (opens new window) 和 QueryWrapperX (opens new window) 类,主要是增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。例如说: 具体的使用示例如下: # 6. Mapper XML 默认配置下,MyBatis Mapper XML 需要写在各 yudao-module-xxx-biz 模块的 resources/mapper 目录下。示例 TestDemoMapper.xml (opens new window) 如下: 尽量避免数据库的连表(多表)查询,而是采用多次查询,Java 内存拼接的方式替代。例如说: # 7. 字段加密 EncryptTypeHandler (opens new window),基于 Hutool AES (opens new window) 实现字段的解密与解密。 例如说,数据源配置 (opens new window)的 password 密码需要实现加密存储,则只需要在该字段上添加 EncryptTypeHandler 处理器。示例代码如下: @TableName(value = "infra_data_source_config", autoResultMap = true) // ① 添加 autoResultMap = truepublic class DataSourceConfigDO extends BaseDO { // ... 省略其它字段 /** * 密码 */ @TableField(typeHandler = EncryptTypeHandler.class) // ② 添加 EncryptTypeHandler 处理器 private String password;} 另外,在 application.yaml 配置文件中,可使用 mybatis-plus.encryptor.password 设置加密密钥。 字段加密后,只允许使用精准匹配,无法使用模糊匹配。示例代码如下: @Test // 测试使用 password 查询,可以查询到数据public void testSelectPassword() { // mock 数据 DataSourceConfigDO dbDataSourceConfig = randomPojo(DataSourceConfigDO.class); dataSourceConfigMapper.insert(dbDataSourceConfig);// @Sql: 先插入出一条存在的数据 // 调用 DataSourceConfigDO result = dataSourceConfigMapper.selectOne(DataSourceConfigDO::getPassword, EncryptTypeHandler.encrypt(dbDataSourceConfig.getPassword())); // 重点:需要使用 EncryptTypeHandler 去加密查询字段!!!} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/30, 21:15:08 系统日志 多数据源(读写分离) ← 系统日志 多数据源(读写分离)→"},{"title":"数据权限","path":"/wiki/YuDaoCloud/后端手册/数据权限/数据权限.html","content":"开发指南后端手册 芋道源码 2022-03-07 目录 数据权限 数据权限,实现指定用户可以操作指定范围的数据。例如说,针对员工信息的数据权限: 用户 数据范围 普通员工 自己 部门领导 所属部门的所有员工 HR 小姐姐 整个公司的所有员工 上述的这个示例,使用硬编码是可以实现的,并且也非常简单。但是,在业务快速迭代的过程中,类似这种数据需求会越来越多,如果全部采用硬编码的方式,无疑会给我们带来非常大的开发与维护成本。 因此,项目提供 yudao-spring-boot-starter-biz-data-permission (opens new window) 技术组件,只需要少量的编码,无需入侵到业务代码,即可实现数据权限。 友情提示:数据权限是否支持指定用户只能查看数据的某些字段? 不支持。权限可以分成三类:功能权限、数据权限、字段权限。 字段权限的控制,不属于数据权限,而是属于字段权限,会在未来提供,敬请期待。 # 1. 实现原理 yudao-spring-boot-starter-biz-data-permission 技术组件的实现原理非常简单,每次对数据库操作时,他会自动拼接 WHERE data_column = ? 条件来进行数据的过滤。 例如说,查看员工信息的功能,对应 SQL 是 SELECT * FROM system_users,那么拼接后的 SQL 结果会是: 用户 数据范围 SQL 普通员工 自己 SELECT * FROM system_users WHERE id = 自己 部门领导 所属部门的所有员工 SELECT * FROM system_users WHERE dept_id = 自己的部门 HR 小姐姐 整个公司的所有员工 SELECT * FROM system_users 无需拼接 明白了实现原理之后,想要进一步加入理解,后续可以找时间 Debug 调试下 DataPermissionDatabaseInterceptor (opens new window) 类的这三个方法: #processSelect(...) 方法:处理 SELECT 语句的 WHERE 条件。 #processUpdate(...) 方法:处理 UPDATE 语句的 WHERE 条件。 #processDelete(...) 方法:处理 DELETE 语句的 WHERE 条件。 # 2. 基于部门的数据权限 项目内置了基于部门的数据权限,支持 5 种数据范围: 全部数据权限:无数据权限的限制。 指定部门数据权限:根据实际需要,设置可操作的部门。 本部门数据权限:只能操作用户所在的部门。 本部门及以下数据权限:在【本部门数据权限】的基础上,额外可操作子部门。 仅本人数据权限:相对特殊,只能操作自己的数据。 # 2.1 后台配置 可通过管理后台的 [系统管理 -> 角色管理] 菜单,设置用户角色的数据权限。 实现代码? 可见 DeptDataPermissionRule (opens new window) 数据权限规则。 # 2.2 字段配置 每个 Maven Module, 通过自定义 DeptDataPermissionRuleCustomizer (opens new window) Bean,配置哪些表的哪些字段,进行数据权限的过滤。以 yudao-module-system 模块来举例子,代码如下: @Configuration(proxyBeanMethods = false)public class DataPermissionConfiguration { @Bean public DeptDataPermissionRuleCustomizer sysDeptDataPermissionRuleCustomizer() { return rule -> { // dept 基于部门的数据权限 rule.addDeptColumn(AdminUserDO.class); // WHERE dept_id = ? rule.addDeptColumn(DeptDO.class, "id"); // WHERE id = ? // user 基于用户的数据权限 rule.addUserColumn(AdminUserDO.class, "id"); // WHERE id = ?// rule.addUserColumn(OrderDO.class); // WHERE user_id = ? }; }} 注意,数据库的表字段必须添加: 基于【部门】过滤数据权限的表,需要添加部门编号字段,例如说 dept_id 字段。 基于【用户】过滤数据权限的表,需要添加部门用户字段,例如说 user_id 字段。 # 3. @DataPermission 注解 @DataPermission (opens new window) 数据权限注解,可声明在类或者方法上,配置使用的数据权限规则。 ① enable 属性:当前类或方法是否开启数据权限,默认是 true 开启状态,可设置 false 禁用状态。 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 也就是说,数据权限默认是开启的,无需添加 @DataPermission 注解 使用示例如下,可见 UserProfileController (opens new window) 类: // UserProfileController.java@GetMapping("/get")@Operation(summary = "获得登录用户信息")@DataPermission(enable = false) // 关闭数据权限,避免只查看自己时,查询不到部门。public CommonResult<UserProfileRespVO> profile() { // .. 省略代码 if (user.getDeptId() != null) { DeptDO dept = deptService.getDept(user.getDeptId()); resp.setDept(UserConvert.INSTANCE.convert02(dept)); } // .. 省略代码} ② includeRules 属性,配置生效的 DataPermissionRule (opens new window) 数据权限规则。例如说,项目里有 10 种 DataPermissionRule 规则,某个方法只想其中的 1 种生效,则可以使用该属性。 ③ excludeRules 属性,配置排除的 DataPermissionRule (opens new window) 数据权限规则。例如说,项目里有 10 种 DataPermissionRule 规则,某个方法不想其中的 1 种生效,则可以使用该属性。 # 4. 自定义的数据权限规则 如果想要自定义数据权限规则,只需要实现 DataPermissionRule (opens new window) 数据权限规则接口,并声明成 Spring Bean 即可。需要实现的只有两个方法: public interface DataPermissionRule { /** * 返回需要生效的表名数组 * 为什么需要该方法?Data Permission 数组基于 SQL 重写,通过 Where 返回只有权限的数据 * * 如果需要基于实体名获得表名,可调用 {@link TableInfoHelper#getTableInfo(Class)} 获得 * * @return 表名数组 */ Set<String> getTableNames(); /** * 根据表名和别名,生成对应的 WHERE / OR 过滤条件 * * @param tableName 表名 * @param tableAlias 别名,可能为空 * @return 过滤条件 Expression 表达式 */ Expression getExpression(String tableName, Alias tableAlias);} #getTableNames() 方法:哪些数据库表,需要使用该数据权限规则。 #getExpression(...) 方法:当操作这些数据库表,需要额外拼接怎么样的 WHERE 条件。 下面,艿艿带你写个自定义数据权限规则的示例,它的数据权限规则是: 针对 system_dict_type 表,它的创建人 creator 要是当前用户。 针对 system_post 表,它的更新人 updater 要是当前用户。 具体实现代码如下: package cn.iocoder.yudao.module.system.framework.datapermission;import cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRule;import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;import com.google.common.collect.Sets;import net.sf.jsqlparser.expression.Alias;import net.sf.jsqlparser.expression.Expression;import net.sf.jsqlparser.expression.LongValue;import net.sf.jsqlparser.expression.operators.relational.EqualsTo;import org.springframework.stereotype.Component;import java.util.Set;@Component // 声明为 Spring Bean,保证被 yudao-spring-boot-starter-biz-data-permission 组件扫描到public class DemoDataPermissionRule implements DataPermissionRule { @Override public Set<String> getTableNames() { return Sets.newHashSet("system_dict_type", "system_post"); } @Override public Expression getExpression(String tableName, Alias tableAlias) { Long userId = SecurityFrameworkUtils.getLoginUserId(); assert userId != null; switch (tableName) { case "system_dict_type": return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, "creator"), new LongValue(userId)); case "system_post": return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, "updater"), new LongValue(userId)); default: return null; } }} ① 启动前端 + 后端项目。 ② 访问 [系统管理 -> 字典管理] 菜单,查看 IDEA 控制台,可以看到 system_dict_type 表的查询自动拼接了 AND creator = 1 的查询条件。 ② 访问 [系统管理 -> 岗位管理] 菜单,查看 IDEA 控制台,可以看到 system_post 表的查询自动拼接了 AND updater = 1 的查询条件。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:33 功能权限 用户体系 ← 功能权限 用户体系→"},{"title":"数据脱敏","path":"/wiki/YuDaoCloud/后端手册/数据脱敏/数据脱敏.html","content":"开发指南后端手册 芋道源码 2023-01-21 目录 数据脱敏 接口在返回一些敏感或隐私数据时,是需要进行脱敏处理,通常的手段是使用 * 隐藏一部分数据。例如说: 类型 原始数据 脱敏数据 手机 13248765917 132****5917 身份证 530321199204074611 530321**********11 银行卡 9988002866797031 998800********31 # 1. 脱敏组件 yudao-spring-boot-starter-desensitize (opens new window) 基于 Jackson 拓展,只需要在字段上添加脱敏注解,即可实现对该字段进行脱敏。 使用步骤如下: ① 在 pom.xml 引入该依赖,如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-desensitize</artifactId></dependency> ② 在字段上添加脱敏注解。如下所示: @Datapublic static class DesensitizeDemo { @MobileDesensitize // 手机号的脱敏注解 private String phoneNumber;} # 2. 内置脱敏注解 根据不同的脱敏处理方式,项目内置了两类脱敏注解:正则脱敏、滑块脱敏。 # 2.1 regex 正则脱敏 # 2.1.1 @RegexDesensitize 注解 正则脱敏注解 @RegexDesensitize ( opens new window):根据正则表达式,将原始数据进行替换处理。 public @interface RegexDesensitize { /** * 匹配的正则表达式(默认匹配所有) */ String regex() default "^[\\\\s\\\\S]*$"; /** * 替换规则,会将匹配到的字符串全部替换成 replacer */ String replacer() default "******";} 例如说:regex=123; replacer=****** 表示将 123 替换为 ****** 原始字符串 123456789 脱敏后字符串 ******456789 # 2.1.2 其它正则脱敏注解 项目内置了其它基于正则脱敏的常用注解,无需手动填写 regex、replacer 属性,更加方便。例如说: @Datapublic static class DesensitizeDemo { @EmailDesensitize private String email;} 所有注解如下: 注解 原始数据 脱敏数据 @EmailDesensitize (opens new window) example@gmail.com e****@gmail.com # 2.2 slider 滑块脱敏 # 2.2.1 @SliderDesensitize 注解 滑块脱敏注解 @SliderDesensitize (opens new window):根据设置的左右明文字符长度,中间部分全部替换为 *。 例如说:prefixKeep=3; suffixKeep=4; replacer=* 表示前 3 后 4 保持明文,中间都替换成 * 原始字符串 13248765917 脱敏后字符串 132****5917 # 2.2.2 其它滑块脱敏注解 项目内置了其它基于滑块脱敏的常用注解,无需手动填写 prefixKeep、suffixKeep、replacer 属性,更加方便。例如说: @Datapublic static class DesensitizeDemo { @MobileDesensitize private String mobile;} 所有注解如下: 注解 原始数据 脱敏数据 @MobileDesensitize (opens new window) 13248765917 132****5917 @FixedPhoneDesensitize (opens new window) 01086551122 0108*****22 @BankCardDesensitize (opens new window) 9988002866797031 998800********31 @PasswordDesensitize (opens new window) 123456 ****** @CarLicenseDesensitize (opens new window) 粤A66666 粤A6***6 @ChineseNameDesensitize (opens new window) 刘子豪 刘** @IdCardDesensitize (opens new window) 530321199204074611 530321**********11 # 3. 自定义脱敏注解 如果内置的注解无法满足你的需求,只需要自定义一个脱敏注解,并实现它的脱敏处理器即可。 例如说,我们要实现一个新的脱敏处理方法,将编号使用 MD5 或 SHA256 计算后返回。步骤如下: ① 创建 @DigestDesensitize 注解,使用 @DesensitizeBy (opens new window) 标记它使用的处理器。代码如下: import cn.iocoder.yudao.framework.desensitize.core.base.annotation.DesensitizeBy;import cn.iocoder.yudao.framework.desensitize.core.handler.DigestHandler;import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;import java.lang.annotation.*;@Documented@Target({ElementType.FIELD})@Retention(RetentionPolicy.RUNTIME)@JacksonAnnotationsInside@DesensitizeBy(handler = DigestHandler.class) // 使用 @DesensitizeBy 设置它的处理器public @interface DigestDesensitize { /** * 摘要算法,例如说:MD5、SHA256 */ String algorithm() default "md5";} ② 创建 DigestHandler 类,实现 DigestHandler (opens new window) 接口,将编号使用 MD5 或 SHA256 处理。代码如下: import cn.hutool.crypto.digest.DigestUtil;import cn.iocoder.yudao.framework.desensitize.core.annotation.DigestDesensitize;import cn.iocoder.yudao.framework.desensitize.core.base.handler.DesensitizationHandler;public class DigestHandler implements DesensitizationHandler<DigestDesensitize> { @Override public String desensitize(String origin, DigestDesensitize annotation) { String algorithm = annotation.algorithm(); return DigestUtil.digester(algorithm).digestHex(origin); }} 友情提示: ① 如果自定义的是基于正则脱敏的注解,可选择继承 AbstractRegexDesensitizationHandler (opens new window) 处理器。 ① 如果自定义的是基于滑块脱敏的注解,可选择继承 AbstractSliderDesensitizationHandler (opens new window) 处理器。 ③ 在需要使用的字段上,添加 @DigestDesensitize 注解。示例代码如下: @Datapublic static class DesensitizeDemo { @DigestDesensitize private String email;} 完事~ # 4. 脱敏工具类 Hutool 提供了 DesensitizedUtil (opens new window) 脱敏工具类,支持用户 ID、 中文名、身份证、座机号、手机号、 地址、电子邮件、 密码、车牌、银行卡号的脱敏处理。 使用方式,代码如下: DesensitizedUtil.desensitized("100", DesensitizedUtils.DesensitizedType.USER_ID)) = "0"DesensitizedUtil.desensitized("段正淳", DesensitizedUtils.DesensitizedType.CHINESE_NAME)) = "段**"DesensitizedUtil.desensitized("51343620000320711X", DesensitizedUtils.DesensitizedType.ID_CARD)) = "5***************1X"DesensitizedUtil.desensitized("09157518479", DesensitizedUtils.DesensitizedType.FIXED_PHONE)) = "0915*****79"DesensitizedUtil.desensitized("18049531999", DesensitizedUtils.DesensitizedType.MOBILE_PHONE)) = "180****1999"DesensitizedUtil.desensitized("北京市海淀区马连洼街道289号", DesensitizedUtils.DesensitizedType.ADDRESS)) = "北京市海淀区马********"DesensitizedUtil.desensitized("duandazhi-jack@gmail.com.cn", DesensitizedUtils.DesensitizedType.EMAIL)) = "d*************@gmail.com.cn"DesensitizedUtil.desensitized("1234567890", DesensitizedUtils.DesensitizedType.PASSWORD)) = "**********"DesensitizedUtil.desensitized("苏D40000", DesensitizedUtils.DesensitizedType.CAR_LICENSE)) = "苏D4***0"DesensitizedUtil.desensitized("11011111222233333256", DesensitizedUtils.DesensitizedType.BANK_CARD)) = "1101 **** **** **** 3256" 适合场景,逻辑里需要直接对某个变量进行脱敏处理,然后打印 logger 日志,或者存储到数据库中。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/27, 21:56:08 站内信配置 敏感词 ← 站内信配置 敏感词→"},{"title":"新建服务","path":"/wiki/YuDaoCloud/后端手册/新建服务/新建服务.html","content":"开发指南后端手册 芋道源码 2022-03-02 目录 新建服务 本章节,将介绍如何新建名字为 yudao-module-demo 的示例服务,并添加 RESTful API 接口。 虽然内容看起来比较长,是因为艿艿写的比较详细,大量截图,保姆级教程!其实只有 6 个步骤,保持耐心,跟着艿艿一点点来。🙂 完成之后,你会对整个 项目结构 有更充分的了解。 # 👍 相关视频教程 从零开始 06:如何 5 分钟,创建一个新模块? (opens new window) 【该视频是 Boot 单体版,Cloud 待录制】 # 1. 新建 demo 模块 ① 选择 File -> New -> Module 菜单,如下图所示: ② 选择 Maven 类型,选择父模块为 yudao,输入名字为 yudao-module-demo,并点击 Create 按钮,如下图所示: ③ 打开 yudao-module-demo 模块,删除 src 文件,如下图所示: ④ 打开 yudao-module-demo 模块的 pom.xml 文件,修改内容如下: 提示 <!-- --> 部分,只是注释,不需要写到 XML 中。 <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao</artifactId> <groupId>cn.iocoder.cloud</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>yudao-module-demo</artifactId> <packaging>pom</packaging> <!-- 2. 新增 packaging 为 pom --> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块,主要实现 XXX、YYY、ZZZ 等功能。 </description></project> # 2. 新建 demo-api 子模块 ① 新建 yudao-module-demo-api 子模块,整个过程和“新建 demo 模块”是一致的,如下图所示: ② 打开 yudao-module-demo-api 模块的 pom.xml 文件,修改内容如下: <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao-module-demo</artifactId> <groupId>cn.iocoder.cloud</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>yudao-module-demo-api</artifactId> <packaging>jar</packaging> <!-- 2. 新增 packaging 为 jar --> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块 API,暴露给其它模块调用 </description> <dependencies> <!-- 5. 新增 yudao-common 依赖 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-common</artifactId> </dependency> </dependencies></project> ③ 【可选】新建 cn.iocoder.yudao.module.demo 基础包,其中 demo 为模块名。之后,新建 api 和 enums 包。如下图所示: # 3. 新建 demo-biz 子模块 ① 新建 yudao-module-demo-biz 子模块,整个过程和“新建 demo 模块”也是一致的,如下图所示: ② 打开 yudao-module-demo-biz 模块的 pom.xml 文件,修改成内容如下: <?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>yudao-module-demo</artifactId> <groupId>cn.iocoder.cloud</groupId> <version>${revision}</version> <!-- 1. 修改 version 为 ${revision} --> </parent> <modelVersion>4.0.0</modelVersion> <packaging>jar</packaging> <!-- 2. 新增 packaging 为 jar --> <artifactId>yudao-module-demo-biz</artifactId> <name>${project.artifactId}</name> <!-- 3. 新增 name 为 ${project.artifactId} --> <description> <!-- 4. 新增 description 为该模块的描述 --> demo 模块,主要实现 XXX、YYY、ZZZ 等功能。 </description> <dependencies> <!-- 5. 新增依赖,这里引入的都是比较常用的业务组件、技术组件 --> <!-- Spring Cloud 基础 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-env</artifactId> </dependency> <!-- 依赖服务 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-module-infra-api</artifactId> <version>${revision}</version> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-module-demo-api</artifactId> <version>${revision}</version> </dependency> <!-- 业务组件 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-banner</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-biz-operatelog</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-biz-dict</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-biz-data-permission</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-biz-tenant</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-biz-error-code</artifactId> </dependency> <!-- Web 相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-security</artifactId> </dependency> <!-- DB 相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-mybatis</artifactId> </dependency> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-redis</artifactId> </dependency> <!-- RPC 远程调用相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-rpc</artifactId> </dependency> <!-- Registry 注册中心相关 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <!-- Config 配置中心相关 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> </dependency> <!-- Job 定时任务相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-job</artifactId> </dependency> <!-- 消息队列相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-mq</artifactId> </dependency> <!-- Test 测试相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-test</artifactId> </dependency> <!-- 工具类相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-excel</artifactId> </dependency> <!-- 监控相关 --> <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-monitor</artifactId> </dependency> </dependencies> <build> <!-- 设置构建的 jar 包名 --> <finalName>${project.artifactId}</finalName> <plugins> <!-- 打包 --> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring.boot.version}</version> <configuration> <fork>true</fork> </configuration> <executions> <execution> <goals> <goal>repackage</goal> <!-- 将引入的 jar 打入其中 --> </goals> </execution> </executions> </plugin> </plugins> </build></project> ③ 【必选】新建 cn.iocoder.yudao.module.demo 基础包,其中 demo 为模块名。之后,新建 controller.admin 和 controller.user 等包。如下图所示: 其中 SecurityConfiguration 的 Java 代码如下: package cn.iocoder.yudao.module.demo.framework.security.config;import cn.iocoder.yudao.framework.security.config.AuthorizeRequestsCustomizer;import cn.iocoder.yudao.module.system.enums.ApiConstants;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;/** * Demo 模块的 Security 配置 */@Configuration(proxyBeanMethods = false)public class SecurityConfiguration { @Bean public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() { return new AuthorizeRequestsCustomizer() { @Override public void customize(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) { // Swagger 接口文档 registry.antMatchers("/v3/api-docs/**").permitAll() // 元数据 .antMatchers("/swagger-ui.html").permitAll(); // Swagger UI // Druid 监控 registry.antMatchers("/druid/**").anonymous(); // Spring Boot Actuator 的安全配置 registry.antMatchers("/actuator").anonymous() .antMatchers("/actuator/**").anonymous(); // RPC 服务的安全配置 registry.antMatchers(ApiConstants.PREFIX + "/**").permitAll(); } }; }} 其中 DemoServerApplication 的 Java 代码如下: package cn.iocoder.yudao.module.demo;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;/** * 项目的启动类 * * @author 芋道源码 */@SpringBootApplicationpublic class DemoServerApplication { public static void main(String[] args) { SpringApplication.run(DemoServerApplication.class, args); }} ④ 打开 Maven 菜单,点击刷新按钮,让引入的 Maven 依赖生效。如下图所示: ⑤ 在 resources 目录下,新建配置文件。如下图所示: 其中 application.yml 的配置如下: spring: main: allow-circular-references: true # 允许循环依赖,因为项目是三层架构,无法避免这个情况。 allow-bean-definition-overriding: true # 允许 Bean 覆盖,例如说 Dubbo 或者 Feign 等会存在重复定义的服务 # Servlet 配置 servlet: # 文件上传相关配置项 multipart: max-file-size: 16MB # 单个文件大小 max-request-size: 32MB # 设置总上传的文件大小 mvc: pathmatch: matching-strategy: ANT_PATH_MATCHER # 解决 SpringFox 与 SpringBoot 2.6.x 不兼容的问题,参见 SpringFoxHandlerProviderBeanPostProcessor 类 # Jackson 配置项 jackson: serialization: write-dates-as-timestamps: true # 设置 LocalDateTime 的格式,使用时间戳 write-date-timestamps-as-nanoseconds: false # 设置不使用 nanoseconds 的格式。例如说 1611460870.401,而是直接 1611460870401 write-durations-as-timestamps: true # 设置 Duration 的格式,使用时间戳 fail-on-empty-beans: false # 允许序列化无属性的 Bean # Cache 配置项 cache: type: REDIS redis: time-to-live: 1h # 设置过期时间为 1 小时--- #################### 接口文档配置 ####################springdoc: api-docs: enabled: true # 1. 是否开启 Swagger 接文档的元数据 path: /v3/api-docs swagger-ui: enabled: true # 2.1 是否开启 Swagger 文档的官方 UI 界面 path: /swagger-ui.htmlknife4j: enable: true # 2.2 是否开启 Swagger 文档的 Knife4j UI 界面 setting: language: zh_cn# MyBatis Plus 的配置项mybatis-plus: configuration: map-underscore-to-camel-case: true # 虽然默认为 true ,但是还是显示去指定下。 global-config: db-config: # 重要说明:如果将配置放到 Nacos 时,请注意将 id-type 设置为对应 DB 的类型,否则会报错;详细见 https://gitee.com/zhijiantianya/yudao-cloud/issues/I5W2N0 讨论 id-type: NONE # “智能”模式,基于 IdTypeEnvironmentPostProcessor + 数据源的类型,自动适配成 AUTO、INPUT 模式。# id-type: AUTO # 自增 ID,适合 MySQL 等直接自增的数据库# id-type: INPUT # 用户输入 ID,适合 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库# id-type: ASSIGN_ID # 分配 ID,默认使用雪花算法。注意,Oracle、PostgreSQL、Kingbase、DB2、H2 数据库时,需要去除实体类上的 @KeySequence 注解 logic-delete-value: 1 # 逻辑已删除值(默认为 1) logic-not-delete-value: 0 # 逻辑未删除值(默认为 0) type-aliases-package: ${yudao.info.base-package}.dal.dataobject--- #################### RPC 远程调用相关配置 ####################dubbo: scan: base-packages: ${yudao.info.base-package}.api # 指定 Dubbo 服务实现类的扫描基准包 protocol: name: dubbo # 协议名称 port: -1 # 协议端口,-1 表示自增端口,从 20880 开始 registry: address: spring-cloud://localhost # 设置使用 Spring Cloud 注册中心--- #################### MQ 消息队列相关配置 ####################--- #################### 定时任务相关配置 ####################xxl: job: executor: appname: ${spring.application.name} # 执行器 AppName logpath: ${user.home}/logs/xxl-job/${spring.application.name} # 执行器运行日志文件存储磁盘路径 accessToken: default_token # 执行器通讯TOKEN--- #################### 芋道相关配置 ####################yudao: info: version: 1.0.0 base-package: cn.iocoder.yudao.module.demo web: admin-ui: url: http://dashboard.yudao.iocoder.cn # Admin 管理后台 UI 的地址 swagger: title: 管理后台 description: 提供管理员管理的所有功能 version: ${yudao.info.version} base-package: ${yudao.info.base-package} tenant: # 多租户相关配置项 enable: truedebug: false yudao.info.version.base-package 配置项:可以改成你的项目的基准包名。 其中 application-local.yml 的配置如下: --- #################### 数据库相关配置 ####################spring: # 数据源配置项 autoconfigure: exclude: - com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure # 排除 Druid 的自动配置,使用 dynamic-datasource-spring-boot-starter 配置多数据源 datasource: druid: # Druid 【监控】相关的全局配置 web-stat-filter: enabled: true stat-view-servlet: enabled: true allow: # 设置白名单,不填则允许所有访问 url-pattern: /druid/* login-username: # 控制台管理用户名和密码 login-password: filter: stat: enabled: true log-slow-sql: true # 慢 SQL 记录 slow-sql-millis: 100 merge-sql: true wall: config: multi-statement-allow: true dynamic: # 多数据源配置 druid: # Druid 【连接池】相关的全局配置 initial-size: 5 # 初始连接数 min-idle: 10 # 最小连接池数量 max-active: 20 # 最大连接池数量 max-wait: 600000 # 配置获取连接等待超时的时间,单位:毫秒 time-between-eviction-runs-millis: 60000 # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位:毫秒 min-evictable-idle-time-millis: 300000 # 配置一个连接在池中最小生存的时间,单位:毫秒 max-evictable-idle-time-millis: 900000 # 配置一个连接在池中最大生存的时间,单位:毫秒 validation-query: SELECT 1 FROM DUAL # 配置检测连接是否有效 test-while-idle: true test-on-borrow: false test-on-return: false primary: master datasource: master: name: ruoyi-vue-pro url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.master.name}?allowMultiQueries=true&useUnicode=true&useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X 连接的示例# url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.master.name}?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=CTT # MySQL Connector/J 5.X 连接的示例# url: jdbc:postgresql://127.0.0.1:5432/${spring.datasource.dynamic.datasource.slave.name} # PostgreSQL 连接的示例# url: jdbc:oracle:thin:@127.0.0.1:1521:xe # Oracle 连接的示例# url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=${spring.datasource.dynamic.datasource.master.name} # SQLServer 连接的示例 username: root password: 123456# username: sa# password: JSm:g(*%lU4ZAkz06cd52KqT3)i1?H7W slave: # 模拟从库,可根据自己需要修改 name: ruoyi-vue-pro url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.slave.name}?allowMultiQueries=true&useUnicode=true&useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X 连接的示例# url: jdbc:mysql://127.0.0.1:3306/${spring.datasource.dynamic.datasource.slave.name}?useSSL=false&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=CTT # MySQL Connector/J 5.X 连接的示例# url: jdbc:postgresql://127.0.0.1:5432/${spring.datasource.dynamic.datasource.slave.name} # PostgreSQL 连接的示例# url: jdbc:oracle:thin:@127.0.0.1:1521:xe # Oracle 连接的示例# url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=${spring.datasource.dynamic.datasource.slave.name} # SQLServer 连接的示例 username: root password: 123456# username: sa# password: JSm:g(*%lU4ZAkz06cd52KqT3)i1?H7W # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 redis: host: 127.0.0.1 # 地址 port: 6379 # 端口 database: 0 # 数据库索引# password: 123456 # 密码,建议生产环境开启--- #################### MQ 消息队列相关配置 ####################spring: cloud: stream: rocketmq: # RocketMQ Binder 配置项,对应 RocketMQBinderConfigurationProperties 类 binder: name-server: 127.0.0.1:9876 # RocketMQ Namesrv 地址--- #################### 定时任务相关配置 ####################xxl: job: admin: addresses: http://127.0.0.1:9090/xxl-job-admin # 调度中心部署跟地址--- #################### 服务保障相关配置 ##################### Lock4j 配置项lock4j: acquire-timeout: 3000 # 获取分布式锁超时时间,默认为 3000 毫秒 expire: 30000 # 分布式锁的超时时间,默认为 30 毫秒--- #################### 监控相关配置 ##################### Actuator 监控端点的配置项management: endpoints: web: base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator exposure: include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 * ,可以开放所有端点。# Spring Boot Admin 配置项spring: boot: admin: # Spring Boot Admin Client 客户端的相关配置 client: instance: service-host-type: IP # 注册实例时,优先使用 IP [IP, HOST_NAME, CANONICAL_HOST_NAME]# 日志文件配置logging: level: # 配置自己写的 MyBatis Mapper 打印日志 cn.iocoder.yudao.module.demo.dal.mysql: debug--- #################### 芋道相关配置 ##################### 芋道配置项,设置当前项目所有自定义的配置yudao: env: # 多环境的配置项 tag: ${HOSTNAME} security: mock-enable: true xss: enable: false exclude-urls: # 如下两个 url,仅仅是为了演示,去掉配置也没关系 - ${spring.boot.admin.context-path}/** # 不处理 Spring Boot Admin 的请求 - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 access-log: # 访问日志的配置项 enable: false error-code: # 错误码相关配置项 enable: false demo: false # 关闭演示模式 logging.level.cn.iocoder.yudao.module.demo.dal.mysql 配置项:可以改成你的项目的基准包名。 其中 bootstrap.yml 的配置如下: spring: application: name: demo-server profiles: active: localserver: port: 48099# 日志文件配置。注意,如果 logging.file.name 不放在 bootstrap.yaml 配置文件,而是放在 application.yaml 中,会导致出现 LOG_FILE_IS_UNDEFINED 文件logging: file: name: ${user.home}/logs/${spring.application.name}.log # 日志文件名,全路径 spring.application.name 配置项:可以改成你想要的服务名。 server.port 配置项:可以改成你想要的端口号。 其中 bootstrap-local.yml 的配置如下: --- #################### 注册中心相关配置 ####################spring: cloud: nacos: server-addr: 127.0.0.1:8848 discovery: namespace: dev # 命名空间。这里使用 dev 开发环境 metadata: version: 1.0.0 # 服务实例的版本号,可用于灰度发布--- #################### 配置中心相关配置 ####################spring: cloud: nacos: # Nacos Config 配置项,对应 NacosConfigProperties 配置属性类 config: server-addr: 127.0.0.1:8848 # Nacos 服务器地址 namespace: dev # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP name: # 使用的 Nacos 配置集的 dataId,默认为 spring.application.name file-extension: yaml # 使用的 Nacos 配置集的 dataId 的文件拓展名,同时也是 Nacos 配置集的配置格式,默认为 properties 其中 logback-spring.xml 的配置如下: <configuration> <!-- 引用 Spring Boot 的 logback 基础配置 --> <include resource="org/springframework/boot/logging/logback/defaults.xml" /> <!-- 变量 yudao.info.base-package,基础业务包 --> <springProperty scope="context" name="yudao.info.base-package" source="yudao.info.base-package"/> <!-- 格式化输出:%d 表示日期,%X{tid} SkWalking 链路追踪编号,%thread 表示线程名,%-5level:级别从左显示 5 个字符宽度,%msg:日志消息,%n是换行符 --> <property name="PATTERN_DEFAULT" value="%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- [%thread] [%tid] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/> <!-- 控制台 Appender --> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder"> <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout"> <pattern>${PATTERN_DEFAULT}</pattern> </layout> </encoder> </appender> <!-- 文件 Appender --> <!-- 参考 Spring Boot 的 file-appender.xml 编写 --> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder"> <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout"> <pattern>${PATTERN_DEFAULT}</pattern> </layout> </encoder> <!-- 日志文件名 --> <file>${LOG_FILE}</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <!-- 滚动后的日志文件名 --> <fileNamePattern>${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN:-${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz}</fileNamePattern> <!-- 启动服务时,是否清理历史日志,一般不建议清理 --> <cleanHistoryOnStart>${LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START:-false}</cleanHistoryOnStart> <!-- 日志文件,到达多少容量,进行滚动 --> <maxFileSize>${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE:-10MB}</maxFileSize> <!-- 日志文件的总大小,0 表示不限制 --> <totalSizeCap>${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP:-0}</totalSizeCap> <!-- 日志文件的保留天数 --> <maxHistory>${LOGBACK_ROLLINGPOLICY_MAX_HISTORY:-30}</maxHistory> </rollingPolicy> </appender> <!-- 异步写入日志,提升性能 --> <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"> <!-- 不丢失日志。默认的,如果队列的 80% 已满,则会丢弃 TRACT、DEBUG、INFO 级别的日志 --> <discardingThreshold>0</discardingThreshold> <!-- 更改默认的队列的深度,该值会影响性能。默认值为 256 --> <queueSize>256</queueSize> <appender-ref ref="FILE"/> </appender> <!-- SkyWalking GRPC 日志收集,实现日志中心。注意:SkyWalking 8.4.0 版本开始支持 --> <appender name="GRPC" class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.log.GRPCLogClientAppender"> <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder"> <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout"> <pattern>${PATTERN_DEFAULT}</pattern> </layout> </encoder> </appender> <!-- 本地环境 --> <springProfile name="local"> <root level="INFO"> <appender-ref ref="STDOUT"/> <appender-ref ref="GRPC"/> <!-- 本地环境下,如果不想接入 SkyWalking 日志服务,可以注释掉本行 --> <appender-ref ref="ASYNC"/> <!-- 本地环境下,如果不想打印日志,可以注释掉本行 --> </root> </springProfile> <!-- 其它环境 --> <springProfile name="dev,test,stage,prod,default"> <root level="INFO"> <appender-ref ref="STDOUT"/> <appender-ref ref="ASYNC"/> <appender-ref ref="GRPC"/> </root> </springProfile></configuration> # 4. 新建 RESTful API 接口 ① 在 controller.admin 包,新建一个 DemoTestController 类,并新建一个 /demo/test/get 接口。代码如下: package cn.iocoder.yudao.module.demo.controller.admin;import cn.iocoder.yudao.framework.common.pojo.CommonResult;import io.swagger.annotations.Api;import io.swagger.annotations.ApiOperation;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;@Tag(name = "管理后台 - Test")@RestController@RequestMapping("/demo/test")@Validatedpublic class DemoTestController { @GetMapping("/get") @Operation(summary = "获取 test 信息") public CommonResult<String> get() { return success("true"); }} 注意,/demo 是该模块所有 RESTful API 的基础路径,/test 是 Test 功能的基础路径。 ① 在 controller.app 包,新建一个 AppDemoTestController 类,并新建一个 /demo/test/get 接口。代码如下: package cn.iocoder.yudao.module.demo.controller.app;import cn.iocoder.yudao.framework.common.pojo.CommonResult;import io.swagger.annotations.Api;import io.swagger.annotations.ApiOperation;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;@Tag(name = "用户 App - Test")@RestController@RequestMapping("/demo/test")@Validatedpublic class AppDemoTestController { @GetMapping("/get") @Operation(summary = "获取 test 信息") public CommonResult<String> get() { return success("true"); }} 在 Controller 的命名上,额外增加 App 作为前缀,一方面区分是管理后台还是用户 App 的 Controller,另一方面避免 Spring Bean 的名字冲突。 可能你会奇怪,这里我们定义了两个 /demo/test/get 接口,会不会存在重复导致冲突呢?答案,当然是并不会。原因是: controller.admin 包下的接口,默认会增加 /admin-api,即最终的访问地址是 /admin-api/demo/test/get controller.app 包下的接口,默认会增加 /app-api,即最终的访问地址是 /app-api/demo/test/get # 5. 启动 demo 服务 ① 运行 SystemServerApplication 类,将 system 服务启动。运行 InfraServerApplication 类,将 infra 服务启动。 ② 运行 DemoServerApplication 类,将新建的 demo 服务进行启动。启动完成后,使用浏览器打开 http://127.0.0.1:48099/doc.html (opens new window) 地址,进入该服务的 Swagger 接口文档。 ③ 打开“管理后台 - Test”接口,进行 /admin-api/demo/test/get 接口的调试,如下图所示: ④ 打开“用户 App - Test”接口,进行 /app-api/demo/test/get 接口的调试,如下图所示: # 6. 网关配置 ① 打开 yudao-gateway 网关项目的 application.yml 配置文件,增加 demo 服务的路由配置。代码如下: - id: demo-admin-api # 路由的编号 uri: grayLb://demo-server predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 - Path=/admin-api/demo/** filters: - RewritePath=/admin-api/demo/v2/api-docs, /v2/api-docs # 配置,保证转发到 /v2/api-docs- id: demo-app-api # 路由的编号 uri: grayLb://demo-server predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 - Path=/app-api/demo/** filters: - RewritePath=/app-api/demo/v2/api-docs, /v2/api-docs - name: demo-server service-name: demo-server url: /admin-api/demo/v3/api-docs ② 运行 GatewayServerApplication 类,将 gateway 网关服务启动。 ③ 使用浏览器打开 http://127.0.0.1:48080/doc.html (opens new window) 地址,进入网关的 Swagger 接口文档。然后,选择 demo-server 服务,即可进行 /admin-api/demo/test/get 和 /app-api/demo/test/get 接口的调试,如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 09:21:21 删除功能 代码生成(新增功能) ← 删除功能 代码生成(新增功能)→"},{"title":"短信配置","path":"/wiki/YuDaoCloud/后端手册/短信配置/短信配置.html","content":"开发指南后端手册 芋道源码 2022-04-10 目录 短信配置 本章节,介绍项目的短信功能。该功能提供统一的短信 API 给其它模块,使它们可以快速接入短信功能,无需关心不同短信平台的具体对接。 短信采用异步发送,基于 Redis 消息队列,如下图所示: yudao-spring-boot-starter-biz-sms (opens new window) 业务组件:封装不同短信平台的客户端。 yudao-module-system 的 sms (opens new window) 业务模块,提供短信渠道、模板的配置,短信日志的查看,短信的发送等功能。 # 1. 表结构 # 2. 短信配置 本小节,讲解如何配置短信功能,整个过程如下: 新建一个短信【渠道】,配置对应短信平台的账号 新建一个短信【模版】,配置对应短信平台的模板 测试该短信模板,查看对应的短信【日志】,确认是否发送成功 # 2.1 新建短信渠道 ① 点击 [系统管理 -> 短信管理 -> 短信渠道] 菜单,查看短信渠道的列表。如下图所示: ② 点击 [新增] 按钮,选择渠道编码为【调试(钉钉)】,并填写信息如下图: 短信 API 的账号: 696b5d8ead48071237e4aa5861ff08dbadb2b4ded1c688a7b7c9afc615579859短信 API 的密钥: SEC5c4e5ff888bc8a9923ae47f59e7ccd30af1f14d93c55b4e2c9cb094e35aeed67 疑问 1:为什么选择渠道编码为【调试(钉钉)】? 该类型使用钉钉机器人来模拟短信发送,用于日常调试。 短信 API 的账号,对应机器人的 Webhook 的 access_token 参数 短信 API 的密钥,对应机器人的安全设置的加签 上图使用的配置,是艿艿自己的钉钉机器人。正式使用时,必须参考 《钉钉开放平台 —— 自定义机器人接入 》 (opens new window) 文档,申请自己的专属机器人。 疑问 2:可以选择其它渠道编码吗? 当然可以,这里主要考虑部分同学暂时没有申请短信平台,所以使用【调试(钉钉)】渠道编码。 不同短信平台的配置,可见 「6. 短信平台附录」 小节。 # 2.2 新建短信模板 ① 点击 [系统管理 -> 短信管理 -> 短信模板] 菜单,查看短信模板的列表。如下图所示: ② 点击 [新增] 按钮,选择刚创建的短信渠道,并填写信息如下图: 短信渠道编号:发送该短信模板时,使用的短信渠道,即使用哪个短信平台进行发送 模板编号:短信模板的唯一标识,使用短信 API 时,通过它标识使用的短信模板 模板内容:短信模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 短信 API 模板编号:短信平台的短信模板的编号,需要保证该模板在短信平台已经审核通过 开启状态:短信模板被禁用时,该短信模板将不发送短信,只记录短信日志 疑问:为什么设计短信模板的功能? 在一些场景下,需要修改短信模板所使用的短信平台。例如说:短信平台出现故障,或者切换短信平台等等。 此时,只需要修改短信模板的两个属性:短信渠道编号、短信 API 模板编号,无需重启应用。 # 2.3 查看短信日志 ① 使用钉钉,扫码 图片 加入机器人所在的【ruoyi-vue-pro 短信测试群】,查看测试短信的模拟发送。 ② 点击 [测试] 按钮,输入任一手机号,进行该短信模板的模拟发送。如下图所示: 友情提示:如果使用的短信渠道是阿里云、腾讯云等正式的短信平台,则会发送到填写的手机号中。例如说: ③ 点击 [系统管理 -> 短信管理 -> 短信日志] 采单,可以查看到每条短信的发送状态、接收状态。如下图所示: # 3. 短信发送 # 3.1 SmsSendApi 使用 SmsSendApi (opens new window) 进行短信的发送,支持多种用户类型。它的方法如下: # 3.2 实战案例 以工作流申请通过时,发送短信为例子,讲解 SmsSendApi 的使用。 ① 引入 yudao-module-system-api 依赖,如下图所示: ② 新建对应的短信模板,如下图所示: ③ 使用 Spring 注入 SmsSendApi Bean,调用对应的短信发送方法。如下图所示: # 4. 验证码发送 # 4.1 SmsCodeApi 使用 SmsCodeApi (opens new window) 进行【验证码】短信的发送,例如说:用户手机验证码登录、用户忘记密码等等。它的方法如下: 验证码使用 system_sms_code (opens new window) 表进行存储,默认每天最多发送 10 条,每分钟发送 1 条,有效期为 10 分钟,可通过 yudao.sms-code 配置项进行自定义: # 4.2 实战案例 以会员用户手机验证码登录为例子,讲解 SmsCodeApi 的使用。 ① 引入 yudao-module-system-api 依赖,如下图所示: ② 新建对应的短信模板,如下图所示: ③ 在 SmsSceneEnum (opens new window) 中,枚举会员用户的手机号登录的场景,如下图所示: ④ 使用 Spring 注入 SmsCodeApi Bean,调用对应的短信验证码的发送与使用方法。如下图所示: # 5. 短信客户端 yudao-spring-boot-starter-biz-sms (opens new window) 业务组件,对接阿里云、腾讯云等短信平台,提供统一的短信客户端,提供给 yudao-module-system 的 sms (opens new window) 业务模块来调用。 # 5.1 SmsClient SmsClient (opens new window) 接口,定义短信客户端的方法。代码如下: 每个短信平台,都对应一个 SmsClient 实现类。 # 5.2 SmsCodeMapping SmsCodeMapping (opens new window) 接口,定义短信平台错误码转换成 标准错误码 (opens new window) 的方法。代码如下: 每个短信平台,都对应一个 SmsCodeMapping 实现类。 # 5.3 对接其它短信平台 如果你想要对接其它短信平台,自定义一个 SmsClient + SmsCodeMapping 实现类,并使用 SmsClientFactoryImpl (opens new window) 进行创建。代码如下: # 6. 短信平台附录 一般情况下,建议接入 2-3 个短信平台,避免某个短信平台故障时,影响业务的正常运行。 例如说,手机验证码的短信平台 A 故障时,赶紧将短信验证码切换到短信平台 B 上,否则用户将无法正常登录或是注册。 # 6.1 阿里云 短信 API 的账号、密钥,可通过 阿里云 —— AccessKey (opens new window) 获取。 短信发送回调 URL,可通过 阿里云 —— 短信服务 —— 通用设置 (opens new window) 配置。 # 6.2 腾讯云 短信 API 的账号、密钥,可通过 腾讯云 —— API 密钥管理 (opens new window) 获取。 注意!!! 腾讯云需要额外使用 SDKAppID (opens new window) 参数,它的账号需要采用 SDKAppID secretId 格式,具体可见 TencentSmsChannelProperties (opens new window) 类。 短信发送回调 URL,可通过 腾讯云 —— 短信 —— 基础配置 (opens new window) 配置。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/28, 22:54:42 数据库文档 邮件配置 ← 数据库文档 邮件配置→"},{"title":"本地缓存","path":"/wiki/YuDaoCloud/后端手册/本地缓存/本地缓存.html","content":"开发指南后端手册 芋道源码 2022-04-03 目录 本地缓存 重要说明: ① 由于大家普遍反馈,“本地缓存”学习成本太高,一般 Redis 缓存足够满足大多数场景的性能要求,所以基本使用 Spring Cache + Redis 所替代。 也因此,本章节更多的,是讲解如何在项目中使用本地缓存。如果你不需要本地缓存,可以忽略本章节。 ② 项目中还保留了部分地方使用本地缓存,例如说:短信客户端、文件客户端、敏感词等。主要原因是,它们是“有状态”的 Java 对象,无法缓存到 Redis 中。 系统使用本地缓存,提升公用逻辑的执行性能。 例如说: * 租户模块 (opens new window) 缓存租户信息,每次 RESTful API 校验租户是否禁用、过期时,无需读库。 部门模块 (opens new window) 缓存部门信息,每次数据权限校验时,无需读库。 权限模块 (opens new window) 缓存权限信息,每次功能权限校验时,无需读库。 # 1. 实现原理 本地缓存的实现,一共有两步,如下图所示: 项目启动时,初始化缓存:从数据库中读取数据,写入到本地缓存(例如说一个 Map 对象) 数据变化时,实时刷新缓存:(例如说通过管理后台修改数据)重新从数据库中读取数据,重新写入到本地缓存 # 2. 实战案例 以 角色模块 (opens new window) 为例,讲解如何实现角色信息的本地缓存。 # 2.1 初始化缓存 ① 在 RoleService (opens new window) 接口中,定义 #initLocalCache() 方法。代码如下: // RoleService.java/** * 初始化角色的本地缓存 */void initLocalCache(); 为什么要定义接口方法? 稍后实时刷新缓存时,会调用 RoleService 接口的该方法。 ② 在 RoleServiceImpl (opens new window) 类中,实现 #initLocalCache() 方法,通过 @PostConstruct 注解,在项目启动时进行本地缓存的初始化。代码如下: // RoleServiceImpl.java/** * 角色缓存 * key:角色编号 {@link RoleDO#getId()} * * 这里声明 volatile 修饰的原因是,每次刷新时,直接修改指向 */@Getterprivate volatile Map<Long, RoleDO> roleCache;/** * 初始化 {@link #roleCache} 缓存 */@Override@PostConstructpublic void initLocalCache() { // 注意:忽略自动多租户,因为要全局初始化缓存 TenantUtils.executeIgnore(() -> { // 第一步:查询数据 List<RoleDO> roleList = roleMapper.selectList(); log.info("[initLocalCache][缓存角色,数量为:{}]", roleList.size()); // 第二步:构建缓存 roleCache = CollectionUtils.convertMap(roleList, RoleDO::getId); });} 疑问:为什么使用 TenantUtils 的 executeIgnore 方法来执行逻辑? 由于 RoleDO 是多租户隔离,如果使用 TenantUtils 方法,会导致缓存刷新时,只加载某个租户的角色数据,导致本地缓存的错误。 所以,如果缓存的数据不存在多租户隔离的情况,可以不使用 TenantUtils 方法!!!! # 2.2 实时刷新缓存 为什么需要使用 Spring Cloud Bus (opens new window) 来实时刷新缓存?考虑到高可用,线上会部署多个 JVM 实例,需要通过 RocketMQ 广播到所有实例,实现本地缓存的刷新。 友情提示: 对 Spring Cloud Bus 不熟悉的同学,可以后续阅读 《芋道 Spring Cloud Alibaba 事件总线 Bus RocketMQ 入门 》 (opens new window) 文档。 # 2.2.1 RoleRefreshMessage 新建 RoleRefreshMessage (opens new window) 类,角色数据刷新 Message。代码如下: @Datapublic class RoleRefreshMessage extends RemoteApplicationEvent { public RoleRefreshMessage() { } public RoleRefreshMessage(Object source, String originService, String destinationService) { super(source, originService, DEFAULT_DESTINATION_FACTORY.getDestination(destinationService)); }} # 2.2.2 RoleProducer ① 新建 RoleProducer ( opens new window) 类,RoleRefreshMessage 的 Producer 生产者。代码如下: @Componentpublic class RoleProducer extends AbstractBusProducer { /** * 发送 {@link RoleRefreshMessage} 消息 */ public void sendRoleRefreshMessage() { publishEvent(new RoleRefreshMessage(this, getBusId(), selfDestinationService())); }} ② 在数据的新增 / 修改 / 删除等写入操作时,需要使用 RoleProducer 发送消息。如下图所示: # 2.2.3 RoleRefreshConsumer 新建 RoleRefreshConsumer (opens new window) 类,RoleRefreshMessage 的 Consumer 消费者,刷新本地缓存。代码如下: @Component@Slf4jpublic class RoleRefreshConsumer { @Resource private RoleService roleService; @EventListener public void execute(RoleRefreshMessage message) { log.info("[execute][收到 Role 刷新消息]"); roleService.initLocalCache(); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/03, 22:14:31 Redis 缓存 异步任务 ← Redis 缓存 异步任务→"},{"title":"文件存储(上传下载)","path":"/wiki/YuDaoCloud/后端手册/文件存储(上传下载)/文件存储(上传下载).html","content":"开发指南后端手册 芋道源码 2022-03-17 目录 文件存储(上传下载) 项目支持将文件上传到三类存储器: 兼容 S3 协议的对象存储:支持 MinIO、腾讯云 COS、七牛云 Kodo、华为云 OBS、亚马逊 S3 等等。 磁盘存储:本地、FTP 服务器、SFTP 服务器。 数据库存储:MySQL、Oracle、PostgreSQL、SQL Server 等等。 技术选型? 优先,✔ 推荐方案 1。如果无法使用云服务,可以自己搭建一个 MinIO 服务。参见 《芋道 Spring Boot 对象存储 MinIO 入门 》 (opens new window) 文章。 其次,推荐方案 3。数据库的主从机制可以实现高可用,备份也方便,少量小文件问题不大。 最后,× 不推荐方案 2。主要是实现高可用比较困难,无法实现故障转移。 # 1. 快速入门 本小节,我们来添加个文件配置,并使用它上传下载文件。 # 1.1 新增配置 ① 打开 [基础设施 -> 文件管理 -> 文件配置] 菜单,进入文件配置的界面。 ② 点击 [新增] 按钮,选择存储器为【S3 对象存储器】,并填写七牛云的配置。如下图: 节点地址:s3-cn-south-1.qiniucs.com 存储 bucket:ruoyi-vue-pro accessKey:b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8 accessSecret:kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP 自定义域名:http://test.yudao.iocoder.cn 友善的眼神! 上述七牛云的配置,是艿艿为了大家方便体验,请勿在测试或生产环境体验。 ③ 添加完后,点击该配置所在行的 [测试] 按钮,测试配置是否正确。 ④ 测试通过后,点击该配置所在行的 [主配置] 按钮,设置它为默认的配置,后续使用它进行文件的上传。 # 1.2 上传文件 ① 点击 [基础设施 -> 文件管理 -> 文件列表] 菜单,进入文件列表的界面。 ② 点击 [上传文件] 按钮,选择要上传的文件。 ③ 上传完成后,如果想要删除,可点击该文件所在行的 [删除] 按钮。 # 2. 文件上传 项目提供了 2 种文件上传的方式,分别适合前端、后端使用。 # 2.1 方式一:前端上传 FileController (opens new window) 提供了 /admin-api/infra/file/upload RESTful API,用于前端直接上传文件。 // FileController.java@PostMapping("/upload")@Operation(summary = "上传文件")@OperateLog(logArgs = false) // 上传文件,没有记录操作日志的必要public CommonResult<String> uploadFile(FileUploadReqVO uploadReqVO) throws Exception { MultipartFile file = uploadReqVO.getFile(); String path = uploadReqVO.getPath(); return success(fileService.createFile(file.getOriginalFilename(), path, IoUtil.readBytes(file.getInputStream())));} 前端上传文件的代码如何实现,可见: 文件列表,文件上传 index.vue (opens new window) 个人中心,头像修改 userAvatar.vue (opens new window) # 2.2 方式二:后端上传 yudao-module-infra 的 FileApi (opens new window) 提供了 #createFile(...) 方法,用于后端需要上传文件的逻辑。 // FileApi.java/** * 保存文件,并返回文件的访问路径 * * @param path 文件路径 * @param content 文件内容 * @return 文件路径 */String createFile(String path, byte[] content); 例如说,个人中心修改头像时,需要进行头像的上传。如下图所示: 注意,需要使用到后端上传的 Maven 模块,需要引入 yudao-module-infra-api 依赖。例如说 yudao-module-system-biz 模块的 pom.xml 文件,引用如下: <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-module-infra-api</artifactId> <version>${revision}</version></dependency> # 3. 文件下载 文件上传成功后,返回的是完整的 URL 访问路径 ,例如说 http://test.yudao.iocoder.cn/822aebded6e6414e912534c6091771a4.jpg ( opens new window) 。 不同的文件存储器,返回的 URL 路径的规则是不同的: ① 当存储器是【S3 对象存储】时,支持 HTTP 访问,所以直接使用 S3 对象存储返回的 URL 路径即可。 ② 当存储器是【数据库】【本地磁盘】等时,它们只支持存储,所以需要 FileController ( opens new window) 提供的 /admin-api/infra/file/{configId}/get/{path} RESTful API,读取文件内容后返回。 // FileController.java@GetMapping("/{configId}/get/**")@PermitAll@Operation(summary = "下载文件")@Parameter(name = "configId", description = "配置编号", required = true)public void getFileContent(HttpServletRequest request, HttpServletResponse response, @PathVariable("configId") Long configId) throws Exception { // 获取请求的路径 String path = StrUtil.subAfter(request.getRequestURI(), "/get/", false); if (StrUtil.isEmpty(path)) { throw new IllegalArgumentException("结尾的 path 路径必须传递"); } // 读取内容 byte[] content = fileService.getFileContent(configId, path); if (content == null) { log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path); response.setStatus(HttpStatus.NOT_FOUND.value()); return; } ServletUtils.writeAttachment(response, path, content);} # 4. 文件客户端 技术组件 yudao-spring-boot-starter-file ( opens new window) ,定义了 FileClient ( opens new window) 接口,抽象了文件客户端的方法。 public interface FileClient { /** * 获得客户端编号 * * @return 客户端编号 */ Long getId(); /** * 上传文件 * * @param content 文件流 * @param path 相对路径 * @return 完整路径,即 HTTP 访问地址 */ String upload(byte[] content, String path); /** * 删除文件 * * @param path 相对路径 */ void delete(String path); /** * 获得文件的内容 * * @param path 相对路径 * @return 文件的内容 */ byte[] getContent(String path);} FileClient 有 5 个实现类,使用不同存储器进行文件的上传与下载。UML 类图如所示: 文件上传的调用的 UML 时序图如下所示: # 5. S3 对象存储的配置 做的不错的云存储服务,都是兼容 S3 协议的。如何获取对应的 S3 配置,艿艿整理到了 S3FileClientConfig (opens new window) 配置类。 有一点要注意,云存储服务的 Bucket 需要设置为公共读,不然 URL 无法访问到文件。 并且,最好使用自定义域名,方便迁移到不同的云存储服务。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:52:02 分页实现 Excel 导入导出 ← 分页实现 Excel 导入导出→"},{"title":"邮件配置","path":"/wiki/YuDaoCloud/后端手册/邮件配置/邮件配置.html","content":"开发指南后端手册 芋道源码 2023-01-26 目录 邮件配置 本章节,介绍项目的邮件功能。它在管理后台有三个菜单,分别是: ① 邮箱账号:配置邮件的发送账号 ② 邮件模版:管理邮件的内容模版 ③ 邮件记录:查看邮件的发送记录 # 1. 表结构 # 2. 实现原理 邮件功能提供统一的 API 给其它模块,使它们可以快速实现发送邮件的功能,无需关心不同邮件平台的具体对接。 邮件采用异步发送,基于 Redis 消息队列,如下图所示: 前端代码:views/system/mail (opens new window) 后端代码:controller/admin/mail (opens new window) 最终使用 Hutool 的 MailUtil (opens new window) 发送邮件。 # 3. 邮箱配置 本小节,讲解如何配置邮件功能,整个过程如下: 新建一个邮箱【账号】,配置邮件的发送账号 新建一个邮件【模版】,配置邮件的内容模版 测试该邮件模板,查看对应的邮件【日志】,确认是否发送成功 # 3.1 新建邮箱账号 ① 点击 [系统管理 -> 邮件管理 -> 邮箱账号] 菜单,查看邮箱账号的列表。如下图所示: ② 点击 [新增] 按钮,添加一个邮箱账号,并填写信息如下图: 友情提示: 邮件发送基于 SMTP (opens new window) 协议实现,需要开通账号的 STMP 服务。例如说: 不同邮件平台的 SMTP 配置,可见 「5. 邮箱平台附录」 小节。 ③ 新增完成后,确认你的邮箱账号是否可以发送邮件,可通过如下代码: import cn.hutool.extra.mail.MailAccount;import cn.hutool.extra.mail.MailUtil;@Testpublic void testDemo() { MailAccount mailAccount = new MailAccount()// .setFrom("奥特曼 <ydym_test@163.com>") .setFrom("ydym_test@163.com") // 邮箱地址 .setHost("smtp.163.com").setPort(465).setSslEnable(true) // SMTP 服务器 .setAuth(true).setUser("ydym_test@163.com").setPass("WBZTEINMIFVRYSOE"); // 登录账号密码 String messageId = MailUtil.send(mailAccount, "7685413@qq.com", "主题", "内容", false); System.out.println("发送结果:" + messageId);} # 3.2 新建邮箱模版 ① 点击 [系统管理 -> 邮箱管理 -> 邮件模板] 菜单,查看邮件模板的列表。如下图所示: ② 点击 [新增] 按钮,选择刚创建的邮箱账号,并填写信息如下图: 邮箱账号:发送该邮件模板时,使用的邮件账号,即使用哪个邮箱进行发送邮件 模版编号:邮件模板的唯一标识,使用邮件 API 时,通过它标识使用的邮件模板 发件人名称:发送邮件显示的发件人名字 模板内容:邮件模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 开启状态:邮件模板被禁用时,该邮件模板将不发送邮件,只记录邮件日志 疑问:为什么设计邮件模板的功能? 在一些场景下,产品会希望修改发送邮件的标题、内容,甚至邮箱账号,此时只需要修改邮件模版的对应属性,无需重启应用。 # 3.3 查看邮件日志 ① 点击 [测试] 按钮,输入测试的收件邮箱地址,进行该邮件模板的模拟发送。如下图所示: ② 打开收件邮箱,查看邮件是否发送成功。如下图所示: ③ 点击 [系统管理 -> 邮箱管理 -> 邮件日志] 采单,可以查看到每条邮件的发送状态。如下图所示: # 4. 邮件发送 # 4.1 MailSendApi 邮箱配置 完成后,可使用 MailSendApi ( opens new window) 进行邮件的发送,支持多种用户类型。它的方法如下: # 4.2 接入示例 以 yudao-module-infra 模块,需要发邮件为例子,讲解 SmsCodeApi 的使用。 ① 在 yudao-module-infra-biz 模块的 pom.xml ( opens new window) 引入 yudao-module-system-api 依赖,如所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 在代码中注入 SmsCodeApi Bean,并调用发送邮件的方法。代码如下: public class TestDemoServiceImpl implements TestDemoService { // 0. 注入 MailSendApi Bean @Resource private MailSendApi mailSendApi; public void sendDemo() { // 1. 准备参数 Long userId = 1L; // 示例中写死,你可以改成你业务中的 userId 噢 String templateCode = "test_01"; // 邮件模版,记得在【邮箱管理】中配置噢 Map<String, Object> templateParams = new HashMap<>(); templateParams.put("key1", "奥特曼"); templateParams.put("key2", "变身"); // 2. 发送邮件 mailSendApi.sendSingleMailToAdmin(new MailSendSingleToUserReqDTO() .setUserId(userId).setTemplateCode(templateCode).setTemplateParams(templateParams)); }} # 5. 邮箱平台附录 《QQ 邮箱的 SMTP 设置》 ( opens new window) 《网易 163 邮箱的 SMTP 设置》 ( opens new window) 《QQ 邮箱、网易邮箱、腾讯企业邮箱、网易企业邮箱的 SMTP 设置》 ( opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/28, 22:54:42 短信配置 站内信配置 ← 短信配置 站内信配置→"},{"title":"站内信配置","path":"/wiki/YuDaoCloud/后端手册/站内信配置/站内信配置.html","content":"开发指南后端手册 芋道源码 2023-01-28 目录 站内信配置 本章节,介绍项目的站内信功能。它在管理后台有三个菜单,分别是: ① 站内信模版:管理站内信的内容模版 ② 站内信管理:查看站内信的发送记录 ③ 我的站内信:查看发送给我的站内信 # 1. 表结构 # 2. 实现代码 前端代码:views/system/notify (opens new window) 后端代码:controller/admin/notify (opens new window) # 3. 站内信配置 本小节,讲解如何配置站内信功能,整个过程如下: 新建一个站内信【模版】,配置站内信的内容模版 测试该站内信模板,查看对应的站内信【记录】,确认是否发送成功 # 3.1 新建站内信模版 ① 点击 [系统管理 -> 站内信管理 -> 模板管理] 菜单,查看站内信模板的列表。如下图所示: ② 点击 [新增] 按钮,填写信息如下图: 模版编号:站内信模板的唯一标识,使用站内信 API 时,通过它标识使用的站内信模板 发件人名称:发送站内信显示的发件人名字 模板内容:站内信模板的内容,使用 {var} 作为占位符,例如说 {name}、{code} 等 模版类型:站内信的分类,可使用 system_notify_template_type 字典进行自定义 开启状态:站内信模板被禁用时,该站内信模板将不发送站内信,只打印 logger 日志 疑问:为什么设计站内信模板的功能? 在一些场景下,产品会希望修改发送站内信的内容、发送人昵称,此时只需要修改站内信模版的对应属性,无需重启应用。 # 3.2 测试站内信模版 ① 点击 [测试] 按钮,选择接收人为「芋道源码」,进行该站内信模板的模拟发送。如下图所示: ② 点击 [系统管理 -> 站内信管理 -> 消息记录] 菜单,可以查看到刚发送的站内信。如下图所示: ③ 点击右上角的 [消息] 图标,也可以查看到刚发送的站内信。如下图所示: # 4. 站内信发送 # 4.1 NotifyMessageSendApi 站内信配置完成后,可使用 NotifyMessageSendApi (opens new window) 进行站内信的发送,支持多种用户类型。它的方法如下: # 4.2 接入示例 以 yudao-module-infra 模块,需要发站内信为例子,讲解 SmsCodeApi 的使用。 ① 在 yudao-module-infra-biz 模块的 pom.xml (opens new window) 引入 yudao-module-system-api 依赖,如所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> ② 在代码中注入 NotifyMessageSendApi Bean,并调用发送站内信的方法。代码如下: public class TestDemoServiceImpl implements TestDemoService { // 0. 注入 NotifyMessageSendApi Bean @Resource private NotifyMessageSendApi notifySendApi; public void sendDemo() { // 1. 准备参数 Long userId = 1L; // 示例中写死,你可以改成你业务中的 userId 噢 String templateCode = "test_01"; // 站内信模版,记得在【站内信管理】中配置噢 Map<String, Object> templateParams = new HashMap<>(); templateParams.put("key1", "奥特曼"); templateParams.put("key2", "变身"); // 2. 发送站内信 notifySendApi.sendSingleNotifylToAdmin(new NotifySendSingleToUserReqDTO() .setUserId(userId).setTemplateCode(templateCode).setTemplateParams(templateParams)); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/01/29, 19:06:42 邮件配置 数据脱敏 ← 邮件配置 数据脱敏→"},{"title":"系统日志","path":"/wiki/YuDaoCloud/后端手册/系统日志/系统日志.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 系统日志 项目提供 2 类 4 种系统日志: 审计日志:用户的操作日志、登录日志 API 日志:RESTful API 的访问日志、错误日志 # 1. 操作日志 操作日志,记录「谁」在「什么时间」对「什么对象」做了「什么事情」。 打开 [系统管理 -> 审计日志 -> 操作日志] 菜单,可以看到对应的列表,如下图所示: 操作日志的记录,由 yudao-spring-boot-starter-biz-operatelog (opens new window) 技术组件实现,OperateLogAspect (opens new window) 通过 Spring AOP 拦声明了 @OperateLog (opens new window) 注解的方法,异步记录日志。使用示例如下: 操作日志的存储,由 yudao-module-system 的 OperateLog (opens new window) 模块实现,记录到数据库的 system_operate_log (opens new window) 表。 # 1.1 @OperateLog 注解 @OperateLog 注解,一共有 6 个属性,如下图所示: module 属性:操作模块,例如说:用户、岗位、部门等等。为空时,默认会读取类上的 Swagger @Tag 注解的 name 属性。 name 属性:操作名,例如说:新增用户、修改用户等等。为空时,默认会读取方法的 Swagger @Operation 注解的 summary 属性。 type 属性:操作类型,在 OperateTypeEnum (opens new window) 枚举。目前有 GET 查询、CREATE 新增、UPDATE 修改、DELETE 删除、EXPORT 导出、IMPORT 导入、OTHER 其它,可进行自定义。 # 1.2 自动记录 操作日志往往记录的是针对某个对象的写操作,所以针对 POST、PUT、DELETE 等写请求,yudao-spring-boot-starter-biz-operatelog 组件会自动记录操作日志。 基于请求方法,转换出对应的 type 操作方法:POST 对应 CREATE 类型,PUT 对应 UPDATE 类型,DELETE 对应 DELETE 类型,其它对应 OTHER 类型。 基于 Swagger 注解,转换出对应的 module 操作模块、name 操作名。 因此,绝大多数 RESTful API 对应的方法,无需添加 @OperateLog 注解。例如说: 一般来说,只有两种场景需要添加 @OperateLog 注解。 ① 场景一:需要自定义 @OperateLog 注解的属性。例如说: ② 场景二:不想自动记录操作日志。例如说: # 1.3 后续优化 yudao-spring-boot-starter-biz-operatelog 组件目前提供的是轻量级的操作日志的解决方案,暂时未提供很好的记录操作对应、操作明细、拓展字段的能力。例如说: 【新增】2021-09-16 10:00 订单创建,订单号:NO.11089999,其中涉及变量订单号 “NO.11089999” 【修改】2021-09-16 10:00 用户小明修改了订单的配送地址:从 “金灿灿小区” 修改到 “银盏盏小区” 未来,艿艿会引入老友开源的 https://github.com/mouzt/mzt-biz-log (opens new window) 操作日志组件,优化项目的操作日志功能。大家记得给个 Star 哟! 目前,如果要记录具体的操作明细、拓展字段,可以调用 OperateLogUtils (opens new window) 的静态方法,代码如下: # 2. 登录日志 登录日志,记录用户的登录、登出行为,包括成功的、失败的。 打开 [系统管理 -> 审计日志 -> 登录日志] 菜单,可以看对应的列表,如下图所示: 登录日志的存储,由 yudao-module-system 的 LoginLog (opens new window) 模块实现,记录到数据库的 system_login_log (opens new window) 表。 登录类型通过 LoginLogTypeEnum (opens new window) 枚举,登录结果通过 LoginResultEnum (opens new window) 枚举,都可以自定义。代码如下: # 3. API 访问日志 API 访问日志,记录 API 的每次调用,包括 HTTP 请求、用户、开始时间、时长等等信息。 打开 [基础设施 -> API 日志 -> 访问日志] 菜单,可以看对应的列表,如下图所示: 访问日志的记录,由 yudao-spring-boot-starter-web (opens new window) 技术组件实现,通过 ApiAccessLogFilter (opens new window) 过滤 RESTful API 请求,异步记录日志。 访问日志的存储,由 yudao-module-infra 的 AccessLog (opens new window) 模块实现,记录到数据库的 infra_api_access_log (opens new window) 表。 # 4. API 错误日志 API 错误日志,记录每次 API 的异常调用,包括 HTTP 请求、用户、异常的堆栈等等信息。 打开 [基础设施 -> API 日志 -> 错误日志] 菜单,可以看对应的列表,如下图所示: 错误日志的记录,由 yudao-spring-boot-starter-web (opens new window) 技术组件实现,通过 GlobalExceptionHandler (opens new window) 拦截每次 RESTful API 的系统异常,异步记录日志。 错误日志的存储,由 yudao-module-infra 的 ErrorLog (opens new window) 模块实现,记录到数据库的 infra_api_error_log (opens new window) 表。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:58:32 Excel 导入导出 数据库 MyBatis ← Excel 导入导出 数据库 MyBatis→"},{"title":"大屏设计器","path":"/wiki/YuDaoCloud/大屏手册/大屏设计器/大屏设计器.html","content":"开发指南大屏手册 芋道源码 2023-02-07 目录 大屏设计器 数据可视化,一般可以通过报表设计器、或者大屏设计器来实现。本小节,我们来讲解大屏设计器的功能开启。 大屏设计器,指的是通过拖拽图表或页面元素,无需编写代码即可制作数据大屏。如下图所示: 在项目中,通过集成市面上的报表引擎,实现了大屏设计器的能力。目前使用如下: 是否集成 是否开源 AJ-Report (opens new window) 集成中 开源 Go-View (opens new window) 集成中 开源 JimuReport (opens new window) 不集成 不开源 为什么不使用 JimuReport 报表引擎呢? 因为 JimuReport 的大屏设计器是商业化的,需要购买授权。我手头暂时没有授权,所以没办法集成~ # 1. 功能开启 yudao-module-report 实现了报表设计器的能力,开启步骤如下: 第一步,导入报表的 SQL 数据库脚本 第二步,启动 yudao-report-report 服务 第三步,启动大屏设计器的前端项目 # 1.1 第一步,导入 SQL 导入 go-view.sql (opens new window) 脚本,初始化 Go-View 相关的表结构和数据。 # 1.2 第二步,启动 report 服务 运行该服务的 ReportServerApplication (opens new window) 启动类,看到 \"Init JimuReport Config [ 线程池 ] \" 说明开启成功。 # 1.3 第三步,启动前端项目(AJ-Report) TODO 开发中,预计 4 月份左右。 # 1.3 第三步,启动前端项目(Go-View) ① 克隆 yudao-ui-go-view (opens new window) 项目,执行如下命令进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ② 启动完成后,浏览器会自动打开 http://127.0.0.1:3000 (opens new window) 地址,可以看到前端界面。 ③ 访问 [报表管理 -> 大屏设计器] 菜单,可以查看对应的功能。如下图所示: # 2. 如何使用? # 2.1 AJ-Report 大屏设计器 TODO 开发中,预计 4 月份左右。 # 2.2 Go-View 大屏设计器 可以查看 Go-View 的官方文档,主要是: GoView 说明文档 —— 页面引导 (opens new window) GoView 说明文档 —— 常见问题 (opens new window) 如果你想了解在 Go-View 中,如何使用 SQL 或 HTTP 查询数据,可以查看内置的两个示例: 集成 Go-View 的代码实现? ① 后端:Go-View 的后端代码,主要在 go-view (opens new window) 包下实现。 ② 前端:在 @/views/report/go-view (opens new window) 文件,通过 IFrame 嵌入 Go-View 界面。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 报表设计器 开发环境 ← 报表设计器 开发环境→"},{"title":"验证码","path":"/wiki/YuDaoCloud/后端手册/验证码/验证码.html","content":"开发指南后端手册 芋道源码 2023-01-20 目录 验证码 项目基于 AJ-Captcha (opens new window) 实现行为验证码,包含滑动拼图、文字点选两种方式,UI 支持弹出和嵌入两种方式。如下图所示: 滑动拼图 文字点选 疑问:为什么采用行为验证码? 相比传统的「传统字符型验证码」的“展示验证码-填写字符-比对答案”的流程来说,「行为验证码」的“展示验证码-操作-比对答案”的流程,用户只需要使用鼠标产生指定的行为轨迹,不需要键盘手动输入,用户体验更好,更加难以被机器识别,更加安全可靠。 # 1. 交互流程 ① 用户访问应用页面,请求显示行为验证码 ② 用户按照提示要求完成验证码拼图/点击 ③ 用户提交表单,前端将第二步的输出一同提交到后台 ④ 验证数据随表单提交到后台后,后台需要调用 captchaService.verification (opens new window) 做二次校验 ⑤ 第 4 步返回校验通过/失败到产品应用后端,再返回到前端 # 2. 如何关闭验证码 管理后台的登录界面,默认开启验证码。如果需要关闭验证码,操作如下: ① 后端的 application-local.yaml 配置文件中,将 yudao.captcha.enabled (opens new window) 设置为 false。 ② 如果前端使用 yudao-ui-admin 项目,将 .env.local 配置文件中,将 VUE_APP_DOC_ENABLE (opens new window) 设置为 false。 如果前端使用 yudao-ui-admin-vue3 项目,将 .env 配置文件中,将 VITE_APP_CAPTCHA_ENABLE (opens new window) 设置为 false。 # 3. 接入场景 # 3.1 后端接入 ① yudao-spring-boot-starter-captcha (opens new window) 对 AJ-Captcha 进行封装,使用 Redis 存储验证码数据,保证分布式环境下的可用性。 由于 AJ-Captcha 对 Spring Boot 3.X 版本的支持还不完善,所以使用 captcha-plus (opens new window) 替代,它是基于 AJ-Captcha 进行增强。 使用时,需要在 pom.xml (opens new window) 引入该依赖,如下所示: <dependency> <groupId>cn.iocoder.boot</groupId> <artifactId>yudao-spring-boot-starter-captcha</artifactId></dependency> ② 验证码的配置,在 application.yaml (opens new window) 配置文件中,配置项如下: aj: captcha: jigsaw: classpath:images/jigsaw # 滑动验证,底图路径,不配置将使用默认图片;以 classpath: 开头,取 resource 目录下路径 pic-click: classpath:images/pic-click # 滑动验证,底图路径,不配置将使用默认图片;以 classpath: 开头,取 resource 目录下路径 cache-type: redis # 缓存 local/redis... cache-number: 1000 # local 缓存的阈值,达到这个值,清除缓存 timing-clear: 180 # local定时清除过期缓存(单位秒),设置为0代表不执行 type: blockPuzzle # 验证码类型 default两种都实例化。 blockPuzzle 滑块拼图 clickWord 文字点选 water-mark: 芋道源码 # 右下角水印文字(我的水印),可使用 https://tool.chinaz.com/tools/unicode.aspx 中文转 Unicode,Linux 可能需要转 unicode interference-options: 0 # 滑动干扰项(0/1/2) req-frequency-limit-enable: false # 接口请求次数一分钟限制是否开启 true|false req-get-lock-limit: 5 # 验证失败 5 次,get接口锁定 req-get-lock-seconds: 10 # 验证失败后,锁定时间间隔 req-get-minute-limit: 30 # get 接口一分钟内请求数限制 req-check-minute-limit: 60 # check 接口一分钟内请求数限制 req-verify-minute-limit: 60 # verify 接口一分钟内请求数限制 如果你想修改验证码的 图片,修改 resources/images (opens new window) 目录即可。 ③ 验证码的使用,可以参考 CaptchaController (opens new window) 和 AuthController (opens new window) 两个类的实现代码。 # 3.2 Vue2.X 管理后台 ① 验证码组件:Verifition (opens new window) ② 登录界面的接入:login.vue (opens new window) <!-- 图形验证码 --><Verify ref="verify" :captcha-type="'blockPuzzle'" :img-size="{width:'400px',height:'200px'}" @success="handleLogin" /> # 3.3 Vue3.X 管理后台 ① 验证码组件: Verifition ( opens new window) ② 登录界面的接入: LoginForm.vue ( opens new window) <Verify ref="verify" mode="pop" :captchaType="captchaType" :imgSize="{ width: '400px', height: '200px' }" @success="handleLogin"/> # 3.4 uni-app 用户 App ① 验证码组件: verifition ( opens new window) ② 登录界面的接入: login.vue ( opens new window) <Verify @success="pwdLogin" :mode="'pop'" :captchaType="'blockPuzzle'" :imgSize="{ width: '330px', height: '155px' }" ref="verify"></Verify> .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/02/11, 02:22:19 敏感词 地区 & IP 库 ← 敏感词 地区 & IP 库→"},{"title":"工作流(Flowable)会签、或签","path":"/wiki/YuDaoCloud/工作流手册/工作流(Flowable)会签、或签/工作流(Flowable)会签、或签.html","content":"开发指南工作流手册 芋道源码 2022-03-07 目录 工作流(Flowable)会签、或签 项目基于 Flowable 实现了工作流的功能。本章节,我们将介绍工作流的相关功能。 以请假流程为例,讲解系统支持的两种表单方式的工作流: 流程表单:在线配置动态表单,无需建表与开发 业务表单:业务需建立独立的数据库表,并开发对应的表单、详情界面 整个过程包括: 定义流程:【管理员】新建流程、设计流程模型、并设置用户任务的审批人,最终发布流程 发起流程:【员工】选择流程,并发起流程实例 审批流程:【审批人】接收到流程任务,审批结果为通过或不通过 微信扫描下方二维码,加入后可观看视频! 01、如何集成 Flowable 框架? (opens new window) 02、如何实现动态的流程表单? (opens new window) 03、如何实现流程表单的保存? (opens new window) 04、如何实现流程表单的展示? (opens new window) 05、如何实现流程模型的新建? (opens new window) 06、如何实现流程模型的流程图的设计? (opens new window) 07、如何实现流程模型的流程图的预览? (opens new window) 08、如何实现流程模型的分配规则? (opens new window) 09、如何实现流程模型的发布? (opens new window) 10、如何实现流程定义的查询? (opens new window) 11、如何实现流程的发起? (opens new window) 12、如何实现我的流程列表? (opens new window) 13、如何实现流程的取消? (opens new window) 14、如何实现流程的任务分配? (opens new window) 15、如何实现会签、或签任务? (opens new window) 16、如何实现我的待办任务列表? (opens new window) 17、如何实现我的已办任务列表? (opens new window) 18、如何实现任务的审批通过? (opens new window) 19、如何实现任务的审批不通过? (opens new window) 20、如何实现流程的审批记录? (opens new window) 21、如何实现流程的流程图的高亮? (opens new window) 22、如何实现工作流的短信通知? (opens new window) 23、如何实现 OA 请假的发起? (opens new window) 24、如何实现 OA 请假的审批? (opens new window) 友情提示:虽然是基于 Boot 项目录制,但是 Cloud 一样可以学习。 # 0. 如何开启 bpm 模块? yudao-module-bpm 模块是工作流服务。启动步骤如下: ① 第一步,运行 BpmServerApplication 类,启动工作流服务。 ② 第二步,查看数据库。启动过程中,Flowable 会自动创建 ACT_ 和 FLW_ 开头的表。 如果启动中报 MySQL “Specified key was too long; max key length is 1000 bytes” (opens new window) 错误,可以将 MySQL 的缺省存储引擎设置为 innodb,即 default-storage-engine=innodb 配置项。 # 1. 请假流程【流程表单】 # 1.1 第一步:定义流程 登录账号 admin、密码 admin123 的用户,扮演【管理员】的角色,进行流程的定义。 ① 访问 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [新建流程] 按钮,填写流程标识、流程名称。如下图所示: 流程标识:对应 BPMN 流程文件 XML 的 id 属性,不能重复,新建后不可修改。 流程名称:对应 BPMN 流程文件 XML 的 name 属性。 <!-- 这是一个 BPMN XML 的示例,主要看 id 和 name 属性 --><?xml version="1.0" encoding="UTF-8"?><bpmn2:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn2="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" id="diagram_Process_1647305370393" targetNamespace="http://activiti.org/bpmn"> <bpmn2:process id="common-form" name="通用表单流程" isExecutable="true" /> <bpmndi:BPMNDiagram id="BPMNDiagram_1"> <bpmndi:BPMNPlane id="common-form_di" bpmnElement="common-form" /> </bpmndi:BPMNDiagram></bpmn2:definitions> ② 访问 [工作流程 -> 流程管理 -> 流程表单] 菜单,点击 [新增] 按钮,新增一个名字为 leave-form 的表单。如下图所示: 流程表单的实现? 基于 https://github.com/JakHuang/form-generator (opens new window) 项目实现的动态表单。 回到 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [修改流程] 按钮,配置表单类型为流程表单,选择名字为 leave-form 的流程表单。如下图所示: ③ 点击 [设计流程] 按钮,在线设计请假流程模型,包含两个用户任务:领导审批、HR 审批。如下图所示: 设计流程的实现? 基于 https://github.com/miyuesc/bpmn-process-designer (opens new window) 项目实现,它的底层是 bpmn-js (opens new window)。 ④ 点击 [分配规则] 按钮,设置用户任务的审批人。其中,规则类型用于分配用户任务的审批人,目前有 7 种规则:角色、部门成员、部门负责人、岗位、用户、用户组、自定义脚本,基本可以满足绝大多数场景,是不是非常良心。 设置【领导审批】的规则类型为自定义脚本 + 流程发起人的一级领导,如下图所示: 设置【HR 审批】的规则类型为岗位 + 人力资源,如下图所示: 规则类型的实现? 可见 BpmUserTaskActivityBehavior (opens new window) 代码,目前暂时支持分配一个审批人。 ⑤ 点击 [发布流程] 按钮,把定义的流程模型部署出去。部署成功后,就可以发起该流程了。如下图所示: 修改流程后,需要重新发布流程吗? 需要,必须重新发布才能生效。每次流程发布后,会生成一个新的流程定义,版本号从 v1 开始递增。 发布成功后,会部署新版本的流程定义,旧版本的流程定义将被挂起。当然,已经发起的流程不会受到影响,还是走老的流程定义。 # 1.2 第二步:发起流程 登录账号 admin、密码 admin123 的用户,扮演【员工】的角色,进行流程的发起。 ① 访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,点击 [发起流程] 按钮,可以看到可以选择的流程定义的列表。 ② 选择名字为通用表单的流程定义,发起请假流程。填写请假表单信息如下: ③ 点击提交成功后,可在我的流程中,可看到该流程的状态、结果。 ④ 点击 [详情] 按钮,可以查看申请的表单信息、审批记录、流程跟踪图。 # 1.2 第三步:审批流程(领导审批) 登录账号 test、密码 test123 的用户,扮演【审批人】的角色,进行请假流程的【领导审批】任务。 ① 访问 [工作流程 -> 任务管理 -> 待办任务] 菜单,可以查询到需要审批的任务。 ② 点击 [审批] 按钮,填写审批建议,并点击 [通过] 按钮,这样任务的审批就完成了。 ③ 访问 [工作流程 -> 任务管理 -> 已办任务] 菜单,可以查询到已经审批的任务。 此时,使用【员工】的角色,访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,可以看到流程流转到了【HR 审批】任务。 # 1.3 第三步:审批流程(HR 审批) 登录账号 hrmgr、密码 hr123 的用户,扮演【审批人】的角色,进行请假流程的【HR 审批】任务。 ① 访问 [工作流程 -> 任务管理 -> 待办任务] 菜单,点击 [审批] 按钮,填写审批建议,并点击 [通过] 按钮。 此时,使用【员工】的角色,访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,可以看到流程处理结束,最终审批通过。 # 2. 请假流程【业务表单】 根据业务需要,业务通过建立独立的数据库表(业务表)记录申请信息,而流程引擎只负责推动流程的前进或者结束。两者需要进行双向的关联: 每一条业务表记录,通过它的流程实例的编号( process_instance_id )指向对应的流程实例 每一个流程实例,通过它的业务键( BUSINESS_KEY_ ) 指向对应的业务表记录。 以项目中提供的 OALeave (opens new window) 请假举例子,它的业务表 bpm_oa_leave 和流程引擎的流程实例的关系如下图: 也因为业务建立了独立的业务表,所以必须开发业务表对应的列表、表单、详情页面。不过,审核相关的功能是无需重新开发的,原因是业务表已经关联对应的流程实例,流程引擎审批流程实例即可。 下面,我们以项目中的 OALeave (opens new window) 为例子,详细讲解下业务表单的开发与使用的过程。 # 2.0 第零步:业务开发 ① 新建业务表 bpm_oa_leave,建表语句如下: CREATE TABLE `bpm_oa_leave` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '请假表单主键', `user_id` bigint NOT NULL COMMENT '申请人的用户编号', `type` tinyint NOT NULL COMMENT '请假类型', `reason` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请假原因', `start_time` datetime NOT NULL COMMENT '开始时间', `end_time` datetime NOT NULL COMMENT '结束时间', `day` tinyint NOT NULL COMMENT '请假天数', `result` tinyint NOT NULL COMMENT '请假结果', `process_instance_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '流程实例的编号', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', `tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA 请假申请表'; process_instance_id 字段,关联流程引擎的流程实例对应的 ACT_HI_PROCINST 表的 PROC_INST_ID_ 字段 result 字段,请假结果,需要通过 Listener 监听回调结果,稍后来看看 ② 实现业务表的【后端】业务逻辑,具体代码可以看看如下两个类: BpmOALeaveController (opens new window) BpmOALeaveServiceImpl (opens new window) 重点是看流程发起的逻辑,它定义了 /bpm/oa/leave/create 给业务的表单界面调用,UML 时序图如下: 具体的实现代码比较简单,如下图所示: PROCESS_KEY 静态变量:是业务对应的流程模型的编号,稍后会进行创建编号为 oa_leave 的流程模型。 BpmProcessInstanceApi (opens new window) 定义了 #createProcessInstance(...) 方法,用于创建流程实例,业务无需关心底层是 Activiti 还是 Flowable 引擎,甚至未来可能的 Camunda 引擎。 ③ 实现业务表的【前端】业务逻辑,具体代码可以看看如下三个页面: leave/create.vue (opens new window) leave/detail.vue (opens new window) leave/index.vue (opens new window) 另外,在 router/index.js (opens new window) 中定义 create.vue 和 detail.vue 的路由,配置如下: { path: '/bpm', component: Layout, hidden: true, redirect: 'noredirect', children: [{ path: 'oa/leave/create', component: (resolve) => require(['@/views/bpm/oa/leave/create'], resolve), name: '发起 OA 请假', meta: {title: '发起 OA 请假', icon: 'form', activeMenu: '/bpm/oa/leave'} }, { path: 'oa/leave/detail', component: (resolve) => require(['@/views/bpm/oa/leave/detail'], resolve), name: '查看 OA 请假', meta: {title: '查看 OA 请假', icon: 'view', activeMenu: '/bpm/oa/leave'} } ]} 为什么要做独立的 `create.vue` 和 `index.vue` 页面? 创建流程时,需要跳转到 create.vue 页面,填写业务表的信息,才能提交流程。 审批流程时,需要跳转到 detail.vue 页面,查看业务表的信息。 ④ 实现业务表的【后端】监听逻辑,具体可见 BpmOALeaveResultListener (opens new window) 监听器。它实现流程引擎定义的 BpmProcessInstanceResultEventListener (opens new window) 抽象类,在流程实例结束时,回调通知它最终的结果是通过还是不通过。代码如下图: 至此,我们了解了 OALeave 使用业务表单所涉及到的开发,下面我们来定义对应的流程、发起该流程、并审批该流程。 # 2.1 第一步:定义流程 登录账号 admin、密码 admin123 的用户,扮演【管理员】的角色,进行流程的定义。 ① 访问 [工作流程 -> 流程管理 -> 流程模型] 菜单,点击 [新建流程] 按钮,填写流程标识、流程名称。如下图所示: 注意,流程标识需要填 oa_leave。因为在 BpmOALeaveServiceImpl 类中,规定了对应的流程标识为 oa_leave。 ② 点击 [修改流程] 按钮,配置表单类型为业务表单,填写表单提交路由为 /bpm/oa/leave/create(用于发起流程时,跳转的业务表单的路由)、表单查看路由为 /bpm/oa/leave/detail(用于在流程详情中,点击查看表单的路由)。如下图所示: ③ 点击 [设计流程] 按钮,在线设计请假流程模型,包含两个用户任务:领导审批、HR 审批。如下图所示: 可以点击 oa_leave_bpmn.XML 进行下载,然后点击 [打开文件] 按钮,进行导入。 ④ 点击 [分配规则] 按钮,设置用户任务的审批人。 设置【领导审批】的规则类型为自定义脚本 + 流程发起人的一级领导,如下图所示: 设置【HR 审批】的规则类型为岗位 + 人力资源,如下图所示: ⑤ 点击 [发布流程] 按钮,把定义的流程模型部署出去。部署成功后,就可以发起该流程了。 # 2.1 第二步:发起流程 登录账号 admin、密码 admin123 的用户,扮演【员工】的角色,进行流程的发起。 ① 发起业务表单请假流程,两种路径: 访问 [工作流程 -> 任务管理 -> 我的流程] 菜单,点击 [发起流程] 按钮,会跳转到流程模型 oa_leave 配置的表单提交路由。 访问 [工作流程 -> 请假查询] 菜单,点击 [发起请假] 按钮。 ② 填写一个小于等于 3 天的请假,只会走【领导审批】任务;填写一个大于 3 天的请假,在走完【领导审批】任务后,会额外走【HR 审批】任务。 后续的流程,和「1. 请假流程【流程表单】」是基本一致的,这里就不重复赘述,当然你还是要试着跑一跑,了解整个的过程。 # 2.3 第三步:审批流程(领导审批) 略~自己跑 # 2.4 第三步:审批流程(HR 审批) 略~自己跑 # 2. 流程通知 流程在发生变化时,会发送通知给相关的人。目前有三个场景会有通知,通过短信的方式。 # 3. 流程图示例 # 3.1 会签 定义:指同一个审批节点设置多个人,如 ABC 三人,三人会同时收到审批,需全部同意之后,审批才可到下一审批节点。 配置方式如下图所示: 重点是【完成条件】为 ${ nrOfCompletedInstances== nrOfInstances }。 # 3.2 或签 定义:指同一个审批节点设置多个人,如ABC三人,三人会同时收到审批,只要其中任意一人审批即可到下一审批节点。 配置方式如下图所示: 重点是【完成条件】为 ${ nrOfCompletedInstances== 1 }。 # 4. 如何使用 Activiti? Activiti 和 Flowable 提供的 Java API 是基本一致的,例如说 Flowable 的 org.flowable.engine.RepositoryService 对应 Activiti 的 org.activiti.engine .RepositoryService。所以,我们可以修改 import 的包路径来替换。 另外,在项目的老版本,我们也提供了 Activiti 实现,你可以具体参考下: yudao-spring-boot-starter-activiti (opens new window) yudao-module-bpm-biz-activiti (opens new window) # 4. 迭代计划 工作流的基本功能已经开发完成,当然还是有很多功能需要进行建设。已经整理在 https://gitee.com/zhijiantianya/ruoyi-vue-pro/issues/I4UPEU (opens new window) 链接中,你也可以提一些功能的想法。 如果您有参与工作流开发的想法,可以添加我的微信 wangwenbin10 ! 艿艿会带着你做技术方案,Code Review 你的每一行代码的实现。相信在这个过程中,你会收获不错的技术成长! .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 服务保障 Sentinel 报表设计器 ← 服务保障 Sentinel 报表设计器→"},{"title":"报表设计器","path":"/wiki/YuDaoCloud/大屏手册/报表设计器/报表设计器.html","content":"开发指南大屏手册 芋道源码 2022-07-29 目录 报表设计器 数据可视化,一般可以通过报表设计器、或者大屏设计器来实现。本小节,我们来讲解报表设计器的功能开启。 报表设计器,指的是使用 Web 版设计器,通过类似于 Excel 操作风格,通过拖拽完成报表设计。如下图所示: 在项目中,通过集成市面上的报表引擎,实现了报表设计器的能力。目前使用如下: 是否集成 是否开源 JimuReport (opens new window) 已集成 不开源 AJ-Report (opens new window) 集成中 开源 UReport2 (opens new window) 不集成 开源 为什么不使用 UReport2 报表引擎呢? UReport2 基本处于不维护的状态,最后发版时间是 2018 年! # 1. 功能开启 yudao-module-report 实现了报表设计器的能力,开启步骤如下: 第一步,导入报表的 SQL 数据库脚本 第二步,启动 yudao-report-report 服务 第三步,启动报表设计器的前端项目 # 1.1 第一步,导入 SQL 导入 jimureport.mysql5.7.create.sql (opens new window) 脚本,初始化 JimuReport 相关的表结构和数据。如果你是 Oracle、PostgreSQL 等其它数据库,需要自己使用 Navicat 进行转换。 # 1.2 第二步,启动 report 服务 运行该服务的 ReportServerApplication (opens new window) 启动类,看到 \"Init JimuReport Config [ 线程池 ] \" 说明开启成功。 # 1.3 第三步,启动前端项目(AJ-Report) TODO 开发中,预计 4 月份左右。 # 1.3 第三步,启动前端项目(JimuReport) ① JimuReport 前端项目内置在后端项目中,无需启动。 ② 访问 [报表管理 -> 报表设计器] 菜单,可以查看对应的功能。如下图所示: 可以看到,JimuReport 支持数据报表、图形报表、打印设计等能力。 # 2. 如何使用? # 2.1 AJ-Report 报表设计器 TODO 开发中,预计 4 月份左右。 # 2.2 JimuReport 报表设计器 可以查看 JimuReport 的官方文档,主要是: 快速入门 (opens new window) 操作手册(报表设计器) (opens new window) 注意,JimuReport 是商业化的产品,报表设计器的功能应该是免费的,大屏设计器的功能是收费的。 集成 JimuReport 的代码实现? ① 后端:在 jmreport (opens new window) 包下,进行 JimuReport 的集成。 ② 前端:在 @/views/report/jmreport (opens new window) 文件,通过 IFrame 嵌入 JimuReport 界面。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 工作流(Flowable)会签、或签 大屏设计器 ← 工作流(Flowable)会签、或签 大屏设计器→"},{"title":"定时任务 XXL Job","path":"/wiki/YuDaoCloud/微服务手册/定时任务 XXL Job/定时任务 XXL Job.html","content":"开发指南微服务手册 芋道源码 2022-04-03 目录 定时任务 XXL Job 定时任务的使用场景主要如下: 时间驱动处理场景:每分钟扫描超时支付的订单,活动状态刷新,整点发送优惠券。 批量处理数据:按月批量统计报表数据,批量更新短信状态,实时性要求不高。 年度最佳定时任务:每个月初的工资单的推送!!! 项目基于 XXL Job 实现分布式定时任务,支持动态控制任务的添加、修改、开启、暂停、删除、执行一次等操作。 # 1. 如何搭建 XXL Job 调度中心 ① 参见 《芋道 XXL-Job 极简入门》 (opens new window) 文档的「4. 搭建调度中心 」部分。 ② 搭建完成后,需要修改管理后台的 [基础设施 -> 定时任务] 菜单,指向你的 XXL-Job 地址。如下图所示: # 2. 如何编写 XXL Job 定时任务 友情提示:以 yudao-module-system 服务为例子。 # 2.1 引入依赖 在 yudao-module-system-biz 模块的 pom.xml (opens new window) 中,引入 yudao-spring-boot-starter-job 技术组件。如下所示: <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-job</artifactId></dependency> 该组件基于 XXL Job 框架的封装,实现它的 Spring Boot Starter 配置。 # 2.2 添加配置 ① 在 application.yaml (opens new window) 中,添加 xxl.job 配置。如下所示: --- #################### 定时任务相关配置 ####################xxl: job: executor: appname: ${spring.application.name} # 执行器 AppName logpath: ${user.home}/logs/xxl-job/${spring.application.name} # 执行器运行日志文件存储磁盘路径 accessToken: default_token # 执行器通讯TOKEN 注意,xxl.job.accessToken 配置,需要改成你的 XXL Job 调度中心的访问令牌。 ② 在 application-local.yaml (opens new window) 中,添加 xxl.job 配置。如下所示: --- #################### 定时任务相关配置 ####################xxl: job: admin: addresses: http://127.0.0.1:9090/xxl-job-admin # 调度中心部署跟地址 # 2.3 创建 Job 定时任务 参见 《芋道 Spring Boot 定时任务入门》 ( opens new window) 文章的「5. 快速入门 XXL-JOB」部分。 常用的 Cron 表达式如下: 0 0 10,14,16 * * ? 每天上午 10 点,下午 2 点、4 点 0 0/30 9-17 * * ? 朝九晚五工作时间内,每半小时 0 0 12 ? * WED 表示每个星期三中午 12 点 0 0 12 * * ? 每天中午 12 点触发 0 15 10 ? * * 每天上午 10:15 触发 0 15 10 * * ? 每天上午 10:15 触发 0 15 10 * * ? * 每天上午 10:15 触发 0 15 10 * * ? 2005 2005 年的每天上午 10:15 触发 0 * 14 * * ? 在每天下午 2 点到下午 2:59 期间,每 1 分钟触发 0 0/5 14 * * ? 在每天下午 2 点到下午 2:55 期间,每 5 分钟触发 0 0/5 14,18 * * ? 在每天下午 2 点到 2:55 期间和下午 6 点到 6:55 期间,每 5 分钟触发 0 0-5 14 * * ? 在每天下午 2 点到下午 2:05 期间,每 1 分钟触发 0 10,44 14 ? 3 WED 每年三月的星期三的下午 2:10 和 2:44 触发 0 15 10 ? * MON-FRI 周一至周五的上午 10:15 触发 0 15 10 15 * ? 每月15日上午 10:15 触发 0 15 10 L * ? 每月最后一日的上午 10:15 触发 0 15 10 ? * 6L 每月的最后一个星期五上午 10:15 触发 0 15 10 ? * 6L 2002-2005 2002 年至 2005 年,每月的最后一个星期五上午 10:15 触发 0 15 10 ? * 6#3 每月的第三个星期五上午 10:15 触发 疑问:为什么 Job 查询数据库时,报多租户的错误? 需要声明 @TenantJob (opens new window) 注解在 Job 类上,实现并行遍历每个租户,执行定时任务的逻辑。 更多多租户的内容,可见 《开发指南 —— SaaS 多租户》 文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 17:01:04 消息队列 RocketMQ 服务调用 Feign ← 消息队列 RocketMQ 服务调用 Feign→"},{"title":"服务保障 Sentinel","path":"/wiki/YuDaoCloud/微服务手册/服务保障 Sentinel/服务保障 Sentinel.html","content":"None"},{"title":"服务网关 Spring Cloud Gateway","path":"/wiki/YuDaoCloud/微服务手册/服务网关 Spring Cloud Gateway/服务网关 Spring Cloud Gateway.html","content":"开发指南微服务手册 芋道源码 2022-12-31 目录 服务网关 Spring Cloud Gateway yudao-gateway (opens new window) 模块,基于 Spring Cloud Gateway 构建 API 服务网关,提供用户认证、服务路由、灰度发布、访问日志、异常处理等功能。 友情提示:如何学习 Spring Cloud Gateway? 阅读 《芋道 Spring Cloud 网关 Spring Cloud Gateway 入门 》 (opens new window) 文章。 # 1. 服务路由 新建服务后,在 application.yaml (opens new window) 配置文件中,需要添加该服务的路由配置。示例如下图: # 2. 用户认证 由 filter/security (opens new window) 包实现,无需配置。 TokenAuthenticationFilter 会获得请求头中的 Authorization 字段,调用 system-server 服务,进行用户认证。 如果认证成功,会将用户信息放到 login-user 请求头,转发到后续服务。后续服务可以从 login-user 请求头,解析 (opens new window)到用户信息。 如果认证失败,依然会转发到后续服务,由该服务决定是否需要登录,是否需要校验权限。 考虑到性能,API 网关会本地缓存 (opens new window) Token 与用户信息,每次收到 HTTP 请求时,异步从 system-server 刷新本地缓存。 # 3. 灰度发布 由 filter/grey (opens new window) 包实现,实现原理如下: 所以在使用灰度时,如要如下配置: ① 第一步,【网关】配置服务的路由配置使用 grebLb:// 协议,指向灰度服务。例如说: ② 第二步,【服务】配置服务的版本 version 配置。例如说: ③ 第三步,请求 API 网关时,请求头带上想要 version 版本。 可能想让用户的请求带上 version 请求头比较难,可以通过 Spring Cloud Gateway 修改请求头,通过 User Agent、Cookie、登录用户等信息,来判断用户想要的版本。详细的解析,可见 《Spring Cloud Gateway 实现灰度发布功能 》 (opens new window) 文章。 # 4. 访问日志 由 filter/logging (opens new window) 包实现,无需配置。 每次收到 HTTP 请求时,会打印访问日志,包括 Request、Response、用户等信息。如下图所示: # 5. 异常处理 由 GlobalExceptionHandler (opens new window) 累实现,无需配置。 请求发生异常时,会翻译异常信息,返回给用户。例如说: { "code": 500, "data": null, "msg": "系统异常"} # 6. 动态路由 在 Nacos 配置发生变化时,Spring Cloud Alibaba Nacos Config 内置的监听器,会监听到配置刷新,最终触发 Gateway 的路由信息刷新。 参见 《芋道 Spring Cloud 网关 Spring Cloud Gateway 入门 》 ( opens new window) 博客的「6. 基于配置中心 Nacos 实现动态路由」小节。 使用方式:在 Nacos 新增 DataId 为 gateway-server.yaml 的配置,修改 spring.cloud.gateway.routes 配置项。 # 7. Swagger 接口文档 基于 Knife4j 实现 Swagger 接口文档的 网关聚合 ( opens new window) 。需要路由配置如下: 管理后台的接口:- RewritePath=/admin-api/{服务的基础路由}/v2/api-docs, /v2/api-docs 用户 App 的接口:- RewritePath=/app-api/{服务的基础路由}/v2/api-docs, /v2/api-docs Knife4j 配置: knife4j.gateway.routes 添加 浏览器访问 http://127.0.0.1:48080/doc.html ( opens new window) 地址,可以看到所有接口的信息。如下图所示: # 7.1 如何调用 〇 点击左边「文档管理 - 全局参数设置」菜单,设置 header-id 和 Authorization 请求头。如下图所示: tenant-id:1Authorization: Bearer test1 添加完后,需要 F5 刷新下网页,否则全局参数不生效。 ① 点击任意一个接口,进行接口的调用测试。这里,使用「管理后台 - 用户个中心」的“获得登录用户信息”举例子。 ② 点击左侧「调试」按钮,并将请求头部的 header-id 和 Authorization 勾选上。 其中,header-id 为租户编号,Authorization 的 \"Bearer test\" 后面为用户编号(模拟哪个用户操作)。 ③ 点击「发送」按钮,即可发起一次 API 的调用。 # 7.2 如何关闭 如果想要禁用 Swagger 功能,可通过 knife4j.gateway.enabled 配置项为 false。一般情况下,建议 prod 生产环境进行禁用,避免发生安全问题。 # 8. Cors 跨域处理 由 filter/cors (opens new window) 包实现,无需配置。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 09:21:21 服务调用 Feign 分布式事务 Seata ← 服务调用 Feign 分布式事务 Seata→"},{"title":"分布式事务 Seata","path":"/wiki/YuDaoCloud/微服务手册/分布式事务 Seata/分布式事务 Seata.html","content":"None"},{"title":"用户体系","path":"/wiki/YuDaoCloud/后端手册/用户体系/用户体系.html","content":"开发指南后端手册 芋道源码 2022-03-28 目录 用户体系 系统提供了 2 种类型的用户,分别满足对应的管理后台、用户 App 场景。 AdminUser 管理员用户,前端访问 yudao-ui-admin (opens new window) 管理后台,后端访问 /admin-api/** RESTful API 接口。 MemberUser 会员用户,前端访问 yudao-ui-user (opens new window) 用户 App,后端访问 /app-api/** RESTful API 接口。 虽然是不同类型的用户,他们访问 RESTful API 接口时,都通过 Token 认证机制,具体可见 《开发指南 —— 功能权限》。 # 1. 表结构 2 种类型的时候,采用不同数据库的表进行存储,管理员用户对应 system_users (opens new window) 表,会员用户对应 member_user (opens new window) 表。如下图所示: 为什么不使用统一的用户表? 确实可以采用这样的方案,新增 type 字段区分用户类型。不同用户类型的信息字段,例如说上图的 dept_id、post_ids 等等,可以增加拓展表,或者就干脆“冗余”在用户表中。 不过实际项目中,不同类型的用户往往是不同的团队维护,并且这也是绝大多团队的实践,所以我们采用了多个用户表的方案。 如果表需要关联多种类型的用户,例如说上述的 system_oauth2_access_token 访问令牌表,可以通过 user_type 字段进行区分。并且 user_type 对应 UserTypeEnum (opens new window) 全局枚举,代码如下: # 2. 如何获取当前登录的用户? 使用 SecurityFrameworkUtils (opens new window) 提供的如下方法,可以获得当前登录用户的信息: /** * 【最常用】获得当前用户的编号,从上下文中 * * @return 用户编号 */@Nullablepublic static Long getLoginUserId() { /** 省略实现 */ }/** * 获取当前用户 * * @return 当前用户 */@Nullablepublic static LoginUser getLoginUser() { /** 省略实现 */ }/** * 获得当前用户的角色编号数组 * * @return 角色编号数组 */@Nullablepublic static Set<Long> getLoginUserRoleIds() { /** 省略实现 */ } # 3. 账号密码登录 # 3.1 管理后台的实现 使用 username 账号 + password 密码进行登录,由 AuthController ( opens new window) 提供 /admin-api/system/auth/login 接口。代码如下: @PostMapping("/login")@Operation(summary = "使用账号密码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) { String token = authService.login(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} 如何关闭验证码? 参见 《后端手册 —— 验证码》 文档。 # 3.2 用户 App 的实现 使用 mobile 手机 + password 密码进行登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/login 接口。代码如下: @PostMapping("/login")@Operation(summary = "使用手机 + 密码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> login(@RequestBody @Valid AppAuthLoginReqVO reqVO) { String token = authService.login(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AppAuthLoginRespVO.builder().token(token).build());} # 4. 手机验证码登录 # 4.1 管理后台的实现 ① 使用 mobile 手机号获得验证码,由 AuthController ( opens new window) 提供 /admin-api/system/auth/send-sms-code 接口。代码如下: @PostMapping("/send-sms-code")@Operation(summary = "发送手机验证码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<Boolean> sendSmsCode(@RequestBody @Valid AuthSendSmsReqVO reqVO) { authService.sendSmsCode(getLoginUserId(), reqVO); return success(true);} ② 使用 mobile 手机 + code 验证码进行登录,由 AppAuthController (opens new window) 提供 /admin-api/system/auth/sms-login 接口。代码如下: @PostMapping("/sms-login")@Operation(summary = "使用短信验证码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) { String token = authService.smsLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} # 4.2 用户 App 的实现 ① 使用 mobile 手机号获得验证码,由 AppAuthController ( opens new window) 提供 /app-api/member/auth/send-sms-code 接口。代码如下: @PostMapping("/send-sms-code")@Operation(summary = "发送手机验证码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<Boolean> sendSmsCode(@RequestBody @Valid AppAuthSendSmsReqVO reqVO) { authService.sendSmsCode(getLoginUserId(), reqVO); return success(true);} ② 使用 mobile 手机 + code 验证码进行登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/sms-login 接口。代码如下: @PostMapping("/sms-login")@Operation(summary = "使用手机 + 验证码登录")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> smsLogin(@RequestBody @Valid AppAuthSmsLoginReqVO reqVO) { String token = authService.smsLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AppAuthLoginRespVO.builder().token(token).build());} 如果用户未注册,会自动使用手机号进行注册会员用户。所以,/app-api/member/user/sms-login 接口也提供了用户注册的功能。 # 5. 三方登录 详细参见 《开发指南 —— 三方登录》 文章。 # 5.1 管理后台的实现 ① 跳转第三方平台,来获得三方授权码,由 AuthController (opens new window) 提供 /admin-api/system/auth/social-auth-redirect 接口。代码如下: @GetMapping("/social-auth-redirect")@Operation(summary = "社交授权的跳转")@Parameters({ @Parameter(name = "type", description = "社交类型", required = true), @Parameter(name = "redirectUri", description = "回调路径")})public CommonResult<String> socialAuthRedirect(@RequestParam("type") Integer type, @RequestParam("redirectUri") String redirectUri) { return CommonResult.success(socialUserService.getAuthorizeUrl(type, redirectUri));} ② 使用 code 三方授权码进行快登录,由 AuthController (opens new window) 提供 /admin-api/system/auth/social-login 接口。代码如下: @PostMapping("/social-login")@Operation(summary = "社交快捷登录,使用 code 授权码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AuthLoginRespVO> socialQuickLogin(@RequestBody @Valid AuthSocialQuickLoginReqVO reqVO) { String token = authService.socialLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} ③ 使用 socialCode 三方授权码 + username + password 进行绑定登录,直接使用 /admin-api/system/auth/login 账号密码登录的接口,区别在于额外带上 socialType + socialCode + socialState 参数。 # 5.2 用户 App 的实现 ① 跳转第三方平台,来获得三方授权码,由 AppAuthController (opens new window) 提供 /app-api/member/auth/social-auth-redirect 接口。代码如下: @GetMapping("/social-auth-redirect")@Operation(summary = "社交授权的跳转")@Parameters({ @Parameter(name = "type", description = "社交类型", required = true), @Parameter(name = "redirectUri", description = "回调路径")})public CommonResult<String> socialAuthRedirect(@RequestParam("type") Integer type, @RequestParam("redirectUri") String redirectUri) { return CommonResult.success(socialUserService.getAuthorizeUrl(type, redirectUri));} ② 使用 code 三方授权码进行快登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/social-login 接口。代码如下: @PostMapping("/social-login")@Operation(summary = "社交快捷登录,使用 code 授权码")@OperateLog(enable = false) // 避免 Post 请求被记录操作日志public CommonResult<AppAuthLoginRespVO> socialQuickLogin(@RequestBody @Valid AuthSocialQuickLoginReqVO reqVO) { String token = authService.socialLogin(reqVO, getClientIP(), getUserAgent()); // 返回结果 return success(AuthLoginRespVO.builder().token(token).build());} ③ 使用 socialCode 三方授权码 + username + password 进行绑定登录,直接使用 /app-api/system/auth/login 手机验证码登录的接口,区别在于额外带上 socialType + socialCode + socialState 参数。 ④ 【微信小程序特有】使用 phoneCode + loginCode 实现获取手机号并一键登录,由 AppAuthController (opens new window) 提供 /app-api/member/auth/weixin-mini-app-login 接口。代码如下: @PostMapping("/weixin-mini-app-login")@Operation(summary = "微信小程序的一键登录")public CommonResult<AppAuthLoginRespVO> weixinMiniAppLogin(@RequestBody @Valid AppAuthWeixinMiniAppLoginReqVO reqVO) { return success(authService.weixinMiniAppLogin(reqVO));} # 6. 注册 # 6.1 管理后台的实现 管理后台暂不支持用户注册,而是通过在 [系统管理 -> 用户管理] 菜单,进行添加用户,由 UserController ( opens new window) 提供 /admin-api/system/user/create 接口。代码如下: @PostMapping("/create")@Operation(summary = "新增用户")@PreAuthorize("@ss.hasPermission('system:user:create')")public CommonResult<Long> createUser(@Valid @RequestBody UserCreateReqVO reqVO) { Long id = userService.createUser(reqVO); return success(id);} # 6.2 用户 App 的实现 手机验证码登录时,如果用户未注册,会自动使用手机号进行注册会员用户。所以, /app-api/system/user/sms-login 接口也提供了用户注册的功能。 # 7. 用户登出 用户登出的功能,统一使用 Spring Security 框架,通过删除用户 Token 的方式来实现。代码如下: 差别在于使用的 API 接口不同,管理员用户使用 /admin-api/system/logout,会员用户使用 /app-api/member/logout。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 22:52:02 数据权限 三方登录 ← 数据权限 三方登录→"},{"title":"服务调用 Feign","path":"/wiki/YuDaoCloud/微服务手册/服务调用 Feign/服务调用 Feign.html","content":"开发指南微服务手册 芋道源码 2022-12-31 目录 服务调用 Feign yudao-spring-boot-starter-rpc (opens new window) 技术组件,基于 Feign 实现服务之间的调用。 为什么不使用 Dubbo 呢? Feign 通用性更强,学习成本更低,对于绝大多数场景,都能够很好的满足需求。虽然 Dubbo 提供的性能更强,特性更全,但都是非必须的。 目前国内 95% 左右都是采用 Feign,而 Dubbo 的使用率只有 5% 左右。所以,我们也选择了 Feign。 如果你对 Feign 了解较少,可以阅读 《芋道 Spring Cloud 声明式调用 Feign 入门》 (opens new window) 系统学习。 # 1. RPC 使用规约 本小节,我们来讲解下项目中 RPC 使用的规约。 # 1.1 API 前缀 API 使用 HTTP 协议,所有的 API 前缀,都以 /rpc-api (opens new window) 开头,方便做统一的全局处理。 # 1.2 API 权限 服务之间的调用,不需要进行权限校验,所以需要在每个服务的 SecurityConfiguration 权限配置类中,添加如下配置: // RPC 服务的安全配置registry.antMatchers(ApiConstants.PREFIX + "/**").permitAll(); # 1.3 API 全局返回 所有 API 接口返回使用 CommonResult ( opens new window) 返回,和前端 RESTful API 保持统一。例如说: public interface DeptApi { @GetMapping(PREFIX + "/get") @Operation(summary = "获得部门信息") @Parameter(name = "id", description = "部门编号", required = true, example = "1024") CommonResult<DeptRespDTO> getDept(@RequestParam("id") Long id);} # 1.4 用户传递 服务调用时,已经封装 Feign 将用户信息通过 HTTP 请求头 login-user 传递,通过 LoginUserRequestInterceptor ( opens new window) 类实现。 这样,被调用服务,可以通过 SecurityFrameworkUtils 获取到用户信息,例如说: #getLoginUser() 方法,获取当前用户。 #getLoginUserId() 方法,获取当前用户编号。 # 2. 如何定义一个 API 接口 本小节,我们来讲解下如何定义一个 API 接口。以 AdminUserApi 提供的 getUser 接口来举例子。 # 2.1 服务提供者 AdminUserApi 由 system-server 服务所提供。 # 2.1.1 ApiConstants 在 yudao-module-system-api 模块,创建 ApiConstants ( opens new window) 类,定义 API 相关的枚举。代码如下: public class ApiConstants { /** * 服务名 * * 注意,需要保证和 spring.application.name 保持一致 */ public static final String NAME = "system-server"; public static final String PREFIX = RpcConstants.RPC_API_PREFIX + "/system"; public static final String VERSION = "1.0.0";} # 2.1.2 AdminUserApi 在 yudao-module-system-api 模块,创建 AdminUserApi ( opens new window) 类,定义 API 接口。代码如下: @FeignClient(name = ApiConstants.NAME) // ① @FeignClient 注解@Tag(name = "RPC 服务 - 管理员用户") // ② Swagger 接口文档public interface AdminUserApi { String PREFIX = ApiConstants.PREFIX + "/user"; @GetMapping(PREFIX + "/get") // ③ Spring MVC 接口注解 @Operation(summary = "通过用户 ID 查询用户") // ② Swagger 接口文档 @Parameter(name = "id", description = "部门编号", required = true, example = "1024") // ② Swagger 接口文档 CommonResult<AdminUserRespDTO> getUser(@RequestParam("id") Long id);} 另外,需要创建 AdminUserRespDTO (opens new window) 类,定义用户 Response DTO。代码如下: @Datapublic class AdminUserRespDTO { /** * 用户ID */ private Long id; /** * 用户昵称 */ private String nickname; /** * 帐号状态 * * 枚举 {@link CommonStatusEnum} */ private Integer status; /** * 部门ID */ private Long deptId; /** * 岗位编号数组 */ private Set<Long> postIds; /** * 手机号码 */ private String mobile;} # 2.1.3 AdminUserRpcImpl 在 yudao-module-system-biz 模块,创建 AdminUserRpcImpl ( opens new window) 类,实现 API 接口。代码如下: @RestController // 提供 RESTful API 接口,给 Feign 调用@Validatedpublic class AdminUserApiImpl implements AdminUserApi { @Resource private AdminUserService userService; @Override public CommonResult<AdminUserRespDTO> getUser(Long id) { AdminUserDO user = userService.getUser(id); return success(UserConvert.INSTANCE.convert4(user)); }} # 2.2 服务消费者 bpm-server 服务,调用了 AdminUserApi 接口。 # 2.2.1 引入依赖 在 yudao-module-bpm-biz 模块的 pom.xml ( opens new window),引入 yudao-module-system-api 模块的依赖。代码如下: <dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-module-system-api</artifactId> <version>${revision}</version></dependency> # 2.2.2 引用 API 在 yudao-module-bpm-biz 模块,创建 RpcConfiguration ( opens new window) 配置类,注入 AdminUserApi 接口。代码如下: @Configuration(proxyBeanMethods = false)@EnableFeignClients(clients = {AdminUserApi.class.class})public class RpcConfiguration {} # 2.2.3 调用 API 例如说, BpmTaskServiceImpl ( opens new window) 调用了 AdminUserApi 接口,代码如下: @Servicepublic class BpmTaskServiceImpl implements BpmTaskService { @Resource private AdminUserApi adminUserApi; @Override public void updateTaskExtAssign(Task task) { // ... 省略非关键代码 AdminUserRespDTO startUser = adminUserApi.getUser(id).getCheckedData(); }} .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/04, 23:05:33 定时任务 XXL Job 服务网关 Spring Cloud Gateway ← 定时任务 XXL Job 服务网关 Spring Cloud Gateway→"},{"title":"注册中心 Nacos","path":"/wiki/YuDaoCloud/微服务手册/注册中心 Nacos/注册中心 Nacos.html","content":"开发指南微服务手册 芋道源码 2022-12-31 目录 注册中心 Nacos 项目使用 Nacos 作为配置中心,实现服务的注册发现。 # 1. 搭建 Nacos Server ① 参考《芋道 Nacos 极简入门》 (opens new window)文章的「2. 单机部署(最简模式)」或「3. 单机部署(基于 MySQL 数据库)」小节。 ② 点击 Nacos 控制台的 [命名空间] 菜单,创建一个 ID 和名字都为 dev 的命名空间,稍后会使用到。如下图所示: # 2. 项目接入 Nacos 友情提示:以 yudao-module-system 服务为例子。 # 2.1 引入依赖 在 yudao-module-system-biz 模块的 pom.xml (opens new window) 中,引入 Nacos 对应的依赖。如下所示: <!-- Spring Cloud 基础 --><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId></dependency><!-- Registry 注册中心相关 --><dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency> # 2.2 添加配置 在 bootstrap-local.yaml ( opens new window) 中,添加 nacos.config 配置。如下所示: --- #################### 注册中心相关配置 ####################spring: cloud: nacos: server-addr: 127.0.0.1:8848 discovery: namespace: dev # 命名空间。这里使用 dev 开发环境 metadata: version: 1.0.0 # 服务实例的版本号,可用于灰度发布 spring.cloud.nacos.discovery.namespace 配置项:设置为 dev,就是刚创建的命名空间 # 2.3 启动项目 运行 SystemServerApplication 类,将 system-server 服务启动。 然后,在 Nacos 控制台的 [服务管理 -> 服务列表] 菜单,就可以看到该服务实例。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 11:00:42 地区 & IP 库 配置中心 Nacos ← 地区 & IP 库 配置中心 Nacos→"},{"title":"【v1.0.0】2021.05.03","path":"/wiki/YuDaoCloud/更新日志/【v1.0.0】2021.05.03/【v1.0.0】2021.05.03.html","content":"开发指南更新日志 芋道源码 2022-03-07 目录 【v1.0.0】2021.05.03 # 初始版本 第一个版本,基于 RuoYi-Vue (opens new window) 重构,主要是三个方面: 代码的重构 技术选型的调整 后台功能的新增 因此,v1.0.0 的更新日志,分成这三方面来写。 # 代码的重构 调整整体代码结构,将多个 Maven Module 合并为单个,使用 Java package 进行拆分隔离,如 图 (opens new window) 所示。原因是:随着业务逻辑的逐步复杂,多个 Maven Module 的依赖关系的管理,会是一个很大的问题。 拆分 framework (opens new window) 为多个 Maven Module,按照 Web (opens new window)、Security (opens new window)、MyBatis (opens new window)、Redis (opens new window) 等不同组件,进行封装与拓展。 基于 JUnit5 (opens new window) 与 Mockito (opens new window),实现单元测试,保证功能的正确性,与代码的可维护性。一直自动化,一直爽! 增加 SpringBoot 多环境的配置文件,提供完善的 deploy.sh (opens new window) 部署脚本,以及 Jenkins 部署教程 (opens new window)。 优化 Spring Security (opens new window) 实现权限的代码,提升可读性和维护性。 增加本地缓存(菜单、角色、数据字典等等),提升性能。通过 Redis 订阅发布,实现缓存的实时刷新。 增加 VO (opens new window) 类,作为 API 接口的响应对象,避免数据库实体与前端的直接耦合。 优化 操作日志 (opens new window),支持读取 Swagger 作为日志的内容。 优化 定时任务 (opens new window),支持执行失败的重试,更完善的执行日志。 优化 codegen (opens new window) 代码生成器,在原先生成 Controller、Service、Mapper、数据库实体、Vue 代码的基础上,额外生成 VO、单元测试的代码。 调整文件改用 数据库 (opens new window) 存储,而不是文件系统。原因是,项目在部署多个服务节点时,文件需要做同步。未来,会增加阿里云、七牛云等存储云服务。 去除原有数据库的连表查询、递归查询,改为单表操作的方式,多次读取 + 内存拼接。 优化 Java 代码的格式,解决 IDEA 代码告警的问题。 # 后台功能的新增 增加 API 访问 (opens new window)与异常 (opens new window)日志,方便排查线上 API 的问题。 增加 全局错误码 (opens new window),统一业务异常的管理。管理后台会支持错误码的管理,支持提示文案的可配置化。 增加 短信模块 (opens new window),提供短信渠道、短息模板、短信日志的管理,对接阿里云、云片等主流短信平台。 增加 Redis Key (opens new window) 的管理,知道项目中使用到的 Redis Key 的格式、数据类型、过期时间、描述等等信息。 # 技术选型的调整 将 Spring Boot 版本,从 2.1.3 升级到 2.4.5 最新。 增加 bom (opens new window) 文件,统一 Maven 的依赖管理。 引入 MyBatis Plus (opens new window) 组件,简化 MyBatis 使用,提升开发效率。 引入 Redisson (opens new window) 组件,作为 Redis 的客户端,提供更强大的 Redis 操作。 基于 Redis 实现分布式消息队列的功能。接入 Redis Pub/Sub (opens new window) 实现广播消费,接入 Redis Stream (opens new window) 实现集群消费。 去除 fastjson (opens new window),统一使用 Jackson (opens new window) 作为 JSON 库,老爆安全漏洞的悲伤。 引入 MapStruct (opens new window) 组件,实现数据库实体与 VO 类之间的转换。 引入 Lombok (opens new window) 组件,生成 setter、getter 等常用方法,去除冗余代码。 引入 Spring Async (opens new window) 功能,实现异步任务。例如说,异步记录 API 访问日志、管理员操作日志等等。 魔改 Apollo (opens new window) 组件,接入本地数据库,实现内嵌的配置中心。通俗的说,我们可以将原本添加到 application.yaml 的配置项,改为添加到数据库中,项目启动会进行读取。 引入 Hutool (opens new window) 组件,去除大量重复的工具类,也避免原本 Util 存在一些 bug 的问题。 引入 Screw (opens new window) 组件,实现数据库文档的生成,虽然好像现在用途较少。 引入 EasyExcel (opens new window),提供 Excel 的导入与导出的功能。 实现 Idempotent (opens new window) 组件,实现幂等的功能,可以用来解决 HTTP 重复请求的问题。 引入 Lock4J (opens new window),实现声明式的分布式锁的功能。虽然 Redisson 内置了分布式锁的功能,但是通过注解声明一个 @Lock4j 注解的使用方式,更加便利,且满足绝大多数场景。 去除原有的服务监控,使用 SpringBoot Admin (opens new window) 替代,提供更完整的监控能力。 引入 SkyWalking (opens new window) 组件,实现链路追踪和日志服务的功能。通过链路追踪,我们可以看到一个 API 请求涉及到的 MySQL、Redis 等操作;通过日志服务,我们可以方便的看到每个服务实例的日志。 引入 Resilience4j (opens new window) 组件,实现限流、熔断等功能,保证服务的稳定性。 引入 Knife4j (opens new window),美化接口文档。原本所有 API 接口文档是缺失的,已经全部补全,可见 http://api-dashboard.yudao.iocoder.cn/doc.html (opens new window) 地址。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.1.0】2021.10.25 ← 【v1.1.0】2021.10.25"},{"title":"【v1.1.0】2021.10.25","path":"/wiki/YuDaoCloud/更新日志/【v1.1.0】2021.10.25/【v1.1.0】2021.10.25.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.1.0】2021.10.25 # 增加管理后台的企业微信、钉钉等社交登录 新增管理后台的企业微信、钉钉等社交登录 新增用户前台(例如说,用户使用的小程序)的后端项目 yudao-user-server 新增公共服务 yudao-core-service 项目,通过 Jar 包的方式,提供 yudao-user-server 和 yudao-admin-server 的共享逻辑的复用 新增用户前台的手机登录、验证码登录 修复管理后台的用户头像上传 404 的问题,原因是请求路径不对 修复用户导入失败的问题,原因是 Lombok 链式与 cglib 读取属性有冲突 修复阿里云短信发送失败的问题,原因是 Opentracing 依赖的版本太低,调整成 0.31.0 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.2.0】2021.12.15 【v1.0.0】2021.05.03 ← 【v1.2.0】2021.12.15 【v1.0.0】2021.05.03→"},{"title":"消息队列 RocketMQ","path":"/wiki/YuDaoCloud/微服务手册/消息队列 RocketMQ/消息队列 RocketMQ.html","content":"开发指南微服务手册 芋道源码 2022-04-03 目录 消息队列 RocketMQ yudao-spring-boot-starter-mq (opens new window) 技术组件,基于 RocketMQ 实现分布式消息队列,支持集群消费、广播消费。 友情提示:我对消息队列不了解,怎么办? ① 项目主要使用 RocketMQ 作为消息队列,所以可以学习下文章: 《芋道 Spring Cloud Alibaba 消息队列 RocketMQ 入门》 (opens new window) 《芋道 Spring Cloud Alibaba 事件总线 Bus RocketMQ 入门》 (opens new window) ② 如果你想替换使用 Kafka 或者 RabbitMQ,可以参考下文章: 《芋道 Spring Cloud 消息队列 Kafka 入门 》 (opens new window) 《芋道 Spring Cloud 事件总线 Bus Kafka 入门》 (opens new window) 《芋道 Spring Cloud 消息队列 RabbitMQ 入门 》 (opens new window) 《芋道 Spring Cloud 事件总线 Bus RabbitMQ 入门》 (opens new window) # 1. 集群消费 集群消费,是指消息发送到 RocketMQ 时,有且只会被一个消费者(应用 JVM 实例)收到,然后消费成功。如下图所示: # 1.1 使用场景 集群消费在项目中的使用场景,主要是提供可靠的、可堆积的异步任务的能力。例如说: 短信模块,使用它异步 (opens new window)发送短信。 邮件模块,使用它异步 (opens new window)发送邮件。 相比 《开发指南 —— 异步任务》 来说,Spring Async 在 JVM 实例重启时,会导致未执行完的任务丢失。而集群消费,因为消息是存储在 RocketMQ 中,所以不会存在该问题。 # 1.2 实战案例 以短信模块异步发送短息为例子,讲解集群消费的使用。 # 1.3.1 引入依赖 在 yudao-module-system-biz 模块的 pom.xml (opens new window) 中,引入 yudao-spring-boot-starter-mq 技术组件。如下所示: <!-- 消息队列相关 --><dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-mq</artifactId></dependency> # 1.3.2 添加配置 ① 在 application.yaml ( opens new window) 中,添加 spring.cloud.stream 配置。如下所示: --- #################### MQ 消息队列相关配置 ####################spring: cloud: # Spring Cloud Stream 配置项,对应 BindingServiceProperties 类 stream: function: definition: smsSendConsumer; # Binding 配置项,对应 BindingProperties Map bindings: smsSend-out-0: destination: system_sms_send smsSendConsumer-in-0: destination: system_sms_send group: system_sms_send_consumer_group # Spring Cloud Stream RocketMQ 配置项 rocketmq: default: # 默认 bindings 全局配置 producer: # RocketMQ Producer 配置项,对应 RocketMQProducerProperties 类 group: system_producer_group # 生产者分组 send-type: SYNC # 发送模式,SYNC 同步 注意,带有 sms 关键字的,都是和短信发送相关的配置。 ② 在 application-local.yaml (opens new window) 中,添加 spring.cloud.stream 配置。如下所示: --- #################### MQ 消息队列相关配置 ####################spring: cloud: stream: rocketmq: # RocketMQ Binder 配置项,对应 RocketMQBinderConfigurationProperties 类 binder: name-server: 127.0.0.1:9876 # RocketMQ Namesrv 地址 # 1.3.3 SmsSendMessage 在 yudao-module-system-biz 的 mq/message/sms ( opens new window) 包下,创建 SmsSendMessage ( opens new window) 类,短信发送消息。代码如下: @Datapublic class SmsSendMessage { /** * 短信日志编号 */ @NotNull(message = "短信日志编号不能为空") private Long logId; /** * 手机号 */ @NotNull(message = "手机号不能为空") private String mobile; /** * 短信渠道编号 */ @NotNull(message = "短信渠道编号不能为空") private Long channelId; /** * 短信 API 的模板编号 */ @NotNull(message = "短信 API 的模板编号不能为空") private String apiTemplateId; /** * 短信模板参数 */ private List<KeyValue<String, Object>> templateParams;} # 1.3.4 SmsProducer ① 在 yudao-module-system-biz 的 mq/producer/sms ( opens new window) 包下,创建 SmsProducer ( opens new window) 类,SmsSendMessage 的 Producer 生产者,核心是使用 StreamBridge 发送 SmsSendMessage 消息。代码如下图: @Componentpublic class SmsProducer { @Resource private StreamBridge streamBridge; /** * 发送 {@link SmsSendMessage} 消息 * * @param logId 短信日志编号 * @param mobile 手机号 * @param channelId 渠道编号 * @param apiTemplateId 短信模板编号 * @param templateParams 短信模板参数 */ public void sendSmsSendMessage(Long logId, String mobile, Long channelId, String apiTemplateId, List<KeyValue<String, Object>> templateParams) { SmsSendMessage message = new SmsSendMessage().setLogId(logId).setMobile(mobile); message.setChannelId(channelId).setApiTemplateId(apiTemplateId).setTemplateParams(templateParams); streamBridge.send("smsSend-out-0", message); }} 注意,这里的 smsSend-out-0 和上述的配置文件是对应的噢。 ② 发送短信时,需要使用 SmsProducer 发送消息。如下图所示: # 1.3.4 SmsSendConsumer 在 yudao-module-system-biz 的 mq/consumer/sms (opens new window) 包下,创建 SmsSendConsumer (opens new window) 类,SmsSendMessage 的 Consumer 消费者。代码如下图: @Component@Slf4jpublic class SmsSendConsumer implements Consumer<SmsSendMessage> { @Resource private SmsSendService smsSendService; @Override public void accept(SmsSendMessage message) { log.info("[accept][消息内容({})]", message); smsSendService.doSendSms(message); }} # 2. 广播消费 广播消费,是指消息发送到 RocketMQ 时,所有消费者(应用 JVM 实例)收到,然后消费成功。如下图所示: # 2.1 使用场景 例如说,在应用中,缓存了数据字典等配置表在内存中,可以通过 RocketMQ 广播消费,实现每个应用节点都消费消息,刷新本地内存的缓存。 又例如说,我们基于 WebSocket 实现了 IM 聊天,在我们给用户主动发送消息时,因为我们不知道用户连接的是哪个提供 WebSocket 的应用,所以可以通过 RocketMQ 广播消费。每个应用判断当前用户是否是和自己提供的 WebSocket 服务连接,如果是,则推送消息给用户。 # 2.2 使用方式一:Bus 基于 RocketMQ 的广播消费,可以使用 Spring Cloud Bus 实现。 Spring Cloud Bus 是什么? Spring Cloud Bus 是 Spring Cloud 的一个子项目,它的作用是将分布式系统的节点与轻量级消息系统链接起来,用于广播状态变化,事件推送等。 它的实现原理是,通过 Spring Cloud Stream 将消息发送到消息代理(如 RabbitMQ、Kafka、RocketMQ),然后通过 Spring Cloud Bus 的事件监听,监听到消息后,进行处理。 以角色的本地缓存刷新为例子,讲解下 Spring Cloud Bus 如何使用 RocketMQ 广播消费。 # 2.2.1 引入依赖 在 yudao-module-system-biz 模块的 pom.xml ( opens new window) 中,引入 yudao-spring-boot-starter-mq 技术组件。如下所示: <!-- 消息队列相关 --><dependency> <groupId>cn.iocoder.cloud</groupId> <artifactId>yudao-spring-boot-starter-mq</artifactId></dependency> # 2.2.2 添加配置 在 application.yaml ( opens new window) 中,添加 spring.cloud.bus 配置。如下所示: spring: cloud: # Spring Cloud Bus 配置项,对应 BusProperties 类 bus: enabled: true # 是否开启,默认为 true id: ${spring.application.name}:${server.port} # 编号,Spring Cloud Alibaba 建议使用“应用:端口”的格式 destination: springCloudBus # 目标消息队列,默认为 springCloudBus # 2.2.3 编写代码 参见 《开发指南 —— 本地缓存》 文章的「3. 实时刷新缓存」小节。 # 2.2 使用方式二:Stream 基于 RocketMQ 的广播消费,也可以使用 Spring Cloud Stream 实现。 Spring Cloud Stream 是什么? Spring Cloud Stream 是 Spring Cloud 的一个子项目,它的作用是为微服务应用构建消息驱动能力。 使用方式,和「1.2 实战案例」小节是一样的,只是需要在 application.yaml 配置文件中,添加 spring.cloud.stream.rocketmq.bindings.<channelName>.consumer.broadcasting ( opens new window) 配置项为 true。 由于项目中暂时使用该方式,文档后续补充。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2022/12/31, 12:11:24 配置中心 Nacos 定时任务 XXL Job ← 配置中心 Nacos 定时任务 XXL Job→"},{"title":"配置中心 Nacos","path":"/wiki/YuDaoCloud/微服务手册/配置中心 Nacos/配置中心 Nacos.html","content":"开发指南微服务手册 芋道源码 2022-04-04 目录 配置中心 Nacos # 1. 配置中心 Nacos 项目使用 Nacos 作为配置中心,实现配置的动态管理。 # 1.1 搭建 Nacos Server ① 参考《芋道 Nacos 极简入门》 (opens new window)文章的「2. 单机部署(最简模式)」或「3. 单机部署(基于 MySQL 数据库)」小节。 ② 点击 Nacos 控制台的 [命名空间] 菜单,创建一个 ID 和名字都为 dev 的命名空间,稍后会使用到。如下图所示: # 1.2 项目接入 Nacos 友情提示:以 yudao-module-system 服务为例子。 # 1.2.1 引入依赖 在 yudao-module-system-biz 模块的 pom.xml (opens new window) 中,引入 Nacos 对应的依赖。如下所示: <!-- Spring Cloud 基础 --><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId></dependency><!-- Config 配置中心相关 --><dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency> # 1.2.2 添加配置 在 bootstrap-local.yaml ( opens new window) 中,添加 nacos.config 配置。如下所示: --- #################### 配置中心相关配置 ####################spring: cloud: nacos: # Nacos Config 配置项,对应 NacosConfigProperties 配置属性类 config: server-addr: 127.0.0.1:8848 # Nacos 服务器地址 namespace: dev # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP name: # 使用的 Nacos 配置集的 dataId,默认为 spring.application.name file-extension: yaml # 使用的 Nacos 配置集的 dataId 的文件拓展名,同时也是 Nacos 配置集的配置格式,默认为 properties spring.cloud.nacos.config.namespace 配置项:设置为 dev,就是刚创建的命名空间 # 1.2.3 配置管理 ① 参考《芋道 Spring Cloud Alibaba 配置中心 Nacos 入门 》 (opens new window)文档,学习 Nacos 配置中心的使用。 ② 按照需要,将不同环境存在差异的 application-local.yaml (opens new window) 和 application-dev.yaml (opens new window) 中的配置,迁移到 Nacos 配置中心。 一般情况下,不建议将 application.yaml 中的配置,迁移到 Nacos 配置中心。因为 application.yaml 中的配置,是通用的配置,无需动态管理。 疑问:为什么项目中的 `application-{env}.yaml` 中的配置,没有放到 Nacos 配置中心中? 主要考虑大家 《快速启动》 可以更简单。 实际项目中,是建议放到 Nacos 配置中心,进行配置的动态管理的。 操作过程中,可能会碰到的问题: IdTypeEnvironmentPostProcessor 与 Nacos 配置中心加载顺序问题 (opens new window) # 2. 配置管理 友情提示:该功能是从 Boot 项目延用到 Cloud 项目,一般情况下不会使用到,使用 Nacos 管理配置即可。 在 [基础设施 -> 配置管理] 菜单,可以查看和管理配置,适合业务上需要动态的管理某个配置。 例如说:创建用户时,需要配置用户的默认密码,这个密码是不会变的,但是有时候需要修改这个默认密码,这个时候就可以通过配置管理来修改。 对应的后端代码是 yudao-module-infra 的 config (opens new window) 业务模块。 # 2.1 配置的表结构 infra_config 的表结构如下: CREATE TABLE `infra_config` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '参数主键', `group` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '参数分组', `type` tinyint NOT NULL COMMENT '参数类型', `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数名称', `key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数键名', `value` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '参数键值', `sensitive` bit(1) NOT NULL COMMENT '是否敏感', `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', PRIMARY KEY (`id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='参数配置表'; key 字段,对应到 Spring Boot 配置文件的配置项,例如说 yudao.captcha.enable、sys.user.init-password 等等。 # 2.2 后端案例 TODO 芋艿:待补充 # 2.3 前端案例 后端提供了 /admin-api/infra/config/get-value-by-key (opens new window) RESTful API 接口,返回指定配置项的值。前端的使用示例如下图: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/08, 00:13:10 注册中心 Nacos 消息队列 RocketMQ ← 注册中心 Nacos 消息队列 RocketMQ→"},{"title":"【v1.2.0】2021.12.15","path":"/wiki/YuDaoCloud/更新日志/【v1.2.0】2021.12.15/【v1.2.0】2021.12.15.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.2.0】2021.12.15 # 新增多租户、数据权限的功能 这个版本新增了多租户与数据权限两个重量级的功能,建议花点时间进行了解与学习。 # ⭐ New Features 【新增】多租户,支持 Web、Security、Job、MQ、Async、DB、Redis 组件 【新增】数据权限,内置基于部门过滤的规则 【新增】用户前台的昵称、头像的修改 【新增】用户前台的微信公众号、微信小程序的社交登录的 API 接口 完整功能,需要等基于 Uniapp 实现的用户前台一起~ 努力 coding 中,胖友可以 star 持续关注一波! 【优化】管理后台的登录成功后,LoginUser 使用统一方法补全信息 # 🐞 Bug Fixes 【修复】通知和字典查询接口的 @PreAuthorize 权限标识错误 【修复】代码生成的 Java 类路径缺少 modules 目录 【修复】代码生成的 Test 单元测试类的引入 Util 工具类的包路径不正确 # 🔨 Dependency Upgrades 【引入】mockito-inline 3.6.28:Mockito 提供对 final、static 的支持 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.3.0】2022.01.24 【v1.1.0】2021.10.25 ← 【v1.3.0】2022.01.24 【v1.1.0】2021.10.25→"},{"title":"【v1.3.0】2022.01.24","path":"/wiki/YuDaoCloud/更新日志/【v1.3.0】2022.01.24/【v1.3.0】2022.01.24.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.3.0】2022.01.24 # 新增工作流的功能 基于 Activiti 7.X 版本实现工作流功能,支持可配置的动态表单、自定义的业务表单。 下个版本会提供基于 Flowable 6.X 版本实现的工作流! # 📈 Statistic 总代码行数:61594 源码代码行数:37931 注释行数:14225 单元测试用例数:278 # ⭐ New Features 【优化】引入 form generator 0.2.0 版本,并重构相关代码 【修改】修改部门负责人,从 String 字符串,调整成和后台用户的用户编号绑定 【新增】流程表单,支持动态进行表单的配置 【新增】工作组,用于支持指定工作组进行任务的审批 【新增】流程模型的管理,支持新增、导入、编辑、删除、发布流程模型 【新增】我的流程的管理,支持发起流程 【新增】待办任务的管理,支持任务的审批通过与不通过 【新增】已办任务的管理,支持详情的查看 【新增】任务分配规则,可指定角色、部门成员、部门负责人、用户、用户组、自定义脚本等维度,进行任务的审批 【新增】引入 bpmn-process-designer 0.0.1 版本,提供流程设计器的能力 【优化】新增 LambdaQueryWrapperX 类,改成使用 Lambda 的方式选择字段,避免手写导致字段不正确 # 🐞 Bug Fixes 【修复】biz-data-permission 组件的缓存机制,导致部分 SQL 未进行数据过滤 【修复】codegen 生成代码时,delete 接口补充 dataTypeClass 属性,避免 Swagger 打印 WARN 日志 【修复】Swagger 文档由于写错 @ApiImplicitParam 注解的 name 和 dataTypeClass 属性,导致文档生成失败 # 🔨 Dependency Upgrades 【升级】redisson from 3.16.3 to 3.16.6,解决 Stream 在调试场景下会存在 NPE 的问题 【升级】spring-boot from 2.4.5 to 2.4.12,最新的 Spring Boot 2.6.X 在等更流行一些,稳定第一 【升级】druid from 1.2.4 to 1.2.8,提升数据库连接池的稳定性 【升级】dynamic-datasource from 3.3.2 to 3.5.0,修复动态数据源切换的问题 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.4.0】2022-02-04 【v1.2.0】2021.12.15 ← 【v1.4.0】2022-02-04 【v1.2.0】2021.12.15→"},{"title":"【v1.5.0】2022-02-17","path":"/wiki/YuDaoCloud/更新日志/【v1.5.0】2022-02-17/【v1.5.0】2022-02-17.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.5.0】2022-02-17 # 重构成多 Maven Module 的代码结构 修复各种多 Maven Module 重构带来的 Bug,感谢大量群友的 PR 支持! 跟进 ruoyi-vue 3.4.0 ~ 3.8.1 版本,感谢这么优秀的开源项目! # 📈 Statistic 总代码行数:69299 源码代码行数:42687 注释行数:15888 单元测试用例数:278 # ⭐ New Features 【优化】使用 Lombok 简化 JsonUtils 工具类 #73 (opens new window) 【新增】兼容 Node 16 版本,通过升级 BPMN-JS 相关库 commit (opens new window) 【新增】前端的表格右侧工具栏组件支持显隐列,具体可见【用户管理】功能 commit (opens new window) 【新增】前端的菜单导航显示风格 TopNav(false 为 左侧导航菜单,true 为顶部导航菜单),支持布局的保存与重置 commit1 (opens new window) commit2 (opens new window) 【新增】前端的网页标题支持根据选择的菜单,动态展示标题 commit (opens new window) 【新增】字典标签样式回显,例如说开启的状态展示为 primary 蓝色,禁用的状态为 info 灰色 commit (opens new window) 【新增】前端的 iframe 组件,方便内嵌网页 commit (opens new window) 【新增】在基础设施-配置管理菜单,可通过修改 yudao.captcha.enable 配置项,动态修改登录是否需要验证码 commit (opens new window) 【新增】在代码生成的预览界面,支持一键复制代码 commit (opens new window) # 🐞 Bug Fixes 【修复】数据权限的 DEPT_AND_CHILD 范围时,未设置自己所在的部门 #72 (opens new window) 【修复】Knife4j 接口文档 404 的问题,原因是 spring.mvc.static-path-pattern 配置项,影响了基础路径 commit (opens new window) 【修复】修复文件访问地址错误 #68 (opens new window) 【修复】工作流程发起以及审批异常,由 @NotEmpty 校验、和 Long 类型异常导致 #73 (opens new window) 【修复】自定义 DefaultStreamMessageListenerContainerX 实现,解决 Redisson Stream 读取不到数据返回 null 导致 NPE 问题 commit (opens new window) 【修复】部门更新后,本地缓存不刷新的问题 #77 (opens new window) 【修复】获取拥有指定的角色用户时,返回错误的 id 编号 #79 (opens new window) # 🔨 Dependency Upgrades *【修复】Maven 构建的一些错误提示 #78 (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.5.1】2022-02-28 【v1.4.0】2022-02-04 ← 【v1.5.1】2022-02-28 【v1.4.0】2022-02-04→"},{"title":"【v1.4.0】2022-02-04","path":"/wiki/YuDaoCloud/更新日志/【v1.4.0】2022-02-04/【v1.4.0】2022-02-04.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.4.0】2022-02-04 # 重构成多 Maven Module 的代码结构 大版本重构,基于 Maven Module 的方式拆分多模块,希望大家多多提点建议! # 📈 Statistic 总代码行数:69118 源码代码行数:42571 注释行数:15847 单元测试用例数:278 # ⭐ New Features 【重构】大模块按照多 Maven Module 的方式拆分,提升可维护性,为后续重构 yudao-cloud 提供基础 【移除】将 yudao-core-service 模块移除,替换成每个 Maven Module 暴露对应的 yudao-module-***-api 模块 【新增】Spring Security 支持读取多种用户类型,从不同的数据库表,从而实现单项目提供管理后台、用户 APP 的不同 RESTful API 接口 【新增】Spring Security 新增 AuthorizeRequestsCustomizer 抽象类, 自定义每个 Maven Module 的 URL 的安全配置 【新增】代码生成器支持多 Maven Module 的方式生成代码,支持管理后台、用户 APP 两种场景的 RESTful API 的生成,支持 H2 SQL 脚本的生成 【新增】每次发布大版本时,将 yudao-ui-admin 编译后,放到 yudao-server 项目中,可以快速体验,无需搭建前端开发环境 【重构】将数据库文档调整到 tool 模块,更加明确 【优化】代码生成器的前端展示效果,例如说 Java 包路径合并 # 🐞 Bug Fixes 【修复】用户无权限访问 指定 API 时,未返回 FORBIDDEN 结果码 【修复】定时任务刷新本地缓存时,无租户上线文,导致查询报错 【修复】配置中心只加载了删除的配置 【修复】管理后台 UI 超时登录后,返回登录界面时,由于未登录加载不到信息,导致报错的问题 # 🔨 Dependency Upgrades 【升级】spring-boot from 2.4.12 to 2.5.9,最新的 Spring Boot 2.6.X 在等更流行一些,稳定第一 【升级】Spring Boot Admin from 2.3.2 to 2.6.2,提供更好的监控能力 【移除】Apache FreeMarker 依赖,修改 Screw 使用 Velocity 作为模板引擎 【升级】redisson from 3.16.6 to 3.16.8 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.5.0】2022-02-17 【v1.3.0】2022.01.24 ← 【v1.5.0】2022-02-17 【v1.3.0】2022.01.24→"},{"title":"【v1.5.1】2022-02-28","path":"/wiki/YuDaoCloud/更新日志/【v1.5.1】2022-02-28/【v1.5.1】2022-02-28.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.5.1】2022-02-28 # 优化多租户功能,新增租户套餐,增强多租户封装 创建租户时,自动创建用户、角色等信息 支持租户套餐,自定义每个租户的菜单、操作、按钮等权限信息 # 📈 Statistic 总代码行数:71249 源码代码行数:43921 注释行数:16341 单元测试用例数:341 # ⭐ New Features 【新增】后端 yudao.tenant.enable 配置项,前端 VUE_APP_TENANT_ENABLE 配置项,用于开关租户功能。 commit (opens new window) 【优化】调整默认所有表开启多租户的特性,可通过 yudao.tenant.ignore-tables 配置项进行忽略,替代原本默认不开启的策略 commit (opens new window) 【新增】通过 yudao.tenant.ignore-urls 配置忽略多租户的请求,例如说 ,例如说短信回调、支付回调等 Open API commit (opens new window) 【新增】新增 @TenantIgnore 注解,标记指定方法,忽略多租户的自动过滤,适合实现跨租户的逻辑 commit (opens new window) 【新增】租户套餐的管理,可配置每个租户的可使用的功能权限 commit (opens new window) 【优化】新建租户时,自动创建对应的管理员账号、角色等基础信息 commit (opens new window) 【优化】Redis 最低版本 5.0.0 检测,解决搭建环境过程中无法理解 XREADGROUP 指令的报错 commit (opens new window) # 🐞 Bug Fixes 【修复】修复不支持根部门的问题 commit (opens new window) 【修复】错误码存在重复的问题 commit (opens new window) 【修复】角色的数据范围为仅本人时,登录后获取权限列表报错的问题 commit (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.5.9 to 2.5.10 【升级】mybatis-plus from 3.4.3.4 to 3.5.1 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.0】2022-03-10 【v1.5.0】2022-02-17 ← 【v1.6.0】2022-03-10 【v1.5.0】2022-02-17→"},{"title":"【v1.6.0】2022-03-10","path":"/wiki/YuDaoCloud/更新日志/【v1.6.0】2022-03-10/【v1.6.0】2022-03-10.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.6.0】2022-03-10 # 支持 Flowable 工作流,发布开发文档 基于 Flowable 实现工作流,可见 yudao-module-bpm-impl-flowable (opens new window) 模块。 友情提示:原本 Activiti 实现的工作流,在 yudao-module-bpm-impl-activiti (opens new window) 模块,保持同步更新。 # 📈 Statistic 总代码行数:75008 源码代码行数:46416 注释行数:17132 单元测试用例数:341 # ⭐ New Features 【新增】 yudao-module-bpm-impl-flowable (opens new window) 模块,实现 Flowable 工作流 #88 (opens new window) 【新增】《开发文档》的简介、功能列表、快速启动、技术选型、项目结构、新建模块、SaaS 多租户等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 # 🐞 Bug Fixes 【修复】正常租户登录后退出,切换到过期租户时造成的 tenant.ignore-urls 配置失效的问题,比如无法获取验证码图片造成无法登录 #91 (opens new window) # 🔨 Dependency Upgrades 暂无,计划升级 Spring Boot 2.6.X .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.1】2022-03-21 【v1.5.1】2022-02-28 ← 【v1.6.1】2022-03-21 【v1.5.1】2022-02-28→"},{"title":"【v1.6.1】2022-03-21","path":"/wiki/YuDaoCloud/更新日志/【v1.6.1】2022-03-21/【v1.6.1】2022-03-21.html","content":"开发指南更新日志 芋道源码 2022-03-10 目录 【v1.6.1】2022-03-21 # 支持 OSS 云存储,优化代码生成 对应 版本 1.6.1 功能列表 (opens new window) # 📈 Statistic 总代码行数:77279 源码代码行数:47812 注释行数:17676 单元测试用例数:537 # ⭐ New Features 【优化】文件存储的功能,支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、SFTP、数据库等 #98 (opens new window) 【新增】《开发文档》的代码生成(新增功能)、功能权限、上传下载等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 【新增】开发环境下,管理后台每个菜单展示对应的《开发文档》的说明 code (opens new window) 【新增】《开发文档》的工作流、代码生成(新增功能)、功能权限、数据权限等小节完成,可访问 https://doc.iocoder.cn (opens new window) 地址 【优化】将 yudao-module-tool 合并到 yudao-module-infra 模块,统一基础设施 #94 (opens new window) 【优化】代码生成时,额外生成 MyBatis Mapper XML 文件 #96 (opens new window) 【新增】开启 TopNav 时,没有子菜单的情况下,隐藏侧边栏 code (opens new window) # 🐞 Bug Fixes 【修复】仅本人数据权限时,个人中心会报错的问题 #97 (opens new window) 【修复】修改租户套餐的权限时,本地缓存刷新错误的问题 #99 (opens new window) 【修复】删除菜单、角色时,本地缓存未刷新的问题 code (opens new window) 【修复】登录界面输入不存在的租户时,导致后续请求报错的问题 code (opens new window) 【修复】登录超时刷新页面时,跳转登录页面还提示重新登录问题 code (opens new window) # 🔨 Dependency Upgrades 【升级】apollo-client from 1.7.0 to 1.9.2 【升级】guide from 4.1.0 to 5.1.0 :解决 Apollo 在 JDK 17 无法启动的问题 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.2】2022-06-05 【v1.6.0】2022-03-10 ← 【v1.6.2】2022-06-05 【v1.6.0】2022-03-10→"},{"title":"【v1.6.2】2022-06-05","path":"/wiki/YuDaoCloud/更新日志/【v1.6.2】2022-06-05/【v1.6.2】2022-06-05.html","content":"开发指南更新日志 芋道源码 2022-03-26 目录 【v1.6.2】2022-06-05 # 新增 OAuth 2.0、SSO 单点登录、多种数据库支持等功能 对应 版本 1.6.2 功能列表 (opens new window) # 📈 Statistic 总代码行数:84846 源码代码行数:52792 注释行数:19234 单元测试用例数:671 # ⭐ New Features 【新增】对 PostgreSQL 数据库的支持 #151 (opens new window) 感谢这个过程中怪物的帮助! 【新增】对 Oracle 数据库的支持 #152 (opens new window) 感谢这个过程中 安贞 (opens new window)、品霖的帮助! 【新增】对 SQL Server 数据库的支持 #153 (opens new window) 感谢这个过程中 Simon、蜉蝣无垠、牛希尧的帮助! 【新增】《开发指南 —— 后端手册》的接口文档、三方登录、异常处理(错误码)、参数校验、分页实现、系统日志、数据库 MyBatis、多数据源、缓存 Redis、本地缓存、定时任务、消息队列、配置中心、单元测试、分布式锁、幂等性、限流熔断、数据库文档、短信配置、开发环境... 【新增】《开发指南 —— 运维手册》的开发环境、Linux 部署、Docker 部署、Jenkins 部署、HTTPS 证书、服务监控... 【新增】《开发指南 —— 前端手册》的开发规范、菜单路由、Icon 图标、字典数据、系统组件、通用方法、配置读取... 【新增】手机验证码登录,美化登录界面,由 #155 (opens new window) 贡献 【新增】一键改包的程序,快速将项目的 Maven、包名等信息替换成你的 #110 (opens new window) 【新增】菜单新增是否缓存、是否隐藏的字段 #133 (opens new window) #172 (opens new window) 【新增】Spring Cache 声明式缓存,使用 Redis 存储 code (opens new window) 【新增】腾讯云短信,由 swpthebest (opens new window) 贡献 #118 (opens new window) 【新增】敏感词,由 dachuan 贡献 #121 (opens new window) 【新增】数据源配置,为多租户、代码生成支持动态数据源做准备 #138 (opens new window) 【新增】用户 Token 采用 OAuth2.0 的 Access Token + Refresh Token,提升安全性 #166 (opens new window) 【新增】基于 OAuth2.0 实现 SSO 单点登录 #176 (opens new window) 【新增】用户与岗位的关联表,由 anzhen-tech (opens new window) 贡献 #113 (opens new window) 【新增】MyBatis 字段的加解密功能 code (opens new window) 【新增】集成微信 Native、小程序的支付能力,支持 v2 和 v3 的回调数据处理 #142 (opens new window) 【优化】yudao-module-xx-impl 调整成 yudao-module-xx-biz,更加符合定位 code (opens new window) 【优化】简化三方登录的实现,降低理解成本 #137 (opens new window) 【优化】去除 yudao-module-system、yudao-module-infra 对 yudao-module-member 的依赖 #122 (opens new window) 【优化】yudao-framework-test 测试组件的封装,内置 Redis、DB 等多种快速测试的基类 code (opens new window) 【优化】配置指定默认的 npm 镜像源 #170 (opens new window) 【优化】字典管理、通知管理、岗位管理、角色管理、错误码管理的排序显示 #174 (opens new window) 【优化】前端 Token、账号、密码等信息,统一使用 LocalStorage 替代 Cookie 存储 code (opens new window) 【优化】上传文件的类型识别,增加基于 filename 的读取 code (opens new window) # 🐞 Bug Fixes 【修复】角色菜单集合复选框回显不正确 #107 (opens new window) 【修复】工作流 BPMN 图的 canvas 自适应,解决展示补全的问题 #104 (opens new window) 【修复】API 访问日志不记录的问题 code (opens new window) 【修复】修复忽略租户的 URL,未带租户会报错的问题 code (opens new window) 【修复】菜单无法使用外链的问题 code (opens new window) 【修复】代码生成器的 vue 模板中,导出 Excel 文件时,文件名未格式化的问题 #133 (opens new window) 【修复】代码生成时,对话框的日期选择器,在编辑情况下不能回显 #135 (opens new window) 【修复】在 Windows 下 ftp 上传和下载存在报错的问题 #156 (opens new window) 【修复】图片上传组件 ImageUpload 上传报错的问题 code (opens new window) 【修复】文件上传组件 FileUpload 上传报错的问题 code (opens new window) 【修复】form generator 组件上传文件、图片报错的问题 code (opens new window) 【修复】富文本编辑器的 Editor 的图片上传报错的问题 code (opens new window) 【修复】DO 生成模板,当主键是 String 类型,模板有误 #167 (opens new window) 【修复】创建用户不分配角色的情况会存在空指针 #171 (opens new window) 【修复】yudao-ui-admin 启动告警 #173 (opens new window) 【修复】新建的用户未分配角色时,操作自己信息回报错的问题 code (opens new window) 【修复】工作流的编辑无法撤回、crtl 选中的问题 code (opens new window) 【修复】支付宝通知回调 BUG 修复 #142 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.5.10 to 2.6.8 :修复 RCE 漏洞,并且 2.5.X 结束声明周期 【升级】redisson from 3.16.6 to 3.17.3 :提升 Redisson 客户端的稳定性 【升级】mysql-connector-java from 5.1.46 to 8.0.28 :提升 MySQL 客户端的性能 【升级】Knife4j from from 3.0.2 to 3.0.3 【升级】swagger-annotations from 1.5.22 to 1.6.6 【升级】spring-boot-admin from 2.6.2 to 2.6.7 【升级】fastjson from 1.2.73 to 2.0.5 【升级】resilience4j from 1.7.0 to 1.7.1 【升级】jackson from 2.12.6 to 2.13.3 【升级】spring-mvc from 5.3.16 to 5.3.20 【升级】spring-security from 5.5.5 to 5.6.5 【升级】hibernate-validator from 6.2.2 to 6.2.3 【升级】junit from 5.7.2 to 5.8.2 【升级】mockito from 3.9.0 to 4.0.0 【升级】mybatis-plus from 3.4.3.4 to 3.5.2 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.3】2022-07-29 【v1.6.1】2022-03-21 ← 【v1.6.3】2022-07-29 【v1.6.1】2022-03-21→"},{"title":"【v1.6.3】2022-07-29","path":"/wiki/YuDaoCloud/更新日志/【v1.6.3】2022-07-29/【v1.6.3】2022-07-29.html","content":"开发指南更新日志 芋道源码 2022-07-29 目录 【v1.6.3】2022-07-29 # 工作流支持会签或签、新增 Vue3 管理后台 # 📈 Statistic 总代码行数:81410 源码代码行数:50413 注释行数:30977 单元测试用例数:671 # ⭐ New Features 【新增】基于 Vue3 + ElementUI Plus 实现 yudao-ui-admin-vue3 (opens new window) 管理后台项目,已完成系统管理 + 基础设施等功能,工作流正在实现中,主要由 @xingyu4j (opens new window) 贡献 【新增】工作流支持会签、或签,可自定义任务分配方式 #212 (opens new window) 【新增】接口支持通过 @PermitAll 注解,允许匿名(未登录)进行访问 d9c2da7 (opens new window) 【新增】yudao.security.permit-all-urls 配置项,允许匿名(未登录)进行访问 d9c2da7 (opens new window) 【新增】Redis 缓存的查询与删除 由 @lwf_org (opens new window) 贡献 #211 (opens new window) 【优化】文件表增加 name 字段,记录上传的文件名,由 @manning233 (opens new window) 贡献 #186 (opens new window) 【优化】基于 Guava 实现 dict 字典数据的本地缓存 d320091 (opens new window) 【优化】基于 Guava 实现 tenant 租户数据的本地缓存 992e205 (opens new window) 【重构】新增 yudao-spring-boot-starter-biz-error-code 错误码组件,用于错误码的自动创建与加载 7a86a61 (opens new window) 【重构】新增 yudao-spring-boot-starter-banner 组件,用于项目启动时打印开发文档、接口文档等 69a3a83 (opens new window) 【新增】yudao.access-log.enable 访问日志的开关,默认在 local 环境关闭记录访问日志 9040b17 (opens new window) 【新增】yudao.error-code.enable 错误码的开关,默认在 local 环境关闭自动生成错误码 cca8375 (opens new window) 【新增】集成 Prometheus 监控点 4dfa816 (opens new window) 【移除】去除 Activiti 工作流的支持,专注提供基于 Flowable 提供更强大的工作流能力 【重构】时间区间的过滤条件,从开始和结束时间两个变量,修改为数组,由 @xingyu4j (opens new window) 贡献 dad10d8 (opens new window) # 🐞 Bug Fixes 【修复】流程审批不通过会报错的问题,由 @wzy_lc (opens new window) 贡献 #215 (opens new window) 【修复】Spring Boot Admin 的 prefer-ip 过期,由 @xingyu4j (opens new window) 贡献 63877cf (opens new window) 【修复】环境 test、stage、stage、prod 不打印日志的问题 8a6c48f (opens new window) 【修复】短信验证码的每日发送条数不正确 e5a7b84 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.6.8 to 2.6.10 【升级】hutool from 5.6.1 to 5.7.22 【升级】druid from 1.2.8 to 1.2.11 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.4】2022-08-22 【v1.6.2】2022-06-05 ← 【v1.6.4】2022-08-22 【v1.6.2】2022-06-05→"},{"title":"【v1.6.5】2022-12-01","path":"/wiki/YuDaoCloud/更新日志/【v1.6.5】2022-12-01/【v1.6.5】2022-12-01.html","content":"开发指南更新日志 芋道源码 2022-08-22 目录 【v1.6.5】2022-12-01 # 重构 Vue3 管理后台,优化稳定性 # 📈 Statistic 总代码行数:98088 源码代码行数:55926 注释行数:23265 单元测试用例数:671 # ⭐ New Features 【新增】管理后台登录时,使用滑块验证码,由 @xingyu4j (opens new window) 贡献 #238 (opens new window) 【新增】SSO 单点登录的示例,包括基于授权码模式、密码模式两种实现 #272 (opens new window) 【优化】提升 Vue3 实现管理后台的稳定性、兼容性,基于 vxe-table 解决 el-table 卡顿的问题,由 @xingyu4j (opens new window) 贡献 #271 (opens new window) #282 (opens new window) #283 (opens new window) #288 (opens new window) #291 (opens new window) #293 (opens new window) #299 (opens new window) #300 (opens new window) #314 (opens new window) #316 (opens new window) 【优化】使用 LocalDateTime 替换 Date,由 @xingyu4j (opens new window) 贡献 #292 (opens new window) 【新增】Spring Cache 在多租户下的支持,由 @whitedolphin (opens new window) 贡献 #257 (opens new window) 【新增】流程图 ServiceTask 的完成和 todo 高亮,增加 ServiceTask 节点的 hover 显示内容,由 @FinalFinancialFreedom (opens new window) 贡献 #260 (opens new window) 【移除】云片短信渠道,解决云片的安全风险 ea95115 (opens new window) 【移除】jasypt-spring-boot-starter 加密库使用 hutool AES 替代 ce3aefa (opens new window) 【移除】Apollo 配置中心,简化学习成本 a8cdf74 (opens new window) # 🐞 Bug Fixes 【修复】WxMaService 的 null key in entry 报错,由 @rayyer (opens new window) 贡献 #259 (opens new window) 【修复】导入用户后编辑报错,由 @wangjun (opens new window) 贡献 #258 (opens new window) 【修复】编辑流程模型时,不退出模拟直接保存,导致后续分配规则报错,由 @wangjun (opens new window) 贡献 #258 (opens new window) 【修复】数据权限,不支持隐式内连接的问题 【修复】\"定时任务 -> 调度日志 -> 详细\"里面,”执行时长“字段显示不正确的问题,由 @idevmo (opens new window) 贡献 #265 (opens new window) 【修复】Vue3 代码生成选择父菜单无效,生成的前端代码缺少字段以及格式错误,由 @jueyinghua (opens new window) 贡献 #286 (opens new window) 【修复】前端配置管理中参数分类显示错误,由 @guyuezb (opens new window) 贡献 #278 (opens new window) 【修复】短信接收报告回调时,设置 errorMsg 不正确,由 @Macro (opens new window) 贡献 #280 (opens new window) 【修复】当只修改模型并保存,再发布时,提示\"流程定义部署失败,原因:信息未发生变化\",由 @SuperHao (opens new window) 贡献 #284 (opens new window) 【修复】WXLitePayClient.java 中 copy 应忽略的字段,由 @chenlei65368 (opens new window) 贡献 #284 (opens new window) 【修复】阿里云 OSS 解析 region 时兼容带 https的 配置,由 @huangyemin (opens new window) 贡献 #276 (opens new window) 【修复】三级及以上菜单路由缓存失效问题,由 @咱哥丶 (opens new window) 贡献 #290 (opens new window) 【修复】钉钉登录时,重定向后 type 丢失导致报错的问题 7093ed3 (opens new window) 【修复】无法自定义 Icon 图标的问题 e403684 (opens new window) 【修复】访问数据库存储的文件,path 多层级时,无法访问的问题 92ace03 (opens new window) 【修复】S3 上传七牛云无 mime type 的问题,由 @石溪 (opens new window) 贡献 #313 (opens new window) 【修复】流程代办,日期时区转换错误,由 @zy_2021 (opens new window) 贡献 #309 (opens new window) # 🔨 Dependency Upgrades 【升级】spring boot from 2.6.10 to 2.7.6 【升级】flowable from 6.7.0 to 6.7.2 【升级】hutool from 5.7.22 to 5.8.9 【升级】velocity from 2.2 to 2.3 【升级】druid from 1.2.11 to 1.2.14 【升级】spring boot admin from 2.6.7 to 2.6.9 【升级】mapstruct from 1.4.1 to 1.5.3.Final 【升级】lombok from 1.16.14 to 1.18.24 【升级】mockito from 4.0.0 to 4.8.0 【升级】dynamic-datasource from 3.5.0 to 3.5.2 【升级】redisson from 3.17.4 to 3.17.7 【升级】easyexcel from 3.1.1 to 3.1.2 【升级】vue from 2.7.0 to 2.7.14 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.6】2023-01-05 【v1.6.4】2022-08-22 ← 【v1.6.6】2023-01-05 【v1.6.4】2022-08-22→"},{"title":"【v1.6.4】2022-08-22","path":"/wiki/YuDaoCloud/更新日志/【v1.6.4】2022-08-22/【v1.6.4】2022-08-22.html","content":"开发指南更新日志 芋道源码 2022-08-22 目录 【v1.6.4】2022-08-22 # 新增 uniapp 管理后台、报表设计器 # 📈 Statistic 总代码行数:87565 源码代码行数:54279 注释行数:19868 单元测试用例数:671 # ⭐ New Features 【新增】完善 Vue3 管理后台的工作流实现,由 @xingyu4j (opens new window) 贡献 #238 【新增】管理后台的移动端 yudao-ui-admin-uniapp 项目,采用 uni-app (opens new window) 方案,一份代码多终端适配,同时支持 APP、小程序、H5!#247 (opens new window) 【新增】集成积木报表,提供低代码报表设计器,由 @jiangqiang1996 (opens new window) 贡献 #237 (opens new window) 【新增】接入支付宝 PC 网站支付,由 @jiangqiang1996 (opens new window) 贡献 #240 (opens new window) 【优化】项目的启动速度,控制在 30 秒左右,默认不启动 bpm、visualization 模块 【优化】管理后台的弹窗支持滚动、拖拽,并点击背景布关闭,避免误操作,由 @颗粒 (opens new window) 贡献 #253 (opens new window) 【优化】一键改包,如果目标目录已存在,则不进行生成,由 @C (opens new window) 贡献 #229 (opens new window) # 🐞 Bug Fixes 【修复】Redis 7.0 监控查询 calls 数值超过 Integer 范围的异常,由 @lanyue52011 (opens new window) 贡献 #239 (opens new window) 【修复】前端表单设计器中动态数据,不能正常获取和更深层级的赋值错误的情况,由 @CorrectRoadH (opens new window) 贡献 #256 (opens new window) 【修复】代码生成功能中,点击同步,会清除已添加并存在的字段,由 @xrcoder (opens new window) 贡献 #249 (opens new window) 【修复】工作流与积木报表的依赖冲突,将 xercesImpl 升级到 2.12.0 版本,由 @shihy (opens new window) 贡献 #254 (opens new window) # 🔨 Dependency Upgrades 暂无 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.6.5】2022-12-01 【v1.6.3】2022-07-29 ← 【v1.6.5】2022-12-01 【v1.6.3】2022-07-29→"},{"title":"【v1.6.6】2023-01-05","path":"/wiki/YuDaoCloud/更新日志/【v1.6.6】2023-01-05/【v1.6.6】2023-01-05.html","content":"开发指南更新日志 芋道源码 2023-01-01 目录 【v1.6.6】2023-01-05 # 完善 Vue3 管理后台,新增 IP & 地区库 # 📈 Statistic 总代码行数:104298 源码代码行数:63656 注释行数:24708 单元测试用例数:602 # ⭐ New Features 【新增】yudao-spring-boot-starter-biz-ip (opens new window) 业务组件,提供地区 & IP 库的封装,由 @WangLH (opens new window) 贡献 0b5aa56 (opens new window) 【新增】《后端手册 —— 地区 & IP 库》 (opens new window) 文档 【新增】《后端手册 —— 敏感词》 (opens new window) 文档 【新增】《前端手册 Vue 3.x》 (opens new window) 文档 【优化】本地缓存的刷新实现,数据变更时,强制刷新,贡献 #3443aa6 (opens new window) 【新增】Vue3 XTable 组件,由 @xingyu4j (opens new window) 贡献 #349 (opens new window) 【优化】优化 Vue3 管理后台实现,由 @xingyu4j (opens new window) 贡献 #317 (opens new window) #322 (opens new window) #331 (opens new window) #335 (opens new window) #339 (opens new window) #343 (opens new window) 【优化】完善 Vue3 上传组件 && 提升打包速度,由 @xingyu4j (opens new window) 贡献 #337 (opens new window) 【重构】Vue3 头像上传,由 @xingyu4j (opens new window) 贡献 #338 (opens new window) 【新增】WebSocket 连接测试,由 @咱哥丶 (opens new window) 贡献 #348 (opens new window) # 🐞 Bug Fixes 【修复】字典类型逻辑删除时,唯一索引冲突的问题,由 @tangkc123 (opens new window) 贡献 #323 (opens new window) 【修复】pay 模块提交退款申请时,重复设置属性,由 @qshome (opens new window) 贡献 #325 (opens new window) 【修复】修改pay 模块创建支付单时,错误返回订单编号,由 @qshome (opens new window) 贡献 #324 (opens new window) 【修复】修改 pay 模块在微信支付时,支付过期时间格式化异常 (yyyy-MM-ddTHH:mm:ssXXX),由 @qshome (opens new window) 贡献 #329 (opens new window) 【修复】数据权限 SQL 存在多个表达式时,缺少括号问题,由 @与或非 (opens new window) 贡献 #328 (opens new window) 【修复】yudao-ui-admin-vue3 面包屑导航图标和文字不在同一水平线,由 @supine-win (opens new window) 贡献 #333 (opens new window) 【修复】yudao-module-system-api 的 ErrorCodeConstants 中错误码重复的问题,由 @王添翼 (opens new window) 贡献 #340 (opens new window) 【修复】DeptService 的 getDeptsByParentIdFromCache 在获取部门列表时,未处理多租户场景,贡献 #75b3a29 (opens new window) 【修复】前端 FileUpload 文件上传时,code 未使用 0 判断成功,由 @plimlips (opens new window) 贡献 #344 (opens new window) 【修复】Redis Stream 消息队列在重启 Java 进程时,由于 Consumer 未释放消息,导致消息丢失的问题,由 @与或非 (opens new window) 贡献 #332 (opens new window) 【修复】腾讯 COS 异常,Region 必传,由 @与或非 (opens new window) 贡献 #347 (opens new window) 【修复】DB 存储文件时,读取可能报错的问题,由 @与或非 (opens new window) 贡献 #346 (opens new window) 【修复】没有数据权限时,添加/修改用户的唯一手机、账号等字段的校验不正确,贡献 7912a54 (opens new window) 【修复】配置管理,配置是否可见判断写反了,由 @kinlon92 (opens new window) 贡献 #350 (opens new window) 【修复】上传视频无法预览,由 @与或非 (opens new window) 贡献 #352 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.7.6 to 2.7.7 【升级】mybatis-plus from 3.5.2 to 3.5.3 【升级】dynamic-datasource from 3.6.0 to 3.6.1 【升级】flowable from 6.7.2 to 6.8.0 【升级】lock4j from 2.2.2 to 2.2.3 【升级】podam from 7.2.9 to 7.2.11 【升级】jedis-mock from 1.0.4 to 1.0.5 【升级】transmittable-thread-local from 2.14.0 to 2.14.2 【升级】netty-all from 4.1.82 to 4.1.86 【升级】aliyun-java-sdk-core from 4.6.2 to 4.6.3 【升级】tencentcloud-sdk-java from 3.1.635 to 3.1.660 【升级】spring-boot-admin from 2.7.7 to 2.7.9 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.7.0】2023-01-30 【v1.6.5】2022-12-01 ← 【v1.7.0】2023-01-30 【v1.6.5】2022-12-01→"},{"title":"【v1.7.0】2023-01-30","path":"/wiki/YuDaoCloud/更新日志/【v1.7.0】2023-01-30/【v1.7.0】2023-01-30.html","content":"开发指南更新日志 芋道源码 2023-01-07 目录 【v1.7.0】2023-01-30 # 增加微信公众号的接入、邮箱、站内信、数据脱敏 # 📈 Statistic 总代码行数:119925 源码代码行数:73678 注释行数:27769 单元测试用例数:674 # ⭐ New Features 【新增】微信公众号功能,包括账号管理、数据统计、粉丝管理、消息管理、自动回复、标签管理、菜单管理、素材管理、图文草稿箱、图文发表记录,由 @芋道源码 (opens new window) 贡献 #382 (opens new window) 【新增】RESTful API 返回数据时,支持数据脱敏,由 @与或非 (opens new window) 贡献 #372 (opens new window) 【新增】邮箱功能:邮箱账号、邮件模版、邮件发送记录,由 @芋道源码 (opens new window) 贡献 #385 (opens new window) 【新增】站内信功能:站内信模版、站内信消息,由 @圆梦巨人 (opens new window)、@xrcoder (opens new window) 贡献 #385 (opens new window) 【新增】Vue3 管理后台新增 WebSocket 连接测试,由 @xingyu4j (opens new window) 贡献 #379 (opens new window) 【新增】配置 yaml 文件中自定义属性的提示,由 @与或非 (opens new window) 贡献 #373 (opens new window) 【优化】重构 Vue3 管理后台的路由代码生成逻辑,优化性能,由 @xingyu4j (opens new window) 贡献 #375 (opens new window) 【优化】Vue3 管理后台的第一次进入加载速度,由 @xingyu4j (opens new window) 贡献 #381 (opens new window) 【新增】Vue3 管理后台基于 unplugin-auto-import 实现自动导入,由 @xingyu4j (opens new window) 贡献 #376 (opens new window) 【优化】重构滑块验证码 captcha 的实现,由 @xingyu4j (opens new window) 贡献 #374 (opens new window) #376 (opens new window) 【优化】简化本地缓存的实现,优化 《后端手册 —— 本地缓存》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 #382 (opens new window) 【优化】代码生成列表的加载速度,由 @与或非 (opens new window) 贡献 #378 (opens new window) 【新增】《后端手册 —— 验证码》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 【新增】《后端手册 —— 数据脱敏》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 【新增】《公众号手册》 (opens new window) 文档,由 @芋道源码 (opens new window) 贡献 # 🐞 Bug Fixes 【修复】积木报表:部分请求会报错:JmReportTokenServices 实现类 getUsername 方法返回值不允许为空,由 @与或非 (opens new window) 贡献 #358 (opens new window) 【修复】积木报表:分享报错,由 @与或非 (opens new window) 贡献 #357 (opens new window) 【修复】积木报表:API数据集解析时,提示数据为空,报表字段明细会被清空,由 @与或非 (opens new window) 贡献 #359 (opens new window) 【修复】yudao-ui-appi 的 refreshToken is not a function 问题修复,由 @chaining (opens new window) 贡献 #356 (opens new window) 【修复】Vue2 管理后台 Redis 监控 echarts 图表不显示,由 @zy_2021 (opens new window) 贡献 #354 (opens new window) 【修复】MyBatis Plus 升级导致 generatorTest 用例找不到对象爆红,由 @miozus (opens new window) 贡献 #365 (opens new window) 【修复】代码生成器读取不到 dataType 属性,导致无法正确生成代码,由 @与或非 (opens new window) 贡献 #370 (opens new window) 【修复】Xss 启用后,编辑器上传图片错误,由 @与或非 (opens new window) 贡献 #361 (opens new window) #383 (opens new window) 【修复】管理后台 uniapp 的令牌过期时,无法刷新令牌的 bug,由 @chaining (opens new window) 贡献 #360 (opens new window) 【修复】获取菜单返回了不可修改集合,导致无法排序的报错,由 @ambi (opens new window) 贡献 #371 (opens new window) 【修复】Vue2 管理后台的 tags 页签超过屏幕后,无法滚动导致无法选择后面的页签,由 @zhang.xionghui (opens new window) 贡献 #366 (opens new window) # 🔨 Dependency Upgrades 【升级】mybatis-plus from 3.5.3 to 3.5.3.1 【升级】spring-security from 3.7.5 to 3.7.6 【升级】spring-boot-admin from 2.7.9 to 2.7.10 【升级】minio from 8.4.6 to 8.5.1 【升级】knife4j from 3.0.3 to 4.0.0 【升级】vxe-table from 4.3.7 to 4.3.9 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 【v1.7.1】2023-03-05 【v1.6.6】2023-01-05 ← 【v1.7.1】2023-03-05 【v1.6.6】2023-01-05→"},{"title":"【v1.7.3】开发中","path":"/wiki/YuDaoCloud/更新日志/【v1.7.3】开发中/【v1.7.3】开发中.html","content":"开发指南更新日志 芋道源码 2023-04-22 目录 【v1.7.3】开发中 # # 📈 Statistic 总代码行数: 源码代码行数: 注释行数: 单元测试用例数: # ⭐ New Features 【重构】Vue3 管理后台:公众号 MP 模块重构,功能增强,由 @dhb52 (opens new window) 贡献 #135 (opens new window) 【新增】Vue3 管理后台:菜单管理:添加刷新菜单缓存按钮,由 @puhui999 (opens new window) 贡献 #134 (opens new window) 【优化】Vue3 管理后台:升级 Vite 4.3.1,升级其它依赖,由 @xingyu4j (opens new window) 贡献 #53b6f0b (opens new window) # 🐞 Bug Fixes 【修复】代码生成:Vue3 标准模板缺少 baseURL 的格式化,由 @baayso (opens new window) 贡献 #462 (opens new window) 【修复】新建商品时商品分类状态判断错误,由 @LiZhongShi (opens new window) 贡献 #459 (opens new window) 【修复】缺少 ServletUtils 引用,由 @inypeacock (opens new window) 贡献 #461 (opens new window) 【修复】一键改包的”占位“文件影响改包工具运行,由 @anzhen-tech (opens new window) 贡献 #458 (opens new window) 【修复】尝试修复项目第一次打包失败报 Failed to execute goal org.apache.maven.plugins:maven-jar-plugin:3.3.0:jar,由 @芋道源码 (opens new window) 贡献 #91f63ff (opens new window) # 🔨 Dependency Upgrades .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:23 IDE 调试 【v1.7.2】2023-04-19 ← IDE 调试 【v1.7.2】2023-04-19→"},{"title":"一键改包","path":"/wiki/YuDaoCloud/萌新必读/一键改包/一键改包.html","content":"开发指南萌新必读 芋道源码 2022-03-27 目录 一键改包 项目提供了 ProjectReactor (opens new window) 程序,支持一键改包,包括 Maven 的 groupId、artifactId、Java 的根 package、前端的 title、数据库的 SQL 配置、应用的 application.yaml 配置文件等等。效果如下图所示: 友情提示:修改包名后,未来合并最新的代码可能会有一定的成本。 # 👍 相关视频教程 08、如何实现一键改包? (opens new window) # 操作步骤 ① 第一步,使用 IDEA (opens new window) 克隆 https://github.com/YunaiV/yudao-cloud (opens new window) 仓库的最新代码,并给该仓库一个 Star (opens new window)。 ② 第二步,打开 ProjectReactor 类,填写 groupIdNew、artifactIdNew、packageNameNew、titleNew 属性。如下图所示: 另外,如下两个属性也必须修改: projectBaseDir 属性:修改为你 yudao-cloud 所在目录的绝对地址 projectBaseDirNew 属性:修改为你想要的新项目的绝对地址。注意,不要有 yudao 关键字。 ③ 第三步,执行 ProjectReactor 的 #main(String[] args) 方法,它会基于当前项目,复制一个新项目到 projectBaseDirNew 目录,并进行相关的改名逻辑。 11:19:11.180 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][原项目路劲改地址 (/Users/yunai/Java/yudao-cloud-2023)]11:19:11.184 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][检测新项目目录 (/Users/yunai/Java/xx-new)是否存在]11:19:11.298 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][完成新项目目录检测,新项目路径地址 (/Users/yunai/Java/xx-new)]11:19:11.298 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][开始获得需要重写的文件,预计需要 10-20 秒]11:19:12.169 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][需要重写的文件数量:1573,预计需要 15-30 秒]11:19:14.607 [main] INFO cn.iocoder.yudao.ProjectReactor - [main][重写完成]共耗时:3 秒 ④ 第四步,使用 IDEA 打开 projectBaseDirNew 目录,参考 《开发指南 —— 快速启动》 文档,进行项目的启动。注意,一定要重新执行 SQL 的导入!!! 整个过程非常简单,如果碰到问题,请添加项目的技术交流群。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 12:50:10 代码热加载 删除功能 ← 代码热加载 删除功能→"},{"title":"代码热加载","path":"/wiki/YuDaoCloud/萌新必读/代码热加载/代码热加载.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 代码热加载 在日常开发中,我们需要经常修改 Java 代码,手动重启项目,查看修改后的效果。如果在项目小时,重启速度比较快,等待的时间是较短的。但是随着项目逐渐变大,重启的速度变慢,等待时间 1-2 min 是比较常见的。 这样就导致我们开发效率降低,影响我们的下班时间,哈哈哈~ 那么是否有方式能够实现,在我们修改完 Java 代码之后,能够不重启项目呢?答案是有的,通过 代码热加载 的方式。实现方案有三种: spring-boot-devtools【不推荐】 IDEA 自带 HowSwap 功能【推荐】 JRebel 插件【最推荐】 友情提示:本文图中看到的 YudaoServerApplication 启动类,可以换成每个服务的 XXXApplication 启动类。 # 1. spring-boot-devtools spring-boot-devtools (opens new window) 是 Spring Boot 提供的开发者工具,它会监控当前应用所在的 classpath 下的文件发生变化,进行自动重启。 devtools 存在重启速度较慢的问题,所以不推荐! # 2. IDEA 自带 HowSwap 功能 该功能是 IDEA Ultimate 旗舰版的专属功能,不支持 IDEA Community 社区版。 # 2.1 如何使用 ① 设置 Spring Boot 启动类,开启 HotSwap 功能。如下图所示: ② Debug 运行该启动类,等待项目启动完成。 ③ 每次修改 Java 代码后,点击左下角的「热加载」按钮,即可实现代码热加载。如下图所示: # 2.2 存在问题 IDEA 自带 HowSwap 功能,支持比较有限,很多修改都不支持。例如说: 只能增加方法或字段但不可以减少方法或字段 只能增加可见性不能减少 只能维持已有方法的签名而不能修改等等。 你可以认为,只支持方法内的代码修改热加载。 如果想要相对完美的方案,建议使用 JRebel 插件。 # 3. JRebel 插件 JRebel 插件是目前最好用的热加载插件,它支持 IDEA Ultimate 旗舰版、Community 社区版。 # 3.1 如何安装 ① 点击 https://plugins.jetbrains.com/plugin/4441-jrebel-and-xrebel/versions (opens new window) 地址,必须下载 2022.4.1 版本。如下图所示: ② 打开 [Preference -> Plugins] 菜单,点击「Install Plugin from Disk...」按钮,选择刚下载的 JRebel 插件的压缩包。如下图所示: 安装完成后,需要重启 IDEA 生效。 ③ 打开 [Preference -> JRebel & XRebel] 菜单,输入 GUID address 为 https://jrebel.qekang.com/1e67ec1b-122f-4708-87d0-c1995dc0cdaa ,邮件随便写,完成 JRebel 的激活。如下图所示: 之后,点击「Work Offline」按钮,设置 JRebel 为离线,避免因为网络问题导致激活失效。如下图所示: # 3.2 如何使用 ① 点击「Debug With JRebel」按钮,使用 JRebel 启动项目。如下图所示: ② 每次修改 Java 代码后,点击左下角的「热加载」按钮,即可实现代码热加载。如下图所示: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/06, 01:38:02 项目结构 一键改包 ← 项目结构 一键改包→"},{"title":"交流群","path":"/wiki/YuDaoCloud/萌新必读/交流群/交流群.html","content":"开发指南萌新必读 芋道源码 2022-03-11 目录 交流群 # 🐱 反馈交流 如果有问题,可以通过 Gitee Issue (opens new window) 或者 Github Issue (opens new window) 进行反馈。 欢迎加入用户交流群,一起苦练技术基本功,每日精进 30 公里。 如果微信提示“提示对方被加好友过于频繁,请稍后再试?”,可以过一会再尝试下!🙂 项目关注和使用的人太多了~ .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/09, 20:53:54 简介 视频教程 ← 简介 视频教程→"},{"title":"【v1.7.2】2023-04-19","path":"/wiki/YuDaoCloud/更新日志/【v1.7.2】2023-04-19/【v1.7.2】2023-04-19.html","content":"开发指南更新日志 芋道源码 2023-03-06 目录 【v1.7.2】2023-04-19 # 重构 Vue3 管理后台,提升易用性、稳定性 # 📈 Statistic 总代码行数:125001 源码代码行数:77128 注释行数:28642 单元测试用例数:789 # ⭐ New Features 【新增】《代码热加载》 (opens new window) 文档,提升开发效率。 【新增】Vue 管理后台:优化 VSCode 代码 Debugger 调试,使用 VSCode 自带的功能,由 @puhui999 (opens new window) 贡献 #117 (opens new window) 【新增】代码生成时,增加 UI 类型的选择,可生成 Vue2、Vue3 多种管理后台的代码,支持 CRUD Schema 模式,由 @芋道源码 (opens new window) 贡献 #453 (opens new window) 【新增】代码生成器,支持 VBEN 管理后台,由 @xingyu (opens new window) 贡献 #454 (opens new window) 【优化】Vue3 管理后台:去除 BPMNJS、FormCreate、Highlight 的全局引入,降低打包后的大小(6.6M -> 1.3M),由 @芋道源码 (opens new window) 贡献 #128 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 配置管理] 由 @芋道源码 (opens new window) 贡献 #24 (opens new window) 【重构】Vue3 管理后台:[SSO 登录] 由 @puhui999 (opens new window) 贡献 #107 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 数据源配置] 由 @xiaowuye (opens new window) 贡献 #25 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 通知公告] 由 @babylazsss (opens new window) 贡献 #26 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 文件管理] 由 @xiaowuye (opens new window) 贡献 #29 (opens new window)、#28 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 字典管理] 由 @Theo (opens new window) 贡献 #38 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 错误码管理] 由 @kinlon92 (opens new window) 贡献 #39 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 岗位管理] 由 @Chika (opens new window) 贡献 #44 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 登录日志] 由 @lour6498 (opens new window) 贡献 #41 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 客户端管理] 由 @yj441106 (opens new window) 贡献 #60 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 错误日志] 由 @oldBaby (opens new window) 贡献 #43 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 访问日志] 由 @oldBaby (opens new window) 贡献 #48 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 代码生成] 由 @xiaowuye (opens new window) 贡献 #68 (opens new window) 【重构】Vue3 管理后台:[基础设施 -> 定时任务] 由 @孔思宇 (opens new window) 贡献 #65 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 租户管理] 由 @东方白 (opens new window) 贡献 #40 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 租户套餐] 由 @puhui999 (opens new window) 贡献 #77 (opens new window)、#75 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 短信管理] 由 @puhui999 (opens new window) 贡献 #45 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 部门管理] 由 @凌太虚 (opens new window) 贡献 #36 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 敏感词管理] 由 @syd (opens new window) 贡献 #55 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 菜单管理] 由 @Theo (opens new window) 贡献 #54 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 用户管理] 由 @fessor (opens new window) 贡献 #67 (opens new window)、#76 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 角色管理] 由 @Chika (opens new window) 贡献 #63 (opens new window)、#85 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 站内信消息] 由 @咱哥丶 (opens new window) 贡献 #53 (opens new window) 【重构】Vue3 管理后台:[系统管理 -> 站内信消息] 由 @咱哥丶 (opens new window) 贡献 #53 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 账号管理] 由 @kinlon92 (opens new window) 贡献 #49 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 标签管理] 由 @矿泉水 (opens new window) 贡献 #50 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 数据统计] 由 @kinlon92 (opens new window) 贡献 #69 (opens new window)、#72 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 粉丝管理] 由 @dhb52 (opens new window) 贡献 #103 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 消息管理] 由 @&wxr (opens new window) 贡献 #58 (opens new window)、#70 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 图文草稿箱] 由 @dhb52 (opens new window) 贡献 #102 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 素材管理] 由 @dhb52 (opens new window) 贡献 #105 (opens new window) 【重构】Vue3 管理后台:[公众号 -> 自动回复] 由 @dhb52 (opens new window) 贡献 #110 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品分类] 由 @孔思宇 (opens new window) 贡献 #82 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品属性] 由 @孔思宇 (opens new window) 贡献 #83 (opens new window) 【重构】Vue3 管理后台:[商品中心 -> 商品品牌] 由 @Aix (opens new window) 贡献 #104 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 商户信息] 由 @凌太虚 (opens new window) 贡献 #81 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 应用信息] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 支付订单] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[支付管理 -> 退款订单] 由 @东方白 (opens new window) 贡献 #116 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 我的流程] 由 @Chika (opens new window) 贡献 #93 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 已办任务] 由 @Chika (opens new window) 贡献 #90 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 待办任务] 由 @Chika (opens new window) 贡献 #93 (opens new window) 【重构】Vue3 管理后台:[工作流 -> 请假查询] 由 @ZanGe丶 (opens new window) 贡献 #108 (opens new window) 【新增】Vue3 管理后台:增加全局权限判断函数 checkPermi 和 checkRole,由 @LinkLi (opens new window) 贡献 #22 (opens new window) 【新增】字典数据 starter 模块单元测试,由 @与或非 (opens new window) 贡献 #440 (opens new window) 【新增】多租住 Job 部分的单元测试,由 @与或非 (opens new window) 贡献 #27 (opens new window) 【优化】校验手机号码是否正确的正则,由 @冰是睡着的水 (opens new window) 贡献 #447 (opens new window) 【新增】PasswordEncoder 加密复杂度自定义,由 @Fanjc (opens new window) 贡献 #24 (opens new window) 【新增】Vue3 增加 @element-plus/icons-vue 依赖,由 @dhb52 (opens new window) 贡献 #101 (opens new window) 【优化】Vue3 管理后台:增加 Mp 账号 Select 下拉框组件,由 @dhb52 (opens new window) 贡献 #113 (opens new window)、#118 (opens new window) 【优化】Vue3 管理后台:使用 Editor 替代 WxEditor,移除 @vueup/vue-quill 依赖,由 @dhb52 (opens new window) 贡献 #121 (opens new window) 【优化】Vue3 管理后台:公众号消息独立 MessageTable 等组件,解决消息弹窗不重置的问题,由 @dhb52 (opens new window) 贡献 #121 (opens new window) 【优化】Vue3 管理后台:公众号的素材管理,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #126 (opens new window) 【优化】Vue3 管理后台:公众号的自动回复,拆分 ReplyTable 列表组件,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的消息回复组件,不同消息拆分不同表单,提升可维护性,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的草稿管理件,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue3 管理后台:公众号的菜单管理,拆分多个独立组建,由 @dhb52 (opens new window) 贡献 #129 (opens new window) 【优化】Vue2 管理后台:将工作流的业务表单做为动态组件,直接显示到审批页面,不再需要点击查看,由 @疯狂的世界 (opens new window) 贡献 #432 (opens new window) 【优化】Vue3 管理后台:将工作流的业务表单做为动态组件,直接显示到审批页面,不再需要点击查看,由 @puhui999 (opens new window) 贡献 #130 (opens new window) 【重构】Vue3 管理后台:给所有组件添加 name 属性预防未知 bug!!! 由 @puhui999 (opens new window) 贡献 #125 (opens new window) # 🐞 Bug Fixes 【修复】Flowable 无法自动建表问题,由 @LinkLi (opens new window) 贡献 #427 (opens new window) 【修复】Vue3 管理后台:包含字典表的页面加载时报错,由 @毕梅 (opens new window) 贡献 #21 (opens new window) 【修复】Vue3 管理后台:ProcessDesigner.vue 编译错误(eslint),由 @孔思宇 (opens new window) 贡献 #23 (opens new window) 【修复】积木报告建表语句错误,由 @疯狂的世界 (opens new window) 贡献 #430 (opens new window) 【修复】基于 Spring Cloud Bus 实现的 Producer 抽象类,获取自己服务实例时获取不到,由 @Lee.J.Eric (opens new window) 贡献 #26 (opens new window) 【修复】修复某些情况下 ContextHolder 的 NPE 异常,由 @xuing (opens new window) 贡献 #225 (opens new window) 【修复】生成代码测试里面的时间问题(buildBetweenTime 方法),由 @xiaohe4966 (opens new window) 贡献 #228 (opens new window) 【修复】Vue3 管你后台的各种验收 bug,由 @周建 (opens new window) 贡献 #32 (opens new window)、#51 (opens new window)、#56 (opens new window)、#71 (opens new window)、#84 (opens new window) 【修复】PostgreSQLSQL 的 system_menu 表缺少 component_name、always_show 字段、缺少 system_mail_account、system_mail_log、system_mail_template、system_notify_message、system_notify_template 表,由 @libran (opens new window) 贡献 #435 (opens new window)、#435 (opens new window)、#436 (opens new window)、#437 (opens new window) 【修复】订单的创建时间差 8 小时的问题,由 @chop (opens new window) 贡献 #442 (opens new window) 【修复】Vue2 短信验证码登录问题,由 @打听幸福的下落 (opens new window) 贡献 #438 (opens new window) 【修复】工作流的审批任务列表的时间不正确的问题,由 @SuperHao (opens new window) 贡献 #426 (opens new window) 【修复】IP 查询时,因为空格导致异常问题,由 @chasel-jc (opens new window) 贡献 #31 (opens new window) 【修复】Spring Cloud 打包后,无法使用 java -jar 的问题,由 @lovezhike (opens new window) 贡献 #28 (opens new window) 【修复】点击遮罩层弹窗关闭后,页面就操作不了了会一直转圈的问题,由 @puhui999 (opens new window) 贡献 #78 (opens new window) 【修复】设置 vite basePath 后,重新登录跳转路由错误,由 @mgzu (opens new window) 贡献 #89 (opens new window) 【修复】在 Vue3 + Vite4 模块中,使用顶层 await打 包的时候报错,由 @puhui999 (opens new window) 贡献 #78 (opens new window) 【修复】Vue3 公众号素材选择时,获取 FreePublic 出错,以及分页溢出,由 @dhb52 (opens new window) 贡献 #96 (opens new window) 【修复】Vue3 公众号图文显示有误,articles 为数组,由 @dhb52 (opens new window) 贡献 #100 (opens new window) 【修复】xss 请求 Wrapper getAttribute 方法返回错误,由 @zhangxingjia (opens new window) 贡献 #451 (opens new window) 【修复】支付通知的通知 Transaction 不生效的问题,由 @kokoko (opens new window) 贡献 #450 (opens new window) 【修复】修复工作流创建流程时,流程名可能不存在的问题,由 @xushu (opens new window) 贡献 #439 (opens new window) 【修复】修复租户名的重复问题,由 @clockdotnet (opens new window) 贡献 #446 (opens new window) 【修复】Vue3 debugger 位置异常,由 @黄爱武 (opens new window) 贡献 #114 (opens new window) 【修复】Vue3 新增或修改菜单时,无法选择菜单图标的 Bug,由 @chongyul (opens new window) 贡献 #2 (opens new window) 【修复】Vue2 管理后台新增租户时,未校验账号、密码是否为空,由 @LiZhongShi (opens new window) 贡献 #456 (opens new window) 【修复】敏感词导出和字典数据编辑保存的两个 BUG,由 @clockdotnet (opens new window) 贡献 #457 (opens new window) 【修复】Vue3 管理后台:用户管理查询入参错误、站内信模板删除 API 调用错误,由 @AhJindeg (opens new window) 贡献 #132 (opens new window) # 🔨 Dependency Upgrades 【升级】knife4j from 4.0.0 to 4.1.0 【升级】spring-boot from 2.7.8 to 2.7.10 【升级】spring-doc 1.6.14 to 1.6.15 【升级】lombok from 1.18.24 to 1.18.26 【升级】druid from 1.2.15 to 1.2.16 【升级】jedis-mock from 1.0.6 to 1.0.7 【升级】hutool from 1.15.3 to 1.15.4 【升级】tika-core from 2.6.0 to 2.7.0 【升级】netty-all from 4.1.86.Final to 4.1.90.Final 【升级】minio from 8.5.1 to 8.5.2 【升级】tencentcloud-sdk-java from 3.1.676 to 3.1.715 【升级】alipay-sdk-java from 4.35.32.ALL to 4.35.79.ALL 【升级】ip-region from 2.6.6 to 2.7.0 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:23 【v1.7.3】开发中 【v1.7.1】2023-03-05 ← 【v1.7.3】开发中 【v1.7.1】2023-03-05→"},{"title":"删除功能","path":"/wiki/YuDaoCloud/萌新必读/删除功能/删除功能.html","content":"开发指南萌新必读 芋道源码 2022-10-17 目录 删除功能 项目内置功能较多,会存在一些你可能用不到的功能。一般的情况下,建议通过设置该功能对应的菜单为【禁用】,实现功能的“删除”。如下图所示: 后续,如果你又需要使用到该功能,只需要设置该功能对应的菜单为【开启】即可。 🙂 当然,如果你希望彻底删除功能,那么就需要采用删除代码的方式。整个过程如下: ① 【菜单】第一步,使用管理后台的菜单管理,删除对应的菜单、按钮。 ② 【数据库表】第二步,删除对应的数据库表。 ③ 【后端代码】第三步,删除对应的 Controller、Service、数据库实体等后端代码;然后启动后端项目,若存在代码报错,则继续删除相关联的代码,之后如此反复,直到成功。 ④ 【前端代码】第四步,删除对应的 View 和 API 等前端代码;然后启动前端项目,若存在代码报错,则继续删除相关联的代码,之后如此反复,直到成功。 下面,我们来举一些例子。 # 👍 相关视频教程 从零开始 07:如何有效的删除不用的功能? (opens new window) # 删除「多租户」功能 对应功能的文档:多租户 对应的关键字是 tenant # 第一步,删除菜单 删除“租户管理“下的所有菜单,从最里层的按钮开始。如下图所示: # 第二步,删除数据库表 删除 system_tenant 和 system_tenant_package 表。如下图所示: # 第三步,删除后端代码 ① 删除 yudao-module-system-api 模块的 api/tenant (opens new window) 包。 ② 删除 yudao-module-system-api 模块的 ErrorCodeConstants (opens new window) 类中,和租户、租户套餐相关的错误码。如下图所示: 如果想删除的更干净,可以把 system_error_code 表中,对应编号的错误码也都删除一下。 ③ 删除 yudao-module-system-biz 模块的如下包: api/tenant (opens new window) controller/admin/tenant (opens new window) service/tenant (opens new window) test/service/tenant (opens new window) dal/dataobject/tenant (opens new window) dal/mysql/tenant (opens new window) convert/tenant (opens new window) ④ 删除 yudao-spring-boot-starter-biz-tenant (opens new window) 模块。 然后,使用 IDEA 搜索 yudao-spring-boot-starter-biz-tenant 关键字,删除 Maven 中所有对它的定义与引用。如下图所示: 之后,使用 IDEA 刷新下 Maven 依赖。如下图所示: ⑤ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.db 不存在的错误,需要将继承 TenantBaseDO 的数据库实体,都改成继承 BaseDO 基类。 ⑥ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.aop 不存在的错误,需要去除对 @TenantIgnore 注解的使用。如下图所示: ⑦ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.module.system.service.tenant 不存在的错误,需要去除对 TenantService 的使用。如下图所示: ⑧ 运行 YudaoServerApplication 启动类,会报 cn.iocoder.yudao.framework.tenant.core.context 不存在的错误,需要去除对 TenantContextHolder 的使用。如下图所示: ⑨ 运行 YudaoServerApplication 启动类,终于成功了!!! ps:可以将 application.yaml 配置文件中,对应的 yudao.tenant 配置项给进一步删除。 # 第四步,删除前端代码 以 yudao-admin-ui 为示例~ ① 删除 View 和 API 的前端代码: views/system/tenant (opens new window) views/system/tenantPackage (opens new window) api/system/tenant.js (opens new window) api/system/tenantPackage.js (opens new window) ② 在 yudao-admin-ui 目录下,执行 npm run local 成功。访问登录页,结果访问白屏。需要清理 login.vue 页,涉及 tenant 关键字的代码。例如说: 刷新,成功访问登录界面。 ③ 在 yudao-admin-ui 目录下,搜索 tenant 或 Tenant 关键字,可进一步清理多租户的代码。例如说: # 第五步,测试验收 至此,我们已经完成了多租户的代码删除,还是蛮艰辛的~ 后续,你可以简单测试一下,看看是不是删除代码,导致一些小问题。 # 更多... 如果你有其它功能想要删除,可以在 Issue (opens new window) 留言,可以不断补充到该文档。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/06, 01:38:02 一键改包 新建服务 ← 一键改包 新建服务→"},{"title":"功能列表","path":"/wiki/YuDaoCloud/萌新必读/功能列表/功能列表.html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 功能列表 芋道,以开发者为中心,打造中国第一流的快速开发平台,全部开源,个人与企业可 100% 免费使用。 管理后台的电脑端:Vue3 提供 element-plus (opens new window)、vben(ant-design-vue) (opens new window) 两个版本,Vue2 提供 element-ui (opens new window) 版本 管理后台的移动端:采用 uni-app (opens new window) 方案,一份代码多终端适配,同时支持 APP、小程序、H5! 后端采用 Spring Cloud Alibaba 微服务架构,注册中心 + 配置中心 Nacos,消息队列 RocketMQ,定时任务 XXL-Job,服务保障 Sentinel,服务网关 Gateway,分布式事务 Seata 数据库可使用 MySQL、Oracle、PostgreSQL、SQL Server、MariaDB、国产达梦 DM、TiDB 等,基于 MyBatis Plus、Redis + Redisson 操作 权限认证使用 Spring Security & Token & Redis,支持多终端、多种用户的认证系统,支持 SSO 单点登录 支持加载动态权限菜单,按钮级别权限控制,本地缓存提升性能 支持 SaaS 多租户系统,可自定义每个租户的权限,提供透明化的多租户底层封装 工作流使用 Flowable,支持动态表单、在线设计流程、会签 / 或签、多种任务分配方式 高效率开发,使用代码生成器可以一键生成前后端代码 + 单元测试 + Swagger 接口文档 + Validator 参数校验 集成微信小程序、微信公众号、企业微信、钉钉等三方登陆,集成支付宝、微信等支付与退款 集成阿里云、腾讯云等短信渠道,集成 MinIO、阿里云、腾讯云、七牛云等云存储服务 集成报表设计器、大屏设计器,通过拖拽即可生成酷炫的报表与大屏 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 🐼 内置功能 系统内置多种多种业务功能,可以用于快速你的业务系统: 系统功能 基础设施 工作流程 支付系统 会员中心 数据报表 商城系统 公众号系统 友情提示:本项目基于 RuoYi-Vue 修改,重构优化后端的代码,美化前端的界面。 额外新增的功能,我们使用 🚀 标记。 重新实现的功能,我们使用 ⭐️ 标记。 🙂 所有功能,都通过 单元测试 保证高质量。 # 系统功能 功能 描述 用户管理 用户是系统操作者,该功能主要完成系统用户配置 ⭐️ 在线用户 当前系统中活跃用户状态监控,支持手动踢下线 角色管理 角色菜单权限分配、设置角色按机构进行数据范围权限划分 菜单管理 配置系统菜单、操作权限、按钮权限标识等,本地缓存提供性能 部门管理 配置系统组织机构(公司、部门、小组),树结构展现支持数据权限 岗位管理 配置系统用户所属担任职务 🚀 租户管理 配置系统租户,支持 SaaS 场景下的多租户功能 🚀 租户套餐 配置租户套餐,自定每个租户的菜单、操作、按钮的权限 字典管理 对系统中经常使用的一些较为固定的数据进行维护 🚀 短信管理 短信渠道、短息模板、短信日志,对接阿里云、腾讯云等主流短信平台 🚀 邮件管理 邮箱账号、邮件模版、邮件发送日志,支持所有邮件平台 🚀 操作日志 系统正常操作日志记录和查询,集成 Swagger 生成日志内容 ⭐️ 登录日志 系统登录日志记录查询,包含登录异常 🚀 错误码管理 系统所有错误码的管理,可在线修改错误提示,无需重启服务 通知公告 系统通知公告信息发布维护 🚀 敏感词 配置系统敏感词,支持标签分组 🚀 应用管理 管理 SSO 单点登录的应用,支持多种 OAuth2 授权方式 🚀 地区管理 展示省份、城市、区镇等城市信息,支持 IP 对应城市 # 基础设施 功能 描述 🚀 代码生成 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 🚀 系统接口 基于 Swagger 自动生成相关的 RESTful API 接口文档 🚀 数据库文档 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 表单构建 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 🚀 配置管理 对系统动态配置常用参数,支持 SpringBoot 加载 🚀 文件服务 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 🚀 文件服务 支持本地文件存储,同时支持兼容 Amazon S3 协议的云服务、开源组件 🚀 API 日志 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 MySQL 监控 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 Redis 监控 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 🚀 消息队列 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 🚀 Java 监控 基于 Spring Boot Admin 实现 Java 应用的监控 🚀 链路追踪 接入 SkyWalking 组件,实现链路追踪 🚀 日志中心 接入 SkyWalking 组件,实现日志中心 🚀 分布式锁 基于 Redis 实现分布式锁,满足并发场景 🚀 幂等组件 基于 Redis 实现幂等组件,解决重复请求问题 🚀 服务保障 基于 Resilience4j 实现服务的稳定性,包括限流、熔断等功能 🚀 日志服务 轻量级日志中心,查看远程服务器的日志 🚀 单元测试 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 # 工作流程 功能 描述 🚀 流程模型 配置工作流的流程模型,支持文件导入与在线设计流程图,提供 7 种任务分配规则 🚀 流程表单 拖动表单元素生成相应的工作流表单,覆盖 Element UI 所有的表单组件 🚀 用户分组 自定义用户分组,可用于工作流的审批分组 🚀 我的流程 查看我发起的工作流程,支持新建、取消流程等操作,高亮流程图、审批时间线 🚀 待办任务 查看自己【未】审批的工作任务,支持通过、不通过、转发、委派、退回等操作 🚀 已办任务 查看自己【已】审批的工作任务,未来会支持回退操作 🚀 OA 请假 作为业务自定义接入工作流的使用示例,只需创建请求对应的工作流程,即可进行审批 # 支付系统 功能 描述 🚀 商户信息 管理商户信息,支持 Saas 场景下的多商户功能 🚀 应用信息 配置商户的应用信息,对接支付宝、微信等多个支付渠道 🚀 支付订单 查看用户发起的支付宝、微信等的【支付】订单 🚀 退款订单 查看用户发起的支付宝、微信等的【退款】订单 ps:核心功能已经实现,正在对接微信小程序中... # 数据报表 功能 描述 🚀 报表设计器 支持数据报表、图形报表、打印设计等 🚀 大屏设计器 拖拽生成数据大屏,内置几十种图表组件 # 微信公众号 功能 描述 🚀 账号管理 配置接入的微信公众号,可支持多个公众号 🚀 数据统计 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 🚀 粉丝管理 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 🚀 消息管理 查看粉丝发送的消息列表,可主动回复粉丝消息 🚀 自动回复 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 🚀 标签管理 对公众号的标签进行创建、查询、修改、删除等操作 🚀 菜单管理 自定义公众号的菜单,也可以从公众号同步菜单 🚀 素材管理 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 🚀 图文草稿箱 新增常用的图文素材到草稿箱,可发布到公众号 🚀 图文发表记录 查看已发布成功的图文素材,支持删除操作 # 商城系统 建设中... # 会员中心 和「商城系统」一起开发 # 🐷 演示图 # 系统功能 模块 biu biu biu 登录 & 首页 用户 & 应用 租户 & 套餐 - 部门 & 岗位 - 菜单 & 角色 - 审计日志 - 短信 字典 & 敏感词 ) 错误码 & 通知 - # 工作流程 模块 biu biu biu 流程模型 表单 & 分组 - 我的流程 待办 & 已办 OA 请假 # 基础设施 模块 biu biu biu 代码生成 - 文档 - 文件 & 配置 定时任务 - API 日志 - MySQL & Redis - 监控平台 # 支付系统 模块 biu biu biu 商家 & 应用 支付 & 退款 --- # 数据报表 模块 biu biu biu 报表设计器 大屏设计器 # 移动端(管理后台) biu biu biu 目前已经实现登录、我的、工作台、编辑资料、头像修改、密码修改、常见问题、关于我们等基础功能。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:23 视频教程 快速启动(适合“后端”工程师) ← 视频教程 快速启动(适合“后端”工程师)→"},{"title":"快速启动(适合“后端”工程师)","path":"/wiki/YuDaoCloud/萌新必读/快速启动(适合“后端”工程师)/快速启动(适合“后端”工程师).html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 快速启动(适合“后端”工程师) 目标:使用 IDEA 工具,将后端项目 yudao-cloud (opens new window) 运行起来,并按需启动前端项目。 整个过程非常简单,预计 30 分钟就可以完成,取决于大家的网速。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ # 👍 相关视频教程 从零开始 02:在 Windows 环境下,如何运行前后端项目? (opens new window) 从零开始 03:在 MacOS 环境下,如何运行前后端项目? (opens new window) # 1. 克隆代码 使用 IDEA (opens new window) 克隆 https://github.com/YunaiV/yudao-cloud (opens new window) 仓库的最新代码,并给该仓库一个 Star (opens new window)。 友情提示:IDEA 请使用至少 2020 版本,不知道怎么激活的可以看看 《IDEA 破解新招 - 无限重置30天试用期(适用于 2018、2019、2020、2021 所有版本) 》 (opens new window) 文章! 注意:不支持使用 Eclipse 启动项目,因为它没有支持 Lombok 和 Mapstruct 的插件。 克隆完成后,耐心等待 Maven 下载完相关的依赖。 友情提示:项目的每个模块的作用,可见 《开发指南 —— 项目结构》 文档。 使用的 Spring Cloud 版本较新,所以需要下载一段时间。趁着这个时间,胖友可以给项目添加一个 Star (opens new window),支持下艿艿。 # 2. Apifox 接口工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 接口文档? 阅读 《开发指南 —— 接口文档》 呀~~ # 3. 基础设施(必选) 本小节的基础设施【必须】安装,否则项目无法启动。 # 3.1 初始化 MySQL 友情提示? 如果你是 PostgreSQL、Oracle、SQL Server 等其它数据库,也是可以的。 因为我主要使用 MySQL数据库为主,所以其它数据库的 SQL 文件可能存在滞后,可以加入 用户群 反馈。 补充说明? 由于工作较忙,暂时未拆分到多个数据库,可以按照前缀自行处理: system_ 前缀,属于 yudao-module-system 服务 infra_ 前缀,属于 yudao-module-infra 服务 项目使用 MySQL 存储数据,所以需要启动一个 MySQL 服务,建议使用 5.7 版本。 ① 创建一个名字为 ruoyi-vue-pro 数据库,执行对应数据库类型的 sql (opens new window) 目录下的 SQL 文件,进行初始化。 ② 默认配置下,MySQL 需要启动在 3306 端口,并且账号是 root,密码是 123456。如果不一致,需要修改 application-local.yaml 配置文件。 # 3.2 初始化 Redis 项目使用 Redis 缓存数据,所以需要启动一个 Redis 服务。 一定要使用 5.0 以上的版本,项目使用 Redis Stream 作为消息队列。 不会安装的胖友,可以选择阅读下文,良心的艿艿。 Windows 安装 Redis 指南:http://www.iocoder.cn/Redis/windows-install (opens new window) Mac 安装 Redis 指南:http://www.iocoder.cn/Redis/mac-install (opens new window) 默认配置下,Redis 启动在 6379 端口,不设置账号密码。如果不一致,需要修改 application-local.yaml 配置文件。 # 3.3 初始化 Nacos 项目使用 Nacos 作为注册中心和配置中心,参考 《芋道 Nacos 极简入门》 (opens new window) 文章,进行安装,只需要看该文的 「2. 单机部署(最简模式)」 即可。 安装完成之后,需要创建 dev 命名空间,如下图所示: Nacos 拓展学习资料: 《芋道 Spring Cloud Alibaba 配置中心 Nacos 入门》 (opens new window) 对应 labx-05-spring-cloud-alibaba-nacos-config (opens new window) 《芋道 Spring Cloud Alibaba 注册中心 Nacos 入门》 (opens new window) 对应 labx-01-spring-cloud-alibaba-nacos-discovery (opens new window) # 4. 基础设施(可选) 本小节的基础设施【可选】安装,不影响项目的启动,可在项目启动后再安装。 # 4.1 RocketMQ 项目使用 RocketMQ 作为消息中心和事件总线,参考 《芋道 RocketMQ 极简入门》 (opens new window) 文章,进行安装,只需要看该文的 「2. 单机部署」 即可。 Seata 拓展学习资料: 《芋道 Spring Cloud Alibaba 消息队列 RocketMQ 入门》 (opens new window) 对应 labx-06-spring-cloud-stream-rocketmq (opens new window) 《芋道 Spring Cloud Alibaba 事件总线 Bus RocketMQ 入门》 (opens new window) 对应 labx-06-spring-cloud-stream-rocketmq (opens new window) 《性能测试 —— RocketMQ 基准测试》 (opens new window) # 4.2 XXL-Job ① 项目使用 XXL-Job 作为定时任务,参考 《芋道 XXL-Job 极简入门》 (opens new window) 文章,进行安装,只需要看该文的 「4. 搭建调度中心」 即可。 注意,需要修改 application.yaml 配置文件,修改 server.port 为 9090。 ② 默认配置下,本地 local 环境的定时任务是关闭的,避免控制台一直报错报错。如果要开启,请参考 《微服务手册 —— 定时任务》 文档。 # 4.3 Seata TODO 暂时忽略,后续版本引入 Seata 拓展学习资料: 《芋道 Spring Cloud Alibaba 分布式事务 Seata 入门 》 (opens new window) 对应 对应 labx-17 (opens new window) # 4.4 Sentinel TODO 暂时忽略,后续版本引入 Sentinel 拓展学习资料: 《芋道 Spring Cloud Alibaba 服务容错 Sentinel 入门 》 (opens new window) 对应 labx-04-spring-cloud-alibaba-sentinel (opens new window) # 4.5 Elasticsearch TODO 暂时忽略,后续版本引入 Elasticsearch 拓展学习资料: 《芋道 Spring Boot Elasticsearch 入门》 (opens new window) 《芋道 ELK(Elasticsearch + Logstash + Kibana) 极简入门》 (opens new window) # 5. 启动后端项目 # 5.1 编译项目 使用 IDEA 打开 Terminal 终端,在根目录下直接执行 mvn clean install package '-Dmaven.test.skip=true' 命令,将项目进行初始化的打包,预计需要 1 分钟左右。成功后,控制台日志如下: [INFO] BUILD SUCCESS[INFO] ------------------------------------------------------------------------[INFO] Total time: 01:12 min[INFO] Finished at: 2022-02-12T09:52:38+08:00[INFO] Final Memory: 250M/2256M[INFO] ------------------------------------------------------------------------ JDK 版本的选择? 如下的 JDK 版本,是艿艿在本地测试通过的 JDK 8 版本:尽量保证 >= 1.8.0_144 JDK 11 版本:尽量保证 >= 11.0.14 JDK 17 版本:尽量保证 >= 17.0.2 如果 JDK 版本过低,包括 JDK 的小版本过低,也会 mvn 编译报错。例如说: “编译器(1.8.0_40)中出现编译错误“。此处,升级下 JDK 版本即可。 Maven 补充说明: ① 只有首次需要执行 Maven 命令,解决基础 pom.xml 文件不存在,导致报 BaseDbUnitTest 类不存在的问题。 ② 如果执行报 Unknown lifecycle phase “.test.skip=true” 错误,使用 mvn clean install package -Dmaven.test.skip=true 即可。 # 5.2 启动 gateway 服务 执行 GatewayServerApplication (opens new window) 类,进行启动。 启动还是报类不存在? 可能是 IDEA 的 bug,点击 [File -> Invalidate Caches] 菜单,清空下缓存,重启后在试试看。 启动完成后,使用浏览器访问 http://127.0.0.1:48080 (opens new window) 地址,返回如下 JSON 字符串,说明成功。 友情提示:注意,默认配置下,网关启动在 48080 端口。 {"code":404,"data":null,"msg":null} 如果报 “Command line is too long” 错误,参考 《Intellij IDEA 运行时报 Command line is too long 解决方法 》 (opens new window) 文章解决,或者直接点击 YudaoServerApplication 蓝字部分! # 5.3 启动 system 服务 执行 SystemServerApplication (opens new window) 类,进行启动。 启动完成后,使用浏览器访问 http://127.0.0.1:48081/admin-api/system/ (opens new window) 和 http://127.0.0.1:48080/admin-api/system/ (opens new window) 地址,都返回如下 JSON 字符串,说明成功。 友情提示:注意,默认配置下,yudao-module-system 服务启动在 48081 端口。 {"code":401,"data":null,"msg":"账号未登录"} # 5.3 启动 infra 服务 执行 InfraServerApplication ( opens new window) 类,进行启动。 启动完成后,使用浏览器访问 http://127.0.0.1:48082/admin-api/infra/ ( opens new window) 和 http://127.0.0.1:48080/admin-api/infra/ ( opens new window) 地址,都返回如下 JSON 字符串,说明成功。 友情提示:注意,默认配置下,yudao-module-infra 服务启动在 48082 端口。 {"code":401,"data":null,"msg":"账号未登录"} # 5.4 启动 bpm 服务 参见 《工作流手册 —— 工作流》 文档。 # 5.5 启动 report 服务 参见 《大屏手册 —— 报表设计器》 文档。 # 5.6 启动 pay 服务 适配中,预计 3 - 4 月份完成。 # 5.7 启动 mall 服务 适配中,预计 6 月份完成。 # 6. 启动前端项目【简易】 在 yudao-ui-static ( opens new window) 项目中,提前编译好了前端项目的静态资源,可以直接体验和使用。操作步骤如下: ① 克隆 https://gitee.com/yudaocode/yudao-ui-static ( opens new window) 项目,运行 UiConfiguration 类,进行启动。 ② 访问 http://127.0.0.1:2048/admin-ui-vue2/ ( opens new window) 地址,可以看到 Vue2 管理后台。 ② 访问 http://127.0.0.1:2048/admin-ui-vue3/ ( opens new window) 地址,可以看到 Vue3 + element-plus 管理后台。 ③ 访问 http://127.0.0.1:2048/admin-ui-vben/ ( opens new window) 地址,可以看到 Vue3 + vben(ant-design-vue) 管理后台。 补充说明: 前端项目是不定期编译,可能不是最新版本。 如果需要最新版本,请继续往下看。 # 7. 启动前端项目【完整】 项目提供了多套前端项目,可以按需启动哈。 友情提示:可能胖友本地没有安装 Node.js 的环境,导致报错。可以参考如下文档安装: Windows 安装 Node.js 指南:http://www.iocoder.cn/NodeJS/windows-install ( opens new window) Mac 安装 Node.js 指南:http://www.iocoder.cn/NodeJS/mac-install ( opens new window) # 7.1 启动 Vue3 + element-plus 管理后台 yudao-ui-admin-vue3 ( opens new window) 是前端 Vue3 + element-plus 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vue3.git ( opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 友情提示:Vue3 使用 Vite 构建,所以它存在如下的情况,都是正常的: 项目启动很快,浏览器打开需要等待 1 分钟左右,请保持耐心。 点击菜单,感觉会有一点卡顿,因为 Vite 采用懒加载机制。不用担心,最终部署到生产环境,就不存在这个问题了。 详细说明,可见 《为什么有人说 Vite 快,有人却说 Vite 慢?》 (opens new window) 文章。 # 7.2 启动 Vue3 + vben(ant-design-vue) 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 + vben(ant-design-vue) 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vben.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run dev ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 # 7.3 启动 Vue2 管理后台 yudao-ui-admin (opens new window) 是前端 Vue2 管理后台项目。 ① 在 yudao-ui-admin 目录下,执行如下命令,进行启动: # 进入项目目录cd yudao-ui-admin# 安装 Yarn,提升依赖的安装速度npm install --global yarn# 安装依赖yarn install# 启动服务npm run local ② 启动完成后,浏览器会自动打开 http://localhost:1024 (opens new window) 地址,可以看到前端界面。 # 7.4 启动 uni-app 管理后台 yudao-ui-admin-uniapp (opens new window) 是前端 uni-app 管理后台项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-admin-uniapp 目录 ③ 执行如下命令,安装 npm 依赖: # 进入项目目录cd yudao-ui-admin-uniapp# 安装 npm 依赖npm i ④ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: 友情提示:登录时,滑块验证码,在内存浏览器可能存在兼容性的问题,此时使用 Chrome 浏览器,并使用“开发者工具”,设置为 iPhone 12 Pro 模式! # 7.5 启动 uni-app 用户前台 yudao-ui-app (opens new window) 是前端 uni-app 用户前台项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-app 目录 ③ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: # 666. 彩蛋 至此,我们已经完成了项目 ruoyi-vue-pro (opens new window) 的启动。 胖友可以根据自己的兴趣,阅读相关源码。如果你想更快速的学习,可以看看 《视频教程 》 教程哟。 后面,艿艿会花大量的时间,继续优化这个项目。同时,输出与项目匹配的技术博客,方便胖友更好的学习与理解。 还是那句话,😆 为开源继绝学,我辈义不容辞! 嘿嘿嘿,记得一定要给 https://github.com/YunaiV/yudao-cloud (opens new window) 一个 star,这对艿艿真的很重要。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/15, 00:05:05 功能列表 快速启动(适合“前端”工程师) ← 功能列表 快速启动(适合“前端”工程师)→"},{"title":"接口文档","path":"/wiki/YuDaoCloud/萌新必读/接口文档/接口文档.html","content":"开发指南萌新必读 芋道源码 2022-03-26 目录 接口文档 项目使用 Swagger 实现 RESTful API 的接口文档,提供两种解决方案: *【推荐】 Apifox (opens new window):强大的 API 工具,支持 API 文档、API 调试、API Mock、API 自动化测试 Knife4j:简易的 API 工具,仅支持 API 文档、API 调试 为什么选择 Swagger 呢? Swagger 通过 Java 注解实现 API 接口文档的编写。相比使用 Java 注释的方式,注解提供更加规范的接口定义方式,开发体验更好。 如果你没有学习 Swagger,可以阅读 《芋道 Spring Boot API 接口文档 Swagger 入门 》 (opens new window) 文章。 每个服务都会启动 Swagger 的接口文档,方便开发者进行 API 调试。下述的内容,使用 system-server 系统服务举例子,它的端口是 48081。 注意!注意!注意!文章部分图中,看到的是 48080 端口,实际你都填写 48081。 # 1. Apifox 使用 本小节,我们来将项目中的 API 接口,一键导入到 Apifox 中,并使用它发起一次 API 的调用。 # 1.1 下载工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 # 1.2 API 导入 ① 先点击「示例项目」,再点击「+」按钮,选择「导入」选项。 ② 先选择「URL 导入」按钮,填写 Swagger 数据 URL 为 http://127.0.0.1:48081/v3/api-docs。 ③ 先点击「提交」按钮,再点击「确认导入」按钮,完成 API 接口的导入。 ④ 导入完成后,点击「接口管理」按钮,可以查看到 API 列表。 # 1.3 API 调试 ① 先点击右上角「请选择环境」,再点击「管理环境」选项,填写测试环境的地址为 http://127.0.0.1:48081,并进行保存。 ② 点击「管理后台 —— 认证」的「使用账号密码登录」接口,查看该 API 接口的定义。 ③ 点击「运行」按钮,填写 Headers 的 tenant-id 为 1,再点击 Body 的「自动生成」按钮,最后点击「发送」按钮。 # 2. Knife4j 使用 浏览器访问 http://127.0.0.1:48081/doc.html (opens new window) 地址,使用 Knife4j 查看 API 接口文档。 ① 点击任意一个接口,进行接口的调用测试。这里,使用「管理后台 - 用户个中心」的“获得登录用户信息”举例子。 ② 点击左侧「调试」按钮,并将请求头部的 header-id 和 Authorization 勾选上。 其中,header-id 为租户编号,Authorization 的 \"Bearer test\" 后面为用户编号(模拟哪个用户操作)。 ③ 点击「发送」按钮,即可发起一次 API 的调用。 如何使用 Gateway 网关,聚合各个服务的接口文档? 参见 《微服务手册 —— 服务网关》 文档 # 3. Swagger 技术组件 ① 在 yudao-spring-boot-starter-web (opens new window) 技术组件的 swagger (opens new window) 包,实现了对 Swagger 的封装。 ② 如果想要禁用 Swagger 功能,可通过 springdoc.api-docs.enable 配置项为 false。一般情况下,建议 prod 生产环境进行禁用,避免发生安全问题。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/05, 12:34:30 快速启动(适合“前端”工程师) 技术选型 ← 快速启动(适合“前端”工程师) 技术选型→"},{"title":"【v1.7.1】2023-03-05","path":"/wiki/YuDaoCloud/更新日志/【v1.7.1】2023-03-05/【v1.7.1】2023-03-05.html","content":"开发指南更新日志 芋道源码 2023-01-30 目录 【v1.7.1】2023-03-05 # 新增 Vue3 管理后台支持工作流、大屏设计器,升级 OpenAPI 3.0 接口文档 # 📈 Statistic 总代码行数:126673 源码代码行数:78532 注释行数:28594 单元测试用例数:782 # ⭐ New Features 【重构】Vue3 管理后台调整到 GitHub (opens new window)、Gitee (opens new window) 地址,逐步分离前端和后端仓库,保证 Git commit 日志的整洁! 【新增】Vue3 工作流的,由 @周建 (opens new window)、@xingyu4j (opens new window) 贡献 #397 (opens new window)、#401 (opens new window)、#407 (opens new window)、#6 (opens new window)、#7 (opens new window)、#12 (opens new window) 【新增】基于 Go-View 共建大屏设计器,支持 Vue2 和 Vue3 管理后台,由 @芋道源码 (opens new window) 贡献 #403 (opens new window) 【新增】支付收银台,接入支付宝的 PC、Wap、二维码、条码、App 等支付方式,由 @芋道源码 (opens new window) 贡献 #403 (opens new window) 【新增】接口文档使用 OpenAPI 3.0 实现,@xingyu4j (opens new window) 贡献 #380 (opens new window) 【优化】菜单新增 alwaysShow 总是展示、componentName 组件名,由 @芋道源码 (opens new window) 贡献 #408 (opens new window) 【优化】system 模块的 Service 逻辑单元测试,单测数量 423,方法行覆盖率 95%,行覆盖率 93%,由 @芋道源码 (opens new window) 贡献 #392 (opens new window) 【优化】infra 模块的 Service 逻辑单元测试,单测数量 81,方法行覆盖率 63%,行覆盖率 47%,由 @芋道源码 (opens new window) 贡献 #393 (opens new window) 【优化】清理单元测试多余的 SQL 脚本,由 @niu_dehua (opens new window) 贡献 #345 (opens new window) 【优化】《后端手册 —— 快速启动》 (opens new window)文档,由 @芋道源码 (opens new window) 贡献 【优化】解决 Vue2 管理后台,只有一个菜单时,不展父菜单/目录的情况,由 @zhang.xionghui (opens new window) 贡献 #394 (opens new window) 【优化】缓存部门的变量命名,由 @重楼 (opens new window) 贡献 #421 (opens new window) 【新增】《萌新必读 —— 快速启动(我是前端)》 (opens new window) 文档,适合前端同学启动前端项目 # 🐞 Bug Fixes 【修复】Vue3 管理后台的tagViews 左右两侧按钮不能垂直居中的问题,由 @AKING (opens new window) 贡献 #406 (opens new window) 【修复】项目启动,链接数据查询时控制台报错 SQLNonTransientConnectionException 异常,由 @zhang (opens new window) 贡献 #406 (opens new window) 【修复】Redis Pub/Sub 广播消费的容器,默认未启动的问题,由 @筱龙缘 (opens new window) 贡献 #415 (opens new window) 【修复】MySQL 连接为 Asia/Shanghai 本地时区,由 @小桂子 (opens new window) 贡献 #409 (opens new window) #410 (opens new window) 【修复】代码生成器的同步报错问题,由 @Rex (opens new window) 贡献 #413 (opens new window) 【修复】登录选择钉钉等第三方弹窗后,点击取消弹窗后恢复登录按钮 loading 状态,由 @thisliuyang (opens new window) 贡献 #217 (opens new window) 【修复】去掉 Swagger 自动配置类中的冗余配置,由 @zhangxingjia (opens new window) 贡献 #424 (opens new window) 【修复】用户详情不显示所属部门部门,由 @babylazsss (opens new window) 贡献 #424 (opens new window) 【修复】GitHub Action 自动 build 前端报错的问题,由 @六楼的雨 (opens new window) 贡献 #424 (opens new window) 【修复】Vue3 管理后台:新增”字典类型“的时候,字典类型的必填校验不通过,由 @六楼的雨 (opens new window) 贡献 #1 (opens new window) 【修复】Vue3 管理后台:字典点击表格红色报错修改;keepalive 缓存 toCamelCase 设置中去掉 ‘-’,保留驼峰命名;新增 Search 组件新增插槽传递;topActionSlots: false 报错修改;tagsView.ts 删除页面缓存优化;,由 @毕梅 (opens new window) 贡献 #2 (opens new window) 【修复】Vue3 管理后台:部分逻辑的规范代码(eslint),由 @孔思宇 (opens new window) 贡献 #4 (opens new window) 【修复】Vue3 管理后台:build script 增加内存配置(解决 nodejs 默认配置内存溢出),由 @孔思宇 (opens new window) 贡献 #5 (opens new window) 【修复】Vue3 管理后台:分配角色的权限 el-tree 组件 setCheckedKeys 设置一旦选中父级子级也被选中,由 @当时明月在 (opens new window) 贡献 #8 (opens new window) 【修复】Vue3 管理后台:XTable 中主题颜色不跟随项目主体一起切换,由 由 @毕梅 (opens new window) 贡献 #12 (opens new window) # 🔨 Dependency Upgrades 【升级】spring-boot from 2.7.7 to 2.7.8 【升级】easy-excel from 3.1.5 to 3.2.0 【升级】captcha-plus from 1.0.1 to 1.0.2 【升级】jedis-mock from 1.0.5 to 1.0.6 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/05, 20:06:33 【v1.7.2】2023-04-19 【v1.7.0】2023-01-30 ← 【v1.7.2】2023-04-19 【v1.7.0】2023-01-30→"},{"title":"技术选型","path":"/wiki/YuDaoCloud/萌新必读/技术选型/技术选型.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 技术选型 # 技术架构图 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 👻 后端 # 系统环境 框架 说明 版本 学习指南 JDK Java 开发工具包 >= 1.8.0 书单 (opens new window) Maven Java 管理与构建工具 >= 3.5.0 书单 (opens new window) Nginx 高性能 Web 服务器 - 文档 (opens new window) # 主框架 框架 说明 版本 学习指南 Spring Cloud Alibaba (opens new window) 微服务框架 2021.0.4.0 文档 (opens new window) Spring MVC (opens new window) MVC 框架 5.3.24 文档 (opens new window) Spring Security (opens new window) Spring 安全框架 5.7.6 文档 (opens new window) Hibernate Validator (opens new window) 参数校验组件 6.2.5 文档 (opens new window) # 存储层 框架 说明 版本 学习指南 MySQL (opens new window) 数据库服务器 >= 5.7 书单 (opens new window) Druid (opens new window) JDBC 连接池、监控组件 1.2.14 文档 (opens new window) MyBatis Plus (opens new window) MyBatis 增强工具包 3.5.3.1 文档 (opens new window) Dynamic Datasource (opens new window) 动态数据源 3.6.1 文档 (opens new window) Redis (opens new window) key-value 数据库 >= 5.0 书单 (opens new window) Redisson (opens new window) Redis 客户端 3.17.7 文档 (opens new window) # 中间件 框架 说明 版本 学习指南 Nacos (opens new window) 配置中心 & 注册中心 2.0.4 文档 (opens new window) RocketMQ (opens new window) 消息队列 4.9.4 文档 (opens new window) Sentinel (opens new window) 服务保障 1.8.6 文档 (opens new window) XXL Job (opens new window) 定时任务 2.3.1 文档 (opens new window) Spring Cloud Gateway (opens new window) 服务网关 3.4.1 文档 (opens new window) Seata (opens new window) 分布式事务 1.6.1 文档 (opens new window) Flowable (opens new window) 工作流引擎 6.7.2 文档 (opens new window) # 系统监控 框架 说明 版本 学习指南 Spring Boot Admin (opens new window) Spring Boot 监控平台 2.6.10 文档 (opens new window) SkyWalking (opens new window) 分布式应用追踪系统 8.5.0 文档 (opens new window) # 单元测试 框架 说明 版本 学习指南 JUnit (opens new window) Java 单元测试框架 5.8.2 - Mockito (opens new window) Java Mock 框架 4.8.0 - # 其它工具 框架 说明 版本 学习指南 Springdoc (opens new window) Swagger 文档 1.6.15 文档 (opens new window) Jackson (opens new window) JSON 工具库 2.13.3 MapStruct (opens new window) Java Bean 转换 1.5.3.Final 文档 (opens new window) Lombok (opens new window) 消除冗长的 Java 代码 1.18.26 文档 (opens new window) # 👾 前端 # 管理后台(Vue3 + ElementPlus) 框架 说明 版本 Vue (opens new window) vue 框架 3.2.45 Vite (opens new window) 开发与构建工具 4.0.1 Element Plus (opens new window) Element Plus 2.2.26 TypeScript (opens new window) JavaScript 的超集 4.9.4 pinia (opens new window) Vue 存储库 替代 vuex5 2.0.28 vueuse (opens new window) 常用工具集 9.6.0 vxe-table (opens new window) vue 最强表单 4.3.7 vue-i18n (opens new window) 国际化 9.2.2 vue-router (opens new window) vue 路由 4.1.6 windicss (opens new window) 下一代工具优先的 CSS 框架 3.5.6 iconify (opens new window) 在线图标库 3.0.0 wangeditor (opens new window) 富文本编辑器 5.1.23 # 管理后台(Vue3 + Vben + Ant-Design-Vue) 框架 说明 版本 Vue (opens new window) Vue 框架 3.2.47 Vite (opens new window) 开发与构建工具 4.3.0 ant-design-vue (opens new window) ant-design-vue 3.2.17 TypeScript (opens new window) JavaScript 的超集 5.0.4 pinia (opens new window) Vue 存储库 替代 vuex5 2.0.34 vueuse (opens new window) 常用工具集 9.13.0 vue-i18n (opens new window) 国际化 9.2.2 vue-router (opens new window) Vue 路由 4.1.6 windicss (opens new window) 下一代工具优先的 CSS 框架 3.5.6 iconify (opens new window) 在线图标库 3.1.0 # 管理后台(Vue2) 框架 说明 版本 学习指南 Node (opens new window) JavaScript 运行时环境 >= 12 - Vue (opens new window) JavaScript 框架 2.7.14 书单 (opens new window) Vue Element Admin (opens new window) 后台前端解决方案 2.5.10 # 管理后台(uni-app) 框架 说明 版本 uni-app 跨平台框架 2.0.0 uni-ui (opens new window) 基于 uni-app 的 UI 框架 1.4.20 # 用户 App 框架 说明 版本 学习指南 Vue (opens new window) JavaScript 框架 2.6.12 书单 (opens new window) UniApp (opens new window) 小程序、H5、App 的统一框架 - - .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 23:29:24 接口文档 项目结构 ← 接口文档 项目结构→"},{"title":"快速启动(适合“前端”工程师)","path":"/wiki/YuDaoCloud/萌新必读/快速启动(适合“前端”工程师)/快速启动(适合“前端”工程师).html","content":"开发指南萌新必读 芋道源码 2023-03-05 目录 快速启动(适合“前端”工程师) 目标:在 本地 将前端项目运行起来,使用 远程 演示环境的后端服务。 整个过程非常简单,预计 5 分钟就可以完成,取决于大家的网速。 ↓↓↓ 技术交流群,一起苦练技术基本功,每日精进 30 公里!↓↓↓ 友情提示: 远程 演示环境的后端服务,只允许 GET 请求,不允许 POST、PUT、DELETE 等请求。 如果你要完整的后端服务,建议后续参考 《快速启动(我是后端)》 文档,将后端服务运行起来。 # 👍 相关视频教程 从零开始 02:在 Windows 环境下,如何运行前后端项目? (opens new window) 从零开始 03:在 MacOS 环境下,如何运行前后端项目? (opens new window) # 1. Apifox 接口工具 点击 Apifox (opens new window) 首页,下载对应的 Apifox 桌面版。如下图所示: 为什么要下载 Apifox 桌面版? 艿艿已经卸载 Postman,使用 Apifox 进行替代。国产软件,yyds 永远滴神! 国内很多互联网公司,包括百度、阿里、腾讯、字节跳动等等在内,都在使用 Apifox 作为 API 工具。 解压后,双击进行安装即可。黑色界面,非常酷炫。 接口文档? 阅读 《开发指南 —— 接口文档》 呀~~ # 2. 启动 Vue3 + element-plus 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vue3.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run front ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 友情提示:Vue3 使用 Vite 构建,所以它存在如下的情况,都是正常的: 项目启动很快,浏览器打开需要等待 1 分钟左右,请保持耐心。 点击菜单,感觉会有一点卡顿,因为 Vite 采用懒加载机制。不用担心,最终部署到生产环境,就不存在这个问题了。 详细说明,可见 《为什么有人说 Vite 快,有人却说 Vite 慢?》 (opens new window) 文章。 # 3. 启动 Vue3 + vben(ant-design-vue) 管理后台 yudao-ui-admin-vue3 (opens new window) 是前端 Vue3 + vben(ant-design-vue) 管理后台项目。 ① 克隆 https://github.com/yudaocode/yudao-ui-admin-vben.git (opens new window) 项目,并 Star 关注下该项目。 ② 在根目录执行如下命令,进行启动: # 安装 pnpm,提升依赖的安装速度npm config set registry https://registry.npmjs.orgnpm install -g pnpm# 安装依赖pnpm install# 启动服务npm run front ③ 启动完成后,浏览器会自动打开 http://localhost:80 (opens new window) 地址,可以看到前端界面。 # 4. 启动 Vue2 管理后台 yudao-ui-admin (opens new window) 是前端 Vue2 管理后台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 在 yudao-ui-admin 目录下,执行如下命令,进行启动: # 进入项目目录cd yudao-ui-admin# 安装 Yarn,提升依赖的安装速度npm install --global yarn# 安装依赖yarn install# 启动服务npm run front ② 启动完成后,浏览器会自动打开 http://localhost:1024 (opens new window) 地址,可以看到前端界面。 # 5. 启动 uni-app 管理后台 yudao-ui-admin-uniapp (opens new window) 是前端 uni-app 管理后台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-admin-uniapp 目录。 然后,修改 config.js 配置文件的 baseUrl 后端服务的地址为 'http://api-dashboard.yudao.iocoder.cn。如下图所示: ③ 执行如下命令,安装 npm 依赖: # 进入项目目录cd yudao-ui-admin-uniapp# 安装 npm 依赖npm i ④ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: 友情提示:登录时,滑块验证码,在内存浏览器可能存在兼容性的问题,此时使用 Chrome 浏览器,并使用“开发者工具”,设置为 iPhone 12 Pro 模式! # 6. 启动 uni-app 用户前台 yudao-ui-app (opens new window) 是前端 uni-app 用户前台项目。 〇 克隆 https://github.com/YunaiV/ruoyi-vue-pro.git (opens new window) 项目,并 Star 关注下该项目。 ① 下载 HBuilder (opens new window) 工具,并进行安装。 ② 点击 HBuilder 的 [文件 -> 导入 -> 从本地项目导入...] 菜单,选择项目的 yudao-ui-app 目录 然后,修改 config.js 配置文件的 baseUrl 后端服务的地址为 'http://api-dashboard.yudao.iocoder.cn/app-api。如下图所示: ③ 点击 HBuilder 的 [运行 -> 运行到内置浏览器] 菜单,使用 H5 的方式运行。成功后,界面如下图所示: # 7. 参与项目 如果你想参与到前端项目的开发,可以微信 wangwenbin-server 噢。 近期,重点开发 Vue3 管理后台、uniapp 商城,欢迎大家参与进来。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/15, 00:05:05 快速启动(适合“后端”工程师) 接口文档 ← 快速启动(适合“后端”工程师) 接口文档→"},{"title":"简介","path":"/wiki/YuDaoCloud/萌新必读/简介/简介.html","content":"开发指南萌新必读 芋道源码 2022-03-01 目录 简介 yudao-cloud (opens new window),RuoYi-Vue 全新 Cloud 版本,优化重构所有功能。 基于 Spring Cloud Alibaba + MyBatis Plus + Vue & Element 实现的后台管理系统 + UniApp 微信小程序,支持 RBAC 动态权限、数据权限、SaaS 多租户、Activiti + Flowable 工作流、三方登录、支付、短信、商城等功能。 (opens new window) (opens new window) 😆 为开源继绝学,我辈义不容辞! 2017 年,艿艿创建「芋道源码」公众号,帮助了 20w+ 工程师学习优秀框架的源码。 2019 年,看了 Gitee 和 Github 非常多的业务开源项目,无法到达代码整洁、架构整洁。 于是,艿艿利用休息时间,每天肝到晚上 1 点多,如此便有了芋道管理后台 + 微信小程序。 # 🐴 严肃声明 现在、未来都不会有商业版本,所有代码全部开源! 「我喜欢写代码,乐此不疲」 「我喜欢做开源,以此为乐」 我 🐶 在上海艰苦奋斗,早中晚在 top3 大厂认真搬砖,夜里为开源做贡献。 如果这个项目让你有所收获,记得 Star 关注哦,这对我是非常不错的鼓励与支持。 # 🐳 项目关系 三个项目的功能对比,可见社区共同整理的 国产开源项目对比 (opens new window) 表格。 # 后端项目 项目 Star 简介 ruoyi-vue-pro (opens new window) (opens new window) (opens new window) 基于 Spring Boot 多模块架构 yudao-cloud (opens new window) (opens new window) (opens new window) 基于 Spring Cloud 微服务架构 Spring-Boot-Labs (opens new window) (opens new window) (opens new window) 系统学习 Spring Boot & Cloud 专栏 # 前端项目 项目 Star 简介 yudao-ui-admin-vue3 (opens new window) (opens new window) (opens new window) 基于 Vue3 + element-plus 实现的管理后台 yudao-ui-admin (opens new window) (opens new window) (opens new window) 基于 Vue2 + element-ui 实现的管理后台 yudao-ui-admin-uniapp (opens new window) (opens new window) (opens new window) 基于 uni-app + uni-ui 实现的管理后台的小程序 yudao-ui-go-view (opens new window) (opens new window) (opens new window) 基于 Vue3 + naive-ui 实现的大屏报表 yudao-ui-app (opens new window) (opens new window) (opens new window) 基于 uni-app + uview 实现的用户 App # 🐶 在线体验 演示地址【Vue3 + element-plus】:http://dashboard-vue3.yudao.iocoder.cn (opens new window) 演示地址【Vue3 + vben(ant-design-vue)】:http://dashboard-vben.yudao.iocoder.cn (opens new window) 演示地址【Vue2 + element-ui】:http://dashboard.yudao.iocoder.cn (opens new window) 如果你要搭建本地环境,可参考如下文档: 《开发指南 —— 快速启动(适合“后端”工程师)》 《开发指南 —— 快速启动(适合“前端”工程师)》 # 📚 国内顶级开源项目对比 社区整理,欢迎补充!传送门 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/24, 08:45:23 交流群 交流群→"},{"title":"Jenkins 部署","path":"/wiki/YuDaoCloud/运维手册/Jenkins 部署/Jenkins 部署.html","content":"开发指南运维手册 芋道源码 2022-04-15 目录 Jenkins 部署 友情提示:目前是 Boot 项目的部署,后续会调整成 Cloud 项目的部署 本小节,讲解如何将前端 + 后端项目,使用 Jenkins 工具,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: 友情提示: 本文是 《开发指南 —— Linux 部署》 的加强版,差别在于使用 Jenkins 部署。 # 1. 安装 Jenkins 阅读 《芋道 Jenkins 极简入门 》 (opens new window) 文章,进行 Jenkins 的安装。 # 2. 部署后端 阅读 《芋道 Spring Boot 持续交付 Jenkins 入门 》 (opens new window) 文章,进行后端的部署。 可参考 Jenkins 配置如下: # 3. 部署前端 可参考 Jenkins 配置如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 Docker 部署 HTTPS 证书 ← Docker 部署 HTTPS 证书→"},{"title":"HTTPS 证书","path":"/wiki/YuDaoCloud/运维手册/HTTPS 证书/HTTPS 证书.html","content":"开发指南运维手册 芋道源码 2022-04-16 目录 HTTPS 证书 本小节,讲解如何在 Nginx 配置 SSL 证书,实现前端和后端使用 HTTPS 安全访问的功能。 考虑到各大云服务厂商的文档写的比较齐全,这里更多做汇总与整理。 😜 如果想要免费的 SSL 证书,请申请 DV 单域名证书。如果要配置多个域名,可以申请多个 DV 单域名证书。 友情提示:HTTPS 的学习资料? 《HTTPS 的工作原理》 (opens new window) 《面试官:你连 HTTPS 原理没搞懂,还给我讲“中间人攻击”?》 (opens new window) # 1. 阿里云 SSL【最常用】 阿里云 SSL 证书 (opens new window) 第一步,免费证书申购流程 (opens new window) 第二步,在 Nginx 或 Tengine 服务器上安装证书 (opens new window) ↑ 点击观看 ↑ (opens new window)# 2. FreeSSL【最便宜】 FreeSSL.cn (opens new window),一个提供免费 HTTPS 证书申请的网站。 《如何在 Nginx/Apache/Tomcat/IIS 自动部署证书?》 (opens new window) 疑问:有没其它类似的平台? OHTTPS (opens new window):免费提供 HTTPS 证书,支持一键申请、自动更新、自动部署的功能。 # 3. 腾讯云 SSL 腾讯云 SSL 证书 (opens new window) 第一步,免费 SSL 证书申请流程 (opens new window) 第二步,Nginx 服务器 SSL 证书安装部署 (opens new window) ↑ 点击观看 ↑ (opens new window)# 4. 华为云 SSL 云证书管理服务 CCM (opens new window) 第一步,SSL 证书申购流程 (opens new window) 第二步,下载与安装 SSL 证书 (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 Jenkins 部署 服务监控 ← Jenkins 部署 服务监控→"},{"title":"Docker 部署","path":"/wiki/YuDaoCloud/运维手册/Docker 部署/Docker 部署.html","content":"开发指南运维手册 芋道源码 2022-04-13 目录 Docker 部署 友情提示:目前是 Boot 项目的部署,后续会调整成 Cloud 项目的部署 本小节,讲解如何将前端 + 后端项目,使用 Docker 容器,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: 注意:服务器的 IP 地址。 外网 IP:139.9.196.247 内网 IP:192.168.0.213 下属所有涉及到 IP 的配置,需要替换成你自己的。 # 1. 安装 Docker 执行如下命令,进行 Docker 的安装。 ## ① 使用 DaoCloud 的 Docker 高速安装脚本。参考 https://get.daocloud.io/#install-dockercurl -sSL https://get.daocloud.io/docker | sh## ② 设置 DaoCloud 的 Docker 镜像中心,加速镜像的下载速度。参考 https://www.daocloud.io/mirrorcurl -sSL https://get.daocloud.io/daotools/set_mirror.sh | sh -s http://f1361db2.m.daocloud.io## ③ 启动 Docker 服务systemctl start docker # 2. 配置 MySQL # 2.1 安装 MySQL(可选) 友情提示:使用 Docker 安装 MySQL 是可选步骤,也可以直接安装 MySQL,或者购买 MySQL 云服务。 ① 执行如下命令,使用 Docker 启动 MySQL 容器。 docker run -v /work/mysql/:/var/lib/mysql \\-p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 \\--restart=always --name mysql -d mysql 数据库文件,挂载到服务器的的 /work/mysql/ 目录下 端口是 3306,密码是 123456 ② 执行 ls /work/mysql 命令,查看 /work/mysql/ 目录的数据库文件。 # 2.2 导入 SQL 脚本 创建一个名字为 ruoyi-vue-pro 数据库,执行数据库对应的 sql (opens new window) 目录下的 SQL 文件,进行初始化。 # 3. 配置 Redis 友情提示:使用 Docker 安装 Redis 是可选步骤,也可以直接安装 Redis,或者购买 Redis 云服务。 执行如下命令,使用 Docker 启动 Redis 容器。 docker run -d --name redis --restart=always -p 6379:6379 redis:5.0.14-alpine 端口是 6379,密码未设置 # 4. 部署后端 # 4.1 修改配置 后端 dev 开发环境对应的是 application-dev.yaml (opens new window) 配置文件,主要是修改 MySQL 和 Redis 为你的地址。如下图所示: # 4.2 编译后端 在项目的根目录下,执行 mvn clean package -Dmaven.test.skip=true 命令,编译后端项目,构建出它的 Jar 包。如下图所示: 疑问:-Dmaven.test.skip=true 是什么意思? 跳过单元测试的执行。如果你项目的单元测试写的不错,建议使用 mvn clean package 命令,执行单元测试,保证交付的质量。 # 4.3 上传 Jar 包 在 Linux 服务器上创建 /work/projects/yudao-server 目录,使用 scp 命令或者 FTP 工具,将 yudao-server.jar 上传到该目录下。如下图所示: # 4.4 构建镜像 ① 在 /work/projects/yudao-server 目录下,新建 Dockerfile (opens new window) 文件,用于制作后端项目的 Docker 镜像。编写内容如下: ## AdoptOpenJDK 停止发布 OpenJDK 二进制,而 Eclipse Temurin 是它的延伸,提供更好的稳定性## 感谢复旦核博士的建议!灰子哥,牛皮!FROM eclipse-temurin:8-jre## 创建目录,并使用它作为工作目录RUN mkdir -p /yudao-serverWORKDIR /yudao-server## 将后端项目的 Jar 文件,复制到镜像中COPY yudao-server.jar app.jar## 设置 TZ 时区## 设置 JAVA_OPTS 环境变量,可通过 docker run -e "JAVA_OPTS=" 进行覆盖ENV TZ=Asia/Shanghai JAVA_OPTS="-Xms512m -Xmx512m"## 暴露后端项目的 48080 端口EXPOSE 48080## 启动后端项目ENTRYPOINT java ${JAVA_OPTS} -Djava.security.egd=file:/dev/./urandom -jar app.jar ② 执行如下命令,构建名字为 yudao-server 的 Docker 镜像。 cd /work/projects/yudao-serverdocker build -t yudao-server . ③ 在 /work/projects/yudao-server 目录下,新建 Shell 脚本 deploy.sh,使用 Docker 启动后端项目。编写内容如下: #!/bin/bashset -e## 第一步:删除可能启动的老 yudao-server 容器echo "开始删除 yudao-server 容器"docker stop yudao-server || truedocker rm yudao-server || trueecho "完成删除 yudao-server 容器"## 第二步:启动新的 yudao-server 容器 \\echo "开始启动 yudao-server 容器"docker run -d \\--name yudao-server \\-p 48080:48080 \\-e "SPRING_PROFILES_ACTIVE=dev" \\-v /work/projects/yudao-server:/root/logs/ \\yudao-serverecho "正在启动 yudao-server 容器中,需要等待 60 秒左右" 应用日志文件,挂载到服务器的的 /work/projects/yudao-server 目录下 通过 SPRING_PROFILES_ACTIVE 设置为 dev 开发环境 # 4.5 启动后端 ① 执行 sh deploy.sh 命令,使用 Docker 启动后端项目。日志如下: 开始删除 yudao-server 容器yudao-serveryudao-server完成删除 yudao-server 容器开始启动 yudao-server 容器0dfd3dc409a53ae6b5e7c5662602cf5dcb52fd4d7f673bd74af7d21da8ead9d5正在启动 yudao-server 容器中,需要等待 60 秒左右 ② 执行 docker logs yudao-server 命令,查看启动日志。看到如下内容,说明启动完成: 友情提示:如果日志比较多,可以使用 grep 进行过滤。 例如说:使用 docker logs yudao-server | grep 48080 2022-04-15 00:34:19.647 INFO 8 --- [main] [TID: N/A] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 48080 (http) # 5. 部署前端 友情提示: 本小节的内容,和 《开发指南 —— Linux 部署》 的「部署前端」是基本一致的。 # 5.1 修改配置 前端 dev 开发环境对应的是 .env.dev ( opens new window) 配置文件,主要是修改 VUE_APP_BASE_API 为你的后端项目的访问地址。如下图所示: # 5.2 编译前端 在 yudao-ui-admin 目录下,执行 npm run build:dev 命令,编译前端项目,构建出它的 dist 文件,里面是 HTML、CSS、JavaScript 等静态文件。如下图所示: 如下想要打包其它环境,可使用如下命令: npm run build:prod ## 打包 prod 生产环境npm run build:stage ## 打包 stage 预发布环境 其它高级参数说明【可暂时不看】: ① PUBLIC_PATH:静态资源地址,可用于七牛等 CDN 服务回源读取前端的静态文件,提升访问速度,建议 prod 生产环境使用。示例如下: ② VUE_APP_APP_NAME:二级部署路径,默认为 / 根目录,一般不用修改。 ③ mode:前端路由的模式,默认采用 history 路由,一般不用修改。可以通过修改 router/index.js (opens new window) 来设置为 hash 路由,示例如下: # 5.3 上传 dist 文件 在 Linux 服务器上创建 /work/projects/yudao-ui-admin 目录,使用 scp 命令或者 FTP 工具,将 dist 上传到 /work/nginx/html 目录下。如下图所示: # 5.4 启动前端? 前端无法直接启动,而是通过 Nginx 转发读取 /work/projects/yudao-ui-admin 目录的静态文件。 # 6. 配置 Nginx # 6.1 安装 Nginx Nginx 挂载到服务器的目录: /work/nginx/conf.d 用于存放配置文件 /work/nginx/html 用于存放网页文件 /work/nginx/logs 用于存放日志 /work/nginx/cert 用于存放 HTTPS 证书 ① 创建 /work/nginx 目录,并在该目录下新建 nginx.conf 文件,避免稍后安装 Nginx 报错。内容如下: user nginx;worker_processes 1;events { worker_connections 1024;}error_log /var/log/nginx/error.log warn;pid /var/run/nginx.pid;http { include /etc/nginx/mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"';# access_log /var/log/nginx/access.log main; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types text/plain application/x-javascript text/css application/xml application/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 include /etc/nginx/conf.d/*.conf; ## 加载该目录下的其它 Nginx 配置文件} ② 执行如下命令,使用 Docker 启动 Nginx 容器。 docker run -d \\--name nginx --restart always \\-p 80:80 -p 443:443 \\-e "TZ=Asia/Shanghai" \\-v /work/nginx/nginx.conf:/etc/nginx/nginx.conf \\-v /work/nginx/conf.d:/etc/nginx/conf.d \\-v /work/nginx/logs:/var/log/nginx \\-v /work/nginx/cert:/etc/nginx/cert \\-v /work/nginx/html:/usr/share/nginx/html ginx:alpine ③ 执行 docker ps 命令,查看到 Nginx 容器的状态是 UP 的。 下面,来看两种 Nginx 的配置,分别满足服务器 IP、独立域名的不同场景。 # 6.2 方式一:服务器 IP 访问 ① 在 /work/nginx/conf.d 目录下,创建 ruoyi-vue-pro.conf,内容如下: server { listen 80; server_name 139.9.196.247; ## 重要!!!修改成你的外网 IP/域名 location / { ## 前端项目 root /usr/share/nginx/html/yudao-admin-ui; index index.html index.htm; try_files $uri $uri/ /index.html; } location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://192.168.0.213:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://192.168.0.213:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }} 友情提示: [root] 指令在本地文件时,要使用 Nginx Docker 容器内的路径,即 /usr/share/nginx/html/yudao-admin-ui,否则会报 404 的错误。 ② 执行 docker exec nginx nginx -s reload 命令,重新加载 Nginx 配置。 友情提示:如果你担心 Nginx 配置不正确,可以执行 docker exec nginx nginx -t 命令。 ③ 执行 curl http://192.168.0.213/admin-api/ 命令,成功访问后端项目的内网地址,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} 执行 curl http://139.9.196.247:48080/admin-api/ 命令,成功访问后端项目的外网地址,返回结果一致。 ④ 请求 http://139.9.196.247:48080 (opens new window) 地址,成功访问前端项目的外网地址,,返回前端界面如下: # 6.3 方式二:独立域名访问 友情提示:在前端项目的编译时,需要把 `VUE_APP_BASE_API` 修改为后端项目对应的域名。 例如说,这里使用的是 http://api.iocoder.cn ① 在 /work/nginx/conf.d 目录下,创建 ruoyi-vue-pro2.conf,内容如下: server { ## 前端项目 listen 80; server_name admin.iocoder.cn; ## 重要!!!修改成你的前端域名 location / { ## 前端项目 root /usr/share/nginx/html/yudao-admin-ui; index index.html index.htm; try_files $uri $uri/ /index.html; }}server { ## 后端项目 listen 80; server_name api.iocoder.cn; ## 重要!!!修改成你的外网 IP/域名 ## 不要使用 location / 转发到后端项目,因为 druid、admin 等监控,不需要外网可访问。或者增加 Nginx IP 白名单限制也可以。 location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://192.168.0.213:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://192.168.0.213:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }} ② 执行 docker exec nginx nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://api.iocoder.cn/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://admin.iocoder.cn (opens new window) 地址,成功访问前端项目,返回前端界面如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 Linux 部署 Jenkins 部署 ← Linux 部署 Jenkins 部署→"},{"title":"Linux 部署","path":"/wiki/YuDaoCloud/运维手册/Linux 部署/Linux 部署.html","content":"开发指南运维手册 芋道源码 2022-04-12 目录 Linux 部署 友情提示:目前是 Boot 项目的部署,后续会调整成 Cloud 项目的部署 本小节,讲解如何将前端 + 后端项目,使用 Shell 脚本,部署到 dev 开发环境下的一台 Linux 服务器上。如下图所示: # 1. 配置 MySQL # 1.1 安装 MySQL(可选) 友情提示:安装 MySQL 是可选步骤,也可以购买 MySQL 云服务。 ① 执行如下命令,进行 MySQL 的安装。 ## ① 安装 MySQL 5.7 版本的软件源 https://dev.mysql.com/downloads/repo/yum/rpm -Uvh https://dev.mysql.com/get/mysql57-community-release-el7-11.noarch.rpm## ② 安装 MySQL Server 5.7 版本yum install mysql-server --nogpgcheck## ③ 查看 MySQL 的安装版本。结果是 mysqld Ver 5.7.37 for Linux on x86_64 (MySQL Community Server (GPL))mysqld --version ② 修改 /etc/my.cnf 文件,在文末加上 lower_case_table_names=1 和 validate_password=off 配置,执行 systemctl restart mysqld 命令重启。 ③ 执行 grep password /var/log/mysqld.log 命令,获得 MySQL 临时密码。 2022-04-16T09:39:57.365086Z 1 [Note] A temporary password is generated for root@localhost: ZOKUaehW2e.e ④ 执行如下命令,修改 MySQL 的密码,设置允许远程连接。 ## ① 连接 MySQL Server 服务,并输入临时密码mysql -uroot -p## ② 修改密码,123456 可改成你想要的密码alter user 'root'@'localhost' identified by '123456';## ③ 设置允许远程连接use mysql;update user set host = '%' where user = 'root';FLUSH PRIVILEGES; # 1.2 导入 SQL 脚本 创建一个名字为 ruoyi-vue-pro 数据库,执行数据库对应的 sql ( opens new window) 目录下的 SQL 文件,进行初始化。 # 2. 配置 Redis 友情提示:安装 Redis 是可选步骤,也可以购买 Redis 云服务。 执行如下命令,进行 Redis 的安装。 ## ① 安装 remi 软件源yum install http://rpms.famillecollet.com/enterprise/remi-release-7.rpm## ② 安装最新 Redis 版本。如果想要安装指定版本,可使用 yum --enablerepo=remi install redis-6.0.6 -y 命令yum --enablerepo=remi install redis ## ③ 查看 Redis 的安装版本。结果是 Redis server v=6.2.6 sha=00000000:0 malloc=jemalloc-5.1.0 bits=64 build=4ab9a06393930489redis-server --version## ④ 启动 Redis 服务systemctl restart redis 端口是 6379,密码未设置 # 3. 部署后端 # 3.1 修改配置 后端 dev 开发环境对应的是 application-dev.yaml (opens new window) 配置文件,主要是修改 MySQL 和 Redis 为你的地址。如下图所示: # 3.2 编译后端 在项目的根目录下,执行 mvn clean package -Dmaven.test.skip=true 命令,编译后端项目,构建出它的 Jar 包。如下图所示: 疑问:-Dmaven.test.skip=true 是什么意思? 跳过单元测试的执行。如果你项目的单元测试写的不错,建议使用 mvn clean package 命令,执行单元测试,保证交付的质量。 # 3.3 上传 Jar 包 在 Linux 服务器上创建 /work/projects/yudao-server 目录,使用 scp 命令或者 FTP 工具,将 yudao-server.jar 上传到该目录下。如下图所示: 疑问:如果构建 War 包,部署到 Tomcat 下? 并不推荐采用 War 包部署到 Tomcat 下。如果真的需要,可以参考 《Deploy a Spring Boot WAR into a Tomcat Server》 (opens new window) 文章。 # 3.4 编写脚本 在 /work/projects/yudao-server 目录下,新建 Shell 脚本 deploy.sh,用于启动后端项目。编写内容如下: #!/bin/bashset -eDATE=$(date +%Y%m%d%H%M)# 基础路径BASE_PATH=/work/projects/yudao-server# 服务名称。同时约定部署服务的 jar 包名字也为它。SERVER_NAME=yudao-server# 环境PROFILES_ACTIVE=dev# heapError 存放路径HEAP_ERROR_PATH=$BASE_PATH/heapError# JVM 参数JAVA_OPS="-Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=$HEAP_ERROR_PATH"# SkyWalking Agent 配置#export SW_AGENT_NAME=$SERVER_NAME#export SW_AGENT_COLLECTOR_BACKEND_SERVICES=192.168.0.84:11800#export SW_GRPC_LOG_SERVER_HOST=192.168.0.84#export SW_AGENT_TRACE_IGNORE_PATH="Redisson/PING,/actuator/**,/admin/**"#export JAVA_AGENT=-javaagent:/work/skywalking/apache-skywalking-apm-bin/agent/skywalking-agent.jar# 停止:优雅关闭之前已经启动的服务function stop() { echo "[stop] 开始停止 $BASE_PATH/$SERVER_NAME" PID=$(ps -ef | grep $BASE_PATH/$SERVER_NAME | grep -v "grep" | awk '{print $2}') # 如果 Java 服务启动中,则进行关闭 if [ -n "$PID" ]; then # 正常关闭 echo "[stop] $BASE_PATH/$SERVER_NAME 运行中,开始 kill [$PID]" kill -15 $PID # 等待最大 120 秒,直到关闭完成。 for ((i = 0; i < 120; i++)) do sleep 1 PID=$(ps -ef | grep $BASE_PATH/$SERVER_NAME | grep -v "grep" | awk '{print $2}') if [ -n "$PID" ]; then echo -e ".\\c" else echo '[stop] 停止 $BASE_PATH/$SERVER_NAME 成功' break fi done # 如果正常关闭失败,那么进行强制 kill -9 进行关闭 if [ -n "$PID" ]; then echo "[stop] $BASE_PATH/$SERVER_NAME 失败,强制 kill -9 $PID" kill -9 $PID fi # 如果 Java 服务未启动,则无需关闭 else echo "[stop] $BASE_PATH/$SERVER_NAME 未启动,无需停止" fi}# 启动:启动后端项目function start() { # 开启启动前,打印启动参数 echo "[start] 开始启动 $BASE_PATH/$SERVER_NAME" echo "[start] JAVA_OPS: $JAVA_OPS" echo "[start] JAVA_AGENT: $JAVA_AGENT" echo "[start] PROFILES: $PROFILES_ACTIVE" # 开始启动 nohup java -server $JAVA_OPS $JAVA_AGENT -jar $BASE_PATH/$SERVER_NAME.jar --spring.profiles.active=$PROFILES_ACTIVE > nohup.out 2>&1 & echo "[start] 启动 $BASE_PATH/$SERVER_NAME 完成"}# 部署function deploy() { cd $BASE_PATH # 第一步:停止 Java 服务 stop # 第二步:启动 Java 服务 start}deploy 友情提示: 脚本的详细讲解,可见 《芋道 Jenkins 极简入门 》 (opens new window) 的「2.3 远程服务器配置 」小节。 如果你想要修改脚本,主要关注 BASE_PATH、PROFILES_ACTIVE、JAVA_OPS 三个参数。如下图所示: # 3.5 启动后端 ① 【可选】执行 yum install -y java-1.8.0-openjdk 命令,安装 OpenJDK 8。 友情提示:如果已经安装 JDK,可不安装。建议使用的 JDK 版本为 8、11、17 这三个。 ② 执行 sh deploy.sh 命令,启动后端项目。日志如下: [stop] 开始停止 /work/projects/yudao-server/yudao-server[stop] /work/projects/yudao-server/yudao-server 未启动,无需停止[start] 开始启动 /work/projects/yudao-server/yudao-server[start] JAVA_OPS: -Xms512m -Xmx512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/work/projects/yudao-server/heapError[start] JAVA_AGENT:[start] PROFILES: dev[start] 启动 /work/projects/yudao-server/yudao-server 完成 ③ 执行 tail -f nohup.out 命令,查看启动日志。看到如下内容,说明启动完成: 2022-04-13 00:06:20.049 INFO 1395 --- [main] [TID: N/A] c.i.yudao.server.YudaoServerApplication : Started YudaoServerApplication in 35.315 seconds (JVM running for 36.282) # 4. 部署前端 # 4.1 修改配置 前端 dev 开发环境对应的是 .env.dev ( opens new window) 配置文件,主要是修改 VUE_APP_BASE_API 为你的后端项目的访问地址。如下图所示: # 4.2 编译前端 在 yudao-ui-admin 目录下,执行 npm run build:dev 命令,编译前端项目,构建出它的 dist 文件,里面是 HTML、CSS、JavaScript 等静态文件。如下图所示: 如下想要打包其它环境,可使用如下命令: npm run build:prod ## 打包 prod 生产环境npm run build:stage ## 打包 stage 预发布环境 其它高级参数说明【可暂时不看】: ① PUBLIC_PATH:静态资源地址,可用于七牛等 CDN 服务回源读取前端的静态文件,提升访问速度,建议 prod 生产环境使用。示例如下: ② VUE_APP_APP_NAME:二级部署路径,默认为 / 根目录,一般不用修改。 ③ mode:前端路由的模式,默认采用 history 路由,一般不用修改。可以通过修改 router/index.js (opens new window) 来设置为 hash 路由,示例如下: # 4.3 上传 dist 文件 在 Linux 服务器上创建 /work/projects/yudao-ui-admin 目录,使用 scp 命令或者 FTP 工具,将 dist 上传到该目录下。如下图所示: # 4.4 启动前端? 前端无法直接启动,而是通过 Nginx 转发读取 /work/projects/yudao-ui-admin 目录的静态文件。 # 5. 配置 Nginx # 5.1 安装 Nginx 参考 Nginx 官方文档 (opens new window),安装 Nginx 服务。命令如下: ## 添加 yum 源yum install epel-releaseyum update## 安装 nginxyum install nginx## 启动 nginx nginx Nginx 默认配置文件是 /etc/nginx/nginx.conf。 下面,来看两种 Nginx 的配置,分别满足服务器 IP、独立域名的不同场景。 # 5.2 方式一:服务器 IP 访问 ① 修改 Nginx 配置,内容如下: worker_processes 1;events { worker_connections 1024;}http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 server { listen 80; server_name 192.168.225.2; ## 重要!!!修改成你的外网 IP/域名 location / { ## 前端项目 root /work/projects/yudao-ui-admin; index index.html index.htm; try_files $uri $uri/ /index.html; } location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://localhost:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://localhost:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }} ② 执行 nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://192.168.225.2/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://192.168.225.2 (opens new window) 地址,成功访问前端项目,返回前端界面如下: # 5.3 方式二:独立域名访问 友情提示:在前端项目的编译时,需要把 `VUE_APP_BASE_API` 修改为后端项目对应的域名。 例如说,这里使用的是 http://api.iocoder.cn ① 修改 Nginx 配置,内容如下: worker_processes 1;events { worker_connections 1024;}http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; gzip on; gzip_min_length 1k; # 设置允许压缩的页面最小字节数 gzip_buffers 4 16k; # 用来存储 gzip 的压缩结果 gzip_http_version 1.1; # 识别 HTTP 协议版本 gzip_comp_level 2; # 设置 gzip 的压缩比 1-9。1 压缩比最小但最快,而 9 相反 gzip_types text/plain application/x-javascript text/css application/xml application/javascript; # 指定压缩类型 gzip_proxied any; # 无论后端服务器的 headers 头返回什么信息,都无条件启用压缩 server { ## 前端项目 listen 80; server_name admin.iocoder.cn; ## 重要!!!修改成你的前端域名 location / { ## 前端项目 root /work/projects/yudao-ui-admin; index index.html index.htm; try_files $uri $uri/ /index.html; } } server { ## 后端项目 listen 80; server_name api.iocoder.cn; ## 重要!!!修改成你的外网 IP/域名 ## 不要使用 location / 转发到后端项目,因为 druid、admin 等监控,不需要外网可访问。或者增加 Nginx IP 白名单限制也可以。 location /admin-api/ { ## 后端项目 - 管理后台 proxy_pass http://localhost:48080/admin-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /app-api/ { ## 后端项目 - 用户 App proxy_pass http://localhost:48080/app-api/; ## 重要!!!proxy_pass 需要设置为后端项目所在服务器的 IP proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }} ② 执行 nginx -s reload 命令,重新加载 Nginx 配置。 ③ 请求 http://api.iocoder.cn/admin-api/ (opens new window) 地址,成功访问后端项目,返回结果如下: {"code":401,"data":null,"msg":"账号未登录"} ④ 请求 http://admin.iocoder.cn (opens new window) 地址,成功访问前端项目,返回前端界面如下: .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 开发环境 Docker 部署 ← 开发环境 Docker 部署→"},{"title":"开发环境","path":"/wiki/YuDaoCloud/运维手册/开发环境/开发环境.html","content":"开发指南运维手册 芋道源码 2022-04-11 目录 开发环境 在系统开发的经典模型,一般会分成 2 类 5 种环境: 【线下】本地环境(local)、开发环境(dev)、测试环境(test) 【线上】预发布环境(stage)、生产环境(prod) 每个环境、每个项目使用独立的二级域名 线下、线上各一套 MySQL 数据库,多个环境共享使用 每个环境对应一个配置文件,后端使用 application-{env}.yaml (opens new window) 文件,前端使用 .env.{env} (opens new window) 文件 友情提示:项目中暂时没有 test、stage、production 等环境的配置,需要自己创建。 另外,本文的 MySQL 数据库是基础设施的“泛指”,包括 Redis 缓存、MQ 消息队列,都需要线上线下独立。 # 1. 本地环境 后端工程师使用 application-local.yaml 配置文件,在本地电脑启动后端服务,连接线下 MySQL 数据库。考虑到不影响 dev、test 环境,会配置禁用定时任务、MQ 集群消费的执行。 前端工程师也会在本地电脑启动前端服务,一般不使用 .env.local 配置文件,而是使用 .env.dev 配置文件,访问 dev 环境的后端服务。如果需要和后端进行本地联调,可以使用 .env.local 配置文件。 # 2. 开发环境 dev 环境的用户是前端工程师、后端工程师,主要用于前后端的联调、又或者功能开发完后的自测。 一些公司可能不提供 dev 环境,直接使用 test 环境,适合团队规模较小的团队,可以降低服务器的成本。 不过,测试工程师可能比较反感 dev 和 test 环境不隔离,因为他们是按照测试用例,一轮一轮的进行验收。这个时候,如果前端或者后端工程师部署了 test 环境,“破坏”了他当前轮次的验收。 疑问:开发环境可以使用独立的 MySQL 数据库吗? 当然是可以的,提供更好的环境隔离性,避免开发阶段产生过多的脏数据,影响 test 环境的验收。 不过呢,这也带来额外的成本,部署程序到 test 环境时,需要做一次数据库的同步。 # 3. 测试环境 test 环境的用户是产品经理、测试工程师,主要用于他们的功能验收。 考虑到 test 环境的稳定性,一般建议由测试工程师使用 Jenkins 等工具,完成该环境的部署。具体的原因,上面 dev 环境已经解释了。 疑问:如果需要并行验收多个功能,怎么办? 并行验收多个功能时候,对应不同的 Git 分支,需要搭建多套测试环境。 # 4. 预发布环境 stage 环境的用户是产品经理、测试工程师,连接线上 MySQL 数据库,基于真实的数据,进行功能的全回归测试。 因为数据更加真实,且更具多样性,所以往往也会测试出较多的 Bug。比较好的解决方案,是将线上数据库定期脱敏,导入线下数据库。 考虑到 stage 环境的安全性,一般由技术经理、运维工程师进行部署。 一些公司可能不提供 stage 环境,直接上线到 production 环境,风险非常高,容易产生较多报错。 # 5. 生产环境 production 环境的用户是真实用户,即线上环境。一般发布上线时,会进行核心功能的快速测试,避免主流程存在问题。 考虑到 production 环境的问题排查效率,会给技术核心开放 MySQL 数据库的读权限。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/01, 00:29:43 大屏设计器 Linux 部署 ← 大屏设计器 Linux 部署→"},{"title":"服务监控","path":"/wiki/YuDaoCloud/运维手册/服务监控/服务监控.html","content":"开发指南运维手册 芋道源码 2022-04-16 目录 服务监控 系统使用 Spring Boot Admin 和 SkyWalking 实现后端服务的监控。 # 1. Spring Boot Admin 阅读 《芋道 Spring Boot 监控工具 Admin 入门》 (opens new window) 文章,入门 Spring Boot Admin。 注意,Spring Boot Admin 是内嵌在 yudao-server 后端项目中,无需单独启动。 # 1.1 配置 在 application-local.yaml (opens new window) 配置文件中,通过 spring.boot.admin 配置项,设置 Spring Boot Admin 的配置。如下图所示: 疑问:prod 生产环境下,后端部署多个 JVM 进程时,spring.boot.admin.client.url 填写哪个 IP? 第一步,在 Nginx 中配置 /admin 路径,转发到多个 JVM 的 IP 上,使用 backup (opens new window) 参数实现主备。注意,该转发只允许内网访问,避免安全问题!!! 第二步,设置 spring.boot.admin.client.url 配置项,为 Nginx 的 内置 IP/admin 地址。 # 1.2 使用 ① 访问 http://127.0.0.1:48080//admin/applications (opens new window) 地址,可以在 Spring Boot Admin 中,查看到应用与实例的列表。如下图所示: ② 点击 yudao-server 应用,再点击实例,可以查看到该实例的细节信息。如下图所示: ③ 点击 [日志 -> 日志文件] 菜单,查看该示例的日志内容。如下图所示: 点击 [日志 -> 日志文件] 菜单,可动态修改 Logger 的日志级别,方便排查线上的某些 BUG。如下图所示: 补充说明:也可以通过前端的 [基础设施 -> Java 监控] 菜单。 前端 [基础设施 -> Java 监控] 菜单,通过 iframe 内嵌后端 /admin/applications 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.spring-boot-admin 配置项。 # 2. SkyWalking 阅读 《芋道 SkyWalking 极简入门》 (opens new window) 文章,入门 SkyWalking。 注意,SkyWalking 需要单独启动,预计需要 4 核 8G 的硬件资源。 # 2.1 配置 ① 在 logback-spring.xml (opens new window) 配置文件中,添加 SkyWalking 收集日志的 appender 配置。如下图所示: ② 修改 SkyWalking 在前端项目的 [基础设施 -> 监控平台] 对应的 skywaling/index.vue (opens new window) 文件,调整为你 SkyWalking 的访问地址。如下图所示: # 2.2 使用 ① 点击 [基础设施 -> 监控平台] 菜单,可以看到 SkyWalking 提供的监控平台。如下图所示: ② 点击 yudao-server 服务,查看该服务的监控信息。如下图所示: 补充说明: 前端 [基础设施 -> 监控平台] 菜单,通过 iframe 内嵌 http://skywalking.iocoder.cn 路径。 如果你想自定义地址,可以前往 [基础设置 -> 配置管理] 菜单,设置 key 为 url.skywalking 配置项。 # 3. 更多监控系统 # 3.1 Prometheus 参见 《芋道 Prometheus + Grafana + Alertmanager 极简入门 》 (opens new window) 文章。 # 3.2 ELK 参见 芋道 ELK(Elasticsearch + Logstash + Kibana) 极简入门 (opens new window) 文章。 # 3.3 Sentry 参见 《Sentry 极简入门 》 (opens new window) 文章。 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/07, 23:24:52 HTTPS 证书 开发规范 ← HTTPS 证书 开发规范→"},{"title":"项目结构","path":"/wiki/YuDaoCloud/萌新必读/项目结构/项目结构.html","content":"开发指南萌新必读 芋道源码 2022-03-02 目录 项目结构 # 👍 相关视频教程 从零开始 01:视频课程导读:项目简介、功能列表、技术选型 (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(上) (opens new window) 从零开始 04:自顶向下,讲解项目的整体结构(下) (opens new window) # 👻 后端结构 后端采用模块化的架构,按照功能拆分成多个 Maven Module,提升开发与研发的效率,带来更好的可维护性。 一共有四类 Maven Module: Maven Module 作用 yudao-dependencies Maven 依赖版本管理 yudao-framework Java 框架拓展 yudao-module-xxx XXX 功能的 Module 模块 yudao-server 管理后台 + 用户 App 的服务端 下面,我们来逐个看看。 # 1. yudao-dependencies 该模块是一个 Maven Bom,只有一个 pom.xml (opens new window) 文件,定义项目中所有 Maven 依赖的版本号,解决依赖冲突问题。 详细的解释,可见 《微服务中使用 Maven BOM 来管理你的版本依赖 》 (opens new window) 文章。 从定位上来说,它和 Spring Boot 的 spring-boot-starter-parent (opens new window) 和 Spring Cloud 的 spring-cloud-dependencies (opens new window) 是一致的。 实际上,ruoyi-vue-pro 本质上还是个单体项目,直接在根目录 pom.xml (opens new window) 管理依赖版本会更加方便,也符合绝大多数程序员的认知。但是要额外考虑一个场景,如果每个 yudao-module-xxx 模块都维护在一个独立的 Git 仓库,那么 yudao-dependencies 就可以在多个 yudao-module-xxx 模块下复用。 # 2. yudao-framework 该模块是 ruoyi-vue-pro 项目的框架封装,其下的每个 Maven Module 都是一个组件,分成两种类型: ① 技术组件:技术相关的组件封装,例如说 MyBatis、Redis 等等。 Maven Module 作用 yudao-common 定义基础 pojo 类、枚举、工具类等 yudao-spring-boot-starter-web Web 封装,提供全局异常、访问日志等 yudao-spring-boot-starter-security 认证授权,基于 Spring Security 实现 yudao-spring-boot-starter-mybatis 数据库操作,基于 MyBatis Plus 实现 yudao-spring-boot-starter-redis 缓存操作,基于 Spring Data Redis + Redisson 实现 yudao-spring-boot-starter-rpc 服务调用,基于 Feign 实现,也可以选择 Dubbo yudao-spring-boot-starter-mq 消息队列,基于 RocketMQ 实现,支持集群消费和广播消费 yudao-spring-boot-starter-job 定时任务,基于 XXL Job 实现,支持集群模式 yudao-spring-boot-starter-env 多环境,实现类似阿里的特性环境的能力 yudao-spring-boot-starter-flowable 工作流,基于 Flowable 实现 yudao-spring-boot-starter-protection 服务保障,基于 Sentinel 实现,提供幂等、分布式锁、限流、熔断等功能 yudao-spring-boot-starter-file 文件客户端,支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、SFTP、数据库等 yudao-spring-boot-starter-excel Excel 导入导出,基于 EasyExcel 实现 yudao-spring-boot-starter-monitor 服务监控,提供链路追踪、日志服务、指标收集等功能 yudao-spring-boot-starter-captcha 验证码 Captcha,提供滑块验证码 yudao-spring-boot-starter-test 单元测试,基于 Junit + Mockito 实现 yudao-spring-boot-starter-banner 控制台 Banner,启动打印各种提示 yudao-spring-boot-starter-desensitize 脱敏组件:支持 JSON 返回数据时,将邮箱、手机等字段进行脱敏 ② 业务组件:业务相关的组件封装,例如说数据字典、操作日志等等。如果是业务组件,名字会包含 biz 关键字。 Maven Module 作用 yudao-spring-boot-starter-biz-tenant SaaS 多租户 yudao-spring-boot-starter-biz-data-permissionn 数据权限 yudao-spring-boot-starter-biz-dict 数据字典 yudao-spring-boot-starter-biz-operatelog 操作日志 yudao-spring-boot-starter-biz-pay 支付客户端,对接微信支付、支付宝等支付平台 yudao-spring-boot-starter-biz-sms 短信客户端,对接阿里云、腾讯云等短信服务 yudao-spring-boot-starter-biz-social 社交客户端,对接微信公众号、小程序、企业微信、钉钉等三方授权平台 yudao-spring-boot-starter-biz-weixin 微信客户端,对接微信的公众号、开放平台等 yudao-spring-boot-starter-biz-error-code 全局错误码 yudao-spring-boot-starter-biz-ip 地区 & IP 库 每个组件,包含两部分: core 包:组件的核心封装,拓展相关的功能。 config 包:组件的 Spring Boot 自动配置。 # 3. yudao-module-xxx 该模块是 XXX 功能的 Module 模块,目前内置了 8 个模块。 项目 说明 是否必须 yudao-module-system 系统功能 √ yudao-module-infra 基础设施 √ yudao-module-member 会员中心 x yudao-module-bpm 工作流程 x yudao-module-pay 支付系统 x yudao-module-report 大屏报表 x yudao-module-mall 商城系统 x yudao-module-mp 微信公众号 x 每个模块包含两个 Maven Module,分别是: Maven Module 作用 yudao-module-xxx-api 提供给其它模块的 API 定义 yudao-module-xxx-biz 模块的功能的具体实现 例如说,yudao-module-infra 想要访问 yudao-module-system 的用户、部门等数据,需要引入 yudao-module-system-api 子模块。示例如下: yudao-module-xxx-api 子模块的项目结构如下: 所在包 类 作用 示例 api Api 接口 提供给其它模块的 API 接口 AdminUserApi (opens new window) api DTO 类 Api 接口的入参 ReqDTO、出参 RespDTO LoginLogCreateReqDTO (opens new window) DeptRespDTO (opens new window) enums Enum 类 字段的枚举 LoginLogTypeEnum (opens new window) enums DictTypeConstants 类 数据字典的枚举 DictTypeConstants (opens new window) enums ErrorCodeConstants 类 错误码的枚举 ErrorCodeConstants (opens new window) yudao-module-xxx-biz 子模块的项目结构如下: 所在包 类 作用 示例 api ApiImpl 类 提供给其它模块的 API 实现类 AdminUserApiImpl (opens new window) controler.admin Controller 类 提供给管理后台的 RESTful API,默认以 admin-api/ 作为前缀。 例如 admin-api/system/auth/login 登录接口 AuthController (opens new window) controler.admin VO 类 Admin Controller 接口的入参 ReqVO、出参 RespVO AuthLoginReqVO (opens new window) AuthLoginRespVO (opens new window) controler.app Controller 类,以 App 为前缀 提供给用户 App 的 RESTful API,默认以 app-api/ 作为前缀。 例如 app-api/member/auth/login 登录接口 AppAuthController (opens new window) controler.app VO 类,以 App 为前缀 App Controller 接口的入参 ReqVO、出参 RespVO AppAuthLoginReqVO (opens new window) AppAuthLoginRespVO (opens new window) controler .http 文件 IDEA Http Client 插件 (opens new window),模拟请求 RESTful 接口 AuthController.http (opens new window) service Service 接口 业务逻辑的接口定义 AdminUserService (opens new window) service ServiceImpl 类 业务逻辑的实现类 AdminUserServiceImpl (opens new window) dal - Data Access Layer,数据访问层 dal.dataobject DO 类 Data Object,映射数据库表、或者 Redis 对象 AdminUserDO (opens new window) dal.mysql Mapper 接口 数据库的操作 AdminUserMapper (opens new window) dal.redis RedisDAO 类 Redis 的操作 LoginUserRedisDAO (opens new window) convert Convert 接口 DTO / VO / DO 等对象之间的转换器 UserConvert (opens new window) job Job 类 定时任务 UserSessionTimeoutJob (opens new window) mq - Message Queue,消息队列 mq.message Message 类 发送和消费的消息 DeptRefreshMessage (opens new window) mq.producer Producer 类 消息的生产者 DeptProducer (opens new window) mq.consumer Producer 类 消息的消费者 DeptRefreshConsumer (opens new window) framework - 模块自身的框架封装 framework (opens new window) 疑问:为什么 Controller 分成 Admin 和 App 两种? 提供给 Admin 和 App 的 RESTful API 接口是不同的,拆分后更加清晰。 疑问:为什么 VO 分成 Admin 和 App 两种? 相同功能的 RESTful API 接口,对于 Admin 和 App 传入的参数、返回的结果都可能是不同的。例如说,Admin 查询某个用户的基本信息时,可以返回全部字段;而 App 查询时,不会返回 mobile 手机等敏感字段。 疑问:为什么 DO 不作为 Controller 的出入参? 明确每个 RESTful API 接口的出入参。例如说,创建部门时,只需要传入 name、parentId 字段,使用 DO 接参就会导致 type、createTime、creator 等字段可以被传入,导致前端同学一脸懵逼。 每个 RESTful API 有自己独立的 VO,可以更好的设置 Swagger 注解、Validator 校验规则,而让 DO 保持整洁,专注映射好数据库表。 疑问:为什么操作 Redis 需要通过 RedisDAO? Service 直接使用 RedisTemplate 操作 Redis,导致大量 Redis 的操作细节和业务逻辑杂糅在一起,导致代码不够整洁。通过 RedisDAO 类,将每个 Redis Key 像一个数据表一样对待,清晰易维护。 总结来说,每个模块采用三层架构 + 非严格分层,如下图所示: # 4. yudao-server 该模块是后端 Server 的主项目,通过引入需要 yudao-module-xxx 业务模块,从而实现提供 RESTful API 给 yudao-ui-admin、yudao-ui-user 等前端项目。 本质上来说,它就是个空壳(容器)!如下图所示: # 👾 前端结构 前端一共有六个项目,分别是: 项目 说明 yudao-ui-admin-vue3 (opens new window) 基于 Vue3 + element-plus 实现的管理后台 yudao-ui-admin-vben (opens new window) 基于 Vue3 + vben(ant-design-vue) 实现的管理后台 yudao-ui-admin 基于 Vue2 + element-ui 实现的管理后台 yudao-ui-go-view (opens new window) 基于 Vue3 + naive-ui 实现的大屏报表 yudao-ui-admin-uniapp 基于 uni-app + uni-ui 实现的管理后台的小程序 yudao-ui-app 基于 uni-app + uview 实现的用户 App # 1. yudao-admin-ui-vue3 .├── .github # github workflows 相关├── .husky # husky 配置├── .vscode # vscode 配置├── mock # 自定义 mock 数据及配置├── public # 静态资源├── src # 项目代码│ ├── api # api接口管理│ ├── assets # 静态资源│ ├── components # 公用组件│ ├── hooks # 常用hooks│ ├── layout # 布局组件│ ├── locales # 语言文件│ ├── plugins # 外部插件│ ├── router # 路由配置│ ├── store # 状态管理│ ├── styles # 全局样式│ ├── utils # 全局工具类│ ├── views # 路由页面│ ├── App.vue # 入口vue文件│ ├── main.ts # 主入口文件│ └── permission.ts # 路由拦截├── types # 全局类型├── .env.base # 本地开发环境 环境变量配置├── .env.dev # 打包到开发环境 环境变量配置├── .env.gitee # 针对 gitee 的环境变量 可忽略├── .env.pro # 打包到生产环境 环境变量配置├── .env.test # 打包到测试环境 环境变量配置├── .eslintignore # eslint 跳过检测配置├── .eslintrc.js # eslint 配置├── .gitignore # git 跳过配置├── .prettierignore # prettier 跳过检测配置├── .stylelintignore # stylelint 跳过检测配置├── .versionrc 自动生成版本号及更新记录配置├── CHANGELOG.md # 更新记录├── commitlint.config.js # git commit 提交规范配置├── index.html # 入口页面├── package.json├── .postcssrc.js # postcss 配置├── prettier.config.js # prettier 配置├── README.md # 英文 README├── README.zh-CN.md # 中文 README├── stylelint.config.js # stylelint 配置├── tsconfig.json # typescript 配置├── vite.config.ts # vite 配置└── windi.config.ts # windicss 配置 # 2. yudao-ui-admin-vben .├── build # 打包脚本相关│ ├── config # 配置文件│ ├── generate # 生成器│ ├── script # 脚本│ └── vite # vite配置├── mock # mock文件夹├── public # 公共静态资源目录├── src # 主目录│ ├── api # 接口文件│ ├── assets # 资源文件│ │ ├── icons # icon sprite 图标文件夹│ │ ├── images # 项目存放图片的文件夹│ │ └── svg # 项目存放svg图片的文件夹│ ├── components # 公共组件│ ├── design # 样式文件│ ├── directives # 指令│ ├── enums # 枚举/常量│ ├── hooks # hook│ │ ├── component # 组件相关hook│ │ ├── core # 基础hook│ │ ├── event # 事件相关hook│ │ ├── setting # 配置相关hook│ │ └── web # web相关hook│ ├── layouts # 布局文件│ │ ├── default # 默认布局│ │ ├── iframe # iframe布局│ │ └── page # 页面布局│ ├── locales # 多语言│ ├── logics # 逻辑│ ├── main.ts # 主入口│ ├── router # 路由配置│ ├── settings # 项目配置│ │ ├── componentSetting.ts # 组件配置│ │ ├── designSetting.ts # 样式配置│ │ ├── encryptionSetting.ts # 加密配置│ │ ├── localeSetting.ts # 多语言配置│ │ ├── projectSetting.ts # 项目配置│ │ └── siteSetting.ts # 站点配置│ ├── store # 数据仓库│ ├── utils # 工具类│ └── views # 页面├── test # 测试│ └── server # 测试用到的服务│ ├── api # 测试服务器│ ├── upload # 测试上传服务器│ └── websocket # 测试ws服务器├── types # 类型文件├── vite.config.ts # vite配置文件└── windi.config.ts # windcss配置文件 # 3. yudao-admin-ui ├── bin // 执行脚本├── build // 构建相关 ├── public // 公共文件│ ├── favicon.ico // favicon 图标│ └── index.html // html 模板│ └── robots.txt // 反爬虫├── src // 源代码│ ├── api // 所有请求【重要】│ ├── assets // 主题、字体等静态资源│ ├── components // 全局公用组件│ ├── directive // 全局指令│ ├── icons // 图标│ ├── layout // 布局│ ├── plugins // 插件│ ├── router // 路由│ ├── store // 全局 store 管理│ ├── utils // 全局公用方法│ ├── views // 视图【重要】│ ├── App.vue // 入口页面│ ├── main.js // 入口 JS,加载组件、初始化等│ ├── permission.js // 权限管理│ └── settings.js // 系统配置├── .editorconfig // 编码格式├── .env.development // 开发环境配置├── .env.production // 生产环境配置├── .env.staging // 测试环境配置├── .eslintignore // 忽略语法检查├── .eslintrc.js // eslint 配置项├── .gitignore // git 忽略项├── babel.config.js // babel.config.js├── package.json // package.json└── vue.config.js // vue.config.js # 4. yudao-admin-ui-uniapp TODO 待补充 # 5. yudao-ui-app 建设中,基于 uniapp 实现... # 6. yudao-ui-go-view TODO 待补充 .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/04/14, 23:29:24 技术选型 代码热加载 ← 技术选型 代码热加载→"},{"title":"视频教程","path":"/wiki/YuDaoCloud/萌新必读/视频教程/视频教程.html","content":"开发指南萌新必读 芋道源码 2022-07-02 目录 视频教程 # 大纲 每个点都是大章节,包含 10-20 小节的视频。 每个视频,控制在 10 分钟左右,问题驱动,全程无废话,保证高质量的学习。 视频的内容,会带你理解整个系统的设计思想,每一个组件和模块的代码实现。 知其然,知其所以然!让你走出只会 CRUD 的困局~ 支持手机、平板、电脑设备,随时随地在线观看,无需下载! # 技术架构图 # 为什么学习该视频? 学习的过程中,往往会碰到如下的问题: 一个人瞎摸索,走弯路,效率低 一脸懵逼,不知道如何学习 遇到问题,无人解答,信心备受打击 遇到一些难题,自己无法透彻理解 知识面狭窄,不知道的太多 而通过这套视频,可以实现 “系统全面,效率高” 的效果。 # 获取方式 使用微信扫描下方二维码,即可获取~ # 从零开始 01、视频课程导读:项目简介、功能列表、技术选型 (opens new window) 02、在 Windows 环境下,如何运行前后端项目? (opens new window) 03、在 MacOS 环境下,如何运行前后端项目? (opens new window) 04、自顶向下,讲解项目的整体结构(上) (opens new window) 04、自顶向下,讲解项目的整体结构(下) (opens new window) 05、如何 5 分钟,开发一个新功能? (opens new window) 06、如何 5 分钟,创建一个新模块? (opens new window) 07、如何有效的删除不用的功能? (opens new window) 08、如何实现一键改包? (opens new window) # 用户认证 01、如何实现管理后台和微信小程序的用户? (opens new window) 02、如何实现用户的创建? (opens new window) 03、如何实现用户的账号密码登录? (opens new window) 04、如何实现用户的手机验证码登录? (opens new window) 05、如何实现用户的退出? (opens new window) 06、如何生成用户认证 Token 令牌? (opens new window) 07、如何校验用户认证 Token 令牌? (opens new window) 08、如何刷新用户认证 Token 令牌? (opens new window) 09、如何模拟用户认证 Token 令牌? (opens new window) 10、如何实现 URL 是否需要登录? (opens new window) 11、如何实现微信、钉钉等第三方登录? (opens new window) 12、如何实现微信小程序的一键登录? (opens new window) # 功能权限 01、如何设计一套权限系统? (opens new window) 02、如何实现菜单的创建? (opens new window) 03、如何实现角色的创建? (opens new window) 04、如何给用户分配权限 —— 将菜单赋予角色? (opens new window) 05、如何给用户分配权限 —— 将角色赋予用户? (opens new window) 06、后端如何实现 URL 权限的校验? (opens new window) 07、前端如何实现菜单的动态加载? (opens new window) 08、前端如何实现按钮的权限校验? (opens new window) # 数据权限 01、如何实现数据权限(内核)—— 原理剖析? (opens new window) 02、如何实现数据权限(内核)—— 源码实现:MyBatis 如何重写 SQL? (opens new window) 03、如何实现数据权限(内核)—— 源码实现:如何基于(数据规则)生成 WHERE 条件? (opens new window) 04、如何实现【部门级别】的数据权限 —— 入门使用? (opens new window) 05、如何实现【部门级别】的数据权限 —— 源码实现? (opens new window) 06、如何实现【自定义】的数据权限 —— 案例实战? (opens new window) # OAuth2 模块 01、快速入门 OAuth 2.0 授权? (opens new window) 02、基于授权码模式,如何实现 SSO 单点登录? (opens new window) 03、请求时,如何校验 accessToken 访问令牌? (opens new window) 04、访问令牌过期时,如何刷新 Token 令牌? (opens new window) 05、登录成功后,如何获得用户信息? (opens new window) 06、退出时,如何删除 Token 令牌? (opens new window) 07、基于密码模式,如何实现 SSO 单点登录? (opens new window) 08、如何实现客户端的管理? (opens new window) 09、单点登录界面,如何进行初始化? (opens new window) 10、单点登录界面,如何进行【手动】授权? (opens new window) 11、单点登录界面,如何进行【自动】授权? (opens new window) 12、基于【授权码】模式,如何获得 Token 令牌? (opens new window) 13、基于【密码】模式,如何获得 Token 令牌? (opens new window) 14、如何校验、刷新、删除访问令牌? (opens new window) # 工作流 01、如何集成 Flowable 框架? (opens new window) 02、如何实现动态的流程表单? (opens new window) 03、如何实现流程表单的保存? (opens new window) 04、如何实现流程表单的展示? (opens new window) 05、如何实现流程模型的新建? (opens new window) 06、如何实现流程模型的流程图的设计? (opens new window) 07、如何实现流程模型的流程图的预览? (opens new window) 08、如何实现流程模型的分配规则? (opens new window) 09、如何实现流程模型的发布? (opens new window) 10、如何实现流程定义的查询? (opens new window) 11、如何实现流程的发起? (opens new window) 12、如何实现我的流程列表? (opens new window) 13、如何实现流程的取消? (opens new window) 14、如何实现流程的任务分配? (opens new window) 15、如何实现会签、或签任务? (opens new window) 16、如何实现我的待办任务列表? (opens new window) 17、如何实现我的已办任务列表? (opens new window) 18、如何实现任务的审批通过? (opens new window) 19、如何实现任务的审批不通过? (opens new window) 20、如何实现流程的审批记录? (opens new window) 21、如何实现流程的流程图的高亮? (opens new window) 22、如何实现工作流的短信通知? (opens new window) 23、如何实现 OA 请假的发起? (opens new window) 24、如何实现 OA 请假的审批? (opens new window) # SaaS 多租户 01、如何实现多租户的 DB 封装? (opens new window) 02、如何实现多租户的 Redis 封装? (opens new window) 03、如何实现多租户的 Web 与 Security 封装? (opens new window) 04、如何实现多租户的 Job 封装? (opens new window) 05、如何实现多租户的 MQ 与 Async 封装? (opens new window) 06、如何实现多租户的 AOP 与 Util 封装? (opens new window) 07、如何实现多租户的管理? (opens new window) 08、如何实现多租户的套餐? (opens new window) # Web 组件 01、如何实现统一 API 前缀? (opens new window) 02、如何实现统一 API 响应? (opens new window) 03、如何实现 API 全局异常处理? (opens new window) 04、如何实现全局错误码? (opens new window) 05、如何实现 API 接口文档? (opens new window) 06、如何记录 API 访问日志? (opens new window) 07、如何校验 API 请求参数? (opens new window) .pageB img{width:80px!important;} .wwads-horizontal .wwads-text, .wwads-content .wwads-text{line-height:1;} 上次更新: 2023/03/05, 16:00:43 交流群 功能列表 ← 交流群 功能列表→"}] \ No newline at end of file diff --git a/sitemap.xml b/sitemap.xml index 6e80e3c8a..dcedb47fb 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -1,6 +1,15 @@ + + https://blog.thatcoder.cn/Stellar-Timeline-More/ + + 2023-09-25 + + monthly + 0.6 + + https://blog.thatcoder.cn/notes/ @@ -109,15 +118,6 @@ 0.6 - - https://blog.thatcoder.cn/Stellar-Timeline-More/ - - 2023-09-19 - - monthly - 0.6 - - https://blog.thatcoder.cn/Stellar%E5%8F%AF%E6%8E%A7%E5%A4%9C%E9%97%B4%E6%A8%A1%E5%BC%8F/ @@ -4801,7 +4801,7 @@ https://blog.thatcoder.cn/ - 2023-09-22 + 2023-09-25 daily 1.0 @@ -4809,98 +4809,98 @@ https://blog.thatcoder.cn/tags/Clash/ - 2023-09-22 + 2023-09-25 weekly 0.2 - https://blog.thatcoder.cn/tags/Memos/ - 2023-09-22 + https://blog.thatcoder.cn/tags/Linux/ + 2023-09-25 weekly 0.2 - https://blog.thatcoder.cn/tags/Linux/ - 2023-09-22 + https://blog.thatcoder.cn/tags/%E8%B7%A8%E5%9F%9F/ + 2023-09-25 weekly 0.2 - https://blog.thatcoder.cn/tags/%E8%B7%A8%E5%9F%9F/ - 2023-09-22 + https://blog.thatcoder.cn/tags/Memos/ + 2023-09-25 weekly 0.2 https://blog.thatcoder.cn/tags/%E9%9A%8F%E7%AC%94/ - 2023-09-22 + 2023-09-25 weekly 0.2 - https://blog.thatcoder.cn/tags/%E5%9B%BE%E5%BA%8A/ - 2023-09-22 + https://blog.thatcoder.cn/tags/Tencent/ + 2023-09-25 weekly 0.2 - https://blog.thatcoder.cn/tags/Tencent/ - 2023-09-22 + https://blog.thatcoder.cn/tags/%E5%9B%BE%E5%BA%8A/ + 2023-09-25 weekly 0.2 https://blog.thatcoder.cn/tags/Stellar/ - 2023-09-22 + 2023-09-25 weekly 0.2 https://blog.thatcoder.cn/tags/Window/ - 2023-09-22 + 2023-09-25 weekly 0.2 https://blog.thatcoder.cn/tags/Vercel/ - 2023-09-22 + 2023-09-25 weekly 0.2 - https://blog.thatcoder.cn/tags/Game/ - 2023-09-22 + https://blog.thatcoder.cn/tags/GalGame/ + 2023-09-25 weekly 0.2 - https://blog.thatcoder.cn/tags/GalGame/ - 2023-09-22 + https://blog.thatcoder.cn/tags/Game/ + 2023-09-25 weekly 0.2 - https://blog.thatcoder.cn/tags/%E9%82%AE%E4%BB%B6/ - 2023-09-22 + https://blog.thatcoder.cn/tags/%E5%BD%B1%E5%89%A7/ + 2023-09-25 weekly 0.2 - https://blog.thatcoder.cn/tags/%E5%BD%B1%E5%89%A7/ - 2023-09-22 + https://blog.thatcoder.cn/tags/%E9%82%AE%E4%BB%B6/ + 2023-09-25 weekly 0.2 @@ -4909,28 +4909,28 @@ https://blog.thatcoder.cn/categories/%E5%A0%86%E6%A0%88/ - 2023-09-22 + 2023-09-25 weekly 0.2 https://blog.thatcoder.cn/categories/%E7%94%9F%E6%B4%BB/ - 2023-09-22 + 2023-09-25 weekly 0.2 https://blog.thatcoder.cn/categories/%E7%AC%AC%E4%B9%9D%E8%89%BA%E6%9C%AF/ - 2023-09-22 + 2023-09-25 weekly 0.2 https://blog.thatcoder.cn/categories/%E5%88%86%E4%BA%AB/ - 2023-09-22 + 2023-09-25 weekly 0.2 diff --git a/tags/Clash/index.html b/tags/Clash/index.html index 546259138..932dc78ff 100644 --- a/tags/Clash/index.html +++ b/tags/Clash/index.html @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git a/tags/GalGame/index.html b/tags/GalGame/index.html index aeef28165..1a199f1c6 100644 --- a/tags/GalGame/index.html +++ b/tags/GalGame/index.html @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git a/tags/Game/index.html b/tags/Game/index.html index 8f3deb23d..9f890d212 100644 --- a/tags/Game/index.html +++ b/tags/Game/index.html @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git a/tags/Linux/index.html b/tags/Linux/index.html index 0bc1bace7..20866bd32 100644 --- a/tags/Linux/index.html +++ b/tags/Linux/index.html @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git a/tags/Memos/index.html b/tags/Memos/index.html index 48d5dbcb9..ee6da8601 100644 --- a/tags/Memos/index.html +++ b/tags/Memos/index.html @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git a/tags/Stellar/index.html b/tags/Stellar/index.html index d49b69070..783c88187 100644 --- a/tags/Stellar/index.html +++ b/tags/Stellar/index.html @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git a/tags/Tencent/index.html b/tags/Tencent/index.html index 89a635da6..ba089b659 100644 --- a/tags/Tencent/index.html +++ b/tags/Tencent/index.html @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git a/tags/Vercel/index.html b/tags/Vercel/index.html index 2b061adb3..06ba52e2e 100644 --- a/tags/Vercel/index.html +++ b/tags/Vercel/index.html @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git a/tags/Window/index.html b/tags/Window/index.html index 1a8e65e10..ba33e9eea 100644 --- a/tags/Window/index.html +++ b/tags/Window/index.html @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git a/tags/index.html b/tags/index.html index 378f4a501..69a9b6a01 100644 --- a/tags/index.html +++ b/tags/index.html @@ -150,7 +150,7 @@

标签

@@ -200,14 +200,14 @@

标签

Clash - - Memos - - 跨域 + + Memos + + 图床 @@ -224,14 +224,14 @@

标签

GalGame - - 邮件 - - 影剧 + + 邮件 + + diff --git "a/tags/\345\233\276\345\272\212/index.html" "b/tags/\345\233\276\345\272\212/index.html" index 14a7af0bd..7157c36ef 100644 --- "a/tags/\345\233\276\345\272\212/index.html" +++ "b/tags/\345\233\276\345\272\212/index.html" @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git "a/tags/\345\275\261\345\211\247/index.html" "b/tags/\345\275\261\345\211\247/index.html" index bc3e737cb..2fec2153b 100644 --- "a/tags/\345\275\261\345\211\247/index.html" +++ "b/tags/\345\275\261\345\211\247/index.html" @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git "a/tags/\350\267\250\345\237\237/index.html" "b/tags/\350\267\250\345\237\237/index.html" index af4be47b3..702f5ec84 100644 --- "a/tags/\350\267\250\345\237\237/index.html" +++ "b/tags/\350\267\250\345\237\237/index.html" @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git "a/tags/\351\202\256\344\273\266/index.html" "b/tags/\351\202\256\344\273\266/index.html" index 7139961d9..d00511351 100644 --- "a/tags/\351\202\256\344\273\266/index.html" +++ "b/tags/\351\202\256\344\273\266/index.html" @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git "a/tags/\351\232\217\347\254\224/index.html" "b/tags/\351\232\217\347\254\224/index.html" index 2bb9c1738..22c81897e 100644 --- "a/tags/\351\232\217\347\254\224/index.html" +++ "b/tags/\351\232\217\347\254\224/index.html" @@ -150,7 +150,7 @@

那个码农-钟意博 diff --git "a/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\345\211\215\347\253\257\344\270\216 BI\343\200\213.html" "b/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\345\211\215\347\253\257\344\270\216 BI\343\200\213.html" index ceb57c77c..97e8f5d5d 100644 --- "a/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\345\211\215\347\253\257\344\270\216 BI\343\200\213.html" +++ "b/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\345\211\215\347\253\257\344\270\216 BI\343\200\213.html" @@ -234,7 +234,7 @@

交互响应

包括上卷下钻、点选、圈选、高亮等交互操作,这些操作反馈到渲染引擎导致数据变化并将新的数据灌入图表组件。

业务逻辑上这些交互操作并不复杂,难点在使用的可视化库是否有这个能力,以及如何统一交互行为。

总结

BI 领域的四大方向:数据集、渲染引擎、数据模型与可视化都有许多可以做深的技术点,每一块都需要深入沉淀几年技术经验才能做好,需要大量优秀人才通力协作才有可能做好。

-

目前我们在阿里数据中台正在打造一款面向未来的优秀 BI 工具,如果 BI 领域让你觉得有挑战,随时欢迎你的加入,联系邮箱:ziyi.hzy@alibaba-inc.com

+

目前我们在阿里数据中台正在打造一款面向未来的优秀 BI 工具,如果 BI 领域让你觉得有挑战,随时欢迎你的加入,联系邮箱:ziyi.hzy@alibaba-inc.com

讨论地址是:精读《前端与 BI》 · Issue ##208 · dt-fe/weekly

diff --git "a/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\345\257\271\344\275\216\344\273\243\347\240\201\346\220\255\345\273\272\347\232\204\347\220\206\350\247\243\343\200\213.html" "b/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\345\257\271\344\275\216\344\273\243\347\240\201\346\220\255\345\273\272\347\232\204\347\220\206\350\247\243\343\200\213.html" index 52e09e972..1d068373f 100644 --- "a/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\345\257\271\344\275\216\344\273\243\347\240\201\346\220\255\345\273\272\347\232\204\347\220\206\350\247\243\343\200\213.html" +++ "b/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\345\257\271\344\275\216\344\273\243\347\240\201\346\220\255\345\273\272\347\232\204\347\220\206\350\247\243\343\200\213.html" @@ -225,7 +225,7 @@

3

所以不仅渲染态是多态的,设计器也应该是多态的,其中可以被固化为标准的部分需要沉淀下来,比如物料接入规范、编排能力、出码能力、运行时能力,让各个搭建平台做到合而不同。

国内外都有非常多做的相当不错的搭建系统,但要不就太通用,具体场景提效不明显,要不就太垂直,换一个业务场景做不了。现在阿里中后台低代码搭建组织就在制定规范,将引擎通用能力固化为标准协议,让不同搭建平台可以对齐规范与功能,未来还会不断收敛核心引擎实现,基于它可以打造出千千万万个垂直领域的搭建平台,贴着业务做搭建提效,同时引擎内核与规范还能保持互通。

笔者所在阿里数据中台体验技术团队就是中后台低代码搭建组织的一员,将数据搭建领域做到极致。在技术上,我们在打通中后台搭建与数据搭建的技术方案,在产品上,我们正在逐渐统一阿里集团数据搭建平台,对外也携 QuickBI 成为国内唯一一家进入 Gartner 象限的 BI 产品,未来可期。

-

阿里数据中台体验技术团队正在火热招人中,如果感兴趣可以联系 ziyi.hzy@alibaba-inc.com

+

阿里数据中台体验技术团队正在火热招人中,如果感兴趣可以联系 ziyi.hzy@alibaba-inc.com

讨论地址是:精读《对低代码搭建的理解》· Issue ##260 · dt-fe/weekly

diff --git "a/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\346\210\221\345\234\250\351\230\277\351\207\214\346\225\260\346\215\256\344\270\255\345\217\260\345\244\247\345\211\215\347\253\257\343\200\213.html" "b/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\346\210\221\345\234\250\351\230\277\351\207\214\346\225\260\346\215\256\344\270\255\345\217\260\345\244\247\345\211\215\347\253\257\343\200\213.html" index ad60cd131..f036e6549 100644 --- "a/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\346\210\221\345\234\250\351\230\277\351\207\214\346\225\260\346\215\256\344\270\255\345\217\260\345\244\247\345\211\215\347\253\257\343\200\213.html" +++ "b/wiki/WebWeekly/\345\211\215\346\262\277\346\212\200\346\234\257/\343\200\212\346\210\221\345\234\250\351\230\277\351\207\214\346\225\260\346\215\256\344\270\255\345\217\260\345\244\247\345\211\215\347\253\257\343\200\213.html" @@ -235,7 +235,7 @@

4 总结

大数据前端人才缺口在 100 人以上,由于业务增长非常非常迅猛,春节前条件放宽、特批急召!

-

如果你对我们感兴趣,请立刻把简历发送到邮箱 ziyi.hzy@alibaba-inc.com 吧!绝无仅有的好机会,响应速度绝对超乎你的想象!

+

如果你对我们感兴趣,请立刻把简历发送到邮箱 ziyi.hzy@alibaba-inc.com 吧!绝无仅有的好机会,响应速度绝对超乎你的想象!

讨论地址是:精读《我在阿里数据中台大前端》 · Issue ##224 · dt-fe/weekly

diff --git "a/wiki/WebWeekly/\347\256\227\346\263\225/\343\200\212\347\256\227\346\263\225 - \345\233\236\346\272\257\343\200\213.html" "b/wiki/WebWeekly/\347\256\227\346\263\225/\343\200\212\347\256\227\346\263\225 - \345\233\236\346\272\257\343\200\213.html" index 72261a21e..b04666395 100644 --- "a/wiki/WebWeekly/\347\256\227\346\263\225/\343\200\212\347\256\227\346\263\225 - \345\233\236\346\272\257\343\200\213.html" +++ "b/wiki/WebWeekly/\347\256\227\346\263\225/\343\200\212\347\256\227\346\263\225 - \345\233\236\346\272\257\343\200\213.html" @@ -208,7 +208,7 @@

192.168@1.1“ 是 无效 IP 地址

+

例如:”0.1.2.201” 和 “192.168.1.1” 是 有效 IP 地址,但是 “0.011.255.245”、”192.168.1.312” 和 “192.168@1.1“ 是 无效 IP 地址

首先肯定一个一个字符读取,问题就在于,一个字符串可能表示多种可能的 IP,比如 25525511135 可以表示为 255.255.11.135255.255.111.35,原因在于,11.135111.35 都是合法的表示,所以我们必须用回溯法解决问题,只是回溯过程中,会根据读取数据动态判定增加哪些新分支,以及哪些分支是非法的。

比如读取到 [1,1,1,3,5] 时,由于 11111 都是合法的,因为这个位置的数字只要在 0~255 之间即可,而 1113 超过这个范围,所以被忽略,所以从这个场景中分叉出两条路:

diff --git "a/\345\205\253\344\270\200\345\271\277\345\234\272\350\256\260/index.html" "b/\345\205\253\344\270\200\345\271\277\345\234\272\350\256\260/index.html" index 25ca5564e..fb309820e 100644 --- "a/\345\205\253\344\270\200\345\271\277\345\234\272\350\256\260/index.html" +++ "b/\345\205\253\344\270\200\345\271\277\345\234\272\350\256\260/index.html" @@ -144,7 +144,7 @@

八一广场记

-
最近更新
+
最近更新
diff --git "a/\345\210\207\345\260\224\350\257\272\350\264\235\345\210\251/index.html" "b/\345\210\207\345\260\224\350\257\272\350\264\235\345\210\251/index.html" index 9fa185755..b76cf5b71 100644 --- "a/\345\210\207\345\260\224\350\257\272\350\264\235\345\210\251/index.html" +++ "b/\345\210\207\345\260\224\350\257\272\350\264\235\345\210\251/index.html" @@ -162,7 +162,7 @@

切尔诺贝利特

最近更新
+
最近更新
@@ -190,7 +190,7 @@

Chernobylite

IP发展史

-

2021年07月28日 一作PC发行

《Chernobylite》是 The Farm 51 开发的科幻生存恐怖角色扮演游戏。故事设定在超现实的切尔诺贝利隔离区,在这片基于 3D扫描的荒弃土地上,探索非线性的故事,揭露你饱受煎熬的过去中隐藏的真相。
+

2021年07月28日 一作PC发行

《Chernobylite》是 The Farm 51 开发的科幻生存恐怖角色扮演游戏。故事设定在超现实的切尔诺贝利隔离区,在这片基于 3D扫描的荒弃土地上,探索非线性的故事,揭露你饱受煎熬的过去中隐藏的真相。
@@ -220,7 +220,7 @@

Chernobylite

下载地址

- +

有能力记得入正喔!

diff --git "a/\345\217\257\345\241\221\346\200\247\350\256\260\345\277\206/index.html" "b/\345\217\257\345\241\221\346\200\247\350\256\260\345\277\206/index.html" index 0e67dfd5f..196c316a2 100644 --- "a/\345\217\257\345\241\221\346\200\247\350\256\260\345\277\206/index.html" +++ "b/\345\217\257\345\241\221\346\200\247\350\256\260\345\277\206/index.html" @@ -167,7 +167,7 @@

可塑性记忆

-
最近更新
+
最近更新
@@ -188,7 +188,7 @@

PLASTIC MEMORIES

IP发展史

-

2014年8月 Twitter入住

2015年3月27日 网络广播

B站UP有留存
B站UP有留存
《满和扎克的PLAMEMO广播》, 每周五在HiBiKi Radio Station播出. 听得懂你就来, 给你链接

2015年4月4日 电视动画

《可塑性记忆》原创电视动画由ANIPLEX公司企划,由负责过5pb.公司开发的《命运石之门》等“科学ADV系列”游戏剧本的林直孝担当编剧。

2015年4月24日 外传漫画

这部前日谈性质的外传漫画由祐佑作画,以绢岛满为主人公,主要讲述发生在动画剧情前的故事。与动画同步进行描写不同视角故事的本传漫画则于《电击G's Comic》2015年6月号开始连载。

2016年9月10日 外传小说

ISBN: 4048654098
《プラスティック・メモリーズ -Heartfelt Thanks》
由原作者林直孝亲自执笔的外传小说于2016年9月10日发售。

电击文库欸嘿
电击文库欸嘿

2016年10月13日 平台游戏

+

2014年8月 Twitter入住

2015年3月27日 网络广播

B站UP有留存
B站UP有留存
《满和扎克的PLAMEMO广播》, 每周五在HiBiKi Radio Station播出. 听得懂你就来, 给你链接

2015年4月4日 电视动画

《可塑性记忆》原创电视动画由ANIPLEX公司企划,由负责过5pb.公司开发的《命运石之门》等“科学ADV系列”游戏剧本的林直孝担当编剧。

2015年4月24日 外传漫画

这部前日谈性质的外传漫画由祐佑作画,以绢岛满为主人公,主要讲述发生在动画剧情前的故事。与动画同步进行描写不同视角故事的本传漫画则于《电击G's Comic》2015年6月号开始连载。

2016年9月10日 外传小说

ISBN: 4048654098
《プラスティック・メモリーズ -Heartfelt Thanks》
由原作者林直孝亲自执笔的外传小说于2016年9月10日发售。

电击文库欸嘿
电击文库欸嘿

2016年10月13日 平台游戏

游戏简介

故事发生在一个比现在的科学要进步的世界。18岁的水柿司高考失败,多亏父母找关系得以进入世界大企业SAI社工作。SAI社是制造管理拥有感情的人形智能机器人(通称:Giftia)的企业,司在其中被安排到终端服务部门工作。这个部门其实就是回收即将到期的Giftia,是所谓的“窗边部门(不被重视的部门)”。于是司和打杂的Giftia少女“艾拉”组成搭档,一起开始了工作……

游戏画面

@@ -197,7 +197,7 @@

PLASTIC MEMORIES

下载地址

- + diff --git "a/\345\260\217\345\260\217\346\242\246\351\255\207/index.html" "b/\345\260\217\345\260\217\346\242\246\351\255\207/index.html" index 38e0f9f85..c076b4129 100644 --- "a/\345\260\217\345\260\217\346\242\246\351\255\207/index.html" +++ "b/\345\260\217\345\260\217\346\242\246\351\255\207/index.html" @@ -162,7 +162,7 @@

小小梦魇

-
最近更新
+
最近更新
diff --git "a/\346\202\250\345\220\215\344\270\213\345\267\262\345\244\207\346\241\210\347\275\221\347\253\231\347\233\256\345\211\215\346\266\211\345\217\212\350\277\235\346\263\225\344\277\241\346\201\257/index.html" "b/\346\202\250\345\220\215\344\270\213\345\267\262\345\244\207\346\241\210\347\275\221\347\253\231\347\233\256\345\211\215\346\266\211\345\217\212\350\277\235\346\263\225\344\277\241\346\201\257/index.html" index bf542dcfc..84e97013c 100644 --- "a/\346\202\250\345\220\215\344\270\213\345\267\262\345\244\207\346\241\210\347\275\221\347\253\231\347\233\256\345\211\215\346\266\211\345\217\212\350\277\235\346\263\225\344\277\241\346\201\257/index.html" +++ "b/\346\202\250\345\220\215\344\270\213\345\267\262\345\244\207\346\241\210\347\275\221\347\253\231\347\233\256\345\211\215\346\266\211\345\217\212\350\277\235\346\263\225\344\277\241\346\201\257/index.html" @@ -144,7 +144,7 @@

您名下已备案网 -
最近更新
+
最近更新
diff --git "a/\351\202\256\347\256\261\346\250\241\346\235\277\351\233\206/index.html" "b/\351\202\256\347\256\261\346\250\241\346\235\277\351\233\206/index.html" index 85521efb1..71ebd3385 100644 --- "a/\351\202\256\347\256\261\346\250\241\346\235\277\351\233\206/index.html" +++ "b/\351\202\256\347\256\261\346\250\241\346\235\277\351\233\206/index.html" @@ -138,7 +138,7 @@

邮件样式模板集< -
最近更新
+
最近更新