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

目前主流的前端 SPA 框架如:React/Vue 是通過 Hash 和 History 兩種方式實(shí)現(xiàn)無刷新路由。
無刷新更新頁面本質(zhì)上是改變頁面的DOM,而不是跳轉(zhuǎn)到新頁面。

一、需要解決的問題:

1革娄、如何改變 URL 不引起頁面刷新倾贰。

Hash 模式:更新 window.location。
History 模式:通過 pushState 或 replaceState 方法改變?yōu)g覽器的 URL拦惋。

2、如何監(jiān)控 URL 的變化安寺。

在 Hash 模式下可以通過監(jiān)聽 Hashchange 事件來監(jiān)控 URL 的變化厕妖。

在 History 模式只有瀏覽器的前進(jìn)和后退會(huì)觸發(fā) popstate 事件, History API 提供的 pushState 和 replaceState 并不會(huì)觸發(fā)相關(guān)事件挑庶。故需要劫持 pushState / replaceState 方法言秸,再手動(dòng)觸發(fā)事件。

既然 History 這么麻煩迎捺,那為什么還要用 History 模式呢举畸?

來先看下完整 URL 的組成:

protocol://hostname:port/pathname?search#hash
  • protocol:通信協(xié)議,常用的有http凳枝、https抄沮、ftp、mailto等岖瑰。
  • hostname:主機(jī)域名或IP地址叛买。
  • port:端口號(hào),可選蹋订。省略時(shí)使用協(xié)議的默認(rèn)端口率挣,如http默認(rèn)端口為80。
  • pathname:路徑由零或多個(gè)"/"符號(hào)隔開的字符串組成露戒,一般用來表示主機(jī)上的一個(gè)目錄或文件地址椒功。
  • search:查詢,可選智什。用于傳遞參數(shù)动漾,可有多個(gè)參數(shù),用"&“符號(hào)隔開撩鹿,每個(gè)參數(shù)的名和值用”="符號(hào)隔開谦炬。
  • hash:信息片斷字符串,也稱為錨點(diǎn)节沦。用于指定網(wǎng)絡(luò)資源中的片斷键思。

可以看到 Hash 前面固定有一個(gè)井號(hào) "#",即不美觀甫贯,也不符合一般我們對(duì)路由認(rèn)知吼鳞,如:

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

而 History 就可以解決這個(gè)問題,它可以直接修改 pathname 部分的內(nèi)容:

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

3叫搁、如何根據(jù) URL 改變頁面內(nèi)容赔桌。

文章開頭說了供炎,無刷新更新頁面本質(zhì)上是改變頁面的DOM,而不是跳轉(zhuǎn)到新頁面疾党。 我們也知道了如何監(jiān)控 URL 的變化音诫,那最簡單粗暴的方式就是直接通過 innerHTML 改變 DOM 內(nèi)容。

當(dāng)然主流的 SPA 框架如:React/Vue 是通過 虛擬DOM(Virtual DOM) 結(jié)合優(yōu)化后的 diff 策略 實(shí)現(xiàn)最小 DOM 操作來更新頁面雪位。

關(guān)于 Virtual DOM 和直接 DOM 操作哪個(gè)性能更高竭钝?

二、路由的實(shí)現(xiàn)

這里就以 History 模式為例雹洗,用 Typescript實(shí)現(xiàn)香罐,Hash 模式可以以此類推。

