Skip to content

Latest commit

 

History

History
1130 lines (1075 loc) · 41.6 KB

vue-router4源码分析.md

File metadata and controls

1130 lines (1075 loc) · 41.6 KB

什么是前端路由

在spa出现之前,页面的跳转(导航)都是通过服务端控制的;用户输入一个url,浏览器向服务端发起请求,服务端匹配映射表,返回对应的资源,页面的跳转存在一个明显白屏过程; spa出现后,为了更好的体验,不再让服务端控制跳转,通过前端路由自由控制组件的渲染,来模拟页面跳转。 要实现一个前端路由,需要三个部分:

  1. 路由映射表:一个能表达url和组件关系的映射表,可以使用Map、对象字面量来实现
  2. 匹配器:负责在访问url时,进行匹配,找出对应的组件
  3. 历史记录栈:浏览器平台,已经原生支持,无需实现,直接调用接口

三者协作关系如下: router

路由系统的实现

我们先脱离vue,来实现一套基础的路由系统,它包含的功能有:

  • 同时支持 history 模式和 hash 模式
  • 暴露两个路由跳转的 API:pushreplace,路由跳转时修改导航信息和 history 记录。
  • 当点击浏览器的前进/后退按钮时,可以监听到路由状态的改变,并打印出对应的 historyState(包含上一个路由、下一个路由、当前路由、页面滚动位置等信息)

history模式

原理:history模式是基于 HTML5 新增的 pushState(state,title,url)replaceState(state,title,url) 两个 API 修改历史栈,通过浏览器的 popState 事件监听历史栈的改变,然后进行页面更新。 history 模式的特点:

  1. 路径漂亮,没有锚点 #
  2. 修改 url,会向服务器发送请求,如果资源不存在会出现 404。解决方案:在SPA应用中,在服务端永远只返回一个页面 index.html,在前端根据路径(如果路径不存在时)重新跳转到 404 页面。

hash 模式

原理:修改 window.location.hash 改变路由的 hash 值,然后通过 hashchange 监听 # 后面的内容的变化来进行页面更新。目前浏览器都支持了 popstate,也可以使用该 API 实现 hash 路由模式。 hash 模式的特点:

  1. 改变 hash 值,浏览器不会重新加载页面
  2. 当刷新页面时,hash 不会传给服务器

也就是说 http://localhost/#ahttp://localhost/ 这两个路由其实都是去请求 http://localhost 这个页面的内容,至于为什么它们可以渲染出不同的页面,这个是前端自己来判断的。所以 hash 模式 不会产生 404,也不能用于服务端渲染

前置工具函数

  1. 自定义路由状态state
function buildState(
    back,
    current,
    forward,
    replace = false,
    computedScroll = false
) {
    return {
        back,
        current,
        forward,
        replace,
        // 缓存scroll之后,可以利用 window.scrollBy(pageXOffset, pageYOffset); 将页面滚动到原来的位置
        scroll: computedScroll
            ? { left: window.pageXOffset, top: window.pageYOffset }
            : null,
        position: window.history.length - 1,
    };
}
  1. 获取当前路由路径
// history模式下,base为'';hash模式下,base 为 '#'
function createCurrentLocation(base) {
    const { pathname, search, hash } = window.location;
    if (base.indexOf("#") > -1) {
        // 如果是hash路由,createCurrentLocation 返回 # 后面的部分
        return base.slice(1) || "/";
    }
    return pathname + hash + search;
}
const currentLocation = {
    value: createCurrentLocation(base),
};
  1. 当前路由状态的初始化
const historyState = {
    value: window.history.state,
};
  1. 页面跳转并修改状态
function changeLocation(to, state, replace = false) {
    const url = base.indexOf("#") > -1 ? base + to : to;
    // window.history.replaceState/pushState 传入的 state 会修改 window.history.state
    window.history[replace ? "replaceState" : "pushState"](
        state,
        null,
        url
    );
    // 手动修改 historyState(保存的是当前的state)
    historyState.value = state;
}

初始化state

当初始化页面时,可以使用buildState创建自定义的state,然后利用replaceState初始化window.history.state

function createCurrentLocation(base) {
    const historyState = {
        value: window.history.state,
    };
    // 初始时 historyState.value 为 null
    if (!historyState.value) {
        changeLocation(
            currentLocation.value,
            // 通过build自定义state
            buildState(null, currentLocation.value, null, true),
            true
        );
    }
}

push 的实现

  1. 创建currentState:添加scrollforward属性,并与当前的historyState合并。
  2. 先执行changeLocation(currentState.current, currentState, true),其中第三个参数传入true,即调用 history.replaceState 实现页面刷新和状态修改。目的是实现在vue中,通过监听状态改变,触发跳转前的路由钩子。
  3. 然后修改 state 中的positionscroll,并与 push 传入的 data 合并生成新的 state
  4. 最后调用 changeLocation 执行 history.pushState 实现真正的页面跳转
function push(to, data) {
    const currentState = Object.assign({}, historyState.value, {
        forward: to,
        scroll: { left: window.pageXOffset, top: window.pageYOffset },
    });
    /**
     * 下方location的第一个参数 to 为当前路径 currentState.current;第三个参数为 true,即使用 history.replaceState 替换掉当前路由;
     * 所以这里本质并没有跳转,【只是更新了当前状态】,方便后续在vue中可以详细监听到状态的变化
     * 这一步的目的是为了实现在将要跳转前,触发生命周期钩子
     */
    changeLocation(currentState.current, currentState, true);

    // 创建 history.pushState 需要传入的 state,并实现真正的跳转
    const state = Object.assign(
        {},
        buildState(currentLocation.value, to, null, false),
        { position: currentState.position + 1 },
        data
    );
    console.log("push跳转时传入的state", state);
    changeLocation(to, state, false); // 真正的跳转
    currentLocation.value = to; // 修改 currentLocation 变量,后续在listener中会用到
}

replace 的实现

  1. 通过 buildState 创建 state,并与 replace 传入的 data 合并生成新的 state
  2. 通过 changeLocation(to, state, true) 执行 history.replaceState 实现页面替换和状态修改
function replace(to, data) {
    // 创建 history.replaceState 需要传入的 state,并实现真正的跳转
    const state = Object.assign(
        {},
        buildState(
            historyState.value.back,
            to,
            historyState.value.forward,
            true
        ),
        data
    );
    console.log("replace跳转时传入的state", state);
    changeLocation(to, state, true); // 跳转并更新history的状态
    currentLocation.value = to;
}

监听浏览器的前进/后退

history.state 发生变化的时候,会触发 popstate 事件,执行其回调; 我们可以创建一个任务队列,当触发 popstate 事件时,执行所有的任务。

function useHistoryListeners(base, historyState, currentLocation) {
    let listeners = [];
    // popstate 回调函数中的 state 是已经前进或后退完毕后的最新状态
    const popStateHandler = ({ state }) => {
        const to = createCurrentLocation(base);
        const from = currentLocation.value;
        const fromState = historyState.value;

        // 点击前进/后退按钮后,修改当前的路径和状态
        currentLocation.value = to;
        historyState.value = state; // state 可能为null

        let isBack = state.position - fromState.position < 0;
        // 监听到前进/后退,执行所有的listener
        listeners.forEach((listener) => {
            listener(to, from, { isBack });
        });
    };
    window.addEventListener("popstate", popStateHandler); // 监听浏览器的前进、后退
    function listen(cb) {
        listeners.push(cb);
    }
    return {
        listen,
    };
}

const routerHistory = createWebHashHistory();
routerHistory.listen((to, from, { isBack }) => {
    console.log(
        "to:",
        to,
        "         from:",
        from,
        "         是后退吗?",
        isBack
    );
});

实现 history 模式和hash模式

当调用 createWebHistory 创建路由时,可以传入base参数;history 模式时base值为 ''hash 模式时,base 值为 #。然后根据 base 参数,创建不同的 locationurl 即可

function createCurrentLocation(base) {
    const { pathname, search, hash } = window.location;
    if (base.indexOf("#") > -1) {
        // 如果是hash路由,createCurrentLocation 返回 # 后面的部分
        return base.slice(1) || "/";
    }
    return pathname + hash + search;
}
function changeLocation(to, state, replace = false) {
    const url = base.indexOf("#") > -1 ? base + to : to;
    window.history[replace ? "replaceState" : "pushState"](
        state,
        null,
        url
    );
    historyState.value = state;
}

