Vue3组件的实现原理

简介: Vue3组件的实现原理

前言:在之前的渲染器中讲了如何将VNode渲染成真实元素.虚拟DOM的Type属性决定了我们如何处理不同类型的节点,需要采用不同的处理方法来完成挂载和更新,例如处理HTML标签,只需要判断type是不是String,如果是就进行以下操作

  1. 判断需要渲染的VNode是否为空(代表卸载),直接调用unmount函数
  2. 否则就代表挂载或更新,如果是挂载调用mountElement.如果是更新调用patchElement
  3. patchElement中,此时新旧节点type相同,先更新一下props
  4. 再进入patchChildren,这里需要根据新旧子节点的类型做出不同反应.
  5. 如果新旧节点的children都是数组,需要进入核心diff.

那么如果我们现在遇到了一个组件,应该怎么处理它呢.首先我们先来看看组件里有什么.

一个组件必须包含一个渲染函数,即 render 函数,并且render的返回值应该是虚拟 DOM。换句话说,组件的渲染函数就是用来描述组件所渲染内容的接口,

   const MyComponent = {
   
            // 组件名称,可选
            name: 'MyComponent',
            // 组件的渲染函数,其返回值必须为虚拟 DOM
            render() {
   
                // 返回虚拟 DOM
                return {
   
                    type: 'div',
                    children: `我是文本内容`
                }
            }
        }

其实对于上面的组件,对应的 type 是MyComponent,也就是一个 对象
patch 中如何处理组件呢,和处理普通标签类似.如果是挂载执行 mountComponent ,更新执行 patchComponent.

先来看 mountComponent 函数,其具体实现如下所示.
其实就是拿到VNode.type,即拿到了组件里相关的所有内容,执行其中的渲染函数获得VNode,再用获取到的虚拟DOm当做patch的参数进行挂载.

 // 用来描述组件的 VNode 对象,type 属性值为组件的选项对象
        const CompVNode = {
   
            type: MyComponent
        }
        // 调用渲染器来渲染组件
        renderer.render(CompVNode, document.querySelector('#app'))

        function mountComponent(vnode, container, anchor) {
   
            // 通过 vnode 获取组件的选项对象,即 vnode.type
            const componentOptions = vnode.type
            // 获取组件的渲染函数 render
            const {
    render } = componentOptions
            // 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟 DOM
            const subTree = render()
            // 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
            patch(null, subTree, container, anchor)
        }

我们还要为组件设计自身的状态,即使用 data 函数来定义组件自身的状态,同时可以在渲染函数中通过this 访问由 data 函数返回的状态数据

const MyComponent = {
   
  name: 'MyComponent',
  // 用 data 函数来定义组件自身的状态
  data() {
   
    return {
   
      foo: 'hello world'
    }
  },
  render() {
   
    return {
   
      type: 'div',
      children: `foo 的值是: ${
     this.foo}` // 在渲染函数内使用组件状态
    }
  }
}

     function mountComponent(vnode, container, anchor) {
   
            const componentOptions = vnode.type
            const {
    render, data } = componentOptions

            // 调用 data 函数得到原始数据,并调用 reactive 函数将其包装为响应式数据
            const state = reactive(data())
            // 调用 render 函数时,将其 this 设置为 state,
            // 从而 render 函数内部可以通过 this 访问组件自身状态数据
            const subTree = render.call(state, state)
            patch(null, subTree, container, anchor)
        }

也就是将data包装为响应式,再将render函数的指向修改为proxy后的data

当组件自身状态(data)发生变化时,我们需要有能力触发组件更新,即组件的自更新
为此,我们需要将整个渲染任务包装到一个 effect 中,也就是执行mountComponent时注册一个副作用,如果代理后的data发生了改变,会再次执行这个副作用函数,也就是重新执行渲染函数

effect(() => {
    
      const subTree = render.call(state, state) 
      patch(null, subTree, container, anchor) 
})

但是如果多次修改响应式数据的值,将会导致渲染函数执行多次,这实际上是没有必要的。因此,我们需要实现一个调度器,当副作用函数需要重新执行时,我们不会立即执行它,而是将它缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行.之前讲响应式原理的时候有提过这里不再赘述

