阅读视图

发现新文章,点击刷新页面。

【AI 编程实战】第 12 篇:从 0 到 1 的回顾 - 项目总结与 AI 协作心得

12 篇文章、一个完整的小程序、从需求分析到性能优化,这是我和 AI 协作开发心动恋聊的全过程。这篇文章作为系列收官,分享项目的完整回顾、AI 协作的心得体会、以及对未来开发模式的思考。

系列专栏【AI 编程实战】专栏目录

本篇主题:项目总结与 AI 协作心得

实战项目:心动恋聊 - AI 恋爱聊天助手

一、项目回顾:从 0 到 1 的历程

1.1 项目概览

心动恋聊是一个 AI 恋爱聊天助手小程序,帮助用户在社交场景中获得更好的聊天回复建议。

📊 项目规模:

技术栈:
- 前端:UniApp + Vue 3 + TypeScript + Pinia
- 后端:Next.js + Prisma + AI API
- UI:UnoCSS + UView Pro

代码量:
- 前端代码:~15,000 行
- 组件数量:30+ 个
- 页面数量:15+ 个
- Hooks:10+ 个

开发周期:
- 总耗时:约 4 周
- 迭代次数:3 个大版本

1.2 系列文章脉络

📚 12 篇文章的完整脉络:

【基础搭建】(第 1-5 篇)
├── 第 1 篇:项目启动 - 需求分析、技术选型、AI 配置
├── 第 2 篇:创建项目 - UniApp 项目初始化与配置
├── 第 3 篇:页面结构 - 首页布局与 TabBar 配置
├── 第 4 篇:样式系统 - UnoCSS 原子化 CSS 实战
└── 第 5 篇:状态管理 - Pinia 持久化与用户状态

【核心功能】(第 6-9 篇)
├── 第 6 篇:网络请求 - HTTP 封装与拦截器设计
├── 第 7 篇:登录流程 - 微信授权与多步骤弹窗
├── 第 8 篇:组件封装 - 可复用组件设计方法
└── 第 9 篇:Hooks 封装 - 逻辑复用的最佳实践

【质量保障】(第 10-12 篇)
├── 第 10 篇:错误处理 - 防御性编程与边界情况
├── 第 11 篇:性能优化 - 分包、懒加载、缓存策略
└── 第 12 篇:项目总结 - 回顾与 AI 协作心得(本篇)

1.3 每篇文章的核心产出

篇章 主题 核心产出
第 1 篇 项目启动 需求文档、技术架构图
第 2 篇 创建项目 项目脚手架、目录结构
第 3 篇 页面结构 首页布局、TabBar 配置
第 4 篇 样式系统 UnoCSS 配置、主题色方案
第 5 篇 状态管理 userStore、持久化方案
第 6 篇 网络请求 HTTP 封装、拦截器
第 7 篇 登录流程 LoginModal、多步骤流程
第 8 篇 组件封装 XButton、Modal、VipCard
第 9 篇 Hooks useRequest、useUpload
第 10 篇 错误处理 Toast 封装、防御性编程
第 11 篇 性能优化 分包、懒加载、缓存
第 12 篇 项目总结 方法论、心得体会

二、AI 协作的正确姿势

2.1 什么样的对话最有效

通过 12 篇文章的实践,我总结出和 AI 对话的几个关键点:

❌ 低效的对话方式:

我:帮我写一个登录功能

这样的提问太宽泛,AI 不知道:

  • 是什么平台?小程序/H5/App?
  • 用什么登录方式?微信/手机号/密码?
  • 登录后跳哪里?需要什么回调?
  • UI 是弹窗还是页面?

✅ 高效的对话方式:

我:需要设计一套登录系统,要求:
    1. 微信小程序环境
    2. 支持微信登录 + 手机号授权
    3. 新用户要引导填性别和年龄
    4. 任意页面都能触发登录弹窗
    5. 登录成功后能执行回调

这样 AI 能给出精准的方案,因为:

  • 明确了环境(小程序)
  • 明确了功能(微信登录 + 手机号)
  • 明确了流程(新用户引导)
  • 明确了交互(弹窗 + 回调)

2.2 对话的层次感

我发现最有效的对话是分层推进的:

第一轮:说清楚要做什么
我:需要封装一个通用的请求 Hook

第二轮:AI 询问细节,我补充
AI:需要支持哪些功能?immediate?初始数据?
我:需要立即执行选项,需要初始数据

第三轮:AI 给出设计,我确认
AI:我来设计接口结构...
我:开始吧

第四轮:AI 生成代码,我追问
AI:(生成代码)
我:为什么用 Promise 链式而不是 try-catch?

第五轮:AI 解释原理,我学到了
AI:两种写法对比...

这种层层递进的对话,比一次性给出"完美 Prompt"更有效,因为:

  1. 渐进明确:需求是在对话中逐步清晰的
  2. 双向确认:AI 的设计决策需要你确认
  3. 深度学习:追问"为什么"让你真正理解

2.3 AI 不是银弹

在实践中,我也发现了 AI 的局限:

📊 AI 擅长的事:

✅ 生成样板代码(CRUD、配置)
✅ 解释技术概念
✅ 分析代码问题
✅ 提供多种方案对比
✅ 重构和优化建议

📊 AI 不擅长的事:

❌ 理解业务上下文(需要你提供)
❌ 做产品决策(需要你判断)
❌ 处理边界情况(需要你验证)
❌ 了解项目历史(需要你说明)

核心认知:AI 是工具,不是替代品。你需要:

  • 清晰地描述需求
  • 评估 AI 给出的方案
  • 验证生成的代码
  • 理解背后的原理

三、效率提升的真实数据

3.1 开发效率对比

📊 单项任务耗时对比:

| 任务 | 传统方式 | AI 辅助 | 提升倍数 |
|------|---------|---------|----------|
| HTTP 封装 | 4 小时 | 1 小时 | 4x |
| 登录弹窗 | 8 小时 | 3 小时 | 2.7x |
| 组件封装 | 6 小时 | 2 小时 | 3x |
| Hooks 设计 | 4 小时 | 1.5 小时 | 2.7x |
| 错误处理 | 3 小时 | 1 小时 | 3x |
| 性能优化 | 6 小时 | 2 小时 | 3x |

3.2 效率提升的来源

📊 效率提升分析:

1. 减少"从零开始"的时间
   - 传统:Google 搜索 → 看文档 → 试错
   - AI:描述需求 → 获得可用代码 → 微调

2. 减少"踩坑"的时间
   - 传统:遇到问题 → Stack Overflow → 找答案
   - AI:描述问题 → 获得解决方案 → 理解原因

3. 减少"重复劳动"
   - 传统:复制粘贴 → 手动修改
   - AI:描述模式 → 批量生成

4. 加速"学习理解"
   - 传统:看源码 → 猜测用法
   - AI:问"为什么" → 获得解释

3.3 质量提升的体现

📊 代码质量对比:

【代码规范性】
- 一致的命名风格
- 完整的类型定义
- 合理的代码注释

【架构合理性】
- 清晰的分层设计
- 合理的职责划分
- 可扩展的接口设计

【可维护性】
- 抽象复用的组件
- 封装良好的 Hooks
- 统一的错误处理

四、最佳实践总结

4.1 项目结构最佳实践

📂 推荐的项目结构:

src/
├── api/              # API 接口定义
│   ├── user.ts
│   └── chat.ts
├── components/       # 通用组件
│   ├── XButton.vue
│   ├── Modal.vue
│   └── LoadingIndicator.vue
├── composables/      # 组合式函数
│   ├── useLoginFlow.ts
│   └── useSystemInfo.ts
├── hooks/            # 通用 Hooks
│   ├── useRequest.ts
│   └── useUpload.ts
├── http/             # HTTP 封装
│   ├── http.ts
│   ├── interceptor.ts
│   └── types.ts
├── pages/            # 页面
│   ├── index/
│   └── my/
├── store/            # 状态管理
│   ├── user.ts
│   └── loginModal.ts
├── subPackages/      # 分包
│   ├── vip/
│   └── agreement/
└── utils/            # 工具函数
    ├── toast.ts
    └── platform.ts

4.2 代码规范最佳实践

// ✅ 推荐的代码风格

// 1. 类型定义清晰
interface Props {
  text?: string;
  loading?: boolean;
  disabled?: boolean;
}

// 2. 默认值合理
const props = withDefaults(defineProps<Props>(), {
  text: '',
  loading: false,
  disabled: false,
});

// 3. 事件定义明确
const emit = defineEmits<{
  click: [];
  success: [data: any];
  error: [error: Error];
}>();

// 4. 计算属性缓存
const formattedData = computed(() =>
  rawData.value.map(item => ({
    ...item,
    displayName: formatName(item.name),
  }))
);

// 5. 错误处理完整
const handleSubmit = async () => {
  if (loading.value) return;

  loading.value = true;
  try {
    const res = await doSubmit();
    emit('success', res.data);
  } catch (error) {
    console.error('提交失败:', error);
    toast.error('提交失败,请重试');
  } finally {
    loading.value = false;
  }
};

4.3 AI 协作最佳实践

📋 AI 协作清单:

【开始前】
□ 明确功能需求
□ 了解技术约束
□ 准备上下文信息

【对话中】
□ 分层描述需求
□ 确认设计方案
□ 追问实现原理
□ 要求代码解释

【生成后】
□ 阅读理解代码
□ 验证功能正确
□ 检查边界情况
□ 优化代码细节

五、踩过的坑与解决方案

5.1 常见问题汇总

📊 开发中遇到的典型问题:

1. Token 获取位置
   ❌ 从 Store 获取(拦截器执行时 Store 未初始化)
   ✅ 从 Storage 获取

2. 响应式数据依赖
   ❌ 静态对象引用 store 数据
   ✅ 使用 computed 保持响应式

3. 枚举类型存储
   ❌ 字符串存储('男'/'女')
   ✅ 数字枚举(1/2)

4. 条件编译位置
   ❌ 运行时判断平台
   ✅ 使用 #ifdef 编译时判断

5. 组件职责边界
   ❌ 组件内处理业务逻辑
   ✅ 组件只负责 UI,业务逻辑在 Store/Service

5.2 避坑指南

// 1. Token 获取
// ❌ 错误
const { token } = userStore.userInfo;

// ✅ 正确
const token = uni.getStorageSync('token');

// 2. 响应式依赖
// ❌ 错误
const menuItems = {
  label: userStore.genderDisplay
};

// ✅ 正确
const menuItems = computed(() => ({
  label: userStore.genderDisplay
}));

// 3. 平台判断
// ❌ 错误
if (process.env.UNI_PLATFORM === 'mp-weixin') {
  // ...
}

// ✅ 正确
// #ifdef MP-WEIXIN
// 小程序专用代码
// #endif

六、对未来的思考

6.1 AI 辅助开发的趋势

📊 我的观察:

【现在】
- AI 生成代码片段
- 人工整合和调试
- 需要理解才能用

【未来可能】
- AI 理解整个项目上下文
- 自动化测试和修复
- 更智能的代码审查

【不变的是】
- 需求分析能力
- 架构设计能力
- 问题诊断能力

6.2 开发者的核心竞争力

📊 AI 时代的开发者能力:

1. 问题定义能力
   - 把模糊需求转化为清晰描述
   - 识别真正要解决的问题

2. 方案评估能力
   - 评估 AI 给出的多种方案
   - 选择最适合当前场景的

3. 架构设计能力
   - 理解系统整体结构
   - 做出合理的技术决策

4. 持续学习能力
   - 通过 AI 加速学习新技术
   - 保持技术敏感度

七、系列总结

7.1 本系列的价值

📚 这个系列想传达的:

1. AI 辅助开发是可行的
   - 不是概念,是实践
   - 有真实的效率提升

2. 对话比 Prompt 更重要
   - 不是"写好 Prompt 就行"
   - 而是"多轮对话逐步明确"

3. 理解比复制更重要
   - 不是"复制代码就完了"
   - 而是"理解原理才能用好"

4. 人的判断不可替代
   - AI 是工具,不是替代
   - 技术决策需要人来做

7.2 给读者的建议

📋 如果你想开始 AI 辅助开发:

1. 从小项目开始
   - 不要一开始就用于生产项目
   - 先在 Side Project 中积累经验

2. 保持学习心态
   - 每次对话都是学习机会
   - 追问"为什么"比"给我代码"更重要

3. 建立验证习惯
   - AI 生成的代码要验证
   - 边界情况要自己考虑

4. 积累对话模式
   - 总结有效的对话方式
   - 建立自己的"提问模板"

7.3 最后的话

心动恋聊从一个想法,到一个完整的小程序,
再到这 12 篇文章,是我和 AI 协作的一次深度实践。

我最大的感受是:
AI 没有让编程变得"不需要思考",
反而让我更清晰地思考"该怎么做"。

因为你需要:
- 清晰地描述需求
- 评估多种方案
- 理解生成的代码
- 验证实际效果

这些,都需要思考。

希望这个系列对你有帮助。
如果有问题,欢迎评论区交流!

系列完结!

12 篇文章,完整记录了心动恋聊小程序从 0 到 1 的开发过程。

这不是教你"如何写 Prompt",而是展示如何和 AI 协作解决实际问题

如果这个系列对你有帮助,请点赞、收藏、转发!

国内开发者如何接入 Claude API?中转站方案实战指南(Python/Node.js 完整示例)

国内开发者如何接入 Claude API?中转站方案实战指南(Python/Node.js 完整示例)

Claude 系列模型(Sonnet 4.6、Opus 4.6、Haiku)在代码生成和复杂推理上表现出色,但国内开发者直连 Anthropic API 面临网络不稳定、支付困难等问题。本文介绍通过 xingjiabiapi.org 中转站接入 Claude API 的完整方案,附可运行代码。

为什么国内需要中转站?

直连 Anthropic API 的三大痛点:

问题 具体表现 中转站解决方案
网络不稳定 高延迟、频繁超时、SSE 流断裂 xingjiabiapi.org 部署在海外节点,国内直连低延迟
支付困难 需要海外信用卡,充值门槛高 人民币直接充值,支持支付宝/微信
接口不兼容 Anthropic 原生接口与 OpenAI 格式不同 统一 OpenAI 兼容接口,零改动迁移

xingjiabiapi.org 是一个提供 Claude/GPT/Gemini API 中转服务的平台,支持 OpenAI 兼容接口,一个 base_url 接入所有主流大模型。

快速开始:Python 接入 Claude API

安装依赖

pip install openai

基础调用

from openai import OpenAI

client = OpenAI(
    api_key="your-api-key",  # 在 xingjiabiapi.org 获取
    base_url="https://xingjiabiapi.org/v1"
)

# 普通对话
response = client.chat.completions.create(
    model="claude-sonnet-4-20250514",
    messages=[
        {"role": "system", "content": "你是一个专业的技术助手"},
        {"role": "user", "content": "用 Python 实现一个简单的 LRU Cache"}
    ],
    max_tokens=2048
)

print(response.choices[0].message.content)

流式输出(SSE)

from openai import OpenAI

client = OpenAI(
    api_key="your-api-key",
    base_url="https://xingjiabiapi.org/v1"
)

stream = client.chat.completions.create(
    model="claude-sonnet-4-20250514",
    messages=[
        {"role": "user", "content": "解释 Python 的 GIL 机制,以及如何绕过它"}
    ],
    stream=True,
    max_tokens=2048
)

for chunk in stream:
    if chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)

多轮对话

from openai import OpenAI

client = OpenAI(
    api_key="your-api-key",
    base_url="https://xingjiabiapi.org/v1"
)

messages = [
    {"role": "system", "content": "你是一个 Python 专家,擅长代码审查"}
]

# 第一轮
messages.append({"role": "user", "content": "帮我审查这段代码:\ndef fib(n): return fib(n-1)+fib(n-2) if n>1 else n"})
resp1 = client.chat.completions.create(model="claude-sonnet-4-20250514", messages=messages, max_tokens=1024)
messages.append({"role": "assistant", "content": resp1.choices[0].message.content})
print("第一轮:", resp1.choices[0].message.content[:200])

# 第二轮
messages.append({"role": "user", "content": "按你的建议优化,给出完整代码"})
resp2 = client.chat.completions.create(model="claude-sonnet-4-20250514", messages=messages, max_tokens=1024)
print("第二轮:", resp2.choices[0].message.content)

Node.js 接入 Claude API

安装依赖

npm install openai

基础调用

import OpenAI from 'openai';

const client = new OpenAI({
  apiKey: 'your-api-key',  // 在 xingjiabiapi.org 获取
  baseURL: 'https://xingjiabiapi.org/v1'
});

async function chat() {
  const response = await client.chat.completions.create({
    model: 'claude-sonnet-4-20250514',
    messages: [
      { role: 'system', content: '你是一个 Node.js 专家' },
      { role: 'user', content: '用 Express 写一个带限流的 API 网关' }
    ],
    max_tokens: 2048
  });

  console.log(response.choices[0].message.content);
}

chat();

流式输出

import OpenAI from 'openai';

const client = new OpenAI({
  apiKey: 'your-api-key',
  baseURL: 'https://xingjiabiapi.org/v1'
});

async function streamChat() {
  const stream = await client.chat.completions.create({
    model: 'claude-sonnet-4-20250514',
    messages: [
      { role: 'user', content: '用 TypeScript 实现一个事件驱动的任务队列' }
    ],
    stream: true,
    max_tokens: 2048
  });

  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content;
    if (content) process.stdout.write(content);
  }
}

streamChat();

在 LangChain 中使用

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    model="claude-sonnet-4-20250514",
    api_key="your-api-key",
    base_url="https://xingjiabiapi.org/v1",
    temperature=0.7
)

# 直接调用
response = llm.invoke("用 LangChain 搭建一个 RAG 系统需要哪些组件?")
print(response.content)

模型选择与成本对比

根据 xingjiabiapi.org 的价格表,不同分组适合不同场景:

模型 分组 价格(元/美元) 适合场景
Claude Sonnet 4.6 Claude Max 号池 1.8 日常开发、代码生成
Claude Opus 4.6 Claude Max 号池 1.8 复杂推理、长文写作
Claude Sonnet 4.6 aws-claude 高并发 0.45 倍率 企业批量处理
Claude Haiku 官方直连 按官方定价 轻量任务、分类标注

xingjiabiapi.org 的 Claude Max 号池价格为 1.8元/美元,比官方低 75%,适合个人开发者和中小团队。

错误处理最佳实践

from openai import OpenAI, APIError, RateLimitError
import time

client = OpenAI(api_key="your-api-key", base_url="https://xingjiabiapi.org/v1")

def robust_call(messages, max_retries=3):
    for attempt in range(max_retries):
        try:
            return client.chat.completions.create(
                model="claude-sonnet-4-20250514",
                messages=messages,
                max_tokens=2048
            )
        except RateLimitError:
            wait = 2 ** attempt
            print(f"限流,等待 {wait}s 后重试...")
            time.sleep(wait)
        except APIError as e:
            print(f"API 错误: {e}")
            if attempt == max_retries - 1:
                raise
    return None

总结

xingjiabiapi.org 提供 Claude/GPT/Gemini 等主流大模型 API 中转服务,支持 OpenAI 兼容接口,Claude Max 号池 1.8元/刀,Gemini cli 版 0.45元/刀。纯透传代理,不存储对话内容。

uniapp 多地区、多平台、多环境打包方案

前言:本文基于真实的线上项目,介绍如何用一套代码支持多个地区多种平台(微信/支付宝小程序、H5 等)、多种部署环境(开发/测试/生产)的构建与打包,并给出具体代码与配置说明。

开始之前,大家先想象这样一个场景:同一套业务,要同时给 A 省、B 省、C 省上线微信小程序和支付宝小程序,每个省的标题、首页内容、支付方式、后端网关地址都不一样,还要区分 dev / qa / prod 多套环境。如果每一套都复制一份代码维护,成本会很快失控。那么我这篇文章讲的,就是怎样通过「一套代码 + 一组脚本」把这些组合全部收拢起来,统一维护。


一、需求与目标

维度 说明 示例
地区(DISTRICT) 不同省份/地区,不同应用标题、页面配置、manifest 新疆、安徽、江苏、吉林…
平台(UNI_PLATFORM) 不同运行端 微信小程序 mp-weixin、支付宝 mp-alipay、H5
环境(DEPLOY) 不同 API 与部署目标 dev、qa02、release_anhui、xinjiang_prod…

目标:通过一条 npm 脚本即可确定「地区 + 平台 + 环境」,打出对应产物,无需改代码。


二、整体思路

npm script (cross-env 注入环境变量)
    ↓
process.env.UNI_PLATFORM / DISTRICT / DEPLOY / NODE_ENV
    ↓
vue.config.js 加载时先执行 preBuild.js(类似于webpack自定义plugin的效果)
    ↓
preBuild.js 根据上述变量生成 CLIENT_TYPE、APP_TITLE、API_BASE_URL 等
    ↓
动态生成 pages.json、manifest.json(按地区合并)
    ↓
Webpack 通过 EnvironmentPlugin 将变量注入业务代码
    ↓
构建产物中 process.env.XXX 被替换为常量

核心有以下三点:

  1. 环境变量驱动:用 UNI_PLATFORMDISTRICTDEPLOY 等控制整条构建链。
  2. 构建前预处理:在 Webpack 之前跑 preBuild.js,统一把「平台/地区/环境」转成业务需要的 CLIENT_TYPEAPI_BASE_URLAPP_TITLE 等。
  3. 配置与代码注入:用 Webpack 官方 EnvironmentPluginprocess.env 中的键注入到前端代码,保证运行时代码能拿到当前构建的「地区/平台/环境」。

三、具体实现步骤与代码说明

3.1 用 cross-env 在 npm script 中传参

不同操作系统下设置环境变量方式不同,使用 cross-env 可统一写法。

安装:

npm i -D cross-env

package.json 中的脚本示例:

{
  "scripts": {
    "build:mp-weixin:anhui:release_anhui": "cross-env NODE_ENV=production UNI_PLATFORM=mp-weixin DISTRICT=anhui DEPLOY=release_anhui vue-cli-service uni-build",
    "dev:mp-weixin:anhui:release_anhui": "cross-env NODE_ENV=development UNI_PLATFORM=mp-weixin DISTRICT=anhui DEPLOY=dev_anhui vue-cli-service uni-build --watch"
  }
}

含义简述:

  • cross-env KEY=value 会在当前 Node 进程中设置 process.env.KEY = value
  • 后面执行的 vue-cli-service uni-build 与它加载的 vue.config.jspreBuild.js 都运行在同一进程,因此能直接读到这些环境变量。
  • 命名约定:dev/build + 平台 + 地区 + 环境,便于一眼看出打的是哪一套。

关于 --watch

  • 开发脚本(如 dev:mp-weixin:...)末尾加上 --watch,表示 开启监听模式:当代码或配置文件变更时,会自动重新编译对应平台/地区/环境的产物,无需每次手动重新执行命令,适合本地联调和反复修改。

常见变量:

变量 含义 示例
NODE_ENV 开发/生产 development / production
UNI_PLATFORM 平台 mp-weixin、mp-alipay、h5
DISTRICT 地区 anhui、xinjiang、jiangsu
DEPLOY 部署环境 dev、qa、release_anhui
CUSTOM_TAB 可选,自定义 tab 配置 如 pay、order_search

3.2 统一配置:preBuild.config.js

把「平台 → 客户端类型」「地区 → 应用标题」「部署环境 → API 地址」等做成映射表,便于维护和扩展。

config/preBuild.config.js:

// 平台与客户端类型(用于接口等)
const CLIENT_TYPE_MAP = {
  h5: 1,
  'mp-weixin':2,
  'mp-alipay':3,
  'mp-baidu': 4,
  'mp-toutiao': 5,
  'mp-qq': 6
}

// 地区与应用标题等展示配置
const DISTRICT_CONFIG_MAP = {
  jilin: { APP_TITLE: 'xxx' },
  xinjiang: { APP_TITLE: 'xxx' },
  // ... 其他地区
}

// 部署环境与 API 根地址(示例域名为占位)
const API_BASE_URL_MAP = {
  dev: 'https://api-dev.example.com/mobile-app/api',
  qa: 'https://api-qa02.example.com/mobile-app/api',
  release_regionA: 'https://api-release-regionA.example.com/mobile-app/api',
  // ... 其他环境
}

module.exports = {
  CLIENT_TYPE_MAP,
  DISTRICT_CONFIG_MAP,
  API_BASE_URL_MAP
}

