阅读视图

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

案例分析:一个复杂表单的响应式性能优化

前言

想象我们是一个银行柜员,每天要处理大量客户开户申请。表单有上百个字段:基本信息、家庭信息、资产信息、工作信息、财务信息...

每次输入一个字段,电脑都要卡顿一下。客户不耐烦地说:"你这电脑也太慢了。"

我们只能无奈地说:"不是电脑慢,是这个系统太卡了。"

场景描述 :一个真实的性能噩梦

业务背景

某金融后台系统的客户信息录入表单,用于银行开户、贷款申请、企业信息登记:

// 这个表单有 100+ 个字段
const formData = {
  // 基本信息 - 20+ 字段
  personalInfo: {
    name: '',           // 姓名
    idCard: '',         // 身份证号
    phone: '',          // 手机号
    email: '',          // 邮箱
    birthday: '',       // 生日
    nationality: '',    // 国籍
    occupation: '',     // 职业
    education: '',      // 学历
    maritalStatus: '',  // 婚姻状况
    // ... 还有10多个字段
  },
  
  // 家庭信息 - 15+ 字段
  familyInfo: {
    spouseName: '',
    spousePhone: '',
    children: [],       // 动态增减的孩子列表
    // ...
  },
  
  // 资产信息 - 30+ 字段
  assetInfo: {
    house: [],          // 房产列表(可动态增减)
    vehicle: [],        // 车辆列表(可动态增减)
    deposit: [],        // 存款列表(可动态增减)
    investment: [],     // 投资列表(可动态增减)
    // ...
  },
  
  // 工作信息 - 20+ 字段
  workInfo: {
    companyName: '',
    position: '',
    income: 0,
    workYears: 0,
    // ...
  },
  
  // 财务信息 - 15+ 字段
  financialInfo: {
    monthlyIncome: 0,
    monthlyExpense: 0,
    creditScore: 0,
    // ...
  }
}

性能问题表现

操作 正常预期 实际情况 用户感受
页面加载 < 1秒 3.5秒 "怎么这么慢?"
输入单个字段 立即响应 延迟 200-500ms "打字跟不上"
添加动态字段 瞬间 1-2秒卡顿 "以为点不动"
字段联动 实时 延迟明显 "体验很差"
表单提交 1秒内 5秒+ "是不是卡死了?"

初始代码结构(问题代码)

<!-- ❌ 问题代码:一个组件包含所有字段 -->
<template>
  <form @submit="handleSubmit">
    <!-- 基本信息 -->
    <div class="section">
      <h3>基本信息</h3>
      <input v-model="formData.personalInfo.name" placeholder="姓名" />
      <input v-model="formData.personalInfo.idCard" placeholder="身份证号" />
      <!-- ... 100+ 个字段 -->
    </div>
    
    <!-- 动态资产列表 -->
    <div v-for="(house, index) in formData.assetInfo.house" :key="index">
      <input v-model="house.address" placeholder="地址" />
      <input v-model="house.area" placeholder="面积" />
      <input v-model="house.value" placeholder="价值" />
      <button @click="removeHouse(index)">删除</button>
    </div>
    <button @click="addHouse">添加房产</button>
    
    <!-- 其他字段... -->
  </form>
</template>

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

// 整个表单数据都是响应式的
const formData = ref(initialData)

// 大量 watch 监听联动
watch(() => formData.value.personalInfo.occupation, (newVal) => {
  // 根据职业动态显示字段
  if (newVal === '医生') {
    formData.value.dynamicFields.hospital = ''
    formData.value.dynamicFields.licenseNumber = ''
  } else if (newVal === '律师') {
    formData.value.dynamicFields.lawFirm = ''
    formData.value.dynamicFields.barNumber = ''
  }
})

watch(() => formData.value.assetInfo.totalValue, (newVal) => {
  // 资产变化影响贷款额度
  formData.value.financialInfo.loanAmount = newVal * 0.7
})
</script>

性能瓶颈分析

1. 响应式系统过载

  • 100+ 个字段全部深度响应式
  • 每次输入触发整个组件的响应式依赖重新计算
  • 递归代理导致内存占用巨大

2. 组件粒度过大

  • 单个组件包含所有逻辑
  • 任何字段变化都导致整个组件重渲染
  • 模板过大,编译和 diff 开销大

3. 联动逻辑低效

  • 过多的 watch 监听
  • 每次输入触发多个 watch
  • watch 中的操作触发更多更新

性能问题诊断

使用 Vue DevTools 分析

  • 步骤1:打开 Vue DevTools 的 Performance 面板
  • 步骤2:开始录制,在表单中输入一个字段
  • 步骤3:停止录制,查看分析结果

分析结果示例

性能时间线分析:

├─ 输入事件处理: 2ms
├─ 响应式依赖收集: 45ms  ← 瓶颈
├─ 组件渲染: 120ms  ← 瓶颈
│  ├─ 模板编译: 35ms
│  ├─ 虚拟 DOM diff: 50ms
│  └─ 真实 DOM 更新: 35ms
└─ watch 回调执行: 35ms  ← 瓶颈

总耗时: 202ms

使用 Chrome DevTools 分析

火焰图分析:

├─ 长任务 (Long Task) > 50ms
│  ├─ reactive setter 调用栈过深
│  ├─ 多个 watch 递归触发
│  └─ 组件渲染重复执行
│
├─ 强制重排 (Forced Reflow)
│  └─ 动态字段添加导致布局抖动
│
└─ 内存分配频繁
   └─ 每次输入都创建大量临时对象

自定义性能监控

// 添加性能监控代码
const perfMonitor = {
  logs: [],
  
  start(operation) {
    return performance.now()
  },
  
  end(operation, startTime) {
    const duration = performance.now() - startTime
    this.logs.push({ operation, duration })
    
    if (duration > 16) {
      console.warn(`⚠️ 慢操作: ${operation} 耗时 ${duration.toFixed(2)}ms`)
    }
    
    return duration
  },
  
  report() {
    console.table(this.logs)
    this.logs = []
  }
}

// 在组件中使用
const handleInput = (field, value) => {
  const start = perfMonitor.start(`update-${field}`)
  
  // 更新数据
  formData.value[field] = value
  
  perfMonitor.end(`update-${field}`, start)
}

优化方案一 :数据结构优化

使用 shallowRef 替代 ref

// ❌ 优化前:深度响应式
const formData = ref(largeFormData)
// 每次修改深层属性都会触发更新

// ✅ 优化后:浅层响应式
const formData = shallowRef(largeFormData)
// 只有整体替换才会触发更新

// 修改数据的模式
function updateField(path, value) {
  // 创建新对象,只修改需要变更的部分
  const newData = { ...formData.value }
  
  // 根据路径找到并修改值
  let current = newData
  for (let i = 0; i < path.length - 1; i++) {
    current = current[path[i]]
  }
  current[path[path.length - 1]] = value
  
  // 整体替换,触发一次更新
  formData.value = newData
}

拆分表单为多个子组件

<!-- ✅ 优化后:拆分为多个子组件 -->
<template>
  <form @submit="handleSubmit">
    <!-- 每个子组件独立渲染 -->
    <PersonalInfoForm 
      v-model="formData.personalInfo"
      @update="handleSectionUpdate"
    />
    
    <FamilyInfoForm 
      v-model="formData.familyInfo"
      @update="handleSectionUpdate"
    />
    
    <AssetInfoForm 
      v-model="formData.assetInfo"
      @update="handleSectionUpdate"
    />
    
    <WorkInfoForm 
      v-model="formData.workInfo"
      @update="handleSectionUpdate"
    />
    
    <FinancialInfoForm 
      v-model="formData.financialInfo"
      @update="handleSectionUpdate"
    />
  </form>
</template>

<script setup>
import { shallowRef } from 'vue'
import PersonalInfoForm from './PersonalInfoForm.vue'
import FamilyInfoForm from './FamilyInfoForm.vue'
import AssetInfoForm from './AssetInfoForm.vue'

// 使用 shallowRef 存储整个表单
const formData = shallowRef(initialData)

// 子组件更新时只更新对应部分
function handleSectionUpdate(section, data) {
  formData.value = {
    ...formData.value,
    [section]: data
  }
}
</script>

使用 Map 管理动态字段

// ❌ 优化前:使用数组存储动态字段
const houses = ref([{ address: '', area: 0, value: 0 }])

function addHouse() {
  houses.value.push({ address: '', area: 0, value: 0 })
  // 每次添加都触发整个数组的响应式更新
}

// ✅ 优化后:使用 Map 存储,减少响应式开销
const houses = shallowRef(new Map())
let nextId = 1

function addHouse() {
  const newMap = new Map(houses.value)
  newMap.set(nextId++, { address: '', area: 0, value: 0 })
  houses.value = newMap  // 整体替换
}

function updateHouse(id, field, value) {
  const newMap = new Map(houses.value)
  const house = newMap.get(id)
  if (house) {
    newMap.set(id, { ...house, [field]: value })
    houses.value = newMap
  }
}

function removeHouse(id) {
  const newMap = new Map(houses.value)
  newMap.delete(id)
  houses.value = newMap
}

优化方案二:渲染优化

虚拟滚动处理长列表

<template>
  <div class="dynamic-list">
    <h3>家庭成员</h3>
    
    <!-- 使用虚拟滚动组件 -->
    <VirtualScroller
      :items="familyMembers"
      :item-height="80"
      class="member-list"
    >
      <template #default="{ item, index }">
        <div class="member-item">
          <input 
            :value="item.name" 
            @input="updateMember(index, 'name', $event.target.value)"
            placeholder="姓名"
          />
          <input 
            :value="item.age" 
            type="number"
            @input="updateMember(index, 'age', $event.target.value)"
            placeholder="年龄"
          />
          <button @click="removeMember(index)">删除</button>
        </div>
      </template>
    </VirtualScroller>
    
    <button @click="addMember">添加家庭成员</button>
  </div>
</template>

<script setup>
import { ref, shallowRef } from 'vue'
import VirtualScroller from 'vue-virtual-scroller'

// 使用 shallowRef 存储列表
const familyMembers = shallowRef([])

function addMember() {
  familyMembers.value = [
    ...familyMembers.value,
    { name: '', age: 0 }
  ]
}

function updateMember(index, field, value) {
  const newMembers = [...familyMembers.value]
  newMembers[index] = { ...newMembers[index], [field]: value }
  familyMembers.value = newMembers
}
</script>

使用 v-memo 缓存静态部分

<template>
  <div class="form-section">
    <h3>基本信息</h3>
    
    <!-- 静态部分使用 v-once -->
    <div v-once class="form-description">
      请填写您的真实信息
    </div>
    
    <!-- 使用 v-memo 缓存不常变化的部分 -->
    <div 
      v-for="field in staticFields" 
      :key="field.key"
      v-memo="[field.key]"
      class="form-row"
    >
      <label>{{ field.label }}</label>
      <input
        :value="formData[field.key]"
        @input="updateField(field.key, $event.target.value)"
      />
    </div>
    
    <!-- 联动字段动态渲染 -->
    <div 
      v-for="field in dynamicFields" 
      :key="field.key"
      class="form-row"
    >
      <label>{{ field.label }}</label>
      <component 
        :is="field.component" 
        v-model="formData[field.key]"
        :options="field.options"
      />
    </div>
  </div>
</template>

异步渲染 - 先渲染首屏

// 分阶段渲染表单
const renderStages = {
  critical: ['personalInfo', 'contactInfo'],      // 首屏必显
  important: ['familyInfo', 'workInfo'],          // 滚动到才渲染
  normal: ['assetInfo', 'financialInfo'],         // 折叠面板内
  lazy: ['attachments', 'remarks']                // 按需加载
}

const visibleSections = ref(new Set(['personalInfo']))

// 使用 Intersection Observer 检测可见性
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const section = entry.target.dataset.section
      if (section && !visibleSections.value.has(section)) {
        visibleSections.value.add(section)
      }
    }
  })
}, { rootMargin: '200px' })

优化方案三:联动逻辑优化

使用计算属性代替 watch

// ❌ 优化前:使用 watch 监听联动
watch(() => formData.value.assetInfo.totalValue, (newVal) => {
  formData.value.financialInfo.loanAmount = newVal * 0.7
  
  if (newVal > 1000000) {
    formData.value.financialInfo.riskLevel = 'high'
  } else if (newVal > 500000) {
    formData.value.financialInfo.riskLevel = 'medium'
  } else {
    formData.value.financialInfo.riskLevel = 'low'
  }
})

// ✅ 优化后:使用计算属性
const loanAmount = computed(() => {
  return formData.value.assetInfo.totalValue * 0.7
})

const riskLevel = computed(() => {
  const total = formData.value.assetInfo.totalValue
  if (total > 1000000) return 'high'
  if (total > 500000) return 'medium'
  return 'low'
})

// 在模板中使用计算属性
<div>贷款额度: {{ loanAmount }}</div>
<div>风险等级: {{ riskLevel }}</div>

防抖处理实时计算

import { debounce } from 'lodash-es'

// ❌ 优化前:每次输入都实时计算
const handleAmountChange = (value) => {
  const loanAmount = calculateLoan(value)
  const interest = calculateInterest(loanAmount)
  const monthlyPayment = calculateMonthlyPayment(loanAmount, interest)
  
  formData.value.financialInfo.loanAmount = loanAmount
  formData.value.financialInfo.interest = interest
  formData.value.financialInfo.monthlyPayment = monthlyPayment
}

// ✅ 优化后:使用防抖,用户停止输入后才计算
const debouncedCalculate = debounce((value) => {
  const loanAmount = calculateLoan(value)
  const interest = calculateInterest(loanAmount)
  const monthlyPayment = calculateMonthlyPayment(loanAmount, interest)
  
  // 批量更新
  formData.value = {
    ...formData.value,
    financialInfo: {
      ...formData.value.financialInfo,
      loanAmount,
      interest,
      monthlyPayment
    }
  }
}, 300)

const handleAmountChange = (value) => {
  debouncedCalculate(value)
}

批量更新优化

// ❌ 优化前:多次更新触发多次渲染
function applyCreditScore(score) {
  formData.value.financialInfo.creditScore = score
  
  if (score >= 800) {
    formData.value.financialInfo.loanRate = 0.035
    formData.value.financialInfo.loanLimit = 5000000
  } else if (score >= 700) {
    formData.value.financialInfo.loanRate = 0.045
    formData.value.financialInfo.loanLimit = 3000000
  }
  
  formData.value.financialInfo.creditLevel = getCreditLevel(score)
}
// 每次属性修改都触发一次更新,共 3 次

// ✅ 优化后:使用批量更新
function applyCreditScore(score) {
  const updates = { creditScore: score }
  
  if (score >= 800) {
    updates.loanRate = 0.035
    updates.loanLimit = 5000000
  } else if (score >= 700) {
    updates.loanRate = 0.045
    updates.loanLimit = 3000000
  }
  
  updates.creditLevel = getCreditLevel(score)
  
  // 批量更新,只触发一次渲染
  formData.value = {
    ...formData.value,
    financialInfo: {
      ...formData.value.financialInfo,
      ...updates
    }
  }
}

优化检查清单

数据结构优化

  • 使用 shallowRef 替代 ref 存储大对象
  • 拆分表单为多个子组件
  • 动态字段使用 Map 管理
  • 按业务模块组织数据结构
  • 避免深层嵌套的响应式数据

渲染优化

  • 长列表使用虚拟滚动
  • 静态内容使用 v-once
  • 不常变化的部分使用 v-memo
  • 非首屏内容异步渲染
  • 条件判断使用计算属性缓存

联动逻辑优化

  • 使用计算属性代替 watch
  • 复杂计算使用防抖/节流
  • 只在必要时触发更新
  • 批量更新使用对象合并
  • 避免在 watch 中修改其他字段

监控与调试

  • 使用 Vue DevTools 分析渲染性能
  • 使用 Chrome DevTools 分析内存占用
  • 添加性能监控埋点
  • 定期检查响应式依赖数量

结语

大表单优化的核心:让不需要响应式的数据不响应,让不需要渲染的部分不渲染。当我们输入一个字段时,只有这个字段对应的子组件重新渲染,而不是整个表单;当我们添加一个动态项时,只更新 Map 中的那一条,而不是整个数组。这样,无论表单有多大,用户都会感觉"很流畅",这就是优化的意义。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

什么是 Harness Engineering,为什么最近都在说它

大家好,我是拭心。

最近 Harness Engineering 很火,国内外都在谈论它,我们也来简单聊聊。

