普通视图

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

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;

最后

萌新小白基础理解篇之 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

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接入你的工程化流程,点个赞让我看到

你敢信这是非Native页面写出来的渐变效果吗🌝(底层原理解析

作者 July_lly
2026年5月23日 20:48

前言

最近主包由于写RN业务的原因,需要实现一个跟随Native半屏容器拉起到全屏时会渐变的效果,这里面涉及到与原生的交互能力,同时由于业务需要提高性能能力,能适配低端机用户。

效果:

550513572529ad2a9d243fdb46d955ed.gif

看着卡了,可以自己去体验一下。

踩坑

肯定很多同学想到了直接监听我们的容器滚动时间,能这么简单的话不需要文章/文档记录了。当然应该也有同学听过一些解决方法,就是ali的BindingX方案。当然这个方案肯定可以的,但是在我们需要适配地端容器,且需要大量的变化时候渐变的元素时候,前端的视角变得尤为重要了。

下面我们不废话,直接将过程and知识点。

什么是 BindingX?

BindingX 是阿里推出的前端动画引擎,主要用于实现跨端的复杂手势交互与动画绑定。其核心原理可从三个维度理解:

数据绑定:将事件与属性变化关联起来

事件驱动:基于手势、时序、滚动等事件触发

数学插值:通过声明式表达式描述动画曲线

它的设计目标是让开发者 "通过声明式绑定,将手势事件、动画和属性变化关联起来" ,避免手写大量命令式(imperative)动画代码。


为什么需要 BindingX?

在 RN 页面中,当 RN_Panel 容器上拉全屏时,常见需求是让页面元素跟随滑动手势产生联动动画。

传统方案的问题:

通过监听浮层滚动进度通知(enable_progress_notification)再回调 JS 逻辑,存在以下瓶颈:

JSB 通信打满执行栈,无法实时拿到当前进度

大量动画导致页面明显卡顿

典型卡顿场景分析:

即使只通知一次(enable_state_notification),大量同时变化的元素也会造成明显卡顿,原因在于:

顶部图片容器高度突然变化 → 触发回流

背景色或渐变瞬时变化 → 触发大量重绘

大量文字颜色同时变化 → 触发 CPU 密集型抗锯齿计算,每帧多次回流

实测发现:约 30 个图标和文字同时变化,是影响动画流畅性的主要瓶颈。

BindingX 通过将动画逻辑下沉到 Native 侧执行,彻底绕开 JS Bridge 通信瓶颈,实现丝滑动画效果。


如何使用 BindingX

基础用法示例

import { useRef } from 'react';
import { View } from 'react-native';
import { useBindingX } from './useBindingX';
function FadeBox() {
  const boxRef = useRef<View>(null);
  useBindingX({
    eventType: 'progress',
    anchor: 'panel_progress',
    props: [
      { element: boxRef, property: 'opacity', expression: 'p' },
    ],
  });
  return <View ref={boxRef} collapsable={false} style={{ opacity: 0 }} />;
}

常用可控属性:opacity / color / height 等。

useBindingX 实现可以直接按照下面的API让你们客户端写好了,或者自己去调JSB封装一下即可


API 参考

UseBindingXOptions 参数

参数 类型 默认值 说明
eventType 'progress' | 'scroll' | 'timing' | 'pan' | 'orientation' 'progress' 事件类型
anchor string | React.RefObject 锚点,含义取决于 eventType
props BindingXProp[] 必填 绑定属性列表
enabled boolean true 是否启用绑定
exitExpression string 退出条件表达式(仅 timing 有效)

支持的事件类型

eventType 表达式变量 anchor 典型场景
progress p (0~1) token 字符串 面板拖拽、进度条、外部驱动
scroll x, y (px) ScrollView ref 滚动视差、Header 折叠
timing t (ms) 不需要 入场动画、脉冲动画
pan x, y (px) 手势视图 ref 拖拽交互
orientation alpha, beta, gamma 不需要 陀螺仪控制

可绑定属性速查表

Transform(变换)

属性 说明 示例表达式
opacity 透明度 p / max(1-y/150,0)
transform.translateX X 平移 -30+60*p
transform.translateY Y 平移 -y*0.4
transform.scale 等比缩放 0.5+0.5*p
transform.scaleX X 缩放 0.5+p
transform.scaleY Y 缩放 0.5+p
transform.rotate Z 轴旋转(度) 360*p
transform.rotateX X 轴旋转 180*p
transform.rotateY Y 轴旋转 180*p

Layout(布局)

属性 说明 示例表达式
width 宽度 30+70*p
height 高度 50+20*min(t/800,1)
left/right/top/bottom 定位偏移 24*p

Spacing(间距)

属性 说明 示例表达式
marginLeft 左外边距 min(y*0.15,30)
marginRight 右外边距 24*p
marginTop 上外边距 18*p
marginBottom 下外边距 18*p
paddingLeft 左内边距 min(y*0.08,16)
paddingRight 右内边距 24*p
paddingTop 上内边距 24*p
paddingBottom 下内边距 24*p

兼容写法:margin-left、margin_left、margin.left 均可,推荐使用 camelCase。

Visual(视觉)

属性 说明 示例表达式
backgroundColor 背景色 rgb(255-255p,50,255p)
color 文字颜色 rgb(min(y,255),0,0)
borderRadius 圆角 4+16*p

兼容写法:background-color、border-radius,推荐使用 camelCase。

Scroll(滚动)

属性 说明 示例表达式
scroll.contentOffsetX 水平滚动偏移 400*p
scroll.contentOffsetY 垂直滚动偏移 400*p

表达式语法

表达式在 Native 侧执行,不经过 JS Bridge,主要都是常见基本表达式。

BindingX 表达式支持以下语法:

运算符:+ - * / %

比较:> < >= <= == !=

逻辑:&& || !

三元:condition ? a : b

内置函数:min(a,b)、max(a,b)、abs(x)、sin(x)、cos(x)、rgb(r,g,b)


最佳实践

iOS vs Android 渲染机制对比

在使用 BindingX 处理颜色/透明度动画时,iOS 和 Android 的底层机制存在显著差异,必须区别对待。

iOS 渲染机制

iOS 使用 Core Animation(CA)和图层(Layer)系统:

颜色变化由 GPU 在图层上处理(Layer-backed 渲染),对简单背景色动画几乎是零 CPU 消耗

注意:颜色变化若同时伴随布局变化(height/width),仍会触发回流和重绘

iOS 对渐变背景或 mask 图层处理相对昂贵

Android 渲染机制

Android 基于 Skia + GPU 渲染:

颜色变化算作一次重绘(repaint) ,简单情况下 GPU 处理能力强,不会引起明显卡顿

高危场景

大面积渐变或半透明叠加层 ⚠️

同时有大量布局属性变化(height、margin、transform 等)→ 可能让 CPU/GPU 瓶颈显现

背景色踩坑:三层透明叠加问题

页面顶部存在三层透明背景叠加的情况,在 Android 上直接使用 BindingX 控制背景色会导致页面卡死(Android bindingX 包优化修复前)。

Android 侧修复方案:

将 CPU 开销转移到 GPU 处理,通过 RN View 属性 renderToHardwareTextureAndroid 将动画图层缓存为 GPU texture,减少每帧 CPU 混合:

<View renderToHardwareTextureAndroid={true}>
  {/* 动画内容 */}
</View>

字体变色踩坑与解决方案

问题根源

直接对文字颜色使用 BindingX 渐变,即使去除底色/图片的渐变叠加,依旧十分卡顿:

文字颜色变化会直接触发回流

CPU 计算抗锯齿消耗巨大

解决方案:双层 Text + View opacity

核心思路:不再改变字体颜色,而是将颜色提前确定好,在变化过程中通过 opacity 控制透明度,实现黑→白渐变。过程中不再触发高密集型的 CPU 计算,而是将图层交由 GPU 合成。

对比维度 方案 A:直接修改 Text color 方案 B:双层 Text + View opacity
动画驱动 每帧更新颜色 每帧更新 View opacity
渲染开销 触发回流 + Layout & Paint 跳过重绘,布局与颜色固定
计算方式 CPU 文本栅格化(高开销) GPU 透明混合,仅图层合成(低开销)
最终效果 卡顿 / 低帧率 ❌ 流畅 / 高帧率 ✅
CPU 占用
GPU 占用 中等

使用总结与注意事项

优先使用 opacity 代替颜色渐变:将颜色变化转为透明度变化,GPU 友好,避免 CPU 瓶颈

Android 额外关注:Android 对半透明叠加、大面积渐变的处理成本显著高于 iOS

使用 renderToHardwareTextureAndroid:对动画图层启用 GPU texture 缓存,减少每帧 CPU 混合

避免同时变化 Layout 属性:height、margin 等布局属性变化会触发回流,尽量避免在动画中同时使用

表达式在 Native 执行:BindingX 表达式不经过 JS Bridge,充分利用这一特性实现高性能动画

树上挂苹果还是挂玻璃球?Three.js 程序化果实的完整实现指南

2026年5月23日 16:06

树上挂苹果还是挂玻璃球?Three.js 程序化果实的完整实现指南

基于 EZ-Tree 扩展:从扫描贴图苹果到物理玻璃球,一套 InstancedMesh 架构搞定两种风格。

项目源码github.com/qdcxj/ez-tr…


前言:为什么这件事比「加个 Sphere」难得多?

我在一个 Three.js 程序化树 项目里做挂果功能,一开始想法很简单:

分支上 new SphereGeometry,贴一张苹果图,完事。

结果踩了三个大坑:

  1. 苹果贴图不是普通 UV 图,而是扫描仪输出的 Atlas 图集——两个圆盘(果柄端 + 花萼端)+ 一条果柄,背景全黑。
  2. 直接贴到球体上,苹果变成「红黑斑马纹」,UV 越界采到了黑色背景。
  3. 后来加了 玻璃球,发现不能和苹果各写一套逻辑——否则 UI、分支采样、实例化渲染全得复制一遍。

最终方案是:一套挂果管线 + 两种材质策略,UI 里一键切换 Apple / GlassBall

本文把完整过程拆开讲,你可以直接抄到自己的 Three.js 项目里。


最终效果一览

preview-apple.png

preview-glass-ball.png

左:Apple 扫描 Atlas 贴图苹果 · 右:GlassBall 程序化物理玻璃球

类型 几何体 材质 贴图 果柄
Apple SphereGeometry + 自定义 UV MeshPhongMaterial 扫描 Atlas
GlassBall SphereGeometry 默认 UV MeshPhysicalMaterial 无(纯程序化)

两种类型共用:分支采样、实例数据、InstancedMesh 渲染、UI 参数面板


整体架构

flowchart TB
    subgraph UI["UI 面板 (ui.js)"]
        Type["Type: apple | glassBall"]
        Common["Count / Size / BranchLevel ..."]
        AppleOnly["Shininess / CapScale"]
        GlassOnly["Transmission / IOR / Thickness"]
    end

    subgraph Options["配置 (options.js)"]
        Fruits["tree.options.fruits"]
    end

    subgraph Tree["树生成 (tree.js)"]
        Gen["generateFruits()"]
        Inst["fruits.instances[]"]
        Mesh["createFruitsGeometry()"]
    end

    subgraph Material["材质 (fruitMaterial.js)"]
        AppleUV["applyAppleSphereUVs()"]
        AppleMat["createAppleBodyMaterial()"]
        GlassMat["createGlassBallMaterial()"]
    end

    subgraph Tex["纹理 (textures.js)"]
        Load["ensureFruitMaps() — 仅 apple"]
    end

    Type --> Fruits
    Common --> Fruits
    Fruits --> Gen --> Inst --> Mesh
    Load --> Fruits
    Mesh -->|apple| AppleUV --> AppleMat
    Mesh -->|glassBall| GlassMat

设计原则:

  • 生成逻辑统一:不管苹果还是玻璃球,都在同一层分支上、用同一套随机采样。
  • 渲染逻辑分叉:只在 createFruitsGeometry() 里根据 type 选材质和是否写 UV。
  • 纹理按需加载:玻璃球不请求任何贴图,避免无意义的 10MB 下载。

第一步:定义配置项

options.js 里扩展 fruits 字段,把「类型无关」和「类型相关」参数放在一起:

this.fruits = {
  enabled: true,
  type: 'apple',          // 'apple' | 'glassBall'

  // 共用:位置与大小
  branchLevel: 3,
  count: 8,
  start: 0.3,
  size: 0.6,
  sizeVariance: 0.2,
  tint: 0xffffff,
  segments: 10,

  // Apple 专用
  shininess: 40,
  capScale: 1.0,

  // GlassBall 专用 — MeshPhysicalMaterial
  transmission: 1.0,
  roughness: 0.05,
  ior: 1.5,
  thickness: 0.35,
  clearcoat: 1.0,
  clearcoatRoughness: 0.03,
};

💡 掘金干货:把两种类型的参数放在同一个 options 对象里,UI 切换时不需要 merge 两套配置,预设 JSON 也能直接序列化。


第二步:在分支上「长」出果实

2.1 挂果时机

tree.js 的分支递归里,当 branch.level === fruits.branchLevel 时调用 generateFruits()。通常和叶子同一层(细枝),视觉上最自然。

2.2 采样算法(和叶子同源)

generateFruits() 复用了叶片的 分层随机采样

generateFruits(sections) {
  const count = this.options.fruits.count;
  const startMin = this.options.fruits.start;
  const heightStep = (1.0 - startMin) / count;
  const angleSlots = this.shuffledIndices(count);

  for (let i = 0; i < count; i++) {
    // 1. 沿分支长度插值得到 origin
    // 2. 用四元数 slerp 得到朝向
    // 3. 径向角 + 抖动,让果实围成一圈而不是排成线
    this.generateFruit(fruitOrigin, fruitOrientation, sectionA.radius);
  }
}

2.3 单个实例数据

每个果实只存三样东西,后面 InstancedMesh 直接用:

this.fruits.instances.push({
  position,   // 从分支点向下悬垂
  rotation,   // 三轴随机,避免「同一个朝向的假球」
  scale: size // 带 sizeVariance 的半径
});

悬垂距离:branchRadius + size * 1.8,让果实「挂在枝上」而不是穿进树干。


第三步:InstancedMesh 批量渲染

100 棵树 × 每树 8 个果实,如果用独立 Mesh 性能会崩。InstancedMesh 是标准答案:

createFruitsGeometry() {
  const geometry = new THREE.SphereGeometry(1, segments, segments);
  const mesh = new THREE.InstancedMesh(geometry, material, instances.length);

  mesh.frustumCulled = false;  // ⚠️ 重要,见下文踩坑
  mesh.renderOrder = isGlassBall ? 3 : 2;

  instances.forEach((instance, index) => {
    dummy.position.copy(instance.position);
    dummy.rotation.copy(instance.rotation);
    dummy.scale.setScalar(instance.scale);
    dummy.updateMatrix();
    mesh.setMatrixAt(index, dummy.matrix);
  });

  mesh.instanceMatrix.needsUpdate = true;
}

踩坑:InstancedMesh 被视锥剔除了

症状:控制台无报错,但 果实完全不显示

原因:实例分散在树的不同位置,几何体本身的 boundingSphere 在原点,相机视锥认为「不在视野内」。

解决:

mesh.frustumCulled = false;
// 或者用所有实例的 AABB 手动设置 boundingSphere

第四步:Apple —— 扫描 Atlas 贴图苹果

4.1 认识贴图布局

苹果用的是 photogrammetry 扫描色贴图 FruitAppleRedWhole001_COL_VAR1_HIRES.jpg

┌─────────────────────────────────┐
│  ● topCap(果柄端)              │
│         ▬ stem(果柄条)         │
│                    ● bottomCap  │
│                      (花萼端)  │
└─────────────────────────────────┘
         背景 = 纯黑

这不是 SphereGeometry 能直接用的等距圆柱 UV,必须手动写 UV。

4.2 标定 Atlas 圆心(别靠肉眼猜)

用脚本对贴图缩略图做像素聚类,得到归一化坐标:

export const AppleAtlas = {
  topCap:    { center: [0.282, 0.720], radius: 0.178 },
  bottomCap: { center: [0.719, 0.277], radius: 0.178 },
  stem:      { center: [0.335, 0.455], size: [0.10, 0.065] },
};

实测比「大概 0.25 / 0.75」精确得多,差 0.03 就会采到黑边。

4.3 核心:半球极投影(Stereographic Projection)

对每个球面顶点,按半球分别投影到对应圆盘:

// 北半球 → topCap(+Y 为果柄端)
if (y >= 0) {
  const sx = x / (1 + y);
  const sz = -z / (1 + y);
  mapped = mapCapUV(sx, sz, atlas.topCap, scale);
}
// 南半球 → bottomCap(-Y 为花萼端)
else {
  const sx = -x / (1 - y);
  const sz = z / (1 - y);
  mapped = mapCapUV(sx, sz, atlas.bottomCap, scale);
}

mapCapUV 里做两件事:

  1. Clamp 到单位圆盘maxRadius = 0.95),防止赤道附近 UV 飞出圆盘。
  2. 映射到 Atlas:u = cx + sx * radius * capScale