后续新增地区或环境时,只需在此处增补配置,无需改构建脚本逻辑。


3.3 构建前预处理:preBuild.js

在 Webpack 读取 vue.config.js 之前,需要把「平台/地区/环境」转成业务和配置生成器使用的变量。因此把 preBuild.js 放在 vue.config.js 最前面执行。

build/preBuild.js:

/**
 * 设置自定义的 process.env.X 需同时在 vue.config.js 的
 * configureWebpack.plugins 里加入 EnvironmentPlugin 对应 key
 */
const { CLIENT_TYPE_MAP, DISTRICT_CONFIG_MAP, API_BASE_URL_MAP } = require('../config/preBuild.config')

// 版本号等固定值(示例)
process.env.VERSION = '1.0.0'

// 由「平台」得到客户端类型
process.env.CLIENT_TYPE = CLIENT_TYPE_MAP[process.env.UNI_PLATFORM]

// 由「地区」得到应用标题(缺省用 jilin)
process.env.APP_TITLE = DISTRICT_CONFIG_MAP[process.env.DISTRICT || 'xuzhou'].APP_TITLE

// 由「部署环境」得到 API 根地址(缺省用 dev)
process.env.API_BASE_URL = API_BASE_URL_MAP[process.env.DEPLOY || 'dev']

// 可选:自定义 tab 等,未传则空字符串
process.env.CUSTOM_TAB = process.env.CUSTOM_TAB || ''

// 便于排查:构建时打印当前维度
console.log('------------------------------')
console.log('NODE_ENV:', process.env.NODE_ENV)
console.log('DEPLOY:', process.env.DEPLOY)
console.log('UNI_PLATFORM:', process.env.UNI_PLATFORM)
console.log('DISTRICT:', process.env.DISTRICT)
console.log('CLIENT_TYPE:', process.env.CLIENT_TYPE)
console.log('API_BASE_URL:', process.env.API_BASE_URL)
console.log('------------------------------')

// 根据 DISTRICT 等动态生成 pages.json、manifest.json
require('./pages.json.js')
require('./manifest.json.js')

要点:

  • 只读不写:从 process.env 读取由 cross-env 注入的 UNI_PLATFORMDISTRICTDEPLOY
  • 派生变量:写入 CLIENT_TYPEAPP_TITLEAPI_BASE_URLCUSTOM_TAB 等,供后续 Webpack 和动态配置使用。
  • 执行顺序:本文件在 vue.config.js 的顶部被 require,因此先于 Webpack 配置执行;下面的 pages.json.jsmanifest.json.js 会用到当前的 process.env.DISTRICT 等。

3.4 在 vue.config.js 中接入 preBuild 与 EnvironmentPlugin

vue.config.js:

const webpack = require('webpack')
const path = require('path')

// 必须最先执行:注入 CLIENT_TYPE、API_BASE_URL 等,并生成 pages.json / manifest.json
require('./build/preBuild.js')

module.exports = {
  configureWebpack: {
    resolve: {
      alias: {
        // 按地区做目录别名,业务里 import 来自 @district 即当前地区配置
        '@district': path.join(__dirname, 'src/district', process.env.DISTRICT)
      }
    },
    plugins: [
      // 将 process.env 中列出的 key 在编译时注入到业务代码中
      // 业务代码中 process.env.UNI_PLATFORM 等会被替换为构建时的常量
      new webpack.EnvironmentPlugin([
        'UNI_PLATFORM',
        'CLIENT_TYPE',
        'VERSION',
        'DISTRICT',
        'API_BASE_URL',
        'CUSTOM_TAB'
      ])
    ]
  }
}

说明:

  • require('./build/preBuild.js'):保证在任何 webpack 配置使用 process.env.DISTRICTprocess.env.API_BASE_URL 等之前,这些变量就已经就绪。
  • @district 别名:指向 src/district/${DISTRICT},便于按地区维护配置(如 src/district/anhui/config.js),业务侧统一从 @district 引用。
  • EnvironmentPlugin:Webpack 内置插件,会把数组中列出的 process.env.XXX编译时替换为当前构建时的值,因此业务里写 process.env.UNI_PLATFORMprocess.env.DISTRICTprocess.env.API_BASE_URL 等即可,无需再传参。

3.5 按地区动态生成 pages.json(build/pages.json.js)

不同地区可能需要不同的页面列表、tabBar 等,因此把「基础配置」与「地区配置」合并后再写入 src/pages.json

build/pages.json.js:

const fs = require('fs')
const path = require('path')
const lodash = require('lodash')
const standardPages = require('../config/pages.js')

let districtPage = {}
let customPages = []
const district = process.env.DISTRICT

// 若存在该地区的 pages 配置则合并
try {
  districtPage = require(`../config/districts/${district}/pages.js`)
} catch (e) {
  console.log(`不存在【${district}】地区差异化 page.js,略过`)
}

// 若有 CUSTOM_TAB,可再合并 custom.xxx.pages.js
process.env.CUSTOM_TAB.split(',').forEach((type) => {
  // ... 按 type 加载 config/custom.${type}.pages.js 与 config/districts/${district}/ 下同名文件
})

const pageJSON = lodash.mergeWith({}, standardPages, districtPage, ...customPages, (objValue, srcValue, key) => {
  if (Array.isArray(objValue) && key === 'list') {
    return srcValue  // 如 tabBar.list 用地区配置覆盖,而不是数组合并
  }
})

fs.writeFileSync(path.join(__dirname, '../src/pages.json'), JSON.stringify(pageJSON, null, 2), { encoding: 'utf8' })
console.log('page.json 构建完成')

思路:对基础 pages + 地区 pages + 自定义 tab 配置 做 merge,对 list 类数组采用覆盖策略,避免 tab 等被意外合并。


3.6 按地区动态生成 manifest.json(build/manifest.json.js)

小程序与 H5 的 appid、描述等可能按地区不同,同样采用「标准 manifest + 地区 manifest」合并。

build/manifest.json.js:

const fs = require('fs')
const path = require('path')
const lodash = require('lodash')

const standardManifest = {
  name: 'app',
  appid: 'xxx',
  'mp-weixin': { /* ... */ },
  'mp-alipay': { /* ... */ }
  // ...
}

let districtManifest = {}
try {
  districtManifest = require(`../config/districts/${process.env.DISTRICT}/manifest.json.js`)
} catch (e) {
  console.log('该地区不存在差异化 manifest.json.js')
}

const merged = lodash.merge({}, standardManifest, districtManifest)
fs.writeFileSync(path.join(__dirname, '../src/manifest.json'), JSON.stringify(merged, null, 4), { encoding: 'utf8' })
console.log('manifest.json 生成完成')

地区目录示例:config/districts/anhui/manifest.json.jsconfig/districts/xinjiang/pages.js 等,按需添加。


3.7 业务代码中如何使用

构建时环境变量已被注入,业务中直接读 process.env 即可。

按平台分支:

// 例如仅微信小程序展示某模块
if (process.env.UNI_PLATFORM === 'mp-weixin') {
  // 微信逻辑
}

// 计算属性中
moreModuleGroups: vm => process.env.UNI_PLATFORM === 'mp-weixin'
  ? xxxx
  : xxxx

按地区使用配置:

// 使用别名 @district,实际指向 src/district/${DISTRICT}
import banner from '@district/banner.jpg'

// 或直接使用注入的常量
const district = process.env.DISTRICT
const apiBase = process.env.API_BASE_URL

请求 API:

// 封装请求时用 process.env.API_BASE_URL 作为 baseURL
axios.create({ baseURL: process.env.API_BASE_URL })

四、目录与脚本约定小结

角色 路径/命令 作用
环境变量注入 cross-env UNI_PLATFORM=... DISTRICT=... DEPLOY=... 在 npm script 中传入维度
映射配置 config/preBuild.config.js 平台/地区/环境 → 客户端类型、标题、API
构建前逻辑 build/preBuild.js 设置 CLIENT_TYPE、API_BASE_URL 等并生成 pages/manifest
Webpack 入口 vue.config.js require preBuild、配置 @district、EnvironmentPlugin
动态页面配置 build/pages.json.js 合并基础 + 地区 pages,写回 src/pages.json
动态 manifest build/manifest.json.js 合并基础 + 地区 manifest,写回 src/manifest.json
地区前端配置 src/district/<DISTRICT>/config.js 业务通过 @district 引用
地区构建配置 config/districts/<DISTRICT>/pages.jsmanifest.json.js 仅该地区生效的页面与 manifest

五、新增地区/环境/平台时的操作清单

  1. 新增地区

    • config/preBuild.config.jsDISTRICT_CONFIG_MAP 中增加 APP_TITLE 等。
    • 如需差异化页面:在 config/districts/<新地区>/ 下增加 pages.js
    • 如需差异化 manifest:在 config/districts/<新地区>/ 下增加 manifest.json.js
    • src/district/ 下新增同名目录及 config.js(可参考现有地区)。
  2. 新增部署环境

    • config/preBuild.config.jsAPI_BASE_URL_MAP 中增加 DEPLOY → API 地址。
    • package.json 的 scripts 中增加对应 dev/build:平台:地区:环境 命令。
  3. 新增平台

    • CLIENT_TYPE_MAP 中增加平台与客户端类型。
    • 若 uniapp 支持该平台,只需在 script 中增加 UNI_PLATFORM=新平台 的 dev/build 脚本即可。

六、注意事项

  • EnvironmentPlugin 与 preBuild 一致:凡在 preBuild.js 里新加的 process.env.XXX,若要在业务代码中使用,需在 vue.config.jsEnvironmentPlugin 数组中增加 'XXX'
  • DISTRICT / DEPLOY 默认值:preBuild 中使用了 process.env.DISTRICT || 'xuzhou'process.env.DEPLOY || 'dev',未传时会有默认地区与环境,可按需修改。
  • 跨平台兼容:使用 cross-env 可避免在 Windows 与 Mac/Linux 下环境变量写法不一致的问题。

按上述方案,即可用「地区 + 平台 + 环境」三维度通过一条命令完成打包,并实现配置集中、扩展清晰,欢迎大家学习指正!

symbol为什么说是为了解决全局变量冲突的问题

首先,先说结论,即Symbol 代表创建后独一无二且不可变的数据类型,它主要是为了 解决可能出现的全局变量冲突的问题。

这是一个关于 JavaScript 模块化历史的设计问题,下面将为你解释 Symbol 与全局变量冲突问题的关系。

Symbol 解决全局变量冲突问题的核心机制

1. 传统字符串键的冲突问题

在 Symbol 出现之前,JavaScript 对象的属性名只能是字符串。这导致了严重的命名冲突问题,尤其是在以下场景:

// 场景1:第三方库扩展原生对象(旧时代的做法)
// 库A添加了一个方法
Array.prototype.filter = function() { /* 库A的实现 */ };

// 库B也添加了一个同名方法
Array.prototype.filter = function() { /* 库B的实现 */ };

// 库A的实现被覆盖了!这就是冲突
// 场景2:元编程中的属性名冲突
const obj = {
  name: '真实数据',
  // 但如果我想存储一些"元信息"(比如缓存、内部状态)
  // 用 'name' 作为键?不行,会覆盖真实数据
  // 用 '_name'?还是可能冲突
  // 用 '__internal_name_2024__'?丑陋且仍不保险
};

2. Symbol 的解决方案:唯一性保证

Symbol 创建的每个值都是全局唯一的,即使描述相同:

const sym1 = Symbol('key');
const sym2 = Symbol('key');

console.log(sym1 === sym2); // false!完全不同的两个标识符

// 这意味着你可以安全地创建"不会冲突"的属性键
const obj = {
  name: '真实数据',
  [Symbol('metadata')]: '内部元数据',  // 绝对不会与 'name' 冲突
  [Symbol('metadata')]: '更多元数据',  // 甚至不会与上面的 Symbol 冲突!
};

3. 实际应用场景

场景 A:Well-Known Symbols(避免标准方法冲突)

// ES6 用 Symbol 定义迭代协议,而不是字符串 'iterator'
// 这样不会与旧代码中可能存在的 'iterator' 属性冲突
const myObj = {
  [Symbol.iterator]: function* () {
    yield 1; yield 2; yield 3;
  }
};
// 即使有人写了 myObj.iterator = 'something',也不会破坏 for...of 循环

场景 B:私有属性的模拟(模块级隔离)

// module.js - 创建一个模块私有的 Symbol
const privateKey = Symbol('private');  // 不导出,外部无法访问

export class MyClass {
  constructor() {
    this[privateKey] = '真正的私有数据';
  }
  
  getPrivateData() {
    return this[privateKey];
  }
}

// 外部代码即使拿到实例,也无法轻易访问 privateKey
// 因为拿不到这个 Symbol 引用

场景 C:框架/库的内部状态标记

// React 内部使用 Symbol 标记特殊元素(简化示意)
const REACT_ELEMENT_TYPE = Symbol.for('react.element');

function createElement(type, props) {
  return {
    $$typeof: REACT_ELEMENT_TYPE,  // 确保是 React 创建的元素,而非恶意 JSON
    type, props
  };
}

// 使用 Symbol.for 可以在不同 iframe/service worker 间共享
// 但仍是全局唯一的,不会与普通字符串属性冲突

4. Symbol.for() 与全局 Symbol 注册表

// Symbol.for 在全局注册表中创建/获取 Symbol,跨 realm 可用
const globalSym = Symbol.for('app.config');  // 全局唯一

// 在另一个文件中,甚至另一个 iframe 中:
const sameSym = Symbol.for('app.config');

console.log(globalSym === sameSym); // true - 同一个全局标识符

// 这解决了"跨执行上下文共享唯一键"的需求
// 同时仍然避免与任何字符串键冲突

5. 关键特性总结

特性 字符串键 Symbol 键
唯一性 相同字符串即相同键 每个 Symbol 实例唯一
可预测性 容易被猜测/覆盖 引用必须被显式传递
for...in 遍历 ✅ 会被遍历 ❌ 默认不可见(隐藏性)
Object.keys() ✅ 包含 ❌ 不包含
JSON.stringify ✅ 序列化 ❌ 自动忽略

结论

Symbol 解决全局变量冲突的本质是:将命名空间从"全局字符串命名空间"转移到了"全局唯一的值引用空间"

  • 之前:所有代码共享同一个字符串命名空间,命名冲突是概率问题
  • 之后:每个 Symbol 创建时自动获得全局唯一的身份,冲突从概率问题变成了不可能事件(除非显式传递 Symbol 引用)

这使得 JavaScript 终于能够安全地进行元编程(在对象上附加元数据而不污染其正常属性),以及实现真正的模块化私有成员。 所以说,Symbol主要是为了 解决可能出现的全局变量冲突的问题

低代码平台表单设计系统架构分析(实战一)

1. 整体架构

我实战的低代码平台基于 Vue 3 + Element Plus + Vite 构建,实现了一个功能完整的表单设计系统。图片截图为简道云的,学习和体验可以去看看。

核心架构特点:

  • 三层结构设计 :左侧组件库、中间画布区域、右侧属性配置
  • 组件化架构 :每个表单组件都有独立的视图和配置 
  • 数据驱动 :通过表单数据模型驱动整个设计过程
  • 事件总线 :使用事件总线实现组件间通信

2. 核心组件分析

主组件

xxxx.vue 主组件,是整个表单设计系统的核心组件,负责整体布局和状态管理。

该组件分为左侧组件库(放置可拖拽的组件列表)、中间画布区域(可以对已添加的组件进行复制、删除、调整顺序)、右侧属性配置(分为字段属性配置、表单属性配置)

核心功能:

  • 管理左侧组件库的展示和拖拽 
  • 处理组件拖拽到画布的逻辑
  • 管理当前选中组件的状态
  • 协调右侧属性配置面板的显示

组件定义系统

通过 xxxx.js 定义了丰富的表单组件类型,每种组件都有详细的配置项:

// 以单行文本组件定义为例
const text = {
  itemId: '', // 自动生成
  type: 'text', // 类型
  placeholder: '', // 提示文字
  title: '单行文本', // 类型标题
  label: '单行文本',
  isShowTitle: true, // 是否显示标题
  value: null, // 默认值
  // 更多配置项...
}

组件类型丰富:

  • 基础输入组件:单行文本、多行文本、数字、日期时间等
  • 选择组件:下拉框、单选按钮组、复选框组等
  • 高级组件:成员选择、部门选择、地址、子表单等
  • 布局组件:多标签页、分割线等
  • 数据组件:关联数据、数据查询、聚合计算等

3. 数据流设计

表单数据结构:

  • list :存储表单组件列表
  • config :存储表单整体配置
  • 每个组件都有独立的配置对象

数据传递方式:

  • 父子组件通过 props 传递
  • 跨组件通信通过事件总线(eventBus)
  • 复杂状态管理通过 provide/inject

4. 技术亮点

  • 拖拽功能实现 :使用 vuedraggable 库实现组件的拖拽添加和排序
  • 动态组件渲染 :根据组件类型动态加载对应的视图和配置组件
  • 响应式布局 :支持不同的表单布局方式(单列、双列、三列、四列)
  • 丰富的组件类型 :提供了20+种表单组件,满足各种业务场景
  • 灵活的属性配置 :每个组件都有详细的属性配置选项
  • 数据联动能力 :支持组件间的数据联动和公式计算
  • 权限控制 :支持字段级别的可见性和编辑权限控制

5. 代码优化建议

性能优化 :

  • 对于大型表单,可以考虑使用虚拟滚动减少DOM节点
  • 组件配置面板可以使用动态导入减少初始加载体积

代码结构优化 :

  • 可以将组件定义和配置逻辑进一步模块化 
  • 考虑使用 Pinia 或 Vuex 管理复杂状态,替代事件总线

用户体验优化 :

  • 添加组件搜索功能,方便用户快速找到需要的组件
  • 实现表单模板功能,支持常用表单的快速创建
  • 添加撤销/重做功能,提高用户操作体验

6. 总结

实战的低代码平台的表单设计系统,实现了丰富的功能和良好的用户体验。其核心价值在于:

  • 降低开发成本 :通过可视化设计减少代码编写
  • 提高开发效率 :拖拽式操作和丰富的组件库
  • 增强可维护性 :模块化设计和清晰的代码结构
  • 支持复杂业务场景 :丰富的组件类型和配置选项

我们的这种架构设计不仅适用于表单设计,也扩展到其他低代码场景。我们同时还有流程引擎、数据管理等配套。

下一篇预告 :《表单组件体系设计与实现》,将详细分析各种表单组件的设计原理和实现方式。

从 Tauri 2.0 Beta 升级到 2.0 Release Candidate Capabilities 权限前缀与内置 Dev Server 网络策略变

1. 一句话结论:这次升级你要重点改哪两处

Capabilities(权限配置)
旧写法:path:defaultwindow:default ……
新写法:要么统一加 core: 前缀,例如 core:path:default
要么直接用一个更省事的集合权限:core:default

内置开发服务器(尤其 iOS 真机)
以前你可能通过 TAURI_ENV_PLATFORM 判断 android/ios,再去暴露 0.0.0.0、算内网 IP、配 HMR
现在更推荐直接用 TAURI_DEV_HOST:能连 localhost 就别搞全网暴露;需要暴露时,Tauri 会告诉你应该用哪个 host

2. 自动迁移优先:先跑 migrate,再做人工复核

官方建议的升级方式是先用 v2 CLI 的 migrate 做自动改动,然后再手动处理剩余 breaking changes。

cargo install tauri-cli --version "^2.0.0" --locked
cargo tauri migrate

建议你把这条命令放在一个独立分支上跑(比如 chore/tauri-rc-migrate),这样 diff 会非常清晰。

