1. 程式人生 > >前端路由的實現(二)

前端路由的實現(二)

handle 因此 win 修改 etc ide span earch key

HTML5History

History interface是瀏覽器歷史記錄棧提供的接口,通過back(), forward(), go()等方法,我們可以讀取瀏覽器歷史記錄棧的信息,進行各種跳轉操作。

從HTML5開始,History interface提供了兩個新的方法:pushState(), replaceState()使得我們可以對瀏覽器歷史記錄棧進行修改

技術分享圖片
  • stateObject: 當瀏覽器跳轉到新的狀態時,將觸發popState事件,該事件將攜帶這個stateObject參數的副本

  • title: 所添加記錄的標題

  • URL: 所添加記錄的URL

這兩個方法有個共同的特點:當調用他們修改瀏覽器歷史記錄棧後,雖然當前URL改變了,但瀏覽器不會立即發送請求該URL(the browser won‘t attempt to load this URL after a call to pushState()),這就為單頁應用前端路由“更新視圖但不重新請求頁面”提供了基礎。

我們來看vue-router中的源碼:

var HTML5History = (function (History$$1) {
  function HTML5History (router, base) {
    
var this$1 = this; History$$1.call(this, router, base); var expectScroll = router.options.scrollBehavior; if (expectScroll) { setupScroll(); } var initLocation = getLocation(this.base); window.addEventListener(popstate, function (e) { var current = this
$1.current; // Avoiding first `popstate` event dispatched in some browsers but first // history route not updated since async guard at the same time. var location = getLocation(this$1.base); if (this$1.current === START && location === initLocation) { return } this$1.transitionTo(location, function (route) { if (expectScroll) { handleScroll(router, route, current, true); } }); }); } if ( History$$1 ) HTML5History.__proto__ = History$$1; HTML5History.prototype = Object.create( History$$1 && History$$1.prototype ); HTML5History.prototype.constructor = HTML5History; HTML5History.prototype.go = function go (n) { window.history.go(n); }; HTML5History.prototype.push = function push (location, onComplete, onAbort) { var this$1 = this; var ref = this; var fromRoute = ref.current; this.transitionTo(location, function (route) { pushState(cleanPath(this$1.base + route.fullPath)); handleScroll(this$1.router, route, fromRoute, false); onComplete && onComplete(route); }, onAbort); }; HTML5History.prototype.replace = function replace (location, onComplete, onAbort) { var this$1 = this; var ref = this; var fromRoute = ref.current; this.transitionTo(location, function (route) { replaceState(cleanPath(this$1.base + route.fullPath)); handleScroll(this$1.router, route, fromRoute, false); onComplete && onComplete(route); }, onAbort); }; HTML5History.prototype.ensureURL = function ensureURL (push) { if (getLocation(this.base) !== this.current.fullPath) { var current = cleanPath(this.base + this.current.fullPath); push ? pushState(current) : replaceState(current); } }; HTML5History.prototype.getCurrentLocation = function getCurrentLocation () { return getLocation(this.base) }; return HTML5History; }(History));

代碼結構以及更新視圖的邏輯與hash模式基本類似,只不過將對window.location.hash直接進行賦值window.location.replace()改為了調用history.pushState()和history.replaceState()方法。

在HTML5History中添加對修改瀏覽器地址欄URL的監聽是直接監聽popstate事件

    window.addEventListener(popstate, function (e) {
      var current = this$1.current;

      // Avoiding first `popstate` event dispatched in some browsers but first
      // history route not updated since async guard at the same time.
      var location = getLocation(this$1.base);
      if (this$1.current === START && location === initLocation) {
        return
      }

      this$1.transitionTo(location, function (route) {
        if (expectScroll) {
          handleScroll(router, route, current, true);
        }
      });
    });

當然了HTML5History用到了HTML5的新特特性,是需要特定瀏覽器版本的支持的,前文已經知道,瀏覽器是否支持是通過變量supportsPushState來檢查的:

var inBrowser = typeof window !== undefined;
var supportsPushState = inBrowser && (function () {
  var ua = window.navigator.userAgent;

  if (
    (ua.indexOf(Android 2.) !== -1 || ua.indexOf(Android 4.0) !== -1) &&
    ua.indexOf(Mobile Safari) !== -1 &&
    ua.indexOf(Chrome) === -1 &&
    ua.indexOf(Windows Phone) === -1
  ) {
    return false
  }

  return window.history && pushState in window.history
})();

以上就是hash模式與history模式源碼的導讀,這兩種模式都是通過瀏覽器接口實現的,除此之外vue-router還為非瀏覽器環境準備了一個abstract模式,其原理為用一個數組stack模擬出瀏覽器歷史記錄棧的功能。當然,以上只是一些核心邏輯,為保證系統的魯棒性源碼中還有大量的輔助邏輯,也很值得學習。此外在vue-router中還有路由匹配、router-view視圖組件等重要部分,關於整體源碼的閱讀推薦滴滴前端的這篇文章。

兩種模式比較

在一般的需求場景中,hash模式與history模式是差不多的,但幾乎所有的文章都推薦使用history模式,理由竟然是:"#" 符號太醜...0_0 "

當然,嚴謹的我們肯定不應該用顏值評價技術的好壞。根據MDN的介紹,調用history.pushState()相比於直接修改hash主要有以下優勢:

  • pushState設置的新URL可以是與當前URL同源的任意URL;而hash只可修改#後面的部分,故只可設置與當前同文檔的URL

  • pushState設置的新URL可以與當前URL一模一樣,這樣也會把記錄添加到棧中;而hash設置的新值必須與原來不一樣才會觸發記錄添加到棧中

  • pushState通過stateObject可以添加任意類型的數據到記錄中;而hash只可添加短字符串

  • pushState可額外設置title屬性供後續使用

history模式的一個問題

我們知道對於單頁應用來講,理想的使用場景是僅在進入應用時加載index.html,後續在的網絡操作通過Ajax完成,不會根據URL重新請求頁面,但是難免遇到特殊情況,比如用戶直接在地址欄中輸入並回車,瀏覽器重啟重新加載應用等。

hash模式僅改變hash部分的內容,而hash部分是不會包含在HTTP請求中的:

技術分享圖片

故在hash模式下遇到根據URL請求頁面的情況不會有問題。

而history模式則會將URL修改得就和正常請求後端的URL一樣

技術分享圖片

在此情況下重新向後端發送請求,如後端沒有配置對應/user/id的路由處理,則會返回404錯誤。官方推薦的解決辦法是在服務端增加一個覆蓋所有情況的候選資源:如果 URL 匹配不到任何靜態資源,則應該返回同一個 index.html 頁面,這個頁面就是你 app 依賴的頁面。同時這麽做以後,服務器就不再返回 404 錯誤頁面,因為對於所有路徑都會返回 index.html 文件。為了避免這種情況,在 Vue 應用裏面覆蓋所有的路由情況,然後在給出一個 404 頁面。或者,如果是用 Node.js 作後臺,可以使用服務端的路由來匹配 URL,當沒有匹配到路由的時候返回 404,從而實現 fallback。

直接加載應用文件

Tip: built files are meant to be served over an HTTP server.

Opening index.html over file:// won‘t work.

Vue項目通過vue-cli的webpack打包完成後,命令行會有這麽一段提示。通常情況,無論是開發還是線上,前端項目都是通過服務器訪問,不存在 "Opening index.html over file://" ,但程序員都知道,需求和場景永遠是千奇百怪的,只有你想不到的,沒有產品經理想不到的。

本文寫作的初衷就是遇到了這樣一個問題:需要快速開發一個移動端的展示項目,決定采用WebView加載Vue單頁應用的形式,但沒有後端服務器提供,所以所有資源需從本地文件系統加載:

// AndroidAppWrapper
public class MainActivity extends AppCompatActivity {

 private WebView webView;

 @Override
 protected void onCreate(Bundle savedInstanceState) {
 super.onCreate(savedInstanceState);

 webView = new WebView(this);
 webView.getSettings().setJavaScriptEnabled(true);
 webView.loadUrl("file:///android_asset/index.html");
 setContentView(webView);
 }

 @Override
 public boolean onKeyDown(int keyCode, KeyEvent event) {
 if ((keyCode == KeyEvent.KEYCODE_BACK) && webView.canGoBack()) {
 webView.goBack();
 return true;
 }
 return false;
 }
}

此情此景看來是必須 "Opening index.html over file://" 了,為此,我首先要進行了一些設置

  • 在項目config.js文件中將assetsPublicPath字段的值改為相對路徑 ‘./‘

  • 調整生成的static文件夾中圖片等靜態資源的位置與代碼中的引用地址一致

這是比較明顯的需要改動之處,但改完後依舊無法順利加載,經過反復排查發現,項目在開發時,router設置為了history模式(為了美觀...0_0"),當改為hash模式後就可正常加載了。

為什麽會出現這種情況呢?我分析原因可能如下:

當從文件系統中直接加載index.html時,URL為:

file:///android_asset/index.html

而首頁視圖需匹配的路徑為path: ‘/‘ :

export default new Router({
 mode: history,
 routes: [
 {
 path: /,
 name: index,
 component: IndexView
 }
 ]
})

我們先來看history模式,在HTML5History中:

HTML5History.prototype.ensureURL = function ensureURL (push) {
    if (getLocation(this.base) !== this.current.fullPath) {
      var current = cleanPath(this.base + this.current.fullPath);
      push ? pushState(current) : replaceState(current);
    }
  };
function getLocation (base) {
  var path = window.location.pathname;
  if (base && path.indexOf(base) === 0) {
    path = path.slice(base.length);
  }
  return (path || /) + window.location.search + window.location.hash
}

邏輯只會確保存在URL,path是通過剪切的方式直接從window.location.pathname獲取到的,它的結尾是index.html,因此匹配不到 ‘/‘ ,故 "Opening index.html over file:// won‘t work" 。

再看hash模式,在HashHistory中:

function ensureSlash () {
  var path = getHash();
  if (path.charAt(0) === /) {
    return true
  }
  replaceHash(/ + path);
  return false
}

我們看到在代碼邏輯中,多次出現一個函數ensureSlash(),當#符號後緊跟著的是‘/‘,則返回true,否則強行插入這個‘/‘,故我們可以看到,即使是從文件系統打開index.html,URL依舊會變為以下形式:

file:///C:/Users/dist/index.html#/

getHash()方法返回的path為 ‘/‘ ,可與首頁視圖的路由匹配。

故要想從文件系統直接加載Vue單頁應用而不借助後端服務器,除了打包後的一些路徑設置外,還需確保vue-router使用的是hash模式。



前端路由的實現(二)