浏览器兼容问题解决方案:实用技巧与开源框架实践


theme: smartblue

前言

在前端开发的蓬勃发展中,我们频繁面对的一个挑战是低版本浏览器的兼容性问题。随着前端技术的飞速发展,尽管浏览器在更新方面也在努力跟上步伐,然而,技术的变革速度有时候会超过浏览器的更新速度。同时,依然存在大量用户坚守着较老版本的浏览器,形成了技术进步与用户实际使用之间的一种时差。这给前端开发者带来了一系列兼容问题和奇异的现象,成为了我们不可忽视的开发痛点。

从页面布局错乱到新特性不生效,从 JavaScript API 的缺失到各平台表现不一致,我们将逐步探究这些问题的原因。为什么一些布局样式在低版本浏览器上无法正常渲染?为何新的 JavaScript 语法无法在这些浏览器中顺利运行?遇到了这些问题我们又该如何解决?

希望这篇文章能够帮助你形成一套解决浏览器兼容问题的方法论,让你轻松克服各种兼容问题。

为啥不兼容?原因揭秘

布局样式在低版本浏览器无法正常渲染,核心原因为这些浏览器对于现代 CSS 属性的支持不足。其中最常见的为:

  • Flexbox 和 Grid 布局的支持较差
  • 媒体查询支持不足
  • 伪类和伪元素支持较差
  • 单位和属性值支持不足
  • 动画和过渡效果差异

可以通过 MDN 上的 CSS 参考 章节,了解 css 相关内容的兼容性。例如:

JavaScript 语法无法在低版本浏览器中顺利运行,核心原因为这些浏览器缺乏对于 ES6 及更高版本语法的支持,使得这些语法在执行时出现错误,甚至会导致页面白屏。

我们可以通过 caniuse 平台 ,了解 JavaScript 相关内容的兼容性(css 也可以)。例如:

搞定兼容性的常规招数

目前,社区提供了丰富的工具和方案来解决很多低版本浏览器兼容性问题,主要策略是将先进的语法转换为适应低版本浏览器的形式。在这一领域,postcssbabel 是两个常用的库,通常会与现代编译工具如 webpack、rollup 等结合使用,为开发者提供更便捷的兼容性处理方式。

利用 PostCSS 和 Babel 搞定常规问题

PostCSS 是一款用 JS 插件转换样式的工具。它会在项目编译时,自动添加浏览器私有前缀(如 -webkit--moz--ms-),转换新的 CSS 特性以适应低版本浏览器,同时对 CSS 代码进行静态分析,帮助发现并修复潜在问题,确保项目样式在各浏览器中正确解释和渲染。这使得开发者能够更轻松地实现 CSS 的跨浏览器兼容性,并提高项目的可维护性。

PostCSS 转换案例如下:

Babel 是一个主要用于将 ES6+ 代码转换为低版本浏览器兼容的 JavaScript 版本的工具。其主要作用包括语法转换、为目标环境提供缺失功能的 polyfill、源代码转换等。Babel 的原理是通过插件(plugin)和预设(preset)进行代码转换,根据配置文件中的规则将高版本 JavaScript 转换为目标版本的 JavaScript 代码,从而确保在不同环境中的兼容性。

Babel 解析流程图:

babel解析

实际案例:

结合 webpack 提高效率

在实际项目开发中,我们无需手动运行 babel 和 postcss 进行编译转换。只需将它们的配置文件集成到打包工具中,便能实现项目的自动化编译转换。

Webpack 是一款备受欢迎的现代化打包工具,具备强大的功能。它能深度分析项目的依赖关系,并将 JavaScript、CSS 等多种资源高效打包成可在浏览器中运行的静态文件。在 Webpack 中配置 Babel 和 PostCSS 的流程非常简单,下面将分别简述配置步骤。

首先,PostCSS 的使用需要先安装 postcss-loaderpostcss

$ npm install -D postcss-loader postcss

然后添加 postcss-loader 的相关配置到你的 webpack 的配置文件。例如:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["postcss-loader"],
        // use: ['style-loader', 'css-loader', 'postcss-loader'], // 一般会配合其他的 css loader 一起使用
      },
    ],
  },
};

