普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月22日技术

Nuxt 3 项目自动化部署到宝塔服务器全攻略 (GitHub Actions + rsync)

作者 知航驿站
2025年12月22日 18:19

本指南详细介绍了如何利用 GitHub Actions 持续集成工具,将 Nuxt 3 项目(静态生成 SSG 模式)自动化部署到宝塔面板服务器。


插件介绍

nuxt-web-plugin 是一款面向 Nuxt 3/4 的全能增强插件,旨在提升开发体验(DX)并为应用提供坚实的基础能力。

核心特性:

  • 🔐 深度安全防护: 集成 AES-GCM 对称加密、RSA 非对称加密及 SHA-256 哈希算法,支持加密存储(Storage/Cookie)。
  • 🛰️ 智能请求封装: 基于 $fetch 的统一网络层,内置 自动去重 (Dedupe)短时缓存 (Cache)并发锁 (Lock),有效防止重复请求。
  • 🖼️ 页面水印系统: 动态 Canvas 水印,支持防篡改监测(Anti-Tamper),保护页面内容版权。
  • 🔍 SEO & 设备检测: 自动元标签生成与移动端/平板/桌面端精准识别。
  • 🎨 玻璃拟态布局: 内置一套现代化的插件控制台模板,完美支持 Tailwind 暗色模式。

一、 准备工作

1.1 服务器环境

  • 确保服务器已安装 宝塔面板
  • 在宝塔面板中创建一个 静态网站(或 PHP 网站,但我们只需其静态能力)。
  • 记住你的网站根目录,例如:/www/wwwroot/nuxt.haiwb.com

1.2 生成 SSH 密钥对

在你的本地终端或服务器执行以下命令(建议在服务器执行):

# 生成密钥对 (ed25519 算法更安全且简短)
ssh-keygen -t ed25519 -C "github-actions-deploy"
  1. 公钥 (.pub): 将内容复制并添加到服务器的 ~/.ssh/authorized_keys 文件中。
  2. 私钥: 将内容完整复制,下一步使用。

二、 GitHub 仓库配置

进入你的 GitHub 项目仓库,点击 Settings -> Secrets and variables -> Actions,点击 New repository secret 添加以下变量:

变量名 说明 示例值
SERVER_SSH_KEY 刚才生成的 私钥 内容 -----BEGIN OPENSSH PRIVATE KEY----- ...
SERVER_HOST 服务器公网 IP 或域名 1.2.3.4nuxt.haiwb.com
SERVER_USER SSH 登录名 root (建议使用有权限的普通用户)
SERVER_TARGET 宝塔面板中的网站根目录 /www/wwwroot/nuxt.haiwb.com

三、 编写工作流文件

在项目根目录创建 .github/workflows/deploy-playground.yml 文件:

name: Deploy Playground to Baota

on:
  push:
    branches: [main] # 仅在代码推送到 main 分支时触发
  workflow_dispatch:  # 支持在 GitHub Actions 页面手动点击运行

jobs:
  deploy-to-baota:
    name: Upload to Server
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9
          run_install: false

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 24 # 关键:Node 版本需匹配项目要求 (v24.4.1+)
          cache: 'pnpm'

      - name: Install Dependencies
        run: pnpm install --no-frozen-lockfile

      - name: Build Playground (Static)
        run: |
          # 准备模块环境
          pnpm run dev:prepare
          cd playground
          # 生成静态文件 (SSG)
          npx nuxi generate 
          echo "Build Output Check:"
          ls -R .output/public/ # 打印构建结果,方便排查路径问题

      - name: Deploy to Server
        uses: easingthemes/ssh-deploy@main
        with:
          # SSH 私钥
          SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }}
          # 远程主机信息
          REMOTE_HOST: ${{ secrets.SERVER_HOST }}
          REMOTE_USER: ${{ secrets.SERVER_USER }}
          TARGET: ${{ secrets.SERVER_TARGET }}
          # 部署源目录 (Nuxt 3 SSG 产物路径)
          SOURCE: "playground/.output/public/"
          # rsync 参数: r(递归), l(链接), g(组), o(所有者), D(设备), z(压缩), v(详细), c(校验), --delete(删除多余文件)
          ARGS: "-rlgoDzvc -i --delete"
          # 关键排坑:排除服务器系统锁定的文件,否则会报 Operation not permitted (rsync code 23)
          EXCLUDE: "/.user.ini, /.htaccess, /.well-known/, /cgi-bin/"

四、 核心避坑指南 (Troubleshooting)

4.1 Node 引擎版本报错

错误信息: Unsupported engine: wanted: {"node":">=24.4.1"} 原因: 项目 package.json 限制了高版本 Node,而 GitHub Actions 默认环境较低。 对策: 在 actions/setup-node 步骤中明确指定 node-version: 24

4.2 预渲染死链报错

