经过浏览器工做台启动本地项目

一直对经过浏览器工做台启动本地项目感兴趣,相似 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

相关文章
相关标签/搜索