# Vue

# 单元测试Vitest

# 安装测试框架

  # -D == --save-dev == -d
  # Vitest requires Vite >= v3.0.0 and Node >= v14.0.0
  npm install -D vitest
1
2
3

# 断言

Vitest支持chaijs断言写法,chai是一个用于节点和浏览器的BDD/TDD断言库,可以与任何javascript测试框架完美搭配。

# 断言的写法

chaijs支持三种写法:

should

chai.should();
foo.should.be.a('string');
foo.should.equal('bar');
foo.should.have.lengthOf(3);
tea.should.have.property('flavors').with.lengthOf(3);
1
2
3
4
5

expect

var expect = chai.expect;

expect(foo).to.be.a('string');
expect(foo).to.equal('bar');
expect(foo).to.have.lengthOf(3);
expect(tea).to.have.property('flavors').with.lengthOf(3);
1
2
3
4
5
6

assert

var assert = chai.assert;

assert.typeOf(foo, 'string');
assert.equal(foo, 'bar');
assert.lengthOf(foo, 3)
assert.property(tea, 'flavors');
assert.lengthOf(tea.flavors, 3);
1
2
3
4
5
6
7

# 在项目中书写测试case

在Vite根目录中创建文件example.test.ts。 然后引入两个依赖,编写对应测试用例:

// expect 所需要使用的断言库
// test 创建测试用例
import { expect, test } from 'vitest'

test('test common matcher', () => {
  const name = 'vanalso'
  // 预计 name变量 等于 'vanalso'
  expect(name).toBe('vanalso')
  expect(2 + 2).not.toBe(5)
})
1
2
3
4
5
6
7
8
9
10

然后使用 npx vitest example 命令运行测试,即可看到结果。

# 相关语法

toBe              // 严格相等(值与引用)
not.toBe          // 不严格相等
toEqual           // 值相等(例如断言对象)
toBeTruehy        // 为真
toBeFalsy         // 为假
toBeGreaterThan   // 大于
toBeLessThan      // 小于
1
2
3
4
5
6
7

# 测试回调函数

假设此时我们需要检测一个回调函数是否被执行:

describe('functions', () => {
  test('create a mock function', () => {
    // 使用vi.fn()创建函数的监听程序
    const callback = vi.fn()
    // 将监听函数传递到目标testfn中作为回调函数
    testFn(12, callback)
    // toHaveBeenCalled断言函数,用于测试函数是否被调用
    expect(callback).toHaveBeenCalled()
    // toHaveBeenCalledWith断言函数,用于测试函数是否至少被传递特定参数调用过一次
    expect(callback).toHaveBeenCalledWith(12)
  })
})

1
2
3
4
5
6
7
8
9
10
11
12
13

describe可以将测试分组

# 监听对象内的方法

使用vi.spyOn监听对象内的方法

  test('spy on method', () => {
    const obj = {
      getName: () => 1
    }
    // 创建对于对象obj的getName方法的监听
    const spy = vi.spyOn(obj, 'getName')
    obj.getName()
    // 断言spy监听的getName方法至少调用一次
    expect(spy).toHaveBeenCalled()
    obj.getName()
    // 断言spy监听的getName方法将会调用两次
    expect(spy).toHaveBeenCalledTimes(2)
  })
1
2
3
4
5
6
7
8
9
10
11
12
13

# 第三方模块模拟Mock数据

通常调用api发送ajax请求时,需要通过后端获取数据,但在坐做前端测试测时候,并不需要调用真实接口,所以需要模拟axios模块,使其不用调用api也能测试接口调用是否正确


// 将需要代理的模块引入
import axios from 'axios'

// 对于ts来讲需要将axios的类型断言为Mocked,并传入泛型axios,才可使用Mock内部方法
const mockedAxios = axios as Mocked<typeof axios>
// 使用vi.mock替换axios中导入的模块
vi.mock('axios')