4.4 血泪踩坑:红黑斑马纹是怎么来的?

错误写法 后果
radius * scale * 2 赤道 UV 偏移翻倍,飞出圆盘,采到黑色背景
不 Clamp 圆盘 极投影在赤道趋向无穷,必然越界
RepeatWrapping 边缘 UV 重复采样,出现鬼影
在球顶采样 stem 区域 和果柄圆柱冲突,UV 跳变

正确组合:

// 1. 去掉 * 2
u = clamped.sx * cap.radius * scale + cap.center.x;

// 2. 纹理 Clamp
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;

// 3. 果柄单独用 CylinderGeometry + applyStemUVs()

验证脚本结果:4225 个顶点,0 个落在圆盘外

4.5 果柄:第二个 InstancedMesh

const stemGeometry = new THREE.CylinderGeometry(0.35, 0.5, 1, 6);
applyStemUVs(stemGeometry);  // 映射到 Atlas 的 stem 岛

// 放在球顶 +Y 方向,略微露出表面
stemDummy.position.add(
  up.clone().multiplyScalar(instance.scale * 1.02).applyEuler(instance.rotation)
);

苹果 = 球体 InstancedMesh + 果柄 InstancedMesh,共用一个 colorMap。


第五步:GlassBall —— 纯 Three.js 玻璃球

