NodeJS是什么
JavaScript 运行时环境
Node.js 并不是一门语言,而是一个 JavaScript 运行时环境,它的语言是JavaScript。
Node.js 与 JavaScript
Node安装
node官网
Nvm
有的项目对node版本有要求,nvm可以帮助我们更方便的切换nodejs版本
windos 安装地址
Mac安装教程
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)
打印却发现 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 规范的核心变量:
exportsmodule.exportsrequire - 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文件
从上面的运行结果可以得出以下结论:
main.js和a.js模块都引用了b.js模块,但是b.js模块只执行了一次a.js模块和b.js模块互相引用,但是没有造成循环引用的情况- 执行顺序是 父->子->父
那么Common.js规范是如何实现上述效果的呢?
require 加载原理
为了弄清楚上述两个问题。我们要明白两个概念,那就是module和Module。
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,其中有encoding和flag,也可以直接传入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
异步读取方法 readFile和readFileSync的前两个参数相同,最后一个参数是回调函数,回调函数接收两个参数err和data,该方法没有返回值,回调函数在读取文件成功后执行。
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,其中有encoding和flag,也可以直接传入encoding
const fs = require("fs");
//如果路径不存在,则会创建一个新的文件
fs.writeFileSync("./2.txt", "hello world");
let data = fs.readFileSync("./2.txt", "utf-8");
console.log(data);
异步写入方法 writeFile
异步读取方法 writeFile和writeFileSync的前三个参数相同,最后一个参数是回调函数,回调函数接收一个参数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);
});
}
可以看到,我们执行的一瞬间,内存直接爆了
那换一种方式呢
for (let i = 2; i < 20; i++) {
const readStream = fs.createReadStream("./test.mp4");
const writeStream = fs.createWriteStream("./test" + i + ".mp4");
readStream.pipe(writeStream);
}
看来 Stream 在处理大量数据时是一个非常 牛叉 的工具。
如何理解 Stream
Readable Stream
首先对于Readable Stream,我们可以把他比喻成一个水龙头:
水龙头的水来自哪里,需要具体的 Readable Stream来实现。比如 fs.createReadableStream创建的 Readable Stream其水源自于文件, process.stdin 水源自于标准输入。
两个状态 flowing 和 paused
水龙头有两个状态flowing和paused,即龙头打开或关闭。初始化一个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的数据超过了buffer的highWaterMark怎么办???????
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初始化后,可能出口那边还没有准备好,此时往池子中灌水显然只能先放到池子里。第二段代码是在writeStream的open 事件触发后再往水池中灌水,此时出口已就绪,可以直接流出了。
Pipe
先看代码
const fs = require("fs");
const readStream = fs.createReadStream("./2.txt");
const writeStream = fs.createWriteStream("./3.txt");
readStream.pipe(writeStream);
其作用就相当于把水龙头和水池用一个管子连接起来
这样,水就源源不断地从水源处流向目标了
其原理也是监听了Readable Stream的data事件,获取到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
获取请求报文
想要获取请求的数据,需要通过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
作业
- 任务一: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请求的可读流连接到文件的可写流中。