普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月30日掘金 前端

Tailwind CSS:顺风CSS

作者 南山安
2025年12月30日 20:03

前言

最近在折腾 React 项目的时候,深深被 Tailwind CSS 迷住了。以前写 CSS 总觉得乱糟糟的,类名起得头疼,样式复用率低,改个颜色还得全局搜索。现在用上 Tailwind,感觉整个人都轻松了,直接在 HTML(或者 JSX)里堆类名,就能快速搭出好看的界面。这篇文章就分享一下我的学习过程,从传统 CSS 的痛点,到原子 CSS 的概念,再到 Tailwind 的实战应用。希望能帮到同样在纠结样式问题的朋友。

传统 CSS 的痛点:为什么我觉得它不够用了

拿我最早写的代码来说,看这个简单的按钮例子:

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        /* 坏例子:每个按钮一个独立的类,样式几乎不复用 */
        .primary-btn {
            padding: 8px 16px;
            background: blue;
            color: white;
            border-radius: 6px;
        }
        .default-btn {
            padding: 8px 16px;
            background: #ccc;
            color: #000;
            border-radius: 6px;
        }
    </style>
</head>
<body>
    <button class="primary-btn">提交</button>
    <button class="default-btn">默认</button>
</body>
</html>

看起来简单吧?但如果项目里有几十种按钮变体呢?每个都写一个类,CSS 文件很快就膨胀了。而且这些类带着强烈的“业务属性”,比如 primary、default,复用性很差。换个项目,这些类名基本废了。

后来我学到“面向对象 CSS”(OOCSS)的思路:把样式拆分成可复用的基类,然后组合起来。

改成这样:

.btn {
    padding: 8px 16px;
    border-radius: 6px;
    cursor: pointer;
}
.btn-primary {
    background: blue;
    color: white;
}
.btn-default {
    background: #ccc;
    color: #000;
}

HTML 里:

<button class="btn btn-primary">提交</button>
<button class="btn btn-default">默认</button>

这就好多了!基类 .btn 处理通用样式,变体类处理差异。通过组合,复用率高了很多。这其实就是原子 CSS 的雏形:把样式拆成一个个小的、不可分的“原子”单元。

原子 CSS:样式世界的乐高积木

原子 CSS(Atomic CSS)就是把 CSS 规则拆成极小的单一职责类,比如:

  • p-4:padding: 1rem;
  • bg-blue-600:background-color: #2563eb;
  • text-white:color: white;
  • rounded-md:border-radius: 0.375rem;

这些类就像乐高积木,单个没什么用,但组合起来能搭出任何形状。好处显而易见:

  • 高度复用:一个类可以在无数地方用,不用担心命名冲突。
  • 不用跳文件:样式直接写在 HTML 里,开发时上下文切换少,效率高。
  • 一致性强:所有间距、颜色都来自统一的设计系统,不会乱七八糟。
  • 生产包小:Tailwind 会自动移除未使用的类,最终 CSS 文件超级精简(往往只有几十 KB)。

Tailwind CSS 就是原子 CSS 的代表作。它不提供现成的组件(像 Bootstrap 的卡片、导航),而是给你一堆 utility 类,让你自己拼。很多人刚看觉得“类名好长好乱”,但用习惯了就回不去了——因为它让你专注于布局和设计,而不是纠结类名。

相比传统 CSS,Tailwind 的优势:

  • 开发速度快:不用写自定义 CSS,90% 的样式直接用 utility 类搞定。
  • 维护容易:改样式直接改类名,不用找 CSS 文件。
  • 响应式友好:内置 mobile-first 设计(后面详说)。
  • 自定义强:通过配置文件,能轻松调整颜色、间距等设计 token。

当然,不是完美:HTML 看起来类名多(“class soup”),初学曲线陡。但多练几次,就爱上了。

在 React + Vite 项目中上手 Tailwind CSS

2025 年了,Tailwind 已经到 v4 了,性能更强,内置 Vite 支持超级方便。我的 React 项目是用 Vite 创建的,安装步骤超级简单。

  1. 创建项目:
npm create vite@latest my-tailwind-project -- --template react
cd my-tailwind-project
npm install
  1. 安装 Tailwind(v4 方式):
npm install tailwindcss @tailwindcss/vite
  1. 配置 vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [react(), tailwindcss()],
})
  1. 导入:

在你的 CSS 文件中添加一个@import导入 Tailwind CSS 的语句。

@import "tailwindcss";

就这么简单!不用 postcss.config.js 了,v4 原生支持 Vite。

启动 npm run dev,你就可以在 JSX 里用 Tailwind 类了。

看我第一个组件:

function App() {
  return (
    <>
      <h1 className="text-4xl font-bold text-center my-8">Hello Tailwind!</h1>
      <button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
        提交
      </button>
      <button className="px-4 py-2 bg-gray-300 text-black rounded-md hover:bg-gray-400 ml-4">
        默认
      </button>
    </>
  )
}

直接在 className 里堆类:padding 用 px/py,背景 bg-xxx,文字 text-xxx,hover 状态直接 hover: 前缀。太爽了!

对应样式:

image.png

我还做了个小卡片组件:

const ArticleCard = () => {
  return (
    <div className="p-4 bg-white rounded-xl shadow hover:shadow-lg transition-shadow">
      <h2 className="text-lg font-bold">Tailwind CSS</h2>
      <p className="text-gray-500 mt-2">用 utility class 快速构建 UI</p>
    </div>
  );
};

hover 的时候阴影变大,transition 平滑过渡,全是 Tailwind 内置的。

image.png

TailWindCss的痛点

1. HTML/JSX 里类名超级长,看起来乱糟糟的(Class Soup 现象)

这是最多人吐槽的点。一个组件的 className 动不动就一长串

className="flex flex-col md:flex-row gap-4 p-6 bg-white rounded-lg shadow hover:shadow-xl transition-all"。

看起来像“类名汤”,阅读性差,尤其是新手或别人接手代码时,得花时间解析这些类到底干了啥。相比传统 CSS 一个语义化的类名(如 .card),Tailwind 把样式全暴露在 markup 里,违反了“关注点分离”的原则——HTML 本该管结构,CSS 管样式,现在全混一起了。

image.png

解决办法:提取组件、用 @apply 自定义类,或者工具如 tailwind-merge 处理条件类。但一开始还是挺别扭的。

image.png

效果展示:

image.png

2. 学习曲线陡峭,得记一大堆类名

Tailwind 的类名不是随便起的,得熟悉它的命名规则:padding 用 p-,margin 用 m-,响应式前缀 md: lg:,hover: focus: 之类的变体。

刚上手时,总得翻文档或靠记忆。很多人说“用一周就上手了”,但对 CSS 不熟的新人来说,可能得一个月才能流畅。

还有,复杂样式(如嵌套选择器、父子状态)用纯 Tailwind 写起来麻烦,得靠 group- 或插件。

解决方法:VS Code 的 IntelliSense 插件提示

image.png

效果展示:

image.png

3. 维护和重构时麻烦,尤其是大项目

想全局改个间距(如所有 padding 从 4 改成 6)?得搜索替换一堆类名。传统 CSS 改一个变量或类就行。

条件样式(比如根据 props 动态类)需要额外工具如 clsx 或 tailwind-merge,否则容易冲突。

调试时,浏览器 DevTools 里一堆原子类,虽然能点开看,但不如语义类直观。

大项目里,如果不严格规范,容易样式重复或不一致。

Tailwind 的响应式设计:Mobile First 是王道

Tailwind 默认是 mobile-first:先写移动端样式,再用前缀(如 md: lg:)覆盖大屏。

比如我做的布局:

export default function App2() {
  return (
    <div className="flex flex-col gap-4 p-4 md:flex-row">
      <main className="bg-blue-100 p-4 rounded-lg md:w-2/3">主内容区</main>
      <aside className="bg-green-100 p-4 rounded-lg md:w-1/3">侧边栏</aside>
    </div>
  );
}
  • 默认(手机):flex-col,垂直栈。
  • md 及以上:flex-row,横排;main 占 2/3,aside 占 1/3。

为什么 mobile-first 好?因为手机流量占一半以上,先保证小屏体验好,再逐步增强大屏。传统 desktop-first 容易忽略手机。

Tailwind 断点默认:

  • sm: 640px
  • md: 768px
  • lg: 1024px
  • xl: 1280px
  • 2xl: 1536px

想只在某个范围生效?组合 max- 前缀,比如 md:max-lg: 只在中屏。

进阶小技巧和最佳实践

用 Tailwind 久了,有些心得:

  1. 类名顺序有讲究:我喜欢按“同心圆”顺序:布局 → 盒模型 → 背景 → 文字 → 其他。这样读起来顺眼。
  2. 重复类太多?提取组件:别在每个按钮都写一长串,封装成 < Button variant="primary" >。
  3. 用 @apply 自定义类:如果某些组合常用,在 CSS 里:
.btn-primary {
  @apply px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700;
}
  1. 暗黑模式超简单:加 class="dark" 到 html,utility 用 dark: 前缀。
  2. 性能优化:Tailwind 自动 purge 未用类,生产包很小。
  3. 和大模型结合:Tailwind 类名语义强,用 AI 生成 UI 特别准。描述“一个蓝色大按钮,hover 变深”,直接吐类名。

React 中的 Fragment

React 组件为什么只能返回一个根元素(没有并列的多个根元素)?

你在学 React 时,肯定碰到过这个经典报错:

image.png 错误信息大概是:“JSX expressions must have one parent element” 或 “Adjacent JSX elements must be wrapped in an enclosing tag”。

简单说,就是 React 函数组件的 return 里,只能返回一个单一的根元素,不能并列多个顶级元素

错的例子:

function App() {
  return (
    <h1>标题</h1>
    <p>段落</p>  // 报错!两个并列
  );
}

对的例子(传统方式):

function App() {
  return (
    <div>  // 用 div 包裹成一个根
      <h1>标题</h1>
      <p>段落</p>
    </div>
  );
}

现在很多人用 Fragment:

function App() {
  return (
    <>  // 空标签,就是 Fragment
      <h1>标题</h1>
      <p>段落</p>
    </>
  );
}

那为什么 React 要强制这个规则呢?不能直接支持并列多个根吗?

核心原因:JSX 本质上是 React.createElement 的语法糖

React 组件其实就是一个函数,这个函数必须返回 一个值(单个 React Element)。

JSX 看起来像 HTML,但它会被 Babel 编译成 JavaScript 的 React.createElement 调用。

比如这个 JSX:

<div>
  <h1>标题</h1>
  <p>段落</p>
</div>

编译后大致是:

React.createElement("div", null,
  React.createElement("h1", null, "标题"),
  React.createElement("p", null, "段落")
);

createElement 返回的是 一个单一的 React Element 对象,它可以有多个 children(子元素数组),但本身必须是单个对象。

如果你写并列两个:

<h1>标题</h1>
<p>段落</p>

编译后相当于:

React.createElement("h1", null, "标题");
React.createElement("p", null, "段落");  // 两个独立的调用

这就像函数里有两条 return 语句:

function bad() {
  return 1;
  return 2;  // 第二个永远执行不到,语法无效
}

JavaScript 函数只能返回一个值,所以 React 组件也只能返回一个 React Element。

另一个重要原因:React 的虚拟 DOM 和 Reconciliation(调和)算法需要树状结构

React 的核心是虚拟 DOM:一个 JavaScript 对象树。

每个组件渲染后,必须对应虚拟 DOM 树上的 一个节点(根节点),这个节点可以有任意多个子节点,但组件本身只能代表一个节点。

如果允许并列多个根,React 就不知道这个组件在虚拟 DOM 树里该怎么挂载、怎么 diff(比较新旧虚拟 DOM)、怎么更新。

React 的 diff 算法依赖严格的树结构:父子关系清晰,便于高效比较和更新。

多根的话,树就乱了,算法复杂度会爆炸,性能变差。

历史背景:早期只能用 div 包裹,后来引入 Fragment

React 早期(v16 之前),大家只能用无意义的 < div> 包裹。

问题:

  • 多一层 DOM 节点,影响 CSS 布局(比如 flex、grid)
  • 在表格 里返回多个 ,包 div 会导致无效 HTML
  • DOM 树更深,略微影响性能

React v16(2017 年)引入 Fragment,专门解决这个痛点。

Fragment 就是一个“透明包裹”,不渲染成真实 DOM 节点,只在虚拟 DOM 里占位。

写法:

import { Fragment } from 'react';
// 或直接用短语法(推荐)
return (
  <Fragment>
    <h1>标题</h1>
    <p>段落</p>
  </Fragment>
);

// 最常用:
return (
  <>
    <h1>标题</h1>
    <p>段落</p>
  </>
);

注意:短语法 <></> 不支持 key 属性,如果在 map 里需要 key,就用 < Fragment key=...>。

总结

  • 技术原因:JSX → React.createElement 只能返回单个元素,函数只能 return 一个值。
  • 算法原因:虚拟 DOM 需要严格树结构,便于高效 diff 和更新。
  • 解决方案:用 Fragment(<>...</>)包裹,既满足规则,又不污染真实 DOM。

这个规则虽然一开始让人觉得麻烦,但它保证了 React 的高效和可预测性。用习惯 Fragment 后,你会觉得超级自然。

为什么叫TailWind

“Tailwind” 在英文里是“顺风”的意思,听起来挺诗意的,跟 CSS 框架有啥关系?

其实,名字的来源超级随意,来自它的创始人 Adam Wathan 本人的一次脑洞大开。

Adam 在一次访谈中亲口说过(大概 2022 年的播客):

他当时在 brainstorm 项目名字,从 “tail” 这个词开始联想——先想到 “tail tail”(重复),然后 “white tail”(白尾?可能是白尾鹿),突然蹦出 Tailwind

他说:“That's a cool name. 那名字挺酷的,而且有点道理,因为它能帮你做事更快(do stuff faster)。”

“Tailwind” 在英语里确实有“顺风”的含义,比如飞机起飞时如果有 tailwind,就能飞得更快、更顺畅。Adam 觉得这个框架能让开发者写样式更快、更高效,就像顺风助力一样,所以名字就这么定了。

他说这是他想到的第一个名字,就觉得特别合适,直接敲定了。

Tailwind CSS 最早是 Adam 在 2017 年做一个侧边项目时,顺手搞出来的一个内部工具(用 Less 写的 utility 类)。后来直播开发时,观众老问“这 CSS 框架叫啥”,他才决定开源。名字就是这么随便取的,没有什么深奥的典故,就是觉得“酷 + 贴合加速开发的理念”。

有趣的是,Tailwind 的 slogan 也是 “Rapidly build modern websites without ever leaving your HTML”(快速构建现代网站,不用离开 HTML),完美呼应了“顺风加速”的感觉。

所以,Tailwind 这个名字本质上就是:

  • 随意脑暴而来:从 “tail” 联想到的词。
  • 寓意开发加速:像顺风一样,让你写 UI 更快、更顺手。

写在最后:为什么我推荐你试试 Tailwind

从传统 CSS 到 Tailwind,我的感觉是:从“手艺人”变成了“建筑师”。以前抠每一个像素写规则,现在直接用现成积木搭,专注在产品逻辑和用户体验上。项目迭代快了,代码干净了,心情也好了。

如果你还在用传统 CSS 纠结,不妨在新项目里试试 Tailwind + React。刚开始可能不适应类名多,但坚持一周,你会爱上这种自由。

🚀 从零开始:如何从 GPTsAPI.net 申请 API Key 并打造自己的 AI 服务

作者 excel
2025年12月30日 19:18

在国内开发 AI 产品时,直接使用 OpenAI 官方 API 经常会遇到网络、注册和付费等障碍。GPTsAPI.net 提供了一个兼容 OpenAI 协议的 API 服务

本文将从注册、申请 API Key 到后端集成逐步拆解,并结合真实 Nuxt 后端代码解释如何打造一个稳定、可控的 AI 产品。


🧠 一、什么是 GPTsAPI.net?

它的主要特点包括:

  • 支持兼容 OpenAI 的 API 调用方式(如 /v1/chat/completions知乎专栏
  • 在国内访问更稳定
  • 模型资源丰富,且支持按量计费

📍 二、如何在 GPTsAPI.net 上申请 API Key

1. 访问 GPTsAPI.net 官网

首先打开:gptsapi.net 你会看到平台介绍和入口页面。

注:GPTsAPI.net 没有公开 API 文档页面,但其官网提供了注册入口和控制台用于 API Key 管理。


2. 注册账号

  1. 点击 注册 / 登录 按钮
  2. 使用邮箱或手机号完成注册
  3. 进入用户控制台

3. 在控制台中创建 API Key

在控制台界面中你可以看到 API Key 管理界面

  • 点击 “创建 API Key”
  • 系统会生成一个独一无二的 Key

通常你会看到形如:
sk-xxx... 或者由平台生成的一串字符

请务必保管好这个 Key!


4. 充值 / 选择计费计划(如有)

部分 API Key 需要充值才能调用,你可以在控制台中选择计费方式并完成充值。

不同模型的价格可能不同,例如:

模型 价格(示例)
GPT-4 系列 较高定价
Claude 系列 不同版本价格不同
其他轻量模型 特定价格 知乎专栏

🛠️ 三、如何在后端使用 GPTsAPI Key

有了 API Key 之后,你就可以通过标准的 OpenAI API 兼容方式调用 GPTsAPI 了。

基本原则:
✨ 把 API Key 放在后端,不暴露给前端
✨ 使用统一基地址 https://api.gptsapi.net/v1

下面是关键的集成方式(基于你提供的 Nuxt 后端代码)。


🔌 四、后端集成示例代码(Nuxt 3 + OpenAI SDK)

import OpenAI from "openai"
import { Config } from "~/utils/Config"

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const {
    model = "gpt-5-nano",
    prompt = "",
    history = [],
    summary = "",
    files = [],
  } = body;

  const client = new OpenAI({
    apiKey: Config.AI_API_KEY(),
    baseURL: Config.AI_API_BASE_URL(),
  })

🔹 Config.AI_API_KEY() 的值需要是你在 GPTsAPI.net 申请的 Key
🔹 Config.AI_API_BASE_URL() 通常填 https://api.gptsapi.net/v1


✂️ 五、清洗历史消息 & 多模态内容构造

为了提升生成质量,你的代码中做了以下优化:

const cleanHistory = (history || []).filter((m) => {
  // 过滤无效历史
})

这样可以避免无效、空内容浪费 token。并支持文本 + 图片输入逻辑:

userContent.push({
  type: "image_url",
  image_url: { url: `data:${f.type};base64,${f.data}` },
})

📡 六、流式响应与产品体验

调用 API 时你采用了流式输出:

const completion = await client.chat.completions.create({
  model,
  stream: true,
  messages,
})

并使用 Nuxt 的 sendStream 将实时输出返回前端,这样:

  • 用户前端可以像 ChatGPT 一样实时看到结果
  • 响应体验更好

💡 七、产品级注意事项

  1. 不要在前端暴露 API Key
  2. 对调用进行限流与监控
  3. 设置最大 token 限制
  4. 做好错误兜底与重试策略

🧾 八、示例请求方式(curl)

当你获取 Key 以后,可以在本地测试:

curl https://api.gptsapi.net/v1/chat/completions \
 -H "Authorization: Bearer YOUR_API_KEY" \
 -H "Content-Type: application/json" \
 -d '{
  "model": "gpt-3.5-turbo",
  "messages": [{"role": "user","content": "Hello"}]
}'

📌 这里使用的就是GPTsAPI 提供的兼容 OpenAI 的 API知乎专栏


✅ 九、总结

通过 GPTsAPI.net 申请 Key 的步骤可以概括为:

  1. 打开 gptsapi.net
  2. 注册账号
  3. 进入控制台创建 API Key
  4. 在后端使用这个 Key 调用模型接口

本文部分内容借助 AI 辅助生成,并由作者整理审核。

防抖 vs 节流:从百度搜索到京东电商,看前端性能优化的“节奏哲学”

作者 栀秋666
2025年12月30日 18:51

🔍引言

在现代 Web 应用中,用户交互越来越频繁——你敲一个字、滑一次屏、点一下按钮,背后可能触发数十次事件回调。如果每个动作都立刻执行复杂逻辑(比如请求接口、重绘 DOM),轻则卡顿,重则页面崩溃。

而真正优秀的用户体验,往往藏在那些你看不见的地方:
👉 百度输入“前端”后不急着搜,而是等你停顿才出建议;
👉 京东滚动加载商品时,不会“刷屏式”疯狂请求数据……

这一切的背后,是两个看似简单却威力巨大的技术——防抖(Debounce)与节流(Throttle)

本文将带你深入剖析它们的实现原理、适用场景与实战差异,结合百度、京东的真实案例,揭示前端性能优化中的“节奏控制艺术”。


🌪️ 一、为什么我们需要“节制”函数?

想象你在餐厅点餐:

  • 如果服务员每听到你说一个菜名就跑去厨房下单 → 厨房炸锅;
  • 正确做法是:等你说完所有菜,再统一提交订单。

前端开发也是如此。以下高频事件若不做处理,极易造成资源浪费:

事件类型 触发频率 潜在问题
input / keyup 每输入一个字符触发一次 多余的 Ajax 请求
scroll 滚动期间持续触发 频繁计算位置导致重排重绘
resize 窗口拖拽时密集触发 布局重算影响渲染性能
click 快速点击多次 表单重复提交、订单创建异常

这些问题的本质是:事件触发频率远高于我们实际需要的执行频率

于是,我们引入两位“节制大师”——

🎯 防抖(Debounce):只响应最后一次操作
⏱️ 节流(Throttle):按固定节奏响应操作

它们不是消灭事件,而是教会函数“何时该说话”。


💡 二、防抖(Debounce)—— 百度搜索的“冷静期智慧”

📍 典型场景:搜索建议延迟显示

当你在百度搜索框输入“JavaScript ”,

image.png

你会发现:

  • 输入过程中,并没有实时发起请求;
  • 只有当你停下来约 300ms 后,才看到下拉建议弹出。

这正是防抖的经典应用:等待用户操作结束后的“静默时刻”,再执行真正逻辑

如果没有防抖?
输入 5 个字 → 发起 5 次请求 → 服务器压力翻倍 + 用户体验混乱(旧结果覆盖新结果)。

用了防抖?
无论你打了多久,最终只发一次请求 —— 干净利落。

✅ 实现原理:闭包 + 定时器 = “重置倒计时”

function debounce(fn,delay){
  var id;  //自由变量
  return function(args){
       if(id) clearTimeout(id);
       var that=this; //用that保存this
        id=setTimeout(function(){
        // fn.call(that); 
        fn.call(that,args);
        },delay);
  }
 }

🔧 关键点解析:

  • clearTimeout(id):每次触发都取消之前的计划,确保只有最后一次生效。
  • setTimeout:设置“冷静期”,期间无新动作则执行。
  • call(this, args):保持原函数调用上下文和参数完整。

🧠 类比理解:电梯关门机制

就像写字楼的电梯——有人进来就暂停关门,直到连续 3 秒没人进出,才自动关闭运行。
防抖就是给函数加了个“智能门禁”,只让最后一个人进去。

🛠️ 实战示例:绑定搜索框

<input type="text" id="searchInput" placeholder="请输入关键词">
const inputEl = document.getElementById('searchInput');

function fetchSuggestions(keyword) {
  console.log('请求后端获取建议:', keyword);
  // 这里可以调用 API
}

// 使用防抖包装请求函数
const debouncedFetch = debounce(fetchSuggestions, 300);

inputEl.addEventListener('input', (e) => {
  debouncedFetch(e.target.value);
});

✅ 效果:快速输入不停止 → 不请求;停止输入 300ms → 请求一次最新值。


⏱️ 三、节流(Throttle)—— 京东滚动加载的“发车节奏”

📍 典型场景:无限滚动商品列表

打开京东首页,向下滚动浏览商品:

image.png

lQLPJxRxFJIgWT_NA7bNBk-wbMl5INfmGnYJLTRxXOrIAA_1615_950.png

  • 即使你飞速滑动鼠标滚轮;
  • 商品也不会瞬间全加载出来;
  • 而是每隔半秒左右“分批”出现新内容。

这不是网络慢,而是节流在工作:控制函数以固定频率执行,防止过度消耗资源

如果没有节流?
滚动一下触发几十次判断 → 频繁请求接口 → 数据错乱、内存飙升。

用了节流?
哪怕你滚得再快,也保证每 500ms 最多加载一次 → 系统稳定、体验流畅。

✅ 实现原理:时间戳 + 定时器 = “节拍器模式”

function throttle(fn, delay) {
  let lastTime = 0;       // 上次执行时间
  let deferTimer = null;  // 延迟执行的定时器

  return function (...args) {
    const context = this;
    const now = Date.now();

    if (now - lastTime > delay) {
      // 时间到了,立即执行
      lastTime = now;
      fn.apply(context, args);
    } else {
      // 时间未到,安排最后一次触发兜底
      clearTimeout(deferTimer);
      deferTimer = setTimeout(() => {
        lastTime = now;
        fn.apply(context, args);
      }, delay);
    }
  };
}

🔧 关键点解析:

  • Date.now() 获取当前时间戳,用于比较间隔;
  • lastTime 记录上次执行时间,决定是否放行;
  • deferTimer 是“补票机制”——防止最后一次触发被遗漏。

🚂 类比理解:地铁发车制度

地铁不管站台人多人少,都是每 5 分钟发一班车。
节流就像这个“准时发车系统”,不管你滚得多猛,我都按我的节奏来。

🛠️ 实战示例:监听页面滚动加载

function checkIfNearBottom() {
  const scrollTop = window.pageYOffset;
  const clientHeight = window.innerHeight;
  const scrollHeight = document.body.scrollHeight;

  if (scrollTop + clientHeight >= scrollHeight - 100) {
    console.log('接近底部,加载下一页商品');
    // loadMoreProducts();
  }
}

// 包装成节流函数
const throttledScroll = throttle(checkIfNearBottom, 500);

window.addEventListener('scroll', throttledScroll);

✅ 效果:快速滚动时,最多每 500ms 检查一次是否到底部,避免无效计算。


🆚 四、防抖 vs 节流:一张表说清所有区别

