ES6 Module + 自定义元素 初体验

可能是最全最新的 “初体验”。
欸,一位朋友和我说,作者啊,你写这么多,指不定也没人看,一点意思都没有,还不如省下这点时间坐下来 喝 杯 奶 茶 ,对吧。我气不过啊,发出来,就想看看有多少人,看了这篇文章,完事了还能给我点个 👍 ,气一气我这炫富的朋友。

新一代 ECMAScript 和 Web Components 标准已经发展得非常全面,现代浏览器支持也十分广泛。纯原生代码也可以写出 Vue 单文件组件的类似效果。

<game-character data-name="El Primo" data-image="//example.dev/el-primo.png"></game-character>
import GameCharacter from '//example.dev/xxx.js';
GameCharacter.register('game-character');

Module + Components Example - CodePen

元素,是组件的身躯

HTML 虽是构建网页的基础技术,但不可否认地,其原生标签数和可扩展性相当有限。开发者总总缺乏一种自动将 JS 与 HTML 相关联的方式。

这一切持续到自定义元素的出现。自定义元素MDN)是 HTML 现代化进程中的里程碑,它将指定 HTML 元素与 ES Class 相绑定,增强了 Web 开发结构与功能的联系,是现代仿生学思想(人们研究生物体的结构与功能工作的原理,创造先进技术) 在前端领域的重要突破,使得易于复用的 HTML 元素的创建和扩展格外简单。总总认同,通过原生自定义元素创建组件简单直接,符合直觉,代码量小

规范的命名

为了区别原生元素、帮助浏览器解析,HTML 规范明确而详细地制定了自定义元素的名称应遵循的规定

  • 自定义元素的名称必须包含连字符(-)。
  • 以字母开头。
  • 不包含大写字母。
  • 不能是以下中的一个:
    • <annotation-xml>
    • <color-profile>
    • <font-face>
    • <font-face-src>
    • <font-face-uri>
    • <font-face-format>
    • <font-face-name>
    • <missing-glyph>

规范以 <math-α><emotion-😍> 为例,只要满足上述要求,名称都可以使用

另外,一个符合命名要求的自定义元素,即使未被注册,也不会被视为 HTMLUnknownElement 的实例。

// 'game-character' 符合命名标准。
document.createElement('game-character') instanceof HTMLUnknownElement
// false

// 'character' 不符合命名标准。
document.createElement('character') instanceof HTMLUnknownElement
// true

简单清晰的关系定义

自定义元素的卖点,基本就在自定义类。以本例为例,所有的 <game-character> 都将会是 GameCharacter 的实例。

class GameCharacter extends HTMLElement {
  // constructor 不继承且为空、或继承但只有一个 super() 时,可以忽略。
  constructor() {
    super();
  }
}

自定义元素与类的关系,可以使用 customElements.defineMDN)搭建,这个 API 同时还会完成该元素在 DOM 中的注册

customElements.define('game-character', GameCharacter);

自定义元素完成了注册流程, new 操作符就掌握了创造该自定义元素实例的能力。

// 注册后,
const ele = document.createElement('game-character');
// 功能上等价于:
const ele = new GameCharacter();

可复用的结构

Shadow DOMMDN)是 HTML 和 CSS 特异性的原生解决方案,书写局部作用域的元素 id 和 CSS 样式由此摆脱了对第三方框架的依赖。它是 Web Components 的关键一环;与自定义元素和 ES6 Module 一起,组成了现代 Web 开发模块化的原生 “硬件” 基础。

创建 Shadow DOM 十分容易,在选定的元素上 attachShadowMDN)即可创造该元素的 “影子根”。该影子根同时也是此方法的返回值,可以通过元素的 shadowRootMDN)属性访问。每个元素只能有一个影子根,调用多次 attachShadow 会报一个 NotSupportedError 错误。影子根拥有正常元素的大多数 API 。

由文档 API 动态构建

class GameCharacter extends HTMLElement {
  imgEle;

