自定义元素的延迟加载方法

Avatar of Frederik Dohr
Frederik Dohr

DigitalOcean 为您旅途的每个阶段提供云产品。 立即开始使用 价值 200 美元的免费积分!

我们很乐意使用 自定义元素。 它们的設計使它们 特别适合延迟加载,这对于性能提升非常有帮助。

受到 一位同事的 实验的启发,我最近开始编写一个简单的自动加载器:每当自定义元素出现在 DOM 中时,我们都希望加载相应的实现(如果它还没有可用)。 浏览器随后会负责从那里升级这些元素。

您可能实际上不需要所有这些;通常会有更简单的方法。 经过深思熟虑地使用,这里展示的技术仍然可以成为您的工具集中的一个有用补充。

为了保持一致性,我们希望我们的自动加载器也成为一个自定义元素——这也意味着我们可以通过 HTML 轻松地配置它。 但是首先,让我们一步一步地识别那些未解析的自定义元素

class AutoLoader extends HTMLElement {
  connectedCallback() {
    let scope = this.parentNode;
    this.discover(scope);
  }
}
customElements.define("ce-autoloader", AutoLoader);

假设我们已经预先加载了这个模块(使用 async 是理想的选择),我们可以将一个 <ce-autoloader> 元素放到我们文档的 <body> 中。 这将立即开始对 <body> 的所有子元素进行发现过程,现在它构成了我们的根元素。 我们也可以通过将 <ce-autoloader> 添加到相应的容器元素来将发现限制到我们文档的子树——实际上,我们甚至可以为不同的子树设置多个实例。

当然,我们仍然必须实现那个 discover 方法(作为上面 AutoLoader 类的一部分)

discover(scope) {
  let candidates = [scope, ...scope.querySelectorAll("*")];
  for(let el of candidates) {
    let tag = el.localName;
    if(tag.includes("-") && !customElements.get(tag)) {
      this.load(tag);
    }
  }
}

在这里,我们检查了我们的根元素以及每个后代(*)。 如果它是一个自定义元素——如带连字符的标签所示——但尚未升级,我们将尝试加载相应的定义。 以这种方式查询 DOM 可能很昂贵,因此我们应该小心一点。 我们可以通过延迟这项工作来减轻主线程的负载

connectedCallback() {
  let scope = this.parentNode;
  requestIdleCallback(() => {
    this.discover(scope);
  });
}

requestIdleCallback 尚未得到普遍支持,但我们可以使用 requestAnimationFrame 作为后备

let defer = window.requestIdleCallback || requestAnimationFrame;

class AutoLoader extends HTMLElement {
  connectedCallback() {
    let scope = this.parentNode;
    defer(() => {
      this.discover(scope);
    });
  }
  // ...
}

现在我们可以继续实现缺少的 load 方法来动态注入一个 <script> 元素

load(tag) {
  let el = document.createElement("script");
  let res = new Promise((resolve, reject) => {
    el.addEventListener("load", ev => {
      resolve(null);
    });
    el.addEventListener("error", ev => {
      reject(new Error("failed to locate custom-element definition"));
    });
  });
  el.src = this.elementURL(tag);
  document.head.appendChild(el);
  return res;
}

elementURL(tag) {
  return `${this.rootDir}/${tag}.js`;
}

请注意 elementURL 中的硬编码约定。 src 属性的 URL 假设存在一个目录,其中包含所有自定义元素定义(例如 <my-widget>/components/my-widget.js)。 我们可以想出更复杂的策略,但这对于我们的目的来说已经足够了。 将此 URL 放到单独的方法中,允许在需要时进行特定于项目的子类化

class FancyLoader extends AutoLoader {
  elementURL(tag) {
    // fancy logic
  }
}

无论哪种方式,请注意我们都依赖于 this.rootDir。 这就是前面提到的可配置性发挥作用的地方。 让我们添加一个相应的 getter

get rootDir() {
  let uri = this.getAttribute("root-dir");
  if(!uri) {
    throw new Error("cannot auto-load custom elements: missing `root-dir`");
  }
  if(uri.endsWith("/")) { // remove trailing slash
    return uri.substring(0, uri.length - 1);
  }
  return uri;
}

您现在可能正在考虑 observedAttributes,但这并没有真正让事情变得更容易。 此外,在运行时更新 root-dir 似乎是我们永远不需要做的事情。

现在我们可以——并且必须——配置我们的元素目录:<ce-autoloader root-dir="/components">

有了它,我们的自动加载器就可以执行它的工作了。 除了它只工作一次,用于在自动加载器初始化时已经存在的元素。 我们可能还想考虑动态添加的元素。 这就是 MutationObserver 发挥作用的地方

connectedCallback() {
  let scope = this.parentNode;
  defer(() => {
    this.discover(scope);
  });
  let observer = this._observer = new MutationObserver(mutations => {
    for(let { addedNodes } of mutations) {
      for(let node of addedNodes) {
        defer(() => {
          this.discover(node);
        });
      }
    }
  });
  observer.observe(scope, { subtree: true, childList: true });
}

disconnectedCallback() {
  this._observer.disconnect();
}

通过这种方式,浏览器会在 DOM(或者更确切地说,我们的相应子树)中出现新元素时通知我们——然后我们用它重新开始发现过程。(您可能会争辩说我们在这里重新发明了自定义元素,而您有点正确。)

我们的自动加载器现在已经完全可以工作了。 未来的增强功能可能会研究潜在的竞争条件并调查优化措施。 但很有可能,这对大多数场景来说已经足够了。 如果您有不同的方法,请在评论中告诉我,我们可以互相交流!