完整代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button onclick="routerHistory.push('/')">首页</button>
    <button onclick="routerHistory.push('/about')">关于</button>
    <button onclick="routerHistory.replace('/xxx')">替换当前路由</button>
    <script>
      /**
       * 1. 读取当前的路径
       * 2. 读取当前路径下的状态
       * 3. 切换路径的方法:push、replace
       * 4. 实现路由监听,如果路径变化,需要通知用户
       */
      function buildState(
        back,
        current,
        forward,
        replace = false,
        computedScroll = false
      ) {
        return {
          back,
          current,
          forward,
          replace,
          // 缓存scroll之后,可以利用 window.scrollBy(pageXOffset, pageYOffset); 将页面滚动到原来的位置
          scroll: computedScroll
            ? { left: window.pageXOffset, top: window.pageYOffset }
            : null,
          position: window.history.length - 1,
        };
      }

      function createCurrentLocation(base) {
        const { pathname, search, hash } = window.location;

        if (base.indexOf("#") > -1) {
          // 如果是hash路由,createCurrentLocation 返回 # 后面的部分
          return base.slice(1) || "/";
        }

        return pathname + hash + search;
      }

      function useHistoryStateNavgation(base) {
        // 路由路径
        const currentLocation = {
          value: createCurrentLocation(base),
        };
        // 路由状态
        const historyState = {
          value: window.history.state,
        };
        // 第一次打开页面,window.history.state 为 null,就自己维护一个状态(后退的路径、当前的路径、要去的路径、是push跳转还是repalce跳转、跳转后的滚动条位置),并通过 history.replaceState 替换掉当前的状态
        if (!historyState.value) {
          changeLocation(
            currentLocation.value,
            buildState(null, currentLocation.value, null, true),
            true
          );
        }

        // 跳转并更新状态
        function changeLocation(to, state, replace = false) {
          const url = base.indexOf("#") > -1 ? base + to : to;

          // window.history.replaceState/pushState 传入的 state 会修改掉 window.history.state,但是 historyState.value 需要手动修改为 state
          window.history[replace ? "replaceState" : "pushState"](
            state,
            null,
            url
          );
          historyState.value = state;
        }
        function push(to, data) {
          const currentState = Object.assign({}, historyState.value, {
            forward: to,
            scroll: { left: window.pageXOffset, top: window.pageYOffset },
          });
          /**
           * 下方location的第一个参数 to 为 当前路径 currentState.current;第三个参数为 true,即使用 history.replaceState 替换掉当前路由;
           * 所以这里本质并没有跳转,【只是更新了当前状态】,后续在vue中我们可以详细监听到状态的变化
           * 这一步的目的是为了实现在将要跳转前,触发生命周期钩子
           */
          changeLocation(currentState.current, currentState, true);

          // 创建 history.pushState 需要传入的 state,并实现真正的跳转
          const state = Object.assign(
            {},
            buildState(currentLocation.value, to, null, false),
            { position: currentState.position + 1 },
            data
          );
          console.log("push跳转时传入的state", state);
          changeLocation(to, state, false); // 真正的跳转
          currentLocation.value = to;
        }
        function replace(to, data) {
          // 创建 history.replaceState 需要传入的 state,并实现真正的跳转
          const state = Object.assign(
            {},
            buildState(
              historyState.value.back,
              to,
              historyState.value.forward,
              true
            ),
            data
          );
          console.log("replace跳转时传入的state", state);
          changeLocation(to, state, true); // 跳转并更新history的状态
          currentLocation.value = to; // 替换后,需要将路径变为现在的路径
        }

        return {
          location: currentLocation,
          state: historyState,
          push,
          replace,
        };
      }

      // 前进、后退的时候,要更新 historyState 和 currentLocation
      function useHistoryListeners(base, historyState, currentLocation) {
        let listeners = [];
        // popstate 回调函数中的 state 是已经前进或后退完毕后的最新状态
        const popStateHandler = ({ state }) => {
          const to = createCurrentLocation(base);
          const from = currentLocation.value;
          const fromState = historyState.value;

          // 点击前进/后退按钮后,修改当前的路径和状态
          currentLocation.value = to;
          historyState.value = state; // state 可能为null

          let isBack = state.position - fromState.position < 0;
          // 监听到前进/后退,执行所有的listener
          listeners.forEach((listener) => {
            listener(to, from, { isBack });
          });
        };
        window.addEventListener("popstate", popStateHandler); // 监听浏览器的前进、后退
        function listen(cb) {
          listeners.push(cb);
        }
        return {
          listen,
        };
      }

      function createWebHistory(base = "") {
        const historyNavgation = useHistoryStateNavgation(base);
        const historyListeners = useHistoryListeners(
          base,
          historyNavgation.state,
          historyNavgation.location
        );
        const routerHistory = Object.assign(
          {},
          historyNavgation,
          historyListeners
        );

        Object.defineProperty(routerHistory, "location", {
          get: () => historyNavgation.location.value,
        });
        Object.defineProperty(routerHistory, "state", {
          get: () => historyNavgation.state.value,
        });
        return routerHistory;
      }

      function createWebHashHistory() {
        return createWebHistory("#");
      }

      // history 模式路由系统
      // const routerHistory = createWebHistory();

      // hash 模式路由系统
      const routerHistory = createWebHashHistory();

      routerHistory.listen((to, from, { isBack }) => {
        console.log(
          "to:",
          to,
          "         from:",
          from,
          "         是后退吗?",
          isBack
        );
      });
    </script>
  </body>
</html>

