前端单页路由《stateman》源码解析

1,330 阅读6分钟

《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) 表示终止路由跳转
    ....
}