是學習 Node.js 的第七篇,這一篇主要是了解 http,同時實現一個靜態資源服務器。先看一下這個服務器有什么功能。
首先我們在命令行工具輸入 ss (意為:super server),它會幫我們在當前目錄啟動一個靜態資源服務器。服務器的地址為 「http://localhost:3000」。
當我們訪問 「http://localhost:3000」時,它把我們當前目錄的所有文件都羅列了出來。
我們點擊一個文件,例如 pacakge.json,它會把當前文件的內容顯示出來:
OK,主要功能就是這些,下面我們一起來實現一下。
可以通過 ss --port 3001 指定端口號,通過 ss --directory C:\foo\bar 指定服務器的工作目錄,即靜態資源的根目錄。
既然是服務器,那一定是使用了 Node 的 http 模塊,我們先簡單的了解下如何使用 http 創建一個服務器。
const http = require('http')
const server = http.createServer((req, res) => {
console.log('有請求過來了~~~')
})
let port = 3000
server.listen(port, () => {
console.log(`server start ${port}`)
})
使用 http.createServer 即可創建一個服務器,然后再調用 server.listen() 方法監聽一個端口,就算正式創建成功了。這時我們直接訪問 http://localhost:3000 即可在命令行看到打印 有請求過來了。
那么我們如何獲得這個請求的具體信息,并給客戶端做出相應呢?
其實,每次請求過來的時候都會執行 createServer(callback) 中傳入的回調,回調內會傳入兩個參數:「req(request) 與 res(response)」。req 就代表請求信息與相關操作,res 代表響應信息與相關操作。
我們具體來使用一下這兩個對象。
const http = require('http')
const url = require('url')
const server = http.createServer((req, res) => {
// 請求方法名
console.log(req.method)
// 請求url
console.log(req.url)
// 請求頭
console.log(req.headers)
// req 是一個可讀流
req.on('data', chunk => {
console.log(chunk)
})
req.on('end', () => {})
// 響應行->響應頭->響應體順序不能變
// 首先設置響應行(狀態碼與狀態碼描述)
res.statusCode = 200
res.statusMessage = 'success'
// 設置響應頭
res.setHeader('name', 'superYue')
// 最后設置響應體
// res 是一個可寫流
res.write('ok')
res.end('1')
})
let port = 3000
server.listen(port, () => {
console.log(`server start ${port}`)
})
此時,我們在瀏覽器訪問 http://localhost:3000 就可以下看到如下內容:
「這里有一些點需要大家注意」
「對可讀流、可寫流不清楚的同學可以看下此系列文章下的《手寫文件流》」
現在我們進入主題——「實現一個靜態資源服務器」。
先看一下我們的目錄結構。
bin 目錄是命令行邏輯代碼
src/satic-server.js 靜態資源服務器
src/template.html html 模板
我們的服務器是在命令行內輸入 ss 之后自動啟動的,我們看一下這個功能是怎么實現的?
首先,我們要在 package.json 內增加一個 bin 字段,如下代碼所示:
// pacakge.json
{
"bin": {
"ss": "./bin/www.js"
},
}
ss 是我們運行的命令,./bin/www.js 是運行 ss 后要被執行的 js 文件。
然后,./bin/www.js 內注意要添加這行代碼 #! /usr/bin/env node,這行代碼的意思是用 node 環境來執行以下代碼,這樣我們就可以盡情的去寫 Node 代碼了。
最后,我們要在當前的工作目錄去執行 npm link,這樣才能將 ss 命令注冊到全局變量中去,不然系統是不認識 ss 的。
現在我們已經可以執行 ss 命令了,理論上就可以在 bin/www.js 內去實現一個靜態服務器了,但是在真正實現之前,我想有一些定制化的功能,比如自定義啟動服務的端口號,自定義靜態服務器的工作目錄。
要實現這樣的定制化功能,那肯定是在命令行內去輸入,例如:
ss --port 3000 啟動一個 3000 端口的服務器
ss --directory C: 靜態資源服務器的根目錄是 C 盤。
然后我們要解析 ss 輸入的參數,這些參數 Node 都幫我們保存在了 process.argv 屬性里,打印出來的結果如下圖所示。
如果我們想得到正確的結果,需要我們自己去解析。這里給大家推薦一個工具——commander,它是一個完整的 node.js 命令行解決方案,github鏈接點這。
我們來看一下示例:
const { program } = require('commander)
// 聲明一個 prot 參數,要求必須有值,默認值是 3000
// 'set your server port' 是命令描述
program.option('-p, --port <v>', 'set your server port', 3000);
// 開始解析命令
program.parse(process.argv);
// 通過 program.port 拿到解析好參數
console.log(`port: ${program.port}`);
可以看到,最終我們輸入的命令都會被解析到 program 內。
這只是 commander 一部分功能,完整功能可以看具體文檔。
接下來把我 www.js 代碼貼出來
#! /usr/bin/env node
const program = require('commander')
const StaticServer = require('../src/static-server')
console.log(process.argv)
program.name('ss')
program
.option('-p, --port <v>', 'set your server port', 3000)
.option('-d, --directory <v>', 'set your server start directory', process.cwd())
program.on('--help', () => {
console.log('\nExamples:')
console.log('ss -p 3000 / ss --port 3000')
console.log('ss -d C: / ss --directory C:')
})
program.parse(process.argv)
const config = {}
config.port = program.port ?? process.cwd()
new StaticServer(config).start()
program.on('--help') 的意思是監聽 --help 命令。每當用戶輸入 ss --help 的時候,我們都把操作提示給打印出來。
在上段代碼內,我們在 www.js 里執行了 new StaticServer(config).start(),這句代碼的意思是啟動一個靜態資源服務器,接下來,我們就來實現一下這個。
首先,我們聲明一個類,并初始化參數。
class StaticServer {
constructor(config) {
this.port = config.port
this.directory = config.directory
}
}
然后,在調用 start 的時候,我們創建一個服務器。
start() {
const server = http.createServer(this.handleRequest.bind(this))
server.listen(this.port, () => {
console.log(`${chalk.yellow('Starting up super-server: ')}${this.directory}`)
console.log(`http://localhost:${chalk.green(this.port)}`)
})
}
為了更好的處理請求,我們把處理請求的邏輯全都放到了 handleRequest() 方法內。
chalk 中文意思為粉筆,是專門用來改變控制臺輸出顏色的第三方包。
這個方法是專門用來處理請求的。
我們現在想一下,當一個靜態資源的請求過來時,我們應該做什么操作?
我們看下具體代碼
async handleRequest(req, res) {
// 獲取請求路徑
// url 為 Node 的核心模塊
const { pathname } = url.parse(req.url)
// 工作目錄與請求路徑拼接,得到最終的靜態資源地址
// 這里的工作目錄默認是 process.cwd(),意思是當前代碼啟動的目錄
// 可以通過 --directory 去指定
const filePath = path.join(this.directory, pathname)
try {
// 獲取文件信息
const stat = await fs.stat(filePath)
if (stat.isFile()) {
// 如果是文件,則返回文件信息
this.sendFile(req, res, filePath, stat)
} else {
// 如果是文件夾,則返回資源列表
this.sendFolder(req, res, filePath, pathname)
}
} catch(e) {
// 返回錯誤信息
this.sendError(req, res, e)
}
}
代碼注釋非常詳細,相信不用做過多的解釋。
這里最終獲取靜態資源的地址是:請求的路徑 + 服務器工作目錄(默認是 process.cwd(),可以通過 --dircetory 去指定)
sendFile 對客戶端響應文件信息,在響應之前,要做緩存相關的操作,這些操作都放在了 cache() 方法內。
緩存包括強緩存與協商緩存,強緩存取的是瀏覽器客戶端內的內容,瀏覽器不會對服務器發起響應。協商緩存需要服務器判斷文件是否發生了變化,如果未發生變化則返回 304。
在具體返回響應之前,要設置響應內容的 mime 格式,用來告訴客戶端如何處理這段內容。例如,如果是 html 內容,那我們的 Content-Type 響應頭必須是 text/html,不然瀏覽器不能正確的解析。這里我們使用了 mime 這個第三方包,它可以根據文件后綴得到正確的 mime 類型。
響應內容的時候,我們會以流的形式去響應,所以這里我們創建了一個文件可讀流。
sendFile(req, res, filePath, stat) {
if (this.cache(req, res, filePath, stat)) {
res.statusCode = 304
res.end()
return;
}
res.setHeader('Content-Type', mime.getType(filePath))
createReadStream(filePath).pipe(res)
}
返回文件夾內的文件列表。
將文件夾內的文件全部讀取出來,并以 html 的形式返回給瀏覽器以供展示。這里使用了 ejs 模板引擎來渲染 html。
async sendFolder(req, res, filePath, pathname) {
let dirs = await fs.readdir(filePath)
dirs = dirs.map(item => ({
filename: item,
href: path.join(pathname, item)
}))
console.log(dirs)
const template = await fs.readFile(path.resolve(__dirname, './template.html'), 'utf-8')
const html = await ejs.render(template, { dirs }, { async: true })
res.setHeader('Content-Type', 'text/html;charset=utf-8')
res.end(html)
}
我們將讀取出來的文件列表傳給 template 靜態模板,然后利用 ejs 的得到渲染后的 html。
template.html 模板代碼如下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>zs</title>
</head>
<body>
<!-- 出路路徑 盡量不要采用./ ../ 絕對路徑 /a/a.js -->
<%dirs.forEach(item=>{%>
<li><a href="<%=item.href%>"><%=item.filename%></a></li>
<%})%>
</body>
</html>
cache() 方法封裝了文件緩存操作。
首先對文件應用緩存,設置 Expires 與 Cache-Control 響應頭,這兩個字段設置任何一個字段都可以實現緩存,但為了最大的保證兼容性,我們這里都做了設置。
如果瀏覽器緩存失效,會重新發起請求,這時需要服務器判斷資源是否真的被更改了,判斷文件資源的緩存是否失效有兩種方案。
但是這種做法有缺陷,假如我們將文件修改了,然后過一會又修改成原來的內容,這時最終的文件是沒有變化的,但是文件的修改時間卻變了,這樣就導致緩存失效。
cache(req, res, filePath, stat) {
res.setHeader('Expires', new Date(Date.now() + 10 * 1000).toGMTString())
res.setHeader('Cache-Control', `max-age=${10}`)
const ifModifiedSince = req.headers['if-modified-since']
const ctime = stat.ctime.toGMTString()
if (ifModifiedSince !== ctime) {
return false
}
const ifNoneMatch = req.headers['if-none-match']
// 利用 MD5 生成文件摘要
// crypto 為內置的加密算法
const etag = crypto.createHash('md5').update( readFileSync(filePath)).digest('base64')
if (ifNoneMatch !== etag) {
return false
}
res.setHeader('Last-Modified', ctime)
res.setHeader('Etag', etag)
return true;
}
const http = require('http')
const url = require('url')
const fs = require('fs').promises
const path = require('path')
const { createReadStream, readFileSync } = require('fs')
const crypto = require('crypto')
const chalk = require('chalk')
const mime = require('mime')
const ejs = require('ejs')
class StaticServer {
constructor(config) {
this.port = config.port
this.directory = config.directory
}
start() {
const server = http.createServer(this.handleRequest.bind(this))
server.listen(this.port, () => {
console.log(`${chalk.yellow('Starting up super-server: ')}${this.directory}`)
console.log(`http://localhost:${chalk.green(this.port)}`)
})
}
async handleRequest(req, res) {
const { pathname } = url.parse(req.url)
const filePath = path.join(this.directory, pathname)
console.log(filePath)
try {
const stat = await fs.stat(filePath)
if (stat.isFile()) {
this.sendFile(req, res, filePath, stat)
} else {
this.sendFolder(req, res, filePath, pathname)
}
} catch(e) {
this.sendError(req, res, e)
}
}
cache(req, res, filePath, stat) {
res.setHeader('Expires', new Date(Date.now() + 10 * 1000).toGMTString())
res.setHeader('Cache-Control', `max-age=${10}`)
const ifModifiedSince = req.headers['if-modified-since']
const ctime = stat.ctime.toGMTString()
if (ifModifiedSince === ctime) {
return true
}
const ifNoneMatch = req.headers['if-none-match']
const etag = crypto.createHash('md5').update( readFileSync(filePath)).digest('base64')
if (ifNoneMatch === etag) {
return true
}
res.setHeader('Last-Modified', ctime)
res.setHeader('Etag', etag)
return false;
}
sendFile(req, res, filePath, stat) {
if (this.cache(req, res, filePath, stat)) {
res.statusCode = 304
res.end()
return;
}
res.setHeader('Content-Type', mime.getType(filePath))
createReadStream(filePath).pipe(res)
}
async sendFolder(req, res, filePath, pathname) {
let dirs = await fs.readdir(filePath)
dirs = dirs.map(item => ({
filename: item,
href: path.join(pathname, item)
}))
console.log(dirs)
const template = await fs.readFile(path.resolve(__dirname, './template.html'), 'utf-8')
const html = await ejs.render(template, { dirs }, { async: true })
res.setHeader('Content-Type', 'text/html;charset=utf-8')
res.end(html)
}
sendError(req, res, e) {
res.end(e.message)
}
}
module.exports = StaticServer
可以看到,這個靜態資源服務器并不是特別復雜,但是它卻給我們帶來了不少知識點。
希望這篇文章可以給大家帶來一些收獲~~~
也可以看下這一系列的其它文章~~~
模板完全靜態化,也就是通過模板完全生成純靜態的網頁,相比動態頁面和偽靜態頁面更安全更利于SEO訪問更快。相比前二者各有利弊吧,現在稍微對這三種形式的優缺點對比一下,以及在ThinkPHP5項目中實現完全靜態化的基本過程。
1. 動態與真靜態
頁面靜態化與動態頁的對比,靜態沒有了SQL和一些后端腳本運行,安全穩定,訪問速度快,對SEO友好(網上也有說現在的搜索引擎已經對動態網頁的抓取沒什么壓力了),但是搜索引擎再強大,靜態的URL也比動態的后面帶問號冒號什么的要好看,不對SEO友好對普通瀏覽用戶者也是友好(好看第一)。但是生成靜態頁面的弊端,也就是如果一個博客網站,隨著文章內容的增多,那生成的頁面也不斷增多,就算一個html就30幾Kb,數量多的情況下也挺耗存儲空間,網上也有說頻繁生成靜態頁面化,容易讓硬盤出現壞道。這個我的看法是不好測試可以忽略,因為現在多數是使用云服務器或云虛擬主機,那些都不是物理硬件,就算太過碎片導致硬盤損壞,網站也能正常訪問的,因為那是云服務器。
2. 真靜態與偽靜態
這二者的對比看起來像是正統之爭,因為大家都知道偽靜態還是動態頁,只是Apache通過URL重寫規則讓其變成了像靜態網頁的樣子。主要也是讓自己對SEO友好,但是相比真靜態多了Apache的步驟,所以也就比較耗費一些服務器的資源。而真靜態的缺點上面也說了,在項目中的選擇看需求,各有利弊,北橋蘇的使用主要是自己網站有時要優化一下速度所以就做了模板靜態化,以下是操作過程。
1. 根據模塊控制器自動遞歸創建目錄。
2. file_exists判斷生成的靜態頁是否存在
3. 或判斷過期與否,存在重定向到靜態網頁
4. file_put_contents($file,$content)函數生成頁面。
1. 目錄的創建
/* * 遞歸創建目錄 * @param string $dir 文件目錄路徑 * @return boolean 創建結果 * **/ function mkdirs($dir) { if(!is_dir($dir)) { if(!mkdirs(dirname($dir))){ return false; } if(!mkdir($dir,0777)){ return false; } } return true; }
2. 在基類中初始化需創建的目錄
protected $staticHtmlDir=""; //靜態模板生成目錄 protected $staticHtmlFile=""; //靜態文件 protected function _initialize() { parent::_initialize(); $this->staticHtmlDir="html".DS.$this->request->controller().DS; //……………………………………………………………………
3. 基類中的生成前與生成后的方法。
//判斷是否存在靜態 public function beforeBuild($param) { //生成靜態 //$baseDir="html".DS.$this->request->controller().DS; if(is_array($param)) { $param=implode("_",$param); } $this->staticHtmlFile=$this->staticHtmlDir.$this->request->action().($param?$param:'').'.html'; if(mkdirs($this->staticHtmlDir)) { if(file_exists($this->staticHtmlFile) && filectime($this->staticHtmlFile)>=time()-60*60*24*5) { //靜態文件存在 $this->redirect('/'.$this->staticHtmlFile); } } } //開始生成靜態文件 public function afterBuild($html) { if(!empty($this->staticHtmlFile) && !empty($html)) { if(file_exists($this->staticHtmlFile)) { unlinnk($this->staticHtmlFile); } if(file_put_contents($this->staticHtmlFile,$html)) { $this->redirect('/'.$this->staticHtmlFile); } } }
4. 視圖控制器中的使用。
ThinkPHP5中fetch方法返回給file_put_contents函數作為content就可以生成一個完整的靜態頁面了。
態站點生成器是使用手工編碼靜態站點和完整CMS(內容管理系統)之間的折衷方案,同時保留兩者的優點。本質上,你可以使用類似CMS的概念(如模板)生成基于靜態HTML頁面的網站。可以從數據庫、標記文件、API或任何實際存儲位置提取內容。想對靜態站點生成器有更深的認識和了解,可以通過參加Web前端培訓來學習,在老師的教導下,你會獲得更大的進步。
下面是3個常用的靜態站點生成器的比較,他們各有特點,通過比較,你能知道什么時候使用它們。
1、Next.js
Next.js是一個用于靜態導出React應用程序的免費開源框架。特點包括:
預渲染(下一步支持服務器端渲染)
零配置
擴展性
JS中的CSS
很棒的文檔
2、Gatsby
Gatsby是一個基于React的免費開源框架,有助于開發人員創建速度極快的網站和應用程序。在Web前端培訓中,有很多課程讓你學習Gatsby以及其他靜態站點生成器的使用,老師面對面教學指導,及時解決疑難雜癥,讓你獲得快速提升。
Gatsby提供了大量功能,如:
CSS和JavaScript的現代力量
豐富的數據插件生態系統
漸進式web應用程序生成
超級容易部署
起動器,或根據不同用例定制的預包裝Gatsby站點
3、Hugo
Hugo是專門用來提高速度的,它在毫秒之間產生網站。由于其速度快和各種內置功能,你會發現Hugo常被用于生成博客和文檔。它得到了廣泛的應用,并繼續得到改善。Hugo的特點有:
速度非常快,任何東西都無法與之匹敵
有很多內置的功能,幾乎不需要第三方插件
很容易搞定
有適當的文檔
模板語言并不難學
靜態站點生成器使靜態網站更易維護和制作,如果你是做前端的,那么學會一些靜態站點生成器的使用是很有必要的。想學習的同學可以考慮報名參加Web前端培訓,通過理論課程和實操項目的訓練,在短時間就能學到有用的知識和技能。
了解更多
*請認真填寫需求信息,我們會在24小時內與您取得聯系。