vue-router4 基本结构

vue-router 的使用

创建:

import {
  createRouter,
  createWebHistory,
  createWebHashHistory,
} from "@/vue-router";
const routes = [
  {
    path: "/",
    name: "Home",
    component: ()=>import("../views/Home.vue"),
    children: [
      {
        path: "a",
        component: { render: () => <h1>a页面</h1> },
      },
      {
        path: "b",
        component: { render: () => <h1>b页面</h1> },
      },
    ],
  },
  {
    path: "/about",
    name: "About",
    component: () =>
      import("../views/About.vue"),
  },
];
const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  // history: createWebHashHistory(process.env.BASE_URL),
  routes,
});
export default router;

引入:

import router from "./router";
createApp(App).use(router).mount("#app");

实现代码基本结构

在src目录下创建一个 vue-router,即自己实现的 vue-router,基本目录结构如下:

│  └─ vue-router
│     ├─ history  —— 路由系统
│     │  ├─ hash.js
│     │  └─ html5.js
│     ├─ matcher  —— 匹配器
│     │  ├─ inedx.js
│     └─ index.js
│     ├─ router-link  —— RouterLink组件
│     ├─ router-view  —— RouterView组件
// vue-router/history/html5.js
function useHistoryStateNavgation(base) {
    // ...略
}
function useHistoryListeners(base,historyState,currentLocation) {
    // ...略
}
export function createWebHistory(base = "") {
  const historyNavgation = useHistoryStateNavgation(base);
  const historyListeners = useHistoryListeners(
    base,
    historyNavgation.state,
    historyNavgation.location
  );
  const routerHistory = Object.assign({}, historyNavgation, historyListeners);
  // ...略
  return routerHistory;
}
// vue-router/history/hash.js
import { createWebHistory } from "./html5";
export function createWebHashHistory() {
  return createWebHistory("#");
}

vue-router 使用 createRouter 创建路由,通过插件的形式引入到项目中。所以我们除了上文实现的createWebHistorycreateWebHashHistory外,还要实现createRouter方法; createRouter 返回一个router对象,其内部需要实现install方法,以及注册全局的 RouterLink 组件和 RouterView 组件:

// vue-router/index.js
import { createWebHashHistory } from "./history/hash";
import { createWebHistory } from "./history/html5";
function createRouter(options) {
  const routerHistory = options.history;
  /**
   * 用户传递的路由格式是深层嵌套的,需要【格式化路由配置,给它拍平】
   * 当用户访问 /a 的时候,需要渲染其父组件 Home 以及 子组件A (对应两个 router-view 出口)
   */
  const matcher = createRouterMatcher(options.routes); // 格式化routes:拍平
  const router = {
    // 路由的核心:路由切换,重新渲染
    install: (app) => {
      console.log("安装路由");
      // 注册全局组件 router-link
      app.component("RouterLink", {
        setup: (props, { slots }) => {
          return () => <a>{slots.default && slots.default()}</a>;
        },
      });
      // 注册全局组件 router-view
      app.component("RouterView", {
        setup: (props, { slots }) => {
          return () => <div>router-view</div>;
        },
      });

      // TODO:解析路径;RouterLink、RouterView实现;页面的钩子;
    },
  };
  return router;
}
export { createWebHashHistory, createWebHistory, createRouter };

createRouterMatcher

当我们访问 url 时,需要快速准确地匹配到组件;而用户传递进来的路由配置是深层嵌套的,我们需要给他拍平来符合我们的要求。 目标格式如下:

matchers: [
    {
        children: [],
        parent: {
            children: [
                {path: '/a', record: {}, parent: {}, children: []},
                {path: '/b', record: {}, parent: {}, children: []}
            ],
            parent: undefined,
            path: '/',
            record: {               // 用户传入的route
                beforeEnter: undefined,
                path: '',
                children: [
                    {path: 'a', component: {}},
                    {path: 'b', component: {}}
                ],
                components: {
                    default: {...}
                },
                meta: {},
                name: 'Home',
                path: '/'
            }
        },
        path: '/a',
        record: {
            beforeEnter: undefined,
            children: [],
            components: {default: {}},
            meta: {},
            name: undefined,
            path: "/a"
        }
    },
    {path: '/b', record: {}, parent: {}, children: Array(0)},
    {path: '/', record: {}, parent: undefined, children: Array(2)},
    {path: '/about', record: {}, parent: undefined, children: Array(0)}
]

createRouterMatcher 主要是将路由转化成一维数组,数组元素主要包含path(路径)、record(路由信息)、parent(父级路由)、children(子路由)等属性。 具体实现如下:

/**
 * 创建record,格式化用户参数
 */
