Live-Server中的NodeJS技巧你都学会了吗

live-server[1]相信很多小伙伴都不陌生,应该都使用过这个库,它是一个支持实时刷新功能的开发环境服务器,类似功能的还有像http-server | anywhere等。为什么要聊这个库呢?因为它真是又小~呃~~又特么SAO啊!!!

核心功能就是热更新、源码总共600行左右、核心实现仅100行,涉及到的技巧却非常非常多的,比如下面👇这些:

  • Node脚本如何编写?
  • 支持中间件模型的Node服务如何搭建?
  • Node静态文件托管服务如何搭建?
  • 如何拦截stream流进行资源注入?
  • 支持跨端如何唤起浏览器(或其他应用)?
  • 延迟初始化一对一的WS服务?
  • 如何监听资源内容发生变化?

如果对上述内容有兴趣的小伙伴且听我继续道来吧,全场干货,不扯闲篇。下面,我们首先看下live-server的基本使用吧:

Live-Server中的NodeJS技巧你都学会了吗
image.png

全局安装后可以作为命令行使用,例如:

# 终端输入
live-server

此时会使开启一个服务,并自动打开浏览器访问当前静态资源。同时监听当前目录下的静态文件内容发生变化,并实时刷新浏览器。

知道了如何使用后,我们看下其原理是如何实现的,基于1.1.2的版本。首先从package.json文件中的bin字段可以看的,当前脚本的入口文件是live-server.js文件:

{
  "bin": {
    "live-server""./live-server.js"
  },
}

脚本入口live-server.js的实现

首先第一的代码是定义脚本的执行环境为node

#!/usr/bin/env node

var path = require('path');
var fs = require('fs');
var assign = require('object-assign');
var liveServer = require("./index");

紧接着都是从我们输入的node命令中,解析出命令相关参数,比如我们输入如下命令:

# 终端输入
live-server --port=3000 --host=http://localhost

具体解析逻辑如下:

var opts = {
 host: process.env.IP,
 port: process.env.PORT,
 open: true,
 mount: [],
 proxy: [],
 middleware: [],
 logLevel: 2,
};

// 获取系统账户根目录文件夹 (等同于os.homedir()) 下的.live-server.json文件
var homeDir = process.env[(process.platform === 'win32') ? 'USERPROFILE' : 'HOME'];
var configPath = path.join(homeDir, '.live-server.json');

// 如果文件存在则读取文件json内容,进行参数的合并
if (fs.existsSync(configPath)) {
 var userConfig = fs.readFileSync(configPath, 'utf8');
 assign(opts, JSON.parse(userConfig));
 if (opts.ignorePattern) opts.ignorePattern = new RegExp(opts.ignorePattern);
}

/**
 * 解析终端命令参数
 * argv第一个参数是node的执行上下文,第二个参数是执行的脚本地址
 * 第三个参数及以后是命令参数
 */
for (var i = process.argv.length - 1; i >= 2; --i) {
  var arg = process.argv[i];
  // 解析端口号
  if (arg.indexOf("--port=") > -1) {
 var portString = arg.substring(7);
 var portNumber = parseInt(portString, 10);
   if (portNumber === +portString) {
  opts.port = portNumber;
  process.argv.splice(i, 1);
   }
  }
  // 解析host地址
  else if (arg.indexOf("--host=") > -1) {
    opts.host = arg.substring(7);
 process.argv.splice(i, 1);
  }
  // 省略其他else if代码
  // 该部分和上述一样,都是解析命令的其他参数
  // ......
}

  • 首先判断用户根目录下有无.live-server.json文件,有则解析json内容作为默认配置
  • process.argv读取所有的命令参数,与默认参数合并。该值是一个数组,数组第一项是node的执行上下文,第二个项是执行的脚本地址,后面的项都是后续的所有参数。
  • 得到默认参数后,开始调用server真正的实现,并把参数传递进入,如下:
liveServer.start(opts);

liveServer的实现

liveServer的实现是在index.js内:

// 读取injected.html的内容
// 内容实际为一段websocket代码,用于和本服务通信的
var INJECTED_CODE = fs.readFileSync(path.join(__dirname, "injected.html"), "utf8");

首先读取了该库根目录下injected.html文件的内容,并把内容赋值给一个变量等待后面使用。该文件内容就是存储的一段websocket代码,该代码的作用是在访问html等资源时要注入进去的代码,注入进去执行就可以在html文件运行时与服务进行ws连接和通信。先看下该injected.html的内容:

<!-- Code injected by live-server -->
<script type="text/javascript">
 // <![CDATA[  <-- For SVG support
 if ('WebSocket' in window) {
  (function() {
   function refreshCSS() {
    var sheets = [].slice.call(document.getElementsByTagName("link"));
    var head = document.getElementsByTagName("head")[0];
    for (var i = 0; i < sheets.length; ++i) {
     var elem = sheets[i];
     head.removeChild(elem);
     var rel = elem.rel;
     if (elem.href && typeof rel != "string" || rel.length == 0 || rel.toLowerCase() == "stylesheet") {
      var url = elem.href.replace(/(&|?)_cacheOverride=d+/, '');
      elem.href = url + (url.indexOf('?') >= 0 ? '&' : '?') + '_cacheOverride=' + (new Date().valueOf());
     }
     head.appendChild(elem);
    }
   }
   var protocol = window.location.protocol === 'http:' ? 'ws://' : 'wss://';
   var address = protocol + window.location.host + window.location.pathname + '/ws';
   var socket = new WebSocket(address);
   socket.onmessage = function(msg) {
    if (msg.data == 'reload') window.location.reload();
    else if (msg.data == 'refreshcss') refreshCSS();
   };
   console.log('Live reload enabled.');
  })();
 }
 // ]]>
</script>

可以看到,该文件的内容就是一段js脚本,脚本主要做了如下事情:

  • 根据url地址生成要连接的ws服务地址
  • 初始化ws连接
  • 监听ws服务推送的消息:
    • reload消息则刷新当前页面
    • refreshcss消息则做无感css刷新

无感css刷新的做法是遍历head标签中所有的样式表的link标签,然后逐个删除,然后重新插入,插入时生成一个新的时间戳字段用于去掉缓存效果。

接下来就是服务的主体实现:

var LiveServer = {
 server: null,
 watcher: null,
 logLevel: 2
};

LiveServer.start = function(options) {}

LiveServer.shutdown = function() {}

module.exports = LiveServer;

就是定义一个对象,然后添加了startshutdown两个方法。先看start的实现:

// 根据options参数启动服务器
LiveServer.start = function(options) {
    options = options || {};
 // host地址
 var host = options.host || '0.0.0.0';
 // 端口号
 var port = options.port !== undefined ? options.port : 8080; // 0 means random
 // 脚本的入口,也就是要启动的资源服的入口
 var root = options.root || process.cwd();
 
 // 其他默认参数设置
 // .....
 
 // 初始化一个connect服务
 var app = connect();
 
 // ... 省略其他日志逻辑等与主体逻辑无关代码
 
 // 加载了一些中间件,例如
 // 添加cors跨域处理的中间件
 if (cors) {
  app.use(require("cors")({
   origin: true, // reflecting request origin
   credentials: true // allowing requests with credentials
  }));
 }
 
 // 加载静态文件托管服务中间件等
 app.use(staticServerHandler)
 
 var server, protocol;
 // 如果用户设置了https的配置
 if (https !== null) {
  var httpsConfig = https;
  // https参数是字符串时,则作为配置文件路径
  // 然后加载文件内容作为https请求的参数配置
  if (typeof https === "string") {
   httpsConfig = require(path.resolve(process.cwd(), https));
  }
  // 创建https服务器
  server = require(httpsModule).createServer(httpsConfig, app);
  protocol = "https";
 } else {
  // 否则默认使用http服务
  server = http.createServer(app);
  protocol = "http";
 }
}

  • 首先进行各种参数的默认赋值
  • 通过connect库实例化一个中间件服务
  • 加载cors中间件、静态文件托管服务中间件等
  • 根据用户参数选择创建http/https服务
  • http/https服务加载中间件模型

核心代码如下:

// 初始化一个connect中间件服务
var app = connect();

// 加载很多中间件
app.use(mideware1);
app.use(mideware2);
app.use(mideware3);

// 初始化http服务并使用中间件
var server = http.createServer(app);

静态文件托管服务如何实现的?

// 创建静态文件托管服务的中间件
var staticServerHandler = staticServer(root);

// 加载静态文件托管服务中间件等
app.use(staticServerHandler) // Custom static server
 .use(entryPoint(staticServerHandler, file))
 .use(serveIndex(root, { icons: true }));

