0%

当基于 Vue 的单页应用采用 SSR 后,有些组件由于内部使用了 document 等浏览器端的变量,会在 import 时报错。比较好的解决方法是在 mounted 中动态的 import 组件,赋值到 data 中,在模板里通过 v-bind:is 来引入。

示例:

<template>
  <component v-bind:is="Comp"></component>
</template>

<script>
export default {
  data() {
    return {
      Comp: null
    }
  },
  mounted() {
    import('comp').then(Comp => {
      this.Comp = Comp;
    });
  }
}
</script>

Paint

当用户导航到网页时,他们会寻找一些视觉反馈来确保一切正常,我们可以用 paint 计时接口来衡量这个指标。

Paint 是指将渲染树转换成屏幕上的像素的过程。

function showPaintTimings() {
  if (window.performance) {
    let performance = window.performance;
    let performanceEntries = performance.getEntriesByType('paint');
    performanceEntries.forEach(performanceEntry => {
      console.log("The time to " + performanceEntry.name + " was " + performanceEntry.startTime + " milliseconds.");
    });
  } else {
    console.log('Performance timing isn\'t supported.');
  }
}

这个接口暴露两个值:

  • Time to first paint: 浏览器开始在屏幕上渲染内容的第一个比特
  • Time to first contentful paint: 浏览器开始渲染 DOM、文字、图片的第一个比特

perf metrics load timeline


基本概念

进程(Process)

进程是计算机中已运行程序的实体,是线程的容器。

进程间通信使用 IPC。

线程(Thread)

线程是操作系统能够进行运算调度的最小单位。它被包含在进程中,是进程中的实际运行单位。一个进程中可以并发多个线程,每条线程并行执行不同的任务。

线程间使用共享内存通信。

A process with two threads of execution, running on one processor

运行在一个处理器上的一个进程有两个线程在执行


什么是弹幕

弹dàn幕mù(日语:弾幕だんまく)本义是指战场上由密集子弹形成的火力网(子弹密集得像幕一样),相当于中文的“枪林弹雨”。同时还可用于代指密集移动的事物,现今多用于视频网站中飞过的即时评论字幕(用于这种用法时,在日语中称为コメント,评论之意)。

技术选择

要实现弹幕,很容易就能想到两种方式:

  • 使用 Canvas 绘制一个个字幕,生成动画
  • 纯 HTML 元素制作字幕,使用 css 移动字幕

比较这两种方式,canvas 方案只需要一个 canvas 元素即可,所有字幕动画都是通过 js 代码生成,更加简洁,但是 canvas 中要高频的重复绘制相似的内容,不够经济。纯 HTML 元素方案中,每一条弹幕就是一个元素节点,可以 css transform 属性来实现流畅的动画,当弹幕移出可视区域时,即可移除节点,性能更好。

约定

  • 弹幕的容器即可视区域,称作 Canvas
  • 每一条字幕称作 Msg
  • Msg 是匀速运动的,所以 transition 中的 timing-function 应使用 linear
  • Msg 的移动速度和 Msg 的长度有关,长度越长速度越快,同时应有最大值与最小值:msg-speed-width
  • Msg 应从 Canvas 的右侧外部移动到 Canvas 中,再从左侧移出:translateX

思路

由约定,可得:

  • Msg 的速度:$v = \alpha * msg_width$
  • Msg 需要运动的位移:$s = canvas_width + msg_width$
  • Msg 需要运动的时间:$duration = \frac{s}{v}$

即,在 Msg 长度、Canvas 长度已知的情况下,位移、速度和时间都是确定的,所以,只需记录下 Msg 运动的初始时间,即可计算出 Msg 在任意时刻的位置。

实现

TBC…


萌娘百科:弹幕

map $http_user_agent $log_ua {

    ~Googlebot 0;
    ~Baiduspider 0;
    ~YandexBot 0;
    ~bingbot 0;
    ~MJ12bot 0;
    ~linkdexbot 0;
    ~GridBot 0;

    default 1;
}

server {
    access_log /var/log/nginx/blog.ihanai.access.log combined if=$log_ua;
}

