[TOC]
# 第 8 章 構建 Web 應用
## Cookie
Cookie 的處理分為如下幾步:
- 服務器向客戶端發送 Cookie
- 瀏覽器將 Cookie 保存
- 之后每次瀏覽器都會將 Cookie 發向服務器端
Cookie 是被放在請求頭中的而不是請求體中,原生 Node 可以通過 req.headers.cookie 來獲取到。
我們來看下設置 Cookie 的 Set-Cookie 字段:
```js
Set-Cookie: name=value; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;
```
其中 name = value 是必須包含的部分,其余部分皆是可選參數。
- path:表示這個 Cookie 影響到的路徑,當前訪問的路徑不滿足該匹配時,瀏覽器則不發送這個 Cookie
- Expires 和 Max-Age:告知瀏覽器這個 Cookie 何時過期,如果不設置該選項,在關閉瀏覽時會丟失掉這個 Cookie。如果設置了過期時間,瀏覽器會把 Cookie 內容寫入到磁盤并保存,下次打開瀏覽器依舊有效。Expires 的值是一個 UTC 格式的時間字符串,告知瀏覽器此 Cookie 何時將過期,Max-Age 則告知瀏覽器此 Cookie 多久后過期。如果服務器端和客戶端的時間不匹配,使用 Expires 就會存在偏差,為此可以使用 Max-Age。
- HttpOnly:告知瀏覽器不允許通過腳本 document.cookie 去更改這個 Cookie 值,事實上,設置 HttpOnly 后,這個值在 document.cookie 中不可見,但是在 HTTP 請求的過程中,依然會發送這個 Cookie 到服務器端。
- Secure:當 Secure 值為 true 時,在 HTTP 中是無效的,在 HTTPS 中才有效,表示創建的 Cookie 只能在 HTTPS 連接中被瀏覽器傳遞到服務器端進行會話驗證,如果是 HTTP 連接則不會傳遞該信息。
```js
// 封裝方法快速設置 Cookie
/**
*
* @param {*} name 該 Cookie 的鍵
* @param {*} val 該 Cookie 的值
* @param {*} opt 可選參數
*/
const serialize = function (name, val, opt) {
const pairs = [`${name}=${val}`]
opt = opt || {}
if (opt.maxAge) pairs.push(`Max-Age=${opt.maxAge}`)
if (opt.domain) pairs.push(`Domain=${opt.domain}`)
if (opt.path) pairs.push(`Path=${opt.path}`)
if (opt.expires) pairs.push(`Expires=${opt.expires}`)
if (opt.httpOnly) pairs.push(`HttpOnly`)
if (opt.secure) pairs.push(`Secure`)
return pairs.join('; ')
}
const handler = function (req, res) {
if (!req.cookies.isVisit) {
res.setHeader('Set-Cookie', serialize('isVisit', 1))
res.writeHead(200)
res.end('歡迎第一次到來')
} else {
res.writeHead(200)
res.end('再次歡迎你')
}
}
// res.setHeader 的第二個參數可以是一個數組
res.setHeader('Set-Cookie', [serialize('foo', 'bar'), serialize('baz', 'val')])
// 這會在報文頭部形成兩條 Set-Cookie 字段
// Set-Cookie: foo=bar; Path=/; Expires= ...; Domain=.domain.com
// Set-Cookie: baz=val; Path=/; Expires= ...; Domain=.domain.com
```
## Cookie 的性能影響:
一旦 Cookie 設置過多,將會導致報頭較大,大多數的 Cookie 并不需要每次都用上,如果在域名的根節點設置 Cookie(Path = /),幾乎所有子路徑下的請求都會帶上這些 Cookie,而它們在有些情況下是無用的,比如靜態文件。
- 將靜態文件放在不同的域名下,使得業務相關的 Cookie 不再影響靜態資源。
- 使用額外的域名的好處是減少了無效 Cookie 的傳輸,還可以突破瀏覽器下載線程數量的限制。缺點是 ¥ 以及額外的一次 DNS 查詢
## Session
Cookie 最嚴重的的問題就是前后端都可以進行修改,Session 的數據只保留在服務器端,客戶端無法修改,但是仍然需要使用 Cookie 實現用戶和數據的映射,一旦服務器端啟用了 Session,它將約定一個鍵值作為 Session 的口令。
一旦服務器檢查到用戶請求 Cookie 中沒有攜帶該值,它就會為之生成一個值,這個值是唯一且不重復的值,并設定超時時間。
PS:以下代碼為原生 node.js 實現的,現在一般使用 express-session 插件配置一下就可以實現相同的功能(我用的時候有 bug 搞不定),所以有時候還是了解下原生實現的好。另外,這里的 session 是存在內存中的,現在一般存在 redis。
```js
const sessions = {}
const key = 'session_id'
const EXPIRES = 20 * 60 * 1000
const generate = function () {
const session = {}
session.id = (new Date()).getTime() + Math.random()
session.cookie = {
expires: (new Date()).getTime() + EXPIRES
}
sessions[session.id] = session
return session
}
function (req, res) {
const id = req.cookies[key]
if (!id) {
req.session = generate()
} else {
const session = sessions[id]
if (session) {
if (session.cookie.expire > (new Date()).getTime()) {
// 更新超時時間
session.cookie.expire = (new Date()).getTime() + EXPIRES
req.session = session
} else {
// 超時了,刪除舊的數據,并重新生成
delete session[id]
req.session = generate()
}
} else {
// 如果 sesion 過期或口令不對,重新生成 session
req.session = generate()
}
}
handler(req, res)
}
// 我們還需要再響應時添加相應頭部
// hack 響應對象的 writeHead() 方法
let writeHead = res.writeHead
res.writeHead = function () {
const cookies = res.getHeader('Set-Cookie')
const session = serialize('Set-Cookie', req.session.id)
cookies = Array.isArray(cookies) ? cookies.concat(session) : [cookies, session]
res.setHeader('Set-Cookie', cookies)
return writeHead.apply(this, arguments)
}
// 業務邏輯
const handler = function (req, res) {
if (!req.session.isVisit) {
res.session.isVisit = true
res.writeHead(200)
res.end('歡迎第一次來到動物園')
} else {
res.writeHead(200)
res.end('動物園再次歡迎你')
}
}
```
## 緩存
緩存需要瀏覽器與服務器共同協作來完成。
通常來說 POST、DELETE、PUT 這類待行為性的請求操作一般不做任何緩存,大多數緩存只應用在 GET 請求中。
本地沒有文件時,瀏覽器必然會請求服務器端的內容,并將這部分內容放置在本地的某個緩存目錄中。在第二次請求時,它將對本地文件進行檢查,如果不能確定這份本地文件是否可以直接使用,它將會發起一次條件請求。所謂條件請求,就是在普通的 GET 請求報文中附帶 If-Modified-Since 字段,如下所示:
If-Modified-Since: Sun, 03 Feb 2019 06:01;12 GMT
它將詢問服務器端是否有更新的版本,本地文件的最后修改時間。如果服務器端沒有新的版本,只需響應一個 304 狀態碼,客戶端就使用本地版本;如果服務器有新版本,就將新的內容發送給客戶端,客戶端放棄本地版本,代碼如下:
```js
const handler = function (req, res) {
fs.stat(filename, function (err, stat) {
const lastModified = stat.mtime.toUTCString()
if (lastModified === req.headers['if-modified-since']) {
res.writeHead(304, 'Not Modified')
res.end()
} else {
fs.readFile(filename, function (err, file) {
const lastModified = stat.mtime.toUTCString()
res.setHeader('Last-Modified', lastModified)
res.writeHead(200, 'OK')
res.end(file)
})
}
})
}
```
瀏覽器在收到 Etag:"83-13591232132"這樣的響應后,下次請求會將其放置在請求頭中:If-None-Match:"83-13591232132"
設置 Expires 或 Cache-Control 頭,瀏覽器就可以不向服務器發送 HTTP 請求而知曉是否直接使用本地版本。
其區別之前已經提到過,Expires 可能會出現服務器端和瀏覽器端時間不同步的情況,Cache-Control 設置 max-age 是倒計時的方式。
如果同時設置了 max-age 和 Expires,max-age 會覆蓋 Expires。
```js
const handler = function (req, res) {
fs.readFile(filename, function (err, file) {
const expires = new Date()
expires.setTime(expires.getTime() + 10 * 365 * 24 * 60 * 60 * 1000)
res.setHeader('Expires', expires.toUTCString())
res.writeHead(200, 'OK')
res.end(file)
})
}
const handelr = function (req, res) {
fs.readFile(filename, function (err, file) {
res.setHeader('Cache-Control', 'max-age=' + 10 * 365 * 24 * 60 * 60 * 1000)
res.writeHead(200, 'OK')
res.end(file)
})
}
```
> 如果使用 Nginx 做靜態資源服務器就看看 Nginx 的緩存配置即可
## 清除緩存
緩存一旦設定,當服務端意外更新內容時,卻無法通知客戶端更新。一般有兩種更新機制:
- 每次發布,路徑中跟隨 Web 應用的版本號:`http://url.com?v=20190502`
- 每次發布,路徑中跟隨該文件內容的 hash 值:`http://url.com?hash=sadsadsa`
一般采用的是第二種方式(所以 webpack 打包生成的文件都要哈希啊)
## MVC
MVC 模型的主要思想是將業務邏輯按職責分離
- 控制器(Controller),一組行為的集合
- 模型(Model),數據相關的操作和封裝
- 視圖(View),視圖的渲染
它的工作模式如下:
- 路由解析,根據 URL 尋找到對應的控制器和行為
- 行為調用相關的模型,進行數據操作
- 數據操作結束后,調用視圖和相關數據進行頁面渲染,輸出到客戶端