对于 Babel 来说,我们也需要安装一些配套依赖 babel-loader@babel/core@babel/preset-env

$ npm install -D babel-loader @babel/core @babel/preset-env

在 webpack 配置对象中,需要将 babel-loader 添加到 module 列表中,并设置匹配的文件类型,就像下面这样:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/, // 匹配 js 文件
        exclude: /node_modules/, // 排除开源依赖
        use: {
          loader: "babel-loader",
        },
      },
    ],
  },
};

babel-loader 会默认读取根目录下的 babel.config.js 配置文件中自定义规则。

// babel.config.js
module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          browsers: ["last 3 versions", "Android >= 4.1", "ios >= 8"],
        },
      },
    ],
  ],
  plugins: ["@babel/plugin-transform-runtime"],
};

其中 @babel/preset-env 是 Babel 中一个非常常用的预设(preset)。它的作用是根据你所支持的环境,自动确定需要的 Babel 插件和转换规则,以实现对当前环境的代码转译。

结合 rollup 提高效率

Rollup 也是一个备受欢迎的现代化 JavaScript 模块打包工具,它专注于将现代的 JavaScript 代码(尤其是 ES6+ 模块化代码)打包成更小、更高效的输出。相较于 Webpack,尤其在构建库或框架时,它的优势更为明显。

在 Rollup 中,PostCSS 和 Babel 已经被封装为其独有的插件模块:rollup-plugin-postcss@rollup/plugin-babel,它的使用方式也愈加简单,下面将分别简述配置步骤。

首先安装对应的依赖模块 @rollup/plugin-babelpostcssrollup-plugin-postcss

$ npm i -D @rollup/plugin-babel
$ npm i -D postcss rollup-plugin-postcss

然后将插件配置添加到你的 rollup 配置文件中,例如:

// rollup.config.js
import postcss from "rollup-plugin-postcss";
import babel from "@rollup/plugin-babel";

export default {
plugins: [postcss(), babel({ babelHelpers: “bundled” })],
};

Rollup 也会默认读取根目录下的 babel.config.js 配置文件。(babel 相关依赖说明可以参考 babel 官网。)

// babel.config.js
module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          browsers: ["last 3 versions", "Android >= 4.1", "ios >= 8"],
        },
      },
    ],
  ],
  plugins: ["@babel/plugin-transform-runtime"],
};

特殊场景需要针对性处理

在浏览器兼容性处理中,除了要处理一般的 JavaScript 语法和 API 的兼容问题外,我们可能还需要针对性处理一些特殊场景。以下是一些常见的特殊场景(包括但不限于如下几点):

  • Web Components 兼容性
  • HTML 标签属性大小写兼容性
  • 特定样式显示的兼容性
  • iOS 下 input 光标聚焦后丢失问题
  • image 图片加载事件的兼容性
  • 不同浏览器中 box-sizing 属性的默认值差异

在特殊场景下,问题的解决方式各有不同,但也共享一些相似之处。首先,通过反复实验确认问题的复现场景,然后逐步缩小问题范围。最终,通过添加兼容代码或替换实现代码的方式解决问题。接下来,我们将通过几个实际案例详细说明问题的解决过程。

Web Components 不兼容处理

在使用 Web Components 时,我们需要注意浏览器对自定义元素、Shadow DOM、HTML Templates 和 HTML Imports 的原生支持程度。即使使用了工具如 Babel,仍需考虑浏览器差异和标准化进程,一般可以借助 webcomponents.org 官方提供的 polyfills,如 @webcomponents/webcomponentsjs ,下面将简述配置步骤。

首先,执行如下安装命令:

$ npm install @webcomponents/webcomponentsjs
// 应用代码中引入库
import "@webcomponents/webcomponentsjs";

或者通过 script 引用的方式:


其次,在项目中就可以放心使用 Web Component Api 了。

// custom-greeting.js

