普通视图

发现新文章,点击刷新页面。
昨天以前技术

iptables Command in Linux: Manage Firewall Rules

When a packet arrives at a Linux machine, the kernel decides what to do with it based on a set of firewall rules. Those rules live in the kernel’s Netfilter framework, and for more than two decades the standard way to edit them from user space has been the iptables command.

iptables controls packet filtering, network address translation, and packet mangling. Higher-level front ends such as ufw and firewalld manage the same Netfilter firewall stack through simpler interfaces, although many modern systems use nftables underneath. Understanding the underlying command is still valuable, especially when you inherit a server that was set up by someone else. This guide walks through the concepts and the commands you need to read, edit, and persist firewall rules.

Tables and Chains

Before you write a rule, you need to know where it goes. iptables is organized into tables, and each table contains chains.

The three tables you will use most often are:

  • filter - the default table, used for allowing and blocking traffic
  • nat - used for network address translation, such as port forwarding and masquerading
  • mangle - used to alter packet headers, for example to set QoS marks

Each table has a set of built-in chains that correspond to moments in the life of a packet. In the filter table:

  • INPUT - packets destined for the local machine
  • OUTPUT - packets originating from the local machine
  • FORWARD - packets routed through the machine

A rule says: for packets that enter this chain and match these criteria, take this action. The action is called a target and is usually ACCEPT, DROP, REJECT, or the name of another chain.

iptables Syntax

The general form of the command is:

txt
iptables [-t TABLE] COMMAND CHAIN [MATCH] [-j TARGET]

If -t is omitted, iptables uses the filter table. Common commands include -A (append a rule), -I (insert), -D (delete), -L (list), -F (flush), and -P (set default policy).

All commands that change the firewall require root privileges. Run them with sudo or as root.

Warning
It is easy to lock yourself out of a remote server with a single wrong rule. Before you apply a restrictive ruleset over SSH, either test on a local machine first or use iptables-apply, which rolls back automatically if you lose access.

List Rules

To print every rule in the filter table, use the -L option:

Terminal
sudo iptables -L

The default output shows service names, resolves IP addresses, and hides packet and byte counters. For real work, add -n to keep numeric output and -v to show counters and interface information:

Terminal
sudo iptables -L -n -v
output
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
1234 98K ACCEPT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:22
0 0 DROP tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:23
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination

To list a single chain, append its name:

Terminal
sudo iptables -L INPUT -n -v

To number the rules so you can reference them by index when deleting, add --line-numbers:

Terminal
sudo iptables -L INPUT -n -v --line-numbers

Add and Remove Rules

New rules are added to the end of a chain with -A (append) or at a specific position with -I (insert). The difference matters because iptables evaluates rules top to bottom and stops at the first match.

To allow incoming SSH connections:

Terminal
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT

To allow HTTP and HTTPS:

Terminal
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT

To insert a rule as the first one in the chain, use -I CHAIN 1:

Terminal
sudo iptables -I INPUT 1 -p tcp --dport 22 -j ACCEPT

This is important when you are working over SSH. If a broad DROP rule already appears earlier in the chain, appending the accept rule after it would not help because the packet would be dropped first.

To delete a rule, either repeat the exact specification with -D:

Terminal
sudo iptables -D INPUT -p tcp --dport 23 -j DROP

Or delete by line number, which is easier when the rule has many options:

Terminal
sudo iptables -D INPUT 3

Allow and Block Specific IPs

To block all traffic from a single IP address:

Terminal
sudo iptables -A INPUT -s 203.0.113.10 -j DROP

To block a range using CIDR notation:

Terminal
sudo iptables -A INPUT -s 203.0.113.0/24 -j DROP

To allow SSH only from a trusted subnet:

Terminal
sudo iptables -A INPUT -p tcp -s 192.168.1.0/24 --dport 22 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j DROP

The first rule accepts SSH from the local subnet. The second drops SSH from everywhere else. Order matters: if you reversed the two lines, every SSH attempt would be dropped before the accept rule had a chance to match.

Allow Established Connections

Most firewall setups include a rule that accepts traffic belonging to an already established connection. This lets return traffic through without needing a matching rule for each outbound request:

Terminal
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

Put this rule near the top of the INPUT chain so it matches early. Without it, the default DROP policy breaks outbound connections that expect responses.

Set a Default Policy

Each built-in chain has a default policy that applies when no rule matches. The -P option changes it:

Terminal
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
sudo iptables -P OUTPUT ACCEPT

Switching INPUT to DROP is the foundation of a deny-by-default firewall: nothing gets in unless an explicit rule allows it. Before you flip the policy, make sure you have already added the rules that allow SSH, established connections, and anything else you need.

Flush Rules

To remove every rule from every chain in the current table:

Terminal
sudo iptables -F

To flush a specific chain only:

Terminal
sudo iptables -F INPUT

Flushing does not reset the default policies. If you have set INPUT to DROP, flushing will leave it at DROP with no rules, which blocks all inbound traffic. Reset the policy to ACCEPT first if that is not what you want:

Terminal
sudo iptables -P INPUT ACCEPT
sudo iptables -F

Save and Restore Rules

Rules added with iptables live in kernel memory only. They disappear on reboot unless you save them.

On Ubuntu, Debian, and Derivatives, the iptables-persistent package saves rules to /etc/iptables/rules.v4 and reloads them at boot:

Terminal
sudo apt install iptables-persistent

The installer asks whether to save the current rules. To update the saved copy later:

Terminal
sudo netfilter-persistent save

On Fedora, RHEL, and Derivatives, the equivalent service is iptables-services:

Terminal
sudo dnf install iptables-services
sudo systemctl enable --now iptables
sudo service iptables save

Independent of the distribution, you can dump and restore rules manually with iptables-save and iptables-restore:

Terminal
sudo iptables-save -f /etc/iptables/rules.v4
sudo iptables-restore /etc/iptables/rules.v4

This is also the recommended way to edit a large ruleset: save to a file, edit the file, then restore it atomically.

Troubleshooting

Rules disappear after a reboot
iptables rules are not persistent by default. Install iptables-persistent on Debian-based systems or iptables-services on RHEL-based ones, and save the ruleset.

SSH stops working after setting a DROP policy
You switched INPUT to DROP without an ACCEPT rule for port 22, or the ACCEPT rule is positioned after a more general DROP rule. Connect through the console, add the rule with -I INPUT 1, and save.

A rule looks correct but does not match
Check the order. iptables walks the chain top to bottom and stops at the first match, so an earlier accept or drop may be catching the packet first. Use iptables -L INPUT -n -v --line-numbers to inspect the order.

Changes are silently ignored
You may be editing the wrong table. A rule in filter does not affect NAT, and vice versa. Pass -t TABLE explicitly when you are not working in filter.

iptables: command not found
On some modern distributions, only nftables is installed by default. Install iptables with your package manager, or use nft directly.

Quick Reference

For a printable quick reference, see the iptables cheatsheet .

Action Command
List rules (verbose, numeric) iptables -L -n -v --line-numbers
Allow port iptables -A INPUT -p tcp --dport PORT -j ACCEPT
Block IP iptables -A INPUT -s IP -j DROP
Allow established connections iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
Insert rule at top iptables -I INPUT 1 -p tcp --dport 22 -j ACCEPT
Delete rule by number iptables -D INPUT N
Set default policy iptables -P INPUT DROP
Flush all rules iptables -F
Save rules iptables-save -f /etc/iptables/rules.v4
Restore rules iptables-restore /etc/iptables/rules.v4

FAQ

Is iptables still relevant in 2026?
It is still installed and widely used, but it is being replaced by nftables, which offers a cleaner syntax and better performance. On recent distributions, the iptables command is often a compatibility front end that writes nftables rules underneath.

Should I use iptables, ufw, or firewalld?
If you are managing rules by hand on Debian or Ubuntu, ufw is simpler and covers most cases. On Fedora and RHEL, firewalld is the default. Reach for raw iptables when you need fine-grained control that the front ends do not expose, or when you are troubleshooting an existing ruleset.

What is the difference between DROP and REJECT?
DROP silently discards the packet; the sender sees a timeout. REJECT sends an ICMP error back (or a TCP reset for TCP), so the sender gets immediate feedback. DROP is often preferred on public interfaces because it does not confirm that the port exists.

How do I block a country or a list of IPs?
For a handful of addresses, add one -s rule per entry. For larger lists, use the ipset tool to manage the addresses and reference the set from a single iptables rule.

Does iptables handle IPv6?
No. Use ip6tables for IPv6 rules. It has the same syntax and the same tables and chains, but operates on a separate rule set.

Conclusion

iptables is a low-level but reliable way to read and shape the Linux firewall. Once you have a working ruleset, save it with iptables-save and commit the file so the next person to touch the server has a clear starting point.

Deno 2.8 正式发布,再次超越 Bun,史上最大的次版本升级诞生!

作者 Web情报局
2026年5月28日 19:50

👇 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

最近 Bun 效仿 Deno,要从 Zig 语言移植到 Rust “锈化“重写,源码 PR 已经合并了,正式官宣指日可待。

Deno 也不甘示弱,Deno 团队官宣 v2.8 正式发布,号称 Deno 进化史上最大的次版本升级,主要包括:

  • Node 兼容性远超 Bun,测试率超过七成
  • Deno CLI 新增命令,可以替换 pnpm install
  • 新增 JS Stage3 import defer 导入延迟提案
  • TS 更新到 v6 主版本,支持类型剥离

deno

👉 Node 兼容性超过 Bun

之前 Deno 2.7 针对 Node 官方测试的通过率约为 42%,勉强超过了 Bun 1.3.14 的 40.6%。

Deno 2.8 更进一步,几乎涵盖了所有 node: 模块,测试率飙升到 76.4%,大幅领先 Bun,Deno 和 Bun 的“Rust 竞赛“预计会愈演愈烈。

bun.png

👉 新增子命令

Deno CLI 新增了几个命令。

deno audit fix 能将漏洞模块升级到最新的补丁版,同时满足我们配置的主/次版本限制,任何需要升级主版本的模块都会单独列出,方便你决定升级与否。

deno bump-version 能更新 package.jsondeno.json 中的 version 字段。它也适用于 workspace 工作区模式,在根目录运行能将更新应用到每个模块。

image.png

还有其他几个命令,我把它们浓缩为下列表格:

命令 作用
deno ci 根据 lockfile 执行安装
deno pack 约等于 tsc + npm pack
deno transpile TSX 类型剥离,输出 JS
deno why 等价于 npm explain / pnpm why

👉 包管理变更

Deno CLI 不再要求 deno adddeno install 命令添加 npm: 前缀,默认将无前缀的包名视为 npm 模块。

image.png

注意,CLI 中的 JSR 注册源仍需要 jsr: 前缀,ESM 模块中的 import 语句也要求 npm: 前缀。

这样,deno install 能取代 npm installpnpm install 等命令,允许你使用 Deno 取代 npm 作为包管理器,但项目还是跑在 Node 上,既符合 Node 开发者的肌肉记忆,又提升了安装速度。

过去,monorepo 跨包共享依赖需要手动协调版本,当共享依赖更新时,每个模块的 package.json 必须同步更新。

Deno 2.8 采用 pnpm 的 catalog: 协议,这允许在 workspace 根目录中声明一个默认的 "catalog" 字段:

image.png

然后只需使用 catalog: 说明符,就能从任意工作区模块同步依赖版本:

image.png

此外,类似 pnpm 的模块隔离结构,Deno 默认的 node_modules 目录结构是隔离的,每个模块都有自己的符号链接解析树,因此它只能看到自己显式声明的依赖。

但一些旧工具仍然依赖 npm install 生成依赖提升的扁平目录结构,每个模块都位于 node_modules 顶层,并且可以 require() 它找到的任何依赖。

deno.json 新增了 nodeModulesLinker 字段,默认值是 "isolated"(隔离目录):

image.png

设置 "nodeModulesLinker": "hoisted",可以移植一个依赖 npm 扁平目录的现有 Node 项目。

还有,Deno 2.6 就新增了 min-release-age 最小发布时限配置,来拦截大多数供应链攻击。Deno 2.8 支持通过 .npmrc 配置:

image.png

👉 JS 新功能

Deno 支持 JS Stage3 的 import defer(延迟导入提案),模块能不运行其顶层代码加载,这样该模块只在首次访问其导出成员时才被执行。

举个栗子,模块先导入,但可以延迟执行:

image.png

这样,模块求值会延迟到访问导出成员的那个时间点。当模块求值成本高昂、但又不常使用时,import defer 新特性能缩短启动时间。

👉 TS 更新

TS 编译器更新到 v6.0.3 版本了,这是为了对齐 ts-go(TS 7.x) 的过渡版本,包括类型系统支持 ES2026 的最新功能等大量改动。

此外,deno check 默认包含 lib.node,不需要在 deno.json 中的 compilerOptions.lib 手动添加 "node" 了。

image.png

如上,Deno 自动支持 process / Buffer 等 Node 专属的全局变量和类型。lib.node 基于 @types/node 实现,Deno 会从 npm 拉取该模块,process.versions.node 匹配 Node 的主版本,目前是 v24.x

如果你希望使用其他版本的 @types/node,比如仍在维护的更低版本 Node 22,可以在 package.json 中将其安装为开发依赖:

image.png

然后在 deno.json 中让 Deno 导入对应版本的模块:

image.png

👉 开发体验

Deno 2.8 支持让 Chrome DevTools(开发者工具)检查网络流量:

  1. 运行程序时添加 --inspect-wait 等参数
  2. 在 Chromium 中打开 chrome://inspect
  3. 点击 Deno 目标上的 Inspect(检查)

image.png

开发者工具的“Network“网络选项卡会显示客户端请求和响应头等所有内容:

network

相同的事件也会通过 node:inspector 客户端和 VS Code 的 JavaScript 调试器等工具显示出来。

此外,Deno 2.8 上线了一个与 Node --cpu-prof 匹配的内置 CPU 分析器,当程序退出时,Deno 会将 V8 的 CPU 分析结果写入磁盘。

image.png

.cpuprofile 文件可以在 Chrome DevTools 中直接打开,也可以输出为另外两种格式:

  • --cpu-prof-flamegraph 会生成一个独立的交互式 SVG 图片,可以在浏览器中打开
  • --cpu-prof-md 会生成一份人类可读的 Markdown 报告,包含最热门的函数等详细信息

image.png

👇 重点总结

Deno 2.8 是 Deno 进化史上最大的次版本升级,主要包括:

  • Deno CLI 新增了若干命令,Node 兼容性远超 Bun
  • 新增 JS Stage3 的 import defer 延迟导入提案
  • 包管理器对齐 npm 行为,支持模块提升的扁平化目录
  • TS 更新到 v6 主版本,支持类型剥离和 Node 专属类型

除此之外,Deno 官方博客还展示了 Deno 2.8 的性能提升,Web API 新功能等,更多技术细节另请参阅官方博客。

以上就是今日《前端快讯》的全部内容了,希望对你有所帮助。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

🙏 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

👇 参考文献:

React 19.x 的 lazy 与 Suspense

作者 米丘
2026年5月28日 18:26

React.lazy

在构建大型 React 应用时,打包体积过大往往会影响首屏加载速度。React.lazy 正是为了解决这一问题而诞生的内置函数,它让你可以将组件动态导入(code splitting),并按需加载,从而显著提升应用性能。

为何使用 React.lazy?

  1. 减少初始包体积:应用的首屏可能不需要所有组件。通过代码分割,只加载当前路由或交互所需的组件。
  2. 提升首屏加载速度:更少的 JavaScript 意味着更快的解析和执行时间,改善用户体验。
  3. 优化缓存与带宽:用户可能只使用部分功能,懒加载未使用的代码可节省流量。
  4. 与 Suspense 天然集成:React.lazy 配合 Suspense 可以优雅地显示加载状态(如 Loading 动画)。

渲染阶段流程

React.lazy 组件加载完成的触发机制依赖于 Promise 的 resolve 回调 和 React 内部的 Suspense 重试(ping)机制

一、 初始化

调用 React.lazy(() => import('./Component')) 会生成一个特殊的“懒加载对象”,内部包含 _status(初始为 Uninitialized)和 _result(存储加载器函数)。

二、 首次渲染

当 React 遇到这个懒加载对象时,会调用内部函数 lazyInitializer

  • 执行 _result()(即 import()),得到一个 Promise(thenable)。
  • 将 _status 更新为 Pending_result 指向该 Promise。
  • 抛出该 Promise 以触发最近的 <Suspense> 边界.

三、 suspense 捕获与监听

React 捕获抛出的 Promise,向上查找 Suspense 边界:

  • 调用 attachPingListener 为该 Promise 添加一个 then 回调(即 ping 函数)。
  • 该边界立即渲染 fallback UI。

四、 加载完成(Promise resolve)

当动态导入的模块成功加载后,Promise 被 resolve,模块对象作为结果返回。Promise 的回调执行:

  • 将 lazy 对象的 _status 更新为 Resolved_result 替换为模块对象。
  • 调用之前附加的 ping 回调(实际上是 pingSuspendedRoot)。

五、 触发重新渲染

pingSuspendedRoot 会标记对应根节点的优先级车道(pingedLanes),并调用 ensureRootIsScheduled,重新调度整个应用的渲染(或仅重试该 Suspense 边界)。

六、 二次渲染

React 再次执行该组件的渲染逻辑,此时 lazyInitializer 发现 _status === Resolved,直接返回 _result.default(真正的组件),从而正常完成渲染,替换 fallback。

注意事项

  1. 避免在渲染函数内动态调用 lazylazy 应在模块顶层定义,确保每次渲染都得到相同的引用。
  2. 重复导入优化:同一个 lazy 组件在多处使用时,内部会共享相同的 Promise,不会重复加载。
  3. 错误处理:懒加载可能因网络问题失败,建议结合错误边界(Error Boundary)捕获加载失败错误。
  4. 命名导出问题:默认导出是 lazy 的约定,非默认导出需手动转换。
  5. 避免在 Suspense 外部调用 lazy 组件:否则无法捕获挂起。

示例 懒加载组件

lazy 接收一个函数,该函数必须返回一个动态 import() 调用(返回 Promise,其 resolve 值为包含 React 组件的模块)。Suspense 用于包裹懒加载组件,并在等待期间渲染 fallback 内容。

import { lazy, Suspense, useState } from "react";

const Card = lazy(() => import("./Card"));
const SuspenseB = () => {
  const [num, setNum] = useState(0);
  return (
    <div className="suspense-b">
      <p>num: {num}</p>
      <button onClick={() => setNum(num + 1)}>click</button>
      <Suspense fallback={<div className="suspense-b-fallback">Loading...</div>}>
        <Card />
      </Suspense>
    </div>
  );
};
export default SuspenseB;

懒加载组件开始到成功的 三个阶段

初始化 状态 -1

image.png

加载中 状态 0

image.png

加载完成 状态 1

image.png

import {  useState, } from "react";

const Card = () => {
  const [count, setCount] = useState(0);
  return (
    <div className="card">
      <p>Count: {count}</p>
      <button onClick={() => setCount((val) => val + 1)}>Click me</button>
    </div>
  );
};

export default Card;

调用 React.lazy(() => import('./Component')) 会生成一个特殊的“懒加载对象”,内部包含 _status(初始为 Uninitialized)和 _result(存储加载器函数)。

image.png

image.png

beginWork

image.png

fiber.elementType

image.png

resolveLazy 解析 lazy 组件

image.png

lazy._init(lazy._payload) 执行初始化函数

image.png

回到 resolveLazy 解析 lazy 组件

全局变量 suspendedThenable 为 promise pending状态

image.png

 const SuspenseException: mixed = new Error(
  "Suspense Exception: This is not a real error! It's an implementation " +
    'detail of `use` to interrupt the current render. You must either ' +
    'rethrow it immediately, or move the `use` call outside of the ' +
    '`try/catch` block. Capturing without rethrowing will lead to ' +
    'unexpected behavior.\n\n' +
    'To handle async errors, wrap your component in an error boundary, or ' +
    "call the promise's `.catch` method and pass the result to `use`.",
);

handleThrow 负责处理渲染过程中抛出的各种异常(包括普通错误和 Suspense 挂起)

image.png

getSuspendedThenable

全局变量重置 为 null ,返回 promise pending

image.png

回到 handleThrow

image.png

renderRootSync

全局变量 workInProgressSuspendedReason 为 3, 代表 SuspendedOnImmediate 因任务立即挂起

image.png

找到边界

image.png

// 未挂起,正常渲染
const NotSuspended: SuspendedReason = 0;
// 渲染过程中抛出异常
const SuspendedOnError: SuspendedReason = 1;
// 等待异步数据
const SuspendedOnData: SuspendedReason = 2;
// 因立即任务挂起
const SuspendedOnImmediate: SuspendedReason = 3;
// 因实例挂起
const SuspendedOnInstance: SuspendedReason = 4;
// 因实例挂起但准备继续
const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
// 因废弃的 Promise 挂起
const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 6;
// 挂起准备继续
const SuspendedAndReadyToContinue: SuspendedReason = 7;
// 因 hydration 挂起
const SuspendedOnHydration: SuspendedReason = 8;
// 因 action 挂起
const SuspendedOnAction: SuspendedReason = 9;

throwAndUnwindWorkLoop 处理渲染过程中的异常/挂起,展开栈并找到处理边界

image.png

throwException 处理渲染阶段的异常和 Suspense

image.png

attachPingListener 为 Suspense 边界的挂起 Promise(wakeable)添加“ping”监听器

image.png

渲染 fallback

再次进入 ,lazy组件还是加载中

image.png

懒加载组件加载完成

image.png

加载完毕是一个函数组件

image.png

示例 命名导出组件的懒加载

const InfoCard = lazy(() =>
  import("./Card").then((mod) => ({ default: mod.InfoCard })),
);
export const InfoCard = () => {
  const [count, setCount] = useState(0);
  return (
    <div className="info-card">
      <p>Info Card</p>
      <button onClick={() => setCount((val) => val + 1)}>
        InfoCard-Click me
      </button>
      <p>InfoCard Count: {count}</p>
    </div>
  );
};

beginWork 阶段

fiber.tag = 16 , 代表 LazyComponent

case LazyComponent: {
  const elementType = workInProgress.elementType;
  return mountLazyComponent(
    current,
    workInProgress,
    elementType,
    renderLanes,
  );
}

image.png

completeWork 阶段

case LazyComponent:
case SimpleMemoComponent:
case FunctionComponent:
case ForwardRef:
case Fragment:
case Mode:
case Profiler:
case ContextConsumer:
case MemoComponent:
  bubbleProperties(workInProgress);
  return null;

源码

