live-server[1]相信很多小伙伴都不陌生,应该都使用过这个库,它是一个支持实时刷新功能的开发环境服务器,类似功能的还有像http-server | anywhere
等。为什么要聊这个库呢?因为它真是又小~呃~~又特么SAO啊!!!
核心功能就是热更新、源码总共600行左右、核心实现仅100行,涉及到的技巧却非常非常多的,比如下面👇这些:
-
Node脚本如何编写? -
支持中间件模型的Node服务如何搭建? -
Node静态文件托管服务如何搭建? -
如何拦截stream流进行资源注入? -
支持跨端如何唤起浏览器(或其他应用)? -
延迟初始化一对一的WS服务? -
如何监听资源内容发生变化?
如果对上述内容有兴趣的小伙伴且听我继续道来吧,全场干货,不扯闲篇。下面,我们首先看下live-server的基本使用吧:
全局安装后可以作为命令行使用,例如:
# 终端输入
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;
就是定义一个对象,然后添加了start
和shutdown
两个方法。先看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,如有侵权,请联系删除。
参考资料
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