  nameEle;

  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });

    // 构建元素结构
    const imgEle = document.createElement('img');
    imgEle.title = 'Avatar Picture Unset.';
    imgEle.alt = 'Unknown Person.';
    shadowRoot.appendChild(imgEle);
    this.imgEle = imgEle;

    const nameEle = document.createElement('span');
    nameEle.textContent = 'Unknown Person.';
    shadowRoot.appendChild(nameEle);
    this.nameEle = nameEle;
  }

  // 响应 HTML 属性变更
  attributeChangedCallback(name, oldValue, newValue) {
    switch (name) {
      case 'data-image':
        this.imgEle.src = newValue;
        break;
      case 'data-name':
        this.nameEle.textContent = newValue;
        this.imgEle.title = newValue;
        this.imgEle.alt = newValue;
        break;
      default:
        break;
    }
  }
}

简单的影子树结构可以直接使用示例的方法,逐个生成。

由模板字符串静态构建

复杂的影子树结构可以创建 <template>MDN)元素,通过接受换行的反引号模板字符串,储存作为模板。

模板
// 元素结构
const template = document.createElement('template');
template.innerHTML = `
  <img title="Avatar Picture Unset." alt="Unknown Person.">
  <span>Unknown Person.</span>
`;
模板结构的导入

模板元素 <template>contentMDN)属性返回模板元素子元素组成的 DocumentFragmentMDN)。

class GameCharacter extends HTMLElement {
  // ...

  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });

    // 构建元素结构
    const children = template.content.cloneNode(true);
    shadowRoot.appendChild(children);

    this.imgEle = shadowRoot.querySelector('img');
    this.nameEle = shadowRoot.querySelector('span');
  }

  // ...
}

储存于 <template> 元素中的内容会被浏览器解析,但不会被渲染。

与世隔绝的样式设计

Shadow DOM 提供的 CSS 作用域保证了影子树中的样式规则不会泄漏,外部样式也不会渗入,这带来了更简单高效的 CSS 选择器,更通用易读的 class 类名称,命名冲突之忧被打入冷宫。给予初涉模块化的开发者入身世外桃源般的豁然开朗,犹如懵懂少年与 Hello World 的悄然初见。项目越做越大,代码又臭又长,作者今日竟能由此返璞归真,不由得破涕为笑。

:hostMDN)选择器负责选择影子根的宿主。限定宿主需要满足的条件时,需要将相关选择器放到 :host()MDN选择器内部:host-content()MDN)选择器用于限定宿主的父元素需要满足的条件。

示例

在这个例子中,鼠标悬浮在自定义元素上时会改变边框颜色和图像大小、父元素包含 .black 类时自定义元素会更换为黑色背景:

:host {
  background: #fff;
  color: #333;
  border-width: 2px;
  border-style: solid;
  border-color: #2196f3;
  transition: border-color 240ms ease-in-out;
}

:host-content(.black) {
  background: #333;
  color: #fff;
}

:host(:hover) {
  border-color: #ffc107;
}

/* 无效规则 */
:host:hover {
  /* 随便什么都无效 */
}

img {
  transition: transform 240ms ease-in-out;
}

:host(:hover) img {
  transform: scale(1.1);
}

如何应用

样式设计可以直接写在 JS 文件中,就像由模板字符串静态构建元素 HTML 结构时做的一样,把 CSS 与 HTML <template> 放在一起。也可以选择独立成为单个文件,在脚本中记录或获取该文件的 URL,在自定义元素的样式类子元素中导入。

静态记录样式
/* 包含在 JS 文件中 */
const styleText = `
  :host {
    ...
  }
  ...
`;

class GameCharacter extends HTMLElement {
  //...

  constructor() {
    // ...

    const styleEle = document.createElement('style');
    styleEle.textContent = styleText;
    shadowRoot.appendChild(styleEle);

    // ...
  }

  // ...
}

下面的包含在 <template> 元素中的做法是较为推荐的,可以通过后文 “import.meta” 和 “模板字符串” 两节获得更多信息。