const Uninitialized = -1; // 未初始化,未调用
const Pending = 0; // 加载中
const Resolved = 1; // 加载成功
const Rejected = 2; // 加载失败
function lazy<T>(
  ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {

  // 创建 payload 对象
  const payload: Payload<T> = {
    // We use these fields to store the result.
    _status: Uninitialized, // 初始化,未调用
    _result: ctor, // 存储工厂函数
  };

  // 创建 lazyType 对象   
  const lazyType: LazyComponent<T, Payload<T>> = {
    $$typeof: REACT_LAZY_TYPE, // 标识是 lazy 组件
    _payload: payload, // 存储 payload 对象,加载信息
    _init: lazyInitializer, // 初始化函数
  };

  return lazyType;
}
  • 未初始化状态(_status === Uninitialized),状态变为 Pending,执行 throw payload._result;,抛出 thenable。这正是 React Suspense 的触发点。
  • 成功回调:当模块加载成功时,将 payload._status 设置为 Resolvedpayload._result 设置为模块对象。
  • 失败回调:将 payload._status 设置为 Rejectedpayload._result 设置为错误对象。如果是 Rejected,抛出错误,由最近的错误边界(Error Boundary)捕获
function lazyInitializer<T>(payload: Payload<T>): T {
  // 未初始化处理
  if (payload._status === Uninitialized) {
    let resolveDebugValue: (void | T) => void = (null: any);
    let rejectDebugValue: mixed => void = (null: any);
    const ctor = payload._result; // 加载器函数 () => import("")
    const thenable = ctor(); // 加载器函数返回的 Thenable 对象

    // 监听 Promise 状态变化
    thenable.then(
      moduleObject => { // 加载成功
        // 正在加载、未初始化
        if (
          (payload: Payload<T>)._status === Pending ||
          payload._status === Uninitialized
        ) {
          // Transition to the next state.
          const resolved: ResolvedPayload<T> = (payload: any);
          resolved._status = Resolved; // 设置状态为加载成功
          resolved._result = moduleObject; // 设置结果为模块对象


          if (thenable.status === undefined) {
            const fulfilledThenable: FulfilledThenable<{default: T, ...}> =
              (thenable: any);
            fulfilledThenable.status = 'fulfilled'; // 设置状态为加载成功
            fulfilledThenable.value = moduleObject; // 设置值为模块对象
          }
        }
      },
      // 加载失败
      error => {
        if (
          (payload: Payload<T>)._status === Pending ||
          payload._status === Uninitialized
        ) {
          // Transition to the next state.
          const rejected: RejectedPayload = (payload: any);
          rejected._status = Rejected; // 设置状态为加载失败
          rejected._result = error; // 设置结果为错误对象
 
          if (thenable.status === undefined) {
            const rejectedThenable: RejectedThenable<{default: T, ...}> =
              (thenable: any);
            rejectedThenable.status = 'rejected';
            rejectedThenable.reason = error;
          }
        }
      },
    );


    // 未初始化
    if (payload._status === Uninitialized) {
      const pending: PendingPayload = (payload: any);
      pending._status = Pending;
      pending._result = thenable;
    }
  }
  // 加载成功
  if (payload._status === Resolved) {
    const moduleObject = payload._result;
    return moduleObject.default; // 返回模块对象的默认导出
    
  } else {
   // 抛出 thenable。这正是 React Suspense 的触发点
    throw payload._result;
  }
}

Suspense

Suspense 是 React 内置的组件,用于包裹那些可能“挂起”(Suspend)的子组件。当子组件抛出 Promise(或 React 内部的 Suspense 异常)时,Suspense 会捕获并渲染 fallback 属性指定的占位内容,直到 Promise 解决后重新渲染子组件。

suspense 能够实现:

  • 并行等待多个资源。
  • 避免加载闪烁(快速加载时不显示 fallback)。
  • 与错误边界(Error Boundary)无缝集成。

注意事项

  1. 避免在 fallback 中再使用 Suspense
  2. Suspense 不能捕获错误 。它只处理 Promise 挂起,普通错误(如运行时错误)需要 Error Boundary。

beginWork

Suspense 组件会根据是否已捕获挂起(DidCapture 标记)或需要停留在 fallback 状态,决定本次渲染显示 fallback 还是 primary 内容:

  • 若需显示 fallback,则创建 fallback 子树并将 primary 子树包裹为隐藏的 Offscreen 组件以保留状态。
  • 否则正常渲染 primary 子树。
case SuspenseComponent:
      return updateSuspenseComponent(current, workInProgress, renderLanes);

completeWork

Suspense 组件负责完成水合收尾(处理 SSR 脱水节点)、处理 DidCapture 标记以触发重新渲染 fallback、调度重试队列(为等待的 Promise 附加 ping 监听器),并标记因 fallback/primary 切换而产生的副作用(如添加 Visibility 或 Passive 标记),最后向上冒泡 childLanes

commit 阶段

Mutation 子阶段

  • 通过 Offscreen 组件的 Visibility 标记,对 primary 树执行 display: none(隐藏)或恢复显示。
  • 处理边界删除时的清理工作(解绑 ref、调用 componentWillUnmount)。
  • 清空已完成的重试队列。

Layout 子阶段

  • 执行 scheduleRetryEffect 中调度的重试回调:为 retryQueue 中的每个 Promise 附加 ping 监听器(pingSuspendedRoot)。
  • 允许子组件(primary 或 fallback)正常执行 useLayoutEffect 和 componentDidMount/Update

Passive 阶段(异步):

  • 执行 Offscreen 子树上因可见性变化而挂起的 useEffect 清理和回调。

示例 代码分割

import { lazy, Suspense, useState } from "react";

const Card = lazy(() => import("./Card"));
const SuspenseB = () => {
  const [num, setNum] = useState(0);
  return (
    <div className="suspense-b">
      <p>num: {num}</p>
      <button onClick={() => setNum(num + 1)}>click</button>
      <Suspense fallback={<div className="suspense-b-fallback">Loading...</div>}>
        <Card />
      </Suspense>
    </div>
  );
};
export default SuspenseB;

beginWork updateSuspenseComponent

当前正在处理 workInprocess 是 suspense 组件,有属性pendingProps包含children 和 fallback,current为 null

image.png

image.png

mountSuspensePrimaryChildren

直接渲染 primary fiber

mountWorkInProgressOffscreenFiber 创建 Offscreen fiber

image.png

这里return,结束当前的beginWork

image.png

来到 beginWork updateOffscreenComponent

此时 workInProcess 为 Offscreen fiber,(之前 suspense要渲染的primary fiber),current 为 null

image.png

首次挂载创建 Offscreen 实例,用于存储 Offscreen 的可见性、待处理的标记、重试缓存及相关 transitions

image.png

image.png

reconcileChildren 子节点

createFiberFromTypeAndProps 创建 lazy fiber

return,又结束此次 beginWork

image.png

来到 beginWrok lazy fiber

image.png

在解析懒加载组件时,会 有微任务产生

进入 beginWork tag 为13 suspense

image.png

支持显示 fallback

挂载 primary fiber,mode 为隐藏状态

image.png

创建 fallback fiber,类型为 Fragment

image.png

关系

workInProcess.childprimary fiber

primary fibersiblingfallback fiber

image.png

image.png

image.png

beginWork 结束,再次进入beginWork 处理 fallback fiber

当 lazy 加载完成后,继续处理

示例 数据获取 use

import { use, useState, Suspense } from "react";

const fetchData = (async () => {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
  return response.json();
})();

const SuspenseC = () => {
  const result = use(fetchData);
  const [count, setCount] = useState(0);

  console.log("state-a-render-----");

  return (
    <div className="state-a">
      <h3>StateA</h3>
      <p>{result?.title}</p>
      <p>当前count: {count}</p>
      <button onClick={() => setCount(count + 1)}>SuspenseC -点击增加</button>
    </div>
  );
};

const App = () => {
  return (
    <Suspense fallback={<div className="suspense-c-fallback">Loading...</div>}>
      <SuspenseC />
    </Suspense>
  );
};
export default App;

最后

手写虚拟DOM后,我反问面试官:key为什么不能用index?

作者 kyriewen
2026年5月28日 18:12

前言

虚拟DOM和diff算法是React面试的“进阶题”,一般不会让手写完整实现,但一旦遇到,就是区分“会用React”和“懂React”的分水岭。大部分前端能说出虚拟DOM的好处,但真要写一个mini版,很多人会卡在diff的key逻辑上。

今天我就还原那次面试:AI生成的虚拟DOM核心代码、我是如何解释diff的、以及为什么“key不能用index”这个问题能让我反客为主。最后附完整代码,你可以直接拿去跑,也可以用来准备面试。

一、AI生成的虚拟DOM核心代码

我在Cursor里输入:

用原生JavaScript实现一个简易虚拟DOM库,包含:

  • h(type, props, ...children) 创建虚拟节点
  • render(vnode) 将虚拟节点转为真实DOM
  • patch(oldVnode, newVnode) 对比并更新真实DOM,支持key属性,实现最小化更新

AI输出的核心结构如下(精简后):

// 创建虚拟节点
function h(type, props, ...children) {
  return { type, props: props || {}, children: children.flat() };
}

// 渲染虚拟DOM到真实DOM
function render(vnode) {
  if (typeof vnode === 'string') return document.createTextNode(vnode);
  const el = document.createElement(vnode.type);
  for (let key in vnode.props) {
    el.setAttribute(key, vnode.props[key]);
  }
  vnode.children.forEach(child => el.appendChild(render(child)));
  return el;
}

// 简易diff(带key优化)
function patch(oldVnode, newVnode, parent = oldVnode.parentNode) {
  // 如果是文本节点
  if (typeof oldVnode === 'string' || typeof newVnode === 'string') {
    if (oldVnode !== newVnode) {
      parent.replaceChild(render(newVnode), oldVnode);
    }
    return;
  }
  // 不同类型,直接替换
  if (oldVnode.type !== newVnode.type) {
    parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  // 相同类型,更新属性(省略细节)
  // 然后递归处理children,这里重点演示key的作用
  const oldChildren = oldVnode.children;
  const newChildren = newVnode.children;
  const keyedOld = new Map();
  // 将旧节点按key建立索引
  oldChildren.forEach((child, idx) => {
    if (child.props && child.props.key) keyedOld.set(child.props.key, { child, idx });
  });
  // 遍历新节点,复用key相同的节点
  newChildren.forEach((newChild, newIdx) => {
    if (newChild.props && newChild.props.key) {
      const matched = keyedOld.get(newChild.props.key);
      if (matched) {
        // 复用该DOM节点,递归更新子内容
        patch(matched.child, newChild, parent);
        // 移动位置(这里省略,示意核心)
        return;
      }
    }
    // 没有匹配,插入新节点
    parent.appendChild(render(newChild));
  });
}

二、我反问了面试官一个问题

等代码展示完,面试官还没开口,我说:“这个diff算法里用key来匹配节点。很多前端都用过key,但有一个经典误区——把数组索引当key用。您知道为什么这样会有问题吗?”

他来了兴趣:“你说说看。”

我解释:

  • diff算法通过key判断节点是否“相同”。如果用索引,比如列表顺序变了,索引0可能原来对应A,现在对应B,但key相同(都是0),React会认为这两个节点相同,不重新创建,只是更新内容。这样本应销毁A、创建B的场景,变成了复用A并修改内容。如果组件有复杂状态(比如动画、输入框焦点),就会出现状态错乱。
  • 更严重的是,在列表头部插入一个元素,所有后续节点的索引都变了,每个节点都会被“原地修改”,性能反而比不用key还差。
  • 正确做法是用数据中唯一稳定的标识(如id)作为key。

他点头:“这才是我想听到的答案。”

三、为什么面试官认可这种“反客为主”?

他后来告诉我:“你能自己生成正确的diff逻辑,还能主动抛出常见的误区,说明你不仅会写,还真的思考过生产中的坑。这种深度,比背代码有价值。”

所以这道题的关键不是完美写出所有diff逻辑,而是理解key的真实作用。AI帮你搭了骨架,你用自己的理解填充了灵魂。

四、完整可运行的迷你虚拟DOM代码

我把面试中使用的完整代码放在这里,你可以在浏览器控制台运行测试:

// 完整示例(带简版diff和key复用)
function h(type, props, ...children) {
  return { type, props: props || {}, children: children.flat() };
}
function render(vnode) {
  if (typeof vnode === 'string') return document.createTextNode(vnode);
  const el = document.createElement(vnode.type);
  for (let k in vnode.props) el.setAttribute(k, vnode.props[k]);
  vnode.children.forEach(c => el.appendChild(render(c)));
  return el;
}
function patch(oldVnode, newVnode, parent = oldVnode.parentNode) {
  if (oldVnode === newVnode) return;
  // 文本节点
  if (typeof oldVnode === 'string' || typeof newVnode === 'string') {
    if (oldVnode !== newVnode) parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  if (oldVnode.type !== newVnode.type) {
    parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  // 更新属性(略)
  // 处理children(简易版:只演示替换,不移动)
  const oldChildren = oldVnode.children;
  const newChildren = newVnode.children;
  const maxLen = Math.max(oldChildren.length, newChildren.length);
  for (let i = 0; i < maxLen; i++) {
    if (i < oldChildren.length && i < newChildren.length) {
      patch(oldChildren[i], newChildren[i], parent.childNodes[i]);
    } else if (i < newChildren.length) {
      parent.appendChild(render(newChildren[i]));
    } else {
      parent.removeChild(parent.childNodes[i]);
    }
  }
}

你可以用这段代码测试列表渲染,尝试改变顺序或插入头节点,观察不用key vs 用index vs 用id的区别。

五、写在最后

虚拟DOM和diff是React的根基,手写一遍能让你对性能优化有更深的体感。AI能帮你快速生成模板,但真正拉开差距的,是对“为什么key不能用index”这种问题的思考深度。

Redux 中间件作用(redux-thunk/redux-saga)

作者 光影少年
2026年5月28日 16:59

Redux 中间件(Middleware)本质上是:
dispatch(action) 到达 reducer 之前,对 action 做增强处理的一层机制。

它主要解决:

  • 异步请求
  • 日志打印
  • 权限校验
  • 接口调用
  • 延迟 dispatch
  • 副作用管理

一、Redux 默认的问题

Redux 原生规定:

store.dispatch({
  type: 'ADD'
})

dispatch 只能发送:

  • 普通对象 action

而且:

  • reducer 必须是纯函数
  • reducer 不能写异步

所以:

setTimeout()
axios()
fetch()

这些都不能直接写进 reducer。

这时候就需要:

中间件 Middleware


二、中间件执行流程

Redux 数据流:

dispatch(action)
   ↓
middleware
   ↓
reducer
   ↓
store 更新
   ↓
view 更新

多个中间件:

dispatch
  ↓
thunk
  ↓
logger
  ↓
saga
  ↓
reducer

三、redux-thunk

1. thunk 是什么

Redux Thunk

Thunk 是 Redux 最常用的异步中间件。

它允许:

dispatch(function)

而不是只能:

dispatch(object)

四、redux-thunk 核心思想

普通 Redux:

dispatch({
  type: 'GET_USER'
})

Thunk:

dispatch(async function(dispatch){
   const res = await axios.get('/user')

   dispatch({
      type:'SET_USER',
      payload: res.data
   })
})

也就是:

dispatch 一个函数

函数内部:

  • 可以写异步
  • 可以再次 dispatch
  • 可以拿到 store

五、thunk 工作原理

内部核心思想:

const thunk = store => next => action => {

   if(typeof action === 'function'){
      return action(store.dispatch, store.getState)
   }

   return next(action)
}

意思:

  • 如果 dispatch 的是函数

    • 就执行它
  • 如果是普通对象

    • 继续传给 reducer

六、thunk 使用流程

1. 安装

npm install redux-thunk

2. 注册 middleware

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'

const store = createStore(
  reducer,
  applyMiddleware(thunk)
)

3. 编写异步 action

export const getUser = () => {

  return async (dispatch) => {

    const res = await axios.get('/api/user')

    dispatch({
      type:'SET_USER',
      payload:res.data
    })
  }
}

4. 页面调用

dispatch(getUser())

七、redux-thunk 优缺点

优点

简单易学

适合:

  • 小项目
  • 中型项目
  • 简单异步

缺点

大型项目容易:

  • 回调地狱
  • action 逻辑混乱
  • 难维护
  • 副作用分散

例如:

dispatch(async ()=>{
   await api1()
   await api2()
   await api3()
})

会越来越复杂。


八、redux-saga

Redux-Saga

Saga 是:

更强大的异步流程管理方案

核心思想:

把异步逻辑单独管理

类似:

  • 后台任务
  • 事件监听
  • 协程
  • generator

九、saga 最大特点

它使用:

Generator

例如:

function* getUserSaga() {

   const res = yield call(api.getUser)

   yield put({
      type:'SET_USER',
      payload:res
   })
}

十、saga 工作流程

dispatch(action)
    ↓
saga监听
    ↓
执行异步任务
    ↓
put(action)
    ↓
reducer

十一、核心 API

takeEvery

监听每次 action

yield takeEvery('GET_USER', getUserSaga)

takeLatest

只保留最后一次请求

适合搜索:

yield takeLatest('SEARCH', searchSaga)

put

等于 dispatch

yield put({
  type:'SET_USER'
})

call

调用异步函数

yield call(api.getUser)

select

获取 store 数据

const state = yield select()

十二、saga 使用流程

1. 安装

npm install redux-saga

2. 创建 sagaMiddleware

import createSagaMiddleware from 'redux-saga'

const sagaMiddleware = createSagaMiddleware()

3. 注册

const store = createStore(
  reducer,
  applyMiddleware(sagaMiddleware)
)

4. 启动 saga

sagaMiddleware.run(rootSaga)

5. 编写 saga

function* getUserSaga(){

   const res = yield call(api.getUser)

   yield put({
      type:'SET_USER',
      payload:res
   })
}

function* rootSaga(){
   yield takeEvery('GET_USER', getUserSaga)
}

十三、thunk vs saga

对比 thunk saga
学习成本
异步方式 函数 Generator
复杂流程 一般
维护性
适合项目 小中型 大型
取消请求 不方便 容易
并发控制
副作用管理 分散 集中

十四、实际项目怎么选

小项目

直接:

  • Redux Toolkit
  • thunk

现在主流:

Redux Toolkit

因为 RTK 默认内置 thunk。


大型项目

复杂场景:

  • websocket
  • mqtt
  • 长连接
  • 多请求编排
  • 权限流
  • 工作流

适合:

  • saga

十五、你现在前端开发里更应该学什么

结合你现在 React + 平台开发经验:

建议优先级:

Redux Toolkit
    ↓
RTK Query
    ↓
redux-thunk
    ↓
redux-saga

因为现在很多公司:

  • 已经不用传统 redux
  • 更偏 RTK

十六、现代 Redux 已经变成这样

以前:

redux
redux-thunk
action
reducer
constants
types

现在:

Redux Toolkit
createSlice
createAsyncThunk
RTK Query

代码量减少很多。


十七、现代写法(推荐)

import { createAsyncThunk } from '@reduxjs/toolkit'

export const getUser = createAsyncThunk(
  'user/getUser',
  async ()=>{

     const res = await axios.get('/user')

     return res.data
  }
)

萌新小白基础理解篇之 this 关键字

作者 biubiubiu_LYQ
2026年5月27日 18:35

前言

  早在我们前几篇文章中,就有出现过 this ,但是我们一直没有详细解释 this 是什么,this 可以出现在哪,this 的用法又是如何?那这篇文章我们一起来看看吧!

一、为什么要有this?

  this 是 js 中的一个关键字,它提供了一种更优雅的方式隐式的传递一个对象的引用,可以让代码更简洁易于复用,js 关键字是内置好的,拥有特殊语法含义的词,不能作为变量名,函数名,还有if,else,for等等关键字。我们来看一段代码感受一下。

 function identify(context) {
   return context.name.toUpperCase()  //.toUpperCase()  让小写全转化为大写 
 }

 function speek(context) {
   var greeting = 'hello, I am ' + identify(context)
   console.log(greeting);
 }

 var me = {
   name: 'tom'
 }

 speek(me)  

  当代码运行到14行时带来speek()函数的调用,把me作为实参传进去,此时运行speek()函数又带来了identify() 的调用,将me作为实参传进去,返回得到大写的 TOM ,console.log(greeting)得到 hello,I am TOM。

image.png

  如果用 this 我们可以怎么写

function identify() {
  return this.name.toUpperCase()
}

function speek() {
  var greeting = 'hello, I am ' + identify.call(this)
  console.log(greeting);
}

var me = {
  name: 'tom'
}

speek.call(me)

  我们可以看到,上述结果是相同的,函数 speek 和 identify 不再接收 context 参数。

  • 使用 this 关键字直接访问调用上下文中的属性(如 this.name)。
  • 调用时,通过 .call(me) 显式绑定 this 指向目标对象。下文会详细解释.call()用法
  • 逻辑链条变为:对象 → 绑定为 this → 函数内部直接通过 this 访问

  它提供了一种更优雅的方式隐式的传递一个对象的引用,可以让代码更简洁易于复用。

image.png

二、this 可以出现在哪?

  • 1.全局 (this === window
  • 2.函数体内

  理论上,this 可以出现在任何地方,如果出现在全局,那么 统一代指 的是window,所以我们主要区分函数体内的 this 代指的是哪个,this 用在不同的地方,代指的内容是不一样的。

this 可以出现在块级作用域但是毫无意义

三、 this的绑定规则

1.默认绑定 --- 当函数独立调用时,函数中的 this 指向 window
var a = 1   //  ===window:{a:1}  等于往window里面增加了 a为1 

function foo(){
    console.log(this.a)  // 1
}

function bar () {
    var a = 2
    foo() //独立调用
}

bar()

   this 如果出现在全局,那么它代指 window , 此时 this 出现在foo函数内,但是这个foo函数是被独立调用的,那么此时 this 依旧指向 windowconsole.log(this.a) 为 1,什么叫独立调用呢? 独立调用 = 函数名直接加括号执行,没有任何对象或上下文“牵着”它。

2.隐式绑定 --- 当一个函数被一个上下文对象所拥有并被该对象调用,那么函数中的 this 指向该对象
var a = 1   //  ===window:{a:1}  等于往window里面增加了 a为1 

function foo(){
    console.log(this.a)   //3
}

function bar () {
    var a = 2
    foo() //独立调用
}

bar()

var test = {
    a : 3,
    foo :foo  //引用函数
}

test.foo()  //隐式绑定

  我们可以看到 前面的 foo() 就是单独的函数名+括号的形式, 后面的为 test.foo() ,打个比方,就像你一个人逛街和你女朋友牵着你逛街的区别,你一个人逛街就叫独立调用,有女朋友牵着就不叫独立调用,此处我们称之为隐式绑定,而此时 this 指向的对象 就是 test ,所以此时 console.log(this.a) 为3

3.隐式丢失 --- 当一个函数被多层对象调用,函数的 this 指向最近的对象
function foo(){
    console.log(this.a)
}
var obj = {
    a:1, 
    foo : foo   //key :value  ,key 的名字可以随便取, 但 value 不可以随便
}
var oo = {
    a : 2,
    foo : obj
}

oo.foo.foo()  //this 指向 obj

  我们来捋一捋这个代码的逻辑,v8运行这段代码,运行到13行前,知道有一个 foo函数 ,有一个obj 对象,一个 oo 对象,当运行到13行时,有函数的调用,才开始读取它们的内容,那代码是从左往右执行,先读取 oo.foo ,那v8就要去oo里面找这个 foo 是什么,我们可以看到此时的 foo 值为 obj 对象,那就相当于 obj.foo(), 在去obj中 找 foo 是什么,此时 foo 的值 为foo 函数 ,然后() 开始foo函数的调用,所以相当于是 obj 调用了这个函数,此时 this 指向 obj ,也即当一个函数被多层对象调用,函数的 this 指向最近的对象。

4.显示绑定 --- 强行''掰弯'' this 指向一个对象 (三种方法)
  • fn.call(obj, x, y)

  • fn.apply(obj, [x,y])

  • fn.bind(obj, x, y)()

function foo(x,y){
    console.log(this.a, x+y)
}

var A = {
    a : 1
}

foo() //独立调用 指向 window
foo.call(A,1,2)  //this 指向A  传递参数 1,2
foo.apply(A,[2,3])  // this 指向A 传入参数2,3 
foo.bind(A,1,2)() //this 指向A,传入参数1,2 

.call( obj, x, y) : 让 this 强行指向 A,可以逐个传递参数 (较为零散的方式传递参数)

.apply( obj, [x,y] ) : 让 this 强行指向 A,以数组的模式逐个传递参数 (较为集中的方式传递参数)

.bind( obj, x, y)() : 让 this 强行指向 A,但是执行完后一定会返回一个函数出来,并且要把它触发掉,也是零散的传递参数,也可以 const bar 来接收 返回的函数 再调用触发,可以分开传参

const bar = foo.bind(obj,x,y)   const bar = foo.bind (obj,x)   const bar = foo.bind (obj) 
bar()                            bar(y)                        bar(x,y)
5.new 绑定 --- new 的原理会导致函数的 this 指向实例对象
function Person(){
    // var obj = {}      //1
    //Person.call(obj)   //2
    this.name = '杰哥'    //3   等同于  obj.name = '杰哥'
    // obj.__proto__ = Person.prototype    //4
    //return obj         //5
}

const p = new Person()  //此时的 p = obj
console.log(p)   // {name : 杰哥}

  我们在万物皆对象那篇文章中有讲到过 new 的工作原理,但当时并没有详细解释 this 所以表述其实并不准确,new 的具体工作原理应该是这样

  • 创建一个空对象 即 var obj = {}

  • 让函数体的 this 强行指向 实例对象 即 Person.call(obj)

  • 运行函数内的代码逻辑

  • 让对象的原型等于函数的原型 即 obj.proto = Person.prototype

  • 返回这个对象 即 return obj

四、箭头函数

  箭头函数没有 this 这个概念,写在箭头函数中的 this,也是它外层那个非箭头函数的

var bar = function(){     //函数表达式

}
bar()

var baz = (x,y) => {     //函数表达式
   
}

  如果不用到 this ,两种写法都是可以的,但如果用到 this 那我们需要注意一下了

function foo(){
    var fn = () =>{   //箭头函数没有 this 这个概念
        this.a = 2
    }
    fn()
}

var obj = {
    a : 1,
    bar:foo
}
obj.bar()
console.log(obj)

  由于箭头函数没有 this 这个概念,写在箭头函数中的 this,也是它外层那个非箭头函数的,所以此时 this 是 foo的 ,而foo是通过obj.bar()调用的,所以 foo 的 this 指向 obj 对象,console.log(obj) 得到 { a : 1, bar : foo }

image.png

箭头函数不可以被new调用 (new的第二步无法执行,用了就会报错)

(如有补充,请大佬指点)

3fd2900e2e696b2fa8e8cedf528d1195.jpg

在 React 里写动画又不跟渲染周期较劲:useRafFn、useRafState、useFps、useDevicePixelRatio、useUpdate

2026年5月27日 16:04

React 用一套时钟,浏览器用另一套。React 的协调器根据 state 更新、effect、调度器对"尽快"的理解来决定何时重新渲染组件。浏览器的合成器则按显示器能撑住的速度刷屏——大多数显示器是 60Hz,少数是 120Hz。两套时钟并不同步。state 更新会落在两次绘制之间被合并;庞大的渲染树可能整个错过一帧;setInterval(handler, 16) 一分钟下来会漂移几百毫秒,因为它根本不关心 GPU 在干嘛。

标准解法是 requestAnimationFrame。它在下一次绘制之前调用你的回调,附带一个高精度时间戳,并且在标签页隐藏时自动节流。它就是所有要看起来"丝滑"的东西该用的原语。但它在 React 里手工接线很繁琐:你需要一个 ref 存帧 ID、一个 effect 启动循环、一段清理函数在卸载时取消、一个 useLatest 让回调看到最新的 props,再加一个 ref 才能做暂停/恢复。每个动画组件都重写一遍这套脚手架,而大多数人第一次写都会漏掉某个清理。

ReactUse 把这套脚手架收进了五个共享同一底层循环的 hook。本文逐个走读——useRafFn 提供循环本身,useRafState 做随循环更新的 state,useFps 量化这个循环,useDevicePixelRatio 让你在循环里以正确分辨率绘制,useUpdate 应付那些"需要推一下 React 但又没 state 可改"的场景。合起来基本能覆盖你在专门的动画库之外要做的所有事。

一个组件里的 bug

一张跟随鼠标的浮卡:

function FloatingCard() {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const move = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', move);
    return () => window.removeEventListener('mousemove', move);
  }, []);

  return (
    <div
      style={{
        position: 'fixed',
        left: pos.x,
        top: pos.y,
        transform: 'translate(-50%, -50%)',
      }}
    >
      card
    </div>
  );
}

看上去没毛病。打开 devtools 性能面板,鼠标在屏幕上甩一遍。在一台快点的笔记本上,mousemove 每秒触发 120 到 500 次,看输入设备和 OS。每次都会调用 setPos,每次都触发一次重渲染调度,React 把它们合并到下一个 microtask。你在做屏幕能展示的两到八倍的协调工作,多出来的渲染全是纯开销——真正有意义的只是下一次绘制之前的最后一次。

useRafState 把这件事压缩成每帧一次,不管事件多快。原地替换,同样的 [state, setState] API,每次鼠标抖动少三次协调。本文剩下的 hook 都遵循同一个模式:保留 React 风格的 API,把 requestAnimationFrame 的管道藏起来。

1. useRafFn——带暂停/恢复的循环

useRafFn 是其他一切的基石。它接收一个回调,在每个 requestAnimationFrame tick 上调用,并把高精度时间戳传进去。返回 [stop, start, isActive],让你可以在标签页失焦、用户交互或任何其他信号上暂停循环:

import { useRef } from 'react';
import { useRafFn } from '@reactuses/core';

function StarField({ count = 200 }: { count?: number }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const starsRef = useRef(
    Array.from({ length: count }, () => ({
      x: Math.random(),
      y: Math.random(),
      z: Math.random() * 0.5 + 0.5,
    })),
  );

  const [stop, start, isActive] = useRafFn((time) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d')!;
    const { width, height } = canvas;

    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, width, height);

    const t = time / 1000;
    for (const star of starsRef.current) {
      const x = ((star.x + t * 0.02 * star.z) % 1) * width;
      const y = star.y * height;
      ctx.fillStyle = `rgba(255, 255, 255, ${star.z})`;
      ctx.fillRect(x, y, 2, 2);
    }
  });

  return (
    <>
      <canvas ref={canvasRef} width={600} height={400} />
      <button onClick={() => (isActive() ? stop() : start())}>
        {isActive() ? '暂停' : '继续'}
      </button>
    </>
  );
}

这个 hook 有四个设计选择值得理解。回调在下一次绘制之前运行——这是 requestAnimationFrame 的语义——所以回调里做的任何 DOM 读取看到的都是即将绘制时的布局,不会额外触发强制回流。回调引用被 useLatest 包了一层,所以你可以闭包到新鲜的 props(count、作用域里任何东西)而不必重启循环。循环挂载时自动启动;第二个参数传 false 则从第一帧起就停在手动控制状态。清理注册在 effect 上,所以卸载时会取消挂起的帧——不会有野回调在死掉的组件上跑。

isActive 返回的是函数而不是布尔。在事件处理器里调用它总能拿到当前值;在渲染里调用只能看到渲染时的值。这种不对称容易踩。如果你要把激活标志用在 JSX 的 disabled={} 这种 prop 上,配合 useUpdatestop/start 调用方里手动 update()——上面示例没这么做是因为按钮文案下一次点击时本来就会重算。

useRafFn 真实场景下还有不少 canvas 之外的用法:任何要在两次事件之间追踪时间的活儿都用得到。一个要按 delta time 积分速度的物理模拟。一个 scrub bar 想紧跟媒体元素的 currentTime,而不是等那个粗糙的 timeupdate 事件(它按编解码器心情触发,不按你心情)。一个用弹簧拖尾跟随真实鼠标的自定义指针——useRafFn 读最新的目标位置,跑一步弹簧迭代,把结果写到 CSS 变量。这些都在替代那些会漂移、又会在后台标签里烧电池的 setInterval 模式。

2. useRafState——按帧合并的 useState

useRafState 是那张浮卡你真正会发布的版本:

import { useRafState } from '@reactuses/core';
import { useEventListener } from '@reactuses/core';

function FloatingCard() {
  const [pos, setPos] = useRafState({ x: 0, y: 0 });

  useEventListener('mousemove', (e) => {
    setPos({ x: e.clientX, y: e.clientY });
  });

  return (
    <div
      style={{
        position: 'fixed',
        left: pos.x,
        top: pos.y,
        transform: 'translate(-50%, -50%)',
        transition: 'transform 0.1s',
      }}
    >
      card
    </div>
  );
}

API 完全是 useState——同样的 setter 签名,同样支持 updater 函数——但写入会被 requestAnimationFrame 排队。同一帧内的五次 setPos 合并为一次 React 更新;React 更新每次绘制最多 flush 一次;DOM 更新的频率正好与屏幕刷新同步。mousemove 监听还是按 500Hz 触发,开销几乎等同于调一个空函数。协调成本掉到 60Hz,正好是屏幕能展示的。

几点要知道。这个 hook 给每个 state 槽位维护一个挂起的 requestAnimationFrame ID,所以同一帧内连续的 setter 是替换,不是排队——最后一个值赢。视觉 state 几乎总是想要这个语义:你不在乎中间的鼠标位置,只在乎绘制那一刻光标在哪。如果你真的在乎——比如你在采样传感器数据每个值都要——那就用普通 useState 并接受重渲染成本,或者写到 ref 里然后用 useRafFn tick 来 flush。

清理细节和 useRafFn 一样:挂起的帧在卸载时取消,所以快速点击-拖拽-卸载的连击不会冒出 setState on unmounted component 警告。内部实现是 useState + useRef(存帧 ID) + useUnmount 清理,总共大概二十行。你自己写得出来;这个 hook 只是省下了你每次都写一遍。

有个坑。因为 state 比事件慢一帧,调用 setter 立刻读 state 还是旧值:

setPos({ x: 100, y: 100 });
console.log(pos); // 还是 { x: 0, y: 0 } —— 更新还没跑

普通 useState 在同一次渲染周期内也是这样,但慢整整一帧这件事在拼命令式代码时容易让你意外。要回读这个值,旁边再放一个 ref 同步存。

3. useFps——量化你做出来的东西

useRafFnuseRafState 都在改善流畅度,但流畅度是一个可量化的指标,不是感觉。useFps 返回当前帧率(数字),通过统计底层 requestAnimationFrame 回调触发的频率算出来:

import { useFps } from '@reactuses/core';

function FpsOverlay() {
  const fps = useFps();
  const color = fps >= 55 ? 'green' : fps >= 30 ? 'orange' : 'red';

  return (
    <div
      style={{
        position: 'fixed',
        top: 8,
        right: 8,
        padding: '4px 8px',
        background: 'rgba(0,0,0,0.7)',
        color,
        fontFamily: 'monospace',
      }}
    >
      {fps} fps
    </div>
  );
}

丢进 dev build,你就有了平时要打开 Chrome rendering 面板才能看的 FPS 计数器。hook 接受一个 every 选项(默认 10),控制平均多少帧;小数字对卡顿响应快但抖动多,大数字读数更平滑但对突然掉帧反应慢。角落的常驻 overlay 用 10 很合适;如果你在调一段具体的卡顿过场动画,就用 1 或 2。

更有意思的用法是自适应渲染。读 FPS,掉到阈值以下就减少要做的事:

function ParticleSystem({ baseCount = 1000 }: { baseCount?: number }) {
  const fps = useFps({ every: 30 });
  const count =
    fps >= 55 ? baseCount : fps >= 40 ? baseCount / 2 : baseCount / 4;

  return <Particles count={count} />;
}

这正是 3A 游戏引擎在帧预算吃紧时的做法——降粒子数、调阴影分辨率、把流体模拟换成更粗的网格。对一个 React 应用来说,通常把动画背景的粒子数减半,或者干脆停掉一个非关键的 useRafFn 循环,就足够了。阈值数字凭口味;60Hz 显示器上 55 是一条合理的"我们基本还行"的线,因为平均值光被 GC 拽一下就能掉进 55 到 60 区间,没人会注意到。

关于 SSR:hook 在服务端返回 0,所以别把关键 UI 卡在"值非零"上。客户端第一次渲染在首个测量窗口结束前也是 0,下个 tick 才跳到真实值。如果你拿它做自适应渲染,第一个测量到达之前默认走"高保真"分支。

4. useDevicePixelRatio——以正确分辨率绘制

Canvas 元素有两套尺寸:CSS 尺寸决定它在页面上看起来多大;像素缓冲尺寸决定它看起来多精细。在 Retina 屏上设备像素比是 2,于是一个 CSS 尺寸 600px × 400px<canvas width="600" height="400"> 会显得糊——600×400 的像素缓冲被浏览器合成器拉伸到 1200×800 的物理像素上。修法是把缓冲设为 cssWidth × dprcssHeight × dpr,再把绘图上下文按 dpr 缩放,这样坐标还是按 CSS 单位写。

useDevicePixelRatio 响应式地追踪当前像素比——包括用户把窗口从 Retina 笔记本屏拖到外接 1x 显示器时:

import { useRef, useEffect } from 'react';
import { useDevicePixelRatio } from '@reactuses/core';

function CrispCanvas({ width, height, draw }: {
  width: number;
  height: number;
  draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => void;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const { pixelRatio } = useDevicePixelRatio();

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.width = width * pixelRatio;
    canvas.height = height * pixelRatio;
    const ctx = canvas.getContext('2d')!;
    ctx.scale(pixelRatio, pixelRatio);
    draw(ctx, width, height);
  }, [width, height, pixelRatio, draw]);

  return (
    <canvas
      ref={canvasRef}
      style={{ width, height }}
    />
  );
}

