从零手写一套 Express 的源码

2,546 阅读9分钟

这里我将从零实现一套简易的Express源码,提供给来年“金三银四”跳槽高峰期的小伙伴们阅读也详细梳理一下自己对Express原理的理解。

起床工作
起床工作

什么是Express

Express是一个简洁、灵活的node.js Web 应用开发框架,它提供一系列强大的特性,帮助我们创建各种Web 和移动设备应用。丰富的 HTTP 快捷方法和任意排列组合的Connect 中间件,让你创建健壮、友好的API 变得即快捷又简单。

Express的使用

在了解 express 原理之前,我们首先要掌握其基本用法。Express 官方文档写的很详细 [Express 官网](www.expressjs.com.cn/ Express 官网)

初始化项目

在命令行依次输入:

mkdir express_self 新建项目目录
cd express_self/ 切换到项目目录
npm init 生成项目的一些信息,最终会生成一个package.json文件。注意:可以输入npm init -y可以不用按回车

安装 express

在项目根目录输入:

npm install express --save

初始化项目目录和文件

在项目根目录新建一下文件:

.gitignore 忽略文件上传到远程仓库
express 改文件夹存放手写express源码的目录
express/index.js 存放手写express核心源码文件
test.js 测试express常用功能文件夹

调用 get方法

在test.js文件中,使用express启动一个本地服务

const express = require('express');
const app = express();

app.get('/name', (req, res) => {
    res.end({
        name: '前端'
    })
})

app.listen(3000, () => {
    console.log('Server at port 3000')
})

调用post方法

const express = require('express');
const app = express();

app.post('/front', (req, res) => {
    res.json({
        name: 'front'
    })
})

app.listen(3000, () => {
    console.log('Server at port 3000')
})

调用use方法

在上面调用get方法中,中文出现了乱码,这是提示我们要设置响应头使其以文本格式返回

单个路由设置

app.get('/name', (req, res) => {
+    res.setHeader('Content-Type', 'text/html;charset=UTF-8;');
   res.end('前端')
})

设置中间件

设置中间件的好处:当路由多,则设置一个中间件即可解决所有路由中文乱码问题

+ app.use('/', (req, res, next) => {
+    res.setHeader('Content-Type', 'text/html;charset=UTF-8;');
+    next();
+ })

注意

  1. use 方法第一个参数不写默认就是 /

  2. 只要开头能匹配到后面的就都匹配到和路由有区别,路由的路径是完全相同

  3. 要随机应变更改中间件的位置,本文案例,要放到第一个路由前面才解决能所有路由中文乱码问题

扩展

什么是路由

路由:根据方法和路径执行匹配成功后的执行对应的回调函数

前端路由

在单页面应用,大部分页面结构不变,只改变部分内容的使用。前端路由是单页面富应用(SPA)的核心。

优点:

  1. 前后端分离

  2. 用户体验度好

缺点:

  1. 使用浏览器的前进,后退键的时候会重新发送请求

  2. 单页面无法记住之前滚动的位置,无法在前进,后退的时候记住滚动的位置

后端路由

通过用户请求的url导航到具体的html页面;每跳转到不同的URL,都是重新访问服务端,然后服务端返回页面,页面也可以是服务端获取数据,然后和模板组合,返回HTML,也可以是直接返回模板HTML,然后由前端***js*再去请求数据,使用前端模板和数据进行组合,生成想要的HTM**

优点:

  1. 服务端渲染后,可以直接返回给浏览器
  2. SEO 友好

缺点:

  1. 页面、数据与逻辑混合在一起
  2. 开发、维护成本高

中间件

中间件就是处理HTTP请求的函数,用来完成各种特定的任务;比如检查用户是否登录、用户是否有权限、计算请求的时间等,它的特点是:

  1. 一个中间件处理完请求和响应可以把对应数据再传递给下一个中间件
  2. 回调函数的next参数,表示接受其他中间件的调用,函数体中的next()表示将请求是否传递下去
  3. 可以根据路径来区分返回执行不同的中间件