function normalizeRouteRecord(route) {
  const record = {
    path: route.path,
    meta: route.meta || {},
    beforeEnter: route.beforeEnter,
    name: route.name,
    // vue-router 中 route 也支持 components,所以合理设置components,default属性为 route.component
    components: {
      default: route.component,
    },
    children: route.children || [],
  };

  return record;
}
/**
 * 创建 matcher,并设置父子关系
 */
function createRouteRecordMatcher(record, parent) {
  const matcher = {
    path: record.path,
    record,
    // 1. 设置当前record的parent
    parent,
    children: [],
  };
  // 2. 给parent添加children
  if (parent) {
    parent.children.push(matcher);
  }
  return matcher;
}
/**
 * 创建目标数据格式:拍平、有父子关系
 */
function createRouterMatcher(routes) {
  const matchers = []; // 闭包
  function addRoute(route, parent) {
    let normalizedRecord = normalizeRouteRecord(route);
    if (parent) {
      normalizedRecord.path = parent.path + (parent.path === "/" ? "" : "/") + normalizedRecord.path;
    }
    // 创建 matcher,并设置父子关系
    const matcher = createRouteRecordMatcher(normalizedRecord, parent);
    // 递归处理children
    if ("children" in normalizedRecord) {
      let children = normalizedRecord.children;
      for (let i = 0; i < children.length; i++) {
        // 遍历 children 时的parent 就是 matcher
        addRoute(children[i], matcher);
      }
    }
    matchers.push(matcher);
  }

  routes.forEach((route) => addRoute(route));
  console.log("目标数据", matchers);

  return {
    // 动态添加路由【官方API的实现方式】
    addRoute,
  };
}

vue-router4 中的响应式原理

定义$router$route 以及 注入全局的 router 和 ReactiveRoute

实现 ReactiveRoute 响应式的目的:

  1. 每次修改 currentRoute 时,ReactiveRoute 都能响应式更新
  2. RouterView 组件中,当路由变化时,获取到响应式的ReactiveRoute.matched

为了保证注入到全局的 ReactiveRoute 经过解构的每一属性都具有响应式,实现步骤:

  1. 先使用 shallowRef 处理 START_LOCATION_NORMALIZED,处理结果为 currentRoute
  2. 然后遍历 currentRoute.value,使用 computed 处理每一个属性,使每个属性具有响应式,然后将每个属性都赋值给 ReactiveRoute 对象。但是还有一个缺点就是取 ReactiveRoute 属性值的时候,需要增加一个 .value
  3. 最后使用 reactive 处理 ReactiveRoute,目的是为了在取 ReactiveRoute 属性值的时候不需要通过 .value 获取

不直接使用 reactive 处理 START_LOCATION_NORMALIZED 的原因是使用 reactive 处理的对象,解构后的属性不具有响应式。

// 初始化路由系统中的默认参数
const START_LOCATION_NORMALIZED = {
  path: "/",
  params: {}, // 路径参数
  query: {},
  matched: [], // 路径的匹配结果
};
function createRouter(options) {
  let currentRoute = shallowRef(START_LOCATION_NORMALIZED);
  const router = {
    push,
    replace,
    install(app) {
      const router = this;  // this指向router
      // 1. 定义全局的 $router 和 $route
      app.config.globalProperties.$router = router;
      Object.defineProperty(app.config.globalProperties, "$route", {
        enumerable: true,
        get: () => unref(currentRoute),
      });

      // 2. 注入 router 和 ReactiveRoute
      const ReactiveRoute = {};
      for (const key in START_LOCATION_NORMALIZED) {
        // 使用 computed 使 currentRoute.value 中的每一项具备响应式
        ReactiveRoute[key] = computed(() => currentRoute.value[key]);
      }
      app.provide("router", router); // 暴露router ——> useRouter 本质上就是 inject('router')
      // 经过computed处理后的数据需要通过 .value 属性进行取值。如果再使用reactive对ref数据进行包裹,则可以直接取值,而不需要通过 ReactiveRoute[key].value 的方式取值
      app.provide("route location", reactive(ReactiveRoute));
    }
  }
}

初始化 currentRoute、实现push、注册listen事件

初始化 currentRoute

  1. 初始状态为 let currentRoute = shallowRef(START_LOCATION_NORMALIZED);,所以当两者相等时即为页面初始化;
  2. 当页面初始化时,注册listen事件监听浏览器的前进后退,以及初始化 currentRoute
  3. 初始化 currentRoute 的值通过 matcher.resolve({ path: to }) 获取,本质是在 matchers 中寻找到 path 对应的 record,然后使用 while 循环将它所有的组件 recode 全部获取到,合成一个数组。