不过,上面这段代码存在缺陷。可以看到,我们在 effect 函数内调用 patch 函数完成渲染时,第一个参数总是 null。这意味着,每次更新发生时都会进行全新的挂载,而不会打补丁,这是不正确的。

与处理普通标签对比,mountElement中会创建真实DOM,并把对应的引用保留到VNode中
const el = vnode.el = createElement(vnode.type)
patchElement会将这个引用传递下来
const el = n2.el = n1.el

  1. 与之类似,在mountComponent中创建一个组件实例,也就是一个对象.用来维护着组件运行过程中的所有信息,例如组件渲染的子树(subTree)、组件是否已经被挂载、组件自身的状态 (data)、生命周期.再把组件实例设置到 vnode 上(不是组件里渲染函数返回的虚拟DOM)
  2. patchComponent中也会把旧VNode的实例挂载到新VNode上
  3. 判断实例中的isMounted,为false代表挂载,patch第一个参数是null,执行之后修改isMounted,代表已挂载.true代表更新,patch第一个参数是实例instance中的subTree
  4. 最后不管是挂载还是更新,都需要更新实例中的subTree

对于组件更新,你暂时不需要知道新VNode是怎么生成的.反正你更新了就是会给我一个新虚拟Dom

 function mountComponent(vnode, container, anchor) {
   
 const componentOptions = vnode.type
 const {
    render, data } = componentOptions

 const state = reactive(data())

 // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信

 const instance = {
   
 // 组件自身的状态数据,即 data
 state,
 // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
 isMounted: false,
 // 组件所渲染的内容,即子树(subTree)
 subTree: null
 }

 // 将组件实例设置到 vnode 上,用于后续更新
 vnode.component = instance

 effect(() => {
   
 // 调用组件的渲染函数,获得子树
 const subTree = render.call(state, state)
 // 检查组件是否已经被挂载
 if (!instance.isMounted) {
   
 // 初次挂载,调用 patch 函数第一个参数传递 null
 patch(null, subTree, container, anchor)
 // 重点:将组件实例的 isMounted 设置为 true,这样当更新发生时就不再次进行挂载操作,
 // 而是会执行更新
 instance.isMounted = true
 } else {
   
 // 当 isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可,
 // 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树,
 // 意思是,使用新的子树与上一次渲染的子树进行打补丁操作
 patch(instance.subTree, subTree, container, anchor)
 }
 // 更新组件实例的子树
 instance.subTree = subTree
 }, {
    scheduler: queueJob })
 }

在上面的实现中,组件实例的 instance.isMounted 属性可以用来区分组件的挂载和更新。因此,我们可以在合适的时机调用组件对应的生命周期钩子

function mountComponent(vnode, container, anchor) {
   
  const componentOptions = vnode.type;
  // 从组件选项对象中取得组件的生命周期函数
  const {
   
    render,
    data,
    beforeCreate,
    created,
    beforeMount,
    mounted,
    beforeUpdate,
    updated,
  } = componentOptions;

  // 在这里调用 beforeCreate 钩子
  beforeCreate && beforeCreate();

  const state = reactive(data());

  const instance = {
   
    state,
    isMounted: false,
    subTree: null,
  };
  vnode.component = instance;

  // 在这里调用 created 钩子
  created && created.call(state);

  effect(
    () => {
   
      const subTree = render.call(state, state);
      if (!instance.isMounted) {
   
        // 在这里调用 beforeMount 钩子
        beforeMount && beforeMount.call(state);
        patch(null, subTree, container, anchor);
        instance.isMounted = true;
        // 在这里调用 mounted 钩子
        mounted && mounted.call(state);
      } else {
   
        // 在这里调用 beforeUpdate 钩子
        beforeUpdate && beforeUpdate.call(state);
        patch(instance.subTree, subTree, container, anchor);
        // 在这里调用 updated 钩子
        updated && updated.call(state);
      }
      instance.subTree = subTree;
    },
    {
    scheduler: queueJob }
  );
}

