Vite兼容方案探索

3,558 阅读9分钟

故事背景

近期发生一起 ‘惊人事件’,重庆某男子,晚上 7 点独自一人开着小面飞驰在解放路上,原本 2 个小时的行程,1 个小时就到达终点,小手一点,结束了订单。在体验到了赚钱的快感后,便准备拉他的好友李某下水,于是在某拉的 APP 上面发现了司机拉新送现金功能,就在此时,惊人的一幕出现了:页面消失了!嗯?它消失了,对,消失的它。

当晚狂风造作,何某紧急联系重庆警方小郑,郑警官火速出警,经过邻里走访取证,同行页面均访问正常,不存在内容消失情况。何某开始躁动起来:如果我朋友不能跟我一起来挣钱,留它何用!

第二天便找来律师小陈,经走访调查,发现该重庆男子何某,所用手机为京东 618 期间购买,售价 314,品牌型号未知 (不肯说,也不知道,只知道不是苹果),在一番非常人的询问下,陈律师确认页面的确消失了。

警方发布通告,汽销拉新,身高 155.85 毫米,在当晚 7 点丢失于解放路,警方公布照片如下:

image.png

以上故事为虚构,借用故事来描述前端白屏问题。

问题取证

陈律师一眼便知是老旧机所致,但苦证据久矣。找来伙计 (内部工具),经过 2 个小时拷问,在刺眼的聚光灯下,何某开口了:Vivo X20。

就在此时,律师陈某伙同警方快速锁定线索:

  • vivo x20 于 17 年发布,Android7.x 系统,chrome 53 (不是绝对的,版本是可以升级的)

    官方 7.0 对应的是 chrome51:developer.android.com/about/versi…

  • 控制台提示:no Vue instance found

  • import 语法不支持

  • globalThis 变量报错

漆黑的夜晚,星空璀璨,可它却消失不见。

解决方案

当务之急

  • 解决 globalThis 问题
  • Vue 加载失败问题
  • Vite 项目兼容降级

Vite 现有方案

为现代浏览器而生,不仅构建快,而且打包出的文件支持 ESM,体验感极强。

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

script 标签添加 type=module 后,浏览器便会按模块的方式进行加载和执行,同时 main.js 中的代码也必须试使用用 importexport 定义导入和导出模块。如果浏览器不支持,便会忽略 type="module" 脚本,后文会解释原因。

JS 加载方式

  • 直接加载:<script src="main.js"></script>

特点:阻塞 Dom 渲染。

  • Async 模式:<script async src="main.js"></script>

特点:异步加载,多个 async 脚本,无序执行,谁先加载完成,谁先执行。

  • Defer 模式:<script defer src="main.js"></script>

特点:延迟加载,多个 defer 脚本,有序执行。

  • Module 模式:<script type="module" src="main.js"></script> 特点:现代浏览器加载模式,异步加载,有序执行。

    也可以配合 importmap 一起使用,但注意 importmap 必须在 module 模块之前定义:

    <script type="importmap">
      {
        "imports": {
          "lodash": "/node_modules/lodash-es/"
        }
      }
    </script>
    <script type="module">
      import { debounce } from 'lodash';
    </script>
    
  • Preload 模式:<link rel="preload" href="main.js"></script>

    特点:提前加载。针对页面重要资源,提前加载。

  • Prefetch 模式:<link rel="prefetch" href="main.js"></script>

特点:资源预加载。对于后续使用的资源提前加载,提升后续的打开速度。

因此在现代浏览器下可继续使用 ESM 模式加载,在传统浏览器下,需要生成 polyfill 文件。

如何降级

Vite 打包文件默认支持 ES Module,官方提供 @vitejs/plugin-legacy 插件兼容传统浏览器。

安装插件

# 安装插件
yarn add @vitejs/plugin-legacy -D

# 安装 terser
yarn add terser -D

Vite 默认基于 esbuild 压缩构建,但 legacy 插件打包传统文件时,基于 terser 压缩算法。

项目配置

// vite.config.js
import legacy from '@vitejs/plugin-legacy';
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    target: ['es2015'],
  },
  plugins: [
    legacy({
      targets: ['chrome > 52'],
    }),
  ],
});

默认构建目标:支持 ESM Script 标签、支持 ESM 动态导入。

注意:build 中的 target 最低支持 es2015,修改为 chrome 53 是无效的,legacy 会覆盖目标版本。

www.vitejs.net/guide/build…

Targets 遵循 browserlist 规范:github.com/browserslis…

  • last 2 versions
  • not ie <= 8
  • Chrome 61
  • Chrome > 61
  • defaults (> 0.5%, last 2 versions,Firefox ESR,not dead)

