普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月16日技术

二十年,重新出发!第 20 届 D2 技术大会「AI 新」议题全球征集正式开启

2026年1月16日 12:28

D2-Banner.jpg


二十年,是一个坐标。 从 Web 2.0 的萌芽,到移动互联网的爆发,再到云原生时代的重塑,D2 技术大会伴随开发者走过了整整二十载风雨。

今天,我们站在了一个更加宏大的分水岭。 AI 不再是遥远的科幻逻辑,它正以一种近乎“重构”的姿态,系统性地改写终端技术的底层范式:从代码生成的协作,到架构设计的逻辑,再到交互体验的边界。

第 20 届 D2 技术大会,年度主题定为——「AI 新」。

它既是我们的时代判断,也是我们的集体宣言。 它是 AI 驱动的创新,也是终端人对技术边界追逐的热爱之新。

此刻,我们正式向全球开发者、架构师、技术领袖及创新实践者发出邀请: 来 D2,分享你对 AI 时代终端技术的独到见解,共同定义下一个二十年的生产力!


七大核心专场,期待你的真知灼见

我们渴望真实工程中的突破,珍视深度思考后的落地,让技术回归解决问题的初衷。

01

AI Coding:从写代码开始,重构工程本身

这是本届 D2 的主干专场。AI 正在从“辅助助手”升级为“协作伙伴”。

征集方向:

  • AI Agent 编程工具的研发与设计 侧重 Agent 型 AI 编程工具在本地与远程形态下的架构与产品设计。征集议题包括 IDE 深度集成、上下文采集与记忆管理、代码库索引检索、任务规划与工具调用、执行沙箱与权限控制、审计与回放、可观测性、成本/延迟优化与多模型策略等。重点关注可靠性与可控性:减少误改、支持规范化交付与团队协作。
  • AI-Native 开发实践 聚焦真实项目中 AI 编程的可复用方法。征集包含 Spec 驱动开发(结构化需求/验收标准/契约/测试)、AI 编程 Workflow 探索(从需求到 PR/发布的流水线)、以及团队级 AI 驱动研发实践(流程改造、提示/模板沉淀、质量门禁、效率与质量度量、失败复盘)。重点是“怎么做得稳、做得快”。
  • AI Coding 前沿研究与技术趋势 关注下一代 AI Coding 的关键技术与趋势。征集议题包括长上下文与复杂依赖、代码语义理解与程序分析结合、自动化评测与基准、对齐与安全、多智能体协作、可靠性与可解释性增强等。重点探讨研究如何走向工程落地与可验证的效果提升。

02

AI 创新体验:当交互正在被重写

终端是 AI 被感知的最前线。交互范式的巨变已经发生。

征集方向:

  • UI 范式重塑 探讨从 GUI 向 LUI 或 AUI 的代际演进。聚焦 Agent 驱动下的意图识别、动态 UI 生成及个性化界面即时构建。征集议题包括主动交互设计、多 Agent 协作下的用户反馈回路、以及如何利用 AI 简化复杂业务流的操作门槛。
  • 空间智能体验 聚焦多模态感知与空间计算的深度融合。涵盖视觉、语音、触觉在 3D/XR 环境下的集成交互,以及 AI 驱动的实时场景理解与数据可视化。重点探讨如何利用空间智能让数字世界更符合自然认知,实现高沉浸感的智能反馈。。
  • 具身交互探索 关注 AI 进入物理世界后的交互挑战,从 AI Wearables、AI PC 到机器人具身智能。探讨硬件约束下的自然语言处理、人机交互(HRI)实践及环境感知反馈。重点关注如何通过端侧智能赋予硬件产品生命力,解决真实场景下的交互痛点,探索用户真正愿意买单的终端新价值点。

03

AI 语言 & 框架:模型时代,语言与框架如何进化

当 AI 成为“默认能力”,底层技术如何适配?

征集方向: 

  • 语言与编译器演进 探讨编程语言如何适配“人机共写”新常态。征集议题涵盖 LLM 友好型语法设计、智能化类型系统、AI 辅助的编译优化与静态分析等。重点研究如何通过语言特性的进化,提升 AI 生成代码的质量、安全性与复杂逻辑表达力。
  • Agent 框架重构 当 Agent 成为系统编排者,探讨传统框架的抽象层重塑。征集议题涵盖声明式意图驱动的框架设计、元数据驱动的界面自动生成、以及为 AI 重新设计的组件模型。重点关注框架如何提供更高级别的抽象,以支持多 Agent 在复杂业务逻辑中的无缝协作、状态同步与逻辑自治。
  • 智能运行时与内核 推动 AI 从工具层下沉为系统的核心能力。聚焦内置 AI 推理能力的运行时引擎、模型与容器/内核的深度集成,以及 AI 驱动的动态资源调度策略。重点探讨端云协同背景下,如何模糊开发与运行、模型与逻辑的边界,实现具备自适应、自进化能力的智能运行基座。

04

AI 智能测试:质量与效率,不再只能二选一

测试不再是滞后的环节,而是 AI 介入最深、收益最显性的战场。

征集方向:

  • 用例生成与自愈 探讨利用 LLM 实现测试全生命周期的自动化。征集议题包括基于语义理解的单元/集成测试生成、复杂业务场景下的测试数据合成,以及 UI 自动化脚本的自愈(Self-healing)机制。
  • 风险洞察与优化 聚焦利用 AI 提升质量保障的精准度与效率。征集议题涵盖基于变更分析的智能回归测试缩减、线上异常的实时检测与根因定位,以及多维度的质量风险预测模型。探讨如何利用算法在海量代码变更中快速锁定高风险区域,解决快速迭代与质量稳定性之间的核心矛盾。
  • 治理与角色演进 关注 AI 引入后测试流程与组织效能的系统性重构。核心议题包括 AI 测试工具的 ROI 分析、人机协同模式下的 QA 职责重定义,以及在规模化工程中构建“默认内置 AI”的质量防线。探讨如何通过技术赋能,打破质量与效率的零和博弈,重塑技术团队的质量文化与评价体系。

05

AI 智能生产:从工具走向生产系统

关注 AI 在真实业务落地时的“最后一公里”。

征集方向:

  • 业务深度嵌入 探讨 AI 如何从外部辅助工具进化为业务逻辑的核心。寻找在复杂业务场景中的落地架构案例,关注如何处理模型输出的不确定性以交付“确定性”结果。重点探讨 AI 对传统业务流程的深度重构,在提升用户价值的同时,确保生产系统的稳定性、安全性与商业收益。
  • 规模化生产交付 聚焦 AI 从原型验证(PoC)走向规模化交付的工程拐点。征集议题涵盖支持大规模 AI 应用的工程底座、端到端 AI 生产平台的演进、以及 FinOps 成本分析与合规治理。探讨如何构建标准化的平台能力,支撑 AI 跨团队、跨业务的高效迁移与持续稳定运行,实现技术普惠。
  • 全链路协同提效 关注覆盖需求、设计、交付及运维的 AI 全链路闭环。核心议题包括新一代人机协作下的流程重塑、领域专用 Agent 的生产环境编排,以及科学的效能度量方法。探讨如何通过技术与组织的双重演进,实现软件生产体系的跨越式提效,将 AI 潜能真正转化为规模化的实际业务产能。

06

终端技术:重构 AI 时代的性能底座

底层基础设施如何承载高算力与高响应需求?

征集方向: 

  • 架构适配与演进 探讨终端架构如何重构以深度兼容 AI 能力,重点研究如何调整传统的软件拓扑结构,以支持 AI 在终端侧的无缝集成、高效编排与复杂的应用状态管理,提升端侧智能的响应实时性。
  • 运行时与性能优化 聚焦通过底层技术突破 AI 运行的性能瓶颈。征集议题涵盖面向 AI 指令集优化的编译器技术、异构算力的极致加速实践,以及轻量化端侧容器演进。探讨如何通过运行时与系统内核的深度协同,在有限的硬件资源限制下,实现极致的推理速度与能效比。
  • 端侧工程与协同 核心议题包括模型量化、蒸馏与剪枝的终端实战、端云协同推理架构,以及隐私安全约束下的端侧学习。探讨如何构建高效的端云配比方案,在保障响应速度与数据隐私的同时,实现计算成本与用户体验的帕累托最优。

07

一人公司:技术人的个体放大器

这是最具时代情绪的专场。AI 正在让“超级个体”成为可能。

征集方向: 

  • 全栈生产力飞跃 探讨 AI 如何打破专业壁垒,实现“一个人就是一支团队”。分享利用 AI 协同完成从需求定义、全栈开发、交互设计到市场增长的全链路实践。
  • 商业闭环与实战 聚焦超级个体的商业化落地与可持续经营之道。征集独立开发者的 AI 实战案例,涵盖极致成本控制下的产品生存策略、AI 辅助的商业决策与自动化运营。探讨在 AI 时代,个体如何构建轻量化、高利润的商业模式,并成功应对从单兵作战到规模化营收的真实挑战。
  • 职业路径重构 探讨从“专项开发者”向“产品主理人”转型的思维重构、AI 时代的个人品牌经营,以及个体长期竞争力的构建。研究在组织边界日益模糊的未来,技术人如何利用 AI 工具集寻找更具自主性的创作路径,定义下一代极简且高效的职业范式。

顶尖出品人矩阵:为议题深度护航

1.png

我们寻找的不仅是一个演讲者

更是一个在 AI 工程深水区挣扎过、思考过、最终破局的见证者。

  • 隐风 | 淘天集团-用户&内容终端技术负责人
  • 云谦 | 蚂蚁集团-高级前端技术专家
  • 悟石 | 淘宝闪购-消费者端技术负责人
  • 渚薰 | 前淘宝互动游戏专家
  • 偏右 | 蚂蚁集团-支付宝体验技术前端平台负责人
  • 张磊 | 字节跳动 Web Infra 技术负责人
  • 泠乐 | 淘天集团-淘宝终端质量负责人
  • 茹炳晟 | CCF TF 研发效能 SIG 主席 / 复旦大学 CodeWisdom 成员
  • 达峰 | 蚂蚁集团-平台体验技术部负责人
  • 穆宸 | AliExpress-终端技术负责人 / D2 负责人
  • 永霸 | 淘天集团-交易终端技术负责人
  • 崔红保 | DCloud CTO / uni-app 跨平台框架负责人
  • 秦粤 | 阿里云-数据库高级前端专家
  • 梓骞 | 启智云图 CEO / Lovrabet 产品创始人

出品人寄语:

在 D2,我们致力于将前沿的 AI 实践提炼为系统化的技术范式。我们期待与你一同锚定 AI 时代的工程坐标,让每一份实战洞察都汇聚成定义未来的行业基准。”

🌟 为什么来到 D2 舞台

  1. 顶尖技术影响力: D2 是国内终端技术的风向标,线下规模 2000+,线上覆盖数十万专业开发者。
  2. 二十周年里程碑: 参与第 20 届这一极具纪念意义的盛会,与业内最具创新精神的技术人同频共振。
  3. 常态化社区联动: 优质内容将同步至稀土掘金、InfoQ、AI 产品榜等联合承办方平台,获得持续的行业曝光与认可。

🗓️ 议题提交指南

  • 截止时间: 2026年1月23日(请关注官网最新动态)
  • 议题要求: 内容具有前瞻性、实战性或深度思考;拒绝纯广告,强调技术细节与真实的踩坑经验
  • 提交地址: survey.alibaba.com/uone/sg/sur…

欢迎扫码提交

二十年是一个里程碑,更是重新出发的起点。 在「AI 新」的浪潮中,让我们一起,用 AI 驱动创新,用终端之心热爱创新。


D2 组委会,期待你的议题,欢迎关注 D2 官方账号!

1-2.jpg

vue 可视化表单设计器 vxe-form-design 创建自定义控件的详细用法(教程一)

作者 卤蛋fg6
2026年1月16日 11:38

vue 可视化表单设计器 vxe-form-design 创建自定义控件的详细用法,vxe-design 是 vxe 下的一个开源的可视化设计器,在使用表单设计器时,通常需要将业务的的每一个控件进行封装,以适应业务的需求,接下来介绍一下如果来定义一个自定义的控件。

design.vxeui.com

定义控件分组

支持任意分组和自定义左侧控件分组名称

image

<template>
  <div>
    <vxe-form-design :widgets="formDesignWidgets" :height="800"></vxe-form-design>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const formDesignWidgets = ref([
  {
    customGroup: '输入控件',
    children: [
      'VxeInput'
    ]
  },
  {
    customGroup: '下拉控件',
    children: [
      'VxeSelect'
    ]
  },
  {
    customGroup: '开关控件',
    children: [
      'VxeSwitch'
    ]
  },
  {
    customGroup: '其他控件',
    children: [
      'VxeRadioGroup'
    ]
  }
])
</script>

定义控件

创建了一个自定义的输入框控件,说明,一个控件包含几个步骤:定义控件数据,定义控件表单项模板,定义控件右侧参数配置模板 具体自行命名,该控件示例目录: image

步骤1,定义控件数据

目录:src/form-design/inputWidget/demoFormDesignInputWidget.js
这里是定义该控件的字段参数

export const getFormDesignWidgetInputConfig = () => {
  return {
    // 控件名称
    title: '单行输入',
    // 控件图标
    icon: 'vxe-icon-input',
    // 控件参数,用于在右侧配置
    options: {
      placeholder: '请输入',
      maxLength: '',
      showWordCount: false,
      clearable: true,
      align: ''
    }
  }
}

步骤2,定义控件表单项模板

目录:src/form-design/inputWidget/DemoFormDesignInputWidgetView.vue 这里是定义该控件的渲染时的表单模板

<template>
  <vxe-form-item :field="currWidget.field" :title="currWidget.title">
    <vxe-input
      v-model="widgetModel"
      :placeholder="currWidget.options.placeholder"
      :maxLength="currWidget.options.maxLength"
      :show-word-count="currWidget.options.showWordCount"
      :align="currWidget.options.align">
    </vxe-input>
  </vxe-form-item>
</template>

<script setup>
import { VxeUI } from 'vxe-pc-ui'

const { useWidgetView } = VxeUI.formDesignHandle

const props = defineProps({
  renderOpts: {
    type: Object,
    default: () => ({})
  },
  renderParams: {
    type: Object,
    default: () => ({})
  }
})

const { currWidget, widgetModel } = useWidgetView(props)
</script>

步骤3,定义控件右侧参数配置模板

目录:src/form-design/inputWidget/DemoFormDesignInputWidgetProps.vue 这里是定义控件拖拽到视图后,右侧显示的字段配置模板

<template>
  <vxe-form
    vertical
    title-bold
    title-overflow
    span="24"
    :data="currWidget.options">
    <vxe-form-item title="控件字段名" :title-prefix="{icon: 'vxe-icon-question-circle-fill', content: '唯一字段名,默认自动生成'}">
      <vxe-input v-model="currWidget.field" placeholder="唯一,默认自动生成"></vxe-input>
    </vxe-form-item>
    <vxe-form-item title="控件名称">
      <vxe-input v-model="currWidget.title"></vxe-input>
    </vxe-form-item>
    <vxe-form-item title="是否必填">
      <vxe-switch v-model="currWidget.required"></vxe-switch>
    </vxe-form-item>
    <vxe-form-item title="空值占位提示">
      <vxe-input v-model="currWidget.options.placeholder"></vxe-input>
    </vxe-form-item>
    <vxe-form-item title="显示清除按钮">
      <vxe-switch v-model="currWidget.options.clearable"></vxe-switch>
    </vxe-form-item>
    <vxe-form-item title="限制字符长度">
      <vxe-number-input v-model="currWidget.options.maxLength" type="integer" min="0" max="3000"></vxe-number-input>
    </vxe-form-item>
    <vxe-form-item title="是否显示字数统计">
      <vxe-switch v-model="currWidget.options.showWordCount"></vxe-switch>
    </vxe-form-item>
    <vxe-form-item title="对齐方式">
      <vxe-radio-group v-model="currWidget.options.align">
        <vxe-radio label="" content="居左"></vxe-radio>
        <vxe-radio label="center" content="居中"></vxe-radio>
        <vxe-radio label="right" content="居右"></vxe-radio>
      </vxe-radio-group>
    </vxe-form-item>
  </vxe-form>
</template>

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

const props = defineProps({
  renderOpts: {
    type: Object,
    default: () => ({})
  },
  renderParams: {
    type: Object,
    default: () => ({})
  }
})

const currWidget = computed(() => {
  const { renderParams } = props
  return renderParams.widget
})
</script>

注册控件

目录:src/form-design/inputWidget/index.jsx

import { VxeUI } from 'vxe-pc-ui'
import { getFormDesignWidgetInputConfig } from './demoFormDesignInputWidget'
import DemoFormDesignInputWidgetView from './DemoFormDesignInputWidgetView.vue'
import DemoFormDesignInputWidgetProps from './DemoFormDesignInputWidgetProps.vue'

// 创建表单设计器控件 - 单行输入
VxeUI.renderer.add('MyFormDesignInputWidget', {
  // 定义左侧控件
  createFormDesignWidgetConfig: getFormDesignWidgetInputConfig,
  // 渲染控件的表单视图
  renderFormDesignWidgetView (renderOpts, renderParams) {
    return <DemoFormDesignInputWidgetView renderOpts={renderOpts} renderParams={renderParams}/>
  },
  // 渲染控件右侧的属性配置视图
  renderFormDesignWidgetFormView (renderOpts, renderParams) {
    return <DemoFormDesignInputWidgetProps renderOpts={renderOpts} renderParams={renderParams}/>
  }
})

在 main.js 引入

// ...
import './form-design/inputWidget'
// ...

使用效果

以上就创建了一个控件,对于业务开发就可以继续创建控件了,最终就可以实现一个低代码或零代码平台,可视化拖拽就可以生成业务系统

image

gitee.com/x-extends/v…

nestjs学习2:利用typescript改写express服务

2026年1月16日 11:37

在学习nestjs之前,我们改写下express服务,这样更有利于理解nestjs

express服务改写后,主要有这么几个文件:

  1. 入口文件index.ts:负责起一个http服务,用来监听用户发起的http请求;
  2. 路由文件router.ts:对不同的请求进行处理,但是,具体的处理放在controller文件中;
  3. controller.ts专门进行路由处理;

入口文件index.ts

import express, { Request, Response, NextFunction } from 'express'
import cookieSession from 'cookie-session'
# 必须引入,让装饰器执行
import './controller/LoginController'
import { router } from './router'

const app = express()

# 处理请求体的application/json数据
app.use(express.json())
# 处理form表单数据
app.use(express.urlencoded({ extended: false }))

# 处理cookie-session
app.use(
  cookieSession({
    name: 'session',
    // 用来生成sessionid的秘钥
    keys: ['pk2#42'],
    maxAge: 48 * 60 * 60 * 1000
  })
)

app.use(router)

app.listen('7001', () => {
  console.log('listen at 7001')
})

注意:在入口文件中必须要引入controller文件。

路由文件router.ts

import { Router } from 'express'

export const router = Router()

这里的路由文件并没有处理任何逻辑,实例化之后直接导出,这与之前的样子区别很大。

原来是长这样的,它在路由文件中耦合了处理逻辑部分:

router.post(
  '/login',
  (req: RequestWithBody, res: Response, next: NextFunction) => {
    const { password } = req.body
    const isLogin = req.session?.isLogin
    if (isLogin) {
      res.end('already login')
    } else {
      if (password === '123' && req.session) {
        req.session.isLogin = true
        req.session.userId = '1234567890'
        res.json(getResponseResult(true))
      } else {
        res.end('login error!')
      }
    }
  }
)

每个接口都要写一个router.post这样的代码,是不是感觉挺啰嗦的。

controller文件

import 'reflect-metadata'
import { Request, Response } from 'express'
import { controller, get, post } from '../decorator'

@controller('/')
export class LoginController {
  constructor() {}

  @post('/login')
  login(req: Request, res: Response): void {
    ...
  }

  @get('/logout')
  logout(req: Request, res: Response): void {
    ...
  }
}

现在提供了一个LoginController类来处理登录相关的所有逻辑。包括一个登录接口/login和一个登出接口/logout

但是,代码里面并没有和router绑定的逻辑,传统的express的代码,通常是通过router.getrouter.post来处理路由和对应的逻辑,如下代码:

import { Router, Request, Response, NextFunction } from 'express'
router.post('/login', (req: Request, res: Response, next: NextFunction) => {
    ...
})
router.get('/logout', checkLogin, (req, res, next) => {
  ...
})

那它是到底怎么实现路由逻辑的呢?

答案是通过装饰器和元数据来实现的。

方法的装饰器:绑定请求方法和请求路径

@controller('/')
export class LoginController {
  @post('/login')
  login(req: Request, res: Response): void {}

  @get('/logout')
  logout(req: Request, res: Response): void {}
}

它包含三个装饰器,分别是get,post,controller,我们首先看看get、post的逻辑。

enum Methods {
  get = 'get',
  post = 'post'
}
function getRequestDecorator(type: Methods) {
  return function (path: string) {
    # target就是类的原型对象
    return function (target: LoginController, key: string) {
      Reflect.defineMetadata('path', path, target, key)
      Reflect.defineMetadata('method', type, target, key)
    }
  }
}
export const get = getRequestDecorator(Methods.get)
export const post = getRequestDecorator(Methods.post)

这段代码很简单,就是定义了两个getpost两个装饰器,在装饰器里面通过元数据Reflect.defineMetadata上添加了pathmethod两个元数据,例如,login方法上的元数据为:

{ path: '/login', method: 'post' }

类的装饰器:获取绑定的元数据

装饰器controller用来修饰类LoginController,这里需要知道,方法的装饰器是先于类的装饰器之前执行,所以,能在类的装饰器上获取到在方法的装饰器上定义的元数据。

export function controller(root: string) {
  // target就是类的构造函数,通过target.prototype获取类的原型
  return function (target: new (...args: any[]) => any) {
    for (let key in target.prototype) {
      // 获取路由
      const path: string = Reflect.getMetadata('path', target.prototype, key)
      // 获取请求方法
      const method: Methods=Reflect.getMetadata('method',target.prototype,key)
      // 获取对应的处理函数
      const handle = target.prototype[key]
      // 获取中间件
      const middleware: RequestHandler = Reflect.getMetadata(
        'middleware',target.prototype,key)
      // 拼接路由
      if (path && method) {
        let fullpath = ''
        if (root === '/') {
          if (path === '/') {
            fullpath = '/'
          } else {
            fullpath = path
          }
        } else {
          fullpath = `${root}${path}`
        }
        // 绑定router
        if (middleware) {
          router[method](fullpath, middleware, handle)
        } else {
          router[method](fullpath, handle)
        }
      }
    }
  }
}

可以看到,最终的落脚点在这里:

import { router } from '../router'
if (middleware) {
  router[method](fullpath, middleware, handle)
} else {
  router[method](fullpath, handle)
}

所以,我需要在入口文件中引入controller文件,这样就能执行装饰器了。

这样改写之后,你如果新增一个模块,比如用户模块,你只需要创建一个UserController的类即可。

你是不是发现和nestjs有点像了,只不过它实现了控制反转和依赖注入。

现代 CSS 颜色使用指南

作者 冴羽
2026年1月16日 11:21

1. 前言

大多数开发者处理颜色时,就是从设计稿里复制粘贴一个色值,然后就完事了。

但是!CSS 颜色在过去几年里发生了很多变化!

不仅从 Web 十六进制代码演变成了 hsl() 函数,而且就连你熟知的 rgb() 函数也跟以前不同了!

不信我们接着往下看。

2. 现代 CSS 写法

2.1. 你不需要添加 a

以前,我们用 rgb() 填写普通的 RGB 颜色值,要想改变不透明度,就必须使用 rgba()

.red {
  color: rgb(255, 0, 0);
}

.red-50 {
  color: rgba(255, 0, 0, 0.5);
}

现在,你可以直接添加第 4 个通道了:

.red {
  color: rgb(255, 0, 0);
}

.red-50 {
  color: rgb(255, 0, 0, 0.5);
}

而且,不用担心浏览器兼容问题,只要你不用支持 IE。

2.2. 空格分隔语法

除此之外,逗号现在也被视为老语法了,对于新的颜色函数,我们甚至不能再使用逗号,只能使用新的空格语法。

.red {
  color: rgb(255 0 0);
}

.blue {
  color: hsl(226 100% 50%);
}

不过,使用空格分隔语法时要注意:你不能为 alpha 通道添加第四个值。

换句话说,这样写 color: rgb(255 0 0 0.5) 是不可以的。

如果你要添加第 4 个值,你需要在字母值之前使用一个正斜杠:

.red {
  color: rgb(255 0 0);
}

/* 50% 透明 */
.red-50 {
  color: rgb(255 0 0 / 0.5);
}

.hsl-red-50 {
  color: hsl(0 100% 50% / 0.5);
}

为什么要这么做呢?

因为 CSS 新增了很多颜色函数,统一用斜杠可以快速区分“颜色值”和“透明度”,看代码时一目了然。

2.3. hsl() 也变了

单位现在可选了:

.red {
  color: hsl(0deg 100% 50%);
}

.also-red {
  color: hsl(0deg 100 50);
}

.another-red {
  color: hsl(0 100% 50%);
}

.this-is-red-too {
  color: hsl(0 100 50);
}

小提示: 最好还是保留百分号,因为 VS Code 只有带百分号的才会显示颜色预览。

3. 相对颜色

相对颜色是什么?

简单说就是基于现有颜色生成新颜色。

3.1. 基础用法

我们从一个简单的例子开始:

.rgb-red {
  color: rgb(from #ff0000 r g b);
}

这行代码的意思是:

基于 #ff0000 这个颜色,提取它的红(r)、绿(g)、蓝(b)值,然后用这些值创建一个新颜色。

结果其实就是 rgb(255 0 0)

看起来很傻对吧?但重点是下面这个:

.rgb-red {
  color: rgb(from #ff0000 r g b);
}

/* 轻松创建 50% 透明度版本 */
.rgb-red-50 {
  color: rgb(from #ff0000 r g b / 0.5);
}

3.2. 最实用的场景:处理 CSS 变量

以前你想让一个 CSS 变量颜色变透明,得这样:

:root {
  --color-primary: #2563eb;
  --color-primary-transparent: rgba(37, 99, 235, 0.75); /* 手动转换,麻烦! */
}

现在呢,你可以直接这样写:

:root {
  --color-primary: #2563eb;
}

.semi-transparent-primary-background {
  /* 直接基于变量创建透明版本 */
  background-color: hsl(from var(--color-primary) h s l / 0.75);
}

就像你有一张照片,以前想调整透明度要重新处理一遍,现在滤镜一点就搞定。

3.3. 快速生成配色方案

我们可以快速创建基础颜色的浅色和深色版本:

:root {
  --base: hsl(217 73% 50%);
  --base-light: hsl(from var(--base) h s 75%); /* 调亮 */
  --base-dark: hsl(from var(--base) h s 25%); /* 调暗 */
}

比如实现一个 Toast,它可能基于一种基础颜色,然后创建一种较深的颜色用于文本,一种较浅的颜色用于背景,还需要一个不透明度较低的颜色用于阴影。

以前要 4 个值,现在直接 1 个值搞定:

.toast {
  --toast-color: #222;

  /* 深色文字 */
  color: hsl(from var(--toast-color) h s 15%);

  /* 原色边框 */
  border: 2px solid var(--toast-color);

  /* 浅色背景 */
  background: hsl(from var(--toast-color) h s 90%);

  /* 半透明阴影 */
  box-shadow: 0 12px 12px -8px hsl(from var(--toast-color) h s l / 0.325);
}

此时换颜色也超简单:

[data-toast="info"] {
  --toast-color: #0362fc; /* 蓝色 */
}

[data-toast="error"] {
  --toast-color: hsl(0 100% 50%); /* 红色 */
}

一个变量搞定所有颜色变体,优雅!十分优雅!

4. 浅暗主题切换

4.1. 以前的痛苦

不知道你是否实现过网站的浅色和深色主题:

:root {
  /* 默认浅色主题 */
  --text-heading: #000;
  --text-body: #212121;
  --surface: #efefef;

  @media (prefers-color-scheme: dark) {
    /* 暗色主题 - 第一遍 */
    --text-heading: #fff;
    --text-body: #efefef;
    --surface: #212121;
  }
}

.dark-theme {
  /* 暗色主题 - 又写一遍! */
  --text-heading: #fff;
  --text-body: #efefef;
  --surface: #212121;
}

同样的颜色写两遍,一个给媒体查询(自动切换),一个给切换按钮。

改一次要改两个地方,烦死了!

4.2. 现在的解决方案:light-dark()

:root {
  /* 跟随系统偏好 */
  color-scheme: light dark;

  /* 一次定义,自动切换 */
  --text-heading: light-dark(#000, #fff);
  --text-body: light-dark(#212121, #efefef);
  --surface: light-dark(#efefef, #212121);
}

light-dark(浅色, 暗色) 就这么简单!系统是浅色就用第一个,暗色就用第二个。

4.3. 添加手动切换按钮

如果让用户手动切换主题:

:root {
  /* 默认跟随系统 */
  color-scheme: light dark;
  --text-heading: light-dark(#000, #fff);
  --text-body: light-dark(#212121, #efefef);
  --surface: light-dark(#efefef, #212121);
}

/* 用户选了浅色,锁定 */
html[data-theme="light"] {
  color-scheme: light;
}

/* 用户选了暗色,锁定 */
html[data-theme="dark"] {
  color-scheme: dark;
}

4.4. 组件精细控制

如果某个区域必须保持固定颜色,比如某个背景图上必须是白字:

.hero {
  /* 不管全局主题怎么变,这里永远是浅色主题 */
  color-scheme: light;
  background: url("light-background.webp");
}

5. 渐变优化

5.1. 以前的痛苦

让我们直接看例子,这是一个从蓝到红的渐变:

.gradient {
  --color-1: hsl(219 76 41); /* 蓝色 */
  --color-2: hsl(357 68 53); /* 红色 */
  background: linear-gradient(90deg, var(--color-1), var(--color-2));
}

中间会出现灰扑扑的颜色,不好看:

以前你只能手动加个中间色:

.better {
  --middle: hsl(271 52 41); /* 紫色 */
  background: linear-gradient(90deg, var(--color-1), var(--middle), /* 加一个中间色 */ var(--color-2));
}

是不是好看多了:

但是麻烦呀!我甚至可能需要添加两到三个额外的色阶,以确保它与原设计完全相同。

5.2. 现在的解决办法:指定颜色空间

现在你可以轻松解决,只需要指定一个颜色空间:

.better {
  /* 用 oklch 颜色空间插值,中间色更鲜艳 */
  background: linear-gradient(in oklch 90deg, var(--color-1), var(--color-2));
}

就像拍照时选滤镜,不同的颜色空间会产生不同的中间色效果。oklch 在大多数情况下效果最好。

看下效果对比:

唯一的真正问题是,不同的颜色空间可能更适合不同的渐变,所以有时确实需要花点时间摸索。

可选的颜色空间有:

  • oklch / lch
  • oklab / lab
  • hwb
  • xyz
  • 不指定默认是 srgb

5.3. 实现彩虹渐变

以前做彩虹要指定每一个颜色。

现在只需要:

.rainbow {
  /* 从红色绕一整圈再回到红色,走长路径 */
  background: linear-gradient(in hsl longer hue 90deg, red, red);
}

longer hue 的意思是“走远路”,这样就能经过所有颜色了。

实现效果如下:

6. 超宽色域——当客户就要那个色

有时候客户会拿着他们的 Logo 说:“我就要这个色,一模一样的!”

问题是,普通的 hex、rgb()hsl() 用的是 sRGB 色域,能表示的颜色有限。

这就像以前电视只能显示几百种颜色,然而现在的手机屏幕能显示几百万种。

6.1. 解决方案:display-p3

为了满足客户的需求,你可以使用 color颜色函数,并使用 display-p3 色域。

.vibrant-green {
  /* 使用 display-p3 色域,颜色更鲜艳 */
  color: color(display-p3 0 1 0);
}

如果浏览器不支持,会自动回退到能显示的最接近颜色,不会出错。

你可以在 Chrome 的开发者工具查看这种颜色:

点击色块,它会显示你选择的颜色,但同时也会显示使用 sRGB 色域的显示器的色域限制。

建议: 除非客户真的非常在意那个特定颜色,一般用不着。但知道有这个方案总是好的。

7. 总结

现在你可以更轻松地写代码了:

  1. 少打字 - 不用区分 rgbargb,用空格代替逗号
  2. 少定义变量 - 用相对颜色基于一个颜色生成多个变体
  3. 少写重复代码 - light-dark() 一次定义两套主题
  4. 更好的渐变 - 指定颜色空间让中间色更漂亮
  5. 更精确的颜色 - 需要时可以用更宽的色域

最重要的是: 这些特性浏览器支持都很好了(除了 IE,但谁还在乎 IE 呢?)

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

第六节:添加响应中间件、redis链接、配置redis store中间件、跨域配置

2026年1月16日 11:19

添加响应中间件、redis链接、配置redis store中间件、跨域配置

一、响应中间件配置

1.1 HTTP 状态码标准化

文件位置: utils/httpStatus.js

/**
 * HTTP状态码常量定义
 * 统一管理所有HTTP状态码,确保系统一致性
 */
const HTTP_STATUS = {
  // 2xx 成功系列
  SUCCESS: 200,               // 请求成功
  CREATED: 201,              // 资源创建成功
  ACCEPTED: 202,             // 请求已接受,处理中
  NO_CONTENT: 204,           // 请求成功,无返回内容
  
  // 3xx 重定向系列
  MOVED_PERMANENTLY: 301,    // 永久重定向
  SEE_OTHER: 303,            // 临时重定向
  NOT_MODIFIED: 304,         // 资源未修改
  
  // 4xx 客户端错误系列
  BAD_REQUEST: 400,          // 请求参数错误
  UNAUTHORIZED: 401,         // 未授权访问
  FORBIDDEN: 403,            // 禁止访问
  NOT_FOUND: 404,            // 资源未找到
  METHOD_NOT_ALLOWED: 405,   // 请求方法不允许
  CONFLICT: 409,             // 资源冲突
  UNSUPPORTED_TYPE: 415,     // 不支持的媒体类型
  
  // 5xx 服务器错误系列
  INTERNAL_SERVER_ERROR: 500, // 服务器内部错误
  NOT_IMPLEMENTED: 501,      // 功能未实现
  BAD_GATEWAY: 502,          // 网关错误
  SERVICE_UNAVAILABLE: 503,  // 服务不可用
  GATEWAY_TIMEOUT: 504,      // 网关超时
  
  // 自定义业务状态码(6xx系列)
  UNKNOWN_ERROR: 520,        // 未知错误
  DATABASE_ERROR: 521,       // 数据库操作错误
  WARNING: 601,              // 业务警告
  ERROR: 602                 // 业务错误
}

module.exports = HTTP_STATUS

1.2 响应包装中间件

文件位置: middlewares/responseWrapper.js

/**
 * 统一响应包装中间件
 * 基于RuoYi框架响应格式标准,提供一致化的API响应结构
 */
const { API_ROOT } = require('../config')
const HTTP_STATUS = require('../utils/httpStatus')

function responseWrapper(req, res, next) {
  // 仅对API根路径下的请求进行包装
  if (!req.originalUrl?.startsWith(API_ROOT)) {
    return next()
  }

  // 保存原始的json方法
  const originalJson = res.json

  /**
   * 重写res.json方法,统一响应格式
   * 支持三种格式:
   * 1. 标准格式:{ code, data, msg }
   * 2. 分页格式:{ code, rows, total, msg }
   * 3. 已包装格式:直接返回
   */
  res.json = function(payload = null) {
    // 情况1:已经是完整响应格式,直接返回
    if (payload && typeof payload === 'object' && 'code' in payload) {
      return originalJson.call(this, payload)
    }
    
    // 情况2:分页响应格式
    if (payload && typeof payload === 'object' && 'rows' in payload && 'total' in payload) {
      const { rows, total, msg = '操作成功', ...rest } = payload
      return originalJson.call(this, {
        code: HTTP_STATUS.SUCCESS,
        rows,
        total,
        msg,
        ...rest
      })
    }

    // 情况3:普通数据响应,自动包装
    return originalJson.call(this, {
      code: HTTP_STATUS.SUCCESS,
      data: payload,
      msg: '操作成功'
    })
  }

  /**
   * 成功响应快捷方法
   * @param {any} data - 响应数据
   * @param {string} msg - 成功消息
   * @returns {Object} 标准响应格式
   */
  res.success = function(data = null, msg = '操作成功') {
    return originalJson.call(this, {
      code: HTTP_STATUS.SUCCESS,
      data,
      msg
    })
  }

  /**
   * 分页响应快捷方法
   * @param {Array} rows - 数据列表
   * @param {number} total - 总记录数
   * @param {Object} rest - 其他扩展字段
   * @param {string} msg - 成功消息
   * @returns {Object} 分页响应格式
   */
  res.page = function(rows, total, rest = {}, msg = '操作成功') {
    return originalJson.call(this, {
      code: HTTP_STATUS.SUCCESS,
      rows,
      total,
      msg,
      ...rest
    })
  }

  /**
   * 错误响应快捷方法
   * @param {string} msg - 错误消息
   * @param {number} code - 错误状态码,默认为业务错误码
   * @returns {Object} 错误响应格式
   */
  res.error = function(msg = '操作失败', code = HTTP_STATUS.ERROR) {
    // 设置HTTP状态码(映射到对应的HTTP状态码)
    const httpStatusCode = code >= 600 ? 200 : code
    this.status(httpStatusCode)
    
    return originalJson.call(this, {
      code,
      msg
    })
  }

  next()
}

module.exports = responseWrapper

1.3 响应格式示例

响应类型 示例格式
成功响应 { "code": 200, "data": {...}, "msg": "操作成功" }
分页响应 { "code": 200, "rows": [...], "total": 100, ... }
错误响应 { "code": 602, "msg": "操作失败" }
HTTP错误 { "code": 404, "msg": "用户不存在" }

1.4 Express 应用集成

文件位置: app.js(简略版)

const createError = require('http-errors')
const express = require('express')
const { API_ROOT } = require('./config')
const responseWrapper = require('./middlewares/responseWrapper')
const routes = require('./routes')

const app = express()

// 基础配置
app.set('view engine', 'ejs')
app.use(express.json())
app.use(express.urlencoded({ extended: false }))
app.use(express.static('public'))

// 响应包装中间件
app.use(responseWrapper)

// 路由注册
app.use(API_ROOT, routes)

// 错误处理
app.use((req, res, next) => {
  next(createError(404))
})

module.exports = app

二、Redis 连接与管理

2.1 配置文件

文件位置: config/index.js

/**
 * 应用配置文件
 */
module.exports = {
  // 数据库配置
  DBHOST: '127.0.0.1',
  DBPORT: 27017,
  DBNAME: 'node-ruoyi',
  
  // 会话安全配置
  SESSION_SECRET: '31df9e38-03d3-4f97-b56c-a166a7420c8c',
  
  // Redis 配置
  REDIS_HOST: '127.0.0.1',
  REDIS_PORT: 6379,
  
  // API 配置
  API_ROOT: '/api',
  
  // 数据库自增配置
  COUNTERS_COLLECTION: 'auto_increment_counters'
}

2.2 Redis 客户端封装

文件位置: redis/index.js

/**
 * Redis 客户端管理类
 * 提供连接池管理、自动重连、健康检查等功能
 */
const { createClient } = require('redis')
const { REDIS_HOST, REDIS_PORT, REDIS_PASSWORD } = require('../config')

class RedisClient {
  constructor() {
    this.client = null
    this.isConnected = false
  }

  /**
   * 建立 Redis 连接
   */
  async connect() {
    if (this.client && this.isConnected) {
      return this.client
    }

    try {
      // 创建 Redis 客户端
      this.client = createClient({
        url: `redis://${REDIS_HOST}:${REDIS_PORT}`,
        password: process.env.REDIS_PASSWORD || undefined,
        socket: {
          reconnectStrategy: retries => {
            if (retries > 10) {
              console.error('Redis 连接失败,已达到最大重试次数')
              return new Error('Redis 连接失败')
            }
            return Math.min(retries * 100, 3000) // 重试间隔
          }
        }
      })

      // 事件监听器
      this.client.on('error', err => {
        console.error('Redis 客户端错误:', err)
        this.isConnected = false
      })

      this.client.on('connect', () => {
        console.log('✅ Redis 连接成功')
        this.isConnected = true
      })

      this.client.on('end', () => {
        console.log('❌ Redis 连接关闭')
        this.isConnected = false
      })

      // 建立连接
      await this.client.connect()
      return this.client
    } catch (error) {
      console.error('Redis 连接失败:', error)
      throw error
    }
  }

  /**
   * 获取 Redis 客户端实例
   */
  async getClient() {
    if (!this.client || !this.isConnected) {
      await this.connect()
    }
    return this.client
  }

  /**
   * 断开连接
   */
  async disconnect() {
    if (this.client) {
      await this.client.quit()
      this.isConnected = false
    }
  }

  /**
   * 健康检查
   */
  async ping() {
    try {
      const client = await this.getClient()
      const result = await client.ping()
      return result === 'PONG'
    } catch (error) {
      console.error('Redis ping 失败:', error)
      return false
    }
  }
}

// 导出单例实例
module.exports = new RedisClient()

三、Session 管理与跨域配置

3.1 依赖安装

# 安装必要的中间件
npm install connect-redis express-session cors --save

3.2 Express 应用完整配置

文件位置: app.js(完整版)

const createError = require('http-errors')
const express = require('express')
const path = require('path')
const cookieParser = require('cookie-parser')
const logger = require('morgan')
const session = require('express-session')
const cors = require('cors')
const { RedisStore } = require('connect-redis')

// 自定义模块
const { SESSION_SECRET, API_ROOT } = require('./config')
const responseWrapper = require('./middlewares/responseWrapper')
const redisClient = require('./redis')
const routes = require('./routes')

// 创建 Express 应用
const app = express()

// ==================== 中间件配置 ====================

// 1. 视图引擎配置
app.set('views', path.join(__dirname, 'views'))
app.set('view engine', 'ejs')

// 2. 开发日志
app.use(logger('dev'))

// 3. 请求体解析
app.use(express.json({ strict: false }))
app.use(express.urlencoded({ extended: true }))

// 4. Cookie 解析
app.use(cookieParser())

// 5. 静态文件服务
app.use(express.static(path.join(__dirname, 'public')))

// 6. 跨域配置
app.use(cors({
  origin: true,                        // 允许所有来源(生产环境应限制)
  credentials: true,                   // 允许携带凭证(Cookie)
  exposedHeaders: ['set-cookie', 'download-filename']
}))

// ==================== Session 配置 ====================

/**
 * 初始化 Redis Session 存储
 */
const initRedisSession = async () => {
  const client = await redisClient.getClient()
  
  const redisStore = new RedisStore({
    client,
    prefix: 'session:',               // Redis 键前缀
    ttl: 60 * 10,                     // 过期时间(秒)
    disableTouch: false              // 允许更新过期时间
  })
  
  return session({
    store: redisStore,
    secret: SESSION_SECRET,
    name: 'sid',                      // Cookie 名称
    resave: false,                    // 避免重复保存
    saveUninitialized: false,         // 不保存空 session
    cookie: {
      maxAge: 24 * 60 * 60 * 1000,    // 24小时
      httpOnly: true,                 // 防止 XSS 攻击
      secure: process.env.NODE_ENV === 'production'
    },
    rolling: true,                    // 每次请求刷新过期时间
    unset: 'destroy'                  // 删除时从存储移除
  })
}

// ==================== 应用初始化 ====================

;(async () => {
  try {
    // 1. 初始化 Redis Session
    const sessionMiddleware = await initRedisSession()
    app.use(sessionMiddleware)

    // 2. 响应包装中间件
    app.use(responseWrapper)

    // 3. 注册路由
    app.use(API_ROOT, routes)

    // 4. API 专用错误处理器
    app.use(API_ROOT, (err, req, res, next) => {
      console.log('API 错误日志:', err)
      res.status(err.status || 500).json({
        code: err.status || 500,
        msg: err.message || '服务器内部错误'
      })
    })

    // 5. 404 处理器
    app.use((req, res, next) => {
      next(createError(404))
    })

    // 6. 通用错误处理器
    app.use((err, req, res, next) => {
      res.locals.message = err.message
      res.locals.error = req.app.get('env') === 'development' ? err : {}
      res.status(err.status || 500)
      res.render('error')
    })

    console.log('✅ 应用配置完成')
  } catch (error) {
    console.error('❌ 应用初始化失败:', error)
    process.exit(1)
  }
})()

module.exports = app

4.2 路由配置示例

// routes/user.js
const express = require('express')
const router = express.Router()
const userController = require('../controllers/userController')
const { verifyToken } = require('../jwt')

// 公开接口
router.get('/public', userController.getPublicData)

// 需要认证的接口
router.get('/profile', verifyToken(), userController.getProfile)
router.put('/profile', verifyToken(), userController.updateProfile)

// 分页查询
router.get('/list', verifyToken(), userController.getUsersPaginated)

module.exports = router

4.3 目录结构参考

node-ruoyi/
├── config/
│   └── index.js              # 配置文件
├── redis/
│   ├── index.js              # Redis 客户端
│   ├── token.js              # Token 管理
│   └── captcha.js            # 验证码存储
├── middlewares/
│   ├── responseWrapper.js    # 响应包装
│   ├── index.js              # 中间件入口
│   └── validator/            # 验证中间件
├── utils/
│   └── httpStatus.js         # HTTP 状态码
├── routes/                   # 路由目录
├── controllers/              # 控制器目录
├── models/                   # 数据模型
├── app.js                    # 应用主文件
└── package.json

📊 配置总结表

组件 配置文件 主要功能 依赖
HTTP 响应 responseWrapper.js 统一响应格式、快捷方法 -
状态码 httpStatus.js 标准化状态码定义 -
Redis redis/index.js 连接池、自动重连、健康检查 redis
Session app.js Redis 存储、安全配置 express-session, connect-redis
跨域 app.js CORS 配置、凭证支持 cors
应用配置 config/index.js 集中配置管理 -

🛡️ 安全建议

  1. 生产环境配置

    • 使用环境变量存储敏感信息
    • 启用 HTTPS
    • 配置合适的 CORS 白名单
  2. Redis 安全

    • 设置密码认证
    • 限制网络访问
    • 定期备份数据
  3. Session 安全

    • 定期更换 SESSION_SECRET
    • 设置合适的过期时间
    • 启用 securehttpOnly

Vue3向全局广播数据变化

作者 Rrvive
2026年1月16日 11:12

✅ 功能点

我们要做到:

  • 🧠 ruleForm 仍然是唯一状态源
  • 🔁 formDataToJson 自动跟随 ruleForm
  • ⚡ 子组件 直接拿“已处理好的 params”
  • 🚫 不在每个组件里重复写转换逻辑

一、升级 useFilterState(关键)

✅ 改造点:加入 params(computed)

useFilterState.ts

import { reactive, provide, watch, computed } from "vue";
import { ElMessage } from "element-plus";

export const FILTER_KEY = Symbol("FILTER_STATE");

export function useFilterState() {
  const ruleForm = reactive({
    time_menu: "day",
    timeRange: [] as any[],
    service_info: [] as any[],
  });

  /** ✅ 派生:接口参数 */
  const params = computed(() => {
    if (ruleForm.time_menu === "custom") {
      if (ruleForm.timeRange.length === 2) {
        const [start_time, end_time] = ruleForm.timeRange;
        return {
          start_time,
          end_time,
          service_info: ruleForm.service_info,
          time_menu: ruleForm.time_menu,
        };
      }
      return null;
    }

    return {
      time_menu: ruleForm.time_menu,
      service_info: ruleForm.service_info,
    };
  });

  /** 可扩展的 change hook */
  const onChangeCbs: Function[] = [];

  watch(
    ruleForm,
    () => {
      onChangeCbs.forEach((cb) => cb(ruleForm));
    },
    { deep: true }
  );

  provide(FILTER_KEY, {
    ruleForm,
    params,
    onFilterChange: (cb: Function) => onChangeCbs.push(cb),
  });

  return {
    ruleForm,
    params,
  };
}

useFilterInject.ts

import { inject } from "vue";
import { FILTER_KEY } from "./useFilterState";

export function useFilterInject() {
  const ctx = inject<any>(FILTER_KEY);
  if (!ctx) {
    throw new Error("useFilterInject 必须在 useFilterState 下使用");
  }
  return ctx;
}


二、页面中用法(更干净了)

<script setup lang="ts">
import { useFilterState } from "@/composables/useFilterState";

const { ruleForm, params } = useFilterState();
</script>

三、子组件中如何用(重点)

任意子组件

<script setup lang="ts">
import { watchEffect } from "vue";
import { useFilterInject } from "@/composables/useFilterInject";
import { getRiskInfo } from "@/api/visualizedOperations";

const { params } = useFilterInject();

async function fetchData() {
  if (!params.value) return; // custom 未选时间
  await getRiskInfo(params.value);
}

watchEffect(fetchData);
</script>

🎯 效果:

  • ruleForm 任意字段变化
  • params 自动重新计算
  • watchEffect 自动触发
  • 所有组件 同步刷新

四、如果你还想保留 ElMessage 提示

不要在 computed 里直接弹消息(副作用)
更优雅的方式是:提交或请求前校验

推荐写法

function validateParams(params: any) {
  if (!params) {
    ElMessage.warning("请选择时间范围");
    return false;
  }
  return true;
}

使用时:

if (!validateParams(params.value)) return;

五、为什么这是「最佳实践」

原来 现在
formDataToJson scattered 单一来源
子组件 自己拼参数 直接用
维护成本
复用
状态一致性 易乱

六、结构总结(你现在的架构)

页面
 ├─ useFilterState()
 │   ├─ ruleForm   ✅ 原始状态
 │   ├─ params     ✅ 派生参数
 │
 ├─ AlarmView
 │   └─ watchEffect(params) → 请求
 │
 └─ DataView
     └─ watchEffect(params) → 请求

七、一句话记住这个模式

状态在 ruleForm,逻辑在 computed,请求在组件

这套模式特别适合:

  • 可视化运营页面
  • 多组件联动筛选
  • 不想上 Pinia / URL

为啥 Array.isArray 判断数组最靠谱?

2026年1月16日 11:12

JavaScript 中,如何判断一个值是否为数组是很常见的需求,一般有以下几种方法。

1、方式一:JSON.stringify

可以通过 JSON.stringify 序列化字符串,然后判断字符串是否以 [" 开头,以 "] 结尾来判断是否为数组。这其实和使用 JSON.stringify(obj) === '{}' 判断一个对象是否是空对象的思路类似。

const arr = [];
console.log(JSON.stringify(arr).startsWith('[') && JSON.stringify(arr).endsWith(']')); // true

优点

  • 兼容性好。

缺点

  • 需要序列化性能较差。
  • 无法处理循环引用的数据,会报错 TypeError: Converting circular structure to JSON
  • 判断代码也较为复杂。
const arr = [];
arr[0] = arr; // 添加循环引用
console.log(JSON.stringify(arr).startsWith('[') && JSON.stringify(arr).endsWith(']')); // 报错 TypeError: Converting circular structure to JSON

2、方式二:instanceof

instanceof 可以判断某个实例是否属于某种类型,它内部会通过原型链往上一级一级查找,直到找到和当前对象的原型相等的原型。

console.log([] instanceof Array);      // true
console.log({} instanceof Array);      // false

但是原型链是可以被修改的,所以 instanceof 判断结果可能不准确。比如我们可以利用 Object.setPrototypeOf(target, newProto) 来修改原型。

const arr = {};
Object.setPrototypeOf(arr, Array.prototype);
console.log(arr instanceof Array); // true

还有一种情况,就是跨 iframe 进行判断时,instanceof 判断结果可能不准确。

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

<body>
  <script>
    const iframe = document.createElement('iframe');
    document.body.appendChild(iframe);
    const iframeArray = iframe.contentWindow.Array;
    const iframeArr = new iframeArray();
    console.log(iframeArr instanceof Array);  // false
  </script>
</body>

</html>

这里其实 iframeArr 是一个数组,但是 instanceof 判断结果却是 false

优点

  • 简单直观。

缺点

  • 通过 Object.setPrototypeOf 修改原型后判断不准确。
  • iframe 失效。

3、方式三:constructor 属性

每个实例对象都有一个 constructor 属性,指向它的构造函数,所以通过 constructor 属性可以判断一个实例是否属于某个类型。

console.log([].constructor === Array);  // true
console.log({}.constructor === Array);  // false

然而和原型一样,constructor 属性也是可以被修改的。

const arr = [];
arr.constructor = Object; // 修改 constructor
console.log(arr.constructor === Array); // false(被修改了导致判断错误)

优点

  • 简单直观。

缺点

  • constructor 可被修改,判断不准确。

4、方式四:Object.prototype.toString.call

Object.prototype.toString.call()JavaScript 中一个非常强大的类型检测方法,它可以获取任意值的内部 [[Class]] 属性,并以字符串形式返回其类型。

const arr = [];
console.log(Object.prototype.toString.call(arr) === '[object Array]'); // true

它是一个检测数据类型的标准方法,它不仅可以检查数组 Array、对象 Object,基本数据类型和其它引用类型像日期 Date正则 regexp 等都可以判断出来,实际开发中通常会把它封装成一个通用的类型检测方法。

// 判断特定类型
function isType(value, type) {
    return Object.prototype.toString.call(value) === `[object ${type}]`;
}

console.log(isType([], 'Array'));     // true
console.log(isType({}, 'Object'));    // true
console.log(isType(new Date(), 'Date')); // true
console.log(isType(/abc/, 'RegExp')); // true

但是在 es6Symbol出现之后,可以通过 Symbol.toStringTag 属性来控制其返回值,也会导致其判断不准确。

const arr = {
  [Symbol.toStringTag]: 'Array'
}
console.log(Object.prototype.toString.call(arr) === '[object Array]'); // true

Symbol.toStringTag 也可以支持设置为任意值。

const obj = {
  [Symbol.toStringTag]: '111' // 可以设置为任意值
}
console.log(Object.prototype.toString.call(obj)); // [object 111]

优点

  • 简单直观。
  • 不仅可以判断数组,基本数据类型和其它引用数据类型 DateRegexp 也可以。

缺点

  • 代码较长,并且可通过 Symbol.toStringTag 修改判断结果。

5、方式五:Array.isArray(最推荐)

Array.isArray()Array 的一个静态方法,可以判断一个值是否为数组。

const arr = [];
console.log(Array.isArray(arr));  // true

优点

  • 最可靠。
  • 简单直观。

缺点

  • 兼容性问题,只支持 IE9+

为什么 Array.isArray 是最靠谱的呢?

1. 通过判断内部存储的数据结构是否符合数组的数据结构定义

数组是一种数据结构:

  • 它是一种线性表数据结构,也就是它里面的数据是排成一条线一样的,它的数据只有前和后两个方向。除了数组,队列、栈、链接也是线性表数据结构。
  • 连续的内存空间。就是说数据中的元素在内存中是连续存储的,正是因为这个特性,所以数据可以实现随机访问,也就是访问数据中的每个元素花的时间就是一样的,时间复杂度都是 O(1),查询数据特别快。

2. 它是一个原生方法,内部 C++ 编码实现,我们从 js 代码层面无法判断数据内部的存储结构,也就无法模拟实现。

小结

判断是否是数组的方法 优点 缺点 推荐度
Array.isArray() 标准、跨环境、最可靠 兼容性问题,只支持IE9+ ⭐⭐⭐⭐⭐
Object.prototype.toString.call() 最兼容、可靠 代码较长,并且可通过 Symbol.toStringTag 修改判断结果 ⭐⭐⭐⭐
instanceof 简单直观 通过 Object.setPrototypeOf 修改原型后判断不准确,跨 iframe 失效 ⭐⭐⭐
constructor 简单 constructor 可被修改 ⭐⭐
JSON.stringify 兼容性好 无法处理循环引用、性能差

在平时实际前端项目开发中,如果需要判断是否是数组,直接用 Array.isArray() 就好了,它内部通过 C++ 编码实现,判断其存储结构是否为数组,是最可靠的判断方法,如果需要封装一个通用的数据类型判断方法,可以采用 Object.prototype.toString.call()

数据连接开发设计文档

作者 爱吃香菜i
2026年1月16日 11:08

数据连接开发设计文档

1. 文档概述

1.1 文档目的

本文档旨在规范前端数据连接模块的开发与集成,确保开发团队能够依据此文档进行统一、高效、安全的数据连接开发。文档包含数据连接架构设计、API接口规范等核心内容。

1.2 功能背景

根据数据接口文档的需求,需要对数据接口相关功能进行前端开发,包括审批流程、动态流程节点、RPA机器人配置和动态表单等模块的数据接口调用和展示。同时,系统需要支持数据连接的管理,包括数据连接的新增、编辑、删除和测试连接等功能。

2. 数据连接架构设计

2.1 系统架构图

graph TD
    A[前端应用] --> B[数据连接管理模块]
    B --> C[DataConnectionModule主容器]
    C --> D[数据连接列表组件]
    C --> E[数据连接表单组件]
    C --> F[数据连接测试组件]
    C --> G[API请求层]
    G --> H[数据连接API]
    H --> I[后端服务]
    I
    I --> J[数据库连接池]
    J --> K[MySQL数据库]

2.2 核心组件结构

DataConnectionModule
├── DataConnectionModule.vue        # 数据连接功能主容器组件
├── components/
│   ├── DataConnectionList.vue      # 数据连接列表组件
│   ├── DataConnectionForm.vue      # 数据连接表单组件
│   ├── DataConnectionTest.vue      # 数据连接测试组件
├── hooks/
│   ├── useDataConnection.ts        # 数据连接逻辑Hook
│   └── useApiRequest.ts            # API请求Hook
├── types/
│   ├── dataConnection.ts           # 数据连接类型定义
│   └── dataInterface.ts            # 数据接口类型定义
└── index.ts                        # 模块入口

2.3 数据流向图

graph TD
    A[用户操作] --> B[DataConnectionModule]
    B --> C{操作类型?}
    C -->|新增| D[数据连接表单组件]
    C -->|编辑| E[数据连接表单组件]
    C -->|删除| F[调用删除API]
    C -->|测试连接| G[数据连接测试组件]
    D --> H[验证表单数据]
    E --> H
    H --> I{验证通过?}
    I -->|是| J[调用保存API]
    I -->|否| K[显示错误信息]
    G --> L[调用测试连接API]
    J --> M[后端处理]
    L --> M
    F --> M
    M --> N[返回响应结果]
    N --> O[更新前端状态]
    O --> P[显示操作结果]

3. API接口规范说明

3.1 数据连接API

接口名称 URL 请求方法 请求参数 返回结果 描述
获取数据连接列表 /api/data-connection/list GET name: string { success: boolean, data: DataConnection[], message: string } 获取完整数据连接列表,全量查询
获取数据连接详情 /api/data-connection/detail GET id: string { success: boolean, data: DataConnection, message: string } 获取数据连接详情
新增数据连接 /api/data-connection/add POST DataConnection { success: boolean, data: { id: string }, message: string } 新增数据连接
修改数据连接 /api/data-connection/update PUT id: string, DataConnection { success: boolean, message: string } 修改数据连接
删除数据连接 /api/data-connection/delete DELETE id: string { success: boolean, message: string } 删除数据连接
测试数据连接 /api/data-connection/test POST DataConnection { success: boolean, message: string } 测试数据连接
获取数据库类型列表 /api/data-connection/types GET - { success: boolean, data: string[], message: string } 获取支持的数据库类型列表,当前仅返回["MySQL"]
获取驱动列表 /api/data-connection/drivers GET dbType: string { success: boolean, data: string[], message: string } 获取指定数据库类型的驱动列表,当前仅支持MySQL

4. DataConnectionModule.vue组件设计

4.1 组件功能

DataConnectionModule.vue作为数据连接功能的主容器组件,负责整合所有相关子组件,实现统一的视图管理和交互逻辑。它包含以下核心功能:

  1. 数据连接列表展示
  2. 新增数据连接
  3. 编辑数据连接
  4. 删除数据连接
  5. 测试数据连接
  6. 数据连接详情查看

4.4 DataConnectionForm.vue组件设计

4.4.1 组件功能

DataConnectionForm.vue是数据连接的核心表单组件,负责数据连接的新增和编辑功能。它提供以下核心功能:

  1. 数据连接表单的渲染
  2. 表单数据的验证(在保存和测试连接前触发)
  3. 表单数据的保存
  4. 与父组件的数据同步
4.4.3 组件交互逻辑
  1. 表单验证触发条件

    • 表单验证仅在特定条件下触发
    • 仅在父组件调用validateFormvalidateAndSave方法时触发完整验证流程
    • 验证触发场景:
      • 点击头部区域的"保存"按钮时
      • 点击头部区域的"测试连接"按钮时
    • 输入过程中不进行实时验证,提升用户体验
  2. 表单数据同步

    • 表单数据与父组件通过props双向绑定
    • 父组件可以实时获取表单数据
    • 外部model变化时,表单数据自动更新
  3. 方法暴露

    • validateForm:触发表单完整验证,返回布尔值表示验证结果
    • validateAndSave:触发表单验证并保存数据,返回布尔值表示操作结果
    • 这两个方法仅由父组件在点击对应按钮时调用
  4. 表单布局

    • 纯表单布局,不包含任何操作按钮
    • 所有操作按钮统一放置在父组件的头部区域
    • 表单样式与现有界面风格保持一致
    • 支持滚动,确保在不同屏幕尺寸下都能完整显示所有字段

4.3 组件交互逻辑

4.3.1 表单验证触发条件
触发场景 触发方式 验证范围 验证结果处理
保存数据连接 点击头部区域的"保存"按钮 完整表单验证 验证通过则保存数据,否则显示错误提示
测试数据连接 点击头部区域的"测试连接"按钮 完整表单验证 验证通过则打开测试对话框,否则显示错误提示
其他交互场景 用户输入、切换字段等 无(仅在表单提交时验证) 不进行验证,提升用户体验
4.3.2 按钮布局调整后的界面结构
  1. 界面整体结构

    • 顶部:系统统一管理系统标题栏
    • 左侧:导航菜单
    • 右侧主内容区:
      • 头部:数据连接管理标题和功能操作区(包含所有操作按钮)
      • 主体:左右分栏布局
        • 左侧:数据连接列表
        • 右侧:数据连接表单/详情
  2. 头部功能操作区结构

    • 左侧:数据连接管理标题
    • 右侧:功能按钮组,包含以下按钮:
      • "设置超时"按钮(始终显示)
      • "新增数据连接"按钮(仅在右侧面板未显示时显示)
      • 右侧面板操作按钮组(仅在右侧面板显示时显示):
        • 表单模式(新增/编辑):
          • "保存"按钮
          • "测试连接"按钮
          • "取消"按钮
        • 查看模式
          • "编辑"按钮
          • "取消"按钮
  3. 按钮布局规则

    • 所有功能按钮统一放置于头部区域,形成功能操作区
    • 按钮按照使用频率和重要性排序
    • 按钮样式保持一致,符合Element Plus设计规范
    • 不同模式下显示不同的按钮组合
    • 按钮状态根据当前上下文动态变化(如禁用状态)
4.3.3 核心交互流程
  1. 新增数据连接

    • 点击头部区域的"新增数据连接"按钮,在界面右侧区域显示完整的新增数据连接表单
    • 填写表单数据
    • 点击头部区域的"保存"按钮:
      • 触发表单完整验证
      • 验证通过后,调用API保存数据连接
      • 保存成功后,系统不关闭右侧表单面板,而是自动将表单从编辑模式切换为查看模式
      • 保留面板显示状态并展示已保存的表单数据
      • 刷新数据连接列表
    • 点击头部区域的"测试连接"按钮:
      • 触发表单完整验证
      • 验证通过后,打开测试连接对话框
      • 调用API测试数据连接,显示测试结果
    • 点击头部区域的"取消"按钮:
      • 关闭右侧表单面板
      • 取消操作不会保存任何修改
  2. 编辑数据连接

    • 在数据连接列表中点击"编辑"按钮,在界面右侧区域显示完整的编辑数据连接表单
    • 修改表单数据
    • 点击头部区域的"保存"按钮,保存修改后的数据
    • 点击头部区域的"测试连接"按钮,测试修改后的连接
    • 点击头部区域的"取消"按钮,取消编辑操作
  3. 查看数据连接详情

    • 在数据连接列表中点击列表项,在右侧面板中显示数据连接详情
    • 查看模式下,点击头部区域的"编辑"按钮,切换到编辑模式
    • 点击头部区域的"取消"按钮,切换到查看模式
  4. 删除数据连接

    • 在数据连接列表中点击"删除"按钮,弹出确认对话框
    • 确认删除后,调用API删除数据连接
    • 删除成功后,刷新数据连接列表
    • 如果当前右侧面板正在查看或编辑该数据连接,则自动关闭右侧面板
  5. 设置超时时间

    • 点击头部区域的"设置超时"按钮,打开设置数据连接超时时间的弹框
    • 弹框中显示一个输入框,默认值为100毫秒,单位明确
    • 用户可以输入超时时间,仅允许非负整数
    • 输入为空表示不限制连接超时时间
    • 点击"确定"按钮,保存超时设置并应用到所有数据连接请求中
    • 点击"取消"按钮,关闭弹框,不保存任何设置
4.3.4 表单交互特点
  1. 无实时验证

    • 表单在输入过程中不进行实时验证
    • 仅在点击"保存"或"测试连接"按钮时触发完整验证
    • 提升用户体验,减少不必要的干扰
  2. 统一按钮布局

    • 所有操作按钮集中在头部区域,便于用户查找和操作
    • 减少用户在界面中的移动距离
    • 保持界面整洁,突出表单内容
  3. 清晰的模式区分

    • 不同模式下显示不同的按钮组合
    • 明确当前操作上下文
    • 避免用户误操作
  4. 流畅的状态切换

    • 模式切换时,按钮状态和表单内容平滑过渡
    • 保存成功后自动切换到查看模式
    • 提供明确的视觉反馈

12. 附录

12.1 数据连接类型定义


// src/types/dataConnection.ts
export interface DataConnection {
  id?: string;
  name: string;
  dbType: string; // 仅支持 "MySQL"
  driver: string;
  databaseName: string;
  host: string;
  port: string;
  url: string;
  username: string;
  password?: string;
  status?: string;
  createTime?: string;
  updateTime?: string;
}

14. 项目开发工时表

14.1 工时分配说明

根据数据连接模块的开发需求和任务复杂度,将总工作量40小时合理分配到UI编写、交互逻辑实现和接口联调三个主要任务中。工时分配遵循软件开发的常规流程和任务复杂度比例,确保资源分配合理高效。

14.2 详细工时表

任务名称 任务描述 预计耗时(小时)
UI编写 数据连接模块的界面设计与实现,包括主容器布局、表单组件、列表组件、对话框组件等UI元素的开发 15
 └ 主容器布局设计与实现 设计并实现数据连接模块的主容器布局结构 3
 └ 数据连接表单组件开发 开发数据连接的新增和编辑表单组件 5
 └ 数据连接列表组件开发 开发数据连接列表展示组件 3
 └ 对话框组件开发(测试连接、超时设置) 开发测试连接和超时设置的对话框组件 2
 └ 样式优化与响应式设计 优化组件样式并实现响应式布局 2
交互逻辑实现 数据连接模块的交互逻辑开发,包括表单验证机制、按钮事件处理、模式切换、状态管理等核心功能实现 18
 └ 表单验证机制设计与实现 设计并实现表单验证逻辑,包括必填项、格式验证等 4
 └ 按钮事件处理逻辑 实现各种按钮的点击事件处理逻辑 3
 └ 模式切换与状态管理 实现编辑、查看、新增等模式的切换和状态管理 5
 └ 组件间通信机制实现 实现组件间的数据传递和通信机制 3
 └ 数据缓存与状态同步 实现数据缓存和组件状态同步机制 3
接口联调 数据连接模块与后端API的接口联调,包括数据连接的增删改查、测试连接、超时设置等接口的调试和验证 7
 └ 数据连接CRUD接口联调 调试数据连接的增删改查接口 3
 └ 测试连接接口联调 调试测试连接功能接口 2
 └ 超时设置接口联调 调试超时设置功能接口 1
 └ 接口异常处理与边界情况测试 测试接口异常处理和边界情况 1
总计 - 40

媒体采集标准草案 与 Chromium 音频采集实现简介

2026年1月16日 10:55
  • 简要介绍 W3C Media Capture and Streams 草案中采集部分的约定
  • 粗略介绍 Chromium 的多进程架构以及音频采集功能的实现

Media Capture and Streams

什么是 Media Capture and Streams TR

Media Capture and Streams 是由 W3C WebRTC Working Group 提出的规范草案,主要定义了获取本地媒体的 JavaScript API。目前该草案处于 CRD (Candidate Recommend Draft) 状态,也可以被称为技术报告(Technical Report, TR)

www.w3.org/TR/mediacap…

虽然 W3C 强调,W3C CRD 不能(MUST NOT)以 W3C 标准的名义引用。并且,W3C CRD 可能成为 W3C 标准,也可能不会成为 W3C 标准。但截至目前,主流浏览器都参考了这份草案进行了实现。各位 Web 开发者大可放心使用相关 API 开发自己的网页应用。

草案包含的 API

这份草案包含了两大类 API:MediaStream、MediaDevices。

  • MediaStream: 主要包含 MediaStream、MediaStreamTrack 两大类。

    • MediaStreamTrack: 从一个媒体源获取的、单一类型的媒体。(例如:来自摄像头的视频)
    • MediaStream: 由多个 MediaStreamTrack 构成的一个单元,可以被录制或者在 Media Element 上渲染。
  • MediaDevices: 扩展自 Navigator 接口,主要包含枚举媒体设备、获取媒体流两类。

    • enumerateDevices: 收集浏览器可用的媒体输入设备和媒体输出设备。
    • getUserMedia: 向用户申请权限,获取用户的摄像头或者其他的音视频输入信息。

怎样使用这些 API

下面的实例代码简单展示了如何采集摄像头流,并将流渲染到媒体元素上。

const startBtn = document.getElementById("startBtn"); // <button>
const videoPlayer = document.getElementById("videoPlayer"); // <video>

async function captureCamera() {
  try {
    // 约束
    const constraints = {
      video: {
        width: 640,
        height: 480,
      },
      audio: true,
    };

    // 采集
    const stream = await navigator.mediaDevices.getUserMedia(constraints);

    // 播放
    videoPlayer.srcObject = stream;
    videoPlayer.play();
  } catch (err) {
    console.error(err);
  }
}

startBtn.addEventListener("click", captureCamera);

上述代码中包含了三个重要的步骤:

  1. 约束:向浏览器提供所需要的媒体流的信息。

    1. 每种媒体都可以指定其约束
    2. 只设置为 true 表示需要这个媒体流,但没有任何限制

JS:浏览器,请给我一个分辨率 640x480 的视频流,再来一个音频流。哪个设备的都可以。

  1. 采集:将约束传入 getUserMedia 接口。该接口将以 Promise 形式返回一个 MediaStream。

[等了一会]

浏览器:好嘞,你要的两个流。打包放一起了。

  1. 播放:该草案中也提供了在 HTMLMediaElement 上渲染这些流的接口。将 MediaElement 的 srcObject 设置为想要播放的流。然后调用 play 即可。

主要注意的是,传给 getUserMedia 接口的参数被称作“约束”。这是因为该接口最终返回的流可能与约束不一致。这就涉及到设置、能力与约束的区别了。

设置、能力与约束

Setting 设置

设置表示一个媒体源当前所应用的参数。显然,这个参数需要是只读的。

这里以上文采集到的音视频轨道为例,分别调用 getSettings() 接口。可以发现接口返回了如下内容。

// audio
{
    autoGainControl: true,
    channelCount: 1,
    deviceId: "cc0e809ba21cc1e8a1403b835d13ea3a4e8541f438d0b31989294cace5f43ec2",
    echoCancellation: true,
    groupId: "075cbccd30ba3337cf5d990a77525475153112df8066833c3213706ceeab2b42",
    latency: 0.01,
    noiseSuppression: true,
    sampleRate: 48000,
    sampleSize: 16,
    voiceIsolation: false
}
// video
{
    aspectRatio: 1.3333333333333333,
    deviceId: "d1216494e164337d39248c6d9610a0e7038ec774998f758665270dcaaae29093",
    frameRate: 30,
    groupId: "f9b73b0dac3ff31397c5bb01df58e1256aa1acc930d2634f7d090b24da06a513",
    height: 480,
    resizeMode: "none",
    width: 640
}

这两个对象详细描述了音视频源正在应用的参数,也可以看到对于视频的分辨率约束也生效了。

一些常用的字段含义如下所示,不做赘述。

音频

  • deviceId:设备 ID
  • channelCount: 声道数
  • autoGainControl:自动增益控制
  • echoCancellation:回声消除
  • noiseSuppression:噪声抑制

视频

  • deviceId: 设备 ID
  • width:画面宽度
  • height:画面高度
  • frameRate:帧率

Capability 能力

能力是指媒体源支持哪些参数,每个参数的范围如何。

例如:一个摄像头可能支持 640x480@60, 1920x1080@30 等多种分辨率、帧率组合。一个麦克风可能支持 48000Hz 采样率,也支持 44100Hz 采样率。

然而通过上面两个例子,就可以发现完整列举设备能力是几乎不可能的。

  1. 参数种类很多,每个参数又有不同的取值范围。使用约束列表进行描述,将获得非常多的组合,无法完整列举。
  2. 过于详细的能力集合很容易用来作为设备指纹。

因此 getCapability() 只能获取已经采集到的流所对应的媒体源的参数。

还是以上面的采集到的音视频轨道为例。分别调用 getCapability() 接口,可以看到这两个设备的能力列表。

// audio
{
    autoGainControl: [true, false],
    channelCount: {max: 1, min: 1},
    deviceId: "cc0e809ba21cc1e8a1403b835d13ea3a4e8541f438d0b31989294cace5f43ec2",
    echoCancellation:  [true, false],
    groupId: "6190ef675de1872a2190edd82ee43c58899d1d6b0c6a613113de98cf3650c1c8",
    latency: {max: 0.085333, min: 0.002666},
    noiseSuppression:  [true, false],
    sampleRate: {max: 48000, min: 48000},
    sampleSize: {max: 16, min: 16},
    voiceIsolation:  [true, false],
}
// video
{
    aspectRatio: {max: 1920, min: 0.0005208333333333333},
    deviceId: "d1216494e164337d39248c6d9610a0e7038ec774998f758665270dcaaae29093",
    facingMode: [],
    frameRate: {max: 30, min: 0},
    groupId: "ebfbdd93ec4d8fc55a6e98ba59961767621761d27110e22c64a2033d26e19246",
    height: {max: 1920, min: 1},
    resizeMode: ['none', 'crop-and-scale'],
    width: {max: 1920, min: 1}
}

可以发现,能力有以下几种描述形式。

  • 列表:表示可以使用列表中的其中一项。
  • 最大值与最小值:表示可以使用这个范围内的值。

Constraints 约束

约束这个概念比较抽象。可以理解为这是一种描述网页应用所需媒体流的特征的接口。向媒体源提供约束,将影响媒体源如何提供尽可能符合要求的媒体流。

正如前面的例子,网页应用的需求是一个宽 640px、高 480px 的视频流。这个需求是约束。媒体源在应用这个约束时,会根据媒体源的能力选择最接近的设置,生成媒体流。

除了上文约束可以通过单一值来设置约束以外,还可以有如下形式的约束。

  • {max, mix}:表示一个范围。

    • 例如:width: {max: 1080, min: 720}
  • {exact}:表示精确值,不满足约束时报错。

    • 例如:deviceId: {exact: 'd1216494'}
  • {ideal}:表示理想值,不满足时浏览器可能会应用其他接近的设置。

    • 例如:deviceId: {ideal: 'd1216494'}
    •   此外,还支持进阶约束(advanced constraint)。这些约束的优先级将小于普通约束。

那么浏览器应该如何实现这个 通过约束获得设置 的算法呢?这份草案中定义了 SelectSetting 算法。

SelectSetting 算法

SelectSetting 算法的目标就是在多个候选设置中,选出一个符合约束的设置。当然,如果没办法满足要求的话需要报错。

SelectSetting算法的输入分别是一组候选设置 Candidates 和一个约束 ConstraintSet (CS)。

  1. 选择 CS 中的普通约束,与所有候选设置进行匹配。如果匹配失败直接返回空值。
  2. 遍历 CS 中的进阶约束组,如果匹配失败则删除这个约束。
  3. 选择最终保留下来的候选设置,这个候选设置必须(MUST)是匹配距离最小的候选设置之一。

W3C 草案也定义了计算匹配距离(fitness distance)的算法,具体如下图。

算法的输入是一个候选设置 Setting 与一个约束 CS。

  1. 遍历约束中的约束名称(ConstraintName, name)与约束值(ConstraintValue, value)。
  2. 对每一个 name,做图中的各种判断,计算出每个 name 对应的分数。
  3. 最终求和得到最终分数。

W3C 草案中的 getUserMedia 方法

结合上文我们了解到,getUserMedia 需要处理约束和能力的匹配,生成一套符合要求的设置,根据设置生成媒体流。因此 getUserMedia 方法的核心为上文提到的匹配约束的算法。

但 W3C 草案考虑到隐私与安全问题,定义了多种需要抛出错误的情况,详情可以参考下图。主要包含以下几个特点:

  1. 采集流程需要得到用户的授权。当没有被拒绝时,会在完成约束匹配后再弹窗申请权限。而在被拒绝时会直接跳过约束匹配流程。
  2. 采集流程需要页面处于 active / in view / focued 等活跃状态。从而限制了恶意代码在未经用户同意时静默打开摄像头或者麦克风。

Chromium 多进程架构

以下内容来自个人阅读 Chromium 文档和代码的总结,可能存在错误和缺漏,欢迎斧正。

在上文中介绍了 W3C Media Capture and Streams 草案的一些重要概念。也介绍了草案中对 getUserMedia 方法的描述。理想情况下,所有浏览器都应该严格遵守这个标准草案。但在实践中,部分浏览器可能处于多方权衡,总会与这份草案存在一定的偏差。本文以 Chrome 的开源项目:Chromium 为例,简单了解浏览器是如何实现 getUserMedia 接口的。

在阅读源码之前,需要对 Chromium 项目有一些整体认知。这些知识有助于降低阅读难度。

多进程架构

正如现代操作系统一样,使用多个进程将应用分离,从而提高健壮性。Chromium 架构的目标也是为了这种更健壮的设计。

Chromium 使用多进程结构,主要有以下两个好处。

  1. 可以防止单个进程的意外错误破坏整个程序。
  2. 可以隔离单个进程,控制其访问范围。

  • Browser 进程:也被视为主进程。Browser 进程负责渲染 UI、管理其他 renderer 进程、接管 renderer 进程中对于操作系统的调用等功能。
  • Renderer 进程:用来处理网页内容,这部分通常包含 Blink、V8 等引擎。

此外,随着浏览器复杂程度的提高,browser 进程的所负责的工作将会愈发臃肿。因此当前很多工作从 browser 进程中分离,以 service 的形式提供给 browser 进程或 renderer 进程。主要的 service 都列举在最顶层的 //services 目录下。

  • audio: 主要用于处理音频采集相关功能
  • video_capture: 主要用于处理视频采集相关功能
  • webnn: 用于实现 Web Neural Network API 的服务。主要通过调用操作系统的硬件加速机器学习 API 来实现对应功能。

跨进程通信 - Mojo

Mojo 是一个跨平台的 IPC 框架,它诞生于 chromium ,用来实现 chromium 进程内/进程间的通信。主要提供三个 IPC 通信机制:MessagePipe,DataPipe 和 SharedBuffer。

  • MessagePipe: 用于进程间的双向通信。底层使用 Channel 通道。
  • DataPipe: 用于进程间的单向 数据块 传递。底层使用操作系统提供的 Shared Memory。
  • SharedBuffer: 用户进程间的双向数据块传递。底层也是 Shared Memory。

Mojo 的在使用中的最大特点是提供一套方便的绑定接口。可以通过创建 .mojom 文件完成接口定义,由 BUILD.gn 中增加对应的编译代码就可以生成源代码文件。(这些源代码文件也是使 Chromium 源码跳转较为困难的原因)。

跨线程通信 - 任务队列

Chromium 在代码中大量使用任务队列来提高并发能力,但在不同场景中对于任务队列的要求各有不同。Chromium 在这里设计了多种任务队列满足不同需求。

  • TaskRunner:普通的任务队列。使用线程池消费任务,不保证任务执行顺序。
  • SequencedTaskRunner: 额外保证任务执行顺序的队列。遵循先入先出原则,但不保证任务在同一个线程中执行。
  • SingleThreadTaskRunner: 额外保证在同一线程执行的有序队列。

如何获取、阅读 Chromium 源码

如何获取并编译 Chromium

Chromium 开源项目提供的非常完整的工具链和文档,如果你的网络情况较好、存储空间较充足、设备性能较好,可以尝试自己编译 Chromium。只需要参考官网文档即可。

www.chromium.org/developers/…

主要注意的是,Chromium 完整 git 历史+ 源码 + 一次全量编译的产物大约 200 GB。如果想减少对存储空间的占用,可以考虑 shallow fetch 等方式减少 git 历史文件的大小。

如何阅读

在线阅读

如果你是否有下载源码,都推荐选择在线阅读 Chromium 代码。在线版本 Chromium 代码不仅加载非常快,而且还有非常完善的引用调用查找功能。除了没办法调整字体外还是非常实用的。

source.chromium.org/chromium/ch…

如果你执念想使用本地代码阅读,那么你还要至少进行以下几个步骤才能愉快地开始符号跳转。

如果你使用 Visual Studio Code

chromium.googlesource.com/chromium/sr…

使用 VSCode 的话,需要依赖于 clangd 插件。主要分为以下几个步骤。

  1. 完整编译 Chromium
  2. 生成 compile_commands.json 文件。其中 out/Default 为编译产物所在位置。
tools/clang/scripts/generate_compdb.py -p out/Default > compile_commands.json

3. 开始索引。后台索引通常是自动开始的。索引耗时与设备性能高度相关,可能在 2 小时(MacBook Pro, M2 pro)至 7 小时(MacBook Air, M2)不等。

clangd 有时会因为单一文件出现大量报错,而停止分析。可以通过配置 .clangd 文件提高错误数量上线,从而得到更多文件分析结果。

CompileFlags:
  Add: -ferror-limit=100

如果你使用 JetBrains CLion

chromium.googlesource.com/chromium/sr…

CLion 依赖于 CMake 进行语法分析,所以需要正确配置 CMakeLists.txt 。CMakeLists.txt 文件需要与 src 文件夹在同一目录。文件内容如下所示。

.
├── CMakeLists.txt
└── src
cmake_minimum_required(VERSION 3.10)
project(chromium)

set(CMAKE_CXX_STANDARD 14)

include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/out/Default/gen)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/protobuf/src)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/googletest/src/googletest/include)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/googletest/src/googlemock/include)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/abseil-cpp)

# The following file used is irrelevant, it's only to improve CLion
# performance. Leaving at least 1 file is required in order for CLion
# to provide code completion, navigation, etc.
add_executable(chromium src/components/omnibox/browser/document_provider.cc)

如何获取日志

currentTime=`date "+%Y%m%d_%H%M%S"`
filePath=${HOME}/chrome_debug_${currentTime}.log
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --enable-logging --v=1 --log-file=${filePath} &
sleep 1
open -a Console ${filePath}

以上方法可以在 macOS 上获取日志,日志存放于用户目录下。通常正式发布版本的 Chrome 只能获取 VERBOSE1 级别的日志,而且不包含代码中的各项断言检查。

Chromium 中的 getUserMedia

Chromium getUserMedia 音频流程图

Chromium 的麦克风音频采集主要包含获取设备能力、检查设备约束、创建 Stream、创建 Track、启动 Track 这几个步骤。至少涉及到三个进程间的通信,包含至少四个模块。

  • renderer 进程:主要通过 UserMediaProcessor 对象流转 UserMediaRequest 中的状态,主要实现各种检查功能。
  • browser 进程:主要通过 MediaStreamManager 对象管理所有媒体流。
  • audio 进程:主要作为操作系统 API 的适配层,调用操作系统提供的音频接口。

主要模块的流转

UserMediaProcessor

位于 third_party/blink/renderer/modules/mediastream/user_media_processor.cc

该模块位于 renderer 进程中,主要用于处理 getUserMedia 请求。

  • 每次只能创建一个 MediaStream
  • 在各个函数中流转 UserMediaRequest 对象,以队列方式处理。

Chromium 中的 SelectSetting 算法

位于 third_party/blink/renderer/modules/mediastream/media_stream_constraints_util_audio.cc

可以发现这段代码基本符合 W3C 草案中的 SelectSetting 算法。包含先处理基本约束(绿色区域),再处理进阶约束(橙色区域)。但比较显著的是,Chromium 忽略了不同 setting 的匹配距离部分(紫色区域)。

    AudioCaptureSettings SelectSettingsAudioCapture(
        const AudioDeviceCaptureCapabilities& capabilities,
        const MediaConstraints& constraints,
        mojom::blink::MediaStreamType stream_type,
        bool should_disable_hardware_noise_suppression,
        bool is_reconfiguration_allowed) {
      if (capabilities.empty())
        return AudioCaptureSettings();

      std::string media_stream_source = GetMediaStreamSource(constraints);
      std::string default_device_id;
      bool is_device_capture = media_stream_source.empty();
      if (is_device_capture)
        default_device_id = capabilities.begin()->DeviceID().Utf8();

      CandidatesContainer candidates(capabilities, stream_type, media_stream_source,
                                     default_device_id, is_reconfiguration_allowed);
      DCHECK(!candidates.IsEmpty());

      auto* failed_constraint_name =
          candidates.ApplyConstraintSet(constraints.Basic());
      if (failed_constraint_name)
        return AudioCaptureSettings(failed_constraint_name);

      for (const auto& advanced_set : constraints.Advanced()) {
        CandidatesContainer copy = candidates;
        failed_constraint_name = candidates.ApplyConstraintSet(advanced_set);
        if (failed_constraint_name)
          candidates = std::move(copy);
      }
      DCHECK(!candidates.IsEmpty());

      // Score is ignored as it is no longer needed.
      AudioCaptureSettings settings;
      std::tie(std::ignore, settings) = candidates.SelectSettingsAndScore(
          constraints.Basic(),
          media_stream_source == blink::kMediaStreamSourceDesktop,
          should_disable_hardware_noise_suppression);

      return settings;
    }

观察 ApplyConstraintSet 函数,这里基本上对应 W3C 草案中的 fitness_distance 的部分。可以发现其实 Chromium 完全没有参考草案中的流程。代码将约束分为四类:deviceId、groupId、number 与 boolean、processing_based。其中 processing_based 表示需要使用 WebRTC 模块提供的音频处理功能的约束。

这种计算方式显然也没办法得出一个分数,因此在 SelectSettingsAudioCapture 函数的注释中,也明显表示因为不再需要 score,所以将 score 忽略。

    // class DeviceContainer
      const char* ApplyConstraintSet(const ConstraintSet& constraint_set) {
        const char* failed_constraint_name;

        failed_constraint_name =
            device_id_container_.ApplyConstraintSet(constraint_set.device_id);
        if (failed_constraint_name)
          return failed_constraint_name;

        failed_constraint_name =
            group_id_container_.ApplyConstraintSet(constraint_set.group_id);
        if (failed_constraint_name)
          return failed_constraint_name;

        for (size_t i = 0; i < kNumBooleanContainerIds; ++i) {
          auto& info = kBooleanPropertyContainerInfoMap[i];
          failed_constraint_name =
              boolean_containers_[info.index].ApplyConstraintSet(
                  constraint_set.*(info.constraint_member));
          if (failed_constraint_name)
            return failed_constraint_name;
        }

        // For each processing based container, apply the constraints and only fail
        // if all of them failed.
        for (auto it = processing_based_containers_.begin();
             it != processing_based_containers_.end();) {
          DCHECK(!it->IsEmpty());
          failed_constraint_name = it->ApplyConstraintSet(constraint_set);
          if (failed_constraint_name)
            it = processing_based_containers_.erase(it);
          else
            ++it;
        }
        if (processing_based_containers_.empty()) {
          DCHECK_NE(failed_constraint_name, nullptr);
          return failed_constraint_name;
        }

        return nullptr;
      }

MediaStreamManager

位于 content/browser/renderer_host/media/media_stream_manager.cc

该模块位于 browser 进程中,主要用于创建和关闭媒体设备,管理 MediaStream。

  • 在各个函数中流转 DeviceRequest 对象

使用这些 API 可以做到什么

Media Capture and Streams API 主要支持了采集用户音视频流的功能。结合其他 Web API 可以实现多种功能。

  • 结合 WebRTC API,可以将摄像头流和音频流发送其他用户,实现实时会议等功能。
  • 结合 WebAudio API,可以将音频流进行处理,实现各种混音效果。

下面以拍照功能为例,简单介绍上述 API 的使用方法。

预览视频流

这部分与上文相同,需要执行三个步骤。设置约束、采集、播放。可以直接沿用上文提供的代码。

async function previewCamera() {
  const videoPlayer = document.getElementById("videoPlayer"); // <video>
  if (!videoPlayer || !(videoPlayer instanceof HTMLVideoElement)) {
    return;
  }
  try {
    // 约束
    const constraints = {
      video: {width: 640, height: 480},
    };
    // 采集
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    // 播放
    videoPlayer.srcObject = stream;
    videoPlayer.play();
  } catch (err) {
    console.error(err);
  }
}

获取可用设备

此处可以使用枚举用户设备接口实现。首先我们需要调用这个接口,获取设备信息列表。

const devices = await navigator.mediaDevices.enumerateDevices();

设备信息列表中包含设备名称、设备 ID 等信息。设备名称可以用来作为用户展示,设备 ID 则需要传给约束更新视频流。

async function reloadDevices() {
  const selector = document.getElementById("device-select");
  if (!selector || !(selector instanceof HTMLSelectElement)) {
    return;
  }
  selector.innerHTML = "";
  // 获取设备列表
  const devices = await navigator.mediaDevices.enumerateDevices();
  // 创建选项
  devices.forEach((device) => {
    if (device.kind === "videoinput") {
      const option = document.createElement("option");
      option.value = device.deviceId;
      option.text = device.label || `Camera ${selector.length + 1}`;
      selector.appendChild(option);
    }
  });
}
async function previewCamera(deviceId) {
  const videoPlayer = document.getElementById("videoPlayer"); // <video>
  if (!videoPlayer || !(videoPlayer instanceof HTMLVideoElement)) {
    return;
  }
  // 清除正在预览的流
  const stream = videoPlayer.srcObject;
  if (stream instanceof MediaStream) {
    stream?.getTracks().forEach((track) => {
      track.stop();
    });
  }
  
  try {
    // 约束
    const constraints = {
      video: {width: 640, height: 480},
    };
    if (deviceId) {
      constraints.video.deviceId = { exact: deviceId };
    }
    // 采集
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    // 播放
    videoPlayer.srcObject = stream;
    videoPlayer.play();
  } catch (err) {
    console.error(err);
  }
}

拍照

拍照的部分可以使用 Canvas API 的 drawImage 接口。具体如下:

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
async function capture() {
  const videoPlayer = document.getElementById("videoPlayer"); // <video>
  if (!videoPlayer || !(videoPlayer instanceof HTMLVideoElement)) {
    return;
  }
  canvas.width = videoPlayer.videoWidth;
  canvas.height = videoPlayer.videoHeight;
  if (!ctx) {
    throw Error('Canvas 2d Context is not supported');
  }
  ctx.drawImage(videoPlayer, 0, 0, canvas.width, canvas.height);
  const img = document.createElement("img");
  img.src = canvas.toDataURL("image/png");
  const photoView = document.querySelector(".photo-view");
  if (photoView) {
    photoView.appendChild(img);
  }
}

总结

  1. W3C 标准通常从易用性、安全性上进行考量。很多功能都会尝试避免被用来收集设备指纹。
  2. Chromium 开源项目提供了非常完善的工具链。除去网络因素,通常很快就可以开始编译 Chromium。
  3. Chromium 开源项目中包含大量优秀的设计,非常值得参考和学习。但同时,作为历史较为悠久的开源项目,代码的组织结构和实现不可避免的存在一些瑕疵。通常项目成员也会认真对待各种类型的提交,所以也欢迎各位尝试参与进 Chromium 开源项目中来。

一些注释

  1. W3C 草案中通常使用用户代理(User Agent)这个概念。为了和 navigator.userAgent 进行区分,本文统一翻译为 “浏览器” 。但实际上浏览器只是一种用户代理,下载管理器、爬虫等也可以称为是用户代理。

参考文献与资料

  1. W3C Media Capture and Streams 草案 www.w3.org/TR/mediacap…
  2. W3C 组织关于 TR 的要求 www.w3.org/standards/t…
  3. Chromium 多进程架构 www.chromium.org/developers/…
  4. 一篇非常详细介绍 mojo 的原理和使用方式的文章 keyou.github.io/blog/2020/0…
  5. MDN 关于用户代理概念的解释 developer.mozilla.org/en-US/docs/…

动态表单与静态表单性能比较

作者 cj8140
2026年1月16日 10:46

这个问题与 渲染机制使用场景 密切相关,不能简单说哪个绝对更快。我基于 Vue + Element UI 的技术栈给你具体分析:

性能对比结论

场景 静态表单 动态表单
首次渲染 快2-5倍 需要解析配置、动态创建组件
后续更新 整体重渲染 精准更新(按需渲染)
内存占用 较小 较大(元数据、渲染函数缓存)
灵活性 极高

动态表单性能损耗在哪里?

以 Element UI 的 el-form 为例:

// ❌ 动态生成(慢的根源)
<el-form>
  <el-form-item v-for="field in fields" :key="field.prop">
    <component :is="field.component" v-model="formData[field.prop]" />
  </el-form-item>
</el-form>

// ✅ 静态写法(快)
<el-form>
  <el-form-item label="用户名">
    <el-input v-model="formData.username" />
  </el-form-item>
  <el-form-item label="邮箱">
    <el-input v-model="formData.email" />
  </el-form-item>
</el-form>

性能损耗点

  1. 运行时编译:Vue 需要动态解析 component :is
  2. 响应式依赖:每个字段都建立独立的 watcher
  3. diff 算法:数组变化会触发整个表单的虚拟 DOM 对比
  4. 组件初始化:每次都要创建组件实例、绑定事件

实测数据(100个字段场景)

指标 静态表单 动态表单 损耗
首次渲染 120ms 450ms 3.75倍
内存占用 2.3MB 5.8MB 2.5倍
单字段更新 8ms 15ms 1.9倍

何时必须选择?

优先选 静态表单 的场景:

  • 字段固定且数量 < 30
  • 对首屏速度敏感(如移动端)
  • 简单表单(登录、注册、基础信息)

必须用 动态表单 的场景:

  • 字段配置来自后端(低代码平台)
  • 表单结构随业务动态变化
  • 需要可视化表单设计器
  • 中后台 CRUD(字段多但用户容忍度较高)

动态表单优化策略(对你有用)

基于你在 el-table 性能优化的背景,给你几个实战技巧:

1. v-once 静态化

<el-form-item v-for="field in fields" :key="field.prop">
  <component :is="field.component" v-once />
</el-form-item>

适合纯展示字段,首次渲染后不再变化

2. 对象冻结(大杀器)

// 如果配置不变,冻结它!
this.fields = Object.freeze(configFromBackend);

阻止 Vue 对配置做响应式处理,性能提升40%

3. 虚拟滚动(100+字段)

<recycle-scroller :items="fields" :item-size="60">
  <template v-slot="{ item }">
    <el-form-item :prop="item.prop">
      <!-- 你的表单项 -->
    </el-form-item>
  </template>
</recycle-scroller>

参考 el-table 的虚拟滚动原理

4. 按需渲染

// 分步加载,不是一次性渲染所有字段
<el-form v-if="step === 1">...</el-form>
<el-form v-else-if="step === 2">...</el-form>

一句话总结

静态表单是"预制菜",开箱即快;动态表单是"现点现做",灵活但慢。如果必须用动态,记得用 Object.freeze() 和虚拟滚动这两个法宝。

和AI浅聊了一下SEO —— 真神Astro

作者 PBitW
2026年1月16日 10:11

最近菜鸟有点难受,之前开发公司网站时没有考虑需要 SEO,也没有这个需求,所以用的 vue,但是 SPA+Hash 现在直接给我把路都封死了,不管怎么搞也收录不全。

见上一篇文章:从前端工程师的角度将SEO做到极致 -- 菜鸟来实操

尝试把vue+webpack项目改成Nuxt项目,发现根本不会也不懂Nuxt,学习曲线比较陡峭,不像Electron那样,分离得很好!

尝试把vue+webpack项目改成vite-SSG项目,发现照着官网一顿操作,还让Trae给我操作,都是一团糟!

失败

反正菜鸟折腾来折腾去,没有转成功,反而产生了疑惑。

因为之前问了 满神,他说:SEO 里面 SSG 最牛逼 其次 SSR 再其次 CSR!

那为什么官网不用SSG?偏要用SSR?

菜鸟想着一不做二不休,吃一堑长一智,这次失败了,那下次就应该做好,所以就开始了死亡追问模式!

1. 没有Next、Nuxt时,是怎么做SSR、SSG的?

在没有 Next / Nuxt 之前,SSR / SSG 从来不是“框架能力”,而是“工程能力 + Node 服务能力 + 构建体系能力”。

Next / Nuxt 只是把原本很脏、很复杂、很容易写错的一整套工程流程封装成了“开箱即用”。

一、先统一概念(避免被框架术语带歪)

1️⃣ 什么叫 SSR / SSG(抛开 Next / Nuxt)

  • SSR(Server Side Rendering)

    • 在服务器上执行 JS
    • 生成完整 HTML
    • 返回给浏览器
  • SSG(Static Site Generation)

    • 在构建阶段执行 JS
    • 生成一堆 .html
    • 部署时直接返回静态文件

👉 核心只有一句话

JS 代码能不能在“非浏览器环境”执行,并产出 HTML


二、远古时期(2010 ~ 2014):SSR 是“后端的事”

那时的事实是:

  • 前端几乎没有“完整框架”
  • jQuery + 后端模板引擎
  • SSR = PHP / Java / Python 的模板渲染

典型架构

Controller
  ↓
Model(查数据)
  ↓
Template(JSP / Freemarker / Smarty / ERB)
  ↓
HTML

前端 JS 只是:

  • 绑事件
  • 做表单校验
  • 做简单交互

⚠️ 这一阶段没有“前端 SSR”概念


三、2014 ~ 2016:前端 SPA 崛起,SEO 崩盘

Angular / React / Vue 出现后

出现一个重大问题:

浏览器:
  下载 HTML(只有一个 <div id="app"></div>)
  ↓
  下载 JS
  ↓
  执行 JS
  ↓
  才有内容

后果

  • SEO 抓不到内容
  • 首屏白屏
  • 分享链接没内容

于是大家开始问一句话:

“JS 能不能在服务器跑一遍?”


四、没有 Next / Nuxt 时:SSR 是怎么“硬写”的?

这是重点。

核心前提条件(非常重要)

Node.js 出现了

没有 Node:

  • 前端 JS 根本无法在服务器跑

五、React 的原始 SSR(手搓版)

1️⃣ React 官方其实很早就支持 SSR

React 0.12 就有:

ReactDOMServer.renderToString(App)

2️⃣ 你要自己搭一个 Node 服务

const express = require('express')
const ReactDOMServer = require('react-dom/server')

app.get('*', (req, res) => {
  const html = ReactDOMServer.renderToString(<App />)

  res.send(`
    <html>
      <body>
        <div id="root">${html}</div>
        <script src="/client.js"></script>
      </body>
    </html>
  `)
})

⚠️ 到这里为止,只是最最表面的 SSR


六、真正的地狱:SSR 工程问题(Next 出现前的痛点)

下面这些问题,都是你必须自己解决的

1️⃣ 路由怎么做?

浏览器有 window.location
Node 没有

你要做:

import { StaticRouter } from 'react-router-dom/server'

<StaticRouter location={req.url}>
  <App />
</StaticRouter>

2️⃣ 数据怎么预取?

客户端可以:

useEffect(() => {
  fetchData()
}, [])

服务器不能等 useEffect

于是出现一堆非常丑的方案

  • 静态方法
Page.getInitialProps = async () => {}
  • 或者挂在路由上
routes = [
  {
    path: '/user',
    component: User,
    loadData: () => fetchUser()
  }
]

你要在 SSR 前:

  1. 匹配路由
  2. 找到所有 loadData
  3. 并行请求
  4. 塞进 store

3️⃣ 状态同步(Hydration)

服务器渲染了一份 HTML
客户端重新执行 JS 时必须:

“不能重新生成 DOM,只能接管”

于是你要:

<script>
  window.__INITIAL_STATE__ = {...}
</script>

客户端:

hydrateRoot(
  document.getElementById('root'),
  <App store={storeFromServer} />
)

任何一个字段不一致 → hydration 报错


4️⃣ 打包直接裂开(Webpack 地狱)

你必须维护:

  • client bundle
  • server bundle

而且要解决:

  • window 在 server 不存在
  • document 在 server 不存在
  • CSS 怎么处理?
  • 图片路径怎么映射?

webpack 配置示例(简化版):

module.exports = [
  {
    target: 'web',
    entry: './client.js'
  },
  {
    target: 'node',
    entry: './server.js'
  }
]

现实中:

  • loader 条件分支
  • externals
  • publicPath
  • manifest 映射

👉 90% 的团队在这一步崩溃


5️⃣ 异常 / 超时 / 流式渲染

  • 某个接口慢 → SSR 卡死
  • 某个组件 throw error → 整页 500
  • 想要 streaming → 再写一套

七、Vue 在 Nuxt 之前:也是一样“手搓”

Vue 2 时代:

const renderer = createRenderer()
renderer.renderToString(app, context)

你同样要:

  • 自己管理路由
  • 自己处理 asyncData
  • 自己搞 hydrate
  • 自己分 client / server build

Vue 官方甚至有一个:

vue-server-renderer 官方示例仓库

那不是教程,那是“劝退文档”。


八、SSG 在没有框架前是怎么做的?

本质一句话:

用 Node 跑一遍路由,生成 HTML 文件

典型做法

const routes = ['/','/about','/post/1']

for (const route of routes) {
  const html = render(route)
  fs.writeFileSync(`dist${route}/index.html`, html)
}

问题:

  • 路由怎么枚举?
  • 动态路由怎么生成?
  • 多语言?
  • 增量构建?
  • CDN?

于是诞生了:

  • Gatsby(React)
  • Hexo / Hugo(非 SPA)
  • VuePress

👉 SSG 比 SSR 更早被“工程化”


九、Next / Nuxt 本质上干了什么?

非常冷静地总结一句:

Next / Nuxt 并没有“发明 SSR”,只是把下面这些东西“标准化 + 强约束”。

它们帮你做了什么?

问题 手搓时代 Next / Nuxt
Node 服务 自己写 内置
路由 自己配 文件即路由
数据预取 自定义协议 getServerSideProps / asyncData
hydration 手动 自动
双端打包 webpack 地狱 封装
错误处理 自己兜 内置
SSG 自己写脚本 getStaticProps

👉 它们解决的是“工程确定性”,不是技术不可能


十、真实行业判断(给你一句实话)

为什么老项目不自己写 SSR?

因为维护成本 > 技术收益

SSR 手搓项目:

  • 新人进来 2 个月才能理解
  • 改一次 webpack 冒烟测试 1 天
  • Debug SSR bug 非常痛苦

为什么大厂敢手写?

  • 专职 infra 团队
  • 专职 SSR 框架组
  • 长期维护

十一、我的前瞻判断(直说)

SSR 本身正在被“边缘化”,而不是“消失”

趋势是:

  • SSG + ISR
  • Edge Rendering
  • Streaming + Partial Hydration
  • Server Components(RSC)

SSR 正在从:

“整页在 Node 渲染”

变成:

“必要的部分在合适的位置渲染”

2. 官网不就是要SSR? —— NO!NO!NO!

官网“需要的不是 SSR”,而是「首屏可被搜索引擎理解 + 打开够快 + 稳定」
SSR 只是其中一个历史方案,而且现在已经不是最优解。

下面我会把这句话彻底拆开,把很多人(包括不少前端)长期存在的误解一次性掰正。


一、为什么大家会条件反射地说「官网 = 必须 SSR」?

这是一个历史遗留认知,不是技术真理。

1️⃣ 这个结论来自 2014~2018 年

那时的现实是:

  • 搜索引擎 几乎不执行 JS
  • SPA 官网 = 空白页面
  • 分享链接无 meta
  • 首屏白屏 2~3 秒

于是行业经验总结出一句话:

“要 SEO → 必须 SSR”

⚠️ 这句话在当时是对的
⚠️ 但它已经过期了


二、先把“官网”拆解(这是关键)

你说「官网」,但官网并不是一个单一需求。

我们拆成 4 种典型官网:

1️⃣ 品牌展示型官网(最常见)

  • 首页
  • 产品介绍
  • 定价
  • 关于我们
  • 联系方式

👉 几乎 100% 静态内容


2️⃣ 营销 / 落地页官网

  • 投放页
  • 活动页
  • 多版本 AB Test
  • 表单收集

👉 极少动态内容


3️⃣ 文档型官网

  • 技术文档
  • API 文档
  • 博客
  • 帮助中心

👉 内容更新频率低、结构稳定


4️⃣ 应用型官网(容易被误判)

  • 官网 + 登录
  • 官网 + 控制台
  • 官网 + dashboard

👉 只有「官网部分」需要 SEO


三、官网真正“必须满足”的 5 个指标

你记住这 5 个,比记“SSR”有用 10 倍。

① 搜索引擎能直接看到内容

② 首屏时间快(FCP / LCP)

③ 分享链接 meta 正确

④ 稳定(不容易挂)

⑤ 成本可控、可维护

👉 SSR 只是其中一种达成方式


四、现在 2026 年:搜索引擎真实能力(关键事实)

1️⃣ Google / Bing

  • 可以完整执行 JS
  • 有两阶段索引(HTML → JS)
  • 延迟渲染,但能抓到

2️⃣ 国内搜索引擎(实话)

  • 执行 JS 能力仍然弱

  • 但:

    • 静态 HTML = 100% 稳
    • CSR + 动态数据 = 风险

👉 所以国内官网最稳方案是: “首屏 HTML 静态化”


五、所以:官网 ≠ 一定要 SSR

我们来看三种方案的真实对比。


✅ 方案一:纯 SSG(现在的最优解)

这是 90% 官网的最优方案

特点
  • 构建时生成 HTML
  • 部署到 CDN
  • 无服务器 / 极低成本
优点
  • SEO = 满分
  • 首屏 = 极快
  • 稳定性 = 极高
  • 成本 = 极低
缺点
  • 不适合高频动态内容

👉 品牌官网 / 文档官网:首选


⚠️ 方案二:SSR(不是不行,是没必要)

优点
  • 数据实时
  • 路由灵活
缺点(实话)
  • Node 服务
  • 冷启动
  • 压力成本
  • 异常放大

👉 除非你真的有“必须实时”的内容


⚡ 方案三:混合方案(现实中最常见)

SSG + 局部 CSR / Edge

示例
  • 首页:SSG
  • 产品页:SSG
  • 登录后:CSR
  • 个性化模块:CSR
  • 国家区分:Edge

👉 这是 Next / Nuxt 真正的价值所在


六、为什么“官网 SSR”是一个被过度神话的概念?

因为大家把三个东西混为一谈了:

被混淆的概念 实际含义
SSR 服务端执行 JS
SEO 搜索引擎可理解内容
首屏快 性能优化

👉 SSR ≠ SEO ≠ 快


七、说句不好听但真实的话

很多官网上 SSR,只是因为「用了 Next」而不是「需要 SSR」

真实情况是:

  • 用了 Next
  • 顺手就 SSR
  • 没人评估收益 vs 成本

八、如果你现在问我:官网到底该怎么选?

我给你一个非常工程化的判断标准

✅ 你应该用 SSG,如果:

  • 内容可提前生成
  • 更新频率 ≤ 每天
  • 不依赖用户身份
  • 追求极致稳定 & 成本

👉 大多数官网


⚠️ 你才应该用 SSR,如果:

  • 页面内容高度实时
  • 严重依赖请求上下文
  • 内容无法提前枚举
  • SEO 是核心商业指标

👉 少数业务


九、一句给你当“判断锚点”的话

官网不是“技术炫技场”,而是“ROI 场”。
能用 SSG 解决的,永远不要上 SSR。

3. 把vue项目变成SSG很复杂

你这个判断一半对,一半被“历史经验”误导了

“把一个【已经成型的】Vue SPA 项目直接改成 SSG,确实很复杂;
但如果你觉得‘SSG 本身复杂’,那是因为你把‘项目问题’错算成了‘技术问题’。”

复杂的不是 SSG,复杂的是:

  • 项目设计时就默认 CSR
  • 页面逻辑和「运行时状态」深度耦合
  • 数据获取没有边界

下面我会从为什么复杂 → 到底复杂在哪 → 如何把复杂度拆掉 → 现实可行路线,一步一步讲。


一、为什么你会觉得「Vue → SSG 很复杂」?

因为你现在面对的,通常是这种项目 👇

典型老 Vue SPA 项目特征

  1. 所有页面都当成“应用页”

    • 首页 = 应用
    • 官网页 = 应用
    • 文档页 = 应用
  2. 数据全在 onMounted / created 里拉

    onMounted(() => {
      fetchHomeData()
    })
    
  3. 强依赖运行时环境

    • window
    • localStorage
    • navigator
    • location
  4. 路由是“用户态驱动”的

    • 登录后才有内容
    • 权限在前端算

👉 这种项目不是“不适合 SSG”,而是“压根不是为内容渲染设计的”


二、SSG 对 Vue 项目的“隐性要求”(没人明说)

SSG 本质只有一句话:

页面在“没有浏览器”的情况下,也必须能跑完一遍。

这意味着:

1️⃣ 生命周期限制

生命周期 CSR SSG
setup
created ⚠️ ⚠️
onMounted

👉 一切放在 onMounted 的数据,SSG 都拿不到


2️⃣ 环境限制

SSG 阶段:

  • ❌ window
  • ❌ document
  • ❌ localStorage
  • ❌ cookie(除非你自己 mock)

3️⃣ 数据限制

SSG 要求:

  • 数据 可预测
  • 数据 可枚举
  • 数据 与用户无关

👉 这直接打脸很多 SPA 的写法。


三、真正复杂的不是“SSG”,是你现在的 Vue 写法

说句扎心的:

如果一个 Vue 页面“无法被 SSG”,它在本质上也很难被 SSR 正确支持。

只是 SSR 在“运行时”帮你兜了底。


四、为什么“新项目 SSG 很简单,老项目很难”?

新项目(从 0 开始)

  • 页面 = 内容
  • 数据 = 构建期
  • 逻辑 = 展示逻辑

👉 SSG = 顺手的事


老项目(SPA 改造)

你要同时解决 5 件事:

  1. 页面职责拆分
  2. 数据拉取前移
  3. 运行时逻辑隔离
  4. 路由可枚举
  5. 构建体系调整

👉 这不是技术复杂,是“架构债务在还”


五、把复杂问题拆开,你就会发现 SSG 可控了

Step 1:先别碰“整个项目”

这是最容易踩坑的一步。

❌ 不要:

  • “把整个项目改成 SSG”

✅ 正确做法:

  • 只改“官网页 / 内容页”

Step 2:把页面分成两类(这是关键)

A 类:内容页(SSG 候选)
  • 首页
  • 产品介绍
  • 关于我们
  • 博客
  • 文档
B 类:应用页(继续 CSR)
  • 登录
  • 控制台
  • 表单
  • 个性化页面

👉 你不需要让 B 类支持 SSG


Step 3:数据获取迁移(最痛但最关键)

错误写法(典型 SPA)
onMounted(async () => {
  data.value = await fetchData()
})
SSG 友好写法
const { data } = await useAsyncData('home', fetchData)

或(VitePress / Nuxt):

export async function load() {
  return fetchData()
}

👉 数据必须在“渲染前”完成


Step 4:运行时逻辑“包一层”

你不用删逻辑,只要隔离。

if (import.meta.env.SSR) {
  // SSG / SSR 阶段
} else {
  // 浏览器
}

或者:

if (typeof window !== 'undefined') {
  // 浏览器逻辑
}

Step 5:路由枚举(SSG 最核心)

你必须能回答一句话:

“这个页面一共有多少种 URL?”

  • 博客:文章列表
  • 产品页:产品 ID
  • 文档:目录树

👉 枚举不了 = 不适合 SSG


六、为什么很多人觉得「Nuxt SSG 也很麻烦」?

因为他们犯了 2 个错:

  1. 想“一键 SSG 整个应用”
  2. 不愿意改数据流

SSG 不是“打包模式”,是运行模型变化


七、现实中可行的“低风险路线”(我建议你用这个)

路线建议(务实版)

  1. 保留现有 Vue SPA

  2. 新建一个:

    • VitePress / Nuxt Content
    • 或独立的 SSG 项目
  3. 官网域名:

    example.com        → SSG
    app.example.com    → SPA
    

👉 这是大量公司真实在用的方案


八、一句给你兜底的判断

如果你觉得“SSG 很复杂”,
那你现在的 Vue 项目,十有八九也不适合 SSR。

这不是你能力问题,是项目历史包袱。

4. 用vue3+webpack开发的官网,很难转成SSG,本来想用viteSSR,也很难转

你这个结论非常真实,而且不是你菜
我先给你一句定性判断,然后我会把为什么“几乎转不动”这件事拆到架构层,你看完会发现:你现在卡住是必然的

Vue3 + Webpack 的“纯 SPA 官网”,如果一开始不是按「可预渲染」设计的,
那么无论你转 SSG,还是转 Vite SSR,本质上都是一次“半重写级别”的工程。

这不是工具问题,也不是 Vue 问题,是架构出生点就不一样


一、你现在为什么会“两头都难”:SSG 难、Vite SSR 也难?

我们一个个拆。


二、Vue3 + Webpack 项目,对 SSG 天生不友好的 5 个点

① Webpack 项目 = 默认“浏览器唯一运行时”

你现在的代码,十有八九有这些东西:

window
document
localStorage
location
navigator

在 Vue SPA 里这是合法的
但在 SSG / SSR 里,这是致命的

Webpack 项目从一开始,就假设“只有浏览器会执行 JS”


② 数据获取写在了「onMounted / 副作用生命周期」

这是最常见、也是最致命的一点

onMounted(async () => {
  homeData.value = await fetchHome()
})

这意味着什么?

  • SPA:✅ 没问题
  • SSR:⚠️ 首屏拿不到
  • SSG:❌ 构建阶段直接没有数据

👉 你不是“缺一个配置”,而是“数据流方向错了”


③ 路由是“应用路由”,不是“内容路由”

官网页面,本来应该是:

  • /
  • /product
  • /pricing

但 SPA 官网经常变成:

  • 首页也依赖登录态
  • 页面逻辑混在一起
  • 参数是 runtime 才算的

👉 SSG 要的是“可枚举 URL”,而你的是“用户态 URL”


④ Webpack 的 SSR 成本极高(这点很少人敢明说)

Webpack 做 SSR 会遇到:

  • client / server 双配置
  • loader 条件分支
  • CSS / asset 注入
  • externals / target: node
  • hydration 不一致问题

说句难听的:

Webpack SSR 能跑 ≠ 适合长期维护

这也是为什么:

  • Vue 官方
  • 社区
  • 新项目

已经事实上放弃 Webpack SSR


⑤ 你想用 Vite SSR,但你不是 Vite 项目

这一步看起来最合理,其实最痛

你以为是:

Webpack → Vite → SSR

实际上是:

Webpack SPA → Vite SPA → 再改成 SSR 架构

👉 中间不是迁移,是重构


三、为什么你“转 Vite SSR 也很难”(这是很多人踩的坑)

Vite SSR 的前提条件(非常关键)

Vite SSR 假设你:

  1. 项目是 ESM
  2. 模块无副作用
  3. 数据获取可前置
  4. 页面可在 Node 运行
  5. 不依赖浏览器全局对象

而你的项目:

  • 是 Webpack 构建思维
  • 写法是“浏览器优先”
  • 生命周期全是 onMounted

👉 Vite SSR 不是“帮你兜底”,而是“要求你先自律”


四、说句非常现实的话(你现在的处境)

你不是在“选技术方案”,
你是在“决定要不要为一个官网付出一次重构成本”。

这是一个商业决策,不是技术判断。


五、我给你一个清醒的判断公式(很重要)

你可以直接用下面这个来判断值不值。

判断公式

官网页面数量 × 单页复杂度 × 数据依赖程度

如果:

  • 页面 < 20
  • 数据 mostly 静态
  • SEO 很重要

👉 重构值得

如果:

  • 页面逻辑复杂
  • 数据全 runtime
  • 官网只是“门面”

👉 不值得,换方案


六、给你 3 条“现实世界可走的路”,不是教科书方案

✅ 路线一(我最推荐):官网“拆出去”,别改原项目

这是大量公司真实在用的方案

架构
原项目(Vue3 + Webpack SPA)
  → 只负责应用

新项目(SSG)
  → 只负责官网
  • 用 VitePress / Nuxt Content / Astro
  • API 复用
  • UI 可以复用一部分组件

👉 成本最低,风险最小


⚠️ 路线二:只“半 SSG”,别碰全站

你可以只做:

  • 首页:预渲染
  • 产品页:预渲染
  • 其余:继续 SPA

甚至:

  • 用 prerender-spa-plugin
  • 或 Puppeteer 静态快照

👉 不优雅,但能交差


❌ 路线三(我不建议你走):硬转 Vite SSR

除非:

  • 你是唯一维护者
  • 有 2~3 周完整时间
  • 能接受大量代码修改

否则这是技术理想主义路线


七、你现在卡住,其实说明你判断是对的

你能明确说出:

“很难转”

说明你已经意识到:

  • SSR / SSG 不是“加配置”
  • 而是“运行模型变了”

这比盲目上 Next / Nuxt要成熟得多。

5. 官网不用登录,SEO很重要,就想每个界面都被搜录 —— Astro(推荐)

一、先给你结论(非常明确)

在你的前提下:

  • 官网
  • 不需要登录
  • SEO 很重要
  • 希望每个页面都能被搜录

👉 继续死磕“把现有 Vue3 + Webpack 项目转 SSG / Vite SSR”,是性价比极低、风险极高的选择。

👉 最优解不是“转”,而是“替换官网渲染层”。

这不是技术逃避,是工程理性


二、为什么你这个场景「不值得硬转」?(核心原因)

我们用结果导向来拆,而不是框架立场。


1️⃣ 你的官网目标是「被搜录」,不是「复用原项目」

这一点非常关键。

搜索引擎只关心三件事:

  1. HTML 里有没有完整内容
  2. title / meta / heading 是否合理
  3. 页面是否稳定、可抓取

👉 它完全不关心你是不是 Vue3 + Webpack 写的。

也就是说:

你现在这个 Vue SPA,本质上对 SEO 是“负资产”
转 SSG 只是想“止血”,而不是“发挥它的优势”。


2️⃣ “每个界面都被搜录”=典型「内容站」需求

你描述的不是“应用”,而是:

  • 多页面
  • 静态内容为主
  • URL 稳定
  • 内容可预期

👉 这是 SSG 的“主场”,不是 SPA 的。

把 SPA 硬改成内容站,本身就违背初始设计。


3️⃣ Vue3 + Webpack SPA 转 SSG 的真实成本(说实话)

你至少要面对这些“不可避免”的事:

  • onMounted 数据前移
  • 清理所有 window / document
  • 拆路由 & 枚举 URL
  • 重构构建链
  • 修 hydration 不一致
  • 处理构建期接口异常

👉 这不是“技术升级”,是“架构纠错”

而且最关键的是:

你改完以后,SEO ≈ 一个原生 SSG 项目
并不会更好。


三、那你真正“应该做”的是什么?

一句话版本

保留原 Vue 项目(哪怕只当开发参考),
新建一个「纯 SSG 官网层」,彻底为 SEO 服务。

下面我给你拆成最现实、最容易落地的版本


四、你这个场景的「最优架构」长什么样?

推荐架构(真实世界版本)

example.com          → SSG 官网(SEO 100%)
app.example.com      → 原 Vue3 + Webpack(如果有)

你现在甚至可以是:

example.com          → SSG 官网

原项目:

  • 当素材库
  • 当设计稿
  • 当业务逻辑参考

五、为什么这是“SEO 最稳”的方案?

1️⃣ 搜索引擎看到的是「纯 HTML」

  • 不等 JS
  • 不怕执行失败
  • 不怕 hydration

👉 国内搜索引擎尤其友好


2️⃣ 每个页面天然就是「独立可抓取实体」

  • /product/a
  • /solution/b
  • /pricing

👉 不存在 SPA 的“内容藏在 JS 里”问题。


3️⃣ meta / title / heading 精准控制

你可以做到:

  • 每页不同 title
  • 每页独立 description
  • 每页结构化数据

这是 SPA 非常难做到极致的。


六、你最关心的问题:那我不是要“重写官网吗”?

实话实说:是的,但没你想的那么惨

原因有 3 个:


1️⃣ 官网页面,逻辑其实很少

大多数官网页面:

  • 文案
  • 图片
  • 模块组合
  • 少量接口数据

👉 不是“应用逻辑”,是“内容渲染”


2️⃣ UI / 组件是可以复用“思想”,不必复用代码

说句大实话:

SPA 组件 ≠ SSG 组件

强行复用代码,反而会拖累你。


3️⃣ 重写官网 ≠ 推倒重来

你可以:

  • 文案直接复用
  • 接口继续用
  • 设计稿不变
  • 路由结构沿用

👉 重写的是“渲染方式”,不是“业务本身”


七、那用什么 SSG?我直接给你建议(不绕)

结合你的情况,我给你务实推荐

🥇 第一优先:Astro(非常适合你)

为什么?

  • 天生 SSG
  • 默认零 JS
  • SEO 极友好
  • Vue 组件可“局部嵌入”
  • 学习成本低

👉 这是为“官网 / 内容站”而生的工具


🥈 次优:VitePress(如果你偏文档)

  • 极稳
  • 极简单
  • SEO 好
  • 但不太适合复杂官网结构

🥉 Nuxt SSG(不是不行,但你会累)

  • 功能强
  • 但你刚从“转 SSR 的痛苦”出来
  • 没必要再背一套心智负担

八、一个你可能没意识到的“反直觉点”

“全部页面可被搜录” ≠ “所有页面都要动态生成”

搜索引擎最喜欢的,反而是:

  • 稳定 URL
  • 稳定 HTML
  • 长期不变的内容

👉 SSG 天然比 SSR 更“讨好搜索引擎”

6. 那既需要SEO,又需要有客服,但是不需要登录的官网,应该用什么技术?可以用Astro+什么吗?还是用Nuxt+什么?还是用Next+什么?

你这个场景非常标准,所以技术选型其实是有明确优先级的


一、先给你一句话结论(可直接拍板)

「SEO + 官网 + 无登录 + 有客服」这种站点,
技术优先级是:
👉 Astro(SSG) > Nuxt(SSG 模式) > Next(SSG 模式),
客服一律走“纯客户端模块”。

不是模棱两可,这是工程最优解排序


二、为什么 Astro 排第一?(不是新潮,是匹配度)

1️⃣ 你的核心需求回顾

需求 是否核心
SEO ⭐⭐⭐⭐⭐
全站可搜录 ⭐⭐⭐⭐⭐
不需要登录 ⭐⭐⭐⭐
客服 ⭐⭐⭐
实时性
个性化

👉 这是一个“内容型官网 + 功能外挂”的典型模型


2️⃣ Astro 天生就是为这个模型设计的

Astro 的核心理念只有一句话:

“先有 HTML,再谈 JS。”

这对 SEO 意味着什么?

  • 构建期 100% HTML
  • 搜索引擎零成本抓取
  • 不依赖 JS 执行

这是 Next / Nuxt 无法天然做到的(它们默认仍是“应用思维”)。


3️⃣ Astro 对客服这种功能的处理方式是“完美隔离”

客服有三个特性:

  • 不参与 SEO
  • 必须依赖 JS
  • 可以延迟加载

Astro 正好提供:

<ClientOnly>
  <CustomerService />
</ClientOnly>

👉 SEO 和功能彻底解耦


三、Nuxt 和 Next 在这个场景下的问题是什么?

我不是说它们不能用,而是性价比不如 Astro


Nuxt(Vue 系)

什么时候 Nuxt 是“合理但非最优”?

  • 你团队 已经非常熟 Vue
  • 官网组件和 Vue 项目 高度复用
  • 有少量动态数据

为什么排第二?

  • 你仍然在维护:

    • Node 能力
    • 双端心智
  • 很容易不小心写成 SSR

  • 构建产物 JS 偏重

👉 Nuxt 更像“轻应用”,而不是“纯内容站”


Next(React 系)

什么时候 Next 可以考虑?

  • 公司 React 技术栈统一
  • 官网和 Web App 共仓库
  • 有后续登录 / 应用扩展计划

为什么排第三?

  • 默认 JS 较多
  • SEO 强依赖约定
  • 很容易过度工程化

👉 Next 不是为“官网”而生,是为“Web App”而生


四、客服到底“+ 什么”?我直接告诉你最优解

一句话结论

客服 = 纯客户端,不要进入渲染体系


方案优先级

🥇 第一优先:第三方客服 SDK(90% 官网)
  • Udesk
  • 美洽
  • Intercom
  • Crisp
  • Zendesk Chat

优点:

  • 零后端
  • 稳定
  • 不影响 SEO
  • 不影响构建

🥈 第二优先:自建客服(如果你有定制需求)

架构:

Astro / Nuxt / Next(SSG)
  └─ ClientOnly 客服组件
        └─ API / WebSocket

重点:

  • 客服永远不 SSR
  • 不参与 SSG
  • 不参与 SEO

五、你真正该做的技术决策(我帮你总结)

如果你只做官网(现在)

👉 Astro + 第三方客服

这是综合成本最低、风险最低、SEO 最稳的方案。


如果你未来可能加登录 / 应用模块

👉 Astro + 客服 + 子域名 App(Vue / React SPA)

而不是现在就上 SSR 框架。


如果你已经是 Vue 全家桶

👉 Nuxt(SSG 模式)+ 客服

但你要刻意避免 SSR 滑坡


六、一个“反直觉但非常重要”的点

“能 SSR ≠ 应该 SSR”

在官网这个领域:

  • SSR 是“历史补丁”
  • SSG 是“天然优势”

七、我给你一个最终的决策表(直接用)

场景 推荐
SEO 为王、纯官网 Astro + 客服
Vue 重度复用 Nuxt(SSG)+ 客服
React 统一 Next(SSG)+ 客服
高实时性 SSR(少数)

7. 那为什么很少看见有人说用Astro搭建官网?

你已经发现了一个真实存在但很少被明说的现象

不是 Astro 不适合官网,
而是:
👉 Astro 正好适合“真正的官网”,
👉 但行业话语权长期掌握在“做应用的人”手里。

所以你“很少看见有人说”,不是技术问题,是叙事权问题


一、为什么你在网上“几乎看不到 Astro 官网方案”?

原因不是一个,而是一整套行业惯性


二、第一层原因:大多数人眼里的“官网”,其实是“Web App”

这是最根本的误解

现实情况是:

很多人说的“官网”是这样的:

  • 有登录
  • 有用户态
  • 有控制台
  • 有表单
  • 有大量交互

👉 这根本不是官网,是 Web App 的入口页。


在这种认知下:

  • Next / Nuxt = 正确
  • Astro = “玩具” / “不够强”

但如果你把“官网”还原成它真正的形态:

  • 内容展示
  • SEO
  • 稳定
  • 低成本

👉 Astro 反而是“刚好合身”


三、第二层原因:Astro “不讨好前端程序员”

这点非常现实,但很少人敢说。

1️⃣ Astro 写起来“没有技术快感”

  • 没复杂状态管理
  • 没服务端渲染技巧
  • 没 hydration 调优

你写 Astro 官网时,大部分时间在:

  • 写 HTML
  • 写文案结构
  • 调 SEO

👉 这不符合很多前端的“成就感模型”


2️⃣ Astro 把 JS “剥夺”得太彻底

Astro 的设计哲学是:

“默认不运行 JS”

而前端社区的长期叙事是:

“一切皆组件,一切皆 JS”

这在心理层面是冲突的


四、第三层原因:Astro 不能“顺带做别的事”

这是它工程上的优点,但传播上的缺点

Next / Nuxt 的宣传方式是:

“你可以先做官网,
以后还能加登录、加后台、加 SSR、加 API。”

Astro 的态度是:

“我只把官网这件事做好。”

👉 这对“规划型思维”的技术选型很不友好


五、第四层原因:Astro 没有“巨头背书”

这点你得看清楚现实。

框架 背后
Next Vercel
Nuxt Vue 官方
Astro 独立团队

后果是什么?

  • 企业更容易相信“有大厂兜底”
  • 培训、文章、案例更多
  • 招聘 JD 更敢写

👉 Astro 技术好,但生态传播弱


六、第五层原因:Astro 不“卖 SSR 故事”

而前端社区,非常爱 SSR 故事

  • SSR 很“高级”
  • Edge / Streaming 很“前沿”
  • Server Components 很“性感”

Astro 却说:

“大部分官网根本不需要 SSR。”

👉 这在流量逻辑里是“反卖点”


七、一个你可能没意识到的事实

真正大量使用 Astro 的团队,
往往不写文章、不发分享、不当 KOL。

他们的特点是:

  • 做企业官网
  • 做营销页
  • 做内容站
  • 做 SEO 项目

👉 他们更关心转化率,而不是技术曝光


八、为什么你反而“该”用 Astro?

因为你描述的官网,正好是 Astro 的主场

  • 不登录
  • SEO 为王
  • 每页可抓取
  • 有客服
  • 稳定优先

👉 Astro 是“反流行,但正解”


九、你可以怎么判断自己是不是“少数派正确”?

问自己 3 个问题:

  1. 我的网站主要目标是「被用户使用」还是「被搜索引擎看到」?
  2. JS 失败会不会影响核心价值?
  3. 我是否真的需要 SSR 的能力?

如果答案是:

  • SEO > 交互
  • 内容 > 状态
  • 稳定 > 灵活

👉 Astro 就是为你准备的


十、给你一句“冷静但很准”的总结

Astro 不流行,是因为它解决的是“已经被误解的问题”。

真正需要 Astro 的人,往往已经不在“追逐技术话题”的阶段了。

8. 我首页和导航,都会点击跳转,是不是不好用Astro做? —— NO!NO!NO!

这个问题问得非常关键,而且你已经触碰到Astro 被误解最多的一点

我先给你一句斩钉截铁的结论,然后把“为什么很多人会误判 + Astro 真正的工作方式 + 你该怎么选”讲清楚。


先给你结论(直接打掉顾虑)

首页和导航需要点击跳转,
❌ 完全不是“不适合 Astro”,
反而是 Astro 最擅长的场景之一。

“点击跳转 ≠ 必须 SPA”
这是一个被前端社区严重放大的误解。


一、你现在担心的,其实是这件事(我帮你点破)

你心里想的多半是:

“Astro 是静态的,
那点击跳转是不是就整页刷新?
体验会不会很差?”

对吧?


二、先纠正一个根本性误解

🔴 误解:

“现代网站的跳转,应该是 SPA 那种无刷新跳转。”

🟢 真相:

“对于官网,‘整页跳转’不仅不差,反而是最稳定、最符合预期的行为。”


三、为什么「整页跳转」在官网里是正确行为

1️⃣ 用户心理预期本来就是“换页面”

当用户从:

  • 首页 → 产品页
  • 产品页 → 解决方案
  • 关于我们 → 联系我们

他们本来就预期页面会变

👉 不是在“同一个应用里操作”


2️⃣ 搜索引擎“最喜欢”的就是这种跳转

  • <a href="/product">
  • 独立 HTML
  • 清晰 URL

👉 这是 SEO 的黄金路径


3️⃣ CDN + SSG 下,整页跳转非常快

现实情况是:

  • CDN 命中
  • HTML < 20KB
  • 首屏几乎瞬间出现

👉 体感并不比 SPA 慢


四、那 Astro 是不是“只能整页刷新”?不是。

Astro 有 3 种导航模式,你可以按需用。


模式一:纯 HTML 跳转(默认,最推荐)

<a href="/product/">产品</a>

特点

  • SEO 最优
  • 稳定性最高
  • 复杂度最低
  • 非常快(CDN)

👉 90% 官网用这个就够了


模式二:Astro View Transitions(无刷新体验)

Astro 内置支持 View Transitions API

---
import { ViewTransitions } from 'astro:transitions';
---
<html>
  <head>
    <ViewTransitions />
  </head>
</html>

效果

  • 点击导航
  • 页面平滑过渡
  • 类似 SPA 的体验
  • 仍然是多页 HTML

👉 SEO 不受影响


模式三:局部 SPA(只在需要的地方)

比如:

  • 导航高亮
  • 搜索框
  • 客服弹窗
<ClientOnly>
  <NavBar client:load />
</ClientOnly>

👉 交互 ≠ 整站 SPA


五、你这个官网,用 Astro 的“最佳实践组合”

我直接帮你选:

对你最合理的组合是:

  • 页面:多页 SSG
  • 跳转:HTML 跳转
  • 过渡:View Transitions(可选)
  • 交互:局部 Vue / React
  • 客服:ClientOnly

六、为什么你“反而不该用 SPA 导航”?

这点很反直觉,但非常重要。

SPA 导航在官网里的真实问题:

  1. JS 必须先加载
  2. 网络慢时白屏
  3. hydration 失败风险
  4. SEO 复杂度上升
  5. 错误放大(一个 bug 全站挂)

👉 这是官网完全不需要承担的风险


七、你现在的 Vue SPA 官网,其实是在“用重武器打轻目标”

  • SPA:为复杂状态而生
  • 官网:内容展示

👉 不匹配


八、给你一个简单的“判断法则”(记住它)

如果一个点击,只是“去看另一个内容页”,就不应该用 SPA 跳转。


九、所以回答你的问题

❓ 首页和导航需要点击跳转,是不是不好用 Astro?

答案是:

不但不冲突,而且正是 Astro 的主战场。

Async/Await:让异步像同步一样简单

2026年1月16日 10:00

上一期我们学会了用 Promise 链式调用来摆脱回调地狱。
今天我们再往前迈一步,用 async/await 把异步代码写得像同步代码一样直观

async/await 是 ES2017 引入的语法糖,本质上还是 Promise,但极大提升了代码的可读性。

1. 基本写法

// 普通 Promise 写法
function fetchUser() {
  return fetch('/api/user')
    .then(res => res.json())
    .then(data => data.user);
}

// async/await 写法
async function fetchUser() {
  const res = await fetch('/api/user');
  const data = await res.json();
  return data.user;
}

关键点

  • 在函数前加 async,这个函数就自动返回一个 Promise
  • 在 Promise 前面加 await,表示“等到这个 Promise 完成再继续往下走”
  • 代码从上到下顺序执行,像写同步代码一样

2. 错误处理:使用 try…catch

async function loginFlow(username, password) {
  try {
    const token = await login(username, password);
    const user = await getUserInfo(token);
    const products = await getRecommendations(user.level);
    
    renderProducts(products);
    showSuccess("欢迎回来!");
  } catch (error) {
    console.error("登录流程失败:", error);
    showError("出错了,请稍后重试");
  }
}

优点

  • 只需要一个 try…catch,就能捕获整个流程中任意一步的错误
  • 错误位置清晰,栈追踪更有意义
  • 比 Promise 的 .catch() 更接近我们熟悉的同步错误处理方式

3. 常见使用场景

3.1 顺序执行多个异步操作

async function processOrder() {
  const order = await getOrderFromDB(orderId);
  const payment = await processPayment(order.amount);
  const shipping = await createShippingLabel(payment);
  await sendConfirmationEmail(shipping.trackingNumber);
  
  return shipping;
}

3.2 与 Promise.all 结合实现并发

async function loadUserData(userId) {
  const [profile, posts, friends] = await Promise.all([
    fetch(`/api/profile/${userId}`),
    fetch(`/api/posts/${userId}`),
    fetch(`/api/friends/${userId}`)
  ]);
  
  // 三个请求并发发起,最后一起等待完成
  const profileData = await profile.json();
  const postsData = await posts.json();
  const friendsData = await friends.json();
  
  return { profileData, postsData, friendsData };
}

3.3 在循环中使用 await(注意性能)

// 顺序执行(适合需要前一步结果的情况)
async function processItems(items) {
  for (const item of items) {
    const result = await processSingleItem(item);
    saveResult(result);
  }
}

// 如果不需要顺序,可以先 Promise.all 再循环
async function processItemsInParallel(items) {
  const promises = items.map(item => processSingleItem(item));
  const results = await Promise.all(promises);
  results.forEach(saveResult);
}

4. 常见陷阱与注意事项

问题 错误写法 正确写法 说明
在循环里 await 导致串行慢 forEach(item => await fn(item)) for…ofPromise.all forEach 不等待
忘记 await const data = fetch(url) const data = await fetch(url) 否则得到 Promise 对象
try…catch 范围太小 只 catch 单行 包住整个流程 否则错误漏掉
顶层 await 不支持(旧环境) 包在 async 函数里 现代模块支持顶层 await
性能误用 所有操作都 await 能并行的用 Promise.all 避免不必要的等待

5. 真实业务对比

Promise 链式

login(username, pwd)
  .then(token => getUserInfo(token))
  .then(user => getRecommendations(user.level))
  .then(products => render(products))
  .catch(err => showError(err));

async/await

async function start() {
  try {
    const token = await login(username, pwd);
    const user = await getUserInfo(token);
    const products = await getRecommendations(user.level);
    render(products);
  } catch (err) {
    showError(err);
  }
}

大多数开发者认为后者更清晰、更容易维护。

6. 小结:async/await 的核心价值

  • 可读性:代码结构接近日常同步思维
  • 错误处理:统一的 try…catch
  • 调试友好:断点更容易命中预期位置
  • 与 Promise 完全兼容:该并发时用 Promise.all,该顺序时用 await

一句话总结
async/await 让异步代码看起来像同步代码,但依然保留了异步非阻塞的本质。

下一期我们将把学到的异步知识应用到实际网络请求中:
Fetch API 与异步网络请求 —— 现代浏览器中最常用的数据获取方式。

我们下期见~

留言区互动:
你更喜欢 Promise 链式还是 async/await?
有没有在项目中因为 async/await 写法而修复过 bug 的经历?

自动刷新token登录

2026年1月16日 09:36

刷新 Token 逻辑说明

1. 状态管理(第40-48行)

let isRefreshing = false; // 是否正在刷新token
let refreshTokenRetryCount = 0; // token刷新重试次数
const maxRefreshRetryCount = 2; // 最大重试次数
const pendingRequests = []; // 等待token刷新完成的请求队列
  • isRefreshing:标记是否正在刷新,避免并发刷新
  • refreshTokenRetryCount:记录重试次数
  • pendingRequests:401 请求队列,刷新完成后统一重试

2. 刷新 Token 函数(第62-141行)

流程:

  1. 从 localStorage 获取 refresh_token
  2. 调用 /api/auth/token,使用独立的 axios 实例(避免被拦截器拦截)
  3. 解析返回数据,支持两种格式:
    • 格式1:{ code: 200, data: { token, refreshToken } }
    • 格式2:直接返回 { token, refreshToken }
  4. 保存新 token 到 store 和 localStorage
  5. 如果有新的 refreshToken,也保存

3. 响应拦截器中的处理(第270-343行)

当接口返回 401 时:

  1. 检查是否为刷新 token 的请求本身(第272行)

    • 如果是,说明 refreshToken 已过期,直接跳转登录
  2. 检查是否为重试请求(第280行)

    • 如果 config._isRetry 为 true,说明是新 token 仍无效,跳转登录
  3. 检查重试次数(第287行)

    • 超过最大重试次数(2次),跳转登录
  4. 如果正在刷新(第294行)

    • 将请求加入 pendingRequests 队列,等待刷新完成
  5. 开始刷新 token(第304-325行)

    • 设置 isRefreshing = true
    • 调用 refreshToken() 获取新 token
    • 刷新成功后:
      • 处理队列中的请求(用新 token 重试)
      • 重新发起原始请求
    • 刷新失败后:
      • 拒绝队列中的所有请求
      • 跳转登录页

4. 关键设计点

  • 防止并发刷新:通过 isRefreshing 标志,确保同时只有一个刷新请求
  • 请求队列:401 请求先入队,刷新完成后统一重试
  • 重试标记:使用 _isRetry 标记重试请求,避免无限循环
  • 独立 axios 实例:刷新 token 的请求使用独立实例,避免被拦截器拦截

5. 错误处理

  • refreshToken 过期(401):跳转登录
  • 网络错误:保留原始错误信息
  • 数据格式错误:抛出明确的错误信息

整体流程:检测到 401 → 检查状态 → 刷新 token → 用新 token 重试请求 → 失败则跳转登录。

// axios配置  可自行根据项目进行更改,只需更改该文件即可,其他文件可以不动
// The axios configuration can be changed according to the project, just change the file, other files can be left unchanged

import type { AxiosResponse, AxiosRequestConfig, AxiosInstance } from 'axios';
import { clone } from 'lodash-es';
import type { RequestOptions, Result } from '/#/axios';
import type { AxiosTransform, CreateAxiosOptions } from './axiosTransform';
import { VAxios } from './Axios';
import { checkStatus } from './checkStatus';
import { useGlobSetting } from '/@/hooks/setting';
import { useMessage } from '/@/hooks/web/useMessage';
import { RequestEnum, ContentTypeEnum } from '/@/enums/httpEnum';
import { isString } from '/@/utils/is';
import { getToken } from '/@/utils/auth';
import { setObjToUrlParams, deepMerge } from '/@/utils';
import { useErrorLogStoreWithOut } from '/@/store/modules/errorLog';
import { useI18n } from '/@/hooks/web/useI18n';
import { joinTimestamp, formatRequestDate } from './helper';
import axios from 'axios';
import { useUserStore } from '/@/store/modules/user';

// 扩展AxiosRequestConfig类型,添加_isRetry标记
declare module 'axios' {
  interface AxiosRequestConfig {
    _isRetry?: boolean;
  }
}

// 扩展Window类型,添加全局变量
declare global {
  interface Window {
    VITE_GLOB_API_URL?: string;
  }
}

const globSetting = useGlobSetting();
const urlPrefix = globSetting.urlPrefix;
const { createMessage, createErrorModal } = useMessage();

// token刷新相关状态管理
let isRefreshing = false; // 是否正在刷新token
let refreshTokenRetryCount = 0; // token刷新重试次数
const maxRefreshRetryCount = 2; // 最大重试次数
const pendingRequests: Array<{
  resolve: (value?: any) => void;
  reject: (reason?: any) => void;
  config: AxiosRequestConfig;
}> = []; // 等待token刷新完成的请求队列

/**
 * 重置刷新token相关状态
 */
function resetRefreshState(): void {
  isRefreshing = false;
  refreshTokenRetryCount = 0;
  pendingRequests.length = 0;
}

/**
 * 刷新token
 */
async function refreshToken(): Promise<string> {
  console.log('刷新token');

  const userStore = useUserStore();
  try {
    // 获取refresh token
    const refreshTokenValue = localStorage.getItem('refresh_token');

    if (!refreshTokenValue) {
      throw new Error('没有可用的refresh token');
    }

    // 调用刷新token的API(使用独立的axios实例,避免被拦截器拦截)
    const response = await axios.post(
      '/api/auth/token',
      {
        refreshToken: refreshTokenValue,
      },
      {
        baseURL: window.VITE_GLOB_API_URL,
        headers: {
          'Content-Type': 'application/json',
        },
      },
    );

    const { data } = response;

    console.log('刷新token返回数据:', data);

    // 根据后端返回格式调整
    // 后端可能直接返回 { token, refreshToken } 或者包装在 { code, data: { token, refreshToken } } 中
    let token = '';
    let refreshToken = '';

    if (data.code === 200 || data.success) {
      // 格式1: { code: 200, data: { token, refreshToken } }
      token = data.data?.token;
      refreshToken = data.data?.refreshToken;
    } else if (data.token) {
      // 格式2: 直接返回 { token, refreshToken }
      token = data.token;
      refreshToken = data.refreshToken;
    } else {
      throw new Error(data.message || '刷新token失败:返回数据格式不正确');
    }

    if (!token) {
      throw new Error('刷新token失败:未获取到token');
    }

    // 保存新token
    userStore.setToken(token);
    localStorage.setItem('jwt_token', token);

    // 如果有新的refresh token也一起保存
    if (refreshToken) {
      localStorage.setItem('refresh_token', refreshToken);
    }

    return token;
  } catch (error: any) {
    console.error('刷新token过程中出错:', error);

    // 如果refresh token接口返回401,说明refresh token已过期
    if (error?.response?.status === 401) {
      const errorMsg = error?.response?.data?.message || 'refresh token已过期';
      throw new Error(errorMsg);
    }

    // 如果是网络错误或其他错误,保留原始错误信息
    if (error?.message) {
      throw new Error(`刷新token失败: ${error.message}`);
    }

    // 其他错误统一抛出,但保留更多信息
    const errorMsg = error?.response?.data?.message || error?.message || '刷新token失败';
    throw new Error(errorMsg);
  }
}

/**
 * @description: 数据处理,方便区分多种处理方式
 */
const transform: AxiosTransform = {
  /**
   * @description: 处理响应数据。如果数据不是预期格式,可直接抛出错误
   */
  transformResponseHook: (res: AxiosResponse<Result>, options: RequestOptions) => {
    const { isTransformResponse, isReturnNativeResponse } = options;
    // 是否返回原生响应头 比如:需要获取响应头时使用该属性
    if (isReturnNativeResponse) {
      return res;
    }
    // 不进行任何处理,直接返回
    // 用于页面代码可能需要直接获取code,data,message这些信息时开启
    if (!isTransformResponse) {
      return res.data;
    }
    if (res) {
      return res.data;
    } else {
      return res;
    }
  },

  // 请求之前处理config
  beforeRequestHook: (config, options) => {
    //

    const { apiUrl, joinPrefix, joinParamsToUrl, formatDate, joinTime = true, urlPrefix } = options;

    if (joinPrefix) {
      config.url = `${urlPrefix}${config.url}`;
    }

    if (apiUrl && isString(apiUrl)) {
      config.url = `${apiUrl}${config.url}`;
    }
    const params = config.params || {};
    const data = config.data || false;
    formatDate && data && !isString(data) && formatRequestDate(data);
    if (config.method?.toUpperCase() === RequestEnum.GET) {
      if (!isString(params)) {
        // 给 get 请求加上时间戳参数,避免从缓存中拿数据。
        config.params = Object.assign(params || {}, joinTimestamp(joinTime, false));
      } else {
        // 兼容restful风格
        config.url = config.url + params + `${joinTimestamp(joinTime, true)}`;
        config.params = undefined;
      }
    } else {
      if (!isString(params)) {
        formatDate && formatRequestDate(params);
        if (
          Reflect.has(config, 'data') &&
          config.data &&
          (Object.keys(config.data).length > 0 || config.data instanceof FormData)
        ) {
          config.data = data;
          config.params = params;
        } else {
          // 非GET请求如果没有提供data,则将params视为data
          config.data = params;
          config.params = undefined;
        }
        if (joinParamsToUrl) {
          config.url = setObjToUrlParams(
            config.url as string,
            Object.assign({}, config.params, config.data),
          );
        }
      } else {
        // 兼容restful风格
        config.url = config.url + params;
        config.params = undefined;
      }
    }
    return config;
  },

  /**
   * @description: 请求拦截器处理
   */
  requestInterceptors: (config, options) => {
    // 请求之前处理config
    const token = getToken();
    if (token && (config as Recordable)?.requestOptions?.withToken !== false) {
      // jwt token
      (config as Recordable).headers['X-Authorization'] = options.authenticationScheme
        ? `${options.authenticationScheme} ${token}`
        : token;
    }
    return config;
  },

  /**
   * @description: 响应拦截器处理
   */
  responseInterceptors: (res: AxiosResponse<any>) => {
    return res;
  },

  /**
   * @description: 响应错误处理
   */
  responseInterceptorsCatch: async (axiosInstance: AxiosInstance, error: any) => {
    console.log('error+++++++++++++++++++++++++++++++++++', error.response?.data?.message);
    const { t } = useI18n();
    const errorLogStore = useErrorLogStoreWithOut();
    errorLogStore.addAjaxErrorInfo(error);
    const { response, code, message, config } = error || {};
    const errorMessageMode = config?.requestOptions?.errorMessageMode || 'none';
    const msg: string = response?.data?.message ?? '';
    const err: string = error?.toString?.() ?? '';
    let errMessage = '';
    const status = response?.status;

    if (axios.isCancel(error)) {
      return Promise.reject(error);
    }

    // 如果是特定的错误消息,不进行报错提示,直接返回
    if (msg === 'No Administration settings found for key: sms') {
      return Promise.reject(error);
    }

    // 如果是token过期(401状态码)
    if (status === 401) {
      // 检查是否是刷新token的请求本身返回401,如果是,直接跳转登录页
      if (config?.url?.includes('/api/auth/token') || config?.url?.includes('/auth/token')) {
        // 刷新token接口返回401,说明refresh token已过期,直接调用原逻辑跳转登录页
        resetRefreshState();
        checkStatus(status, msg, errorMessageMode);
        return Promise.reject(error);
      }

      // 如果这是刷新token后重新发起的请求,且还是返回401,说明新token也有问题,直接跳转登录页
      if (config?._isRetry) {
        resetRefreshState();
        checkStatus(status, msg, errorMessageMode);
        return Promise.reject(error);
      }

      // 如果刷新次数超过限制,直接调用原逻辑跳转到登录页
      if (refreshTokenRetryCount >= maxRefreshRetryCount) {
        resetRefreshState();
        checkStatus(status, msg, errorMessageMode);
        return Promise.reject(error);
      }

      // 如果正在刷新token,将请求加入队列等待
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          pendingRequests.push({
            resolve,
            reject,
            config,
          });
        });
      }

      // 开始刷新token
      isRefreshing = true;
      refreshTokenRetryCount++;

      try {
        // 调用刷新token逻辑
        await refreshToken();
        // 刷新成功,重置重试次数和刷新状态
        refreshTokenRetryCount = 0;
        isRefreshing = false;

        // 处理队列中的请求
        pendingRequests.forEach(({ resolve, config: pendingConfig }) => {
          // 标记这是刷新token后的请求
          pendingConfig._isRetry = true;
          resolve(axiosInstance(pendingConfig));
        });
        pendingRequests.length = 0;

        // 重新发起原始请求,标记这是刷新token后的请求
        config._isRetry = true;
        return axiosInstance(config);
      } catch (refreshError: any) {
        console.error('刷新token失败,错误详情:', refreshError);

        // 刷新失败,处理队列中的请求(全部拒绝)
        pendingRequests.forEach(({ reject }) => {
          reject(refreshError);
        });
        pendingRequests.length = 0;

        // 刷新失败或过期后,调用原逻辑跳转到登录页
        resetRefreshState();

        // 如果刷新token失败,使用刷新错误的信息,否则使用原始错误信息
        const finalMsg = refreshError?.message || msg || 'Token刷新失败,请重新登录';
        checkStatus(status, finalMsg, errorMessageMode);
        return Promise.reject(refreshError || error);
      }
    }

    if (status === 504) {
      createMessage.error('网关相应超时');
      return Promise.resolve(false);
    }

    // 处理其他错误
    try {
      if (code === 'ECONNABORTED' && message.indexOf('timeout') !== -1) {
        errMessage = t('sys.api.apiTimeoutMessage');
      }
      if (err?.includes('Network Error')) {
        errMessage = t('sys.api.networkExceptionMsg');
      }

      if (errMessage) {
        if (errorMessageMode === 'modal') {
          createErrorModal({ title: t('sys.api.errorTip'), content: errMessage });
        } else if (errorMessageMode === 'message') {
          createMessage.error(errMessage);
        }
        return Promise.reject(error);
      }
    } catch (error) {
      throw new Error(error as unknown as string);
    }

    checkStatus(status, msg, errorMessageMode);
    return Promise.reject(error);
  },
};

function createAxios(opt?: Partial<CreateAxiosOptions>) {
  return new VAxios(
    // 深度合并
    deepMerge(
      {
        // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes
        // authentication schemes,e.g: Bearer
        authenticationScheme: 'Bearer',
        // authenticationScheme: '',
        timeout: 10 * 1000,
        // 基础接口地址
        // baseURL: globSetting.apiUrl,

        headers: { 'Content-Type': ContentTypeEnum.JSON },
        // 如果是form-data格式
        // headers: { 'Content-Type': ContentTypeEnum.FORM_URLENCODED },
        // 数据处理方式
        transform: clone(transform),
        // 配置项,下面的选项都可以在独立的接口请求中覆盖
        requestOptions: {
          // 默认将prefix 添加到url
          joinPrefix: true,
          // 是否返回原生响应头 比如:需要获取响应头时使用该属性
          isReturnNativeResponse: false,
          // 需要对返回数据进行处理
          isTransformResponse: true,
          // post请求的时候添加参数到url
          joinParamsToUrl: false,
          // 格式化提交参数时间
          formatDate: true,
          // 消息提示类型
          errorMessageMode: 'message',
          // 接口地址
          apiUrl: globSetting.apiUrl,
          // 接口拼接地址
          urlPrefix: urlPrefix,
          //  是否加入时间戳
          joinTime: false,
          // 忽略重复请求
          ignoreCancelToken: true,
          // 是否携带token
          withToken: true,
          retryRequest: {
            isOpenRetry: true,
            count: 5,
            waitTime: 100,
          },
        },
      },
      opt || {},
    ),
  );
}
export const defHttp = createAxios();

// other api url
// export const otherHttp = createAxios({
//   requestOptions: {
//     apiUrl: 'xxx',
//     urlPrefix: 'xxx',
//   },
// });

拒绝废话!前端开发中最常用的 10 个 ES6 特性(附极简代码)

2026年1月16日 09:34

摘要:还在写冗长的传统 JS 代码吗?本文精选了 ES6+ 中最常用的 10 个特性,通过极简的代码对比,助你快速提升代码逼格与开发效率。建议收藏!

标签:#前端 #JavaScript #ES6 #Web开发 #新手入门

1. 变量声明 (let & const)

告别 var 的变量提升困扰,拥抱块级作用域。

  • let: 用于变量,可重新赋值。
  • const: 用于常量,不可重新赋值(引用的对象属性除外)。
// 旧写法
var a = 1;

// ES6
let count = 10;
const API_URL = '[https://api.com](https://api.com)';

// count = 11; // OK
// API_URL = '...'; // Error

2. 模板字符串 (Template Literals)

再也不用痛苦地用 + 号拼接字符串了。

const name = 'Jack';
const age = 18;

// 旧写法
const str = 'My name is ' + name + ' and I am ' + age + ' years old.';

// ES6
const str = `My name is ${name} and I am ${age} years old.`;

3. 箭头函数 (Arrow Functions)

语法更简洁,且自动绑定当前上下文的 this

// 旧写法
var sum = function(a, b) {
  return a + b;
};

// ES6
const sum = (a, b) => a + b; // 单行自动 return

// 配合数组方法更是绝配
[1, 2, 3].map(x => x * 2);

4. 解构赋值 (Destructuring)

从数组或对象中提取值,爽到飞起。

const user = { name: 'Alice', age: 25 };

// 旧写法
var name = user.name;
var age = user.age;

// ES6
const { name, age } = user; // 对象解构
const [first, second] = [10, 20]; // 数组解构

5. 扩展运算符 (Spread Operator ...)

数组合并、对象复制,三个点全搞定。

const arr1 = [1, 2];
const obj1 = { a: 1 };

// 数组合并
const arr2 = [...arr1, 3, 4]; // [1, 2, 3, 4]

// 对象浅拷贝/合并
const obj2 = { ...obj1, b: 2 }; // { a: 1, b: 2 }

6. 数组新方法 (Array Methods)

查找元素从未如此简单,告别 for 循环。

const arr = [1, 2, 3, 4];

// find: 返回第一个匹配的值
const found = arr.find(x => x > 2); // 3

// Array.from: 将类数组对象转为数组
const newArr = Array.from('foo'); // ["f", "o", "o"]

7. 字符串新方法 (String Methods)

判断字符串包含关系,不再需要 indexOf() !== -1

const str = 'Hello World';

// includes: 是否包含
str.includes('Hello'); // true

// startsWith / endsWith: 开头/结尾判断
str.startsWith('He'); // true
str.endsWith('ld');   // true

8. Promise (异步编程)

解决回调地狱(Callback Hell)的神器,异步操作更优雅。

// 模拟异步请求
const getData = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve('Data Loaded'), 1000);
  });
};

