动手实践一下NodeJS事件驱动架构,流和模块

1,325 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第6天,点击查看活动详情

本文立足于代码,通过实现自定义事件、使用流读写数据以及导入导出模块,抛砖引玉,希望能对大家理解事件驱动结构、流和模块这几个Node.js中的核心概念有所帮助

事件驱动架构

是什么

image.png Node.js中的许多模块,如http模块,fs模块等,都是基于事件驱动架构所创建的。类似于JavaScript中的观察者模式

有什么用

  • 模块之间更加独立
  • 可以对同一个事件多次响应

代码说明

  • 自定义事件

    const EventEmitter = require("events"); // events模块
    // 继承
    class Sales extends EventEmitter {
      constructor() {
        super();
      }
    }
    // 创建实例
    const myEmitter = new Sales();
    // listener1
    myEmitter.on("newSale", () => {
      console.log("There was a new sale");
    });
    // listener2
    myEmitter.on("newSale", (store) => {
      console.log(`There are now ${store} items`);
    });
    // emitter
    myEmitter.emit("newSale", 10);
    
    
  • 使用内置的http模块

    const http = require("http");
    // 创建实例
    const server = http.createServer();
    // listener1
    server.on("request", (req, res) => {
      console.log("Request received!"); // 显示在控制台
      res.end("Request received!"); // 显示在浏览器
    });
    // listener2
    server.on("request", (req, res) => {
      console.log("Another request received!");
    });
    // emitter(启动服务器)
    server.listen(8000, "127.0.0.1", () => {
      console.log("Waiting for request...");
    });
    

需要注意的地方

  • 设置多个事件监听器时,其执行顺序的规则与同步代码一样
  • 编写自定义事件时,注意emitterlistener的顺序

是什么

官网的描述是:

A stream is an abstract interface for working with streaming data in Node.js.

也就是说,流是用于处理流式数据的抽象接口???好家伙,用自己解释自己!!!

那就不管了,先把概念封装起来,直接看API

  • Node.js中的流有四种

    作用案例重要事件重要函数
    ⭐可读流读取数据http 请求,fs 读取文件data, endpipe(), read()
    ⭐可写流写入数据http 响应, fs 写入文件drain, finishwrite(), end()
    双工流即可读又可写net模块的web socket//
    转换流在读写的时候转换数据zlib模块的Gzip creation//
    • Node.js中的Stream本质上是EventEmitter的实例,所以也可以发射和监听事件

有什么用

  • 用于读取或修改数据/文件片段,从而不需要在内存中保存全部资源
  • 适合用于处理海量的数据,比如流媒体

代码说明

首先来设想一个需求:从磁盘读取文件,然后发给客户端

so easy,直接调用fs模块和http模块,一顿操作:

// 从磁盘读取text文件,发给客户端
const fs = require("fs");
const server = require("http").createServer();

server.on("request", (req, res) => {
  fs.readFile("test-file.txt", (err, data) => {
    if (err) console.log(err);
    res.end(data);
  });
});

server.listen(8000, "127.0.0.1", () => {
  console.log("Listening request...");
});

所读取的test-file.txt文件的内容是:100万行“Node.js is the best!”

image.png

启动Node程序,并在浏览器输入127.0.0.1:8000,如期看到文本内容:

image.png

看到这里,相信大家也发现问题了:

  • 使用上面的代码读取的是全部的文本内容,但有时候我们只需要获取部分数据就能满足需求
  • 使用fs.readFile()读取文件时,本质上是先将文件的内容存在变量data中,等全部读取完再发送给客户端,这就给Node应用带来了内存压力,当文件很大时程序就崩溃了

👉使用流可以解决以上问题,代码如下:

const fs = require("fs");
const server = require("http").createServer();

server.on("request", (requ, res) => {
  // 创建可读流实例
  const readable = fs.createReadStream("test-file.txt");
  // 读取文件时
  readable.on("data", (chunk) => {
    res.write(chunk); // 使用可写流的write()方法, chunk为每次传输的数据
  });
  // 文件读取完毕
  readable.on("end", () => {
    res.end();
  });
  // 读取出错
  readable.on("error", (err) => {
    console.log(err);
    res.statusCode(500);
    res.end("File not found!");
  });
});

server.listen(8000, "127.0.0.1", () => {
  console.log("Listening request...");
});

使用流读取数据的思路是:readable是一个可读流实例,在readable.on()中监听data事件,当不断读取数据时,data事件就会一直被触发,此时将读取的数据,不借助变量直接写入res中,文件全部读取完后便会触发end事件。

所以可以简单地将理解为分段传输数据

但上面的思路其实存在一个由读写的速度带来漏洞,如果可读流从磁盘读入数据的速度,远远大于写入res的速度,即接收到的这一波数据还来不及发送出去,下一波就来了,这就会造成内存溢出,也就是背压

👉可读流的pipe()方法可以解决这个问题,它可以自动处理数据的读写速度:

const fs = require("fs");
const server = require("http").createServer();

server.on("request", (requ, res) => {
  const readable = fs.createReadStream("test-file.txt");
  readable.pipe(res); // 优雅永不过时
});

server.listen(8000, "127.0.0.1", () => {
  console.log("Listening request...");
});

模块

是什么

  • Node.js中,每个.js文件都是一个模块
  • Node.js模块的导入导出使用的是CommonJS规范
    • require():引入模块
    • exports:导出多个变量;只能为模块添加属性
    • module.exports:导出单一变量,比如类或函数;可以为模块添加属性或赋值到新对象

