普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月8日掘金 前端

构建无障碍组件之Switch Pattern

作者 anOnion
2026年3月8日 23:03

Switch Pattern 详解:构建无障碍开关组件

开关(Switch)是一种模拟物理开关的控件,用于在两个状态(通常是"开"和"关")之间切换。在一些 UI 组件库中,它也被称为 Toggle(切换开关)。本文基于 W3C WAI-ARIA Switch Pattern 规范,详解如何构建无障碍的开关组件。

一、Switch 的定义与核心概念

1.1 什么是 Switch

Switch 是一种特殊的二元状态控件,它:

  • 模拟物理开关的行为
  • 在两个互斥状态之间切换(开/关、启用/禁用)
  • 与 Checkbox 不同,Switch 的状态改变通常会立即生效,无需提交表单

1.2 Switch 与 Checkbox 的区别

特性 Switch Checkbox
视觉表现 滑动开关样式 方框勾选样式
状态语义 开/关(On/Off) 选中/未选中(Checked/Unchecked)
操作反馈 通常立即生效 通常需要提交表单
使用场景 设置项切换、功能启用/禁用 多选项选择、表单提交
ARIA 角色 role="switch" role="checkbox"

1.3 何时使用 Switch

适合使用 Switch 的场景:

  • 系统设置(如:开启/关闭通知)
  • 功能启用(如:启用暗黑模式)
  • 即时生效的选项(如:开启/关闭 WiFi)

适合使用 Checkbox 的场景:

  • 表单中的多选项
  • 需要提交后才生效的选择
  • 列表中的批量选择

二、原生 HTML Switch 实现

HTML5.2 起,<input type="checkbox"> 新增了 switch 属性,可以直接创建原生 Switch:

<label>
  开启通知
  <input
    type="checkbox"
    role="switch" />
</label>

2.1 原生 Switch 的浏览器支持

目前原生 Switch 的支持情况:

  • Safari:完全支持(包括 iOS Safari)
  • Chrome/Edge:需要通过 CSS 自定义样式
  • Firefox:需要通过 CSS 自定义样式

由于跨浏览器兼容性考虑,实际项目中通常使用自定义样式实现。

三、WAI-ARIA 角色与属性

3.1 基本角色

Switch 具有 role="switch"

3.2 状态属性

注意:Switch 只支持 truefalse 两种状态,不支持 mixed(与 Checkbox 不同)。

3.3 可访问标签

Switch 的可访问标签可以通过以下方式提供:

  • 可见文本内容:直接包含在具有 role="switch" 的元素内的文本
  • aria-labelledby:引用包含标签文本的元素的 ID
  • aria-label:直接在开关元素上设置标签文本
<!-- 方式一:可见文本内容 -->
<div
  role="switch"
  aria-checked="false">
  开启通知
</div>

<!-- 方式二:aria-labelledby -->
<span id="wifi-label">WiFi</span>
<div
  role="switch"
  aria-checked="true"
  aria-labelledby="wifi-label"></div>

<!-- 方式三:aria-label -->
<div
  role="switch"
  aria-checked="false"
  aria-label="开启暗黑模式"></div>

3.4 描述属性

如果包含额外的描述性静态文本,使用 aria-describedby

<div
  role="switch"
  aria-checked="false"
  aria-describedby="airplane-desc">
  飞行模式
</div>
<p id="airplane-desc">关闭所有无线连接</p>

四、键盘交互规范

当 Switch 获得焦点时:

按键 功能
Space 切换开关状态(开 ↔ 关)
Enter(可选) 某些实现中也支持切换开关状态

五、实现方式

5.1 原生 HTML + CSS 实现

<label class="switch">
  <input
    type="checkbox"
    role="switch" />
  <span class="slider"></span>
  开启通知
</label>

<style>
  .switch {
    display: flex;
    align-items: center;
    gap: 12px;
    cursor: pointer;
  }

  .switch input {
    appearance: none;
    width: 48px;
    height: 24px;
    background: #ccc;
    border-radius: 12px;
    position: relative;
    cursor: pointer;
    transition: background 0.3s;
  }

  .switch input::after {
    content: '';
    position: absolute;
    width: 20px;
    height: 20px;
    background: white;
    border-radius: 50%;
    top: 2px;
    left: 2px;
    transition: transform 0.3s;
  }

  .switch input:checked {
    background: #005a9c;
  }

  .switch input:checked::after {
    transform: translateX(24px);
  }

  .switch input:focus {
    outline: 2px solid #005a9c;
    outline-offset: 2px;
  }
</style>

5.2 ARIA 实现(自定义样式)

<div
  role="switch"
  tabindex="0"
  aria-checked="false"
  aria-labelledby="switch-label"
  onclick="toggleSwitch(this)"
  onkeydown="handleKeydown(event, this)">
  <span class="switch-track">
    <span
      class="switch-thumb"
      aria-hidden="true"></span>
  </span>
  <span id="switch-label">开启通知</span>
</div>

<script>
  function toggleSwitch(switchEl) {
    const isChecked = switchEl.getAttribute('aria-checked') === 'true';
    switchEl.setAttribute('aria-checked', !isChecked);
  }

  function handleKeydown(event, switchEl) {
    if (event.key === ' ') {
      event.preventDefault();
      toggleSwitch(switchEl);
    }
  }
</script>

<style>
  [role='switch'] {
    display: flex;
    align-items: center;
    gap: 12px;
    cursor: pointer;
  }

  .switch-track {
    width: 48px;
    height: 24px;
    background: #ccc;
    border-radius: 12px;
    position: relative;
    transition: background 0.3s;
  }

  [role='switch'][aria-checked='true'] .switch-track {
    background: #005a9c;
  }

  .switch-thumb {
    position: absolute;
    width: 20px;
    height: 20px;
    background: white;
    border-radius: 50%;
    top: 2px;
    left: 2px;
    transition: transform 0.3s;
  }

  [role='switch'][aria-checked='true'] .switch-thumb {
    transform: translateX(24px);
  }

  [role='switch']:focus {
    outline: 2px solid #005a9c;
    outline-offset: 2px;
  }
</style>

六、常见应用场景

6.1 系统设置项

<fieldset>
  <legend>通知设置</legend>

  <label>
    <div>
      <span>推送通知</span>
      <p>接收应用推送消息</p>
    </div>
    <input type="checkbox" checked />
  </label>

  <label>
    <div>
      <span>邮件通知</span>
      <p>接收每日摘要邮件</p>
    </div>
    <input type="checkbox" />
  </label>

  <label>
    <div>
      <span>短信通知</span>
      <p>接收重要提醒短信</p>
    </div>
    <input type="checkbox" checked />
  </label>
</fieldset>

6.2 功能开关

<div>
  <label>
    <div>
      <span>🌙</span>
      <div>
        <span>暗黑模式</span>
        <p>使用深色主题保护眼睛</p>
      </div>
    </div>
    <input type="checkbox" />
  </label>

  <label>
    <div>
      <span>🔒</span>
      <div>
        <span>自动锁定</span>
        <p>闲置 5 分钟后自动锁定</p>
      </div>
    </div>
    <input type="checkbox" checked />
  </label>
</div>

6.3 隐私设置

<fieldset>
  <legend>隐私设置</legend>

  <label>
    <div>
      <span>公开个人资料</span>
      <p>允许其他用户查看您的资料</p>
    </div>
    <input type="checkbox" />
  </label>

  <label>
    <div>
      <span>显示在线状态</span>
      <p>让好友知道您在线</p>
    </div>
    <input type="checkbox" checked />
  </label>

  <label>
    <div>
      <span>允许搜索到我</span>
      <p>通过用户名搜索可以找到您</p>
    </div>
    <input type="checkbox" checked />
  </label>
</fieldset>

七、最佳实践

7.1 优先使用原生 Checkbox

原生 HTML <input type="checkbox"> 配合 CSS 样式是最可靠的方式,它自动继承了浏览器的无障碍特性。

7.2 提供清晰的标签

始终为 Switch 提供清晰的标签,说明开关控制的功能:

<!-- 推荐 -->
<label>
  <span>开启自动保存</span>
  <input type="checkbox" />
</label>

<!-- 不推荐:没有标签或标签不清晰 -->
<input type="checkbox" />
<span>开启</span>

7.3 使用描述文本

对于复杂的设置项,提供额外的描述文本:

<label>
  <div>
    <span>数据同步</span>
    <p>自动将数据备份到云端</p>
  </div>
  <input type="checkbox" />
</label>

7.4 避免在 Switch 上嵌套其他交互元素

<!-- 不推荐 -->
<label>
  <input type="checkbox" />
  开启功能 <a href="/help">了解更多</a>
</label>

<!-- 推荐 -->
<div>
  <div>
    <span>开启功能</span>
    <a href="/help">了解更多</a>
  </div>
  <input type="checkbox" />
</div>

7.5 状态反馈

确保用户能够清楚地看到开关的当前状态:

  • 使用颜色变化表示开关状态(如:蓝色表示开启,灰色表示关闭)
  • 提供焦点样式以便键盘用户识别
  • 禁用状态使用较低的透明度并禁用鼠标交互

7.6 移动端触摸区域

确保 Switch 有足够的触摸区域(至少 44x44px),可以通过增加 padding 或增大开关尺寸实现。

八、Switch、Checkbox 与 Radio 的选择

场景 推荐组件 原因
即时生效的设置项 Switch 模拟物理开关,立即反馈
表单中的多选项 Checkbox 需要提交后才生效
单选场景 Radio 互斥选择
列表中的批量操作 Checkbox 支持多选

九、总结

Switch 是一种直观的状态切换控件,适用于需要即时反馈的设置场景。与 Checkbox 相比,Switch 更强调"开/关"的语义,通常用于控制功能的启用和禁用。

构建无障碍的 Switch 组件需要注意:使用正确的 ARIA 角色(role="switch")、提供清晰的标签、确保键盘可访问性(Space 键切换),以及为屏幕阅读器用户提供准确的状态反馈。

开发者应优先使用语义化的 HTML 元素,确保所有用户都能顺畅地使用开关功能。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

多写点skill吧,写的越多这行业死的越快。

作者 华洛
2026年3月8日 22:43

多写点skill吧,写的越多这行业死的越快。

“经验资产化”行为,正在加速打工人被淘汰的进度。

2026年可能是AI大量淘汰人类的元年。

让我们从一个问题开始: 企业为什么需要雇佣一位员工?

很简单,因为这位员工能带来对应的价值。虽然现代企业并非完全依靠榨取剩余价值盈利,但对于单一员工个体而言,没有价值就意味着没有议价权以及没有工作。

那么,员工是如何产生价值的?不排除一部分岗位靠资源与手段,但绝大部分互联网岗位,是靠 经验来变现的。

然而现在的 Skill、技能型 Workflow、技能型 Agent,搭配着 Claude Code、OpenClaw、Coze 等平台工具,正在将这些赖以生存的经验变成巨大的经验资产化工具。

各行各业的精英们,正争先恐后地将自己大脑中的“经验”,封装成廉价甚至免费的代码包,亲手递给那个即将取代他们的系统。

我们正目睹一场职场历史上最诡异的自杀式狂欢。

什么是经验资产化?

所谓经验资产化,就是打工人把自己大脑中原本模糊、非标的“行业直觉”与“隐性知识”,拆解成一个个明确的步骤,并结合 AI 的理解能力,封装成可以自动运行的技能(Skill)、工作流(Workflow)或智能体(Agent)。

这个行为本质上是在不断“武装”AI 的手脚。当 AI 被武装得越来越完善,也就意味着“人类”的价值被稀释得越来越快。

为了不伤害各位的心灵,我们不妨以文案写手为例:

一位资深写手之所以值钱,是因为他知道如何根据用户画像调整语气,如何埋梗,如何煽情。这是他的“手感”,是他十年磨一剑的功力。

现在,他搭建了一个工作流,把这些逻辑写成了提示词和流程。用户只要输入主题,这个工作流就能自动通过检索、生成、润色,最终产出一篇质量上乘的底稿或文案。

就在这个瞬间,写手经验的稀缺性彻底崩塌。他十年的功力,从“依附于人体的稀缺资源”,变成了“脱离肉身的可复制数字资产”。

经济学的基本原理告诉我们:稀缺产生价值。

当你把独门绝技封装成工作流上传到商店,你实际上是在无限供给你的经验。

一旦你的经验可以以 API 的形式被百万次调用,且边际成本接近于零,作为肉身载体的你,价值就归零了。

  • 以前公司花 3 万月薪雇你,是因为只有你懂;
  • 现在公司花 30 块钱订阅你的 Bot,因为它跑得比你快,还不用交社保。

曾经,写手是文案的生产工具;现在,这个工作流也是生产工具。

或许目前这个工作流的能力还不如顶级写手,但可怕的是,正有源源不断的写手在协助武装这个工具。而独立的写手个体呢?会逐渐灵感枯竭、思维固化、年老体衰。

此消彼长,工具必将赢得最终的胜利。

而写手们,会因为失去了在当前商业环境中的议价权,被迫下沉到更低级的劳动力市场中。

除了写手们,本次的淘汰赛,还有哪些岗位更容易被淘汰呢?

不同于以往的“机器换人”(替代体力劳动)。这一次,“经验资产化”直接冲击的是白领阶层的核心腹地:

  1. 纯靠熟练度生存的岗位:比如画图的、写代码片段的、做基础数据分析的。这类人的技能最容易被标准化,也最快被封装。
  2. 以前依靠“人”来协调的岗位:行政、调度、初级项目管理。现在一个配置好的 Agent 就能自动分发任务、追踪进度,效率远超人类。
  3. 放弃大脑训练的人:对AI执行结果没有判断能力的人,不建议用AI,因为你会被AI骗得很惨。有些人因为有了方便的 OpenClaw 或 GPTs,放弃了对自己大脑的训练,高强度依赖 AI,逐渐失去了独立的判断能力。

应对之策

AI的发展是历史的车轮滚滚而来,个体都是尘埃,AI必然会让许多岗位被代替掉,但是也会带来新的岗位需求。

例如提示词工程师,AI产品,AI应用工程师等。

面对浪潮,我们的应对之策可以分为两个维度:

  1. 寻找新赛道:关注 AI 带来的新岗位,新的供需关系里藏着新的机会。
  2. 利用 AI 升维:在本岗位上提高价值。未来的工作形态,大概率是少量核心人员指挥多个 Agent 进行协同作战。所以,不要只做提供数据的“燃料”,不要无脑地把技能包喂给 AI,而要努力成为 AI 的使用者、指挥者和整合者

你说如果有一天你真的被淘汰了怎么办?

唯一的答案就是:学习,无论环境如何恶劣,学习都能带你走出困境。

结语

经验资产化,是一场不可逆的浪潮。

历史的车轮已经轮动起来了,生产力的提升必然带来生产人数的下降。

希望这次,我们还能守住我们的价值。

我是华洛,关注我,学习更多AI落地的实战经验与技巧。

加油,共勉。

☺️你好,我是华洛,All in AI多年,专注于AI在产品侧的应用以及企业AI员工的设计。

专栏文章

# 聊聊我们公司的AI应用工程师每天都干啥?

# SEO还没死,GEO之战已经开始

# 从0到1打造企业级AI售前机器人——实战指南二:RAG工程落地之数据处理篇🧐

# 从0到1打造企业级AI售前机器人——实战指南一:根据产品需求和定位进行agent流程设计🧐

# 聊一下MCP,希望能让各位清醒一点吧🧐

# 实战派!百万PV的AI产品如何搭建RAG系统?

# 团队落地AI产品的全流程

# 5000字长文,AI时代下程序员的巨大优势!

从函数式编程介绍

2026年3月8日 22:30

函数式编程

函数式编程并不是说我们的程序所有的功能都具备下面的纯函数特征,而是规范一些公共函数的封装,就是比较考验我们的抽象能力,其可以让我们的程序更加简洁、易维护

ps:一般也就公共代码比较符合函数式编程,要是整个项目完全都按照下面特征根本不可能哈,我们的业务怎么写是吧,hook副作用函数就是一个鲜明例子😄

函数式变成主要有以下特征:

  1. 纯函数:函数的输出只取决于输入,避免副作用函数的出现(内部不会直接引入外部变量更新外部变量,例如:闭包场景),这样的纯函数更加可控,没有可怕的代码侵入副作用,代码也更加简洁、易维护
  2. 不可变性:数据一旦创建就不可变了,如果要改数据,那么需要创建一个新的变量,使用或返回新的变量,优点就是,当操作一个对象时,不会不小心修改传入对象而引起难以察觉的bug
  3. 函数组合:函数组合以减少重复代码的产生,例如:开发中比较常见的公共代码提取
  4. 高阶函数:函数的入参或者出参是函数指针,例如:函数柯里化、forEach、sort等函数
  5. 惰性计算,在必要的时候才计算执行函数,函数在第一次执行时,会用更高效的逻辑覆盖自身,仅第一次调用时有初始化/判断的开销,后续调用无额外成本,主要对于一些分支只会走一次场景的优化,例如:addEvent 根据不同版本的实现逻辑,单例+一次性初始化

这里面比较难理解的就是副作用和惰性计算了

副作用函数

就是函数体内部逻辑完全由入参决定,直接跟外部内容存在耦合

举一个简单的例子

let a = ...
//引用改变外部变量,对外部内容产生依赖,修改入参等
function handle(type) {
   if (type) {
       a.b = 'b'
   } else {
       a.b = ''
   }
}

惰性计算

如下所示

//兼容代码处理,第一次根据环境变量判断,返回一个最终的高阶函数作为最终统一调用的函数
function addEvent(element, type, handler) {
  // 第一次调用时,根据环境重写函数本身
  if (element.addEventListener) {
    // 重写:后续调用直接用 addEventListener
    addEvent = function(element, type, handler) {
      element.addEventListener(type, handler, false);
    };
  } else if (element.attachEvent) {
    // 重写:后续调用直接用 attachEvent
    addEvent = function(element, type, handler) {
      element.attachEvent('on' + type, handler);
    };
  } else {
    // 重写:后续调用直接用 onxxx
    addEvent = function(element, type, handler) {
      element['on' + type] = handler;
    };
  }
  // 执行本次调用的逻辑(第一次调用时,先重写再执行)
  return addEvent(element, type, handler);
}


function getConfig() {
  // 第一次调用时初始化配置
  const config = {
    apiBaseUrl: 'https://api.example.com',
    timeout: 5000,
    token: localStorage.getItem('token')
  };
  
  // 重写函数:后续调用直接返回缓存的 config
  getConfig = function() {
    return config;
  };
  
  // 第一次调用返回初始化的 config
  return config;
}

ps:由于执行时重塑函数,对于现在导出还是要注意引用指针的问题,可能不小心就是每次都是调用修改前的了,可能一般都是模块化导出,看似没问题,什么情况下会有问题呢(调用时,通过对象直接调用函数,不提前展开,猜猜为啥)😄

最后

提到了惰性函数就想到了懒加载,对于页面图片拉加载有哪些方式呢

ps:个人上来就想到了自定义变量 + IntersectionObserver;滚动时根据偏移手动观察dom是否在视野内,当然细节的需要自己处理了,好了就到这里了

全新唯杰WebCAD编辑平台发布:全面拥抱AI,WebCAD智能体(Agent)来了

作者 vjmap
2026年3月8日 21:51

前言

唯杰地图vjmapvjmap3d一直专注于 CAD 图纸与 GIS 地图的完美结合与高性能展示,帮助众多企业解决了“CAD图纸在Web端看图与GIS融合”的难题。

然而,随着业务的深入,我们收到了大量用户的反馈:“只能看图和做简单的批注还不够,我们需要在浏览器里直接编辑 CAD 图纸!”“我们需要一个能完全替代传统桌面 CAD 的轻量级 Web 方案!”“如果能让 AI 帮我们画图和改图就好了!”

为了满足这些硬核需求,历经数月的底层架构重构与开发,今天,我们非常激动地宣布:全新唯杰WebCAD编辑平台正式发布!

这不仅仅是一个能在网页上画线的工具,而是一个全面拥抱 AI、支持海量图纸处理、具备完整 CAD 编辑能力的现代化 WebCAD 平台。


一、产品功能与核心优势

唯杰 WebCAD 致力于打造下一代 Web 端的专业 CAD 引擎,在保证轻量级的同时,提供了媲美桌面端 CAD 的强大能力与极致体验。

1. 轻量级 WebCAD 编辑平台方案

唯杰 WebCAD 采用现代化的前端技术栈(TypeScript + WebGL + WebAssembly)构建,无需安装任何插件或客户端,在浏览器中即可直接运行。

  • 丰富的实体支持:内置 20+ 种核心几何实体(如直线 Line、圆 Circle、圆弧 Arc、多段线 Polyline、样条曲线 Spline、单行/多行文字 Text/MText、块引用 Insert、填充 Hatch 等),并支持自定义实体扩展。

  • 专业的编辑命令:实现了上百个专业的绘图与修改命令(如 COPYMOVEROTATESCALETRIMEXTEND 等),以及完善的属性编辑(颜色、图层、线型、线宽等)。

  • 完整的撤销重做:内置强大的 Undo/Redo 系统,支持操作历史记录与批量撤销,让编辑更加安心。

    image-20260308211706102

2. AutoCAD 格式 DWG 完全兼容