维度 防抖(Debounce) 节流(Throttle)
核心思想 等待“风平浪静”后再行动 按固定节奏稳步推进
执行次数 只执行最后一次 每个时间间隔至少执行一次
触发时机 延迟结束后执行 间隔开始或结束时执行
典型应用场景 搜索建议、表单验证、窗口 resize 滚动加载、拖拽、高频点击
函数执行频率 极低(可能全程只执行 1 次) 稳定(如 1s 内触发 20 次,仍只执行 2 次)
生活类比 电梯等人上齐再关门 地铁准点发车,不等人满
适合的操作特征 希望“完成后才处理” 希望“过程中定期反馈”

📊 执行行为对比(假设 delay = 300ms)

时间线(ms) 0 100 200 300 400 500 600 700
事件触发
防抖执行 ✅(仅最后一次)
节流执行 ✅(每 ~300ms 一次)

💡 结论:

  • 防抖追求“精简”,牺牲过程保结果;
  • 节流追求“节奏”,平衡效率与负载。

🎯 五、如何选择?三大决策原则

面对高频事件,别再盲目使用 setTimeout 抹黑了。根据业务目标做理性选择:

✅ 原则 1:看“要不要中间反馈”

  • 不需要中间状态?选防抖
    如搜索框输入:中间结果没意义,只要最终关键词。
  • 需要过程反馈?选节流
    如游戏手柄摇杆移动:必须持续响应方向变化。

✅ 原则 2:看“是否允许延迟”

  • 能接受短暂停顿?防抖更省资源
    如用户名唯一性校验,等用户输完再查。
  • 要求即时响应?节流更合适
    如音量调节滑块,必须实时更新 UI。

✅ 原则 3:看“执行成本高低”

  • ✅ 成本极高(如发邮件、下单)→ 优先防抖,防止误操作;
  • ✅ 成本较低但频次高(如监听鼠标位置)→ 优先节流,维持节奏。

🧩 六、进阶技巧 & 最佳实践

1. 支持立即执行的防抖(Leading Edge)

有时我们希望“第一次立刻执行”,后续才防抖:

function debounceImmediate(fn, delay, immediate = false) {
  let timerId;

  return function (...args) {
    const callNow = immediate && !timerId;
    const context = this;

    clearTimeout(timerId);

    if (callNow) {
      fn.apply(context, args);
    }

    timerId = setTimeout(() => {
      timerId = null;
      if (!immediate) fn.apply(context, args);
    }, delay);
  };
}

📌 适用场景:按钮点击防重复提交,首次点击立刻生效。


2. 节流的两种策略:时间戳 vs 定时器

类型 特点 缺点
时间戳版 首次立即执行,末次可能丢失 若停止触发,最后一次不会执行
定时器版 保证每次都能执行,节奏稳定 第一次会有延迟

推荐使用文中提供的“混合模式”:兼顾首次与末次。


3. 实际项目中的配置建议

场景 推荐延迟/间隔 说明
搜索建议 200–300ms 太短易误触,太长影响体验
滚动加载 500–800ms 给浏览器留出渲染时间
窗口 resize 300ms 避免频繁重排
表单实时验证 400ms 用户打字节奏匹配
高频按钮防重复提交 1000ms 提交后需等待接口返回,防止双订单

⚠️ 注意:不要硬编码!建议通过配置项动态调整,便于 A/B 测试优化。


🏁 七、总结:掌握“节奏感”,才是高级前端

防抖与节流,表面是两个工具函数,实则是前端工程师对 用户行为节奏的理解

🔥 真正的性能优化,不只是减少请求,更是学会“等待”与“克制”

  • 百度用防抖告诉我们:有时候慢一点,反而更快
  • 京东用节流提醒我们:再激烈的动作,也要有章法地应对

在高并发、强交互的时代,每一个优雅的交互背后,都有一个默默守候的 setTimeout


深入防抖与节流:从闭包原理到性能优化实战

作者 有意义
2025年12月30日 18:50

前言

在前端开发中,防抖(Debounce)节流(Throttle) 是两种经典的性能优化技术,广泛应用于搜索建议、滚动加载、窗口缩放等高频事件场景。它们能有效减少不必要的函数调用,避免页面卡顿或请求爆炸。

要深入理解其实现原理,你需要掌握以下核心知识点:

闭包(Closure) :用于在函数返回后仍能“记住”并访问内部变量(如定时器 ID 或时间戳)

对于闭包,我写了这两篇文章

柯里化:用闭包编织参数的函数流水线

JavaScript 词法作用域与闭包:从底层原理到实战理解

this 与参数的正确传递:确保被包装的函数在正确上下文中运行。

对于this,有不懂的可以参考这篇文章:

this 不是你想的 this:从作用域迷失到调用栈掌控

本文将结合生活类比、代码实现与真实场景,带你一步步拆解防抖与节流的机制、差异与应用之道。即使你曾觉得它们“有点绕”,读完也会豁然开朗。

一、问题背景:输入框频繁触发事件

全部代码在后面的附录

在 Web 开发中,用户在输入框中打字时,常会绑定 keyup 事件来实时响应输入内容。例如:

// 1.html Lines 17-19
function ajax(content) {
  console.log('ajax request', content);
}
// 1.html Lines 64-66
inputa.addEventListener('keyup', function(e) {
  ajax(e.target.value); // 复杂操作
});

问题:每当用户输入一个字符,就会触发一次 ajax() 调用。若用户输入 “hello”,将产生 5 次请求,造成不必要的网络开销和性能浪费。

image.png


二、防抖(Debounce)机制

想象你站在电梯里,正等着门关上。

可就在这时,一个路人匆匆跑进来,门立刻重新打开;还没等它合拢,又一个人冲了进来……只要不断有人进入,电梯就会一直“耐心”地等下去。

我站在里面心想:“这门到底什么时候才关啊?”

直到最后,整整几秒钟没人再进来——终于,“叮”一声,门缓缓合上,电梯开始运行。

这就像防抖:只要事件还在频繁触发,函数就一直“等”;只有当触发停歇了一段时间,它才真正执行。

这种“按节奏执行”的思想,不仅存在于游戏中,也广泛应用于 Web 交互。

一些AI编辑器 ( 比如Trae Cursor )就是这样

当你在代码框里飞快敲字时,它并不会每按一个键就立刻分析整段逻辑或发起智能补全请求。

那样做不仅浪费资源,还会拖慢输入体验。

相反,它会默默“观察”你的输入节奏:

只要你还在连续打字,它就耐心等待;一旦你停顿半秒,它才迅速介入,给出精准建议

代码实现

// 1.html Lines 21-30
function debounce(fn, delay) {
  let id; // 闭包中的自由变量,用于保存定时器 ID
  return function(...args) {
    if (id) clearTimeout(id); // 清除上一次的定时器
    const that = this;
    id = setTimeout(() => {
      fn.apply(that, args);
    }, delay);
  };
}

关键点解析

防抖函数通过闭包维护一个共享的定时器标识 id,使得多次事件触发都能访问并操作同一个状态。

每当用户触发事件(如键盘输入),函数会先清除之前尚未执行的定时器(如果存在),然后重新启动一个延迟为 delay 毫秒的新定时器

这意味着只要用户持续操作,计时就会不断重置,真实逻辑始终被推迟;只有当用户停止操作并经过指定的等待时间后,目标函数才会真正执行。

delay = 500ms 为例,若用户在 200ms 内快速输入 “hello”,每次按键都会打断之前的倒计时,最终仅在最后一次输入结束 500ms 后调用一次 ajax("hello")。整个过程将原本可能触发 5 次的请求压缩为 1 次,在保证响应合理性的同时,显著降低了系统开销。

image.png

使用示例

// 1.html Lines 58-69
const debounceAjax = debounce(ajax, 500);

inputb.addEventListener('keyup', function(e) {
  debounceAjax(e.target.value);
});

三、节流(Throttle)机制

核心思想

在固定时间间隔内,最多执行一次函数。

我正在玩一款FPS游戏,手指死死按住鼠标左键疯狂扫射——

可游戏里的枪根本没跟着我的节奏“突突突”到底。明明我一秒点了十下,它却稳稳地“哒、哒、哒”,每隔固定时间才射出一发子弹。

后来我才明白:这不是卡顿,而是射速限制在起作用。无论我多着急、按得多快,系统都会冷静地按自己的节奏来,既不让火力过猛破坏平衡,也不让我白白浪费弹药。

这就像节流:不管事件触发得多密集,函数都坚持“定时打卡”,不多不少,稳稳执行。

这种设计哲学,同样被现代开发工具所采纳

比如京东等电商平台:鼠标滚动时,页面需要不断判断是否已滑动到商品列表底部,从而决定是否自动加载下一页商品。

如果对每一次滚动事件都立即响应,浏览器会因频繁计算和发起网络请求而卡顿,尤其在低端设备上体验更差。

于是,开发者会使用节流机制——将滚动处理函数限制为每 200~300 毫秒最多执行一次。这样,即使用户快速拖动滚动条,系统也只会在固定间隔“抽样”检查位置,既保证了加载的及时性,又避免了性能过载。

换句话说:我不在乎你滚得多快,我只按自己的节奏干活——这正是节流在真实场景中的价值。

代码实现

// 1.html Lines 32-52
function throttle(fn, delay) {
  let last = 0;       // 上次执行的时间戳
  let deferTimer = null;

  return function(...args) {
    const now = Date.now();
    const that = this;

    if (last && now < last + delay) {
      // 还未到下次执行时间:延迟执行,并确保最后一次能触发
      clearTimeout(deferTimer);
      deferTimer = setTimeout(() => {
        last = now;
        fn.apply(that, args);
      }, delay - (now - last));
    } else {
      // 可立即执行
      last = now;
      fn.apply(that, args);
    }
  };
}

关键点解析

节流函数通过闭包维护两个关键状态:

last 记录上一次实际执行的时间戳,deferTimer 则用于管理可能的延迟执行任务。

每当事件被触发,函数会先获取当前时间,并判断距离上次执行是否已超过设定的间隔 delay

如果尚未到冷却期(即 now < last + delay),它不会立即执行,而是清除之前安排的延迟任务,并根据剩余时间重新设置一个定时器,确保在当前周期结束时至少执行一次;

如果已经过了冷却期,则直接执行函数并更新 last。这种机制既实现了“固定频率执行”的节奏控制,又巧妙地保证了在连续高频触发的末尾仍能响应最后一次操作。

例如,在 delay = 500ms 的配置下,无论用户在短时间内触发多少次事件,函数都会在 0ms、500ms、1000ms 等时间点稳定执行,既避免了过度调用,又不丢失关键的最终状态。

使用示例

// 1.html Lines 59-62
const throttleAjax = throttle(ajax, 500);

inputc.addEventListener('keyup', function(e) {
  throttleAjax(e.target.value);
});

四、典型应用场景

防抖适用场景

防抖最适合那些“只关心最终结果”的交互场景。

例如,在百度或淘宝的搜索框中,用户一边输入一边期待建议词,但如果每敲一个字母就立刻发起请求,不仅会制造大量无意义的网络调用,还可能因中间态(如拼音未完成)返回错误结果。

通过防抖,系统会耐心等到用户停顿片刻(比如 300 毫秒),再以最终输入内容发起一次精准查询。

类似的逻辑也适用于表单字段的验证——只有当用户真正输完并稍作停顿,才触发校验,避免在输入过程中不断弹出错误提示干扰操作。

简言之,防抖在“太快导致资源浪费”和“太慢影响体验”之间找到了最佳平衡点。

节流适用场景

相比之下,节流则适用于需要“持续响应但必须限频”的场景。

比如在京东、掘金等电商或内容平台,用户快速滚动页面时,系统需判断是否已滑到底部以加载更多商品或帖子。若对每一次滚动都立即响应,浏览器将不堪重负。

而通过节流(如每 300 毫秒最多执行一次检查),既能及时感知滚动行为,又避免过度计算。

同样,鼠标移动或元素拖拽过程中,实时更新坐标若不加限制,极易造成界面卡顿;节流能确保 UI 以稳定帧率更新,保持流畅感。甚至在某些对 resize 事件要求实时反馈的场景(如动态调整画布或视频比例),也会采用节流而非防抖,以兼顾响应性与性能。


防抖与节流,看似简单,却是前端性能优化的基石。掌握它们,就掌握了在“响应速度”与“系统负担”之间优雅平衡的艺术。


五、完整示例代码

上面的代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>防抖</title>
</head>
<body>
  <div>
    <input type="text" id="undebounce" />
    <br>
    <input type="text" id="debounce" />
    <br>
    <input type="text" id="throttle" />
  </div>
  <script>
  function ajax(content) {
    console.log('ajax request', content);
  }
  // 高阶函数 参数或返回值(闭包)是函数(函数就是对象) 
  function debounce(fn, delay) {
    var id; // 自由变量 
    return function(args) {
      if(id) clearTimeout(id);
      var that = this;
      id = setTimeout(function(){
        fn.call(that, args)
      }, delay);
    }
  }
  // 节流 fn 执行的任务 
  function throttle(fn, delay) {
    let 
      last, 
      deferTimer;
    return function() {
      let that = this; // this 丢失
      let _args = arguments // 类数组对象
      let now = + new Date(); // 类型转换, 毫秒数
      // 上次执行过 还没到执行时间
      if(last && now < last + delay) {
        clearTimeout(deferTimer);
        deferTimer = setTimeout(function(){
          last = now;
          fn.apply(that, _args);
        }, delay - (now - last));
      } else {
        last = now;
        fn.apply(that, _args);
      }
    }
  }
  
  const inputa = document.getElementById('undebounce');
  const inputb = document.getElementById('debounce');
  const inputc = document.getElementById('throttle');

  let debounceAjax = debounce(ajax, 500);
  let throttleAjax = throttle(ajax, 500);
  inputc.addEventListener('keyup', function(e) {
    throttleAjax(e.target.value)
  })
  // 频繁触发
  inputa.addEventListener('keyup', function(e) {
    ajax(e.target.value) // 蛮复杂
  })
  inputb.addEventListener('keyup', function(e) {
    debounceAjax(e.target.value)
  })
  </script>
</body>
</html>

网站/接口可用性拨测最佳实践

2025年12月30日 18:21

简介

可用性监测是观测云提供的综合性在线服务监控方案。它通过创建无需编写代码的 API,利用全球分布的监测点模拟真实用户在不同地区和网络环境下的访问体验。这种监测不仅涵盖网络质量、网站性能、关键端点等关键业务场景,还提供了对用户使用体验等多维度性能指标的周期性监控。

应用场景

  • 多协议支持:基于 HTTP、TCP、ICMP、WEBSOCKET 协议创建拨测任务,多方面主动监控在线业务的可用性和性能;
  • 全球网络监控:利用观测云遍布全球的监测点,即时监测网络性能,保障全球服务的可用性和性能表现;
  • 网络站点访问性能分析:从地理纬度和可用性趋势两个方面,分析网络站点的可用性性能;
  • 实时告警通知:基于拨测任务产生的数据配置告警规则,当业务出现异常,会基于规则以邮件、钉钉机器人等方式发送告警通知。

实践步骤

1、创建拨测任务

  • 观测云的「可用性监测」功能中,新建拨测任务,这里以 API 拨测为例。

  • 选择拨测类型,填写目标 URL 和判断条件。

  • 按需选择发送拨测的节点,以及拨测频率,点击保存即可。

2、查看效果

等拨测频率触发后,即可在「可用性监测」的概览和查看器中,即可查看到详细的结果。

3、设置告警监控

当我们希望拨测结果有异常时,能主动告警通知到相关的负责人;我们可以设置监控器来解决这个问题。

3.1 新建可用性数据检测

在观测云的「监控」功能中,新建监控器,选择“可用性数据检测”。

3.2 填写检测配置

按需填写检测频率、检测区间、以及触发的规则。这里表示响应时间大于 100ms 就告警。更多详情,可参考规则配置

3.3 自定义通知内容

观测云支持自定义告警通知的标题和内容,并且可以使用预置的模板变量

3.4 选择告警策略

监控满足触发条件后,支持将告警消息发送给指定的通知对象。通知对象包括但不限于:钉钉机器人、企业微信机器人、飞书机器人、Webhook 自定义、短信组、简单 HTTP 请求、Slack、Teams、电话、IM 消息发送等等

3.5 查看告警结果

告警触发后,相关通知对象就会收到告警信息,以下是钉钉机器人的告警信息:

手把手封装Iframe父子单向双向通讯功能

作者 red润
2025年12月30日 18:01

手把手封装Iframe父子单向双向通讯功能

导言

最近在研究多系统集成到一个主系统中,也就是所谓“微前端”,在研究了用微前端框架 micro-appqiankun来搭建测试项目,发现似乎有一点麻烦。

因为我的项目不需要复杂的路由跳转,只有简单的数据通讯,似乎用Iframe更加符合我当前的业务场景。

业务场景分析

image.png

如上图,我将业务场景使用最小demo展示出来

我们使用vue3+hook封装工具函数

目前实现的功能

我需要父子页面能够单向双向的互相通讯

下面是实现的代码片段功能

单向数据传输
  1. 父级向子级主动发送数据,子级接收父级发来的数据。

    •   // 父级向子级主动发送数据
        send('parent_message', {
            action: 'update',
            value: 'Hello from parent'
        });
      
    •   // 子级接收父级发来的数据
        on('parent_message', data => {
            parentData.value = data;
            console.log('收到父应用消息', data);
        });
        // 收到父应用消息 {action: 'update', value: 'Hello from parent'}
      
  2. 子级向父级主送发送数据,父级接收子级发来的数据。

    •   // 子级向父级主送发送数据
        send('child_message', {
            message: 'Hello from child',
            time: new Date().toISOString()
        });
      
        // 父级接收子级发来的数据。
        on('child_message', data => {
          receivedData.value = data;
          console.log('收到子应用消息', data);
        });
        // 收到子应用消息 {message: 'Hello from child', time: '2025-12-30T08:23:38.850Z'}
      
双向数据传输
  1. 父级向子级发起数据获取请求并等待,子级收到请求并响应

    •   // 父级向子级发起数据获取请求并等待
        try {
            const response = await sendWithResponse('get_data', {
                query: 'some data'// 发给子级的数据
            });
            console.log('收到子级响应数据', response);
        } catch (error) {
            console.error('请求失败', error);
        }
        // 收到子级响应数据 {result: 'data from child', query: 'some data', _responseType: 'get_data_response_1767082999194'}
      
    •   // 子级收到请求并响应
        handleRequest('get_data', async data => {
            // 处理数据
            return {
                result: 'data from child',
                ...data
            };
        });
      
  2. 子级向父级发起数据获取请求并等待,父级收到请求并响应

    •   // 子级向父级发起数据获取请求并等待
          try {
            const response = await sendWithResponse('get_data', {
              query: 'some data'
            });
            console.log('收到响应数据', response);
          } catch (error) {
            console.error('请求失败', error);
          }
          收到响应数据 {result: 'data from parent', query: 'some data', _responseType: 'get_data_response_1767083018851'}
      
    •   // 父级收到请求并响应
          handleRequest('get_data', async data => {
            // 处理数据
            return {
              result: 'data from parent',
              ...data
            };
          });
      

Iframe通讯原理解析

判断是否在iframe嵌套中

这是最简单且常用的方法。

// window.self 表示当前窗口对象,而 window.top 表示最顶层窗口对象。
// 如果两者不相等,则说明当前页面被嵌套在 iframe 中。
if (window.self !== window.top) {
console.log("当前页面被嵌套在 iframe 中");
} else {
console.log("当前页面未被嵌套");
}

核心发送消息逻辑

常使用postMessage来进行消息发送

otherWindow.postMessage(message, targetOrigin, [transfer]);
  • otherWindow

    • 其他窗口的一个引用,比如 iframe 的 contentWindow 属性。
  • message

    • 将要发送到其他 window 的数据。可以是字符串或者对象类型。
  • targetOrigin

    • 通过窗口的 origin 属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个 URI。
  • transfer(一般不传)

    • 是一串和 message 同时传递的 Transferable 对象。这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

核心接收消息逻辑

父子元素通过 监听 message事件来获取接收到的数据。我们将根据这个函数来封装自己的工具函数

window.addEventListener('message', e => {
    // 通过origin对消息进行过滤,避免遭到XSS攻击
    if (e.origin === 'http://xxxx.com') {
        // 判断来源是否和要通讯的地址来源一致
        console.log(e.data)  // 子页面发送的消息, hello, parent!
    }
}, false);

项目搭建

src/
├── utils/
│   ├── iframe-comm.js        # 父应用通信类
│   └── iframe-comm-child.js  # 子应用通信类
├── composables/
│   └── useIframeComm.js      # Vue3 组合式函数
├── App.vue                   # 父应用主组件
└── main.js

使用vite创建两个vue3项目

pnpm create vite

项目名分别是ParentChild,分别代表父级应用和子级应用,除了App.vue不一样其他代码都是相同的

image-20251230164134789

源码分析

核心逻辑分析

我们首先实现两个工具函数,iframe-comm.jsiframe-comm-child.js,分别作为父级和子级的工具函数,他们的逻辑大致一样,核心逻辑是:

  • constructor

    • 初始化配置信息,包括连接目标地址,是父级还是子级,事件对象处理合集等
  • initMessageListener

    • 初始化message事件消息监听
  • connect

    • 传入iframe元素,为全局对象设置iframe
  • send

    • 通过postMessage发送消息
  • on

    • 监听消息,通过new Map(事件类别,[事件回调]),可以实现对多个不同事件监听多个回调函数,后续监听顺序触发回调函数,获取接收到的消息。
  • off

    • 取消监听消息
  • sendWithResponse

    • 发送消息并等待响应,发生消息之前,设置一个回调函数,当消息发送成功后,回调函数会被触发,触发后回调函数清楚。
  • handleRequest

    • 配合sendWithResponse使用,主要监听sendWithResponse事件类别所发来的数据,处理完成并返回结果数据,返回的结果会触发sendWithResponse中设置的回调函数
  • destroy

    • 销毁实例

然后是hook函数useIframeComm.js,这个函数主要是封装了上面两个工具方法,方便vue3项目集成使用

如果是react框架可以自行封装hook函数,原生JS项目的话,可以直接使用上面的工具函数

然后就是App.vue的实现了,可以直接参照源码

src/utils工具函数
  • iframe-comm-child.js 供子级使用的工具函数

    •   // iframe-comm-child.js
        class IframeCommChild {
          constructor(options = {}) {
            this.parentOrigin = options.parentOrigin || window.location.origin;
            this.handlers = new Map();
            this.isParent = false;
        
            // 初始化消息监听
            this.initMessageListener();
          }
        
          /**
           * 向父应用发送消息
           * @param {string} type - 消息类型
           * @param {any} data - 消息数据
           */
          send(type, data) {
            const message = {
              type,
              data,
              source: 'child',
              timestamp: Date.now()
            };
        
            try {
              window.parent.postMessage(message, this.parentOrigin);
              return true;
            } catch (error) {
              console.error('发送消息到父应用失败:', error);
              return false;
            }
          }
        
          /**
           * 监听消息
           * @param {string} type - 消息类型
           * @param {Function} handler - 处理函数
           */
          on(type, handler) {
            if (!this.handlers.has(type)) {
              this.handlers.set(type, []);
            }
            this.handlers.get(type).push(handler);
          }
        
          /**
           * 取消监听消息
           * @param {string} type - 消息类型
           * @param {Function} handler - 处理函数
           */
          off(type, handler) {
            if (!this.handlers.has(type)) return;
        
            if (handler) {
              const handlers = this.handlers.get(type);
              const index = handlers.indexOf(handler);
              if (index > -1) {
                handlers.splice(index, 1);
              }
            } else {
              this.handlers.delete(type);
            }
          }
        
          /**
           * 自动响应请求
           * @param {string} requestType - 请求类型
           * @param {Function} handler - 处理函数,返回响应数据
           */
          handleRequest(requestType, handler) {
            this.on(requestType, async (data, event) => {
              const responseType = data?._responseType;
              if (responseType) {
                try {
                  const responseData = await handler(data, event);
                  this.send(responseType, {
                    success: true,
                    data: responseData
                  });
                } catch (error) {
                  this.send(responseType, {
                    success: false,
                    error: error.message
                  });
                }
              }
            });
          }
        
          /**
           * 发送消息并等待响应
           * @param {string} type - 消息类型
           * @param {any} data - 消息数据
           * @param {number} timeout - 超时时间(毫秒)
           * @returns {Promise}
           */
          sendWithResponse(type, data, timeout = 5000) {
            return new Promise((resolve, reject) => {
              const responseType = `${type}_response_${Date.now()}`;
              let timeoutId;
        
              const responseHandler = (response) => {
                clearTimeout(timeoutId);
                this.off(responseType, responseHandler);
                resolve(response.data);
              };
        
              this.on(responseType, responseHandler);
        
              // 发送请求
              const success = this.send(type, {
                ...data,
                _responseType: responseType
              });
        
              if (!success) {
                this.off(responseType, responseHandler);
                reject(new Error('发送消息失败'));
                return;
              }
        
              // 设置超时
              timeoutId = setTimeout(() => {
                this.off(responseType, responseHandler);
                reject(new Error('等待响应超时'));
              }, timeout);
            });
          }
        
          /**
           * 初始化消息监听器
           */
          initMessageListener() {
            window.addEventListener('message', (event) => {
              // 安全检查
              if (this.parentOrigin !== '*' && event.origin !== this.parentOrigin) {
                return;
              }
        
              const { type, data, source } = event.data;
        
              // 只处理来自父应用的消息
              if (source !== 'parent') return;
        
              if (this.handlers.has(type)) {
                this.handlers.get(type).forEach(handler => {
                  try {
                    handler(data, event);
                  } catch (error) {
                    console.error(`处理消息 ${type} 时出错:`, error);
                  }
                });
              }
            });
          }
        
          /**
           * 销毁实例
           */
          destroy() {
            window.removeEventListener('message', this.messageHandler);
            this.handlers.clear();
          }
        }
        
        export default IframeCommChild;
      
  • iframe-comm.js 供父级使用的工具函数

    •   // iframe-comm.js
        class IframeComm {
          constructor(options = {}) {
            this.origin = options.origin || '*';
            this.targetOrigin = options.targetOrigin || window.location.origin;
            this.handlers = new Map();
            this.iframe = null;
            this.isParent = true;
        
            // 初始化消息监听
            this.initMessageListener();
          }
        
          /**
           * 连接到指定iframe
           * @param {HTMLIFrameElement|string} iframe - iframe元素或选择器
           */
          connect(iframe) {
            if (typeof iframe === 'string') {
              this.iframe = document.querySelector(iframe);
            } else {
              this.iframe = iframe;
            }
        
            if (!this.iframe || !this.iframe.contentWindow) {
              console.error('无效的iframe元素');
              return;
            }
        
            this.isParent = true;
            return this;
          }
        
          /**
           * 向子应用发送消息
           * @param {string} type - 消息类型
           * @param {any} data - 消息数据
           * @param {string} targetOrigin - 目标origin
           */
          send(type, data, targetOrigin = this.targetOrigin) {
            if (!this.iframe?.contentWindow) {
              console.error('未连接到iframe或iframe未加载完成');
              return false;
            }
        
            const message = {
              type,
              data,
              source: 'parent',
              timestamp: Date.now()
            };
        
            try {
              this.iframe.contentWindow.postMessage(message, targetOrigin);
              return true;
            } catch (error) {
              console.error('发送消息失败:', error);
              return false;
            }
          }
        
          /**
           * 监听消息
           * @param {string} type - 消息类型
           * @param {Function} handler - 处理函数
           */
          on(type, handler) {
            if (!this.handlers.has(type)) {
              this.handlers.set(type, []);
            }
            this.handlers.get(type).push(handler);
          }
        
          /**
           * 取消监听消息
           * @param {string} type - 消息类型
           * @param {Function} handler - 处理函数
           */
          off(type, handler) {
            if (!this.handlers.has(type)) return;
        
            if (handler) {
              const handlers = this.handlers.get(type);
              const index = handlers.indexOf(handler);
              if (index > -1) {
                handlers.splice(index, 1);
              }
            } else {
              this.handlers.delete(type);
            }
          }
        
          /**
           * 发送消息并等待响应
           * @param {string} type - 消息类型
           * @param {any} data - 消息数据
           * @param {number} timeout - 超时时间(毫秒)
           * @returns {Promise}
           */
          sendWithResponse(type, data, timeout = 5000) {
            return new Promise((resolve, reject) => {
              const responseType = `${type}_response_${Date.now()}`;
              let timeoutId;
        
              const responseHandler = (response) => {
                clearTimeout(timeoutId);
                this.off(responseType, responseHandler);
                resolve(response.data);
              };
        
              this.on(responseType, responseHandler);
        
              // 发送请求
              const success = this.send(type, {
                ...data,
                _responseType: responseType
              });
        
              if (!success) {
                this.off(responseType, responseHandler);
                reject(new Error('发送消息失败'));
                return;
              }
        
              // 设置超时
              timeoutId = setTimeout(() => {
                this.off(responseType, responseHandler);
                reject(new Error('等待响应超时'));
              }, timeout);
            });
          }
        
          /**
           * 初始化消息监听器
           */
          initMessageListener() {
            window.addEventListener('message', (event) => {
              // 安全检查
              if (this.targetOrigin !== '*' && event.origin !== this.targetOrigin) {
                return;
              }
        
              const { type, data, source } = event.data;
        
              // 只处理来自子应用的消息
              if (source !== 'child') return;
        
              if (this.handlers.has(type)) {
                this.handlers.get(type).forEach(handler => {
                  try {
                    handler(data, event);
                  } catch (error) {
                    console.error(`处理消息 ${type} 时出错:`, error);
                  }
                });
              }
            });
          }
        
          /**
           * 销毁实例
           */
          destroy() {
            window.removeEventListener('message', this.messageHandler);
            this.handlers.clear();
            this.iframe = null;
          }
        }
        
        export default IframeComm;
      