玻璃球走完全不同的路:不写 UV、不加载贴图、不加果柄

5.1 材质:MeshPhysicalMaterial

export function createGlassBallMaterial({
  tint, transmission, roughness, ior, thickness, clearcoat, clearcoatRoughness,
}) {
  const color = new THREE.Color(tint);

  return new THREE.MeshPhysicalMaterial({
    color,
    metalness: 0,
    roughness,
    transmission,       // 透射,1 = 全透明玻璃
    thickness,          // 玻璃厚度,影响折射深度
    ior,                // 折射率,玻璃约 1.5
    transparent: true,
    clearcoat,          // 清漆高光层
    clearcoatRoughness,
    attenuationColor: color,   // 玻璃染色
    attenuationDistance: 0.6,
    depthWrite: false,         // 透明物体标准写法
  });
}

5.2 和 Apple 的分叉点

const isGlassBall = fruits.type === 'glassBall';

if (isGlassBall) {
  material = createGlassBallMaterial(materialOpts);
} else {
  applyAppleSphereUVs(geometry, materialOpts.capScale);
  material = createAppleBodyMaterial(colorMap, materialOpts);
}

5.3 玻璃球调参指南

参数 效果 推荐范围
Transmission 透明度 0.85 ~ 1.0
Roughness 磨砂感 0 ~ 0.1
IOR 折射强度 1.45 ~ 1.52(玻璃)
Thickness 颜色饱和度 0.2 ~ 0.8
Tint 玻璃颜色 #ffffff 无色,#88ccff 淡蓝
Segments 曲面光滑度 16 ~ 32

玻璃球建议把 Segments 拉到 16+,低面数球体 + 折射会有明显折痕。


第六步:纹理加载策略

export const FruitType = {
  Apple: 'apple',
  GlassBall: 'glassBall',
};

// 只有 apple 有贴图路径
const FruitTexturePaths = {
  apple: {
    color: '/textures/guoshi/apple/FruitAppleRedWhole001_COL_VAR1_HIRES.jpg',
  },
};

// 预加载时按类型判断
if (tree.options.fruits?.type === 'apple') {
  tasks.push(ensureFruitMaps('apple'));
}

玻璃球切换时 assignFruitMaps(null),不会残留苹果贴图。


第七步:UI 面板 —— 一个 Fruits 区搞定

const fruitTypeSelect = createSelect('Type', FruitType, ...);

// Apple 专属控件
const appleFruitControls = [fruitShininessSlider.element, fruitCapScaleSlider.element];

// GlassBall 专属控件
const glassFruitControls = [
  fruitTransmissionSlider.element,
  fruitRoughnessSlider.element,
  fruitIorSlider.element,
  fruitThicknessSlider.element,
  fruitClearcoatSlider.element,
];

