之前在学习影子DOM之后留下了一点点遗憾,CSP默认策略不允许影子根内的JS脚本执行。如果想利用影子DOM封装一个可重用的组件,这个限制必须突破。
其实W3C希望我们封装的组件在自定义标签内,它可以允许程序员定义的标签可以像原生的标签那样工作,就好像XSD
可以自定义各种XML
标签那样。
废话不多了,学起来。
例子
记得在学习《ES2015新特性》的时候发现,ECMA委员会除了带来了类的语法、还允许主要的内置类都可以用户脚本去继承。其实同一时间,W3C也允许了所有的HTML标签的类被用户脚本去继承。
回忆一下上一篇影子DOM的文章,我所希望得到的组件被封装在<div class="my-option" />
,而实际上有作用的标签是它内部的<label><input type="checkbox" /> ...</label>
,外部的那个div
容器其实是多余的。既然W3C允许继承各种HTML标签,那也就是说其实可以跳过div
容器直接继承label
标签自定义一个my-option
标签:
<script>
'use strict';
customElements.define('my-option-1', // 要使用window.customElements注册器才可以定义标签名
class extends HTMLLabelElement {
connectedCallback() { // 当自定义标签被插入到DOM树之后会调用这个回调
this.innerHTML = `<input type="checkbox"/> ${this.innerHTML}`;
}
}, { // 既然继承了HTMLLabelElement这里就要声明这个自定义标签只可以在
extends: 'label' // <label is="my-option-1" />标签中使用;
}); // 继承的类与扩展的标签名必须匹配、不得交叉使用
customElements.whenDefined('my-option-1').then(() => {
alert('my-option-1 is defined');
});
</script>
<style>
label[is="my-option-1"] {
font-weight: normal;
cursor: pointer;
display: block;
}
</style>
<div>
<label is="my-option-1">Option 1</label>
<label is="my-option-1">Option 2</label>
</div>
借助HTML的is="..."
语法的自定义标签有一个好处,就是能够优雅地降级——若浏览器并不支持自定义标签这个功能的时候它还可以退化为原本的标签,这也是Google的渐进式Web应用(PWA,Progressive Web App)提倡的做法。
但上面写法很罗嗦,我们可不可以让自定义标签表现得真的就像一个HTML标签那样、甚至是自定义样式的时候都只需要写上标签名?可以的:
<script>
'use strict';
customElements.define('my-option-2',
class extends HTMLElement { // 转为继承最原始的HTMLElement,那这样自定义的自由度就最高了
connectedCallback() {
this.innerHTML = `<input type="checkbox"/> ${this.innerHTML}`;
}
}); // 不在需要指定容器标签,这样就相当于直接使用自定义标签名了
customElements.whenDefined('my-option-2').then(() => {
alert('my-option-2 is defined');
});
</script>
<style>
my-option-2 {
font-weight: normal;
cursor: pointer;
display: block;
}
</style>
<div>
<my-option-2>Option 1</my-option-2>
<my-option-2>Option 2</my-option-2>
</div>
啊,写法是简约了,但是行为又不对了……我希望自定义标签内的复选框是可以像label
内的复选框那样可以与外层联动、而不是非要点中复选框的时候才算选中。甚至,我希望直接在自定义标签那一层就可以像复选框的API那样获取到选中状态。其实都能实现,写多“一点”代码就是了:
<script>
'use strict';
customElements.define('my-option-3',
class extends HTMLElement {
_checkbox;
constructor(...args) { // 构造函数形参必须与父标签类的一致
super(...args);
this.addEventListener('click', (event) => {
if (event.target.tagName === this.tagName) // 将本层的点击事件
this._checkbox.dispatchEvent(new MouseEvent(event.type, { // 转发到下层的复选框中
bubbles: event.bubbles,
cancelable: event.cancelable,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
shitfKey: event.shitfKey,
metaKey: event.metaKey,
button: event.button,
buttons: event.buttons,
relatedTarget: event.relatedTarget,
view: event.window
}));
});
// 不允许构造函数结束的时侯已经带有子标签!
}
connectedCallback() { // 要塞子标签只能够在回调的时候做
this.innerHTML = // 将选中状态复原到下层复选框
`<input type="checkbox" ${this.hasAttribute('checked') ? 'checked' : ''}/> ${this.innerHTML}`;
this._checkbox = this.querySelector('input[type="checkbox"]');
}
// 将checked属性代理到下层复选框
get checked() {
return this._checkbox.checked;
}
set checked(v) {
this._checkbox.checked = v;
if (v)
this.setAttribute('checked', '');
else
this.removeAttribute('checked');
}
});
customElements.whenDefined('my-option-3').then(() => {
alert('my-option-3 is defined');
});
</script>
<style>
my-option-3 {
font-weight: normal;
cursor: pointer;
display: block;
}
</style>
<div>
<my-option-3>Option 1</my-option-3>
<my-option-3 checked>Option 2</my-option-3>
</div>
<script defer>
'use strict';
document.querySelectorAll('my-option-3').forEach((myOpt) => {
myOpt.addEventListener('change', function (event) {
alert(this.innerText + ' checked: ' + this.checked);
});
});
</script>
可代码多了还真不是一丁半点……
而且这样写还有一个缺点,就是自定义标签内的子标签实际上还是会暴露在外部代码中,稍有不慎就会使得复选框的状态与自定义标签的状态不一致。这很不组件化,因为封装的程度不够高。这样就不得不考虑使用上一遍文章的影子DOM+模板技术来重新实现一遍:
<template id="my-option-4-template">
<style>
:host { display: block; }
:host label { cursor: pointer; }
</style>
<label><input type="checkbox" /> <slot name="text" /></label>
</template>
<script>
'use strict';
customElements.define('my-option-4',
class extends HTMLElement {
_checkbox;
constructor() {
super();
const myOptTpl = document.getElementById('my-option-4-template').content,
shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(myOptTpl.cloneNode(true));
this._checkbox = shadowRoot.querySelector(':host label input[type="checkbox"]');
this._checkbox.addEventListener('change', (event) => { // change事件由于是“composed: false”的,
this.dispatchEvent(new Event(event.type, { // 所以必须手动将它转发到外层
bubbles: event.bubbles,
cancelable: event.cancelable,
target: this
}));
});
}
connectedCallback() {
if (this.hasAttribute('checked'))
this._checkbox.setAttribute('checked', '');
}
get checked() {
return this._checkbox.checked;
}
set checked(v) {
this._checkbox.checked = v;
if (v)
this.setAttribute('checked', '');
else
this.removeAttribute('checked');
}
});
customElements.whenDefined('my-option-4').then(() => {
alert('my-option-4 is defined');
});
</script>
<div>
<my-option-4 checked><span slot="text">Option 1</span></my-option-4>
<my-option-4><span slot="text">Option 2</span></my-option-4>
</div>
<script defer>
'use strict';
document.querySelectorAll('my-option-4').forEach((myOpt) => {
myOpt.addEventListener('change', function (event) {
alert(this.innerText + ' checked: ' + this.checked);
});
});
</script>
这样做代码好像没少多少,但是实际上整理成为这种结构后,就可以将模板与脚本都单独打包在一个独立的HTML文档中,然后利用<link ref="import" href="..." />
导入这个HTML文档,便可以实现将一个自定义标签完全封装在一个独立的HTML文档中,这样十分便于维护:
<!-- ./my-option-5.html -->
<template id="my-option-5-template">
<style>
:host { display: block; }
:host label { cursor: pointer; }
</style>
<label><input type="checkbox" /> <slot name="text" /></label>
</template>
<script>
'use strict';
customElements.define('my-option-5',
class extends HTMLElement {
_checkbox;
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' }),
myOptTpl = document.currentScript.ownerDocument // 获取这个单独文档的document对象
.getElementById('my-option-5-template').content;
shadowRoot.appendChild(myOptTpl.cloneNode(true));
this._checkbox = shadowRoot.querySelector(':host label input[type="checkbox"]');
this._checkbox.addEventListener('change', (event) => {
this.dispatchEvent(new Event(event.type, {
bubbles: event.bubbles,
cancelable: event.cancelable,
target: this
}));
});
}
connectedCallback() {
if (this.hasAttribute('checked'))
this._checkbox.setAttribute('checked', '');
}
get checked() {
return this._checkbox.checked;
}
set checked(v) {
this._checkbox.checked = v;
if (v)
this.setAttribute('checked', '');
else
this.removeAttribute('checked');
}
});
customElements.whenDefined('my-option-5').then(() => {
alert('my-option-5 is defined');
});
</script>
<link rel="import" href="./my-option-5.html" />
<div>
<my-option-5 checked><span slot="text">Option 1</span></my-option-5>
<my-option-5><span slot="text">Option 2</span></my-option-5>
</div>
<script defer>
'use strict';
document.querySelectorAll('my-option-5').forEach((myOpt) => {
myOpt.addEventListener('change', function (event) {
alert(this.innerText + ' checked: ' + this.checked);
});
});
</script>
但是!但是!但是!<link ref="import" href="..." />
已经被标记为过时了!!!为什么啊啊!!!这是挺好的功能啊啊!!!这种静态链接应该不会带来XSS问题啊!!查阅一番之后,Chrome的解释是这个是他们的提案,但是公布出来之后一直只有Chrome实现了这个功能,其它厂商都没有跟进。所以谁说IT界就很单纯直接不爱搞政治,这是一幕典型的政治事件。
打开F12
就可以看到相关警告日志。日志里告诉我可以改用已经被绝大多数浏览器实现的ES的模块系统来实现同样的功能。其实使用模块系统来实现最大的好处就是不用担心自定义标签命名冲突,因为只要声明为模块之后,为了可重用就不会直接在模块里面声明自定义标签的名字,而是将这个责任转嫁到导入模块的一方:
<script type="module" src="./my-option-6.js">
// 这里把src的文件内容展示出来
'use strict';
const MY_OPTION_6_TEMPLATE = `
<style>
:host { display: block; }
:host label { cursor: pointer; }
</style>
<label><input type="checkbox" /> <slot name="text" /></label>
`;
export default class extends HTMLElement {
_checkbox;
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = MY_OPTION_6_TEMPLATE;
this._checkbox = shadowRoot.querySelector(':host label input[type="checkbox"]');
this._checkbox.addEventListener('change', (event) => {
this.dispatchEvent(new Event(event.type, {
bubbles: event.bubbles,
cancelable: event.cancelable,
target: this
}));
});
}
connectedCallback() {
if (this.hasAttribute('checked'))
this._checkbox.setAttribute('checked', '');
}
get checked() {
return this._checkbox.checked;
}
set checked(v) {
this._checkbox.checked = v;
if (v)
this.setAttribute('checked', '');
else
this.removeAttribute('checked');
}
}
</script>
<script type="module">
// 在要使用自定义标签的页面中
'use strict';
import HTMLMyOption6Element from './my-option-6.js';
customElements.define('my-option-6', HTMLMyOption6Element);
customElements.whenDefined('my-option-6').then(() => {
alert('my-option-6 is defined');
});
</script>
<div>
<my-option-6><span slot="text">Option 1</span></my-option-6>
<my-option-6 checked><span slot="text">Option 2</span></my-option-6>
</div>
<script defer>
'use strict';
document.querySelectorAll('my-option-6').forEach((myOpt) => {
myOpt.addEventListener('change', function (event) {
alert(this.innerText + ' checked: ' + this.checked);
});
});
</script>
毕竟HTML5的自定义标签组件不像XML
的XSD
那样可以使用名称空间来区隔。但这样写也不是没有坏处:模板被字符串化了、依然会有XSS的隐患,而且IDE的代码辅助与语法着色都变得不可用了,感觉还是不如<link ref="import" href="..." />
方便。
不过这个例子我发现,slot
机制其实与template
标签没什么关系,只要是追加在影子根的影子DOM都可以使用槽机制来回填数据。
感受
通过对Web组件规范的一系列学习之后终于明白,为什么总看到有人对W3C的DOM API与可重用组件的批评。性能低下、用法不稳定、回调生命周期晦涩、代码量大却有做不了什么事情,这些都时刻影响着Web前端程序员的工作乃至生活……
前端编程高可重用的趋势已经没有回头路,不管纯前端还是服务器端的方案都层出不穷,除了W3C官方规范进展缓慢得让人感到可怕之外,还有很多活跃的第三方社区、组织正在这个范畴努力着。
但是不使用官方规范又有什么好的技术选型?以前三分钟热度地学过一下Vue.js、感觉用它都比W3C的规范要方便得多~看来又是时候恶补一下了。
打赏作者