非主流 App GUI 框架 - Druid

不会吧,不会吧,都 2021 年了还有人在写桌面端应用吗?不都是 Electron 一统江湖了吗?对,也不对,Electron 的确大大降低了 web 页面直接生成单个可执行桌面程序的难度,但因其依赖于 Chromium 内核,糟糕的启动速度和海量内存占用一直是广大网友所诟病之处。
市面上还是有很多跨端 GUI 解决方案的,比如 QtGTK 等等,但既然是玩票嘛,就搞点新鲜的,本文就介绍一下当红炸子鸡语言 Rust 上的非主流 GUI 框架 - Druid。

demo

先简单介绍下 Druid,它同样是一个数据驱动的原生框架,背后是同作者开发的 Piet 绘图库。在不同的系统上有着不同的实现,这里就不多提了,感兴趣的可以深入研究一下。目前 Druid 还处在较为早期的开发阶段(除此之外的 GUI 库也都差不多……),所以文档和示例都很不全。本文将基于 0.7.0 版本进行阐述,如后续有不兼容升级,以官方文档为准。

启动

安装

万事开头难,中间更难,最后最难。说实话,Rust 的包管理系统已经算是不错的了,在你装好了 Rust 环境之后,随便创建个 cargo bin 项目即可,把 druid 加到依赖里面,这里推荐装个 cargo-edit 的包,这样你就能得到以下几个 cargo 命令,后续就不需要手动改 Cargo.toml 文件了。

1
2
3
4
cargo-edit v0.7.0:
cargo-add
cargo-rm
cargo-upgrade

第一个界面

官网第一个 case 就让我们栽了跟头,这里的 log_to_consoleui_builder 都不太对劲,改成如下代码就可以跑出首个界面啦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use druid::widget::{Button, Flex, Label};
use druid::{AppLauncher, LocalizedString, PlatformError, Widget, WidgetExt, WindowDesc};

fn main() -> Result<(), PlatformError> {
let main_window = WindowDesc::new(ui_builder);
let data = 0_u32;
AppLauncher::with_window(main_window)
.use_simple_logger()
.launch(data)
}

fn ui_builder() -> impl Widget<u32> {
// The label text will be computed dynamically based on the current locale and count
let text =
LocalizedString::new("hello-counter").with_arg("count", |data: &u32, _env| (*data).into());
let label = Label::new(text).padding(5.0).center();
let button = Button::new("increment")
.on_click(|_ctx, data, _env| *data += 1)
.padding(5.0);

Flex::column().with_child(label).with_child(button)
}

麻雀虽小,五脏俱全,我们可以看到 ui_builder 就是界面相关的部分了,其中有文本和按钮,以 flex 布局,而这个函数被整个传递给了一个 WindowDesc 的构造函数,这就创建了一个窗口,然后这个窗口又被传递给了 AppLauncher,接着用 data 作为初始数据启动。整体逻辑还是比较清晰的。

数据

虽然前面传入的数据只是一个 u32,但实际应用的数据状态肯定不止如此。Druid 提供了一套简单但够用的数据定义和处理模型,其核心是通过内部获取数据的可变引用直接修改数据本身,而外部通过消息传递给代理器统一更改数据,实现了灵活多样的数据操作。

类型定义

首先是类型定义,Druid 提供了 DataLens 两个重要的 trait,它们分别提供了如何判断数据相等和如何从一大块数据中提取所需要数据的方式。

1
2
3
4
5
6
7
8
9
use druid::{Data, Lens};
use tokio::sync::mpsc::{UnboundedSender}

[derive(Debug, Clone, Data, Lens)]
pub struct State {
pub day: u32,
#[data(ignore)]
pub dispatch: UnboundedSender<u32>,
}

通常情形下,DateLens 可以被 derive 自动实现,但有些时候则需要一点小小的帮助,比如上图就需要忽略掉不可比较的 dispatch 字段,它只是一个消息发送器,无所谓变更,也不会变更。
Lens 的用途就更大了,比如组合属性等,详情可见 LenExt

