教你從寫一個迷你koa-router到閱讀koa-router原始碼

織雪紗奈發表於2019-03-22

本打算教一步步實現koa-router,因為要解釋的太多了,所以先簡化成mini版本,從實現部分功能到閱讀原始碼,希望能讓你好理解一些。 希望你之前有讀過koa原始碼,沒有的話,給你連結

最核心需求-路由匹配

router最重要的就是路由匹配,我們就從最核心的入手

router.get('/string',async (ctx, next) => {
  ctx.body = 'koa2 string'
})

router.get('/json',async (ctx, next) => {
  ctx.body = 'koa2 json'
})
複製程式碼

我們希望

  • 路徑訪問 /string 頁面顯示 'koa2 string'
  • 路徑訪問 /json 頁面顯示 'koa2 json'

先分析

收集開發者輸入的資訊配置

1.我們需要一個陣列,陣列裡每個都是一個物件,每個物件包含路徑,方法,函式,傳參等資訊 這個陣列我們起個名字叫stack

const stack = []
複製程式碼

2.對於每一個物件,我們起名叫layer 我們把它定義成一個函式

function Layer() {
    
}
複製程式碼

我們把頁面比喻成一個箱子,箱子是對外的,箱子需要有入口,需要容納。把每一個router比作放在箱子裡的物件,物件是內部的

定義兩個js頁面,router.js做為入口,對於當前頁面的訪問的處理,layer.js包含開發者已經約定好的規則

router.js

module.exports = Router;

function Router(opts) {
  // 容納layer層
  this.stack = [];
};
複製程式碼

layer.js

module.exports = Layer;

function Layer() {

};
複製程式碼

我們在Router要放上許多方法,我們可以在Router內部掛載方法,也可以在原型上掛載函式

但是要考慮多可能Router要被多次例項化,這樣裡面都要開闢一份新的空間,掛載在原型就是同一份空間。 最終決定掛載在原型上

方法有很多,我們先實現約定幾個常用的吧

const methods = [
  'get',
  'post',
  'put',
  'head',
  'delete',
  'options',
];
複製程式碼
methods.forEach(function(method) {
  Router.prototype[method] = function(path,middleware){
    // 對於path,middleware,我們需要把它交給layer,拿到layer返回的結果
    // 這裡交給另一個函式來是實現,我們叫它register就是暫存的意思
    this.register(path, [method], middleware);
    // 因為get還可以繼續get,我們返回this
    return this
  };
});
複製程式碼

實現layer的溝通

Router.prototype.register = function (path, methods, middleware) {
  let stack = this.stack;
  let route = new Layer(path, methods, middleware);
  stack.push(route);
  
  return route
};
複製程式碼

這裡我們先去寫layer


const pathToRegExp = require('path-to-regexp');

function Layer(path, methods, middleware) {
  // 把方法名稱放到methods陣列裡
  this.methods = [];
  // stack盛放中介軟體函式
  this.stack = Array.isArray(middleware) ? middleware : [middleware];
  // 路徑
  this.path = path;
  // 對於這個路徑生成匹配規則,這裡藉助第三方
  this.regexp = pathToRegExp(path);
  // methods
  methods.forEach(function(method) {
    this.methods.push(method.toUpperCase());
    // 繫結layer的this,不然匿名函式的this指向window
  }, this);

};
// 給一個原型方法match匹配返回true
Layer.prototype.match = function (path) {
  return this.regexp.test(path);
};
複製程式碼

回到router層

定義match方法,根據Developer傳入的path, method返回 一個物件(包括是否匹配,匹配成功layer,和匹配成功的方法)

Router.prototype.match = function (path, method) {
  const layers = this.stack;
  let layer;
  const matched = {
    path: [],
    pathAndMethod: [],
    route: false
  };
   //迴圈寄存好的stack層的每一個layer
  for (var len = layers.length, i = 0; i < len; i++) {
    layer = layers[i];

    //layer是提前存好的路徑, path是過來的path
    if (layer.match(path)) {
      // layer放入path,為什麼不把path傳入,一是path已經沒用了,匹配了就夠了,layer含有更多資訊需要用
      matched.path.push(layer);
      //如果methods什麼也沒寫,或者如果方法裡含有你的過來的方法,那麼把layer放入pathAndMethod
      if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) {
        matched.pathAndMethod.push(layer);
        // 路徑匹配,並且有方法
        if (layer.methods.length) matched.route = true;
      }
    }
  }

  return matched;
};

