我是如何通过手写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篇类型体操,等你哟😎

相关推荐
桂月二二25 分钟前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062062 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb2 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角2 小时前
CSS 颜色
前端·css
浪浪山小白兔3 小时前
HTML5 新表单属性详解
前端·html·html5
lee5763 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm
2401_897579653 小时前
AI赋能Flutter开发:ScriptEcho助你高效构建跨端应用
前端·人工智能·flutter
limit for me4 小时前
react上增加错误边界 当存在错误时 不会显示白屏
前端·react.js·前端框架
浏览器爱好者4 小时前
如何构建一个简单的React应用?
前端·react.js·前端框架
qq_392794484 小时前
前端缓存策略:强缓存与协商缓存深度剖析
前端·缓存