/* 包含在 JS 文件中 2,使用 <template> 元素 */
const template = document.createElement('template');
template.innerHTML = `
  <style>
  :host {
    /* ... */
  }
  /* ... */
  </style>
  <img title="Avatar Picture Unset." alt="Unknown Person.">
  <span>Unknown Person.</span>
`;

class GameCharacter extends HTMLElement {
  // ...

  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });

    // 构建元素结构
    const children = template.content.cloneNode(true);
    shadowRoot.appendChild(children);

    // ...
  }

  // ...
}
导入外部样式表
/* 外部样式载入 */
const styleURL = '//example.dev/index.css';

class GameCharacter extends HTMLElement {
  //...

  constructor() {
    // ...

    const styleEle = document.createElement('style');
    styleEle.textContent = `@import url(${styleURL})`;
    shadowRoot.appendChild(styleEle);

    // ...
  }

  // ...
}
/* 外部样式载入 2,使用 <link> 元素 */
  constructor() {
    // ...

    const styleEle = document.createElement('link');
    styleEle.setAttribute('rel', 'stylesheet');
    styleEle.setAttribute('href', styleURL);
    shadowRoot.appendChild(styleEle);

    // ...
  }

回调函数

自定义元素涉及的回调函数名、对应的解释补充点如下:

名称 响应
constructor 元素实例被创建或升级。
connectedCallback 元素被注入 DOM 中。
disconnectedCallback 元素被移出 DOM 。
attributeChangedCallback 元素指定的 HTML 属性被更改。
adoptedCallback 元素被移到新文档。

constructor

HTML Standard 对自定义元素所对应类的 constructor 函数的要求,如下:

  1. 每一个自定义元素对应类的 constructor 函数内部必须首先无参数调用一次 super
  2. 除了直接 returnreturn this ,不能使用 return
  3. 不能调用 document.writedocument.open 方法。
  4. 不能获取或变更任何子元素和任何 HTML 属性。
  5. 一般来说,尽可能把工作交给 connectedCallback(注入 DOM 回调)。
  6. 一般来说, constructor 函数的作用是设置默认值、事件侦听器和可能需要的 shadow root 。

connectedCallback & disconnectedCallback

虽然字面上,元素注入和移除回调的触发意味着元素在或不在 DOM 状态的切换,但值得注意的是,这两个回调都是不阻塞当前队列的同步函数。这意味着,如果你将一个自定义元素添加入 DOM 后又快速移除,那么在 connectedCallback 触发时该元素可能已经不在 DOM 里了

attributeChangedCallback

该回调会接受三个字符串形式的参数,第一个是被改变的 HTML 属性名,第二个是被改变前的旧值,第三个是改变后的新值。该回调只会侦听类的 observedAttributes 静态属性返回的数组中包含的 HTML 属性名。例如,

class GameCharacter extends HTMLElement {
  // ...
  static observedAttributes = ['data-image', 'data-name'];

  attributeChangedCallback(name) {
    // 永远为 true 。
    this.constructor.observedAttributes.includes(name);
  }
  // ...
}

目前,网上大多数教程,包括 HTML 规范、MDN 文档中的示例,都将 observedAttributes 用一个静态 getter 返回,

// ...
static get observedAttributes() {
  return ['data-image', 'data-name'];
}
// ...

这是 ES6 早期 class 内部不允许声明属性、只能包含函数时的做法,现在没有理由再这么做。

adoptedCallback

当元素被移入新 document 对象时调用。例如,存在一个自定义元素 ele ,调用 document.adoptNode(ele) 将触发 ele 元素的 adoptedCallback 回调。

注册系列回调

注册流程完成后,每个已经存在<game-character>触发一系列实例中的回调函数,该过程也称为 “升级(upgrade)”,按顺序主要包含:

  1. 跑一次 constructor
  2. 以元素每个存在的 HTML 属性为相关参数,各跑一次 attributeChangedCallback 属性变更回调。
  3. 若已经存在于 DOM ,跑一次 connectedCallback 注入 DOM 回调。

模块,是组件的灵魂

