普通视图

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

Midscene v1.0 发布 - 视觉驱动,UI 自动化体验跃迁

作者 WebInfra
2025年12月25日 15:25

Midscene v1.0 发布 - 视觉驱动,UI 自动化体验跃迁

Midscene 自 2024 年开源发布以来,已经在 Github 斩获 11k star 、Trending 榜第二名等成绩,并在互联网、金融、政企、汽车等大量应用场景下完成落地。

本月,我们正式宣布 Midscene v1.0 发布!本文将为你介绍:

  • 案例回顾:Midscene 在 PC、Android、iOS 等场景的任务能力

  • 社区案例:社区开发者基于 Midscene 与任意界面集成的特性,扩展了机械臂 + 视觉模型 + 语音模型等模块,完成车机测试

  • 1.0 版本的模型路线:拥抱纯视觉

  • 1.0 版本的特性优化:报告优化、MCP 架构、跨端增强、API 变更等

案例回顾

社区案例:机械臂

视频地址:midscenejs.com/zh/showcase…

移动端案例:外卖下单

视频地址:midscenejs.com/zh/showcase…

1.0 版本的模型路线:

从 V1.0 开始,Midscene 全面转向视觉理解方案,提供更稳定可靠的 UI 自动化能力。

视觉模型有以下特点:

  • 效果稳定 :业界领先的视觉模型(如 Doubao Seed 1.6、Qwen3-VL 等)表现足够稳定,已经可以满足大多数业务需求

  • UI 操作规划 :视觉模型通常具备较强的 UI 操作规划能力,能够完成不少复杂的任务流程

  • 适用于任意系统 :自动化框架不再依赖 UI 渲染的技术栈。无论是 Android、iOS、桌面应用,还是浏览器中的 <canvas>,只要能获取截图,Midscene 即可完成交互操作

  • 易于编写 :抛弃各类 selector 和 DOM 之后,开发者与模型的“磨合”会变得更简单,不熟悉渲染技术的新人也能很快上手

  • token 量显著下降 :在去除 DOM 提取之后,视觉方案的 token 使用量可以减少 80%,成本更低,且本地运行速度也变得更快

  • 有开源模型解决方案 :开源模型表现渐佳,开发者开始有机会进行私有化部署模型,如 Qwen3-VL 提供的 8B、30B 等版本在不少项目中都有着不错的效果

详情请阅读我们更新版的模型策略

🚀 多模型组合,为复杂任务带来更好效果

除了默认的交互场景,Midscene 还定义了 Planning(规划)和 Insight(洞察)两种意图,开发者可以按需为它们启用独立的模型。例如,用 GPT 模型做规划,同时使用默认的 Doubao 模型做元素定位。

多模型组合让开发者可以按需提升复杂需求的处理能力。

🚀 运行时架构优化

针对 Midscene 的运行时表现,我们进行了以下优化:

  • 减少对设备信息接口的调用,在确保安全的情况下复用部分上下文信息,提升运行时性能,让大多数的时间消耗集中在模型端

  • 优化 Web 及移动端环境下的 Action Space 组合,向模型开放更合理、更清晰的工具集

🚀 回放报告优化

回放报告是 Midscene 开发者非常依赖的一个特性,它能有效提升脚本的调试效率。

在 v1.0 中,我们更新了回放报告:

  • 参数视图:标记出交互参数的位置信息,合并截图信息,快速识别模型的规划结果

  • 样式调整:支持以深色模式展示报告,更美观

  • Token 消耗的展示:支持按模型汇总 Token 消耗量,分析不同场景的成本情况

🚀 MCP 架构重构

我们重新定义了 Midscene MCP 服务的定位。Midscene MCP 的职责是围绕着视觉驱动的 UI 操作展开,将 iOS / Android / Web 设备 Action Space 中的每个 Action 操作暴露为 MCP 工具,也就是提供各类“原子操作”。

通过这种形式,开发者可以更专注于构建自己的高阶 Agent,而无需关心底层 UI 操作的实现细节,并且时刻获得满意的成功率。

详情请阅读 MCP 文档

🚀 移动端能力增强

iOS 改进

  • 新增 WebDriverAgent 5.x-7.x 全版本兼容

  • 新增 WebDriver Clear API 支持,解决动态输入框问题

  • 提升设备兼容性

Android 改进

  • 新增截图轮询回退机制,提升远程设备稳定性

  • 新增屏幕方向自动适配(displayId 截图)

  • 新增 YAML 脚本 runAdbShell 支持

跨平台

  • 在 Agent 实例上暴露系统操作接口,包括 Home、Back、RecentApp 等

🚧 API 变更

方法重命名(向后兼容)

  • 改名 aiAction()aiAct()(旧方法保留,有弃用警告)

  • 改名 logScreenshot()recordToReport()(旧方法保留,有弃用警告)

环境变量重命名(向后兼容)

  • 改名 OPENAI_API_KEYMODEL_API_KEY(新变量优先,旧变量作为备选)

  • 改名 OPENAI_BASE_URLMODEL_BASE_URL(新变量优先,旧变量作为备选)

⬆️ 升级到最新版

升级项目中的依赖,例如:

npm install @midscene/web@latest --save-dev

npm install @midscene/android@latest --save-dev

npm install @midscene/ios@latest --save-dev

如果使用全局安装的命令行版本:

npm i -g @midscene/cli

链接

2025年OpenTiny年度人气贡献者评选正式开始

2025年12月25日 14:56

前言

携手共创,致敬不凡!

2025年,OpenTiny持续在前端开源领域扎根,每一位开发者都是推动项目共同前行的宝贵力量。从bug修复,到技术探讨;从参与开源活动,到输出技术文章;从使用项目,到参与共建,每一步跨越,都凝聚了开发者的智慧与汗水。 致敬所有在OpenTiny社区里默默付出、积极贡献、引领创新的杰出个人,我们正式启动“OpenTiny年度贡献者评选”活动!快为你喜爱的人气贡献者投票吧~

人气贡献者评选

名单公布:

新1.PNG

年度贡献者投票评选时间:

2025年12月25日-2025年12月31日

投票规则:

每人每天可回答3次,每次最多可投2票,最终投票结果选取前5名

投票入口:

wj.qq.com/s2/25333949…

去水印.png

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码:github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI~ 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

转型成为AI研发工程师之路

2025年12月25日 14:54

代码是现实世界中最清晰、最明确、最高质量的大模型训练数据,且代码编程天生具有较高的容忍度和巨大的商业价值。因此,各个大模型公司在这个领域的发力远远领先于其他文档撰写、医疗诊断、自动驾驶领域。

毫不夸张的说,代码编程是大模型目前唯一一个已经被人类广泛接受、且接近甚至局部超过人类平均水平的领域。程序员,尤其是“前端程序员”,成为了第一个被大模型冲击的岗位。这是一种不幸,更是一种幸运。

为什么第一个被冲击是一种“幸运”?

被技术浪潮拍在沙滩上,通常被视为一种职业危机。但是毛选中说了:“此处吃亏,彼处胜利,东方不亮西方亮”。如果换个角度来看待这次冲击,会发现其中隐藏着巨大的先发红利:

最早拿到AI时代的入场券

大模型懂代码,意味着程序员是目前世界上唯一能用“自然语言”和“编程语言”双语与 AI进行深度交流的人群。固然有Vibe Coding,但是如果不懂编程的原理,想实现一个真正有一点逻辑的应用的难度,不用我多说,试过的都知道。当其他行业还在摸索如何用AI画图、写文案时,程序员已经可以直接利用agent重构自己的应用。这种对工具的理解深度,就是转型的第一层护城河。

被迫的强制进化

AI在侵蚀工作岗位,程序员没有办法,只能接受并寻找出路。在一个AI并不完美而非全面碾压的时代先学会如何与它共生,积攒与其合作的经验;在后AI时代就相对别人获得经验优势。就好像第一个被迫成为汽车司机的马车夫,驾驶经验总是比新手高超一些。

更不用说前端工程师还有天然优势:用户Sense与串联能力。前端从来不仅仅是写界面的,前端是所有技术岗位中最有用户Sense、最习惯串联上下游、最擅长了解新事物的岗位。当工作中的代码实现逐渐被AI辅助甚至替代后,剩下的部分——即产品能力的定义、用户价值的实现、复杂系统的串联——变得前所未有的重要。而这,恰恰是前端工程师最擅长的领域。

尽管在并发处理、稳定性、甚至LLM的底层上前端会遇到挑战,但是前端遇到的挑战还少吗?而且任何一个人都没有必要自己扛下所有,软件工程本就是体系作战,总还有其他partner的。

AI应用研发工程师主要干什么?

很多人误以为AI应用研发就是“写Prompt”或者“编排workflow”,这是一个巨大的误区。真实的AI应用研发是一个复杂的系统工程,主要包含以下职责:

系统编排与分工设计

这是最核心的能力。面对一个应用或者功能点,需要设计一套机制,决定哪些部分分给AI(模糊处理、创意生成),哪些部分分给传统后端(精确计算、逻辑校验)。这不仅仅是调用接口,而是像设计电路一样,让确定性的代码逻辑与概率性的AI模型相互配合,发挥出1+1>2的效果。

RAG与上下文管理

通用模型不会知道公司内部的私有数据。需要构建高效的向量检索系统,清洗脏数据,设计切片策略,把最准确的信息“喂”给模型。这本质上是数据工程与搜索技术的结合。

大模型交互设计

新的生产力带来了新的交互方式。端侧不再仅是实现界面,而是设计“人与智能的对话”。流式输出带来的体验优化只是最基本的,用户意图的预判、当AI处理耗时较长时的中间态管理都是新的挑战。

评估与迭代

毫无疑问,必须建立一套评估体系。用数据回答:新版本的Prompt是不是真的比旧版本好?RAG检索的准确率是多少?没有量化,就没有工程。甚至,Prompt也不能是人力炼丹,得让提示词优化来接管。复杂的人工Prompt就是天然的屎山代码,不管是换个人还是换个应用的大模型都难以维护。

驯服非确定性

更不用说大模型还有非确定性。传统软件是确定的,但AI是概率性的、会幻觉的,甚至有时候是“抽风”的。如何在AI本身只有90%可靠性的情况下,通过重试机制、输出校验、兜底逻辑等工程手段,构建出一个在生产环境下99.99%健壮的系统,也是是AI工程师面临的挑战。

如何成为AI应用研发工程师

先动起来:Action > Planning

很多人的转型死在了“准备不足”的焦虑上。别总想着要先学完深度学习原理再动手。不上路都是问题,上了路再考虑怎么解的问题。接了一个3天后deadline的活,自然而然就有思路了;没有3天后deadline的活,就自己创造一个。

AI时代,你永远拥有“大模型”这个私人金牌导师。以前卡住要查半天文档,求爷爷告奶奶,现在AI大概率能给你解决。做一个简单的Demo,串联起Python、LangChain、LangSmith,这其中的试错成本极低。动起来,比想清楚更重要。

敏捷补齐:只学最新的

AI领域工具策略的变动日新月异,我3个月前写的Cursor使用指南现在看起来像个憨憨。在AI应用层,紧跟潮流更重要。永远只学最新的,永远接受最新的,永远只看官方文档。现在的AI知识半衰期很短,快速学习、快速遗忘、快速更新是新常态。

保持敬畏:从Demo到上线交付的鸿沟

我听了一句话觉得很有道理:不要向我阐述你的项目有多少用户价值,有多少个用户愿意掏30块/月来购买才是这个项目的用户价值。完成一个酷炫的Demo很容易,但完成一个真正让用户愿意掏钱的项目非常困难。

不要看不起写Prompt,写一个真的能在生产环境用,cover住用户99.9%意图的workflow,都能吐一口血。更不用说延迟问题、Token成本问题、并发问题、记忆问题、以及长尾Case的幻觉问题,才是真正的考验。

优秀AI应用研发工程师的壁垒在哪里?

当所有人都会写Prompt、都在Vibe Coding,AI应用研发工程师的价值沉淀在哪里?真正的壁垒在于构建一条能快速上线部署、精准符合业务、可评测、可迭代的AI链路。

精准的业务与场景匹配

AI是大脑,行业知识是肌肉。知道在哪里不该用AI以及知道在哪里注入AI能产生最大效能。懂业务领域的痛点,比单纯懂模型参数更稀缺。

可评测的数据闭环

能否建立一套自动化的Evals系统?当Prompt调整后,你能否在一分钟内知道它在1000个历史测试用例上的表现是变好了还是变差了?没有评测就没有迭代,没有迭代就没有护城河。

工程化的生产环境部署

与目前的工程化链路做结合:在复杂Case下控制Token成本,在高并发时保证响应速度,在模型抽风时有完美的降级方案。将“概率性模型”封装成“稳定性服务”的工程能力,也是核心壁垒。

总结

最后的最后,想确认前端死了没,只需要看招聘市场的热度就行。

1.png

当Gemini 3轻松的实现了我都写不出的UI的时候,前端这艘大船在我的眼里已经撞上了冰山,沉没只是时间问题公司和就业市场都不相信眼泪,只相信个体能提供的实际价值

我也不关心前端是否死了,我只关心我自己是否还有竞争力。走在别人的前面,走在就业市场的风口,就是个体在这个时代最大的护城河


一个功能强大的 React Native 拖拽排序组件库,支持单列和多列布局,提供流畅的拖拽体验和自动滚动功能

作者 一天前
2025年12月25日 14:47

1.gif

源码地址

# react-native-drag-sort
一个功能强大的 React Native 拖拽排序组件库,支持单列和多列布局,提供流畅的拖拽体验和自动滚动功能。

## 功能特性

- ✅ **长按拖拽**:长按 500ms 后开始拖拽,提供视觉反馈(缩放效果)
- ✅ **单列/多列布局**:支持自定义列数,灵活布局
- ✅ **自动滚动**:拖拽到边缘时自动触发外层 ScrollView 滚动
- ✅ **流畅动画**:使用 Animated API 实现平滑的位置切换动画
- ✅ **ScrollView 联动**:支持与外层 ScrollView 无缝集成
- ✅ **自定义间距**:支持设置行间距和列间距
- ✅ **性能优化**:使用 memo 和 lodash 进行性能优化

## 安装

### 前置依赖

本组件依赖 `react-native-gesture-handler`,请先安装:

```bash
npm install react-native-gesture-handler
# 或
yarn add react-native-gesture-handler
```

### iOS 安装

```bash
cd ios && pod install && cd ..
```

### 使用组件

```bash
# 将组件文件复制到你的项目中,或通过 npm link 等方式引入
```

## 使用方法

### 基础用法(单列)

```javascript
import React from 'react';
import { Dimensions, View, Text } from 'react-native';
import DragSortView from './lib/DragSortView';

const windowWidth = Dimensions.get('window').width;

const MyComponent = () => {
  const [data, setData] = React.useState(['1', '2', '3', '4', '5']);

  const renderItem = (item, index) => {
    return (
      <View style={{
        width: windowWidth,
        height: 50,
        backgroundColor: 'red',
        justifyContent: 'center',
        alignItems: 'center'
      }}>
        <Text>{item}</Text>
      </View>
    );
  };

  return (
    <DragSortView
      column={1}
      childrenWidth={windowWidth}
      childrenHeight={50}
      renderItem={renderItem}
      rowSpace={10}
      dataSource={data}
      onDragEnd={(from, to, newData) => {
        console.log('从位置', from, '移动到位置', to);
        setData(newData);
      }}
    />
  );
};
```

### 多列布局

```javascript
import React from 'react';
import { Dimensions, View, Text } from 'react-native';
import DragSortView from './lib/DragSortView';

const windowWidth = Dimensions.get('window').width;

const MyComponent = () => {
  const [data, setData] = React.useState(['1', '2', '3', '4', '5', '6']);

  const renderItem = (item, index) => {
    return (
      <View style={{
        width: (windowWidth - 10) / 2,
        height: 50,
        backgroundColor: 'blue',
        justifyContent: 'center',
        alignItems: 'center'
      }}>
        <Text>{item}</Text>
      </View>
    );
  };

  return (
    <DragSortView
      column={2}
      childrenWidth={(windowWidth - 10) / 2}
      childrenHeight={50}
      renderItem={renderItem}
      rowSpace={10}
      columnSpace={10}
      dataSource={data}
      onDragEnd={(from, to, newData) => {
        setData(newData);
      }}
    />
  );
};
```

### 与 ScrollView 集成

在 ScrollView 中使用时,需要提供三个 ref 来支持自动滚动功能。你可以在同一个 ScrollView 中放置多个 DragSortView:

```javascript
import React, { useRef } from 'react';
import { Dimensions, ScrollView, Text, View } from 'react-native';
import DragSortView from './lib/DragSortView';

const windowWidth = Dimensions.get('window').width;

const MyComponent = () => {
  const scrollViewRef = useRef(null); // 最外层 scrollView
  const scrollYRef = useRef(0); // 已经滚动的距离
  const scrollViewHeightRef = useRef(0); // 页面展示视图大小

  const renderOneItem = (item, index) => {
    return (
      <View style={{
        width: windowWidth,
        height: 50,
        backgroundColor: 'red',
        justifyContent: 'center',
        alignItems: 'center'
      }}>
        <Text>{item}</Text>
      </View>
    );
  };

  const renderTwoItem = (item, index) => {
    return (
      <View style={{
        width: (windowWidth - 10) / 2,
        height: 50,
        backgroundColor: 'blue',
        justifyContent: 'center',
        alignItems: 'center'
      }}>
        <Text>{item}</Text>
      </View>
    );
  };

  return (
    <View
      onLayout={(e) => {
        scrollViewHeightRef.current = e.nativeEvent.layout.height;
      }}
    >
      <ScrollView
        bounces={false}
        scrollEventThrottle={16}
        ref={scrollViewRef}
        onScroll={(e) => {
          scrollYRef.current = e.nativeEvent.contentOffset.y;
        }}
      >
        {/* 单列拖拽列表 */}
        <DragSortView
          scrollYRef={scrollYRef}
          scrollViewRef={scrollViewRef}
          scrollViewHeightRef={scrollViewHeightRef}
          column={1}
          childrenWidth={windowWidth}
          childrenHeight={50}
          renderItem={renderOneItem}
          rowSpace={10}
          dataSource={['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']}
        />
        
        {/* 多列拖拽列表 */}
        <DragSortView
          scrollYRef={scrollYRef}
          scrollViewRef={scrollViewRef}
          scrollViewHeightRef={scrollViewHeightRef}
          column={2}
          childrenWidth={(windowWidth - 10) / 2}
          childrenHeight={50}
          renderItem={renderTwoItem}
          rowSpace={10}
          columnSpace={10}
          dataSource={['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']}
        />
      </ScrollView>
    </View>
  );
};
```

## API 文档

### DragSortView Props

| 属性名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `dataSource` | `Array` | ✅ | - | 数据源数组 |
| `renderItem` | `Function(item, index)` | ✅ | - | 渲染每个列表项的函数 |
| `childrenWidth` | `Number` | ✅ | - | 子元素宽度 |
| `childrenHeight` | `Number` | ✅ | - | 子元素高度 |
| `column` | `Number` | ❌ | `1` | 列数 |
| `rowSpace` | `Number` | ❌ | `0` | 行间距 |
| `columnSpace` | `Number` | ❌ | `0` | 列间距 |
| `keyStr` | `String` | ❌ | - | 作为列表 key 的关键字(用于优化渲染) |
| `onDragStart` | `Function()` | ❌ | - | 拖拽开始回调 |
| `onDragEnd` | `Function(from, to, newData)` | ❌ | - | 拖拽结束回调,参数:原始索引、新索引、新数据数组 |
| `parentYRef` | `Ref<Number>` | ❌ | - | 如果当前拖拽视图在一个容器中,则需要这个容器在 scrollView 的 y 位置 |
| `scrollYRef` | `Ref<Number>` | ❌ | - | 外层 ScrollView 滚动距离的 ref |
| `scrollViewRef` | `Ref<ScrollView>` | ❌ | - | 外层 ScrollView 的 ref |
| `scrollViewHeightRef` | `Ref<Number>` | ❌ | - | 外层 ScrollView 视图高度的 ref |
| `triggerTop` | `Number` | ❌ | `200` | 距离页面顶部多少距离触发自动向上滚动 |
| `triggerBottom` | `Number` | ❌ | `屏幕高度 - 200` | 距离页面顶部多少距离触发自动向下滚动 |

### onDragEnd 回调参数

- `from` (Number): 拖拽元素的原始索引位置
- `to` (Number): 拖拽元素的新索引位置
- `newData` (Array): 重新排序后的新数据数组

## 注意事项

1. **手势库依赖**:确保已正确安装和配置 `react-native-gesture-handler`,并在应用入口处导入:
   ```javascript
   import 'react-native-gesture-handler';
   ```

2. **ScrollView 集成**:如果需要在 ScrollView 中使用,必须提供 `scrollYRef`、`scrollViewRef` 和 `scrollViewHeightRef` 三个 ref,否则自动滚动功能将无法正常工作。

3. **性能优化**:组件内部使用 `memo` 进行优化,但建议为 `dataSource` 中的每个对象提供唯一的 `keyStr` 属性以进一步提升性能。

4. **平台差异**:
   - iOS 和 Android 的滚动速度和距离有细微差异(iOS: 10ms/2px, Android: 20ms/5px)
   - 可通过修改 `DragSortView.js` 中的 `TIME` 和 `DISTANCE` 常量进行调整

5. **长按时间**:默认长按 500ms 后开始拖拽,可通过修改 `DragItemContainer.js` 中的 `minDuration` 和 `activateAfterLongPress` 进行调整。

## 示例项目

项目包含两个测试文件,展示了不同的使用场景:

- `TestDragSort.js`:基础单列拖拽示例
- `TestMoreDragSort.js`:ScrollView 集成和多列布局示例

一个基于 canvas 的 pdf 图片分页切割方法

2025年12月25日 14:44

主要应用场景是导出 pdf 的时候图片过长的情况下没法在一页内完整导出,此时需要对图片进行分页切割,这里实现的就是一个基于 canvas 的图片分页切割方法

代码实现

/**
 * 获取 base64 图片的尺寸。
 *
 * @param {string} base64 - 图片的Base64编码字符串。
 * @returns {Object} 包含图片宽度和高度的对象,格式为{width: number, height: number}。
 */
export function getPngDimensions(base64: string) {
    // 从base64字符串中提取一部分数据并解码
    const header = atob(base64.slice(22, 70)).slice(16, 24);
    // 将解码后的字符串转换为Uint8Array
    const uint8 = Uint8Array.from(header, c => c.charCodeAt(0));
    // 使用DataView来处理Uint8Array的数据
    const dataView = new DataView(uint8.buffer);

    return {
        // 从DataView中获取图片宽度
        width: dataView.getInt32(0),
        // 从DataView中获取图片高度
        height: dataView.getInt32(4),
    };
}


/**
 * 根据给定的页面尺寸和边距,对PDF中的图片进行分割。
 *
 * @param {Object} imageContent - 图片Base64编码字符串。
 * @param {Object} pageSize - 页面尺寸对象,格式为{width: number, height: number},单位是毫米。
 * @param {number[]} pageMargins - 页面边距数组,包含两个值,表示水平(左右)和垂直(上下)方向的间距,单位是毫米。
 * @returns {Promise<Object[]>} 返回分割后的图片对象,包含每一页分割后的图片大小
 */
export function splitPdfImage(
    imageContent: string, 
    pageSize: { width: number; height: number }, 
    pageMargins: number[]
   ) {
    // 获取图片的宽度和高度
    const { width, height } = getPngDimensions(imageContent);
    // 创建一个div元素,用于模拟页面
    const pageDom = document.createElement('div');
    // 设置div元素的宽度,考虑页面边距
    pageDom.style.width = pageSize.width - pageMargins[0] * 4 +'mm';
    // 设置div元素的高度,考虑页面边距
    pageDom.style.height = (pageSize.height - pageMargins[1] * 2.5) +'mm';
    // 设置div元素的定位为绝对定位
    pageDom.style.position = 'absolute';
    // 设置div元素的顶部位置为0
    pageDom.style.top = '0';
    // 将div元素添加到文档主体中
    document.body.append(pageDom);
    const scale = 2;
    const num = 38;
    // 计算页面高度,基于div元素的高度和一些缩放因子
    const PAGE_HEIGHT = Math.floor(pageDom.clientHeight / num) * num * scale;
    // 计算页面宽度,基于div元素的宽度和缩放因子
    const PAGE_WIDTH = pageDom.clientWidth * scale;
    // 计算图片在当前页面宽度下的打印高度
    const printHeight = (height * PAGE_WIDTH) / width;
    return new Promise(resolve => {
        const content: any[] = [];
        // 如果图片的打印高度大于页面高度,则需要分割图片
        if (printHeight > PAGE_HEIGHT) {
            const img = new Image();
            // 当图片加载完成时执行以下操作
            img.onload = () => {
                const canvas = document.createElement('canvas');
                const ctx = canvas.getContext('2d')!;
                const printHeight = (img.height * PAGE_WIDTH) / img.width;

                canvas.width = PAGE_WIDTH;

                // 循环分割图片
                for (let pages = 0; printHeight > pages * PAGE_HEIGHT; pages++) {
                    // 设置当前canvas的高度,避免最后一张图片超出实际高度
                    canvas.height = Math.min(PAGE_HEIGHT, printHeight - pages * PAGE_HEIGHT);
                    // 在canvas上绘制图片的相应部分
                    ctx.drawImage(img, 0, -pages * PAGE_HEIGHT, canvas.width, printHeight);
                    // 将分割后的图片内容添加到content数组中
                    content.push({
                      image: canvas.toDataURL('image/png'),
                      width: Math.min(width, Math.floor(PAGE_WIDTH / scale)),
                      height: Math.floor(canvas.height / scale),
                    });
                }
                // 清理canvas
                ctx!.clearRect(0, 0, canvas.width, canvas.height)
                canvas.width = 1
                canvas.height = 1
                canvas.remove()
                img.src = '';
                img.remove();
                resolve(content);
            };
            img.src = imageContent;
        } else {
            // 如果图片高度不超过页面高度,直接返回原图片内容
            resolve([{ image: imageContent, width, height }]);
        }
    }).then((res) => {
        // 移除之前创建的模拟页面的div元素
        pageDom.remove();
        return res;
    });
}

