阅读视图

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

在线CAD开发包结构与功能说明

一、MxDraw云图开发包是什么

云图开发包是一个围绕 MxCAD 构建的完整 CAD 云化解决方案工程集合。 它不是单一 SDK,而是将 后台图纸转换、服务接口、前端项目示例、MxCAD 编辑与浏览能力 统一打包的一套工程。对新手来说,可以这样理解:云图开发包已经帮你把“一个 CAD 云系统”拆分好、放在了对应目录中。

说明: 云图开发包会根据不同操作系统(如 Windows、Linux 等)提供对应版本,开发包在可执行程序形式、部署方式及启动方式上可能存在差异。但无论运行于何种操作系统,云图开发包在功能层面与整体架构设计上保持一致,目录职责划分、核心能力以及使用方式不受平台影响,本文档中的架构与说明均适用于所有操作系统版本。


二、整体目录结构总览

MxDraw云图开发包的根目录为:

Windows:

MXDRAWCLOUDSERVER1.0_XXX_TRYVERSION (其中XXX为云图开发包版本号)
└─ MxDrawCloudServer   (云图开发包根目录)

image-20260205150120186.png Linux:

MXDRAWCLOUDSERVER1.0_XXX_xxx_TRYVERSION (其中XXX为云图开发包版本号,xxx为对应的操作系统)
└─ install
   └─ MxDrawCloudServer   (云图开发包根目录)

image-20260205153825135.png

从功能角度看,目录可以分为三大块:

  1. Bin:后台服务与核心能力

  2. SRC:前端项目与示例源码

  3. Mx3dServer.exe:启动服务和演示页面(Windows)

    start_demo.sh:启动服务和演示页面(Linux)

新手理解云图开发包,只要先理解这三块的分工,就不会迷路。


三、Bin 目录:后台服务相关目录(核心)

Windows:

MxDrawCloudServer
└─ Bin
   └─ MxCAD   (图纸转换程序目录)
   └─ MxDrawServer   (MxCAD 项目的后台服务目录)
   └─ MxServiceCode   (Node.js 服务代码目录)

image-20260205154556643.png

Linux:

MxDrawCloudServer
└─ Bin
   └─ Linux   
       └─ MxCAD   (图纸转换程序目录)
       └─ MxDrawServer   (MxCAD 项目的后台服务目录)
   └─ MxServiceCode   (Node.js 服务代码目录)

image-20260205154415049.png

image-20260205154314994.pngBin 是云图开发包中最核心的目录,承载了 CAD 云化所必需的后台能力。

1. MxCAD —— 图纸转换程序目录

  • 用于 CAD 图纸的转换处理
  • 将 DWG / DXF 等原始图纸的格式转换
  • 是云图系统能够“在线显示 CAD”的前提条件

没有这个目录下对应的转换程序,前端无法直接展示编辑 CAD 图纸。


2. MxDrawServer —— MxCAD 项目的后台服务目录

  • 提供 MxCAD 项目内部所需的后台接口服务
  • 为 CAD 图纸加载、处理、交互等能力提供服务支持
  • 属于 MxCAD 工程体系的一部分

这是连接“图纸数据”和“CAD 功能”的关键后台模块。


3. MxServiceCode —— Node.js 服务代码目录

  • 基于 Node 的 后台服务代码
  • 用于对外提供后台图纸处理服务接口
  • 常用于后台图纸处理,如后台参数化绘图,图纸数据提取,图纸拆分等

对新手而言,这是理解“云图后台如何工作”的最佳入口。


四、SRC 目录:前端项目相关目录

Winodws/Linux:

MxDrawCloudServer
└─ SRC
   └─ sample   (前端项目示例代码目录)
   └─ TsWeb   (云服务的web前端门户)
   └─ doc   (文档目录)

image-20260205160852318.png

SRC 目录是 MxDraw 云图开发包中面向开发者的核心区域,包含了所有可开放的前端示例项目源码、集成模板及配套文档。无论你是要快速体验功能,还是进行深度二次开发,都应从此目录入手。


1. doc —— 文档目录(默认为空文件夹)

  • 存放与前端项目相关的说明文档、API 手册或集成指南。
  • 开发者可在此补充自定义说明,辅助团队协作或项目交接。

2. sample —— 前端项目示例代码目录(重点)

Winodws/Linux:

MxDrawCloudServer
└─ SRC
   └─ app   (mxcad-app 在不同架构项目下的集成示例)
   └─ BrowseCAD浏览版项目源码目录)
   └─ EditCAD编辑版项目源码目录)   
   └─ GISCAD+GIS结合项目源码目录)

image-20260206142024797.png

该目录提供了多种典型应用场景的完整前端工程示例,覆盖浏览、编辑、3D、GIS 等核心能力,是新手学习和项目参考的最佳入口。

(1)app —— 集成 mxcad-app 依赖包的CAD编辑项目示例源码

Winodws/Linux:

MxDrawCloudServer
└─ SRC
   └─ sample   (前端项目示例代码目录) 
       └─ app   (mxcad-app 在不同架构项目下的集成示例源码)
          └─ MxCADAppVue2+Webpack)
          └─ plugins   (项目插件目录)
             └─ pluginAiChat   (AI模块)
          └─ sample
             └─ webapack4 
             └─ html+js
             └─ vite+vue3
             └─ webapck+react
             └─ cnd.html

image-20260206142546094.png

提供在不同前端技术栈下集成 mxcad-app 的标准方式:

  • MxCADApp:基于 Vue2 + Webpack 的完整编辑器项目
  • plugins:内置插件扩展机制,如 pluginAiChat(AI 对话模块)
  • 多框架适配示例:包含 vite+vue3webpack+reacthtml+js 及 CDN 引入方式(cnd.html

(2)Browse —— CAD 浏览版项目源码目录

Winodws/Linux:

MxDrawCloudServer
└─ SRC
   └─ sample   (前端项目示例代码目录) 
      └─ BrowseCAD 浏览版项目示例)
          └─ 2d 
             └─ Browseiframe   (iframe嵌套集成示例)
             └─ BrowseCAD浏览版项目源码目录)

image-20260206142808876.png

专注于图纸查看场景,支持轻量级部署:

  • 2d/Browse:纯 2D 图纸浏览页面
  • 2d/Browseiframe:通过 iframe 嵌套集成的浏览模式,便于嵌入第三方系统

(3)Edit—— CAD 编辑版项目源码目录

Winodws/Linux:

MxDrawCloudServer
└─ SRC
   └─ sample   (前端项目示例代码目录) 
      └─ EditCAD 编辑版项目示例)
          └─ 2d  (二维图纸项目)
             └─ dist   (MxCAD APP 的静态资源包)
             └─ MxCADMxCAD APP 中的一个插件源码目录)
             └─ MxCADiframe   (iframe嵌套集成示例)
          └─ 3d  (三维图纸项目)
             └─ dist   (3D项目的静态资源包) 
             └─ MxCAD   (3D项目中的一个插件源码目录) 

image-20260206144840098.png

image-20260206145023459.png

提供完整的在线编辑能力,包含二维与三维模式:

  • Edit/2d:2D 图纸编辑环境,含工具栏、属性面板等
  • Edit/3d:3D 模型查看与基础操作界面
  • dist 子目录:预编译的静态资源包,可直接部署到 Web 服务器

(4)GIS —— CAD+GIS项目源码目录

Winodws/Linux:

MxDrawCloudServer
└─ SRC
   └─ sample   (前端项目示例代码目录) 
       └─ GIS
          └─ MxCADMapGIS+CAD项目源码目录) 

image-20260206145149875.png

展示 MxCAD 与地理信息系统(GIS)的集成方案:

  • MxCADMap:将 CAD 图形叠加到地图底图上,实现空间数据联动分析

各子项目均采用模块化设计,开发者可按需复制、修改或组合使用,极大降低集成门槛

五、Mx3dServer.exe/start_demo.sh:Mxdraw云图启动入口

Mx3dServer.exe(Windows)与 start_demo.sh(Linux)是 MxDraw 云图开发包的统一启动入口,用于一键初始化整个 CAD 云服务环境。它们屏蔽了后台服务配置、端口绑定、依赖启动等复杂细节,让开发者或用户只需“双击”或“执行脚本”即可进入演示状态。

1. Mx3dServer.exe:梦想云图服务启动程序(图形化入口)

Mx3dServer.exe 是 MxDraw 云图开发包在 Windows 平台上的图形化启动程序,双击运行后将自动弹出“梦想云图服务启动程序”窗口。该程序集成了多模块服务的统一管理与快速访问功能,极大简化了部署流程,让开发者与用户无需手动配置即可一键开启完整的 CAD 在线演示环境。

image-20260206145602924.png

  • 开始Web服务

    当你点击 “开启Web服务” 按钮时,MxDraw 会自动启动两个关键的本地服务程序。这两个服务协同工作,共同支撑起完整的在线 CAD 功能体验。

image-20260206155506005.png

  • 第一个服务(端口 1337):CAD 核心引擎

    该服务由 Bin/MxDrawServer/Windows/app.js 脚本启动,是 MxDraw 的“大脑”。它负责处理所有与图纸相关的底层操作,例如打开 DWG 文件、解析图形数据、保存编辑结果等。虽然你看不到它的界面,但所有 CAD 功能都依赖它来完成。 image-20260206155724387.png

  • 第二个服务(端口 3000):Web 前端服务器

    该服务由 SRC/TsWeb/app.js 脚本启动,是用户的“操作窗口”。它基于 Express 框架构建,负责托管所有网页文件(如 2D 编辑器、3D 查看器、文件浏览器等),并将你的操作请求转发给 CAD 引擎。你看到的界面、按钮、工具栏,都由这个服务提供。
    image-20260206155744533.png

  • 启动浏览器查看演示

    自动调用系统默认浏览器(推荐 Chrome 或 Edge)打开首页地址 http://localhost:3000,快速进入演示环境。 image-20260206160529645.png

  • VueBrowse

    启动基于 Vue 框架的图纸浏览项目。 image-20260206160655084.png

  • Browseiframe

    iframe 嵌入模式加载 CAD 浏览页面,便于集成到第三方系统或企业门户中。

  • 启动MxCAD

    打开 2D CAD 在线编辑器,支持绘图、修改、标注、上传、保存等完整编辑功能,适用于工程设计场景。 image-20260206160845969.png

  • 启动MxCAD3D 启动 3D CAD 查看器,基于 WebGL 渲染三维模型。 image-20260206161020003.png

  • MxCAD GIS 启动 CAD 与 GIS 融合应用,将 CAD 图纸叠加至地图底图,实现空间数据联动分析。 image-20260206161958156.png

  • CAD GIS image-20260206162058664.png

  • 打开GIS DEMO目录

    直接打开本地 GIS 示例项目的文件夹,方便查看相关代码与数据资源。 image-20260206162157015.png

  • NodeJs服务测试

    打开 http://localhost:1337/serverTest 页面,提供一键调用 DWG 转换、PDF 导出、图层读取等核心 CAD 接口的可视化测试功能。 image-20260206162352393.png

  • 打开MxCAD代码开发目录

    跳转至 SRC/sample/app/MxCADApp 目录,供开发者参考完整的 Vue + TypeScript 集成项目源码。 image-20260206162440234.png

  • 打开Browse代码开发目录

    跳转至 SRC/sample/Browse 目录,查看图纸浏览类项目的前端实现逻辑。
    image-20260206162528958.png

  • 转换DWG到梦想文件格式

    启动 DWG 格式转换工具,将标准 AutoCAD DWG 文件批量转换为 MxDraw 专用的 .mxweb 格式,提升加载速度与兼容性。 image-20260206162607544.png

  • 关于

    显示软件版本号、版权信息。 image-20260208101603111.png

  • 退出

    关闭启动程序窗口。

提示:首次运行时,请在 Windows 防火墙中允许 Mx3dServer.exe 的网络访问权限,以确保服务可被正常连接。建议使用最新版 Chrome 或 Edge 浏览器获得最佳体验。

2. start_demo.sh:Linux平台云图服务启动脚本

start_demo.sh 是 MxDraw 云图开发包在 Linux 系统下的标准启动脚本,用于一键初始化完整的 Web CAD 演示环境。其功能与 Windows 平台的 Mx3dServer.exe 完全对等,确保跨平台体验一致。

核心作用

  • 同时启动两个关键服务:
    • CAD 核心服务(Node.js):运行于 1337 端口,提供 DWG 解析、绘图命令执行、格式转换等底层能力;
    • Web 前端服务(Express):运行于 3000 端口,托管所有演示页面(如 2D 编辑器、3D 查看器、文件浏览器等)。
  • 自动配置服务路径与依赖,无需手动执行多条命令。

使用步骤

  1. 提前查看LinuxDemo启动说明 参照《LinuxDemo启动说明.txt》执行权限设置运行。 image-20260208102624599.png

  2. 执行启动脚本

    ./start_demo.sh
    
  3. 访问演示页面 服务启动成功后,在浏览器中打开:

    • 首页:http://localhost:3000
    • 2D 编辑:http://localhost:3000/mxcad
    • 3D 查看:http://localhost:3000/mxweb3d.html
    • 文件浏览:http://localhost:3000/browse

注意事项

  • 脚本默认以后台方式启动服务,若需调试可修改脚本移除 & 符号以查看实时日志;
  • 若端口被占用,可编辑脚本中的 PORT 变量进行调整;

提示:尽管无图形界面,start_demo.sh 提供了与 Windows .exe 相同的功能完整性,是 Linux 开发者快速验证和集成 MxDraw 云图能力的标准入口。

typescript常用的dom 元素类型

在 TypeScript 中处理 DOM 操作时,有一整套完善的类型系统。下面我将系统地整理前端开发中最常用的 DOM 元素类型。

🎯 基础 DOM 类型体系

1. 顶层类型

// 所有 DOM 节点的基类
let node: Node = document.createElement('div')

// 所有 HTML 元素的基类
let element: Element = document.querySelector('div')!

// 所有具体的 HTML 元素都继承自 HTMLElement
let htmlElement: HTMLElement = document.createElement('div')

// 文档对象
let doc: Document = document

// 窗口对象
let win: Window = window

📦 常用具体元素类型

1. 输入控件类

// 输入框
let input: HTMLInputElement = document.createElement('input')
input.value = 'hello'
input.checked = true
input.type = 'password'
input.placeholder = '请输入'
input.files // FileList | null

// 文本域
let textarea: HTMLTextAreaElement = document.createElement('textarea')
textarea.value = '多行文本'
textarea.rows = 5
textarea.cols = 30

// 按钮
let button: HTMLButtonElement = document.createElement('button')
button.disabled = true
button.type = 'submit'

// 选择框
let select: HTMLSelectElement = document.createElement('select')
let option: HTMLOptionElement = document.createElement('option')
select.value = 'option1'
select.selectedIndex = 0
select.options // HTMLOptionsCollection

2. 容器和布局类

// 通用块级容器
let div: HTMLDivElement = document.createElement('div')
let span: HTMLSpanElement = document.createElement('span')
let section: HTMLElement = document.createElement('section')  // 直接用 HTMLElement
let article: HTMLElement = document.createElement('article')

// 列表
let ul: HTMLUListElement = document.createElement('ul')
let ol: HTMLOListElement = document.createElement('ol')
let li: HTMLLIElement = document.createElement('li')

// 表格相关
let table: HTMLTableElement = document.createElement('table')
let tr: HTMLTableRowElement = document.createElement('tr')
let td: HTMLTableCellElement = document.createElement('td')
let th: HTMLTableCellElement = document.createElement('th')
let tbody: HTMLTableSectionElement = document.createElement('tbody')
let thead: HTMLTableSectionElement = document.createElement('thead')

// 表单
let form: HTMLFormElement = document.createElement('form')
form.action = '/submit'
form.method = 'POST'
form.elements // HTMLFormControlsCollection

3. 媒体类

// 图片
let img: HTMLImageElement = document.createElement('img')
img.src = '/image.jpg'
img.alt = '描述'
img.width = 100
img.height = 100
img.complete // 图片是否加载完成

// 音频
let audio: HTMLAudioElement = document.createElement('audio')
audio.src = '/audio.mp3'
audio.volume = 0.5
audio.play()
audio.pause()

// 视频
let video: HTMLVideoElement = document.createElement('video')
video.src = '/video.mp4'
video.poster = '/poster.jpg'
video.width = 640
video.height = 360
video.playbackRate = 1.5

// 画布
let canvas: HTMLCanvasElement = document.createElement('canvas')
let ctx: CanvasRenderingContext2D | null = canvas.getContext('2d')

4. 链接和元数据类

// 链接
let a: HTMLAnchorElement = document.createElement('a')
a.href = 'https://example.com'
a.target = '_blank'
a.download = 'file.pdf'

// 图片链接
let area: HTMLAreaElement = document.createElement('area')
area.shape = 'rect'
area.coords = '0,0,100,100'

// Meta 信息
let meta: HTMLMetaElement = document.createElement('meta')
meta.name = 'description'
meta.content = '页面描述'

// 链接资源
let link: HTMLLinkElement = document.createElement('link')
link.rel = 'stylesheet'
link.href = '/style.css'

🎨 特定功能的元素类型

1. 进度和度量

// 进度条
let progress: HTMLProgressElement = document.createElement('progress')
progress.value = 50
progress.max = 100

// 度量
let meter: HTMLMeterElement = document.createElement('meter')
meter.value = 0.6
meter.min = 0
meter.max = 1
meter.low = 0.3
meter.high = 0.8
meter.optimum = 0.5

2. 嵌入内容

// iframe
let iframe: HTMLIFrameElement = document.createElement('iframe')
iframe.src = '/other-page.html'
iframe.contentWindow // Window | null
iframe.contentDocument // Document | null

// 嵌入对象
let object: HTMLObjectElement = document.createElement('object')
object.data = '/file.pdf'
object.type = 'application/pdf'

// 嵌入脚本
let script: HTMLScriptElement = document.createElement('script')
script.src = '/main.js'
script.async = true
script.defer = true

3. 表单特有元素

// 单选/复选
let radio: HTMLInputElement = document.createElement('input')
radio.type = 'radio'
radio.name = 'gender'
radio.value = 'male'
radio.checked = true

let checkbox: HTMLInputElement = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.checked = true
checkbox.indeterminate = false

// 文件上传
let fileInput: HTMLInputElement = document.createElement('input')
fileInput.type = 'file'
fileInput.multiple = true
fileInput.accept = 'image/*'

// 隐藏输入
let hidden: HTMLInputElement = document.createElement('input')
hidden.type = 'hidden'
hidden.value = 'secret-data'

// 颜色选择器
let color: HTMLInputElement = document.createElement('input')
color.type = 'color'
color.value = '#ff0000'

// 范围滑块
let range: HTMLInputElement = document.createElement('input')
range.type = 'range'
range.min = 0
range.max = 100
range.step = 5
range.value = '50'

🔍 类型查询和断言

1. 获取 DOM 元素

// 类型断言
const canvas = document.getElementById('canvas') as HTMLCanvasElement
const input = <HTMLInputElement>document.querySelector('input[type="text"]')

// 安全的类型检查
function isInputElement(el: HTMLElement): el is HTMLInputElement {
  return el.tagName === 'INPUT'
}

// 使用实例
const element = document.getElementById('myElement')
if (element instanceof HTMLInputElement) {
  element.value // TypeScript 知道这是 input
} else if (element instanceof HTMLTextAreaElement) {
  element.value // 这也是 input 类型
}

// 更好的方式:使用类型守卫
function getInput(id: string): HTMLInputElement | null {
  const el = document.getElementById(id)
  return el instanceof HTMLInputElement ? el : null
}

2. 集合类型

// NodeList
const nodes: NodeListOf<HTMLElement> = document.querySelectorAll('.item')
nodes.forEach(node => node.style.color = 'red')

// HTMLCollection
const forms: HTMLCollectionOf<HTMLFormElement> = document.forms
const images: HTMLCollectionOf<HTMLImageElement> = document.images

// 特定类型的集合
const allInputs = document.querySelectorAll<HTMLInputElement>('input')
// allInputs 的类型是 NodeListOf<HTMLInputElement>

// 类型化的集合
interface MyElements {
  'username': HTMLInputElement
  'submitBtn': HTMLButtonElement
  'avatar': HTMLImageElement
}

function getElement<K extends keyof MyElements>(id: K): MyElements[K] | null {
  return document.getElementById(id) as MyElements[K] | null
}

⚡ 事件对象类型

1. 常见事件类型

// 鼠标事件
function handleMouse(e: MouseEvent) {
  e.clientX, e.clientY  // 鼠标坐标
  e.button  // 按下的鼠标键
  e.ctrlKey, e.shiftKey  // 修饰键
}

// 键盘事件
function handleKey(e: KeyboardEvent) {
  e.key  // 按下的键值
  e.code  // 物理按键代码
  e.altKey  // 是否按下 Alt
  e.repeat  // 是否长按重复
}

// 焦点事件
function handleFocus(e: FocusEvent) {
  e.relatedTarget  // 上一个/下一个焦点元素
}

// 表单事件
function handleSubmit(e: Event) {
  e.preventDefault()
  const form = e.target as HTMLFormElement
}

// 拖拽事件
function handleDrag(e: DragEvent) {
  e.dataTransfer?.setData('text/plain', 'data')
}

// 剪贴板事件
function handlePaste(e: ClipboardEvent) {
  const text = e.clipboardData?.getData('text/plain')
}

2. Vue 3 中的事件类型

<script setup lang="ts">
// 原生 DOM 事件
const handleClick = (e: MouseEvent) => {
  console.log(e.clientX)
}

// Input 事件
const handleInput = (e: Event) => {
  const target = e.target as HTMLInputElement
  console.log(target.value)
}

// Change 事件
const handleChange = (e: Event) => {
  const target = e.target as HTMLSelectElement
  console.log(target.value)
}

// 键盘事件
const handleKeyDown = (e: KeyboardEvent) => {
  if (e.key === 'Enter') {
    console.log('回车键按下')
  }
}

// 自定义组件事件
interface Emits {
  (e: 'update', value: string): void
  (e: 'close', reason: 'click' | 'esc'): void
}

const emit = defineEmits<Emits>()
</script>