在 GCE 中生成 initramfs 会很慢,终端输出如下:

update-initramfs: Generating /boot/initrd.img-4.10.0-42-generic
W: mdadm: /etc/mdadm/mdadm.conf defines no arrays.
xz: Adjusted LZMA2 dictionary size from 8 MiB to 2 MiB to not exceed the memory usage limit of 30 MiB

可以看出使用了 xz 压缩格式,并限制使用 30MB 的内存,这对我的小内存机器就很尴尬了。

可以修改配置文件 /etc/initramfs-tools/conf.d/99-gce.conf,将 5% 的内存限制调高。

# Use xz compression per GCE
COMPRESS=xz
XZ_OPT=--memlimit-compress=5%
export XZ_OPT

背景

有一单页应用 小B,近感其加载过慢、客户端载入时请求数过多,故考虑将之转换成 PWA,缩短内容到达时间,提升用户体验,以使其发挥更大的价值。

客户端

其貌不扬的 SPA,基于 Vue, VueRouter, Vuex 生态构建。起始页含 5 个 Tab,靠底部的 TabBar 切换。多数页面需要用户登录,用户信息通过模板被后台注入到 html 文件中。

服务器端

使用 Egg.js 搭建的 Node 应用,用于接收 SPA 的请求,并使用存在 redis 中的 token 等内容与后端 API 服务进行交互。

对客户端进行改动

将 小B 转换成 PWA,修改的重头在客户端,可参考项目 vue-hackernews-2.0。本文仅对部分内容进行讨论。

server 与 client 采用不同的入口

由于不再是纯前端项目,部分代码需要根据环境的不同,执行不同的操作,所以使用两个入口文件 entry-client.jsentry-server.js

依赖特定平台 API 的模块

代码中一些模块会使用到特定平台的 API,如 window 或 document,则在 Node.js 中执行时会抛出错误。这种情况下,应尽量使用同构的模块(如 axios);或者在 mounted 中动态加载需要用到浏览器 API 的模块:

export default {
  mounted() {
    import('utils/preview-image').then(previewImage => this.previewImage = previewImage.default || previewImage);
  },
  methods: {
    onClickImagePreview(urls) {
      this.previewImage({ urls });
    }
  }
}

Vuex store 中的模块重用

在服务端渲染时,为了避免有状态的单例,我们会使用如 createApp, createStore, createRouter 这样的工厂函数,然而在 Vuex 模块中的 state 可能会被忘记。

const module = {
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

需要修改为

const getInitialState = () => {
  return { ... };
};

const module = {
  state: getInitialState,
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}
阅读全文 »

install.js

为 Vue 插件定义 install 方法(Vue.js: Plugins)。

import View from './components/view'
import Link from './components/link'

export let _Vue

export function install (Vue) {
  if (install.installed && _Vue === Vue) return  // 避免重复安装
  install.installed = true

  _Vue = Vue

  const isDef = v => v !== undefined

  const registerInstance = (vm, callVal) => {
    let i = vm.$options._parentVnode // 获取组件的父节点
    if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {  // 判断是否是 router-view
      i(vm, callVal)  // 执行 router-view 的 registerRouteInstance 方法
    }
  }

  // 为所有组件 mixin 方法
  Vue.mixin({
    beforeCreate () {  // 实例创建
      if (isDef(this.$options.router)) {  // 判断是否是路由的根实例(使用 new Vue({router}) 创建,一般为 app)
        this._routerRoot = this  // 绑定到 this._routerRoot
        this._router = this.$options.router
        this._router.init(this)  // 在路由的根实例初始化 vue-router
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this  // 使非路由根实例的 _routerRoot 指向路由的根实例
      }
      registerInstance(this, this)
    },
    destroyed () {  // 实例销毁
      registerInstance(this)
    }
  })

  // 为所有 Vue 实例增加 $router 属性,用于获取路由根实例的 _router 属性
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })

  // 为所有 Vue 实例增加 $route 属性,用于获取路由根实例的 _route 属性
  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })

  Vue.component('router-view', View)  // 注册全局组件 router-view
  Vue.component('router-link', Link)  // 注册全局组件 router-link

  const strats = Vue.config.optionMergeStrategies
  // 路由钩子使用相同的合并策略 https://vuejs.org/v2/guide/mixins.html#Custom-Option-Merge-Strategies
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}

