实现一个简单的router

254 阅读4分钟

实现一个简单的router

前言

简单来说router的作用就是根据路径的不同展示不同的组件。router有两种常见的模式:hash模式history模式。接下来就开始简单的实现以下吧。

hash模式

  • 实现hash模式的主要就是通过监听onhashchange这个事件,然后通过location.hash来获取当前的路径,并根据当前的路径进行匹配要展示的组件。

编写html文件

<!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>
    <div id="app">
      <button id="btn1">/user</button>
      <button id="btn2">/profile</button>
      <div id="view"></div>
    </div>
    <script src="./hash.js"></script>
  </body>
</html>
  • html文件中,有两个按钮btn1btn2来进行路径跳转。然后还有一个id为view的div。这个view相当于一个存放匹配到的组件的容器。同时html文件引入一个hash.js文件。

编写hash.js文件

// 获取view,btn1和btn2
const view = document.querySelector("#view");
const btn1 = document.querySelector("#btn1");
const btn2 = document.querySelector("#btn2");

function push(path) {
  location.hash = path;
}
btn1.addEventListener("click", function (e) {
  push("/user/123");
});

btn2.addEventListener("click", function (e) {
  push("/profile");
});

// 后续进行实现
function processRoutes() {}

// 监听手动刷新页面
window.addEventListener("DOMContentLoaded", processRoutes);
// 监听hash变化
window.addEventListener("hashchange", processRoutes);
  • 目前为止,在hash.js文件中,主要就是对按钮进行事件绑定,以及实现push方法,而push方法就是通过改变location.hash实现改变路径。同时我们监听了DOMContentLoadedhashchange事件。在这两个事件中,我们主要就是进行路由匹配的操作。
  • 在进行路由匹配之前,先创建一些路由对象。

创建路由对象

const Home = () => {
  view.innerHTML = '<h1>Home</h1>'
}
const User = () => {
  view.innerHTML = '<h1>User</h1>'
}
const Profile = () => {
  view.innerHTML = '<h1>Profile</h1>'
}

const routes = [
  {
    path: '/',
    component: Home
  },
  {
    path: '/user/:id',
    component: User
  },
  {
    path: '/profile',
    component: Profile
  }
]
  • 有了路由对象,我们就可以通过匹配pathlocation.hash来决定要展示的组件。

实现processRoutes函数

// 全局变量,用来保存当前的route对象
let currentRoute = null
function processRoutes() {
  // 获取当前路径
  // 当前的location.hash有可能为空字符串,当为空字符串时设置为/
  // 因为location.hash获取到的值是会带有#,所以这里通过slice(1)去除掉#
  const curPath = !location.hash ? "/" : location.hash.slice(1);
  // 构建当前路由对象
  const curRoute = {
    path: curPath,
    params: {},
  };
  // 进行路由匹配
  const matchRoute = routes.find((route) => {
    // 将路径以/进行切割为数组
    // /user/:id -> ['', 'user', ':id']
    const curPathArr = curPath.split("/");
    const routePathArr = route.path.split("/");
    // 如果两个路径数组长度不一致,则不匹配
    if (curPathArr.length !== routePathArr.length) return false;
    for (let i = 0; i < routePathArr.length; i++) {
      // 如果是以:开头的,则认为是params参数
      if (routePathArr[i].startsWith(":")) {
        // routePathArr[i].slice(1) :id -> id
        // curPath: /user/123   routePath: /user/:id
        // 将当前的id作为key,curPathArr[i]作为value
        // params: { id: 123 }
        curRoute.params[routePathArr[i].slice(1)] = curPathArr[i];
      } else if (routePathArr[i] !== curPathArr[i]) {
        // 如果当前两项不匹配则直接返回false
        return false;
      }
    }
    // 如果前面没有return的话,则证明当前的路由是匹配的。
    return true;
  });
  if (matchRoute) {
    matchRoute.component();
    currentRoute = curRoute;
  }
}

实现效果

hashRouter.gif

history模式

  • 在实现history模式之前,因为history模式下,刷新浏览器会出现not found的情况。所以我们需要先简单的搭建一个服务,让页面刷新时还是返回html文件。
  • 同时为了方便,还想要实现,当我们改变代码时,浏览器能够自动刷新,这样就不需要我们进行手动刷新了。

使用koa搭建一个服务

  • 创建服务之前,首先需要安装koa ws chokidar
pnpm i koa ws chokidar

创建index.html文件

