使用 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 版的方块日历~

逛公园才是正经事

总览

一晃在帝都都呆了四年多了,逛过的公园也不少,今日忽有友人问起周末闲时去处,也罢,将这些年逛过的公园一一道来。那几个耳熟能详的,比如故宫、天坛、颐和园和圆明园等就不表了。

东边

朝阳公园

在北京四年,在朝阳区就有三年半,朝阳公园作为朝阳区的扛把子,自然是头把交椅。

美丽的朝阳公园坐落于北京东三环与东四环之间,是我逛过单体最大的公园之一。大约是 2017 年某一个周日,闲得实在蛋疼,就买了张票去了朝阳公园,逛了一圈,逛到脚酸。

公园是很典型的以水域为中心,按功能划分区域的结构,中心区域为综合游乐场,东南侧为运动场地,有户外拓展、足球篮球棒球各种运动场地,北侧则基本以植被覆盖。整个公园坡度较小,适合慢跑等运动,据说住在周边的很多明星会来这里晨跑。

夏季的朝阳公园有荷花莲叶,冬季有人工滑雪场,总的来说游玩价值很高。和前女友在一起的时候甚至考虑过在这里的婚礼堂办婚礼,但她没给我求婚的机会。

公园西北角是蓝色港湾,建得不错,也有蛮多吃的。但我最钟意的是外面好运街的东吴面馆,虽然这家分店的面不如南方分店的好,价格也高出很多,但毕竟是家乡的口味。有几次特意坐车过来吃,除了有关河鲜的浇头不咋滴之外,其他的面都算差强人意。

朝阳公园西边是富人区,那房子建得一看就是打几辈子工都买不起的,我骑车路过时常常想里面的人有好好欣赏这片风景的时间吗?
光晕
夏日莲花

蓝港一角
冬季滑雪场

面对如此美景,甚至还手涂了几幅渣渣写生。

丑写生

朝阳公园门票常年 5 块钱(2021 年春节之后全年免费开放了),如果月末经济拮据掏不起可以出南门左转过马路找小门去红领巾公园,那儿免费,并且麻雀虽小一应俱全,什么游乐场运动场打坐散步区域都有,靠近湖边还经常有老年乐队萨克斯手风琴演奏。

摇一摇,摇到外婆桥
丑写生 2

奥林匹克森林公园

奥森我去的次数不算多,但公园的体量摆在那,去个一次抵别的公园两三次……而且风景确实很好,有个山包,坐落在帝都中轴线上,可以从北向南俯瞰笼罩在雾霾中的北京城。公园主线就是绕园一周的环园路,所以随大流总是能看到无穷无尽的人,那些运动 or 减肥协会也挺喜欢在这办活动,经常能看到穿统一服装的,高矮胖瘦都有,在健步如飞或踌躇前行。

推荐在秋天或者春天去逛奥森,秋天可以看满山枫叶,足够黄。春天可以到北园看三生三世十里桃花,足够骚。

春江水暖鸭先知昨日黄花深秋美景黄叶桃花n 多桃花俯瞰雾霾

将府公园

这个公园没啥名气,也没啥特色,但却是离我刚来北京时住的地方最近的一个,公园里有条废弃的铁路,然后就是各种横平竖直规划好的树林以及屈指可数的游乐运动场,还记得是有个啥点将台的仿古建筑来着,所以这地儿叫”将台”。

记得从将台往这公园走是要过个铁路的,对于从小在铁路旁长大,但与铁路渐行渐远的我来说还挺怀念。顺便说一句,五道口现在也不是道口了,旁边铁路也没了。真是沧海桑田。

冰车

时刻保持社交距离的鸭鸭

四得公园

又一个半点名气的没有的公园,哪”四得”呢?据百度百科,办园宗旨是”使游人能够得到自然清新的乐趣,得到身心的锻炼,得到一定的植物知识,得到健康长寿”,答案竟然如此浅显,真是令人吃惊。但这个公园见证了我足足减掉了 15 斤肥肉的艰苦历程 …… 绕园一周 900 米,我那时经常早上跑 4 圈走 1 圈,晚上跑 3 圈走 2 圈,爆燃脂。然后挺着湿漉漉的大肚子,走过将台西路那一大片露天烧烤店,在满大街吃客面前耀武扬威得像个犯傻的二愣子,回到我那乌漆嘛黑的家。

早上跑步时能偶尔能看到小猫咪,一黄一灰,后来就不见了,估计是被人打死了。夏天晚上跑步能闻到夜来香还不知是玉兰香,野花挺多。我每次跑到撑不下去时就瞅瞅有没有好身材的妹子,想像追到她就能跟她嘿嘿哈哈,那就有动力了。