事件与代理

前面讲了事件处理的方式:

  • 直接获取数据可变引用并修改
  • 通过消息代理

先讲讲消息代理这种形式吧,毕竟从 React 过来的人都偏好单向数据流。

AppLauncher 真正 launch 之前可以通过 get_external_handle 获取一个 ExtEventSink,通过它可以向 Druid App 内发送消息,这玩意甚至可以跨线程传递。而接受消息同样在 AppLauncher 上,通过传入一个实现了 AppDelegate trait 的 struct 给 delegate 方法即可。

需要注意的是,发送的消息和接受的消息都需要以唯一的 Selector 识别,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use druid::{Selector, AppDelegate};

const NEW_DAY: Selector<String> = Selector::new("new-day");
impl AppDelegate<State> for AppDelegater {
fn command(
&mut self,
ctx: &mut DelegateCtx,
_target: Target,
cmd: &Command,
data: &mut State,
_env: &Env,
) -> Handled {
if let Some(day) = cmd.get(NEW_DAY) {
data.days.push_back(day.to_string());
Handled::Yes
}
}
}

控制器

控制器即 Controller,它和 App 上的消息代理类似,但不同之处在于它往往是局部的,能提供针对某种 Event,组件生命周期和内外数据变化的精细控制。

例如,我们想要在窗口中实现一个右键菜单:每当用户操纵鼠标在窗口内右键单击时调用 make_demo_menu 创建一个菜单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use druid::widget::{Controller};
use druid::{Widget, Event, ContextMenu};
use crate::components::menu::make_demo_menu;
use crate::types::{State};

pub struct WindowController;

impl <W: Widget<State>> Controller<State, W> for WindowController {
fn event(
&mut self,
child: &mut W,
ctx: &mut druid::EventCtx<'_, '_>,
event: &druid::Event,
data: &mut State,
env: &druid::Env
) {
match event {
Event::MouseDown(ref mouse) if mouse.button.is_right() => {
let context_menu = ContextMenu::new(make_demo_menu(), mouse.pos);
ctx.show_context_menu(context_menu);
},
_ => child.event(ctx, event, data, env),
}
}
}

需要注意的是没有处理的 Event 需要显式交给 child 继续处理,这与浏览器的 DOM 事件不同,是向下“冒泡”的。

环境变量

这里的环境变量不是指系统的环境变量,而是 Druid App 组件相关的整体设定,例如窗口颜色和按钮尺寸等等。
环境变量分两种:一种全局,一种局部。

全局的环境变量通过 launcher 的 configure_env 设置。

1
2
3
4
5
6
7
8
9
launcher.use_simple_logger()
.configure_env(|env, _| {
env.set(theme::WINDOW_BACKGROUND_COLOR, Color::WHITE);
env.set(theme::LABEL_COLOR, Color::AQUA);
env.set(theme::BUTTON_LIGHT, Color::WHITE);
env.set(theme::BUTTON_DARK, Color::WHITE);
env.set(theme::BACKGROUND_DARK, Color::GRAY);
env.set(theme::BACKGROUND_LIGHT, Color::WHITE);
})

而局部的环境变量则可以通过 EnvScope 来设置,就可以做到组件样式隔离。

1
2
3
4
5
6
EnvScope::new(
|env, data| {
env.set(theme::LABEL_COLOR, Color::WHITE);
},
Label::new("White text!")
)

界面

所谓界面,就是窗口中显示的那部分东西,通常来说是布局和组件的有机结合,当然也可以自定义组件的展示和行为,只需定义好所需的更新方式、事件处理、生命周期等即可,这样就带来了更多的可扩展性。

组件

最常见的组件莫过于文本块 Label 和按钮 Button 了。剩下的比如 Tab 栏、进度条、单选多选项、输入框等也是基本都有。
展示一下基本的按钮和文本块的创建方法。

1
2
3
4
5
6
let button = Button::new(button_text)
.on_click(|_ctx, data: &mut State, _env| {
data.count += 1;
})
.padding(5.0);
let label = Label::new("hello world");