<template>
  <input @click="handleClick">
  <input @input="handleInput">
  <select @change="handleChange">
    <option value="1">选项1</option>
  </select>
  <input @keydown="handleKeyDown">
</template>

🛠️ 实用工具类型

1. 内置的 DOM 工具类型

// 获取元素属性的类型
type InputValue = HTMLInputElement['value']  // string
type ButtonType = HTMLButtonElement['type']  // 'button' | 'submit' | 'reset'
type ElementTag = HTMLElement['tagName']  // string

// Partial 应用于 DOM 配置
interface CanvasConfig {
  width: number
  height: number
  context: CanvasRenderingContext2D
}
const config: Partial<CanvasConfig> = { width: 800 }

// 只读 DOM 引用
const readonlyElement: Readonly<HTMLElement> = document.body
// readonlyElement.innerHTML = '' // ❌ 错误

2. 自定义 DOM 类型

// 自定义数据属性
interface DatasetMap {
  userId: string
  role: 'admin' | 'user'
  theme: 'light' | 'dark'
}

function setDataset<T extends HTMLElement>(
  el: T,
  data: Partial<{ [K in keyof DatasetMap]: string }>
) {
  Object.entries(data).forEach(([key, value]) => {
    el.dataset[key] = value
  })
}

const div = document.createElement('div')
setDataset(div, { userId: '123', role: 'admin' })
// div.dataset.userId 有类型提示

// 带状态的自定义元素
interface StatefulElement<T> extends HTMLElement {
  state: T
  setState: (newState: Partial<T>) => void
}

interface ButtonState {
  loading: boolean
  count: number
}

const btn = document.createElement('button') as StatefulElement<ButtonState>
btn.state = { loading: false, count: 0 }
btn.setState({ loading: true })

📝 实际开发模式

1. 工厂函数模式

// 类型安全的元素创建
function createElement<K extends keyof HTMLElementTagNameMap>(
  tagName: K,
  props?: Partial<HTMLElementTagNameMap[K]>
): HTMLElementTagNameMap[K] {
  const el = document.createElement(tagName)
  if (props) {
    Object.assign(el, props)
  }
  return el
}

// 使用示例
const button = createElement('button', {
  textContent: '点击',
  disabled: false,
  className: 'btn-primary'
})

const input = createElement('input', {
  type: 'email',
  placeholder: '输入邮箱',
  value: ''
})

2. 类型守卫工具

// DOM 类型守卫集合
const domGuards = {
  isInput: (el: Element | null): el is HTMLInputElement => 
    el?.tagName === 'INPUT',
  
  isButton: (el: Element | null): el is HTMLButtonElement => 
    el?.tagName === 'BUTTON',
  
  isSelect: (el: Element | null): el is HTMLSelectElement => 
    el?.tagName === 'SELECT',
  
  isTextArea: (el: Element | null): el is HTMLTextAreaElement => 
    el?.tagName === 'TEXTAREA',
  
  isForm: (el: Element | null): el is HTMLFormElement => 
    el?.tagName === 'FORM'
}

// 使用
const element = document.getElementById('myInput')
if (domGuards.isInput(element)) {
  element.value = '类型安全'
}

3. Vue 3 中的 DOM 模板引用

<script setup lang="ts">
import { ref, onMounted } from 'vue'

// 基本元素引用
const inputRef = ref<HTMLInputElement | null>(null)
const divRef = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)

// 多个元素引用
const itemRefs = ref<HTMLElement[]>([])

// 组件引用
import Modal from './Modal.vue'
const modalRef = ref<InstanceType<typeof Modal> | null>(null)

onMounted(() => {
  // 类型安全的方法调用
  inputRef.value?.focus()
  canvasRef.value?.getContext('2d')
  modalRef.value?.open()
})
</script>

<template>
  <input ref="inputRef" type="text">
  <div ref="divRef" class="container"></div>
  <canvas ref="canvasRef"></canvas>
  
  <!-- 多个元素 -->
  <div 
    v-for="item in 5" 
    :ref="el => itemRefs.push(el as HTMLElement)"
    :key="item"
  >
    Item {{ item }}
  </div>
  
  <Modal ref="modalRef" />
</template>

📊 类型层次结构

EventTarget (所有事件目标的基类)
    ├── Node (所有 DOM 节点的基类)
    │   ├── Element (所有元素的基类)
    │   │   ├── HTMLElement (HTML 元素的基类)
    │   │   │   ├── HTMLInputElement
    │   │   │   ├── HTMLButtonElement
    │   │   │   ├── HTMLDivElement
    │   │   │   └── ...
    │   │   └── SVGElement (SVG 元素)
    │   ├── Text (文本节点)
    │   └── Comment (注释节点)
    ├── Window (窗口对象)
    └── Document (文档对象)

掌握这些 DOM 类型,可以让你在处理浏览器 API 时获得完整的类型支持和智能提示,减少运行时错误。

春节后,有些公司明确要求 AI 经验了

大家好,我是拭心。

昨天晚上,有个群友说:

我看 boss 直聘已经有些公司明确要求要 AI 经验了,之前是大厂先搞,现在中小开始反应过来了。

是的,这个招聘趋势已经越来越明显。不只是招聘,春节以后,很多公司推 AI 的力度也变得更强,从可选变成强制。

babaeae4cc2c5a2f392fe359f41401c8.jpg

根据 BS 招聘网站发布的数据,近 20% 的非 AI 专业岗位明确要求具备 AI 能力,这个数字在 2024 年还只有 12%。

两年时间,AI 技能从「加分项」跃升为「硬指标」

这篇文章,我们来看看 Android、前端、后端三个方向,招聘市场究竟在发生什么变化。


一、Android 岗位:AI 赋能移动端,薪资溢价明显

移动端开发曾经是一片红海,但 AI Native 应用的兴起,正在重新定义这个赛道。

大厂们不再满足于把 AI 功能“接进来用用”,而是要从架构层就以 AI 为核心来设计产品。

来看一个典型的岗位:

Android 开发工程师 - AI 创新应用

某知名电子商务公司(上海)

薪资:30-60K · 14 薪(年薪约 42-84 万)

要求:3-5 年经验,本科;

负责从 0 到 1 创造「AI Native」应用,结合 AI 模型能力打造优秀用户体验;深入掌握 Java/Kotlin,熟悉性能分析和优化方法

注意这里的关键词:AI Native。不是“会用 AI 工具”,不是“了解 LLM 概念”,而是要求从底层就以 AI 为出发点来设计应用。

这和传统 Android 岗位的差距有多大?

传统 JD 写的是「熟悉 Jetpack、掌握性能优化」,而 AI Native 岗位写的是「结合 AI 模型能力打造用户体验」。

前者考察的是工程实现能力,后者考察的是对模型能力的理解——你得知道大模型能做什么、不能做什么,才能在产品设计阶段就做出正确的判断,而不是在开发到一半时发现模型根本撑不起这个交互逻辑。

薪资上限 84 万,对应的正是这层能力溢价。同样是 3-5 年经验的 Android 工程师,懂 AI 和不懂 AI,市场给出的价格已经开始分叉


二、前端岗位:AI 产品化需求爆发,技术门槛提升

前端这个方向,变化可能是最剧烈的。

以往前端更多做展示,但 AI Agent、多模态交互的爆发,把前端工程师直接推到了 AI 应用落地的最前线。

来看三个岗位:

前端开发工程师 - AI 创新应用 P6+

某知名大型电子商务公司(上海 / 北京)

薪资:35-65K(年薪约 42-78 万)

要求:3-5 年经验,本科;了解 AI 和机器学习概念并集成到生产环境;有 webpack、React Native、小程序、后端(Python/Java)相关开发经验

「集成到生产环境」是关键——不是做个 demo,而是要真正跑在线上。

这意味着你得懂模型调用的稳定性、延迟控制、降级策略,工程能力和 AI 能力缺一不可。

AI 前端专家

某知名大型电子商务公司(北京)

薪资:50-80K · 16 薪(年薪约 80-128 万)

要求:5-10 年经验,本科;负责 AI 助理生产力平台产品迭代和技术架构精进;有 AI 相关产品研发经验,熟悉 React/Vue 等主流框架

从「集成 AI 功能」到「负责 AI 平台的技术架构」,这一级跳跃对应的是年薪从 78 万到 128 万。

最值得关注的,是 Agent 工程师这个新职位正在快速成型:

Agent 工程师

某上海知名互联网上市公司(北京)

薪资:40-70K · 15 薪(年薪约 60-105 万)

要求:3-5 年经验,本科;专注于 AI Agent / AI App 方向;有 Web 应用端到端性能优化经验,熟悉 Hybrid 容器技术者优先

从 P6+ 到 AI 前端专家,薪资从 78 万跨越到 128 万,差距的核心只有一个:有没有 AI 产品研发经验

“Agent 前端工程师”这个新职位,一年前几乎不存在,现在已经成为大厂争抢的稀缺岗位,年薪 60-105 万。

前端的边界在 AI 时代被彻底重新定义了


三、后端岗位:AI 工程化落地,架构能力成核心

后端方向的变化同样剧烈。

AI 模型本身不难调用,真正难的是把它做成稳定可靠的生产级系统——高可用、低延迟、可扩展。

这恰好是后端工程师的主场,但前提是你得先懂 AI。

AI 应用后端工程师

某大型互联网上市公司(北京)

薪资:25-50K · 16 薪(年薪约 40-80 万)

要求:3-5 年经验,本科;负责 AI Agent 核心架构设计与开发,构建高可用、低延迟的分布式系统;精通 Java/Python,熟悉 gRPC、Kafka、Redis 等中间件;熟悉 LangChain、AutoGPT 等 Agent 框架优先

高级后端开发工程师 - AI 平台开发

阿里巴巴集团(杭州余杭区)

薪资:40-70K · 16 薪(年薪约 64-112 万)

要求:1-3 年经验,本科;专注于 AI 平台开发;参与过分布式 / 高并发场景系统设计优先

注意阿里这个岗位:1-3 年经验,但年薪最高 112 万

经验要求低,薪资反而高——这说明懂 AI 平台开发的工程师太稀缺了,公司宁愿给高薪也要抢到人。


四、岗位的共同点

把这些岗位放在一起看,有几个规律非常明显:

AI 技能是涨薪的最短路径。 同样是 3-5 年经验,普通 Android 岗位薪资上限约 30K,AI Native Android 岗位上限直接到 60K,溢价接近一倍。后端方向,阿里 AI 平台岗位要求仅 1-3 年经验,但年薪最高 112 万——懂 AI 的工程师太稀缺,公司宁愿用更高的薪资来弥补经验年限的不足。

Agent 开发成为新风口。 无论是前端的 Agent 前端工程师,还是后端的 AI Agent 架构师,这类岗位一年前几乎不存在,现在已经遍布大厂的 JD,年薪普遍在 60 万以上。需求爆发的速度,远远快于市场上供给人才的速度。

不是替换,是升维。 这一点值得认真说清楚——这些岗位没有一个要求你放弃原来的技术栈。JD 里写的是「精通 Java/Kotlin」「熟悉 React/Vue」「掌握 Spring Boot」,原有的工程能力仍然是基础门槛。AI 能力是叠加在上面的新一层,而不是替代。

对于已经有 3-5 年经验的开发者来说,你的积累没有白费,缺的只是补上 AI 这一层。窗口期就在当下,越早补,溢价越大。

我转型 AI 工程师的实战笔记与心血结晶都总结到这里了:《转型 AI 工程师|提升竞争力》

好了,这篇文章到这里就结束了,感谢你的阅读,愿你平安顺遂。

TS 常用工具类型

在 TypeScript 中,常用元素类型可以从两个维度来理解:一是基础数据类型(Basic Types),二是复合/高级类型(Advanced Types)。下面我将系统地整理这些类型,并附上实际开发中的使用场景。

📋 基础数据类型

1. 原始类型

// 基本类型
let isDone: boolean = false
let count: number = 42
let name: string = 'TypeScript'
let notDefined: undefined = undefined
let empty: null = null

// 特殊类型
let anything: any = '可以是任何值'  // 尽量避免使用
let unknownValue: unknown = 4       // 类型安全的 any
let nothing: void = undefined       // 函数无返回值
let neverReturns: never             // 永远不会发生的类型

2. 数组和元组

// 数组 (Array)
let numbers: number[] = [1, 2, 3]
let strings: Array<string> = ['a', 'b', 'c']
let mixed: (string | number)[] = [1, 'two', 3]

// 元组 (Tuple) - 固定长度和类型的数组
let user: [number, string, boolean] = [1, 'Alice', true]
let httpResponse: [number, string] = [200, 'OK']

// 可选元素的元组
let optionalTuple: [string, number?] = ['hello']
optionalTuple = ['hello', 123]

// 剩余元素的元组
let restTuple: [number, ...string[]] = [1, 'a', 'b', 'c']

3. 对象类型

// 简单对象类型
let point: { x: number; y: number } = { x: 10, y: 20 }

// 可选属性
let config: {
  url: string
  method?: 'GET' | 'POST'
  timeout?: number
} = { url: '/api' }

// 只读属性
let settings: {
  readonly id: string
  name: string
} = { id: '123', name: 'app' }
// settings.id = '456' // ❌ 错误

🎯 常用复合类型

1. 接口 (Interface)

// 基础接口
interface User {
  id: number
  name: string
  email: string
  age?: number  // 可选属性
  readonly createdAt: Date  // 只读属性
}

// 接口继承
interface Employee extends User {
  department: string
  salary: number
}

// 接口合并(声明合并)
interface Window {
  title: string
}
interface Window {
  width: number
}
// Window 现在有 title 和 width 两个属性

2. 类型别名 (Type Alias)

// 基本类型别名
type UserID = number | string
type Status = 'pending' | 'success' | 'error'

// 对象类型别名
type Point = {
  x: number
  y: number
}

// 联合类型
type Shape = Circle | Square | Triangle

// 交叉类型
type Draggable = { drag: () => void }
type Resizable = { resize: () => void }
type UIElement = Draggable & Resizable

// 函数类型
type Callback = (data: string) => void
type AsyncFunction<T> = () => Promise<T>

3. 函数类型

// 函数声明
function add(x: number, y: number): number {
  return x + y
}

// 函数表达式
const multiply: (x: number, y: number) => number = (x, y) => x * y

// 函数类型别名
type MathOperation = (a: number, b: number) => number
const divide: MathOperation = (a, b) => a / b

// 可选参数和默认参数
function greet(name: string, greeting: string = 'Hello'): string {
  return `${greeting}, ${name}`
}

// 剩余参数
function sum(...numbers: number[]): number {
  return numbers.reduce((acc, curr) => acc + curr, 0)
}

// 函数重载
function process(input: string): string[]
function process(input: number): number[]
function process(input: string | number): string[] | number[] {
  if (typeof input === 'string') {
    return input.split('')
  }
  return Array.from({ length: input }, (_, i) => i)
}

🚀 高级类型

1. 泛型 (Generics)

// 泛型函数
function identity<T>(arg: T): T {
  return arg
}
const num = identity<number>(42)
const str = identity('hello')  // 类型推断

// 泛型接口
interface Box<T> {
  value: T
  getValue: () => T
}

// 泛型类
class Stack<T> {
  private items: T[] = []
  
  push(item: T): void {
    this.items.push(item)
  }
  
  pop(): T | undefined {
    return this.items.pop()
  }
}

// 泛型约束
interface Lengthwise {
  length: number
}
function logLength<T extends Lengthwise>(arg: T): T {
  console.log(arg.length)
  return arg
}

2. 联合类型 (Union Types) 和交叉类型 (Intersection Types)

// 联合类型 - "或"
type ID = number | string
type Result = Success | Failure

// 类型守卫
function processId(id: ID): string {
  if (typeof id === 'string') {
    return id.toUpperCase()
  }
  return id.toString()
}

// 可辨识联合(Discriminated Unions)
interface Circle {
  kind: 'circle'
  radius: number
}
interface Square {
  kind: 'square'
  sideLength: number
}
type Shape = Circle | Square

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'square':
      return shape.sideLength ** 2
  }
}

// 交叉类型 - "且"
interface Person {
  name: string
  age: number
}
interface Employee {
  company: string
  role: string
}
type Worker = Person & Employee
// Worker 同时拥有 Person 和 Employee 的所有属性

3. 类型守卫和类型断言

// 类型守卫
function isString(value: unknown): value is string {
  return typeof value === 'string'
}

// 使用类型守卫
function process(value: string | number) {
  if (isString(value)) {
    return value.toUpperCase()  // TypeScript 知道这里是 string
  }
  return value.toFixed(2)       // TypeScript 知道这里是 number
}

// 类型断言
const canvas = document.getElementById('canvas') as HTMLCanvasElement
const input = <HTMLInputElement>document.querySelector('input')

// 非空断言
const element = document.querySelector('.exists')!
element.innerHTML = 'Hello'

// 双重断言(谨慎使用)
const expr = '42' as any as number

📦 内置工具类型

1. 常用的 Utility Types

interface Todo {
  title: string
  description: string
  completed: boolean
  createdAt: Date
}

// Partial - 所有属性可选
type PartialTodo = Partial<Todo>
// { title?: string; description?: string; completed?: boolean; createdAt?: Date }

// Required - 所有属性必选
type RequiredTodo = Required<PartialTodo>
// 全部变成必选

// Readonly - 所有属性只读
type ReadonlyTodo = Readonly<Todo>
// 不能修改属性

// Pick - 选取指定属性
type TodoPreview = Pick<Todo, 'title' | 'completed'>
// { title: string; completed: boolean }

// Omit - 排除指定属性
type TodoWithoutDate = Omit<Todo, 'createdAt'>
// { title: string; description: string; completed: boolean }

// Record - 键值对映射
type PageInfo = Record<'home' | 'about' | 'contact', { title: string }>
// {
//   home: { title: string }
//   about: { title: string }
//   contact: { title: string }
// }

// Exclude - 从联合类型中排除
type T = Exclude<'a' | 'b' | 'c', 'a'>  // 'b' | 'c'

// Extract - 提取共有类型
type T0 = Extract<'a' | 'b' | 'c', 'a' | 'f'>  // 'a'

// NonNullable - 排除 null 和 undefined
type T1 = NonNullable<string | number | null | undefined>  // string | number

// ReturnType - 获取函数返回类型
function createUser() {
  return { id: 1, name: 'John' }
}
type UserType = ReturnType<typeof createUser>  // { id: number; name: string }

// Parameters - 获取函数参数类型
type CreateUserParams = Parameters<typeof createUser>  // []

2. 条件类型

// 基础条件类型
type IsString<T> = T extends string ? true : false
type A = IsString<'hello'>  // true
type B = IsString<number>    // false

// 分布式条件类型
type ToArray<T> = T extends any ? T[] : never
type StrNumArr = ToArray<string | number>  // string[] | number[]

// infer 关键字
type UnpackPromise<T> = T extends Promise<infer U> ? U : T
type PromiseType = UnpackPromise<Promise<string>>  // string

// 内置条件类型
type NonFunction<T> = T extends Function ? never : T

🎨 实际应用场景示例

1. API 响应类型

// API 通用响应结构
interface ApiResponse<T = any> {
  code: number
  data: T
  message: string
  timestamp: number
}

// 分页数据结构
interface PaginatedData<T> {
  items: T[]
  total: number
  page: number
  pageSize: number
  hasMore: boolean
}

// 具体业务类型
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

// 组合使用
type UserListResponse = ApiResponse<PaginatedData<User>>

2. Vue 3 组合式函数类型

// 异步状态管理
interface AsyncState<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  execute: () => Promise<void>
}

// 分页状态
interface PaginationState<T> {
  list: Ref<T[]>
  currentPage: Ref<number>
  pageSize: Ref<number>
  total: Ref<number>
  loading: Ref<boolean>
  loadMore: () => Promise<void>
  refresh: () => Promise<void>
}

// 表单验证
type ValidationRule<T> = {
  validator: (value: T) => boolean
  message: string
}

type FormRules<T> = {
  [K in keyof T]?: ValidationRule<T[K]>[]
}

3. 事件处理类型

// DOM 事件
function handleClick(event: MouseEvent): void {
  console.log(event.clientX, event.clientY)
}

function handleChange(event: Event): void {
  const input = event.target as HTMLInputElement
  console.log(input.value)
}

// 自定义事件
interface CustomEvents {
  'user-login': { userId: number; timestamp: Date }
  'user-logout': { userId: number }
  'error': { message: string; code: number }
}

type EventCallback<T> = (data: T) => void

class EventEmitter {
  private events: Map<keyof CustomEvents, EventCallback<any>[]> = new Map()
  
  on<K extends keyof CustomEvents>(
    event: K,
    callback: EventCallback<CustomEvents[K]>
  ): void {
    // 实现
  }
  
  emit<K extends keyof CustomEvents>(
    event: K,
    data: CustomEvents[K]
  ): void {
    // 实现
  }
}

📌 类型定义的最佳实践

  1. 优先使用 interface 定义对象类型,特别是需要扩展的场景
  2. 使用 type 定义联合类型、交叉类型和工具类型
  3. 为所有 API 响应定义完整的类型
  4. 使用 readonly 防止意外修改
  5. 利用泛型提高代码复用性
  6. 避免使用 any,优先使用 unknown

这些是 TypeScript 中最常用和最重要的类型元素,掌握它们可以让你在日常开发中得心应手。

大文件切片上传

秒是不用传,快传是接着传

  • 文件切片
  • 并发控制
  • 状态记录
文件切片

File.slice() 将大文件切分为若干小文件

上传与记录

并发上传这些切片,同时在前端缓存记录下哪些已上传成功

通知合并

所有切片上传完成后,前端发送一个请求通知后端,让后端把这些切片按顺序拼回一个完整的文件

// 1、生成文件hash
const fileHash = await calculateFileHash(file); 
// 2、分片大小
const CHUNK_SIZE = 2 * 1024 *1024;

const chunks = [];
let start = 0;
// 3、分片操作
while (start < file.size) {
    const end = Math.min(start + CHUNK_SIZE, file.size);
    chunks.push({
        file: file.slice(start, end),
        index: chunks.length,
        start,
        end,
        hash: fileHash // 用于服务端校验
    });
    start = end;
}
// 4、从本地存储获取已上传的分片记录
const uploadChunks = JSON.parse(localStorage.getItem(fileHash) || '[]');

