前端單頁路由《stateman》原始碼解析

frontdog發表於2019-03-03

《stateman》是波神的一個超級輕量的單頁路由,拜讀之後寫寫自己的小總結。

stateman的github地址 github.com/leeluolee/s…

簡單使用

以下文章全部以該Demo作為例子講解。

Html:

<ul>
  <li><a href="#/home">/home"</a></li>
  <li><a href="#/contact">/contact"</a></li>
  <li><a href="#/contact/list">/contact/list</a></li>
  <li><a href="#/contact/2">/contact/2</a></li>
  <li><a href="#/contact/2/option">/contact/2/option</a></li>
  <li><a href="#/contact/2/message">/contact/2/message</a></li>
</ul>
複製程式碼

Javascript:

const StateMan = require('../stateman');

let config = {
  enter() {
    console.log('enter: ' + this.name);
  },
  leave() {
    console.log('leave: ' + this.name);
  },
  canLeave() {
    console.log('canLeave: ' + this.name);
    return true;
  },
  canEnter() {
    console.log('canEnter: ' + this.name);
    return true;
  },
  update() {
    console.log('update: ' + this.name);
  }
}      
      

function create(o = {}){
  o.enter= config.enter;
  o.leave = config.leave;
  o.canLeave = config.canLeave;
  o.canEnter = config.canEnter;
  o.update = config.update;
  return o;
}
  
let stateman = new StateMan();
stateman
  .state("home", config)
  .state("contact", config)
  .state("contact.list", config )
  .state("contact.detail", create({url: ":id(\\d+)"}))
  .state("contact.detail.option", config)
  .state("contact.detail.message", config)
  .start({});
複製程式碼

以上程式碼很簡單,首先例項化StateMan,然後通過state函式來建立一個路由狀態,同時傳入路由的配置,最後通過start來啟動,這時路由就開始工作了,以下講解順序會按照以上demo的程式碼執行順序來講解,一步一步解析stateman工作原理。

例項化路由:new StateMan()

function StateMan(options){

  if(this instanceof StateMan === false){ return new StateMan(options)}
  options = options || {};
  this._states = {};
  this._stashCallback = [];
  this.strict = options.strict;
  this.current = this.active = this;
  this.title = options.title;
  this.on("end", function(){
    var cur = this.current,title;
    while( cur ){
      title = cur.title;
      if(title) break; 
      cur = cur.parent;
    }
    document.title = typeof title === "function"? cur.title(): String( title || baseTitle ) ;
  })

}
複製程式碼

這裡的end事件會在state跳轉完成後觸發,這個後面會講到,當跳轉完成後會從當前state節點一層一層往上找到title設定賦給document.title

state樹

image

stateman根據stateName的"."確定父子關係,整個路由的模組最終是上圖右邊的樹狀結構。

構建state樹程式碼分析

image

StateMan.prototype.state

var State = require('./state.js');
var stateFn = State.prototype.state;

...

state: function(stateName, config){

  var active = this.active;
  if(typeof stateName === "string" && active){
     stateName = stateName.replace("~", active.name)
     if(active.parent) stateName = stateName.replace("^", active.parent.name || "");
  }
  // ^ represent current.parent
  // ~ represent  current
  // only 
  return stateFn.apply(this, arguments);

}
複製程式碼

程式碼做了兩件事:

  • stateName的替換
    • "~": 代表當前所處的active狀態;
    • "^": 代表active狀態的父狀態; 例如:
stateman.state({
  "app.user": function() {
    stateman.go("~.detail")  // will navigate to app.user.detail
  },
  "app.contact.detail": function() {
    stateman.go("^.message")  // will navigate to app.contact.message 
  }
})
複製程式碼
  • 使用State.prototype.state函式來找到或者建立state
stateFn.apply(this, arguments);
複製程式碼

State.prototype.state

state: function(stateName, config){
    
    if(_.typeOf(stateName) === "object"){
    
        for(var i in stateName){
            this.state(i, stateName[i]); //注意,這裡的this指向stateman
        }
        
        return this;
    }

    var current, next, nextName, states = this._states, i = 0;

    if( typeof stateName === "string" ) stateName = stateName.split(".");

    var slen = stateName.length, current = this;
    var stack = [];


    do{
        nextName = stateName[i];
        next = states[nextName];
        stack.push(nextName);
        if(!next){
            if(!config) return;
            next = states[nextName] = new State();
            _.extend(next, {
                parent: current,
                manager: current.manager || current,
                name: stack.join("."),
                currentName: nextName
            })
            current.hasNext = true;
            next.configUrl();
        }
        current = next;
        states = next._states;
    }while((++i) < slen )

    if(config){
        next.config(config);
        return this;
    } else {
        return current;
    }
}

複製程式碼

這個函式就是生成state樹的核心,每一個state可以看作是一個節點,它的子節點由自己的_states來儲存。在建立一個節點的時候,這個函式會將stateName以'.'分割,然後通過一個迴圈來從父節點向下檢查,如果發現某一個節點不存在,就建立出來,同時配置它的url

state生成url:State.prototype.configUrl

