作者:Tomasz Jakut
原文链接:Implementing single file Web Components
可能每个了解 Vue 框架的人都听说过它的“单文件组件(single file components)”。这个超简单的概念让 Web 开发人员可以只用一个文件来定一个组件。这个解决方案非常好用以至于一个在浏览器中包含这种机制的提案已经出现。
不过很不幸的是,这个提案貌似已经死掉了,从 2017 年 8 月以后就再没有任何进展。即使如此,在现有可行技术下研究这个话题并且创建一个可以在浏览器中运行的单文件组件仍然值得写一篇文章!
单文件组件
了解渐进增强这个术语的 Web 开发人员应该也听说过“分层”这个词儿。在组件中也是这样的。实际上层数还不少呢,每个组件至少有 3 层:内容/模版,样式和行为。按照传统的做法,每个组件至少会被分成 3 个文件,例如一个 Button 组件可能会是这个样子:
1 | Button/ |
这是按照技术来分层(内容/模版:HTML,样式:CSS,行为:JS)。这就是说--如果你没有用构建工具的话--浏览器将需要加载 3 个文件。于是有人便提出一个概念,保留分层的概念,但不是按照技术种类分成不同但文件。于是单文件组件诞生了。
一般来说,我对“按技术分层”这个做法持怀疑态度。而且,这个概念实际上经常在一些关于放弃分层的争论中被提到--根本就是完全对立的两件事。
单文件的 Button 组件看起来是这样的:
1 | <template> |
可以清楚的看到,单文件组件就是一个有内部样式表、script 和 标签的标准 HTML。得益于这种简洁的实现,你的组件不需要分成多个文件也有很明显的分层(内容/模版:
,样式:
,行为:
)。
不过仍然又一个最重要的问题要解决:如何使用它呢?
基础概念
我们先来编写一个叫做 loadComponent()
的 全局函数来加载我们的组件。
1 | window.loadComponent = ( function() { |
我用到了这些组件模式。你可以定义所有需要的辅助函数,只把 loadComponent
暴露给外部作用域。目前这个函数没有任何功能。
然而我们也没什么东西需要加载,所以这也不是什么问题。在这篇文章里我们假设你要创建一个
组件用于显示以下文字:
Hello, world! My name is <指定的名称>.
而且当你点击了这个组件,它会弹出一个消息:
Don’t touch me!
把组件代码保存为 HelloWorld.wc
(.wc
代表 Web Component)。现阶段它是这样的:
1 | <template> |
现在你还没有为它添加任何功能。只是定义了它的模版和样式。使用没有任何选择器的 div
以及
元素的出现意味着这个组件将使用 Shadow DOM。确实如此:所有的样式和模版默认都会被隐藏在暗处。
组件在网站上的使用方式应该尽可能的简单:
1 | <hello-world>Comandeerhello-world> |
组件的用法跟标准的 Custom Element 一样。唯一的区别是在使用 loadComponent()
方法之前需要先加载一个 js(这个方法位于 loader.js
文件中)。这个方法负责所有的繁重工作,像获取组件和在 customElements.define()
中注册组件。
以上就是所有的基础概念,接下来要开始动真格的了!
基础 loader
如果你想从外部文件中加载数据,你必须使用神圣的 Ajax。然而如今已经是 2018 年了,现在你可以以 Fetch API 的形式使用 Ajax:
1 | function loadComponent( URL ) { |
漂亮!不过现在你只是获取了文件,没有对它做任何操作。获取它的内容的最好方式就是把它转成文字:
1 | function loadComponent( URL ) { |
由于现在 loadComponent()
返回的 fetch()
函数的结果是一个 Promise
。你可以据此检查内容是否真正加载并检查它是不是被转成了文字:
1 | loadComponent( 'HelloWorld.wc' ).then( ( component ) => { |
Chrome console 显示 HelloWorld.wc 已经加载完成并转成了纯文本
运行成功!
解析返回值
不过,只是纯文本并不能满足我们的需求。你用 HTML 写这个组件不是为了做那些不允许做的事情。毕竟你是在浏览器里--一个创建 DOM 的环境。利用它的力量!
浏览器中又一个很棒的 DOMPaser
类让你可以创建 DOM 解析器。我们把它实例化用来把组件转换成 DOM:
1 | return fetch( URL ).then( ( response ) => { |
首先创建一个解析器实例(1),然后解析组件的文本内容(2)。值得注意你使用的是 HTML 模式('text/html'
)。如果希望代码更好的遵循 JSX 规范或原生 Vue 组件,你可以使用 XML 模式('text/xml'
)。不过这样一来你就要更改组件的结构了(例如:增加一个包含所有内容的主元素)。
现在如果你再查看 loadComponent()
的返回值,你将看到一个完整的 DOM 树。
Chrome console 显示 HelloWorld.wc 的内容已经被转成了 DOM
我说的“完整的”真的是非常完整。你得到了一个带有 和
元素的完整的 HTML 文档。你可以看到,组件的内容完全在
元素中。这是 HTML 解析器的工作方式导致的。建立 DOM 树的原理在 HTML LS 规范中有详细的描述。太长了就不详细说了,你可以简单的认为解析器会把所有元素都放在
元素中,直到它遇到了只能放在
中的元素。你用到的所有元素(
,
,
)都允许放在
中。假如你在组件的开头添加了一个空的
标签,那么全部内容都会被渲染在
中。
坦率的说,这个组件是被当作有缺陷的 HTML 文档来处理的,因为它没有以 DOCTYPE
声明开头。因此它是在一个被称为 quirks mode 的模式下渲染的。好在这对你没什么影响,因为你只是用 DOM 解析器剪切出所需的组件内容。
有了 DOM 树,你能获取你所需的那部分元素:
1 | return fetch( URL ).then( ( response ) => { |
把所有的获取和解析代码移到第一个辅助函数 fetchAndParse()
中:
1 | window.loadComponent = ( function() { |
Fetch API 并不是从外部文件中获取 DOM 树的唯一方法。XMLHttpRequest
有一个专门的文件模式 让你可以省掉整个的解析步骤。但是它有一个缺点:XMLHttpRequest
没有 Promise
型的 API,这需要你自行实现。
注册组件
既然已经拿到所有需要的部件了,现在来编写用于注册自定义组件的 registerComponent()
函数:
1 | window.loadComponent = ( function() { |
提醒一下:自定义元素必须是继承自 HTMLElement
的类。另外所有的组件都会用 Shadow DOM 来承载样式和模版内容。这也就是说所有的组件将使用相同的 class。创建:
1 | function registerComponent( { template, style, script } ) { |
你可以把它定义在 registerComponent()
内部,因为这个 class 会用到传给这个函数的一些数据。这个 class 会用一个跟这篇关于声明式 DOM 的文章(波兰语) 所介绍的稍微有所不同的机制来附加 Shadow DOM。
与注册组件相关的只剩下一件事--给他命名并添加到页面上的组件中。
1 | function registerComponent( { template, style, script } ) { |
现在测试这个组件,它看起来是这个样子的:
Chrome 中显示的组件:红色圆角方块,内部有 “Hello, world! My name is Comandeer”字样
获取脚本内容
简单的部分已经做完了。现在该做点高难度的:添加行为层并且……将组件名称动态化。上一步里你把组件名称硬编码在代码里了,不过,它应该是从组件上获取。同样的做法也会被用在为自定义组件绑定事件监听器上。使用基于 Vue 的约定:
1 | <template> |
假定组件中的 是一个模块,它可以导出内容(1),导出的是一个包含组件名称(2)和以
on...
开头的事件监听器方法(3)的对象。
看起来不错,也没有任何泄漏(因为在全局作用域中不存在模块)。然而还有一个问题:如何处理内部模块的导出并没有一个标准(那些直接写在 HTML 文档中的代码也是)。导入声明会假设它得到的是一个模块标识符。多数情况下是一个代码文件的 URL。内部模没有这种标识符。
别着急放弃,你可以用一个超级脏的 hack。至少有两种方法能强制浏览器将文本当作文件来处理:Data URI 和 Object URI。
Stack Overflow 建议使用 Service Worker。不过在我们这个案例中有点大材小用了。
Data URI 和 Object URI
Data URI 是一个古老而原始的方案。它是去用除文件内容中无用的空格并且如果指定会把所有内容转成 Base64 编码的方式将文件转成 URL。假设你有一个非常简单的 JavaScript 文件:
1 | export default true; |
它的 Data URI 形式是这样的:
1 | data:application/javascript;base64,ZXhwb3J0IGRlZmF1bHQgdHJ1ZTs= |
你可以像引用一个文件一样引用这个 URL:
1 | import test from 'data:application/javascript;base64,ZXhwb3J0IGRlZmF1bHQgdHJ1ZTs='; |
然而我们一眼就能看出 Data URI 方式的缺陷:JavaScript 文件越来越大,URL 也就变得越来越长。以简明的方式将二进制数据转成 Data URI 也很困难。因此人们创造了 Object URI。它是几个标准的产物,包括 File API 和包括 和
的 HTML 5.x。Object URI 的目标很简单:从二进制数据生成虚拟文件,返回一个只在当前页面有效的唯一 URL。简单的说:在内存中创建一个具有唯一名称的文件。这样你就利用了 Data URI 的所有优势(用简单的方式创建新“文件”)又避免了它的缺陷(你不用在代码中包含可能多达 100MB 的文字了)。
流媒体(例如: 和
)或者使用拖拽方式通过
input[type=file]
上传文件经常会创建 Object URI。不过你也可以用 File
和 Blob
类手动创建类似的文件。在这个案例中我们要把模块的内容储存在 Blog
类中并把它转换成 Object URI:
1 | const myJSFile = new Blob( [ 'export default true;' ], { type: 'application/javascript' } ); |
动态导入
不过还有一个问题:import 声明无法接收一个作为模块标识符的变量。这意味着你能把这个模块转换成“文件”却无法导入。到头来还是行不通?
不是的。很早以前人们就注意到了这个问题并提出了动态导入提案。写这篇文章的时候(2018 年 8 月)已经进行到标准化的第三阶段了,在浏览器和其他 JavaScript 环境中已经开始部署。使用变量作为模块标识符以及动态导入已经没问题了:
1 | const myJSFile = new Blob( [ 'export default true;' ], { type: 'application/javascript' } ); |
可以看到,import()
的用法跟函数一样并返回一个 Promise
,它是一个代表模块的对象。包含了所有声明过的导出项目,默认的导出项以 default
属性标识。
部署
既然你已经知道你要做什么,那么就动手吧。添加下一个辅助函数 getSettings()
。它会在 registerComponents()
之前被调用来从脚本中获取必须的信息:
1 | function getSettings( { template, style, script } ) { |
目前这个函数只是返回了所有传给它的参数。我们要把上面提到的整个的逻辑添加进去。首先,把脚本转成 Object URI:
1 | const jsFile = new Blob( [ script.textContent ], { type: 'application/javascript' } ); |
然后,通过 import 加载它并返回 template,styles 和从 中提取的组件名称。
1 | return import( jsURL ).then( ( module ) => { |
借助于此,registerComponent()
接收的 3 个参数里 变成了组件的名称。修改代码:
1 | function registerComponent( { template, style, name } ) { |
\( `∀´)/ Voilà!
行为层
组件还有一部分没完成:行为,即处理事件。目前你只能从 getSettings()
函数获取组件的名字,你还需要获取事件监听器。Object.entries()
可以用来实现这个目的。我们回到 getSettings()
给它添加适当代码:
1 | function getSettings( { template, style, script } ) { |
函数变得复杂了。出现了新的辅助函数 getListeners()
(1)。模块的 export 被传递给它(2)。然后使用 Object.entries()
遍历了这个 export 的所有属性(3)。如果当前属性名以 on...
开头(4),就把这个属性的值赋给 listeners
对象,并把 setting[2].toLowerCase() + setting.substr(3)
作为它的属性名(5)。这个属性名是通过去掉 on
前缀后把首字母转换成小写(这样 onClick
会被转换成 click
)得到的。然后把 listeners
对象传出去(6)。
可以用 [].reduce()
代替 [].forEach()
来处理 listeners
变量:
1 | function getListeners( settings ) { |
现在你可以在组件的 class 中绑定这些监听器了:
1 | function registerComponent( { template, style, name, listeners } ) { // 1 |
重构过程中我们增加了一个新的参数 listeners
(1),以及 class 中的一个新的方法 _attachListeners
(2)。我们又一次用到了 Object.entries()
--这次是用来遍历 listeners
(3) 并把它们绑定到这个组件元素上 (4)。
做完这些以后我们的组件应该会响应点击事件了:
点击了这个组件以后 Chrome 弹框显示:“Don’t touch me!”
以上就是如何实现一个单文件 Web Components 🎉!
浏览器兼容性以及其他
你可以看出,即使是为了支持一个最简单形式的单文件组件,也需要做非常多的工作。我们所展现的这个系统中的许多部分都是用脏 hack 实现的(用 Object URI 来加载 ES 模块?--什么鬼!)而且如果没有浏览器的原生支持,这项技术也没什么意义。更重要的是,在我写这篇文章的时候(2018 年 8 月)Firefox 并不支持 Custom Elements 和 dynamic import。坦白说这东西现在只在 Chrome 里好使。因此--目前来说--它只是为了满足一下好奇心,没什么实际用处。
不过实现一个这样的东西还是很好玩的。它是一个涉及了许多浏览器开发和现代 web 标准相关内容的与众不同的小玩意儿。我希望至少能有一个人看完了这篇文章!
当然了整个作品可以在网上预览。