Skip to content

3.2 Node.js:服务端的 JavaScript

Node.js 诞生于 2009 年,是基于 Chrome V8 引擎的 JavaScript 运行时。

所谓运行时,其实是一种运行环境。JavaScript 目前有两种运行环境,一种是浏览器环境,另一种是 Node.js 环境。

在浏览器环境中,JavaScript 可以操作 DOM,具有 Document、Window 等浏览器对象。而在 Node.js 环境中,JavaScript 具有系统访问权限(如操作文件、执行 shell 命令),可以提供后端服务(如操作数据库、运行 Web 服务器),这些实现起来非常容易。

3.2.1 Node.js 基础

简单来说,Node.js 就是服务端的 JavaScript。下面从安装到使用逐一介绍 Node.js 的基本能力。

安装 Node.js

Node.js 可以通过多种方式安装,最简单的方式是官网下载安装包。

下载地址:http://nodejs.cn/download/

打开地址会看到这个页面,选择长期支持的版本,点击下载对应的平台安装包,如图 3-1 所示。

Node.js 的版本升级比较快,不建议使用老版本。到目前为止最新稳定版是 v18,实际使用中至少需要 v16 以上的版本。如果已安装低于 v16 版本,建议升级到最新稳定版。

安装 Node.js 后,打开电脑终端,输入 “node -v”,控制台会打印出版本。

sh
$ node -v
v16.16.0

此时 Node.js 就安装好了。Node.js 安装后会作为一个系统命令(node)存在,这个命令的作用就是创建 Node.js 运行环境。

node 命令

学习 Node.js 的第一步就是了解 node 命令。

JavaScript 有两种运行环境 —— 浏览器环境Node.js 环境。在 HTML 文件中写入 JavaScript 代码并在浏览器打开,这样便创建了浏览器环境;使用 node 命令运行一个脚本文件,此时就创建了 Node.js 环境。

创建 Node.js 环境有两种方式:

  • 运行脚本文件
  • 命令行交互(REPL)

最常用的是运行脚本文件。创建一个 app.js 文件,写入如下代码:

js
// app.js
const path = require("path");
console.log(path.resolve(__filename));

打开终端,切换到 app.js 所在的文件夹下,执行如下命令:

js
$ node app.js
/usr/local/var/app.js // 我的文件地址

可以看到,在 node 命令后面跟一个文件名并执行,首先会创建一个 Node.js 运行环境,然后在这个环境中执行对应的文件。上述 app.js 文件被执行,打印出了文件的绝对路径。

那么,能否将创建 Node.js 环境和运行代码这两步分开呢?

当然可以,命令行交互(REPL)就是一种先创建 Node.js 环境,然后在该环境中编写和执行代码的方式。在终端中直接运行 node 命令,不加任何参数,即可进入 REPL 模式。

js
$ node
>

上面的 > 符号表示已经进入 REPL 模式,等待输入内容。此时可以输入任意 Node.js 代码,输入完毕后按回车键,代码会自动执行,与浏览器开发者工具中的控制台基本一致。

比如在 REPL 模式下写一个全局对象 global,结果如下:

sh
$ node
> global
<ref *1> Object [global] {
  global: [Circular *1],
  clearInterval: [Function: clearInterval],
  clearTimeout: [Function: clearTimeout],
  setInterval: [Function: setInterval],
}

这里会列出全局对象下的所有属性。REPL 模式对于学习和测试 Node.js 代码非常有用,可以快速查看某个对象或执行某个函数,还支持智能提示和 tab 键自动补全。

Node.js 的运行环境也可以通过代码关闭。比如在脚本文件中,当不满足某个条件时可以用 process.exit() 方法退出进程,阻止代码继续执行。方式如下:

js
// app.js
if (1 > 0) {
  process.exit(1);
}
console.log("已经推出");

上述代码执行后,控制台并没有打印信息,因为程序在执行打印前已经退出。

命令参数

使用 node 命令运行脚本文件还可以传递参数,同时在文件中接收参数,这样可以实现不同的逻辑。

Node.js 中有一个 process.argv 属性专门用来接收参数。修改 app.js 的内容如下:

js
// app.js
var argv = process.argv;
console.log("参数:", argv);