function updateFruitTypeControls() {
  const isGlassBall = tree.options.fruits.type === 'glassBall';
  appleFruitControls.forEach(el => el.style.display = isGlassBall ? 'none' : '');
  glassFruitControls.forEach(el => el.style.display = isGlassBall ? '' : 'none');
}

切换类型 → 更新控件可见性 → preloadTreeTextures()tree.generate() 重建。


文件清单(抄作业用)

src/lib/
├── options.js          # fruits 配置项
├── tree.js             # generateFruits / createFruitsGeometry
└── fruitMaterial.js    # UV 映射 + 两种材质工厂

src/app/
├── textures.js         # FruitType、贴图预加载
└── ui.js               # Fruits 面板

完整数据流(一图流)

用户选 Type
    ↓
options.fruits.type = 'apple' | 'glassBall'
    ↓
preloadTreeTextures()  (apple 才加载贴图)
    ↓
tree.generate()
    ↓
generateFruits() → fruits.instances[]createFruitsGeometry()
    ├── apple:    SphereUV + Phong + Stem
    └── glassBall: PhysicalMaterial, 无 Stem
    ↓
InstancedMesh × N 挂到树上

经验总结:7 条可复用的 Three.js 技巧

  1. 扫描贴图 ≠ 普通 UV,Atlas 类资源先分析布局再写映射,别直接 map = texture
  2. 极投影 + 圆盘 Clamp 是「双帽扫描贴图 → 球体」的通用解法。
  3. InstancedMesh 记得关 frustumCulled,或者手动算 boundingSphere。
  4. 透明/玻璃材质设 depthWrite: false,并适当提高 renderOrder
  5. 多种视觉风格共用一套实例管线,只在材质层分叉,代码量减半。
  6. 纹理按需加载,程序化材质不绑贴图路径,启动更快。
  7. UI 控件按类型显隐,比做两个面板维护成本低。

还可以怎么玩?

  • 给 Apple 接上 NRM / GLOSS 扫描图,升级 MeshStandardMaterial
  • 玻璃球加 envMap(HDR 环境贴图),反射更真实
  • 新增 orangepeach 类型:换 Atlas 常量 + 贴图路径即可
  • 果实随风摇摆:在 tree.update(t) 里写 instance matrix 动画

总结

问题 方案
苹果贴图红黑条纹 精确 Atlas 标定 + 极投影 + 圆盘 Clamp + ClampToEdge
100 棵树性能 InstancedMesh 批量渲染
苹果 vs 玻璃球 统一 generateFruits,createFruitsGeometry 处分叉
UI 怎么切换 FruitType 下拉 + 条件显隐控件

从「加个球」到「扫描级苹果 + 物理玻璃球」,核心不是某个神奇 Shader,而是 把生成、实例化、材质三件事解耦

完整代码见:github.com/qdcxj/ez-tr…


如果这篇文章对你有帮助,欢迎点赞收藏。 有问题可以在评论区贴你的 Atlas 布局或截图,一起讨论 UV 映射。


标签:#Three.js #WebGL #程序化生成 #3D #前端可视化 #EZ-Tree #InstancedMesh #PBR

用 wagmi v2 + Next.js 14 搞 NFT 交易市场前端:从合约调用失败到顺利上架,我踩了哪些坑

作者 竹林818
2026年5月20日 18:01

用 wagmi v2 + Next.js 14 搞 NFT 交易市场前端:从合约调用失败到顺利上架,我踩了哪些坑

背景

上周我接了一个 NFT 交易市场的前端开发任务。项目用的是 Next.js 14,钱包连接用的是 wagmi v2 + RainbowKit。需求很简单:用户能连接钱包,查看自己持有的 NFT,把 NFT 上架到市场,设定价格,也能购买别人上架的 NFT,以及取消自己的上架。

听起来是不是很常规?我当时也这么想。之前在别的项目里用过 ethers.js 直接调合约,感觉没什么难度。结果从第一天就开始踩坑——签名失败、交易回滚、gas 估算不准、元数据不显示……整整折腾了两天多才把核心流程跑通。

这篇文章就是把我解决这些问题的过程完整记录下来。如果你也在用 wagmi v2 做 NFT 市场前端,希望你能少走我这些弯路。

问题分析

我的第一版思路很简单:用 wagmi 的 useWriteContract 直接调用合约的 listItem 函数,传入 tokenId 和价格。代码写起来确实很短:

const { writeContract } = useWriteContract();
writeContract({
  address: marketAddress,
  abi: marketAbi,
  functionName: 'listItem',
  args: [nftAddress, tokenId, ethers.parseEther('0.1')],
});

结果一跑,控制台直接报错:User rejected the request。我明明在 MetaMask 里点了确认,怎么还拒绝?后来发现是我没理解 wagmi v2 的 writeContract 签名方式——它默认用的是 eth_sendTransaction,但很多 NFT 市场合约要求先调用 NFT 合约的 approve,然后才能调用 listItem。而且 wagmi v2 的 useWriteContract 返回的是交易哈希,不是回调确认,我之前的处理方式完全不对。

更坑的是,我后来改用 useSimulateContract 做 gas 估算,结果又遇到了 insufficient funds for gas * price + value 的错误。当时我的钱包里确实有 ETH,但 gas 估算值跑飞了。排查了很久才发现,是因为我没有正确设置 account 参数。

核心实现

1. 先调 approve,再调 listItem:两笔交易的顺序坑

NFT 交易市场的标准流程是:用户先调用 NFT 合约的 approve 把某个 tokenId 授权给市场合约,然后市场合约才能把 NFT 转移走。所以前端必须发两笔交易。

我当时的第一反应是:用户点一次"上架"按钮,先发 approve,等 approve 确认后再发 listItem。但在 wagmi v2 里,useWriteContract 是异步的,而且没有内置的等待确认回调。我试了用 waitForTransactionReceipt 来等确认,但发现这样会导致 UI 状态混乱——用户可能以为已经完成了,但实际上第二笔交易还没发。

我的解决方案是:用一个状态机来控制流程。status 有四种状态:idleapprovingapproveDonelisting。用户点击上架后,先发 approve,等交易确认后自动切换到 listing 状态,再发 listItem。

// 状态枚举
type ListStatus = 'idle' | 'approving' | 'approveDone' | 'listing';

const [status, setStatus] = useState<ListStatus>('idle');

// 第一步:调用 approve
const { writeContract: approveWrite } = useWriteContract();
const { data: approveHash } = useWaitForTransactionReceipt({
  hash: status === 'approving' ? pendingHash : undefined,
});

const handleApprove = async (tokenId: bigint) => {
  setStatus('approving');
  approveWrite({
    address: nftAddress as `0x${string}`,
    abi: erc721Abi,
    functionName: 'approve',
    args: [marketAddress, tokenId],
  });
};

// 监听 approve 确认
useEffect(() => {
  if (approveHash && status === 'approving') {
    setStatus('approveDone');
  }
}, [approveHash, status]);