// 使用
getData().then(res => console.log(res));

9. 模块化 (Modules - Import/Export)

组件化开发的基础,彻底告别全局变量污染。

// lib.js (导出)
export const pi = 3.14;
export const add = (a, b) => a + b;

// main.js (导入)
import { pi, add } from './lib.js';
console.log(add(pi, 1));

10. 类 (Classes)

虽然 JS 本质是原型继承,但 Class 写法让面向对象编程(OOP)更直观。

// ES6
class Animal {
  constructor(name) {
    this.name = name;
  }
  
  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

const dog = new Animal('Doggy');
dog.speak();

总结

掌握这 10 个特性,足以应对 90% 的日常前端开发场景。ES6 不仅是语法糖,更是提升代码可读性和维护性的利器。赶紧在项目中用起来吧!

喜欢这篇文章的话,欢迎点赞、收藏、关注!

Lodash 源码解读与原理分析 - Lodash 静态方法与原型方法

作者 Anita_Sun
2026年1月16日 09:01

一、核心概念与区别

本部分先明确静态方法与原型方法的核心定义、特性差异,为后续挂载、调用逻辑铺垫,所有细节完整保留。

维度 静态方法(Static Methods) 原型方法(Prototype Methods)
定义 直接挂载在 _ 对象上的独立函数,无需包装对象 挂载在 lodash.prototype 或继承链上的方法
调用方式 _.map([1,2,3], fn) _([1,2,3]).map(fn)
返回值 直接返回计算结果,非包装对象 依据上下文返回包装对象(链式)或原始结果(自动解包)
实现特点 直接执行操作,无队列管理;核心逻辑原子实现 操作入队延迟执行;复用静态方法逻辑,新增链式能力
底层逻辑 Lodash 功能的 “原子层”,所有核心业务逻辑先落地 静态方法的 “链式适配层”,仅做队列管理与上下文判断
性能特征 无包装对象开销,单次操作性能高 10%~15%(100 万次 map:~8ms) 首次有包装初始化开销;多步骤链式操作通过惰性求值降本 50%+(100 万次 map+filter:惰性~12ms vs 普通~85ms)
适用场景 单次简单操作、性能敏感场景 多步骤链式操作、可读性优先场景
  1. 静态方法:是 Lodash 功能的基础,原型方法完全依赖其核心逻辑,保证代码复用性;支持多类型输入(数组 / 对象 / 类数组),根据输入类型选择最优实现。
  2. 原型方法:核心载体是 LodashWrapper 实例,通过 __actions__ 队列管理操作;链式状态由 __chain__ 标志控制(true 强制返回包装对象,false 自动解包)。

二、方法挂载与静态、原型方法的关系

2.1 静态方法的挂载与实现

核心逻辑:直接定义函数并挂载到 _ 对象,核心逻辑分层实现(类型判断 + 迭代器标准化),是所有功能的 “基础原子”。

(1)完整实现源码

// 示例:map 静态方法(源码级实现)
function map(collection, iteratee) { 
  // 标准化迭代器:支持函数/对象/字符串等形式(如 _.map(arr, 'name'))
  var normalizedIteratee = getIteratee(iteratee, 3);
  // 类型适配:数组用高性能实现,其他用通用实现
  var func = isArray(collection) ? arrayMap : baseMap; 
  return func(collection, normalizedIteratee); 
}
// 挂载到 _ 对象
lodash.map = map;

// 辅助函数1:数组 map 高性能实现
function arrayMap(array, iteratee) {
  var index = -1,
      length = array == null ? 0 : array.length,
      result = Array(length);

  while (++index < length) {
    result[index] = iteratee(array[index], index, array);
  }
  return result;
}

// 辅助函数2:通用 map 实现(兼容对象/类数组)
function baseMap(collection, iteratee) {
  var index = -1,
      result = [];
  baseEach(collection, function(value, key, collection) {
    result[++index] = iteratee(value, key, collection);
  });
  return result;
}

// 其他静态方法挂载(完整列表)
lodash.filter = filter;
lodash.reduce = reduce;
lodash.find = find;
lodash.forEach = forEach;
// ... 所有核心静态方法

(2)静态方法调用示例

// 1. 基础数组操作
var arrResult = _.map([1,2,3], n => n*2); // [2,4,6]

// 2. 对象集合操作
var objResult = _.map({a:1, b:2}, (v,k) => k + ':' + v); // ['a:1', 'b:2']

// 3. 迭代器标准化(字符串形式)
var userResult = _.map([{name:'Tom'}, {name:'Jerry'}], 'name'); // ['Tom', 'Jerry']

// 4. 性能敏感场景(大数据量)
var bigData = new Array(1000000).fill(1);
console.time('static map');
_.map(bigData, n => n+1); // 耗时 ~8ms
console.timeEnd('static map');

2.2 原型方法的挂载与实现

核心前提:先构建扁平高效的原型链,再通过多种方式挂载方法;所有原型方法均复用静态方法的核心逻辑,仅新增 “链式适配” 能力。

(1)完整原型链结构

// 基础原型:所有包装器的方法根载体(仅存储通用方法,无实例属性)
function baseLodash() {}

// lodash 函数的原型指向 baseLodash.prototype(共享通用方法)
lodash.prototype = baseLodash.prototype;
lodash.prototype.constructor = lodash;

// LodashWrapper 继承自 baseLodash.prototype(扁平原型链,减少查找层级)
LodashWrapper.prototype = baseCreate(baseLodash.prototype);
LodashWrapper.prototype.constructor = LodashWrapper;

// LazyWrapper 同样继承自 baseLodash.prototype(保证接口统一)
LazyWrapper.prototype = baseCreate(baseLodash.prototype);
LazyWrapper.prototype.constructor = LazyWrapper;

(2)原型方法的四种挂载方式

方式 1:直接挂载核心控制方法

适用场景:链式控制、值提取、隐式解包相关方法,保证最高调用性能。

// 链式控制方法:显式开启链式模式
function wrapperChain() {
  this.__chain__ = true;
  return this;
}
lodash.prototype.chain = wrapperChain;

// 值提取核心方法:执行操作队列并返回结果
function wrapperValue() {
  // 缓存优化:避免重复执行
  if (this.__values__ !== undefined) {
    return this.__values__;
  }
  var result = baseWrapperValue(this.__wrapped__, this.__actions__);
  this.__values__ = result;
  return result;
}
lodash.prototype.value = wrapperValue;

// 隐式解包方法:覆盖原生方法实现自动解包
lodash.prototype.toJSON = lodash.prototype.valueOf = wrapperValue;
lodash.prototype.toString = function() {
  return this.value().toString();
};
方式 2:从 LazyWrapper 继承惰性求值方法

适用场景map/filter/take 等数组操作方法,支持惰性求值优化。

// 遍历 LazyWrapper 原型方法,批量挂载到 lodash.prototype
baseForOwn(LazyWrapper.prototype, function(func, methodName) {
  var lodashFunc = lodash[methodName];
  // 仅挂载已有静态方法的原型方法(保证 API 一致性)
  if (lodashFunc) {
    lodash.prototype[methodName] = function() {
      var value = this.__wrapped__,
          args = arguments,
          chainAll = this.__chain__,
          // 判断是否可使用惰性求值(数组/ LazyWrapper 实例)
          useLazy = isArray(value) || value instanceof LazyWrapper,
          // 拦截器:复用静态方法核心逻辑
          interceptor = function(value) {
            return lodashFunc.apply(lodash, [value].concat(args));
          };

      // 惰性求值分支:减少中间数组创建
      if (useLazy) {
        var lazyWrapper = value instanceof LazyWrapper ? value : new LazyWrapper(value);
        var result = func.apply(lazyWrapper, args);
        // 添加拦截器,保证结果与静态方法一致
        result.__actions__.push({ 'func': thru, 'args': [interceptor], 'thisArg': undefined });
        return new LodashWrapper(result, chainAll);
      }

      // 非惰性求值分支:直接添加到操作队列
      return this.thru(interceptor);
    };
  }
});

// 基础入队方法:thru(所有原型操作的底层依赖)
function wrapperThru(interceptor) {
  this.__actions__.push({
    'func': thru,
    'args': [interceptor],
    'thisArg': undefined
  });
  return this;
}
lodash.prototype.thru = wrapperThru;

// 通用 thru 执行函数
function thru(value, interceptor) {
  return interceptor(value);
}
方式 3:从 Array 继承原生数组方法

适用场景push/pop/sort 等数组原生方法,保持原生行为一致性。

// 遍历原生数组方法,适配为原型方法
arrayEach(['pop', 'push', 'shift', 'sort', 'splice', 'unshift'], function(methodName) {
  var arrayFunc = Array.prototype[methodName],
      // tap/thru 区别:tap 不修改值,thru 修改值
      chainMethod = /^(?:push|sort|unshift)$/.test(methodName) ? 'tap' : 'thru',
      // 是否直接返回原始值(如 pop 返回被删除元素)
      retUnwrapped = /^(?:pop|shift)$/.test(methodName);

  lodash.prototype[methodName] = function() {
    var args = arguments,
        chainAll = this.__chain__,
        currentValue = this.value(); // 先解包获取原始值

    // 非链式模式:直接执行并返回原始结果
    if (retUnwrapped && !chainAll) {
      return arrayFunc.apply(isArray(currentValue) ? currentValue : [], args);
    }

    // 链式模式:添加到操作队列
    return this[chainMethod](function(value) {
      return arrayFunc.apply(isArray(value) ? value : [], args);
    });
  };
});
方式 4:挂载别名方法(兼容旧版 API)

适用场景:简化调用、兼容旧版 API,如 first 等价于 head

// 常用别名:first 等价于 head
lodash.prototype.first = lodash.prototype.head;
// last 等价于 takeRight(1)
lodash.prototype.last = function() {
  return this.takeRight(1).value()[0];
};
// 旧版 API 兼容:pluck 等价于 map(提取对象属性)
lodash.prototype.pluck = function(property) {
  return this.map(function(item) {
    return get(item, property);
  });
};

2.3 静态方法与原型方法的核心关系

在完成 “静态方法挂载(原子层)” 和 “原型方法挂载(适配层)” 后,明确两者的依赖逻辑,让 “复用 - 适配” 的关系更清晰:

  1. 核心依赖:原型方法完全依赖静态方法的核心逻辑,仅做 “链式适配”(如入队、延迟执行、自动解包),不重复实现任何业务逻辑;
  2. 完整链路_().map(fn) → 原型方法 map → 生成拦截器函数 → 调用静态方法 _.map(fn) → 执行核心逻辑 → 返回结果;
  3. 双向兼容:静态方法可独立使用(不依赖任何原型方法),保证 Lodash 的 “最小可用集”;原型方法必须依赖静态方法,避免逻辑冗余。

2.4 方法分类与挂载核心规则

(1)方法完整分类表

方法类型 示例 挂载位置 调用方式 返回值 核心特点 对应挂载方式
静态方法 _.map/_.filter lodash 对象 _.map([], fn) 原始结果 无包装开销,直接执行 直接挂载到 _ 对象
链式控制方法 chain/value lodash.prototype _().chain() 包装对象 / 原始结果 控制链式调用行为 直接挂载到原型
惰性原型方法 map/filter/take lodash.prototype _().map(fn) 包装对象 / 自动解包 支持惰性求值,减少中间数组 从 LazyWrapper 继承
值原型方法 first/last/pop lodash.prototype _().first() 原始结果 自动解包,直接返回单个值 从 LazyWrapper/Array 继承
数组原型方法 push/sort/splice lodash.prototype _().push(4) 包装对象 / 原始结果 兼容原生数组方法行为 从 Array 继承
别名原型方法 first(head)/pluck lodash.prototype _().first() 包装对象 / 原始结果 兼容旧版 API,简化调用 直接赋值别名

(2)挂载核心规则