// 定义自定义元素
class CustomGreeting extends HTMLElement {
constructor() {
super();

// 获取属性值(name),如果没有提供,默认为"World"
const name = this.getAttribute("name") || "World";

// 创建一个 Shadow DOM
const shadow = this.attachShadow({ mode: "open" });

// 创建一个元素
const greeting = document.createElement("p");
greeting.textContent = `Hello, ${name}!`;

// 将元素添加到 Shadow DOM 中
shadow.appendChild(greeting);

}
}

// 将自定义元素注册到浏览器
customElements.define(“custom-greeting”, CustomGreeting);

页面使用自定义元素代码如下:


<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Components Demo</title>
<script src="custom-greeting.js"></script>


<!-- 使用自定义 Web Component 元素 -->
<custom-greeting name="John"></custom-greeting>

HTML 标签属性大小写不兼容处理

在开发中使用 Web Components 构建自定义元素时,尽管我们已经引入了 @webcomponents/webcomponentsjs 这个垫片(polyfill),并解决了一般兼容性问题,但有时仍会面临一些其他问题,例如在特定情况下无法正确监听属性值变更的问题。当前,这个问题在 iOS 10 的手机上真实存在。

遇到这种特殊的兼容性问题,通常需要耐心地进行反复实验,以找到问题的根本原因。

在 Web Components 中,监听属性变更主要通过一个名为 observedAttributes 的静态属性来定义需要观察的属性,然后通过 attributeChangedCallback() 生命周期回调来进行实际的监听。因此,为了解决这类特殊兼容性问题,我们进行了以下测试实验:

【常规使用方式】

/** 自定义组件调用 */