src/composables 钩子函数
  • 这里面是核心hook函数

  • useIframeComm.js

    •   // useIframeComm.js
        import { ref, onUnmounted } from 'vue';
        import IframeComm from '../utils/iframe-comm.js';
        import IframeCommChild from '../utils/iframe-comm-child.js';
        
        /**
         * Vue3组合式函数 - 父应用
         */
        export function useIframeComm(options = {}) {
          const comm = ref(null);
          const isConnected = ref(false);
          const lastMessage = ref(null);
        
          const connect = (iframe) => {
            if (comm.value) {
              comm.value.destroy();
            }
        
            comm.value = new IframeComm(options);
            comm.value.connect(iframe);
            isConnected.value = true;
        
            // 监听所有消息
            comm.value.on('*', (data, event) => {
              lastMessage.value = { data, timestamp: Date.now() };
            });
          };
        
          const send = (type, data) => {
            if (!comm.value) {
              console.error('未连接到iframe');
              return false;
            }
            return comm.value.send(type, data);
          };
        
          const on = (type, handler) => {
            if (comm.value) {
              comm.value.on(type, handler);
            }
          };
        
          const off = (type, handler) => {
            if (comm.value) {
              comm.value.off(type, handler);
            }
          };
        
          const sendWithResponse = async (type, data, timeout) => {
            try {
              if (!comm.value) {
                throw new Error('未连接到iframe');
              }
              return await comm.value.sendWithResponse(type, data, timeout);
            } catch (error) {
              console.error(error);
        
            }
          };
          const handleRequest = (requestType, handler) => {
            if (comm.value) {
              comm.value.handleRequest(requestType, handler);
            }
          };
        
          onUnmounted(() => {
            if (comm.value) {
              comm.value.destroy();
            }
          });
        
          return {
            comm,
            isConnected,
            lastMessage,
            connect,
            send,
            on,
            off,
            sendWithResponse,
            handleRequest
          };
        }
        
        /**
         * Vue3组合式函数 - 子应用
         */
        export function useIframeCommChild(options = {}) {
          const comm = ref(null);
          const isReady = ref(false);
          const lastMessage = ref(null);
        
          const init = () => {
            if (comm.value) {
              comm.value.destroy();
            }
        
            comm.value = new IframeCommChild(options);
            isReady.value = true;
        
            // 监听所有消息
            comm.value.on('*', (data, event) => {
              lastMessage.value = { data, timestamp: Date.now() };
            });
          };
        
          const send = (type, data) => {
            if (!comm.value) {
              console.error('子应用通信未初始化');
              return false;
            }
            return comm.value.send(type, data);
          };
        
          const on = (type, handler) => {
            if (comm.value) {
              comm.value.on(type, handler);
            }
          };
        
          const off = (type, handler) => {
            if (comm.value) {
              comm.value.off(type, handler);
            }
          };
        
          const sendWithResponse = async (type, data, timeout) => {
            try {
              if (!comm.value) {
                throw new Error('子应用通信未初始化');
              }
              return await comm.value.sendWithResponse(type, data, timeout);
            } catch (error) {
              console.error(error);
        
            }
          };
        
          const handleRequest = (requestType, handler) => {
            if (comm.value) {
              comm.value.handleRequest(requestType, handler);
            }
          };
        
          onUnmounted(() => {
            if (comm.value) {
              comm.value.destroy();
            }
          });
        
          return {
            comm,
            isReady,
            lastMessage,
            init,
            send,
            on,
            off,
            sendWithResponse,
            handleRequest
          };
        }
      
src/App.vue 主代码
  • 这是父级的App.vue

    • 父级的端口是5173,所以他要连接到5174

    •   <script setup>
        import { ref, onMounted } from 'vue';
        import { useIframeComm } from './composables/useIframeComm';
        const iframeRef = ref(null);
        const receivedData = ref(null);
        // 初始化通讯
        const { connect, send, on, sendWithResponse, handleRequest, lastMessage } = useIframeComm({
          targetOrigin: 'http://localhost:5174'
        });
        const onIframeLoad = () => {
          connect(iframeRef.value);
          // 监听子应用
          on('child_message', data => {
            receivedData.value = data;
            console.log('收到子应用消息', data);
          });
          on('child_response', data => {
            console.log('收到子应用响应', data);
          });
          // 处理请求并自自动响应
          handleRequest('get_data', async data => {
            // 处理数据
            return {
              result: 'data from parent',
              ...data
            };
          });
        };
        const sendMessage = async () => {
          send('parent_message', {
            action: 'update',
            value: 'Hello from parent'
          });
          // 发送并等待响应
          try {
            const response = await sendWithResponse('get_data', {
              query: 'some data'
            });
            console.log('收到子级响应数据', response);
          } catch (error) {
            console.error('请求失败', error);
          }
        };
        </script>
        
        <template>
          <div class="parent">
            <h1>父应用</h1>
            <iframe ref="iframeRef" src="http://localhost:5174" @load="onIframeLoad"></iframe>
            <div style="display: flex; flex-direction: row">
              <div style="flex: 2; border: 1px solid black; height: 100px">接收到的子级消息:{{ receivedData }}</div>
              <button style="flex: 1" @click="sendMessage">发送消息到子应用</button>
            </div>
          </div>
        </template>
        
        <style scoped>
        .parent {
          width: 100%;
          height: 100%;
          background-color: white;
          display: flex;
          flex-direction: column;
          justify-content: center;
          iframe {
            height: 400px;
          }
          h1 {
            text-align: center;
          }
        }
        </style>
        
      
  • 这是子级的App.vue

    • 子级的端口是5174,所以他要连接到5173

    •   <script setup>
        import { onMounted, ref } from 'vue';
        import { useIframeCommChild } from './composables/useIframeComm';
        const parentData = ref(null);
        const { init, send, on, handleRequest, sendWithResponse } = useIframeCommChild({
          parentOrigin: 'http://localhost:5173'
        });
        onMounted(() => {
          init();
          on('parent_message', data => {
            parentData.value = data;
            console.log('收到父应用消息', data);
          });
          // 处理请求并自自动响应
          handleRequest('get_data', async data => {
            // 处理数据
            return {
              result: 'data from child',
              ...data
            };
          });
        });
        // 发送消息到父级
        const sendToParent = async () => {
          send('child_message', {
            message: 'Hello from child',
            time: new Date().toISOString()
          });
          // // 发送响应信息
          // send('child_response', { status: 'success' });
          // 发送并等待响应
          try {
            const response = await sendWithResponse('get_data', {
              query: 'some data'
            });
            console.log('收到响应数据', response);
          } catch (error) {
            console.error('请求失败', error);
          }
        };
        </script>
        
        <template>
          <div class="child">
            <h1>子应用</h1>
            <div style="display: flex; flex-direction: row">
              <div style="flex: 2; border: 1px solid black; height: 100px">接收到的父级消息:{{ parentData }}</div>
              <button @click="sendToParent">发送到父应用</button>
            </div>
            <div></div>
          </div>
        </template>
        
        <style scoped>
        .child {
          width: 100%;
          height: 100%;
          border: 5px dashed black;
          display: flex;
          flex-direction: column;
          justify-content: center;
          h1 {
            text-align: center;
          }
        }
        </style>
        
      

结尾

这个封装方案提供了一个完整、可靠的 Iframe 通信解决方案,适用于各种微前端集成场景。

防抖与节流:前端性能优化的“双子星”,让你的网页丝滑如德芙!

作者 Yira
2025年12月30日 17:47

防抖与节流:前端性能优化的“双子星”,让你的网页丝滑如德芙!

在现代 Web 开发中,用户交互越来越丰富,事件触发也越来越频繁。无论是搜索框的实时建议、页面滚动加载,还是窗口尺寸调整,这些看似简单的操作背后,都可能隐藏着性能陷阱。如果不加以控制,高频事件会像洪水一样冲垮你的应用——导致卡顿、内存泄漏,甚至服务器崩溃。

幸运的是,前端工程师早已找到了两大利器:防抖(Debounce)节流(Throttle) 。它们如同性能优化领域的“双子星”,一个专注“等你停手”,一个坚持“按节奏来”。今天,我们就深入剖析这两位高手的原理、区别与实战用法,助你写出更高效、更流畅的代码!


一、问题根源:为什么我们需要防抖和节流?

想象一下你在百度搜索框输入“React教程”:

  • 每按下一个键(R → e → a → c → t …),浏览器都会触发一次 keyup 事件;
  • 如果每次事件都立即发送 AJAX 请求,那么短短 6 个字就会发出 6 次网络请求
  • 而实际上,你只关心最终的关键词 “React教程”。

这就是典型的 “高频事件 + 复杂任务” 组合:

  • 事件太密集keyupscrollresize 等事件每秒可触发数十次;
  • 任务太复杂:AJAX 请求、DOM 操作、复杂计算等消耗大量资源。

若不加限制,后果严重:

  • 浪费带宽和服务器资源;
  • 页面卡顿,用户体验差;
  • 可能因请求顺序错乱导致 UI 显示错误(竞态条件)。

于是,防抖节流 应运而生。


二、防抖(Debounce):只执行最后一次

✅ 核心思想

“别急,等用户彻底停手再说!”

防抖的逻辑非常简单:在连续触发事件的过程中,不执行任务;只有当事件停止触发超过指定时间后,才执行一次。

🏠 生活类比:电梯关门

  • 电梯门打开后,等待 5 秒再关闭;
  • 如果第 3 秒有人进来,就重新计时 5 秒
  • 只有连续 5 秒没人进入,门才真正关闭。

💻 代码实现(闭包 + 定时器)

function debounce(fn, delay) {
  let timer; // 闭包变量,保存定时器 ID
  return function (...args) {
    clearTimeout(timer); // 清除上一个定时器
    timer = setTimeout(() => {
      fn.apply(this, args); // 执行原函数
    }, delay);
  };
}
关键点解析:
  • timer 是自由变量,被内部函数通过闭包“记住”;
  • 每次调用返回的函数,都会先 clearTimeout,再 setTimeout
  • 结果:只有最后一次触发后的 delay 毫秒内无新触发,才会执行

🌟 典型应用场景

场景 说明
搜索建议 用户打字时,等他停手再发请求,避免无效搜索
表单校验 输入邮箱/密码后,延迟验证,减少干扰
窗口 resize 保存布局 用户调整完窗口大小再保存,而非过程中反复保存

✅ 一句话总结:防抖适用于“有明确结束点”的操作,关注最终状态。


三、节流(Throttle):固定间隔执行

✅ 核心思想

“别慌,按我的节奏来!”

节流的逻辑是:无论事件触发多频繁,我保证每隔 X 毫秒最多执行一次任务。

🏠 生活类比:FPS 游戏射速

  • 即使你一直按住鼠标左键,枪也只会按照设定的射速(如每秒 10 发)射击;
  • 多余的点击会被忽略。

💻 代码实现(时间戳版)

function throttle(fn, delay) {
  let last = 0; // 上次执行时间
  return function (...args) {
    const now = Date.now();
    if (now - last >= delay) {
      fn.apply(this, args);
      last = now;
    }
  };
}

但你提供的代码更智能——它结合了尾部补偿

function throttle(fn, delay) {
  let last, deferTimer;
  return function () {
    let that = this;
    let _args = arguments;
    let now = +new Date();

    if (last && now < last + delay) {
      // 还在冷却期:清除旧定时器,安排新尾部任务
      clearTimeout(deferTimer);
      deferTimer = setTimeout(() => {
        last = now;
        fn.apply(that, _args);
      }, delay);
    } else {
      // 冷却期结束:立即执行
      last = now;
      fn.apply(that, _args);
    }
  };
}
工作流程:
  1. 第一次调用 → 立即执行;
  2. 高频调用期间 → 忽略中间操作,但记录最后一次
  3. 停止触发后 → 在 delay 毫秒后执行最后一次。

⚠️ 注意:这种实现确保了尾部操作不丢失,适合需要“收尾”的场景。

🌟 典型应用场景

场景 说明
页面滚动(scroll) 每 200ms 记录一次滚动位置,避免卡顿
鼠标移动(mousemove) 控制动画或绘图频率
按钮防连点 提交订单后 1 秒内禁止再次点击
无限滚动加载 用户滚动到底部时,定期检查是否需加载新数据

✅ 一句话总结:节流适用于“持续高频”的操作,关注过程节奏。


四、防抖 vs 节流:关键区别一目了然

对比项 防抖(Debounce) 节流(Throttle)
执行时机 停止触发后延迟执行 固定间隔执行
执行次数 N 次触发 → 1 次执行 N 次触发 → ≈ N/delay 次执行
是否保留尾部 是(天然保留) 基础版否,增强版可保留
核心机制 clearTimeout + setTimeout 时间戳判断 或 setTimeout 控制
适用事件 inputkeyup scrollresizemousemove
用户感知 “打完字才响应” “滚动时定期响应”

🔥 记住这个口诀:
“防抖等停手,节流控节奏。”


五、闭包:防抖与节流的“幕后英雄”

你可能注意到,无论是 debounce 还是 throttle,都用到了 闭包

function debounce(fn, delay) {
  let timer; // ← 这个变量被内部函数“记住”
  return function() {
    clearTimeout(timer); // ← 能访问外部的 timer
    // ...
  };
}

为什么必须用闭包?

  • timerlast 等状态需要在多次函数调用之间保持
  • 普通局部变量在函数执行完就销毁;
  • 而闭包让内部函数持续持有对外部变量的引用,形成“私有记忆”。

💡 闭包 = 函数 + 其词法环境。它是实现状态管理的基石。


六、实战建议:如何选择?

你的需求 推荐方案
用户输入搜索词 ✅ 防抖(500ms)
监听窗口 resize ✅ 节流(200ms)
滚动加载更多 ✅ 节流(300ms)
表单自动保存草稿 ✅ 防抖(1000ms)
鼠标拖拽元素 ✅ 节流(16ms ≈ 60fps)

📌 小技巧:

  • 防抖延迟通常 300~500ms(平衡响应与性能);
  • 节流间隔通常 100~300ms(根据场景调整)。

七、结语:优雅地控制频率,是专业前端的标志

防抖与节流,看似只是几行代码,却体现了对用户体验和系统性能的深刻理解。它们不是炫技,而是工程实践中不可或缺的“安全阀”。

下次当你面对高频事件时,不妨问问自己:

  • 我需要的是最终结果,还是过程采样
  • 用户是否希望立刻响应,还是可以稍等片刻

答案将指引你选择防抖或节流。掌握这“双子星”,你的代码将不再“颤抖”,而是如丝般顺滑——这才是真正的前端艺术!

vue3 KeepAlive 核心原理和渲染更新流程

作者 淋着141
2025年12月30日 17:45

vue3 KeepAlive 核心原理和渲染更新流程

KeepAlive 是 Vue 3 的内置组件,用于缓存动态组件,避免重复创建和销毁组件实例。 当组件被切换时,KeepAlive 会将组件实例存储在内存中,而不是完全销毁它,从而保留组件状态并提升性能。

1. 挂载

将子组件vnode进行缓存,并且设置vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE,供运行时在卸载时特殊处理

2. 停用 deactivate

当组件需要隐藏时, 根据COMPONENT_SHOULD_KEEP_ALIVE 和 renderer的逻辑

  1. 将组件移动到 storageContainer(一个不可见的 DOM 容器)
  2. 触发组件的 deactivated 生命周期钩子
  3. 组件实例和状态得以保留

3. 激活 activate

当组件再次激活时, 根据COMPONENT_KEPT_ALIVE 和 renderer的逻辑

  1. 新的 vnode.el 使用 cachedVNode.el
  2. 新的 vnode.component 使用 cachedVNode.component,这个是已经挂载的 组件了,里面的subTree都是有el的
  3. 将 vnode 移回目标容器
  4. 执行 patch 更新(处理 props 变化)
  5. 触发组件的 activated 生命周期钩子

4. 相关源码(只保留关于KeepAlive相关的核心逻辑)

const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,
  __isKeepAlive: true,
  setup(_, { slots }: SetupContext) {
    const instance = getCurrentInstance()!
    const sharedContext = instance.ctx as KeepAliveContext
    const cache: Cache = new Map()
    const keys: Keys = new Set()

    const {
      renderer: {
        p: patch,
        m: move,
        um: _unmount,
        o: { createElement },
      },
    } = sharedContext
    const storageContainer = createElement('div')

    // vnode 缓存的子组件, 结合runtime patch
    sharedContext.activate = (
      vnode,
      container,
      anchor,
      namespace,
      optimized
    ) => {
      // instance 是子组件实例
      const instance = vnode.component!
      // 移回来
      move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
      // in case props have changed
      patch(instance.vnode, vnode, container, anchor, instance,...)
      queuePostRenderEffect(() => {
        instance.isDeactivated = false
        if (instance.a) {
          invokeArrayFns(instance.a)
        }
      }, parentSuspense)
    }

    // vnode 缓存的子组件,里面的缓存的组件除了这两个钩子,其他都是常规流程
    sharedContext.deactivate = (vnode: VNode) => {
      const instance = vnode.component!
      // 移到缓存容器
      move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
      queuePostRenderEffect(() => {
        if (instance.da) {
          invokeArrayFns(instance.da)
        }
      }, parentSuspense)
    }

    // 当缓存失效,就需要真正的卸载
    function unmount(vnode: VNode) {
      // reset the shapeFlag so it can be properly unmounted
      resetShapeFlag(vnode)
      _unmount(vnode, instance, parentSuspense, true)
    }

    let pendingCacheKey: CacheKey | null = null
    const cacheSubtree = () => {
      // fix #1621, the pendingCacheKey could be 0
      if (pendingCacheKey != null) {
        cache.set(pendingCacheKey, getInnerChild(instance.subTree))
      }
    }
    onMounted(cacheSubtree)
    onUpdated(cacheSubtree)

    onBeforeUnmount(() => {
      cache.forEach(unmount)
    })

    // 渲染函数
    return () => {
      pendingCacheKey = null

      const children = slots.default()
      const rawVNode = children[0]
      const vnode = children[0]
      // 这里的vnode 就是指 缓存的组件
      // warn(`KeepAlive should contain exactly one component child.`)

      const comp = vnode.type as ConcreteComponent

      const name = getComponentName(comp)

      const { include, exclude, max } = props

      if (
        (include && (!name || !matches(include, name))) ||
        (exclude && name && matches(exclude, name))
      ) {
        // #11717 // 我写的pr!!!!
        vnode.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
        return rawVNode
      }

      const key = vnode.key == null ? comp : vnode.key
      const cachedVNode = cache.get(key)

      pendingCacheKey = key

      if (cachedVNode) {
        // 使用缓存的el,缓存的component tree,所以就不用走mount
        // copy over mounted state
        vnode.el = cachedVNode.el
        vnode.component = cachedVNode.component
        // 结合runtime patch 流程 当激活时就不走mount
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
      } else {
        keys.add(key)
      }
      // avoid vnode being unmounted
      // 结合runtime patch 流程 当卸载时就不走unmount
      vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE

      return vnode
    }
  },
}
// renderer 中关于 KeepAlive的逻辑
function baseCreateRenderer() {
  const processComponent = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null
  ) => {
    // parentComponent 就是 keepalive
    if (n1 == null) {
      if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
        ;(parentComponent!.ctx as KeepAliveContext).activate(
          n2,
          container,
          anchor,
          namespace,
          optimized
        )
      } else {
        // 正常mount mountComponent
      }
    } else {
      // 正常更新 updateComponent
    }
  }

  const mountComponent: MountComponentFn = (initialVNode) => {
    // initialVNode 是keepalive的vnode时,把对应的render传入进去,这逻辑其实不重要,只是为了封装复用
    // inject renderer internals for keepAlive
    if (isKeepAlive(initialVNode)) {
      ;(instance.ctx as KeepAliveContext).renderer = internals
    }
  }

  const unmount: UnmountFn = (vnode, parentComponent) => {
    // parentComponent 就是 keepalive
    const { shapeFlag } = vnode
    if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
      ;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
      return
    }
  }
}

npm/yarn/pnpm 原理与选型指南

作者 借个火er
2025年12月30日 17:27

npm/yarn/pnpm 深度对比:包管理工具的底层原理与选型

从 node_modules 的黑洞说起,剖析 npm、yarn、pnpm 的依赖解析算法、安装策略、锁文件机制,搞懂为什么 pnpm 能省 50% 磁盘空间。

一、包管理器演进史

2010 ────────────────────────────────────────────────► 2025
  
  ├─ 2010: npm 诞生(随 Node.js 一起发布)
           └─ 嵌套依赖,node_modules 黑洞的开始
  
  ├─ 2016: yarn 发布(Facebook)
           └─ 扁平化 + lockfile,解决依赖地狱
  
  ├─ 2017: npm 5.0
           └─ 引入 package-lock.json,追赶 yarn
  
  ├─ 2017: pnpm 发布
           └─ 硬链接 + 符号链接,革命性架构
  
  ├─ 2020: yarn 2 (Berry)
           └─ Plug'n'Play,零 node_modules
  
  └─ 2024: npm/yarn/pnpm 三足鼎立
            └─ pnpm 市场份额快速增长

二、node_modules 结构演进

2.1 npm v2:嵌套地狱

node_modules/
├── A@1.0.0/
│   └── node_modules/
│       └── B@1.0.0/
│           └── node_modules/
│               └── C@1.0.0/
├── D@1.0.0/
│   └── node_modules/
│       └── B@1.0.0/        ← 重复!
│           └── node_modules/
│               └── C@1.0.0/  ← 重复!
└── E@1.0.0/
    └── node_modules/
        └── B@2.0.0/        ← 不同版本
            └── node_modules/
                └── C@1.0.0/  ← 又重复!

问题

  • 🔴 路径过长(Windows 260 字符限制)
  • 🔴 大量重复依赖,磁盘爆炸
  • 🔴 安装速度慢

2.2 npm v3+ / yarn:扁平化

node_modules/
├── A@1.0.0/
├── B@1.0.0/          ← 提升到顶层
├── C@1.0.0/          ← 提升到顶层
├── D@1.0.0/
├── E@1.0.0/
│   └── node_modules/
│       └── B@2.0.0/  ← 版本冲突,保留嵌套
└── ...

解决了:路径过长、部分重复

新问题

  • 🔴 幽灵依赖:可以 require 未声明的包
  • 🔴 依赖分身:同一个包可能有多个副本
  • 🔴 不确定性:安装顺序影响结构

2.3 pnpm:内容寻址 + 符号链接

~/.pnpm-store/                    ← 全局存储(硬链接源)
└── v3/
    └── files/
        ├── 00/
        │   └── abc123...         ← 按内容哈希存储
        ├── 01/
        └── ...

