Node.js简介

Node.js是开源跨平台的JS运行环境,Node.js运行在V8 JS引擎,Chrome的核心,独立于浏览器,这使得Node.js性能很好。Node.js应用是单进程运行,对每个请求不会创建新线程。Node.js在标准库中提供了一系列异步IO特性,来防止阻塞式运行的JS代码,Node.js的库使用非阻塞范式实现,避免大量的阻塞行为。当Node.js做IO操作时,例如在网络上读取数据,访问数据库或者文件系统,并不会阻塞线程浪费CPU周期,Node.js会在接收到响应之后再恢复操作。这种方式允许Node.js处理数千个和单个服务器的并发连接,且不需要去管理线程的并发。Node.js的优势是前端开发人员在不需要学习新语言的前提下可以写服务端的代码。Node.js支持新的ECMA标准,通过改变Node.js的版本可以决定使用哪版ECMA标准,也可以通过参数来运行Node的特殊的实验特性

一个简单的例子

// 引入Node.js的http模块
const http = require('http');

const host = '127.0.0.1';
const port = 3000;

// 创建服务器并且返回
// 接收到新的请求后,request事件被调用,两个参数(req(http.IncomingMessage) res(http.ServerResponse))
// req提供了请求的详情(请求头和请求体)
// resp向调用者返回数据
const server = http.createServer((req, res) => {
    // 这是statusCode属性,表示返回成功
    res.statusCode = 200;
    // 设置响应头的Content-Type
    res.setHeader('Content-Type', 'text/plain');
    // 关闭响应,可以将返回内容作为参数返回
    res.end('Hello world!');
})

// 服务器要监听指定的端口和主机名,当server准备好会调用回调函数
server.listen(port, host, () => {
    console.log(`Server running at http://${host}:${port}`);
})

Node.js的框架和工具

Node.js是一个底层平台,为了方便开发者,在Node.js的社区中有数千个库以供提高开发效率。以下列一些值得学习的库

  1. AdonisJs:全栈框架,专注于开发者的人机工程、稳定性和自信(?confidence)的需求。Node.js的最快的web框架之一
  2. Express:创建一个web服务器的最简单的方式之一。专注于一个服务器的核心特性
  3. Fastify:web框架,专注于通过最少的代码和强大的插件架构来给开发者提供最好的开发体验。最快的web框架之一
  4. Gatsby:基于React,支持GraphQL,有丰富的插件和starter的生态的静态站点生成器(static site generator)
  5. hapi:内容丰富的框架,构建应用和服务,让开发者专注于写可重用的应用逻辑代码而不是构建脚本
  6. koa:Express的团队开发的,目标是更简单和更小,为了满足开发一些不影响现有社区的改变的需求
  7. Loopback.io:让构建需要复杂集成(complex integration)的新应用更简单
  8. Meteor:全栈框架,使用JS来同构地构建app,在客户端和服务器共享代码。一旦有现成的工具提供了所有功能,那么可以将它和任何前端库集成(Vue、Angular、React)。也能用来创建手机app
  9. Micro:提供了轻量级的服务器来创建异步HTTP微服务
  10. NestJS:基于progressive Node.js的TS框架,用来创建企业级高效、可靠和可扩展的服务端应用
  11. Next.js:React框架,提供所有的在开发过程中需要的所有特性(hybrid static,server rendering,支持TS,smart bundling,route pre-fetching,等等)
  12. Nx:使用NestJS,Express,React,Angular等全栈框架开发的工具箱。Nx可以帮助将一个单团队开发的应用扩展到多团队协作的项目
  13. Sapper:创建各种量级的web应用的框架,漂亮的开发体验和灵活的基于文件系统的路由。提供了SSR和其它
  14. Socket.io:实时通信引擎
  15. Strapi:里能够获得,开源的Headless CMS,让开发者自由选择他们喜欢的工具和框架来让编辑者管理和分发他们的创作内容。通过admin控制台和API扩展,它可以让大公司提高内容分发的速度

Node.js的历史回顾

Node.js截至2020年仅仅11岁,JS24岁,Web31岁。JS是为了在处理(网景)浏览器中的web页面而创建的。网景的Netscape LiveWire是可以使用服务端JS来生成动态网页的环境。然而卖的并不好,所以服务端JS也不受欢迎。Node.js的崛起的一个核心因素是时机,就在几年前,JS开始想成为一门严肃的语言,且“Web2.0”应用(Gmail等)也证明了浏览器应用很受欢迎。JS引擎也因为浏览器之间的竞争变得更好,V8(Chrome V8)引擎就是竞争的结果,Node.js就是运行在这个引擎上面。Node.js是在正确的地方以正确的时间点出现的,但是它的设计思想和对于服务端JS开发也做了很多贡献

2009

  • Node.js诞生
  • 第一版npm被创建

2010

  • Express诞生
  • Socket.io诞生

2011

  • npm版本到1.0
  • 大公司开始应用Node.js,例如linkedin,Uber等
  • hapi诞生

2012

  • 应用更加广泛

2013

  • Ghost(大型博客平台)应用Node.js
  • Koa诞生

2014

  • io.js是Node.js的一个最大的fork,为了支持ES6

2015

  • Node.js Foundation诞生
  • IO.js merge回Node.js
  • npm出现了私有模块(private module)
  • Node.js4诞生

2016

2017

  • npm主要关注于安全主题
  • Node.js8诞生
  • HTTP/2
  • V8将Node.js放入了测试套件,官方将Node.js作为JS引擎的目标,不止Chrome
  • 每周30亿npm下载次数

2018

  • Node.js10
  • 支持ES modules.mjs实验版
  • Node.js11

2019

  • Node.js12
  • Node.js13

2020

  • Node.js14
  • Node.js15

安装Node.js

Node.js有很多种安装方式,本post只展示最常用且最方便的一种

对所有平台的官方包都在node下载

安装Node.js最简单的方式是通过包管理器,不同的操作系统有各自的包管理器

MacOS:Homebrew -- brew install node

其它Linux和Windows下的包管理器列表在这里Windows和Linux下的包管理器

nvm是运行Node.js的一种受欢迎的方式,它可以转换Node.js的版本,安装新版本或者回滚到老版本。用老版本的Node.js测试代码也是很有必要的

nvm详情

本post主建议使用官方安装包,如果是新手或者已经不(能)用Homebrew了,否则Homebrew是最佳选择

无论以何种方式安装,一旦Node.js安装好,就可以在命令行来访问node可执行程序了

要使用node需要什么JavaScript基础?

应该掌握的JS基本概念:

  1. Lexical Structure(语法结构)
  2. 表达式
  3. 类型
  4. 变量
  5. 函数
  6. this
  7. Arrow Functions
  8. 循环
  9. scopes(作用域)
  10. Arrays
  11. Template Literals(模板字符串,通过着重号包起来的字符串)
  12. Semicolons
  13. Strict mode
  14. ES6,2016,2017

这些内容保证你成为一个熟练的JS开发人员,无论是针对浏览器还是Node.js

要理解异步编程下面这些概念也很重要,它们是Node.js的基础

  1. 异步编程和回调
  2. Timers
  3. Promises
  4. Async和Await
  5. Closures
  6. The Event Loop

Node.js和浏览器的不同

浏览器和Node.js都是将JS作为它们的编程语言。构建运行在浏览器上的app和构建Node.js应用是完全不一样的

从使用JS的前端程序员角度看,Node.js开发的app给他们带来极大优势:可以使用一种语言编写任何程序(即前后端)

学习另外一种语言很困难,所以能用一种语言实现客户端和服务端的功能是非常有优势的(淘汰后端程序员【手动滑稽】)

Node.js改变的是生态。在浏览器中,大部分时间是在和DOM交互,或者其它的Web平台API,例如Cookies。这些在Node.js中都不存在。当然,浏览器提供的document、window等对象也都不存在。在浏览器中我们也没有Node.js通过它的模块提供的各种好用的API,例如文件系统访问功能

另一个重大区别是,在Node.js中,你控制环境。除非你在开发开源应用,要满足使用的人可以在任意平台部署应用,否则你来决定使用什么版本的Node.js运行程序。和不得不去适配的浏览器相比,这是一个很奢侈的享受。这也意味着你可以写Node.js支持的ES6-7-8-9标准的JS

因为JS更新很快,但是浏览器会稍慢一些,用户更新可能会更慢,有时在web方面,你不得不使用比较老版本的JS/ESMAScript版本。 你可以使用Babel将自己的代码在移植到其它浏览器之前转为ES5兼容的版本,但是Node.js中,不需要这样做

另一个区别是Node.js使用CommonJS模块系统,然而在浏览器中,我们开始看到ES模块标准正在被实现,实践中,这表示你在Node.js中使用require()的时候,在浏览器中要使用import

V8 JavaScript引擎

V8是Chrome的核心JS引擎的名字。它做的事情是在我们通过浏览Chrome是加载和执行JavaScript。V8提供了JS运行的运行时环境(runtime)。DOM和浏览器提供的其它的Web平台API。JS引擎是独立于它所寄生的浏览器的,这个特性使得Node.js得以崛起。在2009年,V8被选来驱动Node.js,由于Node.js影响力扩大,V8变成了驱动不可计数的JS写的服务端代码的引擎

Node.js生态很大,因此V8也可以驱动桌面app,例如Electron项目

其它的浏览器有自己的引擎,Firefox有SpiderMonkey,Safari有JavaScriptCore(Nitro),Edge开始基于Chakra但是最近使用Chromium和V8引擎重构,此外还存在其它的引擎。这些引擎都实现了ECMA ES-262标准,也叫ECMAScript,即JS使用的标准

对性能的需求。V8是用C++编写,并且还在持续改进。它是兼容多个系统平台(Mac,Windows,Linux...),这里我们忽略V8实现的具体细节(官网介绍)。V8一直在进化,就像其它的JS引擎,为了加速Web速度和改进Node.js生态。在Web上,性能上的竞争已经很多年了,作为用户和开发者,竞争导致的我们的用户体验和开发体验都有很大提升

JS通常被认为是解释型语言,但是现在的JS引擎不再只是简单解释JS,而是去编译它。在2009年这件事情就发生了,当时SpiderMonkey JS编译器被添加到了Firefox 3.5,然后所有人都开始跟进。JS被V8使用JIT编译器内部编译后可以加速其执行。看起来有一些反直觉,但是当Google Maps在2004年出现,JS已经从顺序执行几百行代码到可以在浏览器运行上万行的完整程序

我们的应用现在可以在浏览器内运行几个小时。而不再是一些表单验证的简单脚本。在这个新世界中,编译的JS很重要,因为尽管需要一些事件来让JS编译好,但是一旦编译完成,便会获得比单纯解释代码多得多的性能

从命令行运行Node.js程序

运行Node.js程序的一般方法是,通过全局可用命令node和你想执行的文件名。如果你的main Node.js应用文件叫做app.js,你可以这样调用

node app.js

运行时要确保你的命令行当前目录和运行的文件在同一目录

如何退出Node.js程序

有很多种方式来中断Node.js程序。在命令行中运行的程序可用通过ctrl-C来关闭,但是这里讨论的是以编程方式退出

首先展示最drastic的方式,然后来看为什么最好不用它:

// process core模块中提供了方便的方法,可在程序内退出程序
process.exit();

当Node.js运行到这一行,进程立即被强制终止。这代表任何pending状态的回调函数,任何还在发送的网络请求,任何文件系统的访问,或者在向stdout和stderr写数据的进程,全部都会以ungracefully的方式退出

如果这些情况对你来说没有任何问题,那么可以给这个方法传入一个整数值来告诉操作系统这个程序的退出码process.exit(1)。默认退出码是0,代表成功。不同的退出码有不同的含义,这些含义在不同的程序中交互时是有意义的(退出码的介绍)。也可以通过process.exitCode=1的方式来设置属性,当程序退出时Node.js会返回这个退出码

gracefully的程序退出方式是当所有的处理过程都做完时退出

我们通过Node.js多次启动服务器,例如

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

app.get('/', (req, res) => {
  res.send('Hi!')
})

app.listen(3000, () => console.log('Server ready'))

这个程序永远不会结束。如果调用process.exit(),任何当前处在pending和running的请求都会被抛弃,这并不是好方法。这种情况下,你应该发送给command一个SIGTERM标识,然后通过process signal handler来处理

process不需要require(),它是Node.js自动包含的

const express = require('express')

const app = express()

app.get('/', (req, res) => {
  res.send('Hi!')
})

const server = app.listen(3000, () => console.log('Server ready'))

process.on('SIGTERM', () => {
  server.close(() => {
    console.log('Process terminated')
  })
})

什么是signals?它是POSIX的相互通信系统,一个发送到某进程的通知,来告诉这个进程某个发生了的事件。SIGKILL告诉进程立即终止,就像process.exit()SIGTERM告诉进程要gracefully终止,它是从进程管理器(upstart或者supervisord等)发送出的signal

你可以从程序内部发送这个signal,通过另一个函数process.kill(process.pid, 'SIGTERM'),或者从另一个运行中的Node.js程序,或任何运行在系统中的其它程序(知道你想终止的进程的PID)

Node.js中如何读取环境变量

process核心模块中提供了env属性来存放在程序运行时系统具有的所有环境变量。例如process.env.NODE_ENV // "development"。在程序运行前将其设置为“production”可以告诉Node.js现在处于生产环境

如何使用Node.js REPL

node命令是我们运行Node.js脚本的命令之一,如果我们不提供文件名参数,会进入REPL(Read Evaluate Print Loop,指编程语言环境,接收单行用户输入表达式并在控制台返回结果)模式

使用tab来进行自动补全,

将JS类的名字,例如Number,加一个.,再按tab,会打印出这个类的所有属性和方法

可以通过global.tab来查看所有的全局对象

如果在一些代码之后加上了_,那么会打印上一次表达式的结果

REPL有一些特别的命令,都以点.开头

  • .help 显示.命令帮助信息
  • .editor 进入editor模式,可以写多行JS代码,一旦进入这个模式,需要使用ctrl-D来运行代码
  • .break 当输入多行表达式时,输入.break命令会丢弃更多的输入,和ctrl-C效果相同
  • .clear 重置REPL上下文成为一个空对象,并且清除正在输入的所有多行表达式
  • .load 加载JS文件,路径相对于当前工作目录
  • .save 将所有在REPL session中的输入保存在文件中,需要指定文件名
  • .exit 退出REPL,效果和两次ctrl-C相同

当你输入多行表达式时,REPL不需要你调用.editor,在输入enter后,会进入新行而不是打印结果。输入.break会退出多行输入模式

Node.js从命令行接收参数

在使用node app.js运行Node.js应用时可以传入任意数量的参数。参数可以是单个的也可以是k-v形式。即

node app.js joe
node app.js name=joe

这两种方式下,在程序中取值的方法不一样。使用内置的process对象来取命令行参数。它有一个argv属性,是一个数组,包含所有调用程序的参数。第一个元素是node命令的全路径,第二个参数是执行代码文件的全路径,所有的其它参数从第三个位置开始往后

process.argv.forEach((val, index) => {
  console.log(`${index}: ${val}`)
})

可以通过创建一个新数组来仅获取额外参数,const args = process.argv.slice(2),在这种情况下,如果是未命名参数,直接使用args[0]获取,如果是命名参数,args[0]name=joe,需要转换,通常使用minimist库

const args = require('minimist')(process.argv.slice(2))
args['name'] //joe

如果使用这种方式,在给定参数时要加双横杠 node app.js --name=joe

使用Node.js输出到命令行

Node.js提供了console模块,其中有很多和命令行交互的方式。基本上和浏览器中的console对象差不多。最基本和最常用的方法是console.log(),它将字符串打印到控制台中。如果你传入一个对象,会被改变为string。可以将多个变量传入本方法,例如

const x = 'x'
const y = 'y'
// Node.js会全部打印
console.log(x, y)

可以通过变量和格式符来美化字符串

console.log('My %s has %d years', 'cat', 2)
// %s format a variable as a string
// %d format a variable as a number
// %i format a variable as its integer part only
// %o format a variable as an object

console.log('%o', Number)
// 清空控制台,它的行为可能会依赖使用的控制台
console.clear()
// 计算一个字符串被打印的次数并且打印出来
console.count()

// 输出效果
// orange: 1
// orange: 2
// apple: 1
const oranges = ['orange', 'orange'];
const anApple = ['apple']

oranges.forEach((v, i) => {
    console.count(v);
})

anApple.forEach((v, i) => {
    console.count(v);
})

打印堆栈信息。打印一个函数的调用堆栈追踪信息很有用,这可以显示出,你是如何到达代码的某个部分

const function2 = () => console.trace()
const function1 = () => function2()
// 这会打印所有堆栈信息,就是从哪里调用到了console.trace这个语句
// Trace
//     at function2 (repl:1:33)
//     at function1 (repl:1:25)
//     at repl:1:1
//     at ContextifyScript.Script.runInThisContext (vm.js:44:33)
//     at REPLServer.defaultEval (repl.js:239:29)
//     at bound (domain.js:301:14)
//     at REPLServer.runBound [as eval] (domain.js:314:12)
//     at REPLServer.onLine (repl.js:440:10)
//     at emitOne (events.js:120:20)
//     at REPLServer.emit (events.js:210:7)
function1()

计算度过的时间。通过time() timeEnd()可以轻松计算一个函数运行花费的时间

// 会打印出的内容
// test
// doSomething(): 3.243ms
const doSomething = () => console.log('test')
const measureDoingSomething = () => {
  console.time('doSomething()')
  //do something, and measure the time it takes
  doSomething()
  console.timeEnd('doSomething()')
}
measureDoingSomething()

stdout stderrconsole.log打印数据到标准输出(stdout)。console.error打印到了stderr流,这种方式打印的信息不在控制台,而会在错误日志中

给输出内容上色,通过转义序列(escape sequence)可以给打印到控制台的内容上色。一个转义序列是一组标识了颜色的字符

// 文档说是黄色,powershell是红色
console.log('\x1b[33m%s\x1b[0m', 'hi!')

这是一种底层实现方式。简单的方式是使用库。Chalk就是这样的库,除了上色也有其它的格式化工具,例如加粗、斜体和下划线

通过npm安装好后,可以这么使用

const chalk = require('chalk')
console.log(chalk.yellow('hi!'))

创建一个进度条。Progress可以在控制台创建一个进度条。通过npm安装好之后。以下代码段创建了10步进度条,每100ms完成1步,当进度条完成我们清除了interval

const ProgressBar = require('progress')

const bar = new ProgressBar(':bar', { total: 10 })
const timer = setInterval(() => {
  bar.tick()
  if (bar.complete) {
    clearInterval(timer)
  }
}, 100)

Node.js从命令行来接收输入

怎么使用Node.js来开发命令行工具?

从Node.js7开始,它提供了readline模块来做一些工作:从readable流(process.stdin)获取输入,在Node.js程序运行的时候标准输入流就是终端输入,一次一行

const readline = require('readline').createInterface({
  input: process.stdin,
  output: process.stdout
});

readline.question("What's your name?", name => {
  console.log(`Hi, ${name}!`);
  readline.close();
});

这些代码询问了用户名,一旦文本输入并且按下回车,那么程序会发送一个问候信息。question()方法的第一个参数提出问题并且等待回复,一旦回车被按下,会调用回调函数。回调函数中我们关闭了readline接口

readline提供了一些其它的方法,在readline模块文档查看

如果需要输入密码,直接将输入打印到控制台并不合适,应该显示*号,最方便的方法是使用readline-sync包,提供了开箱即用的API。最完整和抽象的解决方案是使用Inquirer.js

使用npm安装完成之后,可以通过以下方式使用

const inquirer = require('inquirer')

var questions = [
  {
    type: 'input',
    name: 'name',
    message: "What's your name?"
  }
]

inquirer.prompt(questions).then(answers => {
  console.log(`Hi ${answers['name']}!`)
})

这个模块提供了很多功能(询问多个选项,包括单选、确认等等)。如果要将CLI输入作为目标,那么更应该学习Inquirer.js库而不是自带的readline

从Node.js文件中使用export来暴露功能

一个Node.js文件可以引入其它Node.js文件暴露出来的功能。当你想引入一些东西时使用const library = require('./library')来引入library.js文件暴露出来的功能(这个文件和当前文件在同一目录)。在这个文件中,功能在它可以被引入之前必须先暴露出去。任何定义在文件中的对象或变量默认都是私有(private)的。是module.exports的API允许我们可以这样做。当你将一个对象或一个函数赋值给一个暴露(export)属性,它们就能被其它的文件引入

有两种方式可以做这件事

  1. 将对象赋值给module.exports,这是模块系统提供的盒子,可以保证这个文件只暴露在这里面存的对象
const car = {
  brand: 'Ford',
  model: 'Fiesta'
}

module.exports = car

// other file
const car = require('./car')
  1. 第二种方式是将要暴露的对象当作exports的属性,这种方式让你可以暴露多个对象,函数或数据
const car = {
  brand: 'Ford',
  model: 'Fiesta'
}

exports.car = car
// or directly
exports.car = {
  brand: 'Ford',
  model: 'Fiesta'
}

// other file
const items = require('./items')
items.car
// or
const car = require('./items').car

这两种方式的区别是,第一种暴露指定的对象,第二种方式暴露的是指定的对象的属性(相当于每个文件有一个exports对象可以暴露,要么直接给它赋值替代(module.exports才能修改值,直接改exports没有用),要么就通过给它加属性来暴露更多的数据)

要注意,当你在其它文件引入这个文件时,这个文件中其它的全局域内的语句会执行

NPM介绍

npm是Node.js的标准包管理器。在2017年1月,npm仓库列出的包超过350000个,它变成了最大的单一语言代码库,这些包(几乎)包含了一切功能

开始npm是用来下载和管理Node.js包的依赖,但是现在它已经变成了前端JS的工具。npm可以做很多事情,Yarn是npm的一个替代品,最好也去了解它一下

npm管理你项目依赖包的下载,如果一个项目有package.json文件,只要运行npm install,它会下载项目需要的所有包,下载到node_modules文件夹,如果不存在会创建此目录

安装单个包,通过运行npm install <package-name>可以安装单个包。这个命令经常伴有不少标志

  • --save 安装并且将entry加入到package.json文件依赖
  • --save-dev 安装并且将entry加入到package.json文件的devDependencies

它们之间的区别是devDependencies通常是开发的工具,例如测试库,并不需要绑定到生产app上

通过npm update来更新包,npm会检查所有包的新版本(满足版本限制),可以指定单个包来更新npm update <package-name>

除了直接下载,npm也可以管理版本,所以你可以指定一个包的任意版本,使用更高或更低的版本来满足需求。通常你会发现一个库只和另一个库的主要发行版本兼容,或者最新版本还没有修复bug。显式指定库的版本可以让所有人在同一套包环境下开发(在package.json文件更新之前)

npm符合semantic versioning(semver)标准

package.json文件支持一种可以被运行的特殊命令行任务(npm run <task-name>)的格式

例如;

{
  "scripts": {
    "start-dev": "node lib/server-development",
    "start": "node lib/server-production"
  },
}
// It's very common to use this feature to run Webpack:
{
  "scripts": {
    "watch": "webpack --watch --progress --colors --config webpack.conf.js",
    "dev": "webpack --progress --colors --config webpack.conf.js",
    "prod": "NODE_ENV=production webpack -p --config webpack.conf.js",
  },
}

此时,不需要输入长命令,而是直接通过一下命令运行应用

$ npm run watch
$ npm run dev
$ npm run prod

npm安装的包在什么位置?

当你使用npm安装包时有两种模式

  1. local install
  2. global install

默认,当你执行安装命令时,包会被安装到当前的文件树下,在node_module子文件夹下。此时,npm也会在当前文件夹下的package.json文件中的dependencies属性下添加一个安装包的入口信息(entry)

通过-g标识来进行全局安装。npm install -g lodash。此时,npm会使用全局位置来作为下载路径。使用npm root -g命令来查看本机的全局目录在哪。macOS或者Linux,位置应该是/usr/local/lib/node_modules,在Windows环境下,应该是C:\Users\YOU\AppData\Roaming\npm\node_modules。如果使用nvm来管理Node.js版本,那么这个位置可能会有变化

如何通过npm来使用或者运行一个包

当你使用npm安装好一个包后,怎么在Node.js代码中使用呢?以lodash包(JS工具包)为例

// 使用require来引入包
const _ = require('lodash')

如果你的包是可执行的,它的可执行文件会被放在node_modules/.bin文件夹。以cowsay包(命令行工具--让一头奶牛说一些东西)为例,使用npm安装它之后,它本身和一些依赖会安装到node_modules目录下,可以通过./node_modules/.bin/cowsay来运行它,但是在npm(5.2之后),npx是一个更好的选项,即npx cowsay,那么npx会找到这个包(可执行文件)的位置

关于package.json文件

package.json文件是你的项目的展示(manifest)。它可以做很多事情,是对各种工具配置的中央仓库。它也是npm和yarn存储安装包的名字和版本的地方

文件的结构,{},对于这个文件没有任何影响要求的内容。唯一的要求就是符合JSON文件的格式,否则它无法被尝试访问它包含的属性的程序读取。如果你正在构建一个Node.js包,要把它发布到npm上,那么必须指定一系列属性来帮助别人使用它

{
  "name": "test-project"
}

它定义了一个名字的属性,声明了这个项目(app/package)的名字,这个项目包含在同目录下的一个同名文件夹下

以下是一个稍显复杂的例子,是从一个Vue.js应用中抽取出来的

{
  "name": "test-project",
  "version": "1.0.0",
  "description": "A Vue.js project",
  "main": "src/main.js",
  "private": true,
  "scripts": {
    "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
    "start": "npm run dev",
    "unit": "jest --config test/unit/jest.conf.js --coverage",
    "test": "npm run unit",
    "lint": "eslint --ext .js,.vue src test/unit",
    "build": "node build/build.js"
  },
  "dependencies": {
    "vue": "^2.5.2"
  },
  "devDependencies": {
    "autoprefixer": "^7.1.2",
    "babel-core": "^6.22.1",
    "babel-eslint": "^8.2.1",
    "babel-helper-vue-jsx-merge-props": "^2.0.3",
    "babel-jest": "^21.0.2",
    "babel-loader": "^7.1.1",
    "babel-plugin-dynamic-import-node": "^1.2.0",
    "babel-plugin-syntax-jsx": "^6.18.0",
    "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
    "babel-plugin-transform-runtime": "^6.22.0",
    "babel-plugin-transform-vue-jsx": "^3.5.0",
    "babel-preset-env": "^1.3.2",
    "babel-preset-stage-2": "^6.22.0",
    "chalk": "^2.0.1",
    "copy-webpack-plugin": "^4.0.1",
    "css-loader": "^0.28.0",
    "eslint": "^4.15.0",
    "eslint-config-airbnb-base": "^11.3.0",
    "eslint-friendly-formatter": "^3.0.0",
    "eslint-import-resolver-webpack": "^0.8.3",
    "eslint-loader": "^1.7.1",
    "eslint-plugin-import": "^2.7.0",
    "eslint-plugin-vue": "^4.0.0",
    "extract-text-webpack-plugin": "^3.0.0",
    "file-loader": "^1.1.4",
    "friendly-errors-webpack-plugin": "^1.6.1",
    "html-webpack-plugin": "^2.30.1",
    "jest": "^22.0.4",
    "jest-serializer-vue": "^0.3.0",
    "node-notifier": "^5.1.2",
    "optimize-css-assets-webpack-plugin": "^3.2.0",
    "ora": "^1.2.0",
    "portfinder": "^1.0.13",
    "postcss-import": "^11.0.0",
    "postcss-loader": "^2.0.8",
    "postcss-url": "^7.2.1",
    "rimraf": "^2.6.0",
    "semver": "^5.3.0",
    "shelljs": "^0.7.6",
    "uglifyjs-webpack-plugin": "^1.1.1",
    "url-loader": "^0.5.8",
    "vue-jest": "^1.0.2",
    "vue-loader": "^13.3.0",
    "vue-style-loader": "^3.0.1",
    "vue-template-compiler": "^2.5.2",
    "webpack": "^3.6.0",
    "webpack-bundle-analyzer": "^2.9.0",
    "webpack-dev-server": "^2.9.1",
    "webpack-merge": "^4.1.0"
  },
  "engines": {
    "node": ">= 6.0.0",
    "npm": ">= 3.0.0"
  },
  "browserslist": ["> 1%", "last 2 versions", "not ie <= 8"]
}
  • version - 表示当前的版本
  • name - 应用/包的名字
  • description - 对应用/包的简单介绍
  • main - 应用的入口
  • private - 如果是true则阻止应用/包被意外发布到npm上
  • scripts - 定义了一系列可以运行的node脚本
  • dependencies - 作为依赖的一系列npm包
  • devDependencies - 作为开发时依赖的一系列npm包
  • engines - 设置应用/包运行的Node.js版本
  • browserslist - 告诉你想支持的浏览器(对应版本)

这些属性会被npm或其它可以使用的工具应用

(包和单机应用的区别就是发布/不发布的区别)

大部分的属性只会在Node.js上使用,还有一些跟你的代码交互的工具使用(npm等)

name

设置包名,"name": "test-project"。名字必须少于214个字符,不能包含空格,只能包含小写字母,横杠(-)或者下划线(_)。这是因为当包发布到npm上后,会生成它自己的链接。如果要把这个包发布到Github,那么把这个属性设置为仓库名是个不错的选择

author

列出作者名单

{
  "author": "Joe <joe@whatever.com> (https://whatever.com)"
}

{
  "author": {
    "name": "Joe",
    "email": "joe@whatever.com",
    "url": "https://whatever.com"
  }
}

contributors

和作者相同,一个项目可能有一个或多个贡献者,这个属性包含一个数组来列出他们的名字

{
  "contributors": ["Joe <joe@whatever.com> (https://whatever.com)"]
}

{
  "contributors": [
    {
      "name": "Joe",
      "email": "joe@whatever.com",
      "url": "https://whatever.com"
    }
  ]
}

bugs

放这个包的issue tracker链接(类似Github的issue页)

{
  "bugs": "https://github.com/whatever/package/issues"
}

homepage

设置包的主页


{
  "homepage": "https://whatever.com/package"
}

version

表示这个包的当前版本。这个属性遵循(semver),即版本总是通过3个数字来表示x.x.x。第一个数字是主要版本,第二个是副版本,第三个是补丁版本。修复bug的版本是补丁版本,推出向下兼容的更新内容是副版本,推出重大更新是主要版本
version

licence

表示这个包采取的协议类型"license": "MIT"

keywords

包括一些和这个包的功能相关的一些关键字

"keywords": [
  "email",
  "machine learning",
  "ai"
]

当人们浏览包页面时,这个属性帮助网站更容易导航到这个包

description

包含对这个包的简要介绍。"description": "A package to work with strings",当你决定将包发不到npm上时,人们可以直到你的包是干什么的

repository

表明这个包的仓库在哪里。"repository": "github:whatever/testing",这是github的前缀,还有一些其它系统的链接。或者通过以下形式

"repository": {
  "type": "git",
  "url": "https://github.com/whatever/testing.git"
}

main

设置这个包的执行入口位置。当你在应用中引用这个包时,这个属性会告诉应用到哪里去寻找模块暴露的位置"main": "src/main.js"

private

如果设为true,那么这个应用/包不会意外发布到npm。"private": true
main

scripts

定义了一系列可以运行的node脚本

"scripts": {
  "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
  "start": "npm run dev",
  "unit": "jest --config test/unit/jest.conf.js --coverage",
  "test": "npm run unit",
  "lint": "eslint --ext .js,.vue src test/unit",
  "build": "node build/build.js"
}

这些脚本都是命令行应用。可以通过npm run xxxx yarn xxx来运行,xxx就是这个属性的key

dependencies

设置了一系列作为依赖的npm包。当你使用npm或yarn安装包时,这个包自动被插入到这个属性的列表中

"dependencies": {
  "vue": "^2.5.2"
}

devDependencies

设置了一系列作为开发阶段依赖的npm包。它和dependencies不同,因为它们只在开发机器上被安装,在生产环境是不需要的

当你使用npm install --save-dev <PACKAGENAME> yarn add --dev <PACKAGENAME>安装包时,这个包的信息自动插入到这个属性的列表中

engines

设置Node.js和其它这个包支持运行的工具的版本

"engines": {
  "node": ">= 6.0.0",
  "npm": ">= 3.0.0",
  "yarn": "^0.13.0"
}

browserslist

曾经被用来说明你想支持的浏览器(和它们的版本)。它会被Babel,Autoprefixer和其它工具引用,只会在你指定的浏览器(如果需要)添加polyfill和fallback

"browserslist": [
  "> 1%",
  "last 2 versions",
  "not ie <= 8"
]

上述内容表明你想支持所有浏览器(1%的使用率--CanIUse.com的数据)的最近2个版本,不支持IE8及以下

针对命令行的属性

package.json文件也可以放针对命令行的配置,例如对于Babel、ESLint等工具。每个都有特殊的属性,例如eslintConfigbabel和其它。通过查看对应的文档来了解相关内容

上述的包的版本中包括~3.0.0^0.13.0是什么意思?这些符号制定了哪个版本是你的包支持的。假如使用semver的版本管理,你可以将多个版本放在一个范围中1.0.0 || >= 1.1.0 < 1.2.0,即使用1.0.0或大于1.1.0,小于1.2.0

关于package-lock.json文件

在npm5中,推出了package-lock.json文件。这个文件是为了追踪安装的所有包的准确版本信息,这样即使包更新了,应用也可以100%以同样方式再现。在package.json中,你可以设置你想升级的版本,使用semver版本管理

如果~0.13.0,那么你只希望更新补丁版本(0.13.1可以,但是0.14.0不行)。如果^0.13.0,那么你只希望升级补丁版本和副版本(0.13.1和0.14.0等)。如果0.13.0,那么会使用这个版本。你不会commit自己的node_modules文件夹到vcs中,当你想在其它机器上复制自己的程序(使用npm下载包),那么会下载你指定的版本中更新的且是你的应用可以接收的版本

如果指定了指定版本,那么不会被这个问题影响。package-lock.json将你当前安装的每个包的版本固定下来,npm在运行install时会使用这些版本。这个文件需要被commit到你的vcs中,依赖的版本信息会在package-lock.json文件中更新,当你运行npm update

一个例子。这是一个package-lock.json文件,当我们在一个空文件夹使用npm安装cowsay是获得的

{
  "requires": true,
  "lockfileVersion": 1,
  "dependencies": {
    "ansi-regex": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.
0.0.tgz",
      "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
    },
    "cowsay": {
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/cowsay/-/cowsay-1.3.1.tgz"
,
      "integrity": "sha512-3PVFe6FePVtPj1HTeLin9v8WyLl+VmM1l1H/5P+BTTDkM
Ajufp+0F9eLjzRnOHzVAYeIYFF5po5NjRrgefnRMQ==",
      "requires": {
        "get-stdin": "^5.0.1",
        "optimist": "~0.6.1",
        "string-width": "~2.1.1",
        "strip-eof": "^1.0.0"
      }
    },
    "get-stdin": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.
1.tgz",
      "integrity": "sha1-Ei4WFZHiH/TFJTAwVpPyDmOTo5g="
    },
    "is-fullwidth-code-point": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/
is-fullwidth-code-point-2.0.0.tgz",
      "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
    },
    "minimist": {
      "version": "0.0.10",
      "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10
.tgz",
      "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8="
    },
    "optimist": {
      "version": "0.6.1",
      "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
      "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=",

      "requires": {
        "minimist": "~0.0.1",
        "wordwrap": "~0.0.2"
      }
    },
    "string-width": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
      "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
      "requires": {
        "is-fullwidth-code-point": "^2.0.0",
        "strip-ansi": "^4.0.0"
      }
    },
    "strip-ansi": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
      "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
      "requires": {
        "ansi-regex": "^3.0.0"
      }
    },
    "strip-eof": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
      "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8="
    },
    "wordwrap": {
      "version": "0.0.3",
      "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz",
      "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc="
    }
  }
}

cowsay依赖了如下包

  • get-stdin
  • optimist
  • string-width
  • strip-eof

同样这些包也依赖了其它包,我们可以在requires属性中看到

  • ansi-regex
  • is-fullwidth-code-point
  • minimist
  • wordwrap
  • strip-eof

这些都按字母顺序添加进了文件,每个包都有版本字段,resolved字段指向包的位置,integrity字符串可以用来做包验证

找到安装好的npm包的版本

要查看所有安装好的包的最新版本和它们的依赖,可以通过npm list来实现

❯ npm list
/Users/joe/dev/node/cowsay
└─┬ cowsay@1.3.1
  ├── get-stdin@5.0.1
  ├─┬ optimist@0.6.1
  │ ├── minimist@0.0.10
  │ └── wordwrap@0.0.3
  ├─┬ string-width@2.1.1
  │ ├── is-fullwidth-code-point@2.0.0
  │ └─┬ strip-ansi@4.0.0
  │   └── ansi-regex@3.0.0
  └── strip-eof@1.0.0

也可以查看package-lock.json文件,但是查看不清晰。npm list -g的功能没有区别,只是查看全局安装的包

如果只想查看顶层包(在package.json中展示的包),那么使用npm list --depth=0

可以通过指定包名来获取它的版本,npm list cowsay。这种方式对于安装的包也适用npm list minimist

如果你想查看npm中某个包的最新可用版本,可以使用npm view [package_name] version

安装npm包的比较老的版本

可以通过npm install <package>@<version>的方式来安装老版本的npm包。例如npm install cowsay@1.2.0(最新版本是1.3.1--发post时间)。使用同样的方式全局安装老版本的npm包。可以通过npm view <package> versions来查看某个包在npm上所有的版本npm view cowsay versions

更新所有的Node.js依赖到最新版本

当你使用npm install安装某个包时,安装的是这个包的最新版本,这个包被下载到了node_modules目录并且在package.json和package-lock.json中添加了对应的entry。npm会分析依赖并且也安装它们的最新版本

cowsay为例,当你npm install cowsay,package.json文件出现

{
  "dependencies": {
    "cowsay": "^1.3.1"
  }
}

package-lock.info文件出现

{
  "requires": true,
  "lockfileVersion": 1,
  "dependencies": {
    "cowsay": {
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/cowsay/-/cowsay-1.3.1.tgz",
      "integrity": "sha512-3PVFe6FePVtPj1HTeLin9v8WyLl+VmM1l1H/5P+BTTDkMAjufp+0F9eLjzRnOHzVAYeIYFF5po5NjRrgefnRMQ==",
      "requires": {
        "get-stdin": "^5.0.1",
        "optimist": "~0.6.1",
        "string-width": "~2.1.1",
        "strip-eof": "^1.0.0"
      }
    }
  }
}

这两个文件告诉我们安装了cowsay的1.3.1版本,我们的更新规则是^1.3.1,即支持补丁版本和副版本的更新。如果有了新版本,我们可以输入npm update,已安装的版本就会更新,package-lock.json文件也会更新,但是package.json不会改变

要查找包的新发行版本,可以运行npm outdated。其中一些更新可能是主要版本更新,npm update不会更新这些版本,由于主要版本基本上是大更新(不兼容),所以不能以这种方式更新。要更新主要版本的包,先全局安装npm-check-updates npm install -g npm-check-updates,然后运行ncu -u,这会更新package.json中所有包的版本,dependencies和devDependencies,因此npm可以安装新的主要版本

如果你下载了一个没有node_modules目录的项目,然后你需要先下载新的包,只需要运行npm install即可

使用npm进行Semantic Versioning

Node.js包都遵循semver规则。这个规则之前已经介绍过了。当你要做一个新的发行版,需要按照规则来更新版本

  • major version - 改变包括不兼容API
  • minor version - 向后兼容的情况下增加功能
  • patch version - 向后兼容的bug修复过程