三行命令式 setup,但这三行恰好是几乎所有 React canvas 教程都写错的三行:把缓冲尺寸设为 css × dpr,再用内联 style 把 CSS 尺寸设回原始值,最后缩放上下文。这个 hook 让第三个依赖——像素比——变成响应式,所以把窗口从一个显示器拖到另一个会触发以新密度重绘。

内部用的是 matchMedia,针对当前像素比的 (resolution: <ratio>dppx) query。比率变化时 matchMedia 监听器触发,hook 重渲染,你的 effect 拿到新值再跑一次。监听器在挂载时加一次、卸载时移除——和本文所有 hook 一样的生命周期。

同样的模式适用于一切要画像素的东西:图像 canvas、WebGL 上下文、视频帧抽取。对 <img>srcset 选择也有意义,但浏览器会自动处理;只有你自己在做渲染时才需要这个 hook。SSR 返回 1,让服务端的布局计算保持合理,hydration 后第一次绘制时再更新到真实值。

5. useUpdate——一次无 state 的重渲染

本文最怪也是你最少用到的 hook。useUpdate 返回一个引用稳定的函数,调用时强制组件重渲染:

import { useRef } from 'react';
import { useUpdate, useRafFn } from '@reactuses/core';

function StopwatchDisplay() {
  const startRef = useRef(performance.now());
  const update = useUpdate();

  useRafFn(() => {
    update();
  });

  const elapsed = ((performance.now() - startRef.current) / 1000).toFixed(2);
  return <div>{elapsed}s</div>;
}

这个秒表每帧更新一次,并不把已用时间放到 React state 里。真相来源是 performance.now(),每次渲染重新读;useUpdate 的存在只是为了调度渲染。六行,没有 setState,没有对过期时间的闭包。你也可以用 useState((s) => s + 1) 做同样的事,但用 useUpdate 意图更清楚——"再渲一次这玩意",而不是"为了让它再渲一次而递增一个计数器"。

更实用的用法是和那些 React 不追踪其变化的命令式 API 互通。一个通过引用暴露当前相机位置的 WebGL 渲染器;一个 Three.js 场景图;一个你拿来当 state 用、但不想每次改都重建的 SetMap。改完之后调一下 update() 告诉 React 这个组件脏了:

function FavoritesList({ favorites }: { favorites: Set<string> }) {
  const update = useUpdate();

  return (
    <ul>
      {[...favorites].map((id) => (
        <li key={id}>
          {id}{' '}
          <button onClick={() => {
            favorites.delete(id);
            update();
          }}>
            remove
          </button>
        </li>
      ))}
    </ul>
  );
}

直接改 Set 再重渲,对大集合来说比 setFavorites(new Set([...favorites].filter(x => x !== id))) 快,还能让 Set 的引用在多次渲染间保持稳定,下游 memoize 的子组件就不用重算。它当然也是个一脚踏入坑里的好办法——React 的优化假设不可变,凡是靠引用变化检测更新的地方都会默默失灵。要刻意用、用要标注清楚、性能压不出问题就老老实实 useState

useUpdate 也常和 useTextSelection 这类与可变平台对象打交道的 hook 搭档(事件 hooks 那篇覆盖了这种情况)。如果底层对象在多次调用间是同一个引用,setState 是个空操作;useUpdate 就是绕路办法。

凑齐:60fps 弹簧拖尾指针

一次用上五个里的四个。一个用弹簧拖尾跟随真实鼠标的自定义指针,在 Retina 上以正确分辨率绘制,角落显示自己的 FPS,标签页隐藏时暂停:

import { useRef } from 'react';
import {
  useRafFn,
  useRafState,
  useFps,
  useDevicePixelRatio,
  useEventListener,
} from '@reactuses/core';

function SpringCursor() {
  const target = useRef({ x: 0, y: 0 });
  const [pos, setPos] = useRafState({ x: 0, y: 0 });
  const velocity = useRef({ x: 0, y: 0 });
  const fps = useFps();
  const { pixelRatio } = useDevicePixelRatio();

  useEventListener('mousemove', (e: MouseEvent) => {
    target.current = { x: e.clientX, y: e.clientY };
  });

  useRafFn(() => {
    const dx = target.current.x - pos.x;
    const dy = target.current.y - pos.y;
    const stiffness = 0.15;
    const damping = 0.7;
    velocity.current.x = velocity.current.x * damping + dx * stiffness;
    velocity.current.y = velocity.current.y * damping + dy * stiffness;
    setPos({
      x: pos.x + velocity.current.x,
      y: pos.y + velocity.current.y,
    });
  });

  useEventListener('visibilitychange', () => {
    if (document.hidden) velocity.current = { x: 0, y: 0 };
  });

  const size = 24;
  return (
    <>
      <div
        style={{
          position: 'fixed',
          left: pos.x,
          top: pos.y,
          width: size,
          height: size,
          marginLeft: -size / 2,
          marginTop: -size / 2,
          borderRadius: '50%',
          background: 'currentColor',
          pointerEvents: 'none',
          imageRendering: pixelRatio >= 2 ? 'auto' : 'pixelated',
        }}
      />
      <div style={{ position: 'fixed', top: 8, left: 8, fontFamily: 'monospace' }}>
        {fps} fps @ {pixelRatio}x
      </div>
    </>
  );
}

四个 hook 各干各的。useEventListener 以原生速率把鼠标坐标读到 ref——不触发 React 渲染。useRafFn 每帧跑一次弹簧积分,读最新目标位置、写当前弹簧位置。useRafState 把每帧的位置更新合并成一次渲染。useFps 反馈当前帧率。useDevicePixelRatio 影响 image-rendering 的选择(小细节,但正好是那种没人注意到、直到 1x 显示器上的用户来投诉的细节)。

朴素版本要么在每个 mousemove 上 setState(500Hz 渲染,烧电池),要么靠 setInterval(handler, 16)(漂移,并且在后台标签里继续跑),要么干脆不要弹簧、看上去很廉价。用这些 hook 之后,读取频率就是问题本身的频率——每帧一次,React 树永远不会以快于用户能看到的速度重渲染。

何时用哪个

你想
每个动画帧跑一个回调 useRafFn
每次绘制最多更新一次 state useRafState
测当前帧率 useFps
以显示器原生分辨率绘制 useDevicePixelRatio
改了 React 看不到的东西之后重新渲染 useUpdate

两条非规则。useRafFn 不是 setInterval 的替代——它按显示器刷新率跑,ProMotion 屏上是 120Hz,省电模式标签里是 30Hz。如果你要严格的"每秒 N 次"节拍,用 useInterval 然后接受视觉代价。还有 useUpdate 是逃生舱——一份代码库里反复用它超过一两次,背后的真问题往往是"我为了性能把 state 放到了 React 之外",正确的修法是修那个性能问题,而不是把逃生舱当常规。

安装

npm install @reactuses/core
# 或
pnpm add @reactuses/core
# 或
yarn add @reactuses/core

五个 hook 都是单独 tree-shake——引 useRafState 不会把 useDevicePixelRatio 拖进来。每个都带 TypeScript 类型,在客户端渲染应用和 SSR 框架(Next.js、Remix、Astro)里都能用;基于循环的 hook 在服务端是 no-op,useDevicePixelRatiouseFps 在 hydration 之前返回安全默认值(分别是 10)。

相关 hook

如果你想要的渲染循环 hook 不在这份名单里,三篇邻居博客可以一起看。ref 逃生舱 那篇讲 useLatest——它就是 useRafFn 内部用来让回调看到新鲜闭包又不重启循环的那个 trick——如果你想理解这些 hook 怎么实现而不只是怎么用,从这一篇开始。事件 hooksuseEventListeneruseThrottleFn,它们和 useRafFn 在输入驱动的动画上配合得很自然。滚动效果 那篇讲的是在这些原语之上更高一层的滚动联动动画 hook。

reactuse.com 浏览完整列表,或者直接打开上面任意一个 hook 读源码——它们大多不到 40 行,五个 hook 底下的循环原语都是同一个八行的 useRef + useEffect 模式,你大概率已经自己写过半打了。

深度解析 JS 中的 this 指向:从底层逻辑到实战规则

作者 甜味弥漫
2026年5月27日 14:34

前言

在 JavaScript 的面试和日常开发中,this 绝对是一个绕不开的“大山”。很多初学者会被它忽左忽右的指向搞得晕头转向。今天我结合自己的学习笔记,把 this 的来龙去脉和绑定规则彻底理清楚。希望对同样在进阶路上的你有所帮助!

一、 为什么我们需要 this?

很多同学会问:既然我可以直接引用对象名,为什么还要用 this? 核心价值:隐式传递对象引用。 this 提供了一种更优雅的方式来传递引用,使得代码更简洁、易于复用。

function identify() {
    return this.name.toUpperCase();
}

var me = { name: "Kyle" };
var you = { name: "Reader" };

identify.call(me);  // KYLE
identify.call(you); // READER

如果不使用 this,你就需要显式地将对象作为参数传递,代码会变得冗余且难以维护。

二、 this 到底出现在哪?

在 JavaScript 中,this 主要出现在两个地方:

  1. 全局环境:在浏览器环境下,this 直接指向 window 对象。
  2. 函数体内:这是最复杂的地方,this 的指向不是在函数创建时决定的,而是在函数被调用时决定的

三、 五大绑定规则

掌握了下面这五条规则,你就掌握了 this 的“密码”:

1. 默认绑定

当函数被独立调用(不带任何修饰的函数调用)时,函数中的 this 指向全局对象 window。

function foo() {
    console.log(this); 
}
foo(); // window

2. 隐式绑定

当函数被一个上下文对象所拥有,并被该对象调用时,this 指向该对象。

var obj = {
    a: 2,
    foo: function() { console.log(this.a); }
};
obj.foo(); // 2

3. 隐式丢失(就近原则)

这是一个细节:当函数被多层对象嵌套调用时,this 指向离它最近的那个对象。

var obj2 = {
    a: 42,
    foo: function() { console.log(this.a); }
};
var obj1 = {
    a: 2,
    obj2: obj2
};
obj1.obj2.foo(); // 42 (指向 obj2)

4. 显式绑定 (Explicit Binding)

显式绑定就像是给函数下达“死命令”,强制它在执行时将 this 指向我们指定的对象。

① call —— 逐个传参的“指挥官”

call 会立即执行函数。它的第一个参数是 this 的指向,后面的参数需要一个一个列出来。

function greet(skill, hobby) {
    console.log(`我是${this.name},我会${skill},喜欢${hobby}`);
}

const user = { name: "阿强" };

// 语法:fn.call(thisArg, arg1, arg2, ...)
greet.call(user, "JavaScript", "代码"); 
// 输出:我是阿强,我会JavaScript,喜欢代码

② apply —— 数组传参的“打包员”

apply 的功能和 call 完全一样,唯一的区别是:它接收参数的方式是数组。这在处理动态参数(如获取数组最大值)时非常有用。

const user = { name: "阿珍" };

// 语法:fn.apply(thisArg, [argsArray])
greet.apply(user, ["Python", "看书"]);
// 输出:我是阿珍,我会Python,喜欢看书

③ bind —— 延后执行的“契约书”

bind 不会立即执行函数,而是返回一个绑定了新 this 的新函数。你可以随时在需要的时候调用它。

const user = { name: "老王" };

// 语法:const newFn = fn.bind(thisArg, arg1, ...)
const bindGreet = greet.bind(user, "Vue", "钓鱼");

// 此时不会有输出,直到你手动调用它
bindGreet(); 
// 输出:我是老王,我会Vue,喜欢钓鱼

💡 快速对比表

为了方便记忆,我总结了一个对比表,大家可以直接保存:

方法 立即执行 传参方式 常用场景
call 参数列表 (arg1, arg2) 对象的属性继承、借用构造函数
apply 数组形式 ([args]) 与 arguments 配合、操作数组
bind 参数列表 (arg1, arg2) React/Vue 中的回调函数绑定、延迟执行

面试小贴士: 如果 call/apply/bind 的第一个参数传入了 null 或 undefined,那么在非严格模式下,this 会自动指向全局对象 window。

5. new 绑定

使用 new 关键字调用构造函数时,JS 内部会创建一个新对象,并把构造函数里的 this 绑定到这个新对象上。

function Person(name) {
    this.name = name;
}
var me = new Person("Jay");
console.log(me.name); // Jay

四、 特殊存在的箭头函数

箭头函数没有自己的 this! 这是它和普通函数最大的区别。箭头函数的 this 是在定义时捕获自外层(父级)非箭头函数的作用域。

注意: 箭头函数的 this 一旦确定,就无法通过 call/apply/bind 再次修改。

总结

  • 独立调用看 window。
  • 对象调用看对象。
  • 多层对象看最近。
  • call/apply/bind 看第一个参数。
  • new 看实例。
  • 箭头函数看它亲爹(外层作用域)。

JavaScript 中的 this 关键字

作者 riuphan
2026年5月27日 00:15

一、为什么需要 this?

  • this 是 js 中的一个关键字,它提供了一种更优雅的方式隐式地传递一个对象的引用,可以让代码更简洁

  • 如果没有 this,我们在编写面向对象的代码时,每一次都需要显式地将对象作为参数传入函数,这样不仅增加了代码的复杂度,也让复用和维护变得困难。

下面通过一个例子来直观感受 this 带来的便利。首先看没有使用 this 的写法:

function identify(context) {
    return context.name.toUpperCase();//将上下文中的name转换为大写
}

function speak(context) {
    var greeting = 'hello, I am ' + identify(context);
    console.log(greeting);
}

var me = { name: 'tom' };
speak(me); // 输出: hello, I am TOM

可以看到,每次调用函数都需要显式传递 context 对象。而使用 this 之后:

function identify() {
    return this.name.toUpperCase();
}

function speak() {
    var greeting = 'hello, I am ' + identify.call(this);
    console.log(greeting);
}

var me = { name: 'tom' };
speak.call(me); // 输出: hello, I am TOM

两段代码的输出结果完全相同,但第二种写法更加简洁,函数不再需要额外的参数context来接收对象,代码的复用性和可读性也更高。

二、this 可以出现在哪里?

this 的值取决于它出现的上下文:

  1. 全局作用域下this 指向 window 对象。例如在全局直接打印 this,得到的就是整个浏览器窗口对象。(Node.js下运行不同)

image.png

  1. 函数体内this 的指向取决于函数的调用方式,这是 this 最为复杂也最为重要的部分。

三、this 的绑定规则

1. 默认绑定

当一个函数被独立调用时(即直接调用函数,不依附于任何对象),函数内部的 this 指向 window 对象。这种情况称为默认绑定

var a = 1;
function foo(){
    console.log(this.a);
}
function bar(){
    var a = 2;
    foo(); // 独立调用,this 指向 window
}
bar();

当全局var a = 1时,相当于window.a = 1, 且this指向window,所以打印的是全局的a = 1

2. 隐式绑定

当一个函数被某个上下文对象所拥有,并通过该对象调用时,函数中的 this 会指向这个对象。这就是隐式绑定的核心含义。

function foo(){
    console.log(this);
}
var obj = {
    a: 1,
    foo: foo
};
obj.foo(); // this 指向 obj,打印 {a: 1, foo: f}

3. 隐式丢失

如果一个函数被多层对象引用并调用,this 只会指向距离函数最近的那个对象,这就是隐式丢失现象。

function foo(){
    console.log(this);
}
var obj = {
    a: 1,
    foo: foo
};
var oo = {
    a: 2,
    foo: obj
};
oo.foo.foo(); // this 指向 obj,而不是 oo

4. 显式绑定

有时候我们需要手动指定函数中 this 的指向,JavaScript 提供了三种方法来实现这一点:

  • call:立即调用函数,并指定 this 的值,可以逐个传递参数。
  • apply:与 call 类似,但参数必须以数组形式传递。
  • bind:不立即调用函数,而是返回一个新的函数,且参数可以分开传递