起因是 OpenAI 的这篇 《Harness engineering: leveraging Codex in an agent-first world》(openai.com/zh-Hans-CN/… Harness Engineering 的概念。

简单来说,OpenAI 花了五个月做了一件事:完全靠 AI 实现了一个大规模(100万行)、可以使用的产品,人类没有编写一行代码

最终 AI 可以自动开发这些工作:

  • 产品代码与测试
  • CI 配置和发布工具
  • 内部开发者工具
  • 文档和设计历史
  • 评估框架
  • review 评论和回复
  • 管理代码仓库本身的脚本

可以看到,基本上做到了能自动完成整个研发流程。

这在之前是无法想象的,通过提示词和上下文优化,我们可以让 AI 多做一些事情,但总是需要人类去 review、测试、指出问题。每次开发需求都是输入提示词开始,人工测试修复结束。

OpenAI 这次的实验,就是秉持着「review 流程,而不是 review 代码」的态度。

遇到问题,不是人为去解决,而是思考「智能体还需要什么样的能力」,比如 AI 无法感知到运行问题,就让应用程序的 UI、日志和应用指标等内容对 Codex 直接可读,从而让智能体可以自测、收集运行情况。

最后,他们把这种 完全靠 AI 完成复杂系统,人类不写一行代码,定义为 Harness Engineering。

Harness 是马具的意思,表达像控制马一样管理大模型。

从目的上来讲,Harness Engineering(Harness 工程) 比提示词工程、上下文工程要宏大

  • 提示词工程的核心是优化问题,让 AI 理解问题细节;
  • 上下文工程的核心是优化模型的输入信息,让 AI 有更多决策信息;
  • Harness Engineering 的核心,则是完全让 AI 自动实现整个流程。

别的不说,光是这个概念,就让工程师、企业老板有了无限的遐想:如果这个系统真的实现了,那真的吃着火锅唱着歌,就把事干了、钱赚了。

听着很厉害,这要怎么做到呢?我们下篇聊。

更多精彩教程,尽在我的转型 AI 应用开发专栏:《转型 AI 工程师|提升竞争力》

不只是接个计算器:我是怎么把 Tool Calling 做成可扩展骨架的

本文对应项目版本:v0.0.5

在前一个版本里,我已经把项目的基础聊天链路搭起来了:

  • 服务端使用 LangChain.js + Ollama
  • 前端使用自定义 useChatStream
  • 内容渲染采用 Markdown + typed parts + Streamdown
  • 输入和协议校验使用 Zod

到这一步,项目虽然已经具备了“本地大模型对话 + 流式输出 + 多轮上下文”的基础能力,但它本质上还是一个纯文本问答系统。

v0.0.5 我想解决的问题,不是“再接一个功能”,而是让系统第一次具备调用外部能力的能力。

为了控制复杂度,这一版我只给自己定了一个目标:

先接一个最小 Tool,跑通完整 Tool Calling 链路,同时保留后续自然扩展的空间。

当前版本只接入了一个工具:

  • calculator

但这一版真正要验证的,不是“计算器能不能算题”,而是下面这些工程问题:

  • 模型怎么决定要不要调用工具?
  • 工具参数怎么校验?
  • 工具结果怎么回填给模型?
  • 前端怎么把推理、工具调用、最终答案区分开来展示?
  • 如果一次流式渲染异常,会不会影响下一次对话?

这些问题,才是 Tool Calling 从 Demo 走向工程实现时真正需要面对的部分。

项目效果

聊天主界面截图

chat1.png

推理过程 + tool part工具调用成功 + 最终答案完整展示图

toolsucess.png

这版到底想做什么

先说清楚 v0.0.5 的边界。

这版不是:

  • 多工具 Agent
  • 工具编排平台
  • LangGraph 工作流
  • 长期记忆系统

这版只做一件事:

用一个最小 Tool Calling 实践,验证当前聊天系统能不能从“只会回答”升级成“会调用能力、会返回结构化结果、还能保持工程可扩展”。

所以这一版的设计原则非常明确:

  • 最小可实现
  • 可扩展
  • 不推翻前一版架构
  • 不引入新的重型框架

总体架构

这一版的整体链路如下:

用户输入
  -> /api/chat
    -> LangChain ChatOllama
      -> 判断是否返回 tool_calls
        -> calculator
          -> ToolMessage 回填
            -> 最终答案流式返回前端

如果把这条链路拆开来看,前后端分别承担的职责其实很清楚。

服务端负责

  • 统一模型接入
  • 挂载当前可用工具
  • 解析模型返回的 tool_calls
  • Zod 校验工具参数
  • 执行工具
  • 把工具结果回填给模型
  • 输出结构化 NDJSON 流

前端负责

  • 读取 NDJSON 流
  • reasoning / tool / text 三类 part 消费事件
  • 渲染推理过程、工具调用状态和最终答案
  • 在多轮对话里只保留必要上下文

为什么我没有继续把返回内容做成“一段字符串”

如果系统只有普通对话,把响应内容当作一段 Markdown 字符串其实是够用的。

但到了 Tool Calling 场景,这种做法就不够了。

因为一次回答里其实会同时包含三类信息:

  1. 模型推理内容
  2. 工具调用过程与工具结果
  3. 最终回答正文

如果仍然全部塞进一段字符串里,前端就会遇到几个问题:

  • 无法单独折叠推理过程
  • 无法结构化展示 tool 调用状态
  • 无法清晰地区分“工具结果”和“模型最后组织出来的答案”
  • 后续增加更多工具时,协议会越来越乱

所以这一版我把 assistant 消息拆成了三类 part:

  • reasoning part
  • tool part
  • text part

这样前端拿到的就不再是“文本流”,而是一种结构化事件流

关键代码:消息 part 结构

export interface TextPart extends BasePart {
    type: 'text'
    text: string
    format: 'markdown'
}

export interface ReasoningPart extends BasePart {
    type: 'reasoning'
    text: string
    format: 'markdown'
    visibility?: 'collapsed' | 'expanded' | 'hidden'
}

export interface ToolPart extends BasePart {
    type: 'tool'
    toolName: string
    status: 'called' | 'completed' | 'failed'
    input: string
    output?: string
    error?: string
}

关键代码:流式 chunk 协议

export interface ToolStartChunk {
    type: 'tool-start'
    partId: string
    toolName: string
    input: string
}

export interface ToolEndChunk {
    type: 'tool-end'
    partId: string
    toolName: string
    input: string
    output: string
}

export type ChatStreamChunk =
    | StartChunk
    | ReasoningStartChunk
    | ReasoningDeltaChunk
    | ReasoningEndChunk
    | ToolStartChunk
    | ToolEndChunk
    | ToolErrorChunk
    | TextStartChunk
    | TextDeltaChunk
    | TextEndChunk
    | FinishChunk
    | ErrorChunk

我后来越来越确信一点:

对 Tool Calling 场景来说,最值得先做对的不是 UI,而是消息协议。

因为协议一旦分层清楚,前端展示、错误定位、后续扩展都会轻松很多。

服务端设计:统一模型接入层 + 最小 Tool Calling 闭环

这一版我有一个很明确的取舍:

不把服务端讲成“普通模型一套、工具模型一套”。

更准确的说法应该是:

服务端维护一个统一的大模型接入层,在运行时根据当前可用工具集合决定是否给模型挂载工具能力。

也就是说,本质上仍然是一个基础模型配置,只是运行时会根据 activeTools 决定是否调用 bindTools()

这样的好处是:

  • 架构更干净
  • 更符合主流 Tool Calling 的实现方式
  • 后续新增工具时,不需要推翻主链路

执行流分成三种情况

1. 当前有可用工具,但当前问题不需要工具

user message
  -> baseModel.bindTools(activeTools)
    -> model response
      -> no tool_calls
        -> final answer

特点:

  • 只调用一次模型
  • 不会多走一轮
  • 即使模型已经绑定工具,也可以直接正常回答

2. 当前有可用工具,且当前问题需要工具

user message
  -> baseModel.bindTools(activeTools)
    -> model response
      -> tool_calls
        -> Zod 校验
        -> execute tool
        -> append ToolMessage
        -> second model response
        -> final answer

这就是当前版本的两阶段最小闭环:

  • 第一阶段:模型决定是否调用工具
  • 第二阶段:工具执行完成后,再生成最终答案

3. 当前没有可用工具

user message
  -> baseModel
    -> final answer

这里要特别强调一下:

“当前没有可用工具”属于运行时能力状态,不是对用户问题做内容分类。

关键代码:统一模型接入与主执行链

function createBaseModel(request: ChatRequest, deps: ChatServiceDependencies) {
    return new ChatOllama({
        model: request.options?.model ?? deps.defaultModel,
        baseUrl: deps.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://127.0.0.1:11434',
        temperature: request.options?.temperature ?? 0.3,
        numPredict: request.options?.maxTokens,
        think: request.options?.enableReasoning,
        streaming: true,
    })
}

const baseModel = createBaseModel(request, deps)
const activeTools = getActiveTools()
const toolBoundModel =
    activeTools.length > 0 ? baseModel.bindTools(activeTools.map(toolDefinition => toolDefinition.tool)) : null

if (!toolBoundModel) {
    await streamDirectAnswer(baseModel, langChainMessages, context, writeChunk, () => closed)
    writeChunk({ type: 'finish' })
    return
}

这里的关键不是代码有多复杂,而是这个判断边界很清晰:

  • 有工具可用时,模型具备 tool calling 能力
  • 没有工具可用时,系统仍然可以正常工作

为什么 calculator 仍然值得单独做

很多人会觉得:

“先做一个计算器工具是不是太简单了?”

但从工程角度看,calculator 恰恰很适合做第一版 Tool Calling。

1. 它是确定性工具

输入同一个表达式,输出应该永远一致。

这意味着一旦结果错了,我们更容易判断问题出在:

  • 模型没有正确发起 tool call
  • tool 参数不合法
  • 工具执行失败
  • 工具结果回填后,模型又把答案组织错了

2. 它非常适合验证校验链路

tool_calls 不是模型说什么就执行什么。

在真正执行前,我仍然做了这几步:

  1. 工具存在性检查
  2. 参数归一化
  3. Zod.safeParse
  4. 执行工具

这套链路虽然不复杂,但正是 Tool Calling 从 Demo 走向工程实现时最关键的一步。

3. 它天然暴露“工具结果正确 ≠ 最终答案一定正确”这个问题

这一点是我这次实现里最值得记录的坑之一。

calculator 明明算出了正确结果,但第二阶段模型仍然可能:

  • 重新手算一遍
  • 把中间步骤写错
  • 输出和工具结果冲突的回答

tool3.png

这说明一个很现实的问题:

Tool Calling 的核心难点,不只是“会不会调工具”,还包括“调完工具以后,系统如何保证最终答案仍然可信”。

关键代码:calculator 工具定义

export const calculatorToolSchema = z.object({
    expression: z.string().min(1).max(200),
})

export function normalizeCalculatorExpression(expression: string): string {
    return expression
        .trim()
        .replaceAll('×', '*')
        .replaceAll('÷', '/')
        .replaceAll('(', '(')
        .replaceAll(')', ')')
        .replaceAll(/\s+/g, ' ')
}

export const calculatorTool = tool(
    async ({ expression }) => {
        const normalizedExpression = normalizeCalculatorExpression(expression)
        const result = evaluate(normalizedExpression)
        return String(result)
    },
    {
        name: 'calculator',
        description: '执行数学表达式计算',
        schema: calculatorToolSchema,
    }
)

关键代码:工具注册入口

const calculatorToolDefinition: ChatToolDefinition = {
    name: 'calculator',
    tool: calculatorTool,
    schema: calculatorToolSchema,
    normalizeArgs: normalizeCalculatorToolArgs,
    formatInput: formatCalculatorToolInput,
    resultIsAuthoritative: true,
}

const chatToolDefinitions = [calculatorToolDefinition]

export function getActiveChatToolDefinitions(): ChatToolDefinition[] {
    return chatToolDefinitions.filter(toolDefinition => toolDefinition.isAvailable?.() ?? true)
}

虽然当前只有一个工具,但这个注册入口已经把下一版的扩展位留出来了。

Zod 在这一版里不是配角

如果只看功能演示,很多人会把 Zod 当成附属工具。

但在实际实现里,Zod 是这一版稳定性的关键基础设施之一。

1. 请求输入校验

前端每次发送:

  • conversationId
  • messages[]
  • options

都要先经过请求 schema 校验。

2. Tool Call 参数校验

模型产出的 tool_calls 不是可信输入。

如果不做校验,模型只要给出一个奇怪参数,就可能直接把工具执行链路带崩。

这一版的处理方式是:

  • 先归一化参数
  • safeParse
  • 通过才执行
  • 失败就输出 tool-error

3. 前端流协议校验

前端消费的不是纯文本,而是结构化 NDJSON 事件流。

所以每个 chunk 进入渲染前,也要先经过 schema 校验。

这件事的价值在于:

  • 某一次流式异常不会直接污染整个状态树
  • 协议一旦有问题,更容易定位在“服务端输出错误”还是“前端消费错误”

关键代码:tool call 显式校验

const normalizedArgs = toolDefinition.normalizeArgs ? toolDefinition.normalizeArgs(toolCall.args) : toolCall.args
const parsedArgs = toolDefinition.schema.safeParse(normalizedArgs)

if (!parsedArgs.success) {
    toolErrors.push({
        id: toolCall.id,
        toolName: toolCall.name,
        input: formatToolInput({
            ...toolCall,
            args: normalizedArgs,
        }),
        message: createToolValidationErrorMessage(toolCall, parsedArgs.error),
    })
    continue
}

validatedToolCalls.push({
    ...toolCall,
    args: parsedArgs.data,
})

我后来越来越觉得:

在 Tool Calling 场景里,模型能力很重要,但真正决定系统稳定性的,往往是“模型之外”的校验与兜底。

前端设计:不是在拼字符串,而是在消费事件流

前端这一版我没有接 AI SDK,而是继续保留自定义 useChatStream

原因很简单:

这一版的目标是把 Tool Calling 跑通,而不是再引入一层新的聊天抽象。

useChatStream 真正做的事情

它处理的不是一段“完整文本”,而是一串结构化事件:

  • start
  • reasoning-start / reasoning-delta / reasoning-end
  • tool-start / tool-end / tool-error
  • text-start / text-delta / text-end
  • finish
  • error

这意味着前端不再是“把文本不断 append 到一个字符串里”,而是在做更细粒度的状态消费。

这带来的直接收益

  1. 推理过程可以单独折叠
  2. 工具调用可以单独展示
  3. 工具结果和最终正文可以自然区分
  4. 以后接更多工具时,前端协议层不需要推翻重来

关键代码:消费 NDJSON 流

async function consumeNdjsonStream(stream: ReadableStream<Uint8Array>, onChunk: (chunk: ChatStreamChunk) => void) {
    const reader = stream.getReader()
    const decoder = new TextDecoder()
    let buffer = ''

    while (true) {
        const { done, value } = await reader.read()

        if (done) {
            break
        }

        buffer += decoder.decode(value, { stream: true })
        const lines = buffer.split('\n')
        buffer = lines.pop() ?? ''

        for (const line of lines) {
            const parsedChunk = chatStreamChunkSchema.safeParse(JSON.parse(line))

            if (!parsedChunk.success) {
                throw new Error('Invalid chat stream chunk.')
            }

            onChunk(parsedChunk.data)
        }
    }
}

关键代码:按 chunk 更新消息

case 'tool-start': {
    const messageId = activeStreamRef.current.messageId
    if (!messageId) return

    updateMessages(current => appendPart(current, messageId, createToolPart(chunk.partId, chunk.toolName, chunk.input)))
    return
}

case 'text-delta': {
    const messageId = activeStreamRef.current.messageId
    const textPartId = activeStreamRef.current.textPartId

    if (!messageId || textPartId !== chunk.partId) {
        return
    }

    updateMessages(current => appendTextualPartDelta(current, messageId, chunk.partId, 'text', chunk.delta))
    return
}

这类实现让我很明确地感受到一件事:

一旦前端开始消费结构化事件,聊天 UI 就从“渲染字符串”升级成了“渲染系统状态”。

这版最值得记录的几个坑

如果这篇文章只写最终方案,会显得过于平滑。

但真实开发里,v0.0.5 其实踩了不少坑,我觉得这些坑反而很值得记录。

坑 1:工具结果是对的,模型最终回答却可能是错的

例如 calculator 已经算出了正确结果,但模型在第二阶段生成最终答案时,仍然可能:

  • 重复手算
  • 口算出错
  • 输出与工具结果不一致的内容

tool3.png

后来我在运行时加了一个更明确的策略:

  • calculator 这类确定性工具,工具结果具有权威性
  • 在必要时优先直出工具结果对应的最终答案

也就是说:

模型可以组织语言,但不能推翻确定性工具的结果。

坑 2:普通问答的流式体验“不像真流”

早期我把第一阶段写成了非流式调用,结果带来的体验是:

  • 页面先等很久
  • 然后一次性吐出一大块内容

这让我意识到:

真正的流式体验,不能靠“先等模型完整返回,再拼装输出”来模拟。

后来第一阶段也切回了真正的流式消费,普通问题的体验才重新正常。

坑 3:前端一次渲染异常,会污染下一轮请求

某次流式异常后,前端残留了一条空的 assistant placeholder。下一次请求时,它又被带回服务端,最终触发了:

  • messages[].parts 为空
  • 后端 Zod 校验失败
  • 直接返回 400

后来我补了两层兜底:

  • 发送前清理瞬态脏消息
  • 只把有效 text part 组装进请求

这才保证“一次异常不会影响下一次对话”。

坑 4:模型绑定了工具,不代表它一定会乖乖用工具

理论上模型已经绑定了 calculator,但实际运行里,它仍然可能:

  • 先自己推理
  • 输出伪调用文本
  • 在 reasoning 里长篇展开错误步骤

tool2.png

这说明:

Tool Calling 的稳定性,不能只依赖模型自觉。

这也是为什么提示词设计、参数校验、结果策略这些工程细节都很重要。

当前版本已经做到什么程度

v0.0.5 为止,项目已经具备了这些能力:

  • 支持普通问答
  • 支持单工具 calculator
  • 支持 Tool Calling 可视化
  • 支持本地多轮上下文
  • 支持 reasoning / tool / text 三类结构化展示
  • 支持流式错误兜底

但我也明确保留了这些边界:

  • 不做多工具调度
  • 不做完整 Agent loop
  • 不做工具权限系统
  • 不做工具管理后台
  • 不做长期记忆

这不是因为它们不重要,而是因为这一版的主题非常明确:

先把单工具 Tool Calling 的最小工程闭环做对。

我对 v0.0.5 的总结

如果要用一句话概括这一版,我会这么说:

v0.0.5 的重点不是做了一个计算器,而是把当前聊天系统从“只有模型回答”升级成了“模型可以发起 Tool Calls、服务端可以校验并执行工具、前端可以结构化展示结果”的最小闭环。

这一版做完以后,项目其实第一次真正具备了“能力扩展”的基础。

从这个版本往后,再增加新的 Tool,就不再是“重新造一套聊天系统”,而是在现有骨架上自然长出新的能力。

下一步可以怎么做

如果继续往后迭代,我比较关心的是三个方向:

  1. 增加更多 Tool,验证单 Tool 骨架能否自然扩展
  2. 引入上下文窗口或摘要策略,避免多轮上下文过长
  3. 继续完善 Tool Result 策略,让确定性工具和非确定性工具的回答方式更清晰

项目地址

GitHub:

[github.com/HWYD/ai-min…]

如果这篇文章或这个项目对你有帮助,欢迎点个 Star 支持一下。

后续我也会继续按版本节奏,把这个项目一步步迭代下去。

大三面字节被问懵?手撕 WebSocket 与 SSE 底层原理,大厂通关指南

俗话说得好:“面试造火箭,工作拧螺丝”。但如果你连长连接的底层协议都搞不清楚,可能连进大厂拧螺丝的资格都没有。

昨天,隔壁寝室的哥们面字节暑期实习,直接被一道 408 场景题干得汗流浃背: “做过 Chat App 是吧?那你说说 WebSocket 和 SSE 有什么区别?接 DeepSeek 的流式输出该用哪个?”

很多同学平时写业务天天 npm install 调包,遇到网络层的问题直接“阿巴阿巴”。但在这个 AI 大模型全网刷屏的时代,长连接和流式输出早就成了前端和 Node.js 圈的绝对高频考点

作为一名见不得“屎山代码”的大三党,今天学弟就带大家抓个包,把 HTTP 轮询、WebSocket 和 SSE 的底层逻辑扒个底朝天。建议先 ⭐ 收藏,面试前拿出来背一遍,绝对让面试官对你刮目相看!


🤡 为什么说 HTTP 轮询是“外包级”方案?

假设现在需求是做一个在线聊天室。新手最爱干的事,就是写个 setInterval(),每隔 3 秒发个 Ajax 请求去问服务器:“大佬,有新消息吗?”

⚠️ 前方高能:这是典型的史诗级灾难写法! HTTP 是一个无状态、单向的短连接(Request-Response 模型)。你每次轮询,都要重新建立 TCP 连接(即使有 Keep-Alive 也会有巨大开销),还要带上一大堆臃肿的 HTTP Header。

打个通俗的比方:HTTP 就像是**“寄信”**。用轮询做聊天室,就像是你每隔 3 秒就去狂敲邮局的门问:“有我的信吗?”——不仅你累,服务器也得被你烦死,人一多直接原地宕机。


🚀 降维打击:WebSocket 的全双工魔法

为了终结这种愚蠢的轮询,HTML5 推出了 WebSocket 协议。这玩意儿一上来,直接把“寄信”跨时代地升级成了**“打电话”**。只要电话一接通,双方就可以毫无阻碍地互发消息。

Talk is cheap,我们先看一眼用 Koa 撸一个 WebSocket 服务器有多优雅:

JavaScript

const Koa = require('koa'); 
const websocket = require('koa-websocket');

// 注入 WebSocket 能力
const app = websocket(new Koa());
const clients = new Set(); // 维护客户端连接池

// 处理 WebSocket 长连接逻辑
app.ws.use(async (ctx, next) => {
    clients.add(ctx.websocket); // 用户上线
    
    // 服务端接收到消息时,广播给所有人(群聊核心逻辑)
    ctx.websocket.on('message', message => {
        for (const client of clients) {
            client.send(message.toString());   
        }
    })
    
    // 划重点:断开连接时必须清理内存,否则会导致内存泄漏!
    ctx.websocket.on('close', () => {
        clients.delete(ctx.websocket);
    })
})

app.listen(3000);

代码很简单,但面试官真正在意的是下面这两个底层护城河

💀 硬核揭秘 1:抓包看 101 协议升级 的密码学验证

面试官发难:“WebSocket 建立连接时发的是 HTTP 请求吗?”

拔掉网线,打开 Wireshark 或者 Network 面板抓个包,你会发现第一次握手的 Header 里藏着玄机:

HTTP

Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

看到这个 Sec-WebSocket-Key 了吗?服务端收到这串随机的 Base64 字符后,必须做一套极其严格的规范动作:

  1. 把这个 Key 与一个全球通用的魔法字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11)拼接。
  2. 进行 SHA-1 运算,再转成 Base64,生成 Sec-WebSocket-Accept 返回给客户端。

为什么要这么折腾?防黑客吗? 错!明文传输防个锤子。这是为了防止无意的**“缓存投毒” (Cache Poisoning)**,并且让客户端确认:“对面这台服务器是真的懂 WebSocket 协议,而不是碰巧返回了 200 OK”。

💀 硬核揭秘 2:为什么 WS 能发图片,而 HTTP 只能发文本?

WebSocket 传输的数据不叫报文,叫**“数据帧(Frame)”**。协议底层定义了一个 4 bit 的 Opcode(操作码)

  • Opcode = 0x1:浏览器知道这是一串文本
  • Opcode = 0x2:浏览器知道这是一坨二进制流,直接扔给 ArrayBuffer 处理图片或音视频。

这才是它能扛起复杂互动场景(如页游、直播弹幕)的全能底气。


🤖 大模型时代的新宠:SSE (Server-Sent Events)

既然 WebSocket 这么强,那为什么我们用 ChatGPT 或 DeepSeek 时,抓包发现它们根本没用 WebSocket,而是用了 SSE

因为业务场景变了! 大模型的“打字机效果”,是一个单向流式输出的过程。你发一句 Prompt,AI 连续吐出几百个词。这个场景根本不需要全双工双向发消息,只需要服务器单向高频推送即可!

💀 硬核揭秘 3:扒掉 SSE 的外衣,它的底层其实是 Chunked 编码

很多小白把 SSE 当成什么高深的新协议,大错特错!SSE 是 100% 纯正的 HTTP/1.1 协议。

它的核心黑科技,是利用了 HTTP 响应头里的 Transfer-Encoding: chunked(分块传输编码)

HTTP

Content-Type: text/event-stream
Transfer-Encoding: chunked
Connection: keep-alive

正常的 HTTP 请求必须带 Content-Length,浏览器拿到指定大小的数据就关门大吉。 但加上 chunked 后,服务器的意思是:“我也不知道 AI 要说多少废话,我一块一块(Chunk)发给你吧。”

服务器每次吐出一个字,就按 data: 你好\n\n 的格式发过去。浏览器底层的流处理器只要读到 \n\n,就知道一块数据到了,立刻触发前端的渲染。杀鸡焉用牛刀,处理单向推送,SSE 才是最优雅的神!


🔥 终极避坑:大厂必问的“心跳保活”机制

不管你用 WS 还是 SSE,只要写了“长连接”,面试官必放终极杀招: “如果用户进了电梯没信号了,或者直接拔了网线,你的服务器怎么知道他掉线了?”

千万别回答“等 TCP 超时断开”——TCP 底层的 Keep-Alive 默认要两小时才触发,那时候你服务器的连接池早被死链接撑爆了!

正确的做法是在应用层实现心跳机制 (Heartbeat)

  • 常规玩法:客户端定时器每隔 30 秒发一个 JSON 格式的 Ping 消息,服务器回复 Pong。超时未收到回复,前端主动断开并重连。
  • 满分玩法(针对 WebSocket) :利用刚才提到的底层帧结构!WebSocket 协议原生定义了 0x9 (Ping帧)0xA (Pong帧)。在 Node.js 中,你可以直接调用底层的 Ping/Pong 控制帧,连 JSON 序列化的性能损耗都省了,把并发性能压榨到极致!

🎯 总结:没有银弹,只有取舍

架构设计的魅力就在于“看菜下饭”:

  1. 联机游戏、协同文档、实时聊天室 👉 毫不犹豫选 WebSocket
  2. 大模型对话、站内单向消息通知 👉 选轻量级、原生兼容 HTTP 的 SSE

技术迭代浩浩荡荡,最后给各位技术大佬留个探讨题:随着 HTTP/2 和 HTTP/3 的普及,它们强大的多路复用和全双工特性,未来会让 WebSocket 退出历史舞台吗?

欢迎在评论区畅所欲言,学弟在线挨打交流!👇

不懂模块化就别谈前端工程化

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

前端工程化最基本的一步就是先学会模块化。简单来说,模块化就是把一大坨代码,拆成一个个小块,每个小块只做一件事,这样写起来和维护都方便多了。而且模块化还能让代码更容易被重复使用,像写好的请求封装、表单验证啥的,以后就不用再重新写一遍。多人合作的时候,模块化能让大家各做各的,互相不踩脚。更重要的是,像 Webpack 这种打包工具,都是基于模块化才能更好工作。常见的模块化写法有 CommonJS、ES Module 这些,学会了它们,工程化就有底子了。掌握模块化,等于给前端工程化打好地基!

什么是模块化

模块化的概念并不是一开始就有的。早期的网页都靠一个个大文件堆在一起,代码混乱又难维护。后来,项目越来越大,大家发现这样不行,得把功能拆分开。于是就有了“模块化”的想法:把代码分成小模块,每个模块只干一件事。这样一来,改东西的时候不容易出错,也能更好地复用代码。模块化也让多人一起开发的时候更有条理,减少冲突。现在常见的模块化方式有 CommonJS、ES Module 这些,都是让代码更清晰、管理更方便。掌握模块化,写项目会省心多了!

模块化的发展历程

石器时代

我们把这个过程称之为石器时代,因为这是最原始阶段,也是 JavaScript 刚被发明的时候(1995 年),它最早是被用来给网页加点动态效果,并没有考虑模块化。这就导致了一个很严重的问题:

  1. 全局变量污染

  2. 难以管理依赖

  3. 代码组织混乱

如下代码所示:

// a.js
const moment = 1;

// b.js
const moment = 2;

在 html 文件中我们有这样的代码来导入它们:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./a.js"></script>
    <script src="./b.js"></script>
  </body>
</html>

很多时候我们会直接在文件里定义变量,无论是自己写的代码、和其他开发成员合作时不同文件里的变量,还是引入的第三方库中的全局变量,都会在全局作用域中共享同一个空间,这种方式在 <script> 标签默认的全局执行环境下非常常见,也因此容易产生变量冲突或被覆盖,导致全局污染和命名冲突,正是因为这样的问题,后续才会有模块化方案来解决作用域隔离和依赖管理的痛点。

20250526193143

这样的问题就非常容易产生了。

IIFE

IIFE(Immediately Invoked Function Expression)的全称是立即执行函数表达式,意思是定义完毕立即执行的函数。它是 JavaScript 中的一种非常常见的语法结构,用来创建一个立即执行的函数作用域,避免污染全局变量。

它的基本语法如下所示:

(function () {
  // 这里是局部作用域
  var a = 1;
  console.log(a);
})(); // 立即执行

// 或者
(function () {
  var b = 2;
  console.log(b);
})();

这是借助了函数作用域,创建了一个私有空间(闭包)。在函数里定义的变量、函数,只在这个作用域可见,外部无法访问。

(function () {
  // 这里是局部作用域
  var a = 1;
  console.log(a);
})(); // 立即执行

// 或者
(function () {
  var b = 2;
  console.log(b);
})();

console.log(typeof a);

最终输出结果如下图所示:

20250526195433

通过这种方式,IIFE 可以避免全局污染,并且能把内部变量封装起来,外部无法访问;不过,它不如模块化方案直观易读,在模块化需求较多时,代码结构容易变得混乱。

CommonJs

为了解决 JavaScript 缺少模块化体系的问题,CommonJS 标准被提出了。它主要就是给 JavaScript 提供了一个模块化的规范,让我们可以像在其他语言里那样按需引入、按需导出,把大项目拆成小块再拼装起来。

Node.js 正是借助 CommonJS 的模块体系,才让模块化管理变得井井有条。比如:

// a.js
const moment = require("moment"); // 引入模块

module.exports = { sayHi: () => console.log("hi") }; // 导出模块

这样做,变量和功能都被封装在自己的模块里,不会再跑到全局作用域里去乱七八糟。

AMD

2011 年前后,浏览器端模块化火了,出现了 AMD(代表:RequireJS),它的出现最主要的一个原因就是浏览器端加载文件是异步的,不能再用 CommonJs 的同步方式了。

AMD"Asynchronous Module Definition" 的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

AMD 也采用 require()语句加载模块,但是不同于 CommonJS,它要求两个参数:

require([module], callback);

第一个参数 [module],是一个数组,里面的成员就是要加载的模块;第二个参数 callback,则是加载成功之后的回调函数。如果将前面的代码改写成 AMD 形式,就是下面这样:

require(["math"], function (math) {
  math.add(2, 3);
});

math.add()math 模块加载不是同步的,浏览器不会发生假死。所以很显然,AMD 比较适合浏览器环境。

接下来编写一个完整的 AMD 来实现这个完整的示例,如下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script
      data-main="main"
      src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"
    ></script>
  </head>
  <body>
    <h1>AMD 示例页面</h1>
  </body>
</html>

在这里的代码中使用的是 RequireJS CDN,它的关键点是 data-main="main",它告诉 RequireJS:页面加载完后去找 main.js 作为入口。

// math.js
define([], function () {
  // 这是一个模块
  return {
    add: function (a, b) {
      return a + b;
    },
    multiply: function (a, b) {
      return a * b;
    },
  };
});

这里用到了 define(),定义了一个模块,暴露 add 和 multiply 方法。

// main.js
require(["math"], function (math) {
  // 这里 math 就是 math.js 返回的模块对象
  var sum = math.add(3, 4);
  var product = math.multiply(3, 4);

  console.log("3 + 4 =", sum);
  console.log("3 * 4 =", product);

  // 也可以在页面显示
  var resultDiv = document.createElement("div");
  resultDiv.textContent = `3 + 4 = ${sum}, 3 * 4 = ${product}`;
  document.body.appendChild(resultDiv);
});

console.log(111222);

通过使用 require(['math'], callback),浏览器遇到后会异步加载 math.js,加载完毕后再执行回调,在回调里就能拿到 math 模块的内容,进行使用。

最终输出结果如下图所示:

20250528080714

UMD

CommonJS 和 AMD 在各自的领域(服务器端和浏览器端)都很好地解决了模块化问题,但它们之间存在兼容性问题。CommonJS 是同步加载模块的,适合服务器端,因为文件都在本地,加载速度快;而 AMD 是异步加载模块的,适合浏览器端,因为网络请求是异步的。这就导致了一个问题:如何编写一份代码,既能在 Node.js 环境下运行,又能在浏览器环境下运行,同时还能兼容 RequireJS 等 AMD 加载器?

为了解决这个问题,UMD(Universal Module Definition)应运而生。它是一种通用的模块定义规范,旨在创建一个能够兼容 CommonJS、AMD 和全局变量这三种模块化方案的代码模式。它的核心思想是,通过一套条件判断逻辑,检测当前运行环境支持哪种模块化方案,然后以对应的方式来定义和导出模块。这样,开发者就可以编写一份代码,无需修改就能在多种环境下使用。

那什么情况下是需要 UMD 呢?

  1. 跨环境兼容性: 如果你想编写一个 JavaScript 库,既希望它能在 Node.js 项目中使用(通过 CommonJS 模块),也希望它能在浏览器中直接作为 <script> 标签引入(暴露全局变量),同时还能被 RequireJS 等 AMD 加载器识别,那么 UMD 是一个非常理想的选择。

  2. 解决 CommonJS 和 AMD 的冲突: CommonJS 是同步加载的,而 AMD 是异步加载的。直接使用其中一种方案会导致在另一种环境中无法正常工作。UMD 通过判断环境来选择最合适的加载方式。

  3. 简化开发流程: 避免为不同的环境编写多份模块代码,提高代码复用性。

接下来我们将借助 Rollup 来帮我们来实现一个这种 UMD 格式的模块,首先安装所需要的模块:

pnpm add rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs -D

接下来我们再 src 目录下分别创建一个 index.js 文件和 utils.js 文件,并编写如下代码:

// utils.js
export function add(a, b) {
  return a + b;
}

// index.js
import { add } from "./utils";

export function greet(name) {
  return `Hello, ${name}! The sum is ${add(2, 3)}.`;
}

export function farewell(name) {
  return `Goodbye, ${name}!`;
}

代码编写完成之后我们要在根目录下创建一个 Rollup 配置文件:

// rollup.config.js
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";

export default {
  input: "src/index.js",
  output: {
    file: "dist/moment.umd.js",
    format: "umd",
    name: "Moment",
    globals: {
      // 如果你的库有外部依赖但不想打包进去,可以在这里配置
      // 'dayjs': 'dayjs' // 例如,如果依赖 dayjs,并且希望从全局变量获取
    },
  },
  plugins: [resolve(), commonjs()],
};

这个时候我们需要在 package.json 中添加一个大包脚本:

  "scripts": {
    "build": "rollup -c"
  },

这个时候我们就可以使用 pnpm build 来执行这些打包了,最终会输出一个 dist 目录:

20250528083501

最终输出的产物如下代码所示:

(function (global, factory) {
  typeof exports === "object" && typeof module !== "undefined"
    ? factory(exports)
    : typeof define === "function" && define.amd
    ? define(["exports"], factory)
    : ((global =
        typeof globalThis !== "undefined" ? globalThis : global || self),
      factory((global.Moment = {})));
})(this, function (exports) {
  "use strict";

  function add(a, b) {
    return a + b;
  }

  function greet(name) {
    return `Hello, ${name}! The sum is ${add(2, 3)}.`;
  }

  function farewell(name) {
    return `Goodbye, ${name}!`;
  }

  exports.farewell = farewell;
  exports.greet = greet;
});

上面这个代码片段就是是一个经典的 UMD(Universal Module Definition) 模式构建产物。

它能够检测当前运行环境,并以最合适的方式导出模块:

  1. CommonJS 环境 (如 Node.js):通过 module.exports 导出 farewell 和 greet 函数。

  2. AMD 环境 (如 RequireJS):通过 define(["exports"], factory) 异步定义并导出模块。

  3. 浏览器全局环境 (无模块加载器):将模块内容挂载到全局对象 global.Moment 上。

简而言之,这份代码让我们的 JavaScript 库能够无缝地在 Node.js、支持 AMD 的浏览器以及普通浏览器环境中使用,极大地提高了兼容性。

当我们在 HTML 文件中直接通过 <script src="./dist/moment.umd.js"></script> 引入这份 UMD 文件时,它会检测到当前是浏览器环境,并将模块内容挂载到全局对象 global.Moment 上。你就可以像使用任何全局变量一样使用它:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <h1>umd 示例页面</h1>
    <script src="./dist/moment.umd.js"></script>
    <script>
      console.log(Moment);
    </script>
  </body>
</html>

最终输出结果如下图所示:

20250528084015

尽管 ES Module 已经成为现代 JavaScript 模块化的主流,并在现代浏览器和 Node.js 中得到了原生支持,但 UMD 在向后兼容和跨环境发布库的场景中仍然占有一席之地。理解 UMD 有助于我们更好地理解 JavaScript 模块化的发展历程以及不同模块化方案之间的兼容性问题。

ESM

ES Module,也称为 ECMAScript 模块,是 JavaScript 语言本身在 ES2015 (ES6) 标准中正式引入的官方模块化方案。它旨在成为 JavaScript 模块化的标准,在浏览器和 Node.js 环境中都能原生支持。