可以看到两个before执行的时机是在执行patch函数之前执行的.
对于组合式的写法:从type中取得注册到组件上的生命周期函数,然后在合适的时机调用它们,这其实就是组件生命周期的实现原理.

接下来就是对组件上props的处理
对应的

<Demo title="title" :flag=flag />
VNode = {
   
  type: Demo,
  props: {
   
    title: 'title',
    flag: this.flag
  }
}

拿父向子通信来说,将数据传递给子组件,在子组件中要声明props选项,父组件传递的数据不在props选项中就会流到attrs.
VNode.props也就是父组件传给子组件的数据,VNode.type.props是子组件的props中声明的数据,由此我们很容易又可以得到attrs.
并且把获取到的props变成响应式存放到实例instance

处理完 props 数据后,我们再来讨论关于 props 数据变化的问题。

props 本质上是父组件的数据,当 props 发生变化时,父组件会进行自更新。同时也会造成子组件的被动更新,即调用 patchComponent 函数,它的作用是修改实例instance中的props

  1. 检测子组件是否真的需要更新,因为子组件的 props 可能是不变的
  2. 如果需要更新,则更新子组件的 props、slots 等内容。
function patchComponent(n1, n2, anchor) {
   
  // 获取组件实例,即 n1.component,同时让新的组件虚拟节点 n2.component也指向组件实例
  const instance = (n2.component = n1.component)
  // 获取当前的 props 数据
  const {
    props } = instance
  // 调用 hasPropsChanged 检测为子组件传递的 props 是否发生变化,如果没有变化,则不需要更新
  if (hasPropsChanged(n1.props, n2.props)) {
   
    // 调用 resolveProps 函数重新获取 props 数据
    const [nextProps] = resolveProps(n2.type.props, n2.props)
    // 更新 props
    for (const k in nextProps) {
   
      props[k] = nextProps[k]
    }
    // 删除不存在的 props
    for (const k in props) {
   
      if (!(k in nextProps)) delete props[k]
    }
  }
}

function hasPropsChanged(prevProps, nextProps) {
   
  const nextKeys = Object.keys(nextProps)
  // 如果新旧 props 的数量变了,则说明有变化
  if (nextKeys.length !== Object.keys(prevProps).length) {
   
    return true
  }
  // 只有
  for (let i = 0; i < nextKeys.length; i++) {
   
    const key = nextKeys[i]
    // 有不相等的 props,则说明有变化
    if (nextProps[key] !== prevProps[key]) return true
  }
  return false
}

这里要注意一下,子组件的更新是由本身数据发生改变更新还是父组件引起的更新,处理方式有些不同

如果是主动更新,会触发mountComponent注册的副作用函数,这里不再赘述.
如果是被动更新触发的是patchComponent,会修改instance中的props,它也是响应式的数据,所以也会触发mountComponent注册的副作用函数

由于 props 数据与组件自身的状态数据都需要暴露到渲染函数中,并使得渲染函数能够通过 this 访问它们,因此我们需要封装一个渲染上下文对象

我们用proxy拦截一下之前创建的实例instance,里面含有state以及props,这样可以监听到对状态和props的读取和设置.

  1. 读取时,先读取组件自身状态,如果没有读取props.
  2. 设置时,只能设置组件自身状态,不能改变props.
  3. 生命周期函数,渲染函数调用时要绑定渲染上下文对象,也就是把它当成this.

并且使用proxy代理我们可以控制取消修改

function mountComponent(vnode, container, anchor) {
   
  // 省略部分代码

  const instance = {
   
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null
  }

  vnode.component = instance

  // 创建渲染上下文对象,本质上是组件实例的代理
  const renderContext = new Proxy(instance, {
   
    get(t, k, r) {
   
      // 取得组件自身状态与 props 数据
      const {
    state, props } = t
      // 先尝试读取自身状态数据
      if (state && k in state) {
   
        return state[k]
      } else if (k in props) {
   
        // 如果组件自身没有该数据,则尝试从props 中读取
        return props[k]
      } else {
   
        console.error('不存在')
      }
    },
    set(t, k, v, r) {
   
      const {
    state, props } = t
      if (state && k in state) {
   
        state[k] = v
      } else if (k in props) {
   
        console.warn(`Attempting to mutate prop "${
     k}". Propsare readonly.`)
      } else {
   
        console.error('不存在')
      }
    }
  })

  // 生命周期函数调用时要绑定渲染上下文对象
  created && created.call(renderContext)

  // 省略部分代码
}

