阅读视图

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

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

引言:当组件遇见 CSS

在现代前端开发中,组件化已成为构建用户界面的主流方式。我们将页面拆分为独立、可复用的组件,每个组件管理自己的 HTML、CSS 和 JavaScript。然而,CSS 的设计初衷是全局作用域的 —— 样式一旦定义,就会影响整个页面,这给组件化带来了严峻挑战。

试想一个多人协作的项目:A 同学写了一个按钮组件,类名为 .button;B 同学也写了一个按钮组件,同样用了 .button。当两个组件同时出现在页面上时,后加载的样式会覆盖前者,造成意料之外的 UI 错乱。如何让组件的样式“与世隔绝”,既不影响他人,也不被他人影响?本文将深入探讨 React 和 Vue 生态中三种主流的样式隔离方案:CSS Modulesstyled-componentsVue scoped。我们将通过实际代码,由浅入深地理解它们的原理与用法。

1. CSS 的“先天不足”与组件化的冲突

在传统网页开发中,我们通常这样写 CSS:

/* global.css */
.button {
  background-color: blue;
  color: white;
}

这个 .button 样式会作用于页面上所有带有 class="button" 的元素,无论它身处哪个组件。这种全局性在小型项目中或许无伤大雅,但在组件化架构下却成了灾难。

假设我们有 Button.jsxAnotherButton.jsx 两个组件,分别引入了各自的 CSS 文件:

/* Button.css */
.button { background: blue; }

/* AnotherButton.css */
.button { background: red; }
效果图

image.png

最终页面上两个按钮都会是红色,因为后引入的 AnotherButton.css 覆盖了前者的规则。这就是样式冲突的典型场景。

为了解决这一问题,社区发展出了多种作用域隔离方案,核心思想都是将样式“限定”在组件内部。下面我们分别看看 React 和 Vue 是如何做到的。

2. React 中的 CSS Modules

2.1 什么是 CSS Modules?

CSS Modules 是一种将 CSS 文件编译为局部作用域的技术。它并不是官方的 CSS 规范,而是通过构建工具(如 webpack)在编译时给类名自动添加唯一的哈希字符串,从而实现样式隔离。在 React 项目中,使用 Create React App 或 Vite 脚手架时,开箱即支持 CSS Modules。

2.2 基本用法

我们约定 CSS 文件命名为 *.module.css。在组件中像导入一个对象一样导入样式文件,然后通过对象的属性引用类名。

Button.module.css

.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
}

.txt {
  color: red;
  background-color: orange;
  font-size: 30px;
}

Button.jsx

import styles from './Button.module.css';
console.log(styles); // 输出:{ button: "Button_button__1a2b3c", txt: "Button_txt__4d5e6f" }

export default function Button() {
  return (
    <>
      <h1 className={styles.txt}>你好,世界!!!</h1>
      <button className={styles.button}>My Button</button>
    </>
  );
}
效果图

image.png

在浏览器中,最终渲染的 HTML 类似:

<h1 class="Button_txt__4d5e6f">你好,世界!!!</h1>
<button class="Button_button__1a2b3c">My Button</button>
打开控制台我们点击元素开可以看到每个元素都有唯一的id

image.png

可以看到,原始的类名 .button.txt 被转换成了带有组件名和哈希的唯一类名,从而避免了全局污染。

2.3 多人协作的保障

再来看另一个组件 AnotherButton,它也定义了同名的 .button 样式:

anotherButton.module.css

.button {
  background-color: red;
  color: black;
  padding: 10px 20px;
}

AnotherButton.jsx

import styles from './anotherButton.module.css';

export default function AnotherButton() {
  return <button className={styles.button}>My Another Button</button>;
}

两个组件的样式互不干扰,因为编译后的类名分别是 AnotherButton_button__xxxButton_button__xxx。这正是 CSS Modules 的魅力所在——让开发者无需担心类名冲突,专注于组件本身的样式。

2.4 原理浅析

CSS Modules 的原理并不复杂:在构建阶段,webpack 的 css-loader 会解析 *.module.css 文件,将每个类名映射为一个唯一的标识符(通常是 [文件名]_[类名]__[hash]),同时生成一个映射对象(即 styles)。在 JavaScript 中,我们通过这个映射对象来引用最终的类名,而 CSS 文件中的原始类名则被替换为哈希后的类名。这样,CSS 和 JS 就通过同一份映射关系保证了样式的私有性。

3. React 中的 styled-components

如果说 CSS Modules 是在编译时通过修改类名来实现隔离,那么 styled-components 则代表了另一种思潮:CSS-in-JS,即在 JavaScript 中编写 CSS,并利用 JavaScript 的作用域来实现样式隔离。

3.1 什么是 styled-components?

styled-components 是一个流行的 React 库,它允许你使用 ES6 的模板字符串定义样式组件,这些样式组件会自动生成一个唯一的类名,并将样式注入到 <head> 中。

3.2 基本用法

首先安装 styled-components:

npm install styled-components

然后在组件中创建样式化组件:

import styled from 'styled-components';

// 定义一个带样式的 button 组件
const Button = styled.button`
  background-color: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`;

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  );
}
效果图

image.png 渲染后的 HTML 如下(每个人的截图中的真实类名可能不同):

<button class="sc-axZvf jflFSQ">默认按钮</button>
<button class="sc-axZvf efDizw">主要按钮</button>
打开控制台点开控制台元素,我们同样可以看到每个元素都有唯一id

image.png 这里的 sc-axZvf 是组件标识前缀,同一组件生成的实例共享这个前缀,而 jflFSQ 和 efDizw 则是具体的样式类名,分别对应不同的样式规则(例如一个是默认样式,一个是 primary 样式)。所有样式都被动态地生成为 <style> 标签插入页面头部。

3.3 动态样式与 props

styled-components 的一大优势是支持基于 props 的动态样式。如上例所示,通过 props.primary 可以轻松改变背景色和文字颜色。这比传统 CSS 需要额外维护多个类名要直观得多。

3.4 原理浅析

styled-components 在运行时(runtime)工作:当组件渲染时,它会解析模板字符串中的样式规则,根据 props 计算出最终的 CSS 文本,然后生成一个唯一的类名(如 jflFSQ),并将 CSS 规则以 <style> 标签的形式插入到文档头部。值得注意的是,同一组件(如 Button)的所有实例会共享一个组件级标识(sc-axZvf),而具体样式类名则每个实例或每个变体可能不同。由于每个组件实例都可能生成不同的类名,样式天然是隔离的。同时,它还能自动处理浏览器前缀、关键帧动画等,为开发者提供了良好的体验。

4. Vue 中的 scoped 样式

Vue 作为另一大前端框架,其单文件组件(SFC)提供了内置的样式隔离方案——scoped 属性。

4.1 什么是 scoped?

在 Vue 的单文件组件中,可以在 <style> 标签上添加 scoped 属性,指示该样式只作用于当前组件。它的实现方式是为组件模板中的元素添加唯一的自定义属性(如 data-v-xxxxx),然后通过属性选择器来限制样式的生效范围。每个组件会生成一个唯一的哈希 ID,该组件内的所有元素都会被打上这个 ID 作为属性。

4.2 基本用法

App.vue

<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
  <div>
    <h1 class="txt">Hello world in App</h1>
    <h2 class="txt2">一点点</h2>
    <HelloWorld />
  </div>
</template>

<style scoped>
.txt {
  color: red;
}
.txt2 {
  color: green;
}
</style>

HelloWorld.vue

<template>
  <div>
    <h1 class="txt">你好,世界!!!</h1>
    <h2 class="txt2">一点点</h2>
  </div>
</template>

<style scoped>
.txt {
  color: blue;
}
.txt2 {
  color: orange;
}
</style>
效果图

image.png

4.3 渲染结果与原理

编译后,Vue 会为每个组件生成一个唯一的哈希 ID。假设 App 组件的 ID 为 data-v-7a7a37b1,HelloWorld 组件的 ID 为 data-v-e17ea971。最终渲染的 HTML 结构如下(来自实际截图):

html

<div data-v-7a7a37b1>
  <h1 data-v-7a7a37b1 class="txt">Hello world in App</h1>
  <h2 data-v-7a7a37b1 class="txt2">一点点</h2>
</div>

<div data-v-e17ea971 data-v-7a7a37b1>
  <h1 data-v-e17ea971 class="txt">你好,世界!!!</h1>
  <h2 data-v-e17ea971 class="txt2">一点点</h2>
</div>

仔细观察可以发现:

  • App 组件内的所有元素(包括根 div)都带有自己的 ID data-v-7a7a37b1
  • HelloWorld 组件内的所有元素(包括其根 div)都带有自己的 ID data-v-e17ea971特别地,HelloWorld 的根元素上还额外附加了父组件 App 的 ID data-v-7a7a37b1。这是 Vue 故意设计的,目的是让父组件的样式可以通过属性选择器(如 .txt[data-v-7a7a37b1])作用于子组件的根元素,从而实现父组件对子组件根节点的样式控制(如果父组件样式选择器匹配的话)。

对应的 CSS 会被编译为:

css

.txt[data-v-7a7a37b1] { color: red; }
.txt2[data-v-7a7a37b1] { color: green; }
.txt[data-v-e17ea971] { color: blue; }
.txt2[data-v-e17ea971] { color: orange; }

由于属性选择器的存在,每个组件的样式只作用于带有对应属性的元素,实现了完美的样式隔离。同时,子组件根元素拥有双重属性,使得父组件的样式能够有选择地影响子组件的最外层,保持了样式的可控性。

打开控制台元素,我们就可以看到

image.png

4.4 与 CSS Modules 的对比

Vue 的 scoped 与 React 的 CSS Modules 思路相似,都是通过给选择器附加唯一标识来实现作用域。区别在于:

  • CSS Modules 修改了类名本身,而 Vue 保留了原始类名,额外添加了属性选择器。
  • Vue 的 scoped 无需导入对象,直接在模板中使用原始类名,可读性更好。
  • CSS Modules 需要显式引用 styles 对象,略显繁琐,但胜在灵活(比如可以组合多个类名)。

4.5 原理浅析

Vue 在编译单文件组件时,会为每个组件生成一个唯一的哈希 ID。然后:

  1. 将模板中的所有元素加上该 ID 作为属性(根元素额外加上父组件的 ID,如果存在父组件)。
  2. 将 <style scoped> 中的每条 CSS 规则都加上对应的属性选择器。
  3. 最终生成带作用域的 CSS。

整个过程在构建阶段完成,没有运行时开销,性能极佳。

5. 对比与总结

方案 框架 实现原理 优点 缺点
CSS Modules React / Vue 编译时修改类名,生成哈希映射 静态样式,简单可靠;可与预处理器结合 类名需要引用,模板稍显啰嗦
styled-components React 运行时生成唯一类名,注入 <style> 动态样式能力强;完全组件化;支持 props 运行时开销;包体积较大;调试稍难
Vue scoped Vue 编译时添加唯一属性,属性选择器限制 语法简洁;无运行时开销;保留原始类名 仅适用于 Vue;深度选择器需特殊处理

如何选择?

  • 如果你的项目是 React,且偏好“传统”的 CSS 写法,CSS Modules 是最佳选择,它简单、高效,与设计工具(如 Figma)配合良好。
  • 如果你追求极致的动态样式和组件封装,或者希望将样式也作为组件逻辑的一部分,styled-components 能带来流畅的开发体验。
  • 对于 Vue 项目,scoped 是官方推荐的内置方案,开箱即用,足够满足绝大多数场景。

当然,这些方案并非互斥。在大型项目中,你可能会组合使用它们:用 CSS Modules 处理全局样式库,用 styled-components 处理高频复用的动态组件。重要的是理解每种方案的原理,以便在合适的场景做出正确的选择。

结语

从 CSS 的全局困境到组件样式的精细隔离,前端社区给出了多种优雅的解决方案。无论是 React 的 CSS Modules 和 styled-components,还是 Vue 的 scoped,它们都体现了“关注点分离”到“组件内聚”的思想演进。希望本文能帮助你更好地掌握这些工具,在项目中写出健壮、可维护的样式代码。如果你有更多关于样式隔离的思考或实践,欢迎在评论区交流讨论!

Vue3 嵌套路由 KeepAlive:动态缓存与反向配置方案

在电商系统中,用户的操作路径往往是这样的:

进入商品列表页 → 进行筛选、排序、分页 → 点击进入商品详情页 → 查看后返回列表页。

这时,用户通常有一个非常明确的预期:

  • 筛选条件还在
  • 分页状态保持
  • 滚动位置不丢失

但同时,我们也会遇到另一种场景:

  • 从首页、搜索页或其他模块进入商品列表页时
  • 希望列表页是“全新状态”
  • 需要重新请求数据

也就是说:

同一个页面,在不同来源路径下,对“是否缓存”的期望是不同的。

在 Vue 项目开发中,KeepAlive 是提升用户体验的重要工具。但在复杂的嵌套路由场景(例如:Layout → SubLayout → Page)下,我们常常会遇到两个典型痛点:

1️⃣ 缓存失效

明明在路由里配置了 keepAlive: true
但页面返回时依然重新挂载,onMounted 再次触发,状态丢失。

2️⃣ 控制逻辑越来越混乱

当我们尝试实现:

  • 从 A 页面跳到 B 页面时缓存
  • 从 C 页面跳到 B 页面时不缓存

路由守卫里开始出现大量 if-else 判断,
逻辑逐渐变得难以维护,甚至演变成“屎山”。

如何实现:

  • 精准控制缓存来源
  • 支持嵌套路由结构
  • 避免父级 Layout 被误销毁
  • 同时保持代码清晰、可维护

本文将分享一种“反向配置”的缓存设计方案,并深入解析嵌套路由场景下的一个核心原则:

父随子存。

通过这套设计,我们可以在复杂电商场景中,实现精细化、可控且可扩展的页面缓存策略。

一、核心痛点:为什么嵌套路由下的缓存容易失效?

在 Vue 中,KeepAlive 的本质其实是“链路存活”。

换句话说,只要组件所在的这条渲染链路还存在,它的缓存才能继续保留。一旦链路中某一层被销毁,缓存就会随之消失。

假设我们的路由结构是这样的:

  • 一级容器:WebsiteLayout(顶层布局)
  • 二级容器:UserLayout(用户中心布局)
  • 三级页面:UserProfile、UserFavorites 等子页面
WebsiteLayout
  └── UserLayout
        └── UserProfile / UserFavorites

这里有一个很多人忽略的“真相”:

如果父容器(例如 UserLayout)被销毁,那么它内部缓存的所有子页面,也会被瞬间“物理清空”。

即使你在 UserProfile 上配置了keepAlive: true, 只要 UserLayout 这一层被重新挂载, 内部所有缓存都会失效,onMounted 会再次触发。

这就是嵌套路由场景下缓存“看起来配置了却没生效”的根本原因。

很多开发者只给子页面设置缓存,却忽略了一个关键原则:

子页面想存活,父容器必须先存活。

这也是后文要讲的核心设计理念——“父随子存”原则。

二、 解决方案:反向配置 + 递归缓存

1. 路由配置:由“去向页”决定“来源页”

我们不再在每个页面写复杂的判断,而是在详情页声明:“从我这里回退时,请保持谁的缓存”

// router/index.ts
export const routes = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layouts/UserLayout.vue'),
    meta: { keepAlive: true }, // 父容器必须支持缓存
    children: [
      {
        path: 'favorites',
        name: 'UserFavorites',
        component: () => import('@/views/user/Favorites.vue'),
        meta: { keepAlive: true }
      }
    ]
  },
  {
    path: '/product/:id',
    name: 'Product',
    component: () => import('@/views/product/index.vue'),
    meta: { 
      // 反向配置:从这里回退时,保护以下页面的缓存
      keepAliveSources: ['UserFavorites', 'Home'] 
    }
  }
];

2. 全局守卫:实现“父随子存”

这是整套方案的灵魂。我们需要在路由守卫中做两件事:

  1. 补全链路:进入子页面时,强制将其所有父 Layout 加入缓存名单。
  2. 精准清理:非合法来源进入时,即时销毁旧缓存。
export const cacheStack = ref<string[]>([]);