node_modules/
├── .pnpm/                        ← 真实依赖(硬链接)
│   ├── A@1.0.0/
│   │   └── node_modules/
│   │       ├── A → <store>/A     ← 硬链接到 store
│   │       └── B → ../../B@1.0.0/node_modules/B  ← 符号链接
│   ├── B@1.0.0/
│   │   └── node_modules/
│   │       └── B → <store>/B
│   └── B@2.0.0/
│       └── node_modules/
│           └── B → <store>/B
├── A.pnpm/A@1.0.0/node_modules/A    ← 符号链接
├── D.pnpm/D@1.0.0/node_modules/D
└── E.pnpm/E@1.0.0/node_modules/E

核心原理

┌─────────────────────────────────────────────────────────────────┐
│                    pnpm 的三层结构                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   项目 node_modules/          只有直接依赖的符号链接             │
│            │                                                     │
│            ▼                                                     │
│   .pnpm/ 虚拟存储             所有依赖的扁平结构(符号链接)     │
│            │                                                     │
│            ▼                                                     │
│   ~/.pnpm-store/              全局存储(硬链接,真实文件)       │
│                                                                  │
│   💡 同一个包版本,全局只存一份                                  │
│   💡 不同项目通过硬链接共享                                      │
│   💡 项目只能访问声明的依赖(解决幽灵依赖)                      │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

三、幽灵依赖问题详解

3.1 什么是幽灵依赖?

// package.json 只声明了 express
{
  "dependencies": {
    "express": "^4.18.0"
  }
}

// 但你可以这样写(npm/yarn 扁平化后)
const debug = require('debug');  // 😱 未声明,但能用!
const qs = require('qs');        // 😱 express 的依赖

// 问题:
// 1. express 升级后可能不再依赖 debug → 你的代码挂了
// 2. 换台机器安装顺序不同 → 可能找不到
// 3. 代码审查看不出真实依赖

3.2 pnpm 如何解决?

node_modules/
├── express → .pnpm/express@4.18.0/...  ← 只有 express
└── .pnpm/
    └── express@4.18.0/
        └── node_modules/
            ├── express/
            ├── debug/      ← debug 在这里,外面访问不到
            └── qs/
// pnpm 项目中
const debug = require('debug');  
// ❌ Error: Cannot find module 'debug'

// 必须显式声明
// package.json: "debug": "^4.0.0"
// 然后才能用

四、依赖解析算法

4.1 npm/yarn 的依赖提升

// 依赖关系
A@1.0 → B@1.0
C@1.0 → B@2.0

// npm/yarn 解析结果(取决于安装顺序)
// 情况1:先安装 A
node_modules/
├── A@1.0/
├── B@1.0/          ← B@1.0 被提升
└── C@1.0/
    └── node_modules/
        └── B@2.0/  ← B@2.0 嵌套

// 情况2:先安装 C
node_modules/
├── A@1.0/
│   └── node_modules/
│       └── B@1.0/  ← B@1.0 嵌套
├── B@2.0/          ← B@2.0 被提升
└── C@1.0/

这就是为什么需要 lockfile!

4.2 pnpm 的确定性解析

// pnpm 不做提升,结构永远确定
node_modules/
├── A → .pnpm/A@1.0.0/node_modules/A
├── C → .pnpm/C@1.0.0/node_modules/C
└── .pnpm/
    ├── A@1.0.0/node_modules/
    │   ├── A/
    │   └── B → ../../B@1.0.0/node_modules/B
    ├── B@1.0.0/node_modules/B/
    ├── B@2.0.0/node_modules/B/
    └── C@1.0.0/node_modules/
        ├── C/
        └── B → ../../B@2.0.0/node_modules/B

// 💡 每个包都能找到正确版本的依赖
// 💡 不受安装顺序影响

五、Lockfile 机制对比

5.1 三种 lockfile 格式

# ==================== package-lock.json (npm) ====================
{
  "name": "my-app",
  "lockfileVersion": 3,
  "packages": {
    "node_modules/lodash": {
      "version": "4.17.21",
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
      "integrity": "sha512-v2kDE..."
    }
  }
}

# ==================== yarn.lock (yarn) ====================
lodash@^4.17.0:
  version "4.17.21"
  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz"
  integrity sha512-v2kDE...

# ==================== pnpm-lock.yaml (pnpm) ====================
lockfileVersion: '9.0'
packages:
  lodash@4.17.21:
    resolution: {integrity: sha512-v2kDE...}
    engines: {node: '>=0.10.0'}

5.2 Lockfile 作用

┌─────────────────────────────────────────────────────────────────┐
│                    Lockfile 解决的问题                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   package.json: "lodash": "^4.17.0"                             │
│                                                                  │
│   没有 lockfile:                                                │
│   • 今天安装:lodash@4.17.20                                    │
│   • 明天安装:lodash@4.17.21(新版本发布了)                    │
│   • 😱 不同时间/机器安装结果不同                                │
│                                                                  │
│   有 lockfile:                                                  │
│   • 锁定 lodash@4.17.20                                         │
│   • 任何时间/机器安装结果相同                                   │
│   • ✅ 可复现的构建                                             │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

5.3 Lockfile 对比

特性 package-lock.json yarn.lock pnpm-lock.yaml
格式 JSON 自定义 YAML
可读性 差(嵌套深)
合并冲突 难解决 较易 较易
包含信息 完整树结构 扁平列表 扁平 + 依赖关系
文件大小

六、性能对比实测

6.1 安装速度

┌─────────────────────────────────────────────────────────────────┐
                    安装速度对比(中型项目,约 500 依赖)          
├─────────────────────────────────────────────────────────────────┤
                                                                  
   首次安装(无缓存)                                             
   npm:     ████████████████████████████████  65s                
   yarn:    ██████████████████████████        52s                
   pnpm:    ████████████████████              40s   🔥 最快     
                                                                  
   重复安装(有缓存)                                             
   npm:     ████████████████████              38s                
   yarn:    ██████████████                    28s                
   pnpm:    ████████                          15s   🔥  2.5x  
                                                                  
   CI 环境(有 lockfile,无 node_modules)                        
   npm ci:  ████████████████████████          48s                
   yarn:    ██████████████████                35s                
   pnpm:    ████████████                      22s   🔥  2x    
                                                                  
└─────────────────────────────────────────────────────────────────┘

6.2 磁盘占用

┌─────────────────────────────────────────────────────────────────┐
                    磁盘占用对比                                  
├─────────────────────────────────────────────────────────────────┤
                                                                  
   单项目 node_modules                                            
   npm:     ████████████████████████████████  850MB              
   yarn:    ████████████████████████████████  850MB              
   pnpm:    ████████████████████████████████  850MB(首次)      
                                                                  
   10 个相似项目(共享依赖 80%)                                  
   npm:     ████████████████████████████████  8.5GB              
   yarn:    ████████████████████████████████  8.5GB              
   pnpm:    ████████████                      2.1GB   🔥  75% 
                                                                  
   💡 pnpm 通过硬链接共享,相同文件只存一份                      
                                                                  
└─────────────────────────────────────────────────────────────────┘

6.3 性能对比表

指标 npm yarn pnpm
首次安装
重复安装 最快
磁盘占用 低(共享)
内存占用
并行下载
离线模式

七、Monorepo 支持对比

7.1 Workspace 配置

# ==================== npm (v7+) ====================
# package.json
{
  "workspaces": ["packages/*"]
}

# ==================== yarn ====================
# package.json
{
  "workspaces": ["packages/*"]
}

# ==================== pnpm ====================
# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
  - '!**/test/**'  # 排除

7.2 Monorepo 命令对比

# 在所有包中执行命令
npm exec --workspaces -- npm run build
yarn workspaces run build
pnpm -r run build                    # 🔥 最简洁

# 在指定包中执行
npm exec --workspace=@my/pkg -- npm run build
yarn workspace @my/pkg run build
pnpm --filter @my/pkg run build      # 🔥 filter 更强大

# 添加依赖到指定包
npm install lodash --workspace=@my/pkg
yarn workspace @my/pkg add lodash
pnpm add lodash --filter @my/pkg

# pnpm filter 高级用法
pnpm --filter "@my/*" run build           # 匹配模式
pnpm --filter "...@my/app" run build      # 包含依赖
pnpm --filter "@my/app..." run build      # 包含被依赖
pnpm --filter "...[origin/main]" run build # Git 变更的包

7.3 Monorepo 对比表

特性 npm yarn pnpm
Workspace 支持 v7+ v1+
依赖提升 默认提升 默认提升 不提升(严格)
Filter 语法 基础 基础 强大
并行执行
拓扑排序
变更检测 ✅ (--filter)

八、特殊场景处理

8.1 Peer Dependencies

// 包 A 声明
{
  "peerDependencies": {
    "react": "^17.0.0 || ^18.0.0"
  }
}

// npm 7+:自动安装 peer deps(可能导致冲突)
// yarn:警告但不自动安装
// pnpm:严格模式,必须显式安装
# pnpm 处理 peer deps 警告
pnpm install --strict-peer-dependencies=false

# 或在 .npmrc 配置
strict-peer-dependencies=false
auto-install-peers=true

8.2 可选依赖失败

// package.json
{
  "optionalDependencies": {
    "fsevents": "^2.3.0"  // macOS only
  }
}

// npm/yarn:失败时静默跳过
// pnpm:同样静默跳过,但日志更清晰

8.3 私有仓库配置

# .npmrc(三者通用)

# 指定 registry
registry=https://registry.npmmirror.com

# 私有包使用私有仓库
@mycompany:registry=https://npm.mycompany.com

# 认证
//npm.mycompany.com/:_authToken=${NPM_TOKEN}

九、安全性对比

9.1 安全审计

# npm
npm audit
npm audit fix
npm audit fix --force  # 强制升级(可能破坏性)

# yarn
yarn audit
yarn audit --json

# pnpm
pnpm audit
pnpm audit --fix

9.2 安全特性对比

特性 npm yarn pnpm
安全审计
自动修复
幽灵依赖防护
完整性校验
签名验证 ✅ (v8.6+)

9.3 pnpm 的安全优势

┌─────────────────────────────────────────────────────────────────┐
│                    pnpm 安全优势                                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   1. 防止幽灵依赖攻击                                            │
│      • 恶意包无法被意外引入                                     │
│      • 只能访问显式声明的依赖                                   │
│                                                                  │
│   2. 内容寻址存储                                                │
│      • 文件按哈希存储                                           │
│      • 篡改会导致哈希不匹配                                     │
│                                                                  │
│   3. 严格的依赖解析                                              │
│      • 不会意外使用错误版本                                     │
│      • 依赖关系更清晰                                           │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

十、迁移指南

10.1 从 npm 迁移到 pnpm

# 1. 安装 pnpm
npm install -g pnpm

# 2. 删除 node_modules 和 lockfile
rm -rf node_modules package-lock.json

# 3. 导入(自动生成 pnpm-lock.yaml)
pnpm import  # 可以从 package-lock.json 导入

# 4. 安装
pnpm install

# 5. 更新 CI 脚本
# npm ci → pnpm install --frozen-lockfile
# npm install → pnpm install
# npm run → pnpm run

10.2 从 yarn 迁移到 pnpm

# 1. 删除 yarn 相关文件
rm -rf node_modules yarn.lock .yarnrc.yml

# 2. 导入
pnpm import  # 可以从 yarn.lock 导入

# 3. 安装
pnpm install

# 4. 处理可能的幽灵依赖问题
# pnpm 会报错,按提示添加缺失的依赖
pnpm add <missing-package>

10.3 常见迁移问题

# 问题1:幽灵依赖报错
# Error: Cannot find module 'xxx'
# 解决:显式添加依赖
pnpm add xxx

# 问题2:peer deps 警告
# 解决:配置 .npmrc
echo "auto-install-peers=true" >> .npmrc

# 问题3:某些包不兼容符号链接
# 解决:配置 shamefully-hoist
echo "shamefully-hoist=true" >> .npmrc  # 不推荐,最后手段

# 问题4:postinstall 脚本路径问题
# 解决:使用相对路径或 pnpm 的 hooks

十一、最佳实践

11.1 项目配置建议

# .npmrc(推荐配置)

# 使用国内镜像
registry=https://registry.npmmirror.com

# 自动安装 peer deps
auto-install-peers=true

# 严格模式(推荐)
strict-peer-dependencies=false

# 提升特定包(兼容性问题时使用)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

11.2 CI/CD 配置

# GitHub Actions 示例
- name: Setup pnpm
  uses: pnpm/action-setup@v2
  with:
    version: 8

- name: Setup Node.js
  uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'pnpm'  # 🔥 缓存 pnpm store

- name: Install dependencies
  run: pnpm install --frozen-lockfile  # 🔥 CI 必须用 frozen

- name: Build
  run: pnpm run build

11.3 团队协作规范

// package.json
{
  "packageManager": "pnpm@8.15.0",  // 🔥 锁定包管理器版本
  "engines": {
    "node": ">=18",
    "pnpm": ">=8"
  },
  "scripts": {
    "preinstall": "npx only-allow pnpm"  // 🔥 强制使用 pnpm
  }
}

十二、选型建议

12.1 决策树

┌─────────────────────────────────────────────────────────────────┐
│                    包管理器选型决策                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   你的场景是?                                                   │
│        │                                                         │
│        ├─ 新项目 ──────────────────────► pnpm(推荐)           │
│        │                                                         │
│        ├─ 老项目迁移成本高 ────────────► 保持现状               │
│        │                                                         │
│        ├─ Monorepo ────────────────────► pnpm(filter 强大)    │
│        │                                                         │
│        ├─ 磁盘空间紧张 ────────────────► pnpm(省 50%+)        │
│        │                                                         │
│        ├─ CI 速度敏感 ─────────────────► pnpm(快 2x)          │
│        │                                                         │
│        └─ 团队不想学新工具 ────────────► npm(零学习成本)      │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

12.2 场景推荐

场景 推荐 原因
新项目 pnpm 性能好、安全、现代
Monorepo pnpm filter 语法强大
老项目维护 保持现状 迁移有成本
开源项目 npm/pnpm npm 兼容性最好
企业项目 pnpm 磁盘省、速度快
学习/教程 npm 文档最多

12.3 总结对比

维度 npm yarn pnpm
安装速度 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
磁盘占用 ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐
安全性 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
Monorepo ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
兼容性 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
学习成本 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
社区生态 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐

最终建议

┌─────────────────────────────────────────────────────────────────┐
│                    2025 年推荐                                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   🥇 pnpm:新项目首选                                           │
│      • 性能最好,磁盘最省                                       │
│      • 解决幽灵依赖,更安全                                     │
│      • Monorepo 支持最强                                        │
│      • Vue、Vite 等主流项目都在用                               │
│                                                                  │
│   🥈 npm:兼容性优先                                            │
│      • Node.js 自带,零配置                                     │
│      • 文档最全,问题最好搜                                     │
│      • 开源项目贡献者友好                                       │
│                                                                  │
│   🥉 yarn:特定场景                                             │
│      • 已有 yarn 的老项目                                       │
│      • 需要 Plug'n'Play 的场景                                  │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

如果这篇文章对你有帮助,欢迎点赞收藏!有问题评论区见 🎉

当AI成为你的前端搭子:零门槛用Cursor开启高效开发新时代

作者 Mr_chiu
2025年12月30日 17:26

从项目初始化到代码优化,一个AI助手如何彻底改变前端开发的工作流

前言:为什么前端开发者需要Cursor?

前端开发正经历一场静悄悄的革命。过去几年,我们见证了从jQuery到三大框架的变迁,从手动配置Webpack到Vite的零配置体验,而现在,AI编程助手正在重新定义我们编写代码的方式。

作为一名前端开发者,你是否也曾面临这些痛点:

  • 纠结于某个复杂组件的实现方案
  • 花费大量时间调试诡异的样式兼容性问题
  • 在重复的业务代码中消耗创造力
  • 面对新技术栈的学习曲线感到焦虑

Cursor的出现,就像是为每个前端开发者配备了一个24小时在线的资深搭档。但不同于普通的代码补全工具,Cursor基于GPT-4,能够理解上下文、分析整个项目结构,并给出智能的解决方案。

一、Cursor初体验:从安装到第一个智能组件

1.1 安装与环境配置

Cursor的安装过程简单到令人惊讶:

# 访问官网下载对应版本
https://www.cursor.so/

# 或者通过Homebrew(macOS)
brew install --cask cursor

安装完成后,你会看到一个与VS Code极其相似的界面——实际上,Cursor正是基于VS Code的开源版本开发,这意味着所有你熟悉的VS Code快捷键和扩展生态基本都能无缝迁移。

重要设置:首次启动后,建议在设置中开启“自动应用建议”功能,这将大幅提升编码效率。

1.2 你的第一个AI辅助组件

让我们从一个实际的前端场景开始:创建一个可复用的表单输入组件。

传统的开发流程:

  1. 创建文件 InputField.jsx
  2. 编写基础结构
  3. 逐步添加功能
  4. 调试和优化

使用Cursor的流程发生了本质变化:

步骤1:用自然语言描述需求 直接在编辑器中输入:

创建一个React表单输入组件,要求:
1. 支持标签、占位符、错误状态
2. 包含输入验证功能
3. 有动画过渡效果
4. 支持自定义样式
5. 使用TypeScript

步骤2:让Cursor生成代码 按下 Cmd/Ctrl + K,Cursor会分析你的需求并生成完整代码:

import React, { useState, useEffect } from 'react';
import './InputField.css';

interface InputFieldProps {
  label: string;
  placeholder?: string;
  type?: 'text' | 'email' | 'password' | 'number';
  required?: boolean;
  error?: string;
  value: string;
  onChange: (value: string, isValid: boolean) => void;
  customClassName?: string;
  validationRules?: {
    pattern?: RegExp;
    minLength?: number;
    maxLength?: number;
    customValidator?: (value: string) => boolean;
  };
}

const InputField: React.FC<InputFieldProps> = ({
  label,
  placeholder = '',
  type = 'text',
  required = false,
  error: externalError,
  value,
  onChange,
  customClassName = '',
  validationRules = {}
}) => {
  const [internalError, setInternalError] = useState<string>('');
  const [isTouched, setIsTouched] = useState(false);
  const [isAnimating, setIsAnimating] = useState(false);

  const validateInput = (inputValue: string): string => {
    if (required && !inputValue.trim()) {
      return '此字段为必填项';
    }

    if (validationRules.pattern && !validationRules.pattern.test(inputValue)) {
      return '格式不正确';
    }

    if (validationRules.minLength && inputValue.length < validationRules.minLength) {
      return `长度不能少于${validationRules.minLength}个字符`;
    }

    if (validationRules.maxLength && inputValue.length > validationRules.maxLength) {
      return `长度不能超过${validationRules.maxLength}个字符`;
    }

    if (validationRules.customValidator && !validationRules.customValidator(inputValue)) {
      return '自定义验证失败';
    }

    return '';
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = e.target.value;
    const errorMsg = validateInput(newValue);
    setInternalError(errorMsg);
    
    // 触发动画
    if (errorMsg && !isAnimating) {
      setIsAnimating(true);
      setTimeout(() => setIsAnimating(false), 300);
    }
    
    onChange(newValue, !errorMsg);
  };

  const handleBlur = () => {
    setIsTouched(true);
    if (value) {
      const errorMsg = validateInput(value);
      setInternalError(errorMsg);
    }
  };

  const error = externalError || internalError;
  const showError = isTouched && error;

  return (
    <div className={`input-field ${customClassName} ${showError ? 'error' : ''} ${isAnimating ? 'shake' : ''}`}>
      <label className="input-label">
        {label}
        {required && <span className="required-asterisk">*</span>}
      </label>
      <input
        type={type}
        value={value}
        onChange={handleChange}
        onBlur={handleBlur}
        placeholder={placeholder}
        className={`input-element ${showError ? 'input-error' : ''}`}
        aria-invalid={!!showError}
        aria-describedby={showError ? `error-${label}` : undefined}
      />
      {showError && (
        <div 
          id={`error-${label}`}
          className="error-message animate-in"
          role="alert"
        >
          {error}
        </div>
      )}
    </div>
  );
};

export default InputField;

步骤3:让Cursor生成配套样式 继续使用 Cmd/Ctrl + K

为上面的组件生成合适的CSS样式,要求:
1. 使用现代CSS设计
2. 包含错误状态的视觉反馈
3. 实现摇动动画效果
4. 确保可访问性

Cursor会生成完整的CSS文件,甚至包括CSS变量和动画关键帧。

1.3 进阶功能:让Cursor重构和优化代码

假设我们需要为组件添加防抖功能,可以这样操作:

选中需要修改的函数,按下 Cmd/Ctrl + L 打开聊天框:

为handleChange函数添加防抖功能,300ms延迟

Cursor会自动重构代码:

import { debounce } from 'lodash-es';

// 在组件内部
const debouncedChangeHandler = useMemo(
  () => debounce((newValue: string, isValid: boolean) => {
    onChange(newValue, isValid);
  }, 300),
  [onChange]
);

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const newValue = e.target.value;
  const errorMsg = validateInput(newValue);
  setInternalError(errorMsg);
  
  if (errorMsg && !isAnimating) {
    setIsAnimating(true);
    setTimeout(() => setIsAnimating(false), 300);
  }
  
  // 使用防抖版本
  debouncedChangeHandler(newValue, !errorMsg);
};

二、Cursor的核心优势:前端开发的四大场景

2.1 智能代码补全与上下文理解

传统IDE的自动补全只能基于语法,而Cursor能基于整个项目上下文。例如,当你在Redux项目中输入:

// 你输入:创建一个action creator来处理用户登录
// Cursor会自动生成:
export const loginUser = (credentials) => async (dispatch) => {
  try {
    dispatch({ type: 'LOGIN_REQUEST' });
    const response = await api.post('/auth/login', credentials);
    localStorage.setItem('token', response.data.token);
    dispatch({ type: 'LOGIN_SUCCESS', payload: response.data.user });
    return response.data;
  } catch (error) {
    dispatch({ type: 'LOGIN_FAILURE', payload: error.message });
    throw error;
  }
};

2.2 零成本学习新技术栈

想尝试Svelte但不想花时间学习所有细节?直接告诉Cursor:

用Svelte创建一个可拖拽的任务看板组件,支持本地存储

2.3 调试与问题解决

遇到一个棘手的bug?直接把错误信息贴给Cursor:

我在Next.js项目中遇到这个错误:Hydration failed because the initial UI does not match what was rendered on the server
这是相关组件代码:[粘贴代码]
如何修复?

Cursor不仅能解释问题原因,还能给出具体的修复方案。

2.4 文档和测试生成

生成组件文档:

为InputField组件生成Markdown文档,包括props表格和使用示例

生成单元测试:

为InputField组件编写Jest测试用例,覆盖所有验证规则

三、实战演练:用Cursor快速搭建项目骨架

让我们看看如何用30分钟搭建一个现代React应用骨架:

  1. 项目初始化
使用Vite + React + TypeScript创建新项目,配置好ESLint、Prettier、Tailwind CSS和React Router
  1. 布局组件生成
创建一个响应式布局组件,包含导航栏、侧边栏和主内容区域
导航栏在移动端显示汉堡菜单
  1. 页面组件批量创建
创建以下页面组件:
- 首页:展示仪表盘
- 用户列表:带搜索和分页
- 设置页面:选项卡布局
- 404页面
  1. 状态管理配置
配置Zustand作为状态管理,创建用户和主题的store
  1. API层封装
创建统一的API请求工具,包含拦截器、错误处理和Loading状态管理

四、Cursor使用技巧:提升效率的秘籍

4.1 精准提问的艺术

低效提问:

如何做一个按钮?

高效提问:

创建一个React按钮组件,要求:
1. 支持primary、secondary、danger三种类型
2. 有loading状态和禁用状态
3. 支持图标和文本组合
4. 使用CSS-in-JS方案
5. 导出TypeScript类型定义

4.2 利用项目上下文

Cursor可以分析整个项目结构。在提问前,确保:

  1. 已经打开了相关文件
  2. 提到了重要的依赖项
  3. 说明了项目约束条件

4.3 链式调用

复杂任务可以分解:

第一步:创建一个用户模型接口
第二步:基于这个接口创建CRUD API函数
第三步:创建对应的React Hook封装
第四步:生成使用示例

五、潜在陷阱与最佳实践

5.1 需要谨慎对待的场景

  1. 安全性:永远不要让Cursor生成涉及敏感逻辑的代码(如认证、支付)
  2. 性能关键代码:算法优化等需要人工审查
  3. 业务复杂逻辑:AI可能不理解业务上下文

5.2 推荐的开发流程

需求分析 → 人工设计核心架构 → Cursor辅助实现细节 → 人工代码审查 → 测试验证

5.3 版本控制策略

建议:将所有Cursor生成的代码视为"初稿",经过审查修改后再提交。可以使用特殊的提交前缀:

git commit -m "cursor: 生成基础组件框架"
git commit -m "feat: 优化Cursor生成的表单组件"

结语:AI时代的前端开发者定位

Cursor不是要取代前端开发者,而是成为我们的"超能力扩展"。它让我们:

  1. 专注架构设计:从琐碎代码中解放出来
  2. 加速学习曲线:快速掌握新技术
  3. 提升代码质量:获得即时的最佳实践建议
  4. 激发创造力:尝试更多创新方案

记住:最强大的开发者不是会写所有代码的人,而是知道如何让AI写出更好代码的人。


预告:在下一篇中,我们将深入探讨如何用Cursor重构大型前端项目,包括:

  • 遗留代码的智能分析与重构
  • 技术栈迁移的自动化策略
  • 性能优化的AI辅助方案
  • 团队协作中Cursor的最佳实践

如果你对某个特定场景感兴趣,欢迎在评论区留言。让我们共同探索AI时代前端开发的新范式!

思考题:在你的当前项目中,哪个重复性最高的任务最适合让Cursor来协助完成?试着用它解决一个小问题,并在评论区分享你的体验!

超详细 Vue CLI 移动端预览插件实战:支持本地/TPGZ/NPM/Git 多场景使用(小白零基础入门)

作者 雅痞_yuppie
2025年12月30日 17:11

摘要

你是否还在为「移动端如何快速预览本地 Vue 开发项目」而烦恼?手动查找局域网 IP、输入端口号繁琐又容易出错?本文将围绕「实现移动端便捷预览本地服务项目」这一核心需求,手把手教你开发一款 Vue CLI 专属二维码插件。插件不仅能自动生成移动端可扫码的二维码,还完美支持本地调试、TGZ 压缩包、NPM 公开包、Git 仓库四种使用场景,同时附带解决插件开发中「二维码输出时机」的常见问题,代码可直接复制,小白也能轻松上手!

