2026-04-10 16:19:37
昨天终于心一横开了苹果开发者,一大早开了,想着我要一天搞定上架提交!然而,钱是付了,等到晚上九点多,才成功开通。好嘛,那第二天再努力吧,带着兴奋入睡,第二天一早起来开干。
事实是我把这想得太简单,出了舒适区,就真的想踏入泥沼一样寸步难行。搞了大半天才终于把不用上架的版本签好,晚上之前也把 pkg 打出来了。不容易啊!
下面讲讲一些值得注意的点吧。如果你也打算入坑 Tauri 开发,而且打算构建 macOS 应用或者 iOS 应用,记得收藏,以后会用到的。
你先得搞清楚,CSR、CER、P12 这些概念分别是啥,不然肯定被 N 个证书搞得晕头转向。
在数字证书和公钥基础设施(PKI)领域,这三个缩写分别代表了证书申请、证书本身以及证书存储的不同阶段和格式。 简单来说,它们的关系可以看作是一个从申请到签发再到打包的过程。
本质:申请表
当你需要一个正式的 SSL/TLS 证书时,你首先要在服务器上生成一对密钥(私钥和公钥)。CSR 就是由你的公钥和一些身份信息(如域名、公司名称、国家等)组成的请求文件。
本质:正式证件
CA 收到你的 CSR 并核实身份后,会用他们的私钥对你的信息进行签名,生成一个证书文件,通常后缀是 .cer 或 .crt。
本质:全家桶安装包
.p12 是一种二进制格式的容器,它可以把私钥、公钥(CER)以及中间证书链全部打包在一个文件里,并且通常由密码保护。
在解决了证书本质上区别之后,你还要搞清楚苹果自己的 N 种证书。Developer ID Application 用于不上架的分发,上架还要用到 Distribution 和 Mac Installer Distribution 两个证书。
机子里证书有两个,一个 Apple Development,一个 Developer ID Application,不小心把证书导错了一次,排查又卡住。
其次 Tauri 一定程度上有点黑盒,加上对苹果应用开发不熟悉,从 Tauri 那不算太完整的文档里逐步实现签名。而且关键是这些信息还散落在 macOS Application Bundle、macOS Code Signing、App Store 三个页面。
为了理清这三个页面的内容,又得把一堆苹果开发流程中的重要概念搞清楚。
它是一组 key-value 对(权利字典),告诉操作系统“这个 App 允许使用哪些特殊能力”。例如:访问 iCloud、HomeKit、推送通知、相机、App Sandbox 等。这些权利会嵌入到 App 的二进制代码签名里。
iOS / macOS 上架时必须正确声明;Xcode 会自动生成 .entitlements 文件,签名时合并进去。
Notarization 翻译过来就是做公证。你把已签名的 macOS App 上传给苹果,它会扫描恶意代码、检查签名问题。
扫描通过后,苹果给你的 App 发一个“公证票据”(ticket),你可以把它“钉”(staple)到 App 上。macOS Gatekeeper(门卫)看到有公证票据,就会放心让用户运行,而不会弹出应用损坏的错误。
使用 Developer ID 证书在 App Store 外分发的 macOS App 必须公证。
一个由苹果服务器签名的 .mobileprovision / .provisionprofile 文件,里面包含:
上架必须,非上架不需要。
在搞清楚上面的概念之后就大概能明白了,Tauri 的构建配置必须分两种。
之前的一个卡点是,签名成功了也公证了,结果反而打不开,签名之前还能用 xattr -cr,现在用了都不行。
{
// ...
"macOS": {
"entitlements": "./entitlements.plist",
"signingIdentity": "Developer ID Application: XXX"
}
// ...
}
问了一轮 AI 以为是不知道什么原因导致的 entitlements 没写进去。但是后来又发现即使通过 codesign --force --deep --options runtime 手动把 entitlements 写进去了,依然打不开。
最后才恍然大悟,Tauri 文档上写的分开 tauri.appstore.conf 文件的必要性……
实际上打包非上架包的时候应该把 entitlements 删掉,这样反倒是打出来的包可以正常运行。于是!Mind Elixir v1.7.0 终于不用绕过安全策略,支持直接运行啦!
然后发布 App Store 的版本我们外加一个配置文件 tauri.appstore.conf.json:
{
"bundle": {
"macOS": {
"entitlements": "./entitlements.plist",
"signingIdentity": "Apple Distribution: Dexter Chow (9J69XMW5FC)",
"files": {
"embedded.provisionprofile": "./provisioning/MindElixirMac.provisionprofile"
}
}
}
}
构建时运行:
pnpm tauri build --config src-tauri/tauri.appstore.conf.json --target universal-apple-darwin
App Store 版本不需要公正,跑公正只会提示你需要用 Developer ID Application 证书。因此我们需要把环境变量里公正用到的值清空,这样 Tauri 就不会自动公正了。
pnpm tauri build 之后拿到了 .app,接着还要用 pkgbuild 打包成 pkg。
这两步就用到了上面提到的两个证书:
最后使用 Transporter 上传 pkg 包(开了虚拟网卡 Transporter 传不了),注意打包兼容 Intel 芯片的 universal 包,如果不想兼容 Intel 芯片,系统要限定在 12.0 以上。
我真的不敢想象没 AI 我看这些文档要看到何年何月。但是做好了,又觉得其实没那么难。所以确实,一件事做到过和没做到过就是完全不一样。没做到过你会怀疑每一个细节有问题,脑子炸炸的,做到了你就知道大致什么是没问题的。后续再处理问题就简单多了。
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 是绑定监听器的元素。在处理复杂的组件嵌套时,分清这两个属性非常重要。