在 ES6 出现之前,服务器和浏览器端的模块化主要由第三方框架 CommonJS 和 AMD 实现。ES6 提供了更为简单直接的解决方案,闭合了原生 HTML、CSS、JS 模块化开发的最后一环

将自定义元素与 ES6 Module 结合,首先可以解决组件脚本分离问题,毕竟 ES6 Module 就是为了将模块代码独立成文件而设计的。在脚本分离后,🤔 如何利用 Module 的特性,解决组件相关的文件和变量依赖,是本节讨论的主题。

import.meta

ES6 中,被 import 导入的项目可以访问一个 import.metaMDN)对象,在当前 HTML 规范中其含且仅含一个 url 属性,以字符串的形式传递模块脚本文件自身的路径。可以简单封装这个路径,创造一个取得同模块下其它文件路径或内容的模块。

取得文件路径

/* get-sibling-url/main.js */
const getSiblingURL = (fileName, referencePath) => {
  const folderPath = referencePath.substr(0, referencePath.lastIndexOf('/'));
  return `${folderPath}/${fileName}`;
};

export default getSiblingURL;

取得文件内容(Promise)

/* get-sibling-file/main.js */
const getSiblingFile = (fileName, referencePath) => {
  const folderPath = referencePath.substr(0, referencePath.lastIndexOf('/'));
  const filePath = `${folderPath}/${fileName}`;
  return fetch(filePath);
};

export default getSiblingFile;

CSS 样式表要不要从模块 JS 中独立出来呢?

作者的建议是,不需要。现代代码编辑器已经可以做到多窗口编辑同一个文件的不同部分,而自定义元素模块的 HTML、CSS、JS 之间的联系十分紧密。这里摘录 Vue 文档 “单文件组件” 一章中的部分内容:

怎么看待关注点分离?
一个重要的事情值得注意,关注点分离不等于文件类型分离。在现代 UI 开发中,我们已经发现相比于把代码库分离成三个大的层次并将其相互交织起来,把它们划分为松散耦合的组件再将其组合起来更合理一些。在一个组件里,其模板、逻辑和样式是内部耦合的,并且把他们搭配在一起实际上使得组件更加内聚且更可维护。

以字符串形式在 JS 中保存 CSS 和 HTML 具有一定优化空间,建议阅读后文 “模板字符串” 一节。

示例

自定义元素 <hello-world> ,用于展示 settings.json 文件内 greeting 属性的字符,则模块配置参数 settings.json 、模块脚本 main.js 和所在的目录结构可以为:

模块配置参数
{
  "greeting": "Are you OK?"
}
模块脚本
/* hello-world/main.js */
import getSiblingFile from '../get-sibling-file';

class HelloWorld extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });

    const selfPath = import.meta.url;
    getSiblingFile('settings.json', selfPath)
    .then((r) => r.json())
    .then((settings) => {
      shadowRoot.textContent = settings.greeting;
    })
    .catch(console.log);
  }
}

export default HelloWorld;
目录结构
├── 📁src
│   ├── 📁modules
│   │   ├── 📁hello-world
│   │   │   ├── 📜settings.json
│   │   │   ├── 📑main.js
│   │   │   ├── 📰README.md
│   │   │   └── 📜package.json
│   │   └── 📁get-sibling-file
│   │       ├── 📑main.js
│   │       ├── 📰README.md
│   │       └── 📜package.json
│   ├── 📁images
│   │   ├── 🌴banner.png
│   │   └── 🌴icon.png
│   ├── 📁scripts
│   │   └── 📑index.js
│   └── 📄robots.txt
├── 📰CHANGELOG.md
├── ✍LICENSE
├── 📰README.md
├── 📜package.json

主脚本 index.js 将能够在 import 导入 HelloWorld 类之后,通过 customElements.define('hello-world', HelloWorld) 建立 <hello-world> 与该类的联系、完成元素在 DOM 中的注册。

注册回调 vs 注册函数

