簡介
本文介紹了一個簡單的靜態資源伺服器的例項專案,希望能給Node.js初學者帶來幫助。專案涉及到http、fs、url、path、zlib、process、child_process等模組,涵蓋大量常用api;還包括了基於http協議的快取策略選取、gzip壓縮優化等;最終我們會發布到npm上,做成一個可以全域性安裝、使用的小工具。麻雀雖小,五臟俱全,一想是不是還有點小激動?話不多說,放碼過來。
文中原始碼地址在最後附錄中。
可先行體驗專案效果:
安裝:npm i -g here11
任意資料夾地址輸入命令:here
step1 新建專案
因為我們要釋出到npm上,所以我們先按照國際慣例,npm init,走你!在命令列可以一路回車,有些配置會在最後的釋出步驟中細說。
目錄結構如下:
bin資料夾存放我們的執行程式碼,web作為一個測試資料夾,裡面放了些網頁。
step2 碼碼
step2.1 雛形
靜態資源伺服器,通俗講就是我們在瀏覽器位址列輸入形如“http://域名/test/index.html”的一個地址,伺服器從根目錄下的對應資料夾找到index.html,讀出檔案內容並返回給瀏覽器,瀏覽器渲染給使用者。
const http = require("http");
const url = require("url");
const fs = require("fs");
const path = require("path");
const item = (name, parentPath) => {
let path = parentPath = `${parentPath}/${name}`.slice(1);
return `<div><a href="${path}">${name}</a></div>`;
}
const list = (arr, parentPath) => {
return arr.map(name => item(name, parentPath)).join("");
}
const server = http.createServer((req, res) => {
let _path = url.parse(req.url).pathname;//去掉search
let parentPath = _path;
_path = path.join(__dirname, _path);
try {
//拿到路徑所對應的檔案描述物件
let stats = fs.statSync(_path);
if (stats.isFile()) {
//是檔案,返回檔案內容
let file = fs.readFileSync(_path);
res.end(file);
} else if (stats.isDirectory()) {
//是目錄,返回目錄列表,讓使用者可以繼續點選
let dirArray = fs.readdirSync(_path);
res.end(list(dirArray, parentPath));
} else {
res.end();
}
} catch (err) {
res.writeHead(404, "Not Found");
res.end();
}
});
const port = 2234;
const hostname = "127.0.0.1";
server.listen(port, hostname, () => {
console.log(`server is running on http://${hostname}:${port}`);
});
以上這段code就是我們的核心程式碼了,已經實現了核心功能,本地執行即可看到返回了檔案目錄,點選檔名便可瀏覽對應的網頁、圖片、文字啦。
step2.2 優化
功能實現了,但是我們可以在某些方面做做優化,提升實用性,順便多學習幾個api(裝逼技巧)。
1. stream
我們目前讀取檔案返回給瀏覽器的操作是通過readFile一次性讀出來,一次性返回,這樣當然可以實現功能,但我們有更好的方式——用stream(流)進行IO操作。stream並不是node.js獨有的概念,而是作業系統最基本的一種操作形式,所以理論上講,任何一門server端語言都實現了stream的API。
為什麼講用stream是一種更好的方式?因為一次性讀取、操作大檔案,記憶體和網路是吃不消的,尤其在使用者訪問量比較大的情況下更為明顯;而藉助stream可以讓資料流動起來,一點一點操作,從而提升效能。程式碼修改如下:
if (stats.isFile()) {
//是檔案,返回檔案內容
//在createServer時傳入的回撥函式被新增到了"request"事件上,回撥函式的兩個形參req和res
//分別為http.IncomingMessage物件和http.ServerResponse物件
//並且它們都實現了流介面
let readStream = fs.createReadStream(_path);
readStream.pipe(res);
}
編碼實現非常簡單,在需要返回檔案內容時,我們建立了一個可讀流,並把它直接導向了res物件。
2. gzip壓縮
gzip壓縮帶來的效能(使用者訪問體驗)提升是非常明顯的,尤其在當下spa應用大行其道的時代,開啟gzip壓縮,可以大幅減小js、css等檔案資源的體積,提升使用者訪問速度。作為一個靜態資源伺服器,我們當然要加上這個功能。
node中有一個zlib的模組,提供了很多壓縮相關的api,我們就用它來實現:
const zlib = require("zlib");
if (stats.isFile()) {
//是檔案,返回檔案內容
res.setHeader("content-encoding", "gzip");
const gzip = zlib.createGzip();
let readStream = fs.createReadStream(_path);
readStream.pipe(gzip).pipe(res);
}
有了stream的使用經驗,我們再看這段程式碼的時候就好理解多了。把檔案流先導向gzip物件,再導向res物件。此外,使用gzip壓縮的時候還需要注意一點:需要把響應頭裡的content-encoding設定為gzip。否則瀏覽器會把一堆亂碼展示出來。
3. http快取
快取這個東西讓人又愛又恨,用得好,可以提升使用者體驗,減輕伺服器壓力;用得不好,可能就會面臨各種各樣奇奇怪怪的問題。一般來講瀏覽器http快取分為強快取(非驗證性快取)和協商快取(驗證性快取)。
什麼叫強快取呢?強快取是由cache-control和expires兩個首部欄位控制的,現在一般用cache-control。比如我們設定了cache-control: max-age=31536000的響應頭,就是告訴瀏覽器這個資源有一年的快取期,一年內不用向服務端傳送請求,直接從快取中讀取資源。
而協商性快取是使用if-modified-since/last-modified、if-none-match/etag等首部欄位,配合強快取,在強快取沒有命中(或告知瀏覽器no-cache)的時候,向伺服器傳送請求,確認資源的有效性,決定從快取中讀取或是返回新的資源。
有了以上概念,我們便可以制定我們的快取策略:
if (stats.isFile()) {
//是檔案,返回檔案內容
//增加判斷檔案是否有改動,沒有改動返回304的邏輯
//從請求頭獲取modified時間
let IfModifiedSince = req.headers["if-modified-since"];
//獲取檔案的修改日期——時間戳格式
let mtime = stats.mtime;
//如果伺服器上的檔案修改時間小於等於請求頭攜帶的修改時間,則認定檔案沒有變化
if (IfModifiedSince && mtime <= new Date(IfModifiedSince).getTime()) {
//返回304
res.writeHead(304, "not modify");
return res.end();
}
//第一次請求或檔案被修改後,返回給客戶端新的修改時間
res.setHeader("last-modified", new Date(mtime).toString());
res.setHeader("content-encoding", "gzip");
let reg = /\.html$/;
//不同的檔案型別設定不同的cache-control
if (reg.test(_path)) {
//我們對html檔案執行每次必須向伺服器驗證資源有效性的策略
res.setHeader("cache-control", "no-cache");
} else {
//我們對其餘的靜態資原始檔採取強快取策略,一個月內無需向伺服器索取
res.setHeader("cache-control", `max-age=${1 * 60 * 60 * 24 * 30}`);
}
//執行gzip壓縮
const gzip = zlib.createGzip();
let readStream = fs.createReadStream(_path);
readStream.pipe(gzip).pipe(res);
}
這樣一套快取策略在現代前端專案體系下還是比較合適的,尤其是對於spa應用來講。我們希望index.html能夠保證每次向伺服器驗證是否有更新,而其餘的檔案統一本地快取一個月(自己定);通過webpack打包或其他工程化方式構建之後,js、css內容如果發生變化,檔名相應更新,index.html插入的manifest(或script連結、link連結等)清單會更新,保證使用者能夠實時得到最新的資源。
當然,快取之路千萬條,適合業務才重要,大家可以靈活制定。
4. 命令列引數
作為一個在命令列執行的工具,怎麼能不象徵性的支援幾個引數呢?
const config = {
//從命令列中獲取埠號,如果未設定採用預設
port: process.argv[2] || 2234,
hostname: "127.0.0.1"
}
server.listen(config.port, config.hostname, () => {
console.log(`server is running on http://${config.hostname}:${config.port}`);
});
這裡就簡單的舉個栗子啦,大家可以自由發揮!
5. 自動開啟瀏覽器
雖然沒太大卵用,但還是要加。我就是要讓你們知道,我加完之後什麼樣,你們就是什麼樣 :-( duang~
const exec = require("child_process").exec;
server.listen(config.port, config.hostname, () => {
console.log(`server is running on http://${config.hostname}:${config.port}`);
exec(`open http://${config.hostname}:${config.port}`);
});
6. process.cwd()
用process.cwd()代替__dirname。
我們最終要做成一個全域性並且可以在任意目錄下呼叫的命令,所以拼接path的程式碼修改如下:
//__dirname是當前檔案的目錄地址,process.cwd()返回的是指令碼執行的路徑
_path = path.join(process.cwd(), _path);
step3 釋出
基本上我們的程式碼都寫完了,可以考慮釋出了!(不釋出到npm上何以顯示逼格?)
step3.1 package.json
得到一個配置類似下面所示的json檔案:
{
"name": "here11",
"version": "0.0.13",
"private": false,
"description": "a node static assets server",
"bin": {
"here": "./bin/index.js"
},
"repository": {
"type": "git",
"url": "https://github.com/gww666/here.git"
},
"scripts": {
"test": "node bin/index.js"
},
"keywords": [
"node"
],
"author": "gw666",
"license": "ISC"
}
其中bin和private較為重要,其餘的按照自己的專案情況填寫。
bin這個配置代表的是npm i -g xxx之後,我們執行here命令所執行的檔案,“here”這個名字可以隨意起。
step3.2 宣告指令碼執行型別
在index.js檔案的開頭加上:#!/usr/bin/env node
否則linux上執行會報錯。
step3.3 註冊npm賬號
勉強貼一手命令,還不清楚自行百度:
沒有賬號的先新增一個,執行:
npm adduser
然後依次填入
Username: your name
Password: your password
Email: yourmail
npm會給你發一封驗證郵件,記得點一下,不然會發布失敗。
執行登入命令:
npm login
執行釋出命令:
npm publish
釋出的時候記得把專案名字、版本號、作者、倉庫啥的改一下,別填成我的。
還有readme檔案寫一下,好歹告訴別人咋用,基本上和文首所說的用法是一樣的。
好了,齊活。
step3.4
還等啥啊,趕快把npm i -g xxx 這行命令發給你的小夥伴啊。什麼?你沒有小夥伴?告辭!
附
本文專案原始碼地址:https://github.com/gww666/here
如果對你有幫助,還請不吝star!