错误信息: Exiting due to prerender errors 原因: Nuxt 3 在 generate 过程中会检查所有链接,如果发现指向 /docs 等不存在的内部路径会报错。 对策:

  1. nuxt.config.ts 中配置 nitro: { prerender: { failOnError: false } }
  2. 将外部链接或独立部署的链接改为绝对路径(如 https://...)。

4.3 rsync exited with code 23

错误信息: unlink(.user.ini) failed: Operation not permitted (1) 原因: 宝塔面板会自动在网站目录创建 .user.ini 并锁定(i 权限)。rsync 尝试删除该文件以便同步时会被拦截。 对策: 在部署脚本中使用 EXCLUDE 配置将其排除掉。


五、 Nginx 伪静态设置 (非常重要)

为了让 Nuxt 的客户端路由正常工作,请在宝塔面板的网站设置 -> 伪静态 中添加以下内容:

location / {
  # 优先寻找文件和目录,找不到则 fallback 到 index.html 让 Vue 处理路由
  try_files $uri $uri/ /index.html;
}

Spec-Kit应用指南

2025年12月22日 17:39

GitHub Spec-Kit 使用指南

规范驱动开发(Spec-Driven Development) - 让 AI 编码更可控、更高效

一、什么是 Spec-Kit?

1.1 简介

Spec-Kit 是 GitHub 官方开源的规范驱动开发工具包,旨在改变传统的 AI 编码方式。

  • 官方仓库: github.com/github/spec…
  • 支持的 AI 工具: Claude Code、GitHub Copilot、Cursor、Gemini CLI、Windsurf 等

1.2 核心理念

传统开发 Spec-Driven 开发
想法 → 直接写代码 → 调试 → 补文档 想法 → 写规范 → AI 生成方案 → AI 实现 → 验证
代码是源头,文档是副产品 规范是源头,代码是规范的实现
“Vibe Coding” - 凭感觉写 结构化、可预测、可追溯

1.3 为什么需要 Spec-Kit?

传统 AI 编码的问题:

  • AI 理解不准确,生成的代码与预期不符
  • 缺乏上下文,AI 无法理解项目架构约束
  • 多人协作时,AI 生成的代码风格不一致
  • 难以追溯需求和实现的对应关系

Spec-Kit 的解决方案:

  • 规范即合约:AI 必须按照规范生成代码
  • Constitution(章程):定义项目的架构约束和编码规范
  • 结构化流程:Specify → Plan → Tasks → Implement
  • 质量门禁:每个阶段都有验证点

二、安装与配置

2.1 前置要求

  • Node.js 18+ 或 Python 3.10+(用于 CLI)
  • Git
  • AI 编码工具(推荐 Claude Code)

2.2 安装方式

方式一:使用 uvx(推荐,无需安装)

# 直接运行,无需安装
uvx --from git+https://github.com/github/spec-kit.git specify init my-project --ai claude

方式二:使用 uv 全局安装

# 安装 CLI
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git

# 初始化项目
specify init my-project --ai claude

方式三:使用 npm/bun

# 使用 bun
bun install -g @spec-kit/cli

# 或使用 npm
npm install -g @spec-kit/cli

# 初始化项目
specify init my-project --ai claude

2.3 初始化项目

# 新项目 + Claude Code
specify init my-project --ai claude

# 在当前目录初始化
specify init . --ai claude

# 跳过 git 初始化
specify init my-project --ai claude --no-git

# 其他 AI 工具
specify init my-project --ai copilot      # GitHub Copilot
specify init my-project --ai cursor-agent  # Cursor
specify init my-project --ai gemini        # Gemini CLI

2.4 初始化后的目录结构

my-project/
├── .specify/                    # Spec-Kit 配置目录
│   ├── memory/
│   │   └── constitution.md      # ⭐ 项目章程(架构约束)
│   ├── scripts/
│   │   ├── bash/                # Bash 脚本
│   │   └── powershell/          # PowerShell 脚本
│   └── templates/
│       ├── spec-template.md     # 规范模板
│       ├── plan-template.md     # 方案模板
│       └── tasks-template.md    # 任务模板
├── specs/                       # 规范文档存放目录
│   └── 001-feature-name/
│       ├── spec.md              # 功能规范
│       ├── plan.md              # 技术方案
│       └── tasks.md             # 任务列表
└── .claude/commands/            # Claude Code 自定义命令
    ├── specify.md
    ├── plan.md
    ├── tasks.md
    └── implement.md

三、工作流程

image.png

3.1 六阶段流程

Constitution → Specify → Clarify → Plan → Tasks → Implement
    ↓            ↓         ↓        ↓       ↓         ↓
 项目章程    功能规范   需求澄清   技术方案  任务拆分   代码实现

3.2 核心命令

阶段 命令 作用 输出物
1. Specify /specify 定义功能规范(WHAT) spec.md
2. Clarify /clarify 澄清模糊需求 更新 spec.md
3. Plan /plan 生成技术方案(HOW) plan.md
4. Tasks /tasks 拆分可执行任务 tasks.md
5. Implement /implement 执行代码实现 源代码
6. Analyze /analyze 质量检查 分析报告

3.3 详细流程说明

阶段一:编写 Constitution(章程)

Constitution 是项目的"宪法",定义了:

  • 技术栈和架构约束
  • 编码规范和命名规则
  • 依赖策略
  • 设计原则

示例(.specify/memory/constitution.md):

# 项目章程

## 技术栈
- 后端:Spring Boot 2.7 + MyBatis Plus + Dubbo 3.3
- 数据库:MySQL 8.0 + Redis
- 前端:Vue 3 + Element Plus

## 架构约束
- 分层架构:Controller/DubboApi → Service → Mapper
- Entity 必须放在 xxx.api.entity 包下
- 禁止在 Controller/DubboApi 中写业务逻辑

## 编码规范
- 使用 Spring Java Format 格式化代码
- 方法必须有 JavaDoc 注释
- 增删改操作必须添加 @Transactional

## 命名规则
- Entity:大驼峰,如 UserInfo
- Service 接口:I{Entity}Service,如 IUserInfoService
- Mapper:{Entity}Mapper,如 UserInfoMapper
阶段二:Specify(功能规范)
# 在 Claude Code 中执行
/specify

输入功能描述后,AI 会生成:

  1. 功能分支(如 001-user-login
  2. 规范目录(specs/001-user-login/
  3. 规范文档(spec.md

spec.md 示例:

# 功能规范:用户登录

## 背景
当前系统没有用户登录功能,需要实现基于手机号+验证码的登录流程。

## 用户故事
- 作为用户,我希望通过手机号和验证码登录,以便访问我的个人中心。
- 作为用户,我希望在验证码错误时收到明确提示。

## 验收标准
1. [ ] 用户输入手机号,点击发送验证码,后端生成并发送(模拟)。
2. [ ] 验证码有效期 5 分钟。
3. [ ] 登录成功返回 JWT Token。
4. [ ] 登录失败提示具体原因(验证码错误/过期)。

## 业务规则
- 手机号必须是 11 位数字。
- 同一手机号 1 分钟内只能请求一次验证码。
阶段三:Plan(技术方案)

在明确了“做什么”之后,下一步是确定“怎么做”。

# 在 Claude Code 中执行
/plan

输入spec.md + constitution.md 或直接回车

输出plan.md

plan.md 示例:

# 技术方案:用户登录

## 架构决策
- 使用 Redis 存储验证码,Key 格式:`auth:code:{phone}`- 使用 JJWT 库生成 Token。
- 遵循 Controller -> Service -> Manager -> Mapper 分层。

## 数据库变更
- 无需新增表,复用 `user_info` 表。

## API 设计
1. POST /api/v1/auth/code
   - Req: { phone: string }
   - Res: { success: boolean }
2. POST /api/v1/auth/login
   - Req: { phone: string, code: string }
   - Res: { token: string, user: UserInfo }

## 模块设计
- `AuthController`: 处理 HTTP 请求。
- `AuthService`: 核心业务逻辑(校验、颁发 Token)。
- `SmsManager`: 对接短信网关(Mock 实现)。
阶段四:Tasks(任务拆分)

将技术方案拆解为 AI 可以独立执行的原子任务(Atomic Tasks)。

# 在 Claude Code 中执行
/tasks

输入plan.md 或直接回车

输出tasks.md

tasks.md 示例:

# 任务列表

## 1. 基础设施
- [ ] Task 1.1: 添加 JJWT 和 Redis 依赖到 `pom.xml`- [ ] Task 1.2: 配置 Redis 连接参数。

## 2. 核心逻辑
- [ ] Task 2.1: 创建 `SmsManager` 并实现发送模拟逻辑。
- [ ] Task 2.2: 创建 `AuthService` 接口及实现,编写 `sendCode` 方法。
- [ ] Task 2.3: 在 `AuthService` 中实现 `login` 方法(含 Token 生成)。

## 3. 接口层
- [ ] Task 3.1: 创建 `AuthController` 并暴露 REST 接口。
- [ ] Task 3.2: 编写 Controller 层单元测试。
阶段五:Implement(执行实现)

AI 逐个读取任务并执行。

# 在 Claude Code 中执行
/implement

执行逻辑:

  1. AI 读取 tasks.md 中第一个未完成的任务。
  2. 读取相关文件上下文。
  3. 编写代码。
  4. 运行测试(如果定义了验证步骤)。
  5. 标记任务为 [x]
  6. 重复上述步骤,直到所有任务完成。
阶段六:Analyze(质量检查)
# 在 Claude Code 中执行
/analyze

对生成的代码进行质量分析,检查是否符合 constitution.md 中的规范,例如:

  • 是否遗漏了 JavaDoc?
  • 是否使用了被禁止的类?
  • 事务注解是否添加?

四、最佳实践

4.1 什么时候使用 Spec-Kit?

  • 推荐:复杂功能开发、需要多人协作、对代码质量有严格要求。
  • 不推荐:简单的 Bug 修复、临时脚本、纯文案修改。

4.2 存量项目接入(Brownfield)

对于已有项目,不需要一次性补全所有文档。可以采用增量接入策略:

  1. 初始化 Spec-Kit。
  2. 配置 constitution.md 以反映当前项目的最佳实践。
  3. 在开发新功能时,按照 Specify -> Plan -> Tasks -> Implement 流程进行。
  4. 对于旧代码的重构,可以先让 AI 读取旧代码生成 spec.md(逆向工程),再进行修改。

五、高级特性

5.1 自定义规范模板

你可以创建自己的规范模板,以适应不同的项目需求或团队标准。

# spec_templates/my_template.py
TEMPLATE = """
项目名称: {project_name}
技术栈: {tech_stack}
功能模块: {features}
性能要求: {performance}
"""

5.2 集成 CI/CD

Spec-Kit 可以与 CI/CD 工具集成,确保所有提交的规范和代码都符合标准。

# .github/workflows/spec-kit.yml
name: Spec-Kit Validation
on: [push]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Validate Specifications
        run: specify validate

5.3 团队协作模式

通过共享规范库实现团队协作,确保所有人使用统一的规范。

# 导出规范
specify export --output team-specs.json

# 导入团队规范
specify import team-specs.json

六、与现有工作流整合

6.1 与 Claude Code 集成

初始化后,Claude Code 自动获得 /specify/plan/tasks/implement 命令。

6.2 与 Git 工作流整合
# 1. 开始新功能
/specify "实现用户登录功能"

# AI 自动创建分支:001-user-login

# 2. 完成规范和方案
/plan
/tasks

# 3. 实现代码
/implement

# 4. 代码审查
/review-code

# 5. 提交 PR
git add .
git commit -m "feat: 实现用户登录功能"
git push
6.3 与现有项目规范整合

将公司现有规范整合到 Constitution 中:

# .specify/memory/constitution.md

## 引用公司规范
本项目遵循《城市停车微服务框架规范》,详见 CLAUDE.md

## 补充约束
- Entity 必须在 xxx.api.entity 包下
- 使用 ThreadPoolFactory 创建线程池
- 分页查询必须调用 startDubboPage()

七、常见问题 (FAQ)

Q1: Spec-Kit 支持哪些编程语言? Spec-Kit 是语言无关的,支持所有主流编程语言,包括 Python, Java, JavaScript, TypeScript, Go, Rust 等。它通过自然语言描述规范,因此不受限于特定编程语言。

Q2: 发现代码逻辑走不通怎么办?

千万不要直接改代码! 这会破坏“规范即源码”的原则,导致文档与代码脱节。 正确做法:

  1. 回滚:回到 Plan 阶段。
  2. 修改:更新 plan.md 中的技术决策或 tasks.md 中的任务拆分。
  3. 重生成:让 AI 重新生成受影响的代码。

Q3: 如何避免 Spec 文档腐烂?

  • 定期归档:Sprint 结束后,将完成的 specs/ 下的文档移动到 docs/archive/,保持工作区整洁。
  • 反哺章程:如果某个 Spec 引入了新的通用模式(例如确立了“新的权限控制方案”),应将其总结并更新到 .specify/memory/constitution.md 中,使其成为后续开发的标准。

Q4: 团队如何协作?

  • 产品经理 (PM): 负责 Review spec.md,重点关注验收标准(Acceptance Criteria)是否覆盖业务需求。
  • 架构师 / Tech Lead: 负责 Review plan.md,把控技术方案、数据库设计和 API 定义是否符合架构规范。
  • 开发者: 负责执行 tasks.md,并监督 AI 生成的代码质量,进行最终的 Code Review。

七、参考资料

  1. 官方仓库: github.com/github/spec…
  2. 官方博客: github.blog/ai-and-ml/g…
  3. Martin Fowler 文章: martinfowler.com/articles/ex…
  4. Microsoft 教程: developer.microsoft.com/blog/spec-d…
  5. 本文档参考来源: blog.csdn.net/a309220728/…

八、总结

Spec-Kit 不仅仅是一个工具,更是一种工程化思维的体现。它通过强制的结构化流程,解决了 AI 编程中常见的“幻觉”、“上下文丢失”和“不可控”问题。

  • 对于个人开发者:它是你的“外脑”,帮你理清思路,保持代码整洁。
  • 对于团队:它是无形的“架构师”,确保所有 AI 生成的代码都遵循统一的团队规范。

🔥 手写 Vue 自定义指令:实现内容区拖拽调整大小(超实用)

作者 酸菜土狗
2025年12月22日 17:29

日常开发中经常遇到需要手动调整内容区大小的场景,比如侧边栏、弹窗、报表面板等。分享一个我写的「拖拽调整大小指令」,支持自定义最小尺寸、拖拽手柄样式,能监听尺寸变化

📌 先看效果

image.png

🛠 核心代码解析

指令文件 directives/resizable-full.js ,关键部分:

1. 指令钩子:初始化 + 更新 + 清理

Vue 指令的 3 个核心钩子,保证指令的生命周期完整:

js

export default {
  bind(el, binding) {
    // 指令绑定时初始化拖拽功能
    initResizable(el, binding);
  },
  update(el, binding) {
    // 禁用状态变化时,重新初始化
    if (binding.value?.disabled !== binding.oldValue?.disabled) {
      cleanupResizable(el); // 先清理旧的
      initResizable(el, binding); // 再初始化新的
    }
  },
  unbind(el) {
    // 指令解绑时,清理所有手柄和事件(避免内存泄漏)
    cleanupResizable(el);
  }
};

2. 初始化拖拽:创建手柄 + 核心逻辑

initResizable 是核心函数,主要做 2 件事:创建拖拽手柄、写拖拽逻辑。

(1)创建拖拽手柄

我只保留了「右下角」的拖拽手柄(其他方向注释掉了,需要的话自己解开),样式可自定义:

js

// 定义手柄配置(只留了bottom-right)
const handles = [
  { dir: 'bottom-right', style: { bottom: 0, right: 0, cursor: 'nwse-resize' } }
];

// 循环创建手柄元素
handles.forEach(handleConf => {
  const handle = document.createElement('div');
  handle.className = `resizable-handle resizable-handle--${handleConf.dir}`;
  handle.dataset.dir = handleConf.dir;
  
  // 手柄样式:小方块、半透明、hover高亮
  Object.assign(handle.style, {
    position: 'absolute',
    width: `${handleSize}px`,
    height: `${handleSize}px`,
    background: handleColor,
    opacity: '0.6',
    zIndex: 999,
    transition: 'opacity 0.2s',
    ...handleConf.style
  });

  // hover时手柄高亮
  handle.addEventListener('mouseenter', () => handle.style.opacity = '1');
  handle.addEventListener('mouseleave', () => handle.style.opacity = '0.6');

  el.appendChild(handle); // 把手柄加到目标元素上
  el._resizableConfig.handles.push(handle); // 存起来方便后续清理
});

(2)拖拽核心逻辑

分 3 步:按下鼠标(记录初始状态)→ 移动鼠标(计算新尺寸)→ 松开鼠标(触发回调 + 清理):

js

// 1. 按下鼠标:记录初始位置和尺寸
const mouseDownHandler = (e) => {
  const handle = e.target.closest('.resizable-handle');
  if (!handle) return;

  e.stopPropagation();
  e.preventDefault();
  
  const dir = handle.dataset.dir;
  const rect = el.getBoundingClientRect(); // 获取元素当前位置和尺寸

  // 存初始状态:鼠标位置、元素尺寸/位置
  startState = {
    dir,
    startX: e.clientX,
    startY: e.clientY,
    startWidth: rect.width,
    startHeight: rect.height
  };

  // 绑定移动/松开事件(绑在document上,避免拖拽时鼠标移出元素失效)
  document.addEventListener('mousemove', onMouseMove);
  document.addEventListener('mouseup', onMouseUp);
};

// 2. 移动鼠标:计算新宽高并赋值
const onMouseMove = (e) => {
  if (!startState) return;
  const { dir, startX, startY, startWidth, startHeight } = startState;
  let newWidth = startWidth;
  let newHeight = startHeight;

  // 只处理右下角拖拽:宽高都增加
  if (dir === 'bottom-right') {
    newWidth = startWidth + (e.clientX - startX);
    newHeight = startHeight + (e.clientY - startY);
  }

  // 限制最小宽高(避免拖到太小)
  newWidth = Math.max(minWidth, newWidth);
  newHeight = Math.max(minHeight, newHeight);

  // 给元素设置新尺寸
  el.style.width = `${newWidth}px`;
  el.style.height = `${newHeight}px`;
};

// 3. 松开鼠标:触发回调+清理事件
const onMouseUp = () => {
  // 拖拽结束,触发自定义回调,返回最新尺寸
  if (startState && el._resizableConfig.onResize) {
    el._resizableConfig.onResize({
      width: parseInt(el.style.width),
      height: parseInt(el.style.height)
    });
  }
  startState = null;
  // 移除事件(避免重复绑定)
  document.removeEventListener('mousemove', onMouseMove);
  document.removeEventListener('mouseup', onMouseUp);
};

// 给元素绑定按下事件
el.addEventListener('mousedown', mouseDownHandler);

3. 清理函数:避免内存泄漏

cleanupResizable 负责移除所有手柄元素和事件监听器,指令解绑时必执行:

js

function cleanupResizable(el) {
  if (el._resizableConfig) {
    // 移除所有手柄
    el._resizableConfig.handles.forEach(handle => {
      if (handle.parentNode === el) el.removeChild(handle);
    });
    // 移除所有事件监听器
    el.removeEventListener('mousedown', el._resizableConfig.mouseDownHandler);
    document.removeEventListener('mousemove', el._resizableConfig.mouseMoveHandler);
    document.removeEventListener('mouseup', el._resizableConfig.mouseUpHandler);
    // 删除配置(释放内存)
    delete el._resizableConfig;
  }
}

🚀 如何使用?

  1. 全局注册指令(main.js):

js

import resizableFull from './directives/resizable-full';
Vue.directive('resizable-full', resizableFull);

2. 页面中使用

vue

<template>
  <!-- 给需要拖拽的元素加指令 -->
  <div 
    v-resizable-full="{
      minWidth: 300, // 最小宽度
      minHeight: 200, // 最小高度
      handleSize: 10, // 手柄大小
      handleColor: '#409eff', // 手柄颜色
      onResize: handleResize // 拖拽结束回调
    }"
    style="position: relative; width: 400px; height: 300px; border: 1px solid #eee;"
  >
    我是可拖拽调整大小的内容区~
  </div>
</template>

<script>
export default {
  methods: {
    // 拖拽结束,拿到最新尺寸
    handleResize({ width, height }) {
      console.log('新尺寸:', width, height);
    }
  }
};
</script>

💡 关键注意点(避坑)

  1. 目标元素必须设 position: relative/absolute/fixed:因为手柄是绝对定位,依赖父元素的定位;
  2. 事件绑在 document 上:拖拽时鼠标可能移出目标元素,绑在 document 上才不会断;
  3. 一定要清理事件 / 元素:指令解绑时执行 cleanupResizable,避免内存泄漏;
  4. 最小尺寸限制:通过 minWidth/minHeight 避免元素被拖到太小,影响体验。

🎨 扩展玩法

  1. 解开注释的其他 7 个方向手柄,实现全方向拖拽;
  2. 给手柄加 hover 提示(比如 “拖拽调整大小”);
  3. 支持拖拽时实时触发回调(不止结束时);
  4. 自定义手柄样式(比如改成虚线、加图标)。

📝 总结

这个自定义指令核心是「创建拖拽手柄 + 监听鼠标事件 + 计算尺寸变化」,逻辑不复杂,可以根据自己的业务场景定制。亲测报表和弹窗都很适用~

如果觉得有用,可以点个赞收藏一下,下次需要直接翻出来用😜

深入理解 React Hooks:useState 与 useEffect 的核心原理与最佳实践

作者 ohyeah
2025年12月22日 17:28

在现代 React 开发中,函数式组件配合 Hooks 已成为主流开发范式。其中,useStateuseEffect 是最基础、最常用的两个内置 Hook。它们分别负责管理组件的响应式状态和处理副作用逻辑。本文将结合代码示例与深入分析,带你全面掌握这两个核心 Hook 的使用方式、底层思想以及常见陷阱。


一、useState:让函数组件拥有“记忆”

1.1 基本用法

useState 是 React 提供的第一个 Hook,用于在函数组件中声明状态变量:

import { useState } from "react";

export default function App() {
  const [num, setNum] = useState(1);
  
  return (
    <div onClick={() => setNum(num + 1)}>
      {num}
    </div>
  );
}

这里 num 是当前状态值,setNum 是更新该状态的函数。每次调用 setNum 都会触发组件重新渲染,并使用新的状态值。

⚠️ 注意:不要直接修改状态(如 num++),必须通过 setNum 触发更新,否则 React 无法感知变化,也就无法触发视图的更新。

1.2 初始值支持函数形式

当初始状态需要复杂计算时,可以传入一个纯函数作为 useState 的参数:

const [num, setNum] = useState(() => {
  const num1 = 1 + 2;
  const num2 = 2 + 3;
  return num1 + num2; // 返回 6
});

这个函数只在组件首次渲染时执行一次,后续更新不会再次调用。这有助于避免不必要的性能开销。

✅ 关键点:该函数必须是同步的、无副作用的纯函数。不能包含 setTimeoutfetch 等异步操作,因为状态必须是确定的,如果是类似于fetch这种异步请求,它的状态是不确定的。

1.3 更新状态时使用函数式更新

当新状态依赖于前一个状态时,推荐使用函数式更新:

<div onClick={() => setNum(prevNum => prevNum + 1)}>
  {num}
</div>

prevNum会接收最新的num状态值,这种方式能确保你总是基于最新的状态值进行计算。


二、useEffect:处理副作用的“生命周期钩子”

如果说 useState 赋予组件“记忆”,那么 useEffect 就赋予组件“行动能力”——执行那些不属于纯渲染逻辑的操作,比如数据请求、订阅、定时器等。

2.1 基本结构

useEffect(() => {
  // 副作用逻辑
  return () => {
    // 清理函数(可选)
  };
}, [dependencies]); // 依赖数组
  • 第一个参数:副作用函数
  • 第二个参数:依赖项数组(决定何时重新执行)
  • 返回值(可选):清理函数,在下次 effect 执行前或组件卸载时调用

2.2 三种典型使用场景

场景一:模拟 componentDidMount(挂载时执行一次)

useEffect(() => {
  console.log('组件已挂载');
  queryData().then(data => setNum(data));
}, []); // 空依赖数组

📌 注意:空数组 [] 表示“仅在挂载时执行一次”。但如果组件被卸载后重新挂载,仍会再次执行。

场景二:监听状态变化(类似 watch

useEffect(() => {
  console.log('num 发生变化:', num);
}, [num]); // 依赖 num
  • 首次渲染时执行一次
  • 每当 num 变化时重新执行

场景三:无依赖项(每次渲染后都执行)

useEffect(() => {
  console.log('每次渲染后都会执行');
}); // 没有第二个参数

⚠️ 谨慎使用!容易引发无限循环或性能问题。

2.3 清理副作用:避免内存泄漏

很多副作用会创建持久资源(如定时器、事件监听器),必须在组件卸载或依赖变化时清理:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num); // 注意:这里打印的是 effect 创建时的 num(闭包)
  }, 1000);

  return () => {
    console.log('清理定时器');
    clearInterval(timer);
  };
}, [num]);
  • 每次 num 变化时,先执行上一次的清理函数(clearInterval),再创建新定时器。
  • 若不清理,会导致多个定时器同时运行,造成内存泄漏,每次新建的定时器那一块内存,没有办法回收了。

🔍 重要细节:console.log(num) 打印的是闭包中的旧值,不是最新状态!这是初学者常踩的坑。


三、纯函数 vs 副作用:React 的哲学基础

理解 useStateuseEffect 的设计,离不开对 纯函数副作用 的区分。

什么是纯函数?

  • 相同输入 → 相同输出
  • 无外部依赖(不修改外部变量)
  • 无 I/O 操作(如网络请求、DOM 操作)
// 纯函数 ✅
function add(x, y) {
  return x + y;
}

// 非纯函数 ❌(修改了外部数组)
function add(nums) {
  nums.push(3); // 副作用!
  return nums.reduce((a, b) => a + b, 0);
}

React 组件本身应尽量保持“纯”:输入 props,输出 JSX。而 useEffect 正是用来隔离副作用的机制。


四、常见误区与最佳实践

❌ 误区1:在 useState 初始值中使用异步函数

// 错误!useState 不支持异步
const [data, setData] = useState(async () => {
  const res = await fetch('/api');
  return res.json();
});

✅ 正确做法:用 useEffect 处理异步初始化:

useEffect(() => {
  fetch('/api').then(res => res.json()).then(setData);
}, []);

❌ 误区2:忘记清理定时器/监听器

会导致内存泄漏,尤其在路由切换或条件渲染组件时。

✅ 总是考虑是否需要返回清理函数。

❌ 误区3:依赖项遗漏或冗余

  • 遗漏依赖 → 使用旧值(闭包陷阱)
  • 冗余依赖 → 不必要的重复执行

五、总结

Hook 作用 关键特性
useState 管理响应式状态 支持函数式更新、惰性初始化
useEffect 处理副作用(数据请求、订阅等) 依赖控制、自动清理、闭包陷阱
  • 状态是组件的核心useState 让函数组件具备状态管理能力。
  • 副作用必须被隔离useEffect 是 React 对“纯组件”理念的优雅妥协。
  • 纯函数是基石,理解它才能写出可预测、可维护的 React 代码。

掌握 useStateuseEffect,就掌握了函数式组件的“灵魂”。在实际开发中,善用它们的特性,避开常见陷阱,你的 React 应用将更加健壮、高效。

📚 延伸阅读:React 官方文档 - Hooks


希望这篇文章能帮助你更深入地理解 React Hooks 的核心思想。如果你觉得有用,欢迎点赞、收藏并在评论区交流你的实践经验!

前端缓存深度解析:从基础到进阶的实现方式与实践指南

作者 MoMoDad
2025年12月22日 17:08

在前端开发中,缓存是提升页面性能、优化用户体验的关键技术之一。它通过将频繁访问的资源或数据存储在本地(浏览器)或中间节点,减少网络请求次数、降低服务器负载,同时实现更快的资源加载速度 —— 尤其在弱网、离线场景或高并发访问中,缓存的价值更为凸显。

前端缓存并非单一技术,而是一套覆盖 “服务器资源缓存”“前端数据持久化”“离线能力支持” 的完整体系。本文将从核心原理、实现方式、应用场景三个维度,系统拆解前端缓存的主流方案,并结合实际开发案例,帮助开发者精准选择合适的缓存策略。

一、HTTP 缓存:静态资源的 “性能基石”

HTTP 缓存是浏览器与服务器通过 HTTP 协议约定的缓存机制,主要针对静态资源(JS、CSS、图片、字体、静态 HTML 等),是前端性能优化的 “第一优先级” 方案。其核心逻辑是:首次请求时,服务器通过响应头告知缓存规则;后续请求时,浏览器先校验本地缓存,再决定是否发起网络请求。

HTTP 缓存分为强缓存协商缓存,优先级:强缓存 > 协商缓存。

1. 强缓存:无需网络请求,直接复用本地资源

强缓存的核心是 “本地缓存未过期则直接使用”,浏览器不会发起任何网络请求,资源加载速度最快(控制台状态码显示 200 OK (from disk cache) 或 200 OK (from memory cache))。

实现原理:响应头控制缓存有效期

服务器通过以下两个响应头定义强缓存规则(Cache-Control 优先级高于 Expires):

  • Cache-Control(HTTP/1.1 标准,推荐使用):通过指令组合指定缓存策略,常用指令:

    • max-age=xxx:缓存有效期(单位:秒),如 max-age=86400 表示缓存 1 天。
    • public:允许所有节点(浏览器、CDN、代理服务器)缓存该资源。
    • private:仅允许浏览器缓存(默认值),禁止中间节点缓存。
    • no-cache:禁用强缓存,直接进入协商缓存。
    • no-store:完全禁用缓存,每次必须请求服务器获取新资源。
    • immutable:声明资源永久不变,即使强缓存过期,浏览器也不会主动发起验证(需配合 max-age 使用)。
  • Expires(HTTP/1.0 兼容):指定缓存过期的绝对时间(如 Expires: Fri, 21 Nov 2025 23:59:59 GMT)。缺点是依赖客户端系统时间,若客户端时间篡改,会导致缓存失效或过期缓存复用。