function foo(x, y){
    console.log(this.a, x + y);
}
var l = { a: 1 };

foo.call(l, 1, 2);          // this 指向 l,1和2是传递给函数的参数,打印 1 3
foo.apply(l, [1, 2]);       // this 指向 l,打印 1 3
const bar = foo.bind(l, 1); // 返回新函数,两个参数可以分开传递
bar(2);                      // this 指向 l,打印 1 3

5. new 绑定

使用 new 关键字调用构造函数时,函数内部的 this 会指向由 new 创建的实例对象。这是 JavaScript 实现面向对象编程的核心机制之一。

function Person(){
    this.name = '张三';
    this.age = 18;
}
const p = new Person(); // p 是 Person 构造函数的实例

new 的执行过程可以分解为以下步骤:

  • 创建一个空对象,var obj = {}
  • 将构造函数的 this 指向这个空对象,Person.call(obj)
  • 将空对象的原型指向构造函数的原型,obj.__proto__ = Person.prototype
  • 返回这个对象,return obj

四、箭头函数与 this

箭头函数是 ES6 引入的新语法,它与普通函数有一个关键区别:箭头函数没有 this 的概念。如果在箭头函数中使用 this,相当于是在外层第一个非箭头函数中的 this

function foo(){
    var fn = () => {
        this.a = 2; // this 相当于外层 foo 的 this
    }
    fn();
}
var obj = {
    a: 1,
    bar: foo
};
obj.bar();
console.log(obj.a); // 打印 2

解析:用obj调用,为隐式绑定,故foo的this指向obj,又因为箭头函数的 this 相当于外层 foo 的 this,所以this.a即obj.a,obj中a的值改为2,故打印2。

这个特性使得箭头函数在需要保持 this 上下文时非常有用,比如在回调函数中。同时也要注意,箭头函数不能用 new 关键字调用,因为它没有自己的 this 绑定机制。

五分钟带你深入了解 this

作者 掰头战士
2026年5月27日 00:02

五分钟带你深入了解 this

为什么要有 this

  • this是 js 中的一个关键字,它能做到隐式地传递一个对象的引用,可以让代码更高效、更简洁,易于复用。

this 用在哪

  • 有域的地方就可以用
  1. 全局 this === window

thisNode中指向{};在网页中输出,则指向window

我们在网页端输出this:

console.log(this)

image.png

  1. 函数体内
function foo(){
   var a = 0
   console.log(this.a)
}

块级作用域内this无意义,因为this的绑定只发生在函数调用和全局作用域中

this 用在不同的地方,代指的内容是不一样的

this的绑定规则

默认绑定

  • 当函数独立调用时,函数中的 this指向window对象,如果console.log(this)会输出undefined

什么是独立调用

function foo(){
    console.log(this)
}
foo()   

这里会输出 undefined,像这样声明一个函数,然后没用什么前缀来调用,就是独立调用

隐式绑定规则

  • 当一个函数被一个上下文对象所拥有,并被该对象调用,函数中的this指向该对象
function foo(){
    console.log(this)
}
var obj = {
    a: 1,
    foo: foo      
}
obj.foo()  

调用点有.[],就是非独立调用

隐式丢失(隐式绑定)

  • 当一个函数被赋值给变量独立调用时,原本的隐式绑定会丢失,退化为默认绑定(指向 windowundefined)
function foo(){
    console.log(this.a)
}
var obj = {
    a: 1,
    foo: foo
}
var oo = {
    a: 2,
    foo: obj      
}
oo.foo.foo()     

oo.foo 指向 obj,不是foo

显式绑定

显式绑定有三种类型

  1. fn.call(obj) 可以把函数的this强行绑定到obj中去,并执行

call的源代码会触发fn()

function foo(x, y){
    console.log(this.a, x + y)   
}

var liu = { a: 1 }
foo.call(liu, 1, 2)             

会直接输出 1 3

  1. fn.apply(obj,[x,y])
var jie = { a: 2 }
foo.apply(jie, [2, 3])           

输出 2 5

call大部分一样,但apply接受参数方式不一样,要用数组传递

  1. var bar = fn.bind(obj,x,y) bar()
var fufu = { a: 3 }
const bar = foo.bind(fufu, 1, 4)  
bar()                                  

输出 3 5

也可以分步传参

const bar2 = foo.bind(fufu, 1)
bar2(4)                             

如果我多写一个参数

const bar2 = foo.bind(fufu, 1,4)
bar2(5)            

输出结果不变,因为会优先找 bind() 里的参数

bind执行后一定返回一个新参数,不会立刻执行

  1. new 绑定
  • new 的原理会导致函数的 this指向实例对象obj
function Person(){
    this.name = 'jie'
}
const p = new Person()

让我们复习一下new的工作原理:

  1. 创建一个空对象 {}
  2. this指向这个空对象
  3. 执行构造函数中的代码
  4. 对象.__proro__==Person.prototype

你会发现,这和# 万物皆对象?带你梳理JS原型及其查找链机制讲的new的工作原理不太一样?这才是更细节的版本。

new 的原理会导致函数的 this 指向实例对象。

箭头函数

  • 箭头函数没有自己的this
  • 写在箭头函数内的 this是其外部非箭头函数的this
  • 箭头函数不能作为构造函数来使用,new的执行步骤中用到了把this指向其prototype

例如:

function foo(){
    var fn = ()=>{
        this.a = 2

    }
    fn()
}
var obj ={
    a: 1,
    bar: foo
}
obj.bar()
console.log(obj);

输出{ a: 2, bar: [Function: foo] }

总结

1.看到this时候做两个判断:这个this是谁的,这个this代指的是谁

2.一图让你明白:

调用方式 绑定规则 this指向
foo() 默认绑定 window/ undefined
obj.foo() 隐式绑定 obj
foo.call(obj) 显式绑定 obj
new Person() new绑定 实例对象
()=>{} 箭头函数 外层函数的this

ripgrep Command in Linux: Fast Recursive Search

Searching through a large codebase or a directory full of log files with grep often means adding -r for recursion, -n for line numbers, and --include or --exclude rules to avoid unrelated files. In a Git repository, you may also need extra excludes for build directories, vendor files, and binary output.

ripgrep (rg) handles the common case with better defaults. It searches directories recursively, respects .gitignore, .ignore, and .rgignore rules, skips hidden and binary files during recursive searches, and shows friendly interactive output. It is also much faster than traditional recursive grep on many large file trees because it uses Rust’s regex engine and parallel directory traversal.

This guide explains how to use the ripgrep command in Linux, from basic searches to file type filters, context lines, replacement previews, and config defaults.

Installing ripgrep

On Ubuntu, Debian, and Derivatives, install the ripgrep package with apt:

Terminal
sudo apt install ripgrep

On Fedora, RHEL, and Derivatives, use dnf:

Terminal
sudo dnf install ripgrep

On Arch Linux, install it from the official repositories:

Terminal
sudo pacman -S ripgrep

Verify the installation with:

Terminal
rg --version

The output shows the installed version:

output
ripgrep 15.1.0 (rev af60c2de9d)

Your distribution may ship a different version, but the examples in this guide use common options available in current ripgrep releases.

Syntax

The basic syntax for the rg command is:

txt
rg [OPTIONS] PATTERN [PATH ...]

The PATTERN argument is the text or regular expression to search for. If you omit PATH, rg searches recursively from the current directory.

For example, rg "error" searches the current directory tree, while rg "error" logs/ limits the search to the logs directory.

Basic Search

Search for a pattern in all files under the current directory:

Terminal
rg "error"

Example output:

output
logs/app.log:42:error: connection refused
logs/app.log:78:error: timeout after 30s
src/main.py:114:raise ValueError("error parsing config")

The output shows the filename, line number, and matching line. In an interactive terminal, rg also uses color by default, so you do not need the usual recursive grep flags for the common code-search workflow.

To search in a specific file, pass the filename after the pattern:

Terminal
rg "error" logs/app.log

To search in a specific directory, pass the directory path:

Terminal
rg "error" logs/

Filtering by File Type

Use -t to restrict the search to a known file type:

Terminal
rg -t py "import os"

This searches only Python files. ripgrep includes built-in definitions for many file types. To see the full list, run:

Terminal
rg --type-list

To exclude a file type, use -T:

Terminal
rg -T js "TODO"

You can also use glob patterns with -g to match or exclude specific filenames. Quote glob patterns so the shell does not expand them before rg receives them:

Terminal
rg -g '*.log' "error"

Use a leading ! to exclude matching paths:

Terminal
rg -g '!*.min.js' "console.log"

The ! prefix belongs to rg, so quoting is especially useful in shells where ! can trigger history expansion.

Case-Insensitive Search

Add -i to ignore letter case:

Terminal
rg -i "warning"

This matches warning, Warning, WARNING, and other case variants.

For a more flexible default, use -S or --smart-case:

Terminal
rg -S "warning"

With smart case, an all-lowercase pattern is case-insensitive, but a pattern with any uppercase letter is case-sensitive. For example, rg -S "warning" matches Warning, while rg -S "Warning" searches for that exact capitalization.

Fixed String Search

By default, rg treats the pattern as a regular expression. Use -F to search for a literal string instead, which is useful when the pattern contains regex characters:

Terminal
rg -F "price[0]"

Without -F, [0] would be interpreted as a character class. With -F, rg searches for the literal text price[0].

Counting and Listing Matches

To count matching lines per file instead of printing the matching lines, use -c:

Terminal
rg -c "error"

Example output:

output
logs/app.log:14
logs/nginx/access.log:3

The count is the number of matching lines, not the total number of matching words or strings.

To print only filenames that contain at least one match, use -l:

Terminal
rg -l "error"

To print only filenames that contain no matches, use --files-without-match:

Terminal
rg --files-without-match "error"

This is different from combining -v with -l. The --files-without-match option checks whether a file has zero matching lines.

Context Lines

When a matching line alone is not enough to understand the surrounding code, add context with -C:

Terminal
rg -C 3 "panic"

This prints three lines before and three lines after each match.

Use -A for lines after the match only:

Terminal
rg -A 2 "def connect"

Use -B for lines before the match only:

Terminal
rg -B 2 "def connect"

Context output is useful when you want to inspect the code around a function, error message, or configuration value without opening each file.

Multiple Patterns

Use -e to search for more than one pattern in a single pass:

Terminal
rg -e "error" -e "warning"

This matches any line containing either word. It is equivalent to the regex error|warning, but repeated -e options are easier to read when patterns become longer.

The -e option is also useful when a pattern begins with a dash:

Terminal
rg -e "--force"

Without -e, rg would try to interpret --force as an option.

Whole-Word Match

The -w flag restricts matches to whole words. This is useful when you want to find a variable name without matching it inside a longer name:

Terminal
rg -w "id"

This matches id, but not uid or invalid.

Invert Match

The -v flag prints lines that do not match the pattern:

Terminal
rg -v "^#" config.txt

This shows all lines in config.txt that do not begin with #.

If you want filenames that do not contain a pattern at all, use --files-without-match instead:

Terminal
rg --files-without-match "version" -g '*.json'

Searching Hidden Files and Ignoring .gitignore

By default, recursive rg searches skip hidden files and directories, and respect .gitignore, .ignore, and .rgignore files. This is usually what you want in a source tree.

To include hidden files and directories, use --hidden:

Terminal
rg --hidden "api_key"

To ignore .gitignore, .ignore, and .rgignore rules, use --no-ignore:

Terminal
rg --no-ignore "TODO"

The --no-ignore option does not include hidden files by itself. To search hidden files and ignored files in the same search, combine both options:

Terminal
rg --hidden --no-ignore "password"
Warning
Searching with --hidden --no-ignore can include build artifacts, dependency directories, cache folders, and other large trees. It is much slower and can produce noisy results, so use it only when you have a specific reason to search outside the normal project files.

Replacing Output

The -r option rewrites matching text in the output to show what a replacement would look like. It does not modify any files:

Terminal
rg "foo" -r "bar"

Example output:

output
src/config.py:5:bar = get_setting("bar")

The output shows the line as it would look after replacing foo with bar. This is useful for previewing a project-wide rename before using sed or a text editor’s find-and-replace feature.

For capture groups in replacements, wrap the command in single quotes so the shell does not expand $1, $name, or similar replacement references before rg runs.

Showing Only the Match

By default, rg prints the full line containing the match. Use -o to print only the matched text:

Terminal
rg -o 'v[0-9]+\.[0-9]+\.[0-9]+'

Example output:

output
package.json:4:v1.4.2
package-lock.json:8:v1.4.2

This is useful when you want to extract values from files rather than inspect matching lines in context.

ripgrep Configuration File

ripgrep can read default options from a configuration file, but it does not automatically look in ~/.config/ripgrep/ or any other fixed path. You must point RIPGREP_CONFIG_PATH to the file you want rg to read.

For example, create a config file named ~/.ripgreprc:

~/.ripgreprctxt
--smart-case
--hidden
--glob=!.git/

Then export the environment variable:

Terminal
export RIPGREP_CONFIG_PATH="$HOME/.ripgreprc"

Add that export line to your shell startup file, such as ~/.bashrc or ~/.zshrc, if you want the setting to apply in new terminal sessions.

Each config file line is passed to rg as one command-line argument. For options with values, use either --option=value on one line or put the option and its value on separate lines.

Troubleshooting

rg does not find a file you expected
The file may be hidden, ignored by .gitignore, ignored by .ignore or .rgignore, or detected as binary. Start with rg --debug "pattern" to see why paths were skipped, then add --hidden, --no-ignore, or both only when needed.

A glob pattern does not work as expected
Quote glob patterns, such as rg -g '*.conf' "server" and rg -g '!*.min.js' "console.log". Without quotes, your shell may expand * before rg sees the pattern.

A pattern starting with - is treated as an option
Use -e before the pattern, for example rg -e "--force". You can also use -- to stop option parsing, as in rg -- "--force".

Options Reference

  • -t TYPE - Search only files of the given type.
  • -T TYPE - Exclude files of the given type.
  • -g GLOB - Include or exclude files by glob. A ! prefix excludes paths.
  • -i - Search case-insensitively.
  • -F - Treat the pattern as a fixed string, not a regex.
  • -c - Count matching lines per file.
  • -l - List only filenames with matches.
  • --files-without-match - List only filenames without matches.
  • -C N - Show N lines of context around each match.
  • -A N - Show N lines after each match.
  • -B N - Show N lines before each match.
  • -e PATTERN - Add a search pattern. Repeat it to search for multiple patterns.
  • -w - Match whole words only.
  • -v - Invert the match and print non-matching lines.
  • -o - Print only the matched text, not the full line.
  • -r REPLACEMENT - Show output with matches replaced. This is a preview only.
  • -n - Show line numbers. This is enabled by default in interactive terminal output.
  • --hidden - Search hidden files and directories.
  • --no-ignore - Do not respect .gitignore, .ignore, and .rgignore rules.
  • -S, --smart-case - Search case-insensitively when the pattern is lowercase, and case-sensitively when it contains uppercase.
  • --stats - Print a summary of the search at the end.
  • -M N - Omit lines longer than N bytes.

Quick Reference

For a printable quick reference, see the ripgrep cheatsheet .

Task Command
Search recursively from the current directory rg "pattern"
Search in a specific file rg "pattern" file.txt
Search in a specific directory rg "pattern" logs/
Search only Python files rg -t py "pattern"
Exclude JavaScript files rg -T js "pattern"
Search by glob rg -g '*.log' "pattern"
Exclude by glob rg -g '!*.min.js' "pattern"
Search case-insensitively rg -i "pattern"
Use smart case rg -S "pattern"
Search for a fixed string rg -F "price[0]"
Count matching lines per file rg -c "pattern"
List matching filenames rg -l "pattern"
List filenames without matches rg --files-without-match "pattern"
Show three lines of context rg -C 3 "pattern"
Search multiple patterns rg -e "foo" -e "bar"
Match a whole word rg -w "id"
Invert line matches rg -v "pattern"
Include hidden files rg --hidden "pattern"
Ignore ignore files rg --no-ignore "pattern"
Include hidden and ignored files rg --hidden --no-ignore "pattern"
Preview replacement output rg "old" -r "new"
Print only matched text rg -o "pattern"

Conclusion

ripgrep covers the same ground as grep , but its recursive search, file filtering, and ignore-file support make it a better default for many code and log searches. For path-based file finding, pair it with find , or use rg -l when you need a list of files that contain a specific pattern.

whois Command in Linux: Query Domain Registration Info

When you need to know who owns a domain, when it expires, which registrar handles it, or which organization holds a particular IP block, the whois command is the fastest route. It queries the registry databases that record this information and returns a plain-text response you can scan in a terminal. The output format varies by registry, but the questions you can answer are consistent: registrar, name servers, registration and expiry dates, and contact info (where privacy rules allow).

This guide explains how to use whois in Linux to look up domains, IP addresses, and AS numbers, how to target a specific server, and how to parse the output for the fields you actually care about.

whois Syntax

The general form is:

txt
whois [OPTIONS] OBJECT

OBJECT is the domain, IP address, or AS number you want information about. With no options, whois picks the right registry automatically based on the type of query.

Install whois

whois is not always installed by default. On Ubuntu, Debian, and Derivatives:

Terminal
sudo apt update
sudo apt install whois

On Fedora, RHEL, and Derivatives:

Terminal
sudo dnf install whois

Confirm it is in place:

Terminal
whois --version
output
Version 5.6.6.

The Debian-family whois is an actively maintained client with built-in routing logic that knows which registry to ask for each TLD.

Look Up a Domain

The most common use is checking a domain:

Terminal
whois example.com
output
 Domain Name: EXAMPLE.COM
Registry Domain ID: 2336799_DOMAIN_COM-VRSN
Registrar WHOIS Server: whois.iana.org
Registrar URL: http://res-dom.iana.org
Updated Date: 2026-01-16T18:26:50Z
Creation Date: 1995-08-14T04:00:00Z
Registry Expiry Date: 2026-08-13T04:00:00Z
Registrar: RESERVED-Internet Assigned Numbers Authority
Registrar IANA ID: 376
Name Server: ELLIOTT.NS.CLOUDFLARE.COM
Name Server: HERA.NS.CLOUDFLARE.COM
DNSSEC: signedDelegation
...

The fields that matter most for everyday questions are:

  • Registrar, the company managing the registration.
  • Creation Date and Registry Expiry Date, which tell you how old the domain is and when it needs renewing.
  • Name Server, which lists the DNS servers authoritative for the domain.
  • DNSSEC, which shows whether the domain is cryptographically signed.

For ccTLDs (.de, .uk, .jp), the format differs because each country runs its own registry. The information is similar; the field names and order change.

Look Up an IP Address

whois on an IP returns the network allocation, not the domain:

Terminal
whois 93.184.216.34
output
inetnum: 93.184.216.0 - 93.184.216.255
netname: EDGECAST-NETBLK-03
descr: NETBLK-03-EU-93-184-216-0-24
country: EU
admin-c: DS7892-RIPE
tech-c: DS7892-RIPE
status: ASSIGNED PA
...