npm定义了一些我们可以在package.json文件来选择使用的规则,用以通过npm update能更新的版本

  • ^ - 在不改变非零值的情况下更新版本。^0.13.0只会在13的副版本下更新补丁版本,^1.13.0不能更新到2.0.0
  • ~ - ~0.13.0只能更新补丁版本
  • > - 接受到任何一个大于给定版本的版本
  • >= - 接受到任何一个等于或大于给定版本的版本
  • < - 接受到任何一个小于给定版本的版本
  • <= - 接受到任何一个等于或小于给定版本的版本
  • = - 接受到指定版本
  • - - 一个版本范围
  • || - 组合接受范围

还有一些其它的规则,不写标识(只接受给定版本),latest使用最新可用版本

卸载npm包

要卸载本地安装(npm install <package-name>)过的包,通过npm uninstall <package-name>,会从项目根目录(包含node_mudoles)中卸载包。使用-S --save标识会移除在package.json文件中的引用。如果包是个开发依赖,那么必须使用-D --save-dev标识来从文件(package.json)中移除它

npm uninstall -S <package-name>
npm uninstall -D <package-name>

如果是全局安装的包,需要加-g --global标识。npm uninstall -g <package-name>。这个命令可以在任何目录下运行

npm global or local packages

在局部和全局包之间的主要区别是:

  1. 局部包安装到了你运行npm install <package-name>命令的目录,它们被放到了这个目录下的node_modules目录
  2. 全局包放在了系统中的一个单独位置(安装时设置),无论在哪里运行npm install -g <package-name>

在你的代码中,你只需要(require)局部包

require('package-name');

因此,哪种包在何时应该被安装呢?通常,所有的包都应该局部安装。这确保你可以在电脑中拥有多个应用,并且它们运行在各自需要的不同版本包环境下。更新全局包会让你所有项目使用新的发行版,这会导致严重的维护性问题。所有项目都有它们自己的局部包的版本,即使看起来浪费了一些空间,但是比起使用全局包的副作用,是可以忽略的。当一个包提供了命令行工具(CLI),它可以全局安装,被所有项目重用。你也可以局部安装可执行的命令,通过npx来运行,但是一些包更适合全局安装,以下是一些例子

  • npm
  • create-react-app
  • vue-cli
  • grunt-cli
  • mocha
  • react-native-cli
  • gatsby-cli
  • forever
  • nodemon

可能有一些包已经全局安装在系统中,通过npm list -g --depth 0来查看

npm的依赖和开发依赖

当你使用npm install <package-name>安装了npm包时,它作为依赖安装。这个包会自动列入package.json文件,在dependencies属性中(npm5之前需要手动指定--save标识)。当你添加了-D --save-dev标识后,它作为开发依赖安装,并且在devDependencies属性被添加

开发依赖是仅在开发时用到的包,生产环境不需要,例如测试包、webpack或Babel。当你在生产环境中,如果输入npm install和包含package.json文件的目录,它们都会被安装,因为npm认为这是一个开发环境的部署。需要设置--production标识来避免安装这些依赖

npx -- Node.js Package Runner

npx是一个很有用的命令,在5.2版本(2017.7)以后的npm中可用.如果你不想安装npm,你可以单独安装npx。npx让你运行通过Node.js构建的代码并且发布到npm仓库

更容易运行局部命令。Node.js开发者曾经将很多可执行命令全局安装,使得它们可以在当前目录快速运行。当npx commandname,会在项目的node_modules目录自动寻找正确的命令引用。不需要直到确切路径,也不需要全局安装包

npx的另一个好用的特性是,不需要安装也可以运行命令。即你不需要安装任何东西。使用@version语法可以运行任何版本的命令

npx cowsay "hello"

可以在没有安装cowsay包的情况下工作。运行npx @vue/cli create my-vue-app可以创建一个vue的app,npx create-react-app my-react-app可以创建一个react的app

一旦下载完成,下载的代码会被清楚。使用不同的Node.js版本来运行代码。使用@来指定版本,结合node的npm包

npx node@10 -v #v10.18.1
npx node@12 -v #v12.14.1

这样可以避免使用nvm或其它的Node.js版本管理工具。它还可以直接运行URL上的代码片段(要小心运行了自己无法控制的代码)

Node.js的Event Loop

Event Loop是理解Node.js最重要的概念之一。它解释了Node.js是如何是异步的并且有非阻塞I/O。Node.js代码在单线程运行,这个限制其实是个优点,因为它不需要你考虑很多并发的问题。你只需要关注你的代码并且保证它们避免阻塞线程(同步网络调用或者无限循环)。通常,大多数浏览器中,每个tab都有一个event loop,让每一个进程(process,这里应该指的是每个页面的js代码)保持独立,避免存在无限循环的网页和处理任务非常长的程序将整个浏览器阻塞

环境管理多个并发的event loop,例如处理各种API调用。Web Worker也运行在它们各自的event loop中。你需要关注的点就是你的代码运行在一个单独的event loop中,并且要避免自己的代码阻塞它

任何花费很长时间来返回控制权给event loop的代码都会阻塞其它运行在此页面的JS代码。甚至阻塞UI线程,用户不能做点击、滑动网页等操作。在JS中,几乎所有的I/O基本都是非阻塞的(网络请求,文件操作等)。阻塞的是例外,这也是为什么JS如此依赖回调函数,现在是promiseasync/await

调用栈(LIFO队列)。event loop持续检查调用栈,检查是否有函数需要运行。在这样做的时候,它将调用的函数放入栈中并且按顺序执行它们。错误栈(error stack)就是浏览器在调用栈中查找函数名来通知你当前调用来源于哪个函数

举一个例子

const bar = () => console.log('bar')
const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  bar()
  baz()
}

foo()

当这个代码运行时,首先foo()被调用,它中我们先调用bar(),然后调用baz()。这里调用栈的变化是

  1. foo()入栈
  2. console.log('foo')入栈
  3. console.log('foo')出栈
  4. bar()入栈
  5. console.log('bar')入栈
  6. console.log('bar')出栈
  7. bar()出栈
  8. baz()入栈
  9. console.log('baz')入栈
  10. console.log('baz')出栈
  11. baz()出栈
  12. foo()出栈

上面的例子很普通(JS按照顺序执行代码)

下面是一个延迟函数运行(直到栈清空后)的例子

setTimeout(() => {}, 0)是调用了一个函数,但是只有执行了其它的代码之后才会运行

const bar = () => console.log('bar')
const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  baz()
}

foo()

// result:
// foo
// baz
// bar

当上述代码运行时,首先foo()调用,在foo()中我们先调用了setTimeout,将bar作为参数传入,然后我们让它尽可能快执行(第二个参数是0),然后调用baz()

此时的调用栈信息类似:

  1. foo()入栈
  2. console.log('foo')入栈
  3. console.log('foo')出栈
  4. setTimeout()入栈
  5. setTimeout()出栈
  6. baz()入栈
  7. console.log('baz')入栈
  8. console.log('baz')出栈
  9. baz()出栈
  10. foo()出栈
  11. bar()入栈
  12. console.log('bar')入栈
  13. console.log('bar')出栈
  14. bar()出栈

为什么是上述的执行方式?当setTimeout()被调用,浏览器或者Node.js开启了一个timer,一旦timer国旗,回调函数会放在Message Queue中。Message Queue中放的是用户触发的事件(click/keyboard events),或者在你的代码可以访问它们之前的拉去数据的过程。或者onLoad类似的DOM事件

loop给调用栈优先级,它先处理在调用栈中找到的所有东西,一旦没有找到(栈空),它会在message queue中找东西来运行。我们不需要等待setTimeout这种函数,或者拉取数据的函数等,因为它们是由浏览器提供的,它们在自己的线程中运行。即,如果你设置参数为2s,那么setTimeout等待的2s不在当前线程发生

ES2015介绍了Job Queue的概念,它通过Promises来应用。这是尽可能快的执行一个异步函数的方式,而不是将它们放到调用栈。在当前函数结束之前定义的Promises,会在当前函数结束后调用

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  new Promise((resolve, reject) =>
    resolve('should be right after baz, before bar')
  ).then(resolve => console.log(resolve))
  baz()
}

foo()

// result:
// foo
// baz
// should be right after baz, before bar
// bar

Promise(async/await)和老式的(setTimeout)异步方法的主要区别就在于它是在当前方法调用结束之后立刻调用,而后者则要在调用栈空后才有机会调用

理解 process.nextTick()

在理解event loop的过程中,一个重要部分就是process.nextTick()。每次event loop走过一个完整过程,我们称其为tick。当我们将一个函数传入process.nextTick,我们构建的引擎(engine)会在当前操作结尾调用这个函数,在下一个event loop tick开始之前

process.nextTick(() => {
  // do something
})

event loop在持续处理当前函数的代码。当这个操作结束后,JS引擎会运行所有在这个函数运行过程中加入到nextTick中的函数。这就是我们可以称JS引擎是异步处理函数(当前函数结束之后),并且尽可能不占用队列(调用栈)

调用setTimeout(() => {}, 0)会在下一个tick结束后执行,比nextTick()(优先调用并且在下一个tick开始之前执行)更晚。当你确定某个操作在下一个tick开始前需要完成,那么使用nextTick()

理解 setImmediate()

当你想异步执行一些代码,还可以使用setImmediate()函数(Node.js提供)

setImmediate(() => {
  // run something
})

任何传入setImmediate()的函数参数都是会在event loop的下一次迭代中执行的回调函数。那么setImmediate()setTimeout(() => {}, 0)process.nextTick()有什么区别?传入process.nextTick()会在eventloop的当前迭代中执行,只是在当前函数结束之后,这代表它总是在setTimeout setImmediate之前执行。setTimeout()伴有0s回调和setImmediate()比较相似。它们的执行顺序和很多因素有关,但是它们都会在下一个event loop迭代中执行

探索JS Timers

setTimeout()

在写代码时,你可能希望延时执行一个函数,这是setTimeout的工作,你指定一个之后执行的回调函数和一个你希望多久之后运行的时间值(ms)

// 这种语法定义了一个新函数,你可以将任何希望调用的函数放在这个位置
setTimeout(() => {
  // runs after 2 seconds
}, 2000)

setTimeout(() => {
  // runs after 50 milliseconds
}, 50)

// 或者可以将已有的函数名传入,还有一系列参数
const myFunction = (firstParam, secondParam) => {
  // do something
}

// runs after 2 seconds
setTimeout(myFunction, 2000, firstParam, secondParam)

setTimeout返回一个timer id,一般情况下不会使用,但是你可以存储这个id,如果想删除这个定时任务可以清除(clear)它

const id = setTimeout(() => {
  // should run after 2 seconds
}, 2000)

// I changed my mind
clearTimeout(id)

如果你指定延迟为0,那么回调函数会尽可能快地执行,但是要在当前函数执行结束之后

setTimeout(() => {
  console.log('after ')
}, 0)

console.log(' before ')

// result:
// before
// after

这是一个防止CPU阻塞在一个重任务上面,让其它的函数也可以执行,通过将函数放入调度器中。一些浏览器(IE和Edge)实现了setImmediate()方法,它做的事情和setTimeout是一样的,但是它不标准且在其它浏览器不可用,但是它在Node.js中是标准函数

setInterval()

setTimeout类似,区别是它会永远运行,以一个指定的时间间隔(ms)

// 除非你停止它(clearInterval),否则它会每隔2s调用一次
setInterval(() => {
  // runs every 2 seconds
}, 2000)

const id = setInterval(() => {
  // runs every 2 seconds
}, 2000)

clearInterval(id)

setInterval的回调函数中调用clearInterval是很普遍的,可以让它自动检测是继续运行还是停止

const interval = setInterval(() => {
  if (App.somethingIWait === 'arrived') {
    clearInterval(interval)
    return
  }
  // otherwise do things
}, 100)

递归的setTimeoutsetInterval每隔n毫秒会开始运行一个函数,不需要考虑什么时候会有一个函数来结束它

如果一个函数总是花费一样的时间,setInterval没有问题。可能一个函数由于其它原因(网络因素)运行时间会变化,setInterval的执行可能会有重叠的情况。为了避免这个问题,你可以通过递归setTimeout来实现,即当回调函数结束之后,继续调用这个setTimeout

const myFunction = () => {
  // do something

  setTimeout(myFunction, 1000)
}

setTimeout(myFunction, 1000)

setTimeout setInterval在Node.js中都可以用,通过Timers模块。Node.js也提供了setImmediate(),它和setTimeout(() => {}, 0)是等价的,大部分是和Node.js Event Loop一起使用

JS异步编程和回调

在设计上,计算机是异步的。异步代表事情发生可以独立于主要的程序流。在当前的消费者电脑中,每个程序运行一段时间,然后停止运行让其它程序继续运行。这件事情运行在一个很快的循环(cycle)之中,所以用户感知不到。我们认为我们的计算机同时运行了很多程序,但这只是一个假象(除非是多处理器)。程序内部使用中断(给处理器发出的信号,获取系统的注意)

通常编程语言是同步的,一些语言会通过语言特性或者库的方式来提供对异步程序的管理。C、Java、C#、PHP、Go、Ruby、Swift、Python默认都是同步运行,一些语言是使用线程、开启新进程来处理异步