然后通过以下命令执行文件并传参:

sh
$ node app.js tag=test name=node
参数:['/usr/local/bin/node', '/usr/local/var/app.js', 'tag=test', 'name=node']

从上述打印结果可以看出,process.argv 的值是一个数组。数组第 1 项是 node 命令的路径,第 2 项是所执行文件的路线,从第 3 项开始才是真正的参数。因此,获取参数的代码可以修改为下面这样:

js
// app.js
var argv = process.argv.slice(2);
console.log("参数:", argv);

模块系统

Node.js 自带模块系统,一个文件就是一个单独的模块,通过 CommonJS 规范来实现模块之间的导入和导出。

CommonJS 规范使用 require() 方法导入模块,使用 module.exports 对象导出模块。假设现在有两个文件(a.js 和 b.js),它们之间的引用方式如下:

js
// a.js
var config = {
  name: "西兰花",
};
module.exports = config;

// b.js
var config = require("./a.js");
console.log(config); // 西兰花

在 a.js 中必须显式地用 module.exports 导出一个对象,这样在 b.js 中才能导入这个对象。如果在 a.js 中没有显式地导出,那么当 a.js 被引入时,只会执行 a.js 中的代码逻辑,如下所示。

js
// a.js
var tag = "a.js";
console.log(tag);

// b.js
var amd = require("./a.js");
console.log("导入内容:", amd);

执行 b.js 查看输出:

js
> node b.js
a.js
导入内容: {}

从上面的代码执行结果可见,当被导入的模块没有显式导出内容时,导入的结果是一个空对象,但模块中的代码正常执行(打印出了 a.js)。

注意:在模块中,全局作用域下的 this 指向会发生变化。我们对比在控制台(REPL)和模块中 this 指向的区别,代码如下:

js
// REPL 模式
$ node
> this
<ref *1> Object [global] {...}

// app.js
this.name = "app";
console.log(module.exports);
// 执行 app.js
$ node app.js
{ name: 'app' }

可以看出,在控制台(REPL)中 this 指向全局对象 global,而在模块中 this 指向 module.exports。

3.2.2 Node.js 内置模块

Node.js 由各种各样的软件包组成,这些软件包统称为模块。Node.js 中的模块分为两大类:

  • 内置模块
  • 第三方模块

内置模块不需要单独安装,直接导入即可使用。Node.js 中的系统能力几乎都被封装在一个个的内置模块中,比如前面用到的 path 模块就是一个典型代表。

下面介绍一下常用的内置模块。

path 模块

path 模块用于对路径和文件进行处理。我们知道,在 MacOS、Linux 和 Window 中路径的表示方法并不一致,在 Window 系统中使用 “\” 做分隔符,而在 Linux 中使用 “/” 做分隔符。

path 模块就是为了屏蔽他们之间的差异,提供统一的路径处理,并支持了路径拼接等功能。

path 模块常用的 API 如下。

  • path.join():将多个路径连接起来,生成一个规范化的路径。
  • path.resolve():将一个或多个路径解析成规范化的绝对路径。

这里的规范化指的是:对于符合当前平台的路径,path 模块会自动识别并处理。代码如下:

js
const path = require("path");
path.join("./", "test.js"); // test.js
path.resolve("./", "test.js"); // /usr/local/var/test.js

在前端工程化项目的配置中,经常使用 path.resolve() 方法解析绝对路径。

fs 模块

fs 模块是文件系统模块,封装了文件操作的能力。使用这个模块可以实现文件的创建、修改和删除。

使用脚手架工具生成代码,其底层原理就是用 fs 模块来实现文件夹和文件的创建。

先来看一下如何 读取文件

js
const fs = require("fs");

fs.readFile("/Users/local/test.txt", "utf8", (err, data) => {
  console.log("文件内容:", data);
  // data 就是文件内容(字符串)
});

上面代码通过 readFile() 方法读取一个文件,第 1 个参数是文件地址,第 2 个参数指定文件的编码,第 3 个参数是表示执行结果的回调函数。

文件操作是一个典型的异步操作,所以需要在回调函数中获取文件数据。其实 fs 模块还提供了对应的同步操作 API,代码如下:

js
try {
  const data = fs.readFileSync("/Users/local/test.txt", "utf8");
  console.log("文件内容:", data);
} catch (err) {
  console.error(err);
}

fs 模块的每个异步操作 API 都有对应的同步 API,下面统一用同步 API 来书写代码示例。

fs 写入文件 的方法如下:

js
const fs = require("fs");

try {
  let content = "我是文件内容";
  fs.writeFileSync("/Users/local/test2.txt", content);
} catch (err) {
  console.error(err);
}

默认情况下,此 API 会替换文件的内容。如果文件不存在则创建新文件。

除了读取文件和写入文件外,还有一个常用的操作 —— 检查文件状态。比如检测某个文件是否存在、获取文件大小,这些都可以通过 fs.stat() 方法来实现。

js
const fs = require("fs");

try {
  let stats = fs.statSync("/Users/joe/test.txt");
  stats.isFile(); // 是否是文件
  stats.isDirectory(); // 是否是文件夹
  stats.size; // 文件大小
} catch (err) {
  console.error(err);
}

http 模块

http 模块提供了极其简单的方式来创建 HTTP Web 服务器,示例代码如下:

js
const http = require("http");

const server = http.createServer((request, response) => {
  response.statusCode = 200;
  response.end("hello world");
});

server.listen(3000, () => {
  console.log("server address: http://localhost:3000");
});

上面代码通过 http.createServer() 方法创建了一个 http 服务器,设置响应码为 200,响应数据为 "hello world",最后监听 3000 端口来访问这个服务器。

现在把代码写进 index.js ,再把它运行起来:

sh
$ node index.js
server address: http://localhost:3000

此时打开浏览器,输入http://localhost:3000,就能看到网页显示的 “helloworld” 了,如图 3-2 所示。

在前端工程化的项目中,本地运行文件(如:npm run dev)的本质就是创建一个 HTTP 服务器,将编译后的代码挂载到这个服务器上,因此前端才可以通过 “IP 地址+端口号” 的方式访问网站。

http.createServer() 方法的回调函数有两个参数,第 1 个参数是请求对象 request,第 2 个参数是响应对象 response,它们是 HTTP 服务器的核心。

请求对象 request 包含了详细的请求数据,即我们前端调接口传递过来的数据。通过它可以获取请求头、请求参数、请求方法等,代码如下:

js
const { method, url, headers } = request;
// method:请求方法
// url:请求地址
// headers:请求头

响应对象 response 主要用于响应相关的设置和操作。响应是指在处理完成客户端的请求后,如何给客户端返回结果。主要包括设置响应状态码和响应数据,代码如下:

js
// 设置状态码
response.statusCode = 200;
// 设置响应头
response.setHeader("Content-Type", "text/plain");
// 发送响应数据
response.end("这是服务器的响应数据");

当然这些只是最基础的 HTTP 功能。在实际场景中有成熟的 HTTP 框架可用,比如 Express(经典的 Node.js HTTP 框架),它提供了强大的功能。

3.2.3 Npm 包管理

前面介绍了内置模块,本节介绍 Node.js 的第三方模块 —— Npm 软件包。

Npm 是目前为止全世界最大的包管理器,托管了超过 35 万个第三方软件包。对于 JavaScript 开发者来说,几乎任何需求都有合适的 Npm 包解决方案。

在 Node.js 安装之后,除生成 node 命令外,还会生成 npm 命令。在控制台检测一下 npm 命令:

sh
$ npm -v
9.1.6

这个命令用于便捷地管理 Npm 的第三方依赖包,它有不同的参数实现不同的功能。Npm 的依赖信息会记录在 package.json 文件中。

因此,在安装一个第三方包之前,首先要初始化一个 package.json 文件。初始化文件只需要一个命令:

sh
$ npm init

这个命令会启动一个 REPL 交互模式,提示输入必要的信息,最后会生成 package.json 文件。

npm 基础命令

npm 命令主要用于添加依赖、安装依赖和删除依赖。假设要在一个 Node.js 项目中安装第三方包 axios,使用如下命令:

sh
$ npm install axios

执行命令后,在 package.json 文件中会加入以下的依赖标识:

json
{
  "dependencies": {
    "axios": "^0.27.2"
  }
}