使用示例

splitPdfImage(imageContent, pdfPage.pageSize, pdfPage.pageMargins).then(res => {
    console.log('---split image---', res)
})

UniApp 请求封装实战:优雅实现 Token 无感刷新(附完整代码)

作者 MoMoDad
2025年12月25日 14:38

在 UniApp 跨端开发中,基于 Token 的接口鉴权是最常见的身份验证方式。但 Token 有过期时间,传统处理方式是遇到 401 状态码直接跳转登录页,用户体验极差 —— 比如用户正在填写表单,突然提示登录过期,之前的操作全部白费。

本文将分享一套生产级的 UniApp request 请求封装方案,核心实现Token 过期后的无感刷新:无需用户手动操作,自动刷新 Token 并重试所有过期请求,仅当刷新 Token 失败时才引导用户重新登录。

核心思路

无感刷新的关键是解决「多个请求同时触发 401」和「刷新 Token 期间的请求等待」问题,核心逻辑如下:

  1. 拦截接口返回的 401 状态码(Token 过期);
  2. isRefreshing标记位防止多个请求同时调用刷新 Token 接口;
  3. 将所有触发 401 的请求存入requests队列,等待 Token 刷新完成后统一重试;
  4. 调用刷新 Token 接口获取新 Token,更新本地存储;
  5. 用新 Token 重试队列中的所有请求,实现「无感」体验;
  6. 若刷新 Token 失败(如 RefreshToken 过期),则引导用户重新登录。

完整代码实现

以下是封装后的request.js完整代码,包含详细注释,可直接接入项目使用:

javascript

运行

import store from '@/store'
import config from '@/config'
// 封装Token的存储/获取/清除(建议单独抽离,方便替换存储方式)
import {
  getToken,
  setToken,
  clearToken,
  setRefreshToken,
  getRefreshToken,
  removeRefreshToken
} from '@/utils/auth'
import errorCode from '@/utils/errorCode' // 错误码映射表
import { toast, showConfirm, tansParams } from '@/utils/common' // 通用工具函数

// 请求超时时间
let timeout = 10000
// 接口基础地址(从配置文件读取)
const baseUrl = config.baseUrl

// 核心标记:是否正在刷新Token(避免重复刷新)
let isRefreshing = false
// 核心队列:存储401失败的请求,刷新Token后重试
let requests = []

/**
 * 封装后的请求核心函数
 * @param {Object} config - 请求配置(url/method/data/header等)
 * @returns {Promise}
 */
const request = config => {
  // 是否需要携带Token(部分接口无需鉴权,可通过header.isToken=false关闭)
  const isToken = (config.headers || {}).isToken === false
  config.header = config.header || {}
  
  // 携带Token(Bearer认证格式)
  if (getToken() && !isToken) {
    config.header['Authorization'] = 'Bearer ' + getToken()
  }

  // GET请求参数拼接(将params转为url参数)
  if (config.params) {
    let url = config.url + '?' + tansParams(config.params)
    url = url.slice(0, -1)
    config.url = url
  }

  return new Promise((resolve, reject) => {
    uni.request({
      method: config.method || 'GET',
      timeout: config.timeout || timeout,
      url: baseUrl + config.url.replace('/api', ''), // 适配后端接口路径(按需调整)
      data: config.data,
      header: config.header,
      dataType: 'json'
    }).then(response => {
      let [error, res] = response
      
      // 网络层错误(如超时、断网)
      if (error) {
        toast('系统异常请联系管理员')
        reject('系统异常请联系管理员')
        return
      }

      // 业务层状态码处理
      const code = res.data.code || 200
      const msg = errorCode[code] || res.data.msg || errorCode['default']

      // 核心:拦截401 Token过期
      if (code === 401) {
        handle401Error(config, resolve, reject)
      }
      // 500服务器错误
      else if (code === 500) {
        toast(msg)
        reject('500')
      }
      // 其他业务错误
      else if (code !== 200) {
        toast(msg)
        reject(code)
      }
      // 请求成功
      else {
        resolve(res.data)
      }
    }).catch(error => {
      // 网络异常兜底处理
      let { message } = error
      if (message === 'Network Error') {
        message = '后端接口连接异常'
      } else if (message.includes('timeout')) {
        message = '系统接口请求超时'
      } else if (message.includes('Request failed with status code')) {
        message = '系统接口' + message.substr(message.length - 3) + '异常'
      }
      toast(message)
      reject(error)
    })
  })
}

/**
 * 核心:401错误处理(无感刷新Token + 重试请求)
 * @param {Object} config - 原请求配置
 * @param {Function} resolve - 原请求的resolve
 * @param {Function} reject - 原请求的reject
 */
function handle401Error(config, resolve, reject) {
  // 封装重试逻辑:用新Token重新发起请求
  const retryRequest = () => {
    if (getToken()) {
      config.header['Authorization'] = 'Bearer ' + getToken()
    }
    request(config).then(res => resolve(res)).catch(err => reject(err))
  }

  // 若正在刷新Token,将当前请求加入队列等待
  if (isRefreshing) {
    requests.push(retryRequest)
    return
  }

  // 标记为「正在刷新Token」,防止并发请求重复刷新
  isRefreshing = true

  // 调用刷新Token接口
  refreshToken()
    .then((newToken) => {
      // 刷新成功:更新Token + 重试所有排队请求
      setToken(newToken) // 存储新Token
      requests.forEach(cb => cb()) // 批量重试队列中的请求
      requests = [] // 清空队列
      retryRequest() // 重试当前请求
    })
    .catch(() => {
      // 刷新失败(如RefreshToken过期):引导用户重新登录
      showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then(res => {
        if (res.confirm) {
          // 优化:记录当前页面路径,登录后可跳转回来
          const currentPages = getCurrentPages()
          const currentPage = currentPages[currentPages.length - 1]
          const redirectUrl = currentPage ? currentPage.route : 'pages/index/index'
          // 跳转登录页(替换为你的实际登录页路径)
          uni.reLaunch({
            url: `/pages/login/login?redirect=${redirectUrl}`
          })
        }
      })
      // 拒绝所有排队请求
      requests.forEach(cb => cb())
      requests = []
      reject('会话已过期,请重新登录')
    })
    .finally(() => {
      // 无论刷新成功/失败,解除「正在刷新」标记
      isRefreshing = false
    })
}

/**
 * 调用后端刷新Token接口
 * @returns {Promise<string>} 新的Access Token
 */
function refreshToken() {
  return new Promise((resolve, reject) => {
    uni.request({
      url: baseUrl + '/system/appUser/app/refreshToken', // 替换为你的刷新Token接口
      method: 'POST',
      header: {
        // 携带RefreshToken(后端需约定鉴权方式)
        'refreshToken': getRefreshToken()
      },
      timeout: timeout,
      success: (res) => {
        const { code, token } = res.data
        // 刷新成功(需后端返回新Token)
        if (code === 200 && token) {
          resolve(token)
        } else {
          reject(new Error('刷新Token失败'))
        }
      },
      fail: (err) => {
        toast('刷新登录状态失败')
        reject(err)
      }
    })
  })
}

export default request

关键模块解析

1. 辅助层准备(必看)

