我是如何通过手写webpack plugin优化本地开发体验的?

大家好,我是苏先生,一名热爱钻研、乐于分享的前端工程师,跟大家分享一句我很喜欢的话:人活着,其实就是一种心态,你若觉得快乐,幸福便无处不在

github与好文

背景

为什么直到现在才做这个需求,这是因为项目一直都是没有启用webpack-dev-server的自动打开浏览器功能的,直到我后来对webpack4升级到webpack5之后才开始启用该属性

直到前两天,突然发现启用了open后每次都会重新开一个浏览器tab,虽说不是不能用,但总觉得别扭,于是就在想能不能让它复用一下子,如果页面中已经有打开的url,那下次就复用这个tab就好了

image.png

技术分析

首先,打开浏览器复用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 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;
});
// 将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并记录进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)
    }
  }
}
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 -D
yarn add open-browser-for-webpack-mac -D
yarn 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篇类型体操,等你哟?

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYOVdJfA' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片