require('module')的过程

graph LR
加载模块 --> 封装模块--> 执行模块--> 返回--> 缓存
  • 加载require加载模块的优先级:

    1. 核心模块:require('http')
    2. 自定义模块:require('./utils')
    3. 第三方模块(npm安装): require('express')

    完整的过程是:

    • 首先按照名称查找有无对应的核心模块
    • 如果以./../开头,就按照路径查找自定义的模块;没找到就去找同名文件夹下的index.js文件
    • 如果按照名称没有找到核心模块,就去node_modules目录下找;所以出现重名,就会优先加载核心模块
  • 封装Node.js封装模块时使用的函数封装器:

    (function(exports, require, module, __filename, __dirname) {
        // 模块的代码实际在这里
    });
    
    // require:加载模块
    // exports:从模块中导出对象
    // module:当前模块文件
    // __filename:当前模块文件的绝对路径
    // __dirname:当前模块文件据所在目录的绝对路径
    

    事实上,在任何一个.js文件中,输入console.log(arguments)就可以看到当前模块的上述参数值;此外,Node.js中有个核心模块就叫module,我们同样可以使用它来查看上面的函数封装器

    // index.js
    console.log(arguments);
    console.log('------------------------')
    console.log(require("module").wrapper);
    

    控制台的输出结果如下:

    [Arguments] {
    '0': {}, // require
    '1': // exports
    { [Function: require]
    resolve: { [Function: resolve] paths: [Function: paths] },
    main:
     Module {
       id: '.',
       exports: {},
       parent: null,
       filename: 'E:\\index.js',
       loaded: false,
       children: [],
       paths: [Array] },
    extensions:
     [Object: null prototype] { '.js': [Function], '.json': [Function], '.node': [Function] },   
    cache: [Object: null prototype] { 'E:\\index.js': [Module] } },
    '2': // module
    Module {
    id: '.',
    exports: {},
    parent: null,
    filename: 'E:\\index.js',
    loaded: false,
    children: [],
    paths: [ 'E:\\node_modules' ] },
    '3': 'E:\\index.js', // __filename
    '4': 'E:\\' } // __dirname
    ------------------------
    [ '(function (exports, require, module, __filename, __dirname) { ',
    '\n});' ]
    
  • 返回

    • require返回的是模块的导出,这些导出存储在module.exports这个对象中,而exports只是指向mudule.exports

代码说明

module.js

  • 使用module.exports导入test-module-1.js

    // module.js
    // 使用module.exports
    const C = require("./test-module-1");
    const calc1 = new C();
    console.log(calc1.add(3, 4));
    // 使用exports
    const { add, print } = require("./test-module-2"); // 导出的是对象(函数封装器中的exports),所以可以用解构赋值
    console.log(add(5, 5)); 
    print();
    
    // test-module-1.js
    module.exports = class {
      add(a, b) {
        return a + b;
      }
    
      multiply(a, b) {
        return a * b;
      }
    
      divide(a, b) {
        return a / b;
      }
    };
    
  • 使用exports导入test-module-2.js

    // test-module-2.js
    exports.add = (a, b) => a + b;
    exports.multiply = (a, b) => a * b;
    exports.divide = (a, b) => a / b;
    module.exports.print = () => {
      console.log("--------");
    };
    
  • 下面做些有意思的事情

    test-module-2.js添加一行代码,然后直接运行node test-module-2.js

    // test-module-2.js
    exports.add = (a, b) => a + b;
    exports.multiply = (a, b) => a * b;
    exports.divide = (a, b) => a / b;
    module.exports.print = () => {
      console.log("--------");
    };
    console.log(arguments);
    

    这里截取控制台输出的一小段

    [Arguments] {
     '0': // require
     { add: [Function],
     multiply: [Function],
     divide: [Function],
     print: [Function] },
     ......
     '2':
     Module {
      id:
       'E:\\Resourses\\Node\\complete-node-bootcamp\\2-how-node-works\\starter\\test-module-2.js',
      exports: // module.exports
       { add: [Function],
         multiply: [Function],
         divide: [Function],
         print: [Function] },
      parent:
      ......
     }
    

    我们已经知道这一项表示的是require,下面在module.js中也增加一行代码console.log(require('test-module-2.js')),对应的输出结果如下:

    { add: [Function],
    multiply: [Function],
    divide: [Function],
    print: [Function] }
    

    现在就比较清晰了,也验证了我们在上一节中的描述,require返回的其实就是Node.js内部module.exports这个对象

总结

  • http模块,fs模块等,都是基于事件驱动架构所创建的
  • 使用可读流读取数据可以不经过变量,直接将读取到的数据发送给客户端,减小了Node.js的内存压力
  • 可读流的pipe()方法可以解决背压问题:可读流实例.pipe(可写流实例)
  • exports用于导出多个变量,但只能为模块添加属性,不能赋值到新对象;module.exports用于导出单一变量,比如类或函数;也可以为模块添加属性或赋值到新对象;名称相同时,优先使用module.exports

参考资料

深入Node.js的模块加载机制,手写require函数 - 掘金 (juejin.cn)

详谈CommonJS模块化 - 掘金 (juejin.cn)

Node.js 入门系列(二)Node 模块 - 掘金 (juejin.cn)

模块系统 · 语雀 (yuque.com)