test('mock third party module', async () => {
  // 将替换后的mockedAxios.get方法的实现替换为mockImplementation传入的方法,即调用axios.get的时候直接返回一promise.resolve
  mockedAxios.get.mockImplementation(() => Promise.resolve({ data: '123' }))
  // 此处为上方mockImplementation的简写形式,直接mock一个resolve
  mockedAxios.get.mockResolvedValue({ data: '1234' })
  const result = await request()
  // 因为上方已经替换掉request中axios.get的执行结果为{ data: '123' },此处直接预计结果为123,即可测试成功
  expect(result).toBe('1234')
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 基于Vue的单元测试工具

vue-test-utils 安装:

npm i -D @vue/test-utils
1

# 创建组件测试

测试组件时,要将组件挂在到页面中,这时需要创建一个包含被挂载和渲染的Vue组件,使用vue-test-utils中的mount方法:

import { mount } from '@vue/test-utils'
import Button from './Button.vue'
const wrapper = mount(Button, {
  props: {
    type: 'primary'
  },
  slots: {
    default: 'button'
  }
})
console.log(wrapper.html())
// .classes()方法可以获取到节点的class集合
// toContain()方法可以断言实际值是否在数组中
expect(wrapper.classes()).toContain('ds-button--primary')
// .getComponent方法可以获取元素,传递css选择器
// .text()可以获取节点文本内容
expect(wrapper.getComponent('.ds-button').text()).toBe('button')

// 通过attributes可以获取到组件上的属性
// 使用toBeDefined断言属性被定义
expect(wrapper.attributes('disabled')).toBeDefined()

// 或者通过element获取到dom元素,从而访问disabled属性
expect(wrapper.find('button').element.disabled).toBeDefined()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

在mound方法中可以传入props、slots等选项用于提供组件创建时所需参数

# 配置vitest运行环境

但此时如果使用npx vitest Button运行测试的话,会报错ReferenceError: document is not defined,这是因为vitest的运行环境是基于node的,需要配置vitest使其运行在dom环境中,解决没有document的问题:

// 在根目录创建vitest.config.ts文件
// 使用///三斜杠命令添加test属性
/// <reference types='vitest' />

// 通过mergeConfig合并vite.config.ts中的配置
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'

export default mergeConfig(
  viteConfig,
  defineConfig({
    // 针对vitest配置
    test: {
      // 开启全局api
      globals: true,
      // 设定运行环境为jsdom
      environment: 'jsdom'
    },
  })
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

此处需要安装jsdom

npm i -D jsdom
1

# Stubs组件存根

在测试组件的时候,经常会遇到组件内部引用第三方组件的情况,此时我们只想知道是否存在第三方组件,无需挂载复杂的组件内部,只要节点存在且传递属性正确即可,所以对于测试来讲,我们不希望挂载完整的第三方组件,使用global.stubs

测试button组件的icon子组件是否被正确挂载

test('button icon', () => {
  const wrapper = mount(Button, {
    props: {
      icon: 'arrow-up'
    },
    slots: {
      default: 'button'
    },
    global: {
      stubs: ['FontAwesomeIcon']
    }
  })
  const fontElement = wrapper.findComponent(FontAwesomeIcon)
  // 断言组件存在
  expect(fontElement.exists()).toBeTruthy()
  // 断言icon属性的值为'arrow-up'
  expect(fontElement.attributes('icon')).toBe('arrow-up')
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# Vnode以及Render Function

# Virtual DOM

Virtual DOM: 一种虚拟的,保存在内存中的数据结构,用来代表UI的表现,和真实DOM节点保持同步。Virtual DOM是由一系列的Vnode组成。

const vnode = {
  type: 'div',
  props: {
    id: 'hello'
  },
  children: [
    // more vnodes
  ]
}
1
2
3
4
5
6
7
8
9

# Render Pipeline 渲染管线

Compile: Vue组件的Template会被编译成render function,一个可以返回Virtual DOM 树的函数

Mount: 执行render function,遍历虚拟DOM树,生成真正的DOM节点

Patch: 当组件中任何响应式对象(依赖)发生变化的时候,执行更新操作。生成新的虚拟节点树,Vue内部会遍历新的虚拟节点树,和旧的树做对比,然后执行必要的更新。

# 创建Vnode

h 和 createVnode 都可以创建vnode,h是hyperscript的缩写,意思是“Javascript that produces HTML(hypertext markup language)”,很多Virtual DOM的实现都是使用这个函数名称。还有一个函数称之为createVnode,更形象,两个函数的用法几乎是一样的。

import {h, createVnode} from 'vue'

const vnode = h(
  'div',  // type
  { id: 'foo', class: 'bar'},  // props
  [
    // choldre
  ]
)
1
2
3
4
5
6
7
8
9
// VNode.ts
import { h, defineComponent } from 'vue'

export default defineComponent({
  name: 'VNode',
  props: {
    msg: {
      type: String,
    }
  },
  setup(props) {
    return () => h('h1', { id: 'vnode' }, props.msg)
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 使用JSX/TSX语法创建vnode

// Collapse.test.tsx
const onChange = vi.fn()
const wrapper = mount(() => (
  <Collapse modelValue={['a']} onChange={onChange}>
    <Item name="a" title="Title A">
      content a
    </Item>

    <Item name="b" title='Title B'>
      content b
    </Item>

    <Item name="c" disabled title='Title c '>
      content c
    </Item>
  </Collapse>
))

const headers = wrapper.findAll('.ds-collapse-item__header')
const contents = wrapper.findAll('.ds-collapse-item__content__wrapper')
const firstHeader = headers[0]
const firstContent = contents[0]
await firstHeader.trigger('click')
expect(firstContent.isVisible()).toBeFalsy()
expect(onChange).toHaveBeenCalledWith([])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# Popperjs

官网: https://floating-ui.com/?utm_source=popper.js.org (opens new window)

# 安装及基础使用

npm i -S @popperjs/core
1
import { onMounted, ref } from 'vue'
import { createPopper, type Instance } from '@popperjs/core'

// tooltip
const overlayNode = ref<HTMLElement>()
// 触发元素
const triggerNode = ref<HTMLElement>()

let popperInstance: Instance | null = null

onMounted(()=>{
  if(overlayNode.value && triggerNode.value) {
    console.log('createPopper create')
    popperInstance = createPopper(triggerNode.value, overlayNode.value, { placement: 'bottom' })
  }

  setTimeout(()=>{
    // 重设属性
    popperInstance?.setOptions({placement: 'bottom'})
  }, 2000)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 动态绑定事件

正常在绑定事件的时候,我们使用@click来绑定,知道@click = v-on:click,所以可以使用v-on传递object作为参数,实现动态绑定事件

const events = reactive({'click': callback, 'mouseenter': callback});
<div v-on="events"></div>
1
2

# Hooks

# hooks 是什么

vue3 中的 hooks 就是函数的一种写法,就是将文件的一些单独功能的 js 代码进行抽离出来进行封装使用。

它的主要作用是 Vue3 借鉴了 React 的一种机制,用于在函数组件中共享状态逻辑和副作用,从而实现代码的可复用性。

注意:其实 hooks 和 vue2 中的 mixin 有点类似,但是相对 mixins 而言, hooks 更清楚复用功能代码的来源, 更清晰易懂。

# hooks 的优点

  • hooks 作为独立逻辑的组件封装,其内部的属性、函数等和外部组件具有响应式依附的作用。
  • 自定义 hook 的作用类似于 vue2 中的 mixin 技术,使用方便,易于上手。
  • 使用 Vue3 的组合 API 封装的可复用,高内聚低耦合。

# 自定义 hook 需要满足的规范

  1. 具备可复用功能,才需要抽离为 hooks 独立文件
  2. 函数名/文件名以 use 开头,形如: useXX
  3. 引用时将响应式变量或者方法显式解构暴露出来;

# 示例

这是一个全局点击事件钩子封装 其作用在于当在document上点击时,判断是否为目标元素以外的其他元素,如果是的话,触发callback。

import { onMounted, onUnmounted, type Ref } from "vue"

// 定义一个钩子函数,功能为点击一个给定元素以外的元素时,触发回调函数
// 接收两个参数,目标元素(ref类型)  回调函数
const useClickOutside = (elementRef: Ref<undefined | HTMLElement>, callback: (e: MouseEvent) => void) => {

  // 定义点击事件,接收参数event
  const handler = (e: MouseEvent) => {
    console.log('handler attach')
    // 如果目标元素存在,且点击目标元素存在
    if (elementRef.value && e.target) {

      // Node.contains() Node 接口的 contains() 方法返回一个布尔值,表示一个节点是否是给定节点的后代,即该节点本身、其直接子节点(childNodes)、子节点的直接子节点等。
      // 这里来判断被点击的元素是不是目标元素或其子元素
      if (!elementRef.value.contains(e.target as HTMLElement)) {

        // 如果不是则触发callback
        callback(e)
      }
    }
  }

  onMounted(() => {
    // 页面挂载时绑定handler事件
    document.addEventListener('click', handler)
  })

  onUnmounted(() => {
    // 页面卸载时移除handler事件
    document.removeEventListener('click', handler)
  })
}

export default useClickOutside

// components
import useClickOutside from '@/hooks/useClickOutside';

const popperContainerNode = ref<HTMLElement>()

useClickOutside(popperContainerNode, () => {
  if(props.trigger === 'click' && isOpen.value) {
    close()
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

# 高级类型 Partial

Partial 可以使一个类型上的所有key变为可选key,例如 引用第三方库popper时,我们希望可以在组件外部定义popper的参数,而popper的参数类型Options是需要必选的,此时就可以使用Partial使其可选

// Popper types.d.ts
...
export declare type Options = {
    placement: Placement;
    modifiers: Array<Partial<Modifier<any, any>>>;
    strategy: PositioningStrategy;
    onFirstUpdate?: (arg0: Partial<State>) => void;
};
// 由上可以看到都是必选项
...

// components type.ts
1
2
3
4
5
6
7
8
9
10
11
12
最后更新:: 3/12/2024, 3:17:51 PM