与 CommonJS 和 AMD 这种由社区提出的规范不同,ESM 是语言层面的原生支持,这使得它在语法、语义和性能上都具有独特的优势。

深入理解 CommonJS

在 CommonJS 中,每一个被 require 的文件,在 Node.js 内部都会被封装成一个 Module 类的实例。这个 Module 实例携带了该模块的唯一标识(ID)、文件路径、父模块信息、子模块依赖、是否已加载等元数据。

最重要的,它提供了一个 exports 对象,你的模块代码就是通过操作这个对象来决定要向外部暴露什么内容的。当你 require 这个模块时,你得到的就是这个 Module 实例的 exports 属性。

// 此类继承的是 WeakMap
const moduleParentCache = new SafeWeakMap();

function Module(id = "", parent) {
  this.id = id; // 模块的识别符,通常是带有绝对路径的模块文件名
  this.path = path.dirname(id); // 文件当前的路径

  /
   * 相当于给构造函数 Module 上添加了一个 exports 为空对象
   * 等同于这样的写法 Module.exports = {};
   */
  setOwnProperty(this, "exports", {});

  // 返回一个弱引用对象,表示调用该模块的模块
  moduleParentCache.set(this, parent);
  updateChildren(parent, this, false);

  this.filename = null; // 模块的文件名,带有绝对路径
  this.loaded = false; // 是否已经被加载过,用作缓存
  this.children = []; // 返回一个数组,表示该模块要用到的其他模块
}

我们编写如下代码:

const foo = 1;

module.exports = { foo };

console.log(module);

当我们通过直接打印 module,终端上会有如下输出:

20250528093757

你看到的这个 module 对象,是 Node.js 在运行你的 index.js 文件时,专门为这个文件创建的一个“档案袋”或者说“容器”。这个档案袋里装着关于你这个文件(模块)的所有重要信息:

  • id: '.': 这就好像你的文件在这个程序里的“身份证号码”。当你是直接运行 node index.js 时,这个 index.js 就是主入口,它的 id 会被标记为 .,表示它是整个程序的“根”。

  • path: '/Users/macmini/Desktop/前端工程化': 这就是你的文件所在的文件夹路径。Node.js 在寻找你 require 的其他模块时,会用到这个路径来确定从哪里开始查找。

  • exports: { foo: 1 }: 这是最重要的!它是一个空盒子。你在这个 index.js 文件里写的所有 module.exports = ... 或者 exports.xxx = ... 的代码,都是在往这个盒子里装东西。当其他文件 require 你的 index.js 时,它们拿到的就是这个 exports 盒子里的内容。

  • filename: '/Users/macmini/Desktop/前端工程化/index.js': 这是你的文件的完整名字和路径,就像你的文件在这个电脑里的完整地址一样。

  • loaded: false: 这个告诉我们你的文件是否已经执行完毕。因为 console.log(module) 这行代码是在文件执行过程中打印的,所以此时模块还没有“加载完成”,还在运行,因此显示 false。等整个文件代码都运行完了,它才会变成 true

  • children: []: 如果你的 index.js 里有 require('其他文件') 的话,那些“其他文件”的 module 对象就会出现在这个数组里,表明你的文件依赖了哪些模块。现在它是空的,说明你的 index.js 没有直接 require 其他文件。

  • paths: [...]: 这是 Node.js 在你 require('第三方库名') (比如 require('lodash')) 时,会去依次查找这些目录来找到 node_modules 文件夹。它从你文件所在的目录开始,逐级向上查找。

  • Symbol(...) 开头的属性: 这些是 Node.js 内部使用的一些特殊标记。例如,kIsMainModule: true 再次强调你的文件是程序的主入口;kIsExecuting: true 则表示你的文件代码正在运行中。这些通常对开发者来说是内部实现细节,但也能帮助我们理解模块的生命周期。

简而言之,这个 module 对象就是 Node.js 对你的文件在模块系统中的“档案”,包含了它的身份信息、当前状态、以及如何与外部世界交互(通过 exports)的关键数据。

之所有会有这样的输出,主要是在 NodeJs 源码 中有这样的实现:

function Module(id = "", parent) {}

/** @type {Record<string, Module>} */

Module._cache = { __proto__: null };

/** @type {Record<string, string>} */

Module._pathCache = { __proto__: null };

/** @type {Record<string, (module: Module, filename: string) => void>} */

Module._extensions = { __proto__: null };

/** @type {string[]} */

let modulePaths = [];

/** @type {string[]} */

Module.globalPaths = [];

let patched = false;

