前端开发人员只要了解过vue.js框架可能都知道单文件组件。vue.js中的单文件组件允许在一个文件中定义一个组件的所有内容。这是一个非常有用的解决方案,在浏览器网页中已经开始提倡这种机制。但是不幸的是,这个概念自从2017年8月被提出以来,到现在没有任何进展,像是已经要消亡了一样。然而,深入研究这个主题并试着使用现有的技术来实现单文件组件是很有趣的,值得尝试。

单文件组件

知道“渐进增强”这个概念的前端开发人员想必也听说过“分层”这个概念。在组件中,同样有这样的概念。事实上,每个组件至少有3层,甚至多余3层:内容/模板,表现和行为。又或者保守的说,每个组件会被分成至少3个文件,比如:一个按钮组件的文件结构可能是下面这样的:

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

采用这种方式分层相当于技术的分离(内容/模板:使用html,表现:使用css,行为:使用JavaScript)。如果没有采用任何构建工具打包,这意味着浏览器需要获取这3个文件。因此,一个想法是:迫切需要一种分离组件代码而不分离技术(文件)的技术来解决这个问题。这就是这篇文章要讨论的主题—单文件组件。

总的来说,我对“技术分层”持怀疑态度。它来自一个事实,就是组件分层常常因为绕不开“技术分层”而被放弃,而这两者是完全分离的。

回到主题,用单文件组件实现按钮可能是这样的:

<template>
<!-- Button.html contents go here. -->
</template> <style>
/* Button.css contents go here. */
</style> <script>
// Button.js contents go here.
</script>

可以看到这个单文件组件很像最初前端开发中的html文档,它有自己的style标签和script标签,只是表现层使用一个template标签。由于使用了简单的方式,得到一个强大的分层组件(内容/模板:<template>,表现:<style>,行为:<script>),而不需要使用3个分离的文件。

基本概念

首先,我们创建一个全局函数loadComponent()来加载组件。

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

这里使用了JavaScript模块模式。它允许定义所有必要的辅助函数,但是只向外公开loadComponent()函数。当然,现在这个函数还是空的。

后面,我们要创建一个<hello-world>组件,显式下面的内容。

Hello, world! My name is <given name>.

另外,点击这个组件,弹出一个信息:

Don’t touch me!

组件代码保存为一个文件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>

目前,没有给组件添加任何行为,只是定义了模板和样式。模板中,可以使用常见的html标签,比如<div>,另外,template中出现了<slot>元素表明组件将实现影子DOM。并且默认情况下这个DOM自身所有样式和模板都隐藏在这个DOM中。

组件在网页中使用的方式非常简单。

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

可以像标准的自定义元素一样使用组件。唯一的区别是需要在使用loadComponent()方法前先加载它(这个方法放在loader.js中)。loadComponent()方法完成所有繁重的工作,比如获取组件,并通过customElements.define()注册它。

在了解了所有概念之后,是时候动手实践了。

简单的loader

如果想从外部文件中加载文件,需要使用万能的ajax。但是现在已经是2020年了,在大部分浏览器中,你可以大胆的使用Fetch API

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

但是,这只是获取到文件,没有对文件做任何处理,接下来要做的是把ajax返回内容转换成text文本,如下:

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

由于loadComponent()函数返回的是fetch函数的执行结果,所以它是一个Promise对象。可以在then方法中检查文件(HelloWorld.wc)是不是真的被加载,还有,是不是被转成文本了:

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

运行结果如下:

chrome浏览器下,使用console()方法,我们看到HelloWorld.wc的内容被转成了text并输出,所以貌似行得通!

解析组件内容

然而,仅仅把文本输出并没有达到我们的目的。最终要把它转换成DOM用于显示,并能和用户真正交互起来。

在浏览器环境中有一个非常实用的类DOMParser,可以实用它创建一个DOM解析器。实例化一个DOMParser类得到一个对象,实用这个对象可以将组件文本转换成DOM:

window.loadComponent = (function () {
function loadComponent(URL) {
return fetch(URL).then((response) => {
return response.text();
}).then((html) => {
const parser = new DOMParser(); // 1
return parser.parseFromString(html, 'text/html'); // 2
});
}
return loadComponent;
}());