This kind of query is the right tool for “who owns this IP that has been hitting my server” investigations. The output names the network block, maintainer, and abuse contact details when the registry publishes them.

Look Up an AS Number

Pass an autonomous system number with the AS prefix:

Terminal
whois AS15169
output
ASNumber: 15169
ASName: GOOGLE
ASHandle: AS15169
RegDate: 2000-03-30
Updated: 2012-02-24
Ref: https://rdap.arin.net/registry/autnum/15169

AS lookups are useful when you trace a route with mtr or traceroute and want to know which network each hop belongs to.

Pick a Specific WHOIS Server

The default routing finds the right server for most TLDs, but you can force a query against a specific server with -h:

Terminal
whois -h whois.arin.net 8.8.8.8

The flag is the right tool for two situations: when the default routing picks the wrong upstream (rare but happens for some legacy TLDs), and when you want to compare answers between regional registries (ARIN, RIPE, APNIC, AFRINIC, LACNIC).

Limit the Recursion

Most modern whois clients follow a referral chain: query IANA, follow the pointer to the TLD registry, follow the pointer to the registrar, and return the most specific answer. To stop registry-to-registrar recursion, pass --no-recursion:

Terminal
whois --no-recursion example.com

The flag is most useful when you specifically want the registry data and not the registrar’s slightly different format.

The -H option has a different purpose. It hides legal disclaimers from the output, which can make short lookups easier to read:

Terminal
whois -H example.com

Filter the Output

Real whois responses are dozens of lines long with legal disclaimers and template text. To extract one field, pipe through grep:

Terminal
whois example.com | grep -E "Registrar:|Expiry Date:"
output
 Registry Expiry Date: 2026-08-13T04:00:00Z
Registrar: RESERVED-Internet Assigned Numbers Authority

For a name-server list:

Terminal
whois example.com | awk '/Name Server:/ {print $NF}'
output
ELLIOTT.NS.CLOUDFLARE.COM
HERA.NS.CLOUDFLARE.COM

These short patterns work for monitoring scripts that watch for domain expirations or DNSSEC status changes.

Check Domain Availability

If the domain is not registered, the response says so explicitly. The exact wording depends on the registry:

Terminal
whois never-existed-domain-xyzzy.com
output
No match for domain "NEVER-EXISTED-DOMAIN-XYZZY.COM".

Some registries (notably .io, .co, and several ccTLDs) return an empty or near-empty response for unregistered domains. Two heuristics that work in scripts:

  • For .com/.net/.org, grep for No match for or Domain Name: in the output.
  • For ccTLDs, grep for Domain not found or check whether the registration fields exist.

Rate Limits and Etiquette

Registries rate-limit whois queries. Hammering them with a script is the fastest way to get blocked. If you query many domains, add a sleep between calls and cache the result locally. For bulk lookups, use the registry’s RDAP service directly or pay for a commercial WHOIS API.

A simple polite pattern:

Terminal
while IFS= read -r domain; do
 whois "$domain"
 sleep 2
done < domains.txt

Two seconds between queries is a sane starting point; raise it if you see throttling responses.

Privacy and Redacted Output

Since GDPR took effect, most TLDs redact personal contact information for individual registrants. The response usually contains placeholders like REDACTED FOR PRIVACY or Data Protected, Not Disclosed. For organizations and legal entities, the contact information often stays visible.

This is not a defect in whois; the underlying registry data is simply less detailed than it used to be. For account-takeover prevention and abuse handling, focus on the registrar field and the abuse contact email, which remain published.

Quick Reference

Task Command
Look up a domain whois example.com
Look up an IP address whois 93.184.216.34
Look up an AS number whois AS15169
Query a specific server whois -h whois.arin.net 8.8.8.8
Stop registry-to-registrar recursion whois --no-recursion example.com
Hide legal disclaimers whois -H example.com
Extract registrar and expiry fields whois example.com | grep -E “Registrar:|Expiry Date:"
List name servers whois example.com | awk ‘/Name Server:/ {print $NF}’

Troubleshooting

whois: command not found
Install the package: sudo apt install whois on Ubuntu, Debian, and Derivatives, or sudo dnf install whois on Fedora, RHEL, and Derivatives. The package is small and adds no significant dependencies.

Output says “fgets: Connection reset by peer”
The registry rate-limited or blocked your IP. Wait a few minutes and retry, slow your script down, or query through a different network.

Response is in a different language or alphabet
Some ccTLD registries return data in the local language. Look for the English section (usually further down), or pipe through iconv if the encoding makes the response unreadable in your terminal.

FAQ

What is the difference between WHOIS and RDAP?
RDAP (Registration Data Access Protocol) is the modern replacement for WHOIS. It returns structured JSON instead of free-text and supports authentication and access controls. Most registries now serve both, and RDAP is usually the better choice for scripts that need predictable fields.

Why does the data for the same domain look different between two whois runs?
Different clients and servers can follow the referral chain differently. One response may come from the registry, while another may include data from the registrar’s WHOIS server. Use --no-recursion when you want to stop at the registry answer.

Can I run my own WHOIS server?
Yes, but only registrars and registries have authoritative data. Self-hosted WHOIS servers are useful for internal directories (IP allocation in a large network), not for public domain lookups.

Conclusion

whois is the answer to “who owns this”, whether the “this” is a domain, an IP, or an AS number. The output is plain text, the flags are short, and a handful of grep/awk patterns turn it into a script-friendly data source. For bulk work, slow the queries down and respect the rate limits the registries publish.

For related reading, see our guides on the dig command and the nslookup command .

three.js从盒子到链条的程序化三维实现

2026年5月24日 01:40

开源仓库: github.com/qdcxj/three… · React Three Fiber · Vite · TypeScript


效果与设计目标

/chain 页:一条悬挂着的金属锁链,链环呈长圆形(跑道形),相邻环错位 90° 穿插,带 PBR 贴图与环境反射,可调节链长、下垂、风力飘动。

5月24日.gif

/box-to-chain 页:许多小方块先摞成一盒金属块,按下「开始」后沿抛物轨迹飞到链上预设位置 → 方块淡出、一环 + 两颗端球长出来 → 按顺序 emissive 「焊接」,最后整条链缓动到下垂形态;链变长时相机与雾、投影范围跟着自动缩放。(第二页建议正文里再放一张自用截图或短视频 GIF,视觉冲击更大。)


如何从仓库跑起来

git clone https://github.com/qdcxj/threejs-box-to-chain.git
cd threejs-box-to-chain
npm install
npm run dev

浏览器打开控制台地址后:

  • /chainChainScene.tsx · 程序化锁链 + Leva 调参
  • / 会自动跳到 /chain · /box-to-chain 为盒子化链特效
  • 贴图在 public/textures/,通过 import.meta.env.BASE_URL 拼路径加载,部署到子路径也不易断链。

总思路:三层分工

层级 做什么
几何 单环 = 中心线曲线 + 圆截面扫掠;整链 = 另一条空间曲线上的密集采样与朝向
材质 MeshStandardMaterial + 五张 PBR 图;diffuse 走 sRGB,其余线性;RepeatWrapping 控制细节密度
时间与相机 动画阶段用单条相对时间线驱动;相机用包围盒 + 画幅 aspect 算视距,避免长链出画

下面按 Demo 1 → Demo 2 写「具体怎么实现」。


Demo 1:/chain 程序化锁链——具体实现

1)单环:为什么不用 TorusGeometry

圆环在工业链里太少见。真实链环多是直边 + 两端半圆,即 stadium(跑道)形闭合中心线。实现上继承 THREE.Curve<Vector3>,用参数 t∈[0,1)弧长拆成四段:上直边、右半圆、下直边、左半圆,都在 XY 平面闭合,长轴沿 +X

然后:

new THREE.TubeGeometry(stadiumCurve, tubularSegments, tubeRadius, radialSegments, true)

最后一个参数 closed: true 表示沿中心线闭合扫一圈,得到「一根铁条弯成环」的实体,而不是一段开口管。

2)整链走向:CatmullRomCurve3 + 控制点

悬挂感由 7 个控制点生成:X 从 -length/2 线性扫到 +length/2;Y 用对称抛物线权重 k = 1 - (2t-1)²sag 得到中间下垂;Z 用 sin(πt) * swirl 做轻微侧摆。再套 CatmullRomCurve3(..., 'catmullrom', 0.5) 得到光滑大曲线 curve

风动时不要逐环算力,只改 中间几个控制点的 Y/Z,整条曲线形变,所有实例跟着走,CPU 成本可控。

3)环数与「环间距」

沿曲线弧长 L = curve.getLength(),相邻环中心近似弧长间距 effectiveSpacing

  • Leva 里 spacing > 0:用手动间距;
  • spacing === 0:用和经验咬合相关的 linkStraight + linkRadius - tubeRadius,让小环更易「扣」进邻近环的视觉。

实例个数:floor(L / effectiveSpacing),至少 2。

4)InstancedMesh:位置和四元数怎么写?

对每个实例索引 i

  • t = i / (count-1)curve.getPointAt(t, pos) 得位置;
  • curve.getTangentAt(t, tangent) 得切线方向(链在该点的走向)。

链环模型里长轴定义为局部 X = (1,0,0),与 stadium 曲线的直段方向一致:

  1. setFromUnitVectors(localX, tangent):把局部 X 旋到与世界切线一致;
  2. 绕切线轴再转 (i % 2) * 90°:相邻环交替,形成穿插;再加整体 twist * t * π 做整条链扭转。
  3. 四元数右乘:q_align * q_spin,写入 dummy 的 quaternion,updateMatrixmesh.setMatrixAt(i, matrix);最后 instanceMatrix.needsUpdate = true

这样既省 draw call,又避免上千个 <mesh> 触发 React reconcile。

5)PBR 与贴图轴向

useTexture 一次拉五张:map / normalMap / roughnessMap / metalnessMap / displacementMap。对每条纹理设置 RepeatWrappingrepeat.u 用 Leva textureRepeat(沿环周铺开),mapSRGBColorSpace

位移 displacementScale 开大容易阴影痤疮,需要和 bias 一起压着调。场景里再配合 Environment(如 warehouse HDR)ContactShadows,金属才「站得住地面」。

两端 EndCap:两个小球放在 CatmullRom 首尾控制点位置,共用同一套贴图材质的视觉锚点。


Demo 2:/box-to-chain——单时间线如何实现「剧情动画」?

1)为什么在 useEffectnew THREE.Mesh

方块数量是 gridDim³(例如 4³ = 64)。若每个 voxel 写一个 React 子组件,useFrame 里再通过 useState 更新,会把 React 和 60fps 绑死。

做法是:挂载一个根 THREE.Group,三层 for 循环里 group.add(box),把引用塞进 unitsRef: UnitObj[]。每个单元结构:

  • 外层 Group:负责整体位移、旋转(从盒子格点飞向链上一点);
  • boxBoxGeometry,阶段 1 可见;
  • sub:子组,内含 TubeGeometry(StadiumCurve) 环 + 两颗 SphereGeometry 端球,阶段初始 scale = 0、不可见。

这样 一整页动画只跑一次 React 渲染树,动力学全在 useFrame 里改 position / quaternion / scale / material.emissive

2)阶段常量(秒)与时间线拆分

源码里常量大致为:

T_EXPLODE · T_MORPH · T_CONNECT_PER(每项焊接节奏) · T_CONNECT_PAD · T_SETTLE
elapsed = clock.elapsedTime - t0,再算:

  • tExplode0 … T_EXPLODE——位置从 gridPos lerp 到「链上的目标点」,y 叠加 sin(u·π)·arcHeight 做抛物感;朝向从单位四元数 slerp 到链上目标的 q_target(与 Demo 1 相仿:对齐切线 + 交错 90°)。
  • tMorph:立方 scale → 0sub scale → 0→1;并叠一层 冷色 emissive 脉冲sin(π·progress))表示「化形」。
  • tConnect:对每个 i,在 tConnectStart + i * T_CONNECT_PER 附近给 暖色 emissive 钟形脉冲,读起来像从左到右咬合一环。
  • tSettle:整条链的中间下垂量 sag 从 0 插到 finalSag(仍用 k = 1-(2ti-1)² 沿链分配高度差);同时两端 挂点球可按 settle 进度 缩放显现

phaseRefidle | running | done)只在 React 侧改;useFrame 里读 phaseRef.current,避免异步 state。进入 running 的第一帧:记录 t0 并把每个单元复位到 gridPos,避免「重播」从上一条结束态突兀跳变。

3)链在空间中的排布:linkSpacing

链上第 i 个单元的水平参数仍可记 ti = i/(count-1)。首尾中心距:

chainLength = (count - 1) * linkSpacing(一环时退化为单笔间距占位)。

(x(ti) = -chainLength/2 + ti · chainLength),与大曲线共用同一套「抛物下垂」表达式,末端切线在 chainLength 很小时退化避免 normalize() 炸了。


自适应相机:BoxChainCameraControls 在算什么?

gridDim 变大linkSpacing 变大,水平跨度猛增。组件在 useLayoutEffect 里读 size.width / size.height

  1. 用链长 + chainBaseY + arcHeight + finalSag 估一个轴对齐包围范围(留 margin);
  2. 对透视相机,给定 候选 FOV,计算 竖直视距 / 横向视距(横向要乘 aspect),取 max 得到能框住整张链的 dist
  3. FOV ∈ [约32°, 55°]少量迭代抬 FOV,避免只靠拉远导致画面像「望远镜」;
  4. 相机放在斜上方 (dx, centerY+ε, dz)lookAt(0, centerY, 0);同步 OrbitControls.targetmaxDistance、以及场景里 雾、接触阴影范围、平行光阴影正交半宽(在 useSceneFraming 里与链长同比例放大),减少「链看见了、影子却裁没了」的违和感。

文件地图(读代码从这里点进去)

路径 内容
src/Chain.tsx StadiumCurveInstancedMesh、CatmullRom、风动、PBR
src/scenes/ChainScene.tsx /chain 场景与 Leva
src/scenes/BoxToChainScene.tsx 盒子化链时间线、挂点、自适应相机
src/App.tsx 路由总线

小结

  • 单环:自定义 Curve + 闭合 TubeGeometry
  • 整链CatmullRom 定空间走向 + 实例矩阵定每环位姿(长轴贴切线 + 90° 交错)。
  • 盒子化链命令式 Three 对象 + 单时间线 useFrame,比「大量 React 组件 + 多个 tween」更稳、更好调时长。
  • 相机与雾影:与 链长、画幅比例 绑定,才能在大屏和「链特别长」两种情况下都不穿帮。

告别“散装代码”:一个前端学习者的首个“模块化”全栈项目实战

作者 浮生望
2026年5月23日 20:53

告别“散装代码”:一个前端学习者的首个“模块化”全栈项目实战

从“div满天飞”到“语义化+前后端分离”,我的代码整洁之路

大家好,我是仍在全栈路上摸索的“小望”。

还记得刚开始写页面的时候,我的“项目”就是一个 index.html 文件。里面从上到下塞满了 <style> 标签写的 CSS、<script> 标签堆的 JS 逻辑、还有直接写在 </table> 里的假数据……改一个按钮颜色,要在密密麻麻的代码里翻半天;想加个新功能,又怕碰坏了哪里。

这种把所有代码“一锅炖”的方式,我给它起了个名字叫 “散装代码” 。它不仅让我调试时头大,更让我觉得前端这行就是个体力活。

直到最近,我强迫自己开始实践 “模块化拆分”“前后端分离” 的思想,把项目拆分成清晰的文件目录,让 HTML、CSS、JS 各司其职,甚至用 json-server 模拟了一个真正的数据接口。

当我把那个“散装”的用户列表页面,改造成一个通过接口动态获取数据、结构清晰、文件职责分明的小应用时,那种“代码终于被我理顺了”的成就感,是之前从未有过的。

今天这篇文章,就是想和你分享我从 “散装代码”“模块化全栈” 的完整实践过程。代码绝对不复杂,思路新手也能听懂。让我们一起告别混乱,写出更“体面”的代码。


一、痛点先行:你的代码还在“一锅炖”吗?

先来看看我刚入门时的“杰作”——一个典型的单体文件

html

<!-- 一个文件搞定一切?那是噩梦的开始 -->
<!DOCTYPE html>
<html>
<head>
    <style>
        /* 几百行CSS塞在这里... */
        body { margin: 0; /* ... */ }
        .header { /* ... */ }
        /* 找样式找到眼花 */
    </style>
</head>
<body>
    <div class="header">用户列表</div>
    <div class="content">
        <table id="userTable">
            <!-- 假数据直接写死 -->
            <tr>
                <td>1</td>
                <td>张三</td>
                <td>上海</td>
            </tr>
            <tr>
                <td>2</td>
                <td>李四</td>
                <td>南昌</td>
            </tr>
        </table>
    </div>
    <script>
        // 几百行JS逻辑也塞在这里...
        function deleteUser() { /* 复杂逻辑 */ }
        function addUser() { /* 更多逻辑 */ }
        // 改一个功能,大海捞针
    </script>
</body>
</html>

这暴露了三个大问题:

  • 不好维护:改个样式要在几百行 CSS 里翻找,修个 JS bug 要滚动半天
  • 不好扩展:想加个“新增用户”功能,HTML、JS、甚至假数据都要改,牵一发动全身
  • 不好协作:一个人写还好,团队合作时天天合并冲突,痛不欲生

核心思想:解决方案就是模块化拆分——每个文件夹有清晰职责,每个文件只做一件事。


二、项目重构:建立“各司其职”的目录结构

让我们从“大泥球”变成“独立小房间”。这是我的最终项目结构:

text

my-project/
├── fe/                    # 📁 前端目录 - 只管展示和交互
│   ├── index.html         # 页面骨架(结构)
│   └── common.js          # 页面行为(逻辑)
│
└── backend/               # 📁 后端目录 - 只管提供数据
    ├── db.json            # 模拟数据库(数据)
    └── package.json       # 后端配置文件

各模块职责一览

目录/文件 职责 包含什么
fe/index.html 页面结构 HTML 标签、Bootstrap 类名
fe/common.js 交互逻辑 获取数据、渲染表格、事件处理
backend/db.json 模拟数据 JSON 格式的用户信息
backend/package.json 后端配置 项目依赖、启动脚本

前后端分离的核心:前端专注用户看到的东西,后端专注提供数据。改前端样式完全不影响后端逻辑,换后端技术栈也轻轻松松。


三、打磨前端(一):别让 div “满天飞”

有了目录结构,我们先写前端页面。语义化标签是第一课

❌ 糟糕的“div泛滥”写法

html

<div class="header">头部</div>
<div class="main">
    <div class="sidebar">侧栏</div>
    <div class="content">内容</div>
</div>
<div class="footer">底部</div>

✅ 优雅的“语义化”写法

html

<header>头部</header>
<main>
    <aside>侧栏</aside>
    <article>内容</article>
</main>
<footer>底部</footer>

语义化的好处

  • 搜索引擎能读懂页面结构(SEO 友好)
  • 屏幕阅读器等无障碍设备能正确解析
  • 代码可读性大大提升,一看标签就知道这块是做什么的

盒模型思维:先搭骨架,再填内容