上面只是处理了选项式的写法,还有组合式的写法需要处理,这就不得不介绍setup函数了

举个例子:之前写选项式数据是从type中解构出来的,但如果是组合式的写法数据是从setup函数的返回值中获取,还有生命周期也不再是从type中解构出来的,而是利用onMounted等钩子在setup中注册生命周期函数.

在组件的整个生命周期中, setup 函数只会在被挂载时执行一次,它的返回值可以有两种情况

1.返回一个函数,该函数将作为组件的 render 函数

2.返回一个对象,该对象中包含的数据将暴露给模板使用

另外setup 函数接收两个参数。第一个参数是 props 数据对 象,第二个参数也是一个对象,通常称为 setupContext,其中保存着与组件接口相关的数据和方法,其中有

slots:组件接收到的插槽
emit:一个函数,用来发射自定义事件。
attrs:当为组件传递 props 时,那些没有显式地声明为 props 的属性会存储到 attrs 对象中。

实现:

  1. 先从虚拟DOM的type属性中解构出setup,props.又可以得到attrs.
  2. 调用setup 函数,将只读版本的 props 作为第一个参数传递,第二个参数是一个对象里面包含attrs emit slot. 可以理解为我们预先知道会给我们传递什么参数,函数体内也就可以使用参数,最后调用setup时他真的传递里参数过来.
  3. 如果 setup 函数的返回值是函数,则将其作为渲染函数
  4. 如果 setup 的返回值不是函数,则作为数据状态赋值给 setupState
  5. 最后还要考虑return的数据怎么在render函数中使用,也就是要用到之前的渲染上下文
function mountComponent(vnode, container, anchor) {
   
  const componentOptions = vnode.type;
  // 从组件选项中取出 setup 函数
  let {
    render, data, setup /* 省略其他选项 */ } = componentOptions;

  beforeCreate && beforeCreate();

  const state = data ? reactive(data()) : null;
  const [props, attrs] = resolveProps(propsOption, vnode.props);

  const instance = {
   
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
  };

  // setupContext,由于我们还没有讲解 emit 和 slots,所以暂时只需要attrs;
  const setupContext = {
    attrs };
  // 调用 setup 函数,将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值,
  // 将 setupContext 作为第二个参数传递
  const setupResult = setup(shallowReadonly(instance.props), tupContext);
  // setupState 用来存储由 setup 返回的数据
  let setupState = null;
  // 如果 setup 函数的返回值是函数,则将其作为渲染函数
  if (typeof setupResult === "function") {
   
    // 报告冲突
    if (render) console.error("setup 函数返回渲染函数,render 选项将被忽略");
    // 将 setupResult 作为渲染函数
    render = setupResult;
  } else {
   
    // 如果 setup 的返回值不是函数,则作为数据状态赋值给 setupState
    setupState = setupResult;
  }

  vnode.component = instance;

  const renderContext = new Proxy(instance, {
   
    get(t, k, r) {
   
      const {
    state, props } = t;
      if (state && k in state) {
   
        return state[k];
      } else if (k in props) {
   
        return props[k];
      } else if (setupState && k in setupState) {
   
        // 渲染上下文需要增加对 setupState 的支持
        return setupState[k];
      } else {
   
        console.error("不存在");
      }
    },
    set(t, k, v, r) {
   
      const {
    state, props } = t;
      if (state && k in state) {
   
        state[k] = v;
      } else if (k in props) {
   
        console.warn(`Attempting to mutate prop "${
     k}". Propsare readonly.`);
      } else if (setupState && k in setupState) {
   
        // 渲染上下文需要增加对 setupState 的支持
        setupState[k] = v;
      } else {
   
        console.error("不存在");
      }
    },
  });

  // 省略部分代码
}

