这是我参与「第四届青训营」笔记创作活动的第9天,本篇笔记主要为 Node.js 相关的知识,包括 Node.js 的应用场景,运行时结构以及如何使用 Node.js 原生的 http 模块来编写服务端、提供静态文件以及实现(以 React 为例)服务端渲染 SSR。
1. Node.js 的应用场景
前端工程化
- Bundle 打包工具:webpack,vite,esbuild,parcel
- Uglify 代码压缩(转换)减少代码体积:uglifyjs
- Transpile 语法转换:babeljs,typescript
- 其他语言加入竞争: esbuild(go),parcel(rust),prisma
现状:Node.js 在前端工程化方面难以替代
Web 服务端应用(Node.js / Vercel)
- 在 JavaScript 基础之上只需要再去了解运行环境 和 Node.js 的 API 即可。
- Node.js 作为一门不需要编译环境的语言,运行效率接近常见的编译语言。
- 社区生态丰富(npm) 及 工具链成熟(V8 inspector)
- 与前端结合的场景会有优势(服务端渲染 SSR)
现状:竞争激烈,Node.js 有自己的优势
Electron 跨端桌面应用
- 结合了 Node 和 JS 去实现的跨端桌面应用。
- 大型公司内的效率工具也会选择 Electron 开发。
- 运行消耗资源较多,运行较慢
- 优势为开发效率高,跨端,稳定性
现状:大部分场景在选型时,都值得考虑
2. Node.js 运行时结构
用户引入的包也算作是用户代码。
Node.js 内部很多代码是使用 JavaScript 写的(如 http 模块)。但更多的功能是使用 C++ 编写的。
- 比如提供 http 模块和对应的 API,需要去调用底层 C++ 的代码。
- N-API:通过 JavaScript 代码无法满足的需求(如性能)
- 需要更 native 的语言去和 Node.js 通信,那么就需要
N-API
一系列的模块来提供这种能力。
- 需要更 native 的语言去和 Node.js 通信,那么就需要
- V8: JavaScript 运行时,包括诊断调试工具(inspector)
- Node.js 在解释性语言中效率较高归因于 V8 引擎
- V8 提供的 inspector 使得 Node.js 可以使用
chrome devtool
类似的工具进行调试
- libuv:封装了各种操作系统的 API,并提供了 Node.js 核心的
EventLoop
- 提供跨平台的 UI 操作
- nghttp2: 与
http2
相关的模块 - zlib: 做常见的压缩与解压缩的算法
- c-ares: 做
dns
查询的库 - llhttp:
http
协议的解析 - OpenSSL: 网络层面上的加密解密工具
举例:使用npm 上的 node-fetch 模块发起请求的过程
- 在 JavaScript 代码中调用引入的
node-fetch
模块,会在 V8 中执行 node-fetch
底层调用了 Node.js 的http
模块,即调用了 Node.js Core(JavaScript)http
模块会去调用更加底层的 C++ 模块的 API- 可能需要调用 llhttp 去实现
http
的序列化和反序列化,将得到的数据通过 libuv 创建的 tcp 连接发送至服务器 - 服务器发来的响应在事件循环中得到一个消息,再将响应的数据交给 libuv 解析出来,再将数据交给 JavaScript 代码,再到用户代码,即收到了数据。
特点
- 异步 I/O
- 单线程
- Node.js 的 JavaScript 线程 是单线程,不适合做 CPU 密集的操作
- Node.js 较新的版本已经支持使用
worker_thread
模块,通过该线程可以创建一个新的 JavaScript 线程,但每个线程的模型没有太大变化。- 都有一个 V8 的实例
- 都有独立的
EventLoop
- 跨平台
- 可以通过在本地创建文件,进行进程间的通信
异步 IO
Node.js 执行 I/O 操作时,会在响应返回后恢复操作,而不是阻塞线程的执行并占用额外内存等待。
- 效率较高
- 对系统资源的利用率较高
fs 模块的 readFile() / readFileSync()
可以用于读取文件。
使用 readFileSync()
时会触发一个异步调用,将读取文件的工作交给 libuv 的线程池去做。
文件读取的操作从线程返回回来的时候,将数据交给主线程。
单线程
其实只有 JS 主线程是单线程,Node.js 实际上有很多线程。
- JS 主线程
- uv 线程池: 默认会创建 4 个线程,来完成 I/O 操作,或是对 CPU 消耗较大的底层操作(如加密 / 解密),避免对主线程产生较大的阻塞
- V8 任务线程池: 可以通过配置调整
- V8 Inspector 线程: 在 JavaScript 中程序进入死循环时,仍然可以进行调试的操作
优点:不用考虑多线程状态同步问题
- 不需要锁机制,也不需要考虑可能出现的死锁的状况。但需要去考虑异步的问题。
- 能够比较高效地利用系统资源
缺点:阻塞会产生更多负面影响
- 解决办法:多进程或多线程
跨平台
libuv
封装了大部分操作系统的 API,并提供了一套统一的 API。
- 仍然有一些 API 是某个平台特有的。
- 如果需要极高的性能,则需要调用大量 UNIX 的 API。
开发成本与学习成本都比较低。
- Node.js 跨平台 + JS 无需编译环境 + Web 跨平台 + 诊断工具跨平台。
3. 编写 Http Server
安装 Node.js
-
Mac、Linux 推荐使用
nvm
,多版本管理。 -
windows 推荐
nvm4w
或官方安装包。 -
安装慢,安装失败的情况,设置安装源。
NVM_NODEJS_ORG_MIRROR = https://npmmirror.com/mirrors/node nvm install 16
Hello World
const http = require('http')
const server = http.createServer((req, res) => {
res.end('hello')
// 发出响应
})
const port = 3000
server.listen(port, () => {
// 端口成功被监听后,会触发该回调
console.log('listening on:', port)
})
Http Server
const http = require('http')
const server = http.createServer((req, res) => {
// 每当连接返回了一段数据, 即 http body 上的数据
// 使用 bufs 收集起来
const bufs = []
req.on('data', (buf) => {
bufs.push(buf)
})
// 当数据传输完毕时会触发 end 事件
req.on('end', () => {
// 将数据段整理成完整的数据
// 将 JSON 转换成 UTF-8 字符串
const buf = Buffer.concat(bufs).toString('utf8')
let msg = 'hello'
try {
const ret = JSON.parse(buf)
msg = ret.msg
} catch (err) {
// 不是 JSON 数据
}
const responseJson = {
msg:`receive: ${msg}`
}
// 将响应数据序列化为 JSON
// 还需要设置响应头 Content-Type
// 浏览器可能基于这个信息去自动进行一些操作
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(responseJson))
})
})
const port = 3000
server.listen(port, () => {
console.log('listening on:', port)
})
Http Client
const http = require('http')
const body = JSON.stringify({
msg:'Hello from my own client',
})
const req = http.request('http://127.0.0.1:3000', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Node.js 中 Content-Length 会自动补上
}
}, (res) => {
// 收到了服务端的响应,会触发该回调函数
const bufs = []
res.on('data', (buf) => {
bufs.push(buf)
})
res.on('end', () => {
const buf = Buffer.concat(bufs).toString('utf8')
cosnt json = JSON.parse(buf)
console.log('json.msg is:', json.msg)
})
})
// 发送请求
req.end(body)
Promisify
用 Promise
+ async await
重写这两个例子:
- 回调函数难以维护,不符合人的思维模式
http.createServer
的回调函数无法 Promisify,因为该函数会被重复调用多次。
const http = require('http')
const server = http.createServer(async (req, res) => {
const msg = await new Promise((resolve, reject) => {
const bufs = []
req.on('data', (buf) => {
bufs.push(buf)
})
req.on('error', (err) => {
reject(err)
})
req.on('end', () => {
const buf = Buffer.concat(bufs).toString('utf8')
let msg = 'hello'
try {
const ret = JSON.parse(buf)
msg = ret.msg
} catch (err) {}
resolve(msg)
})
const responseJson = {
msg:`receive: ${msg}`
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(responseJson))
})
})
const port = 3000
server.listen(port, () => {
console.log('listening on:', port)
})
静态文件
编写一个简单的静态文件服务:
-
使用
stream
类型 API 的好处:- 内部做了处理,可以帮助占用尽可能少的内容空间。
- 按需返回给客户端,匹配客户端的消费速率。内存的使用率更好。
-
假如直接使用
readFile
读取整个文件,则需要分配一个内存空间来存放整个文件
const http = require('http')
const fs = require('fs')
const path = require('path')
const url = require('url')
// __dirname 即当前文件所在的目录
const folderPath = path.resolve(__dirname, './static')
const server = http.createServer((req, res) => {
const info = url.parse(req.url)
const filepath = path.resolve(folderPath, './' + info.path)
// 使用 stream 类型 API 的好处:内部做了处理,可以帮助占用尽可能少的内容空间。按需返回给客户端,匹配客户端的消费速率。内存的使用率更好。
// 假如直接使用 readFile 读取整个文件,则需要分配一个内存空间来存放整个文件
const filestream = fs.createReadStream(filepath)
filestream.pipe(res)
})
const port = 3000
server.listen(port, () => {
console.log('listening on:', port)
})
实现高性能、可靠的服务:
-
CDN:缓存 + 加速
- 缓存静态资源,使返回的速率更快
- 数据分发到不同结点,为用户提供更快的结点。
-
分布式存储,容灾
- 当容器故障时,仍然有已经缓存的数据能够正常提供出去。
外部服务:cloudflare,阿里云,七牛云
React SSR
SSR (server side rendering) 有什么特点?
-
相比传统
HTML 模板引擎
:避免重复编写代码- 现在的 HTML 都由 JavaScript 来编写
- 如果能运行 JavaScript 代码,就能够知道要返回什么 HTML 代码,就不需要模板引擎
-
相比
SPA
:首屏渲染更快,SEO
友好- SPA 应用首屏会更慢:需要等到所有 JavaScript 代码都加载好以后,才可以开始给用户返回数据
- 不运行 JavaScript 代码就无法提供信息给客户端
-
缺点:通常
qps
较低,前端代码编写时需要考虑服务端渲染情况- 服务端需要运行 JavaScript 代码,其中可能有一些比较重的操作,耗时更长
- 代码编写比较困难,需要同时兼顾客户端和服务端运行。
const React = require('react')
const ReactDOMServer = require('react-dom/server')
const http = require('http')
function App(props){
return React.createElement('div', {}, props.children ||'Hello')
}
const server = http.createServer((req, res) => {
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>My Application</title>
</head>
<body>
${ReactDomServer.renderToString(
React.createElement(App,{},'my_content')
)}
<script>
// 可能还需要一些操作来初始化 React 在浏览器上的应用
</script>
</body>
</html>
`)
})
const port = 3000
server.listen(port, () => {
console.log("listening on:", port)
})
SSR 难点:
-
需要处理打包代码的问题
- 在服务端,打包工具加载 css / 图片 没有意义。
- 设置打包工具在服务端处理时,绕过 CSS 、图片文件的打包处理。或者直接改动代码。
-
需要思考前端代码在服务端运行时的逻辑
- 大部分应用在做渲染的时候都需要先获取数据,根据数据再进行渲染
- 获取数据的操作需要放在 React 生命周期的后面再去执行(如 componentDidMount 及以后再去执行)
- 因为这些生命周期在服务端渲染的时候是不会触发的
-
移除对服务端无意义的副作用 / 重置环境
- 比如前端应用需要将数据挂载到全局对象
window
上 - 可能通过一些配置将
Node.js
环境中的GlobalThis
替换成了window
来使应用正常运行,若还需要从window
上将这些数据拿出来渲染,则这些累积的副作用,可能会在两次无关联的渲染中建立意外的联系。 - 重置环境:每次启动一个新的 Node.js 进程去做渲染
- 需要考虑性能问题
- 比如前端应用需要将数据挂载到全局对象
Debug
V8 Inspector: 开箱即用,特性丰富强大,与前端开发一致、跨平台
- 启动调试器:添加
–inspect
参数node --inspect
- 查看帮助:
open http://localhost:9229/json
场景:
- 查看
console.log
内容 breakpoint
logpoint
应用不会被阻塞,但会将断点数据打印出来
- 高 CPU、死循环:
cpuprofile
- 记录一段时间内的 JavaScript 运行情况
- 高内存占用:
heapsnapshot
- 性能分析
部署
部署要解决的问题:
- 守护进程:当进程退出时,重新拉起
- 多进程:
cluster
便捷地利用多进程 - 记录进程状态,用于诊断
容器环境,不必再单独使用进程管理工具
- 通常有健康检查的手段,只需考虑多核 cpu 利用率即可。