为什么 Materialize Select 组件在动态创建元素时无法初始化?

Why does a Materialize Select component fail to initialize on dynamic creation of elements?

当使用“静态”元素(即包含在 HTML 标记中或之前插入到 DOM 中)初始化 Materialize Select component 时,一切都按预期工作:

document.addEventListener('DOMContentLoaded', function() {
  var elems = document.querySelectorAll('select');
  var instances = M.FormSelect.init(elems);
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet" />
<div class="input-field col s12">
  <select>
    <option value="" disabled selected>Choose your option</option>
    <option value="1">Option 1</option>
    <option value="2">Option 2</option>
    <option value="3">Option 3</option>
  </select>
  <label>Materialize Select</label>
</div>

但是动态创建组件时,初始化失败:

const makeOption = (maybeOption) => {

  const {
    text,
    value,
    selected
  } = typeof maybeOption === "string" ? {
      text: maybeOption,
      value: maybeOption
    } :
    maybeOption;

  const opt = document.createElement("option");
  opt.innerText = text;
  opt.value = value;
  opt.selected = selected;

  return opt;
};

const makeSelectInput = ({
  id,
  name,
  empty = true,
  label,
  options = [],
  parent,
  placeholder,
  value,
  classes = []
}) => {

  const wrap = document.createElement("div");
  wrap.classList.add("input-field", ...classes);

  const sel = document.createElement("select");
  sel.id = id || name;
  sel.name = name;

  const lbl = document.createElement("label");
  lbl.htmlFor = id || name;
  lbl.innerText = label;

  if (empty) {
    sel.options.add(makeOption({
      text: placeholder || "",
      value: ""
    }));
  }

  options.map(makeOption).forEach((o) => sel.options.add(o));

  wrap.append(sel, lbl);

  parent.append(wrap);

  const elems = wrap.querySelectorAll(`#${id || name}`);

  M.FormSelect.init(elems);

  return sel;
};

window.addEventListener("DOMContentLoaded", () => {

  const parent = document.createElement("div");
  
  try {
    makeSelectInput({ 
     parent,
     options: ["Apples", "Oranges"], 
     name: "fruits",
     label: "Fruits"
   });
 }
 catch({ message }) {
  console.log(message);
 }

});
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet" />

抛出的错误信息如下:

"TypeError: Cannot set property 'tabIndex' of null"

我理解根本原因在于组件初始化时未能select(或创建)补充元素,但究竟是什么导致了上述第二种情况的错误,但不是第一个?


有类似的 Q&A 处理 using VueJS, React, or Angular (also this) 框架时的错误,但 none 与 DOM 香草 JavaScript 的操作有关。也就是说,总而言之,手头的问题有几个可能的解决方案:

  1. A mysterious one involving switching from a class selector to an id selector - 不幸的是,除非 class selector 格式错误并导致根元素作为 [=15= 传递,否则我无法理解会发生什么变化].
  2. 正在初始化 componentDidMount lifecycle hook - also explaining the "how", but not the "why" part of the issue. But usage of the hook at the very least highlights that the issue has something to do with insertion into DOM
  3. 确保 DOM finished loading, but as you can see from the second code snippet, the component is initialized after the DOMContentLoaded 事件触发。

TL;DR

在您初始化 Materialize FormSelect 组件时,所有根元素必须 在 DOM 中,因为 Materialize 使用 getElementById 方法returns null 在 non-appended 个元素上。

说明

如果检查指向的错误堆栈跟踪点,您会发现错误是在调用组件的 _makeDropdownFocusable 方法期间抛出的。这是 the method 源当前的样子:

_makeDropdownFocusable() {
  // Needed for arrow key navigation
  this.dropdownEl.tabIndex = 0;

  // Only set tabindex if it hasn't been set by user
  $(this.dropdownEl)
    .children()
    .each(function(el) {
      if (!el.getAttribute('tabindex')) {
        el.setAttribute('tabindex', 0);
      }
    });
}

注意 thisdropdownEl 属性 - 显然,如错误所述,它是 null,因此尝试设置 属性 null 导致错误。现在我们需要了解 dropdownEl 是如何设置的。它首先在 class 构造函数中设置:

this.dropdownEl = document.getElementById(this.id);

这告诉我们什么?将 dropdownEl 设置为 nullonly 方法会使执行 return [=18] 的 getElementById 查询失败=] 不匹配,这意味着 this.id 有问题,它是通过以下方式设置的:

this.id = M.getIdFromTrigger(el);

其中 el 是在引擎盖下创建并分配给 this.input 的输入元素:

this.dropdown = M.Dropdown.init(this.input, dropdownOptions);

现在,让我们看看getIdFromTrigger是如何定义的:

M.getIdFromTrigger = function(trigger) {
  let id = trigger.getAttribute('data-target');
  if (!id) {
    id = trigger.getAttribute('href');
    if (id) {
      id = id.slice(1);
    } else {
      id = '';
    }
  }
  return id;
};

注意 getAttribute 方法 - 它告诉我们检查生成的输入是否具有 data-target 属性。属性值应为以下形式(其中 {{}} 表示可变部分):

select-options-{{uuid here}}

到目前为止,还不错。在运行时,如果你检查调试器,这一步正确地 returns 一个输入 Id,所以你可能想知道为什么在下一行 getElementById returns null。如果你不相信我,请看下面的截图(Chrome DevTools + minified Materialize):

起初,这似乎很奇怪,但事实证明真正的罪魁祸首很容易识别:构成 select 元素 骨架的 HTML 元素不在文档中。为什么这件事?因为 getElementById 在 non-document 元素上失败,所以 quote MDN:

Elements not in the document are not searched by getElementById(). When creating an element and assigning it an ID, you have to insert the element into the document tree with Node.insertBefore() or a similar method before you can access it with getElementById()

这就是全部 - Materialise 假设您的元素已经附加到文档并且没有任何防范措施。


  1. 有关 getElementById 何时可以 return null 的一般参考,请参阅 this Q&A