// 5. 找出待上传的分片
const pendingChunks = chunks.filter(chunk => !uploadedChunks.includes(chunk.index));

// 6. 控制并发数(比如3个并发)
const CONCURRENCY = 3;
async function uploadWithConcurrency() {
  for (let i = 0; i < pendingChunks.length; i += CONCURRENCY) {
    const batch = pendingChunks.slice(i, i + CONCURRENCY);
    // 并发上传这一批
    await Promise.all(batch.map((chunk) => uploadChunk(chunk));
  }
}

// 7. 单个分片上传函数
async function uploadChunk(chunk) {
  const formData = new FormData();
  formData.append('file', chunk.file);
  formData.append('index', chunk.index);
  formData.append('hash', chunk.hash);
  formData.append('total', chunks.length); // 总分片数

  try {
    const res = await Request({
      url: 'https://your-api.com/upload/chunk',
      method: 'POST',
      data: formData,
      header: { 'Content-Type': 'multipart/form-data' }
    });

    if (res.data.success) {
      // 上传成功,记录到本地存储
      uploadedChunks.push(chunk.index);
      localStorage.setItem(fileHash, JSON.stringify(uploadedChunks));
      
      // 计算并更新进度
      const progress = (uploadedChunks.length / chunks.length) * 100;
      console.log(`上传进度: ${progress.toFixed(2)}%`);
    }
  } catch (error) {
    console.error(`分片 ${chunk.index} 上传失败:`, error);
    // 这里可以加入重试逻辑,例如重试3次
  }
}

// 8. 开始上传
uploadWithConcurrency().then(() => {
  // 所有分片上传完成,通知服务端合并
  Request({
    url: 'https://your-api.com/upload/merge',
    method: 'POST',
    data: {
      hash: fileHash,
      fileName: file.name,
      totalChunks: chunks.length
    }
  }).then(res => {
    console.log('文件上传成功!URL:', res.data.fileUrl);
    // 上传成功,清除本地记录
    localStorage.removeItem(fileHash);
  });
});

服务端要做的事

前端的工作如上,后端需要配合提供三个接口,这也是面试中常被问到的设计点 

  1. /upload/chunk:接收单个分片。通常会以文件hash_分片索引的格式临时存储。
  2. /upload/check(可选):查询已上传的分片。前端启动时调用,快速实现“断点续传”和“秒传”。
  3. /upload/merge:所有分片完成后,后端将临时分片合并,生成最终文件。

💡 高级优化与面试加分项

当你把这套逻辑讲清楚后,能主动提出以下优化点,会让面试官眼前一亮:

  • 秒传:在初始化上传前,先根据文件hash请求后端。如果文件已存在,直接返回成功,一秒完成 
  • Web Worker:将计算文件hash这种CPU密集型任务放到Web Worker里执行,避免阻塞页面渲染 
  • 本地存储选型:小文件、少量记录用localStorage;大文件、复杂记录用IndexedDB 
  • 暂停/恢复:通过AbortController来取消进行中的请求,实现暂停功能。恢复时,只需重新调用上传逻辑,pendingChunks会自动过滤掉已上传的部分 

Vue状态管理扫盲篇:Vuex 到 Pinia | 为什么大家都在迁移?核心用法对比

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、先搞清楚:Vuex 和 Pinia 到底是啥?

1.1 一句话认识它们

  • Vuex:Vue 2 时代的官方状态管理库,通过集中式存储管理应用的全部状态。
  • Pinia:Vue 3 时代的推荐状态管理库,作者和 Vue 核心团队同一人,被当作 Vuex 5 的正式版。

1.2 为什么大家纷纷从 Vuex 迁到 Pinia?

对比维度 Vuex Pinia
心智负担 概念多(state/mutations/actions/getters) 概念简单(一个 store,其余都是普通函数)
TypeScript 类型支持一般 原生支持好
模块化 需自己设计 modules 天然多 store,无嵌套
Composition API 需配合 useStore 天然适配 setup
打包体积 相对大 更小
官方推荐 Vue 2 主力,Vue 3 仍可用 Vue 3 推荐首选

一句话:Pinia 更简单、更贴近 Vue 3,写起来更像普通 JS。

二、Vuex 核心用法:四大金刚

2.1 整体结构回顾

Vuex 的数据流可以记成:View → Actions → Mutations → State → View

  • state:唯一数据源
  • getters:可理解为“计算属性”
  • mutations:唯一能改 state 的地方(必须同步)
  • actions:可以异步,内部再 commit mutations

2.2 完整示例:用户购物车

// store/index.js (Vuex)
import { createStore } from 'vuex'

export default createStore({
  state: {
    cartItems: [],      // 购物车商品
    user: null          // 当前用户
  },
  
  getters: {
    // 购物车商品数量
    cartCount(state) {
      return state.cartItems.reduce((sum, item) => sum + item.quantity, 0)
    },
    // 购物车总价
    cartTotal(state) {
      return state.cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
    }
  },
  
  mutations: {
    addToCart(state, product) {
      const exist = state.cartItems.find(item => item.id === product.id)
      if (exist) {
        exist.quantity++
      } else {
        state.cartItems.push({ ...product, quantity: 1 })
      }
    },
    setUser(state, user) {
      state.user = user
    }
  },
  
  actions: {
    // 异步:模拟登录
    async login({ commit }, credentials) {
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      })
      const user = await res.json()
      commit('setUser', user)
      return user
    }
  }
})

2.3 在组件里怎么用?

<template>
  <div>
    <p>购物车:{{ cartCount }} 件,总价:{{ cartTotal }}</p>
    <button @click="addProduct">加入购物车</button>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'

const store = useStore()

// 读 state / getters
const cartCount = computed(() => store.getters.cartCount)
const cartTotal = computed(() => store.getters.cartTotal)

// 触发 mutation(同步)
function addProduct() {
  store.commit('addToCart', { id: 1, name: '商品A', price: 99 })
}

// 触发 action(异步)
async function handleLogin() {
  await store.dispatch('login', { username: 'admin', password: '123' })
}
</script>

2.4 Vuex 容易踩的坑

  1. 忘记 mutations:直接 state.xxx = xxx 在严格模式下会报错,只能通过 mutation 修改。
  2. 在 mutation 里写异步:理论上必须同步,写异步会导致难以追踪、调试困难。
  3. 命名冲突:多个 module 时,getter/mutation/action 可能重名,需要命名空间。

三、Pinia 核心用法:一个 Store 搞定

3.1 设计思路

Pinia 不再区分 mutations 和 actions,只有:

  • state:数据
  • getters:计算属性
  • actions:既可以同步也可以异步,直接改 state

3.2 完整示例:同一需求用 Pinia 写

// stores/cart.js (Pinia - Options 风格)
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    cartItems: [],
    user: null
  }),
  
  getters: {
    cartCount(state) {
      return state.cartItems.reduce((sum, item) => sum + item.quantity, 0)
    },
    cartTotal(state) {
      return state.cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
    }
  },
  
  actions: {
    addToCart(product) {
      const exist = this.cartItems.find(item => item.id === product.id)
      if (exist) {
        exist.quantity++
      } else {
        this.cartItems.push({ ...product, quantity: 1 })
      }
    },
    async login(credentials) {
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      })
      const user = await res.json()
      this.user = user  // 直接改 state,不需要 mutation!
      return user
    }
  }
})

3.3 Setup Store 风格(更贴近 Composition API)

// stores/cart.js (Pinia - Setup Store 风格)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCartStore = defineStore('cart', () => {
  // state:用 ref/reactive
  const cartItems = ref([])
  const user = ref(null)
  
  // getters:用 computed
  const cartCount = computed(() => 
    cartItems.value.reduce((sum, item) => sum + item.quantity, 0)
  )
  const cartTotal = computed(() => 
    cartItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )
  
  // actions:普通函数
  function addToCart(product) {
    const exist = cartItems.value.find(item => item.id === product.id)
    if (exist) {
      exist.quantity++
    } else {
      cartItems.value.push({ ...product, quantity: 1 })
    }
  }
  
  async function login(credentials) {
    const res = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })
    const data = await res.json()
    user.value = data
    return data
  }
  
  // 必须 return 出去,组件才能用
  return {
    cartItems,
    user,
    cartCount,
    cartTotal,
    addToCart,
    login
  }
})

3.4 在组件里怎么用?

<template>
  <div>
    <p>购物车:{{ cartStore.cartCount }} 件,总价:{{ cartStore.cartTotal }}</p>
    <button @click="cartStore.addToCart({ id: 1, name: '商品A', price: 99 })">
      加入购物车
    </button>
  </div>
</template>

<script setup>
import { useCartStore } from '@/stores/cart'

const cartStore = useCartStore()

// 用 storeToRefs 解构,保持响应式(getters 和 state 需要)
// 如果直接解构 const { cartCount } = cartStore,会丢失响应式!
import { storeToRefs } from 'pinia'
const { cartCount, cartTotal } = storeToRefs(cartStore)

// actions 直接解构没问题
const { addToCart, login } = cartStore
</script>

四、核心概念对照表

概念 Vuex Pinia (Options) Pinia (Setup)
定义数据 state: { } state: () => ({ }) ref() / reactive()
计算属性 getters: { } getters: { } computed()
修改数据(同步) mutations actions 里直接改 this.xxx 直接改 ref.value
修改数据(异步) actions + commit actions 里直接改 在函数里直接改
组件调用 store.commit() / store.dispatch() store.xxx() store.xxx()

五、迁移时的常见坑

5.1 解构 store 丢响应式

// ❌ 错误:直接解构会丢失响应式
const { cartCount } = useCartStore()

// ✅ 正确:用 storeToRefs
const { cartCount } = storeToRefs(useCartStore())

5.2 Setup Store 忘记 return

// ❌ 错误:没 return,组件拿不到
export const useCartStore = defineStore('cart', () => {
  const count = ref(0)
  function add() { count.value++ }
  // 忘记 return!
})

// ✅ 正确
return { count, add }

5.3 多个 store 之间互相调用

关于这个坑的问题,我在初学的时候看到这个概念其实并不理解。所以我决定在这里展开的说一说。

在 Pinia 中,多个 store 互相调用是开发中很常见的需求(比如「订单 store」需要用到「购物车 store」的商品数据),但新手很容易因为调用时机不对写出“死锁代码”。

本节会用「大白话+实战代码」,教你安全调用的核心规则避坑要点,以及新手最易踩的雷,保证看完就能上手。

一、核心结论(先记重点)

多个 store 之间可以互相调用,但必须遵守一个黄金法则:

只存引用,延迟使用:在 store 的 setup 函数顶层,只能获取另一个 store 的「实例引用」;读取数据、调用方法的操作,必须放到函数(action)内部执行。

❌ 绝对禁止:在 setup 顶层直接读取另一个 store 的数据(会触发“互相等待”的死锁)。

二、安全写法(直接抄作业)

以「订单 store(order.js)」调用「购物车 store(cart.js)」为例,实现下单时获取购物车商品的功能。

步骤1:创建被调用的购物车 store(cart.js)

// stores/cart.js
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCartStore = defineStore('cart', () => {
  // 购物车商品列表(state)
  const cartItems = ref([
    { id: 1, name: '新手小白入门教程', price: 99 },
    { id: 2, name: 'Pinia 避坑手册', price: 59 }
  ])

  // 简单的方法(action),方便后续被调用
  function clearCart() {
    cartItems.value = []
  }

  return { cartItems, clearCart }
})
步骤2:创建调用方订单 store(order.js)

// stores/order.js
import { defineStore } from 'pinia'
// 1. 导入购物车 store 的创建函数
import { useCartStore } from './cart'

export const useOrderStore = defineStore('order', () => {
  // ✅ 安全操作:在 setup 顶层只获取 cartStore 的「实例引用」
  // 此时只是“记下来购物车的地址”,不会读取数据、不会触发死锁
  const cartStore = useCartStore() 

  // 2. 核心:把“使用 cartStore”的逻辑,放到 action 函数内部
  function checkout() {
    // 🎯 延迟使用:只有调用 checkout 时,才会真正读取 cartStore 的数据
    // 此时两个 store 都已初始化完成,数据可正常获取
    console.log('下单商品:', cartStore.cartItems)
    
    // 也可以调用另一个 store 的方法
    if (cartStore.cartItems.length > 0) {
      console.log('下单成功,清空购物车!')
      cartStore.clearCart()
    } else {
      console.log('购物车为空,无法下单!')
    }
  }

  return { checkout }
})
步骤3:在组件中使用(验证效果)

<template>
  <button @click="handleCheckout">点击下单</button>
</template>

<script setup>
// 导入订单 store
import { useOrderStore } from '@/stores/order'

const orderStore = useOrderStore()

// 点击按钮触发下单逻辑
const handleCheckout = () => {
  orderStore.checkout()
}
</script>

点击按钮后,控制台会输出:


下单商品: [{ id: 1, ... }, { id: 2, ... }]
下单成功,清空购物车!

三、为什么要这样写?(大白话讲透“死锁”)

新手最疑惑的是:为什么不能在 setup 顶层直接读数据? 我们用“两个人出门”的例子,讲透背后的逻辑:

1. 安全写法的执行流程(无死锁)

就像两个人(orderStore 和 cartStore)先各自出门(完成初始化),再互相帮忙:

  1. 组件调用 useOrderStore() → orderStore 开始初始化:只做了一件事——“记下 cartStore 的地址”(const cartStore = useCartStore()),自己先完成初始化。

  2. 后续调用 checkout() 时:orderStore 带着“地址”去找 cartStore,此时 cartStore 早就初始化好了,能顺利拿到商品数据。

2. 新手踩坑写法(触发死锁)

如果在 setup 顶层直接读数据,就变成了两个人互相卡条件


// ❌ 错误示例:order.js(千万别这么写!)
export const useOrderStore = defineStore('order', () => {
  const cartStore = useCartStore()
  // ❌ 致命错误:setup 顶层直接读取 cartStore 的数据
  const goods = cartStore.cartItems 

  function checkout() {
    console.log(goods)
  }

  return { checkout }
})

此时的执行流程就会“僵持住”:

  1. orderStore 初始化时,要求“先拿到 cartStore 的商品数据,才能完成初始化”。

  2. 于是去调用 useCartStore(),让 cartStore 初始化。

  3. 如果 cartStore 也在顶层读 orderStore 的数据,就会变成:order 等 cart 给数据,cart 等 order 给数据,俩人都卡着不动 → 代码报错(死锁)。

四、进阶:两个 store 互相调用(依然安全)

如果购物车 store 也需要调用订单 store 的数据,只要遵守「函数内使用」的规则,完全没问题!

补全 cart.js,新增“查看订单状态”的方法:

// stores/cart.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 导入订单 store
import { useOrderStore } from './order'

export const useCartStore = defineStore('cart', () => {
  const cartItems = ref([{ id: 1, name: '新手小白入门教程', price: 99 }])
  
  // ✅ 安全:在函数内调用订单 store
  function checkOrderStatus() {
    const orderStore = useOrderStore()
    // 假设 orderStore 有一个 orderStatus 状态
    console.log('当前订单状态:', orderStore.orderStatus)
  }

  function clearCart() {
    cartItems.value = []
  }

  return { cartItems, clearCart, checkOrderStatus }
})
// stores/order.js 补充 orderStatus 状态
export const useOrderStore = defineStore('order', () => {
  const cartStore = useCartStore()
  // 新增订单状态
  const orderStatus = ref('未支付')

  function checkout() {
    console.log('下单商品:', cartStore.cartItems)
    orderStatus.value = '已支付'
  }

  return { checkout, orderStatus }
})

此时两个 store 互相调用,但因为所有“使用对方”的逻辑都在函数内,初始化阶段互不干扰,完全不会死锁!

五、汇总一下

  1. defineStore同步函数,其 setup 回调不能加 async(异步逻辑只能写在 action 里)。

  2. ✅ 跨 store 调用的核心:setup 顶层只存引用,函数内部才使用

  3. ❌ 禁止在 setup 顶层直接读取另一个 store 的 state/getters(必触发死锁)。

  4. ❌ 禁止在 setup 顶层使用 await(既不支持,也会导致初始化异常)。

5.4 Pinia 需要先挂载

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia())  // 必须在 createApp 之后、mount 之前
app.mount('#app')

六、日常开发该怎么选?

  • 新项目(Vue 3):优先用 Pinia,尤其 Setup Store 风格,和 Composition API 很契合。
  • 老项目(Vue 2 + Vuex):如果项目稳定、迁移成本高,可以先不急着迁;要升级 Vue 3 时,顺带迁到 Pinia 更合适。
  • 团队习惯:如果团队已经统一用 Vuex 且运转良好,不必为了“新”而强行迁移,关键是统一和维护成本。

七、总结

维度 Vuex Pinia
概念数量 4 个(state/getters/mutations/actions) 3 个(state/getters/actions)
改数据方式 只能通过 mutation(同步) actions 直接改(同步/异步都可)
风格 偏“流程化” 更接近普通函数、Composition API
学习成本 中等 较低

一句话:Pinia 用更少的概念、更直接的方式完成同样的状态管理,而且和 Vue 3 配合更好。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

我会如何考核一个在简历里大谈 AI 提效的高级前端?

节日期间,尤其是最近掘金首页有点没法看🤷‍♂️。

满脸写着无奈.gif

点开全是 OpenClaw 怎么提效、怎么用 AI Prompt 写个 TodoList、怎么十分钟上线一个营销页。同质化严重到让人怀疑大家是不是共用了一个脑子🤯🤯。

作为面了不下百号人的老兵,我实话实说:在 2026 年,如果你简历里写熟练使用 AI 提效,对我来说几乎是废话。 现在的行情是,初级和中级前端确实正在被 AI 批量取代,而很多所谓的高级前端,只是学会了怎么更快地拉一坨更大的屎。

今天聊透一点:作为一个 9 年经验的面试者,我到底会怎么考核那些大谈 AI 提效的前端。


要警惕AI 幻觉带来的技术债!

现在的 AI 确实强,它生成的组件逻辑看起来天衣无缝。但问题就在这儿——它只负责跑通,不负责善后。

很多号称提效 50% 的候选人,本质上是在用未来的维护成本换现在的开发速度。面试时,我会拿出一个复杂的业务逻辑,让他用 AI 生成,然后我只问一个点: 这段代码里,AI 隐瞒了哪些潜在的副作用?

资深前端得能看出来:

AI 特别喜欢在 useEffect 或者最新的 Signal 监听里写闭包,层级一深,内存泄漏稳稳的。你发现了吗?

异步请求连发的时候,AI 往往不会帮你写 AbortController,它默认你的网络永远是理想状态。

为了实现一个简单功能,AI 可能会顺手给你引入一个 200KB 的第三方库,而 9 年经验的你应该知道怎么用 10 行原生代码搞定。

如果你看不出 AI 代码里的屎山,那你不是在提效,你是在给项目埋雷😒。

代码架构的坍塌

这是我最担心的。以前我们写代码,脑子里有清晰的模块边界、职责划分。现在有了 AI,大家习惯了 喂一段 Prompt,拿一段代码

后果就是,项目的熵增速度快得惊人。

我会考核候选人: 当 AI 生成的代码风格与你现有的 Monorepo 规范冲突时,你是怎么做约束的?

  • 你有没有沉淀出一套针对 AI 的 CursorRules 或者 Type Definition 约束层?
  • 你是如何保证 AI 生成的业务逻辑不会击穿你的领域模型(Domain Model)?

资深和普通人的区别在于:普通人被 AI 牵着鼻子走,架构师把 AI 关在规范的笼子里。 如果你只会复制粘贴 AI 给出的 Fragment,那你根本撑不起资深这两个字🤔。

技术底层能力

很多候选人现在离了 AI 连 Event Loop 的微任务宏任务执行顺序都讲不清楚了,更别说 WebAssembly 或者 WebGPU 的内存管理。

我会问一个很现实的问题: 当线上出现了一个 AI 无法复现、无法理解的线上 Crash(比如由于浏览器内核版本导致的渲染层级错乱),你的排查思路是什么?

这时候 AI 可帮不了你😃。

它没法帮你分析 Chrome 的 Memory Heap,也没法帮你去翻 WebKit 的源码。如果你把提效省下来的时间全用来摸鱼,而不是去深挖这些 AI 触碰不到的底层,那你很快就会被下一代更便宜的 AI 操作员取代。

提效的意义,是为了腾出时间去研究那些 AI 还没学会的硬核技术,而不是心安理得地退化成一个 Prompt 搬运工。

我平常问的三个问题

如果你的简历里写了 AI 提效,我会这么面你:

1.如果 AI 改动了底层公共组件,你如何确保它在线上环境下导致线上崩盘?

2.你的项目里,有哪些模块是你明确禁止 AI 介入的?理由是什么?(考察对业务核心逻辑的洞察力)

3.关于你的审美,在 UI 风格高度同质化的今天,如果你用的组件库和交互全是 AI 生成的,你如何通过前端工程化手段,去实现那种 AI 模拟不出来的、极致的用户交互体验?


最后

现在的掘金,吹捧技术的人太多,反思技术的人太少😖。

OpenClaw 确实是个里程碑,它让我们的双手得到了解放。但作为一个 9 年的前端,我必须提醒你:手闲下来了,脑子得转得更凶。

我面试时想看到的,不是你如何熟练地调教 AI,而是你作为一个开发,在面对复杂、混乱、不可预测的业务场景时,那份超越算法的判断力

如果你连 AI 生成的代码都 Review 不明白,那你的 9 年经验,可能真的只是 1 年经验重复了 9 次而已。

你们说是不是呢?

谢谢大家.gif

pxcharts-vue:一款专为 Vue3 打造的开源多维表格解决方案

去年和大家分享了我的AI产品 pxcharts 超级表格的创业故事:

图片

同时我们也利用业余时间,基于国内公司最喜欢的技术栈Vue3全家桶,偷偷做了一款完全开源版的多维表格 pxcharts-vue:

图片

设计风格完全对标飞书和钉钉AI表格,大家可以基于这个方案轻松实现多维表格产品。话不多说,先上开源地址:

github.com/MrXujiang/p…

