原始 JavaScript 组件模式

2025-06-07

原始 JavaScript 组件模式

大约一年前,我开始深入研究 Web 组件。我非常喜欢获取自定义元素的引用,然后直接在自定义元素上调用方法并设置值的想法。之后,我研究了 Polymer 3.0,它包含了许多便捷的功能和最佳实践。这些功能和最佳实践主要体现在模板、生命周期管理和属性/特性反射方面。我放弃了 Polymer 3.0,转而使用 lit-element,最后只使用了 lit-html。我继续这个过程,不断剥离技术,同时保留了我所学的模式、方案和最佳实践。最终,我得到了一种类似原生 JavaScript 组件模式(我可能需要一个更具体的名字)的东西。

这种模式甚至没有使用 Web 组件,因为我想要一个可以跨浏览器部署的东西,无需 polyfill 或任何需要传递给浏览器的额外代码。这倒不是什么难事,也不应该成为在新项目中使用 Web 组件的障碍,只是我想要一个可以在任何地方使用的东西。

下面是一个非常简单的组件示例。它使用 ES6 类和一个简单的模板字面量来生成标记。它在构造函数内部做了一些巧妙的操作,这段代码本质上是一个样板,用于确保每个 DOM 元素只有一个 JavaScript 对象代表它。它通过设置一个 data-ref 属性并随机生成一个 ID 来实现这一点。然后,当使用 ExampleComponent 类时,如果提供的 DOM 元素已经存在该类的实例,则构造函数将返回对已存在对象的引用。这允许将一个 DOM 元素多次传递给该类的构造函数,并且该类的实例始终只有一个。

export default class ExampleComponent {
  init(container) {
    this.container = container;
    this.render();
  }

  render() {
    this.container.innerHTML = ExampleComponent.markup(this);
  }

  static markup({}) {
    return `
      <h1>Hello, World!</h1>
    `;
  }

  constructor(container) {
    // The constructor should only contain the boiler plate code for finding or creating the reference.
    if (typeof container.dataset.ref === 'undefined') {
      this.ref = Math.random();
      ExampleComponent.refs[this.ref] = this;
      container.dataset.ref = this.ref;
      this.init(container);
    } else {
      // If this element has already been instantiated, use the existing reference.
      return ExampleComponent.refs[container.dataset.ref];
    }
  }
}

ExampleComponent.refs = {};

document.addEventListener('DOMContentLoaded', () => {
  new ExampleComponent(document.getElementById('example-component'))
});
Enter fullscreen mode Exit fullscreen mode

你会注意到,这会在标签中渲染静态的“Hello, World!”值<h1>。但是,如果我们想要一些动态值怎么办?首先,我们将更新类,如下所示:

export default class ExampleComponent {
  set title(title) {
    this.titleValue = title;
    this.render();
  }

  get title() {
    return titleValue;
  }

  init(container) {
    this.container = container;
    this.titleValue = this.container.dataset.title;
    this.render();
  }

  render() {
    this.container.innerHTML = ExampleComponent.markup(this);
  }

  static markup({title}) {
    return `
      <h1>${title}</h1>
    `;
  }

  constructor(container) {
    // The constructor should only contain the boiler plate code for finding or creating the reference.
    if (typeof container.dataset.ref === 'undefined') {
      this.ref = Math.random();
      ExampleComponent.refs[this.ref] = this;
      container.dataset.ref = this.ref;
      this.init(container);
    } else {
      // If this element has already been instantiated, use the existing reference.
      return ExampleComponent.refs[container.dataset.ref];
    }
  }
}

ExampleComponent.refs = {};

document.addEventListener('DOMContentLoaded', () => {
  new ExampleComponent(document.getElementById('example-component'))
});
Enter fullscreen mode Exit fullscreen mode

现在,我们使用构造函数传入的容器 DOM 元素的 data-title 属性来初始化值。此外,我们还提供了 setter 和 getter 方法来获取和更新值,并且每当值更新时,我们都会重新渲染组件。

但是,如果我们希望子组件作为该组件的一部分进行渲染该怎么办?

export default class ExampleComponent {
  set title(title) {
    this.titleValue = title;
    this.render();
  }

  get title() {
    return titleValue;
  }

  init(container) {
    this.container = container;
    this.titleValue = this.container.dataset.title;
    this.render();
  }

  render() {
    this.container.innerHTML = ExampleComponent.markup(this);
    this.pageElement = this.container.querySelector('.sub-component-example');
    new AnotherExampleComponent(this.pageElement);
  }

  static markup({title}) {
    return `
      <h1>${title}</h1>
      <div class="sub-component-example"></div>
    `;
  }

