转转|神颜小哥哥手把手带你玩转Vue多实例路由

avatar
公众号:转转技术

作者:张译文(西小口第一宫本武藏,爱好唱、跳、rap和足球的小前端)

背景

基于移动端开发的 web 页面,因为人机交互(单屏、单页面呈现)特点的限制,页面路由的实现和处理往往比较简单。常规前端框架的 router 库在不经任何改造的情况下,往往就可以满足业务需求。

但是,对于后台的各类系统,如物流管理、商家管理、客户管理等,因为用户会长时间停留使用,且屏幕空间充足,为了用户操作方便,往往需要实现一个类似浏览器的多页签导航功能。如下图所示:

image

一般说来,常规前端框架的 router 加上一个数组实现多页签导航并没有太大问题。但是,如果使用相同的组件同时渲染多个不同的详情页就有些麻烦。类似下面这样:

image

本文将对这种在移动端 web 页不常遇到的场景做简单的分析和解答。

现状

多页签组件,感觉在后台开发中还是挺常见的。这种组件算是业务组件,所以AntD 和 iView 两个前端组件库是没有这样的组件的。

后台框架antd-pro里也没有页签组件,后台框架 iview-admin 倒是实现了页签组件,但是没有实现最核心的功能:多实例页签。也就是使用相同组件同时渲染多个不同的页签。

所以,基本上业内没有成熟可直接复用的多实例多页签组件。

多页签方案简介(以 vue-router 为例)

多页签组件的实现不难,大体思路就是维护一个方便管理的 openTagList 的数组,用于储存打开的页签以及状态等,这里建议放在 vuex 里(react 则可放在 context 里),方便可能的各种业务需求随时使用,本文主要以 vue 为例。

image

  // vuex
  ...
  state={
    routers: [], // 这里保存了所有配置的路由
    cachePage: [], // 页面缓存
    pageOpenedList: [] // 打开的页面
  },
  mutations: {
    increateTag(state, tagObj) {
        const cacheName = tagObj.name
        state.cachePage.push(cacheName)
        state.pageOpenedList.push(tagObj)
    },
    setCurrentPageName(state, name) {
        state.currentPageName = name
    },
  }

在路由钩子中处理路由变化

router.afterEach((to) => {
  openNewPage(router.app, to)
  ...
})
funtcion openNewPage (context, { name, params, query, fullPath }) {
  const allRouters = context.$store.state.app.routers
  const pageOpenedList = context.$store.state.app.pageOpenedList
  // 找到当前路由是否已在页签中打开
  const routeIsOpen = !!pageOpenedList.find(item => item.name === name)
  if (!routeIsOpen) {
    // 找到当前跳转的路由
    const currRoute = treeFind(allRouters, findRoute(name)) || {}
    const { name: currName } = currRoute
    if (!currName) return false
    context.$store.commit('increateTag', { ...currRoute, params, query, fullPath })
    context.$store.commit('setCurrentPageName', currName)
  }
}

很简单,这样就可以基于 vuex 来实现一个页签组件了(页签组件的样式和交互逻辑这里就不再阐述)。

多页签中的多实例问题

后台系统中常有一些详情页面,比如订单详情页,工单详情页,快递单详情页,物流单详情页等。为了方便用户的使用需要同时打开多个,比如正在处理一个详情页的时候,还要打开另一个详情页(包括新建的详情页),让用户可以随时切换。

这些详情页往往都有一个唯一的 id,我们理所当然的想到用这个唯一 id 作为动态路由的参数。然而在实践中,我们发现打开新的详情页,就会覆盖之前的详情页,也就是说渲染的路由组件实例只能存在一个,为什么会出现这样的情况?

router-view实现原理对多实例页签的影响

我们看看 vue-router 中部分的实现源码,删掉了部分代码,只保留核心部分:

// v3.0.1
var View = {
  ...
  render: function render (_, ref) {
    ...
    var props = ref.props;
    var children = ref.children;
    var parent = ref.parent;
    var h = parent.$createElement; //父组件的$createElement函数引用
    var name = props.name;
    var route = parent.$route;
    // 缓存路由组件实例的地方
    var cache = parent._routerViewCache || (parent._routerViewCache = {});
    // 这里检查嵌套的路由是否被keep-alive包裹,也就是是否需要缓存
    var depth = 0;
    var inactive = false;
    while (parent && parent._routerRoot !== parent) {
      if (parent.$vnode && parent.$vnode.data.routerView) {
        depth++;
      }
      if (parent._inactive) {
        inactive = true;
      }
      parent = parent.$parent;
    }
    // 通过name关联渲染缓存的视图组件
    if (inactive) {
      return h(cache[name], data, children)
    }
    var matched = route.matched[depth];
    // 如果没有匹配到route则渲染一个空的结点
    if (!matched) {
      cache[name] = null;
      return h()
    }
    // 从matched属性获取当前层次的路由对象,这里就是我们路由配置中需要渲染的组件
    // 首次会从matched中拿,然后给到cache中
    var component = cache[name] = matched.components[name];
    ...
    return h(component, data, children)
  }
};