router.beforeEach(async (to, from, next) => {
    const fromName = from.name as string;
    const keepAliveSources = to.meta.keepAliveSources as string[] || [];

    // 1️⃣ 核心:补全父路由缓存名单
    // 遍历 to.matched,确保当前路由的所有父 Layout 都在缓存数组中
    to.matched.forEach((record) => {
      if (record.meta.keepAlive && record.name) {
        const name = record.name as string;
        if (!cacheStack.value.includes(name)) {
          cacheStack.value.push(name);
        }
      }
    });

    // 2️⃣ 处理来源页逻辑
    if (from.meta.keepAlive && fromName) {
      // 如果来源页本身声明了需要缓存,保留它
      if (!cacheStack.value.includes(fromName)) cacheStack.value.push(fromName);
    } else if (keepAliveSources.includes(fromName)) {
      // 如果去向页声明了它是合法的回退来源,保留它
      if (!cacheStack.value.includes(fromName)) cacheStack.value.push(fromName);
    } else if (fromName) {
      // 3️⃣ 清理:如果不是合法来源,从名单中移除,触发组件销毁
      const index = cacheStack.value.indexOf(fromName);
      if (index > -1) cacheStack.value.splice(index, 1);
    }

    next();
});

3. 布局组件:视图层的配合

在所有包含 router-viewLayout 组件中,必须使用 include 绑定这个全局名单。

  1. WebsiteLayout.vue
// WebsiteLayout.vue
<template>
    <router-view v-slot="{ Component, route }">
      <keep-alive :include="cacheStackList">
        <component 
          :is="Component" 
          :key="route.fullPath" 
        />
      </keep-alive>
    </router-view>
</template>

<script lang="ts" setup name="WebsiteLayout">
  import { defineComponent, computed } from 'vue';
  import { cacheStack } from '/@/router/guard/index';

  const cacheStackList = computed(() => {
    return cacheStack.value
  })
</script>
  1. UserLayout.vue
// UserLayout.vue
<template>
  <div class="flex flex-col">
    <div class="flex gap-6 py-4">
      <!-- 左侧菜单 -->
      <aside class="w-96 shrink-0">
        <UserMenu />
      </aside>

      <!-- 右侧内容 -->
      <main class="flex-1">
        <router-view v-slot="{ Component, route }">
          <keep-alive :include="cacheStackList">
            <component 
              :is="Component" 
              :key="route.fullPath" 
            />
          </keep-alive>
        </router-view>
      </main>
    </div>
  </div>
</template>
<script setup lang="ts" name="User">
import UserMenu from './userMenu/index.vue'
import { cacheStack } from '/@/router/guard/index';
import { computed } from 'vue';
const cacheStackList = computed(() => {
  return cacheStack.value
})
</script>

三、 方案优势

  1. 物理隔离确保生效:通过 to.matched 递归确保父容器存活,彻底解决了嵌套路由“名单对了但不缓存”的问题。

  2. 配置解耦:新加一个详情页时,只需在详情页 meta 里增加一行回退目标,无需改动任何业务组件。

  3. 内存友好:不满足回退条件时,缓存会被立即 splice 清理,避免内存堆积。

defineModel 是进步还是边界陷阱?双数据源组件的选择逻辑

defineModel 是 Vue 3.4 引入的语法糖。

它看起来只是让 v-model 更优雅:

const visible = defineModel<boolean>('visible')

但它背后做的事情,远不止简单的语法糖,甚至改变了组件的状态哲学

传统 v-model 的“单一数据源”假设

在大部分 v-model 语义里,存在一个隐含规则:

只传 prop,不监听 update 事件 = 组件不可更新。

比如对弹窗组件,如果父组件只传递了 visibleprop

<MyDialog :visible="visible" />

我们会认为 MyDialog 的显示和隐藏完全由父组件控制

父组件的 visible 变量是控制 MyDialog 显示/隐藏的唯一数据源

这是一种非常清晰的“受控组件”边界

defineModel 在子组件中引入的本地数据源

但是如果 MyDialog.vuevisibledefineModel 实现时,情况会有些不一样:

<script setup>
  const visible = defineModel('visible')
</script>

<template>
  <div>
    <div>MyDialog 内的 visible:{{ visible }}</div>
    <button @click="visible = !visible">MyDialog 内切换 visible</button>
  </div>
</template>

如果父组件中还是

<MyDialog :visible="visible" />

请问子组件按钮点击时,visible 会变化吗?

答案是:会变化

switch.gif

代码链接

defineModel 做了什么?

直接看 playground 生成的代码:

image.png

defineModel 除了生成对应的 propsemits,还通过 useModel 产生了 MyDialog 内的 visible 变量。

而在 useModel 里,会使用 customRef 创建一个本地变量。

在这个本地变量的设值逻辑里,是这样的(简化):

if (
  !(
    rawProps &&
    // check if parent has passed v-model
    (name in rawProps ||
      camelizedName in rawProps ||
      hyphenatedName in rawProps) &&
    (`onUpdate:${name}` in rawProps ||
      `onUpdate:${camelizedName}` in rawProps ||
      `onUpdate:${hyphenatedName}` in rawProps)
  )
) {
  // no v-model, local update
  localValue = value
  trigger()
}

:visible="visible"@update:visible="..." 任意一个不存在,就会更新本地数据。

翻译一下:

只有父组件同时提供 “prop + @update”,子组件才会始终使用父组件的值

否则 —— 子组件会使用本地变量的数据

useModel 的动态数据源

这意味着:

父组件传入的数据,并不一定是最终数据源。

真正的数据源变成:

  • 有监听 → 父组件
  • 无监听 → 子组件本地

这是一种动态切换的数据源模型。

这是不是问题?

从功能角度看,它很强大。

优点

  • 支持“受控 / 非受控”自动切换
  • 多 model 场景写法更优雅

对于“有内部状态”的组件,非常舒服。比如手风琴组件,使用方不需要提供变量保存手风琴的开关状态。

但对于大部分输入组件,它带来了新的权衡。

模糊了边界

传统设计下:

只传 prop = 组件不可修改

现在:

只传 prop ≠ 不可修改

如果你想让组件真正受控,你必须写:

<MyDialog
  :visible="visible"
  @update:visible="() => {}"
/>

用一个空监听器,强制关闭本地数据源。

这就产生了一个认知断层:

  • 使用者需要知道组件内部是否用了 defineModel
  • 否则无法判断它是否会维护本地状态

组件的状态模型,不再从接口上显式表达。

语义变化

<MyDialog :visible="visible" />

由原本的只读受控语义,隐式拓展出了类似 init-visible 的初始值赋值语义。

而是受控,还是初始值赋值,取决于内部是否使用了 defineModel

总结与想法

defineModel 带来的不只是语法糖。还把

数据源从“静态归属”变成了“动态判断”。

它让 Vue 组件具备了“双数据源能力”。需要清醒认知它的能力边界。

defineModel 并没有让 v-model 更简单,反而让组件的状态模型更复杂。

两个想法:

  • 对于普通的输入组件,尽量避免使用 defineModel,保持单一数据源
  • 在状态不一致的问题排查上,需要考虑缺少监听器引发的问题

要闹哪样?又出现了一款新的格式化插件,尤雨溪力荐,速度提升了惊人的45倍!

前两天刚刚讨论完Vize(参考这篇文章: # 前端圈子又出新东西了,大幅提升解析速度。尤雨溪推荐,但我不太推荐),这两天发现前端又出现新工具了,而且是尤大力荐的,我得到这个消息还算是比较晚的了。

其实这款插件早已官宣,最最关键的一点是,它的速度比咱们常用的Prettier快了整整45倍。

今天咱们简单看一下这款插件 —— oxfmt

image.png

背景

其实前端最近几年一直在致力于底层的革新,原因也非常简单,Js在系统中的运行效率和编解码速度远逊于Rust这样的语言。

所以Vite中的 Rollup 变成了 Rolldownesbuild 变成了 Oxc

大家可能不太清楚 Oxc 是啥,咱们简单过一下。

image.png

OxcVoidZero 团队(Vite 核心团队,尤雨溪的公司)用 Rust 开发的 JS/TS 全链路工具链。

简单说就是以后前端的底层部分全都用 Rust 写了,补齐了 Oxc 以后,Rust在前端领域实现了全替换。

带来的好处不言而喻,首先是速度。

作为编译型语言,Rust 的执行效率接近 C/C++,相比传统前端工具的 JS/Go 实现构建 / 转译速度提升 数倍到数十倍。

并且内存占用降低 50%+,大型项目不会出现 JS 工具的内存溢出 / 卡顿问题,真正意义上实现了闪电般的加载速度

其次做为底层语言,Rust 的所有权、借用检查机制从语法层面杜绝空指针、内存泄漏等常见问题,前端工具的崩溃率、异常率大幅降低,尤其适合大型工程化场景。

最关键的一点,Rust 编译出的二进制文件无需依赖 Node.js 运行时,在 Windows/macOS/Linux 上的执行逻辑、性能表现高度一致。

解决了 JS 工具在不同系统下的兼容性问题,完美解决了跨平台一致性的问题。

Oxfmt

理解了Oxc就能简单说说 Oxfmt 了,Oxfmt 是 Oxc 生态中的代码格式化工具,也是目前已经基本上完成的 Rust 替换 Prettier 的例子。

image.png

Oxfmt Beta 几个关键词过一下:

  • 100% Prettier 兼容,无缝迁移
  • 支持 --migrate-prettier
  • 支持更丰富的文件格式
  • Import 自动排序
  • package.json 自动排序
  • Node.js API
  • IDE 完美支持

因为本身 Oxfmt 就可以看作是 Prettier 的Rust版本,所以团队在开发的时候也选择了对 Prettier API的完全兼容,所以开发者一般是没啥感知的。

你能够感受到的也就是快!

尝鲜

安装 Oxfmt

pnpm add -D oxfmt

这里需要给 oxfmt 配置一下脚本,找到 package.json

{
  "scripts": {
    "fmt": "oxfmt",
    "fmt:check": "oxfmt --check"
  }
}

现在已经可以用了。

# 格式化文件
pnpm run fmt

# 检查格式,但不修改文件
pnpm run fmt:check

以上是比较粗浅的应用,真正想要实现项目内详细可用还需要创建一下配置文件,oxfmt 默认使用 .oxfmtrc.json 作为配置文件。

# 初始化配置文件
oxfmt --init

# 从Prettier迁移
oxfmt --migrate prettier

# 全量格式化
npx oxfmt . --write

工程化应用

日常项目开发过程中主要是保存、提交的时候自动格式化,这个场景应用的比较多。

oxfmt 在 vscode 中可以通过 Oxfmt 官方扩展实现保存格式化。

首先安装 Oxfmt 官方插件,搜索 Oxc 即可。

image.png

.vscode/settings.json 中添加以下配置:

{
    "editor.formatOnSave": true,
    "[vue]": {
        "editor.defaultFormatter": "oxc.oxfmt"
    },
    "[javascript]": {
        "editor.defaultFormatter": "oxc.oxfmt"
    },
    "[typescript]": {
        "editor.defaultFormatter": "oxc.oxfmt"
    }
}

之前用 Prettier 的同学记得关掉,避免冲突。

提交格式化可以通过 pre-commit 钩子实现:

pnpm install -D husky lint-staged
npx husky install
# 添加 pre-commit 钩子
npx husky add .husky/pre-commit "npx lint-staged"

同时在 package.json 中增加相应配置:

{
    "lint-staged": {
        "*.{js,ts,vue,json,css,scss,md}": [
        "oxfmt --write"
        ]
    }
}

总结

我个人比较建议大家从现在开始就把 Prettier 替换为 Oxfmt,原因主要有三:

  • Rust实现前端底层已成为大趋势,未来一定是这套工程大一统。
  • 速度更快,内存用的更少,Vite团队开发。
  • 确实好用,接近无感的存在。

React Context 详解:从入门到性能优化

React Context 详解:从入门到性能优化

本文适合熟悉 Vue 但刚开始学习 React 的开发者,通过 Vue 的 provide/inject 对比来理解 React Context。

一、什么是 Context?

在组件开发中,我们经常遇到这样的场景:某个数据需要在多层嵌套的组件间共享。如果一层层通过 props 传递,代码会变得非常冗长且难以维护,这就是所谓的 "prop drilling" 问题。

React 的 Context 和 Vue 的 provide/inject 都是为了解决这个问题而设计的 —— 它们允许数据跨层级传递,跳过中间组件。

二、React Context 基础用法

核心三步

  1. 创建 Context —— 创建一个数据共享的"通道"
  2. 提供数据 —— 父组件通过 Provider 提供数据
  3. 消费数据 —— 子组件通过 useContext 获取数据

完整示例

// ========== 1. 创建 Context ==========
// context.tsx
import { createContext, useContext } from 'react'

// 定义数据类型
type MyContextValue = {
  name: string
  age: number
}

// 创建 Context(可设置默认值)
const MyContext = createContext<MyContextValue>({ name: '', age: 0 })

// 导出一个 hook 方便使用
const useMyContext = () => useContext(MyContext)

export { MyContext, useMyContext }
// ========== 2. 父组件提供数据 ==========
// parent.tsx
import { MyContext } from './context'
import Child from './child'

const Parent = () => {
  const data = { name: '张三', age: 18 }

  return (
    <MyContext.Provider value={data}>
      <Child />
    </MyContext.Provider>
  )
}
// ========== 3. 子组件消费数据 ==========
// child.tsx
import { useMyContext } from './context'

const Child = () => {
  const { name, age } = useMyContext()
  
  return <div>{name} - {age}岁</div>
}

三、对比 Vue 的 provide/inject

如果你熟悉 Vue,这个概念其实非常相似:

步骤 React Vue
创建 createContext() 无需显式创建
提供 <Context.Provider value={}> provide(key, value)
消费 useContext(Context) inject(key)

Vue 等价写法

<!-- 父组件 -->
<script setup>
import { provide } from 'vue'
import Child from './child.vue'

const data = { name: '张三', age: 18 }
provide('myContext', data)
</script>

<template>
  <Child />
</template>
<!-- 子组件 -->
<script setup>
import { inject } from 'vue'

const { name, age } = inject('myContext')
</script>

<template>
  <div>{{ name }} - {{ age }}岁</div>
</template>

可以看到,两者的设计思想是一致的,只是语法不同:

  • React 使用 JSX 的组件包裹方式 <Context.Provider>
  • Vue 使用 Composition API 的函数调用方式

四、原生 Context 的性能问题

原生 React Context 存在一个性能陷阱:

只要 Context value 中的任何一个字段变化,所有消费这个 Context 的组件都会重新渲染,即使它们只用到了没变的字段。

// 原生 React Context 的问题
const MyContext = createContext({ name: '张三', age: 18, city: '北京' })

// 这个组件只用 name,但 age 或 city 变化时也会重新渲染!
const Child = () => {
  const { name } = useContext(MyContext)
  return <div>{name}</div>
}

当 Context 中有几十个字段时(这在大型应用中很常见),这个问题会严重影响性能。

五、use-context-selector:性能优化方案

为了解决这个问题,社区提供了 use-context-selector 库。它支持选择器模式,让组件只订阅自己关心的字段。

安装

npm install use-context-selector

使用方式

// 从 use-context-selector 导入,而不是 react
import { createContext, useContext } from 'use-context-selector'

const MyContext = createContext({ name: '张三', age: 18, city: '北京' })

// 使用选择器,只订阅 name
const Child = () => {
  const name = useContext(MyContext, v => v.name)  // age 或 city 变化不会触发重渲染
  return <div>{name}</div>
}

核心区别

特性 React 原生 use-context-selector
导入来源 'react' 'use-context-selector'
更新粒度 整个 Context 变化就重渲染 可以用选择器精确订阅某个字段
性能 大型 Context 可能性能差 优化了选择器模式,避免不必要的重渲染
使用方式 useContext(ctx) useContext(ctx, selector?)

六、实际案例分析

以 Dify 项目中的 ChatWithHistoryContext 为例:

// context.tsx
import { createContext, useContext } from 'use-context-selector'

export type ChatWithHistoryContextValue = {
  appMeta?: AppMeta | null
  appData?: AppData | null
  appParams?: ChatConfig
  currentConversationId: string
  conversationList: AppConversationData['data']
  handleNewConversation: () => void
  handleChangeConversation: (conversationId: string) => void
  // ... 还有 20+ 个字段
}

export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
  currentConversationId: '',
  // ... 默认值
})

export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)
// parent.tsx - 提供数据
const ChatWithHistoryWrap = () => {
  const contextValue = useChatWithHistory()  // 获取所有数据

  return (
    <ChatWithHistoryContext.Provider value={contextValue}>
      <ChatWithHistory />
    </ChatWithHistoryContext.Provider>
  )
}
// child.tsx - 消费数据
const ChatWithHistory = () => {
  const { 
    appData, 
    conversationList, 
    handleChangeConversation 
  } = useChatWithHistoryContext()
  
  // 使用数据...
}