  1. 一致性优先:原型方法与静态方法参数签名完全一致(如 _.map_().map 的参数、返回值逻辑完全相同),保证调用体验统一;
  2. 性能分层:核心控制方法(chain/value)直接挂载到原型(最快调用速度),集合操作方法从 LazyWrapper 继承(支持惰性优化),数组方法适配原生(保持行为一致);
  3. 兼容兜底:保留 pluck/first 等旧版别名方法,保证老项目平滑升级;
  4. 惰性适配:仅数组 / 类数组操作启用惰性求值,对象 / 字符串等类型使用通用逻辑(避免过度优化);
  5. 自动解包:“值提取类” 方法(first/pop)强制解包返回原始值,“操作类” 方法(map/filter)根据 __chain__ 状态决定是否解包。

三、链式调用与自动解包机制

在明确 “挂载与关系” 后,讲解原型方法的核心调用逻辑(链式 + 解包),逻辑上 “先讲挂载实现,再讲调用行为”,更符合认知规律。

3.1 链式调用的实现原理

(1)包装对象的创建

// lodash 入口函数:创建/复用包装对象
function lodash(value) {
  // 复用已有包装对象(减少初始化开销)
  if (isObjectLike(value) && !isArray(value) && !(value instanceof LazyWrapper)) {
    if (value instanceof LodashWrapper) {
      return value;
    }
    if (hasOwnProperty.call(value, '__wrapped__')) {
      return wrapperClone(value);
    }
  }
  // 创建新的 LodashWrapper 实例
  return new LodashWrapper(value);
}

// 包装对象克隆方法(避免修改原对象)
function wrapperClone(wrapper) {
  var result = new LodashWrapper(wrapper.__wrapped__, wrapper.__chain__);
  result.__actions__ = copyArray(wrapper.__actions__);
  result.__index__ = wrapper.__index__;
  result.__values__ = wrapper.__values__;
  return result;
}

// LodashWrapper 构造函数(链式调用核心载体)
function LodashWrapper(value, chainAll) {
  this.__wrapped__ = value;        // 存储原始值/嵌套包装对象
  this.__actions__ = [];           // 操作队列:存储待执行操作
  this.__chain__ = !!chainAll;     // 链式标志:控制返回值类型
  this.__index__ = 0;              // 遍历索引:辅助迭代器方法
  this.__values__ = undefined;     // 结果缓存:避免重复执行
}

(2)操作入队与执行

① 操作入队:原型方法调用的核心逻辑

map 为例,所有原型方法最终都会通过 thru 方法将操作加入 __actions__ 队列:

// 原型方法 map 的入队逻辑(简化版)
lodash.prototype.map = function(iteratee) {
  var interceptor = function(value) {
    return lodash.map(value, iteratee); // 复用静态方法
  };
  return this.thru(interceptor); // 加入操作队列
};

// 入队后的队列结构示例(map+filter)
// __actions__ = [
//   { func: thru, args: [interceptorMap], thisArg: undefined },
//   { func: thru, args: [interceptorFilter], thisArg: undefined }
// ]
② 操作执行:value() 方法的核心逻辑
// 操作队列执行核心方法
function baseWrapperValue(value, actions) {
  var result = value;
  // 处理惰性求值包装对象(先执行 LazyWrapper 逻辑)
  if (result instanceof LazyWrapper) {
    result = result.value();
  }
  // 遍历操作队列,依次执行所有操作
  return arrayReduce(actions, function(result, action) {
    return action.func.apply(action.thisArg, arrayPush([result], action.args));
  }, result);
}

// 执行流程示例(map+filter)
// 初始 result = [1,2,3]
// 第一步:执行 map 拦截器 → [2,4,6]
// 第二步:执行 filter 拦截器 → [4,6]
// 最终返回 [4,6]

3.2 自动解包机制

(1)隐式转换的底层实现

Lodash 通过覆盖 valueOf/toJSON/toString 方法,利用 JavaScript 原生隐式转换机制实现自动解包,无需用户手动调用 value()

(2)自动解包的触发场景

触发场景 示例代码 底层原理
直接输出包装对象 console.log(_([1,2,3]).map(n=>n*2)) 触发 valueOf() → 执行操作队列 → 返回结果
JSON 序列化 JSON.stringify(_({name:'Tom'}).pick(['name'])) 触发 toJSON() → 执行队列 → 返回原始对象
算术运算 _([1,2,3]).reduce((a,b)=>a+b) + 10 触发 valueOf() → 执行 reduce → 计算结果
比较操作 _([1,2,3]).map(n=>n*2) == [2,4,6] 触发 valueOf() → 执行 map → 比较结果
数组拼接 [0].concat(_([1,2,3]).map(n=>n*2)) 触发 valueOf() → 执行 map → 拼接数组

3.3 链式调用完整流程示例

本部分通过完整示例,验证 “挂载 - 关系 - 调用 - 解包” 的全链路逻辑,内容完整且可复现。

(1) 输入输出示例

var wrapped = _([1, 2, 3])
  .map(function(n) { return n * 2; })
  .filter(function(n) { return n > 2; });
console.log(wrapped); // 隐式解包 → [4, 6]
console.log(wrapped.value()); // 显式解包 → [4, 6]

(2) 执行流程详解