为什么要做pxcharts-vue多维表格

图片

我一直认为,在数据可视化与多维数据处理的场景中,表格始终是核心载体,但市面上多数表格组件往往局限于二维结构,难以满足复杂的多维数据展示、分析需求。

在实际的业务开发中,我们频繁遇到这类需求:

  • 电商行业的多维度经营数据(时间、地区、品类、销售额交叉分析);
  • 金融领域的多指标风控数据(客户维度、产品维度、时间维度的风险值展示);
  • 企业 BI 系统的多维报表(多维度钻取、联动、聚合)。

传统二维表格需要大量二次开发才能适配多维场景,且易出现代码冗余、性能卡顿等问题。

因此,我们决定从零开始,打造一款原生支持多维数据结构、轻量化且高度可定制的 Vue 版多维表格组件 ——pxcharts-vue。

图片

核心特性我总结如下:

  • 🎯 多维表格 - 灵活的数据视图切换(表格视图、看板视图、日历视图)
  • 🎨 低代码表单设计器 - 拖拽式表单构建,支持丰富的表单组件和自定义配置
  • 📊 数据可视化 - 集成 ECharts 图表库,支持多种图表类型和自定义配置
  • 📝 富文本编辑器 - 基于 Tiptap 的强大编辑能力,支持图片、链接、文本样式等
  • 🎭 模板市场 - 内置丰富的行业模板,快速启动项目
  • 👥 团队协作 - 支持多团队管理、成员邀请、权限控制
  • 🎪 水印编辑器 - 自定义水印样式,保护数据安全
  • 📁 文件上传 - 完善的文件管理功能
  • 🌓 响应式设计 - 适配各种屏幕尺寸,提供优质的移动端体验

下面我会和大家分享一下我们这个项目使用到的技术方案和功能亮点,供大家参考研究。

pxcharts-vue 技术架构设计和核心功能设计

先分享一下我们多维表格前端架构设计:

图片

核心技术实现

1. 多维表格系统

图片

技术方案

  • 基于 vue3-grid-layout-next 实现灵活的网格布局
  • 使用 sortablejs 实现拖拽排序功能
  • 虚拟滚动优化大数据量渲染性能

关键代码结构

src/components/DataTable/
├── GridView.vue          # 网格视图
├── KanbanView.vue        # 看板视图
├── CalendarView.vue      # 日历视图
└── TableConfig.vue       # 表格配置

2. 表单设计器

图片

技术方案

  • 自研拖拽引擎,支持组件拖拽、排序、嵌套
  • 配置化表单渲染,支持动态表单验证
  • JSON Schema 驱动的表单配置

实现特点

  • 左侧组件面板 - 组件分类、搜索、预览
  • 中间画布区域 - 实时预览、拖拽编辑
  • 右侧属性配置 - 动态表单、样式配置、事件绑定

3. 数据可视化

图片

技术方案

  • 深度集成 ECharts 6.0,封装图表组件
  • 支持图表主题定制、响应式布局
  • 提供图表二次编辑能力

支持图表类型

  • 折线图、柱状图、饼图、散点图
  • 雷达图、仪表盘、漏斗图
  • 地图、关系图、树图等高级图表

4. 富文本编辑器

图片

技术方案

  • 基于 Tiptap 构建,扩展自定义节点
  • 支持图片上传、链接插入、文本格式化
  • Markdown 快捷键支持

当然我们也实现了看板视图,大家可以开箱即用:

图片

基本上完成了多维表格70%以上的功能,大家只需要基于 pxcharts-vue 的开源版本,进行二次开发,即可实现复杂的多维表格产品。pxcharts-vue 技术栈

前端核心库:

技术 版本 说明
Vue 3 ^3.5.18 渐进式 JavaScript 框架
TypeScript ~5.8.0 JavaScript 的超集,提供类型检查
Vite ^7.0.6 下一代前端构建工具
Vue Router ^4.5.1 Vue.js 官方路由管理器
Pinia ^3.0.3 Vue 3 状态管理库

UI 与组件库:

技术 版本 说明
TDesign Vue Next ^1.16.1 企业级 UI 组件库
ECharts ^6.0.0 数据可视化图表库
Tiptap ^3.10.7 富文本编辑器框架
Lucide Vue Next ^0.548.0 精美的图标库

功能增强:

技术 版本 说明
Axios ^1.11.0 HTTP 请求库
Sortable.js ^1.15.6 拖拽排序库
Vue3 Grid Layout Next ^1.0.7 网格布局组件
Day.js ^1.11.19 轻量级日期处理库
NProgress ^0.2.0 页面加载进度条
Mitt ^3.0.1 事件总线
Lodash ^4.17.21 JavaScript 工具库

开发工具:

技术 版本 说明
ESLint ^9.31.0 代码检查工具
Prettier 3.6.2 代码格式化工具
Vue DevTools ^8.0.0 Vue 开发调试工具
unplugin-auto-import ^20.1.0 自动导入 API
unplugin-vue-components ^29.0.0 自动导入组件

快速开始

环境要求

  • Node.js >= 20.19.0 或 >= 22.12.0
  • pnpm >= 8.0.0 (推荐) / npm >= 9.0.0 / yarn >= 1.22.0

安装依赖

# 克隆项目
git clone https://github.com/MrXujiang/pxcharts-vue.git

# 进入项目目录
cd pxcharts-vue

# 安装依赖(推荐使用 pnpm)
pnpm install
# 或者
npm install

开发运行

# 启动开发服务器
pnpm dev

# 访问 http://localhost:5173

构建部署

# 生产环境构建
pnpm build

# 预览构建结果
pnpm preview

代码规范

# 代码检查
pnpm lint

# 代码格式化
pnpm format

后续我会写2篇详细的产品介绍和功能技术实现的文章,让大家更全面的了解pxcharts-vue这款开源多维表格项目,大家感兴趣可以学习研究一下。

如果你也在寻找一款开箱即用的多维表格解决方案,如果你相信数据协作还有更好的可能,欢迎来 GitHub 搜索 pxcharts-vue,或者访问我们的演示网站。你可以免费使用,可以贡献代码,也可以在留言区交流反馈。

pxcharts-vue 很多功能需要优化,欢迎大家共建。


作者:pxcharts创始人,前大厂架构师,坚信好的工具应该让人忘记工具本身的存在。

github地址:github.com/MrXujiang/p…

Vue3 中 emit 能 await 吗?事件机制里的异步陷阱

一个看起来"理所当然"的写法

某天你在写一个表单弹窗组件,子组件提交数据,父组件负责调接口保存。你顺手写下了这段代码:

// 子组件:提交按钮
const handleSubmit = async () => {
  loading.value = true
  await emit('submit', formData)  // ❌ 看似合理:等父组件保存完再关弹窗
  loading.value = false
  emit('close')
}

看起来没毛病对吧?emit 提交,等父组件处理完,关弹窗。逻辑清晰,语义明确。

然后你发现:loading 闪了一下就没了,弹窗瞬间关闭,接口还没返回。

你 await 了个寂寞。


为什么 await emit 不等于"等父组件执行完"?

很多人把 emit 理解为"调用父组件的方法"——这个理解对了一半,但恰好是错的那一半坑了你。

emit 的本质:同步的函数调用

Vue 的事件机制不是浏览器的 EventEmitter,也不是 Node.js 的事件循环。它的底层实现极其简单:

// Vue3 emit 的核心逻辑(简化版)
function emit(instance, event, ...args) {
  const props = instance.vnode.props || {}

  // 'submit' → 'onSubmit'
  const handlerName = `on${event[0].toUpperCase()}${event.slice(1)}`
  const handler = props[handlerName]

  if (handler) {
    // 就是直接调用,没有任何异步包装
    callWithAsyncErrorHandling(handler, instance, args)
  }
}

emit 就是从 props 里找到对应的回调函数,直接调用。 没有事件队列,没有微任务,没有 Promise 包装。本质上等价于:

// emit('submit', data) 就是:
props.onSubmit(data)

就这么朴素。像你在对象上调方法一样朴素。


那 await emit(...) 到底 await 到了什么?

JavaScript 里 await 一个非 Promise 的值会立即返回:

const result = await 42          // result === 42,立即返回
const result2 = await undefined  // result2 === undefined,立即返回
const result3 = await emit('submit', data) // 取决于父组件回调的返回值

所以关键问题是:父组件的事件处理函数返回了什么?

场景一:父组件返回普通值(await 无效)

// ❌ 父组件:没有 return,也没有 await
const onSubmit = (data) => {
  api.save(data)
  console.log('已发送请求')
}
// 子组件
await emit('submit', formData)
// ↑ onSubmit 返回 undefined → await undefined → 立即继续
// 此时接口还在飞,弹窗已经关了

场景二:父组件返回 Promise(await 碰巧生效)

// 父组件:async 函数自动返回 Promise
const onSubmit = async (data) => {
  await api.save(data)
  message.success('保存成功')
}
// 子组件
await emit('submit', formData)
// ↑ onSubmit 是 async 函数,返回 Promise → await 真正等待了
loading.value = false  // 时机正确

等等,这不是能 await 吗?!

能。但这是一个危险的巧合,不是一个可靠的契约


为什么说"能用"不等于"该用"?

问题一:隐式契约,没有类型保障

const emit = defineEmits<{
  submit: [data: FormData]  // 返回值类型?不存在的
}>()

defineEmits 的类型系统只约束参数,不约束返回值。子组件根本不知道父组件会返回什么。

今天父组件的同事写了 async,明天换个人维护去掉了 async,你的子组件就悄悄坏了。没有编译错误,没有运行时报错,只有一个"偶尔弹窗关太快"的玄学 bug。

问题二:多个监听器时行为不可预测

一个监听器还好。但如果事件通过 v-on="$attrs" 透传,或组件被包了一层 wrapper,监听器可能不止一个。这时候 emit 的返回值是哪个处理器的?没人说得清。

问题三:违反单向数据流

Vue 的设计哲学是:props down, events up。 数据从父到子,事件从子到父。

await emit() 的潜台词是:"子组件等待父组件的处理结果"——这相当于子组件在反向依赖父组件的执行逻辑。

正常的数据流:
  父 —— props ——→ 子
  子 —— emit ——→ 父(通知一下就走,不等回信)

await emit 的数据流:
  父 —— props ——→ 子
  子 —— emit ——→ 父 —— Promise ——→ 子(等回信才走)

这不是 emit,这是 RPC 调用。


那正确的做法是什么?

方案一:props 控制状态(最直接)

不要让子组件等父组件,让父组件主动控制子组件的状态:

// 子组件:只负责发信号,不管后续
const props = defineProps<{
  loading: boolean
}>()

const emit = defineEmits<{
  submit: [data: FormData]
  close: []
}>()

const handleSubmit = () => {
  emit('submit', formData) // ✅ 不 await,发完就完事
}
<!-- 父组件:掌握全部控制权 -->
<MyForm
  :loading="saving"
  @submit="onSubmit"
  @close="visible = false"
/>
// 父组件
const saving = ref(false)

const onSubmit = async (data: FormData) => {
  saving.value = true
  try {
    await api.save(data)
    message.success('保存成功')
    visible.value = false  // ✅ 父组件决定什么时候关弹窗
  } finally {
    saving.value = false
  }
}

子组件只管发信号,父组件全权处理。 清晰,可控,可维护。

方案二:传入异步回调 prop(需要子组件控制流程时)

有些场景子组件内部有复杂的多步骤流程,确实需要等异步结果:

// 子组件
const props = defineProps<{
  onSubmit: (data: FormData) => Promise<boolean> // ✅ 类型明确,契约清晰
}>()

const handleSubmit = async () => {
  loading.value = true
  try {
    const success = await props.onSubmit(formData) // ✅ 类型系统保证返回 Promise<boolean>
    if (success) {
      emit('close')
    }
  } finally {
    loading.value = false
  }
}
<!-- 父组件 -->
<MyForm :on-submit="handleSave" @close="visible = false" />
// 父组件
const handleSave = async (data: FormData): Promise<boolean> => {
  try {
    await api.save(data)
    return true
  } catch {
    message.error('保存失败')
    return false   // 子组件收到 false,不关弹窗
  }
}

await emit 的区别在哪?类型安全。 defineProps 明确声明了返回 Promise<boolean>,父子组件之间有了白纸黑字的契约。谁改了返回类型,TypeScript 立刻报错。

方案三:expose + ref 模式(命令式控制)

适合弹窗、抽屉这类"父组件全权控制生命周期"的场景:

// 子组件:暴露内部状态和方法
const loading = ref(false)
const reset = () => { /* 重置表单 */ }

defineExpose({ loading, reset })
// 父组件:直接操作子组件
const formRef = ref<InstanceType<typeof MyForm>>()

const onSubmit = async (data: FormData) => {
  formRef.value!.loading = true
  try {
    await api.save(data)
    formRef.value!.reset()
    visible.value = false
  } finally {
    formRef.value!.loading = false
  }
}

直接,粗暴,但某些场景下最高效。适合团队内部组件,不适合对外暴露的公共组件。


三种方案怎么选?

维度 方案一:props 控制 方案二:异步 prop 回调 方案三:expose
类型安全 ✅ 好 ✅ 最好 🟡 一般
组件耦合度 ✅ 低 🟡 中 ❌ 高
子组件自治能力 ❌ 低 ✅ 高 ❌ 低
复用性 ✅ 好 ✅ 好 🟡 差
适用场景 简单交互 复杂多步流程 命令式弹窗
  • 80% 的场景用方案一就够了——别过度设计
  • 子组件有复杂流程(多步表单、条件跳转)用方案二
  • 内部工具组件、弹窗管理器用方案三

其他框架怎么处理 emit 的?

不是所有框架都像 Vue 这样:

// Node.js EventEmitter — 返回 boolean(是否有监听器)
emitter.emit('data', payload)  // → true / false

// Svelte createEventDispatcher — 返回 boolean
dispatch('submit', data)  // → true(未被 preventDefault)/ false

// Angular EventEmitter — 基于 RxJS,没有返回值
this.submit.emit(data)  // → void

Vue 的 emit 返回父组件回调的返回值,这在框架中其实是个异类。它不是设计出来让你 await 的——只是 JavaScript 函数调用的自然结果:你调了一个函数,它当然有返回值。

就像 Array.forEach 回调里能 return,但那个返回值没人接收。能用,但不是给你用的。


项目里已经大量 await emit 了怎么办?

别慌,渐进式修复:

// Step 1:加一层防御,避免父组件忘写 async 导致的静默失败
const handleSubmit = async () => {
  loading.value = true
  try {
    const result = emit('submit', formData)
    if (result instanceof Promise) {
      await result  // 只有真正返回 Promise 时才等待
    }
  } finally {
    loading.value = false
  }
}

// Step 2:新组件直接用方案一或方案二,老组件排期重构

最后

emit 是单向通知——我告诉你发生了什么,至于你怎么处理,跟我无关。

await emit 把它强行变成了请求-响应——我不仅要告诉你,还要等你的回复。

就像你不会对着对讲机喊完话之后,傻站在原地等回复——对讲机是单工通信,要双向通话得打电话。

在 Vue 里,"电话"就是 prop 回调expose。选对工具,问题自然消失。

想要长期陪伴你的助理?先从部署一个 OpenClaw 开始 😍😍😍

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

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。 很多人第一次打开 OpenClaw,会下意识把它当成"接在微信或 Slack 上的聊天机器人"。这种理解只对了一半。从架构上看,OpenClaw 更像一个网关:它站在你和一堆能力之间,负责路由、鉴权、记忆和工具调用。真正决定你能做多少事的,不是对话框有多好看,而是背后接了多少"身体"——也就是 Skills。

2025 年很多人说是 vibe coding 的元年,用自然语言描述需求、让 AI 帮你写代码和改代码,从极客玩具变成了日常开发方式。到了 2026 年,一个更直白的问题浮出水面:是不是人人都会有一个自己的 Agent?不再只是"问一问答一答"的聊天窗口,而是一个真的能替你操作电脑、完成任务、且完全听你指挥的智能体。OpenClaw 就是这条路上一个绕不开的名字,它把「大模型 + 电脑」做成开源框架,让任何人都有机会在自家机器上部署一个通用 Agent。这篇文章先说清 Chatbot 和 Agent 到底差在哪,再介绍通用 Agent 是什么,最后落在 OpenClaw 的定位、优劣和可能走向。

豆包、元宝、千问这类产品大家都很熟悉,它们背后是「大语言模型」,能帮你分析、推导问题,最后给出一段文字答案。但在执行层面,始终需要人类参与。比如你可以问豆包"回老家最近的高铁车次是哪趟",具体的付款、买票依然要你自己登录 12306 完成。这类我们习惯称之为 Chatbot。

AI Agent 和 Chatbot 的差别在于,前者是「大语言模型 + 工具」的结合体。模型负责出思路,工具负责落地,最终交付的是用户要的"成品",而不只是一段话。程序员常用的 CursorCodeBuddy 就是典型:大模型给编程思路,编辑器当工具写代码,还能调浏览器做测试、发布。春节期间"让千问帮你点奶茶"、更早的豆包手机,都是传统 Chatbot 往 Agent 方向升级的信号。

20260303091822

上图从左到右概括了 Chatbot 与 Agent 的差别。Chatbot 只产出文字答案,执行仍要你自己动手。Agent 则多了"工具"这一环,能直接操作电脑或外部服务,把"成品"交到你手上。

那有没有一种 Agent,能干的事情特别多、甚至接近"什么都行"?有,这类产品叫「通用 Agent」。它的核心工具是一台完整的电脑,能用电脑上的一切软硬件来完成你的需求,相当于你请了专人,用你的电脑帮你办事。交付物自然也只能是电脑能生产的东西,你不能跟它说"给我一百万",但凡是电脑能做的,它理论上都能参与。比如你可以说:"找一下回老家最近的高铁车次,有票就帮我买,没票就对比交通工具的时间和开销,做成报告发我邮箱。" 通用 Agent 里比较出名的是去年年底被 Meta 收购的 Manus

20260303091927

图中概括了通用 Agent 的工作方式:用户用自然语言下指令,大模型理解并拆解成步骤,把"电脑"当作统一工具,调用上面的软件和网络完成操作,最后把电脑能产出的结果(订单、报告、邮件等)交回给你。

OpenClawManus 在技术本质上是一致的,都是大语言模型配合电脑作为工具的通用 Agent。区别在于:Manus 的模型和电脑由服务方提供,你按月付费使用。OpenClaw 则由开发者自己找电脑或云服务器部署,代码开源。很多人因此把 OpenClaw 当成 Manus 的平替,一上来就丢语义不清的长任务,比如"每天用 rss 拉取最新资讯做成简报",结果抱怨效果差、费钱、费 Token。问题不在技术路线,而在预期,开源带来的两面性下面会细说。

开源为什么看起来"能力弱"

OpenClaw 最初是创始人 Peter Steinberger 用来连接 WhatsApp 和本地 Claude 的工具,方便在 WhatsApp 里给 Claude 下编程指令,所以早期叫 Clawbot(Claw 和 Claude 同音),即"Claude 机器人"。后来这套「大模型控制电脑」的 Agent 框架被正式开源,并定名 OpenClaw

开源的特性决定了它的两面性。一方面,谁都可以改代码,运行逻辑可以高度定制。另一方面,内置逻辑相对"单薄"。早期版本的上下文切换、记忆能力都偏简单,自带的工具也只有读写文件、执行命令等基础操作。而 Manus 在工具层面就覆盖了 Office、图表、浏览器交互等一整套打工人常用能力,去年内测时已经在做"上下文溢出后的无缝切换"。所以如果你追求开箱即用、不想折腾,更适合每月花 199 美元订阅 Manus。如果你愿意花时间教 OpenClaw 更多技能,它可以变成你专属的、完全自主可控的智能助理。

20260303092032

上图左边是开源带来的"看起来能力弱":内置工具少、逻辑薄,和商业版 Manus 一对比尤其明显。右边是开源带来的真正价值:可定制、数据在自己手里、还有社区一起迭代。

开源的优势:自主可控

OpenClaw 的核心价值,恰恰来自开源。

你可以按自己的需求扩展:想用什么模型就用什么模型,觉得文件存记忆不够就自己接向量数据库,觉得太费 Token 就给它设定分步执行、克制的规则。代码完全透明,不用担心偷偷收集数据或乱传数据,所有上下文和敏感信息都留在你自己的设备上。Sam Altman 也提过,OpenAI 不做这类产品,核心原因之一就是隐私。个人建议不要开外网端口,牺牲一点便利,能明显提高安全性。

开源也带来了社区。OpenClawGitHub 上星标接近 20 万,有全球开发者一起修能力、扩展性和安全问题。从今年 1 月初定名 OpenClaw 到现在,已经发布了 41 个版本,基本一两天就有一次更新,社区还有日常直播,方便交流使用和开发心得。只要你愿意投入时间调教,它可以成为只属于你的"白金之星"。即便你目前只有"紫色隐者"级别的需求,它也能做一个完全不依赖外界、完全在你掌控下的本地助理。

两个方向上的预测

第一,OpenClaw 的未来会往本地化部署走。很多人聊安全时会说"要么独立电脑,要么云服务器"。现阶段云上部署性价比高,能力和本地差不太多,但可玩性差很多。比如你没法让云上的 OpenClaw 帮你放音乐,而本地部署可以(有人就教会了 OpenClaw 用 QQ 音乐)。本地电脑能接各种硬件,摄像头当"眼睛",音响当"声带",甚至接机械臂让它动起来。当大模型能操控更多实体硬件时,Agent 的想象空间会大很多。

这里顺带避个坑:最近有些"包装 OpenClaw 的云端产品"打着"无需买服务器和算力、开箱即用"的旗号收月费。这类内容多数可以当作软文看待。真想低成本体验,可以在阿里、腾讯、华为等云厂商买一台内存型服务器、部署 OpenClaw 镜像,最便宜每小时几毛钱,模型用 MinimaxKimi 或千问的免费额度就够试用了,不用了释放即可。不熟悉技术的可以问豆包要具体操作步骤,并不复杂。