这个 Context 有 30+ 个字段,如果使用原生 Context,任何一个字段变化都会导致所有子组件重渲染。使用 use-context-selector 后,框架内部做了优化,避免了不必要的渲染。

七、数据流图解

┌─────────────────────────────────────────────────┐
│  ChatWithHistoryWrap (父组件)                    │
│                                                 │
│  通过 useChatWithHistory() 获取所有数据          │
│  { appData, appParams, conversationList, ... }  │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│  ChatWithHistoryContext.Provider                │
│  value={{ appData, appParams, ... }}            │  ← 数据注入到 Context
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│  ChatWithHistory (子组件)                        │
│                                                 │
│  useChatWithHistoryContext() 获取数据           │
└─────────────────────────────────────────────────┘
                        │
          ┌─────────────┼─────────────┐
          ▼             ▼             ▼
     ┌─────────┐  ┌─────────┐  ┌─────────┐
     │ Sidebar │  │ Header  │  │ ChatWrap│
     └─────────┘  └─────────┘  └─────────┘
          │             │             │
          └─────────────┴─────────────┘
                        │
              孙组件同样可以通过
           useChatWithHistoryContext() 获取数据

八、最佳实践

  1. 小型项目:使用原生 React Context 即可,简单直接
  2. 大型项目:当 Context 字段较多(10+)时,考虑使用 use-context-selector
  3. 拆分 Context:如果可能,将不相关的数据拆分到不同的 Context 中
  4. 命名规范:导出一个自定义 hook(如 useMyContext),统一消费方式

九、总结

场景 推荐方案
简单数据共享 React 原生 Context
大型 Context,字段多 use-context-selector
Vue 背景开发者 理解为 provide/inject 的 React 版本

Context 本质上就是 跨层级传递数据 的工具,理解了这一点,无论是 React 还是 Vue,核心概念都是相通的。

干掉 Virtual DOM?尤雨溪开始"强推" Vapor Mode?

上周 Code Review,我看到同事写了这样一段代码:

const state = reactive({
  user: null,
  loading: false,
  error: '',
  list: []
});

// 后面又单独定义
const currentPage = ref(1);
const pageSize = ref(10);

乍看没问题,但一运行**——页面卡顿、watch 失效、调试器里数据对不上……**

问题出在哪?
不是逻辑错,而是响应式对象的“组合方式”错了

今天,我就用3 条黄金法则 + 2 个实战模板,帮你彻底搞懂 Vue 3 响应式怎么写才高效、安全、可维护。

法则 1:简单值用 ref,复杂对象用 reactive —— 但别混用!

很多教程说:“primitive 用 ref,object 用 reactive”,这没错,但忽略了“解构陷阱”。

错误示范:

const { user, loading } = reactive({ user: null, loading: false });
// 解构后失去响应性!

正确做法:

// 方案 A:全部用 ref(推荐新手)
const user = ref(null);
const loading = ref(false);

// 方案 B:用 toRefs 保持响应性
const state = reactive({ user: null, loading: false });
const { user, loading } = toRefs(state); // ✅ 响应式保留

经验公式:

  • 如果你要频繁解构 or 传递单个属性 → 优先用 ref
  • 如果是完整状态模块(如表单、列表配置)→ 用 reactive + toRefs

法则 2:别把 ref 套进 reactive,除非你真的需要

见过这种写法吗?

const state = reactive({
  count: ref(0), // ❌ 不要!
  name: 'Vue'
});

这会导致:

  • 访问时必须写 state.count.value(破坏一致性)
  • 模板中虽然自动 unwrap,但逻辑层混乱
  • 容易引发“value 嵌套地狱”

正确做法:统一层级

// 要么全 ref
const count = ref(0);
const name = ref('Vue');

// 要么全 reactive(count 直接是 number)
const state = reactive({
  count: 0,
  name: 'Vue'
});

小技巧:在 setup() 返回时,用 ...toRefs(state) 一键暴露所有属性。

法则 3:大型组件,用“状态模块化”代替巨型 reactive

当组件状态超过 5 个字段,别堆在一个 reactive 里!

反面教材:

const state = reactive({
  // 用户信息
  userId, userName, userAvatar,
  // 分页
  page, size, total,
  // 搜索条件
  keyword, status, dateRange,
  // UI 状态
  showDrawer, loading, errorMsg...
});

推荐拆分:

// 按功能拆成多个小状态块
const userState = reactive({ id: '', name: '', avatar: '' });
const pagination = reactive({ page: 1, size: 10, total: 0 });
const uiState = reactive({ loading: false, drawerVisible: false });

// 或封装成 composable
const { userState } = useUserStore();
const { pagination, fetchList } = usePagination();

这样不仅逻辑清晰,还天然支持 逻辑复用(比如分页逻辑抽成 usePagination)。

实战模板:两种主流写法对比

模板 A:全 ref 风格(适合中小型组件)

export default {
  setup() {
    const loading = ref(false);
    const list = ref([]);
    const keyword = ref('');

    const search = async () => {
      loading.value = true;
      list.value = await api.search(keyword.value);
      loading.value = false;
    };

    return { loading, list, keyword, search };
  }
}

优点:直观、无解构风险、TS 类型推导友好
注意:返回时别漏写 .value

模板 B:reactive + toRefs(适合状态密集型组件)

export default {
  setup() {
    const state = reactive({
      loading: false,
      list: [] as Item[],
      keyword: ''
    });

    const search = async () => {
      state.loading = true;
      state.list = await api.search(state.keyword);
      state.loading = false;
    };

    return { ...toRefs(state), search };
  }
}

优点:状态聚合、减少变量声明、模板中直接用 list
注意:内部操作用 state.xxx,别解构!

高阶建议:结合 更清爽

如果你用 Vue 3.3+,直接上 :

import { ref } from 'vue'

const loading = ref(false)
const list = ref([])
const keyword = ref('')

const search = async () => {
  loading.value = true
  list.value = await api.search(keyword.value)
  loading.value = false
}

没有 return,没有 setup(),变量自动暴露——这才是 Vue 3 的终极舒适区。

最后说两句

Vue 3 的响应式系统很强大,但自由也意味着责任。
用对了,代码清爽如诗;用错了,bug 隐蔽如鬼。

记住三句话:

  1. 简单用 ref,复杂用 reactive
  2. 别混用,别嵌套,别解构裸对象
  3. 大组件,拆状态,抽 composable

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

干掉 Virtual DOM?尤雨溪开始"强推" Vapor Mode?

前端这两年有一个明显趋势:

用编译优化彻底消灭运行时开销。

从 Rust 重写工具链,到服务端组件,底层正在被全面 "静态化"

而这一次,轮到了 Vue。

一个正在快速演进的技术 ——Vapor Mode,正在尝试用 Vue 3.5 重构整套 Vue 渲染体系。

它不仅仅是"再快一点",而是想把 Vue 的响应式系统、组件渲染、模板编译、更新机制全部重写为编译时优化

Vapor Mode 到底是什么?

Vapor Mode = 用编译时优化重写 Vue 渲染器。

它是一个全新的渲染模式(非默认),覆盖:

  • 无 Virtual DOM 渲染(细粒度响应式绑定)
  • 编译时依赖追踪(自动依赖收集)
  • 零运行时开销(无 diff 算法)
  • 原生 DOM 操作(直接更新,无代理)
  • 完整生态兼容(Vue Router、Pinia 无缝支持)

你没看错——它不是一个渐进升级,而是一整套"Vue 渲染器重构计划"。

它和普通模式是什么关系?

很多人第一反应:

那它是不是要干掉 Virtual DOM?

答案:不是同一个层级

  • 普通 Vue 模式 = Virtual DOM + 响应式运行时
  • Vapor Mode = 细粒度响应式 + 编译时优化

更准确理解:

  • 普通模式:data 变化 → 触发 setter → 通知依赖 → Virtual DOM diff → 更新真实 DOM
  • Vapor Mode:data 变化 → 直接触发关联 DOM 节点更新

这更像是:

给 Vue 换一颗"零开销引擎"。

快速上手体验

传统模式:Virtual DOM(经典但昂贵)

你以前写 Vue 组件,大概是这样的:

<template>
  <div>
    <h1>{{ title }}</h1>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

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

const title = ref('Hello Vue');
const count = ref(0);

const increment = () => {
  count.value++;
};
</script>

运行时发生了什么?

// 简化后的执行流程
1. 组件初始化:创建 Proxy(title, count)
2. 响应式收集:渲染时追踪依赖(title → h1, count → p)
3. 状态更新:count.value++ 触发 setter
4. 依赖通知:通知所有订阅 count 的组件
5. Virtual DOM diff:对比新旧 VNode6. DOM 更新:真实 DOM 仅更新 p 文本

痛点分析:

  • 每次更新都要运行 Virtual DOM diff(即使只改一个数字)
  • Proxy 开销(内存 + CPU)
  • 响应式系统运行时收集依赖
  • 大型应用下 diff 成本显著

Vapor Mode 方式:零运行时开销

<template>
  <div>
    <h1>{{ title }}</h1>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

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

const title = ref('Hello Vue');
const count = ref(0);

const increment = () => {
  count.value++;
};
</script>

唯一的区别:

<script setup> 中添加 vapor 指令

编译后生成什么?

// 简化后的编译输出(伪代码)
export function render(_ctx) {
  // 1. 直接 DOM 引用(无 VNode)
  const h1 = document.querySelector('h1');
  const p = document.querySelector('p');
  const button = document.querySelector('button');

  // 2. 细粒度绑定(无 Proxy)
  _ctx.title = reactiveValue('Hello Vue', (val) => {
    h1.textContent = val; // 直接更新 DOM
  });

  _ctx.count = reactiveValue(0, (val) => {
    p.textContent = val; // 直接更新 DOM
  });

  button.onclick = () => {
    _ctx.count.value++; // 直接触发更新
  };
}

核心差异:

  • 无 Virtual DOM diff
  • 无 Proxy 开销
  • 编译时依赖追踪
  • 直接 DOM 操作
  • 零运行时响应式系统

架构设计:为什么不只是"更快一点"?

传统 Vue 渲染器(3.x)

  Template Compiler
         ↓
  Render Functions
         ↓
  Virtual DOM Tree
         ↓
  Reconciliation (diff)
         ↓
  Real DOM Updates

特点:

  • 运行时依赖收集(Proxy + Effect)
  • Virtual DOM diff 算法(O(n) 复杂度)
  • 组件级更新(粒度较粗)

Vapor Mode 渲染器

Template Compiler (Vapor)
↓
Dependency Analysis
↓
Fine-grained Binding
↓
Direct DOM Updates

特点:

  • 编译时依赖分析
  • 细粒度绑定(表达式级别)
  • 原生 DOM 操作(无 diff)

对比总结

维度 普通 Vue 模式 Vapor Mode
渲染机制 Virtual DOM diff 直接 DOM 操作
依赖追踪 运行时 Proxy 编译时静态分析
更新粒度 组件级 表达式级
运行时开销 高(diff + Proxy) 极低(仅执行更新逻辑)
编译时优化 有限 极致

性能对比:不是优化,是碾压

官方基准测试(10,000 个简单组件):

场景 普通 Vue 3.4 Vapor Mode 提升
初始渲染 125ms 32ms 3.9×
单个属性更新 8ms 0.8ms 10×
10% 组件更新 45ms 3ms 15×
50% 组件更新 220ms 12ms 18.3×
列表重排序 180ms 5ms 36×

为什么这么快?

1. 无 Virtual DOM diff

// 普通 Vue:每次更新都要 diff
function update() {
  const oldVNode = currentVNode;
  const newVNode = render(); // 重新生成 VNode 树
  const patches = diff(oldVNode, newVNode); // O(n) diff
  applyPatches(patches); // 应用补丁
}

// Vapor Mode:直接更新
function update() {
  textContent.value = newValue; // 直接修改 DOM 文本
}

2. 编译时依赖追踪

<template>
  <div>{{ count }}</div>
</template>
// 普通 Vue:运行时收集
const count = ref(0);
effect(() => {
  div.textContent = count.value; // 运行时追踪依赖
});

// Vapor Mode:编译时已知
const count = reactiveValue(0, (val) => {
  div.textContent = val; // 编译时生成更新逻辑
});

3. 细粒度更新

<template>
  <div>
    <p>Name: {{ user.name }}</p>
    <p>Age: {{ user.age }}</p>
    <p>Email: {{ user.email }}</p>
  </div>
</template>
// 普通 Vue:user 变化 → 整个组件重新渲染
watch(() => user.value, () => {
  render(); // 重渲染整个组件
});

// Vapor Mode:user.name 变化 → 只更新第一个 <p>
user.name.onUpdate((val) => {
  p1.textContent = val; // 只更新对应 DOM
});

user.age.onUpdate((val) => {
  p2.textContent = val;
});

user.email.onUpdate((val) => {
  p3.textContent = val;
});

更疯狂的是:完整生态兼容

Vapor Mode 不是重写所有 Vue,而是无缝集成:

1. Vue Router 兼容

<!-- app.vue -->
<script setup vapor>
import { RouterView } from 'vue-router';
</script>

<template>
  <RouterView /> <!-- Vapor 组件可以渲染普通组件 -->
</template>

2. Pinia 兼容

// stores/counter.ts
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  actions: {
    increment() {
      this.count++;
    }
  }
});
<!-- components/Counter.vue -->
<script setup vapor>
import { useCounterStore } from '@/stores/counter';

const store = useCounterStore();
</script>

<template>
  <p>{{ store.count }}</p>
  <button @click="store.increment">+1</button>
</template>

3. 渐进式采用

<!-- 混合使用:Vapor 组件 + 普通组件 -->
<script setup vapor>
import OrdinaryComponent from './OrdinaryComponent.vue';
</script>

<template>
  <OrdinaryComponent /> <!-- 普通组件在 Vapor 组件中正常工作 -->
</template>

现在能生产使用吗?

部分可用。

Vapor Mode 当前状态:

  • 核心功能稳定(Vue 3.5+)
  • 完整生态兼容
  • TypeScript 支持
  • 部分指令仍在完善(v-for、v-if 复杂场景)
  • 调试工具仍在改进

建议采用场景:

  • 性能敏感型应用(高频更新列表)
  • 移动端应用(低性能设备)
  • 数据可视化(实时图表)
  • 简单 CRUD 应用(收益不明显)

总结一句话

如果说:

  • Vue 2 用 Virtual DOM 解决了 "跨浏览器兼容性"
  • Vue 3 用 Composition API 解决了 "代码复用性"

那 Vapor Mode 正在解决 "极致性能"

它可能不会明天取代所有 Vue 模式(渐进升级策略),

但它已经说明了一件事:

Vue 的未来,不止是框架升级,而是渲染器升级。

如果你是:

  • Vue 深度使用者
  • 性能优化爱好者
  • 编译原理探索者
  • 或对前端性能有极致追求

这个技术值得关注。

官方资源:

扩展阅读:

  • SolidJS 的细粒度响应式系统
  • Svelte 的编译时优化
  • Qwik 的 Resumability 架构

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

给 Vue 开发者的 uni-app 快速指南

作为一名熟悉 Vue 的开发者,当你第一次接触 uni-app 时,好消息是:你已经掌握了 uni-app 80% 的知识。uni-app 的核心就是 Vue 语法加上一套跨端编译机制。

但剩下的 20% 往往是新手踩坑的重灾区。这篇文章将用大白话,从底层原理到实战技巧,带你迅速跨越这 20% 的认知鸿沟,看完即可直接上手企业级项目。


一、 核心认知转换:你不再只写网页了

在写纯 Vue 时,你的代码运行在浏览器里,你可以肆无忌惮地操作 DOM(document.getElementById)、使用 BOM(window.location)。

但在 uni-app 中,你的代码可能要运行在微信小程序原生 App 中。 官方文档明确指出:App 和小程序的逻辑层和渲染层是分离的

  • 逻辑层:运行在独立的 JS 引擎中(App 上是 V8 或 JSCore,小程序是微信的 JS 引擎)。
  • 渲染层:运行在 WebView 或原生渲染引擎(nvue/uts)中。

结论与习惯改变

  1. 彻底戒掉 DOM/BOM 操作:你的 JS 代码环境里压根没有 windowdocument
  2. 数据驱动一切:严格遵守 Vue 的响应式数据驱动视图理念。如果非要操作视图元素(比如获取节点高度),必须使用 uni-app 提供的 uni.createSelectorQuery()

二、 主要的语法与开发差异

1. 标签(组件)的降维打击

在 Web 开发中,我们用 HTML 标签(div, span, img)。在 uni-app 中,为了兼容小程序和 App,必须使用基础组件(靠近微信小程序规范)。

  • <div> ➡️ <view>
  • <span> ➡️ <text>
  • <img> ➡️ <image> (注意:image 默认有宽高,不像 img 默认由内容撑开)
  • <a> ➡️ <navigator>

2. 路由管理的剥离

Vue 开发者习惯用 vue-router。但在 uni-app 中,不需要也不建议使用 vue-router。 uni-app 采用类似小程序的路由配置方式:

  • 配置:所有页面必须在 pages.json 中注册。
  • 跳转:使用 uni.navigateTo()uni.redirectTo()uni.switchTab() 等 API,或者 <navigator> 标签。
// 伪代码对比
// Vue Router 跳转
router.push({ path: '/pages/detail', query: { id: 1 } })

// uni-app 跳转
uni.navigateTo({
  url: '/pages/detail/detail?id=1'
})

3. 生命周期的“加戏”

除了 Vue 原生的生命周期(onMounted, onUnmounted 等),uni-app 引入了应用生命周期页面生命周期

  • 页面生命周期:最常用的是 onLoad(options)(页面加载,接收路由参数)和 onShow()(页面每次显示)。
  • 注意点:Vue 的 created / setup 会在 onLoad 之前执行。建议初始化网络请求写在 onLoad,而不是 onMounted,因为 onLoad 能直接拿到路由参数。

三、 API 与组件库选择

1. API 的替换

所有 Web 专属 API 都被 uni-app 重新封装成了跨端 API:

  • 网络请求:axios / fetch ➡️ uni.request()
  • 本地存储:localStorage.setItem ➡️ uni.setStorageSync()
  • 弹窗提示:alert / ElMessage ➡️ uni.showToast() / uni.showModal()

2. UI 组件库的断舍离

千万不要在 uni-app 中使用 Element Plus、Ant Design 等基于 DOM 的 Web UI 库! 它们在小程序和 App 中会直接报错。

推荐的跨端组件库

  1. uni-ui:官方出品,最稳妥,兼容性最好,按需引入,体积小。
  2. uView Plus:目前生态最火的 Vue3 跨端组件库,功能极其丰富,适合快速外包和后台系统。
  3. ThorUI / GraceUI:部分收费,设计感较好。

四、 版本差异:Vue 与 uni-app 的恩怨情仇

1. Vue 2 vs Vue 3

uni-app 目前全面拥抱 Vue 3。

  • Vue 2 版:底层基于 Webpack 编译。
  • Vue 3 版:底层基于 Vite 编译,编译速度极快(极度推荐)。
  • 语法支持:完全支持 <script setup> 和 Composition API。

2. uni-app 自身的版本分支

  • 普通 uni-app:写 .vue 文件,编译到各端。
  • uni-app x:DCloud 推出的下一代产品,使用 UTS 语言(强类型),在 App 端直接编译为 Kotlin/Swift,纯原生运行,没有 WebView。如果你刚入门,先学普通 uni-app,不要碰 uni-app x,生态还在建设中。

五、 跨端利器:条件编译(核心魔法)

这是 uni-app 最伟大的发明。当某个功能(比如微信支付、App 极光推送)无法跨端时,你可以用特殊注释让代码只在特定平台编译

<template>
  <view>
    <!-- #ifdef MP-WEIXIN -->
    <button open-type="getPhoneNumber">只有微信小程序会编译这个按钮</button>
    <!-- #endif -->

    <!-- #ifdef APP-PLUS -->
    <button @click="appPay">只有原生 App 会编译这个按钮</button>
    <!-- #endif -->
  </view>
</template>

<script setup>
const login = () => {
  // #ifdef H5
  console.log('执行 H5 的网页登录逻辑')
  // #endif

  // #ifndef H5
  console.log('除了 H5 之外的平台(小程序、App)都会执行这段代码')
  // #endif
}
</script>

<style>
/* #ifdef MP-ALIPAY */
.box { background: blue; } /* 只在支付宝小程序生效 */
/* #endif */
</style>

六、 已知的坑与避坑指南(高价值经验)

  1. 逻辑层与渲染层的通讯瓶颈

    • :在 Vue 中,你把一个包含 10000 个对象的巨型数组绑定到视图上,可能只是稍微卡顿。但在 uni-app(小程序/App)中,数据需要从逻辑层(JS引擎)序列化后通过 Bridge 传递给渲染层(WebView)。传递巨型数据会直接导致页面卡死
    • 避坑:视图不需要的数据(如复杂的内部状态),不要放在 refreactive 中,直接用普通变量。长列表必须使用 <scroll-view> 或专门的虚拟列表组件。
  2. CSS 作用域与深度选择器

    • :小程序环境对 CSS 隔离非常严格。
    • 避坑:尽量每个组件都加 <style scoped>。修改子组件(如 uni-ui)样式时,Vue3 中使用 :deep(.uni-card),但要注意某些小程序平台可能不支持过于复杂的深度选择器嵌套。
  3. v-show 的陷阱

    • :在某些原生渲染(nvue)或特定小程序下,v-show 的表现可能不如预期(因为底层不支持 display: none)。
    • 避坑:尽量使用 v-if 控制显隐,除非该组件频繁切换且初始化极其耗时。
  4. 图片路径问题

    • :背景图片如果是本地路径,在 App 端打包后经常找不到。
    • 避坑:小图片转 Base64,大图片必须放在 static 目录下(绝对路径 /static/xxx.png),或者直接使用网络图片(CDN)。

七、 最佳开发习惯建议

  1. 开发工具选择

    • 虽然可以用 VSCode,但强烈建议使用官方的 HBuilderX。它的条件编译高亮、一键运行到微信开发者工具/真机、以及对 pages.json 的智能提示,会为你节省大量配环境的时间。
  2. 多端同步预览

    • 不要在 H5(浏览器)里开发了整整一个月,最后才去跑微信小程序和 App。H5 是最宽容的平台
    • 正确姿势:开发时,至少同时打开浏览器(看 H5)和微信开发者工具(看小程序),确保样式和逻辑在双端表现一致。
  3. 拥抱 uniCloud(可选)

    • 如果你的项目没有后端,可以尝试官方的 uniCloud(Serverless 云开发),前端工程师可以直接用 JS 写云函数操作数据库,全栈开发体验极佳。

总结: 把 uni-app 当作一个受限的 Vue 环境。收起操作 DOM 的野心,严格遵循数据驱动,善用条件编译处理平台差异,你就能在一周内成为跨端开发的高手。

以上内容仅代表个人理解,不喜勿碰,遗漏或者有误的欢迎评论区指出

重走 Vue 长征路 Weapp-vite:编译链路与 Wevu 运行时原理拆解

bg.jpg

重走 Vue 长征路 Weapp-vite:编译链路与 Wevu 运行时原理拆解

书接上篇

我当时在团队里做《Vue 编译本质论》分享,正好把一些判断过程也整理了下来:为什么这么做,没选什么,以及这些取舍在小程序里到底值不值。

如果你更关心怎么上手,先看发布文会更顺:Weapp-vite:原生模式之外,多一种 Vue SFC 选择

先把边界说清:Wevu 不是 Vue 3 的搬运工

Wevu 用起来确实很像 Vue 3,但骨子里不是一回事。

对比维度 Vue 3 Wevu
运行环境 Web 浏览器 微信小程序
响应式系统 Proxy + effect Proxy + effect(同源)
渲染目标 DOM 节点 小程序页面/组件实例
渲染方式 Virtual DOM Diff → DOM API Snapshot Diff → setData
数据模型 VNode 树 纯 JS 对象快照
更新机制 异步调度 + DOM 操作 异步调度 + setData
生命周期 onMounted/onUpdated 等 映射到小程序生命周期
事件系统 DOM 事件 小程序 bind/catch 事件
SFC 编译 @vitejs/plugin-vue Weapp-vite 内置

说白了就一件事:响应式 API 长得一样,但最后数据往哪送、怎么送,完全不同

API 为什么能"几乎同写法"

refcomputedwatch 这些在 wevu 里跟 Vue 3 写法一模一样,没必要再造一套 DSL 出来。

import { computed, ref, watch } from 'wevu'

const count = ref(0)
const doubled = computed(() => count.value * 2)

watch(count, (val) => {
  console.log('count changed:', val)
})

很多团队迁过来之后第一反应不是"又要学新东西",而是"这不就是我平时写的吗,换了个宿主而已"。

渲染链路才是真正不一样的地方

Vue 3 走的是这条路:

状态变化 -> effect 触发 -> 组件更新 -> VNode Diff -> DOM 操作

Wevu 走的是这条:

状态变化 -> effect 触发 -> 快照 Diff -> setData -> 小程序渲染

Wevu 干的事情说穿了就是把"算出哪些东西变了"这一步尽量提前做完,等到真正调 setData 的时候,payload 已经被压到最小了。这在小程序里特别关键——大家踩过坑的都知道,setData 传多了,页面就卡,尤其是列表页。

.vue 到四件套:编译阶段干了啥

一个 MyComponent.vue 最终会变成小程序四件套:

MyComponent.vue
  ├─> MyComponent.js
  ├─> MyComponent.wxml
  ├─> MyComponent.wxss
  └─> MyComponent.json

中间的流程大概是这样:先把 SFC 拆成四块——<script><template><style><json>,各自按小程序的规矩做转换,最后拼成产物。

其中 <json> 块用来声明页面或组件的配置(比如 usingComponentsnavigationBarTitleText 之类的),不过我更推荐用 definePageJson / defineComponentJson / defineAppJson 这几个编译宏来代替它——有类型提示,能跟 <script setup> 共享上下文,IDE 重构的时候也不容易漏改。<json> 块当兼容手段用没问题,但不太适合当主力。

.vue 文件
  ↓
vue/compiler-sfc 解析
  ↓
┌─────────┬──────────┬─────────┬────────┐
│ <script>│<template>│ <style> │ <json> │
└────┬────┴────┬─────┴────┬────┴───┬────┘
     │         │          │        │
     ↓         ↓          ↓        ↓
  处理宏    指令映射     样式转换  配置提取
     │         │          │        │
     └─────────┴──────────┴────────┘
               ↓
         生成 .js/.wxml/.wxss/.json

增量构建的时候只处理改过的文件,HMR 能跑得比较稳也是靠这个缓存策略撑着。

defineXxxJson 宏的用法

上面提到推荐用编译宏来代替 <json> 块,这里展开说一下。defineAppJsondefinePageJsondefineComponentJson 都是编译期宏,构建时提取合并到对应的 .json 文件里,运行时零开销。写起来大概是这样:

<script setup lang="ts">
  definePageJson({
    navigationBarTitleText: '首页',
    usingComponents: {},
  })
</script>

好处就是直接写在 <script setup> 里,有完整的类型推导,改字段名的时候 IDE 能帮你检查,不会出现"json 里改了但别的地方没跟上"的情况。

原生组件与插槽

.vue 里 import 原生组件之后,构建阶段会看模板里到底用没用到,用到了才往 usingComponents 里补。这样就不用手动维护那堆路径配置了,少写少错。

插槽也是类似的思路。你写的是 Vue 的 slot 语法,但输出的时候会按小程序的 slot 语义来生成。作用域插槽稍微复杂一点,背后走的是一套语义映射加代码生成,不是简单的字符串替换能搞定的。

Rolldown:收益主要体现在日常开发体感

v6 切到 Rolldown 不是为了赶时髦,就是想把开发时的等待再缩短一点。

日常能感受到的主要是三个地方:冷启动快了、改完代码后增量构建更灵敏、项目依赖多的时候不容易抽风。不是那种"跑分暴涨 300%"的故事,更像是每次都省个几百毫秒,积少成多,一天下来体感差挺多的。

为什么没走 createRenderer 这条路

@vue/runtime-corecreateRenderer 技术上能跑通,但拿来对小程序用,会发现抽象层对不上:它要求你提供一套完整的宿主节点操作接口,而小程序这边最核心的更新通道就是 setData(payload),两边的假设不太匹配。

Wevu 选了"编译到 WXML + 快照 diff + 最小 setData"这条路,优化点压在更贴近小程序实际约束的地方。不一定是最优雅的方案,但在真实业务里跑下来更稳当。

展开聊的话内容比较多,单独写了一篇:为什么没有使用 @vue/runtime-core 的 createRenderer 来实现

当前能力范围

日常开发用到的东西基本都覆盖了:v-if / v-for / v-model 这些核心指令,事件和属性绑定,SCSS/Less 和 CSS Modules,props/emits/slots/provide/inject,生命周期,常用的响应式 API,还有 TypeScript 类型推导和泛型组件。

如果你是从 Vue 3 过来的,写法上基本不用重新学,主要就是记住最后跑的不是浏览器而是小程序。

最后

感谢每一位提建议、报 bug、提 PR 的同学。


如果 Weapp-vite 帮到了你,欢迎给项目点个 Star

Happy Coding! 🚀

Weapp-vite:原生模式之外,多一种 Vue SFC 选择

bg.jpg

Weapp-vite:原生模式之外,多一种 Vue SFC 选择

大家好呀,我是你们的老朋友,开源爱好者 icebreaker!又到了新的一年了,祝大家财源滚滚,早日不用上班实现财务自由!

今天主要来分享一下我开源项目 Weapp-vite 的开发里程碑,核心就是来给大家秀一把。

前言

我还记得在过去 Weapp-vite@4.0 的发布文章里,写过这样的话:

Weapp-vite 不适用场景:需要使用 Vue/React 等前端框架的写法,来取代小程序原生写法。

但社区的声音让我重新想了想这个定位。说实话,原生小程序的语法写多了确实烦,尤其是你要是平时写 Vue 3 写习惯了,回头再 this.setData、手动绑事件、管生命周期,就会觉得特别笨重。Vue 的 SFC 设计确实好用,这个没什么好争的。

而且即使到了这个 AI 时代,小程序的验收工具也比较笨重,因为小程序缺少 playwright-cli, agent-browser, chrome-devtools-mcp 这类的验收工具, 还原度远远不及 Web。

另外还有一点就是当时我正好在团队里面做《Vue 编译本质论》的技术分享

所以我就在想能不能把 Weapp-vite 改造成一个既保留原生模式优势,又提供 Vue 开发体验的工具?

于是,Weapp-vite@6 来了——在原生模式之外,多一种 Vue 选择

背景故事:从零运行时到 Vue SFC 支持

最初的定位

Weapp-vite 一开始就是奔着零运行时去的——一个纯粹的原生小程序构建工具。你用原生写法写代码,它给你提供现代化的开发体验,打出来的包尽量小、跑起来尽量快。

这个定位确实满足了不少用户,特别是只做微信小程序、对性能有洁癖的那批人,还有用 Skyline 的。

但我后来一直在琢磨:能不能在不动原生模式的前提下,再给一个 Vue 的选项?

市面上的选择

让我们看看现有的方案吧:

Taro

跨端能力确实强,但运行时代码量不小。分包没规划好的话,主包很容易超。语法上虽然说支持 React/Vue,但写起来总有种"变种"的感觉,踩坑成本不低。

而且说实话 Taro 现在维护节奏慢了不少,issue 堆得挺多的。

我也曾经在 2 年前,在他们的公众号上,看到了招聘启事,于是投了简历,结果人家完全没有鸟我(笑~)。

uni-app

上手是挺快的,但 uni.xxx 那套 API 和专属 DSL 毕竟是另一套东西。uni-app x 搞的 uts,跟标准 Vue 生态和社区总感觉有点貌合神离。

我很喜欢 uni-app, 当时也很早就让我另外一个项目 weapp-tailwindcss 中兼容了 uni-app x,但是我不喜欢 HBuilderX

mpx

滴滴出品,基于 Vue 2.7 + webpack。我不喜欢,技术栈老了,响应式系统跟标准 Vue 也不完全一样。

我的 Weapp-vite 方案,你可以理解成 mpx 的下一代:Vue 3 风格 + Rolldown Vite,只做小程序,但跟原生 API 完全兼容

Weapp-vite 的思路

Weapp-vite@6 想做的事情很简单:同一个工具,两种模式

  • 原生模式:零运行时,包体积和性能都拉满,适合对这些有要求的项目
  • Vue 模式:完整的 Vue 3 写法,适合 Vue 技术栈的团队

两者可以在同一个项目里混着用。.vue 组件能引原生组件,原生组件也能引 .vue 组件,按页面按组件自己选就行。

运行时 Wevu 的诞生

