MoreRSS

site iconUsubeni Fantasy修改

常用SSShooter zhoudejie等ID,本名周德杰 ,Web前端开发,坐标广州。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Usubeni Fantasy的 RSS 预览

Tauri 应用苹果签名踩坑实录

2026-04-10 16:19:37

昨天终于心一横开了苹果开发者,一大早开了,想着我要一天搞定上架提交!然而,钱是付了,等到晚上九点多,才成功开通。好嘛,那第二天再努力吧,带着兴奋入睡,第二天一早起来开干。

事实是我把这想得太简单,出了舒适区,就真的想踏入泥沼一样寸步难行。搞了大半天才终于把不用上架的版本签好,晚上之前也把 pkg 打出来了。不容易啊!

下面讲讲一些值得注意的点吧。如果你也打算入坑 Tauri 开发,而且打算构建 macOS 应用或者 iOS 应用,记得收藏,以后会用到的。

证书

你先得搞清楚,CSR、CER、P12 这些概念分别是啥,不然肯定被 N 个证书搞得晕头转向。

在数字证书和公钥基础设施(PKI)领域,这三个缩写分别代表了证书申请、证书本身以及证书存储的不同阶段和格式。 简单来说,它们的关系可以看作是一个从申请到签发再到打包的过程。

CSR

本质:申请表