数据互通是 CAD 平台的基础。唯杰 WebCAD 实现了对 AutoCAD 格式的深度兼容:

  • 原生读写:支持多个版本的 DWG 和 DXF 文件格式的导入与导出。
  • 元数据保留:完美保留图纸中的图层、线型、块定义、文字样式(支持 TrueType 和 SHX 字体)、外部参照等核心属性。
  • 无缝衔接:编辑完成后的图纸可一键导出为标准 DWG,与传统桌面 CAD 软件无缝配合。

3. 大图纸处理与瓦片编辑模式

针对动辄几十兆甚至上百兆的超大 CAD 图纸(如城市规划图、大型建筑图),传统的 Web 方案往往会卡顿甚至崩溃。我们独创了瓦片编辑模式

  • 按需加载:将 CAD 图纸在服务端预渲染为多级分辨率的图片瓦片,客户端只需加载视口内的瓦片,实现秒级打开。
  • 区域/图层编辑:通过 TILEEDITAREA(区域编辑)或 TILEEDITLAYER(图层编辑)命令,按需加载局部实体数据进行编辑。

4. 协同编辑与版本管理

告别传统图纸通过文件传来传去的低效协作!唯杰 WebCAD 引入了类似 Git 的图纸版本管理理念:

  • 分支管理:支持创建 mainfeature 等分支,多名设计师可以基于同一张图纸进行独立的在线制图与更新。
  • Patch 版本链:每次保存都会生成增量的 Patch 版本,记录新增、修改、删除的实体及图层变更。
  • 冲突解决:合并分支时,系统会自动检测冲突,并提供服务端优先、本地优先或手动解决的冲突解决策略,大幅提升团队协作效率。 image-20260308211740575

5. GIS 地图无缝叠加

继承了 vjmap 强大的 GIS 基因,WebCAD 编辑平台原生支持 CAD 图纸与互联网地图(如天地图、高德等)的无缝叠加。在真实的地理空间坐标系下进行 CAD 绘制与编辑,让工程设计与地理信息完美融合。

image-20260308211909839

6. 丰富的高级智能功能

除了基础绘图,平台还内置了众多提效神器:

  • 表格提取:提供强大的表格提取 API,智能识别 CAD 图纸中的表格线条与文字,支持全图、区域或指定图层提取,一键导出为结构化数据。

  • SVG 转 CAD:轻松将矢量图形导入为 CAD 实体。

  • 图片转矢量:自动提取栅格图像的轮廓并转换为 CAD 线条。

  • 中心线提取:为复杂的管道、道路等双线图形快速提取中心线。

    image-20260308212129185

7. 强大灵活的二次开发能力

唯杰 WebCAD 提供了极其丰富的 API 接口和完善的扩展机制,赋能开发者打造专属 CAD 软件。用户完全可以基于这个平台,结合自身的业务需求,二次开发出属于自己的“WebCAD + AI”垂直行业平台。

为了帮助开发者快速上手,我们提供了上百个开箱即用的二次开发在线示例,涵盖了从基础初始化到高级功能扩展的方方面面:

  • 基础与 UI 扩展:包括引擎初始化、多语言切换、Ribbon 功能区定制、右键菜单扩展、对话框与侧边面板开发。

  • 实体与几何计算:涵盖所有基础实体(直线、圆、多段线等)的创建与编辑,以及复杂的几何计算(相交判断、包围盒计算、面积距离测量等)。

  • 交互与命令开发:演示了如何获取用户输入(点选、框选、数值输入),以及如何利用状态机模式开发带有实时预览和撤销重做功能的自定义命令。

  • 插件与事件系统:展示了插件的完整生命周期管理,以及如何利用 Before/After 事件驱动架构和反应器系统(Reactor)实现实体间的联动更新。

  • 高级应用场景:提供了瓦片模式加载、图纸版本控制与分支合并、SVG/图片导入矢量化、表格智能提取等综合性业务场景示例。

    image-20260308212240172

8. 丰富的内置插件与插件生态

唯杰 WebCAD 采用了高度模块化的设计,许多高级功能都是通过插件(Plugin)的形式提供的。平台不仅支持用户自定义插件,还内置了一系列强大的官方插件,开箱即用:

  • AI 插件 (ai-plugin):接入大模型,提供自然语言绘图、智能问答、图纸语义搜索等 AI 助理功能。

  • 视图与渲染插件 (view3d-plugin):扩展了 3D 视图查看能力,让 2D 图纸也能在三维空间中进行多视角预览。

  • 插入与提取插件 (insert-plugin):提供图片转矢量导入(INSERTIMAGE)、插入表格(INSERTTABLE)以及智能提取图纸表格(EXTRACTTABLE)等高级功能。

  • 批注插件 (annotation-plugin):提供专业的尺寸标注、面积测量、引线标注等批注工具。

  • 行业应用插件:如建筑插件(architecture-plugin)和管网拓扑插件(network-graph-plugin),展示了如何将基础 CAD 能力封装为垂直行业的专业工具。

    image-20260308212338991

    image-20260308212502470

开发者可以参考这些官方插件的源码,利用脚手架工具(create-plugin.js)快速初始化自己的插件工程,将专属业务逻辑无缝集成到 WebCAD 的 Ribbon 菜单和命令系统中。


二、全面拥抱 AI:WebCAD 智能体 (Agent) 来了!

在 AI 时代,工具的进化不应仅仅停留在“把功能搬到网页上”,更应该改变人们使用工具的方式。唯杰 WebCAD 业内率先接入 MCP (Model Context Protocol) 协议,让 CAD 平台真正拥有了“大脑”。

我们为 WebCAD 打造了专属的 AI 智能体(Agent),你可以通过自然语言直接与 CAD 图纸对话!

官方 MCP 服务接入

唯杰 WebCAD 提供了官方的 MCP 服务地址,你可以将其直接配置到 CursorTrae 等主流 AI 编程 IDE 或智能体平台中:

  • MCP 地址: https://vjmap.com/server/aicad/mcp
{
  "mcpServers": {
    "vjcad": {
      "url": "https://vjmap.com/server/aicad/mcp"
    }
  }
}

image-20260308212541636

AI 智能体能做什么?聊聊“技能 (Skill)”机制

接入 MCP 后,你的 AI 助手将具备操作 WebCAD 的超能力。为了适应不同复杂度的任务,WebCAD Agent 提供了三种交互模式,并配合强大的“技能包”机制:

三种交互模式
  1. Ask (问答模式):只读模式。AI 只能读取图纸信息、分析数据,不会对图纸进行任何修改。适合用于图纸审查、数据统计或单纯的技术咨询。
  2. Agent (执行模式):默认的执行模式。AI 接收指令后,会直接调用 API 在画布上进行绘制或修改。适合明确、具体的绘图或修改任务。
  3. Plan (规划模式):针对复杂任务的高级模式。AI 先将任务拆解为多个步骤(如:1.建图层 2.画轮廓 3.加标注),生成一个执行计划,按步骤自动执行。

秘密在于我们为 WebCAD Agent 打造的**“技能包 (Skill)”机制**。相当于给 AI 装备了不同领域的“专业知识库”与“操作说明书”:

  1. 基础绘图与修改 (vjcad-sdk 技能): 这是 AI 的基础 API 速查手册。你可以用自然语言让它画图或改图。
    • “根据这组数据(2/1 5度,2/2 9度...),帮我画一张带坐标轴和格网的气温变化折线图。”
    • “把图纸里所有红色的直线,批量改成蓝色的虚线。” AI 会自动查阅 API,生成代码并在画布上执行,瞬间完成繁琐的绘制与批量修改。
  2. 图纸智能分析 (图纸分析 技能): 面对一张陌生的复杂图纸,AI 可以通过分析脚本快速帮你摸清底细。
    • “分析一下这张图纸。” AI 会自动遍历实体、提取文字、识别表格,并输出专业的分析报告:“这是一张建筑平面图,包含 12 个图层,核心图层是墙体和门窗(占65%)...”
  3. 垂直领域绘图 (如:户型图绘制 技能): 通过注入行业规范,AI 甚至能完成特定领域的复杂任务。
    • “帮我画一个三室一厅的户型图,大概 120 平米。” AI 会按照技能中定义的步骤:先建图层 -> 定义门窗块 -> 画墙体 -> 插入门窗 -> 尺寸标注,一步步为你生成一张符合基本规范的初稿。

image-20260308213059258

通过 WebCAD Agent,非 CAD 专业人员也能通过对话完成人工操作繁琐的图纸处理,而专业开发者则可以利用 AI 快速生成二次开发代码,极大地拓宽了 CAD 的应用边界。最重要的是,企业和开发者可以基于唯杰 WebCAD 平台和 MCP 协议,编写自己的“技能包”(如电气原理图技能、管网设计技能),轻松构建出属于自己的、深度贴合垂直行业场景的“WebCAD + AI”专属平台。


三、总结

唯杰 WebCAD 编辑平台不仅是一个功能完备、性能卓越、高度兼容 DWG 的轻量级 Web 绘图工具,更是一个面向未来、全面拥抱 AI 与协同的现代化工程平台。

无论是应对超大图纸的瓦片编辑,还是类似 Git 的版本控制,亦或是强大的插件扩展能力,都彰显了其作为专业级 WebCAD 引擎的硬核实力。而 MCP 协议的接入,更是赋予了它前所未有的智能化交互体验。

百闻不如一试,唯杰 WebCAD 编辑平台现已全面开放体验!欢迎访问以下链接,感受下一代 WebCAD 与 AI 结合的魅力:

扫码登录方式

2026年3月8日 21:14

前言

假如你碰到了一个扫码登录场景,有个pad或者pc端给定一个二维码,让已经登录过得用户扫码实现登录,并且这个二维码是随机生成的,不能是固定组合,你会怎么设计呢

这个扫码登录能解决,那么扫码处理订单此类功能也会有思路了

当然本篇只讲扫码登录有哪些方式,比较哪种方式更好用(当然可能不全,欢迎补充😄)

扫码登录方案

做扫码登录首先要明白,需要做到哪些功能

  • 后端提供生成二维码的唯一标识
  • 前端、pad请求接口获取后端的唯一表示生成二维码
  • 手机端扫码读取到其中的唯一表示
  • 手机扫码端拿着标识请求后端的登录接口,此时需要后端提供登录确认接口,手机端传递唯一标识和用户令牌给后端,将该用户和二维码唯一标识绑定
  • 前端、pad在生成为二维码后,需要和后端持续通信,当得知后端二维码唯一标识和用户绑定后,表示用户扫码登录了,此时传给前端、pad登录所需用户信息或者令牌,表示登录成功

此过程其他都比较像,不同的是生成二维码的前端、pad后端绑定标识后通信方式,也就是最后一条的最后一步,而这一步需要考虑的是,用户生成二维码后需要等待后端反馈用户登录消息

此通信方式大致有以下几种:socket、SSE、http轮询、硬件通信(不稳定)

socket通信

socket即时通信这也是大家可能第一时间想到的,要想前后端长时间通信,那么socket很适合,使用socket有如下几个步骤:

  1. 前后端建立socket通信保持连接
  2. 后端返回二维码标识,前端、pad生成二维码继续保持连接,等待后续登录消息传来
  3. 后端等待过程发现手机端扫码登录了,继续传递给前端、pad 登录成功信息
  4. 登录成功终止socket连接

当然上面也可以通过http获取二维码,socket仅仅是等到后端登录成功消息

SSE通信

虽然socket即时通信这也是大家可能第一时间想到的,但后面发现整个过程都是后端在推送消息给前端,那么可以采用更节省资源的SSE模式,也就是后端单向通信模式,发现挺好,相比socket更节省资源,算是一个http特殊情况(Connection:keep-alive还了解么),现实并不是那么常用,仅仅在一些场景比较常用(例如:股票),毕竟SSE看着虽简单,也不是所有人能直接上手,更主要的是这个不是前端写的应用(ios、android、小程序),可能就更没那么友好了

  1. 建立SSE通信
  2. 后端返回二维码标识,前端、pad生成二维码继续保持连接,等待后续登录消息传来
  3. 后端等待过程发现手机端扫码登录了,继续传递给前端、pad 登录成功信息
  4. 登录成功断开连接

当然上面也可以通过http获取二维码,SSE仅仅是等到后端登录成功消息

http轮询(简单粗暴、推荐)

上面的情况都会加大开发复杂度,并且资源说是节省,然而并没有节省那么多,一个登陆需要花费多久呀,轮询几次没登录就算超时就行了,现实更多地是采用http轮询的方式,简单粗暴,且功能无论对于前后端来说看起来更加简单粗暴好维护

当然有人会反驳,频繁的http可能比socket、sse更占用资源,现实是2~5s的轮询无论是人力开发,还是硬件开销可能都比实际想象的小一些(无论是前端开发开始后端开发都要简单不少),且标准的http请求上看起来更友好,尤其是一些公司统计接口占用时长更在意,此时表现更友好

ps:长时间的连接通信,在一些公司可能会触发接口过慢警告,非常麻烦,因此单次http一直同步等待后端影响的实现也在此列

对于此交互,有如下步骤:

  1. 前端请求接口后端返回二维码标识,然后轮询登录状态的接口,假设2s,1分钟算过期
  2. 轮询登录接口过程发现已经关联登录,后端会返回登录结果,结束

其他硬件设备通信(不稳定,不推荐,特殊场景可能有奇效)

不太推荐,算是一个特殊情况的开拓思维的,就举一个蓝牙例子吧

假设都有蓝牙,通过蓝牙通信,直接将用户的令牌通过蓝牙传递过去,直接就登录成功了,缺点是两端蓝牙通信,对于不太了解的会花费更多时间,当前其他硬件通信也是一样的(特殊场景有奇效)

最后

就介绍到这里吧,实际上可能还有其他方案,就不一一介绍了,主流就这些了,欢迎提出,要是有更好的,这边也会继续编辑更新出来😄

前端截图html2canvas

2026年3月8日 21:04

前言

有时候前端可能会有给页面截图的需求,此时我们可以用比较常见的工具html2canvas,毕竟自己写的话也非常麻烦,一般人写不出来,下面就简单介绍下它吧

html2canvas 实际上就跟我们浏览器渲染html页面一样,他会先进行样式计算,然后开始布局,然后逐步绘制到canvas上,再把绘制好的canvas返回给我们就是了

同时它还处理了网络图片跨域等问题,可以说一步到位,自己写的花,光想就知道很麻烦了,这里就不多介绍了,直接看怎么使用

案例

导入 html2canvas

yarn add html2canvas

使用案例

import { useRef } from "react";
import html2canvas from "html2canvas";

function App() {
  const ref = useRef()
  
  return (
    <div className="App">
      <div ref={ref}>
        <h1>React Demo</h1>
        <h2>Welcome to the React demo application!</h2>
      </div>
      <div onClick={() => {
        //默认第二个参数option不用填写就行
        html2canvas(ref.current,  {
          //useCORS: true, // 解决跨域图片渲染问题,有网络图片可以考虑
          //scale: 2, // 提高分辨率,避免模糊
          //backgroundColor: '#ffffff', // 设置背景色(默认透明)
        }).then(canvas => {
          //直接下载图片
          const link = document.createElement('a');
          link.download = 'screenshot.png';
          link.href = canvas.toDataURL();
          link.click();
        });
      }}>点击</div>
    </div>
  );
}

export default App;

浏览器指纹

2026年3月8日 21:03

浏览器指纹是基于浏览器对当前用户采集唯一特征的手段,相当于在用户未登录的情况标记唯一用户,一般用于多账号串联,安全防护等

浏览器会根据电脑、浏览器、系统软件特征、用户行为等来获取相对唯一的内容

基础指纹:硬件参数(dpi、分辨率,cpu等)、User-Agent、操作系统、语言、时区、DPI、软件列表等

高级指纹:Canvas 渲染哈希(单是这个唯一程度就能达到99%)、WebGL 渲染特征、字体列表、音频指纹、WebRTC 本地 IP、插件列表

行为指纹:Cookie、LocalStorage、浏览轨迹、点击 / 滚动习惯

常见的场景比如广告服务,可能就需要精准知道用户,才能给用户推更合适的内容,以保证引流收入

对于一些场景可能担心用户频繁更换账号存在异常危险触发风控等(实际用到指纹的情况,肯定不是单一情况或者单一指纹了)

ps:了解有这么个东西即可,也有利于提高我们的认知,也许一些场景就会想到它呢,是吧

ps2:以前自己也写过装机的自动化程序,就是给线下手机专卖店安卓手机自动安装软件的(新系统没有qq微信之类的),能给店铺减少不少人工成本,当时就是根据手机型号、系统版本、管家版本等来作为标识指纹对应安装策略的,感觉是不是有点这个感觉了

好了就到这里了,自己也是最近才刷到的,算是学到了😄

【节点】[Object节点]原理解析与实际应用

作者 SmalBox
2026年3月8日 20:33

【Unity Shader Graph 使用与特效实现】专栏-直达

Object节点是Unity Shader Graph中一个基础且重要的工具节点,它允许着色器访问当前渲染对象的各种参数信息。在着色器编程中,经常需要获取对象本身的一些属性数据来实现特定的视觉效果,Object节点正是为此目的而设计的。

描述

Object节点为着色器提供了访问当前渲染对象基本属性的能力。在着色器开发过程中,了解对象在世界空间中的位置和缩放信息对于实现许多视觉效果至关重要。比如,根据对象位置调整光照效果、基于对象缩放控制纹理平铺次数,或者创建与对象变换相关的动画效果等。

需要注意的是,Object节点的行为会根据所使用的渲染管线而有所不同。Unity支持多种渲染管线,包括通用渲染管线(URP)和高清渲染管线(HDRP),每种管线可能有自己的实现方式和特性。这意味着在不同渲染管线中使用相同的Shader Graph时,Object节点可能会产生不同的结果。

对于跨管线兼容性的考虑,建议开发者在构建计划在多个渲染管线中使用的着色器时,在实际部署前分别在目标管线中进行测试和验证。这样可以确保着色器在所有预期环境中都能正常工作,避免因管线差异导致的视觉不一致或功能异常问题。

渲染管线支持情况

Object节点在Unity的主要渲染管线中都能正常工作,包括:

  • 通用渲染管线(URP) - 这是Unity推荐的轻量级渲染管线,适合移动平台和性能要求较高的项目
  • 高清渲染管线(HDRP) - 面向高端平台的高保真渲染管线,提供最先进的图形功能

端口详解

Object节点提供了两个输出端口,每个端口都输出特定类型的对象属性数据。理解这些端口的含义和用法对于有效使用Object节点至关重要。

Position 输出端口

Position端口输出当前渲染对象在世界空间中的位置坐标。这是一个三维向量(Vector3),包含对象的X、Y和Z轴坐标值。

世界空间是Unity场景的全局坐标系,所有对象的位置都是相对于这个坐标系的原点(0,0,0)来定义的。获取对象的世界位置对于许多着色器效果非常有用:

  • 实现基于距离的淡入淡出效果
  • 创建与场景位置相关的特效,如雾效或环境光遮蔽
  • 实现对象与全局风场或其他世界空间效果的交互
  • 制作基于位置的动画或材质变化

在Shader Graph中,Position端口的输出可以直接连接到其他节点的输入,用于计算或控制各种材质属性。

Scale 输出端口

Scale端口输出当前渲染对象在世界空间中的缩放值。同样是一个三维向量(Vector3),分别表示对象在X、Y和Z轴上的缩放倍数。

对象的缩放信息对于创建与对象尺寸相关的效果非常重要:

  • 根据对象大小调整纹理平铺次数,保持纹理密度一致
  • 实现非均匀缩放的正确视觉效果
  • 创建基于对象尺寸的特效,如大对象和小对象使用不同的细节级别
  • 制作响应对象缩放的动画或变形效果

需要注意的是,Scale端口输出的是世界空间的缩放值,这意味着它考虑了对象层级中所有父对象的缩放累积效果。这与本地缩放不同,本地缩放只考虑对象自身的缩放变换,而不包括父对象的缩放影响。

技术实现细节

理解Object节点背后的技术实现有助于更有效地使用它,并在遇到问题时能够进行调试和优化。

位置数据的获取

在底层,Object节点的Position端口通过内置的着色器变量获取对象的世界位置。在URP中,这通常是通过SHADERGRAPH_OBJECT_POSITION宏实现的,该宏封装了从模型矩阵中提取位置信息的复杂计算。

模型矩阵(Model Matrix)是将顶点从对象空间转换到世界空间的变换矩阵,它包含了对象的位置、旋转和缩放信息。从模型矩阵中提取位置信息需要访问矩阵的特定列或行,具体取决于矩阵的存储方式。

缩放数据的计算

Scale端口的实现相对复杂,因为它需要从模型矩阵中提取纯粹的缩放信息,排除旋转和位置的影响。生成的代码示例展示了这一过程:

HLSL

float3 _Object_Scale = float3(length(float3(UNITY_MATRIX_M[0].x, UNITY_MATRIX_M[1].x, UNITY_MATRIX_M[2].x)),
                             length(float3(UNITY_MATRIX_M[0].y, UNITY_MATRIX_M[1].y, UNITY_MATRIX_M[2].y)),
                             length(float3(UNITY_MATRIX_M[0].z, UNITY_MATRIX_M[1].z, UNITY_MATRIX_M[2].z)));