封装前需要先实现 3 个辅助文件,保证代码可运行:

  • auth.js:Token 的本地存储(基于uni.setStorageSync

    javascript

    运行

    // 存储Token
    export const setToken = (token) => uni.setStorageSync('token', token)
    // 获取Token
    export const getToken = () => uni.getStorageSync('token') || ''
    // 清除Token
    export const clearToken = () => uni.removeStorageSync('token')
    // RefreshToken同理(略)
    
  • errorCode.js:业务错误码映射(如 401=“登录过期”)

  • common.js:通用工具函数(toast 提示、confirm 确认框、参数拼接)

2. 核心状态控制

  • isRefreshing:布尔值,标记是否正在刷新 Token,防止多个 401 请求同时调用刷新接口,造成资源浪费;
  • requests:数组,存储所有因 401 失败的请求重试函数,刷新完成后批量执行。

3. 401 处理逻辑(核心)

handle401Error函数是整个无感刷新的核心:

  1. 重试封装retryRequest函数负责用新 Token 重新发起原请求;
  2. 队列等待:若正在刷新 Token,将重试函数加入队列;
  3. 刷新 Token:未刷新时标记状态,调用refreshToken接口;
  4. 刷新成功:更新 Token、重试队列所有请求、重试当前请求;
  5. 刷新失败:弹出确认框,引导用户登录(携带原页面路径,登录后可返回);
  6. 状态重置:无论成败,最终解除isRefreshing标记。

4. 刷新 Token 接口

refreshToken函数调用后端的刷新接口,注意:

  • 后端需提供专门的刷新 Token 接口(如/app/refreshToken);
  • 请求头需携带RefreshToken(有效期长于 Access Token);
  • 成功后返回新的 Access Token,失败则触发登录逻辑。

使用示例

封装完成后,在 API 层直接调用即可,无需关心 Token 刷新逻辑:

javascript

运行

// api/user.js
import request from '@/utils/request'

// 获取用户信息
export const getUserInfo = () => {
  return request({
    url: '/system/user/info',
    method: 'GET'
  })
}

// 提交表单
export const submitForm = (data) => {
  return request({
    url: '/system/form/submit',
    method: 'POST',
    data
  })
}

实战优化点

1. 登录跳转优化

代码中已实现「跳转登录页携带原页面路径」,登录成功后可解析redirect参数,跳转回用户之前操作的页面:

javascript

运行

// 登录页逻辑
onLoad(options) {
  this.redirectUrl = options.redirect || 'pages/index/index'
},
// 登录成功后
uni.reLaunch({
  url: `/${this.redirectUrl}`
})

2. 边界场景处理

  • 多端兼容uni.requestgetCurrentPages是 UniApp 跨端 API,H5 / 小程序 / APP 均兼容;

  • 网络异常:对超时、断网等情况做了兜底提示,提升用户感知;

  • 无 Token 请求:部分接口(如登录、注册)无需鉴权,可通过header.isToken=false关闭 Token 携带:

    javascript

    运行

    // 登录接口(无需Token)
    export const login = (data) => {
      return request({
        url: '/system/login',
        method: 'POST',
        data,
        header: { isToken: false }
      })
    }
    

3. 生产环境适配

  • 可将baseUrl按环境区分(开发 / 测试 / 生产);
  • 增加请求 / 响应拦截器(如添加日志、加密参数);
  • 结合 Vuex 管理用户状态,刷新 Token 后同步更新 store。

注意事项

  1. 后端约定:必须和后端约定好 Token 规则(过期状态码 401、刷新接口地址、RefreshToken 传递方式);
  2. RefreshToken 存储:建议RefreshToken单独存储,且有效期设置为 7 天 / 30 天(长于 Access Token 的 2 小时);
  3. 并发请求:该方案完美处理并发请求的 401 问题,比如页面初始化时多个接口同时返回 401,只会触发一次刷新;
  4. 登出逻辑:若项目有 Vuex 的登出 action,可替换代码中直接跳转登录的逻辑,清空用户信息。
flowchart TD
    Start[开始请求] --> CheckToken{是否需要Token?}
    
    CheckToken -->|是| AddToken[添加Authorization头]
    CheckToken -->|否| ProcessURL
    
    AddToken --> ProcessURL[处理URL参数]
    
    ProcessURL --> Request[发送uni.request请求]
    
    Request --> Response{请求结果}
    
    Response -->|网络异常| NetworkError[显示错误并拒绝]
    Response -->|成功返回| CheckCode{检查响应码}
    
    NetworkError --> End[结束]
    
    CheckCode -->|200| Success[返回数据并解析]
    CheckCode -->|500| ServerError[显示错误并拒绝]
    CheckCode -->|401| Handle401[&#34;<b>处理401错误<br>(核心逻辑)</b>&#34;]
    CheckCode -->|其他非200| OtherError[显示错误并拒绝]
    
    Success --> End
    ServerError --> End
    OtherError --> End
    
    Handle401 --> CheckRefreshing{&#34;<b>是否正在刷新Token?<br>(防重复机制)</b>&#34;}
    
    CheckRefreshing -->|是| AddToQueue[&#34;<b>加入请求队列等待</b>&#34;]
    CheckRefreshing -->|否| SetRefreshing[&#34;<b>设置刷新标记为true</b>&#34;]
    
    AddToQueue --> Wait[等待刷新完成]
    Wait --> End
    
    SetRefreshing --> CallRefresh[&#34;<b>调用refreshToken接口</b><br>/system/appUser/app/refreshToken&#34;]
    
    CallRefresh --> RefreshResult{&#34;<b>刷新结果</b><br>(成功/失败)&#34;}
    
    RefreshResult -->|成功| UpdateToken[&#34;<b>更新Token</b><br>清空RefreshToken&#34;]
    RefreshResult -->|失败| ShowConfirm[&#34;<b>显示登录过期确认框</b><br>(用户选择是否重新登录)&#34;]
    
    UpdateToken --> RetryQueue[&#34;<b>重试队列中所有请求</b><br>(requests.forEach)&#34;]
    RetryQueue --> RetryCurrent[&#34;<b>重试当前请求</b><br>(核心重试逻辑)&#34;]
    RetryCurrent --> ClearQueue[清空请求队列]
    ClearQueue --> ResetFlag[重置刷新标记为false]
    
    ResetFlag --> End
    
    ShowConfirm --> ConfirmResult{用户选择}
    
    ConfirmResult -->|确认| Logout[&#34;<b>跳转到登录页</b><br>(带redirect参数)&#34;]
    ConfirmResult -->|取消| Continue
    
    Logout --> RejectQueue[&#34;<b>拒绝队列中所有请求</b>&#34;]
    Continue --> RejectQueue
    
    RejectQueue --> RejectCurrent[&#34;<b>拒绝当前请求</b>&#34;]
    RejectCurrent --> ResetFlag2[重置刷新标记为false]
    
    ResetFlag2 --> End
    
    %% 样式定义
    classDef highlightNode fill:#fff2cc,stroke:#d6b656,stroke-width:3px,font-weight:bold
    classDef processNode fill:#d5e8d4,stroke:#82b366,stroke-width:2px
    classDef decisionNode fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    classDef errorNode fill:#f8cecc,stroke:#b85450,stroke-width:2px
    classDef endNode fill:#e1d5e7,stroke:#9673a6,stroke-width:2px
    
    %% 应用样式
    class Handle401,CallRefresh,UpdateToken,RetryQueue,RetryCurrent,Logout highlightNode
    class CheckRefreshing,RefreshResult,ConfirmResult decisionNode
    class NetworkError,ServerError,OtherError,RejectQueue,RejectCurrent errorNode
    class Start,End,Wait,ClearQueue,ResetFlag,ResetFlag2 endNode
    class AddToQueue,SetRefreshing,ShowConfirm processNode

总结

这套请求封装方案解决了 UniApp 中 Token 过期的核心痛点,实现了「无感刷新」的极致用户体验:

  • 无需用户手动操作,Token 过期自动刷新;
  • 并发请求不重复刷新,资源更高效;
  • 刷新失败后友好引导登录,兼顾体验与安全。

该方案已在多个生产级 UniApp 项目中验证,适配小程序、H5、APP 等多端,可直接复制使用,也可根据业务需求扩展(如添加请求防抖、缓存等)。

在Vue样式中使用JavaScript 变量(CSS 变量注入)

作者 Drift_Dream
2025年12月25日 14:36

基本介绍

在 Vue 3.2+ 版本中,我们可以直接在 <style>标签中使用响应式的 JavaScript 变量,这被称为 CSS 变量注入v-bind in CSS。这个功能让我们能够:

  1. 动态修改样式:在脚本中定义的响应式变量可以直接在样式中引用

  2. 减少样式类切换:不再需要为不同的状态定义多个 CSS 类

  3. 实现主题切换:轻松实现动态主题色切换功能

  4. 响应式设计:让 CSS 能够响应组件状态的变化

简单说,就是让你在 CSS 中直接使用 Vue 的响应式数据,当数据变化时,样式会自动更新!

简单示例-黑色-亮色主题切换

<template>
  <div class="base-case-container">
    <p>动态主题切换示例</p>
    <button @click="toggle()">切换主题</button>
  </div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useToggle } from "@vueuse/core";

const [isDarkMode, toggle] = useToggle();

const fontSize = ref<string>("16px");
const textColor = computed(() => (isDarkMode.value ? "#ffffff" : "#333333"));
const backgroundColor = computed(() =>
  isDarkMode.value ? "#1a1a1a" : "#f5f5f5"
);
</script>
<style lang="scss">
.base-case-container {
  width: 100%;
  height: 100%;
  font-size: v-bind(fontSize);
  color: v-bind(textColor);
  background-color: v-bind(backgroundColor);
  padding: 20px;
  transition: all 0.3s ease;
}
</style

项目实战-动态主题切换系统

<template>
  <div class="theme-container" :style="customStyles">
    <header class="header">
      <h1>动态主题系统</h1>
      <div class="theme-controls">
        <div class="color-picker">
          <label>主色:</label>
          <input type="color" v-model="theme.primary" />
        </div>
        <div class="slider-control">
          <label>字体大小:{{ theme.fontSize }}px</label>
          <input type="range" v-model="theme.fontSize" min="12" max="24" />
        </div>
        <div class="slider-control">
          <label>圆角:{{ theme.borderRadius }}px</label>
          <input type="range" v-model="theme.borderRadius" min="0" max="20" />
        </div>
      </div>
    </header>

    <main class="content">
      <button class="btn btn-primary">主要按钮</button>
      <button class="btn btn-secondary">次要按钮</button>

      <div class="card">
        <h3>卡片标题</h3>
        <p>这是一个使用动态主题的卡片组件</p>
      </div>

      <div class="alert">这是一个提示信息</div>
    </main>
  </div>
</template>

<script setup>
import { reactive, computed } from "vue";

// 主题配置
const theme = reactive({
  primary: "#4a90e2",
  secondary: "#7b61ff",
  fontSize: 16,
  borderRadius: 8,
  spacing: 20,
});

// 计算其他衍生颜色
const themeColors = computed(() => {
  return {
    primaryLight: lightenColor(theme.primary, 30),
    primaryDark: darkenColor(theme.primary, 20),
    secondaryLight: lightenColor(theme.secondary, 30),
  };
});

// 工具函数:颜色变亮
function lightenColor(color, percent) {
  // 1. 移除#号,将十六进制转为十进制整数
  const num = parseInt(color.replace('#', ''), 16)  // "#4a90e2" → 0x4a90e2 = 4890850
  
  // 2. 计算调整量
  // 255的百分比 → 每个颜色分量最大可增加的值
  const amt = Math.round(2.55 * percent)  // 例如percent=30 → 2.55 * 30=76.5 → 77
  
  // 3. 分解RGB分量
  const R = (num >> 16) + amt         // 红色分量:右移16位
  const G = (num >> 8 & 0x00FF) + amt  // 绿色分量:右移8位后与0x00FF进行与运算
  const B = (num & 0x0000FF) + amt     // 蓝色分量:与0x0000FF进行与运算
  
  // 4. 确保分量在0-255范围内
  return '#' + (
    0x1000000 +  // 保证结果始终是7位十六进制数
    
    // 三元运算符确保值在有效范围内
    (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 +  // 红色分量
    (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 +     // 绿色分量
    (B < 255 ? B < 1 ? 0 : B : 255)               // 蓝色分量
    
  ).toString(16).slice(1)  // 转换为十六进制并去掉开头的1
}

// 工具函数:颜色变暗
function darkenColor(color, percent) {
  // 1. 十六进制转十进制
  const num = parseInt(color.replace('#', ''), 16)
  
  // 2. 计算减少量
  const amt = Math.round(2.55 * percent)  // 百分比转换为实际减少值
  
  // 3. 减少每个分量
  const R = (num >> 16) - amt
  const G = (num >> 8 & 0x00FF) - amt
  const B = (num & 0x0000FF) - amt
  
  // 4. 确保值不小于0
  return '#' + (
    0x1000000 +
    (R > 0 ? R : 0) * 0x10000 +  // 如果R>0,使用R,否则为0
    (G > 0 ? G : 0) * 0x100 +
    (B > 0 ? B : 0)
  ).toString(16).slice(1)
}
</script>

<style scoped>
.theme-container {
  /* 直接使用响应式变量 */
  --primary-color: v-bind("theme.primary");
  --secondary-color: v-bind("theme.secondary");
  --font-size: v-bind('theme.fontSize + "px"');
  --border-radius: v-bind('theme.borderRadius + "px"');
  --spacing: v-bind('theme.spacing + "px"');

  /* 使用计算属性 */
  --primary-light: v-bind("themeColors.primaryLight");
  --primary-dark: v-bind("themeColors.primaryDark");
  --secondary-light: v-bind("themeColors.secondaryLight");

  padding: var(--spacing);
  min-height: 100vh;
  font-size: var(--font-size);
  transition: all 0.3s ease;
  .header {
    margin-bottom: calc(var(--spacing) * 2);
    padding-bottom: var(--spacing);
    border-bottom: 2px solid var(--primary-light);
    .theme-controls {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
      gap: var(--spacing);
      margin-top: var(--spacing);
    }
  }
}
.content {
  .btn {
    padding: 12px 24px;
    border: none;
    border-radius: var(--border-radius);
    font-size: calc(var(--font-size) * 0.875);
    cursor: pointer;
    transition: all 0.2s ease;
    margin: 8px;
    &.btn-primary {
      background-color: var(--primary-color);
      color: white;
    }

    &.btn-primary:hover {
      background-color: var(--primary-dark);
      transform: translateY(-2px);
    }

    &.btn-secondary {
      background-color: var(--secondary-color);
      color: white;
    }

    &.btn-secondary:hover {
      background-color: var(--secondary-light);
    }
  }

  .card {
    background: white;
    border-radius: var(--border-radius);
    padding: var(--spacing);
    margin: var(--spacing) 0;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    border-left: 4px solid var(--primary-color);
  }

  .alert {
    background-color: var(--primary-light);
    border: 1px solid var(--primary-color);
    border-radius: var(--border-radius);
    padding: calc(var(--spacing) * 0.75);
    margin: var(--spacing) 0;
    color: var(--primary-dark);
  }
}
</style>

注意事项和最佳实践

性能考虑

<script setup>
// 避免频繁更新的变量用于样式绑定
// 不推荐:频繁变化的值
const scrollPosition = ref(0)  // 滚动时频繁变化

// 推荐:变化不频繁的值
const themeColor = ref('#3498db')
const fontSize = ref(16)
</script>

兼容性处理

<style scoped>
/* 提供回退方案 */
.element {
  color: #3498db; /* 回退值 */
  color: v-bind(themeColor);
  
  /* 对于不支持 CSS 变量的浏览器 */
  @supports not (color: v-bind(themeColor)) {
    /* 备用样式 */
  }
}
</style>

结合 CSS 自定义属性

<style scoped>
/* 在 :root 或组件中定义 CSS 变量 */
.component {
  --primary-color: v-bind(primaryColor);
  --secondary-color: v-bind(secondaryColor);
  
  /* 在组件内重复使用 */
  .child {
    color: var(--primary-color);
  }
  
  .another-child {
    background-color: var(--secondary-color);
  }
}
</style>

总结

Vue 样式中的 JavaScript 变量功能为前端开发带来了革命性的变化:

  • 更灵活的样式控制:样式可以响应数据变化

  • 代码更简洁:减少样式类切换的逻辑

  • 主题系统更强大:轻松实现动态主题

  • 更好的开发体验:在 Vue 单文件组件中实现样式逻辑一体化

MCP入门梳理

作者 LoveDreaMing
2025年12月25日 14:30

前置知识-stdio(传输层)

什么是stdio

stdio 是 "标准输入输出"(Standard Input/Output)的缩写,是计算机程序中用于进程间通信的基本机制。它通过三个主要的流(standard streams)来处理数据:

  • stdin:标准输入,用于接收外部数据输入。
  • stdout:标准输出,用于输出数据给外部。
  • stderr:标准错误输出,用于输出错误信息。

可以把 stdio 想象成一个管道,程序的一部分通过管道将数据发送出去,另一部分通过管道接收数据。这种设计使得 stdio 成为最基本的通信形式,尤其在 命令行工具脚本程序 中得到了广泛应用。 image.png

为什么 MCP 会选 stdio 作为核心通信方式之一

  • 本地工具场景非常匹配:MCP 通常用于 工具链与模型的协作,这种场景下,stdio 提供了一种最原始、最直接的通信方式。MCP 主要通过本地工具或进程来执行任务,不需要外部的网络支持或服务暴露。通过 stdio,MCP 可以在不同的进程之间传递数据,保证了 本地化、低延迟 的需求。
  • 无需网络,启动成本极低:与传统的网络通信协议相比,stdio 的一个巨大优势在于,它不需要依赖网络连接。无论是 端口配置 还是 网络访问权限,都不再是使用 stdio 的障碍。在 MCP 中,任何一个本地进程都可以通过标准输入输出直接与另一个进程进行通信。
  • 天然的安全边界:在 MCP 的架构中,stdio 的通信只发生在本地进程间,不涉及网络层面的暴露。这意味着它天然具备了较高的安全性。每个进程的标准输入输出只能在该进程的上下文中访问,不会无意中暴露给外部网络或其他程序。
  • 非常容易跨语言:不同于某些专用协议,stdio 是操作系统层级的标准,它被绝大多数编程语言所支持。无论是用 Node.js、Python,还是 Go,所有这些语言都可以通过统一的方式进行输入输出。这为 MCP 提供了极大的灵活性,使得它可以轻松与多种工具和模型集成,跨语言的操作非常简单。

stdio使用演示

client.js:通过 stdio 向子进程发送消息并接收响应

const { spawn } = require('child_process');
const serverProcess = spawn('node', ['server.js']); // 启动 server.js 子进程
const messages = ['你好吗?', '你吃饭了吗?', '天气真好!'];
messages.forEach((message, index) => {
  setTimeout(() => {
    const msg = message + '\n'; // 加上换行符可以让服务端更容易区分每一条输入
    console.log('我:' + msg);
    serverProcess.stdin.write(msg);
  }, index * 1000); // 每秒发送一条消息
});
let count = 0;
serverProcess.stdout.on('data', (data) => {
  console.log('AI:' + data.toString());
  count++;
  if (count === messages.length) {
    serverProcess.stdin.end();
  }
});

server.js:从 stdin 读取输入并通过 stdout 返回结果

process.stdin.setEncoding('utf-8'); // 设置utf-8 编码,输入的数据将被转换为字符串
process.stdin.on('data', (data) => {
  data = data
    .replace(/[??]/g, '')
    .replace(/我/g, '你')
    .replace(/你/g, '我')
    .replace(/吗/g, '');
  process.stdout.write(`${data}\n`);
});
process.stdin.on('end', () => {
  process.exit(0); // 正常退出进程
});

前置知识-JSON-RPC(协议层)

什么是 JSON-RPC

JSON-RPC 是一种轻量级的远程过程调用(RPC)协议,基于 JSON 格式实现跨进程 / 跨网络的通信,核心目标是让不同系统能够通过简单的文本格式调用彼此的方法,无需依赖复杂的传输层协议。通俗的讲,JSON-RPC 做的事情只有一件:把一次“函数调用”,用一段 JSON 表达出来

  • 它的核心特点:

    • 无状态:每次请求独立,服务器不保存客户端上下文;
    • 语言无关:JSON 是通用格式,几乎所有编程语言都支持解析;
    • 简洁:协议规则极少,请求 / 响应格式固定且易理解。
  • 核心结构由「请求对象」和「响应对象」组成:

    • 请求:包含要调用的方法名、参数、唯一标识(ID);
    • 响应:包含调用结果(成功 / 失败)、对应请求的 ID(用于关联)。

MCP 为什么选择 JSON-RPC 2.0

JSON-RPC 2.0 是 1.0 的官方标准化升级(1.0 无正式规范,仅为社区约定),主要解决了 1.0 的模糊性、扩展性问题。MCP 并没有重新设计一套通信协议,而是选择了 JSON-RPC 2.0 作为协议层,主要因为:

首先,JSON-RPC 2.0 是一个成熟且稳定的规范。它的结构固定、语义清晰,不需要在“如何定义消息格式”这件事上再花额外成本。

其次,JSON-RPC 的核心抽象与 MCP 的设计高度契合:

  • method 对应工具或能力的调用
  • params 对应输入参数
  • resulterror 对应执行结果

这种一一对应关系,使得 MCP 在表达工具调用时几乎不需要做额外包装。

最后,JSON-RPC 与传输层完全解耦。 在 MCP 中,JSON-RPC 消息既可以通过 stdio 传输,也可以通过 HTTP 传输,而协议本身不需要做任何修改。可以简单理解为:

stdio 和 http 负责“怎么传”,
JSON-RPC 负责“传什么”。

JSON-RPC 2.0的基本结构

介绍几种常用的rpc调用,字段介绍及其他用法可参照英文文档,也可以参照中文文档

  • 参数是数组的rpc调用
--> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
<-- {"jsonrpc": "2.0", "result": 19, "id": 1}
  • 参数是键值对的rpc调用
--> {"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}
<-- {"jsonrpc": "2.0", "result": 19, "id": 3}
  • 通知(没有 id 字段)
--> {"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}

--> {"jsonrpc": "2.0", "method": "foobar"}
  • 失败响应的rpc调用
--> {"jsonrpc": "2.0", "method": "foobar", "id": "1"}
<-- {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}

JSON-RPC 2.0的使用演示

server.js:是一个基于 JSON-RPC 2.0 的本地服务端进程,核心职责是:读取 stdin 结构化输入 → 根据 method 执行能力 → 返回 stdout 结构化结果

const fs = require('fs');

const utils = {
  sum({ a, b }) {
    return a + b;
  },
  createFile({ filename, content }) {
    try {
      fs.writeFileSync(filename, content);
      return true;
    } catch {
      return false;
    }
  }
};

process.stdin.on('data', (data) => {
  const req = JSON.parse(data);
  const funcName = req.method;
  const params = req.params;
  const result = utils[funcName](params);
  const res = {
    jsonrpc: '2.0',
    id: req.id,
    result
  };
  process.stdout.write(JSON.stringify(res) + '\n');
});

jsonrpc.txt:提供两个模拟客户端发送的 JSON-RPC 请求消息,第一个是无副作用的,单纯的函数调用,第二个是有副作用的,触发系统能力的调用。

{"jsonrpc":"2.0","id":1,"method":"sum","params":{"a":1,"b":2}}

{"jsonrpc":"2.0","id":2,"method":"createFile","params":{"filename":"./test.txt","content":"Hello, World!"}}

基于 JSON-RPC 的 MCP 实现

server.js:是一个基于 JSON-RPC 2.0 实现的 MCP 服务端示例,通过 initializetools/listtools/call 等方法对外声明能力并提供工具调用能力。utils里的MCP字段配置可以参考MCP官方的Schema Reference

const fs = require('fs');

const tools = {
  sum({ a, b }) {
    return {
      content: [
        {
          type: 'text',
          text: `两数求和结果是:${a + b}`
        }
      ]
    };
  },
  createFile({ filename, content }) {
    try {
      fs.writeFileSync(filename, content);
      return {
        content: [
          {
            type: 'text',
            text: `文件创建成功!`
          }
        ]
      };
    } catch (err) {
      return {
        content: [
          {
            type: 'text',
            text: err.message || '文件创建失败!'
          }
        ]
      };
    }
  }
};

const utils = {
  initialize() {
    return {
      // 服务器功能
      capabilities: {
        // 支持工具的服务器必须声明工具能力
        tools: {
          listChanged: true // 该服务器是否支持工具列表变更的通知
        }
      },
      protocolVersion: '2025-11-25', // 协议版本
      // 服务器信息
      serverInfo: {
        name: 'Demo Server', // 名称
        version: '1.0.0' // 版本
      }
    };
  },
  'tools/list'() {
    return {
      tools: [
        {
          name: 'sum',
          title: '两数求和',
          description: '得到两个数的和',
          inputSchema: {
            type: 'object',
            properties: {
              a: {
                type: 'number',
                description: '第一个数'
              },
              b: {
                type: 'number',
                description: '第二个数'
              }
            },
            required: ['a', 'b']
          }
        },
        {
          name: 'createFile',
          title: '创建文件',
          description: '在指定目录下创建一个文件',
          inputSchema: {
            type: 'object',
            properties: {
              filename: {
                type: 'string',
                description: '文件名'
              },
              content: {
                type: 'string',
                description: '文件内容'
              }
            },
            required: ['filename', 'content']
          }
        }
      ]
    };
  }
};

process.stdin.on('data', (data) => {
  const req = JSON.parse(data);
  let result;
  if (req.method === 'tools/call') {
    result = tools[req.params.name](req.params.arguments);
  } else if (req.method in utils) {
    result = utils[req.method](req.params);
  } else {
    return;
  }

  const res = {
    jsonrpc: '2.0',
    result,
    id: req.id
  };
  process.stdout.write(JSON.stringify(res) + '\n');
});

jsonrpc.txt:展示了客户端通过 JSON-RPC 2.0 协议与 MCP 服务端交互的完整流程,包括初始化、查询工具列表以及调用具体工具的示例请求。

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"Demo Client","version":"1.0.0"},"protocolVersion":"2025-11-25"}}

{"jsonrpc":"2.0","id":2,"method":"tools/list"}

{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"sum","arguments":{"a":1,"b":2}}}

{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"createFile","arguments":{"filename":"./test.txt","content":"Hello World!"}}}

使用 TypeScript SDK 构建 MCP 应用

StdioServerTransport

stdio.js:实现了一个基于 StdioServerTransport 的 MCP Server,通过标准输入输出与客户端通信,并对外注册了 sumcreateFile 两个工具,分别用于数值计算和文件写入。

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs';

async function main() {
  const server = new McpServer({
    name: 'demo-server',
    version: '1.0.0'
  });

  server.registerTool(
    'sum',
    {
      title: '两数求和',
      description: '将两个数字相加',
      inputSchema: { a: z.number(), b: z.number() },
      outputSchema: { result: z.number() }
    },
    async ({ a, b }) => {
      const output = { result: a + b };
      return {
        content: [{ type: 'text', text: JSON.stringify(output) }],
        structuredContent: output
      };
    }
  );

  server.registerTool(
    'createFile',
    {
      title: '创建文件',
      description: '将内容添加到文件中',
      inputSchema: { filename: z.string(), content: z.string() }
    },
    async ({ filename, content }) => {
      try {
        fs.writeFileSync(filename, content);
        return {
          content: [
            {
              type: 'text',
              text: '文件创建成功!'
            }
          ]
        };
      } catch (err) {
        return {
          content: [
            {
              type: 'text',
              text: err.message || '文件创建失败!'
            }
          ]
        };
      }
    }
  );

  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch(console.error);
使用rpc调用调试
  • 在命令行窗口运行node ./src/stdio.js启动服务
  • 如下jsonrpc.txt的内容,整行复制jsonrpc协议请求到命令行窗口,调试MCP工具函数
{"jsonrpc":"2.0","id":2,"method":"tools/list"}

{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"sum","arguments":{"a":1,"b":2}}}

{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"createFile","arguments":{"filename":"./test.txt","content":"Hello World!"}}}
使用@modelcontextprotocol/inspector调试
  • 在命令行窗口运行npx @modelcontextprotocol/inspector,浏览器会自动弹出调试窗口
  • 在浏览器的调试窗口左侧填写对应的字段,Arguments里填写对应服务的文件相对地址或者绝对地址,然后点击Connect建立连接,会触发initialize image.png
  • 最后就可以在右侧的如下图两个红框内输入,调试MCP工具函数 image.png
使用LLM客户端调试(以Cursor举例,其他的配置差不多)
  • 点击对话窗口的右上角的三个点,选择Agent Settings image.png
  • 在Tools & MCP一栏,点击Add Custom MCP,这时会打开mcp.json文件 image.png
  • mcp.json文件内容如下,添加自己的mcp服务my-mcp-server,注意args里的服务文件地址需要填写绝对路径地址
{
  "mcpServers": {
    "my-mcp-server": {
      "transport": "stdio",
      "command": "node",
      "args": ["xxx/xxx/src/stdio.js"]
    }
  }
}
  • 返回对话窗口,输入@MCP xxx就能在对话框直接调用mcp工具函数,如果不@MCP有时也会触发mcp工具函数,只不过是大模型自己判断调用的 image.png

StreamableHTTPServerTransport

streamableHttp.js:实现了一个基于 StreamableHTTPServerTransport 的 MCP Server,通过 Express 提供 /mcp HTTP 接口,使 MCP 客户端可以以流式 HTTP 的方式调用 sumcreateFile 两个工具。

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { z } from 'zod';
import fs from 'fs';

const server = new McpServer({
  name: 'demo-server',
  version: '1.0.0'
});

server.registerTool(
  'sum',
  {
    title: '两数求和',
    description: '将两个数字相加',
    inputSchema: { a: z.number(), b: z.number() },
    outputSchema: { result: z.number() }
  },
  async ({ a, b }) => {
    const output = { result: a + b };
    return {
      content: [{ type: 'text', text: JSON.stringify(output) }],
      structuredContent: output
    };
  }
);

server.registerTool(
  'createFile',
  {
    title: '创建文件',
    description: '将内容添加到文件中',
    inputSchema: { filename: z.string(), content: z.string() }
  },
  async ({ filename, content }) => {
    try {
      fs.writeFileSync(filename, content);
      return {
        content: [
          {
            type: 'text',
            text: '文件创建成功!'
          }
        ]
      };
    } catch (err) {
      return {
        content: [
          {
            type: 'text',
            text: err.message || '文件创建失败!'
          }
        ]
      };
    }
  }
);

const app = express();
app.use(express.json());

app.post('/mcp', async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true
  });

  res.on('close', () => {
    transport.close();
  });

  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

const port = parseInt(process.env.PORT || '3000');
app
  .listen(port, () => {
    console.log(`Demo MCP Server running on http://localhost:${port}/mcp`);
  })
  .on('error', (error) => {
    console.error('Server error:', error);
    process.exit(1);
  });
使用@modelcontextprotocol/inspector调试
  • 需要先使用node ./src/streamableHttp.js命令启动服务
  • 再开一个命令行窗口运行npx @modelcontextprotocol/inspector命令启动MCP检查器
  • 打开的浏览器窗口里参数如下,点击Connect就可以在右侧调试工具函数了 image.png
使用LLM客户端调试(以Cursor举例,其他的配置差不多)
  • 需要先使用node ./src/streamableHttp.js命令启动服务
  • 修改mcp.json文件里的配置如下,然后就可以在对话窗口调试了
{
  "mcpServers": {
    "my-mcp-server": {
      "transport": "http",
      "url": "http://localhost:3000/mcp"
    }
  }
}

面试和算法:常见面试题实现与深度解析

作者 Fronty
2025年12月25日 14:24

本文将深入探讨前端面试中常见的算法和编程题,提供多种实现方案和性能优化策略,帮助大家全面掌握核心面试技能。

1. 函数柯里化(Currying)

1.1 基础柯里化实现
/**
 * 基础柯里化函数
 * 将多参数函数转换为一系列单参数函数
 */
function curry(fn) {
  return function curried(...args) {
    // 如果参数数量足够, 直接执行原函数
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    // 否则返回一个新函数, 继续收集参数
    return function (...nextArgs) {
      return curried.apply(this, args.concat(nextArgs));
    };
  };
}
// 示例:加法函数柯里化
function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);

// 测试
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
1.2 高级柯里化实现(支持占位符)
/**
 * 高级柯里化函数, 支持占位符
 */
function advancedCurry(fn) {
  return function curried(...args) {
    // 检查参数是否足够且没有占位符
    const complete =
      args.length >= fn.length &&
      !args.slice(0, fn.length).includes(advancedCurry.placeholder);
    if (complete) {
      return fn.apply(this, args);
    }

    return function (...nextArgs) {
      // 替换占位符
      const combinedArgs = args
        .map((arg) =>
          arg === advancedCurry.placeholder && nextArgs.length
            ? nextArgs.shift()
            : arg
        )
        .concat(nextArgs);

      return curried.apply(this, combinedArgs);
    };
  };
}

// 定义占位符
advancedCurry.placeholder = Symbol("_");
// 示例使用
const curriedMultiply = advancedCurry((a, b, c) => a * b * c);

const _ = advancedCurry.placeholder;

// 测试
console.log(curriedMultiply(2)(3)(4)); // 24
console.log(curriedMultiply(_, 3)(2)(4)); // 24
console.log(curriedMultiply(2, _, 4)(3)); // 24

2. 函数组合(Compose)

2.1 基础函数组合
function compose(...fns) {
  return function(x) {
    return fns.reduceRight((acc, fn) => fn(acc), x);
  };
}

function pipe(...fns) {
  return function(x) {
    return fns.reduce((acc, fn) => fn(acc), x);
  };
}

// 测试
const add1 = x => x + 1;
const multiply2 = x => x * 2;
const square = x => x * x;

const composed = compose(square, multiply2, add1);
console.log(composed(2)); // 36

3. 斐波那契数列优化

3.1 多种实现对比
// 1. 递归(性能差)
function fibonacciRecursive(n) {
  if (n <= 1) return n;
  return fibonacciRecursive(n - 1) + fibonacciRecursive(n - 2);
}

// 2. 记忆化递归
function fibonacciMemo(n, memo = {}) {
  if (n <= 1) return n;
  if (memo[n]) return memo[n];
  memo[n] = fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo);
  return memo[n];
}

// 3. 动态规划
function fibonacciDP(n) {
  if (n <= 1) return n;
  const dp = [0, 1];
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
}

// 4. 空间优化
function fibonacciOptimized(n) {
  if (n <= 1) return n;
  let prev = 0, curr = 1;
  for (let i = 2; i <= n; i++) {
    const next = prev + curr;
    prev = curr;
    curr = next;
  }
  return curr;
}

4. 数组去重多种方法

4.1 基础方法
// 1. Set
function uniqueSet(arr) {
  return [...new Set(arr)];
}

// 2. filter + indexOf
function uniqueFilter(arr) {
  return arr.filter((item, index) => arr.indexOf(item) === index);
}

// 3. reduce
function uniqueReduce(arr) {
  return arr.reduce((acc, curr) => {
    if (!acc.includes(curr)) acc.push(curr);
    return acc;
  }, []);
}
4.2 复杂对象去重
function uniqueComplex(arr, keyFn) {
  const seen = new Map();
  const result = [];
  
  for (let item of arr) {
    const key = keyFn ? keyFn(item) : JSON.stringify(item);
    if (!seen.has(key)) {
      seen.set(key, true);
      result.push(item);
    }
  }
  
  return result;
}

// 测试
const users = [
  { id: 1, name: 'Alice' },
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
];
console.log(uniqueComplex(users, user => user.id));

5. 深比较(DeepEqual)

function deepEqual(a, b) {
  if (a === b) return true;
  
  if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') {
    return false;
  }
  
  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) {
      if (!deepEqual(a[i], b[i])) return false;
    }
    return true;
  }
  
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  
  if (keysA.length !== keysB.length) return false;
  
  for (let key of keysA) {
    if (!keysB.includes(key) || !deepEqual(a[key], b[key])) {
      return false;
    }
  }
  
  return true;
}

6. 防抖与节流

防抖(Debounce) 节流(Throttle)

function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

function throttle(fn, interval) {
  let lastTime = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      fn.apply(this, args);
      lastTime = now;
    }
  };
}

7. Promise实现

手写 Promise:深入理解 JavaScript 异步编程的核心

class MyPromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];
    
    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(cb => cb());
      }
    };
    
    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(cb => cb());
      }
    };
    
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }
  
  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : e => { throw e };
    
    const promise2 = new MyPromise((resolve, reject) => {
      const handleFulfilled = () => {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            this.resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      };
      
      const handleRejected = () => {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            this.resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      };
      
      if (this.state === 'fulfilled') {
        handleFulfilled();
      } else if (this.state === 'rejected') {
        handleRejected();
      } else {
        this.onFulfilledCallbacks.push(handleFulfilled);
        this.onRejectedCallbacks.push(handleRejected);
      }
    });
    
    return promise2;
  }
  
  resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
      return reject(new TypeError('循环引用'));
    }
    
    let called = false;
    
    if (x && (typeof x === 'object' || typeof x === 'function')) {
      try {
        const then = x.then;
        if (typeof then === 'function') {
          then.call(
            x,
            y => {
              if (called) return;
              called = true;
              this.resolvePromise(promise2, y, resolve, reject);
            },
            r => {
              if (called) return;
              called = true;
              reject(r);
            }
          );
        } else {
          resolve(x);
        }
      } catch (error) {
        if (called) return;
        called = true;
        reject(error);
      }
    } else {
      resolve(x);
    }
  }
  
  catch(onRejected) {
    return this.then(null, onRejected);
  }
}

8. call、apply、bind实现

JavaScript 核心方法深度解析:手写 call、apply、bind 和 Object.create

Function.prototype.myCall = function(context = window, ...args) {
  const fnKey = Symbol('fn');
  context[fnKey] = this;
  const result = context[fnKey](...args);
  delete context[fnKey];
  return result;
};

Function.prototype.myApply = function(context = window, args = []) {
  const fnKey = Symbol('fn');
  context[fnKey] = this;
  const result = context[fnKey](...args);
  delete context[fnKey];
  return result;
};

Function.prototype.myBind = function(context = window, ...bindArgs) {
  const self = this;
  return function(...callArgs) {
    return self.apply(context, [...bindArgs, ...callArgs]);
  };
};

9. 事件总线(EventEmitter)

手写 EventEmitter:深入理解发布订阅模式

class EventEmitter {
  constructor() {
    this.events = new Map();
  }
  
  on(event, listener) {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }
    this.events.get(event).push(listener);
  }
  
  off(event, listener) {
    if (!this.events.has(event)) return;
    const listeners = this.events.get(event);
    const index = listeners.indexOf(listener);
    if (index > -1) listeners.splice(index, 1);
  }
  
  emit(event, ...args) {
    if (!this.events.has(event)) return false;
    this.events.get(event).forEach(listener => listener.apply(this, args));
    return true;
  }
  
  once(event, listener) {
    const onceWrapper = (...args) => {
      listener.apply(this, args);
      this.off(event, onceWrapper);
    };
    this.on(event, onceWrapper);
  }
}

10. LRU缓存

JavaScript性能与优化:手写实现关键优化技术 JavaScript 性能与优化:数据结构和算法

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.cache = new Map();
  }
  
  get(key) {
    if (!this.cache.has(key)) return -1;
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }
  
  put(key, value) {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.capacity) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
}

11. 快速排序

JavaScript 数组原生方法手写实现 JavaScript 性能与优化:数据结构和算法

function quickSort(arr) {
  if (arr.length <= 1) return arr;
  
  const pivotIndex = Math.floor(arr.length / 2);
  const pivot = arr[pivotIndex];
  const left = [];
  const right = [];
  
  for (let i = 0; i < arr.length; i++) {
    if (i === pivotIndex) continue;
    if (arr[i] < pivot) {
      left.push(arr[i]);
    } else {
      right.push(arr[i]);
    }
  }
  
  return [...quickSort(left), pivot, ...quickSort(right)];
}

12. 二分查找

JavaScript 性能与优化:数据结构和算法

function binarySearch(arr, target) {
  let left = 0, right = arr.length - 1;
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (arr[mid] === target) return mid;
    if (arr[mid] < target) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }
  
  return -1;
}

总结

本文涵盖了前端面试中常见的算法和编程题,包括函数柯里化、函数组合、斐波那契数列、数组去重、深比较、防抖节流、Promise实现等核心知识点。掌握这些内容有助于提升编程能力和面试表现。

关键要点:

  1. 函数柯里化: 理解函数式编程思想,掌握基础实现和高级功能
  2. 函数组合: 学会构建可复用的函数管道,支持同步和异步操作
  3. 算法优化: 掌握递归优化、动态规划、空间优化等技巧
  4. 数据处理: 了解不同去重方法的适用场景和性能差异
  5. 深度比较: 处理复杂对象的比较,包括循环引用和特殊类型
  6. 异步控制: 实现防抖和节流,优化高频事件处理
  7. Promise实现: 深入理解异步编程模型
  8. 原生方法实现: 掌握call、apply、bind的内部原理
  9. 设计模式: 实现事件总线,理解发布-订阅模式

这些知识点不仅是面试的常见考点,也是实际开发中的重要技能。建议大家不仅要理解代码实现,更要掌握背后的设计思想和适用场景。

手搓实现简易版 Vue2 响应式系统

作者 PlankBevelen
2025年12月25日 14:23

本文引用自作者博客:plankbevelen.cn

以 vue2 为基础,实现一个简易版的响应式系统。 首先了解 vue2 的响应式系统是如何实现的。首先举例(以下为伪代码):

let val = 1;
val = 2;
<div>{{ val }}</div> => <div>1</div> => <div>2</div>

以上总的流程为:改变val值时,vue追踪到val的变化,然后通知所有依赖val的地方,最后更新视图。 vue官方说的是发布订阅模式,核心即:追踪变化,收集依赖,触发更新。

追踪变化 vue2 中是通过 Object.defineProperty 实现的。vue3 中是通过 Proxy 实现的,核心思路是不变的。