当你需要一个正式的 SSL/TLS 证书时,你首先要在服务器上生成一对密钥(私钥和公钥)。CSR 就是由你的公钥和一些身份信息(如域名、公司名称、国家等)组成的请求文件。

  • 作用: 你把这个文件发给证书颁发机构(CA,如 Digicert, Let's Encrypt)。
  • 隐喻: 办护照时填写的“申请表”。表上有你的照片(公钥)和个人资料,但它还不是护照。
  • 包含: 公钥 + 身份信息 + 数字签名。

CER

本质:正式证件

CA 收到你的 CSR 并核实身份后,会用他们的私钥对你的信息进行签名,生成一个证书文件,通常后缀是 .cer.crt

  • 作用: 安装在服务器上,向客户端证明你的身份并提供公钥。
  • 隐喻: 已经盖了章的正式“护照”。
  • 包含: 你的公钥、CA 的签名、有效期、颁发者信息。它不包含私钥。

P12

本质:全家桶安装包

.p12 是一种二进制格式的容器,它可以把私钥公钥(CER)以及中间证书链全部打包在一个文件里,并且通常由密码保护。

  • 作用: 方便迁移。比如你想把证书从一台服务器搬到另一台服务器,直接导出一个 P12 文件即可。在 iOS 开发或 Java 服务中非常常见。
  • 隐喻: 你的“保险箱”,里面装着护照(证书)、开启护照配套的钥匙(私钥)以及其他证明文件。
  • 包含: 证书 + 私钥 + (可选) 证书链。

在解决了证书本质上区别之后,你还要搞清楚苹果自己的 N 种证书。Developer ID Application 用于不上架的分发,上架还要用到 Distribution 和 Mac Installer Distribution 两个证书。

机子里证书有两个,一个 Apple Development,一个 Developer ID Application,不小心把证书导错了一次,排查又卡住。

其次 Tauri 一定程度上有点黑盒,加上对苹果应用开发不熟悉,从 Tauri 那不算太完整的文档里逐步实现签名。而且关键是这些信息还散落在 macOS Application BundlemacOS Code SigningApp Store 三个页面。

为了理清这三个页面的内容,又得把一堆苹果开发流程中的重要概念搞清楚。

概念

Entitlements

它是一组 key-value 对(权利字典),告诉操作系统“这个 App 允许使用哪些特殊能力”。例如:访问 iCloud、HomeKit、推送通知、相机、App Sandbox 等。这些权利会嵌入到 App 的二进制代码签名里。

iOS / macOS 上架时必须正确声明;Xcode 会自动生成 .entitlements 文件,签名时合并进去。

Notarization

Notarization 翻译过来就是做公证。你把已签名的 macOS App 上传给苹果,它会扫描恶意代码、检查签名问题。

扫描通过后,苹果给你的 App 发一个“公证票据”(ticket),你可以把它“钉”(staple)到 App 上。macOS Gatekeeper(门卫)看到有公证票据,就会放心让用户运行,而不会弹出应用损坏的错误

使用 Developer ID 证书在 App Store 外分发的 macOS App 必须公证。

Provisioning Profile

一个由苹果服务器签名的 .mobileprovision / .provisionprofile 文件,里面包含:

  • App ID(Bundle ID)
  • 开发者证书
  • 授权的设备列表(开发阶段)
  • 允许使用的 Entitlements 和服务

上架必须,非上架不需要。

双生配置

在搞清楚上面的概念之后就大概能明白了,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 版本

然后发布 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。

这两步就用到了上面提到的两个证书:

  • Apple Distribution → 签 App 本身(.app)
  • Mac Installer Distribution → 签 安装包(.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 元。绝了,宇宙行是我见过第一家办储蓄卡还收工本费的银行。贵行开成宇宙行的资金,就是从这里薅来的吧?

换卡取钱,完事之后我突然想起来,噢,我旧卡还能拿回来吗?被告知不行,已经被剪了,而且不能拿回来。我知道这个需求也是比较怪,但那好歹也是大学交学费的卡,跟了我十几年,它就这样被砍头了,尸骨都不能交由我处理它后事,有点伤感。

一个插件让你在 Obsidian 画思维导图

2026-03-10 13:42:32

最近新鲜出炉的一个 Obsidian 插件 mindelixir-mindmap:https://github.com/SSShooter/obsidian-mindmap

主要有两个功能:

  1. 让你可以以思维导图的形式阅读 markdown 文件
  2. 让你可以在 markdown 文件中插入思维导图

markdown 转思维导图

mindelixir-mindmap 可以根据标题和列表的层级关系把 markdown 文件转换为思维导图。

Mind Elixir Plaintext

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

BRAT 是一个已上架的 Obsidian 插件,本意是可以让你更方便地测试你的插件。但是实际上你完全可以用这个插件来安装生产级的插件。

在社区插件列表搜索 BRAT 安装:

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

手动安装

不想使用 BRAT 也可以进入插件 Release 页面下载以下 3 个文件:

  • main.js
  • style.css
  • manifest.json

然后在 Obsidian 的设置中,打开插件目录,建一个文件夹把这三个文件放进去,然后刷新一下插件列表即可。

目前 mindelixir-mindmap 仍在持续迭代优化中,如果你在使用中遇到任何问题,或是对新功能有什么好想法,非常欢迎到 GitHub 提交 Issue 和 PR 🤗

看完就懂 useSyncExternalStore

2026-02-27 16:24:32

功能

React 引入 useSyncExternalStore 也很长一段时间了,但是存在感还不太强。简而言之,它专门用来搞定那些不受 React 内部生命周期控制的外部数据源

过去最大的问题其实是 React 渲染时的 「撕裂」,这是 React 为了优化页面响应速度引入的并发渲染机制带来的副作用。

简单来说就是 React 为了防止在渲染时长时间无法响应用户输入,把渲染过程拆分成多个可中断的小任务,这就能小任务的间隙中插入用户响应,从而模拟出「并发」的感觉。更完整的前因后果可以参考《React 的设计哲学》

在 React 并发渲染机制下,如果用普通的 useEffect 去同步外部数据,可能会出现渲染进行到一半时数据突然发生变化,导致同一份页面中,一半的组件拿着老数据,另一半拿着新数据的灵异现象(但是实际上出现这个问题的几率其实非常小,大家都忽略了,这就导致了 useSyncExternalStore 的存在感很低)。使用 useSyncExternalStore 后,如果在渲染过程中快照发生变化,React 会丢弃当前渲染并重新开始,从而保证同一次提交中的所有组件看到的是同一个版本的数据。

使用场景

订阅浏览器 API

拿监听网络状态来说。不使用这个 Hook 之前,我们通常得在组件里写个包含完整挂载和清理逻辑的 useEffect 去监听 onlineoffline 事件。

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 这种现代状态管理库的核心原理

竞品 API

useEffect + setState

曾经大家都习惯在 useEffect 里监听外部变化,如果变了,再跑一下 setState 触发更新。

这就又到了日常批判 useEffect 的时候了。

useEffect 带来重复渲染和闪烁问题。如果你的外部状态和页面初始计算的状态不对齐,页面渲染就会经历「旧值 -> 闪烁 -> 新值」这三步。而 useSyncExternalStore 在渲染中途就能直接取走最新的正确值。

另外,在处理服务端渲染时,用副作用很容易抛出水合(Hydration)错误,因为服务端和客户端首次生成的 HTML 大概率因为外部数据对不上。useSyncExternalStore 为此专门开了一个叫 getServerSnapshot 的参数,让你传能兜底服务端的静态快照。

Context

很多人滥用 Context 做全局状态,但如果是频繁变动的数据,Context 的广播机制简直是一场灾难。只要 Provider 提供的值发生了变动,它底下所有的子组件也会跟着无脑重跑 Render,除非你给每个组件层级套一层 React.memo(当然现在有 compiler,但也不是毫无代价)。

相比之下,useSyncExternalStore 实现了高精度的按需订阅——只有从 Store 取出的快照真的有了变化,关联的组件才会再次渲染。在这里还是顺便强调一下,没事别用 Context。

总结

要判断何时使用 useSyncExternalStore 其实很简单,只要你的数据依然在 React 的生命周期里流转(例如表单实时输入、控制弹窗开闭的布尔值),那就老老实实用回你的 useStateuseReducer

一旦数据满足游离于 React 管理之外、会随时间变化、且你要让 UI 能自动响应这种变化这三个条件,就毫不犹豫上 useSyncExternalStore。日常写前端页面也许碰不到几次,但之后你要是去造底层 Hook 库,或者需要硬啃第三方库内部暴露出的状态时,useSyncExternalStore 绝对好使~

相关链接

看完就懂 useLayoutEffect

2026-02-24 23:08:12

差异

useLayoutEffect 与大家熟悉的 useEffect 语法完全一致,从产生副作用的角度上看,功能上也是一样的,唯一差别就是调用时机。

useEffect 会在画面绘制后异步执行,而 useLayoutEffect 会在画面绘制前同步执行。为了讲清楚这个时机的具体区别,得先复习一下浏览器渲染页面的过程。

浏览器渲染流程

注意最后 js 运行的那一块,useLayoutEffect 和 useEffect 就分别位于 paint 之前和之后。

执行的顺序是:

  • useLayoutEffect
  • 画面绘制
  • 下一轮 js 运行 useEffect

顺便我们也能看出来,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。

相关链接

复习 DOM 事件机制

2026-02-08 17:30:53

本期前端复习的主题是 DOM 事件机制,是前端开发非常重要的基础。除了常听到的“冒泡”,完整的事件流其实更有意思。

事件传播

完整的 DOM 事件传播分为三个阶段:

  1. 捕获阶段(Capturing Phase)
    • 事件从 window 一路向下传递到目标元素的父节点。
    • 期间可以通过 addEventListener(type, listener, true) 第三个参数设为 true 来监听此阶段。
  2. 目标阶段(Target Phase)
    • 事件到达目标元素本身,即 event.target
    • 此阶段监听函数会被触发。
  3. 冒泡阶段(Bubbling Phase)
    • 事件从目标元素向上传播至 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,不会触发 parentgrandparent 的监听器。

同理,如果是在捕获阶段调用 stopPropagation(),事件就无法到达目标元素和冒泡阶段:

parent.addEventListener(
  "click",
  (event) => {
    event.stopPropagation();
    console.log("parent capture");
  },
  true,
); // 注意第三个参数 true 开启捕获

此时点击子元素,事件在 parent 被截获,child 的点击事件将永远不会触发

常见场景

  1. 防止重复触发(阻止冒泡)
    • 场景:点击卡片中的按钮(如“删除”),但不希望触发卡片本身的点击事件(如“跳转详情”)。
    • 做法:在按钮的点击事件中调用 event.stopPropagation()
  2. 全局拦截(阻止捕获)
    • 场景:页面进入“编辑模式”或“引导模式”,需要禁用页面上所有元素的点击交互,只允许特定区域或完全接管交互。
    • 做法:在 window 或最外层容器上监听 click 事件(设置 capture: true),并调用 event.stopPropagation(),这样内部元素都无法收到点击事件。

如果同一个元素绑定了多个监听器,想把后续的监听器也一并拦住,得用 event.stopImmediatePropagation()

默认行为

浏览器会对某些事件执行默认动作。例如:

  • 点击 <a> 标签会跳转链接。
  • 点击表单的提交按钮会提交表单。
  • 在输入框按键会输入字符。
  • 选中文本后右键会弹出上下文菜单。

我们可以使用 event.preventDefault() 来阻止这些默认行为。

跟不是所有事件都会冒泡一样,也并非所有事件的默认行为都能被取消。可以通过 event.cancelable 属性查看事件是否可取消。

passive

passive 的意思是“被动”。用这个选项,等于向浏览器承诺:在这个监听器里,我绝不调用 preventDefault()

既然你不打算阻止滚动,浏览器就不用等你的 JS 跑完再动,页面滚动自然就丝滑多了。否则,浏览器为了确认你会不会拦截滚动,每一帧都得等你,很容易卡顿。

现代浏览器为了优化体验,默认把 touchstartwheel 等滚动事件设为 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 单独绑定,提高性能。

这里就得区分 targetcurrentTarget 了:

  • 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)

总结

  • 传播机制:事件流分为捕获、目标、冒泡三个阶段。日常开发主要利用冒泡进行事件委托,但在特定场景下捕获阶段也可以用于拦截事件。
  • 行为控制:区分 stopPropagationpreventDefault。前者阻止事件继续传播,后者阻止浏览器的默认行为(如表单提交),两者互不影响。
  • 性能优化:滚动类事件(如 touchstart, wheel)建议使用 passive: true,明确告知浏览器不会调用 preventDefault,从而让页面滚动更加流畅。
  • 对象区分event.target 是实际触发事件的元素,event.currentTarget 是绑定监听器的元素。在处理复杂的组件嵌套时,分清这两个属性非常重要。

参考文献

  • https://w3c.github.io/uievents/#event-type-keydown
  • https://developer.mozilla.org/en-US/docs/Web/Events