美文网首页
SPA 前端路由无刷新更新原理

SPA 前端路由无刷新更新原理

作者: IllIIlIlIII | 来源:发表于2021-06-23 17:20 被阅读0次

    目前主流的前端 SPA 框架如:React/Vue 是通过 Hash 和 History 两种方式实现无刷新路由。
    无刷新更新页面本质上是改变页面的DOM,而不是跳转到新页面。

    一、需要解决的问题:

    1、如何改变 URL 不引起页面刷新。

    Hash 模式:更新 window.location。
    History 模式:通过 pushState 或 replaceState 方法改变浏览器的 URL。

    2、如何监控 URL 的变化。

    在 Hash 模式下可以通过监听 Hashchange 事件来监控 URL 的变化。

    在 History 模式只有浏览器的前进和后退会触发 popstate 事件, History API 提供的 pushState 和 replaceState 并不会触发相关事件。故需要劫持 pushState / replaceState 方法,再手动触发事件。

    既然 History 这么麻烦,那为什么还要用 History 模式呢?

    来先看下完整 URL 的组成:

    protocol://hostname:port/pathname?search#hash
    
    • protocol:通信协议,常用的有http、https、ftp、mailto等。
    • hostname:主机域名或IP地址。
    • port:端口号,可选。省略时使用协议的默认端口,如http默认端口为80。
    • pathname:路径由零或多个"/"符号隔开的字符串组成,一般用来表示主机上的一个目录或文件地址。
    • search:查询,可选。用于传递参数,可有多个参数,用"&“符号隔开,每个参数的名和值用”="符号隔开。
    • hash:信息片断字符串,也称为锚点。用于指定网络资源中的片断。

    可以看到 Hash 前面固定有一个井号 "#",即不美观,也不符合一般我们对路由认知,如:

    https://www.test.com/#/home
    https://www.test.com/#/about
    

    而 History 就可以解决这个问题,它可以直接修改 pathname 部分的内容:

    https://www.test.com/home
    https://www.test.com/about
    

    3、如何根据 URL 改变页面内容。

    文章开头说了,无刷新更新页面本质上是改变页面的DOM,而不是跳转到新页面。 我们也知道了如何监控 URL 的变化,那最简单粗暴的方式就是直接通过 innerHTML 改变 DOM 内容。

    当然主流的 SPA 框架如:React/Vue 是通过 虚拟DOM(Virtual DOM) 结合优化后的 diff 策略 实现最小 DOM 操作来更新页面。

    关于 Virtual DOM 和直接 DOM 操作哪个性能更高?

    二、路由的实现

    这里就以 History 模式为例,用 Typescript实现,Hash 模式可以以此类推。

    1、路由的需求和解决思路

    • 如何生成路由
      创建一个 Router 类,传入一个类似 Vue-router 的路由参数数组 routes 来配置路由:

      const routes = [
        {
            path: '/',
            redirect: '/home',
        },
        {
            path: '/home',
            page: home,
        },
        {
            path: '/about',
            page: about,
        },
        {
            path: '/about/me',
            page: aboutMe,
        }
        // ...
      ];
      export { routes };
      
    • 如何跳转地址
      使用 History API 提供的 pushState 和 replaceState 方法:

      // 本质上只是改变了浏览器的 URL 显示
      window.history.pushState({}, '', '/someurl');
      window.history.replaceState({}, '', '/someurl');
      
    • 如何监听 URL 变化
      由于pushState 和 replaceState 并不会触发相应事件,故需劫持 pushState 和 replaceState 方法,手动触发事件:

      bindHistoryEventListener(type: string): any {
            const historyFunction: Function = (<any>history)[type];
            return function() {
                const newHistoryFunction = historyFunction.apply(history, arguments);
                const e = new Event(type);
                (<any>e).arguments = arguments;
                // 触发事件, 让 addEventListener 可以监听到
                window.dispatchEvent(e);
                return newHistoryFunction;
            };
        };
      

      然后就可以监听相关事件了

      window.history.pushState = this.bindHistoryEventListener('pushState');
      window.addEventListener('pushState', () => {
          // ...
      });
      window.history.replaceState = this.bindHistoryEventListener('replaceState');
      window.addEventListener('replaceState', () => {
          // ...
      });
      
    • /about 和 /about/me 是两个不同的页面
      转换 pathname 为数组,再判断数组长度来区分:

      // 浏览器 URL 的 pathname 转化为数组
      // browserPath 为 window.location.pathname
      const browserPathQueryArray: Array<string> = browserPath.substring(1).split('/');
      // routes的 path 属性转化为数组
      // route 为 routes 遍历后的单个元素
      const routeQueryArray: Array<string> = route.path.substring(1).split('/');
      // 对两者比长度
      if (routeQueryArray.length !== browserPathQueryArray.length) {
         return false;
      }
      
    • /blogs/:id 可以动态匹配 /blogs/1、 /blogs/99
      转换 pathname 为数组,字符串判断以冒号 ":" 开头,则为动态属性,把其加入到全局变量 $route 中:

      for (let i = 0; i < routeQueryArray.length; i++) {
          if (routeQueryArray[i].indexOf(':') === 0) {
             // :id 可以用 $router.id 访问
             (<any>window).$route[routeQueryArray[i].substring(1)] = pathQueryArray[i];
          }
      }
      
    • 路由有的地址会 跳转 / 重新定向 到其他地址上
      在路由参数中约定 redirect 属性为 跳转 / 重新定向 的目标地址,查找中再次遇到 redirect 属性则重新查找新的目标地址,直到找到最终地址:

      // Router 类 的 redirect 方法
      if (this.routes[index].redirect !== undefined && this.routes[index].redirect !== '') {
          this.redirect(this.routes[index].redirect);
      } else {
          // 更新 URL 为最终的地址
          window.history.pushState({}, '', window.location.origin + this.routes[index].path);
          // 然后执行更新页面逻辑 ...
      }
      

    2、History 路由的实现

    1、路由参数 routes.ts:

    // 该数组会作为参数传给路由器的实例,其中 page 参数接收一个 Page 对象,该对象包含一些页面更新的方法,可以是 innerHTML 也可以是 虚拟 DOM 更新,这里不重要,只要知道可以调用它的方法更新页面就行
    
    // 甚至可以把 page 参数改为接收 HTML 字符串,路由器直接把这些 HTML 字符串通过 innerHTML 更新进页面
    
    const routes = [
        {
            // 地址
            path: '/',
            // redirect 为要重新定向的地址
            redirect: '/home',
        },
        {
            path: '/home',
            page: homePage,
        },
        {
            path: '/about',
            page: aboutPage,
        },
        {
            path: '/about/me',
            page: aboutMePage,
        },
        {
            path: '/blogs/:id',
            page: blogsPage,
        },
        {
            path: '/404',
            page: pageNotFound,
        },
    ];
    export { routes };
    

    2、路由 router.ts:

    // 路由参数就是 Route 的数组
    interface Route {
        path: string,
        page?: Page,
        redirect?: string,
    }
    
    // 路由器接收的参数
    interface Config {
        // 内容区容器 ID
        container: HTMLElement,
        routes: Route[],
    }
    
    class Router {
        // 页面需要更新的区域
        container: HTMLElement;
        routes: Route[];
        constructor(config: Config) {
            this.routes = config.routes;
            this.container = config.container;
    
            // 先执行一次,初始化页面
            this.monitor();
    
            // 劫持 pushState
            window.history.pushState = this.bindHistoryEventListener('pushState');
            window.addEventListener('pushState', () => {
                this.monitor();
            });
            window.addEventListener('popstate', () => {
                this.monitor();
            });
        }
    
        // 根据路由地址查找相应的参数
        monitor(): void {
            let index: number = this.routes.findIndex((item: Route) => {
                return this.verifyPath(item, window.location.pathname);
            });
            
            // 找到结果
            if (index >= 0) {
                if (this.routes[index].redirect !== undefined && this.routes[index].redirect !== '') {
               
                // 重新定向 
                    this.redirect(this.routes[index].redirect);
                } else {
                    // 不需重新定向,执行更新页面的方法
                    this.updatePage(index);
                }
            } else {
                // 没找到结果跳转到 /404 地址
                window.history.pushState({}, '', '/404');
                console.log('404!');
            }
        }
    
        // 重新定向
        redirect(redirectPath: string): void {
            let index: number = this.routes.findIndex((item: Route) => {
                return redirectPath === item.path;
            });
            // 定向到的地址还是 redirect 则继续找最终 path
            if (this.routes[index].redirect !== undefined && this.routes[index].redirect !== '') {
                this.redirect(this.routes[index].redirect);
            } else {
                // 更新 URL 为最终的地址
                window.history.pushState({}, '', window.location.origin + this.routes[index].path);
                this.updatePage(index);
            }
        }
    
        // 更新页面
        updatePage(index: number): void {
            // 向全局变量 $route 加入动态属性
            const pathQueryArray: Array<string> = window.location.pathname.substring(1).split('/');
            const routeQueryArray: Array<string> = this.routes[index].path.substring(1).split('/');
            for (let i = 0; i < routeQueryArray.length; i++) {
                if (routeQueryArray[i].indexOf(':') === 0) {
                    (<any>window).$route[routeQueryArray[i].substring(1)] = pathQueryArray[i];
                }
            }
            
            // 这里假设 Page 有 create 方法可以更新页面内容,而不用纠结它的具体实现
            this.routes[index].page.create(this.container);
        }
    
        // 对比路由地址
        verifyPath(route: Route, browserPath: string): boolean {
            const browserPathQueryArray: Array<string> = browserPath.substring(1).split('/');
            const routeQueryArray: Array<string> = route.path.substring(1).split('/');
            // 先核对长度
            if (routeQueryArray.length !== browserPathQueryArray.length) {
                return false;
            }
            for (let i = 0; i < routeQueryArray.length; i++) {
                // 判断是否以冒号开头, 如 :id
                // 不是, 则将其与路由 path进行比对
                if (routeQueryArray[i].indexOf(':') !== 0) {
                    if (routeQueryArray[i] !== browserPathQueryArray[i]) {
                        return false;
                    }
                }
            }
            return true;
        }
    
        // 劫持 pushState / popState
        bindHistoryEventListener(type: string): any {
            const historyFunction: Function = (<any>history)[type];
            return function() {
                const newHistoryFunction = historyFunction.apply(history, arguments);
                const e = new Event(type);
                (<any>e).arguments = arguments;
                // 触发事件, 让 addEventListener 可以监听到
                window.dispatchEvent(e);
                return newHistoryFunction;
            };
        };
    }
    
    export { Router };
    

    3、使用路由器

    import { routes } from 'routes.js';
    import { Router } from 'router.js';
    new Router({
        // 更新页面 div#app 中的内容
        container: document.getElementById('app'),
        routes: routes,
    });
    

    相关文章

      网友评论

          本文标题:SPA 前端路由无刷新更新原理

          本文链接:https://www.haomeiwen.com/subject/zgwbyltx.html