let wrap = function (script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

const wrapper = [
  "(function (exports, require, module, __filename, __dirname) { ",
  "\n});",
];

let wrapperProxy = new Proxy(wrapper, {
  __proto__: null,

  set(target, property, value, receiver) {
    patched = true;

    return ReflectSet(target, property, value, receiver);
  },

  defineProperty(target, property, descriptor) {
    patched = true;

    return ObjectDefineProperty(target, property, descriptor);
  },
});

在上面的代码中, Module._cache 是一个缓存区,存储所有已经加载并执行过的模块实例。当你 require 一个模块时,Node.js 会先检查这个缓存,如果模块已经存在,就直接返回缓存中的实例,避免重复加载和执行,确保模块是单例的。 它存储在 Node.js 进程的全局 JavaScript 堆内存中,作为 Module 这个构造函数(或类)的一个静态属性(Module._cache),这意味着它不属于任何特定的模块实例,而是所有模块共享的一个全局数据结构。

wrap 函数和 wrapper 数组是 CommonJS 模块机制的核心,wrapper 数组包含了两个字符串 (function (exports, require, module, filename, dirname) { 是函数体的开始部分,'\n});' 是函数体的结束部分。

这个封装后的函数就是每个 CommonJS 模块被执行时所处的环境。它为你的模块提供了私有的作用域,并且注入了 exportsrequiremodule__filename__dirname 这些局部变量,这样你在模块里才能直接使用它们,而不会污染全局作用域。

module.exports 和 exports 的关系

我们继续来到这里的代码,这相当于给构造函数 Module 上添加了一个 exports 为空对象,等同于这样的写法 Module.exports = {},我们再来到这个文件代码的后面。

20250528203423

_compile 原型方法上定义了一个 exports 用来保存 Module.exports ,所以这也就是为什么 module.exports === exports 的原因了,实际上是它们共享同一块内存空间。

20250528203750

虽然他们共享的是同一块内存空间,但是最终被导出的是 module.exports 而不是 exports。值得注意的是 CommonJs 导出的是对象的引用,通过 require 之后 可以对其进行修改。

如下代码所示:

// utils.js

const object = {
  moment: "Moment",
};

setTimeout(() => {
  object.moment = "靓仔";
}, 2000);

module.exports = {
  object,
};

// main.js
const bar = require("./utils");

console.log("main.js", bar.object.moment); // main.js Moment

setTimeout(() => {
  console.log("2秒之后输出 ", bar.object.moment); // 2秒之后输出  靓仔
}, 2000);

最终的输出结果如下图所示:

20250528204706

验证了我们前面的说法。

CommonJs 读取的模块的缓存

在 Node.js 中,CommonJS 模块首次被 require() 后,其 module.exports 对象就会被缓存到内存中。这意味着,之后无论程序中何处再次 require() 同一个模块,Node.js 都不会重新加载和执行该模块的代码,而是直接返回缓存中的同一个实例。这种机制确保了模块只加载一次,并作为单例存在于整个应用生命周期中,从而优化了性能并避免了状态混乱。

如下代码所示:

// share.js
console.log("---- share.js 模块正在被加载和执行 ----");

let internalCounter = 0;

function increment() {
  internalCounter++;
}

function getCounter() {
  return internalCounter;
}

// 导出一些内容,包括一个时间戳,用于验证是否是同一个实例
module.exports = {
  increment,
  getCounter,
  loadTimestamp: new Date().toISOString(), // 记录模块被加载的时间
};

console.log("---- share.js 模块执行完毕 ----");

创建第一个使用共享模块的模块 (moduleA.js):

// moduleA.js

console.log("*** moduleA.js 开始执行 ***");

const shared = require("./share"); // 第一次 require share
shared.increment(); // 调用共享模块的方法
shared.increment(); // 再次调用,计数器应该增加到 2

console.log("moduleA.js 访问 share 计数器:", shared.getCounter());
console.log("moduleA.js 访问 share 加载时间:", shared.loadTimestamp);

console.log("*** moduleA.js 执行结束 ***");

// 导出 shared 模块的引用,方便 main.js 进一步验证
module.exports = { sharedModuleRef: shared };

创建第二个使用共享模块的模块 (moduleB.js):

// moduleB.js

console.log("*** moduleB.js 开始执行 ***");

const shared = require("./share"); // 第二次 require share (预期从缓存读取)
shared.increment(); // 再次调用共享模块的方法,计数器应该增加到 3

console.log("moduleB.js 访问 share 计数器:", shared.getCounter());
console.log("moduleB.js 访问 share 加载时间:", shared.loadTimestamp);

console.log("*** moduleB.js 执行结束 ***");

// 导出 shared 模块的引用
module.exports = { sharedModuleRef: shared };

接下来我们创建一个主入口文件 index.js:

// index.js

console.log("--- index.js 开始执行 ---");

const moduleAExports = require("./moduleA");
const moduleBExports = require("./moduleB");

console.log("\n--- 验证共享模块的实例 ---");

// 验证 moduleA 和 moduleB 得到的 share 引用是否相同
console.log(
  "moduleA.js 和 moduleB.js 获得的 share 是同一个引用:",
  moduleAExports.sharedModuleRef === moduleBExports.sharedModuleRef
);

// 验证最终的计数器值
console.log(
  "最终的共享模块计数器值:",
  moduleAExports.sharedModuleRef.getCounter()
); // 或者 moduleBExports.sharedModuleRef.getCounter()

console.log("--- index.js 执行结束 ---");

20250528210528

在上面的输出结果中 share.js 被多次 require() 但最终只执行了一次,说明的代码 share.js 只在 moduleA.js 第一次 require 它时被执行了,之后无论是 moduleB.js 再次 require 它,还是你后续再进行任何 require 操作,Node.js 都直接从缓存中拿取其导出的结果,不再重复执行模块文件。

还有一个最直接、最明确的证据。=== 运算符用于比较两个变量是否指向内存中的同一个对象。输出为 true 毫不含糊地表明 moduleArequire 到的 share 引用和 moduleBrequire 到的 share 引用,它们指向的是内存中的同一个 JavaScript 对象。

require 查找细节

require(X) 中的 X 指向一个核心模块时,Node.js 会直接返回对应的内置模块,并立即停止后续查找。这些核心模块,如 httpfsurlpathEvents,是用 C/C++ 编写的,因此在性能上表现优异。它们在 Node.js 编译时就被集成到二进制文件中,并在 Node 进程启动时直接加载到内存,无需额外的定位或编译过程,从而实现了极致的加载效率。

20250528211143

X 是一个路径(以 ./..// 开头)时,Node.js 会尝试解析它:

  • 如果 X 指向一个文件夹,Node.js 会依次查找该文件夹下的 index.jsindex.json,最后是 index.node 文件。

  • 如果 X 指向一个文件但没有后缀名,Node.js 则会尝试追加 .js.json.node 后缀来查找对应文件。

而当 X 既不是路径也不是核心模块(即一个裸模块名,如 lodash)时,Node.js 会从当前目录的 node_modules 文件夹开始,逐级向上查找父目录中的 node_modules,直到文件系统根目录。如果遍历所有这些路径后仍未找到该模块,系统将报错提示。

如下代码所示:

console.log(module.paths);

20250528211350

它会一层一层网上查找,如果没有查到,会报没有找到的错误:

20250528211516

有了路径之后,下面就是 Module.findPath() 的源码,用来确定哪个是正确的路径,其中以下代码有省略的:

Module._findPath = function (request, paths, isMain) {
  // 如果是绝对路径,则不在搜索,返回空
  const absoluteRequest = path.isAbsolute(request);
  if (absoluteRequest) {
    paths = [""];
  } else if (!paths || paths.length === 0) {
    return false;
  }

  // 第一步:如果当前路径已在缓存中,就直接返回缓存
  const cacheKey = request + "\x00" + ArrayPrototypeJoin(paths, "\x00");
  const entry = Module._pathCache[cacheKey];
  if (entry) return entry;

  let exts;
  // 是否有后缀的目录斜杠
  const trailingSlash = "..."; //省略了很多代码
  // 是否相对路径
  const isRelative = "..."; // 省略了很多代码
  let insidePath = true;
  if (isRelative) {
    const normalizedRequest = path.normalize(request);
    if (StringPrototypeStartsWith(normalizedRequest, "..")) {
      insidePath = false;
    }
  }

  // 遍历所有路径
  for (let i = 0; i < paths.length; i++) {
    const curPath = paths[i];
    if (insidePath && curPath && _stat(curPath) < 1) continue;

    if (!absoluteRequest) {
      const exportsResolved = resolveExports(curPath, request);
      if (exportsResolved) return exportsResolved;
    }

    const basePath = path.resolve(curPath, request);
    let filename;

    const rc = _stat(basePath);
    if (!trailingSlash) {
      if (rc === 0) {
        // File.
        if (!isMain) {
          if (preserveSymlinks) {
            filename = path.resolve(basePath);
          } else {
            filename = toRealPath(basePath);
          }
        } else if (preserveSymlinksMain) {
          filename = path.resolve(basePath);
        } else {
          filename = toRealPath(basePath);
        }
      }

      if (!filename) {
        if (exts === undefined) exts = ObjectKeys(Module._extensions);
        // 该模块文件加上后缀名,是否存在
        filename = tryExtensions(basePath, exts, isMain);
      }
    }

    if (!filename && rc === 1) {
      if (exts === undefined) exts = ObjectKeys(Module._extensions);
      // 目录中是否存在 package.json
      filename = tryPackage(basePath, exts, isMain, request);
    }

    if (filename) {
      // 将找到的文件路径存入返回缓存,然后返回
      Module._pathCache[cacheKey] = filename;
      return filename;
    }
  }
  // 如果没有找打返回 false
  return false;
};

我们已经了解了核心模块因 C/C++ 实现而拥有极高的加载速度。然而,为了让这些底层用 C/C++ 编写的内建模块能够无缝地融入 JavaScript 的 CommonJS 模块体系并被 require 函数调用,其内部引入流程却相当复杂。它需要经历多个层面的封装和定义,包括 C/C++ 层的内建模块定义、JavaScript 核心模块的适配和封装,最终才能在 (JavaScript) 文件模块层面被正常引入和使用,以此确保了兼容性和性能的最佳平衡。

20250528212004

整个流程是:用户在 JavaScript 中 require 一个核心模块 -> Node.js 的 JavaScript 层 NativeModule 识别并处理 -> NativeModule 调用 process.binding 进入 C++ 层 -> C++ 层查找并加载对应的预编译模块 -> C++ 模块将其功能以 JavaScript 对象的形式导出,最终返回给用户。这个复杂的分层设计,既保证了核心模块的极致性能,又使其能够无缝融入 Node.js 的 CommonJS 模块加载体系。

一旦 Node.js 确定了模块的准确路径,就可以着手加载它了。你可能会好奇:require 函数究竟从何而来,为何在每个模块中都能“凭空”使用?它背后又执行了哪些操作?

实际上,require 并非一个全局变量。它是 Node.js 在执行每个 CommonJS 模块之前,通过模块封装函数(就是我们之前提到的那个 (function (exports, require, module, __filename, __dirname) { ... });)作为局部参数,注入到该模块的作用域中的。

而这个注入的 require 函数,其核心功能正是来源于 Module 构造函数原型上的 require 方法,它负责执行模块的查找、加载、缓存以及最终返回导出内容的完整流程。

Module.prototype.require = function (id) {
  // 进行简单的 id 变量的判断,需要传入的 id 是一个 string 类型。
  validateString(id, "id");
  if (id === "") {
    throw new ERR_INVALID_ARG_VALUE("id", id, "must be a non-empty string");
  }
  // 默认为0,表示还没有使用过这个模块,每使用一次便自增一次

  requireDepth++;
  try {
    // 用于检查是否有缓存,有则从缓存里查找
    return Module._load(id, this, /* isMain */ false);
  } finally {
    // 每次结束后递减一个,用于判断递归的层次
    requireDepth--;
  }
};

看完了 require 的了,我们再看看构造函数的静态方法 _load:

Module._load = function (request, parent, isMain) {
  let relResolveCacheIdentifier;
  if (parent) {
    debug('Module._load REQUEST %s parent: %s', request, parent.id);
    relResolveCacheIdentifier = `${parent.path}\x00${request}`;
    // 以文件的绝对地址当成缓存 key
    const filename = relativeResolveCache[relResolveCacheIdentifier];
    reportModuleToWatchMode(filename);
    if (filename !== undefined) {
      // 先通过 key 从缓存中获取模块
      const cachedModule = Module._cache[filename];
      if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        if (!cachedModule.loaded)
          // 如果要加载的模块缓存已经存在,但是并没有完全加载好,这是解决循环引用的关键
          return getExportsForCircularRequire(cachedModule);

        // 已经加载好的模块,直接从缓存中读取返回
        return cachedModule.exports;
      }
      // 判断缓存是否存在父模块中,存在则删除
      delete relativeResolveCache[relResolveCacheIdentifier];
    }
  }

  // 判断是否为 node: 前缀的,也就是判断是否为原生模块
  if (StringPrototypeStartsWith(request, 'node:')) {
    // Slice 'node:' prefix
    const id = StringPrototypeSlice(request, 5);

    const module = loadBuiltinModule(id, request);
    if (!module?.canBeRequiredByUsers) {
      throw new ERR_UNKNOWN_BUILTIN_MODULE(request);
    }

    return module.exports;
  }

这个函数的核心逻辑是:它会首先检查请求的模块是否已经存在于内部缓存中——如果已缓存,则直接返回其 exports 对象。如果模块带有 node: 前缀(表明是显式引入的内置模块),则会调用专门的 loadBuiltinModule() 方法处理并返回结果。除此之外,对于所有其他尚未加载过的模块,它会创建一个新的模块实例,执行其代码,并将最终导出的结果保存到缓存中,以供后续快速访问。

CommonJS 通过在检测到循环引用时,立即从缓存中返回模块当前已有的 exports 对象来解决。这意味着,如果一个模块(A)在被 require 时发现它自己又 require 了另一个模块(B)而 B 又 require 了 A,它会立刻提供 A 当前已经导出的部分内容。尽管这个 exports 对象可能是不完整的(缺少尚未执行的代码所导出的属性),但这种机制避免了死锁,并允许模块执行继续进行。

小结

require 的流程图正如下图所示:

20250528213120

Node.js 的 require 模块加载流程包含五个主要阶段。首先是解析(Resolution),确定模块的精确路径;接着是加载(Loading),读取文件内容。然后是包装(Wrapping),将代码放入 CommonJS 函数封装中;随后进行执行(Evaluation),运行模块代码并生成导出内容。最后,模块的导出结果会被缓存(Caching)起来,以确保后续对同一模块的 require 调用能高效地直接获取缓存实例。

CommonJS 模块的加载是同步的,意味着它会阻塞后续代码执行,这在服务器端因文件本地加载速度快而高效,但在浏览器中可能引发阻塞问题。它通过 module.exports 以对象形式导出内容,并且对每个加载的模块都存在缓存,确保无论何时何地 require 同一个模块,都只会得到并操作同一个模块实例。这种缓存机制不仅提升了性能,也有效地处理了模块间的循环引用,避免了死锁。

深入理解 ES Modules

默认情况下,普通的 JavaScript 脚本(包括那些用于旧浏览器兼容的 nomodule 脚本)会阻塞 HTML 解析和页面渲染。为了避免这种阻塞行为,你可以为这些脚本添加 defer 属性。带有 defer 属性的脚本会在 HTML 文档完全解析完毕后才开始执行,并且会按照它们在文档中出现的顺序执行,有效避免了阻塞页面内容呈现。

20250528214610

deferasync 是脚本标签的互斥可选属性,用于控制脚本的加载与执行时机。

对于常规脚本(包括 <script nomodule> 脚本),defer 属性确保脚本在 HTML 解析完成后才按顺序执行,避免阻塞页面渲染;而 async 属性则允许脚本与 HTML 并行解析和下载,并在可用时立即执行,不保证其执行顺序。

至于模块脚本 (<script type="module">),它们的默认行为就类似于 defer,即异步获取并在 HTML 解析后执行。但如果为模块脚本明确指定 async 属性,它及其所有依赖项都将与 HTML 解析并行获取,并一旦可用便立即执行,此时模块的执行顺序不再得到保证。

当我们用 ES Module(import / export)来写前端代码时,JavaScript 引擎在背后会做很多“幕后工作”来帮我们管理这些模块。比如:模块要有自己的作用域(不能全都放到全局变量去乱七八糟),还要能让模块之间互相导入导出,保证变量不会乱改。

这些幕后工作就靠了模块记录(Module Record)和模块环境记录(Module Environment Record)这样的底层概念,它们属于 JavaScript 引擎内部的数据结构,帮我们管理和组织模块。

Module Record

模块记录(Module Record)用来封装一个模块的导入和导出等结构化信息。这些信息在模块链接时非常关键,用来把一个个模块的输入输出都串联起来。一个模块记录里通常包含四个字段:

  1. Realm:用来创建当前模块的作用域。

  2. Environment:模块顶层的绑定环境记录,在模块被链接时设置。

  3. Namespace:模块的命名空间对象,能让外部通过运行时属性访问模块的导出。这个对象本身是“外来对象”,并且没有构造函数。

  4. HostDefined:这个字段是留给宿主环境(host environments)用的,方便在模块中附加额外信息。

Module Environment Record

模块环境记录是 ECMAScript 中的一种特殊的声明性环境记录,用来表示模块的外部作用域。 和普通的作用域环境记录不太一样,它在支持普通变量绑定的同时,还特别提供了不可变的 import 绑定。这些 import 绑定让模块内部能间接访问另一个模块里的变量,但又保证了这些变量不能被修改。

换句话说,不可变绑定就是指模块引入别的模块时,虽然能使用这些导入的变量,但不能在当前模块中直接更改它们,这也是模块化语法的一大特色。

Es Module 的解析流程

在开始之前,我们先大概了解一下整个流程大概是怎么样的,先有一个大概的了解:

  1. 构建(Construction):浏览器根据模块的地址找到对应的 JS 文件,通过网络下载,并把代码解析成一个内部的模块记录(Module Record),为后续步骤做准备。

  2. 实例化(Instantiation):对模块进行实例化,分配内存空间,分析并处理模块里的 import 和 export 语句,让这些变量在内存中有了位置和映射关系。

  3. 执行(Evaluation):真正运行模块里的代码,计算值,并把值写入内存,模块就正式被执行起来了。

Construction 构建阶段

在这个阶段,loader(加载器)负责模块的寻址和下载。它首先从入口文件开始加载,通常在 HTML 中使用 <script type="module"></script> 标签来声明这是一个模块文件。加载器会根据这个入口,去查找并下载模块代码,准备后续的实例化和执行。

20250528215643

模块继续通过 import 语句来声明需要的依赖。在 import 声明中,有一个模块声明标识符(ModuleSpecifier),它告诉 loader 如何去查找下一个模块的地址。

20250528215735

每一个模块标识符都对应着一个模块记录(Module Record),而每个模块记录中包含了:

  • JavaScript 代码本身

  • 执行上下文

  • 以及四种重要的表项:ImportEntriesLocalExportEntriesIndirectExportEntriesStarExportEntries

其中,ImportEntries 是一个 ImportEntry Records 类型的结构,记录了模块里所有的 import 语句信息;

LocalExportEntriesIndirectExportEntriesStarExportEntries 都是 ExportEntry Records 类型的结构,记录了模块的各种导出方式。

ImportEntry Records

一个 ImportEntry Record 记录了当前模块中 import 语句的具体信息,它包含三个字段:

  1. ModuleRequest:模块标识符(ModuleSpecifier),告诉系统从哪里去找这个模块。

  2. ImportName:要从 ModuleRequest 指定的模块中导入的具体名称。值 namespace-object 表示这次导入的是目标模块的命名空间对象。

  3. LocalName:当前模块内部用来引用导入值的变量名,也就是在你自己模块里写的名字。

详情可参考下图:

20250528220153

下面这张表记录了使用 import 导入的 ImportEntry Records 字段的实例:

导入声明 (Import Statement From) 模块标识符 (ModuleRequest) 导入名 (ImportName) 本地名 (LocalName)
import React from "react"; "react" "default" "React"
import * as Moment from "react"; "react" namespace-obj "Moment"
import {useEffect} from "react"; "react" "useEffect" "useEffect"
import {useEffect as effect } from "react"; "react" "useEffect" "effect"
ExportEntry Records

一个 ExportEntry Record 记录了当前模块中的导出信息,它包含四个字段:

  1. ExportName:导出的名称,也就是别的模块在 import 时用到的名字。

  2. ModuleRequest:模块标识符(ModuleSpecifier),如果是间接导出(export { a } from 'x')时,指定从哪里引入。

  3. ImportName:当是间接导出时,要从 ModuleRequest 指定的模块中导出的具体名称。

  4. LocalName:当前模块里要导出的变量名。

ImportEntry Records 不同的是,ExportEntry Records 多了一个 ExportName,专门用来描述这个导出的名字。

下面这张表记录了使用 export 导出的 ExportEntry Records 字段的实例:

导出声明 导出名 模块标识符 导入名 本地名
export var v; "v" null null "v"
export default function f() {} "default" null null "f"
export default function () {} "default" null null "default"
export default 42; "default" null null "default"
export {x}; "x" null null "x"
export {v as x}; "x" null null "v"
export {x} from "mod"; "x" "mod" "x" null
export {v as x} from "mod"; "x" "mod" "v" null
export * from "mod"; null "mod" all-but-default null
export * as ns from "mod"; "ns "mod" all null

回到主题,只有当解析完当前的 Module Record 之后,才能知道当前模块依赖的是那些子模块,然后你需要 resolve 子模块,获取子模块,再解析子模块,不断的循环这个流程 resolving -> fetching -> parsing,结果如下图所示:

20250528220636

这个过程也被称为静态分析,它只会识别 exportimport 关键字,不会真正执行 JavaScript 代码。也正因为这样,import 语句只能出现在全局作用域中,动态导入(import())除外。

那如果多个文件同时依赖同一个模块,会不会引起死循环呢?答案是:不会。

这是因为 loader 使用了一个叫做 Module Map 的东西,来追踪和缓存全局范围内所有的 Module Record。这确保了每个模块只会被 fetch 一次,避免了重复加载或死循环的问题。并且,每个全局作用域都有自己的独立 Module Map。

Module Map 是一个 key/value 结构的映射对象,key 是一个 URL(模块的请求地址),value 是模块类型的字符串(比如 “javascript”)。 模块映射的值可以是模块脚本、null(表示获取失败),或者一个占位符 fetching(表示正在获取中)。

如下图所示:

20250528220855

linking 链接阶段

在所有 Module Record 解析完成后,接下来 JavaScript 引擎会对这些模块进行链接。引擎会从入口文件的 Module Record 开始,按照深度优先的顺序,递归地把依赖的模块链接起来。

在这个过程中,引擎会为每个 Module Record 创建一个 Module Environment Record,用来管理当前模块中声明的变量。

20250528221028

Module Environment Record 中有一个叫做 Binding 的东西,用来存放 Module Record 里导出的变量。比如在模块 main.js 中导出了一个名为 count 的变量,那么在 Module Environment Record 中的 Binding 就会包含一个 count,为这个变量分配内存空间,但初始值是 undefinednull

这个过程类似于 V8 在编译阶段时,先创建一个模块实例对象,并为其中的变量和方法分配内存空间。 当子模块 count.js 中通过 import 关键字导入 main.js 时,count.jsimport 变量和 main.jsexport 变量指向的内存位置是相同的,这样就把父子模块之间的关系联系在一起了。

如下图所示:

20250528221222

需要注意的是,我们称 export 导出的为父模块,import 引入的为子模块,父模块可以对变量进行修改,具有读写权限,而子模块只有读权限。

Evaluation 求值阶段

在所有模块完成链接后,JavaScript 引擎会进入求值阶段。这时,它会按照模块的依赖顺序,执行各个模块文件中的顶层作用域代码。 执行过程中,引擎会将之前在链接阶段中分配好内存空间的变量,赋予实际的运行时值。

这样,模块中声明的变量和导出的内容就真正填充到内存中,整个模块的功能也随之生效。求值阶段也是模块真正开始“工作”的时候,确保模块之间的导入导出关系和依赖都能正确执行。

ES Module 是如何解决循环引用的

在 ES Module 中,模块加载和执行过程通过五种状态来管理,分别是:unlinkedlinkinglinkedevaluatingevaluated

模块的状态存储在 循环模块记录(Cyclic Module Records)的 Status 字段中。通过这个状态,JavaScript 引擎可以判断一个模块是否已经被执行过,从而确保每个模块只会被执行一次。

这也是为什么引擎会使用 Module Map 来缓存全局的 Module Record,保证只在第一次加载时 fetch 并执行一次。

如果检测到一个模块的状态已经是 evaluated,下次再遇到它就会跳过执行,避免了死循环的发生。ES Module 会使用深度优先的方式遍历整个模块图,逐个执行模块的顶层代码,并且只会执行一次,从根本上避免了重复加载和死循环的问题。

深度优先搜索(Depth-First-Search,DFS)是一种常用的图遍历算法,它会尽可能深地搜索一个分支的节点,直到该分支的所有节点都被访问过,再回退到上一层继续探索其他分支。通过这种方式,ES Module 确保了每个模块都能被访问到一次,并且不会重复执行。

20250528221954

来看下面这个循环引用的例子,三个模块之间互相引用,但都只会执行一次:

// main.js
import { bar } from "./bar.js";
export const main = "main";
console.log("main");

// foo.js
import { main } from "./main.js";
export const foo = "foo";
console.log("foo");

// bar.js
import { foo } from "./foo.js";
export const bar = "bar";
console.log("bar");

在 Node.js 中运行 main.js,会得到下面的结果:

20250528221921

可以看到,每个模块只会输出一次,即使循环依赖也不会导致死循环。

总结

前端模块化是将大型代码拆分成独立小块的开发方式,每个模块专注单一功能,提高了代码的可维护性和复用性。模块化经历了从石器时代的全局变量污染,到 IIFE 函数作用域隔离,再到 CommonJS、AMD、UMD 等规范的发展历程。CommonJS 采用同步加载适合服务器端,通过 require/module.exports 实现模块导入导出并具有缓存机制;而 ES Module 是 JavaScript 官方标准,采用异步加载和静态分析,通过 import/export 语法提供更好的性能和树摇优化。掌握模块化是前端工程化的基础,为后续使用 Webpack 等构建工具奠定了重要基础。

AI 对话应用之页面滚动交互的实现

需求背景

现代化的一个 AI 对话聊天应用,需要有一个对用户良好的一个交互处理。业务当中因为要简单的接入一个 AI 对话页面,因此这里是对市面上一些 AI 对话软件的交互进行参考分析,发现基本上都是类似的用户交互处理:

在 AI 思考对话返回当中不断的对 AI 思考内容的时候页面的默认逻辑是会不断的自动滚动到页面底部操作处理;

而当用户如果进行鼠标滚动或者进行页面触摸滚动之后,则对相关的默认页面自动滚动;当用户主动重新将页面滚动到当前页面靠近底部后则重新再次启动输出 AI 思考内容后自动进行页面滚动的逻辑。


具体实现

页面自动滚动

首先就是接口流式返回内容时候进行页面自动滚动的处理操作处理:

这里是通过 MutationObserver 这个 api 我们能够对对话监听页面内容自动进行页面滚动处理

MDN MutationObserver API: developer.mozilla.org/zh-CN/docs/…

与 IntersectionObserver 类似的用法,通过MutationObserver 这个 API 可以监听 DOM 节点树内容的变化,能够捕获多种类型的 DOM 变化,包括节点添加或删除、属性变化、文本内容变化等。

使用 MutationObserver 监听节点内容变化的基本步骤:

// 1. 创建一个 MutationObserver 实例,并传入回调函数:
const observer = new MutationObserver((mutationsList, observer) => {
  for (let mutation of mutationsList) {
    if (mutation.type === 'childList') {
      console.log('子节点发生变化');
    } else if (mutation.type === 'attributes') {
      console.log('属性发生变化');
    } else if (mutation.type === 'characterData') {
      console.log('文本内容发生变化');
    }
  }
});

// 2. 定义观察选项,指定要监听的变化类型:
const config = {
  attributes: true,
  childList: true,
  subtree: true,
  characterData: true  // 监听文本内容变化
};

// 3. 开始观察目标节点:
const targetNode = document.getElementById('target');
observer.observe(targetNode, config);

// 4. 当不需要继续监听时,可以停止观察:
observer.disconnect();

结合在 AI 对话项目页面当中相关逻辑:

let observer: MutationObserver
const messagesContainer: any = document.getElementById('AiChatContent')

observer = new MutationObserver(() => {
  messagesContainer.scrollTo({
    top: messagesContainer.scrollHeight,
    behavior: 'smooth', // 
  })
})

const stopObserverScroll = () => {
  observer && observer.disconnect()
}

const startObserverScroll = () => {
  observer && observer.observe(messagesContainer, {
    /** 观察器的配置(需要观察什么变动) **/
    childList: true, // 观察目标子节点的变化,是否有添加或者删除
    subtree: true, // 观察后代节点,默认为 false
    characterData: true, // 文本
    attributes: true, // 观察属性变动
  })
}

// 开始监听
startObserverScroll()

用户交互取消页面自动滚动或重新触发页面自动滚动

接着是当我们对手机移动端页面进行触摸滚动或者PC滑轮滚动查看前面的对话内容时候,需要暂停掉网页的自动滚动到页面底部的逻辑;

这就需要我们对页面触摸滚动的监听以及滚轮事件监听处理,而移动端的触摸事件是touchstarttouchend,PC 网页的鼠标滚轮事件则是wheel

  1. 因此我们需要在 AI 聊天页面初始化时候需要对这两个事件进行监听处理,当触发了鼠标滚轮或者移动端触摸事件时候需要对 AI 聊天消息容器 DOM 节点的变化监听进行暂停;
  • 可以在移动端触摸开始事件或者鼠标滚轮触发时候暂停掉监听并且设置一个用户主动滚动标识;
  • 改造前面的 MutationObserver 节点内容监听回调逻辑,判断根据这个用户主动滚动标识再进行是否需要进行 scrollTo 滚动处理;
  1. 当我们重新将页面滚动到内容底部的时候(对相关的触摸结束或者鼠标滚轮结束事件进行判断以及针对页面滚动),需要重新触发页面的流式数据返回内容自动滚动的逻辑;
  • 要重新触发内容自动滚动逻辑则需要重新启用 MutationObserver 对节点的监听处理
  • 并且将用户主动滚动标识设置为 false;
  1. 这里关于移动端触摸滑动有一个留意的交互点就是在触摸手势结束之后会有一个滑动的惯性(平滑滑动效果),如果平滑滚动到页面底部的情况下也是需要进行重新触发页面的流式数据返回内容自动滚动的逻辑;
  • 因此这里对节点的滚动事件(scroll)进行一个监听,并且优化如果是此时正在触摸滚动或者此时已经是自动滚动模式下都不进行判断
const AUTO_SCROLLING = 1;
const USER_SCROLLING = 2;
let SCROLLING_MODE: AUTO_SCROLLING | USER_SCROLLING = AUTO_SCROLLING;

const observer: MutationObserver = new MutationObserver(() => {
  // 避免用户主动触摸滚动时互斥的自动滚动到底部,互相抢滚动交互
  if (SCROLLING_MODE === USER_SCROLLING) {
    messagesContainer.scrollTo({
      top: messagesContainer.scrollHeight,
      behavior: pageHasInit ? 'smooth' : 'auto',
    })
  }
})

const updateScrollState = () => {
  const { scrollHeight, clientHeight, scrollTop } = messagesContainer || {};

  // 简单判断滚动到底部了,是的话将用户主动触摸滚动的标识去除
  if (scrollTop + clientHeight >= (scrollHeight - 50)) {
    SCROLLING_MODE = AUTO_SCROLLING
    startObserverScroll() // 重新对节点内容进行监听处理
  }
}

const handleScroll = () => {
  if (SCROLLING_MODE === USER_SCROLLING) { // 优化,只有用户正在主动滚动时候才进行判断
    updateScrollState()
  }
}

const handleTouchStart = () => {
  stopObserverScroll() // 停止节点内容的监听
  SCROLLING_MODE = USER_SCROLLING
}

const handleTouchEnd = () => {
  updateScrollState()
}

const handleWheelEvent = () => {
  handleTouchStart()

  // 简单的实现一个去抖操作处理滚轮事件的结束时候触发
  wheelTimer && clearTimeout(wheelTimer)
  wheelTimer = setTimeout(() => {
    updateScrollState()
  }, 150)
}

messagesContainer.addEventListener('scroll', handleScroll)
messagesContainer.addEventListener('touchstart', handleTouchStart)
messagesContainer.addEventListener('touchend', handleTouchEnd)
messagesContainer.addEventListener('wheel', handleWheelEvent)

封装相关的 Hooks

笔者的 AI 对话项目使用的是 React 技术栈,这里就使用 React Hooks 作为例子(Vue 3.x 的技术栈也是类似的)

组件初始化时候添加相关的事件监听逻辑;

组件卸载时候将相关的事件监听进行取消处理;

import { useEffect } from 'react'

export const useChatContainer = (tagId: string) => {
  const AUTO_SCROLLING = 1;
  const USER_SCROLLING = 2;
  let SCROLLING_MODE: AUTO_SCROLLING | USER_SCROLLING = AUTO_SCROLLING;

  const messagesContainer: any = document.getElementById(tagId)

  const observer: MutationObserver = new MutationObserver(() => {
    // 避免用户主动触摸滚动时互斥的自动滚动到底部,互相抢滚动交互
    if (SCROLLING_MODE === USER_SCROLLING) {
      messagesContainer.scrollTo({
        top: messagesContainer.scrollHeight,
        behavior: pageHasInit ? 'smooth' : 'auto',
      })
    }
  })

  const updateScrollState = () => {
    const { scrollHeight, clientHeight, scrollTop } = messagesContainer || {};
  
    // 简单判断滚动到底部了,是的话将用户主动触摸滚动的标识去除
    if (scrollTop + clientHeight >= (scrollHeight - 50)) {
      SCROLLING_MODE = AUTO_SCROLLING
      startObserverScroll() // 重新对节点内容进行监听处理
    }
  }

  const handleScroll = () => {
    if (SCROLLING_MODE === USER_SCROLLING) { // 优化,只有用户正在主动滚动时候才进行判断
      updateScrollState()
    }
  }

  const handleTouchStart = () => {
    stopObserverScroll() // 停止节点内容的监听
    SCROLLING_MODE = USER_SCROLLING
  }

  const handleTouchEnd = () => {
    updateScrollState()
  }

  const handleWheelEvent = () => {
    handleTouchStart()
  
    // 简单的实现一个去抖操作处理滚轮事件的结束时候触发
    wheelTimer && clearTimeout(wheelTimer)
    wheelTimer = setTimeout(() => {
      updateScrollState()
    }, 150)
  }

  const startObserverScroll = () => {
    observer && observer.observe(messagesContainer, {
      /** 观察器的配置(需要观察什么变动) **/
      childList: true, // 观察目标子节点的变化,是否有添加或者删除
      subtree: true, // 观察后代节点,默认为 false
      characterData: true, // 文本
      attributes: true, // 观察属性变动
    })
  }

  const stopObserverScroll = () => {
    observer && observer.disconnect()
  }

  const startEventListener = () => {
    startObserverScroll()
    messagesContainer.addEventListener('scroll', handleScroll)
    messagesContainer.addEventListener('touchstart', handleTouchStart)
    messagesContainer.addEventListener('touchend', handleTouchEnd)
    messagesContainer.addEventListener('wheel', handleWheelEvent)
  }
  
  const removeEventListener = () => {
    stopObserverScroll()
    messagesContainer.removeEventListener('scroll', handleScroll)
    messagesContainer.removeEventListener('touchstart', handleTouchStart)
    messagesContainer.removeEventListener('touchend', handleTouchEnd)
    messagesContainer.removeEventListener('wheel', handleWheelEvent)
  }

  useEffect(() => {
    startEventListener()
    
    return () => {
      removeEventListener()
    }
  }, [])
}

hooks 使用的 eg:

import { forwardRef, useEffect, PropsWithChildren } from 'react'

import { useChatContainer } from '@/utils/chat/useChatContainer'

import './index.scss'

interface AiChatContainerProps {
}

const AiChatContainer = forwardRef(({}: PropsWithChildren<AiChatContainerProps>, ref) => {
  useChatContainer('AiChatContent');
  
  return (
    <div
      id='AiChatContent'
      className='ai-chat-container'
    >
      <!- AI 聊天内容等 ··· ->
    </ScrollView>
  )
})

AiChatContainer.defaultProps = {
}

export default AiChatContainer

Flutter 弹窗 UI 不刷新?用 StatefulBuilder 解决

问题背景

在使用 Flutter 开发时,通过 showDialog 弹出的对话框,点击内部按钮后 UI 不会实时更新,相信不少开发者都踩过这个坑。

比如我们在弹窗里放了一个下拉选择器或筛选按钮,点击后数据变了,但界面没有任何视觉反馈,用户体验很差。

问题根因

showDialog 创建的弹窗,其 Widget 树与父页面是隔离的。父页面的 setState 只会触发自身 Widget 树的重建,无法让弹窗内部也跟着刷新。

看一个典型的问题代码:

void _showDialog() {
  showDialog(
    context: context,
    builder: (ctx) => AlertDialog(
      title: Text('选择大小端'),
      content: DropdownButton<Endian>(
        value: _selectedEndian,      // 从父组件传入
        items: [...],
        onChanged: (v) {
          setState(() {
            _selectedEndian = v;      // 更新父状态
          });
          _saveConfig(v);             // 执行业务逻辑
        },
      ),
    ),
  );
}

点击下拉框后,setState 更新了 _selectedEndian,但弹窗的 UI 没有重建,因为 AlertDialog 不在 setState 触发的那棵 Widget 树下。

解决方案:StatefulBuilder

Flutter 官方早就想到了这个问题,提供了 StatefulBuilder 这个 widget,它能在弹窗内部创建独立的状态管理能力。

核心用法

void _showDialog() {
  showDialog(
    context: context,
    builder: (ctx) => StatefulBuilder(    // 用 StatefulBuilder 包裹弹窗
      builder: (ctx, dialogSetState) {
        return AlertDialog(
          title: Text('选择大小端'),
          content: DropdownButton<Endian>(
            value: _selectedEndian,
            items: [...],
            onChanged: (v) {
              setState(() {
                _selectedEndian = v;
              });
              _saveConfig(v);
              dialogSetState(() {});       // 关键:刷新弹窗 UI
            },
          ),
        );
      },
    ),
  );
}

关键点:在 onChanged 回调的最后,调用 dialogSetState(() {}),这会触发 StatefulBuilder 内部的 UI 重建,让弹窗实时响应状态变化。

多个状态同时刷新

如果弹窗里有多个独立的状态需要管理,只需要一个 StatefulBuilder,所有的 dialogSetState 调用都会触发同一个 UI 重建:

builder: (ctx, dialogSetState) {
  return AlertDialog(
    content: Column(
      children: [
        DropdownButton<Endian>(
          value: _endian,
          onChanged: (v) {
            setState(() => _endian = v);
            dialogSetState(() {});    // 刷新
          },
        ),
        Row(
          children: [
            FilterChip('全部', selected: _filter == 'all'),
            FilterChip('发送', selected: _filter == 'send'),
            FilterChip('接收', selected: _filter == 'recv'),
          ],
        ),
      ],
    ),
  );
}

初始化状态值

弹窗打开时,状态值需要从外部传入。如果希望每次打开弹窗都读取最新值(而非缓存值),可以直接在 builder 里访问父组件的状态:

builder: (ctx, dialogSetState) {
  return AlertDialog(
    content: DropdownButton<Endian>(
      value: _endian,      // 父组件的当前状态,每次打开都是最新值
      items: [...],
      onChanged: (v) {
        setState(() => _endian = v);
        dialogSetState(() {});
      },
    ),
  );
}

完整示例

class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  Endian _endian = Endian.little;
  String _filter = 'all';

  void _showConfigDialog() {
    showDialog(
      context: context,
      builder: (ctx) => StatefulBuilder(
        builder: (ctx, dialogSetState) {
          return AlertDialog(
            title: Text('设置'),
            content: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                // 大小端选择
                DropdownButton<Endian>(
                  value: _endian,
                  isExpanded: true,
                  items: Endian.values.map((e) {
                    return DropdownMenuItem(
                      value: e,
                      child: Text(e.label),
                    );
                  }).toList(),
                  onChanged: (v) {
                    setState(() => _endian = v!);
                    _saveEndian(v);
                    dialogSetState(() {});   // 刷新弹窗
                  },
                ),
                SizedBox(height: 16),
                // 筛选按钮
                Row(
                  children: [
                    _buildFilterChip('全部', 'all'),
                    _buildFilterChip('发送', 'send'),
                    _buildFilterChip('接收', 'recv'),
                  ],
                ),
              ],
            ),
            actions: [
              TextButton(
                onPressed: () => Navigator.pop(ctx),
                child: Text('关闭'),
              ),
            ],
          );
        },
      ),
    );
  }

  Widget _buildFilterChip(String label, String value) {
    final isActive = _filter == value;
    return Expanded(
      child: GestureDetector(
        onTap: () {
          setState(() => _filter = value);
          _saveFilter(value);
          // 需要通过 GlobalKey 或其他方式获取 dialogSetState
          // 这里只是示意,实际使用见下一节
        },
        child: Container(
          padding: EdgeInsets.all(8),
          decoration: BoxDecoration(
            color: isActive ? Colors.blue : Colors.grey[200],
            borderRadius: BorderRadius.circular(8),
          ),
          child: Text(
            label,
            style: TextStyle(
              color: isActive ? Colors.white : Colors.black,
            ),
          ),
        ),
      ),
    );
  }
}

进阶:向子组件传递 dialogSetState

如果弹窗内容较复杂,拆分成多个子 widget,需要把 dialogSetState 传递给子组件。有两种方式:

方式一:通过回调传递

builder: (ctx, dialogSetState) {
  return AlertDialog(
    content: Column(
      children: [
        _buildEndianDropdown(
          value: _endian,
          onChanged: (v) {
            setState(() => _endian = v);
            dialogSetState(() {});   // 传回调
          },
        ),
      ],
    ),
  );
},

Widget _buildEndianDropdown({
  required Endian value,
  required ValueChanged<Endian> onChanged,
}) {
  return DropdownButton<Endian>(
    value: value,
    items: [...],
    onChanged: onChanged,
  );
}

方式二:使用 GlobalKey(不推荐用于此场景)

有些文章会用 GlobalKey<State> 来获取子组件的 state 并调用 setState,但这种方式增加了耦合,不推荐在弹窗场景使用。StatefulBuilder 才是最简洁优雅的方案。

原理浅析

StatefulBuilder 内部创建了一个 StatefulElement,它持有自己的 State 对象。当调用 dialogSetState 时,会触发这个 Statebuild 方法重建,从而更新弹窗 UI。

showDialog
  └── StatefulBuilder           <- 有独立的 State
        └── AlertDialog          <- 依赖 StatefulBuilder 的 State
              └── DropdownButton  <- 状态变化时调用 dialogSetState 刷新

总结

场景 方案
简单弹窗,单一状态 StatefulBuilder + dialogSetState
复杂弹窗,多个状态 一个 StatefulBuilder 管理所有状态
子组件需要更新弹窗 通过 ValueChanged 回调传递 dialogSetState
避免使用 GlobalKey(过度设计)

StatefulBuilder 是 Flutter 官方提供的轻量级方案,无需引入 Provider、Bloc 等状态管理库,就能优雅解决弹窗 UI 不刷新的问题。


从"包裹器"到"确认按钮"——一个组件的三次重构

从"包裹器"到"确认按钮"——一个组件的三次重构

背景

后台管理系统中,"危险操作需要二次确认"是最高频的交互模式。表格操作列的删除、禁用,批量操作的批量删除,详情页的注销账号——这些场景都需要 tooltip 提示 + popconfirm 确认 + 按钮三者配合。

用 Ant Design Vue 原生写法,每个地方都要写三层嵌套 + 手动互斥控制:

<a-tooltip :visible="popVisible ? false : undefined" title="删除该记录">
  <a-popconfirm v-model:visible="popVisible" title="确定删除?" @confirm="onDelete">
    <a-button icon="delete" danger />
  </a-popconfirm>
</a-tooltip>

ButtonConfirm 就是为了消灭这段重复代码而生的。


V1:slot 包裹器(89bb3e2)

设计思路: 做一个通用包裹器,用 slot 接收任意子元素,外面套上 tooltip 和 popconfirm。

<dbButtonConfirm needConfirm confirmContent="确定删除?" tooltip="删除">
  <a-button type="primary" danger>删除</a-button>
</dbButtonConfirm>

Props:

  • needConfirm:默认 false,需要手动开启
  • disabled:独立的禁用状态
  • 无按钮相关属性,按钮由 slot 传入

模板结构: 4 个 v-if 分支处理 tooltip/popconfirm 的组合:

1. tooltip && needConfirm && !disabledtooltip > popconfirm > span > slot
2. needConfirm && !disabledpopconfirm > span > slot
3. tooltiptooltip > span(@click) > slot
4. elsespan(@click) > slot

问题:

  • needConfirm 默认 false——组件叫"确认按钮",却默认不确认
  • 按钮通过 slot 传入,组件无法控制按钮的事件链
  • <span> 包裹导致布局问题
  • @click 事件会冒泡穿透,绕过 popconfirm 确认流程

V2:内置 Button + @click 防穿透(1030048)

核心改进: 不再用 slot 包裹外部按钮,改为内置 dbButton 渲染。

<!-- V1: slot 包裹 -->
<dbButtonConfirm needConfirm confirmContent="确定删除?">
  <a-button danger>删除</a-button>
</dbButtonConfirm>

<!-- V2: 内置 Button,继承全部按钮属性 -->
<dbButtonConfirm danger confirmContent="确定删除?" @confirm="onDelete">
  删除
</dbButtonConfirm>

为什么必须内置 Button?

因为只有控制了按钮本身,才能从机制上解决 @click 穿透问题:

  1. inheritAttrs: false —— 阻止外部属性直接落到内部元素
  2. safeAttrs computed —— 过滤掉所有 on 开头的事件监听器
  3. 开发环境 console.error —— 检测到 @click 时提醒开发者用 @confirm

移除 needConfirm prop: 通过 confirmContent 是否存在自动推导——有内容就确认,没有就不确认。理由是"组件名叫确认按钮就必须确认"。

模板结构简化为 2 个分支:

1. tooltip → tooltip > popconfirm > Button
2. else    → popconfirm > Button

解决的问题:

  • 消灭了 <span> 包裹,按钮渲染正确
  • @click 被彻底屏蔽,只能通过 @confirm 接收回调
  • 继承 dbButton 全部能力(type/danger/icon/size/appearance 等)
  • API 表面更简洁,一个组件替代三层嵌套

遗留问题:

  • 移除 needConfirm 后,无法动态控制"这次点击要不要弹确认框"
  • 需要确认和不需要确认的场景,开发者被迫用 v-if/v-elsedbButtondbButtonConfirm 之间切换

V3:handleVisibleChange 拦截模式(f9d404c)

核心改进: 重新引入 needConfirm prop,但默认值改为 true,且实现方式完全不同。

V1 vs V3 的 needConfirm

V1 V3
默认值 false(需要手动开启) true(默认就确认)
实现方式 v-if 控制是否渲染 popconfirm handleVisibleChange 拦截是否弹出
false 时行为 点击 span 直接 emit 拦截 popconfirm 弹出,直接 emit

关键设计:参考 antd 官方的 visibleChange 模式

const handleVisibleChange = (visible: boolean) => {
  if (!visible) {
    confirmVisible.value = false
    return
  }
  if (props.needConfirm) {
    confirmVisible.value = true  // 正常弹出确认框
  } else {
    emits('confirm')             // 跳过确认,直接触发
  }
}

popconfirm 始终存在于 DOM 中,但通过 handleVisibleChange 在弹出瞬间拦截。needConfirm: false 时,popconfirm 根本不会展示,直接走 @confirm 回调。

解决了什么实际问题?

同一个按钮,根据业务状态动态决定是否需要确认:

<!-- 一个组件覆盖两种情况,无需 v-if/v-else -->
<dbButtonConfirm
  icon="delete"
  danger
  :needConfirm="record.status !== 'draft'"
  confirmContent="确定删除该记录?"
  @confirm="onDelete(record)"
/>

草稿状态点击直接删除,已发布状态弹确认框。同一个 @confirm 回调,业务只需控制一个布尔值。


三个版本的对比

V1(包裹器)
┌──────────────────────────────┐
│ dbButtonConfirm              │
│   ├─ tooltip (可选)          │
│   ├─ popconfirm (可选)       │
│   └─ <span>                  │
│       └─ <slot> ← 外部按钮  │  ← 无法控制事件链
└──────────────────────────────┘

V2(内置 Button)
┌──────────────────────────────┐
│ dbButtonConfirm              │
│   ├─ tooltip (可选)          │
│   ├─ popconfirm (始终渲染)    │
│   ├─ safeAttrs (过滤 @click) │
│   └─ <Button> ← 内置渲染    │  ← 完全控制事件链
└──────────────────────────────┘

V3handleVisibleChange)
┌──────────────────────────────┐
│ dbButtonConfirm              │
│   ├─ tooltip (可选)          │
│   ├─ popconfirm (始终渲染)    │
│   ├─ handleVisibleChange     │  ← 拦截弹出,动态决定流程
│   ├─ safeAttrs (过滤 @click) │
│   └─ <Button> ← 内置渲染    │
└──────────────────────────────┘

最终运行时流程

点击按钮
    │
    ▼