前进的动力跑累了,拍个照香气扑鼻

望和公园

我搬到望京之后,又想跑两步了,就找了个附近的公园,但这里其实大大小小足有三四个公园,每个都差不多,刚来的人都可能分不清。但我很快就放弃了在这边跑步的想法,因为这里的跑道不仅抓地性有问题,下过雨之后竟然是软的,还有一个极陡的上下坡,加上住 21 层等电梯也不容易,这些都成了跑步的阻碍。后来我买了个瑜伽球 + 垫,照着油管上一个 UP 主分享的动作(大约是平板支撑 + 手脚传球 + 卷腹等)练腰腹力量,辅以一两周一次的 Keepland H.I.T.T. 减脂效果还可以。

这公园的湖边景致还有点意思,像是精心打理过的,如下图的云雾缭绕效果,宛如仙境一般。

云雾缭绕花团锦簇

望京公园

虽然名字挺响亮,但实际挺小挺荒凉,从酒仙桥路向北到快要转弯到望京去的地方往东北侧,大约 500 米就是。这个公园连湖都没有,全是树,树都很高,有种无边落木萧萧下的感觉。去过一次,总觉得像是拍那种带凶杀案的文艺片取景地。无边落木萧萧下

太阳宫公园

有同学住在附近,来逛过两三次,没啥特色,门口有个大广场,里面各种树木一看就是人工产物。在外面四环路看过去倒是能看到一些大型盆栽啥的,但似乎不是一家,在公园内就看不到了。

生机勃勃这是啥?

地坛公园

99.9 % 的人看到地坛这两个字都会联想到史铁生,2017 年春节前我在附近逗留时去逛了一圈,发现地坛公园确实挺好的 …… 因为是皇家园林,布局工工整整,树木修剪得也不错,西北侧有个寺庙,东北侧一些供人憩息的院落,中心位置路挺宽,适合办庙会。有两次花了额外的钱上到了坛上,和天坛一比就显得接地气了很多。

庄严肃穆威武~

中轴

景山公园

讲完了东边的公园,来讲讲皇城根下的景山公园。出了故宫神武门正对着的就是,门票应该是 3 元,景山最著名的当属大明朝崇祯爷自缢那棵歪脖子树了,可惨了,说殓尸时思宗皇帝光着左脚,右脚穿着一只红鞋。但人已逝,树非树,都成了坊间趣谈。

明思宗殉国处

从景山顶上可以望见故宫中轴线,也就是北京城的中轴线。俯瞰故宫

那次去景山还看到了一个老者吹萨克斯,演奏的曲目是《追梦人》。吹萨克斯的老者

北海公园

北海公园在景山旁边,基本也就是个湖,湖中间有个岛。有趣的一点是,你从北门进去就有两条路,一条往西,一条往东,你选了一条走到头之后发现到不了另一条路上,这时咋办呢,只能掏腰包买船票坐船到湖中心再往另一头去,不得不说还是挺精明的。

和同学逛北海公园时,同学说他和他老婆第一次约会就在北海公园,他老婆看到如此良辰美景就开始吟诗作赋了,而他应不上来,只得干笑,想到这场面就忍俊不禁。

北京双飞 7 日游 1080 元 / 人落霞与孤鹜齐飞北海公园边上有个北海幼儿园,我第一次看到就惊呆了,有副对联讲得好,”拳打南山敬老院,脚踢北海幼儿园”,网上搜了搜,南山敬老院在昌平,有点远,所以给这对联配上俩图的愿望一直搁浅着。

北海幼儿园

陶然亭公园

这大概是我最早逛的公园之一了 ,那是一个雾霾天,前一周连写了三个线上 bug 的我决定去庙里烧柱香祈求佛祖保佑永无 bug。先是到了法源寺,看到了一院子的猫,还有几位老和尚在和女施主手把手促膝长谈。然后出门走到牛街买了仨牛肉包子啃了,接着向南就走到了陶然亭。估计是因为地处南城,都是各种老头老太在锻炼身体,过去太久也找不到照片,依稀记得是有个挺大的亭子,门口有些假山假石,园里好像还有个滑滑梯啥的。正值冬季,似乎还有冰雪嘉年华。随便放个网上的照片吧。

网图,侵删

西边

玉渊潭公园

有一年春天去的,人超级多,绕着湖走了一大圈,湖中间有个堤可以通行,北侧有游廊。记得是为了看樱花,但樱花质量也就寥寥,不甘落后的商家还出了樱花特款棒冰。地理位置还是很好的,旁边就是中华世纪坛和各种国家单位,真的是人来人往。抛开人多这一项之外,风景还算不错。