1时肿、路由的需求和解決思路

  • 如何生成路由
    創(chuàng)建一個(gè) Router 類庇茫,傳入一個(gè)類似 Vue-router 的路由參數(shù)數(shù)組 routes 來配置路由:

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

    // 本質(zhì)上只是改變了瀏覽器的 URL 顯示
    window.history.pushState({}, '', '/someurl');
    window.history.replaceState({}, '', '/someurl');
    
  • 如何監(jiān)聽 URL 變化
    由于pushState 和 replaceState 并不會(huì)觸發(fā)相應(yīng)事件,故需劫持 pushState 和 replaceState 方法螃成,手動(dòng)觸發(fā)事件:

    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;
              // 觸發(fā)事件, 讓 addEventListener 可以監(jiān)聽到
              window.dispatchEvent(e);
              return newHistoryFunction;
          };
      };
    

    然后就可以監(jiān)聽相關(guān)事件了

    window.history.pushState = this.bindHistoryEventListener('pushState');
    window.addEventListener('pushState', () => {
        // ...
    });
    window.history.replaceState = this.bindHistoryEventListener('replaceState');
    window.addEventListener('replaceState', () => {
        // ...
    });
    
  • /about 和 /about/me 是兩個(gè)不同的頁面
    轉(zhuǎn)換 pathname 為數(shù)組旦签,再判斷數(shù)組長度來區(qū)分:

    // 瀏覽器 URL 的 pathname 轉(zhuǎn)化為數(shù)組
    // browserPath 為 window.location.pathname
    const browserPathQueryArray: Array<string> = browserPath.substring(1).split('/');
    // routes的 path 屬性轉(zhuǎn)化為數(shù)組
    // route 為 routes 遍歷后的單個(gè)元素
    const routeQueryArray: Array<string> = route.path.substring(1).split('/');
    // 對(duì)兩者比長度
    if (routeQueryArray.length !== browserPathQueryArray.length) {
       return false;
    }
    
  • /blogs/:id 可以動(dòng)態(tài)匹配 /blogs/1、 /blogs/99
    轉(zhuǎn)換 pathname 為數(shù)組锈颗,字符串判斷以冒號(hào) ":" 開頭顷霹,則為動(dòng)態(tài)屬性,把其加入到全局變量 $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];
        }
    }
    
  • 路由有的地址會(huì) 跳轉(zhuǎn) / 重新定向 到其他地址上
    在路由參數(shù)中約定 redirect 屬性為 跳轉(zhuǎn) / 重新定向 的目標(biāo)地址击吱,查找中再次遇到 redirect 屬性則重新查找新的目標(biāo)地址淋淀,直到找到最終地址:

    // 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);
        // 然后執(zhí)行更新頁面邏輯 ...
    }
    

2、History 路由的實(shí)現(xiàn)

1覆醇、路由參數(shù) routes.ts:

// 該數(shù)組會(huì)作為參數(shù)傳給路由器的實(shí)例朵纷,其中 page 參數(shù)接收一個(gè) Page 對(duì)象,該對(duì)象包含一些頁面更新的方法永脓,可以是 innerHTML 也可以是 虛擬 DOM 更新袍辞,這里不重要,只要知道可以調(diào)用它的方法更新頁面就行

// 甚至可以把 page 參數(shù)改為接收 HTML 字符串常摧,路由器直接把這些 HTML 字符串通過 innerHTML 更新進(jìn)頁面

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:

// 路由參數(shù)就是 Route 的數(shù)組
interface Route {
    path: string,
    page?: Page,
    redirect?: string,
}

// 路由器接收的參數(shù)
interface Config {
    // 內(nèi)容區(qū)容器 ID
    container: HTMLElement,
    routes: Route[],
}

class Router {
    // 頁面需要更新的區(qū)域
    container: HTMLElement;
    routes: Route[];
    constructor(config: Config) {
        this.routes = config.routes;
        this.container = config.container;

        // 先執(zhí)行一次,初始化頁面
        this.monitor();

        // 劫持 pushState
        window.history.pushState = this.bindHistoryEventListener('pushState');
        window.addEventListener('pushState', () => {
            this.monitor();
        });
        window.addEventListener('popstate', () => {
            this.monitor();
        });
    }

    // 根據(jù)路由地址查找相應(yīng)的參數(shù)
    monitor(): void {
        let index: number = this.routes.findIndex((item: Route) => {
            return this.verifyPath(item, window.location.pathname);
        });
        
        // 找到結(jié)果
        if (index >= 0) {
            if (this.routes[index].redirect !== undefined && this.routes[index].redirect !== '') {
           
            // 重新定向 
                this.redirect(this.routes[index].redirect);
            } else {
                // 不需重新定向落午,執(zhí)行更新頁面的方法
                this.updatePage(index);
            }
        } else {
            // 沒找到結(jié)果跳轉(zhuǎn)到 /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 則繼續(xù)找最終 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 加入動(dòng)態(tài)屬性
        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];
            }
        }
        
        // 這里假設(shè) Page 有 create 方法可以更新頁面內(nèi)容谎懦,而不用糾結(jié)它的具體實(shí)現(xiàn)
        this.routes[index].page.create(this.container);
    }

    // 對(duì)比路由地址
    verifyPath(route: Route, browserPath: string): boolean {
        const browserPathQueryArray: Array<string> = browserPath.substring(1).split('/');
        const routeQueryArray: Array<string> = route.path.substring(1).split('/');
        // 先核對(duì)長度
        if (routeQueryArray.length !== browserPathQueryArray.length) {
            return false;
        }
        for (let i = 0; i < routeQueryArray.length; i++) {
            // 判斷是否以冒號(hào)開頭, 如 :id
            // 不是, 則將其與路由 path進(jìn)行比對(duì)
            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;
            // 觸發(fā)事件, 讓 addEventListener 可以監(jiān)聽到
            window.dispatchEvent(e);
            return newHistoryFunction;
        };
    };
}

