【2024秋第8节课】深入浅出node.js

407 阅读18分钟

73c689a753152af48fc7874d3e544d0d.png

NodeJS是什么

JavaScript 运行时环境

Node.js 并不是一门语言,而是一个 JavaScript 运行时环境,它的语言是JavaScript。

Node.js 与 JavaScript

image.png

Node安装

node官网

nodejs.org/zh-cn

Nvm

有的项目对node版本有要求,nvm可以帮助我们更方便的切换nodejs版本

windos 安装地址

github.com/coreybutler…

Mac安装教程

www.zhihu.com/question/63…

Nvm 命令

nvm -v //查看nvm版本

nvm ls //查看已安装的 Node.js 版本

nvm install <version> //安装 Node.js 版本

nvm use <version> //使用指定的 Node.js 版本

nvm uninstall <version> //卸载 Node.js 版本

nvm ls-remote //查看可用的 Node.js 版本

nvm current //显示当前使用的 Node.js 版本

Nodejs模块化

早期 JavaScript 开发很容易存在全局污染依赖管理混乱的问题。我这里举一个很常见的场景:

<body>
    <script src="./index.js"></script>
    <script src="./home.js"></script>
    <script src="./list.js"></script>
</body>

如上,在没有模块化的前提下,如果html中这么写,那么就会暴露一系列问题。

  • 全局污染

没有模块化,那么 script 内部的变量是可以相互污染的。比如有一种场景,如上./index.js文件和 ./list.js 文件为 A 开发,./home.js 为 B 开发。

A在 index.js 中声明 name 是一个字符串。

var name = "哈哈哈"

然后A在 list.js 中,引用 name

console.log(name)

image.png

打印却发现 name 竟然变成了一个函数。刚开始 A 不知所措,后来发现 B 开发的 home.js 文件中这么写道:

function name() {
  return "home";
}

上述例子就是没有使用模块化开发,造成的全局污染的问题,每个加载的 js 文件都共享变量。当然在实际的项目开发中,可以使用匿名函数自执行的方式,形成独立的块级作用域解决这个问题。

只需要在home.js中这么写道:

(function () {
  function name() {
    return "home";
  }
})();

这样 A 就能正常在 list.js 中获取 name 属性。但是这只是一个 demo,在实际的开发中情况会更加复杂。所以,不使用模块开发会暴露出很多风险。

  • 依赖管理

依赖管理也是一个难以处理的问题。还是如上的例子,正常情况下,执行 js 的先后顺序就是 script 标签排列的前后顺序。那么如果三个 js 之间有依赖关系,那么如何处理呢???

Commonjs规范

Commonjs 的提出,弥补 Javascript 对于模块化,没有统一标准的缺陷。nodejs 借鉴了 Commonjs的 Module,实现了良好的模块化管理。

目前commonjs广泛应用于以下几个场景:

  • Node 是CommonJS在服务器端一个具有代表性的实现
  • webpack 打包工具对 CommonJS 的支持和转换; 也就是前端应用也可以在编译之前,尽情使用 CommonJS 进行开发。

commonjs 使用与原理

在使用规范下,有几个显著的特点。

  • commonjs中每一个js文件都是一个单独的模块,我们可以称之为 module
  • 在模块中,包含 CommonJS 规范的核心变量:exports module.exports require
  • exports 和 module.exports 可以负责对模块中的内容进行导出
  • require 函数可以从其他模块导入内容

commonjs 初体验

导出

hello.js

let name = "John";

function sayName() {
  return name;
}

// 导出
module.exports = {
  sayName,
};

导入

const { sayName } = require("./hello");

console.log(sayName);

如上就是 Commonjs 最简单的实现,那么暴露出两个问题:

  • 如何解决变量污染的问题
  • module.exports exports require 三者是如何工作的?又有什么关系?

commonjs实现原理

首先从上述得知,每个模块文件上存在module exports require三个变量,然而这三个变量是没有被定义的,但是我们可以在 Commonjs 规范下每一个 js 模块上直接使用它们。在nodejs中还存在__filename__dirname变量。

如上每一个变量代表什么意思呢:

  • module 记录当前模块信息
  • require 引入模块的方法
  • exports 当前模块导出的属性

在编译过程中,实际上 Commonjs 对 js 的代码块进行了首尾包装,我们以上述的hello.js为🌰,它被包装后的样子如下:

(function (exports, require, module, __filename, __dirname) {  
  let name = "John";  

  function sayName() {  
    return name;  
  }  

  // 导出  
  module.exports = {  
    sayName,  
  };  
});
  • 在 Commonjs 规范下模块中,会形成一个包装函数,我们写的代码将作为包装函数的执行上下文,使用的require,exports,module本质上是通过形参的方式传递到包装函数中。

那么包装函数本质上是什么样子的呢?????

function wrapper (script) {
    return '(function (exports, require, module, __filename, __dirname) {'+ 
    script + 
    '\n})'
}

包装函数执行。

const modulefunction = wrapper(`
let name = "John";  

  function sayName() {  
    return name;  
  }  

  // 导出  
  module.exports = {  
    sayName,  
  };  
`)
  • 如上模拟了一个包装函数功能,script为我们在 js 模块中写的内容,最后返回的就是如上包装之后的函数,当然这个函数暂且是一个字符串。
runInThisContext(modulefunction)(module.exports,require,module,__filename,__dirname)
  • 在模块加载的时候,会通过runInThisContext执行modulefunction,传入require,exports,module等参数。最终我们写的 nodejs 文件就执行了。

require文件加载流程

上述说了 commonjs 规范大致实现原理,接下来我们分析一下,require如何进行文件的加载的。 我们还是以 nodejs 为参考:

const fs = require('fs')  //核心模块
const sayName = require('./hello.js')  //文件模块
const crypto = require('crypto-js')  //第三方自定义模块

当 require 方法执行的时候,接收的唯一参数作为一个标识符,Commonjs 下对不同的标识符,处理流程不同,但是目的相同,都是找到对应的模块。

require加载标识符原则

  • 像fs,http,path等标识,会被作为 nodejs 的核心模块
  • ./../作为相对路径的文件模块/作为绝对路径的文件模块
  • 非路径形式也非核心模块的模块,将作为自定义模块

核心模块的处理:

核心模块的优先级仅次于缓存加载,在Node源码编译中,已被编译成二进制代码,所以加载核心模块,加载过程中速度最快。

路径形式的文件模块处理:

./,../,/开始的标识符,会被当成文件模块处理。require()方法会将路径转换成真实路径,并以真实路径作为索引,将编译后的结果缓存起来,第二次加载的时候会更快,至于怎么存的?我们稍后会讲到。

自定义模块处理: 自定义模块,一般指的是非核心的模块,他可能是一个文件或者一个包,它的查找会遵循以下原则:

  • 在当前目录下的node_modules目录查找
  • 如果没有,在父级目录的node_modules查找,如果没有,在父级目录的父级目录的node_modules中查找。
  • 沿着路径向上递归,直到根目录下的node_modules目录
  • 在查找过程中,会找package.json下 main 属性指向的文件,如果没有package.json,在 node 环境下会依次查找index.js,index.json,index.node

require模块引入与处理

CommonJS模块同步加载并执行模块文件

  • a.js文件
const getMes = require('./b');

console.log("我是a.js");

exports.say = function () {
  console.log(getMes());
};
  • b.js文件
const say = require("./a");

const object = { name: "b" };

console.log("我是b模块");

function func_b() {
  return object;
}

module.exports = {
  func_b,
};
  • main.js文件
const a = require("./a");
const b = require("./b");

console.log("node 入口文件");

我们执行 main.js文件

image.png

从上面的运行结果可以得出以下结论:

  • main.jsa.js模块都引用了b.js模块,但是b.js模块只执行了一次
  • a.js模块和b.js模块互相引用,但是没有造成循环引用的情况
  • 执行顺序是 父->子->父

那么Common.js规范是如何实现上述效果的呢?

require 加载原理

为了弄清楚上述两个问题。我们要明白两个概念,那就是moduleModule