// 自定义组件实现 class MyCustomElement extends HTMLElement { static
observedAttributes = ["size", "bigSize"]; // 属性定义 constructor() { super(); }
attributeChangedCallback(name, oldValue, newValue) { // 相应值监听
console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}。`); } }
customElements.define("my-custom-element", MyCustomElement);

【测试 1】:监听 sizebigSize

static observedAttributes = ["size","bigSize"]; // 驼峰

结果:attributeChangedCallback 只能够监听到 size

【测试 2】:监听 sizebigsize

static observedAttributes = ["size","bigsize"]; // 全小写

结果:attributeChangedCallback 监听到 size 和 bigsize,但是非 iOS 10 的设备监听失效

【测试 3】:监听 sizebigsizebigSize

static observedAttributes = ["size","bigsize","bigSize"]; // 驼峰 + 全小写

结果:attributeChangedCallback 在 iOS 10 设备监听到 size 和 bigsize。非 iOS 10 设备监听到 size 和 bigSize。

【实验结论】

通过多次实验,我们发现在 iOS 10 的手机上,自定义元素中定义的所有驼峰形式的属性都无法正常生效,而全小写的属性依然能够正常使用。因此,在处理 HTML 标签属性的兼容性时,我们有两种主要选择:

  • 1、在业务使用侧进行语法规避: 确保在自定义组件的属性定义中,将所有属性修改为全小写形式。
  • 2、动态处理 observedAttributes: 判断 observedAttributes 中的属性值,如果存在驼峰形式的属性,动态添加相应的全小写形式属性,并调整监听逻辑。

如果我们使用一些第三方库来创建 Web Components 元素,且遇到类似问题,可以考虑在框架底层也进行相应的调整。例如,对于使用 lit-element 库的情况,可以根据下述代码进行调整:

// updating-element.js
static get observedAttributes() {
    this.finalize();
    const attributes = [];
    this._classProperties.forEach((v, p) => {
      const attr = this._attributeNameForProperty(p, v);
      if (attr !== undefined) {
        this._attributeToPropertyMap.set(attr, p);
        attributes.push(attr);
    // 兼容低版本系统属性值处理,动态注入全小写监听属性
    const attrToLower = attr.toLowerCase();
    if (attr !== attrToLower) {
      this._attributeToPropertyMap.set(attrToLower, p);
      attributes.push(attrToLower);
    }
  }
});
return attributes;

}

特定样式显示的不兼容处理

除了一般的 JavaScript 语法和 API ,以及一些浏览器特性能力的兼容问题外,还有一些常见的移动端兼容问题,需要根据具体情况采用不同的解决方案,以确保在不同设备上都能获得一致的显示效果。这里单独对 border 1px 在移动端显示问题 进行简单说明。

问题一:Retina 屏幕的 1px 像素问题

这里的 1px 问题指的是在一些 Retina 屏幕的设备上,移动端页面中设置的 1px 边框会显得比实际的 1px 更加粗。这种现象的原因很简单,CSS 中的 1px 并不能与移动设备屏幕上的 1px 完全对应。它们之间的比例关系由一个专门的属性来描述:

// 设备像素比
window.devicePixelRatio = 设备的物理像素 / CSS像素;

常见的解决方案其实非常多,建议根据实际情况进行选择。

方案 优点 缺点
直接写 0.5px 代码简单 IOS 及 Android 老设备不支持
用图片代替边框 全机型兼容 修改颜色及不支持圆角
background 渐变 全机型兼容 代码多及不支持圆角
box-shadow 模拟边框实现 全机型兼容 有边框和虚影无法实现
伪元素先放大后缩小 简单实用 缺点不明显
设置 viewport 解决问题 一套代码适用所有页面 缺点不明显

问题二:border 1px 转为 rem 单位时不显示

通过在 Chrome 中查看元素,我们可以发现实际上 border 仍然存在,只是 1px 被转换为非常小的 rem 数值(例如:0.01333rem)。这导致在部分安卓设备上可能出现 border 不显示的情况。这类兼容性问题主要是由于各大浏览器厂商在处理较小数值时的策略不同所导致的。一种常见的解决方式是通过使用伪元素的放大和缩小来调整,参考如下:

.scale {
  position: relative;
  border: none;
}
.scale:after {
  content: "";
  position: absolute;
  bottom: 0;
  background: #000;
  width: 100%;
  height: 2px; // 放大两倍
  transform: scaleY(0.5); // 再缩小
  transform-origin: 0 0;
}

开源框架中的兼容处理

在日常开发中,我们常常会使用一些开源框架(如 Vue、React、Uni-app、Taro 等)来进行项目开发。这些框架通常会提供一些常见的语法降级配置来处理业务代码,但有时框架本身可能也会引入一些高阶语法,导致最后的应用并不能很好的兼容低版本浏览器。在这种情况下,我们需要深入研究框架源码,并结合前文提到的 Babel 和 PostCSS 库,对高阶语法进行降级处理。

下面通过对 Taro 框架的一次兼容处理实践,来详细阐述如何在开源框架上进行兼容处理。

奇怪问题和现象

在一次偶然的测试验收中,我们发现同学 A 和同学 B 对同一个 Taro 项目进行编译到 H5 端时,同学 A 的项目能够正常显示,而同学 B 的项目却直接白屏。

为了找到问题背后的原因,我们需要尽量排除一些干扰项,于是在相同的 Node 版本、相同的 node_modules 依赖、相同的操作系统、相同的测试手机,以及相同的编译命令上进行了重复测试,但问题依然存在。

面对这个奇怪问题,我们首先还是要确认一下白屏问题的根本原因。于是,我们在 H5 应用的入口文件 index.html 中注入了 window.onerror 监听事件,用于捕获导致白屏的错误日志。打印的日志如下:Uncaught SyntaxError: 'super' keyword unexpected here。所以,问题的原因很明显是生成的应用中存在 JavaScript 的高阶语法,而低版本浏览器并不支持,所以阻塞了页面渲染从而导致了白屏。

那么为什么另一个同学编译生成的应用没有这个问题呢?

与此同时,我们还发现了两个同学编译生成的应用大小竟然存在着差异。

有问题的应用资源如下:

没有问题的应用资源(相对较大)如下:

结合上述两个现象,实际上已经可以确定问题的原因了:一个应用经过了 Babel 语法降级编译,而另一个应用则没有。然而,为什么相同的项目,一个会进行 Babel 转译,而另一个却不会呢?

在经过漫长的排查和定位之后,我们最终发现了一个匪夷所思的现象:竟然是项目的当前工作目录(pwd)路径中只要包含 taro 字符串,就会触发 Babel 转译。

这个发现引发了我们对项目配置和依赖的深入检查。通常情况下,Babel 转译是基于项目配置文件(如 .babelrcbabel.config.js)进行的,而并不依赖于当前工作目录的名称。然而,在这个特殊情况下,由于路径包含 taro,导致某些机制误判,触发了不必要的 Babel 转译,导致了生成物的不一致性。

临时处理的方案

在确认了 Taro 框架编译生成的 H5 应用的白屏原因后,我们立即提出了几个临时处理该问题的方案。

方案一:文件夹命名调整为 taro-xxx 格式。

由于通过多次实验的结果得知,只要工作目录(pwd)路径中包含 taro 字符,就会触发 Babel 编译。因此,我们约定项目名都改为 taro-xxx 格式,以规避这个问题。

方案二:所有 Taro 项目都统一存放在 taro-workspace 的工作空间下。

此方案类似于方案一,通过将所有项目存放在一个名为 taro-workspace 的工作空间下,确保在该空间下项目的集成打包都会触发 Babel 编译。特别适用于云平台的流水线集成打包项目。

方案三:在 Taro 框架的 webpackChain 配置中添加自定义 babel 编译。(推荐)

Taro 框架的底层编译工具是 webpack,并且它提供了让开发者自定义 webpack 配置的能力,只要我们对项目全文件进行 babel 编译,则也可以解决该问题。在 Taro 框架的 config/index.js 配置文件中,定义了 h5.webpackChain 函数,该函数接收两个参数,第一个是 webpackChain 对象,可参考 webpack-chain 的 API 进行修改,第二个参数是 webpack 实例。以下是示例代码:

// config/index.js
module.exports = {
  // ...
  h5: {
    webpackChain(chain, webpack) {
      chain.merge({
        module: {
          rule: {
            otherModules: {
              test: /\.js$/, // 表示当前项目中所有 js 都进行 babel 编译
              use: {
                babelLoader: {
                  loader: "babel-loader",
                },
              },
            },
          },
        },
      });
    },
  },
};

换一种写法可能更轻便:

// config/index.js
module.exports = {
  // ...
  h5: {
    webpackChain(chain, webpack) {
      chain.module
        .rule("otherModules")
        .test(/\.js$/)
        .use("babel")
        .loader("babel-loader"); // 表示当前项目中所有 js 都进行 babel 编译
    },
  },
};

尽管通过以上方案,我们能够解决 Taro 项目的 Babel 编译问题,但是如果缺乏对造成该问题的框架底层根本原因的清晰了解,或多或少都会带来一定的心智负担。因此,我们仍然需要进一步深入了解 Taro 框架的源码,以便更全面、彻底地理解和解决这一问题。

框架深度解析和处理

首先,我们可以看下 Taro 源码中的 webpack 配置模块 @tarojs/webpack5-runner 。其中,index.h5.ts 文件是 H5 端编译的入口文件,我们需要从中找到对应的 webpack 配置,核心逻辑分析如下:

// src/index.h5.ts
import { H5Combination } from "./webpack/H5Combination";
// ...
export default async function build(
  appPath: string,
  rawConfig: H5BuildConfig
): Promise {
  const combination = new H5Combination(appPath, rawConfig); // H5Combination 为 H5 端的联合配置模块
  // ...
  const webpackConfig = combination.chain.toConfig(); // webpack 的 config 配置来源
  // ...
  const compiler = webpack(webpackConfig); // 执行 webpack 编译逻辑
}

然后,我们需要进一步明确 webpackConfig 中 module 字段内容来源,需要分析如下几个文件 H5Combination.tsH5WebpackModule.tsWebpackModule.ts

// src/webpack/H5Combination.ts
import { H5WebpackModule } from "./H5WebpackModule";
// ...
export class H5Combination extends Combination {
  // ...
  webpackModule = new H5WebpackModule(this);
  // ...
  process(config: Partial) {
    const baseConfig = new H5BaseConfig(this.appPath, config);
    const chain = (this.chain = baseConfig.chain); // 获取 webpackChain
    // ...
    const module = this.webpackModule.getModules(); // 获取 webpack 的 module 配置
    // ...
    // 通过 webpackChain 方法将框架内部的默认 webpack 配置合并,包括了 module 字段
    chain.merge({
      entry,
      output: webpackOutput,
      mode,
      devtool: this.getDevtool(sourceMapType),
      resolve: { alias },
      plugin,
      module,
      optimization: this.getOptimization(mode),
      externals,
    });
  }
}
// src/webpack/H5WebpackModule.ts
import { WebpackModule } from "./WebpackModule";
// ...
export class H5WebpackModule {
  // ...
  // 生成默认的 module 配置,除了下面的 script 之外还有 css,img 等等
  getModules() {
    // ...
    const rule: Record = {
      // ...
      script: this.getScriptRule(), // 获取 script 匹配规则
    };
    return { rule };
  }

getScriptRule() {
const rule: IRule = WebpackModule.getScriptRule(); // js 模块的默认规则来源
// …
/**
* 要优先处理 css-loader 问题
*
* Compiles in dev, fails in prod: TypeError: $ is not a function · Issue #471 · webpack-contrib/mini-css-extract-plugin · GitHub
*
* 若包含 @tarojs/components,则跳过 babel-loader 处理
* 除了包含 taro 和 inversify 的第三方依赖均不经过 babel-loader 处理【重点】【重点】【重点】
*/
rule.exclude = [
(filename) =>
/css-loader/.test(filename) ||
/@tarojs[\/]components/.test(filename) ||
(/node_modules/.test(filename) &&
!(/taro/.test(filename) || /inversify/.test(filename))),
];

return rule;

}
}
// src/webpack/WebpackModule.ts
export class WebpackModule {
// …
static getScriptRule() {
return {
test: REG_SCRIPTS, // 匹配 js 文件
use: {
// 通过 babel 对文件内容转译
babelLoader: WebpackModule.getLoader(“babel-loader”, {
compact: false,
}),
},
};
}
}
</string,>

在深入阅读了上述的 H5WebpackModule.ts 文件中的 getScriptRule 规则后,我们终于理解了为什么只要工作目录(pwd)路径中包含有 taro 字符,就会触发 Babel 的转译。此外,我们也发现 Taro 框架对于内部引入的一些**第三方依赖(例如:inversify 库)**采用了枚举的方式进行有针对性的编译。这意味着,如果后续的源码开发者引入了一个未在配置中添加的新第三方依赖,并且该依赖存在问题,就有可能导致生成物中包含 JavaScript 高阶语法,最终引发应用在低版本浏览器中白屏的问题。

经过详细定位,我们发现在当前 Taro v3.6.19 版本中,存在问题的第三方依赖还包括 split-on-firststencilquery-string。通过下面的修改,我们能够让当前 Taro 版本编译的 H5 应用兼容到低版本浏览器,当然枚举的方式可能不是最优雅的解决方案。

rule.exclude = [
  (filename) =>
    /css-loader/.test(filename) ||
    /@tarojs[\\/]components/.test(filename) ||
    (/node_modules/.test(filename) &&
      !(
        /taro/.test(filename) ||
        /inversify|split-on-first|stencil|query-string/.test(filename)
      )),
];

最后

在本文中,我们深入研究了前端在低版本浏览器中的兼容性处理,从常见问题到开源框架的实践,探讨了多个层面的解决方案。通过对 JavaScript 语法和 API 兼容性问题、Web Components 兼容性、HTML 标签属性处理兼容性以及特定设备的样式兼容性等方面的讨论,希望能够帮助开发者,对于如何应对前端开发中的兼容性问题带来一定的启发。

在面对低版本浏览器兼容性问题时,及时了解项目中的具体情况,选择合适的解决方案至关重要。

最后,值得注意的是,前端技术日新月异,各种新的兼容性问题也可能随之出现。因此,保持对前端领域的关注,及时了解新技术和最佳实践,将是持续提升开发效率和用户体验的重要一环。

参考资料


这是一个从 https://juejin.cn/post/7368302351213903911 下的原始话题分离的讨论话题