globalThis 处理

globalThis 是一个全局对象,用于统一不同 Javascript 环境下的 this 对象。

  • 浏览器环境:window、self、frames、globalThis
  • Node 环境:global、globalThis

globalThis 变量在低版本下不存在,因此还需要做兼容处理。

caniuse.com/?search=glo…

错误提示

为何代码会存在 globalThis?

一开始,我怀疑是 Vue.js 做了跨平台环境判断导致的,后来发现是项目中使用的一个插件被 vite 打包生成了 globalThis 导致的。

解决方案:

一、网页头部增加变量赋值

<script>
  this.globalThis || (this.globalThis = this);
</script>

二、legacy 增加 polyfill

legacy({
  targets: ['chrome > 52'],
  modernPolyfills: ['es.global-this'],
});

给现代浏览器增加 globalThis 的 polyfill,打包后会生成 polyfill 文件。还是推荐使用第一种,因为这种会把 core-js 很多内容打进来。

github.com/zloirock/co…

Vue 加载问题

根据报错提示:no Vue instance found,说明 vue.js 文件未加载成功,因为里面涉及脚本报错,代码执行中断。

  • 可能是 globalThis 报错引起的。
  • 重新升级了 Vue3 的版本。
  • 兼容降级处理后,问题自然消失。

结局

经过以上步骤后,就完美解决了老旧机型的兼容问题。

思考

  • 会不会影响构建速度
  • 会不会影响整体的性能
  • @vitejs/plugin-legacy 是如何降级的
  • @vitejs/plugin-legacy 都有哪些配置

会不会影响构建速度?

肯定会,打包后,每个 chunk 文件附带生产了一份 legacy 文件,过去打包在 40s 左右,现在增加到了 60s。

会不会影响整体性能?

我们先看一下打包的 index.html 文件,为了方便演示,我删除了不相关的代码。

<!doctype html>
<html lang="en" data-van-env="stg">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover"
    />
    <title>邀请好友</title>
    <meta
      name="description"
      content="邀请没有车的司机或朋友成功租买车,立马得奖金,奖励无上限!"
    />
    <script type="module" crossorigin src="/assets/index-fa4d14dd.js"></script>
    <script type="module">
      import.meta.url;
      import('_').catch(() => 1);
      async function* g() {}
      if (location.protocol != 'file:') {
        window.__vite_is_modern_browser = true;
      }
    </script>
    <script type="module">
      !(function () {
        if (window.__vite_is_modern_browser) return;
        console.warn(
          'vite: loading legacy chunks, syntax error above and the same error below should be ignored',
        );
        var e = document.getElementById('vite-legacy-polyfill'),
          n = document.createElement('script');
        (n.src = e.src),
          (n.onload = function () {
            System.import(
              document
                .getElementById('vite-legacy-entry')
                .getAttribute('data-src'),
            );
          }),
          document.body.appendChild(n);
      })();
    </script>
  </head>

  <body>
    <div id="app">
      <div class="loading">
        <img
          src="https://static.huolala.cn/image/63cbe5c7aceb40104d8a0d2c4295621c3fcf00f5.svg"
          alt="loading"
        />
        <p>加载中...</p>
      </div>
    </div>

    <script nomodule>
      !(function () {
        var e = document,
          t = e.createElement('script');
        if (!('noModule' in t) && 'onbeforeload' in t) {
          var n = !1;
          e.addEventListener(
            'beforeload',
            function (e) {
              if (e.target === t) n = !0;
              else if (!e.target.hasAttribute('nomodule') || !n) return;
              e.preventDefault();
            },
            !0,
          ),
            (t.type = 'module'),
            (t.src = '.'),
            e.head.appendChild(t),
            t.remove();
        }
      })();
    </script>
    <script
      nomodule
      crossorigin
      id="vite-legacy-polyfill"
      src="/assets/polyfills-legacy-cd4abac1.js"
    ></script>
    <script
      nomodule
      crossorigin
      id="vite-legacy-entry"
      data-src="/assets/index-legacy-b8298135.js"
    >
      System.import(
        document.getElementById('vite-legacy-entry').getAttribute('data-src'),
      );
    </script>
  </body>
</html>

入口文件中,有三个 module,三个 nomodule,他们实际上是 ES6 Module,chrome 61 就已经支持了,当前只有 IE 不支持。

分析:

  • 在支持 ESM 的浏览器中,会按照模块解析该文件,同时忽略 nomodule 所在的脚本。
  • 在不支持 ESM 的浏览器中,会忽略 module 所在脚本,执行 nomodule 所在脚本。

仔细思考一下,这是不是就是实现类似版本发布和回退的功能?