index.js

/* @flow */

import { install } from './install'
import { START } from './util/route'
import { assert } from './util/warn'
import { inBrowser } from './util/dom'
import { cleanPath } from './util/path'
import { createMatcher } from './create-matcher'
import { normalizeLocation } from './util/location'
import { supportsPushState } from './util/push-state'

import { HashHistory } from './history/hash'
import { HTML5History } from './history/html5'
import { AbstractHistory } from './history/abstract'

import type { Matcher } from './create-matcher'

export default class VueRouter {
  static install: () => void;
  static version: string;

  app: any;
  apps: Array<any>;
  ready: boolean;
  readyCbs: Array<Function>;
  options: RouterOptions;
  mode: string;
  history: HashHistory | HTML5History | AbstractHistory;
  matcher: Matcher;
  fallback: boolean;
  beforeHooks: Array<?NavigationGuard>;
  resolveHooks: Array<?NavigationGuard>;
  afterHooks: Array<?AfterNavigationHook>;

  constructor (options: RouterOptions = {}) {
    this.app = null
    this.apps = []
    this.options = options
    this.beforeHooks = []
    this.resolveHooks = []
    this.afterHooks = []
    this.matcher = createMatcher(options.routes || [], this)

    let mode = options.mode || 'hash'
    this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
    if (this.fallback) {
      mode = 'hash'
    }
    if (!inBrowser) {
      mode = 'abstract'
    }
    this.mode = mode

    switch (mode) {  // 根据不同 mode 实例化不同的 History
      case 'history':
        this.history = new HTML5History(this, options.base)
        break
      case 'hash':
        this.history = new HashHistory(this, options.base, this.fallback)
        break
      case 'abstract':
        this.history = new AbstractHistory(this, options.base)
        break
      default:
        if (process.env.NODE_ENV !== 'production') {
          assert(false, `invalid mode: ${mode}`)
        }
    }
  }

  match (
    raw: RawLocation,
    current?: Route,
    redirectedFrom?: Location
  ): Route {
    return this.matcher.match(raw, current, redirectedFrom)
  }

  get currentRoute (): ?Route {
    return this.history && this.history.current
  }

  init (app: any /* Vue component instance */) {
    process.env.NODE_ENV !== 'production' && assert(
      install.installed,
      `not installed. Make sure to call \`Vue.use(VueRouter)\` ` +
      `before creating root instance.`
    )

    this.apps.push(app)

    // main app already initialized.
    if (this.app) {
      return
    }

    this.app = app

    const history = this.history

    if (history instanceof HTML5History) {
      history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) {
      const setupHashListener = () => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupHashListener,
        setupHashListener
      )
    }

    history.listen(route => {
      this.apps.forEach((app) => {
        app._route = route
      })
    })
  }

  beforeEach (fn: Function): Function {
    return registerHook(this.beforeHooks, fn)
  }

  beforeResolve (fn: Function): Function {
    return registerHook(this.resolveHooks, fn)
  }

  afterEach (fn: Function): Function {
    return registerHook(this.afterHooks, fn)
  }

  onReady (cb: Function, errorCb?: Function) {
    this.history.onReady(cb, errorCb)
  }

  onError (errorCb: Function) {
    this.history.onError(errorCb)
  }

  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.history.push(location, onComplete, onAbort)
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.history.replace(location, onComplete, onAbort)
  }

  go (n: number) {
    this.history.go(n)
  }

  back () {
    this.go(-1)
  }

  forward () {
    this.go(1)
  }

  getMatchedComponents (to?: RawLocation | Route): Array<any> {
    const route: any = to
      ? to.matched
        ? to
        : this.resolve(to).route
      : this.currentRoute
    if (!route) {
      return []
    }
    return [].concat.apply([], route.matched.map(m => {
      return Object.keys(m.components).map(key => {
        return m.components[key]
      })
    }))
  }

  resolve (
    to: RawLocation,
    current?: Route,
    append?: boolean
  ): {
    location: Location,
    route: Route,
    href: string,
    // for backwards compat
    normalizedTo: Location,
    resolved: Route
  } {
    const location = normalizeLocation(
      to,
      current || this.history.current,
      append,
      this
    )
    const route = this.match(location, current)
    const fullPath = route.redirectedFrom || route.fullPath
    const base = this.history.base
    const href = createHref(base, fullPath, this.mode)
    return {
      location,
      route,
      href,
      // for backwards compat
      normalizedTo: location,
      resolved: route
    }
  }

  addRoutes (routes: Array<RouteConfig>) {
    this.matcher.addRoutes(routes)
    if (this.history.current !== START) {
      this.history.transitionTo(this.history.getCurrentLocation())
    }
  }
}