module:在Node中每一个 js 文件都是一个 module, module上保存了 exports 等信息之外,还有一个 loaded 表示该模块是否被加载。

  • false表示还没有加载
  • true表示已经加载

Module:以 nodejs 为🌰,整个系统运行之后,会用Module缓存每一个模块加载的信息。

require 源码大致长如下的样子:

// 模拟的 require 函数实现  
const cache = {}; // 用于缓存已加载的模块  

function require(moduleName) {  
    // 检查模块是否已缓存  
    if (cache[moduleName]) {  
        return cache[moduleName].exports;  
    }  

    // 创建一个新的模块对象  
    const module = { exports: {} };  
    
    // 模拟加载模块的代码  
    const modulePath = resolveModulePath(moduleName); // 解析模块路径  
    const moduleCode = loadModuleCode(modulePath); // 读取模块代码  

    // 使用 Function 创建模块的执行函数  
    const moduleFunction = new Function('module', 'exports', moduleCode);  

    // 执行模块代码  
    moduleFunction(module, module.exports);  

    // 缓存模块的 exports  
    cache[moduleName] = module;  

    return module.exports;  
}

从上面我们总结出一次require大致流程是这样的

  • require会接收一个参数--文件标识符,然后分析定位文件,分析过程我们上述已经讲到了,接下来会从 Module 上查找有没有缓存,如果有缓存,那么直接返回缓存的内容。
  • 如果没有缓存,会创建一个module对象,缓存到Module上,然后执行文件,加载完文件了,将loaded属性设置为true,然后返回 module.exports 对象。借此完成模块加载流程。

require 避免重复加载

从上面我们可以直接得出,require 如何避免重复加载的,首先加载之后的文件的 module 会被缓存到 Module 上,比如一个模块已经 require 引入了a模块,如果另一个模块再次引入a,那么会直接读取缓存值module,所以无需再次执行模块

对应 demo 片段中,首先 main.js引用了a.js,a.js中 require 了 b.js,此时b.js的module放入缓存Module中,接下来main.js再次引用b.js,那么会直接走缓存逻辑。所以b.js只会在a.js引入的时候执行一次。

Buffer

在引入 TypedArray 之前,JavaScript 语言没有用于读取或操作二进制数据流的机制。Buffer 类是作为 Node.js API的一部分引入的,用于在 TCP 流,文件系统操作,以及其他上下文中与八位字节流进行交互。这是来自 Node.js 官网的一段描述。总结起来一句话 Node.js 可以用来处理二进制流数据或者与之进行交互

Buffer 用于读取或操作二进制数据流,作为Node.js API的一部分使用时无需 require,用于操作网络协议,数据库,图片和文件I/O等一些需要大量二进制数据的场景。Buffer 在创建时大小已经被确定且是无法调整的,在内存分配这块 Buffer 是由 C++ 层面提供而不是V8.

Buffer.from()

const b1 = Buffer.from('10')
const b2 = Buffer.from(['1','0'])
console.log(b1,b2)

Buffer.alloc

返回一个已初始化的 Buffer ,可以保证新创建的 Buffer 永远不会包含新数据

const buf = Buffer.alloc(10); //创建一个大小为 10 个字节的缓冲区

console.log(buf); //<Buffer 00 00 00 00 00 00 00 00 00 00>

字符串与 Buffer 类型互转

字符串转 Buffer

这个相信不会陌生,如果不传递 encoding 默认按照 UTF-8 格式转换存储

const buf = Buffer.from("Node.js真好学", "UTF-8");

console.log(buf);  //<Buffer 4e 6f 64 65 2e 6a 73 e7 9c 9f e5 a5 bd e5 ad a6>

console.log(buf.length); //16

Buffer 转为字符串

Buffer转为字符串也很简单,使用 toString([encoding], [start], [end])方法,默认编码依然是 UTF-8,如果不传 start, end 可实现全部转换,传入了start, end 可实现部分转换

const buf = Buffer.from("Node.js真好学", "UTF-8");

console.log(buf);

console.log(buf.length);

console.log(buf.toString("UTF-8", 0, 9)); //Node.js�

