1. 程式人生 > >history原始碼解析-管理會話歷史記錄

history原始碼解析-管理會話歷史記錄

history是一個JavaScript庫,可讓你在JavaScript執行的任何地方輕鬆管理會話歷史記錄

1.前言

history是由Facebook維護的,react-router依賴於history,區別於瀏覽器的window.historyhistory是包含window.history的,讓開發者可以在任何環境都能使用history的api(例如NodeReact Native等)。

本篇讀後感分為五部分,分別為前言、使用、解析、demo、總結,五部分互不相連可根據需要分開看。

前言為介紹、使用為庫的使用、解析為原始碼的解析、demo是抽取原始碼的核心實現的小demo,總結為吹水,學以致用。

建議跟著原始碼結合本文閱讀,這樣更加容易理解!

  1. history
  2. history解析的Github地址

2.使用

history有三種不同的方法建立history物件,取決於你的程式碼環境:

  1. createBrowserHistory:支援HTML5 history api的現代瀏覽器(例如:/index);
  2. createHashHistory:傳統瀏覽器(例如:/#/index);
  3. createMemoryHistory:沒有Dom的環境(例如:NodeReact Native)。

注意:本片文章只解析createBrowserHistory,其實三種構造原理都是差不多的

<!DOCTYPE html>
<html>
  <head>
    <script src="./umd/history.js"></script>
    <script>
      var createHistory = History.createBrowserHistory
      // var createHistory = History.createHashHistory

      var page = 0
      // createHistory建立所需要的history物件
      var h = createHistory()

      // h.block觸發在位址列改變之前,用於告知使用者位址列即將改變
h.block(function (location, action) { return 'Are you sure you want to go to ' + location.path + '?' }) // h.listen監聽當前位址列的改變 h.listen(function (location) { console.log(location, 'lis-1') })
</script> </head> <body> <p>Use the two buttons below to test normal transitions.</p> <p> <!-- h.push用於跳轉 --> <button onclick="page++; h.push('/' + page, { page: page })">history.push</button> <!-- <button onclick="page++; h.push('/#/' + page)">history.push</button> --> <button onclick="h.goBack()">history.goBack</button> </p> </body> </html> 複製程式碼

block用於地址改變之前的擷取,listener用於監聽位址列的改變,pushreplacego(n)等用於跳轉,用法簡單明瞭

3.解析

貼出來的原始碼我會刪減對理解原理不重要的部分!!!如果想看完整的請下載原始碼看哈

從history的原始碼庫目錄可以看到modules資料夾,包含了幾個檔案:

  1. createBrowserHistory.js 建立createBrowserHistory的history物件;
  2. createHashHistory.js 建立createHashHistory的history物件;
  3. createMemoryHistory.js 建立createMemoryHistory的history物件;
  4. createTransitionManager.js 過渡管理(例如:處理block函式中的彈框、處理listener的佇列);
  5. DOMUtils.js Dom工具類(例如彈框、判斷瀏覽器相容性);
  6. index.js 入口檔案;
  7. LocationUtils.js 處理Location工具;
  8. PathUtils.js 處理Path工具。

入口檔案index.js

export { default as createBrowserHistory } from "./createBrowserHistory";
export { default as createHashHistory } from "./createHashHistory";
export { default as createMemoryHistory } from "./createMemoryHistory";
export { createLocation, locationsAreEqual } from "./LocationUtils";
export { parsePath, createPath } from "./PathUtils";
複製程式碼

把所有需要暴露的方法根據檔名區分開,我們先看history的建構函式createBrowserHistory

3.1 createBrowserHistory

// createBrowserHistory.js
function createBrowserHistory(props = {}){
  // 瀏覽器的history
  const globalHistory = window.history;
  // 初始化location
  const initialLocation = getDOMLocation(window.history.state);
  // 建立地址
  function createHref(location) {
    return basename + createPath(location);
  }

  ...

  const history = {
    //  window.history屬性長度
    length: globalHistory.length,

    // history 當前行為(包含PUSH-進入、POP-彈出、REPLACE-替換)
    action: "POP",

    // location物件(與地址有關)
    location: initialLocation,

    // 當前地址(包含pathname)
    createHref,

    // 跳轉的方法
    push,
    replace,
    go,
    goBack,
    goForward,

    // 擷取
    block,

    // 監聽
    listen
  };

  return history;
}

export default createBrowserHistory;
複製程式碼

無論是從程式碼還是從用法上我們也可以看出,執行了createBrowserHistory後函式會返回history物件,history物件提供了很多屬性和方法,最大的疑問應該是initialLocation函式,即history.location。我們的解析順序如下:

  1. location;
  2. createHref;
  3. block;
  4. listen;
  5. push;
  6. replace。

3.2 location

location屬性儲存了與位址列有關的資訊,我們對比下createBrowserHistory的返回值history.locationwindow.location

// history.location
history.location = {
  hash: ""
  pathname: "/history/index.html"
  search: "?_ijt=2mt7412gnfvjpfeuv4hjkq2uf8"
  state: undefined
}

// window.location
window.location = {
  hash: ""
  host: "localhost:63342"
  hostname: "localhost"
  href: "http://localhost:63342/history/index.html?_ijt=2mt7412gnfvjpfeuv4hjkq2uf8"
  origin: "http://localhost:63342"
  pathname: "/history/index.html"
  port: "63342"
  protocol: "http:"
  reload: ƒ reload()
  replace: ƒ ()
  search: "?_ijt=2mt7412gnfvjpfeuv4hjkq2uf8"
}
複製程式碼

結論是history.location是window.location的兒砸!我們來研究研究作者是怎麼處理的。

const initialLocation = getDOMLocation(window.history.state)
複製程式碼

initialLocation函式等於getDOMLocation函式的返回值(getDOMLocationhistory中會經常呼叫,理解好這個函式比較重要)。

// createBrowserHistory.js
function createBrowserHistory(props = {}){
  // 處理basename(相對地址,例如:首頁為index,假如設定了basename為/the/base,那麼首頁為/the/base/index)
  const basename = props.basename ? stripTrailingSlash(addLeadingSlash(props.basename)) : "";
  
  const initialLocation = getDOMLocation(window.history.state);

  // 處理state引數和window.location
  function getDOMLocation(historyState) {
    const { key, state } = historyState || {};
    const { pathname, search, hash } = window.location;

    let path = pathname + search + hash;

    // 保證path是不包含basename的
    if (basename) path = stripBasename(path, basename);

    // 建立history.location物件
    return createLocation(path, state, key);
  };

  const history = {
    // location物件(與地址有關)
    location: initialLocation,
    ...
  };

  return history;
}
複製程式碼

一般大型的專案中都會把一個功能拆分成至少兩個函式,一個專門處理引數的函式和一個接收處理引數實現功能的函式:

  1. 處理引數:getDOMLocation函式主要處理statewindow.location這兩引數,返回自定義的history.location物件,主要構造history.location物件是createLocation函式;
  2. 構造功能:createLocation實現具體構造location的邏輯。

接下來我們看在LocationUtils.js檔案中的createLocation函式

// LocationUtils.js
import { parsePath } from "./PathUtils";

export function createLocation(path, state, key, currentLocation) {
  let location;
  if (typeof path === "string") {
    // 兩個引數 例如: push(path, state)

    // parsePath函式用於拆解地址 例如:parsePath('www.aa.com/aa?b=bb') => {pathname: 'www.aa.com/aa', search: '?b=bb', hash: ''}
    location = parsePath(path);
    location.state = state;
  } else {
    // 一個引數 例如: push(location)
    location = { ...path };

    location.state = state;
  }

  if (key) location.key = key;

  // location = {
  //   hash: ""
  //   pathname: "/history/index.html"
  //   search: "?_ijt=2mt7412gnfvjpfeuv4hjkq2uf8"
  //   state: undefined
  // }
  return location;
}

// PathUtils.js
export function parsePath(path) {
  let pathname = path || "/";
  let search = "";
  let hash = "";

  const hashIndex = pathname.indexOf("#");
  if (hashIndex !== -1) {
    hash = pathname.substr(hashIndex);
    pathname = pathname.substr(0, hashIndex);
  }

  const searchIndex = pathname.indexOf("?");
  if (searchIndex !== -1) {
    search = pathname.substr(searchIndex);
    pathname = pathname.substr(0, searchIndex);
  }

  return {
    pathname,
    search: search === "?" ? "" : search,
    hash: hash === "#" ? "" : hash
  };
}
複製程式碼

createLocation根據傳遞進來的path或者location值,返回格式化好的location,程式碼簡單。

3.3 createHref

createHref函式的作用是返回當前路徑名,例如地址http://localhost:63342/history/index.html?a=1,呼叫h.createHref(location)後返回/history/index.html?a=1

// createBrowserHistory.js
import {createPath} from "./PathUtils";

function createBrowserHistory(props = {}){
  // 處理basename(相對地址,例如:首頁為index,假如設定了basename為/the/base,那麼首頁為/the/base/index)
  const basename = props.basename ? stripTrailingSlash(addLeadingSlash(props.basename)) : "";

  function createHref(location) {
    return basename + createPath(location);
  }
  
  const history = {
    // 當前地址(包含pathname)
    createHref,
    ...
  };

  return history;
}

// PathUtils.js
function createPath(location) {
  const { pathname, search, hash } = location;

  let path = pathname || "/";
  
  if (search && search !== "?") path += search.charAt(0) === "?" ? search : `?${search}`;

  if (hash && hash !== "#") path += hash.charAt(0) === "#" ? hash : `#${hash}`;

  return path;
}
複製程式碼

3.4 listen

在這裡我們可以想象下大概的 監聽 流程:

  1. 繫結我們設定的監聽函式;
  2. 監聽歷史記錄條目的改變,觸發監聽函式。

第二章使用程式碼中,建立了History物件後使用了h.listen函式。

// index.html
h.listen(function (location) {
  console.log(location, 'lis-1')
})
h.listen(function (location) {
  console.log(location, 'lis-2')
})
複製程式碼

可見listen可以繫結多個監聽函式,我們先看作者的createTransitionManager.js是如何實現繫結多個監聽函式的。

createTransitionManager是過渡管理(例如:處理block函式中的彈框、處理listener的佇列)。程式碼風格跟createBrowserHistory幾乎一致,暴露全域性函式,呼叫後返回物件即可使用。

// createTransitionManager.js
function createTransitionManager() {
  let listeners = [];

  // 設定監聽函式
  function appendListener(fn) {
    let isActive = true;

    function listener(...args) {
      // good
      if (isActive) fn(...args);
    }

    listeners.push(listener);

    // 解除
    return () => {
      isActive = false;
      listeners = listeners.filter(item => item !== listener);
    };
  }

  // 執行監聽函式
  function notifyListeners(...args) {
    listeners.forEach(listener => listener(...args));
  }

  return {
    appendListener,
    notifyListeners
  };
}
複製程式碼
  1. 設定監聽函式appendListenerfn就是使用者設定的監聽函式,把所有的監聽函式儲存在listeners陣列中;
  2. 執行監聽函式notifyListeners:執行的時候僅僅需要迴圈依次執行即可。

這裡感覺有值得借鑑的地方:新增佇列函式時,增加狀態管理(如上面程式碼的isActive),決定是否啟用。

有了上面的理解,下面看listen原始碼。

// createBrowserHistory.js
import createTransitionManager from "./createTransitionManager";
const transitionManager = createTransitionManager();

function createBrowserHistory(props = {}){
  function listen(listener) {
    // 新增 監聽函式 到 佇列
    const unlisten = transitionManager.appendListener(listener);

    // 新增 歷史記錄條目 的監聽
    checkDOMListeners(1);

    // 解除監聽
    return () => {
      checkDOMListeners(-1);
      unlisten();
    };
  }

  const history = {
    // 監聽
    listen
    ...
  };

  return history;
}


複製程式碼

history.listen是當歷史記錄條目改變時,觸發回撥監聽函式。所以這裡有兩步:

  1. transitionManager.appendListener(listener)把回撥的監聽函式新增到佇列裡;
  2. checkDOMListeners監聽歷史記錄條目的改變;

下面看看如何歷史記錄條目的改變checkDOMListeners(1)

// createBrowserHistory.js
function createBrowserHistory(props = {}){
  let listenerCount = 0;

  function checkDOMListeners(delta) {
    listenerCount += delta;
    
    // 是否已經新增
    if (listenerCount === 1 && delta === 1) {
      // 新增繫結,當歷史記錄條目改變的時候
      window.addEventListener('popstate', handlePopState);
    } else if (listenerCount === 0) {
      //  解除繫結
      window.removeEventListener('popstate', handlePopState);
    }
  }
  
  // getDOMLocation(event.state) = location = {
  //   hash: ""
  //   pathname: "/history/index.html"
  //   search: "?_ijt=2mt7412gnfvjpfeuv4hjkq2uf8"
  //   state: undefined
  // }
  function handlePopState(event) {
    handlePop(getDOMLocation(event.state));
  }
  
  function handlePop(location) {
    const action = "POP";
    setState({ action, location })
  }
}
複製程式碼

雖然作者寫了很多很細的回撥函式,可能會導致有些不好理解,但細細看還是有它道理的:

  1. checkDOMListeners:全域性只能有一個監聽歷史記錄條目的函式(listenerCount來控制);
  2. handlePopState:必須把監聽函式提取出來,不然不能解綁;
  3. handlePop:監聽歷史記錄條目的核心函式,監聽成功後執行setState

setState({ action, location })作用是根據當前地址資訊(location)更新history。

// createBrowserHistory.js
function createBrowserHistory(props = {}){
  function setState(nextState) {
    // 更新history
    Object.assign(history, nextState);
    history.length = globalHistory.length;

    // 執行監聽函式listen
    transitionManager.notifyListeners(history.location, history.action);
  }

  const history = {
    // 監聽
    listen
    ...
  };

  return history;
}
複製程式碼

在這裡,當更改歷史記錄條目成功後:

  1. 更新history;
  2. 執行監聽函式listen;

這就是h.listen的主要流程了,是不是還挺簡單的。

3.5 block

history.block的功能是當歷史記錄條目改變時,觸發提示資訊。在這裡我們可以想象下大概的 擷取 流程:

  1. 繫結我們設定的擷取函式;
  2. 監聽歷史記錄條目的改變,觸發擷取函式。

哈哈這裡是不是感覺跟listen函式的套路差不多呢?其實h.listenh.block的監聽歷史記錄條目改變的程式碼是公用同一套(當然拉只能繫結一個監聽歷史記錄條目改變的函式),3.1.3為了方便理解我修改了部分程式碼,下面是完整的原始碼。


第二章使用程式碼中,建立了History物件後使用了h.block函式(只能繫結一個block函式)。

// index.html
h.block(function (location, action) {
  return 'Are you sure you want to go to ' + location.path + '?'
})
複製程式碼

同樣的我們先看看作者的createTransitionManager.js是如何實現提示的。

createTransitionManager是過渡管理(例如:處理block函式中的彈框、處理listener的佇列)。程式碼風格跟createBrowserHistory幾乎一致,暴露全域性函式,呼叫後返回物件即可使用。

// createTransitionManager.js
function createTransitionManager() {
  let prompt = null;

  // 設定提示
  function setPrompt(nextPrompt) {
    prompt = nextPrompt;

    // 解除
    return () => {
      if (prompt === nextPrompt) prompt = null;
    };
  }

  /**
   * 實現提示
   * @param location:地址
   * @param action:行為
   * @param getUserConfirmation 設定彈框
   * @param callback 回撥函式:block函式的返回值作為引數
   */
  function confirmTransitionTo(location, action, getUserConfirmation, callback) {
    if (prompt != null) {
      const result = typeof prompt === "function" ? prompt(location, action) : prompt;

      if (typeof result === "string") {
        // 方便理解我把原始碼getUserConfirmation(result, callback)直接替換成callback(window.confirm(result))
        callback(window.confirm(result))
      } else {
        callback(result !== false);
      }
    } else {
      callback(true);
    }
  }

  return {
    setPrompt,
    confirmTransitionTo
    ...
  };
}
複製程式碼

setPromptconfirmTransitionTo的用意:

  1. 設定提示setPrompt:把使用者設定的提示資訊函式儲存在prompt變數;
  2. 實現提示confirmTransitionTo:
    1. 得到提示資訊:執行prompt變數;
    2. 提示資訊後的回撥:執行callback把提示資訊作為結果返回出去。

下面看h.block原始碼。

// createBrowserHistory.js
import createTransitionManager from "./createTransitionManager";
const transitionManager = createTransitionManager();

function createBrowserHistory(props = {}){
  let isBlocked = false;

  function block(prompt = false) {
    // 設定提示
    const unblock = transitionManager.setPrompt(prompt);

    // 是否設定了block
    if (!isBlocked) {
      checkDOMListeners(1);
      isBlocked = true;
    }

    // 解除block函式
    return () => {
      if (isBlocked) {
        isBlocked = false;
        checkDOMListeners(-1);
      }

      // 消除提示
      return unblock();
    };
  }

  const history = {
    // 擷取
    block,
    ...
  };

  return history;
}
複製程式碼

history.block的功能是當歷史記錄條目改變時,觸發提示資訊。所以這裡有兩步:

  1. transitionManager.setPrompt(prompt) 設定提示;
  2. checkDOMListeners 監聽歷史記錄條目改變的改變。

這裡感覺有值得借鑑的地方:呼叫history.block,它會返回一個解除監聽方法,只要呼叫一下返回函式即可解除監聽或者復原(有趣)。


我們看看監聽歷史記錄條目改變函式checkDOMListeners(1)(注意:transitionManager.confirmTransitionTo)。

// createBrowserHistory.js
function createBrowserHistory(props = {}){
  function block(prompt = false) {
    // 設定提示
    const unblock = transitionManager.setPrompt(prompt);

    // 是否設定了block
    if (!isBlocked) {
      checkDOMListeners(1);
      isBlocked = true;
    }

    // 解除block函式
    return () => {
      if (isBlocked) {
        isBlocked = false;
        checkDOMListeners(-1);
      }

      // 消除提示
      return unblock();
    };
  }

  let listenerCount = 0;

  function checkDOMListeners(delta) {
    listenerCount += delta;
    
    // 是否已經新增
    if (listenerCount === 1 && delta === 1) {
      // 新增繫結,當地址欄改變的時候
      window.addEventListener('popstate', handlePopState);
    } else if (listenerCount === 0) {
      //  解除繫結
      window.removeEventListener('popstate', handlePopState);
    }
  }
  
  // getDOMLocation(event.state) = location = {
  //   hash: ""
  //   pathname: "/history/index.html"
  //   search: "?_ijt=2mt7412gnfvjpfeuv4hjkq2uf8"
  //   state: undefined
  // }
  function handlePopState(event) {
    handlePop(getDOMLocation(event.state));
  }
  
  function handlePop(location) {
    // 不需要重新整理頁面
    const action = "POP";

    // 實現提示
    transitionManager.confirmTransitionTo(
      location,
      action,
      getUserConfirmation,
      ok => {
        if (ok) {
          // 確定
          setState({ action, location });
        } else {
          // 取消
          revertPop(location);
        }
      }
    );
  }

  const history = {
    // 擷取
    block
    ...
  };

  return history;
}
複製程式碼

就是在handlePop函式觸發transitionManager.confirmTransitionTo的(3.1.3我對這裡做了修改為了方便理解)。


transitionManager.confirmTransitionTo的回撥函式callback有兩條分支,使用者點選提示框的確定按鈕或者取消按鈕:

  1. 當用戶點選提示框的確定後,執行setState({ action, location })
  2. 當用戶點選提示框的取消後,執行revertPop(location)(忽略)。

到這裡已經瞭解完h.block函式、h.listencreateTransitionManager.js。接下來我們繼續看另一個重要的函式h.push

3.6 push

function createBrowserHistory(props = {}){
  function push(path, state) {
    const action = "PUSH";
    // 構造location
    const location = createLocation(path, state, createKey(), history.location);

    // 執行block函式,彈出框
    transitionManager.confirmTransitionTo(
      location,
      action,
      getUserConfirmation,
      ok => {
        if (!ok) return;

        // 獲取當前路徑名
        const href = createHref(location);
        const { key, state } = location;

        // 新增歷史條目
        globalHistory.pushState({ key, state }, null, href);
        
        if (forceRefresh) {
          // 強制重新整理
          window.location.href = href;
        } else {
          // 更新history
          setState({ action, location });
        }
      }
    );
  }

  const history = {
    // 跳轉
    push,
    ...
  };

  return history;
}
複製程式碼

這裡最重要的是globalHistory.pushState函式,它直接新增新的歷史條目。

3.7 replace

function createBrowserHistory(props = {}){
  function replace(path, state) {
    const action = "REPLACE";
    // 構造location
    const location = createLocation(path, state, createKey(), history.location);

    // 執行block函式,彈出框
    transitionManager.confirmTransitionTo(
      location,
      action,
      getUserConfirmation,
      ok => {
        if (!ok) return;
        // 獲取當前路徑名
        const href = createHref(location);
        const { key, state } = location;

        globalHistory.replaceState({ key, state }, null, href);

        if (forceRefresh) {
          window.location.replace(href);
        } else {
          setState({ action, location });
        }
      }
    );
  }

  const history = {
    // 跳轉
    replace,
    ...
  };

  return history;
}
複製程式碼

其實pushreplace的區別就是history.pushStatehistory.replaceState的區別。

3.8 go

function createBrowserHistory(props = {}){
   function go(n) {
    globalHistory.go(n);
  }

  function goBack() {
    go(-1);
  }

  function goForward() {
    go(1);
  }

  const history = {
    // 跳轉
    go,
    goBack,
    goForward,
    ...
  };

  return history;
}
複製程式碼

其實就是history.go的運用。

4.demo

手把手教你寫history,稍後放出哈哈哈~

5.總結

總的來說,如果不需要block的話,原生方法可以滿足。最主要還是對history.pushStatehistory.replaceStatehistory.go(n)popstate方法的運用。公司加班嚴重,利用僅剩的時間擴充下自己的知識面,最好的方法那就是閱讀原始碼了哈哈。開始總會有點困難,第一次讀一臉懵逼,第二次讀二臉懵逼,第三次讀有點懵逼,第四次讀這b牛逼~。只要堅持下多寫點測試用例慢慢理解就好了,加油!

fafa