普通视图

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

流式渲染 Incremark、ant-design-x markdown、streammarkdown-vue 全流程方案对比

作者 king王一帅
2025年12月18日 22:37

流式 Markdown 渲染方案对比

本文档对流式 Markdown 渲染方案进行技术对比:Incremarkant-design-xmarkstream-vue。每个方案都有其独特的设计理念和优势。

全流程对比

ant-design-x 全流程

用户输入(流式 Markdown)
        ↓
┌─────────────────────────────────────────────────────────┐
│  useTyping Hook                                         │
│    - 逐字符消费纯文本                                     │
│    - 输出带 fade-in 标记的文本块                          │
├─────────────────────────────────────────────────────────┤
│  useStreaming Hook                                       │
│    - 正则检测不完整 token(链接、图片等)                   │
│    - 缓存不完整部分,只输出完整的 Markdown                  │
│    ↓                                                    │
│  Parser (marked.js)                                      │
│    - 全量解析:contentHTML 字符串                      │
│    ↓                                                    │
│  Renderer (html-react-parser)                            │
│    - HTML 字符串 → React 组件                            │
└─────────────────────────────────────────────────────────┘
        ↓
    React DOM

关键特点:

  • 每次内容变化时使用 marked.parse() 全量解析
  • 打字机动画在纯文本字符串层操作
  • 使用 HTML 字符串作为中间格式

markstream-vue 全流程

用户输入(Markdown 字符串)
        ↓
┌─────────────────────────────────────────────────────────┐
│  预处理                                                  │
│    - 正则修复流式边界问题                                  │
│    - "- *" → "- \*",裁剪悬空标记等                       │
│    ↓                                                    │
│  markdown-it.parse()                                     │
│    - 全量解析 → Token 数组                                │
│    ↓                                                    │
│  processTokens()                                         │
│    - Token → ParsedNode[](自定义 AST)                   │
├─────────────────────────────────────────────────────────┤
│  Vue 组件渲染                                            │
│    - <transition> 实现渐入动画                            │
│    - 节点类型 → 组件映射                                  │
└─────────────────────────────────────────────────────────┘
        ↓
    Vue DOM

关键特点:

  • 每次内容变化时使用 markdown-it.parse() 全量解析
  • 预处理层处理流式边界情况
  • 使用 Vue <transition> 实现打字机效果

Incremark 全流程

用户输入(流式 Markdown chunk)
        ↓
┌─────────────────────────────────────────────────────────┐
│  IncremarkParser.append(chunk)                           │
│    - 增量更新缓冲区(只处理新增部分)                        │
│    - 检测稳定边界(空行、标题等)                           │
│    - 稳定部分 → completedBlocks(只解析一次)              │
│    - 不稳定部分 → pendingBlocks(重新解析)                │
│    ↓                                                    │
│  输出:ParsedBlock[](mdast AST)                        │
├─────────────────────────────────────────────────────────┤
│  BlockTransformer(可选中间件)                           │
│    - 打字机效果:sliceAst() 截断 AST                      │
│    - 维护 TextChunk[] 实现渐入动画                        │
│    - 可跳过,直接渲染完整内容                              │
│    ↓                                                    │
│  输出:DisplayBlock[](截断后的 AST)                     │
├─────────────────────────────────────────────────────────┤
│  Vue / React 组件渲染                                    │
│    - AST 节点 → 组件映射                                 │
│    - TextChunks 包装渐入动画                             │
└─────────────────────────────────────────────────────────┘
        ↓
    Vue / React DOM

关键特点:

  • 增量解析:只解析新增的稳定块
  • 打字机动画在 AST 节点层操作
  • 同时提供 Vue 和 React 适配器

核心差异

维度 ant-design-x markstream-vue Incremark
解析方式 全量解析 (marked.js) 全量解析 (markdown-it) 增量解析 (micromark)
单次 chunk 复杂度 O(n) O(n) O(k),k = 新块大小
总复杂度 O(n × chunks) ≈ O(n²) O(n × chunks) ≈ O(n²) O(n)
边界处理 正则 token 检测 预处理层 稳定边界检测
打字机动画 文本字符串层 Vue Transition 保持 Markdown 结构
输出格式 HTML 字符串 自定义 AST mdast(兼容 remark)
框架支持 React Vue Vue + React

解析策略

全量解析 vs 增量解析

全量解析(ant-design-x 和 markstream-vue):

Chunk 1: "# Hello"         解析全部内容
Chunk 2: "# Hello\nWorld"  再次解析全部内容
Chunk 3: "# Hello\nWorld\n\n- item"  再次解析全部内容

每个新 chunk 都触发对所有累积内容的完整解析。

增量解析(Incremark):

Chunk 1: "# Hello"      解析  Block 1 (heading)  完成
Chunk 2: "\n\nWorld"    解析  Block 2 (paragraph)  只处理这部分
Chunk 3: "\n\n- item"   解析  Block 3 (list)  只处理这部分

已完成的块被缓存,不会重新解析。只处理待定部分。

复杂度分析

在多 chunk 流式场景中:

  • 全量解析:O(n) × chunk 数量
  • 增量解析:每个 chunk O(k),k 是新块大小

对于典型的 AI 响应(10-50 块),两种方案都能接受。差异在大文档或高频 chunk 到达时更明显。


流式边界处理

所有方案都需要处理流式过程中不完整的 Markdown 语法,各自采用不同方案:

方案 工作原理 权衡
Incremark 解析前检测稳定边界 结构清晰;可能缓冲部分内容
ant-design-x 正则模式检测不完整 token 立即输出;需要维护正则
markstream-vue 解析前预处理内容 适用于任何解析器;需处理多种边界情况

打字机动画

方案 层级 机制
Incremark AST 节点 sliceAst() 截断 AST,TextChunk[] 追踪渐入
ant-design-x 文本字符串 逐字符文本切片
markstream-vue 组件 组件挂载时 Vue <transition>

各方案的权衡:

  • AST 层:动画过程中保持结构感知
  • 文本层:更简单,与框架无关
  • 组件层:与 Vue 响应式系统自然集成

渲染优化

markstream-vue 提供额外的渲染优化:

特性 描述
虚拟化 只在 DOM 中渲染可见节点
批量渲染 使用 requestIdleCallback 渐进式渲染
视口优先 延迟渲染屏幕外节点

流式场景的考量:

在典型 AI 流式用例中:

  • 内容逐步到达(天然的批量处理)
  • 用户注意力在底部(观看新内容)
  • 典型响应大小是 10-50 块
  • 打字机效果提供渐进式渲染

虚拟化在以下场景提供显著收益:

  • 浏览历史内容(非活跃流式)
  • 渲染超长文档(100+ 块)
  • 用户快速滚动浏览内容

总结

各方案的侧重点

ant-design-x

侧重:完整的 AI 聊天 UI 解决方案

  • 提供 Bubble、Sender、Conversations 组件
  • 与 Ant Design 生态深度集成
  • 内置 <thinking> 等特殊块支持
  • Ant Design 用户可快速上手

适用场景:在 Ant Design 生态中构建 AI 聊天界面的团队

markstream-vue

侧重:功能丰富的 Markdown 渲染

  • 虚拟化处理大文档
  • 自适应性能的批量渲染
  • 全面的边界情况预处理
  • 丰富的自定义选项

适用场景:有大文档或聊天历史浏览需求的 Vue 应用

Incremark

侧重:高效的增量解析

  • 增量解析:已完成的块不会重新解析,在长流式会话中显著减少 CPU 消耗
  • 跨框架:同一套核心库支持 Vue 和 React,降低多框架团队的维护成本
  • 兼容 remark 生态:标准 mdast 输出,可使用 remark 插件扩展语法
  • 结构化打字机:动画过程中保持 Markdown 结构,插件系统支持自定义行为(如图片立即显示)

适用场景:长流式内容、多框架团队、或需要 remark 插件兼容的应用


快速参考

你的优先级 可考虑
Ant Design 生态集成 ant-design-x
大文档虚拟化 markstream-vue
纯 Vue 应用 markstream-vue
长流式会话 / 多 chunk Incremark
Vue + React 共用代码 Incremark
需要 remark 插件 Incremark

结论

每个方案以不同的优先级解决流式 Markdown:

  • ant-design-x 提供与 Ant Design 紧密集成的完整 AI 聊天 UI 解决方案
  • markstream-vue 为 Vue 应用提供丰富功能和渲染优化
  • Incremark 专注于解析效率和跨框架灵活性

选择取决于你的具体需求:生态契合度、文档规模、框架需求和性能优先级。

昨天 — 2025年12月18日掘金 前端

后端拒写接口?前端硬核自救:纯前端实现静态资源下载全链路解析

2025年12月18日 22:05

背景

在日常开发中,我们经常遇到这样的场景:业务需求需要提供“导入模板下载”或“操作手册下载”功能。找后端同学要接口,对方却丢下一句:“这不就是个静态文件吗?你们前端自己存一下不就行了,没必要走接口。”

虽然听起来像是在推诿,但从资源利用和架构角度来看,对于纯静态、非敏感、无需鉴权的固定文件,前端自行托管确实是更高效的方案。它减轻了应用服务器的压力,利用了 CDN 或 Nginx 的静态资源分发能力。

本文将从工程实践底层原理,深入剖析如何在 Vue3 + Vite(或 Webpack)项目中优雅地实现这一功能。

一、 核心方案:目录存放策略

实现下载的第一步是决定文件存哪里。在现代前端工程(Vite/Webpack)中,通常有两个存放静态资源的地方:src/assetspublic(或 Vue CLI 时代的 static)。

1.1 src/assets vs public

特性 src/assets public
构建处理 经过 Bundler(Vite/Webpack)编译、压缩、Hash 重命名。 不经过编译,直接原样拷贝到输出目录。
引用方式 import 导入,得到的是打包后的 URL。 使用绝对路径字符串直接引用。
适用场景 组件内部引用的图片、样式、字体。 第三方库、favicon、以及我们要做的“下载文件”。

1.2 最佳实践

对于“下载文件”这种需求,强烈推荐使用 public 目录

理由如下:

  1. 文件名保持不变:用户下载的文件名就是你存放的文件名,不会变成 template.23a8f9.csv 这种带 Hash 的怪名字。
  2. 无需 Import:不需要在 JavaScript 中通过 import 引入文件对象,直接通过 URL 访问,逻辑更解耦。

目录结构示例:

my-project/
├── public/
│   ├── files/
│   │   ├── import_template.csv  <-- 存放在这里
│   │   └── manual.pdf
│   └── favicon.ico
├── src/
│   └── ...

二、 代码实现:动态路径与兼容性

决定了存放位置后,接下来是代码实现。看似简单,但有一个巨大的坑需要注意:部署路径(Public Path)

2.1 基础实现(有坑版)

如果你直接写死路径:

<a href="/files/import_template.csv" download="模板.csv">下载模板</a>