首先,创建一个DOMParser实例parser(1),然后用这个实例将组件内容转化成DOM(2)。值得注意的是,这里实用的是HTML模式(‘text/html’)。如果希望代码更好的符合JSX标准或者原始的Vue.js组件,可以实用XML模式(‘text/XML’)。但是,在这种情况下,需要更改组件本身的结构(例如,添加可以容纳其他元素的主元素)。

这是再输出loadComponent()函数的返回结果,就是一个DOM树了。

在chrome浏览器下,console.log()输出解析后的HelloWorld.wc文件,是一个DOM树。

注意,parser.parseFromString方法自动给组件添加上<html>,<head>,<body>标签元素。这是由HTML解析器的工作原理造成的。在HTML LS规范中详细描述了构建DOM树的算法。这篇文章很长,要花点时间阅读,可以简单地理解为解析器会默认把所有内容放在<head>元素中,直至遇到一个只能放在<body>标签内的DOM元素。所以,组件代码中所有的元素(<element>,<style>,<script>)都允许放在<head>中。如果在<template>外层包一个<p>元素,那么解析器将把它放在<body>中。

还有一个问题,组件被解析之后并没有<!DOCTYPE html>声明,所以这得到的是一个非正常的html文档,因此浏览器会使用一种被成为怪异模式的方式来渲染这种html文档。所幸的是,在这里它不会带来任何负面作用,因为这里只使用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
};
}); 

最后整理一下代码,loadComponent方法如下。

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这一种,XMLHttpRequest有一个专用的文档模式,允许您省略整个解析步骤。但是XMLHttpRequest返回的不是一个Promise,这个需要自己包装。

注册组件

现在有了组件的层,可以创建registerComponent()方法来注册新的自定义组件了。

window.loadComponent = (function () {
function fetchAndParse(URL) {
[…]
}
function registerComponent() {
}
function loadComponent(URL) {
return fetchAndParse(URL).then(registerComponent);
}
return loadComponent;
}()); 

要注意的是,自定义组件必须是一个继承自HTMLElement的类。此外,每个组件都将使用用于存储样式和模板内容的影子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()方法内创建UnityComponent类,因为这个类要使用传入registerComponent()的参数。这个类会使用一种稍加修改的机制来实现影子DOM,在这篇关于影子DOM的文章(波兰文)中我有详细介绍。

关于注册组件,现在只剩下一件事,给单文件组件一个名字,并把它加到当前页面的DOM中。

