前端路由(手寫路由)

collins是個愛哭的鼻涕蟲發表於2019-08-20

1、歷史

路由最早興起於後端,路由就是:使用者端發起一個HTTP請求,後臺用一個函式進行處理,即 函式 => 路由。到後來前端出現ajax,頁面可以進行區域性重新整理,出現SPA應用。

2、SPA

單頁面應用僅在初始化時,載入頁面相應的HTML、CSS、JavaScript。一旦頁面載入完成,SPA不會因為使用者的操作,而進行頁面的重新載入和跳轉,而是利用路由機制在實現HTML的切換,UI與使用者互動,避免重複的載入。

優點:

  • 使用者體驗好、快、內容改變不需要重新載入頁面,避免了不必要的跳轉和重複渲染。並不需要處理HTML檔案的請求,這樣節約了HTTP傳送延遲。
  • 基於上面一點,SPA相對伺服器壓力小很多。
  • 前後端職責分離,架構清楚,前端進行互動邏輯,後端服務資料處理

缺點:

  • 初始化時耗時比較多,為實現單頁面web應用功能及顯示效果,需要將JavaScript和CSS統一載入,雖然可以部分按需載入。
  • SPA為單頁面,但是瀏覽器的前進後臺不能使用,所有頁面的切換需要自己建立一個堆疊進行管理。
  • 所有內容都在一個頁面,SEO天然處於弱勢。

3、前端路由

為了解決SPA應用的缺點,前端路由出現。前端路由結合SPA更新檢視但是瀏覽器不重新整理頁面,只是重新渲染部門子頁面,載入速度快,頁面反應靈活等優點實現。

目前瀏覽器前端路由實現的方式有兩種

  • URL #hash
  • H5的history interface

4、URL #hash

在最開始H5還沒有流行其他的時候,SPA採用URL 的hash值作為錨點,獲取錨點值,監聽其改變,在進行對應的子頁面渲染。window物件有一個專門監聽hash變化的時候,那就是onhashchange。 監聽到URL的變化,我們如何進行頁面的載入了。當頁面傳送改變了,在執行頁面載入有三種方式 查詢節點內容並改變。 使用import匯出js檔案,js檔案export模板字串。 利用Ajax載入不同hash的HTML模組。

4.1 查詢節點內容並改變
<h1 id="page"></h1>
<a href="#/page1">page1</a>
<a href="#/page2">page2</a>
<a href="#/page3">page3</a>
<script>
    window.addEventListener('hashchange', e => {
        e.preventDefault();
        document.querySelector('#page').innerHTML = location.hash;
    })
</script>
複製程式碼
4.2 import方法
const str = `
  <div>
    我是import進來的JS檔案
  </div>
`
export default str
複製程式碼
<body>
    <h1 id="page"></h1>
    <a href="#/page1">page1</a>
    <a href="#/page2">page2</a>
    <a href="#/page3">page3</a>
    <script type="module">
        import demo1 from './import.js'
        document.querySelector('#page').innerHTML = demo1;
        window.addEventListener('hashchange', e => {
            e.preventDefault();
            document.querySelector('#page').innerHTML = location.hash;
        })
    </script>
</body>
複製程式碼
4.3 Ajax請求,例如使用jQuery封裝好的ajax請求方式。
<body>
  <h1 id="id"></h1>
  <a href="#/id1">id1</a>
  <a href="#/id2">id2</a>
  <a href="#/id3">id3</a>
</body>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script type="module">
  // import demo1 from './demo1.js'
  // document.querySelector('#id').innerHTML = demo1
  $.ajax({
    url: './demo2.html',
    success: (res) => {
      document.querySelector('#id').innerHTML = res
    }
  });
  window.addEventListener('hashchange', e => {
    e.preventDefault();
    document.querySelector('#id').innerHTML = location.hash;
  })
</script>
複製程式碼
4.4 通用方式
<body>
    <h1 id="page">空白頁</h1>
    <a href="#/page1">page1</a>
    <a href="#/page2">page2</a>
    <a href="#/page3">page3</a>