在本地开发(localhost:3000)没问题。但如果你的应用部署在子目录(例如 https://example.com/admin/),这个链接会指向 https://example.com/files/...,导致 404 Not Found

2.2 进阶实现(生产环境健壮版)

我们需要根据构建时的 基础路径(Base Path) 动态拼接 URL。

Vite + Vue3 实现:

<script setup lang="ts">
// 1. 获取环境变量中的 Base Path
// Vite 中通常配置在 vite.config.ts 的 base 属性,对应 import.meta.env.BASE_URL 或 VITE_PUBLIC_PATH
const publicPath = import.meta.env.VITE_PUBLIC_PATH || '/';

// 2. 拼接完整的下载链接
const templateUrl = `${publicPath}files/import_template.csv`;
</script>

<template>
  <!-- download 属性指示浏览器下载,而非导航 -->
  <a :href="templateUrl" download="导入模板.csv">
    下载模板
  </a>
</template>

这种写法无论项目部署在根路径还是 /sub-folder/ 下,都能正确找到文件。


三、 深度解析:Build 打包原理

为什么放在 public 目录下的文件,打包后就能通过 URL 访问?这涉及到构建工具的静态资源处理机制

3.1 Vite/Rollup 的处理流程

当你运行 npm run build 时,Vite(底层基于 Rollup)会执行以下操作:

  1. 编译源码:处理 src 目录下的 .vue, .ts, .js 等文件,生成 Bundles。
  2. 静态拷贝:Vite 默认会检查项目根目录下的 public 文件夹。
    • 它会将 public 文件夹内的所有内容原封不动地复制到构建输出目录(通常是 dist)的根目录下。
    • 这个过程不会对文件进行 Hash 处理,也不会修改文件名。

构建前:

/public/files/demo.csv

构建后(dist 目录):

/dist/index.html
/dist/assets/index.f8s7d9.js
/dist/files/demo.csv  <-- 原样存在

因此,Nginx 或静态服务器在托管 dist 目录时,客户端请求 /files/demo.csv,服务器就能直接找到该文件并返回。


四、 深度解析:浏览器下载原理

前端写了 <a download>,浏览器底层发生了什么?

4.1 触发下载的行为判定

当用户点击链接时,浏览器会根据以下优先级决定是预览还是下载

  1. download 属性(HTML5)

    • 如果在 <a> 标签上存在 download 属性,浏览器会尝试强制下载该资源,并使用属性值作为下载后的文件名。
    • 关键限制download 属性仅对同源 URL(Same-origin)或 blob:data: 协议有效。如果你的静态文件放在完全不同的 CDN 域名下,download 属性可能会失效,浏览器会退化为导航(预览)。
  2. Content-Disposition 响应头(HTTP 协议)

    • 这是服务端的“大杀器”。如果服务器响应头包含 Content-Disposition: attachment; filename="xxx.csv",无论前端怎么写,浏览器必须下载。
    • 对于前端托管的静态文件(Nginx 默认配置),通常没有这个头,所以主要依赖前端的 download 属性。
  3. MIME Type 嗅探

    • 如果没有上述强制下载标志,浏览器会检查文件的 MIME 类型。
    • 浏览器能识别的(如 application/pdf, image/jpeg, text/html):在当前窗口或新标签页预览
    • 浏览器不认识的(如 application/octet-stream, application/zip):默认下载

4.2 本文方案的生效链路

  1. 请求阶段:用户点击链接 -> 浏览器向服务器(Nginx)请求 /files/template.csv
  2. 响应阶段:Nginx 返回文件流,Content-Type 可能是 text/csvapplication/vnd.ms-excel
  3. 处理阶段:浏览器接收到响应,虽然它可能支持预览文本,但检测到了 <a> 标签上的 download 属性。
  4. 最终行为:浏览器忽略预览行为,弹出保存对话框(或直接保存),并将文件名重命名为 download 属性指定的值。

五、 小结

在后端不提供接口的情况下,前端利用 public 目录托管静态文件是一种标准且高效的工程化解法。

  • 实现简单:无需后端参与,纯前端闭环。
  • 性能优异:利用 Nginx/CDN 静态分发,速度快,不占用 API 计算资源。
  • 注意细节
    • 文件放入 public 目录以避免 Hash 重命名。
    • 代码中使用环境变量(import.meta.env.VITE_PUBLIC_PATH)拼接路径以支持子目录部署。
    • 利用 download 属性强制浏览器下载 PDF 等可预览文件。

掌握这一套流程,下次再遇到后端让你 “自己存一下” 时,你不仅能轻松搞定,还能顺便给他科普一下打包原理和浏览器行为。

Coco AI 技术演进:Shadcn UI + Tailwind CSS v4.0 深度迁移指南 (踩坑实录)

2025年12月18日 21:07

摘要:本文深度复盘了 Coco AI 项目在引入 shadcn/ui 组件库的同时,激进升级至 Tailwind CSS 4.0 的技术细节。重点剖析了在 Vite + Tsup (Esbuild) 双构建工具链下的兼容性方案,以及如何处理 tailwind.config.js 与 CSS-first 配置模式的冲突,为维护大型遗留项目的开发者提供一份“硬核”避坑指南。

前言:为什么要自找麻烦?

在 Coco AI 的开发过程中,我们面临着大多数成长期项目都会遇到的痛点:

  1. UI 碎片化:早期的手写 CSS 与后期的 Tailwind Utility Class 混杂,维护成本极高。
  2. 重复造轮子:为了一个带键盘导航的 Dropdown,我们可能写了 500 行代码,且 Bug 频出。

引入 shadcn/ui 是为了解决组件复用问题,而升级 Tailwind CSS v4.0 则是为了追求极致的构建性能(Rust 引擎)。当这两者在这个拥有大量遗留代码的项目中相遇时,一场“构建工程化的风暴”不可避免。

本文不谈虚的,直接上干货。

难点一:Vite 与 Tsup 的“双轨制”构建困局

Coco AI 不仅是一个 Web 应用,还包含一个对外提供的 SDK。这就导致我们有两套构建流程:

  • Web App: 使用 Vite (Rollup)。
  • Web SDK: 使用 Tsup (Esbuild)。

Tailwind v4 推荐使用 @tailwindcss/vite 插件,这在 Web App 中运行良好。但在 SDK 构建中,Esbuild 并不支持该插件。

解决方案:混合编译策略

我们被迫采用了一套“混合”方案:Web 端享受 v4 的插件红利,SDK 端则回退到 PostCSS 处理。

1. Web 端 (Vite)

一切从简,使用官方插件。

// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  // 这里的 tailwindcss() 会自动扫描文件,性能极快
  plugins: [tailwindcss() as any, react()],
});

2. SDK 端 (Tsup/PostCSS)

这是最坑的地方。Tsup 基于 Esbuild,而 Esbuild 默认无法解析 v4 的 @import "tailwindcss";。我们需要手动配置 PostCSS 管道。

首先,配置 postcss.config.js,显式使用 v4 的 PostCSS 插件:

// postcss.config.js
export default {
  plugins: {
    // ⚠️ 注意:Tailwind v4 的 PostCSS 插件包名变了
    '@tailwindcss/postcss': {}, 
    autoprefixer: {},
  },
}

然后,在 tsup.config.ts 中施展“魔法”:

// tsup.config.ts
export default defineConfig({
  esbuildOptions(options) {
    // 🔥 关键黑魔法:启用 'style' 条件,让 esbuild 能找到 tailwindcss 的入口
    (options as any).conditions = ["style", "browser", "module", "default"];
  },
  async onSuccess() {
    // 构建后手动运行 PostCSS,处理 CSS 文件中的 @import "tailwindcss"
    // ...代码略,见源码...
  }
});

难点二:JS 配置与 CSS 配置的“博弈”

Tailwind v4 推崇 CSS-first,即把配置都写在 CSS 的 @theme 块中。但 shadcn/ui 强依赖 tailwindcss-animate 插件,且我们有大量复杂的自定义动画(如打字机效果、震动效果)写在 tailwind.config.js 中。

如果完全迁移到 CSS,工作量巨大且易出错。

解决方案:JS 与 CSS 共存

我们保留了 tailwind.config.js,主要用于存放插件复杂动画,而将颜色变量迁移到 CSS 中。

保留的 tailwind.config.js (部分)

import animate from "tailwindcss-animate";

export default {
  // v4 会自动检测并合并这个配置
  theme: {
    extend: {
      // 复杂的 Keyframes 还是写在这里比较清晰
      animation: {
        typing: "typing 1.5s ease-in-out infinite",
        shake: "shake 0.5s ease-in-out",
      },
      keyframes: {
        typing: {
          "0%": { opacity: "0.3" },
          "50%": { opacity: "1" },
          "100%": { opacity: "0.3" },
        },
        // ...
      },
      // 映射 border-radius 到 CSS 变量,适配 shadcn
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
    },
  },
  plugins: [animate], // shadcn 必需的插件
};

新的 src/main.css (v4 风格)

@import "tailwindcss";

/* ⚠️ 必坑点:显式指定扫描源,否则可能漏掉 HTML 或特定目录 */
@source "../index.html";
@source "./**/*.{ts,tsx}";

@theme {
  /* 在 CSS 中通过变量映射颜色,不仅支持 shadcn,还能兼容旧代码 */
  --color-background: var(--background);
  --color-primary: var(--primary);
  /* ... */
}

难点三:颜色空间与暗色模式的“大一统”

Coco AI 的旧代码使用 RGB 值(如 rgb(149, 5, 153)),而 shadcn 使用 HSL(如 222.2 84% 4.9%),Tailwind v4 默认又倾向 OKLCH。

解决方案:变量映射层

我们在 main.css 中建立了一个“中间层”,让新老变量和谐共存。

:root {
  /* === Shadcn 系统 (HSL) === */
  --primary: 221.2 83.2% 53.3%;
  
  /* === Coco Legacy 系统 (RGB) === */
  /* 即使是旧变量,也可以根据需要调整,或者直接硬编码保留 */
  --coco-primary-color: rgb(149, 5, 153);
}

/* ⚠️ v4 暗色模式新语法:废弃了 darkMode: 'class' */
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

.dark.coco-container,
[data-theme="dark"] {
  /* 重新定义 HSL 值实现暗色模式 */
  --background: 222.2 84% 4.9%;
  
  /* 同时覆盖旧系统的变量 */
  --coco-primary-color: rgb(149, 5, 153);
}

难点四:Web SDK 的 CSS 变量兼容性黑科技

在开发 Web SDK 时,我们遇到一个隐蔽的问题:CSS 变量的初始值丢失

Tailwind v4 会生成大量的 CSS Houdini @property 规则来定义变量的类型和初始值:

@property --tw-translate-x {
  syntax: "*";
  inherits: false;
  initial-value: 0;
}

这在现代浏览器中运行完美。但由于我们的 SDK 会被嵌入到各种宿主环境中,部分环境可能不支持 @property,导致变量因为没有显式的赋值而失效(initial-value 被忽略)。

解决方案:构建后脚本补全 (Post-build Script)

为了保证“即插即用”的稳定性,我们编写了一个专门的构建后处理脚本 scripts/buildWebAfter.ts

它的作用是:扫描生成的 CSS,提取所有 @propertyinitial-value,并将它们显式注入到 .coco-container 作用域中。

// scripts/buildWebAfter.ts (精简版)
const extractCssVars = () => {
  const cssContent = readFileSync(filePath, "utf-8");
  const vars: Record<string, string> = {};
  
  // 正则提取所有 @property 的 initial-value
  const propertyBlockRegex = /@property\s+(--[\w-]+)\s*\{([\s\S]*?)\}/g;
  while ((match = propertyBlockRegex.exec(cssContent))) {
    const initialValueMatch = /initial-value\s*:\s*([^;]+);/.exec(match[2]);
    if (initialValueMatch) {
      vars[match[1]] = initialValueMatch[1].trim();
    }
  }

  // 生成标准的 CSS 变量赋值块
  const cssVarsBlock =
    `.coco-container {\n` +
    Object.entries(vars)
      .map(([k, v]) => `  ${k}: ${v};`) // 显式赋值:--var: value;
      .join("\n") +
    `\n}\n`;

  writeFileSync(filePath, `${cssContent}\n${cssVarsBlock}`, "utf-8");
};

效果:即使浏览器不支持 @property,变量也能通过标准的 CSS 级联机制获得正确的初始值,确保 SDK 在任何环境下样式都不崩坏。

避坑清单 (Checklist)

在迁移过程中,我们踩了无数坑,以下是血泪总结:

  1. 样式莫名丢失?

    • 原因:Tailwind v4 的自动扫描可能没覆盖到你的文件结构。
    • 解法:使用 @source 指令显式添加路径,如 @source "./**/*.{ts,tsx}";
  2. VS Code 满屏报错?

    • 原因:VS Code 的 Tailwind 插件版本过低,不认识 @theme@source 等新指令。
    • 解法:升级插件到最新版,并确保设置中关联了正确的文件类型。
  3. 构建时报错 Cannot find module

    • 原因postcss.config.js 中引用了不存在的插件。
    • 解法:确认安装了 @tailwindcss/postcss 并在配置中正确引用(注意包名变化)。
  4. 动画不生效?

    • 原因tailwind.config.js 未被 Vite 插件读取。
    • 解法:在使用 @tailwindcss/vite 时,它通常会自动检测根目录下的配置文件。如果位置特殊,需手动指定。

小结

技术债是还不完的,但每一次还债都是一次成长的机会。

通过这次适配,Coco AI 不仅拥有了更现代化的 UI 架构,也为未来的跨平台(Web/Desktop/Mobile)统一体验打下了基础。特别是 Tailwind CSS v4.0 的引入,虽然初期配置略显折腾,但其带来的构建速度提升和开发体验优化,绝对是“真香”定律的又一次验证。

如果你也想体验一下这个“整容”后的全能生产力工具,欢迎来我们的 GitHub 看看:

Tauri (21)——窗口缩放后的”失焦惊魂”,游戏控制权丢失了

2025年12月18日 20:01

背景

在上一篇文章中,我们分享了如何在 Coco AI 中实现丝滑的 NSPanel 窗口全屏体验。然而,全屏只是第一步,真正的挑战往往隐藏在细节之中。

Coco AI 的核心亮点之一是其日渐强大的插件生态Extensions)。用户可以通过安装插件,在 Coco 的悬浮窗中直接运行各种 Web 应用,甚至玩 Doom 这样的小游戏。

但我们在开发过程中遇到了一个非常影响心情的 Bug:

当用户正沉浸在游戏中,觉得窗口太小而点击“全屏”后,突然发现键盘失灵了——WASD 怎么按都没反应,手动点画面也不能继续操作...

对于一款追求极致体验的生产力工具来说,这种“断触”是不可接受的。今天我们就来深度复盘这个**焦点丢失(Focus Loss)**问题,以及我们在 Coco App 中的“组合拳”解决方案。

场景复现

  1. 用户在 Coco AI 中启动了一个游戏插件(通过 iframe 加载)。
  2. 初始窗口较小,用户通过 WASD 控制角色移动,一切正常。
  3. 用户点击右上角的“全屏”按钮,希望获得沉浸体验。
  4. 窗口瞬间变大铺满屏幕,但此时按下 W 键,角色纹丝不动。
  5. 用户拿起鼠标不断的点击一下游戏画面,控制权也未能恢复。

为什么会失焦?

这个问题的根源在于 DOM 树的重建和窗口系统的焦点管理机制,特别是在 React + Tauri 的混合架构下:

  1. DOM 重绘/重排:当窗口从悬浮模式切换到全屏模式时,React 组件可能会因为状态变化(如 scale 缩放系数改变、layout 模式切换)而重新渲染。如果 iframe 在这个过程中被卸载并重新挂载,它就是一个全新的 iframe,之前的焦点自然荡然无存。
  2. Native 窗口焦点转移:调用 Tauri 的 setWindowSizesetFullscreen 等底层 API 时,操作系统可能会暂时把焦点从 WebView 内容区域移开,转移到窗口边框或系统层级。
  3. Iframe 的隔离性iframe 内部是一个独立的 window 上下文。主文档(Parent)获得焦点并不意味着 iframe 获得焦点。你需要显式地把焦点“传递”进去。

Coco AI 的解决方案:全方位焦点守护

为了确保用户体验的连贯性,我们在 ViewExtension.tsx 组件中实施了一套多层级的焦点管理策略。

第一招:组件挂载后的自动聚焦

在 React 中,利用 refonLoad 事件,确保插件加载完毕的那一刻,焦点就自动锁定在它身上。