複製程式碼

給Developer一個方法

app.use(index.routes())
複製程式碼

這裡不考慮傳多個id,和多次匹配情況,拿到匹配的函式

Router.prototype.routes = function(){
  var router = this;

  const dispatch = function dispatch(ctx, next) {
    const path = ctx.path
    const method = ctx.method
    const matched = router.match(path, ctx.method);

    if (!matched.route) return next();
    const matchedLayers = matched.pathAndMethod
    // 先不考慮多matchedLayers多stack情況
    return matchedLayers[0].stack[0](ctx, next);
  }

  return dispatch
}
複製程式碼

此時一個迷你koa-router已經實現了

讀原始碼

需求實現

實現匹配

方法名匹配,路徑匹配,還要滿足動態引數的傳遞

並且還要給很懶的開發者一個router.all() 也就是說不用區分方法了?

 router
    .get('/', (ctx, next) => {
          ctx.body = 'Hello World!';
    })
    .post('/users', (ctx, next) => {
         // ...
    })
    .put('/users/:id', (ctx, next) => {
        // ...
    })
    .del('/users/:id', (ctx, next) => {
        // ...
    })
    .all('/users/:id', (ctx, next) => {
        // ...
   });
複製程式碼

寫法的多樣性

為了方便眾多的開發者使用

router.get('user', '/users/:id', (ctx, next) => {
 // ...
});
 
router.url('user', 3);
複製程式碼

如下寫法 都是一個路徑

// => "/users/3"
複製程式碼

支援中介軟體

router.get(
    '/users/:id',
    (ctx, next) => {
        return User.findOne(ctx.params.id).then(function(user)  
            ctx.user = user;
            next();
       });
    },
    ctx => {
        console.log(ctx.user);
        // => { id: 17, name: "Alex" }
    })
複製程式碼

多層巢狀

 var forums = new Router();
 var posts = new Router();
 posts.get('/', (ctx, next) => {...});
 posts.get('/:pid', (ctx, next) => {...});
 forums.use('/forums/:fid/posts', posts.routes(), posts.allowedMethods());
 //responds to "/forums/123/posts" and "/forums/123/posts/123"
 app.use(forums.routes());
複製程式碼

路徑字首(Router prefixes)

var router = new Router({
     prefix: '/users'
});
router.get('/', ...); 
// responds to "/users"
router.get('/:id', ...); 
// responds to "/users/:id"
複製程式碼

URL parameters

router.get('/:category/:title', (ctx, next) => {
    console.log(ctx.params);
  // => { category: 'programming', title: 'how-to-node' }
});
複製程式碼

router.js

methods.forEach(function (method) {
  Router.prototype[method] = function (name, path, middleware) {
    var middleware;

    if (typeof path === 'string' || path instanceof RegExp) {
      // 第二個引數是否是路徑,如果是路徑字串那麼從下表[2]開始才是中介軟體
      middleware = Array.prototype.slice.call(arguments, 2);
    } else {
      middleware = Array.prototype.slice.call(arguments, 1);
      path = name;
      name = null;
    }

    this.register(path, [method], middleware, {
      name: name
    });

    return this;
  };
});

//別名
Router.prototype.del = Router.prototype['delete'];
複製程式碼

methods引用第三方包含

