为什么在 SolidJS JSX 渲染期间删除了一个 SVG 节点?

huangapple go评论67阅读模式
英文:

Why is an SVG node removed during SolidJS JSX rendering?

问题

我正在构建一个自定义的下拉框,其中每个选项都有一个SVG图标和一个标签。
选中元素的图标会在初始渲染时显示,但在展开选择框后会被移除。每当选择新选项时,它会再次渲染,直到再次展开选择框。在展开时,value().icon状态不会发生变化。

在JSX中,value().icon中没有应用于svg的条件:

      <button
        onClick={() => setExpanded((prev) => !prev)}
      >
          <span>{value().icon}</span>
          <span>{value().label}</span>
          <svg />
      </button>

可重现的示例播放区

为什么在 SolidJS JSX 渲染期间删除了一个 SVG 节点?

根据浏览器调试(在svg的节点移除上设置断点),我认为在dom-expressions L78附近发生了一些奇怪的事情。parent.appendChild(value)中的value似乎在第一次选择时(当它存在时)正确包含svg,但也在展开时(当它被移除时)包含它,这让我很困惑。

英文:

I'm building a custom dropdown, where each option has an SVG icon and a label.
While the icon of the selected element is rendered initially, the SVG icon is removed upon expanding the select. Whenever a new option is selected, it's rendered again until the select is expanded again. There's no change to the value().icon state upon expansion.

Within the JSX there's no condition applied to the svg in value().icon either:

      <button
        onClick={() => setExpanded((prev) => !prev)}
      >
          <span>{value().icon}</span>
          <span>{value().label}</span>
          <svg />
      </button>

Reproducible Example Playground

为什么在 SolidJS JSX 渲染期间删除了一个 SVG 节点?