## RESTFUL
REST 全稱是 Representational State Transfer,它是一個關于 URL 的設計規范
比如我們過去對用戶的增刪改查或許是這么設計 URL 的:
```shell
POST /user/add?username=jacksontian
GET /user/remove?username=jacksontian
POST /user/update/username=jacksontian
GET /user/get?username=jacksontian
```
在 RESTFUL 設計中,它應該是這樣的:
```shell
POST /user/username=jacksontian
DELETE /user/username=jacksontian
PUT /user/username=jacksontian
GET /user/username=jacksontian
```
過去設計資源的格式與后綴有很大的關聯,比如:
```shell
GET /user/jacksontian.json
GET /user/jacksontian.xml
```
在 RESTFUL 設計中,資源的具體格式由請求報頭中的 Accept 字段和服務器端的支持情況來決定。如果客戶端同時接受 JSON 和 XML 格式的響應,那么它的 Accept 字段值是如下這樣的:
`Accept: application/json,application/xml`
靠譜的服務器應該要顧及這個字段,然后根據自己能響應的格式做出響應,在響應報文中,通過 Content-Type 字段告知客戶端是什么格式,如下
`Content-Type: application/json`
所以 RESTful 的設計就是:**通過 URL 設計資源,請求方法定義資源的操作,通過 Accept 決定資源的表現形式**
## 中間件
中間件的行為類似 Java 中過濾器的工作原理,就是在進入具體的業務處理之前,先讓過濾器處理,比如對于每個請求我們一般都要解析 cookie,querystring 什么的,那么就設計對應的中間件處理完成后存儲在上下文中(req 和 res,Koa2 合并為一個 context)

```js
// 模擬中間件的實現
const http = require('http')
const slice = Array.prototype.slice
class LikeExpress {
constructor() {
// 存放中間件的列表
this.routes = {
all: [], // 存放 app.use 注冊的中間件
get: [], // 存放 app.get 注冊的中間件
post: []
}
}
// 內部實現注冊的方法
register(path) {
const info = {}
if (typeof path === 'string') { // 字符串 - 路由
info.path = path
// 從第二個參數開始,轉換為數組,存入stack
info.stack = slice.call(arguments, 1) // 取出剩余的參數
} else { // 沒有顯式地傳入路由則默認是根路由
info.path = '/' // 省略第一個參數 -> 根目錄
// 從第一個參數開始,轉換為數組,存入stack
info.stack = slice.call(arguments, 0)
}
// { path: '', stack: [middleware, ...] }
return info
}
use() {
const info = this.register.apply(this, arguments) // 把當前函數的所有參數傳入
this.routes.all.push(info)
}
get() {
const info = this.register.apply(this, arguments) // 把當前函數的所有參數傳入
this.routes.get.push(info)
}
post() {
const info = this.register.apply(this, arguments) // 把當前函數的所有參數傳入
this.routes.post.push(info)
}
match(method, url) {
let stack = [] // resultList
if (url === '/favicon.ico') { // 小圖標無視
return stack
}
// 獲取 routes
let curRoutes = []
curRoutes = curRoutes.concat(this.routes.all)
curRoutes = curRoutes.concat(this.routes[method])
curRoutes.forEach(routeInfo => {
if (url.indexOf(routeInfo.path === 0)) {
// url === '/api/get-cookie' 且 routeInfo.path === '/'
// url === '/api/get-cookie' 且 routeInfo.path === '/api'
// url === '/api/get-cookie' 且 routeInfo.path === '/api/get-cookie'
stack = stack.concat(routeInfo.stack)
}
})
return stack
}
// 核心的 next 機制
handle(req, res, stack) {
const next = () => {
// 拿到第一個匹配的中間件
const middleware = stack.shift()
if (middleware) {
// 執行中間件函數
middleware(req, res, next)
}
}
next()
}
callback() {
return (req, res) => {
// 自己定義 res.json 方法
res.json = data => {
res.setHeader('Content-Type', 'application/json')
res.end(
JSON.stringify(data)
)
}
// 獲取 url 和 method :通過這兩個來獲得需要經過的中間件
const url = req.url
const method = req.method.toLowerCase()
// match 函數匹配可用的中間件列表
const resultList = this.match(url, method)
this.handle(req, res, resultList)
}
}
listen(...args) {
const server = http.createServer(this.callback)
server.listen(...args)
}
}
// 工廠函數
module.exports = () => {
return new LikeExpress()
}
```
# 第 9 章 玩轉進程
## 服務模型的變遷
<span style="font-famil: 楷體; font-size: 20px;" >石器時代:同步</span>
最早的服務器,其執行模型是同步的,其服務模式是一次只為一個請求服務,所有請求都得按次序等待服務。這意味著除了當前的請求被處理外,其余請求都處于耽誤的狀態。這類架構如今已基本被淘汰,只在一些無并發要求的應用中存在。
<span style="font-famil: 楷體; font-size: 20px;" >青銅時代:復制進程</span>
通過進程的復制同時服務更多的請求和用戶。這樣每個連接都需要一個進程來服務,即 100 個連接需要啟動 100 個進程來進行服務,這是非常昂貴的代價。在復制進程的過程中,需要復制進程內部的狀態,對于每個連接都進行這樣的復制的話,相同的狀態將會在內存中存在很多份,造成浪費。并且這個過程由于要復制較多的數據,啟動是較為緩慢的。
為了解決啟動緩慢的問題,預復制(prefork)被引入服務模型中,即預先復制一定數量的進程。同時將進程復用,避免進程創建、銷毀帶來的開銷。但是這個模型不具備伸縮性,一旦并發請求過高,內存使用隨著進程數的增長將會被耗盡。
<span style="font-famil: 楷體; font-size: 20px;" >白銀時代:多線程</span>
為了解決進程復制中的浪費問題,多線程被引入服務模型,讓一個線程服務一個請求。線程相對進程的開銷要小許多,并且線程之間可以共享數據,內存浪費的問題可以得到解決,并且利用線程池可以減少創建和銷毀線程的開銷。但是多線程所面臨的并發問題只能說比多進程略好,因為每個縣城都擁有自己獨立的堆棧,這個堆棧需要占用一定的內存空間。另外,由于一個 CPU 核心在一個時刻只能做一件事,操作系統只能通過將 CPU 切分為時間片的方法,讓線程可以較為均勻地使用 CPU 資源,但是操作系統內核在切換線程的同時也要切換線程的上下文,當線程數量過多時,時間將會被耗用在上下文切換中。所以在大并發量時,多線程結構還是無法做到強大的伸縮性。
<span style="font-famil: 楷體; font-size: 20px;" >黃金時代:事件驅動</span>
Node 與 Nginx 均是基于事件驅動的方式實現的,采用單線程避免了不必要的內存開銷和上下文切換開銷。
基于事件的服務模型存在的問題主要由兩個:CPU 的利用率和進程的健壯性,對于 node 來說,所有請求的上下文都是統一的,它的穩定性是亟待解決的問題。
由于所有處理都在單線程上進行,CPU 的計算能力的上限決定了這類服務模型的性能上線,但它不受多進程或多線程模型中資源上限的影響,可伸縮性遠比前兩者高。
## 創建子進程
child_process 模塊給予 Node 可以隨意創建子進程的能力,它提供 4 個方法用于創建子進程:
- spawn():啟動一個子進程來執行命令
- exec():啟動一個子進程來執行命令,與 spawn() 不同的是其接口不同,它有一個回調函數獲知子進程的狀況
- execFile():啟動一個子進程來執行可執行文件
- fork():與 spawn() 類似,不同點在于它創建 Node 的子進程只需指定要執行的 JavaScript 文件模塊即可
spawn() 與 exec()、execFile() 不同的是,后兩者創建時可以指定 timeout 屬性設置超時時間,一旦創建的進程運行超過設定的時間將會被殺死。
exec() 與 execFile() 不同的是,exec() 適合執行已有的命令,execFile() 適合執行文件。
```js
var cp = require('child_process')
cp.spawn('node', ['worker.js'])
cp.exec('node worker.js', function (err, stdout, stderr) {
// some code
})
cp.execFile('worker.js', function (err, stdout, stderr) {
// some code
})
cp.fork('./worker.js')
```
| 類型 | 回調/異常 | 進程類型 | 執行類型 | 可設置超時 |
| :---: | :---: | :---:| :---: | :---: |
| spawn() | × | 任意 | 命令 | × |
| exec() | √| 任意 | 命令 | √|
| execFile() | √| 任意 | 可執行文件| √|
| fork() | × | Node| JavaScript 文件| × |
這里的可執行文件是指可以直接執行的文件,如果是 JavaScript 文件通過 execFile() 運行,它的首行必須添加如下代碼:
```shell
#!/usr/bin/env node
```
## 進程間通信
在前端瀏覽器中,JavaScript 主線程與 UI 渲染線程是互相阻塞的,長時間執行 JavaScript 會造成 UI 停頓不響應,為了解決這個問題,HTML5 提出了 WebWorker API。WebWorker 允許創建工作線程并在后臺運行,使得一些阻塞較為嚴重的計算不影響主線程上的 UI 渲染,它的簡單用法如下:
```js
var worker = new Worker('worker.js')
worker.onmessage = function (event) {
document.getElementById('result').textContent = event.data
}
// worker.js
var n = 1
search: while (true) {
n += 1
for (var i = 2; i <= Math.sqrt(n); i += 1)
if (n % i === 0)
continue search
// found a prime
postMessage(n)
}
```
具體的使用可以閱讀:[Web Worker 使用教程](http://www.ruanyifeng.com/blog/2018/07/web-worker.html)
Node 中對應示例如下:
```js
// parent.js
var cp = require('child_process')
var n = cp.fork(__dirname + '/sub.js')
n.on('message', function (m) {
console.log('PARENT got message: ', m)
})
n.send({ hello: 'world' })
// sub.js
process.on('message', function (m) {
console.log('CHILD got message: ', m)
})
process.send({ foo: 'bar' })
```
通過 fork() 或其他 API,創建子進程之后,為了實現父子進程之間的通信,父進程與子進程之間將會創建 IPC 通道,通過 IPC 通道,父子進程之間才能通過 message 和 send() 傳遞消息。
IPC 全稱是 Inter-Process Communication,即進程間通信。進程間通信的目的是為了讓不同的進程能夠互相訪問資源并進行協調工作,實現進程間通信的技術有很多,如命名管道、匿名管道、socket、共享內存、消息隊列、Domain Socket、信號量等。Node 實現 IPC 通道的是管道(pipe)技術,但此管道非彼管道,在 Node 中管道是一個抽象層面的稱呼,具體細節實現由 libuv 提供。
>TODO:child_process 模塊實現單機集群
## Cluster 模塊
通過 child_process 實現單機集群要注意比較多的細節問題,因此 node 提供了 cluster 模塊來解決多核 CPU 的利用率問題
```js
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
console.log(`主進程 ${process.pid} 正在運行`);
// 衍生工作進程。
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`工作進程 ${worker.process.pid} 已退出`);
});
} else {
// 工作進程可以共享任何 TCP 連接。
// 在本例子中,共享的是 HTTP 服務器。
http.createServer((req, res) => {
res.writeHead(200);
res.end('你好世界\n');
}).listen(8000);
console.log(`工作進程 ${process.pid} 已啟動`);
}
```
運行代碼,則工作進程會共享 8000 端口:
```txt
$ node server.js
主進程 3596 正在運行
工作進程 4324 已啟動
工作進程 4520 已啟動
工作進程 6056 已啟動
工作進程 5644 已啟動
```
- 序言 & 更新日志
- H5
- Canvas
- 序言
- Part1-直線、矩形、多邊形
- Part2-曲線圖形
- Part3-線條操作
- Part4-文本操作
- Part5-圖像操作
- Part6-變形操作
- Part7-像素操作
- Part8-漸變與陰影
- Part9-路徑與狀態
- Part10-物理動畫
- Part11-邊界檢測
- Part12-碰撞檢測
- Part13-用戶交互
- Part14-高級動畫
- CSS
- SCSS
- codePen
- 速查表
- 面試題
- 《CSS Secrets》
- SVG
- 移動端適配
- 濾鏡(filter)的使用
- JS
- 基礎概念
- 作用域、作用域鏈、閉包
- this
- 原型與繼承
- 數組、字符串、Map、Set方法整理
- 垃圾回收機制
- DOM
- BOM
- 事件循環
- 嚴格模式
- 正則表達式
- ES6部分
- 設計模式
- AJAX
- 模塊化
- 讀冴羽博客筆記
- 第一部分總結-深入JS系列
- 第二部分總結-專題系列
- 第三部分總結-ES6系列
- 網絡請求中的數據類型
- 事件
- 表單
- 函數式編程
- Tips
- JS-Coding
- Framework
- Vue
- 書寫規范
- 基礎
- vue-router & vuex
- 深入淺出 Vue
- 響應式原理及其他
- new Vue 發生了什么
- 組件化
- 編譯流程
- Vue Router
- Vuex
- 前端路由的簡單實現
- React
- 基礎
- 書寫規范
- Redux & react-router
- immutable.js
- CSS 管理
- React 16新特性-Fiber 與 Hook
- 《深入淺出React和Redux》筆記
- 前半部分
- 后半部分
- react-transition-group
- Vue 與 React 的對比
- 工程化與架構
- Hybird
- React Native
- 新手上路
- 內置組件
- 常用插件
- 問題記錄
- Echarts
- 基礎
- Electron
- 序言
- 配置 Electron 開發環境 & 基礎概念
- React + TypeScript 仿 Antd
- TypeScript 基礎
- React + ts
- 樣式設計
- 組件測試
- 圖標解決方案
- Storybook 的使用
- Input 組件
- 在線 mock server
- 打包與發布
- Algorithm
- 排序算法及常見問題
- 劍指 offer
- 動態規劃
- DataStruct
- 概述
- 樹
- 鏈表
- Network
- Performance
- Webpack
- PWA
- Browser
- Safety
- 微信小程序
- mpvue 課程實戰記錄
- 服務器
- 操作系統基礎知識
- Linux
- Nginx
- redis
- node.js
- 基礎及原生模塊
- express框架
- node.js操作數據庫
- 《深入淺出 node.js》筆記
- 前半部分
- 后半部分
- 數據庫
- SQL
- 面試題收集
- 智力題
- 面試題精選1
- 面試題精選2
- 問答篇
- 2025面試題收集
- Other
- markdown 書寫
- Git
- LaTex 常用命令
- Bugs