我们很乐意使用 自定义元素。 它们的設計使它们 特别适合延迟加载,这对于性能提升非常有帮助。
受到 一位同事的 实验的启发,我最近开始编写一个简单的自动加载器:每当自定义元素出现在 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(或者更确切地说,我们的相应子树)中出现新元素时通知我们——然后我们用它重新开始发现过程。(您可能会争辩说我们在这里重新发明了自定义元素,而您有点正确。)
我们的自动加载器现在已经完全可以工作了。 未来的增强功能可能会研究潜在的竞争条件并调查优化措施。 但很有可能,这对大多数场景来说已经足够了。 如果您有不同的方法,请在评论中告诉我,我们可以互相交流!
为什么不使用
:not(:defined)
来查询未加载的元素。我还将使用一种更易于 WebPack 化的自动加载器。 例如:
const elems = {
'my-elem': () => import('./my-elem.js')
};
// …
customElements.define(elem, await elemselem. default);`
哦,我之前没有考虑过在这种情况下使用
:defined
;这是一个非常棒的主意,而且更加优雅,谢谢!我同意像那个
elems
对象这样的方法在某些情况下可能更有优势(因此在引言中进行了谨慎的说明)。 但是,在我的情况下,我特别不想依赖捆绑器或类似的工具。我仍然梦想着 HTML 导入。 想象一下任何独立 HTML 文件中的登录部分。 对我来说,重要的是,我希望它们与其他内容一起显示在“源”选项卡中。 所以 fetch 并没有帮助。 所以,我尝试使用一个专门的自定义元素,它使用 iframe 加载独立 HTML 文件,然后该元素会获取其文档内容并将其移动到我们的主文档中。 这解决了我的问题,即在“源”选项卡中显示它们。 但是竞争条件一直在困扰着我…… 它在 16 次中有 15 次有效。(
我很想看看你的 iframe 方法的示例,Michael;你愿意把它放在 CodePen 或 Gist 中吗?(后者可以进行专门的讨论。)
我对 HTML 导入的哀悼表示同情,虽然我一直都在来回思考它本可以或应该是什么样子。
这是一个真正很好的开始方法;非常适合增强一些知识。 我之前也处理过这个问题,但这是改进的总结。
与其使用 Mutation Observer(它会针对所有添加和删除的节点触发),不如使用 CSS 动画监听器来查找
:not(:defined)
元素。由于 Web 组件可以在 shadow root 中使用,因此设置
this.parentNode
不足以捕捉到它们。 相反,为每个传入的 shadow root 添加一个监听器,以便 CSS 动画识别更多元素。确定加载定义的位置是通过引用初始脚本的位置(
document.currentScript.src
)并从那里进行遍历来完成的。 这支持在 CDN 上使用它的可能性(我更喜欢使用 CDN 托管定义)。由于定义请求可以是异步的,因此当元素出现时,我们可能会请求两次。 当找到元素时,它会被添加到注册表中,因此不会再次请求它。 我没有包含的改进是重新尝试失败的请求。
今天我才去阅读你的文章,因为我之前也尝试过这个。 非常棒的文章!感谢你分享所有有用的信息!
这真的很棒!我认为使用
<script type="module">
也可以在这里完美地工作。与其创建一个
<script>
标签来手动加载该组件的实现,不如直接调用动态的import()
函数。仔细想想,你实际上并不需要迁移到
<script type="module">
,但你需要确保在"use strict"
模式下运行你的组件,因为这是使用import()
语句在“模块”模式下运行的必要条件。以下是使用
import()
语法更新后的AutoLoader.load()
方法版本(我决定在 TS 中执行它,只是为了帮助解释我的演示中的内容)这实际上只是将另一个脚本作为副作用运行,你不会从脚本的
export
中获得任何引用。 它只是为了加载运行自定义元素定义的代码。说得对,Brandon:说实话,我不记得为什么我选择注入一个
<script>
元素,而不是使用import()
。 如果 ESM / 严格模式是可以接受的约束(对于自定义元素来说似乎很有可能),你的建议似乎是一个很好的简化。 感谢你指出这一点!