什么 Bug?
昨天有个朋友请教了我一个问题,她在使用原生的 Details 元素封装一个手风琴组件。但是无论如何都不能按照预期工作。
起初我认为是她水平比较差,代码写的有问题。但是她一再向我保证绝对不是她的问题。所以我就抽出时间帮她看了一下。意外发现这一个框架的隐秘 Bug。
我把这个代码放到了码上掘金上,你可以看一下。
https://code.juejin.cn/pen/7145527312400777254
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 。
首先我解释一下当第一次单击按钮时会发生什么:
- summary 元素的 onClick 事件触发,状态 isOpen 从 false 变为 true。
- React 重新渲染组件,将 details 元素的 open 属性设置为 true。
- details 元素的默认行为会切换自身 open 状态,将 open 设置为 false,但 React 并不知道。
所以这就是 details 元素最终没有将 open 属性设置为 true 的原因,而我们的 isOpen 状态依然是 true。
第二次点击:
- summary 元素的 onClick 处理程序 被触发,切换 isOpen 到 false.
- React 重新渲染,发现 details 已经关闭,所以它不会去改变它。
- 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 的状态。
https://code.juejin.cn/pen/7145537955291987976
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> </> ) }
当点击这个按钮时,浏览器就抽风了,进入了死循环状态。
我又试着帮她解析了一下这个问题的原因:
- 按钮的 onClick 事件会切换 isOpen,同时会更改 details 的 open 属性。
- open 属性的变化会触发 onToggle 事件。
- 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>
我也放到了码上掘金上,你可以看一下。
https://code.juejin.cn/pen/7145527732409991200
这个 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>
https://code.juejin.cn/pen/7145540132412588062
现在看来,这似乎是 details 这个元素的底层工作原理的问题,和框架无关。
能够完美解决的唯一办法就是通过 e.preventDefault 来禁止掉浏览器默认行为,让 JavaScript 中的变量成为唯一的数据源。