You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

iOS Safari中Stimulus控制器Fetch请求报"Load failed"错误求助

iPhone Safari生产环境下Fetch请求"Load failed"问题排查

生产环境中,部分iPhone设备的Safari浏览器发送Fetch请求时会出现"Load failed"网络错误,使用axios也存在相同问题。桌面端、安卓设备及iPhone上的其他浏览器均无此异常。

相关Stimulus控制器代码

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static get targets() {
    return ["question", "submit", "next", "back", "source", "preview", "error", "loading"];
  }

  static get values() {
    return { responseDetailsPath: String };
  }


  initialize() {
    console.log("Questionnaire controller initialized test for 24/06/2024")
    this.showCurrentQuestion(0);
    this.lastSelectedImages = {};
    this.hasUnsavedChanges = false;
    this.initInputListeners();
  }

  initInputListeners() {
    this.questionTargets.forEach((question) => {
      const inputs = question.querySelectorAll("input, select, textarea");
      inputs.forEach((input) => {
        input.addEventListener("change", () => {
          this.hasUnsavedChanges = true;
        });
      });
    });
  }

  displayLoadingAnimation(show) {
    if (show) {
      this.loadingTarget.classList.remove("d-none");
    } else {
      this.loadingTarget.classList.add("d-none");
    }
  }

  async next() {
    if (!this.validateResponse()) {
      return;
    }
    if (this.hasUnsavedChanges) {
      this.displayLoadingAnimation(true);
      try {
        await this.sendResponse();
      } catch (error) {
        this.displayLoadingAnimation(false);
        return;
      }
    }
    const currentIndex = this.currentQuestionIndex();
    if (currentIndex < this.questionTargets.length - 1) {
      this.showCurrentQuestion(currentIndex + 1);
    }
    this.updateButtonVisibility();
  }

  async previous() {
    const currentIndex = this.currentQuestionIndex();

    if (!this.validateResponse()) {
      if (currentIndex > 0) {
        this.showCurrentQuestion(currentIndex - 1);
      }
      this.updateButtonVisibility();
      return;
    }
    if (this.hasUnsavedChanges) {
      this.displayLoadingAnimation(true);
      try {
        await this.sendResponse();
      } catch (error) {
        this.displayLoadingAnimation(false);
        return;
      }
    }
    if (currentIndex > 0) {
      this.showCurrentQuestion(currentIndex - 1);
    }
    this.updateButtonVisibility();
  }

  updateButtonVisibility() {
    const currentIndex = this.currentQuestionIndex();
    this.backTarget.classList.toggle("d-none", currentIndex === 0);
    this.nextTarget.classList.toggle("d-none", currentIndex === this.questionTargets.length - 1);
    this.submitTarget.classList.toggle("d-none", currentIndex !== this.questionTargets.length - 1);
  }

  validateResponse() {
    const currentIndex = this.currentQuestionIndex();
    const currentQuestion = this.questionTargets[currentIndex];
    const input = currentQuestion.querySelector("input, select, textarea");
    const isRequired = input.required;
    const inputType = input.getAttribute("type");
    let isValid;

    if (inputType !== "file") {
      isValid = input.value.trim() !== "";
    } else {
      const fileSelected = input.files.length > 0;
      const existingImage = currentQuestion.dataset.existingImage && currentQuestion.dataset.existingImage.trim() !== "";
      const lastSelectedImage = this.lastSelectedImages[currentQuestion.dataset.itemQuestionId];
      isValid = fileSelected || existingImage || this.lastSelectedImages[currentQuestion.dataset.itemQuestionId];
    }

    const errorMessageDiv = this.errorTargets[currentIndex];
    if (!isValid && isRequired) {
      errorMessageDiv.style.display = "block";
      errorMessageDiv.textContent = "Пожалуйста, дайте ответ на текущий вопрос.";
      return false
    } else {
      errorMessageDiv.style.display = "none";
      return true
    }
  }

  showCurrentQuestion(index) {
    this.questionTargets.forEach((element, i) => {
      element.classList.toggle("d-none", i !== index);
      const errorMessageDiv = this.errorTargets[i];
      if (errorMessageDiv) {
        errorMessageDiv.style.display = "none";
      }
      if (i === index && element.dataset.existingImage) {
        this.updateImagePreview(element);
      }
    });
    this.hasUnsavedChanges = false;
  }

  updateImagePreview(questionElement) {
    const existingImageUrl = questionElement.dataset.existingImage;
    const previewTarget = questionElement.querySelector("[data-questionnaire-target='preview");
    if (existingImageUrl) {
      previewTarget.innerHTML = `<img src="${existingImageUrl}">`;
    } else {
      previewTarget.innerHTML = '';
    }
  }

  currentQuestionIndex() {
    return this.questionTargets.findIndex((element) => !element.classList.contains("d-none"));
  }

  async show(event) {
    const index = this.sourceTargets.indexOf(event.target);
    const previewTarget = this.previewTargets[index];
    const file = event.target.files[0];
    const currentIndex = this.currentQuestionIndex();
    const currentQuestion = this.questionTargets[currentIndex];

    if (file && file.type.match('image')) {
      try {
        const resizedFile = await this.resizeImage(file);
        const reader = new FileReader();
        reader.onload = function(e) {
          previewTarget.innerHTML = `<img src="${e.target.result}">`
        };
        reader.readAsDataURL(resizedFile);
        this.lastSelectedImages[currentQuestion.dataset.itemQuestionId] = resizedFile;
        this.hasUnsavedChanges = true;
      } catch (error) {
        console.error("Error resizing image:", error);
      }
    }
  }

  async submit(event) {
    event.preventDefault();
    if (!this.validateResponse()) {
      return;
    }
    this.displayLoadingAnimation(true);

    try {
      const isFinal = true;
      await this.sendResponse(isFinal);
      window.location.href = "/success";
    } catch (error) {
      this.displayLoadingAnimation(false);
    }
  }

  async sendResponse(isFinal = false) {
    const currentIndex = this.currentQuestionIndex();
    const currentQuestion = this.questionTargets[currentIndex];
    const input = currentQuestion.querySelector("input, select, textarea");
    const name = input.name;
    const value = input.value;

    let payload = {
      question_id: currentQuestion.dataset.itemQuestionId,
      hardware_id: currentQuestion.dataset.itemHardwareId,
      questionnaire_id: currentQuestion.dataset.itemQuestionnaireId,
      answer: value,
    };

    if (isFinal) {
      payload.is_final = true;
    }

    if (input.type === "file") {
      let file = input.files[0];
      if (file) {
        try {
          file = await this.resizeImage(file);
          payload.answer = await this.fileToBase64(file);
        } catch (error) {
          console.error("Error resizing or converting image:", error);
        }
      } else if (this.lastSelectedImages[currentQuestion.dataset.itemQuestionId]) {
        payload.answer = await this.fileToBase64(this.lastSelectedImages[currentQuestion.dataset.itemQuestionId]);
        this.lastSelectedImages[currentQuestion.dataset.itemQuestionId] = null;
      } else if (currentQuestion.dataset.existingImage) {
        payload.keep_existing_image = true;
      }
    }

    try {
      const response = await fetch(this.responseDetailsPathValue, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': document.querySelector("[name='csrf-token']").content,
        },
        body: JSON.stringify(payload),
      });

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.error || `HTTP Status ${response.status}`);
      }

      this.hasUnsavedChanges = false;
      return response;

    } catch (error) {
      console.error("Error sending response:", error);
      this.displayErrorMessage(currentQuestion, error.message);
      throw error;
    } finally {
      this.displayLoadingAnimation(false);
    }
  }

  fileToBase64(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = function (e) {
        resolve(e.target.result);
      };
      reader.onerror = function (error) {
        reject(error);
      };
      reader.readAsDataURL(file);
    });
  }

  displayErrorMessage(questionElement, message) {
    const errorMessageDiv = questionElement.querySelector("[data-questionnaire-target='error']");
    if (errorMessageDiv) {
      errorMessageDiv.style.display = "block";
      errorMessageDiv.textContent = message;
    }
  }

  findQuestionIndex(input) {
    let parentQuestion = input.closest("[data-questionnaire-target='question']");
    return this.questionTargets.indexOf(parentQuestion);
  }

  resizeImage(file, maxWidth = 1024, maxHeight = 1024, quality = 1) {
    return new Promise((resolve, reject) => {
      const img = document.createElement("img");
      const reader = new FileReader();

      reader.onload = function(e) {
        img.src = e.target.result;

        img.onload = function() {
          let width = img.width;
          let height = img.height;

          if (width > height) {
            if (width > maxWidth) {
              height *= maxWidth / width;
              width = maxWidth;
            }
          } else {
            if (height > maxHeight) {
              width *= maxHeight / height;
              height = maxHeight;
            }
          }

          const canvas = document.createElement("canvas");
          canvas.width = width;
          canvas.height = height;
          const ctx = canvas.getContext("2d");
          ctx.drawImage(img, 0, 0, width, height);

          canvas.toBlob((blob) => {
            resolve(new File([blob], file.name, { type: file.type, lastModified: Date.now() }));
          }, file.type, quality);
        };

        img.onerror = function(err) {
          reject(err);
        };
      };

      reader.onerror = function(err) {
        reject(err);
      };

      reader.readAsDataURL(file);
    });
  }
}