needConfirm?
    │
    ├── true ──► 弹出 popconfirm
    │                │
    │           ┌────┴────┐
    │           ▼         ▼
    │        确认       取消
    │           │         │
    │           ▼         ▼
    │     emit confirm  emit cancel
    │
    └── false ──► 直接 emit confirm

设计总结

迭代 关键决策 解决的问题
V1 slot 包裹任意元素 基础功能可用
V2 内置 Button + inheritAttrs: false @click 防穿透、消灭 span 包裹
V3 handleVisibleChange 拦截 一个组件覆盖"需确认"和"不需确认"两种场景

最终的 dbButtonConfirm 是一个真正的按钮组件,不是包裹器。它继承了 dbButton 的全部能力,内置了 tooltip/popconfirm 互斥处理和 @click 防穿透机制,通过 needConfirm 动态控制确认流程,让开发者用一个组件、一个 @confirm 回调覆盖所有操作按钮场景。

AI写代码坑了90%程序员!这5个致命bug,上线就炸(附避坑清单)

上周三晚上十一点,一个朋友发我消息,说他的项目刚上线三小时,服务直接崩了。

他排查到凌晨两点,最后发现问题出在一段「AI帮他生成的查询代码」上——循环里套了个没加限制条件的数据库查询,本地测试五条数据完全没问题,生产环境一跑,几千条数据直接把服务打趴下了。

他说了一句话我印象很深:「我以为AI比我严谨,没想到它比我还粗心。」

说实话,这种事我身边不止他一个。

用AI写代码这件事,大家现在基本上分两个阶段:

第一阶段,觉得AI是神,什么都敢往里扔,写完直接用;
第二阶段,被坑过一次之后,开始明白——AI生成的代码,跑通只是起点,能不能上线是另一回事。

我自己也踩过坑。今天这篇,就把我收集到的、程序员被AI代码坑得最惨的5个bug类型,一个一个说清楚,每个都附修复方案,最后给一个可以直接拿去用的校验清单。


🪤 坑一:边界条件像不存在一样

AI写代码有一个特点:它给你的往往是「教科书版本」的逻辑,也就是输入都合法、网络永远不断、用户不会乱操作的理想版本。

举个真实场景:

有人让AI写了一个解析用户上传文件的函数,逻辑很流畅,代码也很干净。但文件为空呢?文件格式不对呢?文件超过大小限制呢?

一个都没判断。

本地跑了个正常的文件,没问题,上线了。然后用户传了一个0字节的文件,直接报 NullPointerException,整个上传模块崩掉,还把后台日志刷成了红色。

修复思路:

每次拿到AI生成的函数,脑子里跑一遍:

  • 入参为空/null/空字符串时,会发生什么?
  • 列表为空时,会发生什么?
  • 网络超时/接口返回错误时,会发生什么?

把这几个场景加进去,基本能堵住90%的边界问题。

// AI原版(没有任何边界处理)
function parseFile(file) {
  const content = fs.readFileSync(file.path, 'utf-8');
  return JSON.parse(content);
}

// 加完边界判断之后
function parseFile(file) {
  if (!file || !file.path) {
    throw new Error('文件对象无效');
  }
  if (file.size === 0) {
    throw new Error('文件内容为空,请重新上传');
  }
  const content = fs.readFileSync(file.path, 'utf-8');
  try {
    return JSON.parse(content);
  } catch (e) {
    throw new Error('文件格式错误,请上传合法的JSON文件');
  }
}

🐌 坑二:性能陷阱藏在看不见的地方

这个坑更隐蔽,因为它在本地完全没有症状。

最经典的一种:循环里查数据库

AI写批量处理逻辑的时候,特别容易生成这种代码——遍历一个列表,列表里每个元素都查一次数据库,逻辑完全正确,但查询次数和数据量成正比。

数据量小的时候,没感觉。等到真实用户数据进来,一个请求发出去,后端发了一百多条SQL,响应时间直接从200ms变成20秒。

// AI生成版(N+1查询,数据量一大就崩)
async function getOrderDetails(userIds) {
  const result = [];
  for (const userId of userIds) {
    const orders = await db.query('SELECT * FROM orders WHERE user_id = ?', [userId]);
    result.push({ userId, orders });
  }
  return result;
}

// 正确版(一次查完,内存里分组)
async function getOrderDetails(userIds) {
  const orders = await db.query(
    'SELECT * FROM orders WHERE user_id IN (?)',
    [userIds]
  );
  return userIds.map(userId => ({
    userId,
    orders: orders.filter(o => o.user_id === userId)
  }));
}

review这类问题的习惯: 看到循环就问自己「这里有没有数据库操作或者网络请求」,有的话,基本就要改。


🔓 坑三:安全漏洞,AI不会主动告诉你

这是最严重的一类。

AI对安全问题的默认处理态度是:不处理。它给你一个能跑的方案,安全防护得你自己加。

最常见的两个:

1. SQL注入

# AI生成版(直接拼接字符串,典型的SQL注入漏洞)
def get_user(username):
    query = f"SELECT * FROM users WHERE username = '{username}'"
    return db.execute(query)

# 安全版(参数化查询)
def get_user(username):
    query = "SELECT * FROM users WHERE username = ?"
    return db.execute(query, (username,))

如果有人传入 username = "'; DROP TABLE users; --",第一种写法这个表就没了。

2. XSS(跨站脚本攻击)

前端项目里,AI经常生成 innerHTML = userInput 这种写法,看起来没问题,但你不知道用户会在输入框里塞什么内容。

// 有漏洞
container.innerHTML = userInput;

// 安全版
container.textContent = userInput;
// 或者用成熟的转义库处理富文本

凡是和用户输入相关的代码,一定要专门过一遍安全检查,别指望AI主动提示你。


🧱 坑四:业务逻辑全靠魔法数字撑着

这个坑当时不疼,三个月后会让你想哭。

AI写出来的业务逻辑,里面经常有一堆「魔法数字」——比如状态码直接写 if (status === 2),折扣直接写 price * 0.85,超时直接写 timeout = 30000

这些数字是什么意思?2 代表什么状态?0.85 是哪个活动的折扣?30000 是哪个接口的超时?

代码里没有任何说明。

三个月后需求一变,你看着一堆裸数字,完全不知道哪个能改、哪个不能改,只能一行一行看逻辑、一个一个猜。

// AI版(魔法数字,半年后改不动)
function calcPrice(price, userType) {
  if (userType === 2) {
    return price * 0.85;
  } else if (userType === 3) {
    return price * 0.75;
  }
  return price;
}

// 可维护版
const USER_TYPE = {
  NORMAL: 1,
  VIP: 2,
  SVIP: 3
};
const DISCOUNT = {
  [USER_TYPE.VIP]: 0.85,   // VIP会员九折
  [USER_TYPE.SVIP]: 0.75,  // SVIP会员七五折
};

function calcPrice(price, userType) {
  const discount = DISCOUNT[userType] ?? 1;
  return price * discount;
}

改动很小,但三个月后的你会感谢现在的你。


📄 坑五:注释和代码讲的不是同一件事

这个坑我觉得是最无语的。

AI写注释有时候是根据函数名「猜」逻辑写的,代码实现换了,注释还是老的。或者注释说「返回用户列表」,代码里其实返回的是分页对象,里面才有列表。

这类问题上线当时不会崩,但后续维护的人(可能就是一个月后的你)会被这些注释完全误导,在错误的方向上排查半天。

// 注释说返回boolean,代码里其实返回number状态码
/**
 * 验证用户权限
 * @returns {boolean} 是否有权限
 */
function checkPermission(userId, resource) {
  // 实际上返回 0/1/2 代表不同权限等级
  return db.getPermissionLevel(userId, resource);
}

检查方法很简单:生成完代码之后,单独让AI「对照代码重新生成注释」,别直接用第一次生成的。


✅ AI代码校验3步法(用完再提交)

说了这么多坑,给一个提交前可以直接用的检查流程:

第一步:边界 + 异常覆盖检查(2分钟)

  • 函数入参为空时有没有处理?
  • 异步操作有没有 try/catch?
  • 列表操作前有没有判空?

第二步:性能热点扫描(1分钟)

  • 循环内有没有数据库查询或网络请求?
  • 大数据处理有没有分页或流式处理?
  • 是否有不必要的重复计算?

第三步:安全敏感点过筛(2分钟)

  • 用户输入有没有做过滤/转义?
  • SQL查询有没有用参数化?
  • 接口返回有没有暴露不该暴露的字段?

整套流程5分钟不到,能挡掉绝大多数上线前的定时炸弹。


写在最后

AI写代码这件事,我现在的态度是:用,但不完全信。

它是一个效率工具,不是一个质量保证。代码能跑是它的工作,代码能上线是你的工作。

这5类bug,是我收集到的大家被坑最多的场景。你踩过哪类,欢迎评论区聊聊。

我把完整的《AI写代码10条避坑清单+校验模板》整理好了,包含前端/后端/AI应用全场景的对照表,后台回复**「避坑」**直接发给你,复制就能用。

公众号关注 【iDao技术魔方】 ,每天一篇可落地的AI/前端实战干货。

使用micro-app 多层嵌套的问题

micro-app 多层嵌套问题解决方案

版本说明:本文讨论的 micro-app 版本为截止发稿日期的最新版 1.0.0-rc.27

一、问题背景

1.1 业务场景

在实际开发中,我们遇到了一个三层嵌套的微前端场景:

基座应用 → 中间应用 → 子应用
  • 技术栈:Vue 3 + Vite
  • 架构层级:三层嵌套结构
  • 业务需求:中间应用和子应用需要进行频繁的数据交互的场景

1.2 官方文档说明

micro-app 官方文档针对 Vite 项目给出了使用 iframe 模式的建议: image.png 官方文档虽然提到了支持多层嵌套,但并未给出具体的实现示例和注意事项: image.png

1.3 问题现象

当中间层应用使用 iframe 模式时,第三层子应用会出现**栈溢出(Stack Overflow)**错误:

Maximum call stack size exceeded

image.png

这个问题在 GitHub Issues 中也有多人反馈,但官方尚未给出明确的解决方案。


二、问题原因分析

2.1 根本原因

经过深入分析和测试,问题的根本原因如下:

  1. 资源查找机制问题:当基座应用和中间层应用都启用 iframe 模式后,第三层子应用在查找 iframe 标签资源时,会向上查找父级应用。

  2. 循环查找导致栈溢出

    • 第三层应用向上查找时,找到的是基座应用而非中间层应用
    • 基座应用再次下发资源
    • 第三层应用继续向上查找
    • 形成无限循环,最终导致栈溢出
  3. iframe 标签的资源查找逻辑:micro-app 在处理 Vite 项目的 iframe 模式时,资源查找机制在多层级嵌套场景下存在缺陷。

2.2 测试验证

我们对不同技术栈和框架进行了测试,测试结果如下: image.png

基座应用 中间应用 子应用 是否出现栈溢出
Vite + iframe Vite + iframe Vite ❌ 是
Vite + iframe Vite + iframe Webpack ❌ 是
Vite + iframe Webpack Vite ✅ 否
Vite + iframe Webpack Webpack ✅ 否

结论:不论第三层使用什么技术栈,只要第二层(中间应用)使用了 iframe 模式,就会出现栈溢出问题。


三、解决方案

方案一:使用原生 iframe 标签(不推荐)

实现方式

第三层子应用使用原生的 `` 标签,而不是 micro-app 标签。

优点
  • ✅ 完全避免栈溢出问题
  • ✅ 实现简单,无需额外配置
缺点
  • ❌ 失去了 micro-app 的所有优势(样式隔离、JS 沙箱、通信机制等)
  • ❌ 需要重新实现微前端的各种能力
  • ❌ 与现有架构不兼容,需要大量改造工作
  • ❌ 性能较差,用户体验不佳
适用场景

仅适用于对微前端能力要求不高的简单嵌入场景。 不需要频繁的进行数据交互及ui风格统一等。


方案二:中间层不使用 iframe 模式(不推荐)

实现方式

中间层应用不使用 iframe 模式,改用 Webpack 构建或其他方式。

优点
  • ✅ 可以避免栈溢出问题
  • ✅ 保持 micro-app 的完整能力
缺点
  • ❌ 需要将 Vite 项目改回 Webpack,技术倒退
  • ❌ 失去 Vite 的快速构建和开发体验
  • ❌ 不符合当前主流技术趋势
  • ❌ 团队需要重新学习 Webpack 配置
适用场景

仅适用于可以接受技术栈变更的项目。


方案三:第三层使用基座应用的标签(推荐⭐)

这是本文重点推荐的解决方案,通过让第三层子应用直接使用基座应用的 micro-app 标签,绕过中间层的资源查找问题。

3.1 核心思路
  • 第三层子应用不再通过中间层应用加载
  • 直接使用基座应用的 micro-app 标签进行渲染
  • 通过基座应用实现中间层和子应用之间的通信
3.2 实现步骤
步骤一:将基座应用的 micro-app 挂载到全局

在基座应用中,将 micro-app 实例挂载到全局对象,以便子应用能够访问:

// 基座应用:main.js 或 bootstrap.js
import microApp from '@micro-zoe/micro-app';

// 权限校验函数(可选)
function accessMicroAppName(appName) {
  // 根据业务需求实现权限校验逻辑
  // 例如:检查当前子应用是否有权限访问指定的子应用
  return true;
}

// 将 micro-app 方法挂载到全局
window.microApp = {
  setData(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.setData(...args);
  },

  addDataListener(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.addDataListener(...args);
  },

  getData(...args) {
    if (!accessMicroAppName(args[0])) {
      return null;
    }
    return microApp.getData(...args);
  },

  removeDataListener(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.removeDataListener(...args);
  },
};

注意事项

  • 建议添加权限校验,防止子应用越权访问
  • 可以根据业务需求选择性暴露方法
步骤二:基座应用设置动态标签名称

基座应用在初始化时,设置动态标签名称,并通过 setGlobalData 传递给子应用:

// 基座应用:micro-app 初始化
import microApp from '@micro-zoe/micro-app';

// 定义动态标签名称常量
const MICRO_APP_TAGNAME = 'micro-app-base';

// 初始化 micro-app
microApp.start({
  tagName: MICRO_APP_TAGNAME, // 使用自定义标签名
  lifeCycles: {
    // 生命周期钩子
  },
  preFetchApps: [
    // 预加载应用列表
  ],
});

// 通过 setGlobalData 将标签名传递给子应用
microApp.setGlobalData({
  microAppTagName: MICRO_APP_TAGNAME,
});

子应用接收 image.png

步骤三:中间层应用创建动态组件

在中间层应用中,创建一个动态组件,使用基座应用的标签名称:



  



import { ref, computed, onMounted } from 'vue';

interface Props {
  appName: string;
  appUrl: string;
  embedPath?: string;
  appData?: Record;
}

const props = defineProps();

// 从全局数据中获取基座应用的标签名
const microAppTagName = ref('micro-app');

// 监听全局数据变化,获取标签名
onMounted(() => {
  if (window.microApp) {
    window.microApp.addDataListener((data: any) => {
      if (data?.microAppTagName) {
        microAppTagName.value = data.microAppTagName;
      }
    }, true); // true 表示立即执行一次

    // 获取初始数据
    const globalData = window.microApp.getData();
    if (globalData?.microAppTagName) {
      microAppTagName.value = globalData.microAppTagName;
    }
  }
});

const handleDataChange = (e: CustomEvent) => {
  // 处理子应用数据变化
  emit('dataChange', e.detail.data);
};

const emit = defineEmits(['dataChange']);

简单版: image.png

步骤四:使用动态组件并传递参数

在中间层应用的页面中,使用动态组件:



  <div class="sub-app-container">
    
  </div>



import { ref, watch } from 'vue';
import MicroApp from './MicroApp.vue';

const subAppName = ref('sub-app-name');
const subAppUrl = ref('https://sub-app.example.com');
const embedPath = ref('/page1'); // 通过 default-page 传递路由参数
const appData = ref({});

// 监听参数变化,更新子应用
watch(embedPath, (newPath) => {
  // 参数变化时,子应用会自动更新
});

const handleSubAppDataChange = (data: any) => {
  // 处理子应用数据变化
  console.log('子应用数据变化:', data);
};

简版: image.png

步骤五:实现参数传递和数据通信

中间层应用通过基座应用的 setData 方法向子应用传递数据:

// 中间层应用:参数传递
import { ref } from 'vue';

const embedPath = ref('/page1');

// 更新子应用参数
const updateSubAppPath = (newPath: string) => {
  embedPath.value = newPath;

  // 通过基座应用向子应用传递数据
  if (window.microApp) {
    window.microApp.setData(subAppName.value, {
      path: newPath,
      timestamp: Date.now(),
    });
  }
};

// 监听子应用数据变化
if (window.microApp) {
  window.microApp.addDataListener((data: any) => {
    console.log('收到子应用数据:', data);
    // 处理子应用返回的数据
  }, subAppName.value);
}

image.png

3.3 方案优势
  • 解决栈溢出问题:第三层直接使用基座应用的标签,绕过中间层的资源查找
  • 保持微前端能力:仍然可以使用 micro-app 的所有功能
  • 支持频繁交互:通过基座应用实现中间层和子应用之间的数据通信
  • 避免白屏问题:子应用不会因为参数变化而重新加载,提升用户体验
  • 支持多子应用:每个子应用都可以使用独立的标签,互不干扰
  • 技术栈兼容:支持 Vite + Vue 3 技术栈
3.4 注意事项
  1. 通信机制:中间层应用和子应用的通信需要通过基座应用进行,不能直接通信
  2. 权限控制:建议在基座应用中实现权限校验,防止子应用越权访问
  3. 标签名称:确保基座应用的标签名称唯一,避免冲突
  4. 数据管理:需要合理设计数据传递机制,避免数据混乱
3.5 架构示意图
┌─────────────────────────────────────┐
│           基座应用                   │
│  ┌───────────────────────────────┐  │
│  │  micro-app (tagName: 'base')  │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │    中间层应用             │  │  │
│  │  │  ┌───────────────────┐  │  │  │
│  │  │  │  动态组件          │  │  │  │
│  │  │  │             │  │  │  │
│  │  │  │    ┌───────────┐  │  │  │  │
│  │  │  │    │ 子应用    │   │  │  │  │
│  │  │  │    └───────────┘  │  │  │  │
│  │  │  └───────────────────┘  │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

四、方案对比

方案 解决栈溢出 保持微前端能力 技术栈兼容 实现复杂度 推荐度
方案一:原生 iframe ⭐⭐
方案二:中间层不用 iframe ⭐⭐⭐ ⭐⭐
方案三:使用基座标签 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

五、总结

5.1 问题根源

micro-app 1.x 版本在处理 Vite 项目的多层嵌套场景时,当中间层应用使用 iframe 模式,会导致第三层子应用在资源查找时出现循环查找,最终引发栈溢出。

5.2 最佳实践

推荐使用方案三:让第三层子应用直接使用基座应用的 micro-app 标签,通过基座应用实现中间层和子应用之间的通信。这样既解决了栈溢出问题,又保持了微前端的完整能力。

5.3 注意事项

  1. 确保基座应用的标签名称唯一且可配置
  2. 实现完善的权限校验机制
  3. 合理设计数据传递和通信机制
  4. 注意处理子应用的生命周期管理

5.4 未来展望

希望 micro-app 官方能够在后续版本中:

  • 修复多层嵌套场景下的资源查找问题
  • 提供更完善的多层嵌套示例和文档
  • 优化 Vite 项目的 iframe 模式支持

【更新】有人已经给出了解决方案,大家如果遇到同类问题,可以用此方案试试~ github.com/jd-opensour…

image.pnggithub.com/jd-opensour…

企业微信截图_5798ebde-4dc0-4c49-a0b2-4eb23d46cb9a.png

六、参考资料


万字长文:从零实现 JWT 鉴权

一、JWT 鉴权概述

今天来回顾一下之前做的 JWT 鉴权。

JWT(JSON Web Token)鉴权的核心不是加密,而是无状态协议下的身份校验。

在 Express 环境下,一个完整的 JWT 鉴权流程通常包含三个关键环节:

  1. 颁发(Issue):用户登录成功后,服务器生成 Token。
  2. 存储与传递(Storage & Transmission):前端如何保存,请求时如何携带。
  3. 拦截与校验(Middleware):后端如何识别并解析这个字符串。

官方文档(RFC 7519) EN: JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. 中文:JSON Web Token(JWT)是一种紧凑、URL 安全的表示声明的方式,用于在两方之间传输声明信息。JWT 中的声明被编码为 JSON 对象,用作 JSON Web Signature(JWS)结构的载荷或 JSON Web Encryption(JWE)结构的明文,使声明能够通过消息认证码(MAC)进行数字签名或完整性保护,和/或进行加密。

二、后端实现:中间件与 JWT 校验

2.1 中间件的概念与职责

Express 中间件是在请求进入路由处理、响应返回客户端之前,执行逻辑校验、数据加工、拦截等操作的函数。

官方文档(Express 官方文档) EN: Middleware functions are functions that have access to the request object (req), the response object (res), and the next middleware function in the application’s request-response cycle. The next middleware function is commonly denoted by a variable named next. 中文:中间件函数是可以访问应用请求-响应周期中请求对象(req)、响应对象(res)以及下一个中间件函数的函数。下一个中间件函数通常由名为 next 的变量表示。

在实现这个模块时,采用的是后端先行、接口先行的方式,先从后端 API 开始写。

这里有一件值得反思的工程实践:后端接口是否可用,不应该等到所有代码写完以后再去测试,效率较低。更合理的方式是在接口链路打通后,就在 Postman 里测一下是否可用。

在后端工程流程里,可以采用 JWT 官方提供的一些方法。它的调用方式是:中间件就像一道安检,校验是否携带了所需的 token,是否能从 JWT 中拿到需要的状态。

关于中间件的写法,我也是参考官方示例。有一个比较重要的函数是 nextnext 用于声明当前处理完成,然后交给下一个处理程序。next 必须显式调用,否则 request 会一直处于挂起状态,无法返回 response。

官方文档(Express 官方文档) EN: The next function is a callback function that invokes the next middleware function in the stack. If the current middleware function does not end the request-response cycle, it must call next() to pass control to the next middleware function. Otherwise, the request will be left hanging. 中文next 函数是一个回调函数,用于调用堆栈中的下一个中间件函数。如果当前中间件函数未结束请求-响应周期,则必须调用 next() 将控制权传递给下一个中间件函数,否则请求将处于挂起状态。

Express 中错误处理中间件必须定义 4 个参数 (err, req, res, next),只有这样才会被识别为错误捕获中间件;普通中间件/路由处理函数为 2–3 个参数,不存在“两个参数即为终止型中间件”的规则。

官方文档(Express 官方文档) EN: Error-handling middleware functions are defined the same way as other middleware functions, except with four arguments instead of three: (err, req, res, next). 中文:错误处理中间件函数的定义方式与其他中间件函数相同,区别在于需要传入四个参数而非三个:(err, req, res, next)

2.2 auth 中间件实现

express 中间件 auth,用于验证 JWT token 并将用户信息注入到请求对象中

import type { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";

export interface AuthPayload extends jwt.JwtPayload {
  userId: string;
  username: string;
}

export interface AuthRequest extends Request {
  user?: AuthPayload;
}
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
  throw new Error("FATAL ERROR: JWT_SECRET is not defined.");
}
export const auth = (req: AuthRequest, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.split(" ")[1];

  if (!token) return res.sendStatus(401);

  try {
    const decoded = jwt.verify(token, JWT_SECRET);

    if (typeof decoded === "string") {
      return res.status(403).json({ message: "Token invalid" });
    }

    if (!decoded.userId || !decoded.username) {
      return res.status(403).json({ message: "Token invalid" });
    }

    req.user = decoded as AuthPayload;

    next();
  } catch (_) {
    return res.status(403).json({ message: "Token invalid" });
  }
};

2.3 JWT 验证的两个核心问题

我记得这里重要的 api 是 verify,还有弄清楚 JWT 承载用户信息的部分是哪里,要回答两个问题:

1. 如何验证 JWT token?

const decoded = jwt.verify(token, JWT_SECRET);

官方文档(jsonwebtoken 官方文档) EN: The verify function takes a token, a secret or public key, and an optional callback function. It verifies the token's signature, checks if the token is expired, and decodes the payload. 中文verify 函数接收一个 token、一个密钥或公钥,以及一个可选的回调函数。它会验证 token 的签名、检查 token 是否过期,并对载荷进行解码。

jwt.verify() 的执行顺序:先对 Token 做 base64url 解码(无需密钥),再验证签名是否合法,最后检验 exp 等过期/时效声明;任何一步不通过都会抛出错误,解码一定会发生,只是非法结果不会被业务使用。

2. 如何把 JWT 内承载的用户信息注入到 req 里面?

注入发生在验证成功后:

req.user = decoded as AuthPayload;

requser 属性被解密后的明文赋值,注入数据,后续路由就可以通过调用这个中间件得到 token 里包含的信息。

其实也就是中间件在后端的作用。我认为中间件是在 request 和 response 之间进行逻辑校验或数据加工。

请求 → 中间件1 → 中间件2 → 路由处理 → 响应
         ↑
    这里做校验、加工、拦截

JWT 的核心是签名验证,而不是加密。payload 只是 base64url 编码,可以解码查看,但无法篡改,因为篡改后签名会失效。

官方文档(RFC 7519) EN: JSON Web Signature (JWS) is an integrated set of specifications for representing content secured with digital signatures or Message Authentication Codes (MACs) using JSON-based data structures. 中文:JSON Web Signature(JWS)是一套集成的规范集,用于使用基于 JSON 的数据结构表示通过数字签名或消息认证码(MAC)保护的内容。

三、数据层设计:MongoDB 与 Mongoose

把中间件处理、也就是 JWT 校验做好后,就开始设计数据库集合(Collection)的 schema,确定要存入哪些数据、登录需要哪些字段,把 schema 字段和加密逻辑配置好。

import mongoose, { Document, Schema } from "mongoose";
import bcrypt from "bcrypt";

export interface IUser extends Document {
  username: string;
  password: string;
  createdAt: Date;
  updatedAt: Date;
}