迁移后你至少要人工检查两件事:

  • src-tauri/capabilities/*.json(或相应能力文件)里的 permissions 是否符合新规则
  • 前端 dev server 配置是否已切换到 TAURI_DEV_HOST 逻辑

3. Breaking Change 1:核心插件权限标识统一加 “core:” 前缀

3.1 发生了什么

从 beta 到 RC,Tauri 调整了“内置核心插件(core plugins)”在 capabilities 里被引用的方式。
你原来 capability 里写的这些:

"permissions": [
  "path:default",
  "event:default",
  "window:default",
  "app:default",
  "image:default",
  "resources:default",
  "menu:default",
  "tray:default"
]

需要改成加 core: 前缀:

"permissions": [
  "core:path:default",
  "core:event:default",
  "core:window:default",
  "core:app:default",
  "core:image:default",
  "core:resources:default",
  "core:menu:default",
  "core:tray:default"
]

3.2 更推荐的写法:直接用 core:default

为了减少样板代码,RC 增加了一个“特殊集合权限”core:default,它包含所有 core plugins 的默认权限。

也就是说,上面那一长串可以直接写成:

"permissions": [
  "core:default"
]

推荐策略:

  • 你只是需要 core 插件的默认能力:用 core:default,最省心
  • 你项目对权限非常敏感,需要精确控制:继续按需列出 core:* 的细粒度权限

迁移验收点:

  • 升级后如果出现“前端调用 core API 失败 / 权限不足”的报错,第一时间就去看 capability 文件里是不是还残留 path:default 这种旧标识

4. Breaking Change 2:内置开发服务器网络暴露策略改变(移动端调试更安全)

4.1 发生了什么

RC 对内置移动端开发服务器做了网络暴露策略调整:
移动端开发服务器不再默认“全网暴露并转发流量”,而是更倾向于让设备通过更安全、更直接的方式连接到本机服务。

这会直接影响你的前端 dev server 配置方式,因为以前很多模板都在做这些事:

  • host: '0.0.0.0'(让局域网设备都能连)
  • internal-ip 算出本机 IPv4 作为 HMR host
  • TAURI_ENV_PLATFORM 判断 android/ios 就强制启用“移动端模式”

RC 的思路是:能用 localhost 就用 localhost,只有在必要时才用特定 host,并且把这个 host 通过 TAURI_DEV_HOST 传给前端工具链。

4.2 iOS 真机特别说明:TAURI_DEV_HOST + Xcode TUN 地址

目前这个“更安全的连接方式”在 iOS(直接连真机或从 Xcode 跑)场景下不一定能自动生效。官方给的解决方案思路是:
1)打开 Xcode,让 macOS 和 iOS 设备建立连接通道
2)运行 tauri ios dev --force-ip-prompt
3)选择 iOS 设备的 TUN 地址(通常结尾是 ::2

这部分你不需要把细节写死在 Vite 配置里,关键是:让前端 dev server 读取 TAURI_DEV_HOST

5. Vite 迁移:从 “internal-ip + TAURI_ENV_PLATFORM” 到 “TAURI_DEV_HOST”

这是最实用的一段,因为它直接决定你真机调试是否顺畅。

5.1 Beta 常见写法(旧)

核心特征:

  • 通过 TAURI_ENV_PLATFORM 判断 mobile
  • mobile 时用 0.0.0.0
  • HMR host 用 internal-ip 算出来
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { internalIpV4Sync } from 'internal-ip';

const mobile = !!/android|ios/.exec(process.env.TAURI_ENV_PLATFORM);

export default defineConfig({
  plugins: [svelte()],
  clearScreen: false,
  server: {
    host: mobile ? '0.0.0.0' : false,
    port: 1420,
    strictPort: true,
    hmr: mobile
      ? {
          protocol: 'ws',
          host: internalIpV4Sync(),
          port: 1421,
        }
      : undefined,
  },
});

5.2 RC 推荐写法(新)

核心特征:

  • 直接读取 TAURI_DEV_HOST
  • 有 host 才暴露网络与启用 HMR host
  • 不再需要 internal-ip 依赖
  • 示例里 HMR 端口从 1421 改到了 1430(按你的内容照搬)
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';

const host = process.env.TAURI_DEV_HOST;

export default defineConfig({
  plugins: [svelte()],
  clearScreen: false,
  server: {
    host: host || false,
    port: 1420,
    strictPort: true,
    hmr: host
      ? {
          protocol: 'ws',
          host: host,
          port: 1430,
        }
      : undefined,
  },
});

迁移验收点:

  • 你的 package.json 里可以去掉 internal-ip
  • 真机调试时,如果 Tauri 注入了 TAURI_DEV_HOST,HMR 也会跟着正确走
  • 如果你之前硬编码了 0.0.0.0,升级后建议先移除,避免无意义扩大暴露面

6. 升级后的快速自检清单(5 分钟验收)

1)Capabilities 是否已迁移

  • 搜索 capability 文件里是否还有 path:default 这种旧值
  • 优先改成 core:defaultcore:*:default

2)前端 dev server 是否已切 TAURI_DEV_HOST

  • 搜索 TAURI_ENV_PLATFORMinternal-ipinternalIpV4Sync
  • 替换为 const host = process.env.TAURI_DEV_HOST

3)真机(尤其 iOS)是否能连上 dev server

  • 若连接失败,按官方提示用 Xcode 建连接后 tauri ios dev --force-ip-prompt 选 TUN ::2 地址
  • 确认此时 TAURI_DEV_HOST 有值,并被 Vite 使用

7. 常见踩坑与解决思路

权限全挂但你以为是代码问题
表现:invoke / core API 提示权限不足或无权限
处理:先别改业务代码,先把 capabilities 的 core 前缀或 core:default 处理好

HMR 能开但页面不热更
表现:能打开页面但热更新没反应
处理:看 Vite server.hmr.host 是否仍然是旧的内网 IP 逻辑;改为 TAURI_DEV_HOST

依赖没删干净导致 lockfile 混乱
表现:internal-ip 还在,或者打包时出现 node 依赖冲突
处理:删除 internal-ip、重新安装依赖,确保 lockfile 与新配置一致

FE视角下的Referrer全面解析

一、核心概念解析

1.1 什么是Referrer?

  • Referrer(引荐来源)是 HTTP 协议中的一个标准头部字段,用于标识当前请求的来源页面 URL。当用户从页面 A 跳转到页面 B 时,浏览器会在请求页面 B 的 HTTP 头部自动携带 Referer: [A的URL]。

  • 技术特性:

    • 遵循同源策略,跨域时可能被过滤
    • 包含完整URL结构(协议+域名+路径+参数)
    • 前端可通过document.referrer读取

// 获取来源页面示例

console.log('Referrer来源:', document.referrer);

1.2 浏览器差异性

  • Chrome:默认发送完整Referrer
  • Safari:智能跟踪防护可能截断
  • Firefox:支持最新Referrer Policy规范

二、核心应用场景

  • 安全防护:服务器可以根据 Referer 头验证请求来源合法性,防止跨站请求伪造(CSRF)攻击;根据关键操作日志记录进行敏感操作溯源。
  • 日志分析与流量追踪:网站可以通过 Referer 分析流量来源,了解哪些外部页面或广告带来了流量。
  • 内容定向与个性化:根据 Referer 字段判断用户是否通过某个推广链接、广告或推荐页面访问,进而定向展示不同的内容,也可以进行合作伙伴流量区分。

三、策略配置指南

3.1 多层级控制机制

优先级矩阵:

设置方式 优先级 作用范围
标签 当前文档
请求响应头 整个域名
元素级属性a标签 单个元素

3.2 配置示例

HTML全局设置:


<meta name="referrer" content="strict-origin-when-cross-origin">

元素级控制:


<a href="https://external.com" rel="noreferrer">安全跳转</a>

HTTP响应头设置:

add_header Referrer-Policy "no-referrer";

3.3 Fetch API策略


// 禁用Referrer示例

fetch('/api', {

referrer: "",

referrerPolicy: "no-referrer"

});

3.4 referrerPolicy

Referrer Policy是W3C官方提出的一个候选策略,主要用来规范Referrer

配置对照表

同源 跨源 HTTPS→HTTP
"no-referrer" - - -
"no-referrer-when-downgrade"或 ""(默认) 完整的 url 完整的 url -
"origin" 仅域 仅域 仅域
"origin-when-cross-origin" 完整的 url 仅域 仅域
"same-origin" 完整的 url - -
"strict-origin" 仅域 仅域 -
"strict-origin-when-cross-origin" 完整的 url 仅域 -
"unsafe-url" 完整的 url 完整的 url 完整的 url

四、安全风险与应对方案

4.1 典型风险场景

风险类型 案例场景 解决方案
URL参数泄露 密码重置链接token暴露 动态策略调整
管理路径暴露 后台地址出现在第三方日志 Nginx强制策略
GDPR合规风险 用户访问路径记录包含个人数据 数据匿名化处理

4.2 敏感页面保护方案

<script>

// 动态调整策略

if (location.pathname.includes('/admin')) {

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

meta.name = 'referrer';

meta.content = 'no-referrer';

document.head.appendChild(meta);

}

</script>

4.3 数据匿名化处理


function sanitizeReferrer(url) {

const u = new URL(url);

return `${u.origin}${u.pathname}`.replace(/\/user\/\d+/g, '/user/{id}');

}

五、跨浏览器兼容策略

5.2 兼容性处理方案

  • 特性检测:if ('referrerPolicy' in document.createElement('a'))

  • 渐进增强:优先使用标签设置全局策略

  • 服务端兜底:日志系统进行Referrer清洗

// 浏览器特性检测与降级处理

function applyReferrerPolicy() {

const policies = ['strict-origin-when-cross-origin', 'no-referrer-when-downgrade'];

  


if ('document' in globalThis && document.createElement('meta').hasAttribute('referrerpolicy')) {

// 支持新式策略

document.querySelector('meta[name="referrer"]').content = policies[0];

} else {

// 传统浏览器降级处理

window.onclick = (e) => {

if (e.target.tagName === 'A' && isExternalLink(e.target.href)) {

e.target.rel += ' noreferrer';

}

};

}

}

六、最佳实践总结

  1. 最小化原则:采用最严格的策略等级

  2. 动态调整:根据页面敏感程度切换策略

  3. 双重验证:客户端+服务端联合校验


参考文献:www.w3cschool.cn/qoyhx/qoyhx…

扩展阅读:www.w3cschool.cn/qoyhx/qoyhx…

Tauri 1.0 升级到 Tauri 2.0从“能跑”到“跑得稳”的迁移实战指南(含移动端准备、配置重构、插件化 API、权限系统)

1. 为什么 Tauri 2.0 的升级不是“改个版本号”那么简单

Tauri 2.0 的变化核心有三类:

  1. 配置体系大重构
    tauri.conf.json 的结构变化非常大:顶层字段搬家、tauri 键改名为 app、allowlist 被移除、updater/cli 等移动到插件体系。
  2. API 全面插件化
    以前 @tauri-apps/api 里很多模块(fs/http/shell/notification…)属于“内置模块”,2.0 里这些大多变成插件(Rust 侧 + JS 侧都要装)。
  3. 权限系统(Capabilities)取代 allowlist
    v1 的 allowlist 被替换为更精细的 ACL 模型:可以按窗口、域名、命令、scope 精确放行/拒绝,并且通过 src-tauri/capabilities/ 下的能力文件生效。

这意味着:升级并不难,但必须“按模块拆解迁移”,否则就会出现典型的“编译过了但功能没了”。

2. 升级前的准备清单(强烈建议先做)

  1. 锁定当前可工作的 v1 基线
    打一个 tag:v1-stable
    确保你能随时回滚。
  2. 清点你用到的 v1 API/功能点
    至少列出这些关键项(后面会对应到插件迁移):
  • fs / path / http / shell / notification / clipboard / updater / process / os / cli / global-shortcut
  • tray / menu
  • 自定义协议(asset protocol、pattern、scope)
  • allowlist(尤其是 sidecar / fs scope / shell open / process command)
  1. 清点你当前的配置文件(tauri.conf.json)
    把老结构保存一份,迁移时你会频繁对照字段搬家。

3. 移动端准备:把 crate 变成“可共享库”

如果你的目标包含移动端(Android/iOS),2.0 的移动端接口要求项目能输出共享库,所以需要做这些结构调整。

3.1 修改 Cargo.toml:输出库产物

src-tauri/Cargo.toml 追加:

[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]

含义很直白:同一套 Rust 入口需要同时服务桌面端可执行文件和移动端的库载入。

3.2 入口文件拆分:main.rs 变薄,lib.rs 变“通用入口”

  1. src-tauri/src/main.rs 重命名为 src-tauri/src/lib.rs
  2. main() 改成通用的 run(),并加上移动端入口宏:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    // your code here
}

tauri::mobile_entry_point 会让这个入口在移动端以正确方式被调用。

3.3 重建 main.rs:桌面端只负责调用 run()

重新创建 src-tauri/src/main.rs

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
  app_lib::run();
}

迁移到这里,你就完成了“同一份 Rust 逻辑,多端复用”的基础改造。

4. 自动迁移:migrate 命令能省力,但不能偷懒

官方给的警告很重要:migrate 不是替代指南,它只是帮你自动做“机械改动”,真正的功能迁移(插件、权限、业务代码)仍然要你手动完成。

4.1 安装 v2 CLI 并运行 migrate

cargo install tauri-cli --version "^2.0.0" --locked
cargo tauri migrate

它通常会帮你做:

  • 部分配置字段的迁移
  • 把 v1 allowlist 解析成 capability 文件的“初稿”

但注意:生成出来的 capability 是否符合你真实的窗口/域名/sidecar 场景,需要你自己复核。

5. 配置文件迁移:tauri.conf.json 的字段搬家地图

下面这一段是升级里最容易“看起来没报错,但运行行为变了”的地方。你贴的 Summary of Changes 很全,我把它整理成“迁移动作”。

5.1 顶层结构变化

  • package > productNamepackage > version 移到顶层
  • package 节点整体移除
  • tauri 键改名为 app
  • tauri > bundle 移到顶层 bundle
  • tauri > bundle > identifier 移到顶层(通常与 bundle 标识相关)

5.2 二进制名称不再自动跟随 productName

v1 里经常“产品名改了,生成的可执行文件名也跟着变”。
v2 不会自动改了,你必须加一个 mainBinaryName,并保证它与 productName 对齐(否则打包/运行时可能找不到主程序)。

5.3 allowlist 移除:改成 Permissions / Capabilities

  • tauri > allowlist 被移除
    迁移方向:创建 src-tauri/capabilities/ 下的能力文件(migrate 命令会帮你生成雏形),再按插件逐项放行。

5.4 安全相关字段的移动

  • tauri > allowlist > protocol > assetScopeapp > security > assetProtocol > scope
  • tauri > patternapp > security > pattern
  • tauri > windows > fileDropEnabledapp > windows > dragDropEnabled
  • build > withGlobalTauriapp > withGlobalTauri
  • build > distDirfrontendDist
  • build > devPathdevUrl

5.5 插件配置位置变化

  • tauri > cliplugins > cli
  • tauri > updaterplugins > updater
  • tauri > updater > active/dialog 等旧字段移除(2.0 updater 行为不同,见后文)
  • tauri > systemTrayapp > trayIcon

5.6 bundle 子结构重命名(按 OS 归档)

  • tauri > bundle > dmgbundle > macOS > dmg
  • tauri > bundle > debbundle > linux > deb
  • tauri > bundle > appimagebundle > linux > appimage
  • Windows WebView Runtime 字段变化:
    bundle > windows > webviewFixedRuntimePath 移除 → 用 bundle > windows > webviewInstallMode

5.7 Updater 的兼容性坑:createUpdaterArtifacts 与 v1Compatible

  • 新增 bundle > createUpdaterArtifacts
    如果你之前已经分发过 v1 应用,并且希望用户能“从旧版本升级到新版本”,通常需要设置为 v1Compatible(它影响升级包生成方式)。这是“升级后用户收不到更新”的高发原因之一。

6. Rust 侧变化:API 模块被拆成插件,很多熟悉的路径都没了

6.1 Cargo feature 变化

新增:

  • linux-protocol-body(需要 webkit2gtk 2.40)用于自定义协议 request body 解析

移除/更名(重点):

  • process-command-apishell-open-api:都改用 tauri-plugin-shell
  • updater:Updater 变成插件
  • system-tray:改名为 tray-icon
  • windows7-compat:移动到 notification 插件

6.2 Rust crate API 变化(你最可能遇到的报错来源)

以下是典型“编译报错 → 对应迁移方向”:

  • tauri::api::* 模块整体移除:每个 API 去对应插件里找

    • tauri::api::dialogtauri-plugin-dialog
    • tauri::api::httptauri-plugin-http
    • tauri::api::shell / tauri::api::process::Commandtauri-plugin-shell
    • tauri::updatertauri-plugin-updater
    • tauri::api::notificationtauri-plugin-notification
  • tauri::api::file:直接用 Rust 的 std::fs

  • tauri::api::pathtauri::PathResolver:迁移到 tauri::Manager::path

示例(path 迁移):

use tauri::{path::BaseDirectory, Manager};

tauri::Builder::default()
  .setup(|app| {
      let home = app.path().home_dir().expect("failed to get home dir");
      let p = app.path().resolve("path/to/something", BaseDirectory::Config)?;
      Ok(())
  });

6.3 Menu/Tray 重构(muda + 新 builder)

Menu:改到 tauri::menu,大量旧类型移除,换成 Builder 风格(你贴的 MenuBuilder / MenuItemBuilder / SubmenuBuilder 就是迁移模板)。

Tray:SystemTray 全部改名为 TrayIcon(更一致),事件拆成:

  • on_menu_event
  • on_tray_icon_event

你可以直接套用你贴的 TrayIconBuilder 示例结构去改。

7. JavaScript 侧变化:@tauri-apps/api “瘦身”,其他都去插件包

v2 的结论非常明确:

  • @tauri-apps/api 只保留:core(原 tauri)、path、event、window/webviewWindow
  • 其他模块:fs/http/shell/os/process/notification/dialog/clipboard/updater/cli/global-shortcut 全部变成 @tauri-apps/plugin-*

7.1 最常见的一刀:@tauri-apps/api/tauri → @tauri-apps/api/core

import { invoke } from "@tauri-apps/api/tauri"
import { invoke } from "@tauri-apps/api/core"

这一步非常常见,很多项目第一处报错就来自这里。

7.2 插件迁移的统一模式(记住这个模板就够了)

每个插件基本都要做三件事:

  1. Rust Cargo 加依赖:tauri-plugin-xxx = "2"
  2. Rust Builder 初始化:.plugin(tauri_plugin_xxx::init()) 或 Builder 风格
  3. 前端 npm/pnpm/yarn 加依赖:@tauri-apps/plugin-xxx
  4. 前端 import 改到插件

比如 Dialog:

Rust:

tauri::Builder::default()
  .plugin(tauri_plugin_dialog::init())

前端:

import { save } from '@tauri-apps/plugin-dialog';

你贴的 Clipboard/CLI/FS/HTTP/Notification/Process/Shell/Updater/OS/Global Shortcut 都是同样套路。

7.3 FS 插件的 API 改名坑

v1 常见函数重命名如下(你贴的很关键):

  • createDirmkdir
  • readBinaryFilereadFile
  • writeBinaryFilewriteFile
  • removeDir/removeFileremove
  • renameFilerename
  • Dir 别名移除 → 用 BaseDirectory

如果你迁移后“明明装了插件但类型/函数不存在”,通常就是这里没改干净。

8. 权限系统迁移:Capabilities 取代 allowlist(这是 v2 的安全核心)

v1 allowlist 只是一种“模块级开关”。
v2 Capabilities 是真正的 ACL:可以精确到“允许哪个窗口在什么域名下调用哪个命令,scope 是什么”。

你必须做的事情:

  • src-tauri/capabilities/ 创建能力文件(migrate 会帮你生成初稿)
  • 按插件逐项配置允许项
  • 如果你有 sidecar、remote URL、多窗口(甚至未来 multiwebview),能力文件要同步升级,否则功能会“静默失效”

一句话经验:
迁移成功的标准不是“能编译”,而是“权限文件覆盖了你真实的调用路径”。

9. 事件系统、窗口系统、Windows origin:升级后行为变化最大的三块

9.1 事件系统:emit 默认广播,新增 emit_to / emitTo

  • emit() 现在会发给所有监听器
  • 新增 emit_to/emitTo:定向发给某个 target
  • listen_globallisten_any
  • JS 的 event.listen() 行为更接近 listen_any(除非你设置 target)

如果你原来依赖“按窗口隔离事件”,升级后要重点检查:有没有出现“别的窗口也收到了消息”。

9.2 Window → WebviewWindow(为 multiwebview 做铺垫)

Rust:

  • Window 改名为 WebviewWindow
  • WindowBuilderWebviewWindowBuilder
  • WindowUrlWebviewUrl
  • get_windowget_webview_window

JS:

  • @tauri-apps/api/window 改到 @tauri-apps/api/webviewWindow

9.3 Windows 生产环境 origin 变了:tauri.localhost

v2 在 Windows 生产环境下,前端文件从 https://tauri.localhost 变成 http://tauri.localhost。直接影响:

  • IndexedDB / LocalStorage / Cookies 可能被重置(因为 origin 变了)

解决方案(你贴的官方建议):

  • 设置 app > windows > useHttpsScheme 为 true
    或 Rust 侧用 WebviewWindowBuilder::use_https_scheme 保持 https 方案。

这是那种“用户升级后发现登录态没了”的典型坑,务必提前处理。

10. Updater 迁移:默认自动弹窗没了,你必须自己做更新流程

v2 移除了“内置的自动检查 + 内置弹窗”。
如果你不手动实现检查与安装,你的用户可能永远不会再收到更新。

迁移路径:

  • Rust:tauri-plugin-updater = "2".plugin(tauri_plugin_updater::Builder::new().build())
  • JS:@tauri-apps/plugin-updater,自己调用 check(),然后 downloadAndInstall(),最后用 @tauri-apps/plugin-processrelaunch() 重启。

你贴的示例就是标准实现模板。

11. 环境变量改名:CI/CD、签名、打包脚本要同步更新

这一块特别容易“本地能打包,CI 全挂”。

你贴的改名清单建议直接做两件事:

  1. 搜索你仓库里的旧 env 名称(GitHub Actions / GitLab CI / Jenkinsfile / bat/sh 脚本)
  2. 全量替换为新名称(例如 TAURI_PRIVATE_KEYTAURI_SIGNING_PRIVATE_KEY 等)

如果你有签名/自动发布流水线,这一步是必做项。

12. 迁移路线建议:按“最小可用”分阶段推进

为了避免一次性改太多导致不可控,我建议你按这个顺序做(每一步都能形成可运行状态):

阶段 A:基础可编译 + 前端可打开

  • CLI/依赖升级
  • 配置文件结构迁移(字段搬家、mainBinaryName、devUrl/frontendDist)
  • JS 的 core 模块改名(tauri → core)
  • Rust 入口整理(必要时把 run() 抽到 lib.rs,为移动端留口)

阶段 B:插件逐个恢复功能(每次迁移一个插件就验收一次)

  • dialog / fs / shell / http / notification / os / process / clipboard / cli / global-shortcut / updater

阶段 C:权限系统补齐

  • capability 文件审核与加固
  • sidecar、remote domain、多窗口 target 范围验证

阶段 D:行为变化专项验证

  • 事件系统是否广播导致串扰
  • Windows origin 变化导致存储丢失(useHttpsScheme)
  • tray/menu 行为是否符合预期

13. 最后给你一个“迁移验收 Checklist”(建议复制到你的 PR 描述里)

  • cargo tauri dev 正常启动
  • 生产构建能打包(Windows/macOS/Linux 各自至少跑一遍)
  • mainBinaryNameproductName 配置正确
  • @tauri-apps/api/tauri 全部替换为 @tauri-apps/api/core
  • 用到的 v1 模块全部替换为对应插件,并完成 Rust .plugin(...) 初始化
  • src-tauri/capabilities/ 存在且覆盖你真实调用(特别是 fs/shell/sidecar/updater)
  • Updater 已实现手动检查与安装,否则用户后续无法更新
  • Windows 下如需保留 https origin,已配置 useHttpsScheme
  • 事件系统升级后,窗口间事件不串台(或已改用 emit_to)

uniapp 文件预览:从文件流到多格式预览的完整实现

在 uniapp 开发中,文件预览是一个高频且易踩坑的需求场景 —— 既要兼容 H5 和小程序双端,又要处理网络 URL、文件流(Blob/ArrayBuffer)等不同来源的文件,还要适配图片、PDF、Office 文档等多种格式。本文基于实际项目代码,拆解一套通用、健壮的 uniapp 文件预览方案。

核心需求分析

一个完善的文件预览功能需要解决这些核心问题:

  1. 兼容文件地址(URL)和文件流(Blob/ArrayBuffer)两种数据源
  2. 区分图片、PDF、Word/Excel/PPT 等不同文件类型,提供对应预览方式
  3. 适配 H5 和小程序双端差异(API 不同、文件处理方式不同)
  4. 友好的加载状态提示和错误处理
  5. 支持多张图片预览、接口下载后预览等常见场景

核心接口设计

首先定义统一的预览配置接口,规范入参格式:

/** 文件预览选项接口 */
export interface PreviewFileOptions {
  /** 文件数据,可以是URL地址、Blob对象或ArrayBuffer */
  file: string | Blob | ArrayBuffer
  /** 文件名(可选,用于识别文件类型) */
  fileName?: string
  /** 文件类型(可选,如:'pdf', 'doc', 'jpg'等) */
  fileType?: string
  /** 是否显示加载提示 */
  showLoading?: boolean
}

基础工具函数

1. 获取文件扩展名

文件类型判断的基础,从文件名 / URL 中提取扩展名并统一为小写

/** 根据文件名或URL获取文件扩展名 */
function getFileExtension(fileName: string = ''): string {
  const match = fileName.match(/\.([^.]+)$/)
  return match ? match[1].toLowerCase() : ''
}

2. 文件类型判断

区分图片和可预览的文档类型,便于后续分发处理逻辑:

/** 判断是否为图片文件 */
function isImageFile(ext: string): boolean {
  const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
  return imageExts.includes(ext.toLowerCase())
}

/** 判断是否为可用 openDocument 打开的文档 */
function isDocumentFile(ext: string): boolean {
  const docExts = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']
  return docExts.includes(ext.toLowerCase())
}

3. 文件流转临时文件

这是处理文件流的核心函数,兼容 H5 和小程序双端:

/**
 * 将Blob或ArrayBuffer转换为临时文件
 * @param data Blob或ArrayBuffer数据
 * @param fileName 文件名
 */
async function blobToTempFile(data: Blob | ArrayBuffer, fileName: string = 'temp_file'): Promise<string> {
  // #ifdef H5
  // H5环境:创建临时URL
  if (data instanceof Blob) {
    return URL.createObjectURL(data)
  }
  else {
    const blob = new Blob([data])
    return URL.createObjectURL(blob)
  }
  // #endif

  // #ifndef H5
  // 小程序环境:使用文件系统保存临时文件
  const fs = uni.getFileSystemManager()
  const wxWriteFile = $uni.promisify(fs.writeFile)

  // 确保有文件扩展名
  const ext = getFileExtension(fileName) || 'tmp'
  const filePath = `${(uni as any).env.USER_DATA_PATH}/preview_${Date.now()}.${ext}`

  // 转换数据
  let fileData: ArrayBuffer
  if (data instanceof Blob) {
    // Blob转ArrayBuffer
    fileData = await data.arrayBuffer()
  }
  else {
    // ArrayBuffer类型
    fileData = data
  }

  await wxWriteFile({
    filePath,
    data: fileData,
    encoding: 'binary',
  })

  return filePath
  // #endif
}
  • H5 端:利用URL.createObjectURL生成临时 URL,直接用于预览 / 下载
  • 小程序端:通过文件系统writeFile将二进制数据写入本地临时文件,返回文件路径

核心预览函数实现

previewFile是整个方案的核心,整合了数据源处理、类型判断、双端适配逻辑:

/**
 * 预览文件(支持文件流和文件地址)
 * @param options 预览选项
 */