如果需要在注册时执行函数, <hello-world> 可以通过 customElements.whenDefinedMDN)侦听自己在 DOM 中的注册事件。该函数接受一个参数作为所侦听的目标元素名称,返回一个 Promise ,根据 whenDefined 被调用时目标元素名称的注册情况,立即或稍后在注册时以 undefined 为值进行 resolve 。但是,

  1. 在自定义元素模块内部静态储存自身元素名称没有任何意义,还降低了模块的灵活性、可能产生主脚本无法解决的命名冲突,不符合模块化的宗旨;
  2. 如果该自定义元素内部存在其它的自定义元素,依赖 whenDefined 会使自定义元素的注册从外元素到内元素进行,违背直觉,逻辑上更加导致性能损耗;
  3. customElements 系列函数无法传参,限制了自定义元素模块与主脚本的信息传递。

哎呀这可难死作者了。怎么办呢?想来想去终于发现通过创建模块内定义的具有一定规范的 “注册函数” 替换注册回调,以上所有问题就都有了解法:

  1. 主脚本可以向注册函数传参,可以控制该自定义元素及其子自定义元素的名称,防止命名冲突
  2. 包含其它自定义元素的自定义元素组件,可以在自身的注册函数被调用时首先调用子自定义元素的注册函数,创造从内元素到外元素的注册链。

示例

在下面的示例中,父自定义元素模块会接受指定的元素名称,在注册自身前先调用子自定义元素的注册函数

父自定义元素模块
/* modules/parent-ele/main.js */
import ChildEle from '../child-ele';

// 默认名称。
const elementNames = {
  ParentEle: 'parent-ele',
  ChildEle: 'child-ele',
};

class ParentEle {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });
    const child = new ChildEle();
    shadowRoot.appendChild(child);
  }

  static register(nameSettings = {}) {
    Object.assign(elementNames, nameSettings);
    // 先调用子自定义元素的注册函数,
    ChildEle.register(elementNames.ChildEle);
    // 再在 DOM 中注册自身。
    customElements.define(elementNames.ParentEle, ParentEle);
  }
}

export default ParentEle;
子自定义元素模块

子自定义元素模块接受指定的元素名称,以指定名称注册自身。

/* modules/child-ele/main.js */

// 默认名称。
const elementName = 'spider-man';

class ChildEle {
  static register(name) {
    if (name) elementName = name;
    customElements.define(elementName, this);
  }
}

export default ChildEle;
主脚本

主脚本实际控制着导入的自定义元素模块的各元素的名称。

/* scripts/main.js */
import ParentEle from '../modules/parent-ele';
ParentEle.register({
  ParentEle: 'ben-ele',
  ChildEle: 'peter-ele',
});

ParentEle 会以 <ben-ele> 身份, ChildEle 会以 <peter-ele> 身份完成 DOM 注册流程。

如果,

  • 主脚本无参数调用注册函数,ParentEle 将会以 <parent-ele> 身份、 ChildEle<child-ele> 身份完成 DOM 注册流程。
  • 主脚本无参数调用注册函数且导入的是 ChildEle ,则 ChildEle 将会以 <spider-man> 身份完成 DOM 注册流程。

可以同理嵌套多层自定义元素模块。

法术,这是法术

不看到这你就输了。

FOUC 全解

不论是将 CSS 样式包含在 JS 文件中,还是设定 <style>@import url()</style><link> 从外部载入样式,自定义元素都有可能出现 FOUC(Flash Of Unstyled Content,浏览器样式闪烁)现象,极大地影响用户体验。开发者似乎只能选择放弃 Shadow DOM、原生 CSS 作用域带来的巨大便利,或是期望用户接受 FOUC 造成的视觉侵犯。开发实践中的痛苦和悲伤,带走了前端码农对新特性的好奇与憧憬,反噬着 Web Components 的发展,自定义元素的前程在 FOUC 频频闪电的笼罩下,若昏黑黯淡,分崩离析。

针对自定义元素,FOUC 分为两种情况。

既有元素定义闪烁

回调函数” 一节中提到,DOM 中已有的自定义元素所关联的类,其相关函数会在调用 customElements.define 之后,触发。Web Components 标准在设计之初就没有准备像 ES Module 一样支持静态分析。自定义元素关联的类是动态选择的,因此浏览器不可能做到在解析 DOM 时就执行自定义元素对应的类的相关函数。