function defineReactive(obj, key, val) {
    object.defineProperty(obj, key, {
        get() {
            return val;
        },
        set(newVal) {
            if (newVal !== val) {
                val = newVal;
            }
        }
    })
}

这个借助Object.defineProperty,为对象的属性添加了getter和setter,当属性被访问或修改时,会触发getter和setter,达到数据变化追踪的效果。 而这个只能为对象的单个属性,而且只能添加一次,所以可以进行递归遍历,为对象的每个属性都加上getter和setter.

// 检测数据变化
class Observer {
    constructor(value) {
        this.value = value;
        if(value && typeof value === 'object') Array.isArray(value) ? obserbeArray(value) : walk(value);
    }
    walk(obj) {
        for(const key in obj) {
            defineReactive(obj, key, obj[key]);
        }
    }
    obserbeArray(obj) {
        for(let i = 0; i < obj.length; i++) {
            observe(obj[i]);
        }
    }
}

export function observe(val) {
    // 如果不是对象
    if(typeof val !== 'object') return 
    return new Observer(val); // 继续
}

Observer类用递归实现了一整个对象的所有属性的监听,如果数据有所变化的话,需要有一个类似于socket的东西去帮它传达更新消息,并执行其对应的业务逻辑。 vue 是使用了一个叫依赖的东西,当数据变化的时候,你所要通知的地方就是依赖。依赖收集的时期在getter时,等你在触发setter的时候就会去触发对应业务。到这儿整个流程大致会清晰一些,最后再做总结。所以中间先建立一个依赖收集器,只实现部分这儿用得到的功能:收集依赖、通知更新,就够了

class Dep {
    constructor() {
        this.subs = [];
    }
    addSub(sub) {
        if(sub && sub.update && !this.subs.includes(sub)) this.subs.push(sub);
    }
    notify() {
        this.subs.foreach((sub) => sub.update());
    }
}
Dep.target = null;

(补一下收集依赖的部分)

function defineReactive(obj, key, val) {
    object.defineProperty(obj, key, {
        get() {
            // 收集依赖
            this.addSub(Dep.target);
            return val;
        },
        set(newVal) {
            if (newVal !== val) {
                val = newVal;
                // 通知更新
                this.notify();
                // 监听新值
                observe(newVal);
            }
        }
    })
}

最后是实现,依赖所要通知的单位,这个单位呢有很多种,比如用户实现的watch,template中的数据等,为了方便管理起来,vue中把这个叫做watcher进行集中管理

class Watcher {
    // vm是组件实例,expOrFn是监听对应的数据,cb是回调函数,也就是其对应的业务逻辑
    constructor(vm, expOrFn, cb) {
        this.vm = vm;
        this.cb = cb;
        if(typeof expOrFn === 'function') {
            this.expOrFn = expOrFn; // 直接执行
        } else {
            this.expOrFn = parseExp(exp);
        }
        this.value = this.get();
    }
    get() {
        Dep.target = this;
        const val = this.expOrFn(this.vm);
        Dep.target = null;
        return val;
    }
    update() {
        let oldValue = this.value;
        let newValue = this.get();
        if(oldValue !== newValue) {
            this.value = newValue;
            this.cb.call(this.vm, newValue);
        }
    }
    parseExp(exp) {
        if(/[^\w.$]/.test(exp)) throw new Error('表达式格式错误');
        const segments = exp.split('.');
        return function(obj) {
            let val = obj;
            segments.forEach((segment) => {
                val = val[segment];
            })
            return val;
        }
    }
}

总的结果就是Observer类负责监听数据变化,Dep类负责收集依赖和通知更新,Watcher类负责订阅数据变化并执行回调函数。

硬核指南:Volta —— 重新定义 JavaScript 工具链管理

作者 汤姆Tom
2025年12月25日 14:07

在前端基建领域,Node.js 版本管理经历了三代演变:

  1. 石器时代:手动下载安装包,版本切换靠重装。
  2. 铁器时代(NVM/N) :基于 Shell 脚本的路径修改,虽然解决了多版本共存,但在跨 Shell、启动速度和项目自动化上存在硬伤。
  3. 电气时代(Volta) :基于 Rust 的二进制 Shim 机制,实现了毫秒级响应零配置上下文切换

本文将带你从原理到实战,彻底掌握 Volta。


一、 核心原理:Volta 是如何工作的?

很多开发者好奇,为什么 Volta 不需要像 NVM 那样运行 nvm use

The Shim Mechanism (垫片机制)

Volta 安装后,会把自己的 Shim 可执行文件(如 node, npm, yarn, pnpm)添加到系统的 PATH 环境变量最前端。

当你运行 node 时,实际发生的是:

  1. 拦截:Volta 的 Shim 拦截了这次调用。

  2. 解析:Volta 迅速扫描当前目录树,寻找 package.json 中的 volta 字段。

    • 有配置:使用配置文件锁定的版本。
    • 无配置:使用你通过 volta install 设置的默认全局版本。
  3. 路由:Volta 瞬间将命令转发给对应的真实二进制文件。

这一切都由 Rust 编写,开销极小(通常在几毫秒内),用户几乎无感。


二、 安装与环境配置(进阶版)

1. 标准安装

  • Mac/Linux: curl https://get.volta.sh | bash
  • Windows: 使用安装包或 winget install Volta.Volta

2. 迁移指南(必须做的清理)

为了防止冲突,安装 Volta 后,必须清理旧的管理器:

  • NVM 用户:注释掉 .zshrc / .bashrc 中关于 NVM_DIR 的加载脚本。
  • Homebrew 用户:如果你之前用 brew 装过 node,建议 brew uninstall node,避免 PATH 优先级混淆。

三、 Volta 命令全解 (Command Reference)

这是你要的详细命令手册,我们将命令分为 环境管理项目管理工具指令 三类。

1. 环境管理命令 (Global)

这些命令影响你的全局默认环境(当你不在具体项目中时使用的版本)。

  • volta install [tool]

    • 作用:下载并安装工具,同时将其设为默认版本

    • 示例

      Bash

      volta install node@18       # 安装 Node 18 的最新 LTS 并设为默认
      volta install node@latest   # 安装 Node 最新版
      volta install yarn@3        # 安装 Yarn 3
      volta install cowsay        # 安装全局二进制包 (cowsay)
      
    • 细节:Volta 会把全局包安装在沙箱中,即使切换 Node 版本,这些全局工具依然可用(这是 NVM 做不到的)。

  • volta list

    • 作用:查看当前生效的工具版本(受当前目录上下文影响)。

    • 示例

      Bash

      volta list
      # 输出:
      # ⚡️ Currently active tools:
      #    Node: v16.19.0 (current @ /projects/my-app/package.json)
      #    Yarn: v1.22.19 (default)
      
  • volta list all

    • 作用:查看本地缓存的所有已下载版本。
    • 场景:当你磁盘空间不足,想看看存了多少个 Node 版本时。
  • volta uninstall [tool]

    • 作用:卸载全局工具。
    • 注意:这主要用于卸载像 typescriptcreate-react-app 这样的全局包。如果你想删除特定的 Node 引擎缓存,通常不需要手动管理,Volta 会自动处理,或者你可以手动清理 ~/.volta/tools/image
  • volta fetch [tool]

    • 作用:仅下载版本到本地缓存,但不设置为默认,也不修改项目配置。
    • 场景:为离线环境做准备,预先下载 Node 20。

2. 项目管理命令 (Project)

这是 Volta 的灵魂,用于锁定项目依赖。

  • volta pin [tool]

    • 作用:将工具版本写入当前目录的 package.json

    • 示例

      Bash

      cd my-project
      volta pin node@14.17
      volta pin yarn@1.22
      
    • 结果

      JSON

      // package.json
      "volta": {
        "node": "14.17.6",
        "yarn": "1.22.19"
      }
      
    • 深层逻辑:一旦 Pin 住,任何进入该目录的人,运行 node -v 都会得到 14.17.6,无论他们全局默认是哪个版本。

3. 高级工具命令 (Utility)

  • volta run

    • 作用:使用特定版本的工具运行一次性命令(不修改配置)。

    • 场景:你需要用 Node 10 跑一个老脚本,但不想安装它为默认,也不想修改项目配置。

    • 示例

      Bash

      volta run --node 12 index.js
      volta run --npm 6 npm install
      
  • volta which [tool]

    • 作用:显示当前工具实际调用的二进制文件路径。

    • 场景:Debug 专用。当你怀疑 Volta 没有生效,或者不知道自己在用哪里的 Node 时。

    • 示例

      Bash

      volta which node
      # 输出: /Users/username/.volta/tools/image/node/18.15.0/bin/node
      
  • volta setup

    • 作用:为当前用户/Shell 重新配置 Volta 环境变量。
    • 场景:主要用于 CI 环境或修复损坏的 Shell 配置文件。
  • volta completions

    • 作用:生成 Shell 自动补全脚本(支持 bash, zsh, fish, powershell)。

四、 进阶实战场景

1. Monorepo 支持 (Workspace)