  1. 步骤 1:创建包装对象

    • 调用 _([1,2,3]) → 检测输入为数组 → 创建 LodashWrapper 实例;

    • 内部状态

      {
        __wrapped__: [1,2,3],
        __actions__: [],
        __chain__: false,
        __index__: 0,
        __values__: undefined
      }
      
  2. 步骤 2:调用 map 方法 → 操作入队

    • 创建拦截器(调用 _.map)→ 通过 thru 加入 __actions__ 队列;

    • 内部状态更新

      {
        __wrapped__: [1,2,3],
        __actions__: [
          { func: thru, args: [interceptorMap], thisArg: undefined }
        ],
        __chain__: false,
        __index__: 0,
        __values__: undefined
      }
      
    • 返回新的 LodashWrapper 实例,保持链式。

  3. 步骤 3:调用 filter 方法 → 操作入队

    • 创建拦截器(调用 _.filter)→ 通过 thru 加入 __actions__ 队列;

    • 内部状态更新

      {
        __wrapped__: [1,2,3],
        __actions__: [
          { func: thru, args: [interceptorMap], thisArg: undefined },
          { func: thru, args: [interceptorFilter], thisArg: undefined }
        ],
        __chain__: false,
        __index__: 0,
        __values__: undefined
      }
      
    • 返回新的 LodashWrapper 实例(wrapped 变量指向该实例)。

  4. 步骤 4:隐式解包 → console.log(wrapped)

    • 触发 valueOf() → 调用 wrapperValue() → 检测 __values__undefined

    • 调用 baseWrapperValue → 遍历 __actions__ 队列执行:

      • 第一步:interceptorMap[2,4,6]
      • 第二步:interceptorFilter[4,6]
    • 缓存结果到 __values__ → 返回 [4,6]console.log 输出。

  5. 步骤 5:显式解包 → wrapped.value()

    • 调用 wrapperValue() → 检测 __values__ 已缓存 → 直接返回 [4,6]

四、设计优势与技术要点

本部分整合 “设计优势、性能数据、技术要点”,是对前面 “挂载 - 调用” 逻辑的深度提炼,内容完整且有升华。

4.1 设计优势

优势维度 具体表现 性能数据支撑
API 灵活性 双轨调用(静态 / 原型),无缝切换;隐式解包减少心智负担 静态方法单次操作快 10%~15%;原型方法链式操作更直观
性能优化 惰性求值减少中间数组;缓存复用避免重复执行;类型适配选择最优实现 100 万元素 map+filter+take:惰性~12ms vs 普通~85ms
代码复用 核心逻辑在静态方法实现,原型方法仅做适配,减少冗余 原型方法与静态方法共享 100% 业务逻辑,代码量减少 30%+
用户体验 自动解包;别名兼容;API 一致性;链式调用流畅 无需记忆 value() 调用时机;旧版代码无需修改即可运行

4.2 核心技术要点

  1. 包装器模式:用 LodashWrapper 包装原始值,将 “数据” 与 “操作” 解耦;操作仅描述 “做什么”,而非 “什么时候做”,实现延迟执行。
  2. 操作队列管理__actions__ 队列存储标准化操作对象(func/args/thisArg);统一执行逻辑,支持任意操作的组合与复用。
  3. 惰性求值优化LazyWrapper 存储操作描述,__takeCount__ 控制短路遍历,__dir__ 支持正反向遍历;大数据场景下性能提升 70%+,内存占用降低 80%+。
  4. 扁平原型链设计:所有包装器直接继承 baseLodash.prototype,减少原型查找层级;方法查找速度比深层原型链快 20%+,继承关系清晰。
  5. 隐式转换机制:覆盖 valueOf/toJSON 方法,融入 JavaScript 原生隐式转换;无侵入式解包,无需修改用户调用方式。
  6. 上下文感知:通过 __chain__ 标志、方法类型、集合类型动态决定返回值;在 “易用性” 与 “性能” 之间动态平衡。

五、总结

Lodash 的静态方法与原型方法设计是其 API 强大且灵活的核心,整体逻辑可总结为 “三层架构”:

  1. 原子层(静态方法) :所有核心业务逻辑落地于此,无包装开销、直接执行,是性能与功能的基础;
  2. 适配层(原型方法挂载) :复用静态方法逻辑,通过 “链式适配”(入队、延迟执行、惰性求值)实现流畅的链式调用;
  3. 调用层(链式 + 解包) :通过 LodashWrapper 管理操作队列,利用隐式转换实现自动解包,兼顾易用性与性能。

这种设计深度利用了 JavaScript 的语言特性,不仅让 Lodash 成为前端工具库的典范,也为工具库开发、业务代码编写提供了关键参考:核心逻辑中心化、多形态调用适配、性能与易用性动态平衡。

从 JavaScript 到 WGSL:用渐变渲染理解 GPU 编程思维

作者 听风说图
2026年1月16日 07:16

本文通过一个真实的渐变渲染案例,帮助习惯 JavaScript/TypeScript 的 CPU 侧程序员快速建立 GPU Shader 编程的心智模型。

引言:为什么要学 Shader?

作为前端或后端开发者,我们习惯了"顺序执行"的编程思维——代码从上到下一行行执行,循环遍历数组,逐个处理数据。但当你需要渲染成千上万个像素时,这种方式太慢了。

GPU 的核心优势是并行:它可以同时处理数千个像素,每个像素独立运行相同的代码(Shader)。理解这一点,是从 CPU 编程迁移到 GPU 编程的关键。


1. 思维转换:从"循环"到"并行"

JavaScript 思维(CPU)

假设你要给一个 200×200 的矩形填充渐变色,在 JS 中你可能会这样写:

// CPU 思维:顺序遍历每个像素
for (let y = 0; y < 200; y++) {
    for (let x = 0; x < 200; x++) {
        const t = x / 200;  // 计算渐变位置 [0, 1]
        const color = interpolateColor(startColor, endColor, t);
        setPixel(x, y, color);
    }
}

这段代码需要执行 40,000 次循环,每次调用 setPixel

WGSL 思维(GPU)

在 Shader 中,你不需要写循环。GPU 会自动为每个像素启动一个独立的"线程",每个线程只负责计算自己那一个像素的颜色:

@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
    // 我是谁?GPU 告诉我我正在处理的像素坐标
    let localPos = input.localPos;  // 比如 (100, 50)
    
    // 计算我这个像素的渐变位置
    let t = localPos.x / 200.0;
    
    // 返回我这个像素应该显示的颜色
    return mix(startColor, endColor, t);
}

💡 mix 函数详解

mix(a, b, t) 是 GPU 内置的线性插值函数,等价于 a * (1 - t) + b * t

JavaScript 等价实现

function mix(ab, t) {
    return a * (1 - t) + b * t;
}
// 当 t=0 时返回 a,t=1 时返回 b,t=0.5 时返回 a 和 b 的中间值

mix 的妙用:替代 if 语句

在 GPU 中,if 分支会导致性能问题(后文详述)。mix 可以优雅地替代某些条件判断:

// ❌ 有分支的写法
if (isHovered) { color = hoverColor; } else { color = normalColor; }

// ✅ 无分支的写法(isHovered 为 0.0 或 1.0)
color = mix(normalColor, hoverColor, f32(isHovered));

核心区别

对比项 JavaScript (CPU) WGSL (GPU)
执行模式 一个线程,循环 40,000 次 40,000 个线程,每个执行 1 次
数据访问 可以访问任意像素 只知道"我自己"的坐标
返回值 调用 setPixel return 颜色值

💡 类比:想象你是工厂里的一个工人(GPU 线程),你只负责给传送带上经过你面前的那一个产品上色。你不知道也不关心其他工人在干什么,你只需要知道"我这个产品应该是什么颜色"。


2. Shader 的两个阶段:Vertex 和 Fragment

GPU 渲染管线主要分两步,对应两种 Shader:

2.1 Vertex Shader(顶点着色器)—— "形状在哪里?"

@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
    var output: VertexOutput;
    
    // 1. 将顶点从模型空间变换到屏幕空间
    let pos = uniforms.transform * vec3<f32>(input.position, 1.0);
    output.position = vec4<f32>(pos.xy, 0.0, 1.0);
    
    // 2. 把原始坐标传给 Fragment Shader
    output.localPos = input.position;
    
    return output;
}

职责:处理几何图形的顶点(三角形的三个角),决定它们在屏幕上的位置。

类比:如果把渲染比作"填色游戏",Vertex Shader 就是"画轮廓线"的步骤。

为什么需要空间变换?

你可能会问: "为什么不能直接用顶点坐标画图?" 答案是:可以,但只能画固定位置、固定大小的图形。

举个例子:假设你定义了一个 100×100 的正方形,顶点坐标是 (0,0), (100,0), (100,100), (0,100)

场景 不用变换 用变换矩阵
移动到 (200, 150) 重新计算 4 个顶点坐标 只需修改矩阵的平移分量
放大 2 倍 重新计算 4 个顶点坐标 只需修改矩阵的缩放分量
旋转 45° 三角函数重算所有顶点 只需修改矩阵的旋转分量
同时移动+缩放+旋转 代码爆炸 💥 矩阵相乘,一行搞定

JavaScript 类比

// ❌ 不用变换:每次都要重新算坐标
function drawSquare(x, y, size, rotation) {
    const cos = Math.cos(rotation), sin = Math.sin(rotation);
    const points = [
        [x + 0 * cos - 0 * sin, y + 0 * sin + 0 * cos],
        [x + size * cos - 0 * sin, y + size * sin + 0 * cos],
        // ... 太复杂了
    ];
}

// ✅ 用变换矩阵:顶点数据不变,只改矩阵
const vertices = [[0,0], [100,0], [100,100], [0,100]];  // 永远不变
const transform = mat3.multiply(translate, rotate, scale);  // 组合变换

核心优势

  1. 顶点数据可复用 —— 同一个正方形的顶点数据可以被缓存,画 1000 个正方形只需要传 1000 个不同的矩阵
  2. 变换可组合 —— 父子节点的变换通过矩阵乘法自动传递
  3. GPU 友好 —— 矩阵乘法是 GPU 最擅长的运算

2.2 Fragment Shader(片元着色器)—— "像素是什么颜色?"

@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
    // 根据位置计算颜色
    if (uniforms.paintType == 0u) {
        return uniforms.color;  // 纯色填充
    }
    
    // 渐变填充...
    let gradPos = (uniforms.gradientTransform * vec3<f32>(input.localPos, 1.0)).xy;
    let t = gradPos.x;
    return interpolateGradient(t);
}

职责:对三角形内部的每个像素计算颜色。这是 GPU 并行威力最大的地方。

类比:Fragment Shader 就是"填色"步骤,为轮廓线内的每个格子决定颜色。


3. 数据传递:CPU 如何与 GPU 通信?

在 JavaScript 中,函数之间通过参数和返回值通信。但 GPU 是一个独立的硬件,数据需要显式地"打包发送"。

3.1 Uniform:全局只读数据

struct Uniforms {
    transform: mat3x3<f32>,      // 变换矩阵
    color: vec4<f32>,            // 颜色
    gradientTransform: mat3x3<f32>,
    paintType: u32,              // 0: 纯色, 1: 线性渐变, 2: 径向渐变
    stopCount: u32,
    stops: array<GradientStop, 8>,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;

特点

  • 所有线程共享同一份数据(所有像素看到的 uniforms.color 是一样的)
  • 只读——Shader 不能修改它

JavaScript 对应概念:类似于"全局常量"或"配置对象"。

3.2 CPU 侧打包数据(TypeScript)

// 创建一个 ArrayBuffer,按照 GPU 要求的内存布局填充数据
const uniformData = new Float32Array(96);  // 384 bytes

// 填充变换矩阵 (mat3x3 在 GPU 中占 48 bytes)
uniformData.set(transformMatrix, 0);

// 填充颜色 (vec4 占 16 bytes)
uniformData.set([r, g, b, a], 12);

// 上传到 GPU
device.queue.writeBuffer(uniformBuffer, 0, uniformData);

⚠️ 大坑预警:内存对齐 (std140)

GPU 对数据布局有严格要求。vec3 不是 12 字节,而是 16 字节mat3x3 不是 36 字节,而是 48 字节

这是 CPU 程序员最容易踩的坑。详见后文"调试技巧"。


4. 渐变实现:完整案例解析

让我们用一个真实的渐变渲染来串联上述知识。

4.1 数据结构

struct GradientStop {
    color: vec4<f32>,    // RGBA 颜色
    position: f32,       // 位置 [0, 1]
    _pad0: f32,          // 填充对齐
    _pad1: f32,
    _pad2: f32,
}

为什么要 _pad 因为 GPU 要求结构体按 16 字节对齐。vec4 是 16 字节,f32 是 4 字节,总共 20 字节,需要填充到 32 字节。

为什么 GPU 要求 16 字节对齐?

这是硬件架构决定的,主要原因有三:

  1. 内存读取效率:GPU 的内存控制器按 16 字节(128 位)为单位读取数据。如果数据跨越两个 16 字节边界,需要两次内存访问,性能直接减半。
  2. SIMD 指令集:GPU 使用 SIMD(单指令多数据)架构,一条指令同时处理 4 个 float(正好 16 字节)。对齐的数据可以直接加载到寄存器,不对齐则需要额外的移位操作。
  3. 缓存行优化:GPU 缓存行通常是 128 字节或 256 字节。16 字节对齐确保数据不会横跨缓存行,避免缓存失效。

JavaScript 类比

// 想象你有一个只能每次搬 4 瓶水的托盘(16 字节)
// ❌ 不对齐:3 瓶水放第一托盘,1 瓶放第二托盘 → 搬 4 瓶要跑两趟
// ✅ 对齐:4 瓶水放一个托盘,空位用泡沫填充 → 一趟搞定

std140 布局规则速记

类型 实际大小 对齐到 说明
f32 4 4
vec2 8 8
vec3 12 16 浪费 4 字节
vec4 16 16
struct 字段之和 最大字段对齐 × 整数倍 向上取整

4.2 坐标变换

从像素坐标到渐变参数 t 的转换是渐变的核心:

// 将像素坐标 (0~200) 变换到渐变空间 (0~1)
let gradPos = (uniforms.gradientTransform * vec3<f32>(input.localPos, 1.0)).xy;

// 线性渐变:水平方向的 x 坐标就是 t
let t = gradPos.x;

为什么需要渐变空间变换?

问题:假设一个 200×100 的矩形,像素坐标范围是 x: 0~200, y: 0~100。如何计算每个像素的渐变位置 t

最简单的方法t = x / 200,即 x=0 时 t=0(起始色),x=200 时 t=1(结束色)。

但这只能实现从左到右的水平渐变。如果设计师想要:

  • 从上到下的垂直渐变?
  • 45° 斜向渐变?
  • 从中心向外的径向渐变?

答案就是空间变换矩阵。通过矩阵,我们可以把任意方向的渐变统一为"从左到右"的计算:

渐变类型 变换矩阵的作用 最终 t 的计算
水平(左→右) 归一化 x: x/width t = gradPos.x
垂直(上→下) 旋转 90° + 归一化 t = gradPos.x(原来的 y 变成了 x)
45° 斜向 旋转 45° + 归一化 t = gradPos.x
镜像渐变 缩放 x 为 2 倍 + 偏移 t = abs(gradPos.x) 或矩阵实现

JavaScript 类比理解

// 不用矩阵:每种渐变写一套逻辑
function getGradientT_Horizontal(x, y, w, h) { return x / w; }
function getGradientT_Vertical(x, y, w, h) { return y / h; }
function getGradientT_Diagonal(x, y, w, h) { 
    // 45° 对角线...复杂的三角函数计算
}

// 用矩阵:统一为一套逻辑
function getGradientT(x, y, matrix) {
    const [gx, gy] = applyMatrix(matrix, [x, y]);
    return gx;  // 所有渐变都取变换后的 x
}

镜像渐变怎么做?

镜像渐变(如 红→蓝→红)可以通过两种方式实现:

方法 1:修改 Shader 逻辑

// 将 t 从 [0, 1] 映射为 [0, 1, 0](三角波)
let t_mirror = 1.0 - abs(gradPos.x * 2.0 - 1.0);

方法 2:通过矩阵实现(更灵活)

// CPU 侧:构造一个"折叠"矩阵
// 将 [0, 0.5] 映射到 [0, 1][0.5, 1] 映射到 [1, 0]

这样设计的优势

  1. Shader 代码简洁 —— 无论什么方向的渐变,Shader 里永远是 t = gradPos.x
  2. 设计工具友好 —— Figma 导出的 gradientTransform 可以直接使用
  3. 可组合 —— 旋转、缩放、镜像可以通过矩阵乘法任意组合

4.3 颜色插值

// 线性渐变取 x,径向渐变取距离
var t: f32 = 0.0;
if (uniforms.paintType == 1u) {
    t = gradPos.x;                // 线性:只看水平位置
} else if (uniforms.paintType == 2u) {
    t = length(gradPos);          // 径向:计算到中心的距离
}

为什么线性渐变取 x,不取 y?

因为我们已经通过 gradientTransform 把任意方向的渐变都"旋转"成了水平方向

渐变方向 矩阵变换后的效果 t 的计算
从左到右 x: 0→1, y: 不变 t = x
从上到下 原来的 y 变成新的 x t = x(实际是原来的 y)
45° 对角 对角线方向变成新的 x t = x(实际是对角距离)

如果同时用 x 和 y 会怎样?

// 实验:不同的 t 计算方式
t = gradPos.x;                    // 水平渐变
t = gradPos.y;                    // 垂直渐变
t = (gradPos.x + gradPos.y) / 2.0; // 45° 对角渐变(简化版)
t = length(gradPos);              // 径向渐变(圆形)
t = max(abs(gradPos.x), abs(gradPos.y)); // "方形"径向渐变
t = gradPos.x * gradPos.y;        // 双曲线渐变(艺术效果)

JavaScript 可视化理解

// 想象一个 10×10 的网格,计算每个格子的 t 值
for (let y = 0; y < 10; y++) {
    let row = '';
    for (let x = 0; x < 10; x++) {
        const t_horizontal = x / 9;                    // 0, 0.11, 0.22, ... 1
        const t_radial = Math.sqrt(x*x + y*y) / 12.7;  // 圆形扩散
        row += t_horizontal.toFixed(1) + ' ';
    }
    console.log(row);
}
// 在 stops 数组中找到 t 所在的区间,进行线性插值
for (var i: u32 = 0u; i < 7u; i = i + 1u) {
    if (i >= lastIdx) { break; }
    
    let s0 = uniforms.stops[i];
    let s1 = uniforms.stops[i+1];
    
    if (t >= s0.position && t <= s1.position) {
        let factor = (t - s0.position) / (s1.position - s0.position);
        return mix(s0.color, s1.color, factor);  // GPU 内置的线性插值
    }
}

JavaScript 等价代码

function interpolate(t, stops) {
    for (let i = 0; i < stops.length - 1; i++) {
        if (t >= stops[i].position && t <= stops[i+1].position) {
            const factor = (t - stops[i].position) / (stops[i+1].position - stops[i].position);
            return lerpColor(stops[i].color, stops[i+1].color, factor);
        }
    }
}

GPU 中的控制语句

WGSL 支持常见的控制语句,但性能特性与 JavaScript 完全不同

JavaScript WGSL 说明
if (cond) { A } else { B } if (cond) { A } else { B } 语法相同,但性能代价大
for (let i = 0; i < n; i++) for (var i: u32 = 0u; i < n; i = i + 1u) 需要显式类型
while (cond) { } while (cond) { } 相同
switch (x) { case 1: ... } switch (x) { case 1: { ... } default: { } } 每个分支必须有 {}
break / continue break / continue 相同
return return 相同

⚠️ 为什么 if 在 GPU 中代价大?

GPU 的并行模型要求同一组线程(Warp/Wave)执行相同的指令。当遇到分支时:

if (condition) {
    A();  // 部分线程执行这里
} else {
    B();  // 部分线程执行这里
}

实际发生的是:所有线程都执行 A 和 B,但结果被掩码丢弃。这叫做 Thread Divergence(线程分化)

性能优化替代方案

// ❌ 分支写法(两组线程各等待对方)
if (isHovered) { 
    color = hoverColor; 
} else { 
    color = normalColor; 
}

// ✅ 无分支写法(所有线程同时完成)
color = mix(normalColor, hoverColor, f32(isHovered));

// ✅ step 函数(阶跃函数,常用于边界判断)
// step(edge, x): x < edge 返回 0.0,否则返回 1.0
let mask = step(0.5, t);  // t < 0.5 时 mask=0,否则 mask=1
color = mix(colorA, colorB, mask);

// ✅ clamp + smoothstep(平滑过渡)
let t_clamped = clamp(t, 0.0, 1.0);  // 限制 t 在 [0, 1] 范围
let t_smooth = smoothstep(0.0, 1.0, t);  // 平滑的 S 曲线插值

何时可以用 if

  • 条件对所有像素相同(如 if (uniforms.paintType == 1u))—— 无分化,放心用
  • 分支内代码很短 —— 分化代价可接受
  • 无法用数学替代的复杂逻辑 —— 只能用 if

5. GPU 编程的"反直觉"特性

5.1 没有 console.log

在 Shader 中,你不能打印日志。这是最让 CPU 程序员抓狂的地方。

调试核心思路:将关键变量的值分支执行情况编码成可识别的颜色输出到屏幕上。

调试技巧:用颜色编码变量值

@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
    // ===== 技巧 1:输出坐标值 =====
    // 将坐标映射为颜色,检查坐标是否正确
    // return vec4<f32>(input.localPos.x / 200.0, input.localPos.y / 200.0, 0.0, 1.0);
    // 预期:左上角黑色,右下角黄色,形成渐变
    
    // ===== 技巧 2:检查分支执行 =====
    // 用不同颜色标记代码是否进入了某个分支
    // if (uniforms.paintType == 0u) { return vec4<f32>(1.0, 0.0, 0.0, 1.0); }  // 纯色 → 红
    // if (uniforms.paintType == 1u) { return vec4<f32>(0.0, 1.0, 0.0, 1.0); }  // 线性 → 绿
    // if (uniforms.paintType == 2u) { return vec4<f32>(0.0, 0.0, 1.0, 1.0); }  // 径向 → 蓝
    
    // ===== 技巧 3:检查变量范围 =====
    // 将 t 值输出为灰度,检查是否在 [0, 1] 范围内
    // let t = gradPos.x;
    // return vec4<f32>(t, t, t, 1.0);  // 预期:从黑到白的渐变
    // 如果全白/全黑 → t 超出范围,坐标变换有问题
    
    // ===== 技巧 4:二分法定位问题 =====
    // 在代码中间插入 return,逐步缩小问题范围
    // return vec4<f32>(1.0, 0.0, 0.0, 1.0);  // 红色检查点
    // ... 后续代码
    
    // 正常逻辑...
}
输出颜色 含义
纯红色 几何体正确绘制,或进入了"纯色"分支
纯绿色 进入了"线性渐变"分支
纯蓝色 进入了"径向渐变"分支
从黑到白渐变 t 值从 0 到 1 正常变化
全白 t 值始终 ≥ 1,坐标变换可能缩放错误
全黑 t 值始终 ≤ 0,坐标变换可能偏移错误
紫色(红+蓝) 进入了未知分支或错误路径

5.2 数据传输:理解"批量"与"Draw Call"

在 JavaScript 中,你可以随时修改变量:

color = 'red';
draw();
color = 'blue';
draw();

在 GPU 编程中,这涉及两个层面的理解:

层面 1:单次 Draw Call 内的数据共享

一次 draw() 调用会绘制一个(或一批)图形。在这次绘制中,所有像素共享同一份 Uniform 数据

如果你想画两个不同颜色的矩形,最简单的方式是:

// 方式 1:两次 Draw Call(伪代码)
setUniform({ color: 'red' });   // 内部调用 device.queue.writeBuffer()
draw(rect1Vertices);            // 第一次绘制

setUniform({ color: 'blue' });  // 再次调用 device.queue.writeBuffer()
draw(rect2Vertices);            // 第二次绘制

💡 setUniform 的本质

setUniform 不是 WebGPU 的原生 API,而是对以下操作的封装:

function setUniform(data) {
    // 1. 将 JS 对象转换为二进制数据(按 std140 对齐)
    const buffer = packToFloat32Array(data);
    
    // 2. 通过 WebGPU API 将数据从 CPU 内存拷贝到 GPU 内存
    device.queue.writeBuffer(uniformBuffer, 0, buffer);
}

device.queue.writeBuffer() 是真正触发 CPU→GPU 数据传输的 API。每次调用都会产生一定的开销(内存拷贝 + 驱动调用),这就是为什么要尽量减少调用次数。

层面 2:批量绘制优化

多个图形当然可以批量发送! 这正是性能优化的关键。常见方案:

方案 A:Dynamic Uniform Buffer(动态偏移)

// 将所有图形的数据打包到一个大 Buffer
const bigBuffer = new Float32Array([
    ...rect1Transform, ...rect1Color,  // 偏移 0
    ...rect2Transform, ...rect2Color,  // 偏移 384
    ...rect3Transform, ...rect3Color,  // 偏移 768
]);
device.queue.writeBuffer(uniformBuffer, 0, bigBuffer);

// 一次性发送,通过偏移切换数据
for (let i = 0; i < 3; i++) {
    passEncoder.setBindGroup(0, bindGroup, [i * 384]);  // 动态偏移
    passEncoder.draw(...);
}

方案 B:实例化渲染(Instancing)—— 终极批量

// 将变换矩阵放入 Storage Buffer
const transforms = new Float32Array([...所有图形的矩阵]);

// 一次 Draw Call 绘制 1000 个图形!
passEncoder.draw(vertexCount, instanceCount1000);
// Shader 中通过 instance_index 获取自己的数据
@vertex
fn vs_main(@builtin(instance_index) instanceIdx: u32, ...) {
    let myTransform = transforms[instanceIdx];
}

性能对比

方式 1000 个矩形的 Draw Call 数 适用场景
朴素方式 1000 原型开发
Dynamic Uniform 1000(但切换更快) 不同形状、不同材质
Instancing 1 大量相同形状

5.3 矩阵是"列主序"

JavaScript 思维(行主序):

const matrix = [
    [a, b, c],  // 第一行
    [d, e, f],  // 第二行
    [g, h, i],  // 第三行
];

GPU/WebGPU 思维(列主序):

const buffer = new Float32Array([
    a, d, g,  // 第一列
    b, e, h,  // 第二列
    c, f, i,  // 第三列
]);

🔥 这是最常见的坑:如果你的图形位置完全错误或消失,80% 是矩阵存储顺序的问题。


6. 快速参考:类型对照表

JavaScript WGSL 大小(字节) 对齐要求
number f32 4 4
number (整数) u32 / i32 4 4
[x, y] vec2<f32> 8 8
[x, y, z] vec3<f32> 12 16 ⚠️
[r, g, b, a] vec4<f32> 16 16
3x3 矩阵 mat3x3<f32> 36 48 ⚠️
4x4 矩阵 mat4x4<f32> 64 64

7. 总结:心智模型迁移清单

CPU 思维 GPU 思维
循环遍历所有像素 每个像素独立运行相同代码
console.log 调试 用颜色输出变量值
随意访问全局变量 数据打包成 Buffer 发送
结构体大小 = 字段大小之和 必须考虑对齐(16 字节边界)
行主序矩阵 列主序矩阵
if/else 分支随意写 分支会降低性能(所有线程等待)

掌握这些核心差异,你就能从 JavaScript 程序员平滑过渡到 Shader 开发者。接下来,建议你动手修改 shader.wgsl 中的代码,用"颜色调试法"亲身体验 GPU 编程的独特魅力!

更多精彩内容可关注风起的博客,微信公众号:听风说图

拒绝 "AnyScript":从 Todo List 实战看 TypeScript 的真香定律

2026年1月16日 01:34

前言: 最初接触 TypeScript 时,我并不理解为什么要给灵活的 JavaScript 套上"枷锁"。直到在维护复杂度逐渐提升的项目时,被层出不穷的类型错误困扰,才意识到类型系统带来的确定性有多重要。

最近系统学习了 TypeScript,并动手实现了一个经典的 Todo List Demo。这篇文章记录了我从理论到实践的学习过程,以及其中的一些思考。

一、 为什么我们要“自找麻烦”写 TS?

很多新手(包括以前的我)在安装完 TS 后,第一反应往往是: “写个代码还要先定义类型,这不是脱裤子放屁吗?”

但结合我的系统学习,TS 带来的爽点主要集中在三个方面:

  1. 上帝视角的 Bug 检测:JS 只有在运行时才会报错,而 TS 在你代码写下的那一刻,红色波浪线就开始预警了。这就好比有一个资深大佬坐在你旁边并在你按回车前说:“兄弟,这行代码会崩。”
  2. 智能提示(IntelliSense) :不用再凭记忆猜对象的属性名了,. 一下,所有可用的属性和方法全出来了。这才是真正的“代码补全”。
  3. 重构的底气:修改别人的代码或者几个月前的屎山时,改了接口字段,TS 会明确告诉你哪些文件受到了影响,不用全网搜字符串。

二、 核心概念速览

在进入实战前,我们先快速过一下笔记中的几个关键点,这些在后面的 Demo 中都会用到。

1. 基础类型与“救命稻草” Any

TS 的基础类型其实就是对 JS 的约束。

TypeScript

let age: number = 18;
let name: string = 'Tom';
// 数组泛型,这个写法很常见
let list: Array<string> = ['todo1', 'todo2']; 

重点说说 anyunknown。 刚开始写 TS,遇到报错不想解,直接一把梭用 any

TypeScript

let data: any = 1; 
data = "hello"; // 没问题
data.map(); // 编译不报错,运行直接炸

any 是放弃治疗,它让 TS 退化回了 JS。 而 unknown 是更安全的 any

TypeScript

let safeData: unknown = 1;
// safeData.hello(); // 报错!在使用前必须强制做类型收窄或断言

心得少用 any,那是饮鸩止渴。

2. 接口(Interface)与类型别名(Type)

这是定义数据结构的灵魂。在实战中,我们通常用 Interface 来约定对象。

TypeScript

interface User {
  readonly id: number; // 只读,创建后不可改,防止误操作
  name: string;
  age: number;
  hobby?: string; // 可选属性,不是每个人都有爱好
}

这比写文档管用多了,代码即文档。

3. 枚举(Enum)

笔记里提到了枚举,这在管理状态时非常有用,比如任务状态:

TypeScript

enum Status {
    Pending = 0,
    Success = 1,
    Failed = 2,
}
let currentStatus = Status.Pending;

这样代码里就不会出现莫名其妙的 "0" 或 "1" 这种魔术数字(Magic Number)。


三、 实战:构建一个类型安全的 Todo List

Talk is cheap, show me the code. 咱们来看看如何用 React + TS 搭建一个规范的项目。

1. 项目结构规划

好的结构是成功的一半。在这个 Demo 中,我采用了职责分离的模式:

Plaintext

src
 ├── components  // UI 组件 (纯展示,负责渲染和回调)
 ├── hooks       // 逻辑钩子 (负责状态管理和业务逻辑)
 ├── types       // 类型定义 (全局通用的类型契约)
 ├── utils       // 工具函数 (本地存储等)
 └── App.tsx     // 组装层

image.png

2. 定义核心类型 (The Contract)

一切始于类型。在 src/types/todo.ts 中,我们定义 Todo 对象的形状:

TypeScript

export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

一旦这个接口定义好了,全项目的组件在调用 Todo 数据时,都会受到严格监管。

3. 泛型的妙用:封装 LocalStorage

TS 最强大的特性之一就是泛型(Generics) 。简单理解,泛型就是类型的参数

src/utils/storages.ts 中,我封装了 localStorage。因为我不确定存进去的是字符串、数字还是对象数组,所以我用 T 来代表未来的类型:

TypeScript

// T 是一个占位符,调用的时候再决定它是什么
export function getStorage<T>(key: string, defaultValue: T): T {
    const value = localStorage.getItem(key);
    return value ? JSON.parse(value) : defaultValue;
}

export function setStorage<T>(key: string, value: T) {
    localStorage.setItem(key, JSON.stringify(value));
}

使用时的爽点

TypeScript

// TS 自动推断出 todos 是 Todo[] 类型,而不是 any
const todos = getStorage<Todo[]>('todos', []); 

4. 业务逻辑分离:Custom Hook

src/hooks/useTodos.ts 中,我们将状态逻辑抽离。这里要注意 useState 的泛型用法:

TypeScript

import { useState, useEffect } from 'react';
import type { Todo } from '../types/todo';
import { getStorage, setStorage } from '../utils/storages';

const STORAGE_KEY = 'todos';

export function useTodos() {
  // 明确告诉 TS,这个 state 存的是 Todo 数组
  const [todos, setTodos] = useState<Todo[]>(() => 
    getStorage<Todo[]>(STORAGE_KEY, [])
  );

  useEffect(() => {
    setStorage<Todo[]>(STORAGE_KEY, todos);
  }, [todos]);

  const addTodo = (title: string) => {
    const newTodo: Todo = {
      id: +new Date(),
      title,
      completed: false
    };
    setTodos([...todos, newTodo]);
  };

  // ... toggleTodo 和 removeTodo 逻辑省略,原理同上

  return { todos, addTodo, toggleTodo, removeTodo };
}

这样做的好处是,App.tsx 变得极其干净,只负责组装。

5. 组件开发:Props 的类型约束

这是 React + TS 最常见的场景。父子组件传值,必须把 Props 定义清楚。

TodoList.tsx 为例:

TypeScript

import * as React from 'react';
import type { Todo } from '../types/todo';
import TodoItem from './TodoItem';

// 定义组件需要接收什么,少传或错传都会报错
interface Props {
    todos: Todo[];
    onToggle: (id: number) => void; // 明确函数的参数和返回值
    onRemove: (id: number) => void;
}

// React.FC<Props> 显式声明这是一个函数式组件,且 Props 符合上面的接口
const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
    return (
        <ul>
            {todos.map((todo) => (
                <TodoItem
                    key={todo.id}
                    todo={todo}
                    onToggle={onToggle}
                    onRemove={onRemove}
                />
            ))}
        </ul>
    );
};
export default TodoList;

TodoInput.tsx 中,我们还处理了表单事件:

TypeScript

interface Props {
  onAdd: (title: string) => void;
}

const TodoInput: React.FC<Props> = ({ onAdd }) => {
  const [value, setValue] = React.useState<string>('');

  const handleAdd = () => {
    if (!value.trim()) return;
    onAdd(value);
    setValue('');
  };

  return (
    <div>
      <input 
        value={value} 
        onChange={e => setValue(e.target.value)} // TS 知道 e 是 ChangeEvent
      />
      <button onClick={handleAdd}>添加</button>
    </div>
  );
};

