阅读视图

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

性能优化之实战指南:让你的 Vue 应⽤跑得飞起

Vue 性能优化实战指南:让你的 Vue 应⽤跑得飞起

1. 列表项 key 属性:被你误解最深的 Vue 知识点

兄弟们,key 这个属性估计是 Vue 里被误解最多的东⻄了。很多同学以为随便给个 index 就完事了,结果性能炸裂还不知道为啥。

1.1 key 的作⽤到底是什么?

Vue 的虚拟 DOM diff 算法通过 key 来判断节点是否可以复用。没有 key 或者 key 重复,Vue 会强制复用 DOM,导致性能下降甚至状态混乱。

<!-- ❌ 错误:用 index 做 key -->
<template>
  <div>
    <div v-for="(item, index) in list" :key="index">
      {{ item.name }}
      <input v-model="item.value" />
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: [
        { name: '张三', value: '' },
        { name: '李四', value: '' },
        { name: '王五', value: '' }
      ]
    }
  }
}
</script>

问题: 当你删除第一个元素时,Vue 会"以为"后面的元素只是变了位置,于是把第二个元素的 DOM 复用给第一个,第三个复用给第二个...结果输入框里的值全乱了!

<!-- ✅ 正确:用唯一标识做 key -->
<template>
  <div>
    <div v-for="item in list" :key="item.id">
      {{ item.name }}
      <input v-model="item.value" />
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: [
        { id: 1, name: '张三', value: '' },
        { id: 2, name: '李四', value: '' },
        { id: 3, name: '王五', value: '' }
      ]
    }
  }
}
</script>

1.2 什么时候必须用 key?

<!-- 1. v-for 必须用 -->
<template>
  <div v-for="item in list" :key="item.id">{{ item.name }}</div>
</template>

<!-- 2. 条件渲染多个元素时建议用 -->
<template>
  <div v-if="showForm" :key="1">表单A</div>
  <div v-else :key="2">表单B</div>
</template>

1.3 key 选择指南

// ✅ 好的 key
:key="item.id"              // 唯一标识,最佳选择
:key="item.uuid"            // 如果有 UUID 更好
:key="`${item.type}_${item.id}`"  // 组合唯一标识

// ❌ 不好的 key
:key="index"                // 列表会出问题
:key="Math.random()"        // 每次都变,失去复用意义
:key="item.name"            // 可能重复

1.4 小贴士

  • 列表只有渲染,不会增删改查,用 index 也问题不大
  • 列表会动态变化,必须用唯一标识
  • 表格、聊天、购物车这种场景,key 选错了会出大问题
  • 调试时可以用 Vue DevTools 看 diff 结果,key 对不对一目了然

2. 架构级优化:从源头解决性能问题

前面讲的都是"术",现在讲"道"。架构级优化能让你的应用从根本上快起来。

2.1 代码分割:把大蛋糕切成小块

现代打包工具(Webpack、Vite)都支持代码分割,把代码拆成多个小块,按需加载。

2.1.1 路由级别代码分割

这是最常见的优化方式,每个路由一个 chunk。

// ❌ 一次性加载所有路由组件
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import Profile from '@/views/Profile.vue'
import Settings from '@/views/Settings.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  { path: '/profile', component: Profile },
  { path: '/settings', component: Settings }
]
// ✅ 路由懒加载
const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/profile',
    component: () => import('@/views/Profile.vue')
  },
  {
    path: '/settings',
    component: () => import('@/views/Settings.vue')
  }
]

打包效果:

  • 首屏只加载 home.js
  • 用户访问 /about 时才加载 about.js
  • 首屏体积从 2MB 降到 300KB,首屏时间缩短 60%+
2.1.2 组件级别代码分割

某些大型组件(如富文本编辑器、图表库)可以按需加载。

<template>
  <div>
    <button @click="showEditor = true">打开编辑器</button>

    <!-- 条件加载大型组件 -->
    <Editor v-if="showEditor" @close="showEditor = false" />
  </div>
</template>

<script>
export default {
  components: {
    Editor: () => import('@/components/Editor.vue')
  },
  data() {
    return {
      showEditor: false
    }
  }
}
</script>
2.1.3 动态导入

更灵活的按需加载方式。

// 点击按钮时才加载某个模块
async function loadFeature() {
  if (needsAdvancedFeatures) {
    const { default: AdvancedModule } = await import('@/features/advanced')
    AdvancedModule.init()
  }
}

// 根据条件加载不同的实现
async function getChartLibrary() {
  if (useECharts) {
    const echarts = await import('echarts')
    return echarts
  } else {
    const chartjs = await import('chart.js')
    return chartjs
  }
}
2.1.4 第三方库分割

某些第三方库可以单独打包。

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          priority: 10
        },
        elementUI: {
          test: /[\\/]node_modules[\\/]element-ui[\\/]/,
          name: 'elementUI',
          priority: 20
        },
        commons: {
          name: 'commons',
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true
        }
      }
    }
  }
}

2.2 路由级别优化

除了代码分割,路由本身也有优化空间。

2.2.1 路由懒加载 + 预加载
// 路由配置
const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    component: () => import(/* webpackPrefetch: true */ '@/views/About.vue')
  },
  {
    path: '/profile',
    component: () => import(/* webpackPreload: true */ '@/views/Profile.vue')
  }
]

区别:

  • webpackPrefetch:空闲时预加载,适合"可能访问"的路由
  • webpackPreload:立即预加载,适合"即将访问"的路由
2.2.2 路由组件缓存

使用 keep-alive 缓存路由组件,避免重复渲染。

<template>
  <div id="app">
    <!-- 缓存所有路由组件 -->
    <keep-alive>
      <router-view />
    </keep-alive>

    <!-- 或者只缓存特定路由 -->
    <keep-alive :include="['Home', 'Profile']">
      <router-view />
    </keep-alive>

    <!-- 排除某些路由 -->
    <keep-alive :exclude="['Login', 'Register']">
      <router-view />
    </keep-alive>
  </div>
</template>
// 组件内配合使用
export default {
  name: 'Home',  // 必须有 name 才能被 include/exclude 匹配
  data() {
    return {
      list: []
    }
  },
  activated() {
    // 从缓存恢复时调用
    console.log('组件被激活')
    this.fetchData()
  },
  deactivated() {
    // 组件被缓存时调用
    console.log('组件被停用')
  }
}
2.2.3 路由守卫优化
// ❌ 重复获取数据
router.beforeEach(async (to, from, next) => {
  // 每次导航都获取用户信息
  const user = await fetchUser()
  next()
})

// ✅ 缓存用户信息
let cachedUser = null
let lastFetchTime = 0
const CACHE_DURATION = 5 * 60 * 1000 // 5分钟

router.beforeEach(async (to, from, next) => {
  const now = Date.now()

  if (!cachedUser || now - lastFetchTime > CACHE_DURATION) {
    cachedUser = await fetchUser()
    lastFetchTime = now
  }

  next()
})

2.3 状态管理优化

2.3.1 Vuex 模块化
// ❌ 所有的 state 都在一个大对象里
const store = new Vuex.Store({
  state: {
    user: {},
    products: [],
    cart: [],
    orders: [],
    settings: {},
    // ... 越来越多
  }
})
// ✅ 模块化管理
const user = {
  namespaced: true,
  state: () => ({ currentUser: null }),
  mutations: { SET_USER(state, user) { state.currentUser = user } },
  actions: { async fetchUser({ commit }) { /* ... */ } }
}

const products = {
  namespaced: true,
  state: () => ({ list: [] }),
  mutations: { SET_PRODUCTS(state, list) { state.list = list } }
}

const store = new Vuex.Store({
  modules: { user, products, cart, orders }
})
2.3.2 按需注册模块
// 动态注册模块
router.beforeEach(async (to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAdmin)) {
    await store.registerModule('admin', adminModule)
  }
  next()
})

// 离开时卸载模块
router.afterEach((to, from) => {
  if (!to.matched.some(record => record.meta.requiresAdmin)) {
    if (store.hasModule('admin')) {
      store.unregisterModule('admin')
    }
  }
})

2.4 组件设计原则

2.4.1 组件粒度
<!-- ❌ 组件太大,职责不清 -->
<template>
  <div class="user-list">
    <div v-for="user in users" :key="user.id">
      <img :src="user.avatar">
      <div>{{ user.name }}</div>
      <div>{{ user.email }}</div>
      <button @click="follow(user)">关注</button>
      <button @click="block(user)">拉黑</button>
      <button @click="sendMessage(user)">发消息</button>
    </div>
  </div>
</template>
<!-- ✅ 拆分成多个小组件 -->
<template>
  <UserList :users="users">
    <template #default="{ user }">
      <UserCard :user="user">
        <template #actions>
          <UserActions :user="user" />
        </template>
      </UserCard>
    </template>
  </UserList>
</template>

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <Avatar :src="user.avatar" />
    <UserInfo :name="user.name" :email="user.email" />
    <slot name="actions" />
  </div>
</template>

<!-- UserActions.vue -->
<template>
  <div class="actions">
    <button @click="$emit('follow')">关注</button>
    <button @click="$emit('block')">拉黑</button>
    <button @click="$emit('message')">发消息</button>
  </div>
</template>
2.4.2 避免不必要的渲染
<template>
  <div>
    <!-- ❌ 每次父组件更新都会重新渲染 -->
    <ExpensiveComponent :data="heavyData" />

    <!-- ✅ 使用计算属性缓存 -->
    <ExpensiveComponent :data="processedData" />

    <!-- ✅ 使用 v-once 只渲染一次 -->
    <div v-once>{{ staticContent }}</div>

    <!-- ✅ 使用 shouldComponentUpdate(Vue 2)或 computed(Vue 3) -->
    <ExpensiveComponent v-if="shouldRender" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      heavyData: largeData,
      someOtherData: []
    }
  },
  computed: {
    processedData() {
      return this.heavyData.map(item => ({
        ...item,
        formatted: this.format(item)
      }))
    },
    shouldRender() {
      return this.heavyData.length > 0
    }
  },
  methods: {
    format(item) {
      // 昂贵的计算
      return item.value.toFixed(2)
    }
  }
}
</script>

3. 服务端渲染 SSR:SEO 和首屏性能的双刃剑

SSR(Server-Side Rendering)能在服务器端渲染 Vue 组件,直接返回 HTML,对 SEO 和首屏加载都有巨大提升。

3.1 SSR vs CSR

对比项 CSR(客户端渲染) SSR(服务端渲染)
SEO ❌ 搜索引擎爬虫难以抓取 ✅ 直接返回 HTML,SEO 友好
首屏时间 ⚠️ 需要加载 JS 后才能渲染 ✅ 首屏直接显示 HTML
服务器压力 ✅ 低,只提供静态资源 ⚠️ 高,需要渲染页面
开发复杂度 ✅ 简单 ⚠️ 复杂,需要考虑同构
交互响应 ✅ 客户端即时响应 ⚠️ 需要注水(hydration)

3.2 Nuxt.js 快速上手

Nuxt.js 是 Vue 的 SSR 框架,开箱即用。

# 创建 Nuxt 项目
npx create-nuxt-app my-app

cd my-app
npm run dev
3.2.1 页面自动路由
pages/
├── index.vue          # / 路由
├── about.vue          # /about 路由
└── users/
    ├── index.vue      # /users 路由
    └── _id.vue       # /users/:id 路由
3.2.2 数据获取
<!-- pages/index.vue -->
<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else>
      <h1>{{ post.title }}</h1>
      <p>{{ post.content }}</p>
    </div>
  </div>
</template>

<script>
export default {
  // 服务器端渲染前获取数据
  async asyncData({ params, $axios }) {
    const post = await $axios.$get(`/api/posts/${params.id}`)
    return { post }
  },

  // 或者在客户端获取数据
  async fetch({ store, $axios }) {
    const posts = await $axios.$get('/api/posts')
    store.commit('posts/SET_POSTS', posts)
  },

  data() {
    return {
      loading: false,
      post: {}
    }
  }
}
</script>
3.2.3 SEO 优化
<template>
  <div>
    <h1>{{ post.title }}</h1>
  </div>
</template>

<script>
export default {
  async asyncData({ $axios, params }) {
    const post = await $axios.$get(`/api/posts/${params.id}`)
    return { post }
  },

  head() {
    return {
      title: this.post.title,
      meta: [
        { hid: 'description', name: 'description', content: this.post.excerpt },
        { hid: 'og:title', property: 'og:title', content: this.post.title },
        { hid: 'og:image', property: 'og:image', content: this.post.image }
      ]
    }
  }
}
</script>

3.3 Vue SSR 手动配置

如果你不想用 Nuxt,可以手动配置 Vue SSR。

3.3.1 服务端入口
// server.js
const express = require('express')
const { createSSRApp } = require('vue')
const { renderToString } = require('@vue/server-renderer')

const server = express()

server.get('*', async (req, res) => {
  const app = createSSRApp({
    data: () => ({ url: req.url }),
    template: `<div>访问的 URL 是:{{ url }}</div>`
  })

  const appContent = await renderToString(app)

  const html = `
    <!DOCTYPE html>
    <html>
      <head><title>Vue SSR</title></head>
      <body>
        <div id="app">${appContent}</div>
      </body>
    </html>
  `

  res.end(html)
})

server.listen(3000)
3.3.2 客户端入口
// client.js
import { createSSRApp } from 'vue'
import { createApp } from 'vue'

const app = createSSRApp({
  data: () => ({ url: window.location.pathname }),
  template: `<div>访问的 URL 是:{{ url }}</div>`
})

app.mount('#app')

3.4 静态站点生成(SSG)

如果你的内容是静态的,可以用静态站点生成,比 SSR 更简单。

// nuxt.config.js
export default {
  // 启用静态生成
  generate: {
    routes: ['/post/1', '/post/2', '/post/3']
  }
}

// 或者动态生成
export default {
  generate: {
    async routes() {
      const posts = await fetchPosts()
      return posts.map(post => `/post/${post.id}`)
    }
  }
}

3.5 SSR 性能优化

3.5.1 缓存渲染结果
const LRU = require('lru-cache')
const ssrCache = new LRU({
  max: 1000,
  maxAge: 1000 * 60 * 15 // 15分钟
})

async function renderPage(url) {
  // 检查缓存
  const cached = ssrCache.get(url)
  if (cached) {
    return cached
  }

  // 渲染页面
  const html = await renderToString(app)

  // 缓存结果
  ssrCache.set(url, html)

  return html
}
3.5.2 流式渲染
const { renderToStream } = require('@vue/server-renderer')

server.get('*', async (req, res) => {
  const stream = renderToStream(app)

  res.write('<!DOCTYPE html><html><head>...')

  // 流式输出
  stream.pipe(res, { end: false })

  stream.on('end', () => {
    res.end('</html>')
  })
})
3.5.3 避免在服务端执行客户端代码
<template>
  <div>
    <!-- ❌ 服务端没有 window -->
    <div>{{ window.innerWidth }}</div>

    <!-- ✅ 使用 process.client 判断 -->
    <div v-if="process.client">{{ window.innerWidth }}</div>
    <div v-else>服务端渲染</div>

    <!-- ✅ 或者在 mounted 中获取 -->
    <div>{{ screenWidth }}</div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      screenWidth: 0
    }
  },
  mounted() {
    // mounted 只在客户端执行
    this.screenWidth = window.innerWidth
  }
}
</script>

3.6 SSR 踩过的坑

3.6.1 状态同步问题
// ❌ 服务端和客户端状态不一致
export default {
  async asyncData() {
    // 服务端获取数据
    const data = await fetchData()
    return { data }
  },
  mounted() {
    // 客户端又获取一次,可能导致冲突
    this.fetchData()
  }
}

// ✅ 统一状态管理
export default {
  async asyncData({ store }) {
    await store.dispatch('fetchData')
    return { data: store.state.data }
  },
  computed: {
    data() {
      return this.$store.state.data
    }
  }
}
3.6.2 Cookie 处理
// ❌ 服务端访问不到 document.cookie
async function fetchUser() {
  const cookie = document.cookie // 报错
}

// ✅ 通过上下文传递 cookie
async function fetchUser(context) {
  const cookie = context.req.headers.cookie
  // 使用 cookie 发送请求
}
3.6.3 异步组件处理
<template>
  <div>
    <!-- SSR 时异步组件不会渲染 -->
    <AsyncComponent />
  </div>
</template>

<script>
export default {
  components: {
    // ✅ 使用 SSR 友好的异步组件
    AsyncComponent: defineAsyncComponent({
      loader: () => import('./AsyncComponent.vue'),
      loadingComponent: LoadingComponent,
      errorComponent: ErrorComponent,
      delay: 200,
      timeout: 3000
    })
  }
}
</script>

3.7 是否需要 SSR?

需要 SSR 的情况:

  • 内容需要 SEO(博客、新闻、电商)
  • 首屏加载时间要求极高
  • 社交媒体分享需要预览卡片

不需要 SSR 的情况:

  • 内部管理系统
  • 社交媒体应用(如 Twitter)
  • 游戏或富交互应用

总结

Vue 性能优化是一个系统工程,需要从多个层面入手:

  1. key 属性要选对,用唯一标识,别用 index
  2. 代码分割是标配,路由懒加载、组件按需加载
  3. 架构设计要合理,模块化、职责单一、避免过度渲染
  4. SSR 看场景使用,SEO 和首屏是刚需就上,否则别自找麻烦
  5. 监控要跟上,用 Vue DevTools、Lighthouse、Web Vitals 持续优化

最后,如果你觉得这篇⽂章对你有帮助,点个赞呗!如果觉得有问题,评论区喷我,我抗揍。

JitWord Office预览引擎:如何用Vue3+Node.js打造丝滑的PDF/Excel/PPT嵌入方案

ps:老规矩,先上地址,github地址:jitword sdk

最近很多用户反馈了需要支持Office预览功能,于是我们加班加点,在Jitword 协同AI文档上支持了一键预览Office文件的功能:

image.png

目前 jitword 已全面支持如下文件类型的解析预览:

  • Markdown文件
  • Docx文件
  • PDF文件
  • Excel文件
  • PPT文件
  • JSON文件
  • HTML文件

接下来我会详细和大家分享一下功能和技术实现,给大家提供一个技术参考。

往期精彩:

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

项目背景:为什么我们要造这个轮子?

image.png

作为一个协同文档项目,JitWord一直在探索轻量级的办公解决方案。最近社区反复提出"Office预览"需求,但是我们面临一个选择:

方案 优点 缺点
OnlyOffice/Collabora 功能完整,支持编辑 部署重(2GB+镜像),加载慢(3-5s),样式难定制
微软/谷歌预览API 接入简单 数据出境,自定义域名受限,免费额度有限
自研预览引擎 轻量、可控、体验统一 开发成本高,需持续维护

我们的决策:自研轻量级预览引擎,专注"预览+文档编排"场景。

下面分享一下我们的技术方案。

架构设计:三层解耦模型

┌─────────────────────────────────────────┐
│           协同层 (Collaboration)         │
│    批注Canvas + 用户体系 + 实时同步        │
├─────────────────────────────────────────┤
│           嵌入层 (Embedding)             │
│    Vue3组件 + 响应式布局 + 主题同步        │
├─────────────────────────────────────────┤
│           解析层 (Parsing)               │
│    PDF.js / SheetJS / PPTX解析器         │
└─────────────────────────────────────────┘

核心技术实现

PDF预览:PDF.js深度优化

问题:原版PDF.js加载大文件时卡顿,内存占用高。

优化方案

// pdf-loader.js
import * as pdfjsLib from 'pdfjs-dist';

class PDFPreviewEngine {
  constructor(container, options = {}) {
    this.container = container;
    this.pdfDoc = null;
    this.scale = options.scale || 1.5;
    this.chunkSize = options.chunkSize || 256 * 1024; // 256KB分片
  }

  async load(url) {
    // 分片加载:只加载可视区域附近的页面
    const loadingTask = pdfjsLib.getDocument({
      url,
      rangeChunkSize: this.chunkSize,
      disableAutoFetch: true, // 关键:禁用自动全量加载
    });

    this.pdfDoc = await loadingTask.promise;
    return this.renderVisiblePages();
  }

  async renderVisiblePages() {
    const viewportHeight = this.container.clientHeight;
    const pages = [];
    
    // 只渲染可视区域 + 上下各缓冲1页
    for (let i = 1; i <= this.pdfDoc.numPages; i++) {
      const page = await this.pdfDoc.getPage(i);
      const viewport = page.getViewport({ scale: this.scale });
      
      // 虚拟列表逻辑:计算页面是否在视口内
      if (this.isPageInViewport(i, viewport.height)) {
        pages.push(this.renderPage(page, viewport));
      }
    }
    
    return Promise.all(pages);
  }

  renderPage(page, viewport) {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.height = viewport.height;
    canvas.width = viewport.width;

    return page.render({
      canvasContext: context,
      viewport: viewport
    }).promise.then(() => canvas);
  }
}

关键优化点

  1. disableAutoFetch: true:禁用PDF.js的自动全量加载
  2. rangeChunkSize:设置分片大小,配合HTTP Range请求
  3. 虚拟列表渲染:只渲染可视区域,100MB+PDF也能流畅滚动

Excel预览:SheetJS + 自研渲染器

问题:SheetJS解析后如何高效渲染?如何保留公式计算?

方案架构

Excel文件 (.xlsx)
    ↓
SheetJS解析 → Workbook对象
    ↓
数据转换层 (Data Transformer)
    ↓
Vue3表格组件 (Virtual Table)

核心代码

// excel-parser.js
import XLSX from 'xlsx';

class ExcelPreviewEngine {
  parse(buffer) {
    const workbook = XLSX.read(buffer, { 
      type: 'array',
      cellFormula: true,      // 保留公式
      cellNF: true,           // 保留数字格式
      cellStyles: true        // 保留样式
    });
    
    return this.transformWorkbook(workbook);
  }

  transformWorkbook(workbook) {
    return workbook.SheetNames.map(name => {
      const worksheet = workbook.Sheets[name];
      const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
      
      return {
        name,
        data,
        merges: this.parseMerges(worksheet['!merges']), // 合并单元格
        formulas: this.extractFormulas(worksheet),      // 公式映射
        colWidths: worksheet['!cols']?.map(c => c.wpx) || []
      };
    });
  }

  extractFormulas(worksheet) {
    const formulas = {};
    for (const [cell, value] of Object.entries(worksheet)) {
      if (value && value.f) { // value.f 是公式字符串
        formulas[cell] = value.f;
      }
    }
    return formulas;
  }
}

前端渲染组件(Vue3 + 虚拟滚动):

<!-- ExcelPreview.vue -->
<template>
  <div class="excel-preview" ref="container">
    <div class="sheet-tabs">
      <button 
        v-for="sheet in sheets" 
        :key="sheet.name"
        :class="{ active: currentSheet === sheet.name }"
        @click="switchSheet(sheet.name)"
      >
        {{ sheet.name }}
      </button>
    </div>
    
    <VirtualTable
      :data="currentData"
      :formulas="currentFormulas"
      :col-widths="currentColWidths"
      :row-height="28"
      @cell-click="handleCellClick"
    />
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import VirtualTable from './VirtualTable.vue';
import { evaluateFormula } from './formula-engine'; // 自研公式计算引擎

const props = defineProps({
  workbook: Object
});

const currentSheet = ref(props.workbook[0]?.name);
const currentData = computed(() => {
  const sheet = props.workbook.find(s => s.name === currentSheet.value);
  return sheet?.data || [];
});

// 公式实时计算
const computedValues = computed(() => {
  const result = {};
  const formulas = props.workbook.find(s => s.name === currentSheet.value)?.formulas || {};
  
  for (const [cell, formula] of Object.entries(formulas)) {
    try {
      result[cell] = evaluateFormula(formula, currentData.value);
    } catch (e) {
      result[cell] = '#ERROR';
    }
  }
  
  return result;
});
</script>

公式计算引擎(简化版):

// formula-engine.js
export function evaluateFormula(formula, data) {
  // 移除开头的=
  const expr = formula.replace(/^=/, '');
  
  // 单元格引用解析:A1 → data[0][0]
  const cellRef = expr.match(/([A-Z]+)(\d+)/g);
  if (!cellRef) return evaluateExpression(expr);
  
  let evalExpr = expr;
  for (const ref of cellRef) {
    const { col, row } = parseCellRef(ref);
    const value = data[row - 1]?.[col] || 0;
    evalExpr = evalExpr.replace(ref, value);
  }
  
  return evaluateExpression(evalExpr);
}

// 支持常用函数
const FUNCTIONS = {
  SUM: (args) => args.reduce((a, b) => Number(a) + Number(b), 0),
  AVERAGE: (args) => FUNCTIONS.SUM(args) / args.length,
  MAX: (args) => Math.max(...args),
  MIN: (args) => Math.min(...args),
  // ... 200+函数实现
};

PPT预览:XML解析 + Vue3幻灯片组件

技术选型:不渲染为图片,而是解析为可交互的组件树

// pptx-parser.js
import JSZip from 'jszip';

class PPTXParser {
  async parse(arrayBuffer) {
    const zip = await JSZip.loadAsync(arrayBuffer);
    
    // 解析核心XML
    const [contentTypes, presentation, slideMasters] = await Promise.all([
      zip.file('[Content_Types].xml').async('string'),
      zip.file('ppt/presentation.xml').async('string'),
      zip.file('ppt/slideMasters/slideMaster1.xml').async('string')
    ]);

    const parser = new DOMParser();
    const presDoc = parser.parseFromString(presentation, 'application/xml');
    
    // 提取幻灯片列表
    const slideIds = Array.from(presDoc.querySelectorAll('sldId')).map(s => s.getAttribute('id'));
    
    // 并行解析所有幻灯片
    const slides = await Promise.all(
      slideIds.map((id, index) => this.parseSlide(zip, index + 1))
    );
    
    return { slides, slideCount: slides.length };
  }

  async parseSlide(zip, slideNum) {
    const slideXml = await zip.file(`ppt/slides/slide${slideNum}.xml`).async('string');
    const doc = new DOMParser().parseFromString(slideXml, 'application/xml');
    
    // 提取形状、文本、图片
    const shapes = Array.from(doc.querySelectorAll('sp')).map(sp => ({
      type: this.getShapeType(sp),
      x: this.emuToPx(sp.querySelector('off')?.getAttribute('x')),
      y: this.emuToPx(sp.querySelector('off')?.getAttribute('y')),
      width: this.emuToPx(sp.querySelector('ext')?.getAttribute('cx')),
      height: this.emuToPx(sp.querySelector('ext')?.getAttribute('cy')),
      text: this.extractText(sp),
      style: this.extractStyle(sp)
    }));

    // 提取动画时序
    const animations = this.parseAnimations(doc);
    
    return { shapes, animations, transition: this.parseTransition(doc) };
  }

  emuToPx(emu) {
    return Math.round(parseInt(emu) / 9525); // 1px = 9525 EMU
  }
}

Vue3幻灯片渲染组件

<!-- SlideViewer.vue -->
<template>
  <div class="slide-viewer" :style="slideStyle">
    <TransitionGroup name="slide">
      <div 
        v-for="(shape, index) in currentSlide.shapes" 
        :key="index"
        class="shape"
        :style="shapeStyle(shape)"
        v-show="isShapeVisible(index)"
      >
        <TextShape v-if="shape.type === 'text'" :content="shape.text" :style="shape.style" />
        <ImageShape v-else-if="shape.type === 'image'" :src="shape.src" />
        <TableShape v-else-if="shape.type === 'table'" :data="shape.data" />
      </div>
    </TransitionGroup>
    
    <!-- 动画控制 -->
    <div class="animation-controls">
      <button @click="prevAnimation" :disabled="currentStep === 0">上一步</button>
      <span>{{ currentStep + 1 }} / {{ totalSteps }}</span>
      <button @click="nextAnimation" :disabled="currentStep >= totalSteps - 1">下一步</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import TextShape from './shapes/TextShape.vue';
import ImageShape from './shapes/ImageShape.vue';
import TableShape from './shapes/TableShape.vue';

const props = defineProps({
  slide: Object
});

const currentStep = ref(0);

// 根据动画时序计算可见形状
const isShapeVisible = (shapeIndex) => {
  if (!props.slide.animations) return true;
  const triggerStep = props.slide.animations[shapeIndex]?.triggerStep || 0;
  return currentStep.value >= triggerStep;
};

const nextAnimation = () => {
  if (currentStep.value < totalSteps.value - 1) {
    currentStep.value++;
  }
};

const totalSteps = computed(() => {
  if (!props.slide.animations) return 1;
  return Math.max(...props.slide.animations.map(a => a.triggerStep)) + 1;
});
</script>

嵌入层:与文档流的完美融合

核心挑战:如何让Office预览组件像<img>标签一样自然嵌入文档?

解决方案contenteditable + Shadow DOM隔离

// embed-manager.js
class OfficeEmbedManager {
  constructor(editor) {
    this.editor = editor; // 富文本编辑器实例
    this.embeds = new Map();
  }