如果你使用 pnpm workspace 或 yarn workspace 管理 Monorepo:

  • 你只需要在根目录package.json 中运行 volta pin
  • 子包(packages/*)会自动继承根目录的 Volta 配置。
  • 如果某个子包需要特殊的 Node 版本,你也可以在子包里单独 Pin(虽然不推荐这样做,但 Volta 支持)。

2. CI/CD 集成 (GitHub Actions)

在 CI 环境中使用 Volta 极度舒适,因为它保证了本地和 CI 环境的严格一致。

示例 GitHub Actions Workflow:

YAML

steps:
  - uses: actions/checkout@v3
  
  # 安装 Volta
  - uses: volta-cli/action@v4
  
  # 此时 Volta 会自动读取 package.json 中的设置
  # 自动安装对应的 Node 和 NPM/Yarn 版本
  
  - run: npm install
  - run: npm test

无需手动指定 node-version: 16,一切以代码库中的 package.json 为准。

3. 处理全局二进制 (Global Binaries)

NVM 的痛点:切了 Node 版本,安装的全局包(如 nest-cli)就找不到了。

Volta 的解法:

Bash

volta install @nestjs/cli

无论你当前处于哪个 Node 版本的项目中,nest 命令都能用。Volta 会智能地用当前项目环境的 Node 去运行这个全局工具。


五、 常见问题排查 (Troubleshooting)

问题现象 可能原因 解决方案
安装后找不到 volta 命令 环境变量没生效 运行 source ~/.zshrc 或重启终端。
Node 版本没变 旧的 PATH 优先级更高 检查 $PATH,确保 ~/.volta/bin/usr/local/bin 或 NVM 路径之前。
npm install 报错权限问题 Windows 常见问题 在 Windows 上开启“开发者模式”,或确保 Volta 目录无权限限制。
Binary 无法执行 缺少依赖 Linux 环境下偶尔需要安装 build-essential

总结

Volta 不仅仅是一个版本切换器,它是一个确定性的环境管理器

  • 对于个人:它提供了极速、无感的开发体验。
  • 对于团队volta pin 彻底终结了“由于 Node 版本不同导致的依赖报错”。

建议: 现在的项目开发中,直接在 package.json 中加入 volta 配置应成为一种行业最佳实践,就像配置 .gitignore 一样标准。

深度解析:fetch 与 Promise 结合实战及面试重点

作者 前端无涯
2025年12月25日 12:16

在前端异步编程领域,Promise 是解决回调地狱的核心方案,而 fetch 作为现代浏览器原生支持的网络请求 API,其设计本身就深度依赖 Promise。掌握二者的结合使用,不仅是日常开发的基础,更是前端面试的高频考点。本文将从核心概念联动、实战场景应用、面试重点拆解三个维度,帮你彻底搞懂 fetch 与 Promise 的协同逻辑。

一、核心概念:为什么 fetch 天然适配 Promise?

在讲解结合用法前,我们先明确两个核心概念的关联:

1.1 Promise 的核心价值

Promise 是一种用于处理异步操作的对象,它将异步操作的结果(成功/失败)封装为可预测的状态流转,避免了多层嵌套的回调地狱。其核心特性包括:

  • 三种状态:pending(进行中)、fulfilled(成功)、rejected(失败),状态一旦改变不可逆;
  • 链式调用:通过 then() 接收成功结果,catch() 捕获错误,支持链式串联多个异步操作;
  • 异步穿透:允许在链式调用中跳过部分 then(),直接由后续 catch() 捕获错误。

1.2 fetch 的设计本质

fetch 是浏览器提供的用于替代 XMLHttpRequest 的网络请求 API,其核心设计理念就是“基于 Promise 封装异步请求”。与 XHR 不同,fetch 调用后会直接返回一个 Promise 对象:

  • 当请求发出后,Promise 处于 pending 状态;
  • 当服务器返回响应(无论 HTTP 状态码是否为 2xx),Promise 会变为 fulfilled,并将响应对象(Response)传递给 then()
  • 只有当网络故障(如断网、跨域未授权等)导致请求无法发出时,Promise 才会变为 rejected,错误会被 catch() 捕获。

注意:fetch 的 Promise 不会因 HTTP 错误状态码(如 404、500)而 reject,这是面试高频易错点!

二、实战场景:fetch 与 Promise 结合用法全解析

基于二者的天然适配性,实际开发中我们通过 Promise 的链式调用,就能优雅地处理 fetch 的请求、响应解析、错误捕获全流程。以下是高频实战场景:

2.1 基础场景:GET 请求与响应解析

fetch 默认发起 GET 请求,需通过 Response 对象的方法(如 json()text())解析响应体,而这些方法本身也返回 Promise,因此需要嵌套一层 then()

// 基础 GET 请求
fetch('https://api.example.com/data')
  // 第一次 then:接收响应对象,解析为 JSON(返回 Promise)
  .then(response => {
    // 手动处理 HTTP 错误状态码
    if (!response.ok) {
      throw new Error(`HTTP 错误:${response.status}`);
    }
    return response.json(); // 解析 JSON 格式响应体,返回 Promise
  })
  // 第二次 then:接收解析后的业务数据
  .then(data => {
    console.log('请求成功:', data);
  })
  // catch:捕获所有错误(网络错误 + 手动抛出的 HTTP 错误)
  .catch(error => {
    console.error('请求失败:', error);
  });

核心逻辑:外层 Promise 处理请求发起与响应接收,内层 Promise 处理响应体解析,通过链式调用串联,代码清晰无嵌套。

2.2 进阶场景:POST 请求与请求配置

发起 POST 请求时,需通过 fetch 的第二个参数配置请求方法、请求头、请求体等,结合 Promise 处理复杂异步逻辑:

// 定义请求数据
const postData = { username: 'test', password: '123456' };

// POST 请求
fetch('https://api.example.com/login', {
  method: 'POST', // 请求方法
  headers: {
    'Content-Type': 'application/json', // 声明请求体格式为 JSON
  },
  body: JSON.stringify(postData), // 序列化请求体(必须为字符串)
})
  .then(response => {//这里是处理fetch返回的promise对象
    if (!response.ok) throw new Error(`状态码:${response.status}`);
    return response.json();
  })
  .then(result => {//response.json也是一个promise对象
    if (result.code === 200) {
      console.log('登录成功:', result.data.token);
      // 可继续发起后续请求(如获取用户信息),形成 Promise 链式串联
      return fetch('https://api.example.com/userInfo', {
        headers: { Authorization: `Bearer ${result.data.token}` }
      });
    } else {
      throw new Error(result.msg);
    }
  })
    // 这里是获取用户对象fetch 返回的promise
  .then(userRes => userRes.json())
    // 处理获取用户信息接口返回的数据
  .then(userData => console.log('用户信息:', userData))
  .catch(error => console.error('流程失败:', error));

关键亮点:在一个 Promise 链中串联多个异步请求(登录 → 获取用户信息),前一个请求的结果作为后一个请求的参数,实现异步流程的线性化。

2.3 高级场景:Promise 工具函数结合 fetch

利用 Promise 的工具函数(如 Promise.all()Promise.race()),可实现 fetch 的批量请求或超时控制:

// 1. Promise.all():并行发起多个请求,全部成功才返回结果
const request1 = fetch('https://api.example.com/data1');
const request2 = fetch('https://api.example.com/data2');

Promise.all([request1, request2])
  .then(responses => Promise.all(responses.map(res => res.json())))
  .then([data1, data2] => {
    console.log('批量请求成功:', data1, data2);
  })
  .catch(error => console.error('任意一个请求失败:', error));

// 2. Promise.race():超时控制(请求超时时中断并抛出错误)
const timeoutPromise = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error('请求超时(3秒)')), 3000);
});

Promise.race([fetch('https://api.example.com/slowData'), timeoutPromise])
  .then(res => res.json())
  .then(data => console.log('请求成功:', data))
  .catch(error => console.error('请求失败:', error));

2.4 优化场景:Promise 工具函数结合async,await

利用async,await将请求更加优雅,统一处理error

const search = async() =>{
    try{
        const res = await fetch('/api/test')
        const data = res.json()
        console.log(data,'返回接口数据')
    }catch(err){
        console.log('请求出错',err)
    }
}

三、面试重点:高频问题与核心答案

fetch 与 Promise 的结合是前端面试的核心考点,以下是高频问题及精准回答思路:

3.1 问题 1:fetch 的返回值是什么?它的 Promise 什么时候会 reject?

  • fetch 的返回值是一个 Promise 对象
  • 该 Promise 仅在「网络故障」时才会 reject(如断网、跨域未配置 CORS、域名无法解析等);
  • 即使服务器返回 404、500 等 HTTP 错误状态码,Promise 仍会 resolve,此时需要通过 Response 对象的 ok 属性(true 表示 2xx 状态码)手动判断请求是否成功,并抛出错误。

3.2 问题 2:如何用 Promise 处理 fetch 的错误(包括网络错误和 HTTP 错误)?

核心答案:通过「手动判断 HTTP 状态码 + catch 捕获」的组合实现全量错误处理:

fetch('/api/data')
  .then(res => {
    // 第一步:判断 HTTP 状态码,非 2xx 则抛出错误
    if (!res.ok) throw new Error(`HTTP 错误:${res.status}`);
    return res.json();
  })
  .then(data => console.log('成功', data))
  .catch(err => {
    // 捕获两类错误:网络错误 + 手动抛出的 HTTP 错误
    console.error('失败', err);
  });

3.3 问题 3:fetch 相比 XMLHttpRequest,结合 Promise 有哪些优势?

  1. 代码更简洁:Promise 链式调用替代 XHR 的多层回调,避免回调地狱;
  2. 状态更可控:Promise 的状态不可逆特性,让异步流程更可预测;
  3. 原生适配:fetch 天生返回 Promise,无需手动封装,而 XHR 需要手动用 Promise 包裹才能实现链式调用;
  4. 功能更强大:支持 Promise 工具函数(如 Promise.all())实现批量请求,轻松处理复杂异步场景。

3.4 问题 4:如何用 Promise.race() 给 fetch 设置超时时间?

创建一个超时 Promise(指定时间后 reject),与 fetch 的 Promise 进行 race 竞争,谁先改变状态就以谁的结果为准:

function fetchWithTimeout(url, timeout = 3000) {
  // 超时 Promise
  const timeoutTask = new Promise((_, reject) => {
    setTimeout(() => reject(new Error(`超时 ${timeout}ms`)), timeout);
  });
  // 竞争:fetch 成功/失败 或 超时
  return Promise.race([fetch(url), timeoutTask]);
}

// 使用
fetchWithTimeout('/api/slowData')
  .then(res => res.json())
  .then(data => console.log('成功', data))
  .catch(err => console.error('失败', err));

3.5 问题 5:fetch 中如何实现请求中断?结合 Promise 怎么处理?

fetch 本身不支持中断,但可通过 AbortController 与 Promise 结合实现:

// 1. 创建 AbortController 实例
const controller = new AbortController();
// 2. 获取信号对象
const signal = controller.signal;

// 3. 发起 fetch 请求,将 signal 传入配置
fetch('/api/data', { signal })
  .then(res => res.json())
  .then(data => console.log('成功', data))
  .catch(err => {
    // 4. 中断时会触发 AbortError
    if (err.name === 'AbortError') {
      console.log('请求已中断');
      return;
    }
    console.error('其他错误', err);
  });

// 5. 主动中断请求(如用户点击取消按钮)
controller.abort();

原理:AbortController 的 signal 对象与 fetch 关联,调用 abort() 时,signal 会触发 abort 事件,fetch 会立即 reject 并抛出 AbortError。

四、总结

fetch 与 Promise 的结合,核心是利用 Promise 的状态管理和链式调用特性,解决 fetch 异步请求的流程控制问题。日常开发中,需重点掌握响应解析、错误处理(尤其是 HTTP 错误)、批量请求、超时控制等场景;面试中,要牢记 fetch 的 Promise 状态规则、错误处理逻辑、与 XHR 的差异及请求中断方案等核心考点。

掌握二者的协同逻辑,不仅能提升异步代码的可读性和可维护性,更能在面试中快速精准地应对高频问题。建议结合实际场景多写多练,加深对 Promise 异步流转和 fetch 核心特性的理解。

使用URLPattern API构建自己的路由器 🛣️

作者 Yanni4Night
2025年12月25日 11:40

原文:jschof.dev/posts/2025/…

URLPattern 现在已经在所有浏览器中可用了!所以我想深入研究一下,看看如何使用原生 JavaScript 和浏览器 API 制作一个简单的 SPA 路由器。我们应该能够创建一个组件,它接受路由器配置,并根据浏览器 URL 渲染相应的组件。

URLPattern() 是做什么的?📎

对于路由器来说,条件渲染组件并不是困难的部分。困难的是准确测试浏览器 URL,以确定应该渲染哪个组件。而且不仅如此,我们还需要能够捕获路由的动态部分(比如像 /posts/{post_id} 这样的路径)。

不多说,让我们来看一些示例,展示如何测试路由 URL 是否匹配某个模式!然后你可以使用这个机制来创建一个具有易于配置路径的路由器。

const catUrlPattern = new URLPattern({ pathname: "/cat" });
catUrlPattern.test("http://www.jschof.dev/cat"); // True!
catUrlPattern.test("http://www.jschof.dev/dog"); // False!
catUrlPattern.test({ pathname: "/cat" }); // True!
catUrlPattern.test("http://www.jschof.dev/cat/"); // False!
catUrlPattern.test("http://www.jschof.dev/cat/other-things?yes"); // False!

你可能会对上面的第四个例子感到惊讶。/cat/cat/ 是有区别的。所以为了处理这种情况,你可以在一个用大括号括起来的组中使模式可选地包含一个结尾斜杠,并使用 ?: 标记它为可选:

const catUrlPattern = new URLPattern({ pathname: "/cat{/}?" });
catUrlPattern.test("http://www.jschof.dev/cat"); // True!
catUrlPattern.test({ pathname: "/cat/" }); // True!
catUrlPattern.test("http://www.jschof.dev/cat/"); // True!
catUrlPattern.test("http://www.jschof.dev/cat/other-things?yes"); // False!

又一个惊喜!你可能期望可以接受 /cat/ 后面的更多内容。要做到这一点,需要包含一个通配符星号:

const catUrlPattern = new URLPattern({ pathname: "/cat{/}?*" });
catUrlPattern.test("http://www.jschof.dev/cat"); // True!
catUrlPattern.test({ pathname: "/cat/" }); // True!
catUrlPattern.test("http://www.jschof.dev/cat/"); // True!
catUrlPattern.test("http://www.jschof.dev/cat/other-things?yes"); // True!

我们从哪里开始?📎

我将使用一个配置对象数组,将 URL 路由与特定的 Web 组件关联起来。这与你使用 vue-router 创建路由器的方式非常相似。

const routerConfig = [
  { pathName: new URLPattern("/home{/}?"), component: "my-home" },
  { pathName: new URLPattern("/posts{/}?"), component: "my-posts" },
  { pathName: new URLPattern("/about{/}?"), component: "my-about" },
];

配置对象的顺序很重要。我们会逐个测试每个模式,如果找到匹配项,就渲染那个 Web 组件。

for (const config of routerConfig) {
  if (config.pathName.test(window.location.href)) {
    // 渲染 config.component!
    return;
  }
}
// TODO: 处理 404!

如何进行渲染呢?这将是我们放置所有这些逻辑的 Web 组件的工作。该组件会查看当前窗口 URL,将其与我们用 URLPattern 设置的所有路由器配置进行测试,然后创建并渲染适当的 Web 组件作为子元素。

有些框架称这个路由器为"出口"组件。

const routerConfig = [
  { pathName: new URLPattern("/home{/}?"), component: "my-home" },
  { pathName: new URLPattern("/posts{/}?"), component: "my-posts" },
  { pathName: new URLPattern("/about{/}?"), component: "my-about" },
];
class MyRouter extends HTMLElement {
  constructor() {
    super();
    const matchedComponent = this.getRouteMatch();
    this.renderComponent(matchedComponent);
  }
  getRouteMatch() {
    for (const config of routerConfig) {
      if (config.pathName.test(window.location.href)) {
        return config.component;
      }
    }
    // TODO: 处理 404!
  }
  renderComponent(component) {
    this.innerHTML = "";
    const viewElement = document.createElement(component);
    this.appendChild(viewElement);
  }
}
customElements.define("my-router", MyRouter);

当然,你还需要注册 my-homemy-postsmy-about 这些 Web 组件。

这样我们就有了一个路由器,它会在页面加载时渲染适当的 Web 组件。不过我们还有很多工作要做。如果有人点击链接怎么办?如果有人使用浏览器导航前进或后退怎么办?我们需要处理这些情况,幸运的是这并不太困难。

处理 SPA 导航和链接点击 📎

有一点需要意识到的是,如果你导航到 http://www.myblog.com/some/path,服务器通常会尝试在后端解析 /some/path。它实际上可能会寻找 "some" 和 "path" 文件夹。但在 SPA 中,我们没有文件夹——我们只有一个处理虚拟路径的 index HTML 文件。所有这些都在客户端用 JS 完成!无论我们要到哪个路径,服务器实际上只需要提供 index 页面。然后客户端会接管,使用我们的新 URLPattern 并处理渲染适当的组件。

对于配置 Vite 来说,这非常简单。你可以使用一个名为 spa 的配置。只需更新你的 vite 配置:

import { defineConfig } from "vite";
export default defineConfig({
  appType: "spa",
});

这对于你的 Vite 本地服务器来说效果很好。但不幸的是,这将取决于你部署的位置以及你使用的其他框架/开发服务器。对于像 netlify 这样的地方,你需要在你的 netlify 配置中设置重定向规则。你可能需要查阅 Stack Overflow、Google 或你选择的 LLM 来了解如何针对你的特定情况执行此操作。

但一旦你设置好服务器配置和重定向,我们就可以开始处理点击事件了!

我们基本上想拦截任何链接点击,并阻止浏览器正常导航。这意味着我们要对所有点击事件调用 preventDefault(),并提取链接的目标来与我们的 URL 模式进行测试。这让我们知道应该渲染哪个组件,然后我们手动将 URL 设置为锚标签指向的内容。看起来我们正在更改页面,但实际上我们是在模拟页面过渡。

我们需要在路由器组件连接到 DOM 时设置一个点击处理器:

class MyRouter extends HTMLElement {
  //...
  connectedCallback() {
    window.addEventListener("click", this.handleClicks);
  }
  handleClicks = (event) => {
    if (event.target instanceof HTMLAnchorElement) {
      // 不要像通常那样处理这个链接!
      event.preventDefault();
      // 手动设置 URL
      const toUrl = event.target.getAttribute("href");
      window.history.pushState({}, "", toUrl);
      // 现在 URL 已经设置好了,执行通常的匹配和渲染!
      const matchedComponent = this.getRouteMatch();
      this.renderComponent(matchedComponent);
    }
  };
  disconnectedCallback() {
    window.removeEventListener("click", this.handleClicks);
  }
}

当然,确保你在 disconnectedCallback 中清理这些处理器!

当用户点击链接时,他们会看到 URL 改变,页面过渡,甚至浏览器导航历史中会有一个新条目。现在,我们需要确保浏览器不会实际前进或后退,而是在点击前进/后退按钮时钩子到我们的路由器。

注意:在上面的例子中,我们拦截了所有锚标签上的点击,无论点击是在我们的路由器组件内部还是外部。你可能只想处理这个路由器组件内部的点击,这样你就不会承担整个页面上链接的责任。

另外,请注意,我们甚至还没有触及如何处理外部链接。这需要特别注意,但这超出了这篇博客文章的范围。

最后一个细节:浏览器导航 📎

当浏览器前进或后退时(无论是通过编程方式 window.back()/forward() 还是用户点击前进/后退按钮),都会触发一个 popstate 事件。

这个事件的好处是,当我们使用 window.pushState 时,浏览器已经在历史栈中前后移动到了我们推送的条目。换句话说,我们只需要监听这种后退/前进导航何时发生,并渲染我们的组件。在按下后退/前进按钮后,URL 已经更新了。

这是我们最小可行路由器的最后一部分:

class MyRouter extends HTMLElement {
  //...
  connectedCallback() {
    window.addEventListener("click", this.handleClicks);
    window.addEventListener("popstate", this.handlePopState);
  }
  handlePopState = (event) => {
    const matchedComponent = this.getRouteMatch();
    this.renderComponent(matchedComponent);
  };
  disconnectedCallback() {
    window.removeEventListener("click", this.handleClicks);
    window.removeEventListener("popstate", this.handlePopState);
  }
}

一个工作示例 📎

如果你有兴趣看到它的工作原理,这里有一个在 StackBlitz 上构建的示例。

还有更多工作要做!以下是我建议研究的事情:

  • 创建动态段,如 /posts/:id。这里有一些关于处理动态参数的文档
  • 使用 search 处理查询参数
  • 处理嵌套或子路由器

需要记住的事情... 📎

我们正在处理相当底层的东西。像这样创建自己的路由器渲染器可能会让你暴露在 XSS 攻击中,如果你留下了让人们破坏你的路由器配置的方法。例如,如果你让人们直接在路由器组件上设置配置数组,有人可能会在控制台中注册他们自己的 Web 组件,并导航到他们的组件来渲染它。实际上,你将允许其他方在你的路由器中运行他们的代码!

这就是为什么,至少在我们讨论的例子中,路由器配置是在我们的路由器组件中的一个私有变量中定义的。如果我们公开这个属性,有人可以注册他们自己的 Web 组件并让我们的路由器渲染它。

我们如何解决这个问题?我想说,永远不要纯粹基于 URL 的查询参数或动态段进行渲染。始终将实际渲染的 Web 组件保存在静态列表中——可能是路由器上的私有变量中的列表。

另一件事——我们应该用 Web 组件构建路由器吗?嗯...也许不应该?Lit 似乎认为这会有帮助和有用。但你自己实现的路由器需要处理很多框架路由器已经解决的问题。Web 组件还增加了另一个你需要注意的安全级别。

我认为这值得研究和学习。而且了解不断推出的原生 API 也很好,这些 API 让我们在依赖平台时的生活更轻松。

JavaScript 还有第三种注释?--> 竟然合法

2025年12月24日 13:25

JavaScript 中藏了十年的秘密注释语法

看下面这段代码:

x
= 1
x
--> 0

猜猜输出是什么?

答案是 1

不是 0,也不是报错。就是 1

这个谜题来自 Hillel Wayne 的博客

为什么不是 0?

因为 --> 在行首的时候,是一个注释符号

对,你没看错。JavaScript 除了 ///* */,还有第三种注释语法:-->。只不过它有个限制:必须出现在行首。

所以上面的代码实际被解析成了:

x = 1;
x;
// --> 0 这行被当成注释了

最后一个表达式是 x,值是 1,所以输出 1

这玩意儿哪来的?

得从二十多年前说起。

早期的浏览器(IE 3、Netscape 2 那个年代)刚开始支持 JavaScript,但很多旧浏览器根本不认识 <script> 标签。为了让这些老古董不把 JavaScript 代码当成页面内容显示出来,开发者想了个办法:把代码包在 HTML 注释里。

<script>
<!--
console.log("hello world!")
-->
</script>

旧浏览器看到 <!-- 就当 HTML 注释跳过了,新浏览器则正常执行 JavaScript。

问题来了:新浏览器要执行 JavaScript,但 <!----> 本身不是 JavaScript 语法啊。怎么办?

答案是:让 JavaScript 引擎把它们当注释处理。

标准化了吗?

标准化了。

ECMAScript 2015(ES6)正式把 <!----> 写进了规范,作为合法的注释 token。规范里特别说明:"--> 只在行首有效,避免和后缀递减运算符混淆"。

想想也是。如果 --> 在任何位置都是注释,那 x-- > 0 这种正常代码就没法写了。

浏览器必须支持,Node.js 不用

这里有个细节:规范只要求浏览器环境支持这个语法。Node.js、Deno、Bun 这些非浏览器的 JavaScript 运行时不强制要求。

实际测试一下:

// 浏览器控制台
x = 1
--> 0
// 输出 1

// Node.js
x = 1
--> 0
// SyntaxError: Unexpected token '>'

Node.js 直接报语法错误。所以这个"特性"只在浏览器里生效。

还有 <!--

既然 --> 是注释,<!-- 也是。

<!-- 这是注释
console.log("hello") <!-- 这也是注释

<!-- 的行为和 // 一样,后面的内容都被忽略。

为什么现在还保留?

向后兼容。

虽然现在没人会这么写代码,但互联网上可能还存在一些上古网页用了这个写法。浏览器厂商不敢删,万一删了哪个政府网站挂了呢。

这就是 JavaScript 的日常——背着二十年的历史包袱往前跑。

实际有什么用?

没用。纯粹是个冷知识。

但下次面试官问你"JavaScript 有几种注释方式",你可以微微一笑,说三种。


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

基于微前端 qiankun 多实例保活的工程实践

2025年12月24日 10:17

一、业务背景与痛点

在中后台系统的实际业务场景中,通常会遇到以下场景:

  • 订单管理:用户正在填写一张复杂的订单表单,已输入大量数据
  • 库存查询:需要临时切换到这里查询商品库存
  • 客户信息:需要确认客户的收货地址

在传统的实现中,当用户从"订单管理"切换到"库存查询"时:

  1. 订单管理页面被重置
  2. 已填写的表单数据全部丢失
  3. 筛选条件、展开的树节点等状态全部清空

当用户查完库存返回时,不得不:

  • 重新加载订单管理页面
  • 重新填写所有表单字段
  • 重新定位到之前的操作位置

这种体验对于需要频繁切换的中后台场景来说是不可接受的。

业务诉求:实现类似浏览器多标签页的效果:

  • ✅ 页面切换时保留完整状态(表单输入、滚动位置、展开/收起状态等)
  • ✅ 无需重新加载,瞬间切换
  • ✅ 支持多个路由同时"存活"

在单独运行的子应用(非微前端场景),通常可以通过 Vue 的 keep-alive、React 的路由缓存等技术手段来实现,而在微前端架构下,又该如何实现该效果呢,或者说如何实现多实例保活的能力呢?

在深入解决方案之前,我们需要先理解 qiankun 沙箱机制的核心原理。

二、qiankun 沙箱机制原理

2.1 为什么需要沙箱?

微前端架构中,多个子应用可能来自不同团队、使用不同技术栈,它们共享同一个浏览器运行环境。如果不加隔离,会产生以下问题:

  • 全局变量污染:子应用 A 定义的 window.config 可能被子应用 B 覆盖
  • 事件监听泄漏:子应用卸载后,注册的 addEventListener 仍在执行
  • 样式冲突:不同子应用的 CSS 规则相互影响

qiankun 通过 JavaScript 沙箱 机制解决全局变量隔离问题。

2.2 ProxySandbox 核心原理

qiankun 提供了三种沙箱方案,其中 ProxySandbox 是多实例保活场景的唯一选择:

沙箱类型 实现原理 多实例支持 适用场景
SnapshotSandbox 激活时快照、失活时 diff 恢复 ❌ 不支持 不支持 Proxy 的低版本浏览器
LegacySandbox 单例代理,记录变更 ❌ 不支持 只有一个子应用激活的场景
ProxySandbox 为每个实例创建独立 fakeWindow ✅ 支持 现代浏览器(推荐)

ProxySandbox 的工作原理

┌─────────────────────────────────────────────────────────────┐
│                      真实 window 对象                        │
└─────────────────────────────────────────────────────────────┘
                              ▲
                              │ 读取白名单属性 / 原生方法
        ┌─────────────────────┼─────────────────────┐
        │                     │                     │
        ▼                     ▼                     ▼
┌───────────────┐    ┌───────────────┐    ┌───────────────┐
│  Proxy 代理层  │    │  Proxy 代理层  │    │  Proxy 代理层  │
│   (子应用 A)   │    │   (子应用 B)   │    │   (子应用 C)   │
├───────────────┤    ├───────────────┤    ├───────────────┤
│  fakeWindow A │    │  fakeWindow B │    │  fakeWindow C │
│  ┌─────────┐  │    │  ┌─────────┐  │    │  ┌─────────┐  │
│  │ config  │  │    │  │ config  │  │    │  │ config  │  │
│  │ myVar   │  │    │  │ myVar   │  │    │  │ myVar   │  │
│  └─────────┘  │    │  └─────────┘  │    │  └─────────┘  │
└───────────────┘    └───────────────┘    └───────────────┘
      各自独立                各自独立                各自独立

核心代码简化示意:

class ProxySandbox {
  private fakeWindow: Record<PropertyKey, any> = {};

  constructor() {
    this.proxy = new Proxy(this.fakeWindow, {
      get: (target, prop) => {
        // 优先从 fakeWindow 读取
        if (prop in target) {
          return target[prop];
        }
        // 白名单属性从真实 window 读取
        return window[prop];
      },
      set: (target, prop, value) => {
        // 所有写操作都写入 fakeWindow,不污染真实 window
        target[prop] = value;
        return true;
      }
    });
  }
}

2.3 沙箱的激活与失活生命周期

qiankun 沙箱有明确的生命周期管理:

子应用加载 ──► beforeLoad ──► 执行入口脚本 ──► mount ──► 沙箱激活
                                                          │
                                                          ▼
                                              副作用 patch 开始生效
                                              (Interval/Listener/History)
                                                          │
                                              ◄───────────┘
                                                          │
用户切换路由 ──► unmount ──► 沙箱失活 ──► 副作用清理 ──► DOM 移除

关键时机说明

  1. **沙箱激活 (active)**:调用 sandbox.active(),Proxy 开始拦截
  2. 副作用 patch:在 mount 阶段对 setIntervaladdEventListener 等进行劫持
  3. **沙箱失活 (inactive)**:调用 sandbox.inactive(),清理记录的副作用

理解了沙箱机制后,我们来分析在多实例保活场景下会遇到哪些具体的技术挑战。

三、技术难点分析

实现微前端多实例保活,有哪些技术难点:

3.1:应用实例的保活与激活

关键点:在路由切换时保留应用状态,而非销毁重建。

对于单体应用,这个问题已有成熟方案:

  • Vue 项目:使用内置的 <keep-alive> 组件即可
  • React 项目:需要自行实现路由缓存,核心思路是缓存组件实例而非销毁

对应到 qiankun 场景

本质上 qiankun 仍然是一个 SPA 应用,只是通过路由规则将不同的路由分发到对应的子应用。因此我们可以套用相同的思路:

  • 子应用切换时:隐藏当前子应用实例(而非调用 unmount)
  • 再次激活时:显示已缓存的实例并渲染
  • 关键要点:隐藏而非销毁 DOM 节点

3.2:多沙箱并存的隔离

关键点:多个子应用同时保活,意味着多个沙箱需要同时激活且互不干扰。

解法

启用 ProxySandbox(多实例代理沙箱)。它为每个子应用创建独立的 fakeWindow 副本,确保多个子应用可以同时激活且全局变量互不污染。

基于以上分析,我们开始实战(核心技术栈:umijs 4 + qiankun + react,代码仓库)。

主应用核心实现:

interface CachedApp {
  microApp: string
  element: React.ReactElement | null
}

export default function Layout() {
  // 保活实例缓存池
  const cache = useRef<CachedApp[]>([])
  const element = useOutlet()
  const routeProps = useRouteProps()
  const { microApp } = routeProps

  // 首次访问时加入缓存池
  if (!cache.current.find(r => r.microApp === microApp)) {
    cache.current.push({
      microApp,
      element
    })
  }

  return (
    <div>
      {/* 所有已缓存的子应用同时渲染,通过 hidden 控制显隐 */}
      {
        cache.current.map((app) => {
          return (
            <div
              key={app.microApp}
              hidden={app.microApp !== microApp}
            >
              {app.element}
            </div>
          )
        })
      }
    </div>
  );
}

然而在真实环境运行时,子应用在切换过程中会丢失状态,浏览器的 warning 信息如下:

接下来我们需要深入分析问题的具体原因。

四、问题分析与定位

4.1:React Router 为什么触发 warning?

通过 warning 执行栈定位到 React Router 的 Router 组件。根因是 pathnamebasename 不匹配时,stripBasename 返回 null,导致 Router 组件渲染空内容并抛出警告。

export function Router({
  // ...
}: RouterProps): React.ReactElement | null {
  let locationContext = React.useMemo(() => {
    // pathname 和 basename 不匹配时返回 null
    let trailingPathname = stripBasename(pathname, basename);
    if (trailingPathname == null) {
      return null;
    }
  }, [basename, pathname, search, hash, state, key, navigationType]);

  warning(
    locationContext != null,
    `<Router basename="${basename}"> is not able to match the URL ` +
      `"${pathname}${search}${hash}" because it does not start with the ` +
      `basename, so the <Router> won't render anything.`
  );

  if (locationContext == null) {
    return null;
  }

  return (
    <NavigationContext.Provider value={navigationContext}>
      <LocationContext.Provider children={children} value={locationContext} />
    </NavigationContext.Provider>
  );
}

具体原因:非激活子应用(basename=/app1)收到了不属于它的路由变化通知(pathname=/app2/xxx),导致匹配失败。

那么问题来了:为什么非激活状态的子应用还会响应路由变化?

4.2:非激活子应用为什么触发 Re-render?

调用链分析

路由变化 (pushState/popstate)
        │
        ▼
window.dispatchEvent('popstate')  ◄── 全局事件,所有监听者都会收到
        │
        ├──► 子应用 A 的 history.listen 回调执行
        ├──► 子应用 B 的 history.listen 回调执行  ◄── 问题:即使 B 已"隐藏"
        └──► 子应用 C 的 history.listen 回调执行
                    │
                    ▼
        BrowserRoutes 组件 setState
                    │
                    ▼
        Router 组件 re-render ──► basename 不匹配 ──► Warning + 渲染失败

分析具体代码链路:

  1. umijs 的 BrowserRoutes 通过 history.listen 订阅路由变化
  2. history 内部通过 window.addEventListener 监听 popstate 事件
  3. 路由切换时触发全局 popstate 事件,所有订阅回调执行
  4. 导致所有子应用的 BrowserRoutes 重新渲染,进而触发 Router 的 re-render
function BrowserRoutes(props) {
  // ...
  useLayoutEffect(() => history.listen(setState), [history]);
  useLayoutEffect(() => {
    return history.listen(onRouteChange);
  }, [history, props.routes, props.clientRoutes]);
  return (
    <Router
      navigator={history}
      location={state.location}
      basename={props.basename}
    >
      {props.children}
    </Router>
  );
}

具体原因:多个子应用的 history.listen 都注册在同一个全局 window 上,路由变化时所有子应用都会响应。

按理说 qiankun 的沙箱应该隔离 window.addEventListener,并且 qiankun 对 setIntervaladdEventListenerhistory.listen 都有 patch,为什么这里没生效?

4.3:为什么 addEventListener 没被 patch 住?

要理解这个问题,需要先了解一个关键概念——**沙箱逃逸(Sandbox Escape)**。

什么是沙箱逃逸?

沙箱逃逸是指代码绕过沙箱的代理机制,直接访问到真实的全局对象。一旦发生逃逸,在真实对象上的操作将无法被沙箱追踪和清理。

正常路径(被沙箱拦截):
子应用代码 ──► proxy.addEventListener ──► 沙箱记录 ──► 卸载时自动清理 ✅

逃逸路径(绕过沙箱):
子应用代码 ──► document.defaultView ──► 真实 window ──► addEventListener
                                                              │
                                              沙箱无法感知,卸载后仍存在 ❌

除此之外还有修改原型链修改深层对象属性等方式也会触发沙箱逃逸,这部分内容可以自行了解。

React Router 的逃逸路径分析

Step 1:qiankun 为何不代理 document?

qiankun 的 ProxySandbox 对 document 的访问会返回真实的 document 对象。这是有意为之——子应用必须操作真实 DOM 才能渲染内容(详见 qiankun document 沙盒环境讨论 issue #493issue #1175issue #1555):

// qiankun ProxySandbox 源码
get: (target, prop) => {
  if (prop === 'document') {
    return this.document;  // 返回真实 document,未代理
  }
  // ...
}

Step 2:React Router 如何触发逃逸?

React Router 的 history 库通过 document.defaultView 获取 window 对象:

// remix-run/history 源码
export function createBrowserHistory(options = {}) {
  // 关键:通过 document.defaultView 获取 window
  // 由于 document 未被代理,这里拿到的是真实 window!
  let { window = document.defaultView! } = options;

  // 在真实 window 上注册监听器,绕过了沙箱的 patch
  window.addEventListener('popstate', handlePop);
}

由于 document.defaultView === window(真实 window),所有子应用的路由监听器最终都注册在同一个全局 window 上。

┌─────────────────────────────────────────────────────────────────┐
│                         子应用代码执行                           │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│              ProxySandbox (fakeWindow 代理层)                    │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │ get(prop) {                                              │    │
│  │   if (prop === 'window') return proxy; // ✅ 拦截       │    │
│  │   if (prop === 'document') return document; // ⚠️ 逃逸  │    │
│  │ }                                                        │    │
│  └─────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────┘
         │                                    │
         │ window.xxx                         │ document.defaultView
         ▼                                    ▼
    ┌──────────┐                      ┌─────────────┐
    │ 被拦截   │                      │  真实 window │
    │ fakeWindow│                      │  (逃逸成功)  │
    └──────────┘                      └─────────────┘
qiankun 的 addEventListener patch 为何失效?

熟悉 qiankun 源码的同学可能会问:qiankun 不是对 addEventListener 做过 patch 吗?

关键在于 patch 的对象是 proxy.addEventListener,而非真实 window 上的方法。当 React Router 通过 document.defaultView 拿到真实 window 后,调用的是未被 patch 的原生 addEventListener,自然无法被沙箱追踪。

qiankun patch 的是:proxy.addEventListener  ──► 被劫持 ✅
React Router 调用的是:window.addEventListener(通过 document.defaultView 获取)──► 未被劫持 ❌

这个问题不仅存在于多实例保活场景,在普通的 qiankun 子应用中也存在(详见 umi issue #12484)。只是在多实例保活场景下,由于子应用不会触发 unmount,问题被放大暴露出来。

这也印证了 qiankun 官方文档的说明:如何同时激活两个微应用?

页面上不能同时显示多个依赖于路由的微应用,因为浏览器只有一个 url,如果有多个依赖路由的微应用同时被激活,那么必定会导致其中一个 404。

问题的原因已经明确:沙箱逃逸导致副作用无法隔离。接下来我们设计针对性的解决方案。

五、解决方案设计

多实例保活场景下,子应用实例未触发 unmount,加上沙箱逃逸导致副作用(如 popstate 监听器)持续累积,引发保活失效。接下来可以通过 patch React Router History 的方式来解决。

核心思路:拦截 history.listen,在回调函数中增加路由匹配判断,只有路由匹配的子应用才执行订阅回调。

路由变化通知
     │
     ▼
┌─────────────────────────────────────────┐
│         包装后的 listen 回调             │
│  ┌───────────────────────────────────┐  │
│  │ if (pathname.startsWith(basename))│  │
│  │   执行原始回调 ✅                  │  │
│  │ else                              │  │
│  │   忽略本次通知 🚫                  │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

利用 umi 的 modifyClientRenderOpts 插件钩子实现:

/**
 * patch history.listen,确保只有路由匹配的子应用响应路由变化
 * 注意:确保该插件最后执行,避免 patch 被覆盖
 */
export const modifyClientRenderOpts = (context: any) => {
  // 仅在 qiankun 子应用模式下且开启保活功能时生效
  if (window.__POWERED_BY_QIANKUN__ && !!context.enablePatchHistory) {
    const { history, basename } = context;
    const rawHistoryListen = history.listen;

    history.listen = (fn: any) => {
      const listener = (...args: any[]) => {
        const { location } = args[0];

        // 核心逻辑:仅当路由匹配当前子应用时才执行回调
        if (location.pathname.startsWith(basename)) {
          fn(...args);
        }
        // 不匹配时静默忽略,避免触发 re-render
      };

      const unlistener = rawHistoryListen(listener);
      return () => unlistener();
    };

    return context;
  }

  return context;
};

接下来,再考虑下直接通过 window.addEventListener 注册的监听器,即子应用在初始化阶段(mount 前)通过 headScripts 等方式注册了全局监听器:

// umirc.ts - 这类副作用在沙箱 patch 生效前就已注册
export default defineConfig({
  headScripts:[
    `window.addEventListener("popstate", function(){
        console.log("========= app1 popstate change =========");
      })`
  ]
});

同样会被继续执行,问题原因:qiankun 的副作用 patch 发生在 mount 阶段,而入口脚本执行在此之前:

export function createSandboxContainer() {
  return {
    async mount() {
      /* ------------------------------------------ 1. 启动/恢复 沙箱------------------------------------------ */
      sandbox.active();

      /* ------------------------------------------ 2. 开启全局变量补丁 ------------------------------------------*/
      // render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化阶段有 事件监听/定时器 等副作用
      mountingFreers = patchAtMounting(...);
    }
  };
}

解法:在 beforeLoad 钩子中提前 patch:

// 主应用配置
registerMicroApps([{
  name: 'app1',
  entry: '//localhost:6001',
  container: '#container',
  activeRule: '/app1',
  props: {
    // 通过 beforeLoad 提前 patch
    beforeLoad: async (app) => {
      patchWindowAddEventListener(app.name);
    }
  }
}]);

:根据实际业务场景决定是否需要实现此增强方案。大多数情况下,方案一已足够解决问题。

六、总结与展望

通过深入分析 qiankun 的沙箱机制和逃逸路径,我们成功实现了多实例保活能力。核心解决思路是:在沙箱无法覆盖的逃逸点进行精准 patch

然而在实践过程中也发现,手动 patch 沙箱副作用需要处理大量边界场景,实现成本较高。

需要特别注意的是,当前方案主要解决了核心功能实现,在生产环境落地时仍需关注以下工程化问题:

  • 性能边界:多实例常驻内存会导致资源消耗激增,可以动态配置开启保活的子应用个数
  • 监控体系:接入内存占用、实例数量、渲染性能等关键指标的实时监控

其他可行方案

除了上面实践的方案,还有以下思路可供参考:

思路一:基于 iframe 的沙箱隔离(如 Wujie)

利用 iframe 天然的隔离特性实现多实例保活,但同样需要处理:

  • iframe 与主应用的路由同步
  • DOM 节点的跨 iframe 渲染

思路二:框架层的状态快照机制

上面方案是在主应用层实现实例保活,另一个思路是从子应用框架层入手:

  • 参考 SSR 的设计理念,在框架层实现状态快照与恢复
  • 通过缓存 Virtual DOM 或渲染结果,在重新挂载时快速恢复现场

完整代码仓库

一文搞懂 Tailwind CSS v4 主题变量映射背后的原理

作者 Moment
2025年12月24日 09:05

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

在 Tailwind CSS v4 中,theme variables(设计变量或主题变量)与页面样式之间的映射关系是一个值得深入理解的核心机制。本文将从底层原理、生成阶段、作用域和命名空间等维度,详细解析变量是如何一步步与页面和 class 建立起映射关系的。

总体架构概览

Tailwind CSS v4 引入了一个核心变化:design tokens(设计变量)默认以 CSS 自定义属性(CSS variables)的方式暴露,并结合新的 CSS-first 配置 DSL(@theme 指令在 CSS 中)来定义变量。这些 design tokens 不仅仅是值的存储,还直接决定哪些 utility classesresponsive 变体或 variant 等会被生成。

整个映射过程可以概括为以下四个步骤:

  1. 在 CSS 文件中,使用 @theme 定义主题变量(命名空间变量)
  2. Tailwind 构建阶段读取这些变量,生成对应的 CSS 自定义属性(在 :root 或主题作用域中)以及与这些变量关联的 utility classesvariants
  3. 在页面(HTML 或模板)中使用这些类名,或者通过 var(--theme-variable) 方式直接访问变量
  4. 如果需要主题切换、暗黑模式、亮色模式或其他主题,则在不同作用域中重写变量值,使同样的类名在 UI 上呈现不同的视觉效果

详细步骤拆解

下面我们将深入解析从代码编写到页面效果呈现的完整流程,揭示变量是如何被映射的。

步骤 1:定义 theme 变量(design tokens)

在 CSS 文件中(通常是一个入口文件,如 app.css),你可以这样定义主题变量:

@import "tailwindcss";

@theme {
  --color-primary: #3490dc;
  --color-secondary: #ff8800;
  --breakpoint-lg: 1024px;
  --spacing-base: 1rem;
  /* 更多 theme 变量 ... */
}

@theme { ... } 是 Tailwind v4 中用来定义那些会影响实用类的 design token 的指令。命名规则中通常包含命名空间,例如 --color-*--font-*--spacing-*--breakpoint-* 等。每个 namespace 通常对应一种或一组 utilityvariant 的生成逻辑。

需要注意的是,这些变量的定义必须在顶层(非嵌套在选择器内、非嵌套在 media query 内)才能被 Tailwind 正确识别为 theme variables

步骤 2:识别并生成 CSS 自定义属性

在编译阶段,Tailwind 会把你在 @theme 中定义的变量转换成 CSS 自定义属性(custom properties),输出通常在 :root 或主题作用域中。也就是说,这些变量会变成浏览器可识别的 --color-primary--spacing-base 等。

简化后的生成示例可能是:

:root {
  --color-primary: #3490dc;
  --color-secondary: #ff8800;
  --breakpoint-lg: 1024px;
  --spacing-base: 1rem;
  /* 其他变量 ... */
}

步骤 3:根据命名空间生成对应的 utility classes 和 variants

这一步是整个映射机制的关键:Tailwind 会根据那些 namespace 变量来决定哪些实用类需要生成。换句话说,变量不仅只是值,它们也决定了哪些 class 是存在的。

举几个具体的例子:

  • 如果你定义了 --color-primary(在 --color-* 命名空间中),那么 Tailwind 会生成 .bg-primary.text-primary.border-primary.fill-primary 等与颜色相关的 utility
  • 如果你定义了 --breakpoint-lg,那么 .lg: 这个 responsive variant 会相对于这个断点存在。比如在 HTML 中你可以写 lg:text-xl,只有在视口宽度大于等于 --breakpoint-lg 时才会应用该样式
  • 如果你定义了 --spacing-basespacing 相关的类(如 p-<n>m-<n>gap-<n> 等)就会基于这个变量(spacing scale)来生成。Tailwind 默认会生成基于 spacing scalemarginpaddinggapwidth/height 等类,这些生成会参考 theme 变量

所以 namespaceutilities / variants 是映射的规则。具体映射关系如下表所示:

命名空间(namespace) 实用类 / variant 类型可能的映射
--color-* 背景色 background、文本颜色 text、边框色 border、填充 fill/stroke 等颜色相关的类
--font-* font-family utilities,例如 font-sansfont-serif
--text-*--text-size-* font-size utilities,如 text-xl
--spacing-* margin / padding / width / height / gap / inset 等与大小、间距相关的实用类
--breakpoint-* 响应式变体(breakpoints),如 sm:...md:...lg:...

步骤 4:CSS 输出和类的形式

在编译输出的 CSS 文件中,会有两部分内容:

  • :root 或主题基础作用域下定义所有被识别的 theme variablesCSS 自定义属性
  • utilities(以及 base / components 层)中,Tailwind 为每个被 theme-variable 驱动的实用类生成对应的 CSS 规则,这些规则使用变量值或者直接映射变量

例如,如果定义了 --color-primary,会生成 .bg-primary { background-color: var(--color-primary); } 或等效的方式。也可能生成 opacity 可变的版本(如 .bg-primary/50)等。

另外,类似 breakpoints 会在 media query 中生成对应 variantclass。比如定义 --breakpoint-lg,那么在 @media (min-width: var(--breakpoint-lg)) { ... } 中会输出 .lg:bg-primary.lg:text-xl 等类。

步骤 5:页面中的使用方式

在页面或模板中,开发者使用 Tailwind utility class 名称。例如:

<div class="bg-primary text-secondary p-4 lg:text-xl">Hello</div>

解析这行代码:

  • bg-primary 会应用 background-color: var(--color-primary)
  • text-secondary 会应用 color: var(--color-secondary)
  • p-4 会应用 padding: calc(var(--spacing-base) * 4) 或类似计算(取决于 spacing 命名空间的定义方式)
  • lg:text-xl 会在大于等于 --breakpoint-lg 的视口上应用 text-xl

步骤 6:主题切换和作用域变量重写

因为主题变量是 CSS 自定义属性,你可以在不同作用域或基于某些属性、数据属性、暗黑模式、亮色模式等重写这些变量的值,从而用同样的 utility 类名产生不同的视觉样式。

示例:

/* 默认 / light 模式 */
@theme {
  --color-primary: #3490dc;
  --color-secondary: #ff8800;
}

/* 暗黑模式或其他 theme 作用域 */
[data-theme="dark"] {
  --color-primary: #0a2239;
  --color-secondary: #ff5500;
}

页面中使用 .bg-primary 的地方会根据 data-theme 的值决定实际背景色。这样类名不变,但变量值会动态变化。

其他细节和边缘情况

inline 选项:如果你定义 @theme inline { ... },则某些 utility 类会直接写入变量值而不是引用变量,例如 .font-sans { font-family: Inter, sans-serif; } 而不是 font-family: var(--font-sans)。这个主要影响变量引用的方式和层次。

静态生成 vs 动态按需生成:Tailwind 会扫描你项目中用到的 class,然后只生成这些所需的 utilities 和对应的媒体查询 / variants,从而减小最终 CSS 大小。变量虽然都在 :root(或主题作用域)定义,但 utility 类如果没有被使用,不会生成对应规则。

arbitrary values:有时候你可能要用一个不在 theme 中的值,这种情况下可以使用 [...] 的语法,例如 bg-[#abcdef] 或者 w-[calc(var(--spacing-base) * 3 + 1rem)] 等,这样会跳过 theme 类的生成逻辑,直接生成或内联这些值。

流程图

为了帮助理解,下面是一个流程图,展示从定义变量到页面生效的完整流程:

20251222142708

设计动机和优势

理解这个映射流程之后,你会明白 Tailwind v4 这样设计的动机与优势:

统一定义和 CSS-first:将设计变量(design tokens)定义在 CSS 中,使整个样式系统更接近 CSS 原生工作流程,无需 JS 配置累赘。

变量暴露和运行时可用性:变量是原生 CSS custom properties,可以在运行时被引用、覆盖、修改(例如主题切换、样式插值、JS 动态样式等),不仅仅在编译阶段。

按需生成:只生成你实际用到的 class,避免生成一大堆冗余 CSS。媒体查询和变体也只有在需要时生成,这样最终 bundle 文件更小。

灵活性与可扩展性:你可以扩展命名空间,新增变量,重写默认主题,实现多个主题,实现暗黑模式等。并且 arbitrary values 给了例外情况下的自由度。

总结

变量与页面建立映射的过程可以总结为:

  1. 定义 theme 变量(design tokens
  2. Tailwind 根据这些变量创建 CSS 自定义属性 + utility classes / variants
  3. 页面通过 class 使用这些 utilities 或直接用 var(...) 引用变量
  4. 若重写变量或在不同作用域里变量的值不同,可实现主题切换等行为

通过这种机制,Tailwind CSS v4 实现了设计系统与样式输出的无缝衔接,既保持了灵活性,又提供了强大的主题定制能力。

【性能监控】别只做工具人了!手把手带你写一个前端性能检测SDK

2025年12月23日 08:17

经常听到前端同学抱怨:

  • 面试聊到性能优化,FCP/LCP/CLS 这些名词都能说;但如果让“先不依赖第三方库,设计一个监控 SDK”,一时间真不知如何下手。
  • 老板说页面慢,我们把图片压了、代码拆了;他追问“到底快了多少秒?”——手里没一套可对齐的数据。
  • 本地跑得飞快,上线后却被用户说卡顿,现场复现不了,这种“抓不住问题”的感觉挺挠头。

不卖关子: Google 官方推出的 web-vitals 库本身很棒,能一键采集 Core Web Vitals(核心 Web 指标)。但本文想带你跳出“调包侠”的舒适区,把控制权拿在自己手里:

  • 要归因:指标异常时,不光要分数,还要知道是哪个 DOM 元素慢、哪段脚本卡。
  • 要适配:在 SPA(单页应用)路由切换时,能灵活地分段统计与结算。
  • 要透明:从零使用 PerformanceObserver,把“黑盒”变成“白盒”。

接下来,我们将手把手打造一个小而透明的性能监控 SDK,一起把“凭感觉优化”升级成“有数据、有依据的优化”。

指标扫盲:别被指标缩写吓跑了

别被那一堆性能指标缩写整蒙了。对于前端来说,核心就关注三件事:快不快(Loading)卡不卡(Interaction)稳不稳(Visual Stability)

1. Loading (加载):别让我等太久!

用户最怕面对一片白屏。这个阶段我们关注三个瞬间:

指标 全称 人话解释 典型场景 及格线
FP First Paint (首次绘制) 屏幕亮了 浏览器开始渲染任何东西的时刻(哪怕只是背景色)。 屏幕从纯白变成浅灰色,虽然啥内容都没有,但你知道“它活着”。 -
FCP First Contentful Paint (首次内容绘制) 看到内容了 浏览器渲染出第一个内容(文字、图片、Logo)的时刻 页面上终于蹦出了“Loading...”文字或者导航栏 Logo。 < 1.8s
LCP Largest Contentful Paint (最大内容绘制) 主角登场 视口内可见的最大图片或文本块渲染完成的时刻。这是 Google 最看重的加载指标 淘宝详情页的大图终于刷出来了,你终于看清商品长啥样了。 < 2.5s

2. Interaction (交互):别卡得像 PPT!

东西加载出来了,用户开始点了,这时候最怕卡顿。

指标 全称 人话解释 典型场景 及格线
FID First Input Delay (首次输入延迟) 第一下没反应? 用户第一次与页面交互(点击按钮、链接)到浏览器真正开始处理这个事件的时间差 兴奋地去点“登录”按钮,结果点了没反应,过了 1 秒钟按钮才变色。 < 100ms
INP Interaction to Next Paint (下次绘制交互) 越用越卡? FID 的升级版。它不仅看第一下,还看你浏览全程中所有交互的延迟,取最慢的那几次 每输入一个字,输入框都要卡顿一下才能显示出来,有种“粘滞感”。 < 200ms
Long Task Long Task (长任务) 谁在堵路? 任何执行时间超过 50ms 的 JavaScript 任务 主线程就像单行道,大卡车(复杂计算)一堵,后面的点击事件全得排队。 < 50ms

:长任务不光交互期会出现,加载时也常见;它会让白屏更久、把 TBT(总阻塞时间)拉高。我们归到“交互”里讲,是因为它最直接拖慢的是点击/输入的响应(FIDINP)。

3. Visual Stability (视觉稳定性):别乱动!

这可能是最让人抓狂的体验。

指标 全称 人话解释 典型场景 及格线
CLS Cumulative Layout Shift (累积布局偏移) 页面布局在加载过程中发生意外移动的程度 你正准备点“取消订单”,顶部广告突然插队把页面往下挤,害你点成了“支付”。这种布局的意外位移,就叫 CLS。分数越低,页面越稳 < 0.1

读完这篇,你将收获什么?

手写 SDK 不只是为了“造轮子”,更是为了把黑盒变成白盒。你将深入掌握:

  1. 底层 API 实战:拒绝盲猜!从零使用 PerformanceObserverResource Timing 等原生 API,彻底搞懂数据从哪来。
  2. 全链路指标体系:覆盖 Loading (FCP/LCP)、Interaction (FID/INP/Long Task)、Visual Stability (CLS) 及网络耗时,给应用做一次全身体检。
  3. 插件化架构设计:学习如何解耦 采集、处理、上报、配置 四大模块,打造一个高扩展、易维护的工程化 SDK。
  4. 关键路径归因:不仅要监控“慢”,更要定位“为什么慢”。通过元素选择器与长任务归因,直击性能瓶颈。

系统架构与功能设计

为了保证 SDK 的轻量性与可扩展性,我们采用了分层架构设计。整个系统由 核心采集层数据处理层数据上报层配置中心 四大模块组成。

简单说,就是把活儿分得明明白白:采集模块只管抓数据,处理模块只管处理数据,上报只管发送数据到服务端,配置只管配置

image.png

架构要点:

  • 采集层:Loading / Interaction / VisualStability / Network(各司其职)
  • 处理层:数据清洗、格式化、核心指标归因(找到卡顿的 DOM 或脚本)
  • 上报层:支持 sendBeacon + fetch keepalive 双保险,确保数据不丢失
  • 配置中心:环境区分、采样率控制、日志开关

1. 核心采集层 (Collectors) —— 用户体验四步走

这是 SDK 的心脏。咱们不按技术分类,按用户实际感受来分,对应源码的四个核心目录:

  • Step 1: Loading (看得见吗?) —— src/loading

    • 核心目标:紧盯白屏时间与关键内容渲染。
    • 实现手段:利用 Paint TimingLargest Contentful Paint API,捕获 FP、FCP、LCP 及页面加载完成时机。
  • Step 2: Interaction (好用吗?) —— src/interaction

    • 核心目标:监控用户交互的响应速度与流畅度。
    • 实现手段:通过 Event Timing 监听点击延迟(FID/INP),并用 Long Task API 揪出导致主线程阻塞的元凶。
  • Step 3: Visual Stability (稳不稳?) —— src/visualStability

    • 核心目标:防止页面布局“乱动”,提升视觉舒适度。
    • 实现手段:结合 Layout Shift API 计算布局偏移(CLS),并针对 SPA 应用做特殊的会话窗口计算。
  • Step 4: Network (为啥慢?) —— src/network

    • 核心目标:定位资源加载与接口响应的瓶颈。
    • 实现手段:复用 Resource Timing 接口,深度解析静态资源与 XHR/Fetch 请求的 DNS、TCP、TTFB 等关键耗时。

2. 数据处理层 (Processor) —— 数据清洗工

  • 洗数据:浏览器原生的 API 给的数据太杂太乱,咱们得把它洗成干净统一的 JSON 格式,方便后端存。
  • 加料:光知道卡了没用,还得知道哪儿卡了。比如 LCP 慢,我们会自动把那个慢元素的 DOM 选择器加上;长任务卡,我们会尝试解析出是哪个脚本干的。

3. 数据上报层 (Reporter) —— 快递员

  • 使命必达:用户都要关页面了,数据还没发出去?首选 Navigator.sendBeacon(兼容性好);在现代浏览器中也可使用 fetch(..., { keepalive: true }) 以支持自定义 Header
  • 省流模式:平时不重要的日志先攒一攒(批量上报),关键的报错立刻发(实时上报),给用户的流量省点钱。

4. 配置中心 (Configurator) —— 遥控器

  • 随心所欲:通过 options 参数控制。开发环境想看日志?开!生产环境只报错误?关!采样率设多少?你说了算。

核心代码实现

  1. 主入口 (index.ts)

入口文件负责对外暴露初始化方法,串联各个模块。

  • 职责明确init() 方法一键启动所有监控,按用户体验生命周期(加载 -> 交互 -> 网络)依次调用。

  • 配置中心:构造函数接收 options,实现配置合并(如开发模式开启日志)。

  • 模块解耦:不直接写监控逻辑,而是通过 import 引入 loading/interaction/network 三大模块的 startXXX 函数,各司其职。

  • 示例代码

import { startFP, startFCP, startLCP, startLoad } from './loading';
import {
  startCLS,
  startFID,
  startInteraction,
  startLongTask,
} from './interaction';
import { startEntries, startRequest } from './network';

export default class PerformanceMonitor {
  constructor(options = {}) {
    this.options = {
      log: true, // 开发模式下开启日志
      ...options,
    };
  }

  init() {
    // 1. 页面加载与渲染 (Loading & Rendering)
    startFP();
    startFCP();
    startLCP();
    startLoad();

    // 2. 交互响应 (Interaction)
    startFID();
    startInteraction(); // INP
    startLongTask();

    // 3. 视觉稳定性 (Visual Stability)
    startCLS();

    // 4. 资源与网络 (Resource & Network)
    startEntries();
    startRequest();

    console.log('Performance Monitor Initialized');
  }
}

2. Loading 监控 (loading/index.js)

这部分主要负责捕捉页面从白屏到内容出现的关键时刻。我们把这个过程拆解为三个关键动作:变色 (FP) -> 有内容 (FCP) -> 主角登场 (LCP)

  1. FP (First Paint):屏幕变色了(不白屏了)。
  2. FCP (First Contentful Paint):看见字或图了(有内容了)。
  3. LCP (Largest Contentful Paint):主角(大图/正文)出来了。
  4. Load:资源全加载完了。

(1) FP & FCP:屏幕终于亮了

先讲道理:

  • 为什么要一起抓这两个指标是一起抓的? 因为它俩在浏览器眼里都属于 paint(绘制)类型,都是“第一眼”的感觉。
  • 怎么抓?PerformanceObserver 蹲守。
  • 避坑指南(Buffered 标志): 这是一个极其容易被忽略的参数!
    • SDK 初始化往往比页面渲染晚。
    • 如果你不开启 buffered: true,就像你 10 点才去蹲守 9 点的日出,永远蹲不到。
    • 开启后,浏览器会把过去发生过的指标打包补发给你。

代码实战:

// src/loading/FP.js (FCP 逻辑完全一致,只需改 entry.name)
export function startFP() {
  const entryHandler = (list) => {
    for (const entry of list.getEntries()) {
      // 筛选 'first-paint'
      if (entry.name === 'first-paint') {
        observer.disconnect(); // FP 一辈子只发生一次,抓到就撤,省内存

        const json = entry.toJSON();
        console.log('FP Captured:', json);

        // 上报数据结构
        const reportData = {
          ...json,
          type: 'performance',
          name: entry.name,
          pageUrl: window.location.href,
        };
      }
    }
  };

  // 1. 创建观测者
  const observer = new PerformanceObserver(entryHandler);

  // 2. 开始蹲守 'paint' 频道
  // buffered: true 是关键,确保能拿到 SDK 初始化之前的记录
  observer.observe({ type: 'paint', buffered: true });

  // 3. 返回清理函数
  return () => observer.disconnect();
}

(2) LCP:主角登场

先讲道理:

  • 为什么 LCP 会变? 浏览器渲染是渐进式的。它刚画了一行字,觉得是 LCP;过了一会图片加载出来,它又觉得图片是 LCP。所以 LCP 可能会触发多次,我们通常取最后一次

  • 光有时间够吗? 不够!老板问你“为什么 LCP 慢”,你不能光说“慢了”,你得告诉他是哪张图慢了。所以我们需要记录 element 并转成选择器。

代码实战:

// src/loading/LCP.js
import { getElementSelector } from '../../util/index';

export function startLCP() {
  const entryHandler = (list) => {
    for (const entry of list.getEntries()) {
      const json = entry.toJSON();
      const reportData = {
        ...json,
        lcpTime: entry.startTime, // 记录时间
        // 核心:利用 element 属性计算出 CSS 选择器,帮你定位是哪个元素慢
        elementSelector: getElementSelector(entry.element),
        type: 'performance',
        name: entry.name,
        pageUrl: window.location.href,
      };
      console.log('LCP Update:', reportData);
    }
  };

  const observer = new PerformanceObserver(entryHandler);
  // 同样开启 buffered,防止漏掉
  observer.observe({ type: 'largest-contentful-paint', buffered: true });
  return () => observer.disconnect();
}

(3) 加载完成

先讲道理:

  • 为什么不用 window.onload 现在的 SPA(单页应用)和浏览器的“往返缓存”(BFCache)机制,让 onload 变得不那么靠谱(有时候回退页面不会触发 onload)。
  • 为什么用 pageshow 它能覆盖更多场景,无论你是新打开的,还是后退回来的,它都会触发。
  • 为什么套一层 requestAnimationFrame pageshow 触发时,浏览器可能还在忙着处理最后的渲染。我们用 rAF 往后稍一稍,让主线程先喘口气,获取的时间更精准,也不影响页面交互。

代码实战:

// src/loading/load.js
export function startLoad() {
  const onPageShow = (event) => {
    // 往后推一帧,避免抢占主线程
    requestAnimationFrame(() => {
      ['load'].forEach((type) => {
        const reportData = {
          type: 'performance',
          subType: type,
          pageUrl: window.location.href,
          // 计算相对时间
          startTime: performance.now() - event.timeStamp,
        };
        console.log('Load Captured:', reportData);
      });
    });
  };

  window.addEventListener('pageshow', onPageShow, true);

  return () => {
    window.removeEventListener('pageshow', onPageShow, true);
  };
}

3. Interaction 监控

交互性能直接决定了用户觉得你的页面“顺不顺手”。这里我们重点关注 FID/INP (响应速度)Long Task (主线程阻塞)

(1) FID & INP:点击要灵敏

先认个脸:

  • FID (First Input Delay)首次输入延迟。看的是“第一印象”。用户刚进页面,第一次点按钮或者链接时,浏览器是不是在发呆?延迟了多久才理你?

  • INP (Interaction to Next Paint)交互到下一次绘制。看的是“全程表现”。不管你是刚来还是快走,只要在页面上点的任何一下卡了,INP 都会记下来,最后取最慢的那几次算总账。

三句话讲明白

  • 相亲 vs 过日子:FID 就像相亲,只要第一眼(第一次交互)没问题,后面拉胯它也不管;INP 就像过日子,日久见人心,它会盯着你全程的每一次表现,哪怕你前面表现再好,最后一下卡了,分也高不了。
  • 只管排队 vs 全程跟踪:FID 只管“排队时间”(你点下去到浏览器开始处理的时间);INP 管得更宽,它包括“排队 + 处理 + 渲染”的全过程。所以 INP 更能代表用户的真实感受。
  • 谁在堵路:想象你去餐厅吃饭(点击),服务员(主线程)正忙着给隔壁桌上菜(执行 JS),没空理你。你等服务员转过身来理你的这段时间,就是 FID。如果服务员理你了,但做菜慢(处理逻辑复杂),上菜也慢(渲染慢),这整个过程太久,INP 就会炸。

直接上代码:FID

// src/interaction/FID.js
export function startFID() {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // 核心公式:处理开始时间 - 点击时间 = 延迟时间
      const delay = entry.processingStart - entry.startTime;
      console.log('FID:', delay, entry.target);
      observer.disconnect(); // FID 只看第一下,拿到就撤
    }
  });
  observer.observe({ type: 'first-input', buffered: true });
}

代码怎么理解?

  • processingStart - startTimestartTime 是你手指按下的瞬间,processingStart 是代码终于开始跑的瞬间。这中间的差值,就是浏览器因为“忙不过来”而让用户等待的时间。
  • disconnect():FID 全称是 First Input Delay,既然是 First,抓到一次就可以收工了,省点内存。
  • buffered: true:防止 SDK 加载晚了。万一用户手快,脚本还没加载完就点了,这个参数能把那次点击记录补发给你。

直接上代码 INP:

// src/interaction/INP.js
export function startINP() {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // 不断收集,因为我们要找最慢的那个
      // 这里只是简单的打印,实际开发中需要一个算法来取 top 几位
      console.log('Interaction Latency:', entry.duration, entry.target);
    }
  });
  // 注意:INP 监听的是 'event' 类型,不是 'first-input'
  observer.observe({ type: 'event', durationThreshold: 16, buffered: true });
}

