Web Component 单文件组件

158 阅读10分钟

实现单文件 Web 组件

可能每个了解Vue.js 框架的人也听说过它的单文件组件。这个超级简单的想法允许 Web 开发人员在一个文件中定义组件的整个代码。这是一个非常有用的解决方案,以至于已经出现了将这种机制包含在浏览器的倡议。然而,它似乎已经死了,不幸的是,自 2017 年 8 月以来没有取得任何进展。 尽管如此,研究这个主题并尝试使用现有技术使单文件组件在浏览器中工作是一个有趣的实验。

单文件组件

了解渐进增强术语的Web 开发人员也知道“层分离”的口头禅。对于组件,没有任何变化。事实上,还有更多层,因为现在每个组件至少有 3 层:内容/模板、表现和行为。如果您使用最保守的方法,每个组件将被分成至少 3 个文件,例如一个Button组件可能如下所示:

Button/
|
| -- Button.html
|
| -- Button.css
|
| -- Button.js

在这种方法中,层的分离等同于技术的分离(内容/模板:HTML,表示:CSS,行为:JavaScript)。如果您不使用任何构建工具,这意味着浏览器必须获取所有 3 个文件。因此,出现了一种保留层分离但没有技术分离的想法。因此,单文件组件诞生了。

一般来说,我对“技术分离”持怀疑态度。源于它经常被用作放弃层分离论据——而这两个东西实际上是完全分离的。

Button作为单个文件的组件将如下所示:

<template>
  <!-- Button.html contents go here. -->
</template>

<style>
  /* Button.css contents go here. */
</style>

<script>
  // Button.js contents go here.
</script>

很明显,单文件组件只是具有内部样式和脚本 +<template>标签的Good Old HTML™ 。多亏了使用最简单方法的方法,您可以获得一个具有强大层分离(内容/模板:<template>、表示:<style>、行为:)的 Web 组件,<script>而无需为每个层创建单独的文件。

然而,最重要的问题仍然存在:我如何使用它?

基础概念

首先创建一个loadComponent()用于加载组件的全局函数。

window.loadComponent = ( function() {
  function loadComponent( URL ) {}

  return loadComponent;
}() );

我在这里使用了模块模式。它允许您定义所有必要的辅助函数,但仅loadComponent()向外部作用域公开该函数。目前,这个函数什么都不做。

这是一件好事,因为您还没有任何东西要加载。出于本文的目的,您可能想要创建一个<hello-world>将显示文本的组件:

你好世界!我的名字是<given name>

此外,单击后,组件应显示警报:

别碰我!

将组件的代码保存为HelloWorld.wc文件(.wc代表Web Component)。一开始它看起来像这样:

<template>
  <div class="hello">
    <p>Hello, world! My name is <slot></slot>.</p>
  </div>
</template>

<style>
  div {
    background: red;
    border-radius: 30px;
    padding: 20px;
    font-size: 20px;
    text-align: center;
    width: 300px;
    margin: 0 auto;
  }
</style>

<script></script>

目前,您尚未为其添加任何行为。您只定义了它的模板和样式。使用div没有任何限制的选择器和<slot>元素的外观表明该组件将使用Shadow DOM。这是真的:默认情况下所有样式和模板都将隐藏在阴影中。

网站上组件的使用应该尽可能简单:

<hello-world>Comandeer</hello-world>

<script src="loader.js"></script>
<script>
  loadComponent( 'HelloWorld.wc' );
</script>

您可以像使用标准自定义元素一样使用组件。唯一的区别是需要在使用前加载loadComponent()(即位于loader.js文件中)。这个函数完成了整个繁重的工作,比如获取组件并通过customElements.define().

这总结了所有基本概念。是时候弄脏了!

基本装载机

如果要从外部文件加载数据,则需要使用 immortal Ajax。但既然已经是 2020 年了,你可以以Fetch API的形式使用 Ajax :

function loadComponent( URL ) {
  return fetch( URL );
}

惊人!但是,目前您只获取文件,不使用它。获取其内容的最佳选择是将响应转换为文本:

function loadComponent( URL ) {
  return fetch( URL ).then( ( response ) => {
    return response.text();
  } );
}

由于loadComponent()现在返回的结果fetch()函数,它返回Promise。你可以利用这些知识来检查组件的内容是否真的被加载了以及是否被转换为文本:

loadComponent( 'HelloWorld.wc' ).then( ( component ) => {
  console.log( component );
} );

Chrome 的控制台显示 HelloWorld.wc 文件的提取已完成并将其内容显示为纯文本。

Chrome 的控制台显示 HelloWorld.wc 文件的提取已完成并将其内容显示为纯文本。