JS默认就是异步的并且是单线程运行。这代表JS程序不能创建新线程并且并行运行。由于JS是在浏览器内部诞生的,它的任务最开始就是响应用户动作,例如onClick onMouseOver onChange onSubmit等,使用同步模型怎么实现这些功能呢?答案就在它的环境中,浏览器提供了一种方式,通过提供一系列API来处理这些事件

目前,Node.js提供了一种非阻塞I/O环境来扩展这个概念到文件访问、网络调用等

回调函数。你不知道什么时候用户会点击这个按钮,所以,你定义了一个处理点击事件的handler。这个handler接收一个函数,当事件触发时会被调用

document.getElementById('button').addEventListener('click', () => {
  //item clicked
})

这就是所谓的callback(回调函数)。一个回调函数是一个简单的函数,它作为值传入到另一个函数中,只有当事件被触发时才会发生。这样做的前提是JS有first-class的函数,即函数可以被赋值给变量并且传入到其它的函数中(higher-order functions)。通常你会将所有的客户端代码放在一个load事件的(window对象)监听器中。当页面加载完成,会调用回调函数

window.addEventListener('load', () => {
  // window loaded
  // do what you want
})

回调随处可见,除了在DOM事件中,一个常见的例子就是timer

setTimeout(() => {
  // run after 2 seconds
}, 2000)

XHR请求也会接收一个回调函数,通过将一个函数赋值给当一个特定事件发生(请求状态改变)会被调用的属性

const xhr = new XMLHttpRequest()
xhr.onreadystatechange = () => {
  if (xhr.readyState === 4) {
    xhr.status === 200 ? console.log(xhr.responseText) :
      console.error('error')
  }
}
xhr.open('GET', 'https://yoursite.com')
xhr.send()

在回调函数中处理错误。如何在回调函数中处理错误?一个Node.js适用的通用方法是,任何回调函数的第一个参数是错误对象(error-first callbacks)。如果没有错误,这个对象为null。如果有一个错误,它会包含错误信息和一些其它信息

fs.readFile('/file.json', (err, data) => {
  if (err !== null) {
    // handle error
    console.log(err)
    return
  }

  // no errors, process data
  console.log(data)
})

回调适用于简单的情况!每个回调都可以添加嵌套层次,当你有很多回调函数的时候,代码很快变得非常复杂

window.addEventListener('load', () => {
  document.getElementById('button').addEventListener('click', () => {
    setTimeout(() => {
      items.forEach(item => {
        //your code here
      })
    }, 2000)
  })
})

这只是一个简单的4层代码,但实际上有很多层次更多的代码。我们如何解决这个问题?适用回调函数的替代品,从ES6开始,JS介绍不包括回调函数的帮助实现异步代码的工具(Promise(ES6)和Async/Await(ES2017))

理解JS的 Promise

一个promise通常被定义为是一个最终会可用的值的代理。它是处理异步代码的一种方式,不需要陷入“回调地狱”。自从ES2015将其标准化和推出,promise已经成为(JS)语言的一部分,自从ES2017推出async/await之后,集成度更高了

async函数背后就是promise,所以要理解async的基础就是要理解promise。一旦一个promise被调用,它开始处于pending状态,这代表直到promise被处理(提供给调用函数需要的数据)之前,调用的函数一直在运行。创建好的promise最终会以resolved状态终止或者是rejected状态,调用其对应的回调函数(then或者catch

除了你自己的代码和库的代码,promise被应用在标准的现代Web API中,例如

  • Battery API
  • Fetch API
  • Service Workers

在现在的JS中不太可能发现不使用promise,以下介绍使用的详情

PromiseAPI暴露出了Promise构造器,可以通过new来创建

let done = true

const isItDoneYet = new Promise((resolve, reject) => {
  if (done) {
    const workDone = 'Here is the thing I built'
    resolve(done)
  } else {
    const why = 'Still working on something else'
    reject(thy)
  }
})

上例中,promise检查了done全局常量,如果它是true,promise会进入resolve状态(resolve的回调会执行),否则使promise进入rejected状态(reject回调会执行),如果哪个函数都没有在执行路径中被调用,那么这个promise仍然处于pending状态

使用resolvereject,我们可以和调用者交互(返回的数据或出现异常)。上例我们已经创建了promise,所以它已经开始执行。一个更常见的例子是叫做Promisifying的技术。这个技术是可以使用经典JS函数(function)来接收回调,并且让它返回一个Promise的方式

const fs = require('fs')

const getFile = (filename) => {
  return new Promise((resolve, reject)) => {
    fs.readFile(filename, (err, data) => {
      if (err) {
        reject(err) // calling reject 会使得promise失败(无论是否传入err参数)
        return
      }
      resolve(data)
    })
  }
}

getFile('/etc/passwd')
  .then(data => console.log(data))
  .catch(err => console.error(err))

最近版本的Node.js,你不需要手写这么多的代码。有一个promisifying的函数在util模块中可用,只要保证你promisifying的函数有正确的签名即可

一个promise是如何被创建的?首先,让我们看看promise是如何被消费(consume)或使用的

const isItDoneYet = new Promise(/* as above */)

const checkIfItsDone = () => {
  isItDoneYet
    .then(ok => {
      console.log(ok)
    })
    .catch(err => {
      console.error(err)
    })
}

运行checkIfItsDone(),当上述的promiseresolve或者reject,这个函数指定的回调函数会执行(thencatch

一个promise可以被另一个promise返回,创建一个promise链。Fetch API是一个promise链的非常好的例子,通过它我们可以用来获取资源并且当资源获取到时继续执行链中的下一部分

Fetch API是基于promise的机制,调用fetch()和使用new Promise()是等价的

const status = response => {
  if (response.status >= 200 && response.statusText < 300) {
    return Promise.resolve(response)
  }
  return Promise.reject(new Error(response.statusText))
}

const json = response => response.json()

fetch('/todos.json')
  .then(status) // status 函数是在这里调用
  .then(json) // json 函数来返回一个promise,处理response中的数据
  .then(data => {
    console.log('Request succeeded with JSON response', data)
  })
  .catch(error => {
    console.log('Request failed', error)
  })

本例中,fetch()todos.json文件获取一系列的TODO事项,然后创建了promise链。运行fetch()返回一个response,它有很多属性,从这里面我们引用了status,一个数值(如果请求成功返回‘OK'--类似HTTP状态码),然后通过json()方法,返回一个处理数据体的数据(转换为JSON)的promise

第一个promise是我们定义的函数,叫做status(),它会检查返回的状态码,看它是否成功(200~299),如果失败则reject这个promise。这个操作会导致promise链跳过所有剩下的promise而直接到catch(),记录请求失败的日志

如果成功,它会调用json()函数,因为之前的promise如果成功会返回一个response,所以我们把它作为下一个方法接收的输入。本例中我们返回的是处理的JSON,所以第三个promise直接接收到了JSON

当promise链中的任意地方失败并且抛出了error或者reject了promise,那么代码控制权会到最近的catch()语句

new Promise((resolve, reject) => {
  throw new Error('Error')
}).catch(err => {
  console.error(err)
})

new Promise((resolve, reject) => {
  reject('error')
}).catch(err => {
  console.error(err)
})

级联(cascading)的error

如果在catch()中,你继续抛出错误,你可以继续在后面追加catch()

new Promise((resolve, reject) => {
  throw new Error('Error')
})
  .catch(err => {
    throw new Error('Error')
  })
  .catch(err => {
    console.error(err)
  })

如果你需要同步不同的promise,Promise.all()可以帮助你定义一组promise,当它们全部resolve之后,会执行一些代码

const f1 = fetch('/something.json')
const f2 = fetch('/something2.json')

Promise.all([f1, f2])
  .then(res => {
    console.log('Array of results', res)
  })
  .catch(err => {
    console.error(err)
  })

ES2015破坏性赋值(destructing assignment)语法可以这么写

Promise.all([f1, f2]).then(([res1, res2]) => {
  console.log('Results', res1, res2)
})

Promise.race()当你传入的第一个promise是resolve的时候运行代码,并且它只会运行之后的代码一次,使用第一个resolve的promise的结果

const first = new Promise((resolve, reject) => {
  setTimeout(resolve, 500, 'first')
})

const second = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'second')
})

Promise.race([first, second]).then(result => {
  console.log(result)
})

现代异步JS,通过Async/Await

JS从回调到promise的进化时间并不长,从ES2017开始,异步的JS通过async/await语法变得更简单了。async函数是promise和generator的组合,它们是promise之上的更高层级的抽象。这里要重复“async/await是基于promise上的”

为什么要推出async/await?它们减少了promise的大量模板代码,并且不打破promise链的限制。当promise在ES2015被推出,它们被认为解决了异步代码的问题,但是在ES2017和ES2015之间,人们发现它并不是最终方案。promise自身有其复杂性,并且语法也很复杂。而async函数的出现,看起来像是同步代码,但是背后依旧是异步和非阻塞的

一个async函数会返回一个promise,例如

const doSomethingAsync = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve('I did something'), 3000)
  })
}

// 当你想调用这个函数,你已经准备好等待,然后调用代码当promise被resolve或reject时停止运行。警告:客户端函数必须被定义为async
const doSomething = async () => {
  console.log(await doSomethingAsync())
}

async关键字放在任何函数之前都意味着这个函数返回一个promise。即使这个函数没有这样做,内部也会让它返回一个promise

const aFunction = async () => {
  return 'test'
}

// alert test
aFuntion().then(alert)

// 上面的代码等同于
cosnt aFunction = () => {
  return Promise.resolve('test')
}

aFunction().then(alert)

这种写法比使用普通的promise和链、回调函数组合的方式更易读,当代码更加复杂的时候,差别会更大

const getFirstUserData = () => {
  return fetch('/users.json')
    .then(response => response.json())
    .then(users => users[0])
    .then(user => fetch(`/users/${user.name}`))
    .then(userResponse => userResponse.json())
}

getFirstUserData()

// 如果使用async/await方式来写
const getFirstUserData = async () => {
  const response = await fetch('/users.json')
  const users = await response.json()
  const user = await users[0]
  const userResponse = await fetch(`/users/${user.name}`)
  const userData = await userResponse.json()
  return userData
}

getFirstUserData()

async函数可以很容易被链接,语法比promise更易读

const promiseToDoSomething = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve('I did something'), 10000)
  })
}

const watchOverSomeoneDoingSomething = async () => {
  const something = await promiseToDoSomething()
  return something + '\nand I watched'
}

const watchOverSomeoneWatchingSomeoneDoingSomething = async () => {
  const something = await watchOverSomeoneDoingSomething()
  return something + '\nand I watched as well'
}

watchOverSomeoneWatchingSomeoneDoingSomething().then(res => {
  console.log(res)
})

promise上debug很困难,因为debugger不会进入异步代码,然而对于async/await却很简单,因为它看起来像是同步代码

Node.js的Event emitter

在浏览器的JS中,存在非常多的和用户交互的事件处理。在后端,Node.js给提供了events模块来构建一个类似的系统。这个模块提供了EventEmitter类,我们可以通过它来处理事件

const EventEmitter = require('events')
const eventEmitter = new EventEmitter()

这个对象暴露出来很多方法,包括on emit

emit用来触发一个事件,on用来添加一个事件被触发之后运行的回调函数。例如,创建一个start事件,交互事件中打印一个字符串

eventEmitter.on('start', () => {
  console.log('started')
})

当我们运行eventEmitter.emit('start'),事件的handler函数被调用,我们得到了控制台的输出,你可以作为emit()的额外参数来给handler方法传递参数,例如

eventEmitter.on('start', number => {
  console.log(`started ${number}`)
})

eventEmitter.emit('start', 23)

// 多个参数
eventEmitter.on('start', (start, end) => {
  console.log(`started from ${start} to ${end}`)
})

eventEmitter.emit('start', 1, 100)

EventEmitter对象还暴露了其它一些方法

  • once() -- 添加一个一次性的监听器
  • removeListener()/off() -- 从一个事件中移除一个监听器
  • removeAllListeners() -- 移除一个事件中所有的监听器

EventEmitter文档

创建一个HTTP Server

const http = require('http')

const port = process.env.PORT

const server = http.createServer((req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/html')
  res.end('<h1>Hello world</h1>)
})

server.listen(port, () => {
  console.log(`Server run at port ${port}`)
})

分析一下上面这个代码结构

  1. 包含进http模块
  2. 使用这个模块来创建HTTP Server
  3. server被设置为监听某个指定端口号(3000),当server准备好后,listen的回调函数会被调用
  4. 无论何时请求到达,请求事件会被调用,提供了两个对象:request(http.IncomingMessage)和response(http.serverResponse)。request提供了请求的详细信息,通过它我们可以访问请求头和请求数据。response用来制造我们将要返回给客户端的数据

res.statusCode = 200,设置了statusCode属性为200,表示这是一个成功的响应。res.setHeader('Content-Type', 'text/plain')设置了一个头,然后将一些内容作为参数传入end()结束了响应过程

通过Node.js制作一个HTTP请求

GET请求示例:

const https = require('https')
const options = {
  hostname: 'whatever.com',
  port: 443,
  path: '/todos',
  method: 'GET'
}

const req = https.request(options, res => {
  console.log(`statusCode: ${res.statusCode}`)

  res.on('data', d => {
    process.stdout.write(d)
  })
})

req.on('error', error => {
  console.error(error)
})

req.end()

POST请求示例:

const https = require('https')

const data = JSON.stringify({
  todo: 'Buy the milk'
})

const options = {
  hostname: 'whatever.com',
  port: 443,
  path: '/todos',
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Content-Length': data.length
  }
}