function registerComponent( { template, style, script } ) {
class UnityComponent extends HTMLElement {
[...]
} return customElements.define( 'hello-world', UnityComponent );

现在可以打开看一下了,如下:

在chrome中,这个按钮组件中,有一个红色矩形,文字内容:Hello, world! My name is Comandeer。

获取脚本内容

现在一个简单的按钮组件已经实现。现在要实现最困难的部分,添加行为层,并自定义按钮内内容。在上面的步骤中,我们应该使用到按钮的地方传入内容,而不是在组件代码中硬编码按钮内的文字内容。同理还要处理组件内绑定的事件监听,这里我们使用类似Vue.js的约定,如下:

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

可以假设组件内<script>标签中的内容是一个JavaScript模块,它导出内容(1)。模块导出的对象包含组件的名称(2)和一个已“on..”开头的事件监听方法(3)。

这看上去很工整,没有内容暴露在模块外部(因为JavaScript中modules并不是在全局作用域中)。这里有一个问题:没有一个标准可以处理从内部模块导出的对象(这些代码直接定义在HTML文档中)。import语句会假设获取到一个模块标识,根据这个标识导入。最常见的是从一个包含代码的文件的URL路径。组件不是一个js文件,没有这样一个标识,内部的模块是没有这样的标识的。

在缴械投降之前,可以使用跟一个超级脏的hack。最少有2中方式让浏览器像处理一个文件一样处理一段文本:Data URIObject URI。也有一些建议是使用Service Worker。但是在这里显得有点大材小用。

Data URI和Object URI

Data URI是一个古老,原始的方法。它的基础是将文件内容转换成URL,去掉不必要的空格,然后使用Base64对所有内容进行编码。假设有一个JavaScript文件,内容如下:

export default true; 

转换成Data URI如下:

data:application/javascript;base64,ZXhwb3J0IGRlZmF1bHQgdHJ1ZTs= 

然后,可以像引入一个文件一样引入这个URI:

import test from 'data:application/javascript;base64,ZXhwb3J0IGRlZmF1bHQgdHJ1ZTs=';
console.log( test ); 

Data URI这种方式的一个明显的缺点是随着JavaScript文件内容增多,这个URL的长度会随之变得很长。还有把二进制数据放在Data URI非常困难。

所以,现在有一种新的Object URI。它是从几种标准中衍生出来的,包括File API和HTML5中的<video>和<audio>标签。Object URI的目的很简单,从给定的二进制数据创建一个“伪文件”,在当前上下文中给出一个唯一URI。简单点说,就是在内存中创建一个有唯一名称的文件。Object URI有Data URI所有的优点(一种创建"文件"的方法),而没它的缺点(即使文件有100M也没关系)。

对象uri通常是从多媒体流(例如在<video>或<audio>上下文中)或通过输入[type=file]和拖放机制发送的文件创建的。还可以使用FileBlob这两个类手动创建。在本例中,我们使用Bolb,先把内容放在模块中,然后转换成Object 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标准的一部分,并且在Firefox,Safari和Node.js13.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对象,then方法中得到模块对象。在它的default属性中包含了模块中定义的所有导出对象。

实现

现在我们已经知道思路了,现在可以着手实现它。在添加一个工具方法,getSetting()。在registerComponents()方法之前调用它,进而从脚本代码中获取所有信息。

function getSettings( { template, style, script } ) {
return {
template,
style,
script
};
}
[...]
function loadComponent( URL ) {
return fetchAndParse( URL ).then( getSettings ).then( registerComponent );

现在,这个方法返回了所有传入的参数。按照上面介绍的逻辑,将脚本代码转换成Object URI:

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

下一步,使用import加载模块,返回模板,样式和组件的名称:

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

由于这个原因,registerComponent()仍然获得3个参数,但是现在它获取的是name,而不是脚本。正确的代码如下:

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

行为层

组件还剩下最后一层:行为层,用来处理事件。现在我们只是在getSettings()方法中获取到了组件的名字,还要获取事件监听。可以使用Object.entrie()方法获取。 在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) ,将模块的输出传入这个参数中。

然后使用Object.entries()(3)方法遍历导出的模块。如果当前属性以“on”(4)开头,说明是一个监听函数,将这个节点的值(监听函数)添加到listeners对象中去,使用setting[2].toLowerCase()+setting.substr(3)(5)得到键值。

键值是通过去掉开头的“on”,并将后面的“Click”首字母转换成小写组成的(就是从onClick得到click作为建值)。然后传入isteners对象(6)。

可以使用[].reduce()方法代替[].forEach()方法,这样可以省略掉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)上增加了一个参数,并且在class中添加了一个新方法_attachListeners()(2)。在这里可以再次使用Object.entries()来遍历listeners(3),并把他们绑定到element(4)。

最后,点击组件可以弹出“Don't touch me!”,如下:

兼容性问题及其他

可以看到,为了实现这个单文件组件,大部分工作围绕如何支持基本的Form。很多部分使用了脏hacks(使用Object URI来加载ES中的模块,没有浏览器的支持,这种技术没有什么意义)。还好,所有的技术在主流浏览器下运行正常,包括:Chrome,Firefox和Safari。

尽管如此,创建一个这样的项目会接触到很多浏览器技术和最新的web标准,也是一件很有趣的事情。

最后,可以在网上获取这个项目的到代码