有用!

解析响应

但是,文本本身并不能满足您的需求。您在 HTML 中编写组件并不是为了执行被禁止的. 毕竟您是在浏览器中——创建 DOM 的环境。使用它的力量!

浏览器中有一个很好的DOMParser类,它允许您创建 DOM 解析器。实例化它以将组件转换为一些 DOM:

return fetch( URL ).then( ( response ) => {
  return response.text();
} ).then( ( html ) => {
  const parser = new DOMParser(); // 1

  return parser.parseFromString( html, 'text/html' ); // 2
} );

首先,创建解析器 (1) 的实例,然后解析组件 (2) 的文本内容。值得注意的是,您使用的是 HTML 模式 ( 'text/html')。如果您希望代码更好地符合 JSX 标准或原始 Vue.js 组件,您可以使用 XML 模式 ( 'text/xml')。但是,在这种情况下,您需要更改组件本身的结构(例如,添加将每隔一个的主元素)。

如果您现在检查loadComponent()返回的内容,您将看到它是一个完整的 DOM 树。

Chrome 的控制台显示解析为 DOM 的 HelloWorld.wc 文件的内容。

Chrome 的控制台显示解析为 DOM 的 HelloWorld.wc 文件的内容。

说“完整”是指真正完整。您已经获得了一个包含<head><body>元素的完整 HTML 文档。

如您所见,组件的内容位于<head>. 这是由 HTML 解析器的工作方式引起的。构建 DOM 树的算法在 HTML LS 规范中有详细描述。对于 TL;DR 它,您可以说解析器会将所有内容放入<head>元素中,直到它接近仅在<body>上下文中允许的元素为止。但是,您使用的所有元素 ( <template><style><script>) 也允许在<head>. 例如,如果您<p>在组件的开头添加了一个空标签,则其整个内容将呈现在<body>.

老实说,该组件被视为不正确的HTML 文档,因为它不以DOCTYPE声明开头。因此,它使用所谓的quirks mode 进行渲染。幸运的是,它不会为您带来任何改变,因为您仅使用 DOM 解析器将组件分割成适当的部分。

有了 DOM 树,你只能得到你需要的部分:

return fetch( URL ).then( ( response ) => {
  return response.text();
} ).then( ( html ) => {
  const parser = new DOMParser();
  const document = parser.parseFromString( html, 'text/html' );
  const head = document.head;
  const template = head.querySelector( 'template' );
  const style = head.querySelector( 'style' );
  const script = head.querySelector( 'script' );

  return {
    template,
    style,
    script
  };
} );

将整个获取和解析代码移动到第一个辅助函数中fetchAndParse()

window.loadComponent = ( function() {
  function fetchAndParse( URL ) {
    return fetch( URL ).then( ( response ) => {
      return response.text();
    } ).then( ( html ) => {
      const parser = new DOMParser();
      const document = parser.parseFromString( html, 'text/html' );
      const head = document.head;
      const template = head.querySelector( 'template' );
      const style = head.querySelector( 'style' );
      const script = head.querySelector( 'script' );

      return {
        template,
        style,
        script
      };
    } );
  }

  function loadComponent( URL ) {
    return fetchAndParse( URL );
  }

  return loadComponent;
}() );

Fetch API 不是获取外部文档的 DOM 树的唯一方法。XMLHttpRequest有一个专用document模式,允许您省略整个解析步骤。但是,有一个缺点:XMLHttpRequest没有Promise基于 - 的 API,您需要自己添加。

注册组件

由于您拥有所有需要的部件,因此创建registerComponent()将用于注册新自定义元素的函数:

window.loadComponent = ( function() {
  function fetchAndParse( URL ) {
    […]
  }

  function registerComponent() {

  }

  function loadComponent( URL ) {
    return fetchAndParse( URL ).then( registerComponent );
  }

  return loadComponent;
}() );

提醒一下:自定义元素必须是继承自HTMLElement. 此外,每个组件都将使用 Shadow DOM 来存储样式和模板内容。这意味着每个组件都将使用相同的类。立即创建:

function registerComponent( { template, style, script } ) {
  class UnityComponent extends HTMLElement {
    connectedCallback() {
      this._upcast();
    }

    _upcast() {
      const shadow = this.attachShadow( { mode: 'open' } );

      shadow.appendChild( style.cloneNode( true ) );
      shadow.appendChild( document.importNode( template.content, true ) );
    }
  }
}

您应该在内部创建它,registerComponent()因为该类将使用将传递给上述函数的信息。该类将使用稍微修改的机制来附加我在一篇关于声明性 Shadow DOM(波兰语)文章中描述的Shadow DOM