<div
  // 绑定 Ref
  ref={iframeRef}
  // 任何点击外层容器的操作,都把焦点送给 iframe
  onClickCapture={() => {
    iframeRef.current?.focus();
  }}
>
  <iframe
    ref={iframeRef}
    src={fileUrl}
    // Iframe 加载完毕瞬间聚焦
    onLoad={(event) => {
      event.currentTarget.focus();
      try {
        // 尝试深入聚焦到 iframe 内部的 window
        iframeRef.current?.contentWindow?.focus();
      } catch (e) {
        console.warn("Failed to focus iframe content:", e);
      }
    }}
    // 允许必要的权限:全屏、鼠标锁定(FPS游戏必备)、手柄
    allow="fullscreen; pointer-lock; gamepad"
    tabIndex={-1}
  />
</div>

第二招:状态变化后的延迟聚焦

当你执行全屏或缩放操作后,Native 层的窗口调整是异步的,React 的渲染也是异步的。如果你立即调用 focus(),可能 DOM 还没稳,或者窗口还在动画中,导致聚焦失败。

我们的秘诀是 setTimeout,等待一轮事件循环:

const applyFullscreen = useCallback(async (next: boolean) => {
  // ... 执行窗口大小调整逻辑 ...
  
  // 等待系统窗口调整完成且 React 渲染完毕
  setTimeout(() => {
    // 1. 聚焦 iframe 元素本身
    iframeRef.current?.focus();
    try {
      // 2. 尝试聚焦 iframe 内部内容(处理跨域限制时可能报错,加 try-catch)
      iframeRef.current?.contentWindow?.focus();
    } catch {}
  }, 0);
}, []);

第三招:显式的“焦点救生圈”

为了应对浏览器安全策略限制脚本自动聚焦等极端情况,我们在 UI 上设计了一个显式的 Focus 按钮。这不仅是一个功能补救,也是一个视觉提示。

{/* Focus helper button */}
<button
  aria-label="Focus Game"
  className="absolute top-2 right-12 z-10 p-2 bg-black/50 hover:bg-black/70 rounded text-white transition-colors"
  onClick={() => {
    iframeRef.current?.focus();
    try {
      iframeRef.current?.contentWindow?.focus();
    } catch {}
  }}
>
  <FocusIcon className="size-4"/>
</button>

当用户发现控制失灵时,潜意识会寻找界面上的交互点,点击这个按钮就能瞬间找回焦点。

第四招:事件捕获(Capture Phase)

有时候用户点击了窗口边缘的空白区域(padding 或 margin),焦点会跑回主文档 body。我们可以通过在容器上监听 onMouseDownCapture 来拦截这些点击,强行把焦点按回 iframe

<div
  className="w-full h-full flex flex-col items-center justify-center"
  // 捕获阶段拦截,比冒泡更早
  onMouseDownCapture={() => {
    iframeRef.current?.focus();
  }}
  onPointerDown={() => {
    iframeRef.current?.focus();
  }}
>
  <iframe ... />
</div>

小结

焦点管理看似简单,但在构建像 Coco AI 这样复杂的桌面+Web 混合应用时,它直接关系到用户的沉浸感。通过这套 “主动出击 + 纵深防御 + 异步等待 + 兜底方案” 的组合拳,我们成功解决了跨平台、跨窗口尺寸下的焦点丢失问题。

现在,无论是在写代码时快速查阅文档,还是在休息时玩一把小游戏,Coco AI 都能提供无缝、流畅的交互体验。

如果你对我们的技术细节感兴趣,或者想体验一下这款全能的生产力工具,欢迎访问我们的开源仓库和官网:

Tauri (20)——为什么 NSPanel 窗口不能用官方 API 全屏?

2025年12月18日 19:45

在基于 Electron 或 Tauri 开发 macOS 桌面应用时,我们经常会遇到一种特殊的窗口类型:NSPanel。它通常用于 spotlight 搜索栏、悬浮工具条等场景。然而,当我们想给这种“小窗口”加上全屏能力(比如玩游戏、看大图)时,往往会撞上一堵墙:官方的全屏 API 对 NSPanel 并不友好,甚至直接失效。

项目背景:Coco AI

我们在构建 Coco AI —— 这款集成了统一搜索、协作与 AI 助手的跨平台桌面生产力工具时,遇到了一个有趣的技术挑战。

Coco AI 强大的插件系统Extensions)允许用户在应用内直接运行各种工具(如小游戏、可视化图表、Web 应用等)。为了保持轻量和随手即用的体验,Coco 默认使用类似于 Spotlight 的悬浮窗(NSPanel)展示。但当用户想要沉浸式地使用插件(比如玩个小游戏)时,默认的小窗口就显得局促了。

我们希望实现的效果是:平时召之即来挥之即去,需要时一键变身全屏工作台。

然而,官方的窗口 API 在 NSPanel 上却频频“翻车”。今天就来复盘一下我们是如何在 Coco App 中解决这个问题的。

应用场景:一个嵌入式小游戏窗口

假设我们在开发一个类似于 Spotlight 的启动器,平时它是一个悬浮在屏幕中央的小框。但我们允许用户通过插件系统加载一个网页(比如 HTML5 游戏)。

image.png

需求很直接:

  1. 默认窗口大小固定(如 1200x900)。
  2. 用户点击“全屏”按钮,窗口瞬间铺满当前屏幕。
  3. 再次点击,恢复原状。

技术栈:

  • Tauri (Rust + WebView)
  • Frontend: React + TypeScript
  • Window Type: NSPanel (macOS)

遇到的坑:NSPanel 与 setFullscreen 的爱恨情仇

在普通的 NSWindow 中,调用 Tauri 的 window.setFullscreen(true) 或 Electron 的 setFullScreen(true),系统会自动创建一个新的 Space,把窗口平铺进去,非常优雅。

但 Coco AI 为了追求“极致的快速启动与无感交互”,使用了 NSPanel 并设置了较高的窗口层级。当我们试图对它调用标准全屏 API 时:

  1. 系统动画冲突:由于没有标准标题栏,系统全屏动画可能会导致窗口消失、闪烁甚至错位。
  2. 多屏支持噩梦:用户在副屏唤起 Coco AI,点击全屏,结果窗口直接飞回了主屏。
  3. 状态不可逆:退出全屏后,窗口焦点和层级可能回不到原来的状态,打断了用户的心流。

简单来说,官方 API 是给 “标准应用窗口” 设计的,并不适配我们这种 “灵动挂件”

解决方案:手动接管窗口布局

既然系统 API 不懂我们的心,我们就自己动手,“伪造”一个全屏效果。通过 “手动计算 + 逻辑坐标转换” 来优雅解决这个问题。

核心原理

  1. 精准定位:获取当前鼠标所在的显示器(Monitor),确保“在哪里唤起,就在哪里全屏”。
  2. 坐标系转换:macOS 使用逻辑像素(Logical Pixel),而底层屏幕信息往往是物理像素(Physical Pixel),必须通过 scaleFactor 进行转换,否则窗口会巨大无比或只有四分之一大。
  3. 暴力美学:直接修改窗口的 x, y, width, height,使其完美覆盖目标屏幕的 Bounds。
  4. 状态快照:在变身前,记住原来的位置和大小,以便随时缩回那个熟悉的“小框框”。

Coco AI 的实现代码

以下是我们在 ViewExtension.tsx 中的核心实现逻辑。

const applyFullscreen = useCallback(
  async (next: boolean) => {
    if (next) {
      // 1. 状态快照:保存当前位置、大小、是否可调整
      const size = await platformAdapter.getWindowSize();
      const resizable = await platformAdapter.isWindowResizable();
      const pos = await platformAdapter.getWindowPosition();
      
      fullscreenPrevRef.current = {
        width: size.width,
        height: size.height,
        resizable,
        x: pos.x,
        y: pos.y,
      };

      // 2. 针对 macOS + Tauri (NSPanel) 的特殊处理
      if (isMac && isTauri) {
        // 关键步:获取鼠标所在的屏幕,实现“原位全屏”
        const monitor = await platformAdapter.getMonitorFromCursor();

        if (!monitor) return;
        const window = await platformAdapter.getCurrentWebviewWindow();
        const factor = await window.scaleFactor();

        // 3. 坐标转换:物理像素 -> 逻辑像素
        const { size, position } = monitor;
        const { width, height } = size.toLogical(factor);
        const { x, y } = position.toLogical(factor);

        // 4. 手动铺满屏幕
        await platformAdapter.setWindowSize(width, height);
        await platformAdapter.setWindowPosition(x, y);
        await platformAdapter.setWindowResizable(true); // 全屏模式下通常允许调整
        await recomputeScale(); // 调整内部 Web 内容的缩放比例
      } else {
        // 其他平台使用标准 API 即可
        await platformAdapter.setWindowFullscreen(true);
        await recomputeScale();
      }
    } else {
      // 5. 退出全屏:恢复如初
      if (!isMac) {
        await platformAdapter.setWindowFullscreen(false);
      }
      // 从配置或默认值恢复大小
      const nextWidth = ui?.width ?? DEFAULT_VIEW_WIDTH;
      const nextHeight = ui?.height ?? DEFAULT_VIEW_HEIGHT;
      
      await platformAdapter.setWindowSize(nextWidth, nextHeight);
      await platformAdapter.setWindowResizable(ui?.resizable ?? true);
      
      // 关键步:居中回原来的屏幕
      await platformAdapter.centerOnCurrentMonitor();
      await recomputeScale();
      
      // 6. 焦点修复(防止操作中断)
      setTimeout(() => {
        iframeRef.current?.focus();
      }, 0);
    }
  },
  [ui, recomputeScale]
);

为什么这样做体验更好?

  • 瞬时响应:没有了系统全屏动画的拖泥带水,点击即全屏。
  • 多屏友好:完美支持多显示器环境,不再发生“窗口瞬移”的灵异事件。
  • UI 自由度:保留了我们自定义的 UI 控件,不受系统标题栏的干扰。

小结

在开发 Coco AI 的过程中,我们始终坚持 “不因为技术限制而妥协用户体验” 。虽然手动管理窗口状态比调用一个 API 麻烦得多,但为了让用户在使用插件时能有丝滑的体验,这一切都是值得的。

如果你对我们的技术栈(Rust + Tauri + React)感兴趣,或者想体验一下这个“既能小巧悬浮,又能全屏沉浸”的生产力工具,欢迎访问我们的 GitHub 仓库和官网:

Monorepo 在 Docker 中的构建方案

作者 zmirror
2025年12月18日 18:14

1. 天然的冲突

Docker 镜像构建的“线性逻辑”与 pnpm Workspace 的“网状依赖逻辑”之间存在天然的冲突

当 Docker 尝试“线性”地拷贝这个“网状”结构时,软链接往往会失效(指向了镜像外或者未被拷贝的路径),或者因为解包软链接导致原本共享的依赖被重复物理拷贝,造成镜像体积剧增。

1. Docker 的线性分层逻辑

Docker 镜像是基于联合文件系统(UnionFS) 的,它是一层叠一层的线性结构。每一层都是前一层的增量补丁。Docker 的缓存机制依赖于这一层的指令和对应的文件内容是否发生变化。

2. pnpm Workspace 的网状依赖逻辑

pnpm 的核心是内容寻址存储(CAS) 。在 Monorepo 中,它通过大量的软链接(Symlinks)和硬链接(Hard Links)建立起一个错综复杂的网状结构。

2. pnpm deploy「官方方案」

pnpm.io/docker

核心理念: 物理提取,按需隔离

1. 构建流

  • pnpm fetch: 仅基于 pnpm-lock.yaml 下载依赖到虚拟存储层。只要依赖清单未变,该层将实现极致的 Docker 缓存命中
  • pnpm install --offline: 拷贝源码后强制离线安装。完全跳过网络请求,利用本地缓存高速装配环境
  • pnpm deploy: 将指定子包从 Workspace 中“抽取”到独立目录,并将原有的内容寻址链接自动转换为真实的物理文件

2. 致命缺点

  • 孤岛效应deploy 后的目录呈绝对隔离状态,不会自动携带根目录的非依赖文件(如 tsconfig.base.json.eslintrc),导致基于相对路径的配置继承失效
  • 工具缺失:若构建脚本依赖根目录的共享工具(如 rimraf),必须在每个子包的 devDependencies 中显式声明,否则隔离后的环境将无法识别指令

3. Hoisted 「补丁方案」

核心理念: 结构模拟,路径桥接

1. 依赖平铺模式

pnpm install --node-linker=hoisted
  • 强制 pnpm 放弃网格化链接结构,将所有依赖(含各子包依赖)平铺安装至根目录 /app/node_modules
  • 彻底消除 Docker 镜像构建中常见的软链接解析失效问题,使文件系统回归经典的物理路径可达状态

2. 重建路径链路

由于依赖被“提升”到了根目录,而子包(Packages)的代码物理位置仍在深层目录,因此需要通过两个关键补丁来修复逻辑断层:

  1. 目录映射 Symbolic Link
RUN ln -s /app/node_modules /app/packages/node_modules

在子包层级的人为创造一个指向根目录依赖的入口。完美修复源码中通过相对路径(如 @import '../node_modules/') 引用依赖时的解析报错

  1. 执行上下文 PATH Injection
// 将根目录的可执行脚本库注入系统全局变量
ENV PATH=/app/node_modules/.bin:$PATH

将根目录的 .bin 可执行脚本库注入系统全局变量。确保在任意子包目录下执行 ng buildvite 时,系统能跨目录精准定位指令

4. 技术选型对比

