2026-03-12 18:43:14
失业之后多次想写点东西,也确实写了,不过都是零散地写在 Obsidian 里面,时间过了,又暂时不想整理发到博客。于是今天还是直接在博客写吧,最近的非日常生活。
2026-02-17
初二被 CC 邀请去他家烧烤,一扫除夕初一的无聊。工具食材都到位了,结果生火生了好久都生饿了。

山姆的羊扒真香嘻嘻!

从天亮吃到天黑,要是我找不到工作,要不要落魄前端在线烧烤呢(

最后摸摸 CC 的猫,乖乖的好猫~
2026-02-28
迫于准备结婚,过完年 2 月底,把送礼的任务完成,又解决一件事。结婚真花钱呀。

2026-03-11
快要领证了,在领导强烈建议下去修脸,换一个更好懂的词,那就是美容。第一次修脸,感觉还不错。
这是直接在大众点评搜的一家店,在石围塘地铁站附近,就地理位置来说比较偏僻,但是因为也住得偏僻,所以一拍即合了。
店的评分很高,可能因为位置比较偏,价格跟同类相比也算实惠,一百多的套餐,服务还挺多的,躺了一个多小时顺便当休息了。按摩挺舒服的最后一步都快睡着了🥱。洁面和剃须的步骤有点小痛不过第二天没什么问题,摸着是挺滑的。
2026-03-12
之前看到工行有羊毛,换一千外汇送积分和微信立减金,于是换了些港币,今天去取。
发现了一个问题,储蓄卡过期了不能在柜台取钱。我倒是知道卡过期了,但是提示只写着过期后 ATM 不能取钱,没想到柜台也不能取。
这一刻,我终于记起来了,ATM 的全称是自动柜员机,柜员机不行,所以柜员也不行(狗头)。
那怎么办呢,就换卡呗,然后被告知工本费 20 元。绝了,宇宙行是我见过第一家办储蓄卡还收工本费的银行。贵行开成宇宙行的资金,就是从这里薅来的吧?
换卡取钱,完事之后我突然想起来,噢,我旧卡还能拿回来吗?被告知不行,已经被剪了,而且不能拿回来。我知道这个需求也是比较怪,但那好歹也是大学交学费的卡,跟了我十几年,它就这样被砍头了,尸骨都不能交由我处理它后事,有点伤感。
2026-03-10 13:42:32
最近新鲜出炉的一个 Obsidian 插件 mindelixir-mindmap:https://github.com/SSShooter/obsidian-mindmap
主要有两个功能:
mindelixir-mindmap 可以根据标题和列表的层级关系把 markdown 文件转换为思维导图。

Mind Elixir Plaintext 是一种类似 markdown 嵌套列表的格式,不过加上了连线、总结和样式的语法。

你可以通过简单的缩进、ID 引用和类似于 JSON 的尾部声明,快速在文本里构建复杂的思维导图结构。同时,这种结构 AI 生成起来也非常方便。
- 产品研发流程
- 调研阶段 [^research]
- 用户访谈 {"color": "#3298db"}
- 竞品分析 {"color": "#3298db"}
- }:2 调研总结
- 开发阶段 [^dev]
- 架构设计 {"color": "#2ecc71"}
- 前后端联调 {"color": "#f39c12"}
- } 开发总结
- > [^research] >-进入-> [^dev]

Mind Elixir Plaintext 同样也可以作为代码块嵌入到现有的文章中,顺便看看移动端的显示效果:

普通 markdown 只能通过编辑文本更新思维导图,针对 Mind Elixir Plaintext 文本,现在正在开发编辑思维导图反向更新文本的功能。
尽管已经提交了官方插件列表的 PR,但是现在 AI 时代随手出插件,前面一千个 PR 排着队……我估计维护团队都要放弃审批第三方插件了。
所以呢,下面推荐两种非官方安装方式。
BRAT 是一个已上架的 Obsidian 插件,本意是可以让你更方便地测试你的插件。但是实际上你完全可以用这个插件来安装生产级的插件。
在社区插件列表搜索 BRAT 安装:

