經過瀏覽器工做臺啓動本地項目

一直對經過瀏覽器工做臺啓動本地項目感興趣,相似 vue-cli3 中提供的 vue ui,在瀏覽器中打開工做臺,就可以建立、啓動、中止、打包、部署你的項目,很好奇這一系列背後的實現原理。css

最近在用 umijs 寫項目,就順便看了它提供的 cli 工具,並解開了本身的疑問。正好本身項目中也要實現相似的功能,明白了原理,只須要再完善打磨就行了。html

體驗工做臺的功能,本身會猜想對應功能的實現方式,換作是個人話,我大體會如何去寫。帶着本身的疑問去看別人寫的源碼,在這個過程當中驗證本身的猜想,去學習別人處理的技巧。前端

本文會刪繁就簡的實現啓動項目這個功能來講明工做臺的工做原理,對於邊界和異常狀況沒有作過多處理,要投入使用中,能夠作進一步的改進。vue

關鍵點

  1. 啓動服務,訪問可視化工做臺 UI 界面
  2. 經過工做臺,執行本地項目指定的命令
  3. 將執行命令的數據主動推送到客戶端顯示

細化:node

第一點,在本地啓動一個服務,可以訪問到頁面,選擇使用 node 的框架 express 完成,統一返回 index.html 頁面。界面可使用任意框架來作,選擇 Vuereact 甚至 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: '中止一個項目服務'}));

執行 shell 命令

使用 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('我是子進程發出的數據')

上面封裝了一個通用執行命令的函數,接受兩個參數:

打開終端,此時運行 node runCommand.js,會發現運行後終端沒有任何輸出。明明有 console.log,爲何沒有輸出呢?

這就須要簡單瞭解下 stdio(標準輸入輸出)。

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.stdoutdata 事件,有數據輸出就觸發了。

可是,運行後,在終端並無看到 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

代碼整合

回顧以前拋出的關鍵點:

  • 啓動服務,訪問可視化工做臺 UI 界面(已完成)
  • 經過工做臺,執行本地項目指定的命令(待整合)
  • 將執行命令的數據主動推送到客戶端顯示(已完成)

經過上面對每個點的單獨實現,基本解決了上述問題,這時就須要將零散的代碼整合起來,來實現一個相對完善的功能。

首先新建一個 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 服務,若是對 nodejsAPI 熟悉的話,很快就能完成這一功能。

若是對你有幫助,請關注【前端技能解鎖】:
qrcode_for_gh_d0af9f92df46_258.jpg

相關文章
相關標籤/搜索