Help us learn about your current experience with the documentation. Take the survey.

Vue

要开始使用 Vue,请阅读 Vue 官方文档

示例

以下章节描述的内容可在这些示例中找到:

何时添加 Vue 应用

有时,HAML 页面就足够满足需求。此说法主要适用于静态页面或逻辑非常简单的页面。我们如何判断是否值得在页面中添加 Vue 应用?答案是:“当我们需要维护应用状态并使其与渲染的页面保持同步时”。

为了更好地解释这一点,让我们想象一个页面上有一个开关,切换它会发送 API 请求。这种情况不涉及需要维护的状态,我们只需发送请求并切换开关即可。但如果再添加一个开关,它必须始终与第一个开关状态相反,这时就需要一个 状态:一个开关需要"感知"另一个开关的状态。如果用原生 JavaScript 实现,这种逻辑通常需要监听 DOM 事件并修改 DOM 来响应。这类情况使用 Vue.js 会更容易处理,因此我们应该在此处创建 Vue 应用。

如何向页面添加 Vue 应用

  1. app/assets/javascripts 中为你的 Vue 应用创建新文件夹。
  2. 添加 页面特定 JavaScript 来加载你的应用。
  3. 你可以使用 `initSimpleApp 辅助函数 来简化 从 HAML 向 JS 传递数据

哪些信号表明你可能需要 Vue 应用?

  • 当你需要基于多个因素定义复杂条件并在用户交互时更新它们;
  • 当你需要维护任何形式的应用状态并在标签/元素间共享;
  • 当你预期未来会添加复杂逻辑 - 从基础 Vue 应用开始,比后续将 JS/HAML 重写为 Vue 更容易。

避免页面中存在多个 Vue 应用

过去,我们通过向渲染的 HAML 页面不同部分添加多个小型 Vue 应用来逐步增强页面交互性。然而,这种方法导致了多个问题:

  • 大多数情况下,这些应用不共享状态,独立执行 API 请求,导致请求数量增加;
  • 我们必须通过多个端点从 Rails 向 Vue 提供数据;
  • 我们无法在页面加载后动态渲染 Vue 应用,导致页面结构变得僵化;
  • 我们无法充分利用客户端路由替代 Rails 路由;
  • 多个应用会导致不可预测的用户体验、增加页面复杂度、使调试过程更困难;
  • 应用间的通信方式会影响 Web Vitals 指标。

由于这些原因,在已有 Vue 应用的页面(这不包括新旧导航)中添加新 Vue 应用时,我们需要谨慎。在添加新应用前,请确保绝对不可能通过扩展现有应用来实现所需功能。如有疑问,请随时在 #frontend#frontend-maintainers Slack 频道寻求架构建议。

如果仍需添加新应用,请确保它与现有应用共享本地状态。 学习:如何选择状态管理器?

Vue 架构

我们通过 Vue 架构试图实现的主要目标是:只有一个数据流,且只有一个数据入口。 为实现此目标,我们使用 PiniaApollo Client

你还可以在 Vue 文档中阅读关于 状态管理单向数据流 的内容。

组件与 Store

在 Vue.js 实现的某些功能中,如 问题看板环境表格 你可以看到清晰的职责分离:

new_feature
├── components
│   └── component.vue
│   └── ...
├── store
│  └── new_feature_store.js
├── index.js

为保持一致性,建议你遵循相同结构。

让我们逐一查看它们:

index.js 文件

这是新功能的入口文件。新功能的根 Vue 实例应在此处。

Store 和 Service 应在此文件中导入并初始化,作为 prop 提供给主组件。

请务必阅读 页面特定 JavaScript

启动注意事项

从 HAML 向 JavaScript 提供数据

在挂载 Vue 应用时,你可能需要从 Rails 向 JavaScript 提供数据。为此,可以在 HTML 元素中使用 data 属性,并在挂载应用时查询它们。这仅在初始化应用时进行,因为挂载的元素会被 Vue 生成的 DOM 替换。

data 属性仅能接受字符串值,因此你需要将其他变量类型转换为字符串。

通过 props 或在 render 函数中使用 provide 从 DOM 向 Vue 实例提供数据(而不是在主 Vue 组件中查询 DOM)的优势在于:在单元测试中无需创建 fixture 或 HTML 元素。

initSimpleApp 辅助函数

initSimpleApp 是一个简化 Vue.js 中挂载组件过程的辅助函数。它接受两个参数:表示 HTML 中挂载点的选择器字符串,以及一个 Vue 组件。

使用 initSimpleApp

  1. 在页面中包含一个具有 ID 或唯一类的 HTML 元素。
  2. 添加包含 JSON 对象的 data-view-model 属性。
  3. 导入所需的 Vue 组件,并将其与有效的 CSS 选择器字符串一起传递给 initSimpleApp。该字符串选择 HTML 元素并在此处挂载组件。

initSimpleApp 会自动检索 data-view-model 属性的内容作为 JSON 对象,并将其作为 props 传递给挂载的 Vue 组件。这可用于预填充组件数据。

示例:

//my_component.vue
<template>
  <div>
    <p>Prop1: {{ prop1 }}</p>
    <p>Prop2: {{ prop2 }}</p>
  </div>
</template>

<script>
export default {
  name: 'MyComponent',
  props: {
    prop1: {
      type: String,
      required: true
    },
    prop2: {
      type: Number,
      required: true
    }
  }
}
</script>
<div id="js-my-element" data-view-model='{"prop1": "my object", "prop2": 42 }'></div>
//index.js
import MyComponent from './my_component.vue'
import { initSimpleApp } from '~/helpers/init_simple_app_helper'

initSimpleApp('#js-my-element', MyComponent, { name: 'MyAppRoot' })
使用 provide/inject 而非 props 传递值

要使用 initSimpleApp 通过 provide/inject 传递值:

  1. 在页面中包含一个具有 ID 或唯一类的 HTML 元素。
  2. 添加包含 JSON 对象的 data-provide 属性。
  3. 导入所需的 Vue 组件,并将其与有效的 CSS 选择器字符串一起传递给 initSimpleApp。该字符串选择 HTML 元素并在此处挂载组件。

initSimpleApp 会自动检索 data-provide 属性的内容作为 JSON 对象,并将其作为 inject 传递给挂载的 Vue 组件。这可用于预填充组件数据。

示例:

//my_component.vue
<template>
  <div>
    <p>Inject1: {{ inject1 }}</p>
    <p>Inject2: {{ inject2 }}</p>
  </div>
</template>

<script>
export default {
  name: 'MyComponent',
  inject: {
    inject1: {
      default: '',
    },
    inject2: {
      default: 0
    }
  },
}
</script>
<div id="js-my-element" data-provide='{"inject1": "my object", "inject2": 42 }'></div>
//index.js
import MyComponent from './my_component.vue'
import { initSimpleApp } from '~/helpers/init_simple_app_helper'

initSimpleApp('#js-my-element', MyComponent, { name: 'MyAppRoot' })
provideinject

Vue 通过 provideinject 支持依赖注入。在组件中,inject 配置可访问 provide 传递下来的值。以下 Vue 应用初始化示例展示了 provide 配置如何将值从 HAML 传递给组件:

#js-vue-app{ data: { endpoint: 'foo' }}

// index.js
const el = document.getElementById('js-vue-app');

if (!el) return false;

const { endpoint } = el.dataset;

return new Vue({
  el,
  name: 'MyComponentRoot',
  render(createElement) {
    return createElement('my-component', {
      provide: {
        endpoint
      },
    });
  },
});

组件或其任何子组件可通过 inject 访问该属性:

<script>
  export default {
    name: 'MyComponent',
    inject: ['endpoint'],
    ...
    ...
  };
</script>
<template>
  ...
  ...
</template>

当满足以下条件时,使用依赖注入从 HAML 提供值是理想选择:

  • 注入的值无需对其数据类型或内容进行显式验证。
  • 该值无需是响应式的。
  • 在层次结构中存在多个组件需要访问该值,此时 prop 传递(prop-drilling)变得不便。prop 传递是指相同的 prop 通过层次结构中的所有组件传递,直到真正使用它的组件。

如果同时满足以下两个条件,依赖注入可能会破坏子组件(直接子组件或多层嵌套子组件):

  • inject 配置中声明的值未定义默认值。
  • 父组件未使用 provide 配置提供该值。

在需要默认值的情况下,默认值 可能很有用。

props

如果来自 HAML 的值不符合依赖注入的条件,请使用 props。请参考以下示例。

// haml
#js-vue-app{ data: { endpoint: 'foo' }}

// index.js
const el = document.getElementById('js-vue-app');

if (!el) return false;

const { endpoint } = el.dataset;

return new Vue({
  el,
  name: 'MyComponentRoot',
  render(createElement) {
    return createElement('my-component', {
      props: {
        endpoint
      },
    });
  },
});

为挂载 Vue 应用而添加 id 属性时,请确保该 id 在代码库中是唯一的。

有关我们明确声明传入 Vue 应用数据的更多信息,请参阅我们的 Vue 风格指南

向 Vue 应用提供 Rails 表单字段

使用 Rails 编写表单时,表单输入的 nameidvalue 属性会生成以匹配后端。在将 Rails 表单转换为 Vue 时,或在 集成组件(如日期选择器或项目选择器)到表单中时,能够访问这些生成的属性会很有帮助。parseRailsFormFields 工具函数可用于解析生成的表单输入属性,以便将其传递给 Vue 应用。这使我们能够集成 Vue 组件,而无需更改表单提交方式。

-# form.html.haml
= form_for user do |form|
  .js-user-form
    = form.text_field :name, class: 'form-control gl-form-input', data: { js_name: 'name' }
    = form.text_field :email, class: 'form-control gl-form-input', data: { js_name: 'email' }

js_name 数据属性用作结果 JavaScript 对象中的键。例如 = form.text_field :email, data: { js_name: 'fooBarBaz' } 将被转换为 { fooBarBaz: { name: 'user[email]', id: 'user_email', value: '' } }

// index.js
import Vue from 'vue';
import { parseRailsFormFields } from '~/lib/utils/forms';
import UserForm from './components/user_form.vue';

export const initUserForm = () => {
  const el = document.querySelector('.js-user-form');

  if (!el) {
    return null;
  }

  const fields = parseRailsFormFields(el);

  return new Vue({
    el,
    name: 'UserFormRoot',
    render(h) {
      return h(UserForm, {
        props: {
          fields,
        },
      });
    },
  });
};
<script>
// user_form.vue
import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';

export default {
  name: 'UserForm',
  components: { GlButton, GlFormGroup, GlFormInput },
  props: {
    fields: {
      type: Object,
      required: true,
    },
  },
};
</script>

<template>
  <div>
    <gl-form-group :label-for="fields.name.id" :label="__('Name')">
      <gl-form-input v-bind="fields.name" width="lg" />
    </gl-form-group>

    <gl-form-group :label-for="fields.email.id" :label="__('Email')">
      <gl-form-input v-bind="fields.email" type="email" width="lg" />
    </gl-form-group>

    <gl-button type="submit" category="primary" variant="confirm">{{ __('Update') }}</gl-button>
  </div>
</template>

访问 gl 对象

我们在查询 DOM 的同一位置查询 gl 对象中在应用生命周期内不会变化的数据。通过遵循此实践,我们可以避免模拟 gl 对象,从而使测试更容易。这应在初始化 Vue 实例时完成,数据应作为 props 提供给主组件:

return new Vue({
  el: '.js-vue-app',
  name: 'MyComponentRoot',
  render(createElement) {
    return createElement('my-component', {
      props: {
        avatarUrl: gl.avatarUrl,
      },
    });
  },
});

访问权限(Abilities)

将权限推送到 前端 后,使用 Vue 中的 provideinject 机制,使权限对 Vue 应用中任何后代组件可用。glAbilties 对象已在 commons/vue.js 中提供,因此只需使用 mixin 即可使用这些标志:

// 任意后代组件

import glAbilitiesMixin from '~/vue_shared/mixins/gl_abilities_mixin';

export default {
  // ...
  mixins: [glAbilitiesMixin()],
  // ...
  created() {
    if (this.glAbilities.someAbility) {
      // ...
    }
  },
}

访问功能标志(Feature Flags)

将功能标志推送到 前端 后,使用 Vue 中的 provideinject 机制,使功能标志对 Vue 应用中任何后代组件可用。glFeatures 对象已在 commons/vue.js 中提供,因此只需使用 mixin 即可使用这些标志:

// 任意后代组件

import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';

export default {
  // ...
  mixins: [glFeatureFlagsMixin()],
  // ...
  created() {
    if (this.glFeatures.myFlag) {
      // ...
    }
  },
}

这种方法有几个好处:

  • 任意深度嵌套的组件可以选择并访问该标志,无需中间组件知晓(通过 props 传递标志)。

  • 良好的可测试性,因为标志可以作为 prop 从 vue-test-utilsmount/shallowMount 提供。

    import { shallowMount } from '@vue/test-utils';
    
    shallowMount(component, {
      provide: {
        glFeatures: { myFlag: true },
      },
    });
  • 除了在应用的 入口点 外,无需访问全局变量。

页面重定向与显示警告

如果需要重定向到另一个页面并显示警告,可以使用 visitUrlWithAlerts 工具函数。当你重定向到新创建的资源并显示成功警告时,这会很有用。

默认情况下,页面重新加载时会清除警告。如果需要让警告在页面上持久化,可以将 persistOnPages 键设置为 Rails 控制器操作的数组。要在控制台中查找 Rails 控制器操作,请运行 document.body.dataset.page

示例:

visitUrlWithAlerts('/dashboard/groups', [
  {
    id: 'resource-building-in-background',
    message: 'Resource is being built in the background.',
    variant: 'info',
    persistOnPages: ['dashboard:groups:index'],
  },
])

如果需要手动移除持久化警告,可以使用 removeGlobalAlertById 工具函数。

如果需要以编程方式关闭警告,可以使用 dismissGlobalAlertById 工具函数。

组件文件夹

此文件夹保存所有与此新功能特定的组件。 要使用或创建可能在其他地方使用的组件,请参考 vue_shared/components

判断何时应创建组件的一个好准则是:思考它是否可能在其他地方重用。 例如,表格在 GitLab 中许多地方使用,表格是组件的良好选择。另一方面,仅在一个表格中使用的表格单元格则不适合使用此模式。

你可以在 Vue.js 网站上阅读更多关于组件的内容,组件系统

Pinia

在 GitLab 中了解 Pinia

Vuex

Vuex 已弃用,请考虑 迁移

Vue Router

向页面添加 Vue Router

  1. 在 Rails 路由文件中使用通配符 *vueroute 添加捕获所有路由:

    # ee/config/routes/project.rb 中的示例
    
    resources :iteration_cadences, path: 'cadences(/*vueroute)', action: :index

    上述示例将 iteration_cadences 控制器的 index 页面提供给与 path 开头匹配的任何路由,例如 groupname/projectname/-/cadences/123/456/

  2. 将基础路由(*vueroute 之前的所有内容)传递给前端,用作初始化 Vue Router 的 base 参数:

    .js-my-app{ data: { base_path: project_iteration_cadences_path(project) } }
  3. 初始化路由器:

    Vue.use(VueRouter);
    
    export function createRouter(basePath) {
      return new VueRouter({
        routes: createRoutes(),
        mode: 'history',
        base: basePath,
      });
    }
  4. 为未识别的路由添加 path: '*' 的后备方案。可以:

    • 在路由数组末尾添加重定向:

      const routes = [
        {
          path: '/',
          name: 'list-page',
          component: ListPage,
        },
        {
          path: '*',
          redirect: '/',
        },
      ];
    • 在路由数组末尾添加后备组件:

      const routes = [
        {
          path: '/',
          name: 'list-page',
          component: ListPage,
        },
        {
          path: '*',
          component: NotFound,
        },
      ];
  5. 可选。为允许在子路由中使用路径助手,添加 controlleraction 参数以使用父控制器。

    resources :iteration_cadences, path: 'cadences(/*vueroute)', action: :index do
      resources :iterations, only: [:index, :new, :edit, :show], constraints: { id: /\d+/ }, controller: :iteration_cadences, action: :index
    end

    这意味着像 /cadences/123/iterations/456/edit 这样的路由可以在后端验证,例如检查组或项目成员资格。 这也意味着我们可以使用 _path 助手,这意味着我们可以在功能规范中加载页面,而无需手动构建路径的 *vueroute 部分。

Vue 与 jQuery 混合使用

  • 不推荐混合使用 Vue 和 jQuery。
  • 要在 Vue 中使用特定的 jQuery 插件,为其创建包装器
  • Vue 可以使用 jQuery 事件监听器监听现有的 jQuery 事件。
  • 不建议为 Vue 与 jQuery 交互而添加新的 jQuery 事件。

Vue 与 JavaScript 类混合使用(在 data 函数中)

Vue 文档 中,Data 函数/对象定义如下:

Vue 实例的数据对象。Vue 递归地将其属性转换为 getter/setter 以使其"响应式"。该对象必须是普通对象:浏览器 API 对象和原型属性将被忽略。一个准则是数据应该只是数据 - 不建议观察具有自身状态行为的对象。

基于 Vue 的指导原则:

  • 不要 在你的 data 函数 中使用或创建 JavaScript 类。
  • 不要 添加新的 JavaScript 类实现。
  • 应该 使用具有内聚解耦的组件或 状态管理器 来封装复杂的状态管理。
  • 应该 维护使用这些方法的现有实现。
  • 应该 在组件发生重大更改时将其迁移到纯对象模型。
  • 应该 将业务逻辑移到单独的文件中,以便与组件分开测试。

为什么

在大型代码库中,使用 JavaScript 类会带来可维护性问题的其他原因:

  • 类创建后,可能会以违反 Vue 响应式和最佳实践的方式进行扩展。
  • 类增加了一层抽象,使组件 API 及其内部工作原理不够清晰。
  • 使测试变得更困难。因为类由组件 data 函数实例化,所以更难"管理"组件和类。
  • 在函数式代码库中添加面向对象原则(OOP)增加了编写代码的方式,降低了一致性和清晰度。

风格指南

在编写和测试 Vue 组件和模板时,请参考我们 风格指南 中的 Vue 部分。

组合式 API (Composition API)

在 Vue 2.7 中,可以在 Vue 组件中作为独立组合式函数使用 组合式 API

优先使用 <script> 而非 <script setup>

组合式 API 允许将逻辑放在组件的 <script> 部分或专用的 <script setup> 部分。我们应该使用 <script> 并通过 setup() 属性将组合式 API 添加到组件中:

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

  export default {
    name: 'MyComponent',
    setup(props) {
      const doubleCount = computed(() => props.count*2)
    }
  }
</script>

v-bind 的限制

除非绝对必要,否则避免使用 v-bind="$attrs"。在开发原生控件包装器时可能需要它(这是 gitlab-ui 组件的良好候选)。在任何其他情况下,始终优先使用 props 和显式数据流。

使用 v-bind="$attrs" 会导致:

  1. 组件契约的丢失。props 专门设计用于解决此问题。
  2. 树中每个组件的高维护成本。v-bind="$attrs" 特别难以调试,因为你必须扫描整个组件层次结构来理解数据流。
  3. 迁移到 Vue 3 时出现问题。Vue 3 中的 $attrs 包含事件监听器,这可能在 Vue 3 迁移完成后导致意外副作用。

力求每个组件一种 API 风格

向 Vue 组件添加 setup() 属性时,考虑将其完全重构为组合式 API。这不总是可行的,特别是对于大型组件,但为了可读性和可维护性,应力求每个组件使用一种 API 风格。

组合式函数 (Composables)

使用组合式 API,我们有了新的方式来抽象逻辑,包括将响应式状态抽象为 组合式函数。组合式函数是接受参数并返回可在 Vue 组件中使用的响应式属性和方法的函数。

// useCount.js
import { ref } from 'vue';

export function useCount(initialValue) {
  const count = ref(initialValue)

  function incrementCount() {
    count.value += 1
  }

  function decrementCount() {
    count.value -= 1
  }

  return { count, incrementCount, decrementCount }
}
// MyComponent.vue
import { useCount } from 'useCount'

export default {
  name: 'MyComponent',
  setup() {
    const { count, incrementCount, decrementCount } = useCount(5)

    return { count, incrementCount, decrementCount }
  }
}

函数和文件名前缀使用 use

Vue 中组合式函数的常见命名约定是前缀为 use,然后简要指代组合式功能(例如 useBreakpointsuseGeolocation 等)。包含组合式函数的 .js 文件也应遵循相同规则 - 即使文件包含多个组合式函数,文件名也应以 use_ 开头。

避免生命周期陷阱

构建组合式函数时,应力求保持其尽可能简单。生命周期钩子增加了组合式函数的复杂性,并可能导致意外副作用。为避免这种情况,我们应遵循以下原则:

  • 尽可能减少生命周期钩子的使用,优先接受/返回回调。
  • 如果你的组合式函数需要生命周期钩子,请确保它也执行清理。如果在 onMounted 上添加了监听器,则应在同一组合式函数中的 onUnmounted 上移除它。
  • 始立立即设置生命周期钩子:
// bad
const useAsyncLogic = () => {
  const action = async () => {
    await doSomething();
    onMounted(doSomethingElse);
  };
  return { action };
};

// OK
const useAsyncLogic = () => {
  const done = ref(false);
  onMounted(() => {
    watch(
      done,
      () => done.value && doSomethingElse(),
      { immediate: true },
    );
  });
  const action = async () => {
    await doSomething();
    done.value = true;
  };
  return { action };
};

避免逃生舱口 (Escape Hatches)

编写一个组合式函数作为黑盒,使用 Vue 提供的一些逃生舱口可能很诱人。但在大多数情况下,这会使它们过于复杂且难以维护。一个逃生舱口是 getCurrentInstance 方法。此方法返回当前渲染组件的实例。不应使用该方法,而应优先通过参数将数据或方法传递给组合式函数。

const useSomeLogic = () => {
  doSomeLogic();
  getCurrentInstance().emit('done'); // bad
};
const done = () => emit('done');

const useSomeLogic = (done) => {
  doSomeLogic();
  done(); // good, 组合式函数不试图过于智能
}

测试组合式函数

测试 Vue 组件

有关测试 Vue 组件的指南和最佳实践,请参阅 Vue 测试风格指南

每个 Vue 组件都有独特的输出。此输出始终存在于渲染函数中。

尽管 Vue 组件的每个方法都可以单独测试,但我们的目标是测试渲染函数的输出,它始终代表所有时刻的状态。

请访问 Vue 测试指南 获取帮助。

以下是 此 Vue 组件 结构良好的单元测试示例:

import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import axios from '~/lib/utils/axios_utils';
import App from '~/todos/app.vue';

const TEST_TODOS = [{ text: 'Lorem ipsum test text' }, { text: 'Lorem ipsum 2' }];
const TEST_NEW_TODO = 'New todo title';
const TEST_TODO_PATH = '/todos';

describe('~/todos/app.vue', () => {
  let wrapper;
  let mock;

  beforeEach(() => {
    // 重要:使用 axios-mock-adapter 来存根 axios API 请求
    mock = new MockAdapter(axios);
    mock.onGet(TEST_TODO_PATH).reply(200, TEST_TODOS);
    mock.onPost(TEST_TODO_PATH).reply(200);
  });

  afterEach(() => {
    // 重要:清理 axios mock 适配器
    mock.restore();
  });

  // 将组件设置与其协作者(例如 Vuex 和 axios)分开非常有帮助。
  const createWrapper = (props = {}) => {
    wrapper = shallowMountExtended(App, {
      propsData: {
        path: TEST_TODO_PATH,
        ...props,
      },
    });
  };
  // 辅助方法极大地有助于测试的可维护性和可读性。
  const findLoader = () => wrapper.findComponent(GlLoadingIcon);
  const findAddButton = () => wrapper.findByTestId('add-button');
  const findTextInput = () => wrapper.findByTestId('text-input');
  const findTodoData = () =>
    wrapper
      .findAllByTestId('todo-item')
      .wrappers.map((item) => ({ text: item.text() }));

  describe('当挂载并加载时', () => {
    beforeEach(() => {
      // 创建永不解析的请求
      mock.onGet(TEST_TODO_PATH).reply(() => new Promise(() => {}));
      createWrapper();
    });

    it('应显示加载状态', () => {
      expect(findLoader().exists()).toBe(true);
    });
  });

  describe('当 todos 已加载时', () => {
    beforeEach(() => {
      createWrapper();
      // 重要:此组件在挂载时异步获取数据,因此等待 Vue 模板更新
      return wrapper.vm.$nextTick();
    });

    it('不应显示加载', () => {
      expect(findLoader().exists()).toBe(false);
    });

    it('应渲染 todos', () => {
      expect(findTodoData()).toEqual(TEST_TODOS);
    });

    it('当添加 todo 时,应发布新 todo', async () => {
      findTextInput().vm.$emit('update', TEST_NEW_TODO);
      findAddButton().vm.$emit('click');

      await wrapper.vm.$nextTick();

      expect(mock.history.post.map((x) => JSON.parse(x.data))).toEqual([{ text: TEST_NEW_TODO }]);
    });
  });
});

子组件

  1. 测试任何定义子组件是否渲染/如何渲染的指令(例如 v-ifv-for)。

  2. 测试我们传递给子组件的任何 props(特别是如果 prop 在被测组件中通过 computed 属性计算)。请记住使用 .props() 而非 .vm.someProp

  3. 测试我们是否正确响应来自子组件发出的事件:

    const checkbox = wrapper.findByTestId('checkboxTestId');
    
    expect(checkbox.attributes('disabled')).not.toBeDefined();
    
    findChildComponent().vm.$emit('primary');
    await nextTick();
    
    expect(checkbox.attributes('disabled')).toBeDefined();
  4. 不要 测试子组件的内部实现:

    // bad
    expect(findChildComponent().find('.error-alert').exists()).toBe(false);
    
    // good
    expect(findChildComponent().props('withAlertContainer')).toBe(false);

事件

我们应该测试组件中响应操作发出的事件。此测试验证是否以正确的参数触发了正确的事件。

对于任何原生 DOM 事件,我们应使用 trigger 来触发事件。

// 假设 SomeButton 渲染: <button>Some button</button>
wrapper = mount(SomeButton);

...
it('应触发点击事件', () => {
  const btn = wrapper.find('button')

  btn.trigger('click');
  ...
})

当触发 Vue 事件时,使用 emit

wrapper = shallowMount(DropdownItem);

...

it('应触发 itemClicked 事件', () => {
  DropdownItem.vm.$emit('itemClicked');
  ...
})

我们应通过断言 emitted() 方法的结果来验证事件是否已触发。

优先使用 vm.$emit 而非 trigger 从子组件发出事件是一种良好实践。

在组件上使用 trigger 意味着我们将其视为白盒:假设子组件的根元素具有原生 click 事件。此外,在 Vue3 模式下,在子组件上使用 trigger 时,某些测试会失败。

const findButton = () => wrapper.findComponent(GlButton);

// bad
findButton().trigger('click');

// good
findButton().vm.$emit('click');

Vue.js 专家角色

只有当你的合并请求(MR)和代码审查显示出以下特征时,才应申请成为 Vue.js 专家:

  • 深入理解 Vue 响应式原理
  • Vue 和 Pinia 代码结构符合官方和我们的指南
  • 全面理解测试 Vue 组件和 Pinia Store
  • 了解现有的 Vue 和 Pinia 应用以及可重用组件

Vue 2 -> Vue 3 迁移

我们建议尽量减少向代码库添加某些功能,以避免增加技术债务,为最终迁移做准备:

  • 过滤器 (filters);
  • 事件总线 (event buses);
  • 函数式模板 (functional templated)
  • slot 属性

你可以在 迁移到 Vue 3 中找到更多详细信息

附录 - Vue 组件测试对象

这是在 测试 Vue 组件 部分中测试的示例组件模板:

<template>
  <div class="content">
    <gl-loading-icon v-if="isLoading" />
    <template v-else>
      <div
        v-for="todo in todos"
        :key="todo.id"
        :class="{ 'gl-strike': todo.isDone }"
        data-testid="todo-item"
      >{{ todo.text }}</div>
      <footer class="gl-border-t-1 gl-mt-3 gl-pt-3">
        <gl-form-input
          type="text"
          v-model="todoText"
          data-testid="text-input"
        >
        <gl-button
          variant="confirm"
          data-testid="add-button"
          @click="addTodo"
        >Add</gl-button>
      </footer>
    </template>
  </div>
</template>