vue中 如何实现一个单文件组件的更多相关文章

  1. vue生命周期及使用 && 单文件组件下的生命周期

    生命周期钩子 这篇文章主要记录与生命周期相关的问题. 之前,我们讲到过生命周期,如下所示: 根据图示我们很容易理解vue的生命周期: js执行到new Vue() 后,即进入vue的beforeCre ...

  2. Vue知识整理16:单文件组件

    过程较为复杂,这里直接写出视频地址,可以直接查看 https://learning.dcloud.io/#/?vid=14

  3. webpack打包vue单文件组件

    一.vue单文件组件 ①文件扩展名为 .vue 的 就是single-file components(单文件组件) ②参考文档:单文件组件 二.webpack加载第三方包 ①项目中,如果需要用到一些第 ...

  4. Vue -3:单文件组件

    在很多 Vue 项目中,我们使用 Vue.component 来定义全局组件,紧接着用 new Vue({ el: '#container '}) 在每个页面内指定一个容器元素. 这种方式在很多中小规 ...

  5. Vue.js的组件(slot/动态组件等)、单文件组件、递归组件使用

    一.组件 1> 组件命名方式有两种(注意:在DOM模板中只有kebab-case命名方法才生效): html中引用组件: <!-- 在DOM模板中,只有 kebab-case命名才生效 - ...

  6. vue单文件组件形成父子(子父)组件之间通信(vue父组件传递数据给子组件,子组件传递数据给父组件)

    看了很多文章,官网文档也有看,对父子组件通信说的不是很明白:决定自己总结一下: vue一般都使用构建工具构建项目:这样每个组件都是单文件组件:而网上很多文章都是script标签方式映入vue,组件通信 ...

  7. Vue 非单文件组件(不常用)3步骤(创建、注册、使用)和几个注意点、组件的本质(VueComponent)

    Vue中使用组件的三大步骤: 1.定义组件(创建) 2.注册组件 3.使用组件(写组件标签) 一.如何定义一个组件? 使用Vue.extend(options)创建,其中options 和 new V ...

  8. vue中的单文件组件

    之前都是在html文件中写组件的css,组件的js,组件的模板来演示vue组件的语法,下面介绍以.vue结尾的单文件组件.vue-loader是一个Webpack的loader,可以将单文件组件转换为 ...

  9. vue中创建全局单文件组件/命令

    1.在 vue中如果我们使用基于vue.js编写的插件,我们可以使用Vue.use() 如在main.js中: 2.添加全局命令,让每个vue单文件组件都可以使用到: 第一步:最好建一个全局的命令文件 ...

  10. vue 单文件组件中样式加载

    在写单文件组件时,一般都是把标签.脚本.样式写到一起,这样写个人感觉有点不够简洁,所以就想着把样式分离出去. 采用import加载样式 在局部作用域(scoped)采用@import加载进来的样式文件 ...

随机推荐

  1. 树形dp套路

    我们知道dp也就是动态规划的思想就是先解决小问题,通过不断的解决小问题,最终解决大问题.那么能够应用树形dp套路的题目都应该符合一个条件,那就是通过解决每个子树的小问题,最终解决整棵树的大问题. 套路 ...

  2. ansible 自动化运维(1)

      ansible 简介 ansible 是什么? ansible是新出现的自动化运维工具,基于Python开发,集合了众多运维工具(puppet.chef.func.fabric)的优点,实现了批量 ...

  3. Java递归实现全排列改进(一)---利用HashSet实现去重

    import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.Iter ...

  4. 记录--用 Vue 实现原神官网的角色切换效果

    这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助 前言 为了更好的了解原神角色,我模仿官网做了一个角色切换效果,在做的过程当中也总结了一些技术点. 为了让大家更好的体验,我兼容了 PC 端 ...

  5. kali 2018.2镜像安装

    本文链接来源 Kali Linux 前身是著名渗透测试系统BackTrack ,是一个基于 Debian 的 Linux 发行版,包含很多安全和取证方面的相关工具.此次通过VMware虚拟机安装201 ...

  6. FFmpeg开发笔记(六)如何访问Github下载FFmpeg源码

    ​学习FFmpeg的时候,经常要到GitHub下载各种开源代码,比如FFmpeg的源码页面位于https://github.com/FFmpeg/FFmpeg.然而国内访问GitHub很不稳定,经常打 ...

  7. Scala 函数闭包和柯里化

    1 package com.atguigu.function 2 3 object HighFunction { 4 def main(args: Array[String]): Unit = { 5 ...

  8. defer 延迟调用【GO 基础】

    〇.前言 在 Go 语言中,defer 是一种用于延迟调用的关键字. defer 在 Go 语言中的地位非常重要,它是确保资源正确释放和程序健壮性的关键字. 本文将通过示例对其进行专门的详解. 一.d ...

  9. #团,构造#洛谷 3524 [POI2011]IMP-Party

    题目 有一个 \(3n\) 个点的无向图,保证有一个大小为 \(2n\) 的团,输出一个大小为 \(n\) 的团 分析 每次选择两个不相连的点删掉,那么剩下的 \(n\) 个点一定是团, 因为每次至少 ...

  10. #KMP,dp#洛谷 3426 [POI2005]SZA-Template

    题目 给定一个字符串\(S\),字符串可以理解成一条每个字母代表一种颜色的线段, 找到一个长度最小的串\(T\),使得在若干位置放置\(T\)后使得字符串被完全覆盖 分析 显然它要么取\(i\),要么 ...