configUrl: function(){
    var url = "" , base = this, currentUrl;
    var _watchedParam = [];

    while( base ){

      url = (typeof base.url === "string" ? base.url: (base.currentName || "")) + "/" + url;

      // means absolute;
      if(url.indexOf("^/") === 0) {
        url = url.slice(1);
        break;
      }
      base = base.parent;
    }
    this.pattern = _.cleanPath("/" + url);
    var pathAndQuery = this.pattern.split("?");
    this.pattern = pathAndQuery[0];
    // some Query we need watched

    _.extend(this, _.normalize(this.pattern), true);
}
複製程式碼

程式碼中以自己(當前state)為起點,向上連線父節點的url,如果url中帶有^說明這是個絕對路徑,這時候不會向上連線url

if(url.indexOf("^/") === 0) {
    url = url.slice(1);
    break;
}
複製程式碼

_.cleanPath(url): 把所有url的形式變成:'/some//some/' -> '/some/some'

_.normalize(path): 解析path

_.normalize('/contact/(detail)/:id/(name)');
=>
{
    keys: [0, "id", 1],
    matches: "/contact/(0)/(id)/(1)",
    regexp: /^\/contact\/(detail)\/([\w-]+)\/(name)\/?$/
}
複製程式碼

啟動路由:StateMan.prototype.start

start: function(options){

    if( !this.history ) this.history = new Histery(options); 
    if( !this.history.isStart ){
        this.history.on("change", _.bind(this._afterPathChange, this));
        this.history.start();
    } 
    return this;

},
複製程式碼

在啟動路由的時候,同時做了3件事:

  • 例項化history
  • 監聽history的change事件
  • 啟動history

這裡監聽了history的change事件這個動作,是連線stateman和history的橋樑。

history工作流程

history這邊的程式碼邏輯比較清晰,所以不講解太多程式碼,主要講解流程。

主要的工作原理分為了3個路線:

  • onhashchange:利用onhashchange事件來檢測路由變化
  • onpopstate:這個是html5新API,在我們點選瀏覽器前進後退時觸發,也就是說hash改變的時候並不會出發這個事件,所有點選a標籤的時候需要進行檢測,點選a標籤,阻止預設跳轉,呼叫pushState來增加一條歷史,然後路由觸發跳轉。
  • iframe hack:在舊版本IE,IE8以下並不支援以上兩個事件,這裡設定了一個定時器,定時去檢視路徑是不是發生了變化,如果發生了變化,就觸發路由跳轉

image

生命週期:單頁不同state之間的跳轉

當路由跳轉時,state樹會按照以下順序進行一系列的生命週期:

image

  1. 找到兩個state節點的共同父節點

permission階段:

  1. 從當前state節點往上到共同父節點進行canLeave
  2. 從共同父節點往下到目標節點進行canEnter

navigation階段:

  1. 從當前state節點往上到共同父節點進行leave
  2. 從共同父節點往上到根節點進行update
  3. 從共同父節點往下到目標節點進行enter

流程分析

在stateman的start函式中有這麼一句話:

this.history.on("change", _.bind(this._afterPathChange, this));
複製程式碼

上面說了,在history模組路由變化最終會觸發change事件,所以這裡會執行this._afterPatchChange函式

image

核心關鍵在於walk-transit-loop之間的迴圈和回撥的執行。

第一次walk函式時為permission階段,第二次為navigation階段

每次walk函式執行2次transit函式,所以transit函式共執行4次

2次為從當前節點到共同父節點的遍歷(canLeave、leave)

2次為從共同父節點到目標節點的遍歷(canEnter、enter)

每次的遍歷都是通過loop函式來執行,

節點之間的移動通過moveOn函式來執行

每一個函式我就不拿出來細講了,沒錯,著一定是一篇假的原始碼解析。

這裡提一下permission階段的canLeave、canEnter是支援非同步的。

permission階段返回Promise

在_moveOn裡面有這麼一段程式碼:

function done( notRejected ){
    if( isDone ) return;
    isPending = false;
    isDone = true;
    callback( notRejected );
}

...

var retValue = applied[method]? applied[method]( option ): true;

...

if( _.isPromise(retValue) ){

    return this._wrapPromise(retValue, done); 

}

複製程式碼

另外,_wrapPromise函式為:

_wrapPromise: function( promise, next ){

    return promise.then( next, function(){next(false)}) ;

}
複製程式碼

程式碼很少,理解起來也容易,就是在moveOn的時候如果canLeave、canEnter函式執行返回值是一個Promise,那麼moveOn函式會終止,同時通過done傳入這個Promise,在Fulfilled的時候觸發,done函式會執行callback,也就是loop函式,從而繼續生命週期的迴圈。

在不支援Promise的環境的非同步

moveOn裡面提供了option.sync函式來讓我們手動停止moveOn的迴圈。

option.async = function(){

    isPending = true;

    return done;
}

...

if( !isPending ) done( retValue )   //程式碼的最後是這樣的
複製程式碼

從最後一句來看,我們如果需要非同步的話,舉個例子,在canLeave函式中:

canLeave: function(option) {
    var done = option.sync();    // return the done function
    ....
    省略你的業務程式碼,在你業務程式碼結束後使用:
    done(true) 表示繼續執行
    done(false) 表示終止路由跳轉
    ....
}   
複製程式碼

相關文章