这里有个坑:wagmi v2 的 useWaitForTransactionReceipt 必须传入一个 hash 参数,而且这个 hash 必须是 useWriteContract 返回的 data。但 useWriteContractdata 是在交易提交后才有的,所以一开始 hashundefined。我一开始没处理这个初始状态,导致 useWaitForTransactionReceipt 永远不触发。后来加了个条件判断,只有 status === 'approving' 时才传入 hash,才正常。

2. 处理 listItem 的 gas 估算问题

approve 成功后,接下来调用 listItem。但我在这一步又遇到了 gas 估算不准的问题。

wagmi v2 提供了 useSimulateContract 来做 gas 估算,但我发现它返回的 request 有时候会报错 insufficient funds。排查后发现,是因为 useSimulateContract 默认使用当前连接的钱包地址作为 account,但如果用户的钱包在另一个链上,或者合约地址写错了,就会导致估算失败。

我的做法是:先检查链 ID 是否匹配,然后用 useSimulateContract 的返回值来构造交易,最后用 useWriteContract 发送。如果估算失败,就 fallback 到默认 gas limit。

const { chain } = useAccount();
const marketChainId = 11155111; // Sepolia

// 检查链是否匹配
const isCorrectChain = chain?.id === marketChainId;

// 估算 listItem 的 gas
const { data: simulateData, error: simulateError } = useSimulateContract({
  address: marketAddress as `0x${string}`,
  abi: marketAbi,
  functionName: 'listItem',
  args: [nftAddress, tokenId, price],
  query: {
    enabled: isCorrectChain && status === 'approveDone',
  },
});

// 发送 listItem 交易
const { writeContract: listWrite } = useWriteContract();

const handleList = () => {
  if (simulateError) {
    // 如果估算失败,用默认 gas limit
    listWrite({
      address: marketAddress as `0x${string}`,
      abi: marketAbi,
      functionName: 'listItem',
      args: [nftAddress, tokenId, price],
      gas: 200000n, // 硬编码一个安全值
    });
  } else if (simulateData?.request) {
    listWrite(simulateData.request);
  }
};

注意这个细节useSimulateContractquery.enabled 很重要。如果链不对或者状态不对,就不要去估算,否则会一直报错。而且 simulateError 不一定是 gas 问题,也可能是参数格式不对。我遇到过一次 args 里的 price 忘记用 parseEther 转换,导致合约报错 revert

3. 读取已上架的 NFT 列表:处理 BigInt 和元数据

上架成功后,用户需要在市场页面上看到所有已上架的 NFT。这里我用 wagmi 的 useReadContract 来读取合约的 getListedItems 函数。

但读出来的数据全是 bigint 类型,包括 tokenIdprice。直接显示在 UI 上会变成 12345678901234567890n 这种形式。而且每个 NFT 的元数据(图片、名称、描述)需要从 NFT 合约的 tokenURI 获取,这是一个异步的 HTTP 请求。

我的做法是:把 useReadContract 的结果映射成一个数组,然后对每个 item 调用 useReadContract 读取 tokenURI,再用 useEffect 去 fetch 元数据 JSON。

// 读取所有上架的 NFT
const { data: listedItems, isLoading: itemsLoading } = useReadContract({
  address: marketAddress as `0x${string}`,
  abi: marketAbi,
  functionName: 'getListedItems',
});

// 对每个 item 读取 tokenURI
const itemsWithMetadata = listedItems?.map((item: any) => {
  const { data: tokenUri } = useReadContract({
    address: item.nftAddress as `0x${string}`,
    abi: erc721Abi,
    functionName: 'tokenURI',
    args: [item.tokenId],
  });

  // 这里有个坑:useReadContract 不能在 map 里用,因为 hooks 数量必须固定
  // 正确做法:用另一个组件或者用 useContractReads 批量读取
});

我踩的这个坑特别大useReadContract 是 React Hook,不能在循环或条件语句里调用。我一开始在 map 里直接调用,结果报错 Rendered more hooks than during the previous render。后来改用 useContractReads 批量读取所有 tokenURI,但 useContractReads 在 wagmi v2 里改成了 useReadContracts,参数格式也不一样。

最终方案:用 useReadContracts 一次性读取所有 NFT 的 tokenURI:

const { data: tokenUris } = useReadContracts({
  contracts: listedItems?.map((item: any) => ({
    address: item.nftAddress as `0x${string}`,
    abi: erc721Abi,
    functionName: 'tokenURI',
    args: [item.tokenId],
  })) || [],
});

// 然后 fetch 每个 URI 获取元数据
const [metadataList, setMetadataList] = useState<any[]>([]);

useEffect(() => {
  if (!tokenUris) return;
  const fetchAll = async () => {
    const results = await Promise.all(
      tokenUris.map(async (result: any) => {
        if (result.status === 'success') {
          const response = await fetch(result.result);
          return response.json();
        }
        return null;
      })
    );
    setMetadataList(results);
  };
  fetchAll();
}, [tokenUris]);