第二,算力也会逐渐本地化。不仅是运行环境,大模型本身也会更多地在本地跑。当前"模型在云端"的方案下,理论上模型方是有办法接触到你的数据的。随着模型变小、消费级硬件变强,大模型完全可以在本地完成推理,到时候断网只要通电,OpenClaw 也能正常用。有人会说本地模型能力不如云端,可以换个角度想:你请的是私人助理,他不需要上知天文下知地理,只要能理解常识、能帮你对接专业能力就行。电视坏了,他帮你联系工程师(比如 Claude)。身体不舒服,他帮你问医生(比如蚂蚁阿福)。更重要的是,和这个"助理"的所有对话都只存在本地硬盘上,你可以聊任何隐私话题,他也能持续、不中断地辅助你,这才是真正意义上的私人助理。

20260303092919

图中上排是部署本地化:云端 Agent 受限于不能碰你本地的硬件,本地部署则可以接摄像头、音响、机械臂,可玩性高很多。下排是算力本地化:从"模型在云端"走向"模型在本地",断网可用,对话只留在自己硬盘上,更像真正的私人助理。

人人都有一个 Agent?2026 年的两条路

所以回到开头那个问题,现在是不是人人都会有一个自己的 Agent?从趋势上看,是的,但"有一个"的方式会分化。2025 年 vibe coding 把"用自然语言写代码"普及了,2026 年大家要的是"用自然语言让 AI 替自己干活"。这条路上有两条很清晰的路径。一条是付费订阅商业通用 Agent,比如 Manus,模型和电脑都由服务方提供,开箱即用,适合不想折腾的人。另一条就是自己部署开源框架,比如 OpenClaw,机器和模型自己选,代码自己控,数据不出本机,适合愿意花时间调教、把 Agent 当成长期资产的人。两条路技术本质相同,都是「大模型 + 电脑」的通用 Agent,差别只在于你要的是省心,还是主权。人人都有一个 Agent,可能指的是人人都有一个"能用"的窗口,但那个窗口是别人家的云端,还是你家电脑上的开源实例,选择权在你。

结语

通用机器人可以承担营救、探索等高危场景,比如去鳌太线执行救援。但私人助理这个场景,无论是 OpenClaw 还是以后出现的其他形态,更可能的方向都是开源且本地化,不被单一厂商垄断,完全由用户自己掌控。2025 年 vibe coding 让"人人都会用 AI 写代码"往前迈了一大步,2026 年"人人都有一个 Agent"不再是一句口号,而是两条可选的路径。OpenClaw 的价值不在于替代 Manus,而在于给愿意折腾的人一条路,用开源和本地换来自主可控和隐私,再通过社区和迭代,把能力一点点打磨成适合自己的样子。选哪条路,取决于你更在意省心,还是更在意主权。

CSS 变量 + 主题切换:从 CSS-in-JS 回归原生方案的实践之路

一、故事的开头:一次构建耗时让我开始反思

事情是这样的。项目用了 styled-components 做主题系统,功能没问题,暗色模式切换丝滑得很。直到有一天,项目膨胀到 600+ 组件,dev server 启动要 40 秒,HMR 改个颜色值要等 3 秒。

打开 Chrome DevTools 的 Performance 面板一看——主题切换时,JS 执行时间 200ms+,整棵组件树在重新渲染。

就为了换个颜色?

颜色本来就是 CSS 的事,为什么要绕一圈让 JS 来管?

二、CSS-in-JS 做主题:到底贵在哪?

先搞清楚运行时成本。以 styled-components 为例:

// ❌ CSS-in-JS 方案:主题切换触发全量 re-render

const lightTheme = { bg: '#fff', text: '#333', primary: '#1890ff' }
const darkTheme = { bg: '#141414', text: '#ffffffd9', primary: '#177ddc' }

// ThemeProvider 本质是个 Context
<ThemeProvider theme={isDark ? darkTheme : lightTheme}>
  <App />
</ThemeProvider>

// 每个组件通过 Context 消费主题
const Card = styled.div`
  background: ${props => props.theme.bg};    // 运行时求值
  color: ${props => props.theme.text};       // 主题变 → 函数重新执行 → CSS 字符串重新生成
`
// 切换主题时:
// 1. Context value 变了 → 所有消费 theme 的组件触发 re-render
// 2. 每个组件重新执行模板函数,生成新的 CSS 字符串
// 3. styled-components 做 hash 比对,更新 <style> 标签
// 一个颜色变了,600 个组件跟着抖一遍

这就像你改了公司 Wi-Fi 密码,结果每个员工的电脑都要重启——明明换个密码就行了。

运行时成本拆解

环节 耗时占比 用 CSS 变量能否跳过
Context 传播 ~15% 完全跳过
模板函数执行 ~35% 完全跳过
CSS 字符串生成 ~25% 完全跳过
DOM style 更新 ~25% 两种方案都要,但粒度不同

前 75% 的成本,用 CSS 变量可以直接砍掉。

三、CSS 变量做主题:原理其实很朴素

CSS 自定义属性的核心能力:声明一次,到处引用,改一处全局生效

/* ✅ 在根节点声明变量 */
:root {
  --color-bg: #ffffff;
  --color-text: #333333;
  --color-primary: #1890ff;
}

/* 暗色主题:只需覆盖变量值,所有引用处自动更新 */
[data-theme="dark"] {
  --color-bg: #141414;
  --color-text: rgba(255, 255, 255, 0.85);
  --color-primary: #177ddc;
}

/* 组件样式引用变量,写一次永远不用改 */
.card {
  background: var(--color-bg);
  color: var(--color-text);
}

切换主题只要一行:

// 翻个开关,整个页面的颜色全换了
document.documentElement.setAttribute('data-theme', 'dark')
// 没有 re-render,没有 JS 重新计算
// 浏览器 CSS 引擎原生处理变量继承,比 JS 快一个数量级

主题切换是纯 CSS 行为,JS 只负责翻开关。这不是优化技巧,是选对了赛道。

四、工程化落地:不是改几个颜色那么简单

知道原理是一回事,在真实项目里落地是另一回事。以下是实际迁移过程中踩出来的路。

4.1 变量体系设计:三层架构

变量不能随便命名,否则维护成本比 CSS-in-JS 还高:

/* 第一层:基础色板(设计师维护,改了全局跟着变,开发不直接引用) */
:root {
  --palette-blue-6: #1890ff;
  --palette-gray-9: #333333;
  --palette-gray-1: #ffffff;
}

/* 第二层:语义化 Token(开发日常用这层,名字即含义) */
:root {
  --color-primary: var(--palette-blue-6);
  --color-bg-base: var(--palette-gray-1);     /* 比 --palette-gray-1 好懂得多 */
  --color-text-base: var(--palette-gray-9);
  --spacing-m: 16px;
  --radius-s: 4px;
  --font-size-base: 14px;
}

/* 第三层:组件级 Token(只有高频复用组件才需要,避免过度抽象) */
:root {
  --card-bg: var(--color-bg-base);
  --card-padding: var(--spacing-m);
  --card-radius: var(--radius-s);
}

一开始试过只用两层,后来发现暗色模式下 primary 色需要调亮度,但色板值不能改(会影响其他引用),只能在语义层做映射。三层看似多余,实则是最小必要设计。

4.2 暗色主题的实现

/* light.css */
:root,
[data-theme="light"] {
  --color-bg-base: #ffffff;
  --color-bg-elevated: #fafafa;
  --color-text-base: #333333;
  --color-text-secondary: #666666;
  --color-border: #e8e8e8;
  --color-primary: #1890ff;
  --color-primary-hover: #40a9ff;
  --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.08);  /* 浅色背景用浅阴影 */
}

/* dark.css */
[data-theme="dark"] {
  --color-bg-base: #141414;
  --color-bg-elevated: #1f1f1f;
  --color-text-base: rgba(255, 255, 255, 0.85);
  --color-text-secondary: rgba(255, 255, 255, 0.45);
  --color-border: #434343;
  --color-primary: #177ddc;
  --color-primary-hover: #3c9ae8;
  --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.32);  /* 深色背景要加重阴影,否则约等于没有 */
}

很多人只换颜色忘了换阴影。深色背景上用浅色模式的阴影,肉眼几乎看不出来。这种细节不踩一脚记不住。

4.3 主题切换的 JS 层

type Theme = 'light' | 'dark' | 'system'

const STORAGE_KEY = 'app-theme'

function setTheme(theme: Theme) {
  const resolved = theme === 'system'
    ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
    : theme

  document.documentElement.setAttribute('data-theme', resolved)
  // 存用户意图('system'),不是解析结果('dark')
  // 否则选了跟随系统,换台电脑就不跟随了
  localStorage.setItem(STORAGE_KEY, theme)
}

// 监听系统主题变化(用户选了"跟随系统"时生效)
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    if (localStorage.getItem(STORAGE_KEY) === 'system') {
      document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light')
    }
  })

// 页面加载时立即执行,避免闪白屏
setTheme((localStorage.getItem(STORAGE_KEY) as Theme) || 'system')

4.4 防闪烁:最容易被忽略的体验问题

如果主题初始化代码放在 Vue/React 的生命周期里,页面会先闪一下白色(默认主题),再切到暗色。用户会以为出 bug 了。

解法很暴力也很有效——在 <head> 里内联一段阻塞脚本:

<head>
  <script>
    // 必须同步执行,在 CSS 解析之前完成,所以放 <head> 内联
    ;(function() {
      var theme = localStorage.getItem('app-theme') || 'system'
      var resolved = theme === 'system'
        ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
        : theme
      document.documentElement.setAttribute('data-theme', resolved)
    })()
  </script>
  <link rel="stylesheet" href="/styles/theme.css">
</head>

对,特意在 <head> 里放了内联 JS。这在"JS 和 CSS 分离"的教条面前有点叛逆,但用户体验不闪屏 > 代码洁癖。

4.5 在 Vue 中封装

<script setup lang="ts">
import { ref, watchEffect } from 'vue'

type Theme = 'light' | 'dark' | 'system'

const theme = ref<Theme>(
  (localStorage.getItem('app-theme') as Theme) || 'system'
)

watchEffect(() => {
  const resolved = theme.value === 'system'
    ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
    : theme.value

  document.documentElement.setAttribute('data-theme', resolved)
  localStorage.setItem('app-theme', theme.value)
})

// 三档循环切换:light → dark → system → light ...
const toggle = () => {
  const order: Theme[] = ['light', 'dark', 'system']
  const idx = order.indexOf(theme.value)
  theme.value = order[(idx + 1) % order.length]
}
</script>

<template>
  <button @click="toggle">
    当前: {{ theme }}
  </button>
</template>

整个主题系统的 JS 代码不超过 30 行。对比 CSS-in-JS 方案需要的 ThemeProvider、createGlobalStyle、useTheme hook……写到这里开始怀疑之前那些代码是不是都白写了。

五、设计权衡:CSS 变量不是银弹

公平地说,CSS-in-JS 不是毫无优势,否则它不会流行这么多年。

CSS 变量的短板

维度 CSS 变量 CSS-in-JS
类型安全 纯字符串,写错了没提示 TypeScript 全链路检查
动态计算 有限(calc 能做一些) 完全的 JS 能力
作用域隔离 靠命名约定 自动 hash
死代码消除 手动管理 构建工具可 tree-shake
调试体验 DevTools 直接看到变量值 需要找到生成的 class

什么时候该用 CSS-in-JS?

高度动态的样式逻辑——比如拖拽编辑器中元素的位置、大小、旋转角度,这些值每帧都在变,用 CSS 变量意味着每帧都要 setProperty,而 CSS-in-JS 可以和组件状态直接绑定。

跨项目分发的组件库——如果组件库需要被不同技术栈的项目引用,CSS-in-JS 的零配置主题能力确实方便。

但对于 90% 的业务项目,尤其是中后台系统,CSS 变量就是更好的选择。

补上类型安全

// theme-tokens.ts —— 单一事实源
export const tokens = {
  colorPrimary: '--color-primary',
  colorBgBase: '--color-bg-base',
  colorTextBase: '--color-text-base',
  spacingM: '--spacing-m',
  radiusS: '--radius-s',
} as const

type TokenKey = keyof typeof tokens

// 工具函数:拼写错了 TS 直接报错
export const cssVar = (key: TokenKey): string => {
  return `var(${tokens[key]})`
}

// ✅ cssVar('colorPrimary')  → "var(--color-primary)"
// ❌ cssVar('colorPrimay')   → TypeScript Error,手滑也能兜住

不如 CSS-in-JS 的类型安全那么"原生",但覆盖了最常见的拼写错误场景,投入产出比很高。

六、性能实测:数字说话

同一个项目上做了 A/B 对比(600+ 组件的中后台系统):

指标 styled-components CSS 变量 提升
主题切换耗时 ~210ms ~6ms 35x
首屏 CSS 体积 180KB(运行时生成) 12KB(变量文件) 15x
Dev HMR 速度 2.8s 0.3s 9x
运行时 JS 体积 +45KB(styled 运行时) +0KB -
Lighthouse 性能评分 72 91 +19

35 倍的切换速度差距不是优化出来的,是选型决定的。

小项目(< 50 组件)这个差距可以忽略不计,用啥都行。但项目会长大,技术选型要为未来的规模买单。

七、迁移策略:渐进式,不要一刀切

如果你已经在用 CSS-in-JS,不建议大爆炸式迁移:

Phase 1(1 周):定义 CSS 变量体系,新组件直接用变量
Phase 2(持续):改一个组件 → 删一个 styled 依赖,随业务迭代逐步替换
Phase 3(收尾):最后一个 styled 组件迁完,移除 styled-components 依赖

关键是 Phase 1 和 Phase 2 可以共存。CSS 变量和 CSS-in-JS 不冲突,styled-components 里照样能引用 CSS 变量:

// 过渡期写法:styled 组件内部用 CSS 变量替代 theme 引用
const Card = styled.div`
  background: var(--color-bg-base);
  color: var(--color-text-base);
  padding: var(--spacing-m);
`
// 这个组件不再依赖 ThemeProvider 了
// 等哪天有空,把 styled.div 换成普通 class 就行

这比一口气重写 600 个组件靠谱多了。

八、边界与踩坑

踩坑 1:CSS 变量不支持媒体查询条件

/* ❌ CSS 变量不能用在媒体查询的条件里 */
@media (max-width: var(--breakpoint-md)) {
  /* 无效,浏览器直接忽略 */
}

/* ✅ 断点值只能硬编码,但可以在媒体查询内部改变量值 */
@media (max-width: 768px) {
  :root {
    --spacing-m: 12px;
  }
}

踩坑 2:var() 的 fallback 陷阱

