Usubeni Fantasy往后仍编织千年之梦https://ssshooter.com/zh-CN久违的生活碎片记录https://ssshooter.com/2026-03-12-diary/https://ssshooter.com/2026-03-12-diary/失业后的非日常生活记录:朋友家烧烤、备婚过大礼、初次体验修脸,以及工行换卡吐槽小插曲。Thu, 12 Mar 2026 10:43:14 GMT<p>失业之后多次想写点东西,也确实写了,不过都是零散地写在 Obsidian 里面,时间过了,又暂时不想整理发到博客。于是今天还是直接在博客写吧,最近的非日常生活。</p> <p><strong>2026-02-17</strong></p> <p>初二被 CC 邀请去他家烧烤,一扫除夕初一的无聊。工具食材都到位了,结果生火生了好久都生饿了。</p> <p><img src="https://img.ssshooter.com/img/260217-%E7%83%A7%E7%83%A41.jpg" alt="" /></p> <p>山姆的羊扒真香嘻嘻!</p> <p><img src="https://img.ssshooter.com/img/260217-%E7%83%A7%E7%83%A42.jpg" alt="" /></p> <p>从天亮吃到天黑,要是我找不到工作,要不要落魄前端在线烧烤呢(</p> <p><img src="https://img.ssshooter.com/img/260217-%E5%A5%BD%E7%8C%AB.jpg" alt="" /></p> <p>最后摸摸 CC 的猫,乖乖的好猫~</p> <p><strong>2026-02-28</strong></p> <p>迫于准备结婚,过完年 2 月底,把送礼的任务完成,又解决一件事。结婚真花钱呀。</p> <p><img src="https://img.ssshooter.com/img/260228-%E8%BF%87%E5%A4%A7%E7%A4%BC.jpg" alt="" /></p> <p><strong>2026-03-11</strong></p> <p>快要领证了,在领导强烈建议下去修脸,换一个更好懂的词,那就是美容。第一次修脸,感觉还不错。</p> <p>这是直接在大众点评搜的一家店,在石围塘地铁站附近,就地理位置来说比较偏僻,但是因为也住得偏僻,所以一拍即合了。</p> <p>店的评分很高,可能因为位置比较偏,价格跟同类相比也算实惠,一百多的套餐,服务还挺多的,躺了一个多小时顺便当休息了。按摩挺舒服的最后一步都快睡着了🥱。洁面和剃须的步骤有点小痛不过第二天没什么问题,摸着是挺滑的。</p> <p><strong>2026-03-12</strong></p> <p>之前看到工行有羊毛,换一千外汇送积分和微信立减金,于是换了些港币,今天去取。</p> <p>发现了一个问题,储蓄卡过期了不能在柜台取钱。我倒是知道卡过期了,但是提示只写着过期后 ATM 不能取钱,没想到柜台也不能取。</p> <p>这一刻,我终于记起来了,ATM 的全称是自动柜员机,柜员机不行,所以柜员也不行(狗头)。</p> <p>那怎么办呢,就换卡呗,然后被告知工本费 20 元。绝了,宇宙行是我见过第一家办储蓄卡还收工本费的银行。贵行开成宇宙行的资金,就是从这里薅来的吧?</p> <p>换卡取钱,完事之后我突然想起来,噢,我旧卡还能拿回来吗?被告知不行,已经被剪了,而且不能拿回来。我知道这个需求也是比较怪,但那好歹也是大学交学费的卡,跟了我十几年,它就这样被砍头了,尸骨都不能交由我处理它后事,有点伤感。</p> 一个插件让你在 Obsidian 画思维导图https://ssshooter.com/obsidian-mindmap/https://ssshooter.com/obsidian-mindmap/本文介绍了一款全新的 Obsidian 插件 mindelixir-mindmap,它支持将 Markdown 文本转换并渲染为思维导图,支持使用 Mind Elixir Plaintext 格式方便地构建复杂导图结构,并详细说明了插件的功能与安装方式。Tue, 10 Mar 2026 05:42:32 GMT<p>最近新鲜出炉的一个 Obsidian 插件 <strong>mindelixir-mindmap</strong>:https://github.com/SSShooter/obsidian-mindmap</p> <p>主要有两个功能:</p> <ol> <li>让你可以以思维导图的形式阅读 markdown 文件</li> <li>让你可以在 markdown 文件中插入思维导图</li> </ol> <h2>markdown 转思维导图</h2> <p><a href="https://github.com/SSShooter/obsidian-mindmap">mindelixir-mindmap</a> 可以根据标题和列表的层级关系把 markdown 文件转换为思维导图。</p> <p><img src="https://img.ssshooter.com/img/obsidian-mindmap/markdown-to-mindmap.gif" alt="" /></p> <h2>Mind Elixir Plaintext</h2> <p>Mind Elixir Plaintext 是一种类似 markdown 嵌套列表的格式,不过加上了连线、总结和样式的语法。</p> <p><img src="https://img.ssshooter.com/img/obsidian-mindmap/obsidian-mind-elixir-plaintext.gif" alt="" /></p> <p>你可以通过简单的缩进、ID 引用和类似于 JSON 的尾部声明,快速在文本里构建复杂的思维导图结构。同时,这种结构 AI 生成起来也非常方便。</p> <pre><code>- 产品研发流程 - 调研阶段 [^research] - 用户访谈 {"color": "#3298db"} - 竞品分析 {"color": "#3298db"} - }:2 调研总结 - 开发阶段 [^dev] - 架构设计 {"color": "#2ecc71"} - 前后端联调 {"color": "#f39c12"} - } 开发总结 - &gt; [^research] &gt;-进入-&gt; [^dev] </code></pre> <p><img src="https://raw.githubusercontent.com/SSShooter/obsidian-mindmap/refs/heads/master/screenshots/ob-mindelixir-codeblock.gif" alt="" /></p> <p>Mind Elixir Plaintext 同样也可以作为代码块嵌入到现有的文章中,顺便看看移动端的显示效果:</p> <p><img src="https://img.ssshooter.com/img/obsidian-mindmap/mobile.png" alt="" /></p> <p>普通 markdown 只能通过编辑文本更新思维导图,针对 Mind Elixir Plaintext 文本,现在正在开发<strong>编辑思维导图反向更新文本</strong>的功能。</p> <h2>安装方式</h2> <p>尽管已经提交了官方插件列表的 PR,但是现在 AI 时代随手出插件,前面一千个 PR 排着队……我估计维护团队都要放弃审批第三方插件了。</p> <p>所以呢,下面推荐两种非官方安装方式。</p> <h3>BRAT</h3> <p><a href="https://github.com/TfTHacker/obsidian42-brat">BRAT</a> 是一个已上架的 Obsidian 插件,本意是可以让你更方便地测试你的插件。但是实际上你完全可以用这个插件来安装生产级的插件。</p> <p>在社区插件列表搜索 BRAT 安装:</p> <p><img src="https://img.ssshooter.com/img/obsidian-mindmap/brat-install.png" alt="" /></p> <p>安装后在 BRAT 配置里点击 Add beta plugin 按钮,填入 <code>https://github.com/SSShooter/obsidian-mindmap</code>,就能自动安装思维导图插件:</p> <p><img src="https://img.ssshooter.com/img/obsidian-mindmap/brat.png" alt="" /></p> <h3>手动安装</h3> <p>不想使用 BRAT 也可以进入<a href="https://github.com/SSShooter/obsidian-mindmap/releases">插件 Release 页面</a>下载以下 3 个文件:</p> <ul> <li><code>main.js</code></li> <li><code>style.css</code></li> <li><code>manifest.json</code></li> </ul> <p>然后在 Obsidian 的设置中,打开插件目录,建一个文件夹把这三个文件放进去,然后刷新一下插件列表即可。</p> <p>目前 <a href="https://github.com/SSShooter/obsidian-mindmap">mindelixir-mindmap</a> 仍在持续迭代优化中,如果你在使用中遇到任何问题,或是对新功能有什么好想法,非常欢迎到 GitHub 提交 Issue 和 PR 🤗</p> 看完就懂 useSyncExternalStorehttps://ssshooter.com/react-usesyncexternalstore/https://ssshooter.com/react-usesyncexternalstore/深入解析 React useSyncExternalStore Hook 的核心原理与真实使用场景。从并发渲染的「撕裂」问题出发,教你如何优雅地订阅浏览器 API、实现轻量级全局状态管理,彻底告别 useEffect 和 Context 的常见渲染问题。Fri, 27 Feb 2026 08:24:32 GMT<h2>功能</h2> <p>React 引入 <code>useSyncExternalStore</code> 也很长一段时间了,但是存在感还不太强。简而言之,它专门用来搞定那些不受 React 内部生命周期控制的<strong>外部数据源</strong>。</p> <p>过去最大的问题其实是 React 渲染时的 <strong>「撕裂」</strong>,这是 React 为了优化页面响应速度引入的<strong>并发渲染机制</strong>带来的副作用。</p> <p>简单来说就是 React 为了防止在渲染时长时间无法响应用户输入,把渲染过程拆分成多个可中断的小任务,这就能小任务的间隙中插入用户响应,从而模拟出「并发」的感觉。更完整的前因后果可以参考<a href="https://ssshooter.com/react-philosophy/">《React 的设计哲学》</a>。</p> <p>在 React <strong>并发渲染机制</strong>下,如果用普通的 <code>useEffect</code> 去同步外部数据,可能会出现渲染进行到一半时数据突然发生变化,导致同一份页面中,一半的组件拿着老数据,另一半拿着新数据的灵异现象(但是实际上出现这个问题的几率其实非常小,大家都忽略了,这就导致了 <code>useSyncExternalStore</code> 的存在感很低)。使用 <code>useSyncExternalStore</code> 后,如果在渲染过程中快照发生变化,React 会丢弃当前渲染并重新开始,从而保证同一次提交中的所有组件看到的是同一个版本的数据。</p> <h2>使用场景</h2> <h3>订阅浏览器 API</h3> <p>拿监听网络状态来说。不使用这个 Hook 之前,我们通常得在组件里写个包含完整挂载和清理逻辑的 <code>useEffect</code> 去监听 <code>online</code> 和 <code>offline</code> 事件。</p> <pre><code>function subscribe(callback) { window.addEventListener("online", callback); window.addEventListener("offline", callback); return () =&gt; { window.removeEventListener("online", callback); window.removeEventListener("offline", callback); }; } function getSnapshot() { return navigator.onLine; } // 组件里直接这么用 const isOnline = useSyncExternalStore(subscribe, getSnapshot); </code></pre> <p>监听媒体查询(Media Queries)响应式布局也是同样的套路:</p> <pre><code>const query = window.matchMedia("(max-width: 600px)"); function subscribe(callback) { query.addEventListener("change", callback); return () =&gt; query.removeEventListener("change", callback); } const isMobile = useSyncExternalStore(subscribe, () =&gt; query.matches); </code></pre> <h3>轻量级全局状态</h3> <p>如果你接手了一个极小的项目,不想引入 Redux 或 Zustand 这样繁琐的包,但又迫切需要在几个跨层级的组件间共享某部分状态。这时候你可以直接手搓一个简易的 Store:</p> <pre><code>// 丢在 React 外面的状态中心 let internalState = { count: 0 }; const listeners = new Set(); const store = { increment() { internalState = { count: internalState.count + 1 }; listeners.forEach((l) =&gt; l()); }, subscribe(callback) { listeners.add(callback); return () =&gt; listeners.delete(callback); }, getSnapshot() { return internalState; }, }; // 任何组件里都可以直接同步获取状态 const state = useSyncExternalStore(store.subscribe, store.getSnapshot); </code></pre> <p>注意:<code>useSyncExternalStore</code> 内部用 <code>Object.is</code> 比较前后快照,如果 <code>getSnapshot</code> 在数据未变的情况下每次都返回新对象,会导致无限循环重渲染。</p> <p>只要把这段代码看懂,<strong>你就掌握了 Zustand 这种现代状态管理库的核心原理</strong>。</p> <h2>竞品 API</h2> <h3>useEffect + setState</h3> <p>曾经大家都习惯在 <code>useEffect</code> 里监听外部变化,如果变了,再跑一下 <code>setState</code> 触发更新。</p> <p>这就又到了日常<a href="https://ssshooter.com/eliminate-useEffect/">批判 <code>useEffect</code></a> 的时候了。</p> <p><code>useEffect</code> 带来重复渲染和<strong>闪烁问题</strong>。如果你的外部状态和页面初始计算的状态不对齐,页面渲染就会经历「旧值 -&gt; 闪烁 -&gt; 新值」这三步。而 <code>useSyncExternalStore</code> 在渲染中途就能直接取走最新的正确值。</p> <p>另外,在处理服务端渲染时,用副作用很容易抛出水合(Hydration)错误,因为服务端和客户端首次生成的 HTML 大概率因为外部数据对不上。<code>useSyncExternalStore</code> 为此专门开了一个叫 <code>getServerSnapshot</code> 的参数,让你传能兜底服务端的静态快照。</p> <h3>Context</h3> <p>很多人滥用 Context 做全局状态,但如果是频繁变动的数据,Context 的广播机制简直是一场灾难。只要 Provider 提供的值发生了变动,它底下所有的子组件也会跟着<strong>无脑重跑 Render</strong>,除非你给每个组件层级套一层 <code>React.memo</code>(当然现在有 compiler,但也不是毫无代价)。</p> <p>相比之下,<code>useSyncExternalStore</code> 实现了高精度的按需订阅——只有从 Store 取出的快照真的有了变化,关联的组件才会再次渲染。在这里还是顺便强调一下,没事别用 Context。</p> <h2>总结</h2> <p>要判断何时使用 <code>useSyncExternalStore</code> 其实很简单,只要你的数据依然在 React 的生命周期里流转(例如表单实时输入、控制弹窗开闭的布尔值),那就老老实实用回你的 <code>useState</code> 和 <code>useReducer</code>。</p> <p>一旦数据满足<strong>游离于 React 管理之外、会随时间变化、且你要让 UI 能自动响应这种变化</strong>这三个条件,就毫不犹豫上 <code>useSyncExternalStore</code>。日常写前端页面也许碰不到几次,但之后你要是去造底层 Hook 库,或者需要硬啃第三方库内部暴露出的状态时,<code>useSyncExternalStore</code> 绝对好使~</p> <h2>相关链接</h2> <ul> <li><a href="https://react.dev/reference/react/useSyncExternalStore">useSyncExternalStore - React</a></li> <li><a href="https://ssshooter.com/react-philosophy/">React 的设计哲学</a></li> </ul> 看完就懂 useLayoutEffecthttps://ssshooter.com/react-uselayouteffect/https://ssshooter.com/react-uselayouteffect/深入解析 React 中 useLayoutEffect 与 useEffect 的核心区别与执行时机,详细说明何时需要读取 DOM 布局并避免画面闪烁,以及在实际开发中的最佳实践。Tue, 24 Feb 2026 15:08:12 GMT<h2>差异</h2> <p><a href="https://react.dev/reference/react/useLayoutEffect">useLayoutEffect</a> 与大家熟悉的 <a href="https://react.dev/reference/react/useEffect">useEffect</a> 语法完全一致,从产生副作用的角度上看,功能上也是一样的,唯一差别就是调用时机。</p> <p>useEffect 会在画面绘制后<strong>异步</strong>执行,而 useLayoutEffect 会在画面绘制前<strong>同步</strong>执行。为了讲清楚这个时机的具体区别,得先复习一下浏览器渲染页面的过程。</p> <p><img src="https://img.ssshooter.com/img/how-browser-render.png" alt="浏览器渲染流程" /></p> <p>注意最后 js 运行的那一块,useLayoutEffect 和 useEffect 就分别位于 paint 之前和之后。</p> <p>执行的顺序是:</p> <ul> <li>useLayoutEffect</li> <li>画面绘制</li> <li>下一轮 js 运行 useEffect</li> </ul> <p>顺便我们也能看出来,useLayoutEffect 之所以叫 useLayoutEffect 就是因为它的运行时间点沾着 layout。</p> <h2>使用场景</h2> <p>知道这两个函数的区别,我们还需要知道,到底什么时候用 useLayoutEffect 呢?</p> <p>答案是,如果进行了 DOM 操作,<strong>且这个 DOM 操作会引起回流(reflow)、重绘(repaint)</strong>,那么就应该使用 useLayoutEffect,例如:</p> <pre><code>function Tooltip() { const ref = useRef&lt;HTMLDivElement&gt;(null); const [pos, setPos] = useState({ top: 0, left: 0 }); // 如果用 useEffect,这里会先渲染一次默认位置,再跳到正确位置 → 可能会造成闪烁 useLayoutEffect(() =&gt; { const rect = ref.current!.getBoundingClientRect(); setPos({ top: rect.top + rect.height + 8, left: rect.left + rect.width / 2, }); }, []); return ( &lt;&gt; &lt;div ref={ref}&gt;hover me&lt;/div&gt; &lt;div style={{ position: 'fixed', top: pos.top, left: pos.left }}&gt;tooltip&lt;/div&gt; &lt;/&gt; ); } </code></pre> <p>因为如果你用 useEffect,在浏览器绘制之后又要重新跑一遍 reflow、repaint,用户可能会看到画面“闪烁”。</p> <p>如果你有代码洁癖,想要一个最优解,那么你确实该按上面说的这么做,但是事实上在这个场景使用 useEffect 可能也不会有很明显的问题。</p> <p>其实即使是<a href="https://react.dev/reference/react/useLayoutEffect">官网的例子</a>里,作为反模式使用 useEffect,用户也不会感知到明显的“闪烁”,因为两次渲染的时间其实是快到肉眼看不清的,为了确定真的存在区别你还要故意写个 <code>while</code> 循环卡一下主进程。</p> <p>既然一般情况下无论 useEffect 和 useLayoutEffect 都不会有明显区别,那么我觉得,作为一个有专业素养的 React 开发者,应该优先使用 useEffect,只在 reflow、repaint 造成闪烁的场景下,使用 useLayoutEffect。</p> <p>当然,useEffect本身也不能乱用,之前在<a href="https://ssshooter.com/eliminate-useEffect/">useEffect 清除计划</a>里已经讲述了它的必要使用场景。</p> <h2>总结</h2> <p>useLayoutEffect 适用于“需要在浏览器绘制前同步完成的副作用”,典型场景是读取布局信息并立即修改 DOM,避免视觉闪动。</p> <p>但因其会阻塞浏览器绘制,影响性能,因此不应滥用。在绝大多数副作用场景下,优先使用 useEffect,只有在感知到闪动才改为使用 useLayoutEffect。</p> <h2>相关链接</h2> <ul> <li><a href="https://react.dev/reference/react/useLayoutEffect">useLayoutEffect</a></li> <li><a href="https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing#avoid_forced_synchronous_layouts">Avoid large, complex layouts and layout thrashing</a></li> <li><a href="https://dev.to/gopal1996/understanding-reflow-and-repaint-in-the-browser-1jbg">Understanding Reflow and Repaint in the Browser</a></li> </ul> 复习 DOM 事件机制https://ssshooter.com/dom-event/https://ssshooter.com/dom-event/深入理解 DOM 事件机制,包括事件捕获、冒泡、事件委托、阻止传播与默认行为的区别,以及 passive 事件监听器的优化原理。Sun, 08 Feb 2026 09:30:53 GMT<p>本期前端复习的主题是 DOM 事件机制,是前端开发非常重要的基础。除了常听到的“冒泡”,完整的事件流其实更有意思。</p> <h2>事件传播</h2> <p>完整的 DOM 事件传播分为三个阶段:</p> <ol> <li><strong>捕获阶段(Capturing Phase)</strong> <ul> <li>事件从 <code>window</code> 一路向下传递到目标元素的父节点。</li> <li>期间可以通过 <code>addEventListener(type, listener, true)</code> 第三个参数设为 <code>true</code> 来监听此阶段。</li> </ul> </li> <li><strong>目标阶段(Target Phase)</strong> <ul> <li>事件到达目标元素本身,即 <code>event.target</code>。</li> <li>此阶段监听函数会被触发。</li> </ul> </li> <li><strong>冒泡阶段(Bubbling Phase)</strong> <ul> <li>事件从目标元素向上传播至 <code>window</code>。</li> <li>默认通过 <code>addEventListener(type, listener, false)</code> 注册的事件监听器会在这个阶段触发。</li> </ul> </li> </ol> <p><img src="https://img.ssshooter.com/img/dom-event/focus.png" alt="" /></p> <p>但也不是所有事件都支持冒泡,例如 <code>focus</code>, <code>blur</code> 等就不冒泡,具体各个事件是否支持冒泡可以 <a href="https://w3c.github.io/uievents/#event-type-focus">w3c 官方文档</a>。</p> <h2>监听事件</h2> <pre><code>&lt;div id="outer" class="box"&gt; Outer &lt;div id="middle" class="box"&gt; Middle &lt;div id="inner" class="box"&gt;Inner&lt;/div&gt; &lt;/div&gt; &lt;/div&gt; </code></pre> <p>事件监听注册如下:</p> <pre><code>const boxes = ["outer", "middle", "inner"]; boxes.forEach((id) =&gt; { const el = document.getElementById(id); // 事件捕获阶段 el.addEventListener( "click", (event) =&gt; logEvent("捕获阶段", id, event), true, // 捕获阶段 ); // 事件冒泡阶段 el.addEventListener( "click", (event) =&gt; logEvent("冒泡阶段", id, event), false, // 冒泡阶段 ); }); </code></pre> <p>可以参考这个示例:https://codesandbox.io/p/sandbox/w93cgd</p> <p><img src="https://img.ssshooter.com/img/dom-event/bubble.gif" alt="" /></p> <h2>阻止传播</h2> <p>调用 <code>event.stopPropagation()</code> 可以阻止事件传播。注意,这不仅能停掉冒泡,在捕获阶段也能把事件截下来。</p> <p>举个例子:</p> <pre><code>child.addEventListener("click", (event) =&gt; { event.stopPropagation(); console.log("child"); }); </code></pre> <p>此时点击按钮,只会输出 <code>child</code>,不会触发 <code>parent</code> 或 <code>grandparent</code> 的监听器。</p> <p>同理,如果是在捕获阶段调用 <code>stopPropagation()</code>,事件就无法到达目标元素和冒泡阶段:</p> <pre><code>parent.addEventListener( "click", (event) =&gt; { event.stopPropagation(); console.log("parent capture"); }, true, ); // 注意第三个参数 true 开启捕获 </code></pre> <p>此时点击子元素,事件在 <code>parent</code> 被截获,<code>child</code> 的点击事件将<strong>永远不会触发</strong>。</p> <h3>常见场景</h3> <ol> <li><strong>防止重复触发(阻止冒泡)</strong> <ul> <li><strong>场景</strong>:点击卡片中的按钮(如“删除”),但不希望触发卡片本身的点击事件(如“跳转详情”)。</li> <li><strong>做法</strong>:在按钮的点击事件中调用 <code>event.stopPropagation()</code>。</li> </ul> </li> <li><strong>全局拦截(阻止捕获)</strong> <ul> <li><strong>场景</strong>:页面进入“编辑模式”或“引导模式”,需要禁用页面上所有元素的点击交互,只允许特定区域或完全接管交互。</li> <li><strong>做法</strong>:在 <code>window</code> 或最外层容器上监听 <code>click</code> 事件(设置 <code>capture: true</code>),并调用 <code>event.stopPropagation()</code>,这样内部元素都无法收到点击事件。</li> </ul> </li> </ol> <p>如果同一个元素绑定了多个监听器,想把后续的监听器也一并拦住,得用 <code>event.stopImmediatePropagation()</code>。</p> <h2>默认行为</h2> <p>浏览器会对某些事件执行默认动作。例如:</p> <ul> <li>点击 <code>&lt;a&gt;</code> 标签会跳转链接。</li> <li>点击表单的提交按钮会提交表单。</li> <li>在输入框按键会输入字符。</li> <li>选中文本后右键会弹出上下文菜单。</li> </ul> <p>我们可以使用 <code>event.preventDefault()</code> 来阻止这些默认行为。</p> <p>跟不是所有事件都会冒泡一样,也并非所有事件的默认行为都能被取消。可以通过 <code>event.cancelable</code> 属性查看事件是否可取消。</p> <h3>passive</h3> <p><code>passive</code> 的意思是“被动”。用这个选项,等于向浏览器承诺:在这个监听器里,我<strong>绝不调用 <code>preventDefault()</code></strong>。</p> <p>既然你不打算阻止滚动,浏览器就不用等你的 JS 跑完再动,页面滚动自然就丝滑多了。否则,浏览器为了确认你会不会拦截滚动,每一帧都得等你,很容易卡顿。</p> <p>现代浏览器为了优化体验,默认把 <code>touchstart</code> 和 <code>wheel</code> 等滚动事件设为 <code>passive: true</code>。也就是说,你在这个监听器里调 <code>preventDefault()</code> 是没用的。如果非要阻止滚动,必须在绑定时显式加上 <code>{ passive: false }</code>。</p> <pre><code>// 默认情况下 passive 为 true,preventDefault() 无效 document.addEventListener("touchstart", function (e) { e.preventDefault(); // 控制台会显示警告,滚动无法阻止 }); // 显式设置 passive: false,preventDefault() 生效 document.addEventListener( "touchstart", function (e) { e.preventDefault(); // 阻止滚动 }, { passive: false }, ); </code></pre> <h2>阻止传播与默认行为的影响</h2> <p>别搞混了:<strong>阻止传播(Stop Propagation)</strong> 和 <strong>阻止默认行为(Prevent Default)</strong> 是两码事。</p> <ul> <li><code>stopPropagation()</code>:让事件不再通过 DOM 树传播(冒泡/捕获),但<strong>不</strong>阻止浏览器执行默认动作。</li> <li><code>preventDefault()</code>:告诉浏览器不要做默认动作,但<strong>不</strong>阻止事件在 DOM 中的传播。</li> </ul> <h3>连锁效应</h3> <p>虽然两者独立,但要注意<strong>一个事件的默认行为可能是触发另一个事件</strong>。</p> <p>例如,在输入框中按键,<code>keydown</code> 事件的默认行为通常包括“将字符输入到文本框”。如果你在 <code>keydown</code> 阶段调用了 <code>event.preventDefault()</code>,浏览器就会取消这个默认动作,即使你阻止的不是 input 的默认行为,字符也不会出现在输入框中。</p> <p><img src="https://img.ssshooter.com/img/dom-event/keydown.png" alt="" /></p> <h3>示例</h3> <p>有个常见的坑:粗暴地在全局阻止默认行为。例如,为了禁用某个快捷键,但在 <code>document</code> 上直接阻止了所有 <code>keydown</code> 的默认行为:</p> <pre><code>document.addEventListener("keydown", (e) =&gt; { // 这会导致整个页面的输入框即使获得焦点也无法输入文字 // 因为“输入文字”也是按键的默认行为之一 e.preventDefault(); }); </code></pre> <p>所以在调用 <code>preventDefault()</code> 前,一定要加条件判断(比如只针对特定键码 <code>e.key === 'Enter'</code> 阻止)。</p> <h2>事件委托</h2> <p>这是冒泡最实用的功能。</p> <p>有了冒泡,**事件委托(Event Delegation)**才成为了可能:</p> <pre><code>document.getElementById("parent").addEventListener("click", (e) =&gt; { if (e.target.tagName === "BUTTON") { console.log("Clicked button:", e.target.id); } }); </code></pre> <p>这样可以只给 <code>parent</code> 绑定一次事件监听器,而不需要为每个 <code>button</code> 单独绑定,提高性能。</p> <p>这里就得区分 <code>target</code> 和 <code>currentTarget</code> 了:</p> <ul> <li><code>target</code> 是事件<strong>触发</strong>的具体目标元素。</li> <li><code>currentTarget</code> 是事件监听器<strong>绑定</strong>的当前元素。</li> </ul> <p>不得不吐槽,这个命名属实有点抽象,久了不用就总会把 <code>currentTarget</code> 记成是当前触发的元素,这就反了😂</p> <pre><code>&lt;div id="parent"&gt; &lt;button id="child"&gt;Click me&lt;/button&gt; &lt;/div&gt; &lt;script&gt; const parent = document.getElementById("parent"); parent.addEventListener("click", function (e) { console.log("target:", e.target); console.log("currentTarget:", e.currentTarget); }); &lt;/script&gt; </code></pre> <p>点击按钮 <code>&lt;button id="child"&gt;</code> 时:</p> <ul> <li><code>e.target</code> 是 <code>&lt;button&gt;</code>:你点的元素</li> <li><code>e.currentTarget</code> 是 <code>&lt;div&gt;</code>:绑定事件的元素(parent)</li> </ul> <h2>总结</h2> <ul> <li><strong>传播机制</strong>:事件流分为捕获、目标、冒泡三个阶段。日常开发主要利用冒泡进行事件委托,但在特定场景下捕获阶段也可以用于拦截事件。</li> <li><strong>行为控制</strong>:区分 <code>stopPropagation</code> 和 <code>preventDefault</code>。前者阻止事件继续传播,后者阻止浏览器的默认行为(如表单提交),两者互不影响。</li> <li><strong>性能优化</strong>:滚动类事件(如 <code>touchstart</code>, <code>wheel</code>)建议使用 <code>passive: true</code>,明确告知浏览器不会调用 <code>preventDefault</code>,从而让页面滚动更加流畅。</li> <li><strong>对象区分</strong>:<code>event.target</code> 是实际触发事件的元素,<code>event.currentTarget</code> 是绑定监听器的元素。在处理复杂的组件嵌套时,分清这两个属性非常重要。</li> </ul> <h2>参考文献</h2> <ul> <li>https://w3c.github.io/uievents/#event-type-keydown</li> <li>https://developer.mozilla.org/en-US/docs/Web/Events</li> </ul> 读完书容易忘?这个开源 AI 应用能帮你!https://ssshooter.com/introduce-ebook-to-mindmap/https://ssshooter.com/introduce-ebook-to-mindmap/ebook-to-mindmap 是一款开源 AI 阅读工具,能将 PDF 和 EPUB 电子书自动转换为分章节的思维导图或文字总结。支持自定义大模型(BYOK)和提示词,保护隐私,助你高效整理读书笔记,解决读完就忘的烦恼。Fri, 30 Jan 2026 06:06:59 GMT<p>其实我们不必回避看完书就忘的问题,因为大多数人看书都是会忘的。其实人类的大脑就是这么设计的,它会过滤掉大部分不重要的信息,只保留下重要的信息。如果真的想要记住一本书重要的知识,需要反复阅读,反复思考,反复练习。</p> <p>在前 AI 时代,做读书笔记是一件非常耗费精力的事情,但是有大模型之后,我们可以在做笔记这件事上偷偷懒。</p> <p><strong>注意:做笔记可以偷懒,但是思考和反复回看是绝对不能偷懒的。</strong></p> <p>那么有什么好用的工具呢?朋友们,有的!欢迎使用 <a href="https://github.com/SSShooter/ebook-to-mindmap">ebook-to-mindmap</a>!简单来说,你可以通过 <a href="https://github.com/SSShooter/ebook-to-mindmap">ebook-to-mindmap</a> 把 pdf 或 epub 格式的电子书转换为分章节的思维导图或者文字总结。</p> <p><img src="https://img.ssshooter.com/img/ebook-to-mindmap/mindmap.jpg" alt="思维导图模式" /></p> <p>点击<a href="https://ebook2me-next.mind-elixir.com/">这里</a>即可立即体验。整个网页应用功能比较简洁,大家可以直接上手,当然,下面我也会比较详细地介绍一下这个应用的使用方法🤗</p> <h2>模型配置</h2> <p>使用 ebook-to-mindmap 的第一步是配置模型。它和很多 AI 应用一样,都是选择 <strong>byok</strong>(Bring Your Own Key)的模式,你可以在这里配置你自己的大模型。</p> <p>这里还是要强调一下,在 ebook-to-mindmap 填写 Key 时不必担心 Key 泄露,因为 <strong>Key 只是保存在你自己的浏览器里</strong>,请求也是直接从你的浏览器发送到大模型提供商的服务器的。你可以在浏览器的开发者工具里查看网络请求,确认这一点。同时,<a href="https://github.com/SSShooter/ebook-to-mindmap">ebook-to-mindmap</a> 作为一个开源项目你可以随时检视它的代码,还可以自己部署一个属于你的 ebook-to-mindmap。</p> <p>说回模型的选择,可能很多人会担心使用 ebook-to-mindmap 的花费太高,其实倒也不必,毕竟现阶段还是能找到很多免费或者低价的大模型。我的首推还是 <a href="https://openrouter.ai/"><strong>openrouter</strong></a>,你只需要充值 10 刀,就能获得一个较大的免费模型(其中包括一些 deepseek 变体、最近小米的新模型、之前一段时间还有 grok)使用额度,基本上一天让它处理好几本书都没问题了。其他详细推荐可以参考<a href="https://ssshooter.com/ai-services-guide/">免费和付费 AI API 选择指南</a>。</p> <p><img src="https://img.ssshooter.com/img/ebook-to-mindmap/set-model.jpg" alt="model list" /></p> <p>在获取到 Key 后如上图填写信息即可。</p> <p>你还可以配置多个模型,点击左侧的星星后会成为默认模型,后续处理时默认使用星标的模型:</p> <p><img src="https://img.ssshooter.com/img/ebook-to-mindmap/model-list.jpg" alt="model list" /></p> <h2>生成笔记</h2> <p>配置模型后,在主页选择电子书即可。之后 ebook-to-mindmap 会自动识别电子书的格式,然后开始识别章节:</p> <p><img src="https://img.ssshooter.com/img/ebook-to-mindmap/ai-summary.jpg" alt="AI 总结页面" /></p> <blockquote> <p>[!TIP] 提示:如果 epub 无法获取到章节,可以在设置里勾选使用 Spine 获取章节</p> </blockquote> <p>章节识别成功后,选择你需要总结的章节,或者使用分组功能(可以使用快捷键 Ctrl + G)把零碎的章节组合成分组一起发送给 AI 处理。</p> <p>一切准备好后,点击开始解释按钮即可开始生成笔记。</p> <p>默认情况下,ebook-to-mindmap 会生成<strong>思维导图</strong>,你也可以点击小齿轮切换到<strong>文字总结</strong>模式:</p> <p><img src="https://img.ssshooter.com/img/ebook-to-mindmap/change-mode.jpg" alt="模式切换" /></p> <blockquote> <p>[!TIP] 虽然有整书思维导图生成功能,但是如果书的内容比较长,AI 可能吃不下这么长的上下文,所以建议还是分章节生成,最后系统会自动拼接</p> </blockquote> <p>生成笔记如果想要中途取消,放心点取消就好,<strong>之前处于完成状态的章节会被缓存</strong>,不用担心之后需要再浪费 Token 重新生成。</p> <h2>提示词</h2> <p>举个例子吧,你在提示词列表里添加一个“小·红书风格”提示词,在生成环节选择这个提示词,就能直接生成小红书风格的笔记。</p> <p><img src="https://img.ssshooter.com/img/ebook-to-mindmap/rednote-prompt.jpg" alt="小红书风格" /></p> <p>不止小红书风格,你也可以让 AI 只简单地提取该章节最重要的 5 个观点,帮助你对整本书的主要内容有一个简要的了解。</p> <p>你还可以使用“反论法”提示词:</p> <pre><code>选取本章的核心论点或思想,并探索它的对立面。如果作者要为相反的观点辩护,他们需要证明什么?文本中是否有无意间支持反面观点的蛛丝马迹? </code></pre> <p>参考<a href="https://ssshooter.com/notebooklm-prompt/">分享几条有意思的 NotebookLM 提示词</a>这篇文章,里面有几个有趣的提示词,或许能让你眼前一亮。</p> <h2>内容管理</h2> <p>ebook-to-mindmap 充满了下载按钮,是的,你生成的数据必须还是属于你的!你可以很轻易地把数据拿出来!</p> <p>导出的文字内容可能是 markdown 文件或是思维导图 json 文件。</p> <p>markdown 文件可以直接阅读,或者导入到 Obsidian、Notion 等笔记软件再细化修改。</p> <p>思维导图 json 文件可以使用 <a href="https://github.com/SSShooter/mind-elixir-core">mind-elixir-core</a> 等前端库渲染,当然,如果你是技术人员,理解 json 数据的结构你也可以随意修改和渲染。</p> <p>思维导图亦可导出为图片,点击思维导图页面右上角的下载按钮即可。</p> <h2>格式选择</h2> <p>最后谈谈电子书格式的问题,ebook-to-mindmap 支持 pdf 和 epub 格式的电子书,但是这两种格式如何选择呢?</p> <p>或许大家都会比较喜欢看 pdf,因为看起来比较工整,但是使用 ebook-to-mindmap,我还是比较<strong>推荐 epub 格式</strong>的电子书。</p> <p>稍微讲一下 pdf 和 epub 的原理吧。</p> <p>pdf 的特点是在任何设备上看起来都一样,这就很容易想到,其实 pdf 的排版是非常固定的,而且更重要的是,pdf 的排版是没有语义的。也就是说,人类能看到一个标题是加粗黑字,但是 pdf 本身并不知道这是一个标题,它只是知道这一块区域的文字是加粗黑字的。</p> <p>更严重的问题是 pdf 如果有一些复杂的排版,例如在角落嵌入一段文字,在解释的时候就很难理解那段文字的意义。所以,大模型理解 pdf 的难度会比较大。</p> <p>而 epub 格式就不一样,它更像是一张网页,有语义,有结构,有层次,就跟 HTML 差不多。但缺点就是人类看来这样的排版有点粗糙,在不同的阅读器上显示效果也不同。在某些落后的 epub 阅读器上阅读时可能会觉得排版很有年代感。但是大模型不在乎排版,有清晰的结构就能得到好的输出结果。</p> <h2>写在最后</h2> <p>总的来说,<a href="https://github.com/SSShooter/ebook-to-mindmap">ebook-to-mindmap</a> 是一个能帮你快速复习或者把书本变薄的工具。在这个信息爆炸的时代,高效地获取和整理知识变得越来越重要。希望这个小工具能成为你阅读路上的得力助手,让你把更多的时间花在深度思考和理解上,而不是机械地摘抄。</p> <p>如果你觉得这个项目对你有帮助,欢迎在 <a href="https://github.com/SSShooter/ebook-to-mindmap">GitHub</a> 上点个 Star ⭐️ 支持一下!如果你有任何建议或发现了 bug,也欢迎提 Issue 或者加入讨论。</p> <p>Happy Reading!</p> 博客功能更新 × 3https://ssshooter.com/blog-upgrade/https://ssshooter.com/blog-upgrade/博客功能升级:新增系列文章功能支持文章连载,相关文章推荐提升内容关联性,Twikoo 评论组件自定义主题色实现 UI 统一。详细介绍 Astro 博客的三大功能更新及使用方法。Fri, 09 Jan 2026 08:17:20 GMT<p>有种好久没更新<a href="https://ssshooter.com/tag/%E6%9C%AC%E7%AB%99%E5%8E%86%E5%8F%B2/">本站历史</a>的感觉,最近有 3 个新功能还是得记一下。</p> <h2>系列文章</h2> <p><img src="https://img.ssshooter.com/img/blog-upgrade/post-series.png" alt="" /></p> <p>使用方法:Frontmatter 中添加 <code>series</code> 和 <code>seriesOrder</code></p> <pre><code>slug: "kitten-large-language-model-1" publishDate: "2025-11-30T15:01:45.814Z" title: "小猫都能懂的大模型原理 1 - 深度学习基础" tags: ["大语言模型", "深度学习", "神经网络", "机器学习"] description: "用最简单易懂的语言解释大语言模型的基本原理,从深度学习基础到神经网络训练,包含梯度下降、反向传播等核心概念,适合初学者的AI入门教程。" series: "小猫都能懂的大模型原理" seriesOrder: 1 useKatex: true </code></pre> <h2>相关文章</h2> <p><img src="https://img.ssshooter.com/img/blog-upgrade/recommend-posts.png" alt="" /></p> <p>使用方法:Frontmatter 中添加 <code>recommendTag</code></p> <pre><code>slug: "2025-summary" publishDate: "2025-12-29T08:49:10.000Z" title: "2025 年终总结" tags: ["diary", "年终总结"] recommendTag: "年终总结" </code></pre> <h2>评论组件更新</h2> <p><a href="https://github.com/twikoojs/twikoo">Twikoo</a> 似乎挺久没更新了,于是 <a href="https://github.com/SSShooter/twikoo">Fork</a> 了一份。Twikoo 本身用的还是 Vue2,本来想顺便把它升级成 Vue3,但是迁移起来比想象中的麻烦(而且我还发现自己已经把 Vue2 的使用方式<strong>忘掉一大半了</strong>😂),如果再过几年它依然没更新的话再重构吧。</p> <p>最后就只是改成 Vite 构建,添加了<strong>主题色功能</strong>,顺便做了一点 UI 微调。</p> <p><img src="https://img.ssshooter.com/img/blog-upgrade/twikoo-update.jpg" alt="" /></p> <p>如果你也想使用,只要安装依赖:</p> <pre><code>pnpm i tttwikoo </code></pre> <p>然后在 <code>global.css</code> 中添加如下代码即可:</p> <pre><code>#twikoo { --tk-primary-color-rgb: 203, 42, 66; } :root[data-theme="dark"] #twikoo { --tk-primary-color-rgb: 232, 120, 142; } </code></pre> <p>太好了,UI 终于统一起来了,从 <a href="https://github.com/chrismwilliams/astro-theme-cactus">cactus</a> Fork 出来也这么久了,更新了不少,晚点也开源一下好了🤗</p> 博客音乐播放器 + 1https://ssshooter.com/blog-music-player/https://ssshooter.com/blog-music-player/介绍开源项目 Elixia Player - 一个可嵌入博客的音乐播放器,支持网易云、QQ音乐等多平台。提供三种嵌入方式:可播放卡片、外链卡片和图片分享,让你轻松在文章中分享音乐。基于 Meting 开发,支持搜歌、歌词显示和 AI 功能。Tue, 06 Jan 2026 14:00:05 GMT<p>尽管我本来不是想做博客播放器,而是做一个歌词解释器,但是做都做了,突然发现做成大杂烩不也行,于是开干呗~</p> <p>最后出来结果还不错,接下来简单介绍一下这个开源项目:</p> <ul> <li>Github 地址:<a href="https://github.com/SSShooter/elixia-player">Elixia Player</a></li> <li>直接来试用:https://elixia-player.koyeb.app/search</li> </ul> <p>特别鸣谢 <a href="https://github.com/metowolf/Meting">Meting</a>,没有 Meting 就没有这个项目!</p> <p>搜歌、看歌词、AI 功能就不在这多说了,下面主要介绍其作为博客音乐播放器的能力,主要是 3 个功能:</p> <ul> <li>插入播放卡片</li> <li>插入外链卡片</li> <li>生成图片分享</li> </ul> <p>首先是播放卡片,可以播放插入的歌曲,但前提是<strong>必须提供对应音乐平台的 cookie</strong>。虽然体验非常好,不用跳转直接播放,但对维护者来说就非常麻烦了,需要在部署服务时设置 cookie,并且 cookie 过期的时候需要及时更换,否则无法播放。</p> <p>举个 QQ 音乐的例子,在登陆 QQ 音乐之后按 F12,打开 Network 找到这个 cookie 在发布时填写,或直接在网页配置页填写都可以~</p> <p><img src="https://img.ssshooter.com/img/elixia-player/qqmusic-cookie.png" alt="" /></p> <pre><code>&lt;iframe loading="lazy" height="80px" width="100%" style="border-radius: 15px;" src="https://elixia-player.koyeb.app/embed/tencent/003cI52o4daJJL" frameborder="0" &gt;&lt;/iframe&gt; </code></pre> <p>填好了再嵌入以上代码,无意外就能看到这样的播放器:</p> <p><img src="https://img.ssshooter.com/img/elixia-player/%E8%8A%B1%E6%B5%B7.png" alt="花海 - Elixia Player" /></p> <p>&lt;!-- &lt;iframe loading="lazy" height="80px" width="100%" style="border-radius: 15px; margin: 20px 0;" src="http://elixia-player.koyeb.app/embed/tencent/003cI52o4daJJL" frameborder="0"</p> <blockquote> <p>&lt;/iframe&gt; --&gt;</p> </blockquote> <p>接着<strong>外链卡片</strong>,是一种比较折中的方式,也<strong>最推荐的方式</strong>:</p> <p><img src="https://img.ssshooter.com/img/elixia-player/%E6%AC%A7%E8%8B%A5%E6%8B%89.png" alt="欧若拉 - Elixia Player" /></p> <p>&lt;!-- &lt;iframe loading="lazy" height="80px" width="100%" style="border-radius: 15px; margin: 20px 0;" src="https://elixia-player.koyeb.app/card/tencent/001t1qJd0DaKOs" frameborder="0"</p> <blockquote> <p>&lt;/iframe&gt; --&gt;</p> </blockquote> <pre><code>&lt;iframe loading="lazy" height="80px" width="100%" style="border-radius: 15px;" src="https://elixia-player.koyeb.app/card/tencent/001t1qJd0DaKOs" frameborder="0" &gt;&lt;/iframe&gt; </code></pre> <p>最后是完全丢弃 HTML 的<strong>图片格式</strong>,完全固定的内容。很多 UGC 平台都不能插入 <code>iframe</code>,这时候就可以直接用生成的 PNG 图片:</p> <pre><code>![](https://elixia-player.koyeb.app/card/tencent/002POzud0db9lK/image) </code></pre> <p><img src="https://img.ssshooter.com/img/elixia-player/Pretender-card.png" alt="Pretender" /></p> <p>当然咯还是建议大家再套一层链接,让用户能直接点击跳转,所以完整版如下:</p> <p><a href="https://y.qq.com/n/ryqq_v2/songDetail/002rhFKO3EjKAg"><img src="https://img.ssshooter.com/img/elixia-player/%E6%98%94%E6%B6%9F-card.png" alt="昔涟" /></a></p> <pre><code>[![昔涟](https://elixia-player.koyeb.app/card/tencent/002rhFKO3EjKAg/image)](https://y.qq.com/n/ryqq_v2/songDetail/002rhFKO3EjKAg) </code></pre> <p><strong>注意</strong>,我这个白嫖服务生成图片非常慢,建议还是保存一份放服务器😂</p> <p>其他官方选择:</p> <p><a href="https://open.spotify.com/"><strong>Spotify</strong></a> 从设计和加载速度上都不失为一个好选择,但最致命的是需要一些魔法才能访问,而且你总不能要求你的读者都会用魔法😂</p> <pre><code>&lt;iframe style="border-radius:12px" src="https://open.spotify.com/embed/track/2leJWl7tBdFVj5Imag5T8J?utm_source=generator" width="100%" height="152" frameborder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy" &gt;&lt;/iframe&gt; </code></pre> <p><a href="http://music.163.com/"><strong>网易云</strong></a> 也不是不行,就是加载贼慢,以前写<a href="https://ssshooter.com/2019-03-27-music-2-natsukoi/">二次元音乐推荐</a>的时候插入了一堆网易云 <code>iframe</code>,感觉加载速度也不怎么样,而且有点嫌弃它的颜值……</p> <pre><code>&lt;iframe frameborder="0" border="0" width="100%" height="100" src="//music.163.com/outchain/player?type=2&amp;id=28915185&amp;auto=0&amp;height=66" &gt; &lt;/iframe&gt; </code></pre> <p>至于 <a href="https://y.qq.com/"><strong>QQ 音乐</strong></a>,好像没在官网找到可嵌入播放器。</p> <p>大概就是这么个事,欢迎大家直接使用我已经<a href="https://elixia-player.koyeb.app">部署好的服务</a>,不过因为是白嫖服务所以打开可能会有点慢……所以也欢迎大家自己在 <a href="https://app.koyeb.com/">koyeb</a> 部署 <a href="https://github.com/SSShooter/elixia-player">Elixia Player</a>,这样服务也不拥挤,体验好那么一点。</p> 2025 年终总结https://ssshooter.com/2025-summary/https://ssshooter.com/2025-summary/2025 年终总结:从 DeepSeek 点燃 AI 编程火花到 Claude 4.5 的生产力飞跃,记录一名开发者在技术迭代下的生存思考。包含 A 股 4000 点定投心得、币圈止损反思、思维导图新产品发布,以及关于出生率、失眠与寻找快乐的生活碎片。Mon, 29 Dec 2025 08:49:10 GMT<p>又到了该年终总结的时候,真的一眨眼,2025 就过去了,所以对于这一年的总结,我的脑子有一点空白。</p> <p>来吧,努努力吧,回想一下,拼凑今年的记忆碎片。</p> <h2>世俗的任务</h2> <p>上年在番禺当兄弟,今年在江门当礼炮手,明年不出意外该是轮到我了,搞婚礼这些世俗任务是真的烦人,花大价钱给亲戚朋友做秀,最后大多数人都并不会记得什么。不过既然我是明年,这事还是留着明年写吧。</p> <p>这里想讲的是另外一个跟哥们讨论过的问题,那就是生孩子。首先表明立场,在这个随时失业且有房贷的前提下,我是不敢生育的,真的看过太多家庭因为钱的问题吵得一地鸡毛了。</p> <p>回到主题,24、25 年的<strong>出生率</strong>处于新中国成立后<strong>最低的水平</strong>,这已经是人尽皆知的事情,很多网友也都开始看日本前瞻服预测中国老龄化结局。但奇妙的是什么,哥们说他身边的人陆续生孩子,还有不少二胎。一般我不会这么疑惑,这存在地域差异,或许他那就是爱生娃,但是今年年末,我同组的两个同事几乎同时休陪产假。除此之外,朋友圈晒孩子出生的粗略回想一下也得有两三个。</p> <p>于是,我跟哥们不禁想问,<strong>到底是谁不生孩子</strong>。我这圈层肯定不是富裕人群,也不是农村人群,这两个刻板意义上的高生育群体,但是为什么非这两个圈层看起来生育率不低,出生率还是这么低。是幸存者偏差还是刻板印象已经被改变了?我仍然没有答案,个人的视角是真的太局限了。</p> <h2>AI 起飞</h2> <p><a href="https://ssshooter.com/2024-summary/">上年总结</a>提到我已拥抱 AI,那时候的描述还只是跨文件处理简单需求,今年可以说彻底起飞。</p> <p>大家都知道今年年头 Deepseek 把中国 AI 的火点燃了,国产大模型能力在今年得到跃迁,中国用户也有低价凑合着用的选择。不过当然,行业领先的 Claude 依然是写代码的神。</p> <p>随着 Claude 的升级让 AI 编程能力继续飙升,通过 Agent、工具调用、超长上下文,AI 已经可以处理一些复杂的任务,并且是无需人类干预自主分步完成。到年末,Claude 都已经出到 4.5 了,只要你不是上班造火箭,「普通的」工作需求都基本都能由 AI 自主解决。<strong>优化的屠刀</strong>真是跑着来追我啊😂。</p> <p>迫不得已,今年真的稍微认真学了一下 AI,主要是大模型的实现,于是有了<a href="https://ssshooter.com/kitten-large-language-model-1/">小猫都能懂的大模型原理</a>系列,整个过程真的不得不感叹数学和统计学的奇妙,语言、甚至是图像居然可以通过这么「简单的」思想建模,效果还能这么好。</p> <p>在 AI 已经这么无敌的前提下,谁还没个「产品」呢?</p> <p>于是,<a href="https://desktop.mind-elixir.com/">Mind Elixir Desktop</a> 终于在今年发布啦~</p> <p>顺便还衍生了两个<strong>完全开源</strong>的思维导图生成工具:</p> <ul> <li><a href="https://github.com/SSShooter/ebook-to-mindmap">ebook 转思维导图</a></li> <li><a href="https://github.com/SSShooter/M10C-Video-Summary">M10C</a></li> </ul> <h2>又有起色的 A 股</h2> <p>上面说 Deepseek 年头爆了,资本自然也会跟上,再加上上年的政策,今年的底还是挺扎实的,有跌,但是没跌太多,并且今年也顺利上 <strong>4000 点</strong>了。</p> <p>虽然作为一个定投党,我还是没赚啥钱就是了,无论如何,比起前面几年心情还是好多了。</p> <p>这年越来越明白想赚钱还是得<strong>会卖</strong>,只是道理都懂,我还没做到,明年真想试试啊,在一个高点割一笔大的再继续定投。</p> <p>跟 A 股比,玩加密币就真的玩出个伤心的故事。简单来说就是先退坑了,<strong>总想着加杠杆赚快钱,最后只会亏钱</strong>,还好吧,我就亏了一百多刀没爆仓就出来了。如果说玩币的终局一定是爆仓的话,那为什么不提早退出呢。</p> <p>Obsidian 里面记了写心路历程,到现在都还没有空整理,有空再另外开个坑讲讲吧。</p> <h2>书音游</h2> <p>微信读书 365 再次打卡完成,并依然参与中。看的书也不少,记住的没几本,感觉我在继续看下去之前,需要先想清楚到底怎么看书才能记住,或者有的读了记不住是不是不该看?又或者是,其实不需要太纠结有没有记住,因为只要一直看下去,你觉得自己没记住,但实际上已经腌入味了。</p> <p>今年记得最牢的书都是些投资系的书,例如 <a href="https://ssshooter.com/die-with-zero/">Die With Zero</a>,对我来说是心理按摩,稍微缓解我抠门的心结。接着是塔勒布的《<strong>反脆弱</strong>》,让我明白投资不要被噪音干扰,以及只有活着才能继续游戏,尤其后者,要是没了这句话我早就在币安爆掉了。</p> <p>今年听的歌非常散,主要是在 <a href="https://open.spotify.com/">Spotify</a>,后面偶尔发现 QQ 音乐和网易云的一些白嫖机会,就会到那边听一下,又到最近,Apple Music 又嫖到了 3 个月,听歌听得像个游牧民族。</p> <p><img src="https://img.ssshooter.com/img/2025-music.jpg" alt="2025 音乐总结" /></p> <p>今年好像没听啥新歌,都是挖到没听过的旧歌相逢恨晚。2025 年年度歌手是 <strong>BoA</strong> 就挺神奇。另外《跳楼机》不知道为啥没上榜,可能是在 Spotify 听得比较多吧,虽然是流水线生产的抖音神曲,但还是好丝滑好喜欢。荐歌环节就不放在这里了,<a href="https://ssshooter.com/tag/%E9%9F%B3%E4%B9%90%E6%8E%A8%E8%8D%90/">挖好坑有空再填</a>。</p> <p>游戏……至于游戏,Steam Deck 还给哥们之后就没玩过 PC 游戏,<strong>但手游还在各种上班</strong>。主要是 ZZZ、学马仕、永劫无间手游,还有其他零零碎碎的有空就签一下到,上班感非常强烈。但没办法,<strong>ADHD</strong> 总需要些乱七八糟的东西杀杀时间。</p> <p>这里想特别提一下永劫无间,一个博弈型动作游戏。我这种又菜又爱玩的人对它,真是既爱又恨。段位上去了被压着打,打出一种毫无希望、无能狂怒的感觉。你面对的对手拥有你自知永远无法企及的反应速度,精准回避,连招永不失手,振刀博弈也超强,那种感觉你懂吗?多人竞技你还能赖一下队友,但是这种多半 1 v 1 的游戏,菜就是菜,或许多练,也没有用。如果说提不起玩游戏的兴趣,算是赛博阳痿,那这种一打就觉得自己菜的感觉,大概就是赛博早泄了吧。</p> <h2>薅羊毛</h2> <p>今年京东、饿了么(现在已经是淘宝闪购)、美团打外卖大战,消费者默默薅了半年羊毛,后面基本打完了,但是我的外卖点咖啡习惯也养成了,真是顶级阳谋。</p> <p>于是我就往别的方向找补,在小红书发现了<strong>不少银行羊毛</strong>。例如建行,经常能抽到立减金,信用卡开户也送了几百块,又连带着看到<strong>云闪付</strong>又有各种羊毛,导致为了薅羊毛在云闪付充了几百块话费😂这一连串操作都让我薅出爽感了,现在装了一堆银行 App,在小红书看到羊毛就去点一下,真是觉得自己有点搞笑了。</p> <h2>失眠仍是难题</h2> <p>最近几年的失眠还没完美解决,不过年末这段时间似乎靠镁+鱼油压制了,鱼油不太确定,至少<strong>镁</strong>对我是真的有用,推荐给失眠的朋友们试试。</p> <p>原因其一是咖啡因。上面也说习惯已经养成,<strong>奶茶咖啡天天忍不住喝</strong>。放在五六年前,这是我完全无法想象的。还在广发上班的时候就看到一老姐每天点咖啡,当时可真是觉得稀奇,现在我也成奇人了。下午太漫长,没有咖啡奶茶怎么过?一杯冰咖啡可以喝一个小时,之后被咖啡因冲昏头的感觉会让我上班更带劲……完了,怎么描述起来跟 Drag 一样了😂</p> <p>问题就是,太晚摄入咖啡因,或是摄入量太大,即使一点半开喝,依然会睡不着。又菜又爱玩的另一个典例。</p> <p>除了咖啡因,一点小小的「抑郁」也成了我的日常。这两年要处理的事情「成人」起来了,总觉得要耗费很多思维带宽处理这些问题。一直以来,各种媒体都在暗示年纪大了就会麻木,我是开始感受到一点了。但同时我又在想,就该这样吗?公司里一个精神小妹,每天快快乐乐,跟老公恩恩爱爱,物欲低,快乐阈值也低,真是快乐人类的典范。所以快乐是不是也没那么难呢。</p> <h2>个人定位</h2> <p>今年也第三人称地感受了一下自己这个人。</p> <p>其一,今年发现自己毒舌程度越来越高,越熟的人越狠,这明明是我很讨厌的行为,但是莫名其妙地就会这么做。</p> <p>其二,今年正式被工友们评为<strong>网络 E 人</strong>,有没有可能我在线下也 E 一点呢,总之先把话说利索吧,一个练习方向可能是……使用语音输入法?(广告位招租)</p> <h2>展望</h2> <p>完了,不太妙,今年写完总结,感觉也跟去年一样——<strong>有点麻木</strong>,甚至感觉有过之而无不及。</p> <p>所以明年最重要的目标,大概是<strong>变得快乐起来</strong>吧。</p> <p>接着是以下次要目标:</p> <ul> <li><strong>学会止盈</strong>,不要贪心,试试真的止盈一次,看看感受是怎样的</li> <li>挖掘点实惠的,又能提高生活质量的营养补充剂,持续补镁,尝试着解决失眠问题</li> <li>明年继续<strong>加强大模型应用</strong>,不过再没什么突破的话明年应该瓶颈期,AI 泡沫可能也要破了吧</li> <li><strong>多说点话</strong>(但不要太毒舌),尝试用说代替写,万一真被优化了也不怕面试憋不出几句话呀</li> </ul> 小猫都能懂的大模型原理 6 - 模型优化https://ssshooter.com/kitten-large-language-model-6/https://ssshooter.com/kitten-large-language-model-6/探索大语言模型前沿技术:推理能力训练、超长上下文处理、性能优化策略、MoE 等先进技术。了解LLM最新发展趋势和未来方向。Thu, 25 Dec 2025 08:00:51 GMT<p><img src="https://img.ssshooter.com/img/cats/llm6.jpg" alt="小猫都能懂的大模型原理 6 图片来源 pixabay.com" /></p> <blockquote> <p>本文旨在用简单易懂的语言解释大语言模型的基本原理,不会详细描述和解释其中的复杂数学和算法细节,希望各位小猫能有所收获 🐱</p> </blockquote> <h2>蒸馏</h2> <p>简单来说,<strong>大模型蒸馏(Model Distillation)</strong> 就是一种“老师带学生”的技术手段。</p> <p>它的核心目的是:把一个超大模型(老师)的能力,“传授”给一个较小的模型(学生),让小模型在变小、变快、变便宜的同时,还能保留大模型的大部分能力。下面稍微具体说说怎么做到蒸馏。</p> <p>可以想象,现在头部模型 800GB - 1TB 的硬盘文件大小,里面有很多冗余信息。对于小模型不需要那么面面俱到,所以通过大模型对小模型某些领域的指导,即使小模型没有那么大的规模,也能让小模型在该领域效果足够好。蒸馏后的模型只有 2GB ~ 4GB 的大小,完全可以在消费级电脑和手机上使用。</p> <p>虽说参数量能大幅减少,但是蒸馏模型遇到老师没教的知识,回答就会十分滑稽。</p> <p><img src="https://img.ssshooter.com/img/kitten-llm/llama-3.2-3B.jpg" alt="林黛玉是谁" /></p> <p>顺带一提,类似的方法不只是可以用于大模型带小模型,也可以用于两个大模型之间左脚踩右脚互相进步,例如 2025 年春节爆火的 Deepseek R1 就是这么诞生的,他就是 V3 跟 R1 原型机互相成就,通过评分系统共同进步的典范。</p> <p>蒸馏可以降低模型参数量且保留较高的智能,但优化方式还有不少。</p> <h2>量化</h2> <p>根据前面的章节我们也知道,每个 Token 都会被映射到很高维度的数组,这个数组里都带有精度很高的小数,聪明的小猫就会想到,哇小数那么难算,能不能砍掉几位?还真能。</p> <p>量化就是通过降低模型的精度节省推理需要的显存,提高计算速度。</p> <p>标准推理状态使用 <code>FP16</code> 的精度,<a href="https://arxiv.org/abs/2210.17323">研究表明</a> 4-bit 量化到 <code>INT4</code> 也能保持模型大致能力,且节省很多内存空间。</p> <p>打开 <a href="https://huggingface.co/meta-llama/Llama-3.2-3B-Instruct/tree/main">Hugging Face 的 Llama-3.2-3B-Instruct</a> 仓库我们可以看到 Llama-3.2-3B 占硬盘大概 6.5G,而 <a href="https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF">4 bit 量化后的版本</a>仅 2.02G。</p> <p><img src="https://img.ssshooter.com/img/kitten-llm/quantization.jpg" alt="量化结果" /></p> <p>从原理上看,量化之后的回答越长,能力应该是越差的,毕竟计算的次数越多,丢失精度造成的误差就会不断扩大。</p> <h2>超长上下文</h2> <p>上下文限制是大语言模型很致命的缺点,在前面所说的注意力矩阵中,默认整个上下文的所有 Token 都会对当前 Token 的真正含义造成影响,也就是如果上下文长度到了 $256k$,这个矩阵的大小就是 $256k ^ 2$,完整计算整个矩阵需要的计算资源会高得十分离谱。</p> <p>但是我们现在可以看到有的离谱模型上下文能到 1M,很明显,大佬们会想出各种优化方法解决超长上下文的问题,例如不要全量计算上下文矩阵:<strong>局部窗口注意力</strong></p> <p><img src="https://img.ssshooter.com/img/kitten-llm/window-attention.jpg" alt="量化结果" /></p> <p>遇到内存不够的情况,也有<strong>环形注意力</strong>可以结合多张显卡共同计算一段上下文。这个环形注意力听起来很酷炫,简单来说就是一个上下文矩阵分多台机计算,设计一个算法只要按顺序环形交换计算结果就能并行运行超大上下文。</p> <p>环形注意力虽然没有降低算力要求,但是解除了单卡显存限制,也就是只要你付得起钱,上下文很长也照样给你算(但是,真的十分贵呀)。</p> <h2>MoE</h2> <p>Mixture of Experts(混合专家)是一种减少计算量的优化方法。具体来说,MoE 通过<strong>门控网络</strong>选择性地激活部分专家,从而减少计算量。</p> <p><img src="https://img.ssshooter.com/img/kitten-llm/deepseek-MoE.jpg" alt="MoE" /></p> <p>以上的 Deepseek V2 的 MoE 架构,<strong>注意 MoE 是在 FFN 层里</strong>。</p> <p>假设我们输入 Token 的向量表示 $x$(维度为 $d$),以及 $N$ 个专家(Experts)。门控网络内部有一个可学习的权重矩阵 $W_g$(维度为 $d \times N$)。它将输入 $x$ 与权重矩阵相乘,计算出该 Token 与每个专家的“匹配度”。</p> <p>然后重点来了,MoE 会根据匹配度只激活 <strong>Top-k</strong> 个专家,计算专家矩阵之后再对 Top-k 个专家的输出加权求和,得到最终的输出。例如 Deepseek V3 总参数 671B,激活的专家参数仅 37B,计算量大幅降低。</p> <p>你或许会好奇 MoE 怎么知道分给哪个专家,其实这同样是通过计算损失反向传播,让门控网络自己学习分配目标。这里还有一点需要注意,如果完全放任不管,门控可能会把所有 Token 都分给少数的专家,那其他专家就等于废掉了,所以实现 MoE 还要注意<strong>负载均衡</strong>。</p> <p>以上就是优化大模型的几种常见方法,或许后面会再开一期优化总结,但是下个话题先定为多模态,敬请期待 🐱</p> <h2>参考资料</h2> <ul> <li><a href="https://magazine.sebastianraschka.com/p/the-big-llm-architecture-comparison">The Big LLM Architecture Comparison</a></li> <li><a href="https://arxiv.org/pdf/1904.10509">Generating Long Sequences with Sparse Transformers</a></li> <li><a href="https://arxiv.org/pdf/2004.05150">Longformer: The Long-Document Transformer</a></li> <li><a href="https://arxiv.org/pdf/2310.01889">Ring Attention with Blockwise Transformers for Near-Infinite Context</a></li> <li><a href="https://arxiv.org/pdf/2405.04434">DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model</a></li> </ul> useEffect 清除计划https://ssshooter.com/eliminate-useEffect/https://ssshooter.com/eliminate-useEffect/useEffect 是 React 的 Evil。本文反向思考,总结仅有的两种合理使用场景:生命周期副作用与响应异步 Props。提供 useEffectEvent、useImperativeHandle 替代方案,附完整代码重构示例,帮你系统性消除项目中多余的 useEffect。Mon, 22 Dec 2025 08:45:00 GMT<p>如果说 <code>eval</code> 是 JavaScript 的 Evil,那么 useEffect 就是 React 的 Evil。</p> <p><code>useEffect</code> 至少有三宗罪:</p> <ol> <li>在其中使用 <code>setState</code> 会引起重复渲染</li> <li>缺乏注释的 <code>useEffect</code> 往往让人意义不明</li> <li><code>useEffect</code> 意味着让人苦恼的依赖管理</li> </ol> <p>我直接给出一个暴论:尽量清除你项目中的 <code>useEffect</code>。</p> <p>下面我不会讲到底啥时候不该用 <code>useEffect</code>,因为 <code>useEffect</code> 本来™的绝大多数情况就不该用,一一列举这些反面例子就是浪费时间。</p> <p>因此反向思考,我下面主要讲到底什么情况必须要用 <code>useEffect</code>,再伴以少量有代表性的反面教材。</p> <h2>响应组件挂载</h2> <p>要说 <code>useEffect</code> 无法清除的情况,首先肯定是作为<strong>组件挂载的钩子</strong>,比如说:</p> <pre><code>useEffect(() =&gt; { document.title = "My homepage"; return () =&gt; {}; }, []); </code></pre> <p>这是绝对无法替代的使用场景,在组件挂载时,如果你需要操作 <code>document</code> 或者其他无法通过 React jsx 修改的对象,就必须使用 <code>useEffect</code>。</p> <p>类似情况还有这些:</p> <ul> <li>订阅/取消订阅(WebSocket、EventSource)</li> <li>定时器的设置与清理</li> <li>ResizeObserver / IntersectionObserver 等浏览器 API</li> </ul> <p>最理想的情况下,<strong>你需要让事件驱动一切</strong>。例如在 jsx 里面写 <code>onClick</code>、<code>onSubmit</code> 等等,然后在事件处理器里面写你的逻辑。</p> <p>本质上,<strong>依赖为空的</strong> <code>useEffect</code> 其实也可以理解为一个事件,触发事件的是“组件挂载”这个动作,你无法用 <code>onXxx</code> 的方法实现,所以只能依赖 <code>useEffect</code>。</p> <p>下面是一个<strong>典型反例</strong>,用户选择了一个下拉选项,你就可以用 <code>onChange</code> 事件处理器来处理,而不是 <code>useEffect</code>。</p> <p><strong>Before:</strong></p> <pre><code>useEffect(() =&gt; { const defaultModel = getDefaultModel(); if (defaultModel &amp;&amp; !selectedModelId) { setSelectedModelId(defaultModel.id); } }, [getDefaultModel, selectedModelId]); // Update config when model selection changes useEffect(() =&gt; { const selectedModel = models.find((m) =&gt; m.id === selectedModelId); if (selectedModel) { setAiProvider(selectedModel.provider); setApiKey(selectedModel.apiKey); setApiUrl(selectedModel.apiUrl); setModel(selectedModel.model); setTemperature(selectedModel.temperature); } }, [selectedModelId, models, setAiProvider, setApiKey, setApiUrl, setModel, setTemperature]); </code></pre> <p>上面代码就是一个经典误用,监听 <code>selectedModelId</code> 去更新其他状态,事实上这完全可以在用户选择模型的事件中完成。</p> <p><strong>After:</strong></p> <pre><code>const selectedModel = models.find(m =&gt; m.id === selectedModelId) const handleModelChange = useCallback((id: string) =&gt; { setSelectedModelId(id) const model = models.find(m =&gt; m.id === id) if (model) { setAiProvider(model.provider) setApiKey(model.apiKey) setApiUrl(model.apiUrl) setModel(model.model) setTemperature(model.temperature) } }, [models, setAiProvider, setApiKey, setApiUrl, setModel, setTemperature]) useEffect(() =&gt; { const defaultModel = getDefaultModel() if (defaultModel &amp;&amp; !selectedModelId) { handleModelChange(defaultModel.id) } }, [getDefaultModel, selectedModelId, handleModelChange]) </code></pre> <p>这样,你就可以在用户选择模型的事件中直接调用 <code>handleModelChange</code>,在初始化时也可以调用同一个函数。</p> <p>然而这不是一个完美状态,因为你可以见到,为了让 <code>useEffect</code> 用上这个事件,你还得把 <code>handleModelChange</code> 用 <code>useCallback</code> 包裹起来,<strong>React 的依赖是一个传染病</strong>,这十分致命。</p> <p>还好,在 React 19.2 之后,你可以使用 <code>useEffectEvent</code> 来避免这个问题,这意思就是,用 <code>useEffectEvent</code> 创造一个 <strong>Effect 事件</strong>,让它适配 <code>useEffect</code>,而不需要依赖管理。</p> <p><strong>Better:</strong></p> <pre><code>const handleModelChange = (id: string) =&gt; { setSelectedModelId(id) const model = models.find(m =&gt; m.id === id) if (model) { setAiProvider(model.provider) setApiKey(model.apiKey) setApiUrl(model.apiUrl) setModel(model.model) setTemperature(model.temperature) } } const onInit = useEffectEvent(() =&gt; { const defaultModel = getDefaultModel() if (defaultModel &amp;&amp; !selectedModelId) { handleModelChange(defaultModel.id) } }); useEffect(() =&gt; { onInit() }, []) </code></pre> <p><code>useEffectEvent</code> 直接忽略依赖,对于里面的响应式变量无论何时都能获取到最新值。这样就能比较优雅地清空一堆依赖了!</p> <p>注意:上述场景仅限于在挂载时就能获取到所有所需变量的情况,如果 <code>models</code>/<code>selectedModelId</code> 是后到的,它就会错过初始化。与官网给出的在挂载时添加 websocket 响应不太一样。</p> <p>实在是一个史诗级更新!不过这很难称得上是一种称赞,说得难听点的话这只不过是 React 团队给之前的设计擦屁股而已……写好 react 这些依赖你可能觉得自己成为了内行人而沾沾自喜,殊不知这本来<strong>可能</strong>就可以更简单地实现这种逻辑……</p> <h2>响应异步数据变化</h2> <p>但是 React 的世界倒也没有这么简单,有的情况确实没有“用户触发的事件”,也没有“Effect 事件”,<strong>那就是响应 <code>props</code> 的变化</strong>。</p> <p>例如一个组件需要根据 props 的变化来执行某个操作,具体一点,需要收集 props 来请求数据,而且最致命的是,这些 props 本身也是异步获取的,这意味着你无法在子组件直接用 <code>useEffect(() =&gt; {}, [])</code> 简单实现,例如:</p> <pre><code>function UserPermissions({ userId, roleId }) { const [permissions, setPermissions] = useState(null); // 响应 props 变化 useEffect(() =&gt; { if (!userId || !roleId) return; fetchUserPermissions(userId, roleId).then((data) =&gt; setPermissions(data)); }, [userId, roleId]); if (!permissions) return &lt;div&gt;Loading permissions...&lt;/div&gt;; return &lt;div&gt;Permission Level: {permissions.level}&lt;/div&gt;; } function ParentComponent() { const [userId, setUserId] = useState(null); const [roleId, setRoleId] = useState(null); useEffect(() =&gt; { fetchCurrentUserId().then((id) =&gt; setUserId(id)); fetchUserRole().then((role) =&gt; setRoleId(role)); }, []); return &lt;UserPermissions userId={userId} roleId={roleId} /&gt;; } </code></pre> <p>当你把 <code>UserPermissions</code> 当成一个纯展示组件,只要 <code>userId</code> 和 <code>roleId</code> 一变化,<code>UserPermissions</code> 就需要自动更新。</p> <p>为了实现逻辑分离,不关心里面的逻辑,这种情况你也是无法去除 <code>useEffect</code> 的。即使你使用 <code>useQuery</code> 之类的数据获取库,也无法避免你<strong>本质上</strong>是在使用 <code>useEffect</code>。</p> <p>针对这种情况,其实 <code>useEffect</code> 不是无可避免的。如果子组件的数据获取是同步的,你可以直接在渲染时计算或者借助 <code>useMemo</code> 缓存,一旦是异步获取,就没有办法了。</p> <p>但是这里我不推荐用奇技淫巧消除 <code>useEffect</code>,因为不关心组件内部实现,通过 <code>props</code> 控制组件渲染也是一种比较优雅的做法。你只需要在 <code>useEffect</code> 里面用<strong>注释明确说明</strong>本次 <code>useEffect</code> 的<strong>使用意图</strong>即可。</p> <p>这是把<strong>复杂度分离</strong>了,子组件并不需要关心数据到达的时机,只要知道,数据到齐了就可以行动。不过缺点是,如果 <code>userId</code> 和 <code>roleId</code> 是分批到达,那么子组件会存在<strong>重复渲染和竞态</strong>的情况,这个时候使用 <code>Promise.all</code> 是一个不错的优化方式。</p> <p>下面我给出一个结合 <code>Promise.all</code> 和 <code>useEffect</code> 消除的例子,只要让子组件借助 <code>useImperativeHandle</code> 给出更新方法让父组件调用:</p> <pre><code>const UserPermissions = forwardRef((props, ref) =&gt; { const [permissions, setPermissions] = useState(null); useImperativeHandle(ref, () =&gt; ({ fetchData: (currentUserId, currentRoleId) =&gt; { if (!currentUserId || !currentRoleId) return; fetchUserPermissions(currentUserId, currentRoleId).then((data) =&gt; setPermissions(data)); }, })); if (!permissions) return &lt;div&gt;Loading permissions...&lt;/div&gt;; return &lt;div&gt;Permission Level: {permissions.level}&lt;/div&gt;; }); function ParentComponent() { const [userId, setUserId] = useState(null); const [roleId, setRoleId] = useState(null); const permissionRef = useRef(); useEffect(() =&gt; { Promise.all([fetchCurrentUserId(), fetchUserRole()]).then(([id, role]) =&gt; { setUserId(id); setRoleId(role); permissionRef.current?.fetchData(id, role); }); }, []); // 后续更新 const handleUserSwitch = (newId) =&gt; { setUserId(newId); permissionRef.current?.fetchData(newId, roleId); }; return ( &lt;&gt; &lt;button onClick={() =&gt; handleUserSwitch("user_999")}&gt;Switch User&lt;/button&gt; &lt;UserPermissions ref={permissionRef} /&gt; &lt;/&gt; ); } </code></pre> <p>这样一来,你就可以通过父组件的事件消除子组件的 <code>useEffect</code>,这说明,<strong>事件触发是可以传递的</strong>。</p> <p>再举个例子,例如<strong>数据回填</strong>,回填后发现某一个数据变化了,就需要修改另一个数据。这看似没有“用户触发的事件”,但是没关系,如上面所说,你可以创造一个函数让父组件事件触发。<strong>在这个例子里我个人是更推荐使用 <code>useImperativeHandle</code> 的方式来实现</strong>。</p> <pre><code>import React, { useImperativeHandle, forwardRef } from "react"; import { Form, Select, Checkbox } from "antd"; const getDerivedPermissions = (role, currentPermissions = []) =&gt; { if (role === "admin") { return [...new Set([...currentPermissions, "manage_system"])]; } return currentPermissions.filter((p) =&gt; p !== "manage_system"); }; const UserFormAfter = forwardRef((props, ref) =&gt; { const [form] = Form.useForm(); const currentRole = Form.useWatch("role", form); useImperativeHandle(ref, () =&gt; ({ fillData: (apiData) =&gt; { const safePermissions = getDerivedPermissions(apiData.role, apiData.permissions); form.setFieldsValue({ ...apiData, permissions: safePermissions, }); }, })); const handleRoleChange = (newRole) =&gt; { const currentPermissions = form.getFieldValue("permissions"); const nextPermissions = getDerivedPermissions(newRole, currentPermissions); form.setFieldsValue({ permissions: nextPermissions }); }; return ( &lt;Form form={form} layout="vertical"&gt; &lt;Form.Item name="role" label="Role"&gt; &lt;Select onChange={handleRoleChange} options={[ { value: "user", label: "User" }, { value: "admin", label: "Admin" }, ]} /&gt; &lt;/Form.Item&gt; &lt;Form.Item name="permissions" label="Permissions"&gt; &lt;Checkbox.Group&gt; &lt;Checkbox value="read_basic"&gt;Read Basic&lt;/Checkbox&gt; &lt;Checkbox value="manage_system" disabled={currentRole === "admin"}&gt; Manage System (Locked for Admin) &lt;/Checkbox&gt; &lt;/Checkbox.Group&gt; &lt;/Form.Item&gt; &lt;/Form&gt; ); }); // 使用示例 export default function Page() { const formRef = React.useRef(null); React.useEffect(() =&gt; { // 模拟数据回填 setTimeout(() =&gt; { formRef.current?.fillData({ role: "admin", permissions: ["read_basic"] }); }, 500); }, []); return &lt;UserFormAfter ref={formRef} /&gt;; } </code></pre> <blockquote> <p>虽然 <code>useImperativeHandle</code> 能消除 <code>useEffect</code>,但它将<strong>数据驱动</strong>变成了<strong>过程驱动</strong>。 如果你的组件仅仅是为了展示(如一个纯图表组件、列表组件),请依然优先使用 Props 传递数据。</p> </blockquote> <p>不止异步的 <code>props</code> 变化,所有异步数据变化都有上面的问题。如果你用 <code>useQuery</code> 封装了一次请求,那么你想根据获得的 <code>data</code> 进一步进行异步操作就需要用到 <code>useEffect</code>(不过 tanstack 提供比较优雅的 <code>enabled</code> 参数)。</p> <p>是的,“封装”就是一道天堑,把两次异步操作分离开,你就只能为此多做一次 <code>useEffect</code> 了,或许以后 React 团队会给出更好的方案吧,谁知道呢?</p> <h2>总结</h2> <p><strong>除了下面两种情况,你代码里剩下的所有 <code>useEffect</code> 都应该被当场处决:</strong></p> <ul> <li>真正的组件生命周期副作用 <ul> <li>操作 DOM、第三方库初始化、订阅/取消订阅、定时器等</li> <li>这些操作无法通过 JSX 或事件处理器完成,useEffect 是唯一选择</li> </ul> </li> <li>响应异步 Props 变化的场景 <ul> <li>子组件需要根据父组件传入的异步数据执行操作</li> <li>但即使是这种情况,也常常可以通过 useImperativeHandle 重构为事件驱动模式</li> </ul> </li> </ul> <h2>彩蛋</h2> <p>如果你不得不使用 <code>useEffect</code>,给你三个建议:</p> <ul> <li>一个 effect 只做一件事</li> <li>必须写注释说明触发源和意图</li> <li><a href="https://react.dev/learn/synchronizing-with-effects">注意竞态</a></li> </ul> 小猫都能懂的大模型原理 5 - 后训练https://ssshooter.com/kitten-large-language-model-5/https://ssshooter.com/kitten-large-language-model-5/大语言模型后训练完整指南:SFT监督微调、RLHF人类反馈强化学习、Reasoning 推理能力训练等技术。详解如何将基础大模型训练成对话助手,提升模型实用性、安全性和推理能力。Mon, 08 Dec 2025 05:40:13 GMT<p><img src="https://img.ssshooter.com/img/cats/llm5.jpg" alt="小猫都能懂的大模型原理 5 图片来源 pixabay.com" /></p> <blockquote> <p>本文旨在用简单易懂的语言解释大语言模型的基本原理,不会详细描述和解释其中的复杂数学和算法细节,希望各位小猫能有所收获 🐱</p> </blockquote> <p>GPT 训练完后并不能直接与用户流畅地聊天,就像是一个只会背书、不擅长与人交往的 Nerd 🤓。你说啥呢,他就接着从他大脑里想到的都一股脑说出来,接在你后面,情商约等于 0。</p> <p>chatGPT 之所以叫 chatGPT,是因为它在 GPT 的基础上做了 chat 的<strong>后训练</strong>。</p> <h2>SFT</h2> <p>对话的训练素材大概长下面这样:</p> <p><img src="https://img.ssshooter.com/img/kitten-llm/instruction-token.jpg" alt="instruction token" /></p> <p>通过特定的 Token 标记对话的格式,然后把这些经过审阅的对话喂给模型即可。</p> <p>在喂对话前,还需要注意整理数据和调整超参数:</p> <ul> <li>数据清洗、过滤(去除垃圾、泄密、违法内容)</li> <li>样本平衡(不同任务/风格的比例)</li> <li>学习率、训练步数等超参的控制,避免遗忘原有能力或过拟合</li> </ul> <p>这个步骤也叫 <strong>SFT</strong>,全称 Supervised Fine-Tuning(监督式微调)。</p> <p>Hugging Face 是一个找 AI 开源资源的好地方,这里也有对话训练集:https://huggingface.co/datasets/openchat/ultrachat-sharegpt</p> <p>除了对话的 SFT,厂商可能还会进行<strong>工具调用</strong>(function calling / MCP)、多轮任务规划、搜索结果整合等子技能,这些微调对 AI Agent 的实现极为重要。</p> <p>除了大模型出厂前的 SFT,厂商也提供出厂后微调的服务,当然你也可以自己微调开源模型。</p> <p>举个例子:如果你原创了一门计算机语言,想训练一个专门帮你的新语言的助手,你可以在通用大模型的基础上,用大量的编程相关数据进行微调,这样模型就会更擅长写对应语言的代码、调试、解释代码等任务。</p> <p>微调的好处是成本相对较低,不需要从头训练模型,就能在特定领域获得很好的效果。</p> <h2>RLHF</h2> <blockquote> <p>RL(强化学习):智能体通过与环境“互动试错”,利用“奖励反馈”来学习如何做出能实现“长期利益最大化”的决策。</p> </blockquote> <p>再下一步,来到 <strong>RLHF(Reinforcement Learning from Human Feedback)</strong>,解决“模型会说话,但不一定合人类偏好”的问题,用人类偏好信号做强化学习,把模型往“更符合人类期望”的方向推,从而实现 <strong>Alignment</strong>(例如禁止黄赌毒啦,不要鼓励自杀啦,还有一系列 ZZZQ)。</p> <p>先让人类对多条模型回答做偏好排序,训练一个<strong>奖励模型(Reward Model)</strong> 去拟合这种偏好;再用强化学习(常见是 PPO)让生成模型最大化奖励,这是很常见的一种通过模型强化另一个模型的方法。后面章节会讲到 路人皆知的 Deepseek R1,他的训练方式更是左脚踩右脚。</p> <p>简单来说就是循环下面三个步骤:</p> <ul> <li>自我生成: 原始模型生成一个回答。</li> <li>裁判打分: 刚才训练好的“奖励模型”给这个回答打一个分数(Scalar Reward)。</li> <li>参数更新: 如果分数高,算法(PPO)就会<strong>调整模型的参数</strong>,鼓励它以后多生成类似的回答;如果分数低,就抑制这种生成方式。</li> </ul> <p>可能这时候就有小猫要问了,咋一句话的评分能影响到逐个 Token 生成的权重呢?Emmm,这个问题还是挺复杂的,但是知道像 PPO 这样的算法,会用这个总分来估算每一步动作的“好坏”(优势),从而对每个 token 的概率做梯度更新了。</p> <p>来一个 RLHF 流程图方便各位小猫理解:</p> <p><img src="https://img.ssshooter.com/img/kitten-llm/RLHF.jpg" alt="RLHF 流程" /></p> <p>另外,现在也有一批“不要 RL 的 RLHF 替代品”,比如 DPO、IPO、ORPO 等,它们直接用人类偏好数据来训练,不再显式训练奖励模型和跑 PPO,但目标还是一样:让模型更符合人类喜欢的回答方式。</p> <h2>Reasoning</h2> <p>实现 Reasoning 的方式应该很多,例如与 RLHF 类似,你可以鼓励模型尽量使用逐步解题的方式回答问题,并把解题步骤放在 <code>&lt;think&gt;</code> 标签里,答案放在 <code>&lt;answer&gt;</code> 标签里,那它就可以学会逐步解题。</p> <p>Deepseek 论文提到,通过一个叫 <strong>GRPO</strong> 的训练策略,通过一些固定的判断逻辑对输出结果进行评分。结果对就加分,格式对也加分,然后同一个 prompt 生成多个回答,奖励平均分以上的回答,这样就不需要额外训练一个奖励模型,只要设计好规则化奖励函数即可,节省掉传统 RLHF 里的花费高昂的奖励模型。</p> <p><img src="https://img.ssshooter.com/img/kitten-llm/ds-r1-zero.jpg" alt="ds R1 zero 的回答长度逐渐变长" /></p> <p>通过不断循环上述过程进行训练,模型会自发地让思考过程变长,为什么呢,因为经过长思考得到正确答案的概率更大,毕竟思考越长,它自己得到的信息就越多。最后,模型会自动产生“等等,我似乎错了”之类的惊喜时刻,这是属于 Reasoning 的“涌现”。</p> <p>下一章将会介绍更多大模型优化策略,敬请期待 🐱</p> <h2>参考资料</h2> <ul> <li><a href="https://tiktokenizer.vercel.app/">Tiktokenizer</a></li> <li><a href="https://magazine.sebastianraschka.com/p/understanding-reasoning-llms">Understanding Reasoning LLMs</a></li> <li><a href="https://arxiv.org/abs/2501.12948">DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning</a></li> </ul> 小猫都能懂的大模型原理 4 - 大语言模型架构https://ssshooter.com/kitten-large-language-model-4/https://ssshooter.com/kitten-large-language-model-4/详解大语言模型完整架构:Transformer层、残差连接、层归一化、前馈神经网络等核心组件。涵盖训练流程、参数优化、推理过程,以及如何构建高性能LLM系统。Thu, 04 Dec 2025 02:16:52 GMT<p><img src="https://img.ssshooter.com/img/cats/llm4.jpg" alt="小猫都能懂的大模型原理 4 图片来源 pixabay.com" /></p> <p><img src="https://img.ssshooter.com/img/kitten-llm/llm.jpg" alt="llm 架构" /></p> <h2>整体结构</h2> <p>在经典的架构中,数据经过注意力模块后会进行<strong>归一化</strong>(LayerNorm)。不过现在很多先进的大模型(如 Llama)为了更稳定,会把归一化放在注意力模块之前。</p> <p>深层网络里,各层输出的尺度和分布会不断漂移,导致后续层“吃进去”的数值忽大忽小、训练变得不稳定。归一化就是把每一层喂给下一层的数值,拉回到一个稳定、可学习的范围(更强调相对大小而不是绝对数值),从而让梯度更稳定(不会爆、不易消)。</p> <p>然后进入到<strong>前馈神经网络</strong>模块(图中的 Feed forward),就是最开始提到的那种神经网络。在这里会有<strong>隐藏层</strong>对向量维度升级,从而学习更多隐藏的内容,最后降回输入维度。</p> <p>另外可以注意到,侧面有一条线跳过部分模块直接连到后面的加号,这被称为<strong>残差连接</strong>。</p> <p>$$ y = x + F(x) $$</p> <p>其中:</p> <ul> <li>$x$:上一层的输出;</li> <li>$F(x)$:这一层学习到的新信息;</li> <li>$y$:二者相加后的结果。</li> </ul> <p>残差连接带来以下好处:</p> <ol> <li><strong>防止信息丢失</strong>:原始输入 (x) 直接保留并传递;</li> <li><strong>防止梯度消失</strong>:反向传播时,梯度能直接穿过“+x”那条通道;</li> <li><strong>让训练更容易</strong>:每层只需“微调”已有知识,而不是重学一遍。</li> </ol> <p>Transformer 层本身也不止一个,最小的 GPT2 都有 12 个 Transformer 层。</p> <p>所以整个大语言模型的架构差不多就是:</p> <pre><code>输入文本 → 分词 → 向量化 (Token + 位置编码) → 经过 N 层 Transformer Block(注意力 + 前馈) → 层归一化 + 线性输出层 → 预测下一个 token 的概率分布 </code></pre> <p>(注意:具体的归一化位置和顺序在不同模型中可能略有不同。)</p> <p>在<strong>生成(推理)阶段</strong>,下一个词的时候就会涉及到“温度 (Temperature)”和“Top-k / Top-p”参数,修改这些参数可以让生成下一个词的可选值更丰富,生成更天马行空的文本。</p> <p>当然这只是一个实现方式,不同的模型会尝试排列组合、或者创新地加入其他模块,尝试优化模型的性能和上下文。</p> <h2>训练</h2> <p>就如之前所说的,你只要把现有的文本拆开,喂给模型,经过<strong>反向传播</strong>不断调整模型参数,最后它就自然能猜到下个字是什么。</p> <p>其中涉及的参数包括:</p> <ul> <li>梯度下降算法:Adam、AdamW、RAdam 之类的;</li> <li>批量(Batch)训练:一次喂多条样本,让显卡更高效;</li> <li>学习率(Learning rate):调节“改参数的步伐”,太大容易崩,太小学不动;</li> </ul> <p>在训练过程中,你可以把参数保存下来,做个 checkpoint,这样就不必一次跑完所有训练,也不怕越练越差,一旦练坏了,只要回滚到上个 checkpoint 就好了。</p> <p>现在除了头部大公司基本上不会从 0 开始训练,因为花费的时间和算力都太多了。作为独立开发者,这注定是一个你知道原理、会写代码,但是自己就是无法实现的领域。</p> <p>下一章会介绍怎么在一个 GPT 的基础上继续做后训练,敬请期待🐱</p> <h2>为什么这样能行</h2> <p>这是一个哲学问题,什么算是“能行”?LLM 是真的学会了什么,还是单纯的概率模型。</p> <p>这让我想起高中的一个梗……数学强解法,什么数学强解物理、数学强解生物,而 LLM,就是用数学强解语言。注意力机制每一步都有其道理,但是我觉得没有人从一开始就觉得这样能行,要不怎么大家都说大模型是“大力出<strong>奇迹</strong>”呢,是的,这本身就是一个意外的奇迹。</p> <p>如果要反过来解释为什么能行,只能说大模型这个实验证明了语言可以被数学强解。</p> <h2>参考资料</h2> <ul> <li><a href="https://book.douban.com/subject/37305124/">从零构建大模型</a></li> <li><a href="https://bbycroft.net/llm">LLM 架构</a></li> </ul> 小猫都能懂的大模型原理 3 - 自注意力机制https://ssshooter.com/kitten-large-language-model-3/https://ssshooter.com/kitten-large-language-model-3/深入解析Transformer自注意力机制原理:通过QKV计算、多头注意力、残差连接等技术,让大语言模型能够理解长距离依赖关系。包含详细的数学公式和实例讲解。Tue, 02 Dec 2025 10:12:47 GMT<p><img src="https://img.ssshooter.com/img/cats/llm3.jpg" alt="小猫都能懂的大模型原理 3 图片来源 pixabay.com" /></p> <blockquote> <p>本文旨在用简单易懂的语言解释大语言模型的基本原理,不会详细描述和解释其中的复杂数学和算法细节……但是本章还是一点点线性代数基础……希望各位小猫能有所收获 🐱</p> </blockquote> <p>Transformer 的核心创新就是自注意力机制,如果忽略数学层面的问题,其实不难理解。</p> <p>过去的深度学习框架对文字的处理,没有考虑到(大范围的)上下文,例如 RNN 就会一直循环计算前面的文字的影响力,但是距离一长,前面内容的记忆会丢失得比较多,而且 RNN 这个<strong>串行</strong>逻辑也跑不快。</p> <h2>自注意力</h2> <p>自注意力的突破点就在这里,它让<strong>整个上下文里的 Token 互相理解</strong>,计算过程是可以<strong>并行</strong>进行的。</p> <p>之前说 GPT2 一个词维度有七百多,在下面这个例子里面,我们假设一个词维度只有 3。首先在词向量的基础上加上同样维度的位置向量,然后我们就要开始子注意力里面最精彩的 QKV 计算了。</p> <p><img src="https://img.ssshooter.com/img/kitten-llm/qkv.jpg" alt="" /></p> <p>我们从 Token 的向量开始:</p> <ul> <li>“Your” → $x^{(1)} = [0.4, 0.1, 0.8]$</li> <li>“journey” → $x^{(2)} = [0.5, 0.8, 0.6]$</li> <li>“step” → $x^{(T)} = [0.0, 0.8, 0.5]$</li> </ul> <p>然后,每个 $x^{(i)}$ 都会通过三个矩阵(每个注意力头都有自己的三个矩阵,这三个矩阵是<strong>可训练的</strong>):</p> <p>$$ W_q, W_k, W_v $$</p> <p>分别<strong>点积</strong>得到:</p> <ul> <li>Query 向量 $q^{(i)}$</li> <li>Key 向量 $k^{(i)}$</li> <li>Value 向量 $v^{(i)}$</li> </ul> <h2>Query 其他 Token</h2> <p>以当前词 “journey” 为例,就是用它的 <strong>query 向量</strong> $q^{(2)} = [0.4, 1.4]$,去点乘<strong>整个上下文其他词</strong>的 <strong>key 向量</strong>:</p> <ul> <li>“Your” → $k^{(1)} = [0.3, 0.7]$</li> <li>“journey” → $k^{(2)} = [0.4, 1.1]$</li> <li>“step” → $k^{(T)} = [0.3, 0.9]$</li> </ul> <p>这就等于计算每个词与当前 query 的<strong>相似度</strong>:</p> <p>$$ \omega_{2j} = q^{(2)} \cdot k^{(j)} $$</p> <p>例如:</p> <ul> <li>$\omega_{21} = 1.2$</li> <li>$\omega_{22} = 1.8$</li> <li>...</li> <li>$\omega_{2T} = 1.5$</li> </ul> <p>这些结果代表了当前词(“journey”)与其他词的“相关程度”。</p> <blockquote> <p>点积: 点积不仅被视为一种将两个向量转化为标量值的数学工具,而且也是度量相似度的一种方 式,因为它可以量化两个向量之间的对齐程度:点积越大,向量之间的对齐程度或相似度就 越高。在自注意机制中,点积决定了序列中每个元素对其他元素的关注程度:点积越大,两 个元素之间的相似度和注意力分数就越高。</p> </blockquote> <h2>归一化注意力权重</h2> <p>接着对所有相似度 $\omega_{2j}$ 进行 Softmax 归一化(公式不用细看,归一化就是让所有值加起来等于 1):</p> <p>$$ \alpha_{2j} = \frac{e^{\omega_{2j}}}{\sum_t e^{\omega_{2t}}} $$</p> <p>得到:</p> <ul> <li>$\alpha_{21} = 0.1$</li> <li>$\alpha_{22} = 0.2$</li> <li>...</li> <li>$\alpha_{2T} = 0.1$</li> </ul> <p>这些值称为 <strong>注意力权重(attention weights)</strong>,表示模型在处理当前词“journey”时,对其他词的关注程度。</p> <h2>上下文向量</h2> <p>最后一步: 每个词都有自己的 value 向量 $v^{(j)}$,将它与对应的注意力权重相乘并求和:</p> <p>$$ z^{(2)} = \sum_j \alpha_{2j} v^{(j)} $$</p> <p>如图中:</p> <ul> <li>$v^{(1)} = [0.1, 0.8]$</li> <li>$v^{(2)} = [0.3, 1.0]$</li> <li>...</li> <li>$v^{(T)} = [0.3, 0.7]$</li> </ul> <p>计算后得到:</p> <p>$$ z^{(2)} = [0.3, 0.8] $$</p> <p>这个向量 $z^{(2)}$ 就是 <strong>“journey” 的上下文向量(context vector)</strong>,<strong>它综合了句子中各个词的语义信息</strong>,并且根据注意力权重动态决定了“关注谁”。</p> <p>在点积之后,为了防止数值过大导致 Softmax 算出来的梯度太小(难以训练),我们通常会把结果除以一个缩放系数(通常是维度的根号,即 $\sqrt{d_k}$),然后再做归一化。</p> <p>经过上面一同操作,就得出了著名得注意力公式:</p> <p>$$ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q K^\top}{\sqrt{d_k}}\right)V $$</p> <h2>掩码</h2> <p><strong>因果注意力</strong>就是把每个字后面的字都盖住,防偷看。</p> <p>因果掩码让大模型只考虑前面的内容,不是因为后面的内容没有用,而是因为训练的目标就是从前面的内容生成后面的内容,所以即使有用,在这个运行机理上后面的内容就是不可访问的,大模型必须在后面不可知的情况下进行学习。</p> <p>另一种掩码是 <strong>dropout</strong>,指每个头都随机选一些词盖住,让模型的注意力能集中到某些词上。</p> <p><img src="https://img.ssshooter.com/img/kitten-llm/mask.jpg" alt="" /></p> <h2>多头</h2> <p>前面也说每个头的 QKV 矩阵都是不一样,因为在初始化 QKV 矩阵时数值就是<strong>随机的</strong>,那么通过反向传播得到的值就不一样,所以各个头<strong>注意到的东西自然也不一样</strong>。</p> <p>虽说人类不好理解注意力,但还是可以通过<a href="https://github.com/jessevig/bertviz">注意力可视化</a>找到一些提示,例如某些头会学习到被动语态,又有某些头会学习到词性分析。</p> <p><img src="https://img.ssshooter.com/img/kitten-llm/multi-head.jpg" alt="" /></p> <p>最后系统会把多个头的信息汇总,最后输出到下一步。</p> <h2>参考资料</h2> <ul> <li><a href="https://book.douban.com/subject/37305124/">从零构建大模型</a></li> </ul> 小猫都能懂的大模型原理 2 - 初见大语言模型https://ssshooter.com/kitten-large-language-model-2/https://ssshooter.com/kitten-large-language-model-2/深入浅出地解析GPT和Transformer架构原理,介绍大语言模型的训练机制、Token化处理、词嵌入技术,以及自注意力机制如何让AI理解和生成人类语言。Mon, 01 Dec 2025 02:11:25 GMT<p><img src="https://img.ssshooter.com/img/cats/llm2.jpg" alt="小猫都能懂的大模型原理 2 图片来源 pixabay.com" /></p> <blockquote> <p>本文旨在用简单易懂的语言解释大语言模型的基本原理,不会详细描述和解释其中的复杂数学和算法细节,希望各位小猫能有所收获 🐱</p> </blockquote> <p>现在大家遇到问题,第一反应都不是使用搜索引擎,而是问 chatGPT,chat 大家都知道是聊天的意思,但是 <strong>GPT</strong> 它到底是个什么呢?</p> <p>展开一下全名:Generative Pretrained Transformer,翻译过来就是<strong>生成式预训练 Transformer</strong>。</p> <p>所以在此之前我们需要更清楚知道 <strong>Transformer</strong> 到底是个啥。</p> <h2>Transformer 架构</h2> <p>说到 Transformer 还是不能不提其源头,鼎鼎大名的<a href="https://arxiv.org/abs/1706.03762">《Attention Is All You Need》</a>,这篇论文提出了这个名为 Transformer 的深度神经网络架构。</p> <p>在论文里面,这个架构是长这样的:</p> <p><img src="https://img.ssshooter.com/img/kitten-llm/transformer.jpg" alt="" /></p> <p>其核心就是<strong>自注意力机制</strong>。</p> <p>注:后来很多 GPT 只保留了 decoder。</p> <h2>最基本的原理</h2> <p>回忆一下神经网络,我们的输入和输出,经过神经网络训练一顿操作!调整好权重,就能输出像模像样的答案。</p> <p>那么对文字是不是也能这么操作呢?没错的,事实证明完全可以。</p> <p>因为文字这个东西,现成的正确答案真的太多啦,我们训练的时候可以这样:</p> <pre><code>输入:番茄是 正确结果:番茄是红 </code></pre> <p>如果猜到的不是红,那就<strong>计算损失</strong>,把权重往对的那边凹一下。</p> <p>经过很多 TB 的文字数据训练之后,就成就了现在的<strong>大语言模型</strong>,它似乎了解地球上一切文字知识,并且表达出来毫无维和感。</p> <p>科学似乎还无法解释这件事,大模型可以流畅回答训练样本里没有的内容,这就称为<strong>涌现</strong>。例如大模型其实没有专门学过翻译呢,但是它偏偏就可以在繁多的参数里面懂得如何翻译不同语言。</p> <h2>输入和输出</h2> <p>对于大模型来说,输入和输出,本质上是 Token。</p> <p>通过这个<a href="https://github.com/dqbd/tiktokenizer">分词工具</a>,可以更清晰地理解 Token 的概念,它不一定是一个字母、一个单词、一个符号,而有可能是它们的组合。</p> <p>如图所示:</p> <p><img src="https://img.ssshooter.com/img/kitten-llm/tiktokenizer.jpg" alt="" /></p> <p>所以在训练的时候,输入输出就是:</p> <pre><code>输入:30357, 21290, 226, 3221 输出:30357, 21290, 226, 3221, 16491 </code></pre> <p>吗?</p> <p>不是的,实际训练的肯定不是这个 Token ID,而是这个 ID 代表的含义本身。</p> <p>把文字转换为向量就是所谓的<strong>词嵌入(embedding)</strong>(关于 RAG 后面再开坑)。根据《从零构建大模型》的说法:最小的 GPT-2 模型(参数量为 1.17 亿)使用的嵌入维度为 768,而 GPT-3模型(参数量为 1750 亿)使用的嵌入维度为 12288。</p> <p>在这个场景下,简单来说就是让这个 Token 转换为一个 768 个值的数组……</p> <p>例如番茄就是 <code>[0.9,0.4,0.7,0.5,0.9...后面还有七百多个维度]</code>,对比其它水果可能是这样的:</p> <table> <thead> <tr> <th>维度</th> <th>含义</th> <th>“番茄”的值</th> <th>“草莓”的值</th> <th>“黄瓜”的值</th> </tr> </thead> <tbody> <tr> <td>1</td> <td>有多红</td> <td><strong>0.9</strong></td> <td>0.85</td> <td>0.1</td> </tr> <tr> <td>2</td> <td>甜度</td> <td>0.4</td> <td><strong>0.8</strong></td> <td>0.1</td> </tr> <tr> <td>3</td> <td>水分</td> <td>0.7</td> <td>0.6</td> <td><strong>0.9</strong></td> </tr> <tr> <td>4</td> <td>是否属于蔬菜</td> <td><strong>0.5</strong></td> <td>0.1</td> <td><strong>0.8</strong></td> </tr> <tr> <td>5</td> <td>是否可生吃</td> <td><strong>0.9</strong></td> <td><strong>0.95</strong></td> <td>0.8</td> </tr> </tbody> </table> <p>注意:这里的含义只是比喻,实际上各个维度的含义人类是看不懂的。</p> <p>大家应该差不多理解了,<strong>真正的输入输出就是这些几百上千维度</strong>。</p> <p>把这些值的正确排列经过神经网络训练,让其预测下一个 Token 是某个词的概率(logits),然后取概率比较高的值。</p> <p>最后举个输入输出例子:</p> <pre><code>输入: "番茄是" ↓ Token化 Token: [123, 456, 789] ↓ 转换为向量 Vector: [[0.1,0.2,...], [0.3,0.4,...], [0.5,0.6,...]] ↓ 神经网络预测 Hidden State: [0.7, -0.2, 1.1, ..., 0.3] # 768维隐藏状态 ↓ 通过输出层投影 Logits: [-2.3, 1.5, ..., 4.2, ..., 2.1, ...] # 词汇表大小的向量 ↓ Softmax转换为概率 概率分布: [0.001, 0.003, ..., 0.45(对应"红"), ..., 0.25(对应"圆的"), ...] ↓ 选择最高概率 输出: Token ID 4567 (对应"红") </code></pre> <p>中间看不懂不用怕,下一章就会讲到<strong>自注意力机制</strong>的原理啦~</p> <h3>循环生成</h3> <p>上面一顿输入输出其实只生成了一个新 Token,你需要生成一句话的话,就继续把新的 Token 拼到原来的句子里,继续循环下去,就能生成一整段话啦。</p> <h2>参考资料</h2> <ul> <li><a href="https://book.douban.com/subject/37305124/">从零构建大模型</a></li> <li><a href="https://www.youtube.com/watch?v=7xTGNNLPyMI&amp;t=2395s">Deep Dive into LLMs like ChatGPT</a></li> </ul> 小猫都能懂的大模型原理 1 - 深度学习基础https://ssshooter.com/kitten-large-language-model-1/https://ssshooter.com/kitten-large-language-model-1/用最简单易懂的语言解释大语言模型的基本原理,从深度学习基础到神经网络训练,包含梯度下降、反向传播等核心概念,适合初学者的AI入门教程。Sun, 30 Nov 2025 15:01:45 GMT<p><img src="https://img.ssshooter.com/img/cats/llm1.jpg" alt="小猫都能懂的大模型原理 1 图片来源 pixabay.com" /></p> <blockquote> <p>本文旨在用简单易懂的语言解释大语言模型的基本原理,不会详细描述和解释其中的复杂数学和算法细节,希望各位小猫能有所收获 🐱</p> </blockquote> <h2>AI 的科技树</h2> <p>我们先来通过一条简单的链路,定位大模型在 AI 领域的位置: 人工智能 &gt; 机器学习 &gt; 深度学习 &gt; 大语言模型</p> <p>机器学习最开始就用<strong>大量数据</strong>做<strong>线性回归</strong>从而对未知数据进行推测。</p> <p>举个最简单的例子,二维的数据。直接就可以使用数学课就学过的线性回归求方程获取数据的趋势。当时就觉得算线性回归真麻烦呀,计算量那么大,没想到这都蹭到人工智能的边了,还能不难算吗?</p> <p>接着,人类不满足于简单的线性回归,想要让计算机自动学习更复杂的数据特征,于是就有了深度学习。</p> <h3>深度学习</h3> <p>深度学习之所以深度是因为,它基于<strong>多层神经网络</strong>自动学习数据特征。多层神经网络就像人脑的神经元互相连接。每根连接的强度就是<strong>权重</strong>,网络会反复调整这些强度,让结果越来越接近正确答案。</p> <p>代表性模型有:卷积神经网络(CNN)、循环神经网络(RNN)、Transformer、GAN 等。</p> <h3>大语言模型</h3> <p>Transformer 无情压榨 GPU 产生的奇迹,起初应该没人觉得这效果能这么好。</p> <p>与深度学习一样,Transformer 也是使用多层神经网络处理矩阵,只不过矩阵异常的大,不到硬件发展到一定水平根本无法实现。</p> <p>关于大语言模型我们停一下,先比较基础的机器学习原理!</p> <h2>训练的方式</h2> <p>还是从最简单的二维数据开始。</p> <p>当我们有一堆房产离市中心距离及其价格的数据时,我们可以在一个二维坐标轴表示这些数据,例如 x 轴是距离,y 轴是价格。</p> <p>在数据都画上坐标轴之后,作为一个人类可能一眼就能粗略看出整个曲线的趋势,从而“拟合”出一条距离价格的关系。</p> <p>它很可能是一个类似这样的函数:<code>price = distance * w + b</code>。</p> <p>对于计算机,要求出 w 和 b 的最优解,就要让真实价格和通过 w b 计算出来的价格的差值最少。</p> <p>最常用的方式是<strong>均方误差(Mean Squared Error, MSE)</strong>:</p> <p>$$ L(w, b) = \frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2 $$</p> <p>我们要求的就是这个函数的最小值,在人工智能领域常用的就是<strong>梯度下降</strong>,也就是求 w 和 b 的偏导数,乘上学习率 α,让它自己慢慢收敛。</p> <p><img src="https://3b1b-posts.us-east-1.linodeobjects.com/content/lessons/2017/gradient-descent/gradient-descent.png" alt="梯度下降" /></p> <h3>过拟合</h3> <p>在拟合出一条接近“规律”的线条后,其实就差不多了。</p> <p>如果你硬加更多的节点,会造成过拟合,也就所有的训练数据的损失值都很完美,但是一让它生成训练数据以外的东西,它就猜不准了。</p> <p>就上面房价的例子,假如本来趋势基本就是一个斜线,但是你最后硬是求得一条曲线方程,把所有的点都穿过了,损失值为 0,但是这样计算用户给你的值,反而是算不准的。</p> <p>这也就是所谓的<strong>失去泛化能力</strong>。</p> <h2>神经网络</h2> <p>上面只用二维数据,就只有一个 <code>price = distance * w + b</code>,但是如果想要做成神经网络,参数就会很多,并且与权重相乘的值的含义,<strong>人类并不能轻易理解</strong>,例如:</p> <p>$$ a = w_1 a_1 + w_2 a_2 + w_3 a_3 + w_4 a_4 + b $$</p> <p>又因为如果只用权重,无论经过多少层都无法拟合曲线,所以最后要添加一个<strong>非线性的激活函数</strong>计算结果:</p> <p>$$ a = \mathrm{ReLU}(w_1 a_1 + w_2 a_2 + w_3 a_3 + w_4 a_4 + b) $$</p> <p>这只是一个层对某一个神经元的计算,下面是一个比较形象的图(请忽略数字)</p> <p><img src="https://3b1b-posts.us-east-1.linodeobjects.com/content/lessons/2017/gradient-descent/weights-and-biases.png" alt="某层对下一个神经元的计算" /></p> <p>所以当一个神经网络有<strong>多层</strong>,每层<strong>多个</strong>神经元的话计算量还是挺可怕的。</p> <p><img src="https://3b1b-posts.us-east-1.linodeobjects.com/content/lessons/2017/gradient-descent/recap-propagation.png" alt="神经网络" /></p> <p>比如判断一张图片有没有猫,你没法用一条简单的线来划分"有猫"和"没猫"的区域。</p> <p>多层网络的魔力在于:</p> <ul> <li><strong>逐层特征提取</strong>:第一层可能只学到边缘和颜色,第二层学到边缘组成的形状,第三层学到眼睛、耳朵,第四层才认识完整的猫脸</li> <li><strong>非线性组合</strong>:通过激活函数,每一层都能创造新的特征组合,让网络可以表示任意复杂的函数</li> <li><strong>层次化抽象</strong>:就像人类认识世界一样,先学简单概念,再组合成复杂概念</li> </ul> <p>这就等于在计算的时候把数据的内涵升维到隐藏层,经过隐藏层额外的处理可以得到更精确的结果。当然这个时候你输入的值也要有足够的信息量它才能学到东西。</p> <p>但问题又来了既然多层这么强大,那是不是层数越多越好?也不是的。神经网络有两个维度可以调整:</p> <p><strong>深度(Deep)</strong>:层数多,每层神经元少</p> <ul> <li>参数少,计算效率高</li> <li>适合层次化特征学习</li> <li>容易出现梯度消失(层数太多,误差传不到前面)</li> <li>训练困难</li> </ul> <p><strong>宽度(Wide)</strong>:层数少,每层神经元多</p> <ul> <li>训练相对简单</li> <li>能并行处理更多信息</li> <li>参数量大,容易过拟合</li> <li>缺乏层次化抽象能力</li> </ul> <p>实际训练时<strong>深度和宽度的平衡</strong>需要把握好。</p> <h2>反向传播</h2> <p>上面我们知道用梯度下降的方式调节 w 和 b,对于神经网络也是一样的数学原理,需要通过链式法则(Chain Rule)一层一层反向调整所有权重。</p> <p>这里就不详细解释怎么层层反推了,就结果而言,我们给出了正确的输入和输出,最开始,这个网络只是瞎猜权重,到最后计算出来,<strong>经过损失函数梯度下降调整各种权重</strong>,到最后,竟然就可以像魔法一样推导出准确率比较高的答案,喵,喵,喵呀!</p> <p>P.S. 如果你真的找虐很想了解更多反向传播的计算过程,可以看 <a href="https://www.3blue1brown.com/lessons/backpropagation-calculus">3blue1brown</a> 🐱</p> <h2>参考资料</h2> <ul> <li><a href="https://www.3blue1brown.com/topics/neural-networks">3blue1brown Neural Networks</a></li> <li><a href="https://playground.tensorflow.org/">tensorflow playground</a></li> </ul> 免费和付费 AI API 选择指南https://ssshooter.com/ai-services-guide/https://ssshooter.com/ai-services-guide/详细介绍2025年最值得推荐的AI服务提供商,包括Google Gemini、OpenRouter、硅基流动等免费方案,以及OpenAI、Anthropic Claude等付费服务的选择建议和使用教程。Wed, 26 Nov 2025 13:33:13 GMT<p>免费的 AI 服务非常多,但是免费的多是聊天功能。现在又很多应用的 AI 服务都需要用户自带 Key,AI 这东西从原理上来说又比较贵,所以使用 Key 进行 API 调用时往往是需要付费的。</p> <p>但方法总比困难多,还是有一些免费或者低成本的方案可以选择。</p> <p>下面就来总结一些目前比较好用的 AI 提供商,付费免费都有,供大家参考。</p> <h2>免费方案</h2> <h3>Google Gemini</h3> <p><strong>推荐理由:</strong></p> <ul> <li>部分可免费使用(不过最近免费额度似乎缩紧了)</li> <li>性能优秀,总结质量高</li> <li>支持超长上下文</li> <li>响应速度快</li> </ul> <p><img src="https://img.ssshooter.com/img/ai-services-guide/google.jpg" alt="Google AI Studio 获取 key" /></p> <p><strong>获取方式:</strong></p> <ol> <li>访问 <a href="https://aistudio.google.com/">Google AI Studio</a></li> <li>使用Google账号登录</li> <li>创建API Key</li> <li>在插件配置中选择"Google Gemini"并填入API Key</li> </ol> <p><img src="https://img.ssshooter.com/img/ai-services-guide/google-key.jpg" alt="Google AI Studio 复制 key" /></p> <h3>OpenRouter</h3> <p>https://openrouter.ai/</p> <p><strong>推荐理由:</strong></p> <ul> <li>大量免费模型可用</li> <li>额度量大管饱</li> </ul> <p>对于<a href="https://openrouter.ai/models?max_price=0">免费模型</a>(型号ID以 <code>:free</code> 结尾)。</p> <p>目前,比较热门的免费模型包括 <a href="https://openrouter.ai/x-ai/grok-4-fast:free">x-ai/grok-4-fast:free</a> 和 <a href="https://openrouter.ai/deepseek/deepseek-chat-v3.1:free">deepseek/deepseek-chat-v3.1:free</a></p> <p>对于免费模型,<strong>请求速率限制取决于你已购买的积分数量</strong>。如果你购买了至少 10 个积分,你的免费模型请求速率限制将是每天 1000 次。否则,你的免费模型 API 请求将被限制为每天 50 次。</p> <p>推荐充值 10 积分,这个额度对于日常使用和开发测试是完全足够的。同时可以配置 Credit limit 为 0 的 Token 避免误用积分。</p> <p><img src="https://img.ssshooter.com/img/ai-services-guide/openrouter.jpg" alt="openrouter" /></p> <p>虽然你是充值了 10 刀,但是使用时不花钱,并且<strong>额度十分可观</strong>。所以,勉强也算是免费吧 😂</p> <h3>硅基流动</h3> <p><strong>适用场景:</strong> 当网络无法访问 Gemini 或 OpenRouter 时</p> <p><strong>推荐理由:</strong></p> <ul> <li>部分小模型免费使用</li> <li>支持多种开源模型</li> <li>速度相对较慢</li> <li>中国大陆访问友好</li> </ul> <p>缺点:</p> <ul> <li>免费模型延迟高,生成速度慢</li> </ul> <p><img src="https://img.ssshooter.com/img/ai-services-guide/siliconflow.jpg" alt="硅基流动 获取 key" /></p> <p>2025.11.23 更新:现在已经没有免费这个筛选项了,只能在列表里找标价为免费的模型。</p> <p><strong>获取方式:</strong></p> <ol> <li>访问 <a href="https://cloud.siliconflow.cn/">硅基流动</a></li> <li>注册账号并完成认证</li> <li>获取API Key</li> <li>在插件配置中选择"自定义",填入硅基流动的API地址和Key</li> </ol> <p><img src="https://img.ssshooter.com/img/ai-services-guide/siliconflow-key.jpg" alt="硅基流动 复制 key" /></p> <h3>公益站</h3> <p>在 <a href="https://linux.do/">linux.do</a>、<a href="https://www.v2ex.com/">V2EX</a>、<a href="https://x.com/">X</a> 等论坛或社媒蹲公益站。</p> <p><img src="https://img.ssshooter.com/img/ai-services-guide/linux-do-free-ai.jpg" alt="linux.do 公益站" /></p> <p>随便搜一下就有很多 👆</p> <p>公益站的系统基本都是像下面这样的一套 👇</p> <p><img src="https://img.ssshooter.com/img/ai-services-guide/free-ai.jpg" alt="公益站示例" /></p> <h2>付费方案</h2> <h3>OpenAI GPT</h3> <p><strong>官网地址:</strong> <a href="https://openai.com/">https://openai.com/</a></p> <p><strong>特点:</strong></p> <ul> <li>掀起 AI 狂热的始祖</li> <li>业界领先的(期间限定)AI 能力</li> </ul> <h3>Anthropic Claude</h3> <p><strong>官网地址:</strong> <a href="https://claude.ai/">https://claude.ai/</a></p> <p><strong>特点:</strong></p> <ul> <li>擅长长文本理解</li> <li>能力强,泛用性高</li> <li>编程能力绝对领先</li> </ul> <h3>Deepseek</h3> <p><strong>官网地址:</strong> <a href="https://deepseek.com/">https://deepseek.com/</a></p> <p><strong>特点:</strong></p> <ul> <li>超高性价比</li> <li>响应速度快</li> </ul> <h3>BigModel</h3> <p>也就是 GLM 系列。我订阅过一个月,纯编程估计没啥问题,但是用来接 AI 客户端的时候有可能会遇到审查问题。例如在使用 <a href="https://github.com/SSShooter/ebook-to-mindmap">ebook-to-mindmap</a> 总结书籍内容时可能会遇到 <code>System detected potentially unsafe or sensitive content in input or generation. Please avoid using prompts that may generate sensitive content. Thank you for your cooperation.</code> 😂</p> <p><strong>官网地址:</strong> <a href="https://bigmodel.cn/">https://bigmodel.cn/</a></p> <p><strong>特点:</strong></p> <ul> <li>编程能力较强</li> <li>Claude 平替,可以丝滑接入 Claude Code</li> </ul> <h2>注意事项</h2> <h3>配置</h3> <p>几乎 100% 供应商都有适配 openAI API 的接口,如果你使用的应用没有明确支持某家供应商,但也几乎一定支持 openAI 兼容接口,在对应文档找到接口地址和 Key 填入即可。</p> <p>OpenAI 兼容接口的优点:</p> <ul> <li><strong>标准化格式</strong>:遵循 OpenAI 的 API 规范,使用相同的请求和响应格式</li> <li><strong>简化集成</strong>:应用只需适配一套接口即可支持多个供应商</li> <li><strong>广泛兼容</strong>:几乎所有的 AI 应用都支持这种接口格式</li> <li><strong>功能完整</strong>:支持聊天完成、嵌入、流式输出等核心功能</li> </ul> <p>常见 OpenAI 兼容接口地址示例:</p> <pre><code># OpenRouter https://openrouter.ai/api/v1 # 硅基流动 https://api.siliconflow.cn/v1 # Deepseek https://api.deepseek.com/v1 # Google Gemini (OpenAI 兼容模式) https://generativelanguage.googleapis.com/v1beta/openai/ # BigModel (智谱AI) https://open.bigmodel.cn/api/paas/v4 </code></pre> <p><img src="https://img.ssshooter.com/img/ai-services-guide/ai-config.jpg" alt="AI 配置例" /></p> <p>以 <a href="https://mind-elixir.com/">Mind Elixir Desktop</a> 为例,在设置中输入以下关键数据即可接通 AI 供应商:</p> <ul> <li>API 地址(Endpoint):供应商提供的 API 访问地址,这需要参考你的供应商文档,大部分地址都是以 <code>/v1</code> 结尾,不过也有像 gemini 那样 <code>https://generativelanguage.googleapis.com/v1beta/openai/</code> 的非主流</li> <li>API Key:供应商提供的访问密钥,<strong>记得注意密钥安全</strong></li> <li>模型名称(Model):选择或填写所需使用的模型名称(部分 AI 工具可以通过 Endpoint 自行搜索可用模型)</li> </ul> <h3>隐私安全</h3> <p>交出 API Key 等同于交出账号控制权,请务必确保 Key 的安全性,避免泄露给他人。同样的,你在使用 AI 应用时<strong>必须确保应用的安全性</strong>,避免 Key 被恶意使用。</p> <p>使用非官方服务时,<strong>请务必确认服务商的信誉和安全性</strong>,避免使用来路不明的服务,<strong>以防止数据泄露或滥用</strong>。</p> <h3>使用限制</h3> <ul> <li>各服务商都有使用频率限制</li> <li>免费服务可能有每日配额</li> <li>网络状况影响响应速度</li> </ul> <h3>故障排除</h3> <ul> <li>API Key 错误:检查 Key 是否正确复制</li> <li>网络超时:尝试更换网络或服务商</li> <li>配额用尽:等待重置或升级付费计划</li> </ul> <h2>总结</h2> <p>AI 服务市场已经相当成熟,用户可以根据自己的需求和预算选择合适的方案:</p> <ul> <li><strong>Google Gemini</strong> 性能优秀,免费额度相对充足</li> <li><strong>OpenRouter</strong> 充值 10 美元解锁大量免费模型,性价比极高,<strong>强烈推荐</strong></li> <li><strong>硅基流动</strong> 国内用户备选,网络访问稳定</li> <li><strong>编程开发</strong>:Anthropic Claude 或 BigModel (GLM)</li> <li><strong>性价比优先</strong>:Deepseek</li> <li><strong>公益站</strong> 社区资源,适合临时使用</li> </ul> <p><strong>关键使用建议:</strong></p> <ul> <li>善用 OpenAI 兼容接口,一个配置通用多个平台</li> <li>注意 API Key 安全,避免泄露</li> <li>根据使用场景选择合适模型,避免资源浪费</li> <li>关注各平台的免费政策和限额变化</li> </ul> <p>随着 AI 技术的快速发展,各供应商的服务和价格也在不断调整,建议大家根据自己的实际需求和使用频率,灵活选择最适合的方案。记住,免费的往往是最贵的,在选择服务商时,平衡性能、稳定性和成本才是明智之举。</p> Die with Zero:最优解人生https://ssshooter.com/die-with-zero/https://ssshooter.com/die-with-zero/探讨如何在有限的生命中最大化体验,而非无休止地积累财富。通过合理规划时间和金钱,实现真正的人生价值。Sat, 18 Oct 2025 14:22:14 GMT<p><a href="https://weread.qq.com/web/bookDetail/87b327c0813ab7c11g01944b">《最优解人生》</a>,原书是<a href="https://book.douban.com/subject/35659403/">《Die with Zero》</a>。(从书名就已经感觉到出版社对死亡的忌讳……)</p> <blockquote> <p>We all have to survive, but we all want to do much more than survive: We want to really live.</p> </blockquote> <p><strong>Erin和John的案例:</strong> 约翰被诊断出罕见且快速发展的癌症,促使他的妻子艾琳辞职,将重心放在与家人共度时光上。这个极端案例生动地说明了生命有限性对人们价值观的冲击,以及在生命尽头时,人们更看重体验而非物质积累。我们应该利用金钱<strong>最大化积极人生体验</strong>,将重心从物质积累转向当下体验。</p> <h2>投资于体验</h2> <p><strong>体验的价值优于物质:</strong> 心理学研究表明,将金钱用于体验比用于物质更能带来持久的幸福感。</p> <p>我们应该将生活视为一系列体验的总和,并像投资金融资产一样,有意识地、尽早地投资于有意义的体验。作者强调,体验不仅带来即时享受,更重要的是能产生 <strong>“记忆红利”(Memory Dividend)</strong>,即通过回忆和分享,持续为我们带来满足感和价值。这种“记忆红利”会随着时间的推移而复利增长,尤其是在与他人分享时。</p> <p><strong>杰森的欧洲背包旅行案例:</strong> 作者以其室友杰森在20岁出头时,不顾经济拮据(年收入1.8万美元,甚至向高利贷借款1万美元)和职业发展风险,毅然前往欧洲背包旅行的经历为例。杰森的旅行虽然在当时看来“疯狂”,但回来后,他通过照片和故事展现出的“无限富足”让作者深感羡慕和后悔。杰森本人也认为,尽管付出了高昂的利息,但所获得的“人生体验是无价的,无法被任何金钱抹去”。相反,作者对比自己30岁才去欧洲的经历,发现那时已经“太老、太讲究”,无法像年轻时那样享受青年旅社和与20多岁年轻人交流的乐趣,且责任更多,难以长时间旅行。这印证了“尽早投资”的重要性。</p> <p><strong>明确赚钱的目的</strong>,找到你真正想要的是什么。</p> <blockquote> <p>The business of life is the acquisition of memories. In the end that’s all there is.</p> </blockquote> <h2>子女问题</h2> <p>人们普遍误解“零遗产”是认为这是一种自私的行为,忽视了子女的福祉。作者驳斥了这一观点,强调真正的“死后归零”并非不给子女留钱,而是<strong>在生前以最有影响力的方式将钱给予子女或慈善机构</strong>。关键概念包括:</p> <ul> <li><strong>“死后归零”并非“花光子女的钱”</strong>:作者澄清,“死后归零”是指花光“你的钱”,而子女应得的钱应在生前就规划并给予。</li> <li><strong>遗产的低效性</strong>:将遗产留到死后才给予子女,往往导致资金到达时机过晚,无法发挥最大效用。</li> <li><strong>赠予时机的优化</strong>:无论是给子女还是慈善机构,资金赠予的最佳时机是其能产生最大影响的时候,而非死后。</li> <li><strong>真正的遗产是经验而非金钱</strong>:作者认为,父母留给子女最重要的遗产是共同的经历、教诲和回忆,这些比单纯的金钱更有价值。</li> </ul> <blockquote> <p>Much more likely, the money will arrive too late for it to have maximum impact on the recipient’s quality of life.</p> </blockquote> <p>案例:</p> <ul> <li><strong>遗产接收年龄高峰</strong>:美联储数据显示,无论收入群体,遗产接收年龄高峰约为<strong>60岁</strong>。这表明许多子女在需要资金时(如购房、抚养子女)无法及时获得遗产。</li> <li><strong>弗吉尼亚·科林案例</strong>:一位在贫困边缘抚养四个孩子的女性,直到49岁母亲去世才获得13万美元遗产。她指出,这笔钱如果能早十年或二十年获得,将更具价值,因为当时她正处于经济困境。</li> </ul> <blockquote> <p>If you’re really putting your kids first, as you claim you are, don’t wait until you’re dead to show your generosity.</p> </blockquote> <h2>平衡你的生活</h2> <p>人生应在不同阶段实现消费与储蓄的平衡,以最大化一生的幸福感。作者挑战了传统的储蓄观念,并强调了<strong>健康、金钱和时间</strong>这三大要素在享受生活中的重要性。</p> <p><img src="https://j9biho.5gcdn.net/ext/resize_960shrink,auto?src=http%3A%2F%2Fwww.finax.eu%2Fstorage%2Fapp%2Fmedia%2Fuploaded-files%2FEN_blog_249_pic2_EN-02.png&amp;sig=dadab204f91cd8e11b598d9d7ad3bb7d8b670699342a9bdc5c8adb98db9c8968" alt="" /></p> <ul> <li><strong>年轻时应更敢于消费:</strong> 许多经济学家(如史蒂文·莱维特、米尔顿·弗里德曼)认为,年轻人由于未来收入增长潜力大,现在更应敢于消费甚至适度借贷,而非过度储蓄,以享受当下可获得的体验。</li> <li><strong>金钱的边际效用随年龄递减:</strong> 随着年龄增长,健康状况下降,人们从金钱中获取乐趣的能力会逐渐减弱。在生命末期,即使拥有巨额财富,也无法带来实质性的享受。</li> <li><strong>“真正的黄金岁月”:</strong> 并非指传统的退休年龄,而是指健康和财富兼备的青壮年时期(例如30-50岁),这段时间是最大化消费体验的最佳时机。</li> <li><strong>健康比金钱更宝贵:</strong> 健康是享受一切体验的基础,再多的金钱也无法弥补极差的健康状况。投资健康是对未来所有体验的投资,其回报远超金钱本身。</li> <li><strong>时间比金钱更稀缺:</strong> 尤其在中年时期,时间往往比金钱更为稀缺。通过花钱购买时间(如外包家务),可以减少负面体验,增加正面体验,从而提升生活满意度。</li> <li><strong>个人利率与延迟满足:</strong> 随着年龄增长,延迟体验的成本(即“个人利率”)会急剧上升。年轻时可以为了未来更大的回报而延迟满足,但年老时则应避免延迟,因为健康状况可能不再允许。</li> </ul> <p><strong>案例:</strong></p> <ul> <li><strong>旅行约束研究:</strong> 研究发现,60岁以下的人主要受时间和金钱限制,而75岁以上的人主要受健康问题限制,印证了健康对体验能力的重要性随年龄增长而凸显。</li> <li><strong>身体机能衰退:</strong> 医疗研究表明,人体各项系统(骨密度、肌肉量、视力、肺功能、心脏健康、认知功能、嗅觉等)都会随年龄增长而衰退,且衰退速度因人而异,但总体趋势不可逆。</li> <li><strong>金钱购买时间的研究:</strong> 心理学研究表明,花钱购买省时服务的人(无论收入高低)生活满意度更高,因为这能减轻时间压力,改善日常情绪,从而提升整体生活满意度。但是要注意不要花太多哦,<a href="https://tools.mind-elixir.com/zh/salary-calculator">算算你的时薪</a>,看看是否值得。</li> </ul> <blockquote> <p>Better health doesn’t just give you a better retirement years from now— investing in your health is investing in every single subsequent experience!</p> </blockquote> <h2>人生分段</h2> <blockquote> <p>We all die a multitude of deaths throughout our lives.</p> </blockquote> <p>本章的核心思想是,将人生视为由不同“季节”或“阶段”组成的,<strong>每个阶段都有其独特的体验窗口</strong>,并且这些阶段的结束往往是无声无息的,而非有明确的截止日期。作者通过引入“时间分段”(Time Bucketing)这一工具,旨在帮助读者主动规划人生体验,避免因拖延而产生的遗憾。</p> <p><strong>案例:</strong></p> <ul> <li><strong>作者案例:</strong> 作者以女儿不再想看小熊维尼电影为例,说明了人生阶段的无声结束和体验窗口的关闭。</li> <li><strong>大学新生实验:</strong> 心理学家团队让一组学生想象30天后将搬离校园,并规划剩余时间,结果显示这组学生比对照组更快乐,证明了<strong>对时间有限性的认知能提升幸福感</strong>。</li> <li><strong>作者45岁生日派对案例:</strong> 作者以自己45岁生日在圣巴茨岛举办的盛大派对为例,说明了在健康状况尚佳、亲友尚能参与时,投入巨资创造“一生一次”的难忘体验的价值。他对比了50岁生日时母亲健康状况下降、父亲已故的遗憾,强调了“时间桶”和及时享受的重要性。</li> <li><strong>延迟满足的陷阱与遗憾:</strong> 过度延迟某些体验会导致遗憾,这种遗憾不仅发生在生命终点,也可能在人生的各个阶段出现。作者引用了临终关怀护士Bronnie Ware的研究,指出人们最常见的两大遗憾是: <ul> <li>“希望自己有勇气过上真正属于自己的生活,而不是别人期望的生活。”</li> <li>“希望自己没有那么努力工作。”他们后悔为了工作错过了孩子的成长和伴侣的陪伴。</li> </ul> </li> </ul> <p><strong>主要观点:</strong></p> <ol> <li><strong>人生是多重“死亡”的集合:</strong> 作者指出,我们一生中会经历许多“小死亡”,例如青少年时期的结束、大学生活的逝去、单身状态的终结、为人父母的某个特定阶段的消逝等。这些“死亡”意味着某个特定版本的自我和相应的体验机会的不可逆转的结束。</li> <li><strong>体验窗口的有限性:</strong> 许多人生体验都有其最佳或唯一的发生时期,就像度假村里不同年龄段的泳池一样,一旦错过某个阶段,某些体验(如滑水梯)就永远无法再进行。这种有限性并非仅限于体力活动,也包括与特定人群(如年幼的孩子)的互动。</li> <li><strong>无明确终点的阶段:</strong> 与学校学年或往返旅行不同,人生中大多数阶段的结束都没有明确的预告,它们悄然流逝,导致人们常常在事后才意识到错失了机会。</li> <li><strong>预见性损失的积极作用:</strong> 意识到时间的有限性和即将到来的损失,反而能激发人们更积极地珍惜当下。一项针对大学新生的实验表明,被告知即将搬离校园的学生,在接下来的30天里比对照组更快乐,因为他们<strong>更主动地去享受和利用剩余的时间</strong>。这类似于旅行时人们会更充分地利用时间去探索和体验。</li> <li><strong>“时间分段”工具:</strong> 这是一种主动规划人生体验的工具,与传统的“遗愿清单”(Bucket List)不同。“遗愿清单”通常是临近生命终点时被动地列出未完成事项,而“时间分段”则是将人生划分为5年或10年的时间段(“时间桶”),然后将希望拥有的体验(不考虑金钱因素)主动分配到最适合的“时间桶”中。</li> </ol> <blockquote> <p>Just realizing that they don’t last forever, that everything eventually fades and dies, can make you appreciate everything more in the here and now.</p> </blockquote> <h3>年轻时大胆行动</h3> <p><strong>在风险不对称的情况下,尤其是在年轻时,应该大胆行动,抓住机遇。</strong> 当潜在收益远大于潜在损失时,不采取行动反而更具风险。就像现在有人在黄金里躲牛市。</p> <p><strong>作者自身的职业经历:</strong> 作者在23岁时被投资银行解雇,但由于年轻,他有足够的时间和机会调整方向,最终找到了更适合自己的交易员工作,并取得了成功。他强调,即使失败,也能从中获得宝贵的经验和积极的记忆。</p> <ul> <li><strong>不对称风险 (Asymmetric Risk):</strong> 指潜在成功带来的收益远大于潜在失败带来的损失的情况。在这种情况下,大胆行动是明智之举。</li> <li><strong>记忆红利 (Memory Dividend):</strong> 即使结果不如预期,大胆尝试的过程也能带来积极的记忆和自豪感,这本身就是一种回报。</li> <li><strong>年轻时的优势:</strong> 年轻时拥有更多的时间和机会从失败中<strong>恢复</strong>,因此承担风险的成本较低,潜在收益更高。</li> <li><strong>不行动的风险:</strong> 停留在舒适区,避免大胆行动,看似安全,实则可能导致终生遗憾和人生体验的缺失,从而降低生活的充实度。</li> <li><strong>量化恐惧 (Quantify the Fear):</strong> 通过理性分析和量化潜在损失,可以发现许多恐惧并非如想象中那么可怕,从而克服行动障碍。</li> </ul> <h2>花钱策略</h2> <p>记住健康还是很重要的,身体是革命的本钱,动不了就赚不了钱,更不能出去玩,吃复利的时间又变少了。</p> <p>但是毕竟这本书主要还是讲花钱的,所以花钱策略必须细讲,健康的部分请大家翻别的书啦。</p> <h3>不要太节俭</h3> <blockquote> <p>To fully enjoy life instead of just surviving it, you need to stop driving mindlessly and actively steer your life the way you want it to go.</p> </blockquote> <p><strong>约翰·阿诺德(John Arnold)的案例:</strong> 作者以其朋友、对冲基金创始人约翰·阿诺德为例,阐述了过度储蓄的弊端。阿诺德最初目标是赚取1500万美元享受生活,但因交易成功和习惯使然,财富不断累积,最终在38岁退休时拥有超过40亿美元。然而,作者指出,他错过了宝贵的年轻时光,且面临“布鲁斯特的百万富翁问题”(Brewster's Millions problem),即财富过多以至于难以在不“宠坏”孩子的前提下有效消费。</p> <p><strong>美国的数据:</strong></p> <ul> <li><strong>净资产累积趋势:</strong> 美联储数据显示,美国人的中位数净资产持续增长,甚至在75岁以上人群中达到最高,表明许多人在退休后仍在继续积累财富,而非消费。</li> <li><strong>退休后消费下降:</strong> 雇员福利研究所(EBRI)和劳工统计局(BLS)的数据显示,退休人员的资产消耗速度非常缓慢,甚至有三分之一的退休人员在退休后资产反而增加。消费支出也随着年龄增长而下降,即使考虑到医疗费用上升,其他开支(如服装、娱乐)的下降幅度更大。</li> </ul> <p><strong>中国的情况:</strong></p> <p>节俭一直是中华民族的传统美德,再加上咱们不是资本主义国家,中国的存款率一直相对较高。</p> <p><img src="https://img.ssshooter.com/img/%E4%BA%BA%E5%9D%87%E5%AD%98%E6%AC%BE.png" alt="人均存款" /></p> <blockquote> <p>If you spend hours and hours of your life acquiring money and then die without spending all of that money, then you’ve needlessly wasted too many precious hours of your life.</p> </blockquote> <p>在生命终结时,将所有财富都用于提升生活体验,避免不必要的储蓄浪费。作者认为,<strong>许多人陷入“自动驾驶”模式</strong>,盲目积累财富,却错失了享受生活的机会,这是一种巨大的 <strong>“生命能量浪费”</strong>。</p> <p>“零遗产”并非指在生前耗尽所有钱财,而是指在生命结束时,将为赚钱所付出的时间和精力转化为<strong>最大化的生活体验</strong>,不留下未被使用的财富。</p> <p><strong>“生命能量”与金钱的转化:</strong> 作者引入了“生命能量”这一概念,指出我们工作所赚取的金钱,本质上是我们投入的生命能量的体现。因此,花钱不仅仅是消费,更是将生命能量转化为体验的过程。如果你死了钱没带走,等于白干好几年,如果是最后把钱全花到 ICU 了,那更是惨上加惨。</p> <p><strong>过度谨慎的储蓄:</strong> 许多人过度储蓄是为了应对老年时的不确定性,特别是医疗费用。作者认为,虽然医疗费用可能很高,但对于大多数人而言,无论储蓄多少,都无法完全覆盖最昂贵的医疗开支。他提出,与其为可能发生的巨额医疗费用而牺牲当下的生活质量,不如将钱花在预防性医疗上,并考虑购买长期护理保险来应对潜在风险。</p> <p><strong>“去积累”财富的策略:</strong> 了解自己的健康状况以决定何时开始增加支出,以及根据预期寿命和基本生活成本来确定最低所需资金,将超出部分积极用于享受生活。作者强调,随着年龄增长,健康状况和兴趣会下降,因此应在年轻时(如50多岁)比年老时(如80、90多岁)花费更多。</p> <h3>防止没钱花</h3> <p>虽然精确地“死时归零”是不可能的,但我们可以通过利用现有工具和金融产品,尽可能接近这一目标,从而避免浪费宝贵的生命能量。</p> <p>通过理性地评估寿命、利用合适的金融工具,并调整对死亡的认知,我们可以更好地管理财富,从而在有限的生命中实现最大的生活享受,避免在离世时留下遗憾和未使用的财富。</p> <p><strong>寿命预测工具</strong> 为了更好地规划支出,了解自己的预期寿命至关重要,你可以尝试使用一些保险公司提供的寿命计算器,它会根据你的年龄和健康状况预测你的寿命,不过这就只是图一乐,谁都不知道明天和死亡哪个先来,下面这个方法就更有效了。</p> <p><strong>利用金融产品风险对冲:</strong></p> <ul> <li><strong>人寿保险(Life Insurance):</strong> 用于应对“死亡风险”,即过早离世的风险,确保受益人在投保人去世后获得经济保障。</li> <li><strong>年金(Annuities):</strong> 被视为人寿保险的“反面”,用于应对“长寿风险”,即活得太久以至于耗尽储蓄的风险。</li> </ul> <p><strong>直面死亡的必要性:</strong> 人类天生倾向于逃避死亡的话题,但这导致了非理性的财务行为,如过度储蓄和推迟享受。作者认为,直面死亡的现实能带来<strong>紧迫感</strong>,促使我们更好地规划和享受有限的生命。</p> <h3>预测资产顶点</h3> <p><strong>为了最大化人生的满足感,人们应该在财富达到“净资产峰值”(Net Worth Peak)时开始有意识地消费,而不是无限期地积累财富,并最终以“零遗产”的状态离世。</strong> 这一峰值并非一个固定的金额,而是一个与个人生物年龄和健康状况紧密相关的“日期”。</p> <p>美国人的中位数净资产通常随年龄增长而上升,房屋拥有率也随年龄增长而提高(35岁以下约35%,35-44岁近60%,45-54岁近70%)。但这仅是现状,并非最大化人生享受的最佳策略。</p> <p>主要观点:</p> <ol> <li><strong>财富积累的终点:</strong> 作者认为,人生目标是最大化“人生体验点”,而非最大化财富。因此,财富积累应有终点,即“净资产峰值”。在此之后,应开始有计划地消费,以确保在健康状况尚佳时享受生活,避免“死后仍有大量未花完的钱”。</li> <li><strong>“净资产峰值”是日期而非数字:</strong> 传统观念常将退休目标设定为某个具体的储蓄金额(如100万或200万),但作者强调,这个峰值更应是一个特定的年龄或日期。因为享受体验需要金钱、自由时间和健康三者兼备,而无限期地积累金钱会牺牲宝贵的自由时间和健康。</li> <li><strong>金钱效用随年龄递减:</strong> 随着年龄增长,健康状况不可避免地下降,即使拥有再多金钱,对某些体验的享受能力也会受限。因此,金钱的边际效用会随着年龄增长而降低。</li> <li><strong>“生存门槛”计算:</strong> 在考虑消费之前,必须确保达到“生存门槛”,即维持基本生活所需的最低储蓄金额。作者提供了一个简化的计算公式:<strong>生存门槛 = 0.7 × (一年生活成本) × (预期剩余寿命年数)</strong>。这个0.7的系数考虑了投资收益对储蓄的补充作用。</li> <li><strong>最佳净资产峰值区间:</strong> 根据作者的模拟研究,对于大多数人而言,最佳的净资产峰值出现在<strong>45岁到60岁之间</strong>。健康状况越好(生物年龄低于实际年龄),峰值可能越靠后;反之,则越靠前。</li> </ol> <p><a href="https://tools.mind-elixir.com/zh/life-asset-calculator">计算工具</a>,里面用到的关键数值:</p> <ul> <li><strong>当前年龄(Current age)</strong></li> <li><strong>退休年龄(Retirement age)</strong></li> <li><strong>预期寿命(Life Expectancy)</strong></li> <li><strong>投资回报率(ROI)</strong></li> <li><strong>年收入(Annual income)</strong></li> <li><strong>净资产(Net worth)</strong></li> <li><strong>退休后福利(Post-retirement benefits)</strong></li> <li><strong>月支出(Monthly expenses)</strong></li> </ul> <p>有意识时间分段后,要进行<strong>消费平滑(Consumption Smoothing)</strong>。这是一个重要的财务概念,指在不同收入水平的时期,通过储蓄或借贷来平衡消费,以实现更稳定的生活质量。作者通过自身年轻时过度节俭的经历,强调了在收入预期增长的情况下,不应过度牺牲当下的体验。</p> <p><img src="https://jamesbachini.com/wp-content/uploads/2021/03/healthWealthCurves-1024x576.png" alt="healthWealthCurves" /></p> <p><strong>重新规划“时间桶”:</strong> 随着人生阶段的变化,兴趣和人际关系也会改变。因此,建议每隔五年或十年重新进行“时间桶”规划,尤其是在接近净资产峰值时,以明确退休后的生活目标和兴趣,避免迷失方向。</p> <blockquote> <p>Every dollar you don’t spend at the right time will have far less value to you later, and in some cases it will bring you no enjoyment at all.</p> </blockquote> <h2>总结</h2> <ol> <li> <p><strong>平衡"蚂蚁"与"蚱蜢":</strong> 承认储蓄的重要性("蚂蚁"),但也要认识到享受生活、投资体验的价值("蚱蜢")。在努力工作和延迟满足的同时,也要学会"活在当下",为体验留出空间。</p> </li> <li> <p><strong>重视"记忆红利":</strong> 认识到体验的价值不仅在于当下,更在于其产生的持久记忆。通过拍照、制作相册、分享故事、组织聚会等方式,主动增强和延长记忆红利。</p> </li> <li> <p><strong>重新审视消费习惯:</strong> 审视日常开销,例如"拿铁因子",思考这些小额消费累积起来能换取哪些更有意义的体验。有意识地选择,而不是盲目消费。</p> </li> <li> <p><strong>明确赚钱的目的:</strong> 赚钱的最终目的是为了拥有更好的体验。不要陷入只顾赚钱而忘记享受生活的误区。在退休时,真正能带来满足感的是丰富的记忆,而非银行账户里的数字。</p> </li> <li> <p><strong>审视你的健康状况:</strong> 思考现在能做而未来可能无法做的体验,并积极投资健康。</p> </li> <li> <p><strong>投资健康:</strong> 学习改善饮食习惯(如推荐《Eat to Live》),多进行喜欢的体育活动,以保持身体机能,提升未来所有体验的乐趣。</p> </li> <li> <p><strong>花钱购买时间:</strong> 如果时间是你的主要限制,考虑将部分金钱用于外包你不喜欢的家务或任务(如洗衣、清洁),从而腾出更多时间用于享受生活。</p> </li> <li> <p><strong>动态调整消费与储蓄比例:</strong> 摒弃固定的储蓄比例,根据年龄、收入增长预期和健康状况,灵活调整消费与储蓄的平衡。年轻时可适当多消费,中年时可加大储蓄,年老时则应更倾向于消费。</p> </li> <li> <p><strong>关注健康、金钱和时间的平衡:</strong> 认识到这三者在不同人生阶段的稀缺性,并学会用充裕的资源去换取稀缺的资源,以实现最佳的生活体验。</p> </li> <li> <p><strong>使用寿命计算器:</strong> 尝试使用在线寿命计算器,对自己的预期寿命有一个大致的了解,这有助于更合理地规划财务。</p> </li> <li> <p><strong>了解并考虑年金产品:</strong> 如果担心在有生之年耗尽资金,应认真研究年金产品,将其视为一种应对长寿风险的保险工具。</p> </li> <li> <p><strong>明确财务目标:</strong> 在与财务顾问交流时,清晰地表达你的目标是"最大化总生活享受",而非仅仅"最大化财富"。</p> </li> <li> <p><strong>积极"去积累"财富:</strong> 随着年龄增长和健康状况的变化,有计划地增加支出,尤其是在身体尚好、兴趣广泛的时期,以充分利用积累的财富。</p> </li> <li> <p><strong>直面死亡:</strong> 克服对死亡的恐惧和逃避,认识到生命的有限性,这能激发你更积极地享受当下,避免将美好的体验无限期推迟。</p> </li> <li> <p><strong>考虑使用"死亡倒计时"工具:</strong> 像作者一样,尝试使用提醒生命有限的工具,这能带来紧迫感,促使你更珍惜时间,做出更符合内心渴望的决定。</p> </li> <li> <p><strong>识别并抓住不对称风险的机会:</strong> 审视生活中的选择,特别是那些潜在收益巨大而潜在损失有限的机会。不要因为害怕而错过这些"高回报、低风险"的时刻。</p> </li> <li> <p><strong>趁年轻大胆尝试:</strong> 年轻是承担风险的最佳时期,因为你有更多的时间从失败中恢复,并且潜在的长期收益更高。不要等到年老时才后悔没有尝试。</p> </li> <li> <p><strong>量化你的恐惧:</strong> 当面对一个大胆的决定时,不要让模糊的恐惧阻碍你。尝试具体分析最坏情况会怎样,以及你有哪些安全网(如储蓄、家人支持、其他就业机会)。通常,你会发现最坏情况并没有想象中那么糟糕。</p> </li> <li> <p><strong>不要低估不行动的风险:</strong> 停留在舒适区看似安全,但它可能让你失去宝贵的人生体验、成长机会和潜在的成功。不行动的代价是"经验值"的损失和可能伴随一生的遗憾。</p> </li> <li> <p><strong>区分低风险承受能力与单纯的恐惧:</strong> 了解自己的风险偏好是健康的,但要警惕单纯的恐惧将实际风险夸大。理性分析和准备可以帮助你克服不必要的恐惧。</p> </li> <li> <p><strong>绘制人生时间线并分段:</strong> 将你的人生从现在到未来划分为5年或10年的"时间桶"。</p> </li> <li> <p><strong>列出梦想体验清单:</strong> 写下你一生中所有希望拥有的关键体验、活动或事件,不考虑金钱因素。</p> </li> <li> <p><strong>将体验分配到"时间桶":</strong> 根据你理想中进行这些体验的年龄或阶段,将它们放入相应的"时间桶"中。优先考虑健康和自由时间,认识到某些体验(如体力活动)更适合年轻时进行。</p> </li> <li> <p><strong>主动规划而非被动等待:</strong> "时间分段"是一种积极主动的生活规划方式,旨在避免临近生命终点时才匆忙弥补遗憾。</p> </li> <li> <p><strong>关注与孩子共度的特定时光:</strong> 如果有孩子,思考在他们某个特定成长阶段结束前,你最想和他们一起完成的体验,并立即行动。</p> </li> <li> <p><strong>认识到金钱并非唯一限制:</strong> 虽然金钱会影响具体体验,但时间、健康和人生阶段的有限性是更根本的限制,且与金钱无关。</p> </li> </ol> <p>如果你真的月光呢?这本书能告诉你什么?月光就月光吧,活出你的精彩。</p> <h2>相关文章</h2> <ul> <li>https://www.finax.eu/en/blog/die-with-zero-on-your-account</li> <li>https://jamesbachini.com/die-with-zero/</li> <li>https://synapsetrading.com/die-with-zero/</li> </ul> 深入理解现代浏览器的工作原理https://ssshooter.com/how-browser-works/https://ssshooter.com/how-browser-works/深入探讨现代浏览器如何工作,从网络请求到页面渲染的完整流程Thu, 25 Sep 2025 09:13:33 GMT<p>原文链接:<a href="https://addyo.substack.com/p/how-modern-browsers-work">How modern browsers work</a> 的中文翻译版本</p> <p>作者:<a href="https://addyosmani.com/">Addy Osmani</a></p> <p>作者信息:Google Chrome 团队成员,目前专注于浏览器性能领域,著作有:<a href="https://www.oreilly.com/library/view/learning-javascript-design/9781449334840/">Learning JavaScript Design Patterns</a>、<a href="https://leet.addy.ie/">Leading Effective Engineering Teams</a>、<a href="https://stoic.im/">Stoic Mind</a>、<a href="https://www.smashingmagazine.com/printed-books/image-optimization/">Image Optimization</a> 等。这篇文章涵盖的知识点极多,简直是前端面试必备神器。</p> <p><strong>注意:<strong>对于希望深入了解浏览器工作原理的读者,Pavel Panchekha 和 Chris Harrelson 编写的《浏览器工程》(可在 <a href="https://browser.engineering/">browser.engineering</a> 获取)是一本</strong>极佳</strong>的参考书籍,<strong>强烈推荐阅读</strong>。本文是对浏览器工作原理的概述。</p> <p>Web 开发者通常将浏览器视为一个<strong>黑盒子</strong>,它神奇地将 HTML、CSS 和 JavaScript 转换为交互式的 Web 应用程序。实际上,像 Chrome(基于 <a href="https://www.chromium.org/chromium-projects/">Chromium</a>)、Firefox(基于 <a href="https://firefox-source-docs.mozilla.org/overview/gecko.html">Gecko</a>)或 Safari(基于 <a href="https://webkit.org/">WebKit</a>)这样的现代浏览器都是极其复杂的软件系统。它们需要协调网络通信、解析和执行代码、通过 GPU 加速渲染图形,并在沙盒进程中隔离内容以确保安全。</p> <p>本文将深入探讨<strong>现代浏览器的工作原理</strong>,重点关注 <strong>Chromium</strong> 的架构和内部机制,同时指出其他引擎的不同之处。我们将探索从网络栈和解析管道,到通过 <a href="https://www.chromium.org/blink/">Blink</a> 进行渲染、通过 <a href="http://v8.dev">V8</a> 执行 JavaScript、模块加载、多进程架构、安全沙盒和开发者工具等各个方面。目标是提供一个对开发者友好的解释,揭开浏览器幕后的运作机制。</p> <p><a href="https://substackcdn.com/image/fetch/$s_!zbep!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd75ce677-87a8-497a-ab9f-495c406c056c_2650x1502.jpeg"><img src="https://substackcdn.com/image/fetch/$s_!zbep!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd75ce677-87a8-497a-ab9f-495c406c056c_2650x1502.jpeg" alt="" /></a></p> <p>那么,让我们开始探索浏览器的内部世界吧。</p> <h2><strong>网络通信和资源加载</strong></h2> <p><a href="https://substackcdn.com/image/fetch/$s_!ixg8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2eb2ffcb-56f8-4d93-8dd4-a64e0a2c44cc_1600x742.png"><img src="https://substackcdn.com/image/fetch/$s_!ixg8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2eb2ffcb-56f8-4d93-8dd4-a64e0a2c44cc_1600x742.png" alt="" /></a></p> <p>每次页面加载都始于浏览器的网络栈从 Web 获取资源。当你输入 URL 或点击链接时,浏览器的 UI 线程(运行在"<a href="https://www.chromium.org/developers/design-documents/multi-process-architecture/">浏览器进程</a>"中)会启动导航请求。</p> <blockquote> <p><strong>浏览器进程</strong>是主要的控制进程,负责管理所有其他进程和浏览器的用户界面。除了特定网页标签页之外的所有操作都由浏览器进程控制。</p> </blockquote> <p>其中具体步骤包括:</p> <p><strong>URL 解析和安全检查</strong>:浏览器解析 URL 以确定协议(http、https 等)和目标域名。它还会判断输入的是搜索查询还是 URL(例如在 Chrome 的地址栏中)。这里可能会检查安全功能如黑名单,以避免钓鱼网站。</p> <p><strong>DNS 查询</strong>:网络栈将域名解析为 IP 地址(除非已缓存)。这个过程可能需要查询 DNS 服务器。现代浏览器可能使用操作系统的 DNS 服务,甚至在配置的情况下使用 DNS over HTTPS (DoH),但最终它们都会获得主机的 IP 地址。</p> <p><strong>建立连接</strong>:如果目前不存在到该服务器的连接,浏览器会建立一个新连接。对于 HTTPS URL,这包括 TLS 握手以安全地交换密钥和验证证书。浏览器的网络线程会自动处理 TCP/TLS 设置等协议细节。</p> <p><strong>发送 HTTP 请求</strong>:连接建立后,发送 HTTP GET 请求(或其他方法)来获取资源。现代浏览器如果服务器支持,会默认使用 HTTP/2 或 HTTP/3,这允许在一个连接上复用多个资源请求。这种方式通过突破 HTTP/1.1 每个主机约 6 个并行连接的限制来提高性能。例如,使用 HTTP/2,HTML、CSS、JS、图片都可以在一个 TCP/TLS 连接上并发获取,而 HTTP/3(基于 QUIC UDP)进一步减少了建立连接的延迟。</p> <p><strong>接收响应</strong>:服务器响应 HTTP 状态码和头部,然后是响应体(HTML 内容、JSON 数据等)。浏览器读取响应流。如果 Content-Type 头部缺失或不正确,浏览器可能需要嗅探 MIME 类型来决定如何处理内容。例如,如果响应看起来像 HTML 但没有相应标记,浏览器仍会尝试将其视为 HTML(根据宽松的 Web 标准)。这里也有安全措施:网络层检查 Content-Type,可能阻止可疑的 MIME 不匹配或不允许的跨源数据(Chrome 的 CORB——跨源读取阻止——就是这样一种机制)。浏览器还会查询安全浏览或类似服务来阻止已知的恶意内容。</p> <p><strong>重定向和后续步骤</strong>:如果响应是 HTTP 重定向(例如带有 Location 头的 301 或 302),网络代码将跟随重定向(在通知 UI 线程后)并对新 URL 重复请求。只有获得包含实际内容的最终响应后,浏览器才会继续处理该内容。</p> <p>所有这些步骤都在网络栈中进行,在 Chromium 中,网络栈运行在专用的网络服务中(现在通常是一个单独的进程,作为 Chrome "<a href="https://www.chromium.org/servicification/">服务化</a>"工作的一部分)。浏览器进程的网络线程协调套接字通信的底层工作,使用操作系统网络 API。重要的是,这种设计意味着渲染器(负责执行页面代码)不能直接访问网络——它必须请求浏览器进程获取所需内容,这是一个重要的安全优势。</p> <h3><strong>预测性加载和资源优化</strong></h3> <p>现代浏览器在网络阶段实现了复杂的性能优化。当你悬停在链接上或开始输入 URL 时,Chrome 会主动执行 DNS 预取或打开 TCP 连接(使用预测器或预连接机制),这个时候你点击链接,就不存在这些延迟了。还有 HTTP 缓存:如果资源已缓存且仍然有效,网络栈可以直接从浏览器缓存提供请求,避免网络往返。</p> <p><strong>预加载扫描器操作</strong>:Chromium 实现了复杂的<a href="https://web.dev/articles/preload-scanner">预加载扫描器</a>,它会在主解析器之前对 HTML 标记进行标记化。当主 HTML 解析器被 CSS 或同步 JavaScript 阻塞时,预加载扫描器会继续检查原始标记,识别可以并行获取的资源,如图片、脚本和样式表。这种机制是现代浏览器性能的基础,无需开发者干预即可自动运行。预加载扫描器无法发现通过 JavaScript 动态注入的资源,导致这些资源可能串行而非并行加载。</p> <p><strong>Early Hints (HTTP 103)</strong>:<a href="https://developer.chrome.com/docs/web-platform/early-hints">Early Hints</a> 允许服务器在生成主响应时发送资源提示,使用 HTTP 103 状态码。这使得预连接和预加载提示可以在服务器处理时间内发送,可能将最大内容绘制时间改善几百毫秒。Early Hints 仅适用于导航请求,支持预连接和预加载指令,但不支持预取。</p> <p><strong>预测规则 API</strong>:<a href="https://developer.chrome.com/docs/web-platform/implementing-speculation-rules">预测规则 API</a> 是一个最新的 Web 标准,允许定义规则来根据用户交互模式动态预取和预渲染 URL。与传统的链接预取不同,此 API 可以预渲染整个页面,包括 JavaScript 执行,实现近乎即时的加载时间。该 API 在脚本元素或 HTTP 头中使用 JSON 语法来指定应进行预测性加载的 URL。Chrome 设有限制以防止过度使用,根据优先级别有不同的容量设置。</p> <p><strong>HTTP/2 和 HTTP/3</strong>:大多数基于 Chromium 的浏览器和 Firefox 完全支持 HTTP/2,<a href="https://alexandrehtrb.github.io/posts/2024/03/http2-and-http3-explained/">HTTP/3</a>(基于 QUIC)也得到广泛支持(Chrome 默认为支持的站点启用)。这些协议通过允许并发传输和减少握手开销来改善页面加载性能。从开发者角度来看,这意味着你可能不再需要雪碧图或域名分片技巧——浏览器可以在一个连接上高效地并行获取许多小文件。</p> <p><strong>资源优先级</strong>:浏览器还会对某些资源进行优先级排序。通常,HTML 和 CSS 是高优先级(因为它们会阻塞渲染),脚本可能是中等优先级(如果适当标记为 defer/async 则为高优先级),图片优先级可能较低。Chromium 的网络栈会分配权重,甚至可以取消或延迟请求以优先处理初始渲染所需的内容。开发者可以使用 <a href="https://web.dev/articles/preload-critical-assets">link rel=preload</a> 和 <a href="https://web.dev/articles/fetch-priority">Fetch Priority</a> 来影响资源优先级。</p> <p>在网络阶段结束时,浏览器获得了页面的初始 HTML(假设这是一个 HTML 导航)。此时,Chrome 的浏览器进程会选择一个渲染器进程来处理内容。Chrome 通常会与网络请求并行启动一个新的渲染器进程(提前准备),这样当数据到达时就已经准备就绪。这个渲染器进程是隔离的(稍后详述多进程架构),将接管解析和渲染页面的工作。</p> <p>一旦响应完全接收(或在流式传输时),浏览器进程会提交导航:它向渲染器进程发出信号,接收字节流并开始处理页面。此时,地址栏会更新,新站点的安全指示器(HTTPS 锁等)会显示。现在控制权转移到渲染器进程:解析 HTML、加载子资源、执行脚本和绘制页面。</p> <h2><strong>解析 HTML、CSS 和 JavaScript</strong></h2> <p>当渲染器进程接收到 HTML 内容时,其主线程开始根据 HTML 规范解析内容。HTML 解析的结果是 DOM(文档对象模型)——表示页面结构的对象树。解析是增量进行的,可以与网络读取交错进行(浏览器以流式方式解析 HTML,因此即使在整个 HTML 文件下载完成之前,DOM 就可以开始构建)。</p> <p><a href="https://substackcdn.com/image/fetch/$s_!A1DI!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4a9bf2c4-1b89-434d-81be-cf841789a6b2_1600x742.png"><img src="https://substackcdn.com/image/fetch/$s_!A1DI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4a9bf2c4-1b89-434d-81be-cf841789a6b2_1600x742.png" alt="" /></a></p> <p><strong>HTML 解析和 DOM 构建</strong>:HTML 解析由 HTML 标准定义为容错过程,无论标记是否存在错误,都会产生 DOM。这意味着即使你忘记了结束 <code>&lt;/p&gt;</code> 标签或标签嵌套不正确,解析器也会隐式修复或调整 DOM 树使其有效。例如,<code>&lt;p&gt;Hello &lt;div&gt;World&lt;/div&gt;</code> 会在 DOM 结构中自动在 <code>&lt;div&gt;</code> 之前结束 <code>&lt;p&gt;</code>。解析器会为 HTML 中的每个标签或文本创建 DOM 元素和文本节点。每个元素都会放置在反映源代码嵌套结构的树中。</p> <p>HTML 解析器在解析过程中可能遇到需要获取的资源,这是一个重要方面:例如,遇到 <code>&lt;link rel="stylesheet" href="..."&gt;</code> 会提示浏览器请求 CSS 文件(在网络线程上),遇到 <code>&lt;img src="..."&gt;</code> 会触发图片请求。这些请求与解析并行发生。解析器可以在这些加载进行时继续工作,但有一个重大例外:脚本。</p> <p><strong>处理 <code>&lt;script&gt;</code> 标签</strong>:如果 HTML 解析器遇到 <code>&lt;script&gt;</code> 标签,它会暂停解析,先执行脚本(默认情况下)。这是因为脚本可以使用 <code>document.write()</code> 或其他 DOM 操作来改变仍在传入的页面结构或内容。通过在该点立即执行,浏览器能够保持相对于 HTML 的正确操作顺序。因此解析器会将脚本交给 JavaScript 引擎执行,只有当脚本完成(以及它所做的任何 DOM 更改都应用)时,HTML 解析才能恢复。在头部包含大型 <code>&lt;script&gt;</code> 文件会减慢页面渲染,就是因为这种脚本执行阻塞行为——HTML 解析无法继续,直到脚本下载并运行完毕。</p> <p>但是,开发者可以通过属性修改这种行为:向 <code>&lt;script&gt;</code> 标签添加 <a href="https://web.dev/articles/efficiently-load-third-party-javascript">defer 或 async</a>(或使用现代 ES 模块脚本)会改变浏览器处理脚本的方式。使用 async,脚本文件会并行获取,一旦准备就绪就执行,不会暂停 HTML 解析(解析不会等待,脚本不保证相对于其他异步脚本的原始顺序执行)。使用 defer,脚本会并行获取,但执行会延迟到 HTML 解析完成(并将在那个时候按原始顺序执行)。在这两种情况下,解析器都不会被阻塞等待脚本,这通常对性能更有利。ES6 模块(使用 <code>&lt;script type="module"&gt;</code>)也会自动延迟(它们也可以使用 <code>import</code> 语句——我们将单独介绍模块加载)。通过使用这些技术,浏览器可以继续构建 DOM 而不会长时间暂停,使页面加载更快。</p> <p><strong>CSS 解析和 CSSOM</strong>:除了 HTML,CSS 文本也必须解析成浏览器可以使用的结构——通常称为 CSSOM(CSS 对象模型)。<a href="https://web.dev/articles/critical-rendering-path/constructing-the-object-model">CSSOM</a> 本质上是应用于文档的所有样式(规则、选择器、属性)的表示。浏览器的 CSS 解析器会读取 CSS 文件(或 <code>&lt;style&gt;</code> 块)并将它们转换为 CSS 规则列表(以及许多布隆过滤器以加速样式解析)。然后,当 DOM 正在构建时(或一旦 DOM 和 CSSOM 都准备好),浏览器会计算每个 DOM 节点的样式。这一步通常称为样式解析或样式计算。浏览器会结合 DOM 和 CSSOM 来确定每个元素应用哪些 CSS 规则以及最终计算的样式是什么(在应用级联、继承和默认样式后)。输出通常被概念化为每个 DOM 节点与计算样式的关联(该元素已解析的最终 CSS 属性,例如元素的颜色、字体、大小等)。</p> <p>值得注意的是,即使开发者没有添加任何 CSS,每个元素都有默认的浏览器样式(用户代理样式表)。例如,<code>&lt;h1&gt;</code> 在几乎所有浏览器中都有默认的字体大小和边距。浏览器的内置样式规则以最低优先级应用,确保有合理的默认呈现效果。开发者可以在 DevTools 中查看计算样式,以确切了解元素最终具有哪些 CSS 属性。样式计算步骤会使用所有适用的样式(用户代理、用户样式、作者样式)来最终确定每个元素的样式。</p> <p><strong>渲染阻塞行为</strong>:虽然 HTML 解析可以在 CSS 完全加载之前进行,但存在<a href="https://web.dev/learn/performance/understanding-the-critical-path">渲染阻塞关系</a>:浏览器通常会等待 CSS 加载后才执行首次渲染(对于 <code>&lt;head&gt;</code> 中的 CSS)。这是因为应用不完整的样式表可能会导致无样式内容闪烁。实际上,如果在 HTML 中 CSS <code>&lt;link&gt;</code> 之前出现未标记为 async/defer 的 <code>&lt;script&gt;</code>,脚本还会等待 CSS 加载后再执行(因为脚本可能通过 DOM API 查询样式信息)。作为经验法则,应将样式表链接放在头部(它们会阻塞渲染但需要早期加载),将非关键或大型脚本使用 defer/async 或放在底部,这样它们不会延迟 DOM 解析。</p> <p>现在浏览器有了(1)从 HTML 构建的 DOM,(2)解析后的 CSS 规则(CSSOM),以及(3)每个 DOM 节点的计算样式。这些一起构成了下一阶段的基础:布局。但在继续之前,我们应该更详细地考虑 JavaScript 方面——具体是 JS 引擎(Chrome 中的 V8)如何执行代码。我们涉及了脚本阻塞,但当 JS 运行时会发生什么?我们将在后面的部分专门讨论 V8 的内部机制和 JS 执行。现在,假设脚本运行时,它们可能会修改 DOM 或 CSSOM(例如,调用 <code>document.createElement</code> 或设置元素样式)。浏览器可能必须通过根据需要重新计算样式或布局来响应这些更改(如果重复进行,可能会产生性能成本)。解析期间脚本的初始运行通常包括设置事件处理程序,或者可能操作 DOM(例如模板化)。之后,页面通常完全解析,我们进入布局和渲染阶段。</p> <h2><strong>样式和布局</strong></h2> <p>在这个阶段,浏览器的渲染器进程知道 DOM 的结构和每个元素的计算样式。下一个问题是:所有这些元素在屏幕上的位置在哪里?它们有多大?这就是布局(也称为"回流"或"布局计算")需要做的。在这个阶段,浏览器根据 CSS 规则(流、盒模型、flexbox 或 grid 等)和 DOM 层次结构计算每个元素几何形状的大小和位置。</p> <p><a href="https://substackcdn.com/image/fetch/$s_!SDsn!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F85e725ec-3cf8-4ee9-935a-2d71a87b22bb_2272x1030.png"><img src="https://substackcdn.com/image/fetch/$s_!SDsn!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F85e725ec-3cf8-4ee9-935a-2d71a87b22bb_2272x1030.png" alt="" /></a></p> <p><strong>布局树构建</strong>:浏览器遍历 DOM 树并生成布局树(有时称为渲染树或框架树)。布局树在结构上类似于 DOM 树,但它会省略非可视元素(例如 script 或 meta 标签不产生框),如果需要,可能将某些元素拆分为多个框(例如,跨多行流动的单个 HTML 元素可能对应多个布局框)。布局树中的每个节点都保存该元素的计算样式,并具有节点内容(文本或图片)和影响布局的计算属性(如宽度、高度、内边距等)的信息。</p> <p>在布局期间,浏览器计算每个元素框的确切位置(x、y 坐标)和大小(宽度、高度)。这涉及 CSS 规范定义的算法:例如,在正常文档流中,块级元素从上到下堆叠,默认情况下每个都占据全宽,而内联元素在行内流动,根据需要换行。像 <a href="https://web.dev/learn/css/flexbox">flexbox</a> 或 <a href="https://web.dev/learn/css/grid">grid</a> 这样的现代布局模式有自己的算法。引擎必须考虑字体度量来断行(因此文本布局涉及测量文本运行),并且必须处理边距、内边距、边框等。有许多边缘情况(例如边距折叠规则、浮动、从流中移除的绝对定位元素等),使布局成为一个令人惊讶的复杂过程。即使是"简单"的从上到下布局也必须弄清楚文本中的换行,这取决于可用宽度和字体大小。浏览器引擎有专门的团队和多年的开发来准确高效地处理布局。</p> <p>关于布局树的一些细节:</p> <ul> <li> <p><code>display:none</code> 的元素完全从布局树中省略(它们不产生任何框)。相比之下,只是不可见的元素(例如 <code>visibility:hidden</code>)确实会得到布局框(占用空间),只是稍后不绘制。</p> </li> <li> <p>生成内容的伪元素如 <code>::before</code> 或 <code>::after</code> 包含在布局树中(因为它们确实有可视框)。</p> </li> <li> <p>布局树节点知道它们的几何形状。例如,<code>&lt;p&gt;</code> 元素的布局节点将知道其相对于视口的位置和尺寸,并为其内部的每行或内联框提供子节点。</p> </li> </ul> <p><strong>布局计算</strong>:布局通常是一个递归过程。从根(<code>&lt;html&gt;</code> 元素)开始,浏览器计算视口的大小(传给 <code>&lt;html&gt;</code>/<code>&lt;body&gt;</code>),然后在其中布局子元素,依此类推。许多元素的大小取决于它们的子元素或父元素(例如,容器可能扩展以适应子元素,或子元素可能是其父元素宽度的 50%)。布局算法通常必须对浮动或某些复杂交互等事物进行多次传递,但通常它以一个方向(自上而下)进行,如果需要可能回溯。</p> <p>到这一阶段结束时,页面上每个元素的位置和尺寸都已确定。此时,我们可以将页面在概念上视为一堆盒子(其中包含文本或图像)。不过,我们尚未在屏幕上实际绘制任何内容——那将是下一步,即“绘制”(painting)阶段。</p> <p>但是,一个关键概念:布局可能是一个昂贵的操作,特别是如果重复进行。如果 JavaScript 稍后更改元素的大小或添加内容,它可能会强制重新布局页面的某些或全部。开发者经常听到避免布局抖动的建议(如在修改 DOM 后立即在 JS 中读取布局信息,这可能强制同步重新计算)。浏览器尝试通过注意布局树的哪些部分是"脏的"并仅重新计算那些部分来优化。但最坏情况下,DOM 高层的更改可能需要为大页面重新计算整个布局。这就是为什么应该最小化昂贵的样式/布局操作以获得更好的性能。</p> <p><strong>样式和布局回顾</strong>:总结一下,从 HTML 和 CSS 浏览器构建:</p> <ul> <li>DOM 树 - 结构和内容</li> <li>CSSOM - 解析的 CSS 规则</li> <li>计算样式 - 将 CSS 规则匹配到每个 DOM 节点的结果</li> <li>布局树 - 过滤到可视元素的 DOM 树,每个节点都有几何形状</li> </ul> <p>每个阶段都建立在上一个阶段的基础上。如果任何阶段发生变化(例如,如果脚本更改 DOM 或修改 CSS 属性),后续阶段可能需要更新。例如,如果你更改元素上的 CSS 类,浏览器可能会重新计算该元素(如果继承发生变化,还有子元素)的样式,然后如果样式更改影响几何形状(比如 display 或 size),可能必须重做布局,然后必须重新绘制。这个链条意味着布局和绘制依赖于最新的样式,依此类推。我们将在 DevTools 部分讨论这种性能影响(因为浏览器提供工具来查看这些步骤何时发生以及需要多长时间)。</p> <p>布局完成后,我们进入下一个主要阶段:绘制。</p> <h2><strong>绘制、合成和 GPU 渲染</strong></h2> <p>绘制是获取结构化布局信息并实际在屏幕上产生像素的过程。在传统术语中,浏览器会遍历布局树并为每个节点发出绘制命令("在这些坐标绘制背景、绘制文本、绘制图片")。现代浏览器在概念上仍然这样做,但它们通常将工作分为多个阶段,并利用 GPU 提高效率。</p> <p><a href="https://substackcdn.com/image/fetch/$s_!A40I!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F871da49b-23af-4a7c-9671-e9228635348c_1600x1116.png"><img src="https://substackcdn.com/image/fetch/$s_!A40I!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F871da49b-23af-4a7c-9671-e9228635348c_1600x1116.png" alt="" /></a></p> <p><strong>绘制/光栅化</strong>:在渲染器的主线程上,布局之后,Chrome 通过遍历布局树生成绘制记录(或显示列表)。这基本上是一个带有坐标的绘制操作列表,很像艺术家规划如何绘制场景:例如"在 (x,y) 绘制宽度为 W 高度为 H 填充颜色为蓝色的矩形,然后在 (x2,y2) 用字体 XYZ 绘制文本 <code>Hello</code>,然后在...绘制图片"等等。这个列表按正确的 <code>z-index</code> 顺序排列(以便重叠元素正确绘制)。例如,如果一个元素有更高的 <code>z-index</code>,它的绘制命令将在较低 <code>z-index</code> 内容之后(在其上方)。浏览器必须考虑堆叠上下文、透明度等以获得正确的顺序。</p> <p>过去,浏览器可能只是按顺序直接将每个元素绘制到屏幕上。但如果页面的部分发生变化(你必须重新绘制所有内容),这种方法可能效率低下。现代浏览器通常记录这些绘制命令,然后使用合成步骤来组装最终图像,特别是在使用 GPU 加速时。</p> <p><strong>分层和合成</strong>:合成是一种优化,将页面分割为几个可以独立处理的层。例如,具有 CSS 变换或动画的定位元素可能获得自己的层。层就像单独的"草稿画布"——浏览器可以单独光栅化(绘制)每个层,然后合成器可以在屏幕上混合它们,通常使用 GPU。</p> <p>在 Chromium 的管道中,生成绘制记录后,有一个构建层树的步骤(这对应于哪些元素在哪个层上)。一些层是自动创建的(例如视频元素、画布或具有某些 CSS 的元素将被提升到层),开发者可以通过使用 <code>will-change</code> 或 <code>transform</code> 等 CSS 属性来提示获得层。层有用的原因是层上的移动或不透明度变化可以合成(即只重新渲染或移动该层)而无需重新绘制整个页面。但是,太多层可能会占用大量内存并增加开销,因此浏览器会谨慎选择。</p> <p>确定层后,Chrome 的主线程交给合成器线程。合成器线程在渲染器进程中运行,但与主线程分离(因此即使主 JS 线程繁忙,它也可以继续工作,这对于平滑滚动和动画很好)。合成器线程的工作是获取层,光栅化它们(将绘制转换为实际像素位图),并将它们合成为帧。</p> <p><strong>GPU 辅助光栅化</strong>:光栅工作也可以分布。在 Chrome 中,合成器线程将层分解为更小的瓦片(想象 256x256 或 512x512 像素块,当 GPU 光栅开启时通常更大,几乎总是如此)。然后它将这些分派给几个光栅工作线程(可能甚至跨多个 CPU 核心运行)进行并发光栅化。每个光栅处理器获取一个瓦片 - 本质上是该层区域的绘制命令列表 - 并产生位图(像素数据)。重要的是,Skia(Chrome 的图形库)可以使用 CPU 或 GPU 进行光栅化;在 Chrome 的情况下,这些光栅线程通常使用 CPU 渲染像素,然后将它们上传到 GPU 内存。Firefox 的较新 WebRender 采用了我们稍后会提到的不同方法。光栅化的瓦片作为纹理存储在 GPU 内存中。一旦所有需要的瓦片都绘制完成,合成器线程基本上就有了一组准备好的纹理层。</p> <p>简单来说,这就是一条发给浏览器进程的消息,其中包含了构成屏幕的所有四边形(即图层的瓦片)、它们的位置等信息。这个合成器帧通过 IPC 提交回浏览器进程,最终浏览器的 GPU 进程(Chrome 中用于访问 GPU 的单独进程)将获取这些并显示它们。浏览器进程自己的 UI(如标签栏)也通过合成器帧绘制,它们都在最后一步混合。GPU 进程接收帧,并使用 GPU(通过 OpenGL/DirectX/Metal 等)合成它们 - 基本上在屏幕上的正确位置绘制每个纹理,应用变换等,非常快。结果是你看到的最终图像。</p> <p>这个管道的优势在滚动或动画时很明显。例如,滚动页面主要只是改变更大页面纹理上的视口。合成器可以只是移动层位置并要求 GPU 重绘进入视图的新部分,而无需主线程重新绘制所有内容。如果动画只是变换(比如移动一个自己层的元素),合成器线程可以更新该元素每帧的位置并产生新帧,而不涉及主线程或重新运行样式和布局。这就是为什么推荐"仅合成"的动画(更改 <code>transform</code> 或 <code>opacity</code>,不触发布局)以获得更好的性能 - 即使主线程繁忙,它们也可以以 60 FPS 平滑运行。相比之下,动画高度或背景颜色等可能强制每帧重新布局或重新绘制,如果主线程跟不上就会卡顿。</p> <p>简而言之,Chrome 的渲染管道是:DOM → 样式 → 布局 → 绘制(记录显示项)→ 分层 → 光栅(瓦片)→ 合成(GPU)。Firefox 的管道在显示列表阶段之前概念上类似,但使用 WebRender 它跳过显式层构建,而是将显示列表发送到 GPU 进程,然后使用 GPU 着色器处理几乎所有绘制(稍后在比较部分详述)。WebKit(Safari)也使用多线程合成器和通过 macOS 上的"CALayers"进行 GPU 渲染。因此,所有现代引擎都利用 GPU 进行渲染,特别是用于合成和光栅化图形密集型部分,以实现高帧率并减轻 CPU 负载。</p> <p>在继续之前,让我们更详细地讨论 GPU 的作用。在 Chromium 中,GPU 进程是一个单独的进程,其工作是与图形硬件接口。它从所有渲染器合成器以及浏览器 UI 接收绘制命令(主要是高级的,如"在这些坐标绘制这些纹理")。然后它将其转换为实际的 GPU API 调用。通过将其隔离在进程中,有问题的 GPU 驱动程序崩溃不会拖垮整个浏览器,只有影响可重启的 GPU 进程。此外,它提供了沙盒边界(因为 GPU 处理潜在不受信任的内容,如画布绘制、WebGL 等,驱动程序中存在安全漏洞 - 在进程外运行可以减轻风险)。</p> <p>合成的结果最终发送到显示器(浏览器运行的操作系统窗口或上下文)。对于每个动画帧(目标 60fps 或每帧 16.7ms 以获得平滑结果),合成器旨在产生一帧。如果主线程繁忙(比如 JavaScript 花费了很长时间),合成器可能跳过帧或无法更新,导致可见的卡顿。开发者工具可以在性能时间线中显示丢帧。像 <code>requestAnimationFrame</code> 这样的技术将 JS 更新与帧边界对齐,以帮助平滑渲染。</p> <p>总之,浏览器的渲染引擎仔细地将页面内容和样式分解为一组几何形状(布局)和绘制指令,然后使用层和 GPU 合成有效地将其转换为你看到的像素。这个复杂的管道使 Web 上丰富的图形和动画能够以交互式帧率运行。接下来,我们将窥视 JavaScript 引擎,了解浏览器如何执行脚本(到目前为止我们将其视为黑盒子)。</p> <h2><strong>JavaScript 引擎内部(V8)</strong></h2> <p>JavaScript 驱动网页的交互行为。在 Chromium 浏览器中,V8 引擎执行 JavaScript(和 WebAssembly)。了解 V8 的工作原理可以帮助开发者编写高性能的 JS。虽然详尽的深入探讨需要一本书的篇幅,但我们将重点关注 JS 执行管道的关键阶段:解析/编译代码、执行代码和管理内存(垃圾收集)。我们还将注意 V8 如何处理现代功能,如即时(JIT)编译层和 ES 模块。</p> <p><a href="https://substackcdn.com/image/fetch/$s_!x62F!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37778d69-97f7-432e-8028-39675d784a6f_1600x1116.png"><img src="https://substackcdn.com/image/fetch/$s_!x62F!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37778d69-97f7-432e-8028-39675d784a6f_1600x1116.png" alt="" /></a></p> <h3><strong>现代 V8 解析和编译管道</strong></h3> <p><a href="https://substackcdn.com/image/fetch/$s_!YJWi!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8306080-5174-480f-8e35-fb316aa97806_2400x830.jpeg"><img src="https://substackcdn.com/image/fetch/$s_!YJWi!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8306080-5174-480f-8e35-fb316aa97806_2400x830.jpeg" alt="" /></a></p> <p><strong>后台编译</strong>:从 Chrome 66 开始,V8 在后台线程上编译 JavaScript 源代码,在典型网站上将主线程上的编译时间减少了 5% 到 20%。自版本 41 以来,Chrome 通过 V8 的 StreamedSource API 支持在后台线程上解析 JavaScript 源文件。V8 可以在从网络下载第一个块时开始解析 JavaScript 源代码,并在流式传输文件时继续并行解析。几乎所有脚本编译都发生在后台线程上,只有短暂的 AST 内化和字节码最终化步骤在脚本执行前在主线程上发生。目前,顶级脚本代码和立即调用的函数表达式在后台线程上编译,而内部函数在首次执行时仍在主线程上延迟编译。</p> <p><strong>解析和字节码</strong>:当遇到 <code>&lt;script&gt;</code>(在 HTML 解析期间或稍后加载)时,V8 首先解析 JavaScript 源代码。这产生代码的抽象语法树(AST)表示。预解析器是解析器的副本,它执行跳过函数所需的最少工作。它验证函数在语法上有效,并产生外部函数正确编译所需的所有信息。当稍后调用预解析的函数时,它会按需完全解析和编译。</p> <p>V8 不是直接从 AST 解释,而是使用名为 Ignition 的字节码解释器(2016 年引入)。Ignition 将 JavaScript 编译为紧凑的字节码格式,这本质上是虚拟机的指令序列。这种初始编译相当快,字节码相当低级(Ignition 是基于寄存器的虚拟机)。目标是快速开始执行代码,前期成本最小(对页面加载时间很重要)。</p> <p><strong>AST 内化过程</strong>:AST 内化涉及在 V8 堆上分配字面对象(字符串、数字、对象字面量样板)供生成的字节码使用。为了启用后台编译,这个过程在编译管道中被移到了后面,在字节码编译之后,需要修改以访问嵌入在 AST 中的原始字面值,而不是内化的堆上值。</p> <p><strong>显式编译提示</strong>:V8 引入了一个名为"<a href="https://v8.dev/blog/explicit-compile-hints">显式编译提示</a>"的新功能,允许开发者指示 V8 在加载时通过急切编译立即解析和编译代码。带有此提示的文件在后台线程上编译,而延迟编译在主线程上发生。对流行网页的实验显示,20 个案例中有 17 个性能改善,前台解析和编译时间平均减少 630ms。开发者可以使用特殊注释向 JavaScript 文件添加显式编译提示,为关键代码路径启用后台线程上的急切编译。</p> <p><strong>扫描器和解析器优化</strong>:V8 的扫描器已经显著优化,在各方面都有改进:单令牌扫描改进了大约 1.4×,字符串扫描改进了 1.3×,多行注释扫描改进了 2.1×,标识符扫描根据标识符长度改进了 1.2-1.5×。</p> <p>当脚本运行时,Ignition 解释字节码,执行程序。解释通常比优化的机器代码慢,但它允许引擎开始运行,也收集关于代码行为的分析信息。当代码运行时,V8 收集关于如何使用它的数据:变量类型、经常调用哪些函数等。这些信息将用于在后续步骤中使代码运行更快。</p> <h3><strong>JIT 编译层</strong></h3> <p>V8 不止于解释。它采用多层即时编译器来加速热代码。思路是在运行很多的代码上花费更多编译努力,使其更快,同时不浪费时间优化只运行一次的代码。</p> <ol> <li> <p><strong>Ignition</strong>(解释字节码)。</p> </li> <li> <p><strong>Sparkplug</strong>:V8 的基线 JIT 称为 Sparkplug(2021 年左右推出)。Sparkplug 获取字节码并快速编译为机器代码,没有重度优化。这产生比解释更快的本机代码,但 Sparkplug 不做深度分析 - 它意味着几乎和解释器一样快开始,但产生运行稍快的代码。</p> </li> <li> <p><strong>Maglev</strong>:2023 年,V8 引入了 Maglev,一个中层优化编译器,现在正在积极部署。Maglev 生成代码的速度比 Sparkplug 慢近 20 倍,但比 TurboFan 快 10 到 100 倍,有效地为中等热度但不足以进行 TurboFan 优化的函数填补了空白。Maglev 适用于有些热但不足以进行 TurboFan 的函数,或者当 TurboFan 的编译成本太高时。截至 Chrome M117,Maglev 可以处理许多情况,通过为在"温暖"代码(不冷,不超热)中花费时间的 Web 应用程序填补基线和最高层 JIT 之间的空白,从而实现更快的启动。</p> </li> <li> <p><strong>TurboFan</strong>:当函数或循环被执行很多次时,V8 将启用其最强大的优化编译器。TurboFan 获取代码并使用收集的类型反馈生成高度优化的机器代码,应用高级优化(内联函数、消除边界检查等)。如果假设成立,这种优化代码可以运行得更快。</p> </li> </ol> <p>因此,V8 现在有效地有四个执行层:Ignition 解释器、Sparkplug 基线 JIT、Maglev 优化 JIT 和 TurboFan 优化 JIT。这类似于 Java 的 HotSpot VM 有多个 JIT 级别(C1 和 C2)。引擎可以根据执行配置文件动态决定优化哪些函数以及何时优化。如果一个函数突然被调用一百万次,它很可能最终被 TurboFan 优化以获得最大速度。</p> <p>Intel 还开发了<a href="https://community.intel.com/t5/Blogs/Tech-Innovation/Client/Profile-Guided-Tiering-in-the-V8-JavaScript-Engine/post/1679340">配置文件引导分层</a>,增强了 V8 的效率,在 Speedometer 3 基准测试中带来了大约 5% 的改进。最近的 V8 更新包括静态根优化,允许在编译时准确预测常用对象的内存地址,显著提高访问速度。</p> <p>JIT 优化的一个挑战是 JavaScript 是动态类型的。V8 可能在某些假设下优化代码(例如这个变量总是整数)。如果稍后的调用违反了这些假设(比如变量变成字符串),优化代码就无效了。V8 然后执行去优化:它回退到不太优化的版本(或用新假设重新生成代码)。这种机制依赖于"内联缓存"和类型反馈来快速适应。去优化的存在意味着如果你的代码有不可预测的类型,有时峰值性能不会持续,但通常 V8 试图处理典型模式(如一个函数一致地传递相同类型的对象)。</p> <h3><strong>字节码刷新和内存管理</strong></h3> <p>V8 实现字节码刷新,如果函数在多次垃圾收集后仍未使用,其字节码将被回收。再次执行时,解析器使用先前存储的结果更快地重新生成字节码。这种机制对内存管理至关重要,但在边缘情况下可能导致解析不一致。</p> <p><strong>内存管理(垃圾收集)</strong>:V8 使用垃圾收集器自动管理 JS 对象的内存。多年来,V8 的 GC 已经发展成所谓的 Orinoco GC,这是一个分代、增量和并发垃圾收集器。要点:</p> <ul> <li> <p><strong>分代</strong>:V8 按年龄分离对象。新对象分配在年轻代(或"托儿所")。这些经常用非常快的清理算法(将活对象复制到新空间并回收其余部分)收集。存活足够周期的对象被提升到老年代。</p> </li> <li> <p><strong>标记-清扫/压缩</strong>:对于老年代,V8 使用带压缩的标记-清扫收集器。这意味着它偶尔会短暂停止 JS 执行(The! World!),标记所有可达对象(从全局对象等根追踪),然后清扫回收未引用对象的内存。它也可能压缩内存(移动对象以减少碎片)。但是,Orinoco 使大部分标记并发 - 它可以在 JS 仍在运行时在后台线程上进行大量标记工作,以最小化暂停时间。</p> </li> <li> <p><strong>增量 GC</strong>:V8 在可能的情况下以小片段而不是一个大暂停执行垃圾收集。这种增量方法将工作分散以避免卡顿。例如,它可以在脚本执行之间穿插一点标记工作,使用空闲时间。</p> </li> <li> <p><strong>并行 GC</strong>:在多核机器上,V8 也可以在并行线程中执行 GC 的部分(如标记或清扫)。</p> </li> </ul> <p>最终效果是,V8 团队多年来大幅缩短了垃圾回收(GC)的暂停时间,使得即使在大型应用中,垃圾回收也几乎难以察觉。次要垃圾回收(Minor GC,即新生代对象的清理)通常执行得非常迅速;而主要垃圾回收(Major GC,即老生代)则更为罕见,且如今大部分已转为并发执行。如果你打开 Chrome 的任务管理器或 DevTools 的内存面板,可能会看到 V8 的堆被划分为“新生代空间”(Young space)和“老生代空间”(Old space),这正体现了其分代式设计。</p> <p>对于开发者,这意味着不需要手动内存管理,但你仍应该注意:例如避免在紧密循环中创建大量短期对象(尽管 V8 在处理短期对象方面相当好),并意识到持有大型数据结构会使它们保留在内存中。像 DevTools 这样的工具可以强制垃圾收集或记录内存配置文件以查看什么在使用内存。</p> <p><strong>V8 和 Web API</strong>:值得一提的是,V8 涵盖核心 JavaScript 语言和运行时(执行、标准 JS 对象等),但许多"浏览器 API"(如 DOM 方法、<code>alert()</code>、网络 XHR/fetch 等)不是 V8 本身的一部分。这些由浏览器提供,通过绑定暴露给 JS。例如,当你调用 <code>document.querySelector</code> 时,在底层它进入引擎对 C++ DOM 实现的绑定。V8 负责处理对 C++ 的调用并返回结果,为此投入了大量工程优化,以加速 JavaScript 与 C++ 之间的交互(Chrome 使用 IDL 自动生成高效的绑定代码)。</p> <p>在介绍了浏览器如何获取资源、解析 HTML/CSS、计算布局、用 GPU 绘制和运行 JS 之后,我们现在对加载和渲染页面的整个过程有了一个图景。但还有更多要探索的:ES 模块如何处理(因为模块涉及它们自己的加载机制)、浏览器的多进程架构如何组织,以及沙盒和站点隔离等安全功能如何工作。</p> <h2><strong>模块加载和导入映射</strong></h2> <p><a href="https://v8.dev/features/modules">JavaScript 模块</a>(ES6 模块)引入了与经典 <code>&lt;script&gt;</code> 标签不同的加载和执行模型。模块不是可能创建全局变量的大脚本文件,而是显式导入/导出值的文件。让我们看看浏览器(特别是 Chrome 中的 V8)如何加载模块,以及动态 <code>import()</code> 和导入映射等功能如何发挥作用。</p> <p><strong>静态模块导入</strong>:当浏览器遇到 <code>&lt;script type="module" src="main.js"&gt;</code> 时,它将 main.js 视为模块入口点。加载过程如下:浏览器将获取 main.js,然后将其解析为 ES 模块。在解析期间,它将找到任何导入语句(例如 <code>import { foo } from './utils.js';</code>)。浏览器不是立即执行代码,而是构建模块依赖图。它将启动获取任何导入的模块(在这种情况下是 utils.js),递归地,每个模块都被解析为它们的导入,获取,依此类推。这异步发生。只有一旦整个模块图被获取和解析,浏览器才能评估模块。模块脚本本质上是延迟的——浏览器不执行模块代码,直到所有依赖项都准备好。然后它按依赖顺序执行它们(确保如果模块 A 导入 B,B 先运行)。</p> <p>这种静态导入过程是为什么 ES 模块在某些情况下无法从 <code>file://</code> 加载,除非允许,以及为什么它们默认需要 CORS 用于跨源脚本——浏览器正在积极链接和加载多个文件,而不仅仅是将 <code>&lt;script&gt;</code> 放入页面。</p> <p><strong>动态 import()</strong>:除了静态导入语句,ES2020 引入了 <code>import(moduleSpecifier)</code> 作为表达式。这允许代码动态加载模块(返回解析为模块导出的 promise)。例如,你可能在响应用户操作时执行 <code>const module = await import('./analytics.js')</code>,从而对应用程序进行代码分割。在底层,<code>import()</code> 触发浏览器获取请求的模块(及其依赖项,如果尚未加载),然后实例化和执行它,并用模块命名空间对象解析 promise。V8 和浏览器在这里协调:浏览器的模块加载器处理获取和解析,V8 在准备好后处理编译和执行。动态导入很强大,因为它也可以在非模块脚本中使用(例如内联脚本可以动态导入模块)。它本质上给开发者控制按需加载 JS。与静态导入的区别是静态导入提前解析(在任何模块代码运行之前,整个图被加载),而动态导入更像在运行时加载新脚本(除了具有模块语义和 promise)。</p> <p><strong>导入映射</strong>:ES 模块在浏览器中的一个挑战是模块说明符。在 Node 或打包器中,你经常按包名导入(例如 <code>import { compile } from 'react'</code>)。在没有打包器的 Web 上,'react' 不是有效的 URL——浏览器会将其视为相对路径(这会失败)。这就是导入映射的用武之地。导入映射是一个 JSON 配置,告诉浏览器如何将模块说明符解析为真实 URL。它通过 HTML 中的 <code>&lt;script type="importmap"&gt;</code> 标签提供。例如,导入映射可能说说明符 <code>react</code> 映射到 <code>https://cdn.example.com/[email protected]/index.js</code>(某个脚本的完整 URL)。然后,当任何模块执行 <code>import 'react'</code> 时,浏览器使用映射找到 URL 并加载它。本质上,导入映射通过将“裸”标识符(如包名)映射到 CDN 链接或本地路径,使其能在网页上正常工作。</p> <p>导入映射对无打包开发来说是一个游戏改变者。自 2023 年以来,导入映射在所有主要浏览器中都得到支持(Chrome 89+、Firefox 108+、Safari 16.4+)。它们对于本地开发或你想在没有构建步骤的情况下使用模块的简单应用程序特别有用。对于生产,大型应用程序通常仍然打包以提高性能(减少请求数量),但随着浏览器和 HTTP/2/3 的改进,提供许多小模块变得更可行。</p> <p>因此,浏览器中的模块加载器包括:模块映射(跟踪已加载的内容),可能的导入映射(用于自定义解析)和获取/解析逻辑。一旦获取和编译,模块代码在严格模式下执行,并具有自己的顶级作用域(除非显式附加,否则不会泄漏到 window)。导出被缓存,因此如果另一个模块稍后导入相同的模块,它不会重新运行(它重用已评估的模块记录)。</p> <p>还要提到的一个方面是 ES 模块与脚本不同,延迟执行,也按给定图的顺序执行。如果 main.js 导入 util.js,util.js 导入 dep.js,评估顺序将是:首先 dep.js,然后 util.js,然后 main.js(深度优先,后序)。这种确定性顺序可以避免在某些情况下需要 DOMContentLoaded 等,因为当你的主模块运行时,所有导入都已加载和执行。</p> <p>从 V8 的角度来看,模块由相同的编译管道处理,但它们创建单独的 ModuleRecords。引擎确保模块的顶级代码只有在所有依赖项准备好后才运行。V8 还必须处理循环模块导入(这是允许的,可能导致部分初始化的导出)。细节按规范 - 但本质上,引擎将创建所有模块实例,然后通过给它们占位符来解析循环,然后以尊重依赖关系的顺序执行(规范算法是模块图的"DAG"拓扑排序)。</p> <p>总之,浏览器中的模块加载是网络(获取模块文件)、模块解析器(使用导入映射或标准 URL 解析)和 JS 引擎(以正确顺序编译和评估模块)之间的协调舞蹈。它比旧的 <code>&lt;script&gt;</code> 加载更复杂,但产生更模块化和可维护的代码结构。对于开发者,关键要点是:使用模块来组织代码,如果你想要裸导入就使用导入映射,并知道你可以在需要时通过 import() 动态加载模块。浏览器将处理确保一切以正确顺序执行的繁重工作。</p> <p>现在我们已经介绍了单个页面的内部工作原理,让我们放大并检查允许多个页面、标签和 Web 应用程序同时运行而不相互干扰的浏览器架构。这将我们带到多进程模型。</p> <h2><strong>浏览器多进程架构</strong></h2> <p>现代浏览器(Chrome、Firefox、Safari、Edge 等)都使用多进程架构来实现稳定性、安全性和性能隔离。不是将整个浏览器作为一个巨大的进程运行(这是早期浏览器的工作方式),浏览器的不同方面在不同的进程中运行。Chrome 在 2008 年是这种方法的先驱,其他浏览器以各种形式跟进。让我们重点关注 Chromium 的架构,并注意 Firefox 和 Safari 的差异。</p> <p>在 Chromium(Chrome、Edge、Brave 等)中,有一个中心的<strong>浏览器进程</strong>。这个浏览器进程负责 UI(地址栏、书签、菜单等整个浏览器外壳)和协调高级任务,如资源加载和导航。当你打开 Chrome 并在操作系统任务管理器中看到一个条目时,那就是浏览器进程。它也是生成其他进程的父进程。</p> <p>然后,对于每个标签(有时对于标签中的每个站点),Chrome 创建一个<strong>渲染器进程</strong>。渲染器进程为该标签的内容运行 Blink 渲染引擎和 V8 JS 引擎。一般来说,每个标签至少获得一个渲染器进程。</p> <p><a href="https://substackcdn.com/image/fetch/$s_!-21l!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46fb7fe0-b266-4767-bd4d-de835fac5126_1536x1024.jpeg"><img src="https://substackcdn.com/image/fetch/$s_!-21l!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F46fb7fe0-b266-4767-bd4d-de835fac5126_1536x1024.jpeg" alt="" /></a></p> <p>如果你打开多个不相关的站点,它们将在单独的进程中(站点 A 在一个,站点 B 在另一个,等等)。Chrome 甚至将跨源 iframe 隔离到单独的进程中(稍后在站点隔离中详述)。渲染器进程是沙盒化的,不能直接访问你的文件系统或网络 - 它必须通过浏览器进程进行这些特权操作。</p> <p>Chrome 中的其他关键进程包括:</p> <ul> <li> <p><strong>GPU 进程</strong>:专门用于与 GPU 通信的进程(如前所述)。来自渲染器的所有渲染和合成请求都发送到 GPU 进程,该进程实际发出图形 API 调用。这个进程是沙盒化和分离的,因此 GPU 崩溃不会拖垮渲染器。</p> </li> <li> <p><strong>网络进程</strong>:(在较旧的 Chrome 版本中,网络是浏览器进程中的线程,但现在通过"服务化"通常是单独的进程)。这个进程处理网络请求、DNS 等,可以单独沙盒化。</p> </li> <li> <p><strong>实用程序进程</strong>:这些用于各种服务(如音频播放、图像解码等),Chrome 可能会卸载。</p> </li> <li> <p><strong>插件进程</strong>:在 Flash 和 NPAPI 插件时代,插件在自己的进程中运行。Flash 现在已弃用,所以这不太相关,但架构仍然准备好让插件不在主浏览器进程中运行。</p> </li> <li> <p><strong>扩展进程</strong>:Chrome 扩展(本质上是可以作用于网页或浏览器的脚本)也在单独的进程中运行,与网站隔离以确保安全。</p> </li> </ul> <p>简化的视图是:一个浏览器进程协调多个渲染器进程(每个标签或每个站点实例一个),加上一个 GPU 进程和一些其他服务。Chrome 的任务管理器(Windows 上的 Shift+Esc 或通过更多工具 &gt; 任务管理器)实际上会列出每种进程类型及其内存使用情况。</p> <p><strong>多进程</strong>的主要好处是:</p> <ul> <li> <p><strong>稳定性</strong>:如果网页(渲染器进程)崩溃或泄漏内存,它不会崩溃整个浏览器 - 你可以关闭该标签,其余部分保持活动。在单进程浏览器中,单个坏脚本可能拖垮一切。Chrome 可以在其进程死亡时为单个标签显示"啊,崩溃"错误,你可以独立重新加载它。</p> </li> <li> <p><strong>安全(沙盒化)</strong>:通过在受限进程中运行 Web 内容,浏览器可以限制该代码在你的系统上可以做什么。即使攻击者在渲染引擎中发现漏洞,他们也被困在沙盒中 - 渲染器进程通常无法读取你的文件或任意打开网络连接或启动程序。它必须向浏览器进程请求文件访问等,这可以验证或拒绝。这个沙盒在操作系统级别强制执行(根据平台使用作业对象、seccomp 过滤器等)。</p> </li> <li> <p><strong>性能隔离</strong>:一个标签中的密集工作(重型 webapp 或无限循环)主要限制在该标签的渲染器进程中。其他标签(不同进程)可以保持响应,因为它们的进程没有被阻塞。此外,操作系统可以在不同的 CPU 核心上调度进程 - 因此两个重型页面可以在多核系统上比它们是一个进程的线程更好地并行运行。</p> </li> <li> <p><strong>内存分段</strong>:每个进程都有自己的地址空间,因此内存不共享。这防止一个站点窥探另一个站点的数据,也意味着当标签关闭时,操作系统可以有效地回收该进程的所有内存。缺点是由于重复资源和进程的一些开销(每个渲染器加载自己的 JS 引擎副本等)。</p> </li> </ul> <p><strong>站点隔离</strong>:最初,Chrome 的模型是每个标签一个进程。随着时间的推移,他们将其发展为每个站点一个进程(特别是在 Spectre 之后 - 见下一节关于安全)。截至 2024 年,站点隔离默认为桌面平台上 99% 的 Chrome 用户启用,Android 支持继续完善。这意味着如果你有两个标签都打开到 example.com,Chrome 可能决定为两者使用一个进程(为了节省内存,因为它们是同一站点,因此放在一起风险较小)。但是一个带有 example.com 和 evil.com iframe 的标签默认会将 evil.com 的 iframe 放在与父页面分离的单独进程中(以保护 example.com 数据)。这种强制执行是 Chrome 称为"严格站点隔离"的(大约在 Chrome 67 作为默认启动)。站点隔离导致 Chrome 由于增加的进程创建而使用 10-13% 更多的系统资源,但提供了关键的安全好处。</p> <p>Firefox 的架构,称为 <a href="https://blog.mozilla.org/addons/2016/04/11/the-why-of-electrolysis/">Electrolysis</a>(e10s),历史上是所有标签一个内容进程(多年来 Firefox 是单进程的,直到 2017 年左右才启用几个内容进程)。截至 2021 年,Firefox 使用多个内容进程(默认为 Web 内容 8 个)。通过 <a href="https://blog.mozilla.org/security/2021/05/18/introducing-site-isolation-in-firefox/">Project Fission</a>(站点隔离),Firefox 正在向类似地隔离站点发展 - 它可以为跨站点 iframe 启动新进程,在 Firefox 108+ 中他们默认启用站点隔离,将进程数量增加到像 Chrome 一样每个站点一个。Firefox 也有 GPU 进程(用于 WebRender 和合成)和单独的网络进程,类似于 Chrome 的分割。因此实际上,Firefox 现在有一个非常像 Chrome 的模型:父进程、GPU 进程、网络进程、几个内容(渲染器)进程,以及一些实用程序进程(用于扩展、媒体解码等 - 例如媒体插件可以隔离运行)。</p> <p>Safari(WebKit)同样转向多进程模型(WebKit2),其中每个标签的内容在单独的 WebContent 进程中,中央 UI 进程控制它们。Safari 的 WebContent 进程也是沙盒化的,不能直接访问设备或文件而不通过 UI 进程。Safari 也有一个共享的网络进程(可能还有其他助手)。因此虽然实现不同,概念是一致的:将每个网页的代码隔离在自己的沙盒环境中。</p> <p>这里有一个重要点,进程间通信(IPC):这些进程如何相互交谈?浏览器使用 IPC 机制(在 Windows 上,通常是命名管道或其他操作系统 IPC;在 Linux 上,可能是 Unix 域套接字或共享内存;Chrome 有自己的 IPC 库 Mojo)。例如,当网络响应到达网络进程时,它需要传递给正确的渲染器进程(通过浏览器进程协调)。类似地,当你执行 DOM fetch() 时,JS 引擎将调用网络 API,该 API 向网络进程发送请求,依此类推。IPC 增加了复杂性,但浏览器大量优化(例如使用共享内存有效传输大数据如图像,并发布异步消息以避免阻塞)。</p> <p><strong>进程分配策略</strong>:Chrome 并不总是为每个单独的标签创建全新的进程 - 有限制(特别是在内存不足的设备上,它可能为同站点标签重用进程)。如果你打开同一站点的另一个标签,Chrome 将重用现有渲染器以节省内存(这就是为什么有时同一站点的两个标签共享进程)。它也有总进程限制(可以根据 RAM 扩展)。当达到限制时,它可能开始将多个不相关的站点放在一个进程中,尽管如果启用站点隔离,它会努力避免混合站点。在 Android 上,Chrome 由于内存限制使用更少的进程(通常最多 5-6 个内容进程)。</p> <p>Chromium 中的另一个概念是<strong>服务化</strong>:将浏览器组件拆分为可以在单独进程中运行的服务。例如,网络服务被制作为可以进程外运行的单独模块。想法是模块化 - 强大的系统可以在自己的进程中运行每个服务,而受限设备可能将一些服务合并回一个进程以节省开销。Chrome 可以在运行时或构建时决定如何部署这些服务。如片段中所述,在高端它可能分割一切(UI、网络、GPU 等都分离),在低端(Android)它可能将浏览器和网络合并在一个进程中以减少开销。</p> <p>要点:Chromium 的架构旨在在不同的沙盒中运行浏览器 UI 和每个站点,使用进程作为隔离边界。Firefox 和 Safari 已经收敛到类似的设计。这种架构以更多内存使用为代价大大提高了安全性和可靠性。Web 内容进程被视为不受信任的,这就是站点隔离(下一节)发挥作用的地方,甚至在单独的进程中将不同的源彼此隔离。</p> <h2><strong>站点隔离和沙盒化</strong></h2> <p>站点隔离和沙盒化是建立在多进程基础上的安全功能。它们旨在确保即使恶意代码在浏览器中运行,它也不能轻易从其他站点窃取数据或访问你的系统。</p> <p><strong>站点隔离</strong>:我们已经涉及了这一点——它意味着不同的网站(更严格地说,不同的站点)在不同的渲染器进程中运行。Chrome 的站点隔离在 2018 年 <a href="https://developer.chrome.com/blog/meltdown-spectre">Spectre 漏洞</a>曝光后得到了推动。Spectre 显示恶意 JavaScript 可能通过利用 CPU 推测执行读取它不应该读取的内存。如果两个站点在同一进程中,恶意站点可能使用 Spectre 窥探敏感站点(如你的银行站点)的内存。唯一强大的解决方案是根本不让它们共享进程。因此 Chrome 使站点隔离成为默认:每个站点获得自己的进程,包括跨源 iframe。Firefox 通过 Project Fission(在最近版本中默认启用)跟进,旨在相同——他们引用在自己的进程中隔离每个站点以确保安全。这是与过去的重大变化,过去如果你有一个父页面和来自各个域的多个 iframe,它们可能都生活在一个进程中(特别是如果它们在一个标签中)。现在,这些 iframe 将被分割,以便例如好站点页面上的 <code>&lt;iframe src="https://evil.com"&gt;</code> 被强制进入不同的进程,防止甚至低级攻击在它们之间泄漏信息。</p> <p>从开发者的角度来看,站点隔离大多是透明的。一个含义是嵌入式 iframe 和其父级之间的通信现在可能跨越进程边界,因此它们之间的 <code>postMessage</code> 等在底层通过 IPC 实现。但浏览器隐藏了此处细节,你作为开发者只需要正常使用 API。</p> <p><strong>沙盒化</strong>:每个渲染器进程(和其他辅助进程)在具有受限权限的沙盒中运行。例如,在 Windows 上,Chrome 使用作业对象并删除权限,因此渲染器无法调用访问系统的大多数 Win32 API。在 Linux 上,它使用命名空间和 seccomp 过滤器来限制系统调用。渲染器基本上可以计算和渲染内容,但如果它尝试打开文件或摄像头或麦克风,它将被阻塞(除非通过适当的渠道请求通过浏览器进程的用户权限)。WebKit 的文档明确指出 WebContent 进程没有直接访问文件系统、剪贴板、设备等的权限——它们必须通过调解的 UI 进程请求。这就是为什么,例如,当站点尝试使用你的麦克风时,权限提示由浏览器 UI(浏览器进程)显示,如果允许,实际录制在受控进程中完成。沙盒是关键的防线。即使攻击者发现在渲染器中运行本机代码的错误,他们然后面临沙盒屏障——他们需要单独的漏洞("逃逸")来突破到系统。这种分层方法(称为站点隔离 + 沙盒)是浏览器安全的最先进技术。</p> <p>Firefox 的沙盒化现在也相当严格(在早期 e10s 时代较弱,但他们加强了)。Firefox 内容进程也不能直接访问太多;Firefox 也沙盒化 GPU 进程以处理图形驱动程序问题。</p> <p><strong>进程外 iframe(OOPIF)</strong>:在 Chrome 的站点隔离实现中,他们发明了术语 <a href="https://www.chromium.org/developers/design-documents/oop-iframes/">OOPIF</a> 用于进程外 iframe。从用户的角度来看,什么都没有改变,但在 Chrome 的内部架构中,页面的每个框架都可能由不同的渲染器进程支持。顶级框架和同站点框架共享一个进程;跨站点框架使用不同的进程。所有这些进程"合作"渲染单个标签的内容,由浏览器进程协调。这相当复杂,但 Chrome 有一个可以跨进程的框架树。这意味着你的一个标签可能运行 N 个进程(一个用于主文档,其他用于每个跨站点子文档)。它们通过 IPC 通信,用于跨边界的 DOM 事件或涉及跨上下文的某些 JavaScript 调用等。Web 平台(通过 <a href="https://web.dev/articles/coop-coep">COOP/COEP</a>、<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer">SharedArrayBuffer</a> 等规范)在 Spectre 之后正在考虑这些约束而发展。</p> <p><strong>内存和性能成本</strong>:站点隔离确实增加了内存使用,因为使用了更多进程。Chrome 开发者注意到在某些情况下可能有 10-20% 的内存<a href="https://www.thurrott.com/mobile/chrome-os/162980/spectre-mitigation-increases-chrome-memory-usage-google-says">开销</a>。他们通过对同站点的"尽力而为进程合并"和限制可以生成多少进程(我们之前提到过)来缓解一些。Firefox 最初由于内存担忧没有隔离每个站点,但在 Spectre 之后他们找到了更有效的方法,通过 8 特权进程限制和按需进程创建。Safari 历史上有强大的进程模型,但我不确定它是否隔离跨站点 iframe;WebKit2 肯定隔离顶级页面。苹果的重点通常也在隐私上(智能跟踪预防将分区 cookie 等),但这是不同的层。</p> <p>出于隐私原因,跨站点预取受到限制,目前只有在用户没有为目标站点设置 cookie 时才会工作,防止站点通过可能永远不会访问的预取页面跟踪用户活动。</p> <p>总而言之,站点隔离确保应用最小权限原则:来自源 A 的代码不能访问来自源 B 的数据,除非通过具有明确同意的 Web API(如 postMessage 或分区的存储)。沙盒确保即使代码是恶意的,它也不能直接触及你的系统。这些措施使浏览器漏洞变得更加困难 - 攻击者现在通常需要多个链式漏洞(一个破坏渲染器,一个逃脱沙盒)才能造成严重损害,这大大提高了门槛。</p> <p>作为 Web 开发者,你可能不会直接感受到站点隔离,但你通过更安全的 Web 从中受益。需要注意的一点是跨源交互可能有稍微更多的开销(由于 IPC),并且一些优化如进程内脚本共享在跨源之间是不可能的。但浏览器正在不断优化进程之间的消息传递,以最小化任何性能影响。</p> <p>在讲完安全性之后,接下来我们来看看工具和性能监控——说白了,就是开发者如何深入这个流程,对它进行观测、调试和性能分析。</p> <h2><strong>比较 Chromium、Gecko 和 WebKit</strong></h2> <p>我们主要描述了 Chrome/Chromium 的行为(用于 HTML/CSS 的 Blink 引擎,用于 JS 的 V8,通过 Aura/Chromium 基础设施的多进程)。其他主要引擎 - Mozilla 的 Gecko(用于 Firefox)和 Apple 的 WebKit(用于 Safari)- 共享相同的基本目标和大致相似的管道,但有值得注意的差异和历史分歧。</p> <p><strong>共同概念</strong>:所有引擎都将 HTML 解析为 DOM,将 CSS 解析为样式数据,计算布局,并绘制/合成。所有都有带 JIT 和垃圾收集的 JS 引擎。所有现代的都是多进程(或至少多线程)以实现并行性和安全性。</p> <h3><strong>CSS/样式系统的差异</strong></h3> <p>一个有趣的差异是渲染引擎如何实现 CSS 样式计算:</p> <ul> <li> <p><strong>Blink(Chromium)</strong>:使用 C++ 中的单线程样式引擎(历史上基于 WebKit 的)。它为 DOM 树顺序计算样式。它有增量样式失效优化,但总的来说它是单个线程在工作(除了动画中的一些小并行化)。</p> </li> <li> <p><strong>Gecko(Firefox)</strong>:在 Quantum 项目(2017)中,Firefox 集成了 Stylo,一个用 Rust 编写的新 CSS 引擎,它是多线程的。Firefox 可以使用所有 CPU 核心并行计算不同 DOM 子树的样式。这是 Gecko 中 CSS 的重大性能改进。因此,Firefox 中的样式重新计算可能使用 4 个核心来完成 Blink 在 1 个核心上做的事情。这是 Gecko 方法的一个优势(以复杂性为代价)。</p> </li> <li> <p><strong>WebKit(Safari)</strong>:WebKit 的样式引擎像 Blink 一样是单线程的(因为 Blink 在 2013 年从 WebKit 分叉,它们在那之前共享架构)。WebKit 做了一些有趣的事情,比如 CSS 选择器匹配的字节码 JIT。它可能将 CSS 选择器转换为字节码并 JIT 编译匹配器以提高速度。Blink 没有采用这种方法(它使用迭代匹配)。</p> </li> </ul> <p>因此,在 CSS 方面,Gecko 通过 Rust 的并行样式计算脱颖而出。Blink 和 WebKit 依赖优化的 C++ 和可能一些 JIT 技巧(在 WebKit 的情况下)。</p> <h3><strong>布局和图形</strong></h3> <p>所有三个引擎都实现 CSS 盒模型和布局算法。特定功能可能在一个引擎中比其他引擎更早实现(例如,一度 WebKit 在 CSS Grid 支持方面领先,然后 Blink 赶上 - 它们经常通过标准机构共享代码)。</p> <p>Firefox(Gecko)通过引入 <strong>WebRender</strong> 作为其合成器/光栅化器做出了巨大改变。WebRender 现在是 Firefox 中的默认渲染引擎,并为图形密集型 Web 内容的性能改进做出了重大贡献。WebRender(也是 Rust)基本上获取显示列表并直接在 GPU 上渲染它,使用 GPU 处理形状镶嵌、文本等。这就像将更多绘制工作移到 GPU。在 Chrome 的管道中,光栅化仍然在 CPU 上完成(对于大多数内容),然后作为位图发送到 GPU。WebRender 尝试避免为整个层制作位图,而是在 GPU 上绘制矢量(除了它缓存为图集纹理的文本字形)。这意味着 Firefox 可能以高性能动画更多内容,因为如果只有小部分发生变化,它不需要重新光栅化所有内容 - 它可以通过 GPU 非常快速地重绘。这类似于游戏引擎如何使用 GPU 调用每帧重绘场景。缺点是实现和调优复杂,可能更多地压力 GPU。但随着 GPU 功率的增长,这种方法是前瞻性的。Chrome 的团队考虑了类似的方法("SKIA GPU"路径),但没有进行完整的 WebRender 风格的大修。</p> <p>Safari(WebKit)使用更类似于较旧 Chrome 的方法:它有一个带层的合成器(称为 CALayer,因为在 Mac 和 iOS 上它使用 Core Animation 层)。Safari 很早就转向 GPU 合成(iPhone OS 和 Safari 4 在 2009 年对某些 CSS 如变换有硬件加速合成)。Safari 和 Chrome 分歧,但在概念上都进行平铺和合成。Safari 也大量卸载到 GPU(并使用平铺,特别是在 iOS 上,平铺绘制对平滑滚动是基础的)。</p> <p><strong>移动优化</strong>:每个引擎都有移动的特殊情况。例如,WebKit 有滚动的平铺覆盖概念(历史上在 iOS 的 UIWebView 中使用)。Android 上的 Chrome 使用"平铺"并尝试保持光栅任务最小以达到帧率。Firefox 的 WebRender 来自移动优先的 Servo 项目。</p> <h3><strong>JavaScript 引擎</strong></h3> <ul> <li> <p><strong>V8(Chromium)</strong>:我们提及了 Ignition、Sparkplug、TurboFan、Maglev 截至 2023 年。</p> </li> <li> <p><strong>SpiderMonkey(Firefox)</strong>:它历史上有解释器,然后是基线 JIT 和优化 JIT(IonMonkey)。最近的工作(Warp)改变了 JIT 层的工作方式,可能简化 Ion 并使其更像 TurboFan 使用缓存字节码和类型信息的方法。SpiderMonkey 也有不同的 GC(也是分代的,自 2012 年以来称为增量 GC,现在大多是增量/并发的)。</p> </li> <li> <p><strong>JavaScriptCore(Safari)</strong>:如前所述,它有 4 层(LLInt、Baseline、DFG、FTL)。它使用不同的 GC(WebKit 的 GC 是分代标记-清扫,历史上称为 Butterfly 或 Boehm 变体,现在是 bmalloc 等)。JSC 的 FTL 使用 LLVM 进行优化,这是独特的(V8 和 SM 有自己的编译器,JSC 为一层利用 LLVM)。这可以产生非常快的代码,但编译很重。JSC 倾向于优先考虑某些基准测试的峰值性能(它经常在某些方面表现出色,但 V8 倾向于赶上;它们互相超越)。</p> </li> </ul> <p>在 ES 功能方面,由于 test262 和彼此的竞争,所有三个引擎都与最新标准保持同步。</p> <h3><strong>多进程模型差异</strong></h3> <ul> <li> <p><strong>Chrome</strong>:每个标签通常分离,源级别的站点隔离,大量进程(可能是几十个)。</p> </li> <li> <p><strong>Firefox</strong>:默认情况下进程较少(8 个内容进程处理所有标签,如果需要跨站点 iframe 与 Fission 则更多)。因此,它不一定是每个标签一个进程;标签在池中共享内容进程。这意味着 Firefox 在多标签场景下可能有更低的内存使用,但也意味着一个内容进程崩溃可能拖垮多个标签(尽管它尝试按站点分组,所以也许所有 Facebook 标签在一个进程中等)。</p> </li> <li> <p><strong>Safari</strong>:可能每个标签一个进程(或每几个标签) - 在 iOS 上,WKWebView 肯定隔离每个 webview。Safari 桌面版历史上也是每个标签分离的。不确定它们是否还隔离跨源 iframe - Apple 没有太多谈论 Spectre 缓解措施,但 Safari 至少对顶级有每个域一个进程。</p> </li> </ul> <p><strong>进程间协调</strong>:所有引擎都必须解决类似的问题,比如如何在多进程环境中实现 alert()(它阻塞 JS) - 通常浏览器进程显示警报 UI 并暂停该脚本上下文。或如何处理 prompt/confirm,如何做模态对话框等。有细微差异(例如 Chrome 不真正阻塞 alert 的线程 - 它在渲染器中旋转嵌套运行循环等,而 Firefox 可能仍然冻结该标签的进程)。</p> <p><strong>崩溃处理</strong>:Chrome 和 Firefox 都有崩溃报告器,可以重新启动崩溃的内容进程并在标签中显示错误。Safari 的 Web Content 进程崩溃通常会在内容区域显示更简单的错误消息。</p> <h3><strong>功能实现分歧</strong></h3> <p>一些 Web 平台功能是引擎特定的:例如 Chrome 有实验性的 document.transition API 用于无缝 DOM 转换,它依赖于 Blink 的架构。Firefox 可能以不同方式或稍后实现某些东西。但最终,标准会收敛功能。</p> <p><strong>开发者工具</strong>:Chrome 的 DevTools 非常先进。Firefox 的 DevTools 也很好(有一些独特功能,如早期的 CSS Grid 高亮器、形状编辑器)。Safari 的 Web Inspector 很好,但在某些领域功能不够全面。这些差异对调试每个浏览器的开发者来说可能很重要。</p> <h3><strong>性能权衡</strong></h3> <p>历史上,Chrome 因多进程和 V8 而被称赞为更快的 JS 和整体性能。Firefox 与 Quantum 缩小了很多差距,有时在图形方面超越 Chrome(WebRender 对复杂页面可能非常快)。Safari 经常在图形和 Apple 硬件上的低功耗使用方面表现出色(他们大量优化功耗)。</p> <p><strong>内存</strong>:Chrome 以高内存使用而闻名(所有这些进程)。Firefox 尝试更保守一些。Safari 在 iOS 上出于必要(有限的 RAM)非常节省内存,他们在 WebKit 中做了很多内存优化。</p> <p><strong>外部贡献者</strong>:有趣的注意 - 这些引擎中的许多改进来自外部团队,如 Igalia(例如在 WebKit 和 Blink 中实现 CSS Grid)。因此有时功能大致同时在各处实现。</p> <p>从 Web 开发者的角度来看,差异通常表现为:</p> <ul> <li> <p>需要在所有引擎上测试,因为一个引擎的 CSS 功能或 API 实现可能有轻微差异或错误。</p> </li> <li> <p>性能可能不同(例如,特定的 JS 工作负载由于 JIT 启发式可能在一个引擎中比另一个更快)。</p> </li> <li> <p>某些 API 可能在一个中不可用(Safari 通常最后实现一些新 API,如 WebRTC 或 IndexedDB 版本等,尽管它们最终会实现)。</p> </li> </ul> <p>但我们讨论的核心概念(网络 -&gt; 解析 -&gt; 布局 -&gt; 绘制 -&gt; 合成 -&gt; JS 执行)适用于所有,只是内部方法或名称不同:</p> <ul> <li> <p>在 Gecko 中:解析 -&gt; 框架树 -&gt; 显示列表 -&gt; WebRender 场景或层树(如果 WebRender 禁用)-&gt; 合成。</p> </li> <li> <p>在 WebKit 中:解析 -&gt; 渲染树 -&gt; 图形层 -&gt; 合成(通过 CoreAnimation)。</p> </li> </ul> <p>所有都有类似的子系统(DOM、样式、布局、图形、JS 引擎、网络、进程/线程)。</p> <p>了解这些有助于调试:例如,如果某些东西在 Safari 中卡顿但在 Chrome 中不卡顿,可能是 WebKit 的绘制不同。或者如果 CSS 在 Firefox 中慢,也许它触及了 Stylo 没有并行化的路径(尽管这很少见)。</p> <p>总结,虽然 Chromium、Gecko 和 WebKit 有不同的实现,甚至一些不同的创新(Gecko 中的并行 CSS、WebRender GPU 等),但它们越来越多地实现相同的 Web 标准,甚至在许多方面合作。引擎的选择对平台供应商和开放 Web 多样性更重要,但作为开发者,你主要关心你的站点在任何地方都能运行。在底层,每个引擎的独特架构可能导致不同的性能配置文件或错误,这就是为什么在每个引擎中测试和使用性能诊断(如 Firefox 的性能工具与 Chrome 的)可能有洞察力。列出所有差异超出了我们的范围,但希望这给出了景观的想法:它们在高级设计上收敛(多进程、类似管道),但在特定技术解决方案上分歧。</p> <h2><strong>结论和进一步阅读</strong></h2> <p>我们已经走过了现代浏览器中网页的生命历程 - 从输入 URL 的那一刻,通过网络和导航、HTML 解析、样式、布局、绘制和 JavaScript 执行,一直到 GPU 在屏幕上放置像素。我们看到浏览器本质上是迷你操作系统:管理进程、线程、内存和大量复杂子系统,以确保 Web 内容快速加载并安全运行。对于 Web 开发者来说,了解这些内部机制可以揭开为什么某些最佳实践(如最小化回流或使用异步脚本)对性能很重要,或者为什么某些安全策略(如不在 iframe 中混合源)存在。</p> <p>给开发者总结的几个关键要点:</p> <p><strong>优化网络使用</strong>:更少的往返和更小的文件 = 更快的开始渲染。浏览器可以做很多(HTTP/2、缓存、预测性加载),但你仍应该利用资源提示和高效缓存等技术。网络栈是高性能的,但延迟总是性能杀手。</p> <p><strong>为效率构建你的 HTML/CSS</strong>:结构良好的 DOM 和精简的 CSS(避免非常深的树或过于复杂的选择器)可以帮助解析和样式系统。理解 CSS 和 DOM 构建计算样式,然后布局计算几何形状——重度 DOM 操作或样式更改可能触发这些重新计算。</p> <p><strong>批量 DOM 更新</strong>:避免重复的样式/布局抖动。使用 DevTools 性能面板捕获脚本何时导致许多布局或绘制。</p> <p><strong>为动画使用合成友好的 CSS</strong>:<code>transform</code> 或 <code>opacity</code> 的动画保持在主线程之外并在合成器上,产生平滑的动画。如果可能,避免动画布局绑定属性。</p> <p><strong>注意 JS 执行</strong>:虽然 JS 引擎超快,但长任务会阻塞主线程。分解长操作(这样页面保持响应),在某些情况下考虑 Web Workers 用于后台任务。另外,记住重度 JS 可能导致 GC 暂停(现在很少长,但如果内存膨胀可能发生)。</p> <p><strong>安全功能</strong>:拥抱它们——例如在适当时使用 iframe sandbox 或 <code>rel=noopener</code>,因为你现在知道浏览器无论如何都会隔离那些;与它合作是好的。</p> <p><strong>DevTools 是你的朋友</strong>:特别是性能和网络面板是查看浏览器确切在做什么的金矿。如果某些东西慢或卡顿,工具通常能帮你找到原因(长布局、慢绘制等)。</p> <p><strong>对于那些希望更深入探讨的人,Pavel Panchekha 和 Chris Harrelson 的《浏览器工程》(可在 <a href="http://browser.engineering">browser.engineering</a> 获取)是一个极佳的资源。</strong></p> <p>它本质上是一本免费的在线书籍,指导你构建一个简单的 Web 浏览器,以易于理解的方式涵盖网络、HTML/CSS 解析、布局等。它可以作为我们讨论的一切的更深入伴侣,通过示例巩固知识。此外,Chrome 团队的多部分系列"<a href="https://developer.chrome.com/blog/inside-browser-part1">现代网络浏览器内部观察</a>"提供了带图表的可读概述。V8 博客(<a href="http://v8.dev">v8.dev</a>)和 <a href="https://hacks.mozilla.org/">Mozilla 的 Hacks 博客</a>是了解引擎进展的好地方(例如新的 JIT 编译器层或 WebRender 内部)。</p> <p>总之,现代浏览器是软件工程的奇迹。它们成功地抽象了所有这些复杂性,因此作为开发者,我们主要只是编写 HTML/CSS/JS 并信任浏览器处理它。然而,通过窥视底层,我们获得了帮助我们编写更高性能、更强大应用程序的洞察。我们理解为什么某些技术改善用户体验(例如避免阻塞主线程,或减少不必要的 DOM 复杂性),因为我们看到浏览器必须在底层如何工作。下次你调试网页或想知道为什么 Chrome 或 Firefox 以某种方式行为时,你将有一个浏览器内部的心理模型来指导你。</p> <p>愉快构建,记住 Web 平台的深度奖励那些探索它的人 - 总有更多要学习的,以及帮助你学习的工具。</p> <h3><strong>进一步阅读</strong></h3> <ul> <li><strong><a href="https://browser.engineering/">Web 浏览器工程</a></strong> - 浏览器工作原理深入探讨书籍</li> <li><strong><a href="https://www.youtube.com/playlist?list=PL9ioqAuyl6ULp1f36EEjIN1vSBEfsb-0a">Chromium 大学</a></strong> - 免费的 Chromium 工作原理深入视频系列,包括优秀的<a href="https://www.youtube.com/watch?v=K2QHdgAKP-s&amp;list=PL9ioqAuyl6ULp1f36EEjIN1vSBEfsb-0a&amp;index=3&amp;pp=iAQB">像素的生命演讲</a></li> <li><strong><a href="https://developer.chrome.com/blog/inside-browser-part1">浏览器内部(Chrome 开发者博客系列)</a></strong> - 第 1-4 部分涵盖架构、导航流程、渲染管道和输入/控制器线程。</li> <li><strong><a href="https://addyosmani.com/blog/chrome-17th/">Google Chrome 17 年 - 我们浏览器的历史</a></strong></li> </ul> <p><em>本文中的插图由 Susie Lu 委托制作。</em></p> <p><a href="https://substackcdn.com/image/fetch/$s_!YPnw!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4a42e9cf-a2e1-4d77-b7bb-f9501c423f29_5246x3496.png"><img src="https://substackcdn.com/image/fetch/$s_!YPnw!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4a42e9cf-a2e1-4d77-b7bb-f9501c423f29_5246x3496.png" alt="" /></a></p> 复习 CSS Flex 和 Grid 布局https://ssshooter.com/css-flex-and-grid/https://ssshooter.com/css-flex-and-grid/深入复习 CSS Flex 和 Grid 布局的核心概念与实战技巧。从弹性布局的轴向理解到网格布局的跨越设置,涵盖对齐方式、换行处理、Tailwind 原子类封装等内容,帮助前端开发者掌握现代 CSS 布局的最佳实践。Mon, 08 Sep 2025 06:46:56 GMT<p>还记得那些年被 CSS 布局折磨的日子吗?<code>float: left</code> 配合 <code>clear: both</code>,各种奇技淫巧只为让元素乖乖待在该待的地方。好在现代 CSS 给了我们两个布局神器:<strong>Flex</strong> 和 <strong>Grid</strong>。</p> <p>如果你已经在用这两个布局方式,但偶尔还是会被轴向搞混,那这篇复习笔记正好适合你。我们会从最基础的概念开始,到实际的使用技巧,再到 Tailwind 的原子类封装,最后总结一些实战心得。</p> <p>准备好了吗?复习开始!</p> <h2>Flex</h2> <p>Flex 被翻译为弹性布局,看名字就知道,主打一个<strong>弹</strong>。(此处应有音效~ <strong>Duang~</strong>)</p> <h3>弹</h3> <p>一个弹性盒子里的内容我们只需要设置部分的宽度,其他没有设置的就能自如调整自己宽度,填满盒子。</p> <p>最常见的需求写成代码如下:</p> <pre><code>&lt;div class="fixed-flex"&gt; &lt;div class="side"&gt;侧边栏&lt;/div&gt; &lt;div class="main"&gt;主内容&lt;/div&gt; &lt;/div&gt; &lt;style&gt; .fixed-flex { display: flex; } .side { width: 200px; background: #ffecb3; } .main { flex-grow: 1; flex-shrink: 1; flex-basis: 0%; background: #fff9c4; } &lt;/style&gt; </code></pre> <p><code>.side</code> 宽度固定 <code>200px</code>,对于 <code>.main</code>:</p> <ul> <li>宽度不设置:<code>flex-basis: 0%</code></li> <li>有多余空间时会膨胀:<code>flex-grow: 1</code></li> <li>空间不足时收缩:<code>flex-shrink: 1</code></li> </ul> <p>以上 3 个值可以简写成一个 <code>flex: 1</code>,不过新手不推荐用除了 <code>flex: 1</code> 之外的 <code>flex</code> 属性,因为这个属性的语法<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/flex">有一点复杂</a>。</p> <h3>换行</h3> <p>有时候你确实不想只填满一行,Flex 也给你换行的选项。</p> <pre><code>&lt;div class="wrap-layout"&gt; &lt;div&gt;Akira&lt;/div&gt; &lt;div&gt;Astro Boy&lt;/div&gt; &lt;div&gt;Cowboy Bebop&lt;/div&gt; &lt;div&gt;Dragon Ball Z&lt;/div&gt; &lt;div&gt;Fullmetal Alchemist&lt;/div&gt; &lt;div&gt;Ghost in the Shell&lt;/div&gt; &lt;div&gt;Mobile Suit Gundam&lt;/div&gt; &lt;div&gt;Neon Genesis Evangelion&lt;/div&gt; &lt;div&gt;Princess Mononoke&lt;/div&gt; &lt;div&gt;Sailor Moon&lt;/div&gt; &lt;/div&gt; &lt;style&gt; .wrap-layout { display: flex; flex-wrap: wrap; gap: 10px; } .wrap-layout div { width: auto; background: #c8e6c9; padding: 10px; text-align: center; } &lt;/style&gt; </code></pre> <p>默认情况下,Flex 布局只有单行,元素位置不够会被自动挤压,添加 <code>flex-wrap: wrap;</code> 后,就能保持其宽度自动换行。</p> <h3>交叉轴</h3> <p>Flexbox 的设计理念是一维布局,它要么是行(row),要么是列(column)。这意味着所有子元素都会沿着<strong>主轴(main axis)</strong> 排成一条线。但是换行之后就不可避免地成为二维布局,产生了<strong>交叉轴(cross axis)</strong> 的概念。</p> <p>默认情况下,横着的是主轴,换行就产生了垂直于主轴的交叉轴。</p> <p>与主轴有关的 CSS 属性带有 justify 前缀:</p> <ul> <li><code>justify-content</code></li> </ul> <p>与交叉轴有关的 CSS 属性带有 align 前缀:</p> <ul> <li><code>align-content</code></li> <li><code>align-items</code></li> <li><code>align-self</code></li> </ul> <p><code>align-content</code> 控制的是<strong>多行的情况下</strong>,行与行之间的排布,你不用 <code>wrap</code> 的话这个属性是不会生效的;<code>align-items</code> 控制的是<strong>单行内</strong>元素的排布。</p> <h3>排列方向</h3> <p>我们可以通过 <code>flex-direction: column;</code> 切换主轴方向。</p> <pre><code>&lt;div class="column-layout"&gt; &lt;div&gt;上&lt;/div&gt; &lt;div&gt;中&lt;/div&gt; &lt;div&gt;下&lt;/div&gt; &lt;/div&gt; &lt;style&gt; .column-layout { display: flex; flex-direction: column; gap: 10px; } &lt;/style&gt; </code></pre> <p>十分简单!我们只需要一句 <code>flex-direction: column;</code> 就能把主轴从水平改为垂直,麻烦的它会给你带来一些思维负担。</p> <p>上述所说的主轴、交叉轴、justify、align、换行概念都不变,只需要你把主轴换成垂直重新理解就好了,这不难,只是需要时间适应。</p> <h3>居中</h3> <p>Flex 拥有新时代最方便的居中布局的方法,<code>justify-content: center;</code> 和 <code>align-items: center;</code></p> <p>2015 年就在干前端的老家伙们仍记得,当年还需要记各种 CSS 居中 hack,现在浏览器跟上了,大部分时间都只需要使用 Flex 就能解决问题。</p> <p>正如上面所说,<code>justify-content</code> 是主轴元素间的分布,<code>align-items</code> 是交叉轴一维元素的分布,结合起来就是在 Flex 盒子内完全居中。</p> <pre><code>&lt;div class="center"&gt; &lt;p&gt;居中内容&lt;/p&gt; &lt;/div&gt; &lt;style&gt; .center { display: flex; justify-content: center; align-items: center; height: 200px; background: #f0f0f0; } &lt;/style&gt; </code></pre> <p>这时候有朋友就要提问了,用 <code>align-content</code> 从行的视角居中不可以吗?也是可以的,不过正如上面所说,必须设置 <code>wrap</code>,<code>align-content</code> 才能生效,所以得多加一行:</p> <pre><code>.center { display: flex; justify-content: center; flex-wrap: wrap; align-content: center; height: 200px; background: #f0f0f0; } </code></pre> <p>你可以在这个<a href="https://tools.mind-elixir.com/en/learn-css-flex">互动学习网站</a>玩玩 Flex 的 <code>wrap</code>、<code>align</code> 和 <code>justify</code> 具体属性对元素布局的影响。</p> <p><strong>Bonus</strong>:Flex 父元素被撑爆了怎么办?当你设置了文字不换行之后,Flex 父元素经常会出现被撑爆的情况,这时候记得设置 <code>overflow</code> 属性。我觉得这里挺奇怪的,如果布局是垂直的时候,我会很容易想到设置 <code>overflow</code>,但是使用 Flex 之后布局变成水平了,我就老忘记设置 😂</p> <h2>Grid</h2> <p>Grid 布局就是一个画格子的游戏,一个例子让我们看看格子怎么画:</p> <pre><code>.container { display: grid; grid-template-columns: 100px 200px auto; grid-template-rows: 50px auto 50px; } </code></pre> <p>某种程度上 Grid 比 Flex 简单,因为基本不用切换主轴和交叉轴的概念,不容易记混。对于 Grid 我们只要知道行列怎么设置就行了。</p> <p><code>grid-template-columns</code> 设置 Grid 的列,<code>100px 200px auto</code> 的含义就是第一列 100px,第二列 200px,第三列自适应。</p> <p><code>grid-template-rows</code> 设置 Grid 的行,<code>50px auto 50px</code> 的含义就是第一行 50px,第二行自适应,第三行 50px。</p> <p>还有一个十分实用的单位 <code>fr</code>,代表<strong>弹性比例(剩余空间的分数单位)</strong>,举个例子:</p> <pre><code>.container { display: grid; grid-template-columns: repeat(3, 1fr); /* 1fr 1fr 1fr */ } </code></pre> <p>这样就是把列分成均等的 3 份。</p> <h3>对齐</h3> <p>Flexbox 的子元素总是在主轴上紧密地排列在一起,没有多余空间可以用来让单个子元素进行单独的对齐,所以 Flex 不存在 <code>justify-items</code>。整个主轴上的对齐是由父容器的 <code>justify-content</code> 属性来统一控制的。它决定了所有子元素作为一个整体如何沿着主轴进行分布和对齐。</p> <p>与之对比,Grid 就是画格子,格子画完之后,子元素在里面各自精彩,自然可以用 <code>justify-items</code>。</p> <h3>命名网格</h3> <pre><code>&lt;div class="grid-container"&gt; &lt;header class="header"&gt;头部&lt;/header&gt; &lt;nav class="nav"&gt;导航&lt;/nav&gt; &lt;main class="content1"&gt;内容1&lt;/main&gt; &lt;main class="content2"&gt;内容2&lt;/main&gt; &lt;footer class="footer"&gt;底部&lt;/footer&gt; &lt;/div&gt; &lt;style&gt; .grid-container { display: grid; grid-template-areas: "header header header" "nav content1 content1" "nav content2 content2" "footer footer footer"; grid-template-columns: 150px 1fr 1fr; grid-template-rows: 100px 1fr 1fr 60px; gap: 10px; height: 90vh; } .header { grid-area: header; background-color: #f0c3a2; } .nav { grid-area: nav; background-color: #a2d2ff; } .content1 { grid-area: content1; background-color: #b7efc5; } .content2 { grid-area: content2; background-color: #b7efc5; } .footer { grid-area: footer; background-color: #f2e293; } &lt;/style&gt; </code></pre> <p>不得不说这玩意有点好玩,在配置好行列宽高后,通过文字“画”出页面结构:</p> <pre><code>"header header header" "nav content1 content1" "nav content2 content2" "footer footer footer" </code></pre> <h3>网格跨越</h3> <p>除了通过 <code>grid-template-areas</code> “画图”,你还可以通过网格跨越设计页面元素。</p> <pre><code>.grid-container { display: grid; grid-template-columns: 150px 1fr 1fr; grid-template-rows: 100px 1fr 1fr 60px; gap: 10px; height: 90vh; } .header { grid-column: 1 / 4; grid-row: 1 / 2; background-color: #f0c3a2; } .nav { grid-column: 1 / 2; grid-row: 2 / 4; background-color: #a2d2ff; } .content1 { grid-column: 2 / 4; grid-row: 2 / 3; background-color: #b7efc5; } .content2 { grid-column: 2 / 4; grid-row: 3 / 4; background-color: #b7efc5; } .footer { grid-column: 1 / 4; grid-row: 4 / 5; background-color: #f2e293; } </code></pre> <p><code>grid-column</code> 后面的数字表示从第 N 个竖线到第 M 个竖线中间的内容,这或许有点抽象,为什么要用竖线而不是第 N 列 这样的说法?</p> <p>其实使用第 N 列的写法也有,例如:</p> <pre><code>.header { grid-column: 1 / span 3; grid-row: 1 / span 1; background-color: #f0c3a2; } </code></pre> <p>如果你要控制某个区间跨度,还是必须要以数字开始,再加 <code>span 数字</code>,<code>1 / span 3</code> 代表从第 1 条竖线开始,跨度 3 列。</p> <p>直接写 <code>grid-column: span 3;</code> 只能代表 3 列,不能控制开始位置。</p> <h3>对齐</h3> <p>Grid 也有 <code>align-</code> 和 <code>justify-</code> 系列的对齐系统,它们的效果也与 Flex 里提到的类似。你可以在这个<a href="https://tools.mind-elixir.com/en/learn-css-grid">互动学习网站</a>玩玩 Grid 的 <code>align</code> 和 <code>justify</code> 具体属性对元素布局的影响。</p> <p><code>place-content</code> 是 CSS 中的一个简写属性,它同时设置了 <code>align-content</code> 和 <code>justify-content</code> 这两个属性。它主要用于 Grid 布局,毕竟 Flex 布局比较少将这两个属性同时设置。<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/place-content">举个例子</a>:</p> <pre><code>place-content: center; place-content: end space-between; </code></pre> <p>只填一个值的话同时应用到水平垂直,填两个值的话顺序是 <code>place-content: &lt;align-content&gt; &lt;justify-content&gt;;</code>。</p> <p>有 <code>content</code> 那有没有 <code>items</code> 呢?有的,朋友,如你所猜,就是 <code>place-items</code>,使用方法也是同理可得,就不多赘述了。</p> <p>Grid 的浏览器支持比 Flex 差一点,不过仍然看好未来,现在用户更新浏览器应该是越来越容易了,保留旧浏览器的情况会比以前大大减少。</p> <h2>Tailwind</h2> <p>Tailwind 把常用的 Flex 与 Grid API 封装为原子类,直接在 class 中组合即可。某种程度上来说,直接从 Tailwind 学习 Flex 布局还好学一点,不过如果你是 Tailwind 的反对者,大可以跳过这一节。</p> <p>下面用类名重现上文的关键示例:</p> <h3>Flex 基础</h3> <ul> <li>容器与方向:</li> </ul> <pre><code>flex flex flex-col flex-row-reverse flex-col-reverse </code></pre> <ul> <li>间距与换行:</li> </ul> <pre><code>gap-4 gap-x-6 gap-y-2 flex-wrap flex-nowrap </code></pre> <h3>Flex 对齐</h3> <ul> <li>主轴/交叉轴(容器):</li> </ul> <pre><code>flex justify-between items-center flex justify-center items-stretch </code></pre> <ul> <li>多行内容分布(容器,需要 flex-wrap 生效时才有体感):</li> </ul> <pre><code>flex flex-wrap content-between flex flex-wrap content-center </code></pre> <ul> <li>单个子项对齐(子项):</li> </ul> <pre><code>self-start self-center self-end self-stretch </code></pre> <h3>Flex 子项尺寸与顺序(子项)</h3> <ul> <li>占比/伸缩/基础宽高:</li> </ul> <pre><code>flex-1 grow shrink-0 basis-40 </code></pre> <h3>Grid 基础</h3> <ul> <li>容器与网格轨道:</li> </ul> <pre><code>grid grid-cols-3 grid-rows-4 </code></pre> <ul> <li>使用自定义列/行尺寸(对应上文 150px / 1fr / 1fr 与 100px / 1fr / 1fr / 60px):</li> </ul> <pre><code>grid grid-cols-[150px_1fr_1fr] grid-rows-[100px_1fr_1fr_60px] gap-2 h-[90vh] </code></pre> <h3>用跨越重现上文布局</h3> <p>Tailwind 原生未提供 <code>grid-template-areas</code> 的原子类,上文示例可用 col-start/col-end 与 row-start/row-end 组合实现:</p> <ul> <li>Header(跨 3 列,1 行):</li> </ul> <pre><code>col-start-1 col-end-4 row-start-1 row-end-2 </code></pre> <ul> <li>Nav(第 1 列,从第 2 行跨到第 4 行):</li> </ul> <pre><code>col-start-1 col-end-2 row-start-2 row-end-4 </code></pre> <ul> <li>Content1(第 2-3 列,第 2 行):</li> </ul> <pre><code>col-start-2 col-end-4 row-start-2 row-end-3 </code></pre> <ul> <li>Content2(第 2-3 列,第 3 行):</li> </ul> <pre><code>col-start-2 col-end-4 row-start-3 row-end-4 </code></pre> <ul> <li>Footer(跨 3 列,1 行):</li> </ul> <pre><code>col-start-1 col-end-4 row-start-4 row-end-5 </code></pre> <p>同样可以用简写:</p> <pre><code>col-span-3 row-span-1 </code></pre> <p>(在确定起始线的情况下用 start/end;仅控制跨度时可用 col-span-/row-span-。)</p> <h3>Grid 对齐</h3> <ul> <li>同时设置 align-content 与 justify-content(容器):</li> </ul> <pre><code>place-content-center place-content-between </code></pre> <ul> <li>同时设置 align-items 与 justify-items(容器):</li> </ul> <pre><code>place-items-center </code></pre> <ul> <li>仅水平/仅垂直(容器):</li> </ul> <pre><code>justify-items-start align-items-end </code></pre> <ul> <li>自动放置方向与紧密填充:</li> </ul> <pre><code>grid-flow-col grid-flow-row-dense </code></pre> <h2>Takeaways</h2> <ul> <li>Flex 更适合<strong>一维</strong>分布与对齐,Grid 更适合<strong>二维</strong>网格与跨行跨列;实际项目里常用“外层 Grid 划区,内层 Flex 排内容”的混搭策略。</li> <li>牢记轴心:<code>justify-*</code> 作用在主轴,<code>align-*</code> 作用在交叉轴;切换为 column 后,两者随主轴一并“旋转”。</li> <li>换行的影响:只有设置了 <code>flex-wrap</code> 时,<code>align-content</code> 才会生效;单行关注 <code>align-items/align-self</code>,多行再谈 <code>align-content</code>。</li> <li>Flex 子项尺寸心法:固定 + 自适应 = 固定宽度 + flex: 1(等价 grow-1 shrink-1 basis-0%);除 flex: 1 外,新手谨慎使用更复杂的 flex 简写。</li> <li>Grid 的核心是“画格子”:grid-template-columns/rows 配合 fr 单位定义轨道;跨区用 grid-column/grid-row(如 1 / 4 或 1 / span 3);也可用 template-areas 直观命名布局。</li> <li>对齐总览:Grid 还支持 <code>justify-items/align-items</code>,以及 <code>place-content/place-items</code> 的简写组合,既能控制“内容整体”,也能控制“单元格内”。</li> <li>兼容性与实践:Grid 支持略逊于 Flex,但现代浏览器基本可用;旧环境保守选 Flex,新项目优先考虑 Grid + Flex 混搭。</li> </ul>