与此同时,在当前目录下还会生成一个 node_modules 文件夹,这个文件夹下存放所有的第三方依赖包,安装后的 axios 也放在这个目录下。

另一个自动生成的文件是 package-lock.json,这个文件用于依赖包的版本锁定,开发者无须关注。

安装 axios 之后,在项目中就可以通过 require 导入并使用了。

js
const axios = require("axios");
axios.get("...");

每个 Npm 包都有确定的版本,当软件包更新时会升级版本号,此时使用者也可以将 npm 包升级到最新的版本。同样使用 npm 命令来升级:

sh
$ npm update axios

假设现在不再需要 axios 了,开发者也可以快捷的移除这个模块:

sh
$ npm uninstall axios

上面说的添加、更新、删除模块只针对于当前项目,因此属于本地安装。NPM 还支持全局安装模块,安装后可在任意位置使用该模块。安装全局只需要加一个 -g 参数即可。

sh
$ npm install -g axios

全局安装到模块不会下载到当前目录到 node_modules 文件夹中,那么会装到哪里呢?可以用 npm 命令获取全局模块安装位置:

sh
$ npm root -g
/usr/local/lib/node_modules

更新和删除全局模块与上面同理,只需要加一个 -g 参数即可。

其他常见的 npm 命令列表如下:

  • npm update:更新所有依赖包
  • npm list:查看安装的依赖
  • npm install:安装所有依赖包
  • npm install [pkgname]:[version]:安装某个固定版本的模块

package.json 解析

package.json 文件是项目的清单,它不光可以记录第三方包的依赖,还包括了很多的项目配置信息,以及一些命令定义,下面介绍一下重要的配置项。

  • name:应用程序/软件包的名称。
  • version:当前版本号。
  • description:应用程序/软件包的描述。
  • main:应用程序的入口点。
  • scripts:定义一组命令。
  • dependencies:第三方依赖列表。
  • devDependencies:第三方开发依赖列表。

通过 npm init 命令初始化生成的 package.json 内容如下:

json
{
  "name": "node-demo",
  "version": "1.0.0",
  "author": "you",
  "description": "Node.js项目小样",
  "main": "app.js",
  "scripts": {
    "test": "echo \"this is test command\""
  },
  "dependencies": {
    "axios": "^0.27.2"
  },
  "devDependencies": {},
  "license": "ISC"
}

首先要说明 dependencies 和 devDependencies 两个字段的区别。

dependencies 表示项目的第三方依赖,而 devDependencies 则表示开发环境需要的第三方依赖。前者表示项目本身需要的模块,后者表示用于开发环境编译使用的工具。

devDependencies 下的模块不会在项目代码中使用,绝大多数是一些编译转换工具。举例如下:

  • webpack
  • eslint
  • babel
  • @types/vue

webpack 用于打包项目,eslint 用于检测代码,@types/vue 用于类型检测。这类模块与项目的业务逻辑本身无关,只是打包编译时需要的工具,在生产环境中并不需要,因此这些模块并不会被打包在生成的代码中。

dependencies 下的模块则是项目中实打实需要的模块,比如:

  • vue
  • axios
  • dayjs

这些模块是项目代码中需要引入并使用的依赖模块,因此需要放到 dependencies 之下。

默认安装模块时会作为 dependencies 安装,如果要安装到 devDependencies,只需加一个 -D 参数。方法如下:

sh
$ npm install axios -D

scripts 字段下定义了一组命令供 npm 调用。比如上面代码定义了一个 test 命令,在控制台中即可如下使用:

sh
$ npm run test
this is test command

然而大多数脚手架中最常见的用法,是将执行命令的逻辑(Node.js 代码)写在一个 JavaScript 文件中。比如创建一个 dev.js 文件,写入以下代码:

js
// dev.js
console.log("执行打包逻辑");

然后在 scripts 字段中配置一个 dev 命令,如下:

json
{
  "scripts": {
    "dev": "node dev.js"
  }
}

最后在项目目录下打开控制台,就可以执行这个 dev 命令:

sh
$ npm run dev
执行打包逻辑

脚手架中那些耳熟能详的命令(如 npm run dev,npm run build)都是通过这种方式来实现的。

npx 命令