代码怎么理解?

  • INP 代码type: 'event'。这里我们不断监听,不断打 log。实际场景里,你需要维护一个数组,把耗时最长的几次交互存下来,最后上报那个最慢的。
  • durationThreshold: 16:这是个优化参数。意思是“小于 16ms(一帧)的交互我就不看了”,省得数据太多刷屏。

踩坑提醒

  • 及格线在哪?(Google 标准)

    • FID (第一印象)100ms 100ms 以内都是优秀,超过 300ms 用户就会觉得“破网站怎么点不动”。
    • INP (全程体验)200ms 以内算优秀,超过 500ms 用户就想砸键盘了。

    为啥 FID 和 INP 的标准不一样?

    • 因为 FID 只算排队时间(你还在门口等服务员),而 INP 算的是全套服务时间(排队 + 吃饭 + 买单)。INP 包含的阶段更多(处理 + 渲染),所以 Google 给的宽容度自然也更高。
  • INP 才是未来:Google 已经在 2024 年正式用 INP 取代了 FID。想监控 INP?把 type 改成 event,然后别断开 (disconnect),一直记到页面走人就行。

  • 谁在堵路:通常是因为主线程在忙着执行巨大的 JS 脚本(Long Task),导致没空搭理用户的点击。

(2) Long Task:主线程别堵车