export { Router };

3、使用路由器

import { routes } from 'routes.js';
import { Router } from 'router.js';
new Router({
    // 更新頁面 div#app 中的內(nèi)容
    container: document.getElementById('app'),
    routes: routes,
});
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
禁止轉(zhuǎn)載溃斋,如需轉(zhuǎn)載請(qǐng)通過簡信或評(píng)論聯(lián)系作者界拦。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市梗劫,隨后出現(xiàn)的幾起案子享甸,更是在濱河造成了極大的恐慌截碴,老刑警劉巖,帶你破解...
    沈念sama閱讀 210,978評(píng)論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蛉威,死亡現(xiàn)場(chǎng)離奇詭異日丹,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)蚯嫌,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,954評(píng)論 2 384
  • 文/潘曉璐 我一進(jìn)店門聚凹,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人齐帚,你說我怎么就攤上這事”撕撸” “怎么了对妄?”我有些...
    開封第一講書人閱讀 156,623評(píng)論 0 345
  • 文/不壞的土叔 我叫張陵,是天一觀的道長敢朱。 經(jīng)常有香客問我剪菱,道長,這世上最難降的妖魔是什么拴签? 我笑而不...
    開封第一講書人閱讀 56,324評(píng)論 1 282
  • 正文 為了忘掉前任孝常,我火速辦了婚禮,結(jié)果婚禮上蚓哩,老公的妹妹穿的比我還像新娘构灸。我一直安慰自己,他們只是感情好岸梨,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,390評(píng)論 5 384
  • 文/花漫 我一把揭開白布喜颁。 她就那樣靜靜地躺著,像睡著了一般曹阔。 火紅的嫁衣襯著肌膚如雪半开。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,741評(píng)論 1 289
  • 那天赃份,我揣著相機(jī)與錄音寂拆,去河邊找鬼。 笑死抓韩,一個(gè)胖子當(dāng)著我的面吹牛纠永,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播园蝠,決...
    沈念sama閱讀 38,892評(píng)論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼渺蒿,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了彪薛?” 一聲冷哼從身側(cè)響起茂装,我...
    開封第一講書人閱讀 37,655評(píng)論 0 266
  • 序言:老撾萬榮一對(duì)情侶失蹤怠蹂,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后少态,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體城侧,經(jīng)...
    沈念sama閱讀 44,104評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,451評(píng)論 2 325
  • 正文 我和宋清朗相戀三年彼妻,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了嫌佑。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,569評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡侨歉,死狀恐怖屋摇,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情幽邓,我是刑警寧澤炮温,帶...
    沈念sama閱讀 34,254評(píng)論 4 328
  • 正文 年R本政府宣布,位于F島的核電站牵舵,受9級(jí)特大地震影響柒啤,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜畸颅,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,834評(píng)論 3 312
  • 文/蒙蒙 一担巩、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧没炒,春花似錦涛癌、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,725評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至漾脂,卻和暖如春假颇,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背骨稿。 一陣腳步聲響...
    開封第一講書人閱讀 31,950評(píng)論 1 264
  • 我被黑心中介騙來泰國打工笨鸡, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人坦冠。 一個(gè)月前我還...
    沈念sama閱讀 46,260評(píng)論 2 360
  • 正文 我出身青樓形耗,卻偏偏與公主長得像,于是被迫代替她去往敵國和親辙浑。 傳聞我的和親對(duì)象是個(gè)殘疾皇子激涤,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,446評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容