那么在 customElements.define 指定的类的 constructor 执行时生成 Shadow DOM,势必导致一次元素重绘。

针对这种元素闪烁,常见的解法是使用 :not(:defined) 选择器隐藏未定义的元素,比如将以下代码加到主文档样式中。

game-character:not(:defined) {
  visibility: hidden;
}

但对于主文档流中(positionstaticrelative)的自定义元素(MDN),往往还需要在 DOM 树加载之前设定好定义后的宽高,以免定义后影响主文档流中的其它元素,造成一定闪烁。然而,在 DOM 树加载时计算宽高不符合直觉,对于宽高不定的自定义元素几乎不现实。在 2019 年 9 月曾经有过一个提案,致力于解决这个 “不现实” 的问题,但后来因故废弃[注],目前还未有新提案被提出。

可行的办法之一,是使用过渡动画减轻视觉上的不适感。

/* 暂不需要,本例中默认宽高为 0 */
game-character:not(:defined) {
  /* visibility: hidden; */
}
class GameCharacter extends HTMLElement {
  //...

  connectedCallback() {
    // 默认宽高为 0 。
    Object.assign(this.style, {
      width: '0',
      height: '0',
      opacity: '0',
      overflow: 'hidden',
      transition: 'all 240ms ease-in-out',
    });
    // 过渡到内容宽高、重置透明度。
    Object.assign(this.style, {
      // 计算内容宽高时会导致元素重绘,因此此处不会造成前一处设置宽高为零的语句失效。
      width: `${this.scrollWidth}px`,
      height: `${this.scrollHeight}px`,
      opacity: '',
    });
    // 过渡后重置变更。
    this.addEventListener('transitionend', () => {
      Object.assign(this.style, {
        width: '',
        height: '',
        overflow: '',
        transition: '',
      });
    }, { once: true });
  }

  // ...
}

[注] 原提案参阅 Mitigating flash of unstyled content with custom elements
有关提案废弃原因请自行在 2020 Spring Virtual F2F · Issue #855 · w3c/webcomponents 中检索关键词 “FOUC”。

外部样式表载入闪烁

与存在于主文档流时的行为不同,样式类元素 <style><link> 在 Shadow DOM 中不会阻塞渲染,自定义元素即使在外联样式已被缓存的情况下,也可能会出现一次闪烁。动态添加自定义元素时,添加本身造成一次闪烁,外联样式载入又造成一次闪烁。

因此,即使从这个角度,在 Shadow DOM 中也不建议导入外部样式表。

如果一定要从外部导入,同时不产生额外的 FOUC 现象,那么可以先获取外部 CSS 文件,动态记录其文本内容为 JS 变量。在样式文本载入之前,避免元素显示、影响其它元素定位;在载入之后,将该变量作为内部样式应用到已有、后续创建的元素。

解决示例
import getSiblingFile from '../get-sibling-file';
const selfPath = import.meta.url;
const styleInfo = {
  // 外部样式文件名。
  fileName: 'main.css',

  // 初始样式, absolute 不影响主文档流元素定位, hidden 隐藏元素。
  // 不使用 display: none 以避免内部图片等资源不被加载。
  defaultText: `
    :host {
      position: absolute;
      visibility: hidden;
    }
  `,

  // 储存外部样式的变量。
  text: '',

  // 等待外部样式加载的实例。
  applyQueue: [],

  // 加载外部样式。
  load() {
    getSiblingFile(this.fileName, selfPath)
    .then((r) => r.text())
    .then((text) => {
      this.text = text;
      // 修改需要载入样式的实例。
      this.applyQueue.forEach((ele) => {
        const { styleEle } = ele;
        styleEle.textContent = text;
      });
    }).catch((error) => {
      throw error;
    });
  },
};

// 加载。
styleInfo.load();

