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

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

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

ts 复制代码
class OpenBrowser {
    apply(){}
}

第二步来考虑参数

  • port

端口我们其实是可以复用webpack-dev-server中的端口的,但是为了更加保险一点,我们也让用户传递一个进来

  • opened

我们当前的webpack项目是采用的lazy模式,每次访问新的页面都会重新compiler,这就意味着buildStart会被多次触发,因此需要一个标记来记录是否已经打开过,如果为true就直接return

  • address

我们将从hosts查找到的域名丢给用户一份,给它一个可检查、可修改的时机,因此它应当是一个函数

  • fallback

我们的项目是介入了单点的,登录页是另外一个项目,它的域名地址是跟将来我们要检测浏览器tab里是否已经存在的目标地址是不一样的,因此我这里搞了个降级,即默认查不到,就尝试查找这个

接着,接管webpack-dev-server的默认open行为,并设置buildStart hook

ts 复制代码
// 将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来打开

ts 复制代码
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

ts 复制代码
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打开一个新的,这种情况一般是浏览器中一个相关的都没有打开,否则,但凡有一个,就不会调用到这里的

ts 复制代码
function openByJs(urls, legalBrowser) {
  try {
    const cliArguments = ["-a", legalBrowser, urls[urls.length - 1]];
    spawn("open", cliArguments);
  } catch (_) {}
}

至于applescript脚本,它copy自cra,我只是在它的基础上加了一个fallback以支持降级查找,既然不是我写的,那我就不贴出来了

最后是关于hosts的获取,这也分成了三步

  • 获取本地可用的ipv4地址,并将符合条件的加入数组并最终返回
ts 复制代码
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对应的域名都是一个数组形式,因为要兼容一对多的情况
ts 复制代码
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;
}
  • 最后对两者进行合并即可
ts 复制代码
for (let i = 0; i < ips.length; i++) {
  const hosts = mapping[ips[i]];
  if (Array.isArray(hosts) && hosts.length) {
    ...
  }
}

使用

  • 安装
ts 复制代码
yarn add open-browser-for-webpack-mac -D
  • 使用
ts 复制代码
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篇类型体操,等你哟😎

相关推荐
开心工作室_kaic1 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
刚刚好ā1 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
沉默璇年2 小时前
react中useMemo的使用场景
前端·react.js·前端框架
yqcoder2 小时前
reactflow 中 useNodesState 模块作用
开发语言·前端·javascript
2401_882727573 小时前
BY组态-低代码web可视化组件
前端·后端·物联网·低代码·数学建模·前端框架
SoaringHeart3 小时前
Flutter进阶:基于 MLKit 的 OCR 文字识别
前端·flutter
会发光的猪。3 小时前
css使用弹性盒,让每个子元素平均等分父元素的4/1大小
前端·javascript·vue.js
天下代码客3 小时前
【vue】vue中.sync修饰符如何使用--详细代码对比
前端·javascript·vue.js
猫爪笔记3 小时前
前端:HTML (学习笔记)【1】
前端·笔记·学习·html
前端李易安4 小时前
Webpack 热更新(HMR)详解:原理与实现
前端·webpack·node.js