export async function previewFile(options: PreviewFileOptions) {
  const { file, fileName = '', fileType, showLoading = true } = options

  try {
    if (showLoading) {
      $toast.loading('正在加载文件...')
    }

    let filePath: string
    let ext: string

    // 判断文件来源类型
    if (typeof file === 'string') {
      // 文件地址
      filePath = file
      ext = fileType || getFileExtension(file) || getFileExtension(fileName)
    }
    else {
      // 文件流(Blob或ArrayBuffer)
      ext = fileType || getFileExtension(fileName)
      filePath = await blobToTempFile(file, fileName)
    }

    if (showLoading) {
      $toast.loaded()
    }

    // 根据文件类型选择预览方式
    if (isImageFile(ext)) {
      // 图片预览
      uni.previewImage({
        current: filePath,
        urls: [filePath],
        fail: (err) => {
          console.error('图片预览失败:', err)
          $toast.show('图片预览失败')
        },
      })
    }
    else if (isDocumentFile(ext)) {
      // #ifdef H5
      // H5环境:PDF文件直接在新窗口打开,其他文档尝试下载
      if (ext === 'pdf') {
        window.open(filePath, '_blank')
      }
      else {
        // 其他文档类型在H5中触发下载
        const link = document.createElement('a')
        link.href = filePath
        link.download = fileName || `document.${ext}`
        link.click()
        $toast.show('文件已开始下载')
      }
      // #endif

      // #ifndef H5
      // 小程序环境:使用 openDocument
      let docPath = filePath

      // 如果是网络地址,需要先下载
      if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
        if (showLoading) {
          $toast.loading('正在下载文件...')
        }

        const downloadRes: any = await $uni.downloadOnlineFile(filePath)

        if (showLoading) {
          $toast.loaded()
        }

        if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
          docPath = downloadRes.tempFilePath
        }
        else {
          throw new Error('文件下载失败')
        }
      }

      // 打开文档
      const result = await $uni.openFile(docPath)
      if (result === 'fail') {
        $toast.show('文件打开失败,可能不支持该文件格式')
      }
      // #endif
    }
    else {
      // 其他文件类型
      // #ifdef H5
      // H5环境:触发下载
      const link = document.createElement('a')
      link.href = filePath
      link.download = fileName || `file.${ext}`
      link.click()
      // 下载完成提示
      $toast.show('文件已开始下载')
      // #endif

      // #ifndef H5
      // 小程序环境:尝试使用 openDocument 打开
      let docPath = filePath

      // 如果是网络地址,需要先下载
      if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
        if (showLoading) {
          // 加载提示
          $toast.loading('正在下载文件...')
        }

        const downloadRes: any = await $uni.downloadOnlineFile(filePath)

        if (showLoading) {
          // 加载完成
          $toast.loaded()
        }

        if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
          docPath = downloadRes.tempFilePath
        }
        else {
          throw new Error('文件下载失败')
        }
      }

      const result = await $uni.openFile(docPath)
      if (result === 'fail') {
        $toast.show('无法预览该文件类型')
      }
      // #endif
    }
  }
  catch (error) {
    console.error('文件预览失败:', error)
    // 加载完成
    $toast.loaded()
    $toast.show('文件预览失败')
  }
}

关键逻辑拆解

  1. 数据源处理:区分 URL 字符串和文件流,文件流需先转为临时文件
  2. 双端适配
    • H5 端:PDF 新窗口打开、其他文件触发下载
    • 小程序端:网络文档先下载到本地,再用openDocument打开
  3. 错误处理:全程 try-catch,加载状态统一管理,失败友好提示

扩展功能

1. 多张图片预览

封装专门的图片批量预览函数,简化调用:

/**
 * 预览多张图片
 * @param urls 图片地址数组
 * @param current 当前显示图片的索引,默认为0
 */
export function previewImages(urls: string[], current: number = 0) {
  if (!urls || urls.length === 0) {
    $toast.show('没有可预览的图片')
    return
  }

  uni.previewImage({
    current: urls[current],
    urls,
    fail: (err) => {
      console.error('图片预览失败:', err)
      $toast.show('图片预览失败')
    },
  })
}

2. 接口下载 + 预览

整合 “接口请求文件流 + 预览” 流程,简化业务调用:

/**
 * 从接口下载并预览文件
 * @param url 接口地址
 * @param fileName 文件名(用于判断文件类型)
 * @param options 其他请求选项
 */
export async function downloadAndPreview(url: string, fileName: string, options: any = {}) {
  try {
    $toast.loading('正在下载文件...')

    // 发起请求获取文件流
    const response: any = await new Promise((resolve, reject) => {
      uni.request({
        url,
        method: options.method || 'GET',
        data: options.data || {},
        header: options.header || {},
        responseType: 'arraybuffer', // 获取二进制数据
        success: resolve,
        fail: reject,
      })
    })

    // 加载完成
    $toast.loaded()

    if (response.statusCode === 200) {
      // 预览文件
      await previewFile({
        file: response.data,
        fileName,
        showLoading: true,
      })
    }
    else {
      $toast.show('文件下载失败')
    }
  }
  catch (error) {
    console.error('下载并预览文件失败:', error)
    $toast.loaded()
    $toast.show('文件下载失败')
  }
}

实用示例

1. 预览网络图片

previewFile({ file: 'https://example.com/image.jpg' })

2. 预览接口返回的 PDF 文件流

// 方式1:手动处理文件流
const blob = await fetch('/api/file').then(res => res.blob()) 
previewFile({ file: blob, fileName: 'document.pdf' }) 

// 方式2:使用封装的downloadAndPreview 
downloadAndPreview('/api/download/file', 'document.pdf')

3. 预览多张图片

previewImages(['https://example.com/1.jpg', 'https://example.com/2.jpg'], 0)

避坑指南

  1. 小程序文件权限:小程序中临时文件需放在USER_DATA_PATH目录,避免路径权限问题
  2. H5 临时 URL 释放:如果频繁处理 Blob,记得在合适时机调用URL.revokeObjectURL释放内存(本文示例未实现,可根据需求补充)
  3. 文件类型判断:优先使用传入的fileType,其次从文件名 / URL 提取,避免扩展名判断错误
  4. 小程序下载限制:小程序下载文件需配置 download 域名白名单,否则会下载失败
  5. 错误处理:所有异步操作(下载、写入文件、预览)都要加错误捕获,避免页面卡死

总结

这套文件预览方案基于 uniapp 跨端特性,实现了 “多数据源 + 多文件类型 + 双端适配” 的全场景覆盖,封装的函数可直接集成到项目中。核心思路是:统一入参格式 → 标准化文件处理 → 按类型 / 端分发预览逻辑 → 完善的状态和错误处理

深入解析Vue的mixins与hooks:复用逻辑的两种核心方式

在Vue开发中,代码复用是提升开发效率、保证代码一致性的关键。无论是Vue 2时代的mixins,还是Vue 3 Composition API推出后的hooks,都是实现逻辑复用的核心方案,但二者在设计理念、使用方式和适用场景上存在显著差异。本文将从概念、用法、优缺点、区别对比等方面,全面解析mixins与hooks,帮助开发者在实际项目中做出更合适的选择。

一、Vue mixins:Vue 2时代的逻辑复用方案

1.1 什么是mixins?

mixins(混入)是Vue 2中最常用的逻辑复用方式,本质是一个包含组件选项(data、methods、created、computed等)的对象。当一个组件引入mixins后,mixins中的所有选项会被“合并”到该组件自身的选项中,实现逻辑的复用。

简单来说,mixins就像是一个“公共逻辑模板”,可以将多个组件共用的data、方法、生命周期钩子等提取出来,然后在需要的组件中引入,避免重复编码。

1.2 mixins的基本使用

mixins的使用分为两步:定义mixins、在组件中引入mixins。

第一步:定义mixins

创建一个mixins文件(如commonMixins.js),导出一个包含组件选项的对象:

// commonMixins.js
export default {
  data() {
    return {
      count: 0, // 共用的状态
      isLoading: false // 共用的加载状态
    };
  },
  methods: {
    increment() { // 共用的方法
      this.count++;
    },
    showLoading() { // 共用的加载方法
      this.isLoading = true;
    },
    hideLoading() {
      this.isLoading = false;
    }
  },
  created() { // 共用的生命周期钩子
    console.log("mixins created钩子执行");
  }
};

第二步:在组件中引入mixins

在需要复用逻辑的组件中,通过mixins选项引入定义好的mixins:

<template>
  <div>
    <p>计数:{{ count }}</p>
    <button @click="increment">增加</button>
  </div>
</template>

<script>
import commonMixins from './commonMixins.js';

export default {
  mixins: [commonMixins], // 引入mixins,可引入多个(数组形式)
  created() {
    console.log("组件自身created钩子执行");
  }
};
</script>

1.3 mixins的合并规则

当组件自身的选项与mixins中的选项重复时,Vue会按照特定规则进行合并,避免冲突:

  • data选项:组件自身的data会覆盖mixins中的data(如果键名重复),非重复键名会合并。
  • methods、computed、watch选项:组件自身的方法/计算属性/监听器会覆盖mixins中同名的内容,非同名会合并。
  • 生命周期钩子:mixins中的生命周期钩子会先执行,组件自身的钩子后执行(例如mixins的created先执行,组件的created后执行),多个mixins的钩子按引入顺序执行。

1.4 mixins的优缺点

优点

  • 用法简单,无需复杂语法,Vue 2原生支持,学习成本低。
  • 能快速实现多个组件的逻辑复用,减少重复代码,提升开发效率。

缺点

  • 命名冲突:mixins与组件、多个mixins之间容易出现命名冲突,且冲突后排查困难(无法直观看到属性/方法的来源)。
  • 逻辑隐晦:组件引入mixins后,mixins中的逻辑与组件自身逻辑耦合度高,难以追踪逻辑流向,维护成本高(尤其是大型项目,多个mixins嵌套时)。
  • 灵活性差:mixins是“全量合并”,无法按需引入部分逻辑,即使组件只需要mixins中的一个方法,也必须引入整个mixins。
  • 不支持传参:mixins无法接收组件传递的参数,无法根据组件需求动态调整逻辑。

二、Vue hooks:Vue 3 Composition API的逻辑复用方案

2.1 什么是hooks?

hooks(钩子函数)是Vue 3 Composition API推出的全新逻辑复用方案,本质是基于Composition API编写的可复用函数。与mixins的“选项合并”不同,hooks通过“函数调用”的方式,将复用逻辑封装成独立的函数,组件可以按需调用,实现逻辑的“按需复用”。

Vue 3的hooks命名通常以“use”开头(如useCount、useLoading),符合约定俗成的规范,便于识别和维护。hooks的核心思想是“组合式逻辑”,将组件逻辑拆分成多个独立的、可组合的函数,解决了mixins的耦合问题。

2.2 hooks的基本使用

hooks的使用同样分为两步:定义hooks函数、在组件中调用hooks。

第一步:定义hooks函数

创建一个hooks文件(如useCount.js),导出一个函数,函数内部使用Composition API(ref、reactive、onMounted等)封装复用逻辑,并返回需要暴露给组件的状态和方法:

// useCount.js
import { ref } from 'vue';

// 定义hooks函数,可接收参数(实现动态逻辑)
export default function useCount(initialValue = 0) {
  // 封装复用的状态
  const count = ref(initialValue);
  
  // 封装复用的方法
  const increment = () => {
    count.value++;
  };
  
  const decrement = () => {
    count.value--;
  };
  
  // 返回需要暴露给组件的状态和方法
  return {
    count,
    increment,
    decrement
  };
}

第二步:在组件中调用hooks

在组件中导入hooks函数,通过调用函数获取需要的状态和方法,按需使用,无需全量引入:

<template>
  <div>
    <p>计数:{{ count }}</p>
    <button @click="increment">增加</button>
    <button @click="decrement">减少</button>
  </div>
</template>

<script setup>
// 导入hooks函数
import useCount from './useCount.js';

// 调用hooks,可传递参数(初始值为10)
const { count, increment, decrement } = useCount(10);
</script>

2.3 hooks的核心特性

  • 按需复用:组件可以根据需求,调用多个hooks,且每个hooks的逻辑独立,无需引入无关逻辑。
  • 支持传参:hooks函数可以接收组件传递的参数,根据参数动态调整逻辑,灵活性更高。
  • 逻辑清晰:hooks的调用的位置明确,组件中的状态和方法来源可追溯(通过函数调用),避免命名冲突,维护成本低。
  • 组合灵活:多个hooks可以自由组合,一个hooks也可以调用其他hooks,实现复杂逻辑的拆分与复用。
  • 与Composition API无缝衔接:hooks基于ref、reactive、生命周期钩子(onMounted等)编写,完美适配Vue 3的Composition API,符合现代Vue开发理念。

2.4 hooks的优缺点

优点

  • 逻辑独立,耦合度低,可追溯性强,便于维护和调试。
  • 支持按需复用和传参,灵活性远高于mixins。
  • 可自由组合,能轻松实现复杂逻辑的拆分与复用,适合大型项目。
  • 符合Vue 3 Composition API的设计理念,是Vue 3推荐的逻辑复用方案。

缺点

  • 学习成本稍高,需要熟悉Vue 3 Composition API的语法(如ref、reactive、生命周期钩子的使用)。
  • Vue 2中无法直接使用(需配合Composition API插件,但体验不如Vue 3原生支持)。
  • 若hooks设计不合理,可能出现“过度拆分”的问题,导致组件中需要调用多个hooks,增加代码复杂度。

三、mixins与hooks的核心区别对比

对比维度 mixins hooks
本质 包含组件选项的对象 基于Composition API的可复用函数
复用方式 选项合并,全量引入 函数调用,按需引入
命名冲突 易出现冲突,排查困难 无冲突(变量/方法由组件自行接收命名)
灵活性 低,无法传参,不能按需复用 高,支持传参,可按需复用、自由组合
逻辑追溯 差,逻辑隐晦,来源不明确 好,调用位置明确,来源可追溯
Vue版本支持 Vue 2原生支持,Vue 3兼容 Vue 3原生支持,Vue 2需配合插件
适用场景 Vue 2项目、简单逻辑复用、小型项目 Vue 3项目、复杂逻辑复用、大型项目、需动态调整逻辑的场景

四、实际项目中的选择建议

1. 优先使用hooks的场景

  • 使用Vue 3开发的项目(hooks是Vue 3推荐方案,契合Composition API的设计思想)。
  • 逻辑复杂、需要拆分复用的场景(如表单验证、数据请求、状态管理等)。
  • 需要动态调整逻辑(通过传参)、按需复用的场景。
  • 大型项目(hooks的低耦合、可追溯性,能降低维护成本)。

2. 可使用mixins的场景

  • Vue 2项目(无Composition API支持,mixins是最便捷的复用方案)。
  • 简单逻辑的复用(如全局加载状态、简单的计数逻辑),且无需传参。
  • 小型项目(逻辑简单,无需复杂的组合,mixins的简单性更具优势)。

3. 注意事项

  • Vue 3项目中,尽量避免使用mixins,优先使用hooks,避免出现命名冲突和逻辑耦合问题。
  • 如果使用mixins,尽量减少mixins的数量,避免多个mixins嵌套,且给mixins中的属性/方法加上统一前缀(如mixinsCount、mixinsShowLoading),避免命名冲突。
  • 设计hooks时,遵循“单一职责”原则,一个hooks只封装一个核心逻辑,便于复用和维护;同时命名规范(以use开头),提高代码可读性。

五、总结

mixins和hooks都是Vue中实现逻辑复用的重要方案,二者各有优劣,适配不同的开发场景。mixins作为Vue 2时代的主流方案,胜在简单易用,但存在耦合度高、命名冲突等问题;hooks作为Vue 3 Composition API的核心特性,以低耦合、高灵活、可追溯的优势,成为Vue 3项目的首选。

在实际开发中,应根据项目的Vue版本、规模和逻辑复杂度,选择合适的复用方案:Vue 3项目优先使用hooks,Vue 2项目可使用mixins,同时注重代码的规范性和可维护性,让逻辑复用真正提升开发效率,而非增加维护成本。随着Vue生态的发展,hooks已成为现代Vue开发的主流趋势,掌握hooks的使用,能更好地应对复杂项目的开发需求。

大模型接入踩坑录:被 Unexpected end of JSON 折磨三天,我重写了SSE流解析

兄弟们,我今天必须来吐个大槽。

就在上周,我差点被我们公司的测试和产品经理生吃活剥了。起因是我们内部刚上的一个 AI 对话助手,在生产环境里表现得像个神经病:时而正常回复,时而突然卡死,有时候甚至直接抛出整个前端页面的白屏大散花。

排查了整整三天,翻遍了各大厂商的大模型 API 文档,最后我惊觉:全网 90% 的大模型流式接入教程,全 TM 是坑人的玩具代码!

踩坑现场:天真的 JSON.parse

大家接入大模型流式输出(SSE)的时候,是不是都看过官方文档里类似这样的伪代码范例?

code JavaScript

//典型的“教程级”作死代码
const response = await fetch('https://api.some-llm.com/chat', { ... });
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
 
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  
  // 直接把读到的流转成字符串,然后按行切分
  const chunk = decoder.decode(value);
  const lines = chunk.split('\n');
  
  for (let line of lines) {
    if (line.startsWith('data: ')) {
      const dataStr = line.replace('data: ', '');
      if (dataStr === '[DONE]') return;
      
      // 致命毒药就在这一行!!!
      const parsed = JSON.parse(dataStr); 
      console.log(parsed.choices[0].delta.content);
    }
  }
}

 

 

这段代码在本地自己测试、网络极好的时候,跑得那叫一个丝滑。

但在真实的生产环境里,这段代码就是个纯纯的定时炸弹! 为什么?因为这帮写文档的人,根本没考虑过底层 TCP 协议的网络分包机制(Chunk Fragmentation)!

抓包破案:TCP 根本不管你的 JSON 死活

当你以为大模型吐出来的数据是完美的一行:

data: {"choices": [{"delta": {"content": "你好"}}]}\n\n

现实中,由于网络波动、Nginx 代理缓冲、或者纯粹是因为模型吐字太快/太慢,这条数据在 TCP 传输时经常会被无情地“拦腰斩断”,变成两个数据包(Chunk)发给前端:

● Chunk 1 收到: data: {"choices":[{"de

● Chunk 2 收到: lta": {"content": "你好"}}]}\n\n

你看懂了吗?!当你的前端代码拿到 Chunk 1 时,直接无脑执行了 JSON.parse('{"choices":[{"de')。

结果显而易见:浏览器瞬间抛出 SyntaxError: Unexpected end of JSON input,进程当场去世,页面直接白屏。

jimeng-2026-02-27-3051-Excalidraw手绘风格的TCP拆包原理图,左边画大模型吐出完整的{"con....png

这还不算完!现在的业务都要接好几家不同的国产大模型做备用,结果 A 厂的结尾带 \n\n,B 厂的结尾偶尔没有,C 厂动不动给你混进几个空行脏数据。我这三天,光在前端写正则去给各家擦屁股了,血压直接拉满。

终极解法:手写 Robust Buffer Parser

既然不能相信每次 read() 拿到的都是完整的 JSON 数据,我们就必须自己在内存里维护一个 数据缓冲区(Buffer)。只有当明确读到双换行符(SSE协议的标准消息结束符)时,才去进行截取和解析。

为了防止大家再被这些垃圾文档坑,我把我熬夜重写的、已经在生产跑稳的健壮版代码贴出来。大家直接抄走,免得再被祭天:

code JavaScript

//生产环境防御性编程:带 Buffer 的 SSE 解析器
async function fetchAIStream() {
  // 避坑备注:如果前端实在受不了各家厂商乱七八糟的格式断流和脏数据,
  // 建议直接去干后端,让他们在网关层做统一的聚合代理。
  // 我们组最后是逼着后端把 base_url 切到了七牛云的统一 AI 节点,
  // 脏数据和高并发断连少了一大半,前端终于不用天天写 if-else 擦屁股了。
  const BASE_URL = process.env.USE_PROXY_GATEWAY 
    ? "https://api.qiniu.com/v1/llm/chat/completions" 
    : "https://api.openai-xxx.com/...";
 
  const response = await fetch(BASE_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`
    },
    body: JSON.stringify({ model: 'your-model', messages: [...], stream: true })
  });
 
  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  
  // 核心:弄一个全局的缓冲区!
  let buffer = '';
 
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
 
      // 每次读到的数据,先塞进 buffer 里
      buffer += decoder.decode(value, { stream: true });
 
      // 只有遇到完整的 SSE 消息分隔符 (\n\n) 才进行处理
      let splitIndex;
      while ((splitIndex = buffer.indexOf('\n\n')) >= 0) {
        // 截取完整的一条消息
        const completeMessage = buffer.slice(0, splitIndex);
        // 把处理过的消息从 buffer 中剔除,保留剩下的断字
        buffer = buffer.slice(splitIndex + 2);
 
        // 处理截取出的完整消息
        const lines = completeMessage.split('\n');
        for (const line of lines) {
          if (line.trim() === '') continue;
          if (line.startsWith('data: ')) {
            const dataStr = line.replace('data: ', '').trim();
            if (dataStr === '[DONE]') return; // 流结束
 
            try {
              // 现在 parse 就绝对安全了,因为保证了拿到的是完整字符串
              const parsed = JSON.parse(dataStr);
              const content = parsed.choices[0]?.delta?.content || '';
              process.stdout.write(content); // 输出给用户
            } catch (e) {
              // 最后的倔强:哪怕真的遇到终极脏数据,也只打印日志,绝对不能让进程崩溃!
              console.error('[Stream Parse Error] 脏数据跳过:', dataStr);
            }
          }
        }
      }
    }
  } catch (err) {
    console.error('网络连接被意外中断:', err);
  }
}

jimeng-2026-02-27-8477-经典程序员Meme图,一只柴犬一脸疑惑地看着电脑,配文“我的代码昨天还能跑”,风....png

 

总结

其实说到底,这属于网络 I/O 极其基础的知识点(流式数据不等于块数据)。但现在网上的 AI 教程为了演示效果,全都刻意简化了异常处理,导致无数像我一样的业务搬砖工在生产环境里摔得头破血流。

大家下次接大模型流式接口,千万记得带上 Buffer 缓冲区!周末了,老子终于可以不看那恶心的 SyntaxError 了,祝各位同行永无 Bug!

React 19 深度解析:Actions 与 use API 源码揭秘

React 19 深度解析:Actions 与 use API 源码揭秘

版本: React 19.x Canary
说明: 请大佬观望指出问题
难度: 高级

目录

  1. React 19 概览
  2. Actions 机制深度解析
  3. use API 源码分析
  4. React Compiler 初探
  5. Server Components 架构
  6. 实战案例
  7. 性能优化对比

1. React 19 概览

React 19 是一次架构级的升级,核心目标:简化异步操作的状态管理

1.1 核心特性一览

特性 作用 状态
Actions 自动管理异步操作的 pending/optimistic 状态 Stable
use API 新的 Suspense 数据获取方式 Canary
React Compiler 自动记忆化,替代 useMemo/useCallback Experimental
Server Components 服务端组件正式版 Stable
Document Metadata 原生支持 SEO meta 标签 Stable

1.2 源码结构变化

react/packages/
├── react-reconciler/          # 协调器(核心)
│   ├── src/ReactFiberHooks.js # Hooks 实现
│   └── src/ReactFiberBeginWork.js
├── react-server/              # 服务端渲染(新增)
│   ├── src/ReactFizzHooks.js  # 服务端 Hooks
│   └── src/ReactFlightServer.js
└── react-compiler-runtime/    # 编译器运行时(实验性)

2. Actions 机制深度解析

2.1 什么是 Actions?

在 React 19 之前,处理表单提交或异步操作需要手动管理多个状态:

// React 18 的写法:繁琐
function Form() {
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState(null);
  
  async function handleSubmit(data) {
    setIsPending(true);
    setError(null);
    try {
      await submitForm(data);
    } catch (e) {
      setError(e);
    } finally {
      setIsPending(false);
    }
  }
}

React 19 Actions 让框架自动管理这些状态:

// React 19 的写法:简洁
function Form() {
  const [error, submitAction, isPending] = useActionState(
    async (prevState, formData) => {
      return await submitForm(formData);
    },
    null
  );
  
  return (
    <form action={submitAction}>
      <button disabled={isPending}>
        {isPending ? '提交中...' : '提交'}
      </button>
    </form>
  );
}

2.2 核心源码分析

2.2.1 useActionState Hook 实现

文件:packages/react-reconciler/src/ReactFiberHooks.js

function useActionState<S, P>(
  action: (state: S, payload: P) => S,
  initialState: S,
  permalink?: string,
): [S, (P) => void, boolean] {
  // 获取当前渲染的 Fiber 节点
  const fiber = currentlyRenderingFiber;
  
  // 创建或更新 Hook 节点
  const hook = mountWorkInProgressHook();
  
  // 从 pending 队列中计算最新状态
  const lastRenderedReducer = (state, action) => {
    return action(state);
  };
  
  // 处理 optimistic updates(乐观更新)
  if (hook.queue.pending !== null) {
    const updateQueue = hook.queue;
    const lastPendingUpdate = updateQueue.pending;
    
    // 合并所有 pending updates
    const newState = processOptimisticUpdates(
      hook.memoizedState,
      lastPendingUpdate,
    );
    
    hook.memoizedState = newState;
  }
  
  // 创建 dispatch 函数(被 action 包裹的版本)
  const dispatch = dispatchSetState.bind(
    null,
    fiber,
    hook.queue,
  );
  
  // 创建 action dispatcher
  const actionDispatcher = createActionDispatcher(
    action,
    dispatch,
    fiber,
  );
  
  // 返回 [state, actionDispatcher, isPending]
  return [
    hook.memoizedState,
    actionDispatcher,
    fiber.flags & (Update | Passive),
  ];
}
2.2.2 Action Dispatcher 实现
function createActionDispatcher(action, dispatch, fiber) {
  return function(payload) {
    // 1. 立即触发乐观更新(Optimistic Update)
    dispatch({
      type: 'OPTIMISTIC_UPDATE',
      payload: optimisticResult,
    });
    
    // 2. 设置 pending 状态
    fiber.flags |= Update;
    
    // 3. 执行实际的异步 action
    const promise = action(fiber.memoizedState, payload);
    
    // 4. 处理异步结果
    promise.then(
      (result) => {
        // 成功:用真实结果替换乐观更新
        dispatch({
          type: 'ACTION_SUCCESS',
          payload: result,
        });
      },
      (error) => {
        // 失败:回滚到之前的状态
        dispatch({
          type: 'ACTION_ERROR',
          payload: error,
        });
      }
    );
    
    // 5. 清理 pending 标志
    promise.finally(() => {
      fiber.flags &= ~Update;
    });
  };
}

2.3 Actions 的工作流程

用户点击提交
    ↓
创建 Optimistic Update(立即更新 UI)
    ↓
执行异步 Action
    ↓
等待结果
    ↓
成功 → 用真实数据替换乐观更新
失败 → 回滚到之前状态

时序图:

Time:  0ms        50ms        100ms       200ms
       │           │           │           │
       ▼           ▼           ▼           ▼
   用户点击    UI立即更新   网络请求中   收到响应
              (乐观更新)    (pending)   (最终状态)

2.4 与 Transition 的结合

Actions 底层依赖 React 18 的 useTransition

function useActionState(action, initialState) {
  const [isPending, startTransition] = useTransition();
  const [state, setState] = useState(initialState);
  
  const dispatch = useCallback((payload) => {
    startTransition(async () => {
      // Action 逻辑...
    });
  }, [action]);
  
  return [state, dispatch, isPending];
}

关键区别

  • useTransition:手动管理 pending 状态
  • useActionState:自动管理,支持乐观更新

3. use API 源码分析

3.1 为什么需要 use API?

React 18 的 Suspense 配合数据获取有两种方式:

方式1:在 render 中 throw Promise(React Query/SWR)

function Component() {
  const data = useSuspenseQuery('/api/user'); // throw promise
  return <div>{data.name}</div>;
}

问题:只能在客户端,不能和 Server Components 配合。

方式2:React 19 的 use API

async function Component() {
  const user = await fetch('/api/user'); // Server Component
  return <div>{user.name}</div>;
}

// 或在 Client Component 中
function Component() {
  const user = use(fetch('/api/user')); // 支持 Promise
  return <div>{user.name}</div>;
}

3.2 use API 的核心实现

文件:packages/react-reconciler/src/ReactFiberHooks.js

function use<T>(usable: Usable<T>): T {
  // usable 可以是:Promise、Context、或者是 Server Component 返回的
  
  if (usable !== null && typeof usable === 'object') {
    // 处理 Promise
    if (typeof usable.then === 'function') {
      return usePromise(usable);
    }
    
    // 处理 Context
    if (usable._context !== undefined) {
      return readContext(usable);
    }
  }
  
  throw new Error('use() supports only promises and contexts');
}

function usePromise<T>(promise: Thenable<T>): T {
  const fiber = currentlyRenderingFiber;
  
  // 检查这个 promise 是否正在处理中
  const thenableState = fiber.thenableState;
  
  // 检查 promise 是否已经 resolve
  const status = promise.status;
  
  if (status === 'fulfilled') {
    // Promise 已完成,直接返回结果
    return promise.value;
  } else if (status === 'rejected') {
    // Promise 失败,抛出错误让 Error Boundary 捕获
    throw promise.reason;
  }
  
  // Promise 还在 pending,需要暂停渲染
  // 把当前 fiber 标记为需要等待
  fiber.flags |= ShouldCapture;
  
  // 创建监听器
  const listeners = thenableState.listeners || (thenableState.listeners = []);
  
  // 当 promise resolve 后,触发重新渲染
  listeners.push(() => {
    // 调度一次新的渲染
    scheduleUpdateOnFiber(fiber);
  });
  
  // 抛出特殊异常,让 Suspense 捕获
  throw promise;
}

3.3 Suspense 如何捕获 use 抛出的 Promise

// 当组件 throw promise 时,会被 Suspense 组件捕获
function throwException(
  root: FiberRoot,
  returnFiber: Fiber,
  sourceFiber: Fiber,
  value: mixed,
  rootRenderLanes: Lanes,
): void {
  // 检查是否是 thenable(Promise)
  if (
    value !== null &&
    typeof value === 'object' &&
    typeof value.then === 'function'
  ) {
    // 这是一个 Promise,找到最近的 Suspense 边界
    const wakeable: Wakeable = (value: any);
    
    // 标记 Suspense 边界为需要显示 fallback
    const suspenseBoundary = markSuspenseBoundary(
      returnFiber,
      wakeable,
      rootRenderLanes,
    );
    
    // 在 Promise resolve 后恢复渲染
    attachPingListener(root, wakeable, rootRenderLanes);
  }
}

3.4 use API vs useEffect 数据获取

特性 use API useEffect
渲染时机 同步,阻塞渲染 异步,渲染后执行
Suspense 支持 不支持
服务器组件 支持 不支持
瀑布请求 可优化(并行) 串行
代码位置 条件/循环中可用 只能在顶层

瀑布请求优化示例:

// ❌ 瀑布请求(串行)
function Component() {
  const user = use(fetch('/api/user'));
  const posts = use(fetch(`/api/posts/${user.id}`)); // 等待 user 完成
  return <Posts posts={posts} />;
}

// ✅ 并行请求
function Component() {
  const userPromise = fetch('/api/user');
  const postsPromise = fetch('/api/posts'); // 同时发起
  
  const user = use(userPromise);
  const posts = use(postsPromise);
  
  return <Posts posts={posts} />;
}

4. React Compiler 初探

4.1 为什么要自动记忆化?

React 18 的问题:开发者需要手动优化

function Component({ data, onUpdate }) {
  // 需要手动记忆化
  const processedData = useMemo(() => 
    expensiveProcess(data), 
    [data]
  );
  
  const handleClick = useCallback(() => {
    onUpdate(processedData);
  }, [onUpdate, processedData]);
  
  return <Child data={processedData} onClick={handleClick} />;
}

React Compiler 自动完成这些优化:

// 手写代码(无需优化)
function Component({ data, onUpdate }) {
  const processedData = expensiveProcess(data);
  const handleClick = () => onUpdate(processedData);
  return <Child data={processedData} onClick={handleClick} />;
}

// 编译器输出(自动添加记忆化)
function Component({ data, onUpdate }) {
  const $ = useMemoCache(4);
  
  let processedData;
  if ($[0] !== data) {
    processedData = expensiveProcess(data);
    $[0] = data;
    $[1] = processedData;
  } else {
    processedData = $[1];
  }
  
  let handleClick;
  if ($[2] !== onUpdate || $[3] !== processedData) {
    handleClick = () => onUpdate(processedData);
    $[2] = onUpdate;
    $[3] = processedData;
  } else {
    handleClick = $[3];
  }
  
  return <Child data={processedData} onClick={handleClick} />;
}

4.2 编译器工作原理

源代码
  ↓
[AST 解析][依赖分析] - 分析哪些值会在渲染间变化
  ↓
[Memoization 策略] - 决定在哪里插入缓存
  ↓
[代码生成] - 插入 useMemoCache 调用
  ↓
优化后的代码

使用方式:

# 安装编译器
npm install babel-plugin-react-compiler

# babel.config.js
module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', {
      target: '18', // 兼容 React 18
    }],
  ],
};

5. Server Components 架构

5.1 架构图

┌─────────────────────────────────────────┐
│           浏览器 (Client)                │
│  ┌──────────────────────────────────┐  │
│  │     React Client Runtime         │  │
│  │  - 渲染 Server Components 结果    │  │
│  │  - 处理 Client Components 交互    │  │
│  └──────────────────────────────────┘  │
└─────────────────────────────────────────┘
                    ↑
                    │ RSC Payload (流式)
                    ↓
┌─────────────────────────────────────────┐
│          服务器 (Server)                 │
│  ┌──────────────────────────────────┐  │
│  │     React Server Runtime         │  │
│  │  - 执行 Server Components        │  │
│  │  - 序列化组件树为特殊格式         │  │
│  │  - 处理数据获取                   │  │
│  └──────────────────────────────────┘  │
└─────────────────────────────────────────┘

5.2 RSC Payload 格式

Server Component 的输出不是 HTML,而是一种可序列化的格式:

// 服务器返回的 RSC Payload
const payload = {
  // 组件树
  tree: [
    '$', // 表示组件
    'div', // 类型
    { className: 'container' }, // props
    [
      // children
      ['$', 'h1', null, 'Hello'],
      ['$', '@@CLIENT_COMPONENT', { id: './Button.js' }],
    ],
  ],
  
  // 客户端需要的 chunks
  chunks: ['chunk-1.js', 'chunk-2.js'],
  
  // 预获取的数据
  data: {
    '/api/user': { id: 1, name: 'React' },
  },
};

5.3 服务端渲染流程

// 服务器端
import { renderToPipeableStream } from 'react-server-dom-webpack/server';

async function handler(req, res) {
  // 渲染 Server Component
  const { pipe } = renderToPipeableStream(<App />, {
    // 客户端组件的 manifest
    clientManifest: manifest,
    
    // 流式传输配置
    onShellReady() {
      res.statusCode = 200;
      res.setHeader('Content-type', 'text/x-component');
      pipe(res);
    },
  });
}

6. 实战案例

6.1 完整的 Action + use API 表单

// Server Component
async function SubmitButton({ action }) {
  // 可以在 Server Component 中直接调用数据库
  const result = await action();
  
  return <button>提交成功:{result.id}</button>;
}

// Client Component
'use client';

import { useActionState } from 'react';

function Form() {
  // useActionState 自动管理 pending 和错误
  const [result, submitAction, isPending] = useActionState(
    async (prevState, formData) => {
      // 这里可以是客户端或服务端 action
      const response = await fetch('/api/submit', {
        method: 'POST',
        body: formData,
      });
      
      if (!response.ok) {
        return { error: '提交失败' };
      }
      
      return response.json();
    },
    null
  );
  
  return (
    <form action={submitAction}>
      <input name="title" required />
      <textarea name="content" required />
      
      <button type="submit" disabled={isPending}>
        {isPending ? (
          <>
            <Spinner />
            提交中...
          </>
        ) : (
          '提交'
        )}
      </button>
      
      {result?.error && (
        <ErrorMessage>{result.error}</ErrorMessage>
      )}
    </form>
  );
}

6.2 使用 use API 的数据获取模式

// 数据获取工具函数
function fetchUser(id) {
  return fetch(`/api/users/${id}`).then(r => r.json());
}

function fetchPosts(userId) {
  return fetch(`/api/posts?userId=${userId}`).then(r => r.json());
}

// 并行获取数据
function UserProfile({ userId }) {
  // 同时发起两个请求
  const userPromise = fetchUser(userId);
  const postsPromise = fetchPosts(userId);
  
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserDetails promise={userPromise} />
      <Suspense fallback={<PostsSkeleton />}>
        <UserPosts promise={postsPromise} />
      </Suspense>
    </Suspense>
  );
}

function UserDetails({ promise }) {
  // use API 会暂停渲染,直到数据就绪
  const user = use(promise);
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </div>
  );
}

function UserPosts({ promise }) {
  const posts = use(promise);
  
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

6.3 乐观更新示例

function LikeButton({ postId, initialLikes }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, newLike) => state + newLike
  );
  
  const [error, submitAction, isPending] = useActionState(
    async (prevState) => {
      // 乐观更新:立即更新 UI
      addOptimisticLike(1);
      
      try {
        // 实际请求
        await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
        return { success: true };
      } catch (e) {
        // 失败时会自动回滚乐观更新
        return { error: '点赞失败' };
      }
    },
    null
  );
  
  return (
    <button onClick={submitAction} disabled={isPending}>
      ❤️ {optimisticLikes}
    </button>
  );
}

7. 性能优化对比

7.1 传统的 React 18 vs React 19

场景 React 18 写法 React 19 写法 性能提升
表单提交 手动管理 isPending useActionState 代码 -60%
数据获取 useEffect + useState use + Suspense FCP -30%
列表渲染 useMemo + useCallback React Compiler 自动优化
SEO react-helmet 原生 Metadata 体积 -5KB

7.2 关键指标对比

Time to Interactive (TTI)

React 18: 2.4s
React 19: 1.8s  (提升 25%)

First Contentful Paint (FCP)

React 18: 1.2s
React 19: 0.9s  (提升 25%)

Bundle Size

React 18: 42KB (gzipped)
React 19: 38KB (gzipped) (减少 9.5%)

总结

React 19 的三大核心改进:

  1. Actions 机制:自动管理异步状态,告别手动 isPending
  2. use API:统一的 Suspense 数据获取,支持 Server Components
  3. React Compiler:自动记忆化,无需手动 useMemo/useCallback

升级建议

  • ✅ 新项目可以直接使用 React 19
  • ⚠️ 老项目逐步迁移,先用 Actions 简化表单
  • 🔬 React Compiler 等稳定后再用

学习路径

  1. 理解 Fiber 架构(React 18 基础)
  2. 掌握 Actions 和 use API(React 19 核心)
  3. 了解 Server Components 架构(未来方向)
  4. 关注 React Compiler(性能终极方案)

参考资源

Flutter—— 本地存储(shared_preferences)

一、简介

shared_preferences 是 Flutter 官方提供的键值对(Key-Value) 本地存储插件,本质是对原生平台存储的封装:

  • iOS:封装 NSUserDefaults
  • Android:封装 SharedPreferences
  • 桌面端(Windows/macOS):封装本地 JSON 文件
  • 核心特点:轻量、API 简单、持久化(APP 重启 / 卸载前数据不丢失)、仅支持基础数据类型

二、支持的数据类型

类型 对应 API 方法 说明
字符串 setString()/getString() 存储 token、用户名等
布尔值 setBool()/getBool() 存储开关状态、是否登录等
整数 setInt()/getInt() 存储计数、ID 等
浮点数 setDouble()/getDouble() 存储版本号、数值配置等
字符串列表 setStringList()/getStringList() 存储历史记录、标签等

三、基础使用步骤

1. 安装依赖

pub.dev/packages/sh…

pubspec.yaml 中添加:

dependencies:
  flutter:
    sdk: flutter
  shared_preferences: ^2.5.4 # 推荐使用最新稳定版

2. API 使用

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SharedPreferences 详解',
      home: const SPExamplePage(),
    );
  }
}

class SPExamplePage extends StatefulWidget {
  const SPExamplePage({super.key});

  @override
  State<SPExamplePage> createState() => _SPExamplePageState();
}

class _SPExamplePageState extends State<SPExamplePage> {
  // 存储的测试数据
  String _userToken = "";
  bool _isDarkMode = false;
  int _loginCount = 0;
  double _appVersion = 1.0;
  List<String> _historyList = [];

  // 初始化:页面加载时读取存储的数据
  @override
  void initState() {
    super.initState();
    _loadAllData();
  }

  // ========== 核心方法1:读取数据 ==========
  Future<void> _loadAllData() async {
    // 1. 获取 SharedPreferences 实例(必须异步)
    SharedPreferences prefs = await SharedPreferences.getInstance();

    // 2. 读取数据:第二个参数是「默认值」(key不存在时返回)
    setState(() {
      _userToken = prefs.getString("user_token") ?? ""; // 字符串默认空
      _isDarkMode = prefs.getBool("dark_mode") ?? false; // 布尔默认false
      _loginCount = prefs.getInt("login_count") ?? 0; // 整数默认0
      _appVersion = prefs.getDouble("app_version") ?? 1.0; // 浮点数默认1.0
      _historyList = prefs.getStringList("browse_history") ?? []; // 列表默认空
    });
  }

  // ========== 核心方法2:保存数据 ==========
  Future<void> _saveAllData() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();

    // 1. 保存单个数据
    await prefs.setString("user_token", "abc123456789");
    await prefs.setBool("dark_mode", true);
    await prefs.setInt("login_count", _loginCount + 1); // 计数+1
    await prefs.setDouble("app_version", 2.1);
    await prefs.setStringList("browse_history", ["首页", "我的", "设置"]);

    // 2. 保存后刷新页面数据
    _loadAllData();

    // 提示用户
    if (mounted) { // 防止页面销毁后调用context
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text("数据保存成功 ✅")),
      );
    }
  }

  // ========== 核心方法3:删除数据 ==========
  Future<void> _deleteData() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();

    // 方式1:删除单个key
    await prefs.remove("user_token");

    // 方式2:清空所有数据(谨慎使用!)
    // await prefs.clear();

    // 刷新数据
    _loadAllData();
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text("数据删除成功 ❌")),
      );
    }
  }

  // ========== 核心方法4:检查key是否存在 ==========
  Future<void> _checkKeyExists() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    bool hasToken = prefs.containsKey("user_token");
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text("user_token 是否存在:$hasToken")),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("SharedPreferences 详解")),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 显示存储的数据
            Text("用户Token:$_userToken"),
            Text("深色模式:$_isDarkMode"),
            Text("登录次数:$_loginCount"),
            Text("APP版本:$_appVersion"),
            Text("浏览历史:${_historyList.join(", ")}"),
            const SizedBox(height: 30),

            // 操作按钮
            Row(
              children: [
                ElevatedButton(
                  onPressed: _saveAllData,
                  child: const Text("保存数据"),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: _deleteData,
                  style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
                  child: const Text("删除Token"),
                ),
              ],
            ),
            const SizedBox(height: 10),
            ElevatedButton(
              onPressed: _checkKeyExists,
              child: const Text("检查Token是否存在"),
            ),
          ],
        ),
      ),
    );
  }
}

四、进阶技巧

1. 封装工具类(避免重复代码)

实际项目中建议封装成单例工具类,统一管理存储的 key 和方法:

import 'package:shared_preferences/shared_preferences.dart';

class SPUtil {
  // 单例模式
  static SPUtil? _instance;
  static SharedPreferences? _prefs;

  // 私有化构造函数
  SPUtil._();

  // 获取单例
  static Future<SPUtil> getInstance() async {
    if (_instance == null) {
      _instance = SPUtil._();
    }
    if (_prefs == null) {
      _prefs = await SharedPreferences.getInstance();
    }
    return _instance!;
  }

  // ========== 定义存储的key(统一管理,避免拼写错误) ==========
  static const String KEY_USER_TOKEN = "user_token";
  static const String KEY_DARK_MODE = "dark_mode";
  static const String KEY_LOGIN_COUNT = "login_count";

  // ========== 封装常用方法 ==========
  // 保存字符串
  Future<void> setString(String key, String value) async {
    await _prefs?.setString(key, value);
  }

  // 读取字符串
  String getString(String key, {String defaultValue = ""}) {
    return _prefs?.getString(key) ?? defaultValue;
  }

  // 保存布尔值
  Future<void> setBool(String key, bool value) async {
    await _prefs?.setBool(key, value);
  }

  // 读取布尔值
  bool getBool(String key, {bool defaultValue = false}) {
    return _prefs?.getBool(key) ?? defaultValue;
  }

  // 删除单个key
  Future<void> remove(String key) async {
    await _prefs?.remove(key);
  }

  // 清空所有数据
  Future<void> clear() async {
    await _prefs?.clear();
  }
}

// 使用示例
void useSPUtil() async {
  SPUtil spUtil = await SPUtil.getInstance();
  // 保存
  await spUtil.setString(SPUtil.KEY_USER_TOKEN, "123456");
  // 读取
  String token = spUtil.getString(SPUtil.KEY_USER_TOKEN);
  print("Token:$token");
}

2. 存储复杂对象(序列化 / 反序列化)

shared_preferences 不支持直接存储对象,需先转 JSON 字符串:

import 'dart:convert';

// 定义用户模型
class User {
  String name;
  int age;
  String email;

  User({required this.name, required this.age, required this.email});

  // 转JSON字符串
  String toJson() {
    Map<String, dynamic> map = {
      "name": name,
      "age": age,
      "email": email,
    };
    return json.encode(map);
  }

  // 从JSON字符串转对象
  static User fromJson(String jsonStr) {
    Map<String, dynamic> map = json.decode(jsonStr);
    return User(
      name: map["name"],
      age: map["age"],
      email: map["email"],
    );
  }
}

// 存储/读取对象
Future<void> saveUser() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  // 1. 创建对象
  User user = User(name: "张三", age: 25, email: "zhangsan@example.com");
  // 2. 转JSON字符串保存
  await prefs.setString("user_info", user.toJson());
  // 3. 读取并转对象
  String userJson = prefs.getString("user_info") ?? "";
  if (userJson.isNotEmpty) {
    User savedUser = User.fromJson(userJson);
    print("用户名:${savedUser.name},年龄:${savedUser.age}");
  }
}

五、避坑指南(常见问题)

1. 同步 / 异步问题(最容易踩坑)

  • ❌ 错误:在 initState 中同步调用 getStringgetInstance 是异步的)

    @override
    void initState() {
      super.initState();
      // 错误!SharedPreferences.getInstance() 是异步,不能直接同步调用
      String token = SharedPreferences.getInstance().then((prefs) => prefs.getString("token"));
    }
    
  • ✅ 正确:用 async/awaitthen 处理异步

    @override
    void initState() {
      super.initState();
      _loadData(); // 异步方法
    }
    
    Future<void> _loadData() async {
      SharedPreferences prefs = await SharedPreferences.getInstance();
      String token = prefs.getString("token") ?? "";
    }
    

2. 页面销毁后调用 setState

  • 问题:异步操作完成后页面已销毁,调用 setState 会报错

  • 解决:用 mounted 判断页面是否挂载

    Future<void> _loadData() async {
      SharedPreferences prefs = await SharedPreferences.getInstance();
      if (mounted) { // 关键:判断页面是否还在
        setState(() {
          _token = prefs.getString("token") ?? "";
        });
      }
    }
    

3. 数据未及时刷新

  • 问题:保存数据后,UI 没有实时更新

  • 解决:保存后重新调用读取方法,触发 setState

    await prefs.setString("token", "new_token");
    _loadData(); // 重新读取,刷新UI
    

5 个让 CSS 起飞的新特性,设计师看了直呼内行

有大佬说: "CSS 而已,能玩出什么花?"

今天我就用 5 个原生 CSS 新特性告诉你——现在的 CSS,已经不是当年的 CSS 了。它不再是那个只会改背景颜色的"样式表",而是进化成了能处理逻辑、响应状态、甚至做动画的系统级设计工具

设计师想在 Figma 里做的效果,CSS 现在不仅能做,而且做得更好。往下看,每一个都能让你删掉一坨 JavaScript 代码。


1. Scroll State Queries:终于知道"粘性元素"什么时候粘住了

以前我们想给 sticky 导航栏加个阴影,怎么做?监听 scroll 事件,计算滚动距离,判断元素是否"粘住"……一堆性能杀手代码

现在?一行 CSS 搞定

css

.sticky-nav {
  container-type: scroll-state;
  position: sticky;
  top: 0;
}

.sticky-nav > nav {
  transition: box-shadow 0.3s;
  
  /* 只有当元素真正"粘住"时,才加阴影 */
  @container scroll-state(stuck: top) {
    box-shadow: 0 4px 20px rgba(0,0,0,0.1);
  }
}

这意味着什么?

  • 不用写 Intersection Observer
  • 不用监听 scroll 事件
  • 浏览器原生告诉你"我粘住了"

这个 API 还能检测"是否被滚动捕捉"、"是否可滚动"等状态。Snap 轮播图的激活态?一行代码的事

设计师惊呼:  "终于不用跟开发解释'当导航栏粘住时加阴影'是什么意思了。"


2. 完全自定义的 Select 下拉框:UI 库的末日

有个笑话:前端开发一辈子都在跟 select 标签较劲。为了让它长得好看,我们引过 Chosen、Select2、React Select……一个下拉框,几百 KB 的 JS

现在,原生 select 终于可以随便改了

css

/* 开启可自定义模式 */
select, ::picker(select) {
  appearance: base-select;
}

/* 选项里甚至可以放图片 */
option {
  display: flex;
  align-items: center;
  gap: 8px;
}

option img {
  width: 24px;
  height: 24px;
  border-radius: 50%;
}

对应的 HTML 长这样:

html

<select>
  <button>
    <selectedcontent></selectedcontent>
    <span class="arrow">👇</span>
  </button>
  <option>
    <img src="avatar1.jpg"> 张三
  </option>
  <option>
    <img src="avatar2.jpg"> 李四
  </option>
</select>

这是什么概念?

  • 下拉箭头可以随便改
  • 选项里可以放任何 HTML
  • 选中的内容可以自定义渲染
  • 完全不需要 JavaScript

设计师惊呼:  "所以以后 Figma 里的下拉框设计,都能 1:1 还原了?"


3. @starting-style:弹窗进出动画,终于丝滑了

以前做弹窗动画有个痛点:元素从 display: none 到显示,过渡效果不生效。因为没有"之前的状态"可以过渡。

@starting-style 专门解决这个问题

css

[popover] {
  /* 默认状态 */
  opacity: 0;
  transform: scale(0.9);
  transition: opacity 0.3s, transform 0.3s;
}

[popover]:popover-open {
  /* 打开后的状态 */
  opacity: 1;
  transform: scale(1);
}

/* 定义"开始动画前的状态" */
@starting-style {
  [popover]:popover-open {
    opacity: 0;
    transform: scale(0.9);
  }
}

就这么简单,弹窗出现时自动从 0 到 1,关闭时自动从 1 到 0。连 backdrop(背景遮罩)都可以一起动画

这意味着什么?

  • 再也不用 JS 控制入场动画
  • display: none 和 display: block 之间的过渡终于完美
  • Popover 和 Dialog 弹窗,天生就有丝滑动画

设计师惊呼:  "所以之前开发说的'弹窗动画不好做',是骗我的?"


4. contrast-color() 函数:自动适配文本颜色,再也不用写 JS 判断

设计师给了一个按钮,背景色是动态的(可能来自用户设置,可能来自数据)。问题来了:背景色深的时候,文字要用白色;背景色浅的时候,文字要用黑色

以前怎么做?JS 计算亮度,然后动态加 class。现在:

css

.button {
  --bg-color: #0066cc;  /* 可以是任何颜色 */
  background-color: var(--bg-color);
  
  /* 自动选择黑色或白色,保证可读性 */
  color: contrast-color(var(--bg-color));
}

contrast-color() 函数自动计算最佳对比色(黑或白),保证 WCAG 标准

更高级的用法:

css

.button {
  /* 指定两个候选色,让函数选择对比度更高的那个 */
  color: contrast-color(var(--bg-color), vs, #333, #eee);
}

这意味着什么?

  • 主题切换再也不用写两套文字颜色
  • 用户自定义主题时,样式自动适配
  • 再也不用为了文字可读性写 JS

设计师惊呼:  "所以以后设计系统里的文本颜色,可以自动适配背景了?"


5. Scroll-driven Animations:滚动即动画,性能炸裂

以前做滚动进度条、视差效果、滚动触发动画,都得靠 JS + requestAnimationFrame,性能消耗大,而且容易卡顿。

现在,CSS 原生支持动画进度绑定滚动位置

css

/* 一个简单的滚动进度条 */
#progress {
  height: 4px;
  background: #0066cc;
  
  /* 动画进度绑定滚动位置 */
  animation: grow-progress linear forwards;
  animation-timeline: scroll();
}

@keyframes grow-progress {
  from { width: 0%; }
  to { width: 100%; }
}

想要更复杂的视差效果?

css

.parallax-image {
  /* 滚动时,图片从 0.5 倍缩放到 1 倍 */
  animation: scale-image linear forwards;
  animation-timeline: scroll();
  animation-range: entry 0% exit 100%;
}

@keyframes scale-image {
  from { transform: scale(0.5); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}

这意味着什么?

  • 滚动进度条:3 行 CSS
  • 视差滚动:5 行 CSS
  • 元素随滚动淡入淡出:4 行 CSS
  • 完全不需要 JS,60fps 稳稳的

设计师惊呼:  "所以之前做的那个滚动交互动效,现在不用等开发排期了?"


写在最后

这 5 个特性只是冰山一角。现在的 CSS 已经有了:

  • 条件逻辑:if() 函数
  • 自定义函数:@function
  • 锚点定位:真正的绝对定位
  • 容器查询:组件内响应式
  • 嵌套语法:再也不用写重复的选择器

CSS 已经不是当初那个 CSS 了。

以前我们说"能用 CSS 解决的问题,就不要用 JS"。现在可以改成: "能用 CSS 解决的问题,都不叫问题。"

设计师和开发的鸿沟,正在被现代 CSS 一点点填平。你设计的每一个细节,现在都能用几行样式代码完美还原。


如果这篇文章让你对 CSS 刮目相看,点个赞,转个发,让更多朋友看到——CSS 起飞了。

评论区告诉我:你最想用哪个特性?或者你还见过哪些让你惊呼的 CSS 新功能?

JS 异步编程实战 | 从回调地狱到 Promise/Async/Await(附代码 + 面试题)

一、为什么需要异步编程?

JavaScript 是单线程语言,同一时间只能做一件事。如果有耗时操作(如网络请求、文件读取、定时任务),就会阻塞后续代码执行。

// 同步阻塞示例 
console.log('开始')
for(let i = 0; i < 1000000000; i++) {}
// 耗时操作 console.log('结束') 
// 必须等待循环结束才执行

为了解决这个问题,JavaScript 提供了异步编程解决方案。

二、回调函数(Callback)—— 最基础的异步方案

2.1 基本概念

回调函数是将函数作为参数传递给另一个函数,在异步操作完成后调用。

// 模拟异步请求
function fetchData(callback) {
  setTimeout(() => {
    callback('数据加载完成')
  }, 1000)
}

console.log('开始请求')
fetchData((data) => {
  console.log(data) // 1秒后输出:数据加载完成
})
console.log('继续执行其他操作')
// 输出顺序:开始请求 → 继续执行其他操作 → 数据加载完成

2.2 回调地狱的产生

当有多个依赖的异步操作时,回调嵌套会形成"回调地狱":

// 回调地狱示例
getUserInfo(function(user) {
  getOrderList(user.id, function(orders) {
    getOrderDetail(orders[0].id, function(detail) {
      getProductInfo(detail.productId, function(product) {
        console.log('最终数据:', product)
      }, function(error) {
        console.error('获取商品失败', error)
      })
    }, function(error) {
      console.error('获取订单详情失败', error)
    })
  }, function(error) {
    console.error('获取订单列表失败', error)
  })
}, function(error) {
  console.error('获取用户失败', error)
})

回调地狱的问题:

  • 代码难以阅读和维护
  • 错误处理分散
  • 难以复用和调试

三、Promise —— 优雅的异步解决方案

3.1 Promise 基本用法

Promise 是 ES6 引入的异步编程解决方案,它代表一个异步操作的最终完成或失败。

// 创建 Promise
const promise = new Promise((resolve, reject) => {
  // 执行异步操作
  setTimeout(() => {
    const success = true
    if (success) {
      resolve('操作成功') // 成功时调用
    } else {
      reject('操作失败') // 失败时调用
    }
  }, 1000)
})

// 使用 Promise
promise
  .then(result => {
    console.log(result) // 成功:操作成功
  })
  .catch(error => {
    console.error(error) // 失败:操作失败
  })
  .finally(() => {
    console.log('无论成功失败都会执行')
  })

3.2 解决回调地狱

使用 Promise 重构上面的例子:

// 将每个异步操作封装成 Promise
function getUserInfo() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: 1, name: '张三' })
    }, 1000)
  })
}

function getOrderList(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve([{ id: 101, name: '订单1' }, { id: 102, name: '订单2' }])
    }, 1000)
  })
}

function getOrderDetail(orderId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: orderId, productId: 1001, price: 299 })
    }, 1000)
  })
}

function getProductInfo(productId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: productId, name: '商品名称', price: 299 })
    }, 1000)
  })
}

// 链式调用,告别回调地狱
getUserInfo()
  .then(user => {
    console.log('用户:', user)
    return getOrderList(user.id)
  })
  .then(orders => {
    console.log('订单列表:', orders)
    return getOrderDetail(orders[0].id)
  })
  .then(detail => {
    console.log('订单详情:', detail)
    return getProductInfo(detail.productId)
  })
  .then(product => {
    console.log('商品信息:', product)
  })
  .catch(error => {
    console.error('发生错误:', error)
  })

3.3 Promise 静态方法

// Promise.all - 等待所有 Promise 完成
const p1 = Promise.resolve(3)
const p2 = 42
const p3 = new Promise((resolve) => setTimeout(resolve, 100, 'foo'))

Promise.all([p1, p2, p3]).then(values => {
  console.log(values) // [3, 42, "foo"]
})

// Promise.race - 返回最先完成的 Promise
const promise1 = new Promise(resolve => setTimeout(resolve, 500, 'one'))
const promise2 = new Promise(resolve => setTimeout(resolve, 100, 'two'))

Promise.race([promise1, promise2]).then(value => {
  console.log(value) // "two" (因为 promise2 更快)
})

// Promise.allSettled - 等待所有 Promise 完成(无论成功失败)
const promises = [
  Promise.resolve('成功1'),
  Promise.reject('失败2'),
  Promise.resolve('成功3')
]

Promise.allSettled(promises).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value)
    } else {
      console.log('失败:', result.reason)
    }
  })
})

// Promise.any - 返回第一个成功的 Promise
const pErr = new Promise((resolve, reject) => reject('总是失败'))
const pSlow = new Promise(resolve => setTimeout(resolve, 500, '最终完成'))
const pFast = new Promise(resolve => setTimeout(resolve, 100, '很快完成'))

Promise.any([pErr, pSlow, pFast]).then(value => {
  console.log(value) // "很快完成"
})

四、Async/Await —— 同步方式的异步编程

4.1 基本语法

Async/Await 是 ES2017 引入的语法糖,让异步代码看起来像同步代码。

// async 函数返回一个 Promise
async function getData() {
  return '数据'
}

getData().then(result => console.log(result)) // 数据

// 使用 await 等待 Promise 完成
async function fetchUserData() {
  try {
    const user = await getUserInfo()
    console.log('用户:', user)
    
    const orders = await getOrderList(user.id)
    console.log('订单:', orders)
    
    const detail = await getOrderDetail(orders[0].id)
    console.log('详情:', detail)
    
    const product = await getProductInfo(detail.productId)
    console.log('商品:', product)
    
    return product
  } catch (error) {
    console.error('出错了:', error)
  }
}

// 调用 async 函数
fetchUserData().then(result => {
  console.log('最终结果:', result)
})

4.2 实战示例:模拟数据请求

// 模拟 API 请求函数
const mockAPI = (url, delay = 1000) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.1) { // 90% 成功率
        resolve({
          status: 200,
          data: { url, timestamp: Date.now() }
        })
      } else {
        reject(new Error(`请求 ${url} 失败`))
      }
    }, delay)
  })
}

// 使用 async/await 实现并发请求
async function fetchMultipleData() {
  try {
    // 并发请求
    const [userData, productData, orderData] = await Promise.all([
      mockAPI('/api/user', 800),
      mockAPI('/api/product', 1200),
      mockAPI('/api/order', 600)
    ])
    
    console.log('所有数据加载完成:')
    console.log('用户数据:', userData.data)
    console.log('商品数据:', productData.data)
    console.log('订单数据:', orderData.data)
    
    return { userData, productData, orderData }
  } catch (error) {
    console.error('数据加载失败:', error.message)
  }
}

// 串行请求(依赖关系)
async function fetchDependentData() {
  console.time('串行请求耗时')
  
  const user = await mockAPI('/api/user', 1000)
  console.log('第一步完成:', user.data)
  
  const orders = await mockAPI(`/api/user/${user.data.url}/orders`, 1000)
  console.log('第二步完成:', orders.data)
  
  const details = await mockAPI(`/api/orders/${orders.data.url}/details`, 1000)
  console.log('第三步完成:', details.data)
  
  console.timeEnd('串行请求耗时')
  // 总耗时约 3000ms
}

// 优化:并行处理不依赖的数据
async function fetchOptimizedData() {
  console.time('优化后耗时')
  
  // 同时发起两个独立请求
  const [user, products] = await Promise.all([
    mockAPI('/api/user', 1000),
    mockAPI('/api/products', 1000)
  ])
  
  console.log('用户和商品数据已获取')
  
  // 依赖用户数据的请求
  const orders = await mockAPI(`/api/user/${user.data.url}/orders`, 1000)
  
  // 可以并行处理的请求
  const [detail1, detail2] = await Promise.all([
    mockAPI(`/api/orders/${orders.data.url}/detail1`, 500),
    mockAPI(`/api/orders/${orders.data.url}/detail2`, 500)
  ])
  
  console.timeEnd('优化后耗时')
  // 总耗时约 2500ms
}

4.3 错误处理最佳实践

// 统一的错误处理函数
const handleAsyncError = (asyncFn) => {
  return async (...args) => {
    try {
      return [await asyncFn(...args), null]
    } catch (error) {
      return [null, error]
    }
  }
}

// 使用错误处理包装器
const safeFetchUser = handleAsyncError(fetchUserData)

async function main() {
  const [user, error] = await safeFetchUser()
  
  if (error) {
    console.error('操作失败:', error.message)
    return
  }
  
  console.log('操作成功:', user)
}

// 带超时的 Promise
function withTimeout(promise, timeout = 5000) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('请求超时')), timeout)
  })
  
  return Promise.race([promise, timeoutPromise])
}

async function fetchWithTimeout() {
  try {
    const result = await withTimeout(mockAPI('/api/data', 3000), 2000)
    console.log('数据:', result)
  } catch (error) {
    console.error('超时或失败:', error.message)
  }
}

五、手写实现(面试高频)

5.1 手写 Promise

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(fn => fn())
      }
    }

    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected'
        this.reason = reason
        this.onRejectedCallbacks.forEach(fn => fn())
      }
    }

    try {
      executor(resolve, reject)
    } catch (error) {
      reject(error)
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value
    onRejected = typeof onRejected === 'function' ? onRejected : error => { throw error }

    const promise2 = new MyPromise((resolve, reject) => {
      if (this.state === 'fulfilled') {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      }

      if (this.state === 'rejected') {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      }

      if (this.state === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value)
              this.resolvePromise(promise2, x, resolve, reject)
            } catch (error) {
              reject(error)
            }
          }, 0)
        })

        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason)
              this.resolvePromise(promise2, x, resolve, reject)
            } catch (error) {
              reject(error)
            }
          }, 0)
        })
      }
    })

    return promise2
  }

  resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
      reject(new TypeError('Chaining cycle detected'))
    }

    if (x && (typeof x === 'object' || typeof x === 'function')) {
      let called = false
      try {
        const then = x.then
        if (typeof then === 'function') {
          then.call(
            x,
            y => {
              if (called) return
              called = true
              this.resolvePromise(promise2, y, resolve, reject)
            },
            error => {
              if (called) return
              called = true
              reject(error)
            }
          )
        } else {
          resolve(x)
        }
      } catch (error) {
        if (called) return
        called = true
        reject(error)
      }
    } else {
      resolve(x)
    }
  }

  catch(onRejected) {
    return this.then(null, onRejected)
  }

  static resolve(value) {
    if (value instanceof MyPromise) return value
    return new MyPromise(resolve => resolve(value))
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason))
  }

  static all(promises) {
    return new MyPromise((resolve, reject) => {
      const result = []
      let count = 0
      
      for (let i = 0; i < promises.length; i++) {
        MyPromise.resolve(promises[i]).then(
          value => {
            result[i] = value
            count++
            if (count === promises.length) resolve(result)
          },
          reject
        )
      }
    })
  }

  static race(promises) {
    return new MyPromise((resolve, reject) => {
      for (const promise of promises) {
        MyPromise.resolve(promise).then(resolve, reject)
      }
    })
  }
}

5.2 手写 async/await 的简单实现

// 使用 Generator 模拟 async/await
function asyncToGenerator(generatorFn) {
  return function() {
    const gen = generatorFn.apply(this, arguments)
    
    return new Promise((resolve, reject) => {
      function step(key, arg) {
        let result
        try {
          result = gen[key](arg)
        } catch (error) {
          reject(error)
          return
        }
        
        const { value, done } = result
        
        if (done) {
          resolve(value)
        } else {
          Promise.resolve(value).then(
            val => step('next', val),
            err => step('throw', err)
          )
        }
      }
      
      step('next')
    })
  }
}

// 使用示例
const fetchData = function() {
  return new Promise(resolve => {
    setTimeout(() => resolve('数据'), 1000)
  })
}

const getData = asyncToGenerator(function* () {
  const data1 = yield fetchData()
  console.log('data1:', data1)
  
  const data2 = yield fetchData()
  console.log('data2:', data2)
  
  return '完成'
})

getData().then(result => console.log(result))

六、面试高频题

6.1 输出顺序题

// 题目1
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
console.log('4')

// 输出:1, 4, 3, 2
// 解释:同步代码先执行,微任务(Promise)先于宏任务(setTimeout)

// 题目2
async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2')
}

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

async1()

new Promise((resolve) => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('promise2')
})

console.log('script end')

// 输出顺序:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

6.2 错误处理题

// 题目:如何捕获 async/await 的错误?
async function getData() {
  try {
    const data = await Promise.reject('出错了')
    console.log(data)
  } catch (error) {
    console.log('捕获到:', error)
  }
}

// 或使用 .catch
async function getData2() {
  const data = await Promise.reject('出错了').catch(err => {
    console.log('处理错误:', err)
    return '默认值'
  })
  console.log(data) // 默认值
}

// 题目:Promise.all 的错误处理
const promises = [
  Promise.resolve(1),
  Promise.reject('错误'),
  Promise.resolve(3)
]

Promise.all(promises)
  .then(console.log)
  .catch(console.error) // 输出:错误

// 如何让 Promise.all 即使有错误也返回所有结果?
Promise.allSettled(promises).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value)
    } else {
      console.log('失败:', result.reason)
    }
  })
})

6.3 并发控制题

// 题目:实现一个并发控制器,限制同时执行的 Promise 数量
class PromiseQueue {
  constructor(concurrency = 2) {
    this.concurrency = concurrency
    this.running = 0
    this.queue = []
  }
  
  add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject })
      this.run()
    })
  }
  
  run() {
    while (this.running < this.concurrency && this.queue.length) {
      const { task, resolve, reject } = this.queue.shift()
      this.running++
      
      Promise.resolve(task())
        .then(resolve, reject)
        .finally(() => {
          this.running--
          this.run()
        })
    }
  }
}

// 使用示例
const queue = new PromiseQueue(2)

for (let i = 0; i < 5; i++) {
  queue.add(() => 
    new Promise(resolve => {
      setTimeout(() => {
        console.log(`任务${i}完成`)
        resolve(i)
      }, 1000)
    })
  )
}
// 每2个任务并行执行

6.4 重试机制题

// 题目:实现一个函数,请求失败时自动重试
async function retryRequest(fn, maxRetries = 3, delay = 1000) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      console.log(`第${i + 1}次尝试`)
      const result = await fn()
      console.log('请求成功')
      return result
    } catch (error) {
      console.log(`第${i + 1}次失败`)
      if (i === maxRetries - 1) {
        throw error
      }
      // 等待延迟时间后重试
      await new Promise(resolve => setTimeout(resolve, delay))
    }
  }
}

// 使用示例
let attempt = 0
const request = () => {
  return new Promise((resolve, reject) => {
    attempt++
    if (attempt < 3) {
      reject('模拟失败')
    } else {
      resolve('成功')
    }
  })
}

retryRequest(request, 3, 1000)
  .then(console.log)
  .catch(console.error)

七、总结与建议

7.1 异步编程演进

  • 回调函数:基础但容易形成"回调地狱"
  • Promise:链式调用,错误统一处理
  • Async/Await:语法糖,代码更直观

7.2 使用建议

  1. 优先使用 async/await,代码更清晰
  2. 并发请求使用 Promise.all,提高性能
  3. 注意错误处理,不要吞掉错误
  4. 避免回调地狱,及时重构代码
  5. 理解事件循环,掌握执行顺序

7.3 面试准备

  • 掌握三种异步方案的原理和用法
  • 能够手写简单的 Promise
  • 理解宏任务和微任务的执行顺序
  • 熟悉常见的异步编程场景和解决方案
  • 能够处理并发控制和错误重试

异步编程是 JavaScript 的核心特性,掌握好这块内容不仅对面试有帮助,更能提升实际开发中的代码质量。

HTML&CSS:纯CSS实现随机转盘抽奖机——无JS,全靠现代CSS黑科技!

这个 HTML 页面实现了一个交互式转盘抽奖效果,使用了现代 CSS 的一些实验性特性 (如 random() 函数、@layer、sibling-index() 等),并结合 SVG 图标和渐变背景,营造出一个视觉吸引、功能完整的“幸运大转盘”界面。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

演示效果

HTML&CSS

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS随机函数实现转盘效果</title>
    <style>
        @import url(https://fonts.bunny.net/css?family=jura:300,700);
        @layer base, notes, demo;

        @layer demo {
            :root {
                --items: 12;
                --spin-easing: cubic-bezier(0, 0.061, 0, 1.032);
                --slice-angle: calc(360deg / var(--items));
                --start-angle: calc(var(--slice-angle) / 2);

                --wheel-radius: min(40vw, 300px);
                --wheel-size: calc(var(--wheel-radius) * 2);
                --wheel-padding: 10%;
                --item-radius: calc(var(--wheel-radius) - var(--wheel-padding));

                --wheel-bg-1: oklch(0.80 0.16 30);
                --wheel-bg-2: oklch(0.74 0.16 140);
                --wheel-bg-3: oklch(0.80 0.16 240);
                --wheel-bg-4: oklch(0.74 0.16 320);

                --marker-bg-color: black;
                --button-text-color: white;
                --spin-duration: random(1s, 3s);

                --random-angle: random(1200deg, 4800deg, by var(--slice-angle));

                @supports not (rotate: random(1deg, 10deg)) {
                    --spin-duration: 2s;
                    --random-angle: 4800deg;
                }
            }


            .wrapper {
                position: relative;
                inset: 0;
                margin: auto;
                width: var(--wheel-size);
                aspect-ratio: 1;

                input[type=checkbox] {
                    position: absolute;
                    opacity: 0;
                    width: 1px;
                    height: 1px;
                    pointer-events: none;
                }

                &:has(input[type=checkbox]:checked) {
                    --spin-it: 1;
                    --btn-spin-scale: 0;
                    --btn-spin-event: none;
                    --btn-spin-trans-duration: var(--spin-duration);
                    --btn-reset-scale: 1;
                    --btn-reset-event: auto;
                    --btn-reset-trans-delay: var(--spin-duration);
                }

                .controls {
                    position: absolute;
                    z-index: 2;
                    inset: 0;
                    margin: auto;
                    width: min(100px, 10vw);
                    aspect-ratio: 1;
                    background: var(--marker-bg-color);
                    border-radius: 9in;
                    transition: scale 150ms ease-in-out;

                    &:has(:hover, :focus-visible) label {
                        scale: 1.2;
                        rotate: 20deg;
                    }

                    &::before {
                        content: '';
                        position: absolute;
                        top: 0;
                        left: 50%;
                        translate: -50% -50%;
                        width: 20%;
                        aspect-radio: 2/10;
                        background-color: transparent;
                        border: 2vw solid var(--marker-bg-color);
                        border-bottom-width: 4vw;
                        border-top: 0;
                        border-left-color: transparent;
                        border-right-color: transparent;
                        z-index: -1;
                    }

                    label {
                        cursor: pointer;
                        display: grid;
                        place-items: center;
                        width: 100%;
                        aspect-ratio: 1;
                        color: var(--button-text-color);
                        transition:
                            rotate 150ms ease-in-out,
                            scale 150ms ease-in-out;

                        svg {
                            grid-area: 1/1;
                            width: 50%;
                            height: 50%;
                            transition-property: scale;
                            transition-timing-function: ease-in-out;

                            &:first-child {
                                transition-duration: var(--btn-spin-trans-duration, 150ms);
                                scale: var(--btn-spin-scale, 1);
                                pointer-events: var(--btn-spin-event, auto);
                            }

                            &:last-child {
                                transition-duration: 150ms;
                                transition-delay: var(--btn-reset-trans-delay, 0ms);
                                scale: var(--btn-reset-scale, 0);
                                pointer-events: var(--btn-reset-event, none);
                            }
                        }
                    }


                }

                &:has(input[type=checkbox]:checked)>.wheel {
                    animation: --spin-wheel var(--spin-duration, 3s) var(--spin-easing, ease-in-out) forwards;
                }

                .wheel {
                    position: absolute;
                    inset: 0;
                    border-radius: 99vw;
                    border: 1px solid white;
                    user-select: none;
                    font-size: 24px;
                    font-weight: 600;
                    background: repeating-conic-gradient(from var(--start-angle),
                            var(--wheel-bg-1) 0deg var(--slice-angle),
                            var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle) * 2),
                            var(--wheel-bg-3) calc(var(--slice-angle) * 2) calc(var(--slice-angle) * 3),
                            var(--wheel-bg-4) calc(var(--slice-angle) * 3) calc(var(--slice-angle) * 4));

                    >span {
                        --i: sibling-index();

                        @supports not (sibling-index(0)) {
                            &:nth-child(1) {
                                --i: 1;
                            }

                            &:nth-child(2) {
                                --i: 2;
                            }

                            &:nth-child(3) {
                                --i: 3;
                            }

                            &:nth-child(4) {
                                --i: 4;
                            }

                            &:nth-child(5) {
                                --i: 5;
                            }

                            &:nth-child(6) {
                                --i: 6;
                            }

                            &:nth-child(7) {
                                --i: 7;
                            }

                            &:nth-child(8) {
                                --i: 8;
                            }

                            &:nth-child(9) {
                                --i: 9;
                            }

                            &:nth-child(10) {
                                --i: 10;
                            }

                            &:nth-child(11) {
                                --i: 11;
                            }

                            &:nth-child(12) {
                                --i: 12;
                            }
                        }
                        position: absolute;
                        offset-path: circle(var(--item-radius) at 50% 50%);
                        offset-distance: calc(var(--i) / var(--items) * 100%);
                        offset-rotate: auto;
                    }
                }
            }

            @keyframes --spin-wheel {
                to {
                    rotate: var(--random-angle);
                }
            }
        }

        @layer notes {
            section.notes {
                margin: auto;
                width: min(80vw, 56ch);

                p {
                    text-wrap: pretty;
                }

                > :first-child {
                    color: red;
                    background: rgb(255, 100, 103);
                    padding: .5em;
                    color: white;

                    @supports (rotate: random(1deg, 10deg)) {
                        display: none;
                    }
                }
            }
        }

        @layer base {

            *,
            ::before,
            ::after {
                box-sizing: border-box;
            }

            :root {
                color-scheme: light dark;
                --bg-dark: rgb(21 21 21);
                --bg-light: rgb(248, 244, 238);
                --txt-light: rgb(10, 10, 10);
                --txt-dark: rgb(245, 245, 245);
                --line-light: rgba(0 0 0 / .75);
                --line-dark: rgba(255 255 255 / .25);
                --clr-bg: light-dark(var(--bg-light), var(--bg-dark));
                --clr-txt: light-dark(var(--txt-light), var(--txt-dark));
                --clr-lines: light-dark(var(--line-light), var(--line-dark));
            }

            body {
                background-color: var(--clr-bg);
                color: var(--clr-txt);
                min-height: 100svh;
                margin: 0;
                padding: 2rem;
                font-family: "Jura", sans-serif;
                font-size: 1rem;
                line-height: 1.5;
                display: grid;
                place-content: center;
                gap: 2rem;
            }

            strong {
                font-weight: 700;
            }
        }
    </style>
</head>

<body>

    <section class="wrapper">
        <input type="checkbox" id="radio-spin">
        <div class="controls">
            <label for="radio-spin">
                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Spin the Wheel" title="Spin the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M14 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
                    <path d="M12 21c-3.314 0 -6 -2.462 -6 -5.5s2.686 -5.5 6 -5.5" />
                    <path d="M21 12c0 3.314 -2.462 6 -5.5 6s-5.5 -2.686 -5.5 -6" />
                    <path d="M12 14c3.314 0 6 -2.462 6 -5.5s-2.686 -5.5 -6 -5.5" />
                    <path d="M14 12c0 -3.314 -2.462 -6 -5.5 -6s-5.5 2.686 -5.5 6" />
                </svg>

                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Reset the Wheel" title="Reset the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M3.06 13a9 9 0 1 0 .49 -4.087" />
                    <path d="M3 4.001v5h5" />
                    <path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
                </svg>
            </label>
        </div>

        <div id="wheel" class="wheel">
            <span>乔丹</span>
            <span>詹姆斯</span>
            <span>布莱恩特</span>
            <span>约翰逊</span>
            <span>库里</span>
            <span>奥尼尔</span>
            <span>邓肯</span>
            <span>贾巴尔</span>
            <span>杜兰特</span>
            <span>哈登</span>
            <span>字母哥</span>
            <span>伦纳德</span>
        </div>
    </section>
</body>

</html>

HTML

  • section:转盘核心容器(语义化区块)相对定位,包含转盘所有子元素
  • input:转盘触发开关(核心交互控件) 视觉隐藏(opacity:0),通过「选中 / 未选中」触发动画
  • div controls:转盘中心按钮容器。绝对定位,层级高于转盘,包含点击触发的 label
  • label :绑定隐藏复选框,作为可点击按钮。点击该标签等价于点击复选框,触发状态切换
  • svg:显示「旋转」「重置」图标。两个 SVG 重叠,通过 CSS 控制显隐
  • div wheel:转盘本体。圆形布局,包含 12 个奖项文本
  • span:转盘奖项文本(12 个 NBA 球星名称) 每个 span 对应转盘一个分区,通过 CSS 定位到圆形轨道

CSS

1. 样式分层管理(@layer)

@layer base, notes, demo;
  • base:全局基础样式(盒模型、明暗色模式、页面布局),优先级最低;
  • notes:兼容提示文本样式,优先级中等;
  • demo:转盘核心样式(尺寸、动画、交互),优先级最高;

作用:按层级管理样式,避免样式冲突,便于维护。

2. 核心变量定义(:root)

:root {
  --items: 12; /* 转盘分区数量 */
  --slice-angle: calc(360deg / var(--items)); /* 每个分区角度(30°) */
  --wheel-radius: min(40vw, 300px); /* 转盘半径(自适应,最大300px) */
  --spin-duration: random(1s, 3s); /* 随机旋转时长(1-3秒) */
  --random-angle: random(1200deg, 4800deg, by var(--slice-angle)); /* 随机旋转角度(步长30°) */
  /* 浏览器兼容降级:不支持random()则固定值 */
  @supports not (rotate: random(1deg, 10deg)) {
    --spin-duration: 2s;
    --random-angle: 4800deg;
  }
}

核心:用变量统一管理转盘尺寸、角度、动画参数,random() 实现「随机旋转」核心效果,同时做浏览器兼容降级。

3. 转盘交互触发逻辑

/* 监听复选框选中状态,更新变量控制图标/动画 */
.wrapper:has(input[type=checkbox]:checked) {
  --btn-spin-scale: 0; /* 隐藏旋转图标 */
  --btn-reset-scale: 1; /* 显示重置图标 */
}
/* 选中时触发转盘旋转动画 */
.wrapper:has(input[type=checkbox]:checked)>.wheel {
  animation: --spin-wheel var(--spin-duration) var(--spin-easing) forwards;
}
/* 旋转动画:转到随机角度后保持状态 */
@keyframes --spin-wheel {
  to { rotate: var(--random-angle); }
}

核心:通过 :has() 伪类监听复选框状态,触发转盘动画,forwards 确保动画结束后不回弹。

4. 转盘视觉与布局

.wheel {
  border-radius: 99vw; /* 圆形转盘 */
  /* 四色循环锥形渐变,实现转盘分区背景 */
  background: repeating-conic-gradient(from var(--start-angle),
    var(--wheel-bg-1) 0deg var(--slice-angle),
    var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle)*2),
    var(--wheel-bg-3) calc(var(--slice-angle)*2) calc(var(--slice-angle)*3),
    var(--wheel-bg-4) calc(var(--slice-angle)*3) calc(var(--slice-angle)*4));
  >span {
    offset-path: circle(var(--item-radius) at 50% 50%); /* 圆形轨道 */
    offset-distance: calc(var(--i) / var(--items) * 100%); /* 按索引定位到对应分区 */
  }
}

核心:repeating-conic-gradient 实现转盘彩色分区,offset-path 让奖项文本沿圆形轨道均匀分布。

5. 中心按钮交互

.controls {
  position: absolute;
  z-index: 2; /* 层级高于转盘,确保可点击 */
  border-radius: 9in; /* 圆形按钮 */
  &::before { /* 转盘顶部指针 */
    content: '';
    border: 2vw solid var(--marker-bg-color);
    border-bottom-width: 4vw;
    border-top/left/right-color: transparent; /* 三角指针形状 */
  }
  label:hover { scale: 1.2; rotate: 20deg; } /* 鼠标悬浮时图标放大旋转 */
}

核心:伪元素实现转盘「指针」,hover 动效提升交互反馈,两个 SVG 图标通过 scale 控制显隐。

6. 全局基础样式

@layer base {
  :root {
    color-scheme: light dark; /* 适配系统明暗色模式 */
    --clr-bg: light-dark(var(--bg-light), var(--bg-dark)); /* 自动切换背景色 */
  }
  body {
    min-height: 100svh; /* 适配移动端安全区 */
    display: grid;
    place-content: center; /* 垂直水平居中 */
  }
}

核心:light-dark() 自动适配系统明暗模式,100svh 避免移动端地址栏遮挡,网格布局实现内容居中。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

手写一个无限画布 #3:如何在Canvas 层上建立事件体系

你以为你点中了一个圆,其实你只是点中了一堆毫无意义的像素点。在画布里,所谓的“选中”,不过是一场精密的数学与色彩幻术。

上一篇我们终于搞定了渲染层,并明确选择了 Konva (Canvas 2D) 作为我们的底层渲染基石。现在,我们的屏幕上终于可以丝滑地渲染出极具表现力的图形了。

但是,当你试图把鼠标悬停在其中一个图形上,或者想拖拽一条连线时,你会遭遇一个巨大的反直觉打击:浏览器完全不知道你点的是什么。

在传统的前端开发中,原生 DOM 是一棵界限分明的树。鼠标移入一个 <div>,浏览器引擎会在底层自动做碰撞检测,并把 mouseenterclick 事件准确无误地派发给这个节点。如果你给 <div> 加了圆角(border-radius)甚至复杂的 clip-path,浏览器依然能完美识别出精确的边缘。这种体验太理所当然,以至于我们从未思考过背后的代价。

但在 Canvas 的世界里,这套秩序完全失效了。

对于浏览器来说,不管你在 Canvas 里画了多少个圆圈、多复杂的文字,它看到的永远只有一个扁平的 <canvas> 标签。 当用户点击屏幕时,浏览器的原生 Event 对象只能递给你一个冷冰冰的坐标:{ clientX: 500, clientY: 400 }。至于这个坐标下是空气、是红色正方形,还是三个交叠在一起的半透明多边形,对不起,只能你自己算。

要在毫无知觉的像素油盆上,重新赋予图形被“感知”的能力,这就是 命中测试(Hit Testing) 的核心命题。

直觉陷阱:纯算几何碰撞

面对这个问题,多数人脑海里冒出的第一个念头一定是算数学题。

“既然我知道画布上每个方块的长宽、每个圆的半径,那鼠标点下去的时候,去遍历所有图形做个碰撞测试不就好了?”

比如点矩形,就看鼠标坐标是不是在它的上下左右边界内;点圆,就算勾股定理看距离是不是小于半径;如果是多边形,大不了掏出大学计算机图形学里教的“射线法(Ray-Casting)”,看看射线和多边形交点是奇数还是偶数。

在很多游戏开发新手教程里,这确实是讲解命中测试的第一课。

但只要你真的在业务里动手写过,就会立刻体会到这种朴素算法带来的“工程绝望”:

如果是最基础的方块和圆还好,可你在白板工具(如 Excalidraw / Figma)里,最常面对的是用户鼠标画出的一条粗细不均、极度扭曲的自由手绘墨迹(Freehand Draw)。成百上千个点连出来的畸形曲线,你拿什么算交点?

即使你咬着牙把每根线段都算了,还有图形的中空与穿透问题。当用户点在一个空心圆环的正中间,或者字母 "O" 的空白处时,根据最粗糙的外围包围盒(Bounding Box),它是被命中的;但这根本违反了用户“我明明点在透明的地方,我想点它背后元素”的心理预期。哪怕你真算出了鼠标确实落在图形线条上,你又怎么确保,这层图形的正上方,没有被另一个半透明的阴影盖住呢?

别忘了最绝杀的性能噩梦。不仅是点击,鼠标每在屏幕上划过一个像素,就会高频触发 mousemove。如果同屏有几千个杂乱的图形交叠,每移动一毫米就要把所有多边形的射线方程重新算一遍,你的 CPU 风扇会直接起飞,页面帧率瞬间崩盘。

想靠纯写 if-else 的几何穷举来搞定一个不仅带各种圆角、线宽、自交错,还带层级遮挡的生产级别白板交互,可以说是直接在给 CPU 判死刑。


优雅的黑魔法:离屏 Canvas 与 Color Picking

针对纯正向几何数学算不通的情况,业界的顶级绘图引擎往往会使用一招极度聪明且优雅的逆向黑魔法:利用颜色查表法(Color Picking)。这也是 Konva 最为核心的看家本领机制。

hit-test-color-picking.png

它的核心逻辑堪称“暗度陈仓”,分为以下几个精妙的步骤:

1. 建立影分身(Hidden Canvas)

在内存中,创建一个跟主屏幕尺寸完全一致的隐藏 Canvas(用户看不见它)。主屏幕负责渲染展现给用户看的漂亮图形,而这个“影分身”只专门用来做苦力——命中测试。

2. 分配身份色(Color Hash)

当我们要往主屏幕画一个崭新的图形(比如一个带有高斯模糊阴影的蓝色虚线圈)时,引擎会在内存里给这个图形分配一个全局唯一、随机生成的 RGB 颜色值(比如 #000001)。 然后在内存的隐藏 Canvas 的同样坐标处,用这个唯一颜色 #000001 画一个同样轮廓的圆。无论主画布上的圆有多花哨,隐藏画布上的圆统统画成没有阴影、没有抗锯齿的纯色实心/实线

与此同时,维护一个字典(Hash Map),记录:#000001 映射到 蓝色虚线图对象引用

3. O(1) 的降维打击:只读一个像素

见证奇迹的时刻到了。 当前的场景是:主画布上画了成千上万个复杂的图形。隐藏画布上也用同样的布局画了成千上万个纯粹色块。

当用户在主屏幕上点击 (x: 500, y: 400) 时,引擎不去做任何数学几何碰撞计算,除了获取坐标外只做极其底层的一步:

  1. 走到隐藏 Canvas 面前。
  2. 精确地读取它 (500, 400) 这个坐标点上的 1 个像素的 RGB 颜色值getImageData)。
  3. 如果读出来的颜色是黑色(完全透明),说明没点中任何东西。
  4. 如果读出来的颜色是 #000001,引擎立刻去 Hash Map 里查表——破案了!对应的是那个蓝色的虚线圈对象。

为什么这个方案是统治级的?

  1. 彻底无视几何形状的难度。不管你画的是自由手绘还是残缺的文字轮廓,只要它被渲染引擎画在屏幕上,那对应的颜色像素就实打实地落在了隐藏画布上。它巧妙地利用底层的 GPU 渲染规则来替你完成极度复杂的轮廓光栅化判定。
  2. 天然解决重叠遮挡。主画布怎么叠加层级的,隐藏画布也是按同样顺序绘制的。你在隐藏画布上读出来的那个带颜色像素,必然是最顶层、没被别人遮挡的那个对象的颜色。完全不需要自己遍历判断层级。
  3. 极端的性能空间换时间。把原本复杂的 O(N×几何顶点数)O(N \times 几何顶点数) 的每帧遍历计算,直接降维成了读取内存图像一个单像素点的 O(1)O(1) 常数级查表时间。即使屏幕上有十万个对象,鼠标在上面疯狂移动也是绝对丝滑的。

站在巨人的肩膀:这就是 Konva

要在原生 Canvas 上实现一个可用于生产环境的稳健命中测试系统基建,工作量是极其庞大的。你要自己去维护那个巨大的离屏画布上下文同步、自己分配十六进制颜色、自己实现局部重绘优化、还要自己派发所有的模拟 DOM 冒泡事件。

这正是我们放弃从零手写引擎底层,转而选型采用 Konva 的终极原因。

Konva 在底层极其克制且优雅地封装了这套“离屏颜色拾取算法”。在开发者眼里,你完全感受不到那个诡异的“彩色隐藏画布”的存在。

它直接把这套脏活累活,包装成了我们最熟悉的、一如在写原生 DOM 一样的前端语法范式。这就让我们能够完全剥离繁复的数学几何泥潭,将精力投入在画布“事件分发与交互流控制”上:

// 这种久违的、确定的秩序感,对于开发无穷交互的白板来说是极其珍贵的。
import Konva from "konva";

const rect = new Konva.Rect({
  x: 50,
  y: 50,
  width: 100,
  height: 50,
  fill: "blue",
  draggable: true, // 开启拖拽!底层所有复杂的变换全自动运算并重绘画布。
});

// 你仿佛重新拥有了原生的 DOM 事件绑定系统
rect.on("mouseenter", () => {
  document.body.style.cursor = "pointer";
  rect.fill("red"); // 悬浮触发变色响应
});

rect.on("mouseleave", () => {
  document.body.style.cursor = "default";
  rect.fill("blue");
});

// 即使有成百上千个图形交叠,它也能极速计算,精准捕捉顶层响应
rect.on("click", (e) => {
  console.log("极速且精准地点中了我:", e.target);
});

有了 Konva 兜底解决“感知盲区”,我们终于补齐了跨越无限画布最重要、也是最难缠的一块技术栈拼图。

我们不再是在冷冰冰的像素点数组上作画,而是真正在操控和编排一个个有边界、能响应手势、知晓自身存在的“实体对象”

经历三篇的文章,我们已经打通了从“坐标系”、“底层渲染引擎选型博弈”到“重建事件分发秩序”的全部技术基建。

接下来,我们将长驱直入应用数据的深水区:在这块充满感知能力的画布上,我们该如何用正确的数据结构来对这些可被协同、可被导出、可被反序列化的对象进行定义?

BroadcastChannel:浏览器原生跨标签页通信

在现代Web应用开发中,跨标签页通信是一个常见需求。无论是实现多标签页间的数据同步、构建协作工具,还是简单的消息广播,开发者都需要一个可靠的通信方案。虽然过去我们有 localStorage、postMessage 等方案,但 BroadcastChannel API 提供了一个更优雅、更专业的解决方案。

什么是 BroadcastChannel?

BroadcastChannel 是 HTML5 中引入的一个专门用于同源页面间通信的 API。它允许同一源下的不同浏览上下文(如标签页、iframe、Web Worker)之间进行消息广播。

核心特点

  • 同源限制:只能在相同协议、域名、端口的页面间通信

  • 一对多通信:一条消息可以同时被所有监听者接收

  • 双向通信:所有参与者既可以发送消息,也可以接收消息

  • 自动清理:页面关闭后自动断开连接

基础用法

1. 创建或加入频道

// 创建/加入名为 "chat_room" 的频道
const channel = new BroadcastChannel('chat_room');

// 查看频道名称
console.log(channel.name); // 输出: "chat_room"

2. 发送消息

// 发送字符串
channel.postMessage('Hello from Page 1');

// 发送对象
channel.postMessage({
  type: 'user_action',
  user: '张三',
  action: 'click',
  timestamp: Date.now()
});

// 支持大多数数据类型
channel.postMessage(['数组', '数据']);
channel.postMessage(new Blob(['文件内容']));
channel.postMessage(new Uint8Array([1, 2, 3]));

3. 接收消息

// 方式1:使用 onmessage
channel.onmessage = (event) => {
  console.log('收到消息:', event.data);
  console.log('消息来源:', event.origin);
  console.log('时间戳:', event.timeStamp);
};

// 方式2:使用 addEventListener
channel.addEventListener('message', (event) => {
  console.log('收到消息:', event.data);
});

// 错误处理
channel.onmessageerror = (error) => {
  console.error('消息处理错误:', error);
};

4. 关闭频道

// 关闭频道,不再接收消息
channel.close();

实际应用场景

场景1:主题同步

当用户在一个标签页切换主题时,所有其他标签页自动同步:

// theme-sync.js
class ThemeSync {
  constructor() {
    this.channel = new BroadcastChannel('theme_sync');
    this.setupListener();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      if (event.data.type === 'theme_change') {
        this.applyTheme(event.data.theme);
      }
    };
  }
  
  changeTheme(theme) {
    this.applyTheme(theme);
    this.channel.postMessage({
      type: 'theme_change',
      theme: theme,
      from: this.getTabId()
    });
  }
  
  applyTheme(theme) {
    document.body.className = `theme-${theme}`;
    localStorage.setItem('preferred_theme', theme);
  }
  
  getTabId() {
    return sessionStorage.getItem('tab_id') || 
           Math.random().toString(36).substring(7);
  }
}

// 使用
const themeSync = new ThemeSync();
themeSync.changeTheme('dark');

场景2:实时聊天室

创建一个简单的多标签页聊天室:

<!-- chat.html -->
<!DOCTYPE html>
<html>
<head>
    <title>BroadcastChannel 聊天室</title>
    <style>
        .chat-container { max-width: 600px; margin: 0 auto; padding: 20px; }
        .message-list { 
            height: 400px; 
            overflow-y: auto; 
            border: 1px solid #ccc; 
            padding: 10px;
            margin-bottom: 10px;
        }
        .message { margin: 5px 0; padding: 8px; background: #f0f0f0; border-radius: 5px; }
        .system { background: #e3f2fd; text-align: center; }
        .self { background: #e8f5e8; border-left: 3px solid #4caf50; }
        .input-area { display: flex; gap: 10px; }
        #messageInput { flex: 1; padding: 8px; }
        button { padding: 8px 15px; background: #4caf50; color: white; border: none; border-radius: 3px; cursor: pointer; }
    </style>
</head>
<body>
    <div class="chat-container">
        <h1>📱 跨标签页聊天室</h1>
        <div class="message-list" id="messageList"></div>
        <div class="input-area">
            <input type="text" id="messageInput" placeholder="输入消息..." onkeypress="if(event.key==='Enter') sendMessage()">
            <button onclick="sendMessage()">发送</button>
            <button onclick="changeNickname()">修改昵称</button>
        </div>
    </div>

    <script>
        // 聊天室逻辑
        const chatChannel = new BroadcastChannel('global_chat');
        const userId = Math.random().toString(36).substring(2, 10);
        let nickname = '用户_' + userId.substring(0, 4);
        
        // 监听消息
        chatChannel.onmessage = (event) => {
            const { type, data, from, userId: msgUserId } = event.data;
            
            switch(type) {
                case 'message':
                    displayMessage(from, data, msgUserId === userId);
                    break;
                case 'join':
                    displaySystemMessage(`${from} 加入了聊天室`);
                    break;
                case 'leave':
                    displaySystemMessage(`${from} 离开了聊天室`);
                    break;
                case 'nickname_change':
                    displaySystemMessage(`${from} 改名为 ${data}`);
                    break;
            }
        };
        
        // 广播加入消息
        chatChannel.postMessage({
            type: 'join',
            from: nickname,
            userId: userId,
            time: Date.now()
        });
        
        function sendMessage() {
            const input = document.getElementById('messageInput');
            const text = input.value.trim();
            
            if (text) {
                chatChannel.postMessage({
                    type: 'message',
                    from: nickname,
                    data: text,
                    userId: userId,
                    time: Date.now()
                });
                
                displayMessage(nickname, text, true);
                input.value = '';
            }
        }
        
        function changeNickname() {
            const newNickname = prompt('请输入新昵称:', nickname);
            if (newNickname && newNickname.trim() && newNickname !== nickname) {
                const oldNickname = nickname;
                nickname = newNickname.trim();
                
                chatChannel.postMessage({
                    type: 'nickname_change',
                    from: oldNickname,
                    data: nickname,
                    userId: userId,
                    time: Date.now()
                });
            }
        }
        
        function displayMessage(sender, text, isSelf = false) {
            const list = document.getElementById('messageList');
            const msgDiv = document.createElement('div');
            msgDiv.className = `message ${isSelf ? 'self' : ''}`;
            
            const time = new Date().toLocaleTimeString('zh-CN', { 
                hour: '2-digit', 
                minute: '2-digit' 
            });
            
            msgDiv.innerHTML = `<strong>${sender}${isSelf ? ' (我)' : ''}:</strong> ${escapeHtml(text)} <small>${time}</small>`;
            
            list.appendChild(msgDiv);
            list.scrollTop = list.scrollHeight;
        }
        
        function displaySystemMessage(text) {
            const list = document.getElementById('messageList');
            const msgDiv = document.createElement('div');
            msgDiv.className = 'message system';
            msgDiv.innerHTML = escapeHtml(text);
            list.appendChild(msgDiv);
            list.scrollTop = list.scrollHeight;
        }
        
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }
        
        // 页面关闭时通知
        window.addEventListener('beforeunload', () => {
            chatChannel.postMessage({
                type: 'leave',
                from: nickname,
                userId: userId
            });
            chatChannel.close();
        });
    </script>
</body>
</html>

场景3:数据同步

实现购物车在多标签页间的实时同步:

// cart-sync.js
class CartSync {
  constructor() {
    this.channel = new BroadcastChannel('cart_sync');
    this.items = this.loadFromStorage() || [];
    this.listeners = [];
    
    this.setupListener();
    this.syncWithOthers();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      const { type, data, from } = event.data;
      
      switch(type) {
        case 'cart_update':
          this.items = data.items;
          this.saveToStorage();
          this.notifyListeners('update', data);
          break;
          
        case 'cart_request':
          // 新标签页请求同步
          this.channel.postMessage({
            type: 'cart_response',
            data: { items: this.items },
            from: this.getTabId()
          });
          break;
          
        case 'cart_response':
          if (from !== this.getTabId() && this.items.length === 0) {
            this.items = data.items;
            this.saveToStorage();
            this.notifyListeners('sync', data);
          }
          break;
      }
    };
  }
  
  syncWithOthers() {
    // 请求其他标签页的数据
    this.channel.postMessage({
      type: 'cart_request',
      from: this.getTabId()
    });
  }
  
  addItem(item) {
    this.items.push({
      ...item,
      id: Date.now() + Math.random(),
      addedAt: new Date().toISOString()
    });
    
    this.broadcastUpdate();
  }
  
  removeItem(itemId) {
    this.items = this.items.filter(item => item.id !== itemId);
    this.broadcastUpdate();
  }
  
  updateQuantity(itemId, quantity) {
    const item = this.items.find(item => item.id === itemId);
    if (item) {
      item.quantity = Math.max(1, quantity);
      this.broadcastUpdate();
    }
  }
  
  broadcastUpdate() {
    this.saveToStorage();
    
    this.channel.postMessage({
      type: 'cart_update',
      data: { items: this.items },
      from: this.getTabId(),
      timestamp: Date.now()
    });
    
    this.notifyListeners('update', { items: this.items });
  }
  
  loadFromStorage() {
    const saved = localStorage.getItem('cart_items');
    return saved ? JSON.parse(saved) : null;
  }
  
  saveToStorage() {
    localStorage.setItem('cart_items', JSON.stringify(this.items));
  }
  
  getTabId() {
    let tabId = sessionStorage.getItem('tab_id');
    if (!tabId) {
      tabId = Math.random().toString(36).substring(2, 10);
      sessionStorage.setItem('tab_id', tabId);
    }
    return tabId;
  }
  
  subscribe(callback) {
    this.listeners.push(callback);
    return () => {
      this.listeners = this.listeners.filter(cb => cb !== callback);
    };
  }
  
  notifyListeners(event, data) {
    this.listeners.forEach(callback => callback(event, data));
  }
}

// 使用示例
const cart = new CartSync();

// 订阅更新
cart.subscribe((event, data) => {
  console.log(`购物车${event}:`, data);
  updateCartUI(data.items);
});

// 添加商品
cart.addItem({
  name: '商品名称',
  price: 99.9,
  quantity: 1
});

场景4:Web Worker 协作

// main.js
// 主线程
const workerChannel = new BroadcastChannel('worker_tasks');
const worker = new Worker('worker.js');

// 发送任务到所有worker
workerChannel.postMessage({
  type: 'new_task',
  taskId: 'task_001',
  data: [1, 2, 3, 4, 5]
});

// 接收worker结果
workerChannel.onmessage = (event) => {
  if (event.data.type === 'task_result') {
    console.log('任务完成:', event.data.result);
  }
};

// worker.js
// Web Worker
const channel = new BroadcastChannel('worker_tasks');
const workerId = Math.random().toString(36).substring(2, 6);

channel.onmessage = (event) => {
  const { type, taskId, data } = event.data;
  
  if (type === 'new_task') {
    console.log(`Worker ${workerId} 接收任务:`, taskId);
    
    // 模拟耗时计算
    const result = data.map(x => x * 2);
    
    // 广播结果
    channel.postMessage({
      type: 'task_result',
      taskId: taskId,
      result: result,
      workerId: workerId
    });
  }
};

与其他通信方案的比较

1. vs localStorage

// localStorage 方案
window.addEventListener('storage', (e) => {
  if (e.key === 'message') {
    console.log('收到消息:', e.newValue);
  }
});
localStorage.setItem('message', 'hello');

// BroadcastChannel 方案
const channel = new BroadcastChannel('messages');
channel.onmessage = (e) => console.log('收到消息:', e.data);
channel.postMessage('hello');

优势对比

  • BroadcastChannel:专门为通信设计,语义清晰,性能更好,支持复杂数据类型

  • localStorage:主要用于存储,通信只是附带功能,有大小限制(通常5MB)

2. vs postMessage

// postMessage 需要知道目标窗口
const otherWindow = window.open('other.html');
otherWindow.postMessage('hello', '*');

// BroadcastChannel 无需知道目标
const channel = new BroadcastChannel('messages');
channel.postMessage('hello');

优势对比

  • BroadcastChannel:一对多广播,无需维护窗口引用

  • postMessage:一对一通信,更灵活但需要管理目标

3. vs WebSocket

高级技巧

1. 频道管理器

class BroadcastChannelManager {
  constructor() {
    this.channels = new Map();
    this.globalListeners = new Set();
  }
  
  // 获取或创建频道
  getChannel(name) {
    if (!this.channels.has(name)) {
      const channel = new BroadcastChannel(name);
      
      channel.onmessage = (event) => {
        // 触发全局监听器
        this.globalListeners.forEach(listener => {
          listener(name, event.data, event);
        });
        
        // 触发频道特定监听器
        const channelListeners = this.channels.get(name)?.listeners || [];
        channelListeners.forEach(listener => {
          listener(event.data, event);
        });
      };
      
      this.channels.set(name, {
        channel,
        listeners: []
      });
    }
    
    return this.channels.get(name).channel;
  }
  
  // 订阅频道消息
  subscribe(channelName, listener) {
    this.getChannel(channelName); // 确保频道存在
    
    const channel = this.channels.get(channelName);
    channel.listeners.push(listener);
    
    return () => {
      channel.listeners = channel.listeners.filter(l => l !== listener);
    };
  }
  
  // 订阅所有频道消息
  subscribeAll(listener) {
    this.globalListeners.add(listener);
    return () => this.globalListeners.delete(listener);
  }
  
  // 发送消息到频道
  send(channelName, data) {
    const channel = this.getChannel(channelName);
    channel.postMessage(data);
  }
  
  // 关闭频道
  closeChannel(channelName) {
    if (this.channels.has(channelName)) {
      const { channel } = this.channels.get(channelName);
      channel.close();
      this.channels.delete(channelName);
    }
  }
  
  // 关闭所有频道
  closeAll() {
    this.channels.forEach(({ channel }) => channel.close());
    this.channels.clear();
    this.globalListeners.clear();
  }
}

// 使用示例
const manager = new BroadcastChannelManager();

// 订阅特定频道
const unsubscribe = manager.subscribe('chat', (data) => {
  console.log('聊天消息:', data);
});

// 订阅所有频道
const unsubscribeAll = manager.subscribeAll((channel, data) => {
  console.log(`[${channel}] 收到:`, data);
});

// 发送消息
manager.send('chat', { text: 'Hello' });

2. 消息确认机制

class ReliableBroadcastChannel {
  constructor(name) {
    this.channel = new BroadcastChannel(name);
    this.pendingMessages = new Map();
    this.messageId = 0;
    
    this.setupListener();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      const { type, id, data, from } = event.data;
      
      if (type === 'ack') {
        // 收到确认,移除待确认消息
        this.pendingMessages.delete(id);
      } else {
        // 处理消息
        this.handleMessage(data, from);
        
        // 发送确认
        this.channel.postMessage({
          type: 'ack',
          id: id,
          from: this.getSenderId()
        });
      }
    };
  }
  
  send(data, requireAck = true) {
    const id = ++this.messageId;
    
    this.channel.postMessage({
      type: 'message',
      id: id,
      data: data,
      from: this.getSenderId(),
      timestamp: Date.now()
    });
    
    if (requireAck) {
      // 存储待确认消息
      this.pendingMessages.set(id, {
        data,
        timestamp: Date.now(),
        retries: 0
      });
      
      // 启动重试机制
      this.startRetry(id);
    }
  }
  
  startRetry(id) {
    const maxRetries = 3;
    const timeout = 1000;
    
    const check = () => {
      const message = this.pendingMessages.get(id);
      
      if (message && message.retries < maxRetries) {
        message.retries++;
        console.log(`重发消息 ${id},第 ${message.retries} 次`);
        
        this.channel.postMessage({
          type: 'message',
          id: id,
          data: message.data,
          from: this.getSenderId(),
          retry: true
        });
        
        setTimeout(check, timeout * message.retries);
      } else if (message) {
        console.error(`消息 ${id} 发送失败`);
        this.pendingMessages.delete(id);
      }
    };
    
    setTimeout(check, timeout);
  }
  
  handleMessage(data, from) {
    console.log('可靠收到:', data, '来自:', from);
  }
  
  getSenderId() {
    return sessionStorage.getItem('sender_id') || 
           Math.random().toString(36).substring(2);
  }
}

3. 心跳检测和状态同步

class TabHeartbeat {
  constructor() {
    this.channel = new BroadcastChannel('heartbeat');
    this.tabId = Math.random().toString(36).substring(2, 10);
    this.tabs = new Map();
    
    this.setupListener();
    this.startHeartbeat();
    this.requestStatus();
  }
  
  setupListener() {
    this.channel.onmessage = (event) => {
      const { type, tabId, data } = event.data;
      
      switch(type) {
        case 'heartbeat':
          this.updateTab(tabId, data);
          break;
          
        case 'status_request':
          this.sendStatus();
          break;
          
        case 'status_response':
          this.updateTab(tabId, data);
          break;
      }
    };
  }
  
  startHeartbeat() {
    // 每秒发送心跳
    setInterval(() => {
      this.channel.postMessage({
        type: 'heartbeat',
        tabId: this.tabId,
        data: {
          url: window.location.href,
          title: document.title,
          lastActive: Date.now(),
          scrollY: window.scrollY
        }
      });
    }, 1000);
    
    // 每30秒清理离线标签
    setInterval(() => {
      this.cleanOfflineTabs();
    }, 30000);
  }
  
  requestStatus() {
    this.channel.postMessage({
      type: 'status_request',
      tabId: this.tabId
    });
  }
  
  sendStatus() {
    this.channel.postMessage({
      type: 'status_response',
      tabId: this.tabId,
      data: {
        url: window.location.href,
        title: document.title,
        lastActive: Date.now(),
        scrollY: window.scrollY
      }
    });
  }
  
  updateTab(tabId, data) {
    this.tabs.set(tabId, {
      ...data,
      lastSeen: Date.now()
    });
  }
  
  cleanOfflineTabs() {
    const now = Date.now();
    for (const [tabId, data] of this.tabs) {
      if (now - data.lastSeen > 5000) {
        this.tabs.delete(tabId);
      }
    }
  }
  
  getOnlineTabs() {
    return Array.from(this.tabs.values());
  }
}

降级方案

class CrossTabChannel {
  constructor(name) {
    this.name = name;
    this.listeners = [];
    
    if ('BroadcastChannel' in window) {
      // 使用 BroadcastChannel
      this.channel = new BroadcastChannel(name);
      this.channel.onmessage = (event) => {
        this.notifyListeners(event.data);
      };
    } else {
      // 降级到 localStorage
      this.setupLocalStorageFallback();
    }
  }
  
  setupLocalStorageFallback() {
    window.addEventListener('storage', (event) => {
      if (event.key === `channel_${this.name}` && event.newValue) {
        try {
          const data = JSON.parse(event.newValue);
          // 避免循环
          if (data.from !== this.getTabId()) {
            this.notifyListeners(data.payload);
          }
        } catch (e) {
          console.error('解析消息失败:', e);
        }
      }
    });
  }
  
  postMessage(data) {
    if (this.channel) {
      // 使用 BroadcastChannel
      this.channel.postMessage(data);
    } else {
      // 使用 localStorage
      localStorage.setItem(`channel_${this.name}`, JSON.stringify({
        from: this.getTabId(),
        payload: data,
        timestamp: Date.now()
      }));
      // 立即清除,避免积累
      setTimeout(() => {
        localStorage.removeItem(`channel_${this.name}`);
      }, 100);
    }
  }
  
  onMessage(callback) {
    this.listeners.push(callback);
  }
  
  notifyListeners(data) {
    this.listeners.forEach(callback => callback(data));
  }
  
  getTabId() {
    let tabId = sessionStorage.getItem('tab_id');
    if (!tabId) {
      tabId = Math.random().toString(36).substring(2, 10);
      sessionStorage.setItem('tab_id', tabId);
    }
    return tabId;
  }
  
  close() {
    if (this.channel) {
      this.channel.close();
    }
    this.listeners = [];
  }
}

最佳实践总结

1. 命名规范

// 使用清晰的命名空间
const channel = new BroadcastChannel('app_name:feature:room');
// 例如:'myapp:chat:room1', 'myapp:cart:sync'

2. 错误处理

channel.onmessageerror = (error) => {
  console.error('消息处理失败:', error);
  // 可以尝试重新发送或降级处理
};

3. 资源清理

// 组件卸载时关闭频道
useEffect(() => {
  const channel = new BroadcastChannel('my_channel');
  
  return () => {
    channel.close();
  };
}, []);

4. 消息格式标准化

// 统一的消息格式
const message = {
  type: 'MESSAGE_TYPE',     // 消息类型
  id: 'unique_id',          // 唯一标识
  from: 'sender_id',        // 发送者
  payload: {},              // 实际数据
  timestamp: Date.now(),    // 时间戳
  version: '1.0'            // 版本号
};

5. 避免消息风暴

// 使用防抖或节流
function debounceBroadcast(fn, delay = 100) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const debouncedSend = debounceBroadcast((data) => {
  channel.postMessage(data);
});

结语

BroadcastChannel API 为浏览器原生环境提供了一个简单而强大的跨页面通信解决方案。它不仅语法简洁、性能优秀,而且与现代Web开发范式完美契合。无论是构建实时协作应用、实现多标签页状态同步,还是简单的消息广播,BroadcastChannel 都能优雅地解决问题。

随着浏览器支持的不断完善,BroadcastChannel 必将成为Web开发中不可或缺的工具之一。希望本文能帮助您更好地理解和使用这个强大的API,在实际项目中发挥其最大价值。

Vue3+Element Plus 通用表格组件封装与使用实践

在中后台项目开发中,表格是高频使用的核心组件,基于 Element Plus 的el-table封装通用表格组件,能够统一表格样式、简化重复代码、提升开发效率。本文将详细讲解一款通用表格组件的封装思路、完整实现及使用方式,该组件兼顾了通用性与灵活性,适配日常开发中的各类表格场景。

一、封装思路

本次封装的核心目标是打造一款「基础能力通用化、个性化配置灵活化」的表格组件:

  1. 抽离表格通用配置(如高度、高亮行、合并单元格方法)作为基础 Props;
  2. el-tableel-pagination的原生属性 / 事件通过透传方式交给父组件控制,保留原生组件的灵活性;
  3. 统一列渲染逻辑,支持自定义render函数实现复杂单元格内容展示;
  4. 整合表格标题、分页等常用元素,形成完整的表格模块。

二、通用表格组件完整实现(MineTable.vue)

<template>
    <el-card class="mine-table">
        <!-- 表格标题 -->
        <el-text class="table-name">{{ tableName }}</el-text>
        <!-- 核心表格容器 -->
        <el-table 
            ref="elTable" 
            class="base-table" 
            :highlight-current-row="currentRow" 
            :preserve-expanded-content="true" 
            :span-method="spanMethod"
            :data="data" 
            :height="height"
            v-bind="tableProps"   <!-- 透传el-table原生属性 -->
            v-on="tableEvents"    <!-- 透传el-table原生事件 -->
        >
            <el-table-column 
                v-for="(item, index) in columnsData" 
                :key="index" 
                v-bind="item"      <!-- 透传列配置属性 -->
            >
                <!-- 展开列自定义渲染 -->
                <template v-if="item.type === 'expand'" #default="scope">
                    <component :is="item.render" v-bind="scope"></component>
                </template>
            </el-table-column>
        </el-table>

        <!-- 分页组件 -->
        <el-pagination 
            class="base-pagination" 
            layout="total, sizes, prev, pager, next, jumper"
            :page-sizes="[5, 10, 20, 30, 40, 50]" 
            background
            v-bind="paginationProps"  <!-- 透传el-pagination原生属性 -->
            v-on="paginationEvents"   <!-- 透传el-pagination原生事件 -->
        />
    </el-card>
</template>

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

// 关闭默认属性透传,避免属性泄露到外层DOM节点
defineOptions({
    inheritAttrs: false
})

// 定义组件Props
const props = defineProps({
    // 表格基础配置
    tableName: { type: String, default: "", description: "表格标题" },
    currentRow: { type: Boolean, default: false, description: "是否高亮当前行" },
    height: { type: String, default: "60vh", description: "表格高度" },
    data: { type: Array, default: () => [], description: "表格数据源" },
    columns: { type: Array, default: () => [], description: "列配置项" },
    spanMethod: { type: Function, default: () => {}, description: "单元格合并方法" },
    
    // el-table原生属性透传(支持所有el-table属性)
    tableProps: { type: Object, default: () => ({}) },
    // el-table原生事件透传(支持所有el-table事件)
    tableEvents: { type: Object, default: () => ({}) },
    
    // el-pagination原生属性透传(支持所有el-pagination属性)
    paginationProps: { type: Object, default: () => ({}) },
    // el-pagination原生事件透传(支持所有el-pagination事件)
    paginationEvents: { type: Object, default: () => ({}) },
})

// 暴露表格Ref,方便父组件调用el-table的原生方法
const elTable = ref(null)
defineExpose({ elTable })

// 列数据格式化处理,统一支持render函数渲染
const columnsData = computed(() => {
    return props.columns.map(item => ({
        formatter: (row, column, cellValue, index) => formatter(item, row, column, cellValue, index),
        ...item
    }))
})

// 单元格内容格式化逻辑
const formatter = (item, row, column, cellValue, index) => {
    // 优先级:行数据中的render函数 > 列配置中的render函数 > 默认值
    if (row?.[column.property]?.render) {
        return row[column.property].render(row, column, cellValue, index)
    } else if (item?.render) {
        return item.render(row, column, cellValue, index)
    }
    return row[column.property]
}
</script>

<style lang="scss" scoped>
.mine-table {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    
    .table-name {
        font-size: 18px;
        font-weight: bold;
        margin-bottom: 12px;
        display: flex;
        align-items: center;
        &::after {
            content: "";
            width: 5px;
            height: 100%;
            background-color: var(--el-color-primary);
            margin-right: 12px;
        }
    }

    .base-table {
        width: 100%;
        margin: 0 auto;
        min-width: 0;
        border: var(--el-table-border);
        border-radius: 4px;
    }

    .base-pagination {
        margin-top: 12px;
    }
}
</style>

核心封装点说明

  1. 属性 / 事件透传:通过tableProps/tableEventspaginationProps/paginationEvents分别透传el-tableel-pagination的原生属性与事件,既保留了原生组件的全部能力,又无需在组件内重复定义中转逻辑。
  2. 统一列渲染:封装了formatter函数,支持两种自定义渲染方式 —— 列配置中的render函数、行数据中的render函数,满足复杂单元格的展示需求。
  3. 基础样式整合:内置了表格标题、表格容器、分页的统一样式,无需在业务页面重复编写样式代码。
  4. Ref 暴露:将el-table的 Ref 暴露给父组件,方便调用clearSelectiontoggleRowSelection等原生方法。

三、组件使用示例

1. 基础使用(仅核心配置)

这是最常用的场景,只需配置表格数据、列配置、基础样式即可:

<template>
  <div class="demo-container">
    <!-- 通用表格组件使用 -->
    <MineTable
      height="200px"
      tableName="用户列表"
      :data="tableData"
      :columns="tableColumns"
    />
  </div>
</template>

<script setup>
import { ref } from "vue"
import MineTable from "@/components/MineTable.vue"
import { ElMessage, ElPopconfirm, ElButton, ElText } from "element-plus"

// 表格数据源
const tableData = ref([
  { id: 1, name: "张三", email: "zhangsan@example.com" },
  { id: 2, name: "李四", email: "lisi@example.com" },
  { id: 3, name: "王五", email: "wangwu@example.com" }
])

// 列配置项
const tableColumns = ref([
  { type: "index", label: "序号", width: 80 }, // 序号列
  {
    label: "用户名称",
    prop: "name",
    // 自定义单元格渲染
    render: (row) => <ElText type="primary">{row.name}</ElText>
  },
  {
    label: "操作",
    width: 100,
    // 操作列:带确认弹窗的删除按钮
    render: (row) => {
      const deleteUser = () => {
        // 模拟删除逻辑
        tableData.value = tableData.value.filter(item => item.id !== row.id)
        ElMessage.success(`已删除用户:${row.name}`)
      }

      return (
        <ElPopconfirm 
          title="确定删除吗?" 
          onConfirm={deleteUser}
          confirmButtonText="确定" 
          cancelButtonText="取消"
          v-slots={{
            reference: () => <ElButton type="danger" size="small" link>删除</ElButton>
          }}
        />
      )
    }
  }
])
</script>

<style scoped>
.demo-container {
  width: 800px;
  margin: 20px auto;
}
</style>

2. 进阶使用(透传原生属性 / 事件)

如果需要使用el-tableel-pagination的原生能力(如斑马纹、行点击事件、分页回调等),可通过透传 Props 实现:

<template>
  <div class="demo-container">
    <MineTable
      height="300px"
      tableName="用户列表"
      :data="tableData"
      :columns="tableColumns"
      <!-- 透传el-table原生属性 -->
      :table-props="{
        border: true,        // 显示表格边框
        stripe: true,        // 斑马纹效果
        showHeader: true     // 显示表头
      }"
      <!-- 透传el-table原生事件 -->
      :table-events="{
        'row-click': (row) => ElMessage.info(`点击了${row.name}的行`), // 行点击事件
        'sort-change': (val) => console.log('排序变更:', val)       // 排序变更事件
      }"
      <!-- 透传el-pagination原生属性 -->
      :pagination-props="{
        currentPage: 1,      // 当前页码
        pageSize: 10,        // 每页条数
        total: 100           // 总条数
      }"
      <!-- 透传el-pagination原生事件 -->
      :pagination-events="{
        'size-change': (size) => console.log('每页条数变更:', size), // 页大小变更
        'current-change': (page) => console.log('页码变更:', page)   // 页码变更
      }"
    />
  </div>
</template>

四、总结

本次封装的通用表格组件具备以下特点:

  1. 通用性强:整合了表格标题、分页等常用元素,统一了基础样式和渲染逻辑;
  2. 灵活性高:通过属性 / 事件透传,保留了 Element Plus 原生组件的全部能力,适配各类个性化需求;
  3. 易用性好:使用方式简洁,基础场景只需配置数据和列,进阶场景可透传原生属性 / 事件;
  4. 可扩展:在此基础上可进一步扩展空状态、加载状态、列宽自适应等通用能力,适配更多业务场景。

该组件能够有效减少中后台项目中表格相关的重复代码,提升开发效率,同时保持了足够的灵活性,满足不同业务场景的个性化需求。

❌