const req = https.request(options, res => {
  console.log(`statusCode: ${res.statusCode}`)

  res.on('data', d => {
    process.stdout.write(d)
  })
})

req.on('error', error => {
  console.error(error)
})

req.write(data)
req.end()

PUT和DELETE请求和POST请求的格式相同,只是改变了options.method的值

使用Node.js来制作HTTP POST请求

在Node.js中有很多种发起POST请求的方式,取决你想使用的抽象级别。在Node.js中最简单的方式使用Axios

const axios = require('axios')

axios
  .post('https://whatever.com/todos', {
    todo: 'Buy the milk'
  })
  .then(res => {
    console.log(`statusCode: ${res.statusCode}`)
    console.log(res)
  })
  .catch(error => {
    console.error(error)
  })

axios需要使用第三方库。一个POST请求可以使用Node.js的标准模块,就是比较累赘(上一章中的例子)

使用Node.js获取HTTP请求体的数据

这一章主要说的是如何将请求体中作为JSON发送的数据取出来

如果你正在使用Express,那么非常简单,使用body-parser模块

// Client
const axios = require('axios')

axios.post('https://whatever.com/todos', {
  todo: 'Buy the milk'
})

// Server
const express = require('express')
const app = express()

app.use(
  express.urlencoded({
    extended: true
  })
)

app.use(express.json())

app.post('/todos', (req, res) => {
  console.log(req.body.todo)
})

如果你没有使用Express,并且想使用普通的Node.js方式,那么需要多做一些事情。要理解的关键点是,当你使用http.createServer()来初始化HTTP服务器时,当服务器获得所有的HTTP头,你传入的回调函数就会被调用,而不是接受完请求体

传入连接回调函数的请求对象是一个流。所以,我们必须要监听将会被处理的body内容,并且它使用chunk的方式被处理。我们首先要监听流数据事件,当数据结束(end)时,流的end事件被调用

const server = http.createServer((req, res) => {
  // access HTTP header
  req.on('data', chunk => {
    console.log(`Data chunk available: ${chunk}`)
  })

  req.on('end', () => {
    // end of data
  })
})

所以如果要访问数据,假设是一个字符串,我们必须将其放入一个数组

const server = http.createServer((req, res) => {
  let data = ''
  req.on('data', chunk => {
    data += chunk
  })
  req.on('end', () => {
    JSON.parse(data).todo // 'Buy the milk'
  })
})

处理Node.js中的file descriptor

在你可以和你的文件系统中的文件交互之前,你必须得到一个文件描述符(file descriptor)。一个文件描述符是使用fs模块提供的open()方法打开文件后返回的内容

const fs = require('fs')

fs.open('/Users/joe/test.txt', 'r', (err, fd) => {
  // fd就是文件操作符
})

上例中的r,是一个标识,含义是以读的方式来打开文件。还有一些其它的方式

  • r+ -- 读写打开文件
  • w+ -- 读写打开文件,将流放在文件的开头。如果此文件不存在,那么会被创建
  • a -- 写打开文件,将流放在文件的末尾,如果文件不存在,那么会被创建
  • a+ -- 读写打开文件,将流放在文件的末尾,如果文件不存在,那么会被创建

也可以通过fs.openSync方法来打开一个文件,它会返回一个文件描述符,不需要提供一个回调函数

const fs = require('fs')

try {
  const fd = fs.openSync('/Users/joe/test.txt', 'r')
} catch (err) {
  console.error(err)
}

当你获得文件描述符之后,无论你选择哪种方式,你可以做所有需要它的操作,例如调用fs.open()和其它与文件系统交互的操作

Node.js file stats

我们可以使用Node.js来查看每个文件的一系列详情。特别的,使用fs模块提供的stat()方法。通过传入一个文件路径来调用它,一旦Node.js获取到了文件详情,它会调用你传入的回调函数,有两个参数(一个错误信息,一个文件状态)

const fs = require('fs')
fs.stat('/Users/joe/test.txt', (err, stats) => {
  if (err) {
    console.error(err)
    return
  }
  // 通过stats来访问文件的stats
})

Node.js也提供了同步的方法,直到文件状态准备好前都会阻塞线程

const fs = require('fs')
try {
  const stats = fs.statSync('/Users/joe/test.txt')
} catch(err) {
  console.error(err)
}

文件的信息包含在stats变量中,那么从中我们可以获取哪些信息?包括,这个文件是目录还是文件,使用stats.isFile()stats.isDirectory(),一个文件是否是链接,使用stats.isSymbolicLink(),文件的字节数,使用stats.size(属性而非方法)

还有很多的高级方法,但是这些是你在日常编程中经常会用到的

const fs = require('fs')
fs.stat('/Users/joe/test.txt', (err, stats) => {
  if (err) {
    console.error(err)
    return
  }

  stats.isFile()
  stats.isDirectory()
  stats.isSymbolicLink()
  stats.size
})

Node.js的文件路径

在系统中的每个文件都有其路径,在Linux和macOS中,例如/users/joe/file.txt,Windows稍有不同,例如C:\users\joe\file.txt。在自己的应用中需要关注这一点不同。使用const path = require('path')来包含这个模块,然后就可以使用它里面的方法

通过一个路径,你可以使用以下这些方法来获取信息

  • dirname -- 获得这个文件的父目录
  • basename -- 获取文件名的部分
  • extname -- 获取文件的扩展名(类型)
const notes = '/users/joe/notes.txt'

path.dirname(notes) // /users/joe
path.basename(notes) // notes.txt
path.extname(notes) // .txt
// 只获取到文件名字的部分,而不带扩展名
path.basename(notes, path.extname(notes)) // notes

通过path.join()可以合并两个甚至多个路径的部分

const name = 'joe'
path.join('/', 'users', name, 'notes.txt') // /users/joe/notes.txt

使用path.resolve()方法可以获得一个相对路径计算出来的绝对路径

path.resolve('joe.txt') // 如果从home目录运行这个程序,那么它返回 /users/joe/joe.txt
path.resolve('tmp', 'joe.txt') // /users/joe/tmp/joe.txt

如果第一个参数是斜杠开始(Linux/macOS),那么代表这是一个绝对路径

path.resolve('/etc', 'joe.txt') // /etc/joe.txt
// normalize函数会尝试计算真实路径,当路径中包含.. .或者//的时候
path.normalize('/users/joe/..//text.txt') // /users/text.txt

resolvenormalize不会检查这个路径是否存在,它们只是计算基于得到的信息的结果路径

使用Node.js来读取文件

在Node.js中最简单的读取文件的方式是fs.readFile(),参数分别是文件路径,编码方式和一个回调函数(处理读取的数据或者错误)

const fs = requrie('fs')

fs.readFile('/users/joe/test.txt', 'utf8', (err, data) => {
  if (err) {
    console.error(err)
    return
  }
  console.log(data)
})

同样的,可以使用同步版本的fs.readFileSync()

const fs = require('fs')

try {
  const data = fs.readFileSync('/users/joe/test.txt', 'utf8')
  console.log(data)
} catch(err) {
  console.error(err)
}

fs.readFile()fs.readFileSync()在返回数据之前都会将文件的全部内容读取到内存。这代表,大文件可能对内存消耗有很大的影响,并且影响程序的执行速度。这种情况下,更好的读取数据的方式是使用stream

通过Node.js来写文件

使用Node.js写文件最简单的方式是fs.writeFile()API

const fs = require('fs')

const content = 'Some content'

fs.writeFile('/users/joe/test.txt', content, err => {
  if (err) {
    console.error(err)
    return
  }
  // fill written successfully
})

此外还可以使用同步写入的版本

const fs = require('fs')

const content = 'Some content'

try {
  const data = fs.writeFileSync('/users/joe/test.txt', content)
} catch(err) {
  console.error(err)
}

默认,如果文件已经存在,那么API会用指定内容替代已经存在的内容。可以通过指定标识来修改这个设置。fs.writeFile('/users/joe/test.txt', content, { flag: 'a+' }, err => {}),这里的flag就是之前记录过的一些打开文件的方式。更多的flag详情