这段代码通过计算模型矩阵每个轴向量的大小来获得缩放值。具体来说:

  • X轴缩放通过计算模型矩阵前三行的X分量向量的长度获得
  • Y轴缩放通过计算模型矩阵前三行的Y分量向量的长度获得
  • Z轴缩放通过计算模型矩阵前三行的Z分量向量的长度获得

这种方法确保了即使对象包含旋转变换,也能正确提取缩放值,因为向量的长度不受旋转影响。

性能考虑

Object节点通常具有很好的性能表现,因为它访问的是每个对象的基础属性数据,这些数据在渲染过程中已经可用。然而,在性能敏感的场景中,仍有一些最佳实践值得注意:

  • 避免在片段着色器阶段不必要地使用Object节点,特别是在移动平台上
  • 如果可能,在顶点着色器阶段计算基于对象属性的值,然后传递给片段着色器
  • 对于静态对象,考虑使用自定义着色器变量预先计算并传递所需数据

实际应用示例

Object节点在着色器开发中有广泛的应用场景。以下是一些常见的用例,展示了如何利用Object节点创建各种视觉效果。

基于位置的材质效果

利用Object节点的Position输出,可以创建根据对象在世界中位置变化的材质效果。例如,实现一个随着高度变化的雪材质:

  1. 将Object节点的Position端口连接到Split节点的输入
  2. 提取Y分量(高度值)
  3. 使用Remap节点将高度值重新映射到0-1范围
  4. 将结果用作雪纹理和基础纹理的混合因子

这种方法可以让低处的对象显示基础材质,高处的对象显示雪材质,中间高度则平滑过渡。

自适应纹理平铺

使用Scale输出可以创建根据对象大小自动调整的纹理平铺,确保不同大小的对象具有一致的纹理密度:

  1. 将Object节点的Scale端口连接到Multiply节点的输入
  2. 将纹理平铺参数作为另一个乘数输入
  3. 将计算结果用作纹理节点的平铺值

这样,较大的对象会自动增加纹理重复次数,而较小的对象则减少重复次数,保持视觉上的一致性。

世界空间坐标可视化

Object节点可以用于调试和可视化目的,帮助开发者理解对象在场景中的实际位置和大小:

  1. 将Position输出直接转换为颜色值
  2. 或者将Scale输出用于控制对象的自发光强度
  3. 创建特殊的调试材质,直观显示对象的空间属性

动态环境交互

结合其他节点,Object节点可以用于创建对象与环境动态交互的效果:

  1. 使用Position输出计算对象到特定点(如玩家位置)的距离
  2. 根据距离控制材质的透明度、颜色或其他属性
  3. 实现基于接近度的提示效果或区域标记

与其他节点的组合使用

Object节点很少单独使用,通常需要与其他Shader Graph节点结合才能发挥最大作用。以下是一些常见的组合方式:

与Math节点组合

Math节点可以对Object节点的输出进行各种数学运算:

  • 使用Length节点计算对象到原点的距离
  • 使用Distance节点计算两个对象位置之间的距离
  • 使用Normalize节点获取对象位置的方向向量
  • 使用各种算术运算修改位置或缩放值

与Vector节点组合

Vector节点可以帮助处理和转换Object节点输出的向量数据:

  • 使用Split节点分离位置或缩放的各个分量
  • 使用Combine节点重新组合修改后的分量
  • 使用Transform节点在不同坐标空间之间转换位置数据

与Texture节点组合

结合Texture节点,可以创建基于对象属性的纹理效果:

  • 使用Position作为三维纹理的UV坐标
  • 根据Scale调整二维纹理的平铺参数
  • 创建基于对象位置和朝向的动态投影效果

常见问题与解决方案

在使用Object节点时,可能会遇到一些常见问题。了解这些问题及其解决方案可以提高开发效率。

位置数据不正确

如果Object节点的Position输出似乎不正确,可能的原因包括:

  • 对象变换被动画或脚本覆盖 - 检查是否有代码在每帧修改对象位置
  • 着色器在错误的渲染阶段使用位置数据 - 确保在适当的着色器阶段访问位置信息
  • 坐标系混淆 - 确认理解世界空间与对象空间的区别

缩放值异常

当Scale输出不符合预期时,可能的解决方案:

  • 检查对象变换层级 - 世界缩放是累积的,包括所有父对象的缩放
  • 验证矩阵计算 - 在复杂变换情况下,手动计算缩放值进行对比
  • 考虑使用本地缩放 - 如果需要对象自身的缩放而非累积缩放,可能需要自定义实现

性能问题

如果使用Object节点导致性能下降:

  • 减少在片段着色器中的使用 - 尽可能在顶点着色器中计算
  • 使用LOD技术 - 为远处对象使用简化的着色器变体
  • 批量处理类似对象 - 通过材质属性块一次性设置多个对象的参数

跨管线兼容性

确保着色器在不同渲染管线中正常工作:

  • 分别在不同管线中测试着色器
  • 使用条件编译或不同的节点图处理管线特定功能
  • 查阅官方文档了解特定管线的限制和最佳实践

高级技巧与最佳实践

对于有经验的着色器开发者,以下高级技巧可以帮助更好地利用Object节点:

自定义对象属性

虽然Object节点提供了基本的位置和缩放信息,但有时需要访问其他对象属性。在这种情况下,可以考虑:

  • 使用Custom Function节点扩展Object节点的功能
  • 通过脚本将额外数据传递到着色器作为材质属性
  • 利用URP或HDRP的特定功能获取更多对象信息

优化策略

在性能关键的应用中优化Object节点的使用:

  • 预先计算不经常变化的值并通过材质属性传递
  • 使用着色器变体为不同情况提供优化版本
  • 在对象移动或缩放时动态更新着色器参数,而不是每帧计算

调试技术

有效调试Object节点相关的问题:

  • 使用Position或Scale输出直接作为颜色值可视化数据
  • 在Shader Graph中创建调试视图,同时显示多个属性
  • 使用Frame Debugger检查实际传递给GPU的数据

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Agent Skill 是什么?

作者 JacksonChen
2026年3月8日 18:11

做 Agent 开发一段时间后,大部分人都会遇到同一个问题:

Prompt 越写越长,模型执行越来越飘。

你把所有规范、流程、示例一股脑塞进系统 Prompt,token 蹭蹭涨,模型的注意力却被稀释了——它在"同时看着"几十件事,结果每件事都做得不够准。

Agent Skill 就是在解决这个问题。

核心思路:按需加载

把不同能力拆成独立的"技能包",Agent 根据当前任务,只加载需要的那一个。

就像你电脑装了几十个软件,但你只打开当前要用的那个,其他的不占内存。

Context Window 就是 Agent 的"内存",留给当前任务的空间越干净,执行越稳定。

一个 Skill 的三层结构

第一层:Metadata(元信息)

技能的"身份证"——叫什么、能干什么、什么时候触发。

name: code-review
description: 审查代码,识别安全风险、逻辑问题、性能瓶颈
triggers:
  - "帮我看一下这段代码"
  - "review 一下"
input:
  - code_snippet: string
output:
  - review_report: string

Metadata 很轻,系统可以把所有技能的 Metadata 一起加载,让 Agent 先选用哪个技能,而不需要把完整内容全暴露出来。

第二层:Instruction(执行指令)

真正告诉 Agent "这件事怎么做",只在执行这个技能时才加载进上下文。

## Code Review 执行指南

按优先级审查以下维度:
1. 安全性:SQL 注入、XSS、敏感信息硬编码 → 标记 [HIGH]
2. 逻辑正确性:边界条件、空值处理、并发问题
3. 性能:循环内重复计算、不必要的数据库查询
4. 可读性:命名是否清晰,复杂逻辑是否有注释

每条问题注明 [HIGH/MEDIUM/LOW],说明位置、问题、修改建议。

第三层:Resources(外部资源)

有些技能执行时还需要额外的东西:规则库、模板、脚本、外部工具调用等。这些放在 Resources 层,Agent 按需拉取,用完即走。

/skills/code-review/
  metadata.yaml          ← 索引,常驻
  instruction.md         ← 执行时加载
  resources/
    security_rules.json  ← 按需拉取
    review_template.md

三层各司其职

层级 作用 加载时机
Metadata 技能索引,用于路由选择 始终在上下文
Instruction 执行指南,指导 Agent 行为 技能被选中时
Resources 外部数据/脚本/模板 执行过程中按需

这就是所谓的渐进式披露——信息随执行进度逐步展开,而不是一开始全部堆在上下文里。

设计 Skill 时最容易踩的坑

粒度问题。 一个技能对应一个完整的用户意图,不要太粗也不要太细。

code-review ✅ 对应"帮我 review 代码"这个完整意图
backend-development ❌ 太粗,一个技能管了太多事
check-variable-naming ❌ 太细,组合起来很麻烦

描述要精准。 Agent 靠 Metadata 的 description 来判断用不用这个技能,描述模糊就容易选错。


举例

比如我们现在需要生成一个视频,但是并不知道怎么做,这时候可以借助这个skill,skills.sh/vercel-labs…,我们把这个 skill 安装到本地

npx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-best-practices

然后直接向 ai 提问,ai 会自动寻找这个 skill,并利用这个skill生成视频

帮我生成一个前端行业报告,使用remotion-best-practices这个skill

可以看到 ai 最后直接帮我创建了一个前端项目

执行看看效果,为了较少大小,我调整为4倍速,可以看到效果还不错,这个就真的像一个视频编辑器一样, 如果不借助 skill,恐怕很难实现这个效果。

写在最后

Agent Skill 的本质是对执行能力的结构化管理

三层结构解决的问题很实际:让 Context Window 里只出现当前任务需要的内容,让模型更专注,执行更稳定,技能库更好维护。

规模小的时候感受不明显,一旦你的 Agent 需要处理十几二十种不同任务类型,这套结构的价值就很清楚了。

浏览器渲染zz

作者 Sunshine111
2026年3月8日 17:39

一、浏览器渲染流程

  1. HTML文档解析

  1. 为了提高解析效率,浏览器在开始解析前,会启动一个预解析的线程,率先下载 HTML 中的外部 CSS 文件和 外部的 JS 文件。

  2. 遇到 CSS暂停渲染(不暂停 HTML 解析),下载 + 解析 CSS

    1. 当主线程解析到<link>标签时,如果外部CSS文件尚未下载解析完成,主线程不会等待,而是继续解析后续HTML。
    2. 下载CSS的工作由预解析线程负责
    3. 主线程和预解析线程并行工作
    4. 但会阻塞布局 构建(必须等待CSSOM完成)
    5. CSSOM构建完成之前,JS执行会被阻塞(因为JS可能会查询元素的样式,如果CSSOM没好,查到的样式是不完整的,为了防止”读到旧样式”)
    6. 遇到 JS 浏览器暂停 HTML 解析,确保 JS 执行时 DOM 树状态完整。外部 JS 并非 "阻塞下载"(预解析线程会异步下载外部 JS),而是阻塞 HTML 解析和 DOM 构建(下载完成后执行 JS 时,主线程暂停解析);内联 JS 无下载过程,直接阻塞 HTML 解析(async/defer 可改变外部 JS 的阻塞行为)。
  1. 生成DOM树

解析的过程中遇到HTML元素会解析HTML元素最终生成DOM树

  1. 生成CSSOM树

解析的过程中遇到style标签link元素行内样式CSS样式,会解析CSS生成CSSOM树