转折点是 wevu 的出现——一个专门给小程序写的 Vue 运行时。

当时本来是叫 wevue 的,但是这个名字 npm 包已经被注册掉了,所以 trimEnd 了一个 e

wevu 保留了 Vue 3 那些核心 API——refcomputedwatchonMounted 之类的,但底层更新走的是小程序的 setData

更重要的是,Wevu 从一开始就是配合 Weapp-vite 的 SFC 编译来设计的,所以编译时能加的糖都尽量加上了,写起来会比较顺手。

编译时 + 运行时

wevu 运行时搞定之后,Vue SFC 编译支持就是顺水推舟的事了。

认识 Wevu:给小程序写的 Vue 3 风格运行时

Wevu 专门给小程序设计,核心思路就是:响应式那套跟 Vue 3 同源,渲染层按小程序的规矩来

它能干什么

  • refreactivecomputedwatchwatchEffect 这些响应式 API 都有,用法跟 Vue 3 一样
  • onMountedonUpdatedonUnmounted 等生命周期钩子,自动映射到小程序对应的生命周期
  • 快照 diff 优化,setData 只传变了的数据路径,不会整坨丢过去
  • 内置了 defineStore/storeToRefs,用法跟 Pinia 差不多
  • 跟 Weapp-vite 的 SFC 编译配合使用,响应式和生命周期都是打通的

Vue 3 和 Wevu 到底哪不一样

响应式 API 和写法基本一致,区别在渲染那层:Wevu 不操作 DOM,而是操作小程序实例,更新走的是"快照 diff + setData"。

为什么没用 createRenderer

@vue/runtime-corecreateRenderer 技术上能做,但拿来对小程序有个根本问题:它假设宿主能提供一套比较完整的节点操作接口,而小程序这边核心就一个 setData(payload),两边的抽象对不上。

Wevu 走的是"编译到 WXML + 快照 diff + 最小 setData",把优化做在更贴近小程序实际情况的地方。

Weapp-vite + Wevu 怎么配合

  • Weapp-vite 管编译:把 Vue SFC 拆开、转换、生成小程序四件套
  • Wevu 管运行时:提供响应式系统和生命周期

两个加一起,你得到的就是:

  1. Vue 3 的开发体验(SFC + Composition API)
  2. 接近小程序原生的运行性能

Vue SFC 支持是直接内置在 weapp-vite 里的,不是外挂插件。

一处编写,四处生成

你写一个 .vue 文件,Weapp-vite 编译完会变成小程序四件套:

MyComponent.vue
    ├─> MyComponent.js    // 脚本逻辑
    ├─> MyComponent.wxml  // 模板结构
    ├─> MyComponent.wxss  // 样式文件
    └─> MyComponent.json  // 组件配置

Vue 的 <script><template><style><json>(可被 defineXXXJson 宏指令取代) 会被拆开,各自转换成小程序能认的格式。整个过程就像是把 Vue 组件"翻译"成了小程序的方言。

Vue 语法怎么转的

这不是简单地把 Vue 代码塞进小程序,而是做了一层语法映射:

Vue 写法 转换为
v-if / v-else-if / v-else wx:if / wx:elif / wx:else
v-for="item in list" wx:for="{{list}}" + wx:key
@click / @tap bindtap / catchtap
:class / :style class="{{...}}" / style="{{...}}"
v-model 双向绑定的完整实现(input/checkbox/radio/textarea 等)
<script setup> 自动处理响应式和生命周期

你按 Vue 的方式写,Weapp-vite 按小程序的方式跑。

工具链友好:智能提示 + AI 协作

智能提示:直接复用 Vue 官方插件

VS Code 里装了 Vue 官方插件(Vue - Official / Volar)的话,Weapp-vite 的 .vue 文件直接就能用上模板智能提示和类型检查,不用再折腾一套新的编辑器插件。

  • v-for 场景下的 :key 等属性补全
  • :class / :style 等常用绑定提示
  • 组件属性与事件相关补全

ic.png

in.png

inc.png

AI 协作

如果你准备用 AI 来协作开发,我自己的顺序一直很固定:先把 skills 装好,再起 MCP,最后按需喂 llms 语料。

先装 skills:

npx skills add sonofmagic/skills

常用的几个:weapp-vite-best-practicesweapp-vite-vue-sfc-best-practiceswevu-best-practices

然后启动 MCP:

weapp-vite mcp

最后是 llms 语料入口:

  • 页面:/llms
  • 文件:/llms.txt/llms-full.txt/llms-index.json
  • 顺序:llms.txt -> llms-full.txt -> llms-index.json

几个常见用法

响应式状态 + 计算属性

<script setup lang="ts">
import { computed, ref } from 'wevu'

const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

<template>
  <view>
    <text>{{ count }} / {{ doubled }}</text>
    <button @tap="count++">+1</button>
  </view>
</template>

definePageJson 宏定义页面配置

<script setup lang="ts">
definePageJson({
  navigationBarTitleText: '首页',
  navigationBarTextStyle: 'white',
})
</script>

.vue 里直接用原生组件

<script setup lang="ts">
import NativeMeter from '../../native/native-meter/index'
</script>

<template>
  <NativeMeter label="构建链能力" :value="80" />
</template>

v-model 表单双向绑定

<script setup lang="ts">
import { ref } from 'wevu'

const message = ref('')
</script>

<template>
  <input v-model="message" placeholder="输入点什么..." />
  <text>{{ message }}</text>
</template>

更多像 slotsprops/emitsapp.vue 配置以及编译行为说明,已放到原理文档统一说明:Weapp-vite@6 原理拆解

适用场景

双模式并存才是 Weapp-vite 的杀手锏

Weapp-vite@6 最实用的一点就是"同仓双模式"。性能敏感的页面继续走原生,迭代快、业务重的页面丢到 Vue 模式里。迁移可以一个页面一个页面来,不用一口气重写整个项目。

什么时候用 Vue 模式:

  • 你平时写 Vue 3,想用同样的写法搞小程序
  • 团队本来就是 Vue 技术栈,想复用过来
  • 想要热重载、TypeScript 这些现代开发体验
  • 希望 Vue 代码后面还能往 Web 项目上搬

什么时候用原生模式:

  • 对性能有洁癖,一点运行时开销都不想要
  • 已经有一大堆原生代码,不想大动
  • 团队对小程序原生 API 很熟
  • 包体积卡得很死

什么时候该选别的框架?

  • Taro:如果你真的要同时出微信、支付宝、百度、字节好几个平台的小程序,甚至还要编 H5 和 RN,那 Taro 确实是绕不开的。不过说真的,大部分项目真需要跨这么多端吗?

  • uni-app:如果你想要一个开箱即用的全家桶,而且已经习惯了 DCloud 那套生态(HBuilderX、uniCloud 之类的),uni-app 挺合适。就是它的 DSL 跟标准 Vue 还是有些差异。

  • mpx:Vue 2.7 + webpack,技术栈偏老了。

快速体验

  1. 创建项目
pnpm create weapp-vite@latest
# 选择 Wevu 模板或者 Wevu + TDesign 模板
  1. 开发
pnpm dev
  1. 享受 Vue 带来的快乐
<script setup>
import { ref } from 'wevu'

const message = ref('Hello, weapp-vite@6!')
</script>

<template>
  <view>{{ message }}</view>
</template>

技术细节

原理和实现细节,如果大家有兴趣的话,我会另外写一篇专门的技术拆解文档。

后面打算做什么

接下来主要推两条线:支持更多小程序平台,以及支持 Web 目标。

Android / iOS 原生方向

现在原生 Android / iOS 这边,很多场景还是得靠微信开发者工具的多端框架来转。这块后面会继续投入,目标是把链路做得更稳、接入成本更低。

最后

Weapp-vite@6 这次就是想把选择权留给你:要性能就走原生,要开发体验就走 Vue 模式,混着来也行。背后靠的是 vue/compiler-sfc 的解析能力、wevu 的运行时设计,以及社区一路给的真实反馈。

感谢每一位提建议、报 bug、提 PR 的同学。


如果 Weapp-vite 帮到了你,欢迎给项目点个 Star

Happy Coding! 🚀

代码生成:从AST到render函数

在前几篇文章中,我们学习了代码编译--转成--生成的过程。今天,我们将聚焦于指令系统——这个 Vue 中强大的声明式功能。从内置指令(v-if、v-for、v-model)到自定义指令,我们将深入它们的编译原理和运行时实现。

前言:指令的本质

指令是 Vue 模板中带有 v- 前缀的特殊属性。它本质上是一种声明式的语法糖,让我们能够在模板中直接操作 DOM 元素。

<!-- 使用指令 -->
<input v-model="message" />
<div v-if="visible">条件渲染</div>
<div v-custom:arg.modifier="value">自定义指令</div>

指令的注册方式

全局注册

const app = createApp(App);

app.directive('focus', {
  mounted(el) {
    el.focus();
  }
});

app.directive('color', {
  mounted(el, binding) {
    el.style.color = binding.value;
  },
  updated(el, binding) {
    el.style.color = binding.value;
  }
});

局部注册

export default {
  directives: {
    focus: {
      mounted(el) {
        el.focus();
      }
    },
    color: {
      mounted(el, binding) {
        el.style.color = binding.value;
      },
      updated(el, binding) {
        el.style.color = binding.value;
      }
    }
  }
}

import { directive } from 'vue';

export default {
  setup() {
    const vFocus = {
      mounted(el) {
        el.focus();
      }
    };
    
    return { vFocus };
  }
}

组件注册原理

// 指令注册的内部实现
function createDirective(name, definition) {
  // 规范化指令定义
  if (typeof definition === 'function') {
    // 函数简写形式
    definition = {
      mounted: definition,
      updated: definition
    };
  }
  
  return {
    name,
    ...definition
  };
}

// 全局注册表
const globalDirectives = new Map();

app.directive = function(name, definition) {
  if (definition === undefined) {
    // 获取指令
    return globalDirectives.get(name);
  } else {
    // 注册指令
    globalDirectives.set(name, createDirective(name, definition));
    return this;
  }
};

指令生命周期钩子

完整的钩子函数

const myDirective = {
  // 在绑定元素的 attribute 前
  // 或事件监听器应用前调用
  created(el, binding, vnode) {
    console.log('created', binding);
  },
  
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode) {
    console.log('beforeMount', binding);
  },
  
  // 在绑定元素的父组件
  // 及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode) {
    console.log('mounted', binding);
    el.focus();
  },
  
  // 在包含组件的 VNode 更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {
    console.log('beforeUpdate', binding);
  },
  
  // 在包含组件的 VNode 及其子组件的 VNode 更新后调用
  updated(el, binding, vnode, prevVnode) {
    console.log('updated', binding);
  },
  
  // 在绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode) {
    console.log('beforeUnmount', binding);
  },
  
  // 在绑定元素的父组件卸载后调用
  unmounted(el, binding, vnode) {
    console.log('unmounted', binding);
  }
};

binding 对象的属性

const binding = {
  value: 'directive value',        // 指令绑定的值
  oldValue: 'old value',            // 更新前的值
  arg: 'argName',                   // 指令参数
  modifiers: {                       // 修饰符对象
    prevent: true,
    stop: true
  },
  instance: componentInstance,       // 组件实例
  dir: directiveDefinition,          // 指令定义对象
  // 在 Vue 3.4+ 中新增
  modifiersKeys: ['prevent', 'stop'] // 修饰符数组
};

组件钩子函数的调用时机

组件钩子函数的调用时机

编译阶段的指令处理

指令的 AST 表示

我们来看一个比较复杂的自定义指令的例子:

<div v-custom:arg.mod1.mod2="value"></div>

这个例子对应的 AST 节点如下:

const elementNode = {
  type: 'Element',
  tag: 'div',
  props: [
    // 普通属性
    { name: 'class', value: 'container' },
    // 指令
    {
      type: 'Directive',
      name: 'custom',
      arg: 'arg',
      modifiers: ['mod1', 'mod2'],
      value: 'value',
      exp: {
        type: 'Expression',
        content: 'value'
      }
    }
  ]
};

指令的编译转换

/**
 * 指令转换插件
 */
const transformDirective = (node, context) => {
  if (node.type !== 'Element') return;
  
  if (!node.props) node.props = [];
  
  // 收集指令
  const directives = [];
  
  for (let i = node.props.length - 1; i >= 0; i--) {
    const prop = node.props[i];
    
    if (prop.type === 'Directive') {
      directives.push(prop);
      node.props.splice(i, 1); // 从props中移除
    }
  }
  
  if (directives.length === 0) return;
  
  // 为节点添加指令信息
  node.directives = directives.map(dir => ({
    name: dir.name,
    arg: dir.arg,
    modifiers: dir.modifiers,
    value: dir.value,
    exp: dir.exp
  }));
};

/**
 * 内置指令的转换
 */
const transformBuiltInDirectives = (node, context) => {
  if (!node.directives) return;
  
  for (const dir of node.directives) {
    switch (dir.name) {
      case 'if':
        transformVIf(node, dir, context);
        break;
      case 'for':
        transformVFor(node, dir, context);
        break;
      case 'model':
        transformVModel(node, dir, context);
        break;
      case 'show':
        transformVShow(node, dir, context);
        break;
      case 'on':
        transformVOn(node, dir, context);
        break;
      case 'bind':
        transformVBind(node, dir, context);
        break;
      // 自定义指令会保留,运行时处理
    }
  }
};

指令的代码生成

/**
 * 生成指令的运行时代码
 */
const genDirective = (dir, context) => {
  const { name, arg, modifiers, value } = dir;
  
  // 处理参数
  const argStr = arg ? `'${arg}'` : 'null';
  
  // 处理修饰符
  const modifiersObj = {};
  if (modifiers) {
    for (const mod of modifiers) {
      modifiersObj[mod] = true;
    }
  }
  
  // 生成指令对象
  return {
    name: `'${name}'`,
    value: `() => ${value}`,
    arg: argStr,
    modifiers: JSON.stringify(modifiersObj)
  };
};

/**
 * 生成节点上的所有指令
 */
const genDirectives = (node, context) => {
  if (!node.directives || node.directives.length === 0) return '';
  
  const dirs = node.directives.map(dir => genDirective(dir, context));
  
  return `directives: [${dirs.map(d => `{${Object.entries(d).map(([k, v]) => `${k}: ${v}`).join(', ')}}`).join(', ')}]`;
};

运行时的指令调用

指令调度器

/**
 * 运行时指令管理器
 */
class DirectiveManager {
  constructor() {
    this.directives = new Map(); // 全局指令
    this.instances = new WeakMap(); // 元素上的指令实例
  }
  
  /**
   * 注册指令
   */
  register(name, definition) {
    this.directives.set(name, definition);
  }
  
  /**
   * 获取指令定义
   */
  get(name) {
    return this.directives.get(name);
  }
  
  /**
   * 在元素上应用指令
   */
  applyDirectives(el, vnode) {
    const { directives } = vnode;
    if (!directives) return;
    
    const instances = [];
    
    for (const dir of directives) {
      const definition = this.get(dir.name);
      if (!definition) {
        console.warn(`指令 ${dir.name} 未注册`);
        continue;
      }
      
      // 创建指令实例
      const instance = {
        dir: definition,
        binding: this.createBinding(dir, vnode),
        vnode
      };
      
      instances.push(instance);
      
      // 调用 created 钩子
      if (definition.created) {
        definition.created(el, instance.binding, vnode);
      }
    }
    
    this.instances.set(el, instances);
  }
  
  /**
   * 创建 binding 对象
   */
  createBinding(dir, vnode) {
    return {
      value: dir.value ? dir.value() : undefined,
      oldValue: undefined,
      arg: dir.arg,
      modifiers: dir.modifiers || {},
      instance: vnode.component,
      dir: this.get(dir.name)
    };
  }
  