布局

1
2
3
4
5
6
7
 -------non-flex----- -flex-----
| child #1 | child #2 |


----flex------- ----flex-------
| child #1 | child #2 |

Druid 提供了 Flex 布局,熟悉 CSS 的同学一定很快就能理解,但类似以下这种命令式的创建方式还是让人皱眉头且怀念 CSS。

1
2
3
4
5
6
7
8
use druid::widget::{Flex, FlexParams, Label, Slider, CrossAxisAlignment};

let my_row = Flex::row()
.cross_axis_alignment(CrossAxisAlignment::Center)
.must_fill_main_axis(true)
.with_child(Label::new("hello"))
.with_default_spacer()
.with_flex_child(Slider::new(), 1.0);

新建窗口

光靠组件和布局,仅仅是在窗口之内操作肯定是不足以创建出足够具有动态的应用的,我们还需要动态创建窗口的能力!Druid 也提供了在 EventCtxDelegateCtx 上创建窗口的能力。

比如我们可以在全局 AppDelegate 上注册新窗口的 Command

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub struct AppDelegater;

impl AppDelegate<State> for AppDelegater {
fn command(
&mut self,
ctx: &mut DelegateCtx,
_target: Target,
cmd: &Command,
data: &mut State,
_env: &Env,
) -> Handled {
if let Some(_) = cmd.get(NEW_WINDOW) {
ctx.new_window(WindowDesc::new(new_window_builder)
.window_size((400.0, 300.0)));
Handled::Yes
} else {
Handled::No
}
}
}

图片

图片是个复杂的东东,目前看到 Druid 的处理方式是直接将图片的二进制数据编译进去,在运行时转变成像素进行渲染,需要安装 image 这个 crate 进行处理。后续 Druid 的版本会简化这一流程,但当前还是得这么写 …… 且图像会被变成黑白照片,不知道为啥,有知道的同学请不吝赐教。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use druid::widget::{Image, SizedBox};
use druid::{Widget, ImageBuf, WidgetExt, Color};
use druid::piet::{ImageFormat};

use crate::types::State;

pub fn make_image() -> impl Widget<State> {
let raw_image = include_bytes!("../../resources/image/example.jpg");
let image_data = image::load_from_memory(raw_image).map_err(|e| e).unwrap();
let rgb_image = image_data.to_rgb8();
let size_of_image = rgb_image.dimensions();
let image_buf = ImageBuf::from_raw(
rgb_image.to_vec(),
ImageFormat::Rgb,
size_of_image.0 as usize,
size_of_image.1 as usize,
);
SizedBox::new(Image::new(image_buf))
.fix_width(size_of_image.0 as f64 / 8.0)
.fix_height(size_of_image.1 as f64 / 8.0)
.border(Color::grey(0.6), 2.0).center().boxed()
}

其他

通常来说 GUI 程序拥有数据和界面就够了,这也就是典型的 MVC 架构,但实际上作为跨平台框架还需要考虑系统原生接口和国际化等问题,甚至包括富文本的处理。只有这些都面面俱到了,才能做到开发者无痛接入,一发入魂。

菜单与快捷键

Windows 和 macOS 的菜单不太一样,Windows 是挂在每个窗口标题栏下,而 macOS 则是挂在屏幕边缘,实际上它们都是作为窗口的一部分存在的,所以在设计时也是统一在窗口初始化时传入。

1
2
3
4
let menu = MenuDesc::new(LocalizedString::new("start"))
.append(make_file_menu());
.append(make_window_menu());
let main_window = WindowDesc::new(ui_builder).menu(menu);

国际化 i18n

Druid 的国际化是通过 LocalizedString 来实现的,例如在界面中有如下一段文本。

1
2
let text = LocalizedString::new("hello-counter")
.with_arg("count", |data: &State, _env| data.count.into());

则可以通过创建一个 resources/i18n/en-CN/builtin.ftl 的文件(具体以 Druid 启动时的输出语言为准),在其中写入对应 hello-counter,其中的 count 就会被替换成实际的数据。
DEBUG 启动时输出了 en-CN