由此可知具体的静态文件托管服务在staticServer中实现:

// 静态文件托管服务
function staticServer(root) {
 var isFile = false;
 try { // For supporting mounting files instead of just directories
  // 判断指定的路径是否是文件
  isFile = fs.statSync(root).isFile();
 } catch (e) {
  if (e.code !== "ENOENT") throw e;
 }
 // 返回一个中间件
 return function(req, res, next) {
  // 仅处理GET和HEAD请求
  if (req.method !== "GET" && req.method !== "HEAD"return next();
  // 获取域名后面的路径部分,例如x.com/abc/def获取的是/abc/def
  // 如果isFile为true,直接为空
  var reqpath = isFile ? "" : url.parse(req.url).pathname;
  var hasNoOrigin = !req.headers.origin;
  var injectCandidates = [ new RegExp("</body>""i"), new RegExp("</svg>"), new RegExp("</head>""i")];
  var injectTag = null;

  // 利用send库把静态资源作为http的请求结果返回
  send(req, reqpath, { root: root })
   .on('error', error)
   .on('directory', directory)
   .on('file', file)
   .on('stream', inject)
   .pipe(res);
 };
}

  • staticServer函数是一个创建函数,用于创建一个中间件函数
  • 如果是非GET | HEAD请求则直接调用next()执行下一个中间件
  • 利用send库把静态资源作为http请求的结果返回
    • 参数1是当前请求对象
    • 参数2是请求的资源路径
    • 参数3指定了请求资源的相对路径是当前脚本根路径或者用户可以指定root
  • 利用send库进行监听事件
    • 请求资源是文件夹时,调用directory处理函数
    • 请求资源是文件时,调用file处理函数
    • 请求的流开始时,调用inject处理逻辑。该部分是最关键的,就是在该部分进行ws代码的注入

下面结束send各个事件的具体处理逻辑:

  • 文件夹

当访问文件时,直接在后面拼接/,然后进行资源重定向

// 当请求的是一个目录时的处理函数
function directory() {
 var pathname = url.parse(req.originalUrl).pathname;
 res.statusCode = 301;
 res.setHeader('Location', pathname + '/');
 res.end('Redirecting to ' + escape(pathname) + '/');
}

  • 文件的处理函数
// 当请求的是一个文件时的处理函数
function file(filepath /*, stat*/) {
 var x = path.extname(filepath).toLocaleLowerCase(), match,
   possibleExtensions = [ """.html"".htm"".xhtml"".php"".svg" ];
 if (hasNoOrigin && (possibleExtensions.indexOf(x) > -1)) {
  // TODO: Sync file read here is not nice, but we need to determine if the html should be injected or not
  var contents = fs.readFileSync(filepath, "utf8");
  for (var i = 0; i < injectCandidates.length; ++i) {
   match = injectCandidates[i].exec(contents);
   if (match) {
    injectTag = match[0];
    break;
   }
  }
 }
}

主要处理逻辑就是根据请求的文件路径的后缀名,判断是否是.html | .htm | .xhtml等文件类型,是的话则读取文件内容,通过正则查找文件内容是否包含</body> | </head>等字符,如果包含则说明该文件是可以进行注入ws代码的。这里只是给injectTag变量打个标记,真正的注入是在stream事件中实现。

  • stream流开始事件
// 在读取的目标文件流中注入socket脚本
function inject(stream) {
 if (injectTag) {
  // We need to modify the length given to browser
  var len = INJECTED_CODE.length + res.getHeader('Content-Length');
  res.setHeader('Content-Length', len);
        // 保存原pipe的引用
  var originalPipe = stream.pipe;
        // 重写原pipe方法
  stream.pipe = function(resp) {
            // 重新调用pipe方法,并且理由event-stream模块,对流的内容进行注入内容
            // 注入的内容为websocket通信的部分
   originalPipe.call(stream, es.replace(new RegExp(injectTag, "i"), INJECTED_CODE + injectTag)).pipe(resp);
  };
 }
}

处理逻辑主要通过重写pipe方法,然后对读取的流的内容进行替换,把</body>字符替换成要注入的ws代码+</body>,然后把res返回的响应头中的Content-Length值更新为替换后的内容长度。

服务监听和打开浏览器

// Handle successful server
server.addListener('listening'function(/*e*/) {
 LiveServer.server = server;

 var address = server.address();
 // @see https://www.cnblogs.com/wenwei-blog/p/12114184.html
 var serveHost = address.address === "0.0.0.0" ? "127.0.0.1" : address.address;
 var openHost = host === "0.0.0.0" ? "127.0.0.1" : host;

 var serveURL = protocol + '://' + serveHost + ':' + address.port;
 // 打开的应用的url服务地址
 var openURL = protocol + '://' + openHost + ':' + address.port;

    // 省略日志的部分
    // ......

 // Launch browser
 // 利用open库唤起应用打开对于路径
 // 用户没有单独设置的情况下,是唤起浏览器
 if (openPath !== null) {
  if (typeof openPath === "object") {
   openPath.forEach(function(p) {
    open(openURL + p, {app: browser});
   });
  } else {
   open(openURL + openPath, {app: browser});
  }
 }
});

// Setup server to listen at port
// 监听端口号和host
server.listen(port, host);

  • 通过listening事件监听http/https服务启动成功
  • 拼接要到的资源的地址,即openPath
  • 利用open库唤起应用,默认是唤起浏览器
  • 监听端口号和host,开始运行服务

页面资源和服务通信连接

上面的分析中我们知道,我们会在流资源的http请求返回时注入ws代码,ws代码会自动尝试和我们的server服务开始连接。此时会触发server的握手事件,那么我们就可以在握手时初始化ws的服务,并建立客户端和ws的一对一连接。

这里之所以建立一对一的连接,主要是为了通信方便和数据互相隔离,同时也做到了由客户端发起连接时才初始化ws服务,因为有些资源是不会注入ws服务的,也就不需要连接。

立一对一的连接通过faye-websocket[2]来实现。

// WebSocket
var clients = [];
// 监听握手事件,每一个socket连接对应一个socket服务
// 利用faye-websocket库实现一对一的连接关系
server.addListener('upgrade'function(request, socket, head) {
 var ws = new WebSocket(request, socket, head);
 // ws初始化成功后,向连接的ws客户端发生一条消息
 // 虽然这条消息客户端没有使用
 ws.onopen = function() {
     ws.send('connected');
 };

    // 监听到客户端关闭时,移除其缓存实例
 ws.onclose = function() {
  clients = clients.filter(function (x) {
   return x !== ws;
  });
 };

    // 缓存客户端实例
 clients.push(ws);
});

如何监听资源内容发生变化

在客户端和服务端建立了ws连接之后,那么就要监听静态资源内容是否发生了变化,我们需要在变化后通知客户端资源进行刷新:

// Setup file watcher
LiveServer.watcher = chokidar.watch(watchPaths, {
 ignored: ignored,
 ignoreInitial: true
});

// 资源发生变化的处理函数
function handleChange(changePath) {
 var cssChange = path.extname(changePath) === ".css" && !noCssInject;

 clients.forEach(function(ws) {
  if (ws) {
   ws.send(cssChange ? 'refreshcss' : 'reload');
  }
 });
}

// 监听相关的变化事件
LiveServer.watcher
 .on("change", handleChange)
 .on("add", handleChange)
 .on("unlink", handleChange)
 .on("addDir", handleChange)
 .on("unlinkDir", handleChange)
 .on("error"function (err) {
  console.log("ERROR:".red, err);
 });

  • 利用chokidar库进行文件内容变更的监听
  • 文件内容变化话,判断是否是css文件发生变化
    • css发生变化,ws发生refreshcss消息
    • 否则ws发送reload消息
  • 客户端根据消息作出不同的响应,reload或者无感刷新css

关闭服务

// 主要就是关闭watcher的资源内容监听和关闭server服务
LiveServer.shutdown = function() {
 var watcher = LiveServer.watcher;
 if (watcher) {
  watcher.close();
 }
 var server = LiveServer.server;
 if (server)
  server.close();
};

// shutdown方法会在server的error事件中触发
server.addListener('error'function(e) {
 if (e.code === 'EADDRINUSE') {
  var serveURL = protocol + '://' + host + ':' + port;
  console.log('%s is already in use. Trying another port.'.yellow, serveURL);
  setTimeout(function() {
   server.listen(0, host);
  }, 1000);
 } else {
  console.error(e.toString().red);
  LiveServer.shutdown();
 }
});

该库功能的核心实现

简单抽取该库最主要的核心实现,基本如下100行代码:

const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const open = require('open');
const send = require('send');
const eventStream = require('event-stream');
const fayeWebsocket = require('faye-websocket');
const chokidar = require('chokidar');

const config = {
  host: 'http://127.0.0.1',
  port: 3000,
  root: process.cwd(),
}

const injectContent = fs.readFileSync('./injected.html');

const server = http.createServer((req, res) => {
  let isInject = false;
  const reqPath = url.parse(req.url).pathname;

  function handleFile(filepath) {
    const ext = path.extname(filepath).toLocaleLowerCase();
    const targetFiles = possibleExtensions = [ '''.html''.htm''.xhtml' ];
    const fileContent = fs.readFileSync(filepath, 'utf8');
    if (!req.headers.origin && targetFiles.includes(ext)) {
      const regexp = /</body>/g;
      if (regexp.exec(fileContent)) {
        isInject = true;
      }
    }
  }

  function inject(stream) {
    if (!isInject) return;

    // We need to modify the length given to browser
    const len = injectContent.length + res.getHeader('Content-Length');
    // 保存原pipe的引用
    const originalPipe = stream.pipe;

    res.setHeader('Content-Length', len);
    // 重写原pipe方法
    stream.pipe = function(resp) {
      // 重新调用pipe方法,并且理由event-stream模块,对流的内容进行注入内容
      // 注入的内容为websocket通信的部分
      originalPipe.call(stream, eventStream.replace(/</body>/g, injectContent + '</body>')).pipe(resp);
    };
  }

  send(req, reqPath, {
    root: config.root,
  }).on('stream', inject)
    .pipe(res);

});

let clients = [];
server.addListener('upgrade', (request, socket, head) => {
  const ws = new fayeWebsocket(request, socket, head);
  ws.onopen = function() {
    ws.send('connected');
  };
  ws.onmessage = function(e) {
    console.log('receive:', e.data);
    ws.send(e.data)
  }
  ws.onclose = function() {
    clients = clients.filter(function (x) {
      return x !== ws;
    });
  };
  clients.push(ws);
});

server.listen(3000, () => {
  const openPath = `${config.host}:${config.port}`;
  console.log(`[live-server] server is running at: ${openPath}`);

  // 服务启动成功后打开浏览器
  open(openPath, {
    app: null,
  });
});

const wathcer = chokidar.watch([config.root], { ignoreInitial: true });

wathcer.on('change', handleChange)
  .on('add', handleChange)
  .on('unlink', handleChange)
  .on('addDir', handleChange)
  .on('unlinkDir', handleChange)
  .on('error', (err) => {});

function handleChange(changePath) {
  console.log('file change');
  // 判断是否是css文件内容发生变化
  const cssChange = path.extname(changePath) === '.css';
  clients.forEach(function(ws) {
    if (ws) {
      ws.send(cssChange ? 'refreshcss' : 'reload');
    }
  });
}

总结

该库的源码实现,希望小伙伴能快速掌握如下几个点,有兴趣的也可以继续对背后依赖的库进一步的探究:

  • node脚本的书写格式
  • http/https服务创建,并通过connect[3]库支持中间件模型
  • 利用send[4]库创建静态资源服务,并对流内容通过event-straem[5]库进行修改
  • 利用open[6]库打开浏览器或者其他应用
  • 利用chokidar[7]进行文件内容变更的监听
  • 利用faye-websocket[8]在客户端连接时才初始化ws服务并建立一对一的连接

本文转自 https://juejin.cn/post/7074620057547964453,如有侵权,请联系删除。

参考资料

[1]

https://github.com/tapio/live-server#readme: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Ftapio%2Flive-server%23readme

[2]

https://github.com/faye/faye-websocket-node: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Ffaye%2Ffaye-websocket-node

[3]

https://github.com/senchalabs/connect#readme: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fsenchalabs%2Fconnect%23readme

[4]

https://github.com/pillarjs/send#readme: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fpillarjs%2Fsend%23readme

[5]

https://github.com/dominictarr/event-stream: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fdominictarr%2Fevent-stream

[6]

https://github.com/sindresorhus/open#readme: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fsindresorhus%2Fopen%23readme

[7]

https://github.com/paulmillr/chokidar: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fpaulmillr%2Fchokidar

[8]

https://github.com/faye/faye-websocket-node: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Ffaye%2Ffaye-websocket-node


版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/20500.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!