Sakura<{=....(嘎~嘎~嘎~)中央电视塔隔岸赏花

紫竹院公园

紫竹院公园过去曾经是北京市知名的女同性恋者聚集场所,后来女同性恋者则主要在专门的餐馆、酒吧聚集。男同性恋者则选择东单公园、元大都城垣遗址公园等处聚集。

北京有俗话称”要想成陶然亭,要想散紫竹院。”指男女朋友若同去陶然亭公园则会谈成,若同去紫竹院公园则会分手。也有说法是”成不成,陶然亭;散不散,紫竹院。

紫竹院公园在国家图书馆南边,旁边都是舞蹈学院艺术馆啥的,人文气息浓郁。既然叫紫竹院,园内自然以竹子为主,错落以假山假水啥的。也像模像样修了各种亭台楼阁。与陶然亭一样也有冰雪嘉年华啥的,毕竟北方人冬天不管下不下雪都得有个玩雪的地方。

园中循湖面往西南望去,可以看到玉渊潭边上的中央电视塔。

无题

百望山森林公园

这是目前离我最近的公园了,我们领导几乎每周都会去这儿爬个山。听名字就知道这是个山,坐落在京城西北角,相传北宋杨六郎与辽兵在此山下鏖战,佘太君登山观阵助威,此山因而得名。山顶有佘太君雕像,迎风伫立,俯瞰众生。[百望]{.ul}山曾是平西抗日游击队诞生地,现有黑山扈抗日战斗纪念碑等。还有个圣母院和一个传教士 PIERRE WILLIEMS 之墓,蛮好奇在他的祖国荷兰,他会被认为是和东渡日本的鉴真和尚一样的宗教人物吗?

天气好的时候可以一览北京的天际线,望京那一排高楼、奥林匹克七星塔、盘古酒店、擎天柱一样的中国樽和中央电视塔,近处的就是颐和园和圆明园了。

西北望长安可怜无数山

北京植物园

植物园是我去过最偏僻的公园之一了,坐落在香山脚底下。大冬天去的,没啥植物,里面有俩景点有点意思,一个是梁启超墓,由其子 —— 著名建筑学家梁思成亲自操刀,整个墓园安静祥和,西侧有个亭子,说是打算立个铜像,因财力不足而作罢。

另一个就有意思了,说是一退休老教师返回祖宅生活,搬动床柜时擦破了墙皮,露出来其中的诗文,考证发现竟是曹雪芹故居。于是政府就依样画葫芦给复原了一个故居出来。故居里详细介绍了曹雪芹家因被贬而辗转北京各处居住的故事,结合北京当时燕京八景等景致。猜测先生当时是如何在北国寒风中忆甜思苦,怀想金陵时极致奢靡的上流生活,写出那部不朽的传世之作《红楼梦》。

从故居出来往山上走,一路溪流蜿蜒,实际上是都结成冰了,有穿古装的小主在冰上拍照,颇有几分像贾府众女眷踏雪寻梅的景致。溯源至尽头,有一巨石,呈元宝状,相传这就是女娲娘娘补天剩下的那块奇石——红楼故事的一切就从这里开始。

西山情缘,红楼之梦惊!老教师在自己祖宅发现曹雪芹故居诗文冰面漫步天降奇石

北京园博园

讲到这里也就差不多了,第一次去园博园还是 2013 年来北京领个奖时和老师一起去的,那时的园博园刚修好,地铁也刚通到那,里面的园子开得还是挺齐的,中间的锦绣谷虽说不上花团锦簇,但还是有几分姿色。一晃过了 7 年了,2020 年和我妈又去了一趟,真是相当惨淡了。几个特色展馆全部歇业,什么东南亚、非洲、台湾馆全部都关门了,连吃饭地方都只剩吉祥馄饨一家黑店,要不然就只能吃方便面自热米饭啥的。整个园博园到处一副颓败气息,唯一还能参观打量的三个园子分别是北京园、江苏园和广东园,大致与 GDP 财力相符。

西北处有个永定塔,气势恢宏,我妈说拍塔不要站在塔脚底下仰拍,这样就会被塔镇住不得翻身。

宝塔镇蛇妖江苏园锦绣谷广东园

后记

这里只列出中心城区方便寻找到的公园,像还有些什么雁栖湖公园之类的因为太远没有列举必要,不过雁栖湖作为会议度假疗养胜地,风景倒还可以,跟十三陵水库有的一拼。下一为雁栖湖,下二三为十三陵水库。

雁栖湖蟒山望十三陵水库十三陵水库

像故宫、颐和园和圆明园啥的,偶尔也会去去,就觉得这几个地儿吧,随着游客纷至沓来,越来越像个现代景点,越来越不是皇家园林那味儿了。