1
2
# resources/i18n/en-CN/builtin.ftl
hello-counter = 现在的值是 { $count }

展示结果

路径示意

富文本渲染、编辑

我们知道,经典物理学只是茫茫科学中限于低速宏观之中极小的一块研究区域,同理,一个简单的输入框也是富文本编辑的一个缩影。


完整的 text 模块包含了很多东西,但简单一点考虑,我们实现一个富文本编辑器只需要一个 Editor 和一个 RichText 的展示容器即可。

而 RichText 本质上是一串字符串与数个按索引设置的属性的数据集合。

1
2
3
4
5
pub fn generate_example_rich_data(text: &str) -> RichText {
let attr = Attribute::TextColor(KeyOrValue::Concrete(Color::PURPLE));
RichText::new(text.into())
.with_attribute(6..=10, attr)
}

显示效果

Druid 并不支持 WYSIWYG 所见即所得的编辑模式,所以编辑器和富文本内容是分离的,在数据中实际存储的应该是一段 raw 文本和数个属性的集合,在渲染时组成 RichText 传递给 RawLabel 进行渲染。

详情可见官方示例 - markdown_preview

调试

在万能的 VS Code 里调试 Rust 程序是比较方便的。在创建 Rust 项目后,VS Code 就会提示按照 llvm 相关组件以便启用 DEBUG 模式。