</body>
<script type="text/javascript">
import demo1 from './import.js';
// 建立路由類
class Router {
    constructor() {
        // 路由物件
        this.routes = {};
        // 當前URL
        this.curUrl = '';
    }
    // 根據RUL或者對應URL的回撥函式
    route(path, callback = () => {}) {
        this.routes[path] = callback;
    }
    // 重新整理
    refresh() {
        // 當前hash值
        this.curUrl = window.location.hash.slice(1) || '/';
        // 執行方法
        this.routes[this.curUrl] && this.routes[this.curUrl]();
    }
    // 初始化監聽函式
    initPage() {
        window.addEventListener('load', this.refresh.bind(this), false);
        window.addEventListener('hashchange', this.refresh.bind(this), false);
    }
}
// 例項化路由
window.router = new Router();
// 初始化
window.router.init();
// 獲取節點
const content = document.getElementById('page');
// 直接改變
Router.route('/page1', () => {
    content.innerHTML = 'page1'
});
// import改變
Router.route('/page2', () => {
    content.innerHTML = demo1
});
// AJAX改變
Router.route('/page3', () => {
    $.ajax({
        url: './demo2.html',
        success: (res) => {
            content.innerHTML = res
        }
    })
});
</script>
複製程式碼

5、h5 interface

5.1 向歷史記錄棧中新增記錄:pushState(state, title, url);
  • state: 一個 JS 物件(不大於640kB),主要用於在 popstate 事件中作為引數被獲取。如果不需要這個物件,此處可以填 null
  • title: 新頁面的標題,部分瀏覽器(比如 Firefox )忽略此引數,因此一般為 null
  • url: 新歷史記錄的地址,可為頁面地址,也可為一個錨點值,新 url 必須與當前 url 處於同一個域,否則將丟擲異常,此引數若沒有特別標註,會被設為當前文件 url
// 現在是 localhost/1.html
const stateObj = { foo: 'bar' };
history.pushState(stateObj, 'page 2', '2.html');

// 瀏覽器位址列將立即變成 localhost/2.html
// 但!!!
// 不會跳轉到 2.html
// 不會檢查 2.html 是否存在
// 不會在 popstate 事件中獲取
// 不會觸發頁面重新整理
// 這個方法僅僅是新增了一條最新記錄
複製程式碼
5.2 改變當前歷史記錄而不是新增新紀錄:replaceState(state, title, url);
PS:
    將URL設定為錨點時將不回觸發hashchange。
    URL設定為不同域名、埠號、協議,將會報錯,這是由於瀏覽器的同源策略的限制。
複製程式碼
5.3 popState(state, title, url);

當瀏覽器歷史棧出現變化時,就會觸發popState事件。

PS:
    呼叫pushState和replaceState時不會觸發popState,只有使用者點選前進後退
    或者使用JavaScript的go、back、forword方法時才會觸發,並且對於不同檔案
    的切換也是不會觸發的
複製程式碼

h5 interface 手寫一個路由類

<body>
    <h1 id="page">空白頁</h1>
    <a class="route" href="#/page1">page1</a>
    <a class="route" href="#/page2">page2</a>
    <a class="route" href="#/page3">page3</a>
</body>
<script type="text/javascript">
import demo1 from './import.js';
// 建立路由類
class Router {
    constructor() {
        // 路由物件
        this.routes = {};
        // 當前URL
        this.curUrl = '';
    }
    // 根據RUL或者對應URL的回撥函式
    route(path, callback = () => {}) {
        this.routes[path] = (type) => {
            if (type === 1) {
                history.pushState({ path }, path, path);
            }
            if (type === 2) {
                history.replaceState({ path }, path, path);
            }
            callback();
        };
    }
    // 重新整理
    refresh(path, type) {
        this.routes[this.curUrl] && this.routes[this.curUrl](type);
    }
    // 初始化監聽函式
    initPage() {
        window.addEventListener('load', () => {
            // 獲取當前 URL 路徑
            this.curUrl = location.href.slice(location.href.indexOf('/', 8));
            this.refresh(this.curUrl, 2)
        }, false);
        window.addEventListener('popstate', () => {
            this.curUrl = history.state.path;
            this.refresh(this.curUrl, 2);
        }, false);
        const links = document.querySelectorAll('.route')
        links.forEach((item) => {
            // 覆蓋 a 標籤的 click 事件,防止預設跳轉行為
            item.onclick = (e) => {
                e.preventDefault();
                // 獲取修改之後的 URL
                this.curUrl = e.target.getAttribute('href');
                // 渲染
                this.refresh(this.curUrl, 2);
            }
        });
    }
}
// 例項化路由
window.router = new Router();
// 初始化
window.router.init();
// 獲取節點
const content = document.getElementById('page');
// 直接改變
Router.route('/page1', () => {
    content.innerHTML = 'page1'
});
// import改變
Router.route('/page2', () => {
    content.innerHTML = demo1
});
// AJAX改變
Router.route('/page3', () => {
    $.ajax({
        url: './demo2.html',
        success: (res) => {
            content.innerHTML = res
        }
    })
});
</script>
複製程式碼

