当前位置:首页>iOSAPP>iOS WebView 的坑(一):innerHTML 渲染问题

iOS WebView 的坑(一):innerHTML 渲染问题

  • 2026-09-06 04:25:51
iOS WebView 的坑(一):innerHTML 渲染问题

诡异的渲染问题

最近在 iPad WebView 中遇到一个诡异的页面更新渲染问题。

设备环境:iPad WebView

问题代码如下:

const friendsEle = document.getElementById(friendsId)if (friendsEle && friends) {  if (friends.length > 0) {    const friendsText = friends.map((item) => {      return `<span style="margin-right: 10px;">${item}</span>`    })    friendsEle.innerHTML = friendsText.join('')  } else {    friendsEle.innerHTML = ''  }}

页面有 tab 可以切换,friends数组会随之更新,可能有值也可能为空。

预期行为:当friends有值时显示内容,为空时清空显示。

实际表现:当friends有值时能正常显示,但当friends为空时,页面只清空了第一项,其余项依然残留在页面上。

如下所示:

问题规律探索

与元素数量的关系

既然只清空了第一项,会不会和子元素数量有关?于是我进行了测试:

friends 长度

显示

清空

1 项

✅ 正常

✅ 正常

2 项

✅ 正常

❌ 只清空第一项

3 项

✅ 正常

❌ 只清空第一项

结论:当子元素超过 1 个时,innerHTML = ''无法正确清空所有元素。

调试与排查

可能的原因猜测

  • 节点未完成重新渲染

  • 更新时机问题

  • DOM 引用过期

调试代码验证

const debugInfo = {  userAgent: navigator.userAgent,  innerHTML: friendsEle.innerHTML,  childCount: friendsEle.children.length,  computed: {    display: getComputedStyle(friendsEle).display,    visibility: getComputedStyle(friendsEle).visibility,  },  offsetHeight: friendsEle.offsetHeight,  offsetWidth: friendsEle.offsetWidth,}log(`清空前: ${JSON.stringify(debugInfo)}`)// 执行清空friendsEle.innerHTML = ''log(`清空后: ${JSON.stringify({  innerHTML: friendsEle.innerHTML,  childCount: friendsEle.children.length,  offsetHeight: friendsEle.offsetHeight,})}`)// 延迟检查setTimeout(() => {  log(`1秒后: ${JSON.stringify({    innerHTML: friendsEle.innerHTML,    childCount: friendsEle.children.length,  })}`)}, 1000)

诡异的发现:清空前后的日志打印都是正确的——innerHTML确实为空,childCount也为 0。但页面渲染就是不对!

这说明问题出在WebKit 的渲染层,而非 DOM 层。

解决方案

方案一:强制重排

friendsEle.innerHTML = ''friendsEle.style.display = 'none'friendsEle.offsetHeight // 强制重排friendsEle.style.display = ''

方案二:双重强制重排

// 第一次清空friendsEle.innerHTML = ''// 强制重排1:移除friendsEle.style.display = 'none'void friendsEle.offsetHeight  // void 强调这是有意的副作用// 强制重排2:恢复friendsEle.style.display = ''void friendsEle.offsetHeight// 额外保险:延迟确认requestAnimationFrame(() => {  friendsEle.innerHTML = ''  // 再次清空  log('RAF 清空确认')})

方案三: 使用 Web API 强制刷新

friendsEle.innerHTML = ''// 方法1: 使用 requestAnimationFrame 链requestAnimationFrame(() => {  requestAnimationFrame(() => {    friendsEle.innerHTML = ''    log('双RAF清空')  })})// 方法2: 强制回流friendsEle.style.transform = 'translateZ(0)'  // 触发GPU加速void friendsEle.offsetHeightfriendsEle.style.transform = ''

方案四: 完全重建节点

const parent = friendsEle.parentNodeconst newEle = friendsEle.cloneNode(false)  // 浅拷贝(不含子节点)parent.replaceChild(newEle, friendsEle)log('节点已重建')

方案五:使用 transform 触发 GPU 加速

friendsEle.innerHTML = ''friendsEle.style.transform = 'translateZ(0)'void friendsEle.offsetHeightfriendsEle.style.transform = ''
以上方案都能解决问题。

另一个渲染残留问题

