【NJ01】NodeJS基础篇—01从全局开始

443 阅读12分钟

前言

最近在搭建B端Node服务,用的Egg,看源码&实际原理的时候,遇到一些问题,重新把NodeJS的基础知识整合一下。如果有什么表述不准确的地方,还希望大家指正一下~

本文主要内容:

  • NodeJS简介
  • 环境
  • 从全局开始
    • 全局对象 - global
    • console 对象
    • __dirname
    • __filename
    • EventLoop 事件轮询
    • 模块化 CommonJS
    • Process 进程
    • Path 路径操作
    • Buffer基本操作
    • Stream
    • FileSystem文件系统
    • Events(核心模块)
    • V8垃圾回收机制

1.NodeJS简介

node.js是⼀一个异步的事件驱动的JavaScript运⾏行行时 nodejs.org/en/

如何判断当前脚本运行在浏览器还是 node 环境中?

typeof window === 'undefined' ? 'node' : 'browser';

通过判断当前环境的 window 对象类型是否为 undefined,
如果是undefined,则说明当前脚本运行在node环境,否则说明运行在window环境。

node.js特性其实是JS的特性:

  • ⾮阻塞I/O
  • 事件驱动
  • 高并发

node历史 — 为性能⽽生

  • 并发处理
    • 多进程
    • 多线程
    • 异步I/O

缺陷

  • 不适用用CPU密集型
    • CPU使用率较重、IO使用率较轻的应用——如视频编码、人工智能等,Node.js的优势无法发挥
  • nodejs是单线程的,只支持单核CPU,无法充分利用多核 CPU 的性能
  • 可靠性低,一旦代码某个环节崩溃,整个系统都崩溃

解决方案

  • Nnigx反向代理,负载均衡,开多个进程,绑定多个端口;
  • 单线程变成多线程,或者多进程单线程;

2.环境

  • 环境搭建:安装 Node.js 解析器,解析执行 Node.js 代码(类似浏览器的 JS 解析器)。
  • 执行环境(CLI & REPL)
CLI : 命令行接口
    windows : cmd、power shell 
    macOS : 终端(terminal)

node 命令
输入 node 命令,进入REPL 环境(类似浏览器控制台) Read Eval Print Loop

REPL命令:

.break
.clear
.exit //退出node REPL终端 .help
.save //代码保存到1.js
.load //加载运行文件中的code .editor
  • 命令行工具: 环境变量
windows

查看:set
设置:set 环境变量名称=值 
删除:set环境变量名=

Linux/macOS

查看:echo $环境变量名称 
设置:export 环境变量名称=值

解析指定文件

# 用指令指定程序打开指定文件

node [options] [V8 options] [script.js | -e "script" | -] [--] [arguments]

- options:选项
- V8 options:V8(node.js的javascript解析引擎)相关 选项
- [script.js | -e "script" | -]:要执行的脚本文件或内容
- arguments:参数

3. 从全局开始

3.1 全局对象 - global

  • 类似浏览器全局对象 window,但是 node (ECMAScript) 环境中是没有window的(本质上,浏览器的 window 其实就是扩展自ECMAScript中的 global)

3.2 console 对象

Console 模块提供了一个简单的调试控制台,类似于

Web 浏览器提供的 JavaScript 控制台

.log([data][, ...args])
.info([data][, ...args]) -> .log()的别名 
.debug(data[, ...args]) -> .log()的别名 
.error([data][, ...args]) -> 与 .log() 类似 
.warn([data][, ...args]) -> .error()的别名 
.trace([message][, ...args])

3.3 __dirname

【文件夹】 当前文件(模块)所在目录,绝对路径,不包含文件名称

3.4 __filename

【文件】 当前文件(模块)的文件名称(包含文件绝对路径)

3.5 EventLoop 事件轮询

事件轮询是一直循环往复的,只有当任务队列为空时,才会停止循环,且在每一趟循环中,每一个环节都会有对应的操作。

process.nextTick() 
// 进入每个阶段前都会执行,也就是当前(** 异步任务队列 **)阶段结束后立马执行nextTick,
// 然后进入下一阶段,包括nodejs启动的时候也会执行
setTimeout()/clearTimeout() 
setInterval()/clearInterval() 
setImmediate()/clearImmediate()

image.png

image.png

  • Timer, 处理所有 setTimeout 和 setInterval 的回调