补充一下setup返回的数据不是存放在instance的data上,而是setupState,也就是setup执行的返回结果

接下来是emit的实现

父组件传递的props中不仅有数据还有方法.所以我们在mountComponent函数中注册一个emit函数
它接收一个方法名和参数,我们只需要根据这个方法名在传递的props中找到对应的函数,并把它执行即可.之后将emit函数添加到 setupContext

function mountComponent(vnode, container, anchor) {
   
  // 省略部分代码

  const instance = {
   
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
  };

  // 定义 emit 函数,它接收两个参数
  // event: 事件名称
  // payload: 传递给事件处理函数的参数
  function emit(event, ...payload) {
   
    // 根据约定对事件名称进行处理,例如 change --> onChange
    const eventName = `on${
     event[0].toUpperCase() + ent.slice(1)}`;
    // 根据处理后的事件名称去 props 中寻找对应的事件处理函数
    const handler = instance.props[eventName];
    if (handler) {
   
      // 调用事件处理函数并传递参数
      handler(...payload);
    } else {
   
      console.error("事件不存在");
    }
  }

  // 将 emit 函数添加到 setupContext 中,用户可以通过 setupContext 取得 emit 函数
  const setupContext = {
    attrs, emit };

  // 省略部分代码
}

可以看到是在 instance.props 查找方法,但是之前说了对于没声明的props,都会放在attrs.这也很容易解决:

  • 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props 数据中,而不是添加到 attrs 中

插槽的实现
这里只介绍具名插槽,先看如何使用

  1. 在子组件中声明插槽中对应节点的存放位置
    <template>
    <header>
     <slot name="header" />
    </header>
    <div>
     <slot name="body" />
    </div>
    <footer>
     <slot name="footer" />
    </footer>
    </template>;
    
  2. 把要传递的节点放在组件内,当成children
    <MyComponent>
     <template #header>
         <h1>我是标题</h1>
     </template>
     <template #body>
         <section>我是内容</section>
     </template>
     <template #footer>
         <p>我是注脚</p>
     </template>
    </MyComponent>
    
  3. 父组件的VNode
{
   
 type: MyComponent,
 // 组件的 children 会被编译成一个对象
 children: {
   
 header() {
   
 return {
    type: 'h1', children: '我是标题' }
 },
 body() {
   
 return {
    type: 'section', children: '我是内容' }
 },
 footer() {
   
 return {
    type: 'p', children: '我是注脚' }
 }
 }
 }

4.子组件的VNode

 // MyComponent 组件模板的编译结果
 function render() {
   
 return [
 {
   
 type: 'header',
 children: [this.$slots.header()]
 },
 {
   
 type: 'body',
 children: [this.$slots.body()]
 },
 {
   
 type: 'footer',
 children: [this.$slots.footer()]
 }
 ]
 }

所以VNode中的children即是slot,在把它作为setup函数的参数,这样在setup函数中就能获取到插槽了.

function mountComponent(vnode, container, anchor) {
   
// 省略部分代码

// 直接使用编译好的 vnode.children 对象作为 slots 对象即可
const slots = vnode.children || {
   }

// 将 slots 对象添加到 setupContext 中
const setupContext = {
    attrs, emit, slots }

}

接下来要支持在生命周期或者渲染函数中使用this.$slot可以拿到插槽内容,先将从type中解构出的slot挂载到instance
使用this.$slot,this指向已经绑定为renderContext,它是对instance的代理.当对renderContext进行读取,判断key是否为$slot

function mountComponent(vnode, container, anchor) {
   
  // 省略部分代码

  const slots = vnode.children || {
   };

  const instance = {
   
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
    // 将插槽添加到组件实例上
    slots,
  };

  // 省略部分代码

  const renderContext = new Proxy(instance, {
   
    get(t, k, r) {
   
      const {
    state, props, slots } = t;
      // 当 k 的值为 $slots 时,直接返回组件实例上的 slots
      if (k === "$slots") return slots;

      // 省略部分代码
    },
    set(t, k, v, r) {
   
      // 省略部分代码
    },
  });

  // 省略部分代码
}

注册生命周期函数,例如onMounted、onUpdated