  /**
   * 更新指令
   */
  updateDirectives(oldVNode, newVNode) {
    const el = newVNode.el;
    const oldInstances = this.instances.get(el) || [];
    const newDirectives = newVNode.directives || [];
    
    // 创建新实例的映射
    const newInstances = [];
    const newDirMap = new Map();
    
    for (const dir of newDirectives) {
      newDirMap.set(dir.name, dir);
    }
    
    // 更新现有指令
    for (const oldInstance of oldInstances) {
      const newDir = newDirMap.get(oldInstance.dir.name);
      
      if (newDir) {
        // 指令仍然存在,更新 binding
        const oldBinding = oldInstance.binding;
        const newBinding = this.createBinding(newDir, newVNode);
        newBinding.oldValue = oldBinding.value;
        
        // 调用 beforeUpdate
        if (oldInstance.dir.beforeUpdate) {
          oldInstance.dir.beforeUpdate(el, newBinding, newVNode, oldInstance.vnode);
        }
        
        // 更新实例
        oldInstance.binding = newBinding;
        oldInstance.vnode = newVNode;
        newInstances.push(oldInstance);
        
        newDirMap.delete(oldInstance.dir.name);
      } else {
        // 指令被移除,调用 beforeUnmount
        if (oldInstance.dir.beforeUnmount) {
          oldInstance.dir.beforeUnmount(el, oldInstance.binding, oldInstance.vnode);
        }
      }
    }
    
    // 添加新指令
    for (const [name, dir] of newDirMap) {
      const definition = this.get(name);
      if (!definition) continue;
      
      const instance = {
        dir: definition,
        binding: this.createBinding(dir, newVNode),
        vnode: newVNode
      };
      
      // 调用 created
      if (definition.created) {
        definition.created(el, instance.binding, newVNode);
      }
      
      newInstances.push(instance);
    }
    
    this.instances.set(el, newInstances);
  }
  
  /**
   * 触发指令钩子
   */
  invokeHook(el, hookName, ...args) {
    const instances = this.instances.get(el);
    if (!instances) return;
    
    for (const instance of instances) {
      const hook = instance.dir[hookName];
      if (hook) {
        hook(el, instance.binding, ...args);
      }
    }
  }
}

// 创建全局指令管理器
const directiveManager = new DirectiveManager();

与渲染器的集成

/**
 * 在渲染器中集成指令
 */
class Renderer {
  patch(oldVNode, newVNode, container) {
    // ... 其他patch逻辑
    
    if (oldVNode && newVNode && oldVNode.el === newVNode.el) {
      // 更新指令
      directiveManager.updateDirectives(oldVNode, newVNode);
    }
  }
  
  mountElement(vnode, container, anchor) {
    const el = document.createElement(vnode.type);
    vnode.el = el;
    
    // 在挂载前调用指令钩子
    directiveManager.applyDirectives(el, vnode);
    
    // ... 其他挂载逻辑
    
    // 挂载后调用 mounted
    directiveManager.invokeHook(el, 'mounted');
  }
  
  unmount(vnode) {
    const el = vnode.el;
    
    // 调用 beforeUnmount
    directiveManager.invokeHook(el, 'beforeUnmount', vnode);
    
    // ... 卸载逻辑
    
    // 调用 unmounted
    directiveManager.invokeHook(el, 'unmounted');
  }
}

内置指令的编译实现

常见内置指令

内置指令 编译处理 运行时 示例
v-if 转为条件表达式 条件渲染 <div v-if="show">
v-for 转为renderList 循环渲染 <li v-for="item in list">
v-model 拆分为value+事件 双向绑定 <input v-model="text">
v-show 转为style控制 切换display <div v-show="visible">
v-on 转为事件绑定 事件监听 <button @click="fn">
v-bind 转为属性绑定 属性更新 <div :class="cls">
自定义指令 保留指令信息 调用钩子 <div v-custom>

v-if 的编译

function transformVIf(node, dir, context) {
  // 将元素转换为条件节点
  node.type = 'Conditional';
  node.condition = dir.value;
  node.consequent = node;
  
  // 查找相邻的 v-else-if 和 v-else
  let current = node;
  while (current.next) {
    const nextNode = current.next;
    const elseDir = nextNode.directives?.find(d => d.name === 'else-if' || d.name === 'else');
    
    if (elseDir) {
      if (elseDir.name === 'else-if') {
        // 转换为条件分支
        current.alternate = {
          type: 'Conditional',
          condition: elseDir.value,
          consequent: nextNode
        };
        current = current.alternate;
      } else {
        // v-else
        current.alternate = nextNode;
      }
      
      // 移除指令标记
      nextNode.directives = nextNode.directives?.filter(d => d.name !== 'else-if' && d.name !== 'else');
    } else {
      break;
    }
  }
}

/**
 * 生成 v-if 代码
 */
function genVIf(node) {
  if (node.type !== 'Conditional') return;
  
  let code = `ctx.${node.condition} ? `;
  code += genNode(node.consequent);
  code += ' : ';
  
  if (node.alternate) {
    if (node.alternate.type === 'Conditional') {
      code += genVIf(node.alternate);
    } else {
      code += genNode(node.alternate);
    }
  } else {
    code += 'null';
  }
  
  return code;
}

v-show 的编译

function transformVShow(node, dir, context) {
  // v-show 只是添加 style 控制
  if (!node.props) node.props = [];
  
  const styleProp = node.props.find(p => p.name === 'style');
  
  if (styleProp) {
    // 合并现有 style
    styleProp.value = `[${styleProp.value}, ctx.${dir.value} ? null : { display: 'none' }]`;
  } else {
    // 添加 style 属性
    node.props.push({
      name: 'style',
      value: `ctx.${dir.value} ? null : { display: 'none' }`
    });
  }
  
  // 移除 v-show 指令
  node.directives = node.directives?.filter(d => d.name !== 'show');
}

/**
 * 生成 v-show 代码(在 props 中体现)
 */
function genVShow(node) {
  // v-show 已经在 props 中处理,这里不需要额外生成
  return genNode(node);
}

v-model 的编译

function transformVModel(node, dir, context) {
  const value = dir.value;
  const modifiers = dir.modifiers || [];
  
  // 根据元素类型生成不同的事件和属性
  let propName = 'modelValue';
  let eventName = 'onUpdate:modelValue';
  
  if (node.tag === 'input') {
    if (modifiers.includes('number')) {
      // v-model.number
      return genNumberModel(value);
    } else if (modifiers.includes('trim')) {
      // v-model.trim
      return genTrimModel(value);
    }
  } else if (node.tag === 'select') {
    propName = 'modelValue';
    eventName = 'onUpdate:modelValue';
  } else if (node.tag === 'textarea') {
    propName = 'modelValue';
    eventName = 'onUpdate:modelValue';
  }
  
  // 添加 props
  if (!node.props) node.props = [];
  
  // 添加 value 绑定
  node.props.push({
    name: propName,
    value: `ctx.${value}`
  });
  
  // 添加事件绑定
  node.props.push({
    name: eventName,
    value: genUpdateHandler(value, modifiers)
  });
}

/**
 * 生成更新处理器
 */
function genUpdateHandler(value, modifiers) {
  let handler = `$event => ctx.${value} = $event`;
  
  if (modifiers.includes('number')) {
    handler = `$event => ctx.${value} = parseFloat($event)`;
  } else if (modifiers.includes('trim')) {
    handler = `$event => ctx.${value} = $event.trim()`;
  }
  
  if (modifiers.includes('lazy')) {
    handler = handler.replace('$event', '$event.target.value');
  }
  
  return handler;
}

/**
 * 生成数字输入模型
 */
function genNumberModel(value) {
  return {
    type: 'Directive',
    name: 'bind',
    arg: 'value',
    value: `ctx.${value}`
  }, {
    type: 'Directive',
    name: 'on',
    arg: 'input',
    value: `$event => ctx.${value} = $event.target.value ? parseFloat($event.target.value) : ''`
  };
}

/**
 * 生成修剪模型
 */
function genTrimModel(value) {
  return {
    type: 'Directive',
    name: 'bind',
    arg: 'value',
    value: `ctx.${value}`
  }, {
    type: 'Directive',
    name: 'on',
    arg: 'blur',
    value: `$event => ctx.${value} = $event.target.value.trim()`
  };
}

v-for 的编译

function transformVFor(node, dir, context) {
  // 解析 v-for 表达式 "item in list"
  const match = dir.value.match(/(.*?) in (.*)/);
  if (!match) return;
  
  const [, alias, source] = match;
  
  // 转换为 For 节点
  node.type = 'For';
  node.source = source.trim();
  node.alias = alias.trim();
  node.children = node.children || [];
  
  // 添加 key 处理
  const keyProp = node.props?.find(p => p.name === 'key' || p.name === ':key');
  if (!keyProp) {
    // 自动添加 key 建议
    console.warn('v-for 应该提供 key 属性');
  }
  
  // 移除 v-for 指令
  node.directives = node.directives?.filter(d => d.name !== 'for');
}

/**
 * 生成 v-for 代码
 */
function genVFor(node) {
  if (node.type !== 'For') return;
  
  const { source, alias, children } = node;
  
  return `renderList(ctx.${source}, (${alias}, index) => {
    return ${genNode(children[0])}
  })`;
}

自定义指令的编译处理

自定义指令的保留

/**
 * 处理自定义指令
 */
function transformCustomDirective(node, context) {
  if (!node.directives) return;
  
  // 保留自定义指令,运行时处理
  node.customDirectives = node.directives.filter(dir => {
    return !['if', 'for', 'model', 'show', 'on', 'bind'].includes(dir.name);
  });
  
  // 移除已处理的指令
  node.directives = node.directives.filter(dir => {
    return ['if', 'for', 'model', 'show', 'on', 'bind'].includes(dir.name);
  });
}

/**
 * 生成自定义指令代码
 */
function genCustomDirectives(node, context) {
  if (!node.customDirectives?.length) return '';
  
  const dirs = node.customDirectives.map(dir => {
    const { name, arg, modifiers, value } = dir;
    
    return {
      name: `'${name}'`,
      value: `() => ${value}`,
      arg: arg ? `'${arg}'` : 'null',
      modifiers: JSON.stringify(modifiers || {})
    };
  });
  
  return `directives: [${dirs.map(d => 
    `{${Object.entries(d).map(([k, v]) => `${k}: ${v}`).join(', ')}}`
  ).join(', ')}]`;
}

指令的参数和修饰符

/**
 * 解析指令参数和修饰符
 */
function parseDirective(name) {
  // 例如:v-on:click.prevent.stop
  const parts = name.split(':');
  const dirName = parts[0];
  
  let arg = parts[1] || '';
  let modifiers = [];
  
  // 解析修饰符
  if (arg.includes('.')) {
    const argParts = arg.split('.');
    arg = argParts[0];
    modifiers = argParts.slice(1);
  }
  
  return {
    name: dirName,
    arg,
    modifiers
  };
}

/**
 * 生成修饰符处理代码
 */
function genModifiers(modifiers) {
  const obj = {};
  for (const mod of modifiers) {
    obj[mod] = true;
  }
  return JSON.stringify(obj);
}

事件修饰符的实现

常用事件修饰符

通用事件修饰符

修饰符 作用 典型使用场景
.stop 阻止事件冒泡。 防止点击一个内部的按钮意外触发了外层容器的点击事件。
.prevent 阻止事件的默认行为。 自定义表单提交逻辑,或自定义链接行为。
.capture 使用事件捕获模式。 当你希望父元素能比子元素更早地捕获到事件时使用。
.self 只有当 event.target 是当前元素自身时,才触发事件处理函数。 严格区分是点击了元素本身还是其内部子元素的场景。
.once 事件将只会触发一次。 一次性操作,如首次点击的引导、支付按钮等,防止重复提交。
.passive 告诉浏览器你不想阻止事件的默认行为,从而提升性能。尤其适用于移动端的滚动事件(touchmove),能让滚动更流畅。 提升滚动性能,通常用于改善移动端设备的滚屏体验。

注:修饰符可以串联使用,比如 @click.stop.prevent 会同时阻止冒泡和默认行为。但需要注意顺序,因为相关代码会按顺序生成。

按键修饰符

按键修饰符专门用于监听键盘事件,方便监听按下了哪个键。Vue 为最常用的按键提供了别名,我们可以直接使用:

  • .enter (回车键)
  • .tab (制表键)
  • .delete (捕获“删除”和“退格”键)
  • .esc (退出键)
  • .space (空格键)
  • .up / .down / .left / .right (方向键)

鼠标按键修饰符

指定由特定鼠标按键触发的事件:

  • .left (鼠标左键)
  • .right (鼠标右键)
  • .middle (鼠标滚轮键)

运行时的事件处理

/**
 * 运行时事件绑定处理
 */
class EventManager {
  constructor() {
    this.eventHandlers = new WeakMap();
  }
  
  /**
   * 绑定事件
   */
  addEventListener(el, eventName, handler, options) {
    // 解析事件选项
    let useCapture = false;
    let isPassive = false;
    
    if (eventName.includes('!')) {
      useCapture = true;
      eventName = eventName.replace('!', '');
    }
    
    if (eventName.includes('~')) {
      isPassive = true;
      eventName = eventName.replace('~', '');
    }
    
    const eventOptions = {
      capture: useCapture,
      passive: isPassive
    };
    
    // 存储事件处理器
    if (!this.eventHandlers.has(el)) {
      this.eventHandlers.set(el, new Map());
    }
    
    const handlers = this.eventHandlers.get(el);
    handlers.set(eventName, { handler, options: eventOptions });
    
    // 绑定事件
    el.addEventListener(eventName, handler, eventOptions);
  }
  
  /**
   * 更新事件
   */
  updateEventListener(el, eventName, newHandler) {
    const handlers = this.eventHandlers.get(el);
    if (!handlers) return;
    
    const old = handlers.get(eventName);
    if (old) {
      el.removeEventListener(eventName, old.handler, old.options);
    }
    
    if (newHandler) {
      this.addEventListener(el, eventName, newHandler.handler, newHandler.options);
    }
  }
}

手写实现:完整指令系统

/**
 * 完整指令编译器
 */
class DirectiveCompiler {
  constructor() {
    this.builtInDirectives = new Set(['if', 'for', 'model', 'show', 'on', 'bind']);
  }
  
  /**
   * 编译模板中的指令
   */
  compile(template) {
    // 1. 解析AST
    const ast = this.parse(template);
    
    // 2. 转换AST
    this.transform(ast);
    
    // 3. 生成代码
    const code = this.generate(ast);
    
    return code;
  }
  
