[譯文]如何實現一個單檔案元件

nd發表於2020-05-29

前端開發人員只要瞭解過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)是不是真的被載入,還有,是不是被轉成text了:

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路徑。所以內部的模組是沒有這樣的標識的。

在繳械投降之前,可以使用跟一個超級髒的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/

相關文章