function getBasicNodeMethods() {
  return [
    'get',
    'post',
    'put',
    'head',
    'delete',
    'options',
    'trace',
    'copy',
    'lock',
    'mkcol',
    'move',
    'purge',
    'propfind',
    'proppatch',
    'unlock',
    'report',
    'mkactivity',
    'checkout',
    'merge',
    'm-search',
    'notify',
    'subscribe',
    'unsubscribe',
    'patch',
    'search',
    'connect'
  ];
}
複製程式碼
Router.prototype.routes = Router.prototype.middleware = function () {
  var router = this;

  var dispatch = function dispatch(ctx, next) {
    var path = router.opts.routerPath || ctx.routerPath || ctx.path;
    var matched = router.match(path, ctx.method);
    var layerChain, layer, i;

    if (ctx.matched) {
      ctx.matched.push.apply(ctx.matched, matched.path);
    } else {
      ctx.matched = matched.path;
    }
    // ctx掛載router
    ctx.router = router;

    if (!matched.route) return next();
    // 拿到既匹配到路徑又匹配到方法的layer
    var matchedLayers = matched.pathAndMethod
    // 取出最後一個layer
    var mostSpecificLayer = matchedLayers[matchedLayers.length - 1]
    // 掛載_matchedRoute屬性
    ctx._matchedRoute = mostSpecificLayer.path;
    // 如果有name,既如下寫法會有name, name是string
    // router.get('/string','/string/:1',async (ctx, next) => {
    //   ctx.body = 'koa2 string'
    // })
    if (mostSpecificLayer.name) {
      // 掛載_matchedRouteName屬性
      ctx._matchedRouteName = mostSpecificLayer.name;
    }
    // layerChain就是中介軟體陣列,目前是兩個函式
    layerChain = matchedLayers.reduce(function(memo, layer) {
      memo.push(function(ctx, next) {
        ctx.captures = layer.captures(path, ctx.captures);
        ctx.params = layer.params(path, ctx.captures, ctx.params);
        // console.log('captures2', ctx.captures)
        // ctx.captures是 :id 的捕捉,正則匹配slice擷取得到
        // ctx.params是物件 {id:1}
        ctx.routerName = layer.name;
        return next();
      });
      return memo.concat(layer.stack);
    }, []);
    // 中介軟體呼叫layerChain
    return compose(layerChain)(ctx, next);
  };

  // routes掛載router物件
  dispatch.router = this;
  // 每次呼叫routes返回一個dispatch函式(layer.stack和memo),函式還有一個屬於這個路徑下的router屬性物件
  return dispatch;
};

複製程式碼

這裡使用compose-koa中介軟體的方式來處理傳遞多個函式和多種匹配的情況 captures和params 處理自定義路徑傳參

param

實現如下需求,訪問/users/:1 在param中能拿到user

router
  .param('user', (user, ctx, next) => {
    ctx.user = user;
 	if (!ctx.user) return ctx.status = 404;
	return next();
   })
  .get('/users/:user', ctx => {
    ctx.body = ctx.user;
  })
複製程式碼
Router.prototype.param = function (param, middleware) {
  this.params[param] = middleware;
  this.stack.forEach(function (route) {
    route.param(param, middleware);
  });
  return this;
};
複製程式碼
Layer.prototype.param = function (param, fn) {
  var stack = this.stack;
  var params = this.paramNames;
  var middleware = function (ctx, next) {
   // 第一個引數是 ctx.params[param], params拿到了user
    return fn.call(this, ctx.params[param], ctx, next);
  };
};
複製程式碼

params

實現如下需求

router.get('/:category/:title', (ctx, next) => {
  console.log(ctx.params);
 // => { category: 'programming', title: 'how-to-node' }
});
複製程式碼

例子

router.get('/string/:id',async (ctx, next) => {
  ctx.body = 'koa2 string'
})
複製程式碼

訪問 string/1

// 拿到{id:1}
ctx.params = layer.params(path, ctx.captures, ctx.params);

  
Layer.prototype.params = function (path, captures, existingParams) {
  var params = existingParams || {};
    
  for (var len = captures.length, i=0; i<len; i++) {
    if (this.paramNames[i]) {
      var c = captures[i];
      // 找到name賦值
      params[this.paramNames[i].name] = c ? safeDecodeURIComponent(c) : c;
    }
  }
  // 返回{id:1}
  return params;
};
複製程式碼

有興趣的可以研究一下allowedMethods,prefix,use,redirect等原型方法,這裡已經把最核心的展示了,至此,koa原始碼系列解讀完畢。

尾聲

從vue原始碼讀到webpack再到koa,深感原始碼架構的有趣,比做業務有趣太多,有意義太多。

以後原始碼閱讀應該不會記錄blog了,這樣學起來太慢了。當然也會繼續研究原始碼。

我覺得程式設計師不做開源不去github貢獻原始碼的人生是沒有意義的。 不想當將軍的士兵不是好士兵。 所以以後大部分時間會去做開源,謝謝閱讀。

相關文章