将内容追加到文件末尾的方便办法是fs.appendFile()(对应的有fs.appendFileSync()

const content = 'Some content'

fs.appendFile('file.log', content, err => {
  if (err) {
    console.error(err)
    return
  }
  // done
})

在将控制权返回到程序之前,这些方法会将所有的内容都写入文件。在这种情况下,大文件写入应该使用stream方式

在Node.js中处理文件夹

Node.js的fs模块提供了很多处理文件夹相关的API

fs.access()检查某个文件夹是否存在和Node.js是否有访问它的权限

fs.mkdir() fs.mkdirSync()来创建一个新的文件夹

const fs = require('fs')

const folderName = '/Users/joe/test'

try {
  if (!fs.existsSync(folderName)) {
    fs.mkdirSync(folderName)
  }
} catch (err) {
  console.error(err)
}

读取目录中的内容

使用fs.readdir() fs.readdirSync()来读取目录中的内容。以下代码读取文件夹得内容,包括文件夹和子文件夹,返回它们的相对路径

const fs = require('fs')
const path = require('path')

const folderPath = '/Users/joe'

fs.readdirSync(folderPath)
// 可以获取它们的全路径
fs.readdirSync(folderPath).map(fileName => {
  return path.join(folderPath, fileName)
})
// 只返回文件,而不返回文件夹
const isFile = fileName => {
  return fs.lstatSync(fileName).isFile()
}

fs.readdirSync(folderPath).map(fileName => {
  return path.join(folderPath, fileName)
})
.filter(isFile)

使用fs.rename() fs.renameSync()来重命名文件夹。第一个参数是当前路径(文件名),第二个参数是新的路径(新的文件名)

const fs = require('fs')

fs.rename('/Users/joe', '/Users/roger', err => {
  if (err) {
    console.error(err)
    return
  }
  // done
})

// 同步版本
const fs = require('fs')

try {
  fs.renameSync('/Users/joe', '/Users/roger')
} catch (err) {
  console.error(err)
}

使用fs.rmdirfs.rmdirSync()来移除一个文件夹。移除一个有内容的文件夹会比较复杂。这种情况下,最好是安装fs-extra模块,它很受欢迎且维护的很好。它是一个drop-in(插入式、适配)的fs模块的替代品,此时remove()方法是你需要的

通过npm install fs-extra安装,然后使用

const fs = require('fs-extra')

const folder = '/Users/joe'

fs.remove(folder, err => {
  console.error(err)
})

// 使用Promise的方式来使用
fs.remove(folder)
  .then(() => {
    // done
  })
  .catch(err => {
    console.error(err)
  })
// 使用async/await
async function removeFolder(folder) {
  try {
    await fs.remove(folder)
    // done
  } catch (err) {
    console.error(err)
  }
}

const folder = '/Users/joe'
removeFolder(folder)

Node.js fs 模块

fs模块提供了很多访问和与文件系统交互的功能。这个模块不需要安装,是Node.js的核心部分,通过const fs = require('fs')使用

  • fs.access() -- 检查文件是否存在,并且Node.js是否有权限访问它
  • fs.appendFile() -- 将数据追加在文件中,如果文件不存在,会被创建
  • fs.chmod() -- 改变传入的文件名的文件的权限(相关:fs.lchmod() fs.fchmod()
  • fs.chown() -- 改变传入的文件名的文件的持有者(owner)和组(group)(相关:fs.fchown() fs.lchown()
  • fs.close() -- 关闭一个文件描述符(file descriptor)
  • fs.copyFile() -- 复制一个文件
  • fs.createReadStream() -- 创建一个可读文件流
  • fs.createWriteStream() -- 创建一个可写文件流
  • fs.link() -- 对某个文件创建一个硬链接(hard link)
  • fs.mkdir() -- 创建一个新文件夹
  • fs.mkdtemp() -- 创建一个临时文件夹
  • fs.open() -- 设置文件模式(file mode)
  • fs.readdir() -- 读取一个目录的内容
  • fs.readFile() -- 读取一个文件的内容(相关:fs.read()
  • fs.readlink() -- 读取一个链接(symbolic link)的值
  • fs.realpath() -- 将带有相对路径(., ..)的文件路径转为全路径
  • fs.rename() -- 重命名文件或文件夹
  • fs.rmdir() -- 移除一个文件夹
  • fs.stat() -- 返回传入的文件名的文件的状态(相关:fs.fstat() fs.lstat()
  • fs.symlink() -- 创建一个文件的新链接(symbolic link)
  • fs.truncate() -- truncate掉传入文件名的文件的指定长度(相关:fs.ftruncate()
  • fs.unlink() -- 移除一个文件或一个symbolic link
  • fs.unwatchFile() -- 停止查看一个文件的变化
  • fs.utimes() -- 改变传入文件名的文件的时间戳(相关:fs.futimes
  • fs.watchFile() -- 开始查看一个文件的变化(相关:fs.watch()
  • fs.writeFile() -- 将数据写入文件(相关:fs.write()

fs模块中的所有方法默认都是异步的,但是通过追加Sync后缀可以以同步方式来完成同样的工作。Node.js10包括了基于promise的API的实验性支持

// callback
const fs = require('fs')

fs.rename('before.json', 'after.json', err => {
  if (err) {
    return console.error(err)
  }
  //done
})

// async
const fs = require('fs')

try {
  fs.renameSync('before', 'after.json')
  // done
} catch(err) {
  console.error(err)
}

两者的区别是你的脚本文件会阻塞在第二种形式,直到文件操作成功(或失败)

Node.js path 模块

path模块(也)提供了很多访问和与文件系统交互的功能。(也)不需要安装,(也)是Node.js的核心部分,(也)是通过const path = require('path')来使用。这个模块提供了path.sep来对不同的平台映射路径分隔符,path.delimiter提供了全局变量path的分隔符(Windows为;,Linux、macOS为:

path.basename() -- 返回一个路径的最后一部分,第二个参数可以过滤掉扩展名

require('path').basename('/test/something') // something
require('path').basename('/test/something.txt') // something.txt
require('path').basename('/test/something.txt', 'txt') // something

path.dirname() -- 返回路径的目录部分

require('path').dirname('/test/something') // /test
require('path').dirname('/test/something/file.txt') // /test/something

path.extname() -- 返回一个路径的扩展名

require('path').extname('/test/something') // ''
require('path').extname('/test/something/file.txt') // '.txt'

path.isAbsolute() -- 如果是绝对路径返回true

require('path').isAbsolute('/test/something') // true
require('path').isAbsolute('./test/something') // false

path.join() -- 讲一个路径的两个或多个部分连起来

const name = 'joe'
require('path').join('/', 'users', name, 'notes.txt') // '/users/joe/notes.txt'

path.normalize() -- 当一个路径包含. .. //时,尝试计算它的真实路径

require('path').normalize('/users/joe/..//test.txt') // '/users/test.txt'

path.parse() -- 将路径转为一个对象,包含以下内容

  • root:根路径
  • dir:从根路径计算出来的文件夹路径
  • base:文件名+扩展名
  • name:文件名
  • ext:扩展名
require('path').parse('/users/test.txt')

// result
{
  root: '/',
  dir: '/users',
  base: 'test.txt',
  ext: '.txt',
  name: 'test'
}

path.relative() -- 接收两个路径作为参数,返回第二个参数的路径相对于第一个参数的路径的相对路径,基于当前的工作目录

require('path').relative('/users/joe', '/users/joe/test.txt') // 'test/txt'
require('path').relative('/users/joe', '/users/joe/something/test.txt') // 'somethign/test.txt'

path.resolve() -- 通过一个相对路径来得到绝对路径

path.resolve('joe.txt') // '/users/joe/joe.txt' 如果这个程序从home目录运行会得到这个路径,通过指定为第二个参数,那么会以第一个参数为基准目录
path.resolve('tmp', 'joe.txt') // 'users/joe/tmp/joe.txt'
// 如果第一个参数以 / 开始,那么返回路径是一个绝对路径
path.resolve('/etc', 'joe.txt') // '/etc/joe.txt'

Node.js os 模块

这个模块提供的很多函数,你能用来获取操作系统和运行此程序的计算机的信息,并且和这些信息做交互

const os = require('os')

os.EOL // 获取行分隔符,Linux、maxOS为 \n Windows为 \r\n
os.constants.signals // 和处理进程信号的所有相关常量,例如SIGHUP SIGKILL等
os.constants.errno // 设置错误报告的常量,例如 EADDRINUSE,EOVERFLOW等,在https://nodejs.org/api/os.html#os_signal_constants获取更多内容
  • os.arch() -- 返回底层架构的标识,例如 arm、x64、arm64
  • os.cpus() -- 返回你系统可用的CPU信息,例如
[
  {
    model: 'Intel(R) Core(TM)2 Duo CPU     P8600  @ 2.40GHz',
    speed: 2400,
    times: {
      user: 281685380,
      nice: 0,
      sys: 187986530,
      idle: 685833750,
      irq: 0
    }
  },
  {
    model: 'Intel(R) Core(TM)2 Duo CPU     P8600  @ 2.40GHz',
    speed: 2400,
    times: {
      user: 282348700,
      nice: 0,
      sys: 161800480,
      idle: 703509470,
      irq: 0
    }
  }
]
  • os.endianness() -- 返回BE或LE,依赖于Node.js是以Big Endian还是Little Endian编译的
  • os.freemem() -- 返回系统中自由内存的字节数
  • os.homedir() -- 返回当前用户的home目录的路径
  • os.hostname() -- 返回主机名
  • os.loadavg() -- 返回操作系统在load average的计算(Load Average 就是一段时间 (1 分钟、5分钟、15分钟) 内平均 Load )。它只会在Linux和macOS返回有意义的值
  • os.networkInterfaces() -- 返回你的系统可用的网络接口的详情
{ lo0:
   [ { address: '127.0.0.1',
       netmask: '255.0.0.0',
       family: 'IPv4',
       mac: 'fe:82:00:00:00:00',
       internal: true },
     { address: '::1',
       netmask: 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff',
       family: 'IPv6',
       mac: 'fe:82:00:00:00:00',
       scopeid: 0,
       internal: true },
     { address: 'fe80::1',
       netmask: 'ffff:ffff:ffff:ffff::',
       family: 'IPv6',
       mac: 'fe:82:00:00:00:00',
       scopeid: 1,
       internal: true } ],
  en1:
   [ { address: 'fe82::9b:8282:d7e6:496e',
       netmask: 'ffff:ffff:ffff:ffff::',
       family: 'IPv6',
       mac: '06:00:00:02:0e:00',
       scopeid: 5,
       internal: false },
     { address: '192.168.1.38',
       netmask: '255.255.255.0',
       family: 'IPv4',
       mac: '06:00:00:02:0e:00',
       internal: false } ],
  utun0:
   [ { address: 'fe80::2513:72bc:f405:61d0',
       netmask: 'ffff:ffff:ffff:ffff::',
       family: 'IPv6',
       mac: 'fe:80:00:20:00:00',
       scopeid: 8,
       internal: false } ] }
  • os.platform() -- 返回Node.js被编译的平台(darwin、freebsd、linux、openbsd、win32等)
  • os.release() -- 返回标识操作系统发行版本号的字符串
  • os.tmpdir() -- 返回被赋值为temp的文件夹路径
  • os.totalmem() -- 返回系统全部可用内存的字节数
  • os.type() -- 标识操作系统(Linux、Darwin on macOS、Windows_NT on Windows)
  • os.uptime() -- 返回计算机从上次重启到现在已经运行了多少s
  • os.userInfo() -- 返回一个对象,包含当前的用户名,uid,gid,shell,还有homedir

Node.js events 模块

event模块提供了EventEmitter类,它是在Node.js中处理事件的核心要素

const EventEmitter = require('events')
const door = new EventEmitter()

要增加一个listener使用newListener,要移除一个listener使用removeListener

  • emitter.addListener() -- 是emitter.on()的别名
  • emitter.emit() -- 发出(emit)事件,它会按照注册顺序异步调用每一个event listener
door.emit('slam') // emit slam事件
  • emitter.eventNames() -- 返回一个字符串数组,表示在当前EventEmitter对象上注册的事件
door.eventNames()
  • emitter.getMaxListeners() -- 获取可用添加到EventEmitter对象的最大数量的listener,默认是10,但是可以使用setMaxListeners()来增加和减少
door.getMaxListeners()
  • emitter.listenerCount() -- 获取作为参数传入的事件的数量
door.listenerCount('open')
  • emitter.listners() -- 获取作为参数传入的事件的listener数组
door.listeners('open')
  • emitter.off() -- emitter.removeListener()的别名,Node.js10加入
  • emitter.on() -- 添加一个当一个事件被发出(emit)时,调用的回调函数
door.on('open', () => {
  console.log('Door was opened')
})
  • emitter.once() -- 添加一个当事件在注册之后第一次发出会调用的回调函数。这个回调函数只会被调用一次
const EventEmitter = require('events')
const ee = new EventEmitter()

ee.once('my-event', () => {
  // call callback function once
})
  • emitter.prependListener() -- 当你使用on addListener时,它会插入到listener队列的尾部,然后最后调用,使用prependListener它会插入到其它的listener之前
  • emitter.prependOnceListener() -- 当你使用once来添加listener时,它会插入到listener队列的尾部,然后最后调用,使用prependOnceListener它会插入到其它的listener之前
  • emitter.removeAllListener() -- 移除一个EventEmitter对象监听某事件的所有listener
door.removeAllListeners('open')
  • emitter.removeListener() -- 移除一个指定的listener,你可以在添加listener时通过将回调函数保存到一个变量中来实现,这样之后可以引用这个listener
const doSomething = () => {}
door.on('open', doSomething)
door.removeListener('open', doSomething)
  • emitter.setMaxListeners() -- 设置可以被添加进EventEmitter对象的listener的最大数量,默认是10,可以增加或减少
door.setMaxListeners(50)

Node.js的 http 模块

// include
const http = require('http')

这个模块提供了一些属性和方法,还有一些类

http.METHODS属性列出了所有HTTP支持的方法:

> require('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',
  'SUBSCRIBE',
  'TRACE',
  'UNBIND',
  'UNLINK',
  'UNLOCK',
  'UNSUBSCRIBE' ]

http.STATUS_CODES列出了所有的HTTP状态码和它们的描述

> require('http').STATUS_CODES
{ '100': 'Continue',
  '101': 'Switching Protocols',
  '102': 'Processing',
  '200': 'OK',
  '201': 'Created',
  '202': 'Accepted',
  '203': 'Non-Authoritative Information',
  '204': 'No Content',
  '205': 'Reset Content',
  '206': 'Partial Content',
  '207': 'Multi-Status',
  '208': 'Already Reported',
  '226': 'IM Used',
  '300': 'Multiple Choices',
  '301': 'Moved Permanently',
  '302': 'Found',
  '303': 'See Other',
  '304': 'Not Modified',
  '305': 'Use Proxy',
  '307': 'Temporary Redirect',
  '308': 'Permanent Redirect',
  '400': 'Bad Request',
  '401': 'Unauthorized',
  '402': 'Payment Required',
  '403': 'Forbidden',
  '404': 'Not Found',
  '405': 'Method Not Allowed',
  '406': 'Not Acceptable',
  '407': 'Proxy Authentication Required',
  '408': 'Request Timeout',
  '409': 'Conflict',
  '410': 'Gone',
  '411': 'Length Required',
  '412': 'Precondition Failed',
  '413': 'Payload Too Large',
  '414': 'URI Too Long',
  '415': 'Unsupported Media Type',
  '416': 'Range Not Satisfiable',
  '417': 'Expectation Failed',
  '418': 'I\'m a teapot',
  '421': 'Misdirected Request',
  '422': 'Unprocessable Entity',
  '423': 'Locked',
  '424': 'Failed Dependency',
  '425': 'Unordered Collection',
  '426': 'Upgrade Required',
  '428': 'Precondition Required',
  '429': 'Too Many Requests',
  '431': 'Request Header Fields Too Large',
  '451': 'Unavailable For Legal Reasons',
  '500': 'Internal Server Error',
  '501': 'Not Implemented',
  '502': 'Bad Gateway',
  '503': 'Service Unavailable',
  '504': 'Gateway Timeout',
  '505': 'HTTP Version Not Supported',
  '506': 'Variant Also Negotiates',
  '507': 'Insufficient Storage',
  '508': 'Loop Detected',
  '509': 'Bandwidth Limit Exceeded',
  '510': 'Not Extended',
  '511': 'Network Authentication Required' }

http.globalAgent指向Agent对象的全局(global)实例,它是http.Agent类的一个实例。是用来管理对HTTP客户端的连接的维持和重用,它是Node.js的HTTP网络的组件

http.createServer()返回一个http.Server类的实例。应用:

const server = http.createServer((req, res) => {
  // handler every single request with this callback
})

http.request()制作一个对服务器的HTTP请求,创建一个http.clientRequest类的实例

http.get()http.request()类似,但是它自动将HTTP的method设置为GET,然后自动调用req.end()

HTTP模块提供了5个类

  • http.Agent
  • http.ClientRequest
  • http.Server
  • http.ServerResponse
  • http.IncomingMessage
  • http.Agent

Node.js创建的全局的http.Agent的实例对象,保证了对服务器的每个请求被排队,然后一个单一的socket被重用。它也维护了一个socket池,这是改善性能的核心

一个http.ClientRequest对象在http.request()http.get()调用的时候创建

当接收到返回之后,response事件通过response来调用,其中会以http.IncomingMessage实例作为参数

一个response返回的数据可以以两种方式读取

  1. response.read(),在response的事件handler
  2. 你可以对data event设置一个event listener,这样可以监听数据流的接入

http.Server对象通常在使用http.createServer()创建一个新服务器的时候实例化和返回。一旦你有了server对象,你可以访问它的方法

  • close() -- server不再接收新连接
  • listen() -- 开启HTTP服务器,监听连接

http.ServerResponsehttp.Server创建的,作为第二个参数传入它服务的request事件,一般应用:

const server = http.createServer((req, res) => {
  // res就是http.ServerResponse对象
})

在handler中你总会调用的方法是end(),它关闭了response,组成信息完成并且服务器会将信息发送给客户端。它必须在每个response上调用

以下这些方法是和HTTP头做交互的

  • getHeaderNames() -- 获取已经设置好的HTTP头的所有名字
  • getHeaders() -- 获取已经设置好的HTTP头的复制
  • getHeader('headername', value) -- 设置HTTP头的值
  • getHeader('headername') -- 获取已经设置好的HTTP头
  • removeHeader('headername') -- 移除已经设置好的HTTP头
  • hasHeader('headername') -- 如果response有这个头则返回true
  • headersSent() -- 如果这个头已经发送到客户端返回true

在处理完头之后,你可以通过调用response.writeHead()发送给客户端,这个方法接收statusCode为第一个参数,还有一个可选的status message,和header对象。要在response body中给客户端发送数据,应该使用write(),它会将buffered data发送到HTTP response stream。如果还没有使用response.writeHead()发送头信息,那么会先发送头,伴随request中设置的状态码和状态信息,你可以通过设置statusCode statusMessage来设置这些值

response.statusCode = 500
response.statusMessage = 'Internal Server Error'

一个http.IncomingMessage对象会在以下情况被创建:

  1. http.Server创建,当监听请求事件时
  2. http.ClientRequest创建,当监听response事件时

它可以用来访问response的信息

  • status -- 使用它的statusCode statusMessage方法
  • headers -- 使用它的headers rawHeaders方法
  • HTTP method -- 使用它的method方法
  • HTTP version -- 使用它的httpVersion方法
  • URL -- 使用它的url方法
  • underlying socket -- 使用它的socket方法

使用stream来访问它的数据,因为http.IncomingMessage实现了Readable Stream接口

Node.js Buffer

一个buffer是内存的一部分,JS开发者不太熟悉这个概念(相比后端语言开发者,经常和内存交互)。它代表内存中的一块固定大小的内存(不能被重新修改大小)(分配在V8JS引擎之外)。可以将buffer看作一个整数数组,每个整数表示数据的一个字节。它是通过Node.js的Buffer类实现的

buffer的推出时帮助开发者处理二进制数据的,曾经的生态基本上只处理字符串而非二进制数据。buffer和stream联系密切。当一个stream处理器接收的数据的速度比消费的速度快,那么它会将数据放进buffer

使用Buffer.from() Buffer.alloc() Buffer.allocUnsafe()方法创建buffer

const buf = Buffer.from('Hey!')
Buffer.from(array)
Buffer.from(arrayBuffer[, byteOffset[, length]])
Buffer.from(buffer)
Buffer.from(string[, encoding])
// 你可以通过传入大小来初始化一个buffer,这创建了1kb的buffer
const buf = Buffer.alloc(1024)
// or
const buf = Buffer.allocUnsafe(1024)

尽管alloc allocUnsafe都分配了一个指定大小的字节buffer,alloc创建的buffer会用0(zero)初始化,而allocUnsafe不会被初始化。这意味着allocUnsafe创建buffer会比较快,但是它分配的内存片段可能会存在遗留的(敏感)旧数据

当buffer内存被读取,上面的旧数据,可能会被访问或泄露。这就是allocUnsafe方法不安全的原因,并且在使用的时候需要格外注意

一个buffer就是一个字节数组,可以像一个数组一样被访问

const buf = Buffer.from('Hey!')
console.log(buf[0]) //72
console.log(buf[1]) //101
console.log(buf[2]) //121

这些数字是Unicode码,标识着buffer不同位置的字符。你可以使用toString()来打印buffer中的内容console.log(buf.toString())。如果你使用一个数字来初始化buffer,即设定它的大小,那么你只能访问被提前初始化的内存(包含随机数据,不是一个空buffer),使用length属性来获得buffer的长度

const buf = Buffer.from('Hey!')
console.log(buf.length)
// 遍历buffer
const buf = Buffer.from('Hey!')
for (const item of buf) {
  console.log(item)
}

你可以通过write()方法将一整个字符串的数据写入buffer

const buf = Buffer.alloc(4)
buf.write('Hey!')
// 使用数组的方式来设置值
const buf = Buffer.from('Hey!')
buf[1] = 111 //o
console.log(buf.toString()) // Hoy!

复制一个buffer需要使用copy()方法

const buf = Buffer.from('Hey!')
let bufcopy = Buffer.alloc(4)
buf.copy(bufcopy)
// 默认copy整个buffer,有3个额外参数可以定义开始位置,结束位置,新buffer的长度
const buf = Buffer.from('Hey!')
let bufcopy = Buffer.alloc(2)
buf.copy(bufcopy, 0, 0, 2)
bufcopy.toString() // 'He'

如果你想创建一个buffer的部分视图(visualization),你可以创建一个slice,它并不是一个copy,原始的buffer仍然是数据源,如果源改变了,那么你的slice也会改变。使用slice()方法来创建,第一个参数是开始位置,你可以指定可选的第二个参数(截至位置)

const buf = Buffer.from('Hey!')
buf.slice(0).toString() // Hey!
const slice = buf.slice(0, 2)
console.log(slice.toString()) // He
buf[1] = 111 // o
console.log (slice.toString()) // Ho

Node.js Stream

Stream是Node.js应用强大能力的一个基本概念。它是一种处理读写文件、网络通信或者任何端到端的信息交换的一种高效方式。Stream并不是Node.js独有的概念,它是在Unix操作系统中介绍的概念,程序之间可以通过pipe(|)来传输流。传统模式中,当程序需要读取一个文件,这个文件会被读到内存,从头到尾,然后你在处理它。使用流的时候,你会分段读取,在不需要把文件全部读取到内存中的前提下就可以处理它

Node.js的stream模块,提供了所有流API的功能,所有的流都是EventEmitter的实例。流相对于其它的数据处理方法有两个显著优点(其实这两点说的一个事)

  1. 高效使用内存:在处理数据之前,你不需要将大量数据全部读取到内存
  2. 节省时间:在开始处理数据之前消耗极少的时间

一个从磁盘读取文件的例子:

const http = require('http')
const fs = require('fs')

const server = http.createServer(function(req, res) {
  fs.readFile(__dirname + '/data.txt', (err, data) => {
    res.end(data)
  })
})

server.listen(3000)

readFile()读取了一个文件中所有的内容,然后在完成这个操作之后调用回调函数res.end()会返回给HTTP客户端所有的文件内容。如果文件很大,这个操作会耗费很长时间,以下是使用流的写法:

const http = require('http')
const fs = require('fs')

const server = http.createServer((req, res) => {
  const stream = fs.createReadStream(__dirname + '/data.txt')
  stream.pipe(res)
})

server.listen(3000)

和之前不一样,只要我们获得了可以发送给客户端的数据的一部分,那么我们直接发送它。pipe()方法在文件流中被调用。它从源中获取数据,然后发送(pipe)到一个目的地。本例中,在文件源上调用了pipe(),所以文件流被发送到了HTTP响应对象。pipe()方法的返回值是目的流,这样可以让我们非常方便的链接多个pipe()调用

src.pipe(dest1).pipe(dest2)
// same as
src.pipe(dest1)
dest1.pipe(dest2)

强调Stream的Node.js的API

  • process.stdin -- 返回一个连接stdin的流
  • process.stdout -- 返回一个连接stdout的流
  • process.stderr -- 返回一个连接stderr的流
  • fs.createReadStream() -- 创建一个文件的可读流
  • fs.createWriteStream() -- 创建一个文件的可写流
  • net.connect() -- 初始化一个基于流的连接
  • http.request() -- 返回一个http.ClientRequest类的对象,并放入流中
  • zlib.createGzip() -- 使用gzip压缩数据,并放入流中
  • zlib.createGunzip() -- 解压一个gzip流
  • zlib.createDeflate() -- 使用deflate压缩数据,并放入流中
  • zlib.createInflate() -- 解压一个deflate流

关于流的类有4种

  1. Readable -- 你可以从这个流pipe数据,但是不能将数据pipe进这个流(你可以从这个流种获取数据,但是你不能给这个流发数据),当你将数据放进(这里的放进并不是程序员将数据放进去,而是cpu将数据放进流中)可读流时,它会被缓存(buffer),直到一个消费者来消费数据
  2. Writable -- 你可以将数据pipe进这个流,但是不能从这个流中pipe数据
  3. Duplex -- 既可以从这个流pipe数据,也可以将数据pipe进这个流,基本上是前两者的结合
  4. Transform -- 和Duplex流类似,但是它的输出就是它的输入转化而来的(可能前者有输入和输出,而这种实现只有一个实体实现输入输出)

我们通过stream模块来获得一个可读流,我们初始化它然后实现readable._read()方法

const Stream = require('stream')
const readableStream = new Stream.Readable()

readableStream._read = () => {}
// 使用 read 选项来实现此方法
const readableStream = new Stream.Readable({
  read() {}
})
// 发送数据
readableStream.push('hi!')
readableStream.push('ho!')

要创建一个可写流,我们需要继承Writable对象,然后实现_write()方法

const Stream = require('stream')
const writableStream = new Stream.Writable()

writableStream._write = (chunk, encoding, next) => {
  console.log(chunk.toString())
  next()
}

你现在可以将一个可读流pipe进这个可写流,process.stdin.pipe(writableStream)

使用一个可写流来从可读流读取数据

const stream = require('stream')

const readableStream = new Stream.Readable({
  read() {}
})
const writableStream = new Stream.Writable()

writableStream._write = (chunk, encoding, next) => {
  console.log(chunk.toString())
  next()
}

readableStream.pipe(writableStream)
readableStream.push('hi')
readableStream.push('ho')

你也可以直接消费一个可写流,使用readable事件

readableStream.on('readable', () => {
  console.log(readableStream.read())
})

使用可写流的write()方法来发送数据

writableStream.write('hey!\n')

// 终止可写流的输出
writableStream.end()

Node.js中,开发和生产的区别

对于开发环境和生产环境你可能有不同的配置。Node.js假设它总是运行在开发环境。你可以通过设置NODE_ENV=production环境变量来告诉Node.js这是在生产环境。通常它是通过命令行来实现的export NODE_ENV=production。但是更好的选择是将其放入你的shell配置文件中(.bash_profile),否则服务器重启这个设置会丢失。你也可以将这个环境变量作为启动参数向前追加到运行应用的命令中NODE_ENV=producton node app.js。这个环境变量也被广泛应用在外部库中

设置生产环境主要保证:

  1. logging保持最小、最必要的级别
  2. 在优化性能的时候会开启更多缓存级别

例如PugExpress使用的模板库,如果NODE_ENV不设置生产环境,那么会在debug模式下编译。Express的views在每个请求都会编译(开发环境),但是生产环境下会缓存

// 使用程序的方式
if (process.env.NODE_ENV === 'development') {
  // ...
}
if (process.env.NODE_ENV === 'production') {
  // ...
}
if (['production', 'staging'].indexOf(process.env.NODE_ENV) >= 0) {
  // ...
}

例如,在Expressapp中,你可以使用这种方式来设置不同环境下的错误handler

if (process.env.NODE_ENV === 'development') {
  app.use(express.errorHandler({ dumpExceptions: true, showStack: true }))
}
if (process.env.NODE_ENV === 'production') {
  app.use(express.errorHandler())
})

Node.js 中的错误处理

使用throw关键字来创建一个异常。throw value,当JS执行到这一行,正常的程序流终止,程序的控制权会移到最近的异常handler。在客户端代码中,这个抛出的值可以是JS中的任何值(string、number或object)。在Node.js中,我们不会抛出字符串,只抛出Error对象

一个error对象既可以是Error类的一个实例,也可以是Error的子类的实例(Error核心模块中提供的类)

throw new Error('Ran out of coffee')
// or
class NotEnoughCoffeeError extends Error {
  // ...
}
throw new NotEnoughCoffeeError()

一个异常handler是try/catch语句。在try语句块中任何抛出的异常都会被对应的catch语句块捕获并处理

try {
  // line of code
} catch (e) {}

可以添加多个handler,可以捕获不同类型的error。如果一个未捕获的异常在程序运行的过程中被抛出,那么程序会崩溃。为了解决这个问题,你可以监听在processuncaughtException事件

process.on('uncaughtException', err => {
  console.error('There was an uncaught error', err)
  process.exit(1)
})

PS:不需要手动引入process模块

使用promise你可以链接不同的操作,然后在最后处理错误

doSomething1()
  .then(doSomething2)
  .then(doSomething3)
  .catch(err => console.error(err))

你不需要知道错误发生在哪里,但是你可以在调用的任何一个方法中处理异常,然后在处理代码中抛出一个新错误,这个错误会调用外部catchhandler

const doSomething1 = () => {
  // ...
  try {
    // ...
  } catch (err) {
    // ... handle it locally
    throw new Error(err.message)
  }
  // ...
}

如果不想在调用的函数中处理异常,可以直接抛出到最外层(会破坏抛出异常之后的所有方法的正常调用)

doSomething1()
  .then(() => {
    return doSomething2().catch(err => {
      // handle error
      throw err // break the chain!
    })
  })
  .then(() => {
    return doSomething2().catch(err => {
      // handler error
      throw err //break the chain!
    })
  })
  .catch(err => console.error(err))

使用async/await也需要捕获错误,可以用以下这种方式

async function someFunction() {
  try {
    await someOtherFunction()
  } catch(err) {
    console.error(err.message)
  }
}

在Node.js中log一个对象

当你在浏览器中使用console.log(),它会将这个对象的信息打印到控制台。在Node.js中,也会这样。当我们要将一些东西打印到控制台(或者日志文件),你会得到一个对象的字符串展示

最好的打印整个对象的方式是使用console.log(JSON.stringify(obj, null, 2)),2是缩进使用的空格数。另一个方式是require('util').inspect.defaultOptions.depth = null console.log(obj)这里的问题是level2之后的嵌套对象会被折叠,对于复杂对象可能是个问题

posted on 2021-01-02 16:16  老鼠不上树  阅读(176)  评论(0)    收藏  举报