.vscode/launch.json 中添加 lldb 为目标的配置后,即可在调试侧边栏一键开启调试模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'gui'",
"cargo": {
"args": [
"build",
"--bin=gui",
"--package=gui"
],
"filter": {
"name": "gui",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

断点调试

如图所示,断点处的变量、调用栈、上下文等信息一览无余。

结语

本文简单介绍了 Rust GUI 框架 Druid 的基本架构和使用,通过笔者自行摸索解决了 Druid 实际运行版本和 Demo 及文档脱钩的问题,希望能对读者有所裨益。

后附笔者调试测试用的 Demo 仓库地址:https://github.com/msyfls123/rust-gui

使用 RxJS 处理前端数据流

在前端开发中,虽然大部分时间都是在”接受用户操作数据将它们发送给服务器,再从服务器拉取数据展示成 UI 给用户“,但偶尔还是会有一些操作和显示不同步的情形,例如用户不停地在搜索框输入文字,那么在用户输入的时候其实是不希望一直去网络请求“建议搜索项”的,一方面会闪烁很厉害,一方面也会发出不必要的请求,可以用防抖 debounce 和节流 throttle 函数优化输入体验。

单个组件也许只需要加个 lodash 的纯函数即可,但遇到更复杂的输入情形会是如何呢?

实际问题

问题 1:简单识别 URL

识别 URL

如上图所示,左侧是一个 window,从其他地方将一个带有超链接的文本复制到剪贴板之后切换到这个 window,我们希望在 window 的 focus 事件发出时能够识别到这个链接并打开。

第一版代码可能非常简单,只需要用正则表达式判断一下即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// window.js
const URL_REG = /(?:(?:https|http):\/\/)?docs\.qq\.com\/\w+/;

function handleClipboardText(text) {
const matched = text.match(URL_REG);
if (matched) {
openUrl(matched[0])
}
}

thisWindow.on('focus', () => {
const text = clipboard.getText();
handleClipboardText(text);
})

问题 2:记忆已识别的文本

记忆已识别的文本

如果有多个窗口呢?希望能只在一个窗口触发一次,那就需要一个中心化的缓存值,缓存之前处理过的文本。

1
2
3
4
5
6
7
8
9
10
// main.js
let memorizedText = null;

export function checkIsMemorizedText(text) {
if (text !== memorizedText) {
memorizedText = text;
return false;
}
return true;
}

而相应的处理单个 window 的地方需要改成这样

1
2
3
4
5
6
7
8
9
10
11
12
// window.js
+ import { checkIsMemorizedText } from 'main';
const URL_REG = /(?:(?:https|http):\/\/)?docs\.qq\.com\/\w+/;

function handleClipboardText(text) {
+ const checked = checkIsMemorizedText(text);
+ if (checked) return;
const matched = text.match(URL_REG);
if (matched) {
openUrl(matched[0])
}
}

进阶问题:通过 HTTP 请求获取详细数据

这时产品觉得只拿到 URL 信息展示给用户没有太大的价值,要求展示成带有丰富信息的卡片格式,问题一下子变得复杂起来。

链接卡片

当然还是可以直接在每个 window 下去发起并接受 HTTP 请求,但这样代码就会变得越来越臃肿,该怎么办呢?

RxJS 实现数据流

这时就不满足于只是能简单处理时间间隔的 lodashdebouncethrottle 函数了,我们需要可以随时掌控数据流向和速率,并且具有终止重试合并等高级功能的工具。

RxJS 作为反应式编程的翘楚映入我们的眼帘,这里简单引用一下官网的介绍。

RxJS is a library for composing asynchronous and event-based programs by using observable sequences. It provides one core type, the Observable, satellite types (Observer, Schedulers, Subjects) and operators inspired by Array#extras (map, filter, reduce, every, etc) to allow handling asynchronous events as collections.

“Think of RxJS as Lodash for events.”

具体教程和 API 文档可参见官网,https://rxjs.dev/guide/overview。
以及本人严重推荐程墨老师的这本《深入浅出RxJS》,可以说把基础知识和实践应用讲透了。

下面的内容需要读者对 RxJS 有基本的了解。

创建数据流

我们创建了一条最基本的数据流 textInClipboard,它是所有后续操作的源头。从技术角度讲,它是一个 Subject,也就是作为触发器接受数据,也能够作为 Observable 向 Observer 发送数据。

1
2
3
4
5
6
7
8
9
const textInClipboard = new Subject();

export function checkUrlInClipboard(windowId) {
const text = clipboard.getText();
textInClipboard.next({
text,
windowId,
});
}

上面的代码创建了 textInClipboard Subject,并创建 checkUrlInClipboard 函数,在其中将当前剪贴板里的值传递给 textInClipboard,这样在 window 侧只需要调用这个方法就可以触发后面的一系列数据操作了。

1
2
3
4
5
thisWindow.on('focus', () => {
const text = clipboard.getText();
- handleClipboardText(text);
+ checkUrlInClipboard(text);
})

数据去重

创建完了接受用户操作的数据流之后,就需要对输入做去重,连续触发多次(例如用户在多个窗口间切换并不会连续识别 URL,而是只识别不同的第一个

去重

这在 RxJS 中非常容易实现,可以使用 distinctUntilKeyChanged 运算符。

加上常用的 filtermap,我们就组合出了一套简易过滤有效 URL的管道,将上面的 textInClipboard 灌进去试一试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { map, filter, distinctUntilKeyChanged } from 'rxjs/operators';

const URL_REG = /(?:(?:https|http):\/\/)?docs\.qq\.com\/\w+/;

const filteredUrlInClipboard$ = textInClipboard.pipe(
distinctUntilKeyChanged('text'),
map(({ text, ...rest }) => {
const matched = text.match(URL_REG);
if (matched) {
return {
url: matched[0],
...rest,
}
}
}),
filter(Boolean(e?.url)),
);

filteredUrlInClipboard$.subscribe(console.log);

演示一下。

发送 HTTP 请求

处理完了重复的文本,下面就该将筛选出的 URL 通过 HTTP 请求去获取详细信息了。

现在前端通常使用 fetch 直接发起 HTTP 请求,得到的是一个 Promise,如何将 fetchRxJS 有机结合起来?RxJS 自身提供了 from 创建符,将一个 Promise 转变成 Observable 是非常容易的。

1
2
3
import { from } from 'rxjs';

const fetch$ = from(fetch(someUrl));

但这里我们对程序的可维护性和健壮性提出了更高的要求:

  • 同时支持多个 HTTP 请求,并且将它们放在一个 Observable 里处理。
  • 支持 HTTP 请求的错误重试及 log 功能。

mergeMap 拍平请求

针对第一个要求,可以使用 mergeMap 来将 URL 一一映射成 fetch 得到的 Observable,因为是在一个 Observable 里创建出的 Observable,所以是高阶 Observable,再将这些高阶 Observable 收集起来变成 Observable 吐出的一个个值,就成为了 docInfo$ 的新 Observable,其中每一个值都是从 HTTP 请求返回的文档信息。

1
2
3
4
5
6
7
8
9
10
import { from } from 'rxjs';
import { mergeMap } from 'rxjs/operators';

const CGI_URL = 'example.com/get-info';

const docInfo$ = filteredUrlInClipboard$.pipe(
mergeMap(url => from(
fetch(`${CGI_URL}?url=${encodeURIComponent(url)}`)
))
);

其实 mergeMap 原来叫做 flatMap,是不是更有拍平摊开的意味?

带有重试功能的请求

RxJS 里的 Observable 如果出错了,默认是直接在当前 Observable 发出 error,并且终止,意味着一次失败的请求后面的请求将永远不会被发出了,这肯定不是我们希望看到的。首先我们得接住这个爆出来的 error,可以用 catchError 操作符。接下来考虑网络不稳定的情形,添加自动重试逻辑,这里会用到比较多的操作符,先将示例代码展示在这里,感兴趣的同学可以自行研究 RxJS 的重试机制,相信会大有裨益。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { defer, timer, NEVER, throwError } from 'rxjs';
import { retryWhen, concatMap, mergeMap, catchError } from 'rxjs/operators';

const CGI_URL = 'example.com/get-info';

const retryThreeTimesWith500msDelay = retryWhen(errors => ( // <-- projector
errors.pipe(concatMap((e, i) => {
console.error(`第 ${i + 1} 次失败`, e.toString());
return i >= 3 ? throwError(e) : timer(500);
}))
));

const docInfo$ = filteredUrlInClipboard$.pipe(
mergeMap(url => (
defer(() => fetch(`${CGI_URL}?url=${encodeURIComponent(url)}`)).pipe(
retryThreeTimesWith500msDelay,
catchError((error) => {
console.error(`获取 url=${url} 的信息失败`, error.toString());
return NEVER;
})
)
))
);

这段代码做了很多事情,retryThreeTimesWith500msDelay 里的 retryWhen 接受一个 projector 函数,传入的 errors 是一个 Observable,它在上游每次报错时吐出一个值 e,这里可以拿到 e 和索引 i,而这个 projector 返回的 Observable 一旦发出值就会重新 subscribe 上游 Observable(相当于再来一次),而当它报错时,这个错误将会抛给上游 Observable 并完结,是不是听的一头雾水?看看 GIF 图吧。

然后特别需要重点注意的是,这里替换掉了 from 创建符,而使用 defer 代替,为什么?

因为 from 是 hot observable,也就是无论有么有被订阅都会自顾自发出值,并且再次订阅后也不会重复发出已有值,就像直播一样。
而 defer 则像是点播,它是 cold observable,每次被订阅都会重新走一遍流程。

这点非常重要,所以在需要重复操作的地方还是需要 defer 来重复创建可利用的 Observable。

而在最后我们 catchError 里,处理掉错误信息并打 log 后,直接返回 NEVER,也就意味着这个错误将消失在漫漫长河里,不会对下游造成影响。

接受 HTTP 请求的结果

一般而言,订阅一个 Observable 只需要 subscribe 即可,但这里出了一点小小的问题,还记得上面传入 checkUrlInClipboard 的参数 windowId 吗?我们需要给不同的 window 订阅不同的 URL 检查结果。如果同时 subscribe 一个 Observable 多次会发生什么?

答案是 subscribe 几次中间的 pipe 过程会走几次,这与我们所期望的不一致啊,总不能为了每个 window 发一次 HTTP 请求?

甚至可能出现多个请求返回结果不一致的情形,那就乱套了。

事实上前面所用的 RxJS 操作符都是单播,也就是一对一,如果要一对多的话需要用到 multicast,但其实还有先后订阅的问题,这里就不展开了,可以参见这篇文章。我们直接使用 share 来共享这里的 HTTP 请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
+ import { share } from 'rxjs/operators';
const docInfo$ = filteredUrlInClipboard$.pipe(
mergeMap(url => (
defer(() => fetch(`${CGI_URL}?url=${encodeURIComponent(url)}`)).pipe(
retryThreeTimesWith500msDelay,
catchError((error) => {
console.error(`获取 url=${url} 的信息失败`, error.toString());
return NEVER;
})
)
)),
+ share(),
);

加完以后可以清楚地发现 HTTP 请求只发送了一次!至此我们已经完美地按照产品的需求完成了「多 window 并发检查剪贴板中 URL 数据」的开发,整个流程使用 RxJS 划分地整整齐齐。在 window 调用处只需要简单地调用 checkUrlInClipboard 并订阅相应的 docInfo$ 即可轻松接入剪贴板监听功能,so easy。

1
2
3
4
5
6
7
8
9
10
thisWindow.on('focus', () => {
const text = clipboard.getText();
checkUrlInClipboard(text);
});

docInfo$.subscribe((info) => {
if (info.windowId === thisWindow.id) {
openDialog(info);
}
});

拓展边界

如果这时产品经理告诉你又有新功能了,需要支持进程间共享剪贴板状态,WTF!如果平时可能就骂娘了,但 RxJS 基于流和操作符的特性拯救了所有的不开心,因为肯定有个 master 进程,只需要在 master 存一份检查过的 URL 文本就可以啦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const filteredUrlInClipboard$ = textInClipboard.pipe(
- distinctUntilKeyChanged('text'),
+ mergeMap((payload) => (
+ from(checkIfUrlChangedInMaster(payload.url)).then(isChanged => isChanged ? payload : null)
+ )),
+ filter(Boolean),
map(({ text, ...rest }) => {
const matched = text.match(URL_REG);
if (matched) {
return {
url: matched[0],
...rest,
}
}
}),
filter(Boolean(e?.url)),
);
  • checkIfUrlChangedInMaster 是与 master 通讯的异步方法,如果检查与前一次不同则返回 true,否则返回 false。

RxJS 的意义

这里给出上面各种操作的示例代码,可以实际修改并操作感受下 RxJS 的魅力。

https://stackblitz.com/edit/rxjs-check-clipboard-demo?file=index.ts

RxJS 单向数据流的设计符合函数式编程、纯函数单一输入输出无副作用的趋势,其强大的根据时间参数操作变量的能力给予了前端在处理并发事件时从容不迫的信心。

最后放张老图~

方块日历

大过年的水一篇。

这是 5 年前的一个问题:利用四个骰子组合出一年 12 个月共 365 天。虽然当时已经有解了,就是题图的这四个方块,但毕竟是想通过自己思路来解决。

一开始想的笨办法,穷举之,0101 —— 1231 这些都列出来,然后暴力组合,但进一步想就觉得不太现实。

小技巧

首先可以确定的一点是 6 和 9 是可以互换的,这样我们就只需要考虑 0 —— 8 这 9 个数的排列了。

划分子问题

然后想一想,月份一共就 12 个数,算是两个骰子排出一个月 31 天的子问题,所以先尝试一下两个骰子能排出多少天,不够的天数再用剩余俩骰子凑凑看。

用两枚骰子排出天数

十位的数字也就 0 1 2 3 这 4 种,而且 3 还不常见,所以猜测每个骰子都会有 0、1、2 这三个数,剩下来还有 3、4、56、7、8,正好可以分布在两枚骰子上!

两枚骰子排出天数

解决了

这样排出天数后,如法炮制就可得到月数,所以证明四枚骰子是完全可以排列出一年 12 个月 365 天的!
等有空时写个 web 版的方块日历~