<!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>
    <div id="app">
      <button id="btn1">/user</button>
      <button id="btn2">/profile</button>
      <div id="view"></div>
    </div>
    <script>
      // 监听onload事件
      window.onload = function () {
        // 创建一个webSocket对象
        const ws = new WebSocket("ws://localhost:8080");
        ws.onerror = function () {
          console.log("error");
        };
        // 监听message事件
        ws.onmessage = function (msg) {
          if (msg.data === "reload") {
            // 当接收到mes.data === 'reload'时,直接刷新浏览器即可
            window.location.reload();
          }
        };
      };
    </script>
    <script src="./history.js"></script>
  </body>
</html>

创建server.js文件

const fs = require("fs");
const http = require("http");

const chokidar = require("chokidar");
const WebSocket = require("ws");
const Koa = require("koa");

const app = new Koa();

const server = http.createServer(app.callback());
const wss = new WebSocket.Server({ server });

// 全局变量用于保存ws
let gws;

wss.on("connection", function (ws) {
  // 将ws赋值给gws
  gws = ws;
});

// 监听文件的变化
chokidar
  .watch(".", {
    // 忽略node_modules文件的变化
    ignored: "node_modules",
  })
  .on("change", function () {
    // 当文件发生变化时,向客户端发送'reload'
    gws?.send("reload");
  });

app.use((ctx, next) => {
  const { url } = ctx;
  let response;
  
  if (url.endsWith(".js")) {
    // 如果请求路径是以.js结尾的,直接返回history.js文件
    response = fs.readFileSync("./history.js", "utf-8");
    ctx.type = "application/javascript";
    ctx.body = response;
  } else {
    // 其余情况都返回index.html文件
    response = fs.readFileSync("./index.html", "utf-8");
    ctx.type = "text/html";
    ctx.body = response;
  }
});

server.listen(8080, () => {
  console.log("服务器启动成功");
});

热刷新.gif

  • 可以看到,当我们修改文件时,浏览器会自动进行刷新

实现history.js文件

  • 实现history模式,主要就是通过html5提供的pushStatereplaceStateAPI,让我们可以添加路径的同时不刷新浏览器。同时通过history.go history.back history.forward可以进行上一个页面或者下一个页面的跳转,而且使用这几个api时,会触发onpopstate事件。但是注意通过pushState或者replacestate是不会触发onpopstate事件的。
  • 这里采用了一种比较投机取巧的方式。可能会有问题,但是目前还没发现。(如果发现的话,也可以告诉一下我)。
function push(path) {
  const state = {
    path,
    params: {},
  };
  history.pushState(state, "", path);
  history.pushState(state, "", path);
  history.go(-1);
}
  • 在push方法中,调用了两次pushState方法,然后调用history.go(-1)。因为调用了go方法,所以会触发onpopstate回调。所以就可以和实现hash模式一样,在回调中进行路由的匹配处理。这里就不在详细解释了。代码如下。
const view = document.querySelector("#view");
let currentRoute = null;

const Home = () => {
  view.innerHTML = "<h1>Home</h1>";
};

const User = () => {
  view.innerHTML = "<h1>User</h1>";
};

const Profile = () => {
  view.innerHTML = "<h1>Profile</h1>";
};

const btn1 = document.querySelector("#btn1");
const btn2 = document.querySelector("#btn2");
btn1.addEventListener("click", (e) => {
  push("/user/123/abc/456");
});

btn2.addEventListener("click", (e) => {
  push("/profile");
});

const routes = [
  {
    path: "/",
    component: Home,
  },
  {
    path: "/user/:id/abc/:name",
    component: User,
  },
  {
    path: "/profile",
    component: Profile,
  },
];

function push(path) {
  const state = {
    path,
    params: {},
  };
  history.pushState(state, "", path);
  history.pushState(state, "", path);
  history.go(-1);
}

function processRoutes(e) {
  const curRoute = !history.state ? { path: "/", params: {} } : history.state;
  const curPath = curRoute.path.slice(1).split("/");
  const matchRoute = routes.find((route) => {
    // /users/:username/posts/:postId
    // 先以/切割
    // 判断是否以:开头,如果是则该值为params参数
    const routeArr = route.path.slice(1).split("/");
    if (routeArr.length !== curPath.length) return false;
    for (let i = 0; i < routeArr.length; i++) {
      if (routeArr[i].startsWith(":")) {
        if (!route.params) {
          route.params = {
            [routeArr[i].slice(1)]: curPath[i],
          };
        } else {
          route.params[routeArr[i].slice(1)] = curPath[i];
        }
      } else if (routeArr[i] !== curPath[i]) {
        return false;
      }
    }
    return true;
  });
  if (matchRoute) {
    currentRoute = curRoute;
    matchRoute.component();
  } else {
    currentRoute = null;
  }
}

window.addEventListener("DOMContentLoaded", processRoutes);

window.addEventListener("popstate", processRoutes);

实现效果

history.gif