Express中间件
Express中间件

实现自己的Express

接下来,我将一步一步的实现Express源码中常见的核心方法

加油干
加油干

返回核心函数

由于引入express后,返回的是一个函数,所有我们在编写源码时,需要导出一个函数app

function createApplication() {
    let app = (req, res) => {
        // 该方法内编写核心逻辑
    }
    return app;
}

module.exports = createApplication;

监听函数

由于监听函数是导出函数上的一个方法,所以我们在app函数上挂载一个listen方法,实现其监听原理。其原理就是调用原生node中http模块来创建服务。

从上面Express只用中,我们可以通过扩展运算符来获取所有的参数,巧妙的解决了用户传入一个还是两个参数的问题。

+ let http = require('http');

+ app.listen = function() {
+ 	let server = http.createServer(app);
+ 	server.listen(...arguments);
+ }

由于http上有很多方法,编写源码讲究的是高类聚、低耦合高可用的代码结构,所有我们不能一个方法写一段逻辑。http提供了METHODS属性会返回http所有的方法,然后我们对其所有方法进行遍历动态的解决方法高复用的代码逻辑;

http.METHODS 输出:
[ 'ACL', 'BIND', 'CHECKOUT','CONNECT', 'COPY', 'DELETE', 'GET', 'HEAD', 'LINK', 'LOCK', 'M-SEARCH', 'MERGE', 'MKACTIVITY', 'MKCALENDAR', 'MKCOL', 'MOVE', 'NOTIFY', 'OPTIONS', 'PATCH', 'POST', 'PROPFIND', 'PROPPATCH', 'PURGE', 'PUT', 'REBIND', 'REPORT', 'SEARCH', 'SOURCE', 'SUBSCRIBE', 'TRACE', 'UNBIND', 'UNLINK', 'UNLOCK', 'UNSUBSCRIBE' ]

然后对其获取的所有方法进行遍历即可。

注意:遍历时需要将所有的方法名转换为小写

http.METHODS.forEach(method => {
	method = method.toLowerCase(); // 将方法名转换为小写
})

实现get、post

由express基本用法可知,所有路由方法是一个函数,改函数接受两个参数:一个请求的路由路径、一个匹配路由成功后执行的回调函数。

一个express项目必定会有很多路由,我们需要将所有路由存放在一个数组容器里;且数组中的每一个路由对象都包括三个属性:一个请求方法、一个请求的路由路径、一个匹配路由成功后执行的回调函数。

核心逻辑:

+ app.routes = []; // 存放所有的路由对象

+ http.METHODS.forEach(method => {
+     method = method.toLowerCase(); // 将方法名转换为小写
+     /**
+      * path: 路径
+      * handler:监听函数 
+     */
+     app[method] = function(path, handler) {
+         let layer = {
+             method,
+             path,
+             handler
+        }
+         app.routes.push(layer);
+     }
+ })

编写所有方法的核心逻辑后,我们需要在上面核心函数中进行遍历获取其中属性与其用户传入的参数是否一致。一致则执行对应的成功回调函数,不一致提示用户具体错误原因。

let app = (req, res) => {
    // 该方法内编写核心逻辑
+    // 1. 获取请求的方法
+    let m = req.method.toLowerCase();
+    // 2. 获取请求的路径
+    let { pathname } = url.parse(req.url, true);

+   for(let i = 0; i < app.routes.length; i++) {
+        let { method, path, handler } = app.routes[i];
+        if((method === m) && (path === pathname)) {
+            handler(req, res);
+        }
+    }

+    res.end(`Cannont ${m} ${pathname} `);
}

从上述逻辑可知,若用户传入的请求方法名和请求路径相同的话,则执行回调函数并传入req、res两个参数。

all

和get函数不同app.all()函数可以匹配所有的HTTP动词,也就是说它可以过滤所有路径的请求。当请求方法为all时,所有路由都可以进行匹配。