Based on browser debugging (breakpoint on node removal on the svg), I believe something odd happens around dom-expressions L78. The value in parent.appendChild(value) seems to correctly contain the svg on first selection (when it prevails), but also on expansion (when it's removed), which I can't make sense of.

答案1

得分: 1

你的列表被包装在一个 Show 组件中,每当状态更新时,整个列表都会被重新创建,因为隐藏将销毁先前呈现的元素。

如果你检查输出代码,你的组件逻辑很复杂,而 svg 部分最终与 Show 元素绑定,尽管 svg 部分是纯粹的:

import { template as _$template } from "solid-js/web";
import { delegateEvents as _$delegateEvents } from "solid-js/web";
import { setAttribute as _$setAttribute } from "solid-js/web";
import { effect as _$effect } from "solid-js/web";
import { memo as _$memo } from "solid-js/web";
import { createComponent as _$createComponent } from "solid-js/web";
import { insert as _$insert } from "solid-js/web";
const _tmpl$ = /*#__PURE__*/_$template(`<ul>`),
  _tmpl$2 = /*#__PURE__*/_$template(`<div><button><span></span><span></span><svg fill="#000000" height="1rem" width="1rem" viewBox="0 0 330 330"><path id="XMLID_225_" d="M325.607,79.393c-5.857-5.857-15.355-5.858-21.213,0.001l-139.39,139.393L25.607,79.393
  c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l150.004,150c-2.813,2.813-6.628,4.393,10.606,4.393
  s7.794-1.581,10.606-4.394l149.996-150C331.465,94.749,331.465,85.251,325.607,79.393z">`),
  _tmpl$3 = /*#__PURE__*/_$template(`<li><button><span>`),
  _tmpl$4 = /*#__PURE__*/_$template(`<span>`),
  _tmpl$5 = /*#__PURE__*/_$template(`<svg height="1rem" width="1rem" viewBox="0 0 512 512"><g><polygon points="256,0 72.115,256 256,512 439.885,256 	">`);
import { createSignal, For, JSXElement, Show } from "solid-js";
import { render } from "solid-js/web";
function Select(p) {
  const props = {
    selectedIndex: 0,
    ...p
  };
  const [value, setValue] = createSignal(props.options[props.selectedIndex]);
  const [expanded, setExpanded] = createSignal(false);
  return (() => {
    const _el$ = _tmpl$2(),
      _el$2 = _el$.firstChild,
      _el$3 = _el$2.firstChild,
      _el$4 = _el$3.nextSibling;
    _el$.$$focusout = e =>
    // 如果焦点位于子元素内(例如,选项按钮单击),不要处理 onFocusOut
    !e.currentTarget.contains(e.relatedTarget) && setExpanded(false);
    _el$2.$$click = () => setExpanded(prev => !prev);
    _$insert(_el$3, () => value().icon);
    _$insert(_el$4, () => value().label);
    _$insert(_el$, _$createComponent(Show, {
      get when() {
        return expanded();
      },
      get children() {
        const _el$5 = _tmpl$();
        _$insert(_el$5, _$createComponent(For, {
          get each() {
            return props.options;
          },
          children: option => (() => {
            const _el$6 = _tmpl$3(),
              _el$7 = _el$6.firstChild,
              _el$8 = _el$7.firstChild;
            _el$7.$$click = () => {
              setValue(option);
              setExpanded(false);
            };
            _$insert(_el$7, (() => {
              const _c$ = _$memo(() => !!option.icon);
              return () => _c$() && (() => {
                const _el$9 = _tmpl$4();
                _$insert(_el$9, () => option.icon);
                return _el$9;
              })();
            })(), _el$8);
            _$insert(_el$8, () => option.label);
            return _el$6;
          })()
        }));
        return _el$5;
      }
    }), null);
    return _el$;
  })();
}
function Icon(props) {
  return (() => {
    const _el$10 = _tmpl$5();
    _$effect(() => _$setAttribute(_el$10, "fill", props.color));
    return _el$10;
  })();
}
function App() {
  return _$createComponent(Select, {
    get options() {
      return [{
        icon: _$createComponent(Icon, {
          color: "#fc5614"
        }),
        label: "Red",
        value: "red"
      }, {
        icon: _$createComponent(Icon, {
          color: "#25b9e6"
        }),
        label: "Blue",
        value: "blue"
      }, {
        icon: _$createComponent(Icon, {
          color: "#79e625"
        }),
        label: "Green",
        value: "green"
      }];
    }
  });
}
render(() => _$createComponent(App, {}), document.getElementById("app"));
_$delegateEvents(["focusout", "click"]);

你需要重新设计你的代码,以使状态更新不会触发 svg 的重新渲染。你可以将 Select 逻辑移动到更小、良好封装的子组件中,这些子组件可以提供适当的隔离,并可以利用 memos 和 untracks,以防

英文:

Your list is wrapped into a Show component, whenever state is updated whole list will re-created because hiding will destroy the previously rendered element.

If you check the output code, your component logic is complicated and svg part ends up tied to the Show element even though svg part is pure:

import { template as _$template } from "solid-js/web";
import { delegateEvents as _$delegateEvents } from "solid-js/web";
import { setAttribute as _$setAttribute } from "solid-js/web";
import { effect as _$effect } from "solid-js/web";
import { memo as _$memo } from "solid-js/web";
import { createComponent as _$createComponent } from "solid-js/web";
import { insert as _$insert } from "solid-js/web";
const _tmpl$ = /*#__PURE__*/_$template(`<ul>`),
  _tmpl$2 = /*#__PURE__*/_$template(`<div><button><span></span><span></span><svg fill="#000000" height="1rem" width="1rem" viewBox="0 0 330 330"><path id="XMLID_225_" d="M325.607,79.393c-5.857-5.857-15.355-5.858-21.213,0.001l-139.39,139.393L25.607,79.393
	c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l150.004,150c2.813,2.813,6.628,4.393,10.606,4.393
	s7.794-1.581,10.606-4.394l149.996-150C331.465,94.749,331.465,85.251,325.607,79.393z">`),
  _tmpl$3 = /*#__PURE__*/_$template(`<li><button><span>`),
  _tmpl$4 = /*#__PURE__*/_$template(`<span>`),
  _tmpl$5 = /*#__PURE__*/_$template(`<svg height="1rem" width="1rem" viewBox="0 0 512 512"><g><polygon points="256,0 72.115,256 256,512 439.885,256 	">`);
import { createSignal, For, JSXElement, Show } from "solid-js";
import { render } from "solid-js/web";
function Select(p) {
  const props = {
    selectedIndex: 0,
    ...p
  };
  const [value, setValue] = createSignal(props.options[props.selectedIndex]);
  const [expanded, setExpanded] = createSignal(false);
  return (() => {
    const _el$ = _tmpl$2(),
      _el$2 = _el$.firstChild,
      _el$3 = _el$2.firstChild,
      _el$4 = _el$3.nextSibling;
    _el$.$$focusout = e =>
    // don't process onFocusOut if the focus is in a child element (e.g., option button click)
    !e.currentTarget.contains(e.relatedTarget) && setExpanded(false);
    _el$2.$$click = () => setExpanded(prev => !prev);
    _$insert(_el$3, () => value().icon);
    _$insert(_el$4, () => value().label);
    _$insert(_el$, _$createComponent(Show, {
      get when() {
        return expanded();
      },
      get children() {
        const _el$5 = _tmpl$();
        _$insert(_el$5, _$createComponent(For, {
          get each() {
            return props.options;
          },
          children: option => (() => {
            const _el$6 = _tmpl$3(),
              _el$7 = _el$6.firstChild,
              _el$8 = _el$7.firstChild;
            _el$7.$$click = () => {
              setValue(option);
              setExpanded(false);
            };
            _$insert(_el$7, (() => {
              const _c$ = _$memo(() => !!option.icon);
              return () => _c$() && (() => {
                const _el$9 = _tmpl$4();
                _$insert(_el$9, () => option.icon);
                return _el$9;
              })();
            })(), _el$8);
            _$insert(_el$8, () => option.label);
            return _el$6;
          })()
        }));
        return _el$5;
      }
    }), null);
    return _el$;
  })();
}
function Icon(props) {
  return (() => {
    const _el$10 = _tmpl$5();
    _$effect(() => _$setAttribute(_el$10, "fill", props.color));
    return _el$10;
  })();
}
function App() {
  return _$createComponent(Select, {
    get options() {
      return [{
        icon: _$createComponent(Icon, {
          color: "#fc5614"
        }),
        label: "Red",
        value: "red"
      }, {
        icon: _$createComponent(Icon, {
          color: "#25b9e6"
        }),
        label: "Blue",
        value: "blue"
      }, {
        icon: _$createComponent(Icon, {
          color: "#79e625"
        }),
        label: "Green",
        value: "green"
      }];
    }
  });
}
render(() => _$createComponent(App, {}), document.getElementById("app"));
_$delegateEvents(["focusout", "click"]);

You need to refactor your code in a way that state update does not trigger re-render for the svg. You can move your Select logic into smaller, well contained sub-components which provides proper isolation and can take advantage of memos and untracks in case state update spill onto them.

Solid does not use VDOM but compiles JSX into native DOM elements. The way it works is, Solid converts component's html structure into a template. Whenever state gets updated, it clones this template, fills with with dynamic values by evaluating them, and re-insert it to its parent component.

You can write any expression inside JSX. Chlid components are functions and they are compiled into function calls.

If you take a look at the output code, you will see svg is compiled into _tmpl$5 and it is inserted into its parent under Show:

$insert(_el$, _$createComponent(Show, {
// Snipped for brevity 
}))

This means, whenever expanded value changes the children of Show component will be re-created and re-inserted.

Normally you don't expect svg ends up being a child to Show because it comes before the Show in the DOM hierarchy and appears outside of it. Your component logic unnecessary complex and convoluted causing some unexpected outcome, a it is pointed in the accepted answer.

Don't rush for returning an element, take you time, build your logic, tame your state, only then return the element.

Here is an Select demo I wrote for another answer which might be helpful. It has basic functionality but can be improved easily:

https://playground.solidjs.com/anonymous/e58974e7-287f-4f56-8ab3-33787d93c629

答案2

得分: 1

首先,我会将代码从示例中复制以便将来可以重现,但我在代码中添加了一些注释。

import { createSignal, For, JSXElement, Show } from "solid-js";
import { render } from "solid-js/web";

function Select(p: {
  options: { label: string; value: string; icon?: JSXElement }[];
  selectedIndex?: number;
}) {
  const props = {
    selectedIndex: 0,
    ...p,
  };

  const [value, setValue] = createSignal(props.options[props.selectedIndex]);

  const [expanded, setExpanded] = createSignal(false);

  return (
    <div
      onFocusOut={(e) => 
        // 不要处理焦点移出事件,如果焦点在子元素内(例如,选项按钮点击)
        !e.currentTarget.contains(e.relatedTarget as Node) && setExpanded(false)
      }
    >
      <button
        onClick={() => setExpanded((prev) => !prev)}
      >
        <span>{value().icon}</span> {/* 尝试附加图标 */}
        <span>{value().label}</span>
        <svg
          fill="#000000"
          height="1rem"
          width="1rem"
          viewBox="0 0 330 330"
        >
          <path
            id="XMLID_225_"
            d="M325.607,79.393c-5.857-5.857-15.355-5.858-21.213,0.001l-139.39,139.393L25.607,79.393
    c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l150.004,150c2.813,2.813,6.628,4.393,10.606,4.393
    s7.794-1.581,10.606-4.394l149.996-150C331.465,94.749,331.465,85.251,325.607,79.393z"
          />
        </svg>
      </button>
      <Show when={expanded()}>
        <ul>
          <For each={props.options}>
            {(option) => (
              <li>
                <button
                  onClick={() => {
                    setValue(option);
                    setExpanded(false);
                  }}
                >
                  {option.icon && <span>{option.icon}</span>} {/* 尝试再次附加图标 */}
                  <span>{option.label}</span>
                </button>
              </li>
            )}
          </For>
        </ul>
      </Show>
    </div>
  );
}

function Icon(props: {color: string}) {
  return (
    <svg
      fill={props.color}
      height="1rem"
      width="1rem"
      viewBox="0 0 512 512"
    >
      <g>
        <polygon points="256,0 72.115,256 256,512 439.885,256 " />
      </g>
    </svg>
  )
}

function App() {
  return (
    <Select
      options={[
        {
          icon: <Icon color="#fc5614" />,
          label: "Red",
          value: "red",
        },
        {
          icon: <Icon color="#25b9e6" />,
          label: "Blue",
          value: "blue",
        },
        {
          icon: <Icon color="#79e625" />,
          label: "Green",
          value: "green",
        },
      ]}
    />
  );
}

render(() => <App />, document.getElementById("app")!);

在SolidJS中,当你像这样创建一个元素:const par = <p>hello</p>,例如,par 将引用一个实际的DOM元素(与React不同,React使用虚拟DOM节点)。

因此,实际DOM节点的限制适用。例如,如果你尝试将节点附加到多个父节点,parent1.appendChild(node); parent2.appendChild(node),子节点不会被克隆,而只会被移动到 parent2。因此,parent1 不再拥有子节点,因为子节点已经随着 parent2 移动了。

在每次调用 App() 时,每种颜色只有一个 <Icon/> 实例。因此,当显示选项时,实际发生的是尝试将一个DOM节点附加到两个不同的位置。但是,该节点只能出现在最多一个位置(因为节点最多只有一个父节点)。

一种解决方法是不使用单一元素,而是使用 icon?: () => JSXElement,它将根据调用的次数生成不同的元素,同时在其他位置进行适当的更改(例如,在 App 中 icon: () => <Icon color="#fc5614" />,在 Select 中 span>{value().icon?.()}</span>)。

这个限制不适用于像 value().value 这样的字符串,可能是因为它只会在稍后转换为实际的DOM节点(与SolidJS中的JSX标记不同,后者会很快转换为实际的DOM元素)。这个限制似乎也不适用于React,可能是因为它在相对较晚的时候将虚拟DOM节点转换为实际的DOM(因此,即使 child 是一个JSXElement,类似 2{child}3{child}4{child} 的情况在React中不会出现奇怪的行为,但在SolidJS中可能会很奇怪)。

英文:

Firstly, I would copy the code from the playground to that it's reproducible in the future, but I added some comments in the code.

import { createSignal, For, JSXElement, Show } from &quot;solid-js&quot;;
import { render } from &quot;solid-js/web&quot;;

function Select(p: {
  options: { label: string; value: string; icon?: JSXElement }[];
  selectedIndex?: number;
}) {
  const props = {
    selectedIndex: 0,
    ...p,
  };

  const [value, setValue] = createSignal(props.options[props.selectedIndex]);

  const [expanded, setExpanded] = createSignal(false);

  return (
    &lt;div
      onFocusOut={(e) =&gt; 
        // don&#39;t process onFocusOut if the focus is in a child element (e.g., option button click)
        !e.currentTarget.contains(e.relatedTarget as Node) &amp;&amp; setExpanded(false)
      }
    &gt;
      &lt;button
        onClick={() =&gt; setExpanded((prev) =&gt; !prev)}
      &gt;
          &lt;span&gt;{value().icon}&lt;/span&gt; {/* try to append the icon */}
          &lt;span&gt;{value().label}&lt;/span&gt;
          &lt;svg
            fill=&quot;#000000&quot;
            height=&quot;1rem&quot;
            width=&quot;1rem&quot;
            viewBox=&quot;0 0 330 330&quot;
          &gt;
            &lt;path
              id=&quot;XMLID_225_&quot;
              d=&quot;M325.607,79.393c-5.857-5.857-15.355-5.858-21.213,0.001l-139.39,139.393L25.607,79.393
	c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l150.004,150c2.813,2.813,6.628,4.393,10.606,4.393
	s7.794-1.581,10.606-4.394l149.996-150C331.465,94.749,331.465,85.251,325.607,79.393z&quot;
            /&gt;
          &lt;/svg&gt;
      &lt;/button&gt;
      &lt;Show when={expanded()}&gt;
          &lt;ul&gt;
            &lt;For each={props.options}&gt;
              {(option) =&gt; (
                &lt;li&gt;
                  &lt;button
                    onClick={() =&gt; {
                      setValue(option);
                      setExpanded(false);
                    }}
                  &gt;
                    {option.icon &amp;&amp; &lt;span&gt;{option.icon}&lt;/span&gt;} {/* try to append the icon again */}
                    &lt;span&gt;{option.label}&lt;/span&gt;
                  &lt;/button&gt;
                &lt;/li&gt;
              )}
            &lt;/For&gt;
          &lt;/ul&gt;
      &lt;/Show&gt;
    &lt;/div&gt;
  );
}

function Icon(props: {color: string}) {
  return (
    &lt;svg
      fill={props.color}
      height=&quot;1rem&quot;
      width=&quot;1rem&quot;
      viewBox=&quot;0 0 512 512&quot;
    &gt;
      &lt;g&gt;
        &lt;polygon points=&quot;256,0 72.115,256 256,512 439.885,256 	&quot; /&gt;
      &lt;/g&gt;
    &lt;/svg&gt;
  )
}

function App() {
  return (
    &lt;Select
      options={[
        {
          icon: &lt;Icon color=&quot;#fc5614&quot; /&gt;,
          label: &quot;Red&quot;,
          value: &quot;red&quot;,
        },
        {
          icon: &lt;Icon color=&quot;#25b9e6&quot; /&gt;,
          label: &quot;Blue&quot;,
          value: &quot;blue&quot;,
        },
        {
          icon: &lt;Icon color=&quot;#79e625&quot; /&gt;,
          label: &quot;Green&quot;,
          value: &quot;green&quot;,
        },
      ]}
    /&gt;
  );
}

render(() =&gt; &lt;App /&gt;, document.getElementById(&quot;app&quot;)!);

In SolidJS, when you do for example const par = &lt;p&gt;hello&lt;/p&gt; for example, par will refer to an actual DOM element (unlike in React which uses a virtual DOM node).

So, the restrictions of a real DOM node applies. For example, if you try appending a node to multiple parents, parent1.appendChild(node); parent2.appendChild(node), the child node is not cloned, but simply moved to parent2. So, parent1 will not have the child because the child goes with parent2.

In each call to App(), for each color there is only one &lt;Icon/&gt; instance.
So effectively, when you show the options, what happens is that you have one DOM node that it tries to append to two different positions. But then the node can only appear in at most one place (because the node has at most one parent).

A workaround is to not use a single element like icon?: JSXElement, but rather to use icon?: () =&gt; JSXElement which will generate separate elements as many times as it is called, along with appropriate changes in other places (e.g. icon: () =&gt; &lt;Icon color=&quot;#fc5614&quot; /&gt; in App and &lt;span&gt;{value().icon?.()}&lt;/span&gt; in Select).

This restriction doesn't apply to strings like value().value, probably because it is only converted to an actual DOM node much later (unlike JSX tags which are converted to actual DOM elements very soon in SolidJS).
The restriction also doesn't seem to apply to React, probably because it converts the virtual DOM nodes to real DOM pretty late (so something like 2{child}3{child}4{child} will not give you a weird behavior in React even when the child is a JSXElement, but it can be quite weird in SolidJS).

huangapple
  • 本文由 发表于 2023年4月4日 13:56:43
  • 转载请务必保留本文链接:https://go.coder-hub.com/75925920.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定