一、前言

在 Vue 项目开发过程中,「移动端预览本地服务」是高频刚需:开发者需要在手机上验证页面适配、交互效果等,但传统方式需要手动查询电脑局域网 IP、记录项目端口,再在手机浏览器中输入地址,步骤繁琐且容易出错(如 IP 输入错误、端口冲突等)。

本文的核心目标是:开发一款 Vue CLI 插件,通过「自动生成可视化二维码」的方式,让手机扫码即可快速访问本地 Vue 服务,同时支持本地调试、TGZ 包、NPM 包、Git 包四种使用场景,满足个人开发、团队协作、全网复用等不同需求,同时解决开发过程中「二维码输出时机」的常见问题。

二、核心需求与插件优势

1. 核心需求

  • 核心功能:自动获取电脑局域网 IP + 项目端口,生成移动端可扫码的二维码,扫码即可访问本地 Vue 服务;
  • 多场景使用:支持本地调试(未发布插件)、TGZ 压缩包(私有部署)、NPM 公开包(全网复用)、Git 仓库(团队协作)四种引入方式;
  • 兼容性强:支持 Vue 2/Vue 3 项目,适配 Vue CLI 3+ 所有版本;
  • 无需手动配置:插件自动开启局域网访问,无需用户修改 vue.config.js
  • 细节优化:解决二维码输出时机问题,确保使用体验流畅,避免日志混乱或输出过晚。

2. 插件核心优势

优势点 具体说明
便捷性 扫码访问,无需手动输入 IP + 端口,零出错
多场景兼容 支持本地/TPGZ/NPM/Git 四种使用方式
无侵入性 不修改项目原有代码,卸载后无残留
自动配置 自动开启 host: 0.0.0.0,支持局域网访问
细节优化 解决二维码输出时机问题,日志整洁美观

三、第一步:插件开发(核心功能实现,支持移动端扫码预览)

1. 插件目录规范(Vue CLI 要求,必须遵守)

Vue CLI 插件必须以 vue-cli-plugin-xxx 命名,否则无法被项目自动识别,执行命令创建目录:

# 创建插件目录并进入(小白直接复制终端执行)
mkdir vue-cli-plugin-vue-mobile-preview && cd vue-cli-plugin-vue-mobile-preview

2. 初始化 package.json(配置插件信息与依赖)

在插件目录下执行 npm init -y 快速生成配置文件,再手动替换为以下内容(确保依赖与兼容性):

{
  "name": "vue-cli-plugin-vue-mobile-preview",
  "version": "1.0.0",
  "description": "Vue CLI 3+ 插件:自动生成二维码,支持移动端扫码预览本地Vue服务,兼容本地/TGZ/NPM/Git多场景",
  "main": "index.js",
  "keywords": [
    "vue",
    "vue-cli",
    "mobile preview",
    "qrcode",
    "local service",
    "TGZ",
    "NPM",
    "Git"
  ],
  "dependencies": {
    "qrcode": "^1.5.3" // 核心依赖:用于生成终端可视化二维码
  },
  "peerDependencies": {
    "@vue/cli-service": ">=3.0.0" // 兼容 Vue CLI 3+ 所有版本
  },
  "author": "你的姓名",
  "license": "MIT"
}
  • 关键说明:dependencies 中声明 qrcode 依赖,用户安装插件后无需手动安装;peerDependencies 确保插件与 Vue CLI 版本兼容。

3. 编写核心 index.js(实现移动端扫码预览核心功能)

这是插件的核心文件,实现「自动获取局域网 IP + 项目端口 + 生成二维码」的核心功能,同时解决「二维码输出时机」问题,代码含详细注释,小白可直接复制:

// 引入核心依赖
const QRCode = require('qrcode');
const os = require('os'); // Node.js 原生模块,无需额外安装,用于获取局域网IP

/**
 * 核心功能1:自动获取本机局域网 IPv4 地址(移动端访问需要该 IP)
 * @returns {string} 有效局域网IP | 兜底本地回环地址(127.0.0.1)
 */
function getLocalLanIp() {
  const networkInterfaces = os.networkInterfaces();
  // 遍历所有网卡接口,筛选符合条件的局域网 IP
  for (const ifaceName in networkInterfaces) {
    const ifaceList = networkInterfaces[ifaceName];
    for (const iface of ifaceList) {
      // 过滤条件:IPv4 协议、非本地回环地址、非虚拟内网地址
      if (iface.family === 'IPv4' && !iface.internal && iface.address !== '127.0.0.1') {
        return iface.address; // 返回有效局域网 IP
      }
    }
  }
  // 兜底:未获取到局域网 IP 时,返回本地回环地址(仅本机可访问,移动端无法扫码)
  return '127.0.0.1';
}

/**
 * 核心功能2:生成并打印移动端可扫码的二维码(核心逻辑)
 * @param {string} lanIp 局域网 IP
 * @param {number} port 项目端口
 */
function printMobileQrcode(lanIp, port) {
  // 构造移动端访问地址(核心:局域网 IP + 项目端口,手机扫码即可访问)
  const mobileAccessUrl = `http://${lanIp}:${port}`;
  // 构造本机访问地址(用于提示开发者)
  const localAccessUrl = `http://localhost:${port}`;

  // 打印醒目提示信息,区分日志层级
  console.log('\n=====================================================');
  console.log('🎉 [vue-cli-plugin-vue-mobile-preview] 移动端预览二维码已生成!');
  console.log(`🔧  本机访问地址:${localAccessUrl}`);
  console.log(`🌐  移动端访问地址:${mobileAccessUrl}`);
  console.log('📱  扫码提示:手机与电脑连接同一 WiFi,打开相机/微信扫一扫即可预览!\n');

  // 生成终端可视化二维码
  QRCode.toString(mobileAccessUrl, {
    type: 'terminal', // 输出到终端
    margin: 1, // 二维码边距(紧凑美观)
    small: false // 显示大尺寸二维码,提高手机扫码成功率
  }, (err, qrCodeStr) => {
    if (!err) {
      console.log(qrCodeStr); // 打印二维码
    } else {
      // 异常捕获:二维码生成失败时,输出错误信息但不影响项目运行
      console.error('❌ 二维码生成失败:', err.message);
      console.log('💡 替代方案:手动在手机浏览器输入地址 -> ', mobileAccessUrl);
    }
    console.log('=====================================================\n');
  });
}

/**
 * 辅助变量:标记是否已打印二维码(避免热更新时重复输出)
 */
let hasPrintQrcode = false;

/**
 * Vue CLI 插件核心导出函数(入口逻辑)
 * @param {Object} api Vue CLI 核心 API
 * @param {Object} options 项目的 vue.config.js 配置项
 */
module.exports = (api, options) => {
  api.chainWebpack((config) => {
    // 核心配置:强制设置 host: 0.0.0.0,允许局域网设备(手机)访问本地服务
    // 无需用户手动修改 vue.config.js,插件自动配置
    config.devServer.host('0.0.0.0');

    // 解决二维码输出时机问题:使用 compiler.hooks.done 事件(项目完全就绪后输出,避免日志混乱)
    // 这是插件开发中的细节优化,不影响核心的移动端预览功能
    config.plugin('mobile-preview-qrcode-plugin').use(class MobilePreviewPlugin {
      apply(compiler) {
        // compiler.hooks.done:Webpack 编译(首次构建/热更新)完成后触发
        compiler.hooks.done.tap('MobilePreviewPlugin', () => {
          // 仅在首次构建完成后打印一次二维码,避免热更新时重复输出
          if (hasPrintQrcode) {
            return;
          }

          // 获取项目端口(优先使用用户自定义端口,默认 8080)
          const devServerConfig = options.devServer || {};
          const projectPort = devServerConfig.port || 8080;
          // 获取局域网 IP
          const localLanIp = getLocalLanIp();
          // 生成并打印移动端预览二维码
          printMobileQrcode(localLanIp, projectPort);

          // 标记已打印二维码
          hasPrintQrcode = true;
        });
      }
    });
  });
};

4. 插件最终目录结构(小白核对,确保无误)

插件目录仅需 2 个核心文件,简洁易维护,结构如下:

vue-cli-plugin-vue-mobile-preview/
├── index.js       # 核心逻辑文件(实现移动端扫码预览+时机优化)
└── package.json   # 插件配置文件(依赖+兼容性配置)

四、第二步:多场景使用教程(核心重点,覆盖所有使用场景)

插件开发完成后,支持 4 种使用场景,满足不同开发需求(个人调试、团队协作、全网复用等),步骤详细,小白可按需选择。

场景1:本地调试(个人开发,未发布插件,快速验证功能)

适用于插件开发完成后,个人在本地 Vue 项目中验证功能,无需发布到任何仓库。

前置准备

  • 插件目录与目标 Vue 项目同级(方便关联),目录结构示例:
├── vue-cli-plugin-vue-mobile-preview/  # 插件目录
└── my-vue-project/                # 目标 Vue 项目(需要移动端预览的项目)
    ├── src/
    ├── package.json
    └── ... 其他项目文件

操作步骤

  1. 进入目标 Vue 项目根目录,执行以下命令关联本地插件:

    # npm 命令(兼容所有环境,推荐小白使用)
    npm install file:../vue-cli-plugin-vue-mobile-preview --save-dev
    
    # 若使用 pnpm,执行此命令
    # pnpm add file:../vue-cli-plugin-vue-mobile-preview -D
    
    # 若使用 yarn,执行此命令
    # yarn add file:../vue-cli-plugin-vue-mobile-preview --dev
    
    • 路径说明:../vue-cli-plugin-vue-mobile-preview 是插件的相对路径,若目录层级不同可调整(如 ../../xxx)。
  2. 验证插件安装成功: 查看目标项目的 package.json 文件,若 devDependencies 中出现以下配置,说明关联成功:

    "devDependencies": {
      "vue-cli-plugin-vue-mobile-preview": "file:../vue-cli-plugin-vue-mobile-preview"
    }
    
  3. 启动项目,验证移动端预览功能: 在目标项目根目录执行启动命令:

    # Vue CLI 3+ 通用命令
    npm run serve
    
    # 兼容命令:npm run dev(部分项目配置别名)
    
  4. 移动端扫码预览:

    • 终端最后会输出二维码(带醒目提示);
    • 确保手机与电脑连接同一 WiFi(同一局域网);
    • 打开手机相机/微信/支付宝「扫一扫」,扫描终端二维码,即可快速预览本地 Vue 项目。
  5. 本地插件卸载(若无需使用):

    # npm 命令
    npm uninstall vue-cli-plugin-vue-mobile-preview
    
    # pnpm/yarn 命令对应:pnpm remove / yarn remove
    

场景2:TGZ 压缩包使用(私有部署,团队内部复用,无需发布 NPM)

适用于插件无需公开,仅在公司/团队内部复用,可上传到私有服务器或共享文件夹供团队成员使用。

操作步骤

  1. 插件打包为 TGZ 压缩包: 进入插件目录,执行以下命令打包:

    # 进入插件目录
    cd vue-cli-plugin-vue-mobile-preview
    
    # 打包生成 TGZ 压缩包(npm pack 是 npm 原生命令,无需额外安装工具)
    npm pack
    
    • 打包成功后,插件目录同级会生成 vue-cli-plugin-vue-mobile-preview-1.0.0.tgz 文件(文件名格式:插件名称+版本号);
    • 该压缩包包含插件所有核心文件,可直接用于安装。
  2. 分发 TGZ 压缩包: 将生成的 TGZ 文件分发到团队成员(如上传到公司私有服务器、共享网盘、GitLab 私有仓库附件等),获取可访问的路径/地址(示例):

    • 本地共享:/Users/xxx/Shared/vue-cli-plugin-vue-mobile-preview-1.0.0.tgz
    • 私有服务器:https://xxx.company.com/plugins/vue-cli-plugin-vue-mobile-preview-1.0.0.tgz
  3. 项目中安装 TGZ 插件: 进入目标 Vue 项目根目录,执行以下命令安装(根据 TGZ 地址类型选择):

    # 方式1:安装本地/共享文件夹中的 TGZ 包
    npm install file:/Users/xxx/Shared/vue-cli-plugin-vue-mobile-preview-1.0.0.tgz --save-dev
    
    # 方式2:安装私有服务器上的 TGZ 包(HTTPS 地址)
    npm install https://xxx.company.com/plugins/vue-cli-plugin-vue-mobile-preview-1.0.0.tgz --save-dev
    
    # pnpm/yarn 命令兼容,只需替换 npm install 为 pnpm add / yarn add
    
  4. 验证移动端预览功能: 执行 npm run serve 启动项目,后续步骤同「本地调试」,手机扫码即可预览。

场景3:NPM 公开包使用(全网复用,开源共享)

适用于插件功能成熟,需要公开给全网开发者使用,发布到 npm 公开仓库。

操作步骤

  1. 前置准备:

    • 拥有 npm 账号(未注册可前往 npm 官网 注册);
    • 切换到 npm 官方源(若配置了国内镜像源,小白执行以下命令):
    npm config set registry https://registry.npmjs.org/
    
  2. 登录 npm 账号: 进入插件目录,执行登录命令,按提示输入用户名、密码、邮箱:

    # 进入插件目录
    cd vue-cli-plugin-vue-mobile-preview
    
    # 登录 npm 账号
    npm login
    
  3. 发布插件到 npm 公开仓库: 在插件目录下执行发布命令,无需额外配置:

    npm publish
    
    • 发布失败排查:若提示包名重复,修改 package.json 中的 name 字段;若提示版本重复,修改 version 字段(如从 1.0.0 改为 1.0.1)。
  4. 项目中安装 NPM 插件: 在任意 Vue CLI 3+ 项目根目录,执行以下命令即可安装(全网开发者均可使用):

    # 方式1:npm 命令(推荐)
    npm install vue-cli-plugin-vue-mobile-preview --save-dev
    
    # 方式2:Vue CLI 专属快捷命令(自动识别插件,无需加 --save-dev)
    vue add vue-mobile-preview
    
    # pnpm/yarn 命令兼容
    # pnpm add vue-cli-plugin-vue-mobile-preview -D
    # yarn add vue-cli-plugin-vue-mobile-preview --dev
    
  5. 验证移动端预览功能: 执行 npm run serve 启动项目,手机扫码即可预览本地 Vue 项目。

场景4:Git 仓库使用(团队协作,无需发布 NPM,支持版本控制)

适用于插件托管在 Git 仓库(GitHub/Gitee/GitLab),团队成员可直接通过 Git 地址安装,支持版本控制和代码同步。

操作步骤

  1. 插件目录初始化 Git 仓库: 进入插件目录,执行以下命令初始化并提交代码:

    # 进入插件目录
    cd vue-cli-plugin-vue-mobile-preview
    
    # 初始化 Git 仓库
    git init
    
    # 添加所有文件
    git add .
    
    # 提交代码
    git commit -m "init: 完成移动端预览插件开发,支持扫码访问"
    
  2. 推送插件到线上 Git 仓库: 在 GitHub/Gitee/GitLab 上创建一个新仓库,然后将本地插件代码推送上去(以 GitHub 为例):

    # 关联线上 Git 仓库(替换为你的仓库地址)
    git remote add origin https://github.com/你的用户名/vue-cli-plugin-vue-mobile-preview.git
    
    # 推送代码到 master/main 分支
    git push -u origin master
    
  3. 项目中通过 Git 地址安装插件: 进入目标 Vue 项目根目录,执行以下命令安装(支持 HTTPS/SSH 两种地址格式):

    # 方式1:HTTPS 地址(无需配置密钥,小白推荐使用,公开/私有仓库均可)
    npm install https://github.com/你的用户名/vue-cli-plugin-vue-mobile-preview.git --save-dev
    
    # 方式2:SSH 地址(需配置 Git 密钥,推荐团队私有仓库使用)
    npm install git+ssh://git@github.com/你的用户名/vue-cli-plugin-vue-mobile-preview.git --save-dev
    
    # 方式3:GitHub 专属简化格式(自动识别)
    npm install 你的用户名/vue-cli-plugin-vue-mobile-preview --save-dev
    
    # pnpm/yarn 命令兼容
    
  4. 验证移动端预览功能: 执行 npm run serve 启动项目,手机扫码即可预览本地 Vue 项目,同时可通过 Git 仓库实现插件版本更新和团队同步。

五、插件开发细节优化(解决二维码输出时机问题)

这是插件开发过程中的常见问题,不影响核心的移动端预览功能,但可优化使用体验,小白可直接复用代码,无需深入理解。

1. 问题描述

若使用 devServer.after 钩子输出二维码,会出现「二维码输出后还有 Webpack 编译日志」的问题,导致日志混乱;若提前输出,会被构建日志覆盖,影响扫码体验。

2. 解决方案

使用 compiler.hooks.done 事件,在 Webpack 编译(首次构建)完成后输出二维码,同时添加 hasPrintQrcode 标记,避免热更新时重复输出,代码已集成在插件核心 index.js 中,无需额外修改。

3. 优化效果

  • 二维码在项目完全就绪后输出,日志整洁美观,无多余信息干扰;
  • 仅输出一次二维码,避免热更新时重复打印,提升使用体验。

六、常见问题排查(小白避坑指南)

1. 移动端扫码无法访问本地项目

  • 排查1:手机与电脑是否连接同一 WiFi(同一局域网);
  • 排查2:电脑防火墙是否关闭(防火墙可能拦截手机的访问请求);
  • 排查3:项目端口是否被占用(更换端口后重新启动项目,如在 vue.config.js 中配置 devServer.port: 8081);
  • 排查4:插件是否自动配置 host: 0.0.0.0(本文插件已自动配置,无需手动修改)。

2. 插件安装后无二维码输出

  • 排查1:插件名称是否以 vue-cli-plugin- 开头(Vue CLI 仅自动识别该前缀);
  • 排查2:是否重启项目(插件安装后需重启项目才能生效);
  • 排查3:目标项目 package.json 中是否存在插件依赖(确认安装成功);
  • 排查4:是否安装 qrcode 依赖(插件已声明依赖,若缺失可手动执行 npm install qrcode --save-dev)。

3. 二维码重复输出(热更新时)

  • 排查:是否添加 hasPrintQrcode 标记(本文插件已添加,无需额外修改)。

4. TGZ/Git 插件安装失败

  • 排查1:地址是否正确(TGZ 地址需指向 .tgz 文件,Git 地址需带 .git 后缀);
  • 排查2:网络是否通畅(私有服务器/Git 仓库是否可正常访问);
  • 排查3:包管理器版本是否兼容(建议使用 npm 8+ / pnpm 6+ / yarn 1+)。

七、总结

  1. 核心目标达成:本文围绕「移动端便捷预览本地 Vue 服务项目」这一核心需求,开发了一款 Vue CLI 插件,实现了「自动生成二维码 + 扫码快速访问」的核心功能,解决了手动输入 IP + 端口的繁琐问题;
  2. 多场景全覆盖:详细讲解了插件的 4 种使用场景(本地调试、TGZ 压缩包、NPM 公开包、Git 仓库),满足个人开发、团队协作、全网复用等不同需求;
  3. 小白友好:所有代码可直接复制,步骤详细无遗漏,无需复杂配置,同时提供常见问题排查指南,降低上手门槛;
  4. 细节优化到位:附带解决插件开发中「二维码输出时机」的问题,优化使用体验,确保日志整洁、无重复输出;
  5. 兼容性强:支持 Vue 2/Vue 3 项目,适配 Vue CLI 3+ 所有版本,无侵入性,卸载后无残留。

通过本文,小白也能轻松开发并使用 Vue CLI 移动端预览插件,实现多场景下的移动端便捷预览,提升 Vue 项目开发效率!

react hook注意事项

2025年12月30日 17:06

写代码出错了,很少深入了解 hook。让ai给总结了一下。

这确实是 React 中非常核心且容易被忽视的“铁律”。React 的很多设计理念与传统的命令式编程(如 jQuery)或基于类的编程(Vue Options API)不太一样,因此有很多类似的“潜规则”和陷阱。

以下为您整理了 React 开发中必须知道的 6 个核心注意事项,掌握这些可以帮您避开 90% 的 Bug:

1. useEffect 依赖数组不能“撒谎” (Stale Closure / 闭包陷阱)

这是 Hook 最容易出 Bug 的地方。如果你在 useEffectuseCallback 内部使用了某个变量,但没有把它加到依赖数组 [] 中,Hook 内部就会一直引用旧的变量值

  • ❌ 错误写法:

    const [count, setCount] = useState(0);
    
    useEffect(() => {
      const timer = setInterval(() => {
        console.log(count); // 永远打印 0!因为这里的 count 是闭包捕获的初始值
      }, 1000);
      return () => clearInterval(timer);
    }, []); // 依赖数组是空的,导致 effect 只运行一次,内部永远拿着旧的 count
    
  • ✅ 正确写法:

    useEffect(() => {
      const timer = setInterval(() => {
        console.log(count);
      }, 1000);
      return () => clearInterval(timer);
    }, [count]); // 依赖 count,每次 count 变化,effect 会销毁旧的并重新运行
    

    或者使用函数式更新(如果不依赖外部变量触发 Effect):

    setCount(prevCount => prevCount + 1); // 这种写法不需要依赖 count
    

2. State 更新是“异步”的(快照特性)

调用 setState 后,状态不会立即改变。React 会把更新放入队列,等到下一次渲染时才会生效。

  • ❌ 常见误区:

    const handleClick = () => {
      setCount(count + 1);
      console.log(count); // 打印的仍然是旧值!不是 +1 后的值
    
      // 如果此时发请求,发出去的也是旧值
      fetchData(count); 
    };
    
  • ✅ 正确理解:

    如果你需要使用更新后的值,应该使用 useEffect 监听该值的变化,或者在 setState 中使用回调函数(仅用于计算新值)。

    useEffect(() => {
      console.log(count); // 这里才能拿到更新后的值
      fetchData(count);
    }, [count]);
    

3. 永远不要直接修改 State (Immutability / 不可变性)

React 比较状态是否变化是基于引用比较(Shallow Compare)。如果你直接修改对象属性,引用地址没变,React 就不知道数据变了,页面就不会刷新。

  • ❌ 错误写法:

    const [user, setUser] = useState({ name: 'Alice', age: 18 });
    
    const updateName = () => {
      user.name = 'Bob'; // 修改了内容,但对象引用没变
      setUser(user);     // React 认为前后是同一个对象,不触发重新渲染
    };
    
  • ✅ 正确写法:

    const updateName = () => {
      // 创建一个新对象(复制旧属性 + 覆盖新属性)
      setUser({ ...user, name: 'Bob' }); 
    };
    

4. 列表渲染必须有唯一的 key(且尽量不要用 index)

key 是 React 识别 DOM 节点的身份证。如果使用数组索引(index)作为 key,当列表顺序发生变化(插入、删除、排序)时,React 会复用错误的 DOM 节点,导致状态错乱。

  • ❌ 风险写法:

    // 如果列表顺序会变,千万别用 index
    {items.map((item, index) => <li key={index}>{item.name}</li>)}
    
  • ✅ 正确写法:

    // 使用数据中的唯一 ID
    {items.map((item) => <li key={item.id}>{item.name}</li>)}
    

5. useEffect 的清理函数 (Cleanup Function)

如果在 useEffect 中订阅了事件、设置了定时器或发起了请求,必须返回一个清理函数,否则会导致内存泄漏或逻辑错误(比如组件卸载了但定时器还在跑)。

  • ✅ 标准模板:

    useEffect(() => {
      // 1. 设置副作用
      const handleResize = () => console.log(window.innerWidth);
      window.addEventListener('resize', handleResize);
    
      // 2. 返回清理函数
      return () => {
        window.removeEventListener('resize', handleResize);
      };
    }, []);
    

6. React Strict Mode 下的“重复执行”

在开发环境(Development)中,为了帮你检查不纯的副作用,React 的 <StrictMode> 会故意将你的组件渲染两次useEffect 也会执行两次。

  • 现象:你看控制台日志,发现 API 请求发了两次,或者 console.log 打印了两次。
  • 注意:这不是 Bug,生产环境(Production)不会发生。不要为了解决这个问题去写奇怪的代码,而应该确保你的 Effect 是可以重复执行且无害的(做好清理工作)。

总结建议

您刚遇到的 “Hook 顺序与数量必须一致” 是 React 运行机制的物理限制(React 内部是靠数组索引来记录每个 Hook 的状态的,如果顺序乱了,状态就张冠李戴了)。

而上面提到的这 6 点,更多是逻辑和模式上的注意事项。掌握了这些,您写 React 就会非常有底气了。

JavaScript 闭包实战:手写防抖与节流函数,优化高频事件性能

2025年12月30日 16:55

在前端开发中,我们经常会遇到高频触发的事件,比如:

  • 输入框 keyup 时的搜索建议(类似百度、VS Code 的智能补全)
  • 窗口 resize 时的布局重新计算
  • 页面滚动时的懒加载或返回顶部按钮显示
  • 鼠标 mousemove 时的拖拽预览

这些事件往往在短时间内被触发数百甚至上千次,如果每次都直接执行复杂的逻辑(如发 AJAX 请求、操作 DOM、计算布局),会严重消耗浏览器资源,导致页面卡顿、掉帧,甚至崩溃。

解决这类问题的核心方案就是函数防抖(debounce)函数节流(throttle) ,而它们的实现都离不开 JavaScript 的灵魂特性——闭包

本文将从实际场景出发,详细讲解防抖和节流的原理、区别、手动实现,并提供完整可运行的 HTML 示例,帮助你彻底掌握这一前端性能优化的必备技能。

什么是闭包?为什么能用于防抖节流?

闭包是指函数能够“记住”并访问其词法作用域中的变量,即使函数在外部作用域之外执行。

在防抖和节流中,我们需要:

  • 保存定时器 ID(用于清除或判断时间)
  • 记住上一次执行的时间戳
  • 保留正确的 this 指向和参数

这些变量必须在多次事件触发间“存活”下来,而不能每次都重新创建——这正是闭包的用武之地。

场景一:搜索输入框的 AJAX 请求优化

用户在搜索框输入关键词时,我们希望实时显示搜索建议(如百度输入“react”时下方出现的建议列表)。