function registerHook (list: Array<any>, fn: Function): Function {
  list.push(fn)
  return () => {
    const i = list.indexOf(fn)
    if (i > -1) list.splice(i, 1)
  }
}

function createHref (base: string, fullPath: string, mode) {
  var path = mode === 'hash' ? '#' + fullPath : fullPath
  return base ? cleanPath(base + '/' + path) : path
}

VueRouter.install = install
VueRouter.version = '__VERSION__'

if (inBrowser && window.Vue) {
  window.Vue.use(VueRouter)
}

前言

history 是 vue-router 中代码量最大的部分,此模块中共有 4 个文件:

  • abstract.js
  • base.js
  • hash.js
  • html5.js

base.js 定义了基类 History,其它三个文件中的类都继承自 Historyabstract.js, hash.js, html5.js 别对应了 vue-router 设置中 mode 的三个值。hash.js, html5.js 适用于浏览器中的环境。abstract.js 适用于 node.js 环境。

读码

base.js

/* @flow */

import { _Vue } from '../install'
import type Router from '../index'
import { inBrowser } from '../util/dom'
import { runQueue } from '../util/async'
import { warn, isError } from '../util/warn'
import { START, isSameRoute } from '../util/route'
import {
  flatten,
  flatMapComponents,
  resolveAsyncComponents
} from '../util/resolve-components'

// 定义基类 History
export class History {
  router: Router;
  base: string;
  current: Route;
  pending: ?Route;
  cb: (r: Route) => void;
  ready: boolean;
  readyCbs: Array<Function>;
  readyErrorCbs: Array<Function>;
  errorCbs: Array<Function>;

  // 在子类中实现以下方法
  +go: (n: number) => void;  // 只读的 go 方法
  +push: (loc: RawLocation) => void;  // 只读的 push 方法
  +replace: (loc: RawLocation) => void;  // 只读的 replace 方法
  +ensureURL: (push?: boolean) => void;  // 只读的 ensureURL 方法,若当前路由的路径与实际路径不等时,则 push 或 replace
  +getCurrentLocation: () => string;  // 只读的 getCurrentLocation 方法,获取当前实际的路径

  constructor (router: Router, base: ?string) {
    this.router = router
    this.base = normalizeBase(base)  // 归一化 base URL
    // start with a route object that stands for "nowhere"
    this.current = START
    this.pending = null
    this.ready = false  // 完成路由初始导航后变为 true
    this.readyCbs = []
    this.readyErrorCbs = []
    this.errorCbs = []
  }

  listen (cb: Function) {
    this.cb = cb
  }

  // 该方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和路由初始化相关联的异步组件。
  // 这可以有效确保服务端渲染时服务端和客户端输出的一致。
  onReady (cb: Function, errorCb: ?Function) {
    if (this.ready) {
      cb()
    } else {
      this.readyCbs.push(cb)
      if (errorCb) {
        this.readyErrorCbs.push(errorCb)
      }
    }
  }

  // 注册一个回调,该回调会在路由导航过程中出错时被调用。
  onError (errorCb: Function) {
    this.errorCbs.push(errorCb)
  }