npx 是自 npm:5.2 之后新增的命令,它的作用是运行 npm 第三方模块的命令。

假设现在安装了一个 typescript 模块,这个模块的内部提供了一个 tsc 命令。此时在终端执行 tsc,结果是这个命令不存在,那该如何执行呢?

这个时候 npx 就可以派上用场了,在项目目录下打开终端,执行如下命令:

sh
$ npx tsc --version
Version 4.6.2

如上代码,tsc 命令执行,并打印出了 typescript 模块的版本。

那么 npx 是如何找到 tsc 命令并执行的呢?其实在 typescript 模块安装之后,会在 node_modules/.bin 目录下生成一个与命令同名的 tsc 脚本文件,npx 找到这个命令后执行。因此以下两种命令的执行效果是一致的:

sh
$ npx tsc --version
# 等同于
$ npm node_modules/.bin/tsc --version
# 两个命令都输出版本号
Version 4.6.2

此时的 node_modules/.bin/tsc 文件其实是 node_modules/typescript/bin/tsc 脚本文件的一个软链接(可以理解为引用),可以用以下命令查看:

sh
$ ls -al node_modules/.bin/tsc
lrwxr-xr-x  ... node_modules/.bin/tsc -> ../typescript/bin/tsc

因此,通过 npx 命令可以便捷的执行 node_modules/.bin 目录下由 npm 模块定义的命令。

3.2.4 环境与环境变量

Node.js 中一个非常重要的知识点是环境变量。环境变量表示在 Node.js 进程中存储的,可供运行时全局设置和全局访问的特殊变量。

在了解环境变量之前,首先要了解的是,什么是环境?

什么是环境?

这里的环境可不是大自然的秀丽山川,而是指一种应用程序的运行环境。

前面介绍过,JavaScript 只能在浏览器和 Node.js 两种环境下运行,事实上任何编程语言都必须在某种环境下才能运行 —— 环境就是执行代码的地方。

环境由某种应用程序创建,操作系统本质上是一个巨大的应用程序,因此环境一般分为两类:

  • 系统环境
  • 应用环境

系统环境在操作系统(如:Linux,MacOS)启动后创建,应用环境在应用程序(如:Node.js)启动后创建。无论在哪种环境,都会有一些能在整个环境中访问的值,这些值就是环境变量。

相应的,环境变量也分系统环境变量和应用环境变量。

在前端工程化的项目中,Node.js 环境变量被大量应用,这些就属于应用环境变量。

Node.js 的环境变量存储在 process.env 对象中,最常用的一个环境变量是 NODE_ENV,表示当前环境,其判断规则如下:

js
process.env.NODE_ENV == "development"; // 开发环境
process.env.NODE_ENV == "production"; // 生产环境

应用环境变量可以设置,可以获取,变量值能在整个应用程序中访问。

设置环境变量

当 Node.js 应用程序启动之后,就可以自定义当前应用的环境变量了,举例如下:

js
process.env.baseURL = "https://api.test.com";
console.log(process.env.baseURL); // 'https://api.test.com'

定义之后,baseURL 这个环境变量可以在整个应用程序中访问。

在一些特殊场景下,我们需要环境变量是动态的,但是源码不可修改。比如一个打包好的应用程序,运行的时候才会指定环境变量,这个时候就要借助系统环境变量了。

Node.js 可以设置和读取自己的环境变量,同时也可以读取系统环境变量。当不修改代码时,可以通过读取系统环境变量,来实现动态的环境变量值。

系统环境变量定义在当前系统用户的配置文件中,首先查看当前的 shell 类型,执行一下命令:

sh
$ echo $SHELL

这个命令的输出值一般会有两种情况,不同值对应的配置文件不同。其关系如下:

  • /bin/bash:bash 类型,配置文件在 ~/.base_profile
  • /bin/zsh:zsh 类型,配置文件在 ~/.zshrc

找到对应的配置文件,我们在文件末尾导出一个环境变量:

js
// ~/.zshrc
export BASE_URL=https://api.pro.com

保存之后,即可在 Node.js 通过 process.env.BASE_URL 访问到这个环境变量。当在不同的系统(服务器)配置不同的环境变量值,即可实现环境变量的自定义。