class OutsideLover extends HTMLElement {
  styleEle = null;

  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });

    const styleEle = document.createElement('style');
    this.styleEle = styleEle;
    // 根据加载完成与否,
    if (!styleInfo.text) {
      // 使用默认样式,置入等待队列。
      styleEle.textContent = styleInfo.defaultText;
      styleInfo.applyQueue.push(this);
    } else {
      // 使用加载完成的样式。
      styleEle.textContent = styleInfo.text;
    }
    shadowRoot.appendChild(styleEle);

    // Something else.
  }
}

作者已经将其做成了一个简单的插件。在导入之后,将继承的 HTMLElement 改为 StylingAdvancedHTMLElement ,省去 attachShadow ,把要导入的外部样式文件名记为静态属性 styleFileUrl 即可。

示例中的代码就可以简化成这样:

import StylingAdvancedHTMLElement from '//raw.githubusercontent.com/PaperFlu/StylingAdvancedHTMLElement/master/export.js';
// 获得 Promise 改为获得 URL 。
import getSiblingURL from '../get-sibling-url';
const selfPath = import.meta.url;

class OutsideLover extends HTMLElement {
  styleFileUrl = getSiblingURL('main.css', selfPath);

  constructor() {
    super();
    // 不再调用 attachShadow ,直接引用 shadowRoot 。
    const { shadowRoot } = this;

    // Something else.
  }
}

模板字符串

Vue 设计者在文档中明确地指出使用字符串在 JS 中保存组件的 HTML 结构的缺点,也是字符串保存其它所有语言文本的共同缺点:一是失去了编辑器语法高亮,二是换行需要使用 \不同于 Vue 中的 “字符串模板”,ES6 的模板字符串MDN)接受换行,在大多数编辑器,以及 Github、NPM 等处的 Markdown 代码块内,可以通过添加标签得到语法高亮

// ES6 模板字符串可以通过这种方式在大多数编辑器中获得正确的语法高亮
// 并被 Prettier 一类的格式化工具正确处理。
const html = String.raw;
const someHTML = html`
  <img title="Avatar Picture Unset." alt="Unknown Person.">
  <span>Unknown Person.</span>
`;

在模板字符串前添加标签是存在于 ES6 标准中的合法操作,标签将被视为函数,以一定规则解析该模板字符串(MDN)。类似 Prettier 的格式化工具能够在标签的辅助下正确格式化模板字符串。

NPM 上有一个包,common-tags,设计了 ES6+ 常用的标签函数。安装后,可以这样:

import { html } from 'common-tags';
const template = document.createElement('template');
template.innerHTML = html`
  <style>
    :host {
      background: #fff;
      color: #333;
      border-width: 2px;
      border-style: solid;
      border-color: #2196f3;
      transition: border-color 240ms ease-in-out;
    }
  </style>
  <img title="Avatar Picture Unset." alt="Unknown Person.">
  <span>Unknown Person.</span>
`;

class GameCharacter extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });

    // 构建元素结构
    const children = template.content.cloneNode(true);
    shadowRoot.appendChild(children);

  }
}