对于timers中队列的处理,setTimeout或setInterval中的函数都添加到队列里,同时记下来这些函数什么时间被调用到了调用时间就调用,没到时间就进入下一阶段,然后会停留在poll阶段

  • i/o cycle
    • Pending I/O Callback, 执行 I/O 回调,文件操作、网络操作等

    除了以下操作的回调函数,其他的回调函数都在这个阶段执行。

    • setTimeout()setInterval()的回调函数,(因为它在timer阶段执行)
    • setImmediate()的回调函数,(因为它在Check阶段执行)
    • 用于关闭请求的回调函数,比如socket.on('close', ...),(因为它在Close callbacks阶段执行)
    • Idle, Prepare 内部使用

    • Poll

    这个阶段是轮询时间,用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等。这个阶段的时间会比较长。如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。
    在这个阶段会一直去问执行相关任务的模块任务执行完了没有,一旦任务执行完成,相关的数据就会放到一个回调里面然后加入到poll的队列中,也就是除了timers阶段外的所有回调都是在poll这个阶段处理的。poll阶段会一直重复检查刚才timers里没有到时间的计时器有没有到时间,如果到时间了,就直接通过check到达timers阶段通知timers执行这个回调,然后从timers队列中清除。


- Check,只处理 setImmediate 的回调函数

- Close Callback,专门处理一些 close 类型的回调,如关闭网络连接等

> **关于setTImeout和setImmediate的执行顺序**       
> 答:正常情况下是setImmediate先执行,只有第一次启动nodejs的情况下timers里的定时器到时间了`才可能`setTimeout先执行。        
> 原因:因为nodejs启动会执行三件事,开始执行脚本也就是执行你页面的代码,
这时候会执行你的setTImeout,然后才会去处理event loop,
而从执行脚本到处理event loop中间也需要时间,setTimeout最小的时间是4ms,
如果从执行脚本到处理event loop花了5ms时间,那么一进入timers就会发现时间到了就会立刻处理回调,
所以这时候setTimeout就会先执行

样例

setTimeout(()=>{
    console.log('timeout') 3
    process.nextTick(()=>{
        console.log('timeout next tick') 4
      })
  })
  setImmediate(()=>{
    console.log('immediate') 5
  })
  console.log('cc') 1
  process.nextTick(()=>{
    console.log('next tick') 2
  })
//cc
//next tick
//timeout
//timeout next tick
//immediate
// 第一圈事件循环,会把任务加到异步任务列表中

主线程执行栈处理

宏任务

  • 主体script
  • setTimeout
  • setInterval
  • setImmediate (Node独有)
  • requestAnimationFrame (浏览器独有)
  • I/O
  • UI rendering (浏览器独有)

微任务 Promise.then,process.nextTick

  • process.nextTick (Node独有)
  • Promise
  • Object.observe
  • MutationObserver

执行顺序(宏任务->微任务->宏任务->微任务···)

image.png

具体怎么执行,咱们一起看两个案例分析,看完绝对就没问题了。

setTimeout(() => {
    new Promise(resolve => {
        // 宏任务 
        console.log('promise') // 1
        resolve(); 
    }).then(() => {
        //微任务
        console.log('then')  // 3
        setTimeout(() => {
         console.log('then promise') // 5
        }, 1000)
    });

    // 宏任务 
    console.log(1); // 2
    // 宏任务 
    setTimeout(() => {
        console.log(1) }, 1000); // 4
    }, 1000);
 });
 
##
- 首先,会执行第一个setTimeout(宏任务),把其推入eventLoop,由于没有其他的宏任务,
当时间到了时,会直接将其回调推入执行栈。
- 宏任务先执行, newPromise先执行打印promise,
- 再打印1, 
- 然后再执行setTimeout,将其推入eventLoop, 
- 没有宏任务了,再执行微任务.then(),打印then,
- 最后主线程执行完毕,将第二个 setTimeout执行内容从事件队列中推入主线程
- 最后打印then promise
setTimeout(function () {
  console.log('setTimeout'); // 4
})
new Promise(function (resolve) {
  console.log('promise'); // 1
  resolve()
}).then(function () {
  console.log('then'); // 3
})
console.log('console'); // 2

## 
这段代码作为宏任务,进入主线程。 
- 先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。(注册过程与上同,下文不再描述)
- 接下来遇到了Promisenew Promise立即执行,then函数分发到微任务Event Queue。
- 遇到console.log(),立即执行。

ok,第一轮事件循环结束了,
- 微任务中去promise.then

我们开始第二轮循环,

- 当然要从宏任务Event Queue开始。我们发现了宏任务Event QueuesetTimeout对应的回调函数,立即执行。

3.6 模块化 CommonJS

模块化用来分割,组织和打包代码。每个模块完成一个特定的子功能,所有的模块按某种方法组装起来,成为一个整体, 完成整个系统所要求的功能。

定义模块

模块化特点:

  1. 一个文件就是一个独立的模块(模块作用域)
  2. 模块加载采用同步模式
  3. 通过 require 函数导入、exports 对象导出

作用域:

  1. 一个文件就是一个独立模块
  2. 每个模块都有自己独立的作用域
即:
    require('../2.js')导入的模块中有let a = 1 
    本文件中也有let a = 2 
    各自在各自的作用域中,不会覆盖 
    2.js中的a并没有影响到当前这个模块的a

module对象

每一个模块中都会有一个内置的对象:module
该对象提供了包括当前模块文件所拥有的一些信息。

module对象
    id: 当前模块的唯一标识,默认id为当前这个文件的绝对路径
    filename: 当前模块的文件路径 
    parent
    children
    loaded
    paths

function Module(id, parent) {
  this.id = id;
  this.exports = {};
  this.parent = parent;
  this.filename = null;
  this.loaded = false;
  this.children = [];
}

module.exports = Module;

var module = new Module(filename, parent);

分类

  • File Modules(文件模块机制【一个文件即一个模块】)
  • Folders as Modules(文件夹模块机制)
    • node_modules Folders(特殊的文件夹模块)
    • global folders(global开发或者上线环境公用,直接在 node安装路径下)
    如果我们导入的模块是在node_modules目录下的,又会有另外的一种规则
        (不以 ./ ../ / 开始的模块,按照另外一种加载机制进行加载):
        * 当非路径加载模式的时候,会按照如果下规则进行模块的查找:
        * 在module对象有一个属性,paths,是一个数组,里面保存的就是这种非路径加载模式需要查找的路径列表
        (数组中一个个去查),子级可以向父级node_modules去查,但父级不可向子级node_modules 中去查   
    当我们导入的模块名称是一个文件夹的时候【文件夹模块机制】
        * 1.读取该文件夹下的package.json文件
        * 2.导入package.json文件中main选项指定的文件
        * 3.如果不存在package.json或者main指定的文件,这默认自动导入模块文件夹下的index.js
  • Core Modules(核心模块机制)

如果自己定义的模块与核心模块冲突了,那么默认加载的是核心模块。

模块导出

每一个模块文件中有一个 exports 对象,在模块对象 module下有一个属性 exports

  • exports 对象,默认是个空对象
  • exports 对象时module.exports对象的引用

模块导入(依赖)

module.require()就是模块中直接用到的require()
require(模块id/路径) 返回被导入模块中的 module.exports 对象

(1)require的模块加载机制

  • 1、先计算模块路径;
  • 2、如果模块在缓存里面,取出缓存, 如果是核心模块,取出模块;
    • 优先从缓存中加载
      • 如果已经 require 过,不会重复执行加载,直接可以拿到里面的接口对象.
      • 目的是避免重复加载,提高模块加载效率
    • 判断模块标识符 require('模块标识符')
      • 核心模块, 取出模块加载
  • 3、new Module并加载模块;
    • 路径加载(自定义模块)
    路径加载模式:
        如果模块的加载是以 ./ ../ / 开始的,那么就是路径模块加载模式
    
    非路径加载模式:
        不以 ./ ../ / 开始的模块,按照另外一种加载机制进行加载。           
        包括:node_modules、全局目录、核心模块;
    
    • 非路径加载(第三方模块)
  • 4、输出模块的exports属性;