.text {
  /* fallback 只在变量完全未定义时生效 */
  color: var(--color-text, #333);

  /* ❌ 如果变量被定义为空字符串,fallback 不会触发 */
  /* --color-text: ;   → color 变成 invalid,但不会用 #333 */
}

踩坑 3:性能边界

CSS 变量的继承是有成本的。如果你在 :root 上定义了 200+ 个变量,每个 DOM 节点都会继承这些变量。在极端 DOM 节点数(10000+)的场景下,可能有几毫秒的额外布局计算。

实际项目中很少遇到这个问题。但如果你在做超大表格渲染,可以用 contain: style 限制变量继承范围。

九、这背后是什么思维?

CSS 变量 vs CSS-in-JS 的选择,归根到底是一个问题:配置数据应该放在它天然属于的层,还是拉到更高层统一管理?

颜色、间距、字号——这些是视觉层面的配置,天然属于 CSS。把它们拉到 JS 层管理,换来了类型安全和动态能力,但也付出了运行时成本和架构复杂度。

CSS 变量方案的成功印证了一条工程直觉:当原生能力足够好时,上层抽象的边际收益会快速递减。 类似的事情正在很多地方发生——fetch 替代 axios、<dialog> 替代 Modal 库、Container Queries 替代 JS resize observer。

不是说抽象不好,而是要问一句:这个抽象带来的好处,是否值得它引入的复杂度?

2020 年 CSS 变量浏览器支持还不够好,CSS-in-JS 是合理选择。到了 2026 年,CSS 变量已经是 baseline 能力,该让 CSS 做回 CSS 的事了。

如果你今天开始一个新的中后台项目——CSS 变量,闭眼选。如果你在纠结老项目要不要迁,回头看看第七节的渐进策略,先在新组件上用起来,成本几乎为零。

组合式函数的设计模式:如何写出真正可复用的 Vue3 Composables

你写了 20 个 composable,其中 15 个只有一个组件在用。

剩下 5 个被 3 个团队成员各 fork 了一份改了改,现在你有 20 个 composable。

这不是段子,这是我在一个中型项目里的真实经历。Vue3 的组合式函数给了我们前所未有的逻辑复用能力,但"能复用"和"复用得好"之间,隔着一整套设计思维。


一、先搞清楚一个问题:Composable 不是 utils

很多人写 composable 的第一反应是——把原来 mixins 里的东西搬过来。

// ❌ 这不是 composable,这是一个普通函数
const useFormat = () => {
  const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD')
  const formatMoney = (val: number) => ${val.toFixed(2)}`
  return { formatDate, formatMoney }
}

这个函数里没有任何响应式状态,没有生命周期,没有副作用。它就是一个工具函数集合,套了个 use 前缀。

Composable 的本质是:封装带有响应式状态的逻辑单元。

它和普通函数的区别在于:

  • 内部持有 ref / reactive 状态
  • 可能注册生命周期钩子(onMountedonUnmounted
  • 可能使用 watch / computed 建立响应式依赖
  • 返回值是响应式的,能驱动模板更新

如果你的函数不涉及以上任何一项,请放到 utils/ 目录下,别浪费 use 这个前缀。


二、一个好的 Composable 长什么样

先看一个真实场景:几乎每个后台系统都有的列表页——请求数据、分页、加载状态、错误处理。

第一版:能用但不好用

const useList = () => {
  const data = ref([])
  const loading = ref(false)
  const total = ref(0)
  const page = ref(1)
  const pageSize = ref(20)

  const fetchData = async () => {
    loading.value = true
    const res = await api.getList({ page: page.value, pageSize: pageSize.value })
    data.value = res.data
    total.value = res.total
    loading.value = false
  }

  onMounted(() => fetchData())

  return { data, loading, total, page, pageSize, fetchData }
}

看起来没问题?用一下就知道了:

  • API 地址写死了,换个列表页得再抄一份
  • 没有错误处理,接口挂了 loading 永远是 true
  • 分页参数变了不会自动请求
  • 无法控制是否立即执行

这是第一个设计原则:composable 必须接受参数,而不是硬编码行为。

第二版:参数化设计

interface UseListOptions<T> {
  api: (params: { page: number; pageSize: number }) => Promise<{ data: T[]; total: number }>
  immediate?: boolean
  defaultPageSize?: number
}

const useList = <T>(options: UseListOptions<T>) => {
  const { api, immediate = true, defaultPageSize = 20 } = options

  const data = ref<T[]>([]) as Ref<T[]>
  const loading = ref(false)
  const error = ref<Error | null>(null)
  const total = ref(0)
  const page = ref(1)
  const pageSize = ref(defaultPageSize)

  const fetchData = async () => {
    loading.value = true
    error.value = null
    try {
      const res = await api({ page: page.value, pageSize: pageSize.value })
      data.value = res.data
      total.value = res.total
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  watch([page, pageSize], () => fetchData())

  if (immediate) {
    onMounted(() => fetchData())
  }

  return { data, loading, error, total, page, pageSize, fetchData }
}

使用方式变成了:

const { data, loading, page, pageSize } = useList({
  api: (params) => getUserList(params),
  defaultPageSize: 10,
})

换个页面,换个 API 就行了。这才是可复用。


三、五个核心设计模式

经过大量实践,我总结出 composable 设计的五个关键模式。

模式一:参数支持 Ref 和原始值

这是很多人会忽略的点。看这个例子:

// 使用方不知道该传 ref 还是原始值
const useSearch = (keyword: string) => {
  // keyword 变了怎么办?拿不到新值
}

Vue 官方用了一个叫 MaybeRef 的模式:

import { ref, unref, watch, type Ref, type MaybeRef } from 'vue'

const useSearch = (keyword: MaybeRef<string>) => {
  const result = ref([])

  const doSearch = async () => {
    const val = unref(keyword) // 不管传的是 ref 还是原始值都能拿到值
    result.value = await searchApi(val)
  }

  // 如果传入的是 ref,自动监听变化
  watch(() => unref(keyword), () => doSearch())

  return { result, doSearch }
}

调用方就自由了:

// 传原始值——一次性搜索
const { result } = useSearch('vue3')

// 传 ref——响应式搜索
const keyword = ref('')
const { result } = useSearch(keyword)

这是 composable 灵活性的基础。 你的参数越宽容,使用场景就越多。

模式二:返回值用对象,不用数组

// ❌ React 风格,Vue 里不推荐
const [data, loading] = useList(api)

// ✅ 返回对象,按需解构
const { data, loading, error, fetchData } = useList({ api })

原因很简单:Vue 的 composable 通常返回 5 个以上的值。数组解构第五个位置是什么,三天后你自己都不记得了。

而且对象解构可以按需取用,不用的字段不解构就行,不会影响性能。

模式三:副作用自动清理

这条规则说起来简单,但忘了就是内存泄漏。

const useEventListener = (
  target: MaybeRef<EventTarget>,
  event: string,
  handler: EventListener
) => {
  onMounted(() => {
    unref(target).addEventListener(event, handler)
  })

  // 关键:组件卸载时自动移除
  onUnmounted(() => {
    unref(target).removeEventListener(event, handler)
  })
}

任何在 composable 里注册的事件、定时器、订阅,都必须在 onUnmounted 里清理。

更优雅的做法是用 onScopeDispose,它在 effectScope 销毁时触发,比 onUnmounted 更通用:

import { onScopeDispose } from 'vue'

const useInterval = (callback: () => void, interval: number) => {
  const id = setInterval(callback, interval)
  onScopeDispose(() => clearInterval(id))
}

模式四:支持配置合并与覆盖

当你的 composable 被多个团队使用时,不同业务线有不同的默认值需求。

interface UseDialogOptions {
  width?: string
  closable?: boolean
  maskClosable?: boolean
}

const DEFAULT_OPTIONS: UseDialogOptions = {
  width: '600px',
  closable: true,
  maskClosable: false,
}

const useDialog = (userOptions?: UseDialogOptions) => {
  const options = { ...DEFAULT_OPTIONS, ...userOptions }
  const visible = ref(false)

  const open = () => { visible.value = true }
  const close = () => { visible.value = false }

  return { visible, open, close, options }
}

为什么不直接把默认值写在参数解构里?因为当选项超过 5 个时,一个独立的默认对象更容易维护,也方便做全局配置覆盖。

模式五:组合而非继承

Composable 最强大的地方在于——它们可以互相组合

const usePagedList = <T>(options: UseListOptions<T>) => {
  // 复用 useList 的核心逻辑
  const listState = useList(options)

  // 在此之上增加搜索能力
  const keyword = ref('')
  const debouncedKeyword = useDebouncedRef(keyword, 300)

  watch(debouncedKeyword, () => {
    listState.page.value = 1
    listState.fetchData()
  })

  return {
    ...listState,
    keyword,
  }
}

usePagedList 没有重写 useList 的逻辑,而是组合了它。这就像搭积木——每个 composable 是一块积木,复杂功能通过组合实现。

写到这里我意识到,composable 的设计哲学和 Unix 管道惊人地相似:每个单元只做一件事,通过组合完成复杂任务


四、一个实战案例:表单验证 composable

来看一个生产环境中的真实设计过程。

需求

  • 支持多个字段的验证规则
  • 支持异步验证(比如检查用户名是否已存在)
  • 支持表单级和字段级验证
  • 返回每个字段的错误信息

第一层:单字段验证

type ValidateRule = (value: any) => true | string
type AsyncValidateRule = (value: any) => Promise<true | string>

const useFieldValidation = (
  value: Ref<any>,
  rules: (ValidateRule | AsyncValidateRule)[]
) => {
  const error = ref('')
  const validating = ref(false)

  const validate = async (): Promise<boolean> => {
    validating.value = true
    error.value = ''

    for (const rule of rules) {
      const result = await rule(value.value)
      if (result !== true) {
        error.value = result
        validating.value = false
        return false
      }
    }

    validating.value = false
    return true
  }

  return { error, validating, validate }
}

第二层:组合成表单验证

const useFormValidation = <T extends Record<string, any>>(
  form: T,
  rules: Partial<Record<keyof T, (ValidateRule | AsyncValidateRule)[]>>
) => {
  const fields: Record<string, ReturnType<typeof useFieldValidation>> = {}

  for (const key of Object.keys(rules) as (keyof T)[]) {
    const fieldRef = isRef(form[key]) ? form[key] : toRef(form, key as string)
    fields[key as string] = useFieldValidation(fieldRef, rules[key]!)
  }

  const validateAll = async (): Promise<boolean> => {
    const results = await Promise.all(
      Object.values(fields).map((f) => f.validate())
    )
    return results.every(Boolean)
  }

  const errors = computed(() => {
    const result: Record<string, string> = {}
    for (const [key, field] of Object.entries(fields)) {
      if (field.error.value) result[key] = field.error.value
    }
    return result
  })

  return { fields, errors, validateAll }
}

使用起来是这样的:

const form = reactive({ username: '', email: '' })

const { errors, validateAll } = useFormValidation(form, {
  username: [
    (v) => (v ? true : '用户名不能为空'),
    async (v) => (await checkUsername(v)) ? true : '用户名已存在',
  ],
  email: [
    (v) => (v ? true : '邮箱不能为空'),
    (v) => (/\S+@\S+\.\S+/.test(v) ? true : '邮箱格式不对'),
  ],
})

注意看,useFormValidation 内部组合了多个 useFieldValidation。每一层各司其职,但合在一起就是一个完整的表单验证系统。


五、设计权衡:该抽和不该抽

这是最容易踩坑的地方。

该抽成 composable 的

信号 示例
3 个以上组件有相同逻辑 列表请求、分页、搜索
逻辑涉及生命周期管理 事件监听、定时器、WebSocket
逻辑涉及响应式状态 + 副作用 防抖搜索、权限控制
逻辑单独可测试、可描述 "管理弹窗的开关状态"

不该抽的

信号 原因
只有 1 个组件在用 过早抽象,增加理解成本
纯计算,无响应式 放 utils 就够了
逻辑和 UI 强绑定 抽出来也没法复用
抽出来之后参数超过 8 个 说明它不是一个内聚的单元

我的经验法则:第一次写在组件里,第二次复制过去,第三次再抽。 过早抽象比不抽象更危险——你会基于不完整的场景做出错误的 API 设计,后面改起来成本极高。


六、可扩展性:当项目变大了

命名约定

composables/
  useList.ts          // 通用列表
  useDialog.ts        // 通用弹窗
  usePermission.ts    // 权限相关

views/user/
  composables/
    useUserList.ts    // 用户列表(组合了 useList)
    useUserForm.ts    // 用户表单

全局通用的放 composables/ 目录,业务级别的跟着模块走。命名上,通用的用 useXxx,业务的用 use[模块]Xxx

依赖注入:跨层级共享

当 composable 的状态需要跨组件共享时:

// 提供方
const useProvideUserContext = () => {
  const currentUser = ref<User | null>(null)
  provide('userContext', { currentUser })
  return { currentUser }
}

// 消费方
const useUserContext = () => {
  const ctx = inject('userContext')
  if (!ctx) throw new Error('useUserContext 必须在 UserProvider 内部使用')
  return ctx
}

这种模式在复杂表单、主题系统、多级联动场景里非常实用。

可插拔架构

更大型的项目里,你可以把 composable 设计成插件化的:

type Plugin<T> = (context: T) => void

const useList = <T>(options: UseListOptions<T>, plugins: Plugin<ListContext<T>>[] = []) => {
  const context = createListContext(options) // 创建内部上下文

  // 执行所有插件
  for (const plugin of plugins) {
    plugin(context)
  }

  return context.expose() // 暴露公开 API
}

// 使用
const { data } = useList(
  { api: getUserList },
  [withCache({ ttl: 60000 }), withRetry({ times: 3 })]
)

这就像给你的 composable 装上了中间件流水线——核心逻辑不变,扩展能力通过插件注入。


七、容易踩的坑

坑 1:在 composable 里解构 props

// ❌ 响应式丢失
const useFeature = (props: Props) => {
  const { name } = props // name 不再是响应式的
  watch(name, () => {}) // 不会触发
}

// ✅ 用 toRefs 或 getter
const useFeature = (name: MaybeRef<string>) => {
  watch(() => unref(name), () => {})
}

坑 2:共享状态的意外污染

// ❌ 模块级变量会被所有调用方共享
const cache = ref(new Map())

const useCache = () => {
  return { cache } // 所有组件共享同一个 cache
}

// ✅ 如果需要独立状态,必须在函数内部创建
const useCache = () => {
  const cache = ref(new Map()) // 每个组件独立
  return { cache }
}

这个坑特别隐蔽。如果你确实需要全局共享,请显式用 provide/inject 或 Pinia,而不是模块级变量——后者会让人误以为状态是独立的。

坑 3:忘了处理 SSR

如果你的项目涉及 SSR(如 Nuxt),composable 里不能直接访问 windowdocument

const useMouse = () => {
  const x = ref(0)
  const y = ref(0)

  // ✅ 通过生命周期钩子确保只在客户端执行
  onMounted(() => {
    window.addEventListener('mousemove', (e) => {
      x.value = e.clientX
      y.value = e.clientY
    })
  })

  return { x, y }
}

八、总结:Composable 设计的通用模型

回头看,一个设计良好的 composable 其实就是一个最小化的响应式状态机

  • 输入:通过参数(支持 MaybeRef)接收配置和依赖
  • 状态:内部用 ref / reactive 管理
  • 转换:通过 computedwatch 响应变化
  • 副作用:在生命周期钩子中注册和清理
  • 输出:返回响应式状态和操作方法

这个模型不只适用于 Vue。你在 React 的 custom hooks、Solid 的 primitives、甚至后端的 Actor Model 里,都能看到类似的影子——封装状态 + 暴露行为 + 管理生命周期

下次动手写 composable 之前,问自己三个问题:

  1. 它有独立的响应式状态吗?(不然放 utils)
  2. 第三次复用了吗?(不然别急着抽)
  3. 参数超过 5 个了吗?(该拆了)

想清楚再动手。好的抽象不是代码少,而是改的时候不用到处找

告别 Prop Drilling:Context API 的陷阱、Reducer 模式与原子化状态库原理

在 React 应用中,状态管理始终是一个核心议题。从早期的 Redux 一家独大,到 Context API 的官方标配,再到如今 Zustand、Jotai 等原子化状态库的崛起,技术选型的变化折射出我们对“状态”理解的深化。

很多团队默认使用 Context API 解决全局状态问题,却往往陷入性能陷阱;或者盲目引入重型库,导致架构过度设计。本文将深入剖析 Context API 的局限性,探讨 Reducer 模式的正确用法,并揭示原子化状态库(Atomic State)背后的核心原理。

一、Context API:便利背后的性能陷阱

1.1 为什么 Context 会引发不必要的重渲染?

Context 的设计初衷是解决“Prop Drilling”(属性层层传递)问题,而非作为高性能的全局状态管理工具。其核心机制是:当 Context 的 value 发生变化时,所有订阅该 Context 的组件都会重新渲染,无论它们是否使用了 value 中变化的那部分数据。

javascript

编辑

// ❌ 陷阱示例:粗粒度的 Context
const AppContext = createContext();

function App() {
  const [user, setUser] = useState({ name: 'Alice', age: 25 });
  const [theme, setTheme] = useState('dark');

  // user 或 theme 任意一个变化,所有消费者都会重渲染
  const value = useMemo(() => ({ user, theme, setUser, setTheme }), [user, theme]);

  return (
    <AppContext.Provider value={value}>
      <UserProfile />  {/* 只关心 user */}
      <ThemeToggle />  {/* 只关心 theme */}
    </AppContext.Provider>
  );
}

function ThemeToggle() {
  const { theme, setTheme } = useContext(AppContext);
  console.log('ThemeToggle 重渲染了'); 
  // 即使只更新了 user,这里也会重渲染!
  return <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>{theme}</button>;
}

1.2 优化方案:拆分 Context 与 选择器模式

方案 A:拆分 Context
将不同维度的状态拆分为独立的 Context,这是最直接的优化手段。

javascript

编辑

// ✅ 优化:拆分 Context
const UserContext = createContext();
const ThemeContext = createContext();

// UserProfile 只订阅 UserContext,ThemeToggle 只订阅 ThemeContext
// 互不干扰

方案 B:实现选择器(Selector)逻辑
如果必须使用单一 Context,可以结合 use-context-selector 或手动实现类似 Redux connect 的选择器逻辑,但这会增加代码复杂度,失去了 Context 的简洁性。

二、Reducer 模式:复杂状态的状态机思维

当状态逻辑变得复杂(涉及多个子值的联动、复杂的状态流转)时,useState 显得力不从心。此时,useReducer 是更好的选择。

2.1 何时使用 useReducer?

  • 状态包含多个子值(对象或数组)。
  • 下一个状态依赖于之前的状态。
  • 状态更新逻辑复杂,需要集中管理。
  • 需要记录状态变更历史或进行时间旅行调试。

2.2 实战:表单状态管理

javascript

编辑

// ✅ 使用 useReducer 管理复杂表单
const formReducer = (state, action) => {
  switch (action.type) {
    case 'CHANGE_FIELD':
      return { ...state, [action.field]: action.value, errors: { ...state.errors, [action.field]: null } };
    case 'SET_ERRORS':
      return { ...state, errors: action.errors };
    case 'RESET':
      return initialState;
    default:
      return state;
  }
};

function RegistrationForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleChange = (field, value) => {
    dispatch({ type: 'CHANGE_FIELD', field, value });
  };

  const handleSubmit = async () => {
    try {
      await api.register(state);
    } catch (err) {
      dispatch({ type: 'SET_ERRORS', errors: err.response.data.errors });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={state.username} onChange={(e) => handleChange('username', e.target.value)} />
      {state.errors.username && <span>{state.errors.username}</span>}
      {/* ... */}
    </form>
  );
}

优势:逻辑集中,易于测试,状态流转可预测。

三、原子化状态库:Zustand 与 Jotai 的原理揭秘

为了解决 Context 的性能问题并简化 API,原子化状态库应运而生。它们的核心理念是:将状态拆分为最小的独立单元(Atom),组件只订阅其依赖的特定 Atom。

3.1 Zustand:极简主义的 Store

Zustand 摒弃了 Provider 包裹的模式,利用发布订阅模式直接创建 Store。

核心原理:

  1. 无 Provider:状态存储在模块级的闭包中,通过 Hook 暴露。
  2. 精细订阅useStore(selector) 允许组件只选择需要的状态片段。只有当选择器返回的值发生变化时,组件才会重渲染。

javascript

编辑

// Zustand 示例
const useStore = create((set) => ({
  count: 0,
  text: 'hello',
  inc: () => set((state) => ({ count: state.count + 1 })),
  setText: (newText) => set({ text: newText }),
}));

function Counter() {
  // 只订阅 count,text 变化不会触发此组件重渲染
  const count = useStore((state) => state.count);
  const inc = useStore((state) => state.inc);
  return <button onClick={inc}>{count}</button>;
}

function TextDisplay() {
  // 只订阅 text,count 变化不会触发此组件重渲染
  const text = useStore((state) => state.text);
  return <div>{text}</div>;
}

3.2 Jotai:基于原子(Atom)的响应式系统

Jotai 更贴近 Recoil 的理念,采用自底向上的原子组合方式。

核心原理:

  1. Atom 定义:每个状态是一个独立的 Atom 对象。
  2. 依赖图:Jotai 内部维护一个依赖图。当某个 Atom 更新时,只有依赖它的派生 Atom 和订阅它的组件会更新。
  3. 异步原生支持:Atom 可以直接定义为异步函数,简化数据请求。

javascript

编辑

// Jotai 示例
import { atom, useAtom } from 'jotai';

const countAtom = atom(0);
const doubleCountAtom = atom((get) => get(countAtom) * 2); // 派生状态

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

function DoubleDisplay() {
  // 仅当 countAtom 变化时,此组件才会重渲染
  const [double] = useAtom(doubleCountAtom);
  return <div>{double}</div>;
}

四、选型建议

表格

特性 Context API Redux / Toolkit Zustand Jotai / Recoil
上手难度 极低
样板代码 极少
性能优化 需手动拆分/优化 内置选择器 内置选择器 自动依赖追踪
适用场景 低频更新的全局配置(主题、语言) 超大型应用,需要严格的时间旅行调试 大多数中小型应用,追求开发体验 复杂依赖关系,细粒度响应式需求

结语

没有银弹,只有最适合的场景。对于简单的主题切换,Context 足矣;对于复杂的表单和流程,useReducer 是利器;而对于高频更新的全局状态,Zustand 或 Jotai 能带来显著的性能提升和开发愉悦感。理解它们的原理,才能做出明智的架构决策。

用 Playwright 实现博客一键发布到稀土掘金

用 Playwright 实现博客一键发布到稀土掘金

每次写完技术文章,手动发布到掘金总是让人头疼 —— 复制标题、粘贴正文、选分类、加标签、上传封面……重复劳动,能省就省。

于是我花了半天写了一个自动化发布工具,用 Python + Playwright 模拟浏览器操作,把整个发布流程跑通了。

核心思路

直接调用掘金官方 API 发布文章?理论上可行,但掘金没有公开的写作 API,逆向内部接口风险高、不稳定。

更靠谱的方案是 浏览器自动化 —— 用 Playwright 驱动真实浏览器,模拟用户的每一步操作:点击按钮、粘贴内容、选标签、点发布。

这样的好处是:

  • 不依赖接口,不怕平台改动 API
  • 跟正常用户操作完全一致,不触发风控
  • 调试直观,能实时看到浏览器在干什么

技术选型

工具 作用
Playwright 浏览器自动化(替代 Selenium,更现代)
pyperclip 系统剪贴板操作
PyYAML 解析 Markdown front matter
requests 下载封面图片

选 Playwright 而不是 Selenium 的原因:

  • 自带浏览器,不用手动下载 chromedriver 并对齐版本
  • API 更简洁,等待元素、截图等操作都更自然
  • 支持 storage_state,可以保存登录 cookie,下次直接复用

一个关键坑:CodeMirror 编辑器

掘金的文章编辑器用的是 CodeMirror,这是个富文本编辑器,它的特点是 HTML 内容随输入动态变化。

所以你不能element.fill()send_keys() 直接输入文章内容 —— 内容不会进到编辑器里。

正确做法是用 系统剪贴板粘贴

import pyperclip
import sys

# 把内容复制到剪贴板
pyperclip.copy(file_content)

# 点击编辑区域
content_element = page.locator('//div[@class="CodeMirror-code"]//span[@role="presentation"]')
content_element.click()

# 模拟 Ctrl+V 粘贴
if sys.platform == 'darwin':
    page.keyboard.press('Meta+v')
else:
    page.keyboard.press('Control+v')

粘贴后,掘金会自动解析 Markdown 并重新上传其中的图片。这个过程需要时间,建议 time.sleep(15) 等待图片上传完成。

Cookie 持久化:只登录一次

Playwright 的 storage_state 功能可以把浏览器的 cookie 和 localStorage 一起保存到本地 JSON 文件,下次启动时直接加载:

# 保存登录状态
storage = context.storage_state()
with open('storage/juejin_storage.json', 'w') as f:
    json.dump(storage, f)

# 下次启动时复用
context = browser.new_context(
    storage_state='storage/juejin_storage.json'
)

这样只需要手动登录一次,之后每次发布都自动复用登录态,无需重复扫码。

从 Markdown front matter 读取元数据

文章用 YAML front matter 格式管理元数据:

---
title: 文章标题
description: 文章摘要
image: https://example.com/cover.jpg
tags:
  - Python
  - 自动化
---

# 正文开始

解析代码:

import re
import yaml

def parse_front_matter(content: str) -> dict:
    match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
    if match:
        return yaml.safe_load(match.group(1)) or {}
    return {}

封面图片从 image 字段读取,先下载到本地,再通过 file input 上传:

file_input = page.locator("//input[@type='file']")
file_input.set_input_files(local_image_path)

完整发布流程

整个发布流程按顺序:

  1. 加载 cookie → 自动登录
  2. 打开掘金创作者页面https://juejin.cn/creator/home
  3. 点击写文章.send-button
  4. 等待编辑器加载 → 检测标题 input 出现
  5. 填写标题//input[@placeholder="输入文章标题..."]
  6. 粘贴正文 → 剪贴板 + Ctrl+V
  7. 等待图片上传 → sleep 15s
  8. 点击发布按钮 → 打开发布设置面板
  9. 设置分类 → 点击对应分类按钮
  10. 添加标签 → 剪贴板粘贴 + 选下拉框
  11. 上传封面 → file input
  12. 设置摘要 → textarea 填充
  13. 确认发布 → 点击"确定并发布"

使用方法

第一步:安装依赖

pip install playwright pyyaml pyperclip requests
playwright install chromium

第二步:登录(只需一次)

python3 login.py juejin

浏览器会打开掘金,扫码登录后按 Enter,cookie 自动保存。

第三步:发布文章

# 不自动发布,人工 review
python3 publish.py --platform juejin --content article.md --no-publish

# 全自动发布
python3 publish.py --platform juejin --content article.md

扩展性

整个架构设计上很容易扩展新平台:

publisher/
├── juejin_publisher.py   ✅ 稀土掘金
├── zhihu_publisher.py    🟡 知乎(TODO)
└── csdn_publisher.py     🟡 CSDN(TODO)

每个平台只需实现一个 xxx_publisher(page, content_file) 函数,主流程自动调用。后续会陆续补上知乎和 CSDN 的支持。

小结

用 Playwright 自动化发布博客的核心就两点:

  1. Cookie 持久化 —— 一次登录,长期复用
  2. 剪贴板粘贴 —— 应对富文本编辑器(CodeMirror)

比 Selenium 更轻量,比逆向 API 更稳定。如果你也在多个平台同步发布文章,不妨试试这个思路。


项目地址稍后整理后会放出,欢迎 star ⭐

Nest 项目小实践之前端注册登陆

使用 react 写登陆和注册 跑起来 添加 router

npx create-vite book-management-system-frontend2

npm run dev

npm install --save react-router-dom

image.png

main.tsx 添加路由

import ReactDOM from 'react-dom/client';
import { RouterProvider, createBrowserRouter} from 'react-router-dom';

function BookManage() {
  return <div>book</div>;
}

function Login(){
  return <div>login</div>;
}

function Register(){
  return <div>register</div>;
}

const routes = [
  {
    path: "/login",
    element: <Login/>,
  },
  {
    path: "/register",
    element: <Register/>,
  },
  {
    path: "/",
    element: <BookManage/>,
  },
];

const router = createBrowserRouter(routes);

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

root.render(<RouterProvider router={router}/>);

添加这几个

image.png

内容先写最简单

image.png

更新 main.ts

import ReactDOM from 'react-dom/client';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { Login } from './pages/Login'
import { Register } from './pages/Register'
import { BookManage } from './pages/BookManage'

const routes = [
  {
    path: "/login",
    element: <Login/>,
  },
  {
    path: "/register",
    element: <Register/>,
  },
  {
    path: "/",
    element: <BookManage/>,
  },
];

const router = createBrowserRouter(routes);

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

root.render(<RouterProvider router={router}/>);

image.png

使用 antd 让页面更好看

npm install antd --save

注册页面

import { Button, Form, Input } from 'antd';
import './index.css';

interface RegisterUser {
    username: string;
    password: string;
    password2: string;
}

const onFinish = (values: RegisterUser) => {
    console.log(values);
};

const layout1 = {
    labelCol: { span: 4 },
    wrapperCol: { span: 20 }
}

const layout2 = {
    labelCol: { span: 0 },
    wrapperCol: { span: 24 }
}

export function Register() {
    return <div id="register-container">
        <h1>图书管理系统</h1>
        <Form
            {...layout1}
            onFinish={onFinish}
            colon={false}
            autoComplete="off"
        >
            <Form.Item
                label="用户名"
                name="username"
                rules={[{ required: true, message: '请输入用户名!' }]}
            >
                <Input />
            </Form.Item>

            <Form.Item
                label="密码"
                name="password"
                rules={[{ required: true, message: '请输入密码!' }]}
            >
                <Input.Password />
            </Form.Item>

            <Form.Item
                label="确认密码"
                name="password2"
                rules={[{ required: true, message: '请输入确认密码!' }]}
            >
                <Input.Password />
            </Form.Item>

            <Form.Item
                {...layout2}
            >
                <div className='links'>
                    <a href='/login'>已有账号?去登录</a>
                </div>
            </Form.Item>

            <Form.Item
                {...layout2}
            >
                <Button className='btn' type="primary" htmlType="submit">
                    注册
                </Button>
            </Form.Item>
        </Form>
    </div>   
}

index.css

#register-container {
    width: 400px;
    margin: 100px auto 0 auto;
    text-align: center;
}

#register-container .links {
    display: flex;
    justify-content: center;
}

#register-container .btn {
    width: 100%;
}

访问看看

image.png

输入信息打印看下

image.png

使用 axios 调用后端接口

npm install --save axios

添加文件 interfaces/index.ts

import axios from "axios";

const axiosInstance = axios.create({
  baseURL: "http://localhost:3000/",
  timeout: 3000,
});

export async function register(username: string, password: string) {
  return await axiosInstance.post("/user/register", {
    username,
    password,
  });
}

在 Register 组件的 onFinish 里调用

const onFinish = async (values: RegisterUser) => {

    if(values.password !== values.password2) {
        message.error('两次密码不一致');
        return;
    }

    try {
        const res = await register(values.username, values.password);

        if(res.status === 201 || res.status === 200) {
            message.success('注册成功');

            setTimeout(() => {
                window.location.href = '/login';
            }, 1000);
        }
    } catch(e: any) {
        message.error(e.response.data.message);
    }
}

提示跨域 需要配置下

image.png

image.png

再次注册就会成功 会跳转到 登陆页

实现下

import { Button, Form, Input, message } from 'antd';
import './index.css';

interface LoginUser {
    username: string;
    password: string;
}

const onFinish = async (values: LoginUser) => {
    console.log(values);
}

const layout1 = {
    labelCol: { span: 4 },
    wrapperCol: { span: 20 }
}

const layout2 = {
    labelCol: { span: 0 },
    wrapperCol: { span: 24 }
}

export function Login() {
    return <div id="login-container">
        <h1>图书管理系统</h1>
        <Form
            {...layout1}
            onFinish={onFinish}
            colon={false}
            autoComplete="off"
        >
            <Form.Item
                label="用户名"
                name="username"
                rules={[{ required: true, message: '请输入用户名!' }]}
            >
                <Input />
            </Form.Item>

            <Form.Item
                label="密码"
                name="password"
                rules={[{ required: true, message: '请输入密码!' }]}
            >
                <Input.Password />
            </Form.Item>

            <Form.Item
                {...layout2}
            >
                <div className='links'>
                    <a href='/register'>没有账号?去注册</a>
                </div>
            </Form.Item>

            <Form.Item
                {...layout2}
            >
                <Button className='btn' type="primary" htmlType="submit">
                    登录
                </Button>
            </Form.Item>
        </Form>
    </div>
}

index.css

#login-container {
    width: 400px;
    margin: 100px auto 0 auto;
    text-align: center;
}

#login-container .links {
    display: flex;
    justify-content: center;
}

#login-container .btn {
    width: 100%;
}

试试登陆

image.png

添加

image.png

login onFinish 里使用

const onFinish = async (values: LoginUser) => {
    try {
        const res = await login(values.username, values.password);

        if(res.status === 201 || res.status === 200) {
            message.success('登录成功');

            setTimeout(() => {
                window.location.href = '/';
            }, 1000);
        }
    } catch(e: any) {
        message.error(e.response.data.message);
    }
}

注册后登陆就可 登陆成功 到这里

image.png

前端JS: ES6新特性

ES6 核心新特性与示例

1. let 与 const

定义:新的变量声明方式,let声明块级作用域变量,const声明常量(不可重新赋值)。

// let 示例
let x = 10;
if (true) {
  let x = 20; // 不同的变量
  console.log(x); // 20
}
console.log(x); // 10

// const 示例
const PI = 3.14159;
// PI = 3.14; // 报错:不能重新赋值
const user = { name: "Alice" };
user.name = "Bob"; // 允许修改对象属性

2. 箭头函数

定义:使用 =>语法的简洁函数形式,不绑定自己的 this

// 传统函数
function add(a, b) {
  return a + b;
}

// 箭头函数
const addArrow = (a, b) => a + b;

// this 绑定差异
const obj = {
  value: 10,
  traditional: function() {
    setTimeout(function() {
      console.log(this.value); // undefined
    }, 100);
  },
  arrow: function() {
    setTimeout(() => {
      console.log(this.value); // 10
    }, 100);
  }
};

3. 模板字符串

定义:使用反引号(`)定义的字符串,支持多行和嵌入表达式。

const name = "Alice";
const age = 25;

// 传统拼接
const str1 = "姓名:" + name + ",年龄:" + age;

// 模板字符串
const str2 = `姓名:${name},年龄:${age}`;
const multiLine = `
  第一行
  第二行
  计算结果:${5 + 3}
`;
console.log(str2); // 姓名:Alice,年龄:25

4. 解构赋值

定义:从数组或对象中提取值到变量中的简洁语法。

// 数组解构
const colors = ['red', 'green', 'blue'];
const [first, , third] = colors;
console.log(first, third); // red blue

// 对象解构
const person = { name: 'Bob', age: 30, city: 'Beijing' };
const { name, city } = person;
console.log(name, city); // Bob Beijing

// 默认值
const { phone = '未知' } = person;
console.log(phone); // 未知

5. 默认参数

定义:函数参数可以指定默认值。

function greet(name = "访客", message = "你好") {
  return `${message}${name}!`;
}

console.log(greet()); // 你好,访客!
console.log(greet("Alice")); // 你好,Alice!
console.log(greet("Bob", "欢迎")); // 欢迎,Bob!

6. 扩展运算符

定义:使用 ...展开可迭代对象。

// 数组合并
const arr1 = [1, 2];
const arr2 = [3, 4];
const combined = [...arr1, ...arr2, 5];
console.log(combined); // [1, 2, 3, 4, 5]

// 对象合并
const obj1 = { a: 1, b: 2 };
const obj2 = { c: 3, d: 4 };
const merged = { ...obj1, ...obj2, e: 5 };
console.log(merged); // {a: 1, b: 2, c: 3, d: 4, e: 5}

// 函数参数
const numbers = [1, 5, 3, 9];
console.log(Math.max(...numbers)); // 9

7. Promise

定义:处理异步操作的标准化方案。

function asyncTask(success) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (success) {
        resolve("操作成功");
      } else {
        reject("操作失败");
      }
    }, 1000);
  });
}

