vue

Vue表单组件如何自动化输入

使用 Vue 的数据双向绑定功能可以很方便的完成表单的数据收集和提交,再也不用自己手动监听事件和收集数据了。但同时自动化输入也变得复杂起来。

# 传统表单自动化输入

对于传统的表单我们可以很简单的自动化输入过程,例如checkboxinput元素,我们可以自动选中和输入内容:

document.querySelector('.checkbox').checked = true;
document.querySelector('.input').value = true;
1
2

当用户提交的时候,程序会读取相应元素的值,然后填充到请求里。

# Vue 组件自动化输入

这里我们以现在流行的Element为例子演示如何自动化输入。用过 Vue 的人都知道, v-model是用来进行双向绑定的。它的原理是:

Vue.component('custom-input', {
  props: ['value'],
  template: `
    <input
      v-bind:value="value"
      v-on:input="$emit('input', $event.target.value)"
    >
  `
});
1
2
3
4
5
6
7
8
9

到这里,我们就知道了通过$emit('input', value)的方式,可以绑定想要输入的值,直接使用传统方式修改input元素的值,有时候不会触发input事件,所以不是一个可行的办法。

接下来,我们讲下如何选中元素并调用$emit,例子是 Vue 官网的搜索框

const input = document.querySelector('.el-input');
input.__vue__.$emit('input', 'Hello world');
1
2

console里输入这两行代码,你会发现搜索框里被填入了Hello World。Vue 实例被隐藏到了 DOM 中,只要知道如何访问 Vue 然好调用$emit,问题就迎刃而解。

# 总结

现在的前端框架用起来方便,但也隐藏了大量细节。导致做自动化的时候不知道如何下手,尝试修改元素的值,但发现提交的时候还是没有带上正确的数值。这时候就需要思考框架绑定数据的本质是什么, 弄清了本质,其实做起来发现更简单!

Vue下使用jest进行单元测试

在前端开发逐渐复杂的今天,当然也需要对前端进行测试。单元测试主要用于白盒测试,检测程序的最小单位的正确性,通常来说就是验证函数的输入和输出。

# vue 集成 jest

vue-cli 已经提供现成的插件,我们可以很方便的进行集成。官方文档也有讲述如何 debug。在使用 jest 之前,我们需要知道 jest 的测试代码不是像前端代码一样运行在浏览器环境,而是使用 node 运行,并且浏览器的 DOM 环境使用jsdom来模拟,所以在集成测试的时候需要解决不少问题。

# jest 与 webpack

当今的前端开发都离不开 webpack 进行打包,我们使用各种各样的 loader 来处理代码,所以可以使用例如import语法来引用 JS 文件,但是 node 环境目前还不支持原生的 es6 import文件。所以在使用 jest 的时候,我们需要用 babel 进行代码转换,好在@vue/cli-plugin-unit-jest 都已经给配置好了,使用预置的 preset 就行。我是基于 TS 开发,所以用的 preset 是 @vue/cli-plugin-unit-jest/presets/typescript-and-babel。但是我们在代码里使用的一些 webpack 的特殊语法就不太好对应了,例如我有使用inline loader来打包特殊的 SVG 文件到代码中,但是在测试的时候就会遇到无法 import 的尴尬问题。这时候的解决方案就是使用moduleNameMapper:

moduleNameMapper: {
    '^!svg-inline-loader!@(.*)$': require.resolve('jest-transform-stub')
}
1
2
3

通过把 import 的资源文件映射为空,就能解决 jest 运行出错的问题,毕竟我们在做单元测试的时候不会检测资源文件。

# jest mock vuex store

做单元测试的时候,mock 是非常常见的需求,例如我们在一个文件里引用了 store 的数据,但是要构造这个数据非常复杂,所以我们需要直接 mock 数据。这里需要注意的是,如果是 import 引入的话,我们需要先 mock 再使用动态引用的方式加载测试模块,保证是在 import 之前 mock 数据。

jest.doMock('@/store', () => ({
  state: {
    account: {
      hello: 'world'
    }
  }
}));
const { default: formatter } = await import('@/utils/formatter');
1
2
3
4
5
6
7
8

不过,mock 之后记得 reset:

beforeEach(() => {
  jest.resetModules();
});
1
2
3

# jest mock navigator

有些测试用例中,我们需要 mock 用户的浏览器是不同语言,这个时候就需要使用spyOn来修改属性的返回值。示例代码如下:

describe('test i18n', () => {
  let languageGetter: jest.SpyInstance;
  beforeEach(() => {
    jest.resetModules();
    languageGetter = jest.spyOn(window.navigator, 'language', 'get');
  });

  it('set lang is zh-CN', () => {
    languageGetter.mockReturnValue('zh-CN');
    console.log(navigator.language); // zh-CN
  });
});
1
2
3
4
5
6
7
8
9
10
11
12

# jest mock vue router

测试 Vue 组件主要依靠Vue Test Utils。官方文档也有介绍如何使用,这个仅给出一个例子来描述用法:

import { mount, createLocalVue } from '@vue/test-utils';
import VueRouter from 'vue-router';

import App from '@/App.vue';
import routes from '@/router/routes';
import HelloWorld from '@/views/HelloWorld.vue';

const localVue = createLocalVue();
localVue.use(VueRouter);

describe('Test index routes', () => {
  it('should find HelloWorld when router is HelloWorld', async () => {
    const router = new VueRouter({ routes, mode: 'history' });
    await router.push('/helloworld');

    const wrapper = mount(App, {
      localVue,
      router
    });

    expect(wrapper.find(HelloWorld).exists()).toBe(true);
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

这里使用 localVue 的原因是项目中只有一个 Vue 实例,直接使用的话会污染原本的 Vue,所以要在测试代码里手动创建router。这里要注意的是,如果 find 是某个组件,需要在 Vue 组件里面显示声明name,否则在 Lazy Loading 的情况下是无法找到组件的。还有一个指得注意的是router.push 是一个 Promise 异步操作,所以如果写成同步的话,会一直找不到组件的。

# 总结

jest 集成了很多测试的工具,只装一个工具便可进行单元测试。但是由于不是运行在浏览器环境,导致jsdom不支持的特性都需要自己来处理。所以希望选择一个在浏览器运行单元测试的框架应该会少遇到很多坑吧。 特别是用了很多浏览器特性的前端项目,测试起来更加折腾。

# 引用

Using with webpack

Test Vue Router

Vue中如何设计面包屑

最近做的项目层级结构比较深,需要使用面包屑来定位用户所在的位置,方便用户跳转层级。所以就遇到 web app 如何设计面包屑问题。

# 根据路由匹配设置面包屑

当路由是下面这种情况的时候:

let routes = [
  {
    path: '/users',
    name: 'users-index',
    meta: {
      text: 'Users'
    },
    children: [
      {
        path: 'settings',
        name: 'user-setting'
      },
      // ...
    ]
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

我们可以使用$route.matched属性来生成面包屑。示例代码如下:

<ul>
  <li v-for="route in $route.matched">
    <router-link :to="{name: route.name}">
      {{ route.meta.text }}
    </router-link>
  </li>
</ul>
1
2
3
4
5
6
7

这种方法只适用于解决静态面包屑,当我们需要处理例如路径是users/1/settings这种情况,显然路由匹配就不是最佳选择了。并且路由层级的加深也并不意味着页面嵌套的加深。 当资源层级很深的时候,我们需要做很多空的父节点来处理。

# 每个页面设置面包屑

let routes = [
  {
    path: '/users/userId(\\d+)',
    component: userLayout
    children: [
      {
        path: 'settings',
        name: 'user-setting',
        component: userSettings
      },
      {
        path: '',
        name: 'user-dashboard'
      }
      // ...
    ]
  }
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

当 URL 路径是users/1/settings的时候,我们期望的面包屑应该是Users > Lee > Settings。但是我们要怎么达到这个效果呢,特别是姓名信息我们还需要异步从服务器获取。

解决办法是使用Vuex来存储面包屑的信息。有使用数组来存储面包屑的方案,但是需要注意面包屑加入的顺序问题。为了更灵活的控制面包屑,我选择了使用链表的方式来存储面包屑。

# 定义数据结构

下面来看看面包屑的结构。

interface BreadCrumb {
  key: symbol;
  text?: string;
  to?: Route;
}

interface BreadCrumbItem {
  prev: BreadCrumbList;
  breadCrumb: BreadCrumb;
  next: BreadCrumbList;
}
type BreadCrumbList = BreadCrumbItem | null;
1
2
3
4
5
6
7
8
9
10
11
12

使用双向链表可以更方便的查询节点,每个节点主要存储三个信息,key 主要用来标识这个节点的唯一性,所以是Symbol类型,text 是当前节点展示的文本,to 是配合 vue-router 跳转到其他页面。 prev 和 next 当然就是指向上一个节点或者下一个节点啦。

# 定义 state 和 mutation

const state = {
  breadCrumbList: null,
  breadCrumbTail: null
};
1
2
3
4

state 里存储的分别是面包屑的头指针和尾指针。这样方便使用。

const mutations = {
  set(state, breadCrumbItem) {
    state.breadCrumbList = breadCrumbItem;
    state.breadCrumbTail = state.breadCrumbList;
  },
  add(state, breadCrumbItem) {
    if (state.breadCrumbTail) {
      state.breadCrumbTail.next = breadCrumbItem;
      breadCrumbItem.prev = state.breadCrumbTail;
      state.breadCrumbTail = breadCrumbItem;
    }
  },
  replace(state, breadCrumbItem) {
    let point = state.breadCrumbList;
    while (point) {
      if (point.breadCrumb.key === breadCrumbItem.breadCrumb.key) {
        point.breadCrumb.text = breadCrumbItem.breadCrumb.text;
        point.breadCrumb.to = breadCrumbItem.breadCrumb.to;
        return;
      } else {
        point = point.next;
      }
    }
  },
  remove(state, breadCrumbItem) {
    let point = state.breadCrumbTail;
    while (point) {
      if (point.breadCrumb === breadCrumbItem.breadCrumb) {
        let before = point.prev;
        let after = point.next;
        if (before) {
          before.next = after;
        } else {
          state.breadCrumbList = after;
        }
        if (after) {
          after.prev = before;
        } else {
          point.next = null;
        }
        return;
      } else {
        point = point.prev;
      }
    }
  },
  empty(state, breadCrumbItem) {
    state.breadCrumbList = null;
    state.breadCrumbTail = null;
  }
};
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
46
47
48
49
50
51

面包屑提供的方法有,set, add, replace, remove, empty。 功能都顾名思义。

# 每个组件设置面包屑

根据上面的路由设计,我们需要在userLayout组件里面完成Users > Lee前两级面包屑的填充。然后在userSettings组件里完成最后一级面包屑的填充。 由于第二级面包屑需要异步请求数据去填充,所以我们需要先事先添加一个空面包屑,之后再 replace。

// userLayout

mounted() {
  const breadCrumbIndex = {
    prev: null,
    breadCrumb: {
      key: Symbol('users'),
      text: 'Users',
      to: {
        name: 'user-index'
      }
    },
    next: null
  }
  const breadCrumbItem = {
    prev: null,
    breadCrumb: {
      key: Symbol('users')
    },
    next: null
  };
  store.commit('set', breadCrumbIndex)
  store.commit('add', breadCrumbItem);
  axios.get(`/api/user/${userId}`).then(res => {
    store.commit('replace', {
      ...breadCrumbItem,
      breadCrumb: {
        ...breadCrumbItem.breadCrumb,
        text: res.data.name,
        to: {
          name: 'user-dashboard'
        }
      }
    })
  })
  this.$once('hook:beforeDestroy', () => {
    store.commit('remove', breadCrumbIndex);
    store.commit('remove', breadCrumbItem);
  })
}

// userSettings

mounted() {
  const breadCrumbItem = {
    prev: null,
    breadCrumb: {
      key: Symbol('users'),
      name: 'Settings',
      to: this.$router.currentRoute
    },
    next: null
  };
  store.commit('add', breadCrumbItem);
  this.$once('hook:beforeDestroy', () => {
    store.commit('remove', breadCrumbItem);
  })
}
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
46
47
48
49
50
51
52
53
54
55
56
57
58

# 组件切换生命周期的执行顺序

当我们加载 userDashboard 并切换到 userSettings 页面的时候,组件执行生命周期执行顺序是。

beforeCreate userDashboard
created userDashboard
beforMount userDashboard
mounted userDashboard  // userDashboard show
beforeCreate userSettings // route to userSettings
created userSettings
beforMount userSettings
beforeDestory userDashboard
destroyed userDashboard
mounted userSettings
1
2
3
4
5
6
7
8
9
10

我们发现组件在beforeDestory后面之前的是mounted。所以如果把添加面包屑的代码放在beforeMount里的时候,其实是先添加面包屑再删除之前的面包屑的。不过好在我们用的是链表,所以顺序不影响面包屑的生成。

# 生成面包屑

breadCrumb() {
  const breadcrumb = [];
  let point = store.state.breadCrumbList;
  while (point && point.breadCrumb.name) {
    breadcrumb.push(point.breadCrumb);
    point = point.next;
  }

  return breadcrumb;
}
1
2
3
4
5
6
7
8
9
10

把面包屑的链表转换成数组,然后使用 vue 的v-for来渲染就可以了。

# 总结

面包屑的这两种实现方式我都有尝试过,个人感觉针对动态面包屑第二种方式比较优雅。如果你有更好的实现方式,欢迎讨论。

参考:

Handling breadcrumbs with VueX in a VueJS Single Page Application