为什么不支持 ESM 的浏览器不会执行 module 对应的脚本?

因为 script 标签,只能解析 type=“text/javascript” 脚本,如果没有指定 type 属性,则默认为 text/javascript,而 type=“module” 会认为是一个无效的脚本,刚好 nomodule 没有 type 属性,此时会优先执行。

所以,对于现代浏览器来说,不会解析使用 core-js polyfill 出来的文件,因此并不会影响加载性能。但是,在低版本浏览器下面,由于引入了很多 core-js 实现的传统代码,一定程度上有所影响,但影响也很有限,因为本身 legacy 文件依然是按需加载。

不得不说,设计的真是精妙!!!

@vitejs/plugin-legacy 是如何降级的

上面已经介绍过了 type=“module” 和 nomodule 的作用,接下来看一下解析过程。

现代浏览器标记

<script type="module">
  import.meta.url;
  import('_').catch(() => 1);
  async function* g() {}
  if (location.protocol != 'file:') {
    window.__vite_is_modern_browser = true;
  }
</script>

这是一坨发了疯的代码,看似毫无规律,实则暗藏凶器,这段代码就是用来检测 ESM 是否支持,比如:import、import()、async 等,如果支持,会标记 __vite_is_modern_browser 为 TRUE。

你可能会好奇,如果浏览器不支持,页面岂不是挂了?当然不是,上文已经说过了,如果浏览器不支持代码块并不会执行。

还有一种情况,type=“module” 支持,但是里面的代码快报错了,比如 async 不支持,怎么办,页面会不会白屏?答案是不会,因为 type=“module” 也算异步加载,里面语法报错,不会阻塞外部脚本执行。

传统浏览器加载兼容脚本

继续看一段代码:

<script type="module">
  !(function () {
    if (window.__vite_is_modern_browser) return;
    console.warn(
      'vite: loading legacy chunks, syntax error above and the same error below should be ignored',
    );
    var e = document.getElementById('vite-legacy-polyfill'),
      n = document.createElement('script');
    (n.src = e.src),
      (n.onload = function () {
        System.import(
          document.getElementById('vite-legacy-entry').getAttribute('data-src'),
        );
      }),
      document.body.appendChild(n);
  })();
</script>
<script
  nomodule
  crossorigin
  id="vite-legacy-polyfill"
  src="https://static.huolala.cn/activity/357059/assets/polyfills-legacy-cd4abac1.js"
></script>
<script
  nomodule
  crossorigin
  id="vite-legacy-entry"
  data-src="https://static.huolala.cn/activity/357059/assets/index-legacy-b8298135.js"
>
  System.import(
    document.getElementById('vite-legacy-entry').getAttribute('data-src'),
  );
</script>

上文已经描述过,如果是现代浏览器,直接执行对应脚本,nomodule 模块全部忽略,这段代码刚好有一个标记判断,假如是现代浏览器,直接 return。如果是传统浏览器,则继续往下执行:

  • 从新获取 id=“vite-legacy-polyfill” 对象,再次执行对应脚本 (因为默认已经执行过一次了)。
  • 再次执行的目的是为了动态加载兼容版本的 entry 文件。
  • 入口文件默认设置的是 data-src 并不会立刻被解析,默认只是加载了 polyfill 对应的脚本。
  • System 对象来自于 polyfill-legacy 文件,也是一个模块化系统,传统浏览器不支持 ESM,就用 System 包模拟了一套模块化系统。参考官方 Github:github.com/systemjs/sy…

@vitejs/plugin-legacy 配置

官方文档:github.com/vitejs/vite…

targets:

构建目标,获取浏览器目标以后会交给 @babel/preset-env 处理,而底层依然基于 core-js 处理,并不是 vite 自己做的降级。

编写方式同 browserlist 规范。github.com/browserslis…

polyfills:

传统浏览器对应的 polyfill。

modernPolyfills:

现代浏览器开启 polyfills。针对现代浏览器,可能出现的不兼容问题,通过此字段进行配置。官方强烈不推荐设置为 TRUE。默认为 FALSE,一旦开启为 TRUE 以后,所有的 ES 特性都会被 polyfill,增加包体积。

总结

  • Vite 开启 legacy 兼容降级。
  • Module 和 nomodule 使用妙处。
  • globalThis 加载处理。
  • Vite 降级基于 @babel/preset-env 插件集实现,最终底层基于 core-js 实现。
  • 兼容模式下,vite 基于 terser 压缩算法,默认基于 esbuild 构建。
  • Vite 降级后,依然实现按需加载。同时支持现代浏览器的 ESModule 加载方式,同时又能回退到传统浏览器版本。