安装后在 BRAT 配置里点击 Add beta plugin 按钮,填入 https://github.com/SSShooter/obsidian-mindmap,就能自动安装思维导图插件:

不想使用 BRAT 也可以进入插件 Release 页面下载以下 3 个文件:
main.jsstyle.cssmanifest.json然后在 Obsidian 的设置中,打开插件目录,建一个文件夹把这三个文件放进去,然后刷新一下插件列表即可。
目前 mindelixir-mindmap 仍在持续迭代优化中,如果你在使用中遇到任何问题,或是对新功能有什么好想法,非常欢迎到 GitHub 提交 Issue 和 PR 🤗
2026-02-27 16:24:32
React 引入 useSyncExternalStore 也很长一段时间了,但是存在感还不太强。简而言之,它专门用来搞定那些不受 React 内部生命周期控制的外部数据源。
过去最大的问题其实是 React 渲染时的 「撕裂」,这是 React 为了优化页面响应速度引入的并发渲染机制带来的副作用。
简单来说就是 React 为了防止在渲染时长时间无法响应用户输入,把渲染过程拆分成多个可中断的小任务,这就能小任务的间隙中插入用户响应,从而模拟出「并发」的感觉。更完整的前因后果可以参考《React 的设计哲学》。
在 React 并发渲染机制下,如果用普通的 useEffect 去同步外部数据,可能会出现渲染进行到一半时数据突然发生变化,导致同一份页面中,一半的组件拿着老数据,另一半拿着新数据的灵异现象(但是实际上出现这个问题的几率其实非常小,大家都忽略了,这就导致了 useSyncExternalStore 的存在感很低)。使用 useSyncExternalStore 后,如果在渲染过程中快照发生变化,React 会丢弃当前渲染并重新开始,从而保证同一次提交中的所有组件看到的是同一个版本的数据。
拿监听网络状态来说。不使用这个 Hook 之前,我们通常得在组件里写个包含完整挂载和清理逻辑的 useEffect 去监听 online 和 offline 事件。
function subscribe(callback) {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
function getSnapshot() {
return navigator.onLine;
}
// 组件里直接这么用
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
监听媒体查询(Media Queries)响应式布局也是同样的套路:
const query = window.matchMedia("(max-width: 600px)");
function subscribe(callback) {
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
}
const isMobile = useSyncExternalStore(subscribe, () => query.matches);
如果你接手了一个极小的项目,不想引入 Redux 或 Zustand 这样繁琐的包,但又迫切需要在几个跨层级的组件间共享某部分状态。这时候你可以直接手搓一个简易的 Store:
// 丢在 React 外面的状态中心
let internalState = { count: 0 };
const listeners = new Set();
const store = {
increment() {
internalState = { count: internalState.count + 1 };
listeners.forEach((l) => l());
},
subscribe(callback) {
listeners.add(callback);
return () => listeners.delete(callback);
},
getSnapshot() {
return internalState;
},
};
// 任何组件里都可以直接同步获取状态
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
注意:useSyncExternalStore 内部用 Object.is 比较前后快照,如果 getSnapshot 在数据未变的情况下每次都返回新对象,会导致无限循环重渲染。
只要把这段代码看懂,你就掌握了 Zustand 这种现代状态管理库的核心原理。
曾经大家都习惯在 useEffect 里监听外部变化,如果变了,再跑一下 setState 触发更新。
这就又到了日常批判 useEffect 的时候了。
useEffect 带来重复渲染和闪烁问题。如果你的外部状态和页面初始计算的状态不对齐,页面渲染就会经历「旧值 -> 闪烁 -> 新值」这三步。而 useSyncExternalStore 在渲染中途就能直接取走最新的正确值。
另外,在处理服务端渲染时,用副作用很容易抛出水合(Hydration)错误,因为服务端和客户端首次生成的 HTML 大概率因为外部数据对不上。useSyncExternalStore 为此专门开了一个叫 getServerSnapshot 的参数,让你传能兜底服务端的静态快照。
很多人滥用 Context 做全局状态,但如果是频繁变动的数据,Context 的广播机制简直是一场灾难。只要 Provider 提供的值发生了变动,它底下所有的子组件也会跟着无脑重跑 Render,除非你给每个组件层级套一层 React.memo(当然现在有 compiler,但也不是毫无代价)。
相比之下,useSyncExternalStore 实现了高精度的按需订阅——只有从 Store 取出的快照真的有了变化,关联的组件才会再次渲染。在这里还是顺便强调一下,没事别用 Context。
要判断何时使用 useSyncExternalStore 其实很简单,只要你的数据依然在 React 的生命周期里流转(例如表单实时输入、控制弹窗开闭的布尔值),那就老老实实用回你的 useState 和 useReducer。
一旦数据满足游离于 React 管理之外、会随时间变化、且你要让 UI 能自动响应这种变化这三个条件,就毫不犹豫上 useSyncExternalStore。日常写前端页面也许碰不到几次,但之后你要是去造底层 Hook 库,或者需要硬啃第三方库内部暴露出的状态时,useSyncExternalStore 绝对好使~
2026-02-24 23:08:12
useLayoutEffect 与大家熟悉的 useEffect 语法完全一致,从产生副作用的角度上看,功能上也是一样的,唯一差别就是调用时机。
useEffect 会在画面绘制后异步执行,而 useLayoutEffect 会在画面绘制前同步执行。为了讲清楚这个时机的具体区别,得先复习一下浏览器渲染页面的过程。

注意最后 js 运行的那一块,useLayoutEffect 和 useEffect 就分别位于 paint 之前和之后。
执行的顺序是:
顺便我们也能看出来,useLayoutEffect 之所以叫 useLayoutEffect 就是因为它的运行时间点沾着 layout。
知道这两个函数的区别,我们还需要知道,到底什么时候用 useLayoutEffect 呢?
答案是,如果进行了 DOM 操作,且这个 DOM 操作会引起回流(reflow)、重绘(repaint),那么就应该使用 useLayoutEffect,例如:
function Tooltip() {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
// 如果用 useEffect,这里会先渲染一次默认位置,再跳到正确位置 → 可能会造成闪烁
useLayoutEffect(() => {
const rect = ref.current!.getBoundingClientRect();
setPos({
top: rect.top + rect.height + 8,
left: rect.left + rect.width / 2,
});
}, []);
return (
<>
<div ref={ref}>hover me</div>
<div style={{ position: 'fixed', top: pos.top, left: pos.left }}>tooltip</div>
</>
);
}
因为如果你用 useEffect,在浏览器绘制之后又要重新跑一遍 reflow、repaint,用户可能会看到画面“闪烁”。
如果你有代码洁癖,想要一个最优解,那么你确实该按上面说的这么做,但是事实上在这个场景使用 useEffect 可能也不会有很明显的问题。
其实即使是官网的例子里,作为反模式使用 useEffect,用户也不会感知到明显的“闪烁”,因为两次渲染的时间其实是快到肉眼看不清的,为了确定真的存在区别你还要故意写个 while 循环卡一下主进程。
既然一般情况下无论 useEffect 和 useLayoutEffect 都不会有明显区别,那么我觉得,作为一个有专业素养的 React 开发者,应该优先使用 useEffect,只在 reflow、repaint 造成闪烁的场景下,使用 useLayoutEffect。
当然,useEffect本身也不能乱用,之前在useEffect 清除计划里已经讲述了它的必要使用场景。
useLayoutEffect 适用于“需要在浏览器绘制前同步完成的副作用”,典型场景是读取布局信息并立即修改 DOM,避免视觉闪动。
但因其会阻塞浏览器绘制,影响性能,因此不应滥用。在绝大多数副作用场景下,优先使用 useEffect,只有在感知到闪动才改为使用 useLayoutEffect。
2026-02-08 17:30:53
本期前端复习的主题是 DOM 事件机制,是前端开发非常重要的基础。除了常听到的“冒泡”,完整的事件流其实更有意思。
完整的 DOM 事件传播分为三个阶段:
window 一路向下传递到目标元素的父节点。addEventListener(type, listener, true) 第三个参数设为 true 来监听此阶段。event.target。window。addEventListener(type, listener, false) 注册的事件监听器会在这个阶段触发。
但也不是所有事件都支持冒泡,例如 focus, blur 等就不冒泡,具体各个事件是否支持冒泡可以 w3c 官方文档。
<div id="outer" class="box">
Outer
<div id="middle" class="box">
Middle
<div id="inner" class="box">Inner</div>
</div>
</div>
事件监听注册如下:
const boxes = ["outer", "middle", "inner"];
boxes.forEach((id) => {
const el = document.getElementById(id);
// 事件捕获阶段
el.addEventListener(
"click",
(event) => logEvent("捕获阶段", id, event),
true, // 捕获阶段
);
// 事件冒泡阶段
el.addEventListener(
"click",
(event) => logEvent("冒泡阶段", id, event),
false, // 冒泡阶段
);
});
可以参考这个示例:https://codesandbox.io/p/sandbox/w93cgd

调用 event.stopPropagation() 可以阻止事件传播。注意,这不仅能停掉冒泡,在捕获阶段也能把事件截下来。
举个例子:
child.addEventListener("click", (event) => {
event.stopPropagation();
console.log("child");
});
此时点击按钮,只会输出 child,不会触发 parent 或 grandparent 的监听器。
同理,如果是在捕获阶段调用 stopPropagation(),事件就无法到达目标元素和冒泡阶段:
parent.addEventListener(
"click",
(event) => {
event.stopPropagation();
console.log("parent capture");
},
true,
); // 注意第三个参数 true 开启捕获
此时点击子元素,事件在 parent 被截获,child 的点击事件将永远不会触发。
event.stopPropagation()。window 或最外层容器上监听 click 事件(设置 capture: true),并调用 event.stopPropagation(),这样内部元素都无法收到点击事件。如果同一个元素绑定了多个监听器,想把后续的监听器也一并拦住,得用 event.stopImmediatePropagation()。
浏览器会对某些事件执行默认动作。例如:
<a> 标签会跳转链接。我们可以使用 event.preventDefault() 来阻止这些默认行为。
跟不是所有事件都会冒泡一样,也并非所有事件的默认行为都能被取消。可以通过 event.cancelable 属性查看事件是否可取消。
passive 的意思是“被动”。用这个选项,等于向浏览器承诺:在这个监听器里,我绝不调用 preventDefault()。
既然你不打算阻止滚动,浏览器就不用等你的 JS 跑完再动,页面滚动自然就丝滑多了。否则,浏览器为了确认你会不会拦截滚动,每一帧都得等你,很容易卡顿。
现代浏览器为了优化体验,默认把 touchstart 和 wheel 等滚动事件设为 passive: true。也就是说,你在这个监听器里调 preventDefault() 是没用的。如果非要阻止滚动,必须在绑定时显式加上 { passive: false }。
// 默认情况下 passive 为 true,preventDefault() 无效
document.addEventListener("touchstart", function (e) {
e.preventDefault(); // 控制台会显示警告,滚动无法阻止
});
// 显式设置 passive: false,preventDefault() 生效
document.addEventListener(
"touchstart",
function (e) {
e.preventDefault(); // 阻止滚动
},
{ passive: false },
);
别搞混了:阻止传播(Stop Propagation) 和 阻止默认行为(Prevent Default) 是两码事。
stopPropagation():让事件不再通过 DOM 树传播(冒泡/捕获),但不阻止浏览器执行默认动作。preventDefault():告诉浏览器不要做默认动作,但不阻止事件在 DOM 中的传播。虽然两者独立,但要注意一个事件的默认行为可能是触发另一个事件。
例如,在输入框中按键,keydown 事件的默认行为通常包括“将字符输入到文本框”。如果你在 keydown 阶段调用了 event.preventDefault(),浏览器就会取消这个默认动作,即使你阻止的不是 input 的默认行为,字符也不会出现在输入框中。

有个常见的坑:粗暴地在全局阻止默认行为。例如,为了禁用某个快捷键,但在 document 上直接阻止了所有 keydown 的默认行为:
document.addEventListener("keydown", (e) => {
// 这会导致整个页面的输入框即使获得焦点也无法输入文字
// 因为“输入文字”也是按键的默认行为之一
e.preventDefault();
});
所以在调用 preventDefault() 前,一定要加条件判断(比如只针对特定键码 e.key === 'Enter' 阻止)。
这是冒泡最实用的功能。
有了冒泡,**事件委托(Event Delegation)**才成为了可能:
document.getElementById("parent").addEventListener("click", (e) => {
if (e.target.tagName === "BUTTON") {
console.log("Clicked button:", e.target.id);
}
});
这样可以只给 parent 绑定一次事件监听器,而不需要为每个 button 单独绑定,提高性能。
这里就得区分 target 和 currentTarget 了:
target 是事件触发的具体目标元素。currentTarget 是事件监听器绑定的当前元素。不得不吐槽,这个命名属实有点抽象,久了不用就总会把 currentTarget 记成是当前触发的元素,这就反了😂
<div id="parent">
<button id="child">Click me</button>
</div>
<script>
const parent = document.getElementById("parent");
parent.addEventListener("click", function (e) {
console.log("target:", e.target);
console.log("currentTarget:", e.currentTarget);
});
</script>
点击按钮 <button id="child"> 时:
e.target 是 <button>:你点的元素e.currentTarget 是 <div>:绑定事件的元素(parent)stopPropagation 和 preventDefault。前者阻止事件继续传播,后者阻止浏览器的默认行为(如表单提交),两者互不影响。touchstart, wheel)建议使用 passive: true,明确告知浏览器不会调用 preventDefault,从而让页面滚动更加流畅。event.target 是实际触发事件的元素,event.currentTarget 是绑定监听器的元素。在处理复杂的组件嵌套时,分清这两个属性非常重要。2026-01-30 14:06:59
其实我们不必回避看完书就忘的问题,因为大多数人看书都是会忘的。其实人类的大脑就是这么设计的,它会过滤掉大部分不重要的信息,只保留下重要的信息。如果真的想要记住一本书重要的知识,需要反复阅读,反复思考,反复练习。
在前 AI 时代,做读书笔记是一件非常耗费精力的事情,但是有大模型之后,我们可以在做笔记这件事上偷偷懒。
注意:做笔记可以偷懒,但是思考和反复回看是绝对不能偷懒的。
那么有什么好用的工具呢?朋友们,有的!欢迎使用 ebook-to-mindmap!简单来说,你可以通过 ebook-to-mindmap 把 pdf 或 epub 格式的电子书转换为分章节的思维导图或者文字总结。