const userSchema = new Schema<IUser>(
  {
    username: {
      type: String,
      required: [true, "用户名不能为空"],
      unique: true,
      trim: true,
      minlength: [3, "用户名至少 3 个字符"],
      maxlength: [20, "用户名最多 20 个字符"],
    },
    password: {
      type: String,
      required: [true, "密码不能为空"],
      minlength: [6, "密码至少 6 个字符"],
      select: false,
    },
  },
  {
    timestamps: true,
  },
);
userSchema.pre("save", async function (next) {
  if (!this.isModified("password")) {
    return next();
  }

  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

const User = mongoose.model<IUser>("User", userSchema);

export default User;

其中,密码加密的 pre("save") 中间件负责密码哈希。

官方文档(Mongoose 官方文档) EN: Mongoose schemas support pre and post hooks for middleware functions. These hooks are functions that are executed before or after a certain event (like save, find, etc.) occurs. 中文:Mongoose 的 Schema 支持用于中间件函数的 pre 和 post 钩子。这些钩子是在特定事件(如 savefind 等)发生之前或之后执行的函数。

3.1 关于 MongoDB 和 Mongoose

MongoDB 本身是 schemaless(无模式) 的,意思是:

  • 你可以往同一个集合里存完全不同的结构
  • 没有强制的字段类型、必填校验
  • 没有自动的钩子(如密码加密)

这很灵活,但大型项目里容易造成:

  • 数据混乱(有的文档有 username,有的没有)
  • 业务逻辑散落各处
  • 难以维护

官方文档(MongoDB 官方文档) EN: MongoDB is a document-oriented database program. Classified as a NoSQL database program, MongoDB uses JSON-like documents with optional schemas. 中文:MongoDB 是一个面向文档的数据库程序。作为 NoSQL 数据库程序,MongoDB 使用具有可选模式的类 JSON 文档。

Mongoose 的作用就是给 MongoDB 加上“规矩”:

  • 定义数据结构(Schema)
  • 自动验证类型、必填、长度等
  • 提供钩子(pre/post)自动处理逻辑(如密码加密)
  • 封装常用的 CRUD 方法

官方文档(Mongoose 官方文档) EN: Mongoose is a MongoDB object modeling tool designed to work in an asynchronous environment. Mongoose provides a straight-forward, schema-based solution to model your application data. 中文:Mongoose 是一个设计用于异步环境的 MongoDB 对象建模工具。Mongoose 提供了一种直观的、基于模式的解决方案来为你的应用数据建模。

前端 → 后端控制器(Controller) → 服务层(Service) → Model → MongoDB
  • Model 是数据层(Data Layer):它封装了所有与数据库直接交互的逻辑
  • 业务逻辑层(Service) 调用 Model 的方法来读写数据
  • 控制器(Controller) 处理 HTTP 请求,调用 Service
  • 这样分层的好处:替换数据库时只需改动 Model 层,业务逻辑不变

Model 设计的必要性

  • 集中管理数据规则(验证、加密、默认值)
  • 避免在多个地方重复写密码加密、字段校验的代码
  • 保证数据一致性

四、业务逻辑层:Controller 实现

于是在 model 定义好以后,我们可以写好 controller,对应业务逻辑。这一块比较核心,代码也比较多,我贴出示例的完整代码参考思路:

// ============================================
// authController.ts - 业务逻辑
// ============================================

import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import { Request, Response } from "express";
import User from "../models/userModel.js";
import { AuthRequest } from "../middleware/auth.js";

const JWT_SECRET = process.env.JWT_SECRET!;

// 辅助函数:生成 JWT 并组装返回数据
const buildAuthPayload = (user: any) => {
  const token = jwt.sign(
    { userId: String(user._id), username: user.username },
    JWT_SECRET,
    { expiresIn: "24h" }
  );
  
  return {
    token: token,
    user: {
      id: String(user._id),
      username: user.username
    }
  };
};

// ========== 登录 ==========
export const login = async (req: Request, res: Response) => {
  const { username, password } = req.body;
  
  if (!username || !password) {
    return res.status(400).json({ message: "用户名和密码不能为空" });
  }
  
  const user = await User.findOne({ username }).select("+password");
  
  if (!user) {
    return res.status(401).json({ message: "用户名或密码错误" });
  }
  
  const isMatch = await bcrypt.compare(password, user.password);
  
  if (!isMatch) {
    return res.status(401).json({ message: "用户名或密码错误" });
  }
  
  return res.status(200).json(buildAuthPayload(user));
};

// ========== 注册 ==========
export const register = async (req: Request, res: Response) => {
  const { username, password } = req.body ?? {};
  
  if (!username || !password) {
    return res.status(400).json({ message: "用户名和密码不能为空" });
  }
  
  if (password.length < 6) {
    return res.status(400).json({ message: "密码至少6个字符" });
  }
  
  try {
    const existingUser = await User.findOne({ username });
    if (existingUser) {
      return res.status(409).json({ message: "用户名已存在" });
    }
    
    const user = await User.create({ username, password });
    // ↑ 保存时自动触发 pre("save") 钩子加密密码
    
    return res.status(201).json({
      message: "注册成功",
      ...buildAuthPayload(user)
    });
  } catch (error) {
    if (error instanceof Error) {
      return res.status(400).json({ message: error.message });
    }
    return res.status(500).json({ message: "服务器错误" });
  }
};

// ========== 获取当前用户 ==========
export const me = async (req: AuthRequest, res: Response) => {
  try {
    const userId = req.user?.userId;
    
    if (!userId) {
      return res.sendStatus(401);
    }
    
    const user = await User.findById(userId).select("_id username");
    
    if (!user) {
      return res.sendStatus(401);
    }
    
    return res.status(200).json({
      user: {
        id: String(user._id),
        username: user.username
      }
    });
  } catch (error) {
    return res.status(500).json({ message: "服务器错误" });
  }
};

五、路由层:接口注册与请求流程

最后编写注册、登录相关路由。

1. 主应用挂载路由模块
   app.use("/api/auth", authRoutes)
              ↓
2. 请求进入,匹配前缀 "/api/auth"
              ↓
3. 进入 authRoutes 模块,匹配具体路径
   router.post("/login", login)
              ↓
4. 完整路径 = "/api/auth/login"
              ↓
5. 执行对应的控制器函数
import "dotenv/config";
import { Router } from "express";
import { login, me, register } from "../controllers/authController.js";
import { auth } from "../middleware/auth.js";

const router: Router = Router();

router.post("/login", login);
router.post("/register", register);
router.get("/me", auth, me);

export default router;

官方文档(Express 官方文档) EN: A router is an isolated instance of middleware and routes. You can use a router to group related routes together and apply middleware to a subset of your application's routes. 中文:Router 是中间件和路由的独立实例。你可以使用 Router 将相关路由分组,并将中间件应用到应用程序路由的子集上。

导入依赖以后,创建全局 Router 实例,定义路由并进行后端注册,目的是之后前端路由请求可以匹配到后端,后端也就调用相关的 controller 处理数据。

router.get("/me", auth, me) 为例:

  • 方法:GET
  • 路径/me
  • 中间件链authme
  • 场景:获取当前登录用户的信息
  • 执行流程
    1. 请求先进入 auth 中间件
    2. auth 验证 token,把用户信息挂到 req.user
    3. 验证通过后调用 next(),进入 me 控制器
    4. me 控制器从 req.user 读取信息返回

5.1 后端完整请求流程图

以上可以得到后端的完整请求流程图:

┌─────────────────────────────────────────────────────────────────┐
│                        注册流程                                  │
├─────────────────────────────────────────────────────────────────┤
│ POST /api/auth/register { username, password }                  │
│                           ↓                                     │
│ authRoutes → router.post("/register", register)                 │
│                           ↓                                     │
│ register 控制器                                                  │
│   ├── 验证 username/password 存在                                │
│   ├── 验证密码长度 ≥ 6                                           │
│   ├── 检查用户名是否已存在                                       │
│   ├── User.create({ username, password })                       │
│   │        ↓                                                    │
│   │   pre("save") 钩子: bcrypt 哈希密码                          │
│   │        ↓                                                    │
│   │   存入 MongoDB                                               │
│   ├── buildAuthPayload() → jwt.sign() 生成 token                 │
│   └── 返回 { token, user }                                       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                        登录流程                                 │
├─────────────────────────────────────────────────────────────────┤
│ POST /api/auth/login { username, password }                     │
│                           ↓                                     │
│ authRoutes → router.post("/login", login)                       │
│                           ↓                                     │
│ login 控制器                                                     │
│   ├── 验证 username/password 存在                                │
│   ├── User.findOne({ username }).select("+password")             │
│   ├── bcrypt.compare(明文密码, 哈希密码)                          │
│   ├── buildAuthPayload() → jwt.sign() 生成 token                 │
│   └── 返回 { token, user }                                       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                      获取当前用户流程                            │
├─────────────────────────────────────────────────────────────────┤
│ GET /api/auth/me                                                │
│ Header: Authorization: Bearer <token>                           │
│                           ↓                                     │
│ authRoutes → router.get("/me", auth, me)                        │
│                           ↓                                     │
│ auth 中间件                                                      │
│   ├── 提取 token                                                 │
│   ├── jwt.verify(token, JWT_SECRET)                             │
│   └── req.user = { userId, username }                           │
│                           ↓                                     │
│ me 控制器                                                        │
│   ├── userId = req.user?.userId                                 │
│   ├── User.findById(userId).select("_id username")              │
│   └── 返回 { user: { id, username } }                            │
└─────────────────────────────────────────────────────────────────┘

5.2 加密与验证对照表

关于加密与验证:

环节 位置 方法 目的
密码加密 userModel.ts bcrypt.hash() 注册时把明文密码转成哈希存储
密码比对 login 控制器 bcrypt.compare() 登录时验证用户输入的密码
JWT 签发 buildAuthPayload() jwt.sign() 登录/注册成功后生成 token
JWT 验证 auth 中间件 jwt.verify() 后续请求验证 token 有效性

实际上,这个时候应该可以测一测后端接口了,用 Postman 测试一下后端已启动服务时是否能接通。我之前是前端也写了才去测,觉得效率很低。


六、前端实现:路由与状态管理

我来看一下前端的 router 导航

import React from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import App from "../App";
import { LoginPage } from "../components/Login";
import { useAuth } from "../contexts/authContext";

const RequireAuth = ({ children }: { children: React.ReactElement }) => {
  const { user, isLoading } = useAuth();

  if (isLoading) return <div>Loading...</div>;
  if (!user) return <Navigate to="/login" replace />;

  return children;
};

const RedirectIfAuthenticated = ({
  children,
}: {
  children: React.ReactElement;
}) => {
  const { user, isLoading } = useAuth();

  if (isLoading) return <div>Loading...</div>;
  if (user) return <Navigate to="/wiki" replace />;

  return children;
};

const AppRoutes: React.FC = () => {
  return (
    <Routes>
      <Route path="/" element={<Navigate to="/login" replace />} />
      <Route
        path="/login"
        element={
          <RedirectIfAuthenticated>
            <LoginPage />
          </RedirectIfAuthenticated>
        }
      />
      <Route
        path="/wiki"
        element={
          <RequireAuth>
            <App />
          </RequireAuth>
        }
      />
      <Route
        path="/wiki/:docId"
        element={
          <RequireAuth>
            <App />
          </RequireAuth>
        }
      />
      <Route path="*" element={<Navigate to="/login" replace />} />
    </Routes>
  );
};

export default AppRoutes;

官方文档(React Router 官方文档) EN: React Router enables “client side routing” for React apps. It allows you to build single-page applications with navigation that doesn’t require a page refresh. 中文:React Router 为 React 应用提供“客户端路由”能力,允许你构建具有导航功能且无需页面刷新的单页应用。

后端已经通了,前端的作用是发起请求,这里的路由导航只是跳转页面,并且前端服务要遵循所有业务功能都在用户认证通过后才能使用的原则,也就是 service 的服务调用逻辑,这些是前端要做的事情。

6.1 前端 API 服务层

前端 service 封装好 API 调用层,封装与后端认证接口的通信逻辑。

以 auth 为例

import apiClient from "./client";
import type { AuthUser } from "../contexts/authContext";

interface AuthResponse {
  token: string;
  user: AuthUser;
}

export const loginApi = (data: { username: string; password: string }) =>
  apiClient.post<AuthResponse>("/api/auth/login", data);

export const registerApi = (data: { username: string; password: string }) =>
  apiClient.post<AuthResponse>("/api/auth/register", data);

export const meApi = () => apiClient.get<{ user: AuthUser }>("/api/auth/me");

6.2 前后端数据流

完整的数据流

业务代码调用 apiClient.post("/api/auth/me")
                ↓
        【请求拦截器】
   从 localStorage 读取 token
   添加 Authorization: Bearer <token>
                ↓
        发送请求到后端
                ↓
       后端验证 token
                ↓
┌───────────────────────────────────────┐
│ token 有效 → 返回 200 + 用户数据       │
│ token 无效/过期 → 返回 401             │
└───────────────────────────────────────┘
                ↓
        【响应拦截器】
                ↓
┌───────────────────────────────────────┐
│ 200 → 直接返回 response               │
│ 401 → 清除 token,跳转 /login         │
└───────────────────────────────────────┘
                ↓
        业务代码拿到结果

6.3 全局认证状态管理

之前我在想项目逻辑要求登录后才能使用相关功能,也就是原来无登录状态的所有路由,都要在登录路由保护下才能访问。怎样让这些接口自动带上鉴权,认为实现起来比较难。

其实也不是很难,可以用一个 authContext 登录状态的全局状态分发。

import React, { createContext, useEffect, useState } from "react";
import { meApi } from "../services/auth";

export interface AuthUser {
  id: string;
  username: string;
}

export interface AuthContextType {
  // 1. 核心状态:当前用户是谁?
  user: AuthUser | null;

  // 2. 状态:是否正在初始化(从 LocalStorage 加载中)?
  // 提示:这能防止页面在检查 Token 时闪现“未登录”状态
  isLoading: boolean;

  // 3. 方法:登录成功后调用的函数
  // 它需要接收后端给的 token 和 user 对象
  login: (token: string, user: AuthUser) => void;

  // 4. 方法:退出登录
  // 它需要清理 LocalStorage 和 context 状态
  logout: () => void;
}
export const AuthContext = createContext<AuthContextType | undefined>(
  undefined,
);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const [user, setUser] = useState<AuthUser | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const initAuth = async () => {
      try {
        const token = localStorage.getItem("token");
        if (!token) {
          return;
        }

        const res = await meApi();
        setUser(res.data.user);
      } catch (_) {
        localStorage.removeItem("token");
        setUser(null);
      } finally {
        // 保证无论成功/失败都结束 loading
        setIsLoading(false);
      }
    };

    initAuth();
  }, []);

  const login = (token: string, user: AuthUser) => {
    localStorage.setItem("token", token);
    setUser(user);
  };

  const logout = () => {
    localStorage.removeItem("token");
    setUser(null);
    window.location.href = "/login";
  };

  return (
    <AuthContext.Provider value={{ user, isLoading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};
export const useAuth = () => {
  const context = React.useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within an AuthProvider");
  return context;
};

存储方案上选择了 localStorage,因为 sessionStorage 在会话关闭、页面关闭后重新打开需要重新登录,使用起来比较麻烦。

但需要注意:localStorage 易受到 XSS 攻击,生产环境更推荐使用 httpOnly Cookie 存储 JWT。(这一块后续我要了解一下)

这里涉及请求头、axios 实例配置(client.ts)、前端请求拦截器(axios)、前端注册接口、文档相关接口、全局状态管理、登录状态管理等等。

最后在前端导航要做重定向,默认定向到登录页,实现UX上的交互。

六、小结

其实这里的内容是前天做的,中间耽搁了一会儿,当时实现的时候觉得困难重重——主要是因为之前没有建立好后端实现的思路,具体实现的细节,并且也没有相对应的概念。

从项目的角度来讲,我更深体会到了前后端协作,也就是之前看到的前端要懂业务,虽说这里有关前端的细节写得不是太多,但是前端要能知道后端要做什么,从产品的角度来讲每一个开发者都要有全栈能力,但是从公司的角度来说业务体系庞大才拆分的前端与后端等等岗位。

从面试的角度,这里也涉及到很多JWT鉴权细节上的考量,随便深挖就会揪出更多底层原理,例如我随意问一问:什么是 JWT?为什么要用 JWT?JWT 和 Session 的区别?登录流程:如何颁发 Token?请求流程:如何校验 Token?前端怎么存?怎么带?中间件在 JWT 里做什么?

啊哈......现在一看又觉得自己不懂了,只是没有把这些表达再深入内化一下,先慢慢来,慢慢深挖学习更多内容。

坚持学习,坚持反思,加油!

特别声明:本次代码实现仅仅是能跑通功能,并不是优雅的做法,存在设计等层面的缺陷,还请见谅。限于个人经验,文中若有疏漏,还请不吝赐教。

【节点】[Texture2DArrayAsset节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

Texture 2D Array Asset 节点是 Unity URP Shader Graph 中的一个重要资源节点,专门用于处理和管理 2D 纹理数组资源。在实时渲染和游戏开发中,纹理数组提供了一种高效的方式来组织和访问多个相关纹理,特别是在需要频繁切换或混合多个纹理的场景中。该节点允许着色器设计师在 Shader Graph 工作流中直接引用和操作 2D 纹理数组,而无需编写复杂的 HLSL 代码。

在传统的着色器编程中,处理多个纹理通常需要声明多个纹理采样器,这会导致代码冗余和性能开销。Texture 2D Array Asset 节点通过将多个 2D 纹理组合成一个单一的纹理数组资源,极大地简化了这一过程。这种方法的优势在于,它允许 GPU 在单个纹理数组中存储和访问多个纹理,从而减少纹理切换的开销,提高渲染效率。

该节点特别适用于需要处理多种变体纹理的场景,如地形系统中的不同地表纹理、角色自定义系统中的皮肤或服装纹理、季节变化系统中的环境纹理,以及任何需要基于索引动态选择纹理的应用场景。通过使用纹理数组,开发者可以避免在渲染过程中频繁绑定和解除绑定不同的纹理资源,这对于优化渲染性能具有重要意义。

在 URP 渲染管线中,Texture 2D Array Asset 节点的集成使得非编程人员也能轻松创建复杂的材质效果。美术师和技术美术可以通过直观的节点界面配置纹理数组,而不必深入了解底层的着色器编程细节。这种可视化的工作流程大大降低了创建高级视觉效果的门槛,同时保持了代码的整洁和可维护性。

描述

Texture 2D Array Asset 节点的主要功能是定义在着色器中使用的常量 2D 纹理数组资源。这里的"常量"意味着该节点引用的是在编辑时确定的固定纹理数组资源,而不是在运行时动态生成的纹理数据。这种设计使得着色器能够在编译时进行优化,同时确保渲染结果的一致性。

2D 纹理数组本质上是一个包含多个 2D 纹理的容器,所有这些纹理必须具有相同的尺寸、格式和 mipmap 级别。这种一致性要求是纹理数组与普通纹理集合的关键区别,也是其性能优势的基础。由于所有子纹理共享相同的特性,GPU 可以更高效地管理和访问这些纹理,特别是在需要随机访问不同索引的情况下。

要对 2D 纹理数组资源进行采样,必须将其与 Sample Texture 2D Array 节点结合使用。这种设计遵循了 Shader Graph 的模块化原则,其中资源定义和采样操作是分离的。这种分离提供了更大的灵活性,允许开发者使用相同的纹理数组资源,但应用不同的采样参数,如使用不同的采样器状态或变换 UV 坐标。

使用单个 Texture 2D Array Asset 节点时,可使用不同的参数对 2D 纹理数组进行多次采样,无需对 2D 纹理数组本身进行多次定义。这种重用性不仅简化了 Shader Graph 的结构,还确保了资源的一致性。例如,在一个复杂的材质中,可能需要使用相同的纹理数组但应用不同的过滤模式或寻址模式,这时只需连接单个 Texture 2D Array Asset 节点到多个 Sample Texture 2D Array 节点即可。

在实际应用中,Texture 2D Array Asset 节点通常用于以下场景:

  • 地形渲染,其中不同的纹理对应不同的地表类型(草地、泥土、岩石等)
  • 角色定制系统,其中纹理数组包含不同的皮肤色调、发型或服装选项
  • 天气或季节系统,其中纹理数组存储不同天气条件下的环境纹理
  • 特效系统,如动画序列帧的纹理数组表示
  • 材质变体管理,其中不同的纹理索引对应不同的材质外观

理解 Texture 2D Array Asset 节点的工作方式对于有效使用 URP Shader Graph 至关重要。该节点本身不执行任何采样或处理操作;它仅仅是对纹理数组资源的引用。实际的纹理查找和过滤操作由专门的采样节点处理。这种关注点分离使得 Shader Graph 更加模块化和可维护。

端口

Texture 2D Array Asset 节点的端口配置相对简单,这反映了其专用性质。节点只有一个输出端口,用于将纹理数组资源传递到 Shader Graph 中的其他节点。

输出端口

名称 方向 类型 描述
Out 输出 2D 纹理数组 输出值

输出端口标记为"Out",方向为输出,数据类型为"2D 纹理数组"。这个端口将纹理数组资源连接到 Shader Graph 中的其他节点,特别是 Sample Texture 2D Array 节点。

输出端口的重要性在于它是纹理数组资源进入着色器处理管道的入口点。通过这个端口,纹理数组可以被多个采样节点复用,每个采样节点可以应用不同的采样参数。这种设计避免了在 Shader Graph 中重复定义相同的纹理数组资源,减少了资源冗余和潜在的错误。

在连接 Texture 2D Array Asset 节点时,输出端口通常连接到 Sample Texture 2D Array 节点的"Texture 2D Array"输入端口。这种连接建立了从资源定义到实际采样操作的完整流程。值得注意的是,输出端口传递的不仅仅是纹理数据本身,还包括与纹理数组相关的元数据,如纹理尺寸、格式和 mipmap 信息。

输出端口的数据流是只读的,意味着纹理数组资源在 Shader Graph 中不能被修改。这种不变性保证了着色器执行的确定性和可预测性,同时也符合现代图形 API 的最佳实践。如果需要在运行时修改纹理内容,应该考虑使用 Render Texture 或其他动态纹理技术,而不是 Texture 2D Array Asset 节点。

在实际使用中,输出端口的连接通常直观明了,但开发者需要注意数据类型匹配。Texture 2D Array Asset 节点的输出必须连接到期望 2D 纹理数组输入的节点,否则 Shader Graph 会显示连接错误。这种类型安全检查有助于在编译前捕获潜在的错误,提高开发效率。

控件

Texture 2D Array Asset 节点的控件配置简洁明了,专注于其核心功能——选择和管理 2D 纹理数组资源。

资源选择控件

名称 类型 选项 描述
对象字段(2D 纹理数组) 定义项目中的 2D 纹理数组资源。

控件区域显示为一个对象字段,允许用户从项目资源中选择一个 2D 纹理数组。这个字段没有特定的标签名称,其功能通过上下文和字段类型显而易见。当字段为空时,通常显示"None (Texture 2D Array)"的提示文本,指示用户需要分配一个有效的纹理数组资源。

对象字段支持拖放操作,用户可以从 Project 窗口直接拖拽纹理数组资源到字段中。此外,字段右侧还提供了一个对象选择器按钮(通常显示为圆圈图标),点击后会打开一个资源选择窗口,过滤只显示 2D 纹理数组资源,方便用户快速定位所需资源。

当选择了有效的纹理数组资源后,字段会显示资源的名称和一个小预览图。这提供了直观的反馈,帮助用户确认已选择了正确的资源。如果资源丢失或类型不匹配,字段通常会显示错误状态,提示用户需要修复资源引用。

Texture 2D Array Asset 节点对引用的纹理数组有一定的要求:

  • 纹理数组必须在 Unity 编辑器中预先创建,不能是运行时生成的
  • 所有包含的纹理必须具有相同的尺寸(宽度和高度)
  • 所有纹理必须具有相同的纹理格式
  • 所有纹理必须具有相同的 mipmap 设置
  • 纹理数组的大小(纹理数量)必须在图形 API 支持的范围内

在 Unity 中创建 2D 纹理数组通常需要通过脚本或特定的编辑器工具,因为标准导入设置不直接支持纹理数组的创建。一种常见的方法是使用 Texture2DArray 类通过 C#脚本创建,或者使用 Package Manager 中的 Texture Array Importer 等第三方工具。

控件区域还隐含着对纹理数组设置的访问。虽然不能直接通过节点控件修改纹理数组的属性,但通常可以通过右键点击资源引用并选择"Edit in Inspector"来在 Inspector 窗口中调整相关设置。这种工作流程保持了节点的简洁性,同时提供了对底层资源的完全控制。

生成的代码示例

当在 Shader Graph 中使用 Texture 2D Array Asset 节点时,Unity 会在生成着色器代码时创建相应的 HLSL 声明。这些声明使得着色器能够访问和采样纹理数组资源。

基础代码生成

以下示例代码表示此节点的一种可能结果:

TEXTURE2D_ARRAY(_Texture2DArrayAsset);
SAMPLER(sampler_Texture2DArrayAsset);

这段生成的代码包含两个关键部分:纹理声明和采样器声明。

TEXTURE2D_ARRAY 宏声明了一个 2D 纹理数组变量,变量名基于节点在 Shader Graph 中的名称或默认命名约定。在这个例子中,变量名为_Texture2DArrayAsset。这个宏扩展为特定图形 API 的纹理声明,确保跨平台兼容性。

SAMPLER 宏声明了与纹理数组关联的采样器状态,变量名通常与纹理变量名对应但带有"sampler_"前缀。采样器状态控制如何对纹理进行采样,包括过滤模式、寻址模式和各向异性设置等。

高级代码生成特性

在实际的 Shader Graph 使用中,生成的代码可能会更加复杂,包含更多的优化和平台特定处理:

TEXTURE2D_ARRAY(_Texture2DArrayAsset);
SAMPLER(sampler_Texture2DArrayAsset);
float4 _Texture2DArrayAsset_TexelSize;

除了基本的纹理和采样器声明,Unity 还可能生成额外的辅助变量,如纹理的 TexelSize。这些变量提供关于纹理尺寸的信息,用于实现与纹理分辨率相关的效果,如精确的纹理坐标计算或基于像素的效果。

生成的代码还会包含适当的 HLSL 预处理指令,以确保在不同平台和渲染管线上的正确性:

#ifdef UNITY_URP_7_0_OR_NEWER
TEXTURE2D_ARRAY(_Texture2DArrayAsset);
SAMPLER(sampler_Texture2DArrayAsset);
#else
// 回退到传统声明
#endif

这种条件编译确保着色器代码与不同版本的 URP 兼容,同时为旧版渲染管线提供适当的回退方案。

采样代码生成

虽然 Texture 2D Array Asset 节点本身不生成采样代码,但了解完整的采样流程对于调试和优化至关重要。当与 Sample Texture 2D Array 节点结合使用时,生成的代码大致如下:

// 由Texture 2D Array Asset节点生成
TEXTURE2D_ARRAY(_TerrainTextures);
SAMPLER(sampler_TerrainTextures);

// 由Sample Texture 2D Array节点生成
float4 sample1 = SAMPLE_TEXTURE2D_ARRAY(_TerrainTextures, sampler_TerrainTextures, uv, index);
float4 sample2 = SAMPLE_TEXTURE2D_ARRAY_LOD(_TerrainTextures, sampler_TerrainTextures, uv, index, lod);

SAMPLE_TEXTURE2D_ARRAY 宏执行实际的纹理查找操作,接受纹理数组、采样器状态、UV 坐标和数组索引作为参数。这个宏处理不同图形 API 之间的差异,提供统一的采样接口。

平台特定考虑

生成的代码会根据目标平台进行优化,例如:

  • 在移动平台上,可能会使用更紧凑的纹理格式
  • 在支持 bindless 纹理的平台上,可能会使用不同的纹理绑定方式
  • 在需要向后兼容的平台上,可能会使用传统的纹理采样方法

理解生成的代码有助于高级用户优化其 Shader Graph 设置,特别是在面对性能瓶颈或平台兼容性问题时。虽然 Shader Graph 旨在抽象这些细节,但在某些情况下,直接检查生成的代码可以提供有价值的洞察。

实际应用示例

为了充分理解 Texture 2D Array Asset 节点的实用性,以下将探讨几个具体的应用场景和实现方法。

地形纹理混合系统

在地形系统中,Texture 2D Array Asset 节点可以高效管理多种地表纹理:

  • 创建包含不同地形类型(草地、沙地、岩石、雪地等)的纹理数组
  • 使用地形 alpha 贴图或高度图确定每个像素应使用哪个纹理索引
  • 通过 Sample Texture 2D Array 节点根据索引采样对应的纹理
  • 实现纹理间的平滑过渡和混合

在这种应用中,Texture 2D Array Asset 节点提供了统一的纹理管理接口,而 Sample Texture 2D Array 节点处理基于索引的实际采样。这种分离使得地形着色器更加模块化,易于维护和扩展。

角色自定义系统

在角色定制系统中,Texture 2D Array Asset 节点可以管理角色的不同外观选项:

  • 为皮肤色调、发型、服装等创建独立的纹理数组
  • 使用脚本控制的参数或 UI 选择确定纹理索引
  • 通过索引动态切换角色外观,无需加载新的纹理资源
  • 支持实时预览和混合不同自定义选项

这种方法的优势在于所有变体都预加载在单个纹理数组中,避免了运行时纹理加载导致的性能问题。Texture 2D Array Asset 节点确保这些变体在着色器中可用,而 Sample Texture 2D Array 节点根据用户选择执行相应的查找。

季节变化系统

在动态环境系统中,Texture 2D Array Asset 节点可以存储不同季节的环境纹理:

  • 创建包含春夏秋冬四季纹理的数组
  • 根据游戏时间或玩家进度动态调整纹理索引
  • 实现季节间的平滑过渡效果
  • 与其他环境元素(如光照、粒子效果)协调变化

通过使用纹理数组,季节变化可以通过简单的索引调整实现,而不是替换整个纹理集。这种方法的性能更高,特别在需要频繁切换环境的场景中。

性能优化技巧

在使用 Texture 2D Array Asset 节点时,以下技巧可以帮助优化性能:

  • 确保纹理数组中的所有纹理使用相同的压缩设置,避免格式转换开销
  • 合理选择纹理数组的大小,平衡内存使用和访问效率
  • 使用 mipmap 确保在远距离渲染时的性能和质量
  • 考虑纹理流送需求,特别是对于大型纹理数组
  • 利用纹理数组的随机访问特性,减少纹理切换次数

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

【解决方案】微信浏览器跳出到浏览器打开、跳转到app,安卓&ios

安卓在微信浏览器中唤起在浏览器打开弹窗

效果:微信 h5 点击按钮后展示弹框‘即将离开微信,在浏览器打开’。

原理:微信检测到下载链接时会拉起弹窗,此时可以选择在浏览器打开。

思路就是实现一个接口,如果是在微信环境,就下载文件,否则就重定向到业务页面。

参考文章:https://segmentfault.com/a/1190000044832622

express 版:

router.get("/jump", (req, res) => {
  // 1. 获取用户代理字符串,并检查是否包含 MicroMessenger (忽略大小写更稳妥)
  const userAgent = req.get("User-Agent") || "";
  const isWeChat = /MicroMessenger/i.test(userAgent);

  if (isWeChat) {
    // 2. 如果是微信浏览器,强制下载文件
    // 假设 jump.doc 和当前执行的 js 文件在同一个目录下
    const filePath = path.join(__dirname, "jump.doc");

    // Express 的 res.download 会自动帮你设置 Content-Type, Content-Disposition 等头信息
    res.download(filePath, "jump.doc", (err) => {
      if (err) {
        // 如果文件不存在或下载过程中出错,进行错误处理
        console.error("文件下载出错:", err);
        if (!res.headersSent) {
          res.status(404).send("文件未找到或下载失败");
        }
      }
    });
  } else {
    // 3. 如果不是微信浏览器,输出 JS 进行重定向到业务页面
    const alipayUrl = "http://192.168.8.7:5502/index.html";

    // 等同于 PHP 的 echo
    res.send(`<script>location.href="${alipayUrl}";</script>`);

    /* * 💡 额外提示:
     * 在 Node/Express 中,通常推荐使用原生 HTTP 重定向,即:
     * res.redirect(alipayUrl);
     * 但如果你明确需要像原 PHP 那样使用前端 JS 跳转(为了规避某些平台的拦截),
     * 使用上面的 res.send('<script>...') 也是完全可以的。
     */
  }
});

使用时直接跳转到这个路径即可:

window.location.href = "http://192.168.8.7:3000/jump";

安卓在微信浏览器中唤起跳转到app弹窗

使用协议链接跳转。

window.location.href = "com.greenpoint://android.mc10086.activity";

苹果在微信浏览器中唤起跳转到app弹窗

苹果可以使用 Universal Link,实现从微信浏览器中跳转到外部app,这里用的是这个方案,还有其他方法比如 wx-open-launch-app从应用宝链接中转等。

window.location.href =
  "https://client.app.coc.10086.cn/activity/transit/universalLink.html";

完整

此处为 http://192.168.8.7:5502/index.html 文件的 js。

const userAgent = navigator.userAgent;
const isWeChat = /MicroMessenger/i.test(userAgent);
const isIos = /iPhone|iPad|iPod/i.test(userAgent);

// 微信内按钮点击:在浏览器中打开
function wxBtnClick() {
  if (isIos) {
    window.location.href = `https://client.app.coc.10086.cn/activity/transit/universalLink.html`;
  } else {
    window.location.href = "http://192.168.8.7:3000/jump";
  }
}

// 微信外打开:跳转到app
function normalBtnClick() {
  if (isIos) {
    window.location.href = `https://client.app.coc.10086.cn/activity/transit/universalLink.html`;
  } else {
    window.location.href = `com.greenpoint://android.mc10086.activity`;
  }
}

if (!isWeChat) {
  normalBtnClick();
}

document.querySelector("#wx-open-app-button").onclick = () => {
  if (isWeChat) {
    wxBtnClick();
  } else {
    normalBtnClick();
  }
};

【uniapp】微信小程序实现自定义 tabBar

前言

自定义 tabBar 可以让开发者更加灵活地设置 tabBar 样式,以满足更多个性化的场景,本文分享如何在uniapp vue3 实现自定义微信小程序 tabBar。

配置信息

pages.json 中添加 tabBar 的相关配置,例如

{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "首页"
      }
    },
    {
      "path": "pages/mine/index",
      "style": {
        "navigationBarTitleText": "我的"
      }
    }
  ],
  "tabBar": {
    "custom": true,
    "color": "#7A7E83",
    "selectedColor": "#3cc51f",
    "borderStyle": "black",
    "backgroundColor": "#ffffff",
    "list": [
      {
        "pagePath": "pages/index/index",
        "iconPath": "static/icon_component.png",
        "selectedIconPath": "static/icon_component_HL.png",
        "text": "首页"
      },
      {
        "pagePath": "pages/mine/index",
        "iconPath": "static/icon_API.png",
        "selectedIconPath": "static/icon_API_HL.png",
        "text": "我的"
      }
    ]
  }
}

