PAYATHON 2026

为什么 Javascript 代码在桌面端和移动端 Safari 中无法正常运行?

排障赵师傅

先修正代码中能确认的问题,再根据 Safari 控制台的报错排查兼容性。仅凭这两段代码,还无法确定 Safari 中的唯一故障原因:第一段有括号配对错误,两段都依赖按钮的 innerText 精确匹配,第二段的类名判断有误,DOM 查询后也没有检查结果。提供的移动端 HTML 与 JavaScript 使用的选择器还不一致。

安装 Babel 后,仍需确认相关语法和浏览器 API 是否得到了兼容处理。

先检查第一段代码的语法错误

题目中粘贴的第一段代码,在事件回调结束处多了一个 }。事件监听和循环应这样闭合:

ItBtns.forEach(btn => {
  btn.addEventListener('click', event => {
    event.preventDefault();

    // 在这里执行内容切换
  });
});

如果部署文件中也有这个多余的括号,Chrome 同样应该报语法错误。既然 Chrome 能正常运行,就需要核对两个浏览器实际加载的是否为同一份构建文件、是否使用了旧缓存,以及粘贴代码时是否多复制了括号。

在 Safari 的 Network 面板中打开实际加载的 JavaScript 文件,结合控制台定位出错行。本地源码不能代替这一步检查。

不要依赖 innerText 判断点击了哪个服务

原代码用下面的条件决定是否更新内容:

btn.innerText === 'WEB DEVELOPMENT'

innerText 是元素渲染后的文本,会受到布局、换行和 CSS 等因素影响。读取结果只要与字符串不完全一致,就可能跳过所有分支,而且不会抛出异常。

可以先在 Safari 控制台查看实际值:

var btn = document.querySelector('.home-btn');

console.log(btn && JSON.stringify(btn.innerText));
console.log(btn && JSON.stringify(btn.textContent));

JSON.stringify() 可以让换行和首尾空白更容易辨认。如果仍需通过文本判断,可以读取标题节点:

var label = btn.querySelector('.btn-title');
var serviceName = label ? label.textContent.trim() : '';

按钮文字会随文案、样式和语言变化,用 data-service 标识业务类型更稳妥。

桌面端的修改示例

为五个按钮分别设置 web、mobile、design、analytics 和 api。Rails 模板中的第一个按钮可以这样写:

<%= link_to "#",
            class: "home-btn home-btn-black active",
            data: { service: "web" } do %>
  <span class="btn-title">WEB DEVELOPMENT</span>
<% end %>

保留原函数中的标题、描述常量,以及 title、text 查询,将 ItBtns.forEach(...) 部分替换为:

const services = {
  web: {
    title: webDevTitle,
    text: webDevText
  },
  mobile: {
    title: mobileAppTitle,
    text: mobileAppText
  },
  design: {
    title: prodDesignTitle,
    text: prodDesignText
  },
  analytics: {
    title: analyticTitle,
    text: analyticText
  },
  api: {
    title: apiDevTitle,
    text: apiDevText
  }
};

if (!title || !text) {
  return;
}

for (var i = 0; i < ItBtns.length; i++) {
  var button = ItBtns[i];

  if (button.getAttribute('data-services-bound') === 'true') {
    continue;
  }

  button.setAttribute('data-services-bound', 'true');

  button.addEventListener('click', function (event) {
    event.preventDefault();

    var key = event.currentTarget.getAttribute('data-service');

    if (!Object.prototype.hasOwnProperty.call(services, key)) {
      return;
    }

    title.textContent = services[key].title;
    text.textContent = services[key].text;
  });
}

这里用 textContent 写入普通文本,通过 data-service 区分按钮,循环也不再依赖 NodeList.prototype.forEach。

event.currentTarget 指向绑定监听器的按钮。即使用户点击内部的 <span>,也能取得按钮上的 data-service。

移动端代码中的 DOM 和状态判断问题

用 classList.contains() 判断是否包含某个类

原代码:

if (btn.classList != 'home-btn-black-v2') {

classList 是 DOMTokenList。上面的比较会涉及字符串转换,比较的是整个类名列表,不能用来判断其中是否包含某个类。

应改为:

if (!btn.classList.contains('home-btn-black-v2')) {
  // 更新选中状态
}

检查查询结果是否为 null

下面两处查询都假定页面已经有选中项:

selectElement('.home-btn-v2.home-btn-black-v2.active-v2')
selectElement('.section.show-description')

如果没有设置初始状态,或者选择器与 HTML 不一致,查询结果就会是 null。此时访问 .classList 或 .style 会抛出异常,中断本次点击处理。

访问属性前,至少应检查查询结果:

var expanded = document.querySelector('.section.show-description');

if (expanded) {
  expanded.style.height = '44px';
  expanded.classList.remove('show-description');
}

btn.nextElementSibling 也可能为 null,读取它的 offsetHeight 前同样需要检查。

核对移动端脚本需要的 HTML 结构

移动端脚本查找 .home-btn-v2 和 .section,但提供的第二份 HTML 仍使用 .home-btn,也没有 .section。

如果实际页面使用的就是这份 HTML,执行:

document.querySelectorAll('.home-btn-v2').length

结果会是 0,因此不会绑定事件监听器。这种情况在 Chrome 中也无法正常工作,需要核对移动端实际渲染的 DOM。

第二份模板中还有一个多余的 >:

<span class="btn-title">ANALYTICS & SEO</span>>

应删除这个字符,但仅凭它还无法判断整个交互失效的原因。

移动端优先让内容自然撑开容器

原代码的五个文字判断分支执行的是同一条高度赋值,可以合并为:

btn.parentElement.style.height = (88 + textHeight) + 'px';

其中的 44 和 88 都依赖具体 CSS。字体、换行或屏幕宽度变化后,固定高度可能截断内容。如果内容处于 display: none 状态,读到的 offsetHeight 还会是 0。

如果只需要展开内容,可以让容器随内容自动增高。下面的 HTML、CSS 和脚本相互对应,其余服务按相同结构添加:

<div class="services-mobile">
  <div class="section">
    <button
      type="button"
      class="home-btn-v2"
      aria-expanded="false"
      aria-controls="service-web-description"
    >
      WEB DEVELOPMENT
    </button>

    <div
      id="service-web-description"
      class="service-description"
    >
      <p>这里放置 Website Development 的说明文字。</p>
    </div>
  </div>
</div>
.services-mobile .section {
  height: auto;
}

.services-mobile .service-description {
  display: none;
}

.services-mobile .section.show-description .service-description {
  display: block;
}
function initItServicesClickV2() {
  var root = document.querySelector('.services-mobile');

  if (!root) {
    return;
  }

  var buttons = root.querySelectorAll('.home-btn-v2');

  for (var i = 0; i < buttons.length; i++) {
    var button = buttons[i];

    if (button.getAttribute('data-services-bound') === 'true') {
      continue;
    }

    button.setAttribute('data-services-bound', 'true');

    button.addEventListener('click', function (event) {
      var currentButton = event.currentTarget;
      var currentSection = currentButton.parentElement;

      for (var j = 0; j < buttons.length; j++) {
        var otherButton = buttons[j];
        var selected = otherButton === currentButton;

        otherButton.parentElement.classList.remove('show-description');
        otherButton.classList.remove('home-btn-black-v2');
        otherButton.classList.remove('active-v2');
        otherButton.setAttribute(
          'aria-expanded',
          selected ? 'true' : 'false'
        );
      }

      currentSection.classList.add('show-description');
      currentButton.classList.add('home-btn-black-v2');
      currentButton.classList.add('active-v2');
    });
  }
}

这个版本仍然一次只展开一个服务,再次点击当前项时保持展开。使用时,需要移除旧脚本写入的内联高度,并调整原有的固定高度样式。示例没有高度过渡动画;如需动画,还要单独处理尺寸测量和过渡。

检查 Babel 配置和构建结果

Babel 的语法转换与浏览器 API 支持需要分别检查。

  • 箭头函数、const、模板字符串等语法是否经过转换,取决于 Babel 配置、目标浏览器,以及文件是否经过构建流程。
  • 转换箭头函数不会自动补上 NodeList.prototype.forEach 等 DOM API 的支持。
  • export 必须经过适当的模块构建,或作为浏览器支持的模块脚本加载,不能直接放进普通脚本中执行。

如果控制台明确提示 forEach 不存在,可以使用前面示例中的普通 for 循环。改成 Array.from(...).forEach(...) 前也要确认兼容性,因为 Array.from 本身也有兼容性要求。

是否需要额外的 polyfill,要根据实际支持的 Safari 版本和报错判断。仅凭 Safari 运行失败,无法认定它不支持 ES6。

桌面端和移动端 Safari 的排查顺序

  1. 先看控制台的第一条错误。

    语法错误会阻止脚本执行;访问空节点的属性通常会中断当前初始化或事件处理。解决第一条错误后,再观察后续情况。

  2. 检查实际加载的脚本。

    在 Network 面板确认请求成功、响应内容确实是 JavaScript,并核对 Safari 与 Chrome 加载的资源版本。

  3. 检查选择器是否命中。

    console.log(document.querySelectorAll('.home-btn').length);
    console.log(document.querySelectorAll('.home-btn-v2').length);
    console.log(document.getElementById('it-services-title'));
    console.log(document.getElementById('it-services-description'));
  4. 确认初始化函数已执行。

    export 只导出函数,不会调用它。入口文件需要导入并执行初始化函数,且执行时对应的 DOM 必须已经存在。

  5. 根据 Rails 的页面导航方式处理初始化。

    普通页面加载可使用 DOMContentLoaded。如果项目使用 Turbolinks 或 Turbo,则分别检查对应的 turbolinks:load 或 turbo:load 生命周期。只注册项目实际需要的事件,并避免重复绑定监听器。

  6. 在真实移动设备上检查。

    在 Mac Safari 中通过 Web Inspector 连接 iPhone 或 iPad 的 Safari 页面,查看点击时的异常、元素样式和尺寸。仅缩窄桌面窗口,无法覆盖移动端 Safari 的全部运行条件。

如果点击处理已执行且没有异常,内容却仍不可见,再检查 display、height、overflow 和媒体查询,重点排查布局和尺寸计算。

备注:内容仅供参考。