  /**
   * 解析模板
   */
  parse(template) {
    // 简化的解析逻辑
    const ast = {
      type: 'Root',
      children: []
    };
    
    // 解析元素和指令
    const elementRegex = /<(\w+)([^>]*)>/g;
    const directiveRegex = /v-(\w+)(?::(\w+))?(?:\.(\w+))?="([^"]*)"/g;
    
    // ... 解析逻辑
    
    return ast;
  }
  
  /**
   * 转换AST
   */
  transform(node) {
    if (node.type === 'Element') {
      // 提取指令
      const directives = [];
      
      if (node.attributes) {
        for (const attr of node.attributes) {
          const match = attr.name.match(/^v-(\w+)(?::(\w+))?(?:\.([\w.]+))?$/);
          if (match) {
            const [_, name, arg, modifiersStr] = match;
            const modifiers = modifiersStr ? modifiersStr.split('.') : [];
            
            directives.push({
              name,
              arg,
              modifiers,
              value: attr.value,
              exp: {
                type: 'Expression',
                content: attr.value
              }
            });
            
            // 移除原始属性
            node.attributes = node.attributes.filter(a => a !== attr);
          }
        }
      }
      
      if (directives.length > 0) {
        node.directives = directives;
        
        // 处理内置指令
        for (const dir of directives) {
          if (this.builtInDirectives.has(dir.name)) {
            this.processBuiltInDirective(node, dir);
          }
        }
        
        // 保留自定义指令
        node.customDirectives = directives.filter(
          dir => !this.builtInDirectives.has(dir.name)
        );
      }
      
      // 递归处理子节点
      if (node.children) {
        for (const child of node.children) {
          this.transform(child);
        }
      }
    }
  }
  
  /**
   * 处理内置指令
   */
  processBuiltInDirective(node, dir) {
    switch (dir.name) {
      case 'if':
        this.processVIf(node, dir);
        break;
      case 'for':
        this.processVFor(node, dir);
        break;
      case 'model':
        this.processVModel(node, dir);
        break;
      case 'show':
        this.processVShow(node, dir);
        break;
      case 'on':
        this.processVOn(node, dir);
        break;
      case 'bind':
        this.processVBind(node, dir);
        break;
    }
  }
  
  /**
   * 处理 v-if
   */
  processVIf(node, dir) {
    node.type = 'Conditional';
    node.condition = dir.value;
    node.consequent = { ...node };
    delete node.consequent.directives;
    delete node.consequent.customDirectives;
  }
  
  /**
   * 处理 v-for
   */
  processVFor(node, dir) {
    const match = dir.value.match(/(.*?) in (.*)/);
    if (match) {
      node.type = 'For';
      node.alias = match[1].trim();
      node.source = match[2].trim();
      node.iterator = node;
      delete node.iterator.directives;
      delete node.iterator.customDirectives;
    }
  }
  
  /**
   * 处理 v-model
   */
  processVModel(node, dir) {
    if (!node.props) node.props = [];
    
    node.props.push({
      name: 'modelValue',
      value: `ctx.${dir.value}`
    });
    
    node.props.push({
      name: 'onUpdate:modelValue',
      value: this.genUpdateHandler(dir)
    });
  }
  
  /**
   * 处理 v-show
   */
  processVShow(node, dir) {
    if (!node.props) node.props = [];
    
    const styleProp = node.props.find(p => p.name === 'style');
    if (styleProp) {
      styleProp.value = `[${styleProp.value}, ctx.${dir.value} ? null : { display: 'none' }]`;
    } else {
      node.props.push({
        name: 'style',
        value: `ctx.${dir.value} ? null : { display: 'none' }`
      });
    }
  }
  
  /**
   * 处理 v-on
   */
  processVOn(node, dir) {
    if (!node.props) node.props = [];
    
    const eventName = dir.arg;
    let handler = `ctx.${dir.value}`;
    
    // 应用修饰符
    if (dir.modifiers) {
      handler = this.applyModifiers(handler, dir.modifiers);
    }
    
    node.props.push({
      name: `on${eventName.charAt(0).toUpperCase() + eventName.slice(1)}`,
      value: handler
    });
  }
  
  /**
   * 处理 v-bind
   */
  processVBind(node, dir) {
    if (!node.props) node.props = [];
    
    node.props.push({
      name: dir.arg,
      value: `ctx.${dir.value}`
    });
  }
  
  /**
   * 应用修饰符
   */
  applyModifiers(handler, modifiers) {
    for (const mod of modifiers) {
      switch (mod) {
        case 'stop':
          handler = `$event => { $event.stopPropagation(); ${handler}($event) }`;
          break;
        case 'prevent':
          handler = `$event => { $event.preventDefault(); ${handler}($event) }`;
          break;
        case 'once':
          handler = `once(${handler})`;
          break;
      }
    }
    return handler;
  }
  
  /**
   * 生成更新处理器
   */
  genUpdateHandler(dir) {
    let handler = `$event => ctx.${dir.value} = $event`;
    
    if (dir.modifiers) {
      if (dir.modifiers.includes('number')) {
        handler = `$event => ctx.${dir.value} = parseFloat($event)`;
      }
      if (dir.modifiers.includes('trim')) {
        handler = `$event => ctx.${dir.value} = $event.trim()`;
      }
      if (dir.modifiers.includes('lazy')) {
        handler = handler.replace('$event', '$event.target.value');
      }
    }
    
    return handler;
  }
  
  /**
   * 生成代码
   */
  generate(node) {
    if (!node) return 'null';
    
    switch (node.type) {
      case 'Root':
        return this.generateRoot(node);
      case 'Element':
        return this.generateElement(node);
      case 'Conditional':
        return this.generateConditional(node);
      case 'For':
        return this.generateFor(node);
      default:
        return 'null';
    }
  }
  
  /**
   * 生成元素代码
   */
  generateElement(node) {
    const parts = ['createVNode'];
    
    // 标签
    parts.push(`'${node.tag}'`);
    
    // 属性
    if (node.props) {
      const propsObj = {};
      for (const prop of node.props) {
        propsObj[prop.name] = prop.value;
      }
      parts.push(JSON.stringify(propsObj));
    } else {
      parts.push('null');
    }
    
    // 子节点
    if (node.children) {
      const children = node.children.map(child => this.generate(child));
      if (children.length === 1) {
        parts.push(children[0]);
      } else {
        parts.push(`[${children.join(', ')}]`);
      }
    } else {
      parts.push('null');
    }
    
    // 自定义指令
    if (node.customDirectives?.length) {
      const dirs = node.customDirectives.map(dir => ({
        name: `'${dir.name}'`,
        value: `() => ${dir.value}`,
        arg: dir.arg ? `'${dir.arg}'` : 'null',
        modifiers: JSON.stringify(dir.modifiers || {})
      }));
      
      parts.push(JSON.stringify({
        directives: dirs
      }));
    }
    
    return `createVNode(${parts.join(', ')})`;
  }
  
  /**
   * 生成条件节点
   */
  generateConditional(node) {
    return `${node.condition} ? ${this.generate(node.consequent)} : null`;
  }
  
  /**
   * 生成循环节点
   */
  generateFor(node) {
    return `renderList(ctx.${node.source}, (${node.alias}, index) => ${this.generate(node.iterator)})`;
  }
}

结语

理解指令系统,不仅帮助我们更好地使用内置指令,也能创建强大的自定义指令,提升开发效率。指令系统是 Vue 声明式编程的重要体现,它将 DOM 操作封装成声明式的语法,让开发者可以专注于业务逻辑。

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

AST转换:静态提升与补丁标志

在上一篇文章中,我们学习了模板编译的三个阶段。今天,我们将深入AST转换阶段的核心优:静态提升补丁标志。这两个优化是 Vue3 性能大幅提升的关键,它们让 Vue 在运行时能够跳过大量不必要的比较,实现精准更新。

前言:从一次渲染说起

想象一下,我们正在读一本电子书:这其中 99% 的内容是固定的,只有 1% 的页码会变化,这时候我们会怎么做:

  • 普通方式:每次变化时,重读整本书(Vue2的方式)
  • 优化方式:只重新读变化的页码(Vue3的方式)

这就是静态提升和补丁标志的核心思想:标记不变的内容,跳过重复工作。

静态节点标记(PatchFlags)

什么是补丁标志?

补丁标志是一个位掩码,用来标记节点的动态内容类型。它告诉渲染器:这个节点哪些部分是需要关注的变化点。

Vue3 中定义了丰富的补丁标志:

const PatchFlags = {
  TEXT: 1,                    // 动态文本内容
  CLASS: 1 << 1,              // 动态 class
  STYLE: 1 << 2,              // 动态 style
  PROPS: 1 << 3,              // 动态属性
  FULL_PROPS: 1 << 4,         // 全量比较
  HYDRATE_EVENTS: 1 << 5,     // 事件监听
  STABLE_FRAGMENT: 1 << 6,    // 稳定 Fragment
  KEYED_FRAGMENT: 1 << 7,     // 带 key 的 Fragment
  UNKEYED_FRAGMENT: 1 << 8,   // 无 key 的 Fragment
  NEED_PATCH: 1 << 9,         // 需要非 props 比较
  DYNAMIC_SLOTS: 1 << 10,     // 动态插槽
  
  HOISTED: -1,                // 静态提升节点
  BAIL: -2                    // 退出优化
};

位掩码的作用

位掩码可以用一个数字表示多个标记,以上述补丁标志为例,如果一个节点既有动态 class,又有动态 style,该怎么处理:

// 组合标记:class和style都是动态的
const combined = CLASS | STYLE;  // 110 = 6

动态内容的识别

编译器是如何识别哪些内容是动态的?其实编译器也是根据补丁标志来进行判断处理的,例如以下模板示例:

<div 
  class="static" 
  :class="dynamicClass"
  :style="dynamicStyle"
  id="static-id"
>
  <h1>静态标题</h1>
  <p>{{ dynamicText }}</p>
  <button @click="handler">点击</button>
</div>

通过编译后的标记:

// 编译后的标记
function render(ctx) {
  return createVNode('div', {
    class: ['static', ctx.dynamicClass],  // class部分是动态的
    style: ctx.dynamicStyle,               // style是动态的
    id: 'static-id'                        // id是静态的
  }, [
    createVNode('h1', null, '静态标题'),    // 完全静态
    createVNode('p', null, ctx.dynamicText, PatchFlags.TEXT),  // 只有文本动态
    createVNode('button', { 
      onClick: ctx.handler 
    }, '点击', PatchFlags.EVENTS)           // 只有事件动态
  ], PatchFlags.CLASS | PatchFlags.STYLE);  // div的class和style动态
}

如果没有标记,说明是静态节点,什么都不用做。

静态提升(HoistStatic)

静态提升的原理

静态提升是将完全静态的节点提取到渲染函数之外,避免每次渲染都重新创建,还是以上一节的代码为例:

const _hoisted_1 = createVNode('h1', null, '静态标题', PatchFlags.HOISTED);

function render(ctx) {
  return createVNode('div', null, [
    _hoisted_1,  // 直接复用
    createVNode('p', null, ctx.dynamicText, PatchFlags.TEXT)
  ]);
}

静态节点的判定规则

当一个节点同时满足以下条件时,这时我们就判定它为静态节点

  1. 没有动态绑定:不存在双向绑定 v-model(简写:)、v-bindv-on
  2. 没有指令:不存在 v-ifv-forv-slot 等指令
  3. 没有插值:不存在 {{ }} 等插值语句
  4. 所有子节点也都是静态的

静态提升的深度

Vue3 不仅提升顶层静态节点,还会提升深层静态节点:

<template>
  <div>
    <div>  <!-- 这个div不是静态的,因为它有动态子节点 -->
      <span>完全静态</span>  <!-- 但这个span是静态的,会被提升 -->
      <span>{{ text }}</span>
    </div>
    <div class="static">  <!-- 这个div是静态的,会被提升 -->
      <span>静态1</span>
      <span>静态2</span>
    </div>
  </div>
</template>

动态节点收集

Block的概念

Block 是Vue3中一个重要的优化概念,它会收集当前模板中的所有动态节点。通常情况下,我们会约定组件模版的根节点作为 Block 角色,从根节点开始,所有动态子代节点都会被收集到根节点的 dynamicChildren 数组中,以此来形成一颗 Block Tree

到了这里,也许会有人问:如果我的 Vue 组件模板中,都是静态节点,不存在动态节点呢? 这种情况也是存在的,这种情况下,就只存在根节点一个 Block,无法形成树,因此也不用额外处理。

Block Tree

Block 会收集所有后代动态节点,形成动态节点树 Block Tree。我们来看下面一个模板代码示例:

<div>  <!-- 这是Block -->
  <span>静态</span>
  <p :class="dynamic">动态1</p>
  <div>
    <span>静态</span>
    <span>{{ text }}</span>  <!-- 动态2 -->
  </div>
</div>

这段代码完整转成树形结构应该是这样的: 完整树形结构 只收集动态节点,形成的动态节点树: 动态节点树结构

更新时的优化

有了动态节点树,更新时只需要遍历 dynamicChildren

function patchChildren(oldNode, newNode, container) {
  if (newNode.dynamicChildren) {
    // 只更新动态节点
    for (let i = 0; i < newNode.dynamicChildren.length; i++) {
      patch(
        oldNode.dynamicChildren[i],
        newNode.dynamicChildren[i],
        container
      );
    }
  } else {
    // 没有动态节点,说明是完全静态,什么都不用做
  }
}

节点转换器的设计

转换器的整体架构

/**
 * AST转换器
 */
class ASTTransformer {
  constructor(ast, options = {}) {
    this.ast = ast;
    this.options = options;
    this.context = {
      currentNode: null,
      parent: null,
      staticNodes: new Set(),
      dynamicNodes: new Set(),
      patchFlags: new Map(),
      hoisted: [],        // 提升的静态节点
      replaceNode: (node) => {
        // 替换当前节点
      },
      removeNode: () => {
        // 删除当前节点
      }
    };
  }
  
  /**
   * 执行转换
   */
  transform() {
    // 1. 遍历AST,标记静态节点
    this.traverse(this.ast);
    
    // 2. 计算补丁标志
    this.computePatchFlags();
    
    // 3. 提取静态节点
    this.hoistStatic();
    
    return this.ast;
  }
  
  /**
   * 遍历AST
   */
  traverse(node, parent = null) {
    if (!node) return;
    
    this.context.currentNode = node;
    this.context.parent = parent;
    
    // 应用所有转换插件
    for (const plugin of this.plugins) {
      plugin(node, this.context);
    }
    
    // 递归处理子节点
    if (node.children) {
      for (const child of node.children) {
        this.traverse(child, node);
      }
    }
  }
}

静态节点检测插件

/**
 * 静态节点检测插件
 */
const detectStaticPlugin = (node, context) => {
  if (node.type === 'Element') {
    // 检查是否有动态绑定
    const hasDynamic = checkDynamic(node);
    
    if (!hasDynamic) {
      // 检查所有子节点
      const childrenStatic = node.children?.every(child => 
        context.staticNodes.has(child) || child.type === 'Text'
      ) ?? true;
      
      if (childrenStatic) {
        context.staticNodes.add(node);
        node.isStatic = true;
      }
    }
  } else if (node.type === 'Text') {
    // 文本节点默认是静态的
    node.isStatic = true;
  }
};

/**
 * 检查节点是否包含动态内容
 */
function checkDynamic(node) {
  if (!node.props) return false;
  
  for (const prop of node.props) {
    // 检查指令
    if (prop.name.startsWith('v-') || prop.name.startsWith('@') || prop.name.startsWith(':')) {
      return true;
    }
    
    // 检查动态属性值
    if (prop.value && prop.value.includes('{{')) {
      return true;
    }
  }
  
  return false;
}

补丁标志计算插件

/**
 * 补丁标志计算插件
 */
const patchFlagPlugin = (node, context) => {
  if (node.type !== 'Element' || node.isStatic) return;
  
  let patchFlag = 0;
  const dynamicProps = [];
  
  if (node.props) {
    for (const prop of node.props) {
      if (prop.name === 'class' && isDynamic(prop)) {
        patchFlag |= PatchFlags.CLASS;
        dynamicProps.push('class');
      } else if (prop.name === 'style' && isDynamic(prop)) {
        patchFlag |= PatchFlags.STYLE;
        dynamicProps.push('style');
      } else if (prop.name.startsWith('@')) {
        patchFlag |= PatchFlags.EVENTS;
        dynamicProps.push(prop.name.slice(1));
      } else if (prop.name.startsWith(':')) {
        patchFlag |= PatchFlags.PROPS;
        dynamicProps.push(prop.name.slice(1));
      }
    }
  }
  
  // 检查文本内容
  if (node.children) {
    for (const child of node.children) {
      if (child.type === 'Interpolation') {
        patchFlag |= PatchFlags.TEXT;
        break;
      }
    }
  }
  
  if (patchFlag) {
    node.patchFlag = patchFlag;
    node.dynamicProps = dynamicProps;
    context.dynamicNodes.add(node);
  }
};

/**
 * 判断属性是否为动态
 */
function isDynamic(prop) {
  return prop.value && (
    prop.value.includes('{{') ||
    prop.value.startsWith('_ctx.') ||
    prop.value.includes('$event')
  );
}

静态提升插件

/**
 * 静态提升插件
 */
const hoistStaticPlugin = (node, context) => {
  if (node.type === 'Element' && node.isStatic) {
    // 生成唯一的变量名
    const hoistName = `_hoisted_${context.hoisted.length + 1}`;
    
    // 存储到提升列表
    context.hoisted.push({
      name: hoistName,
      node: node
    });
    
    // 替换为变量引用
    const replacement = {
      type: 'HoistReference',
      name: hoistName,
      original: node
    };
    
    context.replaceNode(replacement);
  }
};

/**
 * 生成提升的代码
 */
function generateHoisted(hoisted) {
  let code = '';
  
  for (const { name, node } of hoisted) {
    code += `\nconst ${name} = createVNode(`;
    code += `'${node.tag}', `;
    code += generateProps(node.props);
    code += `, ${generateChildren(node.children)}`;
    code += `, PatchFlags.HOISTED);\n`;
  }
  
  return code;
}

常量提升原理

常量的识别

除了静态节点外,常量表达式也会被提升,我们来看下面一个模板示例:

<div>
  <p>{{ 1 + 2 }}</p>  <!-- 常量表达式 -->
</div>

{{ 1 + 2 }} 是一个常量表达式,它在编译时,也会提升:

const _hoisted_1 = 1 + 2;  // 常量表达式提升

function render(ctx) {
  return createVNode('div', null, [
    createVNode('p', null, _hoisted_1, PatchFlags.TEXT),
    createVNode('p', null, ctx.message, PatchFlags.TEXT)
  ]);
}

常量检测的实现

/**
 * 常量检测插件
 */
const constantDetectPlugin = (node, context) => {
  if (node.type === 'Interpolation') {
    // 检查表达式是否为常量
    if (isConstantExpression(node.content)) {
      node.isConstant = true;
      
      // 生成常量名
      const constantName = `_constant_${context.constants.length + 1}`;
      context.constants.push({
        name: constantName,
        value: node.content
      });
      
      // 替换为常量引用
      context.replaceNode({
        type: 'ConstantReference',
        name: constantName
      });
    }
  }
};