添加 tabBar 代码文件

在根目录添加 custom-tab-bar 文件夹,下面包含微信小程序原生文件。具体可以参考 微信小程序官方文档

编写 tabBar 代码

这一步需要获取自定义 tabBar 组件实例,通过示例来更新选中的 tab,微信小程序可以通过 this 操作,uniapp 也支持直接操作微信小程序组件示例,如下代码

<template>
  <view>
    <text>首页</text>
  </view>
</template>

<script setup>
import { getCurrentInstance } from "vue";
import { onShow } from "@dcloudio/uni-app";

const instance = getCurrentInstance();

onShow(() => {
  const tabBar = instance?.proxy?.$scope?.getTabBar?.();  // 获取组件示例函数返回值
  if (tabBar) {
    tabBar.setData({
      selected: 0,
    });
  }
});
</script>

其他 tab 页同理

tabbar.gif

示例项目

代码在下方链接的附件

链接

交流群

我建了一个微信群(非官方),大家可以在群里和我沟通交流 uniapp 开发遇到的问题、uniapp 的源码等问题。

mmqrcode1774407130592.png

WASM 替代服务端的场景探索

WASM 替代服务端的场景探索:视频处理、加密、数据分析,3 个方向的实战验证

你有没有想过,前端直接处理一个 200MB 的视频文件,不经过服务器?两年前我会觉得这是异想天开,但最近在项目里用 WebAssembly 把三个原本必须走服务端的重计算场景搬到了浏览器里跑,结果不但跑通了,某些场景下体验比服务端还好。这篇文章不是 WASM 入门科普,而是聚焦三个具体方向——视频处理、加密运算、数据分析——逐个拆解:哪些场景真的适合用 WASM 替代服务端,哪些是伪命题,以及我踩过的那些坑。

二、视频处理:最直观的收益场景

2.1 痛点在哪

我们的 B 端系统有个视频裁剪功能,用户上传一段会议录像,截取其中 5 分钟片段。原来的流程是:前端上传到 OSS → 服务端拉下来用 FFmpeg 裁剪 → 结果传回 OSS → 前端拿下载链接。一个 500MB 的视频,光上传就要 2 分钟(按 4MB/s 算),服务端处理 30 秒,下载又 1 分钟。

2.2 WASM 方案:ffmpeg.wasm

ffmpeg.wasm 是 FFmpeg 编译到 WebAssembly 的版本,核心能力和原生 FFmpeg 基本一致。关键代码长这样:

import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';

const ffmpeg = new FFmpeg();

// 加载 WASM 核心,这一步大概要下载 25MB 左右的 wasm 文件
await ffmpeg.load({
  coreURL: await toBlobURL('/ffmpeg-core.js', 'text/javascript'),
  wasmURL: await toBlobURL('/ffmpeg-core.wasm', 'application/wasm'),
});

// 把用户选择的视频文件写入虚拟文件系统
await ffmpeg.writeFile('input.mp4', await fetchFile(videoFile));

// 执行裁剪:从第 60 秒开始,截取 300 秒
await ffmpeg.exec([
  '-i', 'input.mp4',
  '-ss', '60',
  '-t', '300',
  '-c', 'copy',    // 关键:不重新编码,直接拷贝流
  'output.mp4'
]);

const data = await ffmpeg.readFile('output.mp4');
const blob = new Blob([data], { type: 'video/mp4' });

这里有个关键点:-c copy 参数。它表示不重新编码,只做流拷贝。视频裁剪、拼接这类不需要重新编码的操作,WASM 的性能完全够用。但如果你要做转码(比如 H.265 转 H.264),浏览器里跑 WASM 会比服务端慢 5-10 倍,这种场景不建议迁移。

2.3 踩坑记录

**坑一:SharedArrayBuffer 的安全限制。

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

我们在 Nginx 加了这两个头之后,页面里嵌入的第三方统计脚本全挂了,因为 require-corp 会拦截没有 Cross-Origin-Resource-Policy 头的跨域资源。最后的方案是把 ffmpeg 处理逻辑放到一个单独的 iframe 里,主页面不受影响。排查这个问题花了大半天。

坑二:内存限制。 浏览器里 WASM 的内存上限通常是 2GB-4GB。处理超过 1GB 的视频文件时,虚拟文件系统会把整个文件加载到内存,很容易 OOM。我们的解法是对大文件先在 JS 层做分片,每次只处理一个分片。

2.4 效果对比

同一个 500MB 视频裁剪 5 分钟片段:| 指标 | 服务端方案 | WASM 方案 | |------|-----------|----------| | 总耗时 | 3 分 30 秒 | 45 秒 | | 服务器带宽消耗 | 1GB(上传+下载) | 0 | | 用户体验 | 上传等待+轮询结果 | 本地实时进度条 | | 月均成本 | ~5000 元 | 0 |

45 秒主要花在读取本地文件到内存上,裁剪本身 -c copy 模式下只要几秒。

三、加密运算:隐私合规的刚需场景

3.1 痛点在哪

去年接了一个医疗数据平台的项目,有个硬性要求:患者的身份证号、手机号等敏感字段,在离开浏览器之前必须完成加密,服务端只存密文。甲方的安全团队原话是:"明文不能出浏览器"。

JavaScript 本身有 Web Crypto API,但它只支持标准算法(AES、RSA、SHA 系列)。

用纯 JS 实现 SM2?可以是可以,npm 上有 sm-crypto 这个包,但性能非常拉胯。我们测过,批量加密 1000 条记录(每条包含 3 个敏感字段),纯 JS 版要 8.2 秒,用户能明显感知到页面卡顿。

3.2 WASM 方案:C 语言国密库编译到 WASM

我们选了开源的 GmSSL(C 语言实现),用 Emscripten 编译成 WASM 模块。封装后的调用接口大概是这样:

// wasm_sm_crypto.js —— 封装层
import initWasm from './gmssl.wasm.js';

let wasmInstance = null;

export async function init() {
  wasmInstance = await initWasm();
}

export function sm4Encrypt(plaintext, key) {
  // 把 JS 字符串写入 WASM 线性内存
  const encoder = new TextEncoder();
  const data = encoder.encode(plaintext);
  const keyBytes = hexToBytes(key);

  const dataPtr = wasmInstance._malloc(data.length);
  const keyPtr = wasmInstance._malloc(16);
  const outPtr = wasmInstance._malloc(data.length + 16); // 补齐 padding

  wasmInstance.HEAPU8.set(data, dataPtr);
  wasmInstance.HEAPU8.set(keyBytes, keyPtr);

  // 调用 C 函数
  const outLen = wasmInstance._sm4_cbc_encrypt(
    dataPtr, data.length,
    keyPtr,
    outPtr
  );

  const result = new Uint8Array(
    wasmInstance.HEAPU8.buffer, outPtr, outLen
  );
  const encrypted = bytesToHex(result);

  // 释放内存——这一步千万别忘
  wasmInstance._free(dataPtr);
  wasmInstance._free(keyPtr);
  wasmInstance._free(outPtr);

  return encrypted;
}

这段代码有个容易踩的坑:手动内存管理。WASM 没有 GC,_malloc 了必须 _free,不然内存泄漏。我们早期忘了释放 outPtr,跑了一会儿就 OOM 崩了。后来统一封装了一个 withMemory 的 helper 函数,类似 Go 的 defer,确保作用域结束自动释放。

3.3 性能数据

批量加密 1000 条记录(每条 3 个字段,SM4-CBC 模式),三个方案对比:

纯 JS(sm-crypto)      :8200ms
WASM(GmSSL 编译)      :620ms
服务端(Go + GmSSL)    :45ms + 网络 RTT 约 200ms ≈ 245ms

WASM 比纯 JS 快了 13 倍。虽然绝对性能不如服务端,但 620ms 的延迟在用户提交表单时完全可以接受,而且满足了"明文不出浏览器"的合规要求。

四、数据分析:最容易被低估的方向

4.1 痛点在哪

我们有个运营后台,核心功能是让运营同事导入 Excel(通常 10 万-50 万行),做筛选、分组统计、透视表这些操作。原来的方案是把 Excel 传到服务端,用 Python Pandas 处理完把结果返回。问题有两个:一是每次改个筛选条件就要重新请求服务端,交互延迟很明显(平均 3-4 秒);二是运营同事经常在处理还没确认之前反复调整条件,十几次请求打到后端,白白浪费计算资源。

4.2 WASM 方案:DuckDB-WASM

DuckDB 是一个嵌入式分析型数据库(你可以理解为分析场景的 SQLite),它有官方的 WASM 版本,可以直接在浏览器里跑 SQL。

import * as duckdb from '@duckdb/duckdb-wasm';

// 初始化
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
const worker = new Worker(bundle.mainWorker);
const logger = new duckdb.ConsoleLogger();
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);

const conn = await db.connect();

// 直接把前端拿到的 Excel 转成的 CSV/Parquet 注册为表
await db.registerFileBuffer(
  'sales.parquet',
  new Uint8Array(parquetBuffer)
);

// 然后就能直接写 SQL 了
const result = await conn.query(`
  SELECT 
    region,
    product_category,
    SUM(amount) as total_sales,
    COUNT(*) as order_count
  FROM 'sales.parquet'
  WHERE order_date >= '2025-01-01'
  GROUP BY region, product_category
  ORDER BY total_sales DESC
`);

这段代码的亮点在于:你在浏览器里获得了一个完整的 SQL 引擎。

4.3 为什么不用纯 JS 方案

你可能会问,直接用 JS 数组操作 filterreduce 不行吗?10 万行数据在 JS 里 reduce 一下也不慢。

小数据量确实可以。但当数据到了 30 万行以上,差距就出来了。DuckDB 底层是列式存储 + 向量化执行引擎,这两个东西是专门为分析型查询设计的。打个比方:JS 数组遍历是逐行扫描,像你拿着清单一行一行找;DuckDB 的列式引擎是直接把"金额"那一列拎出来批量求和,跳过了所有不相关的列。我们在 30 万行、12 列的数据集上做了对比测试——分组聚合(GROUP BY 2 列,SUM 1 列):

JS Array.reduce()        :1850ms
Lodash _.groupBy()       :2100ms
DuckDB-WASM SQL          :180ms
服务端 Pandas             :95ms + 网络 RTT 800ms895ms

DuckDB-WASM 比纯 JS 快了 10 倍,加上省掉的网络开销,实际体验比走服务端还快。

4.4 数据格式的选择很关键

这里有个容易忽略的点:文件格式对性能影响巨大。同样 30 万行数据:

  • 用 CSV 格式加载到 DuckDB-WASM:解析耗时 1200ms
  • 用 Parquet 格式加载:解析耗时 150ms

差了 8 倍。原因是 Parquet 本身就是列式存储格式,DuckDB 读 Parquet 几乎是零解析成本。所以我们的方案是:Excel 上传后,前端先用 SheetJS 解析成 JSON,再用 parquet-wasm(又一个 WASM 工具)转成 Parquet 格式喂给 DuckDB。虽然转换本身要几百毫秒,但后续每次查询都能享受 Parquet 的性能红利,整体算下来非常划算。

七、决策速查表

判断维度 适合 WASM 适合服务端
数据大小 < 1GB > 1GB
单次计算耗时 < 30 秒 > 30 秒
是否涉及数据库 不涉及 涉及
隐私合规要求 数据不能离开客户端 服务端有合规方案
调用频次 用户频繁交互调整 一次性批处理
网络环境 弱网/离线场景 稳定网络
计算类型 CPU 密集 GPU 密集/需要特殊硬件

我的判断流程是:先看数据能不能出浏览器(合规),再看计算量用户端能不能扛住(性能),最后看开发维护成本是否可接受(ROI)。三个条件都满足,就值得用 WASM。

uniapp vue3手搓签名组件

懒得解释,直接就可以用

    <div class="container">
        <CustomNavbar title="签名"></CustomNavbar>
        <div class="content">
            <div class="orientation-tip">
                <i class="fas fa-mobile-alt"></i> 横屏模式下书写体验更佳
            </div>

            <div class="signature-container">
                <div class="canvas-section">
                    <div class="canvas-wrapper">
                        <canvas canvas-id="signatureCanvas" id="signatureCanvas" class="signature-canvas"
                            disable-scroll="true" @touchstart="handleTouchStart" @touchmove="handleTouchMove"
                            @touchend="handleTouchEnd"></canvas>
                        <div class="performance-indicator">
                            FPS: {{ fps }} | 延迟: {{ latency }}ms | 点数: {{ pointCount }}
                        </div>
                        <div class="placeholder" v-if="!hasSignature">
                            <i class="fas fa-pen-nib" style="font-size: 40px; margin-bottom: 10px;"></i>
                            <div>请在此处签名</div>
                            <div style="font-size: 14px; margin-top: 10px;">笔画丝滑无偏移</div>
                        </div>
                    </div>

                    <div class="controls">
                        <button class="btn btn-clear" @click="clearSignature">
                            <i class="fas fa-eraser"></i> 清除
                        </button>
                        <button class="btn btn-undo" @click="undo" :disabled="!canUndo">
                            <i class="fas fa-undo"></i> 撤销
                        </button>
                        <button class="btn btn-save" @click="saveSignature" :disabled="!hasSignature">
                            <i class="fas fa-save"></i> 保存
                        </button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
import CustomNavbar from '@/components/custom-navbar.vue'

import { ref, onMounted, onUnmounted, nextTick } from 'vue'

const hasSignature = ref(false)
const canUndo = ref(false)
const signatureDataUrl = ref('')
const penWidth = ref(6)
const fps = ref(0)
const latency = ref(0)
const pointCount = ref(0)
const canvasWidth = ref(0)
const canvasHeight = ref(0)

// 高性能绘图变量
let ctx = null
let isDrawing = false
let paths = []
let currentPath = null
let lastRenderTime = 0
let fpsCounter = 0
let lastFpsUpdate = Date.now()
let lastPoints = []

// 性能优化配置
const CONFIG = {
    THROTTLE_DELAY: 4,
    USE_BEZIER: true,
    BATCH_DRAW: true
}

onMounted(() => {
    initCanvas()
    startFpsMonitor()
    // 监听横竖屏变化
    uni.onWindowResize((res) => {
        setTimeout(() => {
            initCanvasSize()
            redrawCanvas()
        }, 300)
    })
})

onUnmounted(() => {
    paths = []
    currentPath = null
    uni.offWindowResize()
})

// 初始化 Canvas 尺寸
const initCanvasSize = () => {
    const systemInfo = uni.getSystemInfoSync()
    const isLandscape = systemInfo.windowWidth > systemInfo.windowHeight

    if (isLandscape) {
        // 横屏模式 - 使用窗口宽度
        canvasWidth.value = systemInfo.windowWidth - 50 // 减去padding
        canvasHeight.value = systemInfo.windowHeight - 200 // 减去其他元素高度
    } else {
        // 竖屏模式
        canvasWidth.value = systemInfo.windowWidth - 50
        canvasHeight.value = 400
    }

    console.log('Canvas尺寸:', canvasWidth.value, canvasHeight.value)
}

// 初始化 Canvas
const initCanvas = () => {
    initCanvasSize()

    // 使用nextTick确保DOM更新后再创建canvas上下文
    nextTick(() => {
        ctx = uni.createCanvasContext('signatureCanvas', this)

        // 设置画布实际像素尺寸
        const query = uni.createSelectorQuery().in(this)
        query.select('#signatureCanvas').boundingClientRect(res => {
            if (res) {
                console.log('Canvas元素尺寸:', res.width, res.height)

                // 设置canvas实际绘制尺寸
                ctx.width = res.width
                ctx.height = res.height

                // 设置高性能绘制参数
                ctx.lineWidth = penWidth.value
                ctx.lineCap = 'round'
                ctx.lineJoin = 'round'
                ctx.strokeStyle = '#2c3e50'

                // 预绘制空白画布
                redrawCanvas()
            }
        }).exec()
    })
}

// 高性能触摸开始
const handleTouchStart = (e) => {
    const touch = e.touches[0]
    const startTime = Date.now()

    isDrawing = true
    hasSignature.value = true

    // 开始新路径
    currentPath = {
        points: [{ x: touch.x, y: touch.y, t: startTime }],
        color: '#2c3e50',
        width: penWidth.value
    }

    lastPoints = [{ x: touch.x, y: touch.y, t: startTime }]

    // 立即开始绘制
    ctx.beginPath()
    ctx.moveTo(touch.x, touch.y)
    ctx.stroke()
    ctx.draw(true)

    pointCount.value++
}

// 高性能触摸移动
const handleTouchMove = (e) => {
    if (!isDrawing || !currentPath) return

    const currentTime = Date.now()

    // 节流控制
    if (currentTime - lastRenderTime < CONFIG.THROTTLE_DELAY) {
        return
    }

    const touch = e.touches[0]
    const newPoint = { x: touch.x, y: touch.y, t: currentTime }

    // 添加点到当前路径
    currentPath.points.push(newPoint)
    lastPoints.push(newPoint)

    // 保持最近3个点用于贝塞尔计算
    if (lastPoints.length > 3) {
        lastPoints.shift()
    }

    // 高性能绘制
    if (CONFIG.USE_BEZIER && lastPoints.length >= 3) {
        drawBezierCurve(lastPoints)
    } else {
        drawStraightLine(lastPoints)
    }

    lastRenderTime = currentTime
    pointCount.value = currentPath.points.length
    latency.value = currentTime - e.timeStamp
}

// 绘制贝塞尔曲线
const drawBezierCurve = (points) => {
    if (points.length < 3) return

    const p0 = points[0]
    const p1 = points[1]
    const p2 = points[2]

    const cp1x = p1.x + (p2.x - p0.x) / 4
    const cp1y = p1.y + (p2.y - p0.y) / 4

    ctx.beginPath()
    ctx.moveTo(p1.x, p1.y)
    ctx.quadraticCurveTo(cp1x, cp1y, p2.x, p2.y)
    ctx.stroke()
    ctx.draw(true)
}

// 绘制直线
const drawStraightLine = (points) => {
    if (points.length < 2) return

    const lastPoint = points[points.length - 2]
    const currentPoint = points[points.length - 1]

    ctx.beginPath()
    ctx.moveTo(lastPoint.x, lastPoint.y)
    ctx.lineTo(currentPoint.x, currentPoint.y)
    ctx.stroke()
    ctx.draw(true)
}

// 触摸结束
const handleTouchEnd = () => {
    if (!isDrawing || !currentPath) return

    isDrawing = false

    if (currentPath.points.length > 1) {
        paths.push({ ...currentPath })
        canUndo.value = paths.length > 0
    }

    currentPath = null
    lastPoints = []
}

// 清除签名
const clearSignature = () => {
    ctx.clearRect(0, 0, 10000, 10000)
    ctx.draw(true)
    hasSignature.value = false
    canUndo.value = false
    signatureDataUrl.value = ''
    paths = []
    pointCount.value = 0
}

// 撤销上一步
const undo = () => {
    if (paths.length === 0) return

    paths.pop()
    canUndo.value = paths.length > 0
    hasSignature.value = paths.length > 0

    redrawCanvas()
}

// 高性能重绘画布
const redrawCanvas = () => {
    ctx.clearRect(0, 0, 10000, 10000)

    paths.forEach(path => {
        if (path.points.length < 2) return

        ctx.lineWidth = path.width
        ctx.strokeStyle = path.color
        ctx.beginPath()

        if (path.points.length === 2) {
            ctx.moveTo(path.points[0].x, path.points[0].y)
            ctx.lineTo(path.points[1].x, path.points[1].y)
        } else {
            ctx.moveTo(path.points[0].x, path.points[0].y)
            for (let i = 1; i < path.points.length; i++) {
                ctx.lineTo(path.points[i].x, path.points[i].y)
            }
        }

        ctx.stroke()
    })

    ctx.draw(true)
}

// 保存签名
const saveSignature = () => {
    if (paths.length === 0) return

    uni.showLoading({ title: '生成中...' })

    setTimeout(() => {
        uni.canvasToTempFilePath({
            canvasId: 'signatureCanvas',
            quality: 1,
            success: (res) => {
                convertToBase64(res.tempFilePath).then(base64Data => {
                    uni.hideLoading()
                    signatureDataUrl.value = res.tempFilePath

                    const pages = getCurrentPages()
                    const prevPage = pages[pages.length - 2]

                    if (prevPage && prevPage.$vm) {
                        prevPage.$vm.onBackWithParams({
                            data: base64Data
                        })
                    }

                    uni.navigateBack()
                }).catch(err => {
                    uni.hideLoading()
                    console.error('转换为base64失败:', err)
                    uni.showToast({
                        title: '保存失败',
                        icon: 'none'
                    })
                })
            },
            fail: (err) => {
                uni.hideLoading()
                console.error('保存签名失败:', err)
                uni.showToast({
                    title: '保存失败',
                    icon: 'none'
                })
            }
        })
    }, 100)
}

