大家好,我是苏先生,一名热爱钻研、乐于分享的前端工程师,跟大家分享一句我很喜欢的话:人活着,其实就是一种心态,你若觉得快乐,幸福便无处不在
github与好文
背景
为什么直到现在才做这个需求,这是因为项目一直都是没有启用webpack-dev-server的自动打开浏览器功能的,直到我后来对webpack4升级到webpack5之后才开始启用该属性
直到前两天,突然发现启用了open后每次都会重新开一个浏览器tab,虽说不是不能用,但总觉得别扭,于是就在想能不能让它复用一下子,如果页面中已经有打开的url,那下次就复用这个tab就好了
技术分析
首先,打开浏览器复用tab的能力一定是可以做到的,因为vite和cra里我都见过,但是我们项目有两个地方是比较特殊的:
-
我们真正打开使用的地址实际上来自于在hosts文件中配置的映射关系,因此如何取hosts文件,如果找到映射关系
-
如何接管webpack-dev-server自身的open行为,比如假如某个项目是不需要设置hosts时,是否能回退到webpack-dev-server
-
当配置的hosts的映射关系为一对多时,是否会报错,报错后应该怎么处理,是否需要逐个尝试
-
有没有可能全部都出错,此时应该如何降级
源码地址
代码实现
首先,我们做一个webpack plugin的基础架子,它是一个包含apply接口的固定写法的class
class OpenBrowser {apply(){}}class OpenBrowser { apply(){} }class OpenBrowser { apply(){} }
第二步来考虑参数
- port
端口我们其实是可以复用webpack-dev-server中的端口的,但是为了更加保险一点,我们也让用户传递一个进来
- opened
我们当前的webpack项目是采用的lazy模式,每次访问新的页面都会重新compiler,这就意味着buildStart会被多次触发,因此需要一个标记来记录是否已经打开过,如果为true就直接return
- address
我们将从hosts查找到的域名丢给用户一份,给它一个可检查、可修改的时机,因此它应当是一个函数
- fallback
我们的项目是介入了单点的,登录页是另外一个项目,它的域名地址是跟将来我们要检测浏览器tab里是否已经存在的目标地址是不一样的,因此我这里搞了个降级,即默认查不到,就尝试查找这个
接着,接管webpack-dev-server的默认open行为,并设置buildStart hook
// 将webpack-dev-server的open删除即可delete compiler.options.devServer.open;// 设置buildStart hookcompiler.hooks.emit.tapPromise(PLUGINNAME, async () => {// 仅在首次打开浏览器if(!this.opened){// TODO:validate iturls = urls.map((u) => u.replace(port, realPort));// 真正的打开方法await _openBrowser(urls, this.fallback);}this.opened = true;});// 将webpack-dev-server的open删除即可 delete compiler.options.devServer.open; // 设置buildStart hook compiler.hooks.emit.tapPromise(PLUGINNAME, async () => { // 仅在首次打开浏览器 if(!this.opened){ // TODO:validate it urls = urls.map((u) => u.replace(port, realPort)); // 真正的打开方法 await _openBrowser(urls, this.fallback); } this.opened = true; });// 将webpack-dev-server的open删除即可 delete compiler.options.devServer.open; // 设置buildStart hook compiler.hooks.emit.tapPromise(PLUGINNAME, async () => { // 仅在首次打开浏览器 if(!this.opened){ // TODO:validate it urls = urls.map((u) => u.replace(port, realPort)); // 真正的打开方法 await _openBrowser(urls, this.fallback); } this.opened = true; });
现在,进入_openBrowser函数,该函数由两部分组成,一是调用applescript尝试打开浏览器,如果applescript打不开就降级使用javascript脚本打开
如下,考虑到hosts中一对多的情况,这里搞了个数组并依次去执行,当浏览器页面中没有已经打开的tab时applescript脚本执行会报错,当报错时,表示当前已经尝试过一个,我们停掉当前的for循环并递归调用_openBrowser进行下一轮,如果全部的都尝试过并且都报错了,则调用openByJs来打开
async function _openBrowser(urls, fallback) {// 获取进程信息const ps = await execAsync("ps cax");// 查找进程中是否包含指定的浏览器const legalBrowser = AIMBROWSERS.find((b) => ps.includes(b));if (legalBrowser) {let isBreak = false// 根据地址依次尝试打开for (let i = 0; i < urls.length && !isBreak; i++) {const openStatus = await tryOpen({url: urls[i],fallback,legalBrowser,onError:()=>{// 执行错误时停止当前for并记录进errsisBreak = trueconst act = urls[i]urls.splice(i,1)setTimeout(()=>{errs.push(act)_openBrowser(urls,fallback)},100)}});// 打开了就跳出循环,程序结束if (openStatus === true) {tryTimes = -1errs.length = 0break;}}if(tryTimes === errs.length){// 降级到js执行打开openByJs(errs,legalBrowser)}}}async function _openBrowser(urls, fallback) { // 获取进程信息 const ps = await execAsync("ps cax"); // 查找进程中是否包含指定的浏览器 const legalBrowser = AIMBROWSERS.find((b) => ps.includes(b)); if (legalBrowser) { let isBreak = false // 根据地址依次尝试打开 for (let i = 0; i < urls.length && !isBreak; i++) { const openStatus = await tryOpen({ url: urls[i], fallback, legalBrowser, onError:()=>{ // 执行错误时停止当前for并记录进errs isBreak = true const act = urls[i] urls.splice(i,1) setTimeout(()=>{ errs.push(act) _openBrowser(urls,fallback) },100) } }); // 打开了就跳出循环,程序结束 if (openStatus === true) { tryTimes = -1 errs.length = 0 break; } } if(tryTimes === errs.length){ // 降级到js执行打开 openByJs(errs,legalBrowser) } } }async function _openBrowser(urls, fallback) { // 获取进程信息 const ps = await execAsync("ps cax"); // 查找进程中是否包含指定的浏览器 const legalBrowser = AIMBROWSERS.find((b) => ps.includes(b)); if (legalBrowser) { let isBreak = false // 根据地址依次尝试打开 for (let i = 0; i < urls.length && !isBreak; i++) { const openStatus = await tryOpen({ url: urls[i], fallback, legalBrowser, onError:()=>{ // 执行错误时停止当前for并记录进errs isBreak = true const act = urls[i] urls.splice(i,1) setTimeout(()=>{ errs.push(act) _openBrowser(urls,fallback) },100) } }); // 打开了就跳出循环,程序结束 if (openStatus === true) { tryTimes = -1 errs.length = 0 break; } } if(tryTimes === errs.length){ // 降级到js执行打开 openByJs(errs,legalBrowser) } } }
applescript脚本可以通过exec来进行调用,这里我们传递三个参数:要检查的url地址、要打开浏览器、如果要检测的url没有找到,则需要降级查找的url
async function tryOpen(payload) {const { url, fallback, legalBrowser,onError } = payload;try {await execAsync(`osascript openChrome.applescript "${encodeURI(url)}" "${legalBrowser}" "${fallback}"`,{cwd: resolve(__dirname, ".."),});} catch (_) {onError()}}async function tryOpen(payload) { const { url, fallback, legalBrowser,onError } = payload; try { await execAsync( `osascript openChrome.applescript "${encodeURI(url)}" "${legalBrowser}" "${fallback}"`, { cwd: resolve(__dirname, ".."), } ); } catch (_) { onError() } }async function tryOpen(payload) { const { url, fallback, legalBrowser,onError } = payload; try { await execAsync( `osascript openChrome.applescript "${encodeURI(url)}" "${legalBrowser}" "${fallback}"`, { cwd: resolve(__dirname, ".."), } ); } catch (_) { onError() } }
最后,降级到js打开一个新的,这种情况一般是浏览器中一个相关的都没有打开,否则,但凡有一个,就不会调用到这里的
function openByJs(urls, legalBrowser) {try {const cliArguments = ["-a", legalBrowser, urls[urls.length - 1]];spawn("open", cliArguments);} catch (_) {}}function openByJs(urls, legalBrowser) { try { const cliArguments = ["-a", legalBrowser, urls[urls.length - 1]]; spawn("open", cliArguments); } catch (_) {} }function openByJs(urls, legalBrowser) { try { const cliArguments = ["-a", legalBrowser, urls[urls.length - 1]]; spawn("open", cliArguments); } catch (_) {} }
至于applescript脚本,它copy自cra,我只是在它的基础上加了一个fallback以支持降级查找,既然不是我写的,那我就不贴出来了
最后是关于hosts的获取,这也分成了三步
- 获取本地可用的ipv4地址,并将符合条件的加入数组并最终返回
function getIps() {const interfaces = networkInterfaces();const ips = [];for (const name in interfaces) {const networkInterface = interfaces[name];for (const network of networkInterface) {if (network.family === "IPv4" && !network.internal) {ips.push(network.address);}}}return ips;}function getIps() { const interfaces = networkInterfaces(); const ips = []; for (const name in interfaces) { const networkInterface = interfaces[name]; for (const network of networkInterface) { if (network.family === "IPv4" && !network.internal) { ips.push(network.address); } } } return ips; }function getIps() { const interfaces = networkInterfaces(); const ips = []; for (const name in interfaces) { const networkInterface = interfaces[name]; for (const network of networkInterface) { if (network.family === "IPv4" && !network.internal) { ips.push(network.address); } } } return ips; }
- 获取本地hosts文件,每一个ip对应的域名都是一个数组形式,因为要兼容一对多的情况
function parseHosts() {const mappings = {};if (existsSync(HOSTSPATH)) {try {const code = readFileSync(HOSTSPATH, "utf-8");const lines = code.split("\n");for (let line of lines) {line = line.trim();if (line.startsWith("#") || line === "") {continue;}const tokens = line.split(/\s+/);const ip = tokens[0];for (let i = 1; i < tokens.length; i++) {const domain = tokens[i];if (!mappings[ip]) {mappings[ip] = [];}mappings[ip].push(domain);}}} catch (_) {}}return mappings;}function parseHosts() { const mappings = {}; if (existsSync(HOSTSPATH)) { try { const code = readFileSync(HOSTSPATH, "utf-8"); const lines = code.split("\n"); for (let line of lines) { line = line.trim(); if (line.startsWith("#") || line === "") { continue; } const tokens = line.split(/\s+/); const ip = tokens[0]; for (let i = 1; i < tokens.length; i++) { const domain = tokens[i]; if (!mappings[ip]) { mappings[ip] = []; } mappings[ip].push(domain); } } } catch (_) {} } return mappings; }function parseHosts() { const mappings = {}; if (existsSync(HOSTSPATH)) { try { const code = readFileSync(HOSTSPATH, "utf-8"); const lines = code.split("\n"); for (let line of lines) { line = line.trim(); if (line.startsWith("#") || line === "") { continue; } const tokens = line.split(/\s+/); const ip = tokens[0]; for (let i = 1; i < tokens.length; i++) { const domain = tokens[i]; if (!mappings[ip]) { mappings[ip] = []; } mappings[ip].push(domain); } } } catch (_) {} } return mappings; }
- 最后对两者进行合并即可
for (let i = 0; i < ips.length; i++) {const hosts = mapping[ips[i]];if (Array.isArray(hosts) && hosts.length) {...}}for (let i = 0; i < ips.length; i++) { const hosts = mapping[ips[i]]; if (Array.isArray(hosts) && hosts.length) { ... } }for (let i = 0; i < ips.length; i++) { const hosts = mapping[ips[i]]; if (Array.isArray(hosts) && hosts.length) { ... } }
使用
- 安装
yarn add open-browser-for-webpack-mac -Dyarn add open-browser-for-webpack-mac -Dyarn add open-browser-for-webpack-mac -D
- 使用
const OpenBrowser = require('open-browser-for-webpack-mac');new OpenBrowser({port: 9090,address: (host, port) => `http://${host}:${port}`,fallback: 'http://sso.test.weidiango.com',})const OpenBrowser = require('open-browser-for-webpack-mac'); new OpenBrowser({ port: 9090, address: (host, port) => `http://${host}:${port}`, fallback: 'http://sso.test.weidiango.com', })const OpenBrowser = require('open-browser-for-webpack-mac'); new OpenBrowser({ port: 9090, address: (host, port) => `http://${host}:${port}`, fallback: 'http://sso.test.weidiango.com', })
如果本文对您有用,希望能得到您的点赞和收藏
订阅专栏,每周更新1-2篇类型体操,等你哟?