Node.js�?为什么出现了乱码???

转换过程为什么出现了乱码?

首先以上示例中使用的默认编码方式 UTF-8,问题就出现在这里,一个汉字在 UTF-8 下占3个字节,这个字在buf中对应的字节为e7 9c 9f,而我们的设定范围为 0~9 因此只输出了e7,这个时候就会造成字符被截断出现乱码。

fs模块

文件读取

同步读取方法readFileSync

readFileSync有两个参数:

  • 第一个参数为读取文件的路径或文件描述符
  • 第二个参数为options,默认值为null,其中有 encodingflag,也可以直接传入 encoding
  • 返回值为文件的内容,如果没有encoding,返回的文件内容为Buffer,如果有,按照传入的编码解析
const fs = require("fs");

let buf = fs.readFileSync("./hello.txt"); //<Buffer 68 65 6c 6c 6f 20 77 6f 72 6c 64>
let data = fs.readFileSync("./hello.txt", "utf8"); //hello world

异步读取方法 readFile

异步读取方法 readFilereadFileSync的前两个参数相同,最后一个参数是回调函数,回调函数接收两个参数errdata,该方法没有返回值,回调函数在读取文件成功后执行。

const fs = require("fs");

fs.readFile("./hello.txt", "utf-8", (err, dataStr) => {
  if (err) throw err;
  console.log("异步读取文件成功!" + dataStr);
});

文件写入

同步写入方法 writeFileSync

writeFileSync有三个参数:

  • 第一个参数为读取文件的路径或文件描述符
  • 第二个参数为写入的数据,类型为 String 或 Buffer
  • 第三个参数为options,默认值为null,其中有 encodingflag,也可以直接传入 encoding
const fs = require("fs");

//如果路径不存在,则会创建一个新的文件
fs.writeFileSync("./2.txt", "hello world");
let data = fs.readFileSync("./2.txt", "utf-8");
console.log(data);

异步写入方法 writeFile

异步读取方法 writeFilewriteFileSync的前三个参数相同,最后一个参数是回调函数,回调函数接收一个参数err,回调函数在读取写入成功后执行。

const fs = require("fs");

//异步写入
fs.writeFile("./2.txt", "hello world", (err) => {
  if (err) {
    console.log(err);
  } else {
    console.log("异步写入成功");
  }
});

创建文件目录

同步创建目录方法 mkdirSync

参数为一个目录的路径,没有返回值,在创建目录的过程中,必须保证传入的路径前面的文件目录都存在,否则会抛出异常

const fs = require("fs");

//假设a存在
fs.mkdirSync("./a/b");

异步创建目录方法 mkdir

第一个参数为一个目录的路径,最后一个参数为回调函数,回调函数有一个参数 err (错误),在执行创建操作后执行,同样需要路径前部分的文件夹都存在

const fs = require("fs");

fs.mkdir("./newFolder", (err) => {
  if (err) {
    console.log(err);
  } else {
    console.log("Folder created");
  }
});

读取文件目录

与文件读取方法类似,可以查阅 node 官方文档了解

Stream 流

为什么需要Stream

const fs = require("fs");

//内存占用100%
for (let i = 2; i < 20; i++) {
  fs.readFile("./test.mp4", (err, data) => {
    if (err) throw err;
    fs.writeFileSync("./test" + i + ".mp4", data);
  });
}

image.png

可以看到,我们执行的一瞬间,内存直接爆了

那换一种方式呢

for (let i = 2; i < 20; i++) {
  const readStream = fs.createReadStream("./test.mp4");
  const writeStream = fs.createWriteStream("./test" + i + ".mp4");
  readStream.pipe(writeStream);
}

image.png

看来 Stream 在处理大量数据时是一个非常 牛叉 的工具。

如何理解 Stream

Readable Stream

首先对于Readable Stream,我们可以把他比喻成一个水龙头:

image.png

水龙头的水来自哪里,需要具体的 Readable Stream来实现。比如 fs.createReadableStream创建的 Readable Stream其水源自于文件, process.stdin 水源自于标准输入。

