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
相关产品推荐
相关产品推荐