// 使用
asyncTask(true)
  .then(result => console.log(result)) // 操作成功
  .catch(error => console.error(error));

// 链式调用
asyncTask(true)
  .then(data => data + "!")
  .then(finalData => console.log(finalData)); // 操作成功!

8. 模块化

定义:原生的模块导入导出语法。

// math.js
export const PI = 3.14159;
export function square(x) {
  return x * x;
}
export default function add(a, b) {
  return a + b;
}

// main.js
import add, { PI, square } from './math.js';
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
console.log(square(4)); // 16

这些特性极大地提升了JavaScript的开发效率和代码可读性,是现代前端开发的基础。

React 反模式(Anti-Patterns)排查手册:从性能杀手到逻辑陷阱

在 React 开发中,写出“能跑”的代码很容易,但写出“高效、可维护、无隐患”的代码却需要避开无数陷阱。许多看似合理的写法,实际上隐藏着严重的性能问题或逻辑 Bug。

本文整理了 React 开发中最常见的反模式(Anti-Patterns) ,并提供相应的重构方案,帮助你打造健壮的代码库。

一、性能杀手类反模式

1.1 在 JSX 中直接创建对象/函数

❌ 反模式:

function ListItem({ item }) {
  // 每次渲染都会创建新的对象和函数引用
  const style = { color: 'red' }; 
  const handleClick = () => console.log(item.id);

  return <div style={style} onClick={handleClick}>{item.name}</div>;
}

后果:即使 item 没变,style 和 handleClick 的引用也变了。如果 ListItem 被 React.memo 包裹,它将永远失效,导致子组件无限重渲染。

✅ 修正:

function ListItem({ item }) {
  const handleClick = useCallback(() => console.log(item.id), [item.id]);
  // 样式尽量提取到 CSS 文件或 styled-components,或使用 useMemo
  return <div className="text-red" onClick={handleClick}>{item.name}</div>;
}

1.2 滥用 useEffect 进行派生状态计算

❌ 反模式:

function Cart({ items }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    // 每次 items 变化都触发 Effect,多余且易出错
    const newTotal = items.reduce((sum, i) => sum + i.price, 0);
    setTotal(newTotal);
  }, [items]);

  return <div>Total: {total}</div>;
}

后果:增加了不必要的渲染周期(Render -> Effect -> SetState -> Re-render)。

✅ 修正:

function Cart({ items }) {
  // 渲染期间直接计算,React 会缓存结果
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <div>Total: {total}</div>;
}

注:只有在计算极其耗时(如大数据排序)时,才考虑 useMemo

二、逻辑陷阱类反模式

2.1 条件调用 Hooks

❌ 反模式:

function UserComponent({ isAdmin }) {
  if (isAdmin) {
    useEffect(() => { /* 监控管理员操作 */ });
  }
  // ...
}

后果:违反 Rules of Hooks。Hooks 的调用顺序必须一致,否则会导致状态错位(State Mismatch),引发难以调试的 Bug。

✅ 修正:

function UserComponent({ isAdmin }) {
  useEffect(() => {
    if (!isAdmin) return;
    /* 监控管理员操作 */
  }, [isAdmin]);
}

2.2 索引(Index)作为 Key

❌ 反模式:

{todos.map((todo, index) => (
  <TodoItem key={index} todo={todo} />
))}

后果:当列表发生排序、删除或插入时,React 会错误地复用组件实例,导致输入框内容错乱、状态丢失。

✅ 修正:
始终使用业务唯一 ID:key={todo.id}。如果没有唯一 ID,考虑在数据结构生成时就赋予 UUID。

2.3 在 useEffect 中遗漏依赖项

❌ 反模式:

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // 这里的 count 永远是初始值 0 (闭包陷阱)
      setCount(count + 1); 
    }, 1000);
    return () => clearInterval(id);
  }, []); // 依赖项为空
}

后果:状态不更新或逻辑错误。

✅ 修正:

  • 方案 A:使用函数式更新 setCount(c => c + 1)
  • 方案 B:正确填写依赖项 [count](需注意可能导致的频繁重置定时器)。

三、架构与设计类反模式

3.1 过度封装自定义 Hooks

❌ 反模式:
为了“复用”,将只有两行代码的逻辑强行抽离成 useXXX,或者创建一个包含几十个状态的巨型 Hook。

  • 过小:增加了文件跳转成本,无实际收益。
  • 过大:违反了单一职责原则,导致组件只要用到其中一个状态就要重渲染。

✅ 建议:
遵循“三次法则”:同一段逻辑出现三次再考虑抽取。保持 Hooks 的粒度细小且专注,通过组合来构建复杂逻辑。

3.2 在 Provider 中直接传递字面量对象

❌ 反模式:

<MyContext.Provider value={{ user, logout }}>
  {children}
</MyContext.Provider>

后果:每次父组件渲染,value 对象引用都会变化,导致所有消费该 Context 的子组件无条件重渲染。

✅ 修正:

const value = useMemo(() => ({ user, logout }), [user, logout]);
<MyContext.Provider value={value}>
  {children}
</MyContext.Provider>

四、总结与自查清单

在 Code Review 或自我检查时,请问自己以下问题:

  1. Key:列表是否使用了稳定的唯一 ID?
  2. 引用:是否在 JSX 中直接创建了对象/函数?是否对 Context Value 使用了 useMemo
  3. 依赖useEffect 和 useCallback 的依赖数组是否完整?是否存在闭包陷阱?
  4. 计算:是否可以用直接计算替代 useEffect 派生状态?
  5. 规则:是否有条件调用 Hooks 的情况?
  6. 粒度:Custom Hooks 是否职责单一?Context 是否拆分得当?

避开这些反模式,不仅能提升应用性能,更能让代码逻辑清晰、易于维护。记住,最好的优化是写出符合 React 设计哲学的代码

Suspense:异步组件加载机制

在前面的文章中,我们学习了 Teleport 如何突破 DOM 树的限制。今天,我们将探索 Vue3 中另一个强大的特性:Suspense。它允许我们在等待异步组件时显示备用内容,极大地提升了用户体验。理解它的实现原理,将帮助我们更好地处理异步加载场景。

前言:为什么需要异步组件

从根本上来说,异步组件的实现不需要任何框架层面的支持,我们完全可以自己实现:

const Component = () => import('Component.vue');

上述代码中,使用动态导入语句 import() 来加载组件,它会返回一个 Promise 实例,这样就实现了异步的方式来渲染页面。但是,这也带来了问题:

  • 如果 Component 组件加载失败了或者加载超时了怎么办?
  • 组件加载时,是否需要占位内容,比如 Loading 组件?
  • 加载失败后,是否需要重试?

以上这些问题,就是异步组件要真正解决的问题。

异步组件

异步组件的定义

所谓异步组件,就是指在需要时才加载的组件,而不需要在应用初始化时就全部加载。这样可以有效减少首屏加载时间,提升用户体验。

异步组件的基本使用

<sctipt setup>
import { defineAsyncComponent } from 'vue'

// 最简单的用法
const AsyncComp = defineAsyncComponent(() => 
  import('./components/MyComponent.vue')
)
</script>

<template>
  <AsyncComp />
</template>

defineAsyncComponent 解析

工作原理

defineAsyncComponent 的核心是一个高阶组件,它接收一个返回 Promise 的工厂函数,在组件需要渲染时才会执行这个工厂函数。

完整配置

import { defineAsyncComponent, defineComponent } from 'vue';
import ErrorComponent from './components/Error.vue';
import LoadingComponent from './components/Loading.vue';

const AsyncComponentWithOptions = defineAsyncComponent({
  // 加载函数
  loader: () => import('./MyComponent.vue'),
  
  // 加载中组件
  loadingComponent: LoadingComponent,
  
  // 加载失败组件
  errorComponent: ErrorComponent,
  
  // 显示loading组件的延迟(默认200ms)
  delay: 200,
  
  // 超时时间(默认Infinity)
  timeout: 3000,
  
  // 是否可重试
  suspensible: true,
  
  // 错误处理
  onError(error, retry, fail, attempts) {
    if (attempts <= 3) {
      // 重试
      retry();
    } else {
      // 失败
      fail();
    }
  }
});

源码实现核心逻辑

function defineAsyncComponent(source) {
  if (typeof source === 'function') {
    source = { loader: source }
  }

  const {
    loader,
    loadingComponent,
    errorComponent,
    delay = 200,
    timeout,
    suspensible = true,
    onError: userOnError
  } = source

  let resolvedComp = null

  return defineComponent({
    name: 'AsyncComponentWrapper',
    
    setup() {
      const loaded = ref(false)
      const error = ref(null)
      const loading = ref(false)
      
      let loadingTimer = null
      let retries = 0

      function load() {
        if (resolvedComp) {
          return Promise.resolve(resolvedComp)
        }

        return new Promise((resolve, reject) => {
          // 实际加载逻辑
          loader()
            .then(comp => {
              resolvedComp = comp
              loaded.value = true
              resolve(comp)
            })
            .catch(err => {
              error.value = err
              reject(err)
            })
        })
      }

      // 返回渲染逻辑
      return () => {
        if (loaded.value) {
          return h(resolvedComp)
        } else if (error.value && errorComponent) {
          return h(errorComponent, { error: error.value })
        } else if (loading.value && loadingComponent) {
          return h(loadingComponent)
        }
        // 返回一个注释节点或空
        return null
      }
    }
  })
}

Suspense 的设计哲学

定义与作用

Suspense 是一个内置组件,用于协调组件树中的异步依赖。它可以在等待异步组件解析时显示 fallback 内容。

基本结构

<template>
  <Suspense>
    <!-- 异步组件等待完成时显示的内容 -->
    <template #default>
      <AsyncComponent />
    </template>
    
    <!-- 加载中的回退内容 -->
    <template #fallback>
      <div>Loading...</div>
    </template>
  </Suspense>
</template>

工作流程

Suspense工作流程

插槽结构

/**
 * Suspense组件定义
 */
const Suspense = {
  name: 'Suspense',
  __isSuspense: true,
  
  props: {
    timeout: {
      type: Number,
      default: 0
    }
  },
  
  setup(props, { slots }) {
    // 获取default和fallback插槽
    const defaultSlot = slots.default;
    const fallbackSlot = slots.fallback;
    
    // 状态管理
    const state = ref('pending'); // 'pending' | 'resolving' | 'resolved'
    const activeBranch = ref(null);
    const pendingBranch = ref(null);
    
    // 异步依赖收集
    const asyncDeps = new Set();
    const depPromises = [];
    
    // 挂起计数
    let pendingCount = 0;
    
    return {
      state,
      activeBranch,
      pendingBranch,
      asyncDeps,
      depPromises,
      pendingCount
    };
  },
  
  render() {
    const { state, activeBranch, pendingBranch } = this;
    
    if (state === 'pending') {
      // 显示fallback
      return this.fallbackSlot ? this.fallbackSlot() : null;
    }
    
    // 显示default内容
    return activeBranch || (this.defaultSlot ? this.defaultSlot() : null);
  }
};

Suspense 的挂起与恢复机制

异步依赖的类型

Suspense 可以处理两种类型的异步依赖:

  • 异步组件:使用 defineAsyncComponent 定义的组件
  • 异步 setupsetup 函数返回 Promise 的组件

上面的例子展示了如何使用 defineAsyncComponent 定义的组件,下面这个例子用来展示异步 setup

import { ref } from 'vue'

export default {
  async setup() {
    // setup 返回 Promise,Suspense 会等待这个 Promise
    const data = ref(null)
    // 模拟异步数据获取
    data.value = await fetch('/api/data').then(r => r.json())
    
    return {
      data
    }
  }
}

挂起与恢复的源码分析

// Suspense 组件的简化实现
function processSuspense(n1, n2, container) {
  const suspense = {
    // 挂起的 Promise 列表
    deps: [],
    
    // 正在挂起中的依赖数量
    pending: 0,
    
    // 挂起状态
    isResolved: false,
    
    // 异步依赖注册
    registerDep(instance, setupRender) {
      suspense.deps.push(instance)
      
      instance.asyncDep.catch(err => {
        // 错误处理
        suspense.handleError(err)
      }).then(setupRender => {
        // 依赖完成
        suspense.pending--
        if (suspense.pending === 0) {
          // 所有依赖都已完成,切换回正常状态
          suspense.resolve()
        }
      })
      
      if (suspense.pending++ === 0) {
        // 如果有 pending 状态,显示 fallback
        suspense.showFallback = true
      }
    },
    
    // 解析完成
    resolve() {
      suspense.isResolved = true
      suspense.showFallback = false
      // 重新渲染默认内容
    }
  }
}

手写实现 Suspense 组件

基础实现

import { defineComponent, h, Fragment } from 'vue'

export default defineComponent({
  name: 'Suspense',
  
  props: {
    timeout: Number
  },
  
  setup(props, { slots }) {
    // 异步依赖列表
    const asyncDeps = new Set()
    let isResolved = false
    
    // 注册异步依赖的方法,暴露给子组件使用
    const registerAsyncDep = (asyncDep) => {
      if (isResolved) return
      
      asyncDeps.add(asyncDep)
      
      asyncDep
        .then(() => {
          asyncDeps.delete(asyncDep)
          if (asyncDeps.size === 0 && !isResolved) {
            resolve()
          }
        })
        .catch((err) => {
          console.error('Suspense: 异步依赖执行失败', err)
        })
    }
    
    // 解析完成
    const resolve = () => {
      isResolved = true
      // 触发重新渲染
    }
    
    // 如果设置了超时
    if (props.timeout) {
      setTimeout(() => {
        if (!isResolved) {
          console.warn(`Suspense: 超过 ${props.timeout}ms 未完成解析`)
        }
      }, props.timeout)
    }
    
    // 提供 registerAsyncDep 给后代组件使用
    provide('registerAsyncDep', registerAsyncDep)
    
    return () => {
      // 如果没有异步依赖或已解析,显示 default 内容
      if (isResolved || asyncDeps.size === 0) {
        return slots.default ? slots.default() : null
      }
      
      // 否则显示 fallback
      return slots.fallback ? slots.fallback() : null
    }
  }
})

结合 defineAsyncComponent

// 增强的异步组件工厂函数
function createAsyncComponent(options) {
  const component = defineAsyncComponent(options)
  
  // 包装组件,使其与 Suspense 协作
  return defineComponent({
    name: 'SuspenseAsyncComponent',
    
    setup(props, { attrs, slots }) {
      const registerAsyncDep = inject('registerAsyncDep', null)
      const asyncComp = ref(null)
      
      // 异步加载组件
      const asyncDep = component().then(comp => {
        asyncComp.value = comp
      })
      
      // 向 Suspense 注册异步依赖
      if (registerAsyncDep) {
        registerAsyncDep(asyncDep)
      }
      
      return () => {
        if (asyncComp.value) {
          return h(asyncComp.value, attrs, slots)
        }
        return null
      }
    }
  })
}

异步依赖的等待与完成

嵌套 Suspense 的处理

<template>
  <Suspense>
    <template #default>
      <!-- 父级异步组件 -->
      <ParentAsyncComponent>
        <!-- 子级 Suspense -->
        <Suspense>
          <template #default>
            <ChildAsyncComponent />
          </template>
          <template #fallback>
            <div>子组件加载中...</div>
          </template>
        </Suspense>
      </ParentAsyncComponent>
    </template>
    
    <template #fallback>
      <div>父组件加载中...</div>
    </template>
  </Suspense>
</template>

完成顺序控制

