[译文]如何实现一个单文件组件

前端开发人员只要了解过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标准,也是一件很有趣的事情。

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

 

参考连接:https://ckeditor.com/blog/implementing-single-file-web-components/

作者:Tyler Ning
出处:http://www.cnblogs.com/tylerdonet/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,如有问题,请微信联系冬天里的一把火

posted @ 2020-05-29 14:52  nd  阅读(493)  评论(0编辑  收藏  举报