Procházet zdrojové kódy

添加RouteerTab组件

zhaihaoyi před 6 roky
rodič
revize
5f80ecba8f

+ 11 - 0
.npmignore

@@ -0,0 +1,11 @@
+# 忽略目录
+node_modules
+src/
+public/
+
+# 忽略指定文件
+babel.config.js
+tsconfig.json
+tslint.json
+vue.config.js
+*.map

+ 304 - 14
README.md

@@ -1,29 +1,319 @@
-# vue-router-tab-js
+# Vue Router Tab
 
-## Project setup
-```
-yarn install
+Vue Router Tab 是基于 Vue Router 的路由页签组件。
+
+> 由于 Vue 内置的 `<keep-alive>` 只能根据组件的 `name` 来缓存页面,难以实现同一个组件对应多个页签的缓存,本组件定制了 `<router-alive>` 缓存组件
+
+## 功能
+
+- [x] 根据路由变化新增或切换页签,不同的页签缓存独立的页面
+- [x] 页签关闭和刷新,支持右键操作菜单批量操作
+- [x] [全局](#alive-key)和[针对特定路由](#meta.aliveKey)的页签缓存规则配置
+- [x] [配置初始展示的页签](#tabs),页签可设置为不可关闭
+- [x] [页签和页面过渡效果配置 (已内置过渡效果)](#tab-transition)
+- [x] [自定义页签模板](#自定义页签模板)
+- [x] [动态更新页签信息 (标题/图标/提示)](#动态更新页签信息)
+- [x] [路由页面离开 (页签关闭/刷新/替换) 前确认](#路由页面离开前确认)
+- [ ] 国际化
+
+---
+
+## 安装
+
+### NPM
+
+``` bash
+npm install @bihaiyouhong12/vue-router-tab -S
 ```
 
-### Compiles and hot-reloads for development
+## 引入
+
+ES6:
+
+``` javascript
+// router-tab 组件依赖 vue 和 vue-router
+import Vue from 'vue'
+import Router from 'vue-router'
+
+// 引入组件和样式
+import RouterTab from '@bihaiyouhong12/vue-router-tab'
+import 'vue-router-tab/dist/lib/vue-router-tab.css'
+
+Vue.use(RouterTab)
 ```
-yarn run serve
+
+Template:
+
+``` html
+<template>
+  ...
+  <router-tab></router-tab>
+  ...
+</template>
 ```
 
-### Compiles and minifies for production
+---
+
+## `<router-tab>` props 配置选项
+
+### `alive-key`
+
+页面组件缓存的键
+
+- 类型: `string | Function`
+
+  - 如果类型为 `string` ,则取 `$route[aliveKey]` 的值
+
+  - 如果类型为 `Function` ,则取 `aliveKey($route)` 返回的字符串。该函数不应返回随机变化的字符串,以免页签无法与缓存的页面对应
+
+- 默认值: `'path'`
+  
+  根据 `$route.path` 来缓存页面组件。
+
+  - 同一路由-不同 `$route.params` 的页面,各自打开独立的页签,单独缓存
+
+  - 同一路由-相同 `$route.params` -不同 `$route.query` 的页面,共用同一个页签,后打开的页面将会替换之前页签内的页面,并且旧的页面缓存也被清除
+
+  - 仅仅 `$route.hash` 不同的页面,共用同一页签和缓存
+
+``` html
+<!-- 取 $route.fullPath -->
+<router-tab alive-key="fullPath"></router-tab>
+
+<!-- 函数方式 -->
+<router-tab :alive-key="route => route.fullPath + '1'"></router-tab>
 ```
-yarn run build
+
+
+### `tabs`
+
+**初始页签数据**,页面打开时默认显示的页签。相同 `aliveKey` 的页签只保留第一个
+
+- 类型: `Array <string | Object>`
+  
+  - tabs子元素类型为 `string` 时,应配置为要打开页面的 `fullPath` ,页签的标题/图片/提示等信息会从对应页面的 `router` 配置中获取
+
+  - tabs子元素类型为 `Object` 时:
+    
+    - to: 页签路由地址,跟 `router.push` 的 `location` 参数一致,可以为 `fullPath`,也可以为 `location` 对象 - [参考文档](https://router.vuejs.org/zh/guide/essentials/navigation.html#router-push-location-oncomplete-onabort)
+    
+    - title: 页签标题,如果页面有设置 `routerTab.title` 动态标题,可在此设置最终的动态标题值,以免与默认从 `router` 获取的标题不一致
+    
+    - closable: 页签是否允许关闭,默认为 `true`
+
+- 默认值: `[]`
+
+``` html
+<!-- 默认页签 -->
+<router-tab :tabs="[
+  '/page1',
+  { to: '/page/2', title: '页面2' },
+  { to: '/page/3', closable: false },
+  { to: {
+    name: 'page',
+    params: { id: 4 },
+    query: { t: 2 }
+  }},
+  { to: '/page/2?t=1', title: '页面2-1' }
+]"></router-tab>
+<!-- '/page/2'与'/page/2?t=1'两个路由的aliveKey一致,将只保留前一个页签 -->
 ```
 
-### Run your tests
+### `router-view`
+
+**Vue Router Tab 内置 `<router-view>` 组件的配置**
+
+- 类型: `Object`
+  
+  > 配置参考 [Vue Router 文档 - `<router-view>` Props](https://router.vuejs.org/zh/api/#router-view-props)
+
+- 默认值: `{}`
+
+
+
+### `tab-transition`
+
+**页签过渡效果**,新增和关闭页签时的过渡
+
+- 类型: `string | Object`
+
+  - 类型为 `string` 时,应配置为 `transition.name`
+
+  - 类型为 `Object` 时,配置参考 [Vue文档 - transition](https://cn.vuejs.org/v2/api/#transition)
+
+- 默认值: `'router-tab-zoom-lb'`
+
+
+``` html
+<!-- 直接配置过渡名称 -->
+<router-tab tab-transition="my-transition"></router-tab>
+
+<!-- 过渡详细配置 -->
+<router-tab :tab-transition="{ name: 'my-transition', 'enter-class': 'my-transition-enter' }"></router-tab>
 ```
-yarn run test
+
+
+### `page-transition`
+
+**页面过渡效果**
+
+- 类型: `string | Object`
+  
+  同 [`tab-transition`](#tab-transition)
+
+- 默认值: `{
+  name: 'router-tab-swap',
+  mode: 'out-in'
+}`
+
+
+### 自定义页签模板
+
+``` html
+<router-tab>
+  <template slot-scope="{ tab: { id, title, icon, closable }, tabs, index}">
+      <i v-if="icon" class="tab-icon" :class="icon"></i>
+      {{index}}
+      <span class="tab-title">{{title || '新页签'}}</span>
+      <i class="tab-close el-icon-close" v-if="closable !== false && tabs.length > 1" @click.prevent="close(id)"></i>
+  </template>
+</router-tab>
 ```
 
-### Lints and fixes files
+## `<router-tab>` data 实例数据
+
+**注意**:在 Vue 实例内部,你可以通过 `$routerTab` 访问路由页签实例。因此你可以调用 `this.$routerTab.close()`。
+
+### `routerTab.activedTab`
+
+当前激活的页签id
+
+
+
+## `<router-tab>` methods 实例方法
+
+
+### `routerTab.close(id?)`
+
+**关闭指定页签**
+
+`id` 是页签id,跟页签对应页面的缓存键 `aliveKey` 保持一致。如果未提供 `id`,则默认关闭当前激活的页签
+
+
+### `routerTab.refresh(id?)`
+
+**刷新指定页签**
+
+如果未提供 `id`,则默认刷新当前激活的页签
+
+### `routerTab.refreshAll(force?= false)`
+
+**刷新所有页签**
+
+如果 `force` 为 `true`,则忽略页面组件的 `beforePageLeave` 配置
+
+---
+
+## 路由配置
+
+### `meta.title`
+
+**页签标题**
+
+- 类型: `string`
+- 默认值: `'新页签'`
+
+
+### `meta.icon`
+
+**页签图标**
+
+- 类型: `string`
+
+
+### `meta.tips`
+
+**页签提示**
+
+- 类型: `string`
+- 默认值: 默认和页签标题 `meta.title` 保持一致
+
+
+### `meta.aliveKey`
+
+页面组件缓存的键,用以设置路由独立的页签缓存规则。
+
+> 配置参考: [`<router-tab>` props 配置选项 => `alive-key`](#alive-key)
+
+``` javascript
+import Page1 from './views/Page1'
+import Page2 from './views/Page2'
+
+export default {
+  routes: [{
+    path: '/page1',
+    component: Page1,
+    meta: {
+      title: '页面1', // 页签标题
+      icon: 'el-icon-picture', // 页签图标
+      tips: '这是页面1' // 页签提示
+    }
+  }, {
+    path: '/page2/:id',
+    component: Page2,
+    meta: {
+      title: '页面2', // 页签标题
+      icon: 'el-icon-document', // 页签图标
+      aliveKey: 'fullPath' // 缓存key
+    }
+  }]
+}
 ```
-yarn run lint
+
+---
+
+## 页面组件
+
+### 动态更新页签信息
+
+``` javascript
+export default {
+  name: 'page',
+  mounted () {
+    setTimeout(() => {
+      let { id } = this.$route.params
+
+      // 只更新页签标题
+      this.routerTab = `页面${id}动态标题`
+
+      // 更新其他页签信息
+      this.routerTab = {
+        title: `页面${id}动态标题`,
+        icon: 'el-icon-document',
+        tips: `页面${id}动态提示`
+      }
+    }, 300)
+  }
+}
 ```
 
-### Customize configuration
-See [Configuration Reference](https://cli.vuejs.org/config/).
+### 路由页面离开前确认
+
+``` javascript
+export default {
+  name: 'page',
+
+  // 路由页面离开前确认
+  beforePageLeave (resolve, reject, tab, type) {
+    let action = (type === 'close' && '关闭') ||
+      (type === 'refresh' && '刷新') ||
+      (type === 'replace' && '替换')
+
+    // 此处使用了 Element 的 confirm 组件
+    // 需将 closeOnHashChange 配置为 false,以避免路由切换导致确认框关闭
+    this.$confirm(`您确认要${action}页签“${tab.title}”吗?`, '提示', { closeOnHashChange: false })
+      .then(resolve)
+      .catch(reject)
+  }
+}
+```

+ 17 - 5
package.json

@@ -1,11 +1,23 @@
 {
-  "name": "vue-router-tab-js",
+  "name": "vue-router-tab",
   "version": "0.1.0",
-  "private": true,
+  "description": "基于 Vue 和 Vue Router 的 Tab 页签组件",
+  "keyword": [
+    "vue",
+    "router",
+    "tab"
+  ],
+  "author": "碧海幽虹",
+  "private": false,
+  "license": "MIT",
+  "main": "dist/lib/vue-router-tab.common.js",
   "scripts": {
-    "serve": "vue-cli-service serve",
-    "build": "vue-cli-service build",
-    "lint": "vue-cli-service lint"
+    "demo:serve": "vue-cli-service serve",
+    "demo:build": "vue-cli-service build --dest dist/demo",
+    "lib:build": "vue-cli-service build --target lib --dest dist/lib ./src/lib/RouterTab/index.js",
+    "lib:build:report": "vue-cli-service build --report --target lib --dest dist/lib ./src/lib/RouterTab/index.js",
+    "lint": "vue-cli-service lint",
+    "lint:fix": "vue-cli-service lint --fix"
   },
   "dependencies": {
     "vue": "^2.5.22",

+ 2 - 2
public/index.html

@@ -5,11 +5,11 @@
     <meta http-equiv="X-UA-Compatible" content="IE=edge">
     <meta name="viewport" content="width=device-width,initial-scale=1.0">
     <link rel="icon" href="<%= BASE_URL %>favicon.ico">
-    <title>vue-router-tab-js</title>
+    <title>vue-router-tab</title>
   </head>
   <body>
     <noscript>
-      <strong>We're sorry but vue-router-tab-js doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+      <strong>We're sorry but vue-router-tab doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
     </noscript>
     <div id="app"></div>
     <!-- built files will be auto injected -->

+ 1 - 27
src/App.vue

@@ -1,29 +1,3 @@
 <template>
-  <div id="app">
-    <div id="nav">
-      <router-link to="/">Home</router-link> |
-      <router-link to="/about">About</router-link>
-    </div>
-    <router-view/>
-  </div>
+  <router-view/>
 </template>
-
-<style lang="scss">
-#app {
-  font-family: 'Avenir', Helvetica, Arial, sans-serif;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-  text-align: center;
-  color: #2c3e50;
-}
-#nav {
-  padding: 30px;
-  a {
-    font-weight: bold;
-    color: #2c3e50;
-    &.router-link-exact-active {
-      color: #42b983;
-    }
-  }
-}
-</style>

binární
src/assets/logo.png


+ 0 - 58
src/components/HelloWorld.vue

@@ -1,58 +0,0 @@
-<template>
-  <div class="hello">
-    <h1>{{ msg }}</h1>
-    <p>
-      For a guide and recipes on how to configure / customize this project,<br>
-      check out the
-      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
-    </p>
-    <h3>Installed CLI Plugins</h3>
-    <ul>
-      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
-      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
-    </ul>
-    <h3>Essential Links</h3>
-    <ul>
-      <li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
-      <li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
-      <li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
-      <li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
-      <li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
-    </ul>
-    <h3>Ecosystem</h3>
-    <ul>
-      <li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
-      <li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
-      <li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
-      <li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
-      <li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
-    </ul>
-  </div>
-</template>
-
-<script>
-export default {
-  name: 'HelloWorld',
-  props: {
-    msg: String
-  }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-h3 {
-  margin: 40px 0 0;
-}
-ul {
-  list-style-type: none;
-  padding: 0;
-}
-li {
-  display: inline-block;
-  margin: 0 10px;
-}
-a {
-  color: #42b983;
-}
-</style>

+ 167 - 0
src/lib/RouterTab/components/RouterAlive.js

@@ -0,0 +1,167 @@
+// 空对象和数组
+export const emptyObj = Object.create(null)
+export const emptyArray = []
+
+function isDef (v) {
+  return v !== undefined && v !== null
+}
+
+function getFirstComponentChild (children) {
+  if (Array.isArray(children)) {
+    for (let i = 0; i < children.length; i++) {
+      const c = children[i]
+      if (
+        isDef(c) &&
+        (isDef(c.componentOptions) || isAsyncPlaceholder(c))
+      ) {
+        return c
+      }
+    }
+  }
+}
+
+function isAsyncPlaceholder (node) {
+  return node.isComment && node.asyncFactory
+}
+
+// 获取路由不带hash的路径
+const getPathWithoutHash = route => route.hash
+  ? route.fullPath.replace(route.hash, '')
+  : route.fullPath
+
+// 是否相似路由
+export const isAlikeRoute = function isAlikeRoute (route1, route2) {
+  return getPathWithoutHash(route1) === getPathWithoutHash(route2)
+}
+
+// 获取路由页面组件
+const getRouteComponent = ({ matched }) => matched[matched.length - 1].components.default
+
+// 路由是否共用组件
+function isSameComponentRoute (route1, route2) {
+  return getRouteComponent(route1) === getRouteComponent(route2)
+}
+
+export default {
+  name: 'router-alive',
+  props: {
+    // 缓存key,如果为函数,则参数为route
+    aliveKey: {
+      type: [ String, Function ],
+      default: 'path'
+    }
+  },
+
+  beforeCreate () {
+    Object.assign(this, {
+      cache: Object.create(null),
+      lastRoute: this.$route
+    })
+  },
+
+  render () {
+    const slot = this.$slots.default
+    const vnode = getFirstComponentChild(slot)
+    const vmOpts = vnode && vnode.componentOptions
+
+    if (vmOpts) {
+      const { cache, $route, lastRoute } = this
+
+      // 如果是transition组件,页面组件则为子元素
+      const pageNode = vmOpts.tag === 'transition' ? vmOpts.children[0] : vnode
+
+      if (pageNode && pageNode.componentOptions) {
+        // 获取缓存
+        const key = this.getAliveKey()
+        const cacheItem = cache[key]
+        const { vm: cacheVm, route: cacheRoute } = cacheItem || emptyObj
+
+        // 是否需要重载路由强制刷新页面组件
+        let needReloadRouter = false
+
+        // 路由是否改变
+        let isRouteChange = lastRoute !== $route
+
+        // 是否跟上次路由共用组件
+        let isSameComponent = isRouteChange && isSameComponentRoute($route, lastRoute)
+
+        if (isRouteChange) {
+          // 更新上次路由
+          this.lastRoute = $route
+
+          // 添加缓存
+          if (!cacheItem) this.set(key, { route: $route })
+        }
+
+        if (cacheVm) {
+          // 缓存组件的路由地址除hash外一致则取缓存的组件
+          if (isAlikeRoute(cacheRoute, $route)) {
+            pageNode.componentInstance = cacheVm
+          } else {
+            // 缓存组件路由地址不匹配则销毁缓存并重载路由
+            cacheVm.$destroy()
+            cacheItem.vm = null
+            needReloadRouter = true
+          }
+        }
+
+        // 路由改变后但组件相同需重载路由
+        if (isSameComponent) needReloadRouter = true
+
+        // 重载路由以强制更新页面
+        needReloadRouter && this.$routerTab.reloadRouter()
+
+        // 标记为keepAlive和routerAlive
+        pageNode.data.keepAlive = true
+        pageNode.data.routerAlive = this
+      }
+    }
+
+    return vnode || (slot && slot[0])
+  },
+
+  methods: {
+    // 获取缓存key
+    getAliveKey (route = this.$route) {
+      let aliveKey = (route.meta && route.meta.aliveKey) || this.aliveKey || 'path'
+      if (typeof aliveKey === 'function') {
+        return aliveKey.bind(this)(route)
+      }
+      return route[aliveKey]
+    },
+
+    // 设置缓存项
+    set (key, item) {
+      const { cache } = this
+
+      this.$emit('update', key, item)
+
+      // 更新缓存数据
+      return (cache[key] = item)
+    },
+
+    // 删除缓存项
+    remove (key) {
+      const { cache } = this
+      const item = cache[key]
+
+      // 销毁组件实例
+      if (item) {
+        item.vm && item.vm.$destroy()
+        delete cache[key]
+      }
+
+      this.$emit('remove', [ key ])
+    },
+
+    // 清理缓存
+    clear (key) {
+      const item = this.cache[key]
+      const vm = item && item.vm
+      if (vm) {
+        vm.$destroy()
+        item.vm = null
+      }
+    }
+  }
+}

+ 390 - 0
src/lib/RouterTab/components/RouterTab.js

@@ -0,0 +1,390 @@
+import Vue from 'vue'
+import RouterAlive, { isAlikeRoute, emptyObj, emptyArray } from './RouterAlive'
+
+// 滚动
+function scrollTo ($el, left = 0, top = 0) {
+  if ($el.scrollTo) {
+    $el.scrollTo({ left, top, behavior: 'smooth' })
+  } else {
+    $el.scrollLeft = left
+    $el.scrollTop = top
+  }
+}
+
+// 防抖
+function debounce (fn, delay = 200) {
+  let timeout = null
+  return function () {
+    let context = this
+    let args = arguments
+    clearTimeout(timeout)
+    timeout = setTimeout(() => {
+      fn.call(context, args)
+    }, delay)
+  }
+}
+
+export default {
+  name: 'router-tab',
+  components: { RouterAlive },
+  props: {
+    // 缓存key,如果为函数,则参数为route
+    aliveKey: RouterAlive.props.aliveKey,
+
+    // 初始页签数据
+    tabs: {
+      type: Array,
+      default: () => []
+    },
+
+    // router-view组件配置
+    routerView: Object,
+
+    // 页签过渡效果
+    tabTransition: {
+      type: [ String, Object ],
+      default: 'router-tab-zoom-lb'
+    },
+
+    // 页面过渡效果
+    pageTransition: {
+      type: [ String, Object ],
+      default: () => ({
+        name: 'router-tab-swap',
+        mode: 'out-in'
+      })
+    }
+  },
+  data () {
+    return {
+      loading: false, // 路由页面loading
+      items: [], // 页签项
+      activedTab: null, // 当前激活的页签
+      isRouterAlive: true,
+      // 右键菜单
+      contextmenu: {
+        id: null,
+        index: -1,
+        left: 0,
+        top: 0
+      }
+    }
+  },
+
+  computed: {
+    // 右键菜单是否当前页签
+    isContextTabActived () {
+      return this.contextmenu.id === this.activedTab
+    },
+
+    // 右键页签是否允许关闭
+    isContextTabCanBeClosed () {
+      let { items, contextmenu } = this
+      let contextTab = items[contextmenu.index]
+      return items.length > 1 && contextTab && contextTab.closable !== false
+    },
+
+    // 左侧可关闭的页签
+    tabsLeft () {
+      let { items, contextmenu: { id, index } } = this
+      return id ? items.slice(0, index).filter(({ closable }) => closable !== false) : emptyArray
+    },
+
+    // 左侧可关闭的页签
+    tabsRight () {
+      let { items, contextmenu: { id, index } } = this
+      return id ? items.slice(index + 1).filter(({ closable }) => closable !== false) : emptyArray
+    },
+
+    // 其他可关闭的页签
+    tabsOther () {
+      let { items, contextmenu: { id } } = this
+      return id ? items.filter(({ closable, id: tid }) => closable !== false && id !== tid) : emptyArray
+    }
+  },
+
+  watch: {
+    // 路由切换更新激活的页签
+    $route ($route) {
+      this.loading = false
+      this.hideContextmenu()
+      this.updateActivedTab()
+    },
+
+    activedTab () {
+      // 激活页签时,如果当前页签不在可视区域,则滚动显示页签
+      this.$nextTick(() => {
+        let $cur = this.$el.querySelector('.router-tab-nav .actived')
+        let $scr = this.$el.querySelector('.router-tab-scroll')
+        if ($cur) {
+          let cLeft = $cur.offsetLeft
+          let sLeft = $scr.scrollLeft
+          if (cLeft < sLeft || cLeft + $cur.clientWidth > sLeft + $scr.clientWidth) {
+            this.adjust()
+          }
+        }
+      })
+    },
+
+    // 监听右键菜单显示关闭
+    'contextmenu.id' (val, old) {
+      if (!old && val) {
+        // 显示右键菜单,绑定点击关闭事件
+        document.addEventListener('click', this.onClick = (e) => {
+          if (e.target !== this.$el.querySelector('.router-tab-contextmenu')) {
+            this.hideContextmenu()
+          }
+        })
+      } else if (old && !val) {
+        // 隐藏右键菜单,移除点击关闭事件
+        document.removeEventListener('click', this.onClick)
+      }
+    }
+  },
+
+  beforeCreate () {
+    // 添加到原型链
+    Vue.prototype.$routerTab = this
+  },
+
+  created () {
+    this.getTabItems()
+    this.updateActivedTab()
+
+    this.$router.beforeEach(this.routerPageLeaveGuard)
+  },
+
+  mounted () {
+    // 页面载入和浏览器窗口大小改变时调整Tab滚动显示
+    window.addEventListener('resize', this.onResize = debounce(this.adjust))
+  },
+
+  destroyed () {
+    // 销毁后移除监听事件
+    window.removeEventListener('resize', this.onResize)
+  },
+
+  methods: {
+    getAliveKey: RouterAlive.methods.getAliveKey,
+
+    // 页面离开导航守卫
+    routerPageLeaveGuard (to, from, next) {
+      if (this._isDestroyed) {
+        let hooks = this.$router.beforeHooks
+        let idx = hooks.indexOf(this.routerPageLeaveGuard)
+
+        // 移除已销毁的RouterTab实例注册的导航守卫
+        if (idx > -1) hooks.splice(idx, 1)
+
+        next()
+      } else {
+        const id = this.getAliveKey(to)
+        const $alive = this.$refs.routerAlive
+        const { route: cacheRoute } = ($alive && $alive.cache[id]) || emptyObj
+
+        // 如果不是相同路由则检查beforePageLeave
+        if (cacheRoute && !isAlikeRoute(to, cacheRoute)) {
+          this.pageLeavePromise(id, 'replace')
+            .then(() => next())
+            .catch(() => next(false))
+        } else {
+          next()
+        }
+      }
+    },
+
+    // 根据初始页签数据生成页签列表
+    getTabItems () {
+      let { tabs, $router } = this
+      let ids = {}
+
+      this.items = tabs.map((item, index) => {
+        let { to, closable } = typeof item === 'string'
+          ? { to: item }
+          : (item || emptyObj)
+        let route = to && $router.match(to)
+
+        if (route) {
+          let tab = this.getRouteTab(route)
+          let id = tab.id
+
+          // 根据id去重
+          if (!ids[id]) {
+            return (ids[id] = Object.assign(tab, { closable: closable !== false }))
+          }
+        }
+      }).filter(item => !!item)
+    },
+
+    // 更新激活的页签
+    updateActivedTab () {
+      this.activedTab = this.getAliveKey()
+    },
+
+    // 更新tab数据
+    updateTab (key, { route, tab }) {
+      let { items, getRouteTab } = this
+      let matchIdx = items.findIndex(({ id }) => id === key)
+
+      let item = Object.assign(getRouteTab(route), tab)
+
+      if (matchIdx > -1) {
+        let matchTab = items[matchIdx]
+        item.closable = matchTab.closable !== false
+        this.$set(items, matchIdx, item)
+      } else {
+        items.push(item)
+      }
+    },
+
+    // 从route中获取tab数据
+    getRouteTab (route) {
+      let id = this.getAliveKey(route)
+      let { fullPath: to, meta } = route
+      let { title, icon, tips } = meta
+
+      return { id, to, title, icon, tips }
+    },
+
+    // 页面离开Promise
+    pageLeavePromise (id, type) {
+      return new Promise((resolve, reject) => {
+        let $alive = this.$refs.routerAlive
+        let tab = this.items.find(item => item.id === id) // 当前页签
+        let { vm } = $alive.cache[id] || emptyObj // 缓存数据
+        let beforePageLeave = vm && vm.$vnode.componentOptions.Ctor.options.beforePageLeave
+
+        if (typeof beforePageLeave === 'function') {
+          // 页签关闭前
+          beforePageLeave.bind(vm)(resolve, reject, tab, type)
+        } else {
+          resolve()
+        }
+      })
+    },
+
+    // 关闭tab项
+    closeTabItem (id) {
+      let { items } = this
+      let $alive = this.$refs.routerAlive
+      const idx = items.findIndex(item => item.id === id)
+
+      return this.pageLeavePromise(id, 'close').then(function () {
+        // 承诺关闭后移除页签和缓存
+        $alive.remove(id)
+        idx > -1 && items.splice(idx, 1)
+      }).catch(e => {})
+    },
+
+    // 关闭页签
+    async close (id = this.activedTab) {
+      let { activedTab, items, $router } = this
+      const idx = items.findIndex(item => item.id === id)
+
+      await this.closeTabItem(id)
+
+      // 如果关闭当前页签,则打开后一个页签
+      if (activedTab === id) {
+        let nextTab = items[idx] || items[idx - 1]
+        $router.replace(nextTab.to)
+      }
+    },
+
+    // 关闭多个页签
+    async closeMulti (tabs) {
+      let { items, $router, contextmenu, closeTabItem } = this
+      let nextTab = items.find(({ id }) => id === contextmenu.id)
+
+      for (let { id } of tabs) {
+        try {
+          await closeTabItem(id)
+        } catch (e) {}
+      }
+
+      // 当前页签如已关闭,则打开右键选中页签
+      if (items.findIndex(({ id }) => id === this.activedTab) === -1) {
+        $router.replace(nextTab.to)
+      }
+    },
+
+    // 刷新指定页签
+    async refresh (id = this.activedTab) {
+      try {
+        await this.pageLeavePromise(id, 'refresh')
+        this.$refs.routerAlive.clear(id)
+        this.reloadRouter()
+      } catch (e) {}
+    },
+
+    /**
+     * 刷新所有页签
+     * @param {boolean} [force=false] 是否强制刷新,如果强制则忽略页面beforePageLeave
+     */
+    async refreshAll (force = false) {
+      const $alive = this.$refs.routerAlive
+      const { cache } = $alive
+      for (const id in cache) {
+        if (!force) {
+          try {
+            await this.pageLeavePromise(id, 'refresh')
+            $alive.clear(id)
+          } catch (e) {}
+        } else {
+          $alive.clear(id)
+        }
+      }
+      this.reloadRouter()
+    },
+
+    // 重载路由组件
+    async reloadRouter () {
+      this.isRouterAlive = false // 页面过渡结束后会设置为true
+    },
+
+    // 页签过渡结束
+    onTabTransitionEnd () {
+      this.adjust()
+    },
+
+    // 页面过渡结束
+    onPageTransitionEnd () {
+      if (!this.isRouterAlive) this.isRouterAlive = true
+    },
+
+    // 显示页签右键菜单
+    showContextmenu (id, index, e) {
+      // 菜单定位
+      let { y: top, x: left } = e || emptyObj
+      Object.assign(this.contextmenu, { id, index, top, left })
+    },
+
+    // 关闭页签右键菜单
+    hideContextmenu () {
+      this.showContextmenu(null, -1)
+    },
+
+    // Tab滚动
+    tabScroll (direction) {
+      let $tab = this.$el.querySelector('.router-tab-header')
+      let $scr = $tab.querySelector('.router-tab-scroll')
+      let space = $tab.clientWidth - 110
+
+      scrollTo($scr, $scr.scrollLeft + (direction === 'left' ? -space : space))
+    },
+
+    // 调整Tab滚动显示
+    adjust () {
+      let $tab = this.$el.querySelector('.router-tab-header')
+      let $scr = $tab.querySelector('.router-tab-scroll')
+      let $nav = $scr.querySelector('.router-tab-nav')
+      let $cur = $nav.querySelector('.actived')
+      let isScroll = $nav.clientWidth > $scr.clientWidth // 判断是否需要滚动
+
+      $tab.classList[isScroll ? 'add' : 'remove']('is-scroll')
+
+      if ($cur && isScroll) {
+        scrollTo($scr, $cur.offsetLeft + ($cur.clientWidth - $scr.clientWidth) / 2)
+      }
+    }
+  }
+}

+ 74 - 0
src/lib/RouterTab/components/RouterTab.vue

@@ -0,0 +1,74 @@
+<template>
+  <div class="router-tab">
+    <!-- 页签列表 -->
+    <header class="router-tab-header">
+      <div class="router-tab-scroll">
+        <transition-group
+          tag="ul"
+          class="router-tab-nav"
+          v-bind="typeof tabTransition === 'string' ? { name: tabTransition } : tabTransition"
+          @after-enter="onTabTransitionEnd"
+          @after-leave="onTabTransitionEnd">
+          <router-link
+            class="router-tab-item"
+            tag="li"
+            v-for="({ id, to, title, icon, tips, closable }, index) in items"
+            :class="{ actived: activedTab === id, contextmenu: contextmenu.id === id }"
+            :title="tips || title || ''"
+            :key="id || to"
+            :to="to"
+            @contextmenu.native.prevent="e => showContextmenu(id, index, e)">
+            <slot v-bind="{
+              tab: items[index],
+              tabs: items,
+              index
+            }">
+              <i v-if="icon" class="tab-icon" :class="icon"></i>
+              <span class="tab-title">{{title || '新页签'}}</span>
+              <i class="tab-close" v-if="closable !== false && items.length > 1" @click.prevent="close(id)"></i>
+            </slot>
+          </router-link>
+        </transition-group>
+      </div>
+
+      <!-- 页签滚动 -->
+      <a class="el-icon-caret-left nav-prev" @click="tabScroll('left')"></a>
+      <a class="el-icon-caret-right nav-next" @click="tabScroll('right')"></a>
+    </header>
+
+    <!-- 页面容器 -->
+    <div class="router-tab-container" :class="{ loading }">
+      <router-alive ref="routerAlive" :alive-key="aliveKey" @update="updateTab">
+        <transition
+          v-bind="typeof pageTransition === 'string' ? { name: pageTransition } : pageTransition"
+          @after-enter="onPageTransitionEnd"
+          @after-leave="onPageTransitionEnd"
+          appear>
+          <router-view ref="routerView" v-if="isRouterAlive" v-bind="routerView"/>
+        </transition>
+      </router-alive>
+    </div>
+
+    <!-- 右键菜单 -->
+    <transition name="router-tab-zoom-lt">
+      <div class="router-tab-contextmenu" :style="`left: ${contextmenu.left}px; top: ${contextmenu.top}px;`" v-if="contextmenu.id">
+        <a class="contextmenu-item" :disabled="!isContextTabActived" @click="isContextTabActived && refresh(contextmenu.id)">刷新</a>
+
+        <a class="contextmenu-item" :disabled="items.length < 2" @click="items.length > 1 && refreshAll()">刷新所有</a>
+
+        <a class="contextmenu-item" :disabled="!isContextTabCanBeClosed" @click="isContextTabCanBeClosed && close(contextmenu.id)">关闭</a>
+
+        <a class="contextmenu-item" :disabled="!tabsLeft.length" @click="tabsLeft.length && closeMulti(tabsLeft)">关闭左侧</a>
+
+        <a class="contextmenu-item" :disabled="!tabsRight.length" @click="tabsRight.length && closeMulti(tabsRight)">关闭右侧</a>
+
+        <a class="contextmenu-item" :disabled="!tabsOther.length" @click="tabsOther.length && closeMulti(tabsOther)">关闭其他</a>
+      </div>
+    </transition>
+  </div>
+</template>
+
+<script src="./RouterTab.js"></script>
+
+<style lang="scss" src="../scss/RouterTab.scss"></style>
+<style lang="scss" src="../scss/transition.scss"></style>

binární
src/lib/RouterTab/icon/close-active.png


binární
src/lib/RouterTab/icon/close.png


binární
src/lib/RouterTab/icon/left-hover.png


binární
src/lib/RouterTab/icon/left.png


+ 15 - 0
src/lib/RouterTab/index.js

@@ -0,0 +1,15 @@
+import RouterPage from './mixins/RouterPage'
+import RouterTab from './components/RouterTab.vue'
+
+// 安装
+RouterTab.install = (Vue, options) => {
+  Vue.component(RouterTab.name, RouterTab)
+  Vue.mixin(RouterPage)
+}
+
+// 如果浏览器环境且拥有全局Vue,则自动安装组件
+if (typeof window !== 'undefined' && window.Vue) {
+  window.Vue.use(RouterTab)
+}
+
+export default RouterTab

+ 44 - 0
src/lib/RouterTab/mixins/RouterPage.js

@@ -0,0 +1,44 @@
+// 路由页面混入
+export default {
+  // 创建前记录缓存
+  created () {
+    const { $route, $vnode } = this
+    const $alive = $vnode && $vnode.data.routerAlive
+
+    if (!$alive) return false
+
+    const key = $alive.getAliveKey($route)
+
+    // 更新缓存数据
+    let cacheItem = $alive.set(key, {
+      route: $route,
+      vm: this
+    })
+
+    // 监听routerTab字段,更新页签信息
+    this.$watch('routerTab', function (val, old) {
+      cacheItem.tab = typeof val === 'string' ? { title: val } : val
+      $alive.set(key, cacheItem)
+    }, {
+      deep: true,
+      immediate: true
+    })
+  },
+
+  // 解决webpack热加载后组件缓存不更新
+  async activated () {
+    const { $routerTab, $vnode } = this
+
+    if (!($vnode && $vnode.data.routerAlive)) return false
+
+    let ctorId = $vnode.componentOptions.Ctor.cid
+
+    // 热加载后Ctor.cid改变
+    if (this._ctorId && this._ctorId !== ctorId) {
+      this.$destroy()
+      $routerTab.refresh()
+    }
+
+    this._ctorId = ctorId
+  }
+}

+ 234 - 0
src/lib/RouterTab/scss/RouterTab.scss

@@ -0,0 +1,234 @@
+// 变量
+$color-primary: #409eff;
+
+// 页签
+.router-tab {
+	$bgHover: #f5f5f5;
+	$bgActive: #e5e5e5;
+  $h: 32px;
+  $slideW: 15px;
+
+  display: flex;
+  flex-direction: column;
+
+  &-header {
+    position: relative;
+		z-index: 9;
+    border-bottom: 2px solid $color-primary;
+    box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
+    transition: all .2s ease-in-out;
+    flex: none;
+
+    &.is-scroll {
+      padding: 0 $slideW;
+
+      .nav-prev,
+      .nav-next {
+        display: block;
+      }
+
+      .router-tab-scroll {
+        overflow: hidden;
+      }
+    }
+  }
+
+  &-scroll {
+    overflow: visible;
+  }
+
+  &-nav {
+    position: relative;
+    margin: 0;
+    padding: 0;
+    display: inline-block;
+    white-space: nowrap;
+  }
+
+  // 页签项
+  &-item {
+    $color: #4d4d4d;
+    $borderColor: #e6e6e6;
+
+    position: relative;
+    display: inline-block;
+    margin-right: -1px;
+    padding: 0 20px 0 10px;
+    color: $color;
+    line-height: $h;
+    font-size: 13px;
+    border: 1px solid $borderColor;
+    border-bottom: none;
+    cursor: pointer;
+    transition: all .3s ease-in-out;
+    user-select: none;
+
+    &.actived {
+      background-color: $color-primary;
+      border-color: $color-primary;
+      color: #fff;
+
+      .tab-close {
+        background-image: url(../icon/close-active.png);
+      }
+		}
+
+		&:not(.actived) {
+			&:hover,
+			&.contextmenu {
+				color: #000;
+				background-color: $bgHover;
+        box-shadow: 0 -2px 4px rgba(0,0,0,.08);
+			}
+
+			&:active {
+				background-color: $bgActive;
+			}
+    }
+    
+    .tab-title {
+      display: inline-block;
+      max-width: 100px;
+      min-width: 30px;
+      vertical-align: top;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      overflow: hidden;
+    }
+
+    .tab-icon {
+      margin-right: 5px;
+      vertical-align: top;
+      font-size: 16px;
+      line-height: $h;
+    }
+
+    .tab-close {
+      $size: 16px;
+      $font-size: 12px;
+
+      position: absolute;
+      top: 1px;
+      right: 1px;
+      width: $size;
+      height: $size;
+      text-align: center;
+      font-size: $font-size;
+      border-radius: 50%;
+      background: {
+        image: url(../icon/close.png);
+        position: 50% 50%;
+        repeat: no-repeat;
+      }
+      transition: background-color .2s ease-in-out;
+
+      &::before {
+        line-height: $size;
+      }
+
+      &:hover {
+        background-color: rgba(0,0,0,.1);
+      }
+
+      &:active {
+        background-color: rgba(0,0,0,.2);
+      }
+    }
+  }
+
+  .nav-prev,
+  .nav-next {
+    display: none;
+    position: absolute;
+    top: 0;
+    width: $slideW;
+    height: 100%;
+    line-height: $h;
+    text-align: center;
+    color: #ccc;
+    background: {
+      image: url(../icon/left.png);
+      position: 40% 50%;
+      repeat: no-repeat;
+    }
+    transition: all .2s ease-in-out;
+    box-shadow: 0 0 4px rgba(0,0,0,.2);
+    cursor: pointer;
+
+    &:hover {
+      color: #fff;
+      background-image: url(../icon/left-hover.png);
+      background-color: $color-primary;
+      border-color: $color-primary;
+    }
+
+    &:active {
+      opacity: .8;
+    }
+  }
+
+  .nav-prev {
+    left: 0;
+  }
+
+  .nav-next {
+    right: 0;
+    transform: rotate(180deg);
+	}
+
+  // 页面容器
+  &-container {
+    position: relative;
+    flex: auto;
+    overflow: hidden auto;
+    background: #fff;
+		transition: all .4s ease-in-out;
+  }
+	
+	// 右键菜单
+	&-contextmenu {
+    position: fixed;
+    z-index: 999;
+		min-width: 120px;
+    border-top: 2px solid $color-primary;
+    font-size: 13px;
+		background: #fff;
+    box-shadow: 0 1px 6px 3px rgba(0,0,0,.2);
+    transition: all .2s ease-in-out;
+
+		.contextmenu-item {
+			display: block;
+			padding: 5px 20px;
+			line-height: 1.5;
+			color: #555;
+			cursor: pointer;
+			transition: all .2s ease-in-out;
+      user-select: none;
+
+			&:hover {
+				color: #333;
+				background: $bgHover;
+			}
+
+			&:active {
+				background: $bgActive;
+			}
+
+      &[disabled] {
+        color: #aaa;
+        background: none;
+        pointer-events: none;
+        cursor: default;
+      }
+		}
+	}
+}
+
+.is-tab-fullscreen .router-tab-container {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 990;
+}

+ 55 - 0
src/lib/RouterTab/scss/transition.scss

@@ -0,0 +1,55 @@
+// transition 过渡样式
+.router-tab {
+  &-zoom-lt,
+  &-zoom-lb {
+    &-enter-active,
+    &-leave-active {
+      transition: all .3s;
+    }
+
+    &-enter,
+    &-leave-to {
+      transform: scale(0);
+      opacity: 0;
+    }
+  }
+
+  // 缩放-左上
+  &-zoom-lt {
+    &-enter-active,
+    &-leave-active {
+      transform-origin: left top;
+    }
+  }
+
+  // 缩放-左下
+  &-zoom-lb {
+    &-enter-active,
+    &-leave-active {
+      transform-origin: left bottom;
+    }
+  }
+
+  // 页面交换
+  &-swap {
+    $trans: 30px; // 移动位置
+
+    &-enter-active,
+    &-leave-active {
+      transition: all .5s;
+    }
+
+    &-enter,
+    &-leave-to {
+      opacity: 0;
+    }
+
+    &-enter {
+      transform: translateX(-$trans);
+    }
+
+    &-leave-to {
+      transform: translateX($trans);
+    }
+  }
+}

+ 4 - 1
src/main.js

@@ -1,10 +1,13 @@
 import Vue from 'vue'
+import RouterTab from '@/lib/RouterTab'
+
 import App from './App.vue'
 import router from './router'
 
 Vue.config.productionTip = false
+Vue.use(RouterTab)
 
 new Vue({
   router,
-  render: h => h(App)
+  render: (h) => h(App)
 }).$mount('#app')

+ 8 - 10
src/router.js

@@ -1,6 +1,7 @@
 import Vue from 'vue'
 import Router from 'vue-router'
-import Home from './views/Home.vue'
+
+const importView = view => () => import(/* webpackChunkName: "v-[request]" */ `./views/${view}.vue`)
 
 Vue.use(Router)
 
@@ -9,15 +10,12 @@ export default new Router({
     {
       path: '/',
       name: 'home',
-      component: Home
-    },
-    {
-      path: '/about',
-      name: 'about',
-      // route level code-splitting
-      // this generates a separate chunk (about.[hash].js) for this route
-      // which is lazy-loaded when the route is visited.
-      component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
+      component: importView('Home'),
+      redirect: '/page/1',
+      children: [{
+        path: '/page/:id',
+        component: importView('Page')
+      }]
     }
   ]
 })

+ 0 - 5
src/views/About.vue

@@ -1,5 +0,0 @@
-<template>
-  <div class="about">
-    <h1>This is an about page</h1>
-  </div>
-</template>

+ 23 - 11
src/views/Home.vue

@@ -1,18 +1,30 @@
 <template>
-  <div class="home">
-    <img alt="Vue logo" src="../assets/logo.png">
-    <HelloWorld msg="Welcome to Your Vue.js App"/>
+  <div>
+    <nav class="demo-nav">
+      <router-link to="/page/2">params:2</router-link>
+      <router-link to="/page/2?t=3">params:2 query:t=3</router-link>
+      <router-link to="/page/3">params:3</router-link>
+    </nav>
+    <router-tab/>
   </div>
 </template>
 
-<script>
-// @ is an alias to /src
-import HelloWorld from '@/components/HelloWorld.vue'
+<style lang="scss" scoped>
+.demo-nav {
+  margin-bottom: 10px;
 
-export default {
-  name: 'home',
-  components: {
-    HelloWorld
+  a {
+    margin-right: 15px;
+    font-size: 13px;
+    color: blue;
+
+    &:hover {
+      color: orange;
+    }
+
+    &.router-link-exact-active {
+      font-weight: 700;
+    }
   }
 }
-</script>
+</style>

+ 108 - 0
src/views/Page.vue

@@ -0,0 +1,108 @@
+<template>
+  <div class="app-page-container">
+    <h1 @click="click">RouterTab 实例页</h1>
+    <p>你在<strong>{{second}}</strong>秒前打开本页面</p>
+    <input type="text">
+    <dl>
+      <dt>name</dt>
+      <dd>{{$route.name}}</dd>
+      <dt>path</dt>
+      <dd>{{$route.path}}</dd>
+      <dt>params</dt>
+      <dd>{{$route.params}}</dd>
+      <dt>query</dt>
+      <dd>{{$route.query}}</dd>
+      <dt>hash</dt>
+      <dd>{{$route.hash}}</dd>
+      <dt>fullPath</dt>
+      <dd>{{$route.fullPath}}</dd>
+    </dl>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.app-page-container {
+  padding: 15px;
+  font-size: 14px;
+  line-height: 1.5;
+
+  dt {
+    float: left;
+    width: 150px;
+    font-weight: 700;
+  }
+
+  dd {
+    min-height: 1.5em;
+  }
+}
+</style>
+
+<script>
+export default {
+  name: 'router-tab-page',
+  data () {
+    return {
+      openTime: new Date(),
+      second: 0,
+      routerTab: {
+        title: '页签实例' + this.$route.params.id
+      }
+    }
+  },
+
+  activated () {
+    this.updateOpenTime()
+  },
+
+  deactivated () {
+    this.clearOpenTimeInterval()
+  },
+
+  beforeDestroy () {
+    this.clearOpenTimeInterval()
+  },
+
+  // 页面离开前提示
+  beforePageLeave (resolve, reject, tab, type) {
+    const action = (type === 'close' && '关闭') ||
+      (type === 'refresh' && '刷新') ||
+      (type === 'replace' && '替换')
+
+    const msg = `您确认要${action}页签“${tab.title}”吗?`
+
+    if (confirm(msg)) {
+      resolve()
+    } else {
+      reject('拒绝了页面离开')
+    }
+
+    /* this.$confirm(msg, '提示', { closeOnHashChange: false })
+      .then(resolve)
+      .catch(reject) */
+  },
+
+  methods: {
+    update () {
+      this.second = Math.floor((new Date() - this.openTime) / 1000)
+    },
+
+    updateOpenTime () {
+      this.update()
+
+      this.clearOpenTimeInterval()
+
+      // 定时更新事件
+      this.openTimeInterval = setInterval(this.update, 1000)
+    },
+
+    clearOpenTimeInterval () {
+      clearInterval(this.openTimeInterval)
+    },
+
+    click () {
+      console.log('aaa')
+    }
+  }
+}
+</script>

+ 3 - 0
vue.config.js

@@ -0,0 +1,3 @@
+module.exports = {
+  publicPath: ''
+}