先认个脸:

  • Long Task (长任务):只要执行时间超过 50 毫秒 的任务,都叫长任务。
  • 危害:浏览器的主线程是“单线程”的,一次只能干一件事。如果一个任务霸占了主线程太久,其他的点击、滚动、渲染就都得排队,用户就会觉得“卡死”了。

三句话讲明白

  • 独木桥效应:主线程就像一座独木桥。平时过的小车(短任务)很快,大家都有路走。突然来了一辆大卡车(长任务),把桥堵得死死的,后面的车(用户交互)全被堵住了。
  • 50ms 分界线:为啥是 50ms?
    • 100ms 法则:心理学上,用户点击后 100ms 内有反应就算“即时”。
    • 对半分:Google 把这 100ms 切成两半:50ms 给你跑代码50ms 留给浏览器画画。这样加起来刚好 100ms。
    • 高刷屏怎么办:虽然 120Hz 屏幕每帧只有 8ms,但在 Web 标准里,50ms 依然是那个平衡了“体验”和“代码复杂度”的安全及格线。
  • 抓元凶:监控 Long Task 不光是为了知道“卡了”,更是为了知道“谁卡了”。API 会告诉你是因为哪个 iframe 或者哪个脚本文件导致的。

直接上代码:

// src/interaction/longTask.js
export function startLongTask() {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.duration > 50) {
        // 抓到个慢的!看看是谁
        // attribution 里面藏着“罪魁祸首”的名字
        console.log('LongTask:', entry.duration, entry.attribution);
      }
    }
  });
  // 开启监听,buffered 同样重要
  observer.observe({ type: 'longtask', buffered: true });
}

代码怎么理解?

  • entry.duration > 50:虽然 API 只会返回长任务,但为了保险(或者你想设置更严格的 100ms 阈值),可以再判断一下。
  • entry.attribution:这是个数组,里面会告诉你这个任务来自哪个 container(比如是当前窗口,还是广告 iframe),有的浏览器还能精确到 scriptURL(脚本文件路径)。

踩坑提醒

  • 拆分任务:遇到 Long Task 咋办?拆! 把一个大函数拆成几个小函数,用 setTimeout 或者 requestIdleCallback 分批执行,给主线程留出喘息的机会(让“大卡车”变成“小车队”)。
  • 广告背锅:很多时候你会发现 Long Task 都是广告脚本(iframe)带来的。这种时候……你可以甩锅给广告商,或者延迟加载广告。
  • 兼容性:这个 API 兼容性还不错,但还是老规矩,Safari 可能比较高冷(较新版本才支持)。

4. Visual Stability (视觉稳定性) 监控

这可能是最让人抓狂的体验。

CLS:页面别乱动

  • 别冤枉好人:用户点了个按钮展开菜单,布局肯定会变,这叫“符合预期”
  • 聚沙成塔:CLS 不是一次性的,它是“积分制”。用户在页面上待多久,这期间所有的小抖动都要加起来,算总账。
  • 秋后算账:千万别抖一下报一下!CLS 是“长跑比赛”,不到终点(页面关闭/隐藏)不知道最终成绩。必须等用户关页面或者切后台的时候,把最后的总分一次性报上去。
// src/interaction/CLS.js
export function startCLS() {
  let clsValue = 0;
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // 核心:剔除用户交互(点击/输入)导致的预期偏移
      if (!entry.hadRecentInput) {
        clsValue += entry.value;
      }
    }
  });
  observer.observe({ type: 'layout-shift', buffered: true });

  const report = () => console.log('CLS Final:', clsValue);

  // 双重保险:兼容各类浏览器的卸载场景
  window.addEventListener('pagehide', report, { once: true });
  document.addEventListener(
    'visibilitychange',
    () => {
      if (document.visibilityState === 'hidden') report();
    },
    { once: true }
  );
}

代码怎么理解?

  • 怎么区分是“好动”还是“乱动”?

    刚刚我们说用户交互(比如点按钮展开菜单)导致的布局变化不能算 CLS。那怎么判断呢? 浏览器提供了 hadRecentInput 字段。只要用户最近 500ms 内有过点击或按键,浏览器就会把这个字段标为 true。咱们代码里必须把这些“良民”过滤掉,只抓那些“莫名其妙”的抖动。

  • 分数怎么算?

    entry.value 就是每一次抖动的分数。比如一张大图突然插进来,把文字挤下去 100px,可能就贡献了 0.05 分。我们要做的就是一个无情的“加法器”,把这些分数全加起来。

  • 为啥要监听两个卸载事件?

    visibilitychangepagehide 都是用来监听页面关闭/隐藏的。为啥要搞两个?因为浏览器脾气不一样:有的喜欢 pagehide(比如 Safari),有的推荐 visibilitychange。为了保证数据不丢,咱们搞个“双保险”,谁先触发就算谁的。

    • 为啥不用 beforeunload? 早年间确实流行用 beforeunloadunload,但现在它们不靠谱了,尤其是在手机上。用户直接划掉 App、切后台,这些事件经常不会触发。而且它还会阻止浏览器做“往返缓存”(BFCache),拖慢页面后退速度。所以现在的标准姿势就是 visibilitychange + pagehide

踩坑提醒

  • 成绩线:CLS < 0.1 很好,> 0.25 需要重点优化
  • 动态内容要“留坑位”:骨架屏/固定尺寸,能明显降低位移
  • 广告/懒加载图片经常是元凶,优先排查

💡 进阶小贴士: 这里为了演示原理,我们使用了简单的累加法。但在生产环境中(特别是 SPA 单页应用),Google 推荐使用 Session Window(会话窗口) 算法

先说痛点(累加法的 Bug): 以前的 CLS 算法是“无限累加”的。这就好比你在一个页面看了 10 分钟书,这期间广告每分钟抖一下(假设每次 0.01 分)。等你关页面时,总分就是 0.01 * 10 = 0.1,及格。 但如果你看了 10 个小时呢?总分就变成了 6.0,不及格!

这对 SPA(单页应用)尤其不公平:

  • 传统网页:每次跳转页面都会刷新,指标清零重算,想拿低分(好成绩)很容易。
  • SPA 应用:用户在“首页 -> 列表页 -> 详情页”之间切换时,浏览器并没有刷新。如果你用累加法,这三个页面的抖动会全部加在同一个头上。用户用得越久,CLS 分数就越差,但这显然不符合实际体验(用户只关心当前看到的页面抖不抖)。

新算法(会话窗口): 为了解决这个问题,Google 改成了“会话窗口”制。 想象一下,我们把每一次抖动看作一次“余震”。如果几次余震挨得很近(间隔小于 1 秒),并且总持续时间没超过 5 秒,我们就把它们算作一次大地震

无论你页面开了多久,我们只取震级最高的那一次大地震作为最终成绩。这样,不管你待 10 分钟还是 10 小时,只要没有发生剧烈的连续抖动,你的 CLS 分数都是优秀的。

5. Network 监控:查查谁在拖后腿

先认个脸:

  • Resource Timing (资源计时):专门管资源加载的。不管是图片、CSS、JS 文件,还是接口请求 (XHR/Fetch),只要是从网络下载的东西,它都能记一笔。
  • 核心指标:除了总耗时 (duration),还能细到 DNS 解析多久、TCP 建连多久、首字节时间 (TTFB) 等等。

三句话讲明白

  • 查快递:你买东西(请求资源),想知道为什么这么慢?是卖家发货慢(TTFB),还是路上堵车(下载慢)?Resource Timing 就是那个详细的物流单。
  • 不只是图片:别被名字骗了,它不光管图片 CSS,你的 fetch 请求、axios 请求,只要走了网络,它都能监控到。
  • 严防死守:浏览器为了安全,对于跨域的资源(比如你用了百度的图片),默认只告诉你“用了多久”,不告诉你“怎么用的”(DNS/TCP 细节),除非对方给了通行证。

直接上代码:

export function startEntries() {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // resource 类型包含了 img, script, css, fetch, xmlhttprequest, link 等
      if (entry.entryType === 'resource') {
        console.log(
          'Resource:',
          entry.name,
          entry.initiatorType,
          entry.duration
        );
      }
    }
  });
  // 同样记得 buffered: true,防止漏掉页面刚开始加载的那些资源
  observer.observe({ type: 'resource', buffered: true });
}

代码怎么理解?

  • entryType === 'resource':这个频道包罗万象。图片 (img)、样式 (css)、脚本 (script) 甚至你的接口调用 (fetch/xmlhttprequest) 都在这儿。
  • initiatorType:这个字段告诉你资源是谁发起的。是 <img src="..."> 发起的?还是 fetch() 发起的?一看便知。

进阶用法:监控接口 (API) 耗时详情

有时候我们不关心图片资源,而是重点关注后端接口的响应耗时。通过过滤 fetchxmlhttprequest,我们不仅能知道接口慢不慢,还能知道慢在哪里(是 DNS、TCP 还是服务端处理)。

export function startRequest() {
  const entryHandler = (list) => {
    const data = list.getEntries();
    for (const entry of data) {
      // 过滤出 API 请求 (Fetch 和 XHR)
      if (
        entry.initiatorType === 'fetch' ||
        entry.initiatorType === 'xmlhttprequest'
      ) {
        const reportData = {
          name: entry.name, // 请求地址
          type: 'performance',
          subType: entry.entryType,
          sourceType: entry.initiatorType,
          duration: entry.duration, // 请求总耗时
          dns: entry.domainLookupEnd - entry.domainLookupStart, // DNS 解析耗时
          tcp: entry.connectEnd - entry.connectStart, // TCP 连接耗时
          ttfb: entry.responseStart - entry.requestStart, // 首字节响应时间 (服务端处理时间)
          transferSize: entry.transferSize, // 传输字节数
          startTime: entry.startTime, // 请求开始时间
          pageUrl: window.location.href,
        };
        console.log('Network Request:', reportData);
      }
    }
  };

  // 这里不调用 disconnect(),以便持续监听后续产生的网络请求
  const observer = new PerformanceObserver(entryHandler);
  observer.observe({ type: 'resource', buffered: true });
}

代码怎么理解?

  • TTFB (首字节时间) —— “厨师做菜慢”

    • 公式:responseStart - requestStart
    • 大白话:你点完菜(发送请求)到服务员端上第一盘菜(收到第一个字节)的时间。
    • 谁背锅:这时间长,说明后端处理慢(查数据库慢、业务逻辑太复杂)。跟网速没啥关系,纯粹是“后厨”忙不过来。赶紧截图甩给后端开发!
  • TCP & DNS —— “找路和打招呼”

    • DNS:就像查电话本。要把 api.example.com 变成 IP 地址。如果这块慢,说明用户离你的服务器太远,该上 CDN 了。
    • TCP:就像见面握手。客户端和服务器得先“握手”建立连接才能说话。如果是 HTTPS,还得再加一轮 SSL 握手(查身份证)。这一套下来也得耗不少时间。
  • TransferSize (传输大小) —— “运货量”

    • 大白话:接口到底吐了多少数据给你?
    • 场景:有时候接口慢,不是因为后端慢,而是因为数据量太大。比如一个列表接口,一下子返回了 10000 条数据,足足 5MB。光是下载这 5MB 就得好几秒。这时候就得让后端搞“分页”了。

踩坑提醒

  • 跨域拿不到细节:这是最常见的坑。看到 DNS 时间是 0 别奇怪,八成是跨域了且没加 Timing-Allow-Origin 头。这是浏览器为了保护隐私。
  • 别全报:一个页面可能有上百个资源,全报上去服务器受不了。建议设个门槛(比如只报 > 1 秒的),或者只报核心的 JS/CSS。
  • 接口监控:很多人不知道 fetch 请求也能在这里抓到。其实用它来监控后端接口性能,比自己封装 axios 拦截器要准得多,因为它算的是浏览器底层的真实时间。

6. 必备工具函数:定位神器 (util/index.js)

最后,我们得有个工具能帮我们“指路”。只告诉老板“图片慢”没用,你得告诉他是“哪个图片慢”。

getElementSelector 就是这个“定位神器”。它能把一个 DOM 元素转换成 CSS 选择器(比如 body > div#app > h1),让你直接在代码里找到它。

// src/util/index.js
export function getElementSelector(element) {
  if (!element || element.nodeType !== 1) return '';

  // 如果有 id,直接返回 #id
  if (element.id) {
    return `#${element.id}`;
  }

  // 递归向上查找
  let path = [];
  while (element) {
    let name = element.localName;
    if (!name) break;

    // 如果有 id,拼接后停止
    if (element.id) {
      path.unshift(`#${element.id}`);
      break;
    }

    // 加上 class
    let className = element.getAttribute('class');
    if (className) {
      name += '.' + className.split(/\s+/).join('.');
    }

    path.unshift(name);
    element = element.parentElement;
  }

  return path.join(' > ');
}

7. 自定义指标:代码秒表

除了浏览器给的指标,有时候我们想知道某个具体函数到底跑了多久,或者滚动列表卡不卡。这时候就需要我们自己造个“秒表”。

(1) 函数耗时监控 (Wrapper)

三句话讲明白

  • 不改业务代码:不用在每个函数里写 startend,用这个“高阶函数”包一下就行。
  • 支持异步:不管是普通的 for 循环,还是 await 数据库查询,都能监控。
  • 自动抓报错:如果函数跑挂了,它还能顺便把错误信息记下来。

直接上代码:

/**
 * 同步函数计时器
 * 用法:const newData = timeFunction('处理数据', processData, rawData);
 */
export const timeFunction = (name, fn, ...args) => {
  const start = performance.now();
  try {
    const result = fn(...args);
    const duration = performance.now() - start;
    console.log(`Function [${name}]:`, duration.toFixed(2) + 'ms');
    return result;
  } catch (error) {
    console.error(`Function [${name}] Error:`, error);
    throw error; // 别吞掉错误,继续抛出
  }
};

/**
 * 异步函数计时器 (Promise)
 * 用法:const user = await timeAsyncFunction('获取用户', fetchUser, id);
 */
export const timeAsyncFunction = async (name, fn, ...args) => {
  const start = performance.now();
  try {
    const result = await fn(...args); // 等待异步完成
    const duration = performance.now() - start;
    console.log(`Async Function [${name}]:`, duration.toFixed(2) + 'ms');
    return result;
  } catch (error) {
    console.error(`Async Function [${name}] Error:`, error);
    throw error;
  }
};

(2) 滚动流畅度监控

三句话讲明白

  • 不看帧率看延迟:算 FPS 太复杂。我们就看最朴素的道理:我划了一下屏幕,浏览器多久才画出下一帧?
  • 搭便车 (rAF)requestAnimationFrame (rAF) 是浏览器的“渲染末班车”。我们在滚动事件里跳上这趟车,等车到站了(渲染完成了),看看表,就知道这一路花了多久。
  • 抽查机制:滚动事件触发频率极高(一秒几百次)。我们没必要次次都查,每隔半秒(500ms)抽查一次“末班车”准不准点就行了。

直接上代码:

// src/interaction/scroll.js
export function startScroll() {
  let lastScrollLog = 0;
  const scrollMinInterval = 500; // ms,限流,每半秒检查一次

  const onScroll = () => {
    const start = performance.now();
    // 没到时间就别折腾,省点 CPU
    if (start - lastScrollLog < scrollMinInterval) return;

    lastScrollLog = start;

    // 核心逻辑:预约下一帧渲染
    // 就像你对浏览器说:“兄弟,画完这帧叫我一声。”
    requestAnimationFrame(() => {
      const afterRAF = performance.now();
      const duration = afterRAF - start; // 这一帧到底花了多久?

      // 16ms 是 60Hz 屏幕的标准一帧时间
      // 如果超过 16ms,说明浏览器忙不过来了,这就叫“掉帧”
      if (duration > 16) {
        console.log('Scroll Lag:', duration.toFixed(2) + 'ms');
      }
    });
  };

  // passive: true 告诉浏览器“我不阻止滚动”,能让滚动更丝滑
  window.addEventListener('scroll', onScroll, { passive: true });
}

代码怎么理解?

  • 为什么要用 requestAnimationFrame
    • JS 是单线程的。当你触发 scroll 事件时,浏览器可能正忙着处理别的事(比如重排重绘)。
    • requestAnimationFrame 会把你的代码放到浏览器准备画下一帧的时候执行。
    • 所以,start 是你触发滚动的时间,afterRAF 是画面终于画出来的时间。这俩的差值 (duration) 越大,说明浏览器卡得越久,你感觉到的“顿挫感”就越强。

8. 数据上报(sender.ts)

收集到数据后,如何发给后端?这看似简单,实则暗藏玄机。

1. 核心痛点:页面关了,请求还没发完怎么办?

用户看完网页直接关掉(或者刷新跳转),这时候浏览器会无情地杀掉当前页面进程里所有正在跑的异步请求(XHR/Fetch)。

结果就是:监控数据还没发出去,就死在半路上了。

2. 解决方案

为了确保数据必达,我们采用一套组合拳:

  1. 首选 Navigator.sendBeacon

    它是专门为“页面卸载上报”设计的。

    特点:浏览器会在后台默默把数据发完,不阻塞页面关闭,也不会被杀掉。

  2. 次选 fetch + keepalive

    如果浏览器不支持 Beacon,或者你需要自定义 Header(Beacon 不支持自定义 Header),就用 fetch 并开启 keepalive: true

    特点:告诉浏览器“这个请求很重要,页面关了也请帮我发完”。

3. 代码实现

export const sendBehaviorData = (data: Record<string, any>, url: string) => {
  // 1. 包装数据:加上一些公共信息(比如 UserAgent,屏幕分辨率等)
  const dataToSend = {
    ...data,
    userAgent: navigator.userAgent,
    // screenWidth: window.screen.width, // 可选
  };

  // 2. 优先使用 sendBeacon (最稳,且不阻塞)
  // 注意:sendBeacon 不支持自定义 Content-Type,默认是 text/plain
  // 这里用 Blob 强制指定为 application/json
  if (navigator.sendBeacon) {
    const blob = new Blob([JSON.stringify(dataToSend)], {
      type: 'application/json',
    });

    // sendBeacon 返回 true 表示进入队列成功
    navigator.sendBeacon(url, blob);

    return;
  }

  // 3. 降级方案:使用 fetch + keepalive
  // 即使页面关闭,keepalive 也能保证请求发出
  fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(dataToSend),
    keepalive: true, // <--- 关键参数!防止页面关闭时请求被杀
  }).catch((err) => {
    console.error('上报失败:', err);
  });
};

3. 工程化构建配置

既然是 SDK,最好的分发方式当然是发布到 NPM。这样其他项目只需要一行命令就能接入你的前端错误监控系统。

这里我们选择 Rollup对代码进行打包,因为它比 Webpack 更适合打包库(Library),生成的代码更简洁。

3.1 package 配置 (package.json)

package.json 不仅仅是依赖管理,它还定义了你的包如何被外部使用。配置不当会导致用户引入报错或无法获得代码提示。

{
  "name": "performance-sdk",
  "version": "1.0.0",
  "description": "A lightweight performance monitoring SDK",
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
  "browser": "dist/index.umd.js",
  "type": "module",
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w"
  },
  "keywords": ["performance", "monitor", "sdk"],
  "license": "MIT",
  "files": ["dist"],
  "devDependencies": {
    "rollup": "^4.9.0",
    "@rollup/plugin-typescript": "^11.1.0",
    "@rollup/plugin-terser": "^0.4.0",
    "typescript": "^5.3.0",
    "tslib": "^2.6.0"
  }
}

💡 关键字段解读:

  • name: 包的“身份证号”。在 NPM 全球范围内必须唯一,发布前记得先去搜一下有没有重名。
  • 入口文件“三剑客”(决定了别人怎么引用你的包):
    • main: CommonJS 入口。给 Node.js 环境或老旧构建工具(如 Webpack 4)使用的。
    • module: ESM 入口。给现代构建工具(Vite, Webpack 5)使用的。支持 Tree Shaking(摇树优化),能减小体积。
    • browser: UMD 入口。给浏览器直接通过 <script> 标签引入使用的(如 CDN)。
  • files: 发布白名单。指定 npm publish 时只上传哪些文件(这里我们只传编译后的 dist 目录)。源码、测试代码等不需要发上去,以减小包体积。

3.2 TypeScript 配置 (tsconfig.json)

我们需要配置 TypeScript 如何编译代码,并生成类型声明文件(.d.ts),这对使用 TS 的用户非常友好。

{
  "compilerOptions": {
    "target": "es5", // 编译成 ES5,兼容旧浏览器
    "module": "esnext", // 保留 ES 模块语法,交给 Rollup 处理
    "declaration": true, // 生成 .d.ts 类型文件 (关键!)
    "declarationDir": "./dist", // 类型文件输出目录
    "strict": true, // 开启严格模式,代码更健壮
    "moduleResolution": "node" // 按 Node 方式解析模块
  },
  "include": ["src/**/*"] // 编译 src 下的所有文件
}

3.3 Rollup 打包配置 (rollup.config.js)

为了兼容各种使用场景,我们配置 Rollup 输出三种格式:

  1. ESM (.esm.js): 给现代构建工具(Vite, Webpack)使用,支持 Tree Shaking。
  2. CJS (.cjs.js): 给 Node.js 或旧版工具使用。
  3. UMD (.umd.js): 可以直接在浏览器通过 <script> 标签引入,会挂载全局变量。
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';

export default {
  input: 'src/index.ts',
  output: [
    { file: 'dist/index.cjs.js', format: 'cjs', sourcemap: true },
    { file: 'dist/index.esm.js', format: 'es', sourcemap: true },
    {
      file: 'dist/index.umd.js',
      format: 'umd',
      name: 'PerformanceSDK',
      sourcemap: true,
      plugins: [terser()],
    },
  ],
  plugins: [
    typescript({
      tsconfig: './tsconfig.json',
      declaration: true,
      declarationDir: 'dist',
    }),
  ],
};

4. 发布到 NPM (保姆级教程)

4.1 准备工作

  1. 注册账号:去 npmjs.com 注册一个账号(记得验证邮箱,否则无法发布)。
  2. 检查包名:在 NPM 搜一下你的 package.json 里的 name,确保没有被占用。如果不幸重名,改个独特的名字,比如 performance-sdk-vip

4.2 终端操作三步走

打开终端(Terminal),在项目根目录下操作:

第一步:登录 NPM

npm login
  • 输入命令后按回车,浏览器会弹出登录页面。
  • 或者在终端根据提示输入用户名、密码和邮箱验证码。
  • 登录成功后会显示 Logged in as <your-username>.
  • 注意:如果你之前切换过淘宝源,发布时必须切回官方源:npm config set registry https://registry.npmjs.org/

第二步:打包代码

确保 dist 目录是最新的,不要发布空代码。

npm run build

第三步:正式发布

npm publish --access public
  • --access public 参数用于确保发布的包是公开的(特别是当包名带 @ 前缀时)。
  • 看到 + performance-sdk-vip@1.0.0 字样,恭喜你,发布成功!

现在,全世界的开发者都可以通过 npm install performance-sdk-vip 来使用你的作品了!

5. 如何使用