维度 官方方案 (Deploy) 补丁方案 (Hoisted)
设计取向 子包独立化:将子包视为独立微服务 仓库整体化:保持 Monorepo 完整上下文
配置共享 困难:需手动 cp 补齐父级配置文件 原生支持:天然支持相对路径配置继承
安全性 高:严控幽灵依赖,运行环境纯净 中:依赖平铺可能引入隐式依赖风险
适用场景 子包可完全独立部署、无外部配置依赖 强耦合、重度共享配置的 Monorepo 项目

微信内容emoji表情包编辑器 + vue3 + ts + WrchatEmogi Editor

2025年12月18日 18:14

wechat-emoji-editor组件效果

功能 效果
添加表情 image.png
预览 image.png
目录 image.png

代码-组件: (需要源码留言)

// 
import WechatEmojiEditor from '@/components/wechat-emoji-editor/index.vue'
const welcomeMsg = ''

<WechatEmojiEditor
  :rows="4"
  type="textarea"
  :maxlength="1200"
  show-word-limit
  v-model="welcomeMsg"
></WechatEmojiEditor>

图片打包脚本 (需要源码留言)

package.json

    {
              "name": "build",
              "version": "1.0.0",
              "description": "build",
              "main": "index.js",
              "scripts": {
                "test": "echo "Error: no test specified" && exit 1",
                "buildImg": "sudo node sharpbuild2.js",
                "build": "node sharpbuild3.js && vite build" 
              },
              "devDependencies": {
                "sharp": "^0.34.3"
              }
            }

sharpbuild脚本

```
        const imgs = [
        {
            cn: "[微笑]",
            hk: "[微笑]",
            us: "[Smile]",
            code: "/::)",
            web_code: "/微笑",
            src: "/src/assets/emojis/Smile.png",
          },
          {
            cn: "[撇嘴]",
            hk: "[撇嘴]",
            us: "[Grimace]",
            code: "/::~",
            web_code: "/撇嘴",
            src: "/src/assets/emojis/Grimace.png",
          },
          // ...
        ]
        console.log('imgs', imgs)
        const list = imgs.map((item, index) => {
          return {
            input: `${__dirname}${item.src}`,
            top: Math.floor(index / 10) * 128, // 垂直偏移量
            left: (index % 10) * 128, // 水平偏移量
          };
        });
        const sharp = require("sharp");
        const fs = require("fs");
        // 指定输入文件路径
        const url = `${__dirname}/src/assets/wechat.png`;
        sharp({
          create: {
            width: 10 * 128,
            height: 11 * 128,
            channels: 3,
            background: { r: 255, g: 255, b: 255, alpha: 0 },
          },

        })

          .composite(list)
          .toFile(url)
          .then((info) => {
            console.log("Image composite successfully:", info);
          })
          .catch((error) => {
            console.error("Error processing image:", error);
          });
   ```

Vue 事件机制全面解析:原生事件、自定义事件与 DOM 冒泡完全讲透

作者 码途潇潇
2025年12月18日 17:58

Vue 事件机制全面解析:原生事件、自定义事件与 DOM 冒泡完全讲透

为什么要理解 Vue 事件?

  • 只知道 @click,却分不清它属于谁

  • 子组件不 emit,父组件 @click 为何能触发?

  • 面试中被问 “emit 和 @click 区别?

1.Vue事件的核心机制

1.1 原生事件(native events)

当 @drop 写在 HTML 原生标签上,例如:

<div @drop="handleDrop"></div>

这说明:

  • 监听的是浏览器 DOM 的 drop 事件
  • 用户把文件拖到 上时触发
  • 不需要任何子组件 emit

示例如下:

<div
  class="chunk-upload-trigger"
  @drop="handleDrop"
  @dragenter="handleDragEnter"
  @dragleave="handleDragLeave"
>

这些都是原生 DOM 事件,没有任何“子组件触发”的概念

1.2 子组件自定义事件(子组件 emit)

父组件中写入:

<Child @file-selected="onFileSelected" />

等待子组件 Child 调用

emit('file-selected', file)

父组件收到这个事件并执行 onFileSelected

  • ChunkUploadTrigger 内部 emit(‘file-selected’, file)
  • UploadPanel 接收到
  • 执行 handleFileSelected

这种完全不涉及 DOM 事件。

1.3 浏览器 DOM 的事件冒泡机制

父组件中

<ChunkUploadTrigger @drop="handleDrop" />

子组件中

<div @drop="handleDropInside"></div>

子组件不用 emit 通知父组件,子组件父组件都是同名的事件,此时父组件的事件就会穿透绑定到子组件根元素,两者的执行顺序,childDrop() 会触发,parentDrop() 也会触发,两者触发顺序按 DOM 事件流来(冒泡顺序)

DOM 事件冒泡不关心你是不是在一个文件、一个组件,它只关心 DOM 节点树。

父组件根 DOM
  └── 子组件根 DOM
        └── 子组件内部 DOM

所以虽然写在了两个.vue 文件,但是渲染出来的其实是跟在一个文件里写了两个 父子div 没有任何区别,事件冒泡就是沿着这棵 DOM 树往上走的

<Parent>
  <ChunkUploadTrigger />
</Parent>

<div class="parent-root">
  <div class="chunk-upload-trigger">   ← 子组件根节点
    <div>...</div>
  </div>
</div>

2.事件相关的实用补充

2.4 DOM 常用事件

浏览器原生事件,放在 HTML 标签上就能触发

鼠标事件

click,dblclick,mousedown,mouseup

键盘事件

keydown,keyup,keypress

输入 & 表单事件

dragenter,dragover,dragleave,drop

其他 DOM 事件

scroll,wheel,resize,load,error

2.5 Vue 事件修饰符总览

修饰符 含义 对应的 DOM 行为
.stop 阻止事件冒泡 event.stopPropagation()
.prevent 阻止默认行为 event.preventDefault()
.capture 使用捕获模式 addEventListener(…, true)
.self 只有事件目标是当前元素时触发 event.target === currentTarget
.once 事件只触发一次 自动 removeEventListener
.passive 表示监听器不会调用 preventDefault passive: true

比如:

@dragover.prevent="handleDragOver"

现代前端工程化实战:从 Vite 到 React Router demo的构建之旅

作者 San30
2025年12月18日 17:52

前端技术的迭代从未停歇。当我们谈论现代前端开发时,React 19Vite 已经成为了不可忽视的标准配置。React 19 带来了更高效的并发渲染机制,而 Vite 则凭借基于 ESM 的极致冷启动速度,彻底改变了开发体验。

本文将通过一个名为 react-demo 的实战项目,带你从零开始理解如何搭建、配置并开发一个标准的现代 React 应用。我们将涵盖工程化配置、路由管理、Hooks 状态逻辑以及样式预处理等核心知识点。

一、 极速启动:Vite 与 ESM 的革命

在过去,Webpack 是构建工具的王者,但它在启动大型项目时往往需要漫长的打包等待。现代开发推荐使用 Vite(法语意为“快”)作为脚手架。

1. 为什么是 Vite?

Vite 的核心优势在于它利用了浏览器原生的 ES Modules (ESM) 机制。在开发阶段 (npm run dev),Vite 不需要对代码进行全量打包,而是按需提供模块,这实现了极致的“冷启动”体验。

当我们运行 npm init vite 拉取项目模板后,项目结构非常清晰。观察项目的 package.json 脚本配置:

"scripts": {
  "dev": "vite",
  "build": "vite build",
  "preview": "vite preview"
}

这对应了完整的开发生命周期:dev(开发) -> build(构建生产包) -> preview(本地预览生产包)。

2. 依赖管理的艺术:Dev vs Prod

在安装依赖时,区分“开发依赖”和“生产依赖”至关重要。

  • dependencies (生产依赖) :如 reactreact-dom。React 19.2.0 是核心库,负责组件定义和 diff 算法;而 react-dom 负责将组件渲染到浏览器 DOM 中。这类似于 Vue 的生态,React Core 对应 Vue Core,React DOM 对应 Vue 的渲染器。对应配置为 package.json 中的dependencies
  • devDependencies (开发依赖) :如 stylus。我们使用 npm i -D stylus 安装它,因为 Stylus 只是在开发阶段帮助我们将 .styl 文件编译为 CSS,上线后的代码并不需要 Stylus 引擎。对应配置为 package.json 中的devDependencies
// 生产依赖
  "dependencies": {
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "react-router-dom": "^7.10.1"
  },
// 开发依赖
  "devDependencies": {
    "@eslint/js": "^9.39.1",
    "@types/react": "^19.2.5",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^5.1.1",
    "eslint": "^9.39.1",
    "eslint-plugin-react-hooks": "^7.0.1",
    "eslint-plugin-react-refresh": "^0.4.24",
    "globals": "^16.5.0",
    "stylus": "^0.64.0",
    "vite": "^7.2.4"
  }

二、 入口与渲染:React 19 的严谨模式

项目的入口文件 main.jsx 展示了 React 19 最标准的挂载方式。

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.styl'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

严格模式 (StrictMode)

你可能会发现,在开发环境下组件的生命周期函数(如 useEffect)会执行两次。这并非 Bug,而是 <StrictMode> 的有意为之。它通过双重调用来帮助开发者检测不安全的副作用(Side Effects)和过时的 API 使用,确保代码在生产环境中更加健壮。

样式预处理

我们引入了全局样式 index.styl。Stylus 的魅力在于其极简的语法——省略花括号、冒号和分号,通过缩进来组织代码:

*
  margin: 0
  padding: 0

body
  background-color pink

Vite 内置了对 CSS 预处理器的支持,无需繁琐的 Webpack Loader 配置,安装即用,安装指令为npm i -D stylus。其中的-D就代表了开发依赖,如果不书写-D则会默认安装至生产依赖。

三、 路由架构:单页应用的骨架

单页应用(SPA)的核心在于:页面不刷新,URL 改变,内容切换,在一个页面 (index.html) 中实现 "多页面" 的切换效果。我们使用 react-router-dom v7 来实现这一功能。首先需要通过npm i react-router-dom指令安装路由。

1. 路由模式选择

App.jsx 中,我们采用了 BrowserRouter(别名为 Router)。相比于 URL 中带有 # 号的 HashRouterBrowserRouter 利用 HTML5 History API,提供了更现代化、更美观的 URL 结构,是目前的行业标准。

2. 声明式导航:Link vs A

在 React Router 中,我们严禁使用传统的 <a href> 标签进行内部跳转。因为 <a> 标签会导致浏览器强制刷新页面,从而重置 React 的所有状态。

相反,我们使用 <Link> 组件:

<nav>
  <ul>
    <li><Link to="/">Home</Link></li>
    <li><Link to="/about">About</Link></li>
  </ul>
</nav>

<Link> 组件在内部“消化”了点击事件,通过 JavaScript 修改 URL 并通过 Context 通知路由系统更新视图,实现了无缝的页面切换。

3. 路由配置分离

为了保持代码的整洁,我们将具体的路由规则抽离到了 router/index.jsx 中:

import { Routes, Route } from 'react-router-dom';
import Home from '../pages/Home.jsx';
import About from '../pages/About.jsx';

export default function AppRoutes() {
    return (
        <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/about" element={<About />} />
        </Routes>
    )
}

这种集中管理路由表的方式,使得 App.jsx 只需关注整体布局,而将路由细节交给 AppRoutes 组件处理。

四、 核心业务逻辑:Hooks 驱动的数据流

Home.jsx 组件展示了 React 函数式组件的核心逻辑:Hooks。我们的目标是调用 GitHub API 并展示数据。

1. 响应式状态:useState

const [repos, setRepos] = useState([]);

useState 是 React 响应式系统的基石。它返回当前状态 repos 和更新函数 setRepos。每当调用 setRepos 时,React 就会感知数据变化,并触发组件的重新渲染(Re-render),更新视图。

2. 副作用管理:useEffect

网络请求属于“副作用”(Side Effect),不能直接写在组件渲染逻辑中。我们使用 useEffect 来处理组件挂载后的逻辑:

useEffect(() => {
    // 组件挂载完成 (onMounted)
    fetch('https://api.github.com/users/shunwuyu/repos')
        .then(res => res.json())
        .then(json => setRepos(json))
}, [])
  • 执行时机useEffect 确保代码在组件渲染并挂载到 DOM 之后执行,避免阻塞 UI 渲染。
  • 依赖数组 [] :第二个参数传入空数组 [],意味着这个 Effect 只在组件初始化时执行一次(相当于类组件的 componentDidMount)。如果不传此参数,每次渲染都会触发请求,导致无限循环。

3. 条件渲染与列表 Key

在 JSX 中,我们利用 JavaScript 的灵活性来构建 UI。

return (
        <div>
            <h1>Home</h1>
            {
                repos.length ? (
                    <ul>
                        {
                            repos.map(repo => (
                                <li key={repo.id}>
                                    <a href={repo.html_url} target="_blank" rel="noreferrer">
                                        {repo.name}
                                    </a>
                                </li>
                            ))
                        }
                    </ul>
                ) : null
            }
        </div>
    );
  • Diff 算法的关键:在遍历列表时,必须为每个元素提供唯一的 key(如 repo.id)。这能帮助 React 的 Diff 算法高效地识别元素的增删改,最小化 DOM 操作。
  • 条件渲染:通过三元运算符检查 repos.length,在数据加载前不渲染列表,防止页面报错。

五、 总结

通过这个项目,我们不仅搭建了一个简单的 GitHub 仓库浏览器,更重要的是实践了现代 React 开发的标准范式:

  1. 工程化:利用 Vite 极速构建,区分开发与生产依赖。
  2. 组件化:通过 Props 和 Hooks 实现逻辑复用。
  3. 路由化:使用 React Router 实现 SPA 的无感跳转。
  4. 响应式:利用 useStateuseEffect 驱动数据流向。