注意这个细节tokenURI 返回的可能是 IPFS 地址(如 ipfs://xxx),前端直接 fetch 会失败。需要先解析成 HTTP 网关地址。我用了 ipfs-utils 库,或者简单替换 ipfs://https://ipfs.io/ipfs/

4. 购买功能的实现:处理 ETH 转账和回调

购买功能的逻辑更简单:用户点击"购买"按钮,调用市场合约的 buyItem 函数,同时发送 ETH(价格)。但这里有两个坑:

  1. buyItem 通常需要 payable,所以 writeContract 要带上 value 参数。
  2. 购买成功后需要刷新列表,但 wagmi v2 没有内置的 refetch 机制。

我的做法是:用 useWriteContract 发送交易,然后用 useWaitForTransactionReceipt 监听确认,确认后手动调用 refetch 刷新列表。

const { writeContract: buyWrite, data: buyHash } = useWriteContract();
const { isSuccess: buySuccess } = useWaitForTransactionReceipt({
  hash: buyHash,
});

const handleBuy = (item: ListedItem) => {
  buyWrite({
    address: marketAddress as `0x${string}`,
    abi: marketAbi,
    functionName: 'buyItem',
    args: [item.nftAddress, item.tokenId],
    value: item.price, // 发送 ETH
  });
};

// 购买成功后刷新列表
useEffect(() => {
  if (buySuccess) {
    refetchListedItems(); // 假设这个函数是 useReadContract 返回的 refetch
    toast.success('购买成功!');
  }
}, [buySuccess]);

这里有个坑value 的单位是 wei,而 item.price 从合约读出来就是 bigint 类型的 wei 值,所以直接传就行。但如果你从 UI 输入框获取价格,记得用 parseEther 转换。我当时就是忘了转换,导致发送了 0.000000000000000001 ETH,合约直接 revert。

完整代码

下面是一个可运行的完整示例(基于 Next.js 14 + wagmi v2 + RainbowKit):

// app/components/NFTMarket.tsx
'use client';

import { useState, useEffect } from 'react';
import { useAccount, useWriteContract, useWaitForTransactionReceipt, useReadContract, useReadContracts } from 'wagmi';
import { parseEther, formatEther } from 'viem';
import { erc721Abi } from './abis/erc721Abi';
import { marketAbi } from './abis/marketAbi';

type ListStatus = 'idle' | 'approving' | 'approveDone' | 'listing';
type ListedItem = {
  seller: string;
  nftAddress: string;
  tokenId: bigint;
  price: bigint;
};

export default function NFTMarket() {
  const { address, chain } = useAccount();
  const [status, setStatus] = useState<ListStatus>('idle');
  const [selectedTokenId, setSelectedTokenId] = useState<bigint>(0n);
  const [price, setPrice] = useState<string>('0.1');
  const [listedItems, setListedItems] = useState<ListedItem[]>([]);

  // 合约地址(请替换为实际部署的地址)
  const marketAddress = '0xYourMarketAddress' as `0x${string}`;
  const nftAddress = '0xYourNFTAddress' as `0x${string}`;

  // 读取所有上架的 NFT
  const { data: rawItems, refetch: refetchItems } = useReadContract({
    address: marketAddress,
    abi: marketAbi,
    functionName: 'getListedItems',
  });

  // 批量读取 tokenURI
  const { data: tokenUris } = useReadContracts({
    contracts: (rawItems as ListedItem[] || []).map((item) => ({
      address: item.nftAddress as `0x${string}`,
      abi: erc721Abi,
      functionName: 'tokenURI',
      args: [item.tokenId],
    })),
  });

  // 获取元数据
  const [metadataList, setMetadataList] = useState<any[]>([]);
  useEffect(() => {
    if (!tokenUris) return;
    const fetchAll = async () => {
      const results = await Promise.all(
        tokenUris.map(async (result: any) => {
          if (result.status === 'success') {
            const uri = result.result.replace('ipfs://', 'https://ipfs.io/ipfs/');
            try {
              const res = await fetch(uri);
              return res.json();
            } catch {
              return null;
            }
          }
          return null;
        })
      );
      setMetadataList(results);
    };
    fetchAll();
  }, [tokenUris]);

  // approve 交易
  const { writeContract: approveWrite, data: approveHash } = useWriteContract();
  const { isSuccess: approveSuccess } = useWaitForTransactionReceipt({
    hash: approveHash,
  });

  // approve 成功后自动进入 listing 状态
  useEffect(() => {
    if (approveSuccess && status === 'approving') {
      setStatus('approveDone');
    }
  }, [approveSuccess, status]);

  // listItem 交易
  const { writeContract: listWrite, data: listHash } = useWriteContract();
  const { isSuccess: listSuccess } = useWaitForTransactionReceipt({
    hash: listHash,
  });

  // 上架成功后刷新
  useEffect(() => {
    if (listSuccess) {
      setStatus('idle');
      refetchItems();
      alert('上架成功!');
    }
  }, [listSuccess, refetchItems]);

  // 处理上架按钮点击
  const handleList = async () => {
    if (!address) return;
    const tokenId = selectedTokenId;
    const priceWei = parseEther(price);

    // 第一步:approve
    setStatus('approving');
    approveWrite({
      address: nftAddress,
      abi: erc721Abi,
      functionName: 'approve',
      args: [marketAddress, tokenId],
    });
  };

  // 当 status 变为 approveDone 时,自动调用 listItem
  useEffect(() => {
    if (status === 'approveDone') {
      const priceWei = parseEther(price);
      listWrite({
        address: marketAddress,
        abi: marketAbi,
        functionName: 'listItem',
        args: [nftAddress, selectedTokenId, priceWei],
      });
    }
  }, [status, price, selectedTokenId, marketAddress, nftAddress, listWrite]);

  // 购买功能
  const handleBuy = (item: ListedItem) => {
    buyWrite({
      address: marketAddress,
      abi: marketAbi,
      functionName: 'buyItem',
      args: [item.nftAddress, item.tokenId],
      value: item.price,
    });
  };

  const { writeContract: buyWrite, data: buyHash } = useWriteContract();
  const { isSuccess: buySuccess } = useWaitForTransactionReceipt({
    hash: buyHash,
  });

  useEffect(() => {
    if (buySuccess) {
      refetchItems();
      alert('购买成功!');
    }
  }, [buySuccess, refetchItems]);

  return (
    <div className="p-4">
      <h1 className="text-2xl font-bold mb-4">NFT 交易市场</h1>

      {/* 上架区域 */}
      <div className="border p-4 rounded mb-4">
        <h2 className="text-lg font-semibold mb-2">上架 NFT</h2>
        <input
          type="number"
          placeholder="Token ID"
          value={selectedTokenId.toString()}
          onChange={(e) => setSelectedTokenId(BigInt(e.target.value || '0'))}
          className="border p-2 mr-2"
        />
        <input
          type="text"
          placeholder="价格 (ETH)"
          value={price}
          onChange={(e) => setPrice(e.target.value)}
          className="border p-2 mr-2"
        />
        <button
          onClick={handleList}
          disabled={status !== 'idle'}
          className="bg-blue-500 text-white p-2 rounded disabled:opacity-50"
        >
          {status === 'approving' ? '授权中...' : status === 'listing' ? '上架中...' : '上架'}
        </button>
      </div>

      {/* 已上架列表 */}
      <div className="grid grid-cols-3 gap-4">
        {(rawItems as ListedItem[] || []).map((item, index) => (
          <div key={index} className="border p-4 rounded">
            {metadataList[index] && (
              <img src={metadataList[index].image} alt={metadataList[index].name} className="w-full h-48 object-cover mb-2" />
            )}
            <p>Token ID: {item.tokenId.toString()}</p>
            <p>价格: {formatEther(item.price)} ETH</p>
            <p>卖家: {item.seller.slice(0, 6)}...{item.seller.slice(-4)}</p>
            {item.seller !== address && (
              <button onClick={() => handleBuy(item)} className="bg-green-500 text-white p-2 rounded mt-2">
                购买
              </button>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

踩坑记录

  1. useReadContract 在 map 里调用导致 hooks 数量不固定:这个错误特别隐蔽,因为编译不报错,运行才报。后来查文档才知道 wagmi v2 的 hooks 必须遵守 React 规则。解决方案是改用 useReadContracts 批量读取。

  2. useSimulateContractquery.enabled 没设置导致无限估算:一开始没加 enabled 条件,结果页面一加载就估算,链不对时一直报错。加了 isCorrectChain 判断后就好了。

  3. tokenURI 返回 IPFS 地址,前端直接 fetch 失败:这个问题在测试网上很常见。我一开始没处理,以为合约返回的是 HTTP 地址。后来加了个 replaceipfs:// 换成网关地址。

  4. 购买时 value 忘记用 parseEther 转换:UI 输入的是 0.1,但合约需要 wei。我直接把字符串传进去了,结果交易一直 revert。后来用 parseEther 转换才正常。

小结

这次做 NFT 交易市场前端,最大的感悟是:用 wagmi v2 开发时,一定要搞清楚 hooks 的规则和参数格式。特别是 useWriteContractuseReadContract 的调用方式,跟 ethers.js 差别很大。另外,处理 IPFS 地址和 BigInt 显示也是经常被忽略的细节。如果你也想深入,可以继续研究 wagmi v2 的 useAccountEffect 来做链切换监听,或者用 useSendTransaction 处理更复杂的交易。

事件循环(Event Loop)深度解析:让你彻底搞懂 JS 的执行顺序

2026年5月20日 17:54

事件循环(Event Loop)深度解析:让你彻底搞懂 JS 的执行顺序

为什么 setTimeout 明明是 0 毫秒,却要等到最后才执行?
Promise.thensetTimeout 到底谁先执行?
面试官总爱问的“事件循环”,今天一篇帮你彻底打通。


目录

  1. 从一道经典面试题说起
  2. 事件循环是什么?为什么要它?
  3. 宏任务(MacroTask)与微任务(MicroTask)
  4. 事件循环的工作流程(附流程图)
  5. 用代码验证每一步
  6. async/await 在事件循环中的特殊表现
  7. Node.js 与浏览器事件循环的区别
  8. 经典面试题集锦 + 解析
  9. 总结

1. 从一道经典面试题说起

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

问:以上代码的输出顺序是什么?

很多人会脱口而出:1 2 3 41 4 2 3
正确答案是:1 4 3 2

为什么?这正是事件循环的规则导致的。


2. 事件循环是什么?为什么要它?

JavaScript 是单线程语言,同一时间只能做一件事。但我们需要处理用户点击、网络请求、定时器等异步操作。如果这些操作都排队执行,那网络请求的 200ms 就会让整个页面卡住。

事件循环就是 JS 引擎用来调度同步代码、异步回调、I/O 操作的一套机制,它让 JS 既能保持单线程,又能高效处理异步。

生活类比

  • 你有一个“待办清单”(任务队列)。
  • 你先处理手头的事(同步代码)。
  • 手头的事干完了,就去清单里看看有没有新的待办。
  • 如果有,就取出来做,做完再去看清单……
  • 这个“反复看清单并取任务执行”的过程,就是事件循环

3. 宏任务(MacroTask)与微任务(MicroTask)

在事件循环中,任务被分为两种:宏任务微任务。它们的执行优先级不同。

3.1 宏任务(MacroTask)

  • 每次从任务队列中取出的一个任务,称为一个宏任务。
  • 常见宏任务
    • 整体代码块(script)
    • setTimeoutsetInterval
    • I/O 操作
    • UI 渲染
    • setImmediate(Node.js)
    • requestAnimationFrame(浏览器)

3.2 微任务(MicroTask)

  • 在当前宏任务执行完成后、下一个宏任务开始前执行的任务。
  • 常见微任务
    • Promise.then / catch / finally
    • MutationObserver(浏览器)
    • queueMicrotask
    • process.nextTick(Node.js,优先级高于普通微任务)

3.3 优先级总结

微任务队列 > 宏任务队列
每个宏任务执行完后,会立即清空当前所有的微任务,然后再去取下一个宏任务。


4. 事件循环的工作流程(附流程图)

用文字描述:

  1. 执行一个宏任务(最开始执行的是全局脚本代码)。
  2. 执行过程中如果遇到微任务,就把它添加到微任务队列。
  3. 当前宏任务执行完毕,检查微任务队列。
  4. 依次执行微任务队列中的所有任务(直到清空)。
  5. 执行必要的 UI 渲染(浏览器)。
  6. 从宏任务队列中取出下一个宏任务,重复步骤 1。

流程图(纯文本版):

┌─────────────────────────┐
│   开始执行宏任务(script)  │
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  同步代码执行,遇到微任务入队 │
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│   宏任务执行完毕          │
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│   清空当前所有微任务      │
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│   (可能执行 UI 渲染)    │
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│   取出下一个宏任务        │
└─────────────────────────┘

5. 用代码验证每一步

示例 1:基本顺序

setTimeout(() => console.log('宏任务'), 0);
Promise.resolve().then(() => console.log('微任务'));
console.log('同步');
// 输出:同步 → 微任务 → 宏任务

示例 2:微任务中注册新微任务

Promise.resolve().then(() => {
  console.log('微任务1');
  Promise.resolve().then(() => console.log('微任务2'));
});
console.log('同步');
// 输出:同步 → 微任务1 → 微任务2

关键:微任务队列会一次性清空,新添加的微任务也会在当前轮次执行完。

示例 3:宏任务中注册微任务

setTimeout(() => {
  console.log('宏任务');
  Promise.resolve().then(() => console.log('宏任务中的微任务'));
}, 0);
Promise.resolve().then(() => console.log('外层微任务'));
// 输出:外层微任务 → 宏任务 → 宏任务中的微任务

6. async/await 在事件循环中的特殊表现

async/await 是 Promise 的语法糖,但它在事件循环中的行为有细微的陷阱。

async function foo() {
  console.log('2');
  await bar();        // ← 这里 await 后面的代码相当于 Promise.then
  console.log('4');
}
async function bar() {
  console.log('3');
}
console.log('1');
foo();
console.log('5');
// 输出:1 2 3 5 4

分析

  • 同步:1235
  • await bar() 后面的 console.log('4') 相当于 Promise.resolve(bar()).then(() => console.log('4')),因此它进入微任务队列。
  • 当前宏任务执行完后,清空微任务,输出 4

陷阱:很多人以为 await 会阻塞,其实它只是把后续代码包装成微任务,并不会阻塞主线程。


7. Node.js 与浏览器事件循环的区别

浏览器和 Node.js 的事件循环在实现上有所不同,下面列出主要差异(Node.js 版本 11+ 后已尽量与浏览器对齐,但仍有一些区别)。

特性 浏览器 Node.js(v11+)
宏任务类型 setTimeoutsetInterval、I/O、UI 渲染 setTimeoutsetInterval、I/O、setImmediateprocess.nextTick(特殊微任务)
微任务执行时机 每个宏任务之后清空微任务 基本一致,但 process.nextTick 优先级高于 Promise.then
循环阶段 简单:宏任务 → 微任务 → 渲染 多阶段:timers → pending callbacks → idle → poll → check → close

Node.js 的事件循环有更多阶段,但作为前端开发者,你主要知道 setTimeoutPromise 的行为与浏览器基本一致即可。


8. 经典面试题集锦 + 解析

题目 1

setTimeout(() => console.log(1), 0);
Promise.resolve().then(() => console.log(2));
Promise.resolve().then(() => {
  console.log(3);
  setTimeout(() => console.log(4), 0);
});
console.log(5);

输出5 2 3 1 4
解释

  • 同步 5
  • 微任务依次 23(执行 3 时注册了一个宏任务 4)。
  • 宏任务 14

题目 2(混合 async/await)

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
  console.log('promise1');
  resolve();
}).then(() => console.log('promise2'));
console.log('script end');

输出
script startasync1 startasync2promise1script endasync1 endpromise2setTimeout

逐步拆解

  1. 同步:script start
  2. 调用 async1,输出 async1 start
  3. await async2() 执行 async2,输出 async2,然后 await 后续代码(async1 end)入微任务
  4. 继续执行同步,new Promise 立即执行输出 promise1resolve 后的 then 入微任务
  5. 同步 script end
  6. 当前宏任务结束,清空微任务队列:async1 endpromise2
  7. 下一个宏任务 setTimeout 输出

题目 3(Node.js 中的 process.nextTick

setTimeout(() => console.log('timeout'), 0);
process.nextTick(() => console.log('nextTick'));
console.log('start');

Node.js 输出:startnextTicktimeout
process.nextTick 优先级高于 Promise.then,属于特殊的微任务。


9. 总结

事件循环是 JavaScript 异步执行的核心机制。记住三句话:

  1. 先同步,后异步:同步代码优先执行,异步回调在队列中等待。
  2. 微任务 > 宏任务:每个宏任务执行完后,必须清空所有微任务。
  3. await 不阻塞:它只是把后续代码包装成微任务。

实践建议

  • 日常开发中,优先用 async/await,不用手动管理 then
  • 需要并发请求时用 Promise.all
  • 记住常见宏/微任务的分类,面试时不再慌张。

如果你能独立分析上面所有例子的输出,恭喜你,事件循环这一关你已经通关了!

用一个 Bookmarklet(书签脚本),给任意网页挂一个可拖拽悬浮窗

2026年5月19日 10:45

用一个 Bookmarklet(书签脚本),给任意网页挂一个可拖拽悬浮窗

最近写了一个挺有意思的小工具。

只要点击一下浏览器书签,就能在当前网页右下角弹出一个小窗口直接打开另一个网站。

而且支持:

  • 拖拽移动
  • 折叠
  • 关闭
  • iframe 内嵌网页

核心代码大概长这样:

javascript:(()=>{
  const IFRAME_URL='https://ruankaodaren.com';

  const WIDTH=520,HEIGHT=300;

  if(document.getElementById('__mini_window__')) return;

  const box=document.createElement('div');

  box.id='__mini_window__';

  Object.assign(box.style,{
    position:'fixed',
    right:'24px',
    bottom:'24px',
    width:WIDTH+'px',
    height:HEIGHT+'px',
    background:'#fff',
    borderRadius:'10px',
    boxShadow:'0 10px 30px rgba(0,0,0,.12)',
    zIndex:999999,
    overflow:'hidden',
    fontFamily:'system-ui,-apple-system'
  });

  const header=document.createElement('div');

  Object.assign(header.style,{
    height:'32px',
    display:'flex',
    alignItems:'center',
    justifyContent:'flex-end',
    padding:'0 8px',
    borderBottom:'1px solid #eee',
    cursor:'move',
    userSelect:'none'
  });

  const mkBtn=t=>{
    const b=document.createElement('button');

    b.innerText=t;

    Object.assign(b.style,{
      width:'28px',
      height:'28px',
      border:'none',
      background:'transparent',
      cursor:'pointer',
      color:'#666',
      fontSize:'16px'
    });

    return b;
  };

  const toggle=mkBtn('–'),
        close=mkBtn('×');

  header.append(toggle,close);

  const iframe=document.createElement('iframe');

  iframe.src=IFRAME_URL;

  Object.assign(iframe.style,{
    width:'100%',
    height:'calc(100% - 32px)',
    border:'none'
  });

  box.append(header,iframe);

  document.body.appendChild(box);

  let collapsed=false;

  toggle.onclick=()=>{
    collapsed=!collapsed;

    box.style.height=collapsed?'32px':HEIGHT+'px';

    toggle.innerText=collapsed?'+':'–';
  };

  close.onclick=()=>box.remove();

  let dragging=false,sx,sy,sl,st;

  header.onmousedown=e=>{
    dragging=true;

    sx=e.clientX;
    sy=e.clientY;

    const r=box.getBoundingClientRect();

    sl=r.left;
    st=r.top;

    document.body.style.userSelect='none';
  };

  document.onmousemove=e=>{
    if(!dragging)return;

    box.style.left=sl+e.clientX-sx+'px';

    box.style.top=st+e.clientY-sy+'px';

    box.style.right='auto';

    box.style.bottom='auto';
  };

  document.onmouseup=()=>{
    dragging=false;

    document.body.style.userSelect='';
  };
})();

什么是 Bookmarklet(书签脚本)

很多人第一次看到:

javascript:(()=>{})()

都会有点懵。

其实它本质上就是:

把 JavaScript 当成浏览器书签执行。

也就是说:

普通书签:

https://google.com

会跳转网页。

但 Bookmarklet:

javascript:alert('hello')

点击后会直接执行 JS。

所以它能做很多有意思的事情:

  • 自动填写表单
  • 网页增强
  • 页面调试
  • 注入工具
  • 快捷操作
  • 网页悬浮助手

很多年前流行的玩法了。


这个小工具到底做了什么

整体逻辑其实很简单:

创建一个悬浮 div
    ↓
顶部做控制栏
    ↓
里面塞 iframe
    ↓
支持拖拽 / 折叠 / 关闭

最终效果有点像:

  • 在线客服
  • AI 助手
  • 小窗播放器
  • 浏览器侧边工具

这种悬浮 UI。

效果展示:

image.png


iframe 才是整个核心

真正关键的是:

iframe.src=IFRAME_URL;

它意味着:

在当前网页里,再打开另一个网页。

于是你就能实现:

  • 网页里的 AI 助手
  • 小窗文档
  • 内部系统快捷入口
  • 在线翻译
  • 调试工具

甚至可以做:

“网页中的网页”

这也是 iframe 一直到今天还没被淘汰的原因。


拖拽功能其实很有意思

很多人第一次实现拖拽时都会发现:

原来拖拽本质上只是:
“计算鼠标移动距离”

代码核心其实就两步:

记录鼠标按下的位置:

sx=e.clientX;
sy=e.clientY;

然后移动时不断更新:

box.style.left=
  sl+e.clientX-sx+'px';

于是元素就“跟着鼠标走”了。

很多 UI 框架里的:

  • 弹窗拖拽
  • 看板拖拽
  • 编辑器拖拽

本质都是这一套。


为什么这种代码很适合练前端基础

因为它没有框架帮你处理。

你会真实接触:

  • DOM
  • 事件
  • 坐标
  • 定位
  • iframe
  • 浏览器行为

这些才是真正的浏览器底层能力。

很多人用 Vue、React 久了。

会逐渐忘记:

前端最终操作的其实一直都是 DOM。


这种东西能拿来干什么

其实用途非常多。

比如:

AI 助手

随时悬浮一个 ChatGPT。


内部系统快捷入口

不用切 Tab。


在线翻译

选中网页直接查。


接口调试

直接小窗打开 Swagger。


开发工具

甚至可以做:

  • JSON 格式化
  • 代码运行器
  • 页面检查工具

但它也有局限性

不是所有网站都能 iframe 打开。

有些网站会主动禁止:

X-Frame-Options

或者:

Content-Security-Policy

这时候 iframe 会直接失效。

所以这种方案:

更适合:

  • 自己的网站
  • 内部系统
  • 可控页面

最后

我一直觉得:

Bookmarklet 是一种很有意思的前端玩法。

它不复杂。

甚至代码量很小。

但你会非常直观地感受到:

浏览器 ≠ 网页浏览工具

浏览器其实本身就是一个运行环境。

而前端开发。

很多时候其实就是:

想办法让浏览器变得更有趣。

这个功能好像缺了点什么?缩放和快捷键?

❌
❌