排查方向与修复建议

  • Safari预检请求兼容性:检查服务器是否正确响应OPTIONS预检请求,确保Access-Control-Allow-Origin、Access-Control-Allow-Headers(需包含X-CSRF-Token和Content-Type)等CORS头配置完整。部分旧版Safari对预检请求的处理逻辑与其他浏览器存在差异,需确保服务器返回的头信息符合标准。
  • 请求体积限制:若上传图片转base64后payload过大,Safari的POST请求大小限制可能触发错误。建议改用FormData直接上传二进制文件,避免base64编码导致的体积膨胀(base64会使文件体积增加约30%)。
  • TLS版本兼容性:部分旧iPhone的Safari仅支持TLS 1.0/1.1,而生产服务器可能禁用了这些旧版本。检查服务器TLS配置,确保支持TLS 1.2及以上版本;同时确认出现问题的设备iOS版本,若为iOS 10及以下,需考虑兼容性适配或提示用户升级系统。
  • CSRF Token获取逻辑:Safari在某些场景下可能无法正确获取[name='csrf-token']的content值,改用document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')确保Token获取正确,同时检查页面meta标签是否存在且属性无误。
  • 异步操作错误处理:确保sendResponse中的所有异步操作都有完整的错误捕获逻辑,避免未处理的Promise拒绝导致Safari终止请求。
  • 缓存与Service Worker干扰:生产环境的Service Worker可能缓存了旧的请求逻辑,尝试清除浏览器缓存或临时禁用Service Worker测试;若使用了缓存策略,需确保其在Safari中正常工作。

注:代码中的静态声明、图片转base64等写法为尝试修复的操作,最初的简单Fetch请求也存在相同错误,因此重点需放在基础请求的兼容性问题上。

内容的提问来源于stack exchange,提问作者obreil54

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 18:35:53