push 的实现:

  1. 通过 matcher.resolve({ path: to }) 获取到目标路径的 matchedpath,赋值为 targetLocation
  2. 更新 currentRoute,赋值为 targetLocation
  3. 通过 routerHistory.push(to.path); 进行页面跳转

注册listen,监听浏览器的前进、后退:

  1. 当页面初始化时注册 listen 事件,通过标识符确保只会注册一次;
  2. 本质是通过 routerHistory.listen(); 进行注册,即监听 popState 事件,当状态改变时,就会遍历 listeners 数组,执行所有的 listen 回调。
  3. 在回调中通过 matcher.resolve({ path: to }) 获取到目标路径的 matchedpath;再读取到当前的 currentRoute;最后通过 routerHistory.replace(to.path); 进行页面跳转。

具体实现如下:

function createRouterMatcher(routes) {
  // ...略

  /**
   * 根据用户跳转传入的to(如果是字符串,已经转化成了对象),获取到匹配的组件record(包括祖先组件的record)
   */
  function resolve(to) {
    const matched = [];
    let path = to.path;
    let matcher = matchers.find((m) => m.path === path);
    // 通过while循环,将path涉及到的所有组件全部放到matched中
    while (matcher) {
      matched.unshift(matcher.record);
      matcher = matcher.parent;
    }
    return {
      path,
      matched,
    };
  }

  return {
    addRoute,
    resolve,
  };
}
function createRouter(options) {
  const matcher = createRouterMatcher(options.routes);

  // to 支持多种格式,可能是字符串,也可能是一个对象,
  function resolve(to) {
    if (typeof to === "string") {
      return matcher.resolve({ path: to });
    } else {
      return matcher.resolve(to);
    }
  }

  // 注入listen事件,监听前进、后退
  let ready;
  function markAsReady() {
    if (ready) return;
    ready = true;
    // 监听前进后退
    routerHistory.listen((to) => {
      const targetLocation = resolve(to);
      const from = currentRoute.value;
      // 传入第三个参数,即前进/后退时采用replace模式
      finalizeNavigation(targetLocation, from, true);
    });
  }
  // 初始化(注册listen事件)、页面跳转、状态更新
  function finalizeNavigation(to, from, replaced) {
    // 如果是初始化页面
    if (from === START_LOCATION_NORMALIZED) {
      // 初始化时注册listen事件【只会注册一次】,用来监听popstate事件,状态改变时修改 currentRoute 以及进行页面跳转
      markAsReady();
    } else if (replaced) {
      routerHistory.replace(to.path);
    } else {
      routerHistory.push(to.path);
    }
    // 更新currentRoute
    currentRoute.value = to;
  }
  function pushWithRedirect(to) {
    // 根据 matcher 匹配到对应的 record
    const targetLocation = resolve(to);
    const from = currentRoute.value;
    finalizeNavigation(targetLocation, from);
  }
  function push(to) {
    return pushWithRedirect(to);
  }
  const router = {
    push,
    install(app) {
      // 如果是初始化,需要通过路由系统先进行一次跳转,发生匹配
      if (currentRoute.value === START_LOCATION_NORMALIZED) {
        push(routerHistory.location);
      }
    }
  }
}

RouterLink 的实现

简单来说就是使用 app.component() 注册一个全局组件,在该组件中渲染出 slots.default,以及绑定一个点击事件;在点击事件中执行 router.push() 方法进行页面跳转。

// vue-router/index.js
import { RouterLink } from "./router-link";
function createRouter(options) {
  const router = {
    push,
    replace,
    install(app) {
      // 注册全局组件 router-link
      app.component("RouterLink", RouterLink);
    }
  }
}
// vue-router/router-link.js
import { h, inject } from "vue";
function useLink(props) {
  const router = inject("router");
  function navigate() {
    router.push(props.to);
  }
  return { navigate };
}
export const RouterLink = {
  name: "RouterLink",
  props: {
    to: {
      type: [String, Object],
      required: true,
    },
  },
  setup(props, { slots }) {
    const link = useLink(props);
    return () => {
      return h(
        "a",
        {
          onclick: link.navigate,
          style: { cursor: "pointer" },
        },
        slots.default && slots.default()
      );
    };
  },
};

RouterView 的实现

步骤:

  1. 通过 inject("route location") 可以注入响应式的 ReactiveRoute,里面包含经过路由匹配到的所有 matcher.record(数组格式,parent在前、children在后)
  2. 目标是:在对应的 RouterView 组件渲染出对应位置的 record.components.default
    1. 通过 inject 注入父级 RouterView 组件传入的 depth
    2. depth 初始值为0,即 根RouterView 组件渲染 matched 的第一个元素,然后将 depth 加1,通过 provide 传递给下一个RouterView 组件
  3. 通过响应式获取路由对应的matched,配合injectprovide传递depth的方式,实现 RouterView 能准确渲染出matched对应位置的组件