注册组件只剩下一件事了:给它一个名字并添加到当前页面的组件集合中:

function registerComponent( { template, style, script } ) {
  class UnityComponent extends HTMLElement {
    [...]
  }

  return customElements.define( 'hello-world', UnityComponent );
}

如果您现在尝试使用该组件,它应该可以工作:

Chrome 中显示的组件:带有圆角边框的红色矩形,带有“Hello, world!  我的名字是指挥官”里面的文字。

Chrome 中显示的组件:带有圆角边框的红色矩形,带有“Hello, world! 我的名字是指挥官”里面的文字。

获取脚本的内容

简单的部分就完成了。现在是时候做一些真正困难的事情了:添加行为层和……组件的动态名称。在上一步中,您硬编码了组件的名称,但是,它应该从单文件组件中传递。同样,您应该提供有关要绑定到自定义元素的事件侦听器的信息。使用基于 Vue.js 的约定:

<template>
  […]
</template>

<style>
  […]
</style>

<script>
  export default { // 1
    name: 'hello-world', // 2
    onClick() { // 3
      alert( `Don't touch me!` );
    }
  }
</script>

您可以假设<script>组件内部是一个模块,因此它可以导出某些内容 (1)。该导出是一个包含组件名称 (2) 和隐藏在名称以on...(3)开头的方法后面的事件侦听器的对象。

它看起来不错并且没有任何泄漏(因为模块不存在于全局范围内)。然而有一个问题:没有处理从内部模块导出的标准(因此那些代码直接在 HTML 文档中的模块)。该import语句假定它获取一个模块标识符。大多数情况下,它是包含代码的文件的 URL。在内部模块的情况下,没有这样的标识符。

但在你投降之前,你可以使用一个超级肮脏的黑客。至少有两种方法可以强制浏览器将给定的文本视为文件:数据 URI对象 URI

Stack Overflow 还建议使用 Service Worker。然而,在这种情况下,它看起来有点矫枉过正。

数据 URI 和对象 URI

数据 URI 是一种更古老、更原始的方法。它基于通过修剪不必要的空格将文件内容转换为 URL,然后(可选)使用 Base64 对所有内容进行编码。假设您有这样一个简单的 JavaScript 文件:

export default true;

它看起来像这样作为数据 URI:

data:application/javascript;base64,ZXhwb3J0IGRlZmF1bHQgdHJ1ZTs=

您可以像引用普通文件一样使用此 URL:

import test from 'data:application/javascript;base64,ZXhwb3J0IGRlZmF1bHQgdHJ1ZTs=';

console.log( test );

然而,Data URI 的最大缺点很快就会显现出来:随着 JavaScript 文件越来越大,URL 变得越来越长。以合理的方式将二进制数据放入数据 URI 中也非常困难。

这就是创建对象 URI 的原因。它是多个标准的后代,包括文件 API和带有其<video><audio>标签的HTML5 。Object URI 的目的很简单:从给定的二进制数据创建一个假文件,这将获得一个仅在当前页面上下文中工作的唯一 URL。简单来说:在内存中创建一个具有唯一名称的文件。通过这种方式,您可以获得数据 URI 的所有优点(一种创建新“文件”的简单方法)而没有其缺点(您的代码中不会出现 100 MB 的字符串)。

对象 URI 通常是从多媒体流(例如在<video><audio>上下文中)或通过input[type=file]拖放机制发送的文件创建的。您还可以使用FileBlob类手动创建此类文件。在这种情况下,使用Blob类,您将在其中放置模块的内容,然后将其转换为对象 URI:

const myJSFile = new Blob( [ 'export default true;' ], { type: 'application/javascript' } );
const myJSURL = URL.createObjectURL( myJSFile );

console.log( myJSURL ); // blob:https://blog.comandeer.pl/8e8fbd73-5505-470d-a797-dfb06ca71333

动态导入

但是,还有一个问题:import 语句不接受变量作为模块标识符。这意味着除了使用该方法将模块转换为“文件”之外,您将无法导入它。所以输了?

不完全是。很久以前就注意到了这个问题,并创建了动态导入提案。它是 ES2020 标准的一部分,并且已经在 Chrome、Firefox、Safari 和 Node.js 13.x 中实现。将变量作为模块标识符与动态导入一起使用不再是问题:

const myJSFile = new Blob( [ 'export default true;' ], { type: 'application/javascript' } );
const myJSURL = URL.createObjectURL( myJSFile );

import( myJSURL ).then( ( module ) => {
  console.log( module.default ); // true
} );