如果不做任何处理,每次 keyup 都立即发送请求:

  • 用户输入“react”五个字符 → 触发 5 次请求
  • 网络开销大、服务器压力大
  • 用户体验差(快速输入时建议闪烁)

理想效果是:用户停止输入 500ms 后,才发送一次请求。

这正是防抖的典型应用场景。

函数防抖(debounce)原理与实现

防抖的核心思想:不管事件触发多少次,我只关心最后一次。在最后一次触发后的 delay 时间内如果没有新触发,才真正执行函数。

JavaScript

function debounce(fn, delay) {
  let timer = null; // 闭包中保存定时器 ID

  return function(...args) {
    const context = this;

    // 每次触发时,先清除上一次的定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 重新设置定时器
    timer = setTimeout(() => {
      fn.apply(context, args);
      timer = null; // 执行完可可选清理
    }, delay);
  };
}

关键点解析:

  • timer 变量定义在 debounce 函数作用域中,被返回的函数“记住”(闭包)。
  • 每次事件触发都清除旧定时器,重新开始倒计时。
  • 只有在 delay 时间内没有新触发时,定时器才会执行 fn。
  • 使用 apply 保留正确的 this 和参数。

函数节流(throttle)原理与实现

节流的核心思想:在规定时间内,无论触发多少次,只执行一次。常用于限制执行频率。

典型场景:页面滚动时加载更多内容(scroll 事件),我们希望每 500ms 最多检查一次是否到达底部。

JavaScript

function throttle(fn, delay) {
  let last = 0; // 闭包中记录上次执行时间

  return function(...args) {
    const context = this;
    const now = Date.now();

    // 如果距离上次执行不足 delay,则不执行
    if (now - last < delay) {
      return;
    }

    // 执行并更新 last
    last = now;
    fn.apply(context, args);
  };
}

更常见的时间戳 + 定时器混合版(支持尾部执行):

JavaScript

function throttle(fn, delay) {
  let last = 0;
  let timer = null;

  return function(...args) {
    const context = this;
    const now = Date.now();

    // 如果还在冷却期,且没有定时器(避免重复设置)
    if (now - last < delay) {
      clearTimeout(timer);
      timer = setTimeout(() => {
        last = now;
        fn.apply(context, args);
      }, delay);
    } else {
      // 立即执行(领先执行)
      last = now;
      fn.apply(context, args);
    }
  };
}

这种实现兼顾了“固定频率执行”和“停止触发后仍能执行最后一次”。

防抖 vs 节流:如何选择?

特性 防抖 (debounce) 节流 (throttle)
执行时机 事件停止触发后 delay 时间执行一次 每隔 delay 时间执行一次
典型场景 搜索输入、表单提交验证 滚动加载、鼠标跟随、射击游戏射速
用户体验 等待用户“想好了”再响应 持续操作时保持流畅响应
实现复杂度 相对简单(setTimeout) 稍复杂(时间戳或定时器混合)

记忆口诀:

  • 需要“最后一次”执行 → 用防抖(如搜索)
  • 需要“持续但限频”执行 → 用节流(如滚动)

完整可运行示例

下面是一个完整的 HTML 文件,包含三个输入框:

  • 第一个:无优化,每次 keyup 都发请求
  • 第二个:防抖优化,停止输入 500ms 后发一次请求
  • 第三个:节流优化,每 500ms 最多发一次请求

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>
  <style>
    body { font-family: Arial, sans-serif; padding: 20px; }
    input { display: block; margin: 20px 0; padding: 10px; width: 300px; font-size: 16px; }
    label { font-weight: bold; }
  </style>
</head>
<body>
  <div>
    <label>无优化(每次输入都请求)</label>
    <input type="text" id="undebounce" placeholder="快速输入观察控制台" />

    <label>防抖(停止输入500ms后请求)</label>
    <input type="text" id="debounce" placeholder="输入完成后才会请求" />

    <label>节流(每500ms最多请求一次)</label>
    <input type="text" id="throttle" placeholder="持续输入时会定期请求" />
  </div>

  <script>
    function ajax(content) {
      console.log('ajax request:', content);
    }

    // 防抖实现
    function debounce(fn, delay) {
      let timer = null;
      return function(...args) {
        const context = this;
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => {
          fn.apply(context, args);
        }, delay);
      };
    }

    // 节流实现(时间戳 + 定时器混合版)
    function throttle(fn, delay) {
      let last = 0;
      let timer = null;
      return function(...args) {
        const context = this;
        const now = Date.now();
        if (now - last < delay) {
          clearTimeout(timer);
          timer = setTimeout(() => {
            last = now;
            fn.apply(context, args);
          }, delay);
        } else {
          last = now;
          fn.apply(context, args);
        }
      };
    }

    const inputA = document.getElementById('undebounce');
    const inputB = document.getElementById('debounce');
    const inputC = document.getElementById('throttle');

    const debouncedAjax = debounce(ajax, 500);
    const throttledAjax = throttle(ajax, 500);

    inputA.addEventListener('keyup', function(e) {
      ajax(e.target.value);
    });

    inputB.addEventListener('keyup', function(e) {
      debouncedAjax(e.target.value);
    });

    inputC.addEventListener('keyup', function(e) {
      throttledAjax(e.target.value);
    });
  </script>
</body>
</html>

打开浏览器控制台,分别在三个输入框快速输入,你会清晰看到三者的巨大差异。

现代框架中的应用

虽然原生 JS 需要手写,但现代框架/库已内置:

  • Lodash:_.debounce(fn, wait) 和 _.throttle(fn, wait)
  • Vue:@input.debounce="500ms"
  • React:可配合 useCallback + useRef 实现,或使用第三方如 use-debounce

但理解底层原理,能让你在复杂场景下自定义行为(如立即执行选项、取消功能等)。

最佳实践建议

  1. 搜索输入 → 防抖(节约资源,用户输入完成后响应)
  2. 滚动事件 → 节流(保持流畅)
  3. 按钮防止重复点击 → 防抖(delay 设为 1000ms)
  4. resize/scroll 计算复杂布局 → 节流
  5. 拖拽过程中实时预览 → 节流

结语

闭包是 JavaScript 最强大的特性之一,而防抖和节流则是它在性能优化领域最经典的应用体现。

通过合理使用防抖和节流,我们可以:

  • 大幅减少不必要的网络请求和计算
  • 提升页面响应速度和流畅度
  • 改善用户体验
  • 降低服务器压力

无论你是面试被问“手写防抖节流”,还是实际项目中遇到卡顿问题,这两个函数都是你工具箱中不可或缺的利器。

建议立即复制上面的完整示例到本地运行,亲身体验三者的差异——理论结合实践,你才能真正掌握。

前端性能优化,从理解闭包开始,从手写防抖节流起步。愿你的页面永远丝滑流畅!

react学习15:基于 React Router 实现 keepalive

2025年12月30日 16:47

当路由切换的时候,react router 会销毁之前路由的组件,然后渲染新的路由对应的组件。

在一些场景下,这样是有问题的。

比如移动端很多长列表,用户划了很久之后,点击某个列表项跳到详情页,之后又跳回来,但是这时候列表页的组件销毁重新创建,又回到了最上面。

比如移动端填写了某个表单,有的表单需要跳到别的页面获取数据,然后跳回来,跳回来发现组件销毁重新创建,之前填的都没了。

类似这种场景,就需要路由切换的时候不销毁组件,也就是 keepalive。

我们先复现下这个场景:

npx create-vite

选择 react + typescript 创建项目。

安装 react-router:

npm i --save react-router-dom

在 App.tsx 写下路由:

import { useState } from 'react';
import {  Link, useLocation, RouterProvider, createBrowserRouter, Outlet } from 'react-router-dom';

const Layout = () => {
    const { pathname } = useLocation();

    return (
        <div>
            <div>当前路由: {pathname}</div>
            <Outlet/>
        </div>
    )
}

const Aaa = () => {
    const [count, setCount] = useState(0);

    return <div>
      <p>{count}</p>
      <p>
        <button onClick={() => setCount(count => count + 1)}>加一</button>
      </p>
      <Link to='/bbb'>去 Bbb 页面</Link><br/>
      <Link to='/ccc'>去 Ccc 页面</Link>
    </div>
};

const Bbb = () => {
    const [count, setCount] = useState(0);

    return <div>
      <p>{count}</p>
      <p><button onClick={() => setCount(count => count + 1)}>加一</button></p>
      <Link to='/'>去首页</Link>
    </div>
};

const Ccc = () => {
    return <div>
      <p>ccc</p>
      <Link to='/'>去首页</Link>
    </div>
};

const routes = [
  {
    path: "/",
    element: <Layout></Layout>,
    children: [
      {
        path: "/",
        element: <Aaa></Aaa>,
      },
      {
        path: "/bbb",
        element: <Bbb></Bbb>
      },
      {
        path: "/ccc",
        element: <Ccc></Ccc>
      }
    ]
  }
];

export const router = createBrowserRouter(routes);

const App = () => {
    return <RouterProvider router={router}/>
}

export default App;

这里有 /、/bbb、/ccc 这三个路由。

一级路由渲染 Layout 组件,里面通过 Outlet 指定渲染二级路由的地方。

二级路由 / 渲染 Aaa 组件,/bbb 渲染 Bbb 组件,/ccc 渲染 Ccc 组件。

这里的 Outlet 组件,也可以换成 useOutlet,效果一样:

image.png

image.png

默认路由切换,对应的组件就会销毁。

我们有时候不希望切换路由时销毁页面组件,也就是希望能实现 keepalive。

怎么做呢?

其实很容易想到,我们把所有需要 keepalive 的组件保存到一个全局对象。

然后渲染的时候把它们都渲染出来,路由切换只是改变显示隐藏。

按照这个思路来写一下:

新建 KeepAliveLayout.tsx:

import React, { createContext, useContext } from 'react';
import { useOutlet, useLocation, matchPath } from 'react-router-dom'
import type { FC, PropsWithChildren, ReactNode } from 'react';

interface KeepAliveLayoutProps extends PropsWithChildren{
    keepPaths: Array<string | RegExp>;
    keepElements?: Record<string, ReactNode>;
    dropByPath?: (path: string) => void;
}

type KeepAliveContextType = Omit<Required<KeepAliveLayoutProps>, 'children'>;

const keepElements: KeepAliveContextType['keepElements'] = {};

export const KeepAliveContext = createContext<KeepAliveContextType>({
    keepPaths: [],
    keepElements,
    dropByPath(path: string) {
        keepElements[path] = null;
    }
});

const isKeepPath = (keepPaths: Array<string | RegExp>, path: string) => {
    let isKeep = false;
    for(let i = 0; i< keepPaths.length; i++) {
        let item = keepPaths[i];
        if (item === path) {
            isKeep = true;
        }
        if (item instanceof RegExp && item.test(path)) {
            isKeep = true;
        }
        if (typeof item === 'string' && item.toLowerCase() === path) {
            isKeep = true;
        }
    }
    return isKeep;
}

export function useKeepOutlet() {
    const location = useLocation();
    const element = useOutlet();

    const { keepElements, keepPaths } = useContext(KeepAliveContext);
    const isKeep = isKeepPath(keepPaths, location.pathname);

    if (isKeep) {
        keepElements![location.pathname] = element;
    }

    return <>
        {
            Object.entries(keepElements).map(([pathname, element]) => (
                <div 
                    key={pathname}
                    style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }}
                    className="keep-alive-page"
                    hidden={!matchPath(location.pathname, pathname)}
                >
                    {element}
                </div>
            ))
        }
        {!isKeep && element}
    </>
}

const KeepAliveLayout: FC<KeepAliveLayoutProps> = (props) => {
    const { keepPaths, ...other } = props;

    const { keepElements, dropByPath } = useContext(KeepAliveContext);

    return (
        <KeepAliveContext.Provider value={{ keepPaths, keepElements, dropByPath }} {...other} />
    )
}

export default KeepAliveLayout;

ts 相关知识点

PropsWithChildren

在 React 中,PropsWithChildren 是 TypeScript 提供的一个工具类型,用于为组件的 props 类型添加 children 属性的类型定义。

当你定义组件的 props 类型时,如果组件需要接收 children(子元素),可以使用 PropsWithChildren 来自动包含 children 的类型,无需手动声明。

它的本质是一个泛型类型,定义如下(简化版):

type PropsWithChildren<P = {}> = P & { children?: React.ReactNode };
import { type PropsWithChildren } from 'react';

// 自定义 props 类型
type CardProps = {
  title: string;
  className?: string;
};

// 使用 PropsWithChildren 包含 children
const Card = ({ title, className, children }: PropsWithChildren<CardProps>) => {
  return (
    <div className={className}>
      <h2>{title}</h2>
      <div>{children}</div>
    </div>
  );
};

// 使用组件
const App = () => {
  return (
    <Card title="卡片标题" className="card">
      <p>这是卡片内容</p>
    </Card>
  );
};

Record、 Require、 Omit

  • Record 是创建一个 key value 的对象类型:
 keepElements?: Record<string, ReactNode>;
  • Requried 是去掉可选 -?

  • Omit 是删掉其中的部分属性:

type KeepAliveContextType = Omit<Required<KeepAliveLayoutProps>, 'children'>;

如果要知道某个属性的类型呢? 如下代码:

const keepElements: KeepAliveContextType['keepElements'] = {};

KeepAliveContextType['keepElements'] 就返回了 keepElements 属性的类型。

是不是感觉ts跟编程一样。

继续往下看:

const KeepAliveLayout: FC<KeepAliveLayoutProps> = (props) => {
    const { keepPaths, ...other } = props;

    const { keepElements, dropByPath } = useContext(KeepAliveContext);

    return (
        <KeepAliveContext.Provider value={{ keepPaths, keepElements, dropByPath }} {...other} />
    )
}

export default KeepAliveLayout;

首先从父组件中传入props,其中包括定义的 keepPaths, 然后从useContext 取出其他值,然后通过KeepAliveContext.Provider 的value 进行设置,这样子组件就能获取到这些值。

然后暴露一个 useKeepOutlet 的 hook:

export function useKeepOutlet() {
    const location = useLocation();
    const element = useOutlet();

    const { keepElements, keepPaths } = useContext(KeepAliveContext);
    const isKeep = isKeepPath(keepPaths, location.pathname);

    if (isKeep) {
        keepElements![location.pathname] = element;
    }

    return <>
        {
            Object.entries(keepElements).map(([pathname, element]) => (
                <div 
                    key={pathname}
                    style={{ height: '100%', width: '100%', position: 'relative', overflow: 'hidden auto' }}
                    className="keep-alive-page"
                    hidden={!matchPath(location.pathname, pathname)}
                >
                    {element}
                </div>
            ))
        }
        {!isKeep && element}
    </>
}

用 useLocation 拿到当前路由,用 useOutlet 拿到对应的组件。

判断下当前路由是否在需要 keepalive 的路由内,是的话就保存到 keepElements。

然后渲染所有的 keepElements,如果不匹配 matchPath 就隐藏。

并且如果当前路由不在 keepPaths 内,就直接渲染对应的组件: {!isKeep && element} 。

其实原理比较容易看懂:在 context 中保存所有需要 keepalive 的组件,全部渲染出来,通过路由是否匹配来切换对应组件的显示隐藏。

在 App.tsx 里引入测试下:

在外面包一层 KeepAliveLayout 组件:

const App = () => {
    return (
    <KeepAliveLayout keepPaths={['/bbb', '/']}>
      <RouterProvider router={router}/>
    </KeepAliveLayout>
    )
}

<RouterProvider router={router}/>会作为children传递到KeepAliveLayout组件中。

然后把 useOutlet 换成 useKeepOutlet:

const Layout = () => {
    const { pathname } = useLocation();

    const element = useKeepOutlet()

    return (
        <div>
            <div>当前路由: {pathname}</div>
            { element }
            {/* <Outlet/> */}
        </div>
    )
}

总结

路由切换会销毁对应的组件,但很多场景我们希望路由切换组件不销毁,也就是 keepalive。

react router 并没有实现这个功能,需要我们自己做。

我们在 context 中保存所有需要 keepalive 的组件,然后渲染的时候全部渲染出来,通过路由是否匹配来切换显示隐藏。

这样实现了 keepalive。

这个功能是依赖 React Router 的 useLocation、useOutlet、matchPath 等 api 实现的,和路由功能密不可分。

mini-react最小实现手把手带你看清hooks本质

作者 echo_e
2025年12月30日 16:25

Mini React Runtime 实现解析(Fiber + Hooks)

在这篇博客中,我将分享一个最小化 React Runtime 的实现,包括 Fiber 架构和 Hooks 的原理。本文适合想深入理解 React 内部工作机制的前端开发者。


引言

React 的核心是 虚拟 DOM + Fiber + Hooks

  • Fiber:运行时的组件实例,负责管理状态、hooks 和调度。
  • Hooks:函数组件的状态管理机制,通过调用顺序保证状态稳定性。
  • 虚拟 DOM:描述 UI 的对象,不直接渲染,Fiber 根据它构建真实 DOM。

本文实现的是一个最小可运行版本,帮助理解原理。


整体架构

架构图

graph TD
    A[React Element] -->|createFiber| B[Fiber 节点]
    B -->|child/sibling 链表| C[Fiber 树]
    C -->|performUnitOfWork| D[Function Component 执行]
    D -->|调用 Hooks| E[Hooks 状态存储在 Fiber.hooks]
    B -->|createDom| F[真实 DOM 节点]
    C -->|commitEffects| G[useEffect 执行]
    H[更新操作] -->|setState/useMemo 等| B
    E -->|读取/修改| D
    F -->|挂载到父 DOM| DOM[页面 DOM]

    style A fill:#f9f,stroke:#333,stroke-width:1px
    style B fill:#bbf,stroke:#333,stroke-width:1px
    style C fill:#bfb,stroke:#333,stroke-width:1px
    style D fill:#ffb,stroke:#333,stroke-width:1px
    style E fill:#fbf,stroke:#333,stroke-width:1px
    style F fill:#fbb,stroke:#333,stroke-width:1px
    style G fill:#bff,stroke:#333,stroke-width:1px
    style H fill:#eee,stroke:#333,stroke-width:1px

执行顺序

flowchart TD
    %% 初次渲染
    A["render(TodoApp)"] --> B["创建根 Fiber"]
    B --> C["performUnitOfWork(根 Fiber)"]
    C --> D{"Fiber.type 是否为函数组件?"}
    D -- 是 --> E["设置 wipFiber=当前 Fiber, hookIndex=0, hooks=[]"]
    E --> F["执行 TodoApp(props)"]

    %% useState
    F --> G["调用 useState(0)"]
    G --> H["检查 oldHook 是否存在"]
    H --> I{"首次渲染? (oldHook 为空)"}
    I -- 是 --> J["创建新 Hook {state=0, queue=[]}"]
    I -- 否 --> K["应用队列更新计算最新 state"]
    J --> L["hookIndex++ 返回 state, setState"]
    K --> L

    %% useMemo
    L --> M["调用 useMemo(() => count*2, [count])"]
    M --> N["检查 oldHook 是否存在并比较 deps"]
    N --> O{"依赖是否变化?"}
    O -- 是 --> P["执行 factory() 计算新 value"]
    O -- 否 --> Q["复用上一次 value"]
    P --> R["hookIndex++ 返回 memo value"]
    Q --> R

    %% useEffect
    R --> S["调用 useEffect(..., [count])"]
    S --> T["检查 oldHook 是否存在并比较 deps"]
    T --> U{"依赖是否变化?"}
    U -- 是 --> V["将 effect 放入 pendingEffects 队列"]
    U -- 否 --> W["不收集 effect"]
    V --> X["hookIndex++"]
    W --> X

    %% 返回 JSX
    X --> Y["返回子 Element: div > h3/p/button"]
    Y --> Z["reconcileChildren 创建子 Fiber"]
    Z --> AA{"fiber.child 是否存在?"}
    AA -- 是 --> C
    AA -- 否 --> AB["处理 sibling 或回到 parent"]
    AB --> AC["commit 阶段: 挂载 DOM"]
    AC --> AD["执行 pendingEffects 中的 useEffect"]

    %% 用户点击 button
    AD --> AE["用户点击 button 调用 setCount(c => c+1)"]
    AE --> AF["入队更新 Hook.queue"]
    AF --> AG["rerender() 重置 hookIndex=0, 执行 TodoApp"]
    AG --> G

    style A fill:#f9f,stroke:#333
    style B fill:#bbf,stroke:#333
    style C fill:#bfb,stroke:#333
    style D fill:#ffb,stroke:#333
    style E fill:#fbf,stroke:#333
    style F fill:#fbb,stroke:#333
    style G fill:#bff,stroke:#333
    style H fill:#ffe,stroke:#333
    style I fill:#eef,stroke:#333
    style J fill:#ddd,stroke:#333
    style K fill:#ccd,stroke:#333
    style L fill:#ffd,stroke:#333
    style M fill:#cff,stroke:#333
    style N fill:#ecf,stroke:#333
    style O fill:#fcf,stroke:#333
    style P fill:#ffc,stroke:#333
    style Q fill:#eef,stroke:#333
    style R fill:#ddf,stroke:#333
    style S fill:#fdd,stroke:#333
    style T fill:#def,stroke:#333
    style U fill:#ffd,stroke:#333
    style V fill:#cfc,stroke:#333
    style W fill:#eee,stroke:#333
    style X fill:#fdf,stroke:#333
    style Y fill:#f9f,stroke:#333
    style Z fill:#bbf,stroke:#333
    style AA fill:#bfb,stroke:#333
    style AB fill:#ffb,stroke:#333
    style AC fill:#fbf,stroke:#333
    style AD fill:#fbb,stroke:#333
    style AE fill:#bff,stroke:#333
    style AF fill:#ffe,stroke:#333
    style AG fill:#def,stroke:#333

Element 层(虚拟 DOM)

function createElement(type, props, ...children) {
    return {
        type,
        props: {
            ...props,
            children: children.map(child =>
                typeof child === "object" ? child : createTextElement(child)
            ),
        },
    };
}

function createTextElement(text) {
    return {
        type: "TEXT",
        props: {
            nodeValue: text,
            children: [],
        },
    };
}
  • createElement:生成虚拟 DOM 对象
  • createTextElement:将字符串或数字包装成文本节点
  • 所有子节点统一成对象形式,方便 Fiber 遍历

Fiber 层(运行时实例)

function createFiber(element, parent) {
    return {
        type: element.type,
        props: element.props,
        parent,
        child: null,
        sibling: null,
        dom: null,
        alternate: null,
        hooks: null,
    };
}

function createDom(fiber) {
    if (fiber.type === "TEXT") return document.createTextNode(fiber.props.nodeValue || "");
    const dom = document.createElement(fiber.type);
    Object.keys(fiber.props)
        .filter(key => key !== "children")
        .forEach(name => dom[name] = fiber.props[name]);
    return dom;
}
  • 每个 Fiber 对应一个组件或 DOM 节点
  • alternate 用于保存上一次渲染的 Fiber,实现状态复用
  • hooks 保存当前组件的所有 hooks

Render 阶段(Fiber 构建)

function performUnitOfWork(fiber) {
    if (typeof fiber.type === "function") {
        wipFiber = fiber;
        hookIndex = 0;
        wipFiber.hooks = [];
        const children = [fiber.type(fiber.props)];
        reconcileChildren(fiber, children);
    } else {
        if (!fiber.dom) fiber.dom = createDom(fiber);
        const parentDom = getParentDom(fiber);
        if (parentDom) parentDom.appendChild(fiber.dom);
        reconcileChildren(fiber, fiber.props.children || []);
    }

    if (fiber.child) return fiber.child;
    let next = fiber;
    while (next) {
        if (next.sibling) return next.sibling;
        next = next.parent;
    }
}
  • 对 Function Component 执行组件函数并收集 hooks
  • 对 DOM 节点创建真实 DOM 并挂载
  • 深度优先遍历 Fiber 树

Hooks 实现

useState

function useState(initialState) {
    const oldHook = wipFiber.alternate?.hooks?.[hookIndex];
    const hook = { state: oldHook ? oldHook.state : initialState, queue: [] };
    (oldHook?.queue || []).forEach(action => {
        hook.state = typeof action === "function" ? action(hook.state) : action;
    });
    const setState = action => { hook.queue.push(action); rerender(); };
    wipFiber.hooks.push(hook);
    hookIndex++;
    return [hook.state, setState];
}
  • state 保存在 Fiber.hooks 中
  • 通过 hookIndex 保证顺序
  • setState 入队并触发 rerender

useEffect

function useEffect(effect, deps) {
    const oldHook = wipFiber.alternate?.hooks?.[hookIndex];
    let hasChanged = !oldHook || !deps || deps.some((d,i) => !Object.is(d, oldHook.deps[i]));
    const hook = { effect, deps, cleanup: oldHook?.cleanup };
    if (hasChanged) pendingEffects.push(hook);
    wipFiber.hooks.push(hook);
    hookIndex++;
}
  • render 阶段收集 effect
  • commit 阶段统一执行
  • deps 不变时跳过

useRef

function useRef(initialValue) {
    const oldHook = wipFiber.alternate?.hooks?.[hookIndex];
    const hook = { current: oldHook ? oldHook.current : initialValue };
    wipFiber.hooks.push(hook);
    hookIndex++;
    return hook;
}
  • 返回稳定引用
  • 修改 current 不触发 rerender

useMemo

function useMemo(factory, deps) {
    const oldHook = wipFiber.alternate?.hooks?.[hookIndex];
    const hasChanged = !oldHook || !deps || deps.some((d,i) => !Object.is(d, oldHook.deps[i]));
    const hook = { value: hasChanged ? factory() : oldHook.value, deps };
    wipFiber.hooks.push(hook);
    hookIndex++;
    return hook.value;
}
  • 缓存计算结果
  • deps 不变时返回上次结果

useContext

function useContext(context) {
    const hook = { value: context._currentValue };
    wipFiber.hooks.push(hook);
    hookIndex++;
    return hook.value;
}
  • 读取 context._currentValue
  • 这是一个极简实现,不支持嵌套 Provider

Demo 示例

function TodoApp() {
    const [count, setCount] = MiniReact.useState(0);
    const double = MiniReact.useMemo(() => count * 2, [count]);
    MiniReact.useEffect(() => console.log(count), [count]);

    return MiniReact.createElement(
        "div",
        null,
        MiniReact.createElement("h3", null, "Count: ", count),
        MiniReact.createElement("p", null, "Double: ", double),
        MiniReact.createElement(
            "button",
            { onclick: () => setCount(c => c + 1) },
            "Add"
        )
    );
}