点击这里即可立即体验。整个网页应用功能比较简洁,大家可以直接上手,当然,下面我也会比较详细地介绍一下这个应用的使用方法🤗
使用 ebook-to-mindmap 的第一步是配置模型。它和很多 AI 应用一样,都是选择 byok(Bring Your Own Key)的模式,你可以在这里配置你自己的大模型。
这里还是要强调一下,在 ebook-to-mindmap 填写 Key 时不必担心 Key 泄露,因为 Key 只是保存在你自己的浏览器里,请求也是直接从你的浏览器发送到大模型提供商的服务器的。你可以在浏览器的开发者工具里查看网络请求,确认这一点。同时,ebook-to-mindmap 作为一个开源项目你可以随时检视它的代码,还可以自己部署一个属于你的 ebook-to-mindmap。
说回模型的选择,可能很多人会担心使用 ebook-to-mindmap 的花费太高,其实倒也不必,毕竟现阶段还是能找到很多免费或者低价的大模型。我的首推还是 openrouter,你只需要充值 10 刀,就能获得一个较大的免费模型(其中包括一些 deepseek 变体、最近小米的新模型、之前一段时间还有 grok)使用额度,基本上一天让它处理好几本书都没问题了。其他详细推荐可以参考免费和付费 AI API 选择指南。