如您所见,import()它像函数一样使用并返回Promise,它获取表示模块的对象。它包含所有声明的导出,默认导出在默认键下。

执行

你已经知道你必须做什么,所以你只需要去做。添加下一个辅助函数getSettings(). 您将在之前触发它registerComponents()并从脚本中获取所有必要的信息:

function getSettings( { template, style, script } ) {
  return {
    template,
    style,
    script
  };
}

[...]

function loadComponent( URL ) {
  return fetchAndParse( URL ).then( getSettings ).then( registerComponent );
}

现在,这个函数只返回所有传递的参数。添加上面描述的整个逻辑。首先,将脚本转换为对象 URI:

const jsFile = new Blob( [ script.textContent ], { type: 'application/javascript' } );
const jsURL = URL.createObjectURL( jsFile );

接下来,通过导入加载它并返回从<script>以下位置接收的模板、样式和组件名称:

return import( jsURL ).then( ( module ) => {
  return {
    name: module.default.name,
    template,
    style
  }
} );

多亏了这一点,registerComponent()仍然得到 3 个参数,但script现在得到name. 更正代码:

function registerComponent( { template, style, name } ) {
  class UnityComponent extends HTMLElement {
    [...]
  }

  return customElements.define( name, UnityComponent );
}

这里!

行为层

组件还剩下一部分:行为,因此处理事件。目前,您只能在getSettings()函数中获得组件的名称,但您还应该获得事件侦听器。您可以使用该Object.entries()方法。返回getSettings()并添加适当的代码:

function getSettings( { template, style, script } ) {
  [...]

  function getListeners( settings ) { // 1
    const listeners = {};

    Object.entries( settings ).forEach( ( [ setting, value ] ) => { // 3
      if ( setting.startsWith( 'on' ) ) { // 4
        listeners[ setting[ 2 ].toLowerCase() + setting.substr( 3 ) ] = value; // 5
      }
    } );

    return listeners;
  }

  return import( jsURL ).then( ( module ) => {
    const listeners = getListeners( module.default ); // 2

    return {
      name: module.default.name,
      listeners, // 6
      template,
      style
    }
  } );
}

功能变得复杂。新的辅助函数getListeners()(1) 出现在其中。您将模块的导出传递给它 (2)。

然后使用Object.entries()(3)遍历此导出的所有属性。如果当前属性的名称以on...(4)开头,则将此属性的值添加到listeners对象中,在等于setting[ 2 ].toLowerCase() + setting.substr( 3 )(5)的键下。

关键是通过调整计算的on前缀和切换后的第一个字母,以一个小的(所以你会得到clickonClick)。您listeners进一步传递对象 (6)。

而不是[].forEach()你可以使用[].reduce(),这将消除listeners变量:

function getListeners( settings ) {
  return Object.entries( settings ).reduce( ( listeners, [ setting, value ] ) => {
    if ( setting.startsWith( 'on' ) ) {
      listeners[ setting[ 2 ].toLowerCase() + setting.substr( 3 ) ] = value;
    }

    return listeners;
  }, {} );
}

现在您可以在组件的类中绑定侦听器:

function registerComponent( { template, style, name, listeners } ) { // 1
  class UnityComponent extends HTMLElement {
    connectedCallback() {
      this._upcast();
      this._attachListeners(); // 2
    }

    [...]

    _attachListeners() {
      Object.entries( listeners ).forEach( ( [ event, listener ] ) => { // 3
        this.addEventListener( event, listener, false ); // 4
      } );
    }
  }

  return customElements.define( name, UnityComponent );
}

解构中有一个新参数listeners(1),类中有一个新方法_attachListeners()(2)。您可以Object.entries()再次使用- 这次遍历侦听器 (3) 并将它们绑定到元素 (4)。

在此之后,组件应该对点击做出反应:

单击组件后 Chrome 中显示的警报:“不要碰我!”

单击组件后 Chrome 中显示的警报:“不要碰我!”

这就是你如何实现单文件 Web 组件 🎉!

浏览器兼容性和其他总结

正如您所看到的,很多工作都用于创建对单文件 Web 组件的支持的基本形式。所描述系统的许多部分都是使用脏黑客(用于加载 ES 模块的对象 URI — FTW!)创建的,如果没有浏览器的本机支持,该技术本身似乎没有什么意义。但是,文章中的全部魔法在所有主要浏览器中都能正常运行:Chrome、Firefox 和 Safari!

尽管如此,创造这样的东西还是很有趣的。这是不同的东西,涉及浏览器开发和现代 Web 标准的许多领域。

当然,整个事情都可以在线获得