判断如果是 keep-alive 包裹的组件,则直接取之前缓存的组件渲染(这里是根据 name 来缓存渲染的视图组件的),如果没有,则从 matched 匹配到的组件中渲染 那么我们再来看看 matched 的组件是从哪儿来的。

vue-router 在定义路由记录时,根据 path 和 name 定义了 pathMap 和 nameMap 来储存每一个 record:

// addRouteRecord
var record = {
  path: normalizedPath,
  components: route.components || { default: route.component }, // 这里就是我们定义的路由组件
  name: name,
  parent: parent,
  ...
};
// createRoute
var route = {
  name: location.name || (record && record.name),
  meta: (record && record.meta) || {},
  path: location.path || '/',
  hash: location.hash || '',
  query: query,
  params: location.params || {},
  fullPath: getFullPath(location, stringifyQuery$$1),
  matched: record ? formatMatch(record) : []  // 以及这里定义了上面代码中router-view如何找到它
};

其实看到这里,差不多就能清楚了,vue-router 对于组件的渲染是一对一的,它最后渲染的组件就是我们配置到路由中的 component 或 components,所以我们配置的路由组件只能是单实例的,当我们 push 新的路由对象时,创建的组件实例始终只有一个,新的会覆盖老的。

上面分析了在 vue-router 中实现多实例有点麻烦的根本原因。但是,在后台系统开发中,多实例页签又是刚需。下面,我们尝试用两种解法,来实现多实例页签。

多实例路由的两种解法

方案一:动态添加路由实现多实例

既然 vue-router 的组件实例是一对一的,那么如何解决我们的问题呢?vue-router 的文档有提供 addRoutes 的方法,那么我们首先想到的就是动态地增加路由规则,先来试试看

把路由页组件根据唯一 id(比如订单 id)封装成工厂函数:

  function OrderDetailFactory(params = {}) {
    const {  orderId } = params;
    const extName = `-${orderId}`;
    const Comp = {
      name: `order-info${extName}`,
      props: [ ... ],
      data() { ... },
      methods: { ... },
      render() { ... },
      mounted() { ... },
      beforeDestroy() {
        // 销毁缓存在全局的组件
        const orderTimeStatus = setTimeout(() => {
          window.ticketCache[orderId] = null
          clearTimeout(ticketTimeStatus)
        })
      }
      ...
    };
    return Comp;
  }

定义一个创建动态路由的函数

  async orderDetail(orderId = '0') {
    const _orderId = orderId != '0' ? orderId : '0';
    const postTitle = orderId != '0' ? orderId : '新建';
    // 缓存组件
    if (!window.ticketCache) {
      window.ticketCache = {};
    }
    const { OrderDetailFactory }  = await import('@/views/orderDetail/index.js')
    const OrderDetail = OrderDetailFactory({ orderId: _orderId })
    const { name: compName } = OrderDetail
    window.orderCache[_orderId] = OrderDetail
    return {
      path: '/order/:orderId',
      title: `订单详情-${postTitle}`,
      name: compName,
      component: window.orderCache[_orderId],
      ...
    };
  },

再利用 vue-router 提供的 addRoutes 方法动态添加路由,然后就可以实现跳转了


  /**
  \* 添加动态路由
  \* @param {Object} context vue上下文
  \* @param {Object} currRouter vue-router路由实例
  */
  function addRouter(context, currRouter) {
    const router = context.$router
    ...
    router.addRoutes && router.addRoutes(currRouter)
  }
  async function jumpToDyRouter (context, orderId) {
    const currRouter = await orderDetail(orderId)
    const { name: routerName } = currRouter
    // 往路由里面加入我们动态创建的路由
    addRouter(context, currRouter)
    // 执行跳转
    const timeStatus = setTimeout(function () {
      context.$router.push({ name: routerName })
      context = null
      clearTimeout(timeStatus)
    }, 0)
  }

到这里,我们就实现了动态路由的跳转,通过工厂函数创建组件,然后根据唯一 id 生成路由配置,通过 addRoutes 方法动态添加路由,然后进行跳转。

