在移动端使用vue-router和keep-alive
发布在前端技术分享2018年12月1日view:397前端开发性能优化Vue.js前端的畅想前端工程师网页设计JavaScript
在文章任何区域双击击即可给文章添加【评注】!浮到评注点上可以查看详情。

对于web开发和移动端开发,两者在路由上的处理是不同的。对于移动端来说,页面的路由是相当于栈的结构的。vue-router与keep-alive提供的路由体验与移动端是有一定差别的,因此常常开发微信公众号的我想通过一些尝试来将两者的体验拉近一些。

目标

问题

首先一个问题是keep-alive的行为。我们可以通过keep-alive来保存页面状态,但这样的行为对于类似于APP的体验是有些奇怪的。例如我们的应用有首页、列表页、详情页3个页面,当我们从列表页进入详情页再返回,此时列表页应当是keep-alive的。而当我们从列表页返回首页,再次进入列表页,此时的列表页应当在退出时销毁,并在重新进入时再生成才比较符合习惯。

第二个问题是滚动位置。vue-router提供了 scrollBehavior 来帮助维护滚动位置,但这一工具只能将页面作为滚动载体来处理。但我在实际开发中,喜欢使用flex来布局页面,滚动列表的载体常常是某个元素而非页面本身。

使用环境

对于代码能正确运行的环境,这里严格假定为微信(或是APP中内嵌的web页面),而非通过普通浏览器访问,即:用户无法通过直接输入url来跳转路由。在这样的前提下,路由的跳转是代码可控的,即对应于vue-router的push、replace等方法,而唯一无法干预的是浏览器的回退行为。在这样的前提下,我们可以假定,任何没有通过vue-router触发的路由跳转,是 回退1个记录 的回退行为。

改造前

这里我列出改造前的代码,是一个非常简单的demo,就不详细说了(这里列表页有两个列表,是为了展示改造后的滚动位置维护):

// css
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
html, body {
  height: 100%;
}
#app {
  height: 100%;
}
// html
<div id="app">
  <keep-alive>
    <router-view></router-view>
  </keep-alive>
</div>
// js
const Index = {
  name: 'Index',
  template:
  `<div>
    首页
    <div>
      <router-link :to="{ name: 'List' }">Go to List</router-link>
    </div>
  </div>`,
  mounted() {
    console.warn('Main', 'mounted');
  },
};

const List = {
  name: 'List',
  template: 
  `<div style="display: flex;flex-direction: column;height: 100%;">
    <div>列表页</div>
    <div style="flex: 1;overflow: scroll;">
      <div v-for="item in list" :key="item.id">
        <router-link style="line-height: 100px;display:block;" :to="{ name: 'Detail', params: { id: item.id } }">
          {{item.name}}
        </router-link>
      </div>
    </div>
    <div style="flex: 1;overflow: scroll;">
      <div v-for="item in list" :key="item.id">
        <router-link style="line-height: 100px;display:block;" :to="{ name: 'Detail', params: { id: item.id } }">
          {{item.name}}
        </router-link>
      </div>
    </div>
  </div>`,
  data() {
    return {
      list: new Array(10).fill(1).map((_,index) => {
        return {id: index + 1, name: `item${index + 1}`};
      }),
    };
  },
  mounted() {
    console.warn('List', 'mounted');
  },
  activated() {
    console.warn('List', 'activated');
  },
  deactivated() {
    console.warn('List', 'deactivated');
  },
};

const Detail = {
  name: 'Detail',
  template:
  `<div>
    详情页
    <div>
      {{$route.params.id}}
    </div>
  </div>`,
  mounted() {
    console.warn('Detail', 'mounted');
  },
};

const routes = [
  { path: '', name: 'Main', component: Index },
  { path: '/list', name: 'List', component: List },
  { path: '/detail/:id', name: 'Detail', component: Detail },
];

const router = new VueRouter({
  routes,
});

const app = new Vue({
  router,
}).$mount('#app');

当我们第一次从首页进入列表页时, mounted 和 activated 将被先后触发,而在此后无论是进入详情页再回退,或是回退到首页再进入列表页,都只会触发 deactivated 生命周期。

keep-alive *includes* keep-alive有一个 includes 选项,这个选项可以接受一个数组,并通过这个数组来决定组件的保活状态:

// keep-alive
render () {
  const slot = this.$slots.default
  const vnode: VNode = getFirstComponentChild(slot)
  const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
  if (componentOptions) {
    const name: ?string = getComponentName(componentOptions)
    const { include, exclude } = this
    if (
      (include && (!name || !matches(include, name))) ||
      (exclude && name && matches(exclude, name))
    ) {
      return vnode
    }

    const { cache, keys } = this
    const key: ?string = vnode.key == null
      ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
      : vnode.key
    if (cache[key]) {
      vnode.componentInstance = cache[key].componentInstance
      remove(keys, key)
      keys.push(key)
    } else {
      cache[key] = vnode
      keys.push(key)
      if (this.max && keys.length > parseInt(this.max)) {
        pruneCacheEntry(cache, keys[0], keys, this._vnode)
      }
    }

    vnode.data.keepAlive = true
  }
  return vnode || (slot && slot[0])
}