MiniReact.render(MiniReact.createElement(TodoApp, null), document.getElementById("root"));
  • 演示了 useState、useEffect、useMemo
  • 每次点击按钮都会触发 rerender

总结

通过这个 Mini React Runtime,我们可以理解:

  1. Fiber 树的作用:管理组件实例和 hooks
  2. Hooks 的原理:依赖调用顺序和 Fiber 存储
  3. render 阶段 vs commit 阶段:render 只是构建 Fiber,不操作 DOM;commit 才真正更新 DOM
  4. 最小化实现足够教学,可以逐步扩展支持 diff 算法、异步渲染、useLayoutEffect 等

这是理解 React Hooks 和 Fiber 内部机制的绝佳入门案例。

可以仔细阅读下面源码注释写的非常清楚

/**************************************************************
 * Mini React Runtime (Fiber + Hooks)
 *
 * 这是一个教学用途的「最小可运行 React 内核」
 * 目标:帮助你**完整理解 React Fiber + Hooks 的运行机制**
 *
 * 特点:
 * - 同步 render(无并发、无时间切片)
 * - Fiber 树结构
 * - Hooks 基于「调用顺序」实现
 * - 支持 useState / useEffect / useRef / useMemo / useContext
 **************************************************************/

/**************************************************************
 * 全局运行时状态(非常关键)
 **************************************************************/

let wipFiber = null;
// 当前正在 render 的 Function Component 对应的 Fiber
// Hooks 的所有读写都依赖它

let hookIndex = 0;
// 当前组件 render 过程中,第几个 hook
// useState/useEffect/... 全部依赖「调用顺序」

let currentRoot = null;
// 已经 commit 的根 Fiber
// rerender 时作为 alternate(旧 Fiber 树)
// 用来保存已经提交的Fiber树,下一次render时候把新的rootFiber的alternate指向它

let pendingEffects = [];
// render 阶段收集的所有 useEffect
// 会在 commit 阶段统一执行


/**************************************************************
 * Element 层(虚拟 DOM)
 **************************************************************/

/**
 * createElement
 * 用于创建 React Element(虚拟节点)
 *
 * 等价于:
 *   React.createElement(type, props, ...children)
 *
 * React Element 是一个「纯描述对象」
 * 并不会直接参与渲染
 */
function createElement(type, props, ...children) {
    return {
        type, // string(div)或 function(组件)
        props: {
            ...props,
            // children 必须统一成 object
            children: children.map(child =>
                typeof child === "object"
                    ? child
                    : createTextElement(child)
            ),
        },
    };
}

/**
 * createTextElement
 * 将字符串 / 数字包装成 TEXT 类型的 Element
 */
function createTextElement(text) {
    return {
        type: "TEXT",
        props: {
            nodeValue: text,
            children: [],
        },
    };
}


/**************************************************************
 * Fiber 层(运行时实例)
 **************************************************************/

/**
 * createFiber
 *
 * Fiber 是 React 运行时的核心数据结构
 * 每一个 Fiber ≈ 一个组件/DOM 实例
 */
function createFiber(element, parent) {
    return {
        type: element.type,     // 组件函数 or DOM 标签
        props: element.props,   // props
        parent,                 // 父 Fiber
        child: null,            // 第一个子 Fiber
        sibling: null,          // 下一个兄弟 Fiber
        dom: null,              // 对应的真实 DOM
        alternate: null,        // 上一次 render 的 Fiber
        hooks: null,            // hooks 数组(Function Component 专属)
    };
}

/**
 * createDom
 * 根据 Fiber 创建真实 DOM 节点
 */
function createDom(fiber) {
    // 文本节点
    if (fiber.type === "TEXT") {
        return document.createTextNode(fiber.props.nodeValue ?? "");
    }

    // 普通 DOM 节点
    const dom = document.createElement(fiber.type);

    // 将 props 映射到 DOM 上(事件、属性)
    Object.keys(fiber.props)
        .filter(key => key !== "children")
        .forEach(name => {
            dom[name] = fiber.props[name];
        });

    return dom;
}


/**************************************************************
 * Fiber 构建(reconcile)
 **************************************************************/

/**
 * reconcileChildren
 *
 * 根据 element 列表构建 Fiber.child / Fiber.sibling 链表
 *
 * 同时:
 * - 尝试复用 oldFiber(alternate)
 * - 保证 hooks 能够跨 render 复用
 */
function reconcileChildren(fiber, elements = []) {
    let index = 0;
    let prevSibling = null;

    // 上一次 render 的第一个子 Fiber
    let oldFiber = fiber.alternate && fiber.alternate.child;

    while (index < elements.length) {
        const element = elements[index];
        if (!element) {
            index++;
            continue;
        }

        // 为 element 创建新的 Fiber
        const newFiber = createFiber(element, fiber);

        // 关联旧 Fiber(用于 hooks / state 复用)
        if (oldFiber) {
            newFiber.alternate = oldFiber;
            oldFiber = oldFiber.sibling;
        }

        // 构建 child / sibling 链表
        if (index === 0) {
            fiber.child = newFiber;
        } else {
            prevSibling.sibling = newFiber;
        }

        prevSibling = newFiber;
        index++;
    }
}

/**
 * getParentDom
 * 向上查找最近的 Host Fiber(有 dom 的 Fiber)
 */
function getParentDom(fiber) {
    let parent = fiber.parent;
    while (parent && !parent.dom) {
        parent = parent.parent;
    }
    return parent ? parent.dom : null;
}


/**************************************************************
 * Render 阶段(Fiber 构建)
 **************************************************************/

/**
 * performUnitOfWork
 *
 * Fiber 构建的核心调度函数
 * 采用深度优先遍历(DFS)
 */
function performUnitOfWork(fiber) {

    /******** Function Component ********/
    if (typeof fiber.type === "function") {

        // 标记当前正在 render 的 Fiber
        wipFiber = fiber;

        // hooks 从 0 开始
        hookIndex = 0;

        // 初始化 hooks 容器
        wipFiber.hooks = [];

        // 执行组件函数(render 阶段)
        const children = [fiber.type(fiber.props)];

        // 根据返回的 element 构建子 Fiber
        reconcileChildren(fiber, children);
    }

    /******** Host Component(DOM) ********/
    else {
        // 创建 DOM
        if (!fiber.dom) {
            fiber.dom = createDom(fiber);
        }

        // 挂载到父 DOM
        const parentDom = getParentDom(fiber);
        if (parentDom) {
            parentDom.appendChild(fiber.dom);
        }

        // 继续向下构建
        reconcileChildren(fiber, fiber.props.children || []);
    }

    // 深度优先遍历
    if (fiber.child) return fiber.child;

    let next = fiber;
    while (next) {
        if (next.sibling) return next.sibling;
        next = next.parent;
    }
}


/**************************************************************
 * render 入口
 **************************************************************/

function render(element, container) {

    const rootFiber = {
        dom: container,
        props: { children: [element] },
        parent: null,
        child: null,
        sibling: null,
        alternate: currentRoot, // 旧 Fiber 树
    };

    currentRoot = rootFiber;

    let nextUnitOfWork = rootFiber;
    while (nextUnitOfWork) {
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }

    // commit 阶段
    commitEffects();
}

/**
 * rerender
 * 所有 setState 的最终触发点
 */
function rerender() {
    const container = currentRoot.dom;
    container.innerHTML = "";
    render(currentRoot.props.children[0], container);
}


/**************************************************************
 * Hooks 实现
 **************************************************************/

/**
 * useState
 *
 * 原理总结:
 * 1. state 存储在 fiber.hooks 中
 * 2. 通过 hookIndex 保证顺序一致
 * 3. setState 只是入队,不立即更新
 * 4. rerender 时统一计算新 state
 */
function useState(initialState) {

    // 找到旧 hook
    const oldHook =
        wipFiber.alternate &&
        wipFiber.alternate.hooks &&
        wipFiber.alternate.hooks[hookIndex];

    // 初始化 hook
    const hook = {
        state: oldHook ? oldHook.state : initialState,
        queue: [],
    };

    // 执行上一次 setState 入队的 action
    const actions = oldHook ? oldHook.queue : [];
    actions.forEach(action => {
        // 获取最新状态
        hook.state =
            typeof action === "function"
                ? action(hook.state)
                : action;
    });

    // setState 实现
    const setState = action => {
        // 向之前的wipFiber对应的hook的queue中添加action
        hook.queue.push(action);
        rerender();
    };

    wipFiber.hooks.push(hook);
    hookIndex++;

    return [hook.state, setState];
}


/**
 * useEffect
 *
 * 核心思想:
 * - render 阶段只收集
 * - commit 阶段统一执行
 * - deps 控制是否重新执行
 */
function useEffect(effect, deps) {

    const oldHook =
        wipFiber.alternate &&
        wipFiber.alternate.hooks &&
        wipFiber.alternate.hooks[hookIndex];

    let hasChanged = true;

    // deps 对比
    if (oldHook && deps) {
        hasChanged = deps.some(
            (dep, i) => !Object.is(dep, oldHook.deps[i])
        );
    }

    const hook = {
        effect,                // 副作用函数
        deps,                  // 依赖
        cleanup: oldHook ? oldHook.cleanup : null,
    };

    if (hasChanged) {
        pendingEffects.push(hook);
    }

    wipFiber.hooks.push(hook);
    hookIndex++;
}


/**
 * commitEffects
 * effect 的真正执行阶段
 */
function commitEffects() {
    pendingEffects.forEach(hook => {

        // 先执行上一次 cleanup
        if (hook.cleanup) {
            hook.cleanup();
        }

        // 再执行 effect
        const cleanup = hook.effect();

        // 保存 cleanup
        hook.cleanup = typeof cleanup === "function" ? cleanup : null;
    });

    pendingEffects = [];
}


/**
 * useRef
 *
 * 特点:
 * - 返回稳定引用
 * - 修改 current 不会触发 render
 */
function useRef(initialValue) {
    const oldHook =
        wipFiber.alternate &&
        wipFiber.alternate.hooks &&
        wipFiber.alternate.hooks[hookIndex];

    const hook = {
        current: oldHook ? oldHook.current : initialValue,
    };

    wipFiber.hooks.push(hook);
    hookIndex++;

    return hook;
}


/**
 * useMemo
 *
 * 作用:
 * - 缓存计算结果
 * - deps 不变时复用
 */
function useMemo(factory, deps) {
    const oldHook =
        wipFiber.alternate &&
        wipFiber.alternate.hooks &&
        wipFiber.alternate.hooks[hookIndex];

    let hasChanged = true;

    if (oldHook && deps) {
        hasChanged = deps.some(
            (dep, i) => !Object.is(dep, oldHook.deps[i])
        );
    }

    const hook = {
        value: hasChanged ? factory() : oldHook.value,
        deps,
    };

    wipFiber.hooks.push(hook);
    hookIndex++;

    return hook.value;
}


/**************************************************************
 * Context(极简版)
 **************************************************************/

function createContext(defaultValue) {
    const context = {
        _currentValue: defaultValue,
        Provider: null,
    };

    context.Provider = function Provider(props) {
        context._currentValue = props.value;
        return props.children;
    };

    return context;
}

function useContext(context) {
    const hook = { value: context._currentValue };
    wipFiber.hooks.push(hook);
    hookIndex++;
    return hook.value;
}


/**************************************************************
 * 对外 API
 **************************************************************/

const MiniReact = {
    createElement,
    render,
    useState,
    useEffect,
    useRef,
    useMemo,
    useContext,
    createContext,
};


/**************************************************************
 * Demo
 **************************************************************/

function TodoApp() {
    const [count, setCount] = MiniReact.useState(0);

    const double = MiniReact.useMemo(() => {
        console.log("compute memo");
        return count * 2;
    }, [count]);

    MiniReact.useEffect(() => {
        console.log("effect:", count);
    }, [count]);

    return MiniReact.createElement(
        "div",
        null,
        MiniReact.createElement("h3", null, "Count: ", count),
        MiniReact.createElement("p", null, "Double: ", double),
        MiniReact.createElement(
            "button",
            { onclick: () => setCount(c => c + 1) },
            "Add"
        )
    );
}

MiniReact.render(
    MiniReact.createElement(TodoApp, null),
    document.getElementById("root")
);


/**************************************************************
 * TodoApp 初始化流程(精细 Fiber + Hooks 注释)
 **************************************************************/

/**
 * ======================= Step 1: render() 被调用 =======================
 * MiniReact.render(element, container)
 * container = <div id="root"></div>
 *
 * 创建根 Fiber:
 * rootFiber = {
 *   dom: container,
 *   props: { children: [TodoApp element] },
 *   parent: null,
 *   child: null,
 *   sibling: null,
 *   alternate: null
 * }
 *
 * currentRoot = rootFiber
 * nextUnitOfWork = rootFiber
 */

/**
 * ======================= Step 2: performUnitOfWork(rootFiber) =======================
 * fiber = rootFiber
 * fiber.type = undefined / 容器 DOM
 *
 * 1. 创建 DOM:
 *    fiber.dom = container
 *    parentDom = null (fiber.parent = null)
 *
 * 2. reconcileChildren(fiber, [TodoApp element])
 *    - index=0
 *    - 创建 TodoApp Fiber:
 *      todoFiber = {
 *        type: TodoApp,
 *        props: {},
 *        parent: rootFiber,
 *        child: null,
 *        sibling: null,
 *        dom: null,
 *        alternate: null,
 *        hooks: null
 *      }
 *    - rootFiber.child = todoFiber
 *
 * nextUnitOfWork = todoFiber
 */

/**
 * ======================= Step 3: performUnitOfWork(todoFiber) =======================
 * fiber = todoFiber
 * fiber.type = TodoApp (函数组件)
 *
 * 1. 准备 render 函数组件:
 *    wipFiber = todoFiber
 *    hookIndex = 0
 *    wipFiber.hooks = []   <-- 新 Fiber 的 hooks 数组初始化
 *
 * 2. 执行 TodoApp():
 *    const [count, setCount] = useState(0)
 *
 * ======================= Step 3a: useState =======================
 * oldHook = null (首次渲染,wipFiber.alternate = null)
 * 新 hook:
 * 0: { state: 0, queue: [] }
 * wipFiber.hooks = [ { state:0, queue:[] } ]
 * hookIndex++ => 1
 * 返回 count = 0, setCount = function
 *
 * ======================= Step 3b: useMemo =======================
 * const double = useMemo(() => count*2, [count])
 * oldHook = null
 * hasChanged = true (首次渲染)
 * 新 hook:
 * 1: { value: 0, deps:[0] }
 * wipFiber.hooks = [
 *   { state:0, queue:[] },
 *   { value:0, deps:[0] }
 * ]
 * hookIndex++ => 2
 * double = 0
 *
 * ======================= Step 3c: useEffect =======================
 * const effect = () => { console.log(count); }
 * oldHook = null
 * hasChanged = true
 * 新 hook:
 * 2: { effect: effectFn, deps:[0], cleanup: null }
 * pendingEffects = [ hook ]
 * wipFiber.hooks = [
 *   { state:0, queue:[] },
 *   { value:0, deps:[0] },
 *   { effect:effectFn, deps:[0], cleanup:null }
 * ]
 * hookIndex++ => 3
 *
 * 3. TodoApp 返回 JSX:
 * <div>
 *   <h3>Count: 0</h3>
 *   <p>Double: 0</p>
 *   <button>Add</button>
 * </div>
 *
 * reconcileChildren(todoFiber, [div element])
 * 创建 divFiber、h3Fiber、pFiber、buttonFiber
 * 建立 child/sibling 链表
 */

/**
 * ======================= Step 4: commit 阶段 =======================
 * 遍历 Fiber 树,挂载 DOM:
 * rootFiber.dom.appendChild(todoFiber.dom) -> divFiber.dom -> h3/p/button
 *
 * 执行 pendingEffects:
 * hook.effect() -> console.log(count) 输出 0
 * hook.cleanup = null
 * pendingEffects 清空
 *
 * 最终状态:
 * currentRoot = rootFiber
 * wipFiber = null
 * hookIndex = 0
 * Fiber 树:
 * ROOT
 * └── TodoApp Fiber
 *     └── divFiber
 *         ├── h3Fiber
 *         ├── pFiber
 *         └── buttonFiber
 * Hooks 状态:
 * 0: { state:0, queue:[] }
 * 1: { value:0, deps:[0] }
 * 2: { effect: effectFn, deps:[0], cleanup:null }
 */




/**************************************************************
 * TodoApp 第一次点击按钮更新流程(精细 Fiber + Hooks 注释)
 **************************************************************/

/**
 * ======================= Step 1: 用户点击按钮 =======================
 * 点击 button 执行 onclick:
 * setCount(c => c + 1)
 *
 * 1. 查找当前 Hook:
 *    wipFiber = null (尚未进入 render)
 *    hookIndex = 当前调用位置 0
 *    对应旧 Hook:
 *    oldHook = currentRoot.child.hooks[0] = { state:0, queue:[] }
 *
 * 2. 入队 action 到 hook.queue:
 *    action = c => c + 1
 *    oldHook.queue.push(action)
 *
 *    Hooks 状态(旧 Fiber 的 hooks,不变):
 *    0: { state:0, queue:[c=>c+1] }
 *    1: { value:0, deps:[0] }
 *    2: { effect:effectFn, deps:[0], cleanup:null }
 *
 * 3. 调用 rerender()
 *    清空根 DOM: container.innerHTML = ""
 *    准备重新 render(currentRoot.props.children[0], currentRoot.dom)
 */

/**
 * ======================= Step 2: performUnitOfWork(todoFiber) =======================
 * wipFiber = todoFiber
 * hookIndex = 0
 * wipFiber.hooks = []   <-- 新 Fiber hooks 重建
 *
 * 执行 TodoApp():
 */

/**
 * ======================= Step 2a: useState =======================
 * hookIndex = 0
 * oldHook = { state:0, queue:[c=>c+1] }
 *
 * 新 hook:
 * state = oldHook.state = 0
 * queue = [] (新 hook 的 queue 临时为空)
 *
 * 处理旧 queue:
 * for action in oldHook.queue:
 *   state = action(state) = (0+1) = 1
 * 新 hook.state = 1
 *
 * hookIndex++ => 1
 * wipFiber.hooks = [
 *   { state:1, queue:[] }   <-- 更新后的 useState
 * ]
 *
 * 返回 count = 1, setCount = function
 */

/**
 * ======================= Step 2b: useMemo =======================
 * hookIndex = 1
 * oldHook = { value:0, deps:[0] }
 *
 * 检查 deps:
 * 旧 deps = [0], 新 count = 1
 * deps 变化 -> hasChanged = true
 *
 * 重新计算 value = count*2 = 1*2 = 2
 * 新 hook:
 * { value:2, deps:[1] }
 * hookIndex++ => 2
 * wipFiber.hooks = [
 *   { state:1, queue:[] },
 *   { value:2, deps:[1] }
 * ]
 *
 * double = 2
 */

/**
 * ======================= Step 2c: useEffect =======================
 * hookIndex = 2
 * oldHook = { effect: effectFn, deps:[0], cleanup:null }
 *
 * 检查 deps:
 * old deps=[0], count=1 -> deps变化 -> hasChanged = true
 *
 * 新 hook:
 * { effect:effectFn, deps:[1], cleanup:null }
 * pendingEffects.push(newHook)
 * hookIndex++ => 3
 * wipFiber.hooks = [
 *   { state:1, queue:[] },
 *   { value:2, deps:[1] },
 *   { effect:effectFn, deps:[1], cleanup:null }
 * ]
 */

/**
 * ======================= Step 3: JSX 返回 & Fiber 树复用 =======================
 * TodoApp 返回:
 * <div>
 *   <h3>Count: 1</h3>
 *   <p>Double: 2</p>
 *   <button>Add</button>
 * </div>
 *
 * reconcileChildren(todoFiber, [div element])
 * Fiber 树复用:
 * todoFiber.child = divFiber (复用旧节点)
 * divFiber.child = h3Fiber -> pFiber -> buttonFiber (复用旧节点)
 *
 * dom 不重建,只更新 textContent
 */

/**
 * ======================= Step 4: commit 阶段 =======================
 * 更新 DOM:
 * h3.textContent = "Count:1"
 * p.textContent = "Double:2"
 *
 * 执行 pendingEffects:
 * hook.effect() -> console.log(count) 输出 1
 * alert("Count is: 1")
 * hook.cleanup = null
 * pendingEffects 清空
 */

/**
 * ======================= Step 5: 最终状态 =======================
 * currentRoot = rootFiber
 * wipFiber = null
 * hookIndex = 0
 *
 * Fiber 树:
 * ROOT
 * └── TodoApp Fiber
 *     └── divFiber
 *         ├── h3Fiber
 *         ├── pFiber
 *         └── buttonFiber
 *
 * Hooks 状态:
 * 0: { state:1, queue:[] }          <-- useState 已处理 queue
 * 1: { value:2, deps:[1] }          <-- useMemo 更新
 * 2: { effect:effectFn, deps:[1], cleanup:null } <-- useEffect 更新
 *
 * pendingEffects = []
 */

当 Gemini 3 能写出完美 CSS 时,前端工程师剩下的核心竞争力是什么?

作者 ErpanOmer
2025年12月29日 15:42

google-gemini-3-inc.webp

兄弟们,咱们的护城河越来越窄了😭

Gemini 3 的发布会,大家看了没?

我是在被窝里看完的。看完之后,我直接失眠了。

以前我觉得 AI 写代码也就那样,写个 Todo List 还行,真要上业务逻辑,它就得幻觉给你看😒。

但 Google 秀的这一手,真的有点不讲武德

我出于好奇心,用 Google Al Studio 试了一下几个经典的需求, 直接把飞书需求文档扔给它(纯文案)👇:

Recall landing page
1. 页脚的recalls跳转新的recall landing page
[图片]
2. 页面内容
标题:Product Recalls

两个内容模块,点击后跳转至各自详情页
第一个:
2025 Fat Tire Trike Recall Notice
Pedego has issued a safety recall for Fat Tire Trikes due to a potential frame fracture near a weld that may pose fall or injury risks. Affected owners are eligible for a free repair, completed by a local Pedego dealer.
Learn more (可点击,跳转至Fat Tire Trike Recall Page)