格式:app.all(path,function(request, response));

用处:

  1. 在所有请求路由最前添加all方法设置中文乱码问题
  2. 在所有请求路由最后添加404页面

核心逻辑

+ app.all = function(path, handler) {
+ 		let layer = {
+         method: 'all', // 如果method是all,表示全部匹配
+         path,
+         handler
+     }
+     app.routes.push(layer);
+ }

由于http.MRTHODS返回所有方法中没有all方法,故我们需要自定义一个all方法挂载在核心函数上,此时需要修改对应的核心函数来兼容all方法。

let app = (req, res) => {

     for(let i = 0; i < app.routes.length; i++) {
          let { method, path, handler } = app.routes[i];
+          if((method === m || method === 'all') && (path === pathname || path === '*')) {
              handler(req, res);
          }
      }
        
 }

中间件

中间件作为express最为核心的功能之一,其逻辑也是最为复杂的一块,在编写其核心逻辑之前,我们需要对express的中间件有几点注意:

  1. 中间件方法都是use方法, 所以需要在核心函数上挂载use方法
  2. 中间件可以只传一个回调函数参数
  3. 若不传请求路径,系统则默认为 '/'
  4. 中间件的回调函数有一个next参数
  5. 中间件只要开头能匹配到后面的就都匹配到和路由有区别,路由是完全相同

核心逻辑

+ app.use = function(path, handler) {
+     // 处理中间件参数的问题
+     if(typeof handler !== 'function') {
+         handler = path;
+         path = '/';
+     }

+     let layer = {
+         method: 'middle', // method是middle就表示是一个中间件
+         path,
+         handler
+     }
+     app.routes.push(layer); // 将中间件放到容器内
+ }

此时需要修改上面核心函数方法来兼容use方法。

+ let app = (req, res) => {

+     // 通过next方法进行迭代
+     let index = 0; // 取第一个路由
+     function next() {
+     // 如果数组全部迭代完成还没有找到  说明路径不存在
+     if(index === app.routes.length) return res.end(`Cannot ${m} ${pathname}`);

+        let { method, path, handler } = app.routes[index++];
+        if(method === 'middle') { // 处理中间件
+             if(path === '/' || path === pathname || pathname.startsWith(path + '/')) {
+                 handler(req, res, next);
+             } else {
+                 next(); // 如果这个中间件没有匹配到  那么就继续走下一个layer
+             }
+        } else {
            // 处理路由
            if((method === m || method === 'all') && (path === pathname || path === '*')) {
                 handler(req, res);
             } else {
                 next();
             }
        }
+     }
    
+     next();   
+ }

错误中间件

一般中间件都只有3个参数:req、res、next。但是当一个中间件有4个参数的话系统则会被视为错误处理中间件。

+ function next(err) {
     if(method === 'middle') { // 处理中间件
         if(path === '/' || path === pathname || pathname.startsWith(path + '/')) {
             handler(req, res, next);
         } else {
+             next(err); // 如果这个中间件没有匹配到  那么就继续走下一个layer
         }
     }
}

内置中间件

我们为什么觉得Express即好用又简单呢;因为它框架中跟我们开发者把一些常用的方法都封装好了。接下来我就举一个内置中间件的原理实现:

+ // express 内置中间件
+ app.use(function(req, res, next) {
+    let {pathname, query} = url.parse(req.url, true);
+    let hostname = req.headers['host'].split(':')[0];
+    req.path = pathname;
+    req.query = query;
+    req.hostname = hostname;
+    next()
+ })

项目仓库地址

如果小伙伴觉得楼主写的对小伙伴有所收获,帮忙点个star github.com/tangmengche…

总结

我们大家都知道Express即简单又容易上手,但是很多小伙伴对其原理不了解,这对我们不管是面试还算自我提升都是不友好的。其实从头到尾下来,你会发现Express使用简单,其原理也不是很难,所有真心想提高自己技术功能的自己手动敲一遍,定会有所收获!