两个状态 flowing 和 paused

水龙头有两个状态flowingpaused,即龙头打开或关闭。初始化一个Readable Stream时,默认是关闭的:

const fs = require("fs");

const readStream = fs.createReadStream("./2.txt");
console.log(
  readStream._readableState.flowing,
  readStream._readableState.paused
); // null true

当我们监听data事件时,会自动打开开关:

const fs = require("fs");

const readStream = fs.createReadStream("./2.txt");

readStream.on("data", (chunk) => {
  console.log(chunk.toString());
});

console.log(
  readStream._readableState.flowing,
  readStream._readableState.paused
); //true false

我们也可以通过resume方法来手动开启水龙头,不过要小心,有可能导致水丢失:

const fs = require("fs");

const readStream = fs.createReadStream("./2.txt");

readStream.resume();

setTimeout(() => {
  readStream.on("data", (chunk) => {
    console.log(chunk.toString()); //打印为空
  });
}, 1000);

就好比先把水龙头打开了,再放桶,肯定会漏掉一些水。

当然我们也可以调用pause关闭水龙头,就比如以下例子,在接收到第一批水后就关闭了水龙头:

const fs = require("fs");

const readStream = fs.createReadStream("./2.txt");

readStream.once("data", (chunk) => {
  console.log("data", chunk.toString());
  readStream.pause();
});

buffer

上面代码调用pause后水源的水不会停止,会流到水龙头的一个buffer中,直到达到 highWaterMark(最高水位线) 则停止:

我们可以通过代码验证一下:

const fs = require("fs");

const readStream = fs.createReadStream("./test.mp4");

readStream.once("data", (chunk) => {
  readStream.pause();
  setTimeout(() => {
    console.log(
      readStream._readableState.length, // 水龙头 buffer 的大小
      readStream._readableState.highWaterMark // 最高水位线
    );
  }, 1000);
});

而且,我们可以重新再打开水龙头,此时会消耗掉buffer中的水,然后再从源头读取。

使用read来手动取水

有没有发现,上面这些例子都是水龙头来多少水(即代码中的chunk)我们就接多少水,有没有可能我们自己控制接水的多少呢??答案是肯定的,我们可以调用read方法,例如:

const fs = require("fs");

const readStream = fs.createReadStream("./test.mp4");

console.log(readStream.read(100));

不过上面这个代码是读不到数据的。原因在于,read方法从buffer中读取数据的,而此时buffer里面还是空的。我们需要这样:

const fs = require("fs");

const readStream = fs.createReadStream("./2.txt");

readStream.on("readable", () => {
  let chunk;
  while (null !== (chunk = readStream.read(100))) {
    console.log(chunk.length);
  }
});