image

但是我们能发现,虽然我们销毁了自己缓存在全局的 router 组件,但是通过 addRoutes 往 vue-router 增加的配置以及路由组件是没有销毁的,因为 vue-router 没有提供 replaceRoutes 或者 deleteRoutes 的方法,所以是有些内存问题的。

方案二:劫持路由变化实现多实例

既然方案一有些内存管理上的瑕疵,我们尝试换个思路,看看还有没有更好的解法。

一般情况下组件的渲染是通过直接渲染的,那么能不能自己实现一套组件的渲染呢?路由变化还是交给 vue-router 来做,我们通过监听路由的变化,来自己处理要渲染的组件。

将第一层路由托管到我们定义的 js 中

  // index.js
  data() {
    tagList: [], // 保存打开的页签
    activeKey: '', // 当前显示的页签
  },
  watch: {
    $route(to) {
      this.handlePathChange(to)
    },
  },
  methods: {
    handlePathChange(route) {
      const { fullPath, name, params, meta = {}, matched = [], query } = route
      // 拿到当前匹配到的组件
      const [currMatch] = matched.slice(-1)
      const { components } = currMatch
      const { default: Comp } = components || {}
      // 唯一name
      const uniqueName = this.createName(name, params)
      // 之前已创建的实例
      const findRes = this.tagList.find((item) => item.uniqueName === uniqueName)
        if (findRes) {
          this.activeKey = uniqueName
          return
        }
        const currRoute = {
          fullPath,
          uniqueName,
          component: Comp,
          params,
          query
        }
        this.tagList = [...this.tagList, currRoute]
        this.activeKey = uniqueName
    },
    // 生成唯一的name,根据name来匹配组件
    createName(name, params) {
      const uniqueKey = Object.values(params).reduce((dist, item) => `${dist ? dist + '-' : ''}${item}`, '')
      return `${name}-${uniqueKey}`
    }
  },
  render() {
    return (
      <div>
      // 这里可放置页签组件,共享tagList来维护页签
      // ...
      {this.tagList.map((item) => {
        const { component: Component, uniqueName, query, params } = item
        const isActive = uniqueName === this.activeKey
        // 透传路由参数
        const otherProps = {
          props: { ...params, ...query },
        }
        return (
          <Component
            {...otherProps}
            style={{ display: isActive ? 'block' : 'none' }}
          />
          )
      })}
      </div>
    )
  }

在路由变化时,拿到当前路由组件以及其 params,通过 params 里的参数(比如这里的订单 id)生成唯一的 key,然后就可以配置子路由,在监听到路由页变化时,渲染我们的多实例路由页组件了:

  const customRoute = {
    path: /customRoute,
    name: 'customRoute',
    component: () => import('@/views/CustomRoutes/index.js'),
    children: [
      {
        path: 'OrderDetail/:id',
        name: 'OrderDetail',
        component: () => import('@/views/CustomRoutes/OrderDetail'),
      }
    ]
  }

其实,总结起来主要思路就是使用 watch 来监听路由变化,拿到当前跳转的路由信息,维护路由页签数组,从 tagList 取组件渲染,并跟据激活标志,指定各组件顶层 DOM 的 display 属性。

当然,我们应该要维护一个最大页签数,因为是通过 DOM 的 display 属性缓存真实 DOM ,组件实例也是放在内存中的,在页签关闭时以及超过最大限制时注意释放维护的变量占用的内存空间,注册的事件等。

多实例路由的价值

方案一使用了 vue-router 的 addRoutes()的能力,通过函数式的组件绕过了路由组件一对一的限制,而方案二则相当于在组件内部自己控制组件的渲染,这自然就不会有路由组件一对一的限制了。方案一缺点在于始终有一些不再使用的路由配置内存没法及时释放,方案二在自己控制销毁组件的情况下是可以在内存上有所控制的。

多实例路由的实现不仅可以提高用户操作后台系统的便利性和灵活性,还可以大大提高用户的工作效率,让用户在面对同类页面时,不再仅仅局限于一个工作窗口。

结语

后台系统有各种复杂的业务场景以及关注点,由于移动端页面用户停留时间短,所以我们一般关注的是页面加载的性能,而在 PC 端网络稳定,网速更快,但由于用户长时间停留使用,我们要更关注浏览器的内存占用。所以我们在编写业务代码以及技术方案时要更注意。最后感谢转转客服前端技术团队所有同学的长期实践和积累~

想要了解更多作者小哥哥信息,关注我们公众号...