WKWebView didFinish首次evaluateJavascript获取scrollHeight值异常
问题成因
WKNavigationDelegate的webView(_:didFinish:)回调触发时机,仅代表主框架的HTML资源、主脚本、主样式表的网络加载完成、DOM树初步构建完成,不代表页面完成重排(reflow)、所有异步资源(图片、自定义字体、懒加载内容)渲染完成、JS动态插入的节点布局完成。- WebKit采用渐进式渲染管线:DOM解析完成后会立刻做一次初步布局触发didFinish回调,此时如果存在未加载完成的大尺寸资源(比如未获取尺寸的高清图会临时按原始分辨率占位、未加载的自定义字体会导致文本按默认大尺寸排版)、JS在
DOMContentLoaded后批量插入的DOM节点还未完成二次布局,此时计算的scrollHeight会包含临时占位的异常高度,就会出现数值远大于最终实际高度的情况。你遇到的20w+异常值基本都是未缩放的图片占位导致的。 - 硬编码等待1.5秒能拿到正确值,本质是恰好等渲染管线跑完了所有布局流程,但该方案存在明显缺陷:不同性能设备的页面渲染耗时从几百毫秒到数秒不等,固定延迟要么导致不必要的等待,要么在低端设备上依然拿不到正确值;同时未指定执行Actor的
Task会运行在全局并发线程池,而非主线程,违反了evaluateJavaScript必须在主线程调用的要求,才会触发Xcode的线程警告。 - Safari调试时能看到正确高度,是因为手动打开调试器的耗时远大于页面渲染完成的时间,此时页面已经完成所有重排流程,自然展示最终正确值。
正确解决方案
禁止使用硬编码固定延迟的方案,采用基于原生布局回调/JS渲染完成通知的方式获取稳定高度,所有WebKit相关API调用必须保证在主线程执行。
方案1:KVO监听scrollView内容高度(无JS注入,稳定性最高)
WKWebView内部的scrollView.contentSize会在每次页面布局更新时自动刷新,我们可以在didFinish回调后启动KVO监听,当检测到高度连续两次一致(偏差小于1pt)时判定布局稳定,更新高度值后立刻移除监听,避免重复触发:
extension WebView { class Coordinator: NSObject, WKNavigationDelegate { var parent: WebView private var heightObservation: NSKeyValueObservation? private var lastRecordedHeight: CGFloat = 0 init(_ parent: WebView) { self.parent = parent } public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { guard !webView.isLoading else { return } DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.lastRecordedHeight = 0 // 监听内容尺寸变化 self.heightObservation = webView.scrollView.observe(\.contentSize, options: .new) { [weak self] scrollView, change in guard let self = self, let newHeight = change.newValue?.height, newHeight > 0 else { return } // 高度稳定后更新值 if abs(newHeight - self.lastRecordedHeight) < 1 { self.parent.dynamicHeight = newHeight // 移除监听避免重复触发 self.heightObservation?.invalidate() self.heightObservation = nil return } self.lastRecordedHeight = newHeight } } } // 新导航开始时清理旧监听,避免内存泄漏 func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { heightObservation?.invalidate() heightObservation = nil lastRecordedHeight = 0 } } }
该方案完全基于WebKit原生布局回调,不需要注入JS,没有额外等待耗时,布局完成后立刻拿到准确高度,适配所有类型的网页内容。
方案2:JS侧主动通知渲染完成(适用于自定义可控的HTML页面)
如果加载的是自定义传入的HTML内容,可以直接在JS侧监听所有资源加载完成,等两帧渲染确保布局稳定后,通过ScriptMessageHandler把高度主动推给原生:
- 提前给WKWebView注入监听脚本:
let heightDetectJS = """ window.addEventListener('load', () => { // 连续等待两个渲染帧,确保重排完成 requestAnimationFrame(() => { requestAnimationFrame(() => { // 兼容不同CSS设置,取所有高度属性的最大值 const finalHeight = Math.max( document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight ); // 向原生发送高度消息 window.webkit.messageHandlers.heightNotify.postMessage(finalHeight); }) }) }) """ let script = WKUserScript(source: heightDetectJS, injectionTime: .atDocumentEnd, forMainFrameOnly: true) webView.configuration.userContentController.addUserScript(script)
- 让Coordinator实现
WKScriptMessageHandler协议,收到heightNotify消息时在主线程更新dynamicHeight即可。
注意事项
- 所有WKWebView、
evaluateJavaScript、scrollView相关的UI操作必须放在主线程执行,如果使用Swift Concurrency,需要用@MainActor或await MainActor.run包裹相关代码,避免线程警告。 - 不要仅读取
document.body.scrollHeight作为最终高度,部分页面的CSS会设置body高度为100%、修改overflow属性,会导致body的scrollHeight计算异常,建议同时读取documentElement和body的多个高度属性取最大值,过滤异常结果。
内容的提问来源于stack exchange,提问作者alyx
相关产品推荐
相关产品推荐