在获取到 Key 后如上图填写信息即可。
你还可以配置多个模型,点击左侧的星星后会成为默认模型,后续处理时默认使用星标的模型:

配置模型后,在主页选择电子书即可。之后 ebook-to-mindmap 会自动识别电子书的格式,然后开始识别章节:

[!TIP] 提示:如果 epub 无法获取到章节,可以在设置里勾选使用 Spine 获取章节
章节识别成功后,选择你需要总结的章节,或者使用分组功能(可以使用快捷键 Ctrl + G)把零碎的章节组合成分组一起发送给 AI 处理。
一切准备好后,点击开始解释按钮即可开始生成笔记。
默认情况下,ebook-to-mindmap 会生成思维导图,你也可以点击小齿轮切换到文字总结模式:

[!TIP] 虽然有整书思维导图生成功能,但是如果书的内容比较长,AI 可能吃不下这么长的上下文,所以建议还是分章节生成,最后系统会自动拼接
生成笔记如果想要中途取消,放心点取消就好,之前处于完成状态的章节会被缓存,不用担心之后需要再浪费 Token 重新生成。
举个例子吧,你在提示词列表里添加一个“小·红书风格”提示词,在生成环节选择这个提示词,就能直接生成小红书风格的笔记。

不止小红书风格,你也可以让 AI 只简单地提取该章节最重要的 5 个观点,帮助你对整本书的主要内容有一个简要的了解。
你还可以使用“反论法”提示词:
选取本章的核心论点或思想,并探索它的对立面。如果作者要为相反的观点辩护,他们需要证明什么?文本中是否有无意间支持反面观点的蛛丝马迹?
参考分享几条有意思的 NotebookLM 提示词这篇文章,里面有几个有趣的提示词,或许能让你眼前一亮。
ebook-to-mindmap 充满了下载按钮,是的,你生成的数据必须还是属于你的!你可以很轻易地把数据拿出来!
导出的文字内容可能是 markdown 文件或是思维导图 json 文件。
markdown 文件可以直接阅读,或者导入到 Obsidian、Notion 等笔记软件再细化修改。
思维导图 json 文件可以使用 mind-elixir-core 等前端库渲染,当然,如果你是技术人员,理解 json 数据的结构你也可以随意修改和渲染。
思维导图亦可导出为图片,点击思维导图页面右上角的下载按钮即可。
最后谈谈电子书格式的问题,ebook-to-mindmap 支持 pdf 和 epub 格式的电子书,但是这两种格式如何选择呢?
或许大家都会比较喜欢看 pdf,因为看起来比较工整,但是使用 ebook-to-mindmap,我还是比较推荐 epub 格式的电子书。
稍微讲一下 pdf 和 epub 的原理吧。
pdf 的特点是在任何设备上看起来都一样,这就很容易想到,其实 pdf 的排版是非常固定的,而且更重要的是,pdf 的排版是没有语义的。也就是说,人类能看到一个标题是加粗黑字,但是 pdf 本身并不知道这是一个标题,它只是知道这一块区域的文字是加粗黑字的。
更严重的问题是 pdf 如果有一些复杂的排版,例如在角落嵌入一段文字,在解释的时候就很难理解那段文字的意义。所以,大模型理解 pdf 的难度会比较大。
而 epub 格式就不一样,它更像是一张网页,有语义,有结构,有层次,就跟 HTML 差不多。但缺点就是人类看来这样的排版有点粗糙,在不同的阅读器上显示效果也不同。在某些落后的 epub 阅读器上阅读时可能会觉得排版很有年代感。但是大模型不在乎排版,有清晰的结构就能得到好的输出结果。
总的来说,ebook-to-mindmap 是一个能帮你快速复习或者把书本变薄的工具。在这个信息爆炸的时代,高效地获取和整理知识变得越来越重要。希望这个小工具能成为你阅读路上的得力助手,让你把更多的时间花在深度思考和理解上,而不是机械地摘抄。
如果你觉得这个项目对你有帮助,欢迎在 GitHub 上点个 Star ⭐️ 支持一下!如果你有任何建议或发现了 bug,也欢迎提 Issue 或者加入讨论。
Happy Reading!