SDK 发布后,支持多种引入方式,适配各种开发场景。

  • NPM + ES Modules(推荐)
npm install performance-sdk
import PerformanceMonitor from 'performance-sdk';

const monitor = new PerformanceMonitor({
  /* 可选:log, sampleRate, reportUrl */
});
monitor.init();
  • CDN 直接引入(UMD)
<script src="https://unpkg.com/performance-sdk@x.x.x/dist/index.umd.js"></script>
<script>
  const monitor = new PerformanceSDK.PerformanceMonitor({
    /* 可选配置 */
  });
  monitor.init();
</script>

6. 总结与展望

恭喜你!到这里,你已经亲手打造了一套麻雀虽小,五脏俱全的性能监控 SDK。

咱们再回头看看这四大支柱:

  1. Loading (加载):FP/FCP/LCP 负责盯着**“快不快”**。白屏时间短,用户才愿意留下来。
  2. Interaction (交互):FID/INP 负责盯着**“顺不顺”**。点击有反馈,用户才觉得好用。
  3. Visual Stability (稳定性):CLS 负责盯着**“稳不稳”**。页面不乱跳,用户才不心烦。
  4. Network (网络):Resource Timing 负责盯着**“通不通”**。接口响应快,体验才有底气。

下一步可以玩点啥?

性能监控只是前端监控体系的三分之一。如果你想打造一个无死角的监控系统,光看性能是不够的:

  • 报错了咋办? JS 挂了、接口 500 了、资源加载失败了……这些需要错误监控来兜底。

👉 传送门: 《【错误监控】别只做工具人了!手把手带你写一个前端错误监控 SDK》(https://juejin.cn/post/7580674010837549102)

  • 用户在干啥? 用户点了哪个按钮?在哪个页面停留最久?这些需要行为监控来分析。

👉 传送门:《【用户行为监控】别只做工具人了!手把手带你写一个前端埋点统计 SDK》(https://juejin.cn/post/7583612559443279923)

当然,你还可以结合:

  • 可视化大屏:光有数据不行,得画成图表(ECharts/Grafana)。看着曲线波动,才有成就感。
  • 报警机器人:LCP 超过 4 秒了?接口报错率飙升了?直接钉钉/飞书群里 @ 全体成员,把问题扼杀在摇篮里。

性能优化是一场没有终点的马拉松。希望这篇文章能是你打造专属监控系统的起点。Happy Coding!

如果觉得对您有帮助,欢迎点赞👍 收藏关注 🔔 支持一下!

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

作者 SmalBox
2025年12月25日 12:38

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

节点功能概述

ChannelMask节点是Unity通用渲染管线(URP)中Shader Graph的重要组成部分,专门用于实现通道级的颜色操作。该节点通过动态通道选择机制,能够对输入向量进行选择性屏蔽,其核心优势在于非破坏性编辑——仅调整指定通道,而不会影响其他数据。这一特性在材质编辑、特效合成以及性能优化等场景中具有关键作用。

在URP渲染管线中,ChannelMask节点与SRP Batcher深度兼容,通过批量处理通道操作有效减少Draw Call。节点具备动态维度适配能力,可自动处理Vector2/Vector3/Vector4等不同类型的输入。例如,当连接纹理采样节点时,系统会根据RGBA通道自动生成相应选项,从而显著降低Shader开发的复杂度,使开发者能够更专注于视觉效果的实现。

端口配置详解

输入端口(In)

  • 类型:动态矢量(Dynamic Vector)
  • 绑定:无
  • 特性
    • 支持自动类型推导,可连接任意输出矢量型节点
    • 输入维度决定可选通道范围(例如,Vector3输入仅显示R、G、B选项)
    • 在URP管线中,该端口与StandardLit材质中的基础色通道完全兼容

输出端口(Out)

  • 类型:动态矢量
  • 绑定:无
  • 特性
    • 输出维度与输入保持一致,确保数据流完整性
    • 支持多节点串联输出,便于构建复杂通道处理流水线
    • 在URP渲染过程中,输出结果可直接应用于Albedo、Metallic等材质通道

控件参数分析

Channels控件

  • 类型:动态掩码下拉选单
  • 行为逻辑
    • 当输入为Vector4时,显示R、G、B、A四个选项
    • 支持多选操作(例如同时屏蔽R和G通道)
    • 选项命名与URP材质通道规范保持一致
  • 特殊场景
    • 连接法线贴图时,自动转换为X、Y、Z通道选项
    • 处理HDR颜色时,通道值会依据URP线性空间规则重新计算

数学原理与代码实现

HLSL核心逻辑

// 示例:屏蔽红色通道的Vector4处理 void Unity_ChannelMask_Red_float4(float4 In, out float4 Out) { Out = float4(0, In.g, In.b, In.a); }

URP适配特性

  1. 线性空间处理:在URP的线性颜色空间下,通道屏蔽操作自动应用Gamma校正
  2. HDR支持:处理HDR颜色时,屏蔽操作会保留高动态范围数据
  3. 批处理优化:在SRP Batcher中,相同通道掩码的节点会被合并为单个Draw Call

实际应用场景

材质编辑

  • 金属度控制:通过屏蔽红色通道分离金属反射信息
  • 法线贴图处理:屏蔽Alpha通道以实现法线压缩
  • 次表面散射:单独处理绿色通道以模拟皮肤透光效果

特效制作

  • 发光效果:屏蔽所有通道仅保留亮度通道
  • 颜色渐变:动态切换通道实现实时颜色过渡
  • 故障特效:随机屏蔽通道以模拟数字失真效果

性能优化

  • 通道压缩:屏蔽无用通道以减少纹理采样量
  • LOD适配:根据距离动态调整通道精度
  • 移动端优化:在低端设备上屏蔽非必要通道

性能优化策略

URP专属优化

  1. SRP Batcher兼容:确保通道掩码操作在相同材质实例中进行
  2. 动态分辨率适配:根据屏幕分辨率调整通道精度
  3. GPU Instancing支持:对使用相同通道掩码的物体进行批处理

通用优化技巧

  • 避免在片段着色器中频繁修改通道掩码
  • 使用通道预计算节点减少实时计算量
  • 利用URP的Shader Variants系统创建不同通道配置的变体

常见问题解决方案

URP特有问题

  1. 通道不显示:检查是否启用了URP的线性颜色空间
  2. 性能下降:确认在移动端是否启用了不必要的通道
  3. 渲染异常:验证通道掩码是否与URP材质属性冲突

通用解决方案

  • 使用URP的Shader Graph预览功能实时验证通道效果
  • 通过Frame Debugger分析通道操作对渲染管线的影响
  • 在URP的Quality设置中启用通道操作优化选项

高级应用技巧

URP管线集成

  1. 与Volume系统结合:通过通道掩码实现动态材质修改
  2. 与Lit Shader配合:控制URP标准材质的不同通道
  3. 与Post Processing集成:在后期处理中应用通道操作

动态通道控制

  • 通过URP的MaterialPropertyBlock实现运行时通道修改
  • 结合URP的Render Feature创建自定义通道处理流程
  • 利用URP的Shader Variants系统创建多通道配置

与其他节点的配合使用

URP核心节点

  1. Sample Texture 2D:在纹理采样后应用通道掩码
  2. URP Lit Shader:控制标准材质的通道输出
  3. URP Render Features:在渲染管线中插入通道处理

通用节点组合

  • 与Math节点配合实现通道值计算
  • 与Condition节点结合创建动态通道切换
  • 与Texture Sample节点连接实现基于纹理的通道控制

最佳实践指南

URP开发规范

  1. 在URP项目中优先使用通道掩码而非颜色混合
  2. 为移动端创建简化通道配置的变体
  3. 利用URP的Shader Variants系统管理多通道配置

通用实践建议

  • 为复杂通道操作创建注释节点
  • 使用URP的Shader Graph预览功能验证效果
  • 定期检查通道操作对性能的影响

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

微前端从入门到精通:Vue开发者的大型应用架构演进指南

作者 Mr_chiu
2025年12月25日 11:26

开篇:当Vue应用从“小别墅”变成“摩天大楼”

作为Vue开发者,你是否经历过这样的技术焦虑?

  • 项目代码量突破10万行,npm run build 时间超过5分钟
  • 15个团队在同一个代码库中并行开发,每天产生数十个合并冲突
  • 每次功能上线都需要全量回归测试,发布窗口越来越小
  • 想用Vue 3的Composition API重构老代码,却无从下手

这正是微前端架构要解决的核心问题。本文将为你呈现一套完整的Vue微前端实战指南,从基础概念到生产级架构设计,助你掌握大型Vue应用的拆分艺术。

第一章:重新认识微前端——不只是代码拆分

1.1 微前端的本质:技术架构与组织架构的融合

// 微前端解决的不仅是技术问题
const microFrontendBenefits = {
  technical: [
    '独立开发、部署',
    '技术栈异构(Vue 2/3、React并行)',
    '渐进式升级',
    '增量更新'
  ],
  organizational: [
    '团队自治',
    '并行开发流',
    '按业务领域划分职责',
    '降低协作成本'
  ]
};

1.2 Vue开发者的微前端思维转变

传统思维:

单仓库 → 集中路由 → 全局状态 → 统一构建 → 全量部署

微前端思维:

多仓库 → 路由聚合 → 状态隔离 → 独立构建 → 增量部署

第二章:Vue微前端核心技术选型深度解析

2.1 主流方案横向对比

方案 核心原理 Vue支持度 生产就绪度 学习成本
qiankun 运行时加载 + JS沙箱 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
Module Federation 编译时依赖共享 ⭐⭐⭐⭐ ⭐⭐⭐⭐
Single-SPA 生命周期调度 ⭐⭐⭐⭐ ⭐⭐⭐⭐
无界 iframe沙箱 ⭐⭐⭐⭐ ⭐⭐⭐

2.2 我们的选择:qiankun + Vue 3生态体系

// 技术栈全景图
const techStack = {
  baseFramework: 'Vue 3 + TypeScript + Vite',
  microFramework: {
    host: 'Vue 3 + qiankun',
    microApps: [
      { name: 'auth-center', stack: 'Vue 3 + Pinia' },
      { name: 'dashboard', stack: 'Vue 3 + Composition API' },
      { name: 'legacy-module', stack: 'Vue 2 + Vuex' },
      { name: 'experimental', stack: 'React 18' } // 技术栈自由!
    ]
  },
  stateManagement: 'Pinia (主应用) + 自定义通信协议',
  buildSystem: 'Vite (微应用) + Webpack (主应用)'
};

第三章:实战演练——从零构建生产级Vue微前端架构

3.1 主应用:微前端的“航空母舰”

// main-app/src/micro/registry.ts - 微应用注册中心
import { registerMicroApps, start, initGlobalState } from 'qiankun';
import type { MicroApp } from '@/types/micro';

const microApps: MicroApp[] = [
  {
    name: 'vue3-auth',
    entry: import.meta.env.VITE_AUTH_APP_URL,
    container: '#micro-container',
    activeRule: '/auth',
    props: {
      routerBase: '/auth',
      sharedStore: initGlobalState({ user: null }),
      onAuthSuccess: (token: string) => {
        localStorage.setItem('token', token);
      }
    }
  },
  {
    name: 'vue2-legacy',
    entry: import.meta.env.VITE_LEGACY_APP_URL,
    container: '#micro-container',
    activeRule: '/legacy',
    props: {
      // Vue 2兼容性适配器
      vue2Adapter: true
    }
  }
];

// 智能预加载策略
const prefetchApps = microApps
  .filter(app => app.priority === 'high')
  .map(app => app.name);

registerMicroApps(microApps, {
  beforeLoad: [app => console.log(`加载 ${app.name}`)],
  beforeMount: [app => console.log(`挂载 ${app.name}`)],
  afterUnmount: [app => console.log(`卸载 ${app.name}`)]
});

start({
  prefetch: true,
  sandbox: {
    experimentalStyleIsolation: true // CSS隔离
  },
  singular: false // 允许同时运行多个微应用
});

3.2 Vue 3微应用:现代化配置

// micro-auth/src/entry.ts - 微应用入口文件
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { createPinia } from 'pinia';

let instance: ReturnType<typeof createApp> | null = null;

function render(props: any = {}) {
  const { container, routerBase } = props;
  const app = createApp(App);
  
  // 独立运行时与嵌入运行时路由差异化
  const baseRouter = routerBase || '/';
  
  app.use(router(baseRouter));
  app.use(createPinia());
  
  // 挂载到指定容器或默认#app
  const target = container 
    ? container.querySelector('#auth-app') 
    : '#auth-app';
  
  instance = app.mount(target);
  return instance;
}

// 独立运行
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

// qiankun生命周期协议
export async function bootstrap() {
  console.log('[Auth] 微应用启动');
}

export async function mount(props: any) {
  console.log('[Auth] 接收主应用参数', props);
  return render(props);
}

export async function unmount() {
  if (instance && instance.$destroy) {
    instance.$destroy();
  }
  instance = null;
}

3.3 Vue 2微应用:兼容性适配方案

// micro-legacy/src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // 动态设置webpack publicPath
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

// micro-legacy/src/main.js
import Vue from 'vue';
import App from './App.vue';
import store from './store';

let vueInstance = null;

function render({ container, routerBase } = {}) {
  const target = container 
    ? container.querySelector('#legacy-app') 
    : '#legacy-app';
  
  vueInstance = new Vue({
    store,
    render: h => h(App)
  }).$mount(target);
  
  return vueInstance;
}

export async function bootstrap() {
  console.log('[Legacy] Vue 2应用启动');
}

export async function mount(props) {
  console.log('[Legacy] 挂载', props);
  return render(props);
}

export async function unmount() {
  if (vueInstance) {
    vueInstance.$destroy();
    vueInstance.$el.innerHTML = '';
  }
  vueInstance = null;
}

第四章:高级特性实现——突破微前端核心难题

4.1 跨应用状态管理:不只是Pinia/Vuex

// shared/src/stores/global-store.ts - 跨应用状态总线
import { reactive, watch } from 'vue';
import { initGlobalState, MicroAppStateActions } from 'qiankun';

class GlobalStore {
  private state = reactive({
    user: null as User | null,
    permissions: [] as string[],
    theme: 'light' as 'light' | 'dark'
  });
  
  private actions: MicroAppStateActions | null = null;
  
  init() {
    this.actions = initGlobalState(this.state);
    
    // 监听状态变化
    this.actions.onGlobalStateChange((newState, prevState) => {
      Object.assign(this.state, newState);
    });
  }
  
  // 更新状态并同步到所有微应用
  setUser(user: User) {
    this.state.user = user;
    this.actions?.setGlobalState(this.state);
  }
  
  // 仅本地更新,不广播
  setThemeLocal(theme: 'light' | 'dark') {
    this.state.theme = theme;
  }
  
  getState() {
    return this.state;
  }
}

export const globalStore = new GlobalStore();

// 在Vue组件中使用
import { globalStore } from '@shared/store';
import { storeToRefs } from 'pinia';

export default {
  setup() {
    const { user, theme } = storeToRefs(globalStore.getState());
    
    const updateUser = () => {
      globalStore.setUser({ id: 1, name: 'John' });
    };
    
    return { user, theme, updateUser };
  }
};

4.2 CSS隔离的终极方案

// 方案一:CSS Modules + 命名空间(推荐)
.auth-app {
  // 所有样式都在命名空间下
  :global {
    .button {
      // 覆盖全局样式
    }
  }
}

// 方案二:动态样式表加载/卸载
class ScopedCSSManager {
  private styleCache = new Map<string, HTMLStyleElement>();
  
  load(appName: string, css: string) {
    const style = document.createElement('style');
    style.setAttribute('data-app', appName);
    style.textContent = this.scopeCSS(css, appName);
    document.head.appendChild(style);
    this.styleCache.set(appName, style);
  }
  
  unload(appName: string) {
    const style = this.styleCache.get(appName);
    if (style && document.head.contains(style)) {
      document.head.removeChild(style);
    }
    this.styleCache.delete(appName);
  }
  
  private scopeCSS(css: string, appName: string): string {
    // 将选择器转换为 [data-app="auth"] .button 形式
    return css.replace(/([^{]+)\{/g, `[data-app="${appName}"] $1{`);
  }
}

4.3 智能路由与导航守卫

// main-app/src/router/micro-router.ts
import { createRouter, createWebHistory } from 'vue-router';

const routes = [
  {
    path: '/',
    component: () => import('@/layouts/MainLayout.vue'),
    children: [
      // 主应用路由
      { path: '', component: HomePage },
      // 微前端路由 - 动态匹配
      { 
        path: '/auth/:pathMatch(.*)*',
        component: MicroContainer,
        meta: { microApp: 'vue3-auth' }
      },
      {
        path: '/legacy/:pathMatch(.*)*',
        component: MicroContainer,
        meta: { microApp: 'vue2-legacy' }
      }
    ]
  }
];

// 导航守卫 - 微应用权限控制
router.beforeEach((to, from, next) => {
  const microApp = to.meta.microApp;
  
  if (microApp) {
    // 检查微应用是否就绪
    if (!isMicroAppLoaded(microApp)) {
      loadMicroApp(microApp).then(() => {
        next();
      }).catch(() => {
        next('/error/micro-app-unavailable');
      });
      return;
    }
    
    // 检查微应用访问权限
    if (!hasPermissionForMicroApp(microApp)) {
      next('/forbidden');
      return;
    }
  }
  
  next();
});

第五章:性能优化实战手册

5.1 微应用懒加载与预加载策略

// main-app/src/utils/preload-strategy.ts
class MicroAppPreloader {
  private loadedApps = new Set<string>();
  private prefetchQueue: string[] = [];
  
  // 基于用户行为预测的预加载
  setupUserBehaviorPrediction() {
    // 1. 监听路由变化
    this.router.afterEach(to => {
      const nextApps = this.predictNextApps(to);
      this.prefetch(nextApps);
    });
    
    // 2. 监听鼠标悬停
    document.addEventListener('mouseover', (e) => {
      const link = e.target as HTMLElement;
      if (link.dataset.microApp) {
        this.prefetch([link.dataset.microApp]);
      }
    }, { capture: true });
  }
  
  // 智能预加载算法
  async prefetch(appNames: string[]) {
    const toLoad = appNames.filter(name => 
      !this.loadedApps.has(name) && 
      !this.prefetchQueue.includes(name)
    );
    
    // 空闲时加载
    if ('requestIdleCallback' in window) {
      requestIdleCallback(async () => {
        for (const app of toLoad) {
          await this.loadAppResources(app);
        }
      });
    } else {
      // 降级方案:延迟加载
      setTimeout(() => {
        toLoad.forEach(app => this.loadAppResources(app));
      }, 1000);
    }
  }
  
  private predictNextApps(currentRoute): string[] {
    // 基于路由配置的简单预测
    const routeMap = {
      '/dashboard': ['vue3-auth', 'vue3-analytics'],
      '/user/profile': ['vue3-auth'],
      // ...更多路由映射
    };
    
    return routeMap[currentRoute.path] || [];
  }
}

5.2 构建优化:Vite + qiankun的最佳实践

// micro-app/vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  build: {
    target: 'es2015',
    lib: {
      entry: 'src/entry.ts',
      formats: ['umd'],
      name: 'vue3AuthApp'
    },
    rollupOptions: {
      external: ['vue', 'vue-router', 'pinia'],
      output: {
        globals: {
          vue: 'Vue',
          'vue-router': 'VueRouter',
          pinia: 'Pinia'
        },
        // 确保qiankun能正确加载
        entryFileNames: '[name].js',
        chunkFileNames: '[name].[hash].js'
      }
    }
  },
  server: {
    port: 3001,
    cors: true,
    headers: {
      'Access-Control-Allow-Origin': '*'
    }
  }
});

第六章:生产环境部署与DevOps流水线

6.1 微前端CI/CD架构

# .github/workflows/deploy-micro-apps.yml
name: Deploy Micro Frontends

on:
  push:
    paths:
      - 'micro-apps/**'
      - 'main-app/**'

jobs:
  deploy-micro-apps:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        app: [auth, dashboard, legacy]
    
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          
      - name: Install Dependencies
        run: |
          cd micro-apps/${{ matrix.app }}
          npm ci
          
      - name: Build Micro App
        run: |
          cd micro-apps/${{ matrix.app }}
          npm run build
          
      - name: Deploy to CDN
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
          aws-region: us-east-1
        run: |
          aws s3 sync ./dist s3://my-cdn/micro-apps/${{ matrix.app }}/${{ github.sha }}/
          
      - name: Update Version Registry
        run: |
          # 更新微应用版本清单
          curl -X POST https://api.myapp.com/versions \
            -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" \
            -d '{"app":"${{ matrix.app }}","version":"${{ github.sha }}"}'

6.2 版本管理与灰度发布

// version-manager.ts - 微应用版本控制
class VersionManager {
  private versionManifest: Record<string, string> = {};
  
  async init() {
    this.versionManifest = await this.fetchManifest();
    this.setupVersionPolling();
  }
  
  // 获取指定应用的最佳版本
  getAppVersion(appName: string, userId?: string): string {
    const defaultVersion = this.versionManifest[appName];
    
    // 灰度发布逻辑
    if (userId && this.isInGrayScale(userId, appName)) {
      const grayVersion = this.getGrayVersion(appName);
      return grayVersion || defaultVersion;
    }
    
    return defaultVersion;
  }
  
  // 自动降级机制
  async loadWithFallback(appName: string, version: string) {
    try {
      return await this.loadVersion(appName, version);
    } catch (error) {
      console.warn(`版本 ${version} 加载失败,尝试回退`);
      
      // 尝试上一个稳定版本
      const stableVersion = this.getStableVersion(appName);
      if (stableVersion !== version) {
        return await this.loadVersion(appName, stableVersion);
      }
      
      throw new Error(`微应用 ${appName} 加载失败`);
    }
  }
}

第七章:从Monolith到微前端——真实迁移案例

迁移前后的量化对比

某电商平台Vue项目迁移数据:

指标 迁移前(单体) 迁移后(微前端) 改进
构建时间 8分30秒 平均1分20秒 ⬇️ 84%
首屏加载 3.2秒 1.8秒 ⬇️ 44%
发布频率 每周1次 每日多次 ⬆️ 500%+
团队独立部署率 0% 85% ⬆️ 85%
生产事故影响范围 全局 单个微应用 ⬇️ 90%

渐进式迁移策略示例

// 迁移路线图
const migrationRoadmap = [
  {
    phase: '准备阶段',
    tasks: [
      '搭建微前端基座',
      '配置构建流水线',
      '制定通信协议',
      '培训开发团队'
    ],
    duration: '2周'
  },
  {
    phase: '第一期迁移',
    tasks: [
      '抽离用户中心(低风险)',
      '独立部署认证模块',
      '验证技术方案可行性'
    ],
    duration: '3周'
  },
  {
    phase: '核心业务迁移',
    tasks: [
      '拆解商品详情页',
      '迁移订单流程',
      '实现购物车微应用'
    ],
    duration: '6周'
  },
  {
    phase: '收尾与优化',
    tasks: [
      '迁移剩余模块',
      '性能调优',
      '监控体系完善',
      '文档整理'
    ],
    duration: '3周'
  }
];

第八章:避坑指南与最佳实践

8.1 常见问题与解决方案

## 🐛 问题1:微应用样式污染
**症状**:微应用的CSS影响了其他应用
**解决方案**1. 启用qiankun的experimentalStyleIsolation
2. 使用CSS Modules + 命名空间前缀
3. 动态样式表加载/卸载

## 🐛 问题2:全局变量冲突
**症状**:多个Vue实例或Vuex store冲突
**解决方案**1. 使用沙箱模式运行微应用
2. 避免修改window全局对象
3. 通过props传递共享依赖

## 🐛 问题3:路由跳转异常
**症状**:微应用内跳转导致主应用路由混乱
**解决方案**1. 统一使用主应用路由控制
2. 微应用使用相对路径
3. 实现路由事件代理机制

## 🐛 问题4:通信复杂度高
**症状**:微应用间通信代码混乱
**解决方案**1. 定义清晰的事件通信协议
2. 使用状态管理库(Pinia)作为中心化store
3. 限制直接通信,通过主应用中转

8.2 Vue微前端黄金法则

  1. 单一职责原则:每个微应用只负责一个业务领域
  2. 技术栈自由但有限制:允许异构,但要制定标准
  3. 独立可部署:每个微应用都能独立运行和测试
  4. 向后兼容:确保API和通信协议向后兼容
  5. 监控全覆盖:每个微应用都需要独立的监控和日志

结语:微前端不是银弹,而是架构演进的必经之路

微前端并不是要取代Vue的单页面应用架构,而是为其提供一种可扩展的解决方案。当你的Vue应用从“小别墅”成长为“摩天大楼”时,微前端提供了必要的结构支撑。

记住,技术选型的核心不是追求最新最酷,而是找到最适合团队和业务场景的平衡点。微前端带来的不仅是技术上的解耦,更是组织架构和开发流程的优化。

Vue 3 + qiankun的微前端方案,已经证明是生产可行的。现在,是时候将你的大型Vue应用带入微前端时代了。


资源推荐

本文首发于掘金技术社区,转载请注明出处。如果你在Vue微前端实践中遇到问题,欢迎在评论区交流讨论。

❌
❌