第二个:
2021 Cable Recall Notice
Pedego is voluntarily recalling select e-bike models sold from January 2018 to August 2020 due to a cable issue that may cause unexpected acceleration. Affected owners should stop riding and register for a free safety repair.
Learn more(可点击,跳转至https://www.pedegobikerecall.expertinquiry.com/?_gl=1*1hzkwd0*_gcl_au*MTkxNDc4ODEuMTc2MzM0NDUyMA..*_ga*MTM1MzU3NTAzOC4xNzQ1OTE1NTcz*_ga_4K15HG6FFG*czE3NjQ4MzQ5MDAkbzQyJGcwJHQxNzY0ODM0OTAxJGo1OSRsMCRoMA..*_ga_FGPZTS4D91*czE3NjQ4MzQ5MDAkbzQyJGcwJHQxNzY0ODM0OTAxJGo1OSRsMCRoMA..)
[图片]



Fat Tire Trike Recall Page
标题:Pedego Recalls Fat Tire Trike Due to Fall and Laceration Hazards
[插入几张Fat Tire Trike图片]
 
页面主体内容
Name of Product: Pedego Fat Tire Trike
Hazard: The trike frame can develop a hairline fracture near a weld, which can cause the tube to break, posing fall and laceration hazards. 
Units Affected: Serial Number Range: D2312050001 - D2312050522 
 
按钮:REGISTER NOW (点击后跳转至页面下方注册表单)
  
How is Pedego making this right? 
Pedego is offering you a free repair of your Fat Tire Trike. We have reengineered and strengthened the section of the frame in question. Once you register, we will ship a repair part to a local Pedego dealer that you select using the registration form. 
We will ship the part to the dealer. The Pedego dealer will repair the Fat Tire Trike free of charge. There are no charges or fees associated with this recall. 
You will be contacted when your part is received at the Pedego store for installation.

Make sure that other members of your household also know about the recall and immediately stop using it. Secure your Fat Tire Trike so that it cannot be ridden until it is repaired.
We strongly encourage you to participate and contact us to obtain a free repair.
 
Register for the free repair of your Fat Tire Trike
First Name*
Last Name*
Email*
Phone number*
Dealer Where you’d like the repair to take place *  [Perhaps Preload options or provide location search for dealer(这里有没有可能提供选项让消费者选择?或者搜索地址?)]
State* 
Zip Code*
Country*
 
[] * I hereby affirm that the information I have provided is accurate and correct, and that I have complied with all requirements of the above-referenced recall for seeking a repair of my Fat Tire Trike.
 
 Submit(提交按钮)

成功提交后显示:
Thank you. Your registration has been submitted and is being processed. 
We will notify you when the parts ship to the dealer. The dealer will install and repair your Fat Tire Trike free of charge. 

[所提交信息在这里展示]
 
Please print this page for your records. 

我刚准备点根烟的工夫,页面 UI 就出来了👇。

image.png

image.png

不是那种满屏 div 的垃圾代码,是语义化极好、组件拆分合理、甚至连 dark mode 都给配好了的成品,根本不需要改什么😖。

我看着屏幕上自己刚写了一半、还在纠结 flex-basis 该给多少的样式文件,突然觉得:这几年的代码,好像白写了。

我最新的 个人主页也是用 Gemini 3 重写的,这审美,这效率,没得说!太强了👏


切图仔的时代正式终结了

以前咱总开玩笑说自己是切图仔,其实心里还是有点傲气的: 你以为 CSS 容易啊?BFC、层叠上下文、响应式断点、不同内核的兼容性...这玩意儿水深着呢!

但 Gemini 3 这种级别的 AI 出来,直接把这层傲气给降维打击了。

  • 比速度? 你调一个布局要半小时,它只要 3 秒。
  • 比审美? 它学习了全球数亿个精美网页,配出的视觉UI 把你那程序员审美甩出几条街。
  • 比稳定性? 它不会写错单词,也不会漏写分号,更不会因为下午跟产品经理吵了架就故意在代码里埋坑。

说实话,在实现视觉稿这件事上,人类已经输了。彻底输了!!!😭

如果你的核心竞争力就是能把 UI 图 1:1 还原成网页,那你的职业生涯确实已经进入倒计时了。


既然 CSS 成了废话,那我们还剩什么?

既然 AI 能写出完美的 CSS,甚至连交互动画都能一句话生成,那公司凭啥还花几万块招个前端?

我想了半宿,觉得咱们前端老哥的保命牌,其实正在从手艺转向上层建筑:

培养自己的架构设计能力

AI 可以给你砌出一面完美的墙,但它不知道这面墙该立在什么位置。

一个大型项目里:

  • 组件怎么拆分最利于复用?
  • 目录结构怎么设计才不会让后来的人骂娘?
  • 全局状态是用 Zustand 还是直接原生 Context 梭哈?

这些涉及到工程化决策的东西,AI 目前还是个弟弟。它只能给你局部的最优解,给不了你全局的架构观。

处理那些只有人能理解的业务

AI 最怕的是什么?是逻辑的混沌

用户如果连续点击三次,要触发一个彩蛋,但如果他是 VIP 且余额不足,这个彩蛋要换成充值提醒,顺便还得防止接口重放。

这种只有人类产品经理拍脑袋想出来的、逻辑转了十八道弯的边缘 Case,AI 极其容易写出 Bug。

搞定复杂的异步流,搞定恶心的竞态条件,搞定各种各样的降级策略——这才是你领工资的真正理由。

驾驭 AI 的能力(这应该是 2026 年的高频面试题)

以前面试问:CSS 怎么实现三角形?

以后面试可能问:如何用一句 Prompt,让 Gemini 3 输出一个符合公司私有 UI 规范、且通过了 E2E 测试的复杂组件?

AI 不是你的敌人,它可是你的好伙伴。

别人还在用手敲代码时,你已经学会利用AI 提升工作效率。你的核心竞争力,就是你 调教 AI 的水平。


没必要焦虑,这是超级个体的开始

咱们有木有可能换一种思路🤔。

以前我们想做个自己的副业项目,最头疼的是什么?UI 和 CSS。

对于我们这种逻辑强、审美弱的后端型前端,调样式简直是要了亲命。

现在 Gemini 3 这种东西出来了,简直是送福利。

  • 后端: 让 AI 帮你生成 Schema 和基础 CRUD。
  • UI/CSS: 丢张草图给 Gemini 3。
  • 前端框架: 让 AI 帮你写好骨架。

你一个人,就是一个超级个体。

以前我们需要在大厂里卷,是因为大厂有资源、有配套。

现在 AI 把资源门槛抹平了。在这个代码非常廉价的时代,你的创意、你的产品意识、你的解决问题能力,反而变得更值钱了。


Gemini 3 确实很猛,猛到让人怀疑人生,猛得一塌糊涂!😖

但我相信,只要互联网还需要服务,前端这个角色就不会消失。它只是从体力活进化成了脑力活。

别纠结那几个 marginpadding 了,去研究架构,去深挖性能,去学习怎么让 AI 给你当牛马。

只要你跑得比 AI 进化的速度快,你就不是被淘汰的那一个。

最后默默的问大家一句🤣:

如果明天你的老板让你裁掉团队一半的前端,只留下那些会用 AI 的,你会是在名单里的那个人吗?

欢迎👏顺便说说你被 Gemini 3 惊吓到的瞬间😁。

《网页布局速通:8 大主流方案 + 实战案例》-pink老师现代网页布局总结

作者 王小菲
2025年12月29日 13:37

一、概述与目标

CSS 布局是网页设计的核心技术,主要用于控制页面元素的排列与呈现方式。目前主流的布局方案包括常规文档流布局、模式转换布局、弹性布局(Flexbox)、定位布局、网格布局(Grid)和多列布局。

接下来我们会逐一拆解它们的优缺点与适用场景,帮你快速看懂主流官网的布局实现思路。

二、常规文档流布局

这是浏览器的默认排版,是 CSS 布局的基础,页面大结构依靠块元素上下堆叠实现。包含块元素和行内元素,文档流方向默认从上到下、从左到右排列。

块元素(block) 独占一行,宽度默认撑满容器;可设置宽高,呈垂直排列;举例:div、p、h1~h6
行内元素(inline) 水平依次排列,容器宽度不足则换行;宽高由内容决定,无法直接设置;举例:span、img、strong

image.pngimage.png

三、模式转换布局

image.pngimage.png

如上图所示,需求要求我们把块级盒子展示为一行,或者要求行内元素有更大的点击范围,我们改怎么办呢?

那么就需要用到display转换, 我们可以将上面两种元素的display属性设置为inline-block, 可实现上述效果

image.pngimage.png

display转换为 inline-block后,可以设置宽高,又不用独占一行,这种特点让它可以广泛应用于让块级盒子一行显示或让行内盒子具备宽高的场景

属性值 是否独占一行 能否设置宽高 默认宽度
display: block ✔️ 撑满容器宽度
display: inline 由内容决定
display: inline-block ✔️ 由内容决定(可覆盖)

但是使用行内块元素需要注意: 元素间会有空隙,需要给父元素设font-size: 0,因此适合对间距要求不高的场景,如果精细排版建议用 Flex或Grid。

image.png

四、被逐渐替代的float

float最早是做”文字环绕”效果的,如下图所示

image.png

float可以让元素脱离文档流向左或向右浮动, 但这会导致父容器高度塌陷,从而影响周围元素的布局,例如下图1所示。而很多时候我们是不能给父容器规定高度的,它的高度取决于后台服务返回的数据量,例如京东的这个商品列表展示,随着鼠标的滚动,商品不断增多,高度不断增加,这个时候我们怎么办呢?

image.pngimage.png

这个时候我们就要进行清除浮动了,主要有以下四种方法

1、双伪元素清除浮动

image.png

2、单伪元素清除浮动

image.png

3、额外标签法:在浮动元素最后新增块级标签,但增加冗余标签

image.png

4、overflow 清除浮动:触发 BFC 包裹浮动元素
image.png

因为float问题太多, 要手动解决 “高度塌陷”,还得写额外代码清除浮动, 排版稍微复杂点就容易错位,对新手很不友好, 现在有更简单的 Flex/Grid 布局,又灵活又不存在上述问题,所以浮动就成 “时代的眼泪”了

五、弹性布局

Flexbox是Flexible Box Layout Module(弹性盒子布局模块)的缩写,可以快速实现元素的对齐、分布和空间分配。例如京东、淘宝、小米等主流网站都使用了flex布局,而且我们的低代码平台也可以设置元素为flex布局

image.pngimage.pngimage.png

我们为啥要使用flex布局呢?

以B站头部为例,想要实现下图的效果,三个块级元素并排在一行,实现两端对齐的效果,用之前的办法,可能要变成行内块、给margin或者padding来实现,或者干脆采用浮动的办法,那么实现垂直居中该怎么办呢?

垂直居中是传统布局的 “老大难”,有的同学可能说使用line-height,但是line-height是无法让块级的盒子垂直居中,这个时候我们可以使用flex,只需要三行代码(display: flex;align-items: center;justify-content: space-between;)就可以实现B站头部的布局效果,我们公司的官网头部也是类似的实现方案

image.pngimage.png

1、flex布局的核心

父控子:父盒子控制子盒子如何排列布局(父盒子称为容器,子盒子称为项目),控制属性要写在父元素身上;

轴方向:主轴默认水平、交叉轴默认垂直,可自定义。

2、flex的属性

父盒子属性

属性 作用说明 所有可选值
display 定义元素为 Flex 容器 flex
flex-direction 定义主轴方向(项目排列方向) row(默认,水平从左到右)、row-reverse(水平从右到左)、column(垂直从上到下)、column-reverse(垂直从下到上)
flex-wrap 控制项目是否换行 nowrap(默认,不换行)、wrap(换行,第一行在上)、wrap-reverse(换行,第一行在下)
justify-content 定义主轴上的对齐方式(项目整体分布) flex-start(默认,靠主轴起点)、flex-end(靠主轴终点)、center(居中)、space-between(两端对齐,项目间间距相等)、space-around(项目两侧间距相等)、space-evenly(项目间间距完全相等)
align-items 定义交叉轴上的对齐方式(单行时项目整体对齐) stretch(默认,拉伸填满容器)、flex-start(靠交叉轴起点)、flex-end(靠交叉轴终点)、center(垂直居中)、
align-content 定义多行时交叉轴上的对齐方式(仅当 flex-wrap: wrap 且内容换行时生效) stretch(默认,拉伸填满容器)、flex-start(靠交叉轴起点)、flex-end(靠交叉轴终点)、center(居中)、space-between(两端对齐)、space-around(项目行两侧间距相等)

项目属性:

属性 作用说明 所有可选值 / 取值规则
order 定义项目的排列顺序(默认 0,数值越小越靠前) 任意整数(正整数 / 负整数 / 0),无单位
flex-grow 定义项目的放大比例(默认 0,即不放大) 非负数字(0 / 正小数 / 正整数),无单位;数值越大,占剩余空间比例越高
flex-shrink 定义项目的缩小比例(默认 1,空间不足时等比缩小) 非负数字(0 / 正小数 / 正整数),无单位;设为 0 则空间不足时不缩小
flex-basis 定义项目在主轴方向上的初始大小(优先级高于 width/height) 1. 长度值(px/em/rem/% 等);2. auto(默认,取项目自身宽高);3. content(按内容自适应)
flex flex-grow、flex-shrink、flex-basis 的简写 1. 常用简写:- flex: 1 → 等价于 flex: 1 1 auto- flex: auto → 等价于 flex: 1 1 auto- flex: none → 等价于 flex: 0 0 auto2. 完整写法:flex:
align-self 覆盖容器的 align-items,单独定义某个项目的交叉轴对齐方式 auto(默认,继承容器 align-items)、stretch、flex-start、flex-end、center、baseline

3、使用场景

3.1实现基础横向并排 + 垂直居中(导航栏核心效果)

3 个子元素水平并排,且在父盒子中垂直居中(对应 B 站头部核心布局)

image.png

    /* 父容器(控制子元素) */
    .container {
     ...
      display: flex; /* 开启Flex */
      align-items: center; /* 交叉轴(垂直)居中 */
      ...
    }
  
3.2实现横向两端对齐(导航栏左右分布效果)

logo 居左、登录按钮居右,且两者都垂直居中(网页头部通用布局)。

image.png

  .container {
      ...
      display: flex;
      align-items: center;
      justify-content: space-between; /* 主轴(水平)两端对齐 */
     ...
    }
3.3实现横向平均分布(卡片列表效果)

3 个卡片水平平均分布,间距一致(商品列表 / 功能入口常用)。

image.png

  .container {
      ...
    display: flex;
      align-items: center;
      justify-content: space-around; /* 主轴平均分布(项目两侧间距相等) */
     ...
    }
3.4实现垂直排列(侧边栏)

子元素垂直排列(更改主轴方向),且垂直居中(侧边栏核心布局)。

image.png

  .container {
      ...
     display: flex;
      flex-direction: column; /* 更改主轴为垂直方向 */
      justify-content: center; /* 主轴(垂直)居中 */
      gap: 10px; /* 项目间距(替代margin) */
     ...
    }
3.5实现自动换行(响应式卡片)

元素超出父容器宽度自动换行(响应式布局核心)。

image.png

  .container {
      ...
     width: 800px;
     display: flex;
      flex-wrap: wrap; /* 超出容器宽度自动换行 */
      gap: 15px;
     ...
    }

 .item {
      width: 220px;
      height: 120px;
      ...
    }
3.6实现子元素占满剩余空间(搜索框布局)

搜索框自动占满左右元素的剩余空间(网页搜索栏通用布局)。

image.png

 .container {
      width: 800px;
      height: 80px;
      border: 1px solid #ccc;
      display: flex;
      align-items: center;
        ...
    }
    .left {
      width: 80px;
      height: 40px;
       ...
    }
    .search {
      flex: 1; /* 占满主轴剩余空间 */
      height: 40px;
      ...
    }
    .right {
      width: 80px;
      height: 40px;
      line-height: 40px;
     ...
    }
3.7实现整体居中(登录框 / 弹窗)

在页面中水平 + 垂直居中

image.png

body {
      margin: 0;
      height: 100vh; /* 占满视口高度 */
      display: flex;
      justify-content: center; /* 水平居中 */
      align-items: center; /* 垂直居中 */
       ...
    }
    .login-box {
      width: 400px;
      height: 300px;
      line-height: 300px;
        ...
    }
3.8实现自定义子元素顺序

元素显示顺序为 菜单 2 → 菜单 3 → 菜单 1(无需修改 HTML 结构,仅通过 CSS 调整)。

image.png

  .container {
      ...
    display: flex;
      align-items: center
     ...
    }
.item {
      width: 100px;
      height: 60px;
      ...
    }
    /* 自定义顺序(默认0,数值越小越靠前) */
    .item1 { order: 3; }
    .item2 { order: 1; }
    .item3 { order: 2; }

4、真实应用场景

4.1 百度图片-模仿瀑布流效果

image.pngimage.png

五个块级列容器通过 Flex 水平均分排列(各占父容器 1/5 宽度),每个列容器内垂直排布图片、按钮等内容。

4.2 京东-无限滚动展示商品列表 image.pngimage.png

父容器设 Flex 并允许换行,子元素通过媒体查询 + 宽高限制,实现不同屏幕下自动调整每行展示数量,超出则换行。

淘宝也跟京东一样,使用flex布局来实现的无限滚动展示商品,但是如果你需要更复杂的响应式布局,需精准控制行列、页面多模块分区时就要使用grid了

六、定位布局

定位布局是控制页面元素位置的核心技术,能实现元素脱离文档流、层叠、固定位置等效果。 例如下图中B站首页,很多效果都是使用定位布局实现的。

image.png

常见场景:

固定导航栏:页面滚动时,导航栏始终固定在视口顶部

吸顶效果:元素滚动到特定位置后固定

弹出 / 下拉菜单:鼠标悬浮时显示

悬浮效果:元素浮在其他元素上方

定位分类

  • 相对定位:元素相对自身原位置偏移,不脱离文档流,保留原占位
  • 绝对定位:元素相对最近的已定位父元素偏移,完全脱离文档流,不保留占位
  • 固定定位:元素相对浏览器视口固定,脱离文档流,滚动页面时位置不变
  • 粘性定位:元素在滚动到指定阈值前是相对定位,之后变为固定定位,结合两者特性

1、 场景一:子绝父相实现购物车效果

为什么用 “子绝父相”?

子元素用绝对定位:能浮在上方,且不占位置、不影响其他元素布局,而父元素用相对定位,让子元素能跟着父元素移动(作为定位参考),同时父元素保留原占位、不影响其他布局,例如下图。

image.png

<style>
    /* 父元素:购物车按钮(相对定位) */
    .cart-btn {
      position: relative; /* 父相 */
    ...
    }

    /* 子元素:数量标记(绝对定位) */
    .cart-count {
      position: absolute; /* 子绝 */
      top: -5px; /* 向上偏移 */
      right: -5px; /* 向右偏移 */
      width: 18px;
      height: 18px;
      ...
    }
  </style>
 <button class="cart-btn">
    我的购物车
    <span class="cart-count">3</span>
  </button>

小米官网swiper组件左右翻页的箭头也是采用子绝父相的做法,将左右箭头先使用top调整到50%的高度,然后再使用margin-top往上调整为自身高度的一半,从而实现在swiper中垂直居中效果,如下图所示

image.png

2、 场景二:固定定位实顶部导航栏和侧边悬浮导航

例如下图中官网导航栏和右侧悬浮按钮,就是使用固定定位实现的

image.pngimage.png

3、 场景三:粘性定位实现低代码卡片 tab 标签页吸顶效果

image.pngimage.png

七、网格布局

网格布局是二维布局模型,通过定义行(rows)和列(columns),精准控制网页元素的位置、尺寸,还能实现响应式设计。

网格布局具有上述优势,我们是不是可以抛弃弹性布局,全部使用网格布局呢?

事实上,实际开发中 flex 和 grid 常混用:

Flex:适合快速做一维布局、动态对齐内容(比如单行布局) 等线性排列场景

Grid:适合搭建复杂页面框架,可同时控制行和列的排列,实现真正的二维布局。

例如下图中B站首页布局就是 flex 和 grid 混用实现的

image.png

场景1:实现B站11列2行竖向排列导航栏效果,同时控行列

  /* 1列2行,竖向排列 */
    .bilibili-nav {
 ...
      display: grid;
      /* 核心:列优先排列(竖向填充) */
      grid-auto-flow: column;
      /* 定义2行(每行高度均分) */
      grid-template-rows: repeat(2, 1fr);
      /* 定义11列(每列宽度均分) */
      grid-template-columns: repeat(11, 1fr);
  ...
    }

image.png

场景2:实现阿里巴巴矢量图标库响应式卡片布局(适配手机 / 平板 / PC)

如下图效果,可以直接使用grid布局实现,无须借助媒体查询

...
    /* 卡片网格容器 */
    .card-grid {
      display: grid;
      gap: 20px; /* 卡片之间的水平+垂直间距(无需margin,避免重叠) */
      /* 核心:自动适配列数,列宽最小250px,最大自适应 */
      grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
    }
...
 

image.pngimage.pngimage.png

image.pngimage.png

场景3:实现蔚来汽车官网“2 行 3 列 + 汽车图跨 2 列”效果

...
    /* 红框网格容器 */
    .nio-grid-container {
      display: grid;
      /* 行列比例:匹配2行3列+大元素跨列 */
      grid-template-columns: 2fr 1fr 1fr; 
      grid-template-rows: 1fr 1fr;
...
    }

    /* 1. 汽车图(跨1行2列) */
    .item-car {
      grid-area: 1 / 1 / 2 / 3; /* 行1-2,列1-3 → 跨2列 */
    }
    /* 2. 右上角“生长” */
    .item-grow {
      grid-area: 1 / 3 / 2 / 4;
    }

    /* 3. 中间右侧“11” */
    .item-11 {
      grid-area: 2 / 3 / 3 / 4;
    }

    /* 4. 左下角元素 */
    .item-left-bottom {
      grid-area: 2 / 1 / 3 / 2;
    }

    /* 5. 中间下元素 */
    .item-middle-bottom {
      grid-area: 2 / 2 / 3 / 3;
    }
  </style>

image.pngimage.pngimage.png

简单来说,Grid 是 “为复杂二维布局而生”,能以更少的代码实现更灵活、可控的布局,尤其适合页面框架、响应式卡片、复杂图文组合等场景。

八、多列布局

用于将元素内容自动分割为指定数量的垂直列,如下图效果。有些同学可能会说,下面的布局我们用flex或者grid也能做出来,那么为什么要再学习多列布局呢

因为如果使用flex或者grid布局,我们需要先准备三个盒子,然后再把内容装进去,而使用多列布局则不需要事先准备盒子,直接准备内容就可以了,如下代码所示

image.png

 /* 容器:设置多列 */
    .column-container {
      ...
      /* 多列核心属性 */
      column-count: 3; /* 分为3列 */
      column-gap: 10px; /* 列之间的间隙 */
      column-rule: 2px solid #4da6ff; /* 列分隔线 */
       ...
    }
    /* 子元素:不同高度模拟不规则布局 */
    .item {
      ...
      break-inside: avoid; /* 避免子元素被列分割 */
      ...
    }

适用场景

  1. 长文章分栏:文章自动分列,支持间隙、响应式效果,如语雀官网效果
  2. 图片瀑布流,如阿里巴巴矢量图标库

image.pngimage.png

九、总结

不同技术各有适用场景、优缺点,需配合使用:

  • 简单布局:优先用 Flexbox(一维)或 Grid(二维)
  • 复杂响应式布局:Grid + 媒体查询
  • 文本内容分栏:多列布局(column-count)
  • 兼容旧浏览器:浮动布局,或 Flexbox 降级方案
  • 趋势:CSS Grid 逐渐成为主流,适配更复杂布局场景

vfit.js v2.0.0 发布:精简、语义化与核心重构 🎉

2025年12月29日 12:27

距 vfit.js 初版发布仅一个月,我们就收到了超预期的开发者关注 —— 每一条反馈、每一次讨论,都让我们更清晰地看到大屏适配场景中的真实需求。基于这份热情与信任,我们很高兴地宣布,vfit.js 迎来了 v2.0.0 重大版本更新!

本次更新不仅仅是版本号的提升,更是对大屏适配理念的一次深度梳理。我们从“万能适配”走向了“精准语义”,通过全新的组件体系和重构的核心逻辑,为开发者提供更优雅、更高效的大屏开发体验。

🚀 核心亮点

1. 全新的语义化组件体系 🧩

在 v1.x 版本中,我们主要依赖 FitContainer 这个“万能”组件来处理所有方位的适配。虽然灵活,但在实际开发中,大量的 topleftright 参数往往让代码显得冗余且不够直观。

v2.0.0 引入了 5 个专用方位组件,将常用的布局模式固化为语义清晰的组件:

  • <vfit-lt>  (Left-Top): 左上角定位 🔝
  • <vfit-rt>  (Right-Top): 右上角定位 🔝
  • <vfit-lb>  (Left-Bottom): 左下角定位 🔚
  • <vfit-rb>  (Right-Bottom): 右下角定位 🔚
  • <vfit-center>  (Center): 居中定位 🎯

对比示例:

旧版 (v1.x):

<!-- 需要手动指定单位和具体坐标 -->
<FitContainer :top="0" :left="0">
  <Logo />
</FitContainer>

新版 (v2.0):

<!-- 语义明确,无需多余参数 -->
<vfit-lt>
  <Logo />
</vfit-lt>

这一改变不仅减少了代码量,更让模板结构一目了然。

2. 智能居中与 Transform 冲突解决 ✨

在旧版本中,居中组件往往需要复杂的参数配置,且容易与用户自定义的 CSS transform 发生冲突。

v2.0.0 对 vfit-center 进行了深度优化:

  •  零参数居中:默认即可实现完美的屏幕居中 🎯。
  • Transform 融合:内部逻辑自动处理缩放(Scale)与位移(Translate)的合并,彻底解决了 CSS 样式覆盖导致的偏移问题 🔧。

3. 核心逻辑重构:Composables 🔩

为了提高代码的可维护性和复用性,我们将核心逻辑抽离为两个独立的 Composable 函数:

  • useFitScale: 专注于屏幕尺寸监听与全局缩放比例计算 📏。
  • useFitPosition: 专注于元素定位与坐标转换 📍。

这意味着你不仅可以使用内置组件,还可以直接在自己的组件中引入这些 Hook,实现高度定制化的适配逻辑。

📚 文档与生态升级

  • 中英双语同步:文档现已实现 100% 中英内容对齐,包括最新的组件示例和 API 说明,更好地服务全球开发者 🌐。
  • 赞助者回馈:我们在文档中更新了赞助者列表,感谢每一位支持开源的朋友(包括那位“产品经理的噩梦” 😉)🙏。
  • 首页焕新:重新梳理了首页特性介绍,突出了“组件化精准定位”这一核心优势 ✨。

📦 如何升级

vfit.js v2.0.0 现已发布到 npm。

npm install vfit@latest

对于老用户,FitContainer 依然保留并作为“通用版”组件继续支持,您可以根据项目需求逐步迁移到新的语义化组件 🔄。


感谢您对vfit.js的关注与支持,让我们一起构建更美好的数据可视化大屏!💪

  1. 文档地址 web-vfit.netlify.app/
  2. npm地址 www.npmjs.com/package/vfi…
  3. github地址 github.com/v-plugin/vf…

一次线上样式问题复盘:当你钻进 CSS 牛角尖时,问题可能根本不在 CSS

2025年12月30日 15:25

背景:一个看似很“典型”的样式问题

线上遇到一个样式问题:

页面底部的 footer 无法被撑到预期高度,看起来像是高度计算出了问题。

image.png

从表象看,这是一个非常典型的 CSS 问题

  • 高度没生效
  • 布局被压缩
  • 父子元素高度关系异常

于是我很自然地开始从「局部样式」入手排查。


第一阶段:在“正确但无效”的方向里打转

我的第一反应(相信很多前端都会)是:

  • 是不是 flex 没用对?
  • 是不是 height: 100% 没生效?
  • 是不是父容器没有明确高度?
  • 要不要改成 min-height
  • 会不会是 BFC / overflow 的问题?

于是我开始:

  • 反复调整 footer 和父容器的 CSS
  • 检查 DOM 结构
  • 对比正常和异常页面的样式差异

甚至还把问题丢给了 AI,希望从 CSS 角度找到一个“精确解法”。

👉 但问题是:这些分析逻辑本身都没错,却始终解决不了问题。


第二阶段:意识到自己可能“钻牛角尖了”

真正让我停下来的是一个感觉:

我已经在同一小块区域里反复验证假设,但没有任何实质进展。

这时候我意识到一个危险信号:

  • ❌ 我默认「问题一定在 footer 或它的直接父级」
  • ❌ 我默认这是一个“局部 CSS 失效问题”
  • ❌ 我不断在验证同一类假设

于是我强迫自己换了一个思路:

先不管 footer,看看整个页面的高度是怎么被算出来的。


第三阶段:把视野拉大,问题反而变简单了

当我从页面根节点开始往下看布局结构时,很快发现了一个异常点:

👉 table 容器被设置了 height: 50% 的固定比例高度

这件事的影响是:

  • table 本身高度被强行限制
  • 页面整体高度无法自然撑开
  • footer 即使写得再“正确”,也只能在剩余空间里挤着

而 footer “看起来没被撑高”,其实只是被上游布局截断了


真正的解决方案(非常简单)

/* 原本 */
.table-wrapper {
  height: 50%;
}

/* 修改后 */
.table-wrapper {
  height: auto; /* 或直接移除 */
}

复盘:这个问题真正难的地方是什么?

这个问题并不难,但它有几个很容易让人误判的点:

1️⃣ 表象非常像“footer 自身的问题”

下意识认为:

  • footer 写错了
  • 高度没生效
  • flex 布局有 bug

2️⃣ 局部样式逻辑是“自洽的”

CSS 写的没问题,AI 给的建议也没错,但:

在错误的前提下,所有正确的推导都是无效的。

3️⃣ 真正的问题在“更上游”

布局问题里,经常是:

  • 子元素异常
  • 但根因在祖先节点
❌
❌