应用场景:不频繁变动的静态资源

  • 打包后的 JS/CSS 文件(需配合文件指纹,如 app.[hash].js,更新时修改文件名即可失效旧缓存)。
  • 图片、字体、图标库(如 Logo、Iconfont、静态背景图)。
  • 第三方库(如 Vue、React 的 CDN 资源,版本号固定时可长期缓存)。

实践示例:Nginx 配置强缓存

nginx

server {
  listen 80;
  server_name example.com;

  # 对JS、CSS、图片等静态资源设置30天强缓存
  location ~* .(js|css|png|jpg|jpeg|gif|ico|woff2|svg)$ {
    root /usr/share/nginx/html;
    expires 30d; # 等价于 Cache-Control: max-age=2592000(30*24*3600)
    add_header Cache-Control "public, immutable"; # 声明资源不变,减少无效验证
  }
}

2. 协商缓存:与服务器确认,避免 “脏数据”

强缓存过期后,浏览器会发起 “协商请求”:携带本地缓存的资源标识,服务器判断资源是否更新。若未更新,返回 304 Not Modified,浏览器复用本地缓存;若已更新,返回 200 OK 和新资源。

实现原理:通过 “资源标识” 验证有效性

协商缓存的核心是 “资源标识”,分为两组成对使用的请求头 / 响应头(ETag 优先级高于 Last-Modified):

  • 组 1:Last-Modified + If-Modified-Since(基于文件修改时间)

    • 响应头 Last-Modified:服务器返回资源的最后修改时间(如 Last-Modified: Wed, 20 Nov 2024 14:30:00 GMT)。
    • 请求头 If-Modified-Since:浏览器下次请求时,携带本地缓存的 Last-Modified 值,告知服务器 “我本地资源的最后修改时间”。
    • 服务器逻辑:对比请求头时间与服务器资源当前修改时间,一致则返回 304,否则返回新资源和新 Last-Modified
    • 缺点:修改时间精度为秒级,1 秒内多次修改会失效;文件内容未变但修改时间变动(如重新部署),会误判为更新。
  • 组 2:ETag + If-None-Match(基于文件内容哈希)

    • 响应头 ETag:服务器对资源内容计算哈希值(如 ETag: "61a8a0f2"),内容不变则哈希值不变。
    • 请求头 If-None-Match:浏览器下次请求时,携带本地缓存的 ETag 值,告知服务器 “我本地资源的哈希值”。
    • 服务器逻辑:对比请求头哈希与服务器资源当前哈希,一致返回 304,否则返回新资源和新 ETag
    • 优点:精度更高,仅关注内容变化,不受修改时间影响。

应用场景:动态内容或频繁更新的静态资源

  • 博客文章、产品详情页等动态页面(内容可能更新,但更新频率不高)。
  • 频繁迭代的静态资源(如活动页 CSS,未使用文件指纹时)。
  • 需保证数据实时性,但可接受 “短时间缓存” 的资源(如首页公告、热门榜单)。

实践示例:Nginx 配置协商缓存

nginx

# 对HTML、PHP等动态资源启用协商缓存
location ~* .(html|php|jsp)$ {
  root /usr/share/nginx/html;
  expires -1; # 禁用强缓存
  add_header Cache-Control "no-cache"; # 强制进入协商缓存
  etag on; # 启用ETag
  if_modified_since on; # 启用Last-Modified
}

二、客户端存储缓存:前端数据的 “本地仓库”

HTTP 缓存聚焦 “服务器资源”,而客户端存储缓存用于将前端生成或获取的非资源数据(如用户偏好、登录状态、表单草稿)持久化在浏览器中,无需每次从服务器请求。

常用方案包括 Cookie、LocalStorage、SessionStorage、IndexedDB,各自适用于不同场景,核心区别集中在容量、生命周期、作用域等维度。

1. Cookie:小型会话数据的 “经典选择”

Cookie 是浏览器最早支持的本地存储方案,用于存储少量键值对数据(容量约 4KB),且会随每次 HTTP 请求自动发送到服务器

核心特性:

  • 容量限制:4KB,仅适合存储少量数据(如 Session ID、用户标识)。
  • 生命周期:可通过 expires(绝对时间)或 max-age(相对时间)设置过期时间;未设置则为 “会话 Cookie”,关闭浏览器失效。
  • 作用域:通过 domain(生效域名)和 path(生效路径)控制,如 domain=example.com 表示子域名 blog.example.com 可共享。
  • 安全性:支持 httpOnly(禁止 JS 读取,防御 XSS 攻击)、secure(仅 HTTPS 传输)、SameSite(防御 CSRF 攻击,取值:Strict/Lax/None)。

应用场景:

  • 存储用户登录态(如 Session ID、JWT 令牌,建议设置 httpOnly: true)。
  • 存储 CSRF 令牌(防御跨站请求伪造攻击)。
  • 存储用户偏好(如语言选择、是否记住登录状态)。
  • 第三方统计或广告跟踪(需遵守隐私法规,如 GDPR)。

实践示例:前端 / 服务器操作 Cookie

javascript

运行