/**
 * 判断表达式是否为常量
 */
function isConstantExpression(expr) {
  // 简单判断:只包含字面量和算术运算符
  const constantPattern = /^[\d\s\+\-\*\/\(\)]+$/;
  return constantPattern.test(expr);
}

缓存内联事件处理函数

事件处理函数的问题

在 JavaScript 中,每次重新渲染都会创建新的函数,如以下模板示例:

<template>
  <button @click="() => count++">点击</button>
</template>

在每次渲染时,都会创建新函数:

function render(ctx) {
  return createVNode('button', {
    onClick: () => ctx.count++  // 每次都不同
  }, '点击');
}

这么处理会有什么问题呢?在每次渲染时,都会为 button 创建一个全新的事件处理对象,里面的 onClick 也会是一个全新的函数。这就会导致渲染器每次渲染都会进行一次更新,造成额外的性能浪费。

事件缓存机制

为了解决上述问题,Vue3 采用了事件缓存机制,对内联事件处理函数进行缓存:

function render(ctx, _cache) {
  return createVNode('button', {
    onClick: _cache[0] || (_cache[0] = ($event) => ctx.count++)
  }, '点击');
}

缓存插件的实现

/**
 * 事件缓存插件
 */
const cacheEventHandlerPlugin = (node, context) => {
  if (node.type === 'Element' && node.props) {
    let cacheIndex = 0;
    
    for (let i = 0; i < node.props.length; i++) {
      const prop = node.props[i];
      
      if (prop.name.startsWith('@') || prop.name === 'onClick') {
        // 生成缓存代码
        const eventName = prop.name.replace(/^@|^on/, '').toLowerCase();
        const handler = prop.value;
        
        prop.cached = true;
        prop.cacheIndex = cacheIndex++;
        prop.cachedCode = `_cache[${prop.cacheIndex}] || (_cache[${prop.cacheIndex}] = $event => ${handler})`;
      }
    }
  }
};

/**
 * 生成事件缓存代码
 */
function generateEventCode(node, context) {
  if (!node.props) return 'null';
  
  const propsObj = {};
  
  for (const prop of node.props) {
    if (prop.cached) {
      // 使用缓存
      propsObj[prop.name] = prop.cachedCode;
    } else {
      // 普通属性
      propsObj[prop.name] = prop.value;
    }
  }
  
  return JSON.stringify(propsObj);
}

结语

静态提升和补丁标志是 Vue3 性能优化的两大法宝,它们让 Vue 能够在运行时精准地只更新变化的部分。理解这些优化,不仅帮助我们写出更高效的代码,也让我们对 Vue 的设计哲学有更深的理解。

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

性能优化之实战指南:让你的 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编辑器

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

同学们好,我是 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,你的电子学友,我们下一篇干货见~

组合式函数 、 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>

弹窗与抽屉组件封装:如何做一个全局可控的 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,你的电子学友,我们下一篇干货见~

深入理解 Vue 依赖收集:从源码角度拆解响应式的核心

在 Vue 的响应式系统中,“依赖收集” 是贯穿整个数据驱动视图的核心环节。很多开发者日常使用datacomputedwatch时只知其然,却不知其所以然 —— 为什么修改数据视图会自动更新?为什么computed能精准缓存?这一切的背后,都是依赖收集机制在起作用。

本文将从 Vue 2 的源码出发,层层拆解依赖收集的完整流程,带你从 “使用层” 走向 “原理层”,真正理解 Vue 响应式的底层逻辑。

一、先搞懂:什么是 “依赖”?

在开始源码分析前,我们先明确核心概念:

  • 依赖:本质上是 “使用了某个响应式数据的执行函数”,比如渲染组件的render函数、computed的计算函数、watch的回调函数。
  • 依赖收集:在响应式数据被读取时,记录下 “哪些函数依赖了这个数据”;当数据被修改时,找到这些记录的函数并执行,最终实现 “数据变 → 视图更”。

简单来说,依赖收集的核心目标是:建立 “响应式数据” 与 “使用数据的函数” 之间的映射关系

二、核心角色:依赖收集的 3 个关键模块

Vue 2 的依赖收集主要依赖三个核心模块,我们先认识它们:

表格

模块 作用 核心源码位置
Observer 将普通对象 / 数组转为响应式(给属性添加 get/set) src/core/observer/index.js
Dep 依赖管理器:存储某个响应式数据的所有依赖 src/core/observer/dep.js
Watcher 依赖的载体:封装需要执行的函数(如 render、computed) src/core/observer/watcher.js

三者的关系可以总结为:

Observer 给数据加 get/set 钩子 → 读取数据时触发 get,通过Dep收集Watcher → 修改数据时触发 set,通过Dep通知所有Watcher执行。

三、源码拆解:依赖收集的完整流程

3.1 第一步:响应式数据的初始化(Observer)

首先,Vue 会通过Observer类将data中的数据转为响应式,核心是给每个属性定义getter/setter

核心源码(简化版):

javascript

运行

// src/core/observer/index.js
class Observer {
  constructor(value) {
    this.value = value
    this.dep = new Dep() // 给对象/数组本身创建Dep
    def(value, '__ob__', this) // 给数据添加__ob__属性,标记为响应式
    
    if (Array.isArray(value)) {
      // 处理数组的响应式(重写push/pop等方法)
      this.observeArray(value)
    } else {
      // 处理对象的响应式:遍历属性并定义get/set
      this.walk(value)
    }
  }

  // 遍历对象属性,定义响应式
  walk(obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  // 遍历数组,给每个元素做响应式处理
  observeArray(items) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

// 核心:给单个属性定义get/set
export function defineReactive(
  obj,
  key,
  val,
  customSetter,
  shallow
) {
  // 每个响应式属性都有一个专属的Dep实例
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // 保留原有的get/set
  const getter = property && property.get
  const setter = property && property.set

  // 递归处理子属性,保证深层数据也是响应式
  let childOb = !shallow && observe(val)

  // 定义新的getter/setter
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // 读取属性时触发:依赖收集的入口
    get: function reactiveGetter() {
      const value = getter ? getter.call(obj) : val
      
      // 关键:如果当前有活跃的Watcher,就收集依赖
      if (Dep.target) {
        dep.depend() // 1. 让Dep记录当前Watcher
        if (childOb) {
          // 2. 给对象/数组本身也收集依赖(处理数组/对象整体变更)
          childOb.dep.depend()
          if (Array.isArray(value)) {
            // 3. 数组特殊处理:遍历子元素收集依赖
            dependArray(value)
          }
        }
      }
      return value
    },
    // 修改属性时触发:通知依赖更新
    set: function reactiveSetter(newVal) {
      const value = getter ? getter.call(obj) : val
      // 新旧值相同则不处理
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 新值也要做响应式处理
      childOb = !shallow && observe(newVal)
      // 关键:通知所有依赖更新
      dep.notify()
    }
  })
}

核心要点

  • 每个响应式属性都会创建一个Dep实例,专属管理该属性的依赖;
  • getter中触发依赖收集,setter中触发依赖更新;
  • 不仅处理单个属性,还会递归处理子对象 / 数组,保证深层响应式。

3.2 第二步:依赖管理器(Dep)

Dep是依赖的 “容器”,核心作用是存储和管理某个数据的所有Watcher,提供depend(收集)和notify(通知)两个核心方法。

核心源码(简化版):

javascript

运行

// src/core/observer/dep.js
export default class Dep {
  static target: ?Watcher; // 静态属性,存储当前活跃的Watcher
  id: number; // 唯一标识
  subs: Array<Watcher>; // 存储依赖的Watcher数组

  constructor() {
    this.id = uid++
    this.subs = []
  }

  // 添加一个Watcher到依赖列表
  addSub(sub: Watcher) {
    this.subs.push(sub)
  }

  // 移除一个Watcher
  removeSub(sub: Watcher) {
    remove(this.subs, sub)
  }

  // 核心:收集依赖(让Dep和Watcher互相记录)
  depend() {
    if (Dep.target) {
      // 调用当前Watcher的addDep方法,双向绑定
      Dep.target.addDep(this)
    }
  }

  // 核心:通知所有Watcher更新
  notify() {
    // 复制一份依赖列表,避免更新过程中列表变化
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      // 调用Watcher的update方法
      subs[i].update()
    }
  }
}

// 全局唯一的Dep.target栈(处理嵌套Watcher,比如computed嵌套)
Dep.target = null
const targetStack = []

// 入栈:设置当前活跃的Watcher
export function pushTarget(target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

// 出栈:恢复上一个Watcher
export function popTarget() {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

核心要点

  • Dep.target是全局唯一的,始终指向 “当前正在执行的 Watcher”;
  • depend()方法不是直接添加 Watcher,而是调用Watcher.addDep(),实现 Dep 和 Watcher 的双向记录(避免重复收集);
  • 用栈结构targetStack处理嵌套场景(比如组件嵌套、computed 嵌套)。

3.3 第三步:依赖载体(Watcher)

Watcher是 “依赖” 的具体载体,每个 Watcher 对应一个需要执行的函数(比如组件的render函数、computed的计算函数)。

核心源码(简化版):

javascript

运行

// src/core/observer/watcher.js
export default class Watcher {
  constructor(
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)
    
    // 处理配置项(比如lazy、deep、sync)
    if (options) {
      this.lazy = !!options.lazy // computed用
      this.deep = !!options.deep // 深度监听用
      this.sync = !!options.sync // 同步更新用
    } else {
      this.lazy = this.deep = this.sync = false
    }
    
    this.cb = cb // 更新回调
    this.id = uid++ // 唯一标识
    this.deps = [] // 存储当前Watcher依赖的Dep
    this.newDeps = [] // 临时存储新依赖(用于依赖清理)
    this.depIds = new Set() // 去重
    this.newDepIds = new Set()
    
    // 解析表达式/函数,得到最终要执行的函数
    this.getter = typeof expOrFn === 'function' ? expOrFn : parsePath(expOrFn)
    
    // 非lazy模式(比如render、watch)立即执行get,触发依赖收集
    this.value = this.lazy ? undefined : this.get()
  }

  // 核心:执行getter并收集依赖
  get() {
    // 1. 将当前Watcher入栈,设置为Dep.target
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      // 2. 执行getter(比如render函数),触发数据的getter
      // 此时数据的getter会检测到Dep.target,从而收集当前Watcher
      value = this.getter.call(vm, vm)
    } catch (e) {
      // 错误处理
    } finally {
      // 3. 深度监听处理
      if (this.deep) {
        traverse(value)
      }
      // 4. 出栈,恢复Dep.target
      popTarget()
      // 5. 清理无用的依赖
      this.cleanupDeps()
    }
    return value
  }

  // 核心:添加Dep到Watcher(与Dep.depend()配合)
  addDep(dep: Dep) {
    const id = dep.id
    // 避免重复收集
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        // 让Dep也记录当前Watcher
        dep.addSub(this)
      }
    }
  }

  // 清理无用依赖(比如数据从视图中移除后,不再监听)
  cleanupDeps() {
    // 省略清理逻辑...
  }

  // 核心:响应式数据更新时,触发Watcher更新
  update() {
    if (this.lazy) {
      // computed:标记为脏值,下次访问时重新计算
      this.dirty = true
    } else if (this.sync) {
      // 同步更新:立即执行run
      this.run()
    } else {
      // 异步更新(Vue默认):加入队列,批量更新
      queueWatcher(this)
    }
  }

  // 执行getter并触发回调
  run() {
    const value = this.get()
    if (value !== this.value || this.deep) {
      const oldValue = this.value
      this.value = value
      // 执行回调(比如watch的回调函数)
      this.cb.call(this.vm, value, oldValue)
    }
  }

  // computed专用:计算并返回最新值
  evaluate() {
    this.value = this.get()
    this.dirty = false
  }

  // 重新收集依赖
  depend() {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
}

核心要点

  • Watcher.get()是触发依赖收集的关键:先将自身设为Dep.target,再执行getter(比如render函数),此时render中用到的所有响应式数据都会触发getter,从而收集当前 Watcher;

  • 不同类型的 Watcher 有不同的更新策略:

    • 渲染 Watcher(render):异步更新,加入队列批量执行;
    • 计算 Watcher(computed):懒更新(lazy: true),只有访问时才重新计算;
    • 侦听 Watcher(watch):可配置同步 / 异步,支持深度监听。

3.4 第四步:完整流程梳理(以组件渲染为例)

结合上面的源码,我们用流程图梳理组件渲染时的依赖收集完整流程:

预览

查看代码

组件初始化

创建渲染Watcher

生成失败,请重试

graph TD
    A[组件初始化] --> B[创建渲染Watcher]
    B --> C[执行Watcher.get()]
    C --> D[pushTarget:设置Dep.target为当前Watcher]
    D --> E[执行render函数]
    E --> F[读取响应式数据,触发getter]
    F --> G[Dep.depend():收集依赖]
    G --> H[Watcher.addDep():双向绑定Dep和Watcher]
    H --> I[render执行完成]
    I --> J[popTarget:恢复Dep.target]
    J --> K[依赖收集完成:数据→Dep→Watcher映射建立]
    L[修改响应式数据] --> M[触发setter]
    M --> N[Dep.notify():通知所有Watcher]
    N --> O[Watcher.update():执行更新]
    O --> P[重新执行render,更新视图]

组件初始化

创建渲染Watcher

生成失败,请重试

豆包

你的 AI 助手,助力每日工作学习

四、特殊场景的依赖处理

4.1 数组的依赖收集

数组的响应式处理和对象不同(因为数组的索引无法被Object.defineProperty拦截),Vue 重写了pushpopsplice等 7 个数组方法,核心逻辑:

javascript

运行

// src/core/observer/array.js
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

// 重写的7个方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

methodsToPatch.forEach(function (method) {
  // 保留原方法
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator(...args) {
    // 执行原方法
    const result = original.apply(this, args)
    // 获取数组的__ob__(Observer实例)
    const ob = this.__ob__
    // 处理新增元素(push/unshift/splice),转为响应式
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 关键:通知依赖更新
    ob.dep.notify()
    return result
  })
})

核心:数组的依赖收集在数组本身的__ob__.dep中,修改数组时调用ob.dep.notify()触发更新。

4.2 computed 的依赖收集

computed的 Watcher 是 “懒 Watcher”(lazy: true),特点:

  1. 初始化时不立即执行get,只有首次访问时才触发;
  2. 依赖的数据更新时,只标记dirty: true,不立即重新计算;
  3. 下次访问computed属性时,才重新计算并缓存结果。

核心逻辑:

javascript

运行

// src/core/instance/state.js
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        // 只有脏值时才重新计算
        watcher.evaluate()
      }
      // 收集渲染Watcher到computed的依赖中
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

五、常见问题与面试考点

5.1 为什么 Vue 不能检测数组索引和长度的变化?

  • 数组索引的get/set虽然能被Object.defineProperty拦截,但考虑到性能成本(数组元素可能很多),Vue 放弃了这种方式;
  • 数组长度的set也无法被有效拦截,且修改长度的场景较少;
  • 解决方案:使用 Vue 提供的变异方法(push/splice 等)或Vue.set

5.2 为什么修改对象的新属性视图不更新?

  • 因为对象初始化时,只有已定义的属性被添加了get/set,新属性没有;
  • 解决方案:使用Vue.set(obj, key, value)this.$set,本质是给新属性添加get/set并触发依赖更新。

5.3 依赖收集为什么要双向记录(Dep→Watcher 和 Watcher→Dep)?

  • 避免重复收集:通过depIdsnewDepIds去重;
  • 方便依赖清理:组件销毁时,Watcher 可以遍历自己的deps,从 Dep 中移除自身;
  • 支持依赖更新:Watcher 可以通过deps重新收集依赖(比如computeddepend方法)。

六、总结

Vue 的依赖收集机制是响应式系统的灵魂,核心可以总结为 3 点:

  1. 核心链路Observer给数据加get/set → 读取数据时Dep收集Watcher → 修改数据时Dep通知Watcher执行 → 视图更新;
  2. 核心角色Observer(响应式标记)、Dep(依赖容器)、Watcher(依赖载体)三者协同工作;
  3. 性能优化:通过懒更新(computed)、异步队列(渲染 Watcher)、依赖清理等方式,保证响应式的高效性。

理解依赖收集,不仅能帮你解决日常开发中的响应式问题,更能让你从底层理解 Vue 的设计思想。希望本文能让你对 Vue 的响应式系统有更深入的认识~

❌