我采用了 Bootstrap 框架的栅格系统来布局,核心就是 行列(row/col)  概念:

html

<!-- container:固定宽度容器,左右自动留白 -->
<main class="container">
    <!-- row:一行 -->
    <div class="row">
        <!-- col-md-6:中等屏幕占6列(半行宽)-->
        <!-- col-md-offset-3:向右偏移3列,实现水平居中 -->
        <div class="col-md-6 col-md-offset-3">
            <table class="table table-striped">
                <!-- 表格内容 -->
            </table>
        </div>
    </div>
</main>

布局口诀container 做外框,row 分 12 列,col 占宽度,offset 做偏移。PC、iPad、手机自动适配,不用手写媒体查询!


四、打磨前端(二):让表格“活”过来

静态页面不够,还得动态渲染数据。这就得靠 JavaScript 操作 DOM 了。

DOM 是什么?

当浏览器解析 HTML,会在内存中构建一棵树(Document Object Model)。每个 HTML 标签都变成一个 JS 对象,我们可以通过 JS 找到这些节点,增删改查。

javascript

// 就像拿着遥控器操控页面
const tbody = document.querySelector('#user-table-body'); // 找到表格身体
tbody.innerHTML = '<tr><td>1</td><td>张三</td><td>上海</td></tr>'; // 改变内容

数据驱动渲染(重要思维)

不要手动修改每一个单元格,而是维护数据 → 根据数据重新渲染整个表格

这是我写的 common.js 核心逻辑:

javascript

// 1. 获取数据
const users = await fetch('http://localhost:3000/users').then(res => res.json());

// 2. 根据数据生成HTML
let html = '';
users.forEach(user => {
    html += `<tr><td>${user.id}</td><td>${user.name}</td><td>${user.hometown}</td></tr>`;
});

// 3. 一次性更新页面
document.querySelector('#user-table-body').innerHTML = html;

性能小贴士:循环拼接字符串,最后一次性 innerHTML,比循环中一次次操作 DOM 快得多!


五、火速搭后端:json-server,前端er的救星

前端需要接口才能调试,但后端开发还没写好怎么办?json-server 让你 30 秒拥有完整 REST API!

步骤1:初始化后端项目

bash

cd backend
npm init -y   # 生成 package.json

步骤2:安装 json-server

bash

npm install json-server

步骤3:配置启动脚本

在 package.json 中添加:

json

"scripts": {
  "dev": "json-server --watch db.json --port 3000"
}

步骤4:创建模拟数据库 db.json

json

{
  "users": [
    { "id": 1, "name": "张三", "nickname": "三哥", "hometown": "上海" },
    { "id": 2, "name": "李四", "nickname": "四哥", "hometown": "南昌" },
    { "id": 3, "name": "王五", "nickname": "五哥", "hometown": "北京" }
  ]
}

步骤5:启动服务

bash

npm run dev

访问 http://localhost:3000/users,你就能看到 JSON 数据了!增删改查接口全部自动生成,爽翻!


六、完整代码:拿来即用的“全栈”模板