  constructor(container) {
    // The constructor should only contain the boiler plate code for finding or creating the reference.
    if (typeof container.dataset.ref === 'undefined') {
      this.ref = Math.random();
      ExampleComponent.refs[this.ref] = this;
      container.dataset.ref = this.ref;
      this.init(container);
    } else {
      // If this element has already been instantiated, use the existing reference.
      return ExampleComponent.refs[container.dataset.ref];
    }
  }
}

ExampleComponent.refs = {};

document.addEventListener('DOMContentLoaded', () => {
  new ExampleComponent(document.getElementById('example-component'))
});
Enter fullscreen mode Exit fullscreen mode

注意,这次我们在标记方法中添加了一个具有唯一类名的 div。然后在 render 方法中,我们获取了该元素的引用,并使用该 DOM 元素初始化 AnotherExampleComponent。注意:我这里没有提供 AnotherExampleComponent 的实现。最后,如果我们希望组件将事件从组件传播到父组件,或者任何已初始化或引用该组件的代码,该怎么办?

export default class ExampleComponent {
  set title(title) {
    this.titleValue = title;
    this.render();
  }

  get title() {
    return titleValue;
  }

  init(container) {
    this.container = container;
    this.titleValue = this.container.dataset.title;
    this.render();
  }

  render() {
    this.container.innerHTML = ExampleComponent.markup(this);
    this.pageElement = this.container.querySelector('.sub-component-example');
    this.clickMeButton = this.container.querySelector('.click-me');
    new AnotherExampleComponent(this.pageElement);

    this.addEventListeners();
  }

  static markup({title}) {
    return `
      <h1>${title}</h1>
      <button class="click-me">Click Me</div>
      <div class="sub-component-example"></div>
    `;
  }

  addEventListeners() {
    this.clickMeButton().addEventListener('click', () =>
      this.container.dispatchEvent(new CustomEvent('click-me-was-clicked')));
  }

  constructor(container) {
    // The constructor should only contain the boiler plate code for finding or creating the reference.
    if (typeof container.dataset.ref === 'undefined') {
      this.ref = Math.random();
      ExampleComponent.refs[this.ref] = this;
      container.dataset.ref = this.ref;
      this.init(container);
    } else {
      // If this element has already been instantiated, use the existing reference.
      return ExampleComponent.refs[container.dataset.ref];
    }
  }
}

ExampleComponent.refs = {};

document.addEventListener('DOMContentLoaded', () => {
  new ExampleComponent(document.getElementById('example-component'))
});
Enter fullscreen mode Exit fullscreen mode

请注意,我们现在添加了一个 addEventListeners 方法,用于监听组件内的事件。当按钮被点击时,它会在容器上调度一个自定义名称的事件,这样客户端代码就可以监听容器上一组自定义命名的事件,而无需了解组件本身的实现细节。也就是说,容器是客户端代码和实现之间的边界。类本身永远不应该超出其容器的范围,客户端代码也永远不应该进入容器内部获取数据或事件。所有数据和事件都应通过容器调度的 getter 方法和事件接口提供给客户端。

所有这些关注点分离、封装和组件化开发,在原生 JS 中都可以实现,无需依赖任何库、框架或 polyfill。我一直强调,方案和模式永远比框架和库更好。我们也不需要 Web 组件来实现这一点。那么,Web 组件和库的优势究竟体现在哪里呢?

首先,Web 组件是对平台的增强,它将这里介绍的方案和模式转化为平台的规则。这意味着,对于 Web 组件,这里展示的封装和关注点分离不会被客户端代码破坏,因为平台会强制执行。因此,如果可以使用 Web 组件,这些最佳实践应该针对 Web 组件进行更新(一篇关于此的博客文章即将发布!)。

其次,库也能派上用场。所以,如果你的数据预算中有足够的空间来交付给客户端的代码量,那么有一些库可以帮到我们。目前,这种方案只需要实际的项目代码,因为不需要任何库。这种方案的主要问题是渲染标记。目前,重新渲染的成本很高,而且用简单的模板文字来表示复杂的视图可能很复杂。但是,我们可以使用带标记的模板文字库(例如 hyperHTML 或 lit-html)来简化渲染过程并加快重新渲染过程。请记住,虽然 hyperHTML 已经投入生产一年多了,但 lit-html 目前正处于 1.0 版本的准备阶段。

我在我的博客同样的帖子,在其中我更多地讨论了框架上最新和最伟大的 Web 开发模式

文章来源:https://dev.to/megazear7/the-vanilla-javascript-component-pattern-37la
PREV
我被问到的常见前端面试问题
NEXT
我们是开源的!VS Code 的以数据为中心的兄弟