调试的过程中又发现另一个类似问题。
页面有一点描述文本,tab 切换也会改变内容。只不过这个内容是可以上下滚动的。所以切换时,让内容滚动在顶部。
const descEle = document.getElementById(descId)if (descEle) {  descEle.scrollTop = 0}
当在当前页面滚动内容,然后切换内容,就会发现如下的内容残留问题:
上一个画面的文字还有残留在页面上
和前面的问题一样,重排能够解决:
const descEle = document.getElementById(descId)if (descEle) {  // descEle.scrollTop = 0  // descEle.style.transform = 'translateZ(0)'  // void descEle.offsetHeight  // descEle.style.transform = ''  // 1. 更新内容(在其他地方已经做了)  // descEle.innerHTML = newContent  // 2. 强制重排(确保内容已渲染)  descEle.style.display = 'none'  void descEle.offsetHeight  // 强制计算  descEle.style.display = ''  // 3. 重置滚动  descEle.scrollTop = 0  // 4. 再次强制重排(确保滚动生效)  void descEle.offsetHeight}

跟内容的添加有无关系?

使用 DocumentFragment

// 使用 DocumentFragment 构建新内容const fragment = document.createDocumentFragment()friends.forEach((item) => {  const span = document.createElement('span')  span.style.marginRight = '10px'  span.textContent = item  // 使用 textContent 而不是 innerHTML  fragment.appendChild(span)})// 一次性插入(减少重排)friendsEle.appendChild(fragment)
使用DocumentFragment 无效

使用 textContent

// 使用单一文本节点 + 特殊空格const separator = '\u2003\u2003'  // 全角空格friendsEle.textContent = friends.join(separator)log(`执行后的 innerHTML: ${friendsEle.innerHTML}`)
使用 textContent 可以避免问题

其他因素探索

与 iframe 有关吗?

通过在 WebView 中独立页面测试,发现和 iframe 无关。

与特定 WebView 有关吗?

我在同系统 Safari 中测试,发现也能复现,但是情况比较复杂。

测试不同方式的清空

<!DOCTYPE html><htmllang="zh-CN"><head>  <metacharset="UTF-8">  <metaname="viewport"content="width=device-width, initial-scale=1.0">  <title>iPad innerHTML 渲染测试</title>  <style>    body {      font-family: Arial, sans-serif;      padding: 20px;      margin: 0 auto;    }    .container {      border: 2px solid #333;      padding: 20px;      margin: 20px 0;      min-height: 50px;      background: #f5f5f5;    }    button {      padding: 10px 20px;      font-size: 32px;      margin: 10px;      cursor: pointer;    }    .log {      background: #fff;      border: 1px solid #ddd;      padding: 10px;      margin-top: 20px;      font-family: monospace;      font-size: 32px;      min-height: 300px;      overflow-y: auto;    }    .log-item {      padding: 2px 0;      border-bottom: 1px solid #eee;    }  </style></head><body>  <h1>innerHTML 渲染测试</h1>  <div>    <buttononclick="toggleContent()">切换内容(innerHTML + span)</button>    <buttononclick="toggleTextContent()">切换内容(textContent)</button>    <buttononclick="toggleWithReset()">切换内容(强制重排)</button>    <buttononclick="clearLog()">清空日志</button>  </div>  <h3>测试容器(innerHTML 方式):</h3>  <pid="friendsId"class="container"></p>  <h3>测试容器(textContent 方式):</h3>  <pid="friendsText"class="container"></p>  <h3>调试日志:</h3>  <divid="logContainer"class="log"></div>  <script>    const friends = ['233', '哈哈哈'];    let hasContent = false;    function log(message) {      const logContainer = document.getElementById('logContainer');      const timestamp = new Date().toLocaleTimeString();      const logItem = document.createElement('div');      logItem.className = 'log-item';      logItem.textContent = `[${timestamp}] ${message}`;      logContainer.appendChild(logItem);      logContainer.scrollTop = logContainer.scrollHeight;      console.log(message);    }    function clearLog() {      document.getElementById('logContainer').innerHTML = '';    }    // 方式1: 原始的 innerHTML + span 方式    function toggleContent() {      const friendsEle = document.getElementById('friendsId');      log('=== 开始切换(innerHTML 方式) ===');      log(`切换前状态: hasContent=${hasContent}`);      log(`切换前 innerHTML: "${friendsEle.innerHTML}"`);      log(`切换前子元素数量: ${friendsEle.children.length}`);      hasContent = !hasContent;      if (hasContent) {        const friendsText = friends.map((item) => {          return `<span style="margin-right: 10px;">${item}</span>`;        });        friendsEle.innerHTML = friendsText.join('');        log('设置内容');      } else {        friendsEle.innerHTML = '';        log('清空内容');      }      log(`切换后 innerHTML: "${friendsEle.innerHTML}"`);      log(`切换后子元素数量: ${friendsEle.children.length}`);      // 延迟检查      setTimeout(() => {        log(`[延迟100ms] innerHTML: "${friendsEle.innerHTML}"`);        log(`[延迟100ms] 子元素数量: ${friendsEle.children.length}`);      }, 100);    }    // 方式2: textContent 方式    function toggleTextContent() {      const friendsEle = document.getElementById('friendsText');      log('=== 开始切换(textContent 方式) ===');      log(`切换前 textContent: "${friendsEle.textContent}"`);      hasContent = !hasContent;      if (hasContent) {        // 使用全角空格作为间隔        friendsEle.textContent = friends.join('\u2003\u2003');        log('设置内容');      } else {        friendsEle.textContent = '';        log('清空内容');      }      log(`切换后 textContent: "${friendsEle.textContent}"`);    }    // 方式3: innerHTML + 强制重排    function toggleWithReset() {      const friendsEle = document.getElementById('friendsId');      log('=== 开始切换(强制重排方式) ===');      log(`切换前 innerHTML: "${friendsEle.innerHTML}"`);      hasContent = !hasContent;      if (hasContent) {        // 再设置内容        const friendsText = friends.map((item) => {          return `<span style="margin-right: 10px;">${item}</span>`;        });        friendsEle.innerHTML = friendsText.join('');        log('设置内容(含强制重排)');      } else {        // 强制清空        friendsEle.style.display = 'none';        friendsEle.innerHTML = '';        void friendsEle.offsetHeight; // 强制重排        friendsEle.style.display = '';        log('清空内容(含强制重排)');      }      log(`切换后 innerHTML: "${friendsEle.innerHTML}"`);      log(`切换后子元素数量: ${friendsEle.children.length}`);    }    log('页面加载完成,准备测试');  </script></body></html>

测试多项数据

<!DOCTYPE html><htmllang="zh-CN"><head>  <metacharset="UTF-8">  <metaname="viewport"content="width=device-width, initial-scale=1.0">  <title>iPad innerHTML 渲染测试</title>  <style>    body {      font-family: Arial, sans-serif;      padding: 20px;      margin: 0 auto;    }    .container {      border: 2px solid #333;      padding: 20px;      min-height: 50px;      background: #f5f5f5;    }    button {      padding: 10px 20px;      margin: 5px;    }  </style></head><body>  <h1>innerHTML 渲染测试</h1>  <buttononclick="test1()">测试1项</button>  <buttononclick="test2()">测试2项</button>  <buttononclick="test3()">测试3项</button>  <buttononclick="clear1()">清空(有问题)</button>  <buttononclick="clearFixed()">清空(修复版)</button>  <pid="friends"class="container"></p>  <divid="log"></div>  <script>    const friendsEle = document.getElementById('friends')    const logEle = document.getElementById('log')    function log(msg) {      logEle.innerHTML += `<div>${newDate().toLocaleTimeString()}: ${msg}</div>`    }    function test1() {      const friends = ['233']      render(friends)    }    function test2() {      const friends = ['233', '哈哈哈']      render(friends)    }    function test3() {      const friends = ['233', '哈哈哈', '_-:']      render(friends)    }    function render(friends) {      friendsEle.innerHTML = friends.map(item =>        `<span style="margin-right: 10px;">${item}</span>`      ).join('')      log(`渲染 ${friends.length} 项`)    }    // 有问题的清空方式    function clear1() {      friendsEle.innerHTML = ''      log(`清空后 children: ${friendsEle.children.length}, innerHTML: "${friendsEle.innerHTML}"`)    }    // 修复版清空方式    function clearFixed() {      friendsEle.style.display = 'none'      friendsEle.innerHTML = ''      void friendsEle.offsetHeight  // 强制重排      friendsEle.style.display = ''      log(`修复版清空 children: ${friendsEle.children.length}`)    }  </script></body></html>
对比在 App WebView 和 Safari 中的表现。

结果

Safari 中,偶尔点击第二次,一开始也是没清除,但显然是延迟 100ms 后清除了
偶尔点击直接清除了

有问题情况

显示内容:
清空内容:

有问题的多项渲染清空

显示内容:

有问题的多项渲染清空,但是多次点击能清空

显示内容:
清空,点击 13 次,就清空了:
之后再显示,清空,都没问题了:

总结

这是一个 iOS WebKit 的渲染 Bug:DOM 已正确更新,但渲染层未同步刷新。

核心原因

iOS WebKit 在处理innerHTML批量清空多个子元素时,渲染层可能未能及时同步 DOM 的变化。

解决方案

// 清空内容时,强制触发重排element.style.display = 'none'void element.offsetHeightelement.innerHTML = ''element.style.display = ''

最佳实践建议

如果不需要 HTML 标签,优先使用

textContent

涉及批量 DOM 操作时,考虑在操作后强制重排

在 iOS WebView 中进行充分测试,特别是涉及频繁 DOM 更新的场景

最新文章

随机文章