注意:injectRoute.matched[depth] 必须通过 computed 设为响应式

  • 当没点击 RouterLink 前,injectRoute.matched[depth]undefinedRouterView相当于一个空标签,起到占位的作用)
  • 点击RouterLinkinjectRoute.matched 改变后,injectRoute.matched[depth] 才能取到对应的值
  • 只有当 injectRoute.matched[depth] 是响应式的,才能在点击 RouterLink 、改变它的值之后,触发 ViewRouter 的重新渲染。

具体实现:

// vue-router/index.js
import { RouterView } from "./router-view";
function createRouter(options) {
  const router = {
    push,
    replace,
    install(app) {
      // 注册全局组件 router-view
      app.component("RouterView", RouterView);
    }
  }
}
import { computed, h, inject, provide } from "vue";
export const RouterView = {
  name: "RouterView",
  setup(props, { slots }) {
    // 默认渲染injectRoute.matched数组中的第1个 record 对应的 components.default
    const depth = inject("depth", 0);
    const injectRoute = inject("route location");
    /**
     * 没点击 RouterLink 前,injectRoute.matched[depth] 是 undefined(相当于一个空标签),没有取到下一层匹配的 matcher
     * 所以 matchedRouteRef 必须是响应式的,当点击RouterLink、injectRoute.matched 改变后,injectRoute.matched[depth] 才能取到对应的值,再将 ViewRouter 渲染出来
     */
    const matchedRouteRef = computed(() => injectRoute.matched[depth]);
    provide("depth", depth + 1); // 在前一个的基础上加1

    return () => {
      const matchRoute = matchedRouteRef.value;
      const viewComponent = matchRoute && matchRoute.components.default;
      if (!viewComponent) {
        return slots.default && slots.default();
      }
      return h(viewComponent);
    };
  },
};

路由导航守卫的实现

注册全局守卫

先在 router 实例上创建三个全局守卫:beforeEachafterEachbeforeResolve,它们可以接收一个回调函数,并且可以重复注册;所以可以使用闭包的方式创建数据:

function useCallback() {
  const handlers = [];
  function add(handler) {
    handlers.push(handler);
  }
  return {
    add,
    list: () => handlers,
  };
}
function createRouter(options) {
  const beforeGuards = useCallback();
  const beforeResolveGuards = useCallback();
  const afterGuards = useCallback();
  const router = {
    beforeEach: beforeGuards.add,
    afterEach: afterGuards.add,
    beforeResolve: beforeResolveGuards.add,
  }
  return router;
}

注册全局守卫钩子,即向 handlers 数组中添加回调,还可以通过 list 属性读取 handlers 数组。

实现导航守卫

修改 pushWithRedirect(to),在路由跳转前实现 afterEach 之前的六个导航守卫,在路由跳转后实现 afterEach 全局守卫:

function pushWithRedirect(to) {
  const targetLocation = resolve(to);
  const from = currentRoute.value;
  // 导航守卫
  navigate(targetLocation, from)
    .then(() => {
      return finalizeNavigation(targetLocation, from);
    })
    .then(() => {
      // 7. 导航切换完毕后,执行 afterEach
      for (const guard of afterGuards.list()) {
        guard(to, from);
      }
    });
}
function push(to) {
  return pushWithRedirect(to);
}

