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

目前主流的前端 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,
});
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容