  // 路径跳转
  transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const route = this.router.match(location, this.current)
    this.confirmTransition(route, () => {  // 执行跳转
      this.updateRoute(route)
      onComplete && onComplete(route)
      this.ensureURL()  // 切换路径

      // fire ready cbs once
      if (!this.ready) {
        this.ready = true
        this.readyCbs.forEach(cb => { cb(route) })  // 路由完成初始导航后回调
      }
    }, err => {
      if (onAbort) {
        onAbort(err)
      }
      if (err && !this.ready) {
        // 初始化路由解析运行出错
        this.ready = true
        this.readyErrorCbs.forEach(cb => { cb(err) })
      }
    })
  }

  // transition 中进行修改 url 的操作,history 栈操作在不同 History 子类中实现
  confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
    const current = this.current
    const abort = err => {
      if (isError(err)) {
        if (this.errorCbs.length) {
          this.errorCbs.forEach(cb => { cb(err) })
        } else {
          warn(false, 'uncaught error during route navigation:')
          console.error(err)
        }
      }
      onAbort && onAbort(err)
    }
    if (
      isSameRoute(route, current) &&
      // in the case the route map has been dynamically appended to
      route.matched.length === current.matched.length
    ) {
      this.ensureURL()
      return abort()
    }

    const {
      updated,
      deactivated,
      activated
    } = resolveQueue(this.current.matched, route.matched)

    const queue: Array<?NavigationGuard> = [].concat(
      // in-component leave guards
      extractLeaveGuards(deactivated),
      // global before hooks
      this.router.beforeHooks,
      // in-component update hooks
      extractUpdateHooks(updated),
      // in-config enter guards
      activated.map(m => m.beforeEnter),
      // async components
      resolveAsyncComponents(activated)
    )

    this.pending = route
    const iterator = (hook: NavigationGuard, next) => {
      if (this.pending !== route) {
        return abort()
      }
      try {
        hook(route, current, (to: any) => {
          if (to === false || isError(to)) {
            // next(false) -> abort navigation, ensure current URL
            this.ensureURL(true)
            abort(to)
          } else if (
            typeof to === 'string' ||
            (typeof to === 'object' && (
              typeof to.path === 'string' ||
              typeof to.name === 'string'
            ))
          ) {
            // next('/') or next({ path: '/' }) -> redirect
            abort()
            if (typeof to === 'object' && to.replace) {
              this.replace(to)
            } else {
              this.push(to)
            }
          } else {
            // confirm transition and pass on the value
            next(to)
          }
        })
      } catch (e) {
        abort(e)
      }
    }

    runQueue(queue, iterator, () => {
      const postEnterCbs = []
      const isValid = () => this.current === route
      // wait until async components are resolved before
      // extracting in-component enter guards
      const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
      const queue = enterGuards.concat(this.router.resolveHooks)
      runQueue(queue, iterator, () => {
        if (this.pending !== route) {
          return abort()
        }
        this.pending = null
        onComplete(route)
        if (this.router.app) {
          this.router.app.$nextTick(() => {
            postEnterCbs.forEach(cb => { cb() })
          })
        }
      })
    })
  }

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

// 归一化 base URL
function normalizeBase (base: ?string): string {
  if (!base) {
    if (inBrowser) {
      // 检查 <base> 标签
      // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
      const baseEl = document.querySelector('base')
      base = (baseEl && baseEl.getAttribute('href')) || '/'
      // 去除协议、域名、端口,不使用完整 URL
      base = base.replace(/^https?:\/\/[^\/]+/, '')
    } else {
      base = '/' // 非浏览器环境以 / 为 base URL
    }
  }
  // 使 URL 以 / 开头
  if (base.charAt(0) !== '/') {
    base = '/' + base
  }
  // 移除末尾的 /
  return base.replace(/\/$/, '')
}

function resolveQueue (
  current: Array<RouteRecord>,
  next: Array<RouteRecord>
): {
  updated: Array<RouteRecord>,
  activated: Array<RouteRecord>,
  deactivated: Array<RouteRecord>
} {
  let i
  const max = Math.max(current.length, next.length)
  for (i = 0; i < max; i++) {
    if (current[i] !== next[i]) {
      break
    }
  }
  return {
    updated: next.slice(0, i),
    activated: next.slice(i),
    deactivated: current.slice(i)
  }
}