// 1. 前端设置Cookie(简化版)
function setCookie(name, value, days = 7) {
  const date = new Date();
  date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
  document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; path=/; SameSite=Lax; secure=${window.location.protocol === 'https:'}`;
}

// 2. 前端读取Cookie
function getCookie(name) {
  return document.cookie.split('; ').find(row => row.startsWith(`${name}=`))?.split('=')[1] || null;
}

// 3. 服务器(Node.js/Express)设置Cookie
app.get('/login', (req, res) => {
  res.cookie('token', 'user-jwt-123', {
    maxAge: 7 * 24 * 60 * 60 * 1000,
    httpOnly: true, // 禁止JS读取,防XSS
    secure: process.env.NODE_ENV === 'production',
    SameSite: 'Lax' // 防CSRF
  });
  res.send('登录成功');
});

2. LocalStorage:永久存储的 “轻量数据库”

LocalStorage 是 HTML5 引入的本地存储方案,用于存储键值对数据(容量约 5-10MB),永久存储(除非手动删除或清除浏览器数据),仅在客户端生效,不随 HTTP 请求发送。

核心特性:

  • 容量限制:5-10MB(不同浏览器略有差异)。
  • 生命周期:永久有效,关闭浏览器、重启电脑后数据仍存在。
  • 作用域:同源策略(协议、域名、端口一致),同一域名下所有页面可共享。
  • 存储类型:仅支持字符串,存储对象需通过 JSON.stringify() 序列化,读取时用 JSON.parse() 反序列化。
  • 安全性:无内置安全机制,存储的数据可被同源 JS 读取,易受 XSS 攻击,禁止存储敏感信息。

应用场景:

  • 存储用户偏好设置(如深色 / 浅色主题、字体大小、语言选择)。
  • 存储搜索历史、浏览记录(如电商网站的搜索关键词)。
  • 存储非敏感的表单常用数据(如收货地址、常用联系人)。
  • 单页应用(SPA)的状态持久化(如 Vuex、Redux 的状态缓存)。

实践示例:LocalStorage 基础操作

javascript

运行

// 存储对象(需序列化)
const userSettings = { theme: 'dark', fontSize: '16px' };
localStorage.setItem('userSettings', JSON.stringify(userSettings));

// 读取数据(需反序列化)
const savedSettings = JSON.parse(localStorage.getItem('userSettings')) || { theme: 'light' };
console.log('当前主题:', savedSettings.theme); // 输出 "dark"

// 删除单个数据
localStorage.removeItem('userSettings');

// 清空所有数据
localStorage.clear();

3. SessionStorage:会话级别的 “临时缓存”

SessionStorage 与 LocalStorage API 完全一致,但生命周期和作用域不同,适用于临时存储会话数据

核心特性:

  • 容量限制:5-10MB(与 LocalStorage 一致)。
  • 生命周期:会话级有效,关闭标签页 / 浏览器后数据立即丢失(刷新页面不丢失)。
  • 作用域:比 LocalStorage 更严格 —— 同一域名下的不同标签页互不共享(同一标签页的 iframe 可共享)。
  • 存储类型:仅支持字符串,需序列化 / 反序列化。

应用场景:

  • 存储表单草稿(如用户填写注册信息、长文本编辑时,避免刷新页面丢失数据)。
  • 存储单页应用的路由参数(如当前页面的筛选条件、分页页码)。
  • 存储临时授权信息(如一次性验证码、临时访问令牌)。

实践示例:SessionStorage 存储表单草稿

javascript

运行

// 监听表单输入,实时存储草稿
document.getElementById('register-form').addEventListener('input', (e) => {
  const formDraft = {
    username: document.getElementById('username').value,
    email: document.getElementById('email').value,
    phone: document.getElementById('phone').value
  };
  sessionStorage.setItem('registerDraft', JSON.stringify(formDraft));
});

// 页面加载时恢复草稿
window.addEventListener('load', () => {
  const draft = JSON.parse(sessionStorage.getItem('registerDraft'));
  if (draft) {
    Object.keys(draft).forEach(key => {
      document.getElementById(key).value = draft[key];
    });
  }
});

4. IndexedDB:大量结构化数据的 “本地数据库”

IndexedDB 是浏览器提供的非关系型数据库(NoSQL),用于存储大量结构化数据(容量无明确限制,取决于硬盘空间),支持异步操作(不阻塞主线程)和事务,是客户端存储的 “终极方案”。

核心特性:

  • 容量:无硬性限制(浏览器通常限制为硬盘空间的 50%)。
  • 数据类型:支持对象、数组、字符串、数字、Blob(二进制数据,如图片、文件)等。
  • 操作方式:异步操作(通过回调或 Promise),避免阻塞 UI;支持事务(保证操作原子性,要么全部成功,要么全部失败)。
  • 作用域:同源策略,同一域名下所有页面可共享。

应用场景:

  • 离线应用数据存储(如离线博客、离线文档阅读器,存储文章内容、图片)。
  • 大量用户数据本地缓存(如电商 APP 的商品列表、购物车数据,离线时可操作,在线后同步服务器)。
  • 本地数据分析(如用户行为数据本地预处理,减少服务器压力)。

实践示例:IndexedDB 存储商品数据

javascript

运行

// 打开/创建数据库(数据库名:shopDB,版本号:1)
const request = indexedDB.open('shopDB', 1);

// 数据库初始化(首次创建或版本更新时触发)
request.onupgradeneeded = (e) => {
  const db = e.target.result;
  // 创建对象仓库(表),主键为id
  const productStore = db.createObjectStore('products', { keyPath: 'id' });
  // 创建索引(便于按分类查询)
  productStore.createIndex('category', 'category', { unique: false });
};

// 打开数据库成功
request.onsuccess = (e) => {
  const db = e.target.result;

  // 1. 新增数据(通过事务操作)
  const addTx = db.transaction('products', 'readwrite');
  const productStore = addTx.objectStore('products');
  productStore.add({ id: 1, name: '无线耳机', category: '数码', price: 999 });
  productStore.add({ id: 2, name: '机械键盘', category: '数码', price: 599 });

  // 2. 查询数据(按索引查询“数码”分类商品)
  const getTx = db.transaction('products', 'readonly');
  const productStore = getTx.objectStore('products');
  const categoryIndex = productStore.index('category');
  const cursor = categoryIndex.openCursor('数码');

  cursor.onsuccess = (e) => {
    const res = e.target.result;
    if (res) {
      console.log('商品:', res.value);
      res.continue(); // 遍历下一条
    }
  };

  // 关闭数据库
  db.close();
};

// 打开失败
request.onerror = (e) => {
  console.error('IndexedDB打开失败:', e.target.error);
};

三、进阶缓存方案:离线能力与极致优化

除基础方案外,前端还可通过「ServiceWorker + Cache API」「CDN 缓存」实现更复杂的需求,如离线访问、跨地域资源加速等。

1. ServiceWorker + Cache API:PWA 离线缓存的核心

ServiceWorker 是运行在浏览器后台的 “代理脚本”,独立于页面线程,可拦截网络请求、管理缓存资源,配合 Cache API(专门用于缓存网络资源),是实现 PWA(渐进式 Web 应用)离线功能的核心。

核心特性:

  • 独立线程:不阻塞页面渲染,可在后台执行缓存、数据同步等操作。
  • 拦截请求:能拦截所有同源网络请求,自定义缓存策略(如 “缓存优先”“网络优先”)。
  • 生命周期:安装(install)→ 激活(activate)→ 运行(activated),更新需手动处理。
  • 离线支持:缓存核心资源后,即使无网络,也能展示离线页面或缓存内容。

应用场景:

  • PWA 应用(如离线新闻 APP、离线文档阅读器、离线电商 APP)。
  • 弱网环境优化(缓存核心资源,减少加载失败概率)。
  • 离线数据同步(如表单提交失败后,网络恢复时自动同步)。

实践示例:ServiceWorker 缓存核心资源

javascript

运行

// 1. 页面注册ServiceWorker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('ServiceWorker注册成功:', registration.scope);
    } catch (err) {
      console.error('ServiceWorker注册失败:', err);
    }
  });
}

// 2. sw.js(ServiceWorker核心脚本)
const CACHE_VERSION = 'v1';
const CACHE_ASSETS = [ // 需缓存的核心资源
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/icon.png',
  '/offline.html' // 离线 fallback 页面
];

// 安装阶段:缓存核心资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_VERSION)
      .then(cache => cache.addAll(CACHE_ASSETS))
      .then(() => self.skipWaiting()) // 跳过等待,直接激活
  );
});

// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      // 删除非当前版本的缓存
      return Promise.all(
        cacheNames.filter(name => name !== CACHE_VERSION)
          .map(name => caches.delete(name))
      );
    }).then(() => self.clients.claim()) // 控制所有打开的页面
  );
});

// 拦截请求,自定义缓存策略
self.addEventListener('fetch', (event) => {
  const request = event.request;

  // 策略1:HTML页面 → 网络优先(保证内容最新,离线时展示fallback)
  if (request.mode === 'navigate') {
    event.respondWith(
      fetch(request)
        .then(response => {
          // 更新缓存中的HTML
          caches.open(CACHE_VERSION).then(cache => cache.put(request, response.clone()));
          return response;
        })
        .catch(() => caches.match('/offline.html'))
    );
    return;
  }

  // 策略2:静态资源 → 缓存优先(优先用缓存,无缓存再请求网络)
  event.respondWith(
    caches.match(request)
      .then(cachedResponse => cachedResponse || fetch(request))
  );
});

2. CDN 缓存:跨地域资源的 “加速神器”

CDN(内容分发网络)是部署在全球各地的边缘节点集群,属于 “中间层缓存”—— 通过缓存静态资源,让用户从最近的节点获取资源,减少网络延迟和源站压力。

核心特性:

  • 跨地域加速:边缘节点覆盖全球,用户就近访问,降低跨运营商、跨地区的网络延迟。
  • 减轻源站压力:静态资源请求由 CDN 节点响应,源站仅处理动态请求(如接口调用)。
  • 弹性扩容:支持高并发场景(如秒杀、直播),避免源站带宽瓶颈。
  • 缓存策略:可按文件类型、路径、域名配置缓存过期时间,支持手动刷新缓存。

应用场景:

  • 大型网站的静态资源(图片、视频、JS/CSS、字体)。
  • 跨地域访问的网站(如跨境电商、全球新闻平台)。
  • 高并发场景(如电商秒杀、大型赛事直播的静态资源)。

前端配合方式:

  • 将静态资源路径指向 CDN 域名(如 https://cdn.example.com/app.[hash].js)。
  • 配合文件指纹(如哈希值、版本号),确保资源更新时 CDN 缓存失效。
  • 配置 CDN 缓存规则(如图片缓存 30 天,JS/CSS 缓存 7 天)。

示例:使用 CDN 引入第三方库

html

预览

<!-- 引入CDN上的Vue.js,配合版本号和文件指纹 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.0/dist/vue.global.prod.js"></script>

四、缓存策略选择与最佳实践

前端缓存的核心是 “平衡性能与数据一致性”,需根据资源类型、业务场景灵活选择方案。以下是落地时的关键指南:

1. 按资源 / 数据类型选择方案

数据 / 资源类型 推荐缓存方案 核心配置要点
静态资源(JS/CSS/ 图片) HTTP 强缓存 + CDN 缓存 设 30-90 天过期,配合文件指纹(hash)控制更新
动态页面(HTML / 接口) HTTP 协商缓存 禁用强缓存,启用 ETag/Last-Modified
登录态、CSRF 令牌 Cookie(httpOnly + secure) 设 7-30 天过期,避免存储敏感信息
用户偏好、搜索历史 LocalStorage 不存储敏感数据,定期清理过期内容
表单草稿、临时参数 SessionStorage 利用会话级生命周期,无需手动清理
离线数据、大量结构化数据 IndexedDB + ServiceWorker 缓存核心数据,在线后同步服务器
跨地域静态资源 CDN 缓存 指向 CDN 域名,配置合理过期时间

2. 关键优化技巧

  • 避免缓存脏数据:静态资源必须加文件指纹(如 app.[hash].js)或版本号,更新时修改标识即可失效旧缓存。
  • 敏感数据安全:密码、token 等敏感信息禁止存储在 LocalStorage/SessionStorage,优先使用 httpOnly Cookie;IndexedDB 存储敏感数据需加密。
  • 合理设置过期时间:频繁更新的资源(如活动页)设短缓存(1-7 天),稳定资源(如第三方库)设长缓存(30-90 天)。
  • 清理过期缓存:ServiceWorker 激活时清理旧版本缓存,LocalStorage/IndexedDB 定期清理过期数据。
  • 兼容离线场景:核心业务(如购物车、表单提交)需通过 ServiceWorker + IndexedDB 实现离线能力,避免弱网导致用户操作失败。

3. 常见问题排查

  • 缓存不更新:检查是否未加文件指纹,或 CDN 缓存未刷新;手动清除浏览器缓存测试,或通过 Ctrl+Shift+R 强制刷新。
  • 数据不一致:动态内容误用强缓存,需改为协商缓存;关键数据(如用户余额)禁止缓存,每次请求服务器。
  • 存储容量不足:避免 LocalStorage 存储大量数据,改用 IndexedDB;定期清理无用缓存。
  • 安全风险:Cookie 未设置 httpOnly/secure/SameSite,易受 XSS/CSRF 攻击;LocalStorage 存储的数据需过滤,避免注入攻击。

总结

前端缓存是一套 “分层协同” 的策略体系:HTTP 缓存负责静态资源的快速加载,客户端存储缓存解决前端数据的持久化需求,ServiceWorker 与 CDN 则实现离线能力和跨地域加速。

在实际开发中,无需局限于单一方案 —— 例如,一个 PWA 电商 APP 可采用 “HTTP 强缓存(静态资源)+ CDN(图片加速)+ Cookie(登录态)+ LocalStorage(用户偏好)+ IndexedDB(购物车)+ ServiceWorker(离线访问)” 的组合,既保证性能,又兼顾数据一致性和用户体验。

掌握前端缓存的核心原理和实践技巧,不仅能显著提升页面加载速度、降低服务器压力,更能在弱网、离线等复杂场景下保障用户体验 —— 这也是前端工程师从 “实现功能” 到 “优化体验” 的关键一步。

为什么vue中使用query可以保留参数

2025年12月22日 17:04

本质与原理

一句话回答
这是 Vue Router 将 query 对象序列化为 URL 查询字符串(Query String) ,并拼接到路径后面,形成完整的 URL(如 /user?id=123&name=alice),从而实现参数传递。


本质:前端路由对 URL 的构造与解析

Vue Router 并不“保存”参数,而是:

  1. 构造一个合法的 URL
  2. 通过浏览器 History API 或 hash 变更 URL
  3. 在路由匹配时反向解析该 URL

所以,query 的存在完全依赖于 URL 本身的结构


🛠 执行过程详解

当你调用:

this.$router.push({
  path: '/user',
  query: { id: 123, name: 'alice' }
});

Vue Router 内部会执行以下步骤:

1:序列化 query 对象

  • 使用类似 URLSearchParams 的机制,将 { id: 123, name: 'alice' } 转为字符串:
// 伪代码
const queryString = new URLSearchParams({ id: 123, name: 'alice' }).toString();
// 结果: "id=123&name=alice"

2:拼接完整 URL

  • pathqueryString 合并:
/user + ? + id=123&name=alice → /user?id=123&name=alice

3:触发 URL 变更

  • 根据当前模式(hashhistory):
    • Hash 模式:设置 location.hash = '#/user?id=123&name=alice'
    • History 模式:调用 history.pushState(null, '', '/user?id=123&name=alice')

✅ 此时,浏览器地址栏显示完整带参 URL,且页面不刷新

4:路由匹配与参数注入

  • Vue Router 监听到 URL 变化后:
    • 匹配路由(如 { path: '/user', component: User }
    • 解析查询字符串,还原为对象:

this.$route.query === { id: "123", name: "alice" }

⚠️ 注意:所有 query 值都是 字符串类型(HTTP 协议限制)


为什么可以“带上路径后面”?

因为这是 URL 标准的一部分

根据 RFC 3986,URL 结构如下:



https://example.com/user?id=123&name=alice
│          │        │     └───────────────┘
│          │        │           ↑
│          │        │     Query String(查询字符串)
│          │        └── Path(路径)
│          └── Host(主机)
└── Scheme(协议)
  • 查询字符串( ?key=value&... )是 URL 的合法组成部分
  • 浏览器天然支持它,刷新时会完整保留
  • 服务端和前端都可以读取它

💡 Vue Router 只是利用了这一标准机制,并没有发明新东西。


优势:为什么推荐用 query 传参?

特性 说明
可分享 完整 URL 可直接复制发送给他人
可刷新 刷新后参数仍在(因为 URL 没变)
可书签 用户可收藏带参链接
SEO 友好 搜索引擎能索引不同 query 的页面(如搜索结果页)
调试方便 地址栏直接可见参数

注意事项

  1. 值类型全是字符串

// 传入
query: { id: 123 } // number
// 接收
this.$route.query.id === "123" // string!

需要手动转换:parseInt(this.$route.query.id)

  1. 敏感信息不要放 query
    • 查询字符串会出现在:
      • 浏览器历史记录
      • 服务器日志
      • Referer 头(如果跳转到第三方)
    • 不适合放 token、密码等
  1. 长度有限制
    • URL 总长一般限制在 2048 字符以内(各浏览器不同)
    • 大量数据建议用 POST 或状态管理

对比:query vs params(非路径型)

方式 是否体现在 URL 刷新后保留 适用场景
query ✅ 是(?id=123 ✅ 是 公开、可分享、可刷新的参数
params(未在 path 声明) ❌ 否 ❌ 否 临时跳转(如表单步骤),但刷新丢失

总结

this.$router.push({ path: '/user', query: {...} }) 的本质是:
构造一个标准的、带查询字符串的 URL,并通过前端路由机制导航到该地址。

  • 它利用的是 URL 原生的查询参数机制
  • 参数被持久化在地址栏中,因此刷新不丢失
  • 这是 SPA 应用中最安全、最通用的传参方式之一

🌟 记住:只要参数需要“跨刷新”或“可分享”,优先用 query

Vue 转盘抽奖 transform

作者 小丑755
2025年12月22日 16:48

Vue 转盘抽奖 transform

简介:电商食用级转盘抽奖

讲在前面

在我们日常生活,电子购物已是必不可少的环节了。营销手段更是层出不穷,要数经典的还是转盘抽奖了,紧张又刺激(其实概率还不都是咱们程序猿弄的,刺激个der~)

虽说如此...

但 还是决定自己搞一个试试!

核心 transform

transform 属性向元素应用 2D 或 3D 转换。该属性允许我们对元素进行旋转、缩放、移动或倾斜。

但是既然我们说转盘,当然用到的是旋转啦:rotate

简单示例 顺时针旋转10deg

transform:rotate(10deg);

什么?你已经会这个css属性了? 那恭喜你,你已经能自己独立制作转盘抽奖啦~

核心代码

1. 转盘UI

<template>

    <view class="">

      
            <!-- 转盘包裹 -->
            <view class="rotate">
              <!-- 绘制圆点 -->
              <view :class="'circle circle_' + index" v-for="(item, index) in circleList" :key="index"
                :style="{ background: index % 2 == 0 ? colorCircleFirst : colorCircleSecond }"></view>
              <!-- 转盘图片 -->
              <image class="dish" src="/static/demo/pan.png" :style="{ transform: rotate_deg, transition: rotate_transition }" ></image>
              <!-- 指针图片 -->
              <image class="pointer" src="/static/demo/zhen.png" @click="start" ></image>
              
            
            </view>



      
    </view>

</template>


<style lang="scss" scoped>
.rotate {
  width: 600rpx;
  height: 600rpx;
  background: #ffbe04;
  border-radius: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  position: absolute;
  top: 48%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.rotate .dish {
  width: 550rpx;
  height: 550rpx;
}

.pointer {
  width: 142rpx;
  height: 200rpx;
  position: absolute;
  top: 46%;
  left: 50%;
  transform: translate(-50%, -50%);
}

/* 圆点 */
.rotate .circle {
  position: absolute;
  display: block;
  border-radius: 50%;
  height: 20rpx;
  width: 20rpx;
  background: black;
}

/*这里只写了一个点的位置,其他的自己补充一下 调调位置就好啦*/
.rotate .circle_0 {
  top: 2rpx;
  left: 284rpx;
}
    
</style>

2.让转盘转动起来

var light_timer; //灯光定时器

data() {
        return {
            circleList: [], //圆点列表
            colorCircleFirst: "#FF0000", //圆点闪烁颜色一
            colorCircleSecond: "#fff", //圆点闪烁颜色二
            cat: 45, //总共8个扇形区域,每个区域45度,这就取决去奖品池的UI图了
            isAllowClick: true, //是否能够点击
            rotate_deg: 0, //指针旋转的角度
            rotate_transition: "transform 3s ease-in-out" //过渡属性,渐入渐出

        };
    },
        
onLoad() {
    this.showcircleList();
},
    
    methods: {
        // 设置边缘一圈16个圆点,可以根据需要修改
        showcircleList() {
            let circleList = [];
            for (var i = 0; i < 16; i++) {
                circleList.push(i);
            }
            this.circleList = circleList;
            this.light();   
        },

        //设置边缘灯光闪动效果
        light: function() {
            var that = this;
            clearInterval(light_timer);
            light_timer = setInterval(function() {
                if (that.colorCircleFirst == "#FF0000") {
                    that.colorCircleFirst = "#fff";
                    that.colorCircleSecond = "#FF0000";
                } else {
                    that.colorCircleFirst = "#FF0000";
                    that.colorCircleSecond = "#fff";
                }
            }, 300); //设置圆点闪烁的间隔时间效果
        },
        //点击开始抽奖
        start() {
            this.rotating();
        },
        //旋转
        rotating() {
            if (!this.isAllowClick) return;
            this.isAllowClick = false;
            this.rotate_transition = "transform 3s ease-in-out";
            this.LuckyClick--;
            var rand_circle = 5; //默认多旋转5圈
            var winningIndex = this.set(); //设置概率
            console.log(winningIndex);
            var randomDeg = 360 - winningIndex * 45; //8个区域。一圈是360度,对应区域旋转度数就是顺时针的 360 - winningIndex*45°
            var deg = rand_circle * 360 + randomDeg; //把本来定义多转的圈数度数也加上
            this.rotate_deg = "rotate(" + deg + "deg)";

            var that = this;
            setTimeout(function() {
                that.isAllowClick = true;
                that.rotate_deg = "rotate(" + randomDeg + "deg)"; //定时器关闭的时候角度调回五圈之前相同位置,依照产品需求可以自己更改
            that.rotate_transition = "";

                if (winningIndex == 0) {
                    console.log("恭喜您,IphoneX");
                } else if (winningIndex == 1) {
                    console.log("恭喜您,获得10元现金");
                } else if (winningIndex == 2) {
                    console.log("很遗憾,重在参与");
                    uni.showToast({
                        title:"很遗憾,重在参与",
                        icon:"none"
                    })
                } else if (winningIndex == 3) {
                    console.log("恭喜您,获得30元现金");
                } else if (winningIndex == 4) {
                    console.log("恭喜您,获得20元现金");
                } else if (winningIndex == 5) {
                    console.log("恭喜您,获得50元现金");
                } else if (winningIndex == 6) {
                    console.log("恭喜您,获得5元现金");
                } else if (winningIndex == 7) {
                    console.log("恭喜您,获得100元现金");
                }
            }, 3500);
        },

        //设置概率
        set() {
            var winIndex;
            var __rand__ = Math.random();
            // 随机数 设置抽奖概率 winIndex 记得参考奖品池的UI图
            if (__rand__ < 0.30) winIndex = 2;
            else if (__rand__ < 0.55) winIndex = 6;
            else if (__rand__ < 0.75) winIndex = 1;
            else if (__rand__ < 0.85) winIndex = 4;
            else if (__rand__ < 0.92) winIndex = 3;
            else if (__rand__ < 0.97) winIndex = 5;
            else if (__rand__ < 0.99) winIndex = 7;
            else if (__rand__ == 0.99) winIndex = 0;
            return winIndex;
        },



}

最终效果展示

zhuanpan.png

总结

其实就是利用背景图进行旋转,设置好旋转角度!如果有兴趣的话就快速行动吧,冲冲冲!!!

vue中hash模式和history模式的区别

2025年12月22日 16:47

一句话总结

  • Hash 模式:利用 URL 中 # 后的内容变化实现前端路由,不触发页面刷新,兼容性好。
  • History 模式:基于 HTML5 的 history.pushState()popstate 事件,URL 更干净,但需要服务端配合。

一、Hash 模式(默认模式)

1. 基本形式

https://example.com/#/user/profilehash 部分

2. 工作原理

  • 核心机制:监听 window.onhashchange 事件。
  • 当用户点击链接或调用 router.push() 时,Vue Router 修改 location.hash(如 #/home#/about)。
  • 浏览器不会向服务器发起请求,因为 # 及其后的内容不会发送给服务器
  • 页面 URL 改变但不刷新,前端根据新的 hash 值匹配路由并渲染对应组件。

3. 特点

优点 缺点
✅ 兼容性极好(IE8+ 支持) ❌ URL 中带有 #,不够美观
✅ 无需服务端配置 ❌ SEO 友好性略差(部分爬虫可能忽略 hash)
✅ 天然避免 404 问题 ❌ 不符合传统 URL 语义

4. 示例代码(Vue Router 配置)

const router = new VueRouter({
  mode: 'hash', // 默认值,可省略
  routes: [...]
});

二、History 模式(推荐用于现代项目)

1. 基本形式

https://example.com/user/profile

URL 看起来和传统多页应用一致,无 # 符号。

2. 工作原理

  • 核心技术
    • history.pushState(state, title, url):在不刷新页面的情况下修改浏览器历史记录和 URL。
    • history.replaceState(...):替换当前历史记录。
    • window.onpopstate:监听浏览器前进/后退操作(如点击 ← → 按钮)。
  • 流程示例
    1. 用户访问 /home → 前端加载,Vue Router 渲染 Home 组件。
    2. 点击“关于”链接 → 调用 router.push('/about') → 执行 history.pushState(null, '', '/about')
    3. URL 变为 https://example.com/about,页面不刷新,About 组件被渲染。
    4. 用户刷新页面 → 浏览器向服务器请求 /about 资源。

3. 关键问题:刷新 404

  • 原因:服务器收到 /about 请求时,若未配置,会尝试查找物理路径下的 about.html 或目录,找不到则返回 404。
  • 解决方案服务端需配置“兜底路由” ,将所有前端路由请求重定向到 index.html

Nginx 配置示例:

location / {
  try_files $uri $uri/ /index.html;
}

4. 特点

优点 缺点
✅ URL 简洁美观,符合 REST 风格 ❌ 需要服务端支持(部署配置)
✅ 更好的 SEO(主流爬虫已支持) ❌ 在纯静态托管(如 GitHub Pages)中需额外处理
✅ 用户体验更接近原生 Web ❌ 旧浏览器(IE9 以下)不支持

5. 示例代码(Vue Router 配置)

const router = new VueRouter({
  mode: 'history',
  routes: [...]
});

🔁 三、对比总结

特性 Hash 模式 History 模式
URL 样式 example.com/#/path example.com/path
刷新是否 404 ❌ 不会(# 后内容不发给服务器) ✅ 会(需服务端配置兜底)
浏览器兼容性 IE8+ IE10+(HTML5 History API)
服务端要求 必须配置 fallback 到 index.html
SEO 友好性 一般 较好(现代爬虫支持)
使用场景 快速原型、老旧环境、无服务端控制权 正式项目、追求用户体验、有运维支持

💡 最佳实践建议

  • 开发阶段:两种模式均可,推荐 history 提前暴露部署问题。
  • 生产部署
    • 若使用 Nginx/Apache/Caddy → 优先选 history + 配置 fallback。
  • 无法控制服务端? → 用 hash 模式最稳妥。

补充知识

  • 为什么 hash 不发给服务器?
    根据 HTTP 规范,URL 中 #fragment 部分仅用于客户端定位(如锚点),不会包含在 HTTP 请求中
  • History API 安全限制
    pushState 只能修改同源下的路径,不能跨域篡改 URL,保障了安全性。

现代前端开发工程化:从 Vite 到 Vue 3 多页面应用实战

作者 Tzarevich
2025年12月22日 16:46

现代前端开发工程化:从 Vite 到 Vue 3 多页面应用实战

在当今快速迭代的前端开发环境中,工程化已成为构建高质量、可维护项目的基石。本文将结合实际项目结构与开发流程,带你深入理解如何使用 Vite 搭建一个现代化的 Vue 3 项目,并实现多页面路由功能,打造高效、优雅的前端开发体验。

一、什么是 Vite?为何它如此重要?

Vite 是由 Vue 作者尤雨溪主导开发的新一代前端构建工具,它颠覆了传统打包工具(如 Webpack)的“先打包再运行”模式,转而利用浏览器原生支持的 ES 模块(ESM),实现了:

  • 极速冷启动:无需等待打包,项目秒级启动;
  • 毫秒级热更新(HMR) :修改代码后浏览器自动刷新,开发效率翻倍;
  • 开箱即用的现代特性:对 TypeScript、CSS 预处理器、JSX 等天然支持;
  • 轻量且高性能:基于 Node.js 构建,但不干扰开发阶段的加载逻辑。

简单来说,Vite 是现代前端开发的“加速器” ,让开发者专注于业务逻辑,而非等待编译。

二、初始化项目:npm init vite

打开终端,执行以下命令创建新项目:

npm init vite@latest my-vue-app -- --template vue
cd my-vue-app
npm install

这会生成一个标准的 Vue 3 + Vite 项目模板。运行:

npm run dev

项目将在 http://localhost:5173 启动,并自动打开浏览器,进入开发环境。此时 Vite 已作为开发服务器运行:它不会打包整个应用,而是按需通过原生 ESM 加载模块。当你访问 localhost:5173 时,浏览器直接请求 /src/main.js,Vite 在后台实时解析 .vue 文件并提供模块服务——这正是“无需打包即可开发”的核心机制。

📌 注意:确保安装 Volar 插件(VS Code 官方推荐),以获得 Vue 3 的语法高亮、智能提示和代码补全;同时安装 Vue Devtools 浏览器插件用于调试组件状态。

三、项目架构解析

以下是典型的 Vite + Vue 3 项目结构:

vitevue.png

my-vue-app/
├── index.html              # 入口 HTML 文件
├── src/
│   ├── assets/             # 静态资源(图片、SVG 等)
│   ├── components/         # 可复用组件
│   │   └── HelloWorld.vue
│   ├── router/             # 路由配置
│   │   └── index.js
│   ├── views/              # 页面级组件
│   │   ├── Home.vue
│   │   └── About.vue
│   ├── App.vue             # 根组件
│   ├── main.js             # 应用入口
│   └── style.css           # 全局样式
├── public/                 # 公共静态资源(不会被构建处理)
├── package.json            # 依赖与脚本配置
├── vite.config.js          # Vite 配置文件(可选)
└── .gitignore

关键点说明:

Vue 应用的启动流程如下:浏览器加载 index.html → 执行 <script type="module" src="/src/main.js">main.js 调用 createApp(App) 创建实例 → 将根组件 App.vue 挂载到 #root 元素。整个过程由 Vite 提供的 ESM 环境驱动,无需传统打包步骤。

  • index.html:Vite 默认以此为入口,其中 <div id="root"></div> 是 Vue 应用的挂载点。
  • main.js:创建 Vue 实例并挂载到 #root
  • App.vue:整个应用的根组件,所有内容由此展开。
  • src/components/ :存放通用组件,如按钮、表单等。
  • src/views/ :存放页面级组件,每个页面对应一个 .vue 文件。
  • src/router/index.js:路由配置中心。

这种目录划分体现了现代前端工程化的核心思想

  • 关注点分离:页面(views)、通用组件(components)、路由(router)各司其职;
  • 可扩展性:新增功能只需在对应目录添加文件,不影响整体结构;
  • 团队协作友好:开发者可并行开发不同模块,降低耦合风险。

四、实现多页面:引入 Vue Router

在单页应用(SPA)中,“多页面”其实是通过路由切换不同的视图组件。我们使用 Vue Router 来实现这一功能。

1. 安装 vue-router

npm install vue-router@4

⚠️ 注意:Vue 3 必须搭配 vue-router v4。

2. 创建页面组件

src/views/ 下创建两个页面:

Home.vue

<template>
  <div>
    <h1>首页</h1>
    <p>欢迎来到主页!</p>
  </div>
</template>

About.vue

<template>
  <div>
    <h1>关于</h1>
    <p>这里是关于我们页面。</p>
  </div>
</template>

3. 配置路由

src/router/index.js 中配置路由:

import { createRouter, createWebHashHistory } from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router

💡 使用 createWebHashHistory() 可以避免服务器配置问题,适合本地开发。

4. 注册并使用路由

修改 main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'

createApp(App).use(router).mount('#root')

修改 App.vue 添加导航和路由出口:

<template>
  <nav>
    <router-link to="/">首页</router-link> |
    <router-link to="/about">关于</router-link>
  </nav>
  <router-view />
</template>

现在,点击链接即可在不同页面间切换,URL 也会相应变化,完全符合 SPA 的交互体验。

五、总结:现代前端工程化的核心价值

  • 极速开发体验: 借助 Vite 利用浏览器原生 ES 模块(ESM)的能力,实现项目秒级冷启动和毫秒级热更新,大幅减少等待时间。

  • 组件化开发模式: Vue 3 的单文件组件(.vue)结构将模板、逻辑与样式封装在一起,提升代码复用性与可维护性。

  • 清晰的项目结构: 标准化的目录组织(如 src/views/src/components/src/router/)让项目职责分明,便于团队协作和长期维护。

  • 路由管理能力: 通过官方插件 vue-router 实现声明式路由配置,轻松支持多页面(视图)切换,构建完整的单页应用(SPA)。

  • 强大的工具生态支持:

    • Volar:提供 Vue 3 专属的语法高亮、智能提示和类型检查;
    • Vue Devtools:在浏览器中直观调试组件状态、路由和事件流。
  • 低门槛、高扩展性:npm init vite 一行命令即可生成完整项目骨架,后续可无缝集成 TypeScript、Pinia、单元测试、自动化部署等高级能力。

  • 面向未来的架构设计: 整套工程化方案基于现代 Web 标准构建,兼顾开发效率与生产性能,为构建复杂企业级应用打下坚实基础。

六、结语

前端工程化不是炫技,而是让开发更高效、更可靠、更可持续的过程。从 npm init vite 开始,你已经迈入了现代前端开发的大门。掌握 Vite、Vue 3 和 vue-router,你就拥有了构建复杂应用的核心能力。

🚀 接下来,不妨尝试添加一个表单、引入 Pinia 管理用户登录状态,或者部署到 GitHub Pages —— 让你的第一个现代前端项目真正落地!

代码是思想的体现,工程化是思想的容器。愿你在前端之路上越走越远。

跨域问题详解

2025年12月22日 16:39

引言:在一个前/后端分离的项目开发中,常常会出现前端向后端发送一个请求时,浏览器报错:Access to XMLHttpRequest at 'http://localhost:8080/' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.,也就是通常说的“跨域访问”的问题,由此导致前端代码不能读取到后端数据。

摘要:所谓“跨域问题”,本质上是浏览器在同源策略约束下,主动阻止 JavaScript 读取跨源请求响应的一种安全保护行为。解决跨域问题主要通过服务器端设置CORS(跨域资源共享)机制——浏览器放行跨域请求响应的数据;或者Nginx/网关的代理功能——跨域的请求实际由网关代发,浏览器端依旧是同源请求。

什么是跨域访问

跨域访问指的是:当前网页所在的“源(Origin)”去访问另一个“不同源”的资源,而该访问被浏览器安全策略所限制或拦截的情况。

在浏览器中一个“源”由三部分组成:协议(Protocol) + 域名(Host) + 端口(Port),只要有一个部分不一样就是跨源,也即跨域。例如:

URL 协议 域名 端口 是否同源
http://example.com http example.com 80 基准
http://example.com:8080 http example.com 8080 跨域(端口不同)
https://example.com https example.com 443 跨域(协议不同)
http://api.example.com http api.example.com 80 跨域(域名不同)

这里需要强调:对“跨域访问”进行限制是浏览器的安全策略导致的,并不是前端或后端技术框架引起的

为什么跨域访问请求“得不到”数据

这里就要展开说明为什么浏览器要对“跨域访问”进行限制,导致(尤其是)Web前端中发送HTTP请求会得不到数据,并在控制台报错。

出于安全性,浏览器会采用同源策略(Same-Origin Policy,SOP)限制脚本内发起的跨源 HTTP 请求,限制一个源的文档或者它加载的脚本如何与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。例如,它可以防止互联网上的恶意网站在浏览器中运行 JavaScript 脚本,从第三方网络邮件服务(用户已登录)或公司内网(因没有公共 IP 地址而受到保护,不会被攻击者直接访问)读取数据,并将这些数据转发给攻击者。

假设在没有同源限制的情况下:

  • 用户已登录银行网站 https://bank.com(Cookie 已保存)
  • 用户同时打开一个恶意网站 https://evil.com
  • evil.com 的 JavaScript 可以:
    • 直接读取 bank.com 的接口返回数据
    • 发起转账请求
    • 窃取用户隐私信息

这是非常严重的安全灾难。

同源策略将跨源之间的访问(交互)通常分为3种:

  • 跨源写操作(Cross-origin writes)一般是被允许的。例如链接、重定向以及表单提交。特定少数的 HTTP 请求需要添加预检请求
  • 跨源资源嵌入(Cross-origin embedding)一般是被允许的,比如<img src="..."><script src="..."><link href="...">
  • 跨源读操作(Cross-origin reads)一般是不被允许的。

再次强调:跨域限制是“浏览器行为”,不是后端服务器的限制。后端服务本身是可以接收来自任何来源的 HTTP 请求的。

比如前端访问fetch("https://api.example.com/data"),而当前页面来自http://localhost:8080,请求可以发出去,但浏览器会拦截响应,不让 JavaScript 读取。

要使不同源可以访问(交互),可以使用 CORS来允许跨源访问。CORSHTTP的一部分,它允许服务端来指定哪些主机可以从这个服务端加载资源。

怎么解决跨域访问的“问题”

CORS机制

跨源资源共享(Cross-Origin Resource Sharing,CORS,或通俗地译为跨域资源共享)是一种基于HTTP头的机制,该机制通过允许服务器标示除了它自己以外的其他源(域、协议或端口),使得浏览器允许这些源访问加载自己(服务器)的资源。跨源资源共享还通过一种机制来检查服务器是否会允许要发送的真实请求,该机制通过浏览器发起一个到服务器托管的跨源资源的“预检”请求。在预检中,浏览器发送的头中标示有 HTTP 方法和真实请求中会用到的头(Header)。

对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是GET以外的 HTTP 请求,或者搭配某些MIME类型(多用途互联网邮件扩展,是一种标准,用来表示文档、文件或一组数据的性质和格式)的POST请求),浏览器必须首先使用OPTIONS方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨源请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(例如Cookie和HTTP 认证相关数据)。

一般浏览器要检查的响应头有:

  • Access-Control-Allow-Origin:指示响应的资源是否可以被给定的来源共享。
  • Access-Control-Allow-Methods:指定对预检请求的响应中,哪些 HTTP 方法允许访问请求的资源。
  • Access-Control-Allow-Headers:用在对预检请求的响应中,指示实际的请求中可以使用哪些 HTTP 标头。
  • Access-Control-Allow-Credentials:指示当请求的凭据标记为 true 时,是否可以暴露对该请求的响应给脚本。
  • Access-Control-Max-Age:指示预检请求的结果能被缓存多久。

如:

Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true

可知,若使用CORS解决跨域访问中的问题要在服务器端(通常是后端)进行设置。以Spring Boot的后端为例:

  • 局部的请求:在对应的Controller类或指定方法上使用@CrossOrigin。如下

    @CrossOrigin(
        origins = "http://localhost:3000",
        allowCredentials = "true"
    )
    
  • 全局使用:新建一个配置类并注入Spring框架中。如下:

    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
    
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/api/**")
                    .allowedOrigins(
                        "http://test.example.com"
                    )
                    .allowedMethods("GET","POST","PUT","DELETE")
                    .allowedHeaders("*")
                    .allowCredentials(true)
                    .maxAge(3600);
        }
    }
    

使用CORS 的优点:官方标准;安全、可控;与前后端分离完美匹配。缺点:需要服务端正确配置;初学者容易被预检请求困扰。

通过架构或代理手段

除了使用CORS的方式,还可以通过架构设计或代理的方式让跨域“变成”同源访问

比如通过Nginx / 网关代理浏览器(前端)请求,再由Nginx或网关访问服务器获取数据。

浏览器 → 前端域名 → Nginx → 后端服务

这样的话在浏览器(前端)看到将始终是对当前网站(前端域名)的访问(即使打开开发者工具的网络选项,请求的url地址也是前端域名)。

一个Nginx的配置示例:

server {
    listen 443;
    server_name www.example.com;

    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass http://backend:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

前端请求示例:axios.get('/api/user')

这是通过Nginx或网关这样的中间件实现的,如果在开发阶段想要快速解决跨域访问问题,可以在相应的项目构建的配置中设置代理。这里以Vite为构建工具的Vue项目为例,在vite.config.js中添加如下的配置项:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  }
}

然后请求的URL采用这样的方式axios.get('/api/user'),不在使用axios.get('http://localhost:8080/api/user')

使用代理方式的优点:无跨域;性能好;适合生产环境。缺点:需要额外部署配置。

总结

跨域问题并不是请求被禁止,而是浏览器在同源策略约束下,出于安全考虑,限制前端 JavaScript 对跨源响应数据的访问行为。

跨域问题的根源是 浏览器实现的同源策略(Same-Origin Policy),而不是:

  • HTTP 协议限制
  • 后端服务器限制
  • 前端框架(Vue / React)的问题

浏览器阻止的是JS 获取结果,而不是“阻止请求发送”——跨域请求可以被发出,服务器可以正常返回(比如预检请求响应),浏览器阻止JavaScript访问响应数据。

“跨域问题”只存在于浏览器环境,例如:

  • Java / Node / Python 发 HTTP 请求——没有跨域问题
  • Postman / curl ——没有跨域问题
  • 微服务之间调用——没有跨域问题

因为这些环境不执行浏览器的同源策略跨域问题是浏览器安全模型的一部分,本质上是对跨源资源访问的“读权限控制”,而非通信能力限制。

使用CORS 并不是“绕过”同源策略——浏览器的同源策略始终存在;CORS 是 同源策略的“例外机制”;本质是:服务器显式授权浏览器放行。换句话说:没有 CORS,就没有“合法的跨域读取”

只要不产生跨域,就不会有跨域问题,所以可以使用代理或网关将请求进行转发,而不是由浏览器直接请求服务器端发生跨域问题。

前端开发者必备:在浏览器控制台批量提取HTML表单字段名(Label)

作者 西愚wo
2025年12月22日 16:38

在某种情况下,需要前端参考某个页面的字段,但是又没有这个页面的代码,之前只能一个一个的复制,以element-ui的form表单为例。

image.png

一般提取都是手动复制form的名称ctrl+c -> ctrl+v到代码里面去,然后我发现某个页面有50+的名称需要copy就想把html给ai然后让ai进行区分,结果ai有更加便捷通用的方法,使用脚本。

看到你提供了一个非常长的、包含大量字段的表单HTML结构,需要将其中的标签(label)名称提取出来。这确实是手动操作非常耗时的工作。

我将为你编写一个通用的JavaScript脚本,你可以直接在浏览器的开发者工具(DevTools)的Console(控制台)中运行,来批量提取这些名称。这个方法比手动复制粘贴要高效得多。

批量提取脚本与步骤

  1. 打开浏览器开发者工具
    在你打开的表单页面,按下 F12 键(或右键选择“检查”)打开开发者工具。 2 切换到 Console(控制台)标签页
  2. 粘贴并执行以下脚本
    将下面的代码复制粘贴到控制台中,然后按回车键执行。
// 方法一:提取页面上所有el-form-item的label文本
function extractFormLabels() {
  // 找到所有class包含'el-form-item__label'的label元素
  const labelElements = document.querySelectorAll('.el-form-item__label');
  
  // 如果没有找到,尝试在指定容器内查找(根据你提供的结构)
  if (labelElements.length === 0) {
    const container = document.querySelector('.wiv-dialog-body');
    if (container) {
      labelElements = container.querySelectorAll('.el-form-item__label');
    }
  }
  
  // 如果还是没找到,打印提示
  if (!labelElements || labelElements.length === 0) {
    console.warn('未找到符合条件的label元素,请检查页面结构或选择器。');
    return [];
  }
  
  console.log(`共找到 ${labelElements.length} 个label元素`);
  
  // 提取文本,去重(防止重复),并过滤空值
  const labels = Array.from(labelElements)
    .map(el => el.textContent?.trim())
    .filter(text => text && text !== '') // 过滤掉空字符串
    .filter((text, index, arr) => arr.indexOf(text) === index); // 去重
    
  return labels;
}

// 执行提取并生成你需要的格式
const extractedLabels = extractFormLabels();

// 打印提取到的原始标签数组
console.log('=== 提取的标签列表 ===');
console.log(extractedLabels);

// 打印为你所需的格式化数组(去除冒号)
console.log('\n=== 格式化后的数据(可直接使用) ===');
const formattedData = extractedLabels.map((label, index) => {
  // 去除标签末尾的冒号(如果有的话)
  const cleanLabel = label.replace(/[::]$/, '');
  return {
    label: cleanLabel,
    value: '',
    prop: `field${index + 1}` // 这里生成了简单的prop,你可以根据需要修改
  };
});

// 将格式化后的数组输出到控制台
console.log(JSON.stringify(formattedData, null, 2));

// 同时提供一个更易复制的数组格式
console.log('\n=== 可直接复制的数组格式 ===');
console.log('[');
formattedData.forEach(item => {
  console.log(`  {
    label: '${item.label}',
    value: '',
    prop: '${item.prop}'
  },`);
});
console.log(']');

// 返回最终结果(可在控制台中查看 extractedLabels 变量)
console.log('\n提示:提取的数据已保存在变量 `extractedLabels` 和 `formattedData` 中。');

脚本执行结果

运行脚本后,你会看到控制台输出:

  1. 提取的标签列表:原始的、去重后的所有标签名称数组。
  2. 格式化后的数据:一个 JSON 字符串,格式完全符合你的要求。
  3. 可直接复制的数组格式:一个格式化的数组,你可以直接复制到你的代码中。

React中setState后获取更新后值的完整解决方案

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

在React开发中,很多新手都会遇到一个常见“坑”:调用setState更新状态后,立即读取状态却拿到旧值。这并非React的bug,而是setState的异步特性导致的。本文将从问题本质出发,分类详解类组件和函数组件中获取setState更新后值的多种方案,并补充版本差异注意事项,帮你彻底解决这个问题。

一、先搞懂:为什么setState后直接读是旧值?

React中的setState(包括类组件的this.setState和函数组件的useState更新函数)默认是异步批量更新的。这是React的性能优化策略——它会将多个setState调用合并成一次DOM更新,避免频繁重渲染带来的性能损耗。

简单说:setState的调用只是“发起更新请求”,而非“立即执行更新”。在React处理完这次更新前,状态依然保持旧值。

1.1 类组件旧值问题示例

import React from 'react';

class Counter extends React.Component {
  state = { count: 0 };

  handleClick = () => {
    this.setState({ count: this.state.count + 1 });
    console.log('当前count:', this.state.count); // 输出:0(旧值)
  };

  render() {
    return <button onClick={this.handleClick}>{this.state.count}</button>;
  }
}

export default Counter;

1.2 函数组件旧值问题示例

import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    console.log('当前count:', count); // 输出:0(旧值)
  };

  return <button onClick={handleClick}>{count}</button>;
};

export default Counter;

二、类组件:获取更新后值的3种方案

类组件中this.setState提供了灵活的使用方式,对应不同场景有3种可靠方案,优先推荐函数式更新和回调函数。

方案1:setState的第二个参数(回调函数)

this.setState的完整语法是:this.setState(updater, callback)。其中第二个参数是状态更新完成、DOM重新渲染后的回调函数,在这个回调内可以安全获取最新状态。

适用场景:简单状态更新后,需要立即执行依赖最新状态的逻辑(如打印、接口请求)。

class Counter extends React.Component {
  state = { count: 0 };

  handleClick = () => {
    this.setState(
      { count: this.state.count + 1 },
      // 状态更新完成后的回调
      () => {
        console.log('更新后count:', this.state.count); // 输出:1(最新值)
        // 这里可执行依赖最新状态的逻辑,如调用接口
        // this.fetchData(this.state.count);
      }
    );
  };

  render() {
    return <button onClick={this.handleClick}>{this.state.count}</button>;
  }
}

方案2:函数式更新(依赖旧状态时优先)

如果新状态依赖于旧状态(如计数、累加),推荐将setState的第一个参数改为函数。该函数接收两个参数:prevState(更新前的最新状态)和props(当前组件props),返回新的状态对象。

优势:确保拿到的是更新前的最新状态,避免多次setState调用被合并导致的状态偏差。

class Counter extends React.Component {
  state = { count: 0 };

  handleClick = () => {
    // 函数式更新:prevState是更新前的最新状态
    this.setState((prevState) => {
      const newCount = prevState.count + 1;
      console.log('新count(函数内):', newCount); // 输出:1(可提前拿到新值)
      return { count: newCount };
    }, () => {
      console.log('更新后count(回调):', this.state.count); // 输出:1
    });

    // 连续调用也能正确累积(若用对象式更新会只加1)
    this.setState(prev => ({ count: prev.count + 1 })); // 最终count=2
  };

  render() {
    return <button onClick={this.handleClick}>{this.state.count}</button>;
  }
}

方案3:componentDidUpdate生命周期(不推荐,冗余)

componentDidUpdate是组件更新完成后的生命周期钩子,在这个钩子内可以获取最新状态。但这种方式会监听所有状态的更新,需要额外判断目标状态是否变化,冗余度较高,仅在特殊场景下使用。

class Counter extends React.Component {
  state = { count: 0 };

  handleClick = () => {
    this.setState({ count: this.state.count + 1 });
  };

  // 组件更新完成后执行
  componentDidUpdate(prevProps, prevState) {
    // 仅当count变化时执行逻辑
    if (prevState.count !== this.state.count) {
      console.log('更新后count:', this.state.count); // 输出:1
      // 依赖最新count的逻辑
    }
  }

  render() {
    return <button onClick={this.handleClick}>{this.state.count}</button>;
  }
}

三、函数组件:获取更新后值的3种方案

函数组件中没有this.setState,也没有componentDidUpdate生命周期,需结合useState、useEffect、useRef等Hook实现,核心思路与类组件一致,但用法更简洁。

方案1:useEffect监听状态变化(最常用)

useEffect是函数组件的“副作用钩子”,可以监听状态变化。将目标状态放入useEffect的依赖数组,当状态更新时,useEffect的回调函数会执行,此时能拿到最新状态。

适用场景:状态更新后执行后续逻辑(如接口请求、DOM操作),是函数组件中最推荐的方案。

import { useState, useEffect } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);

  // 监听count变化,count更新后执行
  useEffect(() => {
    console.log('更新后count:', count); // 每次count变化都输出最新值
    // 依赖最新count的逻辑,如接口请求
    // fetch(`/api/data?count=${count}`);
  }, [count]); // 依赖数组:仅当count变化时触发

  const handleClick = () => {
    setCount(count + 1);
  };

  return <button onClick={handleClick}>{count}</button>;
};

export default Counter;

方案2:函数式更新(依赖旧状态时优先)

与类组件的函数式更新逻辑一致,useState的更新函数也可以接收一个函数,参数是更新前的最新状态(prevState),返回新状态。

优势:避免因异步更新导致的状态偏差,支持连续多次更新。

import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 函数式更新:prevCount是更新前的最新状态
    setCount((prevCount) => {
      const newCount = prevCount + 1;
      console.log('新count(函数内):', newCount); // 输出:1
      return newCount;
    });

    // 连续调用正确累积
    setCount(prev => prev + 1); // 最终count=2
  };

  return <button onClick={handleClick}>{count}</button>;
};

方案3:useRef保存最新值(异步回调场景)

如果需要在setTimeout、Promise等异步回调中随时获取最新状态,推荐使用useRef。useRef的current属性是可变的,不会触发组件重渲染,可用来实时保存状态的最新值。

适用场景:异步回调中需要访问最新状态(React 18中异步场景的批量更新会让直接读状态失效)。

import { useState, useEffect, useRef } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);
  const countRef = useRef(count); // 用ref保存最新count

  // 每次count变化,更新ref的current值
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  const handleClick = () => {
    setCount(count + 1);

    // 异步回调中获取最新值
    setTimeout(() => {
      console.log('异步回调最新count:', countRef.current); // 输出:1(最新值)
      console.log('直接读count(旧值):', count); // 输出:0(旧值)
    }, 1000);
  };

  return <button onClick={handleClick}>{count}</button>;
};

四、关键注意事项(避坑重点)

1. React 18的自动批处理特性

React 18中,所有场景(包括setTimeout、Promise、原生事件、axios回调等)的setState都会被自动批量更新。这意味着即使在异步回调中调用setState,依然是异步的,直接读取状态仍可能拿到旧值。

示例(React 18中):

const handleClick = () => {
  setTimeout(() => {
    setCount(count + 1);
    console.log(count); // 输出:0(旧值,因批量更新异步)
  }, 0);
};

解决方案:使用上述的useRef或useEffect方案。

2. 避免过度依赖setState回调

不要在setState回调中执行大量耗时操作(如复杂计算、循环),否则会阻塞DOM更新,影响组件性能。耗时操作建议放在setTimeout中或使用Web Worker。

3. 状态依赖必用函数式更新

当新状态依赖旧状态(如count += 1、list.push(newItem))时,必须使用函数式更新(prevState => newState),否则可能因多次setState合并导致状态错误。

五、总结:不同场景的最优方案选型

组件类型 推荐方案 适用场景
类组件 setState回调函数 简单状态更新后立即获取最新值
函数式更新 新状态依赖旧状态,或连续多次更新
函数组件 useEffect监听状态 状态更新后执行后续逻辑(如接口请求)
函数式更新 新状态依赖旧状态,或连续多次更新
useRef保存最新值 异步回调中随时获取最新状态

最后

React中setState的异步特性是为了性能优化,理解其本质后,就能根据具体场景选择合适的方案。记住核心原则:不依赖setState后的同步读取,通过回调、Hook监听或函数式更新获取最新状态,就能轻松避坑。

如果你的项目中还有其他setState相关的问题,欢迎在评论区交流~

Day05- CSS 标准流、浮动、Flex布局

2025年12月22日 16:04

1.标准流

image.png

2.浮动(了解)

  • 作用:让块元素水平排列

  • 属性名:float

  • 属性值:left、right

  • 浮动后的盒子顶对齐、具备行内块特点、脱标

image.png

2.1 利用浮动实现产品布局效果

image.png

2.2 清除浮动

  • 如果粉色盒子(父级)不给height的话,红色盒子会浮动到最上面

image.png

2.2.1 解决方法

image.png

image.pngimage.pngimage.png

2.3 总结

image.png

3.Flex布局

3.1 认识Flex

image.png

3.2 Flex组成

image.png

3.3 Flex布局

image.png

3.3.1 主轴对齐方式

image.png

1.around:两侧间距是空白的一般

2.evenly:两边距离=box之间距离

image.png

3.3.2 侧轴对齐方式

image.png

image.png

image.png

3.3.3 修改主轴和侧轴位置

  • 只记住column就行

  • 所以使用justify-content和align-item时,不能说控制的是水平或垂直方向,而是要看主侧轴。

image.png

image.png

3.3.4 弹性伸缩比

image.png

image.png

3.3.5 弹性盒子换行

image.png

image.png

3.3.6 行对齐方式

image.png

image.png

4.案例

image.png

javascript - webgl中绑定(bind)缓冲区的逻辑是什么?

作者 lebornjose
2025年12月22日 15:47

我有时会发现自己在以不同顺序声明缓冲区(使用createBuffer/bindBuffer/bufferdata)与将其重新绑定(bind)到代码的其他部分(通常在绘制循环中)之间挣扎。

如果在绘制数组之前不重新绑定(bind)顶点缓冲区,则控制台会提示尝试访问超出范围的顶点。我的怀疑是最后一个绑定(bind)对象在指针处传递,然后传递到drawarrays,但是当我在代码开头更改顺序时,什么都没有改变。有效的方法是在绘制循环中重新绑定(bind)缓冲区。因此,我无法真正理解其背后的逻辑。您何时需要重新绑定(bind)?为什么需要重新绑定(bind)? attribute0指的是什么?

最佳答案

我不知道这是否有帮助。就像有些人说的那样,GL/WebGL有很多内部状态。您调用的所有功能均会设置状态。完成所有设置后,您可以调用drawArraysdrawElements,所有状态都用于绘制内容

SO的其他地方对此进行了解释,但是绑定(bind)缓冲区只是在WebGL内设置2个全局变量中的1个。之后,通过缓冲区的绑定(bind)点引用该缓冲区。

你可以这样想

gl = function() {
   // internal WebGL state
   let lastError;
   let arrayBuffer = null;
   let vertexArray = {
     elementArrayBuffer: null,
     attributes: [
       { enabled: false, type: gl.FLOAT, size: 3, normalized: false,
         stride: 0, offset: 0, value: [0, 0, 0, 1], buffer: null },
       { enabled: false, type: gl.FLOAT, size: 3, normalized: false,
         stride: 0, offset: 0, value: [0, 0, 0, 1], buffer: null },
       { enabled: false, type: gl.FLOAT, size: 3, normalized: false,
         stride: 0, offset: 0, value: [0, 0, 0, 1], buffer: null },
       { enabled: false, type: gl.FLOAT, size: 3, normalized: false,
         stride: 0, offset: 0, value: [0, 0, 0, 1], buffer: null },
       { enabled: false, type: gl.FLOAT, size: 3, normalized: false,
         stride: 0, offset: 0, value: [0, 0, 0, 1], buffer: null },
       ...
     ],
   }
   ...

   // Implementation of gl.bindBuffer.
   // note this function is doing nothing but setting 2 internal variables.
   this.bindBuffer = function(bindPoint, buffer) {
     switch(bindPoint) {
       case gl.ARRAY_BUFFER;
         arrayBuffer = buffer;
         break;
       case gl.ELEMENT_ARRAY_BUFFER;
         vertexArray.elementArrayBuffer = buffer;
         break;
       default:
         lastError = gl.INVALID_ENUM;
         break;
     }
   };
...
}();

之后,其他WebGL函数将引用这些。例如gl.bufferData可能会做类似的事情

   // implementation of gl.bufferData
   // Notice you don't pass in a buffer. You pass in a bindPoint.
   // The function gets the buffer one of its internal variable you set by
   // previously calling gl.bindBuffer

   this.bufferData = function(bindPoint, data, usage) {

     // lookup the buffer from the bindPoint
     var buffer;
     switch (bindPoint) {
       case gl.ARRAY_BUFFER;
         buffer = arrayBuffer;
         break;
       case gl.ELEMENT_ARRAY_BUFFER;
         buffer = vertexArray.elemenArrayBuffer;
         break;
       default:
         lastError = gl.INVALID_ENUM;
         break;
      }

      // copy data into buffer
      buffer.copyData(data);  // just making this up
      buffer.setUsage(usage); // just making this up
   };

与这些绑定(bind)点分开的是,有许多属性。默认情况下,属性也是全局状态。它们定义了如何从缓冲区中提取数据以提供给顶点着色器。调用gl.getAttribLocation(someProgram, "nameOfAttribute")会告诉您顶点着色器将查看哪个属性以从缓冲区中获取数据。

因此,有4个函数可用于配置属性如何从缓冲区中获取数据。 gl.enableVertexAttribArraygl.disableVertexAttribArraygl.vertexAttribPointergl.vertexAttrib??

他们已经有效地实现了这样的事情

this.enableVertexAttribArray = function(location) {
  const attribute = vertexArray.attributes[location];
  attribute.enabled = true;  // true means get data from attribute.buffer
};

this.disableVertexAttribArray = function(location) {
  const attribute = vertexArray.attributes[location];
  attribute.enabled = false; // false means get data from attribute.value
};

this.vertexAttribPointer = function(location, size, type, normalized, stride, offset) {
  const attribute = vertexArray.attributes[location];
  attribute.size       = size;       // num values to pull from buffer per vertex shader iteration
  attribute.type       = type;       // type of values to pull from buffer
  attribute.normalized = normalized; // whether or not to normalize
  attribute.stride     = stride;     // number of bytes to advance for each iteration of the vertex shader. 0 = compute from type, size
  attribute.offset     = offset;     // where to start in buffer.

  // IMPORTANT!!! Associates whatever buffer is currently *bound* to
  // "arrayBuffer" to this attribute
  attribute.buffer     = arrayBuffer;
};

this.vertexAttrib4f = function(location, x, y, z, w) {
  const attribute = vertexArray.attributes[location];
  attribute.value[0] = x;
  attribute.value[1] = y;
  attribute.value[2] = z;
  attribute.value[3] = w;
};

现在,当您调用gl.drawArraysgl.drawElements时,系统知道如何将数据从为提供顶点着色器而制作的缓冲区中拉出。 See here for how that works。

由于属性是全局状态,这意味着每次调用drawElementsdrawArrays时,如何使用属性设置。如果将属性#1和#2设置为每个缓冲区具有3个顶点,但要求使用gl.drawArrays绘制6个顶点,则会出现错误。同样,如果您创建了一个索引缓冲区,并将其绑定(bind)到gl.ELEMENT_ARRAY_BUFFER绑定(bind)点,并且该缓冲区的索引大于2,则会收到index out of range错误。如果缓冲区只有3个顶点,则唯一有效的索引是012

通常,每次绘制不同的东西时,您都会重新绑定(bind)绘制该东西所需的所有属性。绘制具有位置和法线的立方体?将缓冲区与位置数据绑定(bind),设置用于位置的属性,将缓冲区与法线数据绑定(bind),设置用于法线的属性,现在绘制。接下来,绘制一个包含位置,顶点颜色和纹理坐标的球体。绑定(bind)包含位置数据的缓冲区,设置用于位置的属性。绑定(bind)包含顶点颜色数据的缓冲区,设置用于顶点颜色的属性。绑定(bind)包含纹理坐标的缓冲区,设置用于纹理坐标的属性。

唯一不重新绑定(bind)缓冲区的情况是绘制同一件事的次数不止一次。例如,绘制10个多维数据集。您将重新绑定(bind)缓冲区,然后为一个立方体设置制服,绘制它,为下一个立方体设置制服,绘制它,重复。

我还应该补充说,有一个扩展名[OES_vertex_array_object],它也是WebGL 2.0的功能。顶点数组对象是上面的称为vertexArray的全局状态,其中包括elementArrayBuffer和所有属性。

调用gl.createVertexArray会使其中的一种成为新的。调用gl.bindVertexArray会将全局attributes设置为指向绑定(bind)的vertexArray中的那个。

调用gl.bindVertexArray将是

 this.bindVertexArray = function(vao) {
   vertexArray = vao ? vao : defaultVertexArray;
 }

这样做的好处是,您可以在初始化时设置所有属性和缓冲区,然后在绘制时只需1个WebGL调用即可设置所有缓冲区和属性。

这是一个webgl state diagram,可以帮助更好地可视化它。

你每天都在用的 JSON.stringify ,V8 给它开了“加速通道”

2025年12月22日 15:49

V8 如何把 JSON.stringify 性能提升 2 倍

JSON.stringify 应该是 JavaScript 里用得最多的函数之一了。

API 响应要序列化,日志要格式化,数据要存 localStorage,调试要打印对象……几乎每个项目都离不开它。

但说实话,用的时候很少会想"这玩意儿快不快"。反正就是调一下,能用就行。

V8 团队显然不这么想。V8 是 Chrome 和 Node.js 背后的 JavaScript 引擎,你写的每一次 JSON.stringify,最后都要靠它来跑。2025 年 8 月,他们发了篇博客,讲了怎么把 JSON.stringify 的性能提升到原来的 2 倍以上。

这篇文章拆解一下他们做了什么。

读者导航:不懂 V8 也能看

先记住三句话就够了:

  1. 绝大多数优化都在“走捷径”:先判断输入是不是“简单、可预测”的对象,是的话走更快的路径。
  2. 很多名词听着硬,其实都在做同一件事:减少检查、减少函数调用、让 CPU 一次干更多活、减少内存搬运。
  3. 你能做的配合也很简单:少用会触发副作用的写法(getter、toJSON、格式化参数),保持数据对象“干净”。

下面遇到生词可以先跳过,看完“对开发者的启示”再回头补。

优化的前提:无副作用检测

JSON.stringify 慢在哪?

一个重要原因是它要处理各种边界情况:对象可能有 toJSON 方法,属性可能是 getter,可能有循环引用……这些都可能产生副作用,导致序列化结果不可预测。

V8 的第一步优化是:检测对象是否"干净"

如果能确定序列化过程不会触发任何副作用,就可以走一条快速路径,跳过大量的安全检查。

"if we can guarantee that serializing an object will not trigger any side effects, we can use a much faster, specialized implementation."

这条快速路径用迭代替代了递归,好处有两个:

  1. 不用担心栈溢出(深层嵌套对象)
  2. 减少函数调用开销

字符串处理:双版本编译

JavaScript 字符串有两种内部表示:单字节(Latin-1)和双字节(UTF-16)。

以前 V8 用统一的方式处理,现在编译了两个特化版本的序列化器,分别针对这两种编码优化。可以简单理解成:如果字符串全是英文数字,就走“单字节快车道”;如果包含中文表情等,就走“UTF-16 车道”。

遇到混合编码的情况(比如一个对象里既有纯 ASCII 字符串,又有中文),会在执行时动态切换。

这种"按需特化"的思路在编译器优化里很常见,但用在 JSON 序列化上还是挺有意思的。

SIMD 加速字符扫描

序列化字符串时,需要扫描哪些字符需要转义(比如 \n\t")。

V8 用了两种硬件加速策略:

  • SIMD 指令:对于较长的字符串,一次处理多个字符(你可以理解成“把 16 个字节打包一起扫一遍”)
  • SWAR 技术:对于较短的字符串,用位运算在普通寄存器上并行处理(SIMD 的“轻量版”)

SWAR(SIMD Within A Register)是个挺老的技术,思路是把一个 64 位寄存器当成 8 个 8 位的"小寄存器"用,通过位运算实现并行。

举个例子,判断一个字节是否需要转义,可以这样:

// 伪代码示意
// 需要转义的字符:< 0x20 或 == 0x22(") 或 == 0x5C(\)
function needsEscape(byte) {
  return byte < 0x20 || byte === 0x22 || byte === 0x5C;
}

用 SWAR 可以一次判断 8 个字节,只要用合适的掩码和位运算。

Hidden Class 标记

V8 内部用 Hidden Class(隐藏类)来优化对象属性访问。你可以把它理解成“对象结构的身份证”:同一类对象(同样的字段、同样的顺序)会复用同一个结构描述。

这次优化加了一个新标记:fast-json-iterable

当一个对象的属性满足特定条件时,就给它打上这个标记。下次序列化同类对象时,直接跳过验证检查。

这就是典型的"用空间换时间"——在对象上多存一个标记,换来后续操作的加速。

数字转字符串:换算法

把数字转成字符串也是序列化的一部分。

V8 以前用 Grisu3 算法,现在换成了 Dragonbox。

你不需要理解算法细节,只要知道:Dragonbox 能更快、更稳定地把浮点数转成最短且精确的十进制表示。这个改动不只影响 JSON 序列化,所有 Number.toString() 都能受益。

内存管理:分段缓冲区

以前序列化大对象时,V8 用一块连续内存作为缓冲区。对象越大,缓冲区就要不断扩容,每次扩容都要重新分配和复制。

新实现用分段缓冲区(segmented buffer),多个小块链起来用,避免了昂贵的重分配。直觉上就是:不强求“一次申请一大块”,而是“够用就多挂一块”。

这个思路和 Linux 内核的 sk_buff 链表类似——不追求内存连续,换来分配效率。

快速路径的适用条件

不是所有 JSON.stringify 调用都能走快速路径。需要满足:

  1. 不传 replacer 和 space 参数

    // 可以走快速路径
    JSON.stringify(obj);
    
    // 不行
    JSON.stringify(obj, null, 2);
    JSON.stringify(obj, ['name', 'age']);
    

    这里的 replacer/space 分别是“过滤/改写字段的回调或白名单”和“为了好看而加的缩进”。它们会让序列化过程更复杂,所以很难走最激进的优化路径。

  2. 纯数据对象

    • 没有 toJSON 方法
    • 没有 getter
    • 没有 Symbol 属性
  3. 字符串键

    • 所有属性名都是字符串(不是数字下标)
  4. 简单字符串值

    • 字符串值本身没有特殊情况

实际项目里,大部分序列化场景都满足这些条件。API 返回的纯 JSON 数据、配置对象、日志数据……基本都能用上。

什么时候能用?

Chrome 138 / V8 13.8 开始可用。

Node.js 的话,需要等对应的 V8 版本合入。目前最新的 Node.js 22 用的是 V8 12.x,还得等一等。

对开发者的启示

虽然优化是 V8 团队做的,但有几点可以参考:

1. 保持对象"干净"

避免在需要序列化的对象上加 getter 或 toJSON 方法。如果必须用,考虑在序列化前转换成纯数据对象。

// 不太好
class User {
  get fullName() {
    return this.firstName + ' ' + this.lastName;
  }
}

// 更好
const user = {
  firstName: 'John',
  lastName: 'Doe',
  fullName: 'John Doe'  // 直接计算好
};

2. 大量序列化时考虑结构一致性

V8 的 Hidden Class 优化依赖对象结构一致。如果你要序列化大量对象,保持它们的属性顺序和类型一致。

// 好
const users = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: 30 },
  { name: 'Charlie', age: 35 }
];

// 不太好(age 类型不一致)
const users = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: '30' },  // 字符串
  { name: 'Charlie', age: null }
];

3. 避免无意义的格式化

开发环境用 JSON.stringify(obj, null, 2) 看着舒服,但这会跳过快速路径。生产环境记得去掉。

// 开发环境
console.log(JSON.stringify(data, null, 2));

// 生产环境
console.log(JSON.stringify(data));

总结

  1. 分析热点:找到可以优化的场景(无副作用对象)
  2. 特化路径:针对常见情况走快速路径,边界情况走通用路径
  3. 硬件加速:用 SIMD 和 SWAR 提升字符处理速度
  4. 利用已有信息:通过 Hidden Class 标记避免重复验证
  5. 改进算法:Dragonbox 替代 Grisu3
  6. 优化内存:分段缓冲区避免重分配

这些技术单拿出来都不新鲜,但组合起来能把一个已经很成熟的 API 性能翻倍,还是挺厉害的。

对我们写代码的人来说,最大的收获可能是:写"干净"的代码不只是为了可读性,有时候还能让引擎更好地优化。

延伸阅读


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

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

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

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

Electron无边框窗口如何拖拽以及最大化和还原窗口

作者 静待雨落
2025年12月22日 15:44

什么是无边框窗口

设置了frame: false,新建的窗口就没有边框了!

mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    frame: false, // 无边框核心配置
    webPreferences: {
      nodeIntegration: true, // 启用 Node 集成,允许渲染进程调用 Electron API
      contextIsolation: false // 关闭上下文隔离,简化调用
    }
  });

无边框窗口如何拖拽

Electron无边框窗口默认无法拖拽,需在 HTML 中给可拖拽区域添加 CSS 样式 -webkit-app-region: drag;(注意:按钮等可点击元素需设置 webkit-app-region: no-drag; 避免无法点击)。

// 可拖拽部分
.header {
    -webkit-app-region: drag;
}

// 不需要拖拽部分
.main {
    -webkit-app-region: no-drag;
}

如何最大化窗口和还原

Electron提供了窗口的最大化和还原窗口的api

    // 最大化窗口
    mainWindow.maximize()
    // 还原窗口
    mainWindow.unmaximize();

但是!在无边框窗口中使用mainWindow.maximize()最大化窗口并不能被还原,此时就需要其他方法来实现此功能。

首先判断是否处于全屏状态

// 兼容所有浏览器的全屏状态判断
function isDocumentFullScreen() {
  return !!document.fullscreenElement || 
         !!document.webkitFullscreenElement || // 对应 webkitIsFullScreen 的元素版
         !!document.mozFullScreenElement ||    // 对应 mozFullScreen 的元素版
         !!document.msFullscreenElement;
}

你在使用 TypeScript 时,调用 document.webkitFullscreenElementdocument.mozFullScreenElement 等厂商前缀全屏 API 出现类型错误,这是因为 TypeScript 的内置 DOM 类型定义中仅包含标准 API(如 document.fullscreenElement),未包含这些非标准的厂商前缀属性,以下是几种完整的解决方案,按推荐优先级排序:

一、方案 1:类型断言(Type Assertion)—— 快速解决单个属性报错

这是最简洁的临时解决方案,通过类型断言告诉 TypeScript 「该属性存在于 document 上」,忽略类型检查报错。

实现代码
// 兼容所有浏览器的全屏状态判断(TS 兼容写法)
function isDocumentFullScreen(): boolean {
  // 对 document 进行类型断言,指定为包含厂商前缀属性的任意类型
  const doc = document as any;
  return !!doc.fullscreenElement || 
         !!doc.webkitFullscreenElement || 
         !!doc.mozFullScreenElement || 
         !!doc.msFullscreenElement;
}

// 或直接对单个属性进行断言(更精准)
function isDocumentFullScreen精准版(): boolean {
  return !!document.fullscreenElement || 
         !!(document as { webkitFullscreenElement?: Element | null }).webkitFullscreenElement || 
         !!(document as { mozFullScreenElement?: Element | null }).mozFullScreenElement || 
         !!(document as { msFullscreenElement?: Element | null }).msFullscreenElement;
}
特点
  • 优点:快速便捷,无需额外配置,适合简单场景或临时调试。
  • 缺点:缺乏类型提示,若属性名拼写错误(如把 webkitFullscreenElement 写成 webkitFullScreenElement),运行时才会暴露问题。
二、方案 2:扩展全局 DOM 类型(推荐)—— 长期优雅解决

通过 TypeScript 的「全局类型扩展」功能,为 Document 接口补充厂商前缀属性的类型定义,既解决报错,又能获得类型提示,是长期项目的最优解。

步骤 1:创建类型声明文件(如 global.d.ts

在项目根目录或 src 目录下创建 .d.ts 后缀的类型声明文件(TS 会自动识别该类型文件,无需手动引入):

// global.d.ts
declare global {
  interface Document {
    // 补充 WebKit/Blink 内核厂商前缀属性
    webkitFullscreenElement?: Element | null;
    // 补充 Gecko 内核厂商前缀属性(注意:S 大写,与标准 API 有差异)
    mozFullScreenElement?: Element | null;
    // 补充 Trident 内核厂商前缀属性
    msFullscreenElement?: Element | null;

    // 可选:若需使用旧版布尔值属性,也可补充对应类型
    webkitIsFullScreen?: boolean;
    mozFullScreen?: boolean;
  }
}

// 必须导出空对象,标识这是一个模块
export {};
步骤 2:配置 tsconfig.json(确保类型文件被识别)

确保 tsconfig.json 中包含该类型文件的路径(默认情况下,"include" 会覆盖 src 下所有 .ts/.d.ts 文件,若已配置可跳过):

{
  "compilerOptions": {
    "target": "ES6",
    "module": "CommonJS",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "typeRoots": ["./node_modules/@types", "./src/types"] // 若类型文件放在 src/types 下,需配置此路径
  },
  "include": ["src/**/*", "global.d.ts"] // 包含全局类型声明文件
}
步骤 3:正常使用代码(无报错且有类型提示)
// 此时 TS 不会报错,且能自动提示对应属性
function isDocumentFullScreen(): boolean {
  return !!document.fullscreenElement || 
         !!document.webkitFullscreenElement || 
         !!document.mozFullScreenElement || 
         !!document.msFullscreenElement;
}
特点
  • 优点:类型安全、有代码提示,一劳永逸,适合长期维护的项目。
  • 缺点:需要额外创建类型文件,初次配置略繁琐。

补充说明

  1. 厂商前缀 API 已逐步被废弃,若项目无需兼容老旧浏览器,推荐直接使用标准 API document.fullscreenElement,无需额外处理类型问题。
  2. 若使用方案 2 后仍报错,可重启 TS 语言服务(VS Code 中可按 Ctrl+Shift+P,输入「TypeScript: Restart TS Server」)。
  3. 类型声明文件中,属性添加 ? 表示可选属性(因为并非所有浏览器都存在这些属性),符合实际兼容场景。

进入全屏

if (!isFull) { // 进入全屏
    let element = document.documentElement;
    if (element.requestFullscreen) {
        element.requestFullscreen();
    } else if (element.msRequestFullscreen) {
        element.msRequestFullscreen();
    } else if (element.mozRequestFullScreen) {
        element.mozRequestFullScreen();
    } else if (element.webkitRequestFullscreen) {
        element.webkitRequestFullscreen();
    }
}
  • 逻辑:先获取页面根元素 document.documentElement(即 <html> 标签,通常让整个页面进入全屏),再优先调用标准 API,降级调用厂商前缀方法:

    • element.requestFullscreen():标准进入全屏方法(需指定 “要全屏的元素”)。
    • element.msRequestFullscreen():IE / 旧版 Edge 的进入全屏方法。
    • element.mozRequestFullScreen():Firefox 的进入全屏方法(注意命名是 RequestFullScreen)。
    • element.webkitRequestFullscreen():Chrome/Safari 的进入全屏方法。

退出全屏

if (isFull) {// 退出全屏
    if (document.exitFullscreen) {
        document.exitFullscreen();
    } else if (document.msExitFullscreen) {
        document.msExitFullscreen();
    } else if (document.mozCancelFullScreen) {
        document.mozCancelFullScreen();
    } else if (document.webkitExitFullscreen) {
        document.webkitExitFullscreen();
    }
}
  • 逻辑:优先调用标准 API,若浏览器不支持,则降级调用对应厂商前缀的 “退出全屏” 方法:

    • document.exitFullscreen():标准退出全屏方法。
    • document.msExitFullscreen():IE / 旧版 Edge 的退出全屏方法。
    • document.mozCancelFullScreen():Firefox 的退出全屏方法(注意命名是 CancelFullScreen)。
    • document.webkitExitFullscreen():Chrome/Safari 的退出全屏方法。

Java 异常处理:3 个 “避坑神操作”,告别崩溃式报错

2025年12月22日 15:34

做 Java 开发谁没踩过异常的坑?比如空指针直接让程序 “原地去世”,try-catch 裹成 “千层饼”,报错信息含糊到 debug 半小时找不到问题。今天分享 3 个最常用的异常处理技巧,代码简洁还实用,新手也能轻松拿捏~

1. 空指针防护:别再写一堆 if 判空!

日常开发中,空指针是最常见的异常。推荐用Objects.requireNonNullElse(Java 9+)或Optional类,一行搞定空值处理:

import java.util.Objects;
import java.util.Optional;

public class ExceptionDemo {
    public static void main(String[] args) {
        String userName = null; // 模拟可能为空的参数
        Integer age = null;

        // 技巧1:Objects工具类(直接指定默认值)
        String safeName = Objects.requireNonNullElse(userName, "匿名用户");
        System.out.println("用户名:" + safeName); // 输出:匿名用户

        // 技巧2:Optional类(更灵活的空值处理)
        Integer safeAge = Optional.ofNullable(age)
                                 .orElse(18); // 为空则返回默认值18
        System.out.println("年龄:" + safeAge); // 输出:18

        // 进阶:为空时抛自定义异常
        Optional.ofNullable(userName)
                .orElseThrow(() -> new IllegalArgumentException("用户名不能为空!"));
    }
}

2. 多异常处理:别写 N 个 catch 块!

多个异常只需统一处理时,用 “|” 合并异常类型,代码瞬间清爽:

// 技巧3:合并异常处理(避免重复代码)
public static void handleMultiException(String str) {
    try {
        Integer num = Integer.parseInt(str); // 可能抛NumberFormatException
        System.out.println("转换结果:" + num);
    } catch (NumberFormatException | NullPointerException e) {
        // 一个catch处理两种异常
        System.out.println("错误:输入参数无效!原因:" + e.getMessage());
    }
}

// 调用测试
public static void main(String[] args) {
    handleMultiException("abc"); // 抛NumberFormatException
    handleMultiException(null);  // 抛NullPointerException
}

二、关键注意事项

  1. 别用e.printStackTrace()!建议用日志框架(如 SLF4J)打印,方便定位问题;

  2. 自定义异常时,要包含详细的错误信息,别只抛 “发生异常” 这种无用提示;

  3. 不要滥用 try-catch,比如不需要处理的运行时异常(如 IndexOutOfBoundsException),不如让程序快速失败,便于排查问题。

用 Vue3 + Coze API 打造冰球运动员 AI 生成器:从图片上传到风格化输出

作者 ohyeah
2025年12月22日 14:56

本文将带你从零构建一个基于 Vue3 和 Coze 工作流的趣味 AI 应用——“宠物变冰球运动员”生成器。通过上传一张宠物照片,结合用户自定义的队服编号、颜色、位置等参数,即可生成一张风格化的冰球运动员形象图。


一、项目背景与目标

在 AI 能力逐渐普及的今天,越来越多开发者尝试将大模型能力集成进自己的 Web 应用中。本项目的目标是打造一个轻量、有趣、可分享的前端应用:

  • 用户上传宠物照片;
  • 自定义冰球队服(编号、颜色)、场上位置(守门员/前锋/后卫)、持杆手(左/右)以及艺术风格(写实、乐高、国漫等);
  • 后端调用 Coze 平台的工作流 API,完成图像生成;
  • 最终返回生成结果并展示。

这类“趣味换脸/换装”类应用非常适合社交传播,比如冰球协会举办活动时,鼓励用户上传自家宠物照片生成“冰球明星”,再分享至朋友圈,既有趣又具传播性。


二、技术栈与核心流程

技术选型

  • 前端框架:Vue 3(<script setup> + Composition API)
  • 状态管理ref 响应式变量
  • HTTP 请求:原生 fetch
  • AI 能力平台Coze(提供工作流和文件上传 API)
  • 环境变量import.meta.env.VITE_PAT_TOKEN(用于安全存储 PAT Token)

核心业务流程

  1. 图片预览:用户选择图片后,立即在前端显示预览(使用 FileReader + Base64);
  2. 上传图片:将图片通过 FormData 上传至 Coze 文件服务,获取 file_id
  3. 调用工作流:携带 file_id 与用户配置参数,调用 Coze 工作流 API;
  4. 展示结果:解析返回的图片 URL 并渲染。

三、代码详解:从模板到逻辑

1. 模板结构(Template)

<template>
  <div class="container">
    <div class="input">
      <!-- 图片上传与预览 -->
      <div class="file-input">
        <img :src="imgPreview" alt="" v-if="imgPreview">
        <input type="file"
         ref="uploadImage" 
         accept="image/*"
         @change="updataImageData"
         required>
      </div>

      <!-- 配置项:队服、位置、风格等 -->
      <div class="settings">
        <div class="selection">
          <label>队服编号:</label>
          <input type="number" v-model="uniform_number">
        </div>
        <div class="selection">
          <label>队服颜色:</label>
          <select v-model="uniform_color">
            <option value="红"></option>
            <option value="蓝"></option>
            <!-- 其他颜色... -->
          </select>
        </div>
      </div>

      <div class="settings">
        <div class="selection">
          <label>位置</label>
          <select v-model="position">
            <option value="0">守门员</option>
            <option value="1">前锋</option>
            <option value="2">后卫</option>
          </select>
        </div>
        <div class="selection">
          <label>持杆:</label>
          <select v-model="shooting_hand">
            <option value="0">左手</option>
            <option value="1">右手</option>
          </select>
        </div>
        <div class="selection">
          <label>风格:</label>
          <select v-model="style">
            <option value="写实">写实</option>
            <option value="乐高">乐高</option>
            <!-- 多种艺术风格... -->
          </select>
        </div>
      </div>
       
      <!-- 生成按钮 -->
      <div class="generate">
        <button @click="generate">生成</button>
      </div>
    </div>

    <!-- 输出区域 -->
    <div class="output">
      <div class="generated">
        <img :src="imgUrl" alt="" v-if="imgUrl">
        <div v-if="status">{{ status }}</div>
      </div>  
    </div>
  </div>
</template>

关键点

  • 使用 v-if 控制预览图和结果图的显示;
  • accept="image/*" 限制仅可选择图片文件;
  • 所有配置项均通过 v-model 双向绑定到响应式变量。

2. 响应式状态声明(Script Setup)

import { ref, onMounted } from 'vue'

const imgPreview = ref('') // 本地预览图(Base64)
const uniform_number = ref(10)
const uniform_color = ref('红')
const position = ref(0)
const shooting_hand = ref('左手') // 注意:实际传给后端的是 0/1,此处为显示用
const style = ref('写实')

// 生成状态与结果
const status = ref('')
const imgUrl = ref('')

// Coze API 配置
const patToken = import.meta.env.VITE_PAT_TOKEN
const uploadUrl = 'https://api.coze.cn/v1/files/upload'
const workflowUrl = 'https://api.coze.cn/v1/workflow/run'
const workflow_id = '7567272503635771427'

🔒 安全提示VITE_PAT_TOKEN 是 Personal Access Token,绝不能硬编码在代码中!应通过 .env 文件注入,并确保 .gitignore 中排除该文件。


3. 图片预览功能:用户体验的关键

const uploadImage = ref(null)

onMounted(() => {
  console.log(uploadImage.value) // 挂载后指向 input DOM
})
// 状态 null -> input DOM  ref也可以用来绑定DOM元素

const updataImageData = () => {
  const input = uploadImage.value
  if (!input.files || input.files.length === 0) return
  // 文件对象 html新特性
  const file = input.files[0]
  const reader = new FileReader() // 
  reader.readAsDataURL(file)
  // readAsDateURL 返回Base64编码的DataURL 可直接用于<img src>
  reader.onload = (e) => {
    imgPreview.value = e.target.result // // 响应式状态 当拿到图片文件后 立马赋给imgPreview的value 那么此时template中img的src就会接收这个状态 从而响应展示图片
  }
}

🌟 为什么需要预览?

  • 用户上传的图片可能较大,上传需时间;
  • 立即显示预览能提升交互反馈感;
  • FileReader.readAsDataURL() 将图片转为 Base64,无需网络请求即可显示。

4. 上传图片到 Coze:获取 file_id

const uploadFile = async () => {
  const formData = new FormData()
  const input = uploadImage.value
  if (!input.files || input.files.length <= 0) return

  formData.append('file', input.files[0])

  const res = await fetch(uploadUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${patToken}`
    },
    body: formData
  })

  const ret = await res.json()
  console.log(ret)
  if (ret.code !== 0) {
    status.value = ret.msg
    return
    // 当code为0时 表示没有错误 那么这里进行判断 当不为0时 返回错误信息给status.value
  }

  return ret.data.id // 关键:返回 file_id 供后续工作流使用
}