  insertEmbed(type, fileUrl, position) {
    // 生成唯一ID
    const embedId = `embed-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
    
    // 在编辑器中插入占位符
    const placeholder = document.createElement('div');
    placeholder.className = 'office-embed-placeholder';
    placeholder.dataset.embedId = embedId;
    placeholder.dataset.type = type;
    placeholder.contentEditable = false; // 关键:防止编辑器干扰
    
    // 使用Shadow DOM隔离样式
    const shadow = placeholder.attachShadow({ mode: 'open' });
    
    // 根据类型渲染对应组件
    const app = createApp(getPreviewComponent(type), {
      src: fileUrl,
      onReady: (api) => this.embeds.set(embedId, api)
    });
    
    app.mount(shadow);
    
    // 插入到编辑器指定位置
    this.editor.insertNodeAt(position, placeholder);
    
    return embedId;
  }

  // 协同批注:将坐标映射到Office内容
  addAnnotation(embedId, x, y, content) {
    const embed = this.embeds.get(embedId);
    if (!embed) return;
    
    // 将屏幕坐标转换为文档相对坐标
    const rect = embed.getBoundingClientRect();
    const relativeX = (x - rect.left) / rect.width;
    const relativeY = (y - rect.top) / rect.height;
    
    // 根据类型做语义化定位
    const location = embed.resolveLocation(relativeX, relativeY);
    
    return {
      embedId,
      location, // 如:{ type: 'cell', ref: 'B5' } 或 { type: 'page', num: 3 }
      content,
      timestamp: Date.now()
    };
  }
}

性能数据与优化技巧

加载性能对比

文件类型 文件大小 OnlyOffice 我们的方案 提升
PDF 50MB 4.2s 0.8s 5.2x
Excel 10MB (10万行) 3.8s 1.1s 3.5x
PPT 20MB (50页) 5.1s 1.5s 3.4x

关键优化技巧

1. Web Worker卸载解析

// excel-worker.js
self.onmessage = async (e) => {
  const { buffer, sheetName } = e.data;
  
  // 在Worker线程解析,不阻塞主线程
  const workbook = XLSX.read(buffer, { type: 'array' });
  const sheet = workbook.Sheets[sheetName];
  const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
  
  self.postMessage({ data, formulas: extractFormulas(sheet) });
};

2. 虚拟滚动(Excel大数据)

<VirtualList
  :items="flattenedData"
  :item-height="28"
  :buffer="5"
  v-slot="{ item, index }"
>
  <TableRow :cells="item" :row-index="index" />
</VirtualList>

3. 图片懒加载(PDF/PPT)

const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src; // 真正加载
      imageObserver.unobserve(img);
    }
  });
});

我们提供了一个开源SDK版本,大家可以轻松集成到项目里使用:

github:github.com/MrXujiang/j…

总结与展望

这套方案的核心价值在于轻量与可控

  • 轻量:前端包体积<500KB,无需重型服务器
  • 可控:源码支持二次开发,模块化解耦设计
  • 协同:与文档系统深度集成,而非孤立的预览窗口

未来规划

  1. WebAssembly加速:将公式计算用Rust重写,编译为WASM
  2. Rag知识库:支持文档即知识的Rag动态知识库功能
  3. AI增强:PDF自动摘要、Excel智能分析

如果大家有好的方案,欢迎随时交流反馈~

往期精彩:

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

揭秘JavaScript中那些“不冒泡”的DOM事件

在前端开发中,DOM事件流(捕获阶段→目标阶段→冒泡阶段)是核心基础之一。我们熟知的clickkeydownmouseover等事件,都会在触发后从目标元素向上冒泡至父元素、document甚至window,这也是事件委托的核心原理。

但并非所有事件都遵循这一规则——有些事件仅在触发的目标元素上生效,不会向上传播,也就是所谓的**“不冒泡的事件”**。本文将系统梳理这类事件,解析其特性与应用场景,帮你避开开发中的常见坑。

一、为什么有些事件不冒泡?

事件是否冒泡,本质是由其设计初衷决定的:

  • 冒泡事件的核心是“通用交互”(如点击、鼠标移动),允许父元素统一处理子元素的同类事件,便于实现事件委托;

  • 不冒泡事件多与“元素专属状态/资源加载”相关(如焦点、资源加载),这类事件的影响范围仅局限于目标元素本身,向上传播无实际意义。

例如focus(获取焦点)事件,仅对输入框、按钮等可交互元素有意义。若允许冒泡,父元素会无差别接收到所有子元素的焦点变化,反而增加不必要的性能开销和逻辑混乱。

二、常见的不冒泡事件及解析

1. focus / blur:焦点相关事件(核心不冒泡事件)

  • focus:元素获得焦点时触发(如点击输入框、按Tab键切换焦点);

  • blur:元素失去焦点时触发(如点击输入框外区域、切换到其他元素)。

这两个是最典型的不冒泡事件,也是前端开发中最常接触的“非冒泡事件”。

开发提醒:若想监听子元素的焦点变化,不能依赖focus/blur的冒泡,可使用其“冒泡版替代事件”——focusin(对应focus)和focusout(对应blur),这两个事件会正常冒泡,是focus/blur的官方替代方案。

示例代码:

// ❌ 错误:父元素无法捕获子元素的focus事件(不冒泡)
document.querySelector('.parent').addEventListener('focus', () => {
  console.log('父元素捕获focus'); // 不会执行
});

// ✅ 正确:用focusin监听(冒泡)
document.querySelector('.parent').addEventListener('focusin', () => {
  console.log('子元素获取焦点'); // 正常执行
});

2. load / unload:资源加载相关事件

  • load:资源加载完成时触发,常见于imgscriptaudiovideo等元素,仅在目标元素上触发,不会冒泡

  • unload:页面/资源即将被卸载时触发(如关闭标签页、导航到其他页面),仅绑定在window或目标元素上生效,无冒泡行为

注意window.onload是页面所有资源加载完成的事件,虽绑定在window上,但本质也不属于“冒泡事件”——它是全局事件,没有传播对象。

示例代码:

// img加载完成事件(仅在img本身触发)
const img = document.querySelector('img');
img.addEventListener('load', () => {
  console.log('图片加载完成'); // 正常执行
});

// ❌ 父元素无法捕获img的load事件(不冒泡)
document.body.addEventListener('load', () => {
  console.log('捕获图片load'); // 不会执行
});

3. stop:媒体播放相关事件

stop事件仅在audio/video等媒体元素上触发,当媒体播放被主动停止时生效。该事件仅作用于触发的媒体元素,无冒泡行为。例如点击视频的“停止”按钮,仅该视频元素触发stop,其父容器不会接收到该事件。

4. readystatechange:文档状态变化事件

该事件在document.readyState改变时触发(如从"loading""interactive"再到"complete"),仅绑定在documentXMLHttpRequest等对象上生效,不会向父元素传播。常用于监听页面DOM加载完成(作为DOMContentLoaded的补充方案)。

5. scroll:特殊的“条件冒泡”事件

scroll事件是特例:标准规范中scroll不冒泡,但部分浏览器(如Chrome)对其做了“冒泡兼容”——元素的scroll事件不会冒泡,而windowscroll事件是全局事件。

开发建议:若想监听滚动,建议直接绑定到滚动元素本身,而非依赖父元素捕获。

三、如何处理不冒泡事件?

面对不冒泡事件,核心解决思路有三个:

1. 直接绑定到目标元素

针对focus/blur/load等事件,直接在触发的元素上绑定监听函数,是最直接、最可靠的方式。

2. 使用冒泡版替代事件

focusin替代focusfocusout替代blur,利用冒泡特性实现父元素统一监听。

3. 利用事件捕获阶段处理

所有事件(包括不冒泡事件)都会经过**“捕获阶段”**,可在捕获阶段监听不冒泡事件:

// 捕获阶段监听focus事件(即使不冒泡,也能被父元素捕获)
document.querySelector('.parent').addEventListener('focus', () => {
  console.log('捕获阶段捕获focus'); // 正常执行
}, true); // 第三个参数为true,代表在捕获阶段触发

四、总结

不冒泡事件是DOM事件体系的重要组成部分,其设计符合**“事件影响范围最小化”**的原则。核心要点可总结如下:

掌握这些特性,能帮你在事件委托、资源监听、焦点管理等场景中避开陷阱,写出更健壮的前端代码。

前端包管理器演进史:为什么 npm 之后,Yarn 和 pnpm 成了新宠?

前端包管理器演进史:为什么 npm 之后,Yarn 和 pnpm 成了新宠?

作者:一位踩过无数 node_modules 坑的老前端

作为每天和 package.json 打交道的前端开发者,你是否曾好奇:
为什么有了 npm,社区还要造出 Yarn 和 pnpm?它们到底解决了什么问题?

今天,我们就从真实开发痛点出发,用通俗易懂的方式,讲清楚这三大前端包管理器的来龙去脉、核心差异,以及如何平滑迁移。无论你是刚入行的新手,还是久经沙场的老兵,相信都能有所收获。


一、npm:奠基者,但早期“伤痕累累”

npm 随 Node.js 诞生,是 JavaScript 生态的基石。但它在 2016 年之前存在两大致命问题:

❌ 1. 安装慢得像“蜗牛爬”

  • 依赖嵌套安装(node_modules 套娃),同一个包被多个依赖引用时会重复下载
  • 网络请求串行执行,大型项目安装动辄几分钟。

📌 举例:AB 都依赖 lodash@4.17.0,npm v2 会下载两份,浪费时间与磁盘。

❌ 2. “在我机器上能跑!”——依赖不一致

  • 早期没有可靠的锁定机制,不同人 npm install 可能得到不同版本的子依赖
  • 一个微小的 patch 版本更新,就可能让 CI 流水线全线崩溃。

这些问题在 Facebook、Google 等大厂内部尤为突出——于是,变革开始了。


二、Yarn:为速度与确定性而生(2016)

由 Facebook 主导推出的 Yarn,直击 npm 痛点:

✅ 核心改进:

  1. 并行下载 + 本地缓存 → 安装速度提升数倍;
  2. yarn.lock 锁定所有依赖版本 → 团队协作不再“玄学”;
  3. 更友好的 CLI(如 yarn addnpm install --save 简洁得多)。

💡 从此,“yarn install 一下,大家环境完全一致”成了团队标配。

虽然 npm 后来在 v5(2017)引入 package-lock.json 追赶,但 Yarn 已凭借稳定性和体验赢得大量用户。


三、pnpm:解决“胖”、“松”与“乱”的终极方案

即使 Yarn 改进了速度和一致性,一个新的问题浮出水面

node_modules 太臃肿了!

一个中型 React 项目,node_modules 轻松突破 1GB。10 个项目就占用 10GB —— CI 构建慢、Docker 镜像大、本地 SSD 喊疼。

但比“胖”更隐蔽的问题是:依赖太“松”了

🔍 关键澄清:pnpm 并非“扁平化”,而是刻意避免扁平化

很多人误以为 pnpm 用了“更好的扁平化”,其实恰恰相反:

  • npm(v3+)和 Yarn classic 采用 扁平化(hoisting)策略:把所有依赖尽量提升到顶层 node_modules,减少嵌套。
  • pnpm 则采用 非扁平化 + 符号链接结构,实现严格的依赖隔离。
⚠️ 扁平化的代价:幽灵依赖(Phantom Dependencies)

因为依赖被 hoist 到顶层,你的代码可能意外使用未声明的包

// package.json 中并未安装 lodash
import _ from 'lodash'; // 但在 npm/Yarn 下居然能跑!

为什么?因为某个间接依赖(比如 axios)带进了 lodash,被提升到了顶层。
→ 本地开发正常,但换台机器或升级依赖后,lodash 消失,直接报错!

这就是经典的 “在我机器上能跑” 陷阱。

✅ pnpm 的破局之道:严格隔离 + 全局共享

pnpm 的 node_modules 结构看似复杂,实则精妙:

node_modules/
├── .pnpm/
│   ├── react@18.2.0/node_modules/react → symlink
│   └── axios@1.6.0/node_modules/
│       ├── axios → symlink
│       └── lodash → symlink (指向全局 store)
└── react → symlink to .pnpm/react@18.2.0/...
  • 每个包只能看到自己 package.json 声明的依赖
  • 你的项目代码无法访问 axios 带进来的 lodash,除非你自己显式安装;
  • 所有物理文件只存一份(在 ~/.pnpm-store),靠硬链接 + 符号链接节省空间。

🔒 这不是限制,而是提前暴露隐患,让你的依赖关系清晰、可维护。

如今,Vue 3、Vite、Nuxt、Turborepo 等现代工具链官方均推荐 pnpm,足见其已成为新趋势。

💡 小贴士:pnpm 也提供 --shamefully-hoist 参数模拟扁平结构,但官方称其为“羞耻模式”,仅用于兼容极少数老旧工具,日常开发请勿使用


四、命令对照 & 迁移指南(npm 用户必看)

如果你只会 npm,别担心!迁移到 Yarn 或 pnpm 几乎零成本。

🔧 常用命令对照表

场景 npm Yarn pnpm
安装依赖 npm install yarn install pnpm install
添加包 npm install lodash yarn add lodash pnpm add lodash
开发依赖 npm install -D typescript yarn add -D typescript pnpm add -D typescript
运行脚本 npm run dev yarn dev pnpm dev

✅ 记住:installadduninstallremove,脚本可省略 run

🔁 如何迁移?

迁移到 pnpm(推荐新项目使用):
# 全局安装
npm install -g pnpm

# 进入项目,清理旧依赖(可选)
rm -rf node_modules package-lock.json

# 安装
pnpm install

⚠️ 注意:不要混用包管理器!一个项目只用一种。


五、如何选择?我的建议

场景 推荐
个人新项目 / 现代框架(Vite/Vue/Next) pnpm(快 + 省空间 + 安全)
团队已用 Yarn,且稳定运行 👍 继续用 Yarn
快速试玩 demo / 初学者 🆗 npm(无需额外安装)

📌 趋势很明确:pnpm 正在成为新一代默认选择


结语

包管理器看似只是“安装依赖的工具”,实则深刻影响着开发体验、构建效率、协作稳定性
从 npm 的奠基,到 Yarn 的提速,再到 pnpm 的精简与安全,每一次演进都源于开发者对“更好工作流”的追求。

下次当你敲下 pnpm add 时,不妨想想:这背后,是一群人为了让前端工程更高效、更可靠而付出的努力。

技术没有银弹,但有更优解。选对工具,事半功倍。


欢迎在评论区分享你的包管理器使用体验!你团队用的是哪个?遇到过哪些坑? 😊

写个添加注释的vscode插件

写注释真的好烦,每次都得/**……*/的形式才有jsDoc的效果,真的不想浪费时间了,于是写个vscode插件,添加一下jsDoc注释,提升点效率

1.vscode插件开发脚手架

使用Yeoman脚手架工具和generator-codeVS Code 扩展生成器来生成一个vscode插件开发项目

# 安装
npm install -g yo generator-code
# 执行脚手架,生产项目
yo code

image.png

可以看到有不同的类型

  • New Extension (TypeScript):基础ts插件开发项目
  • New Extension (JavaScript):基础js插件开发项目
  • New Color Theme:主题颜色配置插件开发项目
  • New Language Support:程序语义支持插件开发
  • New Code Snippets:代码片段插件开发
  • New Keymap:快捷键插件开发
  • New Extension Pack:插件包开发
  • New Language Pack (Localization):语言包插件
  • New Web Extension (TypeScript):网页插件开发,打开一个新页面,如图片预览
  • New Notebook Renderer (TypeScript):笔记本渲染插件开发,如代码和Markdown的格式化,交互小程序

小试牛刀的话只需要选择简单的New Extension (TypeScript)+esbuild

接下来只需要根据自己的情况填写插件项目名称,插件标识,插件描述等

2.运行调试第一个项目

选择最基础的Typescript模板,建议使用yarn管理包,后面打包成vscode插件包的时候pnpm会因为文件找不到而失败。

image.png

package.json配置命令,可以通过Ctrl+Shift+P唤起vscode命令栏搜索命令名称Hello World

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

src/extension.ts文件对应注册命令的操作

import * as vscode from "vscode";

//安装的时候
export function activate(context: vscode.ExtensionContext) {
 
  console.log('Congratulations, your extension "vscode-hello" is now active!');
    //注册命令
  const disposable = vscode.commands.registerCommand("vscode-hello.helloWorld", () => {
  //触发命令后执行
  
    //右下角弹出信息框
    vscode.window.showInformationMessage("Hello World from vscode-hello!");
    
    cconsole.log("hello", {name: "vscode", say: "hello world", age: 123});
  });   
  context.subscriptions.push(disposable);
}

//卸载的时候
export function deactivate() {}

首次运行Debug vscode插件会提示有问题,原因是因为launch.json配置了预运行的任务"preLaunchTask": "${defaultBuildTask}",即在tasks.json里面配置的预运行任务,其中有个npm: watch:esbuild的任务有问题。

  • 解决方案1:安装插件esbuild Problem Matchers,重新打开在debug
  • 解决方案2:把preLaunchTask去掉,手动执行命令npm run watch监听代码改变并编译成js,在运行debug

image.png

image.png

image.png

Debug时会弹出一个新的vscode窗口,通过Ctrl+Shift+P快捷键唤起vscode命令栏或者右下角设置里面打开命令栏,可以搜索到命令名称Hello World,点击执行就可以看到弹出信息框的内容。

image.pngimage.png

image.png

同时我们也能在vscode-hello项目的DEBUG CONSOLE调试控制台看到相关的输出打印

image.png

3.添加快捷键和右击菜单

在package.json添加contributes.keybindings配置快捷键

 "contributes": {
  "keybindings": [
      {
        "command": "vscode-hello.helloWorld",
        "key": "alt+H"
      }
    ]
 }

在package.json添加contributes.menus.editor/context配置编辑器中右击菜单

  "contributes": {
   "editor/context": [
        {
          "command": "vscode-hello.helloWorld",          
          "group": "1_modification@100",
          "alt": "vscode-hello.helloWorld",
          "key": "alt+H"
        }
      ]
  }
  

image.png

可以直接通过右击菜单的Hello World或者Alt+H快捷键触发helloWorld命令

如果想将菜单放在别的地方或者别的分组group,可以查看官方文档contributes.menus的配置 vscode.js.cn/api/referen…

image.png

当然可以添加一些快捷键和菜单生效的条件设置,比如当前打开的代码是ts/js/vue文件才出现或生效

 "keybindings": [
     {
        "command": "vscode-xcomment.add",
        "when": "editorTextFocus && resourceFilename =~ /.(js|ts|vue|jsx|tsx)$/",
        "key": "alt+/"
      },
 ],
   "menus": {
      "editor/context": [
      {
          "command": "vscode-xcomment.add",
          "when": "editorTextFocus && resourceFilename =~ /.(js|ts|vue|jsx|tsx)$/",
          "group": "1_modification@102",
          "alt": "vscode-xcomment.comment",
          "key": "alt+/"
        }
      ]
  }

具体的when子句上下文配置请看官方文档vscode.js.cn/api/referen…

4.给ts/js/vue文件添加注释

vscode获取当前打开文档的代码

   const editor = vscode.window.activeTextEditor;
  const doc = editor.document;
  const fileName = doc.fileName;//文件绝对路径
  const code = doc.getText();//代码内容

vscode检查是否有语法错误

执行命令前先进行语法错误判断,如果没有错误再执行

export function checkError(editor: vscode.TextEditor) {
  const diagnostics = vscode.languages.getDiagnostics(editor.document.uri);
  const hasSyntaxError = diagnostics.some(
    (d) =>
      d.severity === vscode.DiagnosticSeverity.Error &&
      (/syntax|unexpected|expected/i.test(d.message) ||
        (d.code && typeof d.code === "string" && d.code.toLowerCase().includes("syntax")))
  );
  if (hasSyntaxError) {
    return true;
  }

  return false;
}

vscode判断文件类型

限定执行命令的文件类型

function checkFile(editor: vscode.TextEditor) {
  const doc = editor.document;
  const fileName = doc.fileName;
  if (/\.(ts|js|vue|jsx|tsx)$/.test(fileName)) {
    return true;
  }
  return false;
}

注册命令并提示信息

    const disposable = vscode.commands.registerCommand(PREFIX + "comment", () => {
      //触发命令后执行
      //获取当前打开的编辑页面
      const editor = vscode.window.activeTextEditor;
      if (editor) {
        //检查是否有语法错误
        if (checkError(editor)) {
          //右下角弹出错误提示信息
          vscode.window.showErrorMessage("语法错误是不执行添加注释的命令!");
          return;
        }
        //检查文件类型是否正确
        if (!checkFile(editor)) {
          vscode.window.showErrorMessage("文件必须是js/ts/vue");
          return;
        }
        //添加注释
        const ctrl = new AddCommentController(editor);
        ctrl.doAction();
        ctrl.clearAll();
      }
    });

    context.subscriptions.push(disposable);

获取vscode当前光标所在位置

editor.selection.active

editor.selection.active.line//光标所在行
editor.selection.active.character//光标所在该行的第几个字符的位置

由于vscode按行来记录光标位置,所以为了方便找到具体字符位置,将代码按行进行分割,并进行索引开始结束位置和内容记录

getSourceLines(code: string) {
    const list: Array<[number, number, string]> = [];
    const lines = code.split("\n");
    if (lines.length) {
      let pre = 0;
      lines.forEach((line, idx) => {
        //+idx是因为换行号也算一个字符,需要加上
        list.push([pre + idx, pre + idx + line.length, line]);
        pre += line.length;
      });
    }
    return list;
  }

光标位置

  • 判断是否有光标,即focus聚焦在该代码编辑上了,有时候打开文档但是没有聚焦光标
const doc = this.editor.document;
    const fileName = doc.fileName; //文件绝对路径
    const code = doc.getText(); //代码内容
    this.sourceLines = this.getSourceLines(code);
    //是否有光标
    if (!this.editor.selection.active) {
      return;
    }
    const pos = this.editor.selection.active.line;
    const item = this.sourceLines[pos];
    //判断光标范围在文档代码有效范围内
    if (!item) {
      return;
    }

    //光标具体所在代码的字符索引位置
    const p = item[0] + this.editor.selection.active.character;
  • 如果是vue文件,要判断光标是否定位在vue的js/ts代码范围内,再获取其中的js/ts代码
if (fileName.endsWith(".vue")) {
      let startIndex = code.indexOf("<script");
      let endIndex = code.indexOf("</script>");
      if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
        return;
      }
      for (let i = startIndex; i < endIndex; i++) {
        const c = code[i];
        if (c === ">") {
          startIndex = i + 1;
          break;
        }
      }

      if (p < startIndex || p > endIndex) {
        vscode.window.showInformationMessage("vue文件光标位置不在js/ts范围内");
        return;
      }
      //vue文件内js/ts代码
      const script = code.substring(startIndex, endIndex);
   }

ts/js解析代码成AST

我们常用Typescript库校验和编译代码,同时它也能将代码解析成AST

import * as ts from "typescript";
const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);

node打印出来可能很难清楚其结构,可以到astexplorer查看具体的AST树

image.png

遍历代码根节点下所有节点,查看节点类型和内容

sourceFile.statements.forEach((node) => {
//查看节点类型
  console.log(ts.SyntaxKind[node.kind]);
  console.log(node.getText());
  console.log("------");
});

image.png

查找光标位置的节点

遍历AST根据节点范围判断光标是否在该节点,然后深度遍历该节点,直到找到最终的子节点,即光标所在具体位置,期间可以收集所有父子节点。

因为每类节点的结构都有所差异,推荐使用ts自带的ts.forEachChild遍历子节点的方法

 findNode(file: ts.SourceFile, pos: number) {
    let result: ts.Node[] = [];
    const visitNode = (node: ts.Node) => {
      try {
        ts.forEachChild(node, (child) => {
          if (pos >= child.getStart() && pos < child.getEnd()) {
            result.push(child);
            //深度遍历子节点
            visitNode(child);
            //跳出循环
            throw Error();
          }
        });
      } catch (error) {}
    };

    for (let i = 0; i < file.statements.length; i++) {
      const it = file.statements[i];
      if (pos >= it.getStart() && pos < it.getEnd()) {
        result.push(it);
        //深度遍历子节点
        visitNode(it);
        break;
      }
    }
    return result;
  }

获取当前光标所在的节点

 const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS); 
      const currentNodes = this.findNode(sourceFile, p); 

给不同节点添加注释

  • 判断该节点是否已有jsDoc注释,如果有则不添加注释。
  • 如果是单行注释或者非jsDoc的多行注释则转化为jsDoc注释
  • 如果没有注释则直接添加
checkDocs(node: ts.Node, sourceFile: ts.SourceFile, cb: (msg?: string[]) => void) {
    //@ts-ignore
    if (node.jsDoc && node.jsDoc.length > 0) {
      //有jsDoc就不添加注释
    } else {
      const comments: string[] = [];
      //头部注释
      const leadingComments = ts.getLeadingCommentRanges(sourceFile.text, node.pos);
      if (leadingComments) {
        leadingComments.forEach((comment) => {
          const s = sourceFile.text.substring(comment.pos, comment.end);
          comments.push(s.replace(/[\*\/]+/g, ""));
        });
      }
      //尾部注释
      const tailingComments = ts.getTrailingCommentRanges(sourceFile.text, node.end);
      if (tailingComments) {
        tailingComments.forEach((comment) => {
          const s = sourceFile.text.substring(comment.pos, comment.end);
          comments.push(s.replace(/[\*\/]+/g, ""));
        });
      }

      if (comments && comments.length > 0) {
        //将旧的注释添加到jsDoc内
        cb(comments);
      } else {
        //添加新的注释
        cb();
      }
    }
  }

如果已有注释则延用,如果没有注释则获取节点的名称作为注释内容

getNodeName(stmt: ts.Node, msg?: string[]) {
    const comments: string[] = [];
    if (msg) {
      msg.forEach((a) => {
        if (!/^\s+$/.test(a)) {
          comments.push(" * " + a);
        }
      });
    }
    if (comments.length === 0) {
      //获取父级节点名称
      let current: ts.Node = stmt;
      while (current) {
        //@ts-ignore
        let name = stmt.name;
        if (name) {
          const n = name.getText();
          if (!/^\s+$/.test(n)) {
            comments.push(" * " + n);
            break;
          }
        }
        current = current.parent;
      }
    }
    //如果父级没有名称则添加默认注释
    if (comments.length === 0) {
      comments.push(` * description`);
    }
    return comments;
  }

普通函数与方法

普通函数声明

//对应节点类型 FunctionDeclaration
function sum(a: number, b: number): number {
  return a + b;
}

方法定义

const obj = {
//对应节点类型 MethodDeclaration
  fun(msg: string) {
    console.log(msg);
  }
};
class Person {
//对应节点类型 ConstructorDeclaration
  constructor(aaa: string) {
    console.log(aaa);
  }
  //对应节点类型 MethodDeclaration
  dd(dd: string) {
    console.log(dd);
  }
}

Type和Interface函数定义

interface Shape {
//对应节点类型 MethodSignature
  draw(x: number, y: number): void;
}
type DrawType = {
//对应节点类型 MethodSignature
  draw(x: number, y: number): void;
};

符合此函数结构就添加注释

if (
  ts.isFunctionDeclaration(stmt) ||
  ts.isMethodDeclaration(stmt) ||
  ts.isMethodSignature(stmt) ||
  ts.isConstructorDeclaration(stmt)
) {
  this.checkDocs(stmt, sourceFile, this.addDocFun(stmt));
  return;
}

获取函数参数变量和返回类型的jsDoc注释

getFunComments(
    stmt:
      | ts.FunctionDeclaration
      | ts.MethodDeclaration
      | ts.ArrowFunction
      | ts.FunctionExpression
      | ts.ConstructorDeclaration
      | ts.MethodSignature
  ): string[] {
    const comments: string[] = [];

    //参数
    if (stmt.parameters) {
      stmt.parameters.forEach((param) => {
        comments.push(
          ` * @param ${param.type ? `{${param.type.getText().replace(/\s/g, "")}}` : "{any}"} ${param.name.getText().replace(/\s/g, "")} - description`
        );
      });
    }
    //返回值
    if (!ts.isConstructorDeclaration(stmt) && stmt.type && stmt.type.kind !== ts.SyntaxKind.VoidKeyword) {
      comments.push(` * @returns {${stmt.type.getText().replace(/\s/g, '') || 'any'}} description`);
    }
    return comments;
  }

返回给普通函数添加注释的回调

addDocFun(stmt: ts.FunctionDeclaration | ts.MethodDeclaration | ts.ConstructorDeclaration | ts.MethodSignature) {
    return (msg?: string[]) => {
      const comments = this.getNodeName(stmt, msg);

      comments.push(...this.getFunComments(stmt));
      this.addNodeComment(stmt, comments);
    };
  }

箭头函数与匿名函数

箭头函数

//对应的AST结构 VariableDeclaration.initializer:ArrowFunction
const myFun = (a: number, b: number): number => {
  return a + b;
};

给变量赋值匿名函数

//对应的AST结构 VariableDeclaration.initializer:FunctionExpression
const myFun = function (a: number, b: number): number {
  return a + b;
};

符合此结构就添加注释

if (
  ts.isVariableDeclaration(declaration) &&
  declaration.initializer &&
  (ts.isFunctionExpression(declaration.initializer) || ts.isArrowFunction(declaration.initializer))
) {
  this.checkDocs(declaration, sourceFile, this.addInitializerDoc(stmt, declaration.initializer));
  return;
}

返回给箭头函数和匿名函数添加注释的回调

addInitializerDoc(stmt: ts.Node, initializer: ts.FunctionExpression | ts.ArrowFunction) {
    return (msg?: string[]) => {
      const comments = this.getNodeName(initializer, msg);

      comments.push(...this.getFunComments(initializer));

      this.addNodeComment(stmt, comments);
    };
  }

同理对象或类属性的箭头函数与匿名函数赋值

const obj = {
//对应的AST结构 PropertyAssignment.initializer:ArrowFunction
  aa: (msg: string) => {
    console.log(msg);
  },
  //对应的AST结构 PropertyAssignment.initializer:FunctionExpression
  bb: function (ccc: string) {
    console.log(ccc);
  }
};
class Person {
//对应的AST结构 PropertyDeclaration.initializer:ArrowFunction
  aa = (msg: string) => {
    console.log(msg);
  };
  //对应的AST结构 PropertyDeclaration.initializer:FunctionExpression
  bb = function (ccc: string) {
    console.log(ccc);
  };
}
if (
  (ts.isPropertyAssignment(stmt) || ts.isPropertyDeclaration(stmt)) &&
  stmt.initializer &&
  (ts.isFunctionExpression(stmt.initializer) || ts.isArrowFunction(stmt.initializer))
) {
  this.checkDocs(stmt, sourceFile, this.addInitializerDoc(stmt, stmt.initializer));
  return;
}

给属性等添加注释

//对应节点类型 TypeAliasDeclaration
type AAA={
//对应节点类型 PropertySignature
aaa:string;
}

//对应节点类型 InterfaceDeclaration
interface BBB {
//对应节点类型 PropertySignature
bbb:string;
}
//对应节点类型 ClassDeclaration
class CCC{
//对应节点类型 PropertyDeclaration
ccc:string='hello';
}

符合该节点类型的添加注释

if (ts.isInterfaceDeclaration(stmt) || ts.isClassDeclaration(stmt)) {
  this.checkDocs(stmt, sourceFile, this.addDocProp(stmt));
  return;
} else if (ts.isTypeAliasDeclaration(stmt) && stmt.type && ts.isTypeLiteralNode(stmt.type)) {
  this.checkDocs(stmt, sourceFile, this.addDocProp(stmt));
  return;
} else if (ts.isPropertyDeclaration(stmt) || ts.isPropertySignature(stmt) || ts.isPropertyAssignment(stmt)) {
  this.checkDocs(stmt, sourceFile, this.addDocProp(stmt));
  return;
}

返回属性等节点注释回调

 addDocProp(prop: ts.Node) {
    return (msg?: string[]) => {
      const comments = this.getNodeName(prop, msg);

      this.addNodeComment(prop, comments);
    };
  }

变量等于函数运行结果

//对应的AST结构 VariableDeclaration.initializer=CallExpression
const state = reactive({
  aaa: 1
});
//对应的AST结构 VariableDeclaration.initializer=CallExpression
const valRef = ref("hello");

符合结构添加注释

if (ts.isVariableDeclaration(declaration) && declaration.initializer && ts.isCallExpression(declaration.initializer)) {
  this.checkDocs(stmt, sourceFile, this.addDocProp(stmt));
  return;
}

其他节点添加注释

addComment(sourceFile: ts.SourceFile, nodes: ts.Node[]) {
    if (nodes.length) {
      for (let i = nodes.length - 1; i >= 0; i--) {
        const stmt = nodes[i];
        // if  ...
      }

    //其他节点添加注释
      const stmt = nodes[nodes.length - 1];
      this.checkDocs(stmt, sourceFile, this.addDocProp(stmt));
    }
  }
  
this.addComment(sourceFile, currentNodes);

插入注释内容

使用ts库插入注释

给节点插入头部注释

addNodeComment(node: ts.Node, comments: string[]) {
    ts.addSyntheticLeadingComment(node, ts.SyntaxKind.MultiLineCommentTrivia, "*" + comments.join("\n"), true);
  }

获取新的代码打印内容

 printCode(sourceFile: ts.SourceFile) {
    const printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed});
    const printed = printer.printFile(sourceFile);
    return printed;
  }

替换ts/js来添加注释

if (fileName.endsWith(".vue")) {
  //...
  const code = this.printCode(sourceFile);
  //vue文件替换js/ts部分
  const newText = text.substring(0, startIndex) + "\n" + code + text.substring(endIndex);
  this.replaceAllText(newText);
} else {
  //...
  const newText = this.printCode(sourceFile);
  this.replaceAllText(newText);
}

替换全文

replaceAllText(printed: string) { 
    const editor = this.editor;
    editor.edit((editBuilder) => {
      const firstLine = editor.document.lineAt(0);
      const lastLine = editor.document.lineAt(editor.document.lineCount - 1);
      const textRange = new vscode.Range(firstLine.range.start, lastLine.range.end);
      editBuilder.replace(textRange, printed);
    });
  }

以上方法不推荐,因为printer会将空格之类的格式去掉,会导致prettier之类的格式化被去掉,git对比出大量代码已修改

vscode文本插入

记录需要插入的注释内容,因为只有一处添加注释,然后就会停止遍历光标所在位置的父子节点

addNodeComment(node: ts.Node, comments: string[]) {
    const c = "/**" + comments.join("\n") + "*/";
    this.comment = c;
  }

插入文本,获取光标所在行的文本,判断是否为空白字符,如果全是空白字符则直接插入,否则按照当行前面空格位置插入

//该行内容
    const linestr = this.sourceLines[pos][2];
    if (/^\s*$/.test(linestr)) {
      //如果全是空白字符则直接插入
      this.editor.edit((editBuilder) => {
        editBuilder.insert(this.editor.selection.active, this.comment);
      });
    } else {
      //非空白字符,按照当行前面空格位置插入
      const spaces: string[] = [];
      for (let i = 0; i < linestr.length; i++) {
        if (/\s/.test(linestr[i])) {
          spaces.push(linestr[i]);
        } else {
          break;
        }
      }
      this.editor.edit((editBuilder) => {
        editBuilder.insert(new vscode.Position(pos, 0), spaces.join("") + this.comment + "\n");
      });
    }

5.运行vscode插件

将光标定位在指定的函数或变量上,然后按快捷键ALt+/或右击菜单选择Add Comment即可添加jsDoc注释 preview.gif

rightmenu.png

6.打包成vscode插件并发布

安装vscode插件打包工具

yarn add -D @vscode/vsce

package.json

  • icon:配置logo
  • extensionKind:插件类型workspace工作台功能或ui打开新的web页面,这里添加注释的功能是workspace
  • main:入口文件
{
 "icon": "xcommentlogo.jpg",
 "extensionKind": [
    "workspace"
  ],
  "main": "./dist/extension.js",
}

注意README上图片文件不可打包在其中,建议放到github上

执行打包命令,打包vsix插件包

npx vsce package

登录Azure DevOps创建个人访问令牌,记得复制令牌token字符串

image.png

image.png

package.json里配置Azure DevOps发布者账号名

{
"publisher": "username",
}

执行命令登录账户,然后粘贴刚才复制的token字符串

npx vsce login <username>

image.png

登录成功后执行发布命令

npx vsce publish

也可以到vscode插件管理页面手动发布 https://marketplace.visualstudio.com/manage

image.png

注意:

  • publisher注册和发布时,要使用谷歌的验证码recaptcha,可能要科学上网才能成功
  • 发布插件的时候不要开启 fastGithub等代理,否则会验证失败
  • 另外,一些临时文件不需要打包到vsix插件包的文件请在.vscodeignore 里面设置为忽略

vscode-xcomment这个注释小功能插件已发布到vscode插件市场,欢迎使用~

marketplace.visualstudio.com/items?itemN…

image.png

7.github地址

https://github.com/xiaolidan00/vscode-xcomment

参考

  • vscode插件开发官方示例https://github.com/microsoft/vscode-extension-samples
  • vscode插件开发教程https://vscode.js.cn/api/get-started/your-first-extension
  • 打包发布vscode插件https://vscode.js.cn/api/working-with-extensions/publishing-extension
  • Github Copilot
  • astexplorer.net/

别再用 useState / data 管 Tabs 的 activeKey 了:和 URL 绑定才香

别再用 useState / data 管 Tabs 的 activeKey 了:和 URL 绑定才香

Code review 发现组员用 state 管 tabs,想起自己踩过的坑。建议和 URL 绑定,方便调试和分享链接。


一、Code review 里常见写法:state 管 activeKey

很多人在写 Tabs 时,会照着组件库的 demo 来:用 React 的 useStateVue 的 data 存当前选中的 activeKey,点击 tab 就 setState / 改 data。demo 里这样写没问题——只是为了演示「能切换」——但搬到业务里就会踩坑

// React:典型 demo 写法
const [activeKey, setActiveKey] = useState('1');
<Tabs activeKey={activeKey} onChange={setActiveKey}>...</Tabs>
<!-- Vue:典型 demo 写法 -->
<script setup>
const activeKey = ref('1');
</script>
<template>
  <Tabs v-model:activeKey="activeKey">...</Tabs>
</template>

看起来没问题,功能也能跑。可一旦要调试、协作、分享,问题就来了。


二、为啥吃亏:难调试、不能分享链接、刷新就丢

  • 刷新就丢:用户切到某个 tab 后刷新页面,又回到默认 tab,无法「停在当前 tab」。
  • 链接没法用:想把这个 tab 的页面发给同事或贴到文档里,对方打开永远是默认 tab,你没法说「看第二个 tab」。
  • 调试费劲:排查某个 tab 下的问题时,每次都要手动点过去;不能直接通过改 URL 定位到目标 tab。
  • 和路由脱节:SPA 里路由本来就能表达「当前在看什么」,tabs 却单独用 state 再记一份,状态分散、容易不一致。

这些坑我以前都踩过:改完 bug 想让同事看一眼,发个链接过去,对方打开是第一个 tab,还得口头说「你点一下第二个」;自己排查也要反复点。后来改成 tabs 和 URL 绑定,一下子省心很多。


三、推荐做法:和 URL 绑定

当前 tab 的 key 放进 URL(hash 或 query),用 路由 作为唯一数据源,tabs 只做「读 URL → 渲染 / 写 URL → 跳转」。

好处很直接:

  • 可分享:复制链接就是「当前 tab」,别人打开即同屏。
  • 可调试:改 URL 就能切 tab,不用在页面上点。
  • 刷新不丢:刷新后从 URL 恢复,始终停在当前 tab。
  • 和路由统一:一个状态源,逻辑清晰。

3.1 React:用 searchParams 或 hash

React Router 6 为例,用 searchParams 存 tab key(如 ?tab=2):

import { useSearchParams } from 'react-router-dom';

function PageWithTabs() {
    const [searchParams, setSearchParams] = useSearchParams();
    const activeKey = searchParams.get('tab') || '1';

    const onChange = (key) => {
        setSearchParams({ tab: key });
    };

    return (
        <Tabs activeKey={activeKey} onChange={onChange}>
            <Tabs.TabPane key="1" tab="概览">...</Tabs.TabPane>
            <Tabs.TabPane key="2" tab="详情">...</Tabs.TabPane>
        </Tabs>
    );
}

若不想用 query,可以用 hash(如 #tab=2),用 useLocation().hashwindow.location.hash 读写的思路一样:读 URL → 给 activeKey;改 tab → 写 URL

3.2 Vue:用 query 或 hash

Vue Router 里用 query 存 tab key:

<script setup>
import { useRoute, useRouter } from 'vue-router';

const route = useRoute();
const router = useRouter();
const activeKey = computed(() => route.query.tab || '1');

function onChange(key) {
    router.replace({ query: { ...route.query, tab: key } });
}
</script>
<template>
    <Tabs :activeKey="activeKey" @update:activeKey="onChange">
        <Tabs.TabPane key="1" tab="概览">...</Tabs.TabPane>
        <Tabs.TabPane key="2" tab="详情">...</Tabs.TabPane>
    </Tabs>
</template>

同样,也可以用 hash 存(如 #detail),用 route.hash 读写即可。


四、小结

  • 别学 demo 用 state/data 管 tabs:只适合「演示能切换」,业务里会带来刷新丢、不能分享链接、调试麻烦。
  • 把 activeKey 和 URL 绑定:用 searchParams、query 或 hash 存当前 tab,路由即唯一数据源;方便调试、复制链接、刷新不丢。
  • Code review 时可以提一嘴:看到 tabs 还在用 useState/data 且没有和 URL 联动时,建议改成「读 URL → 渲染,改 tab → 写 URL」,少踩坑。

如果你也经历过「发链接别人看不到当前 tab」或「调试要反复点 tab」的痛,不妨把现有页面的 tabs 改成和 URL 绑定,体验会明显好一截。觉得有用欢迎点赞、收藏或评论区聊聊你的踩坑经历。

前端权限校验最佳实践:一个健壮的柯里化工具函数

在业务开发中,权限校验是绕不开的常见场景。无论是管理后台的按钮权限控制,还是金融系统的操作权限验证,都需要在业务逻辑执行前进行权限判断。

然而,权限校验的代码往往散落在各处,重复且难以维护。本文分享一个经过多轮评审和实战检验的权限校验工具函数,从设计思路到最佳实践,帮助你在项目中优雅地处理权限校验。

需求背景

典型场景

假设我们在开发一个用户管理模块:

// 场景1:删除用户 - 需要管理员权限
const handleDelete = async (userId: string) => {
  if (!hasPermission('user:delete')) {
    message.error('无删除权限');
    return;
  }
  await deleteUser(userId);
};

// 场景2:编辑用户 - 需要特定角色
const handleEdit = async (user: User) => {
  if (!canEditUser(user)) {
    message.error('无编辑权限');
    return;
  }
  await updateUser(user);
};

// 场景3:异步权限校验 - 需要请求后端接口
const handleExport = async () => {
  const hasPerm = await checkPermissionAsync('user:export');
  if (!hasPerm) {
    message.error('无导出权限');
    return;
  }
  await exportUsers();
};

存在的问题

  1. 代码重复:每个函数都要写相同的校验逻辑
  2. 参数透传麻烦:Antd 等组件的事件处理函数需要传递事件参数
  3. 错误处理不统一:权限错误和业务错误混在一起
  4. 难以维护:权限校验逻辑分散,修改需要改动多处

设计思路

核心目标

  • 复用性:一次配置,多处使用
  • 参数透传:保持原函数参数不变
  • 类型安全:完整的 TypeScript 类型支持
  • 错误隔离:权限错误和运行时错误分开处理

方案选择

方案1:装饰器模式

@checkPermission('user:delete')
async handleDelete(userId: string) {
  await deleteUser(userId);
}

优点:语法优雅
缺点:对箭头函数不友好,React Hooks 场景受限

方案2:高阶函数

const handleDelete = withPermission(
  () => hasPermission('user:delete'),
  '无删除权限'
)((userId: string) => deleteUser(userId));

优点:函数式编程,与 React 兼容
缺点:需要处理参数透传

方案3:柯里化(最终选择)

const handleDelete = withPermissionCheck({
  validate: () => hasPermission('user:delete'),
  errorMessage: '无删除权限'
})(async (userId: string) => {
  await deleteUser(userId);
});

优点:配置清晰、支持柯里化、参数自动透传
缺点:返回值类型需要处理

我们选择柯里化方案,它在灵活性和可读性之间取得了良好平衡。

实现详解

基础实现

export interface PermissionCheckOptions {
  validate: boolean | (() => boolean) | (() => Promise<boolean>);
  errorMessage?: string;
  onForbidden?: (message?: string) => boolean | void;
  onError?: (error: unknown) => void;
  onChecking?: (checking: boolean) => void;
  showMessage?: (message: string) => void;
}

export function withPermissionCheck<T extends (...args: unknown[]) => unknown>(
  options: PermissionCheckOptions
) {
  return (targetFn: T): ((...args: Parameters<T>) => Promise<ReturnType<T>>) => {
    return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
      try {
        // 校验权限
        let hasPermission: boolean;
        if (typeof options.validate === 'boolean') {
          hasPermission = options.validate;
        } else if (typeof options.validate === 'function') {
          hasPermission = await options.validate();
        } else {
          hasPermission = false;
        }

        // 权限失败处理
        if (!hasPermission) {
          const msg = options.errorMessage || '无操作权限';
          const handled = options.onForbidden?.(msg);

          if (handled !== true) {
            const messageHandler = options.showMessage || defaultMessageHandler;
            messageHandler(msg);
          }

          throw new PermissionDeniedError(msg, { args, handled });
        }

        // 执行目标函数
        return (await targetFn(...args)) as ReturnType<T>;
      } catch (error) {
        if (error instanceof PermissionDeniedError) {
          throw error;
        }
        options.onError?.(error);
        throw error;
      }
    }) as (...args: Parameters<T>) => Promise<ReturnType<T>>;
  };
}

关键设计点

1. 参数透传保证

使用 TypeScript 泛型和 Parameters<T> 实现参数自动透传:

// 原函数签名
type TargetFn = (pagination: TablePagination, filters: Record<string, any>, sorter: Sorter) => void;

// 包装后
const wrappedFn = withPermissionCheck({
  validate: () => hasPermission('view')
})(targetFn);

// 类型自动推断,参数完整透传
wrappedFn({ current: 1, pageSize: 10 }, {}, {});

2. 自定义错误类型

引入 PermissionDeniedError 区分权限错误和运行时错误:

export class PermissionDeniedError extends Error {
  constructor(
    message: string,
    public readonly context?: Record<string, unknown>
  ) {
    super(message);
    this.name = 'PermissionDeniedError';
  }
}

使用场景:

try {
  await handleDelete('user-123');
} catch (error) {
  if (error instanceof PermissionDeniedError) {
    message.warning(error.message);
    return;
  }
  message.error('系统错误');
}

3. 解耦 UI 库

通过 showMessage 配置项实现 UI 库解耦:

// 使用 Ant Design
import { message } from 'antd';
const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  showMessage: (msg) => message.error(msg)
})(deleteUser);

// 使用 Naive UI
import { useMessage } from 'naive-ui';
const { error } = useMessage();
const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  showMessage: (msg) => error(msg)
})(deleteUser);

// 完全自定义
const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  showMessage: (msg) => {
    const div = document.createElement('div');
    div.textContent = msg;
    document.body.appendChild(div);
    setTimeout(() => div.remove(), 3000);
  }
})(deleteUser);

4. 避免 Loading 闪烁

仅在异步校验时触发 onChecking

// 判断是否为异步校验
const isAsyncValidation =
  typeof options.validate === 'function' &&
  (options.validate as () => Promise<boolean>)().then !== undefined;

// 仅异步校验时触发
if (isAsyncValidation) {
  options.onChecking?.(true);
}

// ... 执行逻辑

if (isAsyncValidation) {
  options.onChecking?.(false);
}

使用指南

基础用法

1. 静态权限(boolean)

const handleDelete = withPermissionCheck({
  validate: hasPermission('delete'),
  errorMessage: '无删除权限'
})(async (userId: string) => {
  await deleteUser(userId);
});

// 调用
try {
  await handleDelete('user-123');
} catch (error) {
  if (error instanceof PermissionDeniedError) {
    // 权限不足
  }
}

2. 同步校验函数

const handleClick = withPermissionCheck({
  validate: () => canEdit(),
  errorMessage: '无编辑权限'
})((event: React.MouseEvent) => {
  console.log(event.currentTarget);
});

// 在 React 组件中使用
<Button onClick={handleClick}>编辑</Button>

3. 异步校验函数

const handleExport = withPermissionCheck({
  validate: async () => {
    const result = await checkPermissionAsync('export');
    return result;
  },
  errorMessage: '无导出权限',
  onChecking: (loading) => setLoading(loading)
})(async () => {
  await exportData();
});

高级用法

1. 自定义错误提示

import { Modal } from 'antd';

const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  errorMessage: '删除权限不足',
  onForbidden: (msg) => {
    Modal.warning({
      title: '权限提示',
      content: msg,
    });
    return true; // 已自定义处理,不显示默认提示
  }
})(deleteUser);

2. 处理运行时错误

const handleAsync = withPermissionCheck({
  validate: () => true,
  errorMessage: '操作失败',
  onError: (error) => {
    console.error('执行出错:', error);
    message.error('操作失败,请重试');
  }
})(async () => {
  await riskyOperation();
});

3. Antd 组件集成

// Table onChange - 多参数透传
const handleTableChange = withPermissionCheck({
  validate: () => hasPermission('view'),
  errorMessage: '无查看权限'
})((pagination, filters, sorter, extra) => {
  console.log(pagination.current, filters, sorter.field, extra.action);
  fetchData();
});

<Table onChange={handleTableChange} />

// Form onFinish
const handleFormSubmit = withPermissionCheck({
  validate: () => canSubmit(),
  errorMessage: '无提交权限'
})(async (values: FormValues) => {
  await submitForm(values);
});

<Form onFinish={handleFormSubmit}>

最佳实践

1. UI 层预处理

在按钮或入口处判断权限,避免触发校验:

const deleteUser = withPermissionCheck({
  validate: () => hasPermission('delete'),
  errorMessage: '无删除权限'
})(async (userId: string) => {
  await api.delete(userId);
});

// 使用
<DataTable
  rowActions={(record) => [
    <Button
      key="delete"
      disabled={!hasPermission('delete')}
      danger
      onClick={() => deleteUser(record.id)}
    >
      删除
    </Button>
  ]}
/>

2. 错误边界处理

在 React Error Boundary 中统一处理:

class PermissionErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError(error: Error) {
    if (error instanceof PermissionDeniedError) {
      return { hasError: false }; // 不显示错误边界,由组件自行处理
    }
    return { hasError: true };
  }

  componentDidCatch(error: Error) {
    if (!(error instanceof PermissionDeniedError)) {
      // 记录其他错误
      logError(error);
    }
  }

  render() {
    if (this.state.hasError) {
      return <ErrorFallback />;
    }
    return this.props.children;
  }
}

3. 权限校验与业务逻辑分离

将权限校验逻辑抽离为独立模块:

// permissions.ts
export const UserPermissions = {
  canDelete: () => hasPermission('user:delete'),
  canEdit: (user: User) => user.id === currentUser.id || hasRole('admin'),
  canExport: async () => {
    const { data } = await api.checkPermission('user:export');
    return data.allowed;
  }
};

// 使用
const handleDelete = withPermissionCheck({
  validate: UserPermissions.canDelete,
  errorMessage: '无删除权限'
})(deleteUser);

常见问题

Q1: 为什么权限失败要抛出错误而不是返回 undefined?

答案:类型安全的考虑。

如果返回 undefined

const getUserData = withPermissionCheck({
  validate: false
})(async (id: string): Promise<User> => {
  return await fetchUser(id);
});

// 类型推断为 Promise<User>,实际返回 Promise<User | undefined>
const user = await getUserData('123');
user.name; // 运行时报错!

抛出错误确保类型契约完整:

try {
  const user = await getUserData('123'); // 类型安全
  user.name;
} catch (error) {
  if (error instanceof PermissionDeniedError) {
    // 明确处理权限错误
  }
}

Q2: 为什么包装后的函数总是异步的?

答案:统一行为,减少复杂度。

虽然这会导致同步函数也被包装成异步,但有以下好处:

  1. API 一致性:所有包装函数的调用方式相同
  2. 简化类型:不需要复杂的函数重载
  3. 扩展性:方便后续添加异步权限校验

在文档中明确说明这一点即可。

Q3: 如何在单元测试中使用?

答案:mock 消息提示函数。

import { withPermissionCheck } from '@/utils/permission-check';

describe('权限校验', () => {
  let showMessageMock: jest.Mock;

  beforeEach(() => {
    showMessageMock = jest.fn();
  });

  it('应该调用自定义提示函数', async () => {
    const targetFn = jest.fn();
    const wrappedFn = withPermissionCheck({
      validate: false,
      errorMessage: '无权限',
      showMessage: showMessageMock,
    })(targetFn);

    await expect(wrappedFn()).rejects.toThrow(PermissionDeniedError);
    expect(showMessageMock).toHaveBeenCalledWith('无权限');
  });
});

Q4: 如何处理高频调用的权限校验?

答案:在 validate 函数外部缓存。

// 简单缓存
let permissionCache: Map<string, boolean> = new Map();

const getPermission = async (key: string) => {
  if (permissionCache.has(key)) {
    return permissionCache.get(key);
  }

  const result = await api.checkPermission(key);
  permissionCache.set(key, result);
  return result;
};

// 使用
const handleDelete = withPermissionCheck({
  validate: async () => await getPermission('user:delete')
})(deleteUser);

如果需要更复杂的缓存逻辑(如 TTL),建议使用成熟的缓存库。

性能考虑

开销分析

包装函数的开销主要来自:

  1. 异步函数调用:Promise 包装的开销很小(< 1ms)
  2. 类型检查:仅在编译时,无运行时开销
  3. 条件判断:几个 if/else 判断,开销可忽略

优化建议

  1. 避免重复创建:在组件外或 useMemo 中创建包装函数
// ❌ 每次 render 都创建新函数
function Component() {
  const handleDelete = withPermissionCheck({ ... })(deleteUser);

  return <Button onClick={handleDelete}>删除</Button>;
}

// ✅ 在组件外创建
const handleDelete = withPermissionCheck({ ... })(deleteUser);

function Component() {
  return <Button onClick={handleDelete}>删除</Button>;
}

// ✅ 或使用 useMemo
function Component() {
  const handleDelete = useMemo(() => withPermissionCheck({ ... })(deleteUser), []);

  return <Button onClick={handleDelete}>删除</Button>;
}
  1. 异步校验加缓存:如上文提到的缓存方案

  2. 批量校验:对于需要多次校验的场景,可以批量获取权限

const permissions = await api.batchCheckPermissions([
  'user:delete',
  'user:edit',
  'user:export'
]);

const handleDelete = withPermissionCheck({
  validate: () => permissions['user:delete']
})(deleteUser);

总结

本文介绍的 withPermissionCheck 工具函数,经过多轮实战和评审,在以下方面取得了平衡:

维度 设计决策 权衡
类型安全 抛出 PermissionDeniedError 保持类型契约完整
参数透传 使用 Parameters 灵活性 > 简洁性
UI 解耦 showMessage 配置项 通用性 > 默认行为
错误处理 分离权限错误和运行时错误 清晰度 > 统一性
异步化 统一返回 Promise 一致性 > 适配性

核心设计原则:优先保证类型安全和行为可预期,其次考虑灵活性和易用性

如果你有更好的想法或建议,欢迎交流讨论。

后台权限与菜单渲染:基于路由和后端返回的几种实现方式

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

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

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

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

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

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

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

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

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

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

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

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

一、先搞清楚:权限到底分几层?

很多人一上来就想"我要做权限控制",但连权限分几层都没理清楚。我们先建立一个清晰的分层认知:

层级 控制什么 典型实现方式
路由级权限 用户能不能访问某个页面 路由守卫 + 动态路由
菜单级权限 侧边栏显示哪些菜单项 后端返回菜单 / 前端根据角色过滤
按钮级权限 页面内某个按钮是否可见/可点 自定义指令 / 组件封装
接口级权限 后端接口是否允许调用 后端网关/中间件拦截(前端兜底)

关键认识:前端权限控制本质上是"体验优化",真正的安全屏障在后端。 前端做的事情是:不该看的别让用户看到,不该点的别让用户点到。但如果有人绕过前端直接调接口,后端必须自己挡住。

二、路由级权限:从静态到动态的三种方案

方案一:最朴素的路由守卫 —— 路由 meta + 全局前置守卫

适用场景:角色简单(比如只有 admin 和 user 两种),页面不多。

思路:所有路由在前端写死,通过 meta 字段标记需要的角色,在全局路由守卫里做判断。

完整示例

路由配置:

// router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Layout from '@/layout/index.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/login',
    component: () => import('@/views/login.vue'),
    meta: { requiresAuth: false }
  },
  {
    path: '/',
    component: Layout,
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard.vue'),
        meta: { requiresAuth: true, roles: ['admin', 'user'] }
      },
      {
        path: 'user-manage',
        component: () => import('@/views/user-manage.vue'),
        meta: { requiresAuth: true, roles: ['admin'] }
      },
      {
        path: 'order-list',
        component: () => import('@/views/order-list.vue'),
        meta: { requiresAuth: true, roles: ['admin', 'user'] }
      }
    ]
  },
  {
    path: '/403',
    component: () => import('@/views/403.vue')
  }
]

const router = new VueRouter({ routes })

export default router

全局守卫:

// router/permission.js
import router from './index'
import store from '@/store'

router.beforeEach(async (to, from, next) => {
  const token = store.getters.token

  // 1. 去登录页:有 token 就跳首页,没有就放行
  if (to.path === '/login') {
    token ? next('/') : next()
    return
  }

  // 2. 没有 token,去登录
  if (!token) {
    next(`/login?redirect=${to.path}`)
    return
  }

  // 3. 有 token,但用户信息还没拉取(刷新页面的场景)
  if (!store.getters.userInfo) {
    try {
      await store.dispatch('user/getUserInfo')
    } catch (error) {
      // token 过期或无效,清除后跳登录
      await store.dispatch('user/logout')
      next(`/login?redirect=${to.path}`)
      return
    }
  }

  // 4. 检查角色权限
  if (to.meta.roles) {
    const userRole = store.getters.role
    if (to.meta.roles.includes(userRole)) {
      next()
    } else {
      next('/403')
    }
  } else {
    next()
  }
})

这种方案的优缺点

优点:简单直观,5 分钟就能写完,小项目完全够用。

缺点

  • 所有路由都注册了,只是守卫拦着不让进。用户在浏览器地址栏敲地址虽然会被拦截,但路由本身是存在的。
  • 角色和路由的对应关系写死在前端,改权限就得改代码、重新发版。
  • 菜单渲染还得另外写一套过滤逻辑。

踩坑点

坑 1:刷新页面时 userInfo 丢失。 Vuex 的状态刷新后就没了,所以守卫里必须有"重新获取用户信息"这一步。很多人一开始忘了这一步,导致刷新后直接跳登录页。

坑 2:next() 多次调用。 在一个守卫函数里,next() 只应该被调用一次。如果你的 if-else 分支写得不够严谨,可能会出现 next() 被调用多次的情况,导致诡异的跳转。上面示例里每个分支都 return 了,就是为了避免这个问题。


方案二:动态路由 —— 前端存完整路由表,登录后按角色过滤

适用场景:角色较多,但角色和权限的对应关系前端可以维护。

思路:前端维护一份"完整路由表"和一份"基础路由表"。用户登录后,根据角色从完整路由表中过滤出有权限的路由,通过 router.addRoutes()(Vue Router 3)或 router.addRoute()(Vue Router 4)动态添加。

完整示例

先把路由分成两份:

// router/routes.js

// 基础路由 —— 所有人都能访问
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login.vue'),
    hidden: true  // 菜单里不显示
  },
  {
    path: '/403',
    component: () => import('@/views/403.vue'),
    hidden: true
  }
]

// 动态路由 —— 需要根据角色过滤
export const asyncRoutes = [
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard.vue'),
        meta: { title: '首页', icon: 'home', roles: ['admin', 'user', 'editor'] }
      }
    ]
  },
  {
    path: '/system',
    component: () => import('@/layout/index.vue'),
    meta: { title: '系统管理', icon: 'setting', roles: ['admin'] },
    children: [
      {
        path: 'user',
        component: () => import('@/views/system/user.vue'),
        meta: { title: '用户管理', roles: ['admin'] }
      },
      {
        path: 'role',
        component: () => import('@/views/system/role.vue'),
        meta: { title: '角色管理', roles: ['admin'] }
      }
    ]
  },
  {
    path: '/content',
    component: () => import('@/layout/index.vue'),
    meta: { title: '内容管理', icon: 'document' },
    children: [
      {
        path: 'article',
        component: () => import('@/views/content/article.vue'),
        meta: { title: '文章管理', roles: ['admin', 'editor'] }
      },
      {
        path: 'comment',
        component: () => import('@/views/content/comment.vue'),
        meta: { title: '评论管理', roles: ['admin'] }
      }
    ]
  }
]

过滤函数:

// utils/permission.js

/**
 * 判断用户角色是否匹配路由要求
 */
function hasPermission(route, role) {
  if (route.meta && route.meta.roles) {
    return route.meta.roles.includes(role)
  }
  // 没有设置 roles 的路由,默认所有人可访问
  return true
}

/**
 * 递归过滤路由表
 * 注意:这里要深拷贝,不能污染原始路由表
 */
export function filterAsyncRoutes(routes, role) {
  const result = []

  routes.forEach(route => {
    // 浅拷贝一份,避免修改原对象
    const tmp = { ...route }

    if (hasPermission(tmp, role)) {
      // 如果有子路由,递归过滤
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, role)
      }
      result.push(tmp)
    }
  })

  return result
}

在 Vuex 中集成(也可以用 Pinia,思路一样):

// store/modules/permission.js
import { constantRoutes, asyncRoutes } from '@/router/routes'
import { filterAsyncRoutes } from '@/utils/permission'

const state = {
  routes: [],        // 最终的完整路由(用于渲染菜单)
  addedRoutes: []    // 动态添加的部分
}

const mutations = {
  SET_ROUTES(state, routes) {
    state.addedRoutes = routes
    state.routes = constantRoutes.concat(routes)
  }
}

const actions = {
  generateRoutes({ commit }, role) {
    return new Promise(resolve => {
      let accessedRoutes

      // admin 拥有全部权限,直接用完整路由表
      if (role === 'admin') {
        accessedRoutes = asyncRoutes
      } else {
        accessedRoutes = filterAsyncRoutes(asyncRoutes, role)
      }

      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

在路由守卫里动态添加:

// router/permission.js
import router from './index'
import store from '@/store'

const whiteList = ['/login', '/403']

router.beforeEach(async (to, from, next) => {
  const token = store.getters.token

  if (token) {
    if (to.path === '/login') {
      next('/')
      return
    }

    // 判断是否已经生成过动态路由
    const hasRoutes = store.getters.addedRoutes && store.getters.addedRoutes.length > 0

    if (hasRoutes) {
      next()
    } else {
      try {
        // 获取用户信息(含角色)
        const { role } = await store.dispatch('user/getUserInfo')

        // 根据角色生成可访问路由
        const accessRoutes = await store.dispatch('permission/generateRoutes', role)

        // 动态添加路由(Vue Router 3 用 addRoutes,4 用 addRoute)
        // Vue Router 3:
        router.addRoutes(accessRoutes)

        // Vue Router 4 的写法:
        // accessRoutes.forEach(route => {
        //   router.addRoute(route)
        // })

        // 用 replace 确保 addRoutes 生效后再跳转
        // hack:{ ...to } 会重新解析路由,确保新加的路由能匹配到
        next({ ...to, replace: true })
      } catch (error) {
        await store.dispatch('user/logout')
        next(`/login?redirect=${to.path}`)
      }
    }
  } else {
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

踩坑点

坑 1:next({ ...to, replace: true }) 是必须的。 这一行容易被忽略。addRoutes 是异步生效的,如果你直接 next(),此时新路由可能还没注册完,就会匹配到 404。next({ ...to, replace: true }) 相当于"用当前目标地址重新走一次路由匹配",此时新路由已经注册好了。

坑 2:刷新页面后动态路由丢失。 addRoutes 添加的路由在刷新后就没了(因为是运行时加的,不是写死在 router 实例化时的)。所以守卫里用 hasRoutes 标志位来判断,如果没了就重新走一遍 generateRoutes → addRoutes 的流程。

坑 3:过滤路由时污染原始数据。 filterAsyncRoutes 一定要拷贝一份再操作。如果你直接改 asyncRoutes 里的对象,下次退出登录换个角色重新登录,过滤就乱了——因为原始路由表已经被改过了。


方案三:完全由后端控制路由表 —— 前端动态生成路由

适用场景:大型后台系统、权限管理非常灵活、角色和菜单由运营/管理员后台配置。

思路:后端返回当前用户有权限的菜单/路由数据(JSON),前端拿到后转换成 Vue Router 能识别的路由对象,然后动态添加。

后端返回的数据长什么样(典型格式)

[
  {
    "id": 1,
    "parentId": 0,
    "path": "/dashboard",
    "component": "views/dashboard",
    "name": "Dashboard",
    "meta": { "title": "首页", "icon": "home" }
  },
  {
    "id": 2,
    "parentId": 0,
    "path": "/system",
    "component": "layout/index",
    "name": "System",
    "meta": { "title": "系统管理", "icon": "setting" },
    "children": [
      {
        "id": 3,
        "parentId": 2,
        "path": "user",
        "component": "views/system/user",
        "name": "UserManage",
        "meta": { "title": "用户管理" }
      },
      {
        "id": 4,
        "parentId": 2,
        "path": "role",
        "component": "views/system/role",
        "name": "RoleManage",
        "meta": { "title": "角色管理" }
      }
    ]
  }
]

注意:后端返回的 component 是一个字符串路径,不是真正的组件。前端需要自己把这个字符串映射成 () => import(...) 的动态导入。

核心:字符串转组件的映射函数

// utils/route-helper.js

// 方式一:用 import() 的动态拼接
// 注意:Webpack 的 import() 不支持完全动态的字符串,必须有一部分是静态的
function loadComponent(componentPath) {
  // 这里 '@/' 是静态前缀,后面拼动态部分,Webpack 才能正确分析
  return () => import(`@/${componentPath}.vue`)
}

// 方式二(更推荐):维护一个显式映射表,更可控
const componentMap = {
  'layout/index': () => import('@/layout/index.vue'),
  'views/dashboard': () => import('@/views/dashboard.vue'),
  'views/system/user': () => import('@/views/system/user.vue'),
  'views/system/role': () => import('@/views/system/role.vue'),
  'views/content/article': () => import('@/views/content/article.vue'),
  // ...根据项目页面逐步维护
}

function loadComponentByMap(componentPath) {
  const loader = componentMap[componentPath]
  if (!loader) {
    console.warn(`[路由警告] 找不到组件: ${componentPath},将渲染 404 页面`)
    return () => import('@/views/404.vue')
  }
  return loader
}

/**
 * 把后端返回的路由数据转换成 Vue Router 格式
 */
export function transformRoutes(backendRoutes) {
  return backendRoutes.map(route => {
    const tmp = { ...route }

    // 字符串组件路径 → 真实组件
    if (tmp.component) {
      tmp.component = loadComponentByMap(tmp.component)
    }

    // 递归处理子路由
    if (tmp.children && tmp.children.length > 0) {
      tmp.children = transformRoutes(tmp.children)
    }

    return tmp
  })
}

在权限 store 中使用:

// store/modules/permission.js
import { constantRoutes } from '@/router/routes'
import { transformRoutes } from '@/utils/route-helper'
import { getUserMenus } from '@/api/user'

const actions = {
  async generateRoutes({ commit }) {
    // 从后端获取当前用户的菜单/路由数据
    const { data: backendRoutes } = await getUserMenus()

    // 将后端数据转换成 Vue Router 路由对象
    const accessedRoutes = transformRoutes(backendRoutes)

    commit('SET_ROUTES', accessedRoutes)
    return accessedRoutes
  }
}

路由守卫的写法和方案二基本一样,只是 generateRoutes 不再需要传角色了——后端已经帮你过滤好了。

踩坑点

坑 1:Webpack 的 import() 不能用完全动态的变量。 比如 import(componentPath) 这样写是不行的,Webpack 需要至少一个静态的目录前缀来确定搜索范围。所以要么写成 import(`@/views/${componentPath}.vue`),要么像上面那样用映射表。Vite 的场景下可以用 import.meta.glob 来实现更优雅的批量导入,后面会提到。

坑 2:后端返回的树形结构可能是扁平的。 有些后端返回的不是嵌套好的 tree,而是一个带 parentId 的扁平数组。这时候你需要先在前端组装成树形结构:

/**
 * 扁平数组 → 树形结构
 */
export function buildTree(flatList) {
  const map = {}
  const tree = []

  // 第一遍:建立 id → item 的映射
  flatList.forEach(item => {
    map[item.id] = { ...item, children: [] }
  })

  // 第二遍:根据 parentId 挂到父节点的 children 下
  flatList.forEach(item => {
    const node = map[item.id]
    if (item.parentId === 0) {
      tree.push(node)
    } else {
      const parent = map[item.parentId]
      if (parent) {
        parent.children.push(node)
      }
    }
  })

  return tree
}

坑 3:Vite 环境下 import() 的写法不同。 如果你用的是 Vite(Vue 3 项目大概率是),可以用 import.meta.glob 来做组件映射:

// Vite 专用写法
const modules = import.meta.glob('@/views/**/*.vue')

function loadComponent(componentPath) {
  const key = `/src/${componentPath}.vue`
  const loader = modules[key]
  if (!loader) {
    console.warn(`[路由警告] 找不到组件: ${componentPath}`)
    return modules['/src/views/404.vue']
  }
  return loader
}

import.meta.glob 返回的本身就是 { 路径: () => import(...) } 的映射对象,天然适合做这个事情,而且不需要手动维护映射表。


三种方案对比总结

维度 方案一:meta 守卫 方案二:前端过滤 方案三:后端返回
复杂度 ⭐⭐ ⭐⭐⭐
灵活度 低,改权限要发版 中,角色固定时够用 高,运营后台可动态配置
安全性 路由全暴露 路由全暴露(只是不添加) 前端只有有权限的路由
菜单渲染 需另外过滤 过滤后的路由即菜单 后端数据即菜单
适合场景 内部小工具 中型项目 大型后台 / SaaS

我的建议:如果你的项目超过 10 个菜单项,或者权限角色超过 3 种,直接上方案三。前期多花半天时间,后期能省几周的维护成本。

三、菜单渲染:路由即菜单 vs 菜单和路由分离

方式一:路由即菜单

这是最常见的做法——侧边栏菜单直接根据路由表渲染。方案二和方案三天然支持这种方式:过滤后的路由表就是菜单数据。

<!-- layout/Sidebar.vue -->
<template>
  <div class="sidebar">
    <template v-for="route in menuRoutes">
      <!-- 只有一个子菜单或没有子菜单:直接渲染为菜单项 -->
      <router-link
        v-if="!route.children || route.children.length <= 1"
        :key="route.path"
        :to="route.children ? route.children[0].path : route.path"
        class="menu-item"
      >
        <i :class="route.meta?.icon" />
        <span>{{ route.meta?.title || route.children?.[0]?.meta?.title }}</span>
      </router-link>

      <!-- 多个子菜单:渲染为可展开的菜单组 -->
      <div v-else :key="route.path" class="submenu">
        <div class="submenu-title">
          <i :class="route.meta?.icon" />
          <span>{{ route.meta?.title }}</span>
        </div>
        <router-link
          v-for="child in route.children.filter(c => !c.hidden)"
          :key="child.path"
          :to="`${route.path}/${child.path}`"
          class="menu-item"
        >
          <span>{{ child.meta?.title }}</span>
        </router-link>
      </div>
    </template>
  </div>
</template>

<script>
export default {
  computed: {
    menuRoutes() {
      // 从 store 拿过滤后的路由,排除 hidden 的
      return this.$store.getters.routes.filter(r => !r.hidden)
    }
  }
}
</script>

优点:菜单和路由保持一致,不会出现"菜单有但页面 404"或"页面有但菜单没显示"的错位问题。

缺点:菜单的层级、排序完全受路由结构限制。如果产品经理说"这个页面属于 A 模块,但菜单要放在 B 模块下面",你就麻烦了。

方式二:菜单和路由分离

后端分别返回两套数据:一套是菜单数据(控制侧边栏显示),一套是权限标识(控制路由注册和按钮权限)。

// 后端返回的菜单数据(只关心展示)
const menus = [
  {
    title: '首页',
    icon: 'home',
    path: '/dashboard'
  },
  {
    title: '运营中心',    // 这是一个虚拟的分组,不对应任何路由
    icon: 'operation',
    children: [
      { title: '文章管理', path: '/content/article' },
      { title: '订单列表', path: '/order/list' }   // 注意:订单本来在"订单模块",但菜单放在了"运营中心"
    ]
  }
]

// 后端返回的权限标识(控制路由和按钮)
const permissions = [
  'dashboard',
  'content:article',
  'content:article:edit',
  'content:article:delete',
  'order:list',
  'order:detail'
]

优点:菜单的展示结构完全灵活,不受路由层级约束。

缺点:要同时维护菜单和路由两套东西,且必须保证菜单的 path 和路由的 path 对得上,否则会出现点菜单跳 404 的情况。

我的建议:除非产品对菜单的展示结构有特殊要求,否则优先用"路由即菜单"。简单就是美。

四、按钮级权限:自定义指令 vs 组件封装

这是权限控制里最细粒度的一层。典型场景:同一个页面,管理员能看到"编辑"和"删除"按钮,普通用户只能看到"查看"。

方式一:自定义指令 v-permission

思路:写一个自定义指令,绑定在按钮上。指令内部检查当前用户的权限列表,如果没权限就把这个 DOM 元素移除。

// directives/permission.js
import store from '@/store'

export default {
  // Vue 2 写法
  inserted(el, binding) {
    const { value: requiredPermission } = binding
    const permissions = store.getters.permissions  // 用户的权限标识列表

    if (!requiredPermission) return

    // 支持传单个字符串或数组
    const requiredList = Array.isArray(requiredPermission)
      ? requiredPermission
      : [requiredPermission]

    // 检查用户是否拥有所需权限中的至少一个
    const hasPermission = requiredList.some(p => permissions.includes(p))

    if (!hasPermission) {
      // 没权限:移除 DOM 元素
      el.parentNode && el.parentNode.removeChild(el)
    }
  }

  // Vue 3 写法(钩子名不同):
  // mounted(el, binding) { ... }  // 对应 Vue 2 的 inserted
}

全局注册:

// main.js
import permissionDirective from '@/directives/permission'

// Vue 2
Vue.directive('permission', permissionDirective)

// Vue 3
app.directive('permission', permissionDirective)

使用:

<template>
  <div>
    <button v-permission="'content:article:edit'" @click="handleEdit">
      编辑
    </button>
    
    <button v-permission="'content:article:delete'" @click="handleDelete">
      删除
    </button>

    <!-- 也支持传数组:拥有其中任意一个权限即可 -->
    <button v-permission="['content:article:edit', 'content:article:publish']">
      编辑或发布
    </button>
  </div>
</template>

踩坑点

坑 1(非常重要):用 v-if 还是操作 DOM? 很多人觉得指令里直接 removeChild 太粗暴了。确实,这种方式有个问题:一旦移除了就不会再回来。如果你的权限数据是异步获取的,指令执行时权限还没拿到,按钮就被误删了。

解决方案有两种:

  1. 确保权限数据一定在组件渲染前就位(在路由守卫里获取完用户信息再放行)。
  2. 不用 removeChild,改成 el.style.display = 'none',然后在 update 钩子里重新检查。

坑 2:指令方式无法与 v-if / v-show 配合。 如果你在同一个元素上同时用了 v-permissionv-if,逻辑会变得混乱。建议二选一。

方式二:组件封装 <Permission>

思路:封装一个函数式组件,通过插槽来控制内容的渲染。

<!-- components/Permission.vue -->
<script>
export default {
  name: 'Permission',
  functional: true,   // Vue 2 函数式组件,性能更好
  props: {
    value: {
      type: [String, Array],
      required: true
    }
  },
  render(h, context) {
    const { value } = context.props
    const permissions = context.parent.$store.getters.permissions

    const requiredList = Array.isArray(value) ? value : [value]
    const hasPermission = requiredList.some(p => permissions.includes(p))

    // 有权限则渲染插槽内容,否则渲染空
    return hasPermission ? context.children : null
  }
}
</script>

Vue 3 的 Composition API 写法:

<!-- components/Permission.vue (Vue 3) -->
<template>
  <slot v-if="hasPermission" />
</template>

<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'  // 或者 import { usePermissionStore } from '@/stores/permission'

const props = defineProps({
  value: {
    type: [String, Array],
    required: true
  }
})

const store = useStore()

const hasPermission = computed(() => {
  const permissions = store.getters.permissions
  const requiredList = Array.isArray(props.value) ? props.value : [props.value]
  return requiredList.some(p => permissions.includes(p))
})
</script>

使用:

<template>
  <div>
    <Permission value="content:article:edit">
      <button @click="handleEdit">编辑</button>
    </Permission>

    <Permission :value="['content:article:delete']">
      <button @click="handleDelete">删除</button>
    </Permission>
  </div>
</template>

方式三(补充):直接用函数判断

有时候权限逻辑比较复杂(比如同时要判断角色 + 数据归属),指令和组件都不太方便。这时候最朴素的 v-if + 工具函数反而最好用:

// utils/permission.js
import store from '@/store'

export function hasPermission(permission) {
  const permissions = store.getters.permissions
  const requiredList = Array.isArray(permission) ? permission : [permission]
  return requiredList.some(p => permissions.includes(p))
}

export function hasRole(role) {
  return store.getters.role === role
}
<template>
  <div>
    <!-- 简单场景 -->
    <button v-if="hasPermission('content:article:edit')" @click="handleEdit">
      编辑
    </button>

    <!-- 复杂场景:不仅要有权限,还要是自己的文章 -->
    <button
      v-if="hasPermission('content:article:edit') && article.authorId === userId"
      @click="handleEdit"
    >
      编辑
    </button>
  </div>
</template>

<script>
import { hasPermission } from '@/utils/permission'

export default {
  methods: {
    hasPermission
  }
}
</script>

三种方式对比

维度 自定义指令 组件封装 函数 + v-if
简洁性 ⭐⭐⭐ 一行搞定 ⭐⭐ 需要包一层 ⭐⭐ 需要导入函数
灵活性 低,只能控制显隐 ⭐⭐⭐ 可组合复杂逻辑
响应式 需手动处理 天然响应式 天然响应式
推荐场景 纯显隐控制 团队规范统一 复杂业务逻辑

我的实战建议:项目中三种可以并存。简单的用指令,需要统一规范的用组件,复杂条件的用函数。别非要"只用一种"——工具是为业务服务的。

五、完整的权限流程串联

最后,我们把上面所有内容串起来,看一个完整的权限控制流程是怎么跑的:

用户打开浏览器,访问 /dashboard
        │
        ▼
  路由守卫拦截,检查 token
        │
    ┌───┴───┐
    │ 无token │──────→ 跳转 /login
    └───┬───┘
        │ 有token
        ▼
  是否已拉取用户信息?
        │
    ┌───┴───┐
    │  还没有  │──────→ 调接口获取 userInfo + permissions
    └───┬───┘
        │ 已有
        ▼
  是否已生成动态路由?
        │
    ┌───┴───┐
    │  还没有  │──────→ 调接口获取菜单数据
    └───┬───┘         → transformRoutes 转换
        │             → router.addRoute 注册
        │             → next({ ...to, replace: true })
        │ 已有
        ▼
  正常进入页面
        │
        ▼
  侧边栏根据 store 里的 routes 渲染菜单
        │
        ▼
  页面内按钮根据 permissions 做显隐控制

在代码层面,一个典型项目的文件组织大概是这样的:

src/
├── router/
│   ├── index.js          # 创建 router 实例,只注册 constantRoutes
│   ├── routes.js         # constantRoutes 和 asyncRoutes(方案二用)
│   └── permission.js     # 全局路由守卫
├── store/
│   └── modules/
│       ├── user.js       # 用户信息、token、登录/登出
│       └── permission.js # 路由/权限数据、generateRoutes
├── api/
│   └── user.js           # getUserInfo、getUserMenus 等接口
├── directives/
│   └── permission.js     # v-permission 自定义指令
├── components/
│   └── Permission.vue    # 权限组件(可选)
├── utils/
│   ├── permission.js     # hasPermission 工具函数
│   └── route-helper.js   # transformRoutes、buildTree
└── layout/
    ├── index.vue         # 整体布局
    └── Sidebar.vue       # 侧边栏菜单

六、常见问题 FAQ

Q1:退出登录后需要做什么清理?

// store/modules/user.js
async logout({ commit, dispatch }) {
  await logoutApi()               // 调后端登出接口
  commit('SET_TOKEN', '')         // 清 token
  commit('SET_USER_INFO', null)   // 清用户信息

  // 重点:重置路由!
  // Vue Router 3 没有 removeRoute,通常的做法是重新创建 router 实例
  resetRouter()

  // 清除 permission store
  dispatch('permission/resetRoutes', null, { root: true })
}

resetRouter 的实现(Vue Router 3 的经典 hack):

// router/index.js
const createRouter = () => new VueRouter({
  routes: constantRoutes
})

const router = createRouter()

export function resetRouter() {
  const newRouter = createRouter()
  router.matcher = newRouter.matcher  // 用新 matcher 替换旧的,相当于清除了动态路由
}

export default router

Vue Router 4 就优雅多了,有 router.removeRoute() 可以用。

Q2:Token 过期怎么处理?

建议在 axios 响应拦截器里统一处理:

// utils/request.js
service.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      // token 过期或无效
      // 避免多个请求同时触发多次弹窗
      if (!isRefreshing) {
        isRefreshing = true
        MessageBox.confirm('登录已过期,请重新登录', '提示', {
          confirmButtonText: '重新登录',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          store.dispatch('user/logout').then(() => {
            location.reload()   // 简单粗暴但有效:刷新页面让路由守卫重新走流程
          })
        }).finally(() => {
          isRefreshing = false
        })
      }
    }
    return Promise.reject(error)
  }
)

Q3:同一个页面需要根据权限展示不同的布局怎么办?

不要用 v-permission(它是非此即彼的),用函数方式更灵活:

<template>
  <div>
    <!-- 管理员看到完整表单 -->
    <FullForm v-if="hasRole('admin')" />
    <!-- 普通用户看到精简表单 -->
    <SimpleForm v-else />
  </div>
</template>

总结

  1. 权限分层:路由级、菜单级、按钮级、接口级,各有各的实现方式,别混在一起。
  2. 路由方案选型:小项目用 meta 守卫,中项目用前端过滤,大项目让后端返回路由表。
  3. 菜单渲染:优先"路由即菜单",除非有特殊展示需求才分离。
  4. 按钮权限:指令、组件、函数三种方式可以并存,按场景选择。
  5. 前端权限只是体验优化,后端一定要有自己的鉴权,不要把安全寄托在前端。

权限这块东西不难,但坑很多,而且大多数坑只有在刷新页面、切换角色、token 过期这些"非正常路径"才会暴露出来。所以写完权限逻辑后,一定要多测这几个场景:

  • 刷新页面后,菜单和路由是否正常
  • 直接输入 URL 访问无权限页面,是否正确拦截
  • 退出登录 → 换角色登录,菜单是否正确更新
  • Token 过期后的操作,是否平滑跳转登录页

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

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

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

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

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

WebSocket 入门:是什么、有什么用、脚本能帮你做什么

WebSocket 入门:是什么、有什么用、脚本能帮你做什么

介绍 WebSocket 是什么、和 HTTP 轮询的区别,以及用脚本做实时收发、压测、自动化监听的常见用法。


一、WebSocket 是什么

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。浏览器或脚本先通过一次 HTTP 请求「升级」到 WebSocket,之后双方可以随时互相发数据,无需再发新的 HTTP 请求。

和传统 HTTP 请求-响应 的对比:

  • HTTP:客户端发请求 → 服务端响应 → 结束;要拿新数据就得再发一次请求(轮询)。
  • WebSocket:建立连接后,服务端可以主动推,客户端也可以随时发;一条长连接,低延迟、省请求头。

所以 WebSocket 适合服务端要主动推送、或需要频繁双向通信的场景,例如实时通知、聊天、协作编辑、行情推送等。


二、有什么用:典型场景

  • 实时推送:消息通知、订单状态、告警,服务端有变化就推给前端,无需前端轮询。
  • 即时通讯 / 聊天:多端双向收发,一条连接即可。
  • 实时协作:多人编辑、光标位置同步,用 WebSocket 广播各端状态。
  • 实时数据大屏 / 监控:服务器指标、日志流、设备状态,持续推送到前端展示。
  • 在线游戏 / 互动:低延迟的指令与状态同步。

这些场景若用 HTTP 轮询,要么延迟大,要么请求多、浪费带宽;用 WebSocket 一条长连接即可。


三、程序员的脚本能拿 WebSocket 做什么

用脚本(Node、Python、浏览器控制台等)连上 WebSocket 服务,可以自动化很多「实时」相关的事情。

3.1 自动化收发与断言

  • 测试:脚本作为客户端连接你的 WebSocket 服务,按协议发送约定消息,校验服务端返回或推送内容,做接口测试、回归测试。
  • 模拟用户:模拟多端同时在线(多连接)、发消息、收消息,验证业务逻辑或压测。

3.2 压测与限流验证

  • 用脚本大量建连、并发收发,观察服务端 QPS、连接数、内存等,做简单的压力测试。
  • 验证服务端的限流、踢人、重连策略是否按预期工作。

3.3 监听与数据采集

  • 有些公开或内部服务通过 WebSocket 推送数据(如行情、日志、事件流),脚本连上后持续收消息,解析后落库、告警或做分析。
  • 配合定时任务或守护进程,实现「7×24 监听 + 落库/转发」。

3.4 与浏览器 / 现有前端协同

  • 浏览器控制台里用 new WebSocket(url) 连上后端,手动发几条消息排查协议或数据格式。
  • Node 写小工具:收 WebSocket 推送 → 转存文件、发钉钉/邮件、触发本地命令等,把「实时事件」接到你自己的脚本流水线里。

四、最小示例:浏览器 + Node 服务端

4.1 服务端(Node + ws)

pnpm add ws
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws) => {
    ws.on('message', (data) => {
        console.log('收到:', data.toString());
        ws.send('服务端回显: ' + data.toString());
    });
});

4.2 客户端(浏览器或 Node 脚本)

const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => ws.send('hello');
ws.onmessage = (e) => console.log('收到:', e.data);

浏览器里打开控制台,把上述客户端代码贴进去(或写成 HTML 里 script),即可和本机 Node 服务互通。脚本里同理,用 ws 库在 Node 里连 WebSocket,即可做上面的自动化、压测、监听等。


五、注意点与参考

  • 断线重连:网络抖动或服务重启会断连,脚本里建议做重连 + 退避,避免无限重试打爆服务端。
  • 协议与格式:WebSocket 只负责传输字节,业务层要自己约定消息格式(如 JSON、二进制),并处理粘包/分包(若用帧协议)。
  • 安全:生产环境用 wss://(TLS),并校验 Origin、做鉴权,避免被滥用。

参考

小结:WebSocket 适合「服务端主动推、双向实时通信」;用脚本可以做自动化测试、压测、监听推送、数据采集和与前端/服务联调。觉得有用欢迎点赞、收藏。

【节点】[FresnelEquation节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

菲涅耳方程节点是Unity着色器图形中一个功能强大的工具,用于模拟光线在不同介质交界处的反射和透射行为。这个节点基于物理光学原理,能够创建出更加真实和自然的材质效果,特别是在处理透明、半透明或具有复杂表面反射特性的材质时表现出色。

菲涅耳效应是现实世界中普遍存在的光学现象,描述了光线在两种不同折射率的介质交界处,反射光量与入射角之间的关系。当光线垂直入射表面时,反射最少;随着入射角增大,反射逐渐增强;当光线几乎平行于表面时,反射达到最大。这种效应在我们日常生活中随处可见,比如水面在远处看像镜子一样反射周围环境,而在近处则能看到水下的景物。

在计算机图形学中,准确模拟菲涅耳效应对于创建逼真的渲染效果至关重要。Unity的Fresnel Equation节点提供了三种不同的计算模式,每种模式适用于不同的物理场景和材质类型,让开发者能够根据具体需求选择最合适的菲涅耳计算方式。

描述

菲涅耳方程节点为材质的光学交互添加了基于物理的菲涅耳效应计算。通过该节点,开发者可以精确控制光线在材质表面的反射和透射行为,创造出更加真实的光照效果。该节点的核心功能是根据入射光线与表面法线的夹角,计算反射光线的强度比例,这一比例会随着视角的变化而动态调整。

该节点的一个关键优势是其灵活性。用户可以通过Mode下拉菜单选择不同的菲涅耳方程计算模式,每种模式都针对特定的材质类型和光学场景进行了优化。无论是简单的介电材料还是复杂的金属材质,都能找到合适的计算方式。

对于需要精确物理参数的场景,开发者可以参考refractiveindex.info网站获取各种材料的准确折射率数值。这个资源库包含了大量常见材料的折射率数据,从普通玻璃到特殊光学材料,为创建物理准确的材质提供了可靠的数据支持。

在实际应用中,菲涅耳方程节点常用于:

  • 创建真实的水面、玻璃和其他透明材质
  • 模拟金属表面的反射特性
  • 实现边缘发光效果
  • 增强材质的立体感和细节表现

端口(Schlick)

Schlick模式是菲涅耳方程节点中最常用且计算效率最高的选项,它基于Christophe Schlick提出的近似公式,在保证物理准确性的同时提供了优秀的性能表现。

输入端口

  • f0:Vector{1, 2, 3}类型输入端口,表示表面在正入射(0度角)时的基础反射率。对于大多数介电材料(如玻璃、水、塑料等),这个值通常在0.02到0.08之间。不同的材质具有不同的f0值:
    • 水:约0.02
    • 塑料:约0.04
    • 玻璃:约0.05-0.08
    • 宝石:可能高达0.1-0.2
  • DotVector:Float类型输入端口,接收表面法线与视线方向或光线方向的点积结果。这个值实际上代表了cosθ,其中θ是入射角。当视线与表面垂直时,DotVector接近1;当视线与表面平行时,DotVector接近0。

输出端口

  • Fresnel:与f0相同类型的输出端口,输出计算得到的菲涅耳系数。这个系数描述了在特定入射角下,被反射的光线比例。剩余的光线则会进入材质内部(透射)或被吸收。

技术细节

Schlick近似公式的数学表达式为:

F(θ) = f0 + (1 - f0) × (1 - cosθ)⁵

其中:

  • F(θ)是在入射角θ时的菲涅耳反射率
  • f0是正入射时的基础反射率
  • cosθ是入射角的余弦值(即DotVector输入)

这个近似公式的优势在于其计算简洁且结果与精确的菲涅耳方程非常接近,特别适合在实时渲染中使用。五次方的计算可以通过简单的乘法操作高效完成,避免了复杂的三角函数计算。

应用示例

假设我们要创建一个水的材质,可以设置f0为0.02,然后将DotVector连接到表面法线与视线方向的点积结果。当玩家从不同角度观察水面时,菲涅耳效应会自动调整反射强度:

  • 垂直向下看水面时,反射较弱,可以看到水下内容
  • 从侧面远眺水面时,反射强烈,水面像镜子一样

端口(Dielectric)

Dielectric模式专门用于计算两个介电材料交界处的菲涅耳效应。介电材料是指不导电的材料,如玻璃、水、塑料等,它们具有实数的折射率。

输入端口

  • IOR Source:Vector类型输入端口,表示光源所在介质的折射率。在大多数情况下,这是空气的折射率,约为1.0003,通常简化为1.0。
  • IOR Medium:Vector类型输入端口,表示光线折射进入的介质的折射率。这是目标材质的折射率,不同材料有不同的值:
    • 水:1.33
    • 玻璃:1.5-1.9
    • 钻石:2.42
  • DotVector:Float类型输入端口,与Schlick模式相同,表示表面法线与光线方向的点积(cosθ)。

输出端口

  • Fresnel:输出计算得到的菲涅耳系数,描述在两个介电材料交界处的反射光比例。

技术细节

Dielectric模式使用完整的菲涅耳方程来计算反射率,考虑了光线在两种不同折射率介质交界处的行为。计算涉及s偏振和p偏振光的平均反射率,公式较为复杂,但能够提供比Schlick近似更精确的结果,特别是在折射率差异较大的情况下。

精确的介电材料菲涅耳方程包括:

  • 计算相对折射率:η = IOR_medium / IOR_source
  • 根据斯涅尔定律计算折射角
  • 分别计算s偏振和p偏振光的反射系数
  • 取两者的平均值作为总反射率

应用场景

Dielectric模式适用于需要高精度光学模拟的场景:

  • 光学透镜系统
  • 精确的液体模拟
  • 专业的玻璃材质渲染
  • 科学可视化应用

例如,在模拟水族馆中的观察体验时,可以使用Dielectric模式准确计算光线从空气到玻璃、再从玻璃到水的多次反射和透射行为。

端口(DielectricGeneric)

DielectricGeneric模式是三种模式中最复杂且功能最全面的选项,它能够处理介电材料与金属材料之间的菲涅耳效应计算。金属材料具有复折射率,即折射率包含虚数部分,这代表了材料对光线的吸收特性。

输入端口

  • IOR Source:Vector类型输入端口,表示光源所在介质的折射率,通常是空气(约1.0)。
  • IOR Medium:Vector类型输入端口,表示折射介质的实部折射率。对于金属材料,这个值通常小于介电材料。
  • IOR MediumK:Vector类型输入端口,表示折射介质的虚部折射率,也称为消光系数。这个值代表了材料对光线的吸收能力:
    • 对于非金属材料,通常为0或接近0
    • 对于金属材料,这个值较大,表示强烈的光吸收
    • 金、银、铜等金属具有特定的IOR MediumK值
  • DotVector:Float类型输入端口,表示表面法线与光线方向的点积(cosθ)。

输出端口

  • Fresnel:输出计算得到的菲涅耳系数,描述了在介电材料与金属交界处的反射光比例。

技术细节

DielectricGeneric模式使用适用于金属的菲涅耳方程,考虑了复折射率的完整形式。金属的折射率可以表示为η + iκ,其中η是实部,κ是虚部(对应IOR MediumK)。

当IOR MediumK值为0时,DielectricGeneric模式会退化为标准的Dielectric模式,因为此时材料没有吸收特性,表现为纯粹的介电材料。

金属的菲涅耳反射具有独特的特性:

  • 反射率通常比介电材料高
  • 反射率随波长的变化而变化,这导致了金属特有的颜色反射
  • 即使在正入射时,反射率也较高

应用场景

DielectricGeneric模式专门用于处理包含金属的复杂光学场景:

  • 金属表面的清漆涂层
  • 镀膜玻璃
  • 金属合金材料
  • 具有金属光泽的汽车漆

例如,在模拟汽车油漆时,可以使用DielectricGeneric模式:底层是金属颜料(具有复折射率),上层是透明的清漆涂层(介电材料)。这种结构会产生独特的视觉效果,既有金属的鲜艳反射,又有清漆的光滑表面。

控制

菲涅耳方程节点的核心控制是通过Mode下拉菜单选择不同的计算模式。每种模式针对特定的物理场景和材质类型,了解它们的特点和适用场景对于创建真实的材质效果至关重要。

Schlick模式

Schlick模式是基于Christophe Schlick在1994年提出的菲涅耳近似公式。这是一种在实时计算机图形学中广泛使用的方法,因其在准确性和计算效率之间的良好平衡而受到青睐。

特点:

  • 计算效率高,适合实时渲染
  • 对于大多数常见材料提供足够准确的结果
  • 只需要一个参数(f0)即可控制
  • 特别适合空气与介电材料之间的交互

适用场景:

  • 游戏中的常规材质渲染
  • 需要高性能的实时应用
  • 初步材质设计和原型制作
  • 移动平台优化

局限性:

  • 对于折射率差异极大的材料可能不够准确
  • 不适用于金属材料
  • 在极端角度下可能有微小误差

Dielectric模式

Dielectric模式使用完整的菲涅耳方程来计算两个介电材料交界处的反射行为。这种方法提供了比Schlick近似更高的准确性,特别是对于光学精度要求较高的场景。

特点:

  • 基于物理的精确计算
  • 适用于任意两种介电材料的组合
  • 能够处理复杂的多层光学系统
  • 结果更加物理准确

适用场景:

  • 专业的光学模拟
  • 科学可视化和工程应用
  • 高质量的电影和动画制作
  • 需要精确光学特性的材质

局限性:

  • 计算复杂度高于Schlick模式
  • 不适用于金属材料
  • 需要更多的输入参数

DielectricGeneric模式

DielectricGeneric模式是最全面的菲涅耳计算选项,能够处理介电材料与金属材料之间的复杂光学交互。金属材料具有复折射率,这导致其光学行为与介电材料有显著不同。

特点:

  • 支持复折射率计算
  • 能够准确模拟金属的光学特性
  • 适用于多层材料系统
  • 提供最高级别的物理准确性

适用场景:

  • 金属材质的精确渲染
  • 涂层材料的光学模拟
  • 高级材质研究和发展
  • 电影级视觉效果制作

局限性:

  • 计算复杂度最高
  • 需要理解和设置复折射率参数
  • 对于简单场景可能过于复杂

重要提示:IORMediumK值设置为0时,DielectricGeneric模式会自动退化为Dielectric模式,因为此时材料没有吸收特性,相当于纯粹的介电材料。这一特性使得DielectricGeneric模式可以灵活地处理各种材料类型,从完全透明的玻璃到完全不透明的金属。

生成代码示例

理解菲涅耳方程节点在底层是如何实现的,有助于开发者更好地使用和优化他们的着色器。以下是该节点在不同模式下可能的代码实现示例,这些示例展示了节点背后的数学计算和算法逻辑。

Schlick模式代码实现

HLSL

void Unity_FresnelEquation_Schlick(out float Fresnel, float cos0, float f0)
{
    // Schlick近似公式实现
    Fresnel = f0 + (1.0 - f0) * pow(1.0 - cos0, 5.0);
}

这段代码实现了经典的Schlick近似公式,通过一个五次方项来模拟菲涅耳效应随入射角的变化。这种实现非常高效,只需要基本的算术运算,适合在性能敏感的场景中使用。

Dielectric模式代码实现

HLSL

void Unity_FresnelEquation_Dielectric(out float3 Fresnel, float cos0, float3 iorSource, float3 iorMedium)
{
    // 计算相对折射率
    float3 eta = iorMedium / iorSource;

    // 使用完整的菲涅耳方程计算反射率
    Fresnel = F_FresnelDielectric(eta, cos0);
}

Dielectric模式的实现更为复杂,涉及完整的菲涅耳方程计算。这里的F_FresnelDielectric函数会处理s偏振和p偏振光的反射计算,并返回它们的平均值作为总反射率。

DielectricGeneric模式代码实现

HLSL

void Unity_FresnelEquation_DielectricGeneric(out float3 Fresnel, float cos0, float3 iorSource, float3 iorMedium, float3 iorMediumK)
{
    // 计算归一化的复折射率
    float3 eta = iorMedium / iorSource;
    float3 kappa = iorMediumK / iorSource;

    // 使用导体菲涅耳方程
    Fresnel = F_FresnelConductor(eta, kappa, cos0);
}

DielectricGeneric模式的实现最为复杂,需要处理复折射率的数学运算。F_FresnelConductor函数实现了适用于金属(导体)的菲涅耳方程,考虑了材料的吸收特性对反射行为的影响。

实际应用中的代码整合

在实际的着色器开发中,菲涅耳计算通常与其他光照计算结合使用。以下是一个简单的示例,展示如何将菲涅耳效应整合到基础光照模型中:

HLSL

void SurfaceFunction_WithFresnel(
    out float4 Albedo,
    out float3 Normal,
    out float Metallic,
    out float Smoothness,
    float3 viewDir,
    float3 worldNormal)
{
    // 计算视线方向与法线的点积
    float NdotV = saturate(dot(normalize(viewDir), normalize(worldNormal)));

    // 使用Schlick近似计算菲涅耳效应
    float fresnel = 0.04 + (1.0 - 0.04) * pow(1.0 - NdotV, 5.0);

    // 将菲涅耳效应应用于反射强度
    float reflectionStrength = fresnel * Smoothness;

    // 后续的光照计算...
}

性能优化考虑

在使用菲涅耳方程节点时,考虑性能优化是很重要的:

  • 模式选择:根据实际需求选择最简单的模式,Schlick模式通常性能最佳
  • 参数简化:对于颜色变化不大的材质,可以使用标量而非向量参数
  • 计算频率:在顶点着色器中计算近似值,在片段着色器中细化
  • 查找表:对于复杂的菲涅耳计算,可以考虑使用预计算的查找表

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

手写Promise,从测试用例的角度理解

最近在补基础,发现Promise里面有挺多东西需要理解的,函数绕来绕去的

先来一个都可看懂的代码框架,剩余的慢慢补充

const PENDING = "pending";
const FULFILLED = "fulfilled";
const REJECTED = "rejected";

class MyPromise {
  constructor(executor) {
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;

    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;
        this.onFulfilledCallbacks.forEach((fn) => fn());
      }
    };
    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;
        this.onRejectedCallbacks.forEach((fn) => fn());
      }
    };
    executor(resolve, reject);
  }
 }

首先补充then方法,由于需要链式调用,所以返回的同样是Promise对象

then(onFulfilled, onRejected) {

    const promise2 = new MyPromise((resolve, reject) => {
      const handleCallback = (callback, value, resolve, reject) => {
        queueMicrotask(() => {
          try {
            const x = callback(value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      };

      if (this.status === FULFILLED) {
        handleCallback(onFulfilled, this.value, resolve, reject);
      } else if (this.status === REJECTED) {
        handleCallback(onRejected, this.reason, resolve, reject);
      } else if (this.status === PENDING) {
        this.onFulfilledCallbacks.push(() =>
          handleCallback(onFulfilled, this.value, resolve, reject),
        );
        this.onRejectedCallbacks.push(() =>
          handleCallback(onRejected, this.reason, resolve, reject),
        );
      }
    });

    return promise2;
  }

handleCallback是一个工具函数,用于将用于传进来的函数进行包装,这里在then中的回调加了queueMicrotask包装了下,使它变成一个异步的任务,为什么呢 考虑两种情况

情况1: new MyPromise中立刻resolve,也就是同步的情况

const myPromise = new MyPromise((resolve, reject) => {
  console.log("状态pending")
  resolve('成功调用resolve')
})

myPromise.then(res => {
  console.log(res);
}, err => {
  console.log(err);
})

对于以上例子,resolve中会立刻执行回调队列中的函数,但是实例对象的then方法这个时候还没调用呢,里面是空的。

然后执行then,这个时候状态已经确定,立即执行handleCallback。

情况2:

const myPromise = new MyPromise((resolve, reject) => {
  console.log("状态pending")
  setTimeout(() => { resolve("成功"); // 【异步执行】主线程空闲后才调用 resolve }, 0)
})

myPromise.then(res => {
  console.log(res);
}, err => {
  console.log(err);
})

这个时候,resolve没有立即调用,因此会先调用then,将任务放到队列里,等待resolve后执行handleCallback。

queueMicrotask保证了用户的回调不会阻塞同步代码的执行。

then中还有一个情况,就是then中什么也没有传,最后值还需要默认传递

const promise = new MyPromise((resolve, reject) => {
  resolve("success");
});
promise
  .then()
  .then()
  .then()
  .then(
    (value) => console.log(value),
    (err) => console.log(err)
  );

只需要加一个默认回调即可

onFulfilled =
      typeof onFulfilled === "function" ? onFulfilled : (value) => value;
onRejected =
  typeof onRejected === "function"
    ? onRejected
    : (reason) => {
        throw reason;
      };

分析一下调用,一旦顶部的promise的resolve调用,不管是resolve同步还是异步的被调用了,都会导致handleCallback被调用,最终onFulfilled被调用,并将值返回。

对于上面的例子, 一共有p p1 p2 p3 p4

P的resolve后,p1是一个新的promise,内部调用(value) => value,resolve后返回值为value; p2进来后发现p1是resolve(value)的敲定状态,也调用(value) => value,将值传递下去。

还剩下一个核心函数resolvePromise,用来处理返回值不是普通值的情况。

用例1:

// 使用 thenable 对象
const myThenable = {
  then: function (resolve, reject) {
    setTimeout(() => {
      resolve("success myThenable");
      reject("fail myThenable");
    }, 1000);
  },
};
new MyPromise((resolve, reject) => {
  resolve("success");
})
  .then((value) => {
    console.log(value);
    return myThenable;
  })
  .then((value) => console.log(value));

这个用例中返回了一个对象,该对象有then方法,

用例2:

// 使用 thenable 对象
new MyPromise((resolve, reject) => {
  resolve("success");
})
  .then((value) => {
    console.log(value);
    return new MyPromise((resolve) => setTimeout(() => resolve(num + 5), 1000));;
  })
  .then((value) => console.log(value));

这个用例返回了一个Promise对象。

当然还有更复杂的,返回嵌套的情况,可以使用递归解决,直到遇到一个普通值再结束。

上面两个用例有点难以理解,先来看普通的Promise对象:

new Promise((resolve, reject) => {
  // 这个函数就是 executor,它会被立即同步执行
  setTimeout(() => {
    resolve('done'); // 调用 resolve 改变 Promise 状态
  }, 1000);
});
  • 作用executor 负责启动异步操作,并在操作完成时调用 resolve 或 reject 来改变 Promise 的状态。
  • 特点executor 是立即执行的,并且由 Promise 构造函数传入 resolve 和 reject 两个函数。

再来看then方法:

promise.then(
  value => console.log(value), // 成功回调
  error => console.error(error) // 失败回调
);
  • 作用:注册当 Promise 状态变为 fulfilled 或 rejected 时执行的回调。
  • 特点then 方法不会主动调用 resolve 或 reject,它只是注册监听。它返回一个新的 Promise,用于链式调用。

Thenable 对象中的 then 方法

const myThenable = {
  then: function (resolve, reject) {
    // 这个 then 方法类似于 executor
    setTimeout(() => {
      resolve("success myThenable"); // 主动调用 resolve
      reject("fail myThenable");      // 也可以调用 reject
    }, 1000);
  },
};
  • 作用:当 Promise 机制(例如 Promise.resolve(myThenable))遇到 thenable 对象时,会自动调用其 then 方法,并传入两个回调(resolvePromise 和 rejectPromise 的包装函数)。thenable 内部的 then 方法可以像 executor 一样启动异步操作,并在适当时候调用传入的 resolve 或 reject 来通知结果。

  • 特点

    • 这个 then 方法承担了启动异步操作并触发状态改变的责任,与 Promise 构造函数的 executor 角色一致。
    • 它和 Promise 的 then 方法名称相同,但语义完全不同:前者是操作发起者,后者是结果监听者

其实写法上也可以看出来,自定义对象的then方法,相当于构造方法了,也是立即执行的,因为两者都叫 then,而且在 ES6 之前,许多 Promise 库(如 Q、Bluebird)的 thenable 对象确实用 then 方法来包装异步操作。但在原生 Promise 中,这两个角色被清晰地分开:

  • 构造函数中的 executor:启动操作 + 触发完成。
  • 原型上的 then 方法:注册回调 + 返回新 Promise。

而 thenable 将“启动操作”和“接收回调”合并到了同一个 then 方法中。当 Promise 处理 thenable 时,它相当于把 thenable 的 then 方法当作一个 executor 来使用,传入的 resolve 和 reject 就是用来改变最终 Promise 状态的函数。

原生 Promise 流程

  1. new Promise(executor) → executor 立即执行,启动异步任务。
  2. 异步任务完成 → 调用 resolve(或 reject)→ Promise 状态改变。
  3. 后续调用 then 注册回调 → 回调会在状态改变后被调用。

Thenable 被 Promise 处理时的流程

  1. Promise.resolve(myThenable) → 检测到 thenable。
  2. Promise 内部调用 myThenable.then(onFulfilled, onRejected),其中 onFulfilled 和 onRejected 是 Promise 提供的包装函数。
  3. myThenable.then 方法内部可以启动异步操作,并在适当时调用 onFulfilled(即传入的 resolve 函数)或 onRejected(即传入的 reject 函数)。
  4. 调用 onFulfilled 或 onRejected 会最终改变由 Promise.resolve 返回的那个 Promise 的状态。

所以,myThenable.then 相当于 Promise 构造函数的 executor,而 Promise.resolve(myThenable) 相当于 new Promise(executor)

根据上面的分析,可以来写一下resolvePromise这个函数了,首先是MYPromise 实例:

if (x instanceof MYPromise) {
    // 根据 x 的状态调用 resolve 或 reject
    x.then(
      y => {
        resolvePromise(promise2, y, resolve, reject);
      },
      reason => {
        reject(reason);
      }
    );
  }

递归调用返回值的then方法,直到返回值是一个普通值,我们再resolve掉。

对于myThenable

    // 获取 x 的 then 方法
      const then = x.then;
      if (typeof then === 'function') { // 如果 then 是函数
        // 使用 x 作为上下文调用 then 方法
        then.call(
          x,
          y => { // 成功回调
            if (called) return; // 如果已经调用过,直接返回
            called = true;
            // 递归处理 y
            resolvePromise(promise2, y, resolve, reject);
          },
          reason => { // 失败回调
            if (called) return; // 如果已经调用过,直接返回
            called = true;
            reject(reason);
          }
        );
      }

有几个需要注意的点,第一这里也是调用了then方法,并且写法上和原生的有点类似,都是传入了一个回调。为什么呢,上面说了myThenable的then有点像构造方法,接收的是resolve,Promise的then接收了onFulfilled回调,对于这俩回调,resolvePromise 在处理的时候都调用了resolvePromise。

resolve 与 onFulfilled 的区别

角色 来源 作用 被谁调用
resolve Promise 构造函数(executor 的第一个参数) 将 Promise 状态从 pending 变为 fulfilled,并设置内部 value。 由用户(或异步任务完成时)主动调用。
onFulfilled then 方法的第一个参数 当 Promise 变为 fulfilled 时被自动调用,接收该 Promise 的 value 作为参数,用于处理结果。 由 Promise 内部机制在状态变更后调用。

简言之,resolve 是“写”操作(触发状态变更),onFulfilled 是“读”操作(响应状态变更)

当 Promise 引擎遇到一个 thenable 对象(如 myThenable)时,它会调用该对象的 then 方法,并传入两个包装函数:

then.call(x,
  (y) => { /* 类似 resolve 的角色 */ },
  (r) => { /* 类似 reject 的角色 */ }
);

这个第一个包装函数(通常记为 resolvePromise 的包装)确实在语义上类似于 executor 中的 resolve——它被 thenable 内部的异步操作调用,用来传递成功值。但区别在于:

  • 并不直接改变最终 Promise 的状态,而是先经过 resolvePromise 的递归解析,最终才可能调用最外层的 resolve
  • 它的任务是接收 thenable 产生的成功值 y,然后启动递归解析过程。

所以,在 thenable 处理中,我们传入的成功回调模拟了 resolve 的行为,但实际上是解析流程的起点

resolve拿到值,处理then中回调,该回调返回不是普通值,递归处理该值。

变成了,resolve拿到值,该值不是普通值,递归处理该值。

迈向全栈新时代:SSR/SSG 原理、Next.js 架构与 React Server Components (RSC) 实战

随着 React 19 的发布和 Next.js 15 的成熟,React 生态正经历着从“纯客户端渲染(CSR)”向“服务端组件(RSC)”的范式转移。传统的 SSR(服务端渲染)和 SSG(静态站点生成)正在与 RSC 融合,形成一种全新的混合渲染架构。

本文将深入解析 SSR/SSG 的核心原理,对比 Next.js 的演进路线,并重点探讨 React Server Components 如何重构前后端边界,带来性能与开发体验的双重飞跃。

一、渲染模式演进:从 CSR 到 RSC

1.1 传统模式回顾

  • CSR (Client-Side Rendering) :首屏加载慢,SEO 不友好,但交互流畅。
  • SSR (Server-Side Rendering) :首屏快,SEO 好,但每次请求都需要服务端重新渲染,服务器压力大,且存在“注水(Hydration)”时的交互卡顿。
  • SSG (Static Site Generation) :构建时生成 HTML,速度最快,但无法处理实时数据,构建时间长。

1.2 React Server Components (RSC) 的突破

RSC 不是简单的 SSR 升级,而是一种组件传输协议的革新

  • 零 Bundle 体积:服务端组件的代码完全不在客户端打包,只在服务器运行。这意味着你可以直接在组件中导入庞大的第三方库(如 Markdown 解析器、日期处理库),而不会增加客户端 JS 体积。
  • 直接访问后端资源:服务端组件可以直接查询数据库、读取文件系统,无需经过 API 层。
  • 流式传输(Streaming) :页面可以分块加载,用户无需等待整个页面生成即可看到部分内容。

二、Next.js 架构:RSC 的最佳实践载体

Next.js 是目前实现 RSC 最成熟的框架。在 Next.js 13/14/15 中,渲染模型发生了根本性变化。

2.1 客户端组件 vs 服务端组件

特性 Server Components (默认) Client Components ('use client')
运行环境 仅服务端 服务端 (预渲染) + 客户端 (交互)
数据访问 直接连接 DB/API 通过 fetch 或 Props 获取
Bundle 大小 0 KB 包含在 JS Bundle 中
交互能力 无 (onClick 等无效) 完整支持 (State, Effects, Listeners)
指令 无 (默认) 顶部添加 'use client'

2.2 实战:构建一个博客详情页

// app/blog/[slug]/page.jsx (Server Component)
import { db } from '@/lib/db';
import Comments from './comments'; // 可能是 Client Component

export default async function BlogPost({ params }) {
  const { slug } = await params;
  // 直接在后端查询数据库,无需 API 接口
  const post = await db.post.findUnique({ where: { slug } });

  if (!post) return <div>Not Found</div>;

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      
      {/* 评论区需要交互,交给客户端组件 */}
      <Comments postId={post.id} />
    </article>
  );
}
// app/blog/[slug]/comments.jsx (Client Component)
'use client';

import { useState } from 'react';

export default function Comments({ postId }) {
  const [comments, setComments] = useState([]);
  
  // 客户端发起数据请求或使用 SWR/React Query
  // ...

  return (
    <section>
      <h3>Comments</h3>
      {/* 渲染评论列表和输入框 */}
    </section>
  );
}

三、关键概念:流式 SSR 与 选择性注水

3.1 流式 SSR (Streaming SSR)

在传统 SSR 中,用户必须等待整个 HTML 生成完毕才能看到页面。而在 Next.js + RSC 中,HTML 可以以流(Stream) 的形式发送。

  • 先发送骨架屏或静态部分。
  • 异步数据加载完成后,再发送剩余部分。
  • 配合 <Suspense> 组件,实现局部加载状态。
// 使用 Suspense 包裹异步组件
<Suspense fallback={<LoadingSkeleton />}>
  <HeavyDataComponent />
</Suspense>

3.2 选择性注水 (Selective Hydration)

React 18+ 允许优先注水用户正在交互的区域。如果用户在一个尚未完全注水的页面上点击了按钮,React 会优先处理该按钮的注水和事件,而不是按顺序等待整个树完成注水。这极大地提升了感知性能。

四、未来展望:React 19 与 Actions

React 19 引入了 Actions,进一步模糊了前后端界限。你可以在 Server Component 中直接定义表单提交逻辑,并通过 useFormStatus 在客户端获取提交状态,无需手动编写 fetch 和处理 loading 状态。

// Server Action
async function updateItem(formData) {
  'use server';
  const id = formData.get('id');
  await db.update(id, { status: 'done' });
  revalidatePath('/dashboard'); // 自动重新验证数据
}

// Client Component
function UpdateButton({ id }) {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending} formAction={updateItem.bind(null, { id })}>
      {pending ? 'Updating...' : 'Update'}
    </button>
  );
}

结语

RSC 和 Next.js 的结合标志着 React 进入了全栈开发的新纪元。通过将重型逻辑移至服务端,我们不仅减少了客户端负担,还简化了数据流。对于现代前端工程师而言,掌握“何时使用 Server Component,何时使用 Client Component”的边界判断能力,将是构建高性能应用的关键。

【浏览器】这几点必须懂

前端人员你对浏览器你不知道的操作


公众号:AI小揭秘;

在很多前端的认知里,浏览器更像是一个「用来跑页面的容器」:写好 HTML/CSS/JS,把代码丢进去,看到页面渲染出来,功能能点得动,事情就结束了。
但如果你只把浏览器当成一个「展示工具」,那就太低估它了——现代浏览器,本质上是一套高度工程化的「分布式操作系统 + 调度中心」,它对页面做的事,远远超出你在 DevTools 里能看到的那一点点。

本文试图从工程视角,拆解几个前端在日常工作中很少深入思考、却又极大影响性能、稳定性与安全性的浏览器「隐秘操作」。理解这些机制,会直接改变你写代码的方式。


一、浏览器其实在「编排你的代码」,而不是简单执行

大部分前端对 JS 的直觉是「单线程、事件循环」,但这只是冰山一角。真正的浏览器内核,会在后台对你的代码进行一系列「编排」:

  • 任务分级与优先级调度

    • 微任务(Microtask)和宏任务(Macrotask)只是 JS 层面可见的划分,底层还有:
      • 用户输入相关任务(如点击、键盘)通常优先级更高,以保证交互流畅;
      • 渲染相关任务会被尽量安排在一帧的合适时间段;
      • 低优先级任务(如空闲回调 requestIdleCallback)则在「系统有空闲」时才会被调度。
    • 你的某段「看似普通」异步代码,可能会因为任务优先级被延迟几十到上百毫秒,直接影响首屏体验和交互体验。
  • 隐形「节流」与「去抖」

    • scrollresize 等高频事件,浏览器在很多实现中已经做了底层优化:
      • 有的内核会在合适时机合并事件;
      • 有的会对触发频率做软限制;
    • 这意味着:即使你什么优化都不做,浏览器也在帮你兜底。但如果你配合合理的节流/去抖策略,就能让系统调度和业务逻辑形成「合力」,达到更顺滑的体验。

可以这样理解:
浏览器不是「按顺序执行你的 JS」,而是在「根据全局状态,动态排程你的 JS」。
你写的每一个 setTimeout、每一次 DOM 操作,背后都有调度器在重新计算「什么时候做、先做谁」。

深一度:长任务、INP 与你可用的调度 API

  • 长任务(Long Task)与 50ms 红线
    主线程上连续执行超过约 50ms 的 JS,会被视为「长任务」。在这段时间内,浏览器无法及时响应点击、键盘等输入,也无法插入样式计算与布局,结果就是可交互延迟(INP)变差、帧率掉下去
    所以「少写慢逻辑」之外,更要主动拆任务、让出主线程:用 setTimeout(fn, 0)queueMicrotask 把大块逻辑拆成小段,或在非关键路径上用 requestIdleCallback 延后执行。

  • scheduler.postTask()(优先级任务 API)
    部分浏览器已支持 Prioritized Task Scheduling:你可以显式把任务挂到 user-blockinguser-visiblebackground 队列,让调度器在「有输入或要渲染」时优先执行高优先级任务,从而减少 INP 和卡顿。
    这是「和浏览器调度器对齐」的写法,而不是盲目用 setTimeout

  • navigator.scheduling.isInputPending()
    用于在长循环里主动检查「用户是否正在等点击/输入」。若返回 true,你可以先 yield(用 setTimeoutpostTask 让出主线程),处理完输入后再继续,从而避免「算到一半用户点不动」的体验。
    适合在解析大 JSON、做复杂计算时插入检查点,实现「可中断」的长任务。


二、你以为的「DOM 更新」,其实是一连串「图的变换」

从直觉上看,修改 DOM 就是「改某个节点的属性或样式」,但在浏览器内部,这是一次对「多张图」的变换过程:

  • DOM Tree(结构图)

    • 你写的 HTML 最终被解析成 DOM Tree;
    • 插入/删除节点,是在这棵树上做结构性变更。
  • Style Tree / Style Rules(样式图)

    • CSS 选择器会被编译成一套高效匹配结构;
    • 浏览器在 DOM Tree 上走访,生成每个节点的计算样式;
    • 复杂的选择器(如大量的后代选择器)会直接增加匹配成本。
  • Layout Tree(布局图)

    • 有些节点不会参与布局(如 display: none),有些会生成匿名盒子;
    • 布局树决定了「谁占多少空间、相对谁排列」。
  • Layer Tree(图层图)

    • 开启硬件加速、position: fixedtransform 等都会影响是否生成独立图层;
    • 图层过多会增加合成与内存开销,但图层过少又会加重大范围重排重绘。

当你写出一行看似简单的代码,例如:

box.style.width = box.offsetWidth + 10 + 'px';

很可能触发的是:

  1. 强制同步布局(为拿到 offsetWidth,布局树需要最新状态);
  2. 更新布局树;
  3. 可能的重绘与图层合成。

高阶前端真正做的是:
不是「避免重排」这么粗糙,而是有意识地控制这些图的变换频率和范围,让浏览器的图结构变化更可预测、更局部化。

深一度:关键渲染路径、合成线程与「可隔离」的 CSS

  • 从解析到像素的完整管线
    一次「从 HTML 到屏幕」的流程大致是:解析 HTML → 构建 DOM → 解析 CSS 并计算样式 → 生成布局树(Layout Tree)→ 分层(Layer)→ 绘制(Paint,产出绘制列表)→ 光栅化(Rasterize)→ 合成(Composite)
    其中 Layout → Paint 在主线程,Rasterize 与 Composite 往往在单独的合成线程/GPU 上。你改的若是只影响「合成层」的属性(如 transformopacity),浏览器可以跳过 Layout 和 Paint,只在合成层做变换,这就是「仅合成」动画便宜的原因。

  • 强制同步布局与「读写分离」
    在 JS 里先布局相关属性(如 offsetWidthgetComputedStyle),再样式,再读、再写……会迫使浏览器在每次读之前都先完成布局,形成多次「强制同步布局」,俗称 layout thrashing
    正确做法是批量读、再批量写:一轮循环里只读不写,下一轮只写不读,或先用变量存下要读的值再统一写,把「读-写」交错改成「读读读-写写写」。

  • content-visibilitycontain
    content-visibility: auto 让视口外的块在未进入视口前不参与布局与绘制,相当于把「图的变换」推迟到需要时再做,长列表、长文档的首屏和滚动性能会明显受益。
    contain: layout / paint / strict 则告诉浏览器「该子树与外界在布局/绘制上可隔离」,引擎可以据此做更激进的裁剪与跳过,减少大范围重排重绘。
    二者都是「用声明式方式缩小浏览器要处理的图的范围」。

  • will-change 的双刃剑
    提前声明 will-change: transform 可以让浏览器提前开层、优化合成路径;但滥用会制造大量合成层,增加内存与合成开销。只对即将发生动画的少量元素、在动画前短暂设置、动画结束后移除,才是可取的用法。


三、浏览器在悄悄「预判你的网络请求」

现代浏览器早已不满足「你请求什么我就拿什么」,而是在不断做「提前一步」的预测与优化:

  • 预连接 / 预解析(Preconnect / DNS Prefetch / Preload / Prefetch)

    • 浏览器可能会根据历史记录、<link rel="preload"> / <link rel="prefetch"> 等信息,在你真正发起请求前就:
      • 完成 DNS 解析;
      • 建立 TCP/TLS 连接;
      • 甚至提前拉取静态资源。
    • 对前端来说,你加的每一个 <link rel="...">,都是在向浏览器的「预测引擎」喂信号。
  • HTTP/2 / HTTP/3 多路复用背后的流量调度

    • 在同一个连接里,多路复用多个请求,浏览器和服务器之间会协商优先级;
    • 某些静态资源(如 CSS)会被自动视为更高优先级;
    • 你随意拆分 bundle、细分 chunk 的行为,实际上在和底层的优先级系统互动。
  • Service Worker 与本地「微型边缘节点」

    • 当你注册 Service Worker,浏览器会在本地帮你搭一个「轻量代理」:
      • 把一部分请求劫持到本地缓存;
      • 控制离线策略、缓存更新策略;
      • 实现近似于「前端自定义的边缘节点」效果。

换句话说:
浏览器早已不是被动地等待你的 fetch 调用。
你写下的每一个 <link>、每一段缓存策略、每一个 Service Worker 逻辑,都在和浏览器的「请求预测」机制合作或对抗。

深一度:preload / prefetch / preconnect 区别与 fetchpriority、103 Early Hints

  • preload / prefetch / preconnect 各管什么

    • <link rel="preload">:当前页面一定会用的资源,浏览器会以高优先级尽快拉取,不阻塞解析。适合首屏关键 CSS/字体/主 bundle。
    • <link rel="prefetch">下一页可能用的资源,浏览器在空闲时预取,优先级较低。适合下一页需要的脚本或数据。
    • <link rel="preconnect">:只建立与目标源头的 DNS + TCP + TLS,不拉具体 URL,适合马上要发多个请求的第三方域,减少首请求的握手延迟。
      用错类型会导致关键资源被当成「低优先级」或浪费带宽在不会用到的 prefetch 上。
  • fetchpriority="high" 与资源优先级
    <img><link><script> 上的 fetchpriority="high"(或 low)会直接影响该请求在浏览器调度器里的优先级,从而影响与同页其他请求的竞争顺序。LCP 大图、首屏关键脚本可以显式标 high,首屏外的图片可标 low,让浏览器更智能地分配带宽与连接。

  • 103 Early Hints
    服务器可以在主响应(200)之前先发 103 Early Hints,在响应头里带上 Link: rel=preload; ...。浏览器收到 103 就会提前开始预加载,而不必等 HTML 完整返回再解析 <link>,首屏关键资源的「提前量」更大,对 TTFB 较长的站点尤其有用。


四、标签页之间,其实在共享一套「资源生态」

常被忽略的一点是:浏览器不是只在意当前这个 Tab,它在意的是整个「会话范围」的资源整体。

  • 进程与线程的资源复用

    • 同一站点下的多个标签页,往往会被智能地安排在同一进程或相关进程中,以减少开销;
    • JS 引擎、JIT 编译结果、部分缓存数据,都可能被多个页面间复用。
  • 内存压力下的「静默回收」

    • 当系统内存吃紧,浏览器可能会:
      • 降级某些后台标签页的优先级;
      • 主动回收不可见标签页的某些内存占用;
      • 对长时间不活动的页面做「冻结」或「休眠」。
    • 这就是为什么你有时切回某些标签页,会看到页面「重新加载」或「重新渲染」。
  • 后台标签页的时间片与计时器限制

    • 在多数内核中,后台标签页的 setTimeout / setInterval 频率会被限制;
    • requestAnimationFrame 在非可见页面中可能根本不会执行;
    • 这些都是浏览器为了全局资源平衡做的「上帝视角」调度。

对前端工程的启示:

  • 不要假设「你的页面永远拥有一个稳定的执行环境」;
  • 要接受「浏览器随时可以因为整体资源考量而牺牲你」这一事实;
    • 因此在关键流程上,要设计好:
    • 状态持久化(LocalStorage / IndexedDB / 服务端状态);
    • 幂等操作(页面被重载、接口被重新请求时不会引发灾难);
    • 重试与恢复机制。

深一度:页面生命周期、冻结/丢弃与 bfcache

  • Page Lifecycle API:freeze / resume / discard
    通过 document.visibilityStatevisibilitychange,以及(在支持的浏览器中)生命周期状态,可以知道页面是 activepassive(可见但未聚焦)、hidden(不可见)还是即将被 frozen(冻结)或 discarded(丢弃)。
    冻结时计时器、RAF 会被限频或暂停;丢弃时整个页面被卸载以省内存,用户再切回来会重新加载。
    freeze 前把必要状态持久化,在 resumepageshow 时恢复,可以避免「切回来白屏、数据没了」的体验。

  • bfcache(Back-Forward Cache)
    用户点击「后退/前进」时,浏览器可能直接从 bfcache 恢复上一页,不重新执行脚本、不重新请求。此时不会触发普通的 load,而是触发 pageshow,且 event.persisted === true
    若你的页面在「重新展示」时依赖 load 或假设网络/DOM 是「刚加载」的,就会在 bfcache 恢复时出 bug。要兼容 bfcache,应把「每次展示时都要做的逻辑」放在 pageshow 里,并根据 persisted 区分「首次加载」与「从 bfcache 恢复」。
    同时,某些行为(如 unload 监听、未关闭的 IndexedDB 连接、BroadcastChannel 等)会禁用或驱逐 bfcache,需要谨慎使用。


五、安全机制:浏览器在帮你挡掉了多少「坑」

许多前端对安全的理解停留在「XSS / CSRF」,但浏览器在背后做的安全相关操作要多得多:

  • 站点隔离(Site Isolation)

    • 现代浏览器会尝试把不同站点放到不同的进程里,降低攻击面;
    • iframe 的跨站内容,可能会被完全隔离在单独进程中。
  • 跨源限制与沙箱(CORS / CSP / sandbox)

    • CORS 限制让脚本无法随意读写跨源资源;
    • CSP(Content Security Policy)可以限制脚本来源、内联脚本执行、资源加载规则;
    • sandbox 属性可以将 iframe 限制在一个更「受限」的执行环境中。
  • 剪贴板、传感器、通知等权限管控

    • 浏览器在你调用相关 API 时,会:
      • 触发权限弹窗;
      • 根据用户选择与策略(包括历史选择)进行长期决策;
      • 甚至根据「来源是否可信(HTTPS / PWA 安装状态等)」决定是否允许调用。

很多时候,你以为「浏览器不让我做」,其实是:

  • 浏览器在「为整个生态」兜底;
  • 防止质量差、恶意的站点伤害用户体验与安全;
  • 给真正注重安全和体验的前端团队一个区分度。

深一度:跨源隔离(COOP/COEP)、Trusted Types 与权限策略

  • COOP / COEP 与跨源隔离
    要使用 SharedArrayBuffer、高精度计时器等能力,页面必须处于跨源隔离环境:通过 Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp(或 credentialless)让浏览器把该页面与跨源 iframe 严格隔离。
    这意味着所有跨源资源要么带上 Cross-Origin-Resource-Policy,要么用 crossorigin 等正确 CORS,否则会被阻塞。这是「用安全边界换能力」的典型:你要先满足浏览器的隔离规则,才能拿到高性能线程与高精度 API。

  • Trusted Types(可信类型)
    开启 Trusted Types 后,向 innerHTMLevaldocument.write 等「危险 sink」传入的必须是引擎创建的可信对象,不能直接塞字符串,从而从根源上限制 DOM XSS。
    你需要用 policy.createHTML() 等封装产出 Trusted Type,或对已知安全字符串使用 trustedTypes.emptyHTML。这是浏览器「强制你按安全方式写代码」的机制。

  • Permissions Policy(原 Feature Policy)
    通过响应头或 <iframe allow="..."> 声明本页或嵌入页能否使用摄像头、麦克风、全屏、支付等能力。即使你调用了对应 API,浏览器也会先查策略,未声明或禁止的会直接拒绝。
    理解并正确配置 Permissions Policy,可以避免「为什么这个 iframe 里调不了某 API」的困惑,同时减少第三方嵌入带来的能力泄露风险。


六、浏览器在对你的代码做「隐式性能分析」

现代 JS 引擎(V8、SpiderMonkey 等)在运行时期,会对你的代码进行持续的分析与优化:

  • 热路径检测与 JIT 优化

    • 某段函数被频繁调用后,会被视为「热点代码」;
    • 引擎会根据运行时观察到的类型信息,对其进行优化编译;
    • 如果后续类型发生剧烈变化,还会触发「退优化」。
  • 隐藏类(Hidden Class)与对象形状

    • 你定义对象属性的顺序、在对象生命周期中是否频繁增删属性,都会影响「隐藏类」生成;
    • 稳定的对象形状可以让热点路径更容易获得优化;
    • 反复动态改结构的对象,会产生更多的隐藏类,导致性能下滑。
  • 内存与垃圾回收策略

    • 浏览器会根据分配模式,动态决定 GC 策略(分代收集、增量收集等);
    • 大量短生命周期的对象可能被快速分配在新生代中;
    • 长期存活的对象则会被提升到老生代。

换句话说:

  • 浏览器在后台「观察你写的代码」,并依据实际运行情况不断调整执行策略;
  • 你写的不是「死代码」,而是在和引擎对话;
  • 优雅的代码风格背后,往往是对这些隐式规则的充分理解。

深一度:V8 的分层编译、内联缓存与典型退优化

  • Ignition 与 TurboFan
    V8 先用电量友好的解释器 Ignition 执行,同时收集类型与调用信息;热点函数再交给 TurboFan 做优化编译,生成高度优化的机器码。
    若运行过程中类型或「对象形状」与优化时假设的不一致,就会退优化(deoptimization):从优化代码退回解释执行,并可能丢弃该函数的优化版本。
    所以「类型稳定、形状稳定」的代码,更容易长期停留在优化路径上。

  • 内联缓存(Inline Cache, IC)与对象形状
    访问属性、调用方法时,引擎会缓存「这个对象当前是什么形状、属性在什么偏移」;下次若形状相同,直接走缓存。
    若同一代码路径上出现多种对象形状(例如同一变量有时是 {a:1} 有时是 {a:1, b:2}),就会产生多态,IC 会退化为更慢的泛化逻辑。
    因此:构造函数里按固定顺序初始化属性、避免在实例上随意增删属性、用 class 而非「每次字面量不同结构」,都有助于稳定形状、利于 IC 与 TurboFan。

  • 数组与「快元素」
    V8 对元素类型(smi、double、object)和 packed/holey 等有内部区分。从「 packed 双精度数组」退化成「带洞或混入对象」会触发更慢的通用路径;大数组上类型混用或先写索引 0 再写 1000,也会影响元素种类推断。
    在热点路径上尽量使用类型一致、连续索引的数组,能减少「快元素」退化,从而减少隐式性能损失。


七、前端如何利用这些「你不知道的操作」?

理解机制的意义不在于「炫技」,而在于反向指导工程实践。几点落地建议:

  • 用「调度思维」写前端

    • 主动区分「交互关键路径」和「非关键任务」:
      • 关键交互尽量避免长任务;
      • 非关键逻辑可以使用 requestIdleCallback、延迟加载。
    • 善用浏览器已经提供的节流、优先级与渲染节奏,而不是一味和它对抗。
  • 用「图的视角」看待 DOM 与样式

    • 减少会导致大范围布局变化的操作;
    • 尽量让动画发生在「合成层」(如 transform / opacity),而不是频繁改布局属性;
    • 对复杂页面进行图层规划,避免「图层爆炸」或「所有内容挤在一个图层」。
  • 把浏览器当成「智能网络中枢」

    • 主动设计资源加载策略:Preload / Prefetch / 分包 / 缓存策略;
    • 善用 Service Worker,将部分静态资源前移到本地,「缩短地理距离」。
  • 接受「环境不可靠」,设计「可恢复」前端

    • 把状态想象成随时可能丢失;
    • 把接口调用想象成随时可能重放;
    • 把标签页想象成随时可能被冻结与唤醒。
  • 在与 JS 引擎「合作」中写代码

    • 保持对象形状稳定;
    • 避免在热点路径做过于动态和反常规的操作;
    • 利用性能分析工具理解「引擎真正怎么跑你的代码」。

可落地的检查清单(与上文对应)

维度 可做之事
调度 用 Performance 面板找 Long Task,用 postTask 或拆分为 0ms setTimeout;长循环里用 isInputPending() 让出主线程。
渲染 读写布局属性时严格「先批量读、再批量写」;首屏外大块用 content-visibility: auto;动画只用 transform/opacity,慎用 will-change。
网络 关键资源 preload、下一页资源 prefetch、第三方域 preconnect;LCP 资源加 fetchpriority="high";有条件上 103 Early Hints。
生命周期 用 visibilitychange + 生命周期状态做 freeze 前持久化;用 pageshow + persisted 兼容 bfcache;避免 unload 等导致 bfcache 失效。
安全 需要 SharedArrayBuffer 时配好 COOP/COEP;高安全场景考虑 Trusted Types;嵌入第三方时用 Permissions Policy 收口能力。
引擎 热点路径上固定对象形状与数组类型;用 Chrome DevTools 的「V8 类型/退优化」等洞察验证优化效果。

结语:高阶前端,是和浏览器做朋友

真正的高阶前端,不是只会写出「能跑的页面」,而是能和浏览器一起「共建性能与体验」:

  • 你写的每一行 JS/CSS/HTML,都在与任务调度器、渲染管线、网络栈、安全沙箱、JIT 引擎进行对话。

当你理解浏览器在背后悄悄做的这一切操作,你会开始从「我怎么把功能实现出来」
转变为「我怎么写出浏览器最喜欢、运行起来最顺畅的代码」。

而这,正是前端工程师从「会写页面」走向「驾驭浏览器」的分水岭。

透视 React 内核:Diff 算法、合成事件与并发特性的深度解析

很多开发者能够熟练使用 React API,但当面对性能瓶颈或奇怪的 Bug 时,往往束手无策。究其原因,是对 React 底层机制缺乏深入理解。本文将从三个核心维度——Diff 算法的演进合成事件系统的原理并发模式(Concurrent Features)的实现,带你透视 React 的内核,掌握性能优化的“上帝视角”。

一、Diff 算法:从 O(n³) 到 O(n) 的智慧

React 之所以快,核心在于其高效的 Diff 算法。它通过启发式策略,将传统的 O(n³) 复杂度降低到了 O(n)。

1.1 三大核心策略

  1. Tree Diff(层级策略)
    React 假设 DOM 节点跨层级的移动非常少见。因此,它只比较同一层级的节点。如果节点类型不同(如 div 变 span),直接销毁旧树,重建新树,不再深入比较子节点。

  2. Component Diff(组件策略)
    同一类型的组件,认为其生成的 DOM 结构相似,继续递归比较子节点。如果组件类型不同(如 <Header> 变 <Footer>),则直接替换整个组件树。
    优化点:可以通过 shouldComponentUpdate 或 React.memo 手动跳过不必要的组件 Diff。

  3. Element Diff(列表策略)
    这是最容易出问题的地方。React 通过 key 来标识列表中的节点。

    • 无 Key 或 Index 为 Key:当列表顺序变化时,React 会误以为节点内容变了,导致大量不必要的 DOM 操作(销毁 + 重建),甚至导致输入框焦点丢失。
    • 稳定唯一的 Key:React 能精准识别节点的移动、插入和删除,仅进行最小化的 DOM 操作。
// ❌ 错误示范:使用 index 作为 key
{items.map((item, index) => (
  <ListItem key={index} data={item} /> 
))}
// 当 items 排序或删除时,会导致组件状态错乱和不必要的重渲染

// ✅ 正确示范:使用唯一 ID
{items.map((item) => (
  <ListItem key={item.id} data={item} />
))}

1.2 Fiber 架构带来的中断与恢复

在 React 16+ 引入 Fiber 后,Diff 过程不再是同步递归完成的,而是可以被中断恢复的。这使得 React 能够将长任务拆分成小的时间片,避免阻塞主线程,为并发渲染奠定了基础。

二、合成事件系统(SyntheticEvent):跨浏览器的统一抽象

React 并没有直接将事件监听器绑定到具体的 DOM 节点上,而是实现了一套自己的事件系统。

2.1 事件委托(Event Delegation)

React 将所有事件监听器绑定在根节点(React 17 之前是 document,17+ 是 root 容器)。当事件发生时,通过冒泡机制传播到根节点,React 再根据事件目标找到对应的组件并执行回调。

优势:

  • 内存优化:无论有多少个按钮,只需要在根节点注册一次监听器。
  • 统一行为:抹平了不同浏览器的事件差异(如 event.preventDefault 的兼容性)。

2.2 事件池(Event Pooling)的历史与现状

在 React 16 及以前,为了性能,React 会复用事件对象(Event Pooling)。这意味着你在异步回调中访问 event 属性时会得到 null,必须调用 event.persist()

React 17+ 的重大变更:移除了事件池。现在的事件对象是原生的,可以在异步回调中安全访问,无需 persist()。这大大降低了心智负担。

// React 16 (旧)
function handleClick(e) {
  e.persist(); 
  setTimeout(() => console.log(e.target), 1000);
}

// React 17+ (新)
function handleClick(e) {
  // 直接使用,无需 persist
  setTimeout(() => console.log(e.target), 1000);
}

三、并发特性(Concurrent Features):用户体验的革命

React 18 正式推出的并发模式,核心目标是保持 UI 响应灵敏,即使在执行重型渲染任务时。

3.1 可中断渲染

传统渲染一旦开始就无法中断,直到完成(阻塞主线程)。并发渲染允许 React 在渲染过程中暂停,去处理更高优先级的任务(如用户输入),然后再回来继续渲染。

3.2 useTransition:标记非紧急更新

当你需要执行一个耗时操作(如过滤一个大列表),但不希望它阻塞输入框的响应时,可以使用 useTransition

import { useTransition, useState } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  const [results, setResults] = useState([]);

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value); // 紧急更新:立即响应输入框

    // 非紧急更新:过滤列表可以稍后执行
    startTransition(() => {
      const filtered = heavyFilter(value); 
      setResults(filtered);
    });
  };

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <List items={results} />
    </>
  );
}

3.3 useDeferredValue:延迟更新值

useDeferredValue 是 useTransition 的另一种写法,适用于你已经有一个值,但想延迟它的副作用(如渲染)的场景。它类似于防抖(debounce),但更加智能,会根据设备性能自动调整延迟时间。

function SearchPage() {
  const [query, setQuery] = useState('');
  // deferredQuery 会在 query 变化后“延迟”更新,给紧急渲染让路
  const deferredQuery = useDeferredValue(query);

  const results = heavyFilter(deferredQuery);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <List items={results} />
    </>
  );
}

结语

深入理解 Diff 算法让我们写出更高效的列表代码;掌握合成事件系统让我们明白事件处理的本质;而并发特性则为构建丝滑的用户体验提供了强大的武器。这些底层知识是区分初级与高级 React 开发者的分水岭。

组合式函数 、 Hooks(Vue2 mixin 、 Vue3 composables)的实战封装

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

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

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

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

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

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

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

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

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

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

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

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

一、先搞清楚一个问题:我们到底在解决什么?

写 Vue 项目久了,你一定遇到过这些场景:

  • 好几个页面都有表格 + 分页 + 搜索,每个页面都写一遍 currentPagepageSizetotalloadingtableData……
  • 好几个弹窗表单都要做打开/关闭、表单校验、提交、重置,每次都 copy 一坨。
  • 几乎所有接口调用都要处理 loading、error、retry,到处重复 try-catch。

核心问题就一个字:重复。

但重复本身不是最可怕的,可怕的是:

  1. 改一个逻辑要改 N 个地方(漏改一个就是 bug)
  2. 逻辑散落在 datamethodswatchmounted 各处,跟读小说一样要来回翻页
  3. 新人接手看不懂,老人自己过半年也看不懂

所以我们需要一种方式,把可复用的有状态逻辑抽出来,做到:写一次、用 N 次、改一处、全生效。

在 Vue2 时代,官方给的方案叫 Mixin
在 Vue3 时代,官方推荐的方案叫 Composables(组合式函数)

二、Vue2 Mixin:能用,但有"三宗罪"

2.1 Mixin 是什么?

简单说:Mixin 就是一个普通的 Vue 组件选项对象,可以包含 datamethodscomputedwatch、生命周期等任何组件选项。当你把它"混入"到一个组件里时,这些选项会和组件自身的选项合并

2.2 一个典型的例子:分页逻辑复用

假设我们有很多列表页,都需要分页,先看不用 Mixin 时你要在每个页面写的东西:

// PageA.vue
export default {
  data() {
    return {
      tableData: [],
      currentPage: 1,
      pageSize: 10,
      total: 0,
      loading: false
    }
  },
  methods: {
    async fetchList() {
      this.loading = true
      try {
        const res = await api.getListA({
          page: this.currentPage,
          size: this.pageSize
        })
        this.tableData = res.data.list
        this.total = res.data.total
      } finally {
        this.loading = false
      }
    },
    handlePageChange(page) {
      this.currentPage = page
      this.fetchList()
    },
    handleSizeChange(size) {
      this.pageSize = size
      this.currentPage = 1
      this.fetchList()
    }
  },
  created() {
    this.fetchList()
  }
}

PageB、PageC…… 全是这套。唯一不同的就是 api.getListA 换成 api.getListB

用 Mixin 抽出来:

// mixins/pagination.js
export default {
  data() {
    return {
      tableData: [],
      currentPage: 1,
      pageSize: 10,
      total: 0,
      loading: false
    }
  },
  methods: {
    // 子组件必须自己实现这个方法,返回接口调用的 Promise
    fetchList() {
      throw new Error('组件必须实现 fetchList 方法')
    },
    handlePageChange(page) {
      this.currentPage = page
      this.fetchList()
    },
    handleSizeChange(size) {
      this.pageSize = size
      this.currentPage = 1
      this.fetchList()
    }
  },
  created() {
    this.fetchList()
  }
}
// PageA.vue
import paginationMixin from '@/mixins/pagination'

export default {
  mixins: [paginationMixin],
  methods: {
    async fetchList() {
      this.loading = true
      try {
        const res = await api.getListA({
          page: this.currentPage,
          size: this.pageSize
        })
        this.tableData = res.data.list
        this.total = res.data.total
      } finally {
        this.loading = false
      }
    }
  }
}

看起来不错对吧?确实能复用了。但用久了你就会遇到 Mixin 的三宗罪

2.3 Mixin 的三宗罪

第一宗:来源不明("这变量哪来的?")

<template>
  <div>
    <!-- currentPage 是组件自己的?还是 mixin 带来的?还是哪个 mixin? -->
    <span>第 {{ currentPage }} 页,共 {{ total }} 条</span>
    <!-- userName 呢?是另一个 mixin 的? -->
    <span>{{ userName }}</span>
  </div>
</template>

<script>
import paginationMixin from '@/mixins/pagination'
import userMixin from '@/mixins/user'

export default {
  mixins: [paginationMixin, userMixin],
  // 你在 data、methods 里完全看不出 currentPage 和 userName 从哪来
  // IDE 也没法跳转到定义,只能靠人肉去翻 mixin 文件
}
</script>

当你引了 2-3 个 mixin,模板里用的变量来源就成了悬案。新人接手的时候更是一脸懵。

第二宗:命名冲突("我的变量被吞了")

// mixins/pagination.js
export default {
  data() {
    return { loading: false }  // 表格加载状态
  }
}

// mixins/auth.js
export default {
  data() {
    return { loading: false }  // 权限校验加载状态
  }
}

// SomePage.vue
export default {
  mixins: [paginationMixin, authMixin],
  data() {
    return { loading: false }  // 提交按钮加载状态
  }
  // 三个 loading 打架了!
  // Vue2 的合并策略:组件自身的 data 优先,后面的 mixin 覆盖前面的
  // 最终只有一个 loading,另外两个的逻辑全乱了
  // 而且——不会报任何错误或警告!
}

这是 Mixin 最要命的问题。项目小的时候还好,项目大了、mixin 多了,命名冲突几乎是必然的,而且是静默的——不报错、不警告,直接覆盖,等你发现 bug 的时候已经不知道要查到什么时候了。

第三宗:不灵活("我想用两份分页怎么办?")

// 如果一个页面有两个独立的表格,各自有各自的分页呢?
// Mixin 混进来就是一份,没法实例化两份
export default {
  mixins: [paginationMixin], // 只有一份 currentPage、total……
  // 第二个表格的分页数据怎么办?再写一遍?那还要 mixin 干嘛?
}

Mixin 本质上是对象合并,不是函数调用,所以你没法像调函数一样"new 两份出来"。

2.4 小结

能力 Mixin
能复用逻辑吗? ✅ 能
来源清晰吗? ❌ 不清晰,变量来源成谜
能避免冲突吗? ❌ 不能,静默覆盖
能多实例吗? ❌ 不能,混进来就是一份
类型推导友好吗? ❌ TypeScript 几乎没法推导

三、Vue3 Composables:函数的胜利

3.1 核心思想:一切皆函数

Vue3 的 Composition API 给了我们一个极其简单但极其强大的模式:

把有状态的逻辑写成一个普通函数,函数里用 ref/reactive 创建响应式状态,最后 return 出去。

就这么简单。没有什么新 API、新概念,就是函数

// composables/useCounter.js —— 最简单的例子
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  function reset() {
    count.value = initialValue
  }

  return { count, increment, decrement, reset }
}

使用的时候:

<script setup>
import { useCounter } from '@/composables/useCounter'

// 调一次就是一份独立的状态
const { count: countA, increment: incrementA } = useCounter(0)
const { count: countB, increment: incrementB } = useCounter(100)

// countA 和 countB 完全独立,互不影响
</script>

<template>
  <button @click="incrementA">A: {{ countA }}</button>
  <button @click="incrementB">B: {{ countB }}</button>
</template>

3.2 对比 Mixin,三宗罪全解了

问题 Mixin Composable
来源不明 ❌ 变量凭空出现 ✅ 显式 import + 解构,清清楚楚
命名冲突 ❌ 静默覆盖 ✅ 解构时可以重命名 { count: countA }
不能多实例 ❌ 只有一份 ✅ 调多次就是多份独立状态
TS 支持 ❌ 几乎不可用 ✅ 完美推导,悬停即可看类型

3.3 命名约定

Vue 社区有一个约定俗成的规范(也是 Vue 官方文档推荐的):

  • 文件名:useXxx.jsuseXxx.ts
  • 函数名:useXxx,以 use 开头
  • 放置位置:项目中统一放在 composables/hooks/ 目录下
src/
├── composables/         # 或叫 hooks/
│   ├── useRequest.js    # 通用请求封装
│   ├── useTable.js      # 表格逻辑封装
│   ├── useForm.js       # 表单逻辑封装
│   ├── useLoading.js    # 加载状态封装
│   └── index.js         # 统一导出
├── views/
├── components/
└── ...

四、实战封装一:useRequest —— 一切的基础

4.1 为什么先封装它?

因为 useTable 要请求数据,useForm 要提交数据,几乎所有业务逻辑都绕不开接口调用。把请求逻辑封装好了,后面的封装都会轻松很多。

4.2 先想清楚:一个接口调用需要管理哪些状态?

别急着写代码,先列需求:

1. data    —— 接口返回的数据
2. loading —— 是否正在请求中(控制按钮 loading、骨架屏等)
3. error   —— 请求失败的错误信息
4. 手动触发 / 自动触发 —— 有的接口进页面就要调,有的要点按钮才调
5. 防重复 —— 快速点击不要发 N 个请求

4.3 最小可用版本(V1)

先写一个最简单的版本,能跑起来:

// composables/useRequest.js  V1 - 最小可用版
import { ref } from 'vue'

/**
 * 通用请求封装
 * @param {Function} apiFn - 接口函数,需返回 Promise
 * @param {Object} options - 配置项
 * @param {boolean} options.immediate - 是否立即执行,默认 false
 * @param {any} options.initialData - data 的初始值,默认 null
 */
export function useRequest(apiFn, options = {}) {
  const {
    immediate = false,
    initialData = null
  } = options

  const data = ref(initialData)
  const loading = ref(false)
  const error = ref(null)

  async function run(...args) {
    loading.value = true
    error.value = null
    try {
      const res = await apiFn(...args)
      data.value = res
      return res
    } catch (err) {
      error.value = err
      throw err  // 继续抛出,让调用方可以 catch
    } finally {
      loading.value = false
    }
  }

  // 如果配置了 immediate,创建时就调一次
  if (immediate) {
    run()
  }

  return { data, loading, error, run }
}

使用示例:

<script setup>
import { useRequest } from '@/composables/useRequest'
import { getUserInfo } from '@/api/user'

// 场景1:进页面自动请求
const { data: userInfo, loading } = useRequest(
  () => getUserInfo(userId),
  { immediate: true }
)

// 场景2:点按钮手动触发
const { loading: submitLoading, run: submitForm } = useRequest(
  (formData) => saveUser(formData)
)

function handleSubmit() {
  submitForm({ name: 'Tom', age: 18 })
}
</script>

<template>
  <div v-loading="loading">{{ userInfo?.name }}</div>
  <button :loading="submitLoading" @click="handleSubmit">提交</button>
</template>

核心理解: useRequest 接收一个"接口函数",帮你管理 loadingdataerror 三个状态,并返回一个 run 方法让你手动触发。就这么简单。

4.4 进阶版本(V2):加上防重复和竞态处理

V1 有两个隐患:

  1. 防重复:用户快速点击提交按钮,会同时发出多个请求
  2. 竞态:用户快速切换筛选条件,先发的请求后返回,会覆盖掉后发请求的正确数据
// composables/useRequest.js  V2 - 加防重和竞态处理
import { ref } from 'vue'

export function useRequest(apiFn, options = {}) {
  const {
    immediate = false,
    initialData = null,
    // 新增:是否在 loading 中时阻止重复调用(适用于提交类接口)
    preventRepeat = false
  } = options

  const data = ref(initialData)
  const loading = ref(false)
  const error = ref(null)

  // 用一个自增 id 来处理竞态
  // 每次调用 run 时 id + 1,回调时检查 id 是否是最新的
  // 如果不是最新的,说明在这次请求还没返回时,又发了新的请求
  // 那这次的结果就应该被丢弃
  let requestId = 0

  async function run(...args) {
    // 防重复:如果正在请求中,直接返回
    if (preventRepeat && loading.value) {
      return
    }

    const currentId = ++requestId
    loading.value = true
    error.value = null

    try {
      const res = await apiFn(...args)
      // 竞态处理:只有最新一次请求的结果才会被赋值
      if (currentId === requestId) {
        data.value = res
      }
      return res
    } catch (err) {
      if (currentId === requestId) {
        error.value = err
      }
      throw err
    } finally {
      if (currentId === requestId) {
        loading.value = false
      }
    }
  }

  if (immediate) {
    run()
  }

  return { data, loading, error, run }
}

竞态问题的具体场景,举个例子:

用户操作:选"北京" → 选"上海"(很快切换)

请求时序:
  请求A(北京)发出 ──────────────────> 请求A返回(北京的数据)
  请求B(上海)发出 ────> 请求B返回(上海的数据)

如果不处理竞态:
  页面先显示上海数据(正确),然后被北京数据覆盖(错误!)

处理竞态后:
  请求A返回时发现 currentId !== requestId,丢弃结果
  页面始终显示上海数据(正确)

这个问题在实际开发中出现频率很高,但很多人意识不到。面试也经常问。

4.5 踩坑提醒

坑 1:忘了 finally 重置 loading

// ❌ 错误写法
async function run(...args) {
  loading.value = true
  try {
    const res = await apiFn(...args)
    data.value = res
    loading.value = false  // 如果上面报错了,这行不会执行!
  } catch (err) {
    error.value = err
    // 忘了在这里也重置 loading → 页面永远转圈
  }
}

// ✅ 正确写法:用 finally
async function run(...args) {
  loading.value = true
  try {
    const res = await apiFn(...args)
    data.value = res
  } catch (err) {
    error.value = err
  } finally {
    loading.value = false  // 不管成功失败都会执行
  }
}

坑 2:immediate: true 的时候传参

// ❌ 这样拿不到参数
const { data } = useRequest(
  (id) => getDetail(id),
  { immediate: true }
)
// immediate 调用 run() 时没传 id,接口会报错

// ✅ 用闭包把参数包进去
const { data } = useRequest(
  () => getDetail(route.params.id),
  { immediate: true }
)

五、实战封装二:useTable —— 中后台的半壁江山

5.1 分析需求

中后台项目里,表格页面占了至少一半。一个标准的表格页面需要:

1. 表格数据(tableData)
2. 分页状态(currentPage、pageSize、total)
3. 加载状态(loading)
4. 搜索/筛选参数(searchParams)
5. 查询方法(搜索、重置、翻页、切换每页条数)
6. 进页面自动加载第一页

5.2 完整实现

// composables/useTable.js
import { ref, reactive, onMounted } from 'vue'

/**
 * 表格逻辑封装
 * @param {Function} apiFn - 列表接口函数
 *   接收参数格式:apiFn({ page, size, ...searchParams })
 *   返回格式约定:{ list: [], total: 0 }
 * @param {Object} options - 配置项
 */
export function useTable(apiFn, options = {}) {
  const {
    defaultPageSize = 10,
    immediate = true,
    // 让调用方可以自定义如何从接口返回值中提取 list 和 total
    // 因为每个项目的接口返回格式可能不同
    formatResult = (res) => ({
      list: res.data?.list ?? res.data?.records ?? [],
      total: res.data?.total ?? 0
    })
  } = options

  // ---- 状态定义 ----
  const tableData = ref([])
  const loading = ref(false)
  const pagination = reactive({
    currentPage: 1,
    pageSize: defaultPageSize,
    total: 0
  })

  // 搜索参数,用 reactive 方便直接 v-model 绑定表单
  const searchParams = reactive({})

  // ---- 核心方法 ----

  /** 加载数据 */
  async function fetchData() {
    loading.value = true
    try {
      const params = {
        page: pagination.currentPage,
        size: pagination.pageSize,
        ...searchParams
      }
      const res = await apiFn(params)
      const { list, total } = formatResult(res)
      tableData.value = list
      pagination.total = total
    } catch (err) {
      console.error('[useTable] fetchData error:', err)
      tableData.value = []
      pagination.total = 0
    } finally {
      loading.value = false
    }
  }

  /** 搜索(重置到第一页) */
  function search() {
    pagination.currentPage = 1
    fetchData()
  }

  /** 重置搜索条件并查询 */
  function reset() {
    // 清空 searchParams 的所有字段
    Object.keys(searchParams).forEach(key => {
      searchParams[key] = undefined
    })
    pagination.currentPage = 1
    fetchData()
  }

  /** 翻页 */
  function onPageChange(page) {
    pagination.currentPage = page
    fetchData()
  }

  /** 切换每页条数 */
  function onSizeChange(size) {
    pagination.pageSize = size
    pagination.currentPage = 1  // 切换条数要回到第一页
    fetchData()
  }

  /** 刷新当前页(不改变任何条件) */
  function refresh() {
    fetchData()
  }

  // ---- 初始化 ----
  if (immediate) {
    onMounted(() => {
      fetchData()
    })
  }

  // ---- 返回 ----
  return {
    tableData,
    loading,
    pagination,
    searchParams,
    search,
    reset,
    refresh,
    onPageChange,
    onSizeChange,
    fetchData
  }
}

5.3 使用示例(完整页面)

<!-- views/UserList.vue -->
<template>
  <div class="page-container">
    <!-- 搜索区域 -->
    <el-form inline @submit.prevent="search">
      <el-form-item label="用户名">
        <el-input v-model="searchParams.username" placeholder="请输入用户名" clearable />
      </el-form-item>
      <el-form-item label="状态">
        <el-select v-model="searchParams.status" placeholder="请选择" clearable>
          <el-option label="启用" :value="1" />
          <el-option label="禁用" :value="0" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="search">查询</el-button>
        <el-button @click="reset">重置</el-button>
      </el-form-item>
    </el-form>

    <!-- 表格 -->
    <el-table :data="tableData" v-loading="loading" border>
      <el-table-column prop="username" label="用户名" />
      <el-table-column prop="email" label="邮箱" />
      <el-table-column prop="status" label="状态">
        <template #default="{ row }">
          <el-tag :type="row.status === 1 ? 'success' : 'danger'">
            {{ row.status === 1 ? '启用' : '禁用' }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="200">
        <template #default="{ row }">
          <el-button size="small" @click="handleEdit(row)">编辑</el-button>
          <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <!-- 分页 -->
    <el-pagination
      v-model:current-page="pagination.currentPage"
      v-model:page-size="pagination.pageSize"
      :total="pagination.total"
      :page-sizes="[10, 20, 50, 100]"
      layout="total, sizes, prev, pager, next, jumper"
      @current-change="onPageChange"
      @size-change="onSizeChange"
      style="margin-top: 16px; justify-content: flex-end;"
    />
  </div>
</template>

<script setup>
import { getUserList } from '@/api/user'
import { useTable } from '@/composables/useTable'

const {
  tableData,
  loading,
  pagination,
  searchParams,
  search,
  reset,
  refresh,
  onPageChange,
  onSizeChange
} = useTable(getUserList)

// 页面自身的业务逻辑
function handleEdit(row) {
  // 打开编辑弹窗...
}

async function handleDelete(row) {
  await ElMessageBox.confirm('确认删除?')
  await deleteUser(row.id)
  ElMessage.success('删除成功')
  refresh()  // 删除后刷新当前页
}
</script>

对比一下:如果不用 useTable,这个页面的 <script> 部分至少要 80+ 行。现在核心逻辑只有 20 行左右,而且每个列表页都是同样的模式

5.4 踩坑提醒

坑 1:searchParamsreactive 还是 ref

// 方案A:reactive(推荐)
const searchParams = reactive({})
// ✅ 优点:模板里直接 v-model="searchParams.xxx",不用 .value
// ✅ 优点:新增字段时直接 searchParams.newField = 'xxx' 就行
// ⚠️ 注意:不能整个替换 searchParams = {},要逐个清字段

// 方案B:ref
const searchParams = ref({})
// 模板里要写 searchParams.xxx(Vue 自动解 ref),看起来一样
// 但重置时可以直接 searchParams.value = {}
// 缺点:如果传给子组件,需要注意 .value 的问题

我推荐用 reactive,因为搜索参数一般不会整个替换,而是逐字段修改。

坑 2:onMounted 还是直接调用?

// ❌ 直接调用
if (immediate) {
  fetchData()  // 此时组件可能还没挂载,某些情况下会有问题
}

// ✅ 放在 onMounted 里
if (immediate) {
  onMounted(() => {
    fetchData()
  })
}

useTable 一般在 setup 阶段调用,如果你在 fetchData 里有用到 DOM 相关的东西(比如获取表格容器高度来做自适应),直接调用就会出问题。养成好习惯,用 onMounted

坑 3:切换 pageSize 时忘了重置页码

// ❌ 错误
function onSizeChange(size) {
  pagination.pageSize = size
  fetchData()
  // 比如当前在第 5 页,每页 10 条,共 45 条
  // 切换成每页 50 条后,第 5 页已经不存在了
  // 接口可能返回空数据甚至报错
}

// ✅ 正确
function onSizeChange(size) {
  pagination.pageSize = size
  pagination.currentPage = 1  // 一定要回到第一页!
  fetchData()
}

六、实战封装三:useForm —— 弹窗表单的终结者

6.1 分析需求

中后台的另一个高频场景:弹窗表单(新增/编辑共用一个弹窗)。需要管理:

1. 弹窗显隐(visible)
2. 弹窗标题(根据新增/编辑动态变化)
3. 表单数据(formData)
4. 表单校验(rules + validate)
5. 提交逻辑(loading + 调接口 + 关弹窗 + 刷新列表)
6. 重置逻辑(关弹窗时清空表单 + 清除校验状态)

6.2 完整实现

// composables/useForm.js
import { ref, reactive, toRaw } from 'vue'

/**
 * 弹窗表单逻辑封装
 * @param {Object} options
 * @param {Function} options.getInitialData - 返回表单初始值的函数(必须是函数,避免引用污染)
 * @param {Function} options.submitApi - 提交接口函数,接收 formData 参数
 * @param {Function} options.onSuccess - 提交成功后的回调
 */
export function useForm(options = {}) {
  const {
    getInitialData = () => ({}),
    submitApi,
    onSuccess
  } = options

  // ---- 状态 ----
  const visible = ref(false)
  const isEdit = ref(false)
  const title = ref('')
  const formData = reactive(getInitialData())
  const formRef = ref(null)  // el-form 的 ref
  const submitLoading = ref(false)

  // ---- 方法 ----

  /** 打开弹窗 - 新增模式 */
  function openAdd() {
    isEdit.value = false
    title.value = '新增'
    resetFields()
    visible.value = true
  }

  /**
   * 打开弹窗 - 编辑模式
   * @param {Object} row - 当前行数据,用于回填表单
   */
  function openEdit(row) {
    isEdit.value = true
    title.value = '编辑'
    resetFields()
    // 回填数据:只填 formData 中存在的字段,避免多余字段
    Object.keys(getInitialData()).forEach(key => {
      if (row[key] !== undefined) {
        formData[key] = row[key]
      }
    })
    visible.value = true
  }

  /** 关闭弹窗 */
  function close() {
    visible.value = false
    // 延迟重置,等弹窗关闭动画结束后再清空,避免用户看到闪烁
    setTimeout(() => {
      resetFields()
    }, 300)
  }

  /** 重置表单字段到初始值 */
  function resetFields() {
    const initial = getInitialData()
    Object.keys(initial).forEach(key => {
      formData[key] = initial[key]
    })
    // 清除 el-form 的校验状态
    formRef.value?.clearValidate?.()
  }

  /** 提交表单 */
  async function submit() {
    if (!submitApi) {
      console.warn('[useForm] submitApi is not provided')
      return
    }
    // 先校验
    try {
      await formRef.value?.validate()
    } catch {
      return  // 校验不通过,直接返回
    }

    submitLoading.value = true
    try {
      // toRaw:把 reactive 对象转成普通对象再传给接口
      // 避免接口层不小心修改了响应式对象
      await submitApi(toRaw(formData))
      close()
      onSuccess?.()
    } catch (err) {
      console.error('[useForm] submit error:', err)
      // 提交失败不关弹窗,让用户可以修改后重试
    } finally {
      submitLoading.value = false
    }
  }

  return {
    visible,
    isEdit,
    title,
    formData,
    formRef,
    submitLoading,
    openAdd,
    openEdit,
    close,
    submit,
    resetFields
  }
}

6.3 使用示例(完整页面)

<!-- views/UserList.vue(在前面 useTable 的基础上加入 useForm) -->
<template>
  <div class="page-container">
    <!-- 搜索区域(省略,同前面 useTable 示例) -->

    <!-- 新增按钮 -->
    <el-button type="primary" @click="openAdd" style="margin-bottom: 16px;">
      新增用户
    </el-button>

    <!-- 表格(省略,同前面 useTable 示例,编辑按钮绑定 openEdit) -->
    <el-table :data="tableData" v-loading="tableLoading" border>
      <!-- ...其他列... -->
      <el-table-column label="操作" width="200">
        <template #default="{ row }">
          <el-button size="small" @click="openEdit(row)">编辑</el-button>
        </template>
      </el-table-column>
    </el-table>

    <!-- 新增/编辑弹窗 -->
    <el-dialog v-model="visible" :title="title" width="500px" @close="close">
      <el-form
        ref="formRef"
        :model="formData"
        :rules="rules"
        label-width="80px"
      >
        <el-form-item label="用户名" prop="username">
          <el-input v-model="formData.username" placeholder="请输入用户名" />
        </el-form-item>
        <el-form-item label="邮箱" prop="email">
          <el-input v-model="formData.email" placeholder="请输入邮箱" />
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-select v-model="formData.status" placeholder="请选择状态">
            <el-option label="启用" :value="1" />
            <el-option label="禁用" :value="0" />
          </el-select>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="close">取消</el-button>
        <el-button type="primary" :loading="submitLoading" @click="submit">
          确定
        </el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { useTable } from '@/composables/useTable'
import { useForm } from '@/composables/useForm'
import { getUserList, createUser, updateUser } from '@/api/user'

// ---- 表格逻辑 ----
const {
  tableData,
  loading: tableLoading,
  pagination,
  searchParams,
  search,
  reset,
  refresh,
  onPageChange,
  onSizeChange
} = useTable(getUserList)

// ---- 表单逻辑 ----
const {
  visible,
  isEdit,
  title,
  formData,
  formRef,
  submitLoading,
  openAdd,
  openEdit,
  close,
  submit
} = useForm({
  getInitialData: () => ({
    id: undefined,
    username: '',
    email: '',
    status: 1
  }),
  submitApi: (data) => {
    // 根据 isEdit 判断调新增还是编辑接口
    return isEdit.value ? updateUser(data) : createUser(data)
  },
  onSuccess: () => {
    ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
    refresh()  // 提交成功后刷新表格
  }
})

// 表单校验规则
const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ],
  status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
</script>

你看,整个页面的 <script> 部分非常清晰:

  1. useTable 管表格和分页
  2. useForm 管弹窗和表单
  3. 页面自身只需要定义校验规则和业务相关的 UI

逻辑分离、职责清晰、代码量大幅减少。

6.4 踩坑提醒

坑 1:getInitialData 为什么必须是函数?

// ❌ 错误:直接传对象
const initialData = { username: '', email: '', status: 1 }
useForm({ getInitialData: initialData })

// 问题:每次 resetFields 重置时,拿到的都是同一个对象引用
// 如果 initialData 被修改过(比如编辑回填时),重置就不是真正的"初始值"了

// ✅ 正确:传一个函数,每次调用都返回一个全新的对象
useForm({
  getInitialData: () => ({ username: '', email: '', status: 1 })
})

这和 Vue2 里 data 必须是函数是同一个道理——避免引用污染。

坑 2:编辑回填时直接赋值整个对象

// ❌ 错误
function openEdit(row) {
  Object.assign(formData, row)
  // 问题:row 里可能有 createdAt、updatedAt 等表单不需要的字段
  // 提交时这些多余字段会被传给接口,可能导致后端报错
}

// ✅ 正确:只回填 formData 中定义过的字段
function openEdit(row) {
  Object.keys(getInitialData()).forEach(key => {
    if (row[key] !== undefined) {
      formData[key] = row[key]
    }
  })
}

坑 3:弹窗关闭时的视觉闪烁

// ❌ 立即重置
function close() {
  visible.value = false
  resetFields()  // 弹窗还在做关闭动画,用户会看到表单内容突然清空

// ✅ 延迟重置,等动画结束
function close() {
  visible.value = false
  setTimeout(() => {
    resetFields()
  }, 300)  // Element Plus 弹窗动画时长大约 300ms
}

七、封装的设计原则与规范

写了三个实战 composable 之后,我们总结一下通用的设计原则:

7.1 命名规范

// ✅ 文件名和函数名保持一致
// composables/useRequest.js → export function useRequest()
// composables/useTable.js   → export function useTable()

// ✅ 返回值命名清晰
return {
  data,          // 名词:状态数据
  loading,       // 形容词:状态标记
  error,         // 名词:错误信息
  run,           // 动词:操作方法
  search,        // 动词:操作方法
  reset,         // 动词:操作方法
}

// ❌ 避免模糊命名
return {
  result,   // result 是什么?请求结果?搜索结果?
  flag,     // flag 是什么?
  handle,   // handle 什么?
  doIt,     // do what?
}

7.2 参数设计

// ✅ 推荐:必选参数放前面,可选配置用 options 对象
export function useTable(apiFn, options = {}) {}

// ❌ 不推荐:一堆位置参数,调用时要记顺序
export function useTable(apiFn, pageSize, immediate, formatFn) {}

// ✅ 提供合理的默认值,让最简单的用法零配置
const { tableData, loading } = useTable(getUserList)
// 不传 options 也能正常工作

7.3 单一职责

// ✅ 一个 composable 做一件事
useRequest  → 只管请求状态
useTable    → 只管表格 + 分页
useForm     → 只管表单 + 弹窗

// ❌ 不要做一个"万能"composable
usePageHelper → 又管表格、又管表单、又管权限、又管路由……
// 这种东西最终会变成新的"屎山"

7.4 组合优于继承

Composable 之间可以互相组合。比如 useTable 可以内部使用 useRequest

// composables/useTable.js(组合版)
import { useRequest } from './useRequest'

export function useTable(apiFn, options = {}) {
  const { data, loading, run } = useRequest(apiFn)

  async function fetchData() {
    const params = { page: pagination.currentPage, size: pagination.pageSize }
    const res = await run(params)
    // 处理 res...
  }

  // ...
}

这就是组合的威力:小函数组成大函数,每一层都清晰可控。

7.5 统一导出

// composables/index.js
export { useRequest } from './useRequest'
export { useTable } from './useTable'
export { useForm } from './useForm'
export { useLoading } from './useLoading'
// ...

使用时一行搞定:

import { useTable, useForm } from '@/composables'

八、常见问题 FAQ

Q1:Composable 里能用生命周期钩子吗?

可以。 在 composable 内部调用 onMountedonUnmounted 等是完全合法的,前提是这个 composable 是在 setup 阶段被调用的(而不是在某个异步回调里调用)。

// ✅ 合法
export function useWindowResize() {
  const width = ref(window.innerWidth)

  function handler() {
    width.value = window.innerWidth
  }

  onMounted(() => window.addEventListener('resize', handler))
  onUnmounted(() => window.removeEventListener('resize', handler))

  return { width }
}

Q2:Composable 之间怎么共享状态?

如果你需要在多个组件之间共享同一份状态(比如全局用户信息),有两种方式:

// 方式1:把状态定义在函数外面(模块级别的单例)
const globalUser = ref(null)

export function useUser() {
  async function fetchUser() {
    globalUser.value = await getUserInfo()
  }
  return { user: globalUser, fetchUser }
}
// 所有组件拿到的都是同一个 globalUser

// 方式2:更复杂的全局状态,建议用 Pinia
// composable 适合组件级的有状态逻辑
// Pinia 适合跨组件/跨页面的全局状态

Q3:和 React Hooks 有什么区别?

最核心的区别:Vue composable 只在 setup 时执行一次,React Hook 每次渲染都会执行。

// Vue:setup 只跑一次,后续数据变化靠响应式系统自动追踪
export function useCounter() {
  const count = ref(0)         // 只创建一次
  const double = computed(() => count.value * 2)  // 自动追踪
  return { count, double }
}

// React:每次渲染都会重新执行,需要 useMemo/useCallback 优化
function useCounter() {
  const [count, setCount] = useState(0)        // 每次渲染都执行
  const double = useMemo(() => count * 2, [count])  // 手动声明依赖
  return { count, double }
}

所以 Vue 的 composable 不需要担心"闭包陷阱"和"依赖数组"这些 React 特有的问题,心智负担更小。

九、总结

维度 Vue2 Mixin Vue3 Composable
来源透明性 ❌ 变量来源不明 ✅ 显式导入解构
命名冲突 ❌ 静默覆盖 ✅ 解构重命名
多实例 ❌ 不支持 ✅ 调多次即多份
TypeScript ❌ 几乎无法推导 ✅ 完美支持
组合能力 ❌ 难以互相调用 ✅ 函数随意组合
调试体验 ❌ 不知道值从哪来 ✅ 断点直接跟进函数

三个核心封装的适用场景速查:

  • useRequest:任何需要调接口的地方(基础设施,其他 composable 的地基)
  • useTable:所有列表/表格页面(中后台的半壁江山)
  • useForm:所有弹窗表单场景(新增/编辑/详情)

封装心法:

  1. 先想清楚要管理哪些状态、暴露哪些方法
  2. 参数设计:必选在前,可选用 options 对象 + 合理默认值
  3. 单一职责,小函数组合成大函数
  4. 统一命名(useXxx),统一目录(composables/),统一导出(index.js

最后想说的是:Composable 不是什么高深技术,它就是"把逻辑写成函数"——这是编程最古老、最朴素、最强大的抽象方式。

Vue3 的 Composition API 只是给了我们一个在 Vue 框架里优雅地使用这种方式的能力。把它用好,你的代码会变得更干净、更可维护、更有生命力。


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

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

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

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

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

JSON或代码对比的工具-vue

Vue 中JSON或代码对比的插件和工具

先看效果

duibi.png

实现方案
// 1、安装依赖
"vue-diff": "^1.2.4"

///2、main.ts 注册组件
import VueDiff from 'vue-diff';
import 'vue-diff/dist/index.css';

app.use(VueDiff);


///3、页面使用组件
<template>
  <el-dialog title="对比 ">
    <Diff
      mode="split"
      theme="light"
      language="json"
      :prev="oldStr"
      :current="newStr"
      style="height: 500px; margin-top: 20px; overflow-y: auto"
    />
  </el-dialog>
</template>

<script setup>

const obj = {
  room_rule: {
    player_limit: 4,
    hand_ready: 2,
    limit_same_ip: 1,
    game_srcj: 0,
    continue_game: 0,
    start_left_time: 10,
    share_gps: 2,
    yu_yin: 0,
    name: {
      nameStr: "张三",
      level: "大师",
      info: {
        name: "张三",
        desc: "这是个人描述",
        sub: {
          name: "张三",
          desc: "这是个人描述",
        },
      },
    },
    room_name: "欢乐2048",
    room_desc: "无房卡,房卡,金币",
  },
};

const obj1 = {
  room_rule: {
    player_limit: 4,
    hand_ready: 2,
    limit_same_ip: 1,
    game_srcj: 0,
    start_left_time: 10,
    type: 0,
    share_gps: 2,
    yu_yin: 0,
    name: {
      nameStr: "张三",
      level: "大师",
      info: {
        name: "张三000",
        desc: "这是个人描述",
        sub: {
          name: "张三111111",
          desc: "这是个人描述33333",
        },
      },
    },
  },
};

const oldStr = JSON.stringify(obj, null, 2);
const newStr = JSON.stringify(obj1, null, 2);
</script>

<style lang='scss' scoped>
</style>

实现方案
// 1、安装依赖
"vue-diff": "^1.2.4"

///2、main.ts 注册组件
import VueDiff from 'vue-diff';
import 'vue-diff/dist/index.css';

app.use(VueDiff);


///3、页面使用组件
<template>
  <el-dialog title="对比 ">
    <Diff
      mode="split"
      theme="light"
      language="json"
      :prev="oldStr"
      :current="newStr"
      style="height: 500px; margin-top: 20px; overflow-y: auto"
    />
  </el-dialog>
</template>

<script setup>

const obj = {
  room_rule: {
    player_limit: 4,
    hand_ready: 2,
    limit_same_ip: 1,
    game_srcj: 0,
    continue_game: 0,
    start_left_time: 10,
    share_gps: 2,
    yu_yin: 0,
    name: {
      nameStr: "张三",
      level: "大师",
      info: {
        name: "张三",
        desc: "这是个人描述",
        sub: {
          name: "张三",
          desc: "这是个人描述",
        },
      },
    },
    room_name: "欢乐2048",
    room_desc: "无房卡,房卡,金币",
  },
};

const obj1 = {
  room_rule: {
    player_limit: 4,
    hand_ready: 2,
    limit_same_ip: 1,
    game_srcj: 0,
    start_left_time: 10,
    type: 0,
    share_gps: 2,
    yu_yin: 0,
    name: {
      nameStr: "张三",
      level: "大师",
      info: {
        name: "张三000",
        desc: "这是个人描述",
        sub: {
          name: "张三111111",
          desc: "这是个人描述33333",
        },
      },
    },
  },
};

const oldStr = JSON.stringify(obj, null, 2);
const newStr = JSON.stringify(obj1, null, 2);
</script>

<style lang='scss' scoped>
</style>

【Three.js多相机渲染】如何在同一场景里实现“画中画”效果

前言

你以为一个场景只能有一个相机?太天真了

上个月,产品经理拿着一个监控大屏的设计图来找我:

“小叶,你看这个效果——主画面看整个车间,右下角有个小窗口专门盯着那台最关键的设备,实时放大,能不能做?”

我一看,这不就是“画中画”吗?电视里看球赛的时候,主画面全场,小窗口给特写镜头,一模一样。

“能做是能做……”我脑子飞速转了一圈,“但你要知道哦,这是3D场景,不是2D视频,得渲染两次。”

产品经理眨眨眼:“那又怎样?你就说能不能做吧。”

“能。”

为了这个“能”,我研究了一下午多相机渲染。今天就把研究成果分享出来,让大伙儿少走弯路。


一、多相机渲染是啥?

平时我们写Three.js,都是一个场景、一个相机、一个渲染器:

renderer.render(scene, camera);

这叫单视角。

但Three.js允许你在同一帧里用多个相机渲染同一个场景。每个相机可以看到不同的角度、不同的位置,然后通过设置视口(viewport),把它们渲染到屏幕的不同区域。

比如:左边一个相机看整体,右边一个相机看局部;或者主画面占满,右下角一个小窗口显示另一个视角。

这就是多相机渲染。


二、最简单的画中画实现

先来个最基础的版本:主相机看整个场景,副相机放在一个设备旁边,把画面渲染到右下角一个小方框里。

1. 创建两个相机

// 主相机:看整体
const mainCamera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
mainCamera.position.set(10, 10, 20);
mainCamera.lookAt(0, 0, 0);

// 副相机:特写某个设备
const subCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000); // 宽高比暂时设1,后面按视口算
subCamera.position.set(2, 2, 5); // 假设设备在原点附近
subCamera.lookAt(0, 0, 0);

2. 在渲染循环里分别设置视口

function animate() {
  requestAnimationFrame(animate);

  // 1. 渲染主相机(全屏)
  renderer.setViewport(0, 0, window.innerWidth, window.innerHeight);
  renderer.render(scene, mainCamera);

  // 2. 渲染副相机(右下角 300x200 的区域)
  const subWidth = 300;
  const subHeight = 200;
  renderer.setViewport(
    window.innerWidth - subWidth,  // x 起点
    0,                              // y 起点(从底部开始算的话,这里是0)
    subWidth,
    subHeight
  );
  renderer.render(scene, subCamera);
}

注意:setViewport 的坐标系是左下角为原点(0,0)。所以右下角的位置是 (window.innerWidth - subWidth, 0)

3. 别忘了清除深度

两个相机渲染同一个场景,如果不做处理,第二个相机的渲染可能会被第一个相机的深度信息干扰。

解决方案:每次渲染前清除深度缓冲区,但保留颜色缓冲区(或者直接重新清除全部)。

// 渲染主相机前,正常清除
renderer.clear();

// 渲染主相机
renderer.render(scene, mainCamera);

// 渲染副相机前,只清除深度(不清除颜色,否则主画面被清掉)
renderer.clearDepth();
renderer.render(scene, subCamera);

这样副相机的画面就能正确覆盖在主画面之上。


三、让副相机可交互

光有画面还不够,产品经理说:“小窗口能不能也支持旋转、缩放?我想仔细看看那台设备的细节。”

也就是说,副相机得绑定一套控制器。

1. 两套控制器?

直接用两个 OrbitControls 绑到同一个 canvas 上会有冲突。因为鼠标事件只有一个,你不知道用户是想操作主相机还是副相机。

解决方案:通过点击切换激活状态。点击哪个窗口,哪个相机就响应控制器。

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

// 主相机控制器
const mainControls = new OrbitControls(mainCamera, renderer.domElement);
mainControls.enableDamping = true;

// 副相机控制器(先禁用它)
const subControls = new OrbitControls(subCamera, renderer.domElement);
subControls.enabled = false;

// 点击事件判断
renderer.domElement.addEventListener('click', (event) => {
  const mouseX = event.clientX;
  const mouseY = event.clientY;

  // 判断是否点击在副相机区域
  const subWidth = 300;
  const subHeight = 200;
  const subLeft = window.innerWidth - subWidth;
  const subBottom = 0;
  const subRight = window.innerWidth;
  const subTop = subHeight;

  if (mouseX >= subLeft && mouseX <= subRight && mouseY >= subBottom && mouseY <= subTop) {
    // 点击在副窗口,激活副相机控制器,禁用主相机控制器
    subControls.enabled = true;
    mainControls.enabled = false;
  } else {
    // 点击在主窗口,激活主相机控制器,禁用副相机控制器
    subControls.enabled = false;
    mainControls.enabled = true;
  }
});

2. 控制器的更新

在动画循环里,同时更新两个控制器:

function animate() {
  requestAnimationFrame(animate);

  mainControls.update(); // 即使 enabled=false 也可以调用,只是没效果
  subControls.update();

  // 渲染代码同上
  // ...
}

四、进阶:小地图(俯视图)

画中画还有一个常见用法:小地图。主画面是自由视角,右下角显示一个从上往下的固定俯视图,帮助用户定位。

实现起来超级简单:

// 创建俯视相机
const mapCamera = new THREE.OrthographicCamera(-20, 20, 20, -20, 0.1, 100);
mapCamera.position.set(0, 30, 0);
mapCamera.lookAt(0, 0, 0);
mapCamera.up.set(0, 0, 1); // 让Z轴朝上,这样俯视图更符合直觉(如果场景是Z轴朝上的话)

// 在动画循环里
renderer.setViewport(0, 0, window.innerWidth, window.innerHeight);
renderer.render(scene, mainCamera);

renderer.clearDepth();
renderer.setViewport(window.innerWidth - 200, window.innerHeight - 200, 180, 180);
renderer.render(scene, mapCamera);

注意正交相机 OrthographicCamera 的参数:left/right/bottom/top 决定了视景范围,数值越小,放大倍数越大。

为了让小地图更清晰,可以关闭一些后期效果,或者用简单的 MeshBasicMaterial 渲染一个副本,但为了简单,直接复用原场景也行。


五、坑点汇总

1. 宽高比

副相机的宽高比要跟视口的宽高比保持一致,否则画面会拉伸:

subCamera.aspect = subWidth / subHeight;
subCamera.updateProjectionMatrix();

每次窗口大小变化时,也要重新计算。

2. 深度冲突

如果不调用 clearDepth(),第二个相机的渲染可能会因为深度测试失败而显示不全。上面已经给出解法。

3. 控制器冲突

上面用了点击切换的方法,但用户可能想同时操作?不太现实,因为鼠标只有一个。如果非要同时操作,可以考虑把副相机绑定到键盘控制,或者用不同的鼠标按钮。

4. 性能

多渲染一次,性能开销肯定翻倍。如果副相机视口很小,可以降低它的渲染分辨率或关闭阴影、后期等:

// 在渲染副相机前,临时关掉阴影
renderer.shadowMap.enabled = false;
renderer.render(scene, subCamera);
renderer.shadowMap.enabled = true; // 恢复

或者更狠一点:副相机用更低精度的几何体(LOD),但实现起来复杂,这里不展开。

5. 清理

副相机用完记得 dispose,尤其是如果动态创建和销毁:

subCamera = null;
// 如果有控制器,也调用 dispose
subControls.dispose();

六、实战:设备特写画中画

最后分享一下我在那个监控项目里的实际代码片段:

// 初始化
const mainCamera = new THREE.PerspectiveCamera(45, width/height, 0.1, 1000);
const subCamera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
subCamera.position.copy(device.position.clone().add(new THREE.Vector3(1, 1, 2)));
subCamera.lookAt(device.position);

// 动画循环
function render() {
  requestAnimationFrame(render);

  // 更新主控制器
  mainControls.update();

  // 主渲染
  renderer.setViewport(0, 0, width, height);
  renderer.clear();
  renderer.render(scene, mainCamera);

  // 副渲染(右下角 300x200)
  renderer.clearDepth();
  renderer.setViewport(width - 310, 10, 300, 200);
  renderer.render(scene, subCamera);

  // 画一个边框,突出小窗口
  // 可以用CSS,或者用另一个Sprite/Canvas画上去,这里省略
}

产品经理看了很满意,说:“就是这个效果!”

我心想:为了这个效果,我研究了半天多相机,但值了。


七、总结

多相机渲染并不复杂,核心就三步:

  1. 创建多个相机
  2. 在渲染循环里分别设置视口
  3. 处理好深度清除和宽高比

有了它,你可以实现:

  • 画中画特写
  • 小地图导航
  • 分屏对比
  • 多角度监控

下次产品经理再提类似需求,你就可以自信地说:“能,而且我能给你三个方案。”


互动

你用过Three.js的多相机渲染吗?实现了什么好玩的效果?评论区晒出来,让我抄抄作业 😏

下篇预告:【Three.js后期处理】如何让你的场景拥有电影级调色

这5个CSS新特性已经强到离谱,攻城狮直呼内行

今天我跟大佬们聊聊——2026年的CSS,已经不是样式表了,它是披着样式表外衣的编程语言。下面这5个新特性,每一个都能让你删掉一坨JavaScript,最后一个连后端看了都沉默。


1. 自定义CSS函数:终于能写逻辑了,不用再靠Sass

以前想在CSS里复用逻辑怎么办?要么复制粘贴,要么上Sass/Less预处理器。现在,原生CSS支持自定义函数了 

css

@function --responsive-padding(--base) {
  result: if(
    media(min-width: 768px): var(--base);
    else: calc(var(--base) / 2);
  );
}

@function --half(--value) {
  result: calc(var(--value) / 2);
}

.card {
  padding: --responsive-padding(2rem);
  width: --half(100%);
}

惊不惊喜?  CSS终于有了自己的函数系统,可以传参、可以条件判断,还能组合使用 

攻城狮惊呼:  “所以以后写响应式不用复制粘贴十遍媒体查询了?终于不用为了一个函数引入整个Sass了?”


2. border-shape属性:边框终于可以不是方的了

从互联网诞生那天起,边框就是直的。圆角?那是border-radius的事。但你想过吗——边框本身能不能是三角形?能不能是斜边?

现在可以了。border-shape属性正式进入Chrome Canary测试,让你用shape()函数定义边框形状 

css

.fancy-box {
  border: 4px solid #0066cc;
  border-shape: shape(
    from top left,
    hline to 100%,
    vline to 100%,
    curve to 0% 100% with 50% 0%
  );
}

配合shape()函数,你可以画出任意形状的边框,就像SVG路径一样灵活,但完全用CSS语法描述 

这意味着什么?

  • 不用再为特殊边框切图
  • 不用再叠加伪元素hack
  • 所有缩放、响应式都自动适配

攻城狮惊呼:  “所以设计师给的异形边框,现在能1:1还原了?”


3. attr()函数进化:终于能读属性值了,不用data-*了

attr()函数存在了15年,但一直只能用在content属性里,只能读字符串,只能用在伪元素上 

2026年,它彻底解放了 

css

/* 从data-*属性读取数字 */
.timer {
  --seconds: attr(data-seconds number);
  animation: countdown calc(var(--seconds) * 1s) linear;
}

/* 从href读取颜色?也行! */
a[data-color] {
  background-color: attr(data-color color, #0066cc);
  /* 第二个参数是fallback值 */
}

/* 甚至能从HTML属性读长度 */
.progress-bar {
  width: attr(data-progress %, 0%);
}

惊不惊喜?  类型转换、默认值、任意属性、任意CSS值类型——全都支持了 

攻城狮惊呼:  “所以以后进度条可以直接在HTML里写data-progress='50',CSS自动读取?不用JS操作DOM了?”


4. 滚动状态查询:终于知道“粘住”和“滚动中”了

以前想知道元素有没有粘住、有没有被滚动捕捉到,得用Intersection Observer,监听scroll事件,一堆性能杀手代码 

现在,CSS原生检测滚动状态 

css

.sticky-nav {
  container-type: scroll-state;
  position: sticky;
  top: 0;
}

.sticky-nav > nav {
  transition: all 0.3s;
  
  /* 检测是否粘在顶部 */
  @container scroll-state(stuck: top) {
    box-shadow: 0 4px 20px rgba(0,0,0,0.1);
    background: rgba(255,255,255,0.95);
    backdrop-filter: blur(10px);
  }
}

/* 检测滚动捕捉状态 */
.slide {
  container-type: scroll-state;
  scroll-snap-align: start;
  
  @container scroll-state(snapped: block) {
    /* 当前被捕捉到的slide高亮 */
    scale: 1.05;
    transition: scale 0.3s;
  }
}

甚至可以检测“用户是否正在滚动” 

css

.scroll-container {
  container-type: scroll-state;
  
  /* 正在滚动时显示滚动提示 */
  @container scroll-state(scrolled: inline) {
    .scroll-hint { opacity: 1; }
  }
}

攻城狮惊呼:  “所以以前用Intersection Observer监听‘元素出现’的代码,现在全用CSS写了?”


5. 媒体元素伪类:终于能知道视频在播放还是暂停了

以前想根据音频/视频的播放状态改变样式,只能靠JS监听事件、动态加class。现在,浏览器原生告诉你 

css

video {
  border: 4px solid transparent;
  transition: border-color 0.3s;
}

/* 正在播放时 */
video:playing {
  border-color: #00cc66;
}

/* 暂停时 */
video:paused {
  border-color: #ff9900;
}

/* 缓冲时 - 显示加载提示 */
video:buffering::after {
  content: "加载中...";
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(0,0,0,0.7);
  color: white;
  padding: 8px 16px;
  border-radius: 20px;
}

/* 静音时显示图标 */
video:muted::before {
  content: "🔇";
  font-size: 24px;
  position: absolute;
  bottom: 10px;
  right: 10px;
}

支持的伪类有这些 

  • :playing - 正在播放
  • :paused - 暂停
  • :seeking - 跳转中
  • :buffering - 缓冲中
  • :stalled - 卡顿
  • :muted - 静音
  • :volume-locked - 音量锁定

攻城狮惊呼:  “所以以前那个播放器里‘缓冲时显示loading’的JS逻辑,现在一行CSS就搞定了?”


写在最后:CSS正在吃掉前端

这5个特性只是冰山一角。Interop 2026还在推进 

  • 自定义高亮::search-text::spelling-error等新伪元素
  • 视图过渡:跨文档页面切换动画
  • 网格巷道布局:原生瀑布流支持 
  • contrast-color()函数:自动计算对比色 

CSS已经不是当初那个CSS了。

以前我们说“能用CSS解决的问题,就不要用JS”。现在可以改成: “能用CSS解决的问题,都不叫问题。”

那些还在用JS做滚动检测、做播放器状态、做数据属性同步的兄弟们,该升级知识库了。


如果这篇文章让你对CSS刮目相看,点个赞,转个发,让更多朋友看到——CSS真的在吃掉前端。

评论区告诉我:你还在用JS做哪个本可以用CSS实现的功能?我帮你找原生替代方案。

弹窗与抽屉组件封装:如何做一个全局可控的 Dialog 服务

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

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

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

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

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

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

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

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

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

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

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

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

一、先说痛点:你一定经历过的"弹窗地狱"

1.1 最原始的写法

大多数人刚接触 Vue 时,弹窗都是这么写的:

<template>
  <div>
    <el-button @click="showDialog = true">打开弹窗</el-button>

    <el-dialog v-model="showDialog" title="提示">
      <p>确定要删除吗?</p>
      <template #footer>
        <el-button @click="showDialog = false">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>

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

const showDialog = ref(false)

const handleConfirm = () => {
  console.log('用户点了确定')
  showDialog.value = false
}
</script>

这段代码能跑,也没错。但问题是——一个页面如果有 5 个弹窗呢?

你会看到:

const showDeleteDialog = ref(false)
const showEditDialog = ref(false)
const showDetailDialog = ref(false)
const showConfirmDialog = ref(false)
const showUploadDialog = ref(false)

模板里 5 个 <el-dialog>,script 里 5 组 ref + handler。这就是我说的 "弹窗地狱"

1.2 痛点总结

问题 表现
状态散落 每个弹窗一个 ref,页面一复杂就找不到谁控制谁
模板臃肿 <template> 里堆满了弹窗代码,实际页面逻辑被淹没
复用困难 同样的确认弹窗,A 页面写一遍,B 页面再写一遍
流程断裂 想在弹窗确认后继续执行逻辑,需要靠回调层层传递

你有没有想过——能不能像调函数一样调弹窗?

// 梦想中的写法
const result = await dialog.confirm('确定要删除吗?')
if (result) {
  await deleteItem(id)
}

这就是我们今天要做的事。

二、设计思路:从"模板驱动"到"命令式调用"

2.1 两种思维模式

Vue 的核心是声明式——你在模板里写好结构,数据变了,视图自动更新。弹窗用 v-model 控制显隐,这是标准的声明式用法。

但弹窗这个场景比较特殊,它更接近一个**"一次性动作":打开 → 用户操作 → 关闭,然后就没了。它更适合命令式**——我告诉你打开,你告诉我结果。

模式 适合场景 弹窗场景的体验
声明式(模板驱动) 持久存在的 UI,如表单、列表 需要维护额外状态,模板臃肿
命令式(函数调用) 一次性交互,如确认框、通知 调用简洁,流程连贯

2.2 核心设计

我们要封装的 Dialog 服务,核心就三件事:

  1. 选项配置:通过一个配置对象描述弹窗长什么样(标题、内容、按钮文案等)
  2. 回调支持:点确定/取消时能触发对应的回调函数
  3. Promise 化:让弹窗的结果可以被 await,融入异步流程

三、第一步:封装基础 Dialog 组件

先别急着搞全局服务,我们从一个配置式的基础弹窗组件开始。

3.1 定义配置类型

// types/dialog.ts

export interface DialogOptions {
  /** 弹窗标题 */
  title?: string
  /** 弹窗内容,可以是字符串,也可以是 VNode */
  content?: string | VNode
  /** 确认按钮文案 */
  confirmText?: string
  /** 取消按钮文案 */
  cancelText?: string
  /** 是否显示取消按钮 */
  showCancel?: boolean
  /** 弹窗宽度 */
  width?: string | number
  /** 点击确认的回调 */
  onConfirm?: () => void | Promise<void>
  /** 点击取消的回调 */
  onCancel?: () => void
  /** 弹窗关闭后的回调(无论确认还是取消) */
  onClosed?: () => void
}

为什么要定义类型? 不是为了装,是为了让调用方有提示。你用 dialog.confirm() 时,IDE 能告诉你可以传什么参数,这是实实在在提升效率的事。

3.2 基础弹窗组件

<!-- components/BaseDialog.vue -->
<template>
  <el-dialog
    v-model="visible"
    :title="options.title || '提示'"
    :width="options.width || '420px'"
    :close-on-click-modal="false"
    @closed="handleClosed"
  >
    <!-- 内容区域 -->
    <div class="dialog-content">
      <!-- 如果 content 是字符串,直接渲染 -->
      <template v-if="typeof options.content === 'string'">
        {{ options.content }}
      </template>
      <!-- 如果是 VNode,用 component 渲染 -->
      <component v-else :is="options.content" />
    </div>

    <!-- 底部按钮 -->
    <template #footer>
      <el-button
        v-if="options.showCancel !== false"
        @click="handleCancel"
      >
        {{ options.cancelText || '取消' }}
      </el-button>
      <el-button
        type="primary"
        :loading="confirmLoading"
        @click="handleConfirm"
      >
        {{ options.confirmText || '确定' }}
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { DialogOptions } from '@/types/dialog'

const props = defineProps<{
  options: DialogOptions
}>()

const visible = ref(false)
const confirmLoading = ref(false)

/** 打开弹窗 */
const open = () => {
  visible.value = true
}

/** 关闭弹窗 */
const close = () => {
  visible.value = false
}

/** 点击确认 */
const handleConfirm = async () => {
  if (props.options.onConfirm) {
    try {
      confirmLoading.value = true
      // 支持 onConfirm 返回 Promise,按钮自动 loading
      await props.options.onConfirm()
    } finally {
      confirmLoading.value = false
    }
  }
  close()
}

/** 点击取消 */
const handleCancel = () => {
  props.options.onCancel?.()
  close()
}

/** 弹窗关闭动画结束后 */
const handleClosed = () => {
  props.options.onClosed?.()
}

defineExpose({ open, close })
</script>

3.3 踩坑点:v-model vs closed 事件的时机

这里要特别注意一个细节:@close@closed 是两个不同的事件。

  • @close:弹窗开始关闭时触发(动画还没结束)
  • @closed:弹窗关闭动画完全结束后触发

为什么用 @closed 而不是 @close 因为如果你在 @close 里就销毁组件或清理数据,用户会看到弹窗内容"闪一下空白"再消失,体验很差。等动画结束再清理,过渡才是丝滑的。

四、第二步:回调模式的使用方式

有了基础组件,我们先看看回调模式怎么用:

<template>
  <div>
    <el-button @click="handleDelete">删除</el-button>
    <BaseDialog ref="dialogRef" :options="dialogOptions" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import BaseDialog from '@/components/BaseDialog.vue'

const dialogRef = ref()
const dialogOptions = ref({})

const handleDelete = () => {
  dialogOptions.value = {
    title: '确认删除',
    content: '删除后不可恢复,确定要继续吗?',
    confirmText: '删除',
    onConfirm: async () => {
      await api.deleteItem(123)
      ElMessage.success('删除成功')
      fetchList() // 刷新列表
    },
    onCancel: () => {
      console.log('用户取消了')
    }
  }
  dialogRef.value.open()
}
</script>

这已经比最初的写法好多了——弹窗的配置和业务逻辑写在一起,不用到处找 ref。但还是有两个问题:

  1. 模板里还是要放一个 <BaseDialog />
  2. 逻辑被"打断"了——你得把确认后的操作塞进 onConfirm 回调里

如果删除操作后面还有其他逻辑呢?回调套回调,又开始嵌套了。

所以我们需要 Promise 化。

五、第三步:Promise 化——让弹窗像 await 一样丝滑

5.1 核心思路

Promise 化的核心思想非常简单:

创建一个 Promise,把它的 resolve 和 reject 交给弹窗的确认和取消按钮。

用户点确认 → resolve(),点取消 → reject()resolve(false)

// 伪代码,感受一下
function confirm(content) {
  return new Promise((resolve, reject) => {
    打开弹窗({
      content,
      onConfirm: () => resolve(true),
      onCancel: () => resolve(false)
    })
  })
}

5.2 实现 useDialog 组合式函数

这是整篇文章最核心的代码,我们一步步拆:

// composables/useDialog.ts

import { createApp, ref, h, type VNode, type Component } from 'vue'
import BaseDialog from '@/components/BaseDialog.vue'
import type { DialogOptions } from '@/types/dialog'
import ElementPlus from 'element-plus'

/**
 * 命令式调用弹窗
 * 内部原理:动态创建一个 Vue 应用实例,挂载到临时 DOM 节点上
 */
function createDialog(options: DialogOptions): Promise<boolean> {
  return new Promise((resolve) => {
    // 1. 创建一个容器节点
    const container = document.createElement('div')
    document.body.appendChild(container)

    // 2. 记录是否已经 resolve,防止重复调用
    let resolved = false

    const safeResolve = (val: boolean) => {
      if (resolved) return
      resolved = true
      resolve(val)
    }

    // 3. 合并选项:把 Promise 的 resolve 注入到回调中
    const mergedOptions: DialogOptions = {
      ...options,
      onConfirm: async () => {
        // 如果用户传了自己的 onConfirm,先执行
        if (options.onConfirm) {
          await options.onConfirm()
        }
        safeResolve(true)
      },
      onCancel: () => {
        options.onCancel?.()
        safeResolve(false)
      },
      onClosed: () => {
        options.onClosed?.()
        // 动画结束后,清理 DOM 和 Vue 实例
        app.unmount()
        container.remove()
      }
    }

    // 4. 创建 Vue 应用实例并挂载
    const app = createApp({
      setup() {
        const dialogRef = ref()

        // 挂载后自动打开弹窗
        const onMounted = () => {
          // 用 nextTick 确保 DOM 已就绪
          setTimeout(() => dialogRef.value?.open(), 0)
        }

        return () =>
          h(BaseDialog, {
            ref: dialogRef,
            options: mergedOptions,
            onVnodeMounted: onMounted
          })
      }
    })

    // 5. 注册 Element Plus(因为是独立的 app 实例)
    app.use(ElementPlus)
    app.mount(container)
  })
}

5.3 关键踩坑:独立 App 实例的样式和插件问题

这里有一个非常容易踩的坑,很多文章不会告诉你:

通过 createApp 创建的实例,和你主应用是完全隔离的!

这意味着:

  • 主应用注册的 Element Plus,新实例里用不了
  • 主应用的 provide/inject,新实例里拿不到
  • 主应用的全局组件、指令,新实例里没有

所以你会看到代码里有一行 app.use(ElementPlus)——这不是多余的,是必须的

如果你的项目用了 Pinia、Vue Router、自定义插件,且弹窗里要用到,也得在新实例里注册:

// 如果弹窗组件里要用 store 或 router
import { createPinia } from 'pinia'
import router from '@/router'

app.use(createPinia())
app.use(router)
app.use(ElementPlus)

更优雅的做法:把主应用用到的插件列表抽出来,封装一个 installPlugins 函数,让主应用和弹窗实例共用:

// plugins/index.ts
import type { App } from 'vue'
import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'

export function installPlugins(app: App) {
  app.use(createPinia())
  app.use(ElementPlus)
  // 其他插件...
}

六、第四步:封装成全局 Dialog 服务

6.1 暴露友好的 API

// services/dialog.ts

import { type VNode } from 'vue'
import type { DialogOptions } from '@/types/dialog'
import { createDialog } from '@/composables/useDialog'

/**
 * 全局 Dialog 服务
 * 用法:
 *   await dialog.confirm('确定删除?')
 *   await dialog.alert('操作成功')
 *   await dialog.open({ title: '自定义', content: h(MyComponent) })
 */
const dialog = {
  /**
   * 确认弹窗(有确定和取消按钮)
   * 返回 true 表示用户点了确认,false 表示取消
   */
  confirm(
    content: string | VNode,
    options?: Partial<DialogOptions>
  ): Promise<boolean> {
    return createDialog({
      title: '确认',
      content,
      showCancel: true,
      ...options
    })
  },

  /**
   * 提示弹窗(只有确定按钮)
   * 用户点确定后 resolve
   */
  alert(
    content: string | VNode,
    options?: Partial<DialogOptions>
  ): Promise<boolean> {
    return createDialog({
      title: '提示',
      content,
      showCancel: false,
      ...options
    })
  },

  /**
   * 完全自定义弹窗
   * 传入完整的配置对象
   */
  open(options: DialogOptions): Promise<boolean> {
    return createDialog(options)
  }
}

export default dialog

6.2 实际业务中使用

现在来看看调用有多舒服:

<template>
  <div class="user-list">
    <el-table :data="userList">
      <el-table-column prop="name" label="姓名" />
      <el-table-column prop="email" label="邮箱" />
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button size="small" @click="handleEdit(row)">编辑</el-button>
          <el-button size="small" type="danger" @click="handleDelete(row)">
            删除
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import dialog from '@/services/dialog'

const userList = ref([/* ... */])

/** 删除用户——注意看,流程多清晰 */
const handleDelete = async (row) => {
  // 第一步:询问用户
  const confirmed = await dialog.confirm(
    `确定要删除用户「${row.name}」吗?删除后不可恢复。`,
    { title: '删除确认', confirmText: '确认删除' }
  )

  // 第二步:用户取消,直接 return
  if (!confirmed) return

  // 第三步:调接口删除
  try {
    await api.deleteUser(row.id)
    ElMessage.success('删除成功')
    fetchUserList()
  } catch (err) {
    ElMessage.error('删除失败,请重试')
  }
}

/** 批量删除——串行多个弹窗也很自然 */
const handleBatchDelete = async (ids: number[]) => {
  const step1 = await dialog.confirm(`即将删除 ${ids.length} 条记录`)
  if (!step1) return

  const step2 = await dialog.confirm(
    '此操作不可逆,是否已经备份相关数据?',
    { title: '二次确认', confirmText: '已备份,继续删除' }
  )
  if (!step2) return

  await api.batchDelete(ids)
  await dialog.alert('批量删除完成')
  fetchUserList()
}
</script>

对比一下之前的写法——没有额外的 ref,没有模板里的 <el-dialog>,流程像读文章一样从上往下。这就是 Promise 化的威力。

七、进阶:在弹窗里渲染自定义组件

确认框只是最简单的场景。实际业务中,弹窗里经常要放表单详情甚至是一个完整的子页面

7.1 渲染自定义组件

import { h } from 'vue'
import EditUserForm from '@/components/EditUserForm.vue'

const handleEdit = async (row) => {
  const confirmed = await dialog.open({
    title: '编辑用户',
    width: '600px',
    content: h(EditUserForm, {
      userId: row.id,
      // 可以通过 props 传值给弹窗内的组件
    }),
    onConfirm: async () => {
      // 这里怎么拿到表单数据?继续看下面
    }
  })
}

7.2 踩坑:弹窗和内部组件的通信

这是一个高频踩坑点:弹窗的确认按钮在外面,表单在里面,点确认时要拿到表单数据并校验——这个数据怎么传出来?

方案一:通过 ref 拿子组件实例(不推荐)

createApp 方案中,你很难直接拿到弹窗内部组件的 ref,因为是动态创建的。

方案二:通过事件 / 回调传递(推荐)

改造一下,让自定义组件通过回调把数据"交"出来:

// 用一个中间变量承接表单组件的数据和方法
const formActions = { validate: null, getData: null }

const confirmed = await dialog.open({
  title: '编辑用户',
  width: '600px',
  content: h(EditUserForm, {
    userId: row.id,
    // 表单组件挂载后,把自己的方法暴露出来
    onReady: (actions) => {
      formActions.validate = actions.validate
      formActions.getData = actions.getData
    }
  }),
  onConfirm: async () => {
    // 先校验
    const valid = await formActions.validate()
    if (!valid) throw new Error('校验不通过') // 抛错可以阻止弹窗关闭
    // 再提交
    const data = formActions.getData()
    await api.updateUser(row.id, data)
    ElMessage.success('更新成功')
  }
})

表单组件那边:

<!-- components/EditUserForm.vue -->
<template>
  <el-form ref="formRef" :model="form" :rules="rules">
    <el-form-item label="姓名" prop="name">
      <el-input v-model="form.name" />
    </el-form-item>
    <el-form-item label="邮箱" prop="email">
      <el-input v-model="form.email" />
    </el-form-item>
  </el-form>
</template>

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

const props = defineProps<{
  userId: number
}>()

const emit = defineEmits<{
  ready: [actions: { validate: () => Promise<boolean>; getData: () => any }]
}>()

const formRef = ref()
const form = ref({ name: '', email: '' })

const rules = {
  name: [{ required: true, message: '请输入姓名' }],
  email: [{ required: true, message: '请输入邮箱' }]
}

onMounted(async () => {
  // 加载用户数据
  const data = await api.getUser(props.userId)
  form.value = data

  // 把校验和取数方法暴露给外部
  emit('ready', {
    validate: () => formRef.value.validate().catch(() => false),
    getData: () => ({ ...form.value })
  })
})
</script>

7.3 踩坑:onConfirm 抛错阻止关闭

注意上面 onConfirm 里的 throw new Error('校验不通过')。我们需要改造一下 BaseDialog 的确认逻辑,让它支持"校验不通过时不关闭":

// BaseDialog.vue 中修改 handleConfirm
const handleConfirm = async () => {
  if (props.options.onConfirm) {
    try {
      confirmLoading.value = true
      await props.options.onConfirm()
    } catch (e) {
      // onConfirm 抛错了,不关闭弹窗,只取消 loading
      confirmLoading.value = false
      return // 注意这里 return 了,不会走到下面的 close()
    } finally {
      confirmLoading.value = false
    }
  }
  close()
}

这个设计非常实用onConfirm 正常执行完 → 自动关闭;onConfirm 抛错 → 不关闭,用户可以修改后重试。

八、同理可得:抽屉(Drawer)服务

抽屉和弹窗的封装思路完全一致,只是底层组件从 el-dialog 换成 el-drawer。我们可以复用同一套逻辑:

// services/drawer.ts

import { createApp, ref, h } from 'vue'
import BaseDrawer from '@/components/BaseDrawer.vue'
import type { DrawerOptions } from '@/types/drawer'

export interface DrawerOptions {
  title?: string
  content?: string | VNode | Component
  /** 抽屉方向 */
  direction?: 'rtl' | 'ltr' | 'ttb' | 'btt'
  /** 抽屉宽度/高度 */
  size?: string | number
  onConfirm?: () => void | Promise<void>
  onCancel?: () => void
  onClosed?: () => void
}

function createDrawer(options: DrawerOptions): Promise<boolean> {
  // 和 createDialog 几乎一模一样
  // 只是内部渲染的是 BaseDrawer 组件
  return new Promise((resolve) => {
    const container = document.createElement('div')
    document.body.appendChild(container)

    let resolved = false
    const safeResolve = (val: boolean) => {
      if (resolved) return
      resolved = true
      resolve(val)
    }

    const mergedOptions = {
      ...options,
      onConfirm: async () => {
        await options.onConfirm?.()
        safeResolve(true)
      },
      onCancel: () => {
        options.onCancel?.()
        safeResolve(false)
      },
      onClosed: () => {
        options.onClosed?.()
        app.unmount()
        container.remove()
      }
    }

    const app = createApp({
      setup() {
        const drawerRef = ref()
        const onMounted = () => {
          setTimeout(() => drawerRef.value?.open(), 0)
        }
        return () =>
          h(BaseDrawer, {
            ref: drawerRef,
            options: mergedOptions,
            onVnodeMounted: onMounted
          })
      }
    })

    app.use(ElementPlus)
    app.mount(container)
  })
}

const drawer = {
  open(options: DrawerOptions): Promise<boolean> {
    return createDrawer(options)
  }
}

export default drawer

使用方式:

import drawer from '@/services/drawer'
import UserDetail from '@/components/UserDetail.vue'

const handleViewDetail = async (row) => {
  await drawer.open({
    title: '用户详情',
    size: '40%',
    direction: 'rtl',
    content: h(UserDetail, { userId: row.id })
  })
}

九、终极优化:抽取公共逻辑,一个工厂搞定

你会发现,Dialog 和 Drawer 的 create 函数长得几乎一样。我们可以抽一个工厂函数:

// composables/createOverlayService.ts

import { createApp, ref, h, type Component } from 'vue'
import ElementPlus from 'element-plus'

interface OverlayOptions {
  onConfirm?: () => void | Promise<void>
  onCancel?: () => void
  onClosed?: () => void
  [key: string]: any
}

/**
 * 覆盖层服务工厂
 * @param OverlayComponent 底层组件(BaseDialog 或 BaseDrawer)
 */
export function createOverlayService<T extends OverlayOptions>(
  OverlayComponent: Component
) {
  return function create(options: T): Promise<boolean> {
    return new Promise((resolve) => {
      const container = document.createElement('div')
      document.body.appendChild(container)

      let resolved = false
      const safeResolve = (val: boolean) => {
        if (resolved) return
        resolved = true
        resolve(val)
      }

      const mergedOptions: T = {
        ...options,
        onConfirm: async () => {
          await options.onConfirm?.()
          safeResolve(true)
        },
        onCancel: () => {
          options.onCancel?.()
          safeResolve(false)
        },
        onClosed: () => {
          options.onClosed?.()
          app.unmount()
          container.remove()
        }
      }

      const app = createApp({
        setup() {
          const overlayRef = ref()
          const onMounted = () => {
            setTimeout(() => overlayRef.value?.open(), 0)
          }
          return () =>
            h(OverlayComponent, {
              ref: overlayRef,
              options: mergedOptions,
              onVnodeMounted: onMounted
            })
        }
      })

      app.use(ElementPlus)
      app.mount(container)
    })
  }
}

然后 Dialog 和 Drawer 服务各只需要几行:

// services/dialog.ts
import { createOverlayService } from '@/composables/createOverlayService'
import BaseDialog from '@/components/BaseDialog.vue'
import type { DialogOptions } from '@/types/dialog'

const createDialog = createOverlayService<DialogOptions>(BaseDialog)

export default {
  confirm: (content, options?) => createDialog({ title: '确认', content, showCancel: true, ...options }),
  alert: (content, options?) => createDialog({ title: '提示', content, showCancel: false, ...options }),
  open: (options) => createDialog(options)
}
// services/drawer.ts
import { createOverlayService } from '@/composables/createOverlayService'
import BaseDrawer from '@/components/BaseDrawer.vue'
import type { DrawerOptions } from '@/types/drawer'

const createDrawer = createOverlayService<DrawerOptions>(BaseDrawer)

export default {
  open: (options) => createDrawer(options)
}

十、踩坑汇总与最佳实践

10.1 高频踩坑清单

# 原因 解决方案
1 弹窗里 Element Plus 组件不渲染 createApp 创建的是独立实例,没注册 Element Plus 新实例里也要 app.use(ElementPlus)
2 弹窗里拿不到 Pinia store 数据 独立实例没有注册 Pinia 新实例里也要 app.use(pinia)
3 关闭弹窗时内容"闪空" @close 而不是 @closed 里清理数据 使用 @closed 事件做清理
4 弹窗关闭后 DOM 节点没清理 忘了 container.remove() onClosedapp.unmount() + container.remove()
5 确认按钮点了弹窗就关了,但接口还没调完 onConfirm 没有 await 异步操作 onConfirm 支持返回 Promise,按钮自动 loading
6 表单校验失败弹窗也关了 没有处理 onConfirm 中的错误 onConfirm 抛错时 return 不调用 close()
7 多次快速点击打开了多个弹窗 没做防重复打开的控制 加一个标志位或用防抖
8 Promise 被 resolve 了两次 点确认后又触发了关闭按钮的逻辑 safeResolve 加标志位防止重复 resolve

10.2 选型建议

场景 推荐方案 原因
简单确认/提示 dialog.confirm() / dialog.alert() 一行代码搞定
弹窗内有简单表单 dialog.open() + h(FormComponent) 组件传 props + onReady 暴露方法
弹窗内有复杂页面级组件 还是考虑声明式 <el-dialog> 太复杂的组件动态创建会有各种边界问题
全局统一的删除确认 封装 useDeleteConfirm Hook 进一步收敛,一处修改全局生效

10.3 不要过度设计

最后说一句大实话:不是所有弹窗都需要命令式调用

如果一个弹窗:

  • 内部有很重的组件(富文本编辑器、地图等)
  • 需要和父组件频繁通信
  • 生命周期内要维护大量状态

那老老实实写在模板里,用 v-model 控制,反而更稳。

命令式服务最适合的场景是:轻量级、一次性、确认类的交互。别拿着锤子看什么都是钉子。

十一、完整项目结构一览

src/
├── components/
│   ├── BaseDialog.vue          # 基础弹窗组件
│   └── BaseDrawer.vue          # 基础抽屉组件
├── composables/
│   └── createOverlayService.ts # 覆盖层服务工厂函数
├── services/
│   ├── dialog.ts               # Dialog 服务(confirm / alert / open)
│   └── drawer.ts               # Drawer 服务
├── types/
│   ├── dialog.ts               # Dialog 配置类型定义
│   └── drawer.ts               # Drawer 配置类型定义
└── plugins/
    └── index.ts                # 插件统一注册

总结

阶段 做了什么 解决了什么问题
原始写法 v-if + ref 控制 能用,但状态散乱、模板臃肿
配置式组件 把选项抽成对象传入 复用性提升,但模板里还得放组件
回调模式 onConfirm / onCancel 逻辑集中了,但回调嵌套还是烦
Promise 化 await 接收结果 流程清晰如读代码,告别回调地狱
全局服务 dialog.confirm() 一行调用 任何地方都能用,零模板侵入
工厂抽象 Dialog/Drawer 共用一套创建逻辑 代码精简,扩展方便(Popover 服务也能用)

从"弹窗地狱"到"一行 await",核心就是三个关键词:配置化、回调化、Promise 化

掌握这套封装思路,不仅仅是弹窗——任何"打开 → 交互 → 关闭"的场景(抽屉、气泡确认、全屏预览等),都可以用同样的套路搞定。


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

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

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

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

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

❌