6、Vue-router的路由實現

專案地址:github.com/vuejs/vue-r… 先看一段Vue-router原始碼(其他已省略)

if (!inBrowser) {
  mode = 'abstract'
}
this.mode = mode

switch (mode) {
  case 'history':
    this.history = new HTML5History(this, options.base)
    break
  case 'hash':
    this.history = new HashHistory(this, options.base, this.fallback)
    break
  case 'abstract':
    this.history = new AbstractHistory(this, options.base)
    break
  default:
    if (process.env.NODE_ENV !== 'production') {
      assert(false, `invalid mode: ${mode}`)
    }
}
複製程式碼

我們發現在Vue-router中,history例項和mode的型別有關。不同的型別實現不一樣。 首先我們來看看HTML5History原始碼實現(/src/index.js)

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // $flow-disable-line
    if (!onComplete && !onAbort && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        this.history.push(location, resolve, reject)
      })
    } else {
      this.history.push(location, onComplete, onAbort)
    }
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // $flow-disable-line
    if (!onComplete && !onAbort && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        this.history.replace(location, resolve, reject)
      })
    } else {
      this.history.replace(location, onComplete, onAbort)
    }
  }

  go (n: number) {
    this.history.go(n)
  }

  back () {
    this.go(-1)
  }

  forward () {
    this.go(1)
  }
複製程式碼

我們發現其實我們在日常專案中使用的push和replace方法就是呼叫的this.history中的方法,也就是 HTML5History類的方法,我們找到HTML5History這個類。在這裡/src/history/html5.js

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(location, route => {
      pushState(cleanPath(this.base + route.fullPath))
      handleScroll(this.router, route, fromRoute, false)
      onComplete && onComplete(route)
    }, onAbort)
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(location, route => {
      replaceState(cleanPath(this.base + route.fullPath))
      handleScroll(this.router, route, fromRoute, false)
      onComplete && onComplete(route)
    }, onAbort)
  }
複製程式碼

在這個HTML5History類中定義了push和replace方法,原來如此,在內部的使用又使用了上文提到 h5 interface的pushState和replaceState,哈哈哈,其實這裡的pushState和replaceState並不是h5 interface的pushState和replaceState。而是呼叫了import { pushState, replaceState, supportsPushState } from '../util/push-state'中的pushState和replaceState。來看看push-state原始檔的實現。

export function pushState (url?: string, replace?: boolean) {
  saveScrollPosition()
  // try...catch the pushState call to get around Safari
  // DOM Exception 18 where it limits to 100 pushState calls
  const history = window.history
  try {
    if (replace) {
      history.replaceState({ key: _key }, '', url)
    } else {
      _key = genKey()
      history.pushState({ key: _key }, '', url)
    }
  } catch (e) {
    window.location[replace ? 'replace' : 'assign'](url)
  }
}

export function replaceState (url?: string) {
  pushState(url, true)
}
複製程式碼

在這裡才是真正的使用了h5 interface的pushState和replaceState,真是兜兜轉轉一圈終於找到了呀

總結:
    router 例項呼叫的 push 實際是 history 的方法,通過 mode 來確定匹配 history 
    的實現方案,從程式碼中我們看到,push 呼叫了 src/util/push-state.js 中被改寫過
    的 pushState 的方法,改寫過的方法會根據傳入的引數 replace?: boolean 來進行
    判斷呼叫 pushState 還是 replaceState ,同時做了錯誤捕獲,如果,history 無刷
    新修改訪問路徑失敗,則呼叫 window.location.replace(url) ,有重新整理的切換使用者訪
    問地址 ,同理 pushState 也是這樣。這裡的 transitionTo 方法主要的作用是做檢視
    的跟新及路由跳轉監測,如果 url 沒有變化(訪問地址切換失敗的情況),在 
    transitionTo 方法內部還會呼叫一個 ensureURL 方法,來修改 url。 
    transitionTo 方法中應用的父方法比較多,這裡不做長篇贅述。



複製程式碼

其他兩種方式hash模式abstract模式內部其實也是比較簡單的,我這裡就不一一為大家來解析原始碼。

參考:zhuanlan.zhihu.com/p/27588422

參考:juejin.im/post/5ba499…

參考:juejin.im/post/5ae958…

可讀:juejin.im/post/5ac61d…

相關文章