⚠️ 常见错误排查

  • 若返回 {"code":700012006,"msg":"cannot get access token from Authorization header"},说明 patToken 未正确设置或格式错误;
  • 确保请求头为 'Authorization': 'Bearer xxx',注意大小写和空格。

5. 调用 Coze 工作流:生成 AI 图像

const generate = async () => {
  status.value = '图片上传中...'
  const file_id = await uploadFile()
  if (!file_id) return

  status.value = '图片上传成功,正在生成中...'

  const parameters = {
    picture: JSON.stringify({ file_id }), // 注意:需 stringify
    style: style.value,
    uniform_color: uniform_color.value,
    uniform_number: uniform_number.value,
    position: position.value,
    shooting_hand: shooting_hand.value
  }

  const res = await fetch(workflowUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${patToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      workflow_id,
      parameters
    })
  })

  const ret = await res.json()
  if (ret.code !== 0) {
    status.value = ret.msg
    return
  }

  const data = JSON.parse(ret.data) // 注意:Coze 返回的是字符串化的 JSON
  imgUrl.value = data.data
  status.value = ''
}

重要细节

  • picture 字段必须是 JSON.stringify({ file_id }),因为 Coze 工作流节点可能期望字符串输入;
  • ret.data 是字符串,需再次 JSON.parse 才能得到真正的结果对象;
  • 若遇到 {"code":4000,"msg":"The requested API endpoint GET /v1/workflow/run does not exist..."},说明你用了 GET 方法,但该接口只支持 POST