// 等待多个异步依赖完成
const useSuspense = () => {
  const deps = ref([])
  const status = ref('pending') // pending | resolved | rejected
  
  const addDep = (promise) => {
    deps.value.push(promise)
    return promise
  }
  
  const waitForAll = async () => {
    try {
      await Promise.all(deps.value)
      status.value = 'resolved'
    } catch (err) {
      status.value = 'rejected'
      throw err
    }
  }
  
  return {
    status,
    addDep,
    waitForAll
  }
}

异步组件错误处理

完整的错误处理策略

const AsyncComponentWithErrorHandling = defineAsyncComponent({
  loader: () => import('./MyComponent.vue'),
  
  errorComponent: defineComponent({
    props: ['error', 'retry'],
    setup(props) {
      return () => h('div', [
        h('p', `加载失败: ${props.error.message}`),
        h('button', { onClick: props.retry }, '重试')
      ])
    }
  }),
  
  onError(error, retry, fail, attempts) {
    // 错误分类处理
    if (error.name === 'ChunkLoadError') {
      // 路由懒加载错误,可能是网络问题
      if (attempts <= 3) {
        console.log(`重试第 ${attempts} 次...`)
        retry()
      } else {
        console.error('网络异常,请检查网络连接')
        fail()
      }
    } else if (error.name === 'TimeoutError') {
      // 超时错误
      console.error('组件加载超时')
      fail()
    } else {
      // 其他错误
      console.error('未知错误:', error)
      fail()
    }
  }
})

Suspense 的错误边界

const SuspenseWithErrorBoundary = defineComponent({
  setup(props, { slots }) {
    const error = ref(null)
    
    const handleError = (err) => {
      error.value = err
    }
    
    provide('suspenseErrorHandler', handleError)
    
    return () => {
      if (error.value) {
        return h('div', { class: 'error-boundary' }, [
          h('h3', '出错了'),
          h('p', error.value.message),
          h('button', {
            onClick: () => {
              error.value = null
              // 触发重新加载
            }
          }, '重试')
        ])
      }
      
      return h(Suspense, null, {
        default: slots.default,
        fallback: slots.fallback
      })
    }
  }
})

Loading 状态的实现

优雅的 Loading 组件

<template>
  <div class="loading-container">
    <div class="loading-spinner" :style="{ width: size + 'px', height: size + 'px' }">
      <div class="spinner"></div>
    </div>
    <p v-if="text" class="loading-text">{{ text }}</p>
    <p v-if="progress !== undefined" class="loading-progress">
      {{ Math.round(progress * 100) }}%
    </p>
  </div>
</template>

<script setup>
defineProps({
  size: {
    type: Number,
    default: 40
  },
  text: {
    type: String,
    default: ''
  },
  progress: {
    type: Number,
    default: undefined
  }
})
</script>

<style scoped>
.loading-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 20px;
}

.loading-spinner {
  position: relative;
  display: inline-block;
}

.spinner {
  width: 100%;
  height: 100%;
  border: 3px solid #f3f3f3;
  border-top: 3px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.loading-text {
  margin-top: 10px;
  color: #666;
  font-size: 14px;
}

.loading-progress {
  margin-top: 8px;
  color: #3498db;
  font-size: 16px;
  font-weight: bold;
}
</style>

进度跟踪的实现

// 可跟踪进度的异步组件加载器
function createProgressAsyncComponent(options) {
  const { loader, onProgress } = options
  
  return defineAsyncComponent({
    loader: () => {
      return new Promise((resolve, reject) => {
        // 模拟进度更新
        let progress = 0
        const timer = setInterval(() => {
          progress += 0.1
          if (progress <= 0.9) {
            onProgress?.(progress)
          }
        }, 100)
        
        loader()
          .then(comp => {
            clearInterval(timer)
            onProgress?.(1)
            resolve(comp)
          })
          .catch(err => {
            clearInterval(timer)
            reject(err)
          })
      })
    },
    
    loadingComponent: defineComponent({
      setup(_, { attrs }) {
        return () => h(LoadingComponent, {
          progress: attrs.progress
        })
      }
    })
  })
}

性能优化与最佳实践

预加载策略

// 预加载组件
const preloadComponent = (componentFactory) => {
  const comp = componentFactory()
  // 触发加载但不等待
  return comp
}

// 路由级别预加载
const router = createRouter({
  routes: [
    {
      path: '/dashboard',
      component: () => import('./views/Dashboard.vue'),
      // 预加载相关组件
      meta: {
        preload: () => {
          import('./components/Chart.vue')
          import('./components/Table.vue')
        }
      }
    }
  ]
})

// 在路由守卫中执行预加载
router.beforeEach((to, from, next) => {
  if (to.meta.preload) {
    to.meta.preload()
  }
  next()
})

Suspense 的最佳实践

<template>
  <div class="app">
    <!-- 为关键路径添加 Suspense -->
    <Suspense :timeout="3000" @resolve="handleResolve" @fallback="handleFallback">
      <template #default>
        <RouterView v-slot="{ Component }">
          <Suspense>
            <component :is="Component" />
            <template #fallback>
              <PageLoading />
            </template>
          </Suspense>
        </RouterView>
      </template>
      
      <template #fallback>
        <AppLoading />
      </template>
    </Suspense>
  </div>
</template>

<script setup>
const handleResolve = () => {
  console.log('路由解析完成')
}

const handleFallback = () => {
  console.log('显示加载状态')
}
</script>

缓存策略

// 实现组件缓存
const componentCache = new Map()

function createCachedAsyncComponent(loader, key) {
  if (componentCache.has(key)) {
    return componentCache.get(key)
  }
  
  const asyncComp = defineAsyncComponent({
    loader,
    loadingComponent: LoadingComponent,
    delay: 200
  })
  
  componentCache.set(key, asyncComp)
  return asyncComp
}

// 在路由中使用
{
  path: '/user/:id',
  component: (route) => {
    const userId = route.params.id
    return createCachedAsyncComponent(
      () => import('./views/UserProfile.vue'),
      `user-profile-${userId}`
    )
  }
}

结语

Suspense 和异步组件加载机制是 Vue3 中非常重要的特性,它们不仅解决了异步组件加载的显示问题,更重要的是提供了一个统一的异步依赖处理模型。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Teleport:渲染到任意DOM节点

在前面的文章中,我们学习了组件渲染、生命周期、依赖注入等核心概念。今天,我们将探索 Vue3 中一个特殊的组件:Teleport。它允许我们将一段 DOM 内容"传送"到指定的 DOM 节点,突破组件树的限制。理解它的实现原理,将帮助我们更好地处理模态框、全局提示等场景。

前言:Teleport 要解决的问题

在传统的 Vue 应用开发中,组件的渲染是严格按照组件树结构进行的,即组件的 DOM 树结构和组件树结构是完全一致的: 传统组件树结构 这种结构虽然直观,但会带来一些问题,比如下面一段代码,我们想创建一个模态对话框:

<template>
  <div class="main">
    <div class="content" id="content">
      <Modal class="modal">
        <!-- 模态框内容 -->
      </Modal>
    </div>
  </div>
</template>

在这段代码中,<Modal> 组件会被渲染到 idcontentdiv 标签下,但这其实并不是我们所想要的。因为对于模态对话框而言,其本质是一个“蒙层”组件,即该组件会渲染一个“蒙层”,并遮挡页面上的所有元素,此时最好的处理方式是:将<Modal> 组件的 z-index 设置到最高。

于是,问题就产生了:假如idcontentdiv 有一个内联样式:z-index: -1 ,此时即使把 <Modal> 组件的 z-index 设置成无穷大,也无法实现遮挡功能。

Teleport 的解决方案

Teleport 组件允许我们指定要渲染的目标,即 to 属性的值,该组件就会直接把 Teleport 组件的内容渲染到指定的目标下:

<template>
  <div class="main">
    <button @click="openModal">打开模态框</button>
    <Teleport to="body">
      <div class="modal">
        <h3>模态框标题</h3>
        <p>模态框内容</p>
      </div>
    </Teleport>
  </div>
</template>

Teleport 示例图

Teleport的目标定位

目标的多种形式

  1. CSS选择器:<Teleport to="body"></Teleport>
  2. DOM元素:<Teleport :to="targetElement"></Teleport>
  3. 动态目标:<Teleport :to="showModal ? 'body' : null"></Teleport>
  4. 禁用传送:<Teleport :to="target" :disabled="!isReady"></Teleport>

目标解析的实现

/**
 * 解析目标
 */
function resolveTarget(target, component) {
  if (typeof target === 'string') {
    // CSS选择器
    const el = document.querySelector(target);
    if (!el) {
      console.warn(`Teleport target "${target}" not found`);
      return document.body; // 降级到body
    }
    return el;
  } else if (target instanceof HTMLElement) {
    // 直接传入DOM元素
    return target;
  } else if (target?.$el) {
    // Vue组件实例
    return target.$el;
  } else if (target === null) {
    return null; // 禁用传送
  }
  
  return document.body; // 默认降级
}

/**
 * Teleport组件定义
 */
const Teleport = {
  name: 'Teleport',
  __isTeleport: true,
  
  props: {
    to: {
      type: [String, Object],
      required: true
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  
  setup(props, { slots }) {
    const target = ref(null);
    
    // 监听to变化
    watch(() => props.to, (newTo) => {
      target.value = resolveTarget(newTo);
    }, { immediate: true });
    
    // 返回插槽内容
    return () => {
      if (props.disabled || !target.value) {
        // 禁用时在当前位置渲染
        return slots.default?.();
      }
      
      // 启用时使用Teleport渲染
      return h(TeleportImpl, {
        to: target.value,
        disabled: false
      }, slots.default?.());
    };
  }
};

目标容器的缓存

/**
 * 目标容器缓存
 */
class TargetCache {
  constructor() {
    this.targets = new Map();
  }
  
  /**
   * 获取目标容器
   */
  getTarget(to, component) {
    const key = typeof to === 'string' ? to : to?.__v_skip ? null : to;
    
    if (key && this.targets.has(key)) {
      return this.targets.get(key);
    }
    
    const target = this.resolveTarget(to, component);
    
    if (key) {
      this.targets.set(key, target);
    }
    
    return target;
  }
  
  /**
   * 解析目标容器
   */
  resolveTarget(to, component) {
    if (typeof to === 'string') {
      // 尝试在组件上下文中查找
      if (to.startsWith('#')) {
        const id = to.slice(1);
        // 先在当前组件的模板中查找
        const contextEl = component?.vnode?.el?.ownerDocument;
        if (contextEl) {
          const el = contextEl.getElementById(id);
          if (el) return el;
        }
      }
      
      return document.querySelector(to) || document.body;
    }
    
    if (to instanceof HTMLElement) {
      return to;
    }
    
    if (to?.$el) {
      return to.$el;
    }
    
    return document.body;
  }
  
  /**
   * 清空缓存
   */
  clear() {
    this.targets.clear();
  }
}

const targetCache = new TargetCache();

父子组件关系维护

组件树 vs DOM树

Teleport 的一个重要特性就是:可以保持组件树的关系不变,仅仅只改变 DOM 树的关系。我们可以看下面一个示例:

// 父组件
const Parent = {
  setup() {
    const count = ref(0);
    
    provide('parentCount', count);
    
    return { count };
  },
  template: `
    <div class="parent">
      <button @click="count++">增加</button>
      <Teleport to="body">
        <Child />
      </Teleport>
    </div>
  `
};

// 子组件(被传送到body)
const Child = {
  inject: ['parentCount'],
  template: `
    <div class="child">
      父组件count: {{ parentCount }}
    </div>
  `
};

上述示例的 DOM 树与组件树关系图如下: 组件树 vs DOM树

组件实例的关联

/**
 * 维护组件实例关系
 */
function createTeleportVNode(component, props, children) {
  const vnode = {
    type: Teleport,
    props,
    children,
    shapeFlag: ShapeFlags.TELEPORT,
    
    // 组件实例(即使DOM分离,组件关系仍在)
    component: null,
    parent: component,
    
    // DOM引用
    el: null,
    anchor: null,
    
    // Teleport特有属性
    target: null,
    disabled: false
  };
  
  return vnode;
}

/**
 * 在渲染器中处理Teleport的父子关系
 */
class Renderer {
  patch(oldVNode, newVNode, container, anchor) {
    const { type } = newVNode;
    
    if (type === Teleport) {
      // Teleport特殊处理
      if (oldVNode == null) {
        this.mountTeleport(newVNode, container, anchor);
      } else {
        this.updateTeleport(oldVNode, newVNode, container, anchor);
      }
      
      // 维护组件实例关系
      if (newVNode.component) {
        newVNode.component.parent = this.currentInstance;
      }
      
      return;
    }
    
    // 普通节点处理...
  }
  
  /**
   * 挂载Teleport
   */
  mountTeleport(vnode, container, anchor) {
    // 创建组件实例(如果vnode包含组件)
    if (vnode.type !== Teleport && vnode.shapeFlag & ShapeFlags.COMPONENT) {
      const instance = createComponentInstance(vnode);
      vnode.component = instance;
      
      // 设置父组件
      if (this.currentInstance) {
        instance.parent = this.currentInstance;
      }
    }
    
    // 调用Teleport的处理逻辑
    Teleport.process(null, vnode, container, anchor, {
      patch: this.patch.bind(this),
      move: this.move.bind(this),
      unmount: this.unmount.bind(this)
    });
  }
}

事件冒泡的处理

/**
 * Teleport中的事件冒泡
 */
<template>
  <div class="parent" @click="handleParentClick">
    <Teleport to="body">
      <div class="child" @click="handleChildClick">
        <button @click.stop="handleButtonClick">按钮</button>
      </div>
    </Teleport>
  </div>
</template>

<script>
export default {
  methods: {
    handleParentClick() {
      console.log('父组件点击'); // 仍然会被触发
    },
    handleChildClick() {
      console.log('子组件点击'); // 会被触发
    },
    handleButtonClick() {
      console.log('按钮点击'); // 会被触发,且冒泡到child和parent
    }
  }
}
</script>

手写实现:完整Teleport组件

完整实现

/**
 * Teleport 完整实现
 */
const Teleport = {
  name: 'Teleport',
  __isTeleport: true,
  
  props: {
    to: {
      type: [String, Object],
      required: true,
      validator(value) {
        if (typeof value === 'string') return true;
        if (value instanceof HTMLElement) return true;
        if (value && value.$el) return true;
        return false;
      }
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  
  setup(props, { slots }) {
    // 目标容器
    const target = ref(null);
    
    // 目标解析错误处理
    const error = ref(null);
    
    // 解析目标
    const resolveTarget = () => {
      try {
        if (props.disabled) return null;
        
        if (typeof props.to === 'string') {
          const el = document.querySelector(props.to);
          if (!el) {
            throw new Error(`Teleport target "${props.to}" not found`);
          }
          return el;
        }
        
        if (props.to instanceof HTMLElement) {
          return props.to;
        }
        
        if (props.to?.$el) {
          return props.to.$el;
        }
        
        return null;
      } catch (err) {
        error.value = err.message;
        return null;
      }
    };
    
    // 监听to变化
    watch(() => props.to, () => {
      target.value = resolveTarget();
    }, { immediate: true, deep: true });
    
    // 提供错误信息(可选)
    provide('teleportError', error);
    
    // 返回渲染函数
    return () => {
      if (error.value) {
        console.error(`Teleport error: ${error.value}`);
      }
      
      // 返回一个占位注释节点和实际内容
      // 这样可以在DOM中标记位置
      return [
        h(Comment, `teleport-start-${props.to}`),
        props.disabled || !target.value
          ? slots.default?.()
          : h(TeleportWrapper, {
              to: target.value,
              disabled: false
            }, slots.default?.()),
        h(Comment, `teleport-end-${props.to}`)
      ];
    };
  }
};

/**
 * Teleport包装器(内部使用)
 */
const TeleportWrapper = {
  name: 'TeleportWrapper',
  __isTeleportWrapper: true,
  
  props: {
    to: {
      type: HTMLElement,
      required: true
    },
    disabled: Boolean
  },
  
  setup(props, { slots }) {
    const container = ref(null);
    const teleportContent = ref(null);
    
    onMounted(() => {
      if (!props.disabled && props.to && teleportContent.value) {
        // 将内容移动到目标容器
        while (teleportContent.value.firstChild) {
          props.to.appendChild(teleportContent.value.firstChild);
        }
      }
    });
    
    onUpdated(() => {
      if (!props.disabled && props.to && teleportContent.value) {
        // 更新时重新移动
        while (teleportContent.value.firstChild) {
          props.to.appendChild(teleportContent.value.firstChild);
        }
      }
    });
    
    onBeforeUnmount(() => {
      // 清理
      if (teleportContent.value) {
        teleportContent.value.innerHTML = '';
      }
    });
    
    return () => {
      if (props.disabled) {
        // 禁用时直接渲染
        return slots.default?.();
      }
      
      // 渲染到一个隐藏容器,然后移动到目标
      return h('div', {
        ref: teleportContent,
        style: { display: 'none' }
      }, slots.default?.());
    };
  }
};

// 注册Teleport
app.component('Teleport', Teleport);

增强版本(支持多个目标)

/**
 * 多目标Teleport
 */
const MultiTeleport = {
  name: 'MultiTeleport',
  
  props: {
    targets: {
      type: Array,
      required: true
    },
    distribution: {
      type: Array,
      default: () => [] // 指定每个子节点去哪个target
    }
  },
  
  setup(props, { slots }) {
    const children = slots.default?.() || [];
    
    // 分配子节点到不同target
    const distributions = props.distribution.length
      ? props.distribution
      : children.map((_, i) => i % props.targets.length);
    
    // 为每个target创建Teleport
    const teleports = props.targets.map((target, index) => {
      const targetChildren = children.filter((_, i) => distributions[i] === index);
      
      return h(Teleport, {
        to: target,
        key: index
      }, () => targetChildren);
    });
    
    return () => teleports;
  }
};

条件 Teleport

/**
 * 条件Teleport
 */
const ConditionalTeleport = {
  name: 'ConditionalTeleport',
  
  props: {
    to: [String, Object],
    condition: {
      type: Function,
      default: () => true
    },
    fallbackTo: {
      type: [String, Object],
      default: null
    }
  },
  
  setup(props, { slots }) {
    const currentTarget = ref(null);
    
    const updateTarget = () => {
      const shouldTeleport = props.condition();
      currentTarget.value = shouldTeleport ? props.to : props.fallbackTo;
    };
    
    // 初始更新
    updateTarget();
    
    // 监听条件变化(需要在组件中触发)
    // 可以通过事件或响应式数据触发
    
    return () => {
      if (!currentTarget.value) {
        return slots.default?.();
      }
      
      return h(Teleport, {
        to: currentTarget.value
      }, slots.default?.());
    };
  }
};

应用场景:模态框

基础模态框

<template>
  <div class="app">
    <button @click="showModal = true">打开模态框</button>
    
    <Teleport to="body">
      <div v-if="showModal" class="modal-overlay" @click="showModal = false">
        <div class="modal-container" @click.stop>
          <div class="modal-header">
            <h3>{{ title }}</h3>
            <button class="close" @click="showModal = false">×</button>
          </div>
          <div class="modal-body">
            <slot></slot>
          </div>
          <div class="modal-footer">
            <button @click="showModal = false">取消</button>
            <button class="primary" @click="confirm">确认</button>
          </div>
        </div>
      </div>
    </Teleport>
  </div>
</template>

<script>
export default {
  props: ['title'],
  emits: ['confirm'],
  data() {
    return {
      showModal: false
    };
  },
  methods: {
    open() {
      this.showModal = true;
    },
    close() {
      this.showModal = false;
    },
    confirm() {
      this.$emit('confirm');
      this.close();
    }
  }
};
</script>

<style scoped>
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-container {
  background-color: white;
  border-radius: 8px;
  min-width: 400px;
  max-width: 90%;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>

可拖拽模态框

const DraggableModal = {
  props: ['title'],
  setup(props, { emit }) {
    const show = ref(false);
    const position = ref({ x: 100, y: 100 });
    const dragging = ref(false);
    const dragStart = ref({ x: 0, y: 0 });
    
    const startDrag = (e) => {
      dragging.value = true;
      dragStart.value = {
        x: e.clientX - position.value.x,
        y: e.clientY - position.value.y
      };
    };
    
    const onDrag = (e) => {
      if (!dragging.value) return;
      position.value = {
        x: e.clientX - dragStart.value.x,
        y: e.clientY - dragStart.value.y
      };
    };
    
    const stopDrag = () => {
      dragging.value = false;
    };
    
    onMounted(() => {
      window.addEventListener('mousemove', onDrag);
      window.addEventListener('mouseup', stopDrag);
    });
    
    onUnmounted(() => {
      window.removeEventListener('mousemove', onDrag);
      window.removeEventListener('mouseup', stopDrag);
    });
    
    const open = () => show.value = true;
    const close = () => show.value = false;
    
    return {
      show,
      position,
      dragging,
      startDrag,
      open,
      close
    };
  }
};

模态框管理器

class ModalManager {
  constructor() {
    this.modals = [];
    this.container = null;
  }
  
  init() {
    // 创建容器
    this.container = document.createElement('div');
    this.container.id = 'modal-manager';
    document.body.appendChild(this.container);
  }
  
  open(component, props = {}) {
    const id = Symbol('modal');
    const modal = {
      id,
      component,
      props,
      resolve: null,
      reject: null
    };
    
    const promise = new Promise((resolve, reject) => {
      modal.resolve = resolve;
      modal.reject = reject;
    });
    
    this.modals.push(modal);
    this.update();
    
    return promise;
  }
  
  close(id, result) {
    const index = this.modals.findIndex(m => m.id === id);
    if (index !== -1) {
      const modal = this.modals[index];
      modal.resolve(result);
      this.modals.splice(index, 1);
      this.update();
    }
  }
  
  update() {
    // 触发重新渲染
    if (this.app) {
      this.app.config.globalProperties.$modals = this.modals;
    }
  }
}

结语

Teleport 是 Vue3 中一个强大的特性,它打破了 DOM 树的限制,让我们可以更灵活地组织组件。理解它的实现原理,不仅能帮助我们更好地使用它,也能在遇到复杂场景时找到合适的解决方案。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

❌