我发现了一个React、Vue等所有前端框架都存在的隐秘Bug?

4,964 阅读4分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第11篇文章,点击查看活动详情

什么 Bug?

昨天有个朋友请教了我一个问题,她在使用原生的 Details 元素封装一个手风琴组件。但是无论如何都不能按照预期工作。

起初我认为是她水平比较差,代码写的有问题。但是她一再向我保证绝对不是她的问题。所以我就抽出时间帮她看了一下。意外发现这一个框架的隐秘 Bug。

我把这个代码放到了码上掘金上,你可以看一下。

import React, { useState } from 'react';
import ReactDom from 'react-dom';

function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <span>状态: {isOpen ? 'open' : 'closed'}</span>

      <details open={isOpen}>
        <summary
          onClick={() => {
            setIsOpen(!isOpen)
          }}
        >
          Summary
        </summary>
        Details
      </details>
    </>
  )
}

ReactDom.render(<App />, document.getElementById('app'));

我们发现在组件的一开始并没有按照预期去渲染组件。之后的每一次点击,都不会按照预期去渲染。

为什么会这样?

原因在于 details 元素具有自身的状态,React 并不知道。

简单来说,这个问题在于 details 的 open 属性有两个数据来源:React 和浏览器。

更详细的讨论可以看这个 Github issue

首先我解释一下当第一次单击按钮时会发生什么:

  1. summary 元素的 onClick 事件触发,状态 isOpen 从 false 变为 true。
  2. React 重新渲染组件,将 details 元素的 open 属性设置为 true。
  3. details 元素的默认行为会切换自身 open 状态,将 open 设置为 false,但 React 并不知道。

所以这就是 details 元素最终没有将 open 属性设置为 true 的原因,而我们的 isOpen 状态依然是 true。

第二次点击:

  1. summary 元素的 onClick 处理程序 被触发,切换 isOpen 到 false.
  2. React 重新渲染,发现 details 已经关闭,所以它不会去改变它。
  3. details 元素的默认行为再次切换它的 open 状态。现在是 false,所以它会把 open 状态改变为 true,而 React 仍然不知道。

在此之后,一切都会打破。

怎么解决?

e.preventDefault

解决思路其实很简单,只要不让浏览器乱动状态就可以了。我们可以使用 e.preventDefault 来禁止浏览器的默认行为。这样就只有 React 能够控制它的状态了。

toggle

除了上面的方法外,还有一种方法是通过 details 的 toggle 事件来处理它。

function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <span>状态: {isOpen ? 'open' : 'closed'}</span>

      <details
        open={isOpen} 
        onToggle={() => {
          setIsOpen(!isOpen)
        }}>
        <summary>
          Summary
        </summary>
        Details
      </details>
    </>
  )
}

这样似乎正常了。

但是很快我的朋友又遇到了新的麻烦,她在 details 中有一个按钮,这个按钮可以改变 isOpen 的状态。

function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <span>状态: {isOpen ? 'open' : 'closed'}</span>

      <details
        open={isOpen}
        onToggle={() => {
          setIsOpen(!isOpen)
        }}>
        <summary
        >
          Summary
        </summary>
        Details
        <button onClick={() => setIsOpen(!isOpen)}>切换状态</button>
      </details>
    </>
  )
}

当点击这个按钮时,浏览器就抽风了,进入了死循环状态。

我又试着帮她解析了一下这个问题的原因:

  1. 按钮的 onClick 事件会切换 isOpen,同时会更改 details 的 open 属性。
  2. open 属性的变化会触发 onToggle 事件。
  3. onToggle 事件会再次切换 isOpen 的状态。同时改变了 details 的 open 属性,这时又回到了第 2 步,所以进入了无限循环状态。

这个 Bug 是 React 框架独有的吗?

虽然朋友解决了这个问题,但是她也向我吐槽 React 难用。

我很好奇这个问题是 React 独有的问题吗?其他类似的框架,比如 Soild、Svelte 和 Vue 它们会有这个问题吗?

于是我尝试了其他所有框架,发现它们都有这个问题。

Vue 的代码如下:

<template>
  <span>状态: {{isOpen ? 'open' : 'closed'}}</span>

  <details :open="isOpen">
    <summary @click="()=> {isOpen = !isOpen}">
      Summary
    </summary>
    Details
  </details>

</template>

<script>
  import { defineComponent, ref } from 'vue';

export default defineComponent({
  setup() {
    const isOpen = ref(false);
    return {
      isOpen
    };
  },
});
</script>

我也放到了码上掘金上,你可以看一下。

这个 Bug 到底是谁的锅?

鉴于所有的框架都有这个问题,所以我认为它不应该是框架的问题。

于是我尝试用原生的 JavaScript 来编写这段程序。

<!DOCTYPE html>
<html>
  <body>
    <span>状态: closed</span>

    <details>
      <summary>Summary</summary>
      Details
    </details>

    <script>
      const span = document.querySelector('span')
      const details = document.querySelector('details')
      const summary = document.querySelector('summary')

      let isOpen = false

      summary.addEventListener('click', () => {
        if (isOpen) {
          isOpen = false
          span.textContent = '状态: closed'
          details.removeAttribute('open')
          return
        }

        isOpen = true
        span.textContent = '状态: open'
        details.setAttribute('open', '')
      })
    </script>
  </body>
</html>

现在看来,这似乎是 details 这个元素的底层工作原理的问题,和框架无关。

能够完美解决的唯一办法就是通过 e.preventDefault 来禁止掉浏览器默认行为,让 JavaScript 中的变量成为唯一的数据源。