从 React 19 的底层优化到 Vite 的工程实践,这套技术栈为开发者提供了极其高效的开发体验,是构建未来 Web 应用的坚实基础。

六、Home.jsx 源代码

import { useState, useEffect } from 'react';

const Home = () => {
    const [repos, setRepos] = useState([]);
    // render 是第一位的
    // console.log('Home 组件渲染了');
    useEffect(() => {
        // home 组件可以看到了
        // console.log('Home 组件挂载了');
        // 发送api请求,不会和组件渲染去争抢
        fetch('https://api.github.com/users/shunwuyu/repos')
            .then(res => res.json())
            .then(json => setRepos(json))
    }, [])
    return (
        <div>
            <h1>Home</h1>
            {
                repos.length ? (
                    <ul>
                        {
                            repos.map(repo => (
                                <li key={repo.id}>
                                    <a href={repo.html_url} target="_blank" rel="noreferrer">
                                        {repo.name}
                                    </a>
                                </li>
                            ))
                        }
                    </ul>
                ) : null
            }
        </div>
    );
}

export default Home;

从 0 到 1 实现CloudBase云开发 + 低代码全栈开发活动管理小程序(13)

作者 Shaneyxs
2025年12月18日 17:52

第 13 章:文件上传与云存储

“一张图胜过千言万语,但前提是你得把图传上去。”

本章将讲解如何利用腾讯云开发 (CloudBase) 的云存储能力,实现高效、稳定的文件上传。

13.1 云存储架构

云存储 (Cloud Storage) 本质上是一个对象存储服务 (COS)。

  • 路径: 我们按照 year/month/filename 的格式组织文件,避免单目录下文件过多。
  • 权限: 默认设置为“所有用户可读,仅创建者/管理员可写”。

13.2 图片/视频上传实现

前端核心 API: uniCloud.uploadFile

封装上传 Hook (useUpload)

为了复用,我们封装了一个 Hook:

// src/hooks/useUpload.ts
export function useUpload() {
  const uploadImage = async (filePath: string) => {
    const ext = filePath.split('.').pop()
    const cloudPath = `activity/${Date.now()}-${Math.random().toString(36).slice(-6)}.${ext}`

    uni.showLoading({ title: '上传中...' })
    try {
      const res = await uniCloud.uploadFile({
        filePath: filePath,
        cloudPath: cloudPath
      })
      return res.fileID // 返回 cloud://...
    } finally {
      uni.hideLoading()
    }
  }
  return { uploadImage }
}

选择文件

结合 uni.chooseImageuni.chooseVideo

const chooseAndUpload = async () => {
  const res = await uni.chooseImage({ count: 1 })
  const filePath = res.tempFilePaths[0]
  const fileID = await uploadImage(filePath)
  // 将 fileID 存入组件配置或表单
  config.src = fileID
}

13.3 FileID 与临时 URL 转换

