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 (opens new window)

如何封装第三方vue组件

终于算是正式接触 SPA 的前端工作了,最近在解决很多 Vue 组件的问题,其中就有一个针对第三方组件的定制化需求,花了不少时间来折腾。

# 解决 v-model 绑定

这次封装的就是 element 的前端 vue 框架,因为框架本身对 iOS 兼容性不好,所以需要二次封装。封装的是一个 select 组件。所以需要数据的双向绑定,官方教程也已经解释了。 v-model (opens new window) 本质上就是绑定一个值和监听相应的事件。 这次本质是封装的一个 input 元素,所以我们需要手动绑定 value 和监听事件。

<template>
  <el-select v-bind:value="value" v-on="$listeners"></el-select>
</template>
1
2
3

官方文档也解释了这个内置变量的用法vm-listeners (opens new window)

# 继承父元素的属性

父元素的属性继承可以使用v-bind="$attrs"来完成。所以完成组建的透明封装只需要加上三个指令。

<template>
  <el-select v-bind="$attrs" v-bind:value="value" v-on="$listeners">
  </el-select>
</template>
1
2
3
4

虽然知道答案之后很简单,但是探索的过程中还是比较花时间的。希望能帮助大家解决这个问题,如果有什么疏漏之处,也请大家指正。