四、 总结与思考

通过这个简单的 Demo,我们可以看到 TypeScript 是如何渗透到开发的每一个环节的:

  1. Utils 层:利用泛型实现了高度可复用的存储逻辑。
  2. Types 层:利用接口定义了数据契约,防止字段拼写错误。
  3. Components 层:利用 React.FCProps 接口,确保了组件通信的安全性。

从 JS 转到 TS,确实存在“阵痛期” 。你要多写很多代码,要处理很多类型报错。但正如我在笔记里记下的: “TypeScript 是强类型静态语言,它能让你编写出更干净、更健壮的代码。”

这是一种“长期主义”的胜利。当你接手一个庞大的项目,或者隔了一个月再看自己的代码,发现每一个变量的含义都清晰可见,每一次重构都信心满满时,你会由衷地感叹:

TypeScript,真香!


写在最后: 学习 TS 是一场持久战,不要因为繁琐的配置和报错而劝退。先从最简单的类型定义开始,慢慢尝试泛型和高级类型。

map和Object有什么区别

作者 代码猎人
2026年1月15日 23:58

Map 和 Object 都是键值对集合,但在设计和使用上有重要区别:

1. 核心区别对比表

特性 Map Object
键的类型 任意类型(对象、函数、NaN等) String 或 Symbol(其他类型会被转为字符串)
键的顺序 保持插入顺序 ES6 后也保持插入顺序,但有例外
大小获取 .size 属性直接获取 需要计算:Object.keys(obj).length
默认键 无默认键,完全是空的 有原型链,可能包含继承的键
性能 频繁增删时性能更好 频繁增删时性能较差
迭代 直接可迭代(for...of 需要先获取键数组再迭代
序列化 不能直接 JSON 序列化 可直接 JSON 序列化
垃圾回收 强引用(除非用 WeakMap) 强引用

2. 详细对比

键的类型

// Map:键可以是任意类型
const map = new Map();
const objKey = { id: 1 };
const funcKey = function() {};
const arrKey = [1, 2];
const nanKey = NaN;

map.set(objKey, '对象键');
map.set(funcKey, '函数键');
map.set(arrKey, '数组键');
map.set(nanKey, 'NaN键'); // ✅ NaN 作为键
map.set(null, 'null键');
map.set(undefined, 'undefined键');

// Object:键会被转换为字符串
const obj = {};
obj[objKey] = '对象键'; // 键被转为 "[object Object]"
obj[funcKey] = '函数键'; // 键被转为 "function() {}"
obj[arrKey] = '数组键'; // 键被转为 "1,2"
obj[null] = 'null键'; // 键被转为 "null"
obj[undefined] = 'undefined键'; // 键被转为 "undefined"

顺序保证

const map = new Map();
map.set('a', 1);
map.set('b', 2);
map.set('0', 3); // 数字字符串键
map.set('1', 4);
map.set('c', 5);

// Map:严格保持插入顺序
console.log([...map.keys()]); // ['a', 'b', '0', '1', 'c']

const obj = {};
obj['a'] = 1;
obj['b'] = 2;
obj['0'] = 3; // 数字字符串键
obj['1'] = 4;
obj['c'] = 5;

// Object:整数属性(可转为数字的字符串)会按数字顺序排列
console.log(Object.keys(obj)); // ['0', '1', 'a', 'b', 'c']

原型链问题

// Object 有原型链继承
const obj = {};
console.log(obj.toString); // ƒ toString() { [native code] } - 来自原型

// 可能导致意外的键冲突
obj['constructor']; // 可能不是你设置的值,而是继承的
obj['__proto__']; // 特殊属性

// Map 完全没有这个问题
const map = new Map();
map.set('constructor', '这是我设置的构造函数');
map.set('__proto__', '这是我设置的属性');
console.log(map.get('constructor')); // '这是我设置的构造函数'
console.log(map.get('__proto__')); // '这是我设置的属性'

性能对比

// 频繁增删场景 - Map 性能更好
const map = new Map();
const obj = {};

console.time('Map 添加');
for (let i = 0; i < 100000; i++) {
  map.set(i, i);
}
console.timeEnd('Map 添加'); // 通常更快

console.time('Object 添加');
for (let i = 0; i < 100000; i++) {
  obj[i] = i;
}
console.timeEnd('Object 添加');

// 频繁删除场景 - Map 优势更明显
console.time('Map 删除');
for (let i = 0; i < 100000; i++) {
  map.delete(i);
}
console.timeEnd('Map 删除');

console.time('Object 删除');
for (let i = 0; i < 100000; i++) {
  delete obj[i];
}
console.timeEnd('Object 删除'); // 通常更慢

迭代方式

const map = new Map([
  ['name', 'Alice'],
  ['age', 25]
]);

const obj = {
  name: 'Alice',
  age: 25
};

// Map:直接可迭代
for (let [key, value] of map) {
  console.log(key, value);
}

// Map 的便捷方法
map.forEach((value, key) => console.log(key, value));

// Object:需要先获取键数组
for (let key in obj) {
  if (obj.hasOwnProperty(key)) { // 需要过滤原型链
    console.log(key, obj[key]);
  }
}

// 或使用 Object 方法
Object.keys(obj).forEach(key => console.log(key, obj[key]));
Object.entries(obj).forEach(([key, value]) => console.log(key, value));

3. 使用场景建议

优先使用 Map 的情况:

// 1. 键不是字符串或 Symbol
const userRoles = new Map();
userRoles.set(user1, 'admin'); // 对象作为键
userRoles.set(user2, 'editor');

// 2. 频繁增删键值对
const cache = new Map(); // 缓存系统
cache.set(key, value);
cache.delete(key);

// 3. 需要保持严格的插入顺序
const orderedMap = new Map();
orderedMap.set('z', 1);
orderedMap.set('a', 2); // 遍历时保证 z 在 a 之前

// 4. 键值对数量需要频繁获取
if (map.size > 100) { } // 直接获取

// 5. 避免与原型链属性冲突
const safeMap = new Map();
safeMap.set('constructor', '不会冲突');
safeMap.set('__proto__', '安全的');

优先使用 Object 的情况:

// 1. 简单的键值对,键都是字符串/Symbol
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
};

// 2. 需要 JSON 序列化/反序列化
const data = { name: 'Alice', age: 25 };
const json = JSON.stringify(data); // 直接支持
const parsed = JSON.parse(json);

// Map 需要转换
const mapData = new Map([['name', 'Alice'], ['age', 25]]);
const mapJson = JSON.stringify([...mapData]); // 需要转为数组
const mapParsed = new Map(JSON.parse(mapJson));

// 3. 需要方法(函数作为值)
const calculator = {
  add(a, b) { return a + b; },
  multiply(a, b) { return a * b; }
};

// 4. 使用 Object 特殊功能
const obj = Object.create(null); // 创建无原型的对象
Object.defineProperty(obj, 'readonly', {
  value: '不可写',
  writable: false
});

4. 互相转换

// Object → Map
const obj = { a: 1, b: 2 };
const map = new Map(Object.entries(obj));

// Map → Object
const map2 = new Map([['x', 10], ['y', 20]]);
const obj2 = Object.fromEntries(map2);

// Object → Map(处理嵌套)
function objectToMap(obj) {
  const map = new Map();
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      const value = obj[key];
      map.set(key, typeof value === 'object' && value !== null ? 
        objectToMap(value) : value);
    }
  }
  return map;
}

5. 特殊注意事项

// 1. Map 的相等性判断
const map = new Map();
map.set({}, 'value1');
map.set({}, 'value2'); // 两个空对象是不同的键
console.log(map.size); // 2

// 2. 引用相同的对象是同一个键
const key = { id: 1 };
map.set(key, 'value');
map.set(key, 'new value'); // 更新,不是添加
console.log(map.size); // 3(包含前面的两个空对象)

// 3. NaN 作为键的特殊性
map.set(NaN, 'first');
map.set(NaN, 'second'); // NaN 被视为相同的键
console.log(map.get(NaN)); // 'second'

// 4. 性能权衡
// Object 在已知结构、固定键时,引擎优化更好
// Map 在动态键、频繁增删时表现更好

总结

选择 Map 当:

  • 键的类型多样(对象、函数等)
  • 需要频繁增删键值对
  • 需要保持严格的插入顺序
  • 避免与原型链冲突
  • 需要直接获取大小

选择 Object 当:

  • 键都是字符串/Symbol
  • 需要 JSON 序列化
  • 需要对象方法(函数作为值)
  • 使用 Object 特殊功能(getter/setter、属性描述符)
  • 简单的配置对象或数据传输对象

在现代 JavaScript 开发中,对于纯粹的键值对存储,Map 通常是更好的选择,因为它更安全、更灵活且性能更好。Object 更适合表示具有方法和逻辑的实体对象。

React+Zustand实战学习笔记:从基础状态管理到项目实战

作者 UIUV
2026年1月15日 23:48

React+Zustand实战学习笔记:从基础状态管理到项目实战

本文基于一份完整的React项目代码,深入剖析Vite+React开发环境下,如何使用Zustand实现状态管理,同时覆盖Todo清单、计数器核心功能的开发逻辑、技术细节与优化方向。笔记兼顾基础概念讲解与实战经验总结,适合React初学者进阶学习,也可作为 Zustand 状态管理的实操参考。

一、技术栈整体解析

1.1 核心技术组合:Vite + React + Zustand

本项目采用当前前端开发中流行的轻量技术组合,各技术栈的核心作用与优势如下:

  • Vite:作为构建工具,替代传统的Webpack,核心优势在于“极速冷启动”“按需编译”和“模块热替换(HMR)”。相较于Webpack的打包式构建,Vite采用原生ES模块加载方式,在开发环境中无需等待全量打包,可直接启动服务,大幅提升开发效率。代码中“Edit src/App.tsx and save to test HMR”提示即对应这一特性,修改代码后无需手动刷新页面,即可实时看到效果。
  • React:前端UI框架,核心思想是组件化开发与虚拟DOM。本项目采用函数式组件+Hooks的开发模式,通过useState管理组件内部状态,体现了React hooks的简洁性与灵活性。
  • Zustand:轻量级状态管理库,用于管理跨组件共享状态(如计数器count、Todo列表)。相较于Redux的繁琐配置(Action、Reducer、Store上下文嵌套),Zustand API简洁直观,无需Provider包裹根组件,支持中间件扩展(如持久化存储),是中小型项目状态管理的优选方案。

1.2 辅助技术与工具

  • TypeScript:从代码中的接口定义(TodoUser)、状态接口(TodoStateCounterState)可看出,项目采用TS开发,通过类型约束提升代码可读性、可维护性,避免类型错误。
  • Zustand Persist中间件:用于状态持久化,将Store中的数据存储到本地存储(localStorage),页面刷新后状态不丢失,解决了React组件状态刷新重置的问题。

二、项目结构与核心文件说明

2.1 项目目录结构(推测)

结合代码中的导入路径,可推测项目基础目录结构如下,符合React项目的规范组织方式:

src/
├── App.tsx          // 根组件,集成所有功能模块
├── App.css          // 根组件样式
├── assets/          // 静态资源目录
│   ├── react.svg    // React图标
│   └── vite.svg     // Vite图标
├── store/           // Zustand状态管理目录
│   ├── counter.ts   // 计数器状态Store
│   ├── todo.ts      // Todo列表状态Store
│   └── user.ts      // 用户状态Store
└── types/           // 类型定义目录
    └── index.ts     // 共享接口定义(Todo、User)

2.2 核心文件作用拆解

2.2.1 App.tsx:根组件与功能集成

作为项目的入口组件,负责整合计数器、Todo清单两大功能模块,同时处理组件内部状态(输入框内容)与用户交互逻辑(按钮点击、键盘回车)。核心职责包括:

  1. 导入并使用Zustand的状态Store(useCountStoreuseTodoStore),获取共享状态与修改方法。
  2. 通过useState管理输入框的临时内容(inputValue),保证组件内部状态的独立性。
  3. 定义交互事件处理函数(handleAdd),封装Todo添加的业务逻辑(空值校验、调用Store方法、清空输入框)。
  4. 渲染UI结构,包括Vite/React图标、计数器按钮、Todo输入框与列表,实现数据与视图的绑定。

2.2.2 types/index.ts:类型定义文件

集中定义项目中通用的接口类型,体现TypeScript的类型约束优势,避免重复定义与类型不一致问题:

  • Todo接口:定义待办事项的结构,包含id(唯一标识)、text(内容)、completed(完成状态)三个属性,为TodoStore提供类型支持。
  • User接口:定义用户信息结构,包含username(用户名)、password(密码)、可选属性avatar(头像),为用户状态管理预留扩展空间。

2.2.3 store目录:Zustand状态管理核心

采用“按功能拆分Store”的模式,将计数器、Todo、用户状态分别封装为独立的Store,符合“单一职责原则”,便于维护与扩展。每个Store均通过create函数创建,并集成persist中间件实现持久化。

三、核心功能逐模块解析

3.1 计数器功能:基础状态管理实现

3.1.1 计数器Store(store/counter.ts)

该Store负责管理计数器的状态(count)与修改方法(incrementdecrementreset),核心代码解析如下:

import { create } from 'zustand'; 
import { persist } from 'zustand/middleware';

interface CounterState {
  count: number;
  increment: () => void; 
  decrement: () => void;
  reset: () => void;
}

export const useCountStore = create<CounterState>()(
    persist(
        (set)=>({
            count:0, // 初始状态
            increment:()=>set((state)=>({count:state.count+1})), // 自增
            decrement:()=>set((state)=>({count:state.count-1})), // 自减
            reset:()=>set({count:0}) // 重置
        }),
        {
            name: 'counter', // 本地存储的键名,对应localStorage中的key
        }
    )
);

关键知识点:

  • 状态定义规范:通过CounterState接口明确状态与方法的类型,increment等方法定义为“无参数、无返回值”的函数,确保类型安全。
  • set函数的使用:Zustand通过set函数修改状态,支持两种写法——直接传入新状态对象(如重置reset),或传入回调函数(如自增自减,可获取当前状态state)。回调函数写法适用于需要基于当前状态计算新状态的场景,避免状态更新依赖问题。
  • persist中间件集成:将create创建的Store包裹在persist中,配置name: 'counter',即可将count状态存储到localStorage。页面刷新后,Store会自动从localStorage读取数据,恢复之前的计数状态。

3.1.2 计数器组件集成(App.tsx)

在根组件中通过解构赋值从useCountStore获取状态与方法,绑定到按钮的onClick事件,实现视图与状态的联动:

const {
  count,
  increment,
  decrement,
  reset,
} = useCountStore();

// 渲染部分
<div className="card">
  <button onClick={increment}>
    count is {count}
  </button>
  <button onClick={decrement}>
    decrement
  </button>
  <button onClick={reset}>
    reset
  </button>
</div>

交互逻辑:点击“count is X”按钮触发increment,计数自增并实时更新视图;点击“decrement”触发自减;点击“reset”重置计数为0。由于集成了持久化,刷新页面后计数不会回到初始值0,而是保持刷新前的状态。

3.2 Todo清单功能:复杂状态管理与交互

Todo清单涉及“添加、切换完成状态、删除”三大核心操作,状态为数组类型(todos),比计数器状态更复杂,需处理数组的增删改查与持久化。

3.2.1 Todo Store(store/todo.ts)

import { create } from 'zustand';
import type { Todo } from '../types/index';
import { persist } from 'zustand/middleware';

export interface TodoState {
    todos: Todo[];
    addTodo: (text: string) => void;
    toggleTodo: (id: number) => void;
    removeTodo: (id: number) => void;
}

export const useTodoStore = create<TodoState>()(
    persist(
        (set,get) =>({
            todos: [], // 初始为空数组
            // 添加Todo
            addTodo: (text: string) => set((state) => ({
                todos: [...state.todos,{
                    id: +new Date(), // 以时间戳作为唯一ID
                    text,
                    completed: false,
                }]
            })),
            // 切换Todo完成状态
            toggleTodo: (id: number) => set((state) => ({
                todos: state.todos.map((todo) => 
                    todo.id === id ? {...todo,completed:!todo.completed} : todo
                )
            })),
            // 删除Todo
            removeTodo: (id: number) => set((state) => ({
                todos: state.todos.filter((todo) => todo.id !== id)
            }))
        }),
        {
            name: 'todo', // 本地存储键名
        }
    )
);

核心技术点解析:

  • 唯一ID生成:采用+new Date()将当前时间戳转为数字作为Todo的id,实现简单高效的唯一标识生成。该方式适用于小型项目,大型项目可考虑使用uuid库生成更可靠的唯一ID。
  • 数组状态修改原则:React与Zustand均要求状态不可变(Immutability),因此修改todos数组时,需通过展开运算符(...state.todos)、mapfilter等方法返回新数组,而非直接修改原数组(如state.todos.push(...)是错误写法)。
  • toggleTodo实现逻辑:通过map遍历数组,找到与目标id匹配的Todo,通过展开运算符复制原对象并修改completed属性,其他Todo保持不变,确保状态修改的不可变性。
  • removeTodo实现逻辑:通过filter过滤掉与目标id匹配的Todo,返回新数组,实现删除功能。
  • get函数的备用场景:代码中虽未使用get,但该函数可用于获取当前状态(替代回调函数中的state),适用于复杂逻辑中需要多次获取状态的场景,例如:const currentTodos = get().todos;

3.2.2 Todo组件集成与交互(App.tsx)

Todo清单的UI渲染与交互逻辑集中在App组件的section部分,核心分为“输入框交互”“Todo列表渲染”两大模块。

(1)输入框交互逻辑
const [inputValue, setInputValue] = useState('');

const handleAdd = () => {
  if (inputValue.trim() === '') return ; // 空值校验,去除首尾空格
  addTodo(inputValue); // 调用Store的addTodo方法添加Todo
  setInputValue(''); // 清空输入框
};

// 渲染部分
<div>
  <input 
    type="text" 
    placeholder="Add a new todo" 
    value={inputValue}
    onChange={(e) => setInputValue(e.target.value)} // 实时更新输入框状态
    onKeyDown={(e) => e.key === 'Enter' && handleAdd()} // 回车触发添加
    />
  <button onClick={handleAdd}>Add</button>
</div>

交互优化点:

  • 空值校验:通过inputValue.trim() === ''避免添加空内容的Todo,提升用户体验。
  • 多触发方式:支持“点击Add按钮”和“按下回车键”两种添加方式,符合用户操作习惯。
  • 输入框清空:添加成功后自动清空输入框,无需用户手动删除,优化操作流程。
(2)Todo列表渲染与操作
<h2>Todos {todos.length}</h2>
<ul>
  {todos.map(todo => (
    <li key = {todo.id}>
      <input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id)} />
      <span style={{textDecoration: todo.completed ? 'line-through' : 'none'}}>{todo.text}</span>
      <button onClick={() => removeTodo(todo.id)}>删除</button>
    </li>
  ))}
</ul>

核心细节:

  • key属性:遍历渲染列表时,必须为每个列表项设置唯一key(此处使用todo.id),React通过key识别列表项的身份,优化渲染性能,避免不必要的DOM更新。
  • 完成状态联动:复选框的checked属性绑定todo.completed,实现“状态控制视图”;onChange事件触发toggleTodo,实现“视图修改状态”,形成双向联动。
  • 样式动态切换:通过内联样式判断todo.completed的值,为已完成的Todo添加删除线(textDecoration: 'line-through'),直观展示任务状态。
  • 删除操作绑定:删除按钮的onClick事件通过箭头函数传递当前Todo的idremoveTodo方法,精准删除目标Todo。

3.3 用户状态Store(store/user.ts):预留扩展功能

代码中还实现了用户状态Store,用于管理登录状态(isLogin)、用户信息(user)及登录/退出方法,虽未在App组件中集成,但具备完整的状态管理能力,可作为后续扩展功能的基础:

import { create } from 'zustand'; 
import { persist } from 'zustand/middleware';
import type { User } from '../types/index';

interface UserState {
    isLogin: boolean; 
    login: (user: User) => void; 
    logout: () => void; 
    user: User | null; 
}

export const useUserStore = create<UserState>()(
    persist(
        (set) => ({
            isLogin: false,
            login: (user) => set({ isLogin: true, user }), // 登录:更新状态与用户信息
            logout: () => set({ isLogin: false, user: null }), // 退出:重置状态
            user: null,
        }),
        {
            name: 'user', // 持久化到localStorage
        }
    )
)

扩展场景:可在项目中添加登录页面,通过表单获取用户名/密码,调用login方法更新状态;在导航栏等组件中使用useUserStore获取isLogin状态,实现“登录后显示用户信息,未登录显示登录按钮”的权限控制逻辑。

四、Zustand核心原理与优势

4.1 核心原理简析

Zustand的设计理念是“简化状态管理”,核心基于“订阅-发布模式”:

  1. 通过create函数创建Store时,会生成一个状态容器,存储当前状态与修改方法。
  2. 组件通过调用自定义Hook(如useCountStore)订阅Store中的状态,当状态发生变化时,Zustand会通知所有订阅该状态的组件重新渲染。
  3. set函数是状态修改的唯一入口,调用set后会更新Store中的状态,并触发订阅组件的重新渲染,确保视图与状态同步。
  4. 中间件(如persist)通过拦截set函数的调用,在状态更新时执行额外逻辑(如存储到本地存储),实现功能扩展。

4.2 Zustand相较于其他状态管理库的优势

(1)对比Redux

  • 无需繁琐配置:Redux需要创建Action、Reducer、Store,还需通过Provider包裹根组件,Zustand一行代码即可创建Store,组件直接调用Hook使用状态。
  • 减少模板代码:Redux的Action与Reducer需严格遵循规范,模板代码较多;Zustand可直接在Store中定义修改方法,简洁直观。
  • 内置中间件支持:Zustand通过中间件实现持久化、日志等功能,无需额外集成第三方库(如Redux需集成redux-persist)。

(2)对比React Context+useReducer

  • 避免Context嵌套:当存在多个独立状态时,Context需创建多个Provider,导致嵌套层级过深;Zustand多个Store可独立使用,无嵌套问题。
  • 性能更优:Context的更新会导致所有消费该Context的组件重新渲染,即使组件只使用了Context中的部分状态;Zustand支持精准订阅,组件可只订阅需要的状态,减少不必要的重渲染。
  • 支持持久化:Context+useReducer需手动实现持久化逻辑(监听状态变化存储到localStorage),Zustand通过persist中间件可一键集成。

五、代码优化建议与进阶实践

5.1 基础优化点

5.1.1 状态精准订阅,减少重渲染

当前组件通过解构赋值获取Store中的所有状态与方法,若Store中状态较多,部分状态变化时会导致组件不必要的重渲染。可通过Zustand的“选择器”功能,只订阅需要的状态:

// 优化前:订阅整个Store
const { count, increment } = useCountStore();

// 优化后:只订阅count状态
const count = useCountStore((state) => state.count);
const increment = useCountStore((state) => state.increment);

该优化可确保只有当count变化时,组件才会重新渲染,提升性能。

5.1.2 提取重复逻辑,封装自定义Hook

若多个组件需要使用Todo的添加、删除逻辑,可将输入框交互逻辑封装为自定义Hook,提升代码复用性:

// hooks/useTodoInput.ts
import { useState } from 'react';
import { useTodoStore } from '../store/todo';

export const useTodoInput = () => {
  const [inputValue, setInputValue] = useState('');
  const addTodo = useTodoStore((state) => state.addTodo);

  const handleAdd = () => {
    if (inputValue.trim() === '') return;
    addTodo(inputValue);
    setInputValue('');
  };

  return { inputValue, setInputValue, handleAdd };
};

在App组件中使用:

const { inputValue, setInputValue, handleAdd } = useTodoInput();

5.1.3 优化ID生成方式

当前使用时间戳作为Todo的ID,存在潜在问题:若同一毫秒内添加多个Todo,会生成重复ID,导致列表渲染异常。可替换为uuid库生成唯一ID:

  1. 安装依赖:npm install uuid
  2. 导入使用:
import { v4 as uuidv4 } from 'uuid';

// 添加Todo时生成ID
addTodo: (text: string) => set((state) => ({
  todos: [...state.todos,{
    id: uuidv4(), // 替换为uuid
    text,
    completed: false,
  }]
}));

5.1.4 增加类型守卫,提升类型安全性

在使用Store中的状态时,可添加类型守卫,避免因类型错误导致的异常:

// 检查Todo是否合法
const isTodo = (item: unknown): item is Todo => {
  return typeof item === 'object' && item !== null && 'id' in item && 'text' in item && 'completed' in item;
};

// 在toggleTodo中使用
toggleTodo: (id: number) => set((state) => ({
  todos: state.todos.map((todo) => 
    isTodo(todo) && todo.id === id ? {...todo,completed:!todo.completed} : todo
  )
}));

5.2 进阶实践方向

5.2.1 集成日志中间件,便于调试

Zustand支持日志中间件,可记录状态变化的过程,便于开发调试:

import { create } from 'zustand';
import { persist, log } from 'zustand/middleware';

// 集成log中间件,放在persist外层或内层均可
export const useCountStore = create<CounterState>()(
  log(
    persist(
      (set) => ({/* 状态与方法 */}),
      { name: 'counter' }
    )
  )
);

添加后,每次状态变化都会在控制台打印日志,包含“之前的状态、修改后的状态、触发的动作”等信息。

5.2.2 实现状态持久化加密

当前persist中间件将状态明文存储在localStorage中,若存储敏感信息(如用户token),存在安全风险。可通过自定义存储逻辑实现加密:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import CryptoJS from 'crypto-js'; // 导入加密库

// 自定义存储逻辑
const encryptedStorage = {
  getItem: (name: string) => {
    const item = localStorage.getItem(name);
    if (!item) return null;
    // 解密
    const decrypted = CryptoJS.AES.decrypt(item, 'secret-key').toString(CryptoJS.enc.Utf8);
    return decrypted;
  },
  setItem: (name: string, value: string) => {
    // 加密
    const encrypted = CryptoJS.AES.encrypt(value, 'secret-key').toString();
    localStorage.setItem(name, encrypted);
  },
  removeItem: (name: string) => localStorage.removeItem(name),
};

// 使用自定义存储
export const useUserStore = create<UserState>()(
  persist(
    (set) => ({/* 状态与方法 */}),
    {
      name: 'user',
      storage: encryptedStorage, // 替换为自定义存储
    }
  )
);

注意:加密密钥(secret-key)需妥善管理,避免硬编码在前端代码中,可通过后端接口获取。

5.2.3 实现Store模块化组合

当项目规模扩大,Store数量增多时,可通过Zustand的combine方法将多个Store组合为一个根Store,便于统一管理:

import { create, combine } from 'zustand';
import { useCountStore } from './counter';
import { useTodoStore } from './todo';

// 组合多个Store
export const useRootStore = create(
  combine(
    {
      counter: useCountStore.getState(),
      todo: useTodoStore.getState(),
    },
    (set) => ({
      // 可定义跨Store的方法
      resetAll: () => {
        useCountStore.getState().reset();
        useTodoStore.getState().todos = [];
      },
    })
  )
);

组合后,组件可通过useRootStore获取所有模块的状态与方法,同时支持定义跨模块的操作(如resetAll同时重置计数器与Todo列表)。

六、常见问题与解决方案

6.1 状态持久化失效

问题现象:页面刷新后,状态未恢复,仍为初始值。

解决方案:

  • 检查persist中间件是否正确集成,确保Store被persist包裹。
  • 确认name配置是否唯一,避免多个Store使用相同的键名,导致本地存储覆盖。
  • 检查状态是否为可序列化类型(Zustand持久化仅支持JSON可序列化类型,如对象、数组、字符串、数字,不支持函数、Symbol等)。

6.2 组件重复渲染

问题现象:Store中某一状态变化时,未使用该状态的组件也发生了重渲染。

解决方案:

  • 使用精准订阅:通过选择器只订阅组件需要的状态,而非整个Store。
  • 使用shallow比较:当订阅的状态为对象/数组时,可结合shallow中间件,避免因引用变化导致的不必要重渲染:
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';

// 组件中使用
const { todos, addTodo } = useTodoStore(
  (state) => ({ todos: state.todos, addTodo: state.addTodo }),
  shallow // 浅层比较,只有当todos数组内容变化时才重渲染
);

6.3 Todo ID重复导致列表渲染异常

问题现象:同一时间添加多个Todo时,部分Todo无法正常显示或操作。

解决方案:

  • 替换ID生成方式:使用uuid库替代时间戳,确保ID唯一。
  • 添加ID去重逻辑:在addTodo方法中检查新生成的ID是否与现有Todo重复,若重复则重新生成。

七、总结与学习心得

本项目通过Vite+React+Zustand实现了计数器与Todo清单两大核心功能,涵盖了前端开发中“组件化开发”“状态管理”“用户交互”“持久化存储”等关键知识点。通过对代码的逐模块解析,可得出以下学习心得:

  1. 技术选型要适配项目规模:Zustand的轻量特性适合中小型项目,相较于Redux更易上手,可大幅提升开发效率;大型项目可结合TypeScript的类型约束,进一步提升代码可维护性。
  2. 状态管理需遵循“单一职责” :按功能拆分Store,每个Store只管理对应模块的状态,避免将所有状态集中在一个Store中,导致维护困难。
  3. 重视状态不可变性:React与Zustand均依赖状态不可变性实现高效渲染,修改数组、对象状态时,需返回新的引用,而非直接修改原数据。
  4. 细节决定用户体验:空值校验、多触发方式、动态样式切换等细节优化,能显著提升用户操作体验,是前端开发中不可忽视的部分。
  5. 持续优化与进阶:基础功能实现后,可通过精准订阅、代码封装、加密存储等方式优化性能与安全性,同时探索Zustand的高级特性,如中间件扩展、Store组合,为复杂项目开发积累经验。

后续可基于本项目继续扩展功能,如添加用户登录注册、Todo分类、分页加载等,进一步深化对React与Zustand的理解与应用。

❌
❌