common-tags 的设计不仅仅是为开启编辑器语法高亮,它还可以实现多余空格消除、换行调整、HTML 安全转义等。(官方文档

语法高亮示例

tagged-template-example

扩展原生元素

如果你不想设计一个全新的自定义元素,只是对现有元素不满意,怎么办?用于联系自定义元素名与类之间的关系的 customElements.define 还接受第三个参数,用于在需要时标记要扩展的原生元素名。对于这样的自定义元素,我们叫它:自定义原生元素

同时,自定义原生元素的对应类继承的可能就不是 HTMLElement 了,它需要继承被扩展的原生元素的 DOM 接口。比如我们需要扩展 <button> ,我们就需要继承 HTMLButtonElement ,如果我们需要扩展 <img> ,我们的类就需要继承 HTMLImageElement

到了这里,我的右手就不高兴了,它认为既然都继承了接口,注册的时候,直接忽略被扩展的原生元素的名字,不是很美观、很简洁吗?因此不愿意帮我继续码字。哎呀我好说歹说,它同意帮我去搜索这个问题,最后发现,不同的原生元素对应的 DOM 接口可能会相同。就像 <blockquote><q> ,接口都是 HTMLQuoteElement ,浏览器怎么知道你想扩展的是哪一个。HTML 规范中有具体的元素接口对应表

这下我的右手满意了,它同意帮我继续完成下面的文章,做一个能够轻松输出自己 DataURL<img>。这样我们在用纯文本传递图片的时候,就方便多了。

示例

在本地文件系统测试本例可能会出现 CORS 相关错误。(file://

// 定义一个扩展 <img> 的自定义元素
class TextImg extends HTMLImageElement {
  get dataURL() {
    const canvas = document.createElement('canvas');
    canvas.width = this.width;
    canvas.height = this.height;

    const ctx = canvas.getContext('2d');
    ctx.drawImage(this, 0, 0);

    return canvas.toDataURL('image/x-icon');
  }
}
customElements.define('text-img', TextImg, { extends: 'img' });

静态和动态创建

自定义原生元素的标签名不变,但存在一个 is 属性,指示其对应的自定义元素名,从而由此绑定对应的类。

在 HTML 中创建
<img is="text-img" src="example.png" alt="example">
在 JS 中创建
// 可以这样
const ele = document.createElement('img', { is: 'text-img' });
// 也可以这样
const ele = new TextImg();

ele.src = 'example.png';
ele.alt = 'example';

矛盾?错误?

可是看到这里,我的读者又不满意了,他们通过广泛的学习,发现我的扩展方法和谷歌 Web Fundamentals 上的 Custom Elements v1: Reusable Web Components 教程 不 一 样!教程里的示例二 明明写着:

Example - extending <img>:

>customElements.define('bigger-img', class extends Image {
  // Give img default size if users don't specify.
  constructor(width=50, height=50) {
    super(width * 10, height * 10);
  }
}, {extends: 'img'});

Users declare this component as:

<!-- This <img> is a bigger img. -->
<img is="bigger-img" width="15" height="20">

or create an instance in JavaScript:

const BiggerImage = customElements.get('bigger-img');
const image = new BiggerImage(15, 20); // pass constructor values like so.
console.assert(image.width === 150);
console.assert(image.height === 200);

继承的是 Image 呢!

所以也是没有办法,又仔细阅读了这篇文章。发现这篇文章前后似乎是存在矛盾的,示例之前明明也点明了 <img> 的扩展要继承 HTMLImageElement

To extend an element, you’ll need to create a class definition that inherits from the correct DOM interface. For example, a custom element that extends <button> needs to inherit from HTMLButtonElement instead of HTMLElement. Similarly, an element that extends <img> needs to extend HTMLImageElement.

我的右手因为之前的误解,决定要补偿我,使出了九牛二虎之力,搜到这个示例至少从 2016 年 12 月 9 日开始,就再没有更新过了,而那个时候自定义原生元素还没有被浏览器支持[注]。即使到了现在,自定义原生元素的需求还很小,因此右手怀疑文章年久失更,在 GitHub 上开了个 issue 提出这个问题,只是目前还没有人理。

[注] 参阅 javascript - How to extend default HTML elements as a “customized built-in element” using Web Components? - Stack Overflow 。讨论点不同,但引用了同一文段。

现在你们都满意了,那我们就继续吧。

注意点

  • 使用 JS 编程式创建的元素的 is 属性在序列化时显示,但不会在 DOM 中显示。
  • 所有原生 <img> 元素的特性都可用于自定义 <img> 元素。比如 src 显示图片, onload 事件等。
  • 自定义原生元素只能扩展规范中包含的 HTML 元素,元素接口为 HTMLUnknownElement 的旧有元素,如 <bgsound><blink><isindex><keygen><multicol><nextid><spacer> ,不能被扩展。

参阅

自定义原生元素在 HTML 规范中的示例:Creating a customized built-in element - HTML Standard

结语

真心感谢有人有耐心能看到最后,我太感动了 😍 !!点个👍、收个藏再走吧!

资料来源