实现 navigate

  1. navigate 返回一个Promise,所以可以将它定义成 async 函数

  2. 在页面跳转时,需要获取到哪些组件是进入、哪些组件是离开、那些组件是更新,方便后续执行对应组件中的组件内守卫

    /**
     * 筛选出leavingRecords、updatingRecords、enteringRecords;
     * 以从 /a 进入 /b 为例:
    * 1. /a(即 from)的 matched 为 [Home, A],/b(即 to)的 matched 为 [Home, B],
    * 2. 经过下面的筛选后,updatingRecords为 [Home]、leavingRecords为 [A]、enteringRecords 为 [B]
    */
    function extractChangeRecords(to, from) {
      const leavingRecords = [];
      const updatingRecords = [];
      const enteringRecords = [];
      const len = Math.max(to.matched.length, from.matched.length);
      for (let i = 0; i < len; i++) {
        // 一、离开的时候
        const recordFrom = from.matched[i];
        if (recordFrom) {
          // 1. 如果去的和来的都有,那么就是更新(比如从 '/' 到 '/',就是更新)
          if (to.matched.find((record) => record.path === recordFrom.path)) {
            updatingRecords.push(recordFrom);
          } else {
            // 2. 否则就是离开
            leavingRecords.push(recordFrom);
          }
        }
        // 二、进入的时候
        const recordTo = to.matched[i];
        if (recordTo) {
          // 如果来的里面不含去的,就是进入
          if (!from.matched.find((record) => record.path === recordTo.path)) {
            enteringRecords.push(recordTo);
          }
        }
      }
      return [leavingRecords, updatingRecords, enteringRecords];
    }
    function createRouter(options) {
      async function navigate(to, from) {
        // 在导航的时候,需要知道哪些组件是进入,哪些组件是离开,那些组件是更新
        const [leavingRecords, updatingRecords, enteringRecords] = extractChangeRecords(to, from);
      }
    }
  3. 为了保证钩子的调用顺序,需要借助 Promise 来实现;所以执行完guards后,需要返回一个Promise(下方的runGuardQueue方法)。另外每一个guard也需要是一个Promise,保证相同guard的执行顺序(下方的guardToPromise方法)。

     // 将guard封装成Promise
     function guardToPromise(guard, to, from, record) {
       return () =>
         new Promise((resolve, reject) => {
           const next = () => resolve();
           // 绑定guard的this
           const guardReturn = guard.call(record, to, from, next);
           // 如果用户没有手动调用next,则返回一个新的Promise,并自动执行next
           return Promise.resolve(guardReturn).then(next);
         });
     }
     // 获取 guardType 对应的所有 guards
     function extractComponentsGuards(matched, guardType, to, from) {
       const guards = [];
       for (const record of matched) {
         let rawComponent = record.components.default;
         const guard = rawComponent[guardType];
         // 每一个 guard 都需要是 Promise
         guard && guards.push(guardToPromise(guard, to, from, record));
       }
       return guards;
     }
     // 通过 Promise 链式执行 guards 中所有的守卫钩子(每个守卫钩子也是一个Promise),并返回新的 Promise
     function runGuardQueue(guards) {
       return guards.reduce(
         (promise, guard) => promise.then(() => guard()),
         Promise.resolve()
       );
     }
     function createRouter(options) {
       async function navigate(to, from) {
         // ...略
    
         // 下方的 guards 是 leavingRecords 中所有组件对应的 beforeRouteLeave 钩子组成的数组
         // 离开的时候需要先销毁子组件,再销毁父组件,所以需要将leavingRecords倒序
         let guards = extractComponentsGuards(
           leavingRecords.reverse(),
           "beforeRouteLeave",
           to,
           from
         );
         // 1. 执行leavingRecords中所有组件内钩子 beforeRouteLeave
         return runGuardQueue(guards).then...
       }
     }
  4. 导航守卫的执行顺序为:离开组件中的beforeRouteLeave、全局beforeEach、重用组件里的beforeRouteUpdate、路由配置里的beforeEnter、进入组件里的beforeRouteEnter、全局beforeResolve、全局afterEach

function createRouter(options) {
  async function navigate(to, from) {
    // ...略

    let guards = extractComponentsGuards(
      leavingRecords.reverse(),
      "beforeRouteLeave",
      to,
      from
    );
    // 1. 执行leavingRecords中所有组件内钩子 beforeRouteLeave
    return runGuardQueue(guards)
      .then(() => {
        // 2. 执行全局守卫 beforeEach
        guards = [];
        for (const guard of beforeGuards.list()) {
          guards.push(guardToPromise(guard, to, from, guard));
          return runGuardQueue(guards);
        }
      })
      .then(() => {
        // 3. 执行组件内钩子 beforeRouteUpdate
        guards = extractComponentsGuards(
          updatingRecords.reverse(),
          "beforeRouteUpdate",
          to,
          from
        );
        return runGuardQueue(guards);
      })
      .then(() => {
        // 4. 执行路由配置里的钩子 beforeEnter
        guards = [];
        for (const record of to.matched) {
          if (record.beforeEnter) {
            guards.push(guardToPromise(record.beforeEnter, to, from, record));
          }
        }
        return runGuardQueue(guards);
      })
      .then(() => {
        // 5. 执行组件内钩子 beforeRouteEnter
        guards = extractComponentsGuards(
          enteringRecords.reverse(),
          "beforeRouteEnter",
          to,
          from
        );
        return runGuardQueue(guards);
      })
      .then(() => {
        // 6. 执行全局守卫 beforeResolve
        guards = [];
        for (const guard of beforeResolveGuards.list()) {
          guards.push(guardToPromise(guard, to, from, guard));
          return runGuardQueue(guards);
        }
      });
  }
}

写在最后

本篇主要是对 vue-router4 源码的学习总结,源代码仓库可以查看 mini-vue-router4。如果本篇对你有所帮助,欢迎点赞收藏,顺便给个 star ~~。