这里我注意到,可以动态的修改这个数组,来使得本来处于保活状态的组件/页面失活。

afterEach

那我们可以在什么时候去维护/修改includes数组呢?vue-router提供了 afterEach 方法来添加路由改变后的回调:

updateRoute (route: Route) {
  const prev = this.current
  this.current = route
  this.cb && this.cb(route)
  this.router.afterHooks.forEach(hook => {
    hook && hook(route, prev)
  })
}

在这里虽然 afterHooks 的执行是晚于路由的设置的,但组件的 render 是在 nextTick 中执行的,也就是说,在keep-alive的render方法判断是否应当从缓存中获取组件时,组件的保活状态已经被我们修改了。

劫持router.push

这里我们将劫持router的push方法:

let dir = 1;
const includes = [];

const routerPush = router.push;
router.push = function push(...args) {
  dir = 1;
  routerPush.apply(router, args);
};

router.afterEach((to, from) => {
  if (dir === 1) {
    includes.push(to.name);
  } else if (dir === -1) {
    includes.pop();
  }
  dir = -1;
});

我们将router.push(当然这里需要劫持的方法不止是push,在此仅用push作为示例)和浏览器的回退行为用不同的 dir 标记,并根据这个值来维护includes数组。

然后,将includes传递给keep-alive组件:

// html
<div id="app">
  <keep-alive :include="includes">
    <router-view></router-view>
  </keep-alive>
</div>

// js
const app = new Vue({
  router,
  data() {
    return {
      includes,
    };
  },
}).$mount('#app');

维护滚动

接下来,我们将编写一个 keep-position 指令(directive):

Vue.directive('keep-position', {
  bind(el, { value }) {
    const parent = positions[positions.length - 1];
    const obj = {
      x: 0,
      y: 0,
    };
    const key = value;
    parent[key] = obj;
    obj.el = el;
    obj.handler = function ({ currentTarget }) {
      obj.x = currentTarget.scrollLeft;
      obj.y = currentTarget.scrollTop;
    };
    el.addEventListener('scroll', obj.handler);
  },
});

并对router进行修改,来维护position数组:

const positions = [];

router.afterEach((to, from) => {
  if (dir === 1) {
    includes.push(to.name);
    positions.push({});
  }

  ...
});

起初我想通过指令来移除事件侦听(unbind)以及恢复滚动位置,但发现使用unbind并不方便,更重要的是指令的几个生命周期在路由跳转到保活的页面时都不会触发。

因此这里我还是使用 afterEach 来处理路由维护,这样在支持回退多步的时候也比较容易去扩展:

router.afterEach((to, from) => {
  if (dir === 1) {
    includes.push(to.name);
    positions.push({});
  } else if (dir === -1) {
    includes.pop();
    unkeepPosition(positions.pop({}));
    restorePosition();
  }
  dir = -1;
});

const restorePosition = function () {
  Vue.nextTick(() => {
    const parent = positions[positions.length - 1];
    for (let key in parent) {
      const { el, x, y } = parent[key];
      el.scrollLeft = x;
      el.scrollTop = y;
    }
  });
};

const unkeepPosition = function (parent) {
  for (let key in parent) {
    const obj = parent[key];
    obj.el.removeEventListener('scroll', obj.handler);
  }
};

最后,我们分别给我们的列表加上我们的指令就可以了:

<div style="flex: 1;overflow: scroll;" v-keep-position="'list1'">
  <!--  -->
</div>
<div style="flex: 1;overflow: scroll;" v-keep-position="'list2'">
  <!--  -->
</div>

本次给大家推荐一个免费的学习群,里面概括移动应用网站开发,css,html,webpack,vue node angular以及面试资源等。 对web开发技术感兴趣的同学,欢迎加入Q群:943129070,不管你是小白还是大牛我都欢迎,还有大牛整理的一套高效率学习路线和教程与您免费分享,同时每天更新视频资料。 最后,祝大家早日学有所成,拿到满意offer,快速升职加薪,走上人生巅峰。

评论
发表评论
暂无评论
WRITTEN BY
__小宸__
前端全栈开发工程师 分享一些心得与技术给大家,希望能对大家有所帮助。 欢迎加入前端开发学习交流群:943129070 大家可以一起交流学习
TA的新浪微博
PUBLISHED IN
前端技术分享

分享各种前端开发需要掌握的知识点与观点,欢迎大家一起来讨论

我的收藏