前端完整代码(fe/index.html

html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>用户管理系统 - 模块化全栈实战</title>
    <!-- Bootstrap样式库:快速美化 -->
    <link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <!-- 语义化头部 -->
    <header class="text-center" style="padding: 20px; background: #f8f9fa;">
        <h1>👥 用户信息管理</h1>
        <p>数据来自 json-server 模拟后端接口</p>
    </header>

    <!-- 主体:container + 行列布局 -->
    <main class="container" style="margin-top: 30px; min-height: 60vh;">
        <div class="row">
            <div class="col-md-8 col-md-offset-2">
                <table class="table table-bordered table-striped" id="user-table">
                    <thead>
                        <tr>
                            <th>ID</th>
                            <th>姓名</th>
                            <th>昵称</th>
                            <th>家乡</th>
                        </tr>
                    </thead>
                    <tbody id="user-table-body">
                        <!-- JS动态插入数据 -->
                        <tr><td colspan="4" class="text-center">⏳ 加载中...</td></tr>
                    </tbody>
                </table>
            </div>
        </div>
    </main>

    <!-- 语义化底部 -->
    <footer class="text-center" style="padding: 15px; background: #f8f9fa; margin-top: 20px;">
        <p>📌 项目演示 | 前端: HTML5 + Bootstrap | 后端: json-server</p>
    </footer>

    <!-- 引入JS模块 -->
    <script src="./common.js"></script>
</body>
</html>

前端逻辑(fe/common.js

javascript

/**
 * 用户列表管理模块
 * 职责:获取后端数据 + 渲染到表格
 * 核心思想:数据驱动视图
 */

const API_URL = 'http://localhost:3000/users';

/**
 * 从后端获取用户数据
 */
async function fetchUsers() {
    try {
        const response = await fetch(API_URL);
        if (!response.ok) throw new Error('网络请求失败');
        const users = await response.json();
        return users;
    } catch (error) {
        console.error('获取用户数据失败:', error);
        return [];
    }
}

/**
 * 渲染用户列表到表格
 * @param {Array} users 用户数组
 */
function renderUserTable(users) {
    const tbody = document.querySelector('#user-table-body');
    
    if (!users || users.length === 0) {
        tbody.innerHTML = '<tr><td colspan="4" class="text-center">📭 暂无数据</td></tr>';
        return;
    }
    
    // 核心:遍历数据,生成HTML字符串
    let html = '';
    users.forEach(user => {
        html += `
            <tr>
                <td>${user.id}</td>
                <td><strong>${user.name}</strong></td>
                <td>${user.nickname || '--'}</td>
                <td>📍 ${user.hometown}</td>
            </tr>
        `;
    });
    
    // 一次性更新DOM(性能优化)
    tbody.innerHTML = html;
}

/**
 * 初始化页面
 */
async function init() {
    const tbody = document.querySelector('#user-table-body');
    tbody.innerHTML = '<tr><td colspan="4" class="text-center">⏳ 加载中...</td></tr>';
    
    const users = await fetchUsers();
    renderUserTable(users);
}

// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', init);

后端配置(backend/package.json

json

{
  "name": "user-management-backend",
  "version": "1.0.0",
  "description": "用户管理模拟后端",
  "main": "index.js",
  "scripts": {
    "dev": "json-server --watch db.json --port 3000"
  },
  "keywords": ["mock-api", "json-server", "全栈练习"],
  "author": "小王",
  "license": "ISC",
  "dependencies": {
    "json-server": "^1.0.0-beta.15"
  }
}

模拟数据(backend/db.json

json

{
  "users": [
    {
      "id": 1,
      "name": "张三",
      "nickname": "三哥",
      "hometown": "上海"
    },
    {
      "id": 2,
      "name": "李四",
      "nickname": "四哥",
      "hometown": "南昌"
    },
    {
      "id": 3,
      "name": "王五",
      "nickname": "五哥",
      "hometown": "北京"
    }
  ]
}

七、运行项目:见证属于你的“全栈”时刻

1️⃣ 启动后端

bash

cd backend
npm run dev

看到这个输出就成功了:

text

JSON Server running at http://localhost:3000

2️⃣ 打开前端页面

直接用浏览器打开 fe/index.html,或者用 VS Code 的 Live Server 右键启动。

3️⃣ 见证奇迹

页面自动从 http://localhost:3000/users 获取数据,并渲染成表格。试着修改 db.json 里的数据,刷新页面,表格内容自动更新!

这一刻,你跑通了一个完整的前后端分离项目!


八、总结与感悟:迈出“全栈”第一步后的三点思考

核心知识点速查表

模块 技术点 一句话总结
工程化 模块化拆分 一个文件只做一件事,职责清晰
HTML 语义化标签 header/main/footer,别滥用 div
CSS布局 盒模型 + 行列 container 做容器,row 分 12 列
样式框架 Bootstrap 引入类名快速美化,专注业务
JS核心 DOM编程 找到节点,动态改内容
前端架构 数据驱动视图 维护数据,批量渲染整个表格
后端 json-server JSON 文件秒变 RESTful API

我的三点感悟

  1. 结构是灵魂:语义化标签和模块化目录,比炫技的 CSS 重要 100 倍。代码首先是给人读的,其次才是给机器运行的。
  2. 思想是核心:理解“数据驱动”和“前后端分离”,比学会某个框架更持久。框架会过时,但思想不会。
  3. 动手是真理:跟着文章跑通这个项目,你获得的成就感会推动你走得更远。全栈不是天赋,而是一步步积累的信心。

这个小项目跑通后,我算是真正理解了前后端分离是什么感觉。下一步我准备加上表单新增用户删除功能,再用 localStorage 做个本地缓存。

全栈之路这才刚刚开始,希望这篇文章能帮你迈出第一步。代码全部贴出,复制粘贴就能跑。如果遇到跨域问题,json-server 默认支持 CORS,不用额外配置。

如果你跑通了,欢迎在评论区告诉我!也欢迎分享你用这个模板做的改进~

告别“散装代码”,从今天开始。


🏷️ 掘金标签#前端 #JavaScript #Node.js #全栈开发 #HTML5

如果这篇文章帮到了你,欢迎点赞、收藏、评论三连~ 你的支持是我继续分享的动力!

《闭包:一个函数偷偷带走了我家的糖》—— 零基础也能懂的JS闭包

作者 甜味弥漫
2026年5月23日 20:30

闭包不是魔法,是作用域链的必然结果

很多和我一样的初学者在一开始学习闭包(Closure)的时候觉得是JS的某种特异功能。但是实际上,闭包在ECMAScript 规范中是一个自然产物。

要彻底理解闭包,我们必须拆解 V8 引擎在执行代码的时候的底层逻辑:调用栈(call stack)执行上下文(execution context)以及词法环境 (lexical environment)中outer的引用

一. 执行上下文与 outer

在 JavaScript 中,每当一个函数被调用,引擎就会为它创建一份执行上下文(Execution Context)并压入调用栈。 每个执行上下文中,都包含一个词法环境(Lexical Environment)。这个环境内部有两个重要组成部分:

  1. 环境记录(Environment Record):存放当前函数内部声明的变量和函数。
  2. 外部环境引用(outer):指向它在词法上(写代码的位置)的外层执行上下文。 正是这个 outer 引用,构成了我们常说的作用域链(Scope Chain)。当引擎在当前函数的环境中找不到某个变量时,就会顺着 outer 指向的外部环境一路向上查找,直到全局环境。

底层铁律: outer 的指向,在函数'定义(声明)'的时候就已经决定了,而不是在函数执行(调用)的时候决定。这就是“词法作用域”。

二.从内存视角拆解一个标准闭包

我们用一段最经典的闭包代码,来看看当它被 V8 引擎执行时,内存和调用栈里究竟发生了什么:

function createCounter() {
  let count = 0;
  function change() {
    count++;
    console.log(count);
  }
  return change;
}

const counter = createCounter();
counter(); // 1

70fa272dda7b6886e81feea22faf26c9.png

1. 执行 createCounter() 时

  • createCounter 的执行上下文被压入调用栈。
  • 它的词法环境中,变量 count 被初始化为 0,同时定义了函数 change。
  • 注意:此时 change 函数作为一个对象被创建,由于它在源码里写在 createCounter 内部,V8 引擎在创建它时,会赋予它一个隐藏属性 [[Scopes]],这个属性会保持对当前 createCounter 词法环境的引用

2. createCounter() 执行完毕并返回时

  • 按照常规逻辑,一个函数执行完,它的执行上下文就会从调用栈弹出并销毁,释放内存。
  • 但是! 它的内部函数 change 被返回了,并被全局变量 counter 引用。
  • 因为 counter(即 change)还活着,而 counter 的 [[Scopes]] 属性死死勾住了 createCounter 的词法环境。

3. V8 引擎的破例:Closure 对象的诞生

V8 发现 createCounter 虽然退栈了,但它里面的 count 变量还在被内部函数引用着。于是,垃圾回收机制(GC)不会清理这段内存。 V8 会把 change 函数用到的外部变量(这里是 count)打包,在堆内存(Heap)中创建一个专门的对象,这个对象就叫 Closure(闭包)

三. 调用闭包函数时的 outer 查找规则

现在,我们执行 counter()(即调用 change 函数):

  1. V8 创建 change 的执行上下文,压入调用栈。
  2. 此时,change 的词法环境被创建,它的 outer 引用指向哪里?
  • 指向它出生时的那个外层环境(即保留在堆内存中的 createCounter 的 Closure 空间)
  1. 执行 count++:
  • 引擎先在 change 本地环境中找 count,没找到。
  • 顺着 outer 链条,进入 createCounter 的闭包环境,找到了 count,将其修改为 1。 当 counter() 执行完,change 的上下文弹栈销毁,但那个堆内存中的 Closure 闭包空间依然存在。下一次你再调用 counter(),它依然顺着 outer 找到同一个 count 变量,实现累加。

四.为什么要从底层理解闭包?

如果只停留在比喻层面,你很难解释下面这两个高级前端面试必考的“深水区”问题:

1. 内存泄漏的本质是什么?

如果闭包函数(如上面的 counter)一直存活在全局作用域中(没有被置为 null),那么它通过 outer 间接引用的整条作用域链上的变量都无法被垃圾回收。这相当于在堆内存里钉死了一块空间,用得多了就会导致内存泄漏

2. V8 引擎的闭包优化

现代 V8 引擎非常智能。如果外层函数有一百个变量,但内部函数只用到了一个,V8 只会把用到的那个变量放进 Closure 对象中,其余没用到的变量在父函数弹栈时依然会被无情销毁。这种精细化的内存控制,只有理解了底层原理才能真正体会。

闭包是语言设计的必然

闭包不是动态注入的补丁,它是 “函数作为一等公民(First-class Function)”“词法作用域(Lexical Scope)” 碰撞后的必然产物。 只要 JavaScript 允许函数作为返回值,且作用域由书写位置决定,那么通过 outer 引用将父级环境锁死在堆内存中的“闭包机制”,就是维持程序逻辑正确的唯一解。

从零搭建一个全栈项目:前后端分离 + DOM 动态渲染实战

2026年5月23日 19:57

从零搭建一个全栈项目:前后端分离 + DOM 动态渲染实战

本文记录了通过搭建一个 user-chat 项目,从零实践前后端分离开发的全过程。涵盖 HTML 语义化标签、Bootstrap 布局、DOM 编程、json-server 模拟后端 API 等核心知识点。

前言

作为一名前端学习者,今天终于从"零散知识点"跨越到了完整项目实战。我们搭建了一个 user-chat 项目,实现了前端页面 + 后端 API 的前后端分离架构。

这篇文章将完整复盘整个开发过程,从项目结构设计到前后端联调,帮你理解全栈开发的基本流程。


一、项目架构:前后端分离

1.1 为什么需要前后端分离?

传统开发(前后端混在一起)
├── 一个项目里 HTML + CSS + JS + 后端代码
├── 难以维护、难以扩展
└── 团队协作困难

前后端分离(现代开发)
├── fe/ 前端项目(专注页面和交互)
│   ├── index.html
│   ├── common.js
│   └── css/
│
└── be/ 后端项目(专注数据和接口)
    ├── package.json
    └── db.json

1.2 模块化设计思想

每个文件夹有职责划分,每个文件只做一件事。

目录/文件 职责
index.html 页面结构(骨架)
common.js 页面行为(逻辑)
db.json 模拟数据库
package.json 后端项目配置

二、前端开发:HTML + CSS + JS 三权分立

2.1 HTML:语义化标签构建页面骨架

不要 div 标签满天飞! 大厂面试特别注重 HTML 语义化。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <!-- CSS 在头部引入,尽早渲染 -->
    <link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <!-- 语义化标签替代 div -->
    <header>顶部导航</header>

    <main class="container">
        <aside>侧边栏</aside>
        <div class="row col-md-6 col-md-offset-3">
            <table class="table table-striped" id="user-table">
                <thead>
                    <tr>
                        <td>ID</td>
                        <td>姓名</td>
                        <td>家乡</td>
                        <td>昵称</td>
                    </tr>
                </thead>
                <tbody>
                    <!-- JS 动态填充 -->
                </tbody>
            </table>
        </div>
        <aside>侧边栏</aside>
    </main>

    <footer>底部信息</footer>

    <!-- JS 在 body 结束前引入,不阻塞渲染 -->
    <script src="./common.js"></script>
</body>
</html>

2.2 语义化标签 vs div

语义化标签 含义 替代的 div
<header> 页头区域 <div class="header">
<footer> 页脚区域 <div class="footer">
<main> 主要内容 <div class="main">
<aside> 侧边栏 <div class="sidebar">
<nav> 导航区域 <div class="nav">

💡 为什么语义化重要?

  • 搜索引擎能更好地理解页面结构(SEO)
  • 屏幕阅读器能更好地辅助无障碍访问
  • 代码可读性更强,团队协作更高效

2.3 HTML 标签的两大类

HTML 标签分类

┌─────────────────────────────────────┐
│  块级元素(Block)                    │
│  - 默认占据一整行                     │
│  - 用来做"盒子"(布局容器)            │
│  - div, p, h1-h6, table, header...   │
├─────────────────────────────────────┤
│  行内元素(Inline)                   │
│  - 不会独占一行                       │
│  - 兄弟元素可以"相安无事"排在一行       │
│  - span, a, strong, em...            │
└─────────────────────────────────────┘

先写盒子,再写内容——这是前端布局的核心思路。

2.4 table 的语义化结构

表格也有语义化标签,<thead> + <tbody> 是关键:

<table>
    <thead>
        <tr>
            <td>ID</td>
            <td>姓名</td>
        </tr>
    </thead>
    <tbody>
        <!-- 数据行由 JS 动态填充 -->
    </tbody>
</table>

⚠️ <thead> 定义表头,<tbody> 定义数据区域。分离它们不仅语义清晰,还能方便地用 CSS/JS 单独控制样式和行为。


三、CSS 布局:Bootstrap 栅格系统

3.1 引入 Bootstrap

通过 CDN 引入 Bootstrap CSS 框架:

<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet">

3.2 核心布局概念

Bootstrap 的栅格系统基于三个核心类:

类名 作用 说明
.container 容器 固定宽度,左右留白,居中显示
.row 一行,包含若干列
.col-md-* 将一行分为 12 等份
<!-- 居中容器 -->
<main class="container">
    <!-- 占 6 列,偏移 3 列(实现居中) -->
    <div class="row col-md-6 col-md-offset-3">
        <!-- 表格内容 -->
    </div>
</main>
Bootstrap 栅格布局示意(12 列系统)

┌──────────────────────────────────────────────┐
│                   container                   │
│  ┌────────────────────────────────────────┐  │
│  │              row (12列)                 │  │
│  │  ┌──────┐  ┌──────────────┐  ┌──────┐  │  │
│  │  │ 3列  │  │    6列       │  │ 3列  │  │  │
│  │  │offset│  │   内容区     │  │offset│  │  │
│  │  └──────┘  └──────────────┘  └──────┘  │  │
│  └────────────────────────────────────────┘  │
└──────────────────────────────────────────────┘

🎯 col-md-offset-3 表示向右偏移 3 列,配合 col-md-6 实现内容居中效果。


四、DOM 编程:用 JS 动态渲染页面

4.1 什么是 DOM?

DOM(Document Object Model)是 HTML 文档在 JavaScript 中的对象表示:

HTML 文档                    JS DOM 对象
┌──────────────┐            ┌──────────────┐
│  <html>      │  ────────▶ │  document    │
│  <head>      │            │  .documentElement  (根节点)
│  <body>      │            │  .body       (body节点)
│    <table>   │            │  .querySelector()  (查找节点)
│      <tbody> │            │  .innerHTML  (修改内容)
└──────────────┘            └──────────────┘

DOM 是一棵树,JS 通过遍历这棵树来查找、修改页面内容。

4.2 核心代码解析

// 1. 发起请求,获取后端数据
fetch('http://localhost:3000/users')
    .then(data => data.json())   // 将响应转为 JSON 对象
    .then(data => {
        console.log(data);
        users = data;

        // 2. 找到 DOM 挂载点(tbody)
        const oBody = document.querySelector('.table tbody');

        // 3. 遍历数据,动态生成 HTML
        for (const user of users) {
            oBody.innerHTML += `
              <tr>
                <td>${user.id}</td>
                <td>${user.name}</td>
                <td>${user.hometown}</td>
                <td>${user.nickname}</td>
              </tr>
            `;
        }
    })

4.3 逐行解读

步骤 代码 说明
请求数据 fetch(url) 向后端 API 发起 HTTP 请求
解析响应 .then(data => data.json()) 将响应体解析为 JSON 对象
查找节点 document.querySelector('.table tbody') 用 CSS 选择器找到 tbody 元素
动态渲染 oBody.innerHTML += ... 将数据拼装成 HTML 插入页面

💡 命名约定:代码中 oBodyo 前缀表示 Object 类型,这是一种常见的命名习惯。

4.4 for...of vs 传统 for 循环

// ✅ ES6 for...of:可读性好,不需要计数
for (const user of users) {
    console.log(user);
}

// ❌ 传统 for 循环:可读性差,太机械
for (let i = 0; i < users.length; i++) {
    let user = users[i];
    console.log(user);
}

五、后端准备:json-server 快速搭建 API

5.1 初始化后端项目

# 1. 初始化项目,生成 package.json
npm init -y

# 2. 安装 json-server
npm i json-server

5.2 package.json 配置

{
  "name": "backend",
  "version": "1.0.0",
  "scripts": {
    "dev": "json-server --watch db.json"
  },
  "dependencies": {
    "json-server": "^1.0.0-beta.15"
  }
}

💡 --watch 参数表示监听文件变化,修改 db.json 后自动重启服务。

5.3 编写模拟数据 db.json

{
  "users": [
    {
      "id": 1,
      "name": "张三",
      "hometown": "北京",
      "nickname": "阿三"
    },
    {
      "id": 2,
      "name": "李四",
      "hometown": "上海",
      "nickname": "阿四"
    },
    {
      "id": 3,
      "name": "王五",
      "hometown": "广州",
      "nickname": "阿五"
    }
  ]
}

5.4 启动服务

npm run dev

json-server 会自动将 db.json 中的数据暴露为 RESTful API:

HTTP 方法 端点 说明
GET http://localhost:3000/users 获取所有用户
GET http://localhost:3000/users/1 获取单个用户
POST http://localhost:3000/users 新增用户
PUT http://localhost:3000/users/1 更新用户
DELETE http://localhost:3000/users/1 删除用户

🚀 零代码搭建后端 API:json-server 是前端开发者的神器,无需写任何后端代码就能拥有完整的 CRUD 接口!


六、前后端联调:完整数据流

6.1 数据流转全流程

前后端联调数据流

┌──────────────┐     fetch()      ┌──────────────┐
│   前端页面    │ ──────────────▶ │  json-server │
│  index.html  │                  │  :3000       │
│  common.js   │ ◀────────────── │  db.json     │
│              │    JSON 响应     │              │
└──────────────┘                  └──────────────┘
       │
       │ document.querySelector()
       │ .innerHTML
       ▼
┌──────────────┐
│  DOM 动态渲染 │
│  表格数据展示  │
└──────────────┘

6.2 项目文件结构

user-chat/
├── fe/                    # 前端目录
│   ├── index.html         # 页面结构
│   └── common.js          # 页面逻辑
│
└── be/                    # 后端目录
    ├── package.json       # 项目配置
    └── db.json            # 模拟数据库

七、今日知识图谱

📚 全栈开发入门知识图谱

前端三件套
├── HTML(结构)
│   ├── 语义化标签(header/footer/main/aside)
│   ├── 块级元素 vs 行内元素
│   ├── table 语义化(thead + tbody)
│   └── DOCTYPE 文档类型声明
│
├── CSS(样式)
│   ├── Bootstrap 栅格系统
│   ├── container / row / col 布局
│   └── CSS 头部引入(尽早渲染)
│
└── JavaScript(行为)
    ├── DOM 编程
    │   ├── document.querySelector()
    │   ├── .innerHTML 动态修改
    │   └── DOM 树状结构
    ├── fetch API 网络请求
    ├── ES6 for...of 循环
    └── 模板字符串 `${}`

后端基础
├── npm init -y 初始化项目
├── package.json 项目描述
├── json-server 模拟 API
└── RESTful 接口规范

工程化思想
├── 模块化开发(职责分离)
├── 前后端分离架构
└── 文件引入顺序优化

八、给初学者的建议

  1. 语义化标签从第一天就用

    • 不要等"以后再改",一开始就养成好习惯
    • 大厂面试必考,SEO 的基础
  2. 理解 DOM 树是前端的核心

    • document.querySelector() 是你最常用的工具
    • 所有动态页面效果都基于 DOM 操作
  3. 善用 json-server 快速原型开发

    • 前端开发不依赖后端进度
    • 零成本拥有完整的 RESTful API
  4. 模块化思维要贯穿始终

    • HTML 管结构,CSS 管样式,JS 管行为
    • 前端管页面,后端管数据

结语

今天的 user-chat 项目虽然简单,但它涵盖了全栈开发的核心流程:前端页面搭建 → DOM 动态渲染 → 后端 API 提供 → 前后端联调

从语义化标签到 DOM 编程,从 Bootstrap 布局到 json-server,每一个知识点都是前端工程师的必备技能。掌握了这些基础,后续学习 Vue、React 等框架就会事半功倍。

希望这篇文章对你有帮助!如果有任何问题,欢迎在评论区交流。


📌 参考资源


📌 文章标签 前端 HTML CSS JavaScript DOM Bootstrap json-server 全栈开发 学习笔记


如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连支持一下~你的鼓励是我持续输出的动力! 💪

Claude Code Doc

作者 gogoing
2026年5月19日 14:11

安装

第一步:安装 Claude Code

在终端中执行以下命令,全局安装 Claude Code:

npm install -g @anthropic-ai/claude-code

安装完成后,执行以下命令验证:

claude --version

第二步:配置环境变量

Claude Code 通过 ANTHROPIC_BASE_URLANTHROPIC_AUTH_TOKEN 确定服务地址与鉴权密钥。

Windows(以管理员身份打开 PowerShell):

[System.Environment]::SetEnvironmentVariable("ANTHROPIC_BASE_URL", "https://xxx.com", [System.EnvironmentVariableTarget]::User)
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_AUTH_TOKEN", "你的 API Key", [System.EnvironmentVariableTarget]::User)

配置完成后,重启终端窗口使变量生效。

ANTHROPIC_AUTH_TOKEN 必须使用 Claude Code 专属分组的 API Key,不可使用其他客户端分组的密钥。

第三步:启动 Claude Code

claude       # 交互式会话
claude -p "问题"   # 非交互式,执行后退出

settings.local.json 常规设置

{
  "permissions": {
    "allow": [
      "Bash(claude --version)",
      "Bash(claude --print)",
      "WebSearch",
      "Bash(claude settings *)",
      "Read(//c/Users/Administrator/.claude/**)",
      "WebFetch(domain:dev.to)",
      "Bash(npx tsc *)"
    ]
  }
}

安装位置(快捷键进入):%USERPROFILE%\.claude

Chrome DevTools MCP

claude mcp add chrome-devtools -s user -- npx chrome-devtools-mcp@latest

安装后可通过 MCP 工具操控浏览器、截图、性能追踪等。


命令

会话管理

命令 说明
/clear 清空对话历史
/compact 压缩对话历史但保留摘要
/resume 恢复之前的对话
/fork 将对话分支为新会话
/rename 重命名当前会话
/btw 快速插问,不打断正在执行的任务
/rewind 回退对话节点,代码文件一起恢复
/context 查看上下文窗口占用情况
/export 导出对话为纯文本文件
/diff 在交互式查看器中查看所有变更
/branch 分支管理

模型与输出

命令 说明
/model 在 Opus、Sonnet 和 Haiku 间切换
/fast 切换快速模式
/effort 交互式推理强度滑块(low / medium / high / xhigh / max)
/plan 切换计划模式(只读规划)
/theme 更改语法高亮主题
/output-style 更改输出样式
/voice 语音输入(针对编程词汇优化)
/focus 精简视图,仅显示最后提示 + 响应摘要

配置与设置

命令 说明
/config 打开设置面板(主题、模型偏好、语言等)
/permissions 管理工具权限
/keybindings 打开快捷键配置
/status-line 设置终端状态栏
/terminal-setup 设置 Shift+Enter 快捷键
/hooks 配置生命周期钩子
/init 新项目开局运行,生成 CLAUDE.md

子智能体与技能(v2.1.3 起斜杠命令与 Skills 统一)

命令 说明
/agents 创建和管理子智能体
/skills 技能管理菜单
/find-skills 浏览并安装技能
/powerup 动画教学,学习 context/hooks/MCP/subagents/loop

代码审查与安全

命令 说明
/review 审查 Pull Request
/pr-comments 显示当前分支的 PR 评论
/autofix-pr CI 失败或 Reviewer 评论时自动推送修复
/install-github-app 设置 GitHub PR 自动审阅
/security-review 对未提交变更进行安全审计

MCP 与插件

命令 说明
/mcp 管理 MCP 服务器连接
/plugin 进入插件市场,安装插件

后台与自动化

命令 说明
/tasks 查看后台任务
/loop 定时运行命令(可省略间隔,自调度)
/batch 跨 worktree 大规模并行重构

诊断与统计

命令 说明
/insights 生成使用分析报告
/usage 检查令牌使用量与套餐限额
/doctor 运行环境诊断
/debug 显示故障排查信息

其他

命令 说明
/team-onboarding 生成新成员上手指南(基于你的使用习惯)
/simplify 3 智能体审阅(架构、重复代码、性能)
/memory 编辑记忆文件
/sandbox 启用沙箱模式
/ide 管理 IDE 集成

账户

命令 说明
/login 重新身份验证
/logout 登出
/upgrade 升级套餐
/help 列出所有可用命令

全局命令 vs 项目命令

Claude Code v2.1.3 起,自定义斜杠命令与 Agent Skills 合并。可以将命令文件(.md)放置在不同位置来控制作用范围:

位置 作用范围 是否纳入版本控制
.claude/commands/*.md 当前项目
~/.claude/commands/*.md 全局(所有项目)

全局命令示例 — 创建 ~/.claude/commands/review.md

分析文件 $ARGUMENTS,指出:

1. 潜在 bug
2. 性能问题
3. 改进建议

然后在任意项目中使用:/review src/auth.ts

优先级:项目级命令覆盖全局同名命令。

你也可以在 ~/.claude/CLAUDE.md 中写入个人指令,它会在每个会话中自动加载。

CLI 命令

命令 说明
claude 启动交互式会话
claude "问题" 启动时附带初始提示
claude -p "问题" 非交互式模式,执行后退出
claude -c 恢复最近会话
claude -r "ID" 按 ID 恢复会话
claude --from-pr 恢复与 PR 关联的会话
claude update 更新到最新版本
claude auth login 身份验证
claude auth status 检查认证状态
claude auth logout 登出
claude agents 列出已配置的智能体
claude rc 启动远程控制会话
claude plugin 插件管理(CLI 方式)
claude config list 显示所有设置
claude config set 更新设置项
claude mcp add 添加 MCP 服务器
claude mcp list 列出 MCP 服务器
claude mcp remove 移除 MCP 服务器
claude mcp serve 将 Claude Code 作为 MCP 服务器运行
claude ultrareview [PR/分支] 云端多智能体代码审查

CLI 启动参数

参数 说明
--model opus 启动时指定模型
--effort high 设置推理深度(low/medium/high/xhigh/max)
--agents '{json}' 启动时定义子智能体
--append-system 追加系统提示词
--max-turns N 设置会话轮次上限
--dangerously-skip 跳过权限提示(危险)
--worktree 隔离 Git 工作树
--bare 最小模式:跳过 hooks/LSP/自动记忆等
--output-format json 输出格式(text/json/stream-json)
--settings <file> 加载额外设置文件
--mcp-config <file> 加载 MCP 配置文件

写组件文档写到吐?我用AI自动生成Storybook,同事以后直接抄

作者 kyriewen
2026年5月23日 21:42

我们公司的组件库有一百多个组件,但文档几乎为零。新同事来了,不知道每个组件怎么用、支持哪些props,只能翻源码。我每周至少被问三次:“这个按钮的size参数是‘large’还是‘lg’?” 我烦了,写了个脚本:用AI读取组件的TypeScript类型定义,自动生成Storybook文档和示例代码。现在每次提交组件,CI自动跑一遍,文档实时更新。同事再也不问我了,CTO看到说:“这个自动化做得好,省了半个前端的人力。”

前言

组件文档的重要性,每个前端都懂。但现实是:业务需求压过来,谁有时间写文档?结果就是组件库越来越膨胀,文档越来越荒废。新人来了,要么猜,要么问,要么翻源码。

我试过手动写Storybook,一个组件写半小时,一百个组件写完,我可能已经离职了。后来我想:能不能让AI自动生成?组件的props类型、默认值、描述,不都写在代码里了吗?让AI提取出来,再套个模板,不就完事了?

今天我把这套自动化流程拆给你看:怎么用AI解析TS类型,怎么生成Storybook的stories文件,怎么集成到CI。以后你只需要写好组件,文档的事交给AI。

金句:最好的文档不是“人写的”,而是“代码里长出来的”。

一、痛点:手动写文档的三大坑

痛点 具体表现
耗时长 一个组件平均花30分钟写文档(props表格+示例代码+说明)
不同步 改了组件props,忘了改文档,文档变成“废纸”
没人愿意写 团队集体拖延,文档库永远是“建设中”

我们团队曾试图用react-docgen-typescript自动提取props,但它只能生成原始JSON,还得人工转成Markdown或Storybook。而且它不懂业务描述,比如“size的large表示大号按钮,用于主操作”,这种描述还是得人写。

AI的出现正好填补了这个缺口:它既能解析类型,又能生成自然语言描述,还能帮你写示例代码。

二、我是怎么做的?三步全自动

第一步:提取组件类型信息

我用react-docgen-typescript把组件的props、类型、默认值、是否必填提取成JSON。写一个脚本extract-props.ts

import * as docgen from 'react-docgen-typescript';

const options = {
  savePropValueAsString: true,
  shouldExtractLiteralValuesFromEnum: true,
};

const parser = docgen.withCustomConfig('./tsconfig.json', options);
const docs = parser.parse('./src/components/Button/index.tsx');

// docs[0] 结构:
// {
//   displayName: 'Button',
//   props: {
//     size: { type: { name: "'small' | 'medium' | 'large'" }, required: false, defaultValue: 'medium' },
//     children: { type: { name: 'ReactNode' }, required: true }
//   }
// }

第二步:用AI生成Storybook内容

把提取的JSON喂给AI,提示词:

你是一名前端技术文档专家。请根据以下组件的props信息,生成一份Storybook的stories文件代码。

要求:

  1. 为每个prop生成控制台(controls)配置
  2. 生成至少3个示例:基础用法、不同尺寸、自定义样式
  3. 用Markdown格式输出,包含组件描述和Props表格

组件信息:

[粘贴上一步的JSON]

AI会输出类似这样的Storybook代码:

// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  parameters: {
    docs: {
      description: {
        component: '通用按钮组件,支持三种尺寸和两种主题色。主要用于表单提交、弹窗确认等操作。'
      }
    }
  },
  argTypes: {
    size: {
      control: 'radio',
      options: ['small', 'medium', 'large'],
      description: '按钮尺寸'
    },
    children: { control: 'text', description: '按钮内容' }
  }
};

export default meta;

export const Default: StoryObj<typeof Button> = {
  args: { children: '按钮', size: 'medium' },
};

export const Large: StoryObj<typeof Button> = {
  args: { children: '大按钮', size: 'large' },
};

export const Small: StoryObj<typeof Button> = {
  args: { children: '小按钮', size: 'small' },
};

第三步:集成到CI

我在项目的.github/workflows/docs.yml里加了一个job:

- name: Auto generate Storybook
  run: |
    npm run extract:props
    npm run ai:generate-stories
    npm run build:storybook
  env:
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

每次git push到main分支,GitHub Actions自动跑一遍,重新生成文档并部署到GitHub Pages。从此文档永远最新。

金句:AI自动生成文档,让“文档过时”成为历史。

三、实测效果:节省了多少时间?

我们组件库有87个组件,人工写一个平均30分钟,总计43.5小时。用AI自动生成后,每个组件约2分钟(人工review和微调),总计约3小时。节省了93%的时间

指标 手工 AI辅助 变化
单组件文档耗时 30分钟 2分钟 ↓ 93%
文档与代码同步率 经常滞后 实时同步 -
新人上手询问次数(月均) 12次 2次 ↓ 83%

同事现在想查组件用法,直接打开Storybook。没人再问我了,我可以安心写代码。

四、注意事项(坑点)

  • AI会脑补不存在的props:有时会生成示例里用了你没提供的prop,必须人工删掉。
  • 复杂泛型可能解析错react-docgen-typescript对高阶组件、泛型组件的解析不够准,需要手动修正输入JSON。
  • 保护公司组件库隐私:不要直接把整个组件库代码喂给云端AI。可以用本地模型(如Ollama + CodeLlama),或者只传类型JSON(不传实现细节)。
  • 示例代码要人工跑一遍:AI生成的示例可能无法直接运行(比如忘记import样式),你至少编译一次确保没语法错误。

五、完整的Prompt模板(复制可用)

# 角色
你是一名前端技术文档专家,擅长为React组件生成Storybook文档。

# 任务
根据以下组件的props信息,生成一个完整的Storybook stories文件。

# 输入格式
JSON对象,包含displayName和props

# 输出要求
1. 使用TypeScript语法
2. 包含meta配置(title, component, parameters, argTypes)
3. 生成至少3个Story:Default, Large, Small(或其他合适的变体)
4. 在parameters.docs.description.component中写一段组件用途说明
5. 每个argType的control要合理(radio/select/text等)

# 组件信息
[粘贴JSON]

六、写在最后

以前我觉得文档是“良心活”,写了加分,不写也没人扣分。现在AI帮我自动生成,我才发现:文档不是负担,而是杠杆——它让组件的复用率大大提升,团队效率自然就上去了。

如果你也被组件文档折磨过,或者想知道怎么把AI接入你的工程化流程,点个赞让我看到

❌
❌