使用方法:

setup() {
    
      onMounted(() => {
   console.log('mounted') 
      onMounted(() => {
   console.log('mounted') 
   })
}

有一个问题:例如我们在不同组件调用onMounted注册生命周期函数,怎么保证能正确的注册到对应的组件呢
解决方法也很简单:定义一个全局变量currentInstance,在setup执行之前,将currentInstance设置为当前组件的实例,执行之后设为null.

并且不同于选项式的写法,执行生命周期函数,是从type中解构获得.在组合式中,要将onMounted注册的生命周期函数保存到instance中,它是一个数组形式,因为可以多次调用onMounted.

function mountComponent(vnode, container, anchor) {
   
  // 省略部分代码

  const instance = {
   
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
    slots,
    // 在组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期钩子函数
    mounted: [],
  };

  // 省略部分代码

  // setup
  const setupContext = {
    attrs, emit, slots };

  // 在调用 setup 函数之前,设置当前组件实例
  setCurrentInstance(instance);
  // 执行 setup 函数
  const setupResult = setup(shallowReadonly(instance.props), tupContext);
  // 在 setup 函数执行完毕之后,重置当前组件实例
  setCurrentInstance(null);

  // 省略部分代码
}

对于onMounted函数本身的实现也很简单,currentInstance存放这当前要注册生命周期函数的实例,只需要 currentInstance.mounted.push(fn),这样就把生命周期保存在实例中了.

function onMounted(fn) {
   
    if (currentInstance) {
   
        // 将生命周期函数添加到 instance.mounted 数组中
        currentInstance.mounted.push(fn)
    } else {
   
        console.error('onMounted 函数只能在 setup 中调用')
    }
}

因为在执行setup中,也会执行onMounted.此时实例上已经有注册的生命周期了.再执行mountComponent中注册的副作用函数.

function mountComponent(vnode, container, anchor) {
   
  // 省略部分代码

  effect(
    () => {
   
      const subTree = render.call(renderContext, renderContext);
      if (!instance.isMounted) {
   
        // 省略部分代码

        // 遍历 instance.mounted 数组并逐个执行即可
        instance.mounted &&
          instance.mounted.forEach((hook) => ok.call(renderContext));
      } else {
   
        // 省略部分代码
      }
      instance.subTree = subTree;
    },
    {
   
      scheduler: queueJob,
    }
  );
}

对于除 mounted 以外的生命周期钩子函数,其原理同上

相关文章
|
5天前
|
JavaScript 数据库
ant design vue日期组件怎么清空 取消默认当天日期
ant design vue日期组件怎么清空 取消默认当天日期
|
4天前
|
JavaScript 前端开发 CDN
vue3速览
vue3速览
14 0
|
4天前
|
设计模式 JavaScript 前端开发
Vue3报错Property “xxx“ was accessed during render but is not defined on instance
Vue3报错Property “xxx“ was accessed during render but is not defined on instance
|
4天前
|
JavaScript API
Vue3 官方文档速通(中)
Vue3 官方文档速通(中)
20 0
|
4天前
|
缓存 JavaScript 前端开发
Vue3 官方文档速通(上)
Vue3 官方文档速通(上)
26 0
|
4天前
Vue3+Vite+Pinia+Naive后台管理系统搭建之五:Pinia 状态管理
Vue3+Vite+Pinia+Naive后台管理系统搭建之五:Pinia 状态管理
8 1
|
4天前
Vue3+Vite+Pinia+Naive后台管理系统搭建之三:vue-router 的安装和使用
Vue3+Vite+Pinia+Naive后台管理系统搭建之三:vue-router 的安装和使用
10 0
|
4天前
Vue3+Vite+Pinia+Naive后台管理系统搭建之二:scss 的安装和使用
Vue3+Vite+Pinia+Naive后台管理系统搭建之二:scss 的安装和使用
8 0
|
4天前
|
JavaScript 前端开发 API
Vue3 系列:从0开始学习vue3.0
Vue3 系列:从0开始学习vue3.0
10 1
|
4天前
|
网络架构
Vue3 系列:vue-router
Vue3 系列:vue-router
9 2