// 将图像文件转换为base64
const convertToBase64 = (filePath) => {
    return new Promise((resolve, reject) => {
        uni.getFileSystemManager().readFile({
            filePath: filePath,
            encoding: 'base64',
            success: (res) => {
                const base64Data = 'data:image/png;base64,' + res.data
                resolve(base64Data)
            },
            fail: (error) => {
                reject(error)
            }
        })
    })
}

// FPS监控
const startFpsMonitor = () => {
    const updateFps = () => {
        fpsCounter++
        const now = Date.now()
        if (now - lastFpsUpdate >= 1000) {
            fps.value = fpsCounter
            fpsCounter = 0
            lastFpsUpdate = now
        }
        requestAnimationFrame(updateFps)
    }
    updateFps()
}
</script>
<style scoped lang="scss">
.container {
    width: 100vw;
    height: 100vh;
    background: rgba(255, 255, 255, 0.98);
    overflow: hidden;
}

.content {
    padding: 25px;
    height: calc(100% - 80rpx);
    display: flex;
    flex-direction: column;
}

.orientation-tip {
    background: linear-gradient(to right, #ff7e5f, #feb47b);
    color: white;
    padding: 12px;
    text-align: center;
    font-size: 14px;
    border-radius: 8px;
    margin-bottom: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
}

.signature-container {
    flex: 1;
    display: flex;
    flex-direction: column;
}

.canvas-section {
    flex: 1;
    display: flex;
    flex-direction: column;
}

.canvas-wrapper {
    position: relative;
    flex: 1;
    width: 100%;
    border: 3px dashed #a0aec0;
    border-radius: 12px;
    background: #f8fafc;
    overflow: hidden;
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
    -webkit-tap-highlight-color: transparent;
}

.signature-canvas {
    width: 100%;
    height: 100%;
    background: white;
    display: block;
    touch-action: none;
}

.placeholder {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    color: #a0aec0;
    font-size: 20px;
    text-align: center;
    pointer-events: none;
    z-index: 1;
}

.performance-indicator {
    position: absolute;
    top: 10px;
    right: 10px;
    background: rgba(0, 0, 0, 0.7);
    color: white;
    padding: 5px 10px;
    border-radius: 4px;
    font-size: 12px;
    z-index: 2;
}

.controls {
    display: flex;
    justify-content: space-around;
    margin: 20rpx 0;
    flex-shrink: 0;
}

.btn {
    border-radius: 10rpx;
    font-size: 14rpx;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.3s;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 1rpx solid rgba(238, 238, 238, 0.5);
    color: #666;
}

.btn:active {
    transform: translateY(2rpx);
}

button {
    padding: 0;
    background: none !important;
    border: none !important;
    padding: 0 !important;
    margin: 0 !important;
    line-height: normal !important;
    border-radius: 0 !important;
    font-size: inherit !important;
    color: inherit !important;

    &::after {
        border: none !important;
    }
}

/* 横屏样式优化 */
@media screen and (orientation: landscape) {
    .container {
        max-width: 100vw;
    }

    .content {
        padding: 15px;
    }

    .canvas-wrapper {
        height: 100%;
        min-height: auto;
    }

    .orientation-tip {
        display: none;
    }

    .controls {
        margin-top: 15px;
        padding: 0 10px;
    }
}

/* 竖屏样式 */
@media screen and (orientation: portrait) {
    .canvas-wrapper {
        height: 400px;
    }
}

/* 防止iOS橡皮筋效果 */
body {
    position: fixed;
    width: 100%;
    height: 100%;
    overflow: hidden;
}
</style>

如何实现低代码源码级交付和私有化部署

这几年,低代码在国内企业级市场已经不算新鲜词了。用得好的团队,开发效率翻倍;用得不好的,心里一直有个疙瘩:低代码会不会是个“黑盒”?万一平台不更新了、厂商出问题了,或者业务复杂到必须改底层代码的时候,是不是就卡住了?

这种担心其实很正常。早几年的低代码产品,确实有不少是“黑盒”逻辑——可视化界面拖着拽着,应用跑起来了,但生成的代码你看不到,数据库结构不归你管,服务器部署也只能用厂商的云。时间一长,企业发现自己不是在开发应用,而是在“租用”应用。想迁移?很难。想深度定制?没门。

到了2026年,情况已经完全不同了。现在还在企业级市场站稳脚跟的低代码平台,几乎都绕不开两个硬指标:能不能源码交付能不能私有化部署。如果再往前推一步,还有一个更关键的能力——去平台化,也就是你哪天不想用这个平台了,能不能带着所有资产全身而退。

这篇文章就拿JNPF快速开发平台做个参照,聊聊这三个事到底怎么落地,以及为什么说现在的低代码已经不是当年的“黑盒”。

源码交付:把“黑盒”直接拆开

所谓“黑盒”,就是你只管输入输出,中间怎么运作的,你看不到也改不了。早期很多低代码平台就是这种模式,平台方把运行时环境、前端框架、后端逻辑都封装在自己手里,企业拿到的只是一个运行中的应用,不是真正的软件资产。

JNPF从一开始走的就是另一条路——源码交付。什么意思?你在平台上拖拽表单、配置流程、搭建页面,完成后生成的不是一堆只能在平台里运行的东西,而是可以直接导出、完整的前后端源代码。

  • 前端:基于Vue 3生成的标准代码,拿到以后你可以继续用WebStorm、VS Code改,想加什么组件就加什么。

  • 后端:基于Spring Boot / Spring Cloud生成的Java代码,Maven工程结构,你完全可以把它导入IDE,继续扩展业务逻辑、对接外部系统、做性能调优。

这就等于说,JNPF只是帮你把那些重复的、机械的CRUD工作自动完成了,但最终产出的依然是标准的、属于你公司的软件资产。你不用担心平台倒闭或者策略变化,因为代码就在你手里,任何时候都能独立维护、独立运行。

对于有技术实力的企业来说,源码交付还有一个很现实的好处:二次开发没有边界。平台自带的功能,比如流程引擎、权限体系、报表设计器,如果不够用,你可以直接在源码层面改。甚至你可以把JNPF当成一个“脚手架”,用它把基础框架搭好,后续全部走原生开发,完全没问题。

私有化部署:数据和控制权都留在自己手里

2026年,企业对数据安全的重视程度已经不用多说了。尤其是金融、政务、军工、大型制造这些行业,系统部署在公有云上基本是红线。低代码平台如果只支持SaaS模式,在这些人面前连入围资格都没有。

JNPF的私有化部署方案,说白了就是把整套平台——设计器、运行引擎、代码生成器、开发管理后台——全部部署在企业自己的服务器上。可以是本地机房,也可以是私有云,网络环境完全由企业自己控制。

这种部署方式带来几个实际价值:

  1. 数据不出域。所有业务数据、代码资产、配置信息,都在企业自己的网络边界内,不经过第三方服务器。对于有等保、信创要求的单位,这是硬性前提。

  2. 独立运行环境。平台不依赖外部任何服务,即便是断网环境下,开发团队照样可以搭建应用、发布上线。

  3. 定制运维。企业可以按照自己的规范做高可用部署、灾备、监控,也可以对接内部已有的运维体系,比如统一日志平台、APM监控。

另外需要提一句,私有化部署不等于“闭门造车”。JNPF在私有化模式下,仍然保留了在线开发、多人协作、Git版本对接这些能力,体验上并不比SaaS差。

去平台化能力:进来容易,出去也容易

源码交付解决了“代码到手”的问题,私有化部署解决了“运行环境自主”的问题。但还有一个更深层的风险,很多人没意识到:即便你拿到了代码,如果代码里到处都是平台厂商的私有SDK、专有协议、硬编码的依赖,那这个代码拿回来也基本上没法独立维护,等于被隐形绑定。

这就是“去平台化能力”要解决的事。

JNPF的做法比较务实。它的代码生成器生成的是标准技术栈的代码,没有在框架层面做过度封装。举个例子:

  • 数据库访问用的是MyBatis-Plus,不是自研的ORM;

  • 权限控制用的是Spring Security,不是私有的权限标签;

  • 接口规范遵循RESTful风格,没有强绑定到平台的API网关。

这意味着,当你把生成的代码拿走后,完全可以交给一个普通的Java开发团队去维护,不需要专门培训“JNPF开发工程师”。你可以随时决定“去平台化”——以后新功能用原生开发写,老功能继续用JNPF维护,或者干脆全部迁移到自研框架,都不会有太大阻力。

这种设计理念,本质上是在“效率”和“掌控”之间找到了一个平衡。平台提供效率,但最终的决定权始终在企业手里。

2026年,低代码的定位已经变了

回到最开始的问题:低代码是“黑盒”吗?

如果你的选型标准停留在五年前,盯着的是那些只能生成“平台内应用”的轻量级工具,那它确实是黑盒。但如果你选择的是JNPF这类支持源码交付、私有化部署、去平台化能力的平台,那它就不再是一个黑盒,而是一个可以随时拆解、扩展、甚至替换的生产力工具

到了2026年,低代码在企业级市场的定位已经非常清晰:它不是要替代专业开发,也不是要把企业锁死在一个封闭环境里,而是把那些重复性的、标准化的开发工作自动化,把专业开发人员从繁琐的增删改查中解放出来,去解决真正的业务难题。

JNPF在这个方向上的做法,说复杂也复杂,说简单也简单——就是用一种更透明、更开放的方式做低代码。你可以把它用成一个快速原型工具,也可以把它用成一个持续交付平台,甚至只把它当成一套代码生成器,生成完代码就脱离平台自己维护。这几种用法,它都能支持。

客观说几句

当然,没有哪个平台是完美的。源码交付模式下,企业需要自己负责代码的后续维护和部署运维,这对技术团队有一定要求;私有化部署虽然解决了数据安全问题,但也意味着企业要承担服务器资源和平台自身的运维成本。如果你的团队规模很小,或者只是做内部管理工具的快速验证,SaaS模式其实更轻量。

但从另一个角度看,JNPF这种“把控制权交还给企业”的思路,确实符合2026年企业软件选型的主流趋势——可掌控、可演进、可替代。不管政策怎么变、业务怎么发展,企业至少不会因为当初选了一个低代码平台而陷入被动。至于适不适合你,就看你的团队规模、安全要求、以及对长期技术掌控力的重视程度了。毕竟,工具只是工具,能把工具用到什么程度,最终还是看人。

聊聊 AI Coding 的最新范式:Harness Engineering:我们这群程序员,又要继续学了?

聊聊 AI Coding 的最新范式:从 Vibe Coding 到 Harness Engineering

最近在看AI 辅助编程的演进,发现咱们熟悉的工作模式Vibe Coding正在经历一次范式变迁。

一、什么是 Harness Engineering?

简单来说,Harness Engineering 就是给 Agent 盖一座“全自动工厂”。

  • Vibe Coding(结对编程): 是你和 AI 坐在电脑前,你说一句它改几行代码,靠“感觉”对齐。这是目前大家用 Cursor 的主流方式,需要人高度参与。
  • Harness Engineering(治具工程): 是你退后一步,不直接碰代码,而是去设计规则、约束、检查点和自动化循环(Loop),让 Agent 自主跑完流程。

“Harness” 最原始的意思是“马具”。Agent 不是在旷野上自由奔跑的野马(容易跑丢产生幻觉),而是被套上了“马具”,只能在你设计的轨道上、按照你的验收标准全力拉车。

在咱们工程语境里,最贴切的翻译是**“约束环境”“测试治具”**。想象一下工厂的生产线:检测电路板时,工人不会拿万用表一根根线去量,而是把电路板放进一个专用的“治具”架子上,一通电就能自动测出所有结果。我们现在要做的,就是为 Agent 打造这个“写代码的治具”。

二、AI 编程范式的三个阶段

阶段 模式 咱们程序员的角色 工具代表
1.0 辅助编程 AI 是插件 搬砖工(你写代码,AI 递砖) Copilot
2.0 Vibe Coding 结对编程 监工(看 AI 写代码,实时干预纠偏) Cursor
3.0 Harness Engineering 环境设计 架构师/工艺工程师(设计全自动生产线) Claude Code, Codex, 内部自研 Agent 平台

三、核心思维转变:从“改代码”到“改环境”

在 Harness 范式下,我们的核心技术点和做事方式需要发生根本性转变:

  1. 修复环境,而非修复代码: 当 AI 写出 Bug 时,低级做法是直接告诉它“这行写错了”;高级做法是思考**“为什么咱们的测试没抓到这个 Bug?”或者“是不是目录结构误导了它?”**。然后,通过修改 AGENTS.md 规范或增加 Linter 来“修复环境”。
  2. 极端的语义化约束: 文件路径、代码分层(如 Types -> UI)必须极度规范。不要再用 utils/ 这种模糊的目录,因为清晰的工程结构就是 Agent 的“认路地图”。
  3. 理解模型的“审美偏好”: 提示词工程不再是写长作文,而是精准预判模型的倾向。比如某个模型倾向于某种特定的 UI 风格或逻辑范式,你就必须在约束环境里加上量化指标,防止它跑偏。
  4. Skill(技能)的模块化与递归: 别把 Agent 当成万能黑盒。把它的能力拆解成一个个带有“Use when...”触发条件的微服务(Skill)。复杂的动作(如“修复 PR”)封装成 Skill,Skill 甚至可以生成新的 Skill,并配上单元测试。

四、咱们未来的核心工作流是什么?

作为高级开发者,我们的精力将从“写业务逻辑”转移到“设计软件生产的数字宪法”。具体分为以下五个维度:

1. 需求与上下文治理(最上游的防线)

  • 构建“需求验证治具”: 现实中的需求常有逻辑漏洞。在 Agent 进入编码循环前,要增加一环:让 AI 扮演“杠精产品经理”推演边缘场景,强制推行 BDD(行为驱动开发),直到需求本身逻辑闭环。
  • 建立“项目知识图谱”: Agent 每次跑任务都是“临时工”,不知道历史踩坑记录。除了写 AGENTS.md,我们还要建立动态的 DECISIONS.md。每次 PR 合并后,自动提炼“核心逻辑”存入向量库,作为 Harness 的长期记忆。

2. 定义数字化架构约束 (The Architect)

AI 就像极速狂奔但没方向感的劳动力,我们要为它修路建围栏。

  • 推行严格的 DDD 与单向依赖: 规定 UI 只能调 Service,Service 只能调 Repo。Agent 只要确认当前任务在哪一层,就能快速圈定上下文。
  • 语义化命名体系: 当前后端模型(如 OrderOrderDTO)命名严格对齐时,Agent 跨端理解的幻觉会大幅降低。

3. 设计确定性的反馈循环 (The Feedback Loop Designer)

Harness 的核心是 Loop。AI 写代码不重要,重要的是它如何知道自己写错了

  • 建立“可观测性”治具: 写脚本自动捕获编译器的报错、Console 的异常,结构化地喂给 Agent,让它自己排查。
  • 自动化测试矩阵(TDD 2.0): 我们不再“写代码”,而是“写验收标准”。在 Agent 动手前先定义好 E2E 测试,不通过测试就不能提 PR。这就是用“法治”代替“人治”。

4. 异构模型编排 (The Model Orchestrator)

不同模型各有所长(如写逻辑用 Claude,搞 UI 用 Gemini,修 Bug 用 Codex)。

  • 设计 Skill 路由: 写一个编排 Skill,根据任务标签自动将工作分发给最适合的模型。
  • 解决“上下文雪崩”: 当项目变大,我们要设计“上下文裁剪算法”,只提取 Agent 当前需要的 Types 和 Schema,而不是把整个仓库塞给它。

5. 掌控演进生命周期 (The Lifecycle Manager)

  • 设计契约(Contract): 在后端接口就绪前,先定义好 JSON Schema。告诉 Agent 基于契约写 Mock 逻辑。以后真实接口上线,代码几乎无需重写。
  • 环境隔离机制: 设计从 Mock 环境到生产环境的平滑切换(如环境变量控制),这是 AI 难以自发想周全的全局配置。

五、结语:重新定义咱们的身份

在古法编程时代,我们是伐木工(手写每一行代码); 在 Vibe Coding 时代,我们是带班组长(看着 AI 写代码); 在 Harness Engineering 时代,我们将是自动化林场的总设计师

大家现在可以立刻尝试落地的 3 件事:

  1. 编写你的 AGENTS.md 这不是给同事看的,而是给 AI 看的“宪法”,写清楚你们项目的目录流转和依赖关系。
  2. 构建核心“验收包”: 跑通一套自动化的 lint + build + test 脚本,作为 Harness 的第一道检查点。
  3. 沉淀“元 Skill”: 把日常工作中诸如“将 UI 图转为组件代码”、“根据 TS 接口生成 Mock 数据”等高频重复动作,封装成独立的原子能力。

拒绝繁琐配置!用 Tailwind CSS 3 搞定多主题 + 暗色模式切换,这套方案谁用谁香

一、前言

平时做 ToB 或 ToC 项目,最怕什么?最怕产品经理突然跑过来:“我们要给客户做定制化,换个品牌色”,或者用户反馈“晚上太亮了,能不能加个暗黑模式?”

如果你以前是硬编码颜色,比如满屏的 bg-blue-500,那改起来简直酸爽,全局搜索替换还得怕漏了某个角落。

recording.gif

今天分享一套我目前在用的 Tailwind CSS 3 主题切换方案。核心思路很简单:CSS 变量做容器,Tailwind 做钩子。不仅能轻松切换橙色、蓝色、紫色等多套主题,还能顺带把暗黑模式给搞定,话不多说,直接上干货!

紫色 橙色 蓝色
image.png image.png image.png

二、核心思路:CSS 变量 + Tailwind 配置

2.1 为什么要用 CSS 变量?

很多人用 Tailwind 喜欢直接在 tailwind.config.js 里写死颜色,比如 primary: '#FF7300'。这在小项目没问题,一旦需要动态切换,这种方式就显得很僵硬。

更优雅的做法是把 Tailwind 当作“消费者”,把 CSS 变量当作“提供者”。

打个比方:

  • CSS 变量就像是房子的**“混凝土结构”**,我们在底层定义好各种颜色的名字(如 --color-primary)。
  • Tailwind 类名就像是**“精装修”**,它不关心水泥砂浆是哪个牌子,只认名字。当你把底层的“橙色水泥”换成“蓝色水泥”时,上面的装修(UI 样式)会自动跟着变。

这套方案的架构逻辑如下:

  1. Config 层:让 Tailwind 的颜色去读取 CSS 变量。
  2. CSS 层:定义不同主题下的变量值(橙、蓝、紫、暗黑)。
  3. JS 层:控制 html 标签上的属性,触发 CSS 变量的切换。

三、Step 1:改造 Tailwind 配置

打开 tailwind.config.js,我们需要告诉 Tailwind:“以后遇到 bg-primary,别去死板地找颜色,去 CSS 变量里找”。

核心代码如下:

/** @type {import('tailwindcss').Config} */
export default {
  content: ['./src/**/*.{vue,js,ts,jsx,tsx}'],
  darkMode: 'class', // 🔥 关键点:开启 class 模式的暗色模式
  theme: {
    extend: {
      colors: {
        primary: {
          DEFAULT: 'var(--color-primary)', // 核心逻辑:引用 CSS 变量
          hover: 'var(--color-primary-hover)',
          // ... 其他色阶
          500: 'var(--color-primary-500)', 
          // 你甚至可以定义 50-900 的色阶,全部映射到 CSS 变量
        },
        // 其他功能性颜色 success, warning, danger 同理...
        text: {
          main: 'var(--color-text-main)',
          muted: 'var(--color-text-muted)',
        },
        bg: {
          alt: 'var(--color-bg-alt)',
        }
      },
    },
  },
  plugins: [],
};

📌 核心结论: 只要配置了 primary: 'var(--color-primary)',你在代码里写 <button class="bg-primary text-white">,Tailwind 编译时就会乖乖地把它替换成 background-color: var(--color-primary)。这一步是地基,一定要打牢。

四、Step 2:定义 CSS 变量库

接下来,我们需要在 CSS 文件中(比如 variables.css)定义这些变量的具体值。这里利用了 CSS 的层级覆盖特性。

4.1 默认主题(橙色)

默认情况下,我们定义一套橙色主题:

:root {
  /* 品牌色 - 橙色 */
  --color-primary: #FE7300;
  --color-primary-hover: #E66800;
  /* ... 省略大量色阶代码 ... */

  /* 文字色、背景色等基础变量 */
  --color-text-main: #0F172A;
  --color-bg-alt: #F8FAFC;
}

4.2 多品牌主题(蓝、紫)

如果用户想换成蓝色主题,我们不需要改代码,只需要在 html 标签上加一个 data-theme='blue',然后在 CSS 里针对这个属性做变量覆盖:

/* 蓝色主题 */
[data-theme='blue'] {
  --color-primary: #3B82F6;      /* 覆盖主色 */
  --color-primary-hover: #2563EB;
  /* 覆盖对应色阶... */
}

/* 紫色主题 */
[data-theme='purple'] {
  --color-primary: #8B5CF6;
  --color-primary-hover: #7C3AED;
  /* ... */
}

4.3 暗色模式

暗色模式不需要额外的 data-theme,因为 Tailwind 开启了 darkMode: 'class',我们只需要针对 .dark 类重置变量即可:

/* 暗色模式 */
:root.dark {
  /* 暗色模式下,文字变白,背景变黑 */
  --color-text-main: #F8FAFC;
  --color-text-muted: #94A3B8;

  --color-bg-alt: #0F172A; /* 深蓝黑背景 */
  --color-border: #334155;
}

🌟 注意事项: 定义 CSS 变量时,命名一定要规范!比如 --color-primary-50--color-primary-900,最好和 Tailwind 的默认色阶命名保持一致,这样后期维护心智负担最小。

五、Step 3:JS 逻辑控制(核心交互)

有了配置和样式,还需要一段 JS 代码来负责“搬运”——也就是给 HTML 标签增删属性。我们把这些逻辑封装到 theme.js 里。

核心逻辑拆解:

// 1. 定义支持的主题
export const themes = {
  orange: 'orange',
  blue: 'blue',
  purple: 'purple'
};

// 2. 设置主题函数
export function setTheme(theme) {
  if (!themes[theme]) return;

  // 🔥 核心操作:切换 data-theme 属性
  if (theme === 'orange') {
    document.documentElement.removeAttribute('data-theme'); // 默认主题移除属性
  } else {
    document.documentElement.setAttribute('data-theme', theme);
  }

  // 💾 存入本地存储,刷新不丢失
  localStorage.setItem('mohub-theme', theme);
}

// 3. 切换暗色模式
export function setDarkMode(isDark) {
  const html = document.documentElement;
  if (isDark) {
    html.classList.add('dark');
  } else {
    html.classList.remove('dark');
  }
  localStorage.setItem('mohub-dark-mode', isDark ? 'true' : 'false');
}

// 4. 初始化:页面加载时读取缓存
export function initTheme() {
  const savedTheme = localStorage.getItem('mohub-theme');
  const savedDarkMode = localStorage.getItem('mohub-dark-mode');

  if (savedTheme && themes[savedTheme]) {
    setTheme(savedTheme);
  }

  if (savedDarkMode === 'true') {
    setDarkMode(true);
  }
  return savedTheme;
}

总结一下: 这段代码就像是开关控制员。当你调用 setTheme('blue') 时,它就去改 HTML 属性;CSS 监测到属性变了,就会自动应用新的变量值;UI 也就跟着变了。一气呵成,丝般顺滑。

六、Step 4:在 Vue 组件中实战

最后,我们在组件里怎么用?非常简单,就像平时写 Tailwind 一样,不需要任何心理负担。

<template>
  <div>
    <!-- 切换器 -->
    <el-select v-model="currentTheme" @change="changeTheme">
      <el-option label="橙色" value="orange" />
      <el-option label="蓝色" value="blue" />
      <el-option label="紫色" value="purple" />
    </el-select>

    <!-- 这里的 bg-primary 会自动随主题变色 -->
    <button class="bg-primary hover:bg-primary-hover text-white px-4 py-2">
      主要按钮
    </button>

    <!-- 背景色和文字色同理 -->
    <div class="bg-primary-light p-4">浅色背景容器</div>
    <p class="text-primary">主题色文字</p>
  </div>
</template>

<script>
import { setTheme, initTheme, getThemeList } from '@/utils/theme';

export default {
  data() {
    return {
      currentTheme: 'orange',
      themeList: getThemeList()
    };
  },
  created() {
    // 初始化读取上一次的选择
    this.currentTheme = initTheme();
  },
  methods: {
    changeTheme() {
      setTheme(this.currentTheme);
    }
  }
};
</script>

亲测有效,你在下拉框切个蓝色,按钮瞬间变蓝,毫秒级响应,完全没有那种传统 Sass 变量替换需要重新编译的延迟感。

七、避坑指南(这 3 个坑我替你踩过了)

虽然方案很完美,但在实际落地时,有几个高频坑点一定要注意:

7.1 服务端渲染(SSR)闪烁问题

如果是 Next.js 或 Nuxt.js 项目,页面初始化时 JS 可能还没执行完,此时 localStorage 没读取到,用户会先看到一瞬间的默认色(比如橙色),然后才闪变成用户保存的蓝色。 解决方案:在 index.html<head> 标签里加一段内联脚本,在页面渲染前就先把 classdata-theme 给加上,虽然写起来有点丑,但能治好闪烁。

7.2 暗色模式下的颜色映射

别以为切了 dark 类就万事大吉了。在暗色模式下,如果你用了 bg-primary-100 这种浅色背景,一定要检查它在 CSS 变量里对应的值是否适合暗黑背景。 建议:暗黑模式下,尽量使用 bg-primary-900 或者专门定义 bg-primary-dark 这种变量,别直接套用浅色阶,不然对比度不够,看字费眼。

7.3 第三方组件库兼容性

如果你用了 Element Plus 或 Ant Design,它们通常也有自己的暗色模式。 注意:Tailwind 的 dark 类加在 html 上,往往会自动触发这些组件库的暗色样式(如果它们支持 CSS 变量),但最好还是确认一下。如果冲突了,可能需要把 Tailwind 的 dark 类加在更外层的容器上,而不是 html 上。

八、总结

这套方案的核心优势在于解耦

  1. 样式与配置解耦:不用改 Tailwind 配置就能换色。
  2. 逻辑与样式解耦:JS 只负责改类名,CSS 负责变颜色,各司其职。

对于需要做多租户 SaaS 平台、或者对 UI 细节要求较高的 C 端产品,这套方案是目前的最优解之一。它既保留了 Tailwind 原子化开发的爽快感,又弥补了它在动态主题上的短板。

技术的本质是解决问题,选择合适的工具,才能让自己从重复劳动中解放出来。别再手动去改每一行颜色代码了,试试这套方案,把时间花在更有价值的业务逻辑上吧!

拓展阅读:


我是海潮,专注前端/全栈技术分享,深耕前端工程化领域 5 年,关注我,一起成长、少踩坑 ✨。

❌