// require 其实内部调用 Module._load 方法
Module._load = function(request, parent, isMain) {
  //  计算绝对路径
  var filename = Module._resolveFilename(request, parent);

  //  第一步:如果有缓存,取出缓存
  var cachedModule = Module._cache[filename];
  if (cachedModule) {
    return cachedModule.exports;

  // 第二步:是否为内置模块
  if (NativeModule.exists(filename)) {
    return NativeModule.require(filename);
  }
  
  /********************************这里注意了**************************/
  // 第三步:生成模块实例,存入缓存
  // 这里的Module就是我们上面的1.1定义的Module
  var module = new Module(filename, parent);
  Module._cache[filename] = module;

  /********************************这里注意了**************************/
  // 第四步:加载模块
  // 下面的module.load实际上是Module原型上有一个方法叫Module.prototype.load
  try {
    module.load(filename);
    hadException = false;
  } finally {
    if (hadException) {
      delete Module._cache[filename];
    }
  }

  // 第五步:输出模块的exports属性
  return module.exports;
};

(2)加载模块时,为什么每个模块都有__dirname, __filename属性呢,new Module的时候我们看到没有这两个属性的,那么这两个属性是从哪里来的?

每个module里面都会传入__filename, __dirname参数,这两个参数并不是module本身就有的,是外界传入的。

// 上面的第四步module.load(filename)加载模块
// 这一步,module模块相当于被包装了,包装形式如下
// 加载js模块,相当于下面的代码(加载node模块和json模块逻辑不一样)
(function (exports, require, module, __filename, __dirname) {
  // 模块源码
  // 假如模块代码如下
  var math = require('math');
  exports.area = function(radius){
      return Math.PI * radius * radius
  }
});

3.7 Process 进程

全局对象,不需要 require;
process 对象是一个全局变量,它提供当前 Node.js 进程的有关信息,以及控制当前 Node.js 进程

API

  • .argv
    用来获取当前运行node程序时(cmd中指令 node 2 -v )的相关参数
console.log( process.argv ); //返回的是一个数组 
if (process.argv.includes('-v')) {console.log('v1.0.0'); }
  • .env
    系统环境变量
console.log( process.env );
  • .exit([code])
    退出当前进程
process.exit()
  • process.stdout
    标准输出流
process.stdout.write(data[, encoding][, callback])
  • process.stdin
    标准输入流
process.stdin.on('data', (e) => { 
    //e 默认是buffer,输入后按回车触发,会 有换行符
    // console.log('项目名称:', e.toString()); 
    fs.mkdirSync( e.toString().replace('\r\n', '') ); 
    process.stdout.write('项目创建成功');
});
  • process.memoryUsage()
    获取内存使用情况,返回如下
{
  rss: 4935680,
  heapTotal: 1826816,
  heapUsed: 650472,
  external: 49879
}

解释一下:
heapTotal 和 heapUsed 代表V8的内存使用情况。 external代表V8管理的,绑定到Javascript的C++对象的内存使用情况。 rss, 驻留集大小, 是给这个进程分配了多少物理内存(占总分配内存的一部分) 这些物理内存中包含堆,栈,和代码段。

  • process.send(message[, sendHandle[, options]][, callback])
    如果使用 IPC 通道衍生 Node.js,则可以使用 process.send() 方法向父进程发送消息。 消息将作为父对象 ChildProcess 对象上的 'message' 事件接收。
    如果 Node.js 没有使用 IPC 通道衍生,则 process.send 将是 undefined

  • .on('message', handler)
    如果 Node.js 进程是使用 IPC 通道衍生(参见子进程集群文档),则每当子进程收到父进程使用 childprocess.send() 发送的消息时,就会触发 'message' 事件。

3.8 Path 路径操作

```
const path = require('path');
```
  • path.basename() 获取路径的最后一部分 quux.html
    // console.log(path.basename('/foo/bar/baz/asdf/quux.html'));
    
  • path.dirname() 获取路径
    // console.log(__dirname);获取路径(当前文件的路径) 
    // console.log(path.dirname('/abc/qqq/www/abc.txt'));
    // 获取的路径会把 abc.txt去掉 ,只留 下 路径
    
  • path.extname() 获取扩展名称
   // console.log(path.extname('index.html')); 
   // path.extname('index.') 返回的是一个点. 
   // path.extname('index') 返回的是空的 
   // path.extname('.index') 返回的是空的
  • 路径的格式化处理

    • path.format() obj->string
    • path.parse() string->obj
  • path.isAbsolute() 判断是否为绝对路径

    // console.log(path.isAbsolute('C:/foo/..')); 
    // console.log(path.isAbsolute('C:\foo\..'))
    
  • path.join() 拼接路径(最后的两个点 ..表示上层路径;.表示当前路径),在连接路径的时候会格式化路径,

    // console.log(path.join('/foo', 'bar', 'baz/asdf', 'quux', '../../'));
    两层..故路径只到 baz
    
  • path.normalize() 规范化路径

    // console.log(path.normalize('C:\temp\\foo\bar\..\')); 路径到 foo
    
  • path.relative() 计算相对路径 从1路径到2路径的相对路径\

    // console.log(path.relative('C:\orandea\test\aaa', 'C:\orandea\impl\bbb'));
    
  • path.resolve() 解析路径

    // console.log(path.resolve('wwwroot', 'static_files/png/', '../gif/image.gif')); 
    先进入 从当前路径 先进入 wwwroot路径 /home/myself/node/wwwroot 
    再往下走 /home/myself/node/wwwroot/static_files/png/
    再往下走 /home/myself/node/wwwroot/static_files/gif/img.gif
    

3.9 Buffer基本操作

Buffer 库为 Node.js 带来了一种存储原始数据的方法,可以让 Node.js 处理二进制数据,每当需要在 Node.js 中处理I/O操作中移动的数据时,就有可能使用 Buffer 库。原始数据存储在 Buffer 类的实例中。一个 Buffer 类似于一个整数数组,但它对应于 V8 堆内存之外的一块原始内存。
Buffer对象是Node处理8位二进制数据的一个接口(0-255)。
Buffer 类在 Node.js 中是一个全局变量

本质:字节数组,操作字节, 长度固定

新建Buffer会占用V8分配的内存吗?
不会,Buffer属于堆外内存,不是V8分配的。

3.9.1 实例化

buffer需要先指定字节长度

new Buffer()初始化实例的时候可以传入arraysizestring
但是当new Buffer(size)时初始化存在问题会随机初始化(不一定为零), buffer.alloc() 初始化的都为零 ,encoding默认 为 'utf8',所以推荐。

  • new Buffer(array | size | string)
  • Buffer.from(array)
  • Buffer.from(string) //可以追加第二个参数 Buffer.from('hello','utf8') 编码方式
  • Buffer.alloc(size)

3.9.2 功能方法

  • Buffer.isEncoding() 判断是否支持该编码 Buffer.isBuffer()判断是否为Buffer //参数是一个对象
  • Buffer.byteLength() 返回指定编码的字节长度,默认utf8
let buf = Buffer.from('中国','ascii'); 
console.log(Buffer.byteLength(buf)); 
  • Buffer.concat()将一组Buffer对象合并为一个Buffer对象 参数为一个列表
let buf3 = Buffer.concat( [buf1,buf2] );

3.9.3 实例方法

image.png

  • write() 向buffer对象中写入内容
let buf = Buffer.alloc(5); 
// 结果 buffer 00 00 00 00 00 
buf.write('hello',2,2); 
// 结果buffer0000 686500

第二个参数:开始位置索引 从第几个位置开始写,若buffer有长度限制,多余的部分则不写进去
第三个参数: 长度 写入几个字节

  • .slice() 截取新的buffer对象
let buf1 = buf.slice(2,3); 半开半闭区间 [2,3)从第二个位置开始截取 但不包含第三个位置的元素 

!!!!!!修改slice返回的切片buffer也会影响到原来的buffer

  • .toString() 把buf对象转成字符串

  • .toJson() 把buf对象转成json形式的字符串。
    toJSON方法不需要显式调用,当JSON.stringify(buf)方法调用的时候会自动调用toJSON方法 返回结果类型:{“ type”:“Buffer”,“data”:[104,101,108,108,111]} //对应hello十进制编码

  • buf[index] Buffer也有下标,可以通过 buf[index] 进行操作,此方 法赋值除了数字外(如:字符串)都需要先转成二进制数据(否则 操作无效赋值00),若下标越界(size)操作无效.

  • .copy()

buf.copy(target[, targetStart[, sourceStart[,sourceEnd]]])

!!!!修改拷贝copy后的buffer不会影响被拷贝的原buffer

3.10 Stream

流(stream)是一种在 Node.js 中处理流式数据的抽象 接口。 stream 模块提供了一些基础的 API,用于构建实现了流 接口的对象,Node.js 中许多的对象都是提供了流的实现:fs文 件操作、net、dgram、http、https等。

流是基于事件的 API,用于管理和处理数据。

理解流的最好方式就是想象一下没有流的时候怎么处理数据:

  • fs.readFileSync 同步读取文件,程序会阻塞,所有数据被读到内存
  • fs.readFile 阻止程序阻塞,但仍会将文件所有数据读取到内存中
  • 希望少内存读取大文件,读取一个数据块到内存处理完再去索取更多的数据
require('stream')

流的基本类型

  • Writable - 可写入数据的流(例如fs.createWriteStream())
  • Readable - 可读取数据的流(例如fs.createReadStream())
  • Duplex - 可读又可写的流(例如 net.Socket)
  • Transform - 在读写过程中可以修改或转换数据的 Duplex 流(例如zlib.createDeflate())

Writable属性方法

  • .write(chunk[, encoding][, callback])
  • .end([chunk][, encoding][, callback])
  • .setDefaultEncoding(encoding)

Readable属性方法

  • .setEncoding(encoding)
  • .read([size])
  • .pipe(destination[, options])
  • .pause()
  • .resume()//恢复

流的错误处理

stream.on('error', (err) => { 
    console.trace(); 
    console.error('Stack:', err.stack); 
    console.error('The error raised was:', err); 
});

3.11 FileSystem文件系统

文件信息获取

const fs = require('fs');
  • .stat(path, (err, data)=>{})
fs.stat('./abc',(err,statE) => {\
    // 一般回调函数的第一个参数是错误对象,
    //如果err为null,表示没有错误,否则表示报错了 
    if(err) return;
})
- statE的属性
    ```
    atime 文件访问时间
    ctime 文件的状态信息发生变化的时间(比如文件的权限) 
    mtime 文件数据发生变化的时间
    birthtime 文件创建的时间
    ```

- statE.isFile()
    判断是否为文件

- statE.isDirectory()
    判断是否为目录 

fs.readFile() & readFileSync() 读文件操作

  • 如果有第二个参数,且其是编码,那么回调函数获取到的数据就是字符串;
  • 编码指定 从文件中 读取出来 后的 数据格式;
  • 如果没有第二个参数,那么得到的就是Buffer实例对象
const fs = require('fs'); const path = require('path');
let strpath = path.join(__dirname,'data.txt');

fs.readFile(strpath, (err,data) =>{ 
    if(err) return; 
    console.log(data.toString());
})

fs.readFile(strpath,'utf8',(err,data)=>{

    if(err) return; 
    //console.log(data.toString());
    console.log(data);  // Buffer
 }); 

// 同步操作
//let ret = fs.readFileSync(strpath,'utf8'); 
//console.log(ret);

fs.writeFile() & writeFileSync()写文件操作

  • 编码方式字段同读操作,不过此处为第三个参数,因为第二个参数为要写的内容。
  • 第三个参数指定编码 ,指定写入到文件中时的编码方式。
  • 如果指定utf-8,不管第二个参数 是字符串还是 buffer 对象 写入到文件中的 都是 utf8编码过的。
const fs = require('fs'); const path = require('path');

let strpath = path.join(__dirname,'data.txt');
fs.writeFile(strpath,'hello nihao','utf8',(err)=>{
      if(!err){
         console.log('文件写入成功');
      }
});
let buf = Buffer.from('hi');
fs.writeFile(strpath,buf,'utf8',(err)=>{
  if(!err){
  console.log('文件写入成功');
  }
});

//  同步操作 
//fs.writeFileSync(strpath,'tom and jerry');

如果文件不存在,则创建,若文件存在 ,全部内容完全覆盖;
如果目录(路径)不存在,创建文件就会失败;

fs.appendFile()

文件尾部追加内容 fs.appendFile( )不会覆盖原来内容

fs.existsSync(appRoot)

该路径文件夹或文件是否存在,true/false

大文件操作(流式操作)

  • fs.createReadStream(path[, options]) //读取文件的事件流
  • fs.createWriteStream(path[, options]) //写文件的事件流
const path = require('path');
const fs = require('fs');

let spath = path.join(__dirname,'../03­source','file.zip');\
let dpath = path.join('C:\Users\www\Desktop','file.zip'); //目标路径

let readStream = fs.createReadStream(spath); 
let writeStream = fs.createWriteStream(dpath);

// 基于事件的处理方式
let num = 1;
readStream.on("data", (chunk) => {
    num++;
    writeStream.write(chunk)
})
// data事件在文件中数据读取一部分的时候就执行
readStream.on('end',()=>{
    console.log('文件处理完成'+num); 
});
  • .pipe() pipe的作用直接把输入流和输出流连到一起,把读取到的数据流直接写到写数据流的入口.
readStream.pipe(writeStream);
const fs = require('fs'); 
http.createServer((req, res) => { 
    fs.createReadStream(`${__dirname}/index.html`).pipe(res); 
}).listen(8000);

const readable = fs.createReadStream('./original.txt'); 
const writeable = fs.createWriteStream('./copy.txt'); 
readable.pipe(writeable);

fs.FSWatcher类,监控文件

监控文件

fs.watch(filename[, options][, listener])
fs.watchFile(filename[, options], listener)

fs.watchFile 比 fs.watch 低效,但更好用。

// 当文件发生改变的时候,触发回调 
fs.watchFile('./data.txt', info=> {
    //info : 保存当前变化的细节信息stats
    console.log(info);
})

// 监听文件或目录\
fs.watch('./a', (eventType, filename) => {
    // eventType: change或者rename 增加或者删除文件触发rename
    // filename: 当前发生改变的具体文件
    console.log(eventType, filename);
 });

目录操作

1、创建目录
fs.mkdir(path[, mode], callback) fs.mkdirSync(path[, mode])

// fs.mkdirSync(path.join(__dirname,'hello')) 在当前目录下 加一级hello目录(即一个hello文件 夹)

2、读取目录
fs.readdir(path[, options], callback) 
fs.readdirSync(path[, options]) 

3、删除目录
fs.rmdir(path, callback) 要删除的 fs.rmdirSync(path)

其他操作

  • fs.rename() 文件重命名

  • fs.unlink() 删除文件

3.12 Events(核心模块)

事件是整个 Node.js 的核心,Node.js中大部分模块都使用或继承了该模块(类似 WebAPI 中的EventTarget)。

require('events')

EventEmitter 类const EventEmmiter = require('events');

  • .emit(eventName[, ...args]) //触发事件
  • .addListener(eventName, listener) //注册事件(后加)
  • .prependListener(eventName, listener)(前加)
  • .on(eventName, listener)
  • .off(eventName, listener)
  • .removeListener(eventName, listener)
  • .eventNames()//返回一个已注册事件的数组
  • .setMaxListeners(num)//每个事件的最大注册次数 超出num会报出警告

3.13 V8垃圾回收机制

3.13.1 如何查看V8的内存使用情况

使用process.memoryUsage(),返回如下

image.png

heapTotal 和 heapUsed 代表V8的内存使用情况。 external代表V8管理的,绑定到Javascript的C++对象的内存使用情况。 rss, 驻留集大小, 是给这个进程分配了多少物理内存(占总分配内存的一部分) 这些物理内存中包含堆,栈,和代码段。

3.13.2 V8的内存限制是多少,为什么V8这样设计

64位系统下是1.4GB, 32位系统下是0.7GB。因为1.5GB的垃圾回收堆内存,V8需要花费50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起Javascript线程暂停执行的事件,在这样的花销下,应用的性能和影响力都会直线下降。

3.13.3 V8的内存分代 和 回收算法

在V8中,主要将堆内存分为新生代和老生代两代。新生代中的对象存活时间较短的对象,老生代中的对象存活时间较长,或常驻内存的对象。

image.png

分代的唯一理由就是优化GC性能

  • Minor GC 会清理年轻代的内存。
  • Major GC 是清理老年代。
  • Full GC 是清理整个堆空间—包括年轻代和老年代。

3.13.3.1 新生代

新生代中的对象主要通过Scavenge算法进行垃圾回收。这是一种采用复制的方式实现的垃圾回收算法。它将堆内存一份为二,每一部分空间成为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。

image.png

  • 当开始垃圾回收的时候,会先检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间发生角色对换。
  • 应为新生代中对象的生命周期比较短,就比较适合这个算法。
  • 当一个对象经过多次复制依然存活,它将会被认为是生命周期较长的对象。这种新生代中生命周期较长的对象在复制过程中会被移到老生代中,再对From和To进行对换。
新生代对象晋升到老生代有两个条件:

(1)第一个是判断是对象否已经经过一次 Scavenge 回收。
若经历过,则将对象从 From 空间复制到老生代中;若没有经历,则复制到 To 空间。

(2)第二个是 To 空间的内存使用占比是否超过限制。
当对象从 From 空间复制到 To 空间时,若 To 空间使用超过 25%,则对象直接晋升到老生代中。
设置 25% 的原因主要是因为算法结束后,两个空间结束后会交换位置,
如果 To 空间的内存太小,会影响后续的内存分配。

更细维度

新生代又分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1:1。

为什么要设置Survivor区? Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。

一般情况下, 新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。

在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

image.png

那么,顺理成章的,应该建立两块Survivor区,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就会被送到老年代中。

3.13.3.2 老生代

老生代主要采取的是标记清除的垃圾回收算法。与Scavenge复制活着的对象不同,标记清除算法在标记阶段遍历堆中的所有对象,并标记活着的对象,只清理死亡对象。活对象在新生代中只占叫小部分,死对象在老生代中只占较小部分,这是为什么采用标记清除算法的原因。

3.13.3.3 标记清除算法的问题

主要问题是每一次进行标记清除回收后,内存空间会出现不连续的状态

image.png

  • 这种内存碎片会对后续内存分配造成问题,很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。即使放得下,也可能存在GC效率的问题。
  • 为了解决碎片问题,标记整理被提出来。就是在对象被标记死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。

3.13.4 V8 GC的触发

在网上找了一张比较容易理解的图(来自知乎@夏木)

image.png

为了确保js的正常执行,gc和js执行不能并行执行,因此选择了类似于共享CPU时间片的执行方式,将gc 和js交替执行。

image.png

3.13.5 哪些情况会造成V8无法立即回收内存

闭包和全局变量

3.13.6 请谈一下内存泄漏是什么,以及常见内存泄漏的原因,和排查的方法

什么是内存泄漏

  • 内存泄漏(Memory Leak)指由于疏忽或错误造成程序未能释放已经不再使用的内存的情况。

  • 如果内存泄漏的位置比较关键,那么随着处理的进行可能持有越来越多的无用内存,这些无用的内存变多会引起服务器响应速度变慢。

  • 严重的情况下导致内存达到某个极限(可能是进程的上限,如 v8 的上限;也可能是系统可提供的内存上限)会使得应用程序崩溃。 常见内存泄漏的原因 内存泄漏的几种情况:

    • 一、全局变量

      a = 10;  
      //未声明对象。  
      global.b = 11;  
      //全局变量引用 
      这种比较简单的原因,全局变量直接挂在 root 对象上,不会被清除掉。
      
    • 二、闭包

      function out() {  
          const bigData = new Buffer(100);  
          inner = function () {  
      
          }  
      } 
      

      闭包会引用到父级函数中的变量,如果闭包未释放,就会导致内存泄漏。上面例子是 inner 直接挂在了 root 上,那么每次执行 out 函数所产生的 bigData 都不会释放,从而导致内存泄漏。

      需要注意的是,这里举得例子只是简单的将引用挂在全局对象上,实际的业务情况可能是挂在某个可以从 root 追溯到的对象上导致的。

    • 三、事件监听

      Node.js 的事件监听也可能出现的内存泄漏。例如对同一个事件重复监听,忘记移除(removeListener),将造成内存泄漏。这种情况很容易在复用对象上添加事件时出现,所以事件重复监听可能收到如下警告:

      emitter.setMaxListeners() to increase limit 
      

      例如,Node.js 中 Agent 的 keepAlive 为 true 时,可能造成的内存泄漏。当 Agent keepAlive 为 true 的时候,将会复用之前使用过的 socket,如果在 socket 上添加事件监听,忘记清除的话,因为 socket 的复用,将导致事件重复监听从而产生内存泄漏。

      原理上与前一个添加事件监听的时候忘了清除是一样的。在使用 Node.js 的 http 模块时,不通过 keepAlive 复用是没有问题的,复用了以后就会可能产生内存泄漏。所以,你需要了解添加事件监听的对象的生命周期,并注意自行移除。

排查方法

想要定位内存泄漏,通常会有两种情况:

  • 对于只要正常使用就可以重现的内存泄漏,这是很简单的情况只要在测试环境模拟就可以排查了。
  • 对于偶然的内存泄漏,一般会与特殊的输入有关系。想稳定重现这种输入是很耗时的过程。如果不能通过代码的日志定位到这个特殊的输入,那么推荐去生产环境打印内存快照了。
  • 需要注意的是,打印内存快照是很耗 CPU 的操作,可能会对线上业务造成影响。 快照工具推荐使用 heapdump 用来保存内存快照,使用 devtool 来查看内存快照。
  • 使用 heapdump 保存内存快照时,只会有 Node.js 环境中的对象,不会受到干扰(如果使用 node-inspector 的话,快照中会有前端的变量干扰)。
  • PS:安装 heapdump 在某些 Node.js 版本上可能出错,建议使用 npm install heapdump -target=Node.js 版本来安装。