CloudID (cloud://...) 是云开发的特有协议。

  • 在小程序内: <image src="cloud://..."> 可以直接展示,无需转换,利用内网 CDN,速度极快。
  • 在 Web/H5 端: 浏览器不认识 cloud://。需要调用 uniCloud.getTempFileURL 换取 https:// 开头的临时链接。

最佳实践: 尽量在数据库存 cloud:// 路径。只在需要在非小程序环境展示时才进行转换。

13.4 批量上传优化

在“活动相册”或“多图投票”场景下,用户可能一次选 9 张图。 串行 vs 并行:

  • ❌ 串行: 传完一张再传下一张,太慢。
  • ✅ 并行: Promise.all
const uploadAll = async (paths) => {
  const tasks = paths.map((path) => uploadImage(path))
  const fileIDs = await Promise.all(tasks)
  return fileIDs
}

本章小结: 文件上传是内容类应用的基础设施。有了云存储,我们不再需要操心文件服务器的搭建和带宽费用,只需关注业务逻辑。下一章,我们将为活动添加一些“氛围组”功能——背景音乐和弹幕。

下一章(14-背景音乐与弹幕)

从 0 到 1 实现CloudBase云开发 + 低代码全栈开发活动管理小程序(12)

作者 Shaneyxs
2025年12月18日 17:50

第 12 章:组件渲染引擎

“编辑器负责生产数据,渲染引擎负责消费数据。”

渲染引擎 (Rendering Engine) 是连接数据与界面的最后一公里。它不仅运行在用户端的展示页,也运行在编辑器内部(作为预览)。

12.1 运行时 vs 编辑时

在我们的架构中,组件 (src/components/ActivityDesign/widget/*) 被设计为双模态

<!-- widget/Button.vue -->
<script setup>
const props = defineProps({
  mode: { type: String, default: 'view' }, // 'view' | 'edit'
  isPreview: Boolean
})

const handleClick = () => {
  // 编辑模式下,点击不触发业务逻辑,而是被外层捕获用于选中
  if (props.mode === 'edit' && !props.isPreview) return

  // 运行时逻辑:跳转报名页
  uni.navigateTo({ url: '/pages/registration/index' })
}
</script>

这种设计保证了所见即所得。编辑器里看到的组件,和最终用户看到的组件,使用的是同一套代码。

12.2 动态组件渲染

虽然我们在编辑器里使用了 v-if/v-else-if 来枚举组件,但在最终的活动详情页 (pages/activity/detail),我们通常会封装一个纯粹的渲染器组件 ActivityRenderer

<template>
  <view class="activity-renderer">
    <view v-for="item in components" :key="item.id" class="widget-wrapper" :style="item.style">
      <!-- 这里的组件只负责展示,没有任何编辑逻辑 -->
      <ImageWidget v-if="item.type === 'image'" :item="item" />
      <TextWidget v-else-if="item.type === 'text'" :item="item" />
      <!-- ... -->
    </view>
  </view>
</template>

12.3 样式计算与应用

组件的样式来源有两个:

  1. Preset Styles (预设样式): 组件内部写死的 CSS,比如按钮的圆角、阴影。
  2. Config Styles (配置样式): 用户在属性面板设置的颜色、字号。

在 Widget 内部,我们将配置映射为内联样式:

const style = computed(() => ({
  fontSize: props.item.config.fontSize + 'rpx',
  color: props.item.config.color,
  textAlign: props.item.config.align,
  fontWeight: props.item.config.bold ? 'bold' : 'normal'
}))

12.4 性能优化技巧

当页面包含大量组件(如长图文页面)时,渲染可能会卡顿。

  1. 按需加载: 只有进入视口的组件才加载图片资源(UniApp 的 image 组件自带 lazy-load)。
  2. 层级优化: 尽量减少嵌套。我们的 Widget 内部结构非常扁平。
  3. 静态提升: 对于不依赖配置的静态部分,Vue 编译器会自动优化。

本章小结: 组件化思想贯穿了从数据库设计、编辑器实现到前端渲染的始终。通过这套体系,我们不仅实现了“可配置”,更实现了“可维护”和“可扩展”。

至此,可视化编辑器的核心秘密已全部揭晓。接下来的章节,我们将介绍一些锦上添花的高级特性,比如文件上传和海报生成。

下一章(13-文件上传与云存储)

从 0 到 1 实现CloudBase云开发 + 低代码全栈开发活动管理小程序(10)

作者 Shaneyxs
2025年12月18日 17:47

第 10 章:组件化设计思想

“好的架构不是设计出来的,而是演进出来的。但组件模型除外,它得一开始就想清楚。”

在传统的活动开发中,每个页面都是“硬编码”的。今天要改个文案,明天要换张图,程序员的生命就浪费在这些琐事上。

我们的目标:打造一个“低代码编辑器”,让运营人员(社长/管理员)能像搭积木一样自己拼出一个精美的活动页面。

要实现这个目标,核心在于:一切皆组件,组件皆数据

10.1 什么是"活动页面配置组件"?

在我们的系统中,一个活动页面不是 .vue 文件,而是一串 JSON 数据。

比如,一个包含“头图 + 标题 + 报名按钮”的页面,在数据库里长这样:

[
  { "type": "image", "config": { "src": "banner.jpg" } },
  { "type": "text", "config": { "content": "中秋晚会", "fontSize": 20 } },
  { "type": "button", "config": { "text": "立即报名" } }
]

我们的渲染引擎(Runtime)读取这个数组,依次把它们画到屏幕上。这就是数据驱动 UI

10.2 组件模型设计

我们需要定义一套标准,规定每个组件长什么样。这套标准就是 Component Schema

通用结构 (ActivityComponent)

所有组件都必须遵循这个接口定义 (src/types/activity.d.ts):

interface ActivityComponent {
  id: string // 唯一ID (如 "w1710001"),用于查找和更新
  type: string // 组件类型 (如 "text", "image")
  order: number // 排序权重
  visible: boolean // 是否可见
  style: {
    // 通用样式容器
    marginTop?: number
    padding?: number
    // ...
  }
  config: any // 组件特有的业务配置 (AnyScript?? 不,下面细说)
}

具体组件定义

1. 文本组件 (text)
interface TextConfig {
  content: string // 文本内容
  fontSize: number // 字号 (默认 32)
  color: string // 字体颜色
  lineHeight: number // 行高 (默认 1.6)
  bold: boolean // 加粗
  decorType: 'none' | 'underline' | 'through' | 'overline' // 装饰线类型
  decorColor: string // 装饰线颜色
  decorTextColor: string // 装饰文字颜色
}
2. 图片组件 (image)
interface ImageConfig {
  src: string // 图片地址
}
3. 按钮组件 (button)
interface ButtonConfig {
  text: string // 按钮文案
}
4. 分割线组件 (divider)
interface DividerConfig {
  styleType: 'simple' | 'text' // 样式类型
  color: string // 线条颜色
  text: string // 标题文字
  description: string // 描述信息
  textColor: string // 文字颜色
}
5. 倒计时组件 (countdown)
interface CountdownConfig {
  targetTime: number // 目标时间戳
  textPrefix: string // 前缀文案
  stylePreset: 'classic' | 'modern' // 样式预设
}
6. 视频组件 (video)
interface VideoConfig {
  src: string // 视频链接
  autoplay: boolean // 自动播放
  loop: boolean // 循环播放
}

10.3 组件注册与渲染机制

有了数据模型,前端如何把它们变成 Vue 组件?

组件映射表 (Component Map)

我们不需要写一堆 if (type === 'text')。Vue 的 <component :is="..."> 是实现动态渲染的绝佳工具。

虽然 Vue 支持动态导入,但在小程序环境中,更推荐静态注册以保证兼容性。

<!-- ActivityDesign/index.vue -->
<template>
  <view v-for="item in components" :key="item.id">
    <ImageWidget v-if="item.type === 'image'" :item="item" />
    <TextWidget v-else-if="item.type === 'text'" :item="item" />
    <ButtonWidget v-else-if="item.type === 'button'" :item="item" />
    <!-- ... -->
  </view>
</template>

(注:虽然写了 if-else,但这只是为了类型安全和 Props 传递的显式控制。本质上依然是根据 Type 渲染 Widget)


本章小结: 我们定义了组件的“基因”——JSON Schema。只要符合这个 Schema 的数据,都能被我们的系统识别并渲染。下一章,我们将亲手打造那个能生成这些 JSON 的可视化编辑器

下一章(11-可视化编辑器实现)

【用户行为监控】别只做工具人了!手把手带你写一个前端埋点统计 SDK | 掘金一周 12.18

作者 掘金一周
2025年12月18日 17:46

本文字数1100+ ,阅读时间大约需要 4分钟。

【掘金一周】本期亮点:

「上榜规则」:文章发布时间在本期「掘金一周」发布时间的前一周内;且符合各个栏目的内容定位和要求。 如发现文章有抄袭、洗稿等违反社区规则的行为,将取消当期及后续上榜资格。

一周“金”选

掘金一周 文章头图 1303x734.jpg

内容评审们会在过去的一周内对社区深度技术好文进行挖掘和筛选,优质的技术文章有机会出现在下方榜单中,排名不分先后。

前端

【AI 编程实战】第 2 篇:让 AI 成为你的前端架构师 - UniApp + Vue3 项目初始化 @HashTang

最终生成的代码不仅逻辑清晰,还处理了很多细节,比如 iOS 的样式适配。这就是 AI 辅助开发的威力:它不仅能写出跑通的代码,还能考虑到平台差异和边界情况。

【用户行为监控】别只做工具人了!手把手带你写一个前端埋点统计 SDK @不一样的少年_

想知道那些“黑科技”是如何拦截点击、统计 PV(页面浏览量)与 UV(独立访客数)、精确计算页面停留时长的吗?本文将从原理角度切入,手把手带你设计并实现一个轻量级、功能完备的用户行为监控 SDK

后端

基于Nacos的轻量任务调度方案 —— 从 XXL-Job 的痛点说起 @踏浪无痕

JobFlow 只是一个想法,一个技术探讨。它的核心不是技术细节,而是一个设计理念:中间件即业务。在云原生时代,调度能力不应该是一个独立部署、独立运维的"平台",而应该是内嵌在微服务体系中的能力模块

Android

十分钟速览 Kotlin Flow 操作符 @RockByte

作为基于协程构建的响应式流 API,Kotlin Flow 让你可以用声明式的方式优雅地处理异步数据流。但要想真正发挥它的强大能力,关键在于熟练掌握各种操作符。

人工智能

✨TRAE SOLO + Holopix AI | 复刻 GBA 游戏-"🐛口袋妖怪 @coder_pig

GBA的图形芯片 (PPU) 没有 "加载图片" 的概念,它只能读取连续的显存块。开发者把所有角色动画帧拼成一张图,通过修改 UV坐标 (读取位置) 来切换帧。

5小时整理60页《Google Agent指南》,不懂Agent的包教包会 @大模型教程

现阶段来说,Tools是Agent真正的核心,而且Tools调用不准也是Agent架构最大的难点,当前我们在生产环境使用Skills技术 + 强意图也最多把准确率做到90%左右。

数据库AI方向探索-MCP原理解析&DB方向实战|得物技术 @得物技术

通过整合多模态数据(文本与二进制)资源使 AI 模型能访问私有或专属知识库(如企业内部文档)、实时外部 API 及系统动态信息,有效突破单一大模型数据孤岛。

AutoGLM 开源实测:一句话让 AI 帮我点个鸡排 @飞哥数智谈

Phone Agent 是一个基于 AutoGLM 构建的手机端智能助理框架,它能够以多模态方式理解手机屏幕内容,并通过自动化操作帮助用户完成任务。

IOS

Flutter 官方正式解决 WebView 在 iOS 26 上有点击问题 @恋猫de小郭

新机制让开发者可以在 Dart 代码中直接指定 PlatformView 的手势拦截策略,而不是依赖全局配置或原生代码,根据不同场景配置不同的拦截处理机制,而 touchBlockingOnly 就是全新的支持。

📖 投稿专区

大家可以在评论区推荐认为不错的文章,并附上链接和推荐理由,有机会呈现在下一期。文章创建日期必须在下期掘金一周发布前一周以内;可以推荐自己的文章、也可以推荐他人的文章。

从 0 到 1 实现CloudBase云开发 + 低代码全栈开发活动管理小程序(08)

作者 Shaneyxs
2025年12月18日 17:45

第 8 章:报名系统设计

“填表单是世界上最枯燥的事,但我们可以让它稍微好玩一点。”

报名系统是连接活动主办方和参与者的桥梁。本章我们将实现一个高度可配置的报名系统,支持动态表单和支付凭证审核。

8.1 自定义表单配置

不同的活动需要收集的信息不同。聚餐需要统计“是否吃辣”,讲座需要统计“所在部门”。我们不能把表单字段写死在代码里。

数据结构设计

activities 表的 registrationConfig 字段中:

{
  "fields": [
    { "id": "name", "type": "text", "label": "姓名", "required": true },
    { "id": "phone", "type": "number", "label": "手机号", "required": true },
    {
      "id": "diet",
      "type": "radio",
      "label": "饮食偏好",
      "options": ["正常", "微辣", "特辣"]
    }
  ]
}

前端渲染 (DynamicForm)

前端组件遍历 fields 数组,根据 type 动态渲染组件:

  • text -> <uni-easyinput>
  • radio -> <uni-data-checkbox>
  • image -> <uni-file-picker>

8.2 报名流程与状态机

报名不仅仅是填个表,它是一个完整的业务事务

状态流转图

graph LR
    Start((开始)) --> Pending[待审核]
    Pending -->|管理员通过| Approved[报名成功]
    Pending -->|管理员拒绝| Rejected[已拒绝]

    subgraph 支付流程
    Pending -->|上传凭证| PaymentPending[支付确认中]
    PaymentPending -->|管理员确认收款| Approved
    PaymentPending -->|管理员驳回| Pending
    end

并发控制 (Race Condition)

当活动限制名额(如仅限 50 人)时,如何防止第 51 个人报名成功?

解决方案: “先查后写”的乐观检查策略。 虽然 MySQL 支持强事务,但为了简化云函数逻辑,我们目前暂未开启严格的数据库事务锁。而是采用了应用层的检查机制:

count = await count(registrations where activityId=xxx)
if (count >= limit) return Error("名额已满")
await create(registration)`

注意: 这种方式在极高并发下(如 1秒内涌入 1000 人)可能会导致少量超卖。但对于社团活动这种量级,这已经足够安全且高效了。如果真遇到了“秒杀”场景,再考虑引入 Redis 锁或数据库事务也不迟。

8.3 支付凭证上传与审核

对于社团活动,接入微信支付门槛太高(需要企业资质)。最接地气的方式是:上传转账截图

流程实现

  1. 主办方: 在活动配置中上传自己的收款二维码(微信/支付宝)。
  2. 参与者:
    • 保存二维码,去微信支付转账。
    • 截图保存。
    • 在报名表单中点击“上传支付凭证”,选择截图。
    • 前端调用 uniCloud.uploadFile,获取图片 FileID
  3. 提交: FileID 随表单数据一同提交到 createRegistration 云函数。

8.4 报名管理后台

管理员在手机端管理报名记录。

扫码核销

如何快速验证参与者身份?

  1. 生成码: 用户端根据报名ID生成二维码 (qrcode).
  2. 扫码: 管理员使用小程序自带的 scanCode 接口扫描。
  3. 核销: 前端解析出报名ID,调用云函数 verifyRegistration,将状态改为“已签到”。

数据导出

社团联需要 Excel 表格备案? 云函数支持将数据导出为 CSV/Excel 文件,生成下载链接,管理员复制链接即可在电脑上下载。


本章小结: 我们实现了一个灵活的报名系统,既满足了多样化的数据收集需求,又巧妙规避了微信支付的资质限制。下一章,我们将让活动“动”起来——实现投票互动系统

下一章(09-投票互动系统)

从 0 到 1 实现CloudBase云开发 + 低代码全栈开发活动管理小程序(07)

作者 Shaneyxs
2025年12月18日 17:42

第 7 章:活动管理模块

“如果把系统比作一个舞台,那么‘活动’就是舞台上的主角。”

本章我们将实现系统的核心业务——活动的创建、查看、编辑和删除(CRUD)。这不仅是数据库的增删改查,更涉及到状态流转和复杂的配置管理。

7.1 活动 CRUD 操作

所有的逻辑都在 server/activity/index.js 中实现。

7.1.1 创建活动 (Create)

管理员填写表单后,提交到后端。 关键逻辑

  • 默认值: 如果未指定封面,使用默认图;如果未指定时间,默认为当前时间。
  • 初始化配置: 创建时会生成默认的 registrationConfig (报名配置) 和 voteConfig (投票配置) JSON 结构,避免后续读取时出现空指针。
// 默认的报名配置
const defaultRegConfig = {
  displayMode: 'page',
  fields: [] // 初始为空,由后续可视化编辑添加
}

7.1.2 查询列表 (Read)

支持分页、关键词搜索和状态筛选。 核心查询语句

const filter = {
  where: {
    deleted: false, // 必须过滤软删除数据
    $or: [
      // 模糊搜索标题或描述
      { title: { $search: keyword } },
      { description: { $search: keyword } }
    ]
  },
  orderBy: [{ field: 'createdAt', direction: 'desc' }] // 最新创建的排前面
}

7.1.3 更新活动 (Update)

支持全量更新部分更新

  • 全量更新: 修改标题、时间等基础信息。
  • 部分更新: 专门用于更新 components (页面组件)、registrationConfig 等大字段。因为这些字段通常由不同的编辑器页面独立保存,避免相互覆盖。

7.1.4 删除活动 (Delete)

这里的删除是逻辑删除 (Soft Delete)

await models.activities.update({
  filter: { _id: activityId },
  data: { deleted: true, deletedAt: Date.now() }
})

7.2 活动状态管理

一个活动有三种基本状态:

  1. 已下架 (Inactive): isActive: false。只有管理员可见,普通用户无法访问。
  2. 未开始 (Not Started): isActive: truenow < startTime。用户可浏览详情,但无法报名/投票。
  3. 进行中 (Active): isActive: truestartTime <= now <= endTime。用户可正常参与。
  4. 已结束 (Ended): isActive: truenow > endTime。页面展示“活动已结束”,禁用操作按钮。

前端展示逻辑:

const statusText = computed(() => {
  if (!activity.isActive) return '已下架'
  const now = Date.now()
  if (now < activity.startTime) return '未开始'
  if (now > activity.endTime) return '已结束'
  return '进行中'
})

7.3 软删除与级联删除

当管理员狠心删除一个活动时,仅仅标记 activities 表是不够的。为了保持数据整洁,我们需要级联删除相关联的数据。

deleteActivity 云函数中:

  1. 标记活动为 deleted: true
  2. 级联操作:
    • 查找所有关联的报名记录 (registrations),标记为 deleted: true
    • 查找所有关联的投票记录 (votes),标记为 deleted: true
    • 查找所有关联的弹幕 (barrages),标记为 deleted: true

⚠️ 警告: 级联删除是危险操作。虽然我们可以恢复(把 deleted 改回 false),但在执行前最好在前端给管理员一个二次确认弹窗:“删除活动将同时删除所有报名和投票数据,确定吗?”

7.4 活动列表与详情页实现

列表页 (pages/activity/list)

使用原生 scroll-view 实现滚动加载。

  • 分页逻辑: 监听 onReachBottom 事件,累加 page 参数请求下一页数据。
  • 性能优化: 列表项只展示封面、标题、时间,不加载庞大的 componentsconfig 字段,减少网络传输体积。

详情页 (pages/activity/detail)

这是用户访问最频繁的页面。

  1. 加载状态: 简单的 uni.showLoadingv-if="loading" 控制,数据返回前展示空白或 Loading 图标。
  2. 动态渲染: 核心区域使用 <ActivityRenderer :components="activity.components" /> 组件,将后台配置的 JSON 渲染为真实的 UI(详见第 12 章)。
  3. 底部操作栏: 根据活动状态(报名中/投票中/已结束)动态显示“立即报名”或“参与投票”按钮。

本章小结: 活动管理模块是系统的躯干。我们不仅实现了基础的增删改查,还处理了复杂的级联逻辑和状态流转。下一章,我们将聚焦于最实用的功能——那个能自动收集表单、还能收钱的报名系统

下一章(08-报名系统设计)

从 0 到 1 实现CloudBase云开发 + 低代码全栈开发活动管理小程序(05)

作者 Shaneyxs
2025年12月18日 17:38

第 5 章:云开发架构详解

“Serverless 不是没有服务器,而是你不需要管理服务器。”

本章我们将深入后端代码 (server/ 目录),看看如何用 Node.js 编写优雅的云函数。

5.1 什么是 Serverless?

简单理解:你写一个函数 main(),把代码上传给腾讯云。当有请求来时,腾讯云分配计算资源运行它;运行结束,资源释放。

  • FaaS (Function as a Service): 云函数。
  • BaaS (Backend as a Service): 云数据库、云存储、云鉴权。

5.2 云函数设计模式

观察 server/activity/index.js,你会发现一种通用的模式:单体函数路由模式 (Monolithic Function Routing)

我们没有为“创建活动”、“删除活动”分别创建几百个云函数,而是按业务领域聚合。

代码结构剖析

// server/activity/index.js

// 1. 引入依赖 & 初始化
const cloudbase = require('@cloudbase/node-sdk')
const app = cloudbase.init({ env: process.env.TCB_ENV_ID })
const models = app.models

// 2. 辅助函数 (Utils)
const checkAdminPermission = (role) => { ... }

// 3. 入口函数 (Main Entry)
exports.main = async (event, context) => {
  const { action, data, token } = event

  // 3.1 全局鉴权 (JWT Middleware)
  const verification = verifyJWT(token)
  if (!verification.valid) return { code: 401, ... }

  // 3.2 路由分发 (Dispatcher)
  switch (action) {
    case 'createActivity':
      return await createActivity(data, context) // 调用业务逻辑
    case 'getActivityList':
      return await getActivityList(data, context)
    // ...
  }
}

// 4. 业务逻辑函数 (Controllers)
const createActivity = async (data) => {
  // 参数校验
  // 权限检查
  // 数据库操作
  return { code: 0, message: '成功' }
}

这种模式的优点:

  • 冷启动优化: 减少了云函数的数量,复用容器热身。
  • 代码复用: 共享数据库连接、工具函数和鉴权逻辑。

5.3 云数据库操作封装

我们使用了 @cloudbase/node-sdk 提供的 models 对象(或者直接使用 db 引用)。

常用操作范式

  1. 查询 (List)

    const { data: result } = await models.activities.list({
      filter: {
        where: {
          _id: 'act_123',
          deleted: false // 记得过滤软删除
        },
        offset: 0,
        limit: 20
      }
    })
    
  2. 创建 (Create)

    await models.activities.create({
      data: {
        title: '...',
        createdAt: dayjs().valueOf() // 记得打时间戳
      }
    })
    
  3. 原子更新 (Atomic Update) 在投票场景下,为了防止并发冲突,我们使用数据库原子操作符(如 $inc,虽然本项目目前的 update 逻辑较简单,但这是高并发场景的最佳实践)。

5.4 云存储与 CDN 加速

文件存储 (models.storage 或前端 uniCloud.uploadFile) 主要用于:

  1. 活动封面: 图片上传后获得 cloud://... 格式的 FileID。
  2. 支付凭证: 用户上传的截图。

CDN 加速: 云存储的文件默认走 CDN。在小程序端展示图片时,可以直接使用 cloud:// 路径,微信底层会自动转换为 HTTPS CDN 链接,加载速度飞快。

5.5 权限模型设计

我们在代码层面实现了严格的 RBAC (Role-Based Access Control)。

1. Token 里的秘密

JWT Token 的 payload 包含:

{
  "userId": "u_123",
  "role": "admin" // 核心权限字段
}

2. 装饰器式的权限检查

在敏感操作(如 deleteActivity)开头,有一行“守门员”代码:

const permissionError = checkAdminPermission(userRole)
if (permissionError) return permissionError

如果 userRole 不是 adminsuper_admin,直接返回 403 错误。

3. 数据所有权检查

对于普通用户操作(如删除自己的报名),我们需要检查 userId 是否匹配:

if (record.userId !== currentUserId) {
  return { code: 403, message: '只能操作自己的数据' }
}

本章小结: 通过云函数的“路由模式”和“鉴权中间件”,我们构建了一个安全、高效的后端。不需要配置 Nginx,不需要写 Dockerfile,一切都在代码里。下一章,我们将详细讲解用户认证系统——那个 JWT Token 是怎么生成和流转的。

下一章(06-用户认证系统)

WEB端小屏切换纯CSS实现

作者 码云之上
2025年12月17日 15:17

背景

最近开发了一个内部问答平台(deepseek套层皮🐷),前端实现很简单,用户输入一个问题,跟后端建立一个SSE连接,后端将结果流式输出,前端流式渲染展示。
突然有一天老板想搞点事:小明,页面这个流式内容能不能支持缩小到页面右下角,让用户更加聚焦输入框内容?
WTF,只见过全屏展示的,还没见过小屏展示的。
好像也不是,暂停播放的时候,各大视频网站就喜欢小屏放内容,让你更加聚焦于弹窗广告👻。
没有办法,在AI的冲击下前端已经死了至少七次了,可不敢惹恼boss,不然就等不到被AI的第八次kill,已经被老板一次KO了🫡。
秉着负(牛)责(马)的态度,帮助boss梳理交互细节~
希望缩小后流式内容还在动态输出...
希望缩小后右侧区域自动扩张到全部区域,然后小窗“悬浮”在右下角...
希望可以丝滑地还原...
明确了需求,直接开干。

方案思路

希望缩小后流式内容还在动态输出...

把当前内容区的容器整个缩小不就行了transform: scale(0.x),也就是从视觉上缩小流式内容区,其他渲染逻辑完全不动,SSE流的解析和渲染还是在内容区组件里完成,太easy了🥰

希望缩小后右侧区域自动扩张到全部区域,然后小窗“悬浮”在右下角...

啧啧,也好实现,在内容区多包一层div,让这个div脱离文档流position:absolute,再设置一下相对位置就可以悬浮到指定位置啦。

希望可以丝滑地还原...

CSS加上过渡效果,transition: transform 0.3s ease简单需求简单做嘛,哈哈哈

技术实现

容器结构

分为两层容器,父容器负责定位,实际缩小内容放置在 .right-panel-content__container 中:

Clipboard_Screenshot_1765869186.png 为了拥有过渡效果,通过setTimeout制造时间差,让过渡动画完成后才让父容器.right-panel-content浮动到右下角为止。看下实现效果:

Kapture 2025-12-16 at 12.01.08.gif

唉,唉!唉?缩小的内容怎么不见了?
F12打开调试面板看下DOM结构:

Clipboard_Screenshot_1765869271.png

DOM结构正常啊,内容都在正确位置,但是展示怎么就不对呢?
继续捣鼓CSS...猛然间看到div.right-panel-content.min-mode 的大小,突然感到一丝诡异,按理说待缩放区域设置的缩小比例为scale(0.2),计算下来容器的宽高至少应该是:90x158。怎么会是64x46呢?

Clipboard_Screenshot_1765867173.png 继续分析DOM结构及大小,发现.right-panel-content__header的大小是:46x28,加上div.right-panel-content.min-mode自己的padding: 8px,好家伙,刚好凑成62x44(=64x46- 1px border😭)。
感情.right-panel-content__container被缩小得根本不占空间了嘛~

问题分析

为什么会这样呢?因为被.right-panel-content__container被二次缩小了,原因在于container的父容器为了定位而做出的自身宽高调整。下面分步骤还原一下内容缩放过程:

设置以下别名:
.right-panel-content__container => content__container
.right-panel-content__header => content__header
.right-panel-content => panel-content

  1. 初始时宽高正常,content__container缩放后达到了目标大小90x158;
  2. 然后panel-content进入min-mode,宽高被强制设置为auto !important,因为内容区坍塌,panel-content被迫坍塌到跟内容一样的高度90x158;
  3. panel-content高度变化激活了content__container的 scale(0.2),content__container继续缩小;
  4. content__container的缩小又引发panel-content的高度坍塌...直到 content__header "独自"撑起panel-content的内容。

一切都解释清楚了,核心问题变成了怎么解决循环坍塌。

解决方案

既然问题父子容器间高度变化引发的循环坍塌,那可以不可以在中间加一层,阻断这种循环效果呢?
而且要阻断循环,那增加的一层应该脱离文档流,让其大小不受父容器和其子容器干扰。 新的DOM结果如下:

小屏DOM结构图.png 为了让操作栏样式更合理,根据minMode状态对 ContentHeader 进行动态渲染:

<div className="right-panel-middle-wrapper">
  {minMode && <ContentHeader minMode={minMode} setMinMode={setMinMode} />}
  <div className={`right-panel-content__container ${containerClassname}`}>
    {!minMode && <ContentHeader minMode={minMode} setMinMode={setMinMode} />}
    <ContentSection />
  </div>
</div>;

完整实现

import { useEffect, useRef, useState } from "react";
import { Layout, Button } from "@arco-design/web-react";
import { IconExpand, IconShrink } from "@arco-design/web-react/icon";

import "./App.less";

const Sider = Layout.Sider;
const Header = Layout.Header;
const Footer = Layout.Footer;
const Content = Layout.Content;

function App() {
  const [minMode, setMinMode] = useState(false);
  const [panelClassname, setPanelClassname] = useState("");
  const [containerClassname, setContainerClassname] = useState("");
  const classnameTimer = useRef<number>(0);

  useEffect(() => {
    if (minMode) {
      // 立即启动缩小动效
      setContainerClassname("min-mode-transition");

      // 动效结束后,再设置 min-mode 类名
      if (classnameTimer.current) {
        clearTimeout(classnameTimer.current);
      }
      // 动画约350ms,需要等动画结束后再设置min-mode
      classnameTimer.current = setTimeout(() => {
        setPanelClassname("min-mode");
      }, 350);
    } else {
      setPanelClassname("");
      setContainerClassname("");
    }
    return () => {
      if (classnameTimer.current) {
        clearTimeout(classnameTimer.current);
      }
    };
  }, [minMode]);

  return (
    <div className="app-layout">
      <Layout>
        <Header>Header</Header>
        <Layout>
          <Sider>Sider</Sider>
          <Content>
            <div className="left-panel-content">左侧内容栏</div>
            <div className={`right-panel-content ${panelClassname}`}>
              <div className="right-panel-middle-wrapper">
                {minMode && (
                  <ContentHeader minMode={minMode} setMinMode={setMinMode} />
                )}
                <div
                  className={`right-panel-content__container ${containerClassname}`}
                >
                  {!minMode && (
                    <ContentHeader minMode={minMode} setMinMode={setMinMode} />
                  )}
                  <ContentSection />
                </div>
              </div>
            </div>
          </Content>
        </Layout>
        <Footer>Footer</Footer>
      </Layout>
    </div>
  );
}

function ContentHeader({
  minMode,
  setMinMode,
}: {
  minMode: boolean;
  setMinMode: (minMode: boolean) => void;
}) {
  return (
    <div className="right-panel-content__header">
      {minMode ? (
        <Button type="text" size="small" onClick={() => setMinMode(false)}>
          <IconExpand style={{ color: "var(--color-border-4)" }} />
        </Button>
      ) : (
        <Button type="text" size="small" onClick={() => setMinMode(true)}>
          <IconShrink style={{ color: "var(--color-border-4)" }} />
        </Button>
      )}
    </div>
  );
}

function ContentSection() {
  return (
    <div className={`right-panel-content__section`}>
      <div className="right-panel-content__section-title">
        张若虚《春江花月夜》
      </div>
      春江潮水连海平,海上明月共潮生。 <br />
      滟滟随波千万里,何处春江无月明。
      <br />
      江流宛转绕芳甸,月照花林皆似霰。
      <br /> 空里流霜不觉飞,汀上白沙看不见。
      <br />
      江天一色无纤尘,皎皎空中孤月轮。
      <br /> 江畔何人初见月?江月何年初照人?
      <br />
      人生代代无穷已,江月年年望相似。
      <br />
      不知江月待何人,但见长江送流水。
      <br />
      白云一片去悠悠,青枫浦上不胜愁。
      <br />
      谁家今夜扁舟子?何处相思明月楼?
      <br />
      可怜楼上月裴回,应照离人妆镜台。
      <br />
      玉户帘中卷不去,捣衣砧上拂还来。
      <br /> 此时相望不相闻,愿逐月华流照君。
      <br />
      鸿雁长飞光不度,鱼龙潜跃水成文。
      <br /> 昨夜闲潭梦落花,可怜春半不还家。
      <br />
      江水流春去欲尽,江潭落月复西斜。
      <br /> 斜月沉沉藏海雾,碣石潇湘无限路。
      <br />
      不知乘月几人归,落月摇情满江树。
    </div>
  );
}

export default App;

样式文件:

.app-layout {
  height: 100%;
  width: 100%;

  .arco-layout {
    height: 100%;
  }

  .arco-layout-header,
  .arco-layout-footer,
  .arco-layout-sider,
  .arco-layout-sider-children,
  .arco-layout-content {
    color: var(--color-white);
    text-align: center;
    font-stretch: condensed;
    font-size: 16px;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }

  .arco-layout-content {
    flex-direction: row;
  }

  .arco-layout-header,
  .arco-layout-footer {
    height: 64px;
    background-color: var(--color-primary-light-4);
  }

  .arco-layout-sider {
    width: 206px;
    background-color: var(--color-primary-light-3);
  }

  .arco-layout-content {
    background-color: rgb(var(--arcoblue-6));

    .left-panel-content {
      flex: auto;
    }

    .right-panel-content {
      background-color: var(--color-primary-light-3);
      height: 100%;
      flex: 1;

      .right-panel-middle-wrapper {
        width: 100%;
        height: 100%;
      }

      .right-panel-content__container {
        width: 100%;
        height: 100%;

        .right-panel-content__header {
          height: 40px;
          line-height: 40px;
          background-color: var(--color-warning-light-1);
          text-align: right;
          padding: 0 10px;
        }

        .right-panel-content__section {
          width: 100%;
          height: calc(100% - 40px);
          color: #000;

          .right-panel-content__section-title {
            font-size: 20px;
            padding: 10px 0;
          }
        }
      }

      .min-mode-transition {
        position: absolute;
        z-index: 1999;
        width: 442px;
        height: 794px;
        transform: scale(0.2);
        transform-origin: right bottom;
        transition: transform 0.3s ease;
      }

      &.min-mode {
        position: absolute;
        z-index: 499;
        bottom: 25px;
        right: 25px;
        border-radius: 6px;
        padding: 8px;
        width: auto !important;
        height: auto !important;
        display: flex;
        flex-direction: column;
        align-items: center;
        transition: none;
        border: 1px solid var(--color-border);
        box-sizing: border-box;

        .right-panel-middle-wrapper {
          width: 90px;
          height: 158px;
          overflow: hidden;
          position: relative;

          .right-panel-content__header {
            height: fit-content;
            line-height: unset;
            background-color: unset;
            padding: 0;
          }

          .right-panel-content__container {
            transform-origin: top left;
          }
        }
      }
    }
  }
}

最终效果

Kapture 2025-12-16 at 16.05.22.gif

小结

文中的小窗效果虽然谈不上尽善尽美,但胜在简单,只需要调整css样式就可以实现。在实现中需要注意的点便是父容器与待缩放内容间的循环依赖问题。本文通过增加一个wrapper层达到依赖解耦的目的。方案有点不够优雅,如果有哪位大佬知道更好的方案,欢迎在评论区给出,让笔者也学习学习🖖

深入理解 CSS 伪类和伪元素的本质区别

作者 刘大华
2025年12月17日 08:54

在写前端CSS样式的时候,经常用到这种:hover或者::before有冒号的写法。我很疑惑,为什么有些是单冒号,有些又是双冒号呢?

后来我才知道这种写法也区分为伪类伪元素

伪类和伪元素是什么?

伪类(Pseudo-classes):用于选择处于特定状态的元素。也可以理解为,当元素处于某种状态的时候,给它加上一个“类”来定义样式。

  • 语法:单个冒号:,例如:hover

伪元素(Pseudo-elements):用于选择元素的特定部分。可以理解为,它在文档中创建了一个虚拟的“元素”来设置样式。

  • 语法:双冒号::,例如::before

只要是单个冒号的就一定是伪元素吗?

在现代CSS3点规范中,所有使用双冒号::语法的选择器都被定义为伪元素。这是W3C为了明确区分伪类和伪元素而引入的约定。

但在早期的CSS中,伪元素也使用单冒号,因为当时没有区分语法。所以为了向后兼容,大多数的浏览器还是会支持:before:after等单冒号的写法。

但新的伪元素只支持双冒号,比如:::selection


伪类(Pseudo-classes)

伪类用于选择处于特定状态的元素,比如用户交互状态、结构位置等。

常见伪类示例:

  • :hover:鼠标悬停时
  • :focus:元素获得焦点时(如输入框)
  • :active:元素被激活时(如点击按钮)
  • :visited:链接已被访问过
  • :first-child / :last-child:第一个/最后一个子元素
  • :nth-child(n):第 n 个子元素
  • :not(selector):排除匹配 selector 的元素

示例:

a:hover {
  color: red;
}

input:focus {
  border: 2px solid blue;
}

li:first-child {
  font-weight: bold;
}

伪类使用单冒号


伪元素(Pseudo-elements)

伪元素用于创建并样式化文档中不存在的虚拟元素,比如段落首字母、选中文本、元素前后插入内容等。

常见伪元素:

  • ::before:在元素内容前插入内容
  • ::after:在元素内容后插入内容
  • ::first-letter:段落的第一个字母
  • ::first-line:段落的第一行
  • ::selection:用户选中的文本(部分浏览器需加前缀)

示例:

p::first-letter {
  font-size: 2em;
  color: gold;
}

.quote::before {
  content: "“";
}

.quote::after {
  content: "”";
}

::selection {
  background: yellow;
  color: black;
}

伪元素使用双冒号::before),这是CSS3的规范写法。出于兼容性考虑,旧代码可能仍用单冒号(如:before),现代项目建议用双冒号。


主要区别

特性 伪类(Pseudo-class) 伪元素(Pseudo-element)
作用对象 已存在的元素的状态或位置 创建不存在的虚拟内容或部分
语法 单冒号(如 :hover 双冒号(如 ::before
是否生成内容 是(常配合 content 属性)
示例 a:hover, :nth-child(2) ::first-letter, ::after

示例效果

一个简单的待办事项列表 在这里插入图片描述

完整代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>伪类vs伪元素示例</title>
    <style>
        .container {
            max-width: 500px;
            margin: 0 auto;
            background: white;
            padding: 30px;
            border-radius: 15px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.2);
        }

        h1 {
            text-align: center;
            color: #2c3e50;
            margin-bottom: 30px;
        }

        .explanation {
            background: #f8f9fa;
            padding: 15px;
            border-radius: 8px;
            margin-bottom: 20px;
            border-left: 4px solid #3498db;
        }

        .todo-list {
            list-style: none;
            padding: 0;
        }

        .todo-list li {
            padding: 15px;
            margin: 8px 0;
            background: white;
            border: 2px solid #e9ecef;
            border-radius: 8px;
            cursor: pointer;
            transition: all 0.3s ease;
            position: relative;
        }

        /* === 伪类样式 === */
        /* 第一个子元素 */
        .todo-list li:first-child {
            border-left: 4px solid #e74c3c;
        }

        /* 偶数项 */
        .todo-list li:nth-child(even) {
            background-color: #f8f9fa;
        }

        /* 悬停效果 */
        .todo-list li:hover {
            background-color: #aab49b;
            color: white;
            transform: translateX(10px);
            border-color: #aab49b;
        }

        /* 点击效果 */
        .todo-list li:active {
            background-color: #2ecc71;
            transform: scale(0.98);
        }

        /* === 伪元素样式 === */
        /* 前面的图标 */
        .todo-list li::before {
            content: "📌";
            margin-right: 10px;
            transition: all 0.3s ease;
        }

        /* 悬停时图标变化 */
        .todo-list li:hover::before {
            content: "🔥";
            transform: scale(1.2);
        }

        /* 后面的装饰线 */
        .todo-list li::after {
            content: "";
            position: absolute;
            left: 0;
            bottom: 0;
            width: 0;
            height: 3px;
            background: linear-gradient(90deg, #e74c3c, #669521);
            transition: width 0.3s ease;
        }

        .todo-list li:hover::after {
            width: 100%;
        }

        /* 首字母样式 */
        .todo-list li::first-letter {
            font-size: 1.3em;
            color: #2c3e50;
            font-weight: bold;
        }

        .todo-list li:hover::first-letter {
            color: white;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>伪类 vs 伪元素 演示</h1>
        
        <div class="explanation">
            <p><strong>伪类(Pseudo-class)</strong>:选择元素的特定<strong>状态</strong></p>
            <p><strong>伪元素(Pseudo-element)</strong>:选择元素的特定<strong>部分</strong></p>
        </div>

        <ul class="todo-list">
            <li>学习 CSS 伪类</li>
            <li>理解伪元素</li>
            <li>完成项目练习</li>
            <li>复习知识点</li>
        </ul>
    </div>

    <script>
        // 添加点击切换完成状态的功能
        document.querySelectorAll('.todo-list li').forEach(item => {
            item.addEventListener('click', function() {
                this.classList.toggle('completed');
            });
        });
    </script>
</body>
</html>

在这个示例中,可以清晰地看到:

伪类(操作的是整个元素)

  • :first-child:操作第一个<li>元素的整体样式
  • :nth-child(even):操作偶数位置<li>的整体背景
  • :hover:操作鼠标悬停时<li>的整体状态变化

伪元素(操作的是元素的一部分)

  • ::before:在<li>内容之前插入新内容(图标)
  • ::after:在<li>内容之后插入装饰线条
  • ::first-letter:只样式化<li>文本的第一个字母

简单记忆:伪类是状态选择器,伪元素是内容生成器。


总结

伪类

  • 选择元素的特定状态(如:hover、:focus)
  • 语法使用单冒号(如:hover
  • 不生成新内容,只针对元素本身的状态变化
  • 常见用途:用户交互反馈、结构位置选择

伪元素

  • 创建并样式化元素的特定部分虚拟内容
  • 语法使用双冒号(如::before
  • 常配合content属性生成新内容
  • 常见用途:插入装饰元素、样式化文本部分

通过上面的内容,我也终于搞懂了伪类和伪元素的区别。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《async/await 到底要不要加 try-catch?异步错误处理最佳实践》

《Vue3 和 Vue2 的核心区别?很多开发者都没完全搞懂的 10 个细节》

《Java 开发必看:什么时候用 for,什么时候用 Stream?》

《这 10 个 MySQL 高级用法,让你的代码又快又好看》

手把手教你写一个VSCode插件,从开发到发布全流程

2025年12月17日 08:08

前言

VSCode 几乎是前端开发人员的标配,而现在编辑器往往只提供代码编辑文件和项目管理终端集成源代码管理调试支持等核心功能,而一些扩展功能则交给插件来实现。

会编写 VSCode 插件可以极大提升我们的开发效率,本文将介绍如何编写一个简单的 VSCode 插件。

1、 安装工具,初始化项目

首先肯定要安装 node,再通过其自带 npm 包管理工具安装 yogenerator-code

  • yo:全称叫 Yeoman,是一个现代 Web 应用的脚手架工具(命令行工具),用于快速生成项目。
  • generator-codeVS Code 扩展生成器。
npm install -g yo@5 generator-code

注意,使用 yo 最新的 6.0.0 版本运行 yo 命令会报错,因此这里指定安装 5 版本。

安装完成后,运行 yo code 命令,初始化项目。

yo code

这里按提示即可,我这里选择的是 pnpm + Typescript

其主要项目结构如下:

my-vscode-extension/
├── .vscode/            # VS Code 调试配置
│   ├── launch.json
│   └── tasks.json
├── src/
│   ├── test/           # 测试文件
│   └── extension.ts    # 插件主入口文件
├── .gitignore          # git 忽略文件
├── .npmrc              # npm 配置文件
├── .vscode-test.mjs    # 单测配置文件
├── .vscodeignore       # 插件发布忽略文件
├── package.json        # 项目依赖管理文件
├── tsconfig.json       # TypeScript 配置文件
├── eslint.config.mjs   # Eslint 配置文件
└── README.md

介绍一下 package.json 中提供的几个重要命令:

{
  "scripts": {
    "vscode:prepublish": "pnpm run compile",
    "compile": "tsc -p ./",
    "watch": "tsc -watch -p ./",
    "pretest": "pnpm run compile && pnpm run lint",
    "lint": "eslint src",
    "test": "vscode-test"
  },
}
  • npm run compile:编译 TypeScript 代码。
  • npm run watch:监听 TypeScript 文件变化并实时编译。
  • npm run test:运行单元测试。

另外是 package.json 中提供的插件命令。

{
  "contributes": {
    "commands": [
      {
        "command": "my-vscode-extension.helloWorld",
        "title": "Hello World"
      }
    ]
  },
}

在默认提供的 demo 中,插件命令名字叫 hello world

2、调试插件

在调试插件前,需要先运行下 npm run compile 编译 TypeScript 代码,经编译后会生成 out/ 文件,然后便可以开始调试了。

为什么要先编译呢?因为在 package.json 中定义了插件运行的入口文件,也就是 main 字段,默认值为 ./out/extension.js,所以如果不先编译生成 out/ 文件是会运行报错的。

{
  "main": "./out/extension.js",
}

有两种启动调试的方式:

  1. 按快捷键 F5
  2. 点击编辑器左下方的 Run Extension(插件名)

启动后,会自动打开一个新的 VS Code 窗口,并启动调试模式,我们可以在该窗口中调试插件。

先按 Ctrl/Command + Shift + P,输入 hello word,会发现找不到命令。

这里是因为我们本地安装的 vscode 版本不符合插件要求,我们在 package.json 找到如下配置:

{
  "engines": {
    "vscode": "^1.107.0"
  },
}

然后我们再查看下本机的 vscode 版本,Code -> About VisualStdio Code

发现我们本地版本为 1.105.1,不满足 ^1.107.0,于是我们把 engines.vscode 修改为 ^1.105.0

{
  "engines": {
    "vscode": "^1.105.0"
  },
}

再通过 F5 或者左下角 Run Extension 启动调试,按 Ctrl/Command + Shift + P,输入hello world,就能看到命令了。这个 hello world 就对应我们上面说的 package.json 文件中的 contributes.commands.title 字段。

成功运行后,会在编辑器右下角看到如下提示:

这个提示就是对应 src/extension.ts 的如下代码:

然后我们就可以开始编写插件的具体功能了。

3、编写插件核心逻辑

我们来实现一个简单的功能,平时我们写代码时,经常会写 console.log,如果每次都写这个太麻烦了,我们希望能实现下面的功能:

  1. 通过快捷键,可以快速生成 console.log 语句。
  2. 光标选中变量并按下快捷键时,能把变量名也显示出来。

3.1 修改 package.json,定义插件名字、描述和快捷键

定义名字 name,描述 description,快捷键 keybindings,通过 ctrl/command+alt+l 可以运行插件。

{
  "name": "log-util",
  "displayName": "log-util",
  "description": "generate log statement",
   "contributes": {
    "commands": [
      {
        "command": "log-util.logUtil",
        "title": "log util"
      }
    ],
    "keybindings": [
      {
        "command": "log-util.logUtil",
        "key": "ctrl+alt+l",
        "mac": "cmd+alt+l",
        "when": "editorTextFocus"
      }
    ]
  },
}

3.2 核心功能代码

修改 src/extension.ts 文件,实现插件功能。

先实现第一步,快速生成 console.log 语句。

import * as vscode from "vscode";

export function activate(context: vscode.ExtensionContext) {
  const disposable = vscode.commands.registerCommand(
    "log-util.logUtil",
    async () => {
      const editor = vscode.window.activeTextEditor;
      if (!editor) {
        vscode.window.showWarningMessage("没有打开的编辑器");
        return;
      }
      // 获取配置
      const getConfig = () =>
        vscode.workspace.getConfiguration("consoleLogGenerator");
      const config = getConfig();
      const logType = config.get<string>("logType", "log");
      const prefix = config.get<string>("logPrefix", "🚀 ~ ");

      const position = editor.selection.active;

      const document = editor.document;
      const selection = editor.selection;
      const text = document.getText(selection);

      const statement = `console.${logType}('${prefix}:', );`;

      editor
        .edit((editBuilder) => {
          const insertPosition = new vscode.Position(position.line, position.character + 1);
          editBuilder.insert(insertPosition, statement);
        })
        .then((success) => {
          if (success) {
            // 设置光标位置
            const newPosition = new vscode.Position(position.line, statement.length + position.character - 2);
            editor.selection = new vscode.Selection(newPosition, newPosition);
          }
        });
    }
  );

  context.subscriptions.push(disposable);
}

export function deactivate() {}

然后实现第二步,增加对光标选中变量的处理。

 // 获取变量名
+   let variableName = text.trim();
-   const statement = `console.${logType}('${prefix}:', );`;
+   const statement = `console.${logType}('${prefix}${variableName ? ` →  ${variableName}` : ''}:', ${variableName});`;
  
    editor.edit(editBuilder => {
-     const insertPosition = new vscode.Position(position.line, position.character + 1);
+     const insertPosition = new vscode.Position(position.line + (variableName ? 1 : 0), variableName ? 0 : position.character + 1);
+     if (variableName) {
+       editBuilder.insert(insertPosition, '\n');
+     }
      editBuilder.insert(insertPosition, statement);
    }).then(success => {
        if (success) {
          // 设置光标位置
-         const newPosition = new vscode.Position(position.line, statement.length + position.character - 2);
+         const newPosition = new vscode.Position(position.line + (variableName ? 1 : 0), variableName ? statement.length : statement.length + position.character - 2);
          editor.selection = new vscode.Selection(newPosition, newPosition);
}
});

测试插件功能:

const path = require('path')
console.log('🚀 ~  →  path:', path); // 光标选中上一行的 path 变量,按 ctrl/command+alt+l 生成
console.log('🚀 ~ :', ); // 在当前行按 ctrl/command+alt+l 生成

效果如下:

4、发布插件

  1. 进入这个网站,点击 Sign in,先注册一个开发者账号。

  2. 点击 Public extensions,按照提示一步步发布即可。

小结

本文主要介绍了如何编写一个简单的 VSCode 插件,从开发到发布全流程:

  1. 安装 yogenerator-code
  2. 运行 yo code 命令生成插件项目,这里可以选择插件的开发语言,定义插件的名称、描述等。
  3. 修改 package.json 文件,可以修改插件的名称、描述、入口文件,定义插件的快捷键。并要注意设置 engines.vscode 字段,指定插件支持的 VSCode 版本。
  4. src/extension.ts 文件中编写插件逻辑。
  5. 运行 npm run compile 编译插件,或者运行 npm run watch 监听文件变化并编译。
  6. 按快捷键 F5,或者点击编辑器左下方的 Run Extension 进行插件调试。
  7. 在调试的新窗口中,使用 Ctrl/Command + Shift + P,输入配置的命令,或者按配置的快捷键,测试插件功能是否正常。
  8. 运行 npm run test 测试插件功能。(本文未涉及到单元测试)
  9. 发布插件。

希望本文对你有所帮助,如果你有任何问题,欢迎在评论区留言。

❌
❌