四、样式与布局(Scoped CSS)

<style scoped>
.container {
  display: flex;
  flex-direction: row;
  height: 100vh;
}

.input {
  display: flex;
  flex-direction: column;
  min-width: 330px;
}

.generated {
  width: 400px;
  height: 400px;
  border: solid 1px black;
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

✨ 使用 scoped 确保样式隔离,避免污染全局;弹性布局实现左右两栏(配置区 + 结果区)。


五、总结与延伸

本项目完整展示了如何将 前端交互AI 工作流 结合:

  • 利用 Vue3 的响应式系统管理状态;
  • 通过 FileReader 实现即时预览;
  • 使用 fetch + FormData 安全上传文件;
  • 调用 Coze API 实现“上传 → 生成 → 展示”闭环。

最后提醒:

  • 务必保护好你的 PAT Token
  • 遵守 Coze 的 API 调用频率限制,如果无法响应,可以尝试更换你的Coze API;
  • 测试不同风格下的生成效果,优化用户体验。

通过这个小而美的项目,你不仅能掌握 Vue3 的实战技巧,还能深入理解如何将 AI 能力无缝集成到 Web 应用中。快去试试吧,让你的宠物穿上冰球队服,成为下一个 AI 冰球明星!🏒🐶

顶层元素问题:popover vs. dialog

2025年12月22日 14:21

原文:Top layer troubles: popover vs. dialog 作者:Stephanie Eckles 日期:2025年12月1日 翻译:田八

来源:前端周刊

你是否曾尝试通过设置 z-index: 9999 解决元素层级问题?如果是,那你其实是在与一个基础的CSS概念 ——层叠上下文—— 斗争。

层叠上下文定义了元素在第三维度(即“z轴”)上的排列顺序。你可以把z轴想象成视口中层叠上下文根节点与用户(即通过浏览器视口观察的你)之间的DOM元素的层级。

image.png

一个元素只能在同一层叠上下文中重新调整层级。虽然 z-index 是实现这一点的工具,但失败往往源于层叠上下文的变化。这种变化可能通过多种方式发生,例如使用固定定位(fixed)、粘性定位(sticky)元素,或是将绝对定位(absolute)/相对定位(relative)与 z-index 结合使用等,MDN 上列出了更多原因

现代网页设计有一个“顶层”特性,它保证使其位于所有其他层叠上下文的最顶层。它覆盖整个视口,不过顶层中的元素实际可见尺寸可能更小。

将元素提升到顶层,可使其摆脱原本所在的任何层叠上下文。

虽然顶层直接解决了一个与CSS相关的问题,但目前还没有属性可用于将元素提升到顶层。取而代之的是,某些元素和特定条件可以访问顶层,例如通过 <div> 标签显示的原生对话框 showModal() 和被指定为 Popover 的元素。

Popover API是一项新推出的 HTML 功能,它允许你声明式的创建非模态覆盖元素。通过使用 Popover API 用来摆脱任何层叠上下文,这是它的一个理想特性。然而,在急于选择这种原生能力之前,需要注意一个潜在的问题。

场景设定

想象一下,在2025年的网络世界:你的网页应用包含一个通过“Toast”消息显示通知的服务。你知道的,就是那些通常出现在角落或其他不太可能遮挡其他用户界面(UI)位置的弹出消息。

通常,这些Toast通知通常用于实时提醒,比如保存成功,或者表单提交失败等错误提示。它们有时有时间限制,或者包含如关闭按钮这样的关闭机制。有时它们还包含额外操作,例如“重试”选项,用于重新提交失败的工作流。

既然您的应用紧跟时代潮流,你最近决定将Toast升级为使用Popover API。这样你就可以将Toast组件放置在应用的任何结构中,而无需为了解决层叠上下文问题而采用一些变通方法。毕竟,Toast必须显示在所有其他元素之上,因此通过 Popover 实现顶层访问是明智之举!

你发布了改进版本,并为自己的工作感到自豪。

发布的当周晚些时候,你收到了一份紧急错误报告。不是普通的错误报告,而是一个可访问性违规报告。

Dialog vs. popover

你的应用很新潮,你之前也升级使用了原生HTML对话框。那是一次很棒的升级,因为你用原生 Web 功能取代了对 JavaScript 的依赖。这也是你兴奋地将Toast也升级为使用Popover的另一个原因。

那么,错误是什么呢?一位键盘用户正在使用一个包含对话框的工作流程,对话框打开期间,后台进程触发了一个弹出式通知。该通知提示存在错误,需要用户进行交互。

当这位键盘用户试图将焦点切换到Toast上时,出现了错误。他们虽然在视觉上能看到Toast显示在对话框背景之上,但焦点无法成功进入Toast,而是意外地跳到了浏览器UI上。

你可以在这个CodePen示例中亲自体验这个错误,使用Tab键,你会发现你永远无法访问到Toast。你也可以尝试使用屏幕阅读器,会发现虚拟光标也无法进入Toast。

CodePen

如果你能够点击弹出框,可能会觉得至少点击操作是可行的。但很快我们就会发现,事情并非如此。

为什么Toast弹出框无法访问

虽然顶层可以超越标准的层叠上下文,但顶层中的元素仍然受分层顺序的影响。最近添加到顶层的元素会显示在之前添加的顶层元素之上。这就是为什么Toast在视觉上会显示在对话框背景之上。

如果弹出框在视觉上可用,那为什么通过键盘或屏幕阅读器的虚拟光标却无法访问呢?

原因在于弹出框与 模态 对话框之间存在竞争关系。当通过 showModal()方法启动原生HTML对话框时,对话框外部的页面会变为 惰性状态惰性状态 是一种必要的可访问性行为,它会隔离对话框内容,并阻止通过Tab键和虚拟光标访问背景页面。

这个错误是由于Toast弹出框是背景页面DOM的一部分。这意味着由于它位于对话框DOM边界之外,所以也变成了惰性状态。

但是,由于顶层顺序的原因,因为它是在对话框打开后创建的,所以在视觉上,它被放置在对话框的顶部,这一点可能会让你感到困惑。

如果你以为点击弹出框就能关闭它,实际上并非如此,尽管弹出框确实会消失。真正发生的情况是,你触发了弹出框的 轻触关闭 行为。这意味着它关闭是因为你实际上点击了它的边界之外,因为对话框捕获了点击操作。

所以,虽然弹出框被关闭了,但“重试”按钮实际上并没有被点击,这意味着任何关联的事件监听器都不会被触发。

即使你创建了一个自动化测试来专门检查当对话框打开时Toast的提醒功能,该自动化测试仍可能出现误报,因为它触发了对Toast按钮的编程式点击。这种伪点击错误地绕过了由于对话框导致页面变为惰性状态所引发的问题。

重新获得弹出框访问权限

解决方案有两个方面:

  1. 将弹出框(popover)在DOM中实际放置在对话框(dialog)内部。
  2. 确保使用 popover="manual",以防止对话框内的点击操作过早触发弹出框的轻触关闭。

完成这两步后,弹出框现在既在视觉上可用,又可以通过任何方式完全交互。

Codepan

经验教训与额外考虑

我们了解到,如果你的网站或应用有可能同时显示弹出框和对话框,并且它们有独立的时间线,那么你需要想出一种在对话框内启动弹出框的机制。

或者,您可以选择在对话框关闭之前禁用后台页面弹出窗口。但如果通知需要及时交互,或者对话框内容有可能触发 Toast 提示,则此方法可能并不理想。

除了可见性和交互性之外,您可能还需要考虑另一个问题:弹出窗口是否需要在对话框关闭后继续保持打开状态。也就是说,即使对话框关闭,弹出窗口也需要保持打开状态,例如继续等待用户执行操作。

虽然我非常支持使用原生平台功能,而且我认为弹出框(popover)尤其出色,但有时冲突是无法完全避免的。事实上,您可能已经遇到过类似的问题,即模态对话框的惰性行为。因此,本文的主要目的是提醒您,如果同时显示背景弹出框和模态对话框,可能会出现问题,因此不要完全放弃之前自定义的弹出框架构。

如果这个问题目前或将来会影响到你的工作,请关注这个HTML问题,其中正在讨论解决方案。

关于斯蒂芬妮·埃克尔斯

Stephanie Eckles 是 Adobe Spectrum CSS 的高级设计工程师,也是 CSSWG 的成员,同时还是 ModernCSS.dev 的作者。Steph 拥有超过 15 年的 Web 开发经验,她乐于以作家、研讨会讲师和会议演讲者的身份分享这些经验。她致力于倡导无障碍设计、可扩展 CSS 和 Web 标准。业余时间,她是两个女儿的妈妈,喜欢烘焙和水彩画。

博客:ModernCSS.dev Mastodon:@5t3ph

译者注:

  1. popover:弹出框指的是轻提示的弹出式框,没有过多的交互逻辑
  2. dialog:对话框指的是带有交互逻辑的弹出框,例如存在确认和取消按钮,输入框等

这两个都是新特性,具体内容可参考MDN

❌
❌