调用 on('readable'...会触发水源往buffer中灌水,当buffer中灌满水后,会调用readable的回调函数,此时可以通过read方法来消费buffer中的水。这里有个问题,当我们的read的数据超过了bufferhighWaterMark怎么办???????

const fs = require("fs");

const readStream = fs.createReadStream("./2.txt");

readStream.on("readable", () => {
  let chunk;
  while (null !== (chunk = readStream.read(65537))) {
    console.log(chunk.length);
  }
});

第一次触发 readable事件,此时buffer中的数据为65536,而我们需要读取65537的数据,数据不够read 返回 null。并且发现 read 读取的数据大于 highWaterMark,所以更新该参数为原来的两倍,即131072,然后以该值从水源中再读入一段数据到一个新的节点中(buffer是一个链表)。

然后,触发第二次readable 事件,此时buffer 数据总长度为65536+131072=196608,我们可以读入两次65537的数据。此时buffer数据总长度变为196608—2 * 65537=65534,数据又不够了,

read 返回 null,且由于 read 读取的数据小于 highWaterMark,不需要更新,仍然以原来的值从水源中再读入一段数据到一个新的节点中。

然后,触发第三次readable...

Writable Stream

我们把Writable Stream比作一个有入口和出口的池子,池子的水最终流向哪,需要具体Writable Stream来实现,比如fs.createWriteStream创建的Writable Stream其水流向文件,process.stdout水流向标准输出。

两种工作模式

水池也有两种工作模式,一种是入口来的水直接流向出口(此时,相当于在入口和出口间接了一根水管),一种是入口的水先流到池子中(源码中是存在buffer这个属性中),出口慢慢进行消费。

我们初始化一个Writable Stream时,然后写一些数据试试:

const fs = require("fs");

const writeStream = fs.createWriteStream("./2.txt");
writeStream.write("hello world");

此时,采用的是第二种模式,如何切换成第一种模式呢?可以这样:

const fs = require("fs");

const writeStream = fs.createWriteStream("./2.txt");
writeStream.on("open", () => {
  writeStream.write("hello world");
});

通过对比,我想你应该恍然大悟了。第一段代码writestream初始化后,可能出口那边还没有准备好,此时往池子中灌水显然只能先放到池子里。第二段代码是在writeStreamopen 事件触发后再往水池中灌水,此时出口已就绪,可以直接流出了。

Pipe

先看代码

const fs = require("fs");

const readStream = fs.createReadStream("./2.txt");
const writeStream = fs.createWriteStream("./3.txt");
readStream.pipe(writeStream);

其作用就相当于把水龙头和水池用一个管子连接起来

这样,水就源源不断地从水源处流向目标了

其原理也是监听了Readable Streamdata事件,获取到chunk写入Writable Stram:

http模块

最简单的服务器

//1.导入http模块
const http = require('http')

//2.创建服务对象   request和response对象可以获取到请求和响应的报文
const server = http.createServer((request, response) => {
    response.end("Hello HTTP Server!") //设置响应体
    //如果需要返回中文内容,则需要先设置响应头
    // response.setHeader('content-type','text/html;charset=utf-8')
})

//3.监听端口  启动服务
server.listen(9000, () => {
    console.log("服务已经启动....")
})
// 有可能遇到9000端口已经被占用的情况,
// 可以换一个端口,也可以用任务管理器找到对应的进程进行关闭
// http协议的默认端口为80,https为443

获取请求报文

image.png

想要获取请求的数据,需要通过request对象

HTTP设置响应

我们可以通过对response对象进行操作来写入响应

//1.导入http模块
const http = require('http')

//2.创建服务对象
const server = http.createServer((request, response) => {
    //1.设置响应状态码
    response.statusCode = 404

    //2.设置响应状态的描述(基本用不上)
    // response.statusMessage = 'xxx'
    //3.响应头的设置,之前已经用过啦!
    // response.setHeader('content-type','text/html;charset=utf-8')
    // response.setHeader('myHeader','big')
    // 总之就是键值对的形式


    //响应体的设置  如果我们用write了一般就不用end写响应体了
    response.write('love')//write可以用多次
    response.write('love')
    response.end('response') // end只能写一次并且必须写一次

})

//3.监听端口  启动服务
server.listen(9000, () => {
    console.log("服务已经启动....")
})

More

前端模块化之 ES modules

path

作业

  • 任务一:NVM与Node.js版本管理
    • 安装NVM并列出您安装的Node.js版本。
    • 创建两个不同版本的Node.js项目(如Node.js 14.x和Node.js 18.x),分别在每个版本中安装一个npm包(如express或lodash)。
    • 编写一个文档,记录NVM命令(如install、use、ls等)的用法,以及如何在不同版本之间切换。
  • 任务二:文件系统操作与Buffer
    • 创建一个Node.js程序,使用fs模块实现以下功能:
    • 创建一个新文件并写入一些内容(使用writeFile和writeFileSync)。
    • 读取该文件的内容并输出到控制台(使用readFile和readFileSync)。
    • 理解Buffer的转化,举例说明如何将字符串转换为Buffer,和反向操作。
  • 任务三:Stream模块与HTTP服务器
    • 实现一个简单的HTTP服务器,能够返回静态文件,并处理GET请求和POST请求。
    • 通过Stream处理文件数据,展示如何使用Readable和Writable Stream。
    • 本项目中,使用pipe()方法将HTTP请求的可读流连接到文件的可写流中。