第一步完成后,会得到 DOM 树和 CSSOM 树,浏览器的默认样式、内部样式、外部样式、行内样式均会包含在 CSSOM 树中。

  1. 样式计算

  1. 核心步骤

  1. CSS 结构化:将外部样式表(<link>)、内部样式表(<style>)、内联样式(style属性)转换为浏览器可理解的StyleSheet结构;

  2. 属性标准化:将 CSS 属性值转换为标准化格式(如2em → 32pxblue → rgb(0,0,255)bold → 700);

  3. 样式 继承 与层叠

    1. 继承:子节点继承父节点的可继承属性(如font-sizecolor)(不可继承属性(如border/padding/width)需显式声明,继承属性的优先级低于显式声明,层叠时需注意)
    2. 层叠:按 “选择器权重→声明顺序→来源(用户代理样式 < 用户样式 < 内联样式)” 规则解决样式冲突;
    3. 计算最终样式:为每个 DOM 节点生成ComputedStyle(计算样式),存储所有属性的最终值。
  1. 性能关键点

  • 复杂选择器(如div > ul li a)会增加样式计算耗时,建议简化选择器(如使用类选择器.link);

  • CSS 规则匹配是 “从右到左” 的(如.container .item先匹配.item再匹配.container),避免通配符*和深层嵌套。

    • 从右到左匹配的核心原因:减少匹配次数(先定位所有.item,再筛选其父级是否为.container,而非遍历所有.container再找子级.item

  1. 布局

  1. 核心步骤

  1. 构建布局 :基于 DOM 树和 CSSOM 树合并生成的渲染树,过滤掉不可见节点(如<head>display: none的元素),保留可见节点

  2. 布局计算

    1. 基于布局树,从根节点开始,算每个元素的几何属性——包括元素的位置(left、top、right、bottom)、大小(width、height、padding、margin)、以及元素之间的关系。
    2. 遵循盒模型规则,结合视口大小、父节点布局约束完成计算;
  3. 布局树仅包含可见节点的几何布局信息,与 DOM 树结构不完全一致(如display: none节点被剔除)。布局是“自上而下”的:从根节点开始,依次计算每个子节点的几何属性,因为父元素的大小和位置会影响子元素。

  4. 布局是“流式布局”:浏览器会按照文档流的顺序,依次计算元素的位置,一旦计算完成,就会确定元素在页面中的最终位置(除非后续触发重排)。

  1. 关键特性

  • 重排(Reflow) :布局计算是递归的,子节点布局变化会触发父节点重新计算,开销极大;
  • 布局抖动(Layout Thrashing) :频繁读取 + 修改布局属性(如offsetTop+style.top)会强制浏览器反复计算布局,导致性能暴跌(使用requestAnimationFrame批量操作,或先读取所有布局属性缓存,再批量修改,避免‘读 - 改 - 读 - 改’的循环触发多次重排)

当修改了节点的几何属性,如大小、位置,就需要重新计算布局,这个过程也叫做重排

获取节点的几何属性时,如 offsetWidth / getBoundingClientRect/clientWidth强制重排

  1. 分层

  • 主线程会使用一套复杂的策略对整个布局树中进行分层。
  • 将页面进行分层,之后某个层变化时,就可以单独更新这一个图层,从而避免了全页面的更新,提高效率。
  • 分层不仅取决于transformopacity ,还取决于videocanvasiframewill-change以及复杂的堆叠上下文(z-index)
  • 避免 过度创建合成层 的问题,每个图层需占用 GPU 显存,图层过多(如数百个独立图层)会导致显存不足,反而触发 GPU 卡顿,因此will-change需谨慎使用(仅给高频动画元素声明)。

  1. 绘制

绘制阶段为每个图层生成绘制列表,定义 “先画什么、后画什么”(如先画背景,再画边框,最后画文本),

  1. 核心流程

    1. 渲染引擎将图层的绘制过程拆解为原子化指令(如 “绘制矩形”“绘制文本”“绘制渐变”);
    2. 按绘制顺序组合指令生成绘制列表(Paint List);
  1. 关键特性

    1. 核心绘制顺序:背景色 → 背景图 → 边框 → 文本 / 替换元素 → 子元素;
    2. 子元素绘制规则:z-index 优先(数值大的后画),无 z-index 按 DOM 顺序(后写的后画)
    3. 关键原则:后绘制的内容会覆盖先绘制的,子元素默认在父元素文本之上绘制。
    4. 绘制是 分层 :不同层可以独立绘制
  1. 性能关键点

  • 绘制指令越复杂(如多层阴影、渐变),绘制耗时越长;
  • 避免给大尺寸图层添加复杂绘制属性(如box-shadow)。

  1. 光栅化

上面我们已经获得了文档结构、元素的样式、元素的几何关系、绘画顺序,接下来把这些信息转化为显示器中的像素才能显示,这个转化的过程,就叫做光栅化。

  1. 核心流程

    1. 接收绘制列表:合成线程从主线程接收每个图层的绘制列表
    2. 分块: 将大图层切割成小块(通常是256x256或512x512像素的图块)
    3. 优先级排序 优先光栅化视口内的图块(用户当前可见区域)
    4. 执行光栅化: 将每个图块的绘制指令转换为 位图 (实际像素)
    5. 存储 位图 将生成的位图存储在GPU 内存(或CPU内存)中,供合成使用

  1. 合成

合成是将光栅化后生成的一块块位图,按照正确的层叠顺序合并成最终画面,显示在屏幕上的过程。

  1. 核心步骤

    1. 接收 位图 : 合成线程从 GPU 显存中获取已完成光栅化的图块位图
    2. 计算变换: 根据图层的transform、opacity等属性,计算每个图块需要应用的变换矩阵
    3. 绘制图块到合成层: 将每个图块的位图绘制到对应的合成层(按正确位置和变换)
    4. 合并合成层: 按照层叠顺序(z-index、堆叠上下文)将所有合成层合并成一张最终图像
    5. 提交显示: 将最终图像提交给GPU的显示缓冲区,等待屏幕刷新显示
  1. 性能关键点

  1. 优先使用「仅触发合成」的属性,使用 transform (位移/缩放/旋转) 和 opacity。它们只影响合成,GPU 处理极快。
  2. 避免过度分层,如果图层太多、太大(显存爆炸),或者图层间依赖关系太复杂(导致无法并行合成),GPU 也会忙不过来,导致掉帧。

二、CPU和GPU

  • CPU(主线程):主导逻辑运算、DOM 操作与布局计算。任务繁重且串行执行,高负载下易引发主线程阻塞,导致页面卡顿。
  • GPU(合成线程):专攻图形渲染、位移、旋转与缩放。利用硬件加速并行处理,高效丝滑,且不占用主线程资源
  1. CPU vs GPU 本质区别

对比维度 CPU GPU
核心数量 4-16 个高性能核心 数千个简单核心
并行能力 同时处理几个任务 同时处理几千个任务
设计目标 复杂逻辑控制 大规模并行计算
适合任务 串行、分支预测 矩阵、图像、并行
  1. 渲染流程中的分工

CPU 负责"怎么画"的复杂决策,GPU 负责"快点画"的并行执行,两者配合实现流畅的渲染。

渲染阶段 执行者 核心原因
HTML 解析 CPU 复杂文本解析、树结构(DOM)构建
CSS 解析 CPU 样式规则解析、选择器匹配、构建 CSSOM
样式计算 CPU 样式继承、层叠规则、属性值标准化计算
布局 (Layout) CPU 复杂几何计算(宽 / 高 / 坐标)、元素依赖关系处理
分层 CPU 分层策略判断、堆叠上下文分析
绘制 (Paint) CPU 生成原子化绘制指令(不直接生成像素)
光栅化 GPU 优先(或 CPU) 并行像素填充,将指令转换为位图(GPU 并行效率远高于 CPU)
合成 GPU 并行图层合并、变换矩阵运算(transform/opacity 仅触发此阶段)

前期阶段( HTML 解析 → 绘制): CPU 主场这些阶段涉及复杂的逻辑判断、递归计算和指令生成,属于串行、高逻辑密度的任务,CPU 是唯一高效执行者;若主线程被阻塞(如长时间 JS 执行),会直接导致渲染卡顿。

后期阶段(光栅化 → 合成): GPU 主场这两个阶段是并行 、像素级运算,GPU 天生擅长处理大规模并行计算,因此性能远优于 CPU;修改 transform/opacity 时,浏览器可跳过前期阶段,仅触发 GPU 合成,是前端性能优化的方案。


三、重排、重绘与合成

页面交互过程中,JS/CSS 修改会触发渲染流水线的局部更新,按开销从高到低分为三类:

类型 触发条件 涉及渲染阶段 性能开销 优化优先级
重排(Reflow) 几何属性变化:宽高、位置、display、DOM 增删 布局→分层→绘制→光栅化→合成 极高 最高
重绘(Repaint) 绘制属性变化:颜色、背景、阴影、边框色 绘制→光栅化→合成 中等
合成(Composite) 合成属性变化:transform、opacity 仅合成阶段 极低 低(优先用)
  1. 重排触发场景

  • 修改几何属性:width: 200pxleft: 10pxmargin: 8px
  • 增删 / 移动 DOM 节点:appendChildremoveChildinsertBefore
  • 窗口操作:resize、普通scroll 仅触发合成(无重排),只有 scroll 时触发了元素几何位置变化(如固定定位元素跟随滚动)才会重排;
  • 读取布局属性:offsetTopclientWidthgetComputedStyle(强制浏览器提前完成重排)。
  1. 重绘触发场景

  • 修改颜色属性:color: redbackground-color: #000
  • 修改边框 / 阴影:border-color: bluebox-shadow: 0 0 10px #000
  • 修改文本样式:text-shadow: 1px 1px 2px #333
  • 修改背景:background-image: url(new.png)background-position: center
  1. 合成触发场景

  • transformtranslate/scale/rotate/skew
  • opacityopacity: 0.5
  • will-change:提前声明元素即将变化的属性。

四、渲染性能优化

渲染阶段 优化目标 核心优化手段
网络请求 减少阻塞 预解析、关键资源优化
HTML解析 减少阻塞 精简HTML、JS异步
CSS解析 减少阻塞 关键CSS内联、避免@import
样式计算 减少耗时 简化选择器、避免通配符
布局 避免重排 批量操作、离线DOM
分层 合理分层 will-change、独立图层
绘制 减少重绘 仅合成属性、避免大面积重绘
光栅化 加速光栅化 视口优先、GPU加速
合成 提升合成效率 transform/opacity、图层管理
  1. 减少重排/重绘

  • 批量修改 DOM 和样式(如使用 DocumentFragment 批量添加节点,或先隐藏节点 display: none,修改完成后再显示)。
  • 避免频繁读取布局属性(如 offsetWidth、clientHeight、getBoundingClientRect()),若需多次读取,可缓存结果。
  • 使用 transform 和 opacity 实现动画(仅触发合成,不触发布局和重绘),替代修改 width、height、top 等几何属性。
  • 避免 表格布局:表格布局的重排成本极高(一个单元格变化会导致整个表格重新布局),优先用 Flex/Grid 布局。
  1. 利用GPU加速

  1. 动画用 transform opacity

    1. transform(如translatescale)和opacity的修改仅触发合成,不触发重排和重绘,是性能最优的动画实现方式:
  2. 创建独立 图层

    1. 对频繁动画的元素,用will-change提示浏览器创建独立图层,提前做好优化准备
  1. 避免渲染阻塞

  1. JS 优化

    1. 首屏非必需的 JS 用async/defer加载;
    2. 大型 JS 文件用代码分割(Code Splitting),按需加载;
  2. CSS 优化

    1. 外部 CSS 文件放在<head>中(确保样式优先加载);
    2. 非首屏 CSS 用媒体查询media="print"等,不阻塞首屏渲染:

script标签中defer和async的区别

如果没有defer或async属性,浏览器会立即加载并执行相应的脚本。它不会等待后续加载的文档元素,读取到就会开始加载和执行,这样就阻塞了后续文档的加载。

其区别如下:

  • 无:遇到script标签时,浏览器暂停 HTML 解析,先同步下载 JS 文件;下载完成后立即执行 JS 代码,执行完毕后才恢复 HTML 解析,JS 下载 / 执行全程阻塞 HTML 解析,若 JS 文件体积大、下载慢,会导致页面长时间白屏,首屏渲染延迟;
  • defer:遇到script标签时,浏览器开始异步下载,HTML页面解析完才执行JS文件。立即下载,但延迟执行(整个页面都解析完毕之后再执行,不阻塞)。多个带async属性的标签,不能保证加载的顺序;
  • async:遇到script标签时,浏览器开始异步下载,下载完成后如果此时 HTML 还没有解析完,浏览器会暂停解析,先让 JS 引擎执行代码,执行完毕后再进行解析(可能会阻塞)。多个带defer属性的标签,按照加载顺序执行;

前端经典面试题:从 URL 输入到页面展示,中间经历了什么?

作者 xiaoxue_
2026年3月8日 17:19

从 URL 输入到页面展示的完整流程

先来看一下下面两张整体流程示意图:

B7C4BAB1-BEE7-4916-9A43-928C4EC1C4EF.png

QQ20260308-170528.png

该流程是前端春招核心考题(考察覆盖率 80%),横跨前端渲染、计算机网络、操作系统(进程通信) 三大领域,核心是浏览器多进程协同完成 “导航”(用户输入 URL 到页面开始解析的全过程),以下按阶段拆解所有细节:

一、前置概念基础:浏览器多进程架构(操作系统层面)

浏览器采用多进程架构,整个流程依赖不同进程的 IPC(Inter-Process Communication,进程间通信)协作,先明确核心进程的职责:

进程类型 核心职责(全流程关键动作)
浏览器主进程 1. 接收用户 URL 输入、处理交互反馈(如输入框响应);2. 管理浏览历史(新 URL 入栈);3. 控制 loading 状态(请求开始显示、完成隐藏);4. 管理子进程(网络进程 / 渲染进程),通过 IPC 通信;5. 触发页面卸载事件(beforeunload)、更新页面状态;6. 管理缓存、Cookie、localStorage 等文件存储
网络进程 1. 为渲染进程 / 主进程提供网络下载能力;2. 处理 HTTP 请求 / 响应的封装与解析;3. 与渲染进程建立 “数据管道” 传输 HTML / 静态资源;4. 处理 DNS 解析、TCP 握手等网络层逻辑
渲染进程 1. 接收网络进程传输的页面数据;2. 解析 HTML/CSS、构建 DOM/CSSOM/ 渲染树;3. 向主进程 “确认提交”,表示准备好接收数据;4. 负责页面最终渲染(本流程截止到 “准备解析数据”,渲染细节为后续环节)

补充概念

  • 进程(Process):操作系统分配资源的最小单位(如内存、CPU);
  • 线程(Thread):操作系统执行指令的最小单位(一个进程可包含多个线程)。

二、阶段 1:URL 输入与预处理(用户交互→标准化 URL)

当用户在浏览器地址栏输入内容并回车,主进程首先完成 URL 预处理:

1. URL 标准化补全

结合文档中的实际示例,URL 补全逻辑如下:

  • 自动补充协议 / 域名前缀:如输入time.geekbang.org → 补全为https://time.geekbang.org(https 为浏览器默认安全协议);输入www.baidu.com → 补全为https://www.baidu.com
  • 补全默认端口:https 默认 443、http 默认 80(如https://www.baidu.com → 实际访问https://www.baidu.com:443
  • 关键词识别:若输入非 URL(如 “前端面试”),自动拼接至默认搜索引擎 URL 后(如https://www.baidu.com/s?wd=前端面试,文档中https://www.baidu.com/s?wd=即为搜索引擎的查询格式)。

2. 重定向预处理(提前拦截跳转逻辑)

若输入的原始 URL 需要跳转(如文档中的http://time.geekbang.org),会触发服务器重定向:

  • 触发条件:服务器返回 301/302/307 状态码 + Location响应头;

  • 重定向类型细节:

    • 301(永久重定向):浏览器会缓存跳转关系,后续直接访问新 URL;
    • 302(临时重定向):不缓存,每次访问都需服务器返回跳转指令;
    • 307(临时重定向):不允许修改请求方法(如 POST 请求跳转后仍为 POST,302 可能改为 GET);
  • 浏览器强制优化:即使服务器未返回重定向,部分浏览器会强制将 http 升级为 https(如http://time.geekbang.org → 直接跳转https://time.geekbang.org,两者会返回的内容一致也验证了这一优化)。

三、阶段 2:DNS 域名解析(域名→IP,分布式数据库查询)

网络通信依赖 IP 地址(如127.0.0.1),但用户输入的是域名(如www.baidu.comtime.geekbang.org),需通过 DNS(分布式数据库)完成 “域名→IP” 映射,解析层级从本地到全球逐步降级:

1. DNS 解析全流程(优先级从高到低)

表格

解析层级 细节说明
本浏览器 DNS 缓存 Chrome 可通过chrome://net-internals/#dns查看缓存的 IP 数组;缓存有过期时间,不同浏览器独立维护
本地操作系统 DNS 缓存 多浏览器共享(如 Chrome/Firefox 共用 Windows/macOS 的 DNS 缓存),由操作系统内核维护
Hosts 文件 - 路径(Windows):C:\Windows\System32\drivers\etc\hosts(需管理员权限编辑);- 用途:本地开发测试(如映射127.0.0.1 → douyin.com,模拟带域名访问本地代码);- 特殊规则:localhost/0.0.0.0等域名无需解析,默认指向127.0.0.1
局域网 DNS 缓存 路由器 / 局域网内其他设备访问过的域名记录(如公司内网缓存常用域名)
运营商 DNS 服务器 电信 / 移动 / 联通的城市级节点(缓存全网高频域名,如文档中的www.baidu.comtime.geekbang.org
全球 DNS 层级 根服务器(全球 13 台)→ 顶级域服务器(如.com/.cn 服务器)→ 权威服务器(域名所属商服务器)

2. DNS 扩展细节(面试高频)

  • 分布式集群:DNS 返回的 IP 并非直接指向业务服务器,而是 Nginx 等反向代理服务器的 IP;
  • 负载均衡:反向代理通过 “轮询(Round Robin)” 将请求分配给后端多台服务器,动态适配服务器负载;
  • 地域优化:DNS 根据用户 IP 归属地,优先返回就近机房的 IP(用户当前位置为中国上海,访问www.baidu.comtime.geekbang.org时,DNS 会优先返回离上海近的机房 IP),降低网络延迟。

3.DNS 相关知识补充

  • Chrome 可通过 chrome://net-internals/#dns 查看 DNS 缓存中记录的 IP 地址列表。若缓存中存在对应域名的有效记录,浏览器在解析该域名时,会优先使用缓存中的 IP 地址,而无需发起新的 DNS 查询。

image.png

  • Hosts 文件用途:本地开发测试(如映射 127.0.0.1 → douyin.com,模拟带域名访问本地代码)。hosts 文件的本质是一个本地的 “域名→IP” 映射表,它的优先级比 DNS 服务器更高。当你在浏览器中输入一个域名时,系统会先检查 hosts 文件中是否有对应的 IP 映射。如果有,就直接使用这个 IP,而不会去请求 DNS 服务器。

image.png

四、阶段 3:TCP 三次握手(建立可靠传输连接)

HTTP/HTTPS 基于 TCP 协议(可靠传输),传输数据前需通过 “三次握手” 确认双方收发能力,核心是交换SYN(同步序号)和ACK(确认序号):

握手阶段 通信方向 核心报文(简化版) 核心目的
第一次 客户端→服务端 发送SYN x(x 为随机初始序号) 客户端向服务端 “请求建立连接”,告知自己的发送起始序号
第二次 服务端→客户端 发送ACK x+1 + SYN y(k 为服务端随机序号) 1. ACK x+1:确认接收客户端的 SYN 请求;2. SYN y:向客户端确认自己的发送能力
第三次 客户端→服务端 发送ACK y+1 客户端确认接收服务端的 SYN 请求,双方确认 “收发能力均正常”,连接建立

48ca501712822a88fa93e67cbf982da9.png

关键补充

  • 为何是 “三次” 而非两次:两次握手仅能确认 “客户端→服务端” 的单向能力,三次才能确认双向收发能力

  • HTTPS 额外步骤:需在 TCP 握手后完成 TLS 握手(验证证书、协商加密算法),比 HTTP 多一层安全校验。HTTPS 的本质:“HTTP 套上 TLS 安全壳”,

    • 无 TLS 时(HTTP) :TCP 连接建立后,HTTP 数据以明文直接传输,任何人截取网络数据包都能看到内容(如账号密码、请求参数),且数据可能被篡改。
    • 有 TLS 时(HTTPS) :TCP 三次握手建立连接后,先通过 TLS 握手建立加密通道,再把 HTTP 数据(请求行、请求头、响应体等)传入这个通道,数据会被加密后传输,截取后无法直接解读,且能检测是否被篡改。
  • TCB本质上是操作系统内核为每一条 TCP 连接单独维护的一个 “专属档案”,用来记录这条连接的所有关键状态和上下文信息。它会记录连接的所有关键信息,主要包括:

    • 连接状态:CLOSEDLISTENSYN-SENTSYN-RCVDESTABLISHED 等(就是你图里看到的那些状态)。
    • 序号信息:当前的发送序号(seq)、确认序号(ack)、窗口大小等,用于保证数据可靠传输。
    • 缓冲区指针:指向发送和接收缓冲区的地址,用于数据的收发。
    • 定时器:重传定时器、保活定时器等,用于超时重传和连接保活。
    • 对端信息:对端的 IP 地址、端口号等。

五、阶段 4:HTTP 请求与响应传输(应用层数据交互)

TCP 连接建立后,网络进程开始封装 HTTP 请求、与服务器交互:

1. 发送 HTTP 请求(客户端→服务端)

请求由 “请求行 + 请求头 + 请求体(可选)” 组成:

  • 请求行:核心信息,格式为请求方法 路径 HTTP版本,示例:

    • 访问https://time.geekbang.orgGET / HTTP/1.1
    • 访问https://www.baidu.com/s?wd=前端面试GET /s?wd=前端面试 HTTP/1.1
    • 常见请求方法:GET(查询数据,如文档中所有网页的访问均使用 GET)、POST(提交数据)、HEAD(仅获取响应头);
  • 请求头:携带业务 / 认证信息,高频字段:

    • Authorization:JWT Token/OAuth2.0 等认证信息;
    • Cookie:浏览器存储的用户标识(由服务端Set-Cookie响应头设置);
    • User-Agent:浏览器 / 设备信息(如Chrome/114.0.0.0 Windows NT 10.0);
  • 请求体:仅 POST/PUT 等方法使用,存储提交的数据(如 JSON、表单参数)。

2. 接收 HTTP 响应(服务端→客户端)

响应由 “状态行 + 响应头 + 响应体” 组成:

  • 状态行:HTTP版本 状态码 状态描述,示例:HTTP/1.1 200 OK(返回 200 状态码,表示请求成功);

    • 核心状态码:

      • 200:请求成功,响应体返回页面 / 资源数据(https://time.geekbang.org返回极客时间页面内容,https://www.baidu.com返回百度热搜页面);
      • 301/302/307:重定向,需重新请求Location头的 URL;
      • 404:资源不存在;500:服务端内部错误;
  • 响应头:控制浏览器行为,高频字段:

    • Content-Type:标识响应体类型(核心!):

      • text/html:HTML 文档,网络进程将数据传给渲染进程解析;
      • text/css/image/jpeg/application/javascript:静态资源,浏览器直接缓存;
      • application/json:接口数据,交给 JS 处理;
    • Location:重定向目标 URL;

    • Cache-Control:控制资源缓存策略(如max-age=3600表示缓存 1 小时);

  • 响应体:实际数据(https://time.geekbang.org返回的极客时间页面源码、https://www.baidu.com返回的百度热搜页面源码)。

六、阶段 5:导航提交与页面接收(进程协作)

HTTP 响应返回后,浏览器主进程、网络进程、渲染进程协同完成 “导航提交”:

  1. 建立数据管道:浏览器主进程通知网络进程,与渲染进程建立数据管道(直接传输 HTML 数据,无需中转);

  2. 渲染进程确认提交:渲染进程接收数据后,向主进程发送 “确认提交” 消息,表示已准备好解析页面;

  3. 页面状态更新:主进程接收到 “确认提交” 后,执行 3 个关键动作:

    • 移除当前标签页的旧文档(如之前打开的百度页面);
    • 更新浏览器的页面状态(URL、标题、历史记录,如访问https://time.geekbang.org后,URL 栏显示该地址,标题更新为 “极客时间”);
    • 显示 loading 状态(直到渲染进程完成首次渲染)。

核心定义

用户从输入 URL 回车,到渲染进程 “确认提交” 准备解析页面的全过程,称为导航(这是面试回答的核心边界)。

七、底层支撑:OSI 七层协议与传输优化

整个流程依赖网络协议栈,核心是 OSI 七层协议(实际常用 TCP/IP 五层模型),以下拆解关键层:

OSI 层级 核心职责 关键细节
物理层 传输 0/1 二进制数据(物理介质:网线、光纤、无线) 无逻辑处理,仅负责 “传输信号”
数据链路层 封装数据为 “帧”,携带 MAC 地址(设备唯一标识) MAC 地址由网卡厂商分配,用于局域网内设备通信
网络层 封装数据为 “数据包”,携带 IP 地址 IP 地址负责跨网络定位主机;可能丢包、出错,依赖传输层修复
传输层 封装数据为 “段 / 报”,标识端口号(对应应用程序) 核心协议:TCP/UDP;端口号范围 0-65535(80/443 为 HTTP/HTTPS 默认端口)
应用层(/表示层/会话层) 定义应用间通信规则(HTTP/HTTPS/DNS) 基于传输层实现业务逻辑,如 HTTP 的请求 / 响应格式

1. UDP 协议(用户数据报协议)

  • 特性:简单、快速、无可靠性保证(无重传、无排序);
  • 适用场景:音视频直播 / 通话(允许少量丢包,优先保证实时性);
  • 核心问题:数据包可能丢失、乱序到达,无法传输 HTML/CSS 等 “要求完整” 的 Web 资源(网页使用 TCP 协议传输)。

2. TCP 协议(传输控制协议)

  • 特性:可靠、有序、速度略慢(有重传、排序机制);

  • 适用场景:浏览器请求、邮件、文件下载(要求数据完整,文档中所有网页的访问均基于 TCP 协议);

  • 核心解决的问题:

    • 丢包重传:为数据包设置 “过期时间”,超时未接收则重传;
    • 乱序重排:为每个数据包分配 “序号”,接收端按序号组装,解决乱序问题;
  • **TCP 完整生命周期:三次握手(建连)→ 数据传输 → 四次挥手(关连)**四次挥手是 TCP 关闭可靠连接的标准流程,和三次握手成对出现,关于它在「从 URL 输入到页面展示」流程中的定位:

    • 它不属于页面首屏渲染的前置核心步骤(页面展示不依赖连接关闭);
    • 它属于 TCP 连接完整生命周期的必要收尾,是整个页面加载全流程的一部分。

    (1)四次挥手的触发时机(与 HTTP 版本强相关)

    HTTP 版本 连接策略 触发四次挥手的场景
    HTTP/1.0 默认短连接 每传输完 1 个资源(如 HTML、单张图片)后,立即触发四次挥手
    HTTP/1.1 默认长连接(Connection: keep-alive ① 页面所有核心资源传输完成后,连接空闲超过超时时间(浏览器默认约 60s);② 页面关闭 / 刷新 / 标签页销毁时;③ 服务器主动关闭(如单连接最大请求数超限)
    HTTP/2/3 多路复用长连接 仅在页面关闭、标签页销毁、浏览器关闭或连接长时间空闲时触发

    (2)四次挥手的完整详细过程(客户端主动发起关闭为例)

    TCP 是全双工通信,客户端和服务端的发送 / 接收通道独立,关闭时需双向确认 “不再发送数据”,因此需要四次挥手:

    挥手阶段 通信方向 核心报文 核心含义
    第一次 客户端→服务端 发送FIN M 客户端告知服务端:我已无数据发送,请求关闭「客户端→服务端」的发送通道
    第二次 服务端→客户端 发送ACK M+1 服务端告知客户端:我已收到关闭请求,先确认;但我可能还有数据没传完,你继续等待接收
    第三次 服务端→客户端 发送FIN N 服务端告知客户端:我也无数据发送了,请求关闭「服务端→客户端」的发送通道
    第四次 客户端→服务端 发送ACK N+1 客户端告知服务端:我已收到关闭请求,双向通道均确认关闭,连接可彻底释放

image.png

**(3)面试高频补充细节**
  • 为什么挥手是四次,握手是三次?

三次握手时,服务端的「ACK 确认客户端能力」和「SYN 告知自身能力」可合并成一次报文;但四次挥手时,服务端收到客户端的 FIN 后,大概率还有未传输完的数据(如文档中https://time.geekbang.org的页面资源可能分多个数据包传输),不能立即回 FIN 关闭自身通道,只能先回 ACK 确认,等自身数据传完后再单独发 FIN,因此必须拆分成两次,总共四次。

  • TIME_WAIT 状态(必考点)

客户端第四次挥手发送 ACK 后,不会立即关闭连接,会进入TIME_WAIT状态,等待2MSL(最长报文寿命,通常 2 分钟) 后才彻底释放连接。核心目的:防止最后一个 ACK 报文丢包,若服务端没收到 ACK,会重发 FIN 报文,客户端需在 TIME_WAIT 状态内处理重传请求,避免新连接收到旧连接的残留报文。

3. 数据包传输优化

  • 大数据拆分:大文件(如 100MB 的视频)拆分为多个小数据包(MTU 限制,通常 1500 字节 / 包),分批次、多通道并发传输;
  • 多路复用:单个 TCP 连接内同时传输多个请求 / 响应(HTTP/2 核心特性),提升带宽利用率;
  • 负载均衡:反向代理服务器(如 Nginx)接收请求后,通过轮询 / 权重分配至后端多台服务器,避免单服务器过载。

八、前端性能与浏览器优化(面试加分项)

1. 核心性能指标

  • FP(First Paint,首次渲染时间):从页面加载到首次绘制像素的时长,计算公式: FP = TTFB + 响应下载时间 + HTML DOM构建时间 + CSSOM构建时间 + 渲染树构建时间 + 布局树构建时间 + 首次渲染

  • TTFB(Time To First Byte,首字节时间):从请求发送到接收第一个响应字节的时长,包含:DNS解析时间 + TCP/TLS握手时间 + 服务器执行时间(如数据库慢查询)

  • 性能影响:FP/TTFB 直接影响用户留存、付费转化、PV(页面访问量)、UV(独立访客数)。

2. 浏览器缓存优化

  • 缓存类型:静态资源(CSS / 图片 / JS)优先缓存,无需重复请求;
  • 缓存逻辑:浏览器根据响应头Cache-Control/Expires判断是否读取本地缓存,缓存命中则跳过 DNS/TCP/HTTP 流程,直接渲染。

3. 页面卸载事件(beforeunload/pagehide)

当用户关闭标签页 / 刷新页面时,浏览器触发卸载相关事件(主进程管控),核心代码示例:

javascript

// 监听beforeunload:提示用户是否离开
window.addEventListener('beforeunload', function (event) {
    console.log('beforeunload 事件已触发');
    event.preventDefault(); // 阻止默认行为(浏览器强制显示默认提示文案)
    event.returnValue = ''; // 兼容各浏览器的提示信息设置
});

// 监听pagehide:处理bfcache场景(浏览器后退/前进缓存)
window.addEventListener('pagehide', function (e) {
    if (e.persisted) {
        console.log('⚠️ 页面进入bfcache(未触发beforeunload),属于浏览器优化');
    } else {
        console.log('✅ 页面正常卸载流程');
    }
});
  • 关键补充:bfcache(后退 / 前进缓存)是浏览器优化,会缓存页面状态,导致beforeunload不触发,需通过pagehide监听e.persisted判断。

总结(面试回答逻辑)

回答该问题时,需按 “进程协作→URL 预处理→DNS 解析→TCP 握手→HTTP 交互→导航提交→协议支撑→性能优化” 的逻辑组织,核心是体现 “多进程协同” 和 “网络协议栈” 两大主线,而非零散罗列知识点。

核心逻辑链:用户输入URL(主进程)→ URL标准化(主进程)→ DNS解析(网络进程)→ TCP握手(网络进程)→ HTTP请求/响应(网络进程)→ 数据管道传输(网络+渲染进程)→ 导航提交(主+渲染进程)→ 准备渲染(渲染进程)→ 数据传输完成后TCP四次挥手(网络进程)

纯函数、柯里化与函数组合:从原理到源码,构建更可维护的前端代码体系

作者 swipe
2026年3月8日 17:10

为什么要关注纯函数和柯里化?

在日常开发中,你是否遇到过这些问题:

  • 修改一个函数后,其他看似无关的模块出现了 bug
  • 相同的输入有时返回不同的结果,导致测试用例不稳定
  • 代码复用困难,类似的逻辑在多处重复编写
  • 阅读 React、Redux、Vue3 源码时,对某些设计模式感到困惑

这些问题的根源往往在于:缺乏对函数式编程核心概念的理解。纯函数和柯里化作为函数式编程的两大基石,不仅能帮助我们写出更稳定、可测试的代码,更是理解现代前端框架设计思想的关键。

本文收益:

  • 掌握纯函数的定义与实践,避免副作用带来的隐患
  • 理解柯里化的本质,学会用单一职责原则优化代码结构
  • 从 Vue3、Redux 源码中看到这些思想的实际应用
  • 获得可直接落地的编码实践和团队推广建议

一、纯函数:稳定性的基石

1.1 什么是纯函数

JavaScript 符合函数式编程范式,纯函数是其中最重要的概念之一。在 React 开发中,组件被要求像纯函数一样工作;在 Redux 中,reducer 必须是纯函数。理解纯函数,是掌握现代前端框架的必经之路。

下图展示了 Redux 官方文档对数据不可变性的强调:

图 1:React 中的数据不可变性

根据维基百科定义,纯函数需要满足三个条件:

  1. 确定性输出:相同的输入必然产生相同的输出
  2. 无外部依赖:输出只依赖于输入参数,不依赖外部状态或 I/O 设备
  3. 无副作用:不触发事件、不修改外部状态、不改变输入参数

简单总结:

  • 确定的输入 → 确定的输出(可预测性)
  • 执行过程中不产生副作用(隔离性)

"纯"字表达的是"纯粹"的含义,即函数只做一件事:根据输入计算输出,不做任何额外操作。

1.2 副作用:bug 的温床

什么是副作用?

副作用(Side Effect)源自医学概念,指药物在治疗疾病之外产生的额外影响。在计算机科学中,副作用指函数执行时,除了返回值之外对外部环境产生的影响,例如:

  • 修改全局变量
  • 修改传入的参数对象
  • 发起网络请求
  • 操作 DOM
  • 写入文件或数据库
  • 打印日志(严格来说也是副作用,但通常可接受)

为什么副作用是问题?

副作用会破坏代码的可预测性和可测试性。当函数依赖或修改外部状态时:

  • 相同输入可能产生不同输出
  • 函数行为难以追踪和调试
  • 并发执行时可能产生竞态条件
  • 单元测试需要复杂的 mock 和环境准备

在编程中,我们提倡"数据的不可变性"(Immutability):尽量不修改原有数据,而是创建新数据。这是避免副作用的重要实践。

1.3 纯函数实战案例

让我们通过数组操作来理解纯函数:

案例 1:slice vs splice

const names = ["小吴", "why", "JS高级"];

// slice 是纯函数
// 1. 相同输入产生相同输出
// 2. 不修改原数组
const newNames1 = names.slice(0, 2);
console.log("newNames1:", newNames1); // ["小吴", "why"]
console.log("names:", names);          // ["小吴", "why", "JS高级"] - 原数组未变

// splice 不是纯函数
// 会修改原数组,产生副作用
const newNames2 = names.splice(2);
console.log("newNames2:", newNames2); // ["JS高级"]
console.log("names:", names);          // ["小吴", "why"] - 原数组被修改!

案例 2:对象操作

// ❌ 非纯函数:直接修改传入的对象
function baz(info) {
  info.age = 100; // 副作用:修改了外部对象
}

const obj = { name: "小吴", age: 23 };
baz(obj);
console.log(obj); // { name: "小吴", age: 100 } - 原对象被修改

// ✅ 纯函数:返回新对象,不修改原对象
function test(info) {
  return {
    ...info,
    age: 100
  };
}

const obj2 = { name: "小吴", age: 23 };
const newObj = test(obj2);
console.log(obj2);   // { name: "小吴", age: 23 } - 原对象未变
console.log(newObj); // { name: "小吴", age: 100 } - 新对象

案例 3:React 组件

// React 函数组件应该像纯函数一样
// ✅ 正确:不修改 props
function HelloWorld(props) {
  // 只读取 props,不修改
  return <div>{props.message}</div>;
}

// ❌ 错误:修改 props
function BadComponent(props) {
  props.count++; // 违反纯函数原则!
  return <div>{props.count}</div>;
}

1.4 纯函数的优势

为什么纯函数在函数式编程中如此重要?

  1. 编写时更专注

    • 只需实现业务逻辑,不用担心外部状态
    • 不需要关心参数来源或依赖的外部变量
  2. 使用时更安心

    • 确定输入不会被篡改
    • 确定的输入必然产生确定的输出
    • 可以安全地并发执行
  3. 测试更简单

    • 不需要复杂的 mock 和环境准备
    • 测试用例稳定可靠
  4. 易于调试和重构

    • 函数行为可预测,问题容易定位
    • 可以安全地替换或组合函数

React 官方文档明确要求:无论是函数组件还是 class 组件,都必须像纯函数一样保护 props 不被修改。

图 2:React 的严格规则

本节小结

  • 纯函数三要素:确定性输出、无外部依赖、无副作用
  • 副作用是 bug 的温床:修改外部状态会破坏可预测性
  • 数据不可变性:优先创建新数据而非修改原数据
  • 实践原则:使用 slicemapfilter 等不修改原数组的方法
  • 框架要求:React/Redux 等框架强制要求纯函数思想

二、柯里化:单一职责的艺术

2.1 柯里化的本质

柯里化(Currying)是函数式编程的另一个核心概念。它的名字来源于数学家 Haskell Curry。

维基百科定义:

  • 把接收多个参数的函数,转换成接受单一参数的函数
  • 返回接受余下参数的新函数
  • 最终返回结果

简单理解: 只传递给函数一部分参数来调用它,让它返回另一个函数处理剩余参数。

对比示例:

// 普通函数:一次性传入所有参数
function foo(m, n, x, y) {
  return m + n + x + y;
}
foo(10, 20, 30, 40); // 100

// 柯里化函数:分步传入参数
function bar(m) {
  return function(n) {
    return function(x, y) {
      return m + n + x + y;
    };
  };
}
bar(10)(20)(30, 40); // 100

这就像调节风扇档位:复杂需求可以分档次调节,每个档位的调用都基于前一档位,档位之间紧密关联且有明确顺序。

2.2 柯里化的结构演进

2.2.1 基础多参数函数

function add(x, y, z) {
  return x + y + z;
}

const result = add(10, 20, 30);
console.log(result); // 60

2.2.2 柯里化改造

// 通过闭包实现参数保存
function sum(x) {
  return function(y) {
    return function(z) {
      return x + y + z;
    };
  };
}

const result1 = sum(10)(20)(30);
console.log(result1); // 60

关键点:

  • 每个函数接收一个参数并返回新函数
  • 通过闭包访问上层函数的参数
  • 最内层函数执行最终计算

2.2.3 箭头函数简化

// 方式 1:保留 return 关键字
const sum2 = x => y => z => {
  return x + y + z;
};

// 方式 2:隐式返回(推荐)
const sum3 = x => y => z => x + y + z;

const result2 = sum3(20)(30)(40);
console.log(result2); // 90

箭头函数的链式写法大幅简化了柯里化代码,这也是现代 JavaScript 中常见的写法。

2.3 柯里化的核心价值

2.3.1 单一职责原则(SRP)

为什么需要柯里化?

在函数式编程中,我们希望:

  • 一个函数处理的问题尽可能单一
  • 不要将一大堆处理过程交给一个函数
  • 每次传入的参数在单一函数中处理
  • 处理完后在下一个函数中使用处理结果

这体现了单一职责原则(Single Responsibility Principle):一个类(或函数)应该只有一个引起它变化的原因。

对比示例:

// ❌ 所有逻辑挤在一起
function add(x, y, z) {
  x = x + 2;
  y = y * 2;
  z = z * z;
  return x + y + z;
}
console.log(add(10, 20, 30)); // 972

// ✅ 柯里化:每层处理一个职责
function sum(x) {
  x = x + 2;  // 第一层:处理 x
  return function(y) {
    y = y * 2;  // 第二层:处理 y
    return function(z) {
      z = z * z;  // 第三层:处理 z
      return x + y + z;
    };
  };
}
console.log(sum(10)(20)(30)); // 972

注意边界:

  • 单一职责不是越细越好,过度拆分会增加复杂度
  • 职责的"粒度"需要根据实际项目判断
  • 通常 2-3 层嵌套是最常见的情况

2.3.2 逻辑复用

柯里化的另一个重要优势是复用重复的参数,这和 bind 函数的思想类似。

案例 1:固定第一个参数

function foo(m, n) {
  return m + n;
}

// 传统方式:重复传入相同的第一个参数
console.log(foo(5, 1)); // 6
console.log(foo(5, 2)); // 7
console.log(foo(5, 3)); // 8
console.log(foo(5, 4)); // 9
console.log(foo(5, 5)); // 10

// ✅ 柯里化:复用第一个参数
function makeAdder(count) {
  return function(num) {
    return count + num;
  };
}

const adder5 = makeAdder(5);
console.log(adder5(1)); // 6
console.log(adder5(2)); // 7
console.log(adder5(3)); // 8
console.log(adder5(4)); // 9
console.log(adder5(5)); // 10

案例 2:日志函数优化

// ❌ 传统方式:重复传入时间和类型
function log(date, type, message) {
  console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`);
}

log(new Date(), "DEBUG", "查找到轮播图的bug");
log(new Date(), "DEBUG", "查询菜单的bug");
log(new Date(), "DEBUG", "查询数据的bug");

// ✅ 柯里化优化:复用时间和类型
const logCurried = date => type => message => {
  console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`);
};

// 复用时间
const nowLog = logCurried(new Date());
nowLog("DEBUG")("查找小吴去哪了");

// 复用时间 + 类型
const debugLog = logCurried(new Date())("DEBUG");
debugLog("查找信息1");
debugLog("查找信息2");
debugLog("查找信息3");

优势总结:

  • 减少重复代码
  • 提高函数灵活性
  • 便于创建专用工具函数

2.4 通用柯里化函数实现

2.4.1 实现思路

如何将普通函数自动转换为柯里化函数?

需求分析:

  1. 传入一个普通函数,返回柯里化版本
  2. 需要知道函数的参数个数(通过 fn.length 获取)
  3. 支持多种调用方式:fn(1,2,3)fn(1,2)(3)fn(1)(2)(3)
// 获取函数参数个数
function foo(x, y, z, q) {
  console.log(foo.length); // 4
}

2.4.2 完整实现

function hyCurrying(fn) {
  // 返回柯里化函数
  function curried(...args) {
    // 1. 参数足够时,直接执行原函数
    if (args.length >= fn.length) {
      // 使用 apply 绑定 this,避免指向问题
      return fn.apply(this, args);
    } else {
      // 2. 参数不足时,返回新函数继续收集参数
      function curried2(...args2) {
        // 递归调用 curried,拼接参数
        return curried.apply(this, args.concat(args2));
      }
      return curried2;
    }
  }
  return curried;
}

// 测试
function add1(x, y, z) {
  return x + y + z;
}

const curryAdd = hyCurrying(add1);
console.log(curryAdd(10, 20, 30));    // 60
console.log(curryAdd(10, 20)(30));    // 60
console.log(curryAdd(10)(20)(30));    // 60

实现要点:

  • fn.length:获取原函数的形参数量(上限)
  • ...args:收集用户传入的实参(不固定)
  • 参数足够时调用原函数,不足时递归返回新函数
  • 使用 apply 绑定 this,防止指向偏移
  • 使用 concat 拼接历史参数和新参数

2.5 柯里化在源码中的应用

2.5.1 Vue3 源码案例

Vue3 源码中大量使用了柯里化思想。下图展示了 createApp 的实现:

图 3:Vue3 源码中的柯里化

在源码中,柯里化的运用方式更加灵活:

图 4:Vue3 源码 createAppAPI 的柯里化运用

代码结构:

return {
  render,
  hydrate,
  createApp: createAppAPI(render, hydrate)
};

createAppAPI 返回的函数就是 createApp,通过 ES6 对象简写形式:

// 完整形式
createApp: createApp

// 简写形式
createApp

最终形成嵌套调用:

createAppAPI(render, hydrate)(rootComponent, rootProps)

这种写法进一步扩大了封装的灵活性,但也提高了抽象程度。

2.5.2 Redux 源码案例

Redux 中也有典型的柯里化应用:

图 5:Redux 柯里化调用

参考链接:redux-thunk/src/index.ts

本节小结

  • 柯里化本质:将多参数函数转换为单参数函数链
  • 核心价值:单一职责 + 逻辑复用
  • 实现关键:闭包保存参数 + 递归收集参数
  • 应用场景:工具函数封装、参数预设、延迟执行
  • 源码体现:Vue3、Redux 等框架广泛使用
  • 注意事项:避免过度嵌套(2-3 层为宜)

三、组合函数:函数的乐高积木

3.1 什么是组合函数

组合函数(Compose Function)是函数式编程中的一种使用技巧,用于将多个函数组合成一个新函数。

场景描述:

  • 需要对数据依次执行两个函数 fn1fn2
  • 每次都要手动调用两次,操作重复
  • 能否将这两个函数组合起来,自动依次调用?

基础示例:

// 乘以 2
function double(num) {
  return num * 2;
}

// 平方
function square(num) {
  return num ** 2;
}

const count = 10;
// 传统方式:嵌套调用
const result = square(double(count)); // (10 * 2) ** 2 = 400
console.log(result);

// ✅ 组合函数:将两个函数组合
function composeFn(m, n) {
  return function(count) {
    return n(m(count));
  };
}

const newFn = composeFn(double, square);
console.log(newFn(10)); // 400

核心思想:

  • 第一层函数接收需要组合的函数
  • 返回第二层函数(组合后的函数)接收数据
  • 第二层函数内部依次执行传入的函数

3.2 组合函数的优势

  1. 保持函数独立性doublesquare 各自功能独立
  2. 减少重复调用:组合一次,多次使用
  3. 提高可读性newFn(10)square(double(10)) 更清晰
  4. 灵活组合:可以调整执行顺序 n(m(count))m(n(count))

这种模式和 bind 函数类似:所有操作都在第二层函数中完成。


四、通用组合函数实现

4.1 需求分析

前面的 composeFn 只能组合两个函数,实际开发中可能需要组合更多函数。我们需要实现一个通用的组合函数:

需求:

  • 支持传入任意数量的函数
  • 验证传入的都是函数类型
  • 按顺序依次执行函数
  • 上一个函数的返回值作为下一个函数的参数

4.2 完整实现

function hyCompose(...fns) {
  const length = fns.length;

  // 1. 验证:确保传入的都是函数
  for (let i = 0; i < length; i++) {
    if (typeof fns[i] !== 'function') {
      throw new TypeError('所有参数必须是函数类型');
    }
  }

  // 2. 返回组合后的函数
  function compose(...args) {
    let index = 0;
    // 执行第一个函数,传入所有参数
    let result = length ? fns[index].apply(this, args) : args;

    // 依次执行剩余函数,每次传入上一个函数的返回值
    while (++index < length) {
      result = fns[index].call(this, result);
    }

    return result;
  }

  return compose;
}

// 测试
function double(m) {
  return m * 2;
}

function square(n) {
  return n ** 2;
}

function addTen(x) {
  return x + 10;
}

// 组合多个函数
const newFn = hyCompose(double, square, addTen);
console.log(newFn(5)); // ((5 * 2) ** 2) + 10 = 110

实现要点:

  1. 参数验证:遍历检查每个参数是否为函数
  2. 边界处理
    • 第一个函数使用 apply 接收多个参数
    • 后续函数使用 call 接收单个参数(上一个函数的返回值)
  3. this 绑定:使用 apply/call 确保 this 指向正确
  4. 执行顺序:按传入顺序依次执行(先 double,再 square,最后 addTen

4.3 执行流程图解

newFn(5)
  ↓
double(5) → 10square(10) → 100addTen(100) → 110

本节小结

  • 组合函数:将多个函数组合成一个新函数
  • 适用场景:多个函数需要依次执行,且关联性强
  • 实现关键:第一个函数接收多参数,后续函数接收单参数
  • 执行顺序:按传入顺序依次执行
  • 注意事项:需要验证参数类型,绑定 this 指向

五、实战落地建议

5.1 代码层面

纯函数实践清单:

  1. 优先使用不可变方法

    • 数组:mapfilterreducesliceconcat
    • 对象:Object.assign({},...){...obj}
    • 避免:pushsplicesort(会修改原数组)
  2. 函数设计原则

    • 输入通过参数传递,不依赖全局变量
    • 输出通过 return 返回,不修改外部状态
    • 避免在函数内部发起网络请求或操作 DOM
  3. React 组件规范

    • 函数组件不修改 props
    • 使用 useState 管理内部状态
    • 副作用统一放在 useEffect

柯里化应用场景:

  1. 工具函数封装

    // 通用请求函数
    const request = baseURL => endpoint => params => {
      return fetch(`${baseURL}${endpoint}`, params);
    };
    
    const apiRequest = request('https://api.example.com');
    const getUserInfo = apiRequest('/user');
    getUserInfo({ id: 123 });
    
  2. 事件处理优化

    // 避免在 JSX 中创建匿名函数
    const handleClick = id => event => {
      console.log('Clicked item:', id);
    };
    
    <button onClick={handleClick(item.id)}>Click</button>
    
  3. 参数预设

    const logger = level => message => {
      console.log(`[${level}] ${message}`);
    };
    
    const errorLog = logger('ERROR');
    const infoLog = logger('INFO');
    

5.2 团队推广

渐进式推广策略:

  1. 第一阶段:意识培养

    • 团队分享会讲解纯函数和柯里化概念
    • Code Review 中指出副作用问题
    • 建立最佳实践文档
  2. 第二阶段:工具支持

    • ESLint 规则:禁止修改参数(no-param-reassign
    • 引入 Immutable.js 或 Immer.js
    • 封装常用的柯里化工具函数
  3. 第三阶段:规范落地

    • 新项目强制使用纯函数
    • 老项目逐步重构
    • 建立代码质量指标

常见问题应对:

问题 解决方案
性能担忧(创建新对象) 使用 Immer.js 优化,实际性能影响很小
学习成本高 提供代码示例和最佳实践文档
历史代码改造难 新代码严格执行,老代码逐步重构
调试困难 使用 Redux DevTools 等工具

5.3 验证指标

代码质量指标:

  • 单元测试覆盖率提升(纯函数更易测试)
  • Bug 率下降(副作用减少)
  • 代码复用率提升(柯里化提高复用性)
  • Code Review 时间减少(代码更清晰)

六、总结与展望

6.1 核心要点回顾

纯函数:

  • 确定的输入产生确定的输出
  • 不产生副作用,不修改外部状态
  • 是构建可预测、可测试代码的基础
  • React、Redux 等框架的核心要求

柯里化:

  • 将多参数函数转换为单参数函数链
  • 体现单一职责原则
  • 提高代码复用性和灵活性
  • 在 Vue3、Redux 等源码中广泛应用

组合函数:

  • 将多个函数组合成新函数
  • 保持函数独立性的同时提高复用
  • 函数式编程的重要技巧

6.2 进阶方向

  1. 深入函数式编程

    • 学习 Functor、Monad 等高级概念
    • 研究 Ramda.js、Lodash/fp 等函数式库
    • 理解函数式编程在大型项目中的应用
  2. 框架源码阅读

    • Vue3 响应式系统中的纯函数应用
    • Redux 中间件的柯里化设计
    • React Hooks 的函数式思想
  3. 性能优化

    • 使用 Immer.js 优化不可变数据操作
    • 理解 React.memo 和纯组件的关系
    • 掌握函数式编程的性能优化技巧

6.3 团队落地路线图

短期(1-2 个月):

  • 团队技术分享,统一认知
  • 建立编码规范和最佳实践文档
  • 新项目试点应用

中期(3-6 个月):

  • 封装团队通用的工具函数库
  • 配置 ESLint 规则自动检查
  • Code Review 中强化纯函数要求

长期(6 个月以上):

  • 老项目逐步重构
  • 建立代码质量监控体系
  • 沉淀团队函数式编程最佳实践

附录:常见误区

  1. 误区:纯函数不能有任何副作用

    • 正解:console.log 等调试代码是可接受的副作用
    • 关键是不影响函数的核心逻辑和可预测性
  2. 误区:柯里化会降低性能

    • 正解:现代 JavaScript 引擎优化很好,性能影响微乎其微
    • 代码可维护性的提升远大于微小的性能损失
  3. 误区:所有函数都要柯里化

    • 正解:根据实际需求选择,不要过度设计
    • 参数固定且无复用需求的函数不需要柯里化
  4. 误区:纯函数不能调用其他函数

    • 正解:可以调用其他纯函数
    • 关键是整体不产生副作用

参考资源:


本文适合有一定 JavaScript 基础的前端工程师阅读。如有疑问或建议,欢迎交流讨论。

uniapp + Vue 自定义组件封装:自定义样式从入门到实战

作者 远山枫谷
2026年3月8日 15:57

uniapp + Vue 自定义组件封装:自定义样式从入门到实战

今天沉浸式学习了 uniapp 中 Vue 自定义组件的封装,重点突破了「自定义样式」这个核心难点——很多新手封装组件时,要么样式冲突、要么无法灵活适配不同场景,其实掌握关键技巧后,自定义样式可以做到既规范又灵活。这篇笔记就把今天的学习成果整理出来,从基础封装到样式自定义,一步步拆解,适合和我一样正在入门的小伙伴参考~

先明确核心目标:封装的自定义组件,不仅要实现复用性,还要支持外部灵活修改样式,同时避免样式污染全局,兼顾易用性和规范性。下面从「组件基础封装」→「自定义样式实现」→「避坑实战」三个维度,结合具体代码讲解,全程可复制实操。

一、基础铺垫:自定义组件的基本封装流程

在 uniapp 中封装 Vue 自定义组件,和纯 Vue 项目思路一致,但要适配 uniapp 的页面结构和语法规范,核心步骤就3步,先搭好基础框架:

1. 新建组件文件

在项目的 components 目录下,新建组件文件夹(如 my-custom-btn),创建 my-custom-btn.vue 文件,这是组件的核心文件。

2. 编写组件基础结构

组件由 template(结构)、script(逻辑)、style(样式)三部分组成,先写一个简单的按钮组件作为示例,后续逐步完善样式自定义:

<template>
  <!-- 组件基础结构 -->
  <view class="custom-btn" @click="handleClick"&gt;
    &lt;slot&gt;默认按钮&lt;/slot&gt; <!-- 插槽支持外部传入按钮文本 -->
  </view>
</template>

<script>
export default {
  name: 'MyCustomBtn', // 组件名称,必填(便于注册和识别)
  props: {
    // 先定义基础props,后续添加样式相关props
    type: {
      type: String,
      default: 'primary' // 按钮默认类型
    }
  },
  methods: {
    handleClick() {
      // 组件点击事件,通过$emit向父组件传值
      this.$emit('click', '按钮被点击啦')
    }
  }
}
</script>

<style scoped>
/* 基础样式,先写固定样式,后续改为可自定义 */
.custom-btn {
  width: 120rpx;
  height: 60rpx;
  line-height: 60rpx;
  text-align: center;
  border-radius: 30rpx;
  background-color: #007aff; /* 默认蓝色 */
  color: #fff;
  font-size: 28rpx;
}
</style>

3. 注册并使用组件

组件封装好后,需要在页面中注册才能使用,有两种注册方式,根据复用频率选择:

方式1:局部注册(仅当前页面使用)
<template>
  &lt;view&gt;
    <!-- 使用自定义组件 -->
    <my-custom-btn @click="handleBtnClick">点击我</my-custom-btn>
  </view>
</template>

<script>
// 引入组件
import MyCustomBtn from '@/components/my-custom-btn/my-custom-btn.vue'
export default {
  components: {
    MyCustomBtn // 注册组件
  },
  methods: {
    handleBtnClick(msg) {
      console.log(msg) // 接收组件传过来的事件
    }
  }
}
</script>
方式2:全局注册(所有页面可使用)

main.js 中注册,无需在每个页面单独引入:

import Vue from 'vue'
import MyCustomBtn from '@/components/my-custom-btn/my-custom-btn.vue'
// 全局注册组件
Vue.component('MyCustomBtn', MyCustomBtn)

二、核心重点:自定义样式的3种实现方式

这是今天学习的核心!封装组件时,固定样式无法满足不同页面的需求(比如有的页面需要红色按钮,有的需要圆角更大),因此需要支持「外部传入样式」,同时避免样式污染。推荐3种实用方式,从简单到灵活,按需选择。

方式1:通过 props 传值控制样式(最基础、最常用)

核心思路:在组件中定义样式相关的 props(如背景色、字体大小、圆角等),外部使用组件时,通过传入 props 覆盖默认样式,适合样式修改场景较少的情况。

修改上面的按钮组件,添加样式相关 props:

<template>
  <view 
    class="custom-btn" 
    @click="handleClick"
    :style="{
      backgroundColor: bgColor,
      color: textColor,
      borderRadius: borderRadius,
      fontSize: fontSize + 'rpx'
    }"
  >
    <slot>默认按钮</slot>
  </view>
</template>

<script>
export default {
  name: 'MyCustomBtn',
  props: {
    type: {
      type: String,
      default: 'primary'
    },
    // 样式相关props,都设置默认值,保证外部不传入时也能正常显示
    bgColor: {
      type: String,
      default: '#007aff' // 默认蓝色
    },
    textColor: {
      type: String,
      default: '#fff' // 默认白色文本
    },
    borderRadius: {
      type: String,
      default: '30rpx' // 默认圆角
    },
    fontSize: {
      type: Number,
      default: 28 // 默认字体大小(单位rpx,外部传入数字即可)
    }
  },
  methods: {
    handleClick() {
      this.$emit('click', '按钮被点击啦')
    }
  }
}
</script>

<style scoped>
/* 保留基础样式,动态样式通过:style绑定 */
.custom-btn {
  width: 120rpx;
  height: 60rpx;
  line-height: 60rpx;
  text-align: center;
}
</style>

外部使用时,传入需要修改的样式 props 即可,未传入的会使用默认值:

<!-- 自定义背景色和文本色 -->
<my-custom-btn 
  bgColor="#ff3333" 
  textColor="#fff"
  @click="handleBtnClick"
>
  红色按钮
</my-custom-btn>

<!-- 自定义圆角和字体大小 -->
<my-custom-btn 
  borderRadius="10rpx" 
  fontSize="32"
  @click="handleBtnClick"
>
  小字体按钮
</my-custom-btn>

方式2:通过 style 传入自定义类(灵活度更高)

核心思路:组件支持外部传入自定义 class,通过 :class 绑定,实现更复杂的样式自定义(比如渐变、阴影、hover效果),适合样式差异较大的场景。

修改组件,添加 customClass props,用于接收外部传入的类名:

<template>
  <view 
    class="custom-btn" 
    :class="customClass" // 绑定外部传入的类
    @click="handleClick"
  >
    <slot>默认按钮</slot>
  </view>
</template>

<script>
export default {
  name: 'MyCustomBtn',
  props: {
    // 新增:接收外部自定义类名
    customClass: {
      type: String,
      default: ''
    },
    // 保留之前的基础props
    bgColor: {
      type: String,
      default: '#007aff'
    }
  },
  // ... 其他代码不变
}
</script>

<style scoped>
.custom-btn {
  width: 120rpx;
  height: 60rpx;
  line-height: 60rpx;
  text-align: center;
  border-radius: 30rpx;
  background-color: v-bind(bgColor); // 也可以用v-bind绑定props中的样式
  color: #fff;
  font-size: 28rpx;
}
</style>

外部页面中,先定义自定义样式类,再传入组件:

<template>
  <view>
    <my-custom-btn 
      customClass="gradient-btn" 
      @click="handleBtnClick"
    >
      渐变按钮
    </my-custom-btn>
  </view>
</template>

<style scoped>
/* 外部自定义样式类 */
.gradient-btn {
  background: linear-gradient(to right, #ff3366, #ff9900); /* 渐变背景 */
  box-shadow: 0 2rpx 10rpx rgba(255, 51, 102, 0.3); /* 阴影效果 */
}
.gradient-btn:hover {
  opacity: 0.9; /*  hover效果 */
}
</style>

注意:如果组件样式用了 scoped(避免样式污染),外部传入的类名可能无法生效,此时有两种解决方案:

  • 方案1:外部样式类不使用 scoped(不推荐,可能污染全局);
  • 方案2:组件中使用深度选择器 ::v-deep(推荐),修改组件样式如下:
<style scoped>
.custom-btn {
  /* 基础样式不变 */
}
/* 深度选择器:穿透scoped,让外部传入的类生效 */
::v-deep .gradient-btn {
  background: linear-gradient(to right, #ff3366, #ff9900);
  box-shadow: 0 2rpx 10rpx rgba(255, 51, 102, 0.3);
}
</style>

方式3:通过 slot 插入样式(极致灵活)

核心思路:如果组件的样式差异极大,甚至结构也有变化,可通过 slot 插入自定义样式(或整个结构),适合复杂场景,比如组件内部部分区域需要完全自定义。

修改组件,添加样式插槽(或结构插槽):

<template>
  &lt;view class="custom-btn" @click="handleClick"&gt;
    <!-- 插槽:支持外部传入整个内容(包括样式) -->
    <slot name="content">
      <view class="default-content">默认按钮</view>
    </slot>
  </view>
</template>

<script>
// ... 逻辑不变
</script>

<style scoped>
.custom-btn {
  width: 120rpx;
  height: 60rpx;
  line-height: 60rpx;
  text-align: center;
  border-radius: 30rpx;
  background-color: #007aff;
}
.default-content {
  color: #fff;
  font-size: 28rpx;
}
</style>

外部使用时,通过插槽插入自定义内容和样式,完全覆盖默认内容:

<my-custom-btn @click="handleBtnClick">
  <template #content>
    <view class="custom-content">
      <image src="/static/btn-icon.png" mode="widthFix" class="btn-icon"></image>
      <text class="btn-text">带图按钮</text>
    </view>
  </template>
</my-custom-btn>

<style scoped>
.custom-content {
  display: flex;
  align-items: center;
  justify-content: center;
  color: #333;
  font-weight: bold;
}
.btn-icon {
  width: 30rpx;
  height: 30rpx;
  margin-right: 8rpx;
}
</style>

三、避坑指南:今天踩过的3个小坑

学习过程中遇到了几个常见问题,整理出来,帮大家少走弯路:

  1. 样式污染问题:忘记给组件样式加 scoped,导致组件样式影响全局页面,解决:给组件的 style 标签加上 scoped,如需穿透,用 ::v-deep
  2. props 传值类型错误:传入字体大小时,误传字符串(如 fontSize="32"),导致样式不生效,解决:props 中定义 fontSize 为 Number 类型,外部传入数字(如 fontSize="32" 改为 :fontSize="32",绑定数字)。
  3. uniapp 样式单位问题:习惯用 px 单位,导致不同设备适配异常,解决:uniapp 中推荐用 rpx 单位,自动适配不同屏幕,组件样式统一用 rpx。

四、学习总结

今天通过实操掌握了 uniapp 中 Vue 自定义组件封装的核心,尤其是自定义样式的3种实现方式,总结下来:

  • 简单样式修改:用 props 传值绑定 inline-style,高效快捷;
  • 复杂样式修改:用 props 传自定义类名 + 深度选择器,灵活度高;
  • 极致灵活场景:用 slot 插入自定义内容和样式,适配各种复杂需求。

其实自定义组件封装的核心就是「复用性」和「灵活性」,样式自定义更是如此——既要保证组件本身的规范性,又要支持外部按需修改。后续还要继续学习组件的生命周期、props 校验、事件传值等进阶内容,慢慢打磨组件封装能力~

如果小伙伴们有更好的样式自定义技巧,欢迎在评论区交流,一起进步!💪

JavaScript 中的 `this` 与变量查找:一场关于“身份”与“作用域”的深度博弈

作者 Lee川
2026年3月8日 15:23

JavaScript 中的 this 与变量查找:一场关于“身份”与“作用域”的深度博弈

在 JavaScript 的浩瀚宇宙中,有两个概念让无数开发者爱恨交织:一个是像变色龙一样的 this,另一个是像迷宫一样的 作用域链(Scope Chain)

很多初学者容易混淆这两者:以为 this 也是沿着作用域链查找的,或者以为变量查找会受 this 影响。事实恰恰相反

  • 变量查找:遵循词法作用域(Lexical Scope),由代码写在哪里决定(静态的)。
  • this 指向:遵循动态绑定(Dynamic Binding),由代码怎么被调用决定(动态的)。

就像一个人的社会身份(this)取决于他此刻站在哪个舞台上,而他的记忆(变量查找)取决于他出生和成长的地方(代码声明的位置)。

本文将基于深度对话中的四个经典场景,从变量查找陷阱到构造函数迷局,再到 DOM 事件与调用方式的终极对比,带你彻底看透 JavaScript 的核心机制。


第一幕:错位的记忆 —— 变量查找 vs this 指向

让我们从一个极具迷惑性的代码片段开始。这段代码完美展示了**“变量去哪找”this 指向谁**是完全平行的两条线。

var bar = { 
  myName: "time.geekbang.com",
  printName: function() {
    // 【变量查找】:沿着作用域链向上找
    // 1. 函数内部有没有 myName? 没有。
    // 2. 外层作用域(全局)有没有 myName? 有!值是 '极客邦'
    console.log(myName); // 输出:极客邦
    
    // 【对象属性访问】:直接访问 bar 对象的属性
    console.log(bar.myName); // 输出:time.geekbang.com
    
    // 【this 指向】:取决于调用方式
    console.log(this); 
    console.log(this.myName);
  }
}

function foo() {
  let myName = '极客时间'; // 注意:这是 foo 内部的局部变量
  return bar.printName;    // 返回的是函数引用,带走了吗?没有!
}

// 全局变量
var myName = '极客邦';

// 获取函数引用
var _printName = foo();

// 【关键调用】:独立函数调用
_printName(); 

🕵️‍♂️ 深度剖析:当 _printName() 执行时

假设我们在浏览器环境(非严格模式)下运行 _printName(),结果如下:

  1. console.log(myName) -> 输出 '极客邦'

    • 原因:这是自由变量查找。
    • 路径:函数内部找不到 -> 沿着词法作用域链向外找 -> 找到全局作用域下的 var myName = '极客邦'
    • 误区:很多人以为它会找到 foo 里的 '极客时间'错! printName 函数是在 bar 对象里定义的(全局作用域),它的“出生地”决定了它只能看到全局变量,根本看不见 foo 内部的 let myName。哪怕它是通过 foo 返回的,它的作用域链依然在定义时就固定了。
  2. console.log(bar.myName) -> 输出 'time.geekbang.com'

    • 原因:这是显式的对象属性访问,与 this 无关,直接读取 bar 对象上的值。
  3. console.log(this) & this.myName -> 输出 Windowundefined (或全局 myName)

    • 原因_printName()独立函数调用(前面没有点号)。
    • 规则:在非严格模式下,独立调用的 this 指向全局对象 window
    • 结果thiswindowwindow.myName 的值正是全局变量 '极客邦'(因为 var 声明的全局变量会自动挂载到 window 上)。

⚖️ 变量修改实验:let vs var 的蝴蝶效应

现在,我们来玩两个“如果”,看看世界如何改变。

实验 A:把 foo() 里的 let 换成 var
function foo() {
  var myName = '极客时间'; // 换成 var
  return bar.printName;
}
  • 结果毫无变化
  • 解析:无论 foo 内部用 let 还是 varmyName 依然是 foo局部变量printName 函数的作用域链依然只包含它自己、全局作用域,不包含 foo 的执行上下文。变量查找依然跳过 foo,直接找到全局的 '极客邦'
实验 B:把全局的 var myName 改为 let myName
// 全局
let myName = '极客邦'; // 换成 let
  • 结果
    • console.log(myName) -> 报错!ReferenceError: myName is not defined (如果在某些模块环境) 或者依然能访问到?
    • 修正解析:在全局作用域用 let 声明的变量不会挂载到 window 对象上,但它依然在全局词法环境中。
    • console.log(myName) (第一行) -> 依然输出 '极客邦'。因为变量查找是沿着词法作用域链,能找到全局 let 变量。
    • console.log(this.myName) (最后一行) -> 输出 undefined
    • 核心差异this 指向 window,而 window 对象上没有 myName 属性(因为 let 不挂载到 window)。
    • 结论:变量查找找到了值,但 this 查找失败了。这再次证明了变量查找路径this 指向是两套完全独立的系统。

💡 核心洞察函数带走的是“代码”,不是“环境”printName 被返回后,它依然坚守着它出生时的作用域链(全局),对 foo 内部的秘密(局部变量)一无所知。而 this 则像个墙头草,谁调用它,它就指向谁。


第二幕:身份的切换 —— 两种调用方式的终极对决

紧接着上面的代码,如果我们换一种调用方式,世界瞬间反转:

// 方式一:独立调用
_printName(); 

// 方式二:对象方法调用
bar.printName();

🥊 巅峰对决

特性 独立调用 (_printName()) 对象方法调用 (bar.printName())
语法形式 函数名直接加括号,前面无归属 对象.函数名(),前面有点号
this 指向 window (非严格模式) bar 对象
this.myName window.myName ('极客邦') bar.myName ('time.geekbang.com')
变量 myName 依然找全局 ('极客邦') 依然找全局 ('极客邦')
本质逻辑 函数失去了上下文,回归默认 函数明确了所有者,指向调用者
  • _printName():就像把一个员工从公司(bar)开除,让他去大街上(全局)流浪。此时他代表的是“路人甲”(window)。
  • bar.printName():员工在公司打卡上班。此时他明确代表“极客时间官网”(bar)。

💡 核心洞察点号(.)是 this 的开关。只要有 obj.func() 的形式,this 就是 obj。一旦把函数赋值给变量再调用(var f = obj.func; f()),点号消失,this 也就迷失了。


第三幕:错位的时空 —— 构造函数中的递归迷局

除了对象方法,new 操作符是 this 的另一个重要舞台。但这里同样藏着陷阱。

function CreateObj() {
    var temObj = {};             
    CreateObj.call(temObj);      // ⚠️ 致命递归
    temObj.__proto__ = CreateObj.prototype;
    return temObj;               
    console.log(this);           // 死代码
    this.name = '极客时间';      
}

var myObj = new CreateObj();

🚨 崩溃现场

这段代码试图在构造函数内部手动模拟 new,却导致了 栈溢出(RangeError)

  1. new 的隐式魔法:执行 new CreateObj() 时,引擎已经创建了实例 instance 并绑定了 this
  2. 致命的递归CreateObj.call(temObj) 并不是改变当前的 this,而是开启了一次全新的函数调用
    • 新调用 -> 创建新 temObj -> 再次 call -> 无限循环。
  3. 死代码return temObj 导致后面的 this.name 永远无法执行。且因为显式返回了对象,new 原本创建的 instance 被丢弃。

✅ 正确的“手动 New”姿势

要在外部模拟 new,必须在函数外控制:

function CreateObj() {
    this.name = '极客时间'; // 这里的 this 由外部 call 决定
}

var temObj = {};
temObj.__proto__ = CreateObj.prototype;
CreateObj.call(temObj); // 只调用一次,绑定 temObj
var myObj = temObj;

💡 核心洞察this 在函数执行瞬间即被定格。你无法在函数内部通过 call 篡改当前执行的 this,那只会开启新的轮回。


第四幕:舞台的主角 —— DOM 事件中的本能反应

最后,来到浏览器前端。

<a href="#" id="link">点击我</a>
<script>
document.getElementById('link').addEventListener("click", function(){
    console.log(this); // <a href="#" id="link">点击我</a>
});
</script>

🎭 舞台规则

addEventListener 的普通函数回调中:

this 自动指向触发事件的 DOM 元素。

  • 谁被点了? <a> 标签。
  • this 是谁? <a> 标签。

⚠️ 陷阱:若改用箭头函数 () => {}this 将不再指向 <a>,而是继承外层(通常是 window)。所以在处理 DOM 事件时,普通函数是首选


🏁 终极总结:掌握 JavaScript 的双核驱动

通过这四幕大戏,我们理清了 JavaScript 中最容易混淆的两个核心机制:

1. 变量查找(静态的·出身的烙印)

  • 规则:沿着词法作用域链向上查找。
  • 决定因素:函数写在哪里(声明位置)。
  • 特点:一旦函数定义完成,它能访问哪些变量就永久固定了,不受调用方式影响。
    • 案例printName 无论在哪儿调用,它永远只能找到全局的 myName,找不到 foo 内部的 myName

2. this 指向(动态的·舞台的身份)

  • 规则:看调用方式(Call Site)。
  • 决定因素:函数怎么被调用
  • 四大场景
    1. 独立调用 (func()) -> window (非严格模式)。
    2. 方法调用 (obj.func()) -> obj
    3. 构造调用 (new Func()) -> 新实例。
    4. 事件回调 (element.addEventListener(..., function)) -> DOM 元素。
    5. 显式绑定 (call/apply/bind) -> 指定的对象(开启新调用)。

🗝️ 钥匙在手

  • 如果你想访问外层变量,请关心作用域链(代码写在哪)。
  • 如果你想操作当前对象,请关心 this(代码怎么调)。
  • 切记:不要试图在函数内部用 call 改变当前的 this,那是徒劳的;也不要以为函数被传递后能带走它的局部变量环境,那也是错觉。

JavaScript 的灵活性赋予了它强大的能力,也带来了复杂性。但只要分清**“静态的作用域”“动态的 this”**,你就能在代码的迷宫中游刃有余,写出既精准又优雅的逻辑!

开发一个 TypeScript 语言服务插件:让 RTK Query 的"跳转到定义"更智能

作者 IT星宿
2026年3月8日 15:13

前言

在使用 Redux Toolkit Query (RTK Query) 进行开发时,你是否遇到过这样的困扰:

当你想查看某个 API 端点的具体实现时,按下 F12(跳转到定义),IDE 却把你带到了类型定义文件,而不是真正的业务代码。你需要手动搜索endpoint名称,才能在 createApi 中找到对应的定义。

这是一个普遍存在的问题,因为 RTK Query 的 hook 名称(如 useGetUserQuery)是动态生成的,TypeScript 无法建立从 hook 调用到endpoint定义的静态映射关系。

今天,我将介绍如何开发一个 TypeScript Language Service Plugin,来解决这个问题,让开发者能够一键跳转到 RTK Query 的 endpoint 定义。


问题背景

RTK Query 的工作原理

RTK Query 通过 createApi 创建 API 切片:

export const userApi = createApi({
  reducerPath: 'userApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUser: builder.query<User, string>({
      query: (id) => `/users/${id}`,
    }),
    updateUser: builder.mutation<User, Partial<User>>({
      query: (body) => ({
        url: '/users',
        method: 'POST',
        body,
      }),
    }),
  }),
})

// 自动生成的 hooks
export const { useGetUserQuery, useUpdateUserMutation } = userApi

痛点分析

  1. Hook 名称是动态派生的getUseruseGetUserQuery
  2. TypeScript 只能看到类型:IDE 的"跳转到定义"只能指向类型体操生成的类型定义
  3. 开发体验断裂:开发者需要手动搜索endpoint名称,打断编码流

解决方案:TypeScript Language Service Plugin

什么是 Language Service Plugin?

TypeScript Language Service Plugin 是一种扩展机制,允许我们拦截和自定义 TypeScript 语言服务的各种操作,包括:

  • 跳转到定义 (Go to Definition)
  • 自动补全 (Auto Completion)
  • 悬停提示 (Hover Information)
  • 代码重构 (Code Refactoring)

核心思路

我们的插件需要完成以下工作:

  1. 识别 RTK Query Hook:通过命名规则识别 use{Endpoint}Queryuse{Endpoint}Mutation 等 hook
  2. 解析 AST:找到 hook 所属的 API 实例
  3. 定位 Endpoint:从 API 实例的 endpoints 属性中找到对应的端点定义
  4. 返回定义位置:将跳转目标指向 endpoint 定义处

实现详解

1. 项目结构

rtk-to-endpoints/
├── src/
│   ├── index.ts      # 插件入口
│   └── utils.ts      # 核心逻辑
├── package.json
└── tsconfig.json

2. 插件入口 (index.ts)

import tslib from "typescript/lib/tsserverlibrary";
import { getDefinitionAndBoundSpan } from "./utils.js";

function init(modules: { typescript: typeof tslib }) {
  const ts = modules.typescript;

  function create(info: tslib.server.PluginCreateInfo) {
    const logger = info.project.projectService.logger;
    
    log("✅ Plugin initialized");

    const proxy: tslib.LanguageService = Object.create(info.languageService);

    // 拦截"跳转到定义"请求
    proxy.getDefinitionAndBoundSpan = (
      fileName: string,
      position: number
    ): tslib.DefinitionInfoAndBoundSpan | undefined => {
      const program = info.languageService.getProgram();
      
      // 尝试我们的自定义跳转逻辑
      const definitionInfo = getDefinitionAndBoundSpan(
        fileName, position, ts, program
      );
      
      // 如果匹配到 RTK Query hook,返回自定义结果
      // 否则,回退到默认行为
      return definitionInfo || 
        info.languageService.getDefinitionAndBoundSpan(fileName, position);
    };

    return proxy;
  }

  return { create };
}

export = init;

3. 核心逻辑 (utils.ts)

3.1 识别 Hook 命名模式

RTK Query 生成的 hook 遵循固定的命名规则:

const HOOK_PREFIXES = ["useLazy", "use"] as const;
const HOOK_SUFFIXES = [
  "InfiniteQueryState",
  "InfiniteQuery", 
  "QueryState",
  "Mutation",
  "Query",
] as const;

// 从 hook 名中提取 endpoint 名
export function extractEndpointName(hookName: string) {
  for (const prefix of HOOK_PREFIXES) {
    if (hookName.startsWith(prefix)) {
      const rest = hookName.slice(prefix.length);
      for (const suffix of HOOK_SUFFIXES) {
        if (rest.endsWith(suffix)) {
          const endpointName = rest.slice(0, rest.length - suffix.length);
          if (endpointName) {
            // 首字母小写:GetUser → getUser
            return endpointName[0].toLowerCase() + endpointName.slice(1);
          }
        }
      }
    }
  }
}

3.2 AST 节点查找

使用二分查找在 AST 中快速定位光标所在的节点:

export function getIdentifierNodeAt(
  sourceFile: tslib.SourceFile,
  pos: number,
): tslib.Node | undefined {
  let current: tslib.Node = sourceFile;
  
  while (true) {
    const children = current.getChildren(sourceFile);
    let left = 0;
    let right = children.length - 1;
    let targetChild: tslib.Node | undefined;

    // 二分查找覆盖指定位置的子节点
    while (left <= right) {
      const mid = (left + right) >>> 1;
      const child = children[mid];
      if (pos < child.pos) {
        right = mid - 1;
      } else if (pos >= child.end) {
        left = mid + 1;
      } else {
        targetChild = child;
        break;
      }
    }

    if (!targetChild) break;
    current = targetChild;
  }
  
  return current;
}

3.3 查找 API 实例

支持两种常见的 API 使用模式:

export function findApi(node: tslib.Node, ts: typeof tslib) {
  const parent = node.parent;
  
  // 模式 1:解构赋值
  // const { useGetUsersQuery } = userApi
  if (ts.isBindingElement(parent)) {
    const expressionNode = parent.parent?.parent;
    if (!ts.isVariableDeclaration(expressionNode)) return;
    const apiNode = expressionNode.getChildAt(
      expressionNode.getChildCount() - 1
    );
    if (!apiNode || !ts.isIdentifier(apiNode)) return;
    return apiNode;
    
  // 模式 2:属性访问
  // userApi.useGetProductsQuery()
  } else if (parent && ts.isPropertyAccessExpression(parent)) {
    return parent.getChildAt(parent.getChildCount() - 3);
  }
}

3.4 定位 Endpoint 定义

利用 TypeScript 的类型检查器,从 API 实例的 endpoints 属性中找到目标端点:

export function findEndpoint(
  apiNode: tslib.Node, 
  endpointName: string, 
  checker: tslib.TypeChecker
) {
  // 获取 API 实例的类型
  const apiType = checker.getTypeAtLocation(apiNode);
  
  // 获取 endpoints 属性
  const endpointsSymbol = apiType.getProperty('endpoints');
  if (!endpointsSymbol) return;
  
  // 获取 endpoints 的类型
  const endpointsType = checker.getTypeOfSymbol(endpointsSymbol);
  
  // 查找具体的 endpoint
  const endpointsPropertySymbol = endpointsType.getProperty(endpointName);
  return endpointsPropertySymbol;
}

3.5 组装定义信息

export function getDefinitionAndBoundSpan(
  fileName: string, 
  position: number, 
  ts: typeof tslib, 
  program?: tslib.Program
) {
  const sf = program!.getSourceFile(fileName);
  const checker = program!.getTypeChecker();
  if (!sf || !program || !checker) return;

  // 1. 找到光标处的标识符节点
  const identNode = getIdentifierNodeAt(sf, position);
  if (!identNode || !ts.isIdentifier(identNode)) return;

  // 2. 提取 endpoint 名称
  const endpointName = extractEndpointName(identNode.getText());
  if (!endpointName) return;

  // 3. 找到 API 实例
  const apiNode = findApi(identNode, ts);
  if (!apiNode) return;

  // 4. 查找 endpoint 定义
  const endpointSymbol = findEndpoint(apiNode, endpointName, checker);
  if (!endpointSymbol?.declarations?.length) return;

  // 5. 组装定义信息
  const definitions = endpointSymbol.declarations.map((node): tslib.DefinitionInfo => {
    return {
      fileName: node.getSourceFile().fileName,
      kind: ts.ScriptElementKind.memberFunctionElement,
      name: endpointSymbol.getName(),
      containerKind: ts.ScriptElementKind.classElement,
      containerName: "endpoints",
      textSpan: {
        start: node.getStart(),
        length: node.getWidth(),
      },
    };
  });

  return {
    definitions,
    textSpan: {
      start: identNode.getStart(sf),
      length: identNode.getWidth(sf),
    },
  };
}

使用方式

1. 安装插件

npm install --save-dev rtk-to-endpoints

2. 配置 tsconfig.json

{
  "compilerOptions": {
    "plugins": [
      {
        "name": "rtk-to-endpoints"
      }
    ]
  }
}

3. 配置 VSCode

由于VSCode内置的TypeScript无法读取到项目下的npm包,需要在 VSCode 中设置使用工作区的TypeScript版本:

  1. Ctrl+Shift+P → 输入 "TypeScript: Select TypeScript Version"
  2. 选择 "Use Workspace Version"
  3. 重新加载窗口 (Developer: Reload Window)

效果演示

配置完成后,当你在任何 RTK Query hook 上使用"跳转到定义":

// 点击 useGetUserQuery,直接跳转到 getUser endpoint 定义
const { data } = userApi.useGetUserQuery(userId);

跳转前

  • 指向类型定义文件(无实际业务价值)

跳转后

  • 直接定位到 createApi 中的 getUser endpoint 定义

技术要点总结

1. TypeScript Language Service 架构

┌─────────────────────────────────────────┐
│           VSCode / IDE                  │
└─────────────┬───────────────────────────┘
              │ LSP 协议
┌─────────────▼───────────────────────────┐
│      TypeScript Language Server         │
└─────────────┬───────────────────────────┘
              │
┌─────────────▼───────────────────────────┐
│    TypeScript Language Service          │
│  ┌─────────────────────────────────┐    │
│  │  rtk-to-endpoints Plugin        │    │
│  │  (拦截 getDefinitionAndBoundSpan)│   │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘

2. 关键技术点

技术点 说明
AST 遍历 使用二分查找高效定位节点
类型检查器 利用 TypeChecker 解析类型信息
代理模式 包装原有 Language Service,保留默认行为
命名解析 通过字符串模式匹配识别 hook 类型

扩展思考

这个插件的实现思路可以扩展到其他类似的场景:

  1. Vue Composition API:从 useXxx 跳转到 composable 定义
  2. React Hooks:增强自定义 hook 的跳转体验

结语

TypeScript Language Service Plugin 是一个强大的工具,能够显著提升开发体验。通过理解 TypeScript 的编译器 API 和语言服务架构,我们可以针对特定的框架和库,打造更智能的 IDE 支持。

希望这篇文章能够帮助你理解 Language Service Plugin 的工作原理,并激发你为自己的项目开发类似的工具。


参考资源


如果这篇文章对你有帮助,欢迎点赞、收藏和分享!

有任何问题或建议,欢迎在评论区留言讨论。

Canvas 直线点击事件处理优化

作者 Kakarotto
2026年3月8日 14:29

    在平常Canvas开发中,经常会遇到直线的点击事件问题,对于这类问题通常的做法就是使用isPointInStroke,但直接使用存在一个问题就是直线的宽度较小时,鼠标点击不太容易选中。下面是针对这类问题总结的一些优化方法。

使用isPointInStroke

    平常开发中,经常使用isPointInStroke方法判断鼠标点击位置是否位于直线上,常规代码如下:

<script setup>
    import { ref, onMounted } from 'vue';
    
    const canvasRef = ref();
    let ctx;
    let isLineSelected = false;
    
    // 直线的起点和终点坐标
    const lineStart = { x: 100, y: 200 };
    const lineEnd = { x: 500, y: 200 };
    
    const clear = () => {
        // 清除画布
        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    }
    
    // 绘制直线的函数
    const drawLine = () => {
    
        // 设置线条样式
        ctx.strokeStyle = isLineSelected ? '#ff0000' : '#000000';
        ctx.lineWidth = isLineSelected ? 4 : 2;
    
        // 绘制直线
        ctx.beginPath();
        ctx.moveTo(lineStart.x, lineStart.y);
        ctx.lineTo(lineEnd.x, lineEnd.y);
        ctx.stroke();
    };
    onMounted(() => {
        if (canvasRef.value) {
            const canvas = canvasRef.value;
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
            ctx = canvas.getContext('2d');
    
            drawLine();
    
            // 添加鼠标点击事件监听器
            canvasRef.value.addEventListener('click', e => {
                const rect = canvasRef.value.getBoundingClientRect();
                const x = e.clientX - rect.left;
                const y = e.clientY - rect.top;
    
                if (ctx.isPointInStroke(x, y)) {
                    isLineSelected = !isLineSelected;
                    clear()
                    drawLine();
                }
            });
        }
    });
</script>    

    这样我们就可以实现鼠标点击的选中效果,但是这种方法并不完美,当线的宽度较小时,这是就很难选中这条线。 下面我们来优化一下,依旧使用isPointInStroke这个方法,代码如下:

// 检测点击是否在直线上的函数
const isPointOnLine = (x, y) => {
    if (!ctx) return false;

    // 创建直线路径
    ctx.beginPath();
    ctx.moveTo(lineStart.x, lineStart.y);
    ctx.lineTo(lineEnd.x, lineEnd.y);

    // 设置鼠标点击时的容错率
    ctx.lineWidth = 10;

    // 使用 Canvas API 的 isPointInStroke 方法检测点击是否在直线上
    return ctx.isPointInStroke(x, y);
};
if (isPointOnLine(x, y)) {
    isLineSelected = !isLineSelected;
    clear()
    drawLine();
}

    我们把判断条件写成一个方法,在判断之前模拟一条起始坐标和终点坐标相同的线,为了解决线的宽度较小时不太容易选中的问题, 我们在模拟这条线是设置一个较大的宽度,这样就可以优化鼠标点击时不容易选中的问题了。

使用点到直线的距离公式

    除了使用isPointInStroke方法判断鼠标点击位置是否位于直线上,我们还可以使用点到直线的距离公式判断鼠标点击位置是否位于直线上。计算点到直线的距离公式有很多种方法,比如一般式、参数式、向量式等。因为这里我们已知直线的两个坐标和鼠标点击 位置的坐标,使用向量叉积来计算点到直线的距离更为方便。
    点到直线的距离公式如下: iShot_2026-03-07_17.26.21.png     其中,(x1,y1)(x1, y1)(x2,y2)(x2, y2) 是直线的两个坐标,(x0,y0)(x0, y0) 是鼠标点击位置的坐标。代码实现如下:

/**
 * 计算点到直线的距离
 * @param x0 点的 x 坐标
 * @param y0 点的 y 坐标
 * @param x1 直线上一点的 x 坐标
 * @param y1 直线上一点的 y 坐标
 * @param x2 直线上另一点的 x 坐标
 * @param y2 直线上另一点的 y 坐标
 * @param threshold 距离阈值,默认为 10
 * @returns 点到直线的距离是否小于阈值
 */
function pointToLineDistance(x0, y0, x1, y1, x2, y2, threshold = 10) {
  // 计算向量 AB
  const vectorABx = x2 - x1;
  const vectorABy = y2 - y1;

  // 计算向量 AP
  const vectorAPx = x0 - x1;
  const vectorAPy = y0 - y1;

  // 计算叉乘的绝对值(点到直线的距离的分子)
  const crossProduct = Math.abs(vectorABx * vectorAPy - vectorABy * vectorAPx);

  // 计算线段 AB 的长度
  const segmentLength = Math.hypot(vectorABx, vectorABy);

  // 处理线段长度为 0 的情况(两点重合)
  if (segmentLength < 1e-6) {
    // 计算点到点的距离
    const pointDistance = Math.hypot(vectorAPx, vectorAPy);
    return pointDistance < threshold;
  }

  // 计算点到直线的距离
  const distance = crossProduct / segmentLength;

  return distance < threshold;
}

总结

    这两种方法都可以解决线的宽度较小时鼠标点击不容易选中的问题。在数据量不是很大的时候,推荐使用isPointInStroke方法, 在Canvas中直线是最小的单位,创建和绘制直线都是非常快的操作,不会对性能造成太大影响。当数据量大的时候,频繁的创建也会导致性能问题,这时候使用数学方法计算点到直线的距离会更加高效,不依赖 Canvas 状态,计算精确,可定制性强。

CSS 里的「if」:@media、@supports 与即将到来的 @when/@else

作者 兆子龙
2026年3月8日 14:18

CSS 里的「if」:@media、@supports 与即将到来的 @when/@else

梳理 CSS 中实现「条件判断」的几种方式:媒体查询、特性查询,以及规范中的 @when/@else,并给出简单用法与兼容性说明。


一、CSS 有 if 吗?

CSS 没有像 JavaScript 那样的 if (x) { } 语句,但可以通过 @ 规则 做「条件式」样式:满足某条件时才应用某段样式。常见的有两类:媒体查询(@media)特性查询(@supports);规范里还有正在推进的 @when / @else,写法更接近「if-else」,但目前浏览器尚未普遍支持。下面按「能用 today」和「即将到来」分开说。


二、@media:按视口/设备「if」

@media 用来根据媒体类型与媒体特征(如视口宽度、横竖屏、分辨率)决定是否应用样式,相当于「如果屏幕满足某条件,用这段 CSS」。

/* 视口宽度 ≥ 768px 时用栅格布局 */
@media (min-width: 768px) {
  .grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
  }
}

/* 横屏时调整内边距 */
@media (orientation: landscape) {
  .panel {
    padding: 2rem;
  }
}

常见条件min-width / max-widthmin-heightorientationprefers-color-scheme(深色/浅色)、prefers-reduced-motion 等。多条件用 and 连接;需要「或」时写多个 @media 或在一个规则里用逗号。兼容性:现代浏览器均支持,是响应式布局的基础。


三、@supports:按浏览器能力「if」

@supports特性查询如果浏览器支持某 CSS 属性或语法,应用这段样式;不支持则跳过。适合做渐进增强(先写基础样式,再在支持新特性的浏览器里增强)。

/* 支持 Grid 时用 Grid 布局 */
@supports (display: grid) {
  .container {
    display: grid;
    gap: 1rem;
  }
}

/* 不支持时回退 */
@supports not (display: grid) {
  .container {
    display: flex;
    flex-wrap: wrap;
  }
}

/* 同时支持多个特性时 */
@supports (display: grid) and (gap: 1rem) {
  .container {
    display: grid;
    gap: 1rem;
  }
}

逻辑@supports (条件)notandor;还可检测选择器,如 @supports selector(:has(a))兼容性:主流浏览器早已支持,可放心用。


四、@when / @else:规范里的「if-else」(即将到来)

@when@elseCSS Conditional Rules Level 5 中的新规则,用来统一写条件:把媒体条件、特性支持等写进同一套「when-else」链里,语义更接近「if-else if-else」,减少多层 @media 嵌套。

示例(语法以最终规范为准)

@when media(min-width: 800px) {
  .sidebar { width: 300px; }
}
@else media(min-width: 600px) {
  .sidebar { width: 240px; }
}
@else {
  .sidebar { width: 100%; }
}

还可组合 mediasupports

@when media(min-width: 1024px) and supports(display: grid) {
  .layout { display: grid; }
}
@else {
  .layout { display: block; }
}

现状:截至 2024–2025 年,主流浏览器尚未支持 @when/@else,目前只能在支持该规范的实验环境或未来版本中使用。写新项目时仍以 @media + @supports 为主;等 @when/@else 普及后,再考虑重构为更简洁的条件链。


五、对比与使用建议

方式 作用 兼容性 典型场景
@media 视口/设备条件 全面支持 响应式、深色模式、动效偏好
@supports 浏览器能力条件 全面支持 渐进增强、Grid/Flex 回退
@when/@else 统一条件链 尚未支持 未来多条件、互斥分支

建议

  • 需要「根据屏幕大小/横竖屏/主题」切换样式 → 用 @media
  • 需要「根据是否支持某 CSS 特性」切换样式 → 用 @supports
  • 两者可以组合:先 @media 再在块内写 @supports,或反过来。
  • @when/@else 先了解语法即可,等 Can I Use 显示普遍支持后再在实际项目中使用。

六、小结

  • CSS 没有字面意义的 if,但用 @media(媒体条件)和 @supports(特性条件)可以实现「满足条件才应用样式」。
  • @media:按视口宽度、横竖屏、prefers-* 等写响应式与偏好适配。
  • @supports:按浏览器是否支持某属性/选择器写渐进增强与回退。
  • @when/@else:规范中的统一条件语法,可读性更好,目前浏览器未支持,可关注 CSS Conditional Level 5 与 Can I Use 的更新。

若对你有用,欢迎点赞、收藏;你若有基于 @supports 或 @media 的实战写法,也欢迎在评论区分享。

PM2 使用指南 - 踩坑记录

2026年3月8日 13:25

最近把本地项目改成用 PM2 跑,踩了一点坑,记录一下免得下次又忘。

基础配置

先装 PM2:

npm install -g pm2
或
pnpm install -g pm2

创建一个 ecosystem.config.cjs 文件,这是 PM2 的配置文件:

module.exports = {
  apps: [
    {
      name: 'blog',           // 应用名称
      script: 'npx',          // 用什么命令跑
      args: 'next start -H 0.0.0.0',  // 命令参数
      cwd: '/path/to/project', // 项目路径
      instances: 1,           // 实例数量
      exec_mode: 'fork',      // 运行模式
      autorestart: true,      // 崩溃自动重启
      watch: false,           // 不监听文件变化
      max_memory_restart: '1G', // 内存超限重启
      env: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
    },
  ],
};

实例数量这个坑

刚开始配置的时候,我把 instances 设置成了 'max',结果一启动就开了 16 个实例。查了一下才知道,'max' 会根据 CPU 核心数自动启动对应数量的实例。我家里主机是 16 核的,所以直接开了 16 个。

对于小项目来说,2-4 个实例就够用了,没必要开那么多。直接写数字就行:

instances: 2,  // 开 2 个实例

重启不生效的问题

改完配置文件后,我直接用了 pm2 restart,结果配置根本没生效。查了文档才知道,pm2 restart 不会重新读取配置文件,只是重启现有的进程。

正确的做法是:

pm2 delete blog      # 先删除
pm2 start ecosystem.config.cjs  # 再启动

或者用我配置好的命令:

npm run pm2:delete
npm run pm2:start

script 和 args 的选择

一开始我用 script: 'npm'args: 'start',结果各种问题。后来改成直接用 npx 就好多了:

// 不推荐
script: 'npm',
args: 'start',

// 推荐
script: 'npx',
args: 'next start -H 0.0.0.0',

npx 直接运行命令更稳定,npm 作为中间层有时候会有奇怪的问题。

开发环境和生产环境的区别

这个坑我解决了好一会。开发环境要用 next dev,生产环境用 next start

开发环境配置:

{
  script: 'npx',
  args: 'next dev --turbopack -H 0.0.0.0',
  exec_mode: 'fork',  // 开发环境不支持 cluster
  env: {
    NODE_ENV: 'development',
  },
}

生产环境配置:

{
  script: 'npx',
  args: 'next start -H 0.0.0.0',
  exec_mode: 'cluster',  // 生产环境可以用 cluster
  instances: 2,
  env: {
    NODE_ENV: 'production',
  },
}

注意!!!开发环境不能用 cluster 模式,只能用 fork

监听地址的问题

Next.js 默认只监听 localhost,外部访问不了。需要加 -H 0.0.0.0 参数。

我试过用环境变量 HOSTNAME: '0.0.0.0',但在生产模式下不起作用,还是得用命令行参数。

args: 'next start -H 0.0.0.0',  // 这样才生效

Next.js 的 basePath

如果 Next.js 配置了 basePath,访问的时候要加上这个路径。比如:

// next.config.ts
export default {
  basePath: '/blog',
}

那访问地址就是 http://127.0.0.1:3000/blog,不是 http://127.0.0.1:3000

常用命令集合

# 启动
pm2 start ecosystem.config.cjs

# 停止
pm2 stop blog

# 重启(不重读配置)
pm2 restart blog

# 删除
pm2 delete blog

# 查看日志
pm2 logs blog

# 查看状态
pm2 list

# 查看详情
pm2 show blog

# 监控面板
pm2 monit

开机自启

# 保存当前进程列表
pm2 save

# 生成开机启动脚本
pm2 startup

package.json 脚本

把常用命令写到 package.json 里,方便使用:

{
  "scripts": {
    "pm2:start": "pm2 start ecosystem.config.cjs",
    "pm2:stop": "pm2 stop blog",
    "pm2:restart": "pm2 restart blog",
    "pm2:delete": "pm2 delete blog",
    "pm2:logs": "pm2 logs blog",
    "pm2:monit": "pm2 monit"
  }
}

总结复盘

  • instances: 'max' 会开很多实例,小项目直接写数字
  • 改配置后要先 deletestartrestart 不重读配置
  • npxnpm 稳定
  • 开发环境用 next dev,生产环境用 next start
  • 开发环境只能用 fork 模式
  • -H 0.0.0.0 让服务监听所有地址
  • 注意 Next.js 的 basePath 配置

差不多就这些点了,希望能帮到后面用 PM2 的同学。

React 中 useState、useEffect、useRef 的区别与使用场景详解,终于有人讲明白了

作者 HelloReader
2026年3月8日 13:23

一、先用一句话概括这三个 Hook

如果你现在还很懵,先别慌,先记住下面这三句话。

useState

让组件记住会影响页面展示的数据

useEffect

让组件在渲染后去执行额外操作

useRef

让组件保存一个不会触发重新渲染的值,或者拿到 DOM 元素

这三句话,已经把它们最本质的区别说出来了。

如果还觉得抽象,没关系,接下来我一个个拆开讲。

二、先说 useState:它是“状态管理”的

React 组件最大的特点之一,就是:

数据一变,页面跟着变。

useState,就是专门用来保存这种“会驱动页面变化的数据”的。

先看最经典的例子。

import React, { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      当前点击了 {count} 次
    </button>
  );
}

export default Counter;

这里这句最关键:

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

它的意思你可以直接翻译成人话:

React,帮我准备一个状态,初始值是 0,当前值叫 count,修改它的方法叫 setCount。

也就是说:

  • count 是当前状态值
  • setCount 是更新状态的方法
  • 0 是初始值

当你点击按钮执行:

setCount(count + 1);

React 会做两件事:

  1. 更新状态值
  2. 重新渲染组件

所以页面上的 count 就会变。

useState 最典型的应用场景

useState 常用于这些地方:

  • 计数器数字
  • 输入框内容
  • 弹窗是否显示
  • 下拉框选中项
  • 当前分页页码
  • 列表数据
  • 加载状态 loading
  • 错误提示信息

比如控制弹窗:

const [visible, setVisible] = useState(false);

比如保存输入框内容:

const [keyword, setKeyword] = useState("");

比如保存接口返回的数据:

const [list, setList] = useState([]);

这些都属于:

一旦数据变化,页面就要跟着变化。

这时候就应该用 useState

三、再说 useEffect:它是“副作用处理”的

很多人第一次看到“副作用”这个词,容易被吓到。

其实它没有那么玄乎。

你可以简单把副作用理解成:

除了渲染页面以外,还要额外做的事情。

比如:

  • 请求接口
  • 设置定时器
  • 监听事件
  • 修改浏览器标题
  • 操作本地存储
  • 手动操作 DOM
  • 组件销毁时做清理

这些都不是“渲染 JSX”本身,而是页面渲染之后要顺便做的事。

这时候就轮到 useEffect 出场了。

先看一个最简单的例子:

import React, { useEffect } from "react";

function Demo() {
  useEffect(() => {
    console.log("组件渲染完成了");
  }, []);

  return <div>Hello React</div>;
}

这段代码的意思就是:

页面渲染完以后,执行 console.log

所以你可以理解成:

useEffect = 渲染后执行任务

useEffect 最常见的使用场景

1. 请求接口

useEffect(() => {
  fetch("/api/user")
    .then((res) => res.json())
    .then((data) => {
      console.log(data);
    });
}, []);

2. 设置定时器

useEffect(() => {
  const timer = setInterval(() => {
    console.log("每秒执行一次");
  }, 1000);

  return () => clearInterval(timer);
}, []);

3. 监听事件

useEffect(() => {
  const handleResize = () => {
    console.log(window.innerWidth);
  };

  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

4. 修改页面标题

useEffect(() => {
  document.title = "用户中心";
}, []);

这些都是副作用。

也就是说:

只要不是单纯为了渲染页面,而是渲染后还要做点别的事,大概率就要想到 useEffect。

四、再说 useRef:它是“持久容器”和“DOM 引用”

useRef 是很多初学者最容易迷糊的 Hook。

因为它不像 useState 那么直观,也不像 useEffect 那么容易理解成“执行动作”。

其实 useRef 可以简单理解成两个作用。

作用一:获取 DOM 元素

比如你想让输入框在页面加载后自动获取焦点:

import React, { useEffect, useRef } from "react";

function InputFocus() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  return <input ref={inputRef} placeholder="请输入内容" />;
}

export default InputFocus;

这里可以这样理解:

  • useRef(null) 创建一个引用对象
  • inputRef.current 会指向真实的 input DOM
  • 通过 focus() 就可以让输入框聚焦

也就是说:

useRef 可以帮你“拿到页面中的真实元素”。

作用二:保存一个值,但不触发页面重新渲染

这是 useRef 更重要、也更容易被忽略的能力。

比如保存定时器 id:

const timerRef = useRef(null);

赋值:

timerRef.current = setInterval(() => {
  console.log("running");
}, 1000);

清除:

clearInterval(timerRef.current);

这个值会一直保留在组件生命周期里,但它变化时不会导致页面重渲染。

所以你可以把 useRef 理解成:

组件里的一个“小盒子”,你可以往里面放东西,它会一直记着,但不会因为盒子里的东西变了就刷新页面。

五、它们三个最大的区别,到底是什么?

这是本文最核心的部分。

我先直接给你一个最重要的结论:

Hook 核心作用 数据变化后会不会触发重新渲染
useState 保存状态
useEffect 执行副作用 本身不是存数据的
useRef 保存引用/持久值 不会

把这张表吃透,你就不容易乱用了。

接下来我一个个解释。

六、useState 和 useRef 的区别,初学者最容易搞混

很多人学到这里时,最大的疑问就是:

既然 useState 能存值,useRef 也能存值,那到底啥时候用谁?

答案非常简单:

需要更新页面的,用 useState

不需要更新页面的,用 useRef

来看例子。

场景 1:页面上要显示这个值

import React, { useState } from "react";

function Demo() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>当前数字:{count}</p>
      <button onClick={() => setCount(count + 1)}>加一</button>
    </div>
  );
}

这里 count 是显示在页面上的。

点击按钮后,页面中的数字也要变化。

所以必须用 useState

场景 2:只是内部记一下,不需要显示

import React, { useRef } from "react";

function Demo() {
  const clickTimesRef = useRef(0);

  const handleClick = () => {
    clickTimesRef.current += 1;
    console.log("点击次数:", clickTimesRef.current);
  };

  return <button onClick={handleClick}>点击我</button>;
}

这里点击次数只是打印在控制台,并没有显示在页面上。

那就没必要用 useState,用 useRef 就够了。

再总结一遍

useState 的场景

  • 页面要展示这个数据
  • 数据变化后希望组件重新渲染
  • 数据会驱动 UI 更新

useRef 的场景

  • 只是临时保存一个值
  • 不希望因为这个值变化而重新渲染
  • 保存 DOM、定时器 id、上一次值等

七、为什么 useRef 改了值,页面不更新?

这个问题特别经典,面试也爱问。

比如下面这段代码:

import React, { useRef } from "react";

function Demo() {
  const countRef = useRef(0);

  const handleClick = () => {
    countRef.current += 1;
    console.log(countRef.current);
  };

  return (
    <div>
      <p>{countRef.current}</p>
      <button onClick={handleClick}>点击</button>
    </div>
  );
}

很多初学者会以为点击按钮后,页面上的数字会变。

但实际上,页面大概率不会更新。

为什么?

因为:

修改 ref.current 不会触发组件重新渲染。

React 只会在这些情况下重新渲染组件:

  • props 变了
  • state 变了
  • 父组件重新渲染导致子组件重新渲染

ref.current 的变化,不在 React 的“响应式更新系统”里。

所以它改了,React 不会主动刷新页面。

这就是 useRefuseState 最大的区别之一。

八、useEffect 和 useState 的关系是什么?

开发中经常看到这俩一起出现。

比如页面加载后请求数据:

import React, { useEffect, useState } from "react";

function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
      });
  }, []);

  return (
    <ul>
      {users.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

这里的配合方式非常典型:

  • useState 负责存数据
  • useEffect 负责获取数据

也就是说:

useState 管“保存结果”,useEffect 管“执行动作”。

你可以理解成:

  • useState 是仓库
  • useEffect 是工人
  • 工人出去搬货,最后把货放进仓库里

这是它们最经典的协作模式。

九、useEffect 的依赖数组到底怎么理解?

这个问题,是 React 初学者最容易卡壳的地方之一。

我们先看写法:

useEffect(() => {
  console.log("执行副作用");
}, []);

第二个参数 [],就叫 依赖数组

它决定这个副作用什么时候执行。

1. 传空数组 []

useEffect(() => {
  console.log("只执行一次");
}, []);

表示:

组件首次渲染完成后执行一次。

常见用途:

  • 页面加载请求一次接口
  • 初始化某些逻辑
  • 绑定事件监听并在销毁时清理

2. 不传依赖数组

useEffect(() => {
  console.log("每次渲染都执行");
});

表示:

组件每次渲染后都会执行。

这个一般要慎用,否则可能造成不必要的执行。

3. 传某个依赖项

useEffect(() => {
  console.log("count 变化了");
}, [count]);

表示:

首次渲染执行一次,以后只有 count 变化时才执行。

4. 传多个依赖项

useEffect(() => {
  console.log("count 或 keyword 变化了");
}, [count, keyword]);

表示:

只要 countkeyword 中任意一个变化,副作用就会重新执行。

最通俗的理解方式

你可以把依赖数组理解成一句话:

只要数组里的这些值变了,就重新执行这段副作用代码。

这就很好记了。

十、useEffect 的清理函数是干嘛的?

很多人刚开始看到这种写法会有点懵:

useEffect(() => {
  const timer = setInterval(() => {
    console.log("执行中");
  }, 1000);

  return () => {
    clearInterval(timer);
  };
}, []);

为什么 useEffect 里面还要 return 一个函数?

这个函数叫:

清理函数

它一般会在这些时候执行:

  1. 组件卸载时
  2. 副作用重新执行前,先清理上一次的副作用

最常见的用途有:

  • 清除定时器
  • 移除事件监听
  • 取消订阅
  • 中断请求

比如监听窗口大小变化:

useEffect(() => {
  const handleResize = () => {
    console.log(window.innerWidth);
  };

  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

这里如果不做清理,组件销毁后事件还在,就可能造成内存泄漏或者逻辑混乱。

所以你可以这样记:

副作用用了什么外部资源,离开时就记得清掉。

十一、三个 Hook 的生活化比喻,一下就记住

为了让你更容易记住,我给你打个特别通俗的比方。

把 React 组件想象成一个办公室员工。

useState:员工的记事本

员工需要记住今天要做什么、当前完成多少、按钮是开还是关。

这些会影响工作展示给老板看。

所以:

useState = 会展示出来的正式数据

useEffect:员工的任务清单

员工上班后要做事:

  • 给客户打电话
  • 发邮件
  • 开会
  • 定时汇报

这些不是“展示内容”,而是要执行的动作。

所以:

useEffect = 渲染后执行的额外任务

useRef:员工的抽屉

员工抽屉里放着一些东西:

  • 钥匙
  • 工牌
  • 上一次会议记录
  • 某个客户电话
  • 临时编号

这些不需要写到汇报 PPT 上,但又得一直留着备用。

所以:

useRef = 持久保存但不驱动页面变化的数据容器

这个比喻基本能帮很多初学者彻底理顺。

十二、实际开发中该怎么选?

这里我给你一个非常实战的判断口诀。

场景一:数据变了,页面也要变

useState

比如:

  • 输入框输入内容
  • 列表数据变化
  • loading 状态
  • tab 切换
  • 当前选中项

场景二:页面出来后要执行动作

useEffect

比如:

  • 请求接口
  • 绑定事件
  • 启动定时器
  • 修改标题
  • 同步本地存储

场景三:只想记个值,不想刷新页面

useRef

比如:

  • 保存 timer id
  • 保存上一次值
  • 防抖节流中的锁
  • 获取 input DOM
  • 防止重复提交标记

这个口诀非常适合业务开发时快速判断。

十三、一个综合案例,把三个 Hook 串起来理解

下面我们写一个小案例:搜索框自动聚焦,并在输入时同步标题,同时记录输入次数。

import React, { useEffect, useRef, useState } from "react";

function SearchDemo() {
  const [keyword, setKeyword] = useState("");
  const inputRef = useRef(null);
  const changeCountRef = useRef(0);

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  useEffect(() => {
    document.title = keyword ? `正在搜索:${keyword}` : "搜索页面";
  }, [keyword]);

  const handleChange = (e) => {
    setKeyword(e.target.value);
    changeCountRef.current += 1;
    console.log("输入次数:", changeCountRef.current);
  };

  return (
    <div>
      <h2>搜索示例</h2>
      <input
        ref={inputRef}
        value={keyword}
        onChange={handleChange}
        placeholder="请输入关键词"
      />
      <p>当前关键词:{keyword}</p>
    </div>
  );
}

export default SearchDemo;

这个案例里:

useState

保存输入框内容 keyword

因为它要显示到页面上,所以必须用状态。

第一个 useEffect

页面加载后让输入框自动聚焦

因为这是渲染后执行的动作,所以用 useEffect

第二个 useEffect

每当 keyword 变化时更新浏览器标题

这也属于副作用,所以还是 useEffect

useRef

一个拿 DOM:inputRef
一个记录输入次数:changeCountRef

输入次数只是打印日志,并不展示到页面,所以没必要用 useState,用 useRef 更合适。

这个案例基本把三个 Hook 的职责划分得很清楚了。

十四、面试中怎么回答它们的区别?

如果面试官问你:

useStateuseEffectuseRef 的区别是什么?

你可以这么回答:

useState 主要用于管理组件状态,当状态变化时会触发组件重新渲染,通常用来保存那些会影响页面展示的数据。
useEffect 主要用于处理副作用,也就是组件渲染之后需要执行的额外逻辑,比如请求接口、事件监听、定时器、修改标题等。
useRef 主要用于保存引用或者持久化数据,它既可以获取 DOM 元素,也可以保存一些不需要触发组件重新渲染的值,比如定时器 id、上一次的值等。
它们的核心区别在于:useState 管状态并驱动视图更新,useEffect 管副作用执行,useRef 管持久化引用但不会触发视图更新。

这段话很适合面试时直接说。

十五、初学者最常犯的几个错误

1. 该用 useRef 的地方用了 useState

比如只是存一个定时器 id,却写成:

const [timer, setTimer] = useState(null);

其实这类数据不参与页面展示,用 useRef 更合理。

2. 该用 useState 的地方用了 useRef

比如页面上的数字要变化,却写成:

const countRef = useRef(0);
countRef.current += 1;

结果发现页面不更新。

因为 useRef 的变化不会触发渲染。

3. 把所有逻辑都往 useEffect 里塞

有些逻辑其实只是普通计算,不一定非要写 useEffect

不要一上来就觉得“只要是逻辑就放 useEffect”。

4. useEffect 依赖数组乱写

比如副作用里明明用到了 count,却不写到依赖数组里,容易造成旧值问题。

5. 忘记清理副作用

比如监听事件、开定时器却不清理,组件销毁后可能引发 bug。

十六、最后给你一个最简单的判断公式

以后开发时,如果你一时分不清到底该用谁,就套这三句判断。

第一问:这个数据要不要显示到页面上?

要,就优先考虑 useState

第二问:这个逻辑是不是要在渲染之后执行?

是,就优先考虑 useEffect

第三问:我是不是只是想记个值,或者拿 DOM,但不想刷新页面?

是,就优先考虑 useRef

这三个问题,基本能帮你解决 80% 的判断场景。

十七、总结

这篇文章讲了很多,其实最后你真正要记住的,就这几句话。

useState 是什么?

保存会影响页面展示的状态,状态变了会重新渲染。

useEffect 是什么?

处理渲染后的副作用,比如请求接口、事件监听、定时器等。

useRef 是什么?

保存不会触发重新渲染的值,或者获取 DOM 元素。

它们的最大区别是什么?

  • useState:存状态,更新会刷新页面
  • useEffect:执行副作用,不是拿来存数据的
  • useRef:存引用或值,但更新不会刷新页面

如果你之前一直觉得这三个 Hook 很绕,那你现在可以直接把它们理解成:

  • useState:页面数据管理员
  • useEffect:页面行为执行器
  • useRef:页面内部小仓库

这样再看 React Hook,很多东西就没那么抽象了。

❌
❌