function extractGuards (
  records: Array<RouteRecord>,
  name: string,
  bind: Function,
  reverse?: boolean
): Array<?Function> {
  const guards = flatMapComponents(records, (def, instance, match, key) => {
    const guard = extractGuard(def, name)
    if (guard) {
      return Array.isArray(guard)
        ? guard.map(guard => bind(guard, instance, match, key))
        : bind(guard, instance, match, key)
    }
  })
  return flatten(reverse ? guards.reverse() : guards)
}

function extractGuard (
  def: Object | Function,
  key: string
): NavigationGuard | Array<NavigationGuard> {
  if (typeof def !== 'function') {
    // extend now so that global mixins are applied.
    def = _Vue.extend(def)
  }
  return def.options[key]
}

function extractLeaveGuards (deactivated: Array<RouteRecord>): Array<?Function> {
  return extractGuards(deactivated, 'beforeRouteLeave', bindGuard, true)
}

function extractUpdateHooks (updated: Array<RouteRecord>): Array<?Function> {
  return extractGuards(updated, 'beforeRouteUpdate', bindGuard)
}

function bindGuard (guard: NavigationGuard, instance: ?_Vue): ?NavigationGuard {
  if (instance) {
    return function boundRouteGuard () {
      return guard.apply(instance, arguments)
    }
  }
}

function extractEnterGuards (
  activated: Array<RouteRecord>,
  cbs: Array<Function>,
  isValid: () => boolean
): Array<?Function> {
  return extractGuards(activated, 'beforeRouteEnter', (guard, _, match, key) => {
    return bindEnterGuard(guard, match, key, cbs, isValid)
  })
}

function bindEnterGuard (
  guard: NavigationGuard,
  match: RouteRecord,
  key: string,
  cbs: Array<Function>,
  isValid: () => boolean
): NavigationGuard {
  return function routeEnterGuard (to, from, next) {
    return guard(to, from, cb => {
      next(cb)
      if (typeof cb === 'function') {
        cbs.push(() => {
          // #750
          // if a router-view is wrapped with an out-in transition,
          // the instance may not have been registered at this time.
          // we will need to poll for registration until current route
          // is no longer valid.
          poll(cb, match.instances, key, isValid)
        })
      }
    })
  }
}

function poll (
  cb: any, // somehow flow cannot infer this is a function
  instances: Object,
  key: string,
  isValid: () => boolean
) {
  if (instances[key]) {
    cb(instances[key])
  } else if (isValid()) {
    setTimeout(() => {
      poll(cb, instances, key, isValid)
    }, 16)
  }
}

abstract.js

最简单的 history 类,适用于非浏览器环境

/* @flow */

import type Router from '../index'
import { History } from './base'

export class AbstractHistory extends History {
  index: number;
  stack: Array<Route>;

  constructor (router: Router, base: ?string) {
    super(router, base)
    this.stack = []  // history 栈
    this.index = -1  // 栈指针
  }

  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.transitionTo(location, route => {  // 调用父类中的 transitionTo 进行路径跳转
      this.stack = this.stack.slice(0, this.index + 1).concat(route)  // 入栈
      this.index++
      onComplete && onComplete(route)
    }, onAbort)
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.transitionTo(location, route => {
      this.stack = this.stack.slice(0, this.index).concat(route)  // replace 栈顶 route,不改变栈指针
      onComplete && onComplete(route)
    }, onAbort)
  }

  go (n: number) {
    const targetIndex = this.index + n
    if (targetIndex < 0 || targetIndex >= this.stack.length) {
      return
    }
    const route = this.stack[targetIndex]
    this.confirmTransition(route, () => {
      this.index = targetIndex
      this.updateRoute(route)
    })
  }

  getCurrentLocation () {
    const current = this.stack[this.stack.length - 1]
    return current ? current.fullPath : '/'
  }

  ensureURL () {
    // noop
  }
}