一直對經過瀏覽器工做臺啓動本地項目感興趣,相似 vue-cli3 中提供的 vue ui,在瀏覽器中打開工做臺,就可以建立、啓動、中止、打包、部署你的項目,很好奇這一系列背後的實現原理。css
最近在用 umijs 寫項目,就順便看了它提供的 cli 工具,並解開了本身的疑問。正好本身項目中也要實現相似的功能,明白了原理,只須要再完善打磨就行了。html
體驗工做臺的功能,本身會猜想對應功能的實現方式,換作是個人話,我大體會如何去寫。帶着本身的疑問去看別人寫的源碼,在這個過程當中驗證本身的猜想,去學習別人處理的技巧。前端
本文會刪繁就簡的實現啓動項目這個功能來講明工做臺的工做原理,對於邊界和異常狀況沒有作過多處理,要投入使用中,能夠作進一步的改進。vue
細化:node
第一點,在本地啓動一個服務,可以訪問到頁面,選擇使用 node 的框架 express 完成,統一返回 index.html 頁面。界面可使用任意框架來作,選擇 Vue、react 甚至 jQuery 均可以。react
第二點,在界面中完成一個交互,像點擊 啓動 按鈕,後端要去指定的目錄下,執行啓動項目的命令,例如 Vue-cli3 構建的項目,須要用 npm run serve 來啓動本地服務,就須要可以執行 shell 命令,使用 node 提供的 child_process 模塊完成。git
第三點,執行命令時打印的信息,本來若是用系統的終端,就能夠在終端打印出來,用到瀏覽器端 UI 界面來執行命令,要跟在終端中同樣,須要把信息打印在頁面中。
執行的任務有些時間比較長,而且在執行過程當中會有異步狀況。若是用 http 接口請求的方式,把服務端的信息發送給客戶端,客戶端須要去輪詢接口,直到肯定命令執行完成中止,這樣會帶來很多開銷。
選擇 webSocket 在瀏覽器和服務器之間創建全雙工的通訊通道,服務端接收瀏覽器的命令,服務端主動推送數據到客戶端。這裏選擇使用 sockjs 模塊完成。github
const express = require('express'); const app = express(); const fs = require('fs'); const path = require('path'); app.use('/*',(req, res) => { let indexHtml = fs.readFileSync(path.join(__dirname, './index.html')); res.set('Content-Type', 'text/html'); res.send(indexHtml); }) const server = app.listen(3002,'0.0.0.0',()=> { console.log('服務啓動'); })
這段代碼比較簡單,起一個服務,返回 html 文檔,打開瀏覽器,訪問 http://localhost:3002/ 便可。web
安裝模塊 npm i sockjs,並使用:vue-cli
const sockjs = require('sockjs'); const ss = sockjs.createServer(); // 建立 sock 服務 // ... 省略上面的 express 代碼 let conns = {}; // 存入鏈接實例 // 監聽有客戶端鏈接 ss.on('connection', (conn) => { console.log('conn: ', conn.id); console.log('有訪問來了'); conns[conn.id] = conn; // 緩存本次訪問者,能夠在別處也能發送信息 // 向客戶端發送數據 conn.write(JSON.stringify({message: '來了老弟'})); // 監控客戶端發送的數據 conn.on('data', (messsage) => { console.log('拿到數據', messsage); }) // 客戶端斷開鏈接 conn.on('close', () => { console.log('離開了 '); delete conns[conn.id]; }) }) // 將sockjs服務掛載到http服務上 ss.installHandlers(server, { prefix: '/test', log: () => {}, });
以上啓動一個 sock 服務,和 express 啓動的服務作了鏈接, 並設置了訪問的前綴爲 /test, 這就能夠在頁面中經過 sockjs-client 訪問到這個服務,訪問的地址爲 http://localhost:3002/test。
- 事件 connection 監聽有客戶端成功鏈接,每次鏈接都會觸發回調函數,回調中接收一個鏈接實例(Connection instance)。 例子中用變量 conn 來接收。
- 鏈接實例下的事件 data,監聽客戶端發送的數據,消息是unicode字符串。因此若是用對象,須要 JSON.parse 解析。
- 鏈接實例下的事件 close,當客戶端斷開鏈接時觸發。
- 鏈接實例下的方法 write(),向客戶端發送數據。若是要發送對象,則先用 JSON.stringify 轉成字符串。
具體能夠參考https://github.com/sockjs/sockjs-node
以上用 conns 經過 id 緩存訪問的實例實例,目的是能夠封裝一個通用的發送數據的方法,能夠再任意須要的地方調用,讓客戶端均可以接收到,這個到後面會有用處。
index.html 中的代碼實現:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>測試在瀏覽器中啓動本地服務</title> <link rel="stylesheet" href="https://gw.alipayobjects.com/os/lib/xterm/3.14.5/dist/xterm.css"> <script src="https://gw.alipayobjects.com/os/lib/xterm/3.14.5/dist/xterm.js"></script> <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.4.0/dist/sockjs.min.js"></script> </head> <body> <script> // 向瀏覽器寫入打印信息 let term = new Terminal(); term.write('打印信息:\r\n'); term.open(document.getElementById('terminal')); // 鏈接 webSocket 服務 var sock = new SockJS('http://localhost:3002/test'); // 初次鏈接成功後觸發的事件 sock.onopen = function() { sock.send(JSON.stringify({type: 'init', message: '成功鏈接'})); }; // 接收服務器發送的消息 sock.onmessage = function(message){ console.log('message: ', message); } </script> </body> </html>
引入了 xterm.js 工具,把接收的消息打印在頁面中。
引入 sockjs-client 文件,使用其中提供的方法,方便和服務端交互。
- new SockJS 傳入 sock 鏈接的 url 地址,與服務器創建通訊通道。
- onopen 和服務端鏈接成功後觸發的事件
- onmessage 當接收來自服務端的消息時觸發
- send() 方法,向服務端發送數據。
服務端和客戶端創建 webSocket 通道後,就能夠通訊了。
這個例子是在初次加載頁面就鏈接 webSocket 服務端,也能夠在須要的時候再進行鏈接。
在這個過程當中區分要作的不一樣的事情,能夠在數據中自定義一些 type 類型,例如:
{type: 'task/init', message: '初始話服務'} 初始服務
{type: 'task/run', message: '啓動服務'} 啓動一個項目服務
{type: 'task/close', message: '中止服務'} 中止一個項目服務
服務端和客戶端能夠根據不一樣的 type 類型,作不一樣的事情。
服務端:
conn.on('data', (messsage) => { // 解析爲對象 const data = JSON.parse(messsage); switch(data.type){ case 'task/init': // 初始服務 break; case 'task/run': // 啓動一個項目服務 break; case 'task/cancel': // 中止一個項目服務 break; } })
客戶端:
sock.send(JSON.stringify({type: 'task/init', message: '初始服務'})); sock.send(JSON.stringify({type: 'task/run', message: '啓動一個項目服務'})); sock.send(JSON.stringify({type: 'task/cancel', message: '中止一個項目服務'}));
使用 Node 內置模塊 child_process 提供的 spawn 方法執行指定的命令,這個方法能夠衍生一個新的子進程,不阻塞主進程的執行。
新建 runCommand.js
let { spawn } = require('child_process'); // 封裝可執行命令的方法。 function runCommand(script, options={}){ options.env = { ...process.env, ...options.env } options.cwd = options.cwd || process.cwd(); // 設置衍生的子進程與主進程通訊方式 options.stdio = ['pipe', 'pipe', 'pipe', 'ipc']; let sh = 'sh',shFlag = '-c'; return spawn(sh, [shFlag, script], options) } // 使用 runCommand('node test.js', { cwd: "/users/node_files/", env: { ENV_TEST: '測試數據' } });
在目錄 /users/test/node_files/(此目錄由本身設定), 新建 test.js:
//用 console.log 向終端輸出內容 console.log(111); console.log('獲取自定義環境變量數據', process.env.ENV_TEST); // 子進程向父進程發送數據 process.send('我是子進程發出的數據')
上面封裝了一個通用執行命令的函數,接受兩個參數:
options 執行命令時額外設置
打開終端,此時運行 node runCommand.js,會發現運行後終端沒有任何輸出。明明有 console.log,爲何沒有輸出呢?
這就須要簡單瞭解下 stdio(標準輸入輸出)。
spawn 方法執行命令會新打開一個子進程,test.js 就在這個子進程中運行。若是關心子進程內部的輸出,須要設置子進程與主進程通訊的管道。經過設置參數的 stdio ,能夠將子進程的 stdio 綁定到不一樣的地方。
能夠給 stdio 設置數組
stdio : [輸入設置 stdin, 輸出設置 stdout, 錯誤輸出 stdrr, [其餘通訊方式]]
舉例:
const fd = require("fs").openSync("./node.log", "w+"); child_process.spawn("node", ["-c", "test.js"], { // 把子進程的輸出和錯誤存在 node.log 這個文件中 stdio: [process.stdin, fd, fd] });
以上這個例子,是把運行 test.js,輸出的結果和錯誤信息打印在 node.log中。
若是設置 stdio 爲 ['pipe', 'pipe', 'pipe'],子進程的 stdio 與父進程的 stdio 經過管道鏈接起來。此時經過監聽子進程輸出(stdout)事件,來獲取子進程的輸出流數據。
// 建立子進程,執行命令 const ipc = runCommand('node test.js', { env: { ENV_TEST: '測試數據' } }); // 接收子進程的輸出數據,例如 console.log 的輸出 ipc.stdout.on('data', log => { console.log(log.toString()); }); // 當子進程執行結束時觸發 ipc.stdout.on('close', log => { console.log('結束了'); }); // 當主程序結束時,不管子程序是否執行完畢,都kill掉 process.on('exit', () => { console.log('主線程退出'); ipc.kill('SIGTERM'); // 終止子進程 });
以上運行 node runCommand.js,就能夠在終端打印出 test.js 輸出的內容。監聽了 ipc.stdout 的 data 事件,有數據輸出就觸發了。
可是,運行後,在終端並無看到 test.js 中 我是子進程發出的數據,這句話。用的是 process.send 發送的,這是要和主進程進行通訊。那就須要額外的 ipc(進程間通訊),設置 stdio 爲 ['pipe', 'pipe', 'pipe', 'ipc'],此時要監聽子進程 process.send 發送的數據,須要監聽 message 事件。
ipc.on('message',function(message){ console.log('message: ', message); })
此時再運行 node runCommand.js,就能夠在終端打印出全部數據了。
以上設置的 stdio,不管是 console.log 這種在進程中輸出流的形式,或者是 process.send 這種與主進程通訊的形式,均可以拿到數據。
以上只是簡略的說了下 stdio的一種設置方式,詳細能夠參考https://cnodejs.org/topic/5aa0e25a19b2e3db18959bee。
回顧以前拋出的關鍵點:
經過上面對每個點的單獨實現,基本解決了上述問題,這時就須要將零散的代碼整合起來,來實現一個相對完善的功能。
首先新建一個 taskManger.js,用來建立子進程執行命令,並將子進程輸出的數據通知給調用方。
let { spawn } = require('child_process'); let {EventEmitter} = require('events'); // 繼承有 on emit 的方法 class TaskManger extends EventEmitter { constructor(){ super(); } // 初始調用,接收發送數據的方法 init(send){ // 監聽 自定義事件 this.on('std-out-message', (message) => { send({ type: 'task.log', payload: { log: message } }); }) } // 通用的執行命令函數 runCommand(script, options={}){ options.env = { ...process.env, ...options.env } options.cwd = options.cwd || process.cwd(); options.stdio = ['pipe', 'pipe', 'pipe', 'ipc']; let sh = 'sh',shFlag = '-c'; return spawn(sh, [shFlag, script], options) } // 開始任務 async run(script, options){ this.ipc = await this.runCommand(script, options); this.processHandler(this.ipc); } // 取消任務 cancel(){ this.ipc.kill('SIGTERM'); } // 接收建立的子進程 processHandler(ipc){ // 子進程 **process.send** 發送的數據 ipc.on('message', (message) => { this.emit('std-out-message', message); }); // 接收子進程的輸出數據,例如 console.log 的輸出 ipc.stdout.setEncoding('utf8'); ipc.stdout.on('data', log => { this.emit('std-out-message', log); }); // 當子進程執行結束時觸發 ipc.stdout.on('close', log => { console.log('結束了', log); this.emit('std-out-message', '服務中止'); }); // 當主程序結束時,不管子程序是否執行完畢,都kill掉 process.on('exit', () => { console.log('主線程退出'); ipc.kill('SIGTERM'); // 終止進程 }); } } module.exports = TaskManger;
以上在使用時調用 init()初始化接收一個未來發送給 socket 的方法(下面會有使用示例)。run() 方法接收命令並建立子進程執行,執行過程數據經過在定義事件 std-out-message,通知給監聽方。進而調用方法通知 socket。
新建一個 socket.js 文件來使用:
let express = require('express'); let app = express(); let fs = require('fs'); let path = require('path'); let TaskManger = require('./taskManger'); let sockjs = require('sockjs'); const ss = sockjs.createServer(); app.use('/*',(req, res) => { let indexHtml = fs.readFileSync(path.join(__dirname, './index.html')); res.set('Content-Type', 'text/html'); res.send(indexHtml.toString()); }) const server = app.listen(3002,()=> { console.log('服務啓動'); }) const task = new TaskManger(); // 發送給 訪問者。 const send = (payload) => { const message = JSON.stringify(payload); Object.keys(conns).forEach(id => { conns[id].write(message); }); } let conns = {}; ss.on('connection', (conn) => { conns[conn.id] = conn; conn.on('data', async (data) => { const datas = JSON.parse(data); switch(datas.type){ case 'task/init': // 初始服務 task.init(send); break; case 'task/run': // 啓動一個項目服務 task.run('npm run serve', { cwd: `/Users/test/vue-cli3-project` // cwd能夠設置爲你本地的vue-cli3建立的項目目錄地址 }); break; case 'task/cancel': // 中止一個項目服務 task.cancel() break; } }) conn.on('close', () => { delete conns[conn.id]; }) }) ss.installHandlers(server, { prefix: '/test', log: () => {}, });
以上經過自定義的 type 來區分不一樣的命令。cwd 目錄路徑,設置爲你本地的 vue-cli3 建立的項目目錄地址,或者其餘項目,並傳入正確的啓動命令便可。
index.html 完整代碼:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>測試在瀏覽器中啓動本地服務</title> <link rel="stylesheet" href="https://gw.alipayobjects.com/os/lib/xterm/3.14.5/dist/xterm.css"> <script src="https://gw.alipayobjects.com/os/lib/xterm/3.14.5/dist/xterm.js"></script> <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.4.0/dist/sockjs.min.js"></script> </head> <body> <button id="button">啓動應用</button> <button id="cancel">中止應用</button> <div id="terminal"></div> <script> let term = new Terminal(); term.write('打印信息:\r\n'); term.open(document.getElementById('terminal')); var sock = new SockJS('http://localhost:3002/test'); // 鏈接成功觸發 sock.onopen = function() { console.log('open'); // 初始化任務 let data = { type: 'task/init' } sock.send(JSON.stringify(data)); }; // 後端推送過來的數據觸發 sock.onmessage = function(message){ console.log('message: ', message); const data = JSON.parse(message.data); let str = data.payload.log.replace(/\n/g, '\r\n'); // 將打印信息寫在頁面上 term.write(str); } // 啓動項目服務 button.onclick = function(){ const task = { type: 'task/run' } sock.send(JSON.stringify(task)); } // 取消項目服務 cancel.onclick = function(){ const task = { type: 'task/cancel' } sock.send(JSON.stringify(task)); } </script> </body> </html>
經過上述例子,關鍵點實際上是兩點,執行命令和創建 Socket 服務,若是對 nodejs 的 API 熟悉的話,很快就能完成這一功能。
若是對你有幫助,請關注【前端技能解鎖】: