history原始碼解析-管理會話歷史記錄
history
是一個JavaScript庫,可讓你在JavaScript執行的任何地方輕鬆管理會話歷史記錄
1.前言
history
是由Facebook維護的,react-router
依賴於history
,區別於瀏覽器的window.history
,history
是包含window.history
的,讓開發者可以在任何環境都能使用history
的api(例如Node
、React Native
等)。
本篇讀後感分為五部分,分別為前言、使用、解析、demo、總結,五部分互不相連可根據需要分開看。
前言為介紹、使用為庫的使用、解析為原始碼的解析、demo是抽取原始碼的核心實現的小demo,總結為吹水,學以致用。
建議跟著原始碼結合本文閱讀,這樣更加容易理解!
- ofollow,noindex">history
- history解析的Github地址
2.使用
history
有三種不同的方法建立history物件,取決於你的程式碼環境:
-
createBrowserHistory
:支援HTML5 history api
的現代瀏覽器(例如:/index
); -
createHashHistory
:傳統瀏覽器(例如:/#/index
); -
createMemoryHistory
:沒有Dom的環境(例如:Node
、React 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
用於監聽位址列的改變,push
、replace
、go(n)
等用於跳轉,用法簡單明瞭
3.解析
貼出來的原始碼我會刪減對理解原理不重要的部分!!!如果想看完整的請下載原始碼看哈
從history的原始碼庫目錄可以看到modules資料夾,包含了幾個檔案:
- createBrowserHistory.js 建立createBrowserHistory的history物件;
- createHashHistory.js 建立createHashHistory的history物件;
- createMemoryHistory.js 建立createMemoryHistory的history物件;
- createTransitionManager.js 過渡管理(例如:處理block函式中的彈框、處理listener的佇列);
- DOMUtils.js Dom工具類(例如彈框、判斷瀏覽器相容性);
- index.js 入口檔案;
- LocationUtils.js 處理Location工具;
- 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
。我們的解析順序如下:
- location;
- createHref;
- block;
- listen;
- push;
- replace。
3.2 location
location屬性儲存了與位址列有關的資訊,我們對比下createBrowserHistory
的返回值history.location
和window.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
函式的返回值(getDOMLocation
在history
中會經常呼叫,理解好這個函式比較重要)。
// 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; } 複製程式碼
一般大型的專案中都會把一個功能拆分成至少兩個函式,一個專門處理引數的函式和一個接收處理引數實現功能的函式:
-
處理引數:
getDOMLocation
函式主要處理state
和window.location
這兩引數,返回自定義的history.location
物件,主要構造history.location
物件是createLocation
函式; -
構造功能:
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
在這裡我們可以想象下大概的監聽 流程:
- 繫結我們設定的監聽函式;
- 監聽歷史記錄條目的改變,觸發監聽函式。
在第二章使用
程式碼中,建立了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 }; } 複製程式碼
-
設定監聽函式
appendListener
:fn
就是使用者設定的監聽函式,把所有的監聽函式儲存在listeners
陣列中; -
執行監聽函式
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
是當歷史記錄條目改變時,觸發回撥監聽函式。所以這裡有兩步:
transitionManager.appendListener(listener) 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 }) } } 複製程式碼
雖然作者寫了很多很細的回撥函式,可能會導致有些不好理解,但細細看還是有它道理的:
-
checkDOMListeners
:全域性只能有一個監聽歷史記錄條目的函式(listenerCount
來控制); -
handlePopState
:必須把監聽函式提取出來,不然不能解綁; -
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; } 複製程式碼
在這裡,當更改歷史記錄條目成功後:
- 更新history;
- 執行監聽函式listen;
這就是h.listen
的主要流程了,是不是還挺簡單的。
3.5 block
history.block
的功能是當歷史記錄條目改變時,觸發提示資訊。在這裡我們可以想象下大概的擷取
流程:
- 繫結我們設定的擷取函式;
- 監聽歷史記錄條目的改變,觸發擷取函式。
哈哈這裡是不是感覺跟listen
函式的套路差不多呢?其實h.listen
和h.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 ... }; } 複製程式碼
setPrompt
和confirmTransitionTo
的用意:
- 設定提示setPrompt:把使用者設定的提示資訊函式儲存在prompt變數;
-
實現提示confirmTransitionTo:
- 得到提示資訊:執行prompt變數;
- 提示資訊後的回撥:執行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
的功能是當歷史記錄條目改變時,觸發提示資訊。所以這裡有兩步:
transitionManager.setPrompt(prompt) 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有兩條分支,使用者點選提示框的確定按鈕或者取消按鈕:
setState({ action, location }) revertPop(location)
到這裡已經瞭解完h.block
函式、h